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.
305 lines
11 KiB
JavaScript
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;
|
|
}
|