From 9eb1f7b80ea65395dc868c35a245a43a06016f8a Mon Sep 17 00:00:00 2001 From: philipp Date: Fri, 10 Apr 2026 17:25:46 +0200 Subject: [PATCH] Consolidate quote lifecycle dashboard Proof: Operator dashboard now renders one full-width quote lifecycle table and one successful-trades-only table from durable lifecycle evidence; targeted dashboard tests, dashboard build, and full npm test pass. Assumptions: Gross edge estimate is edge percent times EUR notional and is labeled separately from realized PnL; realized per-trade PnL remains unavailable until fee and venue-terminal fill data are stored. Still fake: Venue-native terminal fill events, fee attribution, realized per-trade PnL, and full inventory-skew strategy controls remain incomplete. --- src/core/operator-dashboard.mjs | 81 ++++- src/operator-dashboard/static/App.jsx | 24 +- .../static/pages/FundsPage.jsx | 155 --------- .../static/pages/StrategyPage.jsx | 308 +++++++++++++----- src/operator-dashboard/static/styles.css | 109 +++++++ test/operator-dashboard-ui-static.test.mjs | 23 ++ test/operator-dashboard.test.mjs | 63 ++++ 7 files changed, 495 insertions(+), 268 deletions(-) create mode 100644 test/operator-dashboard-ui-static.test.mjs diff --git a/src/core/operator-dashboard.mjs b/src/core/operator-dashboard.mjs index 53cc77a..b0702b8 100644 --- a/src/core/operator-dashboard.mjs +++ b/src/core/operator-dashboard.mjs @@ -752,12 +752,18 @@ export function deriveQuoteLifecycleRows({ 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}`); + const normalizedQuote = normalizeLifecycleQuote(quote); + const row = ensureLifecycleRow(rowsByKey, normalizedQuote?.quote_id || `quote:${normalizedQuote?.observed_at || normalizedQuote?.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, + quote_id: normalizedQuote?.quote_id || null, + pair: normalizedQuote?.pair || null, + request_kind: normalizedQuote?.request_kind || null, + asset_in: normalizedQuote?.asset_in || null, + asset_out: normalizedQuote?.asset_out || null, + amount_in: normalizedQuote?.amount_in || null, + amount_out: normalizedQuote?.amount_out || null, + quote: normalizedQuote, + quote_observed_at: normalizedQuote?.observed_at || normalizedQuote?.ingested_at || null, }); } @@ -804,6 +810,10 @@ export function deriveQuoteLifecycleRows({ pair: command.pair, direction: command.direction, request_kind: command.request_kind, + asset_in: command.asset_in || null, + asset_out: command.asset_out || null, + amount_in: command.amount_in || null, + amount_out: command.amount_out || null, command, command_at: command.command_at || null, }); @@ -857,9 +867,14 @@ function ensureLifecycleRow(rowsByKey, key) { eure_notional: null, quote_observed_at: null, decision_at: null, + asset_in: null, + asset_out: null, + amount_in: null, + amount_out: null, command_at: null, execution_result_at: null, outcome_observed_at: null, + quote: null, decision: null, command: null, execution: null, @@ -875,6 +890,7 @@ function mergeLifecycleEvidence(row, next) { row[key] = value; } } + if (next?.quote) row.quote = next.quote; if (next?.decision) row.decision = next.decision; if (next?.command) row.command = next.command; if (next?.execution) row.execution = next.execution; @@ -1041,6 +1057,22 @@ function normalizeLifecycleToken(value) { .replace(/[\s-]+/g, '_'); } +function normalizeLifecycleQuote(quote) { + if (!quote) return null; + return { + quote_id: quote.quote_id || null, + pair: quote.pair || null, + asset_in: quote.asset_in || null, + asset_out: quote.asset_out || null, + request_kind: quote.request_kind || null, + amount_in: quote.amount_in ?? null, + amount_out: quote.amount_out ?? null, + min_deadline_ms: quote.min_deadline_ms ?? null, + observed_at: quote.observed_at || null, + ingested_at: quote.ingested_at || null, + }; +} + function normalizeCommand(command) { if (!command) return null; return { @@ -1053,6 +1085,8 @@ function normalizeCommand(command) { request_kind: command.request_kind || null, asset_in: command.asset_in || null, asset_out: command.asset_out || null, + amount_in: command.amount_in ?? null, + amount_out: command.amount_out ?? null, command_at: command.command_at || command.observed_at || command.ingested_at || null, }; } @@ -1345,6 +1379,15 @@ function normalizeTradeForUi({ config, trade }) { function enrichLifecycleRowForUi({ config, row }) { return { ...row, + request_terms: buildLifecycleTerms({ + config, + terms: row.quote || row, + }), + submitted_terms: buildLifecycleTerms({ + config, + terms: row.command || row.execution || null, + }), + gross_edge_value_eure: estimateGrossEdgeValueEure(row), settlement_summary: buildSettlementSummary({ config, delta: row.attributed_inventory_delta, @@ -1354,6 +1397,34 @@ function enrichLifecycleRowForUi({ config, row }) { }; } +function buildLifecycleTerms({ config, terms }) { + if (!terms?.asset_in && !terms?.asset_out) return null; + + const assetIn = config.assetRegistry.get(terms.asset_in); + const assetOut = config.assetRegistry.get(terms.asset_out); + const amountIn = terms.amount_in ?? null; + const amountOut = terms.amount_out ?? null; + + return { + asset_in: terms.asset_in || null, + asset_in_symbol: assetIn?.symbol || terms.asset_in || null, + amount_in_units: amountIn, + amount_in: amountIn == null ? null : formatUnits(amountIn, assetIn?.decimals || 0), + asset_out: terms.asset_out || null, + asset_out_symbol: assetOut?.symbol || terms.asset_out || null, + amount_out_units: amountOut, + amount_out: amountOut == null ? null : formatUnits(amountOut, assetOut?.decimals || 0), + }; +} + +function estimateGrossEdgeValueEure(row) { + const edge = Number(row?.gross_edge_pct); + const notional = Number(row?.eure_notional); + if (!Number.isFinite(edge) || !Number.isFinite(notional)) return null; + const value = (notional * edge) / 100; + return value.toFixed(8).replace(/\.?0+$/, ''); +} + function buildSettlementSummary({ config, delta, attributionStatus, attributionMethod }) { if (!delta?.delta_units) { return { diff --git a/src/operator-dashboard/static/App.jsx b/src/operator-dashboard/static/App.jsx index 1d78efc..4358989 100644 --- a/src/operator-dashboard/static/App.jsx +++ b/src/operator-dashboard/static/App.jsx @@ -9,7 +9,7 @@ import StrategyPage from './pages/StrategyPage.jsx'; import SystemPage from './pages/SystemPage.jsx'; import { dashboardReducer, initialDashboardState } from './state/dashboardReducer.js'; -const TRADE_PAGE_SIZE = 20; +const BOOTSTRAP_PAGE_SIZE = 20; function LoadingPanel() { return ( @@ -27,27 +27,11 @@ export default function App() { const criticalBanner = null; async function loadBootstrap(page = 1) { - const dashboard = await fetchJson(`/api/bootstrap?page=${page}&page_size=${TRADE_PAGE_SIZE}`); + const dashboard = await fetchJson(`/api/bootstrap?page=${page}&page_size=${BOOTSTRAP_PAGE_SIZE}`); dispatch({ type: 'bootstrap.loaded', dashboard }); return dashboard; } - async function loadTradesPage(page) { - if (!Number.isFinite(page) || page < 1) return; - - dispatch({ type: 'notice.changed', notice: 'Loading submission history page...' }); - dispatch({ type: 'error.changed', error: null }); - - try { - 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 }); - dispatch({ type: 'notice.changed', notice: null }); - } - } - async function submitControl(service, action, body = {}, { reload = true } = {}) { dispatch({ type: 'notice.changed', notice: `${action} in progress` }); dispatch({ type: 'error.changed', error: null }); @@ -65,8 +49,7 @@ export default function App() { dispatch({ type: 'notice.changed', notice: `${action} completed` }); if (reload) { - const page = state.dashboard?.funds?.submission_ledger?.page || 1; - await loadBootstrap(page); + await loadBootstrap(1); } } catch (error) { dispatch({ type: 'error.changed', error: error.message }); @@ -168,7 +151,6 @@ export default function App() { funds={state.dashboard.funds} lastControlResult={state.lastControlResult} onControl={submitControl} - onTradesPageChange={loadTradesPage} /> ) : null} {currentPage === 'strategy' ? ( diff --git a/src/operator-dashboard/static/pages/FundsPage.jsx b/src/operator-dashboard/static/pages/FundsPage.jsx index badf17c..017f8e4 100644 --- a/src/operator-dashboard/static/pages/FundsPage.jsx +++ b/src/operator-dashboard/static/pages/FundsPage.jsx @@ -5,7 +5,6 @@ import MetricCard from '../components/MetricCard.jsx'; import Pill from '../components/Pill.jsx'; import TableFrame from '../components/TableFrame.jsx'; import { formatEur, formatTimestamp, stringifyJson, truncateMiddle } from '../lib/format.js'; -import { SUBMISSION_COPY } from '../lib/submissionCopy.js'; function buildInitialEstimateForm(balances, withdrawalDefaults) { const firstAssetId = balances?.[0]?.asset_id || ''; @@ -173,99 +172,6 @@ function WithdrawalsTable({ items }) { ); } -function QuotesTable({ items }) { - if (!items?.length) return No quotes have been captured yet.; - - return ( - - - - - - - - - - - - - {items.map((item) => ( - - - - - - - - ))} - -
ObservedPairKindAmount inAmount out
{formatTimestamp(item.observed_at || item.ingested_at)}{truncateMiddle(item.pair || '', 38)}{item.request_kind || ''}{item.amount_in || ''}{item.amount_out || ''}
-
- ); -} - -function AssetChangeTable({ items }) { - if (!items?.length) return {SUBMISSION_COPY.termsEmpty}; - - return ( - - - - - - - - - - - - {items.map((item) => ( - - - - - - - ))} - -
ObservedQuoteInputOutput
{formatTimestamp(item.observed_at)}{item.quote_id || ''}{`${item.amount_in} ${item.asset_in_symbol}`}{`${item.amount_out} ${item.asset_out_symbol}`}
-
- ); -} - -function TradesTable({ items }) { - if (!items?.length) return {SUBMISSION_COPY.ledgerEmpty}; - - return ( - - - - - - - - - - - - - - {items.map((trade) => ( - - - - - - - - - ))} - -
ObservedPairKindSubmittedEdgeResult
{formatTimestamp(trade.observed_at)}{truncateMiddle(trade.pair || '', 38)}{trade.request_kind || ''}{`${trade.amount_in_display || ''} ${trade.asset_in_symbol || ''} -> ${trade.amount_out_display || ''} ${trade.asset_out_symbol || ''}`} 0 ? 'value-positive' : Number(trade.gross_edge_pct) < 0 ? 'value-negative' : ''}>{trade.gross_edge_pct || ''}
-
- ); -} - function WithdrawalEstimateForm({ balances, withdrawalDefaults, onControl }) { const [form, setForm] = useState(() => buildInitialEstimateForm(balances, withdrawalDefaults)); @@ -365,12 +271,10 @@ function LastControlResult({ result }) { export default function FundsPage({ funds, - onTradesPageChange, onControl, lastControlResult, }) { const profitability = funds.profitability; - const submissionLedger = funds.submission_ledger; const controlState = funds.funding.control_state || {}; const externalFlowAdjusted = profitability.external_flow_adjusted; const externalFlowCount = profitability.external_flow_count || 0; @@ -446,11 +350,6 @@ export default function FundsPage({ signedValue={profitability.portfolio_vs_simple_hold_eure} value={formatEur(profitability.portfolio_vs_simple_hold_eure)} /> -
{profitability.caveats.map((item) => ( @@ -554,60 +453,6 @@ export default function FundsPage({
- -
-
-
-
-
Live feed
-

Recent incoming quotes

-
-
WebSocket live feed, capped at 10
-
- -
-
-
-
-
{SUBMISSION_COPY.termsEyebrow}
-

{SUBMISSION_COPY.termsTitle}

-
-
- -
-
- -
-
-
-
Durable ledger
-

{SUBMISSION_COPY.ledgerTitle}

-
-
{SUBMISSION_COPY.ledgerSubtitle}
-
- -
-
{SUBMISSION_COPY.ledgerCountLabel(submissionLedger.page, submissionLedger.total_pages, submissionLedger.total)}
-
- - -
-
-
); } diff --git a/src/operator-dashboard/static/pages/StrategyPage.jsx b/src/operator-dashboard/static/pages/StrategyPage.jsx index 1521df5..df09e21 100644 --- a/src/operator-dashboard/static/pages/StrategyPage.jsx +++ b/src/operator-dashboard/static/pages/StrategyPage.jsx @@ -1,8 +1,12 @@ +import { Fragment, useState } from 'react'; + import EmptyState from '../components/EmptyState.jsx'; import MetricCard from '../components/MetricCard.jsx'; import Pill from '../components/Pill.jsx'; import TableFrame from '../components/TableFrame.jsx'; -import { formatBoolean, formatTimestamp, truncateMiddle } from '../lib/format.js'; +import { formatBoolean, formatEur, formatTimestamp, truncateMiddle } from '../lib/format.js'; + +const RESPONDED_STATES = new Set(['submitted', 'awaiting_outcome', 'not_filled', 'completed']); async function copyIdentifier(value) { if (!value || !navigator?.clipboard?.writeText) return; @@ -27,42 +31,161 @@ function IdentifierRow({ label, value }) { ); } -function LifecycleTable({ items }) { +function formatTerms(terms) { + if (!terms) return 'Unavailable'; + const input = terms.amount_in + ? `${terms.amount_in} ${terms.asset_in_symbol || ''}`.trim() + : terms.asset_in_symbol || terms.asset_in || 'input unavailable'; + const output = terms.amount_out + ? `${terms.amount_out} ${terms.asset_out_symbol || ''}`.trim() + : terms.asset_out_symbol || terms.asset_out || 'output unavailable'; + return `${input} -> ${output}`; +} + +function responseLabel(item) { + if (RESPONDED_STATES.has(item.lifecycle_state)) return 'Yes'; + if (item.lifecycle_state === 'failed') return 'Attempt failed'; + if (item.lifecycle_state === 'blocked') return 'No - executor blocked'; + if (item.lifecycle_state === 'rejected') return 'No - strategy rejected'; + if (item.lifecycle_state === 'command_emitted') return 'Pending executor'; + if (item.lifecycle_state === 'evaluated') return 'Approved, not sent'; + return 'No decision yet'; +} + +function grossEdgeEstimate(item) { + if (!item.gross_edge_value_eure) return 'Unavailable'; + return formatEur(item.gross_edge_value_eure); +} + +function plainCodeLabel(value, fallback = 'Unavailable') { + const text = String(value || '').trim(); + if (!text) return fallback; + return text.replaceAll('_', ' '); +} + +function strategyDecisionStatus(decision) { + if (decision?.decision === 'approved') return 'Strategy approved'; + if (decision?.decision === 'rejected') return 'Strategy rejected'; + return plainCodeLabel(decision?.decision, 'No strategy decision'); +} + +function StageCard({ title, at, status, children }) { + return ( +
+
{title}
+
{formatTimestamp(at)}
+
{status || 'Not recorded'}
+ {children ?
{children}
: null} +
+ ); +} + +function LifecycleDetails({ item }) { + return ( +
+
+ +
{formatTerms(item.request_terms)}
+
{item.pair || 'pair unavailable'}
+
+ + +
{plainCodeLabel(item.decision?.decision_reason || item.reason_code, 'No decision reason recorded')}
+
{item.gross_edge_pct ? `Edge ${item.gross_edge_pct}%` : 'Edge unavailable'}
+
{item.eure_notional ? `Notional ${formatEur(item.eure_notional)}` : 'Notional unavailable'}
+
+ + + +
{formatTerms(item.submitted_terms)}
+
+ + +
{item.execution?.result_code || 'No executor result code stored'}
+
Submitted means the relay accepted the response; it does not prove a trade.
+
+ + +
{item.reason_text}
+
{item.settlement_summary?.text || 'No settled inventory delta is linked to this quote.'}
+ {item.settlement_summary?.caveat ?
{item.settlement_summary.caveat}
: null} +
+
+ +
+ + + +
+
+ ); +} + +function QuoteLifecycleTable({ items }) { + const [expanded, setExpanded] = useState(() => new Set()); if (!items?.length) return No quote lifecycle evidence has been observed yet.; + function toggle(rowKey) { + setExpanded((current) => { + const next = new Set(current); + if (next.has(rowKey)) next.delete(rowKey); + else next.add(rowKey); + return next; + }); + } + return ( - +
- - + + + + + - - - - + + - {items.map((item, index) => ( - - - - - - - - - - ))} + {items.map((item, index) => { + const rowKey = item.quote_id || item.decision_id || item.command_id || item.latest_stage_at || String(index); + const isExpanded = expanded.has(rowKey); + return ( + + + + + + + + + + + + {isExpanded ? ( + + + + ) : null} + + ); + })}
AtLifecycleTimeQuote idRequestResponded?Result ReasonPairTraceEdge %NotionalEdge / notionalLifecycle
{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 || ''}
{formatTimestamp(item.latest_stage_at)} +
{formatTerms(item.request_terms || item.submitted_terms)}
+
{truncateMiddle(item.pair || '', 34)}
+
{responseLabel(item)} +
{item.reason_text}
+
{item.reason_code || 'reason_unknown'}
+
+
0 ? 'value-positive' : Number(item.gross_edge_pct) < 0 ? 'value-negative' : ''}>{item.gross_edge_pct ? `${item.gross_edge_pct}%` : 'Unavailable'}
+
{item.eure_notional ? formatEur(item.eure_notional) : 'Notional unavailable'}
+
+ +
@@ -70,6 +193,7 @@ function LifecycleTable({ items }) { } function SuccessfulTradesTable({ items }) { + const [expanded, setExpanded] = useState(() => new Set()); if (!items?.length) { return ( @@ -78,43 +202,68 @@ function SuccessfulTradesTable({ items }) { ); } + function toggle(rowKey) { + setExpanded((current) => { + const next = new Set(current); + if (next.has(rowKey)) next.delete(rowKey); + else next.add(rowKey); + return next; + }); + } + return ( - +
- - - - + + + + + + - {items.map((item, index) => ( - - - - - - + + + + + + + + + {isExpanded ? ( + + + ) : null} - {item.settlement_summary?.observed_at ? ( -
{`Observed ${formatTimestamp(item.settlement_summary.observed_at)}`}
- ) : null} - {item.settlement_summary?.caveat ? ( -
{item.settlement_summary.caveat}
- ) : null} - - - ))} + + ); + })}
Completed atPairTraceOutcomeCompletedQuote idEdgeGross edge est. SettlementRealized PnLLifecycle
{formatTimestamp(item.latest_stage_at)}{truncateMiddle(item.pair || '', 32)} - - - - {item.reason_text} -
{item.settlement_summary?.text || 'No settled inventory delta is linked to this quote.'}
- {item.settlement_summary?.method ? ( -
{item.settlement_summary.method}
+ {items.map((item, index) => { + const rowKey = item.quote_id || item.command_id || item.latest_stage_at || String(index); + const isExpanded = expanded.has(rowKey); + return ( + +
{formatTimestamp(item.latest_stage_at)} +
{item.gross_edge_pct ? `${item.gross_edge_pct}%` : 'Unavailable'}
+
{item.eure_notional ? formatEur(item.eure_notional) : 'Notional unavailable'}
+
+
{grossEdgeEstimate(item)}
+
Estimate from edge x notional, not realized PnL.
+
+
{item.settlement_summary?.text || 'No settled inventory delta is linked to this quote.'}
+ {item.settlement_summary?.method ?
{item.settlement_summary.method}
: null} +
+
Unavailable
+
Fees and venue-native terminal fill are not stored.
+
+ +
@@ -130,66 +279,51 @@ export default function StrategyPage({ strategy }) {
-
Trading state
-

Strategy and executor

+
Trading evidence
+

Quotes, responses, and proven trades

- This page starts with real trades. Everything else explains why a quote did not become a proven asset-changing trade. + One place for quote truth: every row starts at the incoming quote, then shows whether we responded, why not, and whether any asset movement was proven.
+ - + -
-
Successful trades
+
Successful trades only

Trades with proven asset movement

-
{funnel.caveat}
+
+ This table excludes submitted-only quote responses. Realized PnL remains unavailable until fees and venue-native terminal fills are stored. +
0 ? 'healthy' : 'unknown'} /> 0 ? 'warning' : 'unknown'} /> - 0 ? 'info' : 'unknown'} />
-
-
-
-
-
Why quotes are not trades
-

Recent quote outcomes and blockers

-
- Each row answers why the quote was filtered, rejected, blocked, submitted without outcome, failed, not filled, or completed. Submission still never means asset movement. -
+
+
+
+
Quote lifecycle
+

Incoming quotes and what happened next

+
+ Full-width quote table: incoming quote, response decision, result, decisive reason, and expandable lifecycle stages.
- -
- -
-
-
-
Controls
-

Omitted risky controls

-
-
- - {strategy.omitted_controls.map((item) => ( -
{item}
- ))} -
+
); diff --git a/src/operator-dashboard/static/styles.css b/src/operator-dashboard/static/styles.css index cf662be..ee07306 100644 --- a/src/operator-dashboard/static/styles.css +++ b/src/operator-dashboard/static/styles.css @@ -535,3 +535,112 @@ table.lifecycle-table th:nth-child(5) { min-width: 540px; } } + +.quote-lifecycle-table, +.successful-trades-table { + min-width: 1180px; + table-layout: fixed; +} + +.quote-lifecycle-table th:nth-child(1), +.quote-lifecycle-table td:nth-child(1) { + width: 150px; +} + +.quote-lifecycle-table th:nth-child(2), +.quote-lifecycle-table td:nth-child(2) { + width: 260px; +} + +.quote-lifecycle-table th:nth-child(3), +.quote-lifecycle-table td:nth-child(3) { + width: 210px; +} + +.quote-lifecycle-table th:nth-child(4), +.quote-lifecycle-table td:nth-child(4) { + width: 140px; +} + +.quote-lifecycle-table th:nth-child(5), +.quote-lifecycle-table td:nth-child(5) { + width: 150px; +} + +.quote-lifecycle-table th:nth-child(7), +.quote-lifecycle-table td:nth-child(7) { + width: 140px; +} + +.quote-lifecycle-table th:nth-child(8), +.quote-lifecycle-table td:nth-child(8), +.successful-trades-table th:nth-child(7), +.successful-trades-table td:nth-child(7) { + width: 150px; +} + +.successful-trades-table { + min-width: 1040px; +} + +.successful-trades-table th:nth-child(1), +.successful-trades-table td:nth-child(1) { + width: 150px; +} + +.successful-trades-table th:nth-child(2), +.successful-trades-table td:nth-child(2) { + width: 260px; +} + +.successful-trades-table th:nth-child(3), +.successful-trades-table td:nth-child(3), +.successful-trades-table th:nth-child(4), +.successful-trades-table td:nth-child(4) { + width: 140px; +} + +.lifecycle-expanded-row td { + background: rgba(24, 33, 30, 0.05); +} + +.lifecycle-detail-panel { + display: grid; + gap: 14px; +} + +.lifecycle-stage-grid { + display: grid; + gap: 12px; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); +} + +.lifecycle-stage-card { + min-width: 0; + padding: 14px; + border-radius: 16px; + background: var(--panel-2); + border: 1px solid var(--line); +} + +.stage-title { + font-weight: 700; +} + +.stage-meta, +.stage-status, +.stage-body { + margin-top: 6px; + color: var(--muted); +} + +.stage-status { + color: var(--ink); +} + +.trace-block { + padding: 14px; + border-radius: 16px; + background: rgba(255, 255, 255, 0.48); + border: 1px solid var(--line); +} diff --git a/test/operator-dashboard-ui-static.test.mjs b/test/operator-dashboard-ui-static.test.mjs new file mode 100644 index 0000000..58645b6 --- /dev/null +++ b/test/operator-dashboard-ui-static.test.mjs @@ -0,0 +1,23 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; + +const strategySource = readFileSync(new URL('../src/operator-dashboard/static/pages/StrategyPage.jsx', import.meta.url), 'utf8'); +const fundsSource = readFileSync(new URL('../src/operator-dashboard/static/pages/FundsPage.jsx', import.meta.url), 'utf8'); + +test('strategy page owns consolidated quote lifecycle and successful trade tables', () => { + assert.match(strategySource, /Quote lifecycle/); + assert.match(strategySource, /Incoming quotes and what happened next/); + assert.match(strategySource, /Responded\?/); + assert.match(strategySource, /Successful trades only/); + assert.match(strategySource, /Show lifecycle/); + assert.match(strategySource, /Submitted means the relay accepted the response; it does not prove a trade\./); + assert.doesNotMatch(strategySource, /Actionable|actionable/); +}); + +test('funds page no longer renders duplicate quote and submission tables', () => { + assert.doesNotMatch(fundsSource, /Recent incoming quotes/); + assert.doesNotMatch(fundsSource, /Recent submitted quote terms/); + assert.doesNotMatch(fundsSource, /Submitted quote responses/); + assert.doesNotMatch(fundsSource, /Durable ledger/); +}); diff --git a/test/operator-dashboard.test.mjs b/test/operator-dashboard.test.mjs index e5efe57..e08a73c 100644 --- a/test/operator-dashboard.test.mjs +++ b/test/operator-dashboard.test.mjs @@ -1127,3 +1127,66 @@ test('funding summary includes credited bridge deposits without observer-backed assert.equal(bootstrap.funds.funding.recent_observations[0].tx_hash, 'eth-tx-1'); assert.equal(bootstrap.funds.recent_deposits[0].tx_hash, 'eth-tx-1'); }); + +test('bootstrap lifecycle rows preserve quote terms, submitted terms, and gross edge estimate', () => { + const config = buildConfig(); + const bootstrap = buildDashboardBootstrap({ + config, + auth: { authenticated: true, subject: 'local-operator', mode: 'stub', roles: ['operator'] }, + portfolioMetric: null, + inventorySnapshot: null, + marketPrice: null, + recentQuotes: [{ + quote_id: 'quote-terms-1', + pair: config.activePair, + asset_in: config.tradingBtc.assetId, + asset_out: config.tradingEure.assetId, + request_kind: 'exact_in', + amount_in: '123208', + amount_out: null, + observed_at: '2026-04-09T09:00:00.000Z', + }], + submissionPage: { page: 1, page_size: 20, total: 0, total_pages: 1, items: [] }, + submissionSummary: { total: 0, last_submission_at: null }, + fundingObservations: [], + recentDepositStatuses: [], + recentTradeDecisions: [{ + observed_at: '2026-04-09T09:00:01.000Z', + payload: { + decision_id: 'decision-terms-1', + quote_id: 'quote-terms-1', + pair: config.activePair, + decision: 'actionable', + decision_reason: 'actionable', + gross_edge_pct: '1.5', + eure_notional: '100', + }, + }], + recentExecuteTradeCommands: [{ + observed_at: '2026-04-09T09:00:02.000Z', + payload: { + command_id: 'cmd-terms-1', + decision_id: 'decision-terms-1', + quote_id: 'quote-terms-1', + pair: config.activePair, + request_kind: 'exact_in', + asset_in: config.tradingBtc.assetId, + asset_out: config.tradingEure.assetId, + amount_in: '123208', + amount_out: '76000000000000000000', + }, + }], + recentExecutionResults: [], + recentQuoteOutcomes: [], + recentAlertTransitions: [], + serviceSnapshots: [], + }); + + const row = bootstrap.strategy.strategy_state.recent_lifecycle_rows[0]; + assert.equal(row.quote_id, 'quote-terms-1'); + assert.equal(row.request_terms.amount_in, '0.00123208'); + assert.equal(row.request_terms.asset_in_symbol, 'BTC'); + assert.equal(row.submitted_terms.amount_out, '76'); + assert.equal(row.submitted_terms.asset_out_symbol, 'EURe'); + assert.equal(row.gross_edge_value_eure, '1.5'); +});