feat(simple): auto-create dedicated execution profile
This commit is contained in:
parent
b33afc6c8c
commit
0bd46ab43b
@ -98,7 +98,7 @@ export const config = {
|
||||
COOLDOWN_MS: parseInt(process.env.COOLDOWN_MS || '3600000', 10), // Default 1 hour
|
||||
PROFIT_EXIT_PERCENT: parseFloat(process.env.PROFIT_EXIT_PERCENT || '1.0'), // Default 1%
|
||||
TRAILING_STOP_PERCENT: parseFloat(process.env.TRAILING_STOP_PERCENT || '0.001'), // Default 0.1%
|
||||
PROFILE_SYNC_INTERVAL_MS: parseInt(process.env.PROFILE_SYNC_INTERVAL_MS || '60000', 10), // Default 1 min
|
||||
PROFILE_SYNC_INTERVAL_MS: parseInt(process.env.PROFILE_SYNC_INTERVAL_MS || '5000', 10), // Default 5 sec
|
||||
MONITOR_INTERVAL_MS: parseInt(process.env.MONITOR_INTERVAL_MS || '60000', 10), // Default 1 min
|
||||
ORDER_SYNC_INTERVAL_MS: parseInt(process.env.ORDER_SYNC_INTERVAL_MS || '60000', 10), // Default 1 min
|
||||
STALE_ORDER_THRESHOLD_MINUTES: parseInt(process.env.STALE_ORDER_THRESHOLD_MINUTES || '2', 10), // Default 2 min
|
||||
|
||||
@ -1,5 +1,10 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildSimpleTradePayload, computeProtectionPrices } from './SimpleView';
|
||||
import {
|
||||
buildSimpleAutoProfilePayload,
|
||||
buildSimpleTradePayload,
|
||||
computeProtectionPrices,
|
||||
SIMPLE_AUTO_PROFILE_NAME,
|
||||
} from './SimpleView';
|
||||
|
||||
describe('SimpleView helpers', () => {
|
||||
it('computes buy-side protection prices from reference price', () => {
|
||||
@ -70,4 +75,22 @@ describe('SimpleView helpers', () => {
|
||||
tp: 60160,
|
||||
});
|
||||
});
|
||||
|
||||
it('builds the dedicated simple auto profile payload', () => {
|
||||
expect(buildSimpleAutoProfilePayload('msft')).toEqual({
|
||||
name: SIMPLE_AUTO_PROFILE_NAME,
|
||||
allocated_capital: 1000,
|
||||
risk_per_trade_percent: 1,
|
||||
symbols: 'MSFT',
|
||||
is_active: true,
|
||||
strategy_config: {
|
||||
mode: 'simple-auto',
|
||||
source: 'simple-tab',
|
||||
execution: {
|
||||
orderType: 'market',
|
||||
entryMode: 'manual',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -5,7 +5,12 @@ import { fetchChartBars } from '../lib/marketApi';
|
||||
import { getPlatformAccessToken } from '../lib/authSession';
|
||||
import { tradingRuntime } from '../lib/runtime';
|
||||
import { createRequestId } from '../../../shared/request-id.js';
|
||||
import { fetchTradeProfiles, type TradeProfilePayload } from '../lib/profileApi';
|
||||
import {
|
||||
createTradeProfile,
|
||||
fetchTradeProfiles,
|
||||
setTradeProfileActive,
|
||||
type TradeProfilePayload,
|
||||
} from '../lib/profileApi';
|
||||
|
||||
type SimpleSide = 'buy' | 'sell';
|
||||
type SimpleOrderType = 'market' | 'limit';
|
||||
@ -45,6 +50,11 @@ const DEFAULT_DRAFT: SimpleTradeDraft = {
|
||||
isCrypto: false,
|
||||
};
|
||||
|
||||
export const SIMPLE_AUTO_PROFILE_NAME = 'Simple Auto Profile';
|
||||
const SIMPLE_AUTO_PROFILE_KEY = SIMPLE_AUTO_PROFILE_NAME.toLowerCase();
|
||||
const SIMPLE_PROFILE_RETRY_DELAY_MS = 2000;
|
||||
const SIMPLE_PROFILE_RETRY_ATTEMPTS = 8;
|
||||
|
||||
function parsePositiveNumber(value: string): number | null {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
@ -56,6 +66,34 @@ function roundPrice(value: number): number {
|
||||
return Number(value.toFixed(4));
|
||||
}
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function matchesSimpleAutoProfile(profile: Pick<TradeProfilePayload, 'name'> | null | undefined) {
|
||||
return String(profile?.name || '').trim().toLowerCase() === SIMPLE_AUTO_PROFILE_KEY;
|
||||
}
|
||||
|
||||
export function buildSimpleAutoProfilePayload(symbol: string): TradeProfilePayload {
|
||||
const normalizedSymbol = symbol.trim().toUpperCase();
|
||||
|
||||
return {
|
||||
name: SIMPLE_AUTO_PROFILE_NAME,
|
||||
allocated_capital: 1000,
|
||||
risk_per_trade_percent: 1,
|
||||
symbols: normalizedSymbol || 'AAPL',
|
||||
is_active: true,
|
||||
strategy_config: {
|
||||
mode: 'simple-auto',
|
||||
source: 'simple-tab',
|
||||
execution: {
|
||||
orderType: 'market',
|
||||
entryMode: 'manual',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function computeProtectionPrices(input: {
|
||||
side: SimpleSide;
|
||||
referencePrice: number;
|
||||
@ -147,7 +185,6 @@ function buildExecutionPreview(payload: SimpleTradePayload | null) {
|
||||
export function SimpleView() {
|
||||
const { botState } = useAppContext();
|
||||
const [profiles, setProfiles] = useState<TradeProfilePayload[]>([]);
|
||||
const [selectedProfileId, setSelectedProfileId] = useState('');
|
||||
const [draft, setDraft] = useState<SimpleTradeDraft>(DEFAULT_DRAFT);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [loadingPrice, setLoadingPrice] = useState(false);
|
||||
@ -158,8 +195,8 @@ export function SimpleView() {
|
||||
const livePrice = normalizedSymbol ? Number(botState.symbols?.[normalizedSymbol]?.price || 0) : 0;
|
||||
const resolvedMarketPrice = parsePositiveNumber(draft.currentMarketPrice);
|
||||
const marketPriceSource = livePrice > 0 ? 'live' : (resolvedMarketPrice !== null ? 'recent_close' : null);
|
||||
const activeProfiles = useMemo(
|
||||
() => profiles.filter((profile) => Boolean(profile.is_active)),
|
||||
const simpleAutoProfile = useMemo(
|
||||
() => profiles.find((profile) => matchesSimpleAutoProfile(profile)) || null,
|
||||
[profiles],
|
||||
);
|
||||
|
||||
@ -183,19 +220,6 @@ export function SimpleView() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeProfiles.length) {
|
||||
setSelectedProfileId('');
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedProfileId && activeProfiles.some((profile) => profile.id === selectedProfileId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedProfileId(String(activeProfiles[0]?.id || ''));
|
||||
}, [activeProfiles, selectedProfileId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!livePrice) return;
|
||||
setDraft(prev => (
|
||||
@ -230,6 +254,54 @@ export function SimpleView() {
|
||||
setDraft(prev => ({ ...prev, [key]: value }));
|
||||
}
|
||||
|
||||
async function ensureSimpleAutoProfile(symbol: string) {
|
||||
const refreshProfiles = async () => {
|
||||
const rows = await fetchTradeProfiles();
|
||||
setProfiles(rows);
|
||||
return rows;
|
||||
};
|
||||
|
||||
let rows = await refreshProfiles();
|
||||
let profile = rows.find((row) => matchesSimpleAutoProfile(row)) || null;
|
||||
|
||||
if (!profile) {
|
||||
profile = await createTradeProfile(buildSimpleAutoProfilePayload(symbol));
|
||||
rows = await refreshProfiles();
|
||||
profile = rows.find((row) => row.id === profile?.id) || profile;
|
||||
}
|
||||
|
||||
if (!profile?.is_active && profile?.id) {
|
||||
profile = await setTradeProfileActive(profile.id, true);
|
||||
rows = await refreshProfiles();
|
||||
profile = rows.find((row) => row.id === profile?.id) || profile;
|
||||
}
|
||||
|
||||
if (!profile?.id) {
|
||||
throw new Error('Failed to prepare the Simple Auto Profile');
|
||||
}
|
||||
|
||||
return profile;
|
||||
}
|
||||
|
||||
async function submitTradeRequest(accessToken: string, payload: SimpleTradePayload) {
|
||||
const response = await fetch(`${tradingRuntime.tradingApiUrl}/api/trade`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'x-request-id': createRequestId('web-simple-trade'),
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
const result = await response.json().catch(() => ({}));
|
||||
if (!response.ok || !result?.success) {
|
||||
throw new Error(result?.error || `Trade request failed (${response.status})`);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async function handleLoadMarketPrice() {
|
||||
if (!normalizedSymbol) {
|
||||
setError('Enter a symbol first');
|
||||
@ -262,31 +334,28 @@ export function SimpleView() {
|
||||
setMessage(null);
|
||||
|
||||
try {
|
||||
if (!activeProfiles.length) {
|
||||
throw new Error('No active trade profile available. Activate a profile first.');
|
||||
}
|
||||
|
||||
const payload = buildSimpleTradePayload(draft);
|
||||
if (selectedProfileId) {
|
||||
payload.profile_id = selectedProfileId;
|
||||
}
|
||||
const simpleProfile = await ensureSimpleAutoProfile(payload.symbol);
|
||||
payload.profile_id = String(simpleProfile.id);
|
||||
const accessToken = await getPlatformAccessToken();
|
||||
const response = await fetch(`${tradingRuntime.tradingApiUrl}/api/trade`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'x-request-id': createRequestId('web-simple-trade'),
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
let result: any = null;
|
||||
|
||||
const result = await response.json().catch(() => ({}));
|
||||
if (!response.ok || !result?.success) {
|
||||
throw new Error(result?.error || `Trade request failed (${response.status})`);
|
||||
for (let attempt = 1; attempt <= SIMPLE_PROFILE_RETRY_ATTEMPTS; attempt += 1) {
|
||||
try {
|
||||
result = await submitTradeRequest(accessToken, payload);
|
||||
break;
|
||||
} catch (err: any) {
|
||||
const message = String(err?.message || '');
|
||||
const isManagerWarmup = message.includes('No Manual Trader available');
|
||||
if (!isManagerWarmup || attempt === SIMPLE_PROFILE_RETRY_ATTEMPTS) {
|
||||
throw err;
|
||||
}
|
||||
setMessage(`Preparing ${SIMPLE_AUTO_PROFILE_NAME}… retrying order submission (${attempt}/${SIMPLE_PROFILE_RETRY_ATTEMPTS - 1})`);
|
||||
await sleep(SIMPLE_PROFILE_RETRY_DELAY_MS);
|
||||
}
|
||||
}
|
||||
|
||||
setMessage(`Order submitted${result.orderId ? ` · ${result.orderId}` : ''}`);
|
||||
setMessage(`Order submitted via ${SIMPLE_AUTO_PROFILE_NAME}${result.orderId ? ` · ${result.orderId}` : ''}`);
|
||||
setDraft(prev => ({
|
||||
...DEFAULT_DRAFT,
|
||||
symbol: prev.symbol,
|
||||
@ -322,7 +391,7 @@ export function SimpleView() {
|
||||
Submit a simple buy or sell without the full strategy builder
|
||||
</div>
|
||||
<div style={{ color: '#4B5563', fontSize: 14, lineHeight: 1.6, maxWidth: 760 }}>
|
||||
This page sends a direct trade request to your configured trading profile. Market price is sourced automatically from live data or the most recent close.
|
||||
This page sends direct orders through one dedicated auto-created profile. Your first order prepares that profile automatically, and every later Simple order reuses it.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -363,23 +432,6 @@ export function SimpleView() {
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<span style={{ fontSize: 12, fontWeight: 700, color: '#374151' }}>Profile</span>
|
||||
<select
|
||||
value={selectedProfileId}
|
||||
onChange={e => setSelectedProfileId(e.target.value)}
|
||||
disabled={!activeProfiles.length}
|
||||
style={{ border: '1px solid #D1D5DB', borderRadius: 10, padding: '10px 12px', fontSize: 14, background: '#fff' }}
|
||||
>
|
||||
{!activeProfiles.length && <option value="">No active profile</option>}
|
||||
{activeProfiles.map((profile) => (
|
||||
<option key={profile.id} value={profile.id}>
|
||||
{profile.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<span style={{ fontSize: 12, fontWeight: 700, color: '#374151' }}>Quantity</span>
|
||||
<input
|
||||
@ -497,14 +549,12 @@ export function SimpleView() {
|
||||
Execution Preview
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ color: '#2563EB', fontSize: 13, fontWeight: 800, marginBottom: 8 }}>
|
||||
Routing profile: {simpleAutoProfile?.name || SIMPLE_AUTO_PROFILE_NAME}
|
||||
</div>
|
||||
<div style={{ color: '#111827', fontSize: 14, fontWeight: 700 }}>
|
||||
{executionPreview ?? 'Complete the fields to preview the direct trade request.'}
|
||||
</div>
|
||||
{!activeProfiles.length && (
|
||||
<div style={{ color: '#B91C1C', fontSize: 13, fontWeight: 800, marginTop: 8 }}>
|
||||
No active trade profile is available. Activate one in Strategies before submitting.
|
||||
</div>
|
||||
)}
|
||||
{payloadPreview?.sl && (
|
||||
<div style={{ color: '#B91C1C', fontSize: 13, fontWeight: 800, marginTop: 8 }}>
|
||||
Stop loss: {payloadPreview.sl.toFixed(4)}
|
||||
@ -536,7 +586,7 @@ export function SimpleView() {
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting || !activeProfiles.length}
|
||||
disabled={submitting}
|
||||
style={{
|
||||
border: 'none',
|
||||
borderRadius: 999,
|
||||
@ -545,8 +595,7 @@ export function SimpleView() {
|
||||
padding: '12px 18px',
|
||||
fontSize: 13,
|
||||
fontWeight: 800,
|
||||
cursor: submitting ? 'wait' : (!activeProfiles.length ? 'not-allowed' : 'pointer'),
|
||||
opacity: !activeProfiles.length ? 0.6 : 1,
|
||||
cursor: submitting ? 'wait' : 'pointer',
|
||||
}}
|
||||
>
|
||||
{submitting ? 'Submitting…' : `Submit ${draft.side === 'buy' ? 'Buy' : 'Sell'}`}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user