Clarify maker inventory direction
All checks were successful
deploy / deploy (push) Successful in 1m3s

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.
This commit is contained in:
philipp 2026-05-19 18:36:42 +02:00
parent 7b2f31fd4d
commit 7c006ac6a2
6 changed files with 226 additions and 5 deletions

View file

@ -1111,6 +1111,7 @@ const HUMAN_REASON_TEXT = {
maker_quote_response_policy_invalid: 'Maker response-age policy is invalid.', 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.', 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_deposit_not_credited: 'Funding is not credited yet.',
pending_outbound_reserved: 'Pending outbound inventory is reserved.',
quote_expired: 'Quote expired.', quote_expired: 'Quote expired.',
quote_response_ack: 'Quote response acknowledged by the relay.', quote_response_ack: 'Quote response acknowledged by the relay.',
quote_response_ok: 'Quote response accepted by the relay.', quote_response_ok: 'Quote response accepted by the relay.',
@ -1181,6 +1182,13 @@ export function deriveQuoteLifecycleRows({
direction: decision.direction, direction: decision.direction,
request_kind: decision.request_kind, request_kind: decision.request_kind,
gross_edge_pct: decision.gross_edge_pct, 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: decision.notional,
notional_asset_id: decision.notional_asset_id, notional_asset_id: decision.notional_asset_id,
notional_symbol: decision.notional_symbol, notional_symbol: decision.notional_symbol,
@ -1284,6 +1292,13 @@ function ensureLifecycleRow(rowsByKey, key) {
direction: null, direction: null,
request_kind: null, request_kind: null,
gross_edge_pct: 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: null,
notional_asset_id: null, notional_asset_id: null,
notional_symbol: null, notional_symbol: null,
@ -2037,6 +2052,14 @@ function enrichLifecycleRowForUi({ config, row }) {
config, config,
terms: row.command || row.execution || null, 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: estimateGrossEdgeValue(row),
gross_edge_value_eure: estimateGrossEdgeValueEure(row), gross_edge_value_eure: estimateGrossEdgeValueEure(row),
settlement_summary: buildSettlementSummary({ 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) { function estimateGrossEdgeValueEure(row) {
if (row?.notional && row?.notional_symbol && row.notional_symbol !== 'EURe') return null; if (row?.notional && row?.notional_symbol && row.notional_symbol !== 'EURe') return null;
return estimateGrossEdgeValue(row); return estimateGrossEdgeValue(row);
@ -2289,6 +2352,12 @@ function normalizeDecision(decision) {
max_notional_eure: decision.max_notional_eure || null, max_notional_eure: decision.max_notional_eure || null,
strategy_armed: decision.strategy_armed ?? null, strategy_armed: decision.strategy_armed ?? null,
inventory_asset: decision.inventory_asset || 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: decision.notional || null,
notional_asset_id: decision.notional_asset_id || null, notional_asset_id: decision.notional_asset_id || null,
notional_symbol: decision.notional_symbol || null, notional_symbol: decision.notional_symbol || null,

View file

@ -294,7 +294,9 @@ function buildQuote({
const thresholdFactor = 1 - (thresholdPct / 100); const thresholdFactor = 1 - (thresholdPct / 100);
const penaltyFactor = 1 + (thresholdPct / 100); const penaltyFactor = 1 + (thresholdPct / 100);
const spendAsset = demand.asset_out; 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 pendingInbound = bigintAmount(inventory.pending_inbound?.[spendAsset] || '0');
const referenceRates = resolveRouteRates({ const referenceRates = resolveRouteRates({
direction, direction,
@ -338,7 +340,9 @@ function buildQuote({
return finalizeQuote({ return finalizeQuote({
direction, direction,
available, available,
spendable,
pendingInbound, pendingInbound,
pendingOutbound,
spendAsset, spendAsset,
spendRequired, spendRequired,
quoteNotional, quoteNotional,
@ -392,7 +396,9 @@ function buildQuote({
return finalizeQuote({ return finalizeQuote({
direction, direction,
available, available,
spendable,
pendingInbound, pendingInbound,
pendingOutbound,
spendAsset, spendAsset,
spendRequired, spendRequired,
quoteNotional, quoteNotional,
@ -423,7 +429,9 @@ function buildQuote({
function finalizeQuote({ function finalizeQuote({
direction, direction,
available, available,
spendable,
pendingInbound, pendingInbound,
pendingOutbound,
spendAsset, spendAsset,
spendRequired, spendRequired,
quoteNotional, quoteNotional,
@ -455,6 +463,8 @@ function finalizeQuote({
inventory_asset: spendAsset, inventory_asset: spendAsset,
inventory_required: spendRequired.toString(), inventory_required: spendRequired.toString(),
inventory_available: available.toString(), inventory_available: available.toString(),
inventory_spendable: spendable.toString(),
inventory_pending_outbound: pendingOutbound.toString(),
inventory_id: inventoryId, inventory_id: inventoryId,
price_id: priceId, price_id: priceId,
reference_price_id: priceId, reference_price_id: priceId,
@ -497,10 +507,15 @@ function finalizeQuote({
if (available < spendRequired) { if (available < spendRequired) {
return { return {
ok: false, 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: { details: {
...reasonBase, ...reasonBase,
pending_inbound: pendingInbound.toString(), pending_inbound: pendingInbound.toString(),
pending_outbound: pendingOutbound.toString(),
}, },
}; };
} }

View file

@ -101,6 +101,28 @@ function formatTerms(terms) {
return `${input} -> ${output}`; 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) { function responseLabel(item) {
if (RESPONDED_STATES.has(item.lifecycle_state)) return 'Yes'; if (RESPONDED_STATES.has(item.lifecycle_state)) return 'Yes';
if (item.lifecycle_state === 'failed') return 'Attempt failed'; if (item.lifecycle_state === 'failed') return 'Attempt failed';
@ -217,6 +239,8 @@ function TimingWaterfall({ timing }) {
function LifecycleDetails({ item }) { function LifecycleDetails({ item }) {
const executionTiming = formatExecutionTiming(item.execution?.timing); const executionTiming = formatExecutionTiming(item.execution?.timing);
const makerTerms = formatMakerTerms(item.maker_terms);
const inventoryCheck = formatInventoryCheck(item.inventory_check);
return ( return (
<div className="lifecycle-detail-panel"> <div className="lifecycle-detail-panel">
@ -231,11 +255,13 @@ function LifecycleDetails({ item }) {
<div className="status-subtle">{formatGrossEdgePct(item.gross_edge_pct)}</div> <div className="status-subtle">{formatGrossEdgePct(item.gross_edge_pct)}</div>
<div className="status-subtle">{formatConfiguredEdgeBps(item.edge_bps)}</div> <div className="status-subtle">{formatConfiguredEdgeBps(item.edge_bps)}</div>
<div className="status-subtle">{notionalLabel(item)}</div> <div className="status-subtle">{notionalLabel(item)}</div>
{inventoryCheck ? <div className="status-subtle">{inventoryCheck}</div> : null}
</StageCard> </StageCard>
<StageCard at={item.command_at} status={item.command_id ? 'Command recorded' : 'No command'} title="3. Executor command"> <StageCard at={item.command_at} status={item.command_id ? 'Command recorded' : 'No command'} title="3. Executor command">
<IdentifierRow label="Command" value={item.command_id} /> <IdentifierRow label="Command" value={item.command_id} />
<div>{formatTerms(item.submitted_terms)}</div> <div>{makerTerms || formatTerms(item.submitted_terms)}</div>
{makerTerms ? <div className="status-subtle">{formatTerms(item.submitted_terms)}</div> : null}
</StageCard> </StageCard>
<StageCard at={item.execution_result_at} status={item.execution?.status || 'No relay result'} title="4. Relay response"> <StageCard at={item.execution_result_at} status={item.execution?.status || 'No relay result'} title="4. Relay response">

View file

@ -50,6 +50,8 @@ test('strategy page owns consolidated quote lifecycle and successful trade table
assert.match(strategySource, /Timing:/); assert.match(strategySource, /Timing:/);
assert.match(strategySource, /item\.execution\?\.status === 'submitted'/); 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, /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/); assert.doesNotMatch(strategySource, /Actionable|actionable/);
}); });

View file

@ -1717,6 +1717,11 @@ test('bootstrap lifecycle rows preserve quote terms, submitted terms, and gross
decision: 'actionable', decision: 'actionable',
decision_reason: 'actionable', decision_reason: 'actionable',
gross_edge_pct: '1.5', 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', 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.request_terms.asset_in_symbol, 'BTC');
assert.equal(row.submitted_terms.amount_out, '76'); assert.equal(row.submitted_terms.amount_out, '76');
assert.equal(row.submitted_terms.asset_out_symbol, 'EURe'); 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'); assert.equal(row.gross_edge_value_eure, '1.5');
}); });

View file

@ -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); 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', () => { test('strategy rejects dust exact-out BTC -> USDC below configured min notional after integer rounding', () => {
const config = makeBtcUsdcDbConfig({ const config = makeBtcUsdcDbConfig({
strategyConfigOverrides: { strategyConfigOverrides: {
@ -453,7 +528,7 @@ test('disabled maker response age policy preserves current BTC -> USDC actionabi
assert.ok(result.command); assert.ok(result.command);
}); });
function makeBtcUsdcDbConfig({ strategyConfigOverrides = {} } = {}) { function makeBtcUsdcDbConfig({ strategyConfigOverrides = {}, includeReversePair = false } = {}) {
const tradingBtc = { const tradingBtc = {
assetId: 'nep141:nbtc.bridge.near', assetId: 'nep141:nbtc.bridge.near',
symbol: 'BTC', symbol: 'BTC',
@ -467,6 +542,7 @@ function makeBtcUsdcDbConfig({ strategyConfigOverrides = {} } = {}) {
chain: 'gnosis', chain: 'gnosis',
}; };
const activePair = `${tradingBtc.assetId}->${tradingUsdc.assetId}`; const activePair = `${tradingBtc.assetId}->${tradingUsdc.assetId}`;
const reversePair = `${tradingUsdc.assetId}->${tradingBtc.assetId}`;
const strategyConfig = { const strategyConfig = {
configId: `${activePair}:v1`, configId: `${activePair}:v1`,
version: 1, version: 1,
@ -502,6 +578,22 @@ function makeBtcUsdcDbConfig({ strategyConfigOverrides = {} } = {}) {
strategyConfig, strategyConfig,
priceRoute, 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 { return {
tradingBtc, tradingBtc,
@ -514,7 +606,7 @@ function makeBtcUsdcDbConfig({ strategyConfigOverrides = {} } = {}) {
[tradingBtc.assetId, tradingBtc], [tradingBtc.assetId, tradingBtc],
[tradingUsdc.assetId, tradingUsdc], [tradingUsdc.assetId, tradingUsdc],
]), ]),
pairByKey: new Map([[activePair, pair]]), pairByKey: new Map(pairEntries),
strategyGrossThresholdPct: 0.49, strategyGrossThresholdPct: 0.49,
strategyMaxNotionalEure: 150, strategyMaxNotionalEure: 150,
strategyPriceMaxAgeMs: 30_000, strategyPriceMaxAgeMs: 30_000,
@ -555,6 +647,10 @@ function makeBtcUsdcInventoryEvent(overrides = {}) {
'nep141:nbtc.bridge.near': '0', 'nep141:nbtc.bridge.near': '0',
'nep141:gnosis-0x2a22f9c3b484c3629090feed35f17ff8f88f76f0.omft.near': '0', 'nep141:gnosis-0x2a22f9c3b484c3629090feed35f17ff8f88f76f0.omft.near': '0',
}, },
pending_outbound: {
'nep141:nbtc.bridge.near': '0',
'nep141:gnosis-0x2a22f9c3b484c3629090feed35f17ff8f88f76f0.omft.near': '0',
},
...overrides, ...overrides,
}, },
}; };