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