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();
|
const rowsByKey = new Map();
|
||||||
|
|
||||||
for (const quote of recentQuotes || []) {
|
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, {
|
mergeLifecycleEvidence(row, {
|
||||||
quote_id: quote?.quote_id || null,
|
quote_id: normalizedQuote?.quote_id || null,
|
||||||
pair: quote?.pair || null,
|
pair: normalizedQuote?.pair || null,
|
||||||
request_kind: quote?.request_kind || null,
|
request_kind: normalizedQuote?.request_kind || null,
|
||||||
quote_observed_at: quote?.observed_at || quote?.ingested_at || 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,
|
pair: command.pair,
|
||||||
direction: command.direction,
|
direction: command.direction,
|
||||||
request_kind: command.request_kind,
|
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,
|
||||||
command_at: command.command_at || null,
|
command_at: command.command_at || null,
|
||||||
});
|
});
|
||||||
|
|
@ -857,9 +867,14 @@ function ensureLifecycleRow(rowsByKey, key) {
|
||||||
eure_notional: null,
|
eure_notional: null,
|
||||||
quote_observed_at: null,
|
quote_observed_at: null,
|
||||||
decision_at: null,
|
decision_at: null,
|
||||||
|
asset_in: null,
|
||||||
|
asset_out: null,
|
||||||
|
amount_in: null,
|
||||||
|
amount_out: null,
|
||||||
command_at: null,
|
command_at: null,
|
||||||
execution_result_at: null,
|
execution_result_at: null,
|
||||||
outcome_observed_at: null,
|
outcome_observed_at: null,
|
||||||
|
quote: null,
|
||||||
decision: null,
|
decision: null,
|
||||||
command: null,
|
command: null,
|
||||||
execution: null,
|
execution: null,
|
||||||
|
|
@ -875,6 +890,7 @@ function mergeLifecycleEvidence(row, next) {
|
||||||
row[key] = value;
|
row[key] = value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (next?.quote) row.quote = next.quote;
|
||||||
if (next?.decision) row.decision = next.decision;
|
if (next?.decision) row.decision = next.decision;
|
||||||
if (next?.command) row.command = next.command;
|
if (next?.command) row.command = next.command;
|
||||||
if (next?.execution) row.execution = next.execution;
|
if (next?.execution) row.execution = next.execution;
|
||||||
|
|
@ -1041,6 +1057,22 @@ function normalizeLifecycleToken(value) {
|
||||||
.replace(/[\s-]+/g, '_');
|
.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) {
|
function normalizeCommand(command) {
|
||||||
if (!command) return null;
|
if (!command) return null;
|
||||||
return {
|
return {
|
||||||
|
|
@ -1053,6 +1085,8 @@ function normalizeCommand(command) {
|
||||||
request_kind: command.request_kind || null,
|
request_kind: command.request_kind || null,
|
||||||
asset_in: command.asset_in || null,
|
asset_in: command.asset_in || null,
|
||||||
asset_out: command.asset_out || 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,
|
command_at: command.command_at || command.observed_at || command.ingested_at || null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -1345,6 +1379,15 @@ function normalizeTradeForUi({ config, trade }) {
|
||||||
function enrichLifecycleRowForUi({ config, row }) {
|
function enrichLifecycleRowForUi({ config, row }) {
|
||||||
return {
|
return {
|
||||||
...row,
|
...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({
|
settlement_summary: buildSettlementSummary({
|
||||||
config,
|
config,
|
||||||
delta: row.attributed_inventory_delta,
|
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 }) {
|
function buildSettlementSummary({ config, delta, attributionStatus, attributionMethod }) {
|
||||||
if (!delta?.delta_units) {
|
if (!delta?.delta_units) {
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import StrategyPage from './pages/StrategyPage.jsx';
|
||||||
import SystemPage from './pages/SystemPage.jsx';
|
import SystemPage from './pages/SystemPage.jsx';
|
||||||
import { dashboardReducer, initialDashboardState } from './state/dashboardReducer.js';
|
import { dashboardReducer, initialDashboardState } from './state/dashboardReducer.js';
|
||||||
|
|
||||||
const TRADE_PAGE_SIZE = 20;
|
const BOOTSTRAP_PAGE_SIZE = 20;
|
||||||
|
|
||||||
function LoadingPanel() {
|
function LoadingPanel() {
|
||||||
return (
|
return (
|
||||||
|
|
@ -27,27 +27,11 @@ export default function App() {
|
||||||
const criticalBanner = null;
|
const criticalBanner = null;
|
||||||
|
|
||||||
async function loadBootstrap(page = 1) {
|
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 });
|
dispatch({ type: 'bootstrap.loaded', dashboard });
|
||||||
return 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 } = {}) {
|
async function submitControl(service, action, body = {}, { reload = true } = {}) {
|
||||||
dispatch({ type: 'notice.changed', notice: `${action} in progress` });
|
dispatch({ type: 'notice.changed', notice: `${action} in progress` });
|
||||||
dispatch({ type: 'error.changed', error: null });
|
dispatch({ type: 'error.changed', error: null });
|
||||||
|
|
@ -65,8 +49,7 @@ export default function App() {
|
||||||
dispatch({ type: 'notice.changed', notice: `${action} completed` });
|
dispatch({ type: 'notice.changed', notice: `${action} completed` });
|
||||||
|
|
||||||
if (reload) {
|
if (reload) {
|
||||||
const page = state.dashboard?.funds?.submission_ledger?.page || 1;
|
await loadBootstrap(1);
|
||||||
await loadBootstrap(page);
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
dispatch({ type: 'error.changed', error: error.message });
|
dispatch({ type: 'error.changed', error: error.message });
|
||||||
|
|
@ -168,7 +151,6 @@ export default function App() {
|
||||||
funds={state.dashboard.funds}
|
funds={state.dashboard.funds}
|
||||||
lastControlResult={state.lastControlResult}
|
lastControlResult={state.lastControlResult}
|
||||||
onControl={submitControl}
|
onControl={submitControl}
|
||||||
onTradesPageChange={loadTradesPage}
|
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{currentPage === 'strategy' ? (
|
{currentPage === 'strategy' ? (
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ import MetricCard from '../components/MetricCard.jsx';
|
||||||
import Pill from '../components/Pill.jsx';
|
import Pill from '../components/Pill.jsx';
|
||||||
import TableFrame from '../components/TableFrame.jsx';
|
import TableFrame from '../components/TableFrame.jsx';
|
||||||
import { formatEur, formatTimestamp, stringifyJson, truncateMiddle } from '../lib/format.js';
|
import { formatEur, formatTimestamp, stringifyJson, truncateMiddle } from '../lib/format.js';
|
||||||
import { SUBMISSION_COPY } from '../lib/submissionCopy.js';
|
|
||||||
|
|
||||||
function buildInitialEstimateForm(balances, withdrawalDefaults) {
|
function buildInitialEstimateForm(balances, withdrawalDefaults) {
|
||||||
const firstAssetId = balances?.[0]?.asset_id || '';
|
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 }) {
|
function WithdrawalEstimateForm({ balances, withdrawalDefaults, onControl }) {
|
||||||
const [form, setForm] = useState(() => buildInitialEstimateForm(balances, withdrawalDefaults));
|
const [form, setForm] = useState(() => buildInitialEstimateForm(balances, withdrawalDefaults));
|
||||||
|
|
||||||
|
|
@ -365,12 +271,10 @@ function LastControlResult({ result }) {
|
||||||
|
|
||||||
export default function FundsPage({
|
export default function FundsPage({
|
||||||
funds,
|
funds,
|
||||||
onTradesPageChange,
|
|
||||||
onControl,
|
onControl,
|
||||||
lastControlResult,
|
lastControlResult,
|
||||||
}) {
|
}) {
|
||||||
const profitability = funds.profitability;
|
const profitability = funds.profitability;
|
||||||
const submissionLedger = funds.submission_ledger;
|
|
||||||
const controlState = funds.funding.control_state || {};
|
const controlState = funds.funding.control_state || {};
|
||||||
const externalFlowAdjusted = profitability.external_flow_adjusted;
|
const externalFlowAdjusted = profitability.external_flow_adjusted;
|
||||||
const externalFlowCount = profitability.external_flow_count || 0;
|
const externalFlowCount = profitability.external_flow_count || 0;
|
||||||
|
|
@ -446,11 +350,6 @@ export default function FundsPage({
|
||||||
signedValue={profitability.portfolio_vs_simple_hold_eure}
|
signedValue={profitability.portfolio_vs_simple_hold_eure}
|
||||||
value={formatEur(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>
|
||||||
<div className="panel-subtitle">
|
<div className="panel-subtitle">
|
||||||
{profitability.caveats.map((item) => (
|
{profitability.caveats.map((item) => (
|
||||||
|
|
@ -554,60 +453,6 @@ export default function FundsPage({
|
||||||
<FundingTable items={funds.funding.recent_observations} />
|
<FundingTable items={funds.funding.recent_observations} />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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 EmptyState from '../components/EmptyState.jsx';
|
||||||
import MetricCard from '../components/MetricCard.jsx';
|
import MetricCard from '../components/MetricCard.jsx';
|
||||||
import Pill from '../components/Pill.jsx';
|
import Pill from '../components/Pill.jsx';
|
||||||
import TableFrame from '../components/TableFrame.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) {
|
async function copyIdentifier(value) {
|
||||||
if (!value || !navigator?.clipboard?.writeText) return;
|
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>;
|
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 (
|
return (
|
||||||
<TableFrame>
|
<TableFrame>
|
||||||
<table className="decision-table lifecycle-table">
|
<table className="quote-lifecycle-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>At</th>
|
<th>Time</th>
|
||||||
<th>Lifecycle</th>
|
<th>Quote id</th>
|
||||||
|
<th>Request</th>
|
||||||
|
<th>Responded?</th>
|
||||||
|
<th>Result</th>
|
||||||
<th>Reason</th>
|
<th>Reason</th>
|
||||||
<th>Pair</th>
|
<th>Edge / notional</th>
|
||||||
<th>Trace</th>
|
<th>Lifecycle</th>
|
||||||
<th>Edge %</th>
|
|
||||||
<th>Notional</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{items.map((item, index) => (
|
{items.map((item, index) => {
|
||||||
<tr key={`${item.quote_id || item.decision_id || item.command_id || item.latest_stage_at || index}`}>
|
const rowKey = item.quote_id || item.decision_id || item.command_id || item.latest_stage_at || String(index);
|
||||||
<td>{formatTimestamp(item.latest_stage_at)}</td>
|
const isExpanded = expanded.has(rowKey);
|
||||||
<td><Pill label={item.lifecycle_label} stateLabel={item.lifecycle_tone} /></td>
|
return (
|
||||||
<td>
|
<Fragment key={rowKey}>
|
||||||
<div>{item.reason_text}</div>
|
<tr key={`${rowKey}:row`}>
|
||||||
<div className="status-subtle mono">{item.reason_code || 'reason_unknown'}</div>
|
<td>{formatTimestamp(item.latest_stage_at)}</td>
|
||||||
</td>
|
<td><IdentifierRow label="Quote" value={item.quote_id} /></td>
|
||||||
<td className="mono truncate-cell" title={item.pair || ''}>{truncateMiddle(item.pair || '', 32)}</td>
|
<td>
|
||||||
<td>
|
<div>{formatTerms(item.request_terms || item.submitted_terms)}</div>
|
||||||
<IdentifierRow label="Quote" value={item.quote_id} />
|
<div className="status-subtle mono">{truncateMiddle(item.pair || '', 34)}</div>
|
||||||
<IdentifierRow label="Decision" value={item.decision_id} />
|
</td>
|
||||||
<IdentifierRow label="Command" value={item.command_id} />
|
<td>{responseLabel(item)}</td>
|
||||||
</td>
|
<td><Pill label={item.lifecycle_label} stateLabel={item.lifecycle_tone} /></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>
|
||||||
<td>{item.eure_notional || ''}</td>
|
<div>{item.reason_text}</div>
|
||||||
</tr>
|
<div className="status-subtle mono">{item.reason_code || 'reason_unknown'}</div>
|
||||||
))}
|
</td>
|
||||||
|
<td>
|
||||||
|
<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>
|
||||||
|
</tr>
|
||||||
|
{isExpanded ? (
|
||||||
|
<tr className="lifecycle-expanded-row" key={`${rowKey}:details`}>
|
||||||
|
<td colSpan={8}><LifecycleDetails item={item} /></td>
|
||||||
|
</tr>
|
||||||
|
) : null}
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</TableFrame>
|
</TableFrame>
|
||||||
|
|
@ -70,6 +193,7 @@ function LifecycleTable({ items }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function SuccessfulTradesTable({ items }) {
|
function SuccessfulTradesTable({ items }) {
|
||||||
|
const [expanded, setExpanded] = useState(() => new Set());
|
||||||
if (!items?.length) {
|
if (!items?.length) {
|
||||||
return (
|
return (
|
||||||
<EmptyState>
|
<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 (
|
return (
|
||||||
<TableFrame>
|
<TableFrame>
|
||||||
<table className="decision-table lifecycle-table">
|
<table className="successful-trades-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Completed at</th>
|
<th>Completed</th>
|
||||||
<th>Pair</th>
|
<th>Quote id</th>
|
||||||
<th>Trace</th>
|
<th>Edge</th>
|
||||||
<th>Outcome</th>
|
<th>Gross edge est.</th>
|
||||||
<th>Settlement</th>
|
<th>Settlement</th>
|
||||||
|
<th>Realized PnL</th>
|
||||||
|
<th>Lifecycle</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{items.map((item, index) => (
|
{items.map((item, index) => {
|
||||||
<tr key={`${item.quote_id || item.command_id || item.latest_stage_at || index}`}>
|
const rowKey = item.quote_id || item.command_id || item.latest_stage_at || String(index);
|
||||||
<td>{formatTimestamp(item.latest_stage_at)}</td>
|
const isExpanded = expanded.has(rowKey);
|
||||||
<td className="mono truncate-cell" title={item.pair || ''}>{truncateMiddle(item.pair || '', 32)}</td>
|
return (
|
||||||
<td>
|
<Fragment key={rowKey}>
|
||||||
<IdentifierRow label="Quote" value={item.quote_id} />
|
<tr key={`${rowKey}:trade`}>
|
||||||
<IdentifierRow label="Decision" value={item.decision_id} />
|
<td>{formatTimestamp(item.latest_stage_at)}</td>
|
||||||
<IdentifierRow label="Command" value={item.command_id} />
|
<td><IdentifierRow label="Quote" value={item.quote_id} /></td>
|
||||||
</td>
|
<td>
|
||||||
<td>{item.reason_text}</td>
|
<div>{item.gross_edge_pct ? `${item.gross_edge_pct}%` : 'Unavailable'}</div>
|
||||||
<td>
|
<div className="status-subtle">{item.eure_notional ? formatEur(item.eure_notional) : 'Notional unavailable'}</div>
|
||||||
<div>{item.settlement_summary?.text || 'No settled inventory delta is linked to this quote.'}</div>
|
</td>
|
||||||
{item.settlement_summary?.method ? (
|
<td>
|
||||||
<div className="status-subtle mono">{item.settlement_summary.method}</div>
|
<div>{grossEdgeEstimate(item)}</div>
|
||||||
|
<div className="status-subtle">Estimate from edge x notional, not realized PnL.</div>
|
||||||
|
</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}
|
||||||
|
</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}
|
) : null}
|
||||||
{item.settlement_summary?.observed_at ? (
|
</Fragment>
|
||||||
<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}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</TableFrame>
|
</TableFrame>
|
||||||
|
|
@ -130,66 +279,51 @@ export default function StrategyPage({ strategy }) {
|
||||||
<section className="panel">
|
<section className="panel">
|
||||||
<div className="panel-head">
|
<div className="panel-head">
|
||||||
<div>
|
<div>
|
||||||
<div className="eyebrow">Trading state</div>
|
<div className="eyebrow">Trading evidence</div>
|
||||||
<h2>Strategy and executor</h2>
|
<h2>Quotes, responses, and proven trades</h2>
|
||||||
<div className="panel-subtitle">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<div className="metric-grid">
|
<div className="metric-grid">
|
||||||
<MetricCard label="Successful trades" meta="Requires linked terminal outcome and settlement" value={String(funnel.successful_trade_count || 0)} />
|
<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="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="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="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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="panel">
|
<section className="panel">
|
||||||
<div className="panel-head">
|
<div className="panel-head">
|
||||||
<div>
|
<div>
|
||||||
<div className="eyebrow">Successful trades</div>
|
<div className="eyebrow">Successful trades only</div>
|
||||||
<h3>Trades with proven asset movement</h3>
|
<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>
|
||||||
<div className="pills">
|
<div className="pills">
|
||||||
<Pill label={`${counts.completed || 0} completed`} stateLabel={(counts.completed || 0) > 0 ? 'healthy' : 'unknown'} />
|
<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.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>
|
||||||
</div>
|
</div>
|
||||||
<SuccessfulTradesTable items={funnel.successful_trades} />
|
<SuccessfulTradesTable items={funnel.successful_trades} />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="strategy-layout">
|
<section className="panel full-width-evidence-panel">
|
||||||
<div className="panel">
|
<div className="panel-head">
|
||||||
<div className="panel-head">
|
<div>
|
||||||
<div>
|
<div className="eyebrow">Quote lifecycle</div>
|
||||||
<div className="eyebrow">Why quotes are not trades</div>
|
<h3>Incoming quotes and what happened next</h3>
|
||||||
<h3>Recent quote outcomes and blockers</h3>
|
<div className="panel-subtitle">
|
||||||
<div className="panel-subtitle">
|
Full-width quote table: incoming quote, response decision, result, decisive reason, and expandable lifecycle stages.
|
||||||
Each row answers why the quote was filtered, rejected, blocked, submitted without outcome, failed, not filled, or completed. Submission still never means asset movement.
|
|
||||||
</div>
|
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
<QuoteLifecycleTable items={strategy.strategy_state.recent_lifecycle_rows} />
|
||||||
</section>
|
</section>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -535,3 +535,112 @@ table.lifecycle-table th:nth-child(5) {
|
||||||
min-width: 540px;
|
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.funding.recent_observations[0].tx_hash, 'eth-tx-1');
|
||||||
assert.equal(bootstrap.funds.recent_deposits[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