From cb87910c951bc78a2d0f70a19ca0b7fdbc5f1a27 Mon Sep 17 00:00:00 2001 From: philipp Date: Fri, 12 Jun 2026 19:28:38 +0200 Subject: [PATCH] Implement quote lifecycle investigator Proof: Adds durable quote lifecycle lookup by quote_id, decision_id, or command_id through the authenticated dashboard API, pins selected Quote lifecycle evidence outside the rolling table, and covers the Awaiting executor follow-up regression with targeted tests. Assumptions: Existing swap demand, trade decision, execute command, executor result, and quote outcome history rows are sufficient to reconstruct the selected quote lifecycle; this turn stays read-side and does not alter strategy, pricing, limits, arming, signer identity, pair enablement, relay, or executor behavior. Still fake: Venue-native final fill truth, fee-aware realized PnL, already-pruned historical detail, and the broader quote analytics workbench remain incomplete. --- src/apps/operator-dashboard.mjs | 47 +++ src/core/operator-dashboard.mjs | 101 ++++- src/lib/postgres.mjs | 348 +++++++++++++++++ .../static/lib/quoteLifecycleInvestigator.js | 91 +++++ .../static/pages/StrategyPage.jsx | 366 +++++++++++++++++- src/operator-dashboard/static/styles.css | 87 ++++- test/operator-dashboard-app-static.test.mjs | 10 + test/operator-dashboard-ui-static.test.mjs | 22 +- test/operator-dashboard.test.mjs | 274 +++++++++++++ 9 files changed, 1332 insertions(+), 14 deletions(-) create mode 100644 src/operator-dashboard/static/lib/quoteLifecycleInvestigator.js diff --git a/src/apps/operator-dashboard.mjs b/src/apps/operator-dashboard.mjs index 1eab31b..ead8fac 100644 --- a/src/apps/operator-dashboard.mjs +++ b/src/apps/operator-dashboard.mjs @@ -14,6 +14,7 @@ import { buildDashboardBootstrap, buildDashboardControlErrorResponse, buildLiveQuoteLifecycleRows, + buildQuoteLifecycleLookupResponse, buildLiveStatusBar, createDashboardLiveState, isDashboardWebSocketBackpressured, @@ -44,6 +45,7 @@ import { loadLatestMarketPrice, loadLatestPortfolioMetric, loadPairConfigSummary, + loadQuoteLifecycleLookupEvidence, loadQuoteLifecycleRetentionSummary, loadRecentAlertTransitions, loadRecentDepositStatuses, @@ -378,6 +380,38 @@ async function handleApiRequest({ req, res, url, auth }) { return sendJson(res, 200, submissionPage); } + const lifecycleLookupMatch = req.method === 'GET' + ? url.pathname.match(/^\/api\/strategy\/quote-lifecycle\/(.+)$/) + : null; + if (lifecycleLookupMatch) { + const identifier = decodeURIComponent(lifecycleLookupMatch[1] || '').trim(); + if (!identifier) { + return sendJson(res, 400, { + ok: false, + error: 'quote_lifecycle_identifier_required', + lifecycle_row: null, + lookup: { + requested_identifier: '', + matched_identifier_type: null, + found: false, + lookup_at: new Date().toISOString(), + latest_stage_at: null, + matched_identifiers: { + quote_ids: [], + decision_ids: [], + command_ids: [], + }, + }, + }); + } + + const payload = await loadQuoteLifecycleLookupPayload({ identifier }); + return sendJson(res, payload.ok ? 200 : 404, { + ...payload, + ...(payload.ok ? {} : { error: 'quote_lifecycle_not_found' }), + }); + } + const controlMatch = req.method === 'POST' ? url.pathname.match(/^\/api\/control\/([^/]+)\/([^/]+)$/) : null; @@ -597,6 +631,19 @@ async function loadBootstrapPayload({ auth, page, pageSize }) { return payload; } +async function loadQuoteLifecycleLookupPayload({ identifier }) { + const tradingConfig = await tradingConfigStore.forceRefresh(); + const runtimeConfig = buildRuntimeConfig(tradingConfig); + const evidence = await loadQuoteLifecycleLookupEvidence(pool, { + identifier, + }); + return buildQuoteLifecycleLookupResponse({ + config: runtimeConfig, + identifier, + evidence, + }); +} + async function loadServiceSnapshots() { const services = listDashboardServices(config); return Promise.all(services.map((service) => loadServiceSnapshot(service))); diff --git a/src/core/operator-dashboard.mjs b/src/core/operator-dashboard.mjs index d4af30c..f12c61f 100644 --- a/src/core/operator-dashboard.mjs +++ b/src/core/operator-dashboard.mjs @@ -1130,6 +1130,7 @@ const HUMAN_REASON_TEXT = { pending_deposit_not_credited: 'Funding is not credited yet.', pending_outbound_reserved: 'Pending outbound inventory is reserved.', quote_expired: 'Quote expired.', + quote_not_found_or_finished: 'Relay reported the quote as not found or already finished.', quote_response_ack: 'Quote response acknowledged by the relay.', quote_response_ok: 'Quote response accepted by the relay.', awaiting_outcome: 'No durable venue outcome is recorded yet.', @@ -1425,7 +1426,11 @@ function finalizeLifecycleRow(row) { } else if (execution?.status === 'failed') { lifecycle_state = 'failed'; lifecycle_label = 'Submission failed'; - reason_code = normalizeLifecycleToken(execution?.result_code || 'submission_failed'); + reason_code = normalizeLifecycleToken( + execution?.failure_category + || execution?.result_code + || 'submission_failed', + ); reason_text = buildExecutionFailureText(execution, reason_code); } else if (execution?.status === 'rejected') { lifecycle_state = 'blocked'; @@ -1504,6 +1509,100 @@ function finalizeLifecycleRow(row) { }; } +export function buildQuoteLifecycleLookupResponse({ + config, + identifier, + evidence = {}, + now = new Date().toISOString(), +} = {}) { + const requestedIdentifier = String(identifier || '').trim(); + const rows = deriveQuoteLifecycleRows({ + recentQuotes: evidence.recentQuotes || [], + recentTradeDecisions: evidence.recentTradeDecisions || [], + recentExecuteTradeCommands: evidence.recentExecuteTradeCommands || [], + recentExecutionResults: evidence.recentExecutionResults || [], + recentQuoteOutcomes: evidence.recentQuoteOutcomes || [], + limit: null, + }).map((row) => enrichLifecycleRowForUi({ config, row })); + const lifecycleRow = selectLookupLifecycleRow(rows, requestedIdentifier); + const found = Boolean(lifecycleRow); + const lookup = { + requested_identifier: requestedIdentifier, + matched_identifier_type: found + ? inferLookupIdentifierType(requestedIdentifier, lifecycleRow, evidence) + : null, + found, + lookup_at: evidence.lookup_at || now, + latest_stage_at: lifecycleRow?.latest_stage_at || null, + matched_identifiers: found + ? collectLifecycleIdentifiers(lifecycleRow, evidence.matched_identifiers) + : normalizeMatchedIdentifiers(evidence.matched_identifiers), + }; + + return { + ok: found, + lifecycle_row: lifecycleRow || null, + lookup, + }; +} + +function selectLookupLifecycleRow(rows, identifier) { + if (!rows.length) return null; + const normalizedIdentifier = String(identifier || '').trim(); + if (!normalizedIdentifier) return rows[0] || null; + return rows.find((row) => lifecycleRowHasIdentifier(row, normalizedIdentifier)) + || rows[0] + || null; +} + +function lifecycleRowHasIdentifier(row, identifier) { + return row?.quote_id === identifier + || row?.decision_id === identifier + || row?.command_id === identifier; +} + +function inferLookupIdentifierType(identifier, row, evidence = {}) { + if (evidence.matched_identifier_type) return evidence.matched_identifier_type; + if (row?.quote_id === identifier) return 'quote_id'; + if (row?.decision_id === identifier) return 'decision_id'; + if (row?.command_id === identifier) return 'command_id'; + return 'linked_identifier'; +} + +function collectLifecycleIdentifiers(row, matchedIdentifiers = null) { + const normalized = normalizeMatchedIdentifiers(matchedIdentifiers); + return { + quote_ids: uniqueStrings([ + ...normalized.quote_ids, + row?.quote_id, + ]), + decision_ids: uniqueStrings([ + ...normalized.decision_ids, + row?.decision_id, + ]), + command_ids: uniqueStrings([ + ...normalized.command_ids, + row?.command_id, + ]), + }; +} + +function normalizeMatchedIdentifiers(value = null) { + return { + quote_ids: uniqueStrings(value?.quote_ids), + decision_ids: uniqueStrings(value?.decision_ids), + command_ids: uniqueStrings(value?.command_ids), + }; +} + +function uniqueStrings(values = []) { + return [...new Set( + (values || []) + .map((value) => String(value || '').trim()) + .filter(Boolean), + )]; +} + function buildPolicySkipText(decision, reasonCode) { const base = humanizeReasonCode(reasonCode, 'Policy skipped the response.'); const policy = decision?.response_policy || {}; diff --git a/src/lib/postgres.mjs b/src/lib/postgres.mjs index fc87d37..14cd1fb 100644 --- a/src/lib/postgres.mjs +++ b/src/lib/postgres.mjs @@ -3868,6 +3868,354 @@ export async function loadRecentQuoteOutcomes(pool, { limit = 200 } = {}) { return result.rows.map(normalizeQuoteOutcomeRow); } +export async function loadQuoteLifecycleLookupEvidence(pool, { identifier } = {}) { + const requestedIdentifier = String(identifier || '').trim(); + const lookupAt = new Date().toISOString(); + if (!requestedIdentifier) { + return buildQuoteLifecycleLookupEvidence({ + requestedIdentifier, + lookupAt, + }); + } + + const candidatesResult = await pool.query( + ` + WITH candidates AS ( + SELECT + 'quote_id' AS matched_identifier_type, + COALESCE(quote_id, payload->>'quote_id') AS quote_id, + payload->>'decision_id' AS decision_id, + payload->>'command_id' AS command_id, + COALESCE(observed_at, ingested_at) AS matched_at + FROM swap_demand_events + WHERE quote_id = $1 + OR payload->>'quote_id' = $1 + + UNION ALL + + SELECT + CASE + WHEN quote_id = $1 OR payload->>'quote_id' = $1 THEN 'quote_id' + WHEN decision_key = $1 OR payload->>'decision_id' = $1 THEN 'decision_id' + WHEN payload->>'command_id' = $1 THEN 'command_id' + ELSE 'linked_identifier' + END AS matched_identifier_type, + COALESCE(quote_id, payload->>'quote_id') AS quote_id, + COALESCE(payload->>'decision_id', decision_key) AS decision_id, + payload->>'command_id' AS command_id, + COALESCE(observed_at, ingested_at) AS matched_at + FROM trade_decisions + WHERE quote_id = $1 + OR decision_key = $1 + OR payload->>'quote_id' = $1 + OR payload->>'decision_id' = $1 + OR payload->>'command_id' = $1 + + UNION ALL + + SELECT + CASE + WHEN quote_id = $1 OR payload->>'quote_id' = $1 THEN 'quote_id' + WHEN payload->>'decision_id' = $1 THEN 'decision_id' + WHEN decision_key = $1 OR payload->>'command_id' = $1 THEN 'command_id' + ELSE 'linked_identifier' + END AS matched_identifier_type, + COALESCE(quote_id, payload->>'quote_id') AS quote_id, + payload->>'decision_id' AS decision_id, + COALESCE(payload->>'command_id', decision_key) AS command_id, + COALESCE(observed_at, ingested_at) AS matched_at + FROM execute_trade_commands + WHERE quote_id = $1 + OR decision_key = $1 + OR payload->>'quote_id' = $1 + OR payload->>'decision_id' = $1 + OR payload->>'command_id' = $1 + + UNION ALL + + SELECT + CASE + WHEN quote_id = $1 OR payload->>'quote_id' = $1 THEN 'quote_id' + WHEN payload->>'decision_id' = $1 THEN 'decision_id' + WHEN decision_key = $1 OR payload->>'command_id' = $1 THEN 'command_id' + ELSE 'linked_identifier' + END AS matched_identifier_type, + COALESCE(quote_id, payload->>'quote_id') AS quote_id, + payload->>'decision_id' AS decision_id, + COALESCE(payload->>'command_id', decision_key) AS command_id, + COALESCE(observed_at, ingested_at) AS matched_at + FROM trade_execution_results + WHERE quote_id = $1 + OR decision_key = $1 + OR payload->>'quote_id' = $1 + OR payload->>'decision_id' = $1 + OR payload->>'command_id' = $1 + + UNION ALL + + SELECT + CASE + WHEN quote_id = $1 OR payload->>'quote_id' = $1 THEN 'quote_id' + WHEN decision_id = $1 OR payload->>'decision_id' = $1 THEN 'decision_id' + WHEN command_id = $1 OR payload->>'command_id' = $1 THEN 'command_id' + ELSE 'linked_identifier' + END AS matched_identifier_type, + COALESCE(quote_id, payload->>'quote_id') AS quote_id, + COALESCE(decision_id, payload->>'decision_id') AS decision_id, + COALESCE(command_id, payload->>'command_id') AS command_id, + COALESCE(outcome_observed_at, submitted_at, command_at, computed_at) AS matched_at + FROM ${QUOTE_OUTCOMES_TABLE} + WHERE quote_id = $1 + OR decision_id = $1 + OR command_id = $1 + OR payload->>'quote_id' = $1 + OR payload->>'decision_id' = $1 + OR payload->>'command_id' = $1 + ) + SELECT * + FROM candidates + ORDER BY matched_at DESC NULLS LAST + LIMIT 100 + `, + [requestedIdentifier], + ); + + const identifiers = collectQuoteLifecycleLookupIdentifiers(candidatesResult.rows); + if (!hasLookupIdentifiers(identifiers)) { + return buildQuoteLifecycleLookupEvidence({ + requestedIdentifier, + lookupAt, + matchedIdentifierType: null, + matchedIdentifiers: identifiers, + }); + } + + const [ + recentQuotes, + recentTradeDecisions, + recentExecuteTradeCommands, + recentExecutionResults, + recentQuoteOutcomes, + ] = await Promise.all([ + loadLifecycleLookupQuotes(pool, identifiers), + loadLifecycleLookupTradeDecisions(pool, identifiers), + loadLifecycleLookupExecuteTradeCommands(pool, identifiers), + loadLifecycleLookupExecutionResults(pool, identifiers), + loadLifecycleLookupQuoteOutcomes(pool, identifiers), + ]); + + return buildQuoteLifecycleLookupEvidence({ + requestedIdentifier, + lookupAt, + matchedIdentifierType: inferLookupMatchedIdentifierType( + requestedIdentifier, + candidatesResult.rows, + ), + matchedIdentifiers: identifiers, + recentQuotes, + recentTradeDecisions, + recentExecuteTradeCommands, + recentExecutionResults, + recentQuoteOutcomes, + }); +} + +function buildQuoteLifecycleLookupEvidence({ + requestedIdentifier, + lookupAt, + matchedIdentifierType = null, + matchedIdentifiers = null, + recentQuotes = [], + recentTradeDecisions = [], + recentExecuteTradeCommands = [], + recentExecutionResults = [], + recentQuoteOutcomes = [], +} = {}) { + return { + found: Boolean( + recentQuotes.length + || recentTradeDecisions.length + || recentExecuteTradeCommands.length + || recentExecutionResults.length + || recentQuoteOutcomes.length, + ), + requested_identifier: requestedIdentifier, + matched_identifier_type: matchedIdentifierType, + matched_identifiers: normalizeQuoteLifecycleLookupIdentifiers(matchedIdentifiers), + lookup_at: lookupAt, + recentQuotes, + recentTradeDecisions, + recentExecuteTradeCommands, + recentExecutionResults, + recentQuoteOutcomes, + }; +} + +function collectQuoteLifecycleLookupIdentifiers(rows = []) { + return normalizeQuoteLifecycleLookupIdentifiers({ + quote_ids: rows.map((row) => row.quote_id), + decision_ids: rows.map((row) => row.decision_id), + command_ids: rows.map((row) => row.command_id), + }); +} + +function normalizeQuoteLifecycleLookupIdentifiers(value = null) { + return { + quote_ids: uniqueTextValues(value?.quote_ids), + decision_ids: uniqueTextValues(value?.decision_ids), + command_ids: uniqueTextValues(value?.command_ids), + }; +} + +function uniqueTextValues(values = []) { + return [...new Set( + (values || []) + .map((value) => String(value || '').trim()) + .filter(Boolean), + )]; +} + +function hasLookupIdentifiers(identifiers) { + return Boolean( + identifiers?.quote_ids?.length + || identifiers?.decision_ids?.length + || identifiers?.command_ids?.length, + ); +} + +function inferLookupMatchedIdentifierType(identifier, rows = []) { + const requestedIdentifier = String(identifier || '').trim(); + const direct = rows.find((row) => ( + row.matched_identifier_type + && ( + row.quote_id === requestedIdentifier + || row.decision_id === requestedIdentifier + || row.command_id === requestedIdentifier + ) + )); + return direct?.matched_identifier_type || rows[0]?.matched_identifier_type || null; +} + +async function loadLifecycleLookupQuotes(pool, identifiers) { + if (!identifiers.quote_ids.length) return []; + const result = await pool.query( + ` + SELECT observed_at, ingested_at, payload + FROM swap_demand_events + WHERE quote_id = ANY($1::text[]) + OR payload->>'quote_id' = ANY($1::text[]) + ORDER BY COALESCE(observed_at, ingested_at) DESC + LIMIT 20 + `, + [identifiers.quote_ids], + ); + return result.rows.map(normalizeRecentQuoteRow); +} + +async function loadLifecycleLookupTradeDecisions(pool, identifiers) { + const result = await pool.query( + ` + SELECT observed_at, ingested_at, payload + FROM trade_decisions + WHERE quote_id = ANY($1::text[]) + OR payload->>'quote_id' = ANY($1::text[]) + OR decision_key = ANY($2::text[]) + OR payload->>'decision_id' = ANY($2::text[]) + ORDER BY COALESCE(observed_at, ingested_at) DESC + LIMIT 20 + `, + [identifiers.quote_ids, identifiers.decision_ids], + ); + return result.rows.map((row) => ({ + observed_at: toIsoTimestamp(row.observed_at), + ingested_at: toIsoTimestamp(row.ingested_at), + payload: row.payload, + })); +} + +async function loadLifecycleLookupExecuteTradeCommands(pool, identifiers) { + const result = await pool.query( + ` + SELECT observed_at, ingested_at, payload + FROM execute_trade_commands + WHERE quote_id = ANY($1::text[]) + OR payload->>'quote_id' = ANY($1::text[]) + OR payload->>'decision_id' = ANY($2::text[]) + OR decision_key = ANY($3::text[]) + OR payload->>'command_id' = ANY($3::text[]) + ORDER BY COALESCE(observed_at, ingested_at) DESC + LIMIT 20 + `, + [identifiers.quote_ids, identifiers.decision_ids, identifiers.command_ids], + ); + return result.rows.map((row) => normalizeExecuteTradeCommandRow(row)); +} + +async function loadLifecycleLookupExecutionResults(pool, identifiers) { + 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, + o.payload AS outcome_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') + LEFT JOIN ${QUOTE_OUTCOMES_TABLE} o + ON o.quote_id = r.quote_id + WHERE r.quote_id = ANY($1::text[]) + OR r.payload->>'quote_id' = ANY($1::text[]) + OR r.payload->>'decision_id' = ANY($2::text[]) + OR r.decision_key = ANY($3::text[]) + OR r.payload->>'command_id' = ANY($3::text[]) + ORDER BY COALESCE(r.observed_at, r.ingested_at) DESC + LIMIT 20 + `, + [identifiers.quote_ids, identifiers.decision_ids, identifiers.command_ids], + ); + return result.rows.map((row) => normalizeExecutionResultRow(row)); +} + +async function loadLifecycleLookupQuoteOutcomes(pool, identifiers) { + const result = await pool.query( + ` + SELECT + quote_id, + decision_id, + command_id, + execution_result_status, + execution_result_code, + submitted_at, + command_at, + outcome_status, + outcome_observed_at, + outcome_source, + attribution_status, + attribution_method, + attributed_inventory_delta, + computed_at, + payload + FROM ${QUOTE_OUTCOMES_TABLE} + WHERE quote_id = ANY($1::text[]) + OR payload->>'quote_id' = ANY($1::text[]) + OR decision_id = ANY($2::text[]) + OR payload->>'decision_id' = ANY($2::text[]) + OR command_id = ANY($3::text[]) + OR payload->>'command_id' = ANY($3::text[]) + ORDER BY COALESCE(outcome_observed_at, submitted_at, command_at, computed_at) DESC + LIMIT 20 + `, + [identifiers.quote_ids, identifiers.decision_ids, identifiers.command_ids], + ); + return result.rows.map(normalizeQuoteOutcomeRow); +} + export async function loadIntentRequestPreflightByIdOrKey(pool, { requestId = null, idempotencyKey = null, diff --git a/src/operator-dashboard/static/lib/quoteLifecycleInvestigator.js b/src/operator-dashboard/static/lib/quoteLifecycleInvestigator.js new file mode 100644 index 0000000..51bc397 --- /dev/null +++ b/src/operator-dashboard/static/lib/quoteLifecycleInvestigator.js @@ -0,0 +1,91 @@ +export function lifecycleRowIdentifier(row = null) { + return row?.quote_id || row?.decision_id || row?.command_id || null; +} + +export function findLifecycleRowByIdentifier(rows = [], identifier = '') { + const normalizedIdentifier = String(identifier || '').trim(); + if (!normalizedIdentifier) return null; + return (rows || []).find((row) => ( + row?.quote_id === normalizedIdentifier + || row?.decision_id === normalizedIdentifier + || row?.command_id === normalizedIdentifier + )) || null; +} + +export function createSelectedLifecycleInvestigation(row, { + source = 'live_row', + lookup = null, + now = Date.now(), +} = {}) { + const identifier = lifecycleRowIdentifier(row); + if (!identifier) return null; + const refreshedAt = toIsoTimestamp(now); + return { + identifier, + row, + source, + lookup, + selected_at: refreshedAt, + refreshed_at: refreshedAt, + error: null, + }; +} + +export function updateSelectedLifecycleFromLiveRows(selected, rows = [], { + now = Date.now(), +} = {}) { + if (!selected?.identifier) return selected || null; + const liveRow = findLifecycleRowByIdentifier(rows, selected.identifier); + if (!liveRow) return selected; + return { + ...selected, + row: liveRow, + source: 'live_row', + refreshed_at: toIsoTimestamp(now), + error: null, + }; +} + +export function shouldRefreshSelectedFromDurable(selected, rows = []) { + if (!selected?.identifier) return false; + return !findLifecycleRowByIdentifier(rows, selected.identifier); +} + +export function updateSelectedLifecycleFromLookup(selected, payload, { + identifier = null, + now = Date.now(), +} = {}) { + const requestedIdentifier = identifier || selected?.identifier || payload?.lookup?.requested_identifier || null; + const refreshedAt = toIsoTimestamp(now); + if (payload?.ok && payload.lifecycle_row) { + return { + ...(selected || {}), + identifier: lifecycleRowIdentifier(payload.lifecycle_row) || requestedIdentifier, + row: payload.lifecycle_row, + source: 'durable_lookup', + lookup: payload.lookup || null, + selected_at: selected?.selected_at || refreshedAt, + refreshed_at: refreshedAt, + error: null, + }; + } + + return { + ...(selected || {}), + identifier: requestedIdentifier, + row: selected?.row || null, + source: payload?.error === 'quote_lifecycle_not_found' || payload?.lookup?.found === false + ? 'not_found' + : 'lookup_error', + lookup: payload?.lookup || null, + selected_at: selected?.selected_at || refreshedAt, + refreshed_at: refreshedAt, + error: payload?.error || null, + }; +} + +function toIsoTimestamp(value) { + const timestamp = typeof value === 'number' ? value : Date.parse(value); + if (!Number.isFinite(timestamp)) return new Date().toISOString(); + return new Date(timestamp).toISOString(); +} diff --git a/src/operator-dashboard/static/pages/StrategyPage.jsx b/src/operator-dashboard/static/pages/StrategyPage.jsx index ae46044..136ed1c 100644 --- a/src/operator-dashboard/static/pages/StrategyPage.jsx +++ b/src/operator-dashboard/static/pages/StrategyPage.jsx @@ -5,6 +5,13 @@ import MetricCard from '../components/MetricCard.jsx'; import Pill from '../components/Pill.jsx'; import TableFrame from '../components/TableFrame.jsx'; import { formatAgeFromTimestamp, formatBoolean, formatEur, formatTimestamp, truncateMiddle } from '../lib/format.js'; +import { + createSelectedLifecycleInvestigation, + findLifecycleRowByIdentifier, + shouldRefreshSelectedFromDurable, + updateSelectedLifecycleFromLiveRows, + updateSelectedLifecycleFromLookup, +} from '../lib/quoteLifecycleInvestigator.js'; const RESPONDED_STATES = new Set(['submitted', 'awaiting_outcome', 'not_filled', 'completed']); const TRADING_PAIR_MODES = new Set(['maker', 'taker', 'both']); @@ -13,6 +20,17 @@ const COMPETITIVENESS_DETAIL_ROW_COUNT = 8; const COMPETITIVENESS_LATENCY_ROW_COUNT = 6; const COMPETITIVENESS_ROLLUP_ROW_COUNT = 8; const QUOTE_LIFECYCLE_ROW_COUNT = 20; +const QUOTE_LIFECYCLE_LOOKUP_DEBOUNCE_MS = 750; +const QUOTE_LIFECYCLE_PROBLEM_FILTERS = [ + ['all', 'All states'], + ['awaiting_executor', 'Awaiting executor'], + ['approved_no_command', 'Approved, no command'], + ['blocked_before_submit', 'Blocked before submit'], + ['submission_failed', 'Submission failed'], + ['quote_not_found_or_finished', 'Quote not found or finished'], + ['strategy_rejected', 'Strategy rejected'], + ['submitted_awaiting_outcome', 'Submitted or awaiting outcome'], +]; async function copyIdentifier(value) { if (!value || !navigator?.clipboard?.writeText) return; @@ -23,6 +41,22 @@ async function copyIdentifier(value) { } } +async function fetchQuoteLifecycleLookup(identifier) { + const response = await fetch(`/api/strategy/quote-lifecycle/${encodeURIComponent(identifier)}`, { + headers: { + accept: 'application/json', + }, + }); + const text = await response.text(); + const payload = text.trim() ? JSON.parse(text) : null; + + if (response.status === 404 && payload?.error === 'quote_lifecycle_not_found') return payload; + if (!response.ok) { + throw new Error(payload?.error || text.trim() || `HTTP ${response.status}`); + } + return payload; +} + function useNow(intervalMs = 1000) { const [now, setNow] = useState(() => Date.now()); @@ -179,6 +213,86 @@ function isStrategyRejected(item) { || String(item?.lifecycle_label || '').toLowerCase() === 'rejected by strategy'; } +function isQuoteNotFoundOrFinished(item) { + return [ + item?.reason_code, + item?.execution?.result_code, + item?.execution?.failure_category, + item?.execution?.outcome_reason, + item?.outcome?.outcome_reason, + item?.outcome_reason, + ].some((value) => String(value || '').toLowerCase() === 'quote_not_found_or_finished'); +} + +function matchesLifecycleProblemFilter(item, filter) { + switch (filter) { + case 'awaiting_executor': + return item?.lifecycle_state === 'command_emitted'; + case 'approved_no_command': + return item?.lifecycle_state === 'evaluated'; + case 'blocked_before_submit': + return item?.lifecycle_state === 'blocked'; + case 'submission_failed': + return item?.lifecycle_state === 'failed'; + case 'quote_not_found_or_finished': + return isQuoteNotFoundOrFinished(item); + case 'strategy_rejected': + return isStrategyRejected(item); + case 'submitted_awaiting_outcome': + return item?.lifecycle_state === 'submitted' || item?.lifecycle_state === 'awaiting_outcome'; + case 'all': + default: + return true; + } +} + +function sourceLabel(source) { + if (source === 'live_row') return 'Live row'; + if (source === 'durable_lookup') return 'Durable lookup'; + if (source === 'not_found') return 'Not found'; + if (source === 'lookup_error') return 'Lookup error'; + if (source === 'loading') return 'Lookup loading'; + return 'No selection'; +} + +function sourceTone(source) { + if (source === 'not_found') return 'warning'; + if (source === 'lookup_error') return 'critical'; + if (source === 'live_row' || source === 'durable_lookup') return 'healthy'; + return 'unknown'; +} + +function formatCommandAge(item, now) { + const age = formatAgeFromTimestamp(item?.command_at, now); + return age === 'Unavailable' ? 'Unavailable' : `${age} old`; +} + +function executorResultState(item) { + if (item?.execution?.status) return `Recorded: ${plainCodeLabel(item.execution.status)}`; + if (item?.execution_result_at) return 'Recorded'; + if (item?.lifecycle_state === 'command_emitted') return 'Not recorded yet'; + return 'Unavailable'; +} + +function nextExpectedStage(item) { + if (item?.lifecycle_state === 'command_emitted') { + return 'Executor result, then relay or outcome evidence'; + } + if (item?.lifecycle_state === 'submitted' || item?.lifecycle_state === 'awaiting_outcome') { + return 'Durable outcome and settlement attribution'; + } + if (['failed', 'blocked', 'not_filled', 'completed', 'rejected'].includes(item?.lifecycle_state)) { + return 'No later stage is recorded as expected from current evidence'; + } + return 'Strategy decision or execute command'; +} + +function awaitingExecutorBrief(item, now) { + if (item?.lifecycle_state !== 'command_emitted') return null; + const command = item.command_id ? truncateMiddle(item.command_id, 24) : 'Unavailable'; + return `Command ${command}; age ${formatCommandAge(item, now)}; executor result ${executorResultState(item)}; next ${nextExpectedStage(item)}`; +} + function StageCard({ title, at, status, children }) { return (
@@ -239,7 +353,7 @@ function TimingWaterfall({ timing }) { ); } -function LifecycleDetails({ item }) { +function LifecycleDetails({ item, now = Date.now() }) { const executionTiming = formatExecutionTiming(item.execution?.timing); const makerTerms = formatMakerTerms(item.maker_terms); const inventoryCheck = formatInventoryCheck(item.inventory_check); @@ -262,12 +376,17 @@ function LifecycleDetails({ item }) { +
{`Command timestamp: ${formatTimestamp(item.command_at)}`}
+
{`Command age: ${formatCommandAge(item, now)}`}
+
{`Executor result: ${executorResultState(item)}`}
+
{`Next expected: ${nextExpectedStage(item)}`}
{makerTerms || formatTerms(item.submitted_terms)}
{makerTerms ?
{formatTerms(item.submitted_terms)}
: null}
{item.execution?.result_code || 'No executor result code stored'}
+ {item.execution?.failure_category ?
{plainCodeLabel(item.execution.failure_category)}
: null} {item.execution?.error_message ?
{item.execution.error_message}
: null} {!item.execution?.error_message && item.execution?.note ?
{item.execution.note}
: null} {executionTiming ?
{executionTiming}
: null} @@ -294,6 +413,81 @@ function LifecycleDetails({ item }) { ); } +function QuoteLifecycleInvestigator({ + selected, + lookupState, + lookupError, + now, + onRefresh, +}) { + const row = selected?.row || null; + const source = lookupState === 'loading' ? 'loading' : selected?.source; + + return ( +
+
+
+
Pinned quote investigator
+

{row?.quote_id ? truncateMiddle(row.quote_id, 46) : selected?.identifier ? truncateMiddle(selected.identifier, 46) : 'No quote selected'}

+
+ {row + ? `${row.lifecycle_label || 'Unavailable'} - ${row.reason_text || 'Unavailable'}` + : selected?.source === 'not_found' + ? 'No durable lifecycle evidence matched that identifier.' + : 'Select a row or look up an identifier to inspect durable lifecycle evidence.'} +
+
+
+ + {selected?.refreshed_at ? : null} + +
+
+ + {lookupError ?
{lookupError}
: null} + + {row ? ( + <> +
+
+
Lifecycle
+
{row.lifecycle_label || 'Unavailable'}
+
{row.reason_code || 'Unavailable'}
+
+
+
Command age
+
{formatCommandAge(row, now)}
+
{row.command_id || 'Unavailable'}
+
+
+
Executor result
+
{executorResultState(row)}
+
{formatTimestamp(row.execution_result_at)}
+
+
+
Next expected
+
{nextExpectedStage(row)}
+
{formatTimestamp(row.latest_stage_at)}
+
+
+ + + ) : ( + + Durable lifecycle evidence is unavailable for the current identifier. + + )} +
+ ); +} + function pairDisplayLabel(pairId, pairConfig) { const pair = (pairConfig?.pairs || []).find((entry) => ( (entry.pair_id || entry.pairId || entry.pair) === pairId @@ -661,15 +855,45 @@ function MakerCompetitivenessSection({ summary, pairConfig }) { function QuoteLifecycleTable({ items }) { const [expanded, setExpanded] = useState(() => new Set()); const [showStrategyRejected, setShowStrategyRejected] = useState(true); + const [problemFilter, setProblemFilter] = useState('all'); const latestItemsRef = useRef(items || []); const [quoteDisplayPaused, setQuoteDisplayPaused] = useState(false); const [displayItems, setDisplayItems] = useState(() => items || []); + const [lookupInput, setLookupInput] = useState(''); + const [lookupState, setLookupState] = useState('idle'); + const [lookupError, setLookupError] = useState(null); + const [selectedInvestigation, setSelectedInvestigation] = useState(null); + const selectedInvestigationRef = useRef(null); + const lookupTimerRef = useRef(null); const liveNow = useNow(); const [displayNow, setDisplayNow] = useState(() => Date.now()); + useEffect(() => { + selectedInvestigationRef.current = selectedInvestigation; + }, [selectedInvestigation]); + + useEffect(() => () => { + if (lookupTimerRef.current) window.clearTimeout(lookupTimerRef.current); + }, []); + useEffect(() => { latestItemsRef.current = items || []; if (!quoteDisplayPaused) setDisplayItems(items || []); + + const selected = selectedInvestigationRef.current; + if (!selected?.identifier) return; + const updated = updateSelectedLifecycleFromLiveRows(selected, items || [], { + now: Date.now(), + }); + if (updated !== selected) { + setSelectedInvestigation(updated); + setLookupState('live'); + setLookupError(null); + return; + } + if (shouldRefreshSelectedFromDurable(selected, items || [])) { + scheduleDurableLookup(selected.identifier); + } }, [items, quoteDisplayPaused]); useEffect(() => { @@ -680,9 +904,17 @@ function QuoteLifecycleTable({ items }) { () => displayItems.filter((item) => isStrategyRejected(item)).length, [displayItems], ); + const problemFilteredItems = useMemo( + () => displayItems.filter((item) => matchesLifecycleProblemFilter(item, problemFilter)), + [displayItems, problemFilter], + ); const visibleItems = useMemo( - () => (showStrategyRejected ? displayItems : displayItems.filter((item) => !isStrategyRejected(item))), - [displayItems, showStrategyRejected], + () => ( + showStrategyRejected || problemFilter === 'strategy_rejected' + ? problemFilteredItems + : problemFilteredItems.filter((item) => !isStrategyRejected(item)) + ), + [problemFilteredItems, problemFilter, showStrategyRejected], ); const visibleRows = fixedRows(visibleItems, QUOTE_LIFECYCLE_ROW_COUNT); const emptyRowsMessage = !displayItems.length @@ -700,6 +932,77 @@ function QuoteLifecycleTable({ items }) { }); } + async function performLookup(identifier, { silent = false } = {}) { + const trimmed = String(identifier || '').trim(); + if (!trimmed) return; + if (lookupTimerRef.current) { + window.clearTimeout(lookupTimerRef.current); + lookupTimerRef.current = null; + } + if (!silent) setLookupState('loading'); + setLookupError(null); + + try { + const payload = await fetchQuoteLifecycleLookup(trimmed); + setSelectedInvestigation((current) => updateSelectedLifecycleFromLookup( + current?.identifier === trimmed ? current : { identifier: trimmed, row: null }, + payload, + { identifier: trimmed, now: Date.now() }, + )); + setLookupState(payload?.ok ? 'found' : 'not_found'); + setLookupError(null); + } catch (error) { + const message = error?.message || 'Lookup failed'; + setSelectedInvestigation((current) => updateSelectedLifecycleFromLookup( + current?.identifier === trimmed ? current : { identifier: trimmed, row: null }, + { + error: message, + lookup: { + requested_identifier: trimmed, + matched_identifier_type: null, + found: false, + lookup_at: new Date().toISOString(), + latest_stage_at: null, + matched_identifiers: { + quote_ids: [], + decision_ids: [], + command_ids: [], + }, + }, + }, + { identifier: trimmed, now: Date.now() }, + )); + setLookupState('error'); + setLookupError(message); + } + } + + function scheduleDurableLookup(identifier) { + if (!identifier) return; + if (lookupTimerRef.current) window.clearTimeout(lookupTimerRef.current); + lookupTimerRef.current = window.setTimeout(() => { + performLookup(identifier, { silent: true }).catch(() => {}); + }, QUOTE_LIFECYCLE_LOOKUP_DEBOUNCE_MS); + } + + function inspectRow(item) { + const next = createSelectedLifecycleInvestigation(item, { + source: 'live_row', + now: Date.now(), + }); + if (!next) return; + setSelectedInvestigation(next); + setLookupInput(next.identifier); + setLookupState('live'); + setLookupError(null); + scheduleDurableLookup(next.identifier); + } + + function submitLookup(event) { + event.preventDefault(); + performLookup(lookupInput).catch(() => {}); + } + function applyLatestLifecycleDisplay() { setDisplayItems(latestItemsRef.current || []); setDisplayNow(Date.now()); @@ -712,7 +1015,29 @@ function QuoteLifecycleTable({ items }) { return ( <> +
+ + +
+
- Live rows update as they arrive. Fixed row slots and clamped cells keep the table height stable. + Pause freezes only the rolling table. The pinned quote investigator can still refresh from durable lookup.
+ performLookup(identifier).catch(() => {})} + selected={selectedInvestigation} + /> + @@ -760,13 +1093,18 @@ function QuoteLifecycleTable({ items }) { } const rowKey = item.quote_id || item.decision_id || item.command_id || item.latest_stage_at || String(index); const isExpanded = expanded.has(rowKey); + const isSelected = Boolean( + selectedInvestigation?.identifier + && findLifecycleRowByIdentifier([item], selectedInvestigation.identifier), + ); const quoteTime = item.quote_activity_at || item.latest_stage_at; const updatedText = item.latest_stage_at && item.latest_stage_at !== item.quote_activity_at ? `Updated ${formatTimestamp(item.latest_stage_at)} - ${formatRelativeAge(item.latest_stage_at, displayNow)}` : ''; + const awaitingBrief = awaitingExecutorBrief(item, displayNow); return ( - + {isExpanded ? ( - + ) : null} diff --git a/src/operator-dashboard/static/styles.css b/src/operator-dashboard/static/styles.css index 1a4f942..43bd3e2 100644 --- a/src/operator-dashboard/static/styles.css +++ b/src/operator-dashboard/static/styles.css @@ -618,10 +618,85 @@ table.lifecycle-table th:nth-child(5) { margin-bottom: 12px; } +.quote-lifecycle-lookup { + display: flex; + flex-wrap: wrap; + align-items: end; + gap: 10px; + margin-bottom: 12px; +} + +.quote-lifecycle-lookup-field { + flex: 1 1 320px; +} + +.quote-lifecycle-filter-field { + min-width: 230px; +} + +.quote-lifecycle-lookup-field span, +.quote-lifecycle-filter-field span { + font-size: 0.84rem; + color: var(--muted); +} + .quote-lifecycle-snapshot-note { margin-bottom: 12px; } +.quote-investigator-panel { + display: grid; + gap: 14px; + margin-bottom: 14px; + padding: 16px; + border: 1px solid var(--line); + border-radius: 16px; + background: rgba(255, 255, 255, 0.5); +} + +.quote-investigator-head { + display: flex; + justify-content: space-between; + gap: 16px; + align-items: flex-start; +} + +.quote-investigator-head h4 { + margin: 4px 0 0; + overflow-wrap: anywhere; +} + +.quote-investigator-actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 8px; +} + +.quote-investigator-facts { + display: grid; + gap: 12px; + grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); +} + +.quote-investigator-facts > div { + min-width: 0; + padding: 12px; + border: 1px solid var(--line); + border-radius: 14px; + background: var(--panel-2); +} + +.quote-investigator-facts .status-value { + margin-top: 6px; + font-size: 1rem; + overflow-wrap: anywhere; +} + +.quote-investigator-next { + line-height: 1.3; +} + .toggle-field { display: inline-flex; align-items: center; @@ -651,6 +726,11 @@ table.lifecycle-table th:nth-child(5) { height: 112px; } +.quote-lifecycle-table tbody tr.quote-lifecycle-row.is-selected td { + box-shadow: inset 3px 0 0 rgba(31, 122, 90, 0.76); + background: rgba(31, 122, 90, 0.08); +} + .quote-lifecycle-table td { overflow: hidden; } @@ -752,7 +832,12 @@ table.lifecycle-table th:nth-child(5) { .quote-lifecycle-table td:nth-child(8), .successful-trades-table th:nth-child(7), .successful-trades-table td:nth-child(7) { - width: 150px; + width: 170px; +} + +.lifecycle-action-stack { + display: grid; + gap: 8px; } .successful-trades-table { diff --git a/test/operator-dashboard-app-static.test.mjs b/test/operator-dashboard-app-static.test.mjs index 787fe81..1a36d7c 100644 --- a/test/operator-dashboard-app-static.test.mjs +++ b/test/operator-dashboard-app-static.test.mjs @@ -37,6 +37,16 @@ test('operator dashboard API auth failures are JSON for frontend fetches', () => assert.match(source, /sendJson\(res, 401, \{ error: 'authentication_required' \}\)/); }); +test('operator dashboard exposes authenticated durable quote lifecycle lookup API', () => { + assert.match(source, /quote-lifecycle/); + assert.match(source, /lifecycleLookupMatch/); + assert.match(source, /loadQuoteLifecycleLookupEvidence/); + assert.match(source, /buildQuoteLifecycleLookupResponse/); + assert.match(source, /quote_lifecycle_identifier_required/); + assert.match(source, /quote_lifecycle_not_found/); + assert.match(source, /payload\.ok \? 200 : 404/); +}); + test('operator dashboard bootstrap does not synchronously refresh intent request outcomes', () => { assert.match(source, /loadRecentIntentRequests\(pool, \{[\s\S]*refreshOutcomes: false/); }); diff --git a/test/operator-dashboard-ui-static.test.mjs b/test/operator-dashboard-ui-static.test.mjs index 30153a6..f388d7e 100644 --- a/test/operator-dashboard-ui-static.test.mjs +++ b/test/operator-dashboard-ui-static.test.mjs @@ -32,9 +32,29 @@ test('strategy page owns consolidated quote lifecycle and successful trade table assert.match(strategySource, /setDisplayItems\(items \|\| \[\]\)/); assert.match(strategySource, /useNow\(\)/); assert.match(strategySource, /Live updates/); - assert.match(strategySource, /Live rows update as they arrive/); + assert.match(strategySource, /Pause freezes only the rolling table/); + assert.match(strategySource, /Pinned quote investigator/); + assert.match(strategySource, /Lookup identifier/); + assert.match(strategySource, /Inspect quote/); + assert.match(strategySource, /Refresh lookup/); + assert.match(strategySource, /fetchQuoteLifecycleLookup/); + assert.match(strategySource, /\/api\/strategy\/quote-lifecycle\//); + assert.match(strategySource, /QUOTE_LIFECYCLE_PROBLEM_FILTERS/); + assert.match(strategySource, /awaiting_executor/); + assert.match(strategySource, /quote_not_found_or_finished/); + assert.match(strategySource, /approved_no_command/); + assert.match(strategySource, /submitted_awaiting_outcome/); + assert.match(strategySource, /updateSelectedLifecycleFromLiveRows/); + assert.match(strategySource, /shouldRefreshSelectedFromDurable/); + assert.match(strategySource, /updateSelectedLifecycleFromLookup/); + assert.match(strategySource, /Command age:/); + assert.match(strategySource, /Executor result:/); + assert.match(strategySource, /Next expected:/); assert.match(strategySource, /visibleRows/); assert.match(strategySource, /quote-lifecycle-placeholder-row/); + assert.match(stylesSource, /\.quote-investigator-panel/); + assert.match(stylesSource, /\.quote-lifecycle-lookup/); + assert.match(stylesSource, /\.quote-lifecycle-table tbody tr\.quote-lifecycle-row\.is-selected/); assert.match(stylesSource, /\.shell \{[\s\S]*?width: 100%;[\s\S]*?margin: 0;[\s\S]*?\}/); assert.doesNotMatch(stylesSource, /^\s*max-width\s*:/m); assert.doesNotMatch(strategySource, /maxWidth:/); diff --git a/test/operator-dashboard.test.mjs b/test/operator-dashboard.test.mjs index df9ca56..029cf54 100644 --- a/test/operator-dashboard.test.mjs +++ b/test/operator-dashboard.test.mjs @@ -7,6 +7,7 @@ import { buildDashboardBootstrap, buildDashboardControlErrorResponse, buildLiveStatusBar, + buildQuoteLifecycleLookupResponse, buildProfitabilitySummary, createDashboardLiveState, deriveQuoteLifecycleRows, @@ -15,12 +16,19 @@ import { resolveDashboardControlTimeoutMs, } from '../src/core/operator-dashboard.mjs'; import { formatAge, formatAgeFromTimestamp } from '../src/operator-dashboard/static/lib/format.js'; +import { + createSelectedLifecycleInvestigation, + shouldRefreshSelectedFromDurable, + updateSelectedLifecycleFromLiveRows, + updateSelectedLifecycleFromLookup, +} from '../src/operator-dashboard/static/lib/quoteLifecycleInvestigator.js'; import { dashboardReducer } from '../src/operator-dashboard/static/state/dashboardReducer.js'; import { buildDashboardSessionToken, parseBasicAuthorizationHeader, resolveDashboardRequestAuth, } from '../src/core/operator-dashboard-auth.mjs'; +import { loadQuoteLifecycleLookupEvidence } from '../src/lib/postgres.mjs'; function buildConfig() { const tradingBtc = { @@ -564,6 +572,272 @@ test('socket lifecycle messages replace strategy rows without page refresh', () assert.equal(next.dashboard.strategy.strategy_state.recent_lifecycle_rows[0].live_flash_at, '2026-04-04T09:00:00.000Z'); }); +test('selected awaiting executor quote remains inspectable after live rows move and refreshes from durable result', () => { + const config = buildConfig(); + const awaiting = buildQuoteLifecycleLookupResponse({ + config, + identifier: 'quote-follow', + evidence: { + lookup_at: '2026-06-12T10:00:00.000Z', + matched_identifier_type: 'quote_id', + matched_identifiers: { + quote_ids: ['quote-follow'], + decision_ids: ['decision-follow'], + command_ids: ['cmd-follow'], + }, + recentTradeDecisions: [{ + observed_at: '2026-06-12T09:59:58.000Z', + payload: { + quote_id: 'quote-follow', + decision_id: 'decision-follow', + pair: config.activePair, + decision: 'actionable', + decision_reason: 'actionable', + }, + }], + recentExecuteTradeCommands: [{ + observed_at: '2026-06-12T09:59:59.000Z', + command_id: 'cmd-follow', + decision_id: 'decision-follow', + quote_id: 'quote-follow', + pair: config.activePair, + }], + }, + }); + + assert.equal(awaiting.ok, true); + assert.equal(awaiting.lifecycle_row.lifecycle_state, 'command_emitted'); + assert.equal(awaiting.lifecycle_row.lifecycle_label, 'Awaiting executor'); + + const selected = createSelectedLifecycleInvestigation(awaiting.lifecycle_row, { + now: '2026-06-12T10:00:00.000Z', + }); + const afterLiveMove = updateSelectedLifecycleFromLiveRows(selected, [{ + quote_id: 'quote-newer', + lifecycle_state: 'observed', + }], { + now: '2026-06-12T10:00:02.000Z', + }); + + assert.equal(afterLiveMove.row.quote_id, 'quote-follow'); + assert.equal(afterLiveMove.row.lifecycle_state, 'command_emitted'); + assert.equal(shouldRefreshSelectedFromDurable(afterLiveMove, [{ quote_id: 'quote-newer' }]), true); + + const later = buildQuoteLifecycleLookupResponse({ + config, + identifier: 'cmd-follow', + evidence: { + lookup_at: '2026-06-12T10:00:04.000Z', + matched_identifier_type: 'command_id', + matched_identifiers: { + quote_ids: ['quote-follow'], + decision_ids: ['decision-follow'], + command_ids: ['cmd-follow'], + }, + recentExecuteTradeCommands: [{ + observed_at: '2026-06-12T09:59:59.000Z', + command_id: 'cmd-follow', + decision_id: 'decision-follow', + quote_id: 'quote-follow', + pair: config.activePair, + }], + recentExecutionResults: [{ + command_id: 'cmd-follow', + decision_id: 'decision-follow', + quote_id: 'quote-follow', + pair: config.activePair, + result_at: '2026-06-12T10:00:03.000Z', + status: 'failed', + result_code: 'submission_failed', + failure_category: 'quote_not_found_or_finished', + note: 'relay no longer has the quote', + }], + }, + }); + const refreshed = updateSelectedLifecycleFromLookup(afterLiveMove, later, { + identifier: 'cmd-follow', + now: '2026-06-12T10:00:04.000Z', + }); + + assert.equal(refreshed.source, 'durable_lookup'); + assert.equal(refreshed.row.quote_id, 'quote-follow'); + assert.equal(refreshed.row.lifecycle_state, 'failed'); + assert.equal(refreshed.row.reason_code, 'quote_not_found_or_finished'); + assert.match(refreshed.row.reason_text, /not found or already finished/i); +}); + +test('quote lifecycle lookup response matches quote, decision, and command identifiers from durable evidence', () => { + const config = buildConfig(); + const evidence = { + lookup_at: '2026-06-12T10:10:00.000Z', + matched_identifiers: { + quote_ids: ['quote-lookup'], + decision_ids: ['decision-lookup'], + command_ids: ['cmd-lookup'], + }, + recentQuotes: [{ + quote_id: 'quote-lookup', + pair: config.activePair, + asset_in: config.tradingBtc.assetId, + asset_out: config.tradingEure.assetId, + amount_in: '100', + amount_out: '200', + observed_at: '2026-06-12T10:09:57.000Z', + }], + recentTradeDecisions: [{ + observed_at: '2026-06-12T10:09:58.000Z', + payload: { + quote_id: 'quote-lookup', + decision_id: 'decision-lookup', + pair: config.activePair, + decision: 'actionable', + decision_reason: 'actionable', + }, + }], + recentExecuteTradeCommands: [{ + observed_at: '2026-06-12T10:09:59.000Z', + command_id: 'cmd-lookup', + decision_id: 'decision-lookup', + quote_id: 'quote-lookup', + pair: config.activePair, + }], + }; + + const byQuote = buildQuoteLifecycleLookupResponse({ + config, + identifier: 'quote-lookup', + evidence: { ...evidence, matched_identifier_type: 'quote_id' }, + }); + const byDecision = buildQuoteLifecycleLookupResponse({ + config, + identifier: 'decision-lookup', + evidence: { ...evidence, matched_identifier_type: 'decision_id' }, + }); + const byCommand = buildQuoteLifecycleLookupResponse({ + config, + identifier: 'cmd-lookup', + evidence: { ...evidence, matched_identifier_type: 'command_id' }, + }); + + assert.equal(byQuote.lifecycle_row.quote_id, 'quote-lookup'); + assert.equal(byDecision.lifecycle_row.decision_id, 'decision-lookup'); + assert.equal(byCommand.lifecycle_row.command_id, 'cmd-lookup'); + assert.equal(byQuote.lookup.matched_identifier_type, 'quote_id'); + assert.equal(byDecision.lookup.matched_identifier_type, 'decision_id'); + assert.equal(byCommand.lookup.matched_identifier_type, 'command_id'); +}); + +test('postgres quote lifecycle lookup loader links durable quote, decision, and command evidence', async () => { + const queries = []; + const pool = { + async query(sql, params = []) { + queries.push({ sql, params }); + if (sql.includes('WITH candidates')) { + return { + rows: [{ + matched_identifier_type: 'decision_id', + quote_id: 'quote-loader', + decision_id: 'decision-loader', + command_id: 'cmd-loader', + matched_at: '2026-06-12T10:20:00.000Z', + }], + }; + } + if (sql.includes('FROM swap_demand_events')) { + return { + rows: [{ + observed_at: '2026-06-12T10:19:57.000Z', + ingested_at: '2026-06-12T10:19:57.000Z', + payload: { + quote_id: 'quote-loader', + pair: 'btc->eure', + asset_in: 'btc', + asset_out: 'eure', + amount_in: '100', + amount_out: '200', + }, + }], + }; + } + if (sql.includes('FROM trade_decisions')) { + return { + rows: [{ + observed_at: '2026-06-12T10:19:58.000Z', + ingested_at: '2026-06-12T10:19:58.000Z', + payload: { + quote_id: 'quote-loader', + decision_id: 'decision-loader', + decision: 'actionable', + decision_reason: 'actionable', + }, + }], + }; + } + if (sql.includes('FROM execute_trade_commands')) { + return { + rows: [{ + observed_at: '2026-06-12T10:19:59.000Z', + ingested_at: '2026-06-12T10:19:59.000Z', + payload: { + quote_id: 'quote-loader', + decision_id: 'decision-loader', + command_id: 'cmd-loader', + }, + }], + }; + } + if (sql.includes('FROM trade_execution_results r')) { + return { + rows: [{ + result_observed_at: '2026-06-12T10:20:01.000Z', + result_ingested_at: '2026-06-12T10:20:01.000Z', + result_payload: { + quote_id: 'quote-loader', + decision_id: 'decision-loader', + command_id: 'cmd-loader', + status: 'failed', + result_code: 'submission_failed', + failure_category: 'quote_not_found_or_finished', + }, + command_ingested_at: '2026-06-12T10:19:59.000Z', + command_payload: { + quote_id: 'quote-loader', + decision_id: 'decision-loader', + command_id: 'cmd-loader', + }, + decision_payload: { + quote_id: 'quote-loader', + decision_id: 'decision-loader', + }, + outcome_payload: null, + }], + }; + } + if (sql.includes('FROM quote_outcome_attributions')) { + return { rows: [] }; + } + return { rows: [] }; + }, + }; + + const evidence = await loadQuoteLifecycleLookupEvidence(pool, { + identifier: 'decision-loader', + }); + + assert.equal(evidence.found, true); + assert.equal(evidence.matched_identifier_type, 'decision_id'); + assert.deepEqual(evidence.matched_identifiers, { + quote_ids: ['quote-loader'], + decision_ids: ['decision-loader'], + command_ids: ['cmd-loader'], + }); + assert.equal(evidence.recentQuotes[0].quote_id, 'quote-loader'); + assert.equal(evidence.recentTradeDecisions[0].payload.decision_id, 'decision-loader'); + assert.equal(evidence.recentExecuteTradeCommands[0].command_id, 'cmd-loader'); + assert.equal(evidence.recentExecutionResults[0].failure_category, 'quote_not_found_or_finished'); + assert.equal(queries[0].params[0], 'decision-loader'); +}); + test('dashboard age formatting uses seconds first and exact timestamp deltas', () => { assert.equal(formatAge(999), '999 ms'); assert.equal(formatAge(1_500), '1s');
{formatTimestamp(quoteTime)}
@@ -788,8 +1126,9 @@ function QuoteLifecycleTable({ items }) {
-
{item.reason_text}
-
{item.reason_code || 'reason_unknown'}
+
{item.reason_text || 'Unavailable'}
+
{item.reason_code || 'Unavailable'}
+ {awaitingBrief ?
{awaitingBrief}
: null}
@@ -800,14 +1139,19 @@ function QuoteLifecycleTable({ items }) { - +
+ + +