From e0dfd24a8b9033ada77246c63511e082c41e80fa Mon Sep 17 00:00:00 2001 From: philipp Date: Fri, 10 Apr 2026 11:24:22 +0200 Subject: [PATCH] Link quote outcomes to settled inventory Proof: Adds a durable quote outcome attribution model, refreshes it from submitted execution results plus inventory snapshots, and updates dashboard lifecycle rows so submitted, blocked, rejected, not-filled, and completed states are separated by durable evidence. Lowers the approved live strategy edge threshold to 1.49%. Assumptions: Exact asset-unit inventory deltas inside the attribution window are acceptable as heuristic settlement evidence for the active BTC/EURe NEAR Intents path when the uncertainty is stored and shown. Deadline-plus-inventory non-fill is inferred until venue terminal events are persisted. Still fake: No venue-native terminal fill event or per-trade fee/cost ledger is stored yet; heuristic completed and not-filled records remain explicitly labeled as inferred where applicable, and realized net PnL is still not claimed. --- deploy/k8s/base/unrip.yaml | 168 +++++- src/apps/history-writer.mjs | 43 +- src/apps/operator-dashboard.mjs | 9 + src/apps/strategy-engine.mjs | 18 +- src/apps/trade-executor.mjs | 8 +- src/core/executor-state-store.mjs | 28 +- src/core/operator-dashboard.mjs | 184 ++++++- src/core/portfolio-metrics.mjs | 126 ++++- src/core/quote-outcomes.mjs | 495 ++++++++++++++++++ src/lib/config.mjs | 2 +- src/lib/postgres.mjs | 258 ++++++++- .../static/pages/FundsPage.jsx | 16 +- .../static/pages/StrategyPage.jsx | 14 +- .../static/pages/SystemPage.jsx | 5 + src/venues/near-intents/solver-relay-ws.mjs | 22 + src/venues/near-intents/ws.mjs | 19 + test/executor-state-store.test.mjs | 28 +- test/operator-dashboard.test.mjs | 134 ++++- test/portfolio-metrics.test.mjs | 77 ++- test/quote-outcomes.test.mjs | 166 ++++++ 20 files changed, 1739 insertions(+), 81 deletions(-) create mode 100644 src/core/quote-outcomes.mjs create mode 100644 test/quote-outcomes.test.mjs diff --git a/deploy/k8s/base/unrip.yaml b/deploy/k8s/base/unrip.yaml index a02463e..a44a83d 100644 --- a/deploy/k8s/base/unrip.yaml +++ b/deploy/k8s/base/unrip.yaml @@ -40,6 +40,16 @@ data: TRADE_EXECUTOR_CONTROL_PORT: "8087" OPS_SENTINEL_CONTROL_HOST: 0.0.0.0 OPS_SENTINEL_CONTROL_PORT: "8088" + OPERATOR_DASHBOARD_CONTROL_HOST: 0.0.0.0 + OPERATOR_DASHBOARD_CONTROL_PORT: "8090" + NEAR_INTENTS_CONTROL_BASE_URL: http://near-intents-ingest.unrip.svc.cluster.local:8081 + MARKET_REFERENCE_CONTROL_BASE_URL: http://market-reference-ingest.unrip.svc.cluster.local:8082 + INVENTORY_SYNC_CONTROL_BASE_URL: http://inventory-sync.unrip.svc.cluster.local:8083 + LIQUIDITY_MANAGER_CONTROL_BASE_URL: http://liquidity-manager.unrip.svc.cluster.local:8084 + HISTORY_WRITER_CONTROL_BASE_URL: http://history-writer.unrip.svc.cluster.local:8085 + STRATEGY_ENGINE_CONTROL_BASE_URL: http://strategy-engine.unrip.svc.cluster.local:8086 + TRADE_EXECUTOR_CONTROL_BASE_URL: http://trade-executor.unrip.svc.cluster.local:8087 + OPS_SENTINEL_CONTROL_BASE_URL: http://ops-sentinel.unrip.svc.cluster.local:8088 KAFKA_BROKERS: redpanda.unrip.svc.cluster.local:9092 KAFKA_CLIENT_ID: unrip KAFKA_TOPIC_RAW_NEAR_INTENTS_QUOTE: raw.near_intents.quote @@ -57,6 +67,7 @@ data: KAFKA_CONSUMER_GROUP_STRATEGY: strategy-engine-v1 KAFKA_CONSUMER_GROUP_EXECUTOR: trade-executor-v1 KAFKA_CONSUMER_GROUP_OPS_SENTINEL: ops-sentinel-v1 + KAFKA_CONSUMER_GROUP_OPERATOR_DASHBOARD: operator-dashboard-v1 STRATEGY_STATE_DIR: /var/lib/unrip/strategy-state EXECUTOR_STATE_DIR: /var/lib/unrip/executor-state LIQUIDITY_STATE_DIR: /var/lib/unrip/liquidity-state @@ -67,9 +78,9 @@ data: MARKET_REFERENCE_COINGECKO_URL: https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies=eur INVENTORY_SYNC_REFRESH_MS: "15000" LIQUIDITY_REFRESH_MS: "30000" - STRATEGY_GROSS_THRESHOLD_PCT: "2" + STRATEGY_GROSS_THRESHOLD_PCT: "1.49" STRATEGY_INITIAL_ARMED: "false" - STRATEGY_MAX_NOTIONAL_EURE: "5" + STRATEGY_MAX_NOTIONAL_EURE: "150" STRATEGY_PRICE_MAX_AGE_MS: "30000" STRATEGY_INVENTORY_MAX_AGE_MS: "30000" EXECUTOR_INITIAL_ARMED: "false" @@ -83,6 +94,10 @@ data: OPS_SENTINEL_INVENTORY_STALE_MS: "30000" OPS_SENTINEL_FUNDING_CREDIT_PENDING_MS: "300000" OPS_SENTINEL_FUNDING_STUCK_MS: "3600000" + OPERATOR_DASHBOARD_AUTH_MODE: stub + OPERATOR_DASHBOARD_QUOTE_LIMIT: "10" + OPERATOR_DASHBOARD_TRADE_PAGE_SIZE: "20" + OPERATOR_DASHBOARD_UPSTREAM_TIMEOUT_MS: "3000" --- apiVersion: v1 kind: PersistentVolumeClaim @@ -117,6 +132,123 @@ spec: requests: storage: 5Gi --- +apiVersion: v1 +kind: Service +metadata: + name: near-intents-ingest + namespace: unrip +spec: + selector: + app: near-intents-ingest + ports: + - name: control-api + port: 8081 + targetPort: 8081 +--- +apiVersion: v1 +kind: Service +metadata: + name: market-reference-ingest + namespace: unrip +spec: + selector: + app: market-reference-ingest + ports: + - name: control-api + port: 8082 + targetPort: 8082 +--- +apiVersion: v1 +kind: Service +metadata: + name: inventory-sync + namespace: unrip +spec: + selector: + app: inventory-sync + ports: + - name: control-api + port: 8083 + targetPort: 8083 +--- +apiVersion: v1 +kind: Service +metadata: + name: liquidity-manager + namespace: unrip +spec: + selector: + app: liquidity-manager + ports: + - name: control-api + port: 8084 + targetPort: 8084 +--- +apiVersion: v1 +kind: Service +metadata: + name: history-writer + namespace: unrip +spec: + selector: + app: history-writer + ports: + - name: control-api + port: 8085 + targetPort: 8085 +--- +apiVersion: v1 +kind: Service +metadata: + name: strategy-engine + namespace: unrip +spec: + selector: + app: strategy-engine + ports: + - name: control-api + port: 8086 + targetPort: 8086 +--- +apiVersion: v1 +kind: Service +metadata: + name: trade-executor + namespace: unrip +spec: + selector: + app: trade-executor + ports: + - name: control-api + port: 8087 + targetPort: 8087 +--- +apiVersion: v1 +kind: Service +metadata: + name: ops-sentinel + namespace: unrip +spec: + selector: + app: ops-sentinel + ports: + - name: control-api + port: 8088 + targetPort: 8088 +--- +apiVersion: v1 +kind: Service +metadata: + name: operator-dashboard + namespace: unrip +spec: + selector: + app: operator-dashboard + ports: + - name: http + port: 8090 + targetPort: 8090 +--- apiVersion: apps/v1 kind: Deployment metadata: @@ -393,3 +525,35 @@ spec: - name: executor-state persistentVolumeClaim: claimName: executor-state +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: operator-dashboard + namespace: unrip +spec: + replicas: 1 + selector: + matchLabels: + app: operator-dashboard + template: + metadata: + labels: + app: operator-dashboard + app.kubernetes.io/part-of: unrip + spec: + imagePullSecrets: + - name: unrip-registry-creds + containers: + - name: app + image: ghcr.io/example/unrip:bootstrap + imagePullPolicy: IfNotPresent + command: ["node", "src/apps/operator-dashboard.mjs"] + ports: + - name: http + containerPort: 8090 + envFrom: + - configMapRef: + name: unrip-config + - secretRef: + name: unrip-secrets diff --git a/src/apps/history-writer.mjs b/src/apps/history-writer.mjs index b51e2c6..63f0be4 100644 --- a/src/apps/history-writer.mjs +++ b/src/apps/history-writer.mjs @@ -13,6 +13,7 @@ import { insertHistoryEvent, loadLatestPortfolioMetric, loadPortfolioMetricInputs, + refreshQuoteOutcomes, upsertPortfolioMetric, } from '../lib/postgres.mjs'; @@ -54,6 +55,11 @@ const portfolioMetricTopics = new Set([ config.kafkaTopicCmdExecuteTrade, config.kafkaTopicExecTradeResult, ]); +const quoteOutcomeTopics = new Set([ + config.kafkaTopicStateIntentInventory, + config.kafkaTopicCmdExecuteTrade, + config.kafkaTopicExecTradeResult, +]); for (const topic of topics) { await consumer.subscribe({ topic, fromBeginning: false }); @@ -71,11 +77,17 @@ const state = { offsets: {}, latest_portfolio_metrics: null, metrics_error: null, + last_quote_outcomes_at: null, + latest_quote_outcomes: null, + quote_outcomes_error: null, }; await refreshPortfolioMetrics().catch((error) => { state.metrics_error = serializeError(error); }); +await refreshQuoteOutcomeAttributions().catch((error) => { + state.quote_outcomes_error = serializeError(error); +}); await consumer.run({ eachMessage: async ({ topic, partition, message }) => { @@ -116,6 +128,19 @@ await consumer.run({ }); } } + if (quoteOutcomeTopics.has(topic)) { + try { + await refreshQuoteOutcomeAttributions(); + } catch (error) { + state.quote_outcomes_error = serializeError(error); + logger.error('quote_outcomes_refresh_failed', { + topic, + details: { + error: serializeError(error), + }, + }); + } + } } catch (error) { state.last_error = serializeError(error); state.error_count += 1; @@ -257,6 +282,22 @@ async function refreshPortfolioMetrics() { return state.latest_portfolio_metrics; } +async function refreshQuoteOutcomeAttributions() { + const records = await refreshQuoteOutcomes(pool, { + btcAsset: config.tradingBtc, + eureAsset: config.tradingEure, + }); + state.last_quote_outcomes_at = new Date().toISOString(); + state.quote_outcomes_error = null; + state.latest_quote_outcomes = { + count: records.length, + completed_count: records.filter((entry) => entry.outcome_status === 'completed').length, + not_filled_count: records.filter((entry) => entry.outcome_status === 'not_filled').length, + submitted_count: records.filter((entry) => entry.outcome_status === 'submitted').length, + }; + return state.latest_quote_outcomes; +} + function summarizePortfolioMetric(metric) { if (!metric) return null; return { @@ -265,7 +306,7 @@ function summarizePortfolioMetric(metric) { baseline_anchor_at: metric.baseline_anchor_at, baseline_status: metric.baseline_status, current_portfolio_value_eure: metric.payload?.current_portfolio_value_eure ?? null, - trade_pnl_eure: metric.payload?.trade_pnl_eure ?? null, + portfolio_vs_simple_hold_eure: metric.payload?.portfolio_vs_simple_hold_eure ?? null, mark_to_market_pnl_eure: metric.payload?.mark_to_market_pnl_eure ?? null, price_move_pnl_eure: metric.payload?.price_move_pnl_eure ?? null, command_count: metric.payload?.command_count ?? 0, diff --git a/src/apps/operator-dashboard.mjs b/src/apps/operator-dashboard.mjs index cdb5c6e..f7cb691 100644 --- a/src/apps/operator-dashboard.mjs +++ b/src/apps/operator-dashboard.mjs @@ -34,6 +34,7 @@ import { loadRecentDepositStatuses, loadRecentExecuteTradeCommands, loadRecentExecutionResults, + loadRecentQuoteOutcomes, loadRecentTradeDecisions, loadRecentQuotes, loadSubmissionPage, @@ -343,6 +344,7 @@ async function loadBootstrapPayload({ auth, page, pageSize }) { recentTradeDecisions, recentExecuteTradeCommands, recentExecutionResults, + recentQuoteOutcomes, recentAlertTransitions, serviceSnapshots, ] = await Promise.all([ @@ -403,6 +405,12 @@ async function loadBootstrapPayload({ auth, page, pageSize }) { [], sourceErrors, ), + safeSourceLoad( + 'recent_quote_outcomes', + () => loadRecentQuoteOutcomes(pool, { limit: 200 }), + [], + sourceErrors, + ), safeSourceLoad( 'recent_alert_transitions', () => loadRecentAlertTransitions(pool, { limit: 20 }), @@ -426,6 +434,7 @@ async function loadBootstrapPayload({ auth, page, pageSize }) { recentTradeDecisions, recentExecuteTradeCommands, recentExecutionResults, + recentQuoteOutcomes, recentAlertTransitions, serviceSnapshots, sourceErrors, diff --git a/src/apps/strategy-engine.mjs b/src/apps/strategy-engine.mjs index a900e3b..e81bbaa 100644 --- a/src/apps/strategy-engine.mjs +++ b/src/apps/strategy-engine.mjs @@ -131,18 +131,24 @@ async function handleDemand(event) { } async function publishDecision(decisionPayload) { + const decisionAt = decisionPayload.decision_at || new Date().toISOString(); + const normalizedDecisionPayload = { + ...decisionPayload, + decision_at: decisionAt, + }; const event = buildEventEnvelope({ source: 'strategy-engine', venue: 'near-intents', eventType: 'trade_decision', - payload: decisionPayload, + observedAt: decisionAt, + payload: normalizedDecisionPayload, }); - await producer.sendJson(config.kafkaTopicDecisionTradeDecision, event, { key: decisionPayload.quote_id }); - state.latest_decision = decisionPayload; - state.recent_decisions.unshift(decisionPayload); + await producer.sendJson(config.kafkaTopicDecisionTradeDecision, event, { key: normalizedDecisionPayload.quote_id }); + state.latest_decision = normalizedDecisionPayload; + state.recent_decisions.unshift(normalizedDecisionPayload); state.recent_decisions = state.recent_decisions.slice(0, 20); - state.skipped_counts[decisionPayload.decision_reason] = - (state.skipped_counts[decisionPayload.decision_reason] || 0) + 1; + state.skipped_counts[normalizedDecisionPayload.decision_reason] = + (state.skipped_counts[normalizedDecisionPayload.decision_reason] || 0) + 1; } const controlApi = startControlApi({ diff --git a/src/apps/trade-executor.mjs b/src/apps/trade-executor.mjs index d9458b5..e0f4e64 100644 --- a/src/apps/trade-executor.mjs +++ b/src/apps/trade-executor.mjs @@ -79,7 +79,7 @@ const state = { last_quote_status: null, last_error: null, in_flight_count: 0, - completed_count: 0, + submitted_count: 0, }; await consumer.subscribe({ topic: config.kafkaTopicCmdExecuteTrade, fromBeginning: false }); @@ -107,7 +107,7 @@ async function handleCommand(event) { state.last_command = payload; const existing = stateStore.get(payload.command_id); - if (existing?.status === 'completed') { + if (existing?.status === 'submitted') { logger.warn('duplicate_command_skipped', { topic: config.kafkaTopicCmdExecuteTrade, pair: payload.pair, @@ -158,11 +158,11 @@ async function handleCommand(event) { result_code: response === 'OK' ? 'quote_response_ok' : 'quote_response_ack', venue_response: response, }); - stateStore.markCompleted(payload.command_id, { + stateStore.markSubmitted(payload.command_id, { quote_id: payload.quote_id, result: response, }); - state.completed_count += 1; + state.submitted_count += 1; } catch (error) { state.last_error = serializeError(error); stateStore.markFailed(payload.command_id, { diff --git a/src/core/executor-state-store.mjs b/src/core/executor-state-store.mjs index 851143b..6a607fa 100644 --- a/src/core/executor-state-store.mjs +++ b/src/core/executor-state-store.mjs @@ -13,19 +13,24 @@ export function createExecutorStateStore({ stateDir, fileName = 'trade-executor- return { get(commandId) { - return store.getState().commands[commandId] || null; + const command = store.getState().commands[commandId] || null; + if (!command) return null; + return { + ...command, + status: command.status === 'completed' ? 'submitted' : command.status, + }; }, markProcessing(commandId, metadata) { return updateCommand(store, commandId, metadata, 'processing'); }, - markCompleted(commandId, metadata) { - return updateCommand(store, commandId, metadata, 'completed'); + markSubmitted(commandId, metadata) { + return updateCommand(store, commandId, metadata, 'submitted'); }, markFailed(commandId, metadata) { return updateCommand(store, commandId, metadata, 'failed'); }, getState() { - return store.getState(); + return normalizeState(store.getState()); }, }; } @@ -43,3 +48,18 @@ function updateCommand(store, commandId, metadata, status) { return nextState.commands[commandId]; } + +function normalizeState(state) { + return { + ...state, + commands: Object.fromEntries( + Object.entries(state.commands || {}).map(([commandId, command]) => [ + commandId, + { + ...command, + status: command.status === 'completed' ? 'submitted' : command.status, + }, + ]), + ), + }; +} diff --git a/src/core/operator-dashboard.mjs b/src/core/operator-dashboard.mjs index c5af0d4..53cc77a 100644 --- a/src/core/operator-dashboard.mjs +++ b/src/core/operator-dashboard.mjs @@ -1,6 +1,7 @@ import { unitsToNumber } from './assets.mjs'; import { summarizeFundingObservations } from './funding-observations.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'; export const DASHBOARD_LIVE_QUOTE_LIMIT = 10; @@ -315,6 +316,7 @@ export function buildDashboardBootstrap({ recentTradeDecisions, recentExecuteTradeCommands, recentExecutionResults, + recentQuoteOutcomes = [], recentAlertTransitions, serviceSnapshots, sourceErrors = [], @@ -381,12 +383,14 @@ export function buildDashboardBootstrap({ caveats: profitability.caveats, }, strategy: buildStrategySummary({ + config, servicesByName, activeAlerts, recentQuotes, recentTradeDecisions, recentExecuteTradeCommands, recentExecutionResults, + recentQuoteOutcomes, }), system: buildSystemSummary({ servicesByName, @@ -408,6 +412,7 @@ export function buildProfitabilitySummary({ metric, submissionSummary } = {}) { pnl_vs_deposit_baseline_eure: null, pnl_vs_simple_hold_eure: null, market_move_contribution_eure: null, + portfolio_vs_simple_hold_eure: null, trading_contribution_eure: null, baseline_anchor_at: metric?.baseline_anchor_at || null, baseline_status: metric?.baseline_status || metric?.payload?.baseline_status || 'unavailable', @@ -420,8 +425,8 @@ export function buildProfitabilitySummary({ metric, submissionSummary } = {}) { recent_submission_count: submissionSummary?.total ?? 0, last_submission_at: submissionSummary?.last_submission_at || null, caveats: [ - 'Portfolio PnL is truthful to the current durable inventory and reference price path.', - 'Fees and per-trade realized net settlement deltas are not fully tracked yet.', + 'Portfolio value and simple-hold comparison use durable inventory and reference prices.', + 'This is not realized per-trade PnL; completed trades require linked outcome and settlement records.', ], }; @@ -452,10 +457,11 @@ export function buildProfitabilitySummary({ metric, submissionSummary } = {}) { } if (summary.simple_hold_value_eure) { - summary.trading_contribution_eure = formatDecimalDifference( + summary.portfolio_vs_simple_hold_eure = formatDecimalDifference( summary.current_total_portfolio_value_eure, summary.simple_hold_value_eure, ); + summary.trading_contribution_eure = null; } if (summary.external_flow_adjusted) { @@ -718,6 +724,12 @@ const HUMAN_REASON_TEXT = { quote_expired: 'Quote expired.', quote_response_ack: 'Quote response acknowledged by the relay.', quote_response_ok: 'Quote response accepted by the relay.', + awaiting_outcome: 'No durable venue outcome is recorded yet.', + deadline_elapsed_without_settlement: + 'No matching inventory delta was observed after the quote-response deadline.', + matched_inventory_delta: + 'Matched to an observed inventory delta. Attribution is heuristic unless the source says linked settlement.', + ambiguous_inventory_delta_match: 'Inventory movement matched more than one quote candidate.', reason_unknown: 'Reason not recorded.', stale_reference_price: 'Reference price is stale.', strategy_approved: 'Strategy approved the quote.', @@ -734,6 +746,7 @@ export function deriveQuoteLifecycleRows({ recentTradeDecisions = [], recentExecuteTradeCommands = [], recentExecutionResults = [], + recentQuoteOutcomes = [], limit = 20, } = {}) { const rowsByKey = new Map(); @@ -808,10 +821,27 @@ export function deriveQuoteLifecycleRows({ }); } - return [...rowsByKey.values()] + for (const outcome of recentQuoteOutcomes || []) { + const row = ensureLifecycleRow(rowsByKey, outcome?.quote_id || outcome?.decision_id || outcome?.command_id || `outcome:${outcome?.outcome_observed_at || rowsByKey.size}`); + mergeLifecycleEvidence(row, { + quote_id: outcome?.quote_id || null, + decision_id: outcome?.decision_id || null, + command_id: outcome?.command_id || null, + pair: outcome?.pair || null, + direction: outcome?.direction || null, + request_kind: outcome?.request_kind || null, + gross_edge_pct: outcome?.gross_edge_pct || null, + eure_notional: outcome?.eure_notional || null, + outcome, + outcome_observed_at: outcome?.outcome_observed_at || outcome?.submitted_at || null, + }); + } + + const finalized = [...rowsByKey.values()] .map((row) => finalizeLifecycleRow(row)) - .sort((left, right) => sortTimestamps(right.latest_stage_at, left.latest_stage_at)) - .slice(0, limit); + .sort((left, right) => sortTimestamps(right.latest_stage_at, left.latest_stage_at)); + + return limit == null ? finalized : finalized.slice(0, limit); } function ensureLifecycleRow(rowsByKey, key) { @@ -829,9 +859,11 @@ function ensureLifecycleRow(rowsByKey, key) { decision_at: null, command_at: null, execution_result_at: null, + outcome_observed_at: null, decision: null, command: null, execution: null, + outcome: null, }); } return rowsByKey.get(key); @@ -846,12 +878,19 @@ function mergeLifecycleEvidence(row, next) { if (next?.decision) row.decision = next.decision; if (next?.command) row.command = next.command; if (next?.execution) row.execution = next.execution; + if (next?.outcome) row.outcome = next.outcome; } function finalizeLifecycleRow(row) { const decision = row.decision || null; const execution = row.execution || null; - const outcomeStatus = normalizeLifecycleToken(execution?.outcome_status || execution?.outcome_reason || null); + const outcome = row.outcome || null; + const outcomeStatus = normalizeLifecycleToken( + outcome?.outcome_status + || execution?.outcome_status + || execution?.outcome_reason + || null, + ); let lifecycle_state = 'observed'; let lifecycle_label = 'Observed'; let reason_code = 'reason_unknown'; @@ -860,13 +899,28 @@ function finalizeLifecycleRow(row) { if (outcomeStatus && COMPLETED_OUTCOME_STATUSES.has(outcomeStatus)) { lifecycle_state = 'completed'; lifecycle_label = 'Completed'; - reason_code = normalizeLifecycleToken(execution?.outcome_reason || execution?.result_code || 'completed'); - reason_text = humanizeReasonCode(reason_code, 'Completed'); + reason_code = normalizeLifecycleToken( + outcome?.outcome_reason + || execution?.outcome_reason + || execution?.result_code + || 'completed', + ); + reason_text = buildCompletedOutcomeText({ outcome, reasonCode: reason_code }); } else if (outcomeStatus && NOT_FILLED_OUTCOME_STATUSES.has(outcomeStatus)) { lifecycle_state = 'not_filled'; lifecycle_label = 'Not filled'; - reason_code = normalizeLifecycleToken(execution?.outcome_reason || execution?.result_code || outcomeStatus); - reason_text = humanizeReasonCode(reason_code, 'Not filled'); + reason_code = normalizeLifecycleToken( + outcome?.outcome_reason + || execution?.outcome_reason + || execution?.result_code + || outcomeStatus, + ); + reason_text = buildNotFilledText({ outcome, reasonCode: reason_code }); + } else if (outcomeStatus === 'awaiting_outcome') { + lifecycle_state = 'awaiting_outcome'; + lifecycle_label = 'Awaiting outcome'; + reason_code = normalizeLifecycleToken(outcome?.outcome_reason || 'awaiting_outcome'); + reason_text = humanizeReasonCode(reason_code, 'No durable venue outcome is recorded yet.'); } else if (execution?.status === 'submitted') { lifecycle_state = 'submitted'; lifecycle_label = 'Submitted'; @@ -907,7 +961,8 @@ function finalizeLifecycleRow(row) { reason_code, reason_text, latest_stage_at: - row.execution_result_at + row.outcome_observed_at + || row.execution_result_at || row.command_at || row.decision_at || row.quote_observed_at @@ -922,10 +977,45 @@ function finalizeLifecycleRow(row) { execution_status: execution?.status || null, execution_result_code: execution?.result_code || null, execution_outcome_status: execution?.outcome_status || null, + durable_outcome_status: outcome?.outcome_status || null, + durable_outcome_source: outcome?.outcome_source || null, + attribution_status: outcome?.attribution_status || null, + attribution_method: outcome?.attribution_method || null, }, + outcome_source: outcome?.outcome_source || null, + outcome_status: outcome?.outcome_status || execution?.outcome_status || null, + attribution_status: outcome?.attribution_status || execution?.attribution_status || null, + attribution_method: outcome?.attribution_method || execution?.attribution_method || null, + attributed_inventory_delta: + outcome?.attributed_inventory_delta + || execution?.attributed_inventory_delta + || null, + has_settlement_evidence: hasSettlementEvidence(outcome || execution), }; } +function buildCompletedOutcomeText({ outcome, reasonCode }) { + const base = humanizeReasonCode(reasonCode, 'Completed.'); + if (!outcome?.attribution_status) return `${base} Settlement attribution is not stored.`; + if (outcome.attribution_status === 'heuristic_match') { + return `${base} Matched to inventory movement by exact asset-unit delta; venue terminal status is not stored.`; + } + return base; +} + +function buildNotFilledText({ outcome, reasonCode }) { + const base = humanizeReasonCode(reasonCode, 'Not filled.'); + const uncertainty = outcome?.evidence?.uncertainty; + return uncertainty ? `${base} ${uncertainty}` : base; +} + +function hasSettlementEvidence(outcome) { + return Boolean( + outcome?.attributed_inventory_delta + && TERMINAL_SETTLEMENT_ATTRIBUTION_STATUSES.has(outcome.attribution_status), + ); +} + function buildExecutionFailureText(execution, reasonCode) { const base = humanizeReasonCode(reasonCode, 'Submission failed.'); if (execution?.error_message) return `${base} ${execution.error_message}`; @@ -968,12 +1058,14 @@ function normalizeCommand(command) { } function buildStrategySummary({ + config, servicesByName, activeAlerts, recentQuotes = [], recentTradeDecisions = [], recentExecuteTradeCommands = [], recentExecutionResults = [], + recentQuoteOutcomes = [], }) { const strategyState = servicesByName['strategy-engine']?.state || {}; const executorState = servicesByName['trade-executor']?.state || {}; @@ -1009,14 +1101,16 @@ function buildStrategySummary({ || durableDecisionsById.get(strategyState.latest_decision?.decision_id)?.decision_at || null, }); - const lifecycleRows = deriveQuoteLifecycleRows({ + const allLifecycleRows = deriveQuoteLifecycleRows({ recentQuotes, recentTradeDecisions, recentExecuteTradeCommands, recentExecutionResults, - limit: 20, - }); - const tradeFunnel = buildTradeFunnelSummary(lifecycleRows); + recentQuoteOutcomes, + limit: null, + }).map((row) => enrichLifecycleRowForUi({ config, row })); + const lifecycleRows = allLifecycleRows.slice(0, 20); + const tradeFunnel = buildTradeFunnelSummary(allLifecycleRows); return { strategy_state: { @@ -1038,7 +1132,7 @@ function buildStrategySummary({ paused: executorState.paused ?? null, draining: executorState.draining ?? null, in_flight_count: executorState.in_flight_count ?? 0, - completed_count: executorState.completed_count ?? 0, + submitted_count: executorState.submitted_count ?? executorState.completed_count ?? 0, last_command: executorState.last_command || null, last_venue_response: executorState.last_venue_response || null, last_error: executorState.last_error || null, @@ -1076,7 +1170,7 @@ function buildTradeFunnelSummary(lifecycleRows = []) { counts[row.lifecycle_state] += 1; } - if (row.lifecycle_state === 'completed') { + if (row.lifecycle_state === 'completed' && row.has_settlement_evidence) { successfulTrades.push(row); } else if (['submitted', 'awaiting_outcome'].includes(row.lifecycle_state)) { unresolvedSubmissions.push(row); @@ -1123,9 +1217,12 @@ function buildSystemSummary({ servicesByName, activeAlerts, recentAlerts }) { last_alert_write_at: historyWriterState.last_alert_write_at || null, last_funding_observation_write_at: historyWriterState.last_funding_observation_write_at || null, last_metrics_at: historyWriterState.last_metrics_at || null, + last_quote_outcomes_at: historyWriterState.last_quote_outcomes_at || null, latest_portfolio_metrics: historyWriterState.latest_portfolio_metrics || null, + latest_quote_outcomes: historyWriterState.latest_quote_outcomes || null, offsets: historyWriterState.offsets || {}, metrics_error: historyWriterState.metrics_error || null, + quote_outcomes_error: historyWriterState.quote_outcomes_error || null, }, controls: listDashboardControls({ page: 'system' }), }; @@ -1192,6 +1289,7 @@ function buildServiceSummary(service, state) { return { last_write_at: state.last_write_at || null, last_alert_write_at: state.last_alert_write_at || null, + last_quote_outcomes_at: state.last_quote_outcomes_at || null, database_connectivity: state.database_connectivity ?? null, }; case 'ops-sentinel': @@ -1210,7 +1308,7 @@ function buildServiceSummary(service, state) { case 'trade-executor': return { in_flight_count: state.in_flight_count ?? 0, - completed_count: state.completed_count ?? 0, + submitted_count: state.submitted_count ?? state.completed_count ?? 0, signer_registered: state.signer_registered ?? null, relay_connected: state.relay?.connected ?? null, relay_last_message_at: state.relay?.last_message_at || null, @@ -1244,6 +1342,54 @@ function normalizeTradeForUi({ config, trade }) { }; } +function enrichLifecycleRowForUi({ config, row }) { + return { + ...row, + settlement_summary: buildSettlementSummary({ + config, + delta: row.attributed_inventory_delta, + attributionStatus: row.attribution_status, + attributionMethod: row.attribution_method, + }), + }; +} + +function buildSettlementSummary({ config, delta, attributionStatus, attributionMethod }) { + if (!delta?.delta_units) { + return { + status: attributionStatus || 'unattributed', + method: attributionMethod || null, + lines: [], + text: attributionStatus === 'ambiguous' + ? 'Inventory movement is ambiguous and is not assigned to this quote.' + : 'No settled inventory delta is linked to this quote.', + }; + } + + const lines = Object.entries(delta.delta_units).map(([assetId, units]) => { + const asset = config.assetRegistry.get(assetId); + const symbol = asset?.symbol || assetId; + const formatted = formatUnits(units, asset?.decimals || 0); + const signed = BigInt(String(units || '0')) > 0n ? `+${formatted}` : formatted; + return { + asset_id: assetId, + symbol, + units, + amount: signed, + }; + }); + + return { + status: attributionStatus || 'unattributed', + method: attributionMethod || null, + observed_at: delta.observed_at || null, + previous_observed_at: delta.previous_observed_at || null, + lines, + text: lines.map((line) => `${line.amount} ${line.symbol}`).join(', '), + caveat: delta.uncertainty || null, + }; +} + function buildRecentDepositItems({ config, recentDepositStatuses, liquidityState }) { const recentItems = (recentDepositStatuses || []).map((entry) => normalizeDepositStatusForUi({ config, diff --git a/src/core/portfolio-metrics.mjs b/src/core/portfolio-metrics.mjs index be3ad4c..e2a5dfb 100644 --- a/src/core/portfolio-metrics.mjs +++ b/src/core/portfolio-metrics.mjs @@ -5,6 +5,7 @@ export function computePortfolioMetric({ baseline = null, currentInventory, currentPrice, + externalFlows = [], btcAsset, eureAsset, commandCount = 0, @@ -23,7 +24,7 @@ export function computePortfolioMetric({ const currentPortfolioValue = currentEure + currentBtcMarkValue; const payload = { - metric_version: 1, + metric_version: 2, baseline_status: baseline ? 'active' : 'awaiting_first_execution', command_count: commandCount, result_count: resultCount, @@ -40,12 +41,27 @@ export function computePortfolioMetric({ current_portfolio_value_eure: formatScaledDecimal(currentPortfolioValue), current_btc_mark_value_eure: formatScaledDecimal(currentBtcMarkValue), current_eure_cash_value_eure: formatScaledDecimal(currentEure), + portfolio_vs_simple_hold_eure: null, trade_pnl_eure: null, mark_to_market_pnl_eure: null, price_move_pnl_eure: null, baseline_portfolio_value_eure_at_baseline_price: null, baseline_portfolio_value_eure_at_current_price: null, current_portfolio_value_eure_at_baseline_price: null, + initial_baseline_portfolio_value_eure_at_baseline_price: null, + initial_baseline_portfolio_value_eure_at_current_price: null, + external_cash_flows: { + flow_count: 0, + deposit_count: 0, + withdrawal_count: 0, + latest_effective_at: null, + net_btc_units: '0', + net_btc: '0', + net_eure_units: '0', + net_eure: '0', + net_value_eure_at_flow_time: '0', + net_value_eure_at_current_price: '0', + }, inventory_delta: null, baseline: null, }; @@ -62,22 +78,57 @@ export function computePortfolioMetric({ const baselinePortfolioAtBaselinePrice = baselineEure + multiplyScaled(baselineBtc, baselinePriceScaled); const baselinePortfolioAtCurrentPrice = baselineEure + multiplyScaled(baselineBtc, currentPriceScaled); const currentPortfolioAtBaselinePrice = currentEure + multiplyScaled(currentBtc, baselinePriceScaled); - const tradePnl = currentPortfolioAtBaselinePrice - baselinePortfolioAtBaselinePrice; - const markToMarketPnl = currentPortfolioValue - baselinePortfolioAtCurrentPrice; - const priceMovePnl = markToMarketPnl - tradePnl; + const externalFlowSummary = summarizeExternalFlows({ + externalFlows, + currentPriceScaled, + btcAsset, + eureAsset, + }); + const fundedPortfolioAtFlowTime = baselinePortfolioAtBaselinePrice + + externalFlowSummary.netValueEureAtFlowTime; + const simpleHoldAtCurrentPrice = baselinePortfolioAtCurrentPrice + + externalFlowSummary.netValueEureAtCurrentPrice; + const portfolioVsSimpleHold = currentPortfolioValue - simpleHoldAtCurrentPrice; + const markToMarketPnl = currentPortfolioValue - fundedPortfolioAtFlowTime; + const priceMovePnl = simpleHoldAtCurrentPrice - fundedPortfolioAtFlowTime; - payload.trade_pnl_eure = formatScaledDecimal(tradePnl); + payload.portfolio_vs_simple_hold_eure = formatScaledDecimal(portfolioVsSimpleHold); + payload.trade_pnl_eure = null; payload.mark_to_market_pnl_eure = formatScaledDecimal(markToMarketPnl); payload.price_move_pnl_eure = formatScaledDecimal(priceMovePnl); payload.baseline_portfolio_value_eure_at_baseline_price = formatScaledDecimal( - baselinePortfolioAtBaselinePrice, + fundedPortfolioAtFlowTime, ); payload.baseline_portfolio_value_eure_at_current_price = formatScaledDecimal( - baselinePortfolioAtCurrentPrice, + simpleHoldAtCurrentPrice, ); payload.current_portfolio_value_eure_at_baseline_price = formatScaledDecimal( currentPortfolioAtBaselinePrice, ); + payload.initial_baseline_portfolio_value_eure_at_baseline_price = formatScaledDecimal( + baselinePortfolioAtBaselinePrice, + ); + payload.initial_baseline_portfolio_value_eure_at_current_price = formatScaledDecimal( + baselinePortfolioAtCurrentPrice, + ); + payload.external_cash_flows = { + flow_count: externalFlowSummary.flowCount, + deposit_count: externalFlowSummary.depositCount, + withdrawal_count: externalFlowSummary.withdrawalCount, + latest_effective_at: externalFlowSummary.latestEffectiveAt, + net_btc_units: externalFlowSummary.netBtcUnits.toString(), + net_btc: formatScaledDecimal(unitsToScaledDecimal( + externalFlowSummary.netBtcUnits.toString(), + btcAsset.decimals, + )), + net_eure_units: externalFlowSummary.netEureUnits.toString(), + net_eure: formatScaledDecimal(unitsToScaledDecimal( + externalFlowSummary.netEureUnits.toString(), + eureAsset.decimals, + )), + net_value_eure_at_flow_time: formatScaledDecimal(externalFlowSummary.netValueEureAtFlowTime), + net_value_eure_at_current_price: formatScaledDecimal(externalFlowSummary.netValueEureAtCurrentPrice), + }; payload.inventory_delta = { btc_units: (BigInt(currentBtcUnits) - BigInt(baselineBtcUnits)).toString(), btc: formatScaledDecimal(currentBtc - baselineBtc), @@ -126,6 +177,62 @@ function buildInventoryView({ inventory, btcAsset, eureAsset }) { }; } +function summarizeExternalFlows({ + externalFlows, + currentPriceScaled, + btcAsset, + eureAsset, +}) { + let flowCount = 0; + let depositCount = 0; + let withdrawalCount = 0; + let latestEffectiveAt = null; + let netBtcUnits = 0n; + let netEureUnits = 0n; + let netValueEureAtFlowTime = 0n; + let netValueEureAtCurrentPrice = 0n; + + for (const flow of externalFlows || []) { + if (!flow?.asset_id || !flow?.signed_units) continue; + + const signedUnits = BigInt(flow.signed_units); + if (signedUnits === 0n) continue; + + flowCount += 1; + if (flow.kind === 'deposit') depositCount += 1; + if (flow.kind === 'withdrawal') withdrawalCount += 1; + if (timestampValue(flow.effective_at) > timestampValue(latestEffectiveAt)) { + latestEffectiveAt = flow.effective_at || null; + } + + if (flow.asset_id === btcAsset.assetId) { + netBtcUnits += signedUnits; + const btcAmount = unitsToScaledDecimal(signedUnits.toString(), btcAsset.decimals); + const flowPriceScaled = parseScaledDecimal( + flow.reference_price_eure_per_btc_at_flow_time || '0', + ); + netValueEureAtFlowTime += multiplyScaled(btcAmount, flowPriceScaled); + netValueEureAtCurrentPrice += multiplyScaled(btcAmount, currentPriceScaled); + } else if (flow.asset_id === eureAsset.assetId) { + netEureUnits += signedUnits; + const eureAmount = unitsToScaledDecimal(signedUnits.toString(), eureAsset.decimals); + netValueEureAtFlowTime += eureAmount; + netValueEureAtCurrentPrice += eureAmount; + } + } + + return { + flowCount, + depositCount, + withdrawalCount, + latestEffectiveAt, + netBtcUnits, + netEureUnits, + netValueEureAtFlowTime, + netValueEureAtCurrentPrice, + }; +} + function unitsToScaledDecimal(units, decimals) { return BigInt(units || '0') * 10n ** BigInt(VALUE_SCALE - decimals); } @@ -160,3 +267,8 @@ function formatScaledDecimal(value) { const fractionalText = fractional.toString().padStart(VALUE_SCALE, '0').replace(/0+$/, ''); return `${negative ? '-' : ''}${whole}.${fractionalText}`; } + +function timestampValue(value) { + const parsed = Date.parse(value || ''); + return Number.isFinite(parsed) ? parsed : -Infinity; +} diff --git a/src/core/quote-outcomes.mjs b/src/core/quote-outcomes.mjs new file mode 100644 index 0000000..191d338 --- /dev/null +++ b/src/core/quote-outcomes.mjs @@ -0,0 +1,495 @@ +const DEFAULT_ATTRIBUTION_WINDOW_MS = 10 * 60 * 1000; +const DEFAULT_SETTLEMENT_GRACE_MS = 60 * 1000; + +export const TERMINAL_SETTLEMENT_ATTRIBUTION_STATUSES = new Set([ + 'heuristic_match', + 'linked_settlement', +]); + +export function deriveQuoteOutcomeRecords({ + submissions = [], + commands = [], + decisions = [], + inventorySnapshots = [], + btcAsset, + eureAsset, + now = Date.now(), + attributionWindowMs = DEFAULT_ATTRIBUTION_WINDOW_MS, + settlementGraceMs = DEFAULT_SETTLEMENT_GRACE_MS, +} = {}) { + const activeAssetIds = [btcAsset?.assetId, eureAsset?.assetId].filter(Boolean); + const normalizedSubmissions = submissions + .map(normalizeSubmission) + .filter((entry) => entry?.quote_id && entry.status === 'submitted'); + const commandsByQuote = new Map( + commands + .map(normalizeCommand) + .filter((entry) => entry?.quote_id) + .map((entry) => [entry.quote_id, entry]), + ); + const decisionsByQuote = new Map( + decisions + .map(normalizeDecision) + .filter((entry) => entry?.quote_id) + .map((entry) => [entry.quote_id, entry]), + ); + const inventoryDeltas = deriveInventoryDeltas({ + inventorySnapshots, + activeAssetIds, + }); + const latestInventoryAt = inventoryDeltas.length + ? inventoryDeltas[inventoryDeltas.length - 1].observed_at + : latestSnapshotTimestamp(inventorySnapshots); + const candidatesByMovement = new Map(); + const candidatesByQuote = new Map(); + + for (const submission of normalizedSubmissions) { + const command = commandsByQuote.get(submission.quote_id) || null; + const expectedDelta = buildExpectedMakerDeltas(command); + if (!expectedDelta) continue; + + const matches = inventoryDeltas.filter((movement) => ( + movementMatchesExpectedDelta({ + movement, + expectedDelta, + submittedAt: submission.submitted_at, + attributionWindowMs, + }) + )); + if (matches.length) candidatesByQuote.set(submission.quote_id, matches); + for (const movement of matches) { + const existing = candidatesByMovement.get(movement.movement_id) || []; + existing.push(submission.quote_id); + candidatesByMovement.set(movement.movement_id, existing); + } + } + + return normalizedSubmissions.map((submission) => { + const command = commandsByQuote.get(submission.quote_id) || null; + const decision = decisionsByQuote.get(submission.quote_id) || null; + const matches = candidatesByQuote.get(submission.quote_id) || []; + const uniqueMatch = matches.length === 1 + && (candidatesByMovement.get(matches[0].movement_id) || []).length === 1 + ? matches[0] + : null; + + if (uniqueMatch) { + return buildCompletedOutcomeRecord({ + submission, + command, + decision, + movement: uniqueMatch, + attributionWindowMs, + }); + } + + if (matches.length > 0) { + return buildAmbiguousOutcomeRecord({ + submission, + command, + decision, + matches, + }); + } + + const expiredWindow = getExpiredSettlementWindow({ + submission, + command, + now, + latestInventoryAt, + settlementGraceMs, + }); + if (expiredWindow) { + return buildNotFilledOutcomeRecord({ + submission, + command, + decision, + settlementGraceMs, + expiredAt: expiredWindow.expiresAt, + latestInventoryAt: expiredWindow.latestInventoryAt, + }); + } + + return buildSubmittedOutcomeRecord({ + submission, + command, + decision, + }); + }); +} + +export function deriveInventoryDeltas({ inventorySnapshots = [], activeAssetIds = [] } = {}) { + const sortedSnapshots = inventorySnapshots + .map(normalizeInventorySnapshot) + .filter((entry) => entry?.observed_at) + .sort((left, right) => timestampValue(left.observed_at) - timestampValue(right.observed_at)); + const deltas = []; + + for (let index = 1; index < sortedSnapshots.length; index += 1) { + const previous = sortedSnapshots[index - 1]; + const current = sortedSnapshots[index]; + const delta_units = {}; + let changed = false; + + for (const assetId of activeAssetIds) { + const currentUnits = safeBigInt(current.spendable?.[assetId]); + const previousUnits = safeBigInt(previous.spendable?.[assetId]); + const delta = currentUnits - previousUnits; + delta_units[assetId] = delta.toString(); + if (delta !== 0n) changed = true; + } + + if (!changed) continue; + + deltas.push({ + movement_id: `${previous.observed_at}->${current.observed_at}`, + observed_at: current.observed_at, + previous_observed_at: previous.observed_at, + inventory_id: current.inventory_id, + previous_inventory_id: previous.inventory_id, + delta_units, + }); + } + + return deltas; +} + +function buildCompletedOutcomeRecord({ + submission, + command, + decision, + movement, + attributionWindowMs, +}) { + return baseOutcomeRecord({ + submission, + command, + decision, + outcome_status: 'completed', + outcome_observed_at: movement.observed_at, + outcome_source: 'intent_inventory_spendable_delta', + outcome_reason: 'matched_inventory_delta', + attribution_status: 'heuristic_match', + attribution_method: 'exact_asset_delta_within_window', + attributed_inventory_delta: { + inventory_id: movement.inventory_id, + previous_inventory_id: movement.previous_inventory_id, + observed_at: movement.observed_at, + previous_observed_at: movement.previous_observed_at, + delta_units: movement.delta_units, + attribution_window_ms: attributionWindowMs, + uncertainty: + 'Matched by exact asset-unit delta after submission; no venue terminal event is stored.', + }, + evidence: { + settlement_movement_id: movement.movement_id, + settlement_source: 'intent_inventory_snapshots', + }, + }); +} + +function buildAmbiguousOutcomeRecord({ + submission, + command, + decision, + matches, +}) { + return baseOutcomeRecord({ + submission, + command, + decision, + outcome_status: 'awaiting_outcome', + outcome_observed_at: submission.submitted_at, + outcome_source: 'submission_and_inventory_snapshots', + outcome_reason: 'ambiguous_inventory_delta_match', + attribution_status: 'ambiguous', + attribution_method: null, + attributed_inventory_delta: null, + evidence: { + candidate_movement_count: matches.length, + candidate_movement_ids: matches.map((entry) => entry.movement_id), + }, + }); +} + +function buildNotFilledOutcomeRecord({ + submission, + command, + decision, + settlementGraceMs, + expiredAt, + latestInventoryAt, +}) { + return baseOutcomeRecord({ + submission, + command, + decision, + outcome_status: 'not_filled', + outcome_observed_at: latestInventoryAt || expiredAt || submission.submitted_at, + outcome_source: 'submission_deadline_and_inventory_snapshots', + outcome_reason: 'deadline_elapsed_without_settlement', + attribution_status: 'unattributed', + attribution_method: null, + attributed_inventory_delta: null, + evidence: { + min_deadline_ms: command?.min_deadline_ms || null, + settlement_grace_ms: settlementGraceMs, + settlement_window_expired_at: expiredAt || null, + latest_inventory_observed_at: latestInventoryAt || null, + uncertainty: + 'No matching inventory delta was observed after the quote-response deadline; no venue terminal event is stored.', + }, + }); +} + +function buildSubmittedOutcomeRecord({ + submission, + command, + decision, +}) { + return baseOutcomeRecord({ + submission, + command, + decision, + outcome_status: 'submitted', + outcome_observed_at: submission.submitted_at, + outcome_source: 'executor_submission_result', + outcome_reason: submission.result_code || 'quote_response_submitted', + attribution_status: 'unattributed', + attribution_method: null, + attributed_inventory_delta: null, + evidence: { + uncertainty: 'Quote response was accepted or acknowledged; no settlement evidence is linked yet.', + }, + }); +} + +function baseOutcomeRecord({ + submission, + command, + decision, + outcome_status, + outcome_observed_at, + outcome_source, + outcome_reason, + attribution_status, + attribution_method, + attributed_inventory_delta, + evidence, +}) { + return { + quote_id: submission.quote_id, + decision_id: command?.decision_id || submission.decision_id || decision?.decision_id || null, + command_id: command?.command_id || submission.command_id || null, + execution_result_status: submission.status, + execution_result_code: submission.result_code || null, + submitted_at: submission.submitted_at, + command_at: command?.command_at || null, + outcome_status, + outcome_observed_at, + outcome_source, + outcome_reason, + attribution_status, + attribution_method, + attributed_inventory_delta, + payload: { + quote_id: submission.quote_id, + decision_id: command?.decision_id || submission.decision_id || decision?.decision_id || null, + command_id: command?.command_id || submission.command_id || null, + pair: command?.pair || decision?.pair || submission.pair || null, + direction: decision?.direction || command?.direction || null, + request_kind: command?.request_kind || decision?.request_kind || null, + gross_edge_pct: decision?.gross_edge_pct || null, + eure_notional: decision?.eure_notional || null, + execution_result_status: submission.status, + execution_result_code: submission.result_code || null, + submitted_at: submission.submitted_at, + command_at: command?.command_at || null, + outcome_status, + outcome_observed_at, + outcome_source, + outcome_reason, + attribution_status, + attribution_method, + attributed_inventory_delta, + evidence: { + submission_event_id: submission.event_id || null, + command_id: command?.command_id || submission.command_id || null, + decision_id: command?.decision_id || submission.decision_id || decision?.decision_id || null, + ...evidence, + }, + }, + }; +} + +function movementMatchesExpectedDelta({ + movement, + expectedDelta, + submittedAt, + attributionWindowMs, +}) { + const submittedTs = timestampValue(submittedAt); + const movementTs = timestampValue(movement.observed_at); + if (!Number.isFinite(submittedTs) || !Number.isFinite(movementTs)) return false; + if (movementTs < submittedTs) return false; + if (movementTs - submittedTs > attributionWindowMs) return false; + + for (const [assetId, expected] of Object.entries(expectedDelta)) { + if (safeBigInt(movement.delta_units?.[assetId]) !== expected) return false; + } + + return true; +} + +function getExpiredSettlementWindow({ + submission, + command, + now, + latestInventoryAt, + settlementGraceMs, +}) { + const submittedTs = timestampValue(submission.submitted_at); + if (!Number.isFinite(submittedTs)) return null; + + const deadlineMs = Number(command?.min_deadline_ms || 60_000); + const expiresAt = submittedTs + + (Number.isFinite(deadlineMs) && deadlineMs > 0 ? deadlineMs : 60_000) + + settlementGraceMs; + const nowTs = typeof now === 'number' ? now : timestampValue(now); + const latestInventoryTs = timestampValue(latestInventoryAt); + + const expired = Number.isFinite(nowTs) + && nowTs >= expiresAt + && Number.isFinite(latestInventoryTs) + && latestInventoryTs >= expiresAt; + if (!expired) return null; + return { + expiresAt: new Date(expiresAt).toISOString(), + latestInventoryAt: toIsoTimestamp(latestInventoryAt), + }; +} + +function buildExpectedMakerDeltas(command) { + if (!command?.asset_in || !command?.asset_out || !command?.request_kind) return null; + + const receiveAmount = command.request_kind === 'exact_in' + ? command.amount_in + : command.quote_output?.amount_in || command.proposed_amount_in; + const sendAmount = command.request_kind === 'exact_in' + ? command.quote_output?.amount_out || command.proposed_amount_out + : command.amount_out; + + if (receiveAmount == null || sendAmount == null) return null; + + return { + [command.asset_in]: safeBigInt(receiveAmount), + [command.asset_out]: -safeBigInt(sendAmount), + }; +} + +function normalizeSubmission(entry) { + const payload = payloadOf(entry); + if (!payload) return null; + return { + event_id: entry?.event_id || payload.event_id || null, + quote_id: payload.quote_id || entry?.quote_id || null, + command_id: payload.command_id || null, + decision_id: payload.decision_id || null, + pair: payload.pair || null, + status: payload.status || null, + result_code: payload.result_code || null, + submitted_at: toIsoTimestamp( + entry?.observed_at + || entry?.ingested_at + || payload.observed_at + || payload.ingested_at + || payload.submitted_at, + ), + }; +} + +function normalizeCommand(entry) { + const payload = payloadOf(entry); + if (!payload) return null; + return { + command_id: payload.command_id || entry?.command_id || null, + decision_id: payload.decision_id || entry?.decision_id || null, + quote_id: payload.quote_id || entry?.quote_id || null, + pair: payload.pair || null, + direction: payload.direction || null, + request_kind: payload.request_kind || null, + asset_in: payload.asset_in || null, + asset_out: payload.asset_out || null, + amount_in: payload.amount_in ?? null, + amount_out: payload.amount_out ?? null, + quote_output: payload.quote_output || {}, + proposed_amount_in: payload.proposed_amount_in ?? null, + proposed_amount_out: payload.proposed_amount_out ?? null, + min_deadline_ms: payload.min_deadline_ms ?? null, + command_at: toIsoTimestamp( + entry?.observed_at + || entry?.ingested_at + || payload.observed_at + || payload.ingested_at + || payload.command_at, + ), + }; +} + +function normalizeDecision(entry) { + const payload = payloadOf(entry); + if (!payload) return null; + return { + decision_id: payload.decision_id || entry?.decision_id || null, + quote_id: payload.quote_id || entry?.quote_id || null, + pair: payload.pair || null, + direction: payload.direction || null, + request_kind: payload.request_kind || null, + gross_edge_pct: payload.gross_edge_pct || null, + eure_notional: payload.eure_notional || null, + }; +} + +function normalizeInventorySnapshot(entry) { + const payload = payloadOf(entry); + if (!payload?.spendable) return null; + return { + inventory_id: payload.inventory_id || null, + observed_at: toIsoTimestamp( + entry?.observed_at + || entry?.ingested_at + || payload.observed_at + || payload.synced_at, + ), + spendable: payload.spendable || {}, + }; +} + +function latestSnapshotTimestamp(inventorySnapshots) { + const timestamps = inventorySnapshots + .map((entry) => normalizeInventorySnapshot(entry)?.observed_at) + .filter(Boolean) + .sort((left, right) => timestampValue(right) - timestampValue(left)); + return timestamps[0] || null; +} + +function payloadOf(entry) { + if (!entry) return null; + return entry.payload || entry; +} + +function safeBigInt(value) { + if (value == null || value === '') return 0n; + return BigInt(String(value)); +} + +function toIsoTimestamp(value) { + if (!value) return null; + const date = new Date(value); + return Number.isNaN(date.getTime()) ? null : date.toISOString(); +} + +function timestampValue(value) { + const parsed = Date.parse(value || ''); + return Number.isFinite(parsed) ? parsed : NaN; +} diff --git a/src/lib/config.mjs b/src/lib/config.mjs index 57051df..3f9c355 100644 --- a/src/lib/config.mjs +++ b/src/lib/config.mjs @@ -62,7 +62,7 @@ const DEFAULTS = { 'https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies=eur', inventorySyncRefreshMs: 15_000, liquidityRefreshMs: 30_000, - strategyGrossThresholdPct: 2, + strategyGrossThresholdPct: 1.49, strategyInitialArmed: false, strategyMaxNotionalEure: 5, strategyPriceMaxAgeMs: 30_000, diff --git a/src/lib/postgres.mjs b/src/lib/postgres.mjs index b4a2a23..721ca84 100644 --- a/src/lib/postgres.mjs +++ b/src/lib/postgres.mjs @@ -1,5 +1,7 @@ import { Pool } from 'pg'; +import { deriveQuoteOutcomeRecords } from '../core/quote-outcomes.mjs'; + const TABLES = [ 'raw_near_intents_quotes', 'swap_demand_events', @@ -14,6 +16,7 @@ const TABLES = [ ]; const PORTFOLIO_METRICS_TABLE = 'portfolio_metrics_snapshots'; +const QUOTE_OUTCOMES_TABLE = 'quote_outcome_attributions'; const CREDITED_LIQUIDITY_STATUSES = ['CREDITED', 'COMPLETED', 'FINALIZED', 'SETTLED']; const COMPLETED_WITHDRAWAL_STATUSES = ['COMPLETED', 'FINALIZED', 'SETTLED']; @@ -109,6 +112,34 @@ export async function ensureHistorySchema(pool) { CREATE INDEX IF NOT EXISTS ${PORTFOLIO_METRICS_TABLE}_computed_at_idx ON ${PORTFOLIO_METRICS_TABLE} (computed_at DESC) `); + + await pool.query(` + CREATE TABLE IF NOT EXISTS ${QUOTE_OUTCOMES_TABLE} ( + quote_id TEXT PRIMARY KEY, + decision_id TEXT, + command_id TEXT, + execution_result_status TEXT NOT NULL, + execution_result_code TEXT, + submitted_at TIMESTAMPTZ, + command_at TIMESTAMPTZ, + outcome_status TEXT NOT NULL, + outcome_observed_at TIMESTAMPTZ, + outcome_source TEXT NOT NULL, + attribution_status TEXT NOT NULL, + attribution_method TEXT, + attributed_inventory_delta JSONB, + computed_at TIMESTAMPTZ NOT NULL, + payload JSONB NOT NULL + ) + `); + await pool.query(` + CREATE INDEX IF NOT EXISTS ${QUOTE_OUTCOMES_TABLE}_outcome_observed_at_idx + ON ${QUOTE_OUTCOMES_TABLE} (outcome_observed_at DESC) + `); + await pool.query(` + CREATE INDEX IF NOT EXISTS ${QUOTE_OUTCOMES_TABLE}_outcome_status_idx + ON ${QUOTE_OUTCOMES_TABLE} (outcome_status) + `); } export async function insertHistoryEvent(pool, { table, topic, event, record }) { @@ -260,6 +291,176 @@ export async function loadLatestPortfolioMetric(pool) { return normalizePortfolioMetricRow(result.rows[0]); } +export async function refreshQuoteOutcomes(pool, { + btcAsset = null, + eureAsset = null, + now = Date.now(), +} = {}) { + if (!btcAsset?.assetId || !eureAsset?.assetId) return []; + + const [ + submissionsResult, + commandsResult, + decisionsResult, + inventoryResult, + ] = await Promise.all([ + pool.query(` + SELECT event_id, observed_at, ingested_at, quote_id, payload + FROM trade_execution_results + WHERE payload->>'status' = 'submitted' + ORDER BY COALESCE(observed_at, ingested_at) ASC + `), + pool.query(` + SELECT event_id, observed_at, ingested_at, quote_id, payload + FROM execute_trade_commands + ORDER BY COALESCE(observed_at, ingested_at) ASC + `), + pool.query(` + SELECT event_id, observed_at, ingested_at, quote_id, payload + FROM trade_decisions + ORDER BY COALESCE(observed_at, ingested_at) ASC + `), + pool.query(` + SELECT event_id, observed_at, ingested_at, quote_id, payload + FROM intent_inventory_snapshots + ORDER BY COALESCE(observed_at, ingested_at) ASC + `), + ]); + + const records = deriveQuoteOutcomeRecords({ + submissions: submissionsResult.rows, + commands: commandsResult.rows, + decisions: decisionsResult.rows, + inventorySnapshots: inventoryResult.rows, + btcAsset, + eureAsset, + now, + }); + + if (!records.length) return []; + + const computedAt = new Date( + typeof now === 'number' ? now : Date.parse(now), + ).toISOString(); + for (const record of records) { + await upsertQuoteOutcome(pool, { + ...record, + computedAt, + }); + } + + return records; +} + +export async function upsertQuoteOutcome(pool, { + quote_id, + decision_id = null, + command_id = null, + execution_result_status, + execution_result_code = null, + submitted_at = null, + command_at = null, + outcome_status, + outcome_observed_at = null, + outcome_source, + attribution_status, + attribution_method = null, + attributed_inventory_delta = null, + computedAt, + payload, +}) { + await pool.query( + ` + INSERT INTO ${QUOTE_OUTCOMES_TABLE} ( + quote_id, + decision_id, + command_id, + execution_result_status, + execution_result_code, + submitted_at, + command_at, + outcome_status, + outcome_observed_at, + outcome_source, + attribution_status, + attribution_method, + attributed_inventory_delta, + computed_at, + payload + ) VALUES ( + $1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13::jsonb,$14,$15::jsonb + ) + ON CONFLICT (quote_id) DO UPDATE SET + decision_id = EXCLUDED.decision_id, + command_id = EXCLUDED.command_id, + execution_result_status = EXCLUDED.execution_result_status, + execution_result_code = EXCLUDED.execution_result_code, + submitted_at = EXCLUDED.submitted_at, + command_at = EXCLUDED.command_at, + outcome_status = EXCLUDED.outcome_status, + outcome_observed_at = EXCLUDED.outcome_observed_at, + outcome_source = EXCLUDED.outcome_source, + attribution_status = EXCLUDED.attribution_status, + attribution_method = EXCLUDED.attribution_method, + attributed_inventory_delta = EXCLUDED.attributed_inventory_delta, + computed_at = EXCLUDED.computed_at, + payload = EXCLUDED.payload + `, + [ + quote_id, + decision_id, + command_id, + execution_result_status, + execution_result_code, + submitted_at, + command_at, + outcome_status, + outcome_observed_at, + outcome_source, + attribution_status, + attribution_method, + attributed_inventory_delta ? JSON.stringify(attributed_inventory_delta) : null, + computedAt, + JSON.stringify(payload || {}), + ], + ); +} + +export async function loadRecentQuoteOutcomes(pool, { limit = 200 } = {}) { + const result = await pool.query( + ` + SELECT + quote_id, + decision_id, + command_id, + execution_result_status, + execution_result_code, + submitted_at, + command_at, + outcome_status, + outcome_observed_at, + outcome_source, + attribution_status, + attribution_method, + attributed_inventory_delta, + computed_at, + payload + FROM ${QUOTE_OUTCOMES_TABLE} + ORDER BY + CASE outcome_status + WHEN 'completed' THEN 0 + WHEN 'not_filled' THEN 1 + ELSE 2 + END, + COALESCE(outcome_observed_at, submitted_at, computed_at) DESC + LIMIT $1 + `, + [Math.max(1, Number(limit) || 200)], + ); + + return result.rows.map(normalizeQuoteOutcomeRow); +} + export async function loadLatestInventorySnapshot(pool) { const latest = await loadLatestEventPayload(pool, 'intent_inventory_snapshots'); if (!latest) return null; @@ -325,12 +526,15 @@ export async function loadSubmissionPage(pool, { page = 1, pageSize = 20 } = {}) r.ingested_at AS result_ingested_at, r.payload AS result_payload, c.payload AS command_payload, - d.payload AS decision_payload + d.payload AS decision_payload, + o.payload AS outcome_payload FROM trade_execution_results r LEFT JOIN execute_trade_commands c ON c.decision_key = r.decision_key LEFT JOIN trade_decisions d ON d.decision_key = COALESCE(c.payload->>'decision_id', r.payload->>'decision_id') + LEFT JOIN ${QUOTE_OUTCOMES_TABLE} o + ON o.quote_id = r.quote_id WHERE r.payload->>'status' = 'submitted' ORDER BY COALESCE(r.observed_at, r.ingested_at) DESC LIMIT $1 @@ -360,12 +564,15 @@ export async function loadRecentExecutionResults(pool, { limit = 20 } = {}) { r.payload AS result_payload, c.ingested_at AS command_ingested_at, c.payload AS command_payload, - d.payload AS decision_payload + d.payload AS decision_payload, + o.payload AS outcome_payload FROM trade_execution_results r LEFT JOIN execute_trade_commands c ON c.decision_key = r.decision_key LEFT JOIN trade_decisions d ON d.decision_key = COALESCE(c.payload->>'decision_id', r.payload->>'decision_id') + LEFT JOIN ${QUOTE_OUTCOMES_TABLE} o + ON o.quote_id = r.quote_id ORDER BY COALESCE(r.observed_at, r.ingested_at) DESC LIMIT $1 `, @@ -662,6 +869,36 @@ function normalizePortfolioMetricRow(row) { }; } +function normalizeQuoteOutcomeRow(row) { + const payload = row.payload || {}; + return { + quote_id: row.quote_id || payload.quote_id || null, + decision_id: row.decision_id || payload.decision_id || null, + command_id: row.command_id || payload.command_id || null, + pair: payload.pair || null, + direction: payload.direction || null, + request_kind: payload.request_kind || null, + gross_edge_pct: payload.gross_edge_pct || null, + 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, + 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, + outcome_observed_at: toIsoTimestamp(row.outcome_observed_at || payload.outcome_observed_at), + outcome_source: row.outcome_source || payload.outcome_source || null, + outcome_reason: payload.outcome_reason || null, + attribution_status: row.attribution_status || payload.attribution_status || null, + attribution_method: row.attribution_method || payload.attribution_method || null, + attributed_inventory_delta: + row.attributed_inventory_delta + || payload.attributed_inventory_delta + || null, + computed_at: toIsoTimestamp(row.computed_at), + evidence: payload.evidence || null, + }; +} + function normalizeRecentQuoteRow(row) { const payload = row.payload || {}; return { @@ -682,6 +919,7 @@ function normalizeSubmissionRow(row) { const resultPayload = row.result_payload || {}; const commandPayload = row.command_payload || {}; const decisionPayload = row.decision_payload || {}; + const outcomePayload = row.outcome_payload || {}; return { command_id: resultPayload.command_id || commandPayload.command_id || null, @@ -697,6 +935,10 @@ function normalizeSubmissionRow(row) { ingested_at: toIsoTimestamp(row.result_ingested_at), status: resultPayload.status || null, result_code: resultPayload.result_code || null, + outcome_status: outcomePayload.outcome_status || null, + outcome_reason: outcomePayload.outcome_reason || null, + attribution_status: outcomePayload.attribution_status || null, + attributed_inventory_delta: outcomePayload.attributed_inventory_delta || null, request_kind: commandPayload.request_kind || decisionPayload.request_kind || null, asset_in: commandPayload.asset_in || null, asset_out: commandPayload.asset_out || null, @@ -733,6 +975,7 @@ function normalizeExecutionResultRow(row) { const resultPayload = row.result_payload || {}; const commandPayload = row.command_payload || {}; const decisionPayload = row.decision_payload || {}; + const outcomePayload = row.outcome_payload || {}; return { command_id: resultPayload.command_id || commandPayload.command_id || null, @@ -749,15 +992,22 @@ function normalizeExecutionResultRow(row) { status: resultPayload.status || null, result_code: resultPayload.result_code || null, outcome_status: - resultPayload.outcome_status + outcomePayload.outcome_status + || resultPayload.outcome_status || resultPayload.venue_outcome_status || resultPayload.trade_outcome_status || null, outcome_reason: - resultPayload.outcome_reason + outcomePayload.outcome_reason + || resultPayload.outcome_reason || resultPayload.venue_outcome_reason || resultPayload.trade_outcome_reason || null, + outcome_source: outcomePayload.outcome_source || null, + attribution_status: outcomePayload.attribution_status || null, + attribution_method: outcomePayload.attribution_method || null, + 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, note: resultPayload.note || null, diff --git a/src/operator-dashboard/static/pages/FundsPage.jsx b/src/operator-dashboard/static/pages/FundsPage.jsx index f2e92c9..badf17c 100644 --- a/src/operator-dashboard/static/pages/FundsPage.jsx +++ b/src/operator-dashboard/static/pages/FundsPage.jsx @@ -380,13 +380,13 @@ export default function FundsPage({ : 'Baseline anchored before first live trade'; const simpleHoldMeta = externalFlowAdjusted ? 'Simple hold includes later credited deposits and completed withdrawals' - : 'Trading contribution over simple hold'; + : 'Comparison against a no-trade simple-hold baseline'; const marketMoveMeta = externalFlowAdjusted ? 'Simple-hold market move on baseline plus later external flows' : 'Baseline mark move only'; - const tradingContributionMeta = externalFlowAdjusted - ? 'Current minus cash-flow-adjusted simple hold' - : 'Current minus simple hold'; + const portfolioVsHoldMeta = externalFlowAdjusted + ? 'Current minus cash-flow-adjusted simple hold; not realized trade PnL' + : 'Current minus simple hold; not realized trade PnL'; return ( <> @@ -441,10 +441,10 @@ export default function FundsPage({ value={formatEur(profitability.market_move_contribution_eure)} /> {truncateMiddle(item.pair || '', 32)} + {item.reason_text} - Linked asset delta not exposed yet + +
{item.settlement_summary?.text || 'No settled inventory delta is linked to this quote.'}
+ {item.settlement_summary?.method ? ( +
{item.settlement_summary.method}
+ ) : null} + {item.settlement_summary?.observed_at ? ( +
{`Observed ${formatTimestamp(item.settlement_summary.observed_at)}`}
+ ) : null} + {item.settlement_summary?.caveat ? ( +
{item.settlement_summary.caveat}
+ ) : null} + ))} diff --git a/src/operator-dashboard/static/pages/SystemPage.jsx b/src/operator-dashboard/static/pages/SystemPage.jsx index 7d26a1e..c9cff82 100644 --- a/src/operator-dashboard/static/pages/SystemPage.jsx +++ b/src/operator-dashboard/static/pages/SystemPage.jsx @@ -58,6 +58,11 @@ export default function SystemPage({ system, onControl }) { meta={system.persistence.metrics_error ? 'Metrics error present' : 'Metrics healthy'} value={formatTimestamp(system.persistence.last_metrics_at)} /> + diff --git a/src/venues/near-intents/solver-relay-ws.mjs b/src/venues/near-intents/solver-relay-ws.mjs index 0da4c2a..fbb6d5d 100644 --- a/src/venues/near-intents/solver-relay-ws.mjs +++ b/src/venues/near-intents/solver-relay-ws.mjs @@ -16,6 +16,11 @@ export async function startSolverRelayWs({ let requestId = 1; const pending = new Map(); let readyResolvers = []; + let reconnectCount = 0; + let lastMessageAt = null; + let lastConnectedAt = null; + let lastDisconnectedAt = null; + let lastReconnectAt = null; connect(); @@ -55,8 +60,20 @@ export async function startSolverRelayWs({ return { connected, pending_requests: pending.size, + reconnect_count: reconnectCount, + last_message_at: lastMessageAt, + last_connected_at: lastConnectedAt, + last_disconnected_at: lastDisconnectedAt, + last_reconnect_at: lastReconnectAt, }; }, + reconnect() { + if (socket && socket.readyState <= 1) { + socket.close(); + } else { + connect(); + } + }, close() { closed = true; if (reconnectTimer) clearTimeout(reconnectTimer); @@ -67,6 +84,8 @@ export async function startSolverRelayWs({ function connect() { if (closed) return; + reconnectCount += 1; + lastReconnectAt = new Date().toISOString(); socket = new WebSocket(wsUrl, { headers: { @@ -76,6 +95,7 @@ export async function startSolverRelayWs({ socket.addEventListener('open', () => { connected = true; + lastConnectedAt = new Date().toISOString(); logger?.info('connection_established', { venue: 'near-intents', }); @@ -92,6 +112,7 @@ export async function startSolverRelayWs({ }); socket.addEventListener('message', (event) => { + lastMessageAt = new Date().toISOString(); const text = typeof event.data === 'string' ? event.data : Buffer.from(event.data).toString('utf8'); let payload; try { @@ -116,6 +137,7 @@ export async function startSolverRelayWs({ socket.addEventListener('close', () => { connected = false; + lastDisconnectedAt = new Date().toISOString(); rejectAllPending(new Error('Socket disconnected')); logger?.warn('connection_lost', { venue: 'near-intents', diff --git a/src/venues/near-intents/ws.mjs b/src/venues/near-intents/ws.mjs index 2cf5afb..fdaad8c 100644 --- a/src/venues/near-intents/ws.mjs +++ b/src/venues/near-intents/ws.mjs @@ -38,9 +38,15 @@ export async function startNearIntentsWs({ let lastMatchingQuoteAt = null; let lastPublishedAt = null; let lastPublishedPair = null; + let reconnectCount = 0; + let lastConnectedAt = null; + let lastDisconnectedAt = null; + let lastReconnectAt = null; function connect() { if (closed) return; + reconnectCount += 1; + lastReconnectAt = new Date().toISOString(); const ws = new WebSocket(wsUrl, { headers: { Authorization: `Bearer ${apiKey}` }, @@ -49,6 +55,7 @@ export async function startNearIntentsWs({ ws.addEventListener('open', () => { connected = true; + lastConnectedAt = new Date().toISOString(); logger?.info('connection_established', { namespace, }); @@ -133,6 +140,7 @@ export async function startNearIntentsWs({ ws.addEventListener('close', () => { connected = false; activeSocket = null; + lastDisconnectedAt = new Date().toISOString(); logger?.warn('connection_lost', { namespace, details: { @@ -160,6 +168,7 @@ export async function startNearIntentsWs({ getState() { return { connected, + reconnect_count: reconnectCount, frames_received: framesReceived, quote_frames_received: quoteFramesReceived, filtered_count: filteredCount, @@ -170,10 +179,20 @@ export async function startNearIntentsWs({ last_matching_quote_at: lastMatchingQuoteAt, last_published_at: lastPublishedAt, last_published_pair: lastPublishedPair, + last_connected_at: lastConnectedAt, + last_disconnected_at: lastDisconnectedAt, + last_reconnect_at: lastReconnectAt, raw_topic: rawTopic, normalized_topic: normalizedTopic, }; }, + reconnect() { + if (activeSocket && activeSocket.readyState <= 1) { + activeSocket.close(); + } else { + connect(); + } + }, close() { closed = true; if (reconnectTimer) clearTimeout(reconnectTimer); diff --git a/test/executor-state-store.test.mjs b/test/executor-state-store.test.mjs index 0824233..200bc77 100644 --- a/test/executor-state-store.test.mjs +++ b/test/executor-state-store.test.mjs @@ -6,17 +6,37 @@ import path from 'node:path'; import { createExecutorStateStore } from '../src/core/executor-state-store.mjs'; -test('executor state store persists processing and completion state', () => { +test('executor state store persists processing and submission state', () => { const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'unrip-executor-')); const store = createExecutorStateStore({ stateDir }); store.markProcessing('cmd-1', { quote_id: 'quote-1' }); assert.equal(store.get('cmd-1').status, 'processing'); - store.markCompleted('cmd-1', { result_event_id: 'result-1' }); - assert.equal(store.get('cmd-1').status, 'completed'); + store.markSubmitted('cmd-1', { result_event_id: 'result-1' }); + assert.equal(store.get('cmd-1').status, 'submitted'); const reloaded = createExecutorStateStore({ stateDir }); - assert.equal(reloaded.get('cmd-1').status, 'completed'); + assert.equal(reloaded.get('cmd-1').status, 'submitted'); assert.equal(reloaded.get('cmd-1').result_event_id, 'result-1'); }); + +test('executor state store normalizes legacy completed markers to submitted', () => { + const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'unrip-executor-')); + const statePath = path.join(stateDir, 'trade-executor-commands.json'); + fs.writeFileSync( + statePath, + JSON.stringify({ + commands: { + 'cmd-legacy': { + status: 'completed', + result_event_id: 'result-legacy', + }, + }, + }), + ); + + const store = createExecutorStateStore({ stateDir }); + assert.equal(store.get('cmd-legacy').status, 'submitted'); + assert.equal(store.getState().commands['cmd-legacy'].status, 'submitted'); +}); diff --git a/test/operator-dashboard.test.mjs b/test/operator-dashboard.test.mjs index 66f6988..e5efe57 100644 --- a/test/operator-dashboard.test.mjs +++ b/test/operator-dashboard.test.mjs @@ -42,7 +42,7 @@ function buildConfig() { }; } -test('profitability summary separates baseline, hold, market move, and trading contribution', () => { +test('profitability summary separates baseline, hold, market move, and portfolio comparison', () => { const summary = buildProfitabilitySummary({ metric: { computed_at: '2026-04-04T09:05:00.000Z', @@ -63,7 +63,9 @@ test('profitability summary separates baseline, hold, market move, and trading c assert.equal(summary.pnl_vs_deposit_baseline_eure, '10'); assert.equal(summary.pnl_vs_simple_hold_eure, '5'); assert.equal(summary.market_move_contribution_eure, '5'); - assert.equal(summary.trading_contribution_eure, '5'); + assert.equal(summary.portfolio_vs_simple_hold_eure, '5'); + assert.equal(summary.trading_contribution_eure, null); + assert.match(summary.caveats.join(' '), /not realized per-trade PnL/i); assert.equal(summary.computed_at, '2026-04-04T09:05:00.000Z'); assert.equal(summary.recent_submission_count, 4); assert.equal(summary.last_submission_at, '2026-04-04T09:00:00.000Z'); @@ -489,7 +491,7 @@ test('bootstrap aggregation keeps Funds as default and carries live control stat paused: false, draining: false, in_flight_count: 0, - completed_count: 1, + submitted_count: 1, }, }, { @@ -591,7 +593,7 @@ test('bootstrap normalizes actionable decision vocabulary before exposing it to assert.doesNotMatch(JSON.stringify(bootstrap), /Actionable/); }); -test('completed lifecycle evidence is the only source of successful trade rows', () => { +test('submitted lifecycle evidence never becomes completed by itself', () => { const rows = deriveQuoteLifecycleRows({ recentExecutionResults: [ { @@ -601,25 +603,125 @@ test('completed lifecycle evidence is the only source of successful trade rows', status: 'submitted', result_code: 'quote_response_ok', }, - { - command_id: 'cmd-completed', - quote_id: 'quote-completed', - result_at: '2026-04-09T09:01:00.000Z', - status: 'submitted', - result_code: 'quote_response_ok', - outcome_status: 'completed', - outcome_reason: 'settled', - }, ], }); const completed = rows.filter((row) => row.lifecycle_state === 'completed'); const submitted = rows.filter((row) => row.lifecycle_state === 'submitted'); - assert.equal(completed.length, 1); - assert.equal(completed[0].quote_id, 'quote-completed'); + assert.equal(completed.length, 0); assert.equal(submitted.length, 1); assert.equal(submitted[0].quote_id, 'quote-submitted'); + assert.equal(submitted[0].has_settlement_evidence, false); + assert.doesNotMatch(`${submitted[0].lifecycle_label} ${submitted[0].reason_text}`, /completed|successful trade|asset delta/i); +}); + +test('successful trade rows require completed outcome with linked settled inventory evidence', () => { + const config = buildConfig(); + const bootstrap = buildDashboardBootstrap({ + config, + auth: { + authenticated: true, + subject: 'local-operator', + mode: 'stub', + roles: ['operator'], + }, + portfolioMetric: null, + inventorySnapshot: null, + marketPrice: null, + recentQuotes: [], + submissionPage: { page: 1, page_size: 20, total: 0, total_pages: 1, items: [] }, + submissionSummary: { total: 0, last_submission_at: null }, + fundingObservations: [], + recentDepositStatuses: [], + recentTradeDecisions: [], + recentExecuteTradeCommands: [], + recentExecutionResults: [{ + command_id: 'cmd-submitted', + quote_id: 'quote-submitted', + result_at: '2026-04-09T09:00:00.000Z', + status: 'submitted', + result_code: 'quote_response_ok', + }], + recentQuoteOutcomes: [ + { + command_id: 'cmd-completed', + quote_id: 'quote-completed', + pair: config.activePair, + outcome_status: 'completed', + outcome_reason: 'matched_inventory_delta', + outcome_observed_at: '2026-04-09T09:01:00.000Z', + outcome_source: 'intent_inventory_spendable_delta', + attribution_status: 'heuristic_match', + attribution_method: 'exact_asset_delta_within_window', + attributed_inventory_delta: { + observed_at: '2026-04-09T09:01:00.000Z', + delta_units: { + [config.tradingBtc.assetId]: '37014', + [config.tradingEure.assetId]: '-21000021200021200022', + }, + }, + }, + { + command_id: 'cmd-completed-no-delta', + quote_id: 'quote-completed-no-delta', + pair: config.activePair, + outcome_status: 'completed', + outcome_reason: 'settled', + outcome_observed_at: '2026-04-09T09:02:00.000Z', + outcome_source: 'venue_status_without_settlement', + attribution_status: 'unattributed', + attribution_method: null, + attributed_inventory_delta: null, + }, + ], + recentAlertTransitions: [], + serviceSnapshots: [], + }); + + const funnel = bootstrap.strategy.strategy_state.trade_funnel; + assert.equal(funnel.successful_trade_count, 1); + assert.equal(funnel.successful_trades[0].quote_id, 'quote-completed'); + assert.match(funnel.successful_trades[0].settlement_summary.text, /\+0\.00037014 BTC/); + assert.equal(funnel.counts.submitted, 1); + assert.equal(funnel.counts.completed, 2); +}); + +test('executor blocking is distinct from strategy rejection', () => { + const [row] = deriveQuoteLifecycleRows({ + recentTradeDecisions: [{ + observed_at: '2026-04-09T09:00:01.000Z', + payload: { + decision_id: 'decision-1', + quote_id: 'quote-1', + pair: 'btc->eure', + decision: 'actionable', + decision_reason: 'actionable', + }, + }], + recentExecuteTradeCommands: [{ + observed_at: '2026-04-09T09:00:02.000Z', + command_id: 'cmd-1', + decision_id: 'decision-1', + quote_id: 'quote-1', + pair: 'btc->eure', + }], + recentExecutionResults: [{ + command_id: 'cmd-1', + decision_id: 'decision-1', + quote_id: 'quote-1', + pair: 'btc->eure', + result_at: '2026-04-09T09:00:03.000Z', + status: 'rejected', + result_code: 'executor_disarmed', + note: 'executor is disarmed', + }], + }); + + assert.equal(row.lifecycle_state, 'blocked'); + assert.equal(row.lifecycle_label, 'Blocked before submit'); + assert.equal(row.reason_code, 'executor_disarmed'); + assert.notEqual(row.lifecycle_label, 'Rejected by strategy'); }); test('system service state ignores sentinel alert severity and keeps alert surfaces empty', () => { @@ -1002,7 +1104,7 @@ test('funding summary includes credited bridge deposits without observer-backed paused: false, draining: false, in_flight_count: 0, - completed_count: 0, + submitted_count: 0, }, }, { diff --git a/test/portfolio-metrics.test.mjs b/test/portfolio-metrics.test.mjs index 140572a..ad34003 100644 --- a/test/portfolio-metrics.test.mjs +++ b/test/portfolio-metrics.test.mjs @@ -15,7 +15,7 @@ const eureAsset = { decimals: 18, }; -test('portfolio metrics compute trade pnl and mark-to-market pnl from baseline funding inventory', () => { +test('portfolio metrics compute portfolio comparison and mark-to-market pnl from baseline funding inventory', () => { const metric = computePortfolioMetric({ baseline: { anchor: 'latest_inventory_before_first_command', @@ -55,9 +55,10 @@ test('portfolio metrics compute trade pnl and mark-to-market pnl from baseline f assert.equal(metric.baseline_status, 'active'); assert.equal(metric.current_portfolio_value_eure, '118.576613887978799978'); - assert.equal(metric.trade_pnl_eure, '0.391183707978799978'); - assert.equal(metric.mark_to_market_pnl_eure, '0.497413887978799978'); - assert.equal(metric.price_move_pnl_eure, '0.10623018'); + assert.equal(metric.portfolio_vs_simple_hold_eure, '0.497413887978799978'); + assert.equal(metric.trade_pnl_eure, null); + assert.equal(metric.mark_to_market_pnl_eure, '0.784413887978799978'); + assert.equal(metric.price_move_pnl_eure, '0.287'); assert.deepEqual(metric.inventory_delta, { btc_units: '37014', btc: '0.00037014', @@ -90,11 +91,79 @@ test('portfolio metrics stay available before the first live execution', () => { assert.equal(metric.baseline_status, 'awaiting_first_execution'); assert.equal(metric.current_portfolio_value_eure, '118.0792'); + assert.equal(metric.portfolio_vs_simple_hold_eure, null); assert.equal(metric.trade_pnl_eure, null); assert.equal(metric.mark_to_market_pnl_eure, null); assert.equal(metric.price_move_pnl_eure, null); }); +test('portfolio metrics treat later deposits and withdrawals as external cash flows instead of PnL', () => { + const metric = computePortfolioMetric({ + baseline: { + anchor: 'latest_inventory_before_first_command', + command_at: '2026-04-02T18:10:43.569Z', + inventory: { + inventory_id: 'baseline-2', + synced_at: '2026-04-02T18:10:33.381Z', + spendable: { + 'nep141:btc.omft.near': '100000', + 'nep141:eure.omft.near': '60000000000000000000', + }, + }, + price: { + price_id: 'price-baseline-2', + observed_at: '2026-04-02T18:10:30.109Z', + eure_per_btc: '57792.20000000', + }, + }, + currentInventory: { + inventory_id: 'current-3', + synced_at: '2026-04-07T15:43:30.463Z', + spendable: { + 'nep141:btc.omft.near': '137014', + 'nep141:eure.omft.near': '63999978599978799978', + }, + }, + currentPrice: { + price_id: 'price-current-3', + observed_at: '2026-04-07T15:43:29.885Z', + eure_per_btc: '58845.90000000', + }, + externalFlows: [ + { + flow_id: 'withdrawal-1', + kind: 'withdrawal', + asset_id: eureAsset.assetId, + effective_at: '2026-04-02T10:52:27.863Z', + signed_units: '-1000000000000000000', + reference_price_eure_per_btc_at_flow_time: null, + }, + { + flow_id: 'deposit-1', + kind: 'deposit', + asset_id: eureAsset.assetId, + effective_at: '2026-04-07T15:20:54.757Z', + signed_units: '24999999800000000000', + reference_price_eure_per_btc_at_flow_time: null, + }, + ], + btcAsset, + eureAsset, + commandCount: 7, + resultCount: 7, + }); + + assert.equal(metric.external_cash_flows.flow_count, 2); + assert.equal(metric.external_cash_flows.net_eure, '23.9999998'); + assert.equal(metric.external_cash_flows.net_value_eure_at_flow_time, '23.9999998'); + assert.equal(metric.baseline_portfolio_value_eure_at_baseline_price, '141.7921998'); + assert.equal(metric.baseline_portfolio_value_eure_at_current_price, '142.8458998'); + assert.equal(metric.mark_to_market_pnl_eure, '2.834900225978799978'); + assert.equal(metric.price_move_pnl_eure, '1.0537'); + assert.equal(metric.portfolio_vs_simple_hold_eure, '1.781200225978799978'); + assert.equal(metric.trade_pnl_eure, null); +}); + test('portfolio metric id keys off the baseline and current snapshots', () => { const metricId = buildPortfolioMetricId({ baselineInventoryId: 'baseline-1', diff --git a/test/quote-outcomes.test.mjs b/test/quote-outcomes.test.mjs new file mode 100644 index 0000000..a73c47b --- /dev/null +++ b/test/quote-outcomes.test.mjs @@ -0,0 +1,166 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { deriveQuoteOutcomeRecords } from '../src/core/quote-outcomes.mjs'; + +const BTC = { + assetId: 'nep141:btc.omft.near', + symbol: 'BTC', + decimals: 8, +}; +const EURE = { + assetId: 'nep141:eure.omft.near', + symbol: 'EURe', + decimals: 18, +}; + +function submittedResult(quoteId, observedAt = '2026-04-02T18:13:30.000Z') { + return { + observed_at: observedAt, + payload: { + quote_id: quoteId, + command_id: `cmd-${quoteId}`, + decision_id: `decision-${quoteId}`, + status: 'submitted', + result_code: 'quote_response_ok', + }, + }; +} + +function exactOutCommand(quoteId) { + return { + observed_at: '2026-04-02T18:13:29.000Z', + payload: { + quote_id: quoteId, + command_id: `cmd-${quoteId}`, + decision_id: `decision-${quoteId}`, + pair: `${BTC.assetId}->${EURE.assetId}`, + direction: 'eure_to_btc', + request_kind: 'exact_out', + asset_in: BTC.assetId, + asset_out: EURE.assetId, + amount_out: '21000021200021200022', + quote_output: { + amount_in: '37014', + }, + min_deadline_ms: 60000, + }, + }; +} + +function inventorySnapshot(observedAt, spendable) { + return { + observed_at: observedAt, + payload: { + inventory_id: `inventory-${observedAt}`, + synced_at: observedAt, + spendable, + }, + }; +} + +test('quote outcome completes only when exact submitted quote delta is observed', () => { + const [outcome] = deriveQuoteOutcomeRecords({ + submissions: [submittedResult('quote-settled')], + commands: [exactOutCommand('quote-settled')], + inventorySnapshots: [ + inventorySnapshot('2026-04-02T18:13:00.000Z', { + [BTC.assetId]: '0', + [EURE.assetId]: '100000000000000000000', + }), + inventorySnapshot('2026-04-02T18:13:33.000Z', { + [BTC.assetId]: '37014', + [EURE.assetId]: '78999978799978799978', + }), + ], + btcAsset: BTC, + eureAsset: EURE, + now: '2026-04-02T18:14:00.000Z', + }); + + assert.equal(outcome.outcome_status, 'completed'); + assert.equal(outcome.attribution_status, 'heuristic_match'); + assert.equal(outcome.attributed_inventory_delta.delta_units[BTC.assetId], '37014'); + assert.equal(outcome.attributed_inventory_delta.delta_units[EURE.assetId], '-21000021200021200022'); +}); + +test('submitted quote without settlement stays submitted before the deadline window expires', () => { + const [outcome] = deriveQuoteOutcomeRecords({ + submissions: [submittedResult('quote-submitted', '2026-04-02T18:13:30.000Z')], + commands: [exactOutCommand('quote-submitted')], + inventorySnapshots: [ + inventorySnapshot('2026-04-02T18:13:00.000Z', { + [BTC.assetId]: '0', + [EURE.assetId]: '100000000000000000000', + }), + ], + btcAsset: BTC, + eureAsset: EURE, + now: '2026-04-02T18:13:45.000Z', + }); + + assert.equal(outcome.outcome_status, 'submitted'); + assert.equal(outcome.attribution_status, 'unattributed'); + assert.equal(outcome.attributed_inventory_delta, null); +}); + +test('submitted quote without settlement becomes not filled only after deadline and later inventory evidence', () => { + const [outcome] = deriveQuoteOutcomeRecords({ + submissions: [submittedResult('quote-not-filled', '2026-04-02T18:13:30.000Z')], + commands: [exactOutCommand('quote-not-filled')], + inventorySnapshots: [ + inventorySnapshot('2026-04-02T18:13:00.000Z', { + [BTC.assetId]: '0', + [EURE.assetId]: '100000000000000000000', + }), + inventorySnapshot('2026-04-02T18:15:40.000Z', { + [BTC.assetId]: '0', + [EURE.assetId]: '100000000000000000000', + }), + ], + btcAsset: BTC, + eureAsset: EURE, + now: '2026-04-02T18:15:40.000Z', + }); + + assert.equal(outcome.outcome_status, 'not_filled'); + assert.equal(outcome.outcome_reason, 'deadline_elapsed_without_settlement'); + assert.equal(outcome.outcome_observed_at, '2026-04-02T18:15:40.000Z'); + assert.equal(outcome.payload.evidence.latest_inventory_observed_at, '2026-04-02T18:15:40.000Z'); + assert.equal(outcome.attributed_inventory_delta, null); +}); + +test('ambiguous inventory movement is not counted as completed settlement', () => { + const outcomes = deriveQuoteOutcomeRecords({ + submissions: [ + submittedResult('quote-a', '2026-04-02T18:13:30.000Z'), + submittedResult('quote-b', '2026-04-02T18:13:31.000Z'), + ], + commands: [ + exactOutCommand('quote-a'), + exactOutCommand('quote-b'), + ], + inventorySnapshots: [ + inventorySnapshot('2026-04-02T18:13:00.000Z', { + [BTC.assetId]: '0', + [EURE.assetId]: '100000000000000000000', + }), + inventorySnapshot('2026-04-02T18:13:33.000Z', { + [BTC.assetId]: '37014', + [EURE.assetId]: '78999978799978799978', + }), + ], + btcAsset: BTC, + eureAsset: EURE, + now: '2026-04-02T18:14:00.000Z', + }); + + assert.deepEqual( + outcomes.map((entry) => entry.outcome_status), + ['awaiting_outcome', 'awaiting_outcome'], + ); + assert.deepEqual( + outcomes.map((entry) => entry.attribution_status), + ['ambiguous', 'ambiguous'], + ); +});