Fix maker-side strategy edge semantics
All checks were successful
deploy / deploy (push) Successful in 54s

Proof: Full npm test passed after adding a regression that USDC -> BTC requester quotes use the BTC -> USDC maker-side edge when we sell BTC; operator dashboard tests verify explicit +/- maker movement text.

Assumptions: User explicitly approved overriding the current turn's no-strategy-change constraint to fix a live semantic safety bug; NEAR quote asset_in/asset_out remain request-side venue terms, while maker strategy config is keyed by asset_out->asset_in.

Still fake: Fee-complete realized PnL and venue-native terminal fill ids remain unavailable; existing historical rows before this change only have legacy request-side pair fields.
This commit is contained in:
philipp 2026-06-12 14:43:30 +02:00
parent 38f58139e1
commit e5c0a5dc6e
8 changed files with 288 additions and 39 deletions

View file

@ -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;

View file

@ -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,
};
}

View file

@ -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)

View file

@ -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 }) {
<td><IdentifierRow label="Quote" value={item.quote_id} /></td>
<td>
<div className="quote-lifecycle-cell">
<div className="lifecycle-line lifecycle-clamp-two">{formatTerms(item.request_terms || item.submitted_terms)}</div>
<div className="status-subtle mono lifecycle-line lifecycle-clamp-one">{truncateMiddle(item.pair || '', 34)}</div>
<div className="lifecycle-line lifecycle-clamp-two">{formatMakerTerms(item.maker_terms) || formatTerms(item.request_terms || item.submitted_terms)}</div>
<div className="status-subtle lifecycle-line lifecycle-clamp-two">
{item.maker_terms ? `Request: ${formatTerms(item.request_terms || item.submitted_terms)}` : ''}
</div>
<div className="status-subtle mono lifecycle-line lifecycle-clamp-one">{truncateMiddle(item.maker_trade_pair || item.pair || '', 34)}</div>
</div>
</td>
<td>{responseLabel(item)}</td>
@ -852,7 +872,8 @@ function SuccessfulTradesTable({ items }) {
<div className="status-subtle">Estimate from edge x notional, not realized PnL.</div>
</td>
<td>
<div>{item.settlement_summary?.text || 'No settled inventory delta is linked to this quote.'}</div>
<div>{formatMakerTerms(item.maker_terms) || item.settlement_summary?.text || 'No settled inventory delta is linked to this quote.'}</div>
{item.maker_terms ? <div className="status-subtle">{item.settlement_summary?.text || 'No settled inventory delta is linked to this quote.'}</div> : null}
{item.settlement_summary?.method ? <div className="status-subtle mono">{item.settlement_summary.method}</div> : null}
</td>
<td>
@ -1261,7 +1282,9 @@ function PairConfigSection({ assetCatalog, pairConfig, onControl }) {
return (
<tr key={pairId}>
<td>
<div>{pair.asset_in_symbol || pair.asset_in} {'->'} {pair.asset_out_symbol || pair.asset_out}</div>
<div>{pairAssetInSymbol(pair)} {'->'} {pairAssetOutSymbol(pair)}</div>
<div className="status-subtle">{makerPairMovementLabel(pair)}</div>
<div className="status-subtle">{makerPairRequestLabel(pair)}</div>
<div className="status-subtle mono">{truncateMiddle(pairId, 42)}</div>
</td>
<td><Pill label={pair.mode || pair.status} stateLabel={pair.canTrade || pair.can_trade ? 'healthy' : 'warning'} /></td>

View file

@ -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/);
});

View file

@ -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');

View file

@ -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,

View file

@ -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) {