SPB Git forge

spb/rareindex

Public
54commits 1branches 0releases
7.1 MBsize
maindefault branch
10 days agolast push
TypeScript 61.9% HTML 37.2% SQL 0.7%
5.2 KB · 135 lines typescript
Raw Blame History
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