export type Observation = { ts: number; date: string; value: number }; export type Regime = 'historical' | 'flat' | 'contraction'; export type ModelOptions = { lookback: number; block: number; regime: Regime; paths?: number; seed?: number; horizonWeeks?: number; endDate?: string }; export const MAX_PROJECTION_DATE = '2036-09-09'; const WEEK_SECONDS = 604800; export function quantile(sorted: number[], p: number): number { if (!sorted.length) throw new Error('Empty distribution'); const i = (sorted.length - 1) * p; const lo = Math.floor(i); return sorted[lo]! + (sorted[Math.ceil(i)]! - sorted[lo]!) * (i - lo); } export function simulate(weekly: Observation[], options: ModelOptions) { const { lookback, block, regime, paths = 5000, seed = 20260909, endDate } = options; if (!Number.isInteger(lookback) || lookback < 2 || lookback >= weekly.length || block < 1 || block > lookback || !Number.isInteger(block) || !Number.isInteger(paths) || paths < 1) throw new Error('Invalid model options'); if (weekly.some((x, i) => !Number.isFinite(x.value) || x.value <= 0 || (i > 0 && x.ts - weekly[i - 1]!.ts !== WEEK_SECONDS))) throw new Error('Invalid weekly observations'); const last = weekly.at(-1)!; let endTs: number; if (endDate !== undefined) { if (options.horizonWeeks !== undefined || !/^\d{4}-\d{2}-\d{2}$/.test(endDate)) throw new Error('Invalid projection horizon'); endTs = Date.parse(endDate + 'T00:00:00Z') / 1000; if (!Number.isFinite(endTs) || new Date(endTs * 1000).toISOString().slice(0, 10) !== endDate) throw new Error('Invalid projection date'); } else { const weeks = options.horizonWeeks ?? 52; if (!Number.isInteger(weeks) || weeks < 1) throw new Error('Invalid projection horizon'); endTs = last.ts + weeks * WEEK_SECONDS; } if (endTs <= last.ts || endTs > Date.parse(MAX_PROJECTION_DATE + 'T00:00:00Z') / 1000) throw new Error('Invalid projection horizon'); const horizonWeeks = (endTs - last.ts) / WEEK_SECONDS; const steps = Math.ceil(horizonWeeks); const sample = weekly.slice(-lookback - 1); const returns = sample.slice(1).map((r, i) => Math.log(r.value / sample[i]!.value)); const mean = returns.reduce((a, b) => a + b, 0) / returns.length; const drift = regime === 'historical' ? 0 : regime === 'flat' ? -mean : -mean - 0.2 / 52; let state = seed >>> 0; const random = () => { state += 0x6D2B79F5; let t = state; t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; const bins: number[][] = Array.from({ length: steps + 1 }, () => []); for (let p = 0; p < paths; p++) { let value = last.value; bins[0]!.push(value); let start = 0; for (let w = 1; w <= steps; w++) { if ((w - 1) % block === 0) start = Math.floor(random() * returns.length); const index = (start + (w - 1) % block) % returns.length; const duration = Math.min(1, horizonWeeks - (w - 1)); value *= Math.exp((returns[index]! + drift) * duration); bins[w]!.push(value); } } const forecast = bins.map((bin, step) => { bin.sort((a, b) => a - b); const week = Math.min(step, horizonWeeks); const ts = step === steps ? endTs : last.ts + step * WEEK_SECONDS; return { week, ts, date: new Date(ts * 1000).toISOString().slice(0, 10), p10: quantile(bin, .1), p50: quantile(bin, .5), p90: quantile(bin, .9), up10: bin.filter(v => v >= last.value * 1.1).length / paths, below: bin.filter(v => v < last.value).length / paths }; }); return { forecast, meanWeeklyLogReturn: mean, annualLogDrift: (mean + drift) * 52, trainingStart: sample[0]!.date, trainingEnd: last.date, paths, seed, block, lookback, regime, horizonWeeks, endDate: forecast.at(-1)!.date }; }