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 {
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<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 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<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}`);
this.auditTradeEvent({
event: 'chat_profile_control',

View File

@ -264,7 +264,7 @@ function App() {
{/* Floating AI strategy assistant */}
<Suspense fallback={null}>
<ChatControl profiles={chatProfiles} onApplyProfile={handleChatApply} />
<ChatControl profiles={chatProfiles} botState={botState} onApplyProfile={handleChatApply} />
</Suspense>
</AppContext.Provider>
</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 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(<ChatControl profiles={profilesFixture} onApplyProfile={onApplyProfile} />);
render(<ChatControl profiles={profilesFixture} botState={botStateFixture} onApplyProfile={onApplyProfile} />);
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(<ChatControl profiles={profilesFixture} onApplyProfile={onApplyProfile} />);
render(<ChatControl profiles={profilesFixture} botState={botStateFixture} onApplyProfile={onApplyProfile} />);
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(<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.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(<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 { 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 }) => (
<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>
);
export const ChatControl = ({ profiles, onApplyProfile }: ChatControlProps) => {
export const ChatControl = ({ profiles, botState, onApplyProfile }: ChatControlProps) => {
const [isOpen, setIsOpen] = useState(false);
const [messages, setMessages] = useState<ChatMessage[]>([
{
@ -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) => {
<RobotIcon size={26} />
</div>
<div>
<h3 className="text-[13px] font-bold text-[var(--foreground)] leading-none">AI Strategy Assistant</h3>
<p className="mt-1 text-[10px] text-[var(--muted-foreground)]">Create & manage profiles with natural language</p>
<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 profiles, explain holdings, and diagnose blockers</p>
</div>
</div>
<Button
@ -489,7 +622,7 @@ export const ChatControl = ({ profiles, onApplyProfile }: ChatControlProps) => {
</div>
{/* Profile preview card */}
{msg.profileData && msg.action !== 'explain' && (() => {
{msg.profileData && isProfileMutationAction(msg.action) && (() => {
const activeProfileData = draftProfiles[msg.id] || msg.profileData;
const isEditing = editingIds.has(msg.id);
const activeRules = Array.isArray(activeProfileData?.strategy_config?.rules)
@ -737,7 +870,7 @@ export const ChatControl = ({ profiles, onApplyProfile }: ChatControlProps) => {
border: '1px solid var(--border)',
}}>
<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>
)}
@ -758,7 +891,7 @@ export const ChatControl = ({ profiles, onApplyProfile }: ChatControlProps) => {
value={input}
onChange={(e: ChangeEvent<HTMLTextAreaElement>) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Describe a strategy profile..."
placeholder="Ask for a profile, holding explanation, or reconciliation help..."
disabled={isLoading}
rows={2}
className="w-full rounded-xl py-3 pl-4 pr-12 outline-none disabled:opacity-50 transition-all resize-none"