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 (