feat(performance): investment performance vs Moscow Exchange index (WealthLedger/main#54) (#200)
This commit was merged in pull request #200.
This commit is contained in:
@@ -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) };
|
||||
}
|
||||
Reference in New Issue
Block a user