Link quote outcomes to settled inventory
All checks were successful
deploy / deploy (push) Successful in 32s
All checks were successful
deploy / deploy (push) Successful in 32s
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.
This commit is contained in:
parent
3fca125cdd
commit
e0dfd24a8b
20 changed files with 1739 additions and 81 deletions
|
|
@ -40,6 +40,16 @@ data:
|
||||||
TRADE_EXECUTOR_CONTROL_PORT: "8087"
|
TRADE_EXECUTOR_CONTROL_PORT: "8087"
|
||||||
OPS_SENTINEL_CONTROL_HOST: 0.0.0.0
|
OPS_SENTINEL_CONTROL_HOST: 0.0.0.0
|
||||||
OPS_SENTINEL_CONTROL_PORT: "8088"
|
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_BROKERS: redpanda.unrip.svc.cluster.local:9092
|
||||||
KAFKA_CLIENT_ID: unrip
|
KAFKA_CLIENT_ID: unrip
|
||||||
KAFKA_TOPIC_RAW_NEAR_INTENTS_QUOTE: raw.near_intents.quote
|
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_STRATEGY: strategy-engine-v1
|
||||||
KAFKA_CONSUMER_GROUP_EXECUTOR: trade-executor-v1
|
KAFKA_CONSUMER_GROUP_EXECUTOR: trade-executor-v1
|
||||||
KAFKA_CONSUMER_GROUP_OPS_SENTINEL: ops-sentinel-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
|
STRATEGY_STATE_DIR: /var/lib/unrip/strategy-state
|
||||||
EXECUTOR_STATE_DIR: /var/lib/unrip/executor-state
|
EXECUTOR_STATE_DIR: /var/lib/unrip/executor-state
|
||||||
LIQUIDITY_STATE_DIR: /var/lib/unrip/liquidity-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
|
MARKET_REFERENCE_COINGECKO_URL: https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies=eur
|
||||||
INVENTORY_SYNC_REFRESH_MS: "15000"
|
INVENTORY_SYNC_REFRESH_MS: "15000"
|
||||||
LIQUIDITY_REFRESH_MS: "30000"
|
LIQUIDITY_REFRESH_MS: "30000"
|
||||||
STRATEGY_GROSS_THRESHOLD_PCT: "2"
|
STRATEGY_GROSS_THRESHOLD_PCT: "1.49"
|
||||||
STRATEGY_INITIAL_ARMED: "false"
|
STRATEGY_INITIAL_ARMED: "false"
|
||||||
STRATEGY_MAX_NOTIONAL_EURE: "5"
|
STRATEGY_MAX_NOTIONAL_EURE: "150"
|
||||||
STRATEGY_PRICE_MAX_AGE_MS: "30000"
|
STRATEGY_PRICE_MAX_AGE_MS: "30000"
|
||||||
STRATEGY_INVENTORY_MAX_AGE_MS: "30000"
|
STRATEGY_INVENTORY_MAX_AGE_MS: "30000"
|
||||||
EXECUTOR_INITIAL_ARMED: "false"
|
EXECUTOR_INITIAL_ARMED: "false"
|
||||||
|
|
@ -83,6 +94,10 @@ data:
|
||||||
OPS_SENTINEL_INVENTORY_STALE_MS: "30000"
|
OPS_SENTINEL_INVENTORY_STALE_MS: "30000"
|
||||||
OPS_SENTINEL_FUNDING_CREDIT_PENDING_MS: "300000"
|
OPS_SENTINEL_FUNDING_CREDIT_PENDING_MS: "300000"
|
||||||
OPS_SENTINEL_FUNDING_STUCK_MS: "3600000"
|
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
|
apiVersion: v1
|
||||||
kind: PersistentVolumeClaim
|
kind: PersistentVolumeClaim
|
||||||
|
|
@ -117,6 +132,123 @@ spec:
|
||||||
requests:
|
requests:
|
||||||
storage: 5Gi
|
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
|
apiVersion: apps/v1
|
||||||
kind: Deployment
|
kind: Deployment
|
||||||
metadata:
|
metadata:
|
||||||
|
|
@ -393,3 +525,35 @@ spec:
|
||||||
- name: executor-state
|
- name: executor-state
|
||||||
persistentVolumeClaim:
|
persistentVolumeClaim:
|
||||||
claimName: executor-state
|
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
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import {
|
||||||
insertHistoryEvent,
|
insertHistoryEvent,
|
||||||
loadLatestPortfolioMetric,
|
loadLatestPortfolioMetric,
|
||||||
loadPortfolioMetricInputs,
|
loadPortfolioMetricInputs,
|
||||||
|
refreshQuoteOutcomes,
|
||||||
upsertPortfolioMetric,
|
upsertPortfolioMetric,
|
||||||
} from '../lib/postgres.mjs';
|
} from '../lib/postgres.mjs';
|
||||||
|
|
||||||
|
|
@ -54,6 +55,11 @@ const portfolioMetricTopics = new Set([
|
||||||
config.kafkaTopicCmdExecuteTrade,
|
config.kafkaTopicCmdExecuteTrade,
|
||||||
config.kafkaTopicExecTradeResult,
|
config.kafkaTopicExecTradeResult,
|
||||||
]);
|
]);
|
||||||
|
const quoteOutcomeTopics = new Set([
|
||||||
|
config.kafkaTopicStateIntentInventory,
|
||||||
|
config.kafkaTopicCmdExecuteTrade,
|
||||||
|
config.kafkaTopicExecTradeResult,
|
||||||
|
]);
|
||||||
|
|
||||||
for (const topic of topics) {
|
for (const topic of topics) {
|
||||||
await consumer.subscribe({ topic, fromBeginning: false });
|
await consumer.subscribe({ topic, fromBeginning: false });
|
||||||
|
|
@ -71,11 +77,17 @@ const state = {
|
||||||
offsets: {},
|
offsets: {},
|
||||||
latest_portfolio_metrics: null,
|
latest_portfolio_metrics: null,
|
||||||
metrics_error: null,
|
metrics_error: null,
|
||||||
|
last_quote_outcomes_at: null,
|
||||||
|
latest_quote_outcomes: null,
|
||||||
|
quote_outcomes_error: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
await refreshPortfolioMetrics().catch((error) => {
|
await refreshPortfolioMetrics().catch((error) => {
|
||||||
state.metrics_error = serializeError(error);
|
state.metrics_error = serializeError(error);
|
||||||
});
|
});
|
||||||
|
await refreshQuoteOutcomeAttributions().catch((error) => {
|
||||||
|
state.quote_outcomes_error = serializeError(error);
|
||||||
|
});
|
||||||
|
|
||||||
await consumer.run({
|
await consumer.run({
|
||||||
eachMessage: async ({ topic, partition, message }) => {
|
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) {
|
} catch (error) {
|
||||||
state.last_error = serializeError(error);
|
state.last_error = serializeError(error);
|
||||||
state.error_count += 1;
|
state.error_count += 1;
|
||||||
|
|
@ -257,6 +282,22 @@ async function refreshPortfolioMetrics() {
|
||||||
return state.latest_portfolio_metrics;
|
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) {
|
function summarizePortfolioMetric(metric) {
|
||||||
if (!metric) return null;
|
if (!metric) return null;
|
||||||
return {
|
return {
|
||||||
|
|
@ -265,7 +306,7 @@ function summarizePortfolioMetric(metric) {
|
||||||
baseline_anchor_at: metric.baseline_anchor_at,
|
baseline_anchor_at: metric.baseline_anchor_at,
|
||||||
baseline_status: metric.baseline_status,
|
baseline_status: metric.baseline_status,
|
||||||
current_portfolio_value_eure: metric.payload?.current_portfolio_value_eure ?? null,
|
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,
|
mark_to_market_pnl_eure: metric.payload?.mark_to_market_pnl_eure ?? null,
|
||||||
price_move_pnl_eure: metric.payload?.price_move_pnl_eure ?? null,
|
price_move_pnl_eure: metric.payload?.price_move_pnl_eure ?? null,
|
||||||
command_count: metric.payload?.command_count ?? 0,
|
command_count: metric.payload?.command_count ?? 0,
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ import {
|
||||||
loadRecentDepositStatuses,
|
loadRecentDepositStatuses,
|
||||||
loadRecentExecuteTradeCommands,
|
loadRecentExecuteTradeCommands,
|
||||||
loadRecentExecutionResults,
|
loadRecentExecutionResults,
|
||||||
|
loadRecentQuoteOutcomes,
|
||||||
loadRecentTradeDecisions,
|
loadRecentTradeDecisions,
|
||||||
loadRecentQuotes,
|
loadRecentQuotes,
|
||||||
loadSubmissionPage,
|
loadSubmissionPage,
|
||||||
|
|
@ -343,6 +344,7 @@ async function loadBootstrapPayload({ auth, page, pageSize }) {
|
||||||
recentTradeDecisions,
|
recentTradeDecisions,
|
||||||
recentExecuteTradeCommands,
|
recentExecuteTradeCommands,
|
||||||
recentExecutionResults,
|
recentExecutionResults,
|
||||||
|
recentQuoteOutcomes,
|
||||||
recentAlertTransitions,
|
recentAlertTransitions,
|
||||||
serviceSnapshots,
|
serviceSnapshots,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
|
|
@ -403,6 +405,12 @@ async function loadBootstrapPayload({ auth, page, pageSize }) {
|
||||||
[],
|
[],
|
||||||
sourceErrors,
|
sourceErrors,
|
||||||
),
|
),
|
||||||
|
safeSourceLoad(
|
||||||
|
'recent_quote_outcomes',
|
||||||
|
() => loadRecentQuoteOutcomes(pool, { limit: 200 }),
|
||||||
|
[],
|
||||||
|
sourceErrors,
|
||||||
|
),
|
||||||
safeSourceLoad(
|
safeSourceLoad(
|
||||||
'recent_alert_transitions',
|
'recent_alert_transitions',
|
||||||
() => loadRecentAlertTransitions(pool, { limit: 20 }),
|
() => loadRecentAlertTransitions(pool, { limit: 20 }),
|
||||||
|
|
@ -426,6 +434,7 @@ async function loadBootstrapPayload({ auth, page, pageSize }) {
|
||||||
recentTradeDecisions,
|
recentTradeDecisions,
|
||||||
recentExecuteTradeCommands,
|
recentExecuteTradeCommands,
|
||||||
recentExecutionResults,
|
recentExecutionResults,
|
||||||
|
recentQuoteOutcomes,
|
||||||
recentAlertTransitions,
|
recentAlertTransitions,
|
||||||
serviceSnapshots,
|
serviceSnapshots,
|
||||||
sourceErrors,
|
sourceErrors,
|
||||||
|
|
|
||||||
|
|
@ -131,18 +131,24 @@ async function handleDemand(event) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function publishDecision(decisionPayload) {
|
async function publishDecision(decisionPayload) {
|
||||||
|
const decisionAt = decisionPayload.decision_at || new Date().toISOString();
|
||||||
|
const normalizedDecisionPayload = {
|
||||||
|
...decisionPayload,
|
||||||
|
decision_at: decisionAt,
|
||||||
|
};
|
||||||
const event = buildEventEnvelope({
|
const event = buildEventEnvelope({
|
||||||
source: 'strategy-engine',
|
source: 'strategy-engine',
|
||||||
venue: 'near-intents',
|
venue: 'near-intents',
|
||||||
eventType: 'trade_decision',
|
eventType: 'trade_decision',
|
||||||
payload: decisionPayload,
|
observedAt: decisionAt,
|
||||||
|
payload: normalizedDecisionPayload,
|
||||||
});
|
});
|
||||||
await producer.sendJson(config.kafkaTopicDecisionTradeDecision, event, { key: decisionPayload.quote_id });
|
await producer.sendJson(config.kafkaTopicDecisionTradeDecision, event, { key: normalizedDecisionPayload.quote_id });
|
||||||
state.latest_decision = decisionPayload;
|
state.latest_decision = normalizedDecisionPayload;
|
||||||
state.recent_decisions.unshift(decisionPayload);
|
state.recent_decisions.unshift(normalizedDecisionPayload);
|
||||||
state.recent_decisions = state.recent_decisions.slice(0, 20);
|
state.recent_decisions = state.recent_decisions.slice(0, 20);
|
||||||
state.skipped_counts[decisionPayload.decision_reason] =
|
state.skipped_counts[normalizedDecisionPayload.decision_reason] =
|
||||||
(state.skipped_counts[decisionPayload.decision_reason] || 0) + 1;
|
(state.skipped_counts[normalizedDecisionPayload.decision_reason] || 0) + 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
const controlApi = startControlApi({
|
const controlApi = startControlApi({
|
||||||
|
|
|
||||||
|
|
@ -79,7 +79,7 @@ const state = {
|
||||||
last_quote_status: null,
|
last_quote_status: null,
|
||||||
last_error: null,
|
last_error: null,
|
||||||
in_flight_count: 0,
|
in_flight_count: 0,
|
||||||
completed_count: 0,
|
submitted_count: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
await consumer.subscribe({ topic: config.kafkaTopicCmdExecuteTrade, fromBeginning: false });
|
await consumer.subscribe({ topic: config.kafkaTopicCmdExecuteTrade, fromBeginning: false });
|
||||||
|
|
@ -107,7 +107,7 @@ async function handleCommand(event) {
|
||||||
state.last_command = payload;
|
state.last_command = payload;
|
||||||
|
|
||||||
const existing = stateStore.get(payload.command_id);
|
const existing = stateStore.get(payload.command_id);
|
||||||
if (existing?.status === 'completed') {
|
if (existing?.status === 'submitted') {
|
||||||
logger.warn('duplicate_command_skipped', {
|
logger.warn('duplicate_command_skipped', {
|
||||||
topic: config.kafkaTopicCmdExecuteTrade,
|
topic: config.kafkaTopicCmdExecuteTrade,
|
||||||
pair: payload.pair,
|
pair: payload.pair,
|
||||||
|
|
@ -158,11 +158,11 @@ async function handleCommand(event) {
|
||||||
result_code: response === 'OK' ? 'quote_response_ok' : 'quote_response_ack',
|
result_code: response === 'OK' ? 'quote_response_ok' : 'quote_response_ack',
|
||||||
venue_response: response,
|
venue_response: response,
|
||||||
});
|
});
|
||||||
stateStore.markCompleted(payload.command_id, {
|
stateStore.markSubmitted(payload.command_id, {
|
||||||
quote_id: payload.quote_id,
|
quote_id: payload.quote_id,
|
||||||
result: response,
|
result: response,
|
||||||
});
|
});
|
||||||
state.completed_count += 1;
|
state.submitted_count += 1;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
state.last_error = serializeError(error);
|
state.last_error = serializeError(error);
|
||||||
stateStore.markFailed(payload.command_id, {
|
stateStore.markFailed(payload.command_id, {
|
||||||
|
|
|
||||||
|
|
@ -13,19 +13,24 @@ export function createExecutorStateStore({ stateDir, fileName = 'trade-executor-
|
||||||
|
|
||||||
return {
|
return {
|
||||||
get(commandId) {
|
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) {
|
markProcessing(commandId, metadata) {
|
||||||
return updateCommand(store, commandId, metadata, 'processing');
|
return updateCommand(store, commandId, metadata, 'processing');
|
||||||
},
|
},
|
||||||
markCompleted(commandId, metadata) {
|
markSubmitted(commandId, metadata) {
|
||||||
return updateCommand(store, commandId, metadata, 'completed');
|
return updateCommand(store, commandId, metadata, 'submitted');
|
||||||
},
|
},
|
||||||
markFailed(commandId, metadata) {
|
markFailed(commandId, metadata) {
|
||||||
return updateCommand(store, commandId, metadata, 'failed');
|
return updateCommand(store, commandId, metadata, 'failed');
|
||||||
},
|
},
|
||||||
getState() {
|
getState() {
|
||||||
return store.getState();
|
return normalizeState(store.getState());
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -43,3 +48,18 @@ function updateCommand(store, commandId, metadata, status) {
|
||||||
|
|
||||||
return nextState.commands[commandId];
|
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,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { unitsToNumber } from './assets.mjs';
|
import { unitsToNumber } from './assets.mjs';
|
||||||
import { summarizeFundingObservations } from './funding-observations.mjs';
|
import { summarizeFundingObservations } from './funding-observations.mjs';
|
||||||
import { resolveDashboardRequestAuth } from './operator-dashboard-auth.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';
|
import { inferServiceFreshnessTimestamp as inferRuntimeFreshnessTimestamp } from './runtime-health.mjs';
|
||||||
|
|
||||||
export const DASHBOARD_LIVE_QUOTE_LIMIT = 10;
|
export const DASHBOARD_LIVE_QUOTE_LIMIT = 10;
|
||||||
|
|
@ -315,6 +316,7 @@ export function buildDashboardBootstrap({
|
||||||
recentTradeDecisions,
|
recentTradeDecisions,
|
||||||
recentExecuteTradeCommands,
|
recentExecuteTradeCommands,
|
||||||
recentExecutionResults,
|
recentExecutionResults,
|
||||||
|
recentQuoteOutcomes = [],
|
||||||
recentAlertTransitions,
|
recentAlertTransitions,
|
||||||
serviceSnapshots,
|
serviceSnapshots,
|
||||||
sourceErrors = [],
|
sourceErrors = [],
|
||||||
|
|
@ -381,12 +383,14 @@ export function buildDashboardBootstrap({
|
||||||
caveats: profitability.caveats,
|
caveats: profitability.caveats,
|
||||||
},
|
},
|
||||||
strategy: buildStrategySummary({
|
strategy: buildStrategySummary({
|
||||||
|
config,
|
||||||
servicesByName,
|
servicesByName,
|
||||||
activeAlerts,
|
activeAlerts,
|
||||||
recentQuotes,
|
recentQuotes,
|
||||||
recentTradeDecisions,
|
recentTradeDecisions,
|
||||||
recentExecuteTradeCommands,
|
recentExecuteTradeCommands,
|
||||||
recentExecutionResults,
|
recentExecutionResults,
|
||||||
|
recentQuoteOutcomes,
|
||||||
}),
|
}),
|
||||||
system: buildSystemSummary({
|
system: buildSystemSummary({
|
||||||
servicesByName,
|
servicesByName,
|
||||||
|
|
@ -408,6 +412,7 @@ export function buildProfitabilitySummary({ metric, submissionSummary } = {}) {
|
||||||
pnl_vs_deposit_baseline_eure: null,
|
pnl_vs_deposit_baseline_eure: null,
|
||||||
pnl_vs_simple_hold_eure: null,
|
pnl_vs_simple_hold_eure: null,
|
||||||
market_move_contribution_eure: null,
|
market_move_contribution_eure: null,
|
||||||
|
portfolio_vs_simple_hold_eure: null,
|
||||||
trading_contribution_eure: null,
|
trading_contribution_eure: null,
|
||||||
baseline_anchor_at: metric?.baseline_anchor_at || null,
|
baseline_anchor_at: metric?.baseline_anchor_at || null,
|
||||||
baseline_status: metric?.baseline_status || metric?.payload?.baseline_status || 'unavailable',
|
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,
|
recent_submission_count: submissionSummary?.total ?? 0,
|
||||||
last_submission_at: submissionSummary?.last_submission_at || null,
|
last_submission_at: submissionSummary?.last_submission_at || null,
|
||||||
caveats: [
|
caveats: [
|
||||||
'Portfolio PnL is truthful to the current durable inventory and reference price path.',
|
'Portfolio value and simple-hold comparison use durable inventory and reference prices.',
|
||||||
'Fees and per-trade realized net settlement deltas are not fully tracked yet.',
|
'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) {
|
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.current_total_portfolio_value_eure,
|
||||||
summary.simple_hold_value_eure,
|
summary.simple_hold_value_eure,
|
||||||
);
|
);
|
||||||
|
summary.trading_contribution_eure = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (summary.external_flow_adjusted) {
|
if (summary.external_flow_adjusted) {
|
||||||
|
|
@ -718,6 +724,12 @@ const HUMAN_REASON_TEXT = {
|
||||||
quote_expired: 'Quote expired.',
|
quote_expired: 'Quote expired.',
|
||||||
quote_response_ack: 'Quote response acknowledged by the relay.',
|
quote_response_ack: 'Quote response acknowledged by the relay.',
|
||||||
quote_response_ok: 'Quote response accepted 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.',
|
reason_unknown: 'Reason not recorded.',
|
||||||
stale_reference_price: 'Reference price is stale.',
|
stale_reference_price: 'Reference price is stale.',
|
||||||
strategy_approved: 'Strategy approved the quote.',
|
strategy_approved: 'Strategy approved the quote.',
|
||||||
|
|
@ -734,6 +746,7 @@ export function deriveQuoteLifecycleRows({
|
||||||
recentTradeDecisions = [],
|
recentTradeDecisions = [],
|
||||||
recentExecuteTradeCommands = [],
|
recentExecuteTradeCommands = [],
|
||||||
recentExecutionResults = [],
|
recentExecutionResults = [],
|
||||||
|
recentQuoteOutcomes = [],
|
||||||
limit = 20,
|
limit = 20,
|
||||||
} = {}) {
|
} = {}) {
|
||||||
const rowsByKey = new Map();
|
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))
|
.map((row) => finalizeLifecycleRow(row))
|
||||||
.sort((left, right) => sortTimestamps(right.latest_stage_at, left.latest_stage_at))
|
.sort((left, right) => sortTimestamps(right.latest_stage_at, left.latest_stage_at));
|
||||||
.slice(0, limit);
|
|
||||||
|
return limit == null ? finalized : finalized.slice(0, limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureLifecycleRow(rowsByKey, key) {
|
function ensureLifecycleRow(rowsByKey, key) {
|
||||||
|
|
@ -829,9 +859,11 @@ function ensureLifecycleRow(rowsByKey, key) {
|
||||||
decision_at: null,
|
decision_at: null,
|
||||||
command_at: null,
|
command_at: null,
|
||||||
execution_result_at: null,
|
execution_result_at: null,
|
||||||
|
outcome_observed_at: null,
|
||||||
decision: null,
|
decision: null,
|
||||||
command: null,
|
command: null,
|
||||||
execution: null,
|
execution: null,
|
||||||
|
outcome: null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return rowsByKey.get(key);
|
return rowsByKey.get(key);
|
||||||
|
|
@ -846,12 +878,19 @@ function mergeLifecycleEvidence(row, next) {
|
||||||
if (next?.decision) row.decision = next.decision;
|
if (next?.decision) row.decision = next.decision;
|
||||||
if (next?.command) row.command = next.command;
|
if (next?.command) row.command = next.command;
|
||||||
if (next?.execution) row.execution = next.execution;
|
if (next?.execution) row.execution = next.execution;
|
||||||
|
if (next?.outcome) row.outcome = next.outcome;
|
||||||
}
|
}
|
||||||
|
|
||||||
function finalizeLifecycleRow(row) {
|
function finalizeLifecycleRow(row) {
|
||||||
const decision = row.decision || null;
|
const decision = row.decision || null;
|
||||||
const execution = row.execution || 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_state = 'observed';
|
||||||
let lifecycle_label = 'Observed';
|
let lifecycle_label = 'Observed';
|
||||||
let reason_code = 'reason_unknown';
|
let reason_code = 'reason_unknown';
|
||||||
|
|
@ -860,13 +899,28 @@ function finalizeLifecycleRow(row) {
|
||||||
if (outcomeStatus && COMPLETED_OUTCOME_STATUSES.has(outcomeStatus)) {
|
if (outcomeStatus && COMPLETED_OUTCOME_STATUSES.has(outcomeStatus)) {
|
||||||
lifecycle_state = 'completed';
|
lifecycle_state = 'completed';
|
||||||
lifecycle_label = 'Completed';
|
lifecycle_label = 'Completed';
|
||||||
reason_code = normalizeLifecycleToken(execution?.outcome_reason || execution?.result_code || 'completed');
|
reason_code = normalizeLifecycleToken(
|
||||||
reason_text = humanizeReasonCode(reason_code, 'Completed');
|
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)) {
|
} else if (outcomeStatus && NOT_FILLED_OUTCOME_STATUSES.has(outcomeStatus)) {
|
||||||
lifecycle_state = 'not_filled';
|
lifecycle_state = 'not_filled';
|
||||||
lifecycle_label = 'Not filled';
|
lifecycle_label = 'Not filled';
|
||||||
reason_code = normalizeLifecycleToken(execution?.outcome_reason || execution?.result_code || outcomeStatus);
|
reason_code = normalizeLifecycleToken(
|
||||||
reason_text = humanizeReasonCode(reason_code, 'Not filled');
|
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') {
|
} else if (execution?.status === 'submitted') {
|
||||||
lifecycle_state = 'submitted';
|
lifecycle_state = 'submitted';
|
||||||
lifecycle_label = 'Submitted';
|
lifecycle_label = 'Submitted';
|
||||||
|
|
@ -907,7 +961,8 @@ function finalizeLifecycleRow(row) {
|
||||||
reason_code,
|
reason_code,
|
||||||
reason_text,
|
reason_text,
|
||||||
latest_stage_at:
|
latest_stage_at:
|
||||||
row.execution_result_at
|
row.outcome_observed_at
|
||||||
|
|| row.execution_result_at
|
||||||
|| row.command_at
|
|| row.command_at
|
||||||
|| row.decision_at
|
|| row.decision_at
|
||||||
|| row.quote_observed_at
|
|| row.quote_observed_at
|
||||||
|
|
@ -922,10 +977,45 @@ function finalizeLifecycleRow(row) {
|
||||||
execution_status: execution?.status || null,
|
execution_status: execution?.status || null,
|
||||||
execution_result_code: execution?.result_code || null,
|
execution_result_code: execution?.result_code || null,
|
||||||
execution_outcome_status: execution?.outcome_status || 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) {
|
function buildExecutionFailureText(execution, reasonCode) {
|
||||||
const base = humanizeReasonCode(reasonCode, 'Submission failed.');
|
const base = humanizeReasonCode(reasonCode, 'Submission failed.');
|
||||||
if (execution?.error_message) return `${base} ${execution.error_message}`;
|
if (execution?.error_message) return `${base} ${execution.error_message}`;
|
||||||
|
|
@ -968,12 +1058,14 @@ function normalizeCommand(command) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildStrategySummary({
|
function buildStrategySummary({
|
||||||
|
config,
|
||||||
servicesByName,
|
servicesByName,
|
||||||
activeAlerts,
|
activeAlerts,
|
||||||
recentQuotes = [],
|
recentQuotes = [],
|
||||||
recentTradeDecisions = [],
|
recentTradeDecisions = [],
|
||||||
recentExecuteTradeCommands = [],
|
recentExecuteTradeCommands = [],
|
||||||
recentExecutionResults = [],
|
recentExecutionResults = [],
|
||||||
|
recentQuoteOutcomes = [],
|
||||||
}) {
|
}) {
|
||||||
const strategyState = servicesByName['strategy-engine']?.state || {};
|
const strategyState = servicesByName['strategy-engine']?.state || {};
|
||||||
const executorState = servicesByName['trade-executor']?.state || {};
|
const executorState = servicesByName['trade-executor']?.state || {};
|
||||||
|
|
@ -1009,14 +1101,16 @@ function buildStrategySummary({
|
||||||
|| durableDecisionsById.get(strategyState.latest_decision?.decision_id)?.decision_at
|
|| durableDecisionsById.get(strategyState.latest_decision?.decision_id)?.decision_at
|
||||||
|| null,
|
|| null,
|
||||||
});
|
});
|
||||||
const lifecycleRows = deriveQuoteLifecycleRows({
|
const allLifecycleRows = deriveQuoteLifecycleRows({
|
||||||
recentQuotes,
|
recentQuotes,
|
||||||
recentTradeDecisions,
|
recentTradeDecisions,
|
||||||
recentExecuteTradeCommands,
|
recentExecuteTradeCommands,
|
||||||
recentExecutionResults,
|
recentExecutionResults,
|
||||||
limit: 20,
|
recentQuoteOutcomes,
|
||||||
});
|
limit: null,
|
||||||
const tradeFunnel = buildTradeFunnelSummary(lifecycleRows);
|
}).map((row) => enrichLifecycleRowForUi({ config, row }));
|
||||||
|
const lifecycleRows = allLifecycleRows.slice(0, 20);
|
||||||
|
const tradeFunnel = buildTradeFunnelSummary(allLifecycleRows);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
strategy_state: {
|
strategy_state: {
|
||||||
|
|
@ -1038,7 +1132,7 @@ function buildStrategySummary({
|
||||||
paused: executorState.paused ?? null,
|
paused: executorState.paused ?? null,
|
||||||
draining: executorState.draining ?? null,
|
draining: executorState.draining ?? null,
|
||||||
in_flight_count: executorState.in_flight_count ?? 0,
|
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_command: executorState.last_command || null,
|
||||||
last_venue_response: executorState.last_venue_response || null,
|
last_venue_response: executorState.last_venue_response || null,
|
||||||
last_error: executorState.last_error || null,
|
last_error: executorState.last_error || null,
|
||||||
|
|
@ -1076,7 +1170,7 @@ function buildTradeFunnelSummary(lifecycleRows = []) {
|
||||||
counts[row.lifecycle_state] += 1;
|
counts[row.lifecycle_state] += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (row.lifecycle_state === 'completed') {
|
if (row.lifecycle_state === 'completed' && row.has_settlement_evidence) {
|
||||||
successfulTrades.push(row);
|
successfulTrades.push(row);
|
||||||
} else if (['submitted', 'awaiting_outcome'].includes(row.lifecycle_state)) {
|
} else if (['submitted', 'awaiting_outcome'].includes(row.lifecycle_state)) {
|
||||||
unresolvedSubmissions.push(row);
|
unresolvedSubmissions.push(row);
|
||||||
|
|
@ -1123,9 +1217,12 @@ function buildSystemSummary({ servicesByName, activeAlerts, recentAlerts }) {
|
||||||
last_alert_write_at: historyWriterState.last_alert_write_at || null,
|
last_alert_write_at: historyWriterState.last_alert_write_at || null,
|
||||||
last_funding_observation_write_at: historyWriterState.last_funding_observation_write_at || null,
|
last_funding_observation_write_at: historyWriterState.last_funding_observation_write_at || null,
|
||||||
last_metrics_at: historyWriterState.last_metrics_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_portfolio_metrics: historyWriterState.latest_portfolio_metrics || null,
|
||||||
|
latest_quote_outcomes: historyWriterState.latest_quote_outcomes || null,
|
||||||
offsets: historyWriterState.offsets || {},
|
offsets: historyWriterState.offsets || {},
|
||||||
metrics_error: historyWriterState.metrics_error || null,
|
metrics_error: historyWriterState.metrics_error || null,
|
||||||
|
quote_outcomes_error: historyWriterState.quote_outcomes_error || null,
|
||||||
},
|
},
|
||||||
controls: listDashboardControls({ page: 'system' }),
|
controls: listDashboardControls({ page: 'system' }),
|
||||||
};
|
};
|
||||||
|
|
@ -1192,6 +1289,7 @@ function buildServiceSummary(service, state) {
|
||||||
return {
|
return {
|
||||||
last_write_at: state.last_write_at || null,
|
last_write_at: state.last_write_at || null,
|
||||||
last_alert_write_at: state.last_alert_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,
|
database_connectivity: state.database_connectivity ?? null,
|
||||||
};
|
};
|
||||||
case 'ops-sentinel':
|
case 'ops-sentinel':
|
||||||
|
|
@ -1210,7 +1308,7 @@ function buildServiceSummary(service, state) {
|
||||||
case 'trade-executor':
|
case 'trade-executor':
|
||||||
return {
|
return {
|
||||||
in_flight_count: state.in_flight_count ?? 0,
|
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,
|
signer_registered: state.signer_registered ?? null,
|
||||||
relay_connected: state.relay?.connected ?? null,
|
relay_connected: state.relay?.connected ?? null,
|
||||||
relay_last_message_at: state.relay?.last_message_at || 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 }) {
|
function buildRecentDepositItems({ config, recentDepositStatuses, liquidityState }) {
|
||||||
const recentItems = (recentDepositStatuses || []).map((entry) => normalizeDepositStatusForUi({
|
const recentItems = (recentDepositStatuses || []).map((entry) => normalizeDepositStatusForUi({
|
||||||
config,
|
config,
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ export function computePortfolioMetric({
|
||||||
baseline = null,
|
baseline = null,
|
||||||
currentInventory,
|
currentInventory,
|
||||||
currentPrice,
|
currentPrice,
|
||||||
|
externalFlows = [],
|
||||||
btcAsset,
|
btcAsset,
|
||||||
eureAsset,
|
eureAsset,
|
||||||
commandCount = 0,
|
commandCount = 0,
|
||||||
|
|
@ -23,7 +24,7 @@ export function computePortfolioMetric({
|
||||||
const currentPortfolioValue = currentEure + currentBtcMarkValue;
|
const currentPortfolioValue = currentEure + currentBtcMarkValue;
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
metric_version: 1,
|
metric_version: 2,
|
||||||
baseline_status: baseline ? 'active' : 'awaiting_first_execution',
|
baseline_status: baseline ? 'active' : 'awaiting_first_execution',
|
||||||
command_count: commandCount,
|
command_count: commandCount,
|
||||||
result_count: resultCount,
|
result_count: resultCount,
|
||||||
|
|
@ -40,12 +41,27 @@ export function computePortfolioMetric({
|
||||||
current_portfolio_value_eure: formatScaledDecimal(currentPortfolioValue),
|
current_portfolio_value_eure: formatScaledDecimal(currentPortfolioValue),
|
||||||
current_btc_mark_value_eure: formatScaledDecimal(currentBtcMarkValue),
|
current_btc_mark_value_eure: formatScaledDecimal(currentBtcMarkValue),
|
||||||
current_eure_cash_value_eure: formatScaledDecimal(currentEure),
|
current_eure_cash_value_eure: formatScaledDecimal(currentEure),
|
||||||
|
portfolio_vs_simple_hold_eure: null,
|
||||||
trade_pnl_eure: null,
|
trade_pnl_eure: null,
|
||||||
mark_to_market_pnl_eure: null,
|
mark_to_market_pnl_eure: null,
|
||||||
price_move_pnl_eure: null,
|
price_move_pnl_eure: null,
|
||||||
baseline_portfolio_value_eure_at_baseline_price: null,
|
baseline_portfolio_value_eure_at_baseline_price: null,
|
||||||
baseline_portfolio_value_eure_at_current_price: null,
|
baseline_portfolio_value_eure_at_current_price: null,
|
||||||
current_portfolio_value_eure_at_baseline_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,
|
inventory_delta: null,
|
||||||
baseline: null,
|
baseline: null,
|
||||||
};
|
};
|
||||||
|
|
@ -62,22 +78,57 @@ export function computePortfolioMetric({
|
||||||
const baselinePortfolioAtBaselinePrice = baselineEure + multiplyScaled(baselineBtc, baselinePriceScaled);
|
const baselinePortfolioAtBaselinePrice = baselineEure + multiplyScaled(baselineBtc, baselinePriceScaled);
|
||||||
const baselinePortfolioAtCurrentPrice = baselineEure + multiplyScaled(baselineBtc, currentPriceScaled);
|
const baselinePortfolioAtCurrentPrice = baselineEure + multiplyScaled(baselineBtc, currentPriceScaled);
|
||||||
const currentPortfolioAtBaselinePrice = currentEure + multiplyScaled(currentBtc, baselinePriceScaled);
|
const currentPortfolioAtBaselinePrice = currentEure + multiplyScaled(currentBtc, baselinePriceScaled);
|
||||||
const tradePnl = currentPortfolioAtBaselinePrice - baselinePortfolioAtBaselinePrice;
|
const externalFlowSummary = summarizeExternalFlows({
|
||||||
const markToMarketPnl = currentPortfolioValue - baselinePortfolioAtCurrentPrice;
|
externalFlows,
|
||||||
const priceMovePnl = markToMarketPnl - tradePnl;
|
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.mark_to_market_pnl_eure = formatScaledDecimal(markToMarketPnl);
|
||||||
payload.price_move_pnl_eure = formatScaledDecimal(priceMovePnl);
|
payload.price_move_pnl_eure = formatScaledDecimal(priceMovePnl);
|
||||||
payload.baseline_portfolio_value_eure_at_baseline_price = formatScaledDecimal(
|
payload.baseline_portfolio_value_eure_at_baseline_price = formatScaledDecimal(
|
||||||
baselinePortfolioAtBaselinePrice,
|
fundedPortfolioAtFlowTime,
|
||||||
);
|
);
|
||||||
payload.baseline_portfolio_value_eure_at_current_price = formatScaledDecimal(
|
payload.baseline_portfolio_value_eure_at_current_price = formatScaledDecimal(
|
||||||
baselinePortfolioAtCurrentPrice,
|
simpleHoldAtCurrentPrice,
|
||||||
);
|
);
|
||||||
payload.current_portfolio_value_eure_at_baseline_price = formatScaledDecimal(
|
payload.current_portfolio_value_eure_at_baseline_price = formatScaledDecimal(
|
||||||
currentPortfolioAtBaselinePrice,
|
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 = {
|
payload.inventory_delta = {
|
||||||
btc_units: (BigInt(currentBtcUnits) - BigInt(baselineBtcUnits)).toString(),
|
btc_units: (BigInt(currentBtcUnits) - BigInt(baselineBtcUnits)).toString(),
|
||||||
btc: formatScaledDecimal(currentBtc - baselineBtc),
|
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) {
|
function unitsToScaledDecimal(units, decimals) {
|
||||||
return BigInt(units || '0') * 10n ** BigInt(VALUE_SCALE - 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+$/, '');
|
const fractionalText = fractional.toString().padStart(VALUE_SCALE, '0').replace(/0+$/, '');
|
||||||
return `${negative ? '-' : ''}${whole}.${fractionalText}`;
|
return `${negative ? '-' : ''}${whole}.${fractionalText}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function timestampValue(value) {
|
||||||
|
const parsed = Date.parse(value || '');
|
||||||
|
return Number.isFinite(parsed) ? parsed : -Infinity;
|
||||||
|
}
|
||||||
|
|
|
||||||
495
src/core/quote-outcomes.mjs
Normal file
495
src/core/quote-outcomes.mjs
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -62,7 +62,7 @@ const DEFAULTS = {
|
||||||
'https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies=eur',
|
'https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies=eur',
|
||||||
inventorySyncRefreshMs: 15_000,
|
inventorySyncRefreshMs: 15_000,
|
||||||
liquidityRefreshMs: 30_000,
|
liquidityRefreshMs: 30_000,
|
||||||
strategyGrossThresholdPct: 2,
|
strategyGrossThresholdPct: 1.49,
|
||||||
strategyInitialArmed: false,
|
strategyInitialArmed: false,
|
||||||
strategyMaxNotionalEure: 5,
|
strategyMaxNotionalEure: 5,
|
||||||
strategyPriceMaxAgeMs: 30_000,
|
strategyPriceMaxAgeMs: 30_000,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import { Pool } from 'pg';
|
import { Pool } from 'pg';
|
||||||
|
|
||||||
|
import { deriveQuoteOutcomeRecords } from '../core/quote-outcomes.mjs';
|
||||||
|
|
||||||
const TABLES = [
|
const TABLES = [
|
||||||
'raw_near_intents_quotes',
|
'raw_near_intents_quotes',
|
||||||
'swap_demand_events',
|
'swap_demand_events',
|
||||||
|
|
@ -14,6 +16,7 @@ const TABLES = [
|
||||||
];
|
];
|
||||||
|
|
||||||
const PORTFOLIO_METRICS_TABLE = 'portfolio_metrics_snapshots';
|
const PORTFOLIO_METRICS_TABLE = 'portfolio_metrics_snapshots';
|
||||||
|
const QUOTE_OUTCOMES_TABLE = 'quote_outcome_attributions';
|
||||||
const CREDITED_LIQUIDITY_STATUSES = ['CREDITED', 'COMPLETED', 'FINALIZED', 'SETTLED'];
|
const CREDITED_LIQUIDITY_STATUSES = ['CREDITED', 'COMPLETED', 'FINALIZED', 'SETTLED'];
|
||||||
const COMPLETED_WITHDRAWAL_STATUSES = ['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
|
CREATE INDEX IF NOT EXISTS ${PORTFOLIO_METRICS_TABLE}_computed_at_idx
|
||||||
ON ${PORTFOLIO_METRICS_TABLE} (computed_at DESC)
|
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 }) {
|
export async function insertHistoryEvent(pool, { table, topic, event, record }) {
|
||||||
|
|
@ -260,6 +291,176 @@ export async function loadLatestPortfolioMetric(pool) {
|
||||||
return normalizePortfolioMetricRow(result.rows[0]);
|
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) {
|
export async function loadLatestInventorySnapshot(pool) {
|
||||||
const latest = await loadLatestEventPayload(pool, 'intent_inventory_snapshots');
|
const latest = await loadLatestEventPayload(pool, 'intent_inventory_snapshots');
|
||||||
if (!latest) return null;
|
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.ingested_at AS result_ingested_at,
|
||||||
r.payload AS result_payload,
|
r.payload AS result_payload,
|
||||||
c.payload AS command_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
|
FROM trade_execution_results r
|
||||||
LEFT JOIN execute_trade_commands c
|
LEFT JOIN execute_trade_commands c
|
||||||
ON c.decision_key = r.decision_key
|
ON c.decision_key = r.decision_key
|
||||||
LEFT JOIN trade_decisions d
|
LEFT JOIN trade_decisions d
|
||||||
ON d.decision_key = COALESCE(c.payload->>'decision_id', r.payload->>'decision_id')
|
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'
|
WHERE r.payload->>'status' = 'submitted'
|
||||||
ORDER BY COALESCE(r.observed_at, r.ingested_at) DESC
|
ORDER BY COALESCE(r.observed_at, r.ingested_at) DESC
|
||||||
LIMIT $1
|
LIMIT $1
|
||||||
|
|
@ -360,12 +564,15 @@ export async function loadRecentExecutionResults(pool, { limit = 20 } = {}) {
|
||||||
r.payload AS result_payload,
|
r.payload AS result_payload,
|
||||||
c.ingested_at AS command_ingested_at,
|
c.ingested_at AS command_ingested_at,
|
||||||
c.payload AS command_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
|
FROM trade_execution_results r
|
||||||
LEFT JOIN execute_trade_commands c
|
LEFT JOIN execute_trade_commands c
|
||||||
ON c.decision_key = r.decision_key
|
ON c.decision_key = r.decision_key
|
||||||
LEFT JOIN trade_decisions d
|
LEFT JOIN trade_decisions d
|
||||||
ON d.decision_key = COALESCE(c.payload->>'decision_id', r.payload->>'decision_id')
|
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
|
ORDER BY COALESCE(r.observed_at, r.ingested_at) DESC
|
||||||
LIMIT $1
|
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) {
|
function normalizeRecentQuoteRow(row) {
|
||||||
const payload = row.payload || {};
|
const payload = row.payload || {};
|
||||||
return {
|
return {
|
||||||
|
|
@ -682,6 +919,7 @@ function normalizeSubmissionRow(row) {
|
||||||
const resultPayload = row.result_payload || {};
|
const resultPayload = row.result_payload || {};
|
||||||
const commandPayload = row.command_payload || {};
|
const commandPayload = row.command_payload || {};
|
||||||
const decisionPayload = row.decision_payload || {};
|
const decisionPayload = row.decision_payload || {};
|
||||||
|
const outcomePayload = row.outcome_payload || {};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
command_id: resultPayload.command_id || commandPayload.command_id || null,
|
command_id: resultPayload.command_id || commandPayload.command_id || null,
|
||||||
|
|
@ -697,6 +935,10 @@ function normalizeSubmissionRow(row) {
|
||||||
ingested_at: toIsoTimestamp(row.result_ingested_at),
|
ingested_at: toIsoTimestamp(row.result_ingested_at),
|
||||||
status: resultPayload.status || null,
|
status: resultPayload.status || null,
|
||||||
result_code: resultPayload.result_code || 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,
|
request_kind: commandPayload.request_kind || decisionPayload.request_kind || null,
|
||||||
asset_in: commandPayload.asset_in || null,
|
asset_in: commandPayload.asset_in || null,
|
||||||
asset_out: commandPayload.asset_out || null,
|
asset_out: commandPayload.asset_out || null,
|
||||||
|
|
@ -733,6 +975,7 @@ function normalizeExecutionResultRow(row) {
|
||||||
const resultPayload = row.result_payload || {};
|
const resultPayload = row.result_payload || {};
|
||||||
const commandPayload = row.command_payload || {};
|
const commandPayload = row.command_payload || {};
|
||||||
const decisionPayload = row.decision_payload || {};
|
const decisionPayload = row.decision_payload || {};
|
||||||
|
const outcomePayload = row.outcome_payload || {};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
command_id: resultPayload.command_id || commandPayload.command_id || null,
|
command_id: resultPayload.command_id || commandPayload.command_id || null,
|
||||||
|
|
@ -749,15 +992,22 @@ function normalizeExecutionResultRow(row) {
|
||||||
status: resultPayload.status || null,
|
status: resultPayload.status || null,
|
||||||
result_code: resultPayload.result_code || null,
|
result_code: resultPayload.result_code || null,
|
||||||
outcome_status:
|
outcome_status:
|
||||||
resultPayload.outcome_status
|
outcomePayload.outcome_status
|
||||||
|
|| resultPayload.outcome_status
|
||||||
|| resultPayload.venue_outcome_status
|
|| resultPayload.venue_outcome_status
|
||||||
|| resultPayload.trade_outcome_status
|
|| resultPayload.trade_outcome_status
|
||||||
|| null,
|
|| null,
|
||||||
outcome_reason:
|
outcome_reason:
|
||||||
resultPayload.outcome_reason
|
outcomePayload.outcome_reason
|
||||||
|
|| resultPayload.outcome_reason
|
||||||
|| resultPayload.venue_outcome_reason
|
|| resultPayload.venue_outcome_reason
|
||||||
|| resultPayload.trade_outcome_reason
|
|| resultPayload.trade_outcome_reason
|
||||||
|| null,
|
|| 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,
|
venue_response: resultPayload.venue_response || null,
|
||||||
error_message: resultPayload.error?.message || null,
|
error_message: resultPayload.error?.message || null,
|
||||||
note: resultPayload.note || null,
|
note: resultPayload.note || null,
|
||||||
|
|
|
||||||
|
|
@ -380,13 +380,13 @@ export default function FundsPage({
|
||||||
: 'Baseline anchored before first live trade';
|
: 'Baseline anchored before first live trade';
|
||||||
const simpleHoldMeta = externalFlowAdjusted
|
const simpleHoldMeta = externalFlowAdjusted
|
||||||
? 'Simple hold includes later credited deposits and completed withdrawals'
|
? '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
|
const marketMoveMeta = externalFlowAdjusted
|
||||||
? 'Simple-hold market move on baseline plus later external flows'
|
? 'Simple-hold market move on baseline plus later external flows'
|
||||||
: 'Baseline mark move only';
|
: 'Baseline mark move only';
|
||||||
const tradingContributionMeta = externalFlowAdjusted
|
const portfolioVsHoldMeta = externalFlowAdjusted
|
||||||
? 'Current minus cash-flow-adjusted simple hold'
|
? 'Current minus cash-flow-adjusted simple hold; not realized trade PnL'
|
||||||
: 'Current minus simple hold';
|
: 'Current minus simple hold; not realized trade PnL';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
@ -441,10 +441,10 @@ export default function FundsPage({
|
||||||
value={formatEur(profitability.market_move_contribution_eure)}
|
value={formatEur(profitability.market_move_contribution_eure)}
|
||||||
/>
|
/>
|
||||||
<MetricCard
|
<MetricCard
|
||||||
label="Trading contribution"
|
label="Portfolio vs simple hold"
|
||||||
meta={tradingContributionMeta}
|
meta={portfolioVsHoldMeta}
|
||||||
signedValue={profitability.trading_contribution_eure}
|
signedValue={profitability.portfolio_vs_simple_hold_eure}
|
||||||
value={formatEur(profitability.trading_contribution_eure)}
|
value={formatEur(profitability.portfolio_vs_simple_hold_eure)}
|
||||||
/>
|
/>
|
||||||
<MetricCard
|
<MetricCard
|
||||||
label={SUBMISSION_COPY.recentMetricLabel}
|
label={SUBMISSION_COPY.recentMetricLabel}
|
||||||
|
|
|
||||||
|
|
@ -97,10 +97,22 @@ function SuccessfulTradesTable({ items }) {
|
||||||
<td className="mono truncate-cell" title={item.pair || ''}>{truncateMiddle(item.pair || '', 32)}</td>
|
<td className="mono truncate-cell" title={item.pair || ''}>{truncateMiddle(item.pair || '', 32)}</td>
|
||||||
<td>
|
<td>
|
||||||
<IdentifierRow label="Quote" value={item.quote_id} />
|
<IdentifierRow label="Quote" value={item.quote_id} />
|
||||||
|
<IdentifierRow label="Decision" value={item.decision_id} />
|
||||||
<IdentifierRow label="Command" value={item.command_id} />
|
<IdentifierRow label="Command" value={item.command_id} />
|
||||||
</td>
|
</td>
|
||||||
<td>{item.reason_text}</td>
|
<td>{item.reason_text}</td>
|
||||||
<td>Linked asset delta not exposed yet</td>
|
<td>
|
||||||
|
<div>{item.settlement_summary?.text || 'No settled inventory delta is linked to this quote.'}</div>
|
||||||
|
{item.settlement_summary?.method ? (
|
||||||
|
<div className="status-subtle mono">{item.settlement_summary.method}</div>
|
||||||
|
) : null}
|
||||||
|
{item.settlement_summary?.observed_at ? (
|
||||||
|
<div className="status-subtle">{`Observed ${formatTimestamp(item.settlement_summary.observed_at)}`}</div>
|
||||||
|
) : null}
|
||||||
|
{item.settlement_summary?.caveat ? (
|
||||||
|
<div className="status-subtle">{item.settlement_summary.caveat}</div>
|
||||||
|
) : null}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,11 @@ export default function SystemPage({ system, onControl }) {
|
||||||
meta={system.persistence.metrics_error ? 'Metrics error present' : 'Metrics healthy'}
|
meta={system.persistence.metrics_error ? 'Metrics error present' : 'Metrics healthy'}
|
||||||
value={formatTimestamp(system.persistence.last_metrics_at)}
|
value={formatTimestamp(system.persistence.last_metrics_at)}
|
||||||
/>
|
/>
|
||||||
|
<MetricCard
|
||||||
|
label="Last quote outcome write"
|
||||||
|
meta={system.persistence.quote_outcomes_error ? 'Outcome attribution error present' : 'Outcome attribution healthy'}
|
||||||
|
value={formatTimestamp(system.persistence.last_quote_outcomes_at)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<TableFrame style={{ marginTop: 14 }}>
|
<TableFrame style={{ marginTop: 14 }}>
|
||||||
<table>
|
<table>
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,11 @@ export async function startSolverRelayWs({
|
||||||
let requestId = 1;
|
let requestId = 1;
|
||||||
const pending = new Map();
|
const pending = new Map();
|
||||||
let readyResolvers = [];
|
let readyResolvers = [];
|
||||||
|
let reconnectCount = 0;
|
||||||
|
let lastMessageAt = null;
|
||||||
|
let lastConnectedAt = null;
|
||||||
|
let lastDisconnectedAt = null;
|
||||||
|
let lastReconnectAt = null;
|
||||||
|
|
||||||
connect();
|
connect();
|
||||||
|
|
||||||
|
|
@ -55,8 +60,20 @@ export async function startSolverRelayWs({
|
||||||
return {
|
return {
|
||||||
connected,
|
connected,
|
||||||
pending_requests: pending.size,
|
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() {
|
close() {
|
||||||
closed = true;
|
closed = true;
|
||||||
if (reconnectTimer) clearTimeout(reconnectTimer);
|
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||||||
|
|
@ -67,6 +84,8 @@ export async function startSolverRelayWs({
|
||||||
|
|
||||||
function connect() {
|
function connect() {
|
||||||
if (closed) return;
|
if (closed) return;
|
||||||
|
reconnectCount += 1;
|
||||||
|
lastReconnectAt = new Date().toISOString();
|
||||||
|
|
||||||
socket = new WebSocket(wsUrl, {
|
socket = new WebSocket(wsUrl, {
|
||||||
headers: {
|
headers: {
|
||||||
|
|
@ -76,6 +95,7 @@ export async function startSolverRelayWs({
|
||||||
|
|
||||||
socket.addEventListener('open', () => {
|
socket.addEventListener('open', () => {
|
||||||
connected = true;
|
connected = true;
|
||||||
|
lastConnectedAt = new Date().toISOString();
|
||||||
logger?.info('connection_established', {
|
logger?.info('connection_established', {
|
||||||
venue: 'near-intents',
|
venue: 'near-intents',
|
||||||
});
|
});
|
||||||
|
|
@ -92,6 +112,7 @@ export async function startSolverRelayWs({
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.addEventListener('message', (event) => {
|
socket.addEventListener('message', (event) => {
|
||||||
|
lastMessageAt = new Date().toISOString();
|
||||||
const text = typeof event.data === 'string' ? event.data : Buffer.from(event.data).toString('utf8');
|
const text = typeof event.data === 'string' ? event.data : Buffer.from(event.data).toString('utf8');
|
||||||
let payload;
|
let payload;
|
||||||
try {
|
try {
|
||||||
|
|
@ -116,6 +137,7 @@ export async function startSolverRelayWs({
|
||||||
|
|
||||||
socket.addEventListener('close', () => {
|
socket.addEventListener('close', () => {
|
||||||
connected = false;
|
connected = false;
|
||||||
|
lastDisconnectedAt = new Date().toISOString();
|
||||||
rejectAllPending(new Error('Socket disconnected'));
|
rejectAllPending(new Error('Socket disconnected'));
|
||||||
logger?.warn('connection_lost', {
|
logger?.warn('connection_lost', {
|
||||||
venue: 'near-intents',
|
venue: 'near-intents',
|
||||||
|
|
|
||||||
|
|
@ -38,9 +38,15 @@ export async function startNearIntentsWs({
|
||||||
let lastMatchingQuoteAt = null;
|
let lastMatchingQuoteAt = null;
|
||||||
let lastPublishedAt = null;
|
let lastPublishedAt = null;
|
||||||
let lastPublishedPair = null;
|
let lastPublishedPair = null;
|
||||||
|
let reconnectCount = 0;
|
||||||
|
let lastConnectedAt = null;
|
||||||
|
let lastDisconnectedAt = null;
|
||||||
|
let lastReconnectAt = null;
|
||||||
|
|
||||||
function connect() {
|
function connect() {
|
||||||
if (closed) return;
|
if (closed) return;
|
||||||
|
reconnectCount += 1;
|
||||||
|
lastReconnectAt = new Date().toISOString();
|
||||||
|
|
||||||
const ws = new WebSocket(wsUrl, {
|
const ws = new WebSocket(wsUrl, {
|
||||||
headers: { Authorization: `Bearer ${apiKey}` },
|
headers: { Authorization: `Bearer ${apiKey}` },
|
||||||
|
|
@ -49,6 +55,7 @@ export async function startNearIntentsWs({
|
||||||
|
|
||||||
ws.addEventListener('open', () => {
|
ws.addEventListener('open', () => {
|
||||||
connected = true;
|
connected = true;
|
||||||
|
lastConnectedAt = new Date().toISOString();
|
||||||
logger?.info('connection_established', {
|
logger?.info('connection_established', {
|
||||||
namespace,
|
namespace,
|
||||||
});
|
});
|
||||||
|
|
@ -133,6 +140,7 @@ export async function startNearIntentsWs({
|
||||||
ws.addEventListener('close', () => {
|
ws.addEventListener('close', () => {
|
||||||
connected = false;
|
connected = false;
|
||||||
activeSocket = null;
|
activeSocket = null;
|
||||||
|
lastDisconnectedAt = new Date().toISOString();
|
||||||
logger?.warn('connection_lost', {
|
logger?.warn('connection_lost', {
|
||||||
namespace,
|
namespace,
|
||||||
details: {
|
details: {
|
||||||
|
|
@ -160,6 +168,7 @@ export async function startNearIntentsWs({
|
||||||
getState() {
|
getState() {
|
||||||
return {
|
return {
|
||||||
connected,
|
connected,
|
||||||
|
reconnect_count: reconnectCount,
|
||||||
frames_received: framesReceived,
|
frames_received: framesReceived,
|
||||||
quote_frames_received: quoteFramesReceived,
|
quote_frames_received: quoteFramesReceived,
|
||||||
filtered_count: filteredCount,
|
filtered_count: filteredCount,
|
||||||
|
|
@ -170,10 +179,20 @@ export async function startNearIntentsWs({
|
||||||
last_matching_quote_at: lastMatchingQuoteAt,
|
last_matching_quote_at: lastMatchingQuoteAt,
|
||||||
last_published_at: lastPublishedAt,
|
last_published_at: lastPublishedAt,
|
||||||
last_published_pair: lastPublishedPair,
|
last_published_pair: lastPublishedPair,
|
||||||
|
last_connected_at: lastConnectedAt,
|
||||||
|
last_disconnected_at: lastDisconnectedAt,
|
||||||
|
last_reconnect_at: lastReconnectAt,
|
||||||
raw_topic: rawTopic,
|
raw_topic: rawTopic,
|
||||||
normalized_topic: normalizedTopic,
|
normalized_topic: normalizedTopic,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
reconnect() {
|
||||||
|
if (activeSocket && activeSocket.readyState <= 1) {
|
||||||
|
activeSocket.close();
|
||||||
|
} else {
|
||||||
|
connect();
|
||||||
|
}
|
||||||
|
},
|
||||||
close() {
|
close() {
|
||||||
closed = true;
|
closed = true;
|
||||||
if (reconnectTimer) clearTimeout(reconnectTimer);
|
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||||||
|
|
|
||||||
|
|
@ -6,17 +6,37 @@ import path from 'node:path';
|
||||||
|
|
||||||
import { createExecutorStateStore } from '../src/core/executor-state-store.mjs';
|
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 stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'unrip-executor-'));
|
||||||
const store = createExecutorStateStore({ stateDir });
|
const store = createExecutorStateStore({ stateDir });
|
||||||
|
|
||||||
store.markProcessing('cmd-1', { quote_id: 'quote-1' });
|
store.markProcessing('cmd-1', { quote_id: 'quote-1' });
|
||||||
assert.equal(store.get('cmd-1').status, 'processing');
|
assert.equal(store.get('cmd-1').status, 'processing');
|
||||||
|
|
||||||
store.markCompleted('cmd-1', { result_event_id: 'result-1' });
|
store.markSubmitted('cmd-1', { result_event_id: 'result-1' });
|
||||||
assert.equal(store.get('cmd-1').status, 'completed');
|
assert.equal(store.get('cmd-1').status, 'submitted');
|
||||||
|
|
||||||
const reloaded = createExecutorStateStore({ stateDir });
|
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');
|
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');
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -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({
|
const summary = buildProfitabilitySummary({
|
||||||
metric: {
|
metric: {
|
||||||
computed_at: '2026-04-04T09:05:00.000Z',
|
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_deposit_baseline_eure, '10');
|
||||||
assert.equal(summary.pnl_vs_simple_hold_eure, '5');
|
assert.equal(summary.pnl_vs_simple_hold_eure, '5');
|
||||||
assert.equal(summary.market_move_contribution_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.computed_at, '2026-04-04T09:05:00.000Z');
|
||||||
assert.equal(summary.recent_submission_count, 4);
|
assert.equal(summary.recent_submission_count, 4);
|
||||||
assert.equal(summary.last_submission_at, '2026-04-04T09:00:00.000Z');
|
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,
|
paused: false,
|
||||||
draining: false,
|
draining: false,
|
||||||
in_flight_count: 0,
|
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/);
|
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({
|
const rows = deriveQuoteLifecycleRows({
|
||||||
recentExecutionResults: [
|
recentExecutionResults: [
|
||||||
{
|
{
|
||||||
|
|
@ -601,25 +603,125 @@ test('completed lifecycle evidence is the only source of successful trade rows',
|
||||||
status: 'submitted',
|
status: 'submitted',
|
||||||
result_code: 'quote_response_ok',
|
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 completed = rows.filter((row) => row.lifecycle_state === 'completed');
|
||||||
const submitted = rows.filter((row) => row.lifecycle_state === 'submitted');
|
const submitted = rows.filter((row) => row.lifecycle_state === 'submitted');
|
||||||
|
|
||||||
assert.equal(completed.length, 1);
|
assert.equal(completed.length, 0);
|
||||||
assert.equal(completed[0].quote_id, 'quote-completed');
|
|
||||||
assert.equal(submitted.length, 1);
|
assert.equal(submitted.length, 1);
|
||||||
assert.equal(submitted[0].quote_id, 'quote-submitted');
|
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', () => {
|
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,
|
paused: false,
|
||||||
draining: false,
|
draining: false,
|
||||||
in_flight_count: 0,
|
in_flight_count: 0,
|
||||||
completed_count: 0,
|
submitted_count: 0,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ const eureAsset = {
|
||||||
decimals: 18,
|
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({
|
const metric = computePortfolioMetric({
|
||||||
baseline: {
|
baseline: {
|
||||||
anchor: 'latest_inventory_before_first_command',
|
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.baseline_status, 'active');
|
||||||
assert.equal(metric.current_portfolio_value_eure, '118.576613887978799978');
|
assert.equal(metric.current_portfolio_value_eure, '118.576613887978799978');
|
||||||
assert.equal(metric.trade_pnl_eure, '0.391183707978799978');
|
assert.equal(metric.portfolio_vs_simple_hold_eure, '0.497413887978799978');
|
||||||
assert.equal(metric.mark_to_market_pnl_eure, '0.497413887978799978');
|
assert.equal(metric.trade_pnl_eure, null);
|
||||||
assert.equal(metric.price_move_pnl_eure, '0.10623018');
|
assert.equal(metric.mark_to_market_pnl_eure, '0.784413887978799978');
|
||||||
|
assert.equal(metric.price_move_pnl_eure, '0.287');
|
||||||
assert.deepEqual(metric.inventory_delta, {
|
assert.deepEqual(metric.inventory_delta, {
|
||||||
btc_units: '37014',
|
btc_units: '37014',
|
||||||
btc: '0.00037014',
|
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.baseline_status, 'awaiting_first_execution');
|
||||||
assert.equal(metric.current_portfolio_value_eure, '118.0792');
|
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.trade_pnl_eure, null);
|
||||||
assert.equal(metric.mark_to_market_pnl_eure, null);
|
assert.equal(metric.mark_to_market_pnl_eure, null);
|
||||||
assert.equal(metric.price_move_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', () => {
|
test('portfolio metric id keys off the baseline and current snapshots', () => {
|
||||||
const metricId = buildPortfolioMetricId({
|
const metricId = buildPortfolioMetricId({
|
||||||
baselineInventoryId: 'baseline-1',
|
baselineInventoryId: 'baseline-1',
|
||||||
|
|
|
||||||
166
test/quote-outcomes.test.mjs
Normal file
166
test/quote-outcomes.test.mjs
Normal file
|
|
@ -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'],
|
||||||
|
);
|
||||||
|
});
|
||||||
Loading…
Add table
Reference in a new issue