/**
* Forecasting utilities and backtest evaluation helpers
*
* Provides functions for evaluating forecast accuracy and computing
* forecast statistics across different models.
*/
import { mean, stdDev, percentile } from "./utils";
export interface ForecastDistribution {
/** Distribution mean */
mean: number;
/** Distribution median */
median: number;
/** Distribution standard deviation */
std: number;
/** 5th percentile */
percentile5: number;
/** 25th percentile */
percentile25: number;
/** 75th percentile */
percentile75: number;
/** 95th percentile */
percentile95: number;
/** Probability of price being above target (e.g., spot * 1.2) */
probAboveTarget?: number;
}
export interface ForecastEvaluation {
/** Forecast mean */
forecastMean: number;
/** Realized price */
realizedPrice: number;
/** Absolute error */
absoluteError: number;
/** Relative error (signed) */
relativeError: number;
/** Percentile of realized in forecast distribution */
realizedPercentile: number;
/** Whether realized was inside 95% CI */
insideCI95: boolean;
}
export interface ForecastBacktestResult {
/** Model name */
model: string;
/** Number of forecast periods evaluated */
periods: number;
/** Mean absolute error */
mae: number;
/** Mean relative error */
mre: number;
/** Root mean square error */
rmse: number;
/** 95% CI coverage rate (should be ~0.95 for well-calibrated model) */
coverage95: number;
/** Average realized percentile (should be ~50% for unbiased model) */
avgRealizedPercentile: number;
/** Individual period evaluations (sample) */
evaluations: ForecastEvaluation[];
}
/**
* Compute distribution statistics from terminal prices
*/
export function computeDistributionStats(
terminalPrices: number[],
targetPrice?: number
): ForecastDistribution {
if (terminalPrices.length === 0) {
throw new Error("Cannot compute stats from empty array");
}
const sorted = [...terminalPrices].sort((a, b) => a - b);
const n = terminalPrices.length;
const result: ForecastDistribution = {
mean: mean(terminalPrices),
median: percentile(sorted, 0.5),
std: stdDev(terminalPrices),
percentile5: percentile(sorted, 0.05),
percentile25: percentile(sorted, 0.25),
percentile75: percentile(sorted, 0.75),
percentile95: percentile(sorted, 0.95),
};
if (targetPrice !== undefined) {
const countAbove = terminalPrices.filter((p) => p >= targetPrice).length;
result.probAboveTarget = countAbove / n;
}
return result;
}
/**
* Evaluate a single forecast against realized outcome
*/
export function evaluateForecast(
terminalPrices: number[],
realizedPrice: number
): ForecastEvaluation {
const sorted = [...terminalPrices].sort((a, b) => a - b);
const n = terminalPrices.length;
const forecastMean = mean(terminalPrices);
// Find percentile of realized price in distribution
let countBelow = 0;
for (const p of sorted) {
if (p < realizedPrice) countBelow++;
else break;
}
const realizedPercentile = countBelow / n;
// 95% CI bounds
const ci95Lower = percentile(sorted, 0.025);
const ci95Upper = percentile(sorted, 0.975);
const insideCI95 = realizedPrice >= ci95Lower && realizedPrice <= ci95Upper;
return {
forecastMean,
realizedPrice,
absoluteError: Math.abs(forecastMean - realizedPrice),
relativeError: (forecastMean - realizedPrice) / realizedPrice,
realizedPercentile,
insideCI95,
};
}
/**
* Aggregate multiple forecast evaluations into summary statistics
*/
export function aggregateForecastEvaluations(
evaluations: ForecastEvaluation[],
model: string
): ForecastBacktestResult {
if (evaluations.length === 0) {
return {
model,
periods: 0,
mae: NaN,
mre: NaN,
rmse: NaN,
coverage95: NaN,
avgRealizedPercentile: NaN,
evaluations: [],
};
}
const n = evaluations.length;
const mae = mean(evaluations.map((e) => e.absoluteError));
const mre = mean(evaluations.map((e) => e.relativeError));
const rmse = Math.sqrt(mean(evaluations.map((e) => e.absoluteError ** 2)));
const coverage95 = evaluations.filter((e) => e.insideCI95).length / n;
const avgRealizedPercentile = mean(evaluations.map((e) => e.realizedPercentile));
return {
model,
periods: n,
mae,
mre,
rmse,
coverage95,
avgRealizedPercentile,
evaluations: evaluations.slice(0, 20), // Return sample of individual evaluations
};
}
/**
* Model disagreement metrics
*/
export interface DisagreementMetrics {
/** Spread of medians across models */
medianSpread: number;
/** Spread of 95th percentiles */
percentile95Spread: number;
/** Spread of probUp20 estimates */
probUp20Spread: number;
/** Overall disagreement level */
level: "LOW" | "MEDIUM" | "HIGH";
}
/**
* Compute disagreement metrics across multiple model forecasts
*/
export function computeDisagreement(
forecasts: Array<{ median: number; percentile95: number; probUp20: number }>
): DisagreementMetrics {
if (forecasts.length < 2) {
return {
medianSpread: 0,
percentile95Spread: 0,
probUp20Spread: 0,
level: "LOW",
};
}
const medians = forecasts.map((f) => f.median);
const p95s = forecasts.map((f) => f.percentile95);
const probs = forecasts.map((f) => f.probUp20);
const medianMean = mean(medians);
const medianSpread = medianMean > 0
? (Math.max(...medians) - Math.min(...medians)) / medianMean
: 0;
const p95Mean = mean(p95s);
const percentile95Spread = p95Mean > 0
? (Math.max(...p95s) - Math.min(...p95s)) / p95Mean
: 0;
const probUp20Spread = Math.max(...probs) - Math.min(...probs);
// Classify disagreement level
let level: "LOW" | "MEDIUM" | "HIGH";
const avgSpread = (medianSpread + percentile95Spread + probUp20Spread) / 3;
if (avgSpread < 0.1) {
level = "LOW";
} else if (avgSpread < 0.25) {
level = "MEDIUM";
} else {
level = "HIGH";
}
return {
medianSpread,
percentile95Spread,
probUp20Spread,
level,
};
}
/**
* Convert horizon in days to years
*/
export function daysToYears(days: number): number {
return days / 365.25;
}
/**
* Add business days to a date (simple approximation)
*/
export function addBusinessDays(date: Date, days: number): Date {
const result = new Date(date);
let remaining = days;
while (remaining > 0) {
result.setDate(result.getDate() + 1);
const dayOfWeek = result.getDay();
if (dayOfWeek !== 0 && dayOfWeek !== 6) {
remaining--;
}
}
return result;
}
/**
* Get calendar days between two dates
*/
export function daysBetween(start: Date, end: Date): number {
const msPerDay = 24 * 60 * 60 * 1000;
return Math.round((end.getTime() - start.getTime()) / msPerDay);
}