TypeScript 61.9%
HTML 37.2%
SQL 0.7%
1import { median, round } from '@rareindex/shared';23export interface ConstituentSeries {4 id: string;5 /** date (YYYY-MM-DD) → value in USD (RIV or repeat-sales level), sorted ascending by date */6 points: Array<{ date: string; value: number }>;7 /** liquidity weight (e.g. sales count over 12m); default 1 */8 weight?: number;9}1011export interface IndexPoint {12 date: string;13 value: number;14 constituents: number;15 coverage: number;16 /** daily return applied (0 on first day) */17 ret: number;18}1920export interface ChainOptions {21 baseDate: string;22 baseValue: number;23 /** publish nothing until this many constituents have values on a day */24 minConstituents: number;25 weighting?: 'equal' | 'liquidity';26 /** clamp for a single-constituent daily log return (guards against data glitches) */27 maxAbsDailyReturn?: number;28 dates?: string[];29}3031function toDayMap(points: ConstituentSeries['points']): Map<string, number> {32 const m = new Map<string, number>();33 for (const p of points) if (p.value > 0) m.set(p.date, p.value);34 return m;35}3637/** All distinct dates across series between baseDate and the last date, ascending. */38export function unionDates(series: ConstituentSeries[], baseDate: string): string[] {39 const s = new Set<string>();40 for (const c of series) for (const p of c.points) if (p.date >= baseDate) s.add(p.date);41 return [...s].sort();42}4344/**45 * Chain-linked index (§3, §190): each day the index moves by the weighted average of the log46 * returns of constituents that have a value both on that day and on their previous observation,47 * carried forward on gaps. Composition may change daily without breaking the level (chaining).48 */49export function chainLinkedIndex(series: ConstituentSeries[], opts: ChainOptions): IndexPoint[] {50 const dates = opts.dates ?? unionDates(series, opts.baseDate);51 const maps = series.map((c) => ({ id: c.id, w: Math.max(c.weight ?? 1, 1e-9), m: toDayMap(c.points), last: null as number | null }));52 const cap = opts.maxAbsDailyReturn ?? Math.log(2.5);53 const out: IndexPoint[] = [];54 let level: number | null = null;55 for (const date of dates) {56 const rets: Array<{ r: number; w: number }> = [];57 let have = 0;58 for (const c of maps) {59 const v = c.m.get(date);60 if (v === undefined) continue;61 have++;62 if (c.last !== null && c.last > 0) {63 let r = Math.log(v / c.last);64 if (Math.abs(r) > cap) r = Math.sign(r) * cap;65 rets.push({ r, w: opts.weighting === 'liquidity' ? c.w : 1 });66 }67 c.last = v;68 }69 const coverage = series.length ? have / series.length : 0;70 if (have < opts.minConstituents) {71 // not enough evidence today: carry the level without publishing a new point72 continue;73 }74 if (level === null) {75 level = opts.baseValue;76 out.push({ date, value: level, constituents: have, coverage: round(coverage, 3), ret: 0 });77 continue;78 }79 // no constituent produced a return today (only first observations): the index cannot move → no point80 if (rets.length === 0) continue;81 const wsum = rets.reduce((a, x) => a + x.w, 0);82 // trimmed weighted mean of log returns: drop the top/bottom 5% when many constituents83 let ret = 0;84 if (wsum > 0) {85 const sorted = [...rets].sort((a, b) => a.r - b.r);86 const k = sorted.length >= 20 ? Math.floor(sorted.length * 0.05) : 0;87 const core = sorted.slice(k, sorted.length - k);88 const cw = core.reduce((a, x) => a + x.w, 0);89 ret = core.reduce((a, x) => a + x.r * x.w, 0) / cw;90 }91 level = level * Math.exp(ret);92 out.push({ date, value: round(level, 4), constituents: have, coverage: round(coverage, 3), ret: round(ret, 6) });93 }94 return out;95}9697/** Returns over standard horizons from a daily series (uses last point on/before target date). */98export function horizonReturns(points: Array<{ date: string; value: number }>, asOf?: string): Record<'1d' | '7d' | '30d' | '90d' | 'ytd' | '1y' | '3y' | '5y' | '10y' | 'all', number | null> {99 const out: Record<string, number | null> = { '1d': null, '7d': null, '30d': null, '90d': null, ytd: null, '1y': null, '3y': null, '5y': null, '10y': null, all: null };100 if (points.length < 2) return out as never;101 const last = asOf ? points.filter((p) => p.date <= asOf).at(-1) : points.at(-1);102 if (!last) return out as never;103 const valueOnOrBefore = (date: string): number | null => {104 let best: number | null = null;105 for (const p of points) {106 if (p.date <= date) best = p.value;107 else break;108 }109 return best;110 };111 const d = new Date(`${last.date}T00:00:00Z`);112 const shift = (days: number) => new Date(d.getTime() - days * 86_400_000).toISOString().slice(0, 10);113 const h: Array<[string, string]> = [114 ['1d', shift(1)],115 ['7d', shift(7)],116 ['30d', shift(30)],117 ['90d', shift(90)],118 ['ytd', `${d.getUTCFullYear() - 1}-12-31`],119 ['1y', shift(365)],120 ['3y', shift(3 * 365)],121 ['5y', shift(5 * 365)],122 ['10y', shift(10 * 365)],123 ];124 for (const [k, date] of h) {125 if (date < points[0]!.date) continue;126 const v = valueOnOrBefore(date);127 out[k] = v && v > 0 ? round(last.value / v - 1, 6) : null;128 }129 const first = points[0]!;130 out.all = first.value > 0 ? round(last.value / first.value - 1, 6) : null;131 return out as never;132}133134export { median };135