import { median, round } from '@rareindex/shared'; export interface ConstituentSeries { id: string; /** date (YYYY-MM-DD) → value in USD (RIV or repeat-sales level), sorted ascending by date */ points: Array<{ date: string; value: number }>; /** liquidity weight (e.g. sales count over 12m); default 1 */ weight?: number; } export interface IndexPoint { date: string; value: number; constituents: number; coverage: number; /** daily return applied (0 on first day) */ ret: number; } export interface ChainOptions { baseDate: string; baseValue: number; /** publish nothing until this many constituents have values on a day */ minConstituents: number; weighting?: 'equal' | 'liquidity'; /** clamp for a single-constituent daily log return (guards against data glitches) */ maxAbsDailyReturn?: number; dates?: string[]; } function toDayMap(points: ConstituentSeries['points']): Map { const m = new Map(); for (const p of points) if (p.value > 0) m.set(p.date, p.value); return m; } /** All distinct dates across series between baseDate and the last date, ascending. */ export function unionDates(series: ConstituentSeries[], baseDate: string): string[] { const s = new Set(); for (const c of series) for (const p of c.points) if (p.date >= baseDate) s.add(p.date); return [...s].sort(); } /** * Chain-linked index (§3, §190): each day the index moves by the weighted average of the log * returns of constituents that have a value both on that day and on their previous observation, * carried forward on gaps. Composition may change daily without breaking the level (chaining). */ export function chainLinkedIndex(series: ConstituentSeries[], opts: ChainOptions): IndexPoint[] { const dates = opts.dates ?? unionDates(series, opts.baseDate); 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 })); const cap = opts.maxAbsDailyReturn ?? Math.log(2.5); const out: IndexPoint[] = []; let level: number | null = null; for (const date of dates) { const rets: Array<{ r: number; w: number }> = []; let have = 0; for (const c of maps) { const v = c.m.get(date); if (v === undefined) continue; have++; if (c.last !== null && c.last > 0) { let r = Math.log(v / c.last); if (Math.abs(r) > cap) r = Math.sign(r) * cap; rets.push({ r, w: opts.weighting === 'liquidity' ? c.w : 1 }); } c.last = v; } const coverage = series.length ? have / series.length : 0; if (have < opts.minConstituents) { // not enough evidence today: carry the level without publishing a new point continue; } if (level === null) { level = opts.baseValue; out.push({ date, value: level, constituents: have, coverage: round(coverage, 3), ret: 0 }); continue; } // no constituent produced a return today (only first observations): the index cannot move → no point if (rets.length === 0) continue; const wsum = rets.reduce((a, x) => a + x.w, 0); // trimmed weighted mean of log returns: drop the top/bottom 5% when many constituents let ret = 0; if (wsum > 0) { const sorted = [...rets].sort((a, b) => a.r - b.r); const k = sorted.length >= 20 ? Math.floor(sorted.length * 0.05) : 0; const core = sorted.slice(k, sorted.length - k); const cw = core.reduce((a, x) => a + x.w, 0); ret = core.reduce((a, x) => a + x.r * x.w, 0) / cw; } level = level * Math.exp(ret); out.push({ date, value: round(level, 4), constituents: have, coverage: round(coverage, 3), ret: round(ret, 6) }); } return out; } /** Returns over standard horizons from a daily series (uses last point on/before target date). */ export function horizonReturns(points: Array<{ date: string; value: number }>, asOf?: string): Record<'1d' | '7d' | '30d' | '90d' | 'ytd' | '1y' | '3y' | '5y' | '10y' | 'all', number | null> { const out: Record = { '1d': null, '7d': null, '30d': null, '90d': null, ytd: null, '1y': null, '3y': null, '5y': null, '10y': null, all: null }; if (points.length < 2) return out as never; const last = asOf ? points.filter((p) => p.date <= asOf).at(-1) : points.at(-1); if (!last) return out as never; const valueOnOrBefore = (date: string): number | null => { let best: number | null = null; for (const p of points) { if (p.date <= date) best = p.value; else break; } return best; }; const d = new Date(`${last.date}T00:00:00Z`); const shift = (days: number) => new Date(d.getTime() - days * 86_400_000).toISOString().slice(0, 10); const h: Array<[string, string]> = [ ['1d', shift(1)], ['7d', shift(7)], ['30d', shift(30)], ['90d', shift(90)], ['ytd', `${d.getUTCFullYear() - 1}-12-31`], ['1y', shift(365)], ['3y', shift(3 * 365)], ['5y', shift(5 * 365)], ['10y', shift(10 * 365)], ]; for (const [k, date] of h) { if (date < points[0]!.date) continue; const v = valueOnOrBefore(date); out[k] = v && v > 0 ? round(last.value / v - 1, 6) : null; } const first = points[0]!; out.all = first.value > 0 ? round(last.value / first.value - 1, 6) : null; return out as never; } export { median };