diff --git a/backend/src/domain/operationalEvents.ts b/backend/src/domain/operationalEvents.ts index 2aa0620..a80ad4f 100644 --- a/backend/src/domain/operationalEvents.ts +++ b/backend/src/domain/operationalEvents.ts @@ -12,13 +12,16 @@ export type OperationalEventType = export type OperationalEventSeverity = 'INFO' | 'WARN' | 'ERROR'; -export interface OperationalEvent { - id: string; - type: OperationalEventType; - severity: OperationalEventSeverity; - message: string; - profileId?: string; - userId?: string; - symbol?: string; - timestamp: number; -} +export interface OperationalEvent { + id: string; + type: OperationalEventType; + severity: OperationalEventSeverity; + message: string; + profileId?: string; + userId?: string; + symbol?: string; + setupId?: string; + tradeId?: string; + orderId?: string; + timestamp: number; +} diff --git a/backend/src/index.ts b/backend/src/index.ts index affe68a..54fbbc4 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -246,7 +246,12 @@ async function main() { const livePrice = Number(apiServer.getState().symbols?.[symbol]?.price || 0); return Number.isFinite(livePrice) && livePrice > 0 ? livePrice : null; }; - const emitSimpleSetupEvent = (entry: ManualEntryRecord, message: string, severity: 'INFO' | 'WARN' | 'ERROR' = 'INFO') => { + const emitSimpleSetupEvent = ( + entry: ManualEntryRecord, + message: string, + severity: 'INFO' | 'WARN' | 'ERROR' = 'INFO', + metadata?: { tradeId?: string; orderId?: string } + ) => { observabilityService.emitEvent({ type: 'SIMPLE_SETUP_UPDATE', severity, @@ -254,6 +259,9 @@ async function main() { profileId: String(entry.profile_id || '').trim() || undefined, userId: String(entry.user_id || '').trim() || undefined, symbol: String(entry.symbol || '').trim().toUpperCase() || undefined, + setupId: String(entry.stock_instance_id || '').trim() || undefined, + tradeId: String(metadata?.tradeId || entry.linked_trade_id || '').trim() || undefined, + orderId: String(metadata?.orderId || '').trim() || undefined, }); }; const findSimpleEntryByTrade = async (userId: string | undefined, tradeId: string | undefined): Promise => { @@ -364,7 +372,8 @@ async function main() { linked_trade_id: result.tradeId || entry.linked_trade_id, }, `${symbol} entry submitted${result.adjustedQty ? ` for ${result.adjustedQty}` : ''}. Waiting for fill confirmation.`, - 'INFO' + 'INFO', + { tradeId: result.tradeId, orderId: result.orderId } ); continue; } @@ -455,7 +464,8 @@ async function main() { linked_trade_id: linkedTradeId || activePosition.tradeId, }, `${symbol} exit submitted after hitting the configured profit target. Waiting for fill confirmation.`, - 'INFO' + 'INFO', + { tradeId: linkedTradeId || activePosition.tradeId } ); } } catch (error: any) { @@ -513,7 +523,8 @@ async function main() { profile_id: simpleEntry.profile_id || event.profileId || null, }, `${event.symbol} entry filled. Monitoring for the configured profit exit.`, - 'INFO' + 'INFO', + { tradeId: event.tradeId, orderId: event.orderId } ); } return; @@ -531,7 +542,8 @@ async function main() { emitSimpleSetupEvent( simpleEntry, `${event.symbol} entry did not fill (${normalizedStatus}). The setup is armed again and waiting for the trigger.`, - normalizedStatus === 'rejected' || normalizedStatus === 'unknown' ? 'WARN' : 'INFO' + normalizedStatus === 'rejected' || normalizedStatus === 'unknown' ? 'WARN' : 'INFO', + { tradeId: event.tradeId, orderId: event.orderId } ); } return; @@ -550,7 +562,8 @@ async function main() { emitSimpleSetupEvent( simpleExitEntry, `${event.symbol} exit did not complete (${normalizedStatus}). The setup is back to monitoring the profit target.`, - normalizedStatus === 'rejected' || normalizedStatus === 'unknown' ? 'WARN' : 'INFO' + normalizedStatus === 'rejected' || normalizedStatus === 'unknown' ? 'WARN' : 'INFO', + { tradeId: event.tradeId, orderId: event.orderId } ); } return; @@ -596,7 +609,8 @@ async function main() { emitSimpleSetupEvent( simpleExitEntry, `${event.symbol} exit partially filled. Remaining size is still being monitored for the profit target.`, - 'INFO' + 'INFO', + { tradeId: event.tradeId, orderId: event.orderId } ); } return; @@ -613,7 +627,8 @@ async function main() { emitSimpleSetupEvent( simpleExitEntry, `${event.symbol} setup completed. The linked position is closed.`, - 'INFO' + 'INFO', + { tradeId: event.tradeId, orderId: event.orderId } ); } }; diff --git a/backend/src/services/observabilityService.ts b/backend/src/services/observabilityService.ts index 199a2ed..1cabef7 100644 --- a/backend/src/services/observabilityService.ts +++ b/backend/src/services/observabilityService.ts @@ -211,6 +211,9 @@ export class ObservabilityService { profileId?: string; userId?: string; symbol?: string; + setupId?: string; + tradeId?: string; + orderId?: string; }) { const event: OperationalEvent = { id: crypto.randomUUID(), diff --git a/web/src/hooks/useWebSocket.ts b/web/src/hooks/useWebSocket.ts index 8a3ac37..0db03f1 100644 --- a/web/src/hooks/useWebSocket.ts +++ b/web/src/hooks/useWebSocket.ts @@ -181,6 +181,9 @@ export interface BotState { profileId?: string; userId?: string; symbol?: string; + setupId?: string; + tradeId?: string; + orderId?: string; timestamp: number; }>; } diff --git a/web/src/views/SimpleView.tsx b/web/src/views/SimpleView.tsx index 720b466..38848a5 100644 --- a/web/src/views/SimpleView.tsx +++ b/web/src/views/SimpleView.tsx @@ -50,6 +50,8 @@ type SimpleRuntimeSnapshot = { orderId?: string; }; +type SimpleOperationalEvent = NonNullable['botState']['operationalEvents']>[number]; + type MarketPriceSource = 'live' | 'latest_close' | 'reference_price' | null; const SIMPLE_AUTO_PROFILE_NAME = 'Simple Auto Profile'; @@ -424,6 +426,46 @@ function formatSetupUpdatedAt(entry: ManualEntryPayload): string | null { return parsed.toLocaleString(); } +function formatEventTimestamp(timestamp?: number): string | null { + if (!timestamp || !Number.isFinite(timestamp)) return null; + const parsed = new Date(timestamp); + if (Number.isNaN(parsed.getTime())) return null; + return parsed.toLocaleString(); +} + +function deriveSimpleEventHistory( + entry: ManualEntryPayload, + operationalEvents: SimpleOperationalEvent[], +): SimpleOperationalEvent[] { + const setupId = String(entry.stock_instance_id || '').trim(); + const tradeId = String(entry.linked_trade_id || '').trim(); + const symbol = String(entry.symbol || '').trim().toUpperCase(); + + return operationalEvents + .filter((event): event is SimpleOperationalEvent => Boolean(event) && event.type === 'SIMPLE_SETUP_UPDATE') + .filter((event) => { + const eventSetupId = String(event.setupId || '').trim(); + const eventTradeId = String(event.tradeId || '').trim(); + const eventSymbol = String(event.symbol || '').trim().toUpperCase(); + if (setupId && eventSetupId === setupId) return true; + if (tradeId && eventTradeId === tradeId) return true; + return !setupId && !tradeId && !!symbol && eventSymbol === symbol; + }) + .sort((left, right) => right.timestamp - left.timestamp) + .slice(0, 5); +} + +function eventSeverityClasses(severity?: string) { + const normalized = String(severity || '').trim().toUpperCase(); + if (normalized === 'ERROR') { + return 'border-red-500/30 bg-red-500/10 text-red-700 dark:text-red-300'; + } + if (normalized === 'WARN') { + return 'border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300'; + } + return 'border-sky-500/30 bg-sky-500/10 text-sky-700 dark:text-sky-300'; +} + function normalizeSimpleEntries(entries: ManualEntryPayload[]): ManualEntryPayload[] { return entries .filter((entry) => String(entry.workflow_type || '').trim().toLowerCase() === 'simple') @@ -550,6 +592,10 @@ export function SimpleView() { return String(order.profileId || '').trim() === simpleAutoProfile.id; }); }, [botState?.orders, simpleAutoProfile?.id]); + const runtimeEvents = useMemo( + () => normalizeRuntimeArray(botState?.operationalEvents), + [botState?.operationalEvents], + ); const matchingHolding = useMemo( () => simpleHoldings.find((holding) => holding.symbol === normalizedSymbol) || null, @@ -1092,6 +1138,7 @@ export function SimpleView() { const runtimeSnapshot = deriveRuntimeSnapshot(entry, runtimeOrders, simpleHoldings); const nextActionText = describeNextAction(entry, runtimeSnapshot); const updatedAt = formatSetupUpdatedAt(entry); + const eventHistory = deriveSimpleEventHistory(entry, runtimeEvents); return (
@@ -1212,6 +1259,36 @@ export function SimpleView() { Next action:{' '} {nextActionText}
+ + {eventHistory.length > 0 && ( +
+
+
Recent activity
+
+ setup-level runtime history +
+
+
+ {eventHistory.map((event) => ( +
+
+
+ {String(event.severity || 'INFO').toUpperCase()} +
+
+ {formatEventTimestamp(event.timestamp) || 'Just now'} +
+
+
{event.message}
+
+ {event.tradeId ? Trade {event.tradeId.slice(0, 18)}… : null} + {event.orderId ? Order {event.orderId.slice(0, 12)}… : null} +
+
+ ))} +
+
+ )}
); })}