/** Small, dependency-free numeric helpers used by valuation, indices and scores. */ export function median(values: number[]): number | null { if (values.length === 0) return null; const s = [...values].sort((a, b) => a - b); const mid = Math.floor(s.length / 2); return s.length % 2 ? s[mid]! : (s[mid - 1]! + s[mid]!) / 2; } export function mean(values: number[]): number | null { if (values.length === 0) return null; return values.reduce((a, b) => a + b, 0) / values.length; } export function quantile(values: number[], q: number): number | null { if (values.length === 0) return null; const s = [...values].sort((a, b) => a - b); const pos = (s.length - 1) * q; const lo = Math.floor(pos); const hi = Math.ceil(pos); if (lo === hi) return s[lo]!; return s[lo]! + (s[hi]! - s[lo]!) * (pos - lo); } /** Trimmed mean dropping `trim` fraction from each tail (e.g. 0.1). */ export function trimmedMean(values: number[], trim = 0.1): number | null { if (values.length === 0) return null; const s = [...values].sort((a, b) => a - b); const k = Math.floor(s.length * trim); const core = s.slice(k, s.length - k); return mean(core.length ? core : s); } export function stddev(values: number[]): number | null { if (values.length < 2) return null; const m = mean(values)!; const v = values.reduce((acc, x) => acc + (x - m) ** 2, 0) / (values.length - 1); return Math.sqrt(v); } /** Median absolute deviation (robust dispersion). */ export function mad(values: number[]): number | null { const m = median(values); if (m === null) return null; return median(values.map((v) => Math.abs(v - m))); } /** Exponentially weighted mean; `halfLifeDays` controls decay by age (days). Most recent = highest weight. */ export function ewMean(points: Array<{ value: number; ageDays: number }>, halfLifeDays = 60): number | null { if (points.length === 0) return null; let num = 0; let den = 0; for (const p of points) { const w = Math.pow(0.5, Math.max(0, p.ageDays) / halfLifeDays); num += w * p.value; den += w; } return den > 0 ? num / den : null; } export function weightedMean(points: Array<{ value: number; weight: number }>): number | null { let num = 0; let den = 0; for (const p of points) { num += p.value * p.weight; den += p.weight; } return den > 0 ? num / den : null; } export function clamp(x: number, lo: number, hi: number): number { return Math.min(hi, Math.max(lo, x)); } /** Percent change guarded against zero/null. */ export function pctChange(from: number | null | undefined, to: number | null | undefined): number | null { if (from === null || from === undefined || to === null || to === undefined || from === 0) return null; return (to - from) / from; } /** Max drawdown of a value series (0..1). */ export function maxDrawdown(series: number[]): number | null { if (series.length < 2) return null; let peak = series[0]!; let mdd = 0; for (const v of series) { if (v > peak) peak = v; if (peak > 0) mdd = Math.max(mdd, (peak - v) / peak); } return mdd; } /** Annualised volatility of log returns from an evenly spaced series. */ export function volatility(series: number[], periodsPerYear = 365): number | null { if (series.length < 3) return null; const rets: number[] = []; for (let i = 1; i < series.length; i++) { const a = series[i - 1]!; const b = series[i]!; if (a > 0 && b > 0) rets.push(Math.log(b / a)); } const sd = stddev(rets); return sd === null ? null : sd * Math.sqrt(periodsPerYear); } export function round(x: number, digits = 2): number { const f = 10 ** digits; return Math.round(x * f) / f; }