Add maker timing competitiveness truth
All checks were successful
deploy / deploy (push) Successful in 46s
All checks were successful
deploy / deploy (push) Successful in 46s
Proof: quote-to-relay maker timing now propagates through ingest, normalized quotes, strategy decisions, commands, executor results, quote outcomes, lifecycle rows, dashboard summaries, and runtime alerts; relay failures preserve original text while classifying quote_not_found_or_finished; targeted tests, full npm test, and operator dashboard build passed before commit. Assumptions: response-age policy stays disabled by default and is only activated through DB-backed pair strategy config after operators review timing evidence; unrelated pre-existing dirty worktree files were left unstaged. Still fake: relay acceptance is not settlement or realized PnL; live policy thresholds still require post-deploy evidence before enabling skips for production pairs.
This commit is contained in:
parent
c2675df141
commit
365acf7b7f
25 changed files with 1755 additions and 40 deletions
|
|
@ -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',
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
305
src/core/maker-competitiveness.mjs
Normal file
305
src/core/maker-competitiveness.mjs
Normal file
|
|
@ -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;
|
||||
}
|
||||
146
src/core/maker-timing.mjs
Normal file
146
src/core/maker-timing.mjs
Normal file
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
45
src/core/relay-failure-classification.mjs
Normal file
45
src/core/relay-failure-classification.mjs
Normal file
|
|
@ -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(' ');
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<TableFrame>
|
||||
<table className="timing-waterfall-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Stage</th>
|
||||
<th>Timestamp</th>
|
||||
<th>Step</th>
|
||||
<th>From quote</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map(([label, at, duration]) => (
|
||||
<tr key={label}>
|
||||
<td>{label}</td>
|
||||
<td>{formatTimestamp(at)}</td>
|
||||
<td>{formatTimingMs(duration) || 'Unavailable'}</td>
|
||||
<td>
|
||||
{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'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</TableFrame>
|
||||
);
|
||||
}
|
||||
|
||||
function LifecycleDetails({ item }) {
|
||||
const executionTiming = formatExecutionTiming(item.execution?.timing);
|
||||
|
||||
|
|
@ -183,10 +243,222 @@ function LifecycleDetails({ item }) {
|
|||
<IdentifierRow label="Decision" value={item.decision_id} />
|
||||
<IdentifierRow label="Command" value={item.command_id} />
|
||||
</div>
|
||||
|
||||
<TimingWaterfall timing={item.maker_timing} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<section className="panel">
|
||||
<div className="panel-head">
|
||||
<div>
|
||||
<div className="eyebrow">Maker competitiveness</div>
|
||||
<h3>Response timing and quote-age outcomes</h3>
|
||||
<div className="panel-subtitle">
|
||||
Pair-native response evidence from durable quote, decision, command, executor result, and outcome rows.
|
||||
</div>
|
||||
</div>
|
||||
<div className="pills">
|
||||
<Pill label={`${total.count || 0} rows`} stateLabel="info" />
|
||||
<Pill label={`${total.quote_not_found_or_finished_count || 0} stale/finished`} stateLabel={(total.quote_not_found_or_finished_count || 0) > 0 ? 'warning' : 'unknown'} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="metric-grid">
|
||||
<MetricCard label="Accepted" meta={formatRate(total.accepted_rate)} value={String(total.accepted_count || 0)} />
|
||||
<MetricCard label="Relay failed" meta="Executor reached result" value={String(total.relay_failed_count || 0)} />
|
||||
<MetricCard label="Already finished" meta={formatRate(total.stale_or_finished_rate)} value={String(total.quote_not_found_or_finished_count || 0)} />
|
||||
<MetricCard label="Policy skips" meta="No relay submission" value={String(total.policy_skip_count || 0)} />
|
||||
</div>
|
||||
|
||||
<TableFrame>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Pair</th>
|
||||
<th>Direction</th>
|
||||
<th>Request</th>
|
||||
<th>Result</th>
|
||||
<th>Age / notional</th>
|
||||
<th>Outcome</th>
|
||||
<th>Counts</th>
|
||||
<th>Quote to relay</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{groups.length ? groups.slice(0, 12).map((group, index) => {
|
||||
const quoteToRelay = group.latency_stages?.find((stage) => stage.stage === 'quote_to_relay_result_ms');
|
||||
return (
|
||||
<tr key={`${group.pair}:${group.direction}:${group.request_kind}:${group.result_code}:${group.quote_age_bucket}:${index}`}>
|
||||
<td>
|
||||
<div>{pairDisplayLabel(group.pair, pairConfig)}</div>
|
||||
<div className="status-subtle mono">{truncateMiddle(group.pair || '', 42)}</div>
|
||||
</td>
|
||||
<td>{plainCodeLabel(group.direction)}</td>
|
||||
<td>{plainCodeLabel(group.request_kind)}</td>
|
||||
<td>
|
||||
<div>{plainCodeLabel(group.result_code)}</div>
|
||||
{group.failure_category ? <div className="status-subtle">{plainCodeLabel(group.failure_category)}</div> : null}
|
||||
</td>
|
||||
<td>
|
||||
<div>{group.quote_age_bucket}</div>
|
||||
<div className="status-subtle">{group.notional_bucket}</div>
|
||||
</td>
|
||||
<td>{plainCodeLabel(group.outcome_status)}</td>
|
||||
<td>
|
||||
<div>{group.count}</div>
|
||||
<div className="status-subtle">{`${group.accepted_count || 0} accepted / ${group.policy_skip_count || 0} skipped`}</div>
|
||||
</td>
|
||||
<td>
|
||||
<div>{formatTimingMs(quoteToRelay?.p50_ms) || 'Unavailable'}</div>
|
||||
<div className="status-subtle">{`p90 ${formatTimingMs(quoteToRelay?.p90_ms) || 'Unavailable'}`}</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}) : (
|
||||
<tr><td colSpan={8}>No competitiveness rows are available yet.</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</TableFrame>
|
||||
|
||||
<div className="two-column-grid">
|
||||
<TableFrame>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Latency stage</th>
|
||||
<th>p50</th>
|
||||
<th>p90</th>
|
||||
<th>p99</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(summary?.latency_stages || []).length ? summary.latency_stages.map((stage) => (
|
||||
<tr key={stage.stage}>
|
||||
<td>{stageLabel(stage.stage)}</td>
|
||||
<td>{formatTimingMs(stage.p50_ms)}</td>
|
||||
<td>{formatTimingMs(stage.p90_ms)}</td>
|
||||
<td>{formatTimingMs(stage.p99_ms)}</td>
|
||||
</tr>
|
||||
)) : (
|
||||
<tr><td colSpan={4}>No stage timing percentiles are available yet.</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</TableFrame>
|
||||
|
||||
<TableFrame>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Age bucket</th>
|
||||
<th>Outcome</th>
|
||||
<th>Count</th>
|
||||
<th>Accepted</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ageBuckets.length ? ageBuckets.slice(0, 12).map((bucket, index) => (
|
||||
<tr key={`${bucket.pair}:${bucket.quote_age_bucket}:${bucket.outcome_status}:${index}`}>
|
||||
<td>
|
||||
<div>{bucket.quote_age_bucket}</div>
|
||||
<div className="status-subtle">{pairDisplayLabel(bucket.pair, pairConfig)}</div>
|
||||
</td>
|
||||
<td>{plainCodeLabel(bucket.outcome_status)}</td>
|
||||
<td>{bucket.count}</td>
|
||||
<td>{bucket.accepted_count || 0}</td>
|
||||
</tr>
|
||||
)) : (
|
||||
<tr><td colSpan={4}>No quote-age buckets are available yet.</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</TableFrame>
|
||||
</div>
|
||||
|
||||
<div className="two-column-grid">
|
||||
<TableFrame>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Latest relay errors</th>
|
||||
<th>Quote age</th>
|
||||
<th>Error</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{latestErrors.length ? latestErrors.map((error) => (
|
||||
<tr key={`${error.quote_id}:${error.result_at}`}>
|
||||
<td>
|
||||
<IdentifierRow label="Quote" value={error.quote_id} />
|
||||
<div className="status-subtle">{pairDisplayLabel(error.pair, pairConfig)}</div>
|
||||
<div className="status-subtle">{plainCodeLabel(error.failure_category || error.result_code)}</div>
|
||||
</td>
|
||||
<td>
|
||||
<div>{formatTimingMs(error.quote_age_ms) || 'Unavailable'}</div>
|
||||
<div className="status-subtle">{error.quote_age_bucket}</div>
|
||||
</td>
|
||||
<td>{error.error_message || 'Error text unavailable'}</td>
|
||||
</tr>
|
||||
)) : (
|
||||
<tr><td colSpan={3}>No relay errors are available yet.</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</TableFrame>
|
||||
|
||||
<TableFrame>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Policy skips</th>
|
||||
<th>Age</th>
|
||||
<th>Config</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{policySkips.length ? policySkips.map((skip) => (
|
||||
<tr key={`${skip.quote_id}:${skip.decision_at}`}>
|
||||
<td>
|
||||
<IdentifierRow label="Quote" value={skip.quote_id} />
|
||||
<div className="status-subtle">{plainCodeLabel(skip.reason_code)}</div>
|
||||
</td>
|
||||
<td>
|
||||
<div>{formatTimingMs(skip.quote_age_ms) || 'Unavailable'}</div>
|
||||
<div className="status-subtle">{`max ${formatTimingMs(skip.max_quote_age_ms) || 'Unavailable'}`}</div>
|
||||
</td>
|
||||
<td>
|
||||
<div>{skip.pair_config_version ? `v${skip.pair_config_version}` : 'Version unavailable'}</div>
|
||||
<div className="status-subtle mono">{truncateMiddle(skip.pair_config_id || '', 36)}</div>
|
||||
</td>
|
||||
</tr>
|
||||
)) : (
|
||||
<tr><td colSpan={3}>No policy skips are available yet.</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</TableFrame>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<tr key={pairId}>
|
||||
|
|
@ -738,6 +1037,11 @@ function PairConfigSection({ assetCatalog, pairConfig, onControl }) {
|
|||
: `${strategyConfig.request_max_slippage_bps} bps slippage max`}
|
||||
</div>
|
||||
<div className="status-subtle">{strategyConfig.price_max_age_ms || 'Unavailable'} ms price max age</div>
|
||||
<div className="status-subtle">
|
||||
{strategyConfig.maker_max_quote_age_enabled || strategyConfig.makerMaxQuoteAgeEnabled
|
||||
? `${strategyConfig.maker_max_quote_age_ms ?? strategyConfig.makerMaxQuoteAgeMs} ms response max age`
|
||||
: 'Response age policy disabled'}
|
||||
</div>
|
||||
</td>
|
||||
<td>{route.source || 'Unavailable'}</td>
|
||||
<td>{pair.blockReason || pair.block_reason || 'No'}</td>
|
||||
|
|
@ -783,6 +1087,36 @@ function PairConfigSection({ assetCatalog, pairConfig, onControl }) {
|
|||
{hasStrategyConfig ? 'Save' : 'Init'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="trace-row">
|
||||
<label className="checkbox-row">
|
||||
<input
|
||||
aria-label={`Enable response age policy for ${pairId}`}
|
||||
checked={policyEnabled}
|
||||
onChange={(event) => setPolicyEnabledDrafts((current) => ({
|
||||
...current,
|
||||
[pairId]: event.target.checked,
|
||||
}))}
|
||||
type="checkbox"
|
||||
/>
|
||||
<span className="status-subtle">Age policy</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="trace-row">
|
||||
<span className="status-subtle">Max age</span>
|
||||
<input
|
||||
aria-label={`Max quote response age milliseconds for ${pairId}`}
|
||||
disabled={!policyEnabled}
|
||||
min="1"
|
||||
onChange={(event) => setMaxQuoteAgeDrafts((current) => ({
|
||||
...current,
|
||||
[pairId]: event.target.value,
|
||||
}))}
|
||||
step="1"
|
||||
style={{ maxWidth: 112 }}
|
||||
type="number"
|
||||
value={maxQuoteAgeDrafts[pairId] ?? ''}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div className="button-row">
|
||||
|
|
@ -843,6 +1177,11 @@ export default function StrategyPage({ strategy, onControl }) {
|
|||
|
||||
<PairConfigSection assetCatalog={strategy.asset_catalog} pairConfig={strategy.pair_config} onControl={onControl} />
|
||||
|
||||
<MakerCompetitivenessSection
|
||||
pairConfig={strategy.pair_config}
|
||||
summary={strategy.strategy_state.maker_competitiveness}
|
||||
/>
|
||||
|
||||
<AssetCatalogSection assetCatalog={strategy.asset_catalog} onControl={onControl} />
|
||||
|
||||
<section className="panel">
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
161
test/maker-timing-competitiveness.test.mjs
Normal file
161
test/maker-timing-competitiveness.test.mjs
Normal file
|
|
@ -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');
|
||||
});
|
||||
|
|
@ -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('<PairConfigSection') < strategySource.indexOf('<AssetCatalogSection'),
|
||||
|
|
|
|||
|
|
@ -29,3 +29,8 @@ test('ops sentinel exposes trimmed service snapshots and computed runtime alerts
|
|||
assert.match(source, /activeAlerts: desiredRuntimeAlerts/);
|
||||
assert.match(source, /state\.latest_runtime_alerts = desiredRuntimeAlerts/);
|
||||
});
|
||||
|
||||
test('ops sentinel turns dashboard maker competitiveness truth into runtime alerts', () => {
|
||||
assert.match(source, /buildMakerCompetitivenessRuntimeAlerts/);
|
||||
assert.match(source, /latest_maker_competitiveness/);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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/);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue