/** * Repeat-sales index (Bailey–Muth–Nourse / Case–Shiller flavour, §190). Each pair of consecutive * sales of the SAME variant gives one observation: log(p2/p1) = Σ_t D_t·β_t where D_t = +1 for the * period of the second sale, −1 for the first. Solved by ordinary least squares via normal * equations (small dense system; periods are months). Level_t = exp(β_t), first period = 1. */ export interface RepeatSale { variantId: string; date: string; // YYYY-MM-DD priceUsd: number; } export interface RepeatSalesResult { periods: string[]; // YYYY-MM levels: number[]; // relative to first period = 1 pairs: number; /** number of pairs contributing to each period */ support: number[]; } export function monthKey(date: string): string { return date.slice(0, 7); } function solveNormal(A: number[][], b: number[]): number[] | null { const n = b.length; const M = A.map((row, i) => [...row, b[i]!]); for (let col = 0; col < n; col++) { let piv = col; for (let r = col + 1; r < n; r++) if (Math.abs(M[r]![col]!) > Math.abs(M[piv]![col]!)) piv = r; if (Math.abs(M[piv]![col]!) < 1e-12) return null; [M[col], M[piv]] = [M[piv]!, M[col]!]; for (let r = 0; r < n; r++) { if (r === col) continue; const f = M[r]![col]! / M[col]![col]!; if (f === 0) continue; for (let c = col; c <= n; c++) M[r]![c]! -= f * M[col]![c]!; } } return M.map((row, i) => row[n]! / row[i]!); } export function repeatSalesIndex(sales: RepeatSale[], opts: { minPairs?: number; maxAbsLogReturnPerMonth?: number } = {}): RepeatSalesResult | null { const minPairs = opts.minPairs ?? 10; const byVariant = new Map(); for (const s of sales) if (s.priceUsd > 0) byVariant.set(s.variantId, [...(byVariant.get(s.variantId) ?? []), s]); const pairs: Array<{ t1: string; t2: string; y: number }> = []; for (const list of byVariant.values()) { list.sort((a, b) => a.date.localeCompare(b.date)); for (let i = 1; i < list.length; i++) { const a = list[i - 1]!; const b = list[i]!; const t1 = monthKey(a.date); const t2 = monthKey(b.date); if (t1 === t2) continue; const y = Math.log(b.priceUsd / a.priceUsd); const months = monthsBetween(t1, t2); const cap = (opts.maxAbsLogReturnPerMonth ?? 0.5) * Math.max(1, months); if (Math.abs(y) > cap) continue; // glitch guard pairs.push({ t1, t2, y }); } } if (pairs.length < minPairs) return null; const periods = [...new Set(pairs.flatMap((p) => [p.t1, p.t2]))].sort(); const idx = new Map(periods.map((p, i) => [p, i])); const k = periods.length - 1; // first period fixed at 0 if (k < 1) return null; const A: number[][] = Array.from({ length: k }, () => Array(k).fill(0)); const b: number[] = Array(k).fill(0); const support = Array(periods.length).fill(0) as number[]; for (const p of pairs) { const i1 = idx.get(p.t1)! - 1; const i2 = idx.get(p.t2)! - 1; support[i1 + 1]!++; support[i2 + 1]!++; // x vector: +1 at i2, -1 at i1 (skip index -1 = base period) const entries: Array<[number, number]> = []; if (i2 >= 0) entries.push([i2, 1]); if (i1 >= 0) entries.push([i1, -1]); for (const [r, xr] of entries) { b[r]! += xr * p.y; for (const [c, xc] of entries) A[r]![c]! += xr * xc; } } // ridge for stability on sparse months for (let i = 0; i < k; i++) A[i]![i]! += 1e-6; const beta = solveNormal(A, b); if (!beta) return null; return { periods, levels: [1, ...beta.map((x) => Math.exp(x))], pairs: pairs.length, support }; } export function monthsBetween(a: string, b: string): number { const [ya, ma] = a.split('-').map(Number) as [number, number]; const [yb, mb] = b.split('-').map(Number) as [number, number]; return (yb - ya) * 12 + (mb - ma); }