82 lines
3.3 KiB
TypeScript
82 lines
3.3 KiB
TypeScript
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) };
|
|
}
|