From 3fca125cddf39c21cfc7b7416f57b08bd17db61f Mon Sep 17 00:00:00 2001 From: philipp Date: Fri, 10 Apr 2026 10:01:46 +0200 Subject: [PATCH] Put real trade outcomes first Proof: Operator dashboard now starts from successful trades with linked outcome evidence, keeps submitted-only rows in awaiting/no-trade buckets, and explains why recent quotes are not proven asset-changing trades. Assumptions: Until durable terminal outcome and settlement attribution are implemented, successful trade count must remain zero for submitted-only evidence. Still fake: Per-quote terminal outcome and settled asset delta plumbing is still not implemented; the page now exposes that absence directly instead of hiding it behind submission counts. --- src/core/operator-dashboard.mjs | 48 ++++++++++++ .../static/lib/submissionCopy.js | 2 +- .../static/pages/StrategyPage.jsx | 73 +++++++++++++++++-- test/operator-dashboard.test.mjs | 35 +++++++++ 4 files changed, 150 insertions(+), 8 deletions(-) diff --git a/src/core/operator-dashboard.mjs b/src/core/operator-dashboard.mjs index 501daae..c5af0d4 100644 --- a/src/core/operator-dashboard.mjs +++ b/src/core/operator-dashboard.mjs @@ -1016,6 +1016,7 @@ function buildStrategySummary({ recentExecutionResults, limit: 20, }); + const tradeFunnel = buildTradeFunnelSummary(lifecycleRows); return { strategy_state: { @@ -1028,6 +1029,7 @@ function buildStrategySummary({ ? recentDecisions : [...durableDecisionsById.values()].slice(0, 20), recent_lifecycle_rows: lifecycleRows, + trade_funnel: tradeFunnel, skipped_counts: strategyState.skipped_counts || {}, durable_control_state: strategyState.durable_control_state || null, }, @@ -1053,6 +1055,52 @@ function buildStrategySummary({ }; } +function buildTradeFunnelSummary(lifecycleRows = []) { + const counts = { + observed: 0, + rejected: 0, + blocked: 0, + submitted: 0, + awaiting_outcome: 0, + failed: 0, + not_filled: 0, + completed: 0, + }; + + const successfulTrades = []; + const unresolvedSubmissions = []; + const noTradeRows = []; + + for (const row of lifecycleRows || []) { + if (Object.hasOwn(counts, row.lifecycle_state)) { + counts[row.lifecycle_state] += 1; + } + + if (row.lifecycle_state === 'completed') { + successfulTrades.push(row); + } else if (['submitted', 'awaiting_outcome'].includes(row.lifecycle_state)) { + unresolvedSubmissions.push(row); + noTradeRows.push(row); + } else if (['observed', 'evaluated', 'command_emitted', 'rejected', 'blocked', 'failed', 'not_filled'].includes(row.lifecycle_state)) { + noTradeRows.push(row); + } + } + + return { + successful_trade_count: successfulTrades.length, + unresolved_submission_count: unresolvedSubmissions.length, + no_trade_count: noTradeRows.length, + successful_trades: successfulTrades, + unresolved_submissions: unresolvedSubmissions, + no_trade_rows: noTradeRows, + counts, + caveat: + successfulTrades.length > 0 + ? 'Successful trades require durable terminal outcome evidence.' + : 'No quote currently has linked terminal outcome and settled inventory evidence, so there are no successful trades to show yet.', + }; +} + function buildSystemSummary({ servicesByName, activeAlerts, recentAlerts }) { const historyWriterState = servicesByName['history-writer']?.state || {}; void activeAlerts; diff --git a/src/operator-dashboard/static/lib/submissionCopy.js b/src/operator-dashboard/static/lib/submissionCopy.js index b566723..7ae7940 100644 --- a/src/operator-dashboard/static/lib/submissionCopy.js +++ b/src/operator-dashboard/static/lib/submissionCopy.js @@ -1,6 +1,6 @@ export const SUBMISSION_COPY = { statusTileLabel: 'Submissions', - statusTileSubtitle: 'Successful quote-response submissions from durable history', + statusTileSubtitle: 'Quote responses accepted by the relay; not completed trades', statusTileValueSuffix: 'submissions', lastStatusTileLabel: 'Last Submission', recentMetricLabel: 'Recent submissions', diff --git a/src/operator-dashboard/static/pages/StrategyPage.jsx b/src/operator-dashboard/static/pages/StrategyPage.jsx index 3cfc3a6..0c582b5 100644 --- a/src/operator-dashboard/static/pages/StrategyPage.jsx +++ b/src/operator-dashboard/static/pages/StrategyPage.jsx @@ -69,7 +69,50 @@ function LifecycleTable({ items }) { ); } +function SuccessfulTradesTable({ items }) { + if (!items?.length) { + return ( + + No successful trades with linked settlement evidence yet. A submitted quote response is not counted here until a durable terminal outcome and settled asset movement are linked to the quote. + + ); + } + + return ( + + + + + + + + + + + + + {items.map((item, index) => ( + + + + + + + + ))} + +
Completed atPairTraceOutcomeSettlement
{formatTimestamp(item.latest_stage_at)}{truncateMiddle(item.pair || '', 32)} + + + {item.reason_text}Linked asset delta not exposed yet
+
+ ); +} + export default function StrategyPage({ strategy }) { + const funnel = strategy.strategy_state.trade_funnel || {}; + const counts = funnel.counts || {}; + return ( <>
@@ -78,28 +121,44 @@ export default function StrategyPage({ strategy }) {
Trading state

Strategy and executor

- This page shows the strongest durable claim for each recent quote, from strategy evaluation through executor submission evidence. + This page starts with real trades. Everything else explains why a quote did not become a proven asset-changing trade.
+ + + - - -
+
+
+
+
Successful trades
+

Trades with proven asset movement

+
{funnel.caveat}
+
+
+ 0 ? 'healthy' : 'unknown'} /> + 0 ? 'warning' : 'unknown'} /> + 0 ? 'info' : 'unknown'} /> +
+
+ +
+
-
Lifecycle truth
-

Recent quote decisions and execution evidence

+
Why quotes are not trades
+

Recent quote outcomes and blockers

- Strategy rejection, executor blocking, submission failure, and submission success are separated. Submission never implies trade completion or realized asset movement. + Each row answers why the quote was filtered, rejected, blocked, submitted without outcome, failed, not filled, or completed. Submission still never means asset movement.
diff --git a/test/operator-dashboard.test.mjs b/test/operator-dashboard.test.mjs index cf396b1..66f6988 100644 --- a/test/operator-dashboard.test.mjs +++ b/test/operator-dashboard.test.mjs @@ -584,9 +584,44 @@ test('bootstrap normalizes actionable decision vocabulary before exposing it to assert.equal(bootstrap.funds.submission_ledger.items[0].decision_reason, 'strategy_approved'); assert.equal(bootstrap.strategy.strategy_state.recent_decisions[0].decision, 'approved'); assert.equal(bootstrap.strategy.strategy_state.recent_lifecycle_rows[0].reason_code, 'quote_response_ok'); + assert.equal(bootstrap.strategy.strategy_state.trade_funnel.successful_trade_count, 0); + assert.equal(bootstrap.strategy.strategy_state.trade_funnel.unresolved_submission_count, 1); + assert.equal(bootstrap.strategy.strategy_state.trade_funnel.counts.submitted, 1); + assert.match(bootstrap.strategy.strategy_state.trade_funnel.caveat, /No quote currently has linked terminal outcome/); assert.doesNotMatch(JSON.stringify(bootstrap), /Actionable/); }); +test('completed lifecycle evidence is the only source of successful trade rows', () => { + const rows = deriveQuoteLifecycleRows({ + recentExecutionResults: [ + { + command_id: 'cmd-submitted', + quote_id: 'quote-submitted', + result_at: '2026-04-09T09:00:00.000Z', + status: 'submitted', + result_code: 'quote_response_ok', + }, + { + command_id: 'cmd-completed', + quote_id: 'quote-completed', + result_at: '2026-04-09T09:01:00.000Z', + status: 'submitted', + result_code: 'quote_response_ok', + outcome_status: 'completed', + outcome_reason: 'settled', + }, + ], + }); + + const completed = rows.filter((row) => row.lifecycle_state === 'completed'); + const submitted = rows.filter((row) => row.lifecycle_state === 'submitted'); + + assert.equal(completed.length, 1); + assert.equal(completed[0].quote_id, 'quote-completed'); + assert.equal(submitted.length, 1); + assert.equal(submitted[0].quote_id, 'quote-submitted'); +}); + test('system service state ignores sentinel alert severity and keeps alert surfaces empty', () => { const config = buildConfig(); const bootstrap = buildDashboardBootstrap({