Consolidate quote lifecycle dashboard
All checks were successful
deploy / deploy (push) Successful in 31s
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:
parent
fa7e8c885f
commit
9eb1f7b80e
7 changed files with 495 additions and 268 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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' ? (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
23
test/operator-dashboard-ui-static.test.mjs
Normal file
23
test/operator-dashboard-ui-static.test.mjs
Normal 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/);
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue