feat(chat): add runtime copilot explanations
This commit is contained in:
parent
dfe876d49b
commit
f3dfe31d1f
@ -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',
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user