diff --git a/src/apps/strategy-engine.mjs b/src/apps/strategy-engine.mjs index 102a21f..6e27161 100644 --- a/src/apps/strategy-engine.mjs +++ b/src/apps/strategy-engine.mjs @@ -9,7 +9,7 @@ import { createLogger, serializeError } from '../core/log.mjs'; import { extendMakerTiming } from '../core/maker-timing.mjs'; import { createRecentIdCache } from '../core/recent-id-cache.mjs'; import { assertInventorySnapshotEvent, assertMarketPriceEvent, assertNormalizedSwapDemand } from '../core/schemas.mjs'; -import { evaluateTradeOpportunity } from '../core/strategy.mjs'; +import { evaluateTradeOpportunity, makerTradePairKeyForDemand } from '../core/strategy.mjs'; import { loadConfig } from '../lib/config.mjs'; import { createPairStrategyConfigVersion, @@ -113,7 +113,9 @@ async function handleDemand(event) { const tradingConfig = await tradingConfigStore.getConfig(); if (seenQuotes.has(event.payload.quote_id)) { - const pair = tradingConfig.pairByKey?.get(event.payload.pair || `${event.payload.asset_in}->${event.payload.asset_out}`); + const requestPairKey = event.payload.pair || `${event.payload.asset_in}->${event.payload.asset_out}`; + const makerPairKey = makerTradePairKeyForDemand(event.payload); + const pair = tradingConfig.pairByKey?.get(makerPairKey) || tradingConfig.pairByKey?.get(requestPairKey); const strategyConfig = pair?.strategyConfig || null; const legacyEureNotional = !pair?.priceRoute || pair.priceRoute.quoteAssetId === tradingConfig.tradingEure?.assetId; @@ -121,7 +123,11 @@ async function handleDemand(event) { decision_id: `duplicate-${event.payload.quote_id}`, decision_at: strategyReceivedAt, quote_id: event.payload.quote_id, - pair: event.payload.pair || `${event.payload.asset_in}->${event.payload.asset_out}`, + pair: requestPairKey, + request_pair: requestPairKey, + request_pair_id: tradingConfig.pairByKey?.get(requestPairKey)?.pairId || requestPairKey, + maker_trade_pair: makerPairKey, + maker_trade_pair_id: pair?.pairId || makerPairKey, pair_id: pair?.pairId || null, pair_config_id: strategyConfig?.configId || null, pair_config_version: strategyConfig?.version == null ? null : String(strategyConfig.version), @@ -135,6 +141,10 @@ async function handleDemand(event) { max_notional_eure: legacyEureNotional && strategyConfig?.maxNotional != null ? String(strategyConfig.maxNotional) : null, + maker_send_asset_id: event.payload.asset_out || null, + maker_receive_asset_id: event.payload.asset_in || null, + maker_send_symbol: tradingConfig.assetRegistry?.get(event.payload.asset_out)?.symbol || null, + maker_receive_symbol: tradingConfig.assetRegistry?.get(event.payload.asset_in)?.symbol || null, maker_timing: extendMakerTiming(event.payload.maker_timing, { strategy_received_at: strategyReceivedAt, strategy_decided_at: strategyReceivedAt, @@ -185,7 +195,8 @@ async function handleDemand(event) { function resolvePriceEventForDemand(event, tradingConfig) { const key = event.payload.pair || `${event.payload.asset_in}->${event.payload.asset_out}`; - const pair = tradingConfig.pairByKey?.get(key); + const makerKey = makerTradePairKeyForDemand(event.payload); + const pair = tradingConfig.pairByKey?.get(makerKey) || tradingConfig.pairByKey?.get(key); const routeId = pair?.priceRoute?.routeId || null; if (routeId) return state.latest_price_events_by_route[routeId] || null; return state.latest_price_events_by_pair[key] || state.latest_price_event; diff --git a/src/core/operator-dashboard.mjs b/src/core/operator-dashboard.mjs index 6d6d0d8..fe7ab03 100644 --- a/src/core/operator-dashboard.mjs +++ b/src/core/operator-dashboard.mjs @@ -1192,6 +1192,10 @@ export function deriveQuoteLifecycleRows({ quote_id: decision.quote_id, decision_id: decision.decision_id, pair: decision.pair, + request_pair: decision.request_pair, + request_pair_id: decision.request_pair_id, + maker_trade_pair: decision.maker_trade_pair, + maker_trade_pair_id: decision.maker_trade_pair_id, pair_id: decision.pair_id, pair_config_id: decision.pair_config_id, pair_config_version: decision.pair_config_version, @@ -1204,6 +1208,10 @@ export function deriveQuoteLifecycleRows({ inventory_available: decision.inventory_available, inventory_spendable: decision.inventory_spendable, inventory_pending_outbound: decision.inventory_pending_outbound, + maker_send_asset_id: decision.maker_send_asset_id, + maker_receive_asset_id: decision.maker_receive_asset_id, + maker_send_amount_units: decision.maker_send_amount_units, + maker_receive_amount_units: decision.maker_receive_amount_units, pending_inbound: decision.pending_inbound, pending_outbound: decision.pending_outbound, notional: decision.notional, @@ -1232,6 +1240,10 @@ export function deriveQuoteLifecycleRows({ decision_id: command.decision_id, command_id: command.command_id, pair: command.pair, + request_pair: command.request_pair, + request_pair_id: command.request_pair_id, + maker_trade_pair: command.maker_trade_pair, + maker_trade_pair_id: command.maker_trade_pair_id, pair_id: command.pair_id, pair_config_id: command.pair_config_id, pair_config_version: command.pair_config_version, @@ -1242,6 +1254,10 @@ export function deriveQuoteLifecycleRows({ asset_out: command.asset_out || null, amount_in: command.amount_in || null, amount_out: command.amount_out || null, + maker_send_asset_id: command.maker_send_asset_id || null, + maker_receive_asset_id: command.maker_receive_asset_id || null, + maker_send_amount_units: command.maker_send_amount_units || null, + maker_receive_amount_units: command.maker_receive_amount_units || null, notional: command.notional || null, notional_asset_id: command.notional_asset_id || null, notional_symbol: command.notional_symbol || null, @@ -1302,6 +1318,10 @@ function ensureLifecycleRow(rowsByKey, key) { decision_id: null, command_id: null, pair: null, + request_pair: null, + request_pair_id: null, + maker_trade_pair: null, + maker_trade_pair_id: null, pair_id: null, pair_config_id: null, pair_config_version: null, @@ -1326,6 +1346,10 @@ function ensureLifecycleRow(rowsByKey, key) { asset_out: null, amount_in: null, amount_out: null, + maker_send_asset_id: null, + maker_receive_asset_id: null, + maker_send_amount_units: null, + maker_receive_amount_units: null, command_at: null, execution_result_at: null, outcome_observed_at: null, @@ -1562,6 +1586,10 @@ function normalizeCommand(command) { execution_key: command.execution_key || null, quote_id: command.quote_id || null, pair: command.pair || null, + request_pair: command.request_pair || null, + request_pair_id: command.request_pair_id || null, + maker_trade_pair: command.maker_trade_pair || null, + maker_trade_pair_id: command.maker_trade_pair_id || null, pair_id: command.pair_id || null, pair_config_id: command.pair_config_id || null, pair_config_version: command.pair_config_version || null, @@ -1576,6 +1604,12 @@ function normalizeCommand(command) { asset_out: command.asset_out || null, amount_in: command.amount_in ?? null, amount_out: command.amount_out ?? null, + maker_send_asset_id: command.maker_send_asset_id || null, + maker_receive_asset_id: command.maker_receive_asset_id || null, + maker_send_symbol: command.maker_send_symbol || null, + maker_receive_symbol: command.maker_receive_symbol || null, + maker_send_amount_units: command.maker_send_amount_units || null, + maker_receive_amount_units: command.maker_receive_amount_units || null, maker_timing: command.maker_timing || null, quote_age_at_command_ms: command.quote_age_at_command_ms ?? null, command_at: command.command_at || command.observed_at || command.ingested_at || null, @@ -2107,7 +2141,7 @@ function enrichLifecycleRowForUi({ config, row }) { }), maker_terms: buildMakerLifecycleTerms({ config, - terms: row.command || row.execution || null, + terms: row.command || row.execution || row.decision || row, }), inventory_check: buildInventoryCheck({ config, @@ -2145,6 +2179,31 @@ function buildLifecycleTerms({ config, terms }) { } function buildMakerLifecycleTerms({ config, terms }) { + if (terms?.maker_send_asset_id || terms?.maker_receive_asset_id) { + const sendAsset = config.assetRegistry.get(terms.maker_send_asset_id); + const receiveAsset = config.assetRegistry.get(terms.maker_receive_asset_id); + const sendAmount = terms.maker_send_amount_units ?? null; + const receiveAmount = terms.maker_receive_amount_units ?? null; + const sendDisplay = sendAmount == null ? null : formatUnits(sendAmount, sendAsset?.decimals || 0); + const receiveDisplay = receiveAmount == null ? null : formatUnits(receiveAmount, receiveAsset?.decimals || 0); + const sendSymbol = sendAsset?.symbol || terms.maker_send_symbol || terms.maker_send_asset_id || null; + const receiveSymbol = receiveAsset?.symbol || terms.maker_receive_symbol || terms.maker_receive_asset_id || null; + return { + receive_asset: terms.maker_receive_asset_id || null, + receive_asset_symbol: receiveSymbol, + receive_amount_units: receiveAmount, + receive_amount: receiveDisplay, + send_asset: terms.maker_send_asset_id || null, + send_asset_symbol: sendSymbol, + send_amount_units: sendAmount, + send_amount: sendDisplay, + delta_text: [ + sendSymbol ? `-${sendDisplay || ''} ${sendSymbol}`.trim() : null, + receiveSymbol ? `+${receiveDisplay || ''} ${receiveSymbol}`.trim() : null, + ].filter(Boolean).join(' / '), + }; + } + const relayTerms = buildLifecycleTerms({ config, terms }); if (!relayTerms?.asset_in && !relayTerms?.asset_out) return null; return { @@ -2156,6 +2215,10 @@ function buildMakerLifecycleTerms({ config, terms }) { send_asset_symbol: relayTerms.asset_out_symbol, send_amount_units: relayTerms.amount_out_units, send_amount: relayTerms.amount_out, + delta_text: [ + relayTerms.asset_out ? `-${relayTerms.amount_out || ''} ${relayTerms.asset_out_symbol || relayTerms.asset_out}`.trim() : null, + relayTerms.asset_in ? `+${relayTerms.amount_in || ''} ${relayTerms.asset_in_symbol || relayTerms.asset_in}`.trim() : null, + ].filter(Boolean).join(' / '), }; } @@ -2391,6 +2454,10 @@ function normalizeDecision(decision) { decision_at: decision.decision_at || null, quote_id: decision.quote_id || null, pair: decision.pair || null, + request_pair: decision.request_pair || null, + request_pair_id: decision.request_pair_id || null, + maker_trade_pair: decision.maker_trade_pair || null, + maker_trade_pair_id: decision.maker_trade_pair_id || null, pair_id: decision.pair_id || null, pair_config_id: decision.pair_config_id || null, pair_config_version: decision.pair_config_version || null, @@ -2409,6 +2476,12 @@ function normalizeDecision(decision) { inventory_available: decision.inventory_available || null, inventory_spendable: decision.inventory_spendable || null, inventory_pending_outbound: decision.inventory_pending_outbound || null, + maker_send_asset_id: decision.maker_send_asset_id || null, + maker_receive_asset_id: decision.maker_receive_asset_id || null, + maker_send_symbol: decision.maker_send_symbol || null, + maker_receive_symbol: decision.maker_receive_symbol || null, + maker_send_amount_units: decision.maker_send_amount_units || null, + maker_receive_amount_units: decision.maker_receive_amount_units || null, pending_inbound: decision.pending_inbound || null, pending_outbound: decision.pending_outbound || null, notional: decision.notional || null, @@ -2569,6 +2642,10 @@ function normalizeLiveExecutionResult(payload, event) { execution_key: payload.execution_key || null, quote_id: payload.quote_id || null, pair: payload.pair || null, + request_pair: payload.request_pair || null, + request_pair_id: payload.request_pair_id || null, + maker_trade_pair: payload.maker_trade_pair || null, + maker_trade_pair_id: payload.maker_trade_pair_id || null, result_at: event.observed_at || event.ingested_at || new Date().toISOString(), status: payload.status || null, result_code: payload.result_code || null, @@ -2582,6 +2659,10 @@ function normalizeLiveExecutionResult(payload, event) { error_message: payload.relay_error_message || payload.error?.message || null, note: payload.note || null, timing: payload.executor_timing || null, + maker_send_asset_id: payload.maker_send_asset_id || null, + maker_receive_asset_id: payload.maker_receive_asset_id || null, + maker_send_amount_units: payload.maker_send_amount_units || null, + maker_receive_amount_units: payload.maker_receive_amount_units || null, maker_timing: payload.maker_timing || null, }; } diff --git a/src/core/strategy.mjs b/src/core/strategy.mjs index 93f84f7..dd3852a 100644 --- a/src/core/strategy.mjs +++ b/src/core/strategy.mjs @@ -19,6 +19,11 @@ import { const EDGE_EPSILON_PCT = 1e-9; +export function makerTradePairKeyForDemand(payload = {}) { + if (!payload?.asset_in || !payload?.asset_out) return null; + return pairKey(payload.asset_out, payload.asset_in); +} + export function evaluateTradeOpportunity({ demandEvent, priceEvent, @@ -31,6 +36,8 @@ export function evaluateTradeOpportunity({ maxNotional = config.strategyMaxNotional ?? config.strategyMaxNotionalEure, }) { const payload = demandEvent.payload; + const requestPairKey = pairKey(payload.asset_in, payload.asset_out); + const makerTradePairKey = makerTradePairKeyForDemand(payload); const pairRuntime = resolvePairRuntime({ payload, config, thresholdPct, maxNotional }); const effectiveThresholdPct = pairRuntime.thresholdPct ?? thresholdPct; const effectiveMaxNotional = pairRuntime.maxNotional ?? maxNotional; @@ -46,7 +53,11 @@ export function evaluateTradeOpportunity({ decision_id: decisionId, decision_at: decisionAt, quote_id: payload.quote_id, - pair: pairKey(payload.asset_in, payload.asset_out), + pair: requestPairKey, + request_pair: requestPairKey, + request_pair_id: pairRuntime.requestPair?.pairId || requestPairKey, + maker_trade_pair: pairRuntime.makerTradePairKey || makerTradePairKey, + maker_trade_pair_id: pairRuntime.makerTradePair?.pairId || pairRuntime.pair?.pairId || makerTradePairKey, pair_id: pairRuntime.pair?.pairId || null, pair_config_id: pairRuntime.strategyConfig?.configId || null, pair_config_version: pairRuntime.strategyConfig?.version == null @@ -69,6 +80,10 @@ export function evaluateTradeOpportunity({ maker_timing: makerTiming, quote_age_at_decision_ms: makerTiming.quote_age_at_decision_ms, strategy_armed: armed, + maker_send_asset_id: payload.asset_out, + maker_receive_asset_id: payload.asset_in, + maker_send_symbol: pairRuntime.assetOut?.symbol || null, + maker_receive_symbol: pairRuntime.assetIn?.symbol || null, assumptions: compact({ eure_per_eur: pairRuntime.priceRoute?.source === 'btc_eur_reference' ? '1' : null, usdc_per_usd: pairRuntime.priceRoute?.source === 'btc_usdc_reference' ? '1' : null, @@ -140,10 +155,16 @@ export function evaluateTradeOpportunity({ }); if (!buildResult.ok) { + const makerTerms = buildMakerTradeTerms({ + demand: payload, + proposedAmountIn: buildResult.details?.proposed_amount_in ?? null, + proposedAmountOut: buildResult.details?.proposed_amount_out ?? null, + }); return { decision: { ...baseDecision, ...buildResult.details, + ...makerTerms, price_freshness_ms: String(priceAgeMs), inventory_freshness_ms: String(inventoryAgeMs), decision_reason: buildResult.reason, @@ -151,9 +172,16 @@ export function evaluateTradeOpportunity({ }; } + const makerTerms = buildMakerTradeTerms({ + demand: payload, + quoteOutput: buildResult.quoteOutput, + proposedAmountIn: buildResult.details.proposed_amount_in ?? null, + proposedAmountOut: buildResult.details.proposed_amount_out ?? null, + }); const decision = { ...baseDecision, ...buildResult.details, + ...makerTerms, price_freshness_ms: String(priceAgeMs), inventory_freshness_ms: String(inventoryAgeMs), decision: armed ? 'actionable' : 'rejected', @@ -206,6 +234,7 @@ 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, + ...makerTerms, expected_inventory_delta_units: buildExpectedMakerInventoryDelta({ demand: payload, quoteOutput: buildResult.quoteOutput, @@ -563,13 +592,35 @@ function buildExpectedMakerInventoryDelta({ }; } +function buildMakerTradeTerms({ + demand, + quoteOutput = {}, + proposedAmountIn = null, + proposedAmountOut = null, +} = {}) { + if (!demand?.asset_in || !demand?.asset_out || !demand?.request_kind) return {}; + 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; + return compact({ + maker_send_asset_id: demand.asset_out, + maker_receive_asset_id: demand.asset_in, + maker_send_amount_units: sendAmount == null ? null : String(sendAmount), + maker_receive_amount_units: receiveAmount == null ? null : String(receiveAmount), + }); +} + function resolvePairRuntime({ payload, config, thresholdPct, maxNotional, }) { - const key = pairKey(payload.asset_in, payload.asset_out); + const requestKey = pairKey(payload.asset_in, payload.asset_out); + const makerTradeKey = makerTradePairKeyForDemand(payload); const requiresDb = config.requireDbTradingConfig === true || config.tradingConfigLoaded === true; if (!requiresDb) { @@ -619,18 +670,21 @@ function resolvePairRuntime({ }; } - const pair = config.pairByKey?.get(key) || null; - if (!pair || !pair.enabled || !pair.observeEnabled) { + const requestPair = config.pairByKey?.get(requestKey) || null; + if (!requestPair || !requestPair.enabled || !requestPair.observeEnabled) { return { ok: false, - reason: pair ? 'pair_disabled' : 'unsupported_pair', + reason: requestPair ? 'pair_disabled' : 'unsupported_pair', direction: 'unsupported', - pair, - strategyConfig: pair?.strategyConfig || null, - priceRoute: pair?.priceRoute || null, + pair: null, + requestPair, + makerTradePair: null, + makerTradePairKey: makerTradeKey, + strategyConfig: null, + priceRoute: requestPair?.priceRoute || null, assetRegistry: config.assetRegistry, - assetIn: pair?.assetIn || config.assetRegistry?.get(payload.asset_in) || null, - assetOut: pair?.assetOut || config.assetRegistry?.get(payload.asset_out) || null, + assetIn: config.assetRegistry?.get(payload.asset_in) || null, + assetOut: config.assetRegistry?.get(payload.asset_out) || null, thresholdPct, maxNotional, priceMaxAgeMs: config.strategyPriceMaxAgeMs, @@ -638,24 +692,34 @@ function resolvePairRuntime({ }; } - if (!pair.makerEnabled) return blockedPairRuntime(pair, config, 'pair_not_maker_enabled'); - if (pair.blockReason) return blockedPairRuntime(pair, config, pair.blockReason); - if (!pair.strategyConfig) return blockedPairRuntime(pair, config, 'pair_strategy_config_missing'); - if (!pair.priceRoute) return blockedPairRuntime(pair, config, 'price_route_missing'); + const pair = config.pairByKey?.get(makerTradeKey) || null; + if (!pair) { + return blockedPairRuntime(null, config, 'maker_trade_pair_unavailable', { payload, requestPair, makerTradePairKey: makerTradeKey }); + } + if (!pair.makerEnabled) return blockedPairRuntime(pair, config, 'pair_not_maker_enabled', { payload, requestPair, makerTradePairKey: makerTradeKey }); + if (pair.blockReason) return blockedPairRuntime(pair, config, pair.blockReason, { payload, requestPair, makerTradePairKey: makerTradeKey }); + if (!pair.strategyConfig) return blockedPairRuntime(pair, config, 'pair_strategy_config_missing', { payload, requestPair, makerTradePairKey: makerTradeKey }); + const priceRoute = pair.priceRoute || requestPair.priceRoute || null; + if (!priceRoute) return blockedPairRuntime(pair, config, 'price_route_missing', { payload, requestPair, makerTradePairKey: makerTradeKey }); - const direction = classifyPriceRouteDirection({ payload, priceRoute: pair.priceRoute }); - if (direction === 'unsupported') return blockedPairRuntime(pair, config, 'price_route_missing'); + const direction = classifyPriceRouteDirection({ payload, priceRoute }); + if (direction === 'unsupported') { + return blockedPairRuntime(pair, config, 'price_route_missing', { payload, requestPair, makerTradePairKey: makerTradeKey, priceRoute }); + } return { ok: true, reason: null, direction, pair, + requestPair, + makerTradePair: pair, + makerTradePairKey: makerTradeKey, strategyConfig: pair.strategyConfig, - priceRoute: pair.priceRoute, + priceRoute, assetRegistry: config.assetRegistry, - assetIn: pair.assetIn, - assetOut: pair.assetOut, + assetIn: config.assetRegistry?.get(payload.asset_in) || null, + assetOut: config.assetRegistry?.get(payload.asset_out) || null, thresholdPct: Number(pair.strategyConfig.edgeBps) / 100, maxNotional: Number(pair.strategyConfig.maxNotional), minNotional: Number(pair.strategyConfig.minNotional ?? 0), @@ -664,20 +728,35 @@ function resolvePairRuntime({ }; } -function blockedPairRuntime(pair, config, reason) { +function blockedPairRuntime(pair, config, reason, { + payload = null, + requestPair = null, + makerTradePairKey = pair?.key || pair?.pairId || null, + priceRoute = null, +} = {}) { + const assetIn = payload?.asset_in + ? config.assetRegistry?.get(payload.asset_in) || null + : pair?.assetIn || null; + const assetOut = payload?.asset_out + ? config.assetRegistry?.get(payload.asset_out) || null + : pair?.assetOut || null; + const effectivePriceRoute = priceRoute || pair?.priceRoute || requestPair?.priceRoute || null; return { ok: false, reason, direction: classifyPriceRouteDirection({ - payload: { asset_in: pair?.asset_in, asset_out: pair?.asset_out }, - priceRoute: pair?.priceRoute, + payload: payload || { asset_in: pair?.asset_in, asset_out: pair?.asset_out }, + priceRoute: effectivePriceRoute, }), pair, + requestPair, + makerTradePair: pair || null, + makerTradePairKey, strategyConfig: pair?.strategyConfig || null, - priceRoute: pair?.priceRoute || null, + priceRoute: effectivePriceRoute, assetRegistry: config.assetRegistry, - assetIn: pair?.assetIn || null, - assetOut: pair?.assetOut || null, + assetIn, + assetOut, thresholdPct: pair?.strategyConfig ? Number(pair.strategyConfig.edgeBps) / 100 : config.strategyGrossThresholdPct, maxNotional: pair?.strategyConfig ? Number(pair.strategyConfig.maxNotional) diff --git a/src/operator-dashboard/static/pages/StrategyPage.jsx b/src/operator-dashboard/static/pages/StrategyPage.jsx index c2f2e8f..ae46044 100644 --- a/src/operator-dashboard/static/pages/StrategyPage.jsx +++ b/src/operator-dashboard/static/pages/StrategyPage.jsx @@ -104,13 +104,14 @@ function formatTerms(terms) { function formatMakerTerms(terms) { if (!terms) return null; + if (terms.delta_text) return `For us: ${terms.delta_text}`; const send = terms.send_amount ? `${terms.send_amount} ${terms.send_asset_symbol || ''}`.trim() : terms.send_asset_symbol || terms.send_asset || 'send unavailable'; const receive = terms.receive_amount ? `${terms.receive_amount} ${terms.receive_asset_symbol || ''}`.trim() : terms.receive_asset_symbol || terms.receive_asset || 'receive unavailable'; - return `Maker sends ${send}; receives ${receive}`; + return `For us: -${send}; +${receive}`; } function formatInventoryCheck(check) { @@ -301,6 +302,22 @@ function pairDisplayLabel(pairId, pairConfig) { return `${pair.asset_in_symbol || pair.asset_in || pair.assetIn} -> ${pair.asset_out_symbol || pair.asset_out || pair.assetOut}`; } +function pairAssetInSymbol(pair) { + return pair.asset_in_symbol || pair.assetInSymbol || pair.asset_in || pair.assetIn || 'send asset'; +} + +function pairAssetOutSymbol(pair) { + return pair.asset_out_symbol || pair.assetOutSymbol || pair.asset_out || pair.assetOut || 'receive asset'; +} + +function makerPairMovementLabel(pair) { + return `For us: -${pairAssetInSymbol(pair)} / +${pairAssetOutSymbol(pair)}`; +} + +function makerPairRequestLabel(pair) { + return `Requests: ${pairAssetOutSymbol(pair)} -> ${pairAssetInSymbol(pair)}`; +} + function fixedRows(items, count) { const rows = (items || []).slice(0, count); while (rows.length < count) rows.push(null); @@ -760,8 +777,11 @@ function QuoteLifecycleTable({ items }) {
-
{formatTerms(item.request_terms || item.submitted_terms)}
-
{truncateMiddle(item.pair || '', 34)}
+
{formatMakerTerms(item.maker_terms) || formatTerms(item.request_terms || item.submitted_terms)}
+
+ {item.maker_terms ? `Request: ${formatTerms(item.request_terms || item.submitted_terms)}` : ''} +
+
{truncateMiddle(item.maker_trade_pair || item.pair || '', 34)}
{responseLabel(item)} @@ -852,7 +872,8 @@ function SuccessfulTradesTable({ items }) {
Estimate from edge x notional, not realized PnL.
-
{item.settlement_summary?.text || 'No settled inventory delta is linked to this quote.'}
+
{formatMakerTerms(item.maker_terms) || item.settlement_summary?.text || 'No settled inventory delta is linked to this quote.'}
+ {item.maker_terms ?
{item.settlement_summary?.text || 'No settled inventory delta is linked to this quote.'}
: null} {item.settlement_summary?.method ?
{item.settlement_summary.method}
: null} @@ -1261,7 +1282,9 @@ function PairConfigSection({ assetCatalog, pairConfig, onControl }) { return ( -
{pair.asset_in_symbol || pair.asset_in} {'->'} {pair.asset_out_symbol || pair.asset_out}
+
{pairAssetInSymbol(pair)} {'->'} {pairAssetOutSymbol(pair)}
+
{makerPairMovementLabel(pair)}
+
{makerPairRequestLabel(pair)}
{truncateMiddle(pairId, 42)}
diff --git a/test/operator-dashboard-ui-static.test.mjs b/test/operator-dashboard-ui-static.test.mjs index 3914bdd..06c4aa3 100644 --- a/test/operator-dashboard-ui-static.test.mjs +++ b/test/operator-dashboard-ui-static.test.mjs @@ -53,7 +53,9 @@ test('strategy page owns consolidated quote lifecycle and successful trade table assert.match(strategySource, /Timing:/); assert.match(strategySource, /item\.execution\?\.status === 'submitted'/); assert.match(strategySource, /Submitted means the relay accepted the response; it does not prove a trade\./); - assert.match(strategySource, /Maker sends/); + assert.match(strategySource, /For us:/); + assert.match(strategySource, /makerPairMovementLabel/); + assert.match(strategySource, /makerPairRequestLabel/); assert.match(strategySource, /formatInventoryCheck/); assert.doesNotMatch(strategySource, /Actionable|actionable/); }); diff --git a/test/operator-dashboard.test.mjs b/test/operator-dashboard.test.mjs index f9868a8..ff7afbc 100644 --- a/test/operator-dashboard.test.mjs +++ b/test/operator-dashboard.test.mjs @@ -1846,6 +1846,7 @@ test('bootstrap lifecycle rows preserve quote terms, submitted terms, and gross assert.equal(row.maker_terms.send_asset_symbol, 'EURe'); assert.equal(row.maker_terms.receive_amount, '0.00123208'); assert.equal(row.maker_terms.receive_asset_symbol, 'BTC'); + assert.equal(row.maker_terms.delta_text, '-76 EURe / +0.00123208 BTC'); assert.equal(row.inventory_check.required, '76'); assert.equal(row.inventory_check.available, '79'); assert.equal(row.inventory_check.spendable, '80'); diff --git a/test/strategy.test.mjs b/test/strategy.test.mjs index 304792b..111d606 100644 --- a/test/strategy.test.mjs +++ b/test/strategy.test.mjs @@ -228,6 +228,50 @@ test('strategy uses BTC inventory, not USDC inventory, for USDC -> BTC maker res assert.equal(result.command.expected_inventory_delta_units[config.tradingUsdc.assetId], '8000000'); }); +test('USDC -> BTC requester quotes use BTC -> USDC maker-side edge config when we sell BTC', () => { + const config = makeBtcUsdcDbConfig({ + strategyConfigOverrides: { edgeBps: 400 }, + reverseStrategyConfigOverrides: { edgeBps: 1 }, + }); + const requestPair = `${config.tradingUsdc.assetId}->${config.tradingBtc.assetId}`; + const makerTradePair = `${config.tradingBtc.assetId}->${config.tradingUsdc.assetId}`; + const result = evaluateTradeOpportunity({ + demandEvent: { + payload: { + quote_id: 'quote-usdc-to-btc-maker-edge', + pair: requestPair, + asset_in: config.tradingUsdc.assetId, + asset_out: config.tradingBtc.assetId, + request_kind: 'exact_in', + amount_in: '8000000', + min_deadline_ms: '60000', + }, + }, + priceEvent: makeBtcUsdcPriceEvent(), + inventoryEvent: makeBtcUsdcInventoryEvent({ + spendable: { + [config.tradingBtc.assetId]: '1000000', + [config.tradingUsdc.assetId]: '0', + }, + }), + config, + armed: true, + now: Date.parse('2026-04-02T10:00:05.000Z'), + }); + + assert.equal(result.decision.decision, 'actionable'); + assert.equal(result.decision.request_pair, requestPair); + assert.equal(result.decision.maker_trade_pair, makerTradePair); + assert.equal(result.decision.pair_id, makerTradePair); + assert.equal(result.decision.edge_bps, '400'); + assert.equal(result.decision.threshold_pct, '4'); + assert.equal(result.command.pair_config_id, `${makerTradePair}:v1`); + assert.equal(result.command.maker_send_asset_id, config.tradingBtc.assetId); + assert.equal(result.command.maker_receive_asset_id, config.tradingUsdc.assetId); + assert.equal(result.command.expected_inventory_delta_units[config.tradingBtc.assetId].startsWith('-'), true); + assert.equal(result.command.expected_inventory_delta_units[config.tradingUsdc.assetId], '8000000'); +}); + test('strategy subtracts pending outbound from maker inventory before approving USDC -> BTC responses', () => { const config = makeBtcUsdcDbConfig({ includeReversePair: true }); const reversePair = `${config.tradingUsdc.assetId}->${config.tradingBtc.assetId}`; @@ -528,7 +572,11 @@ test('disabled maker response age policy preserves current BTC -> USDC actionabi assert.ok(result.command); }); -function makeBtcUsdcDbConfig({ strategyConfigOverrides = {}, includeReversePair = false } = {}) { +function makeBtcUsdcDbConfig({ + strategyConfigOverrides = {}, + reverseStrategyConfigOverrides = {}, + includeReversePair = true, +} = {}) { const tradingBtc = { assetId: 'nep141:nbtc.bridge.near', symbol: 'BTC', @@ -581,6 +629,7 @@ function makeBtcUsdcDbConfig({ strategyConfigOverrides = {}, includeReversePair const reverseStrategyConfig = { ...strategyConfig, configId: `${reversePair}:v1`, + ...reverseStrategyConfigOverrides, }; const reversePairConfig = { ...pair, diff --git a/test/trading-config.test.mjs b/test/trading-config.test.mjs index cd05876..dc99f0a 100644 --- a/test/trading-config.test.mjs +++ b/test/trading-config.test.mjs @@ -5,6 +5,7 @@ import { evaluateTradeOpportunity } from '../src/core/strategy.mjs'; import { CURRENT_EURE_ASSET_ID, CURRENT_NBTC_ASSET_ID, + CURRENT_REVERSE_PAIR_KEY, CURRENT_USDC_ASSET_ID, GNOSIS_USDC_ASSET_ID, LEGACY_OMFT_BTC_ASSET_ID, @@ -555,7 +556,7 @@ test('pair mode activation requires both assets to be registered', async () => { ); }); -test('strategy uses DB pair config for current pair and persists config version', async () => { +test('strategy uses DB maker-side pair config and persists config version', async () => { const pool = createMemoryPool(); const snapshot = await seedTradingConfig(pool); const result = evaluateTradeOpportunity({ @@ -578,10 +579,12 @@ test('strategy uses DB pair config for current pair and persists config version' }); assert.equal(result.decision.decision, 'actionable'); + assert.equal(result.decision.request_pair, `${CURRENT_NBTC_ASSET_ID}->${CURRENT_EURE_ASSET_ID}`); + assert.equal(result.decision.maker_trade_pair, CURRENT_REVERSE_PAIR_KEY); assert.equal(result.decision.edge_bps, '49'); assert.equal(result.decision.pair_config_version, '1'); assert.equal(result.command.quote_output.amount_out, '4975500000000000000'); - assert.equal(result.command.pair_config_id, `${CURRENT_NBTC_ASSET_ID}->${CURRENT_EURE_ASSET_ID}:v1`); + assert.equal(result.command.pair_config_id, `${CURRENT_REVERSE_PAIR_KEY}:v1`); }); function token(assetId, symbol, decimals) {