mod common;
use kestrel_chartkit::evaluation::probability::{
block_bootstrap_brier, compute_calibration_metrics, IsotonicCalibrator,
ValidationExperimentManifest,
};
use kestrel_chartkit::evaluation::split::{split_trades_purged, PurgedSplitConfig, TradeSpan};
const GOLDEN: &str = include_str!("fixtures/golden_validation_probability.txt");
#[test]
fn test_golden_frozen_calibrator_invariant_to_test_data() {
let train_data = vec![(20.0, false), (40.0, false), (60.0, true), (80.0, true)];
let calibrator = IsotonicCalibrator::fit(&train_data).unwrap();
let pred_at_50 = calibrator.predict(50.0);
assert_eq!(
pred_at_50, 0.5,
"Midpoint between 40 (prob 0.0) and 60 (prob 1.0) must be 0.5"
);
let _test_set_a = [(50.0, false), (50.0, false), (50.0, false)];
assert_eq!(
calibrator.predict(50.0),
pred_at_50,
"Calibrator must remain strictly frozen regardless of test data"
);
let _test_set_b = [(50.0, true), (50.0, true), (50.0, true)];
assert_eq!(
calibrator.predict(50.0),
pred_at_50,
"Calibrator prediction cannot be influenced by test observations"
);
}
#[test]
fn test_golden_purging_and_embargo_split() {
let config = PurgedSplitConfig {
train_start: 0,
train_end: 100,
test_start: 110, test_end: 200,
embargo_bars: 10,
};
let trades = vec![
TradeSpan {
id: 1,
entry_bar: 10,
exit_bar: 50,
},
TradeSpan {
id: 2,
entry_bar: 85,
exit_bar: 105,
},
TradeSpan {
id: 3,
entry_bar: 95,
exit_bar: 115,
},
TradeSpan {
id: 4,
entry_bar: 120,
exit_bar: 160,
},
];
let split = split_trades_purged(&trades, &config).unwrap();
assert_eq!(split.train_trade_ids, vec![1, 2]);
assert_eq!(
split.purged_trade_ids,
vec![3],
"Trade overlapping test set MUST be purged"
);
assert_eq!(split.test_trade_ids, vec![4]);
}
#[test]
fn test_golden_hand_calc_brier_score_and_brier_skill_score() {
let preds = vec![0.8, 0.7, 0.4, 0.1];
let actuals = vec![true, true, false, false];
let metrics = compute_calibration_metrics(&preds, &actuals, 0.50, 2).unwrap();
assert!((metrics.brier_score - 0.075).abs() < 1e-12);
assert!((metrics.baseline_brier_score - 0.25).abs() < 1e-12);
assert!((metrics.brier_skill_score - 0.70).abs() < 1e-12);
common::assert_close(
metrics.log_loss,
common::golden_value(GOLDEN, "log_loss"),
common::golden_value(GOLDEN, "validation_probability_tolerance"),
"Log-Loss",
);
assert!((metrics.expected_calibration_error - 0.25).abs() < 1e-12);
assert_eq!(metrics.sample_size, 4);
}
#[test]
fn test_golden_isotonic_calibrator_monotonicity() {
let noisy_data = vec![
(10.0, false),
(20.0, true),
(30.0, false),
(40.0, true),
(50.0, false),
(60.0, true),
];
let calibrator = IsotonicCalibrator::fit(&noisy_data).unwrap();
let test_scores = [0.0, 15.0, 25.0, 35.0, 45.0, 55.0, 75.0];
let mut prev_prob = 0.0;
for &s in &test_scores {
let prob = calibrator.predict(s);
assert!(
prob >= prev_prob - 1e-12,
"Isotonic calibrator must be monotonically non-decreasing: at {s} got {prob} < {prev_prob}"
);
assert!((0.0..=1.0).contains(&prob));
prev_prob = prob;
}
}
#[test]
fn test_golden_low_sample_size_visibility() {
let small_preds = vec![0.6, 0.7, 0.8];
let small_actuals = vec![false, true, true];
let metrics = compute_calibration_metrics(&small_preds, &small_actuals, 0.5, 10).unwrap();
assert_eq!(
metrics.sample_size, 3,
"Small sample size N=3 must be preserved explicitly"
);
let manifest = ValidationExperimentManifest {
experiment_id: "exp_oos_001".to_string(),
model_version: "v1.0.0".to_string(),
train_range: (0, 100),
test_range: (110, 150),
embargo_bars: 10,
train_sample_size: 20,
test_sample_size: 3,
metrics,
};
assert_eq!(manifest.test_sample_size, 3);
assert_eq!(manifest.train_sample_size, 20);
}
#[test]
fn test_golden_block_bootstrap_brier() {
let preds = vec![0.8, 0.7, 0.4, 0.1, 0.85, 0.65, 0.35, 0.15];
let actuals = vec![true, true, false, false, true, true, false, false];
let (mean_brier, p05, p95) = block_bootstrap_brier(&preds, &actuals, 2, 100, 42).unwrap();
assert!(mean_brier > 0.0 && mean_brier < 0.25);
assert!(p05 <= mean_brier);
assert!(mean_brier <= p95);
}