From 7c006ac6a2b964e1a9f40cfcfc3da2263ca9a38a Mon Sep 17 00:00:00 2001 From: philipp Date: Tue, 19 May 2026 18:36:42 +0200 Subject: [PATCH] Clarify maker inventory direction Proof: Strategy tests now cover USDC -> BTC maker responses using BTC inventory with zero USDC, pending outbound units are subtracted before approval, lifecycle rows expose maker send/receive terms and inventory check details, targeted dashboard tests pass, full npm test passes, and the operator dashboard bundle builds. Assumptions: pending_outbound in inventory snapshots represents units unavailable for new maker commitments; this change does not skip quotes because of relay-error risk and does not loosen edge, notional, arming, pair enablement, stale price, or stale inventory checks. Still fake: relay acceptance is still only submission evidence; venue-native terminal fill ids and fee-complete realized PnL remain unavailable. --- src/core/operator-dashboard.mjs | 69 ++++++++++++ src/core/strategy.mjs | 19 +++- .../static/pages/StrategyPage.jsx | 28 ++++- test/operator-dashboard-ui-static.test.mjs | 2 + test/operator-dashboard.test.mjs | 13 +++ test/strategy.test.mjs | 100 +++++++++++++++++- 6 files changed, 226 insertions(+), 5 deletions(-) diff --git a/src/core/operator-dashboard.mjs b/src/core/operator-dashboard.mjs index e411056..5a75f37 100644 --- a/src/core/operator-dashboard.mjs +++ b/src/core/operator-dashboard.mjs @@ -1111,6 +1111,7 @@ const HUMAN_REASON_TEXT = { maker_quote_response_policy_invalid: 'Maker response-age policy is invalid.', maker_quote_too_old: 'Maker quote is too old for the configured response-age policy.', pending_deposit_not_credited: 'Funding is not credited yet.', + pending_outbound_reserved: 'Pending outbound inventory is reserved.', quote_expired: 'Quote expired.', quote_response_ack: 'Quote response acknowledged by the relay.', quote_response_ok: 'Quote response accepted by the relay.', @@ -1181,6 +1182,13 @@ export function deriveQuoteLifecycleRows({ direction: decision.direction, request_kind: decision.request_kind, gross_edge_pct: decision.gross_edge_pct, + inventory_asset: decision.inventory_asset, + inventory_required: decision.inventory_required, + inventory_available: decision.inventory_available, + inventory_spendable: decision.inventory_spendable, + inventory_pending_outbound: decision.inventory_pending_outbound, + pending_inbound: decision.pending_inbound, + pending_outbound: decision.pending_outbound, notional: decision.notional, notional_asset_id: decision.notional_asset_id, notional_symbol: decision.notional_symbol, @@ -1284,6 +1292,13 @@ function ensureLifecycleRow(rowsByKey, key) { direction: null, request_kind: null, gross_edge_pct: null, + inventory_asset: null, + inventory_required: null, + inventory_available: null, + inventory_spendable: null, + inventory_pending_outbound: null, + pending_inbound: null, + pending_outbound: null, notional: null, notional_asset_id: null, notional_symbol: null, @@ -2037,6 +2052,14 @@ function enrichLifecycleRowForUi({ config, row }) { config, terms: row.command || row.execution || null, }), + maker_terms: buildMakerLifecycleTerms({ + config, + terms: row.command || row.execution || null, + }), + inventory_check: buildInventoryCheck({ + config, + decision: row.decision || row, + }), gross_edge_value: estimateGrossEdgeValue(row), gross_edge_value_eure: estimateGrossEdgeValueEure(row), settlement_summary: buildSettlementSummary({ @@ -2068,6 +2091,46 @@ function buildLifecycleTerms({ config, terms }) { }; } +function buildMakerLifecycleTerms({ config, terms }) { + const relayTerms = buildLifecycleTerms({ config, terms }); + if (!relayTerms?.asset_in && !relayTerms?.asset_out) return null; + return { + receive_asset: relayTerms.asset_in, + receive_asset_symbol: relayTerms.asset_in_symbol, + receive_amount_units: relayTerms.amount_in_units, + receive_amount: relayTerms.amount_in, + send_asset: relayTerms.asset_out, + send_asset_symbol: relayTerms.asset_out_symbol, + send_amount_units: relayTerms.amount_out_units, + send_amount: relayTerms.amount_out, + }; +} + +function buildInventoryCheck({ config, decision }) { + if (!decision?.inventory_asset) return null; + const asset = config.assetRegistry.get(decision.inventory_asset); + const decimals = asset?.decimals || 0; + const required = decision.inventory_required ?? null; + const available = decision.inventory_available ?? null; + const spendable = decision.inventory_spendable ?? null; + const pendingOutbound = decision.inventory_pending_outbound ?? decision.pending_outbound ?? null; + const pendingInbound = decision.pending_inbound ?? null; + return { + asset_id: decision.inventory_asset, + asset_symbol: asset?.symbol || decision.inventory_asset, + required_units: required, + required: required == null ? null : formatUnits(required, decimals), + available_units: available, + available: available == null ? null : formatUnits(available, decimals), + spendable_units: spendable, + spendable: spendable == null ? null : formatUnits(spendable, decimals), + pending_outbound_units: pendingOutbound, + pending_outbound: pendingOutbound == null ? null : formatUnits(pendingOutbound, decimals), + pending_inbound_units: pendingInbound, + pending_inbound: pendingInbound == null ? null : formatUnits(pendingInbound, decimals), + }; +} + function estimateGrossEdgeValueEure(row) { if (row?.notional && row?.notional_symbol && row.notional_symbol !== 'EURe') return null; return estimateGrossEdgeValue(row); @@ -2289,6 +2352,12 @@ function normalizeDecision(decision) { max_notional_eure: decision.max_notional_eure || null, strategy_armed: decision.strategy_armed ?? null, inventory_asset: decision.inventory_asset || null, + inventory_required: decision.inventory_required || null, + inventory_available: decision.inventory_available || null, + inventory_spendable: decision.inventory_spendable || null, + inventory_pending_outbound: decision.inventory_pending_outbound || null, + pending_inbound: decision.pending_inbound || null, + pending_outbound: decision.pending_outbound || null, notional: decision.notional || null, notional_asset_id: decision.notional_asset_id || null, notional_symbol: decision.notional_symbol || null, diff --git a/src/core/strategy.mjs b/src/core/strategy.mjs index 41363b3..93f84f7 100644 --- a/src/core/strategy.mjs +++ b/src/core/strategy.mjs @@ -294,7 +294,9 @@ function buildQuote({ const thresholdFactor = 1 - (thresholdPct / 100); const penaltyFactor = 1 + (thresholdPct / 100); const spendAsset = demand.asset_out; - const available = bigintAmount(inventory.spendable?.[spendAsset] || '0'); + const spendable = bigintAmount(inventory.spendable?.[spendAsset] || '0'); + const pendingOutbound = bigintAmount(inventory.pending_outbound?.[spendAsset] || '0'); + const available = spendable > pendingOutbound ? spendable - pendingOutbound : 0n; const pendingInbound = bigintAmount(inventory.pending_inbound?.[spendAsset] || '0'); const referenceRates = resolveRouteRates({ direction, @@ -338,7 +340,9 @@ function buildQuote({ return finalizeQuote({ direction, available, + spendable, pendingInbound, + pendingOutbound, spendAsset, spendRequired, quoteNotional, @@ -392,7 +396,9 @@ function buildQuote({ return finalizeQuote({ direction, available, + spendable, pendingInbound, + pendingOutbound, spendAsset, spendRequired, quoteNotional, @@ -423,7 +429,9 @@ function buildQuote({ function finalizeQuote({ direction, available, + spendable, pendingInbound, + pendingOutbound, spendAsset, spendRequired, quoteNotional, @@ -455,6 +463,8 @@ function finalizeQuote({ inventory_asset: spendAsset, inventory_required: spendRequired.toString(), inventory_available: available.toString(), + inventory_spendable: spendable.toString(), + inventory_pending_outbound: pendingOutbound.toString(), inventory_id: inventoryId, price_id: priceId, reference_price_id: priceId, @@ -497,10 +507,15 @@ function finalizeQuote({ if (available < spendRequired) { return { ok: false, - reason: pendingInbound > 0n ? 'pending_deposit_not_credited' : 'insufficient_inventory', + reason: pendingInbound > 0n + ? 'pending_deposit_not_credited' + : pendingOutbound > 0n + ? 'pending_outbound_reserved' + : 'insufficient_inventory', details: { ...reasonBase, pending_inbound: pendingInbound.toString(), + pending_outbound: pendingOutbound.toString(), }, }; } diff --git a/src/operator-dashboard/static/pages/StrategyPage.jsx b/src/operator-dashboard/static/pages/StrategyPage.jsx index 4db4ddf..1ad5d5c 100644 --- a/src/operator-dashboard/static/pages/StrategyPage.jsx +++ b/src/operator-dashboard/static/pages/StrategyPage.jsx @@ -101,6 +101,28 @@ function formatTerms(terms) { return `${input} -> ${output}`; } +function formatMakerTerms(terms) { + if (!terms) return null; + 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}`; +} + +function formatInventoryCheck(check) { + if (!check) return null; + const required = check.required == null ? 'required unavailable' : `${check.required} ${check.asset_symbol || ''}`.trim(); + const available = check.available == null ? 'available unavailable' : `${check.available} available`; + const spendable = check.spendable == null ? null : `${check.spendable} spendable`; + const pendingOutbound = Number(check.pending_outbound_units || 0) > 0 + ? `${check.pending_outbound} pending outbound` + : null; + return [`Inventory ${required}`, available, spendable, pendingOutbound].filter(Boolean).join(', '); +} + function responseLabel(item) { if (RESPONDED_STATES.has(item.lifecycle_state)) return 'Yes'; if (item.lifecycle_state === 'failed') return 'Attempt failed'; @@ -217,6 +239,8 @@ function TimingWaterfall({ timing }) { function LifecycleDetails({ item }) { const executionTiming = formatExecutionTiming(item.execution?.timing); + const makerTerms = formatMakerTerms(item.maker_terms); + const inventoryCheck = formatInventoryCheck(item.inventory_check); return (
@@ -231,11 +255,13 @@ function LifecycleDetails({ item }) {
{formatGrossEdgePct(item.gross_edge_pct)}
{formatConfiguredEdgeBps(item.edge_bps)}
{notionalLabel(item)}
+ {inventoryCheck ?
{inventoryCheck}
: null} -
{formatTerms(item.submitted_terms)}
+
{makerTerms || formatTerms(item.submitted_terms)}
+ {makerTerms ?
{formatTerms(item.submitted_terms)}
: null}
diff --git a/test/operator-dashboard-ui-static.test.mjs b/test/operator-dashboard-ui-static.test.mjs index 57a1510..65dd402 100644 --- a/test/operator-dashboard-ui-static.test.mjs +++ b/test/operator-dashboard-ui-static.test.mjs @@ -50,6 +50,8 @@ 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, /formatInventoryCheck/); assert.doesNotMatch(strategySource, /Actionable|actionable/); }); diff --git a/test/operator-dashboard.test.mjs b/test/operator-dashboard.test.mjs index 1f5015f..83cb2da 100644 --- a/test/operator-dashboard.test.mjs +++ b/test/operator-dashboard.test.mjs @@ -1717,6 +1717,11 @@ test('bootstrap lifecycle rows preserve quote terms, submitted terms, and gross decision: 'actionable', decision_reason: 'actionable', gross_edge_pct: '1.5', + inventory_asset: config.tradingEure.assetId, + inventory_required: '76000000000000000000', + inventory_spendable: '80000000000000000000', + inventory_pending_outbound: '1000000000000000000', + inventory_available: '79000000000000000000', eure_notional: '100', }, }], @@ -1746,6 +1751,14 @@ test('bootstrap lifecycle rows preserve quote terms, submitted terms, and gross assert.equal(row.request_terms.asset_in_symbol, 'BTC'); assert.equal(row.submitted_terms.amount_out, '76'); assert.equal(row.submitted_terms.asset_out_symbol, 'EURe'); + assert.equal(row.maker_terms.send_amount, '76'); + 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.inventory_check.required, '76'); + assert.equal(row.inventory_check.available, '79'); + assert.equal(row.inventory_check.spendable, '80'); + assert.equal(row.inventory_check.pending_outbound, '1'); assert.equal(row.gross_edge_value_eure, '1.5'); }); diff --git a/test/strategy.test.mjs b/test/strategy.test.mjs index 4dbd994..304792b 100644 --- a/test/strategy.test.mjs +++ b/test/strategy.test.mjs @@ -193,6 +193,81 @@ test('strategy emits actionable exact-in BTC -> USDC command from DB price route assert.equal(result.command.asset_out_decimals, 6); }); +test('strategy uses BTC inventory, not USDC inventory, for USDC -> BTC maker responses', () => { + const config = makeBtcUsdcDbConfig({ includeReversePair: true }); + const reversePair = `${config.tradingUsdc.assetId}->${config.tradingBtc.assetId}`; + const result = evaluateTradeOpportunity({ + demandEvent: { + payload: { + quote_id: 'quote-usdc-to-btc-inventory-side', + pair: reversePair, + 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.direction, 'quote_to_base'); + assert.equal(result.decision.inventory_asset, config.tradingBtc.assetId); + assert.equal(result.decision.inventory_available, '1000000'); + assert.equal(result.command.expected_inventory_delta_units[config.tradingBtc.assetId], '-9951'); + 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}`; + const result = evaluateTradeOpportunity({ + demandEvent: { + payload: { + quote_id: 'quote-usdc-to-btc-pending-outbound', + pair: reversePair, + 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]: '9951', + [config.tradingUsdc.assetId]: '0', + }, + pending_outbound: { + [config.tradingBtc.assetId]: '1', + [config.tradingUsdc.assetId]: '0', + }, + }), + config, + armed: true, + now: Date.parse('2026-04-02T10:00:05.000Z'), + }); + + assert.equal(result.decision.decision, 'rejected'); + assert.equal(result.decision.decision_reason, 'pending_outbound_reserved'); + assert.equal(result.decision.inventory_required, '9951'); + assert.equal(result.decision.inventory_spendable, '9951'); + assert.equal(result.decision.inventory_pending_outbound, '1'); + assert.equal(result.decision.inventory_available, '9950'); + assert.equal(result.command, undefined); +}); + test('strategy rejects dust exact-out BTC -> USDC below configured min notional after integer rounding', () => { const config = makeBtcUsdcDbConfig({ strategyConfigOverrides: { @@ -453,7 +528,7 @@ test('disabled maker response age policy preserves current BTC -> USDC actionabi assert.ok(result.command); }); -function makeBtcUsdcDbConfig({ strategyConfigOverrides = {} } = {}) { +function makeBtcUsdcDbConfig({ strategyConfigOverrides = {}, includeReversePair = false } = {}) { const tradingBtc = { assetId: 'nep141:nbtc.bridge.near', symbol: 'BTC', @@ -467,6 +542,7 @@ function makeBtcUsdcDbConfig({ strategyConfigOverrides = {} } = {}) { chain: 'gnosis', }; const activePair = `${tradingBtc.assetId}->${tradingUsdc.assetId}`; + const reversePair = `${tradingUsdc.assetId}->${tradingBtc.assetId}`; const strategyConfig = { configId: `${activePair}:v1`, version: 1, @@ -502,6 +578,22 @@ function makeBtcUsdcDbConfig({ strategyConfigOverrides = {} } = {}) { strategyConfig, priceRoute, }; + const reverseStrategyConfig = { + ...strategyConfig, + configId: `${reversePair}:v1`, + }; + const reversePairConfig = { + ...pair, + pairId: reversePair, + key: reversePair, + assetIn: tradingUsdc, + assetOut: tradingBtc, + asset_in: tradingUsdc.assetId, + asset_out: tradingBtc.assetId, + strategyConfig: reverseStrategyConfig, + }; + const pairEntries = [[activePair, pair]]; + if (includeReversePair) pairEntries.push([reversePair, reversePairConfig]); return { tradingBtc, @@ -514,7 +606,7 @@ function makeBtcUsdcDbConfig({ strategyConfigOverrides = {} } = {}) { [tradingBtc.assetId, tradingBtc], [tradingUsdc.assetId, tradingUsdc], ]), - pairByKey: new Map([[activePair, pair]]), + pairByKey: new Map(pairEntries), strategyGrossThresholdPct: 0.49, strategyMaxNotionalEure: 150, strategyPriceMaxAgeMs: 30_000, @@ -555,6 +647,10 @@ function makeBtcUsdcInventoryEvent(overrides = {}) { 'nep141:nbtc.bridge.near': '0', 'nep141:gnosis-0x2a22f9c3b484c3629090feed35f17ff8f88f76f0.omft.near': '0', }, + pending_outbound: { + 'nep141:nbtc.bridge.near': '0', + 'nep141:gnosis-0x2a22f9c3b484c3629090feed35f17ff8f88f76f0.omft.near': '0', + }, ...overrides, }, };