feat(simple): auto-create dedicated execution profile

This commit is contained in:
root 2026-05-06 01:29:45 +00:00
parent b33afc6c8c
commit 0bd46ab43b
3 changed files with 137 additions and 65 deletions

View File

@ -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

View File

@ -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',
},
},
});
});
});

View File

@ -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'}`}