Proof: npm test passed 159/159; npm run operator-dashboard:build passed; repo-local Postgres importer smoke test imported 163 live 1Click tokens with only 3 inventory-enabled seed assets and nBTC/EURe pairs at 49 bps. Assumptions: Forgejo main push is the repo deployment path; production has existing repo-managed POSTGRES_URL/POSTGRES_PASSWORD/NEAR_INTENTS_API_KEY secrets; startup seed may create initial current nBTC/EURe config but must preserve DB runtime pair flags after creation. Still fake: no live funds movement was attempted; imported supported assets remain catalog-only unless explicitly enabled in DB; production rollout evidence still depends on the Forgejo deploy job completing after this push.
This commit is contained in:
parent
5425152ed9
commit
2ffa4b17f1
26 changed files with 3190 additions and 421 deletions
|
|
@ -11,21 +11,8 @@ data:
|
|||
NEAR_INTENTS_BRIDGE_RPC_URL: https://bridge.chaindefuser.com/rpc
|
||||
NEAR_INTENTS_VERIFIER_CONTRACT: intents.near
|
||||
NEAR_RPC_URL: https://near.lava.build
|
||||
NEAR_INTENTS_PAIR_FILTER: nep141:nbtc.bridge.near->nep141:gnosis-0x420ca0f9b9b604ce0fd9c18ef134c705e5fa3430.omft.near
|
||||
NEAR_INTENTS_STATUS_POLL_MS: "60000"
|
||||
NEAR_INTENTS_ACCOUNT_ID: unrip-dev.near
|
||||
TRADING_BTC_ASSET_ID: nep141:nbtc.bridge.near
|
||||
TRADING_BTC_TRACKED_ASSET_IDS: nep141:nbtc.bridge.near,nep141:btc.omft.near
|
||||
TRADING_BTC_SYMBOL: BTC
|
||||
TRADING_BTC_LABEL: BTC / nBTC reserve
|
||||
TRADING_BTC_DECIMALS: "8"
|
||||
TRADING_BTC_CHAIN: btc:mainnet
|
||||
TRADING_BTC_WITHDRAW_ADDRESS: ""
|
||||
TRADING_EURE_ASSET_ID: nep141:gnosis-0x420ca0f9b9b604ce0fd9c18ef134c705e5fa3430.omft.near
|
||||
TRADING_EURE_SYMBOL: EURe
|
||||
TRADING_EURE_DECIMALS: "18"
|
||||
TRADING_EURE_CHAIN: "eth:100"
|
||||
TRADING_EURE_WITHDRAW_ADDRESS: "0x6C40267e03A97B2132e7a7d3159C88534eBEfdFb"
|
||||
NEAR_INTENTS_CONTROL_API_ENABLED: "true"
|
||||
NEAR_INTENTS_CONTROL_HOST: 0.0.0.0
|
||||
NEAR_INTENTS_CONTROL_PORT: "8081"
|
||||
|
|
@ -53,6 +40,7 @@ data:
|
|||
STRATEGY_ENGINE_CONTROL_BASE_URL: http://strategy-engine.unrip.svc.cluster.local:8086
|
||||
TRADE_EXECUTOR_CONTROL_BASE_URL: http://trade-executor.unrip.svc.cluster.local:8087
|
||||
OPS_SENTINEL_CONTROL_BASE_URL: http://ops-sentinel.unrip.svc.cluster.local:8088
|
||||
OPERATOR_DASHBOARD_CONTROL_BASE_URL: http://operator-dashboard.unrip.svc.cluster.local:8090
|
||||
NOTIFICATION_NTFY_BASE_URL: http://ntfy.utility.svc.cluster.local
|
||||
NOTIFICATION_NTFY_TOPIC: unrip
|
||||
NOTIFICATION_NTFY_TIMEOUT_MS: "5000"
|
||||
|
|
@ -86,23 +74,12 @@ data:
|
|||
MARKET_REFERENCE_COINGECKO_URL: https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies=eur
|
||||
INVENTORY_SYNC_REFRESH_MS: "15000"
|
||||
LIQUIDITY_REFRESH_MS: "30000"
|
||||
STRATEGY_GROSS_THRESHOLD_PCT: "0.49"
|
||||
STRATEGY_INITIAL_ARMED: "false"
|
||||
STRATEGY_MAX_NOTIONAL_EURE: "150"
|
||||
STRATEGY_PRICE_MAX_AGE_MS: "30000"
|
||||
STRATEGY_INVENTORY_MAX_AGE_MS: "30000"
|
||||
EXECUTOR_INITIAL_ARMED: "false"
|
||||
EXECUTOR_RESPONSE_TIMEOUT_MS: "10000"
|
||||
INTENT_REQUEST_DEFAULT_AMOUNT_EURE: "5"
|
||||
INTENT_REQUEST_MAX_AMOUNT_EURE: "5"
|
||||
INTENT_REQUEST_DEFAULT_SLIPPAGE_BPS: "200"
|
||||
INTENT_REQUEST_MAX_SLIPPAGE_BPS: "200"
|
||||
INTENT_REQUEST_MIN_DEADLINE_MS: "60000"
|
||||
INTENT_REQUEST_QUOTE_TIMEOUT_MS: "10000"
|
||||
INTENT_REQUEST_PUBLISH_TIMEOUT_MS: "10000"
|
||||
INTENT_REQUEST_STATUS_TIMEOUT_MS: "10000"
|
||||
INTENT_REQUEST_INVENTORY_MAX_AGE_MS: "30000"
|
||||
INTENT_REQUEST_PRICE_MAX_AGE_MS: "30000"
|
||||
LIQUIDITY_WITHDRAWALS_FROZEN: "true"
|
||||
BTC_FUNDING_OBSERVER_ENABLED: "true"
|
||||
BTC_FUNDING_OBSERVER_BASE_URL: https://mempool.space/api
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@
|
|||
"strategy:engine": "node src/apps/strategy-engine.mjs",
|
||||
"trade:executor": "node src/apps/trade-executor.mjs",
|
||||
"operator:dashboard": "node src/apps/operator-dashboard.mjs",
|
||||
"assets:import": "node src/apps/supported-token-importer.mjs",
|
||||
"operator-dashboard:build": "vite build --config vite.operator-dashboard.config.mjs",
|
||||
"operator-dashboard:dev": "bash scripts/dev/operator-dashboard-dev.sh",
|
||||
"operator-dashboard:forward": "bash scripts/dev/operator-dashboard-forward.sh",
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import { parseEventMessage } from '../core/event-envelope.mjs';
|
|||
import { loadConfig } from '../lib/config.mjs';
|
||||
import {
|
||||
claimNotificationDelivery,
|
||||
createTradingConfigStore,
|
||||
createPostgresPool,
|
||||
ensureHistorySchema,
|
||||
insertEnvironmentStatusChange,
|
||||
|
|
@ -27,6 +28,7 @@ import {
|
|||
loadPortfolioMetricInputs,
|
||||
refreshIntentRequestOutcomes,
|
||||
refreshQuoteOutcomes,
|
||||
seedTradingConfig,
|
||||
upsertPortfolioMetric,
|
||||
} from '../lib/postgres.mjs';
|
||||
|
||||
|
|
@ -41,6 +43,12 @@ const pool = createPostgresPool({
|
|||
connectionString: config.postgresUrl,
|
||||
});
|
||||
await ensureHistorySchema(pool);
|
||||
await seedTradingConfig(pool);
|
||||
const tradingConfigStore = createTradingConfigStore({
|
||||
pool,
|
||||
logger: logger.child({ component: 'trading-config' }),
|
||||
});
|
||||
await tradingConfigStore.forceRefresh();
|
||||
|
||||
const notificationLogger = logger.child({ component: 'notifications' });
|
||||
const notificationClient = createNtfyNotificationClient({
|
||||
|
|
@ -282,16 +290,20 @@ const controlApi = startControlApi({
|
|||
return {
|
||||
...state,
|
||||
database_connectivity: connectivity,
|
||||
trading_config: tradingConfigStore.getState(),
|
||||
};
|
||||
},
|
||||
},
|
||||
healthProvider: {
|
||||
async getHealth() {
|
||||
const connectivity = await pool.query('SELECT 1').then(() => true).catch(() => false);
|
||||
const tradingConfig = tradingConfigStore.getState();
|
||||
const lastTruthAt = state.last_write_at || state.last_metrics_at || null;
|
||||
const freshnessAgeMs = lastTruthAt ? Date.now() - new Date(lastTruthAt).getTime() : null;
|
||||
return {
|
||||
ok: connectivity && (freshnessAgeMs == null || freshnessAgeMs <= config.opsSentinelHistoryWriterStaleMs),
|
||||
ok: connectivity
|
||||
&& tradingConfig.ok === true
|
||||
&& (freshnessAgeMs == null || freshnessAgeMs <= config.opsSentinelHistoryWriterStaleMs),
|
||||
paused: state.paused,
|
||||
last_write_at: state.last_write_at,
|
||||
last_alert_write_at: state.last_alert_write_at,
|
||||
|
|
@ -302,7 +314,12 @@ const controlApi = startControlApi({
|
|||
last_metrics_at: state.last_metrics_at,
|
||||
freshness_age_ms: Number.isFinite(freshnessAgeMs) ? Math.max(0, freshnessAgeMs) : null,
|
||||
database_connectivity: connectivity,
|
||||
trading_config_ok: tradingConfig.ok,
|
||||
trading_config_block_reason: tradingConfig.block_reason,
|
||||
last_error: state.last_error,
|
||||
reason: tradingConfig.ok === true
|
||||
? null
|
||||
: tradingConfig.block_reason || 'trading config unavailable',
|
||||
};
|
||||
},
|
||||
},
|
||||
|
|
@ -357,19 +374,20 @@ const controlApi = startControlApi({
|
|||
});
|
||||
|
||||
async function refreshPortfolioMetrics() {
|
||||
const tradingConfig = await requireTradingConfig();
|
||||
const inputs = await loadPortfolioMetricInputs(pool, {
|
||||
btcAsset: config.tradingBtc,
|
||||
btcAssets: config.tradingBtcAssets,
|
||||
eureAsset: config.tradingEure,
|
||||
btcAsset: tradingConfig.tradingBtc,
|
||||
btcAssets: tradingConfig.tradingBtcAssets,
|
||||
eureAsset: tradingConfig.tradingEure,
|
||||
});
|
||||
const payload = computePortfolioMetric({
|
||||
baseline: inputs.baseline,
|
||||
currentInventory: inputs.currentInventory?.payload,
|
||||
currentPrice: inputs.currentPrice?.payload,
|
||||
externalFlows: inputs.externalFlows || [],
|
||||
btcAsset: config.tradingBtc,
|
||||
btcAssets: config.tradingBtcAssets,
|
||||
eureAsset: config.tradingEure,
|
||||
btcAsset: tradingConfig.tradingBtc,
|
||||
btcAssets: tradingConfig.tradingBtcAssets,
|
||||
eureAsset: tradingConfig.tradingEure,
|
||||
commandCount: inputs.commandCount,
|
||||
resultCount: inputs.resultCount,
|
||||
});
|
||||
|
|
@ -401,9 +419,10 @@ async function refreshPortfolioMetrics() {
|
|||
}
|
||||
|
||||
async function refreshIntentRequestOutcomeAttributions() {
|
||||
const tradingConfig = await requireTradingConfig();
|
||||
const records = await refreshIntentRequestOutcomes(pool, {
|
||||
btcAsset: config.tradingBtc,
|
||||
eureAsset: config.tradingEure,
|
||||
btcAsset: tradingConfig.tradingBtc,
|
||||
eureAsset: tradingConfig.tradingEure,
|
||||
});
|
||||
state.last_intent_request_outcomes_at = new Date().toISOString();
|
||||
state.latest_intent_request_outcomes = {
|
||||
|
|
@ -417,9 +436,10 @@ async function refreshIntentRequestOutcomeAttributions() {
|
|||
}
|
||||
|
||||
async function refreshQuoteOutcomeAttributions() {
|
||||
const tradingConfig = await requireTradingConfig();
|
||||
const records = await refreshQuoteOutcomes(pool, {
|
||||
btcAsset: config.tradingBtc,
|
||||
eureAsset: config.tradingEure,
|
||||
btcAsset: tradingConfig.tradingBtc,
|
||||
eureAsset: tradingConfig.tradingEure,
|
||||
});
|
||||
state.last_quote_outcomes_at = new Date().toISOString();
|
||||
state.quote_outcomes_error = null;
|
||||
|
|
@ -432,6 +452,14 @@ async function refreshQuoteOutcomeAttributions() {
|
|||
return records;
|
||||
}
|
||||
|
||||
async function requireTradingConfig() {
|
||||
const tradingConfig = await tradingConfigStore.getConfig();
|
||||
if (!tradingConfig.ok || !tradingConfig.tradingBtc || !tradingConfig.tradingEure) {
|
||||
throw new Error(`trading config unavailable: ${tradingConfig.blockReason || 'missing current assets'}`);
|
||||
}
|
||||
return tradingConfig;
|
||||
}
|
||||
|
||||
async function publishLiquidityNotification({ topic, event }) {
|
||||
if (topic !== config.kafkaTopicOpsLiquidityAction) return;
|
||||
const notification = buildLiquidityActionNotification({ event, config });
|
||||
|
|
|
|||
|
|
@ -18,6 +18,12 @@ import {
|
|||
assertLiquidityActionEvent,
|
||||
} from '../core/schemas.mjs';
|
||||
import { loadConfig } from '../lib/config.mjs';
|
||||
import {
|
||||
createPostgresPool,
|
||||
createTradingConfigStore,
|
||||
ensureHistorySchema,
|
||||
seedTradingConfig,
|
||||
} from '../lib/postgres.mjs';
|
||||
import { createNearBridgeClient } from '../venues/near-intents/bridge-client.mjs';
|
||||
import { createVerifierClient } from '../venues/near-intents/verifier-client.mjs';
|
||||
|
||||
|
|
@ -48,12 +54,17 @@ const producer = await createProducer({
|
|||
clientId: config.kafkaClientId,
|
||||
logger,
|
||||
});
|
||||
const configPool = createPostgresPool({
|
||||
connectionString: config.postgresUrl,
|
||||
});
|
||||
await ensureHistorySchema(configPool);
|
||||
await seedTradingConfig(configPool);
|
||||
const tradingConfigStore = createTradingConfigStore({
|
||||
pool: configPool,
|
||||
logger: logger.child({ component: 'trading-config' }),
|
||||
});
|
||||
await tradingConfigStore.forceRefresh();
|
||||
|
||||
const chains = uniqueChainsForAssets(config.trackedAssets);
|
||||
const fallbackAssetByChain = new Map([
|
||||
[config.tradingBtc.chain, config.tradingBtc.assetId],
|
||||
[config.tradingEure.chain, config.tradingEure.assetId],
|
||||
]);
|
||||
const consumer = await createConsumer({
|
||||
groupId: config.kafkaConsumerGroupInventory,
|
||||
brokers: config.kafkaBrokers,
|
||||
|
|
@ -126,9 +137,15 @@ async function refresh() {
|
|||
if (state.paused) return;
|
||||
|
||||
try {
|
||||
const tradingConfig = await tradingConfigStore.getConfig();
|
||||
if (!tradingConfig.ok) throw new Error(`trading config unavailable: ${tradingConfig.blockReason}`);
|
||||
const chains = uniqueChainsForAssets(tradingConfig.trackedAssets);
|
||||
const fallbackAssetByChain = new Map(
|
||||
tradingConfig.trackedAssets.map((asset) => [asset.chain, asset.assetId]),
|
||||
);
|
||||
const balances = await verifierClient.mtBatchBalanceOf({
|
||||
accountId: config.nearIntentsAccountId,
|
||||
tokenIds: config.trackedAssetIds,
|
||||
tokenIds: tradingConfig.trackedAssetIds,
|
||||
});
|
||||
const recentDeposits = [];
|
||||
for (const chain of chains) {
|
||||
|
|
@ -138,7 +155,7 @@ async function refresh() {
|
|||
});
|
||||
for (const deposit of response?.deposits || []) {
|
||||
const assetId = bridgeDepositAssetId(deposit, {
|
||||
assetRegistry: config.assetRegistry,
|
||||
assetRegistry: tradingConfig.assetRegistry,
|
||||
fallbackAssetId: fallbackAssetByChain.get(chain),
|
||||
});
|
||||
recentDeposits.push({
|
||||
|
|
@ -163,7 +180,7 @@ async function refresh() {
|
|||
balances,
|
||||
recentDeposits,
|
||||
trackedWithdrawals: Object.values(state.tracked_withdrawals),
|
||||
assetRegistry: config.assetRegistry,
|
||||
assetRegistry: tradingConfig.assetRegistry,
|
||||
observedAt: new Date().toISOString(),
|
||||
});
|
||||
state.last_snapshot = snapshot;
|
||||
|
|
@ -184,7 +201,7 @@ async function refresh() {
|
|||
state.last_error = serializeError(error);
|
||||
logger.error('inventory_refresh_failed', {
|
||||
topic: config.kafkaTopicStateIntentInventory,
|
||||
pair: config.activePair,
|
||||
pair: tradingConfig.activePair,
|
||||
details: {
|
||||
error: serializeError(error),
|
||||
},
|
||||
|
|
@ -206,6 +223,7 @@ const controlApi = startControlApi({
|
|||
getState() {
|
||||
return {
|
||||
account_id: config.nearIntentsAccountId,
|
||||
trading_config: tradingConfigStore.getState(),
|
||||
...state,
|
||||
funding_visibility: buildFundingVisibility(
|
||||
Object.values(state.funding_observations),
|
||||
|
|
@ -248,6 +266,7 @@ async function shutdown() {
|
|||
await controlApi.close().catch(() => {});
|
||||
await consumer.disconnect();
|
||||
await producer.disconnect();
|
||||
await configPool.end().catch(() => {});
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -22,6 +22,12 @@ import { buildBridgeWithdrawalPlan } from '../core/liquidity-withdrawals.mjs';
|
|||
import { createLogger, serializeError } from '../core/log.mjs';
|
||||
import { assertFundingObservationEvent, assertLiquidityActionEvent } from '../core/schemas.mjs';
|
||||
import { loadConfig } from '../lib/config.mjs';
|
||||
import {
|
||||
createPostgresPool,
|
||||
createTradingConfigStore,
|
||||
ensureHistorySchema,
|
||||
seedTradingConfig,
|
||||
} from '../lib/postgres.mjs';
|
||||
import { createBtcAddressObserver } from '../observers/btc-address-observer.mjs';
|
||||
import { createNearBridgeClient } from '../venues/near-intents/bridge-client.mjs';
|
||||
import { createVerifierClient } from '../venues/near-intents/verifier-client.mjs';
|
||||
|
|
@ -48,6 +54,26 @@ const producer = await createProducer({
|
|||
clientId: config.kafkaClientId,
|
||||
logger,
|
||||
});
|
||||
const configPool = createPostgresPool({
|
||||
connectionString: config.postgresUrl,
|
||||
});
|
||||
await ensureHistorySchema(configPool);
|
||||
await seedTradingConfig(configPool);
|
||||
const tradingConfigStore = createTradingConfigStore({
|
||||
pool: configPool,
|
||||
logger: logger.child({ component: 'trading-config' }),
|
||||
});
|
||||
const initialTradingConfig = await tradingConfigStore.forceRefresh();
|
||||
const runtimeConfig = {
|
||||
...config,
|
||||
...initialTradingConfig,
|
||||
assetRegistry: initialTradingConfig.assetRegistry || config.assetRegistry,
|
||||
trackedAssets: initialTradingConfig.trackedAssets?.length
|
||||
? initialTradingConfig.trackedAssets
|
||||
: config.trackedAssets,
|
||||
tradingBtc: initialTradingConfig.tradingBtc || config.tradingBtc,
|
||||
tradingEure: initialTradingConfig.tradingEure || config.tradingEure,
|
||||
};
|
||||
const bridgeClient = createNearBridgeClient({ rpcUrl: config.nearBridgeRpcUrl });
|
||||
const verifierClient = createVerifierClient({
|
||||
nearRpcUrl: config.nearRpcUrl,
|
||||
|
|
@ -89,15 +115,15 @@ const store = createJsonStateStore({
|
|||
},
|
||||
});
|
||||
|
||||
const chains = uniqueChainsForAssets(config.trackedAssets);
|
||||
const trackedAssetsByChain = groupAssetsByChain(config.trackedAssets);
|
||||
const chains = uniqueChainsForAssets(runtimeConfig.trackedAssets);
|
||||
const trackedAssetsByChain = groupAssetsByChain(runtimeConfig.trackedAssets);
|
||||
const fallbackAssetByChain = new Map([
|
||||
[config.tradingBtc.chain, config.tradingBtc.assetId],
|
||||
[config.tradingEure.chain, config.tradingEure.assetId],
|
||||
[runtimeConfig.tradingBtc.chain, runtimeConfig.tradingBtc.assetId],
|
||||
[runtimeConfig.tradingEure.chain, runtimeConfig.tradingEure.assetId],
|
||||
]);
|
||||
|
||||
const fundingObserverByChain = new Map(
|
||||
btcAddressObserver ? [[config.tradingBtc.chain, btcAddressObserver]] : [],
|
||||
btcAddressObserver ? [[runtimeConfig.tradingBtc.chain, btcAddressObserver]] : [],
|
||||
);
|
||||
|
||||
async function refresh() {
|
||||
|
|
@ -401,7 +427,7 @@ async function estimateWithdrawal({ assetId, amount, destinationAddress, chain =
|
|||
destinationAddress,
|
||||
chain,
|
||||
supportedTokens: state.supported_tokens,
|
||||
config,
|
||||
config: runtimeConfig,
|
||||
});
|
||||
const estimate = await bridgeClient.withdrawalEstimate({
|
||||
chain: plan.chain,
|
||||
|
|
@ -783,6 +809,7 @@ async function shutdown() {
|
|||
clearInterval(timer);
|
||||
await controlApi.close().catch(() => {});
|
||||
await producer.disconnect();
|
||||
await configPool.end().catch(() => {});
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
|
|
@ -800,7 +827,7 @@ function mapSupportedTokens(tokens) {
|
|||
|
||||
function mapDepositAssetId(deposit, chain) {
|
||||
return bridgeDepositAssetId(deposit, {
|
||||
assetRegistry: config.assetRegistry,
|
||||
assetRegistry: runtimeConfig.assetRegistry,
|
||||
fallbackAssetId: fallbackAssetByChain.get(chain),
|
||||
});
|
||||
}
|
||||
|
|
@ -831,9 +858,10 @@ function buildPublicState() {
|
|||
|
||||
return {
|
||||
account_id: config.nearIntentsAccountId,
|
||||
tracked_assets: config.trackedAssets,
|
||||
tracked_assets: runtimeConfig.trackedAssets,
|
||||
trading_config: tradingConfigStore.getState(),
|
||||
withdrawal_defaults: Object.fromEntries(
|
||||
config.trackedAssets.map((asset) => [asset.assetId, asset.withdrawAddress || null]),
|
||||
runtimeConfig.trackedAssets.map((asset) => [asset.assetId, asset.withdrawAddress || null]),
|
||||
),
|
||||
...state,
|
||||
observer_health: buildObserverHealth(state.observer_health, {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,12 @@ import { createLogger, serializeError } from '../core/log.mjs';
|
|||
import { assertMarketPriceEvent } from '../core/schemas.mjs';
|
||||
import { fetchCoinGeckoBtcEur, fetchKrakenBtcEur } from '../lib/market-data.mjs';
|
||||
import { loadConfig } from '../lib/config.mjs';
|
||||
import {
|
||||
createPostgresPool,
|
||||
createTradingConfigStore,
|
||||
ensureHistorySchema,
|
||||
seedTradingConfig,
|
||||
} from '../lib/postgres.mjs';
|
||||
|
||||
const config = loadConfig();
|
||||
const logger = createLogger({
|
||||
|
|
@ -21,6 +27,16 @@ const producer = await createProducer({
|
|||
clientId: config.kafkaClientId,
|
||||
logger,
|
||||
});
|
||||
const configPool = createPostgresPool({
|
||||
connectionString: config.postgresUrl,
|
||||
});
|
||||
await ensureHistorySchema(configPool);
|
||||
await seedTradingConfig(configPool);
|
||||
const tradingConfigStore = createTradingConfigStore({
|
||||
pool: configPool,
|
||||
logger: logger.child({ component: 'trading-config' }),
|
||||
});
|
||||
await tradingConfigStore.forceRefresh();
|
||||
|
||||
const state = {
|
||||
paused: false,
|
||||
|
|
@ -43,7 +59,7 @@ async function refresh() {
|
|||
const now = Date.now();
|
||||
await refreshKraken(now).catch((error) => {
|
||||
logger.warn('kraken_refresh_failed', {
|
||||
pair: config.activePair,
|
||||
pair: tradingConfigStore.getState().active_pair,
|
||||
details: {
|
||||
error: serializeError(error),
|
||||
},
|
||||
|
|
@ -54,7 +70,9 @@ async function refresh() {
|
|||
coingeckoDueAt = now + config.marketReferenceCoinGeckoRefreshMs;
|
||||
}
|
||||
|
||||
const event = buildPriceEvent(now);
|
||||
const tradingConfig = await tradingConfigStore.getConfig();
|
||||
if (!tradingConfig.ok) throw new Error(`trading config unavailable: ${tradingConfig.blockReason}`);
|
||||
const event = buildPriceEvent(now, { tradingConfig });
|
||||
assertMarketPriceEvent(event);
|
||||
await producer.sendJson(config.kafkaTopicRefMarketPrice, event, { key: event.payload.price_id });
|
||||
state.last_published_at = new Date(now).toISOString();
|
||||
|
|
@ -65,7 +83,7 @@ async function refresh() {
|
|||
state.last_publish_error = serializeError(error);
|
||||
logger.error('reference_refresh_failed', {
|
||||
topic: config.kafkaTopicRefMarketPrice,
|
||||
pair: config.activePair,
|
||||
pair: tradingConfigStore.getState().active_pair,
|
||||
details: {
|
||||
error: serializeError(error),
|
||||
},
|
||||
|
|
@ -110,7 +128,7 @@ async function refreshCoinGecko(now) {
|
|||
error: serializeError(error),
|
||||
};
|
||||
logger.warn('coingecko_refresh_failed', {
|
||||
pair: config.activePair,
|
||||
pair: tradingConfigStore.getState().active_pair,
|
||||
details: {
|
||||
error: serializeError(error),
|
||||
},
|
||||
|
|
@ -118,9 +136,13 @@ async function refreshCoinGecko(now) {
|
|||
}
|
||||
}
|
||||
|
||||
function buildPriceEvent(now) {
|
||||
function buildPriceEvent(now, { tradingConfig }) {
|
||||
const sourceUsed = chooseSource(now);
|
||||
if (!sourceUsed) throw new Error('No fresh reference price available');
|
||||
const referencePair = tradingConfig.pairs.find((pair) => (
|
||||
pair.priceRoute?.source === 'btc_eur_reference' && pair.canTrade
|
||||
));
|
||||
if (!referencePair) throw new Error('No DB-enabled BTC/EUR price route available');
|
||||
|
||||
const eurPerBtc = sourceUsed === 'kraken'
|
||||
? state.kraken.price
|
||||
|
|
@ -137,7 +159,11 @@ function buildPriceEvent(now) {
|
|||
observedAt: new Date(now).toISOString(),
|
||||
payload: {
|
||||
price_id: `price-${now}`,
|
||||
pair: config.activePair,
|
||||
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,
|
||||
eur_per_btc: eurPerBtc.toFixed(8),
|
||||
eure_per_btc: eurPerBtc.toFixed(8),
|
||||
btc_per_eur: btcPerEur.toFixed(12),
|
||||
|
|
@ -189,7 +215,23 @@ const controlApi = startControlApi({
|
|||
getState() {
|
||||
return {
|
||||
...state,
|
||||
active_pair: config.activePair,
|
||||
active_pair: tradingConfigStore.getState().active_pair,
|
||||
trading_config: tradingConfigStore.getState(),
|
||||
};
|
||||
},
|
||||
},
|
||||
healthProvider: {
|
||||
getHealth() {
|
||||
const tradingConfig = tradingConfigStore.getState();
|
||||
return {
|
||||
ok: tradingConfig.ok === true && !state.last_publish_error,
|
||||
trading_config_ok: tradingConfig.ok,
|
||||
trading_config_block_reason: tradingConfig.block_reason,
|
||||
paused: state.paused,
|
||||
last_published_at: state.last_published_at,
|
||||
reason: tradingConfig.ok !== true
|
||||
? tradingConfig.block_reason || 'trading config unavailable'
|
||||
: state.last_publish_error?.message || null,
|
||||
};
|
||||
},
|
||||
},
|
||||
|
|
@ -211,7 +253,7 @@ const controlApi = startControlApi({
|
|||
handler: () => {
|
||||
state.paused = true;
|
||||
logger.warn('polling_paused', {
|
||||
pair: config.activePair,
|
||||
pair: tradingConfigStore.getState().active_pair,
|
||||
});
|
||||
return { ok: true, paused: true };
|
||||
},
|
||||
|
|
@ -222,7 +264,7 @@ const controlApi = startControlApi({
|
|||
handler: async () => {
|
||||
state.paused = false;
|
||||
logger.info('polling_resumed', {
|
||||
pair: config.activePair,
|
||||
pair: tradingConfigStore.getState().active_pair,
|
||||
});
|
||||
await refresh();
|
||||
return { ok: true, paused: false };
|
||||
|
|
@ -235,6 +277,7 @@ async function shutdown() {
|
|||
clearInterval(timer);
|
||||
await controlApi.close().catch(() => {});
|
||||
await producer.disconnect();
|
||||
await configPool.end().catch(() => {});
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,8 +3,13 @@ import process from 'node:process';
|
|||
import { createProducer } from '../bus/kafka/producer.mjs';
|
||||
import { startControlApi } from '../core/control-api.mjs';
|
||||
import { createLogger } from '../core/log.mjs';
|
||||
import { createPairFilterController } from '../core/pair-filter.mjs';
|
||||
import { loadConfig } from '../lib/config.mjs';
|
||||
import {
|
||||
createPostgresPool,
|
||||
createTradingConfigStore,
|
||||
ensureHistorySchema,
|
||||
seedTradingConfig,
|
||||
} from '../lib/postgres.mjs';
|
||||
import { startNearIntentsWs } from '../venues/near-intents/ws.mjs';
|
||||
import { ageMs } from '../core/runtime-health.mjs';
|
||||
|
||||
|
|
@ -14,17 +19,20 @@ const logger = createLogger({
|
|||
component: 'ingest',
|
||||
namespace: config.projectNamespace,
|
||||
});
|
||||
const pairFilterController = createPairFilterController({
|
||||
argv: process.argv.slice(2),
|
||||
env: process.env,
|
||||
defaultPairFilter: config.nearIntentsPairFilter,
|
||||
pairFilterFile: config.nearIntentsPairFilterFile,
|
||||
reloadEveryMs: config.nearIntentsPairFilterReloadMs,
|
||||
void process.argv;
|
||||
|
||||
const configPool = createPostgresPool({
|
||||
connectionString: config.postgresUrl,
|
||||
});
|
||||
await ensureHistorySchema(configPool);
|
||||
await seedTradingConfig(configPool);
|
||||
const tradingConfigStore = createTradingConfigStore({
|
||||
pool: configPool,
|
||||
logger: logger.child({
|
||||
component: 'filter',
|
||||
venue: 'near-intents',
|
||||
component: 'trading-config',
|
||||
}),
|
||||
});
|
||||
await tradingConfigStore.forceRefresh();
|
||||
|
||||
if (!config.nearIntentsApiKey) {
|
||||
logger.error('missing_api_key', {
|
||||
|
|
@ -45,7 +53,11 @@ const producer = await createProducer({
|
|||
const wsRuntime = await startNearIntentsWs({
|
||||
apiKey: config.nearIntentsApiKey,
|
||||
wsUrl: config.nearIntentsWsUrl,
|
||||
getPairFilter: () => pairFilterController.getPairFilter(),
|
||||
matchesPair: async (assetIn, assetOut) => {
|
||||
const tradingConfig = await tradingConfigStore.getConfig();
|
||||
if (!tradingConfig.ok) return false;
|
||||
return tradingConfig.enabledPairKeys.has(`${assetIn}->${assetOut}`);
|
||||
},
|
||||
producer,
|
||||
rawTopic: config.kafkaTopicRawNearIntentsQuote,
|
||||
normalizedTopic: config.kafkaTopicNormSwapDemand,
|
||||
|
|
@ -69,7 +81,7 @@ const controlApi = config.nearIntentsControlApiEnabled
|
|||
stateProvider: {
|
||||
getState() {
|
||||
return {
|
||||
pair_filter: pairFilterController.getState(),
|
||||
trading_config: tradingConfigStore.getState(),
|
||||
ingest: wsRuntime.getState(),
|
||||
};
|
||||
},
|
||||
|
|
@ -77,19 +89,26 @@ const controlApi = config.nearIntentsControlApiEnabled
|
|||
healthProvider: {
|
||||
getHealth() {
|
||||
const ingest = wsRuntime.getState();
|
||||
const tradingConfig = tradingConfigStore.getState();
|
||||
const lastTruthAt = ingest.last_published_at || ingest.last_matching_quote_at || ingest.last_message_at;
|
||||
const freshnessAgeMs = ageMs(lastTruthAt);
|
||||
const staleAfterMs = config.opsSentinelIngestQuoteStaleMs;
|
||||
return {
|
||||
ok: Boolean(ingest.connected) && (freshnessAgeMs == null || freshnessAgeMs <= staleAfterMs),
|
||||
ok: Boolean(ingest.connected)
|
||||
&& tradingConfig.ok === true
|
||||
&& (freshnessAgeMs == null || freshnessAgeMs <= staleAfterMs),
|
||||
connected: ingest.connected,
|
||||
trading_config_ok: tradingConfig.ok,
|
||||
trading_config_block_reason: tradingConfig.block_reason,
|
||||
last_message_at: ingest.last_message_at,
|
||||
last_matching_quote_at: ingest.last_matching_quote_at,
|
||||
last_published_at: ingest.last_published_at,
|
||||
freshness_age_ms: freshnessAgeMs,
|
||||
stale_after_ms: staleAfterMs,
|
||||
reason:
|
||||
ingest.connected
|
||||
tradingConfig.ok !== true
|
||||
? tradingConfig.block_reason || 'trading config unavailable'
|
||||
: ingest.connected
|
||||
? freshnessAgeMs != null && freshnessAgeMs > staleAfterMs
|
||||
? 'quote truth stale'
|
||||
: null
|
||||
|
|
@ -108,34 +127,33 @@ const controlApi = config.nearIntentsControlApiEnabled
|
|||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/pair-filter',
|
||||
path: '/pairs',
|
||||
readBody: false,
|
||||
handler: () => pairFilterController.getState(),
|
||||
},
|
||||
{
|
||||
method: 'PUT',
|
||||
path: '/pair-filter',
|
||||
handler: ({ body }) => {
|
||||
if (body.disabled === true || body.enabled === false || body.pair == null) {
|
||||
return pairFilterController.disable();
|
||||
}
|
||||
|
||||
if (typeof body.pair !== 'string') {
|
||||
handler: async () => {
|
||||
const tradingConfig = await tradingConfigStore.forceRefresh();
|
||||
return {
|
||||
statusCode: 400,
|
||||
payload: {
|
||||
error: 'send JSON like {"pair":"asset_a->asset_b"} or {"pair":null}',
|
||||
},
|
||||
ok: tradingConfig.ok,
|
||||
pairs: tradingConfig.observedPairs.map((pair) => ({
|
||||
pair_id: pair.pairId,
|
||||
pair: pair.key,
|
||||
mode: pair.mode,
|
||||
status: pair.status,
|
||||
can_trade: pair.canTrade,
|
||||
block_reason: pair.blockReason,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
return pairFilterController.setPairFilter(body.pair);
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/pair-filter/reset',
|
||||
handler: () => pairFilterController.reset(),
|
||||
path: '/config/refresh',
|
||||
handler: async () => {
|
||||
const tradingConfig = await tradingConfigStore.forceRefresh();
|
||||
return {
|
||||
ok: tradingConfig.ok,
|
||||
trading_config: tradingConfigStore.getState(),
|
||||
};
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
|
@ -144,8 +162,8 @@ const controlApi = config.nearIntentsControlApiEnabled
|
|||
async function shutdown() {
|
||||
controlApi && await controlApi.close().catch(() => {});
|
||||
wsRuntime.close();
|
||||
pairFilterController.close();
|
||||
await producer.disconnect();
|
||||
await configPool.end().catch(() => {});
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -29,12 +29,18 @@ import { readJsonBody, sendJson } from '../core/control-api.mjs';
|
|||
import { loadConfig } from '../lib/config.mjs';
|
||||
import { fetchJson } from '../lib/http.mjs';
|
||||
import {
|
||||
createPairStrategyConfigVersion,
|
||||
createPostgresPool,
|
||||
createTradingConfigStore,
|
||||
enableObserveOnlyPair,
|
||||
ensureHistorySchema,
|
||||
importSupportedAssets,
|
||||
loadAssetCatalogSummary,
|
||||
loadCurrentFundingObservations,
|
||||
loadLatestInventorySnapshot,
|
||||
loadLatestMarketPrice,
|
||||
loadLatestPortfolioMetric,
|
||||
loadPairConfigSummary,
|
||||
loadRecentAlertTransitions,
|
||||
loadRecentDepositStatuses,
|
||||
loadRecentEnvironmentStatuses,
|
||||
|
|
@ -46,6 +52,7 @@ import {
|
|||
loadRecentQuotes,
|
||||
loadSubmissionPage,
|
||||
loadSubmissionSummary,
|
||||
seedTradingConfig,
|
||||
} from '../lib/postgres.mjs';
|
||||
|
||||
const config = loadConfig();
|
||||
|
|
@ -80,6 +87,13 @@ const pool = createPostgresPool({
|
|||
connectionString: config.postgresUrl,
|
||||
});
|
||||
await ensureHistorySchema(pool);
|
||||
await seedTradingConfig(pool);
|
||||
const tradingConfigStore = createTradingConfigStore({
|
||||
pool,
|
||||
logger: logger.child({ component: 'trading-config' }),
|
||||
});
|
||||
const initialTradingConfig = await tradingConfigStore.forceRefresh();
|
||||
const initialRuntimeConfig = buildRuntimeConfig(initialTradingConfig);
|
||||
|
||||
const staticAssets = await loadStaticAssets();
|
||||
const initialServiceSnapshots = await loadServiceSnapshots();
|
||||
|
|
@ -127,12 +141,12 @@ const initialRecentQuoteOutcomes = await safeSourceLoad(
|
|||
);
|
||||
const initialNearIntentsStatus = await safeSourceLoad(
|
||||
'near_intents_status',
|
||||
() => loadNearIntentsStatus(),
|
||||
() => loadNearIntentsStatus(initialRuntimeConfig),
|
||||
null,
|
||||
);
|
||||
|
||||
const liveState = createDashboardLiveState({
|
||||
config,
|
||||
config: initialRuntimeConfig,
|
||||
recentQuotes: initialRecentQuotes,
|
||||
recentTradeDecisions: initialRecentTradeDecisions,
|
||||
recentExecuteTradeCommands: initialRecentExecuteTradeCommands,
|
||||
|
|
@ -361,7 +375,9 @@ async function handleApiRequest({ req, res, url, auth }) {
|
|||
.find((definition) => definition.service === control.service);
|
||||
try {
|
||||
const result = await invokeControl(control, body || {});
|
||||
const serviceSnapshot = await loadServiceSnapshot(serviceDefinition);
|
||||
const serviceSnapshot = serviceDefinition
|
||||
? await loadServiceSnapshot(serviceDefinition)
|
||||
: null;
|
||||
return sendJson(res, 200, {
|
||||
ok: true,
|
||||
control,
|
||||
|
|
@ -375,13 +391,15 @@ async function handleApiRequest({ req, res, url, auth }) {
|
|||
error: serializeError(error),
|
||||
},
|
||||
});
|
||||
const serviceSnapshot = await loadServiceSnapshot(serviceDefinition).catch((snapshotError) => ({
|
||||
const serviceSnapshot = serviceDefinition
|
||||
? await loadServiceSnapshot(serviceDefinition).catch((snapshotError) => ({
|
||||
...serviceDefinition,
|
||||
reachable: false,
|
||||
state: null,
|
||||
health: null,
|
||||
error: serializeError(snapshotError),
|
||||
}));
|
||||
}))
|
||||
: null;
|
||||
const failure = buildDashboardControlErrorResponse(error, { control });
|
||||
return sendJson(res, failure.statusCode, {
|
||||
...failure.payload,
|
||||
|
|
@ -395,6 +413,8 @@ async function handleApiRequest({ req, res, url, auth }) {
|
|||
|
||||
async function loadBootstrapPayload({ auth, page, pageSize }) {
|
||||
const sourceErrors = [];
|
||||
const tradingConfig = await tradingConfigStore.forceRefresh();
|
||||
const runtimeConfig = buildRuntimeConfig(tradingConfig);
|
||||
const [
|
||||
portfolioMetric,
|
||||
inventorySnapshot,
|
||||
|
|
@ -411,6 +431,8 @@ async function loadBootstrapPayload({ auth, page, pageSize }) {
|
|||
recentIntentRequests,
|
||||
recentAlertTransitions,
|
||||
recentEnvironmentStatuses,
|
||||
assetCatalog,
|
||||
pairConfig,
|
||||
serviceSnapshots,
|
||||
nearIntentsStatus,
|
||||
] = await Promise.all([
|
||||
|
|
@ -420,7 +442,7 @@ async function loadBootstrapPayload({ auth, page, pageSize }) {
|
|||
safeSourceLoad(
|
||||
'recent_quotes',
|
||||
() => loadRecentQuotes(pool, {
|
||||
limit: config.operatorDashboardQuoteLimit,
|
||||
limit: runtimeConfig.operatorDashboardQuoteLimit,
|
||||
}),
|
||||
[],
|
||||
sourceErrors,
|
||||
|
|
@ -481,8 +503,8 @@ async function loadBootstrapPayload({ auth, page, pageSize }) {
|
|||
'recent_intent_requests',
|
||||
() => loadRecentIntentRequests(pool, {
|
||||
limit: 20,
|
||||
btcAsset: config.tradingBtc,
|
||||
eureAsset: config.tradingEure,
|
||||
btcAsset: runtimeConfig.tradingBtc,
|
||||
eureAsset: runtimeConfig.tradingEure,
|
||||
}),
|
||||
[],
|
||||
sourceErrors,
|
||||
|
|
@ -499,12 +521,14 @@ async function loadBootstrapPayload({ auth, page, pageSize }) {
|
|||
[],
|
||||
sourceErrors,
|
||||
),
|
||||
safeSourceLoad('asset_catalog', () => loadAssetCatalogSummary(pool, { limit: 80 }), null, sourceErrors),
|
||||
safeSourceLoad('pair_config', () => loadPairConfigSummary(pool), null, sourceErrors),
|
||||
loadServiceSnapshots(),
|
||||
safeSourceLoad('near_intents_status', () => loadNearIntentsStatus(), null, sourceErrors),
|
||||
safeSourceLoad('near_intents_status', () => loadNearIntentsStatus(runtimeConfig), null, sourceErrors),
|
||||
]);
|
||||
|
||||
const payload = buildDashboardBootstrap({
|
||||
config,
|
||||
config: runtimeConfig,
|
||||
auth,
|
||||
portfolioMetric,
|
||||
inventorySnapshot,
|
||||
|
|
@ -521,6 +545,8 @@ async function loadBootstrapPayload({ auth, page, pageSize }) {
|
|||
recentIntentRequests,
|
||||
recentAlertTransitions,
|
||||
recentEnvironmentStatuses,
|
||||
assetCatalog,
|
||||
pairConfig,
|
||||
serviceSnapshots,
|
||||
nearIntentsStatus,
|
||||
sourceErrors,
|
||||
|
|
@ -564,7 +590,7 @@ async function fetchUpstreamJson(url) {
|
|||
});
|
||||
}
|
||||
|
||||
async function loadNearIntentsStatus() {
|
||||
async function loadNearIntentsStatus(runtimeConfig = config) {
|
||||
const [servicesResponse, postsResponse, postEnumsResponse] = await Promise.all([
|
||||
fetchNearIntentsStatusJson(config.nearIntentsStatusServicesUrl),
|
||||
fetchNearIntentsStatusJson(config.nearIntentsStatusPostsUrl),
|
||||
|
|
@ -576,7 +602,7 @@ async function loadNearIntentsStatus() {
|
|||
postsResponse,
|
||||
postEnumsResponse,
|
||||
observedAt: new Date().toISOString(),
|
||||
trackedAssets: config.trackedAssets,
|
||||
trackedAssets: runtimeConfig.trackedAssets,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -587,6 +613,34 @@ async function fetchNearIntentsStatusJson(url) {
|
|||
}
|
||||
|
||||
async function invokeControl(control, body) {
|
||||
if (control.service === 'operator-dashboard' && control.action === 'import-supported-assets') {
|
||||
const result = await importSupportedAssets(pool);
|
||||
await tradingConfigStore.forceRefresh();
|
||||
return result;
|
||||
}
|
||||
|
||||
if (control.service === 'operator-dashboard' && control.action === 'update-pair-edge') {
|
||||
const result = await createPairStrategyConfigVersion(pool, {
|
||||
pairId: body.pair_id || body.pair,
|
||||
edgeBps: Number(body.edge_bps),
|
||||
changedBy: body.changed_by || 'operator',
|
||||
reason: body.reason || 'dashboard edge update',
|
||||
});
|
||||
await tradingConfigStore.forceRefresh();
|
||||
return result;
|
||||
}
|
||||
|
||||
if (control.service === 'operator-dashboard' && control.action === 'enable-observe-only-pair') {
|
||||
const result = await enableObserveOnlyPair(pool, {
|
||||
assetIn: body.asset_in,
|
||||
assetOut: body.asset_out,
|
||||
changedBy: body.changed_by || 'operator',
|
||||
reason: body.reason || 'dashboard observe-only enable',
|
||||
});
|
||||
await tradingConfigStore.forceRefresh();
|
||||
return result;
|
||||
}
|
||||
|
||||
const response = await fetchJson(
|
||||
`${lookupServiceBaseUrl(control.service)}${control.path}`,
|
||||
{
|
||||
|
|
@ -610,6 +664,22 @@ function lookupServiceBaseUrl(serviceName) {
|
|||
return service.base_url;
|
||||
}
|
||||
|
||||
function buildRuntimeConfig(tradingConfig) {
|
||||
return {
|
||||
...config,
|
||||
...tradingConfig,
|
||||
assetRegistry: tradingConfig.assetRegistry || config.assetRegistry,
|
||||
trackedAssets: tradingConfig.trackedAssets || config.trackedAssets,
|
||||
trackedAssetIds: tradingConfig.trackedAssetIds || config.trackedAssetIds,
|
||||
tradingBtc: tradingConfig.tradingBtc || config.tradingBtc,
|
||||
tradingBtcAssets: tradingConfig.tradingBtcAssets?.length
|
||||
? tradingConfig.tradingBtcAssets
|
||||
: config.tradingBtcAssets,
|
||||
tradingEure: tradingConfig.tradingEure || config.tradingEure,
|
||||
activePair: tradingConfig.activePair || config.activePair,
|
||||
};
|
||||
}
|
||||
|
||||
function broadcast(payload) {
|
||||
const encoded = JSON.stringify(payload);
|
||||
for (const socket of webSockets) {
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import {
|
|||
shouldContainExecutorForAlerts,
|
||||
shouldRaiseIngestPublishStale,
|
||||
} from '../core/runtime-health.mjs';
|
||||
import { summarizeServiceSnapshotForSentinel } from '../core/service-snapshot-summary.mjs';
|
||||
import {
|
||||
assertEnvironmentStatusEvent,
|
||||
assertFundingObservationEvent,
|
||||
|
|
@ -31,14 +32,38 @@ import {
|
|||
} from '../core/schemas.mjs';
|
||||
import { loadConfig } from '../lib/config.mjs';
|
||||
import { fetchJson } from '../lib/http.mjs';
|
||||
import {
|
||||
createPostgresPool,
|
||||
createTradingConfigStore,
|
||||
ensureHistorySchema,
|
||||
seedTradingConfig,
|
||||
} from '../lib/postgres.mjs';
|
||||
|
||||
const config = loadConfig();
|
||||
const thresholds = createRuntimeHealthThresholds(config);
|
||||
const logger = createLogger({
|
||||
service: 'ops-sentinel',
|
||||
component: 'alerts',
|
||||
namespace: config.projectNamespace,
|
||||
});
|
||||
const configPool = createPostgresPool({
|
||||
connectionString: config.postgresUrl,
|
||||
});
|
||||
await ensureHistorySchema(configPool);
|
||||
await seedTradingConfig(configPool);
|
||||
const tradingConfigStore = createTradingConfigStore({
|
||||
pool: configPool,
|
||||
logger: logger.child({ component: 'trading-config' }),
|
||||
});
|
||||
const initialTradingConfig = await tradingConfigStore.forceRefresh();
|
||||
Object.assign(config, {
|
||||
...initialTradingConfig,
|
||||
assetRegistry: initialTradingConfig.assetRegistry || config.assetRegistry,
|
||||
trackedAssets: initialTradingConfig.trackedAssets?.length
|
||||
? initialTradingConfig.trackedAssets
|
||||
: config.trackedAssets,
|
||||
activePair: initialTradingConfig.activePair || config.activePair,
|
||||
});
|
||||
const thresholds = createRuntimeHealthThresholds(config);
|
||||
|
||||
const producer = await createProducer({
|
||||
brokers: config.kafkaBrokers,
|
||||
|
|
@ -183,7 +208,7 @@ const controlApi = startControlApi({
|
|||
last_runtime_eval_at: state.last_runtime_eval_at,
|
||||
service_snapshots: state.service_snapshots,
|
||||
service_health: state.service_health,
|
||||
latest_runtime_alerts: [],
|
||||
latest_runtime_alerts: state.latest_runtime_alerts,
|
||||
near_intents_status: state.near_intents_status,
|
||||
last_environment_status_poll_at: state.last_environment_status_poll_at,
|
||||
last_environment_status_publish_at: state.last_environment_status_publish_at,
|
||||
|
|
@ -193,8 +218,9 @@ const controlApi = startControlApi({
|
|||
environment_status_publish_count: state.environment_status_publish_count,
|
||||
containment: state.containment,
|
||||
notifier: notifier.getState(),
|
||||
trading_config: tradingConfigStore.getState(),
|
||||
anomaly_samples: state.anomaly_samples.slice(-thresholds.anomalyWindowSize),
|
||||
active_alerts: [],
|
||||
active_alerts: state.latest_runtime_alerts,
|
||||
recent_transitions: [],
|
||||
};
|
||||
},
|
||||
|
|
@ -203,13 +229,20 @@ const controlApi = startControlApi({
|
|||
getHealth() {
|
||||
const staleMs = ageMs(state.last_runtime_eval_at);
|
||||
return {
|
||||
ok: !state.paused && (staleMs == null || staleMs <= thresholds.sentinelStaleMs),
|
||||
ok: !state.paused
|
||||
&& tradingConfigStore.getState().ok === true
|
||||
&& (staleMs == null || staleMs <= thresholds.sentinelStaleMs),
|
||||
paused: state.paused,
|
||||
trading_config_ok: tradingConfigStore.getState().ok,
|
||||
trading_config_block_reason: tradingConfigStore.getState().block_reason,
|
||||
last_event_at: state.last_event_at,
|
||||
last_runtime_eval_at: state.last_runtime_eval_at,
|
||||
last_error: state.last_error,
|
||||
stale: staleMs != null && staleMs > thresholds.sentinelStaleMs,
|
||||
stale_after_ms: thresholds.sentinelStaleMs,
|
||||
reason: tradingConfigStore.getState().ok === true
|
||||
? null
|
||||
: tradingConfigStore.getState().block_reason || 'trading config unavailable',
|
||||
};
|
||||
},
|
||||
},
|
||||
|
|
@ -247,7 +280,7 @@ async function evaluateRuntimeHealthLoop() {
|
|||
const now = new Date().toISOString();
|
||||
const previousRuntimeEvalAt = state.last_runtime_eval_at;
|
||||
const serviceSnapshots = await Promise.all(monitoredServices.map(loadServiceSnapshot));
|
||||
state.service_snapshots = serviceSnapshots;
|
||||
state.service_snapshots = serviceSnapshots.map(summarizeServiceSnapshotForSentinel);
|
||||
state.last_runtime_eval_at = now;
|
||||
|
||||
const servicesByName = Object.fromEntries(serviceSnapshots.map((snapshot) => [snapshot.service, snapshot]));
|
||||
|
|
@ -257,10 +290,10 @@ async function evaluateRuntimeHealthLoop() {
|
|||
state.service_health = [...evaluateRuntimeHealth({
|
||||
servicesByName,
|
||||
activePair: config.activePair,
|
||||
activeAlerts: [],
|
||||
activeAlerts: desiredRuntimeAlerts,
|
||||
now,
|
||||
}).values()];
|
||||
state.latest_runtime_alerts = [];
|
||||
state.latest_runtime_alerts = desiredRuntimeAlerts;
|
||||
state.containment.executor_auto_disarmed = null;
|
||||
state.containment.last_action_at = now;
|
||||
state.containment.last_action_reason = 'automatic_executor_containment_disabled';
|
||||
|
|
@ -759,6 +792,7 @@ async function shutdown() {
|
|||
await controlApi.close().catch(() => {});
|
||||
await consumer.disconnect();
|
||||
await producer.disconnect();
|
||||
await configPool.end().catch(() => {});
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,13 @@ import { createLogger, serializeError } from '../core/log.mjs';
|
|||
import { assertInventorySnapshotEvent, assertMarketPriceEvent, assertNormalizedSwapDemand } from '../core/schemas.mjs';
|
||||
import { evaluateTradeOpportunity } from '../core/strategy.mjs';
|
||||
import { loadConfig } from '../lib/config.mjs';
|
||||
import {
|
||||
createPairStrategyConfigVersion,
|
||||
createPostgresPool,
|
||||
createTradingConfigStore,
|
||||
ensureHistorySchema,
|
||||
seedTradingConfig,
|
||||
} from '../lib/postgres.mjs';
|
||||
|
||||
const config = loadConfig();
|
||||
const logger = createLogger({
|
||||
|
|
@ -29,6 +36,16 @@ const producer = await createProducer({
|
|||
clientId: config.kafkaClientId,
|
||||
logger,
|
||||
});
|
||||
const configPool = createPostgresPool({
|
||||
connectionString: config.postgresUrl,
|
||||
});
|
||||
await ensureHistorySchema(configPool);
|
||||
await seedTradingConfig(configPool);
|
||||
const tradingConfigStore = createTradingConfigStore({
|
||||
pool: configPool,
|
||||
logger: logger.child({ component: 'trading-config' }),
|
||||
});
|
||||
await tradingConfigStore.forceRefresh();
|
||||
const armedStateStore = createArmedStateStore({
|
||||
stateDir: config.strategyStateDir,
|
||||
fileName: 'strategy-engine-control.json',
|
||||
|
|
@ -42,8 +59,6 @@ await consumer.subscribe({ topic: config.kafkaTopicStateIntentInventory, fromBeg
|
|||
const state = {
|
||||
armed: armedStateStore.isArmed(),
|
||||
paused: false,
|
||||
threshold_pct: config.strategyGrossThresholdPct,
|
||||
max_notional_eure: config.strategyMaxNotionalEure,
|
||||
latest_price_event: null,
|
||||
latest_inventory_event: null,
|
||||
latest_decision: null,
|
||||
|
|
@ -87,18 +102,25 @@ await consumer.run({
|
|||
|
||||
async function handleDemand(event) {
|
||||
if (state.paused) return;
|
||||
const tradingConfig = await tradingConfigStore.getConfig();
|
||||
|
||||
if (state.seen_quotes[event.payload.quote_id]) {
|
||||
const pair = tradingConfig.pairByKey?.get(event.payload.pair || `${event.payload.asset_in}->${event.payload.asset_out}`);
|
||||
const strategyConfig = pair?.strategyConfig || null;
|
||||
await publishDecision({
|
||||
decision_id: `duplicate-${event.payload.quote_id}`,
|
||||
quote_id: event.payload.quote_id,
|
||||
pair: event.payload.pair || `${event.payload.asset_in}->${event.payload.asset_out}`,
|
||||
pair_id: pair?.pairId || null,
|
||||
pair_config_id: strategyConfig?.configId || null,
|
||||
pair_config_version: strategyConfig?.version == null ? null : String(strategyConfig.version),
|
||||
edge_bps: strategyConfig?.edgeBps == null ? null : String(strategyConfig.edgeBps),
|
||||
direction: 'duplicate',
|
||||
request_kind: event.payload.request_kind,
|
||||
decision: 'rejected',
|
||||
decision_reason: 'duplicate_quote_id',
|
||||
threshold_pct: String(state.threshold_pct),
|
||||
max_notional_eure: String(state.max_notional_eure),
|
||||
threshold_pct: strategyConfig?.edgeBps == null ? null : String(Number(strategyConfig.edgeBps) / 100),
|
||||
max_notional_eure: strategyConfig?.maxNotional == null ? null : String(strategyConfig.maxNotional),
|
||||
strategy_armed: state.armed,
|
||||
});
|
||||
return;
|
||||
|
|
@ -110,10 +132,11 @@ async function handleDemand(event) {
|
|||
demandEvent: event,
|
||||
priceEvent: state.latest_price_event,
|
||||
inventoryEvent: state.latest_inventory_event,
|
||||
config,
|
||||
config: {
|
||||
...config,
|
||||
...tradingConfig,
|
||||
},
|
||||
armed: state.armed,
|
||||
thresholdPct: state.threshold_pct,
|
||||
maxNotionalEure: state.max_notional_eure,
|
||||
});
|
||||
|
||||
await publishDecision(evaluation.decision);
|
||||
|
|
@ -161,17 +184,33 @@ const controlApi = startControlApi({
|
|||
getState() {
|
||||
return {
|
||||
...state,
|
||||
trading_config: tradingConfigStore.getState(),
|
||||
durable_control_state: armedStateStore.getState(),
|
||||
};
|
||||
},
|
||||
},
|
||||
healthProvider: {
|
||||
getHealth() {
|
||||
const tradingConfig = tradingConfigStore.getState();
|
||||
return {
|
||||
ok: tradingConfig.ok === true,
|
||||
trading_config_ok: tradingConfig.ok,
|
||||
trading_config_block_reason: tradingConfig.block_reason,
|
||||
paused: state.paused,
|
||||
armed: state.armed,
|
||||
reason: tradingConfig.ok === true
|
||||
? null
|
||||
: tradingConfig.block_reason || 'trading config unavailable',
|
||||
};
|
||||
},
|
||||
},
|
||||
routes: [
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/arm',
|
||||
handler: () => {
|
||||
state.armed = armedStateStore.setArmed(true).armed;
|
||||
logger.warn('strategy_armed', { pair: config.activePair });
|
||||
logger.warn('strategy_armed', { pair: tradingConfigStore.getState().active_pair });
|
||||
return { ok: true, armed: true };
|
||||
},
|
||||
},
|
||||
|
|
@ -180,7 +219,7 @@ const controlApi = startControlApi({
|
|||
path: '/disarm',
|
||||
handler: () => {
|
||||
state.armed = armedStateStore.setArmed(false).armed;
|
||||
logger.warn('strategy_disarmed', { pair: config.activePair });
|
||||
logger.warn('strategy_disarmed', { pair: tradingConfigStore.getState().active_pair });
|
||||
return { ok: true, armed: false };
|
||||
},
|
||||
},
|
||||
|
|
@ -211,27 +250,23 @@ const controlApi = startControlApi({
|
|||
},
|
||||
},
|
||||
{
|
||||
method: 'PUT',
|
||||
path: '/threshold',
|
||||
handler: ({ body }) => {
|
||||
const next = Number(body.threshold_pct);
|
||||
if (!Number.isFinite(next) || next <= 0) {
|
||||
return { statusCode: 400, payload: { error: 'threshold_pct must be > 0' } };
|
||||
method: 'POST',
|
||||
path: '/pair-config/edge',
|
||||
handler: async ({ body }) => {
|
||||
const pairId = body.pair_id || body.pair;
|
||||
const edgeBps = Number(body.edge_bps);
|
||||
if (!pairId) return { statusCode: 400, payload: { error: 'pair_id is required' } };
|
||||
if (!Number.isInteger(edgeBps) || edgeBps <= 0) {
|
||||
return { statusCode: 400, payload: { error: 'edge_bps must be a positive integer' } };
|
||||
}
|
||||
state.threshold_pct = next;
|
||||
return { ok: true, threshold_pct: next };
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'PUT',
|
||||
path: '/limits',
|
||||
handler: ({ body }) => {
|
||||
const next = Number(body.max_notional_eure);
|
||||
if (!Number.isFinite(next) || next <= 0) {
|
||||
return { statusCode: 400, payload: { error: 'max_notional_eure must be > 0' } };
|
||||
}
|
||||
state.max_notional_eure = next;
|
||||
return { ok: true, max_notional_eure: next };
|
||||
const nextConfig = await createPairStrategyConfigVersion(configPool, {
|
||||
pairId,
|
||||
edgeBps,
|
||||
changedBy: body.changed_by || 'operator',
|
||||
reason: body.reason || 'operator edge update',
|
||||
});
|
||||
await tradingConfigStore.forceRefresh();
|
||||
return { ok: true, config: nextConfig, trading_config: tradingConfigStore.getState() };
|
||||
},
|
||||
},
|
||||
],
|
||||
|
|
@ -241,6 +276,7 @@ async function shutdown() {
|
|||
await controlApi.close().catch(() => {});
|
||||
await consumer.disconnect();
|
||||
await producer.disconnect();
|
||||
await configPool.end().catch(() => {});
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
|
|
|
|||
42
src/apps/supported-token-importer.mjs
Normal file
42
src/apps/supported-token-importer.mjs
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import process from 'node:process';
|
||||
|
||||
import { createLogger, serializeError } from '../core/log.mjs';
|
||||
import { loadConfig } from '../lib/config.mjs';
|
||||
import {
|
||||
createPostgresPool,
|
||||
ensureHistorySchema,
|
||||
importSupportedAssets,
|
||||
seedTradingConfig,
|
||||
} from '../lib/postgres.mjs';
|
||||
|
||||
const config = loadConfig();
|
||||
const logger = createLogger({
|
||||
service: 'supported-token-importer',
|
||||
component: 'asset-registry',
|
||||
namespace: config.projectNamespace,
|
||||
});
|
||||
|
||||
const pool = createPostgresPool({
|
||||
connectionString: config.postgresUrl,
|
||||
});
|
||||
|
||||
try {
|
||||
await ensureHistorySchema(pool);
|
||||
await seedTradingConfig(pool);
|
||||
const result = await importSupportedAssets(pool);
|
||||
logger.info('supported_token_import_completed', {
|
||||
details: result,
|
||||
});
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} catch (error) {
|
||||
logger.error('supported_token_import_failed', {
|
||||
details: {
|
||||
error: serializeError(error),
|
||||
import_run: error.importRun || null,
|
||||
},
|
||||
});
|
||||
if (error.importRun) console.error(JSON.stringify(error.importRun, null, 2));
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
await pool.end().catch(() => {});
|
||||
}
|
||||
|
|
@ -17,6 +17,7 @@ import {
|
|||
import { loadConfig } from '../lib/config.mjs';
|
||||
import {
|
||||
createPostgresPool,
|
||||
createTradingConfigStore,
|
||||
ensureHistorySchema,
|
||||
insertHistoryEvent,
|
||||
loadIntentRequestPreflightByIdOrKey,
|
||||
|
|
@ -25,6 +26,7 @@ import {
|
|||
loadLatestInventorySnapshot,
|
||||
loadLatestMarketPrice,
|
||||
refreshIntentRequestOutcomes,
|
||||
seedTradingConfig,
|
||||
} from '../lib/postgres.mjs';
|
||||
import { buildQuoteResponseSubmission } from '../venues/near-intents/signing.mjs';
|
||||
import { startSolverRelayWs } from '../venues/near-intents/solver-relay-ws.mjs';
|
||||
|
|
@ -78,6 +80,12 @@ const requestPool = createPostgresPool({
|
|||
connectionString: config.postgresUrl,
|
||||
});
|
||||
await ensureHistorySchema(requestPool);
|
||||
await seedTradingConfig(requestPool);
|
||||
const tradingConfigStore = createTradingConfigStore({
|
||||
pool: requestPool,
|
||||
logger: logger.child({ component: 'trading-config' }),
|
||||
});
|
||||
await tradingConfigStore.forceRefresh();
|
||||
const relayClient = await startSolverRelayWs({
|
||||
apiKey: config.nearIntentsApiKey,
|
||||
wsUrl: config.nearIntentsWsUrl,
|
||||
|
|
@ -125,6 +133,7 @@ const requestController = createIntentRequestController({
|
|||
signer,
|
||||
isArmed: () => state.armed,
|
||||
isPaused: () => state.paused,
|
||||
getTradingConfig: () => tradingConfigStore.getConfig(),
|
||||
withMakerSuppressed,
|
||||
logger: logger.child({ component: 'intent-request-controller' }),
|
||||
});
|
||||
|
|
@ -267,6 +276,12 @@ async function publishResult(command, extraPayload) {
|
|||
execution_key: command.execution_key,
|
||||
quote_id: command.quote_id,
|
||||
pair: command.pair,
|
||||
pair_id: command.pair_id || null,
|
||||
pair_config_id: command.pair_config_id || null,
|
||||
pair_config_version: command.pair_config_version || null,
|
||||
edge_bps: command.edge_bps || null,
|
||||
max_notional: command.max_notional || null,
|
||||
price_route_id: command.price_route_id || null,
|
||||
...extraPayload,
|
||||
},
|
||||
});
|
||||
|
|
@ -290,6 +305,7 @@ const controlApi = startControlApi({
|
|||
signer_public_key: signer.getPublicKey().toString(),
|
||||
signer_registered: signerRegistered,
|
||||
relay: relayClient.getState(),
|
||||
trading_config: tradingConfigStore.getState(),
|
||||
...state,
|
||||
durable_control_state: armedStateStore.getState(),
|
||||
durable_state: stateStore.getState(),
|
||||
|
|
@ -301,14 +317,20 @@ const controlApi = startControlApi({
|
|||
const relay = relayClient.getState();
|
||||
const freshnessAgeMs = ageMs(relay.last_message_at);
|
||||
return {
|
||||
ok: relay.connected && (freshnessAgeMs == null || freshnessAgeMs <= config.opsSentinelExecutorRelayStaleMs),
|
||||
ok: relay.connected
|
||||
&& tradingConfigStore.getState().ok === true
|
||||
&& (freshnessAgeMs == null || freshnessAgeMs <= config.opsSentinelExecutorRelayStaleMs),
|
||||
connected: relay.connected,
|
||||
trading_config_ok: tradingConfigStore.getState().ok,
|
||||
trading_config_block_reason: tradingConfigStore.getState().block_reason,
|
||||
relay_last_message_at: relay.last_message_at,
|
||||
relay_freshness_age_ms: freshnessAgeMs,
|
||||
paused: state.paused,
|
||||
armed: state.armed,
|
||||
reason:
|
||||
relay.connected
|
||||
tradingConfigStore.getState().ok !== true
|
||||
? tradingConfigStore.getState().block_reason || 'trading config unavailable'
|
||||
: relay.connected
|
||||
? freshnessAgeMs != null && freshnessAgeMs > config.opsSentinelExecutorRelayStaleMs
|
||||
? 'solver relay stale'
|
||||
: null
|
||||
|
|
@ -433,7 +455,7 @@ function createIntentRequestStore() {
|
|||
event,
|
||||
record: {
|
||||
quote_id: null,
|
||||
pair: payload.source_asset_id + '->' + payload.destination_asset_id,
|
||||
pair: payload.pair || payload.source_asset_id + '->' + payload.destination_asset_id,
|
||||
decision_key: payload.request_id,
|
||||
},
|
||||
});
|
||||
|
|
@ -453,15 +475,21 @@ function createIntentRequestStore() {
|
|||
event,
|
||||
record: {
|
||||
quote_id: null,
|
||||
pair: payload.source_asset_id + '->' + payload.destination_asset_id,
|
||||
pair: payload.pair || payload.source_asset_id + '->' + payload.destination_asset_id,
|
||||
decision_key: payload.request_id,
|
||||
},
|
||||
});
|
||||
},
|
||||
refreshOutcomes: () => refreshIntentRequestOutcomes(requestPool, {
|
||||
btcAsset: config.tradingBtc,
|
||||
eureAsset: config.tradingEure,
|
||||
}),
|
||||
refreshOutcomes: async () => {
|
||||
const tradingConfig = await tradingConfigStore.getConfig();
|
||||
if (!tradingConfig.ok || !tradingConfig.tradingBtc || !tradingConfig.tradingEure) {
|
||||
throw new Error(`trading config unavailable: ${tradingConfig.blockReason || 'missing current assets'}`);
|
||||
}
|
||||
return refreshIntentRequestOutcomes(requestPool, {
|
||||
btcAsset: tradingConfig.tradingBtc,
|
||||
eureAsset: tradingConfig.tradingEure,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ export function createIntentRequestController({
|
|||
signer,
|
||||
isArmed = () => false,
|
||||
isPaused = () => false,
|
||||
getTradingConfig = null,
|
||||
now = () => Date.now(),
|
||||
uuid = () => crypto.randomUUID(),
|
||||
withMakerSuppressed = async (operation) => operation(),
|
||||
|
|
@ -37,15 +38,22 @@ export function createIntentRequestController({
|
|||
const createdAt = new Date(now()).toISOString();
|
||||
const requestId = String(body.request_id || uuid());
|
||||
const idempotencyKey = String(body.idempotency_key || `intent-request:${requestId}`);
|
||||
const sourceAsset = config.tradingEure;
|
||||
const destinationAsset = config.tradingBtc;
|
||||
const amountEure = String(body.amount_eure || config.intentRequestDefaultAmountEure || '5');
|
||||
const slippageBps = Number(body.slippage_bps ?? config.intentRequestDefaultSlippageBps ?? 200);
|
||||
const minDeadlineMs = Number(body.min_deadline_ms || config.intentRequestMinDeadlineMs || 60_000);
|
||||
const requestPair = await resolveIntentRequestPair({ body, config, getTradingConfig });
|
||||
const sourceAsset = requestPair.sourceAsset;
|
||||
const destinationAsset = requestPair.destinationAsset;
|
||||
const amountEure = String(
|
||||
body.amount_eure
|
||||
|| body.amount
|
||||
|| requestPair.requestDefaultNotional
|
||||
|| config.intentRequestDefaultAmountEure
|
||||
|| '5',
|
||||
);
|
||||
const slippageBps = Number(body.slippage_bps ?? requestPair.slippageBps ?? config.intentRequestDefaultSlippageBps ?? 200);
|
||||
const minDeadlineMs = Number(body.min_deadline_ms || requestPair.minDeadlineMs || config.intentRequestMinDeadlineMs || 60_000);
|
||||
const maxAmountUnits = parseDecimalToUnits(
|
||||
String(config.intentRequestMaxAmountEure || 5),
|
||||
String(requestPair.requestMaxNotional || config.intentRequestMaxAmountEure || 5),
|
||||
sourceAsset.decimals,
|
||||
{ field: 'intent_request_max_amount_eure' },
|
||||
{ field: 'intent_request_max_notional' },
|
||||
);
|
||||
|
||||
let sourceAmountUnits = '0';
|
||||
|
|
@ -64,23 +72,30 @@ export function createIntentRequestController({
|
|||
let blockedBeforeQuote = false;
|
||||
|
||||
try {
|
||||
if (!requestPair.ok) {
|
||||
blockedBeforeQuote = true;
|
||||
throw codedError(
|
||||
requestPair.reasonCode,
|
||||
requestPair.reasonText,
|
||||
);
|
||||
}
|
||||
sourceAmountUnits = parseDecimalToUnits(amountEure, sourceAsset.decimals, { field: 'amount_eure' });
|
||||
if (BigInt(sourceAmountUnits) > BigInt(maxAmountUnits)) {
|
||||
blockedBeforeQuote = true;
|
||||
throw codedError(
|
||||
'amount_exceeds_request_limit',
|
||||
`Requested ${amountEure} EURe exceeds configured live request limit ${config.intentRequestMaxAmountEure || 5} EURe.`,
|
||||
`Requested ${amountEure} ${sourceAsset.symbol} exceeds configured live request limit ${requestPair.requestMaxNotional || config.intentRequestMaxAmountEure || 5} ${sourceAsset.symbol}.`,
|
||||
);
|
||||
}
|
||||
if (!Number.isInteger(slippageBps) || slippageBps < 0) {
|
||||
blockedBeforeQuote = true;
|
||||
throw codedError('invalid_slippage', 'Slippage must be a non-negative integer in basis points.');
|
||||
}
|
||||
if (slippageBps > Number(config.intentRequestMaxSlippageBps ?? 200)) {
|
||||
if (slippageBps > Number(requestPair.requestMaxSlippageBps ?? config.intentRequestMaxSlippageBps ?? 200)) {
|
||||
blockedBeforeQuote = true;
|
||||
throw codedError(
|
||||
'slippage_exceeds_request_limit',
|
||||
`Slippage ${slippageBps} bps exceeds configured limit ${config.intentRequestMaxSlippageBps ?? 200} bps.`,
|
||||
`Slippage ${slippageBps} bps exceeds configured limit ${requestPair.requestMaxSlippageBps ?? config.intentRequestMaxSlippageBps ?? 200} bps.`,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -96,7 +111,7 @@ export function createIntentRequestController({
|
|||
blockedBeforeQuote = true;
|
||||
throw codedError('inventory_unavailable', 'No spendable inventory snapshot is available.');
|
||||
}
|
||||
if (!isFresh(inventoryObservedAt, config.intentRequestInventoryMaxAgeMs ?? config.strategyInventoryMaxAgeMs, now())) {
|
||||
if (!isFresh(inventoryObservedAt, requestPair.inventoryMaxAgeMs ?? config.intentRequestInventoryMaxAgeMs ?? config.strategyInventoryMaxAgeMs, now())) {
|
||||
blockedBeforeQuote = true;
|
||||
throw codedError('stale_inventory', 'Inventory snapshot is too stale for request creation.');
|
||||
}
|
||||
|
|
@ -104,7 +119,7 @@ export function createIntentRequestController({
|
|||
blockedBeforeQuote = true;
|
||||
throw codedError('reference_price_unavailable', 'No BTC/EUR reference price is available.');
|
||||
}
|
||||
if (!isFresh(priceObservedAt, config.intentRequestPriceMaxAgeMs ?? config.strategyPriceMaxAgeMs, now())) {
|
||||
if (!isFresh(priceObservedAt, requestPair.priceMaxAgeMs ?? config.intentRequestPriceMaxAgeMs ?? config.strategyPriceMaxAgeMs, now())) {
|
||||
blockedBeforeQuote = true;
|
||||
throw codedError('stale_reference_price', 'Reference price is too stale for request creation.');
|
||||
}
|
||||
|
|
@ -168,6 +183,22 @@ export function createIntentRequestController({
|
|||
state,
|
||||
reason_code: reasonCode,
|
||||
reason_text: reasonText,
|
||||
pair: `${sourceAsset.assetId}->${destinationAsset.assetId}`,
|
||||
pair_id: requestPair.pair?.pairId || null,
|
||||
pair_config_id: requestPair.strategyConfig?.configId || null,
|
||||
pair_config_version: requestPair.strategyConfig?.version == null
|
||||
? null
|
||||
: String(requestPair.strategyConfig.version),
|
||||
edge_bps: requestPair.strategyConfig?.edgeBps == null
|
||||
? null
|
||||
: String(requestPair.strategyConfig.edgeBps),
|
||||
max_notional: requestPair.strategyConfig?.maxNotional == null
|
||||
? null
|
||||
: String(requestPair.strategyConfig.maxNotional),
|
||||
request_max_notional: requestPair.requestMaxNotional == null
|
||||
? null
|
||||
: String(requestPair.requestMaxNotional),
|
||||
price_route_id: requestPair.priceRoute?.routeId || null,
|
||||
source_asset_id: sourceAsset.assetId,
|
||||
source_symbol: sourceAsset.symbol,
|
||||
source_decimals: sourceAsset.decimals,
|
||||
|
|
@ -197,6 +228,7 @@ export function createIntentRequestController({
|
|||
ingested_at: marketPrice.ingested_at,
|
||||
observed_at: marketPrice.payload?.observed_at || null,
|
||||
eure_per_btc: marketPrice.payload?.eure_per_btc || null,
|
||||
price_id: marketPrice.payload?.price_id || null,
|
||||
} : null,
|
||||
solver_quote_count: solverQuotes.length,
|
||||
selected_quote: selectedQuote,
|
||||
|
|
@ -410,6 +442,14 @@ export function createIntentRequestController({
|
|||
result_code,
|
||||
result_text,
|
||||
submitted_at: submittedAt,
|
||||
pair: preflight.pair || `${preflight.source_asset_id}->${preflight.destination_asset_id}`,
|
||||
pair_id: preflight.pair_id || null,
|
||||
pair_config_id: preflight.pair_config_id || null,
|
||||
pair_config_version: preflight.pair_config_version || null,
|
||||
edge_bps: preflight.edge_bps || null,
|
||||
max_notional: preflight.max_notional || null,
|
||||
request_max_notional: preflight.request_max_notional || null,
|
||||
price_route_id: preflight.price_route_id || null,
|
||||
source_asset_id: preflight.source_asset_id,
|
||||
destination_asset_id: preflight.destination_asset_id,
|
||||
source_amount_units: preflight.source_amount_units,
|
||||
|
|
@ -434,6 +474,122 @@ export function createIntentRequestController({
|
|||
};
|
||||
}
|
||||
|
||||
async function resolveIntentRequestPair({ body, config, getTradingConfig }) {
|
||||
if (typeof getTradingConfig !== 'function') {
|
||||
return {
|
||||
ok: true,
|
||||
sourceAsset: config.tradingEure,
|
||||
destinationAsset: config.tradingBtc,
|
||||
pair: null,
|
||||
strategyConfig: null,
|
||||
priceRoute: null,
|
||||
requestDefaultNotional: config.intentRequestDefaultAmountEure,
|
||||
requestMaxNotional: config.intentRequestMaxAmountEure,
|
||||
slippageBps: config.intentRequestDefaultSlippageBps,
|
||||
requestMaxSlippageBps: config.intentRequestMaxSlippageBps,
|
||||
minDeadlineMs: config.intentRequestMinDeadlineMs,
|
||||
priceMaxAgeMs: config.intentRequestPriceMaxAgeMs,
|
||||
inventoryMaxAgeMs: config.intentRequestInventoryMaxAgeMs,
|
||||
};
|
||||
}
|
||||
|
||||
const tradingConfig = await getTradingConfig();
|
||||
const fallbackSource = config.tradingEure || tradingConfig.tradingEure;
|
||||
const fallbackDestination = config.tradingBtc || tradingConfig.tradingBtc;
|
||||
if (!tradingConfig?.ok) {
|
||||
return blockedRequestPair({
|
||||
reasonCode: 'pair_config_unavailable',
|
||||
reasonText: `Trading pair config is unavailable: ${tradingConfig?.blockReason || 'unknown'}.`,
|
||||
sourceAsset: fallbackSource,
|
||||
destinationAsset: fallbackDestination,
|
||||
});
|
||||
}
|
||||
|
||||
const requestedSource = body.source_asset_id || body.asset_in || null;
|
||||
const requestedDestination = body.destination_asset_id || body.asset_out || null;
|
||||
const pair = requestedSource && requestedDestination
|
||||
? tradingConfig.pairByKey.get(`${requestedSource}->${requestedDestination}`)
|
||||
: tradingConfig.defaultTakerPair;
|
||||
|
||||
if (!pair) {
|
||||
return blockedRequestPair({
|
||||
reasonCode: 'taker_pair_missing',
|
||||
reasonText: 'No DB-enabled taker pair is configured for request creation.',
|
||||
sourceAsset: fallbackSource || tradingConfig.trackedAssets?.[0],
|
||||
destinationAsset: fallbackDestination || tradingConfig.trackedAssets?.[1],
|
||||
});
|
||||
}
|
||||
|
||||
if (!pair.takerEnabled) {
|
||||
return blockedRequestPair({
|
||||
reasonCode: 'pair_not_taker_enabled',
|
||||
reasonText: 'The selected pair is not enabled for taker request creation.',
|
||||
pair,
|
||||
sourceAsset: pair.assetIn,
|
||||
destinationAsset: pair.assetOut,
|
||||
});
|
||||
}
|
||||
|
||||
if (!pair.canTrade) {
|
||||
return blockedRequestPair({
|
||||
reasonCode: pair.blockReason || 'pair_blocked',
|
||||
reasonText: `The selected pair is blocked: ${pair.blockReason || 'unknown'}.`,
|
||||
pair,
|
||||
sourceAsset: pair.assetIn,
|
||||
destinationAsset: pair.assetOut,
|
||||
});
|
||||
}
|
||||
|
||||
if (pair.priceRoute?.source !== 'btc_eur_reference') {
|
||||
return blockedRequestPair({
|
||||
reasonCode: 'price_route_missing',
|
||||
reasonText: 'Only the DB-backed BTC/EUR price route is supported for request creation in this turn.',
|
||||
pair,
|
||||
sourceAsset: pair.assetIn,
|
||||
destinationAsset: pair.assetOut,
|
||||
});
|
||||
}
|
||||
|
||||
const strategyConfig = pair.strategyConfig;
|
||||
return {
|
||||
ok: true,
|
||||
sourceAsset: pair.assetIn,
|
||||
destinationAsset: pair.assetOut,
|
||||
pair,
|
||||
strategyConfig,
|
||||
priceRoute: pair.priceRoute,
|
||||
requestDefaultNotional:
|
||||
strategyConfig.requestDefaultNotional || config.intentRequestDefaultAmountEure,
|
||||
requestMaxNotional:
|
||||
strategyConfig.requestMaxNotional || config.intentRequestMaxAmountEure,
|
||||
slippageBps: strategyConfig.slippageBps ?? config.intentRequestDefaultSlippageBps,
|
||||
requestMaxSlippageBps:
|
||||
strategyConfig.requestMaxSlippageBps ?? strategyConfig.slippageBps ?? config.intentRequestMaxSlippageBps,
|
||||
minDeadlineMs: strategyConfig.minDeadlineMs ?? config.intentRequestMinDeadlineMs,
|
||||
priceMaxAgeMs: strategyConfig.priceMaxAgeMs ?? config.intentRequestPriceMaxAgeMs,
|
||||
inventoryMaxAgeMs: strategyConfig.inventoryMaxAgeMs ?? config.intentRequestInventoryMaxAgeMs,
|
||||
};
|
||||
}
|
||||
|
||||
function blockedRequestPair({
|
||||
reasonCode,
|
||||
reasonText,
|
||||
pair = null,
|
||||
sourceAsset = null,
|
||||
destinationAsset = null,
|
||||
}) {
|
||||
return {
|
||||
ok: false,
|
||||
reasonCode,
|
||||
reasonText,
|
||||
pair,
|
||||
strategyConfig: pair?.strategyConfig || null,
|
||||
priceRoute: pair?.priceRoute || null,
|
||||
sourceAsset,
|
||||
destinationAsset,
|
||||
};
|
||||
}
|
||||
|
||||
function isFresh(timestamp, maxAgeMs, nowMs) {
|
||||
const parsed = Date.parse(timestamp || '');
|
||||
if (!Number.isFinite(parsed)) return false;
|
||||
|
|
|
|||
|
|
@ -48,6 +48,36 @@ const CONTROL_DEFINITIONS = [
|
|||
page: 'funds',
|
||||
risk_class: 'safe',
|
||||
},
|
||||
{
|
||||
service: 'operator-dashboard',
|
||||
action: 'import-supported-assets',
|
||||
method: 'POST',
|
||||
path: '/internal/import-supported-assets',
|
||||
label: 'Import Assets',
|
||||
description: 'Fetch and store the current 1Click supported token catalog.',
|
||||
page: 'strategy',
|
||||
risk_class: 'safe',
|
||||
},
|
||||
{
|
||||
service: 'operator-dashboard',
|
||||
action: 'update-pair-edge',
|
||||
method: 'POST',
|
||||
path: '/internal/update-pair-edge',
|
||||
label: 'Update Edge',
|
||||
description: 'Create a new active strategy config version for a pair.',
|
||||
page: 'strategy',
|
||||
risk_class: 'safe',
|
||||
},
|
||||
{
|
||||
service: 'operator-dashboard',
|
||||
action: 'enable-observe-only-pair',
|
||||
method: 'POST',
|
||||
path: '/internal/enable-observe-only-pair',
|
||||
label: 'Enable Observe-Only',
|
||||
description: 'Approve a directed pair for observation without enabling trading.',
|
||||
page: 'strategy',
|
||||
risk_class: 'safe',
|
||||
},
|
||||
{
|
||||
service: 'trade-executor',
|
||||
action: 'intent-request-preflight',
|
||||
|
|
@ -229,6 +259,7 @@ const SERVICE_DEFINITIONS = [
|
|||
['ops-sentinel', 'Ops Sentinel', 'opsSentinelControlBaseUrl'],
|
||||
['strategy-engine', 'Strategy Engine', 'strategyEngineControlBaseUrl'],
|
||||
['trade-executor', 'Trade Executor', 'tradeExecutorControlBaseUrl'],
|
||||
['operator-dashboard', 'Operator Dashboard', 'operatorDashboardControlBaseUrl'],
|
||||
];
|
||||
|
||||
export function resolveDashboardAuth({ mode = 'stub' } = {}) {
|
||||
|
|
@ -475,6 +506,8 @@ export function buildDashboardBootstrap({
|
|||
recentIntentRequests = [],
|
||||
recentAlertTransitions,
|
||||
recentEnvironmentStatuses = [],
|
||||
assetCatalog = null,
|
||||
pairConfig = null,
|
||||
serviceSnapshots,
|
||||
nearIntentsStatus = null,
|
||||
sourceErrors = [],
|
||||
|
|
@ -555,6 +588,8 @@ export function buildDashboardBootstrap({
|
|||
config,
|
||||
servicesByName,
|
||||
activeAlerts,
|
||||
assetCatalog,
|
||||
pairConfig,
|
||||
recentQuotes,
|
||||
recentTradeDecisions,
|
||||
recentExecuteTradeCommands,
|
||||
|
|
@ -1029,6 +1064,10 @@ export function deriveQuoteLifecycleRows({
|
|||
quote_id: decision.quote_id,
|
||||
decision_id: decision.decision_id,
|
||||
pair: decision.pair,
|
||||
pair_id: decision.pair_id,
|
||||
pair_config_id: decision.pair_config_id,
|
||||
pair_config_version: decision.pair_config_version,
|
||||
edge_bps: decision.edge_bps,
|
||||
direction: decision.direction,
|
||||
request_kind: decision.request_kind,
|
||||
gross_edge_pct: decision.gross_edge_pct,
|
||||
|
|
@ -1054,6 +1093,10 @@ export function deriveQuoteLifecycleRows({
|
|||
decision_id: command.decision_id,
|
||||
command_id: command.command_id,
|
||||
pair: command.pair,
|
||||
pair_id: command.pair_id,
|
||||
pair_config_id: command.pair_config_id,
|
||||
pair_config_version: command.pair_config_version,
|
||||
edge_bps: command.edge_bps,
|
||||
direction: command.direction,
|
||||
request_kind: command.request_kind,
|
||||
asset_in: command.asset_in || null,
|
||||
|
|
@ -1109,6 +1152,10 @@ function ensureLifecycleRow(rowsByKey, key) {
|
|||
decision_id: null,
|
||||
command_id: null,
|
||||
pair: null,
|
||||
pair_id: null,
|
||||
pair_config_id: null,
|
||||
pair_config_version: null,
|
||||
edge_bps: null,
|
||||
direction: null,
|
||||
request_kind: null,
|
||||
gross_edge_pct: null,
|
||||
|
|
@ -1336,6 +1383,10 @@ function normalizeCommand(command) {
|
|||
execution_key: command.execution_key || null,
|
||||
quote_id: command.quote_id || null,
|
||||
pair: command.pair || null,
|
||||
pair_id: command.pair_id || null,
|
||||
pair_config_id: command.pair_config_id || null,
|
||||
pair_config_version: command.pair_config_version || null,
|
||||
edge_bps: command.edge_bps || null,
|
||||
direction: command.direction || null,
|
||||
request_kind: command.request_kind || null,
|
||||
asset_in: command.asset_in || null,
|
||||
|
|
@ -1350,6 +1401,8 @@ function buildStrategySummary({
|
|||
config,
|
||||
servicesByName,
|
||||
activeAlerts,
|
||||
assetCatalog = null,
|
||||
pairConfig = null,
|
||||
recentQuotes = [],
|
||||
recentTradeDecisions = [],
|
||||
recentExecuteTradeCommands = [],
|
||||
|
|
@ -1405,8 +1458,14 @@ function buildStrategySummary({
|
|||
strategy_state: {
|
||||
armed: strategyState.armed ?? null,
|
||||
paused: strategyState.paused ?? null,
|
||||
threshold_pct: strategyState.threshold_pct ?? null,
|
||||
max_notional_eure: strategyState.max_notional_eure ?? null,
|
||||
threshold_pct:
|
||||
strategyState.threshold_pct
|
||||
?? firstActivePairConfig(pairConfig)?.edge_pct
|
||||
?? null,
|
||||
max_notional_eure:
|
||||
strategyState.max_notional_eure
|
||||
?? firstActivePairConfig(pairConfig)?.max_notional
|
||||
?? null,
|
||||
latest_decision: latestDecision?.decision_id ? latestDecision : null,
|
||||
recent_decisions: recentDecisions.length
|
||||
? recentDecisions
|
||||
|
|
@ -1415,7 +1474,10 @@ function buildStrategySummary({
|
|||
trade_funnel: tradeFunnel,
|
||||
skipped_counts: strategyState.skipped_counts || {},
|
||||
durable_control_state: strategyState.durable_control_state || null,
|
||||
trading_config: strategyState.trading_config || null,
|
||||
},
|
||||
asset_catalog: assetCatalog || buildFallbackAssetCatalog(config),
|
||||
pair_config: pairConfig || buildFallbackPairConfig(config),
|
||||
executor_state: {
|
||||
armed: executorState.armed ?? null,
|
||||
paused: executorState.paused ?? null,
|
||||
|
|
@ -1488,6 +1550,57 @@ function buildTradeFunnelSummary(lifecycleRows = []) {
|
|||
};
|
||||
}
|
||||
|
||||
function firstActivePairConfig(pairConfig) {
|
||||
const pair = pairConfig?.pairs?.find((entry) => entry.strategyConfig || entry.strategy_config);
|
||||
const strategyConfig = pair?.strategyConfig || pair?.strategy_config || null;
|
||||
if (!strategyConfig) return null;
|
||||
return {
|
||||
edge_pct: strategyConfig.edge_bps == null ? null : String(Number(strategyConfig.edge_bps) / 100),
|
||||
max_notional: strategyConfig.max_notional == null ? null : String(strategyConfig.max_notional),
|
||||
};
|
||||
}
|
||||
|
||||
function buildFallbackAssetCatalog(config) {
|
||||
const items = [...(config.assetRegistry?.values?.() || [])].map((asset) => ({
|
||||
asset_id: asset.assetId,
|
||||
symbol: asset.symbol,
|
||||
label: asset.label || asset.symbol,
|
||||
decimals: asset.decimals,
|
||||
blockchain: asset.blockchain || asset.chain || null,
|
||||
chain: asset.chain || asset.blockchain || null,
|
||||
supported: asset.supported ?? null,
|
||||
retired_at: asset.retiredAt || null,
|
||||
enabled_for_inventory: asset.enabledForInventory ?? true,
|
||||
}));
|
||||
return {
|
||||
latest_import: null,
|
||||
counts: {
|
||||
known: items.length,
|
||||
supported: items.filter((asset) => asset.supported === true).length,
|
||||
retired: items.filter((asset) => asset.retired_at).length,
|
||||
inventory_enabled: items.filter((asset) => asset.enabled_for_inventory !== false).length,
|
||||
},
|
||||
items,
|
||||
};
|
||||
}
|
||||
|
||||
function buildFallbackPairConfig(config) {
|
||||
return {
|
||||
ok: false,
|
||||
block_reason: 'pair_config_unavailable',
|
||||
loaded_at: null,
|
||||
pairs: config.activePair ? [{
|
||||
pair_id: config.activePair,
|
||||
key: config.activePair,
|
||||
mode: 'legacy',
|
||||
status: 'legacy',
|
||||
enabled: false,
|
||||
canTrade: false,
|
||||
blockReason: 'pair_config_unavailable',
|
||||
}] : [],
|
||||
};
|
||||
}
|
||||
|
||||
function summarizeGrossEdgeEstimate(rows = []) {
|
||||
let total = 0n;
|
||||
let count = 0;
|
||||
|
|
@ -1638,7 +1751,8 @@ function buildServiceSummary(service, state) {
|
|||
return {
|
||||
connected: state.ingest?.connected ?? null,
|
||||
reconnect_count: state.ingest?.reconnect_count ?? null,
|
||||
pair_filter: state.pair_filter?.pair_filter || null,
|
||||
active_pair: state.trading_config?.active_pair || null,
|
||||
enabled_pair_count: state.trading_config?.enabled_pair_count ?? null,
|
||||
last_message_at: state.ingest?.last_message_at || null,
|
||||
last_matching_quote_at: state.ingest?.last_matching_quote_at || null,
|
||||
last_published_at: state.ingest?.last_published_at || null,
|
||||
|
|
@ -1952,6 +2066,10 @@ function normalizeDecision(decision) {
|
|||
decision_at: decision.decision_at || null,
|
||||
quote_id: decision.quote_id || null,
|
||||
pair: decision.pair || null,
|
||||
pair_id: decision.pair_id || null,
|
||||
pair_config_id: decision.pair_config_id || null,
|
||||
pair_config_version: decision.pair_config_version || null,
|
||||
edge_bps: decision.edge_bps || null,
|
||||
direction: decision.direction || null,
|
||||
request_kind: decision.request_kind || null,
|
||||
decision: normalizeDecisionVerdict(decision.decision),
|
||||
|
|
|
|||
|
|
@ -1,8 +1,3 @@
|
|||
import fs from 'node:fs';
|
||||
|
||||
export const DEFAULT_NEAR_INTENTS_PAIR_FILTER =
|
||||
'nep141:nbtc.bridge.near->nep141:gnosis-0x420ca0f9b9b604ce0fd9c18ef134c705e5fa3430.omft.near';
|
||||
|
||||
export function parsePairFilter(argv) {
|
||||
const idx = argv.indexOf('--pair');
|
||||
if (idx === -1) return null;
|
||||
|
|
@ -33,8 +28,7 @@ export function formatPairFilter(pairFilter) {
|
|||
|
||||
export function resolvePairFilter({
|
||||
argv = [],
|
||||
env = process.env,
|
||||
defaultPairFilter = DEFAULT_NEAR_INTENTS_PAIR_FILTER,
|
||||
defaultPairFilter = null,
|
||||
} = {}) {
|
||||
const cliPairFilter = parsePairFilter(argv);
|
||||
if (cliPairFilter) {
|
||||
|
|
@ -45,124 +39,38 @@ export function resolvePairFilter({
|
|||
};
|
||||
}
|
||||
|
||||
const envPairFilter = parsePairFilterValue(env.NEAR_INTENTS_PAIR_FILTER, {
|
||||
fieldName: 'NEAR_INTENTS_PAIR_FILTER',
|
||||
});
|
||||
if (envPairFilter) {
|
||||
return {
|
||||
pairFilter: envPairFilter,
|
||||
pair: formatPairFilter(envPairFilter),
|
||||
source: 'env',
|
||||
};
|
||||
}
|
||||
|
||||
const defaultResolved = parsePairFilterValue(defaultPairFilter, {
|
||||
fieldName: 'default pair filter',
|
||||
});
|
||||
return {
|
||||
pairFilter: defaultResolved,
|
||||
pair: formatPairFilter(defaultResolved),
|
||||
source: 'default',
|
||||
source: defaultResolved ? 'default' : 'disabled',
|
||||
};
|
||||
}
|
||||
|
||||
export function createPairFilterController({
|
||||
argv = [],
|
||||
env = process.env,
|
||||
logger = null,
|
||||
defaultPairFilter = DEFAULT_NEAR_INTENTS_PAIR_FILTER,
|
||||
pairFilterFile = env.NEAR_INTENTS_PAIR_FILTER_FILE,
|
||||
reloadEveryMs = env.NEAR_INTENTS_PAIR_FILTER_RELOAD_MS,
|
||||
defaultPairFilter = null,
|
||||
} = {}) {
|
||||
function resolveConfiguredState() {
|
||||
const nextResolved = resolvePairFilter({ argv, env, defaultPairFilter });
|
||||
let nextPairFilter = nextResolved.pairFilter;
|
||||
let nextPair = nextResolved.pair;
|
||||
let nextSource = nextResolved.source;
|
||||
|
||||
if (normalizedPairFilterFile) {
|
||||
const fileValue = readPairFilterFile(normalizedPairFilterFile);
|
||||
if (fileValue != null) {
|
||||
const filePairFilter = parsePairFilterValue(fileValue, {
|
||||
fieldName: 'NEAR_INTENTS_PAIR_FILTER_FILE',
|
||||
});
|
||||
nextPairFilter = filePairFilter;
|
||||
nextPair = formatPairFilter(filePairFilter);
|
||||
nextSource = 'file';
|
||||
}
|
||||
return resolvePairFilter({ argv, defaultPairFilter });
|
||||
}
|
||||
|
||||
return {
|
||||
pairFilter: nextPairFilter,
|
||||
pair: nextPair,
|
||||
source: nextSource,
|
||||
};
|
||||
}
|
||||
|
||||
const normalizedPairFilterFile = String(pairFilterFile || '').trim() || null;
|
||||
const normalizedReloadEveryMs = parseReloadMs(reloadEveryMs);
|
||||
const resolved = resolveConfiguredState();
|
||||
let currentPairFilter = resolved.pairFilter;
|
||||
let currentPair = resolved.pair;
|
||||
let lastLoadedFileValue = null;
|
||||
let source = resolved.source;
|
||||
let overrideSource = null;
|
||||
|
||||
if (normalizedPairFilterFile) {
|
||||
const initialFileValue = readPairFilterFile(normalizedPairFilterFile);
|
||||
if (initialFileValue != null) {
|
||||
const initialFilePairFilter = parsePairFilterValue(initialFileValue, {
|
||||
fieldName: 'NEAR_INTENTS_PAIR_FILTER_FILE',
|
||||
});
|
||||
currentPairFilter = initialFilePairFilter;
|
||||
currentPair = formatPairFilter(initialFilePairFilter);
|
||||
lastLoadedFileValue = initialFileValue;
|
||||
source = 'file';
|
||||
}
|
||||
}
|
||||
|
||||
logger?.info('pair_filter_configured', {
|
||||
pair: currentPair,
|
||||
details: {
|
||||
source,
|
||||
pair_filter_file: normalizedPairFilterFile,
|
||||
},
|
||||
});
|
||||
|
||||
const timer = normalizedPairFilterFile
|
||||
? setInterval(() => {
|
||||
if (overrideSource === 'api') return;
|
||||
|
||||
const nextValue = readPairFilterFile(normalizedPairFilterFile);
|
||||
if (nextValue == null || nextValue === lastLoadedFileValue) return;
|
||||
|
||||
try {
|
||||
const nextPairFilter = parsePairFilterValue(nextValue, {
|
||||
fieldName: 'NEAR_INTENTS_PAIR_FILTER_FILE',
|
||||
});
|
||||
currentPairFilter = nextPairFilter;
|
||||
currentPair = formatPairFilter(nextPairFilter);
|
||||
lastLoadedFileValue = nextValue;
|
||||
logger?.info('pair_filter_reloaded', {
|
||||
pair: currentPair,
|
||||
details: {
|
||||
pair_filter_file: normalizedPairFilterFile,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger?.error('pair_filter_reload_failed', {
|
||||
pair: currentPair,
|
||||
details: {
|
||||
pair_filter_file: normalizedPairFilterFile,
|
||||
error: error.message,
|
||||
},
|
||||
});
|
||||
}
|
||||
}, normalizedReloadEveryMs)
|
||||
: null;
|
||||
|
||||
if (timer && typeof timer.unref === 'function') timer.unref();
|
||||
|
||||
function setState(nextPairFilter, nextSource) {
|
||||
currentPairFilter = nextPairFilter;
|
||||
currentPair = formatPairFilter(nextPairFilter);
|
||||
|
|
@ -182,8 +90,8 @@ export function createPairFilterController({
|
|||
pair: currentPair,
|
||||
source,
|
||||
configured: resolveConfiguredState(),
|
||||
pairFilterFile: normalizedPairFilterFile,
|
||||
reloadEveryMs: normalizedPairFilterFile ? normalizedReloadEveryMs : null,
|
||||
pairFilterFile: null,
|
||||
reloadEveryMs: null,
|
||||
};
|
||||
},
|
||||
setPairFilter(raw, { source: nextSource = 'api' } = {}) {
|
||||
|
|
@ -198,7 +106,6 @@ export function createPairFilterController({
|
|||
pair: currentPair,
|
||||
details: {
|
||||
source: nextSource,
|
||||
pair_filter_file: normalizedPairFilterFile,
|
||||
},
|
||||
});
|
||||
return this.getState();
|
||||
|
|
@ -210,7 +117,6 @@ export function createPairFilterController({
|
|||
pair: null,
|
||||
details: {
|
||||
source: nextSource,
|
||||
pair_filter_file: normalizedPairFilterFile,
|
||||
},
|
||||
});
|
||||
return this.getState();
|
||||
|
|
@ -223,13 +129,11 @@ export function createPairFilterController({
|
|||
pair: currentPair,
|
||||
details: {
|
||||
source,
|
||||
pair_filter_file: normalizedPairFilterFile,
|
||||
},
|
||||
});
|
||||
return this.getState();
|
||||
},
|
||||
close() {
|
||||
if (timer) clearInterval(timer);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -240,19 +144,3 @@ export function matchesPairFilter(assetIn, assetOut, pairFilter) {
|
|||
const y = assetOut.toLowerCase();
|
||||
return (x === pairFilter[0] && y === pairFilter[1]) || (x === pairFilter[1] && y === pairFilter[0]);
|
||||
}
|
||||
|
||||
function readPairFilterFile(filePath) {
|
||||
if (!fs.existsSync(filePath)) return null;
|
||||
|
||||
const raw = fs.readFileSync(filePath, 'utf8')
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.find((line) => line && !line.startsWith('#'));
|
||||
|
||||
return raw || null;
|
||||
}
|
||||
|
||||
function parseReloadMs(raw) {
|
||||
const parsed = Number(raw);
|
||||
return Number.isFinite(parsed) && parsed >= 1_000 ? parsed : 5_000;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import {
|
|||
bigintAmount,
|
||||
classifyPairDirection,
|
||||
formatNumber,
|
||||
isActivePair,
|
||||
numberToUnits,
|
||||
pairKey,
|
||||
unitsToNumber,
|
||||
|
|
@ -21,30 +20,42 @@ export function evaluateTradeOpportunity({
|
|||
maxNotionalEure = config.strategyMaxNotionalEure,
|
||||
}) {
|
||||
const payload = demandEvent.payload;
|
||||
const pairRuntime = resolvePairRuntime({ payload, config, thresholdPct, maxNotionalEure });
|
||||
const effectiveThresholdPct = pairRuntime.thresholdPct ?? thresholdPct;
|
||||
const effectiveMaxNotionalEure = pairRuntime.maxNotionalEure ?? maxNotionalEure;
|
||||
const decisionId = crypto.randomUUID();
|
||||
const baseDecision = {
|
||||
decision_id: decisionId,
|
||||
quote_id: payload.quote_id,
|
||||
pair: pairKey(payload.asset_in, payload.asset_out),
|
||||
direction: classifyPairDirection({
|
||||
assetIn: payload.asset_in,
|
||||
assetOut: payload.asset_out,
|
||||
btcAssetId: config.tradingBtc.assetId,
|
||||
eureAssetId: config.tradingEure.assetId,
|
||||
}),
|
||||
pair_id: pairRuntime.pair?.pairId || null,
|
||||
pair_config_id: pairRuntime.strategyConfig?.configId || null,
|
||||
pair_config_version: pairRuntime.strategyConfig?.version == null
|
||||
? null
|
||||
: String(pairRuntime.strategyConfig.version),
|
||||
edge_bps: pairRuntime.strategyConfig?.edgeBps == null
|
||||
? null
|
||||
: String(pairRuntime.strategyConfig.edgeBps),
|
||||
max_notional: effectiveMaxNotionalEure == null ? null : String(effectiveMaxNotionalEure),
|
||||
min_notional: pairRuntime.strategyConfig?.minNotional == null
|
||||
? null
|
||||
: String(pairRuntime.strategyConfig.minNotional),
|
||||
price_route_id: pairRuntime.priceRoute?.routeId || null,
|
||||
direction: pairRuntime.direction,
|
||||
request_kind: payload.request_kind,
|
||||
decision: 'rejected',
|
||||
decision_reason: 'unknown',
|
||||
threshold_pct: String(thresholdPct),
|
||||
max_notional_eure: String(maxNotionalEure),
|
||||
threshold_pct: String(effectiveThresholdPct),
|
||||
max_notional_eure: String(effectiveMaxNotionalEure),
|
||||
strategy_armed: armed,
|
||||
assumptions: {
|
||||
eure_per_eur: '1',
|
||||
price_route_source: pairRuntime.priceRoute?.source || null,
|
||||
},
|
||||
};
|
||||
|
||||
if (!isActivePair(payload.asset_in, payload.asset_out, config)) {
|
||||
return { decision: withReason(baseDecision, 'unsupported_pair') };
|
||||
if (!pairRuntime.ok) {
|
||||
return { decision: withReason(baseDecision, pairRuntime.reason) };
|
||||
}
|
||||
|
||||
if (!priceEvent) {
|
||||
|
|
@ -58,7 +69,7 @@ export function evaluateTradeOpportunity({
|
|||
const priceAgeMs = now - Date.parse(priceEvent.ingested_at || priceEvent.observed_at || 0);
|
||||
const inventoryAgeMs = now - Date.parse(inventoryEvent.ingested_at || inventoryEvent.observed_at || 0);
|
||||
|
||||
if (!Number.isFinite(priceAgeMs) || priceAgeMs > config.strategyPriceMaxAgeMs) {
|
||||
if (!Number.isFinite(priceAgeMs) || priceAgeMs > pairRuntime.priceMaxAgeMs) {
|
||||
return {
|
||||
decision: {
|
||||
...withReason(baseDecision, 'stale_reference_price'),
|
||||
|
|
@ -67,7 +78,7 @@ export function evaluateTradeOpportunity({
|
|||
};
|
||||
}
|
||||
|
||||
if (!Number.isFinite(inventoryAgeMs) || inventoryAgeMs > config.strategyInventoryMaxAgeMs) {
|
||||
if (!Number.isFinite(inventoryAgeMs) || inventoryAgeMs > pairRuntime.inventoryMaxAgeMs) {
|
||||
return {
|
||||
decision: {
|
||||
...withReason(baseDecision, 'stale_inventory_snapshot'),
|
||||
|
|
@ -83,8 +94,9 @@ export function evaluateTradeOpportunity({
|
|||
price,
|
||||
inventory,
|
||||
config,
|
||||
thresholdPct,
|
||||
maxNotionalEure,
|
||||
pairRuntime,
|
||||
thresholdPct: effectiveThresholdPct,
|
||||
maxNotionalEure: effectiveMaxNotionalEure,
|
||||
});
|
||||
|
||||
if (!buildResult.ok) {
|
||||
|
|
@ -119,8 +131,18 @@ export function evaluateTradeOpportunity({
|
|||
execution_key: payload.quote_id,
|
||||
quote_id: payload.quote_id,
|
||||
pair: decision.pair,
|
||||
pair_id: decision.pair_id,
|
||||
pair_config_id: decision.pair_config_id,
|
||||
pair_config_version: decision.pair_config_version,
|
||||
edge_bps: decision.edge_bps,
|
||||
max_notional: decision.max_notional,
|
||||
max_notional_eure: decision.max_notional_eure,
|
||||
price_route_id: decision.price_route_id,
|
||||
reference_price_id: buildResult.details.price_id || null,
|
||||
asset_in: payload.asset_in,
|
||||
asset_out: payload.asset_out,
|
||||
asset_in_decimals: pairRuntime.assetIn?.decimals ?? null,
|
||||
asset_out_decimals: pairRuntime.assetOut?.decimals ?? null,
|
||||
amount_in: payload.amount_in ?? null,
|
||||
amount_out: payload.amount_out ?? null,
|
||||
request_kind: payload.request_kind,
|
||||
|
|
@ -137,20 +159,28 @@ function buildQuote({
|
|||
price,
|
||||
inventory,
|
||||
config,
|
||||
pairRuntime = null,
|
||||
thresholdPct,
|
||||
maxNotionalEure,
|
||||
}) {
|
||||
const direction = classifyPairDirection({
|
||||
const direction = pairRuntime?.direction || classifyPairDirection({
|
||||
assetIn: demand.asset_in,
|
||||
assetOut: demand.asset_out,
|
||||
btcAssetId: config.tradingBtc.assetId,
|
||||
eureAssetId: config.tradingEure.assetId,
|
||||
btcAssetId: config.tradingBtc?.assetId,
|
||||
eureAssetId: config.tradingEure?.assetId,
|
||||
});
|
||||
|
||||
if (direction === 'unsupported') {
|
||||
return { ok: false, reason: 'unsupported_pair', details: {} };
|
||||
}
|
||||
|
||||
const assetRegistry = pairRuntime?.assetRegistry || config.assetRegistry;
|
||||
const assetIn = pairRuntime?.assetIn || assetRegistry.get(demand.asset_in);
|
||||
const assetOut = pairRuntime?.assetOut || assetRegistry.get(demand.asset_out);
|
||||
if (!Number.isInteger(assetIn?.decimals) || !Number.isInteger(assetOut?.decimals)) {
|
||||
return { ok: false, reason: 'asset_decimals_missing', details: {} };
|
||||
}
|
||||
|
||||
const thresholdFactor = 1 - (thresholdPct / 100);
|
||||
const penaltyFactor = 1 + (thresholdPct / 100);
|
||||
const spendAsset = demand.asset_out;
|
||||
|
|
@ -165,7 +195,7 @@ function buildQuote({
|
|||
|
||||
const inputNumber = unitsToNumber(
|
||||
demand.amount_in,
|
||||
config.assetRegistry.get(demand.asset_in).decimals,
|
||||
assetIn.decimals,
|
||||
);
|
||||
const fairOutput = direction === 'btc_to_eure'
|
||||
? inputNumber * Number(price.eure_per_btc)
|
||||
|
|
@ -173,7 +203,7 @@ function buildQuote({
|
|||
const proposedOutput = fairOutput * thresholdFactor;
|
||||
const proposedOutputUnits = numberToUnits(
|
||||
proposedOutput,
|
||||
config.assetRegistry.get(demand.asset_out).decimals,
|
||||
assetOut.decimals,
|
||||
{ mode: 'floor' },
|
||||
);
|
||||
const spendRequired = bigintAmount(proposedOutputUnits);
|
||||
|
|
@ -182,7 +212,7 @@ function buildQuote({
|
|||
: inputNumber;
|
||||
const impliedRate = unitsToNumber(
|
||||
proposedOutputUnits,
|
||||
config.assetRegistry.get(demand.asset_out).decimals,
|
||||
assetOut.decimals,
|
||||
) / inputNumber;
|
||||
const referenceRate = direction === 'btc_to_eure'
|
||||
? Number(price.eure_per_btc)
|
||||
|
|
@ -201,6 +231,8 @@ function buildQuote({
|
|||
referenceRate,
|
||||
inventoryId: inventory.inventory_id,
|
||||
priceId: price.price_id,
|
||||
assetInDecimals: assetIn.decimals,
|
||||
assetOutDecimals: assetOut.decimals,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -212,7 +244,7 @@ function buildQuote({
|
|||
|
||||
const outputNumber = unitsToNumber(
|
||||
demand.amount_out,
|
||||
config.assetRegistry.get(demand.asset_out).decimals,
|
||||
assetOut.decimals,
|
||||
);
|
||||
const fairInput = direction === 'btc_to_eure'
|
||||
? outputNumber * Number(price.btc_per_eure)
|
||||
|
|
@ -220,7 +252,7 @@ function buildQuote({
|
|||
const proposedInput = fairInput * penaltyFactor;
|
||||
const proposedInputUnits = numberToUnits(
|
||||
proposedInput,
|
||||
config.assetRegistry.get(demand.asset_in).decimals,
|
||||
assetIn.decimals,
|
||||
{ mode: 'ceil' },
|
||||
);
|
||||
const spendRequired = amountOut;
|
||||
|
|
@ -229,7 +261,7 @@ function buildQuote({
|
|||
: fairInput;
|
||||
const impliedRate = outputNumber / unitsToNumber(
|
||||
proposedInputUnits,
|
||||
config.assetRegistry.get(demand.asset_in).decimals,
|
||||
assetIn.decimals,
|
||||
);
|
||||
const referenceRate = direction === 'btc_to_eure'
|
||||
? Number(price.eure_per_btc)
|
||||
|
|
@ -249,6 +281,8 @@ function buildQuote({
|
|||
referenceRate,
|
||||
inventoryId: inventory.inventory_id,
|
||||
priceId: price.price_id,
|
||||
assetInDecimals: assetIn.decimals,
|
||||
assetOutDecimals: assetOut.decimals,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -269,6 +303,8 @@ function finalizeQuote({
|
|||
referenceRate,
|
||||
inventoryId,
|
||||
priceId,
|
||||
assetInDecimals = null,
|
||||
assetOutDecimals = null,
|
||||
}) {
|
||||
const grossEdgePct = ((referenceRate - impliedRate) / referenceRate) * 100;
|
||||
const reasonBase = {
|
||||
|
|
@ -281,6 +317,9 @@ function finalizeQuote({
|
|||
inventory_available: available.toString(),
|
||||
inventory_id: inventoryId,
|
||||
price_id: priceId,
|
||||
reference_price_id: priceId,
|
||||
asset_in_decimals: assetInDecimals == null ? null : String(assetInDecimals),
|
||||
asset_out_decimals: assetOutDecimals == null ? null : String(assetOutDecimals),
|
||||
eure_notional: formatNumber(eureNotional, 6),
|
||||
proposed_amount_in: proposedAmountIn,
|
||||
proposed_amount_out: proposedAmountOut,
|
||||
|
|
@ -331,3 +370,136 @@ function withReason(decision, reason) {
|
|||
decision_reason: reason,
|
||||
};
|
||||
}
|
||||
|
||||
function resolvePairRuntime({
|
||||
payload,
|
||||
config,
|
||||
thresholdPct,
|
||||
maxNotionalEure,
|
||||
}) {
|
||||
const key = pairKey(payload.asset_in, payload.asset_out);
|
||||
const requiresDb = config.requireDbTradingConfig === true || config.tradingConfigLoaded === true;
|
||||
|
||||
if (!requiresDb) {
|
||||
const direction = classifyPairDirection({
|
||||
assetIn: payload.asset_in,
|
||||
assetOut: payload.asset_out,
|
||||
btcAssetId: config.tradingBtc?.assetId,
|
||||
eureAssetId: config.tradingEure?.assetId,
|
||||
});
|
||||
const active = (
|
||||
direction !== 'unsupported'
|
||||
&& config.assetRegistry?.has(payload.asset_in)
|
||||
&& config.assetRegistry?.has(payload.asset_out)
|
||||
);
|
||||
return {
|
||||
ok: active,
|
||||
reason: active ? null : 'unsupported_pair',
|
||||
direction,
|
||||
assetRegistry: config.assetRegistry,
|
||||
assetIn: config.assetRegistry?.get(payload.asset_in) || null,
|
||||
assetOut: config.assetRegistry?.get(payload.asset_out) || null,
|
||||
thresholdPct,
|
||||
maxNotionalEure,
|
||||
priceMaxAgeMs: config.strategyPriceMaxAgeMs,
|
||||
inventoryMaxAgeMs: config.strategyInventoryMaxAgeMs,
|
||||
pair: null,
|
||||
strategyConfig: null,
|
||||
priceRoute: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (!config.tradingConfigLoaded || config.ok === false) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'pair_config_unavailable',
|
||||
direction: 'unsupported',
|
||||
pair: null,
|
||||
strategyConfig: null,
|
||||
priceRoute: null,
|
||||
assetRegistry: config.assetRegistry || new Map(),
|
||||
assetIn: null,
|
||||
assetOut: null,
|
||||
thresholdPct,
|
||||
maxNotionalEure,
|
||||
priceMaxAgeMs: config.strategyPriceMaxAgeMs,
|
||||
inventoryMaxAgeMs: config.strategyInventoryMaxAgeMs,
|
||||
};
|
||||
}
|
||||
|
||||
const pair = config.pairByKey?.get(key) || null;
|
||||
if (!pair || !pair.enabled || !pair.observeEnabled) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: pair ? 'pair_disabled' : 'unsupported_pair',
|
||||
direction: 'unsupported',
|
||||
pair,
|
||||
strategyConfig: pair?.strategyConfig || null,
|
||||
priceRoute: pair?.priceRoute || null,
|
||||
assetRegistry: config.assetRegistry,
|
||||
assetIn: pair?.assetIn || config.assetRegistry?.get(payload.asset_in) || null,
|
||||
assetOut: pair?.assetOut || config.assetRegistry?.get(payload.asset_out) || null,
|
||||
thresholdPct,
|
||||
maxNotionalEure,
|
||||
priceMaxAgeMs: config.strategyPriceMaxAgeMs,
|
||||
inventoryMaxAgeMs: config.strategyInventoryMaxAgeMs,
|
||||
};
|
||||
}
|
||||
|
||||
if (!pair.makerEnabled) return blockedPairRuntime(pair, config, 'pair_not_maker_enabled');
|
||||
if (pair.blockReason) return blockedPairRuntime(pair, config, pair.blockReason);
|
||||
if (!pair.strategyConfig) return blockedPairRuntime(pair, config, 'pair_strategy_config_missing');
|
||||
if (!pair.priceRoute) return blockedPairRuntime(pair, config, 'price_route_missing');
|
||||
|
||||
const direction = classifyPriceRouteDirection({ payload, priceRoute: pair.priceRoute });
|
||||
if (direction === 'unsupported') return blockedPairRuntime(pair, config, 'price_route_missing');
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
reason: null,
|
||||
direction,
|
||||
pair,
|
||||
strategyConfig: pair.strategyConfig,
|
||||
priceRoute: pair.priceRoute,
|
||||
assetRegistry: config.assetRegistry,
|
||||
assetIn: pair.assetIn,
|
||||
assetOut: pair.assetOut,
|
||||
thresholdPct: Number(pair.strategyConfig.edgeBps) / 100,
|
||||
maxNotionalEure: Number(pair.strategyConfig.maxNotional),
|
||||
priceMaxAgeMs: Number(pair.strategyConfig.priceMaxAgeMs),
|
||||
inventoryMaxAgeMs: Number(pair.strategyConfig.inventoryMaxAgeMs),
|
||||
};
|
||||
}
|
||||
|
||||
function blockedPairRuntime(pair, config, reason) {
|
||||
return {
|
||||
ok: false,
|
||||
reason,
|
||||
direction: classifyPriceRouteDirection({
|
||||
payload: { asset_in: pair?.asset_in, asset_out: pair?.asset_out },
|
||||
priceRoute: pair?.priceRoute,
|
||||
}),
|
||||
pair,
|
||||
strategyConfig: pair?.strategyConfig || null,
|
||||
priceRoute: pair?.priceRoute || null,
|
||||
assetRegistry: config.assetRegistry,
|
||||
assetIn: pair?.assetIn || null,
|
||||
assetOut: pair?.assetOut || null,
|
||||
thresholdPct: pair?.strategyConfig ? Number(pair.strategyConfig.edgeBps) / 100 : config.strategyGrossThresholdPct,
|
||||
maxNotionalEure: pair?.strategyConfig ? Number(pair.strategyConfig.maxNotional) : config.strategyMaxNotionalEure,
|
||||
priceMaxAgeMs: pair?.strategyConfig ? Number(pair.strategyConfig.priceMaxAgeMs) : config.strategyPriceMaxAgeMs,
|
||||
inventoryMaxAgeMs: pair?.strategyConfig ? Number(pair.strategyConfig.inventoryMaxAgeMs) : config.strategyInventoryMaxAgeMs,
|
||||
};
|
||||
}
|
||||
|
||||
function classifyPriceRouteDirection({ payload, priceRoute }) {
|
||||
if (!priceRoute) return 'unsupported';
|
||||
if (priceRoute.source !== 'btc_eur_reference') return 'unsupported';
|
||||
if (payload.asset_in === priceRoute.baseAssetId && payload.asset_out === priceRoute.quoteAssetId) {
|
||||
return 'btc_to_eure';
|
||||
}
|
||||
if (payload.asset_in === priceRoute.quoteAssetId && payload.asset_out === priceRoute.baseAssetId) {
|
||||
return 'eure_to_btc';
|
||||
}
|
||||
return 'unsupported';
|
||||
}
|
||||
|
|
|
|||
238
src/core/trading-config.mjs
Normal file
238
src/core/trading-config.mjs
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
import crypto from 'node:crypto';
|
||||
|
||||
import { pairKey } from './assets.mjs';
|
||||
|
||||
export const NEAR_INTENTS_VENUE = 'near-intents';
|
||||
export const ONE_CLICK_TOKENS_URL = 'https://1click.chaindefuser.com/v0/tokens';
|
||||
|
||||
export const CURRENT_NBTC_ASSET_ID = 'nep141:nbtc.bridge.near';
|
||||
export const LEGACY_OMFT_BTC_ASSET_ID = 'nep141:btc.omft.near';
|
||||
export const CURRENT_EURE_ASSET_ID =
|
||||
'nep141:gnosis-0x420ca0f9b9b604ce0fd9c18ef134c705e5fa3430.omft.near';
|
||||
|
||||
export const CURRENT_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_EDGE_BPS = 49;
|
||||
export const CURRENT_STRATEGY_MAX_NOTIONAL = '150';
|
||||
export const CURRENT_REQUEST_DEFAULT_NOTIONAL_EURE = '5';
|
||||
export const CURRENT_REQUEST_MAX_NOTIONAL_EURE = '5';
|
||||
export const CURRENT_SLIPPAGE_BPS = 200;
|
||||
export const CURRENT_MIN_DEADLINE_MS = 60_000;
|
||||
export const CURRENT_PRICE_MAX_AGE_MS = 30_000;
|
||||
export const CURRENT_INVENTORY_MAX_AGE_MS = 30_000;
|
||||
|
||||
export const PAIR_MODES = new Set(['observe_only', 'maker', 'taker', 'both']);
|
||||
export const PAIR_STATUSES = new Set(['disabled', 'observe_only', 'maker', 'taker', 'both']);
|
||||
|
||||
export function normalizeOneClickToken(token) {
|
||||
if (!isRecord(token)) throw new Error('token record must be an object');
|
||||
|
||||
const assetId = stringField(token.assetId ?? token.asset_id ?? token.defuseAssetId, 'assetId');
|
||||
const symbol = stringField(token.symbol, 'symbol');
|
||||
const decimals = integerField(token.decimals, 'decimals');
|
||||
const blockchain = stringField(token.blockchain ?? token.chain, 'blockchain');
|
||||
const contractAddress = optionalString(token.contractAddress ?? token.contract_address);
|
||||
const latestPrice = token.price == null ? null : String(token.price);
|
||||
const priceUpdatedAt = optionalTimestamp(token.priceUpdatedAt ?? token.price_updated_at);
|
||||
|
||||
return {
|
||||
assetId,
|
||||
venue: NEAR_INTENTS_VENUE,
|
||||
symbol,
|
||||
label: symbol,
|
||||
decimals,
|
||||
blockchain,
|
||||
chain: blockchain,
|
||||
contractAddress,
|
||||
latestPrice,
|
||||
priceUpdatedAt,
|
||||
supported: true,
|
||||
rawPayload: token,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeOneClickTokenResponse(response) {
|
||||
const tokens = Array.isArray(response)
|
||||
? response
|
||||
: Array.isArray(response?.tokens)
|
||||
? response.tokens
|
||||
: Array.isArray(response?.result)
|
||||
? response.result
|
||||
: null;
|
||||
|
||||
if (!tokens) throw new Error('supported token response must be an array');
|
||||
return tokens.map((token) => normalizeOneClickToken(token));
|
||||
}
|
||||
|
||||
export function hashJson(value) {
|
||||
return crypto.createHash('sha256')
|
||||
.update(JSON.stringify(value))
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
export function buildSeedAssets() {
|
||||
return [
|
||||
{
|
||||
assetId: CURRENT_NBTC_ASSET_ID,
|
||||
venue: NEAR_INTENTS_VENUE,
|
||||
symbol: 'BTC',
|
||||
label: 'BTC / nBTC reserve',
|
||||
decimals: 8,
|
||||
blockchain: 'near',
|
||||
chain: 'btc:mainnet',
|
||||
contractAddress: 'nbtc.bridge.near',
|
||||
latestPrice: null,
|
||||
priceUpdatedAt: null,
|
||||
supported: true,
|
||||
enabledForInventory: true,
|
||||
role: 'trading',
|
||||
rawPayload: { source: 'repo_seed', assetId: CURRENT_NBTC_ASSET_ID },
|
||||
},
|
||||
{
|
||||
assetId: LEGACY_OMFT_BTC_ASSET_ID,
|
||||
venue: NEAR_INTENTS_VENUE,
|
||||
symbol: 'BTC',
|
||||
label: 'BTC / legacy OMFT',
|
||||
decimals: 8,
|
||||
blockchain: 'btc',
|
||||
chain: 'btc:mainnet',
|
||||
contractAddress: null,
|
||||
latestPrice: null,
|
||||
priceUpdatedAt: null,
|
||||
supported: true,
|
||||
enabledForInventory: true,
|
||||
role: 'legacy',
|
||||
rawPayload: { source: 'repo_seed', assetId: LEGACY_OMFT_BTC_ASSET_ID },
|
||||
},
|
||||
{
|
||||
assetId: CURRENT_EURE_ASSET_ID,
|
||||
venue: NEAR_INTENTS_VENUE,
|
||||
symbol: 'EURe',
|
||||
label: 'EURe',
|
||||
decimals: 18,
|
||||
blockchain: 'gnosis',
|
||||
chain: 'eth:100',
|
||||
contractAddress: '0x420ca0f9b9b604ce0fd9c18ef134c705e5fa3430',
|
||||
latestPrice: null,
|
||||
priceUpdatedAt: null,
|
||||
supported: true,
|
||||
enabledForInventory: true,
|
||||
role: 'trading',
|
||||
withdrawAddress: '0x6C40267e03A97B2132e7a7d3159C88534eBEfdFb',
|
||||
rawPayload: { source: 'repo_seed', assetId: CURRENT_EURE_ASSET_ID },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function buildSeedPairs() {
|
||||
return [
|
||||
{
|
||||
pairId: CURRENT_PAIR_KEY,
|
||||
venue: NEAR_INTENTS_VENUE,
|
||||
assetIn: CURRENT_NBTC_ASSET_ID,
|
||||
assetOut: CURRENT_EURE_ASSET_ID,
|
||||
mode: 'both',
|
||||
enabled: true,
|
||||
status: 'both',
|
||||
},
|
||||
{
|
||||
pairId: CURRENT_REVERSE_PAIR_KEY,
|
||||
venue: NEAR_INTENTS_VENUE,
|
||||
assetIn: CURRENT_EURE_ASSET_ID,
|
||||
assetOut: CURRENT_NBTC_ASSET_ID,
|
||||
mode: 'both',
|
||||
enabled: true,
|
||||
status: 'both',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function buildSeedStrategyConfig(pairId, {
|
||||
version = 1,
|
||||
active = true,
|
||||
createdBy = 'repo_seed',
|
||||
reason = 'seed current nBTC/EURe production config',
|
||||
} = {}) {
|
||||
return {
|
||||
configId: `${pairId}:v${version}`,
|
||||
pairId,
|
||||
version,
|
||||
active,
|
||||
edgeBps: CURRENT_EDGE_BPS,
|
||||
maxNotional: CURRENT_STRATEGY_MAX_NOTIONAL,
|
||||
minNotional: '0',
|
||||
slippageBps: CURRENT_SLIPPAGE_BPS,
|
||||
minDeadlineMs: CURRENT_MIN_DEADLINE_MS,
|
||||
priceMaxAgeMs: CURRENT_PRICE_MAX_AGE_MS,
|
||||
inventoryMaxAgeMs: CURRENT_INVENTORY_MAX_AGE_MS,
|
||||
requestDefaultNotional: CURRENT_REQUEST_DEFAULT_NOTIONAL_EURE,
|
||||
requestMaxNotional: CURRENT_REQUEST_MAX_NOTIONAL_EURE,
|
||||
requestMaxSlippageBps: CURRENT_SLIPPAGE_BPS,
|
||||
createdBy,
|
||||
reason,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildSeedPriceRoute(pairId) {
|
||||
return {
|
||||
routeId: `${pairId}:btc-eur-reference`,
|
||||
pairId,
|
||||
source: 'btc_eur_reference',
|
||||
baseAssetId: CURRENT_NBTC_ASSET_ID,
|
||||
quoteAssetId: CURRENT_EURE_ASSET_ID,
|
||||
routeConfig: {
|
||||
reference_pair: 'BTC/EUR',
|
||||
eure_per_eur_assumption: '1',
|
||||
},
|
||||
maxAgeMs: CURRENT_PRICE_MAX_AGE_MS,
|
||||
enabled: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function pairCanObserve(pair) {
|
||||
return Boolean(pair?.enabled) && PAIR_MODES.has(pair.mode) && pair.status !== 'disabled';
|
||||
}
|
||||
|
||||
export function pairCanMake(pair) {
|
||||
return pairCanObserve(pair) && ['maker', 'both'].includes(pair.mode);
|
||||
}
|
||||
|
||||
export function pairCanTake(pair) {
|
||||
return pairCanObserve(pair) && ['taker', 'both'].includes(pair.mode);
|
||||
}
|
||||
|
||||
export function normalizePairMode(mode) {
|
||||
const normalized = String(mode || '').trim().toLowerCase();
|
||||
if (!PAIR_MODES.has(normalized)) throw new Error(`unsupported pair mode: ${mode}`);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function stringField(value, field) {
|
||||
const normalized = optionalString(value);
|
||||
if (!normalized) throw new Error(`${field} is required`);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function optionalString(value) {
|
||||
const normalized = String(value ?? '').trim();
|
||||
return normalized || null;
|
||||
}
|
||||
|
||||
function integerField(value, field) {
|
||||
const number = Number(value);
|
||||
if (!Number.isInteger(number) || number < 0) {
|
||||
throw new Error(`${field} must be a non-negative integer`);
|
||||
}
|
||||
return number;
|
||||
}
|
||||
|
||||
function optionalTimestamp(value) {
|
||||
if (value == null || value === '') return null;
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) throw new Error('priceUpdatedAt must be a timestamp');
|
||||
return date.toISOString();
|
||||
}
|
||||
|
||||
function isRecord(value) {
|
||||
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
import { loadDotenv } from './env.mjs';
|
||||
import { DEFAULT_NEAR_INTENTS_PAIR_FILTER } from '../core/pair-filter.mjs';
|
||||
|
||||
const DEFAULTS = {
|
||||
nearIntentsWsUrl: 'wss://solver-relay-v2.chaindefuser.com/ws',
|
||||
|
|
@ -7,8 +6,6 @@ const DEFAULTS = {
|
|||
nearBridgeRpcUrl: 'https://bridge.chaindefuser.com/rpc',
|
||||
nearRpcUrl: 'https://near.lava.build',
|
||||
nearVerifierContract: 'intents.near',
|
||||
nearIntentsPairFilter: DEFAULT_NEAR_INTENTS_PAIR_FILTER,
|
||||
nearIntentsPairFilterReloadMs: 5_000,
|
||||
nearIntentsControlApiEnabled: true,
|
||||
nearIntentsControlHost: '0.0.0.0',
|
||||
nearIntentsControlPort: 8081,
|
||||
|
|
@ -186,21 +183,17 @@ export function loadConfig({ envPath = '.env' } = {}) {
|
|||
loadDotenv(envPath);
|
||||
|
||||
const tradingBtc = buildAsset({
|
||||
assetId: process.env.TRADING_BTC_ASSET_ID || DEFAULTS.tradingBtcAssetId,
|
||||
symbol: process.env.TRADING_BTC_SYMBOL || DEFAULTS.tradingBtcSymbol,
|
||||
label: process.env.TRADING_BTC_LABEL || DEFAULTS.tradingBtcLabel,
|
||||
decimals: parseNumber(process.env.TRADING_BTC_DECIMALS, DEFAULTS.tradingBtcDecimals),
|
||||
chain: process.env.TRADING_BTC_CHAIN || DEFAULTS.tradingBtcChain,
|
||||
withdrawAddress:
|
||||
process.env.TRADING_BTC_WITHDRAW_ADDRESS || DEFAULTS.tradingBtcWithdrawAddress,
|
||||
assetId: DEFAULTS.tradingBtcAssetId,
|
||||
symbol: DEFAULTS.tradingBtcSymbol,
|
||||
label: DEFAULTS.tradingBtcLabel,
|
||||
decimals: DEFAULTS.tradingBtcDecimals,
|
||||
chain: DEFAULTS.tradingBtcChain,
|
||||
withdrawAddress: DEFAULTS.tradingBtcWithdrawAddress,
|
||||
role: 'trading',
|
||||
});
|
||||
const configuredTrackedBtcAssetIds = splitCsv(process.env.TRADING_BTC_TRACKED_ASSET_IDS);
|
||||
const trackedBtcAssetIds = unique([
|
||||
tradingBtc.assetId,
|
||||
...(configuredTrackedBtcAssetIds.length
|
||||
? configuredTrackedBtcAssetIds
|
||||
: DEFAULTS.tradingBtcTrackedAssetIds),
|
||||
...DEFAULTS.tradingBtcTrackedAssetIds,
|
||||
]);
|
||||
const tradingBtcAssets = trackedBtcAssetIds.map((assetId) => {
|
||||
if (assetId === tradingBtc.assetId) return tradingBtc;
|
||||
|
|
@ -215,13 +208,12 @@ export function loadConfig({ envPath = '.env' } = {}) {
|
|||
});
|
||||
});
|
||||
const tradingEure = buildAsset({
|
||||
assetId: process.env.TRADING_EURE_ASSET_ID || DEFAULTS.tradingEureAssetId,
|
||||
symbol: process.env.TRADING_EURE_SYMBOL || DEFAULTS.tradingEureSymbol,
|
||||
label: process.env.TRADING_EURE_LABEL || DEFAULTS.tradingEureSymbol,
|
||||
decimals: parseNumber(process.env.TRADING_EURE_DECIMALS, DEFAULTS.tradingEureDecimals),
|
||||
chain: process.env.TRADING_EURE_CHAIN || DEFAULTS.tradingEureChain,
|
||||
withdrawAddress:
|
||||
process.env.TRADING_EURE_WITHDRAW_ADDRESS || DEFAULTS.tradingEureWithdrawAddress,
|
||||
assetId: DEFAULTS.tradingEureAssetId,
|
||||
symbol: DEFAULTS.tradingEureSymbol,
|
||||
label: DEFAULTS.tradingEureSymbol,
|
||||
decimals: DEFAULTS.tradingEureDecimals,
|
||||
chain: DEFAULTS.tradingEureChain,
|
||||
withdrawAddress: DEFAULTS.tradingEureWithdrawAddress,
|
||||
role: 'trading',
|
||||
});
|
||||
const trackedAssets = [
|
||||
|
|
@ -243,13 +235,6 @@ export function loadConfig({ envPath = '.env' } = {}) {
|
|||
nearRpcUrl: process.env.NEAR_RPC_URL || DEFAULTS.nearRpcUrl,
|
||||
nearVerifierContract:
|
||||
process.env.NEAR_INTENTS_VERIFIER_CONTRACT || DEFAULTS.nearVerifierContract,
|
||||
nearIntentsPairFilter:
|
||||
process.env.NEAR_INTENTS_PAIR_FILTER || DEFAULTS.nearIntentsPairFilter,
|
||||
nearIntentsPairFilterFile: process.env.NEAR_INTENTS_PAIR_FILTER_FILE || '',
|
||||
nearIntentsPairFilterReloadMs: parseNumber(
|
||||
process.env.NEAR_INTENTS_PAIR_FILTER_RELOAD_MS,
|
||||
DEFAULTS.nearIntentsPairFilterReloadMs,
|
||||
),
|
||||
nearIntentsControlApiEnabled: parseBoolean(
|
||||
process.env.NEAR_INTENTS_CONTROL_API_ENABLED,
|
||||
DEFAULTS.nearIntentsControlApiEnabled,
|
||||
|
|
@ -388,6 +373,16 @@ export function loadConfig({ envPath = '.env' } = {}) {
|
|||
process.env.OPERATOR_DASHBOARD_CONTROL_PORT,
|
||||
DEFAULTS.operatorDashboardControlPort,
|
||||
),
|
||||
operatorDashboardControlBaseUrl:
|
||||
process.env.OPERATOR_DASHBOARD_CONTROL_BASE_URL
|
||||
|| defaultControlBaseUrl({
|
||||
serviceName: 'operator-dashboard',
|
||||
port: parseNumber(
|
||||
process.env.OPERATOR_DASHBOARD_CONTROL_PORT,
|
||||
DEFAULTS.operatorDashboardControlPort,
|
||||
),
|
||||
namespace: projectNamespace,
|
||||
}),
|
||||
kafkaBrokers: splitCsv(process.env.KAFKA_BROKERS).length
|
||||
? splitCsv(process.env.KAFKA_BROKERS)
|
||||
: DEFAULTS.kafkaBrokers,
|
||||
|
|
@ -469,26 +464,14 @@ export function loadConfig({ envPath = '.env' } = {}) {
|
|||
process.env.LIQUIDITY_REFRESH_MS,
|
||||
DEFAULTS.liquidityRefreshMs,
|
||||
),
|
||||
strategyGrossThresholdPct: parseNumber(
|
||||
process.env.STRATEGY_GROSS_THRESHOLD_PCT,
|
||||
DEFAULTS.strategyGrossThresholdPct,
|
||||
),
|
||||
strategyGrossThresholdPct: DEFAULTS.strategyGrossThresholdPct,
|
||||
strategyInitialArmed: parseBoolean(
|
||||
process.env.STRATEGY_INITIAL_ARMED,
|
||||
DEFAULTS.strategyInitialArmed,
|
||||
),
|
||||
strategyMaxNotionalEure: parseNumber(
|
||||
process.env.STRATEGY_MAX_NOTIONAL_EURE,
|
||||
DEFAULTS.strategyMaxNotionalEure,
|
||||
),
|
||||
strategyPriceMaxAgeMs: parseNumber(
|
||||
process.env.STRATEGY_PRICE_MAX_AGE_MS,
|
||||
DEFAULTS.strategyPriceMaxAgeMs,
|
||||
),
|
||||
strategyInventoryMaxAgeMs: parseNumber(
|
||||
process.env.STRATEGY_INVENTORY_MAX_AGE_MS,
|
||||
DEFAULTS.strategyInventoryMaxAgeMs,
|
||||
),
|
||||
strategyMaxNotionalEure: DEFAULTS.strategyMaxNotionalEure,
|
||||
strategyPriceMaxAgeMs: DEFAULTS.strategyPriceMaxAgeMs,
|
||||
strategyInventoryMaxAgeMs: DEFAULTS.strategyInventoryMaxAgeMs,
|
||||
executorInitialArmed: parseBoolean(
|
||||
process.env.EXECUTOR_INITIAL_ARMED,
|
||||
DEFAULTS.executorInitialArmed,
|
||||
|
|
@ -497,26 +480,11 @@ export function loadConfig({ envPath = '.env' } = {}) {
|
|||
process.env.EXECUTOR_RESPONSE_TIMEOUT_MS,
|
||||
DEFAULTS.executorResponseTimeoutMs,
|
||||
),
|
||||
intentRequestDefaultAmountEure: parseNumber(
|
||||
process.env.INTENT_REQUEST_DEFAULT_AMOUNT_EURE,
|
||||
DEFAULTS.intentRequestDefaultAmountEure,
|
||||
),
|
||||
intentRequestMaxAmountEure: parseNumber(
|
||||
process.env.INTENT_REQUEST_MAX_AMOUNT_EURE,
|
||||
DEFAULTS.intentRequestMaxAmountEure,
|
||||
),
|
||||
intentRequestDefaultSlippageBps: parseNumber(
|
||||
process.env.INTENT_REQUEST_DEFAULT_SLIPPAGE_BPS,
|
||||
DEFAULTS.intentRequestDefaultSlippageBps,
|
||||
),
|
||||
intentRequestMaxSlippageBps: parseNumber(
|
||||
process.env.INTENT_REQUEST_MAX_SLIPPAGE_BPS,
|
||||
DEFAULTS.intentRequestMaxSlippageBps,
|
||||
),
|
||||
intentRequestMinDeadlineMs: parseNumber(
|
||||
process.env.INTENT_REQUEST_MIN_DEADLINE_MS,
|
||||
DEFAULTS.intentRequestMinDeadlineMs,
|
||||
),
|
||||
intentRequestDefaultAmountEure: DEFAULTS.intentRequestDefaultAmountEure,
|
||||
intentRequestMaxAmountEure: DEFAULTS.intentRequestMaxAmountEure,
|
||||
intentRequestDefaultSlippageBps: DEFAULTS.intentRequestDefaultSlippageBps,
|
||||
intentRequestMaxSlippageBps: DEFAULTS.intentRequestMaxSlippageBps,
|
||||
intentRequestMinDeadlineMs: DEFAULTS.intentRequestMinDeadlineMs,
|
||||
intentRequestQuoteTimeoutMs: parseNumber(
|
||||
process.env.INTENT_REQUEST_QUOTE_TIMEOUT_MS,
|
||||
DEFAULTS.intentRequestQuoteTimeoutMs,
|
||||
|
|
@ -529,14 +497,8 @@ export function loadConfig({ envPath = '.env' } = {}) {
|
|||
process.env.INTENT_REQUEST_STATUS_TIMEOUT_MS,
|
||||
DEFAULTS.intentRequestStatusTimeoutMs,
|
||||
),
|
||||
intentRequestInventoryMaxAgeMs: parseNumber(
|
||||
process.env.INTENT_REQUEST_INVENTORY_MAX_AGE_MS,
|
||||
DEFAULTS.intentRequestInventoryMaxAgeMs,
|
||||
),
|
||||
intentRequestPriceMaxAgeMs: parseNumber(
|
||||
process.env.INTENT_REQUEST_PRICE_MAX_AGE_MS,
|
||||
DEFAULTS.intentRequestPriceMaxAgeMs,
|
||||
),
|
||||
intentRequestInventoryMaxAgeMs: DEFAULTS.intentRequestInventoryMaxAgeMs,
|
||||
intentRequestPriceMaxAgeMs: DEFAULTS.intentRequestPriceMaxAgeMs,
|
||||
withdrawalsFrozen: parseBoolean(
|
||||
process.env.LIQUIDITY_WITHDRAWALS_FROZEN,
|
||||
DEFAULTS.withdrawalsFrozen,
|
||||
|
|
|
|||
1282
src/lib/postgres.mjs
1282
src/lib/postgres.mjs
File diff suppressed because it is too large
Load diff
|
|
@ -155,7 +155,7 @@ export default function App() {
|
|||
/>
|
||||
) : null}
|
||||
{currentPage === 'strategy' ? (
|
||||
<StrategyPage strategy={state.dashboard.strategy} />
|
||||
<StrategyPage onControl={submitControl} strategy={state.dashboard.strategy} />
|
||||
) : null}
|
||||
{currentPage === 'system' ? (
|
||||
<SystemPage onControl={submitControl} system={state.dashboard.system} />
|
||||
|
|
|
|||
|
|
@ -294,7 +294,160 @@ function SuccessfulTradesTable({ items }) {
|
|||
);
|
||||
}
|
||||
|
||||
export default function StrategyPage({ strategy }) {
|
||||
function AssetCatalogSection({ assetCatalog, onControl }) {
|
||||
const latest = assetCatalog?.latest_import || null;
|
||||
const counts = assetCatalog?.counts || {};
|
||||
const items = assetCatalog?.items || [];
|
||||
|
||||
return (
|
||||
<section className="panel">
|
||||
<div className="panel-head">
|
||||
<div>
|
||||
<div className="eyebrow">Asset registry</div>
|
||||
<h3>Supported-token import status</h3>
|
||||
<div className="panel-subtitle">
|
||||
Last import {latest?.fetched_at ? formatTimestamp(latest.fetched_at) : 'not run'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="pills">
|
||||
<Pill label={latest?.status || 'not imported'} stateLabel={latest?.status === 'success' ? 'healthy' : 'warning'} />
|
||||
<button className="button secondary" onClick={() => onControl?.('operator-dashboard', 'import-supported-assets')} type="button">
|
||||
Import assets
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="metric-grid">
|
||||
<MetricCard label="Known assets" meta={`${counts.inventory_enabled || 0} inventory tracked`} value={String(counts.known || 0)} />
|
||||
<MetricCard label="Supported now" meta={`${latest?.token_count || 0} tokens in latest run`} value={String(counts.supported || 0)} />
|
||||
<MetricCard label="Retired" meta="Kept for balances and history" value={String(counts.retired || 0)} />
|
||||
</div>
|
||||
<TableFrame>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Asset</th>
|
||||
<th>Decimals</th>
|
||||
<th>Chain</th>
|
||||
<th>Price</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.length ? items.slice(0, 20).map((asset) => (
|
||||
<tr key={asset.asset_id || asset.assetId}>
|
||||
<td>
|
||||
<div>{asset.label || asset.symbol}</div>
|
||||
<div className="status-subtle mono">{truncateMiddle(asset.asset_id || asset.assetId, 42)}</div>
|
||||
</td>
|
||||
<td>{asset.decimals}</td>
|
||||
<td>{asset.blockchain || asset.chain || 'Unavailable'}</td>
|
||||
<td>{asset.latest_price || asset.latestPrice || 'Unavailable'}</td>
|
||||
<td>
|
||||
<Pill
|
||||
label={asset.supported ? 'supported' : 'retired'}
|
||||
stateLabel={asset.supported ? 'healthy' : 'warning'}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
)) : (
|
||||
<tr><td colSpan={5}>No DB asset registry rows are available.</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</TableFrame>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function PairConfigSection({ pairConfig, onControl }) {
|
||||
const pairs = pairConfig?.pairs || [];
|
||||
|
||||
async function updateEdge(pair) {
|
||||
const current = pair.strategyConfig?.edge_bps ?? pair.strategy_config?.edge_bps ?? pair.edge_bps ?? '';
|
||||
const next = window.prompt('edge_bps', current);
|
||||
if (!next) return;
|
||||
await onControl?.('operator-dashboard', 'update-pair-edge', {
|
||||
pair_id: pair.pair_id || pair.pairId,
|
||||
edge_bps: Number(next),
|
||||
});
|
||||
}
|
||||
|
||||
async function enableObserveOnly() {
|
||||
const assetIn = window.prompt('asset_in');
|
||||
if (!assetIn) return;
|
||||
const assetOut = window.prompt('asset_out');
|
||||
if (!assetOut) return;
|
||||
await onControl?.('operator-dashboard', 'enable-observe-only-pair', {
|
||||
asset_in: assetIn,
|
||||
asset_out: assetOut,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="panel">
|
||||
<div className="panel-head">
|
||||
<div>
|
||||
<div className="eyebrow">Pair config</div>
|
||||
<h3>Directed pairs and active strategy versions</h3>
|
||||
<div className="panel-subtitle">
|
||||
Loaded {pairConfig?.loaded_at ? formatTimestamp(pairConfig.loaded_at) : 'unavailable'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="pills">
|
||||
<Pill label={pairConfig?.ok ? 'config loaded' : pairConfig?.block_reason || 'blocked'} stateLabel={pairConfig?.ok ? 'healthy' : 'warning'} />
|
||||
<button className="button secondary" onClick={enableObserveOnly} type="button">Observe-only pair</button>
|
||||
</div>
|
||||
</div>
|
||||
<TableFrame>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Pair</th>
|
||||
<th>Mode</th>
|
||||
<th>Edge</th>
|
||||
<th>Limits</th>
|
||||
<th>Route</th>
|
||||
<th>Blocked</th>
|
||||
<th>Config</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{pairs.length ? pairs.map((pair) => {
|
||||
const strategyConfig = pair.strategyConfig || pair.strategy_config || {};
|
||||
const route = pair.priceRoute || pair.price_route || {};
|
||||
return (
|
||||
<tr key={pair.pair_id || pair.pairId}>
|
||||
<td>
|
||||
<div>{pair.asset_in_symbol || pair.asset_in} {'->'} {pair.asset_out_symbol || pair.asset_out}</div>
|
||||
<div className="status-subtle mono">{truncateMiddle(pair.pair_id || pair.pairId, 42)}</div>
|
||||
</td>
|
||||
<td><Pill label={pair.mode || pair.status} stateLabel={pair.canTrade || pair.can_trade ? 'healthy' : 'warning'} /></td>
|
||||
<td>{strategyConfig.edge_bps ?? 'Unavailable'} bps</td>
|
||||
<td>
|
||||
<div>{strategyConfig.max_notional || 'Unavailable'} max</div>
|
||||
<div className="status-subtle">{strategyConfig.price_max_age_ms || 'Unavailable'} ms price max age</div>
|
||||
</td>
|
||||
<td>{route.source || 'Unavailable'}</td>
|
||||
<td>{pair.blockReason || pair.block_reason || 'No'}</td>
|
||||
<td>
|
||||
<div>v{strategyConfig.version || 'Unavailable'}</div>
|
||||
<button className="button secondary trace-copy-button" onClick={() => updateEdge(pair)} type="button">
|
||||
Edge
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}) : (
|
||||
<tr><td colSpan={7}>No directed pairs are configured.</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</TableFrame>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default function StrategyPage({ strategy, onControl }) {
|
||||
const funnel = strategy.strategy_state.trade_funnel || {};
|
||||
const counts = funnel.counts || {};
|
||||
|
||||
|
|
@ -326,6 +479,10 @@ export default function StrategyPage({ strategy }) {
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<AssetCatalogSection assetCatalog={strategy.asset_catalog} onControl={onControl} />
|
||||
|
||||
<PairConfigSection pairConfig={strategy.pair_config} onControl={onControl} />
|
||||
|
||||
<section className="panel">
|
||||
<div className="panel-head">
|
||||
<div>
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ export async function startNearIntentsWs({
|
|||
wsUrl = DEFAULT_WS_URL,
|
||||
pairFilter,
|
||||
getPairFilter = () => pairFilter,
|
||||
matchesPair = null,
|
||||
producer,
|
||||
rawTopic,
|
||||
normalizedTopic,
|
||||
|
|
@ -25,6 +26,7 @@ export async function startNearIntentsWs({
|
|||
let quoteSubscriptionId = null;
|
||||
let quoteStatusSubscriptionId = null;
|
||||
let publishedCount = 0;
|
||||
let rawPublishedCount = 0;
|
||||
let publishLocked = false;
|
||||
let closed = false;
|
||||
let reconnectTimer = null;
|
||||
|
|
@ -101,8 +103,24 @@ export async function startNearIntentsWs({
|
|||
if (quoteSubscriptionId && subscription && subscription !== quoteSubscriptionId) return;
|
||||
if (publishLocked) return;
|
||||
|
||||
const rawEnvelope = buildNearIntentsRawEnvelope(merged);
|
||||
const envelope = buildNearIntentsQuoteEnvelope(merged);
|
||||
const rawEnvelope = buildNearIntentsRawEnvelope(merged);
|
||||
|
||||
try {
|
||||
await producer.sendJson(rawTopic, rawEnvelope, { key: rawEnvelope.event_id });
|
||||
rawPublishedCount += 1;
|
||||
} catch (error) {
|
||||
publishErrorCount += 1;
|
||||
logger?.error('raw_publish_failed', {
|
||||
namespace,
|
||||
topic: rawTopic,
|
||||
details: {
|
||||
error: serializeError(error),
|
||||
quote_id: rawEnvelope.payload?.message?.quote_id || rawEnvelope.payload?.message?.quote_hash || null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (!envelope) return;
|
||||
assertNormalizedSwapDemand(envelope);
|
||||
|
||||
|
|
@ -110,8 +128,10 @@ export async function startNearIntentsWs({
|
|||
const assetOut = envelope.payload?.asset_out;
|
||||
if (!assetIn || !assetOut) return;
|
||||
|
||||
const activePairFilter = getPairFilter();
|
||||
if (!matchesPairFilter(assetIn, assetOut, activePairFilter)) {
|
||||
const pairAllowed = matchesPair
|
||||
? await matchesPair(assetIn, assetOut)
|
||||
: matchesPairFilter(assetIn, assetOut, getPairFilter());
|
||||
if (!pairAllowed) {
|
||||
filteredCount += 1;
|
||||
return;
|
||||
}
|
||||
|
|
@ -119,7 +139,6 @@ export async function startNearIntentsWs({
|
|||
|
||||
publishLocked = true;
|
||||
try {
|
||||
await producer.sendJson(rawTopic, rawEnvelope, { key: rawEnvelope.event_id });
|
||||
await producer.sendJson(normalizedTopic, envelope, { key: envelope.payload.quote_id });
|
||||
publishedCount += 1;
|
||||
lastPublishedAt = new Date().toISOString();
|
||||
|
|
@ -202,6 +221,7 @@ export async function startNearIntentsWs({
|
|||
frames_received: framesReceived,
|
||||
quote_frames_received: quoteFramesReceived,
|
||||
filtered_count: filteredCount,
|
||||
raw_published_count: rawPublishedCount,
|
||||
published_count: publishedCount,
|
||||
publish_error_count: publishErrorCount,
|
||||
invalid_json_count: invalidJsonCount,
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import { loadConfig } from '../src/lib/config.mjs';
|
|||
|
||||
const ENV_KEYS = [
|
||||
'NEAR_RPC_URL',
|
||||
'NEAR_INTENTS_PAIR_FILTER',
|
||||
'TRADING_BTC_ASSET_ID',
|
||||
'TRADING_BTC_TRACKED_ASSET_IDS',
|
||||
'TRADING_BTC_LABEL',
|
||||
|
|
@ -33,10 +32,6 @@ test('default config trades nBTC while still tracking legacy BTC', () => withCle
|
|||
const config = loadConfig({ envPath: '/tmp/unrip-no-such-env-file' });
|
||||
|
||||
assert.equal(config.nearRpcUrl, 'https://near.lava.build');
|
||||
assert.equal(
|
||||
config.nearIntentsPairFilter,
|
||||
`${NBTC}->${EURE}`,
|
||||
);
|
||||
assert.equal(config.tradingBtc.assetId, NBTC);
|
||||
assert.deepEqual(config.activeAssetIds, [NBTC, EURE]);
|
||||
assert.deepEqual(config.trackedAssetIds, [NBTC, LEGACY_BTC, EURE]);
|
||||
|
|
@ -45,8 +40,8 @@ test('default config trades nBTC while still tracking legacy BTC', () => withCle
|
|||
assert.equal(config.assetRegistry.get(LEGACY_BTC).role, 'legacy');
|
||||
}));
|
||||
|
||||
test('tracked BTC ids always include the configured trading BTC reserve', () => withCleanEnv(() => {
|
||||
process.env.TRADING_BTC_ASSET_ID = NBTC;
|
||||
test('legacy trading asset env overrides are ignored by runtime config', () => withCleanEnv(() => {
|
||||
process.env.TRADING_BTC_ASSET_ID = 'nep141:wrong-btc.near';
|
||||
process.env.TRADING_BTC_TRACKED_ASSET_IDS = LEGACY_BTC;
|
||||
|
||||
const config = loadConfig({ envPath: '/tmp/unrip-no-such-env-file' });
|
||||
|
|
|
|||
|
|
@ -22,16 +22,16 @@ function withCleanEnv(fn) {
|
|||
}
|
||||
}
|
||||
|
||||
test('history writer derived refresh replay cutoff matches request inventory freshness', () => withCleanEnv(() => {
|
||||
test('history writer derived refresh replay cutoff keeps the approved default', () => withCleanEnv(() => {
|
||||
const config = loadConfig({ envPath: '/tmp/unrip-no-such-env-file' });
|
||||
|
||||
assert.equal(config.historyWriterDerivedRefreshMaxEventAgeMs, 30000);
|
||||
assert.equal(config.historyWriterDerivedRefreshMaxEventAgeMs, config.intentRequestInventoryMaxAgeMs);
|
||||
}));
|
||||
|
||||
test('kubernetes history writer replay cutoff matches request inventory freshness', () => {
|
||||
test('legacy request freshness env no longer ships in kubernetes config', () => {
|
||||
const manifest = readFileSync(new URL('../deploy/k8s/base/unrip.yaml', import.meta.url), 'utf8');
|
||||
|
||||
assert.match(manifest, /HISTORY_WRITER_DERIVED_REFRESH_MAX_EVENT_AGE_MS: "30000"/);
|
||||
assert.match(manifest, /INTENT_REQUEST_INVENTORY_MAX_AGE_MS: "30000"/);
|
||||
assert.doesNotMatch(manifest, /INTENT_REQUEST_INVENTORY_MAX_AGE_MS/);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,24 +2,23 @@ import test from 'node:test';
|
|||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
import { loadConfig } from '../src/lib/config.mjs';
|
||||
import {
|
||||
CURRENT_PAIR_KEY,
|
||||
buildSeedStrategyConfig,
|
||||
} from '../src/core/trading-config.mjs';
|
||||
|
||||
test('repo default strategy threshold reflects explicitly approved 0.49 percent edge', () => {
|
||||
const previous = process.env.STRATEGY_GROSS_THRESHOLD_PCT;
|
||||
delete process.env.STRATEGY_GROSS_THRESHOLD_PCT;
|
||||
|
||||
try {
|
||||
const config = loadConfig({ envPath: '/tmp/unrip-no-such-env-file' });
|
||||
assert.equal(config.strategyGrossThresholdPct, 0.49);
|
||||
} finally {
|
||||
if (previous == null) delete process.env.STRATEGY_GROSS_THRESHOLD_PCT;
|
||||
else process.env.STRATEGY_GROSS_THRESHOLD_PCT = previous;
|
||||
}
|
||||
test('repo DB seed carries the approved 49 bps current-pair edge', () => {
|
||||
const config = buildSeedStrategyConfig(CURRENT_PAIR_KEY);
|
||||
assert.equal(config.edgeBps, 49);
|
||||
});
|
||||
|
||||
test('kubernetes strategy threshold deploys the approved 0.49 percent edge', () => {
|
||||
test('kubernetes production config does not carry pair, asset, or edge env vars', () => {
|
||||
const manifest = readFileSync(new URL('../deploy/k8s/base/unrip.yaml', import.meta.url), 'utf8');
|
||||
assert.match(manifest, /STRATEGY_GROSS_THRESHOLD_PCT: "0\.49"/);
|
||||
assert.doesNotMatch(manifest, /STRATEGY_GROSS_THRESHOLD_PCT: "0\.99"/);
|
||||
assert.doesNotMatch(manifest, /STRATEGY_GROSS_THRESHOLD_PCT: "1\.49"/);
|
||||
assert.doesNotMatch(manifest, /NEAR_INTENTS_PAIR_FILTER/);
|
||||
assert.doesNotMatch(manifest, /TRADING_BTC_/);
|
||||
assert.doesNotMatch(manifest, /TRADING_EURE_/);
|
||||
assert.doesNotMatch(manifest, /STRATEGY_GROSS_THRESHOLD_PCT/);
|
||||
assert.doesNotMatch(manifest, /STRATEGY_MAX_NOTIONAL_EURE/);
|
||||
assert.doesNotMatch(manifest, /INTENT_REQUEST_DEFAULT_AMOUNT_EURE/);
|
||||
assert.doesNotMatch(manifest, /INTENT_REQUEST_MAX_AMOUNT_EURE/);
|
||||
});
|
||||
|
|
|
|||
458
test/trading-config.test.mjs
Normal file
458
test/trading-config.test.mjs
Normal file
|
|
@ -0,0 +1,458 @@
|
|||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { evaluateTradeOpportunity } from '../src/core/strategy.mjs';
|
||||
import {
|
||||
CURRENT_EURE_ASSET_ID,
|
||||
CURRENT_NBTC_ASSET_ID,
|
||||
LEGACY_OMFT_BTC_ASSET_ID,
|
||||
normalizeOneClickToken,
|
||||
} from '../src/core/trading-config.mjs';
|
||||
import {
|
||||
createPairStrategyConfigVersion,
|
||||
enableObserveOnlyPair,
|
||||
importSupportedAssets,
|
||||
loadTradingConfig,
|
||||
seedTradingConfig,
|
||||
} from '../src/lib/postgres.mjs';
|
||||
|
||||
test('1Click token normalizer preserves live asset fields', () => {
|
||||
const token = normalizeOneClickToken({
|
||||
assetId: CURRENT_NBTC_ASSET_ID,
|
||||
decimals: 8,
|
||||
blockchain: 'near',
|
||||
symbol: 'BTC',
|
||||
price: 80293,
|
||||
priceUpdatedAt: '2026-05-12T16:25:00.425Z',
|
||||
contractAddress: 'nbtc.bridge.near',
|
||||
});
|
||||
|
||||
assert.equal(token.assetId, CURRENT_NBTC_ASSET_ID);
|
||||
assert.equal(token.decimals, 8);
|
||||
assert.equal(token.symbol, 'BTC');
|
||||
assert.equal(token.latestPrice, '80293');
|
||||
assert.equal(token.priceUpdatedAt, '2026-05-12T16:25:00.425Z');
|
||||
});
|
||||
|
||||
test('supported token import is idempotent, does not enable inventory, and retires missing assets', async () => {
|
||||
const pool = createMemoryPool();
|
||||
const first = await importSupportedAssets(pool, {
|
||||
fetchedAt: '2026-05-12T16:30:00.000Z',
|
||||
response: [
|
||||
token(CURRENT_NBTC_ASSET_ID, 'BTC', 8),
|
||||
token(CURRENT_EURE_ASSET_ID, 'EURe', 18),
|
||||
],
|
||||
});
|
||||
|
||||
assert.equal(first.added_count, 2);
|
||||
assert.equal(pool.assets.get(CURRENT_NBTC_ASSET_ID).enabled_for_inventory, false);
|
||||
assert.equal(Object.hasOwn(first, 'raw_response'), false);
|
||||
|
||||
const second = await importSupportedAssets(pool, {
|
||||
fetchedAt: '2026-05-12T16:31:00.000Z',
|
||||
response: [
|
||||
token(CURRENT_NBTC_ASSET_ID, 'BTC', 8),
|
||||
token(CURRENT_EURE_ASSET_ID, 'EURe', 18),
|
||||
],
|
||||
});
|
||||
assert.equal(second.added_count, 0);
|
||||
assert.equal(second.unchanged_count, 2);
|
||||
|
||||
const third = await importSupportedAssets(pool, {
|
||||
fetchedAt: '2026-05-12T16:32:00.000Z',
|
||||
response: [
|
||||
{ ...token(CURRENT_NBTC_ASSET_ID, 'BTC', 8), price: 81000 },
|
||||
],
|
||||
});
|
||||
assert.equal(third.updated_count, 1);
|
||||
assert.equal(third.retired_count, 1);
|
||||
assert.equal(pool.assets.get(CURRENT_EURE_ASSET_ID).supported, false);
|
||||
assert.equal(pool.assets.get(CURRENT_EURE_ASSET_ID).retired_at, '2026-05-12T16:32:00.000Z');
|
||||
});
|
||||
|
||||
test('seeded DB config preserves current nBTC/EURe pair, 49 bps edge, and legacy BTC tracking', async () => {
|
||||
const pool = createMemoryPool();
|
||||
await seedTradingConfig(pool, { now: '2026-05-12T16:35:00.000Z' });
|
||||
await seedTradingConfig(pool, { now: '2026-05-12T16:36:00.000Z' });
|
||||
|
||||
const snapshot = await loadTradingConfig(pool);
|
||||
|
||||
assert.equal(snapshot.ok, true);
|
||||
assert.equal(snapshot.activePair, `${CURRENT_NBTC_ASSET_ID}->${CURRENT_EURE_ASSET_ID}`);
|
||||
assert.equal(snapshot.pairs.length, 2);
|
||||
assert.equal(snapshot.pairByKey.get(snapshot.activePair).strategyConfig.edgeBps, 49);
|
||||
assert.equal(snapshot.trackedAssetIds.includes(LEGACY_OMFT_BTC_ASSET_ID), true);
|
||||
assert.equal([...snapshot.makerPairKeys].some((pair) => pair.includes(LEGACY_OMFT_BTC_ASSET_ID)), false);
|
||||
});
|
||||
|
||||
test('missing DB pair config fails closed', async () => {
|
||||
const snapshot = await loadTradingConfig(createMemoryPool());
|
||||
|
||||
assert.equal(snapshot.ok, false);
|
||||
assert.equal(snapshot.blockReason, 'no_enabled_pairs');
|
||||
assert.equal(snapshot.enabledPairKeys.size, 0);
|
||||
});
|
||||
|
||||
test('edge update creates a new active strategy version', async () => {
|
||||
const pool = createMemoryPool();
|
||||
await seedTradingConfig(pool);
|
||||
|
||||
const next = await createPairStrategyConfigVersion(pool, {
|
||||
pairId: `${CURRENT_NBTC_ASSET_ID}->${CURRENT_EURE_ASSET_ID}`,
|
||||
edgeBps: 75,
|
||||
changedBy: 'test',
|
||||
reason: 'test edge update',
|
||||
});
|
||||
const snapshot = await loadTradingConfig(pool);
|
||||
const versions = [...pool.strategyConfigs.values()]
|
||||
.filter((row) => row.pair_id === `${CURRENT_NBTC_ASSET_ID}->${CURRENT_EURE_ASSET_ID}`);
|
||||
|
||||
assert.equal(next.version, 2);
|
||||
assert.equal(snapshot.pairByKey.get(`${CURRENT_NBTC_ASSET_ID}->${CURRENT_EURE_ASSET_ID}`).strategyConfig.edgeBps, 75);
|
||||
assert.equal(versions.find((row) => row.version === 1).active, false);
|
||||
});
|
||||
|
||||
test('observe-only enable does not downgrade an active trading pair', async () => {
|
||||
const pool = createMemoryPool();
|
||||
await seedTradingConfig(pool);
|
||||
const pairId = `${CURRENT_NBTC_ASSET_ID}->${CURRENT_EURE_ASSET_ID}`;
|
||||
|
||||
const pair = await enableObserveOnlyPair(pool, {
|
||||
assetIn: CURRENT_NBTC_ASSET_ID,
|
||||
assetOut: CURRENT_EURE_ASSET_ID,
|
||||
changedBy: 'test',
|
||||
reason: 'avoid downgrade',
|
||||
});
|
||||
const snapshot = await loadTradingConfig(pool);
|
||||
|
||||
assert.equal(pair.pairId, pairId);
|
||||
assert.equal(pair.mode, 'both');
|
||||
assert.equal(snapshot.pairByKey.get(pairId).makerEnabled, true);
|
||||
assert.equal(snapshot.pairByKey.get(pairId).takerEnabled, true);
|
||||
});
|
||||
|
||||
test('observe-only enable creates a non-trading tracked pair', async () => {
|
||||
const pool = createMemoryPool();
|
||||
await seedTradingConfig(pool);
|
||||
const pair = await enableObserveOnlyPair(pool, {
|
||||
assetIn: LEGACY_OMFT_BTC_ASSET_ID,
|
||||
assetOut: CURRENT_EURE_ASSET_ID,
|
||||
changedBy: 'test',
|
||||
reason: 'watch legacy route',
|
||||
});
|
||||
const snapshot = await loadTradingConfig(pool);
|
||||
|
||||
assert.equal(pair.mode, 'observe_only');
|
||||
assert.equal(snapshot.pairByKey.get(`${LEGACY_OMFT_BTC_ASSET_ID}->${CURRENT_EURE_ASSET_ID}`).observeEnabled, true);
|
||||
assert.equal(snapshot.pairByKey.get(`${LEGACY_OMFT_BTC_ASSET_ID}->${CURRENT_EURE_ASSET_ID}`).makerEnabled, false);
|
||||
assert.equal(snapshot.pairByKey.get(`${LEGACY_OMFT_BTC_ASSET_ID}->${CURRENT_EURE_ASSET_ID}`).takerEnabled, false);
|
||||
});
|
||||
|
||||
test('repo seed does not re-enable pair runtime flags already stored in DB', async () => {
|
||||
const pool = createMemoryPool();
|
||||
await seedTradingConfig(pool);
|
||||
const pairId = `${CURRENT_NBTC_ASSET_ID}->${CURRENT_EURE_ASSET_ID}`;
|
||||
const routeId = `${pairId}:btc-eur-reference`;
|
||||
Object.assign(pool.pairs.get(pairId), {
|
||||
mode: 'observe_only',
|
||||
enabled: false,
|
||||
status: 'disabled',
|
||||
});
|
||||
Object.assign(pool.routes.get(routeId), {
|
||||
enabled: false,
|
||||
});
|
||||
|
||||
await seedTradingConfig(pool);
|
||||
const snapshot = await loadTradingConfig(pool);
|
||||
const pair = snapshot.pairByKey.get(pairId);
|
||||
|
||||
assert.equal(pair.enabled, false);
|
||||
assert.equal(pair.mode, 'observe_only');
|
||||
assert.equal(pair.status, 'disabled');
|
||||
assert.equal(pool.routes.get(routeId).enabled, false);
|
||||
assert.equal(pair.priceRoute, null);
|
||||
assert.equal(pair.makerEnabled, false);
|
||||
assert.equal(pair.takerEnabled, false);
|
||||
});
|
||||
|
||||
test('strategy uses DB pair config for current pair and persists config version', async () => {
|
||||
const pool = createMemoryPool();
|
||||
const snapshot = await seedTradingConfig(pool);
|
||||
const result = evaluateTradeOpportunity({
|
||||
demandEvent: {
|
||||
payload: {
|
||||
quote_id: 'quote-db-1',
|
||||
pair: `${CURRENT_NBTC_ASSET_ID}->${CURRENT_EURE_ASSET_ID}`,
|
||||
asset_in: CURRENT_NBTC_ASSET_ID,
|
||||
asset_out: CURRENT_EURE_ASSET_ID,
|
||||
request_kind: 'exact_in',
|
||||
amount_in: '5000',
|
||||
min_deadline_ms: '60000',
|
||||
},
|
||||
},
|
||||
priceEvent: priceEvent(),
|
||||
inventoryEvent: inventoryEvent(),
|
||||
config: snapshot,
|
||||
armed: true,
|
||||
now: Date.parse('2026-05-12T16:35:05.000Z'),
|
||||
});
|
||||
|
||||
assert.equal(result.decision.decision, 'actionable');
|
||||
assert.equal(result.decision.edge_bps, '49');
|
||||
assert.equal(result.decision.pair_config_version, '1');
|
||||
assert.equal(result.command.quote_output.amount_out, '4975500000000000000');
|
||||
assert.equal(result.command.pair_config_id, `${CURRENT_NBTC_ASSET_ID}->${CURRENT_EURE_ASSET_ID}:v1`);
|
||||
});
|
||||
|
||||
function token(assetId, symbol, decimals) {
|
||||
return {
|
||||
assetId,
|
||||
decimals,
|
||||
blockchain: symbol === 'EURe' ? 'gnosis' : 'near',
|
||||
symbol,
|
||||
price: symbol === 'EURe' ? 1.17 : 80293,
|
||||
priceUpdatedAt: '2026-05-12T16:25:00.425Z',
|
||||
contractAddress: assetId.replace(/^nep141:/, ''),
|
||||
};
|
||||
}
|
||||
|
||||
function priceEvent() {
|
||||
return {
|
||||
ingested_at: '2026-05-12T16:35:00.000Z',
|
||||
payload: {
|
||||
price_id: 'price-db-1',
|
||||
pair: `${CURRENT_NBTC_ASSET_ID}->${CURRENT_EURE_ASSET_ID}`,
|
||||
eur_per_btc: '100000.00000000',
|
||||
eure_per_btc: '100000.00000000',
|
||||
btc_per_eur: '0.000010000000',
|
||||
btc_per_eure: '0.000010000000',
|
||||
source_used: 'kraken',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function inventoryEvent() {
|
||||
return {
|
||||
ingested_at: '2026-05-12T16:35:00.000Z',
|
||||
payload: {
|
||||
inventory_id: 'inventory-db-1',
|
||||
spendable: {
|
||||
[CURRENT_NBTC_ASSET_ID]: '1000000',
|
||||
[CURRENT_EURE_ASSET_ID]: '10000000000000000000',
|
||||
[LEGACY_OMFT_BTC_ASSET_ID]: '0',
|
||||
},
|
||||
pending_inbound: {
|
||||
[CURRENT_NBTC_ASSET_ID]: '0',
|
||||
[CURRENT_EURE_ASSET_ID]: '0',
|
||||
[LEGACY_OMFT_BTC_ASSET_ID]: '0',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createMemoryPool() {
|
||||
return {
|
||||
assets: new Map(),
|
||||
pairs: new Map(),
|
||||
strategyConfigs: new Map(),
|
||||
routes: new Map(),
|
||||
importRuns: new Map(),
|
||||
audit: [],
|
||||
async query(sql, params = []) {
|
||||
if (/CREATE TABLE|CREATE (UNIQUE )?INDEX/i.test(sql)) return { rows: [], rowCount: 0 };
|
||||
if (/SELECT \* FROM trading_assets\s*$/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 (/UPDATE trading_assets/i.test(sql)) return retireAssets(this, params);
|
||||
if (/INSERT INTO supported_asset_import_runs/i.test(sql)) return insertImportRun(this, params);
|
||||
if (/SELECT \*\s+FROM supported_asset_import_runs/i.test(sql)) {
|
||||
return { rows: [...this.importRuns.values()].slice(-1), rowCount: this.importRuns.size ? 1 : 0 };
|
||||
}
|
||||
if (/COUNT\(\*\)::INT AS known_count/i.test(sql)) {
|
||||
const assets = [...this.assets.values()];
|
||||
return {
|
||||
rows: [{
|
||||
known_count: assets.length,
|
||||
supported_count: assets.filter((asset) => asset.supported).length,
|
||||
retired_count: assets.filter((asset) => asset.retired_at || !asset.supported).length,
|
||||
inventory_enabled_count: assets.filter((asset) => asset.enabled_for_inventory).length,
|
||||
}],
|
||||
rowCount: 1,
|
||||
};
|
||||
}
|
||||
if (/INSERT INTO trading_pairs/i.test(sql)) return insertPair(this, params, sql);
|
||||
if (/SELECT \*\s+FROM trading_pairs\s+WHERE pair_id = \$1/i.test(sql)) {
|
||||
const row = this.pairs.get(params[0]);
|
||||
return { rows: row ? [row] : [], rowCount: row ? 1 : 0 };
|
||||
}
|
||||
if (/SELECT \*\s+FROM trading_pairs/i.test(sql)) return rows(this.pairs);
|
||||
if (/INSERT INTO pair_strategy_configs/i.test(sql)) return insertStrategyConfig(this, params);
|
||||
if (/SELECT \*\s+FROM pair_strategy_configs\s+WHERE active = true/i.test(sql)) {
|
||||
return { rows: [...this.strategyConfigs.values()].filter((row) => row.active), rowCount: 0 };
|
||||
}
|
||||
if (/SELECT \*\s+FROM pair_strategy_configs\s+WHERE pair_id = \$1 AND active = true/i.test(sql)) {
|
||||
const active = [...this.strategyConfigs.values()]
|
||||
.filter((row) => row.pair_id === params[0] && row.active)
|
||||
.sort((left, right) => right.version - left.version)[0];
|
||||
return { rows: active ? [active] : [], rowCount: active ? 1 : 0 };
|
||||
}
|
||||
if (/UPDATE pair_strategy_configs SET active = false/i.test(sql)) {
|
||||
let count = 0;
|
||||
for (const row of this.strategyConfigs.values()) {
|
||||
if (row.pair_id === params[0] && row.active) {
|
||||
row.active = false;
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
return { rows: [], rowCount: count };
|
||||
}
|
||||
if (/INSERT INTO pair_price_routes/i.test(sql)) return insertRoute(this, params, sql);
|
||||
if (/SELECT \*\s+FROM pair_price_routes/i.test(sql)) {
|
||||
return { rows: [...this.routes.values()].filter((row) => row.enabled), rowCount: 0 };
|
||||
}
|
||||
if (/INSERT INTO pair_config_audit_log/i.test(sql)) {
|
||||
this.audit.push(params);
|
||||
return { rows: [], rowCount: 1 };
|
||||
}
|
||||
throw new Error(`unhandled SQL in memory pool: ${sql}`);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function rows(map) {
|
||||
return { rows: [...map.values()], rowCount: map.size };
|
||||
}
|
||||
|
||||
function insertAsset(pool, params) {
|
||||
const seed = params.length === 16;
|
||||
const [
|
||||
assetId,
|
||||
venue,
|
||||
symbol,
|
||||
label,
|
||||
decimals,
|
||||
blockchain,
|
||||
chain,
|
||||
contractAddress,
|
||||
latestPrice,
|
||||
priceUpdatedAt,
|
||||
] = params;
|
||||
const previous = pool.assets.get(assetId);
|
||||
const row = {
|
||||
...(previous || {}),
|
||||
asset_id: assetId,
|
||||
venue,
|
||||
symbol,
|
||||
label,
|
||||
decimals,
|
||||
blockchain,
|
||||
chain,
|
||||
contract_address: contractAddress,
|
||||
latest_price: latestPrice,
|
||||
price_updated_at: priceUpdatedAt,
|
||||
supported: seed ? (previous?.supported || params[10]) : true,
|
||||
retired_at: null,
|
||||
enabled_for_inventory: seed ? true : previous?.enabled_for_inventory === true,
|
||||
role: seed ? params[12] : previous?.role || null,
|
||||
withdraw_address: seed ? params[13] : previous?.withdraw_address || '',
|
||||
raw_payload: JSON.parse(seed ? params[14] : params[10]),
|
||||
last_supported_at: seed ? params[15] : params[11],
|
||||
updated_at: seed ? params[15] : params[11],
|
||||
};
|
||||
pool.assets.set(assetId, row);
|
||||
return { rows: [], rowCount: previous ? 0 : 1 };
|
||||
}
|
||||
|
||||
function retireAssets(pool, params) {
|
||||
const [retiredAt, importedIds] = params;
|
||||
let count = 0;
|
||||
for (const row of pool.assets.values()) {
|
||||
if (row.venue === 'near-intents' && row.supported && !importedIds.includes(row.asset_id)) {
|
||||
row.supported = false;
|
||||
row.retired_at ||= retiredAt;
|
||||
row.updated_at = retiredAt;
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
return { rows: [], rowCount: count };
|
||||
}
|
||||
|
||||
function insertImportRun(pool, params) {
|
||||
const row = {
|
||||
run_id: params[0],
|
||||
source_url: params[1],
|
||||
fetched_at: params[2],
|
||||
status: params[3],
|
||||
token_count: params[4],
|
||||
added_count: params[5],
|
||||
updated_count: params[6],
|
||||
unchanged_count: params[7],
|
||||
retired_count: params[8],
|
||||
raw_response_hash: params[9],
|
||||
error: params[10],
|
||||
raw_response: params[11] == null ? null : JSON.parse(params[11]),
|
||||
};
|
||||
pool.importRuns.set(row.run_id, row);
|
||||
return { rows: [], rowCount: 1 };
|
||||
}
|
||||
|
||||
function insertPair(pool, params, sql = '') {
|
||||
const previous = pool.pairs.get(params[0]);
|
||||
const row = {
|
||||
pair_id: params[0],
|
||||
venue: params[1],
|
||||
asset_in: params[2],
|
||||
asset_out: params[3],
|
||||
mode: /mode = trading_pairs\.mode/i.test(sql) && previous ? previous.mode : params[4],
|
||||
enabled: /enabled = trading_pairs\.enabled/i.test(sql) && previous ? previous.enabled : params[5],
|
||||
status: /status = trading_pairs\.status/i.test(sql) && previous ? previous.status : params[6],
|
||||
created_at: params[7],
|
||||
updated_at: params[7],
|
||||
};
|
||||
pool.pairs.set(row.pair_id, row);
|
||||
return { rows: [], rowCount: 1 };
|
||||
}
|
||||
|
||||
function insertStrategyConfig(pool, params) {
|
||||
const configId = params[0];
|
||||
if (pool.strategyConfigs.has(configId)) return { rows: [], rowCount: 0 };
|
||||
const row = {
|
||||
config_id: configId,
|
||||
pair_id: params[1],
|
||||
version: params[2],
|
||||
active: params[3],
|
||||
edge_bps: params[4],
|
||||
max_notional: params[5],
|
||||
min_notional: params[6],
|
||||
slippage_bps: params[7],
|
||||
min_deadline_ms: params[8],
|
||||
price_max_age_ms: params[9],
|
||||
inventory_max_age_ms: params[10],
|
||||
request_default_notional: params[11],
|
||||
request_max_notional: params[12],
|
||||
request_max_slippage_bps: params[13],
|
||||
created_by: params[14],
|
||||
reason: params[15],
|
||||
created_at: '2026-05-12T16:35:00.000Z',
|
||||
};
|
||||
pool.strategyConfigs.set(configId, row);
|
||||
return { rows: [], rowCount: 1 };
|
||||
}
|
||||
|
||||
function insertRoute(pool, params, sql = '') {
|
||||
const previous = pool.routes.get(params[0]);
|
||||
const row = {
|
||||
route_id: params[0],
|
||||
pair_id: params[1],
|
||||
source: params[2],
|
||||
base_asset_id: params[3],
|
||||
quote_asset_id: params[4],
|
||||
route_config: JSON.parse(params[5]),
|
||||
max_age_ms: params[6],
|
||||
enabled: /enabled = pair_price_routes\.enabled/i.test(sql) && previous ? previous.enabled : params[7],
|
||||
created_at: params[8],
|
||||
updated_at: params[8],
|
||||
};
|
||||
pool.routes.set(row.route_id, row);
|
||||
return { rows: [], rowCount: 1 };
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue