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