feat(chat): add runtime copilot explanations

This commit is contained in:
root 2026-05-07 06:58:11 +00:00
parent dfe876d49b
commit f3dfe31d1f
4 changed files with 566 additions and 46 deletions

View File

@ -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<BotState['settings']> | null;
}
interface ChatRequestContext {
profiles: any[];
runtime: ChatRuntimeContextPayload;
}
interface ChatResponsePayload { interface ChatResponsePayload {
action: 'create_profile' | 'update_profile' | 'explain'; action: ChatAction;
profile?: ChatProfilePayload; profile?: ChatProfilePayload;
summary: string; summary: string;
reasoning: string; reasoning: string;
@ -916,15 +1021,194 @@ export class ApiServer {
void persistAuditEvent(evt); 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<string, any>) : {};
const runtime = (record.runtime && typeof record.runtime === 'object') ? record.runtime as Record<string, any> : {};
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 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) 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); && !/(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) { if (asksForExplain) {
return { return {
action: 'explain', action: 'explain',
summary: 'AI provider is currently unavailable. A local fallback can still generate deterministic profile configurations.', 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: 'Use prompts that include risk appetite, symbols, capital, and whether you want long-only or both sides.' 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) { if (updateTarget) {
const existingConfig = updateTarget.strategy_config || {}; const existingConfig = updateTarget.strategy_config || {};
const existingExecution = existingConfig.execution || {}; const existingExecution = existingConfig.execution || {};
@ -2629,13 +2913,14 @@ export class ApiServer {
return; return;
} }
const { message, context } = req.body; const { message, context } = req.body;
if (!message) { if (!message) {
return res.status(400).json({ error: 'Message is required' }); return res.status(400).json({ error: 'Message is required' });
} }
const chatContext = this.normalizeChatContext(context);
this.auditTradeEvent({ this.auditTradeEvent({
event: 'chat_profile_control', event: 'chat_profile_control',
userId: authUserId, userId: authUserId,
details: { details: {
messageLength: typeof message === 'string' ? message.length : 0 messageLength: typeof message === 'string' ? message.length : 0
@ -2655,7 +2940,7 @@ AVAILABLE RULES (use these exact ruleId values):
PROFILE SCHEMA: PROFILE SCHEMA:
{ {
"action": "create_profile" | "update_profile" | "explain", "action": "create_profile" | "update_profile" | "explain" | "explain_position" | "explain_blocker" | "summarize_reconciliation",
"profile": { "profile": {
"name": string, "name": string,
"allocated_capital": number, "allocated_capital": number,
@ -2669,19 +2954,27 @@ PROFILE SCHEMA:
} }
}, },
"summary": string (1-2 sentence human-readable summary of what you did), "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): 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: RULES:
1. For "create_profile": generate a complete profile with sensible defaults based on the user's description. 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. 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. 3. For "explain": use it for general educational answers. 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. 4. For "explain_position": use live position context and explain the current holding plus likely next step. No profile needed.
5. Always include at least TrendBiasRule and RiskManagementRule as enabled for safety. 5. For "explain_blocker": explain the most relevant trade/order/operational blocker from runtime context. No profile needed.
6. Output ONLY valid JSON. No markdown, no backticks, no explanation outside the JSON.`; 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 { try {
let aiResponse: string | null = null; let aiResponse: string | null = null;
@ -2694,7 +2987,7 @@ RULES:
} }
if (!aiResponse) { if (!aiResponse) {
const fallback = this.buildLocalChatFallback(message, Array.isArray(context) ? context : []); const fallback = this.buildLocalChatFallback(message, chatContext);
this.auditTradeEvent({ this.auditTradeEvent({
event: 'chat_profile_control', event: 'chat_profile_control',
userId: authUserId, userId: authUserId,
@ -2711,13 +3004,29 @@ RULES:
parsed = JSON.parse(cleaned); parsed = JSON.parse(cleaned);
} catch (parseErr) { } catch (parseErr) {
logger.error(`[Chat] Failed to parse AI response: ${aiResponse}`); 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({ return res.json({
...fallback, ...fallback,
reasoning: `${fallback.reasoning} AI output was non-JSON, so local fallback parsing was used.` reasoning: `${fallback.reasoning} AI output was non-JSON, so local fallback parsing was used.`
}); });
} }
const allowedActions = new Set<ChatAction>([
'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}`); logger.info(`[Chat] Action: ${parsed.action}, Summary: ${parsed.summary}`);
this.auditTradeEvent({ this.auditTradeEvent({
event: 'chat_profile_control', event: 'chat_profile_control',

View File

@ -264,7 +264,7 @@ function App() {
{/* Floating AI strategy assistant */} {/* Floating AI strategy assistant */}
<Suspense fallback={null}> <Suspense fallback={null}>
<ChatControl profiles={chatProfiles} onApplyProfile={handleChatApply} /> <ChatControl profiles={chatProfiles} botState={botState} onApplyProfile={handleChatApply} />
</Suspense> </Suspense>
</AppContext.Provider> </AppContext.Provider>
</BrowserRouter> </BrowserRouter>

View File

@ -3,6 +3,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { ChatControl } from './ChatControl'; import { ChatControl } from './ChatControl';
import { DEFAULT_BOT_STATE } from '../hooks/useWebSocket';
import type { BotState } from '../hooks/useWebSocket';
const { getPlatformAccessTokenMock, writeTextMock } = vi.hoisted(() => ({ const { getPlatformAccessTokenMock, writeTextMock } = vi.hoisted(() => ({
getPlatformAccessTokenMock: vi.fn(), 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' } { 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', () => { describe('ChatControl DOM flow', () => {
beforeEach(() => { beforeEach(() => {
getPlatformAccessTokenMock.mockReset(); getPlatformAccessTokenMock.mockReset();
@ -35,15 +64,15 @@ describe('ChatControl DOM flow', () => {
const onApplyProfile = vi.fn(async () => ({ success: true })); const onApplyProfile = vi.fn(async () => ({ success: true }));
const user = userEvent.setup(); const user = userEvent.setup();
render(<ChatControl profiles={profilesFixture} onApplyProfile={onApplyProfile} />); render(<ChatControl profiles={profilesFixture} botState={botStateFixture} onApplyProfile={onApplyProfile} />);
await user.click(screen.getAllByRole('button')[0]); 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 High Risk Scalper ⚡')).toBeInTheDocument();
expect(screen.getByText('Tune Conservative Bag')).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(() => { await waitFor(() => {
expect(screen.getByText(/Error: Not authenticated/i)).toBeInTheDocument(); expect(screen.getByText(/Error: Not authenticated/i)).toBeInTheDocument();
@ -75,11 +104,11 @@ describe('ChatControl DOM flow', () => {
const onApplyProfile = vi.fn(async () => ({ success: true })); const onApplyProfile = vi.fn(async () => ({ success: true }));
const user = userEvent.setup(); const user = userEvent.setup();
render(<ChatControl profiles={profilesFixture} onApplyProfile={onApplyProfile} />); render(<ChatControl profiles={profilesFixture} botState={botStateFixture} onApplyProfile={onApplyProfile} />);
await user.click(screen.getAllByRole('button')[0]); 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 user.type(textarea, 'Create a profile for BTC/USDT{enter}');
await waitFor(() => { await waitFor(() => {
@ -127,9 +156,9 @@ describe('ChatControl DOM flow', () => {
const user = userEvent.setup(); const user = userEvent.setup();
render(<ChatControl profiles={profilesFixture} onApplyProfile={vi.fn(async () => ({ success: true }))} />); render(<ChatControl profiles={profilesFixture} botState={botStateFixture} onApplyProfile={vi.fn(async () => ({ success: true }))} />);
await user.click(screen.getAllByRole('button')[0]); 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(() => { await waitFor(() => {
expect(screen.getByRole('button', { name: /Cancel/i })).toBeInTheDocument(); expect(screen.getByRole('button', { name: /Cancel/i })).toBeInTheDocument();
@ -142,4 +171,53 @@ describe('ChatControl DOM flow', () => {
expect(screen.getByText(/Profile creation cancelled/i)).toBeInTheDocument(); 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(<ChatControl profiles={profilesFixture} botState={botStateFixture} onApplyProfile={vi.fn(async () => ({ 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,
})
);
});
}); });

View File

@ -4,6 +4,7 @@ import { createPortal } from 'react-dom';
import { tradingRuntime } from '../lib/runtime'; import { tradingRuntime } from '../lib/runtime';
import { getPlatformAccessToken } from '../lib/authSession'; import { getPlatformAccessToken } from '../lib/authSession';
import { createRequestId } from '../../../shared/request-id.js'; import { createRequestId } from '../../../shared/request-id.js';
import type { BotState } from '../hooks/useWebSocket';
import { import {
Send, X, Bot, User, Send, X, Bot, User,
Check, Loader2, Check, Loader2,
@ -17,15 +18,24 @@ interface ChatMessage {
role: 'user' | 'assistant'; role: 'user' | 'assistant';
content: string; content: string;
profileData?: any; profileData?: any;
action?: string; action?: ChatAssistantAction;
timestamp: Date; timestamp: Date;
} }
interface ChatControlProps { interface ChatControlProps {
profiles: any[]; profiles: any[];
botState: BotState;
onApplyProfile: (action: string, profile: any) => Promise<{ success: boolean; error?: string }>; 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 { export interface QuickAction {
label: string; label: string;
prompt: 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: '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: '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: '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: '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' }, { 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, 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 // 3D Robot SVG Icon
const RobotIcon = ({ size = 32 }: { size?: number }) => ( const RobotIcon = ({ size = 32 }: { size?: number }) => (
<svg width={size} height={size} viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg width={size} height={size} viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
@ -127,7 +257,7 @@ const RobotIcon = ({ size = 32 }: { size?: number }) => (
</svg> </svg>
); );
export const ChatControl = ({ profiles, onApplyProfile }: ChatControlProps) => { export const ChatControl = ({ profiles, botState, onApplyProfile }: ChatControlProps) => {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [messages, setMessages] = useState<ChatMessage[]>([ const [messages, setMessages] = useState<ChatMessage[]>([
{ {
@ -213,15 +343,18 @@ export const ChatControl = ({ profiles, onApplyProfile }: ChatControlProps) => {
}, },
body: JSON.stringify({ body: JSON.stringify({
message: msg, message: msg,
context: profiles.map(p => ({ context: {
id: p.id, profiles: profiles.map(p => ({
name: p.name, id: p.id,
allocated_capital: p.allocated_capital, name: p.name,
risk_per_trade_percent: p.risk_per_trade_percent, allocated_capital: p.allocated_capital,
symbols: p.symbols, risk_per_trade_percent: p.risk_per_trade_percent,
is_active: p.is_active, symbols: p.symbols,
strategy_config: p.strategy_config, 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', role: 'assistant',
content: data.summary || data.reasoning || 'Profile configuration generated.', content: data.summary || data.reasoning || 'Profile configuration generated.',
profileData: data.profile || null, profileData: data.profile || null,
action: data.action, action: data.action as ChatAssistantAction | undefined,
timestamp: new Date(), timestamp: new Date(),
}; };
@ -259,7 +392,7 @@ export const ChatControl = ({ profiles, onApplyProfile }: ChatControlProps) => {
}; };
const handleApply = async (msg: ChatMessage) => { const handleApply = async (msg: ChatMessage) => {
if (msg.profileData && msg.action) { if (msg.profileData && isProfileMutationAction(msg.action)) {
const activeDraft = draftProfiles[msg.id] || msg.profileData; const activeDraft = draftProfiles[msg.id] || msg.profileData;
const payload = normalizeProfileForApply(activeDraft); const payload = normalizeProfileForApply(activeDraft);
const result = await onApplyProfile(msg.action, payload); const result = await onApplyProfile(msg.action, payload);
@ -444,8 +577,8 @@ export const ChatControl = ({ profiles, onApplyProfile }: ChatControlProps) => {
<RobotIcon size={26} /> <RobotIcon size={26} />
</div> </div>
<div> <div>
<h3 className="text-[13px] font-bold text-[var(--foreground)] leading-none">AI Strategy Assistant</h3> <h3 className="text-[13px] font-bold text-[var(--foreground)] leading-none">AI Trading Copilot</h3>
<p className="mt-1 text-[10px] text-[var(--muted-foreground)]">Create & manage profiles with natural language</p> <p className="mt-1 text-[10px] text-[var(--muted-foreground)]">Create profiles, explain holdings, and diagnose blockers</p>
</div> </div>
</div> </div>
<Button <Button
@ -489,7 +622,7 @@ export const ChatControl = ({ profiles, onApplyProfile }: ChatControlProps) => {
</div> </div>
{/* Profile preview card */} {/* Profile preview card */}
{msg.profileData && msg.action !== 'explain' && (() => { {msg.profileData && isProfileMutationAction(msg.action) && (() => {
const activeProfileData = draftProfiles[msg.id] || msg.profileData; const activeProfileData = draftProfiles[msg.id] || msg.profileData;
const isEditing = editingIds.has(msg.id); const isEditing = editingIds.has(msg.id);
const activeRules = Array.isArray(activeProfileData?.strategy_config?.rules) const activeRules = Array.isArray(activeProfileData?.strategy_config?.rules)
@ -737,7 +870,7 @@ export const ChatControl = ({ profiles, onApplyProfile }: ChatControlProps) => {
border: '1px solid var(--border)', border: '1px solid var(--border)',
}}> }}>
<Loader2 size={12} className="animate-spin text-[var(--primary)]" /> <Loader2 size={12} className="animate-spin text-[var(--primary)]" />
<span className="text-[11px] text-[var(--muted-foreground)]">Generating configuration...</span> <span className="text-[11px] text-[var(--muted-foreground)]">Thinking through your trading context...</span>
</div> </div>
</div> </div>
)} )}
@ -758,7 +891,7 @@ export const ChatControl = ({ profiles, onApplyProfile }: ChatControlProps) => {
value={input} value={input}
onChange={(e: ChangeEvent<HTMLTextAreaElement>) => setInput(e.target.value)} onChange={(e: ChangeEvent<HTMLTextAreaElement>) => setInput(e.target.value)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
placeholder="Describe a strategy profile..." placeholder="Ask for a profile, holding explanation, or reconciliation help..."
disabled={isLoading} disabled={isLoading}
rows={2} rows={2}
className="w-full rounded-xl py-3 pl-4 pr-12 outline-none disabled:opacity-50 transition-all resize-none" className="w-full rounded-xl py-3 pl-4 pr-12 outline-none disabled:opacity-50 transition-all resize-none"