feat(chat): explain waiting trade signals

This commit is contained in:
root 2026-05-07 06:59:40 +00:00
parent f3dfe31d1f
commit 3f73310b4e
3 changed files with 115 additions and 10 deletions

View File

@ -234,6 +234,7 @@ type ChatAction =
| 'update_profile'
| 'explain'
| 'explain_position'
| 'explain_waiting'
| 'explain_blocker'
| 'summarize_reconciliation';
@ -252,6 +253,19 @@ interface ChatRuntimeContextPosition {
takeProfit?: number;
}
interface ChatRuntimeSignalContext {
symbol?: string;
profileId?: string;
profileName?: string;
signal?: string;
passed?: boolean;
reason?: string;
executionStatus?: 'EXECUTED' | 'BLOCKED' | 'SKIPPED';
executionCode?: string;
executionReason?: string;
orderId?: string;
}
interface ChatRuntimeContextOrder {
id?: string;
symbol?: string;
@ -319,6 +333,7 @@ interface ChatRuntimeContextHealth {
}
interface ChatRuntimeContextPayload {
signalContexts: ChatRuntimeSignalContext[];
positions: ChatRuntimeContextPosition[];
recentOrders: ChatRuntimeContextOrder[];
recentHistory: ChatRuntimeContextHistory[];
@ -1026,8 +1041,9 @@ export class ApiServer {
return {
profiles: context,
runtime: {
positions: [],
recentOrders: [],
positions: [],
signalContexts: [],
recentOrders: [],
recentHistory: [],
orderFailures: [],
operationalEvents: [],
@ -1045,6 +1061,7 @@ export class ApiServer {
profiles: Array.isArray(record.profiles) ? record.profiles : [],
runtime: {
positions: Array.isArray(runtime.positions) ? runtime.positions : [],
signalContexts: Array.isArray(runtime.signalContexts) ? runtime.signalContexts : [],
recentOrders: Array.isArray(runtime.recentOrders) ? runtime.recentOrders : [],
recentHistory: Array.isArray(runtime.recentHistory) ? runtime.recentHistory : [],
orderFailures: Array.isArray(runtime.orderFailures) ? runtime.orderFailures : [],
@ -1136,6 +1153,30 @@ export class ApiServer {
};
}
private buildWaitingExplanation(message: string, chatContext: ChatRequestContext): ChatResponsePayload | null {
const symbolHint = this.extractPrimaryMentionedSymbol(message);
const signals = (chatContext.runtime.signalContexts || []).filter((signal) =>
!symbolHint || String(signal.symbol || '').toUpperCase() === symbolHint
);
const target = signals.find((signal) => signal.executionStatus === 'BLOCKED' || signal.executionStatus === 'SKIPPED')
|| signals.find((signal) => signal.passed === false)
|| signals[0];
if (!target) return null;
const label = String(target.profileName || target.profileId || 'the active profile');
const summarySource = String(target.executionReason || target.reason || '').trim();
const signalDirection = String(target.signal || 'NEUTRAL').toUpperCase();
const executionStatus = String(target.executionStatus || (target.passed === false ? 'BLOCKED' : 'WAITING')).toUpperCase();
return {
action: 'explain_waiting',
summary: `${label} on ${target.symbol || 'the current symbol'} is currently ${executionStatus.toLowerCase()} with signal ${signalDirection}.${summarySource ? ` ${summarySource}` : ''}`,
reasoning: target.executionCode
? `Execution code: ${target.executionCode}. This means the bot evaluated the setup but did not proceed yet.${target.orderId ? ` Related order: ${target.orderId}.` : ''}`
: 'The bot is still evaluating rule alignment, risk constraints, or session eligibility before entering.'
};
}
private buildReconciliationSummary(chatContext: ChatRequestContext): ChatResponsePayload {
const health = chatContext.runtime.health || {};
const mismatchCount = Number(health.reconciliationMismatchCount || 0);
@ -1177,6 +1218,7 @@ export class ApiServer {
private buildLocalChatFallback(message: string, context: ChatRequestContext): ChatResponsePayload {
const lower = String(message || '').toLowerCase();
const asksForReconciliation = /\b(reconciliation|reconcile|quarantine|manual review|stale order|no[_\s-]*go|drift)\b/i.test(lower);
const asksForWaiting = /\b(why no trade|why.*no trade|why didn'?t.*trade|why did not.*trade|why.*enter|waiting for|no entry|no signal)\b/i.test(lower);
const asksForBlocker = /\b(why.*(blocked|stuck|failed|not execute|didn'?t execute|did not execute)|blocker|blocked|manual trader unavailable|unauthorized|forbidden|failed order)\b/i.test(lower);
const asksForPosition = /\b(position|holding|portfolio|pnl|profit|loss|exit|take profit|stop loss)\b/i.test(lower);
const asksForExplain = /(what|how|why|help|explain|suggest)/i.test(lower)
@ -1195,6 +1237,15 @@ export class ApiServer {
};
}
if (asksForWaiting) {
return this.buildWaitingExplanation(message, context)
|| {
action: 'explain_waiting',
summary: 'I did not find a recent rule-evaluation or execution-waiting signal in the scoped runtime context.',
reasoning: 'If you expected a trade, check the active profile, the relevant symbol signal panel, and any recent blocked or skipped execution reasons.'
};
}
if (asksForPosition) {
return this.buildPositionExplanation(message, context)
|| {
@ -2940,7 +2991,7 @@ AVAILABLE RULES (use these exact ruleId values):
PROFILE SCHEMA:
{
"action": "create_profile" | "update_profile" | "explain" | "explain_position" | "explain_blocker" | "summarize_reconciliation",
"action": "create_profile" | "update_profile" | "explain" | "explain_position" | "explain_waiting" | "explain_blocker" | "summarize_reconciliation",
"profile": {
"name": string,
"allocated_capital": number,
@ -2968,13 +3019,14 @@ RULES:
2. For "update_profile": include the profile "id" field and only change what the user asked for. Keep everything else the same.
3. For "explain": use it for general educational answers. No profile needed.
4. For "explain_position": use live position context and explain the current holding plus likely next step. No profile needed.
5. For "explain_blocker": explain the most relevant trade/order/operational blocker from runtime context. No profile needed.
6. For "summarize_reconciliation": summarize reconciliation health, mismatches, quarantines, and operational implications. No profile needed.
7. Only include "profile" when the action is create_profile or update_profile.
8. If runtime context is relevant, prefer concrete symbols, profile names, counts, and current state over generic advice.
9. Match the user's risk appetite: "conservative" = low risk (0.5-1%), low capital. "aggressive" = higher risk (2-5%), more rules enabled.
10. Always include at least TrendBiasRule and RiskManagementRule as enabled for safety.
11. Output ONLY valid JSON. No markdown, no backticks, no explanation outside the JSON.`;
5. For "explain_waiting": explain why a profile/symbol has not entered yet using signal/rule/execution context. No profile needed.
6. For "explain_blocker": explain the most relevant trade/order/operational blocker from runtime context. No profile needed.
7. For "summarize_reconciliation": summarize reconciliation health, mismatches, quarantines, and operational implications. No profile needed.
8. Only include "profile" when the action is create_profile or update_profile.
9. If runtime context is relevant, prefer concrete symbols, profile names, counts, and current state over generic advice.
10. Match the user's risk appetite: "conservative" = low risk (0.5-1%), low capital. "aggressive" = higher risk (2-5%), more rules enabled.
11. Always include at least TrendBiasRule and RiskManagementRule as enabled for safety.
12. Output ONLY valid JSON. No markdown, no backticks, no explanation outside the JSON.`;
try {
let aiResponse: string | null = null;
@ -3016,6 +3068,7 @@ RULES:
'update_profile',
'explain',
'explain_position',
'explain_waiting',
'explain_blocker',
'summarize_reconciliation'
]);

View File

@ -22,6 +22,32 @@ const profilesFixture = [
const botStateFixture: BotState = {
...DEFAULT_BOT_STATE,
symbols: {
'BTC/USDT': {
price: 61500,
change24h: 2.1,
changeToday: 1.2,
session: 'London',
volatility: 'High',
signal: 'BUY',
priceHistory: [],
rules: {},
profileSignals: {
p1: {
profileName: 'High Risk Scalper ⚡',
signal: 'BUY',
passed: false,
reason: 'Waiting for stronger rule alignment.',
execution: {
status: 'SKIPPED',
code: 'rule_ratio_not_met',
reason: 'Only 2 of 4 voting rules passed.',
}
}
},
indicators: {},
}
},
positions: [
{
id: 'pos-1',
@ -219,5 +245,14 @@ describe('ChatControl DOM flow', () => {
reconciliationNoGoTrades: 1,
})
);
expect(body.context.runtime.signalContexts).toEqual(
expect.arrayContaining([
expect.objectContaining({
symbol: 'BTC/USDT',
executionCode: 'rule_ratio_not_met',
executionStatus: 'SKIPPED',
})
])
);
});
});

View File

@ -47,6 +47,7 @@ export const BASE_QUICK_ACTIONS: QuickAction[] = [
{ label: 'Low Risk Profile', prompt: 'Create a low-risk profile that only trades BTC during London and NY sessions with $5000 capital' },
{ label: 'AI Momentum', prompt: 'Create a momentum strategy with AI analysis enabled, focusing on ETH/SOL with 2% risk' },
{ label: 'Explain holding', prompt: 'Explain my current open holding and what the bot is waiting for next.' },
{ label: 'Why no trade?', prompt: 'Why has no trade fired yet for my active profile? Explain what the bot is waiting for.' },
{ label: 'Explain blocker', prompt: 'Why is a trade or exit blocked right now? Explain the main blocker.' },
{ label: 'Recon summary', prompt: 'Summarize reconciliation health, stale orders, and any manual review risk right now.' },
{ label: 'What rules?', prompt: 'What rules should I use for a day trading strategy?' },
@ -103,6 +104,22 @@ const isProfileMutationAction = (action?: ChatAssistantAction): action is 'creat
action === 'create_profile' || action === 'update_profile';
const summarizeRuntimeContext = (botState: BotState) => ({
signalContexts: Object.entries(botState.symbols ?? {})
.flatMap(([symbol, symbolState]) =>
Object.entries(symbolState?.profileSignals || {}).map(([profileId, profileSignal]) => ({
symbol,
profileId,
profileName: profileSignal?.profileName,
signal: profileSignal?.signal,
passed: profileSignal?.passed,
reason: profileSignal?.reason,
executionStatus: profileSignal?.execution?.status,
executionCode: profileSignal?.execution?.code,
executionReason: profileSignal?.execution?.reason,
orderId: profileSignal?.execution?.orderId,
}))
)
.slice(0, 20),
positions: (botState.positions ?? []).slice(0, 10).map((position) => ({
symbol: position.symbol,
side: position.side,