///|
/// One rolling-origin forecast evaluation record.
pub struct BacktestFold {
origin : Int
horizon : Int
actual : Array[Double]
predicted : Array[Double]
mae : Double
rmse : Double
bias : Double
coverage : Double
}
///|
/// Summary of a backtest run.
pub struct BacktestReport {
folds : Array[BacktestFold]
mean_mae : Double
mean_rmse : Double
mean_bias : Double
median_rmse : Double
worst_rmse : Double
stability : Double
}
///|
/// Configuration for rolling-origin validation.
pub struct BacktestRule {
minimum_train : Int
horizon : Int
step : Int
confidence : Double
}
///|
pub fn backtest_default_rule() -> BacktestRule {
{ minimum_train: 10, horizon: 3, step: 1, confidence: 0.95 }
}
///|
pub fn backtest_rule(
minimum_train : Int,
horizon : Int,
step : Int,
confidence : Double,
) -> BacktestRule {
{
minimum_train: if minimum_train < 1 {
1
} else {
minimum_train
},
horizon: if horizon < 1 {
1
} else {
horizon
},
step: if step < 1 {
1
} else {
step
},
confidence: if confidence <= 0.0 {
0.5
} else if confidence >= 1.0 {
0.999
} else {
confidence
},
}
}
///|
pub fn backtest_holdout(
data : Array[Double],
origin : Int,
horizon : Int,
) -> Array[Double] {
let result = []
let start = if origin < 0 { 0 } else { origin }
let end = if start + horizon > data.length() {
data.length()
} else {
start + horizon
}
for index = start; index < end; index = index + 1 {
result.push(data[index])
}
result
}
///|
pub fn backtest_prediction(
data : Array[Double],
origin : Int,
strategy : ForecastMethod,
horizon : Int,
) -> Array[Double] {
forecast_with_method(forecast_prefix(data, origin), strategy, horizon)
}
///|
pub fn backtest_fold(
data : Array[Double],
origin : Int,
strategy : ForecastMethod,
horizon : Int,
confidence : Double,
) -> BacktestFold {
let actual = backtest_holdout(data, origin, horizon)
let predicted = backtest_prediction(data, origin, strategy, actual.length())
let points = forecast_points(
forecast_prefix(data, origin),
strategy,
actual.length(),
confidence~,
)
let lower = []
let upper = []
for point in points {
lower.push(point.lower)
upper.push(point.upper)
}
{
origin,
horizon: actual.length(),
actual,
predicted,
mae: forecast_mae(actual, predicted),
rmse: forecast_rmse(actual, predicted),
bias: forecast_bias(actual, predicted),
coverage: forecast_coverage(actual, lower, upper),
}
}
///|
pub fn backtest(
data : Array[Double],
strategy : ForecastMethod,
rule : BacktestRule,
) -> BacktestReport {
let folds = []
let mut origin = rule.minimum_train
while origin < data.length() {
let fold = backtest_fold(
data,
origin,
strategy,
rule.horizon,
rule.confidence,
)
if fold.horizon > 0 {
folds.push(fold)
}
origin += rule.step
}
backtest_report(folds)
}
///|
pub fn backtest_report(folds : Array[BacktestFold]) -> BacktestReport {
let maes = []
let rmses = []
let biases = []
for fold in folds {
maes.push(fold.mae)
rmses.push(fold.rmse)
biases.push(fold.bias)
}
let worst = if rmses.length() == 0 { 0.0 } else { max_value(rmses) }
let average = if rmses.length() == 0 { 0.0 } else { mean(rmses) }
{
folds,
mean_mae: mean(maes),
mean_rmse: average,
mean_bias: mean(biases),
median_rmse: median(rmses),
worst_rmse: worst,
stability: if worst == 0.0 {
1.0
} else {
median(rmses) / worst
},
}
}
///|
pub fn backtest_report_vector(report : BacktestReport) -> Array[Double] {
[
report.folds.length().to_double(),
report.mean_mae,
report.mean_rmse,
report.mean_bias,
report.median_rmse,
report.worst_rmse,
report.stability,
]
}
///|
pub fn backtest_report_lines(report : BacktestReport) -> Array[String] {
[
"folds=" + report.folds.length().to_string(),
"mean_mae=" + report.mean_mae.to_string(),
"mean_rmse=" + report.mean_rmse.to_string(),
"mean_bias=" + report.mean_bias.to_string(),
"median_rmse=" + report.median_rmse.to_string(),
"worst_rmse=" + report.worst_rmse.to_string(),
"stability=" + report.stability.to_string(),
]
}
///|
pub fn backtest_report_string(report : BacktestReport) -> String {
backtest_report_lines(report).join("\n")
}
///|
pub fn backtest_fold_errors(report : BacktestReport) -> Array[Double] {
let result = []
for fold in report.folds {
result.push(fold.rmse)
}
result
}
///|
pub fn backtest_fold_origins(report : BacktestReport) -> Array[Int] {
let result = []
for fold in report.folds {
result.push(fold.origin)
}
result
}
///|
pub fn backtest_fold_biases(report : BacktestReport) -> Array[Double] {
let result = []
for fold in report.folds {
result.push(fold.bias)
}
result
}
///|
pub fn backtest_coverage(report : BacktestReport) -> Double {
let values = []
for fold in report.folds {
values.push(fold.coverage)
}
mean(values)
}
///|
pub fn backtest_method_report(
data : Array[Double],
methods : Array[ForecastMethod],
rule : BacktestRule,
) -> Array[BacktestReport] {
let result = []
for strategy in methods {
result.push(backtest(data, strategy, rule))
}
result
}
///|
pub fn backtest_method_errors(
data : Array[Double],
methods : Array[ForecastMethod],
rule : BacktestRule,
) -> Array[Double] {
let result = []
for strategy in methods {
result.push(backtest(data, strategy, rule).mean_rmse)
}
result
}
///|
pub fn backtest_best_method(
data : Array[Double],
methods : Array[ForecastMethod],
rule : BacktestRule,
) -> ForecastMethod {
if methods.length() == 0 {
return Naive
}
let mut best = methods[0]
let mut score = backtest(data, best, rule).mean_rmse
for strategy in methods {
let current = backtest(data, strategy, rule).mean_rmse
if current < score {
best = strategy
score = current
}
}
best
}
///|
pub fn backtest_gain(
data : Array[Double],
candidate : ForecastMethod,
baseline : ForecastMethod,
rule : BacktestRule,
) -> Double {
let candidate_score = backtest(data, candidate, rule).mean_rmse
let baseline_score = backtest(data, baseline, rule).mean_rmse
if baseline_score == 0.0 {
0.0
} else {
(baseline_score - candidate_score) / baseline_score
}
}
///|
pub fn backtest_residuals(report : BacktestReport) -> Array[Double] {
let result = []
for fold in report.folds {
for index = 0
index < fold.actual.length() && index < fold.predicted.length()
index = index + 1 {
result.push(fold.actual[index] - fold.predicted[index])
}
}
result
}
///|
pub fn backtest_residual_quality(report : BacktestReport) -> Double {
robust_signal_quality(backtest_residuals(report))
}
///|
pub fn backtest_residual_bias(report : BacktestReport) -> Double {
mean(backtest_residuals(report))
}
///|
pub fn backtest_residual_scale(report : BacktestReport) -> Double {
mad(backtest_residuals(report))
}
///|
pub fn backtest_residual_autocorrelation(
report : BacktestReport,
lag : Int,
) -> Double {
robust_autocorrelation(backtest_residuals(report), lag)
}
///|
pub fn backtest_error_quantiles(report : BacktestReport) -> Array[Double] {
let errors = backtest_fold_errors(report)
[quantile(errors, 0.05), quantile(errors, 0.5), quantile(errors, 0.95)]
}
///|
pub fn backtest_error_outliers(
report : BacktestReport,
threshold : Double,
) -> Array[Int] {
outlier_indices_z(backtest_fold_errors(report), threshold~)
}
///|
pub fn backtest_stable(report : BacktestReport, tolerance : Double) -> Bool {
report.stability >= tolerance
}
///|
pub fn backtest_improvement(
candidate : BacktestReport,
baseline : BacktestReport,
) -> Double {
if baseline.mean_rmse == 0.0 {
0.0
} else {
(baseline.mean_rmse - candidate.mean_rmse) / baseline.mean_rmse
}
}
///|
pub fn backtest_walk_forward_scores(
data : Array[Double],
strategy : ForecastMethod,
minimum_train : Int,
horizon : Int,
step : Int,
) -> Array[Double] {
backtest_fold_errors(
backtest(data, strategy, backtest_rule(minimum_train, horizon, step, 0.95)),
)
}
///|
pub fn backtest_learning_curve(
data : Array[Double],
strategy : ForecastMethod,
rule : BacktestRule,
points : Int,
) -> Array[Double] {
let result = []
let count = if points < 1 { 1 } else { points }
for index = 1; index <= count; index = index + 1 {
let size = data.length() * index / count
if size >= rule.minimum_train {
result.push(
backtest(forecast_prefix(data, size), strategy, rule).mean_rmse,
)
} else {
result.push(0.0)
}
}
result
}
///|
pub fn backtest_regime_scores(
data : Array[Double],
segments : Int,
strategy : ForecastMethod,
rule : BacktestRule,
) -> Array[Double] {
let result = []
for values in segment_values(data, segments) {
result.push(backtest(values, strategy, rule).mean_rmse)
}
result
}
///|
pub fn backtest_regime_stability(
data : Array[Double],
segments : Int,
strategy : ForecastMethod,
rule : BacktestRule,
) -> Double {
let scores = backtest_regime_scores(data, segments, strategy, rule)
if scores.length() == 0 {
0.0
} else {
1.0 / (1.0 + mad(scores))
}
}
///|
pub fn backtest_forecast_quality(
data : Array[Double],
strategy : ForecastMethod,
rule : BacktestRule,
) -> Double {
let report = backtest(data, strategy, rule)
1.0 /
(1.0 + report.mean_rmse + abs_double(report.mean_bias)) *
report.stability
}