All checks were successful
deploy / deploy (push) Successful in 22s
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.
162 lines
6.3 KiB
JavaScript
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}`;
|
|
}
|