diff --git a/web/src/tabs/PositionsTab.dom.test.tsx b/web/src/tabs/PositionsTab.dom.test.tsx index 0dbba2e..67c6cab 100644 --- a/web/src/tabs/PositionsTab.dom.test.tsx +++ b/web/src/tabs/PositionsTab.dom.test.tsx @@ -436,6 +436,58 @@ describe('PositionsTab DOM behavior', () => { }), 'create-exit-plan'); }); + it('uses runtime simple setup events to expose open-plan actions before bootstrap catches up', async () => { + const now = Date.now(); + fetchPositionsBootstrapMock.mockResolvedValue({ + entries: [], + orders: [{ + id: 'entry-order', + order_id: 'entry-order', + profile_id: 'p1', + symbol: 'BTC/USDT', + type: 'Market', + side: 'BUY', + qty: 1, + price: 100, + status: 'filled', + timestamp: now - 1_000, + created_at: new Date(now - 1_000).toISOString(), + trade_id: 'TRD-POS-1', + action: 'ENTRY', + source: 'BOT' + }], + historyTradeKeys: [], + profiles: [{ id: 'p1', name: 'High Risk Scalper' }] + }); + + const onManageHolding = vi.fn(); + const user = userEvent.setup(); + const botState = buildBotState(now); + botState.operationalEvents = [{ + id: 'evt-1', + type: 'SIMPLE_SETUP_UPDATE', + severity: 'INFO', + message: 'BTC/USDT entry filled. Monitoring for the configured profit exit.', + symbol: 'BTC/USDT', + setupId: 'simple-setup-live', + tradeId: 'TRD-POS-1', + timestamp: now, + }]; + + render(); + + await user.click(await screen.findByRole('button', { name: 'High Risk Scalper' })); + const manageButtons = await screen.findAllByRole('button', { name: 'Open Plan' }); + await user.click(manageButtons[0]); + + expect(onManageHolding).toHaveBeenCalledWith(expect.objectContaining({ + symbol: 'BTC/USDT', + profileId: 'p1', + tradeId: 'TRD-POS-1', + planEntryId: 'simple-setup-live' + }), 'open-plan'); + }); + it('handles non-admin bootstrap failures with empty-state fallback', async () => { authState.user = { id: 'user-2' }; authState.profile = { role: 'trader' }; diff --git a/web/src/tabs/PositionsTab.tsx b/web/src/tabs/PositionsTab.tsx index e54ef8e..5c60a14 100644 --- a/web/src/tabs/PositionsTab.tsx +++ b/web/src/tabs/PositionsTab.tsx @@ -12,6 +12,12 @@ interface PositionsTabProps { botState: BotState; onManageHolding?: (position: HybridPosition, action: 'open-plan' | 'create-exit-plan') => void; } + +type SimplePlanMeta = { + entryId?: string; + holdingMode: 'short_term' | 'long_term'; + automationState?: string | null; +}; interface HybridPosition { source: 'BOT' | 'MANUAL'; @@ -32,6 +38,19 @@ interface HybridPosition { planState?: string | null; planEntryId?: string; } + +function inferAutomationStateFromSimpleEvent(message: string): string | null { + const normalized = String(message || '').trim().toLowerCase(); + if (!normalized) return null; + if (normalized.includes('entry submitted')) return 'entry_submitted'; + if (normalized.includes('entry filled')) return 'holding_managed'; + if (normalized.includes('exit submitted')) return 'exit_submitted'; + if (normalized.includes('setup completed')) return 'closed'; + if (normalized.includes('entry did not fill')) return 'armed'; + if (normalized.includes('exit did not complete')) return 'holding_managed'; + if (normalized.includes('exit partially filled')) return 'holding_managed'; + return null; +} interface Profile { id: string; @@ -419,7 +438,7 @@ export const assignLifecycleTradeIds = ( export const PositionsTab = ({ botState, onManageHolding }: PositionsTabProps) => { const { user, profile } = useAuth(); const [manualPositions, setManualPositions] = useState([]); - const [simplePlanMetaByTradeId, setSimplePlanMetaByTradeId] = useState>({}); + const [simplePlanMetaByTradeId, setSimplePlanMetaByTradeId] = useState>({}); const [dbOrders, setDbOrders] = useState([]); const [historyTradeKeys, setHistoryTradeKeys] = useState([]); const [profiles, setProfiles] = useState([]); @@ -520,7 +539,7 @@ export const PositionsTab = ({ botState, onManageHolding }: PositionsTabProps) = automationState: String(entry.automation_state || '').trim() || null, }] as const; }) - .filter(Boolean) as Array + .filter(Boolean) as Array ); setSimplePlanMetaByTradeId(nextSimplePlanMetaByTradeId); } @@ -538,9 +557,39 @@ export const PositionsTab = ({ botState, onManageHolding }: PositionsTabProps) = window.clearInterval(refreshTimer); }; }, [user, profile?.role]); - - // 2. Build bot positions from real-time Socket.IO data - const botPositionsRaw: HybridPosition[] = useMemo(() => { + + const runtimeSimplePlanMetaByTradeId = useMemo(() => { + const merged: Record> = {}; + const events = Array.isArray(botState.operationalEvents) ? botState.operationalEvents : []; + for (const event of events) { + if (!event || event.type !== 'SIMPLE_SETUP_UPDATE') continue; + const tradeId = String(event.tradeId || '').trim(); + if (!tradeId) continue; + const nextAutomationState = inferAutomationStateFromSimpleEvent(event.message); + const existing = merged[tradeId] || {}; + merged[tradeId] = { + entryId: String(event.setupId || '').trim() || existing.entryId, + automationState: nextAutomationState ?? existing.automationState ?? null, + }; + } + return merged; + }, [botState.operationalEvents]); + + const effectiveSimplePlanMetaByTradeId = useMemo(() => { + const merged: Record = { ...simplePlanMetaByTradeId }; + for (const [tradeId, runtimeMeta] of Object.entries(runtimeSimplePlanMetaByTradeId)) { + const existing = merged[tradeId]; + merged[tradeId] = { + entryId: runtimeMeta.entryId || existing?.entryId, + holdingMode: existing?.holdingMode || 'short_term', + automationState: runtimeMeta.automationState ?? existing?.automationState ?? null, + }; + } + return merged; + }, [runtimeSimplePlanMetaByTradeId, simplePlanMetaByTradeId]); + + // 2. Build bot positions from real-time Socket.IO data + const botPositionsRaw: HybridPosition[] = useMemo(() => { const deduped = new Map(); const score = (position: HybridPosition): number => { const tradeScore = position.tradeId ? 4 : 0; @@ -566,14 +615,14 @@ export const PositionsTab = ({ botState, onManageHolding }: PositionsTabProps) = profileId: p.profileId, profileName: p.profileName, tradeId: p.tradeId, - planMode: p.tradeId && simplePlanMetaByTradeId[p.tradeId] - ? simplePlanMetaByTradeId[p.tradeId].holdingMode + planMode: p.tradeId && effectiveSimplePlanMetaByTradeId[p.tradeId] + ? effectiveSimplePlanMetaByTradeId[p.tradeId].holdingMode : undefined, - planState: p.tradeId && simplePlanMetaByTradeId[p.tradeId] - ? simplePlanMetaByTradeId[p.tradeId].automationState || null + planState: p.tradeId && effectiveSimplePlanMetaByTradeId[p.tradeId] + ? effectiveSimplePlanMetaByTradeId[p.tradeId].automationState || null : null, - planEntryId: p.tradeId && simplePlanMetaByTradeId[p.tradeId] - ? simplePlanMetaByTradeId[p.tradeId].entryId + planEntryId: p.tradeId && effectiveSimplePlanMetaByTradeId[p.tradeId] + ? effectiveSimplePlanMetaByTradeId[p.tradeId].entryId : undefined, }; @@ -598,7 +647,7 @@ export const PositionsTab = ({ botState, onManageHolding }: PositionsTabProps) = }); } return Array.from(deduped.values()); - }, [botState.positions, simplePlanMetaByTradeId]); + }, [botState.positions, effectiveSimplePlanMetaByTradeId]); const managedSymbols = useMemo(() => { return new Set(Object.keys(botState.symbols || {}).map((symbol) => String(symbol).toUpperCase())); @@ -1084,14 +1133,14 @@ export const PositionsTab = ({ botState, onManageHolding }: PositionsTabProps) = profileId: position.profileId, profileName: position.profileName, tradeId: position.tradeId, - planMode: position.tradeId && simplePlanMetaByTradeId[position.tradeId] - ? simplePlanMetaByTradeId[position.tradeId].holdingMode + planMode: position.tradeId && effectiveSimplePlanMetaByTradeId[position.tradeId] + ? effectiveSimplePlanMetaByTradeId[position.tradeId].holdingMode : undefined, - planState: position.tradeId && simplePlanMetaByTradeId[position.tradeId] - ? simplePlanMetaByTradeId[position.tradeId].automationState || null + planState: position.tradeId && effectiveSimplePlanMetaByTradeId[position.tradeId] + ? effectiveSimplePlanMetaByTradeId[position.tradeId].automationState || null : null, - planEntryId: position.tradeId && simplePlanMetaByTradeId[position.tradeId] - ? simplePlanMetaByTradeId[position.tradeId].entryId + planEntryId: position.tradeId && effectiveSimplePlanMetaByTradeId[position.tradeId] + ? effectiveSimplePlanMetaByTradeId[position.tradeId].entryId : undefined } as HybridPosition));