From 8f109a7463cac9ff679abb66f4af1cc8ca79b1fc Mon Sep 17 00:00:00 2001 From: philipp Date: Mon, 18 May 2026 17:48:18 +0200 Subject: [PATCH] Value tracked USDC in portfolio metrics Proof: Dashboard portfolio metrics now include DB-tracked USDC balances valued from live BTC/EUR and BTC/USDC reference prices, with regression coverage for the observed USDC inventory case. Assumptions: USDC is cash-equivalent for valuation when a fresh BTC/USDC reference event is available; live trading safety remains governed by pair config and price route checks. Still fake: Portfolio valuation still does not provide fee-complete realized PnL or generalized valuation for every imported non-stable asset. --- src/apps/history-writer.mjs | 7 +- src/core/operator-dashboard.mjs | 21 ++- src/core/portfolio-metrics.mjs | 251 +++++++++++++++++++++++++++- src/lib/postgres.mjs | 54 +++++- test/history-writer-static.test.mjs | 6 + test/operator-dashboard.test.mjs | 69 ++++++++ test/portfolio-metrics.test.mjs | 63 ++++++- 7 files changed, 455 insertions(+), 16 deletions(-) diff --git a/src/apps/history-writer.mjs b/src/apps/history-writer.mjs index 2532390..79455a3 100644 --- a/src/apps/history-writer.mjs +++ b/src/apps/history-writer.mjs @@ -384,6 +384,7 @@ async function refreshPortfolioMetrics() { btcAsset: tradingConfig.tradingBtc, btcAssets: tradingConfig.tradingBtcAssets, eureAsset: tradingConfig.tradingEure, + trackedAssets: tradingConfig.trackedAssets, }); const payload = computePortfolioMetric({ baseline: inputs.baseline, @@ -393,6 +394,7 @@ async function refreshPortfolioMetrics() { btcAsset: tradingConfig.tradingBtc, btcAssets: tradingConfig.tradingBtcAssets, eureAsset: tradingConfig.tradingEure, + valuationAssets: inputs.valuationAssets || [], commandCount: inputs.commandCount, resultCount: inputs.resultCount, }); @@ -402,7 +404,10 @@ async function refreshPortfolioMetrics() { const metricId = buildPortfolioMetricId({ baselineInventoryId: inputs.baseline?.inventory?.inventory_id || null, currentInventoryId: inputs.currentInventory?.payload?.inventory_id || null, - currentPriceId: inputs.currentPrice?.payload?.price_id || null, + currentPriceId: [ + inputs.currentPrice?.payload?.price_id, + ...(inputs.valuationAssets || []).map((asset) => asset.priceId), + ].filter(Boolean).join('+') || null, }); await upsertPortfolioMetric(pool, { metricId, diff --git a/src/core/operator-dashboard.mjs b/src/core/operator-dashboard.mjs index c2f0e7a..5923941 100644 --- a/src/core/operator-dashboard.mjs +++ b/src/core/operator-dashboard.mjs @@ -551,6 +551,7 @@ export function buildDashboardBootstrap({ inventorySnapshot, marketPrice, config, + portfolioMetric, }); const funding = buildFundingSummary({ config, @@ -771,7 +772,7 @@ function buildStatusBar({ }; } -function buildBalanceSummary({ inventorySnapshot, marketPrice, config }) { +function buildBalanceSummary({ inventorySnapshot, marketPrice, config, portfolioMetric = null }) { const inventory = inventorySnapshot?.payload || {}; const spendable = inventory.spendable || {}; const pendingInbound = inventory.pending_inbound || {}; @@ -779,6 +780,11 @@ function buildBalanceSummary({ inventorySnapshot, marketPrice, config }) { const balanceAssets = config.trackedAssets?.length ? config.trackedAssets : [...config.assetRegistry.values()]; + const metricValuationsByAssetId = new Map( + (portfolioMetric?.payload?.current_inventory?.valued_assets || []) + .filter((asset) => asset?.asset_id) + .map((asset) => [asset.asset_id, asset]), + ); return { synced_at: inventory.synced_at || inventorySnapshot?.ingested_at || null, @@ -787,6 +793,7 @@ function buildBalanceSummary({ inventorySnapshot, marketPrice, config }) { const spendableUnits = String(spendable[asset.assetId] || '0'); const pendingInboundUnits = String(pendingInbound[asset.assetId] || '0'); const pendingOutboundUnits = String(pendingOutbound[asset.assetId] || '0'); + const metricValuation = metricValuationsByAssetId.get(asset.assetId); return { asset_id: asset.assetId, symbol: asset.symbol, @@ -799,11 +806,13 @@ function buildBalanceSummary({ inventorySnapshot, marketPrice, config }) { pending_inbound: formatUnits(pendingInboundUnits, asset.decimals), pending_outbound_units: pendingOutboundUnits, pending_outbound: formatUnits(pendingOutboundUnits, asset.decimals), - eur_value_eure: valueAssetInEur({ - asset, - units: spendableUnits, - marketPrice: marketPrice?.payload || marketPrice || null, - }), + eur_value_eure: metricValuation?.value_eure + || valueAssetInEur({ + asset, + units: spendableUnits, + marketPrice: marketPrice?.payload || marketPrice || null, + }), + eur_value_source: metricValuation?.valuation_source || null, }; }), }; diff --git a/src/core/portfolio-metrics.mjs b/src/core/portfolio-metrics.mjs index 612e1b6..0754c97 100644 --- a/src/core/portfolio-metrics.mjs +++ b/src/core/portfolio-metrics.mjs @@ -9,6 +9,7 @@ export function computePortfolioMetric({ btcAsset, btcAssets = null, eureAsset, + valuationAssets = [], commandCount = 0, resultCount = 0, } = {}) { @@ -24,10 +25,20 @@ export function computePortfolioMetric({ const currentEure = unitsToScaledDecimal(currentEureUnits, eureAsset.decimals); const currentPriceScaled = parseScaledDecimal(currentPrice.eure_per_btc); const currentBtcMarkValue = multiplyScaled(currentBtc, currentPriceScaled); - const currentPortfolioValue = currentEure + currentBtcMarkValue; + const effectiveValuationAssets = normalizeValuationAssets({ + valuationAssets, + btcAssets: effectiveBtcAssets, + eureAsset, + }); + const currentValuedAssets = valueValuationAssets({ + inventory: currentInventory, + valuationAssets: effectiveValuationAssets, + priceField: 'currentUnitValueEure', + }); + const currentPortfolioValue = currentEure + currentBtcMarkValue + currentValuedAssets.total; const payload = { - metric_version: 2, + metric_version: 3, baseline_status: baseline ? 'active' : 'awaiting_first_execution', command_count: commandCount, result_count: resultCount, @@ -41,10 +52,12 @@ export function computePortfolioMetric({ btcAsset: displayBtcAsset, btcAssets: effectiveBtcAssets, eureAsset, + valuationAssets: effectiveValuationAssets, }), current_portfolio_value_eure: formatScaledDecimal(currentPortfolioValue), current_btc_mark_value_eure: formatScaledDecimal(currentBtcMarkValue), current_eure_cash_value_eure: formatScaledDecimal(currentEure), + current_valued_asset_value_eure: formatScaledDecimal(currentValuedAssets.total), portfolio_vs_simple_hold_eure: null, trade_pnl_eure: null, mark_to_market_pnl_eure: null, @@ -63,6 +76,7 @@ export function computePortfolioMetric({ net_btc: '0', net_eure_units: '0', net_eure: '0', + net_valued_assets: [], net_value_eure_at_flow_time: '0', net_value_eure_at_current_price: '0', }, @@ -79,15 +93,37 @@ export function computePortfolioMetric({ const baselineBtc = sumAssetScaled(baseline.inventory, effectiveBtcAssets); const baselineEure = unitsToScaledDecimal(baselineEureUnits, eureAsset.decimals); const baselinePriceScaled = parseScaledDecimal(baseline.price.eure_per_btc); - const baselinePortfolioAtBaselinePrice = baselineEure + multiplyScaled(baselineBtc, baselinePriceScaled); - const baselinePortfolioAtCurrentPrice = baselineEure + multiplyScaled(baselineBtc, currentPriceScaled); - const currentPortfolioAtBaselinePrice = currentEure + multiplyScaled(currentBtc, baselinePriceScaled); + const baselineValuedAssetsAtBaselinePrice = valueValuationAssets({ + inventory: baseline.inventory, + valuationAssets: effectiveValuationAssets, + priceField: 'baselineUnitValueEure', + }); + const baselineValuedAssetsAtCurrentPrice = valueValuationAssets({ + inventory: baseline.inventory, + valuationAssets: effectiveValuationAssets, + priceField: 'currentUnitValueEure', + }); + const currentValuedAssetsAtBaselinePrice = valueValuationAssets({ + inventory: currentInventory, + valuationAssets: effectiveValuationAssets, + priceField: 'baselineUnitValueEure', + }); + const baselinePortfolioAtBaselinePrice = baselineEure + + multiplyScaled(baselineBtc, baselinePriceScaled) + + baselineValuedAssetsAtBaselinePrice.total; + const baselinePortfolioAtCurrentPrice = baselineEure + + multiplyScaled(baselineBtc, currentPriceScaled) + + baselineValuedAssetsAtCurrentPrice.total; + const currentPortfolioAtBaselinePrice = currentEure + + multiplyScaled(currentBtc, baselinePriceScaled) + + currentValuedAssetsAtBaselinePrice.total; const externalFlowSummary = summarizeExternalFlows({ externalFlows, currentPriceScaled, btcAsset: displayBtcAsset, btcAssets: effectiveBtcAssets, eureAsset, + valuationAssets: effectiveValuationAssets, }); const fundedPortfolioAtFlowTime = baselinePortfolioAtBaselinePrice + externalFlowSummary.netValueEureAtFlowTime; @@ -131,6 +167,10 @@ export function computePortfolioMetric({ externalFlowSummary.netEureUnits.toString(), eureAsset.decimals, )), + net_valued_assets: buildNetValuedAssetFlowView({ + netValuedAssetUnits: externalFlowSummary.netValuedAssetUnits, + valuationAssets: effectiveValuationAssets, + }), net_value_eure_at_flow_time: formatScaledDecimal(externalFlowSummary.netValueEureAtFlowTime), net_value_eure_at_current_price: formatScaledDecimal(externalFlowSummary.netValueEureAtCurrentPrice), }; @@ -140,6 +180,13 @@ export function computePortfolioMetric({ eure_units: (BigInt(currentEureUnits) - BigInt(baselineEureUnits)).toString(), eure: formatScaledDecimal(currentEure - baselineEure), }; + if (effectiveValuationAssets.length) { + payload.inventory_delta.valued_assets = buildValuedAssetInventoryDelta({ + currentInventory, + baselineInventory: baseline.inventory, + valuationAssets: effectiveValuationAssets, + }); + } payload.baseline = { anchor: baseline.anchor || 'latest_inventory_before_first_command', command_at: baseline.command_at || null, @@ -153,12 +200,49 @@ export function computePortfolioMetric({ btcAsset: displayBtcAsset, btcAssets: effectiveBtcAssets, eureAsset, + valuationAssets: effectiveValuationAssets, }), }; return payload; } +export function buildCashEquivalentValuationAssets({ + trackedAssets = [], + btcAsset = null, + btcAssets = null, + eureAsset = null, + priceEvents = [], +} = {}) { + const effectiveBtcAssets = normalizeBtcAssets({ btcAsset, btcAssets }); + const excludedAssetIds = new Set( + [...effectiveBtcAssets.map((asset) => asset.assetId), eureAsset?.assetId].filter(Boolean), + ); + const payloads = (Array.isArray(priceEvents) ? priceEvents : [priceEvents]) + .map((event) => event?.payload || event) + .filter(Boolean); + const usdcPrice = payloads.find((event) => event?.eure_per_btc && event?.usdc_per_btc); + const usdcUnitValueEure = usdcPrice + ? divideScaledDecimalStrings(usdcPrice.eure_per_btc, usdcPrice.usdc_per_btc) + : null; + + return (trackedAssets || []) + .filter((asset) => asset?.assetId && !excludedAssetIds.has(asset.assetId)) + .map((asset) => { + if (asset.symbol !== 'USDC' || !usdcUnitValueEure) return null; + return { + asset, + assetId: asset.assetId, + currentUnitValueEure: usdcUnitValueEure, + baselineUnitValueEure: usdcUnitValueEure, + valuationSource: 'btc_usdc_reference', + priceId: usdcPrice.price_id || null, + observedAt: usdcPrice.observed_at || usdcPrice.ingested_at || null, + }; + }) + .filter(Boolean); +} + export function buildPortfolioMetricId({ baselineInventoryId, currentInventoryId, currentPriceId }) { return [ 'portfolio-metric', @@ -168,11 +252,27 @@ export function buildPortfolioMetricId({ baselineInventoryId, currentInventoryId ].join(':'); } -function buildInventoryView({ inventory, btcAsset, btcAssets = null, eureAsset }) { +function buildInventoryView({ + inventory, + btcAsset, + btcAssets = null, + eureAsset, + valuationAssets = [], +}) { const effectiveBtcAssets = normalizeBtcAssets({ btcAsset, btcAssets }); + const effectiveValuationAssets = normalizeValuationAssets({ + valuationAssets, + btcAssets: effectiveBtcAssets, + eureAsset, + }); const spendable = inventory?.spendable || {}; const btcUnits = sumAssetUnits(inventory, effectiveBtcAssets).toString(); const eureUnits = String(spendable[eureAsset.assetId] || '0'); + const valuedAssets = valueValuationAssets({ + inventory, + valuationAssets: effectiveValuationAssets, + priceField: 'currentUnitValueEure', + }); return { inventory_id: inventory?.inventory_id || null, @@ -190,6 +290,8 @@ function buildInventoryView({ inventory, btcAsset, btcAssets = null, eureAsset } }), eure_units: eureUnits, eure: formatAssetUnits(eureUnits, eureAsset.decimals), + valued_assets: valuedAssets.items, + valued_assets_value_eure: formatScaledDecimal(valuedAssets.total), }; } @@ -199,14 +301,21 @@ function summarizeExternalFlows({ btcAsset, btcAssets = null, eureAsset, + valuationAssets = [], }) { const effectiveBtcAssets = normalizeBtcAssets({ btcAsset, btcAssets }); + const effectiveValuationAssets = normalizeValuationAssets({ + valuationAssets, + btcAssets: effectiveBtcAssets, + eureAsset, + }); let flowCount = 0; let depositCount = 0; let withdrawalCount = 0; let latestEffectiveAt = null; let netBtcUnits = 0n; let netEureUnits = 0n; + const netValuedAssetUnits = new Map(); let netValueEureAtFlowTime = 0n; let netValueEureAtCurrentPrice = 0n; @@ -237,6 +346,24 @@ function summarizeExternalFlows({ const eureAmount = unitsToScaledDecimal(signedUnits.toString(), eureAsset.decimals); netValueEureAtFlowTime += eureAmount; netValueEureAtCurrentPrice += eureAmount; + } else { + const valuedAsset = effectiveValuationAssets.find((asset) => asset.assetId === flow.asset_id); + if (!valuedAsset) continue; + netValuedAssetUnits.set( + valuedAsset.assetId, + (netValuedAssetUnits.get(valuedAsset.assetId) || 0n) + signedUnits, + ); + const amount = unitsToScaledDecimal(signedUnits.toString(), valuedAsset.decimals); + const flowUnitValue = parseScaledDecimal( + flow.reference_price_eure_per_unit_at_flow_time + || valuedAsset.baselineUnitValueEure + || valuedAsset.currentUnitValueEure, + ); + netValueEureAtFlowTime += multiplyScaled(amount, flowUnitValue); + netValueEureAtCurrentPrice += multiplyScaled( + amount, + parseScaledDecimal(valuedAsset.currentUnitValueEure), + ); } } @@ -247,11 +374,114 @@ function summarizeExternalFlows({ latestEffectiveAt, netBtcUnits, netEureUnits, + netValuedAssetUnits, netValueEureAtFlowTime, netValueEureAtCurrentPrice, }; } +function normalizeValuationAssets({ 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 currentUnitValueEure = + entry.currentUnitValueEure + ?? entry.current_unit_value_eure + ?? entry.valueEurePerUnit + ?? entry.value_eure_per_unit + ?? null; + const baselineUnitValueEure = + entry.baselineUnitValueEure + ?? entry.baseline_unit_value_eure + ?? currentUnitValueEure; + if (!assetId || excludedAssetIds.has(assetId) || currentUnitValueEure == null) 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), + currentUnitValueEure: String(currentUnitValueEure), + baselineUnitValueEure: String(baselineUnitValueEure), + valuationSource: entry.valuationSource || entry.valuation_source || null, + priceId: entry.priceId || entry.price_id || null, + observedAt: entry.observedAt || entry.observed_at || null, + }; + }) + .filter((asset) => ( + asset + && Number.isInteger(asset.decimals) + && asset.decimals >= 0 + && asset.decimals <= VALUE_SCALE + )); +} + +function valueValuationAssets({ inventory, valuationAssets, priceField }) { + const spendable = inventory?.spendable || {}; + let total = 0n; + const items = []; + + for (const asset of valuationAssets || []) { + const units = String(spendable[asset.assetId] || '0'); + const amount = unitsToScaledDecimal(units, asset.decimals); + const unitValue = parseScaledDecimal(asset[priceField] || asset.currentUnitValueEure); + const value = multiplyScaled(amount, unitValue); + total += value; + items.push({ + asset_id: asset.assetId, + symbol: asset.symbol, + label: asset.label, + units, + amount: formatAssetUnits(units, asset.decimals), + value_eure: formatScaledDecimal(value), + unit_value_eure: formatScaledDecimal(unitValue), + valuation_source: asset.valuationSource, + price_id: asset.priceId, + observed_at: asset.observedAt, + }); + } + + return { total, items }; +} + +function buildValuedAssetInventoryDelta({ + currentInventory, + baselineInventory, + valuationAssets, +}) { + const currentSpendable = currentInventory?.spendable || {}; + const baselineSpendable = baselineInventory?.spendable || {}; + + return (valuationAssets || []).map((asset) => { + const currentUnits = BigInt(currentSpendable[asset.assetId] || '0'); + const baselineUnits = BigInt(baselineSpendable[asset.assetId] || '0'); + const deltaUnits = currentUnits - baselineUnits; + return { + asset_id: asset.assetId, + symbol: asset.symbol, + units: deltaUnits.toString(), + amount: formatAssetUnits(deltaUnits.toString(), asset.decimals), + }; + }); +} + +function buildNetValuedAssetFlowView({ netValuedAssetUnits, valuationAssets }) { + return (valuationAssets || []).map((asset) => { + const units = netValuedAssetUnits?.get(asset.assetId) || 0n; + return { + asset_id: asset.assetId, + symbol: asset.symbol, + units: units.toString(), + amount: formatAssetUnits(units.toString(), asset.decimals), + }; + }); +} + function normalizeBtcAssets({ btcAsset, btcAssets = null }) { const assets = btcAssets?.length ? btcAssets : [btcAsset]; const byId = new Map(); @@ -300,6 +530,15 @@ function multiplyScaled(left, right) { return (left * right) / VALUE_FACTOR; } +function divideScaled(left, right) { + if (right === 0n) throw new Error('cannot divide by zero scaled decimal'); + return (left * VALUE_FACTOR) / right; +} + +function divideScaledDecimalStrings(left, right) { + return formatScaledDecimal(divideScaled(parseScaledDecimal(left), parseScaledDecimal(right))); +} + function formatScaledDecimal(value) { const negative = value < 0n; const absolute = negative ? -value : value; diff --git a/src/lib/postgres.mjs b/src/lib/postgres.mjs index 7e0f3ad..b95eb97 100644 --- a/src/lib/postgres.mjs +++ b/src/lib/postgres.mjs @@ -1,6 +1,7 @@ import { Pool } from 'pg'; import { deriveIntentRequestOutcomeRecords } from '../core/intent-request-outcomes.mjs'; +import { buildCashEquivalentValuationAssets } from '../core/portfolio-metrics.mjs'; import { deriveQuoteOutcomeRecords } from '../core/quote-outcomes.mjs'; import { CURRENT_NBTC_ASSET_ID, @@ -2003,11 +2004,17 @@ export async function loadPortfolioMetricInputs(pool, { btcAsset = null, btcAssets = null, eureAsset = null, + trackedAssets = [], } = {}) { const effectiveBtcAssets = normalizeBtcAssets({ btcAsset, btcAssets }); - const [currentInventory, currentPrice, commandAggregate, resultAggregate] = await Promise.all([ + const [currentInventory, currentPrice, currentPriceEvents, commandAggregate, resultAggregate] = await Promise.all([ loadLatestEventPayload(pool, 'intent_inventory_snapshots'), - loadLatestEventPayload(pool, 'market_price_events'), + loadLatestEventPayload( + pool, + 'market_price_events', + "WHERE COALESCE(payload->>'eure_per_btc', '') <> '' ORDER BY COALESCE(observed_at, ingested_at) DESC LIMIT 1", + ), + loadLatestMarketPricePayloadsByRoute(pool), pool.query(` SELECT MIN(ingested_at) AS first_command_at, @@ -2023,11 +2030,20 @@ export async function loadPortfolioMetricInputs(pool, { const firstCommandAt = commandAggregate.rows[0]?.first_command_at || null; const commandCount = Number(commandAggregate.rows[0]?.command_count || 0); const resultCount = Number(resultAggregate.rows[0]?.result_count || 0); + const valuationAssets = buildCashEquivalentValuationAssets({ + trackedAssets, + btcAsset: effectiveBtcAssets[0], + btcAssets: effectiveBtcAssets, + eureAsset, + priceEvents: currentPriceEvents.map((entry) => entry.payload), + }); if (!firstCommandAt) { return { currentInventory, currentPrice, + currentPriceEvents, + valuationAssets, baseline: null, commandCount, resultCount, @@ -2052,12 +2068,15 @@ export async function loadPortfolioMetricInputs(pool, { btcAsset: effectiveBtcAssets[0], btcAssets: effectiveBtcAssets, eureAsset, + valuationAssets, }) : []; return { currentInventory, currentPrice, + currentPriceEvents, + valuationAssets, baseline: baselineInventory && baselinePrice ? { anchor: 'latest_inventory_before_first_command', command_at: new Date(firstCommandAt).toISOString(), @@ -2070,6 +2089,28 @@ export async function loadPortfolioMetricInputs(pool, { }; } +async function loadLatestMarketPricePayloadsByRoute(pool) { + const result = await pool.query(` + SELECT DISTINCT ON (payload->>'price_route_id') + observed_at, + ingested_at, + payload + FROM market_price_events + WHERE COALESCE(payload->>'price_route_id', '') <> '' + ORDER BY payload->>'price_route_id', COALESCE(observed_at, ingested_at) DESC + `); + + return result.rows.map((row) => ({ + observed_at: toIsoTimestamp(row.observed_at), + ingested_at: toIsoTimestamp(row.ingested_at), + payload: { + ...(row.payload || {}), + observed_at: row.payload?.observed_at || toIsoTimestamp(row.observed_at), + ingested_at: row.payload?.ingested_at || toIsoTimestamp(row.ingested_at), + }, + })); +} + export async function upsertPortfolioMetric(pool, { metricId, computedAt, @@ -3081,6 +3122,7 @@ async function loadExternalAssetFlowsSince(pool, { btcAsset, btcAssets = null, eureAsset, + valuationAssets = [], } = {}) { const effectiveBtcAssets = normalizeBtcAssets({ btcAsset, btcAssets }); const [depositRows, withdrawalRows] = await Promise.all([ @@ -3098,6 +3140,7 @@ async function loadExternalAssetFlowsSince(pool, { btcAsset: effectiveBtcAssets[0], btcAssets: effectiveBtcAssets, eureAsset, + valuationAssets, })); } @@ -3109,6 +3152,7 @@ async function loadExternalAssetFlowsSince(pool, { btcAsset: effectiveBtcAssets[0], btcAssets: effectiveBtcAssets, eureAsset, + valuationAssets, })); } @@ -3167,6 +3211,7 @@ async function normalizeExternalFlowRow(pool, { btcAsset, btcAssets = null, eureAsset, + valuationAssets = [], } = {}) { const payload = row?.payload || {}; const details = payload.details || {}; @@ -3177,12 +3222,16 @@ async function normalizeExternalFlowRow(pool, { const effectiveAt = toIsoTimestamp(details.created_at || row.observed_at || row.ingested_at); const signedUnits = (sign * BigInt(amount)).toString(); let referencePriceAtFlowTime = null; + let referencePriceEurePerUnitAtFlowTime = null; const effectiveBtcAssets = normalizeBtcAssets({ btcAsset, btcAssets }); const flowBtcAsset = effectiveBtcAssets.find((asset) => asset.assetId === assetId); + const valuationAsset = valuationAssets.find((asset) => asset.assetId === assetId); if (flowBtcAsset) { const nearestPrice = await loadNearestPricePayload(pool, effectiveAt); referencePriceAtFlowTime = nearestPrice?.payload?.eure_per_btc || null; + } else if (valuationAsset) { + referencePriceEurePerUnitAtFlowTime = valuationAsset.currentUnitValueEure || null; } else if (assetId !== eureAsset?.assetId) { return null; } @@ -3199,6 +3248,7 @@ async function normalizeExternalFlowRow(pool, { tx_hash: details.tx_hash || null, withdrawal_hash: details.withdrawal_hash || null, reference_price_eure_per_btc_at_flow_time: referencePriceAtFlowTime, + reference_price_eure_per_unit_at_flow_time: referencePriceEurePerUnitAtFlowTime, }; } diff --git a/test/history-writer-static.test.mjs b/test/history-writer-static.test.mjs index bdbb8d4..1129697 100644 --- a/test/history-writer-static.test.mjs +++ b/test/history-writer-static.test.mjs @@ -8,3 +8,9 @@ test('history writer replays durable topics but joins the raw quote firehose liv assert.match(source, /fromBeginning:\s*topic !== config\.kafkaTopicRawNearIntentsQuote/); assert.match(source, /Raw quote volume is a live firehose/); }); + +test('history writer passes tracked assets into portfolio valuation', () => { + assert.match(source, /trackedAssets:\s*tradingConfig\.trackedAssets/); + assert.match(source, /valuationAssets:\s*inputs\.valuationAssets \|\| \[\]/); + assert.match(source, /inputs\.valuationAssets[\s\S]+asset\.priceId/); +}); diff --git a/test/operator-dashboard.test.mjs b/test/operator-dashboard.test.mjs index f0df974..53d4495 100644 --- a/test/operator-dashboard.test.mjs +++ b/test/operator-dashboard.test.mjs @@ -909,6 +909,75 @@ test('bootstrap balances exclude imported catalog assets that are not inventory- ); }); +test('bootstrap balances reuse portfolio metric valuation for tracked USDC', () => { + const config = buildDualBtcConfig(); + const usdcAsset = { + assetId: 'nep141:eth-0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48.omft.near', + symbol: 'USDC', + label: 'USDC', + decimals: 6, + chain: 'eth:1', + enabledForInventory: true, + }; + config.trackedAssets = [...config.trackedAssets, usdcAsset]; + config.assetRegistry.set(usdcAsset.assetId, usdcAsset); + + const bootstrap = buildDashboardBootstrap({ + config, + portfolioMetric: { + computed_at: '2026-05-18T15:49:19.436Z', + baseline_anchor_at: '2026-04-02T18:10:43.569Z', + baseline_status: 'active', + payload: { + current_portfolio_value_eure: '344.233277302186441562', + current_inventory: { + valued_assets: [{ + asset_id: usdcAsset.assetId, + amount: '366.39517', + value_eure: '314.632199818186441562', + valuation_source: 'btc_usdc_reference', + }], + }, + }, + }, + inventorySnapshot: { + ingested_at: '2026-05-18T15:49:19.000Z', + payload: { + synced_at: '2026-05-18T15:49:19.000Z', + reconciliation_status: 'ok', + spendable: { + [config.tradingBtc.assetId]: '45186', + [config.tradingEure.assetId]: '0', + [usdcAsset.assetId]: '366395170', + }, + pending_inbound: {}, + pending_outbound: {}, + }, + }, + marketPrice: { + payload: { + observed_at: '2026-05-18T15:49:19.000Z', + eure_per_btc: '65509.40000000', + }, + }, + recentQuotes: [], + submissionPage: { page: 1, page_size: 20, total: 0, total_pages: 1, items: [] }, + submissionSummary: { total: 0 }, + fundingObservations: [], + recentTradeDecisions: [], + recentExecuteTradeCommands: [], + recentExecutionResults: [], + recentAlertTransitions: [], + serviceSnapshots: [], + }); + + const usdcRow = bootstrap.funds.balances.items.find((item) => item.asset_id === usdcAsset.assetId); + assert.equal(usdcRow.spendable, '366.39517'); + assert.equal(usdcRow.eur_value_eure, '314.632199818186441562'); + assert.equal(usdcRow.eur_value_source, 'btc_usdc_reference'); + assert.equal(bootstrap.status_bar.current_total_portfolio_value_eure, '344.233277302186441562'); +}); + test('bootstrap keeps no-trade counts without shipping non-rendered row payloads', () => { const config = buildConfig(); const bootstrap = buildDashboardBootstrap({ diff --git a/test/portfolio-metrics.test.mjs b/test/portfolio-metrics.test.mjs index db4a915..5edf9b3 100644 --- a/test/portfolio-metrics.test.mjs +++ b/test/portfolio-metrics.test.mjs @@ -1,7 +1,11 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { buildPortfolioMetricId, computePortfolioMetric } from '../src/core/portfolio-metrics.mjs'; +import { + buildCashEquivalentValuationAssets, + buildPortfolioMetricId, + computePortfolioMetric, +} from '../src/core/portfolio-metrics.mjs'; const btcAsset = { assetId: 'nep141:btc.omft.near', @@ -22,6 +26,12 @@ const eureAsset = { decimals: 18, }; +const usdcAsset = { + assetId: 'nep141:eth-0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48.omft.near', + symbol: 'USDC', + decimals: 6, +}; + test('portfolio metrics compute portfolio comparison and mark-to-market pnl from baseline funding inventory', () => { const metric = computePortfolioMetric({ baseline: { @@ -135,6 +145,57 @@ test('portfolio metrics value both tracked BTC wrappers as BTC-equivalent invent ]); }); +test('portfolio metrics include tracked USDC valued from live BTC/USDC reference', () => { + const currentPrice = { + price_id: 'price-usdc-current', + observed_at: '2026-05-18T15:41:08.837Z', + eure_per_btc: '65495.20000000', + usdc_per_btc: '76316.32000000', + }; + const valuationAssets = buildCashEquivalentValuationAssets({ + trackedAssets: [nbtcAsset, btcAsset, eureAsset, usdcAsset], + btcAssets: [nbtcAsset, btcAsset], + eureAsset, + priceEvents: [currentPrice], + }); + + const metric = computePortfolioMetric({ + baseline: null, + currentInventory: { + inventory_id: 'current-usdc', + synced_at: '2026-05-18T15:40:38.976Z', + spendable: { + 'nep141:nbtc.bridge.near': '45186', + [usdcAsset.assetId]: '366395170', + [eureAsset.assetId]: '0', + }, + }, + currentPrice, + btcAsset: nbtcAsset, + btcAssets: [nbtcAsset, btcAsset], + eureAsset, + valuationAssets, + }); + + assert.equal(valuationAssets[0].currentUnitValueEure, '0.858206999498927621'); + assert.equal(metric.current_btc_mark_value_eure, '29.594661072'); + assert.equal(metric.current_valued_asset_value_eure, '314.442899476599500513'); + assert.equal(metric.current_portfolio_value_eure, '344.037560548599500513'); + assert.deepEqual(metric.current_inventory.valued_assets.map((asset) => ({ + asset_id: asset.asset_id, + amount: asset.amount, + value_eure: asset.value_eure, + valuation_source: asset.valuation_source, + })), [ + { + asset_id: usdcAsset.assetId, + amount: '366.39517', + value_eure: '314.442899476599500513', + valuation_source: 'btc_usdc_reference', + }, + ]); +}); + test('portfolio metrics treat later deposits and withdrawals as external cash flows instead of PnL', () => { const metric = computePortfolioMetric({ baseline: {