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 (
+
+
+
+
+ | Stage |
+ Timestamp |
+ Step |
+ From quote |
+
+
+
+ {rows.map(([label, at, duration]) => (
+
+ | {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'} />
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | Pair |
+ Direction |
+ Request |
+ Result |
+ Age / notional |
+ Outcome |
+ Counts |
+ Quote to relay |
+
+
+
+ {groups.length ? groups.slice(0, 12).map((group, index) => {
+ const quoteToRelay = group.latency_stages?.find((stage) => stage.stage === 'quote_to_relay_result_ms');
+ return (
+
+ |
+ {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. |
+ )}
+
+
+
+
+
+
+
+
+
+ | Latency stage |
+ p50 |
+ p90 |
+ p99 |
+
+
+
+ {(summary?.latency_stages || []).length ? summary.latency_stages.map((stage) => (
+
+ | {stageLabel(stage.stage)} |
+ {formatTimingMs(stage.p50_ms)} |
+ {formatTimingMs(stage.p90_ms)} |
+ {formatTimingMs(stage.p99_ms)} |
+
+ )) : (
+ | No stage timing percentiles are available yet. |
+ )}
+
+
+
+
+
+
+
+
+ | Age bucket |
+ Outcome |
+ Count |
+ Accepted |
+
+
+
+ {ageBuckets.length ? ageBuckets.slice(0, 12).map((bucket, index) => (
+
+ |
+ {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. |
+ )}
+
+
+
+
+
+
+
+
+
+
+ | Latest relay errors |
+ Quote age |
+ Error |
+
+
+
+ {latestErrors.length ? latestErrors.map((error) => (
+
+ |
+
+ {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. |
+ )}
+
+
+
+
+
+
+
+
+ | Policy skips |
+ Age |
+ Config |
+
+
+
+ {policySkips.length ? policySkips.map((skip) => (
+
+ |
+
+ {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);
|