From 729d2ade0e835e0ad3a7fd9860912617d12498e6 Mon Sep 17 00:00:00 2001 From: philipp Date: Mon, 18 May 2026 18:52:18 +0200 Subject: [PATCH] Implement pair-native trade semantics Proof: Pair-native trade semantics and multi-asset outcome truth; strategy, request preflight, outcome attribution, valuation visibility, dashboard labels, alerts, and ops watch paths now use DB pair/asset/route metadata with nBTC/EURe compatibility and nBTC/USDC regressions covered. Assumptions: Postgres asset, pair, strategy config, and price route rows remain canonical; supported reference adapters remain BTC/EUR and BTC/USDC; deployment is push-driven through the existing Forgejo workflow. Still fake: Arbitrary multi-hop valuation, new execution venues, fee-complete realized PnL, venue-native terminal fill ingestion, and autonomous optimization remain unbuilt. --- scripts/ops/watch_live_mm.py | 54 +++-- src/apps/history-writer.mjs | 4 +- src/apps/market-reference-ingest.mjs | 4 + src/apps/ops-sentinel.mjs | 9 + src/apps/strategy-engine.mjs | 7 +- src/apps/trade-executor.mjs | 11 +- src/core/alert-engine.mjs | 51 ++++- src/core/intent-request-controller.mjs | 211 +++++++++++++++--- src/core/intent-request-outcomes.mjs | 38 +++- src/core/operator-dashboard.mjs | 124 ++++++++-- src/core/portfolio-metrics.mjs | 84 ++++++- src/core/quote-outcomes.mjs | 53 ++++- src/core/route-rates.mjs | 144 ++++++++++++ src/core/runtime-health.mjs | 8 +- src/core/strategy.mjs | 147 +++++++----- src/lib/postgres.mjs | 29 ++- .../static/components/StatusBar.jsx | 10 +- .../static/pages/FundsPage.jsx | 57 +++-- .../static/pages/StrategyPage.jsx | 18 +- test/alert-engine.test.mjs | 60 +++++ test/intent-request-outcomes.test.mjs | 51 +++++ test/intent-requests.test.mjs | 193 ++++++++++++++++ test/operator-dashboard-ui-static.test.mjs | 19 ++ test/portfolio-metrics.test.mjs | 51 +++++ test/price-route-runtime-static.test.mjs | 2 + test/quote-outcomes.test.mjs | 98 ++++++++ test/route-rates.test.mjs | 87 ++++++++ test/runtime-health.test.mjs | 30 +++ test/strategy-threshold-config.test.mjs | 7 + test/strategy.test.mjs | 6 +- 30 files changed, 1504 insertions(+), 163 deletions(-) create mode 100644 src/core/route-rates.mjs create mode 100644 test/route-rates.test.mjs diff --git a/scripts/ops/watch_live_mm.py b/scripts/ops/watch_live_mm.py index b4bef10..61ac70d 100644 --- a/scripts/ops/watch_live_mm.py +++ b/scripts/ops/watch_live_mm.py @@ -11,7 +11,7 @@ from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent)) -from common import DEFAULT_NAMESPACE, config_value, kubectl +from common import DEFAULT_NAMESPACE, kubectl CONTROL_PORTS = { @@ -120,19 +120,15 @@ def main() -> int: def load_asset_registry(*, namespace: str) -> dict[str, AssetMeta]: - btc = AssetMeta( - asset_id=config_value("TRADING_BTC_ASSET_ID", namespace=namespace), - symbol=config_value("TRADING_BTC_SYMBOL", namespace=namespace) or "BTC", - decimals=int(config_value("TRADING_BTC_DECIMALS", namespace=namespace) or "8"), - ) - eure = AssetMeta( - asset_id=config_value("TRADING_EURE_ASSET_ID", namespace=namespace), - symbol=config_value("TRADING_EURE_SYMBOL", namespace=namespace) or "EURe", - decimals=int(config_value("TRADING_EURE_DECIMALS", namespace=namespace) or "18"), - ) + rows = query_rows(ASSET_QUERY, namespace=namespace, columns=ASSET_COLUMNS) return { - btc.asset_id: btc, - eure.asset_id: eure, + row["asset_id"]: AssetMeta( + asset_id=row["asset_id"], + symbol=row["symbol"] or row["asset_id"], + decimals=int(row["decimals"] or "0"), + ) + for row in rows + if row["asset_id"] } @@ -358,7 +354,8 @@ def render_decision_row(row: dict[str, str], assets: dict[str, AssetMeta]) -> st f" direction={row['direction']}" f" decision={row['decision']}" f" reason={row['decision_reason']}" - f" notional_eure={row['eure_notional'] or '-'}" + f" notional={row.get('notional') or row.get('eure_notional') or '-'}" + f" notional_symbol={row.get('notional_symbol') or ('EURe' if row.get('eure_notional') else '-')}" f" gross_edge_pct={row['gross_edge_pct'] or '-'}" f" need={need}" f" have={have}" @@ -451,12 +448,39 @@ DECISION_COLUMNS = [ "decision", "decision_reason", "gross_edge_pct", + "notional", + "notional_symbol", "eure_notional", "inventory_asset", "inventory_available", "inventory_required", ] +ASSET_COLUMNS = [ + "asset_id", + "symbol", + "decimals", +] + +ASSET_QUERY = """ +select + asset_id, + coalesce(symbol, ''), + coalesce(decimals::text, '') +from trading_assets +where enabled_for_inventory = true + or asset_id in ( + select payload->>'asset_in' from trade_decisions where payload ? 'asset_in' + union + select payload->>'asset_out' from trade_decisions where payload ? 'asset_out' + union + select payload->>'source_asset_id' from intent_request_preflights where payload ? 'source_asset_id' + union + select payload->>'destination_asset_id' from intent_request_preflights where payload ? 'destination_asset_id' + ) +order by symbol, asset_id; +""" + COMMAND_COLUMNS = [ "event_id", "ingested_at", @@ -505,6 +529,8 @@ select coalesce(payload->>'decision', ''), coalesce(payload->>'decision_reason', ''), coalesce(payload->>'gross_edge_pct', ''), + coalesce(payload->>'notional', ''), + coalesce(payload->>'notional_symbol', ''), coalesce(payload->>'eure_notional', ''), coalesce(payload->>'inventory_asset', ''), coalesce(payload->>'inventory_available', ''), diff --git a/src/apps/history-writer.mjs b/src/apps/history-writer.mjs index 79455a3..2353e8c 100644 --- a/src/apps/history-writer.mjs +++ b/src/apps/history-writer.mjs @@ -464,8 +464,8 @@ async function refreshQuoteOutcomeAttributions() { async function requireTradingConfig() { const tradingConfig = await tradingConfigStore.getConfig(); - if (!tradingConfig.ok || !tradingConfig.tradingBtc || !tradingConfig.tradingEure) { - throw new Error(`trading config unavailable: ${tradingConfig.blockReason || 'missing current assets'}`); + if (!tradingConfig.ok) { + throw new Error(`trading config unavailable: ${tradingConfig.blockReason || 'unavailable'}`); } return tradingConfig; } diff --git a/src/apps/market-reference-ingest.mjs b/src/apps/market-reference-ingest.mjs index ecdd07b..40be34a 100644 --- a/src/apps/market-reference-ingest.mjs +++ b/src/apps/market-reference-ingest.mjs @@ -271,6 +271,8 @@ function buildBtcEurPriceEvent(now, { price_route_id: referencePair.priceRoute.routeId, base_asset_id: referencePair.priceRoute.baseAssetId, quote_asset_id: referencePair.priceRoute.quoteAssetId, + quote_per_base: eurPerBtc.toFixed(8), + base_per_quote: btcPerEur.toFixed(12), reference_pair: 'BTC/EUR', eur_per_btc: eurPerBtc.toFixed(8), eure_per_btc: eurPerBtc.toFixed(8), @@ -308,6 +310,8 @@ function buildBtcUsdcPriceEvent(now, { price_route_id: referencePair.priceRoute.routeId, base_asset_id: referencePair.priceRoute.baseAssetId, quote_asset_id: referencePair.priceRoute.quoteAssetId, + quote_per_base: usdcPerBtc.toFixed(8), + base_per_quote: btcPerUsdc.toFixed(12), reference_pair: 'BTC/USDC', eur_per_btc: eurPerBtc.toFixed(8), eure_per_btc: eurPerBtc.toFixed(8), diff --git a/src/apps/ops-sentinel.mjs b/src/apps/ops-sentinel.mjs index 6068cef..c1aaf76 100644 --- a/src/apps/ops-sentinel.mjs +++ b/src/apps/ops-sentinel.mjs @@ -112,6 +112,14 @@ const state = { const alertEngine = createAlertEngine({ activePair: config.activePair, + activePairs: (config.observedPairs || config.pairs || []).map((pair) => pair.key || pair.pairId).filter(Boolean), + priceRoutes: (config.pairs || []) + .filter((pair) => pair.priceRoute?.routeId) + .map((pair) => ({ + pair: pair.key || pair.pairId, + price_route_id: pair.priceRoute.routeId, + reference_pair: pair.priceRoute.routeConfig?.reference_pair || pair.priceRoute.source || null, + })), priceStaleMs: config.opsSentinelPriceStaleMs, inventoryStaleMs: config.opsSentinelInventoryStaleMs, fundingCreditPendingMs: config.opsSentinelFundingCreditPendingMs, @@ -290,6 +298,7 @@ async function evaluateRuntimeHealthLoop() { state.service_health = [...evaluateRuntimeHealth({ servicesByName, activePair: config.activePair, + activePairs: (config.observedPairs || config.pairs || []).map((pair) => pair.key || pair.pairId).filter(Boolean), activeAlerts: desiredRuntimeAlerts, now, }).values()]; diff --git a/src/apps/strategy-engine.mjs b/src/apps/strategy-engine.mjs index 4d4f28f..23457c0 100644 --- a/src/apps/strategy-engine.mjs +++ b/src/apps/strategy-engine.mjs @@ -113,6 +113,8 @@ async function handleDemand(event) { if (seenQuotes.has(event.payload.quote_id)) { const pair = tradingConfig.pairByKey?.get(event.payload.pair || `${event.payload.asset_in}->${event.payload.asset_out}`); const strategyConfig = pair?.strategyConfig || null; + const legacyEureNotional = !pair?.priceRoute + || pair.priceRoute.quoteAssetId === tradingConfig.tradingEure?.assetId; await publishDecision({ decision_id: `duplicate-${event.payload.quote_id}`, quote_id: event.payload.quote_id, @@ -126,7 +128,10 @@ async function handleDemand(event) { decision: 'rejected', decision_reason: 'duplicate_quote_id', threshold_pct: strategyConfig?.edgeBps == null ? null : String(Number(strategyConfig.edgeBps) / 100), - max_notional_eure: strategyConfig?.maxNotional == null ? null : String(strategyConfig.maxNotional), + max_notional: strategyConfig?.maxNotional == null ? null : String(strategyConfig.maxNotional), + max_notional_eure: legacyEureNotional && strategyConfig?.maxNotional != null + ? String(strategyConfig.maxNotional) + : null, strategy_armed: state.armed, }); return; diff --git a/src/apps/trade-executor.mjs b/src/apps/trade-executor.mjs index 1a04e9b..bda7310 100644 --- a/src/apps/trade-executor.mjs +++ b/src/apps/trade-executor.mjs @@ -26,6 +26,7 @@ import { loadLatestIntentRequestSubmission, loadLatestInventorySnapshot, loadLatestMarketPrice, + loadLatestMarketPriceForRoute, refreshIntentRequestOutcomes, seedTradingConfig, } from '../lib/postgres.mjs'; @@ -452,7 +453,11 @@ const controlApi = startControlApi({ function createIntentRequestStore() { return { loadLatestInventorySnapshot: () => loadLatestInventorySnapshot(requestPool), - loadLatestMarketPrice: () => loadLatestMarketPrice(requestPool), + loadLatestMarketPrice: ({ priceRouteId } = {}) => ( + priceRouteId + ? loadLatestMarketPriceForRoute(requestPool, { priceRouteId }) + : loadLatestMarketPrice(requestPool) + ), findPreflight: ({ requestId = null, idempotencyKey = null } = {}) => ( loadIntentRequestPreflightByIdOrKey(requestPool, { requestId, idempotencyKey }) ), @@ -504,8 +509,8 @@ function createIntentRequestStore() { }, refreshOutcomes: async () => { const tradingConfig = await tradingConfigStore.getConfig(); - if (!tradingConfig.ok || !tradingConfig.tradingBtc || !tradingConfig.tradingEure) { - throw new Error(`trading config unavailable: ${tradingConfig.blockReason || 'missing current assets'}`); + if (!tradingConfig.ok) { + throw new Error(`trading config unavailable: ${tradingConfig.blockReason || 'unavailable'}`); } return refreshIntentRequestOutcomes(requestPool, { btcAsset: tradingConfig.tradingBtc, diff --git a/src/core/alert-engine.mjs b/src/core/alert-engine.mjs index 089ead5..cd82d36 100644 --- a/src/core/alert-engine.mjs +++ b/src/core/alert-engine.mjs @@ -2,6 +2,8 @@ const DEFAULT_RECENT_LIMIT = 50; export function createAlertEngine({ activePair, + activePairs = activePair ? [activePair] : [], + priceRoutes = [], priceStaleMs, inventoryStaleMs, fundingCreditPendingMs, @@ -11,6 +13,7 @@ export function createAlertEngine({ }) { const state = { latest_price: null, + latest_prices_by_route: {}, latest_inventory: null, latest_liquidity_action: null, latest_trade_result: null, @@ -26,6 +29,7 @@ export function createAlertEngine({ switch (topic) { case 'ref.market_price': state.latest_price = payload; + if (payload?.price_route_id) state.latest_prices_by_route[payload.price_route_id] = payload; break; case 'state.intent_inventory': state.latest_inventory = payload; @@ -48,6 +52,8 @@ export function createAlertEngine({ return evaluateAlerts({ state, activePair, + activePairs, + priceRoutes, priceStaleMs, inventoryStaleMs, fundingCreditPendingMs, @@ -60,6 +66,8 @@ export function createAlertEngine({ return evaluateAlerts({ state, activePair, + activePairs, + priceRoutes, priceStaleMs, inventoryStaleMs, fundingCreditPendingMs, @@ -79,6 +87,8 @@ export function createAlertEngine({ getState(now = new Date().toISOString()) { return summarizeState({ state, + activePairs, + priceRoutes, evaluationIntervalMs, now, }); @@ -89,6 +99,8 @@ export function createAlertEngine({ function evaluateAlerts({ state, activePair, + activePairs = activePair ? [activePair] : [], + priceRoutes = [], priceStaleMs, inventoryStaleMs, fundingCreditPendingMs, @@ -99,26 +111,41 @@ function evaluateAlerts({ const desired = new Map(); const nowValue = timestampValue(now); - const priceAgeMs = ageMs(state.latest_price?.observed_at || state.latest_price?.ingested_at, nowValue); - if (priceAgeMs == null || priceAgeMs > priceStaleMs) { + const routesToCheck = priceRoutes.length + ? priceRoutes + : [{ + pair: activePair, + price_route_id: state.latest_price?.price_route_id || null, + reference_pair: state.latest_price?.reference_pair || null, + }]; + for (const route of routesToCheck) { + const latestPrice = route.price_route_id + ? state.latest_prices_by_route[route.price_route_id] + : state.latest_price; + const priceAgeMs = ageMs(latestPrice?.observed_at || latestPrice?.ingested_at, nowValue); + if (priceAgeMs != null && priceAgeMs <= priceStaleMs) continue; desired.set( buildAlertKey({ alertCode: 'reference_price_stale', serviceScope: 'market-reference-ingest', - pair: activePair, + pair: route.pair || activePair, + assetId: route.price_route_id || null, }), { alert_code: 'reference_price_stale', severity: 'warning', reason: priceAgeMs == null - ? 'no reference price has been observed' - : `reference price age ${priceAgeMs}ms exceeds ${priceStaleMs}ms`, + ? `no reference price has been observed for ${route.reference_pair || route.price_route_id || 'route'}` + : `reference price age ${priceAgeMs}ms exceeds ${priceStaleMs}ms for ${route.reference_pair || route.price_route_id || 'route'}`, service_scope: 'market-reference-ingest', - pair: activePair, + pair: route.pair || activePair, asset_id: null, tx_hash: null, details: { - last_price_at: state.latest_price?.observed_at || state.latest_price?.ingested_at || null, + price_route_id: route.price_route_id || null, + reference_pair: route.reference_pair || null, + active_pairs: activePairs, + last_price_at: latestPrice?.observed_at || latestPrice?.ingested_at || null, age_ms: priceAgeMs, stale_after_ms: priceStaleMs, }, @@ -411,7 +438,7 @@ function reconcileRuntimeAlertState({ return transitions; } -function summarizeState({ state, evaluationIntervalMs, now }) { +function summarizeState({ state, activePairs = [], priceRoutes = [], evaluationIntervalMs, now }) { const activeAlerts = Object.values(state.active_alerts) .sort((left, right) => timestampValue(right.first_raised_at) - timestampValue(left.first_raised_at)); const nowValue = timestampValue(now); @@ -421,8 +448,16 @@ function summarizeState({ state, evaluationIntervalMs, now }) { recent_transitions: state.recent_transitions, last_evaluated_at: state.last_evaluated_at, stale: ageMs(state.last_evaluated_at, nowValue) > (evaluationIntervalMs * 2), + active_pairs: activePairs, + price_routes: priceRoutes, latest_inputs: { market_price_at: state.latest_price?.observed_at || state.latest_price?.ingested_at || null, + market_prices_by_route: Object.fromEntries( + Object.entries(state.latest_prices_by_route || {}).map(([routeId, price]) => [ + routeId, + price?.observed_at || price?.ingested_at || null, + ]), + ), inventory_at: state.latest_inventory?.synced_at || state.latest_inventory?.ingested_at || null, liquidity_action_at: state.latest_liquidity_action?.observed_at || null, trade_result_at: state.latest_trade_result?.ingested_at || null, diff --git a/src/core/intent-request-controller.mjs b/src/core/intent-request-controller.mjs index 2cc7ff1..d2aef1f 100644 --- a/src/core/intent-request-controller.mjs +++ b/src/core/intent-request-controller.mjs @@ -4,7 +4,7 @@ import { serializeError } from './log.mjs'; import { applySlippageBps, buildSolverQuoteRequest, - computeBtcReceiveUnitsFromEure, + formatUnitsDecimal, futureIso, isExpired, normalizeRelayPublishResponse, @@ -12,6 +12,12 @@ import { parseDecimalToUnits, selectBestSolverQuote, } from './intent-requests.mjs'; +import { + classifyRouteDirection, + computeDestinationAmountUnitsFromRoute, + isSupportedPriceRouteSource, + resolveRouteRates, +} from './route-rates.mjs'; import { buildIntentRequestSubmission } from '../venues/near-intents/signing.mjs'; export function createIntentRequestController({ @@ -41,28 +47,25 @@ export function createIntentRequestController({ const requestPair = await resolveIntentRequestPair({ body, config, getTradingConfig }); const sourceAsset = requestPair.sourceAsset; const destinationAsset = requestPair.destinationAsset; - const amountEure = String( - body.amount_eure + const sourceAmount = String( + body.source_amount || body.amount + || body.amount_eure || requestPair.requestDefaultNotional || config.intentRequestDefaultAmountEure || '5', ); + const legacyAmountEure = body.amount_eure == null ? null : String(body.amount_eure); const slippageBps = Number(body.slippage_bps ?? requestPair.slippageBps ?? config.intentRequestDefaultSlippageBps ?? 200); const minDeadlineMs = Number(body.min_deadline_ms || requestPair.minDeadlineMs || config.intentRequestMinDeadlineMs || 60_000); - const maxAmountUnits = requestPair.requestMaxNotional == null - ? null - : parseDecimalToUnits( - String(requestPair.requestMaxNotional), - sourceAsset.decimals, - { field: 'intent_request_max_notional' }, - ); let sourceAmountUnits = '0'; let expectedDestinationAmountUnits = '0'; let minDestinationAmountUnits = '0'; let inventorySnapshot = null; let marketPrice = null; + let routeDirection = null; + let routeRates = null; let signerRegistered = null; let solverQuoteResponse = null; let solverQuotes = []; @@ -81,12 +84,23 @@ export function createIntentRequestController({ requestPair.reasonText, ); } - sourceAmountUnits = parseDecimalToUnits(amountEure, sourceAsset.decimals, { field: 'amount_eure' }); + if (!Number.isInteger(sourceAsset?.decimals) || !Number.isInteger(destinationAsset?.decimals)) { + blockedBeforeQuote = true; + throw codedError('asset_decimals_missing', 'Source and destination asset decimals are required.'); + } + const maxAmountUnits = requestPair.requestMaxNotional == null + ? null + : parseDecimalToUnits( + String(requestPair.requestMaxNotional), + sourceAsset.decimals, + { field: 'intent_request_max_notional' }, + ); + sourceAmountUnits = parseDecimalToUnits(sourceAmount, sourceAsset.decimals, { field: 'source_amount' }); if (maxAmountUnits != null && BigInt(sourceAmountUnits) > BigInt(maxAmountUnits)) { blockedBeforeQuote = true; throw codedError( 'amount_exceeds_request_limit', - `Requested ${amountEure} ${sourceAsset.symbol} exceeds configured live request limit ${requestPair.requestMaxNotional} ${sourceAsset.symbol}.`, + `Requested ${sourceAmount} ${sourceAsset.symbol} exceeds configured live request limit ${requestPair.requestMaxNotional} ${sourceAsset.symbol}.`, ); } if (!Number.isInteger(slippageBps) || slippageBps < 0) { @@ -106,7 +120,7 @@ export function createIntentRequestController({ [inventorySnapshot, marketPrice, signerRegistered] = await Promise.all([ store.loadLatestInventorySnapshot(), - store.loadLatestMarketPrice(), + store.loadLatestMarketPrice({ priceRouteId: requestPair.priceRoute?.routeId || null }), verifierClient.isPublicKeyRegistered({ accountId: config.nearIntentsAccountId }), ]); @@ -120,9 +134,22 @@ export function createIntentRequestController({ blockedBeforeQuote = true; throw codedError('stale_inventory', 'Inventory snapshot is too stale for request creation.'); } - if (!marketPrice?.payload?.eure_per_btc) { + routeDirection = classifyRouteDirection({ + sourceAssetId: sourceAsset.assetId, + destinationAssetId: destinationAsset.assetId, + priceRoute: requestPair.priceRoute, + }); + routeRates = resolveRouteRates({ + price: marketPrice?.payload || null, + priceRoute: requestPair.priceRoute, + direction: routeDirection, + }); + if (!routeRates.ok) { blockedBeforeQuote = true; - throw codedError('reference_price_unavailable', 'No BTC/EUR reference price is available.'); + throw codedError( + routeRates.reason === 'unsupported_price_route' ? 'unsupported_price_route' : 'reference_price_unavailable', + `No usable reference price is available for route ${requestPair.priceRoute?.routeId || 'unknown'}.`, + ); } if (!isFresh(priceObservedAt, requestPair.priceMaxAgeMs ?? config.intentRequestPriceMaxAgeMs ?? config.strategyPriceMaxAgeMs, now())) { blockedBeforeQuote = true; @@ -136,14 +163,19 @@ export function createIntentRequestController({ const spendableUnits = String(inventorySnapshot.payload.spendable[sourceAsset.assetId] || '0'); if (BigInt(spendableUnits) < BigInt(sourceAmountUnits)) { blockedBeforeQuote = true; - throw codedError('insufficient_spendable_eure', 'Spendable EURe is below the requested amount.'); + throw codedError( + insufficientInventoryCode(sourceAsset), + `Spendable ${sourceAsset.symbol || sourceAsset.assetId} is below the requested amount.`, + ); } - expectedDestinationAmountUnits = computeBtcReceiveUnitsFromEure({ - eureUnits: sourceAmountUnits, - eurPerBtc: marketPrice.payload.eure_per_btc, - eureDecimals: sourceAsset.decimals, - btcDecimals: destinationAsset.decimals, + expectedDestinationAmountUnits = computeDestinationAmountUnitsFromRoute({ + sourceAmountUnits, + sourceDecimals: sourceAsset.decimals, + destinationDecimals: destinationAsset.decimals, + direction: routeDirection, + quotePerBase: routeRates.quotePerBase, + basePerQuote: routeRates.basePerQuote, }); minDestinationAmountUnits = applySlippageBps(expectedDestinationAmountUnits, slippageBps); @@ -182,6 +214,14 @@ export function createIntentRequestController({ }); } + const preflightNotional = buildPreflightNotional({ + sourceAsset, + destinationAsset, + sourceAmount, + sourceAmountUnits, + expectedDestinationAmountUnits, + priceRoute: requestPair.priceRoute, + }); const payload = { request_id: requestId, idempotency_key: idempotencyKey, @@ -207,14 +247,22 @@ export function createIntentRequestController({ ? null : Number(requestPair.requestMaxSlippageBps), price_route_id: requestPair.priceRoute?.routeId || null, + reference_price_id: routeRates?.referencePriceId || marketPrice?.payload?.price_id || null, + route_direction: routeDirection, source_asset_id: sourceAsset.assetId, source_symbol: sourceAsset.symbol, source_decimals: sourceAsset.decimals, destination_asset_id: destinationAsset.assetId, destination_symbol: destinationAsset.symbol, destination_decimals: destinationAsset.decimals, + source_amount: sourceAmount, source_amount_units: sourceAmountUnits, - amount_eure: amountEure, + destination_amount_units: expectedDestinationAmountUnits, + notional: preflightNotional.notional, + notional_asset_id: preflightNotional.notionalAssetId, + notional_symbol: preflightNotional.notionalSymbol, + amount_eure: legacyAmountEure + ?? (sourceAsset.assetId === config.tradingEure?.assetId ? sourceAmount : null), expected_destination_amount_units: expectedDestinationAmountUnits, min_destination_amount_units: minDestinationAmountUnits, quoted_destination_amount_units: selectedQuote?.amount_out || null, @@ -235,7 +283,14 @@ export function createIntentRequestController({ market_price: marketPrice ? { ingested_at: marketPrice.ingested_at, observed_at: marketPrice.payload?.observed_at || null, + price_route_id: marketPrice.payload?.price_route_id || requestPair.priceRoute?.routeId || null, + reference_pair: marketPrice.payload?.reference_pair || requestPair.priceRoute?.routeConfig?.reference_pair || null, + base_asset_id: marketPrice.payload?.base_asset_id || requestPair.priceRoute?.baseAssetId || null, + quote_asset_id: marketPrice.payload?.quote_asset_id || requestPair.priceRoute?.quoteAssetId || null, + quote_per_base: routeRates?.quotePerBase == null ? null : String(routeRates.quotePerBase), + base_per_quote: routeRates?.basePerQuote == null ? null : String(routeRates.basePerQuote), eure_per_btc: marketPrice.payload?.eure_per_btc || null, + usdc_per_btc: marketPrice.payload?.usdc_per_btc || null, price_id: marketPrice.payload?.price_id || null, } : null, solver_quote_count: solverQuotes.length, @@ -313,10 +368,14 @@ export function createIntentRequestController({ const latestInventory = await store.loadLatestInventorySnapshot(); const spendableUnits = String(latestInventory?.payload?.spendable?.[preflight.source_asset_id] || '0'); if (BigInt(spendableUnits) < BigInt(preflight.source_amount_units)) { + const sourceAssetForReason = { + assetId: preflight.source_asset_id, + symbol: preflight.source_symbol, + }; const blocked = await recordSubmissionResult(preflight, { status: 'blocked', - result_code: 'insufficient_spendable_eure', - result_text: 'Spendable EURe changed below the requested amount before submit.', + result_code: insufficientInventoryCode(sourceAssetForReason), + result_text: `Spendable ${preflight.source_symbol || preflight.source_asset_id} changed below the requested amount before submit.`, }); return { preflight, submission_result: blocked }; } @@ -458,9 +517,14 @@ export function createIntentRequestController({ max_notional: preflight.max_notional || null, request_max_notional: preflight.request_max_notional || null, price_route_id: preflight.price_route_id || null, + reference_price_id: preflight.reference_price_id || null, + notional: preflight.notional || null, + notional_asset_id: preflight.notional_asset_id || null, + notional_symbol: preflight.notional_symbol || null, source_asset_id: preflight.source_asset_id, destination_asset_id: preflight.destination_asset_id, source_amount_units: preflight.source_amount_units, + destination_amount_units: preflight.destination_amount_units || null, min_destination_amount_units: preflight.min_destination_amount_units, quote_hash: preflight.selected_quote?.quote_hash || extra.quote_hash || null, lifecycle: { @@ -490,7 +554,13 @@ async function resolveIntentRequestPair({ body, config, getTradingConfig }) { destinationAsset: config.tradingBtc, pair: null, strategyConfig: null, - priceRoute: null, + priceRoute: { + routeId: 'legacy-btc-eur-reference', + source: 'btc_eur_reference', + baseAssetId: config.tradingBtc?.assetId, + quoteAssetId: config.tradingEure?.assetId, + routeConfig: { reference_pair: 'BTC/EUR' }, + }, requestDefaultNotional: config.intentRequestDefaultAmountEure, requestMaxNotional: config.intentRequestMaxAmountEure, slippageBps: config.intentRequestDefaultSlippageBps, @@ -513,11 +583,14 @@ async function resolveIntentRequestPair({ body, config, getTradingConfig }) { }); } - const requestedSource = body.source_asset_id || body.asset_in || null; - const requestedDestination = body.destination_asset_id || body.asset_out || null; - const pair = requestedSource && requestedDestination - ? tradingConfig.pairByKey.get(`${requestedSource}->${requestedDestination}`) - : tradingConfig.defaultTakerPair; + const requestedPairId = body.pair_id || null; + const requestedSource = body.source_asset_id || body.asset_in || body.asset_in_id || null; + const requestedDestination = body.destination_asset_id || body.asset_out || body.asset_out_id || null; + const pair = requestedPairId + ? tradingConfig.pairById?.get(requestedPairId) || tradingConfig.pairByKey?.get(requestedPairId) + : requestedSource && requestedDestination + ? tradingConfig.pairByKey.get(`${requestedSource}->${requestedDestination}`) + : tradingConfig.defaultTakerPair; if (!pair) { return blockedRequestPair({ @@ -548,10 +621,21 @@ async function resolveIntentRequestPair({ body, config, getTradingConfig }) { }); } - if (pair.priceRoute?.source !== 'btc_eur_reference') { + const priceRoute = normalizeRequestPriceRoute(pair); + if (!priceRoute) { return blockedRequestPair({ reasonCode: 'price_route_missing', - reasonText: 'Only the DB-backed BTC/EUR price route is supported for request creation in this turn.', + reasonText: 'The selected pair has no DB-backed price route for request creation.', + pair, + sourceAsset: pair.assetIn, + destinationAsset: pair.assetOut, + }); + } + + if (!isSupportedPriceRouteSource(priceRoute.source)) { + return blockedRequestPair({ + reasonCode: 'unsupported_price_route', + reasonText: `The selected pair uses unsupported price route source ${priceRoute.source || 'unknown'}.`, pair, sourceAsset: pair.assetIn, destinationAsset: pair.assetOut, @@ -565,7 +649,7 @@ async function resolveIntentRequestPair({ body, config, getTradingConfig }) { destinationAsset: pair.assetOut, pair, strategyConfig, - priceRoute: pair.priceRoute, + priceRoute, requestDefaultNotional: strategyConfig.requestDefaultNotional || config.intentRequestDefaultAmountEure, requestMaxNotional: strategyConfig.requestMaxNotional ?? null, @@ -577,6 +661,22 @@ async function resolveIntentRequestPair({ body, config, getTradingConfig }) { }; } +function normalizeRequestPriceRoute(pair) { + const priceRoute = pair?.priceRoute || null; + if (!priceRoute) return null; + if (priceRoute.baseAssetId && priceRoute.quoteAssetId) return priceRoute; + const assets = [pair.assetIn, pair.assetOut].filter(Boolean); + const baseAsset = assets.find((asset) => asset.symbol === 'BTC') || null; + const quoteAsset = priceRoute.source === 'btc_usdc_reference' + ? assets.find((asset) => asset.symbol === 'USDC') + : assets.find((asset) => asset.symbol === 'EURe') || assets.find((asset) => asset.symbol !== 'BTC'); + return { + ...priceRoute, + baseAssetId: priceRoute.baseAssetId || baseAsset?.assetId || null, + quoteAssetId: priceRoute.quoteAssetId || quoteAsset?.assetId || null, + }; +} + function blockedRequestPair({ reasonCode, reasonText, @@ -596,6 +696,51 @@ function blockedRequestPair({ }; } +function buildPreflightNotional({ + sourceAsset, + destinationAsset, + sourceAmount, + sourceAmountUnits, + expectedDestinationAmountUnits, + priceRoute, +} = {}) { + const quoteAssetId = priceRoute?.quoteAssetId || null; + if (!quoteAssetId) { + return { + notional: null, + notionalAssetId: null, + notionalSymbol: null, + }; + } + if (sourceAsset?.assetId === quoteAssetId) { + return { + notional: sourceAmount, + notionalAssetId: sourceAsset.assetId, + notionalSymbol: sourceAsset.symbol || null, + }; + } + if (destinationAsset?.assetId === quoteAssetId) { + return { + notional: expectedDestinationAmountUnits + ? formatUnitsDecimal(expectedDestinationAmountUnits, destinationAsset.decimals) + : null, + notionalAssetId: destinationAsset.assetId, + notionalSymbol: destinationAsset.symbol || null, + }; + } + return { + notional: null, + notionalAssetId: quoteAssetId, + notionalSymbol: null, + }; +} + +function insufficientInventoryCode(asset) { + const symbol = String(asset?.symbol || '').trim().toLowerCase(); + if (/^[a-z0-9]+$/.test(symbol)) return `insufficient_spendable_${symbol}`; + return 'insufficient_source_inventory'; +} + function isFresh(timestamp, maxAgeMs, nowMs) { const parsed = Date.parse(timestamp || ''); if (!Number.isFinite(parsed)) return false; diff --git a/src/core/intent-request-outcomes.mjs b/src/core/intent-request-outcomes.mjs index 933dc18..f3e75e7 100644 --- a/src/core/intent-request-outcomes.mjs +++ b/src/core/intent-request-outcomes.mjs @@ -16,13 +16,21 @@ export function deriveIntentRequestOutcomeRecords({ attributionWindowMs = DEFAULT_ATTRIBUTION_WINDOW_MS, settlementGraceMs = DEFAULT_SETTLEMENT_GRACE_MS, } = {}) { - const activeAssetIds = [btcAsset?.assetId, eureAsset?.assetId].filter(Boolean); const preflightsByRequest = new Map( preflights .map(normalizePreflight) .filter((entry) => entry?.request_id) .map((entry) => [entry.request_id, entry]), ); + const expectedDeltasByRequest = new Map(); + for (const preflight of preflightsByRequest.values()) { + const expectedDelta = buildExpectedRequestDelta(preflight, null); + if (expectedDelta) expectedDeltasByRequest.set(preflight.request_id, expectedDelta); + } + const activeAssetIds = uniqueAssetIds([ + ...expectedDeltasByRequest.values(), + Object.fromEntries([btcAsset?.assetId, eureAsset?.assetId].filter(Boolean).map((assetId) => [assetId, 0n])), + ]); const latestSubmissionByRequest = new Map(); for (const submission of submissions.map(normalizeSubmission).filter(Boolean)) { @@ -273,6 +281,7 @@ function deriveOneOutcome({ function buildExpectedRequestDelta(preflight, submission) { const destinationAmount = submission?.destination_amount_units + || preflight?.destination_amount_units || preflight?.selected_quote?.amount_out || preflight?.quoted_destination_amount_units; if (!preflight?.source_asset_id || !preflight?.destination_asset_id) return null; @@ -302,9 +311,14 @@ function baseOutcomeRecord({ submission_id: submission?.submission_id || null, intent_hash: submission?.intent_hash || null, source_asset_id: preflight.source_asset_id, + source_symbol: preflight.source_symbol || null, destination_asset_id: preflight.destination_asset_id, + destination_symbol: preflight.destination_symbol || null, source_amount_units: preflight.source_amount_units, - destination_amount_units: submission?.destination_amount_units || null, + destination_amount_units: submission?.destination_amount_units || preflight.destination_amount_units || null, + notional: preflight.notional || null, + notional_asset_id: preflight.notional_asset_id || null, + notional_symbol: preflight.notional_symbol || null, min_destination_amount_units: preflight.min_destination_amount_units, quote_hash: submission?.quote_hash || preflight.selected_quote?.quote_hash || null, submitted_at: submission?.submitted_at || null, @@ -353,6 +367,10 @@ function movementMatchesExpectedDelta({ for (const [assetId, expected] of Object.entries(expectedDelta)) { if (safeBigInt(movement.delta_units?.[assetId]) !== expected) return false; } + for (const [assetId, actual] of Object.entries(movement.delta_units || {})) { + if (Object.prototype.hasOwnProperty.call(expectedDelta, assetId)) continue; + if (safeBigInt(actual) !== 0n) return false; + } return true; } @@ -405,8 +423,14 @@ function normalizePreflight(entry) { state: payload.state || null, reason_code: payload.reason_code || null, source_asset_id: payload.source_asset_id || null, + source_symbol: payload.source_symbol || null, destination_asset_id: payload.destination_asset_id || null, + destination_symbol: payload.destination_symbol || null, source_amount_units: payload.source_amount_units || null, + destination_amount_units: payload.destination_amount_units || null, + notional: payload.notional || null, + notional_asset_id: payload.notional_asset_id || null, + notional_symbol: payload.notional_symbol || null, min_destination_amount_units: payload.min_destination_amount_units || null, quoted_destination_amount_units: payload.quoted_destination_amount_units || null, selected_quote: payload.selected_quote || null, @@ -471,6 +495,16 @@ function payloadOf(entry) { return entry.payload || entry; } +function uniqueAssetIds(deltaRecords = []) { + const ids = new Set(); + for (const record of deltaRecords || []) { + for (const assetId of Object.keys(record || {})) { + if (assetId) ids.add(assetId); + } + } + return [...ids]; +} + function safeBigInt(value) { if (value == null || value === '') return 0n; return BigInt(String(value)); diff --git a/src/core/operator-dashboard.mjs b/src/core/operator-dashboard.mjs index 5923941..ffe764e 100644 --- a/src/core/operator-dashboard.mjs +++ b/src/core/operator-dashboard.mjs @@ -103,8 +103,8 @@ const CONTROL_DEFINITIONS = [ action: 'intent-request-preflight', method: 'POST', path: '/intent-request/preflight', - label: 'Preflight BTC Request', - description: 'Ask solvers for an EURe-to-BTC request quote without submitting live funds.', + label: 'Preflight Pair Request', + description: 'Ask solvers for a configured pair request quote without submitting live funds.', page: 'funds', risk_class: 'safe', }, @@ -113,8 +113,8 @@ const CONTROL_DEFINITIONS = [ action: 'intent-request-submit', method: 'POST', path: '/intent-request/submit', - label: 'Submit BTC Request', - description: 'Submit a previously drafted EURe-to-BTC request. Relay acceptance is not settlement.', + label: 'Submit Pair Request', + description: 'Submit a previously drafted pair request. Relay acceptance is not settlement.', page: 'funds', risk_class: 'live_funds', }, @@ -702,12 +702,18 @@ export function buildProfitabilitySummary({ metric, submissionSummary } = {}) { } export function buildLiveStatusBar(state) { + const referenceLabel = state.latest_market_price?.reference_pair || 'Reference route'; + const referenceValue = state.latest_market_price?.quote_per_base + || state.latest_market_price?.eure_per_btc + || null; return { near_intents_upstream_status: state.near_intents_status?.status || null, near_intents_upstream_label: state.near_intents_status?.label || null, near_intents_upstream_reason: state.near_intents_status?.decisive_reason || null, near_intents_upstream_observed_at: state.near_intents_status?.observed_at || null, latest_reference_price_eure_per_btc: state.latest_market_price?.eure_per_btc || null, + latest_reference_route_label: referenceLabel, + latest_reference_route_value: referenceValue, market_observed_at: state.latest_market_price?.observed_at || state.latest_market_price?.ingested_at @@ -748,13 +754,35 @@ function buildStatusBar({ servicesByName, nearIntentsStatus = null, }) { + const activePairs = (config.observedPairs || config.pairs || []) + .filter((pair) => pair.observeEnabled || pair.enabled) + .map((pair) => pair.key || pair.pairId) + .filter(Boolean); + const referenceRoutes = (config.pairs || []) + .filter((pair) => pair.priceRoute?.routeId) + .map((pair) => ({ + pair: pair.key || pair.pairId, + price_route_id: pair.priceRoute.routeId, + reference_pair: pair.priceRoute.routeConfig?.reference_pair || pair.priceRoute.source || null, + })); + const referenceLabel = marketPrice?.payload?.reference_pair + || referenceRoutes[0]?.reference_pair + || 'Reference route'; + const referenceValue = marketPrice?.payload?.quote_per_base + || marketPrice?.payload?.eure_per_btc + || null; return { active_pair: config.activePair, + active_pairs: activePairs, + active_pair_count: activePairs.length, + reference_routes: referenceRoutes, near_intents_upstream_status: nearIntentsStatus?.status || null, near_intents_upstream_label: nearIntentsStatus?.label || null, near_intents_upstream_reason: nearIntentsStatus?.decisive_reason || null, near_intents_upstream_observed_at: nearIntentsStatus?.observed_at || null, latest_reference_price_eure_per_btc: marketPrice?.payload?.eure_per_btc || null, + latest_reference_route_label: referenceLabel, + latest_reference_route_value: referenceValue, market_observed_at: marketPrice?.payload?.observed_at || marketPrice?.ingested_at || null, market_freshness_ms: ageMs(marketPrice?.payload?.observed_at || marketPrice?.ingested_at), inventory_observed_at: @@ -785,6 +813,11 @@ function buildBalanceSummary({ inventorySnapshot, marketPrice, config, portfolio .filter((asset) => asset?.asset_id) .map((asset) => [asset.asset_id, asset]), ); + const metricUnvaluedByAssetId = new Map( + (portfolioMetric?.payload?.current_inventory?.unvalued_assets || []) + .filter((asset) => asset?.asset_id) + .map((asset) => [asset.asset_id, asset]), + ); return { synced_at: inventory.synced_at || inventorySnapshot?.ingested_at || null, @@ -794,6 +827,13 @@ function buildBalanceSummary({ inventorySnapshot, marketPrice, config, portfolio const pendingInboundUnits = String(pendingInbound[asset.assetId] || '0'); const pendingOutboundUnits = String(pendingOutbound[asset.assetId] || '0'); const metricValuation = metricValuationsByAssetId.get(asset.assetId); + const metricUnvalued = metricUnvaluedByAssetId.get(asset.assetId); + const fallbackValue = valueAssetInEur({ + asset, + units: spendableUnits, + marketPrice: marketPrice?.payload || marketPrice || null, + }); + const value = metricValuation?.value_eure || fallbackValue; return { asset_id: asset.assetId, symbol: asset.symbol, @@ -806,13 +846,12 @@ function buildBalanceSummary({ inventorySnapshot, marketPrice, config, portfolio pending_inbound: formatUnits(pendingInboundUnits, asset.decimals), pending_outbound_units: pendingOutboundUnits, pending_outbound: formatUnits(pendingOutboundUnits, asset.decimals), - eur_value_eure: metricValuation?.value_eure - || valueAssetInEur({ - asset, - units: spendableUnits, - marketPrice: marketPrice?.payload || marketPrice || null, - }), + eur_value_eure: value, eur_value_source: metricValuation?.valuation_source || null, + valuation_status: value != null ? 'valued' : 'unvalued', + valuation_reason: value == null + ? metricUnvalued?.valuation_reason || 'valuation_route_missing' + : null, }; }), }; @@ -914,25 +953,46 @@ function buildIntentRequestSummary({ config, intentRequests = [], executorState const usingDbPairConfig = Boolean(config.tradingConfigLoaded && strategyConfig); const sourceAsset = defaultPair?.assetIn || config.tradingEure; const destinationAsset = defaultPair?.assetOut || config.tradingBtc; + const takerPairs = (config.pairs || []).filter((pair) => pair.takerEnabled); return { defaults: { + pair_id: defaultPair?.pairId || null, + pair_label: defaultPair + ? `${sourceAsset?.symbol || defaultPair.asset_in} -> ${destinationAsset?.symbol || defaultPair.asset_out}` + : null, source_symbol: sourceAsset?.symbol || 'Source', destination_symbol: destinationAsset?.symbol || 'Destination', - amount_eure: String(strategyConfig?.requestDefaultNotional ?? config.intentRequestDefaultAmountEure ?? 5), - max_amount_eure: usingDbPairConfig + source_amount: String(strategyConfig?.requestDefaultNotional ?? config.intentRequestDefaultAmountEure ?? 5), + max_source_amount: usingDbPairConfig ? strategyConfig.requestMaxNotional ?? null : String(config.intentRequestMaxAmountEure || 5), + amount_eure: sourceAsset?.assetId === config.tradingEure?.assetId + ? String(strategyConfig?.requestDefaultNotional ?? config.intentRequestDefaultAmountEure ?? 5) + : null, + max_amount_eure: sourceAsset?.assetId === config.tradingEure?.assetId + ? (usingDbPairConfig ? strategyConfig.requestMaxNotional ?? null : String(config.intentRequestMaxAmountEure || 5)) + : null, slippage_bps: Number(strategyConfig?.slippageBps ?? config.intentRequestDefaultSlippageBps ?? 200), max_slippage_bps: usingDbPairConfig ? strategyConfig.requestMaxSlippageBps ?? null : Number(config.intentRequestMaxSlippageBps ?? 200), }, + pairs: takerPairs.map((pair) => ({ + pair_id: pair.pairId, + pair: pair.key, + label: `${pair.assetIn?.symbol || pair.asset_in} -> ${pair.assetOut?.symbol || pair.asset_out}`, + mode: pair.mode, + can_trade: pair.canTrade, + block_reason: pair.blockReason || null, + source_symbol: pair.assetIn?.symbol || pair.asset_in, + destination_symbol: pair.assetOut?.symbol || pair.asset_out, + })), executor_armed: executorState.armed ?? null, executor_paused: executorState.paused ?? null, request_creation_state: executorState.request_creation || null, items: (intentRequests || []).map((request) => normalizeIntentRequestForUi({ config, request })), caveat: - 'Own request relay acceptance is not a completed trade. Completed requires durable EURe decrease and BTC increase evidence.', + 'Own request relay acceptance is not a completed trade. Completed requires durable source decrease and destination increase evidence.', }; } @@ -1113,6 +1173,9 @@ export function deriveQuoteLifecycleRows({ direction: decision.direction, request_kind: decision.request_kind, gross_edge_pct: decision.gross_edge_pct, + notional: decision.notional, + notional_asset_id: decision.notional_asset_id, + notional_symbol: decision.notional_symbol, eure_notional: decision.eure_notional, decision, decision_at: decision.decision_at || null, @@ -1145,6 +1208,10 @@ export function deriveQuoteLifecycleRows({ asset_out: command.asset_out || null, amount_in: command.amount_in || null, amount_out: command.amount_out || null, + notional: command.notional || null, + notional_asset_id: command.notional_asset_id || null, + notional_symbol: command.notional_symbol || null, + eure_notional: command.eure_notional || null, command, command_at: command.command_at || null, }); @@ -1172,6 +1239,9 @@ export function deriveQuoteLifecycleRows({ direction: outcome?.direction || null, request_kind: outcome?.request_kind || null, gross_edge_pct: outcome?.gross_edge_pct || null, + notional: outcome?.notional || null, + notional_asset_id: outcome?.notional_asset_id || null, + notional_symbol: outcome?.notional_symbol || null, eure_notional: outcome?.eure_notional || null, outcome, command_at: outcome?.command_at || null, @@ -1201,6 +1271,9 @@ function ensureLifecycleRow(rowsByKey, key) { direction: null, request_kind: null, gross_edge_pct: null, + notional: null, + notional_asset_id: null, + notional_symbol: null, eure_notional: null, quote_observed_at: null, decision_at: null, @@ -1431,6 +1504,10 @@ function normalizeCommand(command) { edge_bps: command.edge_bps || null, direction: command.direction || null, request_kind: command.request_kind || null, + notional: command.notional || null, + notional_asset_id: command.notional_asset_id || null, + notional_symbol: command.notional_symbol || null, + eure_notional: command.eure_notional || null, asset_in: command.asset_in || null, asset_out: command.asset_out || null, amount_in: command.amount_in ?? null, @@ -1910,8 +1987,10 @@ function normalizeTradeForUi({ config, trade }) { } function enrichLifecycleRowForUi({ config, row }) { + const notionalDisplay = formatNotional(row); return { ...row, + notional_display: notionalDisplay, request_terms: buildLifecycleTerms({ config, terms: row.quote || row, @@ -1920,6 +1999,7 @@ function enrichLifecycleRowForUi({ config, row }) { config, terms: row.command || row.execution || null, }), + gross_edge_value: estimateGrossEdgeValue(row), gross_edge_value_eure: estimateGrossEdgeValueEure(row), settlement_summary: buildSettlementSummary({ config, @@ -1951,13 +2031,25 @@ function buildLifecycleTerms({ config, terms }) { } function estimateGrossEdgeValueEure(row) { + if (row?.notional && row?.notional_symbol && row.notional_symbol !== 'EURe') return null; + return estimateGrossEdgeValue(row); +} + +function estimateGrossEdgeValue(row) { const edge = Number(row?.gross_edge_pct); - const notional = Number(row?.eure_notional); + const notional = Number(row?.notional ?? row?.eure_notional); if (!Number.isFinite(edge) || !Number.isFinite(notional)) return null; const value = (notional * edge) / 100; return value.toFixed(8).replace(/\.?0+$/, ''); } +function formatNotional(row) { + const notional = row?.notional ?? row?.eure_notional ?? null; + if (notional == null) return null; + const symbol = row?.notional_symbol || (row?.eure_notional ? 'EURe' : null); + return symbol ? `${notional} ${symbol}` : String(notional); +} + function buildSettlementSummary({ config, delta, @@ -2155,9 +2247,13 @@ function normalizeDecision(decision) { decision_reason: normalizeDecisionReason(decision.decision_reason), gross_edge_pct: decision.gross_edge_pct || null, threshold_pct: decision.threshold_pct || null, + max_notional: decision.max_notional || null, max_notional_eure: decision.max_notional_eure || null, strategy_armed: decision.strategy_armed ?? null, inventory_asset: decision.inventory_asset || null, + notional: decision.notional || null, + notional_asset_id: decision.notional_asset_id || null, + notional_symbol: decision.notional_symbol || null, eure_notional: decision.eure_notional || null, }; } diff --git a/src/core/portfolio-metrics.mjs b/src/core/portfolio-metrics.mjs index 0754c97..3646a5e 100644 --- a/src/core/portfolio-metrics.mjs +++ b/src/core/portfolio-metrics.mjs @@ -30,6 +30,11 @@ export function computePortfolioMetric({ btcAssets: effectiveBtcAssets, eureAsset, }); + const currentUnvaluedAssets = normalizeUnvaluedAssets({ + valuationAssets, + btcAssets: effectiveBtcAssets, + eureAsset, + }); const currentValuedAssets = valueValuationAssets({ inventory: currentInventory, valuationAssets: effectiveValuationAssets, @@ -53,6 +58,7 @@ export function computePortfolioMetric({ btcAssets: effectiveBtcAssets, eureAsset, valuationAssets: effectiveValuationAssets, + unvaluedAssets: currentUnvaluedAssets, }), current_portfolio_value_eure: formatScaledDecimal(currentPortfolioValue), current_btc_mark_value_eure: formatScaledDecimal(currentBtcMarkValue), @@ -124,6 +130,7 @@ export function computePortfolioMetric({ btcAssets: effectiveBtcAssets, eureAsset, valuationAssets: effectiveValuationAssets, + unvaluedAssets: currentUnvaluedAssets, }); const fundedPortfolioAtFlowTime = baselinePortfolioAtBaselinePrice + externalFlowSummary.netValueEureAtFlowTime; @@ -201,6 +208,7 @@ export function computePortfolioMetric({ btcAssets: effectiveBtcAssets, eureAsset, valuationAssets: effectiveValuationAssets, + unvaluedAssets: currentUnvaluedAssets, }), }; @@ -229,12 +237,26 @@ export function buildCashEquivalentValuationAssets({ return (trackedAssets || []) .filter((asset) => asset?.assetId && !excludedAssetIds.has(asset.assetId)) .map((asset) => { - if (asset.symbol !== 'USDC' || !usdcUnitValueEure) return null; + if (asset.symbol !== 'USDC' || !usdcUnitValueEure) { + return { + asset, + assetId: asset.assetId, + symbol: asset.symbol || asset.assetId, + label: asset.label || asset.symbol || asset.assetId, + decimals: asset.decimals, + valuationStatus: 'unvalued', + valuationReason: 'valuation_route_missing', + }; + } return { asset, assetId: asset.assetId, + symbol: asset.symbol || asset.assetId, + label: asset.label || asset.symbol || asset.assetId, + decimals: asset.decimals, currentUnitValueEure: usdcUnitValueEure, baselineUnitValueEure: usdcUnitValueEure, + valuationStatus: 'valued', valuationSource: 'btc_usdc_reference', priceId: usdcPrice.price_id || null, observedAt: usdcPrice.observed_at || usdcPrice.ingested_at || null, @@ -258,6 +280,7 @@ function buildInventoryView({ btcAssets = null, eureAsset, valuationAssets = [], + unvaluedAssets = [], }) { const effectiveBtcAssets = normalizeBtcAssets({ btcAsset, btcAssets }); const effectiveValuationAssets = normalizeValuationAssets({ @@ -291,6 +314,7 @@ function buildInventoryView({ eure_units: eureUnits, eure: formatAssetUnits(eureUnits, eureAsset.decimals), valued_assets: valuedAssets.items, + unvalued_assets: buildUnvaluedAssetRows({ inventory, unvaluedAssets }), valued_assets_value_eure: formatScaledDecimal(valuedAssets.total), }; } @@ -421,6 +445,37 @@ function normalizeValuationAssets({ valuationAssets = [], btcAssets = [], eureAs )); } +function normalizeUnvaluedAssets({ valuationAssets = [], btcAssets = [], eureAsset = null } = {}) { + const excludedAssetIds = new Set([ + ...normalizeBtcAssets({ btcAssets }).map((asset) => asset.assetId), + eureAsset?.assetId, + ].filter(Boolean)); + + return (valuationAssets || []) + .map((entry) => { + const asset = entry.asset || entry; + const assetId = entry.assetId || entry.asset_id || asset.assetId || asset.asset_id || null; + const valuationStatus = entry.valuationStatus || entry.valuation_status || null; + if (!assetId || excludedAssetIds.has(assetId) || valuationStatus !== 'unvalued') return null; + return { + assetId, + symbol: asset.symbol || entry.symbol || assetId, + label: asset.label || entry.label || asset.symbol || entry.symbol || assetId, + decimals: Number(asset.decimals ?? entry.decimals ?? 0), + valuationStatus: 'unvalued', + valuationReason: + entry.valuationReason + || entry.valuation_reason + || 'valuation_route_missing', + }; + }) + .filter((asset) => ( + asset + && Number.isInteger(asset.decimals) + && asset.decimals >= 0 + )); +} + function valueValuationAssets({ inventory, valuationAssets, priceField }) { const spendable = inventory?.spendable || {}; let total = 0n; @@ -449,6 +504,22 @@ function valueValuationAssets({ inventory, valuationAssets, priceField }) { return { total, items }; } +function buildUnvaluedAssetRows({ inventory, unvaluedAssets }) { + const spendable = inventory?.spendable || {}; + return (unvaluedAssets || []).map((asset) => { + const units = String(spendable[asset.assetId] || '0'); + return { + asset_id: asset.assetId, + symbol: asset.symbol, + label: asset.label, + units, + amount: formatAssetUnits(units, asset.decimals), + valuation_status: asset.valuationStatus, + valuation_reason: asset.valuationReason, + }; + }); +} + function buildValuedAssetInventoryDelta({ currentInventory, baselineInventory, @@ -512,9 +583,20 @@ function unitsToScaledDecimal(units, decimals) { } function formatAssetUnits(units, decimals) { + if (decimals > VALUE_SCALE) return formatRawUnitsDecimal(units, decimals); return formatScaledDecimal(BigInt(units || '0') * 10n ** BigInt(VALUE_SCALE - decimals)); } +function formatRawUnitsDecimal(units, decimals) { + const raw = String(units ?? '0'); + const negative = raw.startsWith('-'); + const digits = negative ? raw.slice(1) : raw; + const padded = digits.padStart(decimals + 1, '0'); + const whole = padded.slice(0, padded.length - decimals) || '0'; + const fraction = decimals > 0 ? padded.slice(-decimals).replace(/0+$/, '') : ''; + return `${negative ? '-' : ''}${whole}${fraction ? `.${fraction}` : ''}`; +} + function parseScaledDecimal(value) { const normalized = String(value ?? '0').trim(); const negative = normalized.startsWith('-'); diff --git a/src/core/quote-outcomes.mjs b/src/core/quote-outcomes.mjs index 7ab409f..fc2d989 100644 --- a/src/core/quote-outcomes.mjs +++ b/src/core/quote-outcomes.mjs @@ -17,7 +17,6 @@ export function deriveQuoteOutcomeRecords({ 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'); @@ -33,6 +32,16 @@ export function deriveQuoteOutcomeRecords({ .filter((entry) => entry?.quote_id) .map((entry) => [entry.quote_id, entry]), ); + const expectedDeltasByQuote = new Map(); + for (const submission of normalizedSubmissions) { + const command = commandsByQuote.get(submission.quote_id) || null; + const expectedDelta = buildExpectedMakerDeltas(command); + if (expectedDelta) expectedDeltasByQuote.set(submission.quote_id, expectedDelta); + } + const activeAssetIds = uniqueAssetIds([ + ...expectedDeltasByQuote.values(), + Object.fromEntries([btcAsset?.assetId, eureAsset?.assetId].filter(Boolean).map((assetId) => [assetId, 0n])), + ]); const inventoryDeltas = deriveInventoryDeltas({ inventorySnapshots, activeAssetIds, @@ -42,8 +51,7 @@ export function deriveQuoteOutcomeRecords({ const candidatesByQuote = new Map(); for (const submission of normalizedSubmissions) { - const command = commandsByQuote.get(submission.quote_id) || null; - const expectedDelta = buildExpectedMakerDeltas(command); + const expectedDelta = expectedDeltasByQuote.get(submission.quote_id) || null; if (!expectedDelta) continue; const matches = inventoryDeltas.filter((movement) => ( @@ -298,7 +306,10 @@ function baseOutcomeRecord({ 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, + notional: decision?.notional || command?.notional || null, + notional_asset_id: decision?.notional_asset_id || command?.notional_asset_id || null, + notional_symbol: decision?.notional_symbol || command?.notional_symbol || null, + eure_notional: decision?.eure_notional || command?.eure_notional || null, execution_result_status: submission.status, execution_result_code: submission.result_code || null, submitted_at: submission.submitted_at, @@ -335,6 +346,10 @@ function movementMatchesExpectedDelta({ for (const [assetId, expected] of Object.entries(expectedDelta)) { if (safeBigInt(movement.delta_units?.[assetId]) !== expected) return false; } + for (const [assetId, actual] of Object.entries(movement.delta_units || {})) { + if (Object.prototype.hasOwnProperty.call(expectedDelta, assetId)) continue; + if (safeBigInt(actual) !== 0n) return false; + } return true; } @@ -368,6 +383,8 @@ function getExpiredSettlementWindow({ } function buildExpectedMakerDeltas(command) { + const explicitDelta = normalizeExpectedDelta(command?.expected_inventory_delta_units); + if (explicitDelta) return explicitDelta; if (!command?.asset_in || !command?.asset_out || !command?.request_kind) return null; const receiveAmount = command.request_kind === 'exact_in' @@ -416,6 +433,10 @@ function normalizeCommand(entry) { pair: payload.pair || null, direction: payload.direction || null, request_kind: payload.request_kind || null, + notional: payload.notional || null, + notional_asset_id: payload.notional_asset_id || null, + notional_symbol: payload.notional_symbol || null, + eure_notional: payload.eure_notional || null, asset_in: payload.asset_in || null, asset_out: payload.asset_out || null, amount_in: payload.amount_in ?? null, @@ -423,6 +444,7 @@ function normalizeCommand(entry) { quote_output: payload.quote_output || {}, proposed_amount_in: payload.proposed_amount_in ?? null, proposed_amount_out: payload.proposed_amount_out ?? null, + expected_inventory_delta_units: payload.expected_inventory_delta_units || null, min_deadline_ms: payload.min_deadline_ms ?? null, command_at: toIsoTimestamp( entry?.observed_at @@ -444,6 +466,9 @@ function normalizeDecision(entry) { direction: payload.direction || null, request_kind: payload.request_kind || null, gross_edge_pct: payload.gross_edge_pct || null, + notional: payload.notional || null, + notional_asset_id: payload.notional_asset_id || null, + notional_symbol: payload.notional_symbol || null, eure_notional: payload.eure_notional || null, }; } @@ -476,6 +501,26 @@ function payloadOf(entry) { return entry.payload || entry; } +function uniqueAssetIds(deltaRecords = []) { + const ids = new Set(); + for (const record of deltaRecords || []) { + for (const assetId of Object.keys(record || {})) { + if (assetId) ids.add(assetId); + } + } + return [...ids]; +} + +function normalizeExpectedDelta(delta) { + if (!delta || typeof delta !== 'object' || Array.isArray(delta)) return null; + const normalized = {}; + for (const [assetId, units] of Object.entries(delta)) { + if (!assetId || units == null || units === '') continue; + normalized[assetId] = safeBigInt(units); + } + return Object.keys(normalized).length ? normalized : null; +} + function safeBigInt(value) { if (value == null || value === '') return 0n; return BigInt(String(value)); diff --git a/src/core/route-rates.mjs b/src/core/route-rates.mjs new file mode 100644 index 0000000..c15804d --- /dev/null +++ b/src/core/route-rates.mjs @@ -0,0 +1,144 @@ +const SUPPORTED_ROUTE_SOURCES = new Set([ + 'btc_eur_reference', + 'btc_usdc_reference', +]); + +export function isSupportedPriceRouteSource(source) { + return SUPPORTED_ROUTE_SOURCES.has(source); +} + +export function classifyRouteDirection({ + sourceAssetId = null, + destinationAssetId = null, + priceRoute = null, +} = {}) { + if (!priceRoute || !isSupportedPriceRouteSource(priceRoute.source)) return 'unsupported'; + if (sourceAssetId === priceRoute.baseAssetId && destinationAssetId === priceRoute.quoteAssetId) { + return 'base_to_quote'; + } + if (sourceAssetId === priceRoute.quoteAssetId && destinationAssetId === priceRoute.baseAssetId) { + return 'quote_to_base'; + } + return 'unsupported'; +} + +export function resolveRouteRates({ + price = null, + priceRoute = null, + direction = null, +} = {}) { + if (!price) return { ok: false, reason: 'reference_price_missing' }; + const normalizedDirection = normalizeRouteDirection(direction); + if (!normalizedDirection) return { ok: false, reason: 'unsupported_pair' }; + + if (priceRoute?.routeId && price.price_route_id && price.price_route_id !== priceRoute.routeId) { + return { ok: false, reason: 'reference_price_missing' }; + } + if (priceRoute?.source && !isSupportedPriceRouteSource(priceRoute.source)) { + return { ok: false, reason: 'unsupported_price_route' }; + } + + const legacyFields = legacyRateFields({ + source: priceRoute?.source || inferRouteSourceFromDirection(direction), + direction, + }); + let quotePerBase = Number(firstPresent( + price.quote_per_base, + price.quotePerBase, + legacyFields ? price[legacyFields.quotePerBaseField] : null, + )); + let basePerQuote = Number(firstPresent( + price.base_per_quote, + price.basePerQuote, + legacyFields ? price[legacyFields.basePerQuoteField] : null, + )); + if (!(basePerQuote > 0) && quotePerBase > 0) basePerQuote = 1 / quotePerBase; + if (!(quotePerBase > 0) && basePerQuote > 0) quotePerBase = 1 / basePerQuote; + + if (!(quotePerBase > 0) || !(basePerQuote > 0)) { + return { ok: false, reason: 'reference_price_missing' }; + } + + return { + ok: true, + quotePerBase, + basePerQuote, + baseToQuote: normalizedDirection === 'base_to_quote', + direction: normalizedDirection, + referencePriceId: price.price_id || null, + priceRouteId: price.price_route_id || priceRoute?.routeId || null, + }; +} + +export function computeDestinationAmountUnitsFromRoute({ + sourceAmountUnits, + sourceDecimals, + destinationDecimals, + direction, + quotePerBase, + basePerQuote, +} = {}) { + const normalizedDirection = normalizeRouteDirection(direction); + if (!normalizedDirection) throw new Error('unsupported route direction'); + if (!Number.isInteger(sourceDecimals) || sourceDecimals < 0) { + throw new Error('source decimals are required'); + } + if (!Number.isInteger(destinationDecimals) || destinationDecimals < 0) { + throw new Error('destination decimals are required'); + } + + const rate = parsePositiveDecimal( + normalizedDirection === 'base_to_quote' ? quotePerBase : basePerQuote, + { field: normalizedDirection === 'base_to_quote' ? 'quote_per_base' : 'base_per_quote' }, + ); + const sourceUnits = BigInt(String(sourceAmountUnits || '0')); + if (sourceUnits <= 0n) throw new Error('sourceAmountUnits must be greater than zero'); + + const numerator = sourceUnits * (10n ** BigInt(destinationDecimals)) * rate.units; + const denominator = (10n ** BigInt(sourceDecimals)) * rate.scale; + if (denominator <= 0n) throw new Error('invalid route denominator'); + return (numerator / denominator).toString(); +} + +export function normalizeRouteDirection(direction) { + if (direction === 'base_to_quote' || direction === 'quote_to_base') return direction; + if (direction === 'btc_to_eure' || direction === 'btc_to_usdc') return 'base_to_quote'; + if (direction === 'eure_to_btc' || direction === 'usdc_to_btc') return 'quote_to_base'; + return null; +} + +function legacyRateFields({ source, direction } = {}) { + if (source === 'btc_usdc_reference' || direction === 'btc_to_usdc' || direction === 'usdc_to_btc') { + return { + quotePerBaseField: 'usdc_per_btc', + basePerQuoteField: 'btc_per_usdc', + }; + } + if (source === 'btc_eur_reference' || direction === 'btc_to_eure' || direction === 'eure_to_btc') { + return { + quotePerBaseField: 'eure_per_btc', + basePerQuoteField: 'btc_per_eure', + }; + } + return null; +} + +function inferRouteSourceFromDirection(direction) { + if (direction === 'btc_to_usdc' || direction === 'usdc_to_btc') return 'btc_usdc_reference'; + if (direction === 'btc_to_eure' || direction === 'eure_to_btc') return 'btc_eur_reference'; + return null; +} + +function firstPresent(...values) { + return values.find((value) => value != null && value !== ''); +} + +function parsePositiveDecimal(value, { field }) { + const raw = String(value ?? '').trim(); + if (!/^\d+(\.\d+)?$/.test(raw)) throw new Error(`${field} must be a positive decimal`); + const [whole, fraction = ''] = raw.split('.'); + const scale = 10n ** BigInt(fraction.length); + const units = BigInt(`${whole}${fraction}`.replace(/^0+/, '') || '0'); + if (units <= 0n) throw new Error(`${field} must be greater than zero`); + return { units, scale }; +} diff --git a/src/core/runtime-health.mjs b/src/core/runtime-health.mjs index 316ca15..1a72f74 100644 --- a/src/core/runtime-health.mjs +++ b/src/core/runtime-health.mjs @@ -33,6 +33,7 @@ export function createRuntimeHealthThresholds(config = {}) { export function evaluateRuntimeHealth({ servicesByName, activePair, + activePairs = activePair ? [activePair] : [], activeAlerts = [], now = new Date().toISOString(), } = {}) { @@ -45,6 +46,7 @@ export function evaluateRuntimeHealth({ service, snapshot, activePair, + activePairs, activeAlerts: alerts, now, })); @@ -57,6 +59,7 @@ export function deriveServiceHealth({ service, snapshot, activePair = null, + activePairs = activePair ? [activePair] : [], activeAlerts = [], now = new Date().toISOString(), } = {}) { @@ -149,7 +152,7 @@ export function deriveServiceHealth({ if ( ['strategy-engine', 'trade-executor'].includes(service) && (state.armed ?? false) - && hasCriticalTruthAlert(activeAlerts, activePair) + && hasCriticalTruthAlert(activeAlerts, activePairs.length ? activePairs : activePair) ) { status = escalateHealth(status, 'critical'); if (label === 'healthy' || label === 'warning') { @@ -355,11 +358,12 @@ function indexAlertsByService(activeAlerts) { } function hasCriticalTruthAlert(alerts, activePair) { + const activePairSet = new Set(Array.isArray(activePair) ? activePair : [activePair].filter(Boolean)); return (alerts || []).some((alert) => ( alert.severity === 'critical' && ( alert.pair == null - || alert.pair === activePair + || activePairSet.has(alert.pair) || alert.alert_code.includes('stale') || alert.alert_code.includes('disconnected') ) diff --git a/src/core/strategy.mjs b/src/core/strategy.mjs index ad2c605..19045f1 100644 --- a/src/core/strategy.mjs +++ b/src/core/strategy.mjs @@ -8,6 +8,10 @@ import { pairKey, unitsToNumber, } from './assets.mjs'; +import { + classifyRouteDirection, + resolveRouteRates, +} from './route-rates.mjs'; export function evaluateTradeOpportunity({ demandEvent, @@ -17,12 +21,13 @@ export function evaluateTradeOpportunity({ now = Date.now(), armed = false, thresholdPct = config.strategyGrossThresholdPct, - maxNotionalEure = config.strategyMaxNotionalEure, + maxNotional = config.strategyMaxNotional ?? config.strategyMaxNotionalEure, }) { const payload = demandEvent.payload; - const pairRuntime = resolvePairRuntime({ payload, config, thresholdPct, maxNotionalEure }); + const pairRuntime = resolvePairRuntime({ payload, config, thresholdPct, maxNotional }); const effectiveThresholdPct = pairRuntime.thresholdPct ?? thresholdPct; - const effectiveMaxNotionalEure = pairRuntime.maxNotionalEure ?? maxNotionalEure; + const effectiveMaxNotional = pairRuntime.maxNotional ?? maxNotional; + const legacyEureNotional = isLegacyEureNotional({ pairRuntime, config }); const decisionId = crypto.randomUUID(); const baseDecision = { decision_id: decisionId, @@ -36,7 +41,7 @@ export function evaluateTradeOpportunity({ edge_bps: pairRuntime.strategyConfig?.edgeBps == null ? null : String(pairRuntime.strategyConfig.edgeBps), - max_notional: effectiveMaxNotionalEure == null ? null : String(effectiveMaxNotionalEure), + max_notional: effectiveMaxNotional == null ? null : String(effectiveMaxNotional), min_notional: pairRuntime.strategyConfig?.minNotional == null ? null : String(pairRuntime.strategyConfig.minNotional), @@ -46,7 +51,9 @@ export function evaluateTradeOpportunity({ decision: 'rejected', decision_reason: 'unknown', threshold_pct: String(effectiveThresholdPct), - max_notional_eure: String(effectiveMaxNotionalEure), + max_notional_eure: legacyEureNotional && effectiveMaxNotional != null + ? String(effectiveMaxNotional) + : null, strategy_armed: armed, assumptions: compact({ eure_per_eur: pairRuntime.priceRoute?.source === 'btc_eur_reference' ? '1' : null, @@ -97,7 +104,7 @@ export function evaluateTradeOpportunity({ config, pairRuntime, thresholdPct: effectiveThresholdPct, - maxNotionalEure: effectiveMaxNotionalEure, + maxNotional: effectiveMaxNotional, }); if (!buildResult.ok) { @@ -137,12 +144,19 @@ export function evaluateTradeOpportunity({ pair_config_version: decision.pair_config_version, edge_bps: decision.edge_bps, max_notional: decision.max_notional, + min_notional: decision.min_notional, max_notional_eure: decision.max_notional_eure, price_route_id: decision.price_route_id, reference_price_id: buildResult.details.price_id || null, notional: decision.notional, notional_asset_id: decision.notional_asset_id, notional_symbol: decision.notional_symbol, + source_asset_id: payload.asset_in, + source_symbol: pairRuntime.assetIn?.symbol || null, + source_decimals: pairRuntime.assetIn?.decimals ?? null, + destination_asset_id: payload.asset_out, + destination_symbol: pairRuntime.assetOut?.symbol || null, + destination_decimals: pairRuntime.assetOut?.decimals ?? null, asset_in: payload.asset_in, asset_out: payload.asset_out, asset_in_decimals: pairRuntime.assetIn?.decimals ?? null, @@ -154,6 +168,12 @@ export function evaluateTradeOpportunity({ quote_output: buildResult.quoteOutput, proposed_amount_in: buildResult.details.proposed_amount_in ?? null, proposed_amount_out: buildResult.details.proposed_amount_out ?? null, + expected_inventory_delta_units: buildExpectedMakerInventoryDelta({ + demand: payload, + quoteOutput: buildResult.quoteOutput, + proposedAmountIn: buildResult.details.proposed_amount_in ?? null, + proposedAmountOut: buildResult.details.proposed_amount_out ?? null, + }), }, }; } @@ -165,7 +185,7 @@ function buildQuote({ config, pairRuntime = null, thresholdPct, - maxNotionalEure, + maxNotional, }) { const direction = pairRuntime?.direction || classifyPairDirection({ assetIn: demand.asset_in, @@ -190,11 +210,17 @@ function buildQuote({ const spendAsset = demand.asset_out; const available = bigintAmount(inventory.spendable?.[spendAsset] || '0'); const pendingInbound = bigintAmount(inventory.pending_inbound?.[spendAsset] || '0'); - const referenceRates = resolveReferenceRates({ direction, price }); + const referenceRates = resolveRouteRates({ + direction, + price, + priceRoute: pairRuntime?.priceRoute || null, + }); if (!referenceRates.ok) return { ok: false, reason: referenceRates.reason, details: {} }; const { quotePerBase, basePerQuote, baseToQuote } = referenceRates; - const notionalAssetId = pairRuntime?.priceRoute?.quoteAssetId || assetOut.assetId; + const notionalAssetId = pairRuntime?.priceRoute?.quoteAssetId + || (baseToQuote ? assetOut.assetId : assetIn.assetId); const notionalAsset = assetRegistry.get(notionalAssetId) || null; + const legacyEureNotional = notionalAssetId === config.tradingEure?.assetId; if (demand.request_kind === 'exact_in') { const amountIn = bigintAmount(demand.amount_in); @@ -232,7 +258,8 @@ function buildQuote({ quoteNotional, notionalAssetId, notionalSymbol: notionalAsset?.symbol || null, - maxNotionalEure, + maxNotional, + legacyEureNotional, proposedAmountOut: proposedOutputUnits, impliedRate, referenceRate, @@ -240,6 +267,10 @@ function buildQuote({ priceId: price.price_id, assetInDecimals: assetIn.decimals, assetOutDecimals: assetOut.decimals, + assetInId: assetIn.assetId, + assetOutId: assetOut.assetId, + assetInSymbol: assetIn.symbol, + assetOutSymbol: assetOut.symbol, }); } @@ -279,7 +310,8 @@ function buildQuote({ quoteNotional, notionalAssetId, notionalSymbol: notionalAsset?.symbol || null, - maxNotionalEure, + maxNotional, + legacyEureNotional, proposedAmountIn: proposedInputUnits, proposedAmountOut: demand.amount_out, impliedRate, @@ -288,6 +320,10 @@ function buildQuote({ priceId: price.price_id, assetInDecimals: assetIn.decimals, assetOutDecimals: assetOut.decimals, + assetInId: assetIn.assetId, + assetOutId: assetOut.assetId, + assetInSymbol: assetIn.symbol, + assetOutSymbol: assetOut.symbol, }); } @@ -303,7 +339,8 @@ function finalizeQuote({ quoteNotional, notionalAssetId = null, notionalSymbol = null, - maxNotionalEure, + maxNotional, + legacyEureNotional = false, proposedAmountIn = null, proposedAmountOut = null, impliedRate, @@ -312,6 +349,10 @@ function finalizeQuote({ priceId, assetInDecimals = null, assetOutDecimals = null, + assetInId = null, + assetOutId = null, + assetInSymbol = null, + assetOutSymbol = null, }) { const grossEdgePct = ((referenceRate - impliedRate) / referenceRate) * 100; const reasonBase = { @@ -327,10 +368,16 @@ function finalizeQuote({ reference_price_id: priceId, asset_in_decimals: assetInDecimals == null ? null : String(assetInDecimals), asset_out_decimals: assetOutDecimals == null ? null : String(assetOutDecimals), + source_asset_id: assetInId, + source_symbol: assetInSymbol, + source_decimals: assetInDecimals == null ? null : String(assetInDecimals), + destination_asset_id: assetOutId, + destination_symbol: assetOutSymbol, + destination_decimals: assetOutDecimals == null ? null : String(assetOutDecimals), notional: formatNumber(quoteNotional, 6), notional_asset_id: notionalAssetId, notional_symbol: notionalSymbol, - eure_notional: formatNumber(quoteNotional, 6), + eure_notional: legacyEureNotional ? formatNumber(quoteNotional, 6) : null, proposed_amount_in: proposedAmountIn, proposed_amount_out: proposedAmountOut, }; @@ -339,7 +386,7 @@ function finalizeQuote({ return { ok: false, reason: 'invalid_pricing', details: reasonBase }; } - if (quoteNotional > maxNotionalEure) { + if (quoteNotional > maxNotional) { return { ok: false, reason: 'max_notional_exceeded', details: reasonBase }; } @@ -381,11 +428,31 @@ function withReason(decision, reason) { }; } +function buildExpectedMakerInventoryDelta({ + demand, + quoteOutput = {}, + proposedAmountIn = null, + proposedAmountOut = null, +} = {}) { + if (!demand?.asset_in || !demand?.asset_out || !demand?.request_kind) return null; + const receiveAmount = demand.request_kind === 'exact_in' + ? demand.amount_in + : quoteOutput?.amount_in || proposedAmountIn; + const sendAmount = demand.request_kind === 'exact_in' + ? quoteOutput?.amount_out || proposedAmountOut + : demand.amount_out; + if (receiveAmount == null || sendAmount == null) return null; + return { + [demand.asset_in]: bigintAmount(receiveAmount).toString(), + [demand.asset_out]: (-bigintAmount(sendAmount)).toString(), + }; +} + function resolvePairRuntime({ payload, config, thresholdPct, - maxNotionalEure, + maxNotional, }) { const key = pairKey(payload.asset_in, payload.asset_out); const requiresDb = config.requireDbTradingConfig === true || config.tradingConfigLoaded === true; @@ -410,7 +477,7 @@ function resolvePairRuntime({ assetIn: config.assetRegistry?.get(payload.asset_in) || null, assetOut: config.assetRegistry?.get(payload.asset_out) || null, thresholdPct, - maxNotionalEure, + maxNotional, priceMaxAgeMs: config.strategyPriceMaxAgeMs, inventoryMaxAgeMs: config.strategyInventoryMaxAgeMs, pair: null, @@ -431,7 +498,7 @@ function resolvePairRuntime({ assetIn: null, assetOut: null, thresholdPct, - maxNotionalEure, + maxNotional, priceMaxAgeMs: config.strategyPriceMaxAgeMs, inventoryMaxAgeMs: config.strategyInventoryMaxAgeMs, }; @@ -450,7 +517,7 @@ function resolvePairRuntime({ assetIn: pair?.assetIn || config.assetRegistry?.get(payload.asset_in) || null, assetOut: pair?.assetOut || config.assetRegistry?.get(payload.asset_out) || null, thresholdPct, - maxNotionalEure, + maxNotional, priceMaxAgeMs: config.strategyPriceMaxAgeMs, inventoryMaxAgeMs: config.strategyInventoryMaxAgeMs, }; @@ -475,7 +542,7 @@ function resolvePairRuntime({ assetIn: pair.assetIn, assetOut: pair.assetOut, thresholdPct: Number(pair.strategyConfig.edgeBps) / 100, - maxNotionalEure: Number(pair.strategyConfig.maxNotional), + maxNotional: Number(pair.strategyConfig.maxNotional), priceMaxAgeMs: Number(pair.strategyConfig.priceMaxAgeMs), inventoryMaxAgeMs: Number(pair.strategyConfig.inventoryMaxAgeMs), }; @@ -496,43 +563,23 @@ function blockedPairRuntime(pair, config, reason) { assetIn: pair?.assetIn || null, assetOut: pair?.assetOut || null, thresholdPct: pair?.strategyConfig ? Number(pair.strategyConfig.edgeBps) / 100 : config.strategyGrossThresholdPct, - maxNotionalEure: pair?.strategyConfig ? Number(pair.strategyConfig.maxNotional) : config.strategyMaxNotionalEure, + maxNotional: pair?.strategyConfig + ? Number(pair.strategyConfig.maxNotional) + : (config.strategyMaxNotional ?? config.strategyMaxNotionalEure), priceMaxAgeMs: pair?.strategyConfig ? Number(pair.strategyConfig.priceMaxAgeMs) : config.strategyPriceMaxAgeMs, inventoryMaxAgeMs: pair?.strategyConfig ? Number(pair.strategyConfig.inventoryMaxAgeMs) : config.strategyInventoryMaxAgeMs, }; } function classifyPriceRouteDirection({ payload, priceRoute }) { - if (!priceRoute) return 'unsupported'; - if (!['btc_eur_reference', 'btc_usdc_reference'].includes(priceRoute.source)) return 'unsupported'; - if (payload.asset_in === priceRoute.baseAssetId && payload.asset_out === priceRoute.quoteAssetId) { - return priceRoute.source === 'btc_usdc_reference' ? 'btc_to_usdc' : 'btc_to_eure'; - } - if (payload.asset_in === priceRoute.quoteAssetId && payload.asset_out === priceRoute.baseAssetId) { - return priceRoute.source === 'btc_usdc_reference' ? 'usdc_to_btc' : 'eure_to_btc'; - } - return 'unsupported'; + return classifyRouteDirection({ + sourceAssetId: payload.asset_in, + destinationAssetId: payload.asset_out, + priceRoute, + }); } -function resolveReferenceRates({ direction, price }) { - const rateFieldsByDirection = { - btc_to_eure: ['eure_per_btc', 'btc_per_eure', true], - eure_to_btc: ['eure_per_btc', 'btc_per_eure', false], - btc_to_usdc: ['usdc_per_btc', 'btc_per_usdc', true], - usdc_to_btc: ['usdc_per_btc', 'btc_per_usdc', false], - }; - const fields = rateFieldsByDirection[direction]; - if (!fields) return { ok: false, reason: 'unsupported_pair' }; - const [quotePerBaseField, basePerQuoteField, baseToQuote] = fields; - const quotePerBase = Number(price[quotePerBaseField]); - const basePerQuote = Number(price[basePerQuoteField]); - if (!(quotePerBase > 0) || !(basePerQuote > 0)) { - return { ok: false, reason: 'reference_price_missing' }; - } - return { - ok: true, - quotePerBase, - basePerQuote, - baseToQuote, - }; +function isLegacyEureNotional({ pairRuntime, config }) { + if (!pairRuntime?.priceRoute) return true; + return pairRuntime.priceRoute.quoteAssetId === config.tradingEure?.assetId; } diff --git a/src/lib/postgres.mjs b/src/lib/postgres.mjs index b95eb97..f8bc8b9 100644 --- a/src/lib/postgres.mjs +++ b/src/lib/postgres.mjs @@ -1148,9 +1148,11 @@ function buildTradingConfigSnapshot({ const defaultTakerPair = pairByKey.get('nep141:gnosis-0x420ca0f9b9b604ce0fd9c18ef134c705e5fa3430.omft.near->nep141:nbtc.bridge.near') || pairs.find((pair) => pair.takerEnabled && pair.canTrade) || null; - const activeAssetIds = preferredActivePair?.assetIn && preferredActivePair?.assetOut - ? [preferredActivePair.assetIn.assetId, preferredActivePair.assetOut.assetId] - : []; + const activeAssetIds = [...new Set( + observedPairs + .flatMap((pair) => [pair.assetIn?.assetId, pair.assetOut?.assetId]) + .filter(Boolean), + )]; const blockReason = observedPairs.length === 0 ? 'no_enabled_pairs' : trackedAssets.length === 0 @@ -1231,6 +1233,7 @@ export function summarizeTradingConfigSnapshot(snapshot) { tracked_asset_count: snapshot.trackedAssets?.length || 0, enabled_pair_count: snapshot.observedPairs?.length || 0, active_pair: snapshot.activePair || null, + active_pairs: (snapshot.observedPairs || []).map((pair) => pair.key || pair.pairId), pairs: (snapshot.pairs || []).map((pair) => ({ pair_id: pair.pairId, pair: pair.key, @@ -2233,8 +2236,6 @@ export async function refreshQuoteOutcomes(pool, { submissionLimit = 1000, inventoryLimit = 5000, } = {}) { - if (!btcAsset?.assetId || !eureAsset?.assetId) return []; - const safeSubmissionLimit = Math.max(1, Number(submissionLimit) || 1000); const safeInventoryLimit = Math.max(1, Number(inventoryLimit) || 5000); const submissionsResult = await pool.query( @@ -2860,6 +2861,21 @@ export async function loadLatestMarketPrice(pool) { }; } +export async function loadLatestMarketPriceForRoute(pool, { priceRouteId } = {}) { + if (!priceRouteId) return loadLatestMarketPrice(pool); + const latest = await loadLatestEventPayload( + pool, + 'market_price_events', + "WHERE payload->>'price_route_id' = $1 ORDER BY COALESCE(observed_at, ingested_at) DESC LIMIT 1", + [priceRouteId], + ); + if (!latest) return null; + return { + ingested_at: latest.ingested_at, + payload: latest.payload, + }; +} + export async function loadRecentQuotes(pool, { limit = 10 } = {}) { const result = await pool.query( ` @@ -3281,6 +3297,9 @@ function normalizeQuoteOutcomeRow(row) { direction: payload.direction || null, request_kind: payload.request_kind || null, gross_edge_pct: payload.gross_edge_pct || null, + notional: payload.notional || null, + notional_asset_id: payload.notional_asset_id || null, + notional_symbol: payload.notional_symbol || 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, diff --git a/src/operator-dashboard/static/components/StatusBar.jsx b/src/operator-dashboard/static/components/StatusBar.jsx index dede672..c03166d 100644 --- a/src/operator-dashboard/static/components/StatusBar.jsx +++ b/src/operator-dashboard/static/components/StatusBar.jsx @@ -5,6 +5,10 @@ function statusSubtitle(label, status, websocketState) { switch (label) { case 'Pair': return `WebSocket ${websocketState}`; + case 'Pairs': + return status.active_pair_count ? `${status.active_pair_count} enabled` : `WebSocket ${websocketState}`; + case 'Reference Route': + return status.latest_reference_route_label || 'Reference route'; case 'Market Freshness': return formatTimestamp(status.market_observed_at); case 'Inventory Freshness': @@ -29,10 +33,10 @@ export default function StatusBar({ status, websocketState }) { ]] : []; const tiles = [ - ['Pair', truncateMiddle(status.active_pair, 40), status.active_pair], + ['Pairs', truncateMiddle((status.active_pairs || [status.active_pair]).filter(Boolean).join(', '), 40), (status.active_pairs || [status.active_pair]).filter(Boolean).join(', ')], ...nearIntentsTile, ['Portfolio', formatEur(status.current_total_portfolio_value_eure)], - ['Reference BTC/EUR', formatEur(status.latest_reference_price_eure_per_btc)], + ['Reference Route', status.latest_reference_route_value || formatEur(status.latest_reference_price_eure_per_btc), status.latest_reference_route_label], ['Market Freshness', formatAge(status.market_freshness_ms)], ['Inventory Freshness', formatAge(status.inventory_freshness_ms)], ['Strategy Armed', formatBoolean(status.strategy_armed)], @@ -50,7 +54,7 @@ export default function StatusBar({ status, websocketState }) { className={[ 'status-value', signedClass(value), - label === 'Pair' ? 'truncate-line mono' : '', + label === 'Pairs' ? 'truncate-line mono' : '', ].filter(Boolean).join(' ')} title={fullValue || 'Unavailable'} > diff --git a/src/operator-dashboard/static/pages/FundsPage.jsx b/src/operator-dashboard/static/pages/FundsPage.jsx index a970d9d..0bddd4a 100644 --- a/src/operator-dashboard/static/pages/FundsPage.jsx +++ b/src/operator-dashboard/static/pages/FundsPage.jsx @@ -44,7 +44,12 @@ function BalancesTable({ items }) { {item.spendable} {item.pending_inbound} {item.pending_outbound} - {formatEur(item.eur_value_eure)} + + {item.valuation_status === 'unvalued' + ? item.valuation_reason || 'valuation_route_missing' + : formatEur(item.eur_value_eure)} + {item.eur_value_source ?
{item.eur_value_source}
: null} + ))} @@ -279,26 +284,31 @@ function IdentifierLine({ label, value }) { function IntentRequestForm({ summary, onControl }) { const defaults = summary?.defaults || {}; - const sourceSymbol = defaults.source_symbol || 'EURe'; - const destinationSymbol = defaults.destination_symbol || 'BTC'; - const hasAmountCap = defaults.max_amount_eure != null && defaults.max_amount_eure !== ''; + const pairs = summary?.pairs || []; + const hasAmountCap = defaults.max_source_amount != null && defaults.max_source_amount !== ''; const hasSlippageCap = defaults.max_slippage_bps != null && defaults.max_slippage_bps !== ''; const [form, setForm] = useState({ - amount_eure: defaults.amount_eure || '5', + pair_id: defaults.pair_id || '', + source_amount: defaults.source_amount || '5', slippage_bps: String(defaults.slippage_bps ?? 200), }); + const selectedPair = pairs.find((pair) => pair.pair_id === form.pair_id) || null; + const sourceSymbol = selectedPair?.source_symbol || defaults.source_symbol || 'Source'; + const destinationSymbol = selectedPair?.destination_symbol || defaults.destination_symbol || 'Destination'; useEffect(() => { setForm({ - amount_eure: defaults.amount_eure || '5', + pair_id: defaults.pair_id || '', + source_amount: defaults.source_amount || '5', slippage_bps: String(defaults.slippage_bps ?? 200), }); - }, [defaults.amount_eure, defaults.slippage_bps]); + }, [defaults.pair_id, defaults.source_amount, defaults.slippage_bps]); async function handlePreflight(event) { event.preventDefault(); await onControl('trade-executor', 'intent-request-preflight', { - amount_eure: form.amount_eure, + pair_id: form.pair_id || undefined, + source_amount: form.source_amount, slippage_bps: Number(form.slippage_bps), }); } @@ -306,20 +316,37 @@ function IntentRequestForm({ summary, onControl }) { return (
+ {pairs.length ? ( +
+ + +
+ ) : null}
setForm((current) => ({ ...current, amount_eure: event.target.value }))} + name="source_amount" + onChange={(event) => setForm((current) => ({ ...current, source_amount: event.target.value }))} step="0.01" type="number" - value={form.amount_eure} - {...(hasAmountCap ? { max: defaults.max_amount_eure } : {})} + value={form.source_amount} + {...(hasAmountCap ? { max: defaults.max_source_amount } : {})} />
- {hasAmountCap ? `Max ${defaults.max_amount_eure} ${sourceSymbol}` : 'No request amount cap'} + {hasAmountCap ? `Max ${defaults.max_source_amount} ${sourceSymbol}` : 'No request amount cap'}
@@ -412,7 +439,7 @@ function IntentRequestLifecycle({ item }) { function IntentRequestsTable({ items, executorArmed, onControl }) { const [expanded, setExpanded] = useState(() => new Set()); - if (!items?.length) return No repo-created EURe-to-BTC requests are stored yet.; + if (!items?.length) return No repo-created pair requests are stored yet.; function toggle(rowKey) { setExpanded((current) => { @@ -679,7 +706,7 @@ export default function FundsPage({
Own requests
-

EURe to BTC request creation

+

Pair request creation

Create a solver quote request first, then submit only a drafted request. Completed requires inventory movement, not relay acceptance.
diff --git a/src/operator-dashboard/static/pages/StrategyPage.jsx b/src/operator-dashboard/static/pages/StrategyPage.jsx index 3742f6a..731a0b7 100644 --- a/src/operator-dashboard/static/pages/StrategyPage.jsx +++ b/src/operator-dashboard/static/pages/StrategyPage.jsx @@ -70,8 +70,16 @@ function responseLabel(item) { } function grossEdgeEstimate(item) { - if (!item.gross_edge_value_eure) return 'Unavailable'; - return formatEur(item.gross_edge_value_eure); + if (!item.gross_edge_value) return 'Unavailable'; + const symbol = item.notional_symbol || (item.eure_notional ? 'EURe' : null); + if (symbol === 'EURe') return formatEur(item.gross_edge_value); + return symbol ? `${item.gross_edge_value} ${symbol}` : item.gross_edge_value; +} + +function notionalLabel(item) { + if (item.notional_display) return item.notional_display; + if (item.notional != null) return `${item.notional}${item.notional_symbol ? ` ${item.notional_symbol}` : ''}`; + return item.eure_notional ? formatEur(item.eure_notional) : 'Notional unavailable'; } function plainCodeLabel(value, fallback = 'Unavailable') { @@ -109,7 +117,7 @@ function LifecycleDetails({ item }) {
{plainCodeLabel(item.decision?.decision_reason || item.reason_code, 'No decision reason recorded')}
{item.gross_edge_pct ? `Edge ${item.gross_edge_pct}%` : 'Edge unavailable'}
-
{item.eure_notional ? `Notional ${formatEur(item.eure_notional)}` : 'Notional unavailable'}
+
{notionalLabel(item)}
@@ -195,7 +203,7 @@ function QuoteLifecycleTable({ items }) {
0 ? 'value-positive' : Number(item.gross_edge_pct) < 0 ? 'value-negative' : ''}>{item.gross_edge_pct ? `${item.gross_edge_pct}%` : 'Unavailable'}
-
{item.eure_notional ? formatEur(item.eure_notional) : 'Notional unavailable'}
+
{notionalLabel(item)}