unrip/src/core/maker-competitiveness.mjs
philipp 365acf7b7f
All checks were successful
deploy / deploy (push) Successful in 46s
Add maker timing competitiveness truth
Proof: quote-to-relay maker timing now propagates through ingest, normalized quotes, strategy decisions, commands, executor results, quote outcomes, lifecycle rows, dashboard summaries, and runtime alerts; relay failures preserve original text while classifying quote_not_found_or_finished; targeted tests, full npm test, and operator dashboard build passed before commit.

Assumptions: response-age policy stays disabled by default and is only activated through DB-backed pair strategy config after operators review timing evidence; unrelated pre-existing dirty worktree files were left unstaged.

Still fake: relay acceptance is not settlement or realized PnL; live policy thresholds still require post-deploy evidence before enabling skips for production pairs.
2026-05-18 23:47:52 +02:00

305 lines
11 KiB
JavaScript

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;
}