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 {
|
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 || {};
|
||||||
@ -2633,6 +2917,7 @@ export class ApiServer {
|
|||||||
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',
|
||||||
@ -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',
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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,7 +343,8 @@ export const ChatControl = ({ profiles, onApplyProfile }: ChatControlProps) => {
|
|||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
message: msg,
|
message: msg,
|
||||||
context: profiles.map(p => ({
|
context: {
|
||||||
|
profiles: profiles.map(p => ({
|
||||||
id: p.id,
|
id: p.id,
|
||||||
name: p.name,
|
name: p.name,
|
||||||
allocated_capital: p.allocated_capital,
|
allocated_capital: p.allocated_capital,
|
||||||
@ -222,6 +353,8 @@ export const ChatControl = ({ profiles, onApplyProfile }: ChatControlProps) => {
|
|||||||
is_active: p.is_active,
|
is_active: p.is_active,
|
||||||
strategy_config: p.strategy_config,
|
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"
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user