unrip/src/core/portfolio-metrics.mjs
philipp 16e7b79978
All checks were successful
deploy / deploy (push) Successful in 22s
Add durable portfolio metrics
Proof: Persist portfolio value and PnL snapshots from the live inventory and reference-price path so operators can inspect trading performance from repo-controlled data.
Assumptions: The last credited inventory snapshot before the first live command is the correct baseline for trade-driven PnL, and EURe remains explicit 1:1 with EUR.
Still fake: The new portfolio metrics and watch output are implemented and tested locally but are not live until the updated app image is deployed to k3s.
2026-04-03 01:02:27 +02:00

162 lines
6.3 KiB
JavaScript

const VALUE_SCALE = 18;
const VALUE_FACTOR = 10n ** BigInt(VALUE_SCALE);
export function computePortfolioMetric({
baseline = null,
currentInventory,
currentPrice,
btcAsset,
eureAsset,
commandCount = 0,
resultCount = 0,
} = {}) {
if (!currentInventory || !currentPrice || !btcAsset?.assetId || !eureAsset?.assetId) {
return null;
}
const currentBtcUnits = String(currentInventory.spendable?.[btcAsset.assetId] || '0');
const currentEureUnits = String(currentInventory.spendable?.[eureAsset.assetId] || '0');
const currentBtc = unitsToScaledDecimal(currentBtcUnits, btcAsset.decimals);
const currentEure = unitsToScaledDecimal(currentEureUnits, eureAsset.decimals);
const currentPriceScaled = parseScaledDecimal(currentPrice.eure_per_btc);
const currentBtcMarkValue = multiplyScaled(currentBtc, currentPriceScaled);
const currentPortfolioValue = currentEure + currentBtcMarkValue;
const payload = {
metric_version: 1,
baseline_status: baseline ? 'active' : 'awaiting_first_execution',
command_count: commandCount,
result_count: resultCount,
current_price: {
price_id: currentPrice.price_id || null,
observed_at: currentPrice.observed_at || null,
eure_per_btc: String(currentPrice.eure_per_btc),
},
current_inventory: buildInventoryView({
inventory: currentInventory,
btcAsset,
eureAsset,
}),
current_portfolio_value_eure: formatScaledDecimal(currentPortfolioValue),
current_btc_mark_value_eure: formatScaledDecimal(currentBtcMarkValue),
current_eure_cash_value_eure: formatScaledDecimal(currentEure),
trade_pnl_eure: null,
mark_to_market_pnl_eure: null,
price_move_pnl_eure: null,
baseline_portfolio_value_eure_at_baseline_price: null,
baseline_portfolio_value_eure_at_current_price: null,
current_portfolio_value_eure_at_baseline_price: null,
inventory_delta: null,
baseline: null,
};
if (!baseline?.inventory || !baseline?.price) {
return payload;
}
const baselineBtcUnits = String(baseline.inventory.spendable?.[btcAsset.assetId] || '0');
const baselineEureUnits = String(baseline.inventory.spendable?.[eureAsset.assetId] || '0');
const baselineBtc = unitsToScaledDecimal(baselineBtcUnits, btcAsset.decimals);
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 tradePnl = currentPortfolioAtBaselinePrice - baselinePortfolioAtBaselinePrice;
const markToMarketPnl = currentPortfolioValue - baselinePortfolioAtCurrentPrice;
const priceMovePnl = markToMarketPnl - tradePnl;
payload.trade_pnl_eure = formatScaledDecimal(tradePnl);
payload.mark_to_market_pnl_eure = formatScaledDecimal(markToMarketPnl);
payload.price_move_pnl_eure = formatScaledDecimal(priceMovePnl);
payload.baseline_portfolio_value_eure_at_baseline_price = formatScaledDecimal(
baselinePortfolioAtBaselinePrice,
);
payload.baseline_portfolio_value_eure_at_current_price = formatScaledDecimal(
baselinePortfolioAtCurrentPrice,
);
payload.current_portfolio_value_eure_at_baseline_price = formatScaledDecimal(
currentPortfolioAtBaselinePrice,
);
payload.inventory_delta = {
btc_units: (BigInt(currentBtcUnits) - BigInt(baselineBtcUnits)).toString(),
btc: formatScaledDecimal(currentBtc - baselineBtc),
eure_units: (BigInt(currentEureUnits) - BigInt(baselineEureUnits)).toString(),
eure: formatScaledDecimal(currentEure - baselineEure),
};
payload.baseline = {
anchor: baseline.anchor || 'latest_inventory_before_first_command',
command_at: baseline.command_at || null,
price: {
price_id: baseline.price.price_id || null,
observed_at: baseline.price.observed_at || null,
eure_per_btc: String(baseline.price.eure_per_btc),
},
inventory: buildInventoryView({
inventory: baseline.inventory,
btcAsset,
eureAsset,
}),
};
return payload;
}
export function buildPortfolioMetricId({ baselineInventoryId, currentInventoryId, currentPriceId }) {
return [
'portfolio-metric',
baselineInventoryId || 'no-baseline',
currentInventoryId || 'no-current-inventory',
currentPriceId || 'no-current-price',
].join(':');
}
function buildInventoryView({ inventory, btcAsset, eureAsset }) {
const spendable = inventory?.spendable || {};
const btcUnits = String(spendable[btcAsset.assetId] || '0');
const eureUnits = String(spendable[eureAsset.assetId] || '0');
return {
inventory_id: inventory?.inventory_id || null,
synced_at: inventory?.synced_at || null,
btc_units: btcUnits,
btc: formatAssetUnits(btcUnits, btcAsset.decimals),
eure_units: eureUnits,
eure: formatAssetUnits(eureUnits, eureAsset.decimals),
};
}
function unitsToScaledDecimal(units, decimals) {
return BigInt(units || '0') * 10n ** BigInt(VALUE_SCALE - decimals);
}
function formatAssetUnits(units, decimals) {
return formatScaledDecimal(BigInt(units || '0') * 10n ** BigInt(VALUE_SCALE - decimals));
}
function parseScaledDecimal(value) {
const normalized = String(value ?? '0').trim();
const negative = normalized.startsWith('-');
const unsigned = normalized.replace(/^[+-]/, '');
const [wholePart, fractionalPart = ''] = unsigned.split('.');
const whole = BigInt(wholePart || '0');
const fractional = BigInt((fractionalPart.padEnd(VALUE_SCALE, '0')).slice(0, VALUE_SCALE) || '0');
const scaled = (whole * VALUE_FACTOR) + fractional;
return negative ? -scaled : scaled;
}
function multiplyScaled(left, right) {
return (left * right) / VALUE_FACTOR;
}
function formatScaledDecimal(value) {
const negative = value < 0n;
const absolute = negative ? -value : value;
const whole = absolute / VALUE_FACTOR;
const fractional = absolute % VALUE_FACTOR;
if (fractional === 0n) {
return `${negative ? '-' : ''}${whole}`;
}
const fractionalText = fractional.toString().padStart(VALUE_SCALE, '0').replace(/0+$/, '');
return `${negative ? '-' : ''}${whole}.${fractionalText}`;
}