Consolidate quote lifecycle dashboard
All checks were successful
deploy / deploy (push) Successful in 31s

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.
This commit is contained in:
philipp 2026-04-10 17:25:46 +02:00
parent fa7e8c885f
commit 9eb1f7b80e
7 changed files with 495 additions and 268 deletions

View file

@ -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 {

View file

@ -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' ? (

View file

@ -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 <EmptyState>No quotes have been captured yet.</EmptyState>;
return (
<TableFrame>
<table className="pair-compact-table">
<thead>
<tr>
<th>Observed</th>
<th>Pair</th>
<th>Kind</th>
<th>Amount in</th>
<th>Amount out</th>
</tr>
</thead>
<tbody>
{items.map((item) => (
<tr key={`${item.quote_id}:${item.ingested_at || item.observed_at}`}>
<td>{formatTimestamp(item.observed_at || item.ingested_at)}</td>
<td className="mono truncate-cell" title={item.pair || ''}>{truncateMiddle(item.pair || '', 38)}</td>
<td>{item.request_kind || ''}</td>
<td className="mono">{item.amount_in || ''}</td>
<td className="mono">{item.amount_out || ''}</td>
</tr>
))}
</tbody>
</table>
</TableFrame>
);
}
function AssetChangeTable({ items }) {
if (!items?.length) return <EmptyState>{SUBMISSION_COPY.termsEmpty}</EmptyState>;
return (
<TableFrame>
<table>
<thead>
<tr>
<th>Observed</th>
<th>Quote</th>
<th>Input</th>
<th>Output</th>
</tr>
</thead>
<tbody>
{items.map((item) => (
<tr key={`${item.quote_id}:${item.observed_at}`}>
<td>{formatTimestamp(item.observed_at)}</td>
<td className="mono">{item.quote_id || ''}</td>
<td>{`${item.amount_in} ${item.asset_in_symbol}`}</td>
<td>{`${item.amount_out} ${item.asset_out_symbol}`}</td>
</tr>
))}
</tbody>
</table>
</TableFrame>
);
}
function TradesTable({ items }) {
if (!items?.length) return <EmptyState>{SUBMISSION_COPY.ledgerEmpty}</EmptyState>;
return (
<TableFrame>
<table className="pair-compact-table">
<thead>
<tr>
<th>Observed</th>
<th>Pair</th>
<th>Kind</th>
<th>Submitted</th>
<th>Edge</th>
<th>Result</th>
</tr>
</thead>
<tbody>
{items.map((trade) => (
<tr key={`${trade.command_id || trade.quote_id}:${trade.observed_at}`}>
<td>{formatTimestamp(trade.observed_at)}</td>
<td className="mono truncate-cell" title={trade.pair || ''}>{truncateMiddle(trade.pair || '', 38)}</td>
<td>{trade.request_kind || ''}</td>
<td>{`${trade.amount_in_display || ''} ${trade.asset_in_symbol || ''} -> ${trade.amount_out_display || ''} ${trade.asset_out_symbol || ''}`}</td>
<td className={Number(trade.gross_edge_pct) > 0 ? 'value-positive' : Number(trade.gross_edge_pct) < 0 ? 'value-negative' : ''}>{trade.gross_edge_pct || ''}</td>
<td><Pill label={trade.result_code || trade.status} stateLabel={trade.result_code || trade.status} /></td>
</tr>
))}
</tbody>
</table>
</TableFrame>
);
}
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)}
/>
<MetricCard
label={SUBMISSION_COPY.recentMetricLabel}
meta={formatTimestamp(profitability.last_submission_at)}
value={`${profitability.recent_submission_count || 0} ${SUBMISSION_COPY.recentMetricValueSuffix}`}
/>
</div>
<div className="panel-subtitle">
{profitability.caveats.map((item) => (
@ -554,60 +453,6 @@ export default function FundsPage({
<FundingTable items={funds.funding.recent_observations} />
</div>
</section>
<section className="stack-grid">
<div className="panel">
<div className="panel-head">
<div>
<div className="eyebrow">Live feed</div>
<h3>Recent incoming quotes</h3>
</div>
<div className="status-subtle">WebSocket live feed, capped at 10</div>
</div>
<QuotesTable items={funds.recent_quotes || []} />
</div>
<div className="panel">
<div className="panel-head">
<div>
<div className="eyebrow">{SUBMISSION_COPY.termsEyebrow}</div>
<h3>{SUBMISSION_COPY.termsTitle}</h3>
</div>
</div>
<AssetChangeTable items={funds.recent_submission_terms} />
</div>
</section>
<section className="panel">
<div className="panel-head">
<div>
<div className="eyebrow">Durable ledger</div>
<h3>{SUBMISSION_COPY.ledgerTitle}</h3>
</div>
<div className="status-subtle">{SUBMISSION_COPY.ledgerSubtitle}</div>
</div>
<TradesTable items={submissionLedger.items} />
<div className="pagination">
<div className="status-subtle">{SUBMISSION_COPY.ledgerCountLabel(submissionLedger.page, submissionLedger.total_pages, submissionLedger.total)}</div>
<div className="button-row">
<button
className="button secondary"
disabled={submissionLedger.page <= 1}
onClick={() => onTradesPageChange(submissionLedger.page - 1)}
type="button"
>
Previous
</button>
<button
className="button secondary"
disabled={submissionLedger.page >= submissionLedger.total_pages}
onClick={() => onTradesPageChange(submissionLedger.page + 1)}
type="button"
>
Next
</button>
</div>
</div>
</section>
</>
);
}

View file

@ -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 (
<div className="lifecycle-stage-card">
<div className="stage-title">{title}</div>
<div className="stage-meta">{formatTimestamp(at)}</div>
<div className="stage-status">{status || 'Not recorded'}</div>
{children ? <div className="stage-body">{children}</div> : null}
</div>
);
}
function LifecycleDetails({ item }) {
return (
<div className="lifecycle-detail-panel">
<div className="lifecycle-stage-grid">
<StageCard at={item.quote_observed_at} status={item.quote_observed_at ? 'Quote observed' : 'Missing quote event'} title="1. Quote came in">
<div>{formatTerms(item.request_terms)}</div>
<div className="status-subtle mono">{item.pair || 'pair unavailable'}</div>
</StageCard>
<StageCard at={item.decision_at} status={strategyDecisionStatus(item.decision)} title="2. Strategy decided">
<div>{plainCodeLabel(item.decision?.decision_reason || item.reason_code, 'No decision reason recorded')}</div>
<div className="status-subtle">{item.gross_edge_pct ? `Edge ${item.gross_edge_pct}%` : 'Edge unavailable'}</div>
<div className="status-subtle">{item.eure_notional ? `Notional ${formatEur(item.eure_notional)}` : 'Notional unavailable'}</div>
</StageCard>
<StageCard at={item.command_at} status={item.command_id ? 'Command recorded' : 'No command'} title="3. Executor command">
<IdentifierRow label="Command" value={item.command_id} />
<div>{formatTerms(item.submitted_terms)}</div>
</StageCard>
<StageCard at={item.execution_result_at} status={item.execution?.status || 'No relay result'} title="4. Relay response">
<div>{item.execution?.result_code || 'No executor result code stored'}</div>
<div className="status-subtle">Submitted means the relay accepted the response; it does not prove a trade.</div>
</StageCard>
<StageCard at={item.outcome_observed_at} status={item.outcome_status || item.lifecycle_state} title="5. Outcome and settlement">
<div>{item.reason_text}</div>
<div className="status-subtle">{item.settlement_summary?.text || 'No settled inventory delta is linked to this quote.'}</div>
{item.settlement_summary?.caveat ? <div className="status-subtle">{item.settlement_summary.caveat}</div> : null}
</StageCard>
</div>
<div className="trace-block">
<IdentifierRow label="Quote" value={item.quote_id} />
<IdentifierRow label="Decision" value={item.decision_id} />
<IdentifierRow label="Command" value={item.command_id} />
</div>
</div>
);
}
function QuoteLifecycleTable({ items }) {
const [expanded, setExpanded] = useState(() => new Set());
if (!items?.length) return <EmptyState>No quote lifecycle evidence has been observed yet.</EmptyState>;
function toggle(rowKey) {
setExpanded((current) => {
const next = new Set(current);
if (next.has(rowKey)) next.delete(rowKey);
else next.add(rowKey);
return next;
});
}
return (
<TableFrame>
<table className="decision-table lifecycle-table">
<table className="quote-lifecycle-table">
<thead>
<tr>
<th>At</th>
<th>Lifecycle</th>
<th>Time</th>
<th>Quote id</th>
<th>Request</th>
<th>Responded?</th>
<th>Result</th>
<th>Reason</th>
<th>Pair</th>
<th>Trace</th>
<th>Edge %</th>
<th>Notional</th>
<th>Edge / notional</th>
<th>Lifecycle</th>
</tr>
</thead>
<tbody>
{items.map((item, index) => (
<tr key={`${item.quote_id || item.decision_id || item.command_id || item.latest_stage_at || 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 (
<Fragment key={rowKey}>
<tr key={`${rowKey}:row`}>
<td>{formatTimestamp(item.latest_stage_at)}</td>
<td><IdentifierRow label="Quote" value={item.quote_id} /></td>
<td>
<div>{formatTerms(item.request_terms || item.submitted_terms)}</div>
<div className="status-subtle mono">{truncateMiddle(item.pair || '', 34)}</div>
</td>
<td>{responseLabel(item)}</td>
<td><Pill label={item.lifecycle_label} stateLabel={item.lifecycle_tone} /></td>
<td>
<div>{item.reason_text}</div>
<div className="status-subtle mono">{item.reason_code || 'reason_unknown'}</div>
</td>
<td className="mono truncate-cell" title={item.pair || ''}>{truncateMiddle(item.pair || '', 32)}</td>
<td>
<IdentifierRow label="Quote" value={item.quote_id} />
<IdentifierRow label="Decision" value={item.decision_id} />
<IdentifierRow label="Command" value={item.command_id} />
<div className={Number(item.gross_edge_pct) > 0 ? 'value-positive' : Number(item.gross_edge_pct) < 0 ? 'value-negative' : ''}>{item.gross_edge_pct ? `${item.gross_edge_pct}%` : 'Unavailable'}</div>
<div className="status-subtle">{item.eure_notional ? formatEur(item.eure_notional) : 'Notional unavailable'}</div>
</td>
<td>
<button className="button secondary" onClick={() => toggle(rowKey)} type="button">
{isExpanded ? 'Hide lifecycle' : 'Show lifecycle'}
</button>
</td>
<td className={Number(item.gross_edge_pct) > 0 ? 'value-positive' : Number(item.gross_edge_pct) < 0 ? 'value-negative' : ''}>{item.gross_edge_pct || ''}</td>
<td>{item.eure_notional || ''}</td>
</tr>
))}
{isExpanded ? (
<tr className="lifecycle-expanded-row" key={`${rowKey}:details`}>
<td colSpan={8}><LifecycleDetails item={item} /></td>
</tr>
) : null}
</Fragment>
);
})}
</tbody>
</table>
</TableFrame>
@ -70,6 +193,7 @@ function LifecycleTable({ items }) {
}
function SuccessfulTradesTable({ items }) {
const [expanded, setExpanded] = useState(() => new Set());
if (!items?.length) {
return (
<EmptyState>
@ -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 (
<TableFrame>
<table className="decision-table lifecycle-table">
<table className="successful-trades-table">
<thead>
<tr>
<th>Completed at</th>
<th>Pair</th>
<th>Trace</th>
<th>Outcome</th>
<th>Completed</th>
<th>Quote id</th>
<th>Edge</th>
<th>Gross edge est.</th>
<th>Settlement</th>
<th>Realized PnL</th>
<th>Lifecycle</th>
</tr>
</thead>
<tbody>
{items.map((item, index) => (
<tr key={`${item.quote_id || item.command_id || item.latest_stage_at || index}`}>
{items.map((item, index) => {
const rowKey = item.quote_id || item.command_id || item.latest_stage_at || String(index);
const isExpanded = expanded.has(rowKey);
return (
<Fragment key={rowKey}>
<tr key={`${rowKey}:trade`}>
<td>{formatTimestamp(item.latest_stage_at)}</td>
<td className="mono truncate-cell" title={item.pair || ''}>{truncateMiddle(item.pair || '', 32)}</td>
<td><IdentifierRow label="Quote" value={item.quote_id} /></td>
<td>
<IdentifierRow label="Quote" value={item.quote_id} />
<IdentifierRow label="Decision" value={item.decision_id} />
<IdentifierRow label="Command" value={item.command_id} />
<div>{item.gross_edge_pct ? `${item.gross_edge_pct}%` : 'Unavailable'}</div>
<div className="status-subtle">{item.eure_notional ? formatEur(item.eure_notional) : 'Notional unavailable'}</div>
</td>
<td>
<div>{grossEdgeEstimate(item)}</div>
<div className="status-subtle">Estimate from edge x notional, not realized PnL.</div>
</td>
<td>{item.reason_text}</td>
<td>
<div>{item.settlement_summary?.text || 'No settled inventory delta is linked to this quote.'}</div>
{item.settlement_summary?.method ? (
<div className="status-subtle mono">{item.settlement_summary.method}</div>
) : null}
{item.settlement_summary?.observed_at ? (
<div className="status-subtle">{`Observed ${formatTimestamp(item.settlement_summary.observed_at)}`}</div>
) : null}
{item.settlement_summary?.caveat ? (
<div className="status-subtle">{item.settlement_summary.caveat}</div>
) : null}
{item.settlement_summary?.method ? <div className="status-subtle mono">{item.settlement_summary.method}</div> : null}
</td>
<td>
<div>Unavailable</div>
<div className="status-subtle">Fees and venue-native terminal fill are not stored.</div>
</td>
<td>
<button className="button secondary" onClick={() => toggle(rowKey)} type="button">
{isExpanded ? 'Hide lifecycle' : 'Show lifecycle'}
</button>
</td>
</tr>
))}
{isExpanded ? (
<tr className="lifecycle-expanded-row" key={`${rowKey}:trade-details`}>
<td colSpan={7}><LifecycleDetails item={item} /></td>
</tr>
) : null}
</Fragment>
);
})}
</tbody>
</table>
</TableFrame>
@ -130,66 +279,51 @@ export default function StrategyPage({ strategy }) {
<section className="panel">
<div className="panel-head">
<div>
<div className="eyebrow">Trading state</div>
<h2>Strategy and executor</h2>
<div className="eyebrow">Trading evidence</div>
<h2>Quotes, responses, and proven trades</h2>
<div className="panel-subtitle">
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.
</div>
</div>
</div>
<div className="metric-grid">
<MetricCard label="Successful trades" meta="Requires linked terminal outcome and settlement" value={String(funnel.successful_trade_count || 0)} />
<MetricCard label="Not filled" meta="Submitted but no settled inventory delta" value={String(counts.not_filled || 0)} />
<MetricCard label="Awaiting outcome" meta="Submitted, no durable terminal result yet" value={String(funnel.unresolved_submission_count || 0)} />
<MetricCard label="No-trade rows" meta="Filtered, rejected, blocked, failed, or unresolved" value={String(funnel.no_trade_count || 0)} />
<MetricCard label="Rejected / blocked" meta="Strategy rejection or executor block" value={String((counts.rejected || 0) + (counts.blocked || 0))} />
<MetricCard label="Strategy armed" meta={`Paused ${formatBoolean(strategy.strategy_state.paused)}`} value={formatBoolean(strategy.strategy_state.armed)} />
<MetricCard label="Executor armed" meta={`Paused ${formatBoolean(strategy.executor_state.paused)}`} value={formatBoolean(strategy.executor_state.armed)} />
<MetricCard label="Signer registered" meta={strategy.executor_state.account_id || ''} value={formatBoolean(strategy.executor_state.signer_registered)} />
</div>
</section>
<section className="panel">
<div className="panel-head">
<div>
<div className="eyebrow">Successful trades</div>
<div className="eyebrow">Successful trades only</div>
<h3>Trades with proven asset movement</h3>
<div className="panel-subtitle">{funnel.caveat}</div>
<div className="panel-subtitle">
This table excludes submitted-only quote responses. Realized PnL remains unavailable until fees and venue-native terminal fills are stored.
</div>
</div>
<div className="pills">
<Pill label={`${counts.completed || 0} completed`} stateLabel={(counts.completed || 0) > 0 ? 'healthy' : 'unknown'} />
<Pill label={`${counts.not_filled || 0} not filled`} stateLabel={(counts.not_filled || 0) > 0 ? 'warning' : 'unknown'} />
<Pill label={`${counts.submitted || 0} submitted only`} stateLabel={(counts.submitted || 0) > 0 ? 'info' : 'unknown'} />
</div>
</div>
<SuccessfulTradesTable items={funnel.successful_trades} />
</section>
<section className="strategy-layout">
<div className="panel">
<section className="panel full-width-evidence-panel">
<div className="panel-head">
<div>
<div className="eyebrow">Why quotes are not trades</div>
<h3>Recent quote outcomes and blockers</h3>
<div className="eyebrow">Quote lifecycle</div>
<h3>Incoming quotes and what happened next</h3>
<div className="panel-subtitle">
Each row answers why the quote was filtered, rejected, blocked, submitted without outcome, failed, not filled, or completed. Submission still never means asset movement.
Full-width quote table: incoming quote, response decision, result, decisive reason, and expandable lifecycle stages.
</div>
</div>
</div>
<LifecycleTable items={strategy.strategy_state.recent_lifecycle_rows} />
</div>
<div className="panel strategy-side-panel">
<div className="panel-head">
<div>
<div className="eyebrow">Controls</div>
<h3>Omitted risky controls</h3>
</div>
</div>
<EmptyState>
{strategy.omitted_controls.map((item) => (
<div key={item}>{item}</div>
))}
</EmptyState>
</div>
<QuoteLifecycleTable items={strategy.strategy_state.recent_lifecycle_rows} />
</section>
</>
);

View file

@ -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);
}

View file

@ -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/);
});

View file

@ -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');
});