feat(performance): investment performance vs Moscow Exchange index (WealthLedger/main#54) (#200)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 1m7s
Backend verify / verify (push) Failing after 7m19s

This commit was merged in pull request #200.
This commit is contained in:
2026-11-08 16:45:29 +03:00
parent 84433ee207
commit fee61537b6
28 changed files with 1507 additions and 4 deletions
@@ -0,0 +1,81 @@
import {
add, type Decimal, decimalFromString, divide, formatDecimal, multiply,
} from '#src/common/decimal';
import type { PerformanceFlow } from './performance-flows';
const UNIT_SCALE = 12;
const CENT_SCALE = 2;
const ZERO: Decimal = { scale: 0, value: 0n };
const FIRST = 0;
const NEXT = 1;
const LAST = -1;
const CENT = decimalFromString('1');
export type IndexClose = { close: string; tradeDate: string };
export type IndexPoint = { date: string; netInvested: string; value: string };
function valueAt(units: Decimal, close: string): string {
return formatDecimal(divide(multiply(units, decimalFromString(close)), CENT, CENT_SCALE));
}
/** A closed price on or before every cash flow is mandatory; no invented prices. */
export function shadowIndex(
flows: PerformanceFlow[],
closes: IndexClose[],
from: string,
to: string,
): null | { asOf: string; points: IndexPoint[]; value: string } {
const prices = closes.filter((close) => close.tradeDate <= to)
.sort((a, b) => a.tradeDate.localeCompare(b.tradeDate));
if (!prices.length) return null;
const events = [...flows].sort((a, b) => a.at.valueOf() - b.at.valueOf());
let closeIndex = FIRST;
let eventIndex = FIRST;
let units = ZERO;
let invested = ZERO;
const points: IndexPoint[] = [];
// Advance prices only up to the date of the event, never to the next trading day.
const applyEvent = (event: PerformanceFlow): boolean => {
while (prices[closeIndex + NEXT]?.tradeDate <= event.date) closeIndex++;
// eslint-disable-next-line security/detect-object-injection -- Numeric index advances only through this validated sorted local array.
const price = prices[closeIndex];
if (!price || price.tradeDate > event.date) return false;
units = add(units, divide(event.amount, decimalFromString(price.close), UNIT_SCALE));
// Withdrawals larger than the index position cannot be replayed honestly.
if (units.value < ZERO.value) return false;
invested = add(invested, event.amount);
return true;
};
for (const close of prices) {
// eslint-disable-next-line security/detect-object-injection -- Sequential local iteration bounded by array length.
while (events[eventIndex]?.date <= close.tradeDate) {
// eslint-disable-next-line security/detect-object-injection -- While condition ensures an event exists at this index.
const event = events[eventIndex];
if (!event || !applyEvent(event)) return null;
eventIndex++;
}
if (close.tradeDate >= from) {
points.push({
date: close.tradeDate,
netInvested: formatDecimal(invested),
value: valueAt(units, close.close),
});
}
}
// A flow on a market holiday after the last close still buys at that close.
// eslint-disable-next-line security/detect-object-injection -- Sequential local iteration bounded by array length.
while (events[eventIndex]?.date <= to) {
// eslint-disable-next-line security/detect-object-injection -- While condition ensures an event exists at this index.
const event = events[eventIndex];
if (!event || !applyEvent(event)) return null;
eventIndex++;
}
if (eventIndex !== events.length || !points.length) return null;
const last = points.at(LAST);
const finalPrice = prices.at(LAST);
if (!last || !finalPrice) return null;
return { asOf: last.date, points, value: valueAt(units, finalPrice.close) };
}