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.
This commit is contained in:
parent
8def832c5e
commit
8f109a7463
7 changed files with 455 additions and 16 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
eur_value_eure: metricValuation?.value_eure
|
||||
|| valueAssetInEur({
|
||||
asset,
|
||||
units: spendableUnits,
|
||||
marketPrice: marketPrice?.payload || marketPrice || null,
|
||||
}),
|
||||
eur_value_source: metricValuation?.valuation_source || null,
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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/);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue