diff --git a/src/apps/operator-dashboard.mjs b/src/apps/operator-dashboard.mjs index 9ef886b..cdb5c6e 100644 --- a/src/apps/operator-dashboard.mjs +++ b/src/apps/operator-dashboard.mjs @@ -32,10 +32,12 @@ import { loadLatestPortfolioMetric, loadRecentAlertTransitions, loadRecentDepositStatuses, + loadRecentExecuteTradeCommands, + loadRecentExecutionResults, loadRecentTradeDecisions, loadRecentQuotes, - loadSuccessfulTradeSummary, - loadSuccessfulTradesPage, + loadSubmissionPage, + loadSubmissionSummary, } from '../lib/postgres.mjs'; const config = loadConfig(); @@ -79,10 +81,10 @@ const initialRecentQuotes = await safeSourceLoad( }), [], ); -const initialSuccessfulTradeSummary = await safeSourceLoad( - 'successful_trade_summary', - () => loadSuccessfulTradeSummary(pool), - { total: 0, last_successful_trade_at: null }, +const initialSubmissionSummary = await safeSourceLoad( + 'submission_summary', + () => loadSubmissionSummary(pool), + { total: 0, last_submission_at: null }, ); const initialMarketPrice = await safeSourceLoad( 'latest_market_price', @@ -100,8 +102,8 @@ const liveState = createDashboardLiveState({ recentQuotes: initialRecentQuotes, latestMarketPrice: initialMarketPrice, latestInventory: initialInventory, - successfulTradeCount: initialSuccessfulTradeSummary.total, - lastSuccessfulTradeAt: initialSuccessfulTradeSummary.last_successful_trade_at, + recentSubmissionCount: initialSubmissionSummary.total, + lastSubmissionAt: initialSubmissionSummary.last_submission_at, activeAlerts: initialServiceSnapshots.find((snapshot) => snapshot.service === 'ops-sentinel')?.state?.active_alerts || [], @@ -287,16 +289,16 @@ async function handleApiRequest({ req, res, url, auth }) { return sendJson(res, 200, payload); } - if (req.method === 'GET' && url.pathname === '/api/trades') { + if (req.method === 'GET' && (url.pathname === '/api/submissions' || url.pathname === '/api/trades')) { const page = Number(url.searchParams.get('page') || 1); const pageSize = Number( url.searchParams.get('page_size') || config.operatorDashboardTradePageSize, ); - const successfulTrades = await loadSuccessfulTradesPage(pool, { + const submissionPage = await loadSubmissionPage(pool, { page, pageSize, }); - return sendJson(res, 200, successfulTrades); + return sendJson(res, 200, submissionPage); } const controlMatch = req.method === 'POST' @@ -334,11 +336,13 @@ async function loadBootstrapPayload({ auth, page, pageSize }) { inventorySnapshot, marketPrice, recentQuotes, - successfulTradeSummary, - successfulTrades, + submissionSummary, + submissionPage, fundingObservations, recentDepositStatuses, recentTradeDecisions, + recentExecuteTradeCommands, + recentExecutionResults, recentAlertTransitions, serviceSnapshots, ] = await Promise.all([ @@ -354,14 +358,14 @@ async function loadBootstrapPayload({ auth, page, pageSize }) { sourceErrors, ), safeSourceLoad( - 'successful_trade_summary', - () => loadSuccessfulTradeSummary(pool), - { total: 0, last_successful_trade_at: null }, + 'submission_summary', + () => loadSubmissionSummary(pool), + { total: 0, last_submission_at: null }, sourceErrors, ), safeSourceLoad( - 'successful_trades', - () => loadSuccessfulTradesPage(pool, { + 'submission_page', + () => loadSubmissionPage(pool, { page, pageSize, }), @@ -387,6 +391,18 @@ async function loadBootstrapPayload({ auth, page, pageSize }) { [], sourceErrors, ), + safeSourceLoad( + 'recent_execute_trade_commands', + () => loadRecentExecuteTradeCommands(pool, { limit: 40 }), + [], + sourceErrors, + ), + safeSourceLoad( + 'recent_execution_results', + () => loadRecentExecutionResults(pool, { limit: 40 }), + [], + sourceErrors, + ), safeSourceLoad( 'recent_alert_transitions', () => loadRecentAlertTransitions(pool, { limit: 20 }), @@ -403,11 +419,13 @@ async function loadBootstrapPayload({ auth, page, pageSize }) { inventorySnapshot, marketPrice, recentQuotes, - successfulTrades, - successfulTradeSummary, + submissionPage, + submissionSummary, fundingObservations, recentDepositStatuses, recentTradeDecisions, + recentExecuteTradeCommands, + recentExecutionResults, recentAlertTransitions, serviceSnapshots, sourceErrors, diff --git a/src/core/operator-dashboard.mjs b/src/core/operator-dashboard.mjs index d323987..7040fda 100644 --- a/src/core/operator-dashboard.mjs +++ b/src/core/operator-dashboard.mjs @@ -227,8 +227,8 @@ export function createDashboardLiveState({ recentQuotes = [], latestMarketPrice = null, latestInventory = null, - successfulTradeCount = 0, - lastSuccessfulTradeAt = null, + recentSubmissionCount = 0, + lastSubmissionAt = null, activeAlerts = [], } = {}) { const state = { @@ -239,8 +239,8 @@ export function createDashboardLiveState({ recent_quotes: recentQuotes.slice(0, config.operatorDashboardQuoteLimit || DASHBOARD_LIVE_QUOTE_LIMIT), latest_market_price: latestMarketPrice?.payload || latestMarketPrice || null, latest_inventory: latestInventory?.payload || latestInventory || null, - successful_trade_count: Number(successfulTradeCount || 0), - last_successful_trade_at: lastSuccessfulTradeAt || null, + recent_submission_count: Number(recentSubmissionCount || 0), + last_submission_at: lastSubmissionAt || null, active_alerts: new Map(), }; @@ -306,8 +306,8 @@ export function applyDashboardLiveEvent(state, { topic, event }) { } case 'exec.trade_result': if (event.payload.status !== 'submitted') return []; - state.successful_trade_count += 1; - state.last_successful_trade_at = event.observed_at || event.ingested_at || new Date().toISOString(); + state.recent_submission_count += 1; + state.last_submission_at = event.observed_at || event.ingested_at || new Date().toISOString(); return [{ type: 'status_bar.updated', status_bar: buildLiveStatusBar(state), @@ -324,11 +324,13 @@ export function buildDashboardBootstrap({ inventorySnapshot, marketPrice, recentQuotes, - successfulTrades, - successfulTradeSummary, + submissionPage, + submissionSummary, fundingObservations, recentDepositStatuses, recentTradeDecisions, + recentExecuteTradeCommands, + recentExecutionResults, recentAlertTransitions, serviceSnapshots, sourceErrors = [], @@ -346,7 +348,7 @@ export function buildDashboardBootstrap({ )); const profitability = buildProfitabilitySummary({ metric: portfolioMetric, - successfulTradeSummary, + submissionSummary, }); const balances = buildBalanceSummary({ inventorySnapshot, @@ -359,9 +361,9 @@ export function buildDashboardBootstrap({ recentDepositStatuses, liquidityState: servicesByName['liquidity-manager']?.state || {}, }); - const tradesPage = normalizeSuccessfulTradesPage({ + const normalizedSubmissionPage = normalizeSubmissionPage({ config, - successfulTrades, + submissionPage, }); return { @@ -385,19 +387,22 @@ export function buildDashboardBootstrap({ config, liquidityState: servicesByName['liquidity-manager']?.state || {}, }), - trade_asset_changes: buildTradeAssetChanges({ + recent_submission_terms: buildRecentSubmissionTerms({ config, - trades: tradesPage.items, + submissions: normalizedSubmissionPage.items, }), recent_quotes: (recentQuotes || []).slice(0, config.operatorDashboardQuoteLimit || DASHBOARD_LIVE_QUOTE_LIMIT), - successful_trades: tradesPage, + submission_ledger: normalizedSubmissionPage, controls: listDashboardControls({ page: 'funds' }), caveats: profitability.caveats, }, strategy: buildStrategySummary({ servicesByName, activeAlerts, + recentQuotes, recentTradeDecisions, + recentExecuteTradeCommands, + recentExecutionResults, }), system: buildSystemSummary({ servicesByName, @@ -407,7 +412,7 @@ export function buildDashboardBootstrap({ }; } -export function buildProfitabilitySummary({ metric, successfulTradeSummary } = {}) { +export function buildProfitabilitySummary({ metric, submissionSummary } = {}) { const externalCashFlows = metric?.payload?.external_cash_flows || {}; const externalFlowCount = Number(externalCashFlows.flow_count || 0); const externalFlowAdjusted = externalFlowCount > 0; @@ -428,8 +433,8 @@ export function buildProfitabilitySummary({ metric, successfulTradeSummary } = { external_withdrawal_count: Number(externalCashFlows.withdrawal_count || 0), latest_external_flow_at: externalCashFlows.latest_effective_at || null, net_external_flow_value_eure: externalCashFlows.net_value_eure_at_flow_time || '0', - recent_trade_count: successfulTradeSummary?.total ?? metric?.payload?.result_count ?? 0, - last_successful_trade_at: successfulTradeSummary?.last_successful_trade_at || null, + recent_submission_count: submissionSummary?.total ?? 0, + last_submission_at: submissionSummary?.last_submission_at || null, caveats: [ 'Portfolio PnL is truthful to the current durable inventory and reference price path.', 'Fees and per-trade realized net settlement deltas are not fully tracked yet.', @@ -506,8 +511,8 @@ export function buildLiveStatusBar(state) { }), active_alert_count: state.active_alerts.size, highest_alert_severity: highestAlertSeverity([...state.active_alerts.values()]), - recent_trade_count: state.successful_trade_count, - last_successful_trade_at: state.last_successful_trade_at, + recent_submission_count: state.recent_submission_count, + last_submission_at: state.last_submission_at, }; } @@ -534,8 +539,8 @@ function buildStatusBar({ strategy_armed: servicesByName['strategy-engine']?.state?.armed ?? null, executor_armed: servicesByName['trade-executor']?.state?.armed ?? null, current_total_portfolio_value_eure: profitability.current_total_portfolio_value_eure, - recent_trade_count: profitability.recent_trade_count, - last_successful_trade_at: profitability.last_successful_trade_at, + recent_submission_count: profitability.recent_submission_count, + last_submission_at: profitability.last_submission_at, }; } @@ -675,37 +680,316 @@ function buildRecentWithdrawals({ config, liquidityState }) { }); } -function buildTradeAssetChanges({ config, trades }) { - return (trades || []).slice(0, 10).map((trade) => { - const assetIn = config.assetRegistry.get(trade.asset_in); - const assetOut = config.assetRegistry.get(trade.asset_out); +function buildRecentSubmissionTerms({ config, submissions }) { + return (submissions || []).slice(0, 10).map((submission) => { + const assetIn = config.assetRegistry.get(submission.asset_in); + const assetOut = config.assetRegistry.get(submission.asset_out); return { - observed_at: trade.observed_at, - quote_id: trade.quote_id, - request_kind: trade.request_kind, - asset_in: trade.asset_in, - asset_in_symbol: assetIn?.symbol || trade.asset_in, - amount_in_units: trade.amount_in, - amount_in: formatUnits(trade.amount_in || '0', assetIn?.decimals || 0), - asset_out: trade.asset_out, - asset_out_symbol: assetOut?.symbol || trade.asset_out, - amount_out_units: trade.amount_out, - amount_out: formatUnits(trade.amount_out || '0', assetOut?.decimals || 0), + observed_at: submission.observed_at, + quote_id: submission.quote_id, + request_kind: submission.request_kind, + asset_in: submission.asset_in, + asset_in_symbol: assetIn?.symbol || submission.asset_in, + amount_in_units: submission.amount_in, + amount_in: formatUnits(submission.amount_in || '0', assetIn?.decimals || 0), + asset_out: submission.asset_out, + asset_out_symbol: assetOut?.symbol || submission.asset_out, + amount_out_units: submission.amount_out, + amount_out: formatUnits(submission.amount_out || '0', assetOut?.decimals || 0), }; }); } -function normalizeSuccessfulTradesPage({ config, successfulTrades }) { +function normalizeSubmissionPage({ config, submissionPage }) { return { - ...successfulTrades, - items: (successfulTrades?.items || []).map((trade) => normalizeTradeForUi({ + ...submissionPage, + items: (submissionPage?.items || []).map((trade) => normalizeTradeForUi({ config, trade, })), }; } -function buildStrategySummary({ servicesByName, activeAlerts, recentTradeDecisions = [] }) { +const LIFECYCLE_TONE_BY_STATE = { + observed: 'info', + evaluated: 'healthy', + command_emitted: 'info', + rejected: 'warning', + blocked: 'warning', + failed: 'critical', + submitted: 'healthy', + awaiting_outcome: 'info', + not_filled: 'warning', + completed: 'healthy', +}; + +const HUMAN_REASON_TEXT = { + actionable: 'Strategy approved the quote.', + below_edge_threshold: 'Below edge threshold.', + duplicate_quote_id: 'Duplicate quote id.', + executor_disarmed: 'Executor is disarmed.', + executor_paused: 'Executor intake is paused.', + inventory_unavailable: 'Inventory unavailable.', + pending_deposit_not_credited: 'Funding is not credited yet.', + quote_expired: 'Quote expired.', + quote_response_ack: 'Quote response acknowledged by the relay.', + quote_response_ok: 'Quote response accepted by the relay.', + reason_unknown: 'Reason not recorded.', + stale_reference_price: 'Reference price is stale.', + strategy_disarmed: 'Strategy is disarmed.', + submission_failed: 'Submission failed.', + unsupported_pair: 'Unsupported pair.', +}; + +const COMPLETED_OUTCOME_STATUSES = new Set(['completed', 'filled', 'settled', 'finalized']); +const NOT_FILLED_OUTCOME_STATUSES = new Set(['expired', 'not_filled', 'cancelled']); + +export function deriveQuoteLifecycleRows({ + recentQuotes = [], + recentTradeDecisions = [], + recentExecuteTradeCommands = [], + recentExecutionResults = [], + limit = 20, +} = {}) { + const rowsByKey = new Map(); + + for (const quote of recentQuotes || []) { + const row = ensureLifecycleRow(rowsByKey, quote?.quote_id || `quote:${quote?.observed_at || quote?.ingested_at || rowsByKey.size}`); + mergeLifecycleEvidence(row, { + quote_id: quote?.quote_id || null, + pair: quote?.pair || null, + request_kind: quote?.request_kind || null, + quote_observed_at: quote?.observed_at || quote?.ingested_at || null, + }); + } + + for (const decisionEntry of recentTradeDecisions || []) { + const decision = normalizeDecision({ + ...(decisionEntry?.payload || decisionEntry || {}), + decision_at: + decisionEntry?.payload?.decision_at + || decisionEntry?.decision_at + || decisionEntry?.observed_at + || decisionEntry?.ingested_at + || null, + }); + if (!decision) continue; + const row = ensureLifecycleRow(rowsByKey, decision.quote_id || decision.decision_id || `decision:${decision.decision_at || rowsByKey.size}`); + mergeLifecycleEvidence(row, { + quote_id: decision.quote_id, + decision_id: decision.decision_id, + pair: decision.pair, + direction: decision.direction, + request_kind: decision.request_kind, + gross_edge_pct: decision.gross_edge_pct, + eure_notional: decision.eure_notional, + decision, + decision_at: decision.decision_at || null, + }); + } + + for (const commandEntry of recentExecuteTradeCommands || []) { + const command = normalizeCommand({ + ...(commandEntry?.payload || commandEntry || {}), + command_at: + commandEntry?.command_at + || commandEntry?.observed_at + || commandEntry?.ingested_at + || null, + }); + if (!command) continue; + const row = ensureLifecycleRow(rowsByKey, command.quote_id || command.decision_id || command.command_id || `command:${command.command_at || rowsByKey.size}`); + mergeLifecycleEvidence(row, { + quote_id: command.quote_id, + decision_id: command.decision_id, + command_id: command.command_id, + pair: command.pair, + direction: command.direction, + request_kind: command.request_kind, + command, + command_at: command.command_at || null, + }); + } + + for (const execution of recentExecutionResults || []) { + const row = ensureLifecycleRow(rowsByKey, execution?.quote_id || execution?.decision_id || execution?.command_id || `execution:${execution?.result_at || rowsByKey.size}`); + mergeLifecycleEvidence(row, { + quote_id: execution?.quote_id || null, + decision_id: execution?.decision_id || null, + command_id: execution?.command_id || null, + pair: execution?.pair || null, + execution, + execution_result_at: execution?.result_at || null, + }); + } + + return [...rowsByKey.values()] + .map((row) => finalizeLifecycleRow(row)) + .sort((left, right) => sortTimestamps(right.latest_stage_at, left.latest_stage_at)) + .slice(0, limit); +} + +function ensureLifecycleRow(rowsByKey, key) { + if (!rowsByKey.has(key)) { + rowsByKey.set(key, { + quote_id: null, + decision_id: null, + command_id: null, + pair: null, + direction: null, + request_kind: null, + gross_edge_pct: null, + eure_notional: null, + quote_observed_at: null, + decision_at: null, + command_at: null, + execution_result_at: null, + decision: null, + command: null, + execution: null, + }); + } + return rowsByKey.get(key); +} + +function mergeLifecycleEvidence(row, next) { + for (const [key, value] of Object.entries(next || {})) { + if (value != null && row[key] == null) { + row[key] = value; + } + } + if (next?.decision) row.decision = next.decision; + if (next?.command) row.command = next.command; + if (next?.execution) row.execution = next.execution; +} + +function finalizeLifecycleRow(row) { + const decision = row.decision || null; + const execution = row.execution || null; + const outcomeStatus = normalizeLifecycleToken(execution?.outcome_status || execution?.outcome_reason || null); + let lifecycle_state = 'observed'; + let lifecycle_label = 'Observed'; + let reason_code = 'reason_unknown'; + let reason_text = 'Strategy evaluation not recorded yet.'; + + if (outcomeStatus && COMPLETED_OUTCOME_STATUSES.has(outcomeStatus)) { + lifecycle_state = 'completed'; + lifecycle_label = 'Completed'; + reason_code = normalizeLifecycleToken(execution?.outcome_reason || execution?.result_code || 'completed'); + reason_text = humanizeReasonCode(reason_code, 'Completed'); + } else if (outcomeStatus && NOT_FILLED_OUTCOME_STATUSES.has(outcomeStatus)) { + lifecycle_state = 'not_filled'; + lifecycle_label = 'Not filled'; + reason_code = normalizeLifecycleToken(execution?.outcome_reason || execution?.result_code || outcomeStatus); + reason_text = humanizeReasonCode(reason_code, 'Not filled'); + } else if (execution?.status === 'submitted') { + lifecycle_state = 'submitted'; + lifecycle_label = 'Submitted'; + reason_code = normalizeLifecycleToken(execution?.result_code || 'awaiting_outcome'); + reason_text = `${humanizeReasonCode(reason_code, 'Submitted to the relay.')} No durable venue outcome is recorded yet.`; + } else if (execution?.status === 'failed') { + lifecycle_state = 'failed'; + lifecycle_label = 'Submission failed'; + reason_code = normalizeLifecycleToken(execution?.result_code || 'submission_failed'); + reason_text = buildExecutionFailureText(execution, reason_code); + } else if (execution?.status === 'rejected') { + lifecycle_state = 'blocked'; + lifecycle_label = 'Blocked before submit'; + reason_code = normalizeLifecycleToken(execution?.result_code || 'reason_unknown'); + reason_text = buildExecutorBlockText(execution, reason_code); + } else if (row.command_id || row.command_at) { + lifecycle_state = 'command_emitted'; + lifecycle_label = 'Awaiting executor'; + reason_code = 'awaiting_executor'; + reason_text = 'Execute command recorded, but no executor result is stored yet.'; + } else if (decision?.decision === 'rejected') { + lifecycle_state = 'rejected'; + lifecycle_label = 'Rejected by strategy'; + reason_code = normalizeLifecycleToken(decision?.decision_reason || 'reason_unknown'); + reason_text = humanizeReasonCode(reason_code, 'Strategy rejected the quote.'); + } else if (decision?.decision) { + lifecycle_state = 'evaluated'; + lifecycle_label = 'Approved by strategy'; + reason_code = normalizeLifecycleToken(decision?.decision_reason || 'actionable'); + reason_text = 'Strategy approved the quote, but no durable execute command is recorded yet.'; + } + + return { + ...row, + lifecycle_state, + lifecycle_label, + lifecycle_tone: LIFECYCLE_TONE_BY_STATE[lifecycle_state] || 'unknown', + reason_code, + reason_text, + latest_stage_at: + row.execution_result_at + || row.command_at + || row.decision_at + || row.quote_observed_at + || null, + stage_details: { + quote_observed_at: row.quote_observed_at, + decision_at: row.decision_at, + command_at: row.command_at, + execution_result_at: row.execution_result_at, + strategy_decision: decision?.decision || null, + strategy_reason_code: decision?.decision_reason || null, + execution_status: execution?.status || null, + execution_result_code: execution?.result_code || null, + execution_outcome_status: execution?.outcome_status || null, + }, + }; +} + +function buildExecutionFailureText(execution, reasonCode) { + const base = humanizeReasonCode(reasonCode, 'Submission failed.'); + if (execution?.error_message) return `${base} ${execution.error_message}`; + if (execution?.note) return `${base} ${execution.note}`; + return base; +} + +function buildExecutorBlockText(execution, reasonCode) { + if (execution?.note) return execution.note; + return humanizeReasonCode(reasonCode, 'Executor blocked the submission.'); +} + +function humanizeReasonCode(code, fallback = 'Reason not recorded.') { + const normalized = normalizeLifecycleToken(code || ''); + if (!normalized) return fallback; + return HUMAN_REASON_TEXT[normalized] || normalized.replaceAll('_', ' '); +} + +function normalizeLifecycleToken(value) { + return String(value || '') + .trim() + .toLowerCase() + .replace(/[\s-]+/g, '_'); +} + +function normalizeCommand(command) { + if (!command) return null; + return { + command_id: command.command_id || null, + decision_id: command.decision_id || null, + execution_key: command.execution_key || null, + quote_id: command.quote_id || null, + pair: command.pair || null, + direction: command.direction || null, + request_kind: command.request_kind || null, + asset_in: command.asset_in || null, + asset_out: command.asset_out || null, + command_at: command.command_at || command.observed_at || command.ingested_at || null, + }; +} + +function buildStrategySummary({ + servicesByName, + activeAlerts, + recentQuotes = [], + recentTradeDecisions = [], + recentExecuteTradeCommands = [], + recentExecutionResults = [], +}) { const strategyState = servicesByName['strategy-engine']?.state || {}; const executorState = servicesByName['trade-executor']?.state || {}; const durableDecisionsById = new Map( @@ -740,6 +1024,13 @@ function buildStrategySummary({ servicesByName, activeAlerts, recentTradeDecisio || durableDecisionsById.get(strategyState.latest_decision?.decision_id)?.decision_at || null, }); + const lifecycleRows = deriveQuoteLifecycleRows({ + recentQuotes, + recentTradeDecisions, + recentExecuteTradeCommands, + recentExecutionResults, + limit: 20, + }); return { strategy_state: { @@ -751,6 +1042,7 @@ function buildStrategySummary({ servicesByName, activeAlerts, recentTradeDecisio recent_decisions: recentDecisions.length ? recentDecisions : [...durableDecisionsById.values()].slice(0, 20), + recent_lifecycle_rows: lifecycleRows, skipped_counts: strategyState.skipped_counts || {}, durable_control_state: strategyState.durable_control_state || null, }, diff --git a/src/lib/postgres.mjs b/src/lib/postgres.mjs index 3f460ac..b4a2a23 100644 --- a/src/lib/postgres.mjs +++ b/src/lib/postgres.mjs @@ -292,22 +292,22 @@ export async function loadRecentQuotes(pool, { limit = 10 } = {}) { return result.rows.map(normalizeRecentQuoteRow); } -export async function loadSuccessfulTradeSummary(pool) { +export async function loadSubmissionSummary(pool) { const result = await pool.query(` SELECT COUNT(*)::INT AS total, - MAX(COALESCE(observed_at, ingested_at)) AS last_successful_trade_at + MAX(COALESCE(observed_at, ingested_at)) AS last_submission_at FROM trade_execution_results WHERE payload->>'status' = 'submitted' `); return { total: Number(result.rows[0]?.total || 0), - last_successful_trade_at: toIsoTimestamp(result.rows[0]?.last_successful_trade_at), + last_submission_at: toIsoTimestamp(result.rows[0]?.last_submission_at), }; } -export async function loadSuccessfulTradesPage(pool, { page = 1, pageSize = 20 } = {}) { +export async function loadSubmissionPage(pool, { page = 1, pageSize = 20 } = {}) { const normalizedPage = Math.max(1, Number(page) || 1); const normalizedPageSize = Math.max(1, Number(pageSize) || 20); const offset = (normalizedPage - 1) * normalizedPageSize; @@ -347,10 +347,48 @@ export async function loadSuccessfulTradesPage(pool, { page = 1, pageSize = 20 } page_size: normalizedPageSize, total, total_pages: total > 0 ? Math.ceil(total / normalizedPageSize) : 1, - items: rowsResult.rows.map(normalizeSuccessfulTradeRow), + items: rowsResult.rows.map(normalizeSubmissionRow), }; } +export async function loadRecentExecutionResults(pool, { limit = 20 } = {}) { + const result = await pool.query( + ` + SELECT + r.observed_at AS result_observed_at, + r.ingested_at AS result_ingested_at, + r.payload AS result_payload, + c.ingested_at AS command_ingested_at, + c.payload AS command_payload, + d.payload AS decision_payload + FROM trade_execution_results r + LEFT JOIN execute_trade_commands c + ON c.decision_key = r.decision_key + LEFT JOIN trade_decisions d + ON d.decision_key = COALESCE(c.payload->>'decision_id', r.payload->>'decision_id') + ORDER BY COALESCE(r.observed_at, r.ingested_at) DESC + LIMIT $1 + `, + [limit], + ); + + return result.rows.map((row) => normalizeExecutionResultRow(row)); +} + +export async function loadRecentExecuteTradeCommands(pool, { limit = 20 } = {}) { + const result = await pool.query( + ` + SELECT observed_at, ingested_at, payload + FROM execute_trade_commands + ORDER BY COALESCE(observed_at, ingested_at) DESC + LIMIT $1 + `, + [limit], + ); + + return result.rows.map((row) => normalizeExecuteTradeCommandRow(row)); +} + export async function loadCurrentFundingObservations(pool) { const result = await pool.query(` SELECT DISTINCT ON (decision_key) @@ -640,7 +678,7 @@ function normalizeRecentQuoteRow(row) { }; } -function normalizeSuccessfulTradeRow(row) { +function normalizeSubmissionRow(row) { const resultPayload = row.result_payload || {}; const commandPayload = row.command_payload || {}; const decisionPayload = row.decision_payload || {}; @@ -672,6 +710,60 @@ function normalizeSuccessfulTradeRow(row) { }; } +function normalizeExecuteTradeCommandRow(row) { + const payload = row.payload || {}; + return { + command_id: payload.command_id || null, + decision_id: payload.decision_id || null, + execution_key: payload.execution_key || null, + quote_id: payload.quote_id || null, + pair: payload.pair || null, + direction: payload.direction || null, + request_kind: payload.request_kind || null, + asset_in: payload.asset_in || null, + asset_out: payload.asset_out || null, + amount_in: resolveTradeAmount(payload, 'amount_in'), + amount_out: resolveTradeAmount(payload, 'amount_out'), + observed_at: toIsoTimestamp(row.observed_at || row.ingested_at), + ingested_at: toIsoTimestamp(row.ingested_at), + }; +} + +function normalizeExecutionResultRow(row) { + const resultPayload = row.result_payload || {}; + const commandPayload = row.command_payload || {}; + const decisionPayload = row.decision_payload || {}; + + return { + command_id: resultPayload.command_id || commandPayload.command_id || null, + decision_id: + commandPayload.decision_id + || resultPayload.decision_id + || decisionPayload.decision_id + || null, + execution_key: resultPayload.execution_key || commandPayload.execution_key || null, + quote_id: resultPayload.quote_id || commandPayload.quote_id || decisionPayload.quote_id || null, + pair: resultPayload.pair || commandPayload.pair || decisionPayload.pair || null, + command_at: toIsoTimestamp(row.command_ingested_at), + result_at: toIsoTimestamp(row.result_observed_at || row.result_ingested_at), + status: resultPayload.status || null, + result_code: resultPayload.result_code || null, + outcome_status: + resultPayload.outcome_status + || resultPayload.venue_outcome_status + || resultPayload.trade_outcome_status + || null, + outcome_reason: + resultPayload.outcome_reason + || resultPayload.venue_outcome_reason + || resultPayload.trade_outcome_reason + || null, + venue_response: resultPayload.venue_response || null, + error_message: resultPayload.error?.message || null, + note: resultPayload.note || null, + }; +} + function resolveTradeAmount(commandPayload, field) { const quoteOutputField = commandPayload?.quote_output?.[field]; const proposedField = commandPayload?.[`proposed_${field}`]; diff --git a/src/operator-dashboard/static/App.jsx b/src/operator-dashboard/static/App.jsx index 58d4918..5841aa1 100644 --- a/src/operator-dashboard/static/App.jsx +++ b/src/operator-dashboard/static/App.jsx @@ -37,12 +37,12 @@ export default function App() { async function loadTradesPage(page) { if (!Number.isFinite(page) || page < 1) return; - dispatch({ type: 'notice.changed', notice: 'Loading trade history page...' }); + dispatch({ type: 'notice.changed', notice: 'Loading submission history page...' }); dispatch({ type: 'error.changed', error: null }); try { - const successfulTrades = await fetchJson(`/api/trades?page=${page}&page_size=${TRADE_PAGE_SIZE}`); - dispatch({ type: 'trades.loaded', successfulTrades }); + const submissionLedger = await fetchJson(`/api/submissions?page=${page}&page_size=${TRADE_PAGE_SIZE}`); + dispatch({ type: 'submissionLedger.loaded', submissionLedger }); dispatch({ type: 'notice.changed', notice: null }); } catch (error) { dispatch({ type: 'error.changed', error: error.message }); @@ -67,7 +67,7 @@ export default function App() { dispatch({ type: 'notice.changed', notice: `${action} completed` }); if (reload) { - const page = state.dashboard?.funds?.successful_trades?.page || 1; + const page = state.dashboard?.funds?.submission_ledger?.page || 1; await loadBootstrap(page); } } catch (error) { diff --git a/src/operator-dashboard/static/components/StatusBar.jsx b/src/operator-dashboard/static/components/StatusBar.jsx index 0ff1894..1503a55 100644 --- a/src/operator-dashboard/static/components/StatusBar.jsx +++ b/src/operator-dashboard/static/components/StatusBar.jsx @@ -26,8 +26,8 @@ export default function StatusBar({ status, websocketState }) { ['Alerts', `${status.active_alert_count || 0} ${status.highest_alert_severity ? `(${status.highest_alert_severity})` : ''}`.trim()], ['Strategy Armed', formatBoolean(status.strategy_armed)], ['Executor Armed', formatBoolean(status.executor_armed)], - [SUBMISSION_COPY.statusTileLabel, `${status.recent_trade_count || 0} ${SUBMISSION_COPY.statusTileValueSuffix}`], - [SUBMISSION_COPY.lastStatusTileLabel, formatTimestamp(status.last_successful_trade_at)], + [SUBMISSION_COPY.statusTileLabel, `${status.recent_submission_count || 0} ${SUBMISSION_COPY.statusTileValueSuffix}`], + [SUBMISSION_COPY.lastStatusTileLabel, formatTimestamp(status.last_submission_at)], ]; return ( diff --git a/src/operator-dashboard/static/pages/FundsPage.jsx b/src/operator-dashboard/static/pages/FundsPage.jsx index 9743602..f2e92c9 100644 --- a/src/operator-dashboard/static/pages/FundsPage.jsx +++ b/src/operator-dashboard/static/pages/FundsPage.jsx @@ -370,7 +370,7 @@ export default function FundsPage({ lastControlResult, }) { const profitability = funds.profitability; - const trades = funds.successful_trades; + const submissionLedger = funds.submission_ledger; const controlState = funds.funding.control_state || {}; const externalFlowAdjusted = profitability.external_flow_adjusted; const externalFlowCount = profitability.external_flow_count || 0; @@ -448,8 +448,8 @@ export default function FundsPage({ />
@@ -573,7 +573,7 @@ export default function FundsPage({

{SUBMISSION_COPY.termsTitle}

- + @@ -585,22 +585,22 @@ export default function FundsPage({
{SUBMISSION_COPY.ledgerSubtitle}
- +
-
{SUBMISSION_COPY.ledgerCountLabel(trades.page, trades.total_pages, trades.total)}
+
{SUBMISSION_COPY.ledgerCountLabel(submissionLedger.page, submissionLedger.total_pages, submissionLedger.total)}
+
+ ); +} + +function LifecycleTable({ items }) { + if (!items?.length) return No quote lifecycle evidence has been observed yet.; return ( - +
- - + + + - {items.map((item, index) => { - const verdict = describeStrategyDecision(item.decision); - return ( - - - - - - - - - ); - })} + {items.map((item, index) => ( + + + + + + + + + + ))}
AtStrategy verdictPairLifecycle ReasonPairTrace Edge % Notional
{formatTimestamp(item.decision_at)}{truncateMiddle(item.pair || '', 32)}{item.decision_reason || ''} 0 ? 'value-positive' : Number(item.gross_edge_pct) < 0 ? 'value-negative' : ''}>{item.gross_edge_pct || ''}{item.eure_notional || ''}
{formatTimestamp(item.latest_stage_at)} +
{item.reason_text}
+
{item.reason_code || 'reason_unknown'}
+
{truncateMiddle(item.pair || '', 32)} + + + + 0 ? 'value-positive' : Number(item.gross_edge_pct) < 0 ? 'value-negative' : ''}>{item.gross_edge_pct || ''}{item.eure_notional || ''}
@@ -71,7 +79,7 @@ export default function StrategyPage({ strategy }) {
Trading state

Strategy and executor

- This page shows whether the system is actively deciding and submitting without offering risky arming controls. + This page shows the strongest durable claim for each recent quote, from strategy evaluation through executor submission evidence.
@@ -80,26 +88,26 @@ export default function StrategyPage({ strategy }) { - + -
+
-
Decision flow
-

Recent decisions and skip reasons

+
Lifecycle truth
+

Recent quote decisions and execution evidence

- Rejected by strategy means the strategy engine did not emit a trade command. Venue or executor rejections appear elsewhere. + Strategy rejection, executor blocking, submission failure, and submission success are separated. Submission never implies trade completion or realized asset movement.
- +
-
+
Guard rails
diff --git a/src/operator-dashboard/static/state/dashboardReducer.js b/src/operator-dashboard/static/state/dashboardReducer.js index 327fe3f..cedf54f 100644 --- a/src/operator-dashboard/static/state/dashboardReducer.js +++ b/src/operator-dashboard/static/state/dashboardReducer.js @@ -41,12 +41,12 @@ function applySocketMessage(dashboard, payload, session) { ...dashboard.funds, profitability: { ...dashboard.funds.profitability, - recent_trade_count: - payload.status_bar.recent_trade_count - ?? dashboard.funds.profitability.recent_trade_count, - last_successful_trade_at: - payload.status_bar.last_successful_trade_at - || dashboard.funds.profitability.last_successful_trade_at, + recent_submission_count: + payload.status_bar.recent_submission_count + ?? dashboard.funds.profitability.recent_submission_count, + last_submission_at: + payload.status_bar.last_submission_at + || dashboard.funds.profitability.last_submission_at, }, }, }, @@ -91,14 +91,14 @@ export function dashboardReducer(state, action) { dashboard: action.dashboard, page: state.page || action.dashboard.default_page || 'funds', }; - case 'trades.loaded': + case 'submissionLedger.loaded': return { ...state, dashboard: { ...state.dashboard, funds: { ...state.dashboard.funds, - successful_trades: action.successfulTrades, + submission_ledger: action.submissionLedger, }, }, }; diff --git a/src/operator-dashboard/static/styles.css b/src/operator-dashboard/static/styles.css index cb7c985..cf662be 100644 --- a/src/operator-dashboard/static/styles.css +++ b/src/operator-dashboard/static/styles.css @@ -254,6 +254,17 @@ select { grid-template-columns: 1fr; } +.strategy-layout { + display: grid; + gap: 18px; + grid-template-columns: minmax(0, 1.75fr) minmax(320px, 0.85fr); + align-items: start; +} + +.strategy-side-panel { + height: 100%; +} + .table-wrap { overflow-x: auto; border: 1px solid var(--line); @@ -322,12 +333,18 @@ pre { table.decision-table td:nth-child(2), table.decision-table th:nth-child(2) { - width: 34%; + width: 180px; + white-space: nowrap; } table.decision-table td:nth-child(3), table.decision-table th:nth-child(3) { - width: 36%; + width: 28%; +} + +table.lifecycle-table td:nth-child(5), +table.lifecycle-table th:nth-child(5) { + width: 34%; } .pills, @@ -388,6 +405,29 @@ table.decision-table th:nth-child(3) { margin-top: 12px; } +.trace-row { + display: flex; + align-items: flex-start; + gap: 8px; + margin-bottom: 6px; +} + +.trace-row:last-child { + margin-bottom: 0; +} + +.trace-id { + flex: 1; + min-width: 0; + overflow-wrap: anywhere; +} + +.trace-copy-button { + padding: 4px 8px; + font-size: 0.75rem; + line-height: 1.2; +} + .form-grid { display: grid; gap: 12px; @@ -465,7 +505,8 @@ table.decision-table th:nth-child(3) { @media (max-width: 1100px) { .app-grid, - .split { + .split, + .strategy-layout { grid-template-columns: 1fr; } diff --git a/test/operator-dashboard.test.mjs b/test/operator-dashboard.test.mjs index c31b1a5..27ead4f 100644 --- a/test/operator-dashboard.test.mjs +++ b/test/operator-dashboard.test.mjs @@ -6,6 +6,7 @@ import { buildDashboardBootstrap, buildProfitabilitySummary, createDashboardLiveState, + deriveQuoteLifecycleRows, resolveDashboardControl, } from '../src/core/operator-dashboard.mjs'; import { @@ -52,9 +53,9 @@ test('profitability summary separates baseline, hold, market move, and trading c baseline_portfolio_value_eure_at_current_price: '105', }, }, - successfulTradeSummary: { + submissionSummary: { total: 4, - last_successful_trade_at: '2026-04-04T09:00:00.000Z', + last_submission_at: '2026-04-04T09:00:00.000Z', }, }); @@ -63,8 +64,8 @@ test('profitability summary separates baseline, hold, market move, and trading c assert.equal(summary.market_move_contribution_eure, '5'); assert.equal(summary.trading_contribution_eure, '5'); assert.equal(summary.computed_at, '2026-04-04T09:05:00.000Z'); - assert.equal(summary.recent_trade_count, 4); - assert.equal(summary.last_successful_trade_at, '2026-04-04T09:00:00.000Z'); + assert.equal(summary.recent_submission_count, 4); + assert.equal(summary.last_submission_at, '2026-04-04T09:00:00.000Z'); }); test('profitability summary flags cash-flow-adjusted benchmarks after later funding changes', () => { @@ -86,9 +87,9 @@ test('profitability summary flags cash-flow-adjusted benchmarks after later fund }, }, }, - successfulTradeSummary: { + submissionSummary: { total: 7, - last_successful_trade_at: '2026-04-02T20:17:44.768Z', + last_submission_at: '2026-04-02T20:17:44.768Z', }, }); @@ -154,12 +155,12 @@ test('basic auth resolves operator identity and reuses a session cookie', () => assert.equal(second.via, 'session_cookie'); }); -test('live quote updates stay capped at ten items and successful trades update live counters', () => { +test('live quote updates stay capped at ten items and submitted results update live counters', () => { const config = buildConfig(); const state = createDashboardLiveState({ config, - successfulTradeCount: 2, - lastSuccessfulTradeAt: '2026-04-04T08:00:00.000Z', + recentSubmissionCount: 2, + lastSubmissionAt: '2026-04-04T08:00:00.000Z', }); for (let index = 0; index < 11; index += 1) { @@ -195,11 +196,111 @@ test('live quote updates stay capped at ten items and successful trades update l assert.equal(state.recent_quotes.length, 10); assert.equal(state.recent_quotes[0].quote_id, 'quote-10'); assert.equal(state.recent_quotes.at(-1).quote_id, 'quote-1'); - assert.equal(state.successful_trade_count, 3); - assert.equal(state.last_successful_trade_at, '2026-04-04T08:30:00.000Z'); + assert.equal(state.recent_submission_count, 3); + assert.equal(state.last_submission_at, '2026-04-04T08:30:00.000Z'); assert.equal(updates[0].type, 'status_bar.updated'); }); +test('lifecycle derivation keeps executor blocking distinct from strategy rejection', () => { + const rows = deriveQuoteLifecycleRows({ + recentQuotes: [{ + quote_id: 'quote-1', + pair: 'btc->eure', + request_kind: 'exact_in', + observed_at: '2026-04-09T09:00:00.000Z', + }], + recentTradeDecisions: [{ + observed_at: '2026-04-09T09:00:01.000Z', + payload: { + decision_id: 'decision-1', + quote_id: 'quote-1', + pair: 'btc->eure', + decision: 'actionable', + decision_reason: 'actionable', + gross_edge_pct: '1.2', + }, + }], + recentExecuteTradeCommands: [{ + observed_at: '2026-04-09T09:00:02.000Z', + command_id: 'command-1', + decision_id: 'decision-1', + quote_id: 'quote-1', + pair: 'btc->eure', + }], + recentExecutionResults: [{ + command_id: 'command-1', + decision_id: 'decision-1', + quote_id: 'quote-1', + pair: 'btc->eure', + result_at: '2026-04-09T09:00:03.000Z', + status: 'rejected', + result_code: 'executor_disarmed', + note: 'executor is disarmed', + }], + }); + + assert.equal(rows[0].lifecycle_state, 'blocked'); + assert.equal(rows[0].lifecycle_label, 'Blocked before submit'); + assert.equal(rows[0].reason_code, 'executor_disarmed'); + assert.notEqual(rows[0].lifecycle_state, 'rejected'); +}); + +test('lifecycle derivation never upgrades submitted evidence into completion or asset movement', () => { + const rows = deriveQuoteLifecycleRows({ + recentTradeDecisions: [{ + observed_at: '2026-04-09T09:10:01.000Z', + payload: { + decision_id: 'decision-2', + quote_id: 'quote-2', + pair: 'btc->eure', + decision: 'actionable', + decision_reason: 'actionable', + }, + }], + recentExecuteTradeCommands: [{ + observed_at: '2026-04-09T09:10:02.000Z', + command_id: 'command-2', + decision_id: 'decision-2', + quote_id: 'quote-2', + pair: 'btc->eure', + }], + recentExecutionResults: [{ + command_id: 'command-2', + decision_id: 'decision-2', + quote_id: 'quote-2', + pair: 'btc->eure', + result_at: '2026-04-09T09:10:03.000Z', + status: 'submitted', + result_code: 'quote_response_ok', + }], + }); + + assert.equal(rows[0].lifecycle_state, 'submitted'); + assert.equal(rows[0].lifecycle_label, 'Submitted'); + assert.match(rows[0].reason_text, /no durable venue outcome/i); + assert.notEqual(rows[0].lifecycle_state, 'completed'); + assert.doesNotMatch(`${rows[0].lifecycle_label} ${rows[0].reason_text}`.toLowerCase(), /asset delta|realized/); +}); + +test('strategy approval no longer renders the forbidden actionable label', () => { + const rows = deriveQuoteLifecycleRows({ + recentTradeDecisions: [{ + observed_at: '2026-04-09T09:20:01.000Z', + payload: { + decision_id: 'decision-3', + quote_id: 'quote-3', + pair: 'btc->eure', + decision: 'actionable', + decision_reason: 'actionable', + }, + }], + }); + + assert.equal(rows[0].lifecycle_state, 'evaluated'); + assert.equal(rows[0].lifecycle_label, 'Approved by strategy'); + assert.notEqual(rows[0].lifecycle_label, 'Actionable'); +}); + test('bootstrap aggregation keeps Funds as default and carries live control state', () => { const config = buildConfig(); const bootstrap = buildDashboardBootstrap({ @@ -247,16 +348,16 @@ test('bootstrap aggregation keeps Funds as default and carries live control stat }, }, recentQuotes: [], - successfulTrades: { + submissionPage: { page: 1, page_size: 20, total: 0, total_pages: 1, items: [], }, - successfulTradeSummary: { + submissionSummary: { total: 1, - last_successful_trade_at: '2026-04-04T09:30:00.000Z', + last_submission_at: '2026-04-04T09:30:00.000Z', }, fundingObservations: [ { @@ -381,12 +482,18 @@ test('bootstrap aggregation keeps Funds as default and carries live control stat assert.equal(bootstrap.default_page, 'funds'); assert.equal(bootstrap.funds.profitability.computed_at, '2026-04-04T09:05:00.000Z'); + assert.equal(bootstrap.funds.profitability.last_submission_at, '2026-04-04T09:30:00.000Z'); assert.equal(bootstrap.funds.funding.control_state.withdrawals_frozen, true); assert.equal(bootstrap.funds.funding.handles[0].address, 'btc-address'); + assert.deepEqual(bootstrap.funds.recent_submission_terms, []); + assert.deepEqual(bootstrap.funds.submission_ledger.items, []); assert.equal(bootstrap.status_bar.strategy_armed, true); assert.equal(bootstrap.status_bar.executor_armed, true); + assert.equal(bootstrap.status_bar.recent_submission_count, 1); assert.equal(bootstrap.strategy.strategy_state.recent_decisions[0].decision_at, '2026-04-04T09:10:00.000Z'); assert.equal(bootstrap.strategy.strategy_state.recent_decisions[0].decision_reason, 'strategy_disarmed'); + assert.equal(bootstrap.strategy.strategy_state.recent_lifecycle_rows[0].lifecycle_state, 'rejected'); + assert.equal(bootstrap.strategy.strategy_state.recent_lifecycle_rows[0].reason_code, 'strategy_disarmed'); }); test('system service health uses sentinel-derived severity so stale ingest is never shown healthy', () => { @@ -403,16 +510,16 @@ test('system service health uses sentinel-derived severity so stale ingest is ne inventorySnapshot: null, marketPrice: null, recentQuotes: [], - successfulTrades: { + submissionPage: { page: 1, page_size: 20, total: 0, total_pages: 1, items: [], }, - successfulTradeSummary: { + submissionSummary: { total: 0, - last_successful_trade_at: null, + last_submission_at: null, }, fundingObservations: [], recentTradeDecisions: [], @@ -496,16 +603,16 @@ test('ingest disconnected still renders as a critical transport failure', () => inventorySnapshot: null, marketPrice: null, recentQuotes: [], - successfulTrades: { + submissionPage: { page: 1, page_size: 20, total: 0, total_pages: 1, items: [], }, - successfulTradeSummary: { + submissionSummary: { total: 0, - last_successful_trade_at: null, + last_submission_at: null, }, fundingObservations: [], recentTradeDecisions: [], @@ -583,16 +690,16 @@ test('recent alert history collapses repeated flapping transitions into one read inventorySnapshot: null, marketPrice: null, recentQuotes: [], - successfulTrades: { + submissionPage: { page: 1, page_size: 20, total: 0, total_pages: 1, items: [], }, - successfulTradeSummary: { + submissionSummary: { total: 0, - last_successful_trade_at: null, + last_submission_at: null, }, fundingObservations: [], recentTradeDecisions: [], @@ -674,16 +781,16 @@ test('funding summary includes credited bridge deposits without observer-backed inventorySnapshot: null, marketPrice: null, recentQuotes: [], - successfulTrades: { + submissionPage: { page: 1, page_size: 20, total: 0, total_pages: 1, items: [], }, - successfulTradeSummary: { + submissionSummary: { total: 0, - last_successful_trade_at: null, + last_submission_at: null, }, fundingObservations: [], recentDepositStatuses: [