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:
parent
5805ea801d
commit
0f33a53fa9
10 changed files with 653 additions and 52 deletions
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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 = {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
37
test/market-data.test.mjs
Normal 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;
|
||||||
|
}
|
||||||
|
});
|
||||||
21
test/price-route-runtime-static.test.mjs
Normal file
21
test/price-route-runtime-static.test.mjs
Normal 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/);
|
||||||
|
});
|
||||||
|
|
@ -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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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],
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue