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
88d635033c
5 changed files with 371 additions and 10 deletions
|
|
@ -384,6 +384,7 @@ async function refreshPortfolioMetrics() {
|
||||||
btcAsset: tradingConfig.tradingBtc,
|
btcAsset: tradingConfig.tradingBtc,
|
||||||
btcAssets: tradingConfig.tradingBtcAssets,
|
btcAssets: tradingConfig.tradingBtcAssets,
|
||||||
eureAsset: tradingConfig.tradingEure,
|
eureAsset: tradingConfig.tradingEure,
|
||||||
|
trackedAssets: tradingConfig.trackedAssets,
|
||||||
});
|
});
|
||||||
const payload = computePortfolioMetric({
|
const payload = computePortfolioMetric({
|
||||||
baseline: inputs.baseline,
|
baseline: inputs.baseline,
|
||||||
|
|
@ -393,6 +394,7 @@ async function refreshPortfolioMetrics() {
|
||||||
btcAsset: tradingConfig.tradingBtc,
|
btcAsset: tradingConfig.tradingBtc,
|
||||||
btcAssets: tradingConfig.tradingBtcAssets,
|
btcAssets: tradingConfig.tradingBtcAssets,
|
||||||
eureAsset: tradingConfig.tradingEure,
|
eureAsset: tradingConfig.tradingEure,
|
||||||
|
valuationAssets: inputs.valuationAssets || [],
|
||||||
commandCount: inputs.commandCount,
|
commandCount: inputs.commandCount,
|
||||||
resultCount: inputs.resultCount,
|
resultCount: inputs.resultCount,
|
||||||
});
|
});
|
||||||
|
|
@ -402,7 +404,10 @@ async function refreshPortfolioMetrics() {
|
||||||
const metricId = buildPortfolioMetricId({
|
const metricId = buildPortfolioMetricId({
|
||||||
baselineInventoryId: inputs.baseline?.inventory?.inventory_id || null,
|
baselineInventoryId: inputs.baseline?.inventory?.inventory_id || null,
|
||||||
currentInventoryId: inputs.currentInventory?.payload?.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, {
|
await upsertPortfolioMetric(pool, {
|
||||||
metricId,
|
metricId,
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ export function computePortfolioMetric({
|
||||||
btcAsset,
|
btcAsset,
|
||||||
btcAssets = null,
|
btcAssets = null,
|
||||||
eureAsset,
|
eureAsset,
|
||||||
|
valuationAssets = [],
|
||||||
commandCount = 0,
|
commandCount = 0,
|
||||||
resultCount = 0,
|
resultCount = 0,
|
||||||
} = {}) {
|
} = {}) {
|
||||||
|
|
@ -24,10 +25,20 @@ export function computePortfolioMetric({
|
||||||
const currentEure = unitsToScaledDecimal(currentEureUnits, eureAsset.decimals);
|
const currentEure = unitsToScaledDecimal(currentEureUnits, eureAsset.decimals);
|
||||||
const currentPriceScaled = parseScaledDecimal(currentPrice.eure_per_btc);
|
const currentPriceScaled = parseScaledDecimal(currentPrice.eure_per_btc);
|
||||||
const currentBtcMarkValue = multiplyScaled(currentBtc, currentPriceScaled);
|
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 = {
|
const payload = {
|
||||||
metric_version: 2,
|
metric_version: 3,
|
||||||
baseline_status: baseline ? 'active' : 'awaiting_first_execution',
|
baseline_status: baseline ? 'active' : 'awaiting_first_execution',
|
||||||
command_count: commandCount,
|
command_count: commandCount,
|
||||||
result_count: resultCount,
|
result_count: resultCount,
|
||||||
|
|
@ -41,10 +52,12 @@ export function computePortfolioMetric({
|
||||||
btcAsset: displayBtcAsset,
|
btcAsset: displayBtcAsset,
|
||||||
btcAssets: effectiveBtcAssets,
|
btcAssets: effectiveBtcAssets,
|
||||||
eureAsset,
|
eureAsset,
|
||||||
|
valuationAssets: effectiveValuationAssets,
|
||||||
}),
|
}),
|
||||||
current_portfolio_value_eure: formatScaledDecimal(currentPortfolioValue),
|
current_portfolio_value_eure: formatScaledDecimal(currentPortfolioValue),
|
||||||
current_btc_mark_value_eure: formatScaledDecimal(currentBtcMarkValue),
|
current_btc_mark_value_eure: formatScaledDecimal(currentBtcMarkValue),
|
||||||
current_eure_cash_value_eure: formatScaledDecimal(currentEure),
|
current_eure_cash_value_eure: formatScaledDecimal(currentEure),
|
||||||
|
current_valued_asset_value_eure: formatScaledDecimal(currentValuedAssets.total),
|
||||||
portfolio_vs_simple_hold_eure: null,
|
portfolio_vs_simple_hold_eure: null,
|
||||||
trade_pnl_eure: null,
|
trade_pnl_eure: null,
|
||||||
mark_to_market_pnl_eure: null,
|
mark_to_market_pnl_eure: null,
|
||||||
|
|
@ -63,6 +76,7 @@ export function computePortfolioMetric({
|
||||||
net_btc: '0',
|
net_btc: '0',
|
||||||
net_eure_units: '0',
|
net_eure_units: '0',
|
||||||
net_eure: '0',
|
net_eure: '0',
|
||||||
|
net_valued_assets: [],
|
||||||
net_value_eure_at_flow_time: '0',
|
net_value_eure_at_flow_time: '0',
|
||||||
net_value_eure_at_current_price: '0',
|
net_value_eure_at_current_price: '0',
|
||||||
},
|
},
|
||||||
|
|
@ -79,15 +93,37 @@ export function computePortfolioMetric({
|
||||||
const baselineBtc = sumAssetScaled(baseline.inventory, effectiveBtcAssets);
|
const baselineBtc = sumAssetScaled(baseline.inventory, effectiveBtcAssets);
|
||||||
const baselineEure = unitsToScaledDecimal(baselineEureUnits, eureAsset.decimals);
|
const baselineEure = unitsToScaledDecimal(baselineEureUnits, eureAsset.decimals);
|
||||||
const baselinePriceScaled = parseScaledDecimal(baseline.price.eure_per_btc);
|
const baselinePriceScaled = parseScaledDecimal(baseline.price.eure_per_btc);
|
||||||
const baselinePortfolioAtBaselinePrice = baselineEure + multiplyScaled(baselineBtc, baselinePriceScaled);
|
const baselineValuedAssetsAtBaselinePrice = valueValuationAssets({
|
||||||
const baselinePortfolioAtCurrentPrice = baselineEure + multiplyScaled(baselineBtc, currentPriceScaled);
|
inventory: baseline.inventory,
|
||||||
const currentPortfolioAtBaselinePrice = currentEure + multiplyScaled(currentBtc, baselinePriceScaled);
|
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({
|
const externalFlowSummary = summarizeExternalFlows({
|
||||||
externalFlows,
|
externalFlows,
|
||||||
currentPriceScaled,
|
currentPriceScaled,
|
||||||
btcAsset: displayBtcAsset,
|
btcAsset: displayBtcAsset,
|
||||||
btcAssets: effectiveBtcAssets,
|
btcAssets: effectiveBtcAssets,
|
||||||
eureAsset,
|
eureAsset,
|
||||||
|
valuationAssets: effectiveValuationAssets,
|
||||||
});
|
});
|
||||||
const fundedPortfolioAtFlowTime = baselinePortfolioAtBaselinePrice
|
const fundedPortfolioAtFlowTime = baselinePortfolioAtBaselinePrice
|
||||||
+ externalFlowSummary.netValueEureAtFlowTime;
|
+ externalFlowSummary.netValueEureAtFlowTime;
|
||||||
|
|
@ -131,6 +167,10 @@ export function computePortfolioMetric({
|
||||||
externalFlowSummary.netEureUnits.toString(),
|
externalFlowSummary.netEureUnits.toString(),
|
||||||
eureAsset.decimals,
|
eureAsset.decimals,
|
||||||
)),
|
)),
|
||||||
|
net_valued_assets: buildNetValuedAssetFlowView({
|
||||||
|
netValuedAssetUnits: externalFlowSummary.netValuedAssetUnits,
|
||||||
|
valuationAssets: effectiveValuationAssets,
|
||||||
|
}),
|
||||||
net_value_eure_at_flow_time: formatScaledDecimal(externalFlowSummary.netValueEureAtFlowTime),
|
net_value_eure_at_flow_time: formatScaledDecimal(externalFlowSummary.netValueEureAtFlowTime),
|
||||||
net_value_eure_at_current_price: formatScaledDecimal(externalFlowSummary.netValueEureAtCurrentPrice),
|
net_value_eure_at_current_price: formatScaledDecimal(externalFlowSummary.netValueEureAtCurrentPrice),
|
||||||
};
|
};
|
||||||
|
|
@ -140,6 +180,13 @@ export function computePortfolioMetric({
|
||||||
eure_units: (BigInt(currentEureUnits) - BigInt(baselineEureUnits)).toString(),
|
eure_units: (BigInt(currentEureUnits) - BigInt(baselineEureUnits)).toString(),
|
||||||
eure: formatScaledDecimal(currentEure - baselineEure),
|
eure: formatScaledDecimal(currentEure - baselineEure),
|
||||||
};
|
};
|
||||||
|
if (effectiveValuationAssets.length) {
|
||||||
|
payload.inventory_delta.valued_assets = buildValuedAssetInventoryDelta({
|
||||||
|
currentInventory,
|
||||||
|
baselineInventory: baseline.inventory,
|
||||||
|
valuationAssets: effectiveValuationAssets,
|
||||||
|
});
|
||||||
|
}
|
||||||
payload.baseline = {
|
payload.baseline = {
|
||||||
anchor: baseline.anchor || 'latest_inventory_before_first_command',
|
anchor: baseline.anchor || 'latest_inventory_before_first_command',
|
||||||
command_at: baseline.command_at || null,
|
command_at: baseline.command_at || null,
|
||||||
|
|
@ -153,12 +200,49 @@ export function computePortfolioMetric({
|
||||||
btcAsset: displayBtcAsset,
|
btcAsset: displayBtcAsset,
|
||||||
btcAssets: effectiveBtcAssets,
|
btcAssets: effectiveBtcAssets,
|
||||||
eureAsset,
|
eureAsset,
|
||||||
|
valuationAssets: effectiveValuationAssets,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
return payload;
|
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 }) {
|
export function buildPortfolioMetricId({ baselineInventoryId, currentInventoryId, currentPriceId }) {
|
||||||
return [
|
return [
|
||||||
'portfolio-metric',
|
'portfolio-metric',
|
||||||
|
|
@ -168,11 +252,27 @@ export function buildPortfolioMetricId({ baselineInventoryId, currentInventoryId
|
||||||
].join(':');
|
].join(':');
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildInventoryView({ inventory, btcAsset, btcAssets = null, eureAsset }) {
|
function buildInventoryView({
|
||||||
|
inventory,
|
||||||
|
btcAsset,
|
||||||
|
btcAssets = null,
|
||||||
|
eureAsset,
|
||||||
|
valuationAssets = [],
|
||||||
|
}) {
|
||||||
const effectiveBtcAssets = normalizeBtcAssets({ btcAsset, btcAssets });
|
const effectiveBtcAssets = normalizeBtcAssets({ btcAsset, btcAssets });
|
||||||
|
const effectiveValuationAssets = normalizeValuationAssets({
|
||||||
|
valuationAssets,
|
||||||
|
btcAssets: effectiveBtcAssets,
|
||||||
|
eureAsset,
|
||||||
|
});
|
||||||
const spendable = inventory?.spendable || {};
|
const spendable = inventory?.spendable || {};
|
||||||
const btcUnits = sumAssetUnits(inventory, effectiveBtcAssets).toString();
|
const btcUnits = sumAssetUnits(inventory, effectiveBtcAssets).toString();
|
||||||
const eureUnits = String(spendable[eureAsset.assetId] || '0');
|
const eureUnits = String(spendable[eureAsset.assetId] || '0');
|
||||||
|
const valuedAssets = valueValuationAssets({
|
||||||
|
inventory,
|
||||||
|
valuationAssets: effectiveValuationAssets,
|
||||||
|
priceField: 'currentUnitValueEure',
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
inventory_id: inventory?.inventory_id || null,
|
inventory_id: inventory?.inventory_id || null,
|
||||||
|
|
@ -190,6 +290,8 @@ function buildInventoryView({ inventory, btcAsset, btcAssets = null, eureAsset }
|
||||||
}),
|
}),
|
||||||
eure_units: eureUnits,
|
eure_units: eureUnits,
|
||||||
eure: formatAssetUnits(eureUnits, eureAsset.decimals),
|
eure: formatAssetUnits(eureUnits, eureAsset.decimals),
|
||||||
|
valued_assets: valuedAssets.items,
|
||||||
|
valued_assets_value_eure: formatScaledDecimal(valuedAssets.total),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -199,14 +301,21 @@ function summarizeExternalFlows({
|
||||||
btcAsset,
|
btcAsset,
|
||||||
btcAssets = null,
|
btcAssets = null,
|
||||||
eureAsset,
|
eureAsset,
|
||||||
|
valuationAssets = [],
|
||||||
}) {
|
}) {
|
||||||
const effectiveBtcAssets = normalizeBtcAssets({ btcAsset, btcAssets });
|
const effectiveBtcAssets = normalizeBtcAssets({ btcAsset, btcAssets });
|
||||||
|
const effectiveValuationAssets = normalizeValuationAssets({
|
||||||
|
valuationAssets,
|
||||||
|
btcAssets: effectiveBtcAssets,
|
||||||
|
eureAsset,
|
||||||
|
});
|
||||||
let flowCount = 0;
|
let flowCount = 0;
|
||||||
let depositCount = 0;
|
let depositCount = 0;
|
||||||
let withdrawalCount = 0;
|
let withdrawalCount = 0;
|
||||||
let latestEffectiveAt = null;
|
let latestEffectiveAt = null;
|
||||||
let netBtcUnits = 0n;
|
let netBtcUnits = 0n;
|
||||||
let netEureUnits = 0n;
|
let netEureUnits = 0n;
|
||||||
|
const netValuedAssetUnits = new Map();
|
||||||
let netValueEureAtFlowTime = 0n;
|
let netValueEureAtFlowTime = 0n;
|
||||||
let netValueEureAtCurrentPrice = 0n;
|
let netValueEureAtCurrentPrice = 0n;
|
||||||
|
|
||||||
|
|
@ -237,6 +346,24 @@ function summarizeExternalFlows({
|
||||||
const eureAmount = unitsToScaledDecimal(signedUnits.toString(), eureAsset.decimals);
|
const eureAmount = unitsToScaledDecimal(signedUnits.toString(), eureAsset.decimals);
|
||||||
netValueEureAtFlowTime += eureAmount;
|
netValueEureAtFlowTime += eureAmount;
|
||||||
netValueEureAtCurrentPrice += 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,
|
latestEffectiveAt,
|
||||||
netBtcUnits,
|
netBtcUnits,
|
||||||
netEureUnits,
|
netEureUnits,
|
||||||
|
netValuedAssetUnits,
|
||||||
netValueEureAtFlowTime,
|
netValueEureAtFlowTime,
|
||||||
netValueEureAtCurrentPrice,
|
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 }) {
|
function normalizeBtcAssets({ btcAsset, btcAssets = null }) {
|
||||||
const assets = btcAssets?.length ? btcAssets : [btcAsset];
|
const assets = btcAssets?.length ? btcAssets : [btcAsset];
|
||||||
const byId = new Map();
|
const byId = new Map();
|
||||||
|
|
@ -300,6 +530,15 @@ function multiplyScaled(left, right) {
|
||||||
return (left * right) / VALUE_FACTOR;
|
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) {
|
function formatScaledDecimal(value) {
|
||||||
const negative = value < 0n;
|
const negative = value < 0n;
|
||||||
const absolute = negative ? -value : value;
|
const absolute = negative ? -value : value;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { Pool } from 'pg';
|
import { Pool } from 'pg';
|
||||||
|
|
||||||
import { deriveIntentRequestOutcomeRecords } from '../core/intent-request-outcomes.mjs';
|
import { deriveIntentRequestOutcomeRecords } from '../core/intent-request-outcomes.mjs';
|
||||||
|
import { buildCashEquivalentValuationAssets } from '../core/portfolio-metrics.mjs';
|
||||||
import { deriveQuoteOutcomeRecords } from '../core/quote-outcomes.mjs';
|
import { deriveQuoteOutcomeRecords } from '../core/quote-outcomes.mjs';
|
||||||
import {
|
import {
|
||||||
CURRENT_NBTC_ASSET_ID,
|
CURRENT_NBTC_ASSET_ID,
|
||||||
|
|
@ -2003,11 +2004,17 @@ export async function loadPortfolioMetricInputs(pool, {
|
||||||
btcAsset = null,
|
btcAsset = null,
|
||||||
btcAssets = null,
|
btcAssets = null,
|
||||||
eureAsset = null,
|
eureAsset = null,
|
||||||
|
trackedAssets = [],
|
||||||
} = {}) {
|
} = {}) {
|
||||||
const effectiveBtcAssets = normalizeBtcAssets({ btcAsset, btcAssets });
|
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, '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(`
|
pool.query(`
|
||||||
SELECT
|
SELECT
|
||||||
MIN(ingested_at) AS first_command_at,
|
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 firstCommandAt = commandAggregate.rows[0]?.first_command_at || null;
|
||||||
const commandCount = Number(commandAggregate.rows[0]?.command_count || 0);
|
const commandCount = Number(commandAggregate.rows[0]?.command_count || 0);
|
||||||
const resultCount = Number(resultAggregate.rows[0]?.result_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) {
|
if (!firstCommandAt) {
|
||||||
return {
|
return {
|
||||||
currentInventory,
|
currentInventory,
|
||||||
currentPrice,
|
currentPrice,
|
||||||
|
currentPriceEvents,
|
||||||
|
valuationAssets,
|
||||||
baseline: null,
|
baseline: null,
|
||||||
commandCount,
|
commandCount,
|
||||||
resultCount,
|
resultCount,
|
||||||
|
|
@ -2052,12 +2068,15 @@ export async function loadPortfolioMetricInputs(pool, {
|
||||||
btcAsset: effectiveBtcAssets[0],
|
btcAsset: effectiveBtcAssets[0],
|
||||||
btcAssets: effectiveBtcAssets,
|
btcAssets: effectiveBtcAssets,
|
||||||
eureAsset,
|
eureAsset,
|
||||||
|
valuationAssets,
|
||||||
})
|
})
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
currentInventory,
|
currentInventory,
|
||||||
currentPrice,
|
currentPrice,
|
||||||
|
currentPriceEvents,
|
||||||
|
valuationAssets,
|
||||||
baseline: baselineInventory && baselinePrice ? {
|
baseline: baselineInventory && baselinePrice ? {
|
||||||
anchor: 'latest_inventory_before_first_command',
|
anchor: 'latest_inventory_before_first_command',
|
||||||
command_at: new Date(firstCommandAt).toISOString(),
|
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, {
|
export async function upsertPortfolioMetric(pool, {
|
||||||
metricId,
|
metricId,
|
||||||
computedAt,
|
computedAt,
|
||||||
|
|
@ -3081,6 +3122,7 @@ async function loadExternalAssetFlowsSince(pool, {
|
||||||
btcAsset,
|
btcAsset,
|
||||||
btcAssets = null,
|
btcAssets = null,
|
||||||
eureAsset,
|
eureAsset,
|
||||||
|
valuationAssets = [],
|
||||||
} = {}) {
|
} = {}) {
|
||||||
const effectiveBtcAssets = normalizeBtcAssets({ btcAsset, btcAssets });
|
const effectiveBtcAssets = normalizeBtcAssets({ btcAsset, btcAssets });
|
||||||
const [depositRows, withdrawalRows] = await Promise.all([
|
const [depositRows, withdrawalRows] = await Promise.all([
|
||||||
|
|
@ -3098,6 +3140,7 @@ async function loadExternalAssetFlowsSince(pool, {
|
||||||
btcAsset: effectiveBtcAssets[0],
|
btcAsset: effectiveBtcAssets[0],
|
||||||
btcAssets: effectiveBtcAssets,
|
btcAssets: effectiveBtcAssets,
|
||||||
eureAsset,
|
eureAsset,
|
||||||
|
valuationAssets,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3109,6 +3152,7 @@ async function loadExternalAssetFlowsSince(pool, {
|
||||||
btcAsset: effectiveBtcAssets[0],
|
btcAsset: effectiveBtcAssets[0],
|
||||||
btcAssets: effectiveBtcAssets,
|
btcAssets: effectiveBtcAssets,
|
||||||
eureAsset,
|
eureAsset,
|
||||||
|
valuationAssets,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3167,6 +3211,7 @@ async function normalizeExternalFlowRow(pool, {
|
||||||
btcAsset,
|
btcAsset,
|
||||||
btcAssets = null,
|
btcAssets = null,
|
||||||
eureAsset,
|
eureAsset,
|
||||||
|
valuationAssets = [],
|
||||||
} = {}) {
|
} = {}) {
|
||||||
const payload = row?.payload || {};
|
const payload = row?.payload || {};
|
||||||
const details = payload.details || {};
|
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 effectiveAt = toIsoTimestamp(details.created_at || row.observed_at || row.ingested_at);
|
||||||
const signedUnits = (sign * BigInt(amount)).toString();
|
const signedUnits = (sign * BigInt(amount)).toString();
|
||||||
let referencePriceAtFlowTime = null;
|
let referencePriceAtFlowTime = null;
|
||||||
|
let referencePriceEurePerUnitAtFlowTime = null;
|
||||||
const effectiveBtcAssets = normalizeBtcAssets({ btcAsset, btcAssets });
|
const effectiveBtcAssets = normalizeBtcAssets({ btcAsset, btcAssets });
|
||||||
const flowBtcAsset = effectiveBtcAssets.find((asset) => asset.assetId === assetId);
|
const flowBtcAsset = effectiveBtcAssets.find((asset) => asset.assetId === assetId);
|
||||||
|
const valuationAsset = valuationAssets.find((asset) => asset.assetId === assetId);
|
||||||
|
|
||||||
if (flowBtcAsset) {
|
if (flowBtcAsset) {
|
||||||
const nearestPrice = await loadNearestPricePayload(pool, effectiveAt);
|
const nearestPrice = await loadNearestPricePayload(pool, effectiveAt);
|
||||||
referencePriceAtFlowTime = nearestPrice?.payload?.eure_per_btc || null;
|
referencePriceAtFlowTime = nearestPrice?.payload?.eure_per_btc || null;
|
||||||
|
} else if (valuationAsset) {
|
||||||
|
referencePriceEurePerUnitAtFlowTime = valuationAsset.currentUnitValueEure || null;
|
||||||
} else if (assetId !== eureAsset?.assetId) {
|
} else if (assetId !== eureAsset?.assetId) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
@ -3199,6 +3248,7 @@ async function normalizeExternalFlowRow(pool, {
|
||||||
tx_hash: details.tx_hash || null,
|
tx_hash: details.tx_hash || null,
|
||||||
withdrawal_hash: details.withdrawal_hash || null,
|
withdrawal_hash: details.withdrawal_hash || null,
|
||||||
reference_price_eure_per_btc_at_flow_time: referencePriceAtFlowTime,
|
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, /fromBeginning:\s*topic !== config\.kafkaTopicRawNearIntentsQuote/);
|
||||||
assert.match(source, /Raw quote volume is a live firehose/);
|
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/);
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,11 @@
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
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 = {
|
const btcAsset = {
|
||||||
assetId: 'nep141:btc.omft.near',
|
assetId: 'nep141:btc.omft.near',
|
||||||
|
|
@ -22,6 +26,12 @@ const eureAsset = {
|
||||||
decimals: 18,
|
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', () => {
|
test('portfolio metrics compute portfolio comparison and mark-to-market pnl from baseline funding inventory', () => {
|
||||||
const metric = computePortfolioMetric({
|
const metric = computePortfolioMetric({
|
||||||
baseline: {
|
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', () => {
|
test('portfolio metrics treat later deposits and withdrawals as external cash flows instead of PnL', () => {
|
||||||
const metric = computePortfolioMetric({
|
const metric = computePortfolioMetric({
|
||||||
baseline: {
|
baseline: {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue