Fix maker-side strategy edge semantics
All checks were successful
deploy / deploy (push) Successful in 54s
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:
parent
38f58139e1
commit
e5c0a5dc6e
8 changed files with 288 additions and 39 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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/);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue