From 365acf7b7f2468988d29087da08a5b2ac2569a9e Mon Sep 17 00:00:00 2001 From: philipp Date: Mon, 18 May 2026 23:47:52 +0200 Subject: [PATCH] Add maker timing competitiveness truth Proof: quote-to-relay maker timing now propagates through ingest, normalized quotes, strategy decisions, commands, executor results, quote outcomes, lifecycle rows, dashboard summaries, and runtime alerts; relay failures preserve original text while classifying quote_not_found_or_finished; targeted tests, full npm test, and operator dashboard build passed before commit. Assumptions: response-age policy stays disabled by default and is only activated through DB-backed pair strategy config after operators review timing evidence; unrelated pre-existing dirty worktree files were left unstaged. Still fake: relay acceptance is not settlement or realized PnL; live policy thresholds still require post-deploy evidence before enabling skips for production pairs. --- src/apps/operator-dashboard.mjs | 26 +- src/apps/ops-sentinel.mjs | 6 + src/apps/strategy-engine.mjs | 29 +- src/apps/trade-executor.mjs | 52 ++- src/core/maker-competitiveness.mjs | 305 ++++++++++++++++ src/core/maker-timing.mjs | 146 ++++++++ src/core/operator-dashboard.mjs | 54 ++- src/core/quote-outcomes.mjs | 12 + src/core/relay-failure-classification.mjs | 45 +++ src/core/runtime-health.mjs | 68 ++++ src/core/strategy.mjs | 79 ++++ src/core/trading-config.mjs | 6 + src/lib/postgres.mjs | 118 +++++- .../static/pages/StrategyPage.jsx | 341 +++++++++++++++++- .../static/state/dashboardReducer.js | 6 + src/operator-dashboard/static/styles.css | 21 +- src/venues/near-intents/normalize.mjs | 30 +- src/venues/near-intents/ws.mjs | 17 +- test/maker-timing-competitiveness.test.mjs | 161 +++++++++ test/operator-dashboard-ui-static.test.mjs | 15 + test/ops-sentinel-static.test.mjs | 5 + test/runtime-health.test.mjs | 46 +++ test/strategy-engine-static.test.mjs | 7 +- test/strategy.test.mjs | 145 +++++++- test/trading-config.test.mjs | 55 ++- 25 files changed, 1755 insertions(+), 40 deletions(-) create mode 100644 src/core/maker-competitiveness.mjs create mode 100644 src/core/maker-timing.mjs create mode 100644 src/core/relay-failure-classification.mjs create mode 100644 test/maker-timing-competitiveness.test.mjs diff --git a/src/apps/operator-dashboard.mjs b/src/apps/operator-dashboard.mjs index ef4c6d3..c4807f0 100644 --- a/src/apps/operator-dashboard.mjs +++ b/src/apps/operator-dashboard.mjs @@ -7,6 +7,7 @@ import { WebSocketServer } from 'ws'; import { createConsumer } from '../bus/kafka/consumer.mjs'; import { parseEventMessage } from '../core/event-envelope.mjs'; +import { buildMakerCompetitivenessSummary } from '../core/maker-competitiveness.mjs'; import { applyDashboardLiveEvent, buildDashboardBootstrap, @@ -70,6 +71,7 @@ const dashboardRuntimeState = { source_errors: {}, last_source_error_at: null, last_live_event_error: null, + latest_maker_competitiveness: null, websocket_clients: 0, }; @@ -163,6 +165,9 @@ const liveState = createDashboardLiveState({ initialServiceSnapshots.find((snapshot) => snapshot.service === 'ops-sentinel')?.state?.active_alerts || [], }); +dashboardRuntimeState.latest_maker_competitiveness = buildMakerCompetitivenessSummary({ + lifecycleRows: buildLiveQuoteLifecycleRows(liveState), +}); const liveConsumer = await createConsumer({ groupId: config.kafkaConsumerGroupOperatorDashboard, @@ -194,6 +199,9 @@ await liveConsumer.run({ const event = parseEventMessage(message.value.toString()); const updates = applyDashboardLiveEvent(liveState, { topic, event }); for (const update of updates) { + if (update.maker_competitiveness) { + dashboardRuntimeState.latest_maker_competitiveness = update.maker_competitiveness; + } broadcast(update); } } catch (error) { @@ -216,12 +224,17 @@ const webSocketServer = new WebSocketServer({ webSocketServer.on('connection', (socket, _req, authContext) => { webSockets.add(socket); dashboardRuntimeState.websocket_clients = webSockets.size; + const recentLifecycleRows = buildLiveQuoteLifecycleRows(liveState); + dashboardRuntimeState.latest_maker_competitiveness = buildMakerCompetitivenessSummary({ + lifecycleRows: recentLifecycleRows, + }); socket.send(JSON.stringify({ type: 'session.ready', session: authContext, live: { recent_quotes: liveState.recent_quotes, - recent_lifecycle_rows: buildLiveQuoteLifecycleRows(liveState), + recent_lifecycle_rows: recentLifecycleRows, + maker_competitiveness: dashboardRuntimeState.latest_maker_competitiveness, status_bar: buildLiveStatusBar(liveState), }, })); @@ -260,6 +273,7 @@ const server = http.createServer(async (req, res) => { source_error_count: Object.keys(dashboardRuntimeState.source_errors).length, last_source_error_at: dashboardRuntimeState.last_source_error_at, last_live_event_error: dashboardRuntimeState.last_live_event_error, + latest_maker_competitiveness: dashboardRuntimeState.latest_maker_competitiveness, }); } @@ -556,6 +570,10 @@ async function loadBootstrapPayload({ auth, page, pageSize }) { }); dashboardRuntimeState.last_bootstrap_at = new Date().toISOString(); dashboardRuntimeState.last_bootstrap_error = null; + dashboardRuntimeState.latest_maker_competitiveness = ( + payload.strategy?.strategy_state?.maker_competitiveness + || dashboardRuntimeState.latest_maker_competitiveness + ); return payload; } @@ -630,6 +648,9 @@ async function invokeControl(control, body) { requestDefaultNotional: bodyField(body, 'request_default_notional', 'requestDefaultNotional'), requestMaxNotional: bodyField(body, 'request_max_notional', 'requestMaxNotional'), requestMaxSlippageBps: bodyField(body, 'request_max_slippage_bps', 'requestMaxSlippageBps'), + makerMaxQuoteAgeEnabled: bodyField(body, 'maker_max_quote_age_enabled', 'makerMaxQuoteAgeEnabled'), + makerMaxQuoteAgeMs: bodyField(body, 'maker_max_quote_age_ms', 'makerMaxQuoteAgeMs'), + makerLatencyPolicyReason: bodyField(body, 'maker_latency_policy_reason', 'makerLatencyPolicyReason'), changedBy: body.changed_by || 'operator', reason: body.reason || 'dashboard pair strategy config update', }); @@ -659,6 +680,9 @@ async function invokeControl(control, body) { requestDefaultNotional: bodyField(body, 'request_default_notional', 'requestDefaultNotional'), requestMaxNotional: bodyField(body, 'request_max_notional', 'requestMaxNotional'), requestMaxSlippageBps: bodyField(body, 'request_max_slippage_bps', 'requestMaxSlippageBps'), + makerMaxQuoteAgeEnabled: bodyField(body, 'maker_max_quote_age_enabled', 'makerMaxQuoteAgeEnabled'), + makerMaxQuoteAgeMs: bodyField(body, 'maker_max_quote_age_ms', 'makerMaxQuoteAgeMs'), + makerLatencyPolicyReason: bodyField(body, 'maker_latency_policy_reason', 'makerLatencyPolicyReason'), changedBy: body.changed_by || 'operator', reason: body.reason || 'dashboard pair mode update', }); diff --git a/src/apps/ops-sentinel.mjs b/src/apps/ops-sentinel.mjs index c1aaf76..85189f7 100644 --- a/src/apps/ops-sentinel.mjs +++ b/src/apps/ops-sentinel.mjs @@ -14,6 +14,7 @@ import { import { listDashboardServices } from '../core/operator-dashboard.mjs'; import { ageMs, + buildMakerCompetitivenessRuntimeAlerts, buildRuntimeAlert, createRuntimeHealthThresholds, evaluateRuntimeHealth, @@ -476,6 +477,11 @@ function buildDeterministicRuntimeAlerts({ servicesByName, now, previousRuntimeE const dashboard = servicesByName['operator-dashboard']; const dashboardState = dashboard?.state || {}; + alerts.push(...buildMakerCompetitivenessRuntimeAlerts({ + makerCompetitiveness: dashboardState.latest_maker_competitiveness + || dashboardState.maker_competitiveness + || null, + })); const dashboardSourceErrorCount = Number( dashboardState.source_error_count || dashboard?.health?.source_error_count diff --git a/src/apps/strategy-engine.mjs b/src/apps/strategy-engine.mjs index aebefa1..a4f83b0 100644 --- a/src/apps/strategy-engine.mjs +++ b/src/apps/strategy-engine.mjs @@ -6,6 +6,7 @@ import { createArmedStateStore } from '../core/armed-state-store.mjs'; import { startControlApi } from '../core/control-api.mjs'; import { buildEventEnvelope, parseEventMessage } from '../core/event-envelope.mjs'; import { createLogger, serializeError } from '../core/log.mjs'; +import { extendMakerTiming } from '../core/maker-timing.mjs'; import { createRecentIdCache } from '../core/recent-id-cache.mjs'; import { assertInventorySnapshotEvent, assertMarketPriceEvent, assertNormalizedSwapDemand } from '../core/schemas.mjs'; import { evaluateTradeOpportunity } from '../core/strategy.mjs'; @@ -108,6 +109,7 @@ await consumer.run({ async function handleDemand(event) { if (state.paused) return; + const strategyReceivedAt = new Date().toISOString(); const tradingConfig = await tradingConfigStore.getConfig(); if (seenQuotes.has(event.payload.quote_id)) { @@ -117,6 +119,7 @@ async function handleDemand(event) { || pair.priceRoute.quoteAssetId === tradingConfig.tradingEure?.assetId; await publishDecision({ decision_id: `duplicate-${event.payload.quote_id}`, + decision_at: strategyReceivedAt, quote_id: event.payload.quote_id, pair: event.payload.pair || `${event.payload.asset_in}->${event.payload.asset_out}`, pair_id: pair?.pairId || null, @@ -132,6 +135,10 @@ async function handleDemand(event) { max_notional_eure: legacyEureNotional && strategyConfig?.maxNotional != null ? String(strategyConfig.maxNotional) : null, + maker_timing: extendMakerTiming(event.payload.maker_timing, { + strategy_received_at: strategyReceivedAt, + strategy_decided_at: strategyReceivedAt, + }), strategy_armed: state.armed, }); return; @@ -147,20 +154,32 @@ async function handleDemand(event) { ...config, ...tradingConfig, }, + strategyReceivedAt, + now: Date.parse(strategyReceivedAt), armed: state.armed, }); await publishDecision(evaluation.decision); if (evaluation.command) { + const commandPublishedAt = new Date().toISOString(); + const makerTiming = extendMakerTiming(evaluation.command.maker_timing, { + command_published_at: commandPublishedAt, + }); + const commandPayload = { + ...evaluation.command, + command_published_at: commandPublishedAt, + maker_timing: makerTiming, + quote_age_at_command_ms: makerTiming.quote_age_at_command_ms, + }; const commandEvent = buildEventEnvelope({ source: 'strategy-engine', venue: 'near-intents', eventType: 'execute_trade', - observedAt: evaluation.command.decision_at || event.observed_at || event.ingested_at, - payload: evaluation.command, + observedAt: commandPublishedAt, + payload: commandPayload, }); - await producer.sendJson(config.kafkaTopicCmdExecuteTrade, commandEvent, { key: evaluation.command.execution_key }); + await producer.sendJson(config.kafkaTopicCmdExecuteTrade, commandEvent, { key: commandPayload.execution_key }); } } @@ -282,6 +301,10 @@ const controlApi = startControlApi({ const nextConfig = await createPairStrategyConfigVersion(configPool, { pairId, edgeBps, + maxNotional: body.max_notional, + makerMaxQuoteAgeEnabled: body.maker_max_quote_age_enabled, + makerMaxQuoteAgeMs: body.maker_max_quote_age_ms, + makerLatencyPolicyReason: body.maker_latency_policy_reason, changedBy: body.changed_by || 'operator', reason: body.reason || 'operator edge update', }); diff --git a/src/apps/trade-executor.mjs b/src/apps/trade-executor.mjs index 15e89e6..a554c00 100644 --- a/src/apps/trade-executor.mjs +++ b/src/apps/trade-executor.mjs @@ -10,6 +10,8 @@ import { buildEventEnvelope, parseEventMessage } from '../core/event-envelope.mj import { createExecutorStateStore } from '../core/executor-state-store.mjs'; import { createIntentRequestController } from '../core/intent-request-controller.mjs'; import { createLogger, serializeError } from '../core/log.mjs'; +import { extendMakerTiming } from '../core/maker-timing.mjs'; +import { classifyRelaySubmissionFailure } from '../core/relay-failure-classification.mjs'; import { assertExecuteTradeCommand, assertIntentRequestPreflightEvent, @@ -307,6 +309,8 @@ async function handleCommand(event) { await publishResult(payload, withExecutorTiming({ status: 'failed', result_code: 'submission_failed', + failure_category: classifyRelaySubmissionFailure(error), + relay_error_message: error?.message || String(error), error: serializeError(error), }, timing)); } finally { @@ -325,9 +329,14 @@ function startExecutorTiming(event) { || event?.payload?.decision_at || '', ); + const makerTiming = extendMakerTiming(event?.payload?.maker_timing, { + executor_received_at: receivedAt, + }); return { received_at: receivedAt.toISOString(), started_at_ms: performance.now(), + maker_timing: makerTiming, + quote_age_at_executor_receipt_ms: makerTiming.quote_age_at_executor_receipt_ms, command_event_age_ms: Number.isFinite(commandEventAtMs) ? roundTimingMs(receivedAt.getTime() - commandEventAtMs) : null, @@ -340,23 +349,40 @@ function recordExecutorTiming(timing, field, startedAtMs) { } function withExecutorTiming(payload, timing) { + const finished = finishExecutorTiming(timing); return { ...payload, - executor_timing: finishExecutorTiming(timing), + maker_timing: finished.maker_timing, + quote_age_at_executor_receipt_ms: finished.executor_timing?.quote_age_at_executor_receipt_ms ?? null, + quote_age_at_relay_result_ms: finished.executor_timing?.quote_age_at_relay_result_ms ?? null, + executor_timing: finished.executor_timing, }; } function finishExecutorTiming(timing) { - if (!timing) return null; + if (!timing) return { maker_timing: null, executor_timing: null }; + const executorResultAt = new Date().toISOString(); + const relayResultAt = timing.relay_response_ms == null ? null : executorResultAt; + const makerTiming = extendMakerTiming(timing.maker_timing, { + executor_result_at: executorResultAt, + relay_result_at: relayResultAt, + }); return { - received_at: timing.received_at, - command_event_age_ms: timing.command_event_age_ms, - current_salt_ms: timing.current_salt_ms ?? null, - current_salt_source: timing.current_salt_source ?? null, - current_salt_age_ms: timing.current_salt_age_ms ?? null, - sign_ms: timing.sign_ms ?? null, - relay_response_ms: timing.relay_response_ms ?? null, - executor_total_ms: roundTimingMs(performance.now() - timing.started_at_ms), + maker_timing: makerTiming, + executor_timing: { + received_at: timing.received_at, + executor_result_at: executorResultAt, + relay_result_at: relayResultAt, + command_event_age_ms: timing.command_event_age_ms, + quote_age_at_executor_receipt_ms: timing.quote_age_at_executor_receipt_ms ?? null, + quote_age_at_relay_result_ms: makerTiming.quote_age_at_relay_result_ms ?? null, + current_salt_ms: timing.current_salt_ms ?? null, + current_salt_source: timing.current_salt_source ?? null, + current_salt_age_ms: timing.current_salt_age_ms ?? null, + sign_ms: timing.sign_ms ?? null, + relay_response_ms: timing.relay_response_ms ?? null, + executor_total_ms: roundTimingMs(performance.now() - timing.started_at_ms), + }, }; } @@ -383,6 +409,12 @@ async function publishResult(command, extraPayload) { edge_bps: command.edge_bps || null, max_notional: command.max_notional || null, price_route_id: command.price_route_id || null, + reference_price_id: command.reference_price_id || null, + direction: command.direction || null, + request_kind: command.request_kind || null, + notional: command.notional || null, + notional_asset_id: command.notional_asset_id || null, + notional_symbol: command.notional_symbol || null, ...extraPayload, }, }); diff --git a/src/core/maker-competitiveness.mjs b/src/core/maker-competitiveness.mjs new file mode 100644 index 0000000..152f098 --- /dev/null +++ b/src/core/maker-competitiveness.mjs @@ -0,0 +1,305 @@ +import { normalizeMakerTiming } from './maker-timing.mjs'; +import { classifyRelaySubmissionFailure } from './relay-failure-classification.mjs'; + +const LATENCY_FIELDS = [ + 'quote_to_decision_ms', + 'decision_to_command_ms', + 'command_to_executor_ms', + 'executor_to_relay_result_ms', + 'quote_to_relay_result_ms', + 'quote_to_outcome_ms', +]; + +const QUOTE_AGE_FIELDS = [ + 'quote_age_at_decision_ms', + 'quote_age_at_executor_receipt_ms', + 'quote_age_at_relay_result_ms', +]; + +export function buildMakerCompetitivenessSummary({ lifecycleRows = [], generatedAt = new Date().toISOString() } = {}) { + const entries = (lifecycleRows || []) + .map(normalizeCompetitivenessEntry) + .filter((entry) => entry.quote_id || entry.decision_id || entry.command_id); + const groups = [...groupEntries(entries).values()] + .map(summarizeGroup) + .sort((left, right) => right.count - left.count || String(left.pair).localeCompare(String(right.pair))); + + return { + generated_at: generatedAt, + total: summarizeCounts(entries), + latency_stages: summarizeLatency(entries), + quote_age_stages: summarizeQuoteAgeStages(entries), + groups, + age_buckets: summarizeAgeBuckets(entries), + salt_sources: summarizeSaltSources(entries), + latest_errors: entries + .filter((entry) => entry.error_message || entry.failure_category) + .sort((left, right) => timestampMs(right.result_at) - timestampMs(left.result_at)) + .slice(0, 10) + .map((entry) => ({ + quote_id: entry.quote_id, + pair: entry.pair, + direction: entry.direction, + request_kind: entry.request_kind, + result_code: entry.result_code, + failure_category: entry.failure_category, + error_message: entry.error_message, + quote_age_bucket: entry.quote_age_bucket, + quote_age_ms: entry.quote_age_ms, + result_at: entry.result_at, + })), + policy_skips: entries + .filter((entry) => entry.outcome_status === 'policy_skip') + .slice(0, 10) + .map((entry) => ({ + quote_id: entry.quote_id, + pair: entry.pair, + direction: entry.direction, + request_kind: entry.request_kind, + reason_code: entry.result_code, + quote_age_ms: entry.quote_age_ms, + quote_age_bucket: entry.quote_age_bucket, + max_quote_age_ms: entry.policy?.max_quote_age_ms ?? null, + pair_config_id: entry.pair_config_id, + pair_config_version: entry.pair_config_version, + decision_at: entry.decision_at, + })), + }; +} + +export function normalizeCompetitivenessEntry(row = {}) { + const timing = normalizeMakerTiming( + row.maker_timing + || row.execution?.maker_timing + || row.command?.maker_timing + || row.decision?.maker_timing + || row.quote?.maker_timing + || {}, + ); + const execution = row.execution || {}; + const decision = row.decision || {}; + const policy = decision.response_policy || row.response_policy || null; + const resultCode = execution.result_code + || (decision.decision === 'blocked' ? decision.decision_reason : null) + || row.reason_code + || null; + const failureCategory = execution.failure_category + || (execution.status === 'failed' ? classifyRelaySubmissionFailure(execution) : null); + const quoteAgeMs = firstNumber([ + timing.quote_age_at_relay_result_ms, + timing.quote_age_at_executor_receipt_ms, + timing.quote_age_at_decision_ms, + policy?.measured_quote_age_ms, + row.quote_age_at_decision_ms, + ]); + const outcomeStatus = classifyOutcomeStatus({ row, execution, decision }); + + return { + quote_id: row.quote_id || decision.quote_id || execution.quote_id || null, + decision_id: row.decision_id || decision.decision_id || execution.decision_id || null, + command_id: row.command_id || execution.command_id || null, + pair: row.pair || decision.pair || execution.pair || null, + pair_id: row.pair_id || decision.pair_id || execution.pair_id || null, + pair_config_id: row.pair_config_id || decision.pair_config_id || execution.pair_config_id || null, + pair_config_version: + row.pair_config_version || decision.pair_config_version || execution.pair_config_version || null, + direction: row.direction || decision.direction || row.command?.direction || 'unknown', + request_kind: row.request_kind || decision.request_kind || row.command?.request_kind || 'unknown', + result_code: resultCode || 'no_result', + failure_category: failureCategory, + notional: row.notional ?? decision.notional ?? row.command?.notional ?? null, + notional_symbol: row.notional_symbol || decision.notional_symbol || row.command?.notional_symbol || null, + notional_bucket: notionalBucket(row.notional ?? decision.notional ?? row.command?.notional, row.notional_symbol || decision.notional_symbol || row.command?.notional_symbol), + quote_age_ms: quoteAgeMs, + quote_age_bucket: quoteAgeBucket(quoteAgeMs), + outcome_status: outcomeStatus, + accepted: execution.status === 'submitted', + relay_failed: execution.status === 'failed', + policy_skip: outcomeStatus === 'policy_skip', + error_message: execution.error_message || execution.error?.message || null, + result_at: row.execution_result_at || execution.result_at || null, + decision_at: row.decision_at || decision.decision_at || null, + maker_timing: timing, + executor_timing: execution.timing || null, + policy, + }; +} + +export function quoteAgeBucket(value) { + const number = Number(value); + if (!Number.isFinite(number)) return 'unavailable'; + if (number < 50) return '<50ms'; + if (number < 100) return '50-100ms'; + if (number < 250) return '100-250ms'; + if (number < 500) return '250-500ms'; + if (number < 1000) return '500-1000ms'; + return '>=1000ms'; +} + +export function notionalBucket(value, symbol = null) { + const number = Number(value); + const suffix = symbol ? ` ${symbol}` : ''; + if (!Number.isFinite(number)) return `unavailable${suffix}`; + if (number < 1) return `<1${suffix}`; + if (number < 5) return `1-5${suffix}`; + if (number < 25) return `5-25${suffix}`; + if (number < 100) return `25-100${suffix}`; + return `>=100${suffix}`; +} + +function groupEntries(entries) { + const groups = new Map(); + for (const entry of entries) { + const key = [ + entry.pair || 'unknown', + entry.direction || 'unknown', + entry.request_kind || 'unknown', + entry.result_code || 'no_result', + entry.failure_category || 'none', + entry.quote_age_bucket, + entry.notional_bucket, + entry.outcome_status || 'unknown', + ].join('|'); + const list = groups.get(key) || []; + list.push(entry); + groups.set(key, list); + } + return groups; +} + +function summarizeGroup(entries) { + const first = entries[0] || {}; + return { + pair: first.pair || null, + direction: first.direction || 'unknown', + request_kind: first.request_kind || 'unknown', + result_code: first.result_code || 'no_result', + failure_category: first.failure_category || null, + quote_age_bucket: first.quote_age_bucket, + notional_bucket: first.notional_bucket, + outcome_status: first.outcome_status || 'unknown', + ...summarizeCounts(entries), + latency_stages: summarizeLatency(entries), + }; +} + +function summarizeCounts(entries) { + const count = entries.length; + const accepted = entries.filter((entry) => entry.accepted).length; + const relayFailed = entries.filter((entry) => entry.relay_failed).length; + const policySkips = entries.filter((entry) => entry.policy_skip).length; + const staleFinished = entries.filter((entry) => entry.failure_category === 'quote_not_found_or_finished').length; + return { + count, + accepted_count: accepted, + relay_failed_count: relayFailed, + policy_skip_count: policySkips, + quote_not_found_or_finished_count: staleFinished, + accepted_rate: count ? accepted / count : null, + stale_or_finished_rate: count ? staleFinished / count : null, + }; +} + +function summarizeLatency(entries) { + return LATENCY_FIELDS.map((field) => ({ + stage: field, + ...percentiles(entries.map((entry) => entry.maker_timing?.[field])), + })).filter((stage) => stage.count > 0); +} + +function summarizeQuoteAgeStages(entries) { + return QUOTE_AGE_FIELDS.map((field) => ({ + stage: field, + ...percentiles(entries.map((entry) => entry.maker_timing?.[field])), + })).filter((stage) => stage.count > 0); +} + +function summarizeAgeBuckets(entries) { + const buckets = new Map(); + for (const entry of entries) { + const key = [ + entry.pair || 'unknown', + entry.direction || 'unknown', + entry.request_kind || 'unknown', + entry.quote_age_bucket, + entry.outcome_status || 'unknown', + ].join('|'); + const list = buckets.get(key) || []; + list.push(entry); + buckets.set(key, list); + } + return [...buckets.values()].map((list) => { + const first = list[0] || {}; + return { + pair: first.pair || null, + direction: first.direction || 'unknown', + request_kind: first.request_kind || 'unknown', + quote_age_bucket: first.quote_age_bucket, + outcome_status: first.outcome_status || 'unknown', + ...summarizeCounts(list), + }; + }).sort((left, right) => right.count - left.count); +} + +function summarizeSaltSources(entries) { + const counts = new Map(); + for (const entry of entries) { + const source = entry.executor_timing?.current_salt_source || 'unavailable'; + counts.set(source, (counts.get(source) || 0) + 1); + } + return [...counts.entries()] + .map(([source, count]) => ({ source, count })) + .sort((left, right) => right.count - left.count); +} + +function percentiles(values) { + const sorted = values + .filter(isNumericValue) + .map(Number) + .filter(Number.isFinite) + .sort((left, right) => left - right); + if (!sorted.length) return { count: 0, p50_ms: null, p90_ms: null, p99_ms: null }; + return { + count: sorted.length, + p50_ms: percentile(sorted, 0.5), + p90_ms: percentile(sorted, 0.9), + p99_ms: percentile(sorted, 0.99), + }; +} + +function percentile(sorted, rank) { + const index = Math.min(sorted.length - 1, Math.ceil(sorted.length * rank) - 1); + return sorted[index]; +} + +function classifyOutcomeStatus({ row, execution, decision }) { + if (decision.decision === 'blocked' && String(decision.decision_reason || '').startsWith('maker_')) { + return 'policy_skip'; + } + if (row.outcome_status) return row.outcome_status; + if (row.lifecycle_state === 'failed') return 'relay_failed'; + if (execution.status === 'failed') return 'relay_failed'; + if (execution.status === 'submitted') return row.outcome_status || 'submitted'; + if (row.lifecycle_state) return row.lifecycle_state; + if (decision.decision === 'rejected') return 'strategy_rejected'; + return 'unknown'; +} + +function firstNumber(values) { + for (const value of values || []) { + if (!isNumericValue(value)) continue; + const number = Number(value); + if (Number.isFinite(number)) return number; + } + return null; +} + +function isNumericValue(value) { + return value != null && value !== ''; +} + +function timestampMs(value) { + if (!value) return Number.NEGATIVE_INFINITY; + const parsed = Date.parse(value); + return Number.isFinite(parsed) ? parsed : Number.NEGATIVE_INFINITY; +} diff --git a/src/core/maker-timing.mjs b/src/core/maker-timing.mjs new file mode 100644 index 0000000..0103717 --- /dev/null +++ b/src/core/maker-timing.mjs @@ -0,0 +1,146 @@ +export const MAKER_TIMING_TIMESTAMP_FIELDS = [ + 'quote_observed_at', + 'quote_received_at', + 'quote_normalized_at', + 'quote_published_at', + 'strategy_received_at', + 'strategy_decided_at', + 'command_published_at', + 'executor_received_at', + 'executor_result_at', + 'relay_result_at', + 'outcome_observed_at', +]; + +export const MAKER_TIMING_DURATION_FIELDS = [ + 'quote_to_decision_ms', + 'decision_to_command_ms', + 'command_to_executor_ms', + 'executor_to_relay_result_ms', + 'quote_to_relay_result_ms', + 'quote_to_outcome_ms', + 'quote_age_at_decision_ms', + 'quote_age_at_command_ms', + 'quote_age_at_executor_receipt_ms', + 'quote_age_at_relay_result_ms', +]; + +const DURATION_DEFINITIONS = { + quote_to_decision_ms: ['quote_start_at', 'strategy_decided_at'], + decision_to_command_ms: ['strategy_decided_at', 'command_published_at'], + command_to_executor_ms: ['command_published_at', 'executor_received_at'], + executor_to_relay_result_ms: ['executor_received_at', 'relay_result_at'], + quote_to_relay_result_ms: ['quote_start_at', 'relay_result_at'], + quote_to_outcome_ms: ['quote_start_at', 'outcome_observed_at'], + quote_age_at_decision_ms: ['quote_start_at', 'strategy_decided_at'], + quote_age_at_command_ms: ['quote_start_at', 'command_published_at'], + quote_age_at_executor_receipt_ms: ['quote_start_at', 'executor_received_at'], + quote_age_at_relay_result_ms: ['quote_start_at', 'relay_result_at'], +}; + +export function buildInitialMakerTiming({ + quoteObservedAt = null, + quoteReceivedAt = null, + quoteNormalizedAt = null, + quotePublishedAt = null, +} = {}) { + return extendMakerTiming(null, { + quote_observed_at: quoteObservedAt, + quote_received_at: quoteReceivedAt, + quote_normalized_at: quoteNormalizedAt, + quote_published_at: quotePublishedAt, + }); +} + +export function extendMakerTiming(existing = null, fields = {}) { + const timing = normalizeMakerTiming(existing); + + for (const field of MAKER_TIMING_TIMESTAMP_FIELDS) { + if (!Object.hasOwn(fields, field)) continue; + timing[field] = toIsoTimestamp(fields[field]); + } + + for (const field of MAKER_TIMING_DURATION_FIELDS) { + if (!Object.hasOwn(fields, field)) continue; + timing[field] = roundTimingMs(fields[field]); + } + + return recomputeMakerTiming(timing); +} + +export function normalizeMakerTiming(value = null) { + const source = value?.maker_timing && typeof value.maker_timing === 'object' + ? value.maker_timing + : value || {}; + const timing = {}; + + for (const field of MAKER_TIMING_TIMESTAMP_FIELDS) { + timing[field] = toIsoTimestamp(source[field] ?? value?.[field]); + } + for (const field of MAKER_TIMING_DURATION_FIELDS) { + timing[field] = roundTimingMs(source[field] ?? value?.[field]); + } + + const unavailable = source.unavailable_reasons || value?.unavailable_reasons || {}; + timing.unavailable_reasons = isRecord(unavailable) ? { ...unavailable } : {}; + return recomputeMakerTiming(timing); +} + +export function quoteAgeMsAt(timingInput, timestamp) { + const timing = normalizeMakerTiming(timingInput); + return safeDurationMs(resolveQuoteStartAt(timing), timestamp).value; +} + +export function resolveQuoteStartAt(timingInput) { + const timing = normalizeMakerTiming(timingInput); + return timing.quote_received_at || timing.quote_observed_at || null; +} + +export function safeDurationMs(start, end) { + const startMs = timestampMs(start); + const endMs = timestampMs(end); + if (!Number.isFinite(startMs) || !Number.isFinite(endMs)) { + return { value: null, reason: 'timestamp_missing' }; + } + const duration = endMs - startMs; + if (duration < 0) return { value: null, reason: 'clock_skew_or_negative_duration' }; + return { value: roundTimingMs(duration), reason: null }; +} + +export function toIsoTimestamp(value) { + if (value == null || value === '') return null; + if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value.toISOString(); + const parsed = new Date(value); + return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString(); +} + +export function roundTimingMs(value) { + const number = Number(value); + return Number.isFinite(number) ? Math.round(number * 1000) / 1000 : null; +} + +function recomputeMakerTiming(timing) { + const next = { ...timing, unavailable_reasons: {} }; + const quoteStartAt = timing.quote_received_at || timing.quote_observed_at || null; + + for (const [field, [startField, endField]] of Object.entries(DURATION_DEFINITIONS)) { + const start = startField === 'quote_start_at' ? quoteStartAt : timing[startField]; + const end = timing[endField]; + const duration = safeDurationMs(start, end); + next[field] = duration.value; + if (duration.reason) next.unavailable_reasons[field] = duration.reason; + } + + if (!Object.keys(next.unavailable_reasons).length) delete next.unavailable_reasons; + return next; +} + +function timestampMs(value) { + if (!value) return Number.NaN; + const parsed = Date.parse(value); + return Number.isFinite(parsed) ? parsed : Number.NaN; +} + +function isRecord(value) { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} diff --git a/src/core/operator-dashboard.mjs b/src/core/operator-dashboard.mjs index b4ce5ba..4ba9ab6 100644 --- a/src/core/operator-dashboard.mjs +++ b/src/core/operator-dashboard.mjs @@ -1,6 +1,7 @@ import { unitsToNumber } from './assets.mjs'; import { bridgeDepositObservedAt } from './bridge-assets.mjs'; import { summarizeFundingObservations } from './funding-observations.mjs'; +import { buildMakerCompetitivenessSummary } from './maker-competitiveness.mjs'; import { resolveDashboardRequestAuth } from './operator-dashboard-auth.mjs'; import { TERMINAL_SETTLEMENT_ATTRIBUTION_STATUSES } from './quote-outcomes.mjs'; import { inferServiceFreshnessTimestamp as inferRuntimeFreshnessTimestamp } from './runtime-health.mjs'; @@ -1105,6 +1106,9 @@ const HUMAN_REASON_TEXT = { executor_disarmed: 'Executor is disarmed.', executor_paused: 'Executor intake is paused.', inventory_unavailable: 'Inventory unavailable.', + maker_quote_age_unavailable: 'Maker quote age is unavailable.', + maker_quote_response_policy_invalid: 'Maker response-age policy is invalid.', + maker_quote_too_old: 'Maker quote is too old for the configured response-age policy.', pending_deposit_not_credited: 'Funding is not credited yet.', quote_expired: 'Quote expired.', quote_response_ack: 'Quote response acknowledged by the relay.', @@ -1147,6 +1151,7 @@ export function deriveQuoteLifecycleRows({ asset_out: normalizedQuote?.asset_out || null, amount_in: normalizedQuote?.amount_in || null, amount_out: normalizedQuote?.amount_out || null, + maker_timing: normalizedQuote?.maker_timing || null, quote: normalizedQuote, quote_observed_at: normalizedQuote?.observed_at || normalizedQuote?.ingested_at || null, }); @@ -1179,6 +1184,7 @@ export function deriveQuoteLifecycleRows({ notional_asset_id: decision.notional_asset_id, notional_symbol: decision.notional_symbol, eure_notional: decision.eure_notional, + maker_timing: decision.maker_timing || null, decision, decision_at: decision.decision_at || null, }); @@ -1214,6 +1220,7 @@ export function deriveQuoteLifecycleRows({ notional_asset_id: command.notional_asset_id || null, notional_symbol: command.notional_symbol || null, eure_notional: command.eure_notional || null, + maker_timing: command.maker_timing || null, command, command_at: command.command_at || null, }); @@ -1226,6 +1233,7 @@ export function deriveQuoteLifecycleRows({ decision_id: execution?.decision_id || null, command_id: execution?.command_id || null, pair: execution?.pair || null, + maker_timing: execution?.maker_timing || null, execution, execution_result_at: execution?.result_at || null, }); @@ -1245,6 +1253,7 @@ export function deriveQuoteLifecycleRows({ notional_asset_id: outcome?.notional_asset_id || null, notional_symbol: outcome?.notional_symbol || null, eure_notional: outcome?.eure_notional || null, + maker_timing: outcome?.maker_timing || null, outcome, command_at: outcome?.command_at || null, execution_result_at: outcome?.submitted_at || null, @@ -1286,6 +1295,7 @@ function ensureLifecycleRow(rowsByKey, key) { command_at: null, execution_result_at: null, outcome_observed_at: null, + maker_timing: null, quote: null, decision: null, command: null, @@ -1369,6 +1379,11 @@ function finalizeLifecycleRow(row) { lifecycle_label = 'Awaiting executor'; reason_code = 'awaiting_executor'; reason_text = 'Execute command recorded, but no executor result is stored yet.'; + } else if (decision?.decision === 'blocked') { + lifecycle_state = 'blocked'; + lifecycle_label = 'Policy skip'; + reason_code = normalizeLifecycleToken(decision?.decision_reason || 'reason_unknown'); + reason_text = buildPolicySkipText(decision, reason_code); } else if (decision?.decision === 'rejected') { lifecycle_state = 'rejected'; lifecycle_label = 'Rejected by strategy'; @@ -1416,6 +1431,7 @@ function finalizeLifecycleRow(row) { durable_outcome_source: outcome?.outcome_source || null, attribution_status: outcome?.attribution_status || null, attribution_method: outcome?.attribution_method || null, + maker_timing: row.maker_timing || null, }, outcome_source: outcome?.outcome_source || null, outcome_status: outcome?.outcome_status || execution?.outcome_status || null, @@ -1426,9 +1442,20 @@ function finalizeLifecycleRow(row) { || execution?.attributed_inventory_delta || null, has_settlement_evidence: hasSettlementEvidence(outcome || execution), + maker_timing: row.maker_timing || null, }; } +function buildPolicySkipText(decision, reasonCode) { + const base = humanizeReasonCode(reasonCode, 'Policy skipped the response.'); + const policy = decision?.response_policy || {}; + const measured = policy.measured_quote_age_ms == null ? null : `${policy.measured_quote_age_ms}ms`; + const max = policy.max_quote_age_ms == null ? null : `${policy.max_quote_age_ms}ms`; + if (measured && max) return `${base} Quote age ${measured} exceeded configured max ${max}.`; + if (measured) return `${base} Quote age ${measured}.`; + return base; +} + function buildCompletedOutcomeText({ outcome, reasonCode }) { const base = humanizeReasonCode(reasonCode, 'Completed.'); if (!outcome?.attribution_status) return `${base} Settlement attribution is not stored.`; @@ -1487,6 +1514,7 @@ function normalizeLifecycleQuote(quote) { amount_in: quote.amount_in ?? null, amount_out: quote.amount_out ?? null, min_deadline_ms: quote.min_deadline_ms ?? null, + maker_timing: quote.maker_timing || null, observed_at: quote.observed_at || null, ingested_at: quote.ingested_at || null, }; @@ -1514,6 +1542,8 @@ function normalizeCommand(command) { asset_out: command.asset_out || null, amount_in: command.amount_in ?? null, amount_out: command.amount_out ?? null, + maker_timing: command.maker_timing || null, + quote_age_at_command_ms: command.quote_age_at_command_ms ?? null, command_at: command.command_at || command.observed_at || command.ingested_at || null, }; } @@ -1575,6 +1605,9 @@ function buildStrategySummary({ }).map((row) => enrichLifecycleRowForUi({ config, row })); const lifecycleRows = allLifecycleRows.slice(0, 20); const tradeFunnel = buildTradeFunnelSummary(allLifecycleRows); + const makerCompetitiveness = buildMakerCompetitivenessSummary({ + lifecycleRows: allLifecycleRows, + }); return { strategy_state: { @@ -1594,6 +1627,7 @@ function buildStrategySummary({ : [...durableDecisionsById.values()].slice(0, 20), recent_lifecycle_rows: lifecycleRows, trade_funnel: tradeFunnel, + maker_competitiveness: makerCompetitiveness, skipped_counts: strategyState.skipped_counts || {}, durable_control_state: strategyState.durable_control_state || null, trading_config: strategyState.trading_config || null, @@ -2257,6 +2291,9 @@ function normalizeDecision(decision) { notional_asset_id: decision.notional_asset_id || null, notional_symbol: decision.notional_symbol || null, eure_notional: decision.eure_notional || null, + maker_timing: decision.maker_timing || null, + quote_age_at_decision_ms: decision.quote_age_at_decision_ms ?? null, + response_policy: decision.response_policy || null, }; } @@ -2342,12 +2379,14 @@ function normalizeDashboardLiveTopic(state, topic) { function buildQuoteLifecycleUpdate(state, { flashQuoteId = null } = {}) { const receivedAt = new Date().toISOString(); + const lifecycleRows = buildLiveQuoteLifecycleRows(state, { + flashQuoteId, + flashAt: receivedAt, + }); return { type: 'quote_lifecycle.updated', - recent_lifecycle_rows: buildLiveQuoteLifecycleRows(state, { - flashQuoteId, - flashAt: receivedAt, - }), + recent_lifecycle_rows: lifecycleRows, + maker_competitiveness: buildMakerCompetitivenessSummary({ lifecycleRows, generatedAt: receivedAt }), flash_quote_id: flashQuoteId || null, received_at: receivedAt, }; @@ -2379,6 +2418,7 @@ function normalizeLiveDecision(payload, event) { payload: { ...payload, decision_at: decisionAt, + maker_timing: payload.maker_timing || null, }, }; } @@ -2392,6 +2432,7 @@ function normalizeLiveCommand(payload, event) { ...payload, amount_in: payload.quote_output?.amount_in ?? payload.proposed_amount_in ?? payload.amount_in ?? null, amount_out: payload.quote_output?.amount_out ?? payload.proposed_amount_out ?? payload.amount_out ?? null, + maker_timing: payload.maker_timing || null, }, }; } @@ -2407,15 +2448,17 @@ function normalizeLiveExecutionResult(payload, event) { result_at: event.observed_at || event.ingested_at || new Date().toISOString(), status: payload.status || null, result_code: payload.result_code || null, + failure_category: payload.failure_category || null, outcome_status: payload.outcome_status || payload.venue_outcome_status || payload.trade_outcome_status || null, outcome_reason: payload.outcome_reason || payload.venue_outcome_reason || payload.trade_outcome_reason || null, attribution_status: payload.attribution_status || null, attribution_method: payload.attribution_method || null, attributed_inventory_delta: payload.attributed_inventory_delta || null, venue_response: payload.venue_response || null, - error_message: payload.error?.message || null, + error_message: payload.relay_error_message || payload.error?.message || null, note: payload.note || null, timing: payload.executor_timing || null, + maker_timing: payload.maker_timing || null, }; } @@ -2434,6 +2477,7 @@ function normalizeLiveQuote(payload, event) { request_kind: payload.request_kind || null, amount_in: payload.amount_in ?? null, amount_out: payload.amount_out ?? null, + maker_timing: payload.maker_timing || null, observed_at: event.observed_at || null, ingested_at: event.ingested_at || null, }; diff --git a/src/core/quote-outcomes.mjs b/src/core/quote-outcomes.mjs index fc2d989..b6f5485 100644 --- a/src/core/quote-outcomes.mjs +++ b/src/core/quote-outcomes.mjs @@ -1,3 +1,5 @@ +import { extendMakerTiming } from './maker-timing.mjs'; + const DEFAULT_ATTRIBUTION_WINDOW_MS = 10 * 60 * 1000; const DEFAULT_SETTLEMENT_GRACE_MS = 60 * 1000; @@ -283,6 +285,10 @@ function baseOutcomeRecord({ attributed_inventory_delta, evidence, }) { + const makerTiming = extendMakerTiming( + submission.maker_timing || command?.maker_timing || decision?.maker_timing || {}, + { outcome_observed_at }, + ); return { quote_id: submission.quote_id, decision_id: command?.decision_id || submission.decision_id || decision?.decision_id || null, @@ -312,6 +318,8 @@ function baseOutcomeRecord({ eure_notional: decision?.eure_notional || command?.eure_notional || null, execution_result_status: submission.status, execution_result_code: submission.result_code || null, + failure_category: submission.failure_category || null, + maker_timing: makerTiming, submitted_at: submission.submitted_at, command_at: command?.command_at || null, outcome_status, @@ -413,6 +421,8 @@ function normalizeSubmission(entry) { pair: payload.pair || null, status: payload.status || null, result_code: payload.result_code || null, + failure_category: payload.failure_category || null, + maker_timing: payload.maker_timing || null, submitted_at: toIsoTimestamp( entry?.observed_at || entry?.ingested_at @@ -446,6 +456,7 @@ function normalizeCommand(entry) { proposed_amount_out: payload.proposed_amount_out ?? null, expected_inventory_delta_units: payload.expected_inventory_delta_units || null, min_deadline_ms: payload.min_deadline_ms ?? null, + maker_timing: payload.maker_timing || null, command_at: toIsoTimestamp( entry?.observed_at || entry?.ingested_at @@ -470,6 +481,7 @@ function normalizeDecision(entry) { notional_asset_id: payload.notional_asset_id || null, notional_symbol: payload.notional_symbol || null, eure_notional: payload.eure_notional || null, + maker_timing: payload.maker_timing || null, }; } diff --git a/src/core/relay-failure-classification.mjs b/src/core/relay-failure-classification.mjs new file mode 100644 index 0000000..7ae906a --- /dev/null +++ b/src/core/relay-failure-classification.mjs @@ -0,0 +1,45 @@ +export const RELAY_FAILURE_CATEGORIES = new Set([ + 'quote_not_found_or_finished', + 'relay_timeout', + 'relay_disconnected', + 'signing_failed', + 'salt_unavailable', + 'unknown_submission_failure', +]); + +export function classifyRelaySubmissionFailure(errorOrPayload = null) { + const text = failureText(errorOrPayload); + + if (/quote[^.]*not[^.]*found|already[^.]*finished|not[^.]*found[^.]*already[^.]*finished/i.test(text)) { + return 'quote_not_found_or_finished'; + } + if (/timed?\s*out|timeout|aborted/i.test(text)) return 'relay_timeout'; + if (/socket[^.]*not[^.]*connected|disconnected|connection\s*lost|connection\s*closed|websocket|econnreset|epipe/i.test(text)) { + return 'relay_disconnected'; + } + if (/salt|current_salt|verifier salt/i.test(text)) return 'salt_unavailable'; + if (/sign|signature|private key|ed25519|key pair/i.test(text)) return 'signing_failed'; + + return 'unknown_submission_failure'; +} + +function failureText(value) { + if (!value) return ''; + if (typeof value === 'string') return value; + + const error = value.error && typeof value.error === 'object' ? value.error : value; + return [ + error.name, + error.code, + error.message, + error.reason, + value.error_message, + value.relay_error_message, + value.result_code, + value.result_text, + value.note, + ] + .filter(Boolean) + .map(String) + .join(' '); +} diff --git a/src/core/runtime-health.mjs b/src/core/runtime-health.mjs index 1a72f74..2b13887 100644 --- a/src/core/runtime-health.mjs +++ b/src/core/runtime-health.mjs @@ -297,6 +297,74 @@ export function buildRuntimeAlert({ }; } +export function buildMakerCompetitivenessRuntimeAlerts({ + makerCompetitiveness, + minPairSamples = 5, + minQuoteNotFoundOrFinishedRate = 0.4, +} = {}) { + const groups = Array.isArray(makerCompetitiveness?.groups) ? makerCompetitiveness.groups : []; + const pairStats = new Map(); + + for (const group of groups) { + const pair = group.pair || null; + if (!pair) continue; + const current = pairStats.get(pair) || { + pair, + sample_count: 0, + quote_not_found_or_finished_count: 0, + accepted_count: 0, + policy_skip_count: 0, + worst_group: null, + }; + const count = Number(group.count || 0); + const staleFinishedCount = group.failure_category === 'quote_not_found_or_finished' + ? count + : 0; + current.sample_count += count; + current.quote_not_found_or_finished_count += staleFinishedCount; + current.accepted_count += Number(group.accepted_count || 0); + current.policy_skip_count += Number(group.policy_skip_count || 0); + if ( + staleFinishedCount > 0 + && (!current.worst_group || staleFinishedCount > Number(current.worst_group.count || 0)) + ) { + current.worst_group = group; + } + pairStats.set(pair, current); + } + + return [...pairStats.values()] + .filter((stats) => { + if (stats.sample_count < minPairSamples) return false; + if (stats.quote_not_found_or_finished_count <= 0) return false; + return stats.quote_not_found_or_finished_count / stats.sample_count >= minQuoteNotFoundOrFinishedRate; + }) + .map((stats) => { + const rate = stats.quote_not_found_or_finished_count / stats.sample_count; + return buildRuntimeAlert({ + alert_code: 'maker_quote_not_found_or_finished_rate_high', + severity: 'warning', + reason: `maker responses for ${stats.pair} are frequently reaching relay after the quote is finished`, + service_scope: 'strategy-engine', + pair: stats.pair, + details: { + generated_at: makerCompetitiveness?.generated_at || null, + sample_count: stats.sample_count, + quote_not_found_or_finished_count: stats.quote_not_found_or_finished_count, + quote_not_found_or_finished_rate: Number(rate.toFixed(4)), + accepted_count: stats.accepted_count, + policy_skip_count: stats.policy_skip_count, + min_pair_samples: minPairSamples, + min_quote_not_found_or_finished_rate: minQuoteNotFoundOrFinishedRate, + worst_direction: stats.worst_group?.direction || null, + worst_request_kind: stats.worst_group?.request_kind || null, + worst_quote_age_bucket: stats.worst_group?.quote_age_bucket || null, + worst_notional_bucket: stats.worst_group?.notional_bucket || null, + }, + }); + }); +} + export function shouldRaiseIngestPublishStale({ lastMatchingQuoteAt = null, lastPublishedAt = null, diff --git a/src/core/strategy.mjs b/src/core/strategy.mjs index fa975bb..9d781f3 100644 --- a/src/core/strategy.mjs +++ b/src/core/strategy.mjs @@ -12,6 +12,10 @@ import { classifyRouteDirection, resolveRouteRates, } from './route-rates.mjs'; +import { + extendMakerTiming, + quoteAgeMsAt, +} from './maker-timing.mjs'; export function evaluateTradeOpportunity({ demandEvent, @@ -19,6 +23,7 @@ export function evaluateTradeOpportunity({ inventoryEvent, config, now = Date.now(), + strategyReceivedAt = now, armed = false, thresholdPct = config.strategyGrossThresholdPct, maxNotional = config.strategyMaxNotional ?? config.strategyMaxNotionalEure, @@ -30,6 +35,10 @@ export function evaluateTradeOpportunity({ const legacyEureNotional = isLegacyEureNotional({ pairRuntime, config }); const decisionId = crypto.randomUUID(); const decisionAt = new Date(now).toISOString(); + const makerTiming = extendMakerTiming(payload.maker_timing, { + strategy_received_at: strategyReceivedAt, + strategy_decided_at: decisionAt, + }); const baseDecision = { decision_id: decisionId, decision_at: decisionAt, @@ -56,6 +65,8 @@ export function evaluateTradeOpportunity({ max_notional_eure: legacyEureNotional && effectiveMaxNotional != null ? String(effectiveMaxNotional) : null, + maker_timing: makerTiming, + quote_age_at_decision_ms: makerTiming.quote_age_at_decision_ms, strategy_armed: armed, assumptions: compact({ eure_per_eur: pairRuntime.priceRoute?.source === 'btc_eur_reference' ? '1' : null, @@ -68,6 +79,23 @@ export function evaluateTradeOpportunity({ return { decision: withReason(baseDecision, pairRuntime.reason) }; } + const responsePolicy = evaluateMakerResponseAgePolicy({ + strategyConfig: pairRuntime.strategyConfig, + makerTiming, + decidedAt: decisionAt, + }); + if (!responsePolicy.ok) { + return { + decision: { + ...baseDecision, + decision: 'blocked', + decision_reason: responsePolicy.reason, + response_policy: responsePolicy.policy, + quote_age_at_decision_ms: responsePolicy.policy.measured_quote_age_ms, + }, + }; + } + if (!priceEvent) { return { decision: withReason(baseDecision, 'no_reference_price') }; } @@ -128,6 +156,7 @@ export function evaluateTradeOpportunity({ inventory_freshness_ms: String(inventoryAgeMs), decision: armed ? 'actionable' : 'rejected', decision_reason: armed ? 'actionable' : 'strategy_disarmed', + response_policy: responsePolicy.policy, }; if (!armed) return { decision }; @@ -169,6 +198,9 @@ export function evaluateTradeOpportunity({ amount_out: payload.amount_out ?? null, request_kind: payload.request_kind, min_deadline_ms: payload.min_deadline_ms ?? '60000', + maker_timing: makerTiming, + quote_age_at_decision_ms: makerTiming.quote_age_at_decision_ms, + response_policy: responsePolicy.policy, quote_output: buildResult.quoteOutput, proposed_amount_in: buildResult.details.proposed_amount_in ?? null, proposed_amount_out: buildResult.details.proposed_amount_out ?? null, @@ -182,6 +214,53 @@ export function evaluateTradeOpportunity({ }; } +function evaluateMakerResponseAgePolicy({ + strategyConfig, + makerTiming, + decidedAt, +}) { + const enabled = strategyConfig?.makerMaxQuoteAgeEnabled === true; + const rawMaxAgeMs = strategyConfig?.makerMaxQuoteAgeMs; + const maxAgeMs = rawMaxAgeMs == null ? null : Number(rawMaxAgeMs); + const measuredQuoteAgeMs = quoteAgeMsAt(makerTiming, decidedAt); + const policy = { + enabled, + max_quote_age_ms: Number.isInteger(maxAgeMs) && maxAgeMs > 0 ? maxAgeMs : null, + measured_quote_age_ms: measuredQuoteAgeMs, + reason: strategyConfig?.makerLatencyPolicyReason || null, + pair_config_id: strategyConfig?.configId || strategyConfig?.config_id || null, + pair_config_version: strategyConfig?.version == null ? null : String(strategyConfig.version), + }; + + if (!enabled) return { ok: true, policy: { ...policy, valid: true } }; + + if (!Number.isInteger(maxAgeMs) || maxAgeMs <= 0) { + return { + ok: false, + reason: 'maker_quote_response_policy_invalid', + policy: { ...policy, valid: false }, + }; + } + + if (!Number.isFinite(measuredQuoteAgeMs)) { + return { + ok: false, + reason: 'maker_quote_age_unavailable', + policy: { ...policy, valid: true }, + }; + } + + if (measuredQuoteAgeMs > maxAgeMs) { + return { + ok: false, + reason: 'maker_quote_too_old', + policy: { ...policy, valid: true }, + }; + } + + return { ok: true, policy: { ...policy, valid: true } }; +} + function buildQuote({ demand, price, diff --git a/src/core/trading-config.mjs b/src/core/trading-config.mjs index 8e294ec..ffb39ab 100644 --- a/src/core/trading-config.mjs +++ b/src/core/trading-config.mjs @@ -25,6 +25,9 @@ export const CURRENT_REQUEST_MAX_SLIPPAGE_BPS = null; export const CURRENT_MIN_DEADLINE_MS = 60_000; export const CURRENT_PRICE_MAX_AGE_MS = 30_000; export const CURRENT_INVENTORY_MAX_AGE_MS = 30_000; +export const CURRENT_MAKER_MAX_QUOTE_AGE_ENABLED = false; +export const CURRENT_MAKER_MAX_QUOTE_AGE_MS = null; +export const CURRENT_MAKER_LATENCY_POLICY_REASON = null; export const PAIR_MODES = new Set(['observe_only', 'maker', 'taker', 'both']); export const PAIR_STATUSES = new Set(['disabled', 'observe_only', 'maker', 'taker', 'both']); @@ -190,6 +193,9 @@ export function buildSeedStrategyConfig(pairId, { requestDefaultNotional: CURRENT_REQUEST_DEFAULT_NOTIONAL_EURE, requestMaxNotional: CURRENT_REQUEST_MAX_NOTIONAL_EURE, requestMaxSlippageBps: CURRENT_REQUEST_MAX_SLIPPAGE_BPS, + makerMaxQuoteAgeEnabled: CURRENT_MAKER_MAX_QUOTE_AGE_ENABLED, + makerMaxQuoteAgeMs: CURRENT_MAKER_MAX_QUOTE_AGE_MS, + makerLatencyPolicyReason: CURRENT_MAKER_LATENCY_POLICY_REASON, createdBy, reason, }; diff --git a/src/lib/postgres.mjs b/src/lib/postgres.mjs index 500c753..a138255 100644 --- a/src/lib/postgres.mjs +++ b/src/lib/postgres.mjs @@ -378,12 +378,27 @@ export async function ensureTradingConfigSchema(pool) { request_default_notional NUMERIC, request_max_notional NUMERIC, request_max_slippage_bps INTEGER, + maker_max_quote_age_enabled BOOLEAN NOT NULL DEFAULT false, + maker_max_quote_age_ms INTEGER, + maker_latency_policy_reason TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_by TEXT NOT NULL, reason TEXT, UNIQUE (pair_id, version) ) `); + await pool.query(` + ALTER TABLE ${PAIR_STRATEGY_CONFIGS_TABLE} + ADD COLUMN IF NOT EXISTS maker_max_quote_age_enabled BOOLEAN NOT NULL DEFAULT false + `); + await pool.query(` + ALTER TABLE ${PAIR_STRATEGY_CONFIGS_TABLE} + ADD COLUMN IF NOT EXISTS maker_max_quote_age_ms INTEGER + `); + await pool.query(` + ALTER TABLE ${PAIR_STRATEGY_CONFIGS_TABLE} + ADD COLUMN IF NOT EXISTS maker_latency_policy_reason TEXT + `); await pool.query(` CREATE UNIQUE INDEX IF NOT EXISTS ${PAIR_STRATEGY_CONFIGS_TABLE}_active_one_idx ON ${PAIR_STRATEGY_CONFIGS_TABLE} (pair_id) @@ -723,6 +738,9 @@ export async function createPairStrategyConfigVersion(pool, { requestDefaultNotional = undefined, requestMaxNotional = undefined, requestMaxSlippageBps = undefined, + makerMaxQuoteAgeEnabled = undefined, + makerMaxQuoteAgeMs = undefined, + makerLatencyPolicyReason = undefined, changedBy = 'operator', reason = 'operator config update', } = {}) { @@ -775,9 +793,22 @@ export async function createPairStrategyConfigVersion(pool, { requestMaxSlippageBps === undefined ? active.request_max_slippage_bps == null ? null : Number(active.request_max_slippage_bps) : nullableNonNegativeInteger(requestMaxSlippageBps, 'request_max_slippage_bps'), + makerMaxQuoteAgeEnabled: + makerMaxQuoteAgeEnabled === undefined + ? active.maker_max_quote_age_enabled === true + : Boolean(makerMaxQuoteAgeEnabled), + makerMaxQuoteAgeMs: + makerMaxQuoteAgeMs === undefined + ? active.maker_max_quote_age_ms == null ? null : Number(active.maker_max_quote_age_ms) + : nullablePositiveInteger(makerMaxQuoteAgeMs, 'maker_max_quote_age_ms'), + makerLatencyPolicyReason: + makerLatencyPolicyReason === undefined + ? active.maker_latency_policy_reason || null + : nullableString(makerLatencyPolicyReason), createdBy: changedBy, reason, }; + validateMakerQuoteAgePolicy(nextConfig); await client.query( `UPDATE ${PAIR_STRATEGY_CONFIGS_TABLE} SET active = false WHERE pair_id = $1 AND active = true`, @@ -810,6 +841,9 @@ export async function createPairStrategyConfigVersion(pool, { request_default_notional: nextConfig.requestDefaultNotional, request_max_notional: nextConfig.requestMaxNotional, request_max_slippage_bps: nextConfig.requestMaxSlippageBps, + maker_max_quote_age_enabled: nextConfig.makerMaxQuoteAgeEnabled, + maker_max_quote_age_ms: nextConfig.makerMaxQuoteAgeMs, + maker_latency_policy_reason: nextConfig.makerLatencyPolicyReason, created_by: changedBy, reason, }); @@ -878,6 +912,9 @@ export async function setTradingPairMode(pool, { requestDefaultNotional = undefined, requestMaxNotional = undefined, requestMaxSlippageBps = undefined, + makerMaxQuoteAgeEnabled = undefined, + makerMaxQuoteAgeMs = undefined, + makerLatencyPolicyReason = undefined, changedBy = 'operator', reason = 'operator pair mode update', } = {}) { @@ -950,6 +987,9 @@ export async function setTradingPairMode(pool, { requestDefaultNotional, requestMaxNotional, requestMaxSlippageBps, + makerMaxQuoteAgeEnabled, + makerMaxQuoteAgeMs, + makerLatencyPolicyReason, changedBy, reason, }); @@ -969,6 +1009,9 @@ export async function setTradingPairMode(pool, { request_default_notional: nextConfig.requestDefaultNotional, request_max_notional: nextConfig.requestMaxNotional, request_max_slippage_bps: nextConfig.requestMaxSlippageBps, + maker_max_quote_age_enabled: nextConfig.makerMaxQuoteAgeEnabled, + maker_max_quote_age_ms: nextConfig.makerMaxQuoteAgeMs, + maker_latency_policy_reason: nextConfig.makerLatencyPolicyReason, created_by: changedBy, reason, }); @@ -1246,6 +1289,9 @@ export function summarizeTradingConfigSnapshot(snapshot) { strategy_config_version: pair.strategyConfig?.version || null, edge_bps: pair.strategyConfig?.edgeBps ?? null, max_notional: pair.strategyConfig?.maxNotional ?? null, + maker_max_quote_age_enabled: pair.strategyConfig?.makerMaxQuoteAgeEnabled ?? false, + maker_max_quote_age_ms: pair.strategyConfig?.makerMaxQuoteAgeMs ?? null, + maker_latency_policy_reason: pair.strategyConfig?.makerLatencyPolicyReason ?? null, price_route_id: pair.priceRoute?.routeId || null, })), }; @@ -1343,6 +1389,9 @@ function buildInitialPairStrategyConfig(pairId, { requestDefaultNotional = undefined, requestMaxNotional = undefined, requestMaxSlippageBps = undefined, + makerMaxQuoteAgeEnabled = undefined, + makerMaxQuoteAgeMs = undefined, + makerLatencyPolicyReason = undefined, changedBy = 'operator', reason = 'operator pair strategy config initialization', } = {}) { @@ -1351,7 +1400,7 @@ function buildInitialPairStrategyConfig(pairId, { reason, }); - return { + const next = { ...baseConfig, edgeBps: positiveIntegerOrDefault(edgeBps, baseConfig.edgeBps, 'edge_bps'), maxNotional: positiveNumberStringOrDefault(maxNotional, baseConfig.maxNotional, 'max_notional'), @@ -1379,7 +1428,21 @@ function buildInitialPairStrategyConfig(pairId, { baseConfig.requestMaxSlippageBps, 'request_max_slippage_bps', ), + makerMaxQuoteAgeEnabled: + makerMaxQuoteAgeEnabled === undefined + ? baseConfig.makerMaxQuoteAgeEnabled + : Boolean(makerMaxQuoteAgeEnabled), + makerMaxQuoteAgeMs: + makerMaxQuoteAgeMs === undefined + ? baseConfig.makerMaxQuoteAgeMs + : nullablePositiveInteger(makerMaxQuoteAgeMs, 'maker_max_quote_age_ms'), + makerLatencyPolicyReason: + makerLatencyPolicyReason === undefined + ? baseConfig.makerLatencyPolicyReason + : nullableString(makerLatencyPolicyReason), }; + validateMakerQuoteAgePolicy(next); + return next; } function hasConfigOverride(value) { @@ -1434,6 +1497,23 @@ function nullableNonNegativeInteger(value, field) { return nonNegativeIntegerOrDefault(value, 0, field); } +function nullablePositiveInteger(value, field) { + if (!hasConfigOverride(value)) return null; + return positiveIntegerOrDefault(value, 1, field); +} + +function nullableString(value) { + const normalized = String(value ?? '').trim(); + return normalized || null; +} + +function validateMakerQuoteAgePolicy(config) { + if (config.makerMaxQuoteAgeEnabled !== true) return; + if (!Number.isInteger(config.makerMaxQuoteAgeMs) || config.makerMaxQuoteAgeMs <= 0) { + throw new Error('maker_max_quote_age_ms must be a positive integer when maker quote age policy is enabled'); + } +} + function normalizeStrategyConfigRow(row) { if (!row) return null; return { @@ -1469,6 +1549,14 @@ function normalizeStrategyConfigRow(row) { row.request_max_slippage_bps == null ? null : Number(row.request_max_slippage_bps), request_max_slippage_bps: row.request_max_slippage_bps == null ? null : Number(row.request_max_slippage_bps), + makerMaxQuoteAgeEnabled: row.maker_max_quote_age_enabled === true, + maker_max_quote_age_enabled: row.maker_max_quote_age_enabled === true, + makerMaxQuoteAgeMs: + row.maker_max_quote_age_ms == null ? null : Number(row.maker_max_quote_age_ms), + maker_max_quote_age_ms: + row.maker_max_quote_age_ms == null ? null : Number(row.maker_max_quote_age_ms), + makerLatencyPolicyReason: row.maker_latency_policy_reason || null, + maker_latency_policy_reason: row.maker_latency_policy_reason || null, created_at: toIsoTimestamp(row.created_at), created_by: row.created_by || null, reason: row.reason || null, @@ -1690,9 +1778,12 @@ async function insertPairStrategyConfig(pool, { config, active = true }) { request_default_notional, request_max_notional, request_max_slippage_bps, + maker_max_quote_age_enabled, + maker_max_quote_age_ms, + maker_latency_policy_reason, created_by, reason - ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16) + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19) ON CONFLICT (config_id) DO NOTHING `, [ @@ -1710,6 +1801,9 @@ async function insertPairStrategyConfig(pool, { config, active = true }) { config.requestDefaultNotional, config.requestMaxNotional, config.requestMaxSlippageBps, + config.makerMaxQuoteAgeEnabled === true, + config.makerMaxQuoteAgeMs, + config.makerLatencyPolicyReason, config.createdBy, config.reason, ], @@ -3303,6 +3397,8 @@ function normalizeQuoteOutcomeRow(row) { eure_notional: payload.eure_notional || null, execution_result_status: row.execution_result_status || payload.execution_result_status || null, execution_result_code: row.execution_result_code || payload.execution_result_code || null, + failure_category: payload.failure_category || null, + maker_timing: payload.maker_timing || null, submitted_at: toIsoTimestamp(row.submitted_at || payload.submitted_at), command_at: toIsoTimestamp(row.command_at || payload.command_at), outcome_status: row.outcome_status || payload.outcome_status || null, @@ -3488,6 +3584,7 @@ function normalizeRecentQuoteRow(row) { amount_in: payload.amount_in ?? null, amount_out: payload.amount_out ?? null, min_deadline_ms: payload.min_deadline_ms ?? null, + maker_timing: payload.maker_timing || null, observed_at: toIsoTimestamp(row.observed_at), ingested_at: toIsoTimestamp(row.ingested_at), }; @@ -3525,6 +3622,8 @@ function normalizeSubmissionRow(row) { ingested_at: toIsoTimestamp(row.result_ingested_at), status: resultPayload.status || null, result_code: resultPayload.result_code || null, + failure_category: resultPayload.failure_category || null, + relay_error_message: resultPayload.relay_error_message || resultPayload.error?.message || null, outcome_status: outcomePayload.outcome_status || null, outcome_reason: outcomePayload.outcome_reason || null, attribution_status: outcomePayload.attribution_status || null, @@ -3539,6 +3638,11 @@ function normalizeSubmissionRow(row) { gross_edge_pct: decisionPayload.gross_edge_pct || null, decision_reason: decisionPayload.decision_reason || null, direction: decisionPayload.direction || null, + maker_timing: + resultPayload.maker_timing + || commandPayload.maker_timing + || decisionPayload.maker_timing + || null, }; } @@ -3560,6 +3664,8 @@ function normalizeExecuteTradeCommandRow(row) { asset_out: payload.asset_out || null, amount_in: resolveTradeAmount(payload, 'amount_in'), amount_out: resolveTradeAmount(payload, 'amount_out'), + maker_timing: payload.maker_timing || null, + quote_age_at_command_ms: payload.quote_age_at_command_ms ?? null, observed_at: toIsoTimestamp(row.observed_at || row.ingested_at), ingested_at: toIsoTimestamp(row.ingested_at), }; @@ -3597,6 +3703,7 @@ function normalizeExecutionResultRow(row) { result_at: toIsoTimestamp(row.result_observed_at || row.result_ingested_at), status: resultPayload.status || null, result_code: resultPayload.result_code || null, + failure_category: resultPayload.failure_category || null, outcome_status: outcomePayload.outcome_status || resultPayload.outcome_status @@ -3615,9 +3722,14 @@ function normalizeExecutionResultRow(row) { attributed_inventory_delta: outcomePayload.attributed_inventory_delta || null, outcome_payload: outcomePayload.quote_id ? outcomePayload : null, venue_response: resultPayload.venue_response || null, - error_message: resultPayload.error?.message || null, + error_message: resultPayload.relay_error_message || resultPayload.error?.message || null, note: resultPayload.note || null, timing: resultPayload.executor_timing || null, + maker_timing: + resultPayload.maker_timing + || commandPayload.maker_timing + || decisionPayload.maker_timing + || null, }; } diff --git a/src/operator-dashboard/static/pages/StrategyPage.jsx b/src/operator-dashboard/static/pages/StrategyPage.jsx index 81207dc..f4c63cc 100644 --- a/src/operator-dashboard/static/pages/StrategyPage.jsx +++ b/src/operator-dashboard/static/pages/StrategyPage.jsx @@ -40,6 +40,16 @@ function formatTimingMs(value) { return `${number < 10 ? number.toFixed(1) : number.toFixed(0)} ms`; } +function formatRate(value) { + const number = Number(value); + if (!Number.isFinite(number)) return 'Unavailable'; + return `${(number * 100).toFixed(1)}%`; +} + +function stageLabel(value) { + return plainCodeLabel(value).replace(/\bms\b/i, '').trim(); +} + function formatExecutionTiming(timing) { if (!timing) return null; const saltMs = formatTimingMs(timing.current_salt_ms); @@ -90,7 +100,8 @@ function formatTerms(terms) { function responseLabel(item) { if (RESPONDED_STATES.has(item.lifecycle_state)) return 'Yes'; if (item.lifecycle_state === 'failed') return 'Attempt failed'; - if (item.lifecycle_state === 'blocked') return 'No - executor blocked'; + if (item.lifecycle_state === 'blocked' && item.reason_code?.startsWith('maker_')) return 'No - policy skip'; + if (item.lifecycle_state === 'blocked') return 'No - blocked'; if (item.lifecycle_state === 'rejected') return 'No - strategy rejected'; if (item.lifecycle_state === 'command_emitted') return 'Pending executor'; if (item.lifecycle_state === 'evaluated') return 'Approved, not sent'; @@ -139,6 +150,55 @@ function StageCard({ title, at, status, children }) { ); } +function TimingWaterfall({ timing }) { + if (!timing) return null; + const rows = [ + ['Quote observed', timing.quote_observed_at], + ['Quote received', timing.quote_received_at], + ['Normalized', timing.quote_normalized_at], + ['Published', timing.quote_published_at], + ['Strategy received', timing.strategy_received_at], + ['Strategy decided', timing.strategy_decided_at, timing.quote_to_decision_ms], + ['Command published', timing.command_published_at, timing.decision_to_command_ms], + ['Executor received', timing.executor_received_at, timing.command_to_executor_ms], + ['Relay result', timing.relay_result_at, timing.executor_to_relay_result_ms], + ['Outcome observed', timing.outcome_observed_at, timing.quote_to_outcome_ms], + ]; + const hasEvidence = rows.some(([, at, duration]) => at || duration != null); + if (!hasEvidence) return null; + + return ( + + + + + + + + + + + + {rows.map(([label, at, duration]) => ( + + + + + + + ))} + +
StageTimestampStepFrom quote
{label}{formatTimestamp(at)}{formatTimingMs(duration) || 'Unavailable'} + {label === 'Strategy decided' ? formatTimingMs(timing.quote_age_at_decision_ms) + : label === 'Executor received' ? formatTimingMs(timing.quote_age_at_executor_receipt_ms) + : label === 'Relay result' ? formatTimingMs(timing.quote_age_at_relay_result_ms) + : label === 'Outcome observed' ? formatTimingMs(timing.quote_to_outcome_ms) + : 'Unavailable'} +
+
+ ); +} + function LifecycleDetails({ item }) { const executionTiming = formatExecutionTiming(item.execution?.timing); @@ -183,10 +243,222 @@ function LifecycleDetails({ item }) { + + ); } +function pairDisplayLabel(pairId, pairConfig) { + const pair = (pairConfig?.pairs || []).find((entry) => ( + (entry.pair_id || entry.pairId || entry.pair) === pairId + )); + if (!pair) return truncateMiddle(pairId || 'Unknown pair', 42); + return `${pair.asset_in_symbol || pair.asset_in || pair.assetIn} -> ${pair.asset_out_symbol || pair.asset_out || pair.assetOut}`; +} + +function MakerCompetitivenessSection({ summary, pairConfig }) { + const total = summary?.total || {}; + const groups = summary?.groups || []; + const ageBuckets = summary?.age_buckets || []; + const latestErrors = summary?.latest_errors || []; + const policySkips = summary?.policy_skips || []; + + return ( +
+
+
+
Maker competitiveness
+

Response timing and quote-age outcomes

+
+ Pair-native response evidence from durable quote, decision, command, executor result, and outcome rows. +
+
+
+ + 0 ? 'warning' : 'unknown'} /> +
+
+
+ + + + +
+ + + + + + + + + + + + + + + + + {groups.length ? groups.slice(0, 12).map((group, index) => { + const quoteToRelay = group.latency_stages?.find((stage) => stage.stage === 'quote_to_relay_result_ms'); + return ( + + + + + + + + + + + ); + }) : ( + + )} + +
PairDirectionRequestResultAge / notionalOutcomeCountsQuote to relay
+
{pairDisplayLabel(group.pair, pairConfig)}
+
{truncateMiddle(group.pair || '', 42)}
+
{plainCodeLabel(group.direction)}{plainCodeLabel(group.request_kind)} +
{plainCodeLabel(group.result_code)}
+ {group.failure_category ?
{plainCodeLabel(group.failure_category)}
: null} +
+
{group.quote_age_bucket}
+
{group.notional_bucket}
+
{plainCodeLabel(group.outcome_status)} +
{group.count}
+
{`${group.accepted_count || 0} accepted / ${group.policy_skip_count || 0} skipped`}
+
+
{formatTimingMs(quoteToRelay?.p50_ms) || 'Unavailable'}
+
{`p90 ${formatTimingMs(quoteToRelay?.p90_ms) || 'Unavailable'}`}
+
No competitiveness rows are available yet.
+
+ +
+ + + + + + + + + + + + {(summary?.latency_stages || []).length ? summary.latency_stages.map((stage) => ( + + + + + + + )) : ( + + )} + +
Latency stagep50p90p99
{stageLabel(stage.stage)}{formatTimingMs(stage.p50_ms)}{formatTimingMs(stage.p90_ms)}{formatTimingMs(stage.p99_ms)}
No stage timing percentiles are available yet.
+
+ + + + + + + + + + + + + {ageBuckets.length ? ageBuckets.slice(0, 12).map((bucket, index) => ( + + + + + + + )) : ( + + )} + +
Age bucketOutcomeCountAccepted
+
{bucket.quote_age_bucket}
+
{pairDisplayLabel(bucket.pair, pairConfig)}
+
{plainCodeLabel(bucket.outcome_status)}{bucket.count}{bucket.accepted_count || 0}
No quote-age buckets are available yet.
+
+
+ +
+ + + + + + + + + + + {latestErrors.length ? latestErrors.map((error) => ( + + + + + + )) : ( + + )} + +
Latest relay errorsQuote ageError
+ +
{pairDisplayLabel(error.pair, pairConfig)}
+
{plainCodeLabel(error.failure_category || error.result_code)}
+
+
{formatTimingMs(error.quote_age_ms) || 'Unavailable'}
+
{error.quote_age_bucket}
+
{error.error_message || 'Error text unavailable'}
No relay errors are available yet.
+
+ + + + + + + + + + + + {policySkips.length ? policySkips.map((skip) => ( + + + + + + )) : ( + + )} + +
Policy skipsAgeConfig
+ +
{plainCodeLabel(skip.reason_code)}
+
+
{formatTimingMs(skip.quote_age_ms) || 'Unavailable'}
+
{`max ${formatTimingMs(skip.max_quote_age_ms) || 'Unavailable'}`}
+
+
{skip.pair_config_version ? `v${skip.pair_config_version}` : 'Version unavailable'}
+
{truncateMiddle(skip.pair_config_id || '', 36)}
+
No policy skips are available yet.
+
+
+
+ ); +} + function QuoteLifecycleTable({ items }) { const [expanded, setExpanded] = useState(() => new Set()); const [showStrategyRejected, setShowStrategyRejected] = useState(true); @@ -504,6 +776,8 @@ function PairConfigSection({ assetCatalog, pairConfig, onControl }) { }); const [edgeDrafts, setEdgeDrafts] = useState({}); const [maxNotionalDrafts, setMaxNotionalDrafts] = useState({}); + const [policyEnabledDrafts, setPolicyEnabledDrafts] = useState({}); + const [maxQuoteAgeDrafts, setMaxQuoteAgeDrafts] = useState({}); useEffect(() => { if (!assets.length) return; @@ -527,6 +801,20 @@ function PairConfigSection({ assetCatalog, pairConfig, onControl }) { const tradingMode = TRADING_PAIR_MODES.has(pair.mode); return [pairId, String(strategyConfig.max_notional ?? pair.max_notional ?? (tradingMode ? '150' : ''))]; }))); + setPolicyEnabledDrafts(Object.fromEntries(pairs.map((pair) => { + const pairId = pair.pair_id || pair.pairId; + const strategyConfig = pair.strategyConfig || pair.strategy_config || {}; + return [pairId, Boolean( + strategyConfig.maker_max_quote_age_enabled ?? strategyConfig.makerMaxQuoteAgeEnabled, + )]; + }))); + setMaxQuoteAgeDrafts(Object.fromEntries(pairs.map((pair) => { + const pairId = pair.pair_id || pair.pairId; + const strategyConfig = pair.strategyConfig || pair.strategy_config || {}; + return [pairId, String( + strategyConfig.maker_max_quote_age_ms ?? strategyConfig.makerMaxQuoteAgeMs ?? '', + )]; + }))); }, [pairs]); async function updatePairConfig(pair) { @@ -535,7 +823,10 @@ function PairConfigSection({ assetCatalog, pairConfig, onControl }) { const hasStrategyConfig = Boolean(strategyConfig.config_id || strategyConfig.configId); const edgeBps = edgeDrafts[pairId]; const maxNotional = maxNotionalDrafts[pairId]; + const policyEnabled = policyEnabledDrafts[pairId] === true; + const maxQuoteAgeMs = maxQuoteAgeDrafts[pairId]; if (!edgeBps || !maxNotional) return; + if (policyEnabled && !maxQuoteAgeMs) return; if (!hasStrategyConfig) { const mode = pair.mode || pair.status || 'observe_only'; @@ -550,6 +841,9 @@ function PairConfigSection({ assetCatalog, pairConfig, onControl }) { mode, edge_bps: Number(edgeBps), max_notional: maxNotional, + maker_max_quote_age_enabled: policyEnabled, + maker_max_quote_age_ms: policyEnabled ? Number(maxQuoteAgeMs) : null, + maker_latency_policy_reason: policyEnabled ? 'operator dashboard maker response-age policy' : null, }); return; } @@ -558,6 +852,9 @@ function PairConfigSection({ assetCatalog, pairConfig, onControl }) { pair_id: pairId, edge_bps: Number(edgeBps), max_notional: maxNotional, + maker_max_quote_age_enabled: policyEnabled, + maker_max_quote_age_ms: policyEnabled ? Number(maxQuoteAgeMs) : null, + maker_latency_policy_reason: policyEnabled ? 'operator dashboard maker response-age policy' : null, }); } @@ -714,8 +1011,10 @@ function PairConfigSection({ assetCatalog, pairConfig, onControl }) { const route = pair.priceRoute || pair.price_route || {}; const hasStrategyConfig = Boolean(strategyConfig.config_id || strategyConfig.configId); const tradingMode = TRADING_PAIR_MODES.has(pair.mode); + const policyEnabled = policyEnabledDrafts[pairId] === true; const configButtonDisabled = !edgeDrafts[pairId] || !maxNotionalDrafts[pairId] + || (policyEnabled && !maxQuoteAgeDrafts[pairId]) || (!hasStrategyConfig && !tradingMode); return ( @@ -738,6 +1037,11 @@ function PairConfigSection({ assetCatalog, pairConfig, onControl }) { : `${strategyConfig.request_max_slippage_bps} bps slippage max`}
{strategyConfig.price_max_age_ms || 'Unavailable'} ms price max age
+
+ {strategyConfig.maker_max_quote_age_enabled || strategyConfig.makerMaxQuoteAgeEnabled + ? `${strategyConfig.maker_max_quote_age_ms ?? strategyConfig.makerMaxQuoteAgeMs} ms response max age` + : 'Response age policy disabled'} +
{route.source || 'Unavailable'} {pair.blockReason || pair.block_reason || 'No'} @@ -783,6 +1087,36 @@ function PairConfigSection({ assetCatalog, pairConfig, onControl }) { {hasStrategyConfig ? 'Save' : 'Init'} +
+ +
+
+ Max age + setMaxQuoteAgeDrafts((current) => ({ + ...current, + [pairId]: event.target.value, + }))} + step="1" + style={{ maxWidth: 112 }} + type="number" + value={maxQuoteAgeDrafts[pairId] ?? ''} + /> +
@@ -843,6 +1177,11 @@ export default function StrategyPage({ strategy, onControl }) { + +
diff --git a/src/operator-dashboard/static/state/dashboardReducer.js b/src/operator-dashboard/static/state/dashboardReducer.js index 2e87266..a7189ea 100644 --- a/src/operator-dashboard/static/state/dashboardReducer.js +++ b/src/operator-dashboard/static/state/dashboardReducer.js @@ -16,6 +16,9 @@ function applySocketMessage(dashboard, payload, session) { strategy_state: { ...dashboard.strategy.strategy_state, recent_lifecycle_rows: payload.live.recent_lifecycle_rows, + maker_competitiveness: + payload.live.maker_competitiveness + || dashboard.strategy.strategy_state.maker_competitiveness, }, } : dashboard.strategy, status_bar: { @@ -47,6 +50,9 @@ function applySocketMessage(dashboard, payload, session) { recent_lifecycle_rows: payload.recent_lifecycle_rows || dashboard.strategy.strategy_state.recent_lifecycle_rows, + maker_competitiveness: + payload.maker_competitiveness + || dashboard.strategy.strategy_state.maker_competitiveness, }, }, }, diff --git a/src/operator-dashboard/static/styles.css b/src/operator-dashboard/static/styles.css index c4e975d..c05478e 100644 --- a/src/operator-dashboard/static/styles.css +++ b/src/operator-dashboard/static/styles.css @@ -265,6 +265,24 @@ select { height: 100%; } +.two-column-grid { + display: grid; + gap: 14px; + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); + margin-top: 14px; +} + +.checkbox-row { + display: inline-flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.timing-waterfall-table { + min-width: 720px; +} + .table-wrap { overflow-x: auto; border: 1px solid var(--line); @@ -506,7 +524,8 @@ table.lifecycle-table th:nth-child(5) { @media (max-width: 1100px) { .app-grid, .split, - .strategy-layout { + .strategy-layout, + .two-column-grid { grid-template-columns: 1fr; } diff --git a/src/venues/near-intents/normalize.mjs b/src/venues/near-intents/normalize.mjs index cd29cb6..6321f5f 100644 --- a/src/venues/near-intents/normalize.mjs +++ b/src/venues/near-intents/normalize.mjs @@ -1,9 +1,14 @@ import { buildEventEnvelope } from '../../core/event-envelope.mjs'; +import { buildInitialMakerTiming } from '../../core/maker-timing.mjs'; -export function buildNearIntentsRawEnvelope(message, { ingestedAt = new Date() } = {}) { +export function buildNearIntentsRawEnvelope(message, { ingestedAt = new Date(), receivedAt = ingestedAt } = {}) { const raw = isRecord(message) ? message : {}; const quoteId = first(raw, ['quote_id', 'quoteRequestId', 'request_id', 'id', 'quote_hash']); const occurredAt = first(raw, ['created_at', 'createdAt', 'timestamp', 'ts']); + const makerTiming = buildInitialMakerTiming({ + quoteObservedAt: occurredAt, + quoteReceivedAt: receivedAt, + }); return buildEventEnvelope({ source: 'near-intents.ws', @@ -12,17 +17,27 @@ export function buildNearIntentsRawEnvelope(message, { ingestedAt = new Date() } eventId: quoteId || `near-intents-raw-${ingestedAt.getTime()}`, observedAt: occurredAt, ingestedAt, - payload: { message: raw }, + payload: { message: raw, maker_timing: makerTiming }, raw, }); } -export function buildNearIntentsQuoteEnvelope(message, { ingestedAt = new Date() } = {}) { +export function buildNearIntentsQuoteEnvelope(message, { + ingestedAt = new Date(), + receivedAt = ingestedAt, + normalizedAt = new Date(), + publishedAt = null, +} = {}) { const raw = isRecord(message) ? message : {}; - const payload = normalizeNearIntentsQuote(raw); - if (!payload) return null; - const occurredAt = first(raw, ['created_at', 'createdAt', 'timestamp', 'ts']); + const makerTiming = buildInitialMakerTiming({ + quoteObservedAt: occurredAt, + quoteReceivedAt: receivedAt, + quoteNormalizedAt: normalizedAt, + quotePublishedAt: publishedAt, + }); + const payload = normalizeNearIntentsQuote(raw, { makerTiming }); + if (!payload) return null; return buildEventEnvelope({ source: 'near-intents.ws', @@ -36,7 +51,7 @@ export function buildNearIntentsQuoteEnvelope(message, { ingestedAt = new Date() }); } -export function normalizeNearIntentsQuote(message) { +export function normalizeNearIntentsQuote(message, { makerTiming = null } = {}) { const quoteId = first(message, ['quote_id', 'quoteRequestId', 'request_id', 'id']); const assetIn = first(message, ['defuse_asset_identifier_in', 'sellToken', 'asset_in']); const assetOut = first(message, ['defuse_asset_identifier_out', 'buyToken', 'asset_out']); @@ -56,6 +71,7 @@ export function normalizeNearIntentsQuote(message) { amount_in: amountIn, amount_out: amountOut, min_deadline_ms: stringify(first(message, ['min_deadline_ms', 'ttl_ms', 'deadline_ms'])), + maker_timing: makerTiming, }; } diff --git a/src/venues/near-intents/ws.mjs b/src/venues/near-intents/ws.mjs index fb2991a..70e37a6 100644 --- a/src/venues/near-intents/ws.mjs +++ b/src/venues/near-intents/ws.mjs @@ -1,5 +1,6 @@ import { matchesPairFilter } from '../../core/pair-filter.mjs'; import { serializeError } from '../../core/log.mjs'; +import { extendMakerTiming } from '../../core/maker-timing.mjs'; import { assertNormalizedSwapDemand } from '../../core/schemas.mjs'; import { buildNearIntentsQuoteEnvelope, buildNearIntentsRawEnvelope } from './normalize.mjs'; @@ -73,7 +74,8 @@ export async function startNearIntentsWs({ ws.addEventListener('message', async (event) => { if (activeSocket !== ws) return; framesReceived += 1; - lastMessageAt = new Date().toISOString(); + const frameReceivedAt = new Date(); + lastMessageAt = frameReceivedAt.toISOString(); const text = typeof event.data === 'string' ? event.data : Buffer.from(event.data).toString('utf8'); let payload; @@ -114,8 +116,14 @@ export async function startNearIntentsWs({ let assetOut = null; let publishTopic = rawTopic; try { - envelope = buildNearIntentsQuoteEnvelope(merged); - rawEnvelope = buildNearIntentsRawEnvelope(merged); + envelope = buildNearIntentsQuoteEnvelope(merged, { + ingestedAt: frameReceivedAt, + receivedAt: frameReceivedAt, + }); + rawEnvelope = buildNearIntentsRawEnvelope(merged, { + ingestedAt: frameReceivedAt, + receivedAt: frameReceivedAt, + }); await producer.sendJson(rawTopic, rawEnvelope, { key: rawEnvelope.event_id }); rawPublishedCount += 1; @@ -136,6 +144,9 @@ export async function startNearIntentsWs({ lastMatchingQuoteAt = new Date().toISOString(); publishTopic = normalizedTopic; + envelope.payload.maker_timing = extendMakerTiming(envelope.payload.maker_timing, { + quote_published_at: new Date(), + }); await producer.sendJson(normalizedTopic, envelope, { key: envelope.payload.quote_id }); publishedCount += 1; lastPublishedAt = new Date().toISOString(); diff --git a/test/maker-timing-competitiveness.test.mjs b/test/maker-timing-competitiveness.test.mjs new file mode 100644 index 0000000..e9155c1 --- /dev/null +++ b/test/maker-timing-competitiveness.test.mjs @@ -0,0 +1,161 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { + buildInitialMakerTiming, + extendMakerTiming, + quoteAgeMsAt, +} from '../src/core/maker-timing.mjs'; +import { buildMakerCompetitivenessSummary } from '../src/core/maker-competitiveness.mjs'; +import { classifyRelaySubmissionFailure } from '../src/core/relay-failure-classification.mjs'; + +test('maker timing computes waterfall fields and marks clock skew unavailable', () => { + const initial = buildInitialMakerTiming({ + quoteReceivedAt: '2026-05-18T10:00:00.000Z', + quoteNormalizedAt: '2026-05-18T10:00:00.010Z', + quotePublishedAt: '2026-05-18T10:00:00.020Z', + }); + const timing = extendMakerTiming(initial, { + strategy_received_at: '2026-05-18T10:00:00.030Z', + strategy_decided_at: '2026-05-18T10:00:00.040Z', + command_published_at: '2026-05-18T10:00:00.050Z', + executor_received_at: '2026-05-18T10:00:00.075Z', + relay_result_at: '2026-05-18T10:00:00.140Z', + outcome_observed_at: '2026-05-18T10:00:02.000Z', + }); + + assert.equal(timing.quote_to_decision_ms, 40); + assert.equal(timing.decision_to_command_ms, 10); + assert.equal(timing.command_to_executor_ms, 25); + assert.equal(timing.executor_to_relay_result_ms, 65); + assert.equal(timing.quote_to_relay_result_ms, 140); + assert.equal(timing.quote_to_outcome_ms, 2000); + assert.equal(quoteAgeMsAt(timing, '2026-05-18T10:00:00.050Z'), 50); + + const skewed = extendMakerTiming(initial, { + strategy_decided_at: '2026-05-18T09:59:59.999Z', + }); + assert.equal(skewed.quote_to_decision_ms, null); + assert.equal(skewed.unavailable_reasons.quote_to_decision_ms, 'clock_skew_or_negative_duration'); +}); + +test('relay submission failure classifier preserves specific already-finished category', () => { + assert.equal( + classifyRelaySubmissionFailure(new Error('quote not found or already finished')), + 'quote_not_found_or_finished', + ); + assert.equal( + classifyRelaySubmissionFailure({ error_message: 'quote not found or already finished' }), + 'quote_not_found_or_finished', + ); + assert.equal( + classifyRelaySubmissionFailure(new Error('quote_response timed out')), + 'relay_timeout', + ); + assert.equal( + classifyRelaySubmissionFailure(new Error('Socket not connected')), + 'relay_disconnected', + ); + assert.equal( + classifyRelaySubmissionFailure(new Error('current_salt unavailable')), + 'salt_unavailable', + ); +}); + +test('maker competitiveness aggregates pair, direction, request kind, result, failure, age, notional, and outcome', () => { + const nbtc = 'nep141:nbtc.bridge.near'; + const eure = 'nep141:eure.omft.near'; + const usdc = 'nep141:eth-0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48.omft.near'; + const baseTiming = buildInitialMakerTiming({ + quoteReceivedAt: '2026-05-18T10:00:00.000Z', + }); + const summary = buildMakerCompetitivenessSummary({ + generatedAt: '2026-05-18T10:01:00.000Z', + lifecycleRows: [ + { + quote_id: 'quote-eure-ok', + pair: `${nbtc}->${eure}`, + direction: 'base_to_quote', + request_kind: 'exact_in', + notional: '5', + notional_symbol: 'EURe', + outcome_status: 'submitted', + execution_result_at: '2026-05-18T10:00:00.080Z', + maker_timing: extendMakerTiming(baseTiming, { + strategy_decided_at: '2026-05-18T10:00:00.010Z', + command_published_at: '2026-05-18T10:00:00.020Z', + executor_received_at: '2026-05-18T10:00:00.030Z', + relay_result_at: '2026-05-18T10:00:00.080Z', + }), + execution: { + status: 'submitted', + result_code: 'quote_response_ok', + timing: { current_salt_source: 'cache' }, + }, + }, + { + quote_id: 'quote-usdc-failed', + pair: `${nbtc}->${usdc}`, + direction: 'base_to_quote', + request_kind: 'exact_in', + notional: '8', + notional_symbol: 'USDC', + execution_result_at: '2026-05-18T10:00:00.400Z', + maker_timing: extendMakerTiming(baseTiming, { + strategy_decided_at: '2026-05-18T10:00:00.100Z', + command_published_at: '2026-05-18T10:00:00.150Z', + executor_received_at: '2026-05-18T10:00:00.250Z', + relay_result_at: '2026-05-18T10:00:00.400Z', + }), + execution: { + status: 'failed', + result_code: 'submission_failed', + failure_category: 'quote_not_found_or_finished', + error_message: 'quote not found or already finished', + timing: { current_salt_source: 'cache' }, + }, + }, + { + quote_id: 'quote-usdc-skip', + pair: `${nbtc}->${usdc}`, + direction: 'base_to_quote', + request_kind: 'exact_in', + notional: '12', + notional_symbol: 'USDC', + maker_timing: extendMakerTiming(baseTiming, { + strategy_decided_at: '2026-05-18T10:00:00.600Z', + }), + decision: { + decision: 'blocked', + decision_reason: 'maker_quote_too_old', + response_policy: { + measured_quote_age_ms: 600, + max_quote_age_ms: 250, + }, + }, + }, + ], + }); + + assert.equal(summary.total.count, 3); + assert.equal(summary.total.accepted_count, 1); + assert.equal(summary.total.relay_failed_count, 1); + assert.equal(summary.total.policy_skip_count, 1); + assert.equal(summary.total.quote_not_found_or_finished_count, 1); + assert.ok(summary.groups.some((group) => ( + group.pair === `${nbtc}->${usdc}` + && group.request_kind === 'exact_in' + && group.result_code === 'submission_failed' + && group.failure_category === 'quote_not_found_or_finished' + && group.quote_age_bucket === '250-500ms' + && group.notional_bucket === '5-25 USDC' + && group.outcome_status === 'relay_failed' + ))); + assert.ok(summary.age_buckets.some((bucket) => ( + bucket.pair === `${nbtc}->${usdc}` + && bucket.quote_age_bucket === '500-1000ms' + && bucket.outcome_status === 'policy_skip' + ))); + assert.equal(summary.latest_errors[0].error_message, 'quote not found or already finished'); + assert.equal(summary.policy_skips[0].reason_code, 'maker_quote_too_old'); +}); diff --git a/test/operator-dashboard-ui-static.test.mjs b/test/operator-dashboard-ui-static.test.mjs index af5e7b4..5b3d2e9 100644 --- a/test/operator-dashboard-ui-static.test.mjs +++ b/test/operator-dashboard-ui-static.test.mjs @@ -111,11 +111,26 @@ test('strategy page exposes pair activation, pause, edge, and deposit address co assert.match(strategySource, /pair-max-notional/); assert.match(strategySource, /Edge bps for/); assert.match(strategySource, /Max notional for/); + assert.match(strategySource, /maker_max_quote_age_enabled/); + assert.match(strategySource, /maker_max_quote_age_ms/); + assert.match(strategySource, /Age policy/); + assert.match(strategySource, /Max age/); assert.match(strategySource, /Init/); assert.match(strategySource, /deposit_address/); assert.match(strategySource, /Copy/); }); +test('strategy page exposes maker timing waterfall and competitiveness summaries', () => { + assert.match(strategySource, /Maker competitiveness/); + assert.match(strategySource, /TimingWaterfall/); + assert.match(strategySource, /quote_to_relay_result_ms/); + assert.match(strategySource, /quote_not_found_or_finished/); + assert.match(strategySource, /maker_competitiveness/); + assert.match(strategySource, /pairDisplayLabel/); + assert.match(stylesSource, /\.two-column-grid/); + assert.match(stylesSource, /\.timing-waterfall-table/); +}); + test('pair controls are rendered before the long asset catalog table', () => { assert.ok( strategySource.indexOf(' { + assert.match(source, /buildMakerCompetitivenessRuntimeAlerts/); + assert.match(source, /latest_maker_competitiveness/); +}); diff --git a/test/runtime-health.test.mjs b/test/runtime-health.test.mjs index 3fc6e85..2ec1497 100644 --- a/test/runtime-health.test.mjs +++ b/test/runtime-health.test.mjs @@ -2,6 +2,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { + buildMakerCompetitivenessRuntimeAlerts, deriveServiceHealth, shouldContainExecutorForAlerts, shouldRaiseIngestPublishStale, @@ -77,3 +78,48 @@ test('armed service treats critical truth alerts for any active pair as critical assert.equal(health.status, 'critical'); assert.equal(health.label, 'armed on stale truth'); }); + +test('maker competitiveness alerts are pair-scoped for high quote-finished relay failure rate', () => { + const alerts = buildMakerCompetitivenessRuntimeAlerts({ + makerCompetitiveness: { + generated_at: '2026-05-18T10:00:00.000Z', + groups: [ + { + pair: 'nbtc->usdc', + direction: 'base_to_quote', + request_kind: 'exact_in', + result_code: 'submission_failed', + failure_category: 'quote_not_found_or_finished', + quote_age_bucket: '100-250ms', + notional_bucket: '5-10', + count: 4, + }, + { + pair: 'nbtc->usdc', + direction: 'base_to_quote', + request_kind: 'exact_in', + result_code: 'quote_response_ok', + failure_category: null, + quote_age_bucket: '<100ms', + notional_bucket: '5-10', + count: 1, + accepted_count: 1, + }, + { + pair: 'nbtc->eure', + result_code: 'quote_response_ok', + failure_category: null, + count: 8, + accepted_count: 8, + }, + ], + }, + }); + + assert.equal(alerts.length, 1); + assert.equal(alerts[0].alert_code, 'maker_quote_not_found_or_finished_rate_high'); + assert.equal(alerts[0].service_scope, 'strategy-engine'); + assert.equal(alerts[0].pair, 'nbtc->usdc'); + assert.equal(alerts[0].details.quote_not_found_or_finished_count, 4); + assert.equal(alerts[0].details.quote_not_found_or_finished_rate, 0.8); +}); diff --git a/test/strategy-engine-static.test.mjs b/test/strategy-engine-static.test.mjs index 66703d9..2803c03 100644 --- a/test/strategy-engine-static.test.mjs +++ b/test/strategy-engine-static.test.mjs @@ -11,9 +11,12 @@ test('strategy duplicate quote tracking is bounded and state-safe', () => { assert.doesNotMatch(source, /seen_quotes:\s*\{\}/); }); -test('strategy execute commands use decision timestamp as durable observed time', () => { +test('strategy execute commands stamp command publish time into durable observed time', () => { assert.match( source, - /observedAt:\s*evaluation\.command\.decision_at\s*\|\|\s*event\.observed_at\s*\|\|\s*event\.ingested_at/, + /const commandPublishedAt = new Date\(\)\.toISOString\(\)/, ); + assert.match(source, /command_published_at: commandPublishedAt/); + assert.match(source, /quote_age_at_command_ms: makerTiming\.quote_age_at_command_ms/); + assert.match(source, /observedAt:\s*commandPublishedAt/); }); diff --git a/test/strategy.test.mjs b/test/strategy.test.mjs index 45f6f8a..dd4a68b 100644 --- a/test/strategy.test.mjs +++ b/test/strategy.test.mjs @@ -241,7 +241,146 @@ test('strategy blocks BTC -> USDC when price event lacks USDC route fields', () assert.equal(result.command, undefined); }); -function makeBtcUsdcDbConfig() { +test('maker response age policy skips stale BTC -> USDC quotes without emitting a relay command', () => { + const config = makeBtcUsdcDbConfig({ + strategyConfigOverrides: { + makerMaxQuoteAgeEnabled: true, + makerMaxQuoteAgeMs: 100, + makerLatencyPolicyReason: 'test stale response protection', + }, + }); + const result = evaluateTradeOpportunity({ + demandEvent: { + payload: { + quote_id: 'quote-usdc-too-old', + pair: config.activePair, + asset_in: config.tradingBtc.assetId, + asset_out: config.tradingUsdc.assetId, + request_kind: 'exact_in', + amount_in: '10000', + maker_timing: { + quote_received_at: '2026-04-02T10:00:04.800Z', + }, + }, + }, + priceEvent: makeBtcUsdcPriceEvent(), + inventoryEvent: makeBtcUsdcInventoryEvent(), + config, + armed: true, + strategyReceivedAt: '2026-04-02T10:00:05.000Z', + now: Date.parse('2026-04-02T10:00:05.000Z'), + }); + + assert.equal(result.decision.decision, 'blocked'); + assert.equal(result.decision.decision_reason, 'maker_quote_too_old'); + assert.equal(result.decision.quote_age_at_decision_ms, 200); + assert.equal(result.decision.response_policy.enabled, true); + assert.equal(result.decision.response_policy.max_quote_age_ms, 100); + assert.equal(result.command, undefined); +}); + +test('maker response age policy skips when quote timing prerequisites are missing', () => { + const config = makeBtcUsdcDbConfig({ + strategyConfigOverrides: { + makerMaxQuoteAgeEnabled: true, + makerMaxQuoteAgeMs: 100, + }, + }); + const result = evaluateTradeOpportunity({ + demandEvent: { + payload: { + quote_id: 'quote-usdc-no-timing', + pair: config.activePair, + asset_in: config.tradingBtc.assetId, + asset_out: config.tradingUsdc.assetId, + request_kind: 'exact_in', + amount_in: '10000', + }, + }, + priceEvent: makeBtcUsdcPriceEvent(), + inventoryEvent: makeBtcUsdcInventoryEvent(), + config, + armed: true, + strategyReceivedAt: '2026-04-02T10:00:05.000Z', + now: Date.parse('2026-04-02T10:00:05.000Z'), + }); + + assert.equal(result.decision.decision, 'blocked'); + assert.equal(result.decision.decision_reason, 'maker_quote_age_unavailable'); + assert.equal(result.decision.response_policy.valid, true); + assert.equal(result.command, undefined); +}); + +test('maker response age policy skips invalid enabled config', () => { + const config = makeBtcUsdcDbConfig({ + strategyConfigOverrides: { + makerMaxQuoteAgeEnabled: true, + makerMaxQuoteAgeMs: null, + }, + }); + const result = evaluateTradeOpportunity({ + demandEvent: { + payload: { + quote_id: 'quote-usdc-invalid-policy', + pair: config.activePair, + asset_in: config.tradingBtc.assetId, + asset_out: config.tradingUsdc.assetId, + request_kind: 'exact_in', + amount_in: '10000', + maker_timing: { + quote_received_at: '2026-04-02T10:00:04.950Z', + }, + }, + }, + priceEvent: makeBtcUsdcPriceEvent(), + inventoryEvent: makeBtcUsdcInventoryEvent(), + config, + armed: true, + strategyReceivedAt: '2026-04-02T10:00:05.000Z', + now: Date.parse('2026-04-02T10:00:05.000Z'), + }); + + assert.equal(result.decision.decision, 'blocked'); + assert.equal(result.decision.decision_reason, 'maker_quote_response_policy_invalid'); + assert.equal(result.decision.response_policy.valid, false); + assert.equal(result.command, undefined); +}); + +test('disabled maker response age policy preserves current BTC -> USDC actionability', () => { + const config = makeBtcUsdcDbConfig({ + strategyConfigOverrides: { + makerMaxQuoteAgeEnabled: false, + makerMaxQuoteAgeMs: null, + }, + }); + const result = evaluateTradeOpportunity({ + demandEvent: { + payload: { + quote_id: 'quote-usdc-policy-disabled', + pair: config.activePair, + asset_in: config.tradingBtc.assetId, + asset_out: config.tradingUsdc.assetId, + request_kind: 'exact_in', + amount_in: '10000', + maker_timing: { + quote_received_at: '2026-04-02T09:59:59.000Z', + }, + }, + }, + priceEvent: makeBtcUsdcPriceEvent(), + inventoryEvent: makeBtcUsdcInventoryEvent(), + config, + armed: true, + strategyReceivedAt: '2026-04-02T10:00:05.000Z', + now: Date.parse('2026-04-02T10:00:05.000Z'), + }); + + assert.equal(result.decision.decision, 'actionable'); + assert.equal(result.decision.response_policy.enabled, false); + assert.ok(result.command); +}); + +function makeBtcUsdcDbConfig({ strategyConfigOverrides = {} } = {}) { const tradingBtc = { assetId: 'nep141:nbtc.bridge.near', symbol: 'BTC', @@ -263,6 +402,10 @@ function makeBtcUsdcDbConfig() { priceMaxAgeMs: 30_000, inventoryMaxAgeMs: 30_000, minNotional: '0', + makerMaxQuoteAgeEnabled: false, + makerMaxQuoteAgeMs: null, + makerLatencyPolicyReason: null, + ...strategyConfigOverrides, }; const priceRoute = { routeId: 'btc-usdc:v1', diff --git a/test/trading-config.test.mjs b/test/trading-config.test.mjs index d1032f5..93dc535 100644 --- a/test/trading-config.test.mjs +++ b/test/trading-config.test.mjs @@ -136,6 +136,8 @@ test('seeded DB config preserves current nBTC/EURe pair, 49 bps edge, and legacy assert.equal(snapshot.activePair, `${CURRENT_NBTC_ASSET_ID}->${CURRENT_EURE_ASSET_ID}`); assert.equal(snapshot.pairs.length, 2); assert.equal(snapshot.pairByKey.get(snapshot.activePair).strategyConfig.edgeBps, 49); + assert.equal(snapshot.pairByKey.get(snapshot.activePair).strategyConfig.makerMaxQuoteAgeEnabled, false); + assert.equal(snapshot.pairByKey.get(snapshot.activePair).strategyConfig.makerMaxQuoteAgeMs, null); assert.equal(snapshot.trackedAssetIds.includes(LEGACY_OMFT_BTC_ASSET_ID), true); assert.equal(snapshot.assetRegistry.get(CURRENT_USDC_ASSET_ID).chain, 'eth:1'); assert.equal(snapshot.trackedAssetIds.includes(CURRENT_USDC_ASSET_ID), false); @@ -213,6 +215,50 @@ test('edge update creates a new active strategy version', async () => { assert.equal(versions.find((row) => row.version === 1).active, false); }); +test('strategy config update can enable pair-scoped maker response age policy', async () => { + const pool = createMemoryPool(); + await seedTradingConfig(pool); + const pairId = `${CURRENT_NBTC_ASSET_ID}->${CURRENT_EURE_ASSET_ID}`; + + const next = await createPairStrategyConfigVersion(pool, { + pairId, + edgeBps: 49, + maxNotional: '150', + makerMaxQuoteAgeEnabled: true, + makerMaxQuoteAgeMs: 125, + makerLatencyPolicyReason: 'timing evidence shows stale responses', + changedBy: 'test', + reason: 'enable maker response age policy', + }); + const snapshot = await loadTradingConfig(pool); + const strategyConfig = snapshot.pairByKey.get(pairId).strategyConfig; + + assert.equal(next.makerMaxQuoteAgeEnabled, true); + assert.equal(next.makerMaxQuoteAgeMs, 125); + assert.equal(next.makerLatencyPolicyReason, 'timing evidence shows stale responses'); + assert.equal(strategyConfig.edgeBps, 49); + assert.equal(strategyConfig.makerMaxQuoteAgeEnabled, true); + assert.equal(strategyConfig.makerMaxQuoteAgeMs, 125); +}); + +test('strategy config update rejects enabled maker response age policy without a max age', async () => { + const pool = createMemoryPool(); + await seedTradingConfig(pool); + + await assert.rejects( + createPairStrategyConfigVersion(pool, { + pairId: `${CURRENT_NBTC_ASSET_ID}->${CURRENT_EURE_ASSET_ID}`, + edgeBps: 49, + maxNotional: '150', + makerMaxQuoteAgeEnabled: true, + makerMaxQuoteAgeMs: null, + changedBy: 'test', + reason: 'invalid maker response age policy', + }), + /maker_max_quote_age_ms must be a positive integer/, + ); +}); + test('strategy config update can clear request amount and slippage caps', async () => { const pool = createMemoryPool(); await seedTradingConfig(pool); @@ -558,7 +604,7 @@ function createMemoryPool() { importRuns: new Map(), audit: [], async query(sql, params = []) { - if (/CREATE TABLE|CREATE (UNIQUE )?INDEX/i.test(sql)) return { rows: [], rowCount: 0 }; + if (/CREATE TABLE|CREATE (UNIQUE )?INDEX|ALTER TABLE/i.test(sql)) return { rows: [], rowCount: 0 }; if (/SELECT \* FROM trading_assets\s*$/i.test(sql)) return rows(this.assets); if (/SELECT\s+asset_id,[\s\S]+raw_payload_available[\s\S]+FROM trading_assets/i.test(sql)) { return selectAssetCatalogRows(this, params); @@ -777,8 +823,11 @@ function insertStrategyConfig(pool, params) { request_default_notional: params[11], request_max_notional: params[12], request_max_slippage_bps: params[13], - created_by: params[14], - reason: params[15], + maker_max_quote_age_enabled: params[14], + maker_max_quote_age_ms: params[15], + maker_latency_policy_reason: params[16], + created_by: params[17], + reason: params[18], created_at: '2026-05-12T16:35:00.000Z', }; pool.strategyConfigs.set(configId, row);