Proof: Pair-native trade semantics and multi-asset outcome truth; strategy, request preflight, outcome attribution, valuation visibility, dashboard labels, alerts, and ops watch paths now use DB pair/asset/route metadata with nBTC/EURe compatibility and nBTC/USDC regressions covered. Assumptions: Postgres asset, pair, strategy config, and price route rows remain canonical; supported reference adapters remain BTC/EUR and BTC/USDC; deployment is push-driven through the existing Forgejo workflow. Still fake: Arbitrary multi-hop valuation, new execution venues, fee-complete realized PnL, venue-native terminal fill ingestion, and autonomous optimization remain unbuilt.
This commit is contained in:
parent
8f109a7463
commit
729d2ade0e
30 changed files with 1504 additions and 163 deletions
|
|
@ -11,7 +11,7 @@ from pathlib import Path
|
|||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
from common import DEFAULT_NAMESPACE, config_value, kubectl
|
||||
from common import DEFAULT_NAMESPACE, kubectl
|
||||
|
||||
|
||||
CONTROL_PORTS = {
|
||||
|
|
@ -120,19 +120,15 @@ def main() -> int:
|
|||
|
||||
|
||||
def load_asset_registry(*, namespace: str) -> dict[str, AssetMeta]:
|
||||
btc = AssetMeta(
|
||||
asset_id=config_value("TRADING_BTC_ASSET_ID", namespace=namespace),
|
||||
symbol=config_value("TRADING_BTC_SYMBOL", namespace=namespace) or "BTC",
|
||||
decimals=int(config_value("TRADING_BTC_DECIMALS", namespace=namespace) or "8"),
|
||||
)
|
||||
eure = AssetMeta(
|
||||
asset_id=config_value("TRADING_EURE_ASSET_ID", namespace=namespace),
|
||||
symbol=config_value("TRADING_EURE_SYMBOL", namespace=namespace) or "EURe",
|
||||
decimals=int(config_value("TRADING_EURE_DECIMALS", namespace=namespace) or "18"),
|
||||
)
|
||||
rows = query_rows(ASSET_QUERY, namespace=namespace, columns=ASSET_COLUMNS)
|
||||
return {
|
||||
btc.asset_id: btc,
|
||||
eure.asset_id: eure,
|
||||
row["asset_id"]: AssetMeta(
|
||||
asset_id=row["asset_id"],
|
||||
symbol=row["symbol"] or row["asset_id"],
|
||||
decimals=int(row["decimals"] or "0"),
|
||||
)
|
||||
for row in rows
|
||||
if row["asset_id"]
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -358,7 +354,8 @@ def render_decision_row(row: dict[str, str], assets: dict[str, AssetMeta]) -> st
|
|||
f" direction={row['direction']}"
|
||||
f" decision={row['decision']}"
|
||||
f" reason={row['decision_reason']}"
|
||||
f" notional_eure={row['eure_notional'] or '-'}"
|
||||
f" notional={row.get('notional') or row.get('eure_notional') or '-'}"
|
||||
f" notional_symbol={row.get('notional_symbol') or ('EURe' if row.get('eure_notional') else '-')}"
|
||||
f" gross_edge_pct={row['gross_edge_pct'] or '-'}"
|
||||
f" need={need}"
|
||||
f" have={have}"
|
||||
|
|
@ -451,12 +448,39 @@ DECISION_COLUMNS = [
|
|||
"decision",
|
||||
"decision_reason",
|
||||
"gross_edge_pct",
|
||||
"notional",
|
||||
"notional_symbol",
|
||||
"eure_notional",
|
||||
"inventory_asset",
|
||||
"inventory_available",
|
||||
"inventory_required",
|
||||
]
|
||||
|
||||
ASSET_COLUMNS = [
|
||||
"asset_id",
|
||||
"symbol",
|
||||
"decimals",
|
||||
]
|
||||
|
||||
ASSET_QUERY = """
|
||||
select
|
||||
asset_id,
|
||||
coalesce(symbol, ''),
|
||||
coalesce(decimals::text, '')
|
||||
from trading_assets
|
||||
where enabled_for_inventory = true
|
||||
or asset_id in (
|
||||
select payload->>'asset_in' from trade_decisions where payload ? 'asset_in'
|
||||
union
|
||||
select payload->>'asset_out' from trade_decisions where payload ? 'asset_out'
|
||||
union
|
||||
select payload->>'source_asset_id' from intent_request_preflights where payload ? 'source_asset_id'
|
||||
union
|
||||
select payload->>'destination_asset_id' from intent_request_preflights where payload ? 'destination_asset_id'
|
||||
)
|
||||
order by symbol, asset_id;
|
||||
"""
|
||||
|
||||
COMMAND_COLUMNS = [
|
||||
"event_id",
|
||||
"ingested_at",
|
||||
|
|
@ -505,6 +529,8 @@ select
|
|||
coalesce(payload->>'decision', ''),
|
||||
coalesce(payload->>'decision_reason', ''),
|
||||
coalesce(payload->>'gross_edge_pct', ''),
|
||||
coalesce(payload->>'notional', ''),
|
||||
coalesce(payload->>'notional_symbol', ''),
|
||||
coalesce(payload->>'eure_notional', ''),
|
||||
coalesce(payload->>'inventory_asset', ''),
|
||||
coalesce(payload->>'inventory_available', ''),
|
||||
|
|
|
|||
|
|
@ -464,8 +464,8 @@ async function refreshQuoteOutcomeAttributions() {
|
|||
|
||||
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'}`);
|
||||
if (!tradingConfig.ok) {
|
||||
throw new Error(`trading config unavailable: ${tradingConfig.blockReason || 'unavailable'}`);
|
||||
}
|
||||
return tradingConfig;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -271,6 +271,8 @@ function buildBtcEurPriceEvent(now, {
|
|||
price_route_id: referencePair.priceRoute.routeId,
|
||||
base_asset_id: referencePair.priceRoute.baseAssetId,
|
||||
quote_asset_id: referencePair.priceRoute.quoteAssetId,
|
||||
quote_per_base: eurPerBtc.toFixed(8),
|
||||
base_per_quote: btcPerEur.toFixed(12),
|
||||
reference_pair: 'BTC/EUR',
|
||||
eur_per_btc: eurPerBtc.toFixed(8),
|
||||
eure_per_btc: eurPerBtc.toFixed(8),
|
||||
|
|
@ -308,6 +310,8 @@ function buildBtcUsdcPriceEvent(now, {
|
|||
price_route_id: referencePair.priceRoute.routeId,
|
||||
base_asset_id: referencePair.priceRoute.baseAssetId,
|
||||
quote_asset_id: referencePair.priceRoute.quoteAssetId,
|
||||
quote_per_base: usdcPerBtc.toFixed(8),
|
||||
base_per_quote: btcPerUsdc.toFixed(12),
|
||||
reference_pair: 'BTC/USDC',
|
||||
eur_per_btc: eurPerBtc.toFixed(8),
|
||||
eure_per_btc: eurPerBtc.toFixed(8),
|
||||
|
|
|
|||
|
|
@ -112,6 +112,14 @@ const state = {
|
|||
|
||||
const alertEngine = createAlertEngine({
|
||||
activePair: config.activePair,
|
||||
activePairs: (config.observedPairs || config.pairs || []).map((pair) => pair.key || pair.pairId).filter(Boolean),
|
||||
priceRoutes: (config.pairs || [])
|
||||
.filter((pair) => pair.priceRoute?.routeId)
|
||||
.map((pair) => ({
|
||||
pair: pair.key || pair.pairId,
|
||||
price_route_id: pair.priceRoute.routeId,
|
||||
reference_pair: pair.priceRoute.routeConfig?.reference_pair || pair.priceRoute.source || null,
|
||||
})),
|
||||
priceStaleMs: config.opsSentinelPriceStaleMs,
|
||||
inventoryStaleMs: config.opsSentinelInventoryStaleMs,
|
||||
fundingCreditPendingMs: config.opsSentinelFundingCreditPendingMs,
|
||||
|
|
@ -290,6 +298,7 @@ async function evaluateRuntimeHealthLoop() {
|
|||
state.service_health = [...evaluateRuntimeHealth({
|
||||
servicesByName,
|
||||
activePair: config.activePair,
|
||||
activePairs: (config.observedPairs || config.pairs || []).map((pair) => pair.key || pair.pairId).filter(Boolean),
|
||||
activeAlerts: desiredRuntimeAlerts,
|
||||
now,
|
||||
}).values()];
|
||||
|
|
|
|||
|
|
@ -113,6 +113,8 @@ async function handleDemand(event) {
|
|||
if (seenQuotes.has(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;
|
||||
const legacyEureNotional = !pair?.priceRoute
|
||||
|| pair.priceRoute.quoteAssetId === tradingConfig.tradingEure?.assetId;
|
||||
await publishDecision({
|
||||
decision_id: `duplicate-${event.payload.quote_id}`,
|
||||
quote_id: event.payload.quote_id,
|
||||
|
|
@ -126,7 +128,10 @@ async function handleDemand(event) {
|
|||
decision: 'rejected',
|
||||
decision_reason: 'duplicate_quote_id',
|
||||
threshold_pct: strategyConfig?.edgeBps == null ? null : String(Number(strategyConfig.edgeBps) / 100),
|
||||
max_notional_eure: strategyConfig?.maxNotional == null ? null : String(strategyConfig.maxNotional),
|
||||
max_notional: strategyConfig?.maxNotional == null ? null : String(strategyConfig.maxNotional),
|
||||
max_notional_eure: legacyEureNotional && strategyConfig?.maxNotional != null
|
||||
? String(strategyConfig.maxNotional)
|
||||
: null,
|
||||
strategy_armed: state.armed,
|
||||
});
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import {
|
|||
loadLatestIntentRequestSubmission,
|
||||
loadLatestInventorySnapshot,
|
||||
loadLatestMarketPrice,
|
||||
loadLatestMarketPriceForRoute,
|
||||
refreshIntentRequestOutcomes,
|
||||
seedTradingConfig,
|
||||
} from '../lib/postgres.mjs';
|
||||
|
|
@ -452,7 +453,11 @@ const controlApi = startControlApi({
|
|||
function createIntentRequestStore() {
|
||||
return {
|
||||
loadLatestInventorySnapshot: () => loadLatestInventorySnapshot(requestPool),
|
||||
loadLatestMarketPrice: () => loadLatestMarketPrice(requestPool),
|
||||
loadLatestMarketPrice: ({ priceRouteId } = {}) => (
|
||||
priceRouteId
|
||||
? loadLatestMarketPriceForRoute(requestPool, { priceRouteId })
|
||||
: loadLatestMarketPrice(requestPool)
|
||||
),
|
||||
findPreflight: ({ requestId = null, idempotencyKey = null } = {}) => (
|
||||
loadIntentRequestPreflightByIdOrKey(requestPool, { requestId, idempotencyKey })
|
||||
),
|
||||
|
|
@ -504,8 +509,8 @@ function createIntentRequestStore() {
|
|||
},
|
||||
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'}`);
|
||||
if (!tradingConfig.ok) {
|
||||
throw new Error(`trading config unavailable: ${tradingConfig.blockReason || 'unavailable'}`);
|
||||
}
|
||||
return refreshIntentRequestOutcomes(requestPool, {
|
||||
btcAsset: tradingConfig.tradingBtc,
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ const DEFAULT_RECENT_LIMIT = 50;
|
|||
|
||||
export function createAlertEngine({
|
||||
activePair,
|
||||
activePairs = activePair ? [activePair] : [],
|
||||
priceRoutes = [],
|
||||
priceStaleMs,
|
||||
inventoryStaleMs,
|
||||
fundingCreditPendingMs,
|
||||
|
|
@ -11,6 +13,7 @@ export function createAlertEngine({
|
|||
}) {
|
||||
const state = {
|
||||
latest_price: null,
|
||||
latest_prices_by_route: {},
|
||||
latest_inventory: null,
|
||||
latest_liquidity_action: null,
|
||||
latest_trade_result: null,
|
||||
|
|
@ -26,6 +29,7 @@ export function createAlertEngine({
|
|||
switch (topic) {
|
||||
case 'ref.market_price':
|
||||
state.latest_price = payload;
|
||||
if (payload?.price_route_id) state.latest_prices_by_route[payload.price_route_id] = payload;
|
||||
break;
|
||||
case 'state.intent_inventory':
|
||||
state.latest_inventory = payload;
|
||||
|
|
@ -48,6 +52,8 @@ export function createAlertEngine({
|
|||
return evaluateAlerts({
|
||||
state,
|
||||
activePair,
|
||||
activePairs,
|
||||
priceRoutes,
|
||||
priceStaleMs,
|
||||
inventoryStaleMs,
|
||||
fundingCreditPendingMs,
|
||||
|
|
@ -60,6 +66,8 @@ export function createAlertEngine({
|
|||
return evaluateAlerts({
|
||||
state,
|
||||
activePair,
|
||||
activePairs,
|
||||
priceRoutes,
|
||||
priceStaleMs,
|
||||
inventoryStaleMs,
|
||||
fundingCreditPendingMs,
|
||||
|
|
@ -79,6 +87,8 @@ export function createAlertEngine({
|
|||
getState(now = new Date().toISOString()) {
|
||||
return summarizeState({
|
||||
state,
|
||||
activePairs,
|
||||
priceRoutes,
|
||||
evaluationIntervalMs,
|
||||
now,
|
||||
});
|
||||
|
|
@ -89,6 +99,8 @@ export function createAlertEngine({
|
|||
function evaluateAlerts({
|
||||
state,
|
||||
activePair,
|
||||
activePairs = activePair ? [activePair] : [],
|
||||
priceRoutes = [],
|
||||
priceStaleMs,
|
||||
inventoryStaleMs,
|
||||
fundingCreditPendingMs,
|
||||
|
|
@ -99,26 +111,41 @@ function evaluateAlerts({
|
|||
const desired = new Map();
|
||||
const nowValue = timestampValue(now);
|
||||
|
||||
const priceAgeMs = ageMs(state.latest_price?.observed_at || state.latest_price?.ingested_at, nowValue);
|
||||
if (priceAgeMs == null || priceAgeMs > priceStaleMs) {
|
||||
const routesToCheck = priceRoutes.length
|
||||
? priceRoutes
|
||||
: [{
|
||||
pair: activePair,
|
||||
price_route_id: state.latest_price?.price_route_id || null,
|
||||
reference_pair: state.latest_price?.reference_pair || null,
|
||||
}];
|
||||
for (const route of routesToCheck) {
|
||||
const latestPrice = route.price_route_id
|
||||
? state.latest_prices_by_route[route.price_route_id]
|
||||
: state.latest_price;
|
||||
const priceAgeMs = ageMs(latestPrice?.observed_at || latestPrice?.ingested_at, nowValue);
|
||||
if (priceAgeMs != null && priceAgeMs <= priceStaleMs) continue;
|
||||
desired.set(
|
||||
buildAlertKey({
|
||||
alertCode: 'reference_price_stale',
|
||||
serviceScope: 'market-reference-ingest',
|
||||
pair: activePair,
|
||||
pair: route.pair || activePair,
|
||||
assetId: route.price_route_id || null,
|
||||
}),
|
||||
{
|
||||
alert_code: 'reference_price_stale',
|
||||
severity: 'warning',
|
||||
reason: priceAgeMs == null
|
||||
? 'no reference price has been observed'
|
||||
: `reference price age ${priceAgeMs}ms exceeds ${priceStaleMs}ms`,
|
||||
? `no reference price has been observed for ${route.reference_pair || route.price_route_id || 'route'}`
|
||||
: `reference price age ${priceAgeMs}ms exceeds ${priceStaleMs}ms for ${route.reference_pair || route.price_route_id || 'route'}`,
|
||||
service_scope: 'market-reference-ingest',
|
||||
pair: activePair,
|
||||
pair: route.pair || activePair,
|
||||
asset_id: null,
|
||||
tx_hash: null,
|
||||
details: {
|
||||
last_price_at: state.latest_price?.observed_at || state.latest_price?.ingested_at || null,
|
||||
price_route_id: route.price_route_id || null,
|
||||
reference_pair: route.reference_pair || null,
|
||||
active_pairs: activePairs,
|
||||
last_price_at: latestPrice?.observed_at || latestPrice?.ingested_at || null,
|
||||
age_ms: priceAgeMs,
|
||||
stale_after_ms: priceStaleMs,
|
||||
},
|
||||
|
|
@ -411,7 +438,7 @@ function reconcileRuntimeAlertState({
|
|||
return transitions;
|
||||
}
|
||||
|
||||
function summarizeState({ state, evaluationIntervalMs, now }) {
|
||||
function summarizeState({ state, activePairs = [], priceRoutes = [], evaluationIntervalMs, now }) {
|
||||
const activeAlerts = Object.values(state.active_alerts)
|
||||
.sort((left, right) => timestampValue(right.first_raised_at) - timestampValue(left.first_raised_at));
|
||||
const nowValue = timestampValue(now);
|
||||
|
|
@ -421,8 +448,16 @@ function summarizeState({ state, evaluationIntervalMs, now }) {
|
|||
recent_transitions: state.recent_transitions,
|
||||
last_evaluated_at: state.last_evaluated_at,
|
||||
stale: ageMs(state.last_evaluated_at, nowValue) > (evaluationIntervalMs * 2),
|
||||
active_pairs: activePairs,
|
||||
price_routes: priceRoutes,
|
||||
latest_inputs: {
|
||||
market_price_at: state.latest_price?.observed_at || state.latest_price?.ingested_at || null,
|
||||
market_prices_by_route: Object.fromEntries(
|
||||
Object.entries(state.latest_prices_by_route || {}).map(([routeId, price]) => [
|
||||
routeId,
|
||||
price?.observed_at || price?.ingested_at || null,
|
||||
]),
|
||||
),
|
||||
inventory_at: state.latest_inventory?.synced_at || state.latest_inventory?.ingested_at || null,
|
||||
liquidity_action_at: state.latest_liquidity_action?.observed_at || null,
|
||||
trade_result_at: state.latest_trade_result?.ingested_at || null,
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { serializeError } from './log.mjs';
|
|||
import {
|
||||
applySlippageBps,
|
||||
buildSolverQuoteRequest,
|
||||
computeBtcReceiveUnitsFromEure,
|
||||
formatUnitsDecimal,
|
||||
futureIso,
|
||||
isExpired,
|
||||
normalizeRelayPublishResponse,
|
||||
|
|
@ -12,6 +12,12 @@ import {
|
|||
parseDecimalToUnits,
|
||||
selectBestSolverQuote,
|
||||
} from './intent-requests.mjs';
|
||||
import {
|
||||
classifyRouteDirection,
|
||||
computeDestinationAmountUnitsFromRoute,
|
||||
isSupportedPriceRouteSource,
|
||||
resolveRouteRates,
|
||||
} from './route-rates.mjs';
|
||||
import { buildIntentRequestSubmission } from '../venues/near-intents/signing.mjs';
|
||||
|
||||
export function createIntentRequestController({
|
||||
|
|
@ -41,28 +47,25 @@ export function createIntentRequestController({
|
|||
const requestPair = await resolveIntentRequestPair({ body, config, getTradingConfig });
|
||||
const sourceAsset = requestPair.sourceAsset;
|
||||
const destinationAsset = requestPair.destinationAsset;
|
||||
const amountEure = String(
|
||||
body.amount_eure
|
||||
const sourceAmount = String(
|
||||
body.source_amount
|
||||
|| body.amount
|
||||
|| body.amount_eure
|
||||
|| requestPair.requestDefaultNotional
|
||||
|| config.intentRequestDefaultAmountEure
|
||||
|| '5',
|
||||
);
|
||||
const legacyAmountEure = body.amount_eure == null ? null : String(body.amount_eure);
|
||||
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 = requestPair.requestMaxNotional == null
|
||||
? null
|
||||
: parseDecimalToUnits(
|
||||
String(requestPair.requestMaxNotional),
|
||||
sourceAsset.decimals,
|
||||
{ field: 'intent_request_max_notional' },
|
||||
);
|
||||
|
||||
let sourceAmountUnits = '0';
|
||||
let expectedDestinationAmountUnits = '0';
|
||||
let minDestinationAmountUnits = '0';
|
||||
let inventorySnapshot = null;
|
||||
let marketPrice = null;
|
||||
let routeDirection = null;
|
||||
let routeRates = null;
|
||||
let signerRegistered = null;
|
||||
let solverQuoteResponse = null;
|
||||
let solverQuotes = [];
|
||||
|
|
@ -81,12 +84,23 @@ export function createIntentRequestController({
|
|||
requestPair.reasonText,
|
||||
);
|
||||
}
|
||||
sourceAmountUnits = parseDecimalToUnits(amountEure, sourceAsset.decimals, { field: 'amount_eure' });
|
||||
if (!Number.isInteger(sourceAsset?.decimals) || !Number.isInteger(destinationAsset?.decimals)) {
|
||||
blockedBeforeQuote = true;
|
||||
throw codedError('asset_decimals_missing', 'Source and destination asset decimals are required.');
|
||||
}
|
||||
const maxAmountUnits = requestPair.requestMaxNotional == null
|
||||
? null
|
||||
: parseDecimalToUnits(
|
||||
String(requestPair.requestMaxNotional),
|
||||
sourceAsset.decimals,
|
||||
{ field: 'intent_request_max_notional' },
|
||||
);
|
||||
sourceAmountUnits = parseDecimalToUnits(sourceAmount, sourceAsset.decimals, { field: 'source_amount' });
|
||||
if (maxAmountUnits != null && BigInt(sourceAmountUnits) > BigInt(maxAmountUnits)) {
|
||||
blockedBeforeQuote = true;
|
||||
throw codedError(
|
||||
'amount_exceeds_request_limit',
|
||||
`Requested ${amountEure} ${sourceAsset.symbol} exceeds configured live request limit ${requestPair.requestMaxNotional} ${sourceAsset.symbol}.`,
|
||||
`Requested ${sourceAmount} ${sourceAsset.symbol} exceeds configured live request limit ${requestPair.requestMaxNotional} ${sourceAsset.symbol}.`,
|
||||
);
|
||||
}
|
||||
if (!Number.isInteger(slippageBps) || slippageBps < 0) {
|
||||
|
|
@ -106,7 +120,7 @@ export function createIntentRequestController({
|
|||
|
||||
[inventorySnapshot, marketPrice, signerRegistered] = await Promise.all([
|
||||
store.loadLatestInventorySnapshot(),
|
||||
store.loadLatestMarketPrice(),
|
||||
store.loadLatestMarketPrice({ priceRouteId: requestPair.priceRoute?.routeId || null }),
|
||||
verifierClient.isPublicKeyRegistered({ accountId: config.nearIntentsAccountId }),
|
||||
]);
|
||||
|
||||
|
|
@ -120,9 +134,22 @@ export function createIntentRequestController({
|
|||
blockedBeforeQuote = true;
|
||||
throw codedError('stale_inventory', 'Inventory snapshot is too stale for request creation.');
|
||||
}
|
||||
if (!marketPrice?.payload?.eure_per_btc) {
|
||||
routeDirection = classifyRouteDirection({
|
||||
sourceAssetId: sourceAsset.assetId,
|
||||
destinationAssetId: destinationAsset.assetId,
|
||||
priceRoute: requestPair.priceRoute,
|
||||
});
|
||||
routeRates = resolveRouteRates({
|
||||
price: marketPrice?.payload || null,
|
||||
priceRoute: requestPair.priceRoute,
|
||||
direction: routeDirection,
|
||||
});
|
||||
if (!routeRates.ok) {
|
||||
blockedBeforeQuote = true;
|
||||
throw codedError('reference_price_unavailable', 'No BTC/EUR reference price is available.');
|
||||
throw codedError(
|
||||
routeRates.reason === 'unsupported_price_route' ? 'unsupported_price_route' : 'reference_price_unavailable',
|
||||
`No usable reference price is available for route ${requestPair.priceRoute?.routeId || 'unknown'}.`,
|
||||
);
|
||||
}
|
||||
if (!isFresh(priceObservedAt, requestPair.priceMaxAgeMs ?? config.intentRequestPriceMaxAgeMs ?? config.strategyPriceMaxAgeMs, now())) {
|
||||
blockedBeforeQuote = true;
|
||||
|
|
@ -136,14 +163,19 @@ export function createIntentRequestController({
|
|||
const spendableUnits = String(inventorySnapshot.payload.spendable[sourceAsset.assetId] || '0');
|
||||
if (BigInt(spendableUnits) < BigInt(sourceAmountUnits)) {
|
||||
blockedBeforeQuote = true;
|
||||
throw codedError('insufficient_spendable_eure', 'Spendable EURe is below the requested amount.');
|
||||
throw codedError(
|
||||
insufficientInventoryCode(sourceAsset),
|
||||
`Spendable ${sourceAsset.symbol || sourceAsset.assetId} is below the requested amount.`,
|
||||
);
|
||||
}
|
||||
|
||||
expectedDestinationAmountUnits = computeBtcReceiveUnitsFromEure({
|
||||
eureUnits: sourceAmountUnits,
|
||||
eurPerBtc: marketPrice.payload.eure_per_btc,
|
||||
eureDecimals: sourceAsset.decimals,
|
||||
btcDecimals: destinationAsset.decimals,
|
||||
expectedDestinationAmountUnits = computeDestinationAmountUnitsFromRoute({
|
||||
sourceAmountUnits,
|
||||
sourceDecimals: sourceAsset.decimals,
|
||||
destinationDecimals: destinationAsset.decimals,
|
||||
direction: routeDirection,
|
||||
quotePerBase: routeRates.quotePerBase,
|
||||
basePerQuote: routeRates.basePerQuote,
|
||||
});
|
||||
minDestinationAmountUnits = applySlippageBps(expectedDestinationAmountUnits, slippageBps);
|
||||
|
||||
|
|
@ -182,6 +214,14 @@ export function createIntentRequestController({
|
|||
});
|
||||
}
|
||||
|
||||
const preflightNotional = buildPreflightNotional({
|
||||
sourceAsset,
|
||||
destinationAsset,
|
||||
sourceAmount,
|
||||
sourceAmountUnits,
|
||||
expectedDestinationAmountUnits,
|
||||
priceRoute: requestPair.priceRoute,
|
||||
});
|
||||
const payload = {
|
||||
request_id: requestId,
|
||||
idempotency_key: idempotencyKey,
|
||||
|
|
@ -207,14 +247,22 @@ export function createIntentRequestController({
|
|||
? null
|
||||
: Number(requestPair.requestMaxSlippageBps),
|
||||
price_route_id: requestPair.priceRoute?.routeId || null,
|
||||
reference_price_id: routeRates?.referencePriceId || marketPrice?.payload?.price_id || null,
|
||||
route_direction: routeDirection,
|
||||
source_asset_id: sourceAsset.assetId,
|
||||
source_symbol: sourceAsset.symbol,
|
||||
source_decimals: sourceAsset.decimals,
|
||||
destination_asset_id: destinationAsset.assetId,
|
||||
destination_symbol: destinationAsset.symbol,
|
||||
destination_decimals: destinationAsset.decimals,
|
||||
source_amount: sourceAmount,
|
||||
source_amount_units: sourceAmountUnits,
|
||||
amount_eure: amountEure,
|
||||
destination_amount_units: expectedDestinationAmountUnits,
|
||||
notional: preflightNotional.notional,
|
||||
notional_asset_id: preflightNotional.notionalAssetId,
|
||||
notional_symbol: preflightNotional.notionalSymbol,
|
||||
amount_eure: legacyAmountEure
|
||||
?? (sourceAsset.assetId === config.tradingEure?.assetId ? sourceAmount : null),
|
||||
expected_destination_amount_units: expectedDestinationAmountUnits,
|
||||
min_destination_amount_units: minDestinationAmountUnits,
|
||||
quoted_destination_amount_units: selectedQuote?.amount_out || null,
|
||||
|
|
@ -235,7 +283,14 @@ export function createIntentRequestController({
|
|||
market_price: marketPrice ? {
|
||||
ingested_at: marketPrice.ingested_at,
|
||||
observed_at: marketPrice.payload?.observed_at || null,
|
||||
price_route_id: marketPrice.payload?.price_route_id || requestPair.priceRoute?.routeId || null,
|
||||
reference_pair: marketPrice.payload?.reference_pair || requestPair.priceRoute?.routeConfig?.reference_pair || null,
|
||||
base_asset_id: marketPrice.payload?.base_asset_id || requestPair.priceRoute?.baseAssetId || null,
|
||||
quote_asset_id: marketPrice.payload?.quote_asset_id || requestPair.priceRoute?.quoteAssetId || null,
|
||||
quote_per_base: routeRates?.quotePerBase == null ? null : String(routeRates.quotePerBase),
|
||||
base_per_quote: routeRates?.basePerQuote == null ? null : String(routeRates.basePerQuote),
|
||||
eure_per_btc: marketPrice.payload?.eure_per_btc || null,
|
||||
usdc_per_btc: marketPrice.payload?.usdc_per_btc || null,
|
||||
price_id: marketPrice.payload?.price_id || null,
|
||||
} : null,
|
||||
solver_quote_count: solverQuotes.length,
|
||||
|
|
@ -313,10 +368,14 @@ export function createIntentRequestController({
|
|||
const latestInventory = await store.loadLatestInventorySnapshot();
|
||||
const spendableUnits = String(latestInventory?.payload?.spendable?.[preflight.source_asset_id] || '0');
|
||||
if (BigInt(spendableUnits) < BigInt(preflight.source_amount_units)) {
|
||||
const sourceAssetForReason = {
|
||||
assetId: preflight.source_asset_id,
|
||||
symbol: preflight.source_symbol,
|
||||
};
|
||||
const blocked = await recordSubmissionResult(preflight, {
|
||||
status: 'blocked',
|
||||
result_code: 'insufficient_spendable_eure',
|
||||
result_text: 'Spendable EURe changed below the requested amount before submit.',
|
||||
result_code: insufficientInventoryCode(sourceAssetForReason),
|
||||
result_text: `Spendable ${preflight.source_symbol || preflight.source_asset_id} changed below the requested amount before submit.`,
|
||||
});
|
||||
return { preflight, submission_result: blocked };
|
||||
}
|
||||
|
|
@ -458,9 +517,14 @@ export function createIntentRequestController({
|
|||
max_notional: preflight.max_notional || null,
|
||||
request_max_notional: preflight.request_max_notional || null,
|
||||
price_route_id: preflight.price_route_id || null,
|
||||
reference_price_id: preflight.reference_price_id || null,
|
||||
notional: preflight.notional || null,
|
||||
notional_asset_id: preflight.notional_asset_id || null,
|
||||
notional_symbol: preflight.notional_symbol || null,
|
||||
source_asset_id: preflight.source_asset_id,
|
||||
destination_asset_id: preflight.destination_asset_id,
|
||||
source_amount_units: preflight.source_amount_units,
|
||||
destination_amount_units: preflight.destination_amount_units || null,
|
||||
min_destination_amount_units: preflight.min_destination_amount_units,
|
||||
quote_hash: preflight.selected_quote?.quote_hash || extra.quote_hash || null,
|
||||
lifecycle: {
|
||||
|
|
@ -490,7 +554,13 @@ async function resolveIntentRequestPair({ body, config, getTradingConfig }) {
|
|||
destinationAsset: config.tradingBtc,
|
||||
pair: null,
|
||||
strategyConfig: null,
|
||||
priceRoute: null,
|
||||
priceRoute: {
|
||||
routeId: 'legacy-btc-eur-reference',
|
||||
source: 'btc_eur_reference',
|
||||
baseAssetId: config.tradingBtc?.assetId,
|
||||
quoteAssetId: config.tradingEure?.assetId,
|
||||
routeConfig: { reference_pair: 'BTC/EUR' },
|
||||
},
|
||||
requestDefaultNotional: config.intentRequestDefaultAmountEure,
|
||||
requestMaxNotional: config.intentRequestMaxAmountEure,
|
||||
slippageBps: config.intentRequestDefaultSlippageBps,
|
||||
|
|
@ -513,9 +583,12 @@ async function resolveIntentRequestPair({ body, config, getTradingConfig }) {
|
|||
});
|
||||
}
|
||||
|
||||
const requestedSource = body.source_asset_id || body.asset_in || null;
|
||||
const requestedDestination = body.destination_asset_id || body.asset_out || null;
|
||||
const pair = requestedSource && requestedDestination
|
||||
const requestedPairId = body.pair_id || null;
|
||||
const requestedSource = body.source_asset_id || body.asset_in || body.asset_in_id || null;
|
||||
const requestedDestination = body.destination_asset_id || body.asset_out || body.asset_out_id || null;
|
||||
const pair = requestedPairId
|
||||
? tradingConfig.pairById?.get(requestedPairId) || tradingConfig.pairByKey?.get(requestedPairId)
|
||||
: requestedSource && requestedDestination
|
||||
? tradingConfig.pairByKey.get(`${requestedSource}->${requestedDestination}`)
|
||||
: tradingConfig.defaultTakerPair;
|
||||
|
||||
|
|
@ -548,10 +621,21 @@ async function resolveIntentRequestPair({ body, config, getTradingConfig }) {
|
|||
});
|
||||
}
|
||||
|
||||
if (pair.priceRoute?.source !== 'btc_eur_reference') {
|
||||
const priceRoute = normalizeRequestPriceRoute(pair);
|
||||
if (!priceRoute) {
|
||||
return blockedRequestPair({
|
||||
reasonCode: 'price_route_missing',
|
||||
reasonText: 'Only the DB-backed BTC/EUR price route is supported for request creation in this turn.',
|
||||
reasonText: 'The selected pair has no DB-backed price route for request creation.',
|
||||
pair,
|
||||
sourceAsset: pair.assetIn,
|
||||
destinationAsset: pair.assetOut,
|
||||
});
|
||||
}
|
||||
|
||||
if (!isSupportedPriceRouteSource(priceRoute.source)) {
|
||||
return blockedRequestPair({
|
||||
reasonCode: 'unsupported_price_route',
|
||||
reasonText: `The selected pair uses unsupported price route source ${priceRoute.source || 'unknown'}.`,
|
||||
pair,
|
||||
sourceAsset: pair.assetIn,
|
||||
destinationAsset: pair.assetOut,
|
||||
|
|
@ -565,7 +649,7 @@ async function resolveIntentRequestPair({ body, config, getTradingConfig }) {
|
|||
destinationAsset: pair.assetOut,
|
||||
pair,
|
||||
strategyConfig,
|
||||
priceRoute: pair.priceRoute,
|
||||
priceRoute,
|
||||
requestDefaultNotional:
|
||||
strategyConfig.requestDefaultNotional || config.intentRequestDefaultAmountEure,
|
||||
requestMaxNotional: strategyConfig.requestMaxNotional ?? null,
|
||||
|
|
@ -577,6 +661,22 @@ async function resolveIntentRequestPair({ body, config, getTradingConfig }) {
|
|||
};
|
||||
}
|
||||
|
||||
function normalizeRequestPriceRoute(pair) {
|
||||
const priceRoute = pair?.priceRoute || null;
|
||||
if (!priceRoute) return null;
|
||||
if (priceRoute.baseAssetId && priceRoute.quoteAssetId) return priceRoute;
|
||||
const assets = [pair.assetIn, pair.assetOut].filter(Boolean);
|
||||
const baseAsset = assets.find((asset) => asset.symbol === 'BTC') || null;
|
||||
const quoteAsset = priceRoute.source === 'btc_usdc_reference'
|
||||
? assets.find((asset) => asset.symbol === 'USDC')
|
||||
: assets.find((asset) => asset.symbol === 'EURe') || assets.find((asset) => asset.symbol !== 'BTC');
|
||||
return {
|
||||
...priceRoute,
|
||||
baseAssetId: priceRoute.baseAssetId || baseAsset?.assetId || null,
|
||||
quoteAssetId: priceRoute.quoteAssetId || quoteAsset?.assetId || null,
|
||||
};
|
||||
}
|
||||
|
||||
function blockedRequestPair({
|
||||
reasonCode,
|
||||
reasonText,
|
||||
|
|
@ -596,6 +696,51 @@ function blockedRequestPair({
|
|||
};
|
||||
}
|
||||
|
||||
function buildPreflightNotional({
|
||||
sourceAsset,
|
||||
destinationAsset,
|
||||
sourceAmount,
|
||||
sourceAmountUnits,
|
||||
expectedDestinationAmountUnits,
|
||||
priceRoute,
|
||||
} = {}) {
|
||||
const quoteAssetId = priceRoute?.quoteAssetId || null;
|
||||
if (!quoteAssetId) {
|
||||
return {
|
||||
notional: null,
|
||||
notionalAssetId: null,
|
||||
notionalSymbol: null,
|
||||
};
|
||||
}
|
||||
if (sourceAsset?.assetId === quoteAssetId) {
|
||||
return {
|
||||
notional: sourceAmount,
|
||||
notionalAssetId: sourceAsset.assetId,
|
||||
notionalSymbol: sourceAsset.symbol || null,
|
||||
};
|
||||
}
|
||||
if (destinationAsset?.assetId === quoteAssetId) {
|
||||
return {
|
||||
notional: expectedDestinationAmountUnits
|
||||
? formatUnitsDecimal(expectedDestinationAmountUnits, destinationAsset.decimals)
|
||||
: null,
|
||||
notionalAssetId: destinationAsset.assetId,
|
||||
notionalSymbol: destinationAsset.symbol || null,
|
||||
};
|
||||
}
|
||||
return {
|
||||
notional: null,
|
||||
notionalAssetId: quoteAssetId,
|
||||
notionalSymbol: null,
|
||||
};
|
||||
}
|
||||
|
||||
function insufficientInventoryCode(asset) {
|
||||
const symbol = String(asset?.symbol || '').trim().toLowerCase();
|
||||
if (/^[a-z0-9]+$/.test(symbol)) return `insufficient_spendable_${symbol}`;
|
||||
return 'insufficient_source_inventory';
|
||||
}
|
||||
|
||||
function isFresh(timestamp, maxAgeMs, nowMs) {
|
||||
const parsed = Date.parse(timestamp || '');
|
||||
if (!Number.isFinite(parsed)) return false;
|
||||
|
|
|
|||
|
|
@ -16,13 +16,21 @@ export function deriveIntentRequestOutcomeRecords({
|
|||
attributionWindowMs = DEFAULT_ATTRIBUTION_WINDOW_MS,
|
||||
settlementGraceMs = DEFAULT_SETTLEMENT_GRACE_MS,
|
||||
} = {}) {
|
||||
const activeAssetIds = [btcAsset?.assetId, eureAsset?.assetId].filter(Boolean);
|
||||
const preflightsByRequest = new Map(
|
||||
preflights
|
||||
.map(normalizePreflight)
|
||||
.filter((entry) => entry?.request_id)
|
||||
.map((entry) => [entry.request_id, entry]),
|
||||
);
|
||||
const expectedDeltasByRequest = new Map();
|
||||
for (const preflight of preflightsByRequest.values()) {
|
||||
const expectedDelta = buildExpectedRequestDelta(preflight, null);
|
||||
if (expectedDelta) expectedDeltasByRequest.set(preflight.request_id, expectedDelta);
|
||||
}
|
||||
const activeAssetIds = uniqueAssetIds([
|
||||
...expectedDeltasByRequest.values(),
|
||||
Object.fromEntries([btcAsset?.assetId, eureAsset?.assetId].filter(Boolean).map((assetId) => [assetId, 0n])),
|
||||
]);
|
||||
const latestSubmissionByRequest = new Map();
|
||||
|
||||
for (const submission of submissions.map(normalizeSubmission).filter(Boolean)) {
|
||||
|
|
@ -273,6 +281,7 @@ function deriveOneOutcome({
|
|||
|
||||
function buildExpectedRequestDelta(preflight, submission) {
|
||||
const destinationAmount = submission?.destination_amount_units
|
||||
|| preflight?.destination_amount_units
|
||||
|| preflight?.selected_quote?.amount_out
|
||||
|| preflight?.quoted_destination_amount_units;
|
||||
if (!preflight?.source_asset_id || !preflight?.destination_asset_id) return null;
|
||||
|
|
@ -302,9 +311,14 @@ function baseOutcomeRecord({
|
|||
submission_id: submission?.submission_id || null,
|
||||
intent_hash: submission?.intent_hash || null,
|
||||
source_asset_id: preflight.source_asset_id,
|
||||
source_symbol: preflight.source_symbol || null,
|
||||
destination_asset_id: preflight.destination_asset_id,
|
||||
destination_symbol: preflight.destination_symbol || null,
|
||||
source_amount_units: preflight.source_amount_units,
|
||||
destination_amount_units: submission?.destination_amount_units || null,
|
||||
destination_amount_units: submission?.destination_amount_units || preflight.destination_amount_units || null,
|
||||
notional: preflight.notional || null,
|
||||
notional_asset_id: preflight.notional_asset_id || null,
|
||||
notional_symbol: preflight.notional_symbol || null,
|
||||
min_destination_amount_units: preflight.min_destination_amount_units,
|
||||
quote_hash: submission?.quote_hash || preflight.selected_quote?.quote_hash || null,
|
||||
submitted_at: submission?.submitted_at || null,
|
||||
|
|
@ -353,6 +367,10 @@ function movementMatchesExpectedDelta({
|
|||
for (const [assetId, expected] of Object.entries(expectedDelta)) {
|
||||
if (safeBigInt(movement.delta_units?.[assetId]) !== expected) return false;
|
||||
}
|
||||
for (const [assetId, actual] of Object.entries(movement.delta_units || {})) {
|
||||
if (Object.prototype.hasOwnProperty.call(expectedDelta, assetId)) continue;
|
||||
if (safeBigInt(actual) !== 0n) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -405,8 +423,14 @@ function normalizePreflight(entry) {
|
|||
state: payload.state || null,
|
||||
reason_code: payload.reason_code || null,
|
||||
source_asset_id: payload.source_asset_id || null,
|
||||
source_symbol: payload.source_symbol || null,
|
||||
destination_asset_id: payload.destination_asset_id || null,
|
||||
destination_symbol: payload.destination_symbol || null,
|
||||
source_amount_units: payload.source_amount_units || null,
|
||||
destination_amount_units: payload.destination_amount_units || null,
|
||||
notional: payload.notional || null,
|
||||
notional_asset_id: payload.notional_asset_id || null,
|
||||
notional_symbol: payload.notional_symbol || null,
|
||||
min_destination_amount_units: payload.min_destination_amount_units || null,
|
||||
quoted_destination_amount_units: payload.quoted_destination_amount_units || null,
|
||||
selected_quote: payload.selected_quote || null,
|
||||
|
|
@ -471,6 +495,16 @@ function payloadOf(entry) {
|
|||
return entry.payload || entry;
|
||||
}
|
||||
|
||||
function uniqueAssetIds(deltaRecords = []) {
|
||||
const ids = new Set();
|
||||
for (const record of deltaRecords || []) {
|
||||
for (const assetId of Object.keys(record || {})) {
|
||||
if (assetId) ids.add(assetId);
|
||||
}
|
||||
}
|
||||
return [...ids];
|
||||
}
|
||||
|
||||
function safeBigInt(value) {
|
||||
if (value == null || value === '') return 0n;
|
||||
return BigInt(String(value));
|
||||
|
|
|
|||
|
|
@ -103,8 +103,8 @@ const CONTROL_DEFINITIONS = [
|
|||
action: 'intent-request-preflight',
|
||||
method: 'POST',
|
||||
path: '/intent-request/preflight',
|
||||
label: 'Preflight BTC Request',
|
||||
description: 'Ask solvers for an EURe-to-BTC request quote without submitting live funds.',
|
||||
label: 'Preflight Pair Request',
|
||||
description: 'Ask solvers for a configured pair request quote without submitting live funds.',
|
||||
page: 'funds',
|
||||
risk_class: 'safe',
|
||||
},
|
||||
|
|
@ -113,8 +113,8 @@ const CONTROL_DEFINITIONS = [
|
|||
action: 'intent-request-submit',
|
||||
method: 'POST',
|
||||
path: '/intent-request/submit',
|
||||
label: 'Submit BTC Request',
|
||||
description: 'Submit a previously drafted EURe-to-BTC request. Relay acceptance is not settlement.',
|
||||
label: 'Submit Pair Request',
|
||||
description: 'Submit a previously drafted pair request. Relay acceptance is not settlement.',
|
||||
page: 'funds',
|
||||
risk_class: 'live_funds',
|
||||
},
|
||||
|
|
@ -702,12 +702,18 @@ export function buildProfitabilitySummary({ metric, submissionSummary } = {}) {
|
|||
}
|
||||
|
||||
export function buildLiveStatusBar(state) {
|
||||
const referenceLabel = state.latest_market_price?.reference_pair || 'Reference route';
|
||||
const referenceValue = state.latest_market_price?.quote_per_base
|
||||
|| state.latest_market_price?.eure_per_btc
|
||||
|| null;
|
||||
return {
|
||||
near_intents_upstream_status: state.near_intents_status?.status || null,
|
||||
near_intents_upstream_label: state.near_intents_status?.label || null,
|
||||
near_intents_upstream_reason: state.near_intents_status?.decisive_reason || null,
|
||||
near_intents_upstream_observed_at: state.near_intents_status?.observed_at || null,
|
||||
latest_reference_price_eure_per_btc: state.latest_market_price?.eure_per_btc || null,
|
||||
latest_reference_route_label: referenceLabel,
|
||||
latest_reference_route_value: referenceValue,
|
||||
market_observed_at:
|
||||
state.latest_market_price?.observed_at
|
||||
|| state.latest_market_price?.ingested_at
|
||||
|
|
@ -748,13 +754,35 @@ function buildStatusBar({
|
|||
servicesByName,
|
||||
nearIntentsStatus = null,
|
||||
}) {
|
||||
const activePairs = (config.observedPairs || config.pairs || [])
|
||||
.filter((pair) => pair.observeEnabled || pair.enabled)
|
||||
.map((pair) => pair.key || pair.pairId)
|
||||
.filter(Boolean);
|
||||
const referenceRoutes = (config.pairs || [])
|
||||
.filter((pair) => pair.priceRoute?.routeId)
|
||||
.map((pair) => ({
|
||||
pair: pair.key || pair.pairId,
|
||||
price_route_id: pair.priceRoute.routeId,
|
||||
reference_pair: pair.priceRoute.routeConfig?.reference_pair || pair.priceRoute.source || null,
|
||||
}));
|
||||
const referenceLabel = marketPrice?.payload?.reference_pair
|
||||
|| referenceRoutes[0]?.reference_pair
|
||||
|| 'Reference route';
|
||||
const referenceValue = marketPrice?.payload?.quote_per_base
|
||||
|| marketPrice?.payload?.eure_per_btc
|
||||
|| null;
|
||||
return {
|
||||
active_pair: config.activePair,
|
||||
active_pairs: activePairs,
|
||||
active_pair_count: activePairs.length,
|
||||
reference_routes: referenceRoutes,
|
||||
near_intents_upstream_status: nearIntentsStatus?.status || null,
|
||||
near_intents_upstream_label: nearIntentsStatus?.label || null,
|
||||
near_intents_upstream_reason: nearIntentsStatus?.decisive_reason || null,
|
||||
near_intents_upstream_observed_at: nearIntentsStatus?.observed_at || null,
|
||||
latest_reference_price_eure_per_btc: marketPrice?.payload?.eure_per_btc || null,
|
||||
latest_reference_route_label: referenceLabel,
|
||||
latest_reference_route_value: referenceValue,
|
||||
market_observed_at: marketPrice?.payload?.observed_at || marketPrice?.ingested_at || null,
|
||||
market_freshness_ms: ageMs(marketPrice?.payload?.observed_at || marketPrice?.ingested_at),
|
||||
inventory_observed_at:
|
||||
|
|
@ -785,6 +813,11 @@ function buildBalanceSummary({ inventorySnapshot, marketPrice, config, portfolio
|
|||
.filter((asset) => asset?.asset_id)
|
||||
.map((asset) => [asset.asset_id, asset]),
|
||||
);
|
||||
const metricUnvaluedByAssetId = new Map(
|
||||
(portfolioMetric?.payload?.current_inventory?.unvalued_assets || [])
|
||||
.filter((asset) => asset?.asset_id)
|
||||
.map((asset) => [asset.asset_id, asset]),
|
||||
);
|
||||
|
||||
return {
|
||||
synced_at: inventory.synced_at || inventorySnapshot?.ingested_at || null,
|
||||
|
|
@ -794,6 +827,13 @@ function buildBalanceSummary({ inventorySnapshot, marketPrice, config, portfolio
|
|||
const pendingInboundUnits = String(pendingInbound[asset.assetId] || '0');
|
||||
const pendingOutboundUnits = String(pendingOutbound[asset.assetId] || '0');
|
||||
const metricValuation = metricValuationsByAssetId.get(asset.assetId);
|
||||
const metricUnvalued = metricUnvaluedByAssetId.get(asset.assetId);
|
||||
const fallbackValue = valueAssetInEur({
|
||||
asset,
|
||||
units: spendableUnits,
|
||||
marketPrice: marketPrice?.payload || marketPrice || null,
|
||||
});
|
||||
const value = metricValuation?.value_eure || fallbackValue;
|
||||
return {
|
||||
asset_id: asset.assetId,
|
||||
symbol: asset.symbol,
|
||||
|
|
@ -806,13 +846,12 @@ function buildBalanceSummary({ inventorySnapshot, marketPrice, config, portfolio
|
|||
pending_inbound: formatUnits(pendingInboundUnits, asset.decimals),
|
||||
pending_outbound_units: pendingOutboundUnits,
|
||||
pending_outbound: formatUnits(pendingOutboundUnits, asset.decimals),
|
||||
eur_value_eure: metricValuation?.value_eure
|
||||
|| valueAssetInEur({
|
||||
asset,
|
||||
units: spendableUnits,
|
||||
marketPrice: marketPrice?.payload || marketPrice || null,
|
||||
}),
|
||||
eur_value_eure: value,
|
||||
eur_value_source: metricValuation?.valuation_source || null,
|
||||
valuation_status: value != null ? 'valued' : 'unvalued',
|
||||
valuation_reason: value == null
|
||||
? metricUnvalued?.valuation_reason || 'valuation_route_missing'
|
||||
: null,
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
|
@ -914,25 +953,46 @@ function buildIntentRequestSummary({ config, intentRequests = [], executorState
|
|||
const usingDbPairConfig = Boolean(config.tradingConfigLoaded && strategyConfig);
|
||||
const sourceAsset = defaultPair?.assetIn || config.tradingEure;
|
||||
const destinationAsset = defaultPair?.assetOut || config.tradingBtc;
|
||||
const takerPairs = (config.pairs || []).filter((pair) => pair.takerEnabled);
|
||||
return {
|
||||
defaults: {
|
||||
pair_id: defaultPair?.pairId || null,
|
||||
pair_label: defaultPair
|
||||
? `${sourceAsset?.symbol || defaultPair.asset_in} -> ${destinationAsset?.symbol || defaultPair.asset_out}`
|
||||
: null,
|
||||
source_symbol: sourceAsset?.symbol || 'Source',
|
||||
destination_symbol: destinationAsset?.symbol || 'Destination',
|
||||
amount_eure: String(strategyConfig?.requestDefaultNotional ?? config.intentRequestDefaultAmountEure ?? 5),
|
||||
max_amount_eure: usingDbPairConfig
|
||||
source_amount: String(strategyConfig?.requestDefaultNotional ?? config.intentRequestDefaultAmountEure ?? 5),
|
||||
max_source_amount: usingDbPairConfig
|
||||
? strategyConfig.requestMaxNotional ?? null
|
||||
: String(config.intentRequestMaxAmountEure || 5),
|
||||
amount_eure: sourceAsset?.assetId === config.tradingEure?.assetId
|
||||
? String(strategyConfig?.requestDefaultNotional ?? config.intentRequestDefaultAmountEure ?? 5)
|
||||
: null,
|
||||
max_amount_eure: sourceAsset?.assetId === config.tradingEure?.assetId
|
||||
? (usingDbPairConfig ? strategyConfig.requestMaxNotional ?? null : String(config.intentRequestMaxAmountEure || 5))
|
||||
: null,
|
||||
slippage_bps: Number(strategyConfig?.slippageBps ?? config.intentRequestDefaultSlippageBps ?? 200),
|
||||
max_slippage_bps: usingDbPairConfig
|
||||
? strategyConfig.requestMaxSlippageBps ?? null
|
||||
: Number(config.intentRequestMaxSlippageBps ?? 200),
|
||||
},
|
||||
pairs: takerPairs.map((pair) => ({
|
||||
pair_id: pair.pairId,
|
||||
pair: pair.key,
|
||||
label: `${pair.assetIn?.symbol || pair.asset_in} -> ${pair.assetOut?.symbol || pair.asset_out}`,
|
||||
mode: pair.mode,
|
||||
can_trade: pair.canTrade,
|
||||
block_reason: pair.blockReason || null,
|
||||
source_symbol: pair.assetIn?.symbol || pair.asset_in,
|
||||
destination_symbol: pair.assetOut?.symbol || pair.asset_out,
|
||||
})),
|
||||
executor_armed: executorState.armed ?? null,
|
||||
executor_paused: executorState.paused ?? null,
|
||||
request_creation_state: executorState.request_creation || null,
|
||||
items: (intentRequests || []).map((request) => normalizeIntentRequestForUi({ config, request })),
|
||||
caveat:
|
||||
'Own request relay acceptance is not a completed trade. Completed requires durable EURe decrease and BTC increase evidence.',
|
||||
'Own request relay acceptance is not a completed trade. Completed requires durable source decrease and destination increase evidence.',
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -1113,6 +1173,9 @@ export function deriveQuoteLifecycleRows({
|
|||
direction: decision.direction,
|
||||
request_kind: decision.request_kind,
|
||||
gross_edge_pct: decision.gross_edge_pct,
|
||||
notional: decision.notional,
|
||||
notional_asset_id: decision.notional_asset_id,
|
||||
notional_symbol: decision.notional_symbol,
|
||||
eure_notional: decision.eure_notional,
|
||||
decision,
|
||||
decision_at: decision.decision_at || null,
|
||||
|
|
@ -1145,6 +1208,10 @@ export function deriveQuoteLifecycleRows({
|
|||
asset_out: command.asset_out || null,
|
||||
amount_in: command.amount_in || null,
|
||||
amount_out: command.amount_out || null,
|
||||
notional: command.notional || null,
|
||||
notional_asset_id: command.notional_asset_id || null,
|
||||
notional_symbol: command.notional_symbol || null,
|
||||
eure_notional: command.eure_notional || null,
|
||||
command,
|
||||
command_at: command.command_at || null,
|
||||
});
|
||||
|
|
@ -1172,6 +1239,9 @@ export function deriveQuoteLifecycleRows({
|
|||
direction: outcome?.direction || null,
|
||||
request_kind: outcome?.request_kind || null,
|
||||
gross_edge_pct: outcome?.gross_edge_pct || null,
|
||||
notional: outcome?.notional || null,
|
||||
notional_asset_id: outcome?.notional_asset_id || null,
|
||||
notional_symbol: outcome?.notional_symbol || null,
|
||||
eure_notional: outcome?.eure_notional || null,
|
||||
outcome,
|
||||
command_at: outcome?.command_at || null,
|
||||
|
|
@ -1201,6 +1271,9 @@ function ensureLifecycleRow(rowsByKey, key) {
|
|||
direction: null,
|
||||
request_kind: null,
|
||||
gross_edge_pct: null,
|
||||
notional: null,
|
||||
notional_asset_id: null,
|
||||
notional_symbol: null,
|
||||
eure_notional: null,
|
||||
quote_observed_at: null,
|
||||
decision_at: null,
|
||||
|
|
@ -1431,6 +1504,10 @@ function normalizeCommand(command) {
|
|||
edge_bps: command.edge_bps || null,
|
||||
direction: command.direction || null,
|
||||
request_kind: command.request_kind || null,
|
||||
notional: command.notional || null,
|
||||
notional_asset_id: command.notional_asset_id || null,
|
||||
notional_symbol: command.notional_symbol || null,
|
||||
eure_notional: command.eure_notional || null,
|
||||
asset_in: command.asset_in || null,
|
||||
asset_out: command.asset_out || null,
|
||||
amount_in: command.amount_in ?? null,
|
||||
|
|
@ -1910,8 +1987,10 @@ function normalizeTradeForUi({ config, trade }) {
|
|||
}
|
||||
|
||||
function enrichLifecycleRowForUi({ config, row }) {
|
||||
const notionalDisplay = formatNotional(row);
|
||||
return {
|
||||
...row,
|
||||
notional_display: notionalDisplay,
|
||||
request_terms: buildLifecycleTerms({
|
||||
config,
|
||||
terms: row.quote || row,
|
||||
|
|
@ -1920,6 +1999,7 @@ function enrichLifecycleRowForUi({ config, row }) {
|
|||
config,
|
||||
terms: row.command || row.execution || null,
|
||||
}),
|
||||
gross_edge_value: estimateGrossEdgeValue(row),
|
||||
gross_edge_value_eure: estimateGrossEdgeValueEure(row),
|
||||
settlement_summary: buildSettlementSummary({
|
||||
config,
|
||||
|
|
@ -1951,13 +2031,25 @@ function buildLifecycleTerms({ config, terms }) {
|
|||
}
|
||||
|
||||
function estimateGrossEdgeValueEure(row) {
|
||||
if (row?.notional && row?.notional_symbol && row.notional_symbol !== 'EURe') return null;
|
||||
return estimateGrossEdgeValue(row);
|
||||
}
|
||||
|
||||
function estimateGrossEdgeValue(row) {
|
||||
const edge = Number(row?.gross_edge_pct);
|
||||
const notional = Number(row?.eure_notional);
|
||||
const notional = Number(row?.notional ?? row?.eure_notional);
|
||||
if (!Number.isFinite(edge) || !Number.isFinite(notional)) return null;
|
||||
const value = (notional * edge) / 100;
|
||||
return value.toFixed(8).replace(/\.?0+$/, '');
|
||||
}
|
||||
|
||||
function formatNotional(row) {
|
||||
const notional = row?.notional ?? row?.eure_notional ?? null;
|
||||
if (notional == null) return null;
|
||||
const symbol = row?.notional_symbol || (row?.eure_notional ? 'EURe' : null);
|
||||
return symbol ? `${notional} ${symbol}` : String(notional);
|
||||
}
|
||||
|
||||
function buildSettlementSummary({
|
||||
config,
|
||||
delta,
|
||||
|
|
@ -2155,9 +2247,13 @@ function normalizeDecision(decision) {
|
|||
decision_reason: normalizeDecisionReason(decision.decision_reason),
|
||||
gross_edge_pct: decision.gross_edge_pct || null,
|
||||
threshold_pct: decision.threshold_pct || null,
|
||||
max_notional: decision.max_notional || null,
|
||||
max_notional_eure: decision.max_notional_eure || null,
|
||||
strategy_armed: decision.strategy_armed ?? null,
|
||||
inventory_asset: decision.inventory_asset || null,
|
||||
notional: decision.notional || null,
|
||||
notional_asset_id: decision.notional_asset_id || null,
|
||||
notional_symbol: decision.notional_symbol || null,
|
||||
eure_notional: decision.eure_notional || null,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,11 @@ export function computePortfolioMetric({
|
|||
btcAssets: effectiveBtcAssets,
|
||||
eureAsset,
|
||||
});
|
||||
const currentUnvaluedAssets = normalizeUnvaluedAssets({
|
||||
valuationAssets,
|
||||
btcAssets: effectiveBtcAssets,
|
||||
eureAsset,
|
||||
});
|
||||
const currentValuedAssets = valueValuationAssets({
|
||||
inventory: currentInventory,
|
||||
valuationAssets: effectiveValuationAssets,
|
||||
|
|
@ -53,6 +58,7 @@ export function computePortfolioMetric({
|
|||
btcAssets: effectiveBtcAssets,
|
||||
eureAsset,
|
||||
valuationAssets: effectiveValuationAssets,
|
||||
unvaluedAssets: currentUnvaluedAssets,
|
||||
}),
|
||||
current_portfolio_value_eure: formatScaledDecimal(currentPortfolioValue),
|
||||
current_btc_mark_value_eure: formatScaledDecimal(currentBtcMarkValue),
|
||||
|
|
@ -124,6 +130,7 @@ export function computePortfolioMetric({
|
|||
btcAssets: effectiveBtcAssets,
|
||||
eureAsset,
|
||||
valuationAssets: effectiveValuationAssets,
|
||||
unvaluedAssets: currentUnvaluedAssets,
|
||||
});
|
||||
const fundedPortfolioAtFlowTime = baselinePortfolioAtBaselinePrice
|
||||
+ externalFlowSummary.netValueEureAtFlowTime;
|
||||
|
|
@ -201,6 +208,7 @@ export function computePortfolioMetric({
|
|||
btcAssets: effectiveBtcAssets,
|
||||
eureAsset,
|
||||
valuationAssets: effectiveValuationAssets,
|
||||
unvaluedAssets: currentUnvaluedAssets,
|
||||
}),
|
||||
};
|
||||
|
||||
|
|
@ -229,12 +237,26 @@ export function buildCashEquivalentValuationAssets({
|
|||
return (trackedAssets || [])
|
||||
.filter((asset) => asset?.assetId && !excludedAssetIds.has(asset.assetId))
|
||||
.map((asset) => {
|
||||
if (asset.symbol !== 'USDC' || !usdcUnitValueEure) return null;
|
||||
if (asset.symbol !== 'USDC' || !usdcUnitValueEure) {
|
||||
return {
|
||||
asset,
|
||||
assetId: asset.assetId,
|
||||
symbol: asset.symbol || asset.assetId,
|
||||
label: asset.label || asset.symbol || asset.assetId,
|
||||
decimals: asset.decimals,
|
||||
valuationStatus: 'unvalued',
|
||||
valuationReason: 'valuation_route_missing',
|
||||
};
|
||||
}
|
||||
return {
|
||||
asset,
|
||||
assetId: asset.assetId,
|
||||
symbol: asset.symbol || asset.assetId,
|
||||
label: asset.label || asset.symbol || asset.assetId,
|
||||
decimals: asset.decimals,
|
||||
currentUnitValueEure: usdcUnitValueEure,
|
||||
baselineUnitValueEure: usdcUnitValueEure,
|
||||
valuationStatus: 'valued',
|
||||
valuationSource: 'btc_usdc_reference',
|
||||
priceId: usdcPrice.price_id || null,
|
||||
observedAt: usdcPrice.observed_at || usdcPrice.ingested_at || null,
|
||||
|
|
@ -258,6 +280,7 @@ function buildInventoryView({
|
|||
btcAssets = null,
|
||||
eureAsset,
|
||||
valuationAssets = [],
|
||||
unvaluedAssets = [],
|
||||
}) {
|
||||
const effectiveBtcAssets = normalizeBtcAssets({ btcAsset, btcAssets });
|
||||
const effectiveValuationAssets = normalizeValuationAssets({
|
||||
|
|
@ -291,6 +314,7 @@ function buildInventoryView({
|
|||
eure_units: eureUnits,
|
||||
eure: formatAssetUnits(eureUnits, eureAsset.decimals),
|
||||
valued_assets: valuedAssets.items,
|
||||
unvalued_assets: buildUnvaluedAssetRows({ inventory, unvaluedAssets }),
|
||||
valued_assets_value_eure: formatScaledDecimal(valuedAssets.total),
|
||||
};
|
||||
}
|
||||
|
|
@ -421,6 +445,37 @@ function normalizeValuationAssets({ valuationAssets = [], btcAssets = [], eureAs
|
|||
));
|
||||
}
|
||||
|
||||
function normalizeUnvaluedAssets({ valuationAssets = [], btcAssets = [], eureAsset = null } = {}) {
|
||||
const excludedAssetIds = new Set([
|
||||
...normalizeBtcAssets({ btcAssets }).map((asset) => asset.assetId),
|
||||
eureAsset?.assetId,
|
||||
].filter(Boolean));
|
||||
|
||||
return (valuationAssets || [])
|
||||
.map((entry) => {
|
||||
const asset = entry.asset || entry;
|
||||
const assetId = entry.assetId || entry.asset_id || asset.assetId || asset.asset_id || null;
|
||||
const valuationStatus = entry.valuationStatus || entry.valuation_status || null;
|
||||
if (!assetId || excludedAssetIds.has(assetId) || valuationStatus !== 'unvalued') return null;
|
||||
return {
|
||||
assetId,
|
||||
symbol: asset.symbol || entry.symbol || assetId,
|
||||
label: asset.label || entry.label || asset.symbol || entry.symbol || assetId,
|
||||
decimals: Number(asset.decimals ?? entry.decimals ?? 0),
|
||||
valuationStatus: 'unvalued',
|
||||
valuationReason:
|
||||
entry.valuationReason
|
||||
|| entry.valuation_reason
|
||||
|| 'valuation_route_missing',
|
||||
};
|
||||
})
|
||||
.filter((asset) => (
|
||||
asset
|
||||
&& Number.isInteger(asset.decimals)
|
||||
&& asset.decimals >= 0
|
||||
));
|
||||
}
|
||||
|
||||
function valueValuationAssets({ inventory, valuationAssets, priceField }) {
|
||||
const spendable = inventory?.spendable || {};
|
||||
let total = 0n;
|
||||
|
|
@ -449,6 +504,22 @@ function valueValuationAssets({ inventory, valuationAssets, priceField }) {
|
|||
return { total, items };
|
||||
}
|
||||
|
||||
function buildUnvaluedAssetRows({ inventory, unvaluedAssets }) {
|
||||
const spendable = inventory?.spendable || {};
|
||||
return (unvaluedAssets || []).map((asset) => {
|
||||
const units = String(spendable[asset.assetId] || '0');
|
||||
return {
|
||||
asset_id: asset.assetId,
|
||||
symbol: asset.symbol,
|
||||
label: asset.label,
|
||||
units,
|
||||
amount: formatAssetUnits(units, asset.decimals),
|
||||
valuation_status: asset.valuationStatus,
|
||||
valuation_reason: asset.valuationReason,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function buildValuedAssetInventoryDelta({
|
||||
currentInventory,
|
||||
baselineInventory,
|
||||
|
|
@ -512,9 +583,20 @@ function unitsToScaledDecimal(units, decimals) {
|
|||
}
|
||||
|
||||
function formatAssetUnits(units, decimals) {
|
||||
if (decimals > VALUE_SCALE) return formatRawUnitsDecimal(units, decimals);
|
||||
return formatScaledDecimal(BigInt(units || '0') * 10n ** BigInt(VALUE_SCALE - decimals));
|
||||
}
|
||||
|
||||
function formatRawUnitsDecimal(units, decimals) {
|
||||
const raw = String(units ?? '0');
|
||||
const negative = raw.startsWith('-');
|
||||
const digits = negative ? raw.slice(1) : raw;
|
||||
const padded = digits.padStart(decimals + 1, '0');
|
||||
const whole = padded.slice(0, padded.length - decimals) || '0';
|
||||
const fraction = decimals > 0 ? padded.slice(-decimals).replace(/0+$/, '') : '';
|
||||
return `${negative ? '-' : ''}${whole}${fraction ? `.${fraction}` : ''}`;
|
||||
}
|
||||
|
||||
function parseScaledDecimal(value) {
|
||||
const normalized = String(value ?? '0').trim();
|
||||
const negative = normalized.startsWith('-');
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@ export function deriveQuoteOutcomeRecords({
|
|||
attributionWindowMs = DEFAULT_ATTRIBUTION_WINDOW_MS,
|
||||
settlementGraceMs = DEFAULT_SETTLEMENT_GRACE_MS,
|
||||
} = {}) {
|
||||
const activeAssetIds = [btcAsset?.assetId, eureAsset?.assetId].filter(Boolean);
|
||||
const normalizedSubmissions = submissions
|
||||
.map(normalizeSubmission)
|
||||
.filter((entry) => entry?.quote_id && entry.status === 'submitted');
|
||||
|
|
@ -33,6 +32,16 @@ export function deriveQuoteOutcomeRecords({
|
|||
.filter((entry) => entry?.quote_id)
|
||||
.map((entry) => [entry.quote_id, entry]),
|
||||
);
|
||||
const expectedDeltasByQuote = new Map();
|
||||
for (const submission of normalizedSubmissions) {
|
||||
const command = commandsByQuote.get(submission.quote_id) || null;
|
||||
const expectedDelta = buildExpectedMakerDeltas(command);
|
||||
if (expectedDelta) expectedDeltasByQuote.set(submission.quote_id, expectedDelta);
|
||||
}
|
||||
const activeAssetIds = uniqueAssetIds([
|
||||
...expectedDeltasByQuote.values(),
|
||||
Object.fromEntries([btcAsset?.assetId, eureAsset?.assetId].filter(Boolean).map((assetId) => [assetId, 0n])),
|
||||
]);
|
||||
const inventoryDeltas = deriveInventoryDeltas({
|
||||
inventorySnapshots,
|
||||
activeAssetIds,
|
||||
|
|
@ -42,8 +51,7 @@ export function deriveQuoteOutcomeRecords({
|
|||
const candidatesByQuote = new Map();
|
||||
|
||||
for (const submission of normalizedSubmissions) {
|
||||
const command = commandsByQuote.get(submission.quote_id) || null;
|
||||
const expectedDelta = buildExpectedMakerDeltas(command);
|
||||
const expectedDelta = expectedDeltasByQuote.get(submission.quote_id) || null;
|
||||
if (!expectedDelta) continue;
|
||||
|
||||
const matches = inventoryDeltas.filter((movement) => (
|
||||
|
|
@ -298,7 +306,10 @@ function baseOutcomeRecord({
|
|||
direction: decision?.direction || command?.direction || null,
|
||||
request_kind: command?.request_kind || decision?.request_kind || null,
|
||||
gross_edge_pct: decision?.gross_edge_pct || null,
|
||||
eure_notional: decision?.eure_notional || null,
|
||||
notional: decision?.notional || command?.notional || null,
|
||||
notional_asset_id: decision?.notional_asset_id || command?.notional_asset_id || null,
|
||||
notional_symbol: decision?.notional_symbol || command?.notional_symbol || null,
|
||||
eure_notional: decision?.eure_notional || command?.eure_notional || null,
|
||||
execution_result_status: submission.status,
|
||||
execution_result_code: submission.result_code || null,
|
||||
submitted_at: submission.submitted_at,
|
||||
|
|
@ -335,6 +346,10 @@ function movementMatchesExpectedDelta({
|
|||
for (const [assetId, expected] of Object.entries(expectedDelta)) {
|
||||
if (safeBigInt(movement.delta_units?.[assetId]) !== expected) return false;
|
||||
}
|
||||
for (const [assetId, actual] of Object.entries(movement.delta_units || {})) {
|
||||
if (Object.prototype.hasOwnProperty.call(expectedDelta, assetId)) continue;
|
||||
if (safeBigInt(actual) !== 0n) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
|
@ -368,6 +383,8 @@ function getExpiredSettlementWindow({
|
|||
}
|
||||
|
||||
function buildExpectedMakerDeltas(command) {
|
||||
const explicitDelta = normalizeExpectedDelta(command?.expected_inventory_delta_units);
|
||||
if (explicitDelta) return explicitDelta;
|
||||
if (!command?.asset_in || !command?.asset_out || !command?.request_kind) return null;
|
||||
|
||||
const receiveAmount = command.request_kind === 'exact_in'
|
||||
|
|
@ -416,6 +433,10 @@ function normalizeCommand(entry) {
|
|||
pair: payload.pair || null,
|
||||
direction: payload.direction || null,
|
||||
request_kind: payload.request_kind || null,
|
||||
notional: payload.notional || null,
|
||||
notional_asset_id: payload.notional_asset_id || null,
|
||||
notional_symbol: payload.notional_symbol || null,
|
||||
eure_notional: payload.eure_notional || null,
|
||||
asset_in: payload.asset_in || null,
|
||||
asset_out: payload.asset_out || null,
|
||||
amount_in: payload.amount_in ?? null,
|
||||
|
|
@ -423,6 +444,7 @@ function normalizeCommand(entry) {
|
|||
quote_output: payload.quote_output || {},
|
||||
proposed_amount_in: payload.proposed_amount_in ?? null,
|
||||
proposed_amount_out: payload.proposed_amount_out ?? null,
|
||||
expected_inventory_delta_units: payload.expected_inventory_delta_units || null,
|
||||
min_deadline_ms: payload.min_deadline_ms ?? null,
|
||||
command_at: toIsoTimestamp(
|
||||
entry?.observed_at
|
||||
|
|
@ -444,6 +466,9 @@ function normalizeDecision(entry) {
|
|||
direction: payload.direction || null,
|
||||
request_kind: payload.request_kind || null,
|
||||
gross_edge_pct: payload.gross_edge_pct || null,
|
||||
notional: payload.notional || null,
|
||||
notional_asset_id: payload.notional_asset_id || null,
|
||||
notional_symbol: payload.notional_symbol || null,
|
||||
eure_notional: payload.eure_notional || null,
|
||||
};
|
||||
}
|
||||
|
|
@ -476,6 +501,26 @@ function payloadOf(entry) {
|
|||
return entry.payload || entry;
|
||||
}
|
||||
|
||||
function uniqueAssetIds(deltaRecords = []) {
|
||||
const ids = new Set();
|
||||
for (const record of deltaRecords || []) {
|
||||
for (const assetId of Object.keys(record || {})) {
|
||||
if (assetId) ids.add(assetId);
|
||||
}
|
||||
}
|
||||
return [...ids];
|
||||
}
|
||||
|
||||
function normalizeExpectedDelta(delta) {
|
||||
if (!delta || typeof delta !== 'object' || Array.isArray(delta)) return null;
|
||||
const normalized = {};
|
||||
for (const [assetId, units] of Object.entries(delta)) {
|
||||
if (!assetId || units == null || units === '') continue;
|
||||
normalized[assetId] = safeBigInt(units);
|
||||
}
|
||||
return Object.keys(normalized).length ? normalized : null;
|
||||
}
|
||||
|
||||
function safeBigInt(value) {
|
||||
if (value == null || value === '') return 0n;
|
||||
return BigInt(String(value));
|
||||
|
|
|
|||
144
src/core/route-rates.mjs
Normal file
144
src/core/route-rates.mjs
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
const SUPPORTED_ROUTE_SOURCES = new Set([
|
||||
'btc_eur_reference',
|
||||
'btc_usdc_reference',
|
||||
]);
|
||||
|
||||
export function isSupportedPriceRouteSource(source) {
|
||||
return SUPPORTED_ROUTE_SOURCES.has(source);
|
||||
}
|
||||
|
||||
export function classifyRouteDirection({
|
||||
sourceAssetId = null,
|
||||
destinationAssetId = null,
|
||||
priceRoute = null,
|
||||
} = {}) {
|
||||
if (!priceRoute || !isSupportedPriceRouteSource(priceRoute.source)) return 'unsupported';
|
||||
if (sourceAssetId === priceRoute.baseAssetId && destinationAssetId === priceRoute.quoteAssetId) {
|
||||
return 'base_to_quote';
|
||||
}
|
||||
if (sourceAssetId === priceRoute.quoteAssetId && destinationAssetId === priceRoute.baseAssetId) {
|
||||
return 'quote_to_base';
|
||||
}
|
||||
return 'unsupported';
|
||||
}
|
||||
|
||||
export function resolveRouteRates({
|
||||
price = null,
|
||||
priceRoute = null,
|
||||
direction = null,
|
||||
} = {}) {
|
||||
if (!price) return { ok: false, reason: 'reference_price_missing' };
|
||||
const normalizedDirection = normalizeRouteDirection(direction);
|
||||
if (!normalizedDirection) return { ok: false, reason: 'unsupported_pair' };
|
||||
|
||||
if (priceRoute?.routeId && price.price_route_id && price.price_route_id !== priceRoute.routeId) {
|
||||
return { ok: false, reason: 'reference_price_missing' };
|
||||
}
|
||||
if (priceRoute?.source && !isSupportedPriceRouteSource(priceRoute.source)) {
|
||||
return { ok: false, reason: 'unsupported_price_route' };
|
||||
}
|
||||
|
||||
const legacyFields = legacyRateFields({
|
||||
source: priceRoute?.source || inferRouteSourceFromDirection(direction),
|
||||
direction,
|
||||
});
|
||||
let quotePerBase = Number(firstPresent(
|
||||
price.quote_per_base,
|
||||
price.quotePerBase,
|
||||
legacyFields ? price[legacyFields.quotePerBaseField] : null,
|
||||
));
|
||||
let basePerQuote = Number(firstPresent(
|
||||
price.base_per_quote,
|
||||
price.basePerQuote,
|
||||
legacyFields ? price[legacyFields.basePerQuoteField] : null,
|
||||
));
|
||||
if (!(basePerQuote > 0) && quotePerBase > 0) basePerQuote = 1 / quotePerBase;
|
||||
if (!(quotePerBase > 0) && basePerQuote > 0) quotePerBase = 1 / basePerQuote;
|
||||
|
||||
if (!(quotePerBase > 0) || !(basePerQuote > 0)) {
|
||||
return { ok: false, reason: 'reference_price_missing' };
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
quotePerBase,
|
||||
basePerQuote,
|
||||
baseToQuote: normalizedDirection === 'base_to_quote',
|
||||
direction: normalizedDirection,
|
||||
referencePriceId: price.price_id || null,
|
||||
priceRouteId: price.price_route_id || priceRoute?.routeId || null,
|
||||
};
|
||||
}
|
||||
|
||||
export function computeDestinationAmountUnitsFromRoute({
|
||||
sourceAmountUnits,
|
||||
sourceDecimals,
|
||||
destinationDecimals,
|
||||
direction,
|
||||
quotePerBase,
|
||||
basePerQuote,
|
||||
} = {}) {
|
||||
const normalizedDirection = normalizeRouteDirection(direction);
|
||||
if (!normalizedDirection) throw new Error('unsupported route direction');
|
||||
if (!Number.isInteger(sourceDecimals) || sourceDecimals < 0) {
|
||||
throw new Error('source decimals are required');
|
||||
}
|
||||
if (!Number.isInteger(destinationDecimals) || destinationDecimals < 0) {
|
||||
throw new Error('destination decimals are required');
|
||||
}
|
||||
|
||||
const rate = parsePositiveDecimal(
|
||||
normalizedDirection === 'base_to_quote' ? quotePerBase : basePerQuote,
|
||||
{ field: normalizedDirection === 'base_to_quote' ? 'quote_per_base' : 'base_per_quote' },
|
||||
);
|
||||
const sourceUnits = BigInt(String(sourceAmountUnits || '0'));
|
||||
if (sourceUnits <= 0n) throw new Error('sourceAmountUnits must be greater than zero');
|
||||
|
||||
const numerator = sourceUnits * (10n ** BigInt(destinationDecimals)) * rate.units;
|
||||
const denominator = (10n ** BigInt(sourceDecimals)) * rate.scale;
|
||||
if (denominator <= 0n) throw new Error('invalid route denominator');
|
||||
return (numerator / denominator).toString();
|
||||
}
|
||||
|
||||
export function normalizeRouteDirection(direction) {
|
||||
if (direction === 'base_to_quote' || direction === 'quote_to_base') return direction;
|
||||
if (direction === 'btc_to_eure' || direction === 'btc_to_usdc') return 'base_to_quote';
|
||||
if (direction === 'eure_to_btc' || direction === 'usdc_to_btc') return 'quote_to_base';
|
||||
return null;
|
||||
}
|
||||
|
||||
function legacyRateFields({ source, direction } = {}) {
|
||||
if (source === 'btc_usdc_reference' || direction === 'btc_to_usdc' || direction === 'usdc_to_btc') {
|
||||
return {
|
||||
quotePerBaseField: 'usdc_per_btc',
|
||||
basePerQuoteField: 'btc_per_usdc',
|
||||
};
|
||||
}
|
||||
if (source === 'btc_eur_reference' || direction === 'btc_to_eure' || direction === 'eure_to_btc') {
|
||||
return {
|
||||
quotePerBaseField: 'eure_per_btc',
|
||||
basePerQuoteField: 'btc_per_eure',
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function inferRouteSourceFromDirection(direction) {
|
||||
if (direction === 'btc_to_usdc' || direction === 'usdc_to_btc') return 'btc_usdc_reference';
|
||||
if (direction === 'btc_to_eure' || direction === 'eure_to_btc') return 'btc_eur_reference';
|
||||
return null;
|
||||
}
|
||||
|
||||
function firstPresent(...values) {
|
||||
return values.find((value) => value != null && value !== '');
|
||||
}
|
||||
|
||||
function parsePositiveDecimal(value, { field }) {
|
||||
const raw = String(value ?? '').trim();
|
||||
if (!/^\d+(\.\d+)?$/.test(raw)) throw new Error(`${field} must be a positive decimal`);
|
||||
const [whole, fraction = ''] = raw.split('.');
|
||||
const scale = 10n ** BigInt(fraction.length);
|
||||
const units = BigInt(`${whole}${fraction}`.replace(/^0+/, '') || '0');
|
||||
if (units <= 0n) throw new Error(`${field} must be greater than zero`);
|
||||
return { units, scale };
|
||||
}
|
||||
|
|
@ -33,6 +33,7 @@ export function createRuntimeHealthThresholds(config = {}) {
|
|||
export function evaluateRuntimeHealth({
|
||||
servicesByName,
|
||||
activePair,
|
||||
activePairs = activePair ? [activePair] : [],
|
||||
activeAlerts = [],
|
||||
now = new Date().toISOString(),
|
||||
} = {}) {
|
||||
|
|
@ -45,6 +46,7 @@ export function evaluateRuntimeHealth({
|
|||
service,
|
||||
snapshot,
|
||||
activePair,
|
||||
activePairs,
|
||||
activeAlerts: alerts,
|
||||
now,
|
||||
}));
|
||||
|
|
@ -57,6 +59,7 @@ export function deriveServiceHealth({
|
|||
service,
|
||||
snapshot,
|
||||
activePair = null,
|
||||
activePairs = activePair ? [activePair] : [],
|
||||
activeAlerts = [],
|
||||
now = new Date().toISOString(),
|
||||
} = {}) {
|
||||
|
|
@ -149,7 +152,7 @@ export function deriveServiceHealth({
|
|||
if (
|
||||
['strategy-engine', 'trade-executor'].includes(service)
|
||||
&& (state.armed ?? false)
|
||||
&& hasCriticalTruthAlert(activeAlerts, activePair)
|
||||
&& hasCriticalTruthAlert(activeAlerts, activePairs.length ? activePairs : activePair)
|
||||
) {
|
||||
status = escalateHealth(status, 'critical');
|
||||
if (label === 'healthy' || label === 'warning') {
|
||||
|
|
@ -355,11 +358,12 @@ function indexAlertsByService(activeAlerts) {
|
|||
}
|
||||
|
||||
function hasCriticalTruthAlert(alerts, activePair) {
|
||||
const activePairSet = new Set(Array.isArray(activePair) ? activePair : [activePair].filter(Boolean));
|
||||
return (alerts || []).some((alert) => (
|
||||
alert.severity === 'critical'
|
||||
&& (
|
||||
alert.pair == null
|
||||
|| alert.pair === activePair
|
||||
|| activePairSet.has(alert.pair)
|
||||
|| alert.alert_code.includes('stale')
|
||||
|| alert.alert_code.includes('disconnected')
|
||||
)
|
||||
|
|
|
|||
|
|
@ -8,6 +8,10 @@ import {
|
|||
pairKey,
|
||||
unitsToNumber,
|
||||
} from './assets.mjs';
|
||||
import {
|
||||
classifyRouteDirection,
|
||||
resolveRouteRates,
|
||||
} from './route-rates.mjs';
|
||||
|
||||
export function evaluateTradeOpportunity({
|
||||
demandEvent,
|
||||
|
|
@ -17,12 +21,13 @@ export function evaluateTradeOpportunity({
|
|||
now = Date.now(),
|
||||
armed = false,
|
||||
thresholdPct = config.strategyGrossThresholdPct,
|
||||
maxNotionalEure = config.strategyMaxNotionalEure,
|
||||
maxNotional = config.strategyMaxNotional ?? config.strategyMaxNotionalEure,
|
||||
}) {
|
||||
const payload = demandEvent.payload;
|
||||
const pairRuntime = resolvePairRuntime({ payload, config, thresholdPct, maxNotionalEure });
|
||||
const pairRuntime = resolvePairRuntime({ payload, config, thresholdPct, maxNotional });
|
||||
const effectiveThresholdPct = pairRuntime.thresholdPct ?? thresholdPct;
|
||||
const effectiveMaxNotionalEure = pairRuntime.maxNotionalEure ?? maxNotionalEure;
|
||||
const effectiveMaxNotional = pairRuntime.maxNotional ?? maxNotional;
|
||||
const legacyEureNotional = isLegacyEureNotional({ pairRuntime, config });
|
||||
const decisionId = crypto.randomUUID();
|
||||
const baseDecision = {
|
||||
decision_id: decisionId,
|
||||
|
|
@ -36,7 +41,7 @@ export function evaluateTradeOpportunity({
|
|||
edge_bps: pairRuntime.strategyConfig?.edgeBps == null
|
||||
? null
|
||||
: String(pairRuntime.strategyConfig.edgeBps),
|
||||
max_notional: effectiveMaxNotionalEure == null ? null : String(effectiveMaxNotionalEure),
|
||||
max_notional: effectiveMaxNotional == null ? null : String(effectiveMaxNotional),
|
||||
min_notional: pairRuntime.strategyConfig?.minNotional == null
|
||||
? null
|
||||
: String(pairRuntime.strategyConfig.minNotional),
|
||||
|
|
@ -46,7 +51,9 @@ export function evaluateTradeOpportunity({
|
|||
decision: 'rejected',
|
||||
decision_reason: 'unknown',
|
||||
threshold_pct: String(effectiveThresholdPct),
|
||||
max_notional_eure: String(effectiveMaxNotionalEure),
|
||||
max_notional_eure: legacyEureNotional && effectiveMaxNotional != null
|
||||
? String(effectiveMaxNotional)
|
||||
: null,
|
||||
strategy_armed: armed,
|
||||
assumptions: compact({
|
||||
eure_per_eur: pairRuntime.priceRoute?.source === 'btc_eur_reference' ? '1' : null,
|
||||
|
|
@ -97,7 +104,7 @@ export function evaluateTradeOpportunity({
|
|||
config,
|
||||
pairRuntime,
|
||||
thresholdPct: effectiveThresholdPct,
|
||||
maxNotionalEure: effectiveMaxNotionalEure,
|
||||
maxNotional: effectiveMaxNotional,
|
||||
});
|
||||
|
||||
if (!buildResult.ok) {
|
||||
|
|
@ -137,12 +144,19 @@ export function evaluateTradeOpportunity({
|
|||
pair_config_version: decision.pair_config_version,
|
||||
edge_bps: decision.edge_bps,
|
||||
max_notional: decision.max_notional,
|
||||
min_notional: decision.min_notional,
|
||||
max_notional_eure: decision.max_notional_eure,
|
||||
price_route_id: decision.price_route_id,
|
||||
reference_price_id: buildResult.details.price_id || null,
|
||||
notional: decision.notional,
|
||||
notional_asset_id: decision.notional_asset_id,
|
||||
notional_symbol: decision.notional_symbol,
|
||||
source_asset_id: payload.asset_in,
|
||||
source_symbol: pairRuntime.assetIn?.symbol || null,
|
||||
source_decimals: pairRuntime.assetIn?.decimals ?? null,
|
||||
destination_asset_id: payload.asset_out,
|
||||
destination_symbol: pairRuntime.assetOut?.symbol || null,
|
||||
destination_decimals: pairRuntime.assetOut?.decimals ?? null,
|
||||
asset_in: payload.asset_in,
|
||||
asset_out: payload.asset_out,
|
||||
asset_in_decimals: pairRuntime.assetIn?.decimals ?? null,
|
||||
|
|
@ -154,6 +168,12 @@ export function evaluateTradeOpportunity({
|
|||
quote_output: buildResult.quoteOutput,
|
||||
proposed_amount_in: buildResult.details.proposed_amount_in ?? null,
|
||||
proposed_amount_out: buildResult.details.proposed_amount_out ?? null,
|
||||
expected_inventory_delta_units: buildExpectedMakerInventoryDelta({
|
||||
demand: payload,
|
||||
quoteOutput: buildResult.quoteOutput,
|
||||
proposedAmountIn: buildResult.details.proposed_amount_in ?? null,
|
||||
proposedAmountOut: buildResult.details.proposed_amount_out ?? null,
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -165,7 +185,7 @@ function buildQuote({
|
|||
config,
|
||||
pairRuntime = null,
|
||||
thresholdPct,
|
||||
maxNotionalEure,
|
||||
maxNotional,
|
||||
}) {
|
||||
const direction = pairRuntime?.direction || classifyPairDirection({
|
||||
assetIn: demand.asset_in,
|
||||
|
|
@ -190,11 +210,17 @@ function buildQuote({
|
|||
const spendAsset = demand.asset_out;
|
||||
const available = bigintAmount(inventory.spendable?.[spendAsset] || '0');
|
||||
const pendingInbound = bigintAmount(inventory.pending_inbound?.[spendAsset] || '0');
|
||||
const referenceRates = resolveReferenceRates({ direction, price });
|
||||
const referenceRates = resolveRouteRates({
|
||||
direction,
|
||||
price,
|
||||
priceRoute: pairRuntime?.priceRoute || null,
|
||||
});
|
||||
if (!referenceRates.ok) return { ok: false, reason: referenceRates.reason, details: {} };
|
||||
const { quotePerBase, basePerQuote, baseToQuote } = referenceRates;
|
||||
const notionalAssetId = pairRuntime?.priceRoute?.quoteAssetId || assetOut.assetId;
|
||||
const notionalAssetId = pairRuntime?.priceRoute?.quoteAssetId
|
||||
|| (baseToQuote ? assetOut.assetId : assetIn.assetId);
|
||||
const notionalAsset = assetRegistry.get(notionalAssetId) || null;
|
||||
const legacyEureNotional = notionalAssetId === config.tradingEure?.assetId;
|
||||
|
||||
if (demand.request_kind === 'exact_in') {
|
||||
const amountIn = bigintAmount(demand.amount_in);
|
||||
|
|
@ -232,7 +258,8 @@ function buildQuote({
|
|||
quoteNotional,
|
||||
notionalAssetId,
|
||||
notionalSymbol: notionalAsset?.symbol || null,
|
||||
maxNotionalEure,
|
||||
maxNotional,
|
||||
legacyEureNotional,
|
||||
proposedAmountOut: proposedOutputUnits,
|
||||
impliedRate,
|
||||
referenceRate,
|
||||
|
|
@ -240,6 +267,10 @@ function buildQuote({
|
|||
priceId: price.price_id,
|
||||
assetInDecimals: assetIn.decimals,
|
||||
assetOutDecimals: assetOut.decimals,
|
||||
assetInId: assetIn.assetId,
|
||||
assetOutId: assetOut.assetId,
|
||||
assetInSymbol: assetIn.symbol,
|
||||
assetOutSymbol: assetOut.symbol,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -279,7 +310,8 @@ function buildQuote({
|
|||
quoteNotional,
|
||||
notionalAssetId,
|
||||
notionalSymbol: notionalAsset?.symbol || null,
|
||||
maxNotionalEure,
|
||||
maxNotional,
|
||||
legacyEureNotional,
|
||||
proposedAmountIn: proposedInputUnits,
|
||||
proposedAmountOut: demand.amount_out,
|
||||
impliedRate,
|
||||
|
|
@ -288,6 +320,10 @@ function buildQuote({
|
|||
priceId: price.price_id,
|
||||
assetInDecimals: assetIn.decimals,
|
||||
assetOutDecimals: assetOut.decimals,
|
||||
assetInId: assetIn.assetId,
|
||||
assetOutId: assetOut.assetId,
|
||||
assetInSymbol: assetIn.symbol,
|
||||
assetOutSymbol: assetOut.symbol,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -303,7 +339,8 @@ function finalizeQuote({
|
|||
quoteNotional,
|
||||
notionalAssetId = null,
|
||||
notionalSymbol = null,
|
||||
maxNotionalEure,
|
||||
maxNotional,
|
||||
legacyEureNotional = false,
|
||||
proposedAmountIn = null,
|
||||
proposedAmountOut = null,
|
||||
impliedRate,
|
||||
|
|
@ -312,6 +349,10 @@ function finalizeQuote({
|
|||
priceId,
|
||||
assetInDecimals = null,
|
||||
assetOutDecimals = null,
|
||||
assetInId = null,
|
||||
assetOutId = null,
|
||||
assetInSymbol = null,
|
||||
assetOutSymbol = null,
|
||||
}) {
|
||||
const grossEdgePct = ((referenceRate - impliedRate) / referenceRate) * 100;
|
||||
const reasonBase = {
|
||||
|
|
@ -327,10 +368,16 @@ function finalizeQuote({
|
|||
reference_price_id: priceId,
|
||||
asset_in_decimals: assetInDecimals == null ? null : String(assetInDecimals),
|
||||
asset_out_decimals: assetOutDecimals == null ? null : String(assetOutDecimals),
|
||||
source_asset_id: assetInId,
|
||||
source_symbol: assetInSymbol,
|
||||
source_decimals: assetInDecimals == null ? null : String(assetInDecimals),
|
||||
destination_asset_id: assetOutId,
|
||||
destination_symbol: assetOutSymbol,
|
||||
destination_decimals: assetOutDecimals == null ? null : String(assetOutDecimals),
|
||||
notional: formatNumber(quoteNotional, 6),
|
||||
notional_asset_id: notionalAssetId,
|
||||
notional_symbol: notionalSymbol,
|
||||
eure_notional: formatNumber(quoteNotional, 6),
|
||||
eure_notional: legacyEureNotional ? formatNumber(quoteNotional, 6) : null,
|
||||
proposed_amount_in: proposedAmountIn,
|
||||
proposed_amount_out: proposedAmountOut,
|
||||
};
|
||||
|
|
@ -339,7 +386,7 @@ function finalizeQuote({
|
|||
return { ok: false, reason: 'invalid_pricing', details: reasonBase };
|
||||
}
|
||||
|
||||
if (quoteNotional > maxNotionalEure) {
|
||||
if (quoteNotional > maxNotional) {
|
||||
return { ok: false, reason: 'max_notional_exceeded', details: reasonBase };
|
||||
}
|
||||
|
||||
|
|
@ -381,11 +428,31 @@ function withReason(decision, reason) {
|
|||
};
|
||||
}
|
||||
|
||||
function buildExpectedMakerInventoryDelta({
|
||||
demand,
|
||||
quoteOutput = {},
|
||||
proposedAmountIn = null,
|
||||
proposedAmountOut = null,
|
||||
} = {}) {
|
||||
if (!demand?.asset_in || !demand?.asset_out || !demand?.request_kind) return null;
|
||||
const receiveAmount = demand.request_kind === 'exact_in'
|
||||
? demand.amount_in
|
||||
: quoteOutput?.amount_in || proposedAmountIn;
|
||||
const sendAmount = demand.request_kind === 'exact_in'
|
||||
? quoteOutput?.amount_out || proposedAmountOut
|
||||
: demand.amount_out;
|
||||
if (receiveAmount == null || sendAmount == null) return null;
|
||||
return {
|
||||
[demand.asset_in]: bigintAmount(receiveAmount).toString(),
|
||||
[demand.asset_out]: (-bigintAmount(sendAmount)).toString(),
|
||||
};
|
||||
}
|
||||
|
||||
function resolvePairRuntime({
|
||||
payload,
|
||||
config,
|
||||
thresholdPct,
|
||||
maxNotionalEure,
|
||||
maxNotional,
|
||||
}) {
|
||||
const key = pairKey(payload.asset_in, payload.asset_out);
|
||||
const requiresDb = config.requireDbTradingConfig === true || config.tradingConfigLoaded === true;
|
||||
|
|
@ -410,7 +477,7 @@ function resolvePairRuntime({
|
|||
assetIn: config.assetRegistry?.get(payload.asset_in) || null,
|
||||
assetOut: config.assetRegistry?.get(payload.asset_out) || null,
|
||||
thresholdPct,
|
||||
maxNotionalEure,
|
||||
maxNotional,
|
||||
priceMaxAgeMs: config.strategyPriceMaxAgeMs,
|
||||
inventoryMaxAgeMs: config.strategyInventoryMaxAgeMs,
|
||||
pair: null,
|
||||
|
|
@ -431,7 +498,7 @@ function resolvePairRuntime({
|
|||
assetIn: null,
|
||||
assetOut: null,
|
||||
thresholdPct,
|
||||
maxNotionalEure,
|
||||
maxNotional,
|
||||
priceMaxAgeMs: config.strategyPriceMaxAgeMs,
|
||||
inventoryMaxAgeMs: config.strategyInventoryMaxAgeMs,
|
||||
};
|
||||
|
|
@ -450,7 +517,7 @@ function resolvePairRuntime({
|
|||
assetIn: pair?.assetIn || config.assetRegistry?.get(payload.asset_in) || null,
|
||||
assetOut: pair?.assetOut || config.assetRegistry?.get(payload.asset_out) || null,
|
||||
thresholdPct,
|
||||
maxNotionalEure,
|
||||
maxNotional,
|
||||
priceMaxAgeMs: config.strategyPriceMaxAgeMs,
|
||||
inventoryMaxAgeMs: config.strategyInventoryMaxAgeMs,
|
||||
};
|
||||
|
|
@ -475,7 +542,7 @@ function resolvePairRuntime({
|
|||
assetIn: pair.assetIn,
|
||||
assetOut: pair.assetOut,
|
||||
thresholdPct: Number(pair.strategyConfig.edgeBps) / 100,
|
||||
maxNotionalEure: Number(pair.strategyConfig.maxNotional),
|
||||
maxNotional: Number(pair.strategyConfig.maxNotional),
|
||||
priceMaxAgeMs: Number(pair.strategyConfig.priceMaxAgeMs),
|
||||
inventoryMaxAgeMs: Number(pair.strategyConfig.inventoryMaxAgeMs),
|
||||
};
|
||||
|
|
@ -496,43 +563,23 @@ function blockedPairRuntime(pair, config, reason) {
|
|||
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,
|
||||
maxNotional: pair?.strategyConfig
|
||||
? Number(pair.strategyConfig.maxNotional)
|
||||
: (config.strategyMaxNotional ?? 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 (!['btc_eur_reference', 'btc_usdc_reference'].includes(priceRoute.source)) return 'unsupported';
|
||||
if (payload.asset_in === priceRoute.baseAssetId && payload.asset_out === priceRoute.quoteAssetId) {
|
||||
return priceRoute.source === 'btc_usdc_reference' ? 'btc_to_usdc' : 'btc_to_eure';
|
||||
}
|
||||
if (payload.asset_in === priceRoute.quoteAssetId && payload.asset_out === priceRoute.baseAssetId) {
|
||||
return priceRoute.source === 'btc_usdc_reference' ? 'usdc_to_btc' : 'eure_to_btc';
|
||||
}
|
||||
return 'unsupported';
|
||||
return classifyRouteDirection({
|
||||
sourceAssetId: payload.asset_in,
|
||||
destinationAssetId: payload.asset_out,
|
||||
priceRoute,
|
||||
});
|
||||
}
|
||||
|
||||
function resolveReferenceRates({ direction, price }) {
|
||||
const rateFieldsByDirection = {
|
||||
btc_to_eure: ['eure_per_btc', 'btc_per_eure', true],
|
||||
eure_to_btc: ['eure_per_btc', 'btc_per_eure', false],
|
||||
btc_to_usdc: ['usdc_per_btc', 'btc_per_usdc', true],
|
||||
usdc_to_btc: ['usdc_per_btc', 'btc_per_usdc', false],
|
||||
};
|
||||
const fields = rateFieldsByDirection[direction];
|
||||
if (!fields) return { ok: false, reason: 'unsupported_pair' };
|
||||
const [quotePerBaseField, basePerQuoteField, baseToQuote] = fields;
|
||||
const quotePerBase = Number(price[quotePerBaseField]);
|
||||
const basePerQuote = Number(price[basePerQuoteField]);
|
||||
if (!(quotePerBase > 0) || !(basePerQuote > 0)) {
|
||||
return { ok: false, reason: 'reference_price_missing' };
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
quotePerBase,
|
||||
basePerQuote,
|
||||
baseToQuote,
|
||||
};
|
||||
function isLegacyEureNotional({ pairRuntime, config }) {
|
||||
if (!pairRuntime?.priceRoute) return true;
|
||||
return pairRuntime.priceRoute.quoteAssetId === config.tradingEure?.assetId;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1148,9 +1148,11 @@ function buildTradingConfigSnapshot({
|
|||
const defaultTakerPair = pairByKey.get('nep141:gnosis-0x420ca0f9b9b604ce0fd9c18ef134c705e5fa3430.omft.near->nep141:nbtc.bridge.near')
|
||||
|| pairs.find((pair) => pair.takerEnabled && pair.canTrade)
|
||||
|| null;
|
||||
const activeAssetIds = preferredActivePair?.assetIn && preferredActivePair?.assetOut
|
||||
? [preferredActivePair.assetIn.assetId, preferredActivePair.assetOut.assetId]
|
||||
: [];
|
||||
const activeAssetIds = [...new Set(
|
||||
observedPairs
|
||||
.flatMap((pair) => [pair.assetIn?.assetId, pair.assetOut?.assetId])
|
||||
.filter(Boolean),
|
||||
)];
|
||||
const blockReason = observedPairs.length === 0
|
||||
? 'no_enabled_pairs'
|
||||
: trackedAssets.length === 0
|
||||
|
|
@ -1231,6 +1233,7 @@ export function summarizeTradingConfigSnapshot(snapshot) {
|
|||
tracked_asset_count: snapshot.trackedAssets?.length || 0,
|
||||
enabled_pair_count: snapshot.observedPairs?.length || 0,
|
||||
active_pair: snapshot.activePair || null,
|
||||
active_pairs: (snapshot.observedPairs || []).map((pair) => pair.key || pair.pairId),
|
||||
pairs: (snapshot.pairs || []).map((pair) => ({
|
||||
pair_id: pair.pairId,
|
||||
pair: pair.key,
|
||||
|
|
@ -2233,8 +2236,6 @@ export async function refreshQuoteOutcomes(pool, {
|
|||
submissionLimit = 1000,
|
||||
inventoryLimit = 5000,
|
||||
} = {}) {
|
||||
if (!btcAsset?.assetId || !eureAsset?.assetId) return [];
|
||||
|
||||
const safeSubmissionLimit = Math.max(1, Number(submissionLimit) || 1000);
|
||||
const safeInventoryLimit = Math.max(1, Number(inventoryLimit) || 5000);
|
||||
const submissionsResult = await pool.query(
|
||||
|
|
@ -2860,6 +2861,21 @@ export async function loadLatestMarketPrice(pool) {
|
|||
};
|
||||
}
|
||||
|
||||
export async function loadLatestMarketPriceForRoute(pool, { priceRouteId } = {}) {
|
||||
if (!priceRouteId) return loadLatestMarketPrice(pool);
|
||||
const latest = await loadLatestEventPayload(
|
||||
pool,
|
||||
'market_price_events',
|
||||
"WHERE payload->>'price_route_id' = $1 ORDER BY COALESCE(observed_at, ingested_at) DESC LIMIT 1",
|
||||
[priceRouteId],
|
||||
);
|
||||
if (!latest) return null;
|
||||
return {
|
||||
ingested_at: latest.ingested_at,
|
||||
payload: latest.payload,
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadRecentQuotes(pool, { limit = 10 } = {}) {
|
||||
const result = await pool.query(
|
||||
`
|
||||
|
|
@ -3281,6 +3297,9 @@ function normalizeQuoteOutcomeRow(row) {
|
|||
direction: payload.direction || null,
|
||||
request_kind: payload.request_kind || null,
|
||||
gross_edge_pct: payload.gross_edge_pct || null,
|
||||
notional: payload.notional || null,
|
||||
notional_asset_id: payload.notional_asset_id || null,
|
||||
notional_symbol: payload.notional_symbol || null,
|
||||
eure_notional: payload.eure_notional || null,
|
||||
execution_result_status: row.execution_result_status || payload.execution_result_status || null,
|
||||
execution_result_code: row.execution_result_code || payload.execution_result_code || null,
|
||||
|
|
|
|||
|
|
@ -5,6 +5,10 @@ function statusSubtitle(label, status, websocketState) {
|
|||
switch (label) {
|
||||
case 'Pair':
|
||||
return `WebSocket ${websocketState}`;
|
||||
case 'Pairs':
|
||||
return status.active_pair_count ? `${status.active_pair_count} enabled` : `WebSocket ${websocketState}`;
|
||||
case 'Reference Route':
|
||||
return status.latest_reference_route_label || 'Reference route';
|
||||
case 'Market Freshness':
|
||||
return formatTimestamp(status.market_observed_at);
|
||||
case 'Inventory Freshness':
|
||||
|
|
@ -29,10 +33,10 @@ export default function StatusBar({ status, websocketState }) {
|
|||
]]
|
||||
: [];
|
||||
const tiles = [
|
||||
['Pair', truncateMiddle(status.active_pair, 40), status.active_pair],
|
||||
['Pairs', truncateMiddle((status.active_pairs || [status.active_pair]).filter(Boolean).join(', '), 40), (status.active_pairs || [status.active_pair]).filter(Boolean).join(', ')],
|
||||
...nearIntentsTile,
|
||||
['Portfolio', formatEur(status.current_total_portfolio_value_eure)],
|
||||
['Reference BTC/EUR', formatEur(status.latest_reference_price_eure_per_btc)],
|
||||
['Reference Route', status.latest_reference_route_value || formatEur(status.latest_reference_price_eure_per_btc), status.latest_reference_route_label],
|
||||
['Market Freshness', formatAge(status.market_freshness_ms)],
|
||||
['Inventory Freshness', formatAge(status.inventory_freshness_ms)],
|
||||
['Strategy Armed', formatBoolean(status.strategy_armed)],
|
||||
|
|
@ -50,7 +54,7 @@ export default function StatusBar({ status, websocketState }) {
|
|||
className={[
|
||||
'status-value',
|
||||
signedClass(value),
|
||||
label === 'Pair' ? 'truncate-line mono' : '',
|
||||
label === 'Pairs' ? 'truncate-line mono' : '',
|
||||
].filter(Boolean).join(' ')}
|
||||
title={fullValue || 'Unavailable'}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -44,7 +44,12 @@ function BalancesTable({ items }) {
|
|||
<td className="mono">{item.spendable}</td>
|
||||
<td className="mono">{item.pending_inbound}</td>
|
||||
<td className="mono">{item.pending_outbound}</td>
|
||||
<td className="mono">{formatEur(item.eur_value_eure)}</td>
|
||||
<td className="mono">
|
||||
{item.valuation_status === 'unvalued'
|
||||
? item.valuation_reason || 'valuation_route_missing'
|
||||
: formatEur(item.eur_value_eure)}
|
||||
{item.eur_value_source ? <div className="status-subtle">{item.eur_value_source}</div> : null}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
|
@ -279,26 +284,31 @@ function IdentifierLine({ label, value }) {
|
|||
|
||||
function IntentRequestForm({ summary, onControl }) {
|
||||
const defaults = summary?.defaults || {};
|
||||
const sourceSymbol = defaults.source_symbol || 'EURe';
|
||||
const destinationSymbol = defaults.destination_symbol || 'BTC';
|
||||
const hasAmountCap = defaults.max_amount_eure != null && defaults.max_amount_eure !== '';
|
||||
const pairs = summary?.pairs || [];
|
||||
const hasAmountCap = defaults.max_source_amount != null && defaults.max_source_amount !== '';
|
||||
const hasSlippageCap = defaults.max_slippage_bps != null && defaults.max_slippage_bps !== '';
|
||||
const [form, setForm] = useState({
|
||||
amount_eure: defaults.amount_eure || '5',
|
||||
pair_id: defaults.pair_id || '',
|
||||
source_amount: defaults.source_amount || '5',
|
||||
slippage_bps: String(defaults.slippage_bps ?? 200),
|
||||
});
|
||||
const selectedPair = pairs.find((pair) => pair.pair_id === form.pair_id) || null;
|
||||
const sourceSymbol = selectedPair?.source_symbol || defaults.source_symbol || 'Source';
|
||||
const destinationSymbol = selectedPair?.destination_symbol || defaults.destination_symbol || 'Destination';
|
||||
|
||||
useEffect(() => {
|
||||
setForm({
|
||||
amount_eure: defaults.amount_eure || '5',
|
||||
pair_id: defaults.pair_id || '',
|
||||
source_amount: defaults.source_amount || '5',
|
||||
slippage_bps: String(defaults.slippage_bps ?? 200),
|
||||
});
|
||||
}, [defaults.amount_eure, defaults.slippage_bps]);
|
||||
}, [defaults.pair_id, defaults.source_amount, defaults.slippage_bps]);
|
||||
|
||||
async function handlePreflight(event) {
|
||||
event.preventDefault();
|
||||
await onControl('trade-executor', 'intent-request-preflight', {
|
||||
amount_eure: form.amount_eure,
|
||||
pair_id: form.pair_id || undefined,
|
||||
source_amount: form.source_amount,
|
||||
slippage_bps: Number(form.slippage_bps),
|
||||
});
|
||||
}
|
||||
|
|
@ -306,20 +316,37 @@ function IntentRequestForm({ summary, onControl }) {
|
|||
return (
|
||||
<form onSubmit={handlePreflight}>
|
||||
<div className="form-grid">
|
||||
{pairs.length ? (
|
||||
<div className="field">
|
||||
<label htmlFor="intent-request-pair">Pair</label>
|
||||
<select
|
||||
id="intent-request-pair"
|
||||
name="pair_id"
|
||||
onChange={(event) => setForm((current) => ({ ...current, pair_id: event.target.value }))}
|
||||
value={form.pair_id}
|
||||
>
|
||||
{pairs.map((pair) => (
|
||||
<option disabled={!pair.can_trade} key={pair.pair_id} value={pair.pair_id}>
|
||||
{pair.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="field">
|
||||
<label htmlFor="intent-request-amount">{`Spend ${sourceSymbol}`}</label>
|
||||
<input
|
||||
id="intent-request-amount"
|
||||
min="0.01"
|
||||
name="amount_eure"
|
||||
onChange={(event) => setForm((current) => ({ ...current, amount_eure: event.target.value }))}
|
||||
name="source_amount"
|
||||
onChange={(event) => setForm((current) => ({ ...current, source_amount: event.target.value }))}
|
||||
step="0.01"
|
||||
type="number"
|
||||
value={form.amount_eure}
|
||||
{...(hasAmountCap ? { max: defaults.max_amount_eure } : {})}
|
||||
value={form.source_amount}
|
||||
{...(hasAmountCap ? { max: defaults.max_source_amount } : {})}
|
||||
/>
|
||||
<div className="status-subtle">
|
||||
{hasAmountCap ? `Max ${defaults.max_amount_eure} ${sourceSymbol}` : 'No request amount cap'}
|
||||
{hasAmountCap ? `Max ${defaults.max_source_amount} ${sourceSymbol}` : 'No request amount cap'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="field">
|
||||
|
|
@ -412,7 +439,7 @@ function IntentRequestLifecycle({ item }) {
|
|||
|
||||
function IntentRequestsTable({ items, executorArmed, onControl }) {
|
||||
const [expanded, setExpanded] = useState(() => new Set());
|
||||
if (!items?.length) return <EmptyState>No repo-created EURe-to-BTC requests are stored yet.</EmptyState>;
|
||||
if (!items?.length) return <EmptyState>No repo-created pair requests are stored yet.</EmptyState>;
|
||||
|
||||
function toggle(rowKey) {
|
||||
setExpanded((current) => {
|
||||
|
|
@ -679,7 +706,7 @@ export default function FundsPage({
|
|||
<div className="panel-head">
|
||||
<div>
|
||||
<div className="eyebrow">Own requests</div>
|
||||
<h3>EURe to BTC request creation</h3>
|
||||
<h3>Pair request creation</h3>
|
||||
<div className="panel-subtitle">Create a solver quote request first, then submit only a drafted request. Completed requires inventory movement, not relay acceptance.</div>
|
||||
</div>
|
||||
<div className="pills">
|
||||
|
|
|
|||
|
|
@ -70,8 +70,16 @@ function responseLabel(item) {
|
|||
}
|
||||
|
||||
function grossEdgeEstimate(item) {
|
||||
if (!item.gross_edge_value_eure) return 'Unavailable';
|
||||
return formatEur(item.gross_edge_value_eure);
|
||||
if (!item.gross_edge_value) return 'Unavailable';
|
||||
const symbol = item.notional_symbol || (item.eure_notional ? 'EURe' : null);
|
||||
if (symbol === 'EURe') return formatEur(item.gross_edge_value);
|
||||
return symbol ? `${item.gross_edge_value} ${symbol}` : item.gross_edge_value;
|
||||
}
|
||||
|
||||
function notionalLabel(item) {
|
||||
if (item.notional_display) return item.notional_display;
|
||||
if (item.notional != null) return `${item.notional}${item.notional_symbol ? ` ${item.notional_symbol}` : ''}`;
|
||||
return item.eure_notional ? formatEur(item.eure_notional) : 'Notional unavailable';
|
||||
}
|
||||
|
||||
function plainCodeLabel(value, fallback = 'Unavailable') {
|
||||
|
|
@ -109,7 +117,7 @@ function LifecycleDetails({ item }) {
|
|||
<StageCard at={item.decision_at} status={strategyDecisionStatus(item.decision)} title="2. Strategy decided">
|
||||
<div>{plainCodeLabel(item.decision?.decision_reason || item.reason_code, 'No decision reason recorded')}</div>
|
||||
<div className="status-subtle">{item.gross_edge_pct ? `Edge ${item.gross_edge_pct}%` : 'Edge unavailable'}</div>
|
||||
<div className="status-subtle">{item.eure_notional ? `Notional ${formatEur(item.eure_notional)}` : 'Notional unavailable'}</div>
|
||||
<div className="status-subtle">{notionalLabel(item)}</div>
|
||||
</StageCard>
|
||||
|
||||
<StageCard at={item.command_at} status={item.command_id ? 'Command recorded' : 'No command'} title="3. Executor command">
|
||||
|
|
@ -195,7 +203,7 @@ function QuoteLifecycleTable({ items }) {
|
|||
</td>
|
||||
<td>
|
||||
<div className={Number(item.gross_edge_pct) > 0 ? 'value-positive' : Number(item.gross_edge_pct) < 0 ? 'value-negative' : ''}>{item.gross_edge_pct ? `${item.gross_edge_pct}%` : 'Unavailable'}</div>
|
||||
<div className="status-subtle">{item.eure_notional ? formatEur(item.eure_notional) : 'Notional unavailable'}</div>
|
||||
<div className="status-subtle">{notionalLabel(item)}</div>
|
||||
</td>
|
||||
<td>
|
||||
<button className="button secondary" onClick={() => toggle(rowKey)} type="button">
|
||||
|
|
@ -261,7 +269,7 @@ function SuccessfulTradesTable({ items }) {
|
|||
<td><IdentifierRow label="Quote" value={item.quote_id} /></td>
|
||||
<td>
|
||||
<div>{item.gross_edge_pct ? `${item.gross_edge_pct}%` : 'Unavailable'}</div>
|
||||
<div className="status-subtle">{item.eure_notional ? formatEur(item.eure_notional) : 'Notional unavailable'}</div>
|
||||
<div className="status-subtle">{notionalLabel(item)}</div>
|
||||
</td>
|
||||
<td>
|
||||
<div>{grossEdgeEstimate(item)}</div>
|
||||
|
|
|
|||
|
|
@ -14,6 +14,33 @@ function createEngine() {
|
|||
});
|
||||
}
|
||||
|
||||
function createMultiRouteEngine() {
|
||||
return createAlertEngine({
|
||||
activePair: 'nep141:btc.omft.near->nep141:eure.omft.near',
|
||||
activePairs: [
|
||||
'nep141:btc.omft.near->nep141:eure.omft.near',
|
||||
'nep141:btc.omft.near->nep141:usdc.omft.near',
|
||||
],
|
||||
priceRoutes: [
|
||||
{
|
||||
pair: 'nep141:btc.omft.near->nep141:eure.omft.near',
|
||||
price_route_id: 'btc-eur-route',
|
||||
reference_pair: 'BTC/EUR',
|
||||
},
|
||||
{
|
||||
pair: 'nep141:btc.omft.near->nep141:usdc.omft.near',
|
||||
price_route_id: 'btc-usdc-route',
|
||||
reference_pair: 'BTC/USDC',
|
||||
},
|
||||
],
|
||||
priceStaleMs: 30_000,
|
||||
inventoryStaleMs: 30_000,
|
||||
fundingCreditPendingMs: 300_000,
|
||||
fundingStuckMs: 3_600_000,
|
||||
evaluationIntervalMs: 5_000,
|
||||
});
|
||||
}
|
||||
|
||||
test('alert engine raises and clears stale state transitions', () => {
|
||||
const engine = createEngine();
|
||||
|
||||
|
|
@ -127,3 +154,36 @@ test('runtime alerts raise and clear independently from event-derived alerts', (
|
|||
assert.equal(transitions[0].alert_code, 'near_intents_quotes_stale');
|
||||
assert.equal(transitions[0].status, 'cleared');
|
||||
});
|
||||
|
||||
test('reference price stale alert is scoped to the affected route and pair', () => {
|
||||
const engine = createMultiRouteEngine();
|
||||
|
||||
const transitions = engine.applyEvent('ref.market_price', {
|
||||
price_id: 'price-eur-1',
|
||||
pair: 'nep141:btc.omft.near->nep141:eure.omft.near',
|
||||
price_route_id: 'btc-eur-route',
|
||||
reference_pair: 'BTC/EUR',
|
||||
quote_per_base: '50000',
|
||||
observed_at: '2026-04-03T08:00:00.000Z',
|
||||
}, '2026-04-03T08:00:00.000Z');
|
||||
engine.applyEvent('state.intent_inventory', {
|
||||
inventory_id: 'inventory-1',
|
||||
spendable: {},
|
||||
synced_at: '2026-04-03T08:00:00.000Z',
|
||||
}, '2026-04-03T08:00:00.000Z');
|
||||
|
||||
const stale = transitions.find((entry) => entry.alert_code === 'reference_price_stale');
|
||||
|
||||
assert.equal(stale.status, 'raised');
|
||||
assert.equal(stale.pair, 'nep141:btc.omft.near->nep141:usdc.omft.near');
|
||||
assert.equal(stale.details.price_route_id, 'btc-usdc-route');
|
||||
assert.equal(stale.details.reference_pair, 'BTC/USDC');
|
||||
assert.deepEqual(stale.details.active_pairs, [
|
||||
'nep141:btc.omft.near->nep141:eure.omft.near',
|
||||
'nep141:btc.omft.near->nep141:usdc.omft.near',
|
||||
]);
|
||||
assert.deepEqual(engine.getState('2026-04-03T08:00:10.000Z').active_pairs, [
|
||||
'nep141:btc.omft.near->nep141:eure.omft.near',
|
||||
'nep141:btc.omft.near->nep141:usdc.omft.near',
|
||||
]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -13,6 +13,11 @@ const EURE = {
|
|||
symbol: 'EURe',
|
||||
decimals: 18,
|
||||
};
|
||||
const USDC = {
|
||||
assetId: 'nep141:usdc.omft.near',
|
||||
symbol: 'USDC',
|
||||
decimals: 6,
|
||||
};
|
||||
|
||||
function preflight(overrides = {}) {
|
||||
return {
|
||||
|
|
@ -179,3 +184,49 @@ test('blocked preflight remains blocked and is distinct from request rejection o
|
|||
assert.notEqual(record.outcome_status, 'completed');
|
||||
assert.notEqual(record.outcome_status, 'rejected');
|
||||
});
|
||||
|
||||
test('completed BTC/USDC request uses preflight source and destination assets', () => {
|
||||
const [record] = outcomes({
|
||||
preflights: [preflight({
|
||||
source_asset_id: USDC.assetId,
|
||||
source_symbol: 'USDC',
|
||||
destination_asset_id: BTC.assetId,
|
||||
destination_symbol: 'BTC',
|
||||
source_amount_units: '10000000',
|
||||
destination_amount_units: '20000',
|
||||
quoted_destination_amount_units: '20000',
|
||||
notional: '10',
|
||||
notional_asset_id: USDC.assetId,
|
||||
notional_symbol: 'USDC',
|
||||
})],
|
||||
submissions: [submission({ destination_amount_units: '20000' })],
|
||||
inventorySnapshots: [
|
||||
{
|
||||
inventory_id: 'inventory-before-usdc',
|
||||
observed_at: '2026-04-12T10:00:00.000Z',
|
||||
spendable: {
|
||||
[USDC.assetId]: '10000000',
|
||||
[BTC.assetId]: '0',
|
||||
[EURE.assetId]: '1',
|
||||
},
|
||||
},
|
||||
{
|
||||
inventory_id: 'inventory-after-usdc',
|
||||
observed_at: '2026-04-12T10:00:15.000Z',
|
||||
spendable: {
|
||||
[USDC.assetId]: '0',
|
||||
[BTC.assetId]: '20000',
|
||||
[EURE.assetId]: '1',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
assert.equal(record.outcome_status, 'completed');
|
||||
assert.equal(record.payload.notional_symbol, 'USDC');
|
||||
assert.deepEqual(record.attributed_inventory_delta.delta_units, {
|
||||
[BTC.assetId]: '20000',
|
||||
[EURE.assetId]: '0',
|
||||
[USDC.assetId]: '-10000000',
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -23,6 +23,11 @@ const EURE = {
|
|||
symbol: 'EURe',
|
||||
decimals: 18,
|
||||
};
|
||||
const USDC = {
|
||||
assetId: 'nep141:usdc.omft.near',
|
||||
symbol: 'USDC',
|
||||
decimals: 6,
|
||||
};
|
||||
|
||||
function buildConfig() {
|
||||
return {
|
||||
|
|
@ -155,6 +160,43 @@ function buildController({
|
|||
};
|
||||
}
|
||||
|
||||
function buildPair({
|
||||
sourceAsset,
|
||||
destinationAsset,
|
||||
source,
|
||||
routeId,
|
||||
canTrade = true,
|
||||
blockReason = null,
|
||||
}) {
|
||||
const pairId = `${sourceAsset.assetId}->${destinationAsset.assetId}`;
|
||||
return {
|
||||
key: pairId,
|
||||
pairId,
|
||||
takerEnabled: true,
|
||||
canTrade,
|
||||
blockReason,
|
||||
assetIn: sourceAsset,
|
||||
assetOut: destinationAsset,
|
||||
strategyConfig: {
|
||||
configId: `${pairId}:v1`,
|
||||
version: 1,
|
||||
requestDefaultNotional: '5',
|
||||
requestMaxNotional: null,
|
||||
slippageBps: 200,
|
||||
requestMaxSlippageBps: null,
|
||||
minDeadlineMs: 60_000,
|
||||
priceMaxAgeMs: 30_000,
|
||||
inventoryMaxAgeMs: 30_000,
|
||||
},
|
||||
priceRoute: source ? {
|
||||
routeId,
|
||||
source,
|
||||
baseAssetId: BTC.assetId,
|
||||
quoteAssetId: sourceAsset.assetId === BTC.assetId ? destinationAsset.assetId : sourceAsset.assetId,
|
||||
} : null,
|
||||
};
|
||||
}
|
||||
|
||||
test('EURe decimal parsing, BTC expected receive, and slippage math are exact enough for request limits', () => {
|
||||
const sourceUnits = parseDecimalToUnits('5', EURE.decimals, { field: 'amount_eure' });
|
||||
const expectedBtc = computeBtcReceiveUnitsFromEure({
|
||||
|
|
@ -259,6 +301,157 @@ test('DB null request limits allow operator-chosen amount and slippage', async (
|
|||
assert.equal(relay.quoteCalls, 1);
|
||||
});
|
||||
|
||||
test('nBTC/USDC preflight accepts pair_id and source_amount without EURe price fields', async () => {
|
||||
const nowIso = '2026-04-12T10:00:00.000Z';
|
||||
const store = buildStore({ nowIso });
|
||||
store.loadLatestInventorySnapshot = async () => ({
|
||||
ingested_at: nowIso,
|
||||
payload: {
|
||||
synced_at: nowIso,
|
||||
spendable: {
|
||||
[USDC.assetId]: '10000000',
|
||||
[BTC.assetId]: '0',
|
||||
},
|
||||
pending_inbound: {},
|
||||
},
|
||||
});
|
||||
store.loadLatestMarketPrice = async ({ priceRouteId } = {}) => ({
|
||||
ingested_at: nowIso,
|
||||
payload: {
|
||||
observed_at: nowIso,
|
||||
price_id: 'price-usdc-1',
|
||||
price_route_id: priceRouteId,
|
||||
usdc_per_btc: '50000',
|
||||
},
|
||||
});
|
||||
const relay = buildRelay();
|
||||
relay.quote = async function quote(request) {
|
||||
this.quoteCalls += 1;
|
||||
assert.equal(request.defuse_asset_identifier_in, USDC.assetId);
|
||||
assert.equal(request.defuse_asset_identifier_out, BTC.assetId);
|
||||
assert.equal(request.exact_amount_in, '10000000');
|
||||
return [{
|
||||
quote_hash: 'usdc-quote-hash',
|
||||
amount_out: '20000',
|
||||
expiration_time: '2026-04-12T10:01:00.000Z',
|
||||
}];
|
||||
};
|
||||
const pair = buildPair({
|
||||
sourceAsset: USDC,
|
||||
destinationAsset: BTC,
|
||||
source: 'btc_usdc_reference',
|
||||
routeId: 'btc-usdc-route',
|
||||
});
|
||||
const { controller } = buildController({
|
||||
store,
|
||||
relay,
|
||||
getTradingConfig: async () => ({
|
||||
ok: true,
|
||||
tradingEure: EURE,
|
||||
tradingBtc: BTC,
|
||||
pairByKey: new Map([[pair.key, pair]]),
|
||||
pairById: new Map([[pair.pairId, pair]]),
|
||||
defaultTakerPair: pair,
|
||||
}),
|
||||
});
|
||||
|
||||
const preflight = await controller.preflight({
|
||||
pair_id: pair.pairId,
|
||||
source_amount: '10',
|
||||
slippage_bps: 200,
|
||||
});
|
||||
|
||||
assert.equal(preflight.state, 'draft');
|
||||
assert.equal(preflight.reason_code, 'quote_available');
|
||||
assert.equal(preflight.amount_eure, null);
|
||||
assert.equal(preflight.source_asset_id, USDC.assetId);
|
||||
assert.equal(preflight.destination_asset_id, BTC.assetId);
|
||||
assert.equal(preflight.expected_destination_amount_units, '20000');
|
||||
assert.equal(preflight.min_destination_amount_units, '19600');
|
||||
assert.equal(preflight.notional, '10');
|
||||
assert.equal(preflight.notional_symbol, 'USDC');
|
||||
assert.equal(preflight.reference_price_id, 'price-usdc-1');
|
||||
assert.equal(relay.quoteCalls, 1);
|
||||
});
|
||||
|
||||
test('nBTC/USDC preflight blocks missing route before solver quote', async () => {
|
||||
const relay = buildRelay();
|
||||
const pair = buildPair({
|
||||
sourceAsset: USDC,
|
||||
destinationAsset: BTC,
|
||||
source: null,
|
||||
routeId: null,
|
||||
canTrade: false,
|
||||
blockReason: 'price_route_missing',
|
||||
});
|
||||
const { controller } = buildController({
|
||||
relay,
|
||||
getTradingConfig: async () => ({
|
||||
ok: true,
|
||||
tradingEure: EURE,
|
||||
tradingBtc: BTC,
|
||||
pairByKey: new Map([[pair.key, pair]]),
|
||||
pairById: new Map([[pair.pairId, pair]]),
|
||||
defaultTakerPair: pair,
|
||||
}),
|
||||
});
|
||||
|
||||
const preflight = await controller.preflight({ pair_id: pair.pairId, source_amount: '10' });
|
||||
|
||||
assert.equal(preflight.state, 'blocked');
|
||||
assert.equal(preflight.reason_code, 'price_route_missing');
|
||||
assert.equal(relay.quoteCalls, 0);
|
||||
});
|
||||
|
||||
test('nBTC/USDC preflight blocks insufficient source inventory with source-specific reason', async () => {
|
||||
const nowIso = '2026-04-12T10:00:00.000Z';
|
||||
const store = buildStore({ nowIso });
|
||||
store.loadLatestInventorySnapshot = async () => ({
|
||||
ingested_at: nowIso,
|
||||
payload: {
|
||||
synced_at: nowIso,
|
||||
spendable: {
|
||||
[USDC.assetId]: '1',
|
||||
},
|
||||
pending_inbound: {},
|
||||
},
|
||||
});
|
||||
store.loadLatestMarketPrice = async ({ priceRouteId } = {}) => ({
|
||||
ingested_at: nowIso,
|
||||
payload: {
|
||||
observed_at: nowIso,
|
||||
price_id: 'price-usdc-2',
|
||||
price_route_id: priceRouteId,
|
||||
usdc_per_btc: '50000',
|
||||
},
|
||||
});
|
||||
const relay = buildRelay();
|
||||
const pair = buildPair({
|
||||
sourceAsset: USDC,
|
||||
destinationAsset: BTC,
|
||||
source: 'btc_usdc_reference',
|
||||
routeId: 'btc-usdc-route',
|
||||
});
|
||||
const { controller } = buildController({
|
||||
store,
|
||||
relay,
|
||||
getTradingConfig: async () => ({
|
||||
ok: true,
|
||||
tradingEure: EURE,
|
||||
tradingBtc: BTC,
|
||||
pairByKey: new Map([[pair.key, pair]]),
|
||||
pairById: new Map([[pair.pairId, pair]]),
|
||||
defaultTakerPair: pair,
|
||||
}),
|
||||
});
|
||||
|
||||
const preflight = await controller.preflight({ pair_id: pair.pairId, source_amount: '10' });
|
||||
|
||||
assert.equal(preflight.state, 'blocked');
|
||||
assert.equal(preflight.reason_code, 'insufficient_spendable_usdc');
|
||||
assert.equal(relay.quoteCalls, 0);
|
||||
});
|
||||
|
||||
test('insufficient spendable EURe blocks before solver quote or signing', async () => {
|
||||
const store = buildStore({ inventoryUnits: '0' });
|
||||
const relay = buildRelay();
|
||||
|
|
|
|||
|
|
@ -32,6 +32,25 @@ test('funds page no longer renders duplicate quote and submission tables', () =>
|
|||
assert.doesNotMatch(fundsSource, /Durable ledger/);
|
||||
});
|
||||
|
||||
test('request UI uses pair-native source amount fields and copy', () => {
|
||||
assert.match(fundsSource, /name="source_amount"/);
|
||||
assert.match(fundsSource, /pair_id: form\.pair_id/);
|
||||
assert.match(fundsSource, /Pair request creation/);
|
||||
assert.doesNotMatch(fundsSource, /EURe to BTC request creation/);
|
||||
assert.doesNotMatch(fundsSource, /EURe-to-BTC/);
|
||||
});
|
||||
|
||||
test('funds balance rows expose unvalued valuation reasons', () => {
|
||||
assert.match(fundsSource, /valuation_status === 'unvalued'/);
|
||||
assert.match(fundsSource, /valuation_route_missing/);
|
||||
});
|
||||
|
||||
test('status bar labels reference routes instead of a single BTC\\/EUR tile', () => {
|
||||
assert.match(statusBarSource, /Reference Route/);
|
||||
assert.match(statusBarSource, /active_pairs/);
|
||||
assert.doesNotMatch(statusBarSource, /Reference BTC\/EUR/);
|
||||
});
|
||||
|
||||
test('dashboard freshness surfaces show age and exact timestamp evidence', () => {
|
||||
assert.match(serviceCardSource, /formatAgeFromTimestamp\(service\.freshness_at, now\)/);
|
||||
assert.match(serviceCardSource, /formatTimestamp\(service\.freshness_at\)/);
|
||||
|
|
|
|||
|
|
@ -32,6 +32,12 @@ const usdcAsset = {
|
|||
decimals: 6,
|
||||
};
|
||||
|
||||
const nearAsset = {
|
||||
assetId: 'nep141:wrap.near',
|
||||
symbol: 'wNEAR',
|
||||
decimals: 24,
|
||||
};
|
||||
|
||||
test('portfolio metrics compute portfolio comparison and mark-to-market pnl from baseline funding inventory', () => {
|
||||
const metric = computePortfolioMetric({
|
||||
baseline: {
|
||||
|
|
@ -196,6 +202,51 @@ test('portfolio metrics include tracked USDC valued from live BTC/USDC reference
|
|||
]);
|
||||
});
|
||||
|
||||
test('portfolio metrics keep tracked assets visible when valuation route is missing', () => {
|
||||
const currentPrice = {
|
||||
price_id: 'price-current-unvalued',
|
||||
observed_at: '2026-05-18T15:41:08.837Z',
|
||||
eure_per_btc: '65495.20000000',
|
||||
};
|
||||
const valuationAssets = buildCashEquivalentValuationAssets({
|
||||
trackedAssets: [nbtcAsset, eureAsset, nearAsset],
|
||||
btcAssets: [nbtcAsset],
|
||||
eureAsset,
|
||||
priceEvents: [currentPrice],
|
||||
});
|
||||
|
||||
const metric = computePortfolioMetric({
|
||||
baseline: null,
|
||||
currentInventory: {
|
||||
inventory_id: 'current-unvalued',
|
||||
synced_at: '2026-05-18T15:40:38.976Z',
|
||||
spendable: {
|
||||
[nbtcAsset.assetId]: '0',
|
||||
[eureAsset.assetId]: '0',
|
||||
[nearAsset.assetId]: '1000000000000000000000000',
|
||||
},
|
||||
},
|
||||
currentPrice,
|
||||
btcAsset: nbtcAsset,
|
||||
btcAssets: [nbtcAsset],
|
||||
eureAsset,
|
||||
valuationAssets,
|
||||
});
|
||||
|
||||
assert.deepEqual(metric.current_inventory.unvalued_assets.map((asset) => ({
|
||||
asset_id: asset.asset_id,
|
||||
amount: asset.amount,
|
||||
valuation_status: asset.valuation_status,
|
||||
valuation_reason: asset.valuation_reason,
|
||||
})), [{
|
||||
asset_id: nearAsset.assetId,
|
||||
amount: '1',
|
||||
valuation_status: 'unvalued',
|
||||
valuation_reason: 'valuation_route_missing',
|
||||
}]);
|
||||
assert.equal(metric.current_portfolio_value_eure, '0');
|
||||
});
|
||||
|
||||
test('portfolio metrics treat later deposits and withdrawals as external cash flows instead of PnL', () => {
|
||||
const metric = computePortfolioMetric({
|
||||
baseline: {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ test('market reference ingest publishes BTC/USDC route-specific prices', () => {
|
|||
assert.match(marketReferenceSource, /XBTUSDC/);
|
||||
assert.match(marketReferenceSource, /btc_usdc_reference/);
|
||||
assert.match(marketReferenceSource, /usdc_per_btc/);
|
||||
assert.match(marketReferenceSource, /quote_per_base/);
|
||||
assert.match(marketReferenceSource, /base_per_quote/);
|
||||
assert.match(marketReferenceSource, /buildPriceId\(now, referencePair\.priceRoute\.routeId\)/);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,11 @@ const EURE = {
|
|||
symbol: 'EURe',
|
||||
decimals: 18,
|
||||
};
|
||||
const USDC = {
|
||||
assetId: 'nep141:usdc.omft.near',
|
||||
symbol: 'USDC',
|
||||
decimals: 6,
|
||||
};
|
||||
|
||||
function submittedResult(quoteId, observedAt = '2026-04-02T18:13:30.000Z') {
|
||||
return {
|
||||
|
|
@ -192,3 +197,96 @@ test('ambiguous inventory movement is not counted as completed settlement', () =
|
|||
['ambiguous', 'ambiguous'],
|
||||
);
|
||||
});
|
||||
|
||||
test('BTC/USDC maker command completes from pair-native expected deltas', () => {
|
||||
const quoteId = 'quote-usdc-settled';
|
||||
const [outcome] = deriveQuoteOutcomeRecords({
|
||||
submissions: [submittedResult(quoteId)],
|
||||
commands: [{
|
||||
observed_at: '2026-04-02T18:13:29.000Z',
|
||||
payload: {
|
||||
quote_id: quoteId,
|
||||
command_id: `cmd-${quoteId}`,
|
||||
decision_id: `decision-${quoteId}`,
|
||||
pair: `${BTC.assetId}->${USDC.assetId}`,
|
||||
direction: 'base_to_quote',
|
||||
request_kind: 'exact_in',
|
||||
asset_in: BTC.assetId,
|
||||
asset_out: USDC.assetId,
|
||||
amount_in: '10000',
|
||||
quote_output: { amount_out: '7960800' },
|
||||
expected_inventory_delta_units: {
|
||||
[BTC.assetId]: '10000',
|
||||
[USDC.assetId]: '-7960800',
|
||||
},
|
||||
notional: '8',
|
||||
notional_asset_id: USDC.assetId,
|
||||
notional_symbol: 'USDC',
|
||||
min_deadline_ms: 60000,
|
||||
},
|
||||
}],
|
||||
inventorySnapshots: [
|
||||
inventorySnapshot('2026-04-02T18:13:00.000Z', {
|
||||
[BTC.assetId]: '0',
|
||||
[USDC.assetId]: '10000000',
|
||||
[EURE.assetId]: '1',
|
||||
}),
|
||||
inventorySnapshot('2026-04-02T18:13:33.000Z', {
|
||||
[BTC.assetId]: '10000',
|
||||
[USDC.assetId]: '2039200',
|
||||
[EURE.assetId]: '1',
|
||||
}),
|
||||
],
|
||||
btcAsset: BTC,
|
||||
eureAsset: EURE,
|
||||
now: '2026-04-02T18:14:00.000Z',
|
||||
});
|
||||
|
||||
assert.equal(outcome.outcome_status, 'completed');
|
||||
assert.equal(outcome.payload.notional_symbol, 'USDC');
|
||||
assert.equal(outcome.attributed_inventory_delta.delta_units[USDC.assetId], '-7960800');
|
||||
});
|
||||
|
||||
test('unrelated active-asset movement does not create false maker completion', () => {
|
||||
const quoteId = 'quote-usdc-extra-move';
|
||||
const [outcome] = deriveQuoteOutcomeRecords({
|
||||
submissions: [submittedResult(quoteId)],
|
||||
commands: [{
|
||||
observed_at: '2026-04-02T18:13:29.000Z',
|
||||
payload: {
|
||||
quote_id: quoteId,
|
||||
command_id: `cmd-${quoteId}`,
|
||||
decision_id: `decision-${quoteId}`,
|
||||
pair: `${BTC.assetId}->${USDC.assetId}`,
|
||||
request_kind: 'exact_in',
|
||||
asset_in: BTC.assetId,
|
||||
asset_out: USDC.assetId,
|
||||
amount_in: '10000',
|
||||
quote_output: { amount_out: '7960800' },
|
||||
expected_inventory_delta_units: {
|
||||
[BTC.assetId]: '10000',
|
||||
[USDC.assetId]: '-7960800',
|
||||
},
|
||||
min_deadline_ms: 60000,
|
||||
},
|
||||
}],
|
||||
inventorySnapshots: [
|
||||
inventorySnapshot('2026-04-02T18:13:00.000Z', {
|
||||
[BTC.assetId]: '0',
|
||||
[USDC.assetId]: '10000000',
|
||||
[EURE.assetId]: '1',
|
||||
}),
|
||||
inventorySnapshot('2026-04-02T18:13:33.000Z', {
|
||||
[BTC.assetId]: '10000',
|
||||
[USDC.assetId]: '2039200',
|
||||
[EURE.assetId]: '2',
|
||||
}),
|
||||
],
|
||||
btcAsset: BTC,
|
||||
eureAsset: EURE,
|
||||
now: '2026-04-02T18:14:00.000Z',
|
||||
});
|
||||
|
||||
assert.equal(outcome.outcome_status, 'submitted');
|
||||
assert.equal(outcome.attribution_status, 'unattributed');
|
||||
});
|
||||
|
|
|
|||
87
test/route-rates.test.mjs
Normal file
87
test/route-rates.test.mjs
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
classifyRouteDirection,
|
||||
computeDestinationAmountUnitsFromRoute,
|
||||
resolveRouteRates,
|
||||
} from '../src/core/route-rates.mjs';
|
||||
|
||||
const BTC = 'nep141:nbtc.bridge.near';
|
||||
const EURE = 'nep141:eure.omft.near';
|
||||
const USDC = 'nep141:usdc.omft.near';
|
||||
|
||||
test('generic route math handles BTC/EUR base to quote and quote to base fixtures', () => {
|
||||
const priceRoute = {
|
||||
routeId: 'btc-eur-route',
|
||||
source: 'btc_eur_reference',
|
||||
baseAssetId: BTC,
|
||||
quoteAssetId: EURE,
|
||||
};
|
||||
const price = {
|
||||
price_route_id: 'btc-eur-route',
|
||||
quote_per_base: '50000',
|
||||
base_per_quote: '0.00002',
|
||||
};
|
||||
|
||||
const sellBtcDirection = classifyRouteDirection({
|
||||
sourceAssetId: BTC,
|
||||
destinationAssetId: EURE,
|
||||
priceRoute,
|
||||
});
|
||||
const buyBtcDirection = classifyRouteDirection({
|
||||
sourceAssetId: EURE,
|
||||
destinationAssetId: BTC,
|
||||
priceRoute,
|
||||
});
|
||||
|
||||
assert.equal(sellBtcDirection, 'base_to_quote');
|
||||
assert.equal(buyBtcDirection, 'quote_to_base');
|
||||
|
||||
const rates = resolveRouteRates({ price, priceRoute, direction: sellBtcDirection });
|
||||
assert.equal(rates.ok, true);
|
||||
assert.equal(computeDestinationAmountUnitsFromRoute({
|
||||
sourceAmountUnits: '10000',
|
||||
sourceDecimals: 8,
|
||||
destinationDecimals: 18,
|
||||
direction: sellBtcDirection,
|
||||
quotePerBase: rates.quotePerBase,
|
||||
basePerQuote: rates.basePerQuote,
|
||||
}), '5000000000000000000');
|
||||
assert.equal(computeDestinationAmountUnitsFromRoute({
|
||||
sourceAmountUnits: '5000000000000000000',
|
||||
sourceDecimals: 18,
|
||||
destinationDecimals: 8,
|
||||
direction: buyBtcDirection,
|
||||
quotePerBase: rates.quotePerBase,
|
||||
basePerQuote: rates.basePerQuote,
|
||||
}), '10000');
|
||||
});
|
||||
|
||||
test('generic route math handles BTC/USDC legacy adapter fields', () => {
|
||||
const priceRoute = {
|
||||
routeId: 'btc-usdc-route',
|
||||
source: 'btc_usdc_reference',
|
||||
baseAssetId: BTC,
|
||||
quoteAssetId: USDC,
|
||||
};
|
||||
const rates = resolveRouteRates({
|
||||
price: {
|
||||
price_route_id: 'btc-usdc-route',
|
||||
usdc_per_btc: '80000',
|
||||
btc_per_usdc: '0.0000125',
|
||||
},
|
||||
priceRoute,
|
||||
direction: 'quote_to_base',
|
||||
});
|
||||
|
||||
assert.equal(rates.ok, true);
|
||||
assert.equal(computeDestinationAmountUnitsFromRoute({
|
||||
sourceAmountUnits: '10000000',
|
||||
sourceDecimals: 6,
|
||||
destinationDecimals: 8,
|
||||
direction: 'quote_to_base',
|
||||
quotePerBase: rates.quotePerBase,
|
||||
basePerQuote: rates.basePerQuote,
|
||||
}), '12500');
|
||||
});
|
||||
|
|
@ -2,6 +2,7 @@ import test from 'node:test';
|
|||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
deriveServiceHealth,
|
||||
shouldContainExecutorForAlerts,
|
||||
shouldRaiseIngestPublishStale,
|
||||
} from '../src/core/runtime-health.mjs';
|
||||
|
|
@ -47,3 +48,32 @@ test('executor containment stays disabled even for broken truth path alerts', ()
|
|||
severity: 'critical',
|
||||
}]), false);
|
||||
});
|
||||
|
||||
test('armed service treats critical truth alerts for any active pair as critical', () => {
|
||||
const health = deriveServiceHealth({
|
||||
service: 'strategy-engine',
|
||||
snapshot: {
|
||||
reachable: true,
|
||||
state: {
|
||||
armed: true,
|
||||
},
|
||||
health: {
|
||||
ok: true,
|
||||
},
|
||||
},
|
||||
activePairs: [
|
||||
'btc->eure',
|
||||
'btc->usdc',
|
||||
],
|
||||
activeAlerts: [{
|
||||
alert_code: 'reference_price_stale',
|
||||
severity: 'critical',
|
||||
service_scope: 'strategy-engine',
|
||||
pair: 'btc->usdc',
|
||||
}],
|
||||
now: '2026-05-18T10:00:00.000Z',
|
||||
});
|
||||
|
||||
assert.equal(health.status, 'critical');
|
||||
assert.equal(health.label, 'armed on stale truth');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -22,3 +22,10 @@ test('kubernetes production config does not carry pair, asset, or edge env vars'
|
|||
assert.doesNotMatch(manifest, /INTENT_REQUEST_DEFAULT_AMOUNT_EURE/);
|
||||
assert.doesNotMatch(manifest, /INTENT_REQUEST_MAX_AMOUNT_EURE/);
|
||||
});
|
||||
|
||||
test('live market-maker watch script reads DB assets instead of removed trading env vars', () => {
|
||||
const source = readFileSync(new URL('../scripts/ops/watch_live_mm.py', import.meta.url), 'utf8');
|
||||
assert.match(source, /from trading_assets/);
|
||||
assert.doesNotMatch(source, /TRADING_BTC_ASSET_ID/);
|
||||
assert.doesNotMatch(source, /TRADING_EURE_ASSET_ID/);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -174,12 +174,16 @@ test('strategy emits actionable exact-in BTC -> USDC command from DB price route
|
|||
});
|
||||
|
||||
assert.equal(result.decision.decision, 'actionable');
|
||||
assert.equal(result.decision.direction, 'btc_to_usdc');
|
||||
assert.equal(result.decision.direction, 'base_to_quote');
|
||||
assert.equal(result.decision.edge_bps, '49');
|
||||
assert.equal(result.decision.price_route_id, 'btc-usdc:v1');
|
||||
assert.equal(result.decision.notional, '8.000000');
|
||||
assert.equal(result.decision.notional_symbol, 'USDC');
|
||||
assert.equal(result.decision.eure_notional, null);
|
||||
assert.equal(result.command.quote_output.amount_out, '7960800');
|
||||
assert.equal(result.command.notional_symbol, 'USDC');
|
||||
assert.equal(result.command.expected_inventory_delta_units[config.tradingBtc.assetId], '10000');
|
||||
assert.equal(result.command.expected_inventory_delta_units[config.tradingUsdc.assetId], '-7960800');
|
||||
assert.equal(result.command.asset_out_decimals, 6);
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue