Fix quote lifecycle dashboard semantics
All checks were successful
deploy / deploy (push) Successful in 32s
All checks were successful
deploy / deploy (push) Successful in 32s
Proof: Quote lifecycle truth and execution explanation is rendered from durable quote, decision, command, and execution evidence without treating submission as completion or realized asset movement. Assumptions: Pushes to main trigger the repo-owned deployment workflow, and current durable records do not yet contain authoritative downstream venue outcomes for recent submissions. Still fake: Completed and not-filled terminal trade outcomes remain unavailable unless explicit durable outcome evidence is stored; submitted rows therefore remain submission-only evidence.
This commit is contained in:
parent
b695f60bc6
commit
e35bb9ab8f
10 changed files with 721 additions and 163 deletions
|
|
@ -32,10 +32,12 @@ import {
|
|||
loadLatestPortfolioMetric,
|
||||
loadRecentAlertTransitions,
|
||||
loadRecentDepositStatuses,
|
||||
loadRecentExecuteTradeCommands,
|
||||
loadRecentExecutionResults,
|
||||
loadRecentTradeDecisions,
|
||||
loadRecentQuotes,
|
||||
loadSuccessfulTradeSummary,
|
||||
loadSuccessfulTradesPage,
|
||||
loadSubmissionPage,
|
||||
loadSubmissionSummary,
|
||||
} from '../lib/postgres.mjs';
|
||||
|
||||
const config = loadConfig();
|
||||
|
|
@ -79,10 +81,10 @@ const initialRecentQuotes = await safeSourceLoad(
|
|||
}),
|
||||
[],
|
||||
);
|
||||
const initialSuccessfulTradeSummary = await safeSourceLoad(
|
||||
'successful_trade_summary',
|
||||
() => loadSuccessfulTradeSummary(pool),
|
||||
{ total: 0, last_successful_trade_at: null },
|
||||
const initialSubmissionSummary = await safeSourceLoad(
|
||||
'submission_summary',
|
||||
() => loadSubmissionSummary(pool),
|
||||
{ total: 0, last_submission_at: null },
|
||||
);
|
||||
const initialMarketPrice = await safeSourceLoad(
|
||||
'latest_market_price',
|
||||
|
|
@ -100,8 +102,8 @@ const liveState = createDashboardLiveState({
|
|||
recentQuotes: initialRecentQuotes,
|
||||
latestMarketPrice: initialMarketPrice,
|
||||
latestInventory: initialInventory,
|
||||
successfulTradeCount: initialSuccessfulTradeSummary.total,
|
||||
lastSuccessfulTradeAt: initialSuccessfulTradeSummary.last_successful_trade_at,
|
||||
recentSubmissionCount: initialSubmissionSummary.total,
|
||||
lastSubmissionAt: initialSubmissionSummary.last_submission_at,
|
||||
activeAlerts:
|
||||
initialServiceSnapshots.find((snapshot) => snapshot.service === 'ops-sentinel')?.state?.active_alerts
|
||||
|| [],
|
||||
|
|
@ -287,16 +289,16 @@ async function handleApiRequest({ req, res, url, auth }) {
|
|||
return sendJson(res, 200, payload);
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && url.pathname === '/api/trades') {
|
||||
if (req.method === 'GET' && (url.pathname === '/api/submissions' || url.pathname === '/api/trades')) {
|
||||
const page = Number(url.searchParams.get('page') || 1);
|
||||
const pageSize = Number(
|
||||
url.searchParams.get('page_size') || config.operatorDashboardTradePageSize,
|
||||
);
|
||||
const successfulTrades = await loadSuccessfulTradesPage(pool, {
|
||||
const submissionPage = await loadSubmissionPage(pool, {
|
||||
page,
|
||||
pageSize,
|
||||
});
|
||||
return sendJson(res, 200, successfulTrades);
|
||||
return sendJson(res, 200, submissionPage);
|
||||
}
|
||||
|
||||
const controlMatch = req.method === 'POST'
|
||||
|
|
@ -334,11 +336,13 @@ async function loadBootstrapPayload({ auth, page, pageSize }) {
|
|||
inventorySnapshot,
|
||||
marketPrice,
|
||||
recentQuotes,
|
||||
successfulTradeSummary,
|
||||
successfulTrades,
|
||||
submissionSummary,
|
||||
submissionPage,
|
||||
fundingObservations,
|
||||
recentDepositStatuses,
|
||||
recentTradeDecisions,
|
||||
recentExecuteTradeCommands,
|
||||
recentExecutionResults,
|
||||
recentAlertTransitions,
|
||||
serviceSnapshots,
|
||||
] = await Promise.all([
|
||||
|
|
@ -354,14 +358,14 @@ async function loadBootstrapPayload({ auth, page, pageSize }) {
|
|||
sourceErrors,
|
||||
),
|
||||
safeSourceLoad(
|
||||
'successful_trade_summary',
|
||||
() => loadSuccessfulTradeSummary(pool),
|
||||
{ total: 0, last_successful_trade_at: null },
|
||||
'submission_summary',
|
||||
() => loadSubmissionSummary(pool),
|
||||
{ total: 0, last_submission_at: null },
|
||||
sourceErrors,
|
||||
),
|
||||
safeSourceLoad(
|
||||
'successful_trades',
|
||||
() => loadSuccessfulTradesPage(pool, {
|
||||
'submission_page',
|
||||
() => loadSubmissionPage(pool, {
|
||||
page,
|
||||
pageSize,
|
||||
}),
|
||||
|
|
@ -387,6 +391,18 @@ async function loadBootstrapPayload({ auth, page, pageSize }) {
|
|||
[],
|
||||
sourceErrors,
|
||||
),
|
||||
safeSourceLoad(
|
||||
'recent_execute_trade_commands',
|
||||
() => loadRecentExecuteTradeCommands(pool, { limit: 40 }),
|
||||
[],
|
||||
sourceErrors,
|
||||
),
|
||||
safeSourceLoad(
|
||||
'recent_execution_results',
|
||||
() => loadRecentExecutionResults(pool, { limit: 40 }),
|
||||
[],
|
||||
sourceErrors,
|
||||
),
|
||||
safeSourceLoad(
|
||||
'recent_alert_transitions',
|
||||
() => loadRecentAlertTransitions(pool, { limit: 20 }),
|
||||
|
|
@ -403,11 +419,13 @@ async function loadBootstrapPayload({ auth, page, pageSize }) {
|
|||
inventorySnapshot,
|
||||
marketPrice,
|
||||
recentQuotes,
|
||||
successfulTrades,
|
||||
successfulTradeSummary,
|
||||
submissionPage,
|
||||
submissionSummary,
|
||||
fundingObservations,
|
||||
recentDepositStatuses,
|
||||
recentTradeDecisions,
|
||||
recentExecuteTradeCommands,
|
||||
recentExecutionResults,
|
||||
recentAlertTransitions,
|
||||
serviceSnapshots,
|
||||
sourceErrors,
|
||||
|
|
|
|||
|
|
@ -227,8 +227,8 @@ export function createDashboardLiveState({
|
|||
recentQuotes = [],
|
||||
latestMarketPrice = null,
|
||||
latestInventory = null,
|
||||
successfulTradeCount = 0,
|
||||
lastSuccessfulTradeAt = null,
|
||||
recentSubmissionCount = 0,
|
||||
lastSubmissionAt = null,
|
||||
activeAlerts = [],
|
||||
} = {}) {
|
||||
const state = {
|
||||
|
|
@ -239,8 +239,8 @@ export function createDashboardLiveState({
|
|||
recent_quotes: recentQuotes.slice(0, config.operatorDashboardQuoteLimit || DASHBOARD_LIVE_QUOTE_LIMIT),
|
||||
latest_market_price: latestMarketPrice?.payload || latestMarketPrice || null,
|
||||
latest_inventory: latestInventory?.payload || latestInventory || null,
|
||||
successful_trade_count: Number(successfulTradeCount || 0),
|
||||
last_successful_trade_at: lastSuccessfulTradeAt || null,
|
||||
recent_submission_count: Number(recentSubmissionCount || 0),
|
||||
last_submission_at: lastSubmissionAt || null,
|
||||
active_alerts: new Map(),
|
||||
};
|
||||
|
||||
|
|
@ -306,8 +306,8 @@ export function applyDashboardLiveEvent(state, { topic, event }) {
|
|||
}
|
||||
case 'exec.trade_result':
|
||||
if (event.payload.status !== 'submitted') return [];
|
||||
state.successful_trade_count += 1;
|
||||
state.last_successful_trade_at = event.observed_at || event.ingested_at || new Date().toISOString();
|
||||
state.recent_submission_count += 1;
|
||||
state.last_submission_at = event.observed_at || event.ingested_at || new Date().toISOString();
|
||||
return [{
|
||||
type: 'status_bar.updated',
|
||||
status_bar: buildLiveStatusBar(state),
|
||||
|
|
@ -324,11 +324,13 @@ export function buildDashboardBootstrap({
|
|||
inventorySnapshot,
|
||||
marketPrice,
|
||||
recentQuotes,
|
||||
successfulTrades,
|
||||
successfulTradeSummary,
|
||||
submissionPage,
|
||||
submissionSummary,
|
||||
fundingObservations,
|
||||
recentDepositStatuses,
|
||||
recentTradeDecisions,
|
||||
recentExecuteTradeCommands,
|
||||
recentExecutionResults,
|
||||
recentAlertTransitions,
|
||||
serviceSnapshots,
|
||||
sourceErrors = [],
|
||||
|
|
@ -346,7 +348,7 @@ export function buildDashboardBootstrap({
|
|||
));
|
||||
const profitability = buildProfitabilitySummary({
|
||||
metric: portfolioMetric,
|
||||
successfulTradeSummary,
|
||||
submissionSummary,
|
||||
});
|
||||
const balances = buildBalanceSummary({
|
||||
inventorySnapshot,
|
||||
|
|
@ -359,9 +361,9 @@ export function buildDashboardBootstrap({
|
|||
recentDepositStatuses,
|
||||
liquidityState: servicesByName['liquidity-manager']?.state || {},
|
||||
});
|
||||
const tradesPage = normalizeSuccessfulTradesPage({
|
||||
const normalizedSubmissionPage = normalizeSubmissionPage({
|
||||
config,
|
||||
successfulTrades,
|
||||
submissionPage,
|
||||
});
|
||||
|
||||
return {
|
||||
|
|
@ -385,19 +387,22 @@ export function buildDashboardBootstrap({
|
|||
config,
|
||||
liquidityState: servicesByName['liquidity-manager']?.state || {},
|
||||
}),
|
||||
trade_asset_changes: buildTradeAssetChanges({
|
||||
recent_submission_terms: buildRecentSubmissionTerms({
|
||||
config,
|
||||
trades: tradesPage.items,
|
||||
submissions: normalizedSubmissionPage.items,
|
||||
}),
|
||||
recent_quotes: (recentQuotes || []).slice(0, config.operatorDashboardQuoteLimit || DASHBOARD_LIVE_QUOTE_LIMIT),
|
||||
successful_trades: tradesPage,
|
||||
submission_ledger: normalizedSubmissionPage,
|
||||
controls: listDashboardControls({ page: 'funds' }),
|
||||
caveats: profitability.caveats,
|
||||
},
|
||||
strategy: buildStrategySummary({
|
||||
servicesByName,
|
||||
activeAlerts,
|
||||
recentQuotes,
|
||||
recentTradeDecisions,
|
||||
recentExecuteTradeCommands,
|
||||
recentExecutionResults,
|
||||
}),
|
||||
system: buildSystemSummary({
|
||||
servicesByName,
|
||||
|
|
@ -407,7 +412,7 @@ export function buildDashboardBootstrap({
|
|||
};
|
||||
}
|
||||
|
||||
export function buildProfitabilitySummary({ metric, successfulTradeSummary } = {}) {
|
||||
export function buildProfitabilitySummary({ metric, submissionSummary } = {}) {
|
||||
const externalCashFlows = metric?.payload?.external_cash_flows || {};
|
||||
const externalFlowCount = Number(externalCashFlows.flow_count || 0);
|
||||
const externalFlowAdjusted = externalFlowCount > 0;
|
||||
|
|
@ -428,8 +433,8 @@ export function buildProfitabilitySummary({ metric, successfulTradeSummary } = {
|
|||
external_withdrawal_count: Number(externalCashFlows.withdrawal_count || 0),
|
||||
latest_external_flow_at: externalCashFlows.latest_effective_at || null,
|
||||
net_external_flow_value_eure: externalCashFlows.net_value_eure_at_flow_time || '0',
|
||||
recent_trade_count: successfulTradeSummary?.total ?? metric?.payload?.result_count ?? 0,
|
||||
last_successful_trade_at: successfulTradeSummary?.last_successful_trade_at || null,
|
||||
recent_submission_count: submissionSummary?.total ?? 0,
|
||||
last_submission_at: submissionSummary?.last_submission_at || null,
|
||||
caveats: [
|
||||
'Portfolio PnL is truthful to the current durable inventory and reference price path.',
|
||||
'Fees and per-trade realized net settlement deltas are not fully tracked yet.',
|
||||
|
|
@ -506,8 +511,8 @@ export function buildLiveStatusBar(state) {
|
|||
}),
|
||||
active_alert_count: state.active_alerts.size,
|
||||
highest_alert_severity: highestAlertSeverity([...state.active_alerts.values()]),
|
||||
recent_trade_count: state.successful_trade_count,
|
||||
last_successful_trade_at: state.last_successful_trade_at,
|
||||
recent_submission_count: state.recent_submission_count,
|
||||
last_submission_at: state.last_submission_at,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -534,8 +539,8 @@ function buildStatusBar({
|
|||
strategy_armed: servicesByName['strategy-engine']?.state?.armed ?? null,
|
||||
executor_armed: servicesByName['trade-executor']?.state?.armed ?? null,
|
||||
current_total_portfolio_value_eure: profitability.current_total_portfolio_value_eure,
|
||||
recent_trade_count: profitability.recent_trade_count,
|
||||
last_successful_trade_at: profitability.last_successful_trade_at,
|
||||
recent_submission_count: profitability.recent_submission_count,
|
||||
last_submission_at: profitability.last_submission_at,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -675,37 +680,316 @@ function buildRecentWithdrawals({ config, liquidityState }) {
|
|||
});
|
||||
}
|
||||
|
||||
function buildTradeAssetChanges({ config, trades }) {
|
||||
return (trades || []).slice(0, 10).map((trade) => {
|
||||
const assetIn = config.assetRegistry.get(trade.asset_in);
|
||||
const assetOut = config.assetRegistry.get(trade.asset_out);
|
||||
function buildRecentSubmissionTerms({ config, submissions }) {
|
||||
return (submissions || []).slice(0, 10).map((submission) => {
|
||||
const assetIn = config.assetRegistry.get(submission.asset_in);
|
||||
const assetOut = config.assetRegistry.get(submission.asset_out);
|
||||
return {
|
||||
observed_at: trade.observed_at,
|
||||
quote_id: trade.quote_id,
|
||||
request_kind: trade.request_kind,
|
||||
asset_in: trade.asset_in,
|
||||
asset_in_symbol: assetIn?.symbol || trade.asset_in,
|
||||
amount_in_units: trade.amount_in,
|
||||
amount_in: formatUnits(trade.amount_in || '0', assetIn?.decimals || 0),
|
||||
asset_out: trade.asset_out,
|
||||
asset_out_symbol: assetOut?.symbol || trade.asset_out,
|
||||
amount_out_units: trade.amount_out,
|
||||
amount_out: formatUnits(trade.amount_out || '0', assetOut?.decimals || 0),
|
||||
observed_at: submission.observed_at,
|
||||
quote_id: submission.quote_id,
|
||||
request_kind: submission.request_kind,
|
||||
asset_in: submission.asset_in,
|
||||
asset_in_symbol: assetIn?.symbol || submission.asset_in,
|
||||
amount_in_units: submission.amount_in,
|
||||
amount_in: formatUnits(submission.amount_in || '0', assetIn?.decimals || 0),
|
||||
asset_out: submission.asset_out,
|
||||
asset_out_symbol: assetOut?.symbol || submission.asset_out,
|
||||
amount_out_units: submission.amount_out,
|
||||
amount_out: formatUnits(submission.amount_out || '0', assetOut?.decimals || 0),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeSuccessfulTradesPage({ config, successfulTrades }) {
|
||||
function normalizeSubmissionPage({ config, submissionPage }) {
|
||||
return {
|
||||
...successfulTrades,
|
||||
items: (successfulTrades?.items || []).map((trade) => normalizeTradeForUi({
|
||||
...submissionPage,
|
||||
items: (submissionPage?.items || []).map((trade) => normalizeTradeForUi({
|
||||
config,
|
||||
trade,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function buildStrategySummary({ servicesByName, activeAlerts, recentTradeDecisions = [] }) {
|
||||
const LIFECYCLE_TONE_BY_STATE = {
|
||||
observed: 'info',
|
||||
evaluated: 'healthy',
|
||||
command_emitted: 'info',
|
||||
rejected: 'warning',
|
||||
blocked: 'warning',
|
||||
failed: 'critical',
|
||||
submitted: 'healthy',
|
||||
awaiting_outcome: 'info',
|
||||
not_filled: 'warning',
|
||||
completed: 'healthy',
|
||||
};
|
||||
|
||||
const HUMAN_REASON_TEXT = {
|
||||
actionable: 'Strategy approved the quote.',
|
||||
below_edge_threshold: 'Below edge threshold.',
|
||||
duplicate_quote_id: 'Duplicate quote id.',
|
||||
executor_disarmed: 'Executor is disarmed.',
|
||||
executor_paused: 'Executor intake is paused.',
|
||||
inventory_unavailable: 'Inventory unavailable.',
|
||||
pending_deposit_not_credited: 'Funding is not credited yet.',
|
||||
quote_expired: 'Quote expired.',
|
||||
quote_response_ack: 'Quote response acknowledged by the relay.',
|
||||
quote_response_ok: 'Quote response accepted by the relay.',
|
||||
reason_unknown: 'Reason not recorded.',
|
||||
stale_reference_price: 'Reference price is stale.',
|
||||
strategy_disarmed: 'Strategy is disarmed.',
|
||||
submission_failed: 'Submission failed.',
|
||||
unsupported_pair: 'Unsupported pair.',
|
||||
};
|
||||
|
||||
const COMPLETED_OUTCOME_STATUSES = new Set(['completed', 'filled', 'settled', 'finalized']);
|
||||
const NOT_FILLED_OUTCOME_STATUSES = new Set(['expired', 'not_filled', 'cancelled']);
|
||||
|
||||
export function deriveQuoteLifecycleRows({
|
||||
recentQuotes = [],
|
||||
recentTradeDecisions = [],
|
||||
recentExecuteTradeCommands = [],
|
||||
recentExecutionResults = [],
|
||||
limit = 20,
|
||||
} = {}) {
|
||||
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}`);
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
for (const decisionEntry of recentTradeDecisions || []) {
|
||||
const decision = normalizeDecision({
|
||||
...(decisionEntry?.payload || decisionEntry || {}),
|
||||
decision_at:
|
||||
decisionEntry?.payload?.decision_at
|
||||
|| decisionEntry?.decision_at
|
||||
|| decisionEntry?.observed_at
|
||||
|| decisionEntry?.ingested_at
|
||||
|| null,
|
||||
});
|
||||
if (!decision) continue;
|
||||
const row = ensureLifecycleRow(rowsByKey, decision.quote_id || decision.decision_id || `decision:${decision.decision_at || rowsByKey.size}`);
|
||||
mergeLifecycleEvidence(row, {
|
||||
quote_id: decision.quote_id,
|
||||
decision_id: decision.decision_id,
|
||||
pair: decision.pair,
|
||||
direction: decision.direction,
|
||||
request_kind: decision.request_kind,
|
||||
gross_edge_pct: decision.gross_edge_pct,
|
||||
eure_notional: decision.eure_notional,
|
||||
decision,
|
||||
decision_at: decision.decision_at || null,
|
||||
});
|
||||
}
|
||||
|
||||
for (const commandEntry of recentExecuteTradeCommands || []) {
|
||||
const command = normalizeCommand({
|
||||
...(commandEntry?.payload || commandEntry || {}),
|
||||
command_at:
|
||||
commandEntry?.command_at
|
||||
|| commandEntry?.observed_at
|
||||
|| commandEntry?.ingested_at
|
||||
|| null,
|
||||
});
|
||||
if (!command) continue;
|
||||
const row = ensureLifecycleRow(rowsByKey, command.quote_id || command.decision_id || command.command_id || `command:${command.command_at || rowsByKey.size}`);
|
||||
mergeLifecycleEvidence(row, {
|
||||
quote_id: command.quote_id,
|
||||
decision_id: command.decision_id,
|
||||
command_id: command.command_id,
|
||||
pair: command.pair,
|
||||
direction: command.direction,
|
||||
request_kind: command.request_kind,
|
||||
command,
|
||||
command_at: command.command_at || null,
|
||||
});
|
||||
}
|
||||
|
||||
for (const execution of recentExecutionResults || []) {
|
||||
const row = ensureLifecycleRow(rowsByKey, execution?.quote_id || execution?.decision_id || execution?.command_id || `execution:${execution?.result_at || rowsByKey.size}`);
|
||||
mergeLifecycleEvidence(row, {
|
||||
quote_id: execution?.quote_id || null,
|
||||
decision_id: execution?.decision_id || null,
|
||||
command_id: execution?.command_id || null,
|
||||
pair: execution?.pair || null,
|
||||
execution,
|
||||
execution_result_at: execution?.result_at || null,
|
||||
});
|
||||
}
|
||||
|
||||
return [...rowsByKey.values()]
|
||||
.map((row) => finalizeLifecycleRow(row))
|
||||
.sort((left, right) => sortTimestamps(right.latest_stage_at, left.latest_stage_at))
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
function ensureLifecycleRow(rowsByKey, key) {
|
||||
if (!rowsByKey.has(key)) {
|
||||
rowsByKey.set(key, {
|
||||
quote_id: null,
|
||||
decision_id: null,
|
||||
command_id: null,
|
||||
pair: null,
|
||||
direction: null,
|
||||
request_kind: null,
|
||||
gross_edge_pct: null,
|
||||
eure_notional: null,
|
||||
quote_observed_at: null,
|
||||
decision_at: null,
|
||||
command_at: null,
|
||||
execution_result_at: null,
|
||||
decision: null,
|
||||
command: null,
|
||||
execution: null,
|
||||
});
|
||||
}
|
||||
return rowsByKey.get(key);
|
||||
}
|
||||
|
||||
function mergeLifecycleEvidence(row, next) {
|
||||
for (const [key, value] of Object.entries(next || {})) {
|
||||
if (value != null && row[key] == null) {
|
||||
row[key] = value;
|
||||
}
|
||||
}
|
||||
if (next?.decision) row.decision = next.decision;
|
||||
if (next?.command) row.command = next.command;
|
||||
if (next?.execution) row.execution = next.execution;
|
||||
}
|
||||
|
||||
function finalizeLifecycleRow(row) {
|
||||
const decision = row.decision || null;
|
||||
const execution = row.execution || null;
|
||||
const outcomeStatus = normalizeLifecycleToken(execution?.outcome_status || execution?.outcome_reason || null);
|
||||
let lifecycle_state = 'observed';
|
||||
let lifecycle_label = 'Observed';
|
||||
let reason_code = 'reason_unknown';
|
||||
let reason_text = 'Strategy evaluation not recorded yet.';
|
||||
|
||||
if (outcomeStatus && COMPLETED_OUTCOME_STATUSES.has(outcomeStatus)) {
|
||||
lifecycle_state = 'completed';
|
||||
lifecycle_label = 'Completed';
|
||||
reason_code = normalizeLifecycleToken(execution?.outcome_reason || execution?.result_code || 'completed');
|
||||
reason_text = humanizeReasonCode(reason_code, 'Completed');
|
||||
} else if (outcomeStatus && NOT_FILLED_OUTCOME_STATUSES.has(outcomeStatus)) {
|
||||
lifecycle_state = 'not_filled';
|
||||
lifecycle_label = 'Not filled';
|
||||
reason_code = normalizeLifecycleToken(execution?.outcome_reason || execution?.result_code || outcomeStatus);
|
||||
reason_text = humanizeReasonCode(reason_code, 'Not filled');
|
||||
} else if (execution?.status === 'submitted') {
|
||||
lifecycle_state = 'submitted';
|
||||
lifecycle_label = 'Submitted';
|
||||
reason_code = normalizeLifecycleToken(execution?.result_code || 'awaiting_outcome');
|
||||
reason_text = `${humanizeReasonCode(reason_code, 'Submitted to the relay.')} No durable venue outcome is recorded yet.`;
|
||||
} else if (execution?.status === 'failed') {
|
||||
lifecycle_state = 'failed';
|
||||
lifecycle_label = 'Submission failed';
|
||||
reason_code = normalizeLifecycleToken(execution?.result_code || 'submission_failed');
|
||||
reason_text = buildExecutionFailureText(execution, reason_code);
|
||||
} else if (execution?.status === 'rejected') {
|
||||
lifecycle_state = 'blocked';
|
||||
lifecycle_label = 'Blocked before submit';
|
||||
reason_code = normalizeLifecycleToken(execution?.result_code || 'reason_unknown');
|
||||
reason_text = buildExecutorBlockText(execution, reason_code);
|
||||
} else if (row.command_id || row.command_at) {
|
||||
lifecycle_state = 'command_emitted';
|
||||
lifecycle_label = 'Awaiting executor';
|
||||
reason_code = 'awaiting_executor';
|
||||
reason_text = 'Execute command recorded, but no executor result is stored yet.';
|
||||
} else if (decision?.decision === 'rejected') {
|
||||
lifecycle_state = 'rejected';
|
||||
lifecycle_label = 'Rejected by strategy';
|
||||
reason_code = normalizeLifecycleToken(decision?.decision_reason || 'reason_unknown');
|
||||
reason_text = humanizeReasonCode(reason_code, 'Strategy rejected the quote.');
|
||||
} else if (decision?.decision) {
|
||||
lifecycle_state = 'evaluated';
|
||||
lifecycle_label = 'Approved by strategy';
|
||||
reason_code = normalizeLifecycleToken(decision?.decision_reason || 'actionable');
|
||||
reason_text = 'Strategy approved the quote, but no durable execute command is recorded yet.';
|
||||
}
|
||||
|
||||
return {
|
||||
...row,
|
||||
lifecycle_state,
|
||||
lifecycle_label,
|
||||
lifecycle_tone: LIFECYCLE_TONE_BY_STATE[lifecycle_state] || 'unknown',
|
||||
reason_code,
|
||||
reason_text,
|
||||
latest_stage_at:
|
||||
row.execution_result_at
|
||||
|| row.command_at
|
||||
|| row.decision_at
|
||||
|| row.quote_observed_at
|
||||
|| null,
|
||||
stage_details: {
|
||||
quote_observed_at: row.quote_observed_at,
|
||||
decision_at: row.decision_at,
|
||||
command_at: row.command_at,
|
||||
execution_result_at: row.execution_result_at,
|
||||
strategy_decision: decision?.decision || null,
|
||||
strategy_reason_code: decision?.decision_reason || null,
|
||||
execution_status: execution?.status || null,
|
||||
execution_result_code: execution?.result_code || null,
|
||||
execution_outcome_status: execution?.outcome_status || null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildExecutionFailureText(execution, reasonCode) {
|
||||
const base = humanizeReasonCode(reasonCode, 'Submission failed.');
|
||||
if (execution?.error_message) return `${base} ${execution.error_message}`;
|
||||
if (execution?.note) return `${base} ${execution.note}`;
|
||||
return base;
|
||||
}
|
||||
|
||||
function buildExecutorBlockText(execution, reasonCode) {
|
||||
if (execution?.note) return execution.note;
|
||||
return humanizeReasonCode(reasonCode, 'Executor blocked the submission.');
|
||||
}
|
||||
|
||||
function humanizeReasonCode(code, fallback = 'Reason not recorded.') {
|
||||
const normalized = normalizeLifecycleToken(code || '');
|
||||
if (!normalized) return fallback;
|
||||
return HUMAN_REASON_TEXT[normalized] || normalized.replaceAll('_', ' ');
|
||||
}
|
||||
|
||||
function normalizeLifecycleToken(value) {
|
||||
return String(value || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[\s-]+/g, '_');
|
||||
}
|
||||
|
||||
function normalizeCommand(command) {
|
||||
if (!command) return null;
|
||||
return {
|
||||
command_id: command.command_id || null,
|
||||
decision_id: command.decision_id || null,
|
||||
execution_key: command.execution_key || null,
|
||||
quote_id: command.quote_id || null,
|
||||
pair: command.pair || null,
|
||||
direction: command.direction || null,
|
||||
request_kind: command.request_kind || null,
|
||||
asset_in: command.asset_in || null,
|
||||
asset_out: command.asset_out || null,
|
||||
command_at: command.command_at || command.observed_at || command.ingested_at || null,
|
||||
};
|
||||
}
|
||||
|
||||
function buildStrategySummary({
|
||||
servicesByName,
|
||||
activeAlerts,
|
||||
recentQuotes = [],
|
||||
recentTradeDecisions = [],
|
||||
recentExecuteTradeCommands = [],
|
||||
recentExecutionResults = [],
|
||||
}) {
|
||||
const strategyState = servicesByName['strategy-engine']?.state || {};
|
||||
const executorState = servicesByName['trade-executor']?.state || {};
|
||||
const durableDecisionsById = new Map(
|
||||
|
|
@ -740,6 +1024,13 @@ function buildStrategySummary({ servicesByName, activeAlerts, recentTradeDecisio
|
|||
|| durableDecisionsById.get(strategyState.latest_decision?.decision_id)?.decision_at
|
||||
|| null,
|
||||
});
|
||||
const lifecycleRows = deriveQuoteLifecycleRows({
|
||||
recentQuotes,
|
||||
recentTradeDecisions,
|
||||
recentExecuteTradeCommands,
|
||||
recentExecutionResults,
|
||||
limit: 20,
|
||||
});
|
||||
|
||||
return {
|
||||
strategy_state: {
|
||||
|
|
@ -751,6 +1042,7 @@ function buildStrategySummary({ servicesByName, activeAlerts, recentTradeDecisio
|
|||
recent_decisions: recentDecisions.length
|
||||
? recentDecisions
|
||||
: [...durableDecisionsById.values()].slice(0, 20),
|
||||
recent_lifecycle_rows: lifecycleRows,
|
||||
skipped_counts: strategyState.skipped_counts || {},
|
||||
durable_control_state: strategyState.durable_control_state || null,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -292,22 +292,22 @@ export async function loadRecentQuotes(pool, { limit = 10 } = {}) {
|
|||
return result.rows.map(normalizeRecentQuoteRow);
|
||||
}
|
||||
|
||||
export async function loadSuccessfulTradeSummary(pool) {
|
||||
export async function loadSubmissionSummary(pool) {
|
||||
const result = await pool.query(`
|
||||
SELECT
|
||||
COUNT(*)::INT AS total,
|
||||
MAX(COALESCE(observed_at, ingested_at)) AS last_successful_trade_at
|
||||
MAX(COALESCE(observed_at, ingested_at)) AS last_submission_at
|
||||
FROM trade_execution_results
|
||||
WHERE payload->>'status' = 'submitted'
|
||||
`);
|
||||
|
||||
return {
|
||||
total: Number(result.rows[0]?.total || 0),
|
||||
last_successful_trade_at: toIsoTimestamp(result.rows[0]?.last_successful_trade_at),
|
||||
last_submission_at: toIsoTimestamp(result.rows[0]?.last_submission_at),
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadSuccessfulTradesPage(pool, { page = 1, pageSize = 20 } = {}) {
|
||||
export async function loadSubmissionPage(pool, { page = 1, pageSize = 20 } = {}) {
|
||||
const normalizedPage = Math.max(1, Number(page) || 1);
|
||||
const normalizedPageSize = Math.max(1, Number(pageSize) || 20);
|
||||
const offset = (normalizedPage - 1) * normalizedPageSize;
|
||||
|
|
@ -347,10 +347,48 @@ export async function loadSuccessfulTradesPage(pool, { page = 1, pageSize = 20 }
|
|||
page_size: normalizedPageSize,
|
||||
total,
|
||||
total_pages: total > 0 ? Math.ceil(total / normalizedPageSize) : 1,
|
||||
items: rowsResult.rows.map(normalizeSuccessfulTradeRow),
|
||||
items: rowsResult.rows.map(normalizeSubmissionRow),
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadRecentExecutionResults(pool, { limit = 20 } = {}) {
|
||||
const result = await pool.query(
|
||||
`
|
||||
SELECT
|
||||
r.observed_at AS result_observed_at,
|
||||
r.ingested_at AS result_ingested_at,
|
||||
r.payload AS result_payload,
|
||||
c.ingested_at AS command_ingested_at,
|
||||
c.payload AS command_payload,
|
||||
d.payload AS decision_payload
|
||||
FROM trade_execution_results r
|
||||
LEFT JOIN execute_trade_commands c
|
||||
ON c.decision_key = r.decision_key
|
||||
LEFT JOIN trade_decisions d
|
||||
ON d.decision_key = COALESCE(c.payload->>'decision_id', r.payload->>'decision_id')
|
||||
ORDER BY COALESCE(r.observed_at, r.ingested_at) DESC
|
||||
LIMIT $1
|
||||
`,
|
||||
[limit],
|
||||
);
|
||||
|
||||
return result.rows.map((row) => normalizeExecutionResultRow(row));
|
||||
}
|
||||
|
||||
export async function loadRecentExecuteTradeCommands(pool, { limit = 20 } = {}) {
|
||||
const result = await pool.query(
|
||||
`
|
||||
SELECT observed_at, ingested_at, payload
|
||||
FROM execute_trade_commands
|
||||
ORDER BY COALESCE(observed_at, ingested_at) DESC
|
||||
LIMIT $1
|
||||
`,
|
||||
[limit],
|
||||
);
|
||||
|
||||
return result.rows.map((row) => normalizeExecuteTradeCommandRow(row));
|
||||
}
|
||||
|
||||
export async function loadCurrentFundingObservations(pool) {
|
||||
const result = await pool.query(`
|
||||
SELECT DISTINCT ON (decision_key)
|
||||
|
|
@ -640,7 +678,7 @@ function normalizeRecentQuoteRow(row) {
|
|||
};
|
||||
}
|
||||
|
||||
function normalizeSuccessfulTradeRow(row) {
|
||||
function normalizeSubmissionRow(row) {
|
||||
const resultPayload = row.result_payload || {};
|
||||
const commandPayload = row.command_payload || {};
|
||||
const decisionPayload = row.decision_payload || {};
|
||||
|
|
@ -672,6 +710,60 @@ function normalizeSuccessfulTradeRow(row) {
|
|||
};
|
||||
}
|
||||
|
||||
function normalizeExecuteTradeCommandRow(row) {
|
||||
const payload = row.payload || {};
|
||||
return {
|
||||
command_id: payload.command_id || null,
|
||||
decision_id: payload.decision_id || null,
|
||||
execution_key: payload.execution_key || null,
|
||||
quote_id: payload.quote_id || null,
|
||||
pair: payload.pair || null,
|
||||
direction: payload.direction || null,
|
||||
request_kind: payload.request_kind || null,
|
||||
asset_in: payload.asset_in || null,
|
||||
asset_out: payload.asset_out || null,
|
||||
amount_in: resolveTradeAmount(payload, 'amount_in'),
|
||||
amount_out: resolveTradeAmount(payload, 'amount_out'),
|
||||
observed_at: toIsoTimestamp(row.observed_at || row.ingested_at),
|
||||
ingested_at: toIsoTimestamp(row.ingested_at),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeExecutionResultRow(row) {
|
||||
const resultPayload = row.result_payload || {};
|
||||
const commandPayload = row.command_payload || {};
|
||||
const decisionPayload = row.decision_payload || {};
|
||||
|
||||
return {
|
||||
command_id: resultPayload.command_id || commandPayload.command_id || null,
|
||||
decision_id:
|
||||
commandPayload.decision_id
|
||||
|| resultPayload.decision_id
|
||||
|| decisionPayload.decision_id
|
||||
|| null,
|
||||
execution_key: resultPayload.execution_key || commandPayload.execution_key || null,
|
||||
quote_id: resultPayload.quote_id || commandPayload.quote_id || decisionPayload.quote_id || null,
|
||||
pair: resultPayload.pair || commandPayload.pair || decisionPayload.pair || null,
|
||||
command_at: toIsoTimestamp(row.command_ingested_at),
|
||||
result_at: toIsoTimestamp(row.result_observed_at || row.result_ingested_at),
|
||||
status: resultPayload.status || null,
|
||||
result_code: resultPayload.result_code || null,
|
||||
outcome_status:
|
||||
resultPayload.outcome_status
|
||||
|| resultPayload.venue_outcome_status
|
||||
|| resultPayload.trade_outcome_status
|
||||
|| null,
|
||||
outcome_reason:
|
||||
resultPayload.outcome_reason
|
||||
|| resultPayload.venue_outcome_reason
|
||||
|| resultPayload.trade_outcome_reason
|
||||
|| null,
|
||||
venue_response: resultPayload.venue_response || null,
|
||||
error_message: resultPayload.error?.message || null,
|
||||
note: resultPayload.note || null,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveTradeAmount(commandPayload, field) {
|
||||
const quoteOutputField = commandPayload?.quote_output?.[field];
|
||||
const proposedField = commandPayload?.[`proposed_${field}`];
|
||||
|
|
|
|||
|
|
@ -37,12 +37,12 @@ export default function App() {
|
|||
async function loadTradesPage(page) {
|
||||
if (!Number.isFinite(page) || page < 1) return;
|
||||
|
||||
dispatch({ type: 'notice.changed', notice: 'Loading trade history page...' });
|
||||
dispatch({ type: 'notice.changed', notice: 'Loading submission history page...' });
|
||||
dispatch({ type: 'error.changed', error: null });
|
||||
|
||||
try {
|
||||
const successfulTrades = await fetchJson(`/api/trades?page=${page}&page_size=${TRADE_PAGE_SIZE}`);
|
||||
dispatch({ type: 'trades.loaded', successfulTrades });
|
||||
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 });
|
||||
|
|
@ -67,7 +67,7 @@ export default function App() {
|
|||
dispatch({ type: 'notice.changed', notice: `${action} completed` });
|
||||
|
||||
if (reload) {
|
||||
const page = state.dashboard?.funds?.successful_trades?.page || 1;
|
||||
const page = state.dashboard?.funds?.submission_ledger?.page || 1;
|
||||
await loadBootstrap(page);
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -26,8 +26,8 @@ export default function StatusBar({ status, websocketState }) {
|
|||
['Alerts', `${status.active_alert_count || 0} ${status.highest_alert_severity ? `(${status.highest_alert_severity})` : ''}`.trim()],
|
||||
['Strategy Armed', formatBoolean(status.strategy_armed)],
|
||||
['Executor Armed', formatBoolean(status.executor_armed)],
|
||||
[SUBMISSION_COPY.statusTileLabel, `${status.recent_trade_count || 0} ${SUBMISSION_COPY.statusTileValueSuffix}`],
|
||||
[SUBMISSION_COPY.lastStatusTileLabel, formatTimestamp(status.last_successful_trade_at)],
|
||||
[SUBMISSION_COPY.statusTileLabel, `${status.recent_submission_count || 0} ${SUBMISSION_COPY.statusTileValueSuffix}`],
|
||||
[SUBMISSION_COPY.lastStatusTileLabel, formatTimestamp(status.last_submission_at)],
|
||||
];
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -370,7 +370,7 @@ export default function FundsPage({
|
|||
lastControlResult,
|
||||
}) {
|
||||
const profitability = funds.profitability;
|
||||
const trades = funds.successful_trades;
|
||||
const submissionLedger = funds.submission_ledger;
|
||||
const controlState = funds.funding.control_state || {};
|
||||
const externalFlowAdjusted = profitability.external_flow_adjusted;
|
||||
const externalFlowCount = profitability.external_flow_count || 0;
|
||||
|
|
@ -448,8 +448,8 @@ export default function FundsPage({
|
|||
/>
|
||||
<MetricCard
|
||||
label={SUBMISSION_COPY.recentMetricLabel}
|
||||
meta={formatTimestamp(profitability.last_successful_trade_at)}
|
||||
value={`${profitability.recent_trade_count || 0} ${SUBMISSION_COPY.recentMetricValueSuffix}`}
|
||||
meta={formatTimestamp(profitability.last_submission_at)}
|
||||
value={`${profitability.recent_submission_count || 0} ${SUBMISSION_COPY.recentMetricValueSuffix}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="panel-subtitle">
|
||||
|
|
@ -573,7 +573,7 @@ export default function FundsPage({
|
|||
<h3>{SUBMISSION_COPY.termsTitle}</h3>
|
||||
</div>
|
||||
</div>
|
||||
<AssetChangeTable items={funds.trade_asset_changes} />
|
||||
<AssetChangeTable items={funds.recent_submission_terms} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
|
@ -585,22 +585,22 @@ export default function FundsPage({
|
|||
</div>
|
||||
<div className="status-subtle">{SUBMISSION_COPY.ledgerSubtitle}</div>
|
||||
</div>
|
||||
<TradesTable items={trades.items} />
|
||||
<TradesTable items={submissionLedger.items} />
|
||||
<div className="pagination">
|
||||
<div className="status-subtle">{SUBMISSION_COPY.ledgerCountLabel(trades.page, trades.total_pages, trades.total)}</div>
|
||||
<div className="status-subtle">{SUBMISSION_COPY.ledgerCountLabel(submissionLedger.page, submissionLedger.total_pages, submissionLedger.total)}</div>
|
||||
<div className="button-row">
|
||||
<button
|
||||
className="button secondary"
|
||||
disabled={trades.page <= 1}
|
||||
onClick={() => onTradesPageChange(trades.page - 1)}
|
||||
disabled={submissionLedger.page <= 1}
|
||||
onClick={() => onTradesPageChange(submissionLedger.page - 1)}
|
||||
type="button"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
className="button secondary"
|
||||
disabled={trades.page >= trades.total_pages}
|
||||
onClick={() => onTradesPageChange(trades.page + 1)}
|
||||
disabled={submissionLedger.page >= submissionLedger.total_pages}
|
||||
onClick={() => onTradesPageChange(submissionLedger.page + 1)}
|
||||
type="button"
|
||||
>
|
||||
Next
|
||||
|
|
|
|||
|
|
@ -5,57 +5,65 @@ import Pill from '../components/Pill.jsx';
|
|||
import TableFrame from '../components/TableFrame.jsx';
|
||||
import { formatBoolean, formatTimestamp, truncateMiddle } from '../lib/format.js';
|
||||
|
||||
function describeStrategyDecision(decision) {
|
||||
if (decision === 'actionable') {
|
||||
return {
|
||||
label: 'Actionable',
|
||||
stateLabel: 'healthy',
|
||||
};
|
||||
async function copyIdentifier(value) {
|
||||
if (!value || !navigator?.clipboard?.writeText) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
} catch {
|
||||
// Best-effort copy affordance; keep the full identifier visible regardless.
|
||||
}
|
||||
|
||||
if (decision === 'rejected') {
|
||||
return {
|
||||
label: 'Rejected by strategy',
|
||||
stateLabel: 'warning',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
label: decision || 'Unknown',
|
||||
stateLabel: decision || 'unknown',
|
||||
};
|
||||
}
|
||||
|
||||
function DecisionsTable({ items }) {
|
||||
if (!items?.length) return <EmptyState>No strategy decisions have been observed yet.</EmptyState>;
|
||||
function IdentifierRow({ label, value }) {
|
||||
if (!value) return <div className="status-subtle">{`${label}: unavailable`}</div>;
|
||||
|
||||
return (
|
||||
<div className="trace-row">
|
||||
<span className="status-subtle">{`${label}:`}</span>
|
||||
<span className="mono trace-id">{value}</span>
|
||||
<button className="button secondary trace-copy-button" onClick={() => copyIdentifier(value)} type="button">
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LifecycleTable({ items }) {
|
||||
if (!items?.length) return <EmptyState>No quote lifecycle evidence has been observed yet.</EmptyState>;
|
||||
|
||||
return (
|
||||
<TableFrame>
|
||||
<table className="decision-table">
|
||||
<table className="decision-table lifecycle-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>At</th>
|
||||
<th>Strategy verdict</th>
|
||||
<th>Pair</th>
|
||||
<th>Lifecycle</th>
|
||||
<th>Reason</th>
|
||||
<th>Pair</th>
|
||||
<th>Trace</th>
|
||||
<th>Edge %</th>
|
||||
<th>Notional</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item, index) => {
|
||||
const verdict = describeStrategyDecision(item.decision);
|
||||
return (
|
||||
<tr key={`${item.decision_id || item.decision_at || index}`}>
|
||||
<td>{formatTimestamp(item.decision_at)}</td>
|
||||
<td><Pill label={verdict.label} stateLabel={verdict.stateLabel} /></td>
|
||||
{items.map((item, index) => (
|
||||
<tr key={`${item.quote_id || item.decision_id || item.command_id || item.latest_stage_at || index}`}>
|
||||
<td>{formatTimestamp(item.latest_stage_at)}</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>{item.decision_reason || ''}</td>
|
||||
<td>
|
||||
<IdentifierRow label="Quote" value={item.quote_id} />
|
||||
<IdentifierRow label="Decision" value={item.decision_id} />
|
||||
<IdentifierRow label="Command" value={item.command_id} />
|
||||
</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>
|
||||
);
|
||||
})}
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</TableFrame>
|
||||
|
|
@ -71,7 +79,7 @@ export default function StrategyPage({ strategy }) {
|
|||
<div className="eyebrow">Trading state</div>
|
||||
<h2>Strategy and executor</h2>
|
||||
<div className="panel-subtitle">
|
||||
This page shows whether the system is actively deciding and submitting without offering risky arming controls.
|
||||
This page shows the strongest durable claim for each recent quote, from strategy evaluation through executor submission evidence.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -80,26 +88,26 @@ export default function StrategyPage({ strategy }) {
|
|||
<MetricCard label="Threshold %" meta="Current gross threshold" value={strategy.strategy_state.threshold_pct ?? 'Unavailable'} />
|
||||
<MetricCard label="Max notional EURe" meta="Current cap" value={strategy.strategy_state.max_notional_eure ?? 'Unavailable'} />
|
||||
<MetricCard label="Executor armed" meta={`Paused ${formatBoolean(strategy.executor_state.paused)}`} value={formatBoolean(strategy.executor_state.armed)} />
|
||||
<MetricCard label="Executor in flight" meta={`Completed ${strategy.executor_state.completed_count || 0}`} value={String(strategy.executor_state.in_flight_count || 0)} />
|
||||
<MetricCard label="Executor in flight" meta={`Handled ${strategy.executor_state.completed_count || 0}`} value={String(strategy.executor_state.in_flight_count || 0)} />
|
||||
<MetricCard label="Signer registered" meta={strategy.executor_state.account_id || ''} value={formatBoolean(strategy.executor_state.signer_registered)} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="stack-grid">
|
||||
<section className="strategy-layout">
|
||||
<div className="panel">
|
||||
<div className="panel-head">
|
||||
<div>
|
||||
<div className="eyebrow">Decision flow</div>
|
||||
<h3>Recent decisions and skip reasons</h3>
|
||||
<div className="eyebrow">Lifecycle truth</div>
|
||||
<h3>Recent quote decisions and execution evidence</h3>
|
||||
<div className="panel-subtitle">
|
||||
Rejected by strategy means the strategy engine did not emit a trade command. Venue or executor rejections appear elsewhere.
|
||||
Strategy rejection, executor blocking, submission failure, and submission success are separated. Submission never implies trade completion or realized asset movement.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DecisionsTable items={strategy.strategy_state.recent_decisions} />
|
||||
<LifecycleTable items={strategy.strategy_state.recent_lifecycle_rows} />
|
||||
</div>
|
||||
|
||||
<div className="panel">
|
||||
<div className="panel strategy-side-panel">
|
||||
<div className="panel-head">
|
||||
<div>
|
||||
<div className="eyebrow">Guard rails</div>
|
||||
|
|
|
|||
|
|
@ -41,12 +41,12 @@ function applySocketMessage(dashboard, payload, session) {
|
|||
...dashboard.funds,
|
||||
profitability: {
|
||||
...dashboard.funds.profitability,
|
||||
recent_trade_count:
|
||||
payload.status_bar.recent_trade_count
|
||||
?? dashboard.funds.profitability.recent_trade_count,
|
||||
last_successful_trade_at:
|
||||
payload.status_bar.last_successful_trade_at
|
||||
|| dashboard.funds.profitability.last_successful_trade_at,
|
||||
recent_submission_count:
|
||||
payload.status_bar.recent_submission_count
|
||||
?? dashboard.funds.profitability.recent_submission_count,
|
||||
last_submission_at:
|
||||
payload.status_bar.last_submission_at
|
||||
|| dashboard.funds.profitability.last_submission_at,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
@ -91,14 +91,14 @@ export function dashboardReducer(state, action) {
|
|||
dashboard: action.dashboard,
|
||||
page: state.page || action.dashboard.default_page || 'funds',
|
||||
};
|
||||
case 'trades.loaded':
|
||||
case 'submissionLedger.loaded':
|
||||
return {
|
||||
...state,
|
||||
dashboard: {
|
||||
...state.dashboard,
|
||||
funds: {
|
||||
...state.dashboard.funds,
|
||||
successful_trades: action.successfulTrades,
|
||||
submission_ledger: action.submissionLedger,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -254,6 +254,17 @@ select {
|
|||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.strategy-layout {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
grid-template-columns: minmax(0, 1.75fr) minmax(320px, 0.85fr);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.strategy-side-panel {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
overflow-x: auto;
|
||||
border: 1px solid var(--line);
|
||||
|
|
@ -322,12 +333,18 @@ pre {
|
|||
|
||||
table.decision-table td:nth-child(2),
|
||||
table.decision-table th:nth-child(2) {
|
||||
width: 34%;
|
||||
width: 180px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
table.decision-table td:nth-child(3),
|
||||
table.decision-table th:nth-child(3) {
|
||||
width: 36%;
|
||||
width: 28%;
|
||||
}
|
||||
|
||||
table.lifecycle-table td:nth-child(5),
|
||||
table.lifecycle-table th:nth-child(5) {
|
||||
width: 34%;
|
||||
}
|
||||
|
||||
.pills,
|
||||
|
|
@ -388,6 +405,29 @@ table.decision-table th:nth-child(3) {
|
|||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.trace-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.trace-row:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.trace-id {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.trace-copy-button {
|
||||
padding: 4px 8px;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
|
|
@ -465,7 +505,8 @@ table.decision-table th:nth-child(3) {
|
|||
|
||||
@media (max-width: 1100px) {
|
||||
.app-grid,
|
||||
.split {
|
||||
.split,
|
||||
.strategy-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
buildDashboardBootstrap,
|
||||
buildProfitabilitySummary,
|
||||
createDashboardLiveState,
|
||||
deriveQuoteLifecycleRows,
|
||||
resolveDashboardControl,
|
||||
} from '../src/core/operator-dashboard.mjs';
|
||||
import {
|
||||
|
|
@ -52,9 +53,9 @@ test('profitability summary separates baseline, hold, market move, and trading c
|
|||
baseline_portfolio_value_eure_at_current_price: '105',
|
||||
},
|
||||
},
|
||||
successfulTradeSummary: {
|
||||
submissionSummary: {
|
||||
total: 4,
|
||||
last_successful_trade_at: '2026-04-04T09:00:00.000Z',
|
||||
last_submission_at: '2026-04-04T09:00:00.000Z',
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -63,8 +64,8 @@ test('profitability summary separates baseline, hold, market move, and trading c
|
|||
assert.equal(summary.market_move_contribution_eure, '5');
|
||||
assert.equal(summary.trading_contribution_eure, '5');
|
||||
assert.equal(summary.computed_at, '2026-04-04T09:05:00.000Z');
|
||||
assert.equal(summary.recent_trade_count, 4);
|
||||
assert.equal(summary.last_successful_trade_at, '2026-04-04T09:00:00.000Z');
|
||||
assert.equal(summary.recent_submission_count, 4);
|
||||
assert.equal(summary.last_submission_at, '2026-04-04T09:00:00.000Z');
|
||||
});
|
||||
|
||||
test('profitability summary flags cash-flow-adjusted benchmarks after later funding changes', () => {
|
||||
|
|
@ -86,9 +87,9 @@ test('profitability summary flags cash-flow-adjusted benchmarks after later fund
|
|||
},
|
||||
},
|
||||
},
|
||||
successfulTradeSummary: {
|
||||
submissionSummary: {
|
||||
total: 7,
|
||||
last_successful_trade_at: '2026-04-02T20:17:44.768Z',
|
||||
last_submission_at: '2026-04-02T20:17:44.768Z',
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -154,12 +155,12 @@ test('basic auth resolves operator identity and reuses a session cookie', () =>
|
|||
assert.equal(second.via, 'session_cookie');
|
||||
});
|
||||
|
||||
test('live quote updates stay capped at ten items and successful trades update live counters', () => {
|
||||
test('live quote updates stay capped at ten items and submitted results update live counters', () => {
|
||||
const config = buildConfig();
|
||||
const state = createDashboardLiveState({
|
||||
config,
|
||||
successfulTradeCount: 2,
|
||||
lastSuccessfulTradeAt: '2026-04-04T08:00:00.000Z',
|
||||
recentSubmissionCount: 2,
|
||||
lastSubmissionAt: '2026-04-04T08:00:00.000Z',
|
||||
});
|
||||
|
||||
for (let index = 0; index < 11; index += 1) {
|
||||
|
|
@ -195,11 +196,111 @@ test('live quote updates stay capped at ten items and successful trades update l
|
|||
assert.equal(state.recent_quotes.length, 10);
|
||||
assert.equal(state.recent_quotes[0].quote_id, 'quote-10');
|
||||
assert.equal(state.recent_quotes.at(-1).quote_id, 'quote-1');
|
||||
assert.equal(state.successful_trade_count, 3);
|
||||
assert.equal(state.last_successful_trade_at, '2026-04-04T08:30:00.000Z');
|
||||
assert.equal(state.recent_submission_count, 3);
|
||||
assert.equal(state.last_submission_at, '2026-04-04T08:30:00.000Z');
|
||||
assert.equal(updates[0].type, 'status_bar.updated');
|
||||
});
|
||||
|
||||
test('lifecycle derivation keeps executor blocking distinct from strategy rejection', () => {
|
||||
const rows = deriveQuoteLifecycleRows({
|
||||
recentQuotes: [{
|
||||
quote_id: 'quote-1',
|
||||
pair: 'btc->eure',
|
||||
request_kind: 'exact_in',
|
||||
observed_at: '2026-04-09T09:00:00.000Z',
|
||||
}],
|
||||
recentTradeDecisions: [{
|
||||
observed_at: '2026-04-09T09:00:01.000Z',
|
||||
payload: {
|
||||
decision_id: 'decision-1',
|
||||
quote_id: 'quote-1',
|
||||
pair: 'btc->eure',
|
||||
decision: 'actionable',
|
||||
decision_reason: 'actionable',
|
||||
gross_edge_pct: '1.2',
|
||||
},
|
||||
}],
|
||||
recentExecuteTradeCommands: [{
|
||||
observed_at: '2026-04-09T09:00:02.000Z',
|
||||
command_id: 'command-1',
|
||||
decision_id: 'decision-1',
|
||||
quote_id: 'quote-1',
|
||||
pair: 'btc->eure',
|
||||
}],
|
||||
recentExecutionResults: [{
|
||||
command_id: 'command-1',
|
||||
decision_id: 'decision-1',
|
||||
quote_id: 'quote-1',
|
||||
pair: 'btc->eure',
|
||||
result_at: '2026-04-09T09:00:03.000Z',
|
||||
status: 'rejected',
|
||||
result_code: 'executor_disarmed',
|
||||
note: 'executor is disarmed',
|
||||
}],
|
||||
});
|
||||
|
||||
assert.equal(rows[0].lifecycle_state, 'blocked');
|
||||
assert.equal(rows[0].lifecycle_label, 'Blocked before submit');
|
||||
assert.equal(rows[0].reason_code, 'executor_disarmed');
|
||||
assert.notEqual(rows[0].lifecycle_state, 'rejected');
|
||||
});
|
||||
|
||||
test('lifecycle derivation never upgrades submitted evidence into completion or asset movement', () => {
|
||||
const rows = deriveQuoteLifecycleRows({
|
||||
recentTradeDecisions: [{
|
||||
observed_at: '2026-04-09T09:10:01.000Z',
|
||||
payload: {
|
||||
decision_id: 'decision-2',
|
||||
quote_id: 'quote-2',
|
||||
pair: 'btc->eure',
|
||||
decision: 'actionable',
|
||||
decision_reason: 'actionable',
|
||||
},
|
||||
}],
|
||||
recentExecuteTradeCommands: [{
|
||||
observed_at: '2026-04-09T09:10:02.000Z',
|
||||
command_id: 'command-2',
|
||||
decision_id: 'decision-2',
|
||||
quote_id: 'quote-2',
|
||||
pair: 'btc->eure',
|
||||
}],
|
||||
recentExecutionResults: [{
|
||||
command_id: 'command-2',
|
||||
decision_id: 'decision-2',
|
||||
quote_id: 'quote-2',
|
||||
pair: 'btc->eure',
|
||||
result_at: '2026-04-09T09:10:03.000Z',
|
||||
status: 'submitted',
|
||||
result_code: 'quote_response_ok',
|
||||
}],
|
||||
});
|
||||
|
||||
assert.equal(rows[0].lifecycle_state, 'submitted');
|
||||
assert.equal(rows[0].lifecycle_label, 'Submitted');
|
||||
assert.match(rows[0].reason_text, /no durable venue outcome/i);
|
||||
assert.notEqual(rows[0].lifecycle_state, 'completed');
|
||||
assert.doesNotMatch(`${rows[0].lifecycle_label} ${rows[0].reason_text}`.toLowerCase(), /asset delta|realized/);
|
||||
});
|
||||
|
||||
test('strategy approval no longer renders the forbidden actionable label', () => {
|
||||
const rows = deriveQuoteLifecycleRows({
|
||||
recentTradeDecisions: [{
|
||||
observed_at: '2026-04-09T09:20:01.000Z',
|
||||
payload: {
|
||||
decision_id: 'decision-3',
|
||||
quote_id: 'quote-3',
|
||||
pair: 'btc->eure',
|
||||
decision: 'actionable',
|
||||
decision_reason: 'actionable',
|
||||
},
|
||||
}],
|
||||
});
|
||||
|
||||
assert.equal(rows[0].lifecycle_state, 'evaluated');
|
||||
assert.equal(rows[0].lifecycle_label, 'Approved by strategy');
|
||||
assert.notEqual(rows[0].lifecycle_label, 'Actionable');
|
||||
});
|
||||
|
||||
test('bootstrap aggregation keeps Funds as default and carries live control state', () => {
|
||||
const config = buildConfig();
|
||||
const bootstrap = buildDashboardBootstrap({
|
||||
|
|
@ -247,16 +348,16 @@ test('bootstrap aggregation keeps Funds as default and carries live control stat
|
|||
},
|
||||
},
|
||||
recentQuotes: [],
|
||||
successfulTrades: {
|
||||
submissionPage: {
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
total: 0,
|
||||
total_pages: 1,
|
||||
items: [],
|
||||
},
|
||||
successfulTradeSummary: {
|
||||
submissionSummary: {
|
||||
total: 1,
|
||||
last_successful_trade_at: '2026-04-04T09:30:00.000Z',
|
||||
last_submission_at: '2026-04-04T09:30:00.000Z',
|
||||
},
|
||||
fundingObservations: [
|
||||
{
|
||||
|
|
@ -381,12 +482,18 @@ test('bootstrap aggregation keeps Funds as default and carries live control stat
|
|||
|
||||
assert.equal(bootstrap.default_page, 'funds');
|
||||
assert.equal(bootstrap.funds.profitability.computed_at, '2026-04-04T09:05:00.000Z');
|
||||
assert.equal(bootstrap.funds.profitability.last_submission_at, '2026-04-04T09:30:00.000Z');
|
||||
assert.equal(bootstrap.funds.funding.control_state.withdrawals_frozen, true);
|
||||
assert.equal(bootstrap.funds.funding.handles[0].address, 'btc-address');
|
||||
assert.deepEqual(bootstrap.funds.recent_submission_terms, []);
|
||||
assert.deepEqual(bootstrap.funds.submission_ledger.items, []);
|
||||
assert.equal(bootstrap.status_bar.strategy_armed, true);
|
||||
assert.equal(bootstrap.status_bar.executor_armed, true);
|
||||
assert.equal(bootstrap.status_bar.recent_submission_count, 1);
|
||||
assert.equal(bootstrap.strategy.strategy_state.recent_decisions[0].decision_at, '2026-04-04T09:10:00.000Z');
|
||||
assert.equal(bootstrap.strategy.strategy_state.recent_decisions[0].decision_reason, 'strategy_disarmed');
|
||||
assert.equal(bootstrap.strategy.strategy_state.recent_lifecycle_rows[0].lifecycle_state, 'rejected');
|
||||
assert.equal(bootstrap.strategy.strategy_state.recent_lifecycle_rows[0].reason_code, 'strategy_disarmed');
|
||||
});
|
||||
|
||||
test('system service health uses sentinel-derived severity so stale ingest is never shown healthy', () => {
|
||||
|
|
@ -403,16 +510,16 @@ test('system service health uses sentinel-derived severity so stale ingest is ne
|
|||
inventorySnapshot: null,
|
||||
marketPrice: null,
|
||||
recentQuotes: [],
|
||||
successfulTrades: {
|
||||
submissionPage: {
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
total: 0,
|
||||
total_pages: 1,
|
||||
items: [],
|
||||
},
|
||||
successfulTradeSummary: {
|
||||
submissionSummary: {
|
||||
total: 0,
|
||||
last_successful_trade_at: null,
|
||||
last_submission_at: null,
|
||||
},
|
||||
fundingObservations: [],
|
||||
recentTradeDecisions: [],
|
||||
|
|
@ -496,16 +603,16 @@ test('ingest disconnected still renders as a critical transport failure', () =>
|
|||
inventorySnapshot: null,
|
||||
marketPrice: null,
|
||||
recentQuotes: [],
|
||||
successfulTrades: {
|
||||
submissionPage: {
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
total: 0,
|
||||
total_pages: 1,
|
||||
items: [],
|
||||
},
|
||||
successfulTradeSummary: {
|
||||
submissionSummary: {
|
||||
total: 0,
|
||||
last_successful_trade_at: null,
|
||||
last_submission_at: null,
|
||||
},
|
||||
fundingObservations: [],
|
||||
recentTradeDecisions: [],
|
||||
|
|
@ -583,16 +690,16 @@ test('recent alert history collapses repeated flapping transitions into one read
|
|||
inventorySnapshot: null,
|
||||
marketPrice: null,
|
||||
recentQuotes: [],
|
||||
successfulTrades: {
|
||||
submissionPage: {
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
total: 0,
|
||||
total_pages: 1,
|
||||
items: [],
|
||||
},
|
||||
successfulTradeSummary: {
|
||||
submissionSummary: {
|
||||
total: 0,
|
||||
last_successful_trade_at: null,
|
||||
last_submission_at: null,
|
||||
},
|
||||
fundingObservations: [],
|
||||
recentTradeDecisions: [],
|
||||
|
|
@ -674,16 +781,16 @@ test('funding summary includes credited bridge deposits without observer-backed
|
|||
inventorySnapshot: null,
|
||||
marketPrice: null,
|
||||
recentQuotes: [],
|
||||
successfulTrades: {
|
||||
submissionPage: {
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
total: 0,
|
||||
total_pages: 1,
|
||||
items: [],
|
||||
},
|
||||
successfulTradeSummary: {
|
||||
submissionSummary: {
|
||||
total: 0,
|
||||
last_successful_trade_at: null,
|
||||
last_submission_at: null,
|
||||
},
|
||||
fundingObservations: [],
|
||||
recentDepositStatuses: [
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue