From 0f33a53fa9ce00969c4090d51fbfaa111b62bb42 Mon Sep 17 00:00:00 2001 From: philipp Date: Wed, 13 May 2026 15:18:38 +0200 Subject: [PATCH] Add BTC USDC price route Proof: npm test passed 184/184; npm run operator-dashboard:build; focused route tests cover BTC/USDC route seeding, inventory tracking, Kraken/CoinGecko parsing, route-specific price selection, fresh BTC/USDC strategy approval, stale route blocking, and missing USDC reference fields. Assumptions: BTC/USDC uses Kraken XBTUSDC as primary reference and CoinGecko bitcoin/USD as fallback with USDC ~= USD; operator-enabled maker pairs may mark their assets inventory-tracked, but imported assets remain non-trading by default. Still fake: BTC/USDC taker request creation is not generalized; USDC/USD parity is an explicit assumption; solver liquidity and fee-complete PnL are not proven by this route. --- src/apps/market-reference-ingest.mjs | 201 ++++++++++++++++++++--- src/apps/strategy-engine.mjs | 15 +- src/core/strategy.mjs | 91 ++++++---- src/core/trading-config.mjs | 37 +++++ src/lib/market-data.mjs | 22 ++- src/lib/postgres.mjs | 57 +++++++ test/market-data.test.mjs | 37 +++++ test/price-route-runtime-static.test.mjs | 21 +++ test/strategy.test.mjs | 182 ++++++++++++++++++++ test/trading-config.test.mjs | 42 +++++ 10 files changed, 653 insertions(+), 52 deletions(-) create mode 100644 test/market-data.test.mjs create mode 100644 test/price-route-runtime-static.test.mjs diff --git a/src/apps/market-reference-ingest.mjs b/src/apps/market-reference-ingest.mjs index 24d9d71..ecdd07b 100644 --- a/src/apps/market-reference-ingest.mjs +++ b/src/apps/market-reference-ingest.mjs @@ -5,7 +5,12 @@ import { startControlApi } from '../core/control-api.mjs'; import { buildEventEnvelope } from '../core/event-envelope.mjs'; import { createLogger, serializeError } from '../core/log.mjs'; import { assertMarketPriceEvent } from '../core/schemas.mjs'; -import { fetchCoinGeckoBtcEur, fetchKrakenBtcEur } from '../lib/market-data.mjs'; +import { + fetchCoinGeckoBtcEur, + fetchCoinGeckoBtcUsd, + fetchKrakenBtcEur, + fetchKrakenBtcUsdc, +} from '../lib/market-data.mjs'; import { loadConfig } from '../lib/config.mjs'; import { createPostgresPool, @@ -22,6 +27,10 @@ const logger = createLogger({ venue: 'reference-market', }); +const KRAKEN_BTC_USDC_TICKER_URL = 'https://api.kraken.com/0/public/Ticker?pair=XBTUSDC'; +const COINGECKO_BTC_USD_URL = + 'https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies=usd&include_last_updated_at=true'; + const producer = await createProducer({ brokers: config.kafkaBrokers, clientId: config.kafkaClientId, @@ -43,6 +52,8 @@ const state = { refreshing: false, kraken: null, coingecko: null, + kraken_btc_usdc: null, + coingecko_btc_usd: null, last_published_at: null, last_publish_error: null, publish_count: 0, @@ -57,7 +68,7 @@ async function refresh() { try { const now = Date.now(); - await refreshKraken(now).catch((error) => { + await refreshKrakenBtcEur(now).catch((error) => { logger.warn('kraken_refresh_failed', { pair: tradingConfigStore.getState().active_pair, details: { @@ -65,19 +76,29 @@ async function refresh() { }, }); }); + await refreshKrakenBtcUsdc(now).catch((error) => { + logger.warn('kraken_btc_usdc_refresh_failed', { + details: { + error: serializeError(error), + }, + }); + }); if (now >= coingeckoDueAt || !state.coingecko) { - await refreshCoinGecko(now); + await refreshCoinGeckoBtcEur(now); + await refreshCoinGeckoBtcUsd(now); coingeckoDueAt = now + config.marketReferenceCoinGeckoRefreshMs; } const tradingConfig = await tradingConfigStore.getConfig(); if (!tradingConfig.ok) throw new Error(`trading config unavailable: ${tradingConfig.blockReason}`); - const event = buildPriceEvent(now, { tradingConfig }); - assertMarketPriceEvent(event); - await producer.sendJson(config.kafkaTopicRefMarketPrice, event, { key: event.payload.price_id }); + const events = buildPriceEvents(now, { tradingConfig }); + for (const event of events) { + assertMarketPriceEvent(event); + await producer.sendJson(config.kafkaTopicRefMarketPrice, event, { key: event.payload.price_id }); + } state.last_published_at = new Date(now).toISOString(); state.last_publish_error = null; - state.publish_count += 1; + state.publish_count += events.length; } catch (error) { state.error_count += 1; state.last_publish_error = serializeError(error); @@ -93,7 +114,7 @@ async function refresh() { } } -async function refreshKraken(now) { +async function refreshKrakenBtcEur(now) { try { const price = await fetchKrakenBtcEur(config.marketReferenceKrakenTickerUrl); state.kraken = { @@ -112,7 +133,26 @@ async function refreshKraken(now) { } } -async function refreshCoinGecko(now) { +async function refreshKrakenBtcUsdc(now) { + try { + const price = await fetchKrakenBtcUsdc(KRAKEN_BTC_USDC_TICKER_URL); + state.kraken_btc_usdc = { + price, + observed_at: new Date(now).toISOString(), + healthy: true, + error: null, + }; + } catch (error) { + state.kraken_btc_usdc = { + ...(state.kraken_btc_usdc || {}), + healthy: false, + error: serializeError(error), + }; + throw error; + } +} + +async function refreshCoinGeckoBtcEur(now) { try { const price = await fetchCoinGeckoBtcEur(config.marketReferenceCoinGeckoUrl); state.coingecko = { @@ -136,15 +176,34 @@ async function refreshCoinGecko(now) { } } -function buildPriceEvent(now, { tradingConfig }) { - const sourceUsed = chooseSource(now); - if (!sourceUsed) throw new Error('No fresh reference price available'); - const referencePair = tradingConfig.pairs.find((pair) => ( - pair.priceRoute?.source === 'btc_eur_reference' && pair.canTrade - )); - if (!referencePair) throw new Error('No DB-enabled BTC/EUR price route available'); +async function refreshCoinGeckoBtcUsd(now) { + try { + const price = await fetchCoinGeckoBtcUsd(COINGECKO_BTC_USD_URL); + state.coingecko_btc_usd = { + price, + observed_at: new Date(now).toISOString(), + healthy: true, + error: null, + }; + } catch (error) { + state.coingecko_btc_usd = { + ...(state.coingecko_btc_usd || {}), + healthy: false, + error: serializeError(error), + }; + logger.warn('coingecko_btc_usd_refresh_failed', { + details: { + error: serializeError(error), + }, + }); + } +} - const eurPerBtc = sourceUsed === 'kraken' +function buildPriceEvents(now, { tradingConfig }) { + const eurSourceUsed = chooseBtcEurSource(now); + if (!eurSourceUsed) throw new Error('No fresh BTC/EUR reference price available'); + + const eurPerBtc = eurSourceUsed === 'kraken' ? state.kraken.price : state.coingecko.price; const btcPerEur = 1 / eurPerBtc; @@ -152,18 +211,67 @@ function buildPriceEvent(now, { tradingConfig }) { ? Math.abs((state.kraken.price - state.coingecko.price) / state.kraken.price) * 100 : null; + const events = []; + for (const referencePair of tradingConfig.pairs) { + if (!referencePair.priceRoute || !referencePair.canTrade) continue; + if (referencePair.priceRoute.source === 'btc_eur_reference') { + events.push(buildBtcEurPriceEvent(now, { + referencePair, + eurPerBtc, + btcPerEur, + sourceUsed: eurSourceUsed, + divergencePct, + })); + continue; + } + + if (referencePair.priceRoute.source === 'btc_usdc_reference') { + const usdcSourceUsed = chooseBtcUsdcSource(now); + if (!usdcSourceUsed) continue; + const usdcPerBtc = usdcSourceUsed === 'kraken' + ? state.kraken_btc_usdc.price + : state.coingecko_btc_usd.price; + const btcPerUsdc = 1 / usdcPerBtc; + const usdcDivergencePct = state.kraken_btc_usdc && state.coingecko_btc_usd + ? Math.abs((state.kraken_btc_usdc.price - state.coingecko_btc_usd.price) / state.kraken_btc_usdc.price) * 100 + : null; + events.push(buildBtcUsdcPriceEvent(now, { + referencePair, + eurPerBtc, + btcPerEur, + eurSourceUsed, + usdcPerBtc, + btcPerUsdc, + sourceUsed: usdcSourceUsed, + divergencePct: usdcDivergencePct, + })); + } + } + + if (!events.length) throw new Error('No DB-enabled supported price route available'); + return events; +} + +function buildBtcEurPriceEvent(now, { + referencePair, + eurPerBtc, + btcPerEur, + sourceUsed, + divergencePct, +}) { return buildEventEnvelope({ source: 'market-reference-ingest', venue: 'reference-market', eventType: 'market_price', observedAt: new Date(now).toISOString(), payload: { - price_id: `price-${now}`, + price_id: buildPriceId(now, referencePair.priceRoute.routeId), pair: referencePair.key, pair_id: referencePair.pairId, price_route_id: referencePair.priceRoute.routeId, base_asset_id: referencePair.priceRoute.baseAssetId, quote_asset_id: referencePair.priceRoute.quoteAssetId, + reference_pair: 'BTC/EUR', eur_per_btc: eurPerBtc.toFixed(8), eure_per_btc: eurPerBtc.toFixed(8), btc_per_eur: btcPerEur.toFixed(12), @@ -178,12 +286,67 @@ function buildPriceEvent(now, { tradingConfig }) { }); } -function chooseSource(now) { +function buildBtcUsdcPriceEvent(now, { + referencePair, + eurPerBtc, + btcPerEur, + eurSourceUsed, + usdcPerBtc, + btcPerUsdc, + sourceUsed, + divergencePct, +}) { + return buildEventEnvelope({ + source: 'market-reference-ingest', + venue: 'reference-market', + eventType: 'market_price', + observedAt: new Date(now).toISOString(), + payload: { + price_id: buildPriceId(now, referencePair.priceRoute.routeId), + pair: referencePair.key, + pair_id: referencePair.pairId, + price_route_id: referencePair.priceRoute.routeId, + base_asset_id: referencePair.priceRoute.baseAssetId, + quote_asset_id: referencePair.priceRoute.quoteAssetId, + reference_pair: 'BTC/USDC', + eur_per_btc: eurPerBtc.toFixed(8), + eure_per_btc: eurPerBtc.toFixed(8), + btc_per_eur: btcPerEur.toFixed(12), + btc_per_eure: btcPerEur.toFixed(12), + usd_per_btc: usdcPerBtc.toFixed(8), + usdc_per_btc: usdcPerBtc.toFixed(8), + btc_per_usd: btcPerUsdc.toFixed(12), + btc_per_usdc: btcPerUsdc.toFixed(12), + source_used: sourceUsed, + fallback_in_use: sourceUsed !== 'kraken', + divergence_pct: divergencePct?.toFixed(6) ?? null, + eure_per_eur_assumption: '1', + usdc_per_usd_assumption: '1', + eur_source_used: eurSourceUsed, + kraken: compactMarketSource(state.kraken, now), + coingecko: compactMarketSource(state.coingecko, now), + kraken_btc_usdc: compactMarketSource(state.kraken_btc_usdc, now), + coingecko_btc_usd: compactMarketSource(state.coingecko_btc_usd, now), + }, + }); +} + +function chooseBtcEurSource(now) { if (isFresh(state.kraken, now, config.marketReferenceMaxAgeMs)) return 'kraken'; if (isFresh(state.coingecko, now, config.marketReferenceMaxAgeMs)) return 'coingecko'; return null; } +function chooseBtcUsdcSource(now) { + if (isFresh(state.kraken_btc_usdc, now, config.marketReferenceMaxAgeMs)) return 'kraken'; + if (isFresh(state.coingecko_btc_usd, now, config.marketReferenceMaxAgeMs)) return 'coingecko'; + return null; +} + +function buildPriceId(now, routeId) { + return `price-${now}-${String(routeId).replace(/[^a-zA-Z0-9]+/g, '-').replace(/^-|-$/g, '')}`; +} + function compactMarketSource(source, now) { if (!source) return null; const ageMs = source.observed_at ? now - Date.parse(source.observed_at) : null; diff --git a/src/apps/strategy-engine.mjs b/src/apps/strategy-engine.mjs index 0884ebc..695903c 100644 --- a/src/apps/strategy-engine.mjs +++ b/src/apps/strategy-engine.mjs @@ -60,6 +60,8 @@ const state = { armed: armedStateStore.isArmed(), paused: false, latest_price_event: null, + latest_price_events_by_route: {}, + latest_price_events_by_pair: {}, latest_inventory_event: null, latest_decision: null, recent_decisions: [], @@ -77,6 +79,9 @@ await consumer.run({ if (topic === config.kafkaTopicRefMarketPrice) { assertMarketPriceEvent(event); state.latest_price_event = event; + const routeId = event.payload.price_route_id || null; + if (routeId) state.latest_price_events_by_route[routeId] = event; + if (event.payload.pair) state.latest_price_events_by_pair[event.payload.pair] = event; return; } @@ -130,7 +135,7 @@ async function handleDemand(event) { const evaluation = evaluateTradeOpportunity({ demandEvent: event, - priceEvent: state.latest_price_event, + priceEvent: resolvePriceEventForDemand(event, tradingConfig), inventoryEvent: state.latest_inventory_event, config: { ...config, @@ -153,6 +158,14 @@ 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 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; +} + async function publishDecision(decisionPayload) { const decisionAt = decisionPayload.decision_at || new Date().toISOString(); const normalizedDecisionPayload = { diff --git a/src/core/strategy.mjs b/src/core/strategy.mjs index 470a934..ad2c605 100644 --- a/src/core/strategy.mjs +++ b/src/core/strategy.mjs @@ -48,10 +48,11 @@ export function evaluateTradeOpportunity({ threshold_pct: String(effectiveThresholdPct), max_notional_eure: String(effectiveMaxNotionalEure), strategy_armed: armed, - assumptions: { - eure_per_eur: '1', + assumptions: compact({ + eure_per_eur: pairRuntime.priceRoute?.source === 'btc_eur_reference' ? '1' : null, + usdc_per_usd: pairRuntime.priceRoute?.source === 'btc_usdc_reference' ? '1' : null, price_route_source: pairRuntime.priceRoute?.source || null, - }, + }), }; if (!pairRuntime.ok) { @@ -139,6 +140,9 @@ export function evaluateTradeOpportunity({ max_notional_eure: decision.max_notional_eure, price_route_id: decision.price_route_id, reference_price_id: buildResult.details.price_id || null, + notional: decision.notional, + notional_asset_id: decision.notional_asset_id, + notional_symbol: decision.notional_symbol, asset_in: payload.asset_in, asset_out: payload.asset_out, asset_in_decimals: pairRuntime.assetIn?.decimals ?? null, @@ -186,6 +190,11 @@ function buildQuote({ const spendAsset = demand.asset_out; const available = bigintAmount(inventory.spendable?.[spendAsset] || '0'); const pendingInbound = bigintAmount(inventory.pending_inbound?.[spendAsset] || '0'); + const referenceRates = resolveReferenceRates({ direction, price }); + if (!referenceRates.ok) return { ok: false, reason: referenceRates.reason, details: {} }; + const { quotePerBase, basePerQuote, baseToQuote } = referenceRates; + const notionalAssetId = pairRuntime?.priceRoute?.quoteAssetId || assetOut.assetId; + const notionalAsset = assetRegistry.get(notionalAssetId) || null; if (demand.request_kind === 'exact_in') { const amountIn = bigintAmount(demand.amount_in); @@ -197,9 +206,9 @@ function buildQuote({ demand.amount_in, assetIn.decimals, ); - const fairOutput = direction === 'btc_to_eure' - ? inputNumber * Number(price.eure_per_btc) - : inputNumber * Number(price.btc_per_eure); + const fairOutput = baseToQuote + ? inputNumber * quotePerBase + : inputNumber * basePerQuote; const proposedOutput = fairOutput * thresholdFactor; const proposedOutputUnits = numberToUnits( proposedOutput, @@ -207,16 +216,12 @@ function buildQuote({ { mode: 'floor' }, ); const spendRequired = bigintAmount(proposedOutputUnits); - const eureNotional = direction === 'btc_to_eure' - ? fairOutput - : inputNumber; + const quoteNotional = baseToQuote ? fairOutput : inputNumber; const impliedRate = unitsToNumber( proposedOutputUnits, assetOut.decimals, ) / inputNumber; - const referenceRate = direction === 'btc_to_eure' - ? Number(price.eure_per_btc) - : Number(price.btc_per_eure); + const referenceRate = baseToQuote ? quotePerBase : basePerQuote; return finalizeQuote({ direction, @@ -224,7 +229,9 @@ function buildQuote({ pendingInbound, spendAsset, spendRequired, - eureNotional, + quoteNotional, + notionalAssetId, + notionalSymbol: notionalAsset?.symbol || null, maxNotionalEure, proposedAmountOut: proposedOutputUnits, impliedRate, @@ -246,9 +253,9 @@ function buildQuote({ demand.amount_out, assetOut.decimals, ); - const fairInput = direction === 'btc_to_eure' - ? outputNumber * Number(price.btc_per_eure) - : outputNumber * Number(price.eure_per_btc); + const fairInput = baseToQuote + ? outputNumber * basePerQuote + : outputNumber * quotePerBase; const proposedInput = fairInput * penaltyFactor; const proposedInputUnits = numberToUnits( proposedInput, @@ -256,16 +263,12 @@ function buildQuote({ { mode: 'ceil' }, ); const spendRequired = amountOut; - const eureNotional = direction === 'btc_to_eure' - ? outputNumber - : fairInput; + const quoteNotional = baseToQuote ? outputNumber : fairInput; const impliedRate = outputNumber / unitsToNumber( proposedInputUnits, assetIn.decimals, ); - const referenceRate = direction === 'btc_to_eure' - ? Number(price.eure_per_btc) - : Number(price.btc_per_eure); + const referenceRate = baseToQuote ? quotePerBase : basePerQuote; return finalizeQuote({ direction, @@ -273,7 +276,9 @@ function buildQuote({ pendingInbound, spendAsset, spendRequired, - eureNotional, + quoteNotional, + notionalAssetId, + notionalSymbol: notionalAsset?.symbol || null, maxNotionalEure, proposedAmountIn: proposedInputUnits, proposedAmountOut: demand.amount_out, @@ -295,7 +300,9 @@ function finalizeQuote({ pendingInbound, spendAsset, spendRequired, - eureNotional, + quoteNotional, + notionalAssetId = null, + notionalSymbol = null, maxNotionalEure, proposedAmountIn = null, proposedAmountOut = null, @@ -320,7 +327,10 @@ function finalizeQuote({ reference_price_id: priceId, asset_in_decimals: assetInDecimals == null ? null : String(assetInDecimals), asset_out_decimals: assetOutDecimals == null ? null : String(assetOutDecimals), - eure_notional: formatNumber(eureNotional, 6), + notional: formatNumber(quoteNotional, 6), + notional_asset_id: notionalAssetId, + notional_symbol: notionalSymbol, + eure_notional: formatNumber(quoteNotional, 6), proposed_amount_in: proposedAmountIn, proposed_amount_out: proposedAmountOut, }; @@ -329,7 +339,7 @@ function finalizeQuote({ return { ok: false, reason: 'invalid_pricing', details: reasonBase }; } - if (eureNotional > maxNotionalEure) { + if (quoteNotional > maxNotionalEure) { return { ok: false, reason: 'max_notional_exceeded', details: reasonBase }; } @@ -494,12 +504,35 @@ function blockedPairRuntime(pair, config, reason) { function classifyPriceRouteDirection({ payload, priceRoute }) { if (!priceRoute) return 'unsupported'; - if (priceRoute.source !== 'btc_eur_reference') return 'unsupported'; + if (!['btc_eur_reference', 'btc_usdc_reference'].includes(priceRoute.source)) return 'unsupported'; if (payload.asset_in === priceRoute.baseAssetId && payload.asset_out === priceRoute.quoteAssetId) { - return 'btc_to_eure'; + return priceRoute.source === 'btc_usdc_reference' ? 'btc_to_usdc' : 'btc_to_eure'; } if (payload.asset_in === priceRoute.quoteAssetId && payload.asset_out === priceRoute.baseAssetId) { - return 'eure_to_btc'; + return priceRoute.source === 'btc_usdc_reference' ? 'usdc_to_btc' : 'eure_to_btc'; } return 'unsupported'; } + +function resolveReferenceRates({ direction, price }) { + const rateFieldsByDirection = { + btc_to_eure: ['eure_per_btc', 'btc_per_eure', true], + eure_to_btc: ['eure_per_btc', 'btc_per_eure', false], + btc_to_usdc: ['usdc_per_btc', 'btc_per_usdc', true], + usdc_to_btc: ['usdc_per_btc', 'btc_per_usdc', false], + }; + const fields = rateFieldsByDirection[direction]; + if (!fields) return { ok: false, reason: 'unsupported_pair' }; + const [quotePerBaseField, basePerQuoteField, baseToQuote] = fields; + const quotePerBase = Number(price[quotePerBaseField]); + const basePerQuote = Number(price[basePerQuoteField]); + if (!(quotePerBase > 0) || !(basePerQuote > 0)) { + return { ok: false, reason: 'reference_price_missing' }; + } + return { + ok: true, + quotePerBase, + basePerQuote, + baseToQuote, + }; +} diff --git a/src/core/trading-config.mjs b/src/core/trading-config.mjs index be91d33..709041b 100644 --- a/src/core/trading-config.mjs +++ b/src/core/trading-config.mjs @@ -9,6 +9,8 @@ export const CURRENT_NBTC_ASSET_ID = 'nep141:nbtc.bridge.near'; export const LEGACY_OMFT_BTC_ASSET_ID = 'nep141:btc.omft.near'; export const CURRENT_EURE_ASSET_ID = 'nep141:gnosis-0x420ca0f9b9b604ce0fd9c18ef134c705e5fa3430.omft.near'; +export const CURRENT_USDC_ASSET_ID = + 'nep141:gnosis-0x2a22f9c3b484c3629090feed35f17ff8f88f76f0.omft.near'; export const CURRENT_PAIR_KEY = pairKey(CURRENT_NBTC_ASSET_ID, CURRENT_EURE_ASSET_ID); export const CURRENT_REVERSE_PAIR_KEY = pairKey(CURRENT_EURE_ASSET_ID, CURRENT_NBTC_ASSET_ID); @@ -121,6 +123,22 @@ export function buildSeedAssets() { withdrawAddress: '0x6C40267e03A97B2132e7a7d3159C88534eBEfdFb', rawPayload: { source: 'repo_seed', assetId: CURRENT_EURE_ASSET_ID }, }, + { + assetId: CURRENT_USDC_ASSET_ID, + venue: NEAR_INTENTS_VENUE, + symbol: 'USDC', + label: 'USDC', + decimals: 6, + blockchain: 'gnosis', + chain: 'gnosis', + contractAddress: '0x2a22f9c3b484c3629090feed35f17ff8f88f76f0', + latestPrice: null, + priceUpdatedAt: null, + supported: true, + enabledForInventory: false, + role: null, + rawPayload: { source: 'repo_seed', assetId: CURRENT_USDC_ASSET_ID }, + }, ]; } @@ -189,6 +207,25 @@ export function buildSeedPriceRoute(pairId) { }; } +export function buildBtcUsdcPriceRoute(pairId) { + return { + routeId: `${pairId}:btc-usdc-reference`, + pairId, + source: 'btc_usdc_reference', + baseAssetId: CURRENT_NBTC_ASSET_ID, + quoteAssetId: CURRENT_USDC_ASSET_ID, + routeConfig: { + reference_pair: 'BTC/USDC', + kraken_pair: 'XBTUSDC', + coingecko_id: 'bitcoin', + coingecko_vs_currency: 'usd', + usdc_per_usd_assumption: '1', + }, + maxAgeMs: CURRENT_PRICE_MAX_AGE_MS, + enabled: true, + }; +} + export function pairCanObserve(pair) { return Boolean(pair?.enabled) && PAIR_MODES.has(pair.mode) && pair.status !== 'disabled'; } diff --git a/src/lib/market-data.mjs b/src/lib/market-data.mjs index d15bba4..c0d2dd7 100644 --- a/src/lib/market-data.mjs +++ b/src/lib/market-data.mjs @@ -1,6 +1,6 @@ import { fetchJson } from './http.mjs'; -export async function fetchKrakenBtcEur(url) { +export async function fetchKrakenTickerPrice(url) { const response = await fetchJson(url); const pair = Object.values(response.result || {})[0]; const lastTrade = pair?.c?.[0] || pair?.a?.[0]; @@ -8,13 +8,29 @@ export async function fetchKrakenBtcEur(url) { return Number(lastTrade); } -export async function fetchCoinGeckoBtcEur(url) { +export async function fetchKrakenBtcEur(url) { + return fetchKrakenTickerPrice(url); +} + +export async function fetchKrakenBtcUsdc(url) { + return fetchKrakenTickerPrice(url); +} + +export async function fetchCoinGeckoBtcCurrency(url, currency) { const response = await fetchJson(url, { headers: { accept: 'application/json', }, }); - const price = response?.bitcoin?.eur; + const price = response?.bitcoin?.[currency]; if (!Number.isFinite(price)) throw new Error('CoinGecko price missing'); return Number(price); } + +export async function fetchCoinGeckoBtcEur(url) { + return fetchCoinGeckoBtcCurrency(url, 'eur'); +} + +export async function fetchCoinGeckoBtcUsd(url) { + return fetchCoinGeckoBtcCurrency(url, 'usd'); +} diff --git a/src/lib/postgres.mjs b/src/lib/postgres.mjs index 3967df9..8a373f9 100644 --- a/src/lib/postgres.mjs +++ b/src/lib/postgres.mjs @@ -3,7 +3,10 @@ import { Pool } from 'pg'; import { deriveIntentRequestOutcomeRecords } from '../core/intent-request-outcomes.mjs'; import { deriveQuoteOutcomeRecords } from '../core/quote-outcomes.mjs'; import { + CURRENT_NBTC_ASSET_ID, + CURRENT_USDC_ASSET_ID, ONE_CLICK_TOKENS_URL, + buildBtcUsdcPriceRoute, buildSeedAssets, buildSeedPairs, buildSeedPriceRoute, @@ -439,6 +442,8 @@ export async function seedTradingConfig(pool, { }); } + await seedKnownEnabledPairRuntimeConfig(pool, { now }); + return loadTradingConfig(pool); } @@ -907,6 +912,11 @@ export async function setTradingPairMode(pool, { let strategyConfig = null; if (pairCanMake(nextPair) || pairCanTake(nextPair)) { + await enableInventoryForAssets(client, { + assetIds: [resolvedAssetIn, resolvedAssetOut], + now: new Date().toISOString(), + }); + const activeConfigResult = await client.query( ` SELECT * @@ -965,6 +975,14 @@ export async function setTradingPairMode(pool, { reason, }); } + + const knownRoute = buildKnownPriceRouteForPair(nextPair); + if (knownRoute) { + await upsertSeedPriceRoute(client, { + route: knownRoute, + now: new Date().toISOString(), + }); + } } await insertConfigAuditLog(client, { @@ -1479,6 +1497,45 @@ function publicAssetImportRunSummary(run) { return publicRun; } +async function seedKnownEnabledPairRuntimeConfig(pool, { now }) { + const pairResult = await pool.query(`SELECT * FROM ${TRADING_PAIRS_TABLE}`); + for (const row of pairResult.rows) { + const pair = normalizeTradingPairRow(row); + if (!pairCanMake(pair) && !pairCanTake(pair)) continue; + + await enableInventoryForAssets(pool, { + assetIds: [pair.assetIn, pair.assetOut], + now, + }); + + const route = buildKnownPriceRouteForPair(pair); + if (route) await upsertSeedPriceRoute(pool, { route, now }); + } +} + +function buildKnownPriceRouteForPair(pair) { + const assets = new Set([pair?.assetIn, pair?.assetOut]); + if (assets.has(CURRENT_NBTC_ASSET_ID) && assets.has(CURRENT_USDC_ASSET_ID)) { + return buildBtcUsdcPriceRoute(pair.pairId); + } + return null; +} + +async function enableInventoryForAssets(pool, { assetIds, now }) { + const uniqueAssetIds = [...new Set(assetIds.filter(Boolean))]; + if (!uniqueAssetIds.length) return; + + await pool.query( + ` + UPDATE ${TRADING_ASSETS_TABLE} + SET enabled_for_inventory = true, + updated_at = $2 + WHERE asset_id = ANY($1::text[]) + `, + [uniqueAssetIds, now], + ); +} + async function upsertSeedAsset(pool, { asset, now }) { await pool.query( ` diff --git a/test/market-data.test.mjs b/test/market-data.test.mjs new file mode 100644 index 0000000..793ca4c --- /dev/null +++ b/test/market-data.test.mjs @@ -0,0 +1,37 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { + fetchCoinGeckoBtcUsd, + fetchKrakenBtcUsdc, +} from '../src/lib/market-data.mjs'; + +test('market data helpers parse Kraken BTC/USDC and CoinGecko BTC/USD prices', async () => { + const previousFetch = globalThis.fetch; + try { + globalThis.fetch = async (url) => ({ + ok: true, + async text() { + if (String(url).includes('kraken')) { + return JSON.stringify({ + result: { + XBTUSDC: { + c: ['80266.54000', '0.00019173'], + }, + }, + }); + } + return JSON.stringify({ + bitcoin: { + usd: 80250.12, + }, + }); + }, + }); + + assert.equal(await fetchKrakenBtcUsdc('https://kraken.test'), 80266.54); + assert.equal(await fetchCoinGeckoBtcUsd('https://coingecko.test'), 80250.12); + } finally { + globalThis.fetch = previousFetch; + } +}); diff --git a/test/price-route-runtime-static.test.mjs b/test/price-route-runtime-static.test.mjs new file mode 100644 index 0000000..54447de --- /dev/null +++ b/test/price-route-runtime-static.test.mjs @@ -0,0 +1,21 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; + +const marketReferenceSource = readFileSync(new URL('../src/apps/market-reference-ingest.mjs', import.meta.url), 'utf8'); +const strategyEngineSource = readFileSync(new URL('../src/apps/strategy-engine.mjs', import.meta.url), 'utf8'); + +test('market reference ingest publishes BTC/USDC route-specific prices', () => { + assert.match(marketReferenceSource, /XBTUSDC/); + assert.match(marketReferenceSource, /btc_usdc_reference/); + assert.match(marketReferenceSource, /usdc_per_btc/); + assert.match(marketReferenceSource, /buildPriceId\(now, referencePair\.priceRoute\.routeId\)/); +}); + +test('strategy engine stores and consumes latest prices by price route', () => { + assert.match(strategyEngineSource, /latest_price_events_by_route/); + assert.match(strategyEngineSource, /state\.latest_price_events_by_route\[routeId\] = event/); + assert.match(strategyEngineSource, /resolvePriceEventForDemand\(event, tradingConfig\)/); + assert.match(strategyEngineSource, /pair\?\.priceRoute\?\.routeId/); + assert.match(strategyEngineSource, /return state\.latest_price_events_by_route\[routeId\] \|\| null/); +}); diff --git a/test/strategy.test.mjs b/test/strategy.test.mjs index 8e02e4d..1e77532 100644 --- a/test/strategy.test.mjs +++ b/test/strategy.test.mjs @@ -151,3 +151,185 @@ test('strategy blocks stale prices before command emission', () => { assert.equal(result.decision.decision_reason, 'stale_reference_price'); assert.equal(result.command, undefined); }); + +test('strategy emits actionable exact-in BTC -> USDC command from DB price route', () => { + const config = makeBtcUsdcDbConfig(); + const result = evaluateTradeOpportunity({ + demandEvent: { + payload: { + quote_id: 'quote-usdc-1', + pair: config.activePair, + asset_in: config.tradingBtc.assetId, + asset_out: config.tradingUsdc.assetId, + request_kind: 'exact_in', + amount_in: '10000', + min_deadline_ms: '60000', + }, + }, + priceEvent: makeBtcUsdcPriceEvent(), + inventoryEvent: makeBtcUsdcInventoryEvent(), + config, + armed: true, + now: Date.parse('2026-04-02T10:00:05.000Z'), + }); + + assert.equal(result.decision.decision, 'actionable'); + assert.equal(result.decision.direction, 'btc_to_usdc'); + assert.equal(result.decision.edge_bps, '49'); + assert.equal(result.decision.price_route_id, 'btc-usdc:v1'); + assert.equal(result.decision.notional, '8.000000'); + assert.equal(result.decision.notional_symbol, 'USDC'); + assert.equal(result.command.quote_output.amount_out, '7960800'); + assert.equal(result.command.asset_out_decimals, 6); +}); + +test('strategy blocks BTC -> USDC when route-specific reference price is stale', () => { + const config = makeBtcUsdcDbConfig(); + const result = evaluateTradeOpportunity({ + demandEvent: { + payload: { + quote_id: 'quote-usdc-stale', + pair: config.activePair, + asset_in: config.tradingBtc.assetId, + asset_out: config.tradingUsdc.assetId, + request_kind: 'exact_in', + amount_in: '10000', + }, + }, + priceEvent: makeBtcUsdcPriceEvent(), + inventoryEvent: makeBtcUsdcInventoryEvent(), + config, + armed: true, + now: Date.parse('2026-04-02T10:00:45.000Z'), + }); + + assert.equal(result.decision.decision_reason, 'stale_reference_price'); + assert.equal(result.command, undefined); +}); + +test('strategy blocks BTC -> USDC when price event lacks USDC route fields', () => { + const config = makeBtcUsdcDbConfig(); + const result = evaluateTradeOpportunity({ + demandEvent: { + payload: { + quote_id: 'quote-usdc-missing-price', + pair: config.activePair, + asset_in: config.tradingBtc.assetId, + asset_out: config.tradingUsdc.assetId, + request_kind: 'exact_in', + amount_in: '10000', + }, + }, + priceEvent: makePriceEvent(), + inventoryEvent: makeBtcUsdcInventoryEvent(), + config, + armed: true, + now: Date.parse('2026-04-02T10:00:05.000Z'), + }); + + assert.equal(result.decision.decision_reason, 'reference_price_missing'); + assert.equal(result.command, undefined); +}); + +function makeBtcUsdcDbConfig() { + const tradingBtc = { + assetId: 'nep141:nbtc.bridge.near', + symbol: 'BTC', + decimals: 8, + chain: 'btc:mainnet', + }; + const tradingUsdc = { + assetId: 'nep141:gnosis-0x2a22f9c3b484c3629090feed35f17ff8f88f76f0.omft.near', + symbol: 'USDC', + decimals: 6, + chain: 'gnosis', + }; + const activePair = `${tradingBtc.assetId}->${tradingUsdc.assetId}`; + const strategyConfig = { + configId: `${activePair}:v1`, + version: 1, + edgeBps: 49, + maxNotional: '150', + priceMaxAgeMs: 30_000, + inventoryMaxAgeMs: 30_000, + minNotional: '0', + }; + const priceRoute = { + routeId: 'btc-usdc:v1', + source: 'btc_usdc_reference', + baseAssetId: tradingBtc.assetId, + quoteAssetId: tradingUsdc.assetId, + }; + const pair = { + pairId: activePair, + key: activePair, + assetIn: tradingBtc, + assetOut: tradingUsdc, + asset_in: tradingBtc.assetId, + asset_out: tradingUsdc.assetId, + enabled: true, + observeEnabled: true, + makerEnabled: true, + takerEnabled: false, + canTrade: true, + blockReason: null, + strategyConfig, + priceRoute, + }; + + return { + tradingBtc, + tradingUsdc, + activePair, + ok: true, + tradingConfigLoaded: true, + requireDbTradingConfig: true, + assetRegistry: new Map([ + [tradingBtc.assetId, tradingBtc], + [tradingUsdc.assetId, tradingUsdc], + ]), + pairByKey: new Map([[activePair, pair]]), + strategyGrossThresholdPct: 0.49, + strategyMaxNotionalEure: 150, + strategyPriceMaxAgeMs: 30_000, + strategyInventoryMaxAgeMs: 30_000, + }; +} + +function makeBtcUsdcPriceEvent(overrides = {}) { + return { + ingested_at: new Date('2026-04-02T10:00:00.000Z').toISOString(), + payload: { + price_id: 'price-usdc-1', + pair: 'nep141:nbtc.bridge.near->nep141:gnosis-0x2a22f9c3b484c3629090feed35f17ff8f88f76f0.omft.near', + price_route_id: 'btc-usdc:v1', + eur_per_btc: '75000.00000000', + eure_per_btc: '75000.00000000', + btc_per_eur: '0.000013333333', + btc_per_eure: '0.000013333333', + usd_per_btc: '80000.00000000', + usdc_per_btc: '80000.00000000', + btc_per_usd: '0.000012500000', + btc_per_usdc: '0.000012500000', + ...overrides, + }, + }; +} + +function makeBtcUsdcInventoryEvent(overrides = {}) { + return { + ingested_at: new Date('2026-04-02T10:00:00.000Z').toISOString(), + payload: { + inventory_id: 'inventory-usdc-1', + spendable: { + 'nep141:nbtc.bridge.near': '1000000', + 'nep141:gnosis-0x2a22f9c3b484c3629090feed35f17ff8f88f76f0.omft.near': '1000000000', + }, + pending_inbound: { + 'nep141:nbtc.bridge.near': '0', + 'nep141:gnosis-0x2a22f9c3b484c3629090feed35f17ff8f88f76f0.omft.near': '0', + }, + ...overrides, + }, + }; +} diff --git a/test/trading-config.test.mjs b/test/trading-config.test.mjs index a5b737c..39363cd 100644 --- a/test/trading-config.test.mjs +++ b/test/trading-config.test.mjs @@ -5,6 +5,7 @@ import { evaluateTradeOpportunity } from '../src/core/strategy.mjs'; import { CURRENT_EURE_ASSET_ID, CURRENT_NBTC_ASSET_ID, + CURRENT_USDC_ASSET_ID, LEGACY_OMFT_BTC_ASSET_ID, normalizeOneClickToken, } from '../src/core/trading-config.mjs'; @@ -247,6 +248,31 @@ test('pair mode updates activate a directed pair without inventing a price route assert.equal(pair.blockReason, 'price_route_missing'); }); +test('pair mode activation wires known BTC/USDC route and inventory tracking', async () => { + const pool = createMemoryPool(); + await seedTradingConfig(pool); + const pairId = `${CURRENT_NBTC_ASSET_ID}->${CURRENT_USDC_ASSET_ID}`; + + await setTradingPairMode(pool, { + pairId, + mode: 'maker', + edgeBps: 49, + maxNotional: '150', + changedBy: 'test', + reason: 'operator btc usdc activation test', + }); + const snapshot = await loadTradingConfig(pool); + const pair = snapshot.pairByKey.get(pairId); + + assert.equal(pair.canTrade, true); + assert.equal(pair.blockReason, null); + assert.equal(pair.priceRoute.source, 'btc_usdc_reference'); + assert.equal(pair.priceRoute.baseAssetId, CURRENT_NBTC_ASSET_ID); + assert.equal(pair.priceRoute.quoteAssetId, CURRENT_USDC_ASSET_ID); + assert.equal(snapshot.assetRegistry.get(CURRENT_USDC_ASSET_ID).enabledForInventory, true); + assert.equal(snapshot.trackedAssetIds.includes(CURRENT_USDC_ASSET_ID), true); +}); + test('pair mode activation rejects invalid initial edge config', async () => { const pool = createMemoryPool(); await seedTradingConfig(pool); @@ -390,6 +416,9 @@ function createMemoryPool() { } if (/SELECT \*\s+FROM trading_assets\s+ORDER BY/i.test(sql)) return rows(this.assets); if (/INSERT INTO trading_assets/i.test(sql)) return insertAsset(this, params); + if (/UPDATE trading_assets[\s\S]+enabled_for_inventory = true/i.test(sql)) { + return enableInventoryAssets(this, params); + } if (/UPDATE trading_assets/i.test(sql)) return retireAssets(this, params); if (/INSERT INTO supported_asset_import_runs/i.test(sql)) return insertImportRun(this, params); if (/SELECT \*\s+FROM supported_asset_import_runs/i.test(sql)) { @@ -532,6 +561,19 @@ function retireAssets(pool, params) { return { rows: [], rowCount: count }; } +function enableInventoryAssets(pool, params) { + const [assetIds, updatedAt] = params; + let count = 0; + for (const assetId of assetIds) { + const row = pool.assets.get(assetId); + if (!row) continue; + row.enabled_for_inventory = true; + row.updated_at = updatedAt; + count += 1; + } + return { rows: [], rowCount: count }; +} + function insertImportRun(pool, params) { const row = { run_id: params[0],