diff --git a/src/apps/operator-dashboard.mjs b/src/apps/operator-dashboard.mjs
index 9ef886b..cdb5c6e 100644
--- a/src/apps/operator-dashboard.mjs
+++ b/src/apps/operator-dashboard.mjs
@@ -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,
diff --git a/src/core/operator-dashboard.mjs b/src/core/operator-dashboard.mjs
index d323987..7040fda 100644
--- a/src/core/operator-dashboard.mjs
+++ b/src/core/operator-dashboard.mjs
@@ -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,
},
diff --git a/src/lib/postgres.mjs b/src/lib/postgres.mjs
index 3f460ac..b4a2a23 100644
--- a/src/lib/postgres.mjs
+++ b/src/lib/postgres.mjs
@@ -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}`];
diff --git a/src/operator-dashboard/static/App.jsx b/src/operator-dashboard/static/App.jsx
index 58d4918..5841aa1 100644
--- a/src/operator-dashboard/static/App.jsx
+++ b/src/operator-dashboard/static/App.jsx
@@ -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) {
diff --git a/src/operator-dashboard/static/components/StatusBar.jsx b/src/operator-dashboard/static/components/StatusBar.jsx
index 0ff1894..1503a55 100644
--- a/src/operator-dashboard/static/components/StatusBar.jsx
+++ b/src/operator-dashboard/static/components/StatusBar.jsx
@@ -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 (
diff --git a/src/operator-dashboard/static/pages/FundsPage.jsx b/src/operator-dashboard/static/pages/FundsPage.jsx
index 9743602..f2e92c9 100644
--- a/src/operator-dashboard/static/pages/FundsPage.jsx
+++ b/src/operator-dashboard/static/pages/FundsPage.jsx
@@ -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({
/>