From f3dfe31d1f4a8bcb85ca12e4226df4fabe203129 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 7 May 2026 06:58:11 +0000 Subject: [PATCH] feat(chat): add runtime copilot explanations --- backend/src/services/apiServer.ts | 349 ++++++++++++++++++-- web/src/App.tsx | 2 +- web/src/components/ChatControl.dom.test.tsx | 92 +++++- web/src/components/ChatControl.tsx | 169 +++++++++- 4 files changed, 566 insertions(+), 46 deletions(-) diff --git a/backend/src/services/apiServer.ts b/backend/src/services/apiServer.ts index 68c00bb..62dfda8 100644 --- a/backend/src/services/apiServer.ts +++ b/backend/src/services/apiServer.ts @@ -229,8 +229,113 @@ interface ChatProfilePayload { }; } +type ChatAction = + | 'create_profile' + | 'update_profile' + | 'explain' + | 'explain_position' + | 'explain_blocker' + | 'summarize_reconciliation'; + +interface ChatRuntimeContextPosition { + symbol?: string; + side?: 'BUY' | 'SELL'; + size?: number; + entryPrice?: number; + currentPrice?: number; + unrealizedPnl?: number; + unrealizedPnlPercent?: number; + profileId?: string; + profileName?: string; + tradeId?: string; + stopLoss?: number; + takeProfit?: number; +} + +interface ChatRuntimeContextOrder { + id?: string; + symbol?: string; + side?: string; + qty?: number; + price?: number; + status?: string; + timestamp?: number; + profileId?: string; + tradeId?: string; + action?: string; + source?: string; +} + +interface ChatRuntimeContextHistory { + symbol?: string; + side?: string; + entryPrice?: number; + exitPrice?: number; + pnl?: number; + pnlPercent?: number; + reason?: string; + timestamp?: number; + profileId?: string; + tradeId?: string; + source?: string; +} + +interface ChatRuntimeContextFailure { + symbol?: string; + side?: 'BUY' | 'SELL'; + qty?: number; + reason?: string; + profileId?: string; + tradeId?: string; + timestamp?: number; +} + +interface ChatRuntimeContextEvent { + id?: string; + type?: string; + severity?: string; + message?: string; + symbol?: string; + profileId?: string; + tradeId?: string; + orderId?: string; + timestamp?: number; +} + +interface ChatRuntimeContextHealth { + tradingLoopHealthy?: boolean; + orderSyncHealthy?: boolean; + reconciliationLoopHealthy?: boolean; + reconciliationMismatchCount?: number; + reconciliationMissingFromExchange?: number; + reconciliationMissingInDb?: number; + reconciliationNoGoTrades?: number; + reconciliationParityMismatchTrades?: number; + reconciliationParityQuarantinedTrades?: number; + reconciliationParityAutoClosedTrades?: number; + reconciliationIntegrityWatchdogTriggered?: boolean; + lockContentionCount?: number; + reconciliationLockContentionCount?: number; +} + +interface ChatRuntimeContextPayload { + positions: ChatRuntimeContextPosition[]; + recentOrders: ChatRuntimeContextOrder[]; + recentHistory: ChatRuntimeContextHistory[]; + orderFailures: ChatRuntimeContextFailure[]; + operationalEvents: ChatRuntimeContextEvent[]; + accountSnapshot?: AccountSnapshot | null; + health?: ChatRuntimeContextHealth | null; + settings?: Partial | null; +} + +interface ChatRequestContext { + profiles: any[]; + runtime: ChatRuntimeContextPayload; +} + interface ChatResponsePayload { - action: 'create_profile' | 'update_profile' | 'explain'; + action: ChatAction; profile?: ChatProfilePayload; summary: string; reasoning: string; @@ -916,15 +1021,194 @@ export class ApiServer { void persistAuditEvent(evt); } - private buildLocalChatFallback(message: string, context: any[]): ChatResponsePayload { + private normalizeChatContext(context: unknown): ChatRequestContext { + if (Array.isArray(context)) { + return { + profiles: context, + runtime: { + positions: [], + recentOrders: [], + recentHistory: [], + orderFailures: [], + operationalEvents: [], + accountSnapshot: null, + health: null, + settings: null + } + }; + } + + const record = (context && typeof context === 'object') ? (context as Record) : {}; + const runtime = (record.runtime && typeof record.runtime === 'object') ? record.runtime as Record : {}; + + return { + profiles: Array.isArray(record.profiles) ? record.profiles : [], + runtime: { + positions: Array.isArray(runtime.positions) ? runtime.positions : [], + recentOrders: Array.isArray(runtime.recentOrders) ? runtime.recentOrders : [], + recentHistory: Array.isArray(runtime.recentHistory) ? runtime.recentHistory : [], + orderFailures: Array.isArray(runtime.orderFailures) ? runtime.orderFailures : [], + operationalEvents: Array.isArray(runtime.operationalEvents) ? runtime.operationalEvents : [], + accountSnapshot: runtime.accountSnapshot || null, + health: runtime.health || null, + settings: runtime.settings || null, + } + }; + } + + private buildPositionExplanation(message: string, chatContext: ChatRequestContext): ChatResponsePayload | null { + const positions = Array.isArray(chatContext.runtime.positions) ? chatContext.runtime.positions : []; + if (positions.length === 0) return null; + + const symbolHint = this.extractPrimaryMentionedSymbol(message); + const target = positions.find((position) => symbolHint && String(position.symbol || '').toUpperCase() === symbolHint) + || positions[0]; + if (!target?.symbol) return null; + + const pnl = Number(target.unrealizedPnl || 0); + const pnlPercent = Number(target.unrealizedPnlPercent || 0); + const side = String(target.side || 'BUY'); + const profileName = String(target.profileName || target.profileId || 'your active profile'); + const nextStep = side === 'BUY' + ? 'The bot is primarily waiting for either your profit target, stop-loss, or a management change from you.' + : 'The bot is monitoring the short-side lifecycle and waiting for its next exit or reconciliation decision.'; + const targetHints: string[] = []; + if (Number.isFinite(Number(target.takeProfit)) && Number(target.takeProfit) > 0) { + targetHints.push(`take profit ${Number(target.takeProfit).toFixed(2)}`); + } + if (Number.isFinite(Number(target.stopLoss)) && Number(target.stopLoss) > 0) { + targetHints.push(`stop loss ${Number(target.stopLoss).toFixed(2)}`); + } + + return { + action: 'explain_position', + summary: `${target.symbol} is currently ${pnl >= 0 ? 'up' : 'down'} ${Math.abs(pnl).toFixed(2)} (${Math.abs(pnlPercent).toFixed(2)}%) under ${profileName}.`, + reasoning: `${nextStep}${targetHints.length > 0 ? ` Current guardrails: ${targetHints.join(' and ')}.` : ''} Entry ${Number(target.entryPrice || 0).toFixed(2)}, current ${Number(target.currentPrice || 0).toFixed(2)}, size ${Number(target.size || 0).toFixed(6)}.` + }; + } + + private buildBlockerExplanation(message: string, chatContext: ChatRequestContext): ChatResponsePayload | null { + const symbolHint = this.extractPrimaryMentionedSymbol(message); + const recentFailure = (chatContext.runtime.orderFailures || []).find((failure) => + !symbolHint || String(failure.symbol || '').toUpperCase() === symbolHint + ); + const recentEvent = (chatContext.runtime.operationalEvents || []).find((event) => { + const msg = String(event.message || '').toLowerCase(); + const eventSymbol = String(event.symbol || '').toUpperCase(); + return (!symbolHint || eventSymbol === symbolHint) + && ( + event.severity === 'ERROR' + || event.severity === 'WARN' + || /\b(blocked|failed|manual review|quarantine|reconciliation|stale|unavailable|unauthorized|forbidden)\b/.test(msg) + ); + }); + + if (!recentFailure && !recentEvent) return null; + + const summaryParts: string[] = []; + if (recentFailure?.symbol) { + summaryParts.push(`${recentFailure.symbol} most recently failed with: ${String(recentFailure.reason || 'unknown failure')}.`); + } else if (recentEvent?.message) { + summaryParts.push(recentEvent.message); + } + + const health = chatContext.runtime.health || {}; + const healthNotes: string[] = []; + if (Number(health.reconciliationMismatchCount || 0) > 0) { + healthNotes.push(`${Number(health.reconciliationMismatchCount)} reconciliation mismatches`); + } + if (Number(health.reconciliationNoGoTrades || 0) > 0) { + healthNotes.push(`${Number(health.reconciliationNoGoTrades)} NO_GO trades`); + } + if (Number(health.reconciliationParityQuarantinedTrades || 0) > 0) { + healthNotes.push(`${Number(health.reconciliationParityQuarantinedTrades)} quarantined parity trades`); + } + if (Boolean(health.reconciliationIntegrityWatchdogTriggered)) { + healthNotes.push('integrity watchdog triggered'); + } + + return { + action: 'explain_blocker', + summary: summaryParts.join(' ') || 'A recent operational event indicates the trade flow is blocked.', + reasoning: healthNotes.length > 0 + ? `Operational context also shows ${healthNotes.join(', ')}. The safest next step is to review the related profile, live order state, and reconciliation surfaces before retrying execution.` + : 'The safest next step is to review the related profile, live order state, and recent operational events before retrying execution.' + }; + } + + private buildReconciliationSummary(chatContext: ChatRequestContext): ChatResponsePayload { + const health = chatContext.runtime.health || {}; + const mismatchCount = Number(health.reconciliationMismatchCount || 0); + const noGoCount = Number(health.reconciliationNoGoTrades || 0); + const quarantinedCount = Number(health.reconciliationParityQuarantinedTrades || 0); + const autoClosedCount = Number(health.reconciliationParityAutoClosedTrades || 0); + const missingExchange = Number(health.reconciliationMissingFromExchange || 0); + const missingDb = Number(health.reconciliationMissingInDb || 0); + const degraded = !health.reconciliationLoopHealthy || mismatchCount > 0 || noGoCount > 0 || quarantinedCount > 0; + + const recentReconEvents = (chatContext.runtime.operationalEvents || []) + .filter((event) => String(event.type || '').toLowerCase().includes('reconciliation') || String(event.message || '').toLowerCase().includes('reconciliation')) + .slice(0, 3) + .map((event) => event.message) + .filter(Boolean); + + return { + action: 'summarize_reconciliation', + summary: degraded + ? `Reconciliation is currently carrying ${mismatchCount} mismatches, ${noGoCount} NO_GO trades, and ${quarantinedCount} quarantined parity trades.` + : `Reconciliation currently looks healthy with no active mismatches, NO_GO trades, or quarantined parity trades.`, + reasoning: degraded + ? `Missing-from-exchange: ${missingExchange}. Missing-in-DB: ${missingDb}. Auto-closed parity trades: ${autoClosedCount}.${recentReconEvents.length > 0 ? ` Recent events: ${recentReconEvents.join(' | ')}` : ''}` + : `Auto-closed parity trades this cycle: ${autoClosedCount}.${recentReconEvents.length > 0 ? ` Recent events: ${recentReconEvents.join(' | ')}` : ''}` + }; + } + + private extractPrimaryMentionedSymbol(message: string): string | null { + const upper = String(message || '').toUpperCase(); + const explicitPair = upper.match(/\b[A-Z]{2,10}\/[A-Z]{2,10}\b/); + if (explicitPair?.[0]) return explicitPair[0]; + const stockSymbol = upper.match(/\b(AAPL|MSFT|NVDA|TSLA|META|AMZN|GOOGL|GOOG|SPY|QQQ|DIA)\b/); + if (stockSymbol?.[0]) return stockSymbol[0]; + const asset = upper.match(/\b(BTC|ETH|SOL|DOGE|XRP|ADA|BNB|AVAX|MATIC|LTC|LINK|DOT|TRX|SHIB)\b/); + if (asset?.[0]) return `${asset[0]}/USDT`; + return null; + } + + private buildLocalChatFallback(message: string, context: ChatRequestContext): ChatResponsePayload { const lower = String(message || '').toLowerCase(); + const asksForReconciliation = /\b(reconciliation|reconcile|quarantine|manual review|stale order|no[_\s-]*go|drift)\b/i.test(lower); + const asksForBlocker = /\b(why.*(blocked|stuck|failed|not execute|didn'?t execute|did not execute)|blocker|blocked|manual trader unavailable|unauthorized|forbidden|failed order)\b/i.test(lower); + const asksForPosition = /\b(position|holding|portfolio|pnl|profit|loss|exit|take profit|stop loss)\b/i.test(lower); const asksForExplain = /(what|how|why|help|explain|suggest)/i.test(lower) && !/(create|build|make|generate|new profile|strategy|setup|configure|update|modify)/i.test(lower); + + if (asksForReconciliation) { + return this.buildReconciliationSummary(context); + } + + if (asksForBlocker) { + return this.buildBlockerExplanation(message, context) + || { + action: 'explain_blocker', + summary: 'I did not find a recent concrete blocker in the scoped runtime context.', + reasoning: 'If the issue is recent, check the latest operational events, order failures, and reconciliation status for the affected profile or symbol.' + }; + } + + if (asksForPosition) { + return this.buildPositionExplanation(message, context) + || { + action: 'explain_position', + summary: 'I did not find an open scoped holding to explain right now.', + reasoning: 'If you expected an open position, check Portfolio and recent order history for the relevant symbol or profile.' + }; + } + if (asksForExplain) { return { action: 'explain', - summary: 'AI provider is currently unavailable. A local fallback can still generate deterministic profile configurations.', - reasoning: 'Use prompts that include risk appetite, symbols, capital, and whether you want long-only or both sides.' + summary: 'AI provider is currently unavailable. I can still explain positions, blockers, and reconciliation state using live runtime context, or generate deterministic fallback profile configurations.', + reasoning: 'Ask for a holding explanation, blocker diagnosis, reconciliation summary, or include risk appetite, symbols, capital, and side preference for a profile request.' }; } @@ -974,7 +1258,7 @@ export class ApiServer { } }; - const updateTarget = this.detectProfileToUpdate(message, context); + const updateTarget = this.detectProfileToUpdate(message, context.profiles); if (updateTarget) { const existingConfig = updateTarget.strategy_config || {}; const existingExecution = existingConfig.execution || {}; @@ -2629,13 +2913,14 @@ export class ApiServer { return; } - const { message, context } = req.body; - if (!message) { - return res.status(400).json({ error: 'Message is required' }); - } + const { message, context } = req.body; + if (!message) { + return res.status(400).json({ error: 'Message is required' }); + } + const chatContext = this.normalizeChatContext(context); - this.auditTradeEvent({ - event: 'chat_profile_control', + this.auditTradeEvent({ + event: 'chat_profile_control', userId: authUserId, details: { messageLength: typeof message === 'string' ? message.length : 0 @@ -2655,7 +2940,7 @@ AVAILABLE RULES (use these exact ruleId values): PROFILE SCHEMA: { - "action": "create_profile" | "update_profile" | "explain", + "action": "create_profile" | "update_profile" | "explain" | "explain_position" | "explain_blocker" | "summarize_reconciliation", "profile": { "name": string, "allocated_capital": number, @@ -2669,19 +2954,27 @@ PROFILE SCHEMA: } }, "summary": string (1-2 sentence human-readable summary of what you did), - "reasoning": string (brief explanation of why you chose these parameters) + "reasoning": string (brief explanation of why you chose these parameters or what operational state means) } CURRENT CONTEXT (existing profiles): -${context ? JSON.stringify(context, null, 2) : 'No existing profiles.'} +${chatContext.profiles.length > 0 ? JSON.stringify(chatContext.profiles, null, 2) : 'No existing profiles.'} + +LIVE RUNTIME CONTEXT: +${JSON.stringify(chatContext.runtime, null, 2)} RULES: 1. For "create_profile": generate a complete profile with sensible defaults based on the user's description. 2. For "update_profile": include the profile "id" field and only change what the user asked for. Keep everything else the same. -3. For "explain": just set action to "explain" and put your answer in "summary". No profile needed. -4. Match the user's risk appetite: "conservative" = low risk (0.5-1%), low capital. "aggressive" = higher risk (2-5%), more rules enabled. -5. Always include at least TrendBiasRule and RiskManagementRule as enabled for safety. -6. Output ONLY valid JSON. No markdown, no backticks, no explanation outside the JSON.`; +3. For "explain": use it for general educational answers. No profile needed. +4. For "explain_position": use live position context and explain the current holding plus likely next step. No profile needed. +5. For "explain_blocker": explain the most relevant trade/order/operational blocker from runtime context. No profile needed. +6. For "summarize_reconciliation": summarize reconciliation health, mismatches, quarantines, and operational implications. No profile needed. +7. Only include "profile" when the action is create_profile or update_profile. +8. If runtime context is relevant, prefer concrete symbols, profile names, counts, and current state over generic advice. +9. Match the user's risk appetite: "conservative" = low risk (0.5-1%), low capital. "aggressive" = higher risk (2-5%), more rules enabled. +10. Always include at least TrendBiasRule and RiskManagementRule as enabled for safety. +11. Output ONLY valid JSON. No markdown, no backticks, no explanation outside the JSON.`; try { let aiResponse: string | null = null; @@ -2694,7 +2987,7 @@ RULES: } if (!aiResponse) { - const fallback = this.buildLocalChatFallback(message, Array.isArray(context) ? context : []); + const fallback = this.buildLocalChatFallback(message, chatContext); this.auditTradeEvent({ event: 'chat_profile_control', userId: authUserId, @@ -2711,13 +3004,29 @@ RULES: parsed = JSON.parse(cleaned); } catch (parseErr) { logger.error(`[Chat] Failed to parse AI response: ${aiResponse}`); - const fallback = this.buildLocalChatFallback(message, Array.isArray(context) ? context : []); + const fallback = this.buildLocalChatFallback(message, chatContext); return res.json({ ...fallback, reasoning: `${fallback.reasoning} AI output was non-JSON, so local fallback parsing was used.` }); } + const allowedActions = new Set([ + 'create_profile', + 'update_profile', + 'explain', + 'explain_position', + 'explain_blocker', + 'summarize_reconciliation' + ]); + if (!allowedActions.has(parsed?.action)) { + const fallback = this.buildLocalChatFallback(message, chatContext); + return res.json({ + ...fallback, + reasoning: `${fallback.reasoning} AI output used an unsupported action, so local fallback routing was used.` + }); + } + logger.info(`[Chat] Action: ${parsed.action}, Summary: ${parsed.summary}`); this.auditTradeEvent({ event: 'chat_profile_control', diff --git a/web/src/App.tsx b/web/src/App.tsx index caecd72..41dd4c0 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -264,7 +264,7 @@ function App() { {/* Floating AI strategy assistant */} - + diff --git a/web/src/components/ChatControl.dom.test.tsx b/web/src/components/ChatControl.dom.test.tsx index 0cc86fd..2bb69a7 100644 --- a/web/src/components/ChatControl.dom.test.tsx +++ b/web/src/components/ChatControl.dom.test.tsx @@ -3,6 +3,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { ChatControl } from './ChatControl'; +import { DEFAULT_BOT_STATE } from '../hooks/useWebSocket'; +import type { BotState } from '../hooks/useWebSocket'; const { getPlatformAccessTokenMock, writeTextMock } = vi.hoisted(() => ({ getPlatformAccessTokenMock: vi.fn(), @@ -18,6 +20,33 @@ const profilesFixture = [ { id: 'p2', name: 'Conservative Bag', allocated_capital: 2000, risk_per_trade_percent: 0.8, symbols: 'ETH/USDT' } ]; +const botStateFixture: BotState = { + ...DEFAULT_BOT_STATE, + positions: [ + { + id: 'pos-1', + symbol: 'BTC/USDT', + side: 'BUY' as const, + size: 0.25, + entryPrice: 60000, + currentPrice: 61500, + stopLoss: 58500, + takeProfit: 63000, + unrealizedPnl: 375, + unrealizedPnlPercent: 2.5, + marketValue: 15375, + profileId: 'p1', + profileName: 'High Risk Scalper ⚡', + tradeId: 'trade-1', + } + ], + health: { + ...DEFAULT_BOT_STATE.health!, + reconciliationMismatchCount: 2, + reconciliationNoGoTrades: 1, + }, +}; + describe('ChatControl DOM flow', () => { beforeEach(() => { getPlatformAccessTokenMock.mockReset(); @@ -35,15 +64,15 @@ describe('ChatControl DOM flow', () => { const onApplyProfile = vi.fn(async () => ({ success: true })); const user = userEvent.setup(); - render(); + render(); await user.click(screen.getAllByRole('button')[0]); - expect(screen.getByText('AI Strategy Assistant')).toBeInTheDocument(); + expect(screen.getByText('AI Trading Copilot')).toBeInTheDocument(); expect(screen.getByText('Tune High Risk Scalper ⚡')).toBeInTheDocument(); expect(screen.getByText('Tune Conservative Bag')).toBeInTheDocument(); - await user.type(screen.getByPlaceholderText('Describe a strategy profile...'), 'Create a conservative profile{enter}'); + await user.type(screen.getByPlaceholderText(/Ask for a profile, holding explanation, or reconciliation help/i), 'Create a conservative profile{enter}'); await waitFor(() => { expect(screen.getByText(/Error: Not authenticated/i)).toBeInTheDocument(); @@ -75,11 +104,11 @@ describe('ChatControl DOM flow', () => { const onApplyProfile = vi.fn(async () => ({ success: true })); const user = userEvent.setup(); - render(); + render(); await user.click(screen.getAllByRole('button')[0]); - const textarea = screen.getByPlaceholderText('Describe a strategy profile...'); + const textarea = screen.getByPlaceholderText(/Ask for a profile, holding explanation, or reconciliation help/i); await user.type(textarea, 'Create a profile for BTC/USDT{enter}'); await waitFor(() => { @@ -127,9 +156,9 @@ describe('ChatControl DOM flow', () => { const user = userEvent.setup(); - render( ({ success: true }))} />); + render( ({ success: true }))} />); await user.click(screen.getAllByRole('button')[0]); - await user.type(screen.getByPlaceholderText('Describe a strategy profile...'), 'Create profile{enter}'); + await user.type(screen.getByPlaceholderText(/Ask for a profile, holding explanation, or reconciliation help/i), 'Create profile{enter}'); await waitFor(() => { expect(screen.getByRole('button', { name: /Cancel/i })).toBeInTheDocument(); @@ -142,4 +171,53 @@ describe('ChatControl DOM flow', () => { expect(screen.getByText(/Profile creation cancelled/i)).toBeInTheDocument(); }); }); + + it('sends runtime context and renders explanation-only assistant responses without apply controls', async () => { + getPlatformAccessTokenMock.mockResolvedValue('token-3'); + const fetchMock = vi.mocked(fetch); + fetchMock.mockResolvedValue({ + ok: true, + json: async () => ({ + summary: 'BTC/USDT is currently up 375.00 (2.50%) under High Risk Scalper ⚡.', + reasoning: 'The bot is primarily waiting for either your profit target, stop-loss, or a management change from you.', + action: 'explain_position', + }) + } as any); + + const user = userEvent.setup(); + + render( ({ success: true }))} />); + await user.click(screen.getAllByRole('button')[0]); + await user.type(screen.getByPlaceholderText(/Ask for a profile, holding explanation, or reconciliation help/i), 'Explain my current BTC holding{enter}'); + + await waitFor(() => { + expect(screen.getByText(/BTC\/USDT is currently up 375.00/i)).toBeInTheDocument(); + expect(screen.getByText(/waiting for either your profit target/i)).toBeInTheDocument(); + }); + + expect(screen.queryByRole('button', { name: /Apply to Dashboard/i })).not.toBeInTheDocument(); + + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining('/api/chat'), + expect.objectContaining({ + method: 'POST', + body: expect.any(String), + }) + ); + const body = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body)); + expect(body.context.runtime.positions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + symbol: 'BTC/USDT', + tradeId: 'trade-1', + }) + ]) + ); + expect(body.context.runtime.health).toEqual( + expect.objectContaining({ + reconciliationMismatchCount: 2, + reconciliationNoGoTrades: 1, + }) + ); + }); }); diff --git a/web/src/components/ChatControl.tsx b/web/src/components/ChatControl.tsx index e441255..4c56bb5 100644 --- a/web/src/components/ChatControl.tsx +++ b/web/src/components/ChatControl.tsx @@ -4,6 +4,7 @@ import { createPortal } from 'react-dom'; import { tradingRuntime } from '../lib/runtime'; import { getPlatformAccessToken } from '../lib/authSession'; import { createRequestId } from '../../../shared/request-id.js'; +import type { BotState } from '../hooks/useWebSocket'; import { Send, X, Bot, User, Check, Loader2, @@ -17,15 +18,24 @@ interface ChatMessage { role: 'user' | 'assistant'; content: string; profileData?: any; - action?: string; + action?: ChatAssistantAction; timestamp: Date; } interface ChatControlProps { profiles: any[]; + botState: BotState; onApplyProfile: (action: string, profile: any) => Promise<{ success: boolean; error?: string }>; } +type ChatAssistantAction = + | 'create_profile' + | 'update_profile' + | 'explain' + | 'explain_position' + | 'explain_blocker' + | 'summarize_reconciliation'; + export interface QuickAction { label: string; prompt: string; @@ -36,6 +46,9 @@ export const BASE_QUICK_ACTIONS: QuickAction[] = [ { label: 'Aggressive Scalper', prompt: 'Build an aggressive scalper for SOL/DOGE with $500 capital, 3% risk and all rules enabled' }, { label: 'Low Risk Profile', prompt: 'Create a low-risk profile that only trades BTC during London and NY sessions with $5000 capital' }, { label: 'AI Momentum', prompt: 'Create a momentum strategy with AI analysis enabled, focusing on ETH/SOL with 2% risk' }, + { label: 'Explain holding', prompt: 'Explain my current open holding and what the bot is waiting for next.' }, + { label: 'Explain blocker', prompt: 'Why is a trade or exit blocked right now? Explain the main blocker.' }, + { label: 'Recon summary', prompt: 'Summarize reconciliation health, stale orders, and any manual review risk right now.' }, { label: 'What rules?', prompt: 'What rules should I use for a day trading strategy?' }, { label: 'Modify existing', prompt: 'Show me my existing profiles and suggest improvements' }, ]; @@ -86,6 +99,123 @@ export const normalizeProfileForApply = (profileData: any) => ({ is_active: profileData?.is_active !== false, }); +const isProfileMutationAction = (action?: ChatAssistantAction): action is 'create_profile' | 'update_profile' => + action === 'create_profile' || action === 'update_profile'; + +const summarizeRuntimeContext = (botState: BotState) => ({ + positions: (botState.positions ?? []).slice(0, 10).map((position) => ({ + symbol: position.symbol, + side: position.side, + size: position.size, + entryPrice: position.entryPrice, + currentPrice: position.currentPrice, + unrealizedPnl: position.unrealizedPnl, + unrealizedPnlPercent: position.unrealizedPnlPercent, + profileId: position.profileId, + profileName: position.profileName, + tradeId: position.tradeId, + stopLoss: position.stopLoss, + takeProfit: position.takeProfit, + })), + recentOrders: (botState.orders ?? []) + .slice() + .sort((a, b) => Number(b.timestamp || 0) - Number(a.timestamp || 0)) + .slice(0, 12) + .map((order) => ({ + id: order.id, + symbol: order.symbol, + side: order.side, + qty: order.qty, + price: order.price, + status: order.status, + timestamp: order.timestamp, + profileId: order.profileId, + tradeId: order.trade_id, + action: order.action, + source: order.source, + })), + recentHistory: (botState.history ?? []) + .slice() + .sort((a, b) => Number(b.timestamp || 0) - Number(a.timestamp || 0)) + .slice(0, 12) + .map((trade) => ({ + symbol: trade.symbol, + side: trade.side, + entryPrice: trade.entryPrice, + exitPrice: trade.exitPrice, + pnl: trade.pnl, + pnlPercent: trade.pnlPercent, + reason: trade.reason, + timestamp: trade.timestamp, + profileId: trade.profileId, + tradeId: trade.trade_id, + source: trade.source, + })), + orderFailures: (botState.orderFailures ?? []) + .slice() + .sort((a, b) => Number(b.timestamp || 0) - Number(a.timestamp || 0)) + .slice(0, 8) + .map((failure) => ({ + symbol: failure.symbol, + side: failure.side, + qty: failure.qty, + reason: failure.reason, + profileId: failure.profileId, + tradeId: failure.tradeId, + timestamp: failure.timestamp, + })), + operationalEvents: (botState.operationalEvents ?? []) + .filter(Boolean) + .slice() + .sort((a, b) => Number(b?.timestamp || 0) - Number(a?.timestamp || 0)) + .slice(0, 12) + .map((event) => ({ + id: event?.id, + type: event?.type, + severity: event?.severity, + message: event?.message, + symbol: event?.symbol, + profileId: event?.profileId, + tradeId: event?.tradeId, + orderId: event?.orderId, + timestamp: event?.timestamp, + })), + accountSnapshot: botState.accountSnapshot + ? { + buying_power: botState.accountSnapshot.buying_power, + cash: botState.accountSnapshot.cash, + currency: botState.accountSnapshot.currency, + timestamp: botState.accountSnapshot.timestamp, + } + : null, + health: botState.health + ? { + tradingLoopHealthy: botState.health.tradingLoopHealthy, + orderSyncHealthy: botState.health.orderSyncHealthy, + reconciliationLoopHealthy: botState.health.reconciliationLoopHealthy, + reconciliationMismatchCount: botState.health.reconciliationMismatchCount, + reconciliationMissingFromExchange: botState.health.reconciliationMissingFromExchange, + reconciliationMissingInDb: botState.health.reconciliationMissingInDb, + reconciliationNoGoTrades: botState.health.reconciliationNoGoTrades, + reconciliationParityMismatchTrades: botState.health.reconciliationParityMismatchTrades ?? 0, + reconciliationParityQuarantinedTrades: botState.health.reconciliationParityQuarantinedTrades ?? 0, + reconciliationParityAutoClosedTrades: botState.health.reconciliationParityAutoClosedTrades ?? 0, + reconciliationIntegrityWatchdogTriggered: botState.health.reconciliationIntegrityWatchdogTriggered, + lockContentionCount: botState.health.lockContentionCount, + reconciliationLockContentionCount: botState.health.reconciliationLockContentionCount, + } + : null, + settings: botState.settings + ? { + executionMode: botState.settings.executionMode, + totalCapital: botState.settings.totalCapital, + riskPerTrade: botState.settings.riskPerTrade, + maxOpenTrades: botState.settings.maxOpenTrades, + isAlgoEnabled: botState.settings.isAlgoEnabled, + } + : null, +}); + // 3D Robot SVG Icon const RobotIcon = ({ size = 32 }: { size?: number }) => ( @@ -127,7 +257,7 @@ const RobotIcon = ({ size = 32 }: { size?: number }) => ( ); -export const ChatControl = ({ profiles, onApplyProfile }: ChatControlProps) => { +export const ChatControl = ({ profiles, botState, onApplyProfile }: ChatControlProps) => { const [isOpen, setIsOpen] = useState(false); const [messages, setMessages] = useState([ { @@ -213,15 +343,18 @@ export const ChatControl = ({ profiles, onApplyProfile }: ChatControlProps) => { }, body: JSON.stringify({ message: msg, - context: profiles.map(p => ({ - id: p.id, - name: p.name, - allocated_capital: p.allocated_capital, - risk_per_trade_percent: p.risk_per_trade_percent, - symbols: p.symbols, - is_active: p.is_active, - strategy_config: p.strategy_config, - })), + context: { + profiles: profiles.map(p => ({ + id: p.id, + name: p.name, + allocated_capital: p.allocated_capital, + risk_per_trade_percent: p.risk_per_trade_percent, + symbols: p.symbols, + is_active: p.is_active, + strategy_config: p.strategy_config, + })), + runtime: summarizeRuntimeContext(botState), + }, }), }); @@ -237,7 +370,7 @@ export const ChatControl = ({ profiles, onApplyProfile }: ChatControlProps) => { role: 'assistant', content: data.summary || data.reasoning || 'Profile configuration generated.', profileData: data.profile || null, - action: data.action, + action: data.action as ChatAssistantAction | undefined, timestamp: new Date(), }; @@ -259,7 +392,7 @@ export const ChatControl = ({ profiles, onApplyProfile }: ChatControlProps) => { }; const handleApply = async (msg: ChatMessage) => { - if (msg.profileData && msg.action) { + if (msg.profileData && isProfileMutationAction(msg.action)) { const activeDraft = draftProfiles[msg.id] || msg.profileData; const payload = normalizeProfileForApply(activeDraft); const result = await onApplyProfile(msg.action, payload); @@ -444,8 +577,8 @@ export const ChatControl = ({ profiles, onApplyProfile }: ChatControlProps) => {
-

AI Strategy Assistant

-

Create & manage profiles with natural language

+

AI Trading Copilot

+

Create profiles, explain holdings, and diagnose blockers