Clarify maker inventory direction
All checks were successful
deploy / deploy (push) Successful in 1m3s
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:
parent
7b2f31fd4d
commit
7c006ac6a2
6 changed files with 226 additions and 5 deletions
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue