diff --git a/backend/src/services/apiServer.ts b/backend/src/services/apiServer.ts index 62dfda8..6b2576b 100644 --- a/backend/src/services/apiServer.ts +++ b/backend/src/services/apiServer.ts @@ -234,6 +234,7 @@ type ChatAction = | 'update_profile' | 'explain' | 'explain_position' + | 'explain_waiting' | 'explain_blocker' | 'summarize_reconciliation'; @@ -252,6 +253,19 @@ interface ChatRuntimeContextPosition { takeProfit?: number; } +interface ChatRuntimeSignalContext { + symbol?: string; + profileId?: string; + profileName?: string; + signal?: string; + passed?: boolean; + reason?: string; + executionStatus?: 'EXECUTED' | 'BLOCKED' | 'SKIPPED'; + executionCode?: string; + executionReason?: string; + orderId?: string; +} + interface ChatRuntimeContextOrder { id?: string; symbol?: string; @@ -319,6 +333,7 @@ interface ChatRuntimeContextHealth { } interface ChatRuntimeContextPayload { + signalContexts: ChatRuntimeSignalContext[]; positions: ChatRuntimeContextPosition[]; recentOrders: ChatRuntimeContextOrder[]; recentHistory: ChatRuntimeContextHistory[]; @@ -1026,8 +1041,9 @@ export class ApiServer { return { profiles: context, runtime: { - positions: [], - recentOrders: [], + positions: [], + signalContexts: [], + recentOrders: [], recentHistory: [], orderFailures: [], operationalEvents: [], @@ -1045,6 +1061,7 @@ export class ApiServer { profiles: Array.isArray(record.profiles) ? record.profiles : [], runtime: { positions: Array.isArray(runtime.positions) ? runtime.positions : [], + signalContexts: Array.isArray(runtime.signalContexts) ? runtime.signalContexts : [], recentOrders: Array.isArray(runtime.recentOrders) ? runtime.recentOrders : [], recentHistory: Array.isArray(runtime.recentHistory) ? runtime.recentHistory : [], orderFailures: Array.isArray(runtime.orderFailures) ? runtime.orderFailures : [], @@ -1136,6 +1153,30 @@ export class ApiServer { }; } + private buildWaitingExplanation(message: string, chatContext: ChatRequestContext): ChatResponsePayload | null { + const symbolHint = this.extractPrimaryMentionedSymbol(message); + const signals = (chatContext.runtime.signalContexts || []).filter((signal) => + !symbolHint || String(signal.symbol || '').toUpperCase() === symbolHint + ); + const target = signals.find((signal) => signal.executionStatus === 'BLOCKED' || signal.executionStatus === 'SKIPPED') + || signals.find((signal) => signal.passed === false) + || signals[0]; + if (!target) return null; + + const label = String(target.profileName || target.profileId || 'the active profile'); + const summarySource = String(target.executionReason || target.reason || '').trim(); + const signalDirection = String(target.signal || 'NEUTRAL').toUpperCase(); + const executionStatus = String(target.executionStatus || (target.passed === false ? 'BLOCKED' : 'WAITING')).toUpperCase(); + + return { + action: 'explain_waiting', + summary: `${label} on ${target.symbol || 'the current symbol'} is currently ${executionStatus.toLowerCase()} with signal ${signalDirection}.${summarySource ? ` ${summarySource}` : ''}`, + reasoning: target.executionCode + ? `Execution code: ${target.executionCode}. This means the bot evaluated the setup but did not proceed yet.${target.orderId ? ` Related order: ${target.orderId}.` : ''}` + : 'The bot is still evaluating rule alignment, risk constraints, or session eligibility before entering.' + }; + } + private buildReconciliationSummary(chatContext: ChatRequestContext): ChatResponsePayload { const health = chatContext.runtime.health || {}; const mismatchCount = Number(health.reconciliationMismatchCount || 0); @@ -1177,6 +1218,7 @@ export class ApiServer { 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 asksForWaiting = /\b(why no trade|why.*no trade|why didn'?t.*trade|why did not.*trade|why.*enter|waiting for|no entry|no signal)\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) @@ -1195,6 +1237,15 @@ export class ApiServer { }; } + if (asksForWaiting) { + return this.buildWaitingExplanation(message, context) + || { + action: 'explain_waiting', + summary: 'I did not find a recent rule-evaluation or execution-waiting signal in the scoped runtime context.', + reasoning: 'If you expected a trade, check the active profile, the relevant symbol signal panel, and any recent blocked or skipped execution reasons.' + }; + } + if (asksForPosition) { return this.buildPositionExplanation(message, context) || { @@ -2940,7 +2991,7 @@ AVAILABLE RULES (use these exact ruleId values): PROFILE SCHEMA: { - "action": "create_profile" | "update_profile" | "explain" | "explain_position" | "explain_blocker" | "summarize_reconciliation", + "action": "create_profile" | "update_profile" | "explain" | "explain_position" | "explain_waiting" | "explain_blocker" | "summarize_reconciliation", "profile": { "name": string, "allocated_capital": number, @@ -2968,13 +3019,14 @@ RULES: 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": 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.`; +5. For "explain_waiting": explain why a profile/symbol has not entered yet using signal/rule/execution context. No profile needed. +6. For "explain_blocker": explain the most relevant trade/order/operational blocker from runtime context. No profile needed. +7. For "summarize_reconciliation": summarize reconciliation health, mismatches, quarantines, and operational implications. No profile needed. +8. Only include "profile" when the action is create_profile or update_profile. +9. If runtime context is relevant, prefer concrete symbols, profile names, counts, and current state over generic advice. +10. Match the user's risk appetite: "conservative" = low risk (0.5-1%), low capital. "aggressive" = higher risk (2-5%), more rules enabled. +11. Always include at least TrendBiasRule and RiskManagementRule as enabled for safety. +12. Output ONLY valid JSON. No markdown, no backticks, no explanation outside the JSON.`; try { let aiResponse: string | null = null; @@ -3016,6 +3068,7 @@ RULES: 'update_profile', 'explain', 'explain_position', + 'explain_waiting', 'explain_blocker', 'summarize_reconciliation' ]); diff --git a/web/src/components/ChatControl.dom.test.tsx b/web/src/components/ChatControl.dom.test.tsx index 2bb69a7..3e4816e 100644 --- a/web/src/components/ChatControl.dom.test.tsx +++ b/web/src/components/ChatControl.dom.test.tsx @@ -22,6 +22,32 @@ const profilesFixture = [ const botStateFixture: BotState = { ...DEFAULT_BOT_STATE, + symbols: { + 'BTC/USDT': { + price: 61500, + change24h: 2.1, + changeToday: 1.2, + session: 'London', + volatility: 'High', + signal: 'BUY', + priceHistory: [], + rules: {}, + profileSignals: { + p1: { + profileName: 'High Risk Scalper ⚡', + signal: 'BUY', + passed: false, + reason: 'Waiting for stronger rule alignment.', + execution: { + status: 'SKIPPED', + code: 'rule_ratio_not_met', + reason: 'Only 2 of 4 voting rules passed.', + } + } + }, + indicators: {}, + } + }, positions: [ { id: 'pos-1', @@ -219,5 +245,14 @@ describe('ChatControl DOM flow', () => { reconciliationNoGoTrades: 1, }) ); + expect(body.context.runtime.signalContexts).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + symbol: 'BTC/USDT', + executionCode: 'rule_ratio_not_met', + executionStatus: 'SKIPPED', + }) + ]) + ); }); }); diff --git a/web/src/components/ChatControl.tsx b/web/src/components/ChatControl.tsx index 4c56bb5..66f473e 100644 --- a/web/src/components/ChatControl.tsx +++ b/web/src/components/ChatControl.tsx @@ -47,6 +47,7 @@ export const BASE_QUICK_ACTIONS: QuickAction[] = [ { 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: 'Why no trade?', prompt: 'Why has no trade fired yet for my active profile? Explain what the bot is waiting for.' }, { 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?' }, @@ -103,6 +104,22 @@ const isProfileMutationAction = (action?: ChatAssistantAction): action is 'creat action === 'create_profile' || action === 'update_profile'; const summarizeRuntimeContext = (botState: BotState) => ({ + signalContexts: Object.entries(botState.symbols ?? {}) + .flatMap(([symbol, symbolState]) => + Object.entries(symbolState?.profileSignals || {}).map(([profileId, profileSignal]) => ({ + symbol, + profileId, + profileName: profileSignal?.profileName, + signal: profileSignal?.signal, + passed: profileSignal?.passed, + reason: profileSignal?.reason, + executionStatus: profileSignal?.execution?.status, + executionCode: profileSignal?.execution?.code, + executionReason: profileSignal?.execution?.reason, + orderId: profileSignal?.execution?.orderId, + })) + ) + .slice(0, 20), positions: (botState.positions ?? []).slice(0, 10).map((position) => ({ symbol: position.symbol, side: position.side,