Add BTC USDC price route
Some checks failed
deploy / deploy (push) Failing after 54s

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.
This commit is contained in:
philipp 2026-05-13 15:18:38 +02:00
parent 5805ea801d
commit 0f33a53fa9
10 changed files with 653 additions and 52 deletions

View file

@ -5,7 +5,12 @@ import { startControlApi } from '../core/control-api.mjs';
import { buildEventEnvelope } from '../core/event-envelope.mjs'; import { buildEventEnvelope } from '../core/event-envelope.mjs';
import { createLogger, serializeError } from '../core/log.mjs'; import { createLogger, serializeError } from '../core/log.mjs';
import { assertMarketPriceEvent } from '../core/schemas.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 { loadConfig } from '../lib/config.mjs';
import { import {
createPostgresPool, createPostgresPool,
@ -22,6 +27,10 @@ const logger = createLogger({
venue: 'reference-market', 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({ const producer = await createProducer({
brokers: config.kafkaBrokers, brokers: config.kafkaBrokers,
clientId: config.kafkaClientId, clientId: config.kafkaClientId,
@ -43,6 +52,8 @@ const state = {
refreshing: false, refreshing: false,
kraken: null, kraken: null,
coingecko: null, coingecko: null,
kraken_btc_usdc: null,
coingecko_btc_usd: null,
last_published_at: null, last_published_at: null,
last_publish_error: null, last_publish_error: null,
publish_count: 0, publish_count: 0,
@ -57,7 +68,7 @@ async function refresh() {
try { try {
const now = Date.now(); const now = Date.now();
await refreshKraken(now).catch((error) => { await refreshKrakenBtcEur(now).catch((error) => {
logger.warn('kraken_refresh_failed', { logger.warn('kraken_refresh_failed', {
pair: tradingConfigStore.getState().active_pair, pair: tradingConfigStore.getState().active_pair,
details: { 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) { if (now >= coingeckoDueAt || !state.coingecko) {
await refreshCoinGecko(now); await refreshCoinGeckoBtcEur(now);
await refreshCoinGeckoBtcUsd(now);
coingeckoDueAt = now + config.marketReferenceCoinGeckoRefreshMs; coingeckoDueAt = now + config.marketReferenceCoinGeckoRefreshMs;
} }
const tradingConfig = await tradingConfigStore.getConfig(); const tradingConfig = await tradingConfigStore.getConfig();
if (!tradingConfig.ok) throw new Error(`trading config unavailable: ${tradingConfig.blockReason}`); if (!tradingConfig.ok) throw new Error(`trading config unavailable: ${tradingConfig.blockReason}`);
const event = buildPriceEvent(now, { tradingConfig }); const events = buildPriceEvents(now, { tradingConfig });
for (const event of events) {
assertMarketPriceEvent(event); assertMarketPriceEvent(event);
await producer.sendJson(config.kafkaTopicRefMarketPrice, event, { key: event.payload.price_id }); await producer.sendJson(config.kafkaTopicRefMarketPrice, event, { key: event.payload.price_id });
}
state.last_published_at = new Date(now).toISOString(); state.last_published_at = new Date(now).toISOString();
state.last_publish_error = null; state.last_publish_error = null;
state.publish_count += 1; state.publish_count += events.length;
} catch (error) { } catch (error) {
state.error_count += 1; state.error_count += 1;
state.last_publish_error = serializeError(error); state.last_publish_error = serializeError(error);
@ -93,7 +114,7 @@ async function refresh() {
} }
} }
async function refreshKraken(now) { async function refreshKrakenBtcEur(now) {
try { try {
const price = await fetchKrakenBtcEur(config.marketReferenceKrakenTickerUrl); const price = await fetchKrakenBtcEur(config.marketReferenceKrakenTickerUrl);
state.kraken = { 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 { try {
const price = await fetchCoinGeckoBtcEur(config.marketReferenceCoinGeckoUrl); const price = await fetchCoinGeckoBtcEur(config.marketReferenceCoinGeckoUrl);
state.coingecko = { state.coingecko = {
@ -136,15 +176,34 @@ async function refreshCoinGecko(now) {
} }
} }
function buildPriceEvent(now, { tradingConfig }) { async function refreshCoinGeckoBtcUsd(now) {
const sourceUsed = chooseSource(now); try {
if (!sourceUsed) throw new Error('No fresh reference price available'); const price = await fetchCoinGeckoBtcUsd(COINGECKO_BTC_USD_URL);
const referencePair = tradingConfig.pairs.find((pair) => ( state.coingecko_btc_usd = {
pair.priceRoute?.source === 'btc_eur_reference' && pair.canTrade price,
)); observed_at: new Date(now).toISOString(),
if (!referencePair) throw new Error('No DB-enabled BTC/EUR price route available'); 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.kraken.price
: state.coingecko.price; : state.coingecko.price;
const btcPerEur = 1 / eurPerBtc; const btcPerEur = 1 / eurPerBtc;
@ -152,18 +211,67 @@ function buildPriceEvent(now, { tradingConfig }) {
? Math.abs((state.kraken.price - state.coingecko.price) / state.kraken.price) * 100 ? Math.abs((state.kraken.price - state.coingecko.price) / state.kraken.price) * 100
: null; : 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({ return buildEventEnvelope({
source: 'market-reference-ingest', source: 'market-reference-ingest',
venue: 'reference-market', venue: 'reference-market',
eventType: 'market_price', eventType: 'market_price',
observedAt: new Date(now).toISOString(), observedAt: new Date(now).toISOString(),
payload: { payload: {
price_id: `price-${now}`, price_id: buildPriceId(now, referencePair.priceRoute.routeId),
pair: referencePair.key, pair: referencePair.key,
pair_id: referencePair.pairId, pair_id: referencePair.pairId,
price_route_id: referencePair.priceRoute.routeId, price_route_id: referencePair.priceRoute.routeId,
base_asset_id: referencePair.priceRoute.baseAssetId, base_asset_id: referencePair.priceRoute.baseAssetId,
quote_asset_id: referencePair.priceRoute.quoteAssetId, quote_asset_id: referencePair.priceRoute.quoteAssetId,
reference_pair: 'BTC/EUR',
eur_per_btc: eurPerBtc.toFixed(8), eur_per_btc: eurPerBtc.toFixed(8),
eure_per_btc: eurPerBtc.toFixed(8), eure_per_btc: eurPerBtc.toFixed(8),
btc_per_eur: btcPerEur.toFixed(12), 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.kraken, now, config.marketReferenceMaxAgeMs)) return 'kraken';
if (isFresh(state.coingecko, now, config.marketReferenceMaxAgeMs)) return 'coingecko'; if (isFresh(state.coingecko, now, config.marketReferenceMaxAgeMs)) return 'coingecko';
return null; 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) { function compactMarketSource(source, now) {
if (!source) return null; if (!source) return null;
const ageMs = source.observed_at ? now - Date.parse(source.observed_at) : null; const ageMs = source.observed_at ? now - Date.parse(source.observed_at) : null;

View file

@ -60,6 +60,8 @@ const state = {
armed: armedStateStore.isArmed(), armed: armedStateStore.isArmed(),
paused: false, paused: false,
latest_price_event: null, latest_price_event: null,
latest_price_events_by_route: {},
latest_price_events_by_pair: {},
latest_inventory_event: null, latest_inventory_event: null,
latest_decision: null, latest_decision: null,
recent_decisions: [], recent_decisions: [],
@ -77,6 +79,9 @@ await consumer.run({
if (topic === config.kafkaTopicRefMarketPrice) { if (topic === config.kafkaTopicRefMarketPrice) {
assertMarketPriceEvent(event); assertMarketPriceEvent(event);
state.latest_price_event = 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; return;
} }
@ -130,7 +135,7 @@ async function handleDemand(event) {
const evaluation = evaluateTradeOpportunity({ const evaluation = evaluateTradeOpportunity({
demandEvent: event, demandEvent: event,
priceEvent: state.latest_price_event, priceEvent: resolvePriceEventForDemand(event, tradingConfig),
inventoryEvent: state.latest_inventory_event, inventoryEvent: state.latest_inventory_event,
config: { config: {
...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) { async function publishDecision(decisionPayload) {
const decisionAt = decisionPayload.decision_at || new Date().toISOString(); const decisionAt = decisionPayload.decision_at || new Date().toISOString();
const normalizedDecisionPayload = { const normalizedDecisionPayload = {

View file

@ -48,10 +48,11 @@ export function evaluateTradeOpportunity({
threshold_pct: String(effectiveThresholdPct), threshold_pct: String(effectiveThresholdPct),
max_notional_eure: String(effectiveMaxNotionalEure), max_notional_eure: String(effectiveMaxNotionalEure),
strategy_armed: armed, strategy_armed: armed,
assumptions: { assumptions: compact({
eure_per_eur: '1', 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, price_route_source: pairRuntime.priceRoute?.source || null,
}, }),
}; };
if (!pairRuntime.ok) { if (!pairRuntime.ok) {
@ -139,6 +140,9 @@ export function evaluateTradeOpportunity({
max_notional_eure: decision.max_notional_eure, max_notional_eure: decision.max_notional_eure,
price_route_id: decision.price_route_id, price_route_id: decision.price_route_id,
reference_price_id: buildResult.details.price_id || null, 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_in: payload.asset_in,
asset_out: payload.asset_out, asset_out: payload.asset_out,
asset_in_decimals: pairRuntime.assetIn?.decimals ?? null, asset_in_decimals: pairRuntime.assetIn?.decimals ?? null,
@ -186,6 +190,11 @@ function buildQuote({
const spendAsset = demand.asset_out; const spendAsset = demand.asset_out;
const available = bigintAmount(inventory.spendable?.[spendAsset] || '0'); const available = bigintAmount(inventory.spendable?.[spendAsset] || '0');
const pendingInbound = bigintAmount(inventory.pending_inbound?.[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') { if (demand.request_kind === 'exact_in') {
const amountIn = bigintAmount(demand.amount_in); const amountIn = bigintAmount(demand.amount_in);
@ -197,9 +206,9 @@ function buildQuote({
demand.amount_in, demand.amount_in,
assetIn.decimals, assetIn.decimals,
); );
const fairOutput = direction === 'btc_to_eure' const fairOutput = baseToQuote
? inputNumber * Number(price.eure_per_btc) ? inputNumber * quotePerBase
: inputNumber * Number(price.btc_per_eure); : inputNumber * basePerQuote;
const proposedOutput = fairOutput * thresholdFactor; const proposedOutput = fairOutput * thresholdFactor;
const proposedOutputUnits = numberToUnits( const proposedOutputUnits = numberToUnits(
proposedOutput, proposedOutput,
@ -207,16 +216,12 @@ function buildQuote({
{ mode: 'floor' }, { mode: 'floor' },
); );
const spendRequired = bigintAmount(proposedOutputUnits); const spendRequired = bigintAmount(proposedOutputUnits);
const eureNotional = direction === 'btc_to_eure' const quoteNotional = baseToQuote ? fairOutput : inputNumber;
? fairOutput
: inputNumber;
const impliedRate = unitsToNumber( const impliedRate = unitsToNumber(
proposedOutputUnits, proposedOutputUnits,
assetOut.decimals, assetOut.decimals,
) / inputNumber; ) / inputNumber;
const referenceRate = direction === 'btc_to_eure' const referenceRate = baseToQuote ? quotePerBase : basePerQuote;
? Number(price.eure_per_btc)
: Number(price.btc_per_eure);
return finalizeQuote({ return finalizeQuote({
direction, direction,
@ -224,7 +229,9 @@ function buildQuote({
pendingInbound, pendingInbound,
spendAsset, spendAsset,
spendRequired, spendRequired,
eureNotional, quoteNotional,
notionalAssetId,
notionalSymbol: notionalAsset?.symbol || null,
maxNotionalEure, maxNotionalEure,
proposedAmountOut: proposedOutputUnits, proposedAmountOut: proposedOutputUnits,
impliedRate, impliedRate,
@ -246,9 +253,9 @@ function buildQuote({
demand.amount_out, demand.amount_out,
assetOut.decimals, assetOut.decimals,
); );
const fairInput = direction === 'btc_to_eure' const fairInput = baseToQuote
? outputNumber * Number(price.btc_per_eure) ? outputNumber * basePerQuote
: outputNumber * Number(price.eure_per_btc); : outputNumber * quotePerBase;
const proposedInput = fairInput * penaltyFactor; const proposedInput = fairInput * penaltyFactor;
const proposedInputUnits = numberToUnits( const proposedInputUnits = numberToUnits(
proposedInput, proposedInput,
@ -256,16 +263,12 @@ function buildQuote({
{ mode: 'ceil' }, { mode: 'ceil' },
); );
const spendRequired = amountOut; const spendRequired = amountOut;
const eureNotional = direction === 'btc_to_eure' const quoteNotional = baseToQuote ? outputNumber : fairInput;
? outputNumber
: fairInput;
const impliedRate = outputNumber / unitsToNumber( const impliedRate = outputNumber / unitsToNumber(
proposedInputUnits, proposedInputUnits,
assetIn.decimals, assetIn.decimals,
); );
const referenceRate = direction === 'btc_to_eure' const referenceRate = baseToQuote ? quotePerBase : basePerQuote;
? Number(price.eure_per_btc)
: Number(price.btc_per_eure);
return finalizeQuote({ return finalizeQuote({
direction, direction,
@ -273,7 +276,9 @@ function buildQuote({
pendingInbound, pendingInbound,
spendAsset, spendAsset,
spendRequired, spendRequired,
eureNotional, quoteNotional,
notionalAssetId,
notionalSymbol: notionalAsset?.symbol || null,
maxNotionalEure, maxNotionalEure,
proposedAmountIn: proposedInputUnits, proposedAmountIn: proposedInputUnits,
proposedAmountOut: demand.amount_out, proposedAmountOut: demand.amount_out,
@ -295,7 +300,9 @@ function finalizeQuote({
pendingInbound, pendingInbound,
spendAsset, spendAsset,
spendRequired, spendRequired,
eureNotional, quoteNotional,
notionalAssetId = null,
notionalSymbol = null,
maxNotionalEure, maxNotionalEure,
proposedAmountIn = null, proposedAmountIn = null,
proposedAmountOut = null, proposedAmountOut = null,
@ -320,7 +327,10 @@ function finalizeQuote({
reference_price_id: priceId, reference_price_id: priceId,
asset_in_decimals: assetInDecimals == null ? null : String(assetInDecimals), asset_in_decimals: assetInDecimals == null ? null : String(assetInDecimals),
asset_out_decimals: assetOutDecimals == null ? null : String(assetOutDecimals), 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_in: proposedAmountIn,
proposed_amount_out: proposedAmountOut, proposed_amount_out: proposedAmountOut,
}; };
@ -329,7 +339,7 @@ function finalizeQuote({
return { ok: false, reason: 'invalid_pricing', details: reasonBase }; return { ok: false, reason: 'invalid_pricing', details: reasonBase };
} }
if (eureNotional > maxNotionalEure) { if (quoteNotional > maxNotionalEure) {
return { ok: false, reason: 'max_notional_exceeded', details: reasonBase }; return { ok: false, reason: 'max_notional_exceeded', details: reasonBase };
} }
@ -494,12 +504,35 @@ function blockedPairRuntime(pair, config, reason) {
function classifyPriceRouteDirection({ payload, priceRoute }) { function classifyPriceRouteDirection({ payload, priceRoute }) {
if (!priceRoute) return 'unsupported'; 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) { 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) { 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'; 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,
};
}

View file

@ -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 LEGACY_OMFT_BTC_ASSET_ID = 'nep141:btc.omft.near';
export const CURRENT_EURE_ASSET_ID = export const CURRENT_EURE_ASSET_ID =
'nep141:gnosis-0x420ca0f9b9b604ce0fd9c18ef134c705e5fa3430.omft.near'; '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_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); export const CURRENT_REVERSE_PAIR_KEY = pairKey(CURRENT_EURE_ASSET_ID, CURRENT_NBTC_ASSET_ID);
@ -121,6 +123,22 @@ export function buildSeedAssets() {
withdrawAddress: '0x6C40267e03A97B2132e7a7d3159C88534eBEfdFb', withdrawAddress: '0x6C40267e03A97B2132e7a7d3159C88534eBEfdFb',
rawPayload: { source: 'repo_seed', assetId: CURRENT_EURE_ASSET_ID }, 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) { export function pairCanObserve(pair) {
return Boolean(pair?.enabled) && PAIR_MODES.has(pair.mode) && pair.status !== 'disabled'; return Boolean(pair?.enabled) && PAIR_MODES.has(pair.mode) && pair.status !== 'disabled';
} }

View file

@ -1,6 +1,6 @@
import { fetchJson } from './http.mjs'; import { fetchJson } from './http.mjs';
export async function fetchKrakenBtcEur(url) { export async function fetchKrakenTickerPrice(url) {
const response = await fetchJson(url); const response = await fetchJson(url);
const pair = Object.values(response.result || {})[0]; const pair = Object.values(response.result || {})[0];
const lastTrade = pair?.c?.[0] || pair?.a?.[0]; const lastTrade = pair?.c?.[0] || pair?.a?.[0];
@ -8,13 +8,29 @@ export async function fetchKrakenBtcEur(url) {
return Number(lastTrade); 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, { const response = await fetchJson(url, {
headers: { headers: {
accept: 'application/json', accept: 'application/json',
}, },
}); });
const price = response?.bitcoin?.eur; const price = response?.bitcoin?.[currency];
if (!Number.isFinite(price)) throw new Error('CoinGecko price missing'); if (!Number.isFinite(price)) throw new Error('CoinGecko price missing');
return Number(price); return Number(price);
} }
export async function fetchCoinGeckoBtcEur(url) {
return fetchCoinGeckoBtcCurrency(url, 'eur');
}
export async function fetchCoinGeckoBtcUsd(url) {
return fetchCoinGeckoBtcCurrency(url, 'usd');
}

View file

@ -3,7 +3,10 @@ import { Pool } from 'pg';
import { deriveIntentRequestOutcomeRecords } from '../core/intent-request-outcomes.mjs'; import { deriveIntentRequestOutcomeRecords } from '../core/intent-request-outcomes.mjs';
import { deriveQuoteOutcomeRecords } from '../core/quote-outcomes.mjs'; import { deriveQuoteOutcomeRecords } from '../core/quote-outcomes.mjs';
import { import {
CURRENT_NBTC_ASSET_ID,
CURRENT_USDC_ASSET_ID,
ONE_CLICK_TOKENS_URL, ONE_CLICK_TOKENS_URL,
buildBtcUsdcPriceRoute,
buildSeedAssets, buildSeedAssets,
buildSeedPairs, buildSeedPairs,
buildSeedPriceRoute, buildSeedPriceRoute,
@ -439,6 +442,8 @@ export async function seedTradingConfig(pool, {
}); });
} }
await seedKnownEnabledPairRuntimeConfig(pool, { now });
return loadTradingConfig(pool); return loadTradingConfig(pool);
} }
@ -907,6 +912,11 @@ export async function setTradingPairMode(pool, {
let strategyConfig = null; let strategyConfig = null;
if (pairCanMake(nextPair) || pairCanTake(nextPair)) { if (pairCanMake(nextPair) || pairCanTake(nextPair)) {
await enableInventoryForAssets(client, {
assetIds: [resolvedAssetIn, resolvedAssetOut],
now: new Date().toISOString(),
});
const activeConfigResult = await client.query( const activeConfigResult = await client.query(
` `
SELECT * SELECT *
@ -965,6 +975,14 @@ export async function setTradingPairMode(pool, {
reason, reason,
}); });
} }
const knownRoute = buildKnownPriceRouteForPair(nextPair);
if (knownRoute) {
await upsertSeedPriceRoute(client, {
route: knownRoute,
now: new Date().toISOString(),
});
}
} }
await insertConfigAuditLog(client, { await insertConfigAuditLog(client, {
@ -1479,6 +1497,45 @@ function publicAssetImportRunSummary(run) {
return publicRun; 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 }) { async function upsertSeedAsset(pool, { asset, now }) {
await pool.query( await pool.query(
` `

37
test/market-data.test.mjs Normal file
View file

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

View file

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

View file

@ -151,3 +151,185 @@ test('strategy blocks stale prices before command emission', () => {
assert.equal(result.decision.decision_reason, 'stale_reference_price'); assert.equal(result.decision.decision_reason, 'stale_reference_price');
assert.equal(result.command, undefined); 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,
},
};
}

View file

@ -5,6 +5,7 @@ import { evaluateTradeOpportunity } from '../src/core/strategy.mjs';
import { import {
CURRENT_EURE_ASSET_ID, CURRENT_EURE_ASSET_ID,
CURRENT_NBTC_ASSET_ID, CURRENT_NBTC_ASSET_ID,
CURRENT_USDC_ASSET_ID,
LEGACY_OMFT_BTC_ASSET_ID, LEGACY_OMFT_BTC_ASSET_ID,
normalizeOneClickToken, normalizeOneClickToken,
} from '../src/core/trading-config.mjs'; } 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'); 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 () => { test('pair mode activation rejects invalid initial edge config', async () => {
const pool = createMemoryPool(); const pool = createMemoryPool();
await seedTradingConfig(pool); 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 (/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 (/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 (/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 (/INSERT INTO supported_asset_import_runs/i.test(sql)) return insertImportRun(this, params);
if (/SELECT \*\s+FROM supported_asset_import_runs/i.test(sql)) { if (/SELECT \*\s+FROM supported_asset_import_runs/i.test(sql)) {
@ -532,6 +561,19 @@ function retireAssets(pool, params) {
return { rows: [], rowCount: count }; 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) { function insertImportRun(pool, params) {
const row = { const row = {
run_id: params[0], run_id: params[0],