use crate::core::errors::WbtError;
use crate::core::utils::std_inline;
use chrono::{Datelike, NaiveDate};
use serde_json::{Value, json};
use std::collections::HashMap;
const RETURN_EPSILON: f64 = 1e-9;
const VOL_EPSILON: f64 = 1e-12;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Mode {
History,
Recent,
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct YearMetric {
pub year: i32,
pub abs_return: f64,
pub alpha_return: f64,
pub days: usize,
pub is_complete_year: bool,
pub year_passed: bool,
}
fn parse_date_key_strict(dk: i32) -> Result<NaiveDate, WbtError> {
let y = dk / 10000;
let m = (dk / 100) % 100;
let d = dk % 100;
NaiveDate::from_ymd_opt(y, m as u32, d as u32).ok_or_else(|| {
WbtError::InvalidInput(format!("invalid date_key {dk}: not a valid YYYYMMDD"))
})
}
fn local_max_drawdown_abs(returns: &[f64]) -> f64 {
if returns.is_empty() {
return 0.0;
}
let mut nav = 1.0_f64;
let mut peak = 1.0_f64;
let mut max_dd = 0.0_f64;
for &r in returns {
if !r.is_finite() {
return f64::NAN;
}
nav *= 1.0 + r;
if nav > peak {
peak = nav;
}
if peak > 0.0 {
let dd = (peak - nav) / peak;
if dd > max_dd {
max_dd = dd;
}
}
}
max_dd
}
pub(crate) fn compute_vol_adjusted_alpha(
long: &[f64],
bench: &[f64],
yearly_days: usize,
target_vol: f64,
) -> Option<Vec<f64>> {
if long.len() != bench.len() {
return None;
}
if !target_vol.is_finite() || target_vol <= 0.0 {
return None;
}
if long.iter().any(|v| !v.is_finite()) || bench.iter().any(|v| !v.is_finite()) {
return None;
}
let yd_sqrt = (yearly_days as f64).sqrt();
let long_vol = std_inline(long) * yd_sqrt;
let bench_vol = std_inline(bench) * yd_sqrt;
if !long_vol.is_finite()
|| !bench_vol.is_finite()
|| long_vol < VOL_EPSILON
|| bench_vol < VOL_EPSILON
{
return None;
}
let long_scale = target_vol / long_vol;
let bench_scale = target_vol / bench_vol;
Some(
long.iter()
.zip(bench.iter())
.map(|(&l, &b)| l * long_scale - b * bench_scale)
.collect(),
)
}
pub(crate) fn compute_yearly_metrics(
date_keys: &[i32],
strategy_daily: &[f64],
alpha_daily: &[f64],
min_year_days: usize,
) -> Result<Vec<YearMetric>, WbtError> {
use std::collections::BTreeMap;
if date_keys.len() != strategy_daily.len() || date_keys.len() != alpha_daily.len() {
return Err(WbtError::InvalidInput(format!(
"compute_yearly_metrics length mismatch: date_keys={}, strategy_daily={}, alpha_daily={}",
date_keys.len(),
strategy_daily.len(),
alpha_daily.len()
)));
}
let mut buckets: BTreeMap<i32, (Vec<f64>, Vec<f64>)> = BTreeMap::new();
for (i, &dk) in date_keys.iter().enumerate() {
let nd = parse_date_key_strict(dk)?;
let year = nd.year();
let entry = buckets.entry(year).or_default();
entry.0.push(strategy_daily[i]);
entry.1.push(alpha_daily[i]);
}
Ok(buckets
.into_iter()
.map(|(year, (strat, alpha))| {
let days = strat.len();
let abs_return = strat.iter().fold(1.0_f64, |acc, r| acc * (1.0 + r)) - 1.0;
let alpha_return = alpha.iter().fold(1.0_f64, |acc, r| acc * (1.0 + r)) - 1.0;
YearMetric {
year,
abs_return,
alpha_return,
days,
is_complete_year: days >= min_year_days,
year_passed: false,
}
})
.collect())
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct RecentMetric {
pub start_date: NaiveDate,
pub end_date: NaiveDate,
pub actual_days: usize,
pub abs_return: f64,
pub alpha_return: f64,
pub alpha_max_drawdown: f64,
}
pub(crate) fn compute_recent_window(
date_keys: &[i32],
strategy_daily: &[f64],
alpha_daily: &[f64],
recent_days: usize,
) -> Result<RecentMetric, WbtError> {
if date_keys.is_empty() {
return Err(WbtError::InvalidInput(
"compute_recent_window: date_keys is empty".into(),
));
}
if recent_days == 0 {
return Err(WbtError::InvalidInput(
"compute_recent_window: recent_days must be > 0".into(),
));
}
if date_keys.len() != strategy_daily.len() || date_keys.len() != alpha_daily.len() {
return Err(WbtError::InvalidInput(format!(
"compute_recent_window length mismatch: date_keys={}, strategy_daily={}, alpha_daily={}",
date_keys.len(),
strategy_daily.len(),
alpha_daily.len()
)));
}
let n = date_keys.len();
let actual = n.min(recent_days);
let start_idx = n - actual;
let strat_w = &strategy_daily[start_idx..];
let alpha_w = &alpha_daily[start_idx..];
let abs_return = strat_w.iter().fold(1.0_f64, |acc, r| acc * (1.0 + r)) - 1.0;
let alpha_return = alpha_w.iter().fold(1.0_f64, |acc, r| acc * (1.0 + r)) - 1.0;
let alpha_max_drawdown = local_max_drawdown_abs(alpha_w);
let start_date = parse_date_key_strict(date_keys[start_idx])?;
let end_date = parse_date_key_strict(date_keys[n - 1])?;
Ok(RecentMetric {
start_date,
end_date,
actual_days: actual,
abs_return,
alpha_return,
alpha_max_drawdown,
})
}
pub(crate) fn compute_history_max_dd_full(alpha_daily: &[f64]) -> f64 {
local_max_drawdown_abs(alpha_daily)
}
pub(crate) fn compute_history_max_dd_excl_recent(
alpha_daily: &[f64],
recent_days: usize,
min_history_days: usize,
) -> Option<f64> {
let n = alpha_daily.len();
let recent_actual = n.min(recent_days);
let head_len = n.saturating_sub(recent_actual);
if head_len < min_history_days || head_len == 0 {
return None;
}
Some(local_max_drawdown_abs(&alpha_daily[..head_len]))
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn judge(
mode: Mode,
date_keys: &[i32],
strategy_daily: &[f64],
bench_daily: &[f64],
long_daily: &[f64],
yearly_days: usize,
target_vol: f64,
max_dd_threshold: f64,
min_year_days: usize,
recent_days: usize,
min_history_days: usize,
) -> Result<HashMap<String, Value>, WbtError> {
let n = date_keys.len();
if n == 0 {
return Err(WbtError::InvalidInput("date_keys is empty".into()));
}
if strategy_daily.len() != n || bench_daily.len() != n || long_daily.len() != n {
return Err(WbtError::InvalidInput(format!(
"input length mismatch: date_keys={}, strategy={}, bench={}, long={}",
n,
strategy_daily.len(),
bench_daily.len(),
long_daily.len()
)));
}
if matches!(mode, Mode::Recent) && recent_days == 0 {
return Err(WbtError::InvalidInput(
"recent_days must be > 0 for recent mode".into(),
));
}
if !target_vol.is_finite() || target_vol <= 0.0 {
return Err(WbtError::InvalidInput(format!(
"target_vol must be positive and finite, got {target_vol}"
)));
}
if !max_dd_threshold.is_finite() || max_dd_threshold <= 0.0 {
return Err(WbtError::InvalidInput(format!(
"max_dd_threshold must be positive and finite, got {max_dd_threshold}"
)));
}
for &dk in date_keys {
parse_date_key_strict(dk)?;
}
if strategy_daily.iter().any(|v| !v.is_finite()) {
return Err(WbtError::InvalidInput(
"strategy_daily contains NaN/Inf".into(),
));
}
let alpha_opt = compute_vol_adjusted_alpha(long_daily, bench_daily, yearly_days, target_vol);
let alpha_degenerate = alpha_opt.is_none();
let alpha_daily = alpha_opt.unwrap_or_else(|| vec![0.0; n]);
let mut out: HashMap<String, Value> = HashMap::new();
let mut reasons: Vec<String> = Vec::new();
if alpha_degenerate {
reasons
.push("alpha series degenerate (NaN/Inf or vol of long/bench below threshold)".into());
}
match mode {
Mode::History => {
out.insert("mode".into(), json!("history"));
let mut yearly =
compute_yearly_metrics(date_keys, strategy_daily, &alpha_daily, min_year_days)?;
for m in yearly.iter_mut() {
if m.is_complete_year {
m.year_passed =
m.abs_return > RETURN_EPSILON || m.alpha_return > RETURN_EPSILON;
}
}
let complete: Vec<&YearMetric> = yearly.iter().filter(|m| m.is_complete_year).collect();
let cond_yearly = if complete.is_empty() {
reasons.push("no complete year".into());
false
} else {
let mut ok = true;
for m in &complete {
if !m.year_passed {
reasons.push(format!(
"year {} both metrics ≤ ε (abs_return={:.6}, alpha_return={:.6})",
m.year, m.abs_return, m.alpha_return
));
ok = false;
}
}
ok
};
let history_dd_full = compute_history_max_dd_full(&alpha_daily);
let cond_dd = if alpha_degenerate {
false
} else {
history_dd_full < max_dd_threshold
};
if !alpha_degenerate && !cond_dd {
reasons.push(format!(
"history_alpha_max_drawdown {:.6} ≥ threshold {:.6}",
history_dd_full, max_dd_threshold
));
}
out.insert(
"yearly_metrics".into(),
Value::Array(yearly.iter().map(year_metric_to_value).collect()),
);
out.insert("complete_year_count".into(), json!(complete.len()));
out.insert(
"history_alpha_max_drawdown".into(),
if alpha_degenerate {
Value::Null
} else {
json!(history_dd_full)
},
);
out.insert("alpha_degenerate".into(), json!(alpha_degenerate));
out.insert("cond_yearly_passed".into(), json!(cond_yearly));
out.insert("cond_history_dd_passed".into(), json!(cond_dd));
out.insert("is_good".into(), json!(cond_yearly && cond_dd));
}
Mode::Recent => {
out.insert("mode".into(), json!("recent"));
let recent =
compute_recent_window(date_keys, strategy_daily, &alpha_daily, recent_days)?;
let history_dd_excl = if alpha_degenerate {
None
} else {
compute_history_max_dd_excl_recent(&alpha_daily, recent_days, min_history_days)
};
let cond_return_value =
recent.abs_return > RETURN_EPSILON || recent.alpha_return > RETURN_EPSILON;
let cond_return = !alpha_degenerate && cond_return_value;
if !alpha_degenerate && !cond_return {
reasons.push(format!(
"recent abs_return {:.6} ≤ ε and alpha_return {:.6} ≤ ε",
recent.abs_return, recent.alpha_return
));
}
let history_window_short = history_dd_excl.is_none() && !alpha_degenerate;
let (cond_dd, history_dd_excl_value) = if alpha_degenerate {
(false, Value::Null)
} else {
match history_dd_excl {
Some(h) => {
let ok = recent.alpha_max_drawdown < max_dd_threshold
&& recent.alpha_max_drawdown < h;
if !ok {
if recent.alpha_max_drawdown >= max_dd_threshold {
reasons.push(format!(
"recent_alpha_max_drawdown {:.6} ≥ threshold {:.6}",
recent.alpha_max_drawdown, max_dd_threshold
));
}
if recent.alpha_max_drawdown >= h {
reasons.push(format!(
"recent_alpha_max_drawdown {:.6} ≥ history_excl {:.6}",
recent.alpha_max_drawdown, h
));
}
}
(ok, json!(h))
}
None => {
reasons.push(format!(
"history window too short (head < min_history_days={min_history_days})"
));
(false, Value::Null)
}
}
};
out.insert(
"recent_start_date".into(),
json!(recent.start_date.format("%Y-%m-%d").to_string()),
);
out.insert(
"recent_end_date".into(),
json!(recent.end_date.format("%Y-%m-%d").to_string()),
);
out.insert("recent_actual_days".into(), json!(recent.actual_days));
out.insert("recent_abs_return".into(), json!(recent.abs_return));
out.insert(
"recent_alpha_return".into(),
if alpha_degenerate {
Value::Null
} else {
json!(recent.alpha_return)
},
);
out.insert(
"recent_alpha_max_drawdown".into(),
if alpha_degenerate {
Value::Null
} else {
json!(recent.alpha_max_drawdown)
},
);
out.insert(
"history_alpha_max_drawdown_excl_recent".into(),
history_dd_excl_value,
);
out.insert(
"history_window_empty".into(),
json!(alpha_degenerate || history_window_short),
);
out.insert("alpha_degenerate".into(), json!(alpha_degenerate));
out.insert("cond_recent_return_passed".into(), json!(cond_return));
out.insert("cond_recent_dd_passed".into(), json!(cond_dd));
out.insert("is_good".into(), json!(cond_return && cond_dd));
}
}
out.insert("reason".into(), json!(reasons.join("; ")));
Ok(out)
}
fn year_metric_to_value(m: &YearMetric) -> Value {
let mut obj = serde_json::Map::new();
obj.insert("year".into(), json!(m.year));
obj.insert("abs_return".into(), json!(m.abs_return));
obj.insert("alpha_return".into(), json!(m.alpha_return));
obj.insert("days".into(), json!(m.days));
obj.insert("is_complete_year".into(), json!(m.is_complete_year));
obj.insert("year_passed".into(), json!(m.year_passed));
Value::Object(obj)
}
#[cfg(test)]
mod tests {
use super::*;
fn date_key(y: i32, m: u32, d: u32) -> i32 {
y * 10000 + (m as i32) * 100 + (d as i32)
}
fn date_key_from_nd(nd: chrono::NaiveDate) -> i32 {
nd.year() * 10000 + nd.month() as i32 * 100 + nd.day() as i32
}
fn consecutive_keys(start: chrono::NaiveDate, n: usize) -> Vec<i32> {
(0..n)
.map(|i| date_key_from_nd(start + chrono::Duration::days(i as i64)))
.collect()
}
fn build_two_year_samples(
strat_per_day_a: f64,
strat_per_day_b: f64,
) -> (Vec<i32>, Vec<f64>, Vec<f64>, Vec<f64>) {
let mut keys = Vec::new();
let mut strat = Vec::new();
let mut bench = Vec::new();
let mut long = Vec::new();
for i in 0..130 {
let nd = chrono::NaiveDate::from_ymd_opt(2020, 1, 2).unwrap()
+ chrono::Duration::days(i as i64);
keys.push(date_key_from_nd(nd));
strat.push(strat_per_day_a);
bench.push(0.0003 * ((i % 3) as f64 - 1.0));
long.push(0.0003 * ((i % 5) as f64 - 2.0));
}
for i in 0..130 {
let nd = chrono::NaiveDate::from_ymd_opt(2021, 1, 4).unwrap()
+ chrono::Duration::days(i as i64);
keys.push(date_key_from_nd(nd));
strat.push(strat_per_day_b);
bench.push(0.0003 * ((i % 3) as f64 - 1.0));
long.push(0.0003 * ((i % 5) as f64 - 2.0));
}
(keys, strat, bench, long)
}
#[test]
fn local_max_drawdown_known_curve() {
let alpha = vec![0.05_f64, -0.10, -0.05, 0.02, 0.03];
let dd = local_max_drawdown_abs(&alpha);
let expected = (1.05_f64 - 0.89775) / 1.05;
assert!(
(dd - expected).abs() < 1e-10,
"expected {expected}, got {dd}"
);
}
#[test]
fn local_max_drawdown_empty_is_zero() {
assert_eq!(local_max_drawdown_abs(&[]), 0.0);
}
#[test]
fn local_max_drawdown_nan_input_returns_nan() {
let alpha = vec![0.01, f64::NAN, 0.02];
assert!(local_max_drawdown_abs(&alpha).is_nan());
}
#[test]
fn local_max_drawdown_survives_cumzero_with_real_dd() {
let alpha = vec![0.2_f64, -0.5, 2.0 / 3.0, 0.0];
let dd = local_max_drawdown_abs(&alpha);
assert!((dd - 0.5).abs() < 1e-10, "expected 0.5, got {dd}");
}
#[test]
fn parse_date_key_strict_valid() {
let nd = parse_date_key_strict(20200601).unwrap();
assert_eq!(nd, chrono::NaiveDate::from_ymd_opt(2020, 6, 1).unwrap());
}
#[test]
fn parse_date_key_strict_invalid_errors() {
assert!(matches!(
parse_date_key_strict(0),
Err(WbtError::InvalidInput(_))
));
assert!(matches!(
parse_date_key_strict(20200230),
Err(WbtError::InvalidInput(_))
));
}
#[test]
fn vol_adjusted_alpha_equal_inputs_produce_zero_series() {
let series: Vec<f64> = (0..100)
.map(|i| if i % 2 == 0 { 0.01 } else { -0.01 })
.collect();
let alpha = compute_vol_adjusted_alpha(&series, &series, 252, 0.20).unwrap();
assert_eq!(alpha.len(), series.len());
for (i, v) in alpha.iter().enumerate() {
assert!(v.abs() < 1e-12, "alpha[{i}] expected ~0, got {v}");
}
}
#[test]
fn vol_adjusted_alpha_zero_long_vol_returns_none() {
let long = vec![0.0_f64; 50];
let bench: Vec<f64> = (0..50)
.map(|i| if i % 3 == 0 { 0.005 } else { -0.002 })
.collect();
assert!(compute_vol_adjusted_alpha(&long, &bench, 252, 0.20).is_none());
}
#[test]
fn vol_adjusted_alpha_zero_bench_vol_returns_none() {
let bench = vec![0.0_f64; 50];
let long: Vec<f64> = (0..50)
.map(|i| if i % 3 == 0 { 0.005 } else { -0.002 })
.collect();
assert!(compute_vol_adjusted_alpha(&long, &bench, 252, 0.20).is_none());
}
#[test]
fn vol_adjusted_alpha_nan_input_returns_none() {
let mut long: Vec<f64> = (0..50).map(|i| 0.001 * (i as f64)).collect();
long[10] = f64::NAN;
let bench: Vec<f64> = (0..50).map(|i| 0.001 * (i as f64)).collect();
assert!(compute_vol_adjusted_alpha(&long, &bench, 252, 0.20).is_none());
}
#[test]
fn vol_adjusted_alpha_length_mismatch_returns_none() {
let long = vec![0.01_f64; 10];
let bench = vec![0.01_f64; 9];
assert!(compute_vol_adjusted_alpha(&long, &bench, 252, 0.20).is_none());
}
#[test]
fn vol_adjusted_alpha_length_matches_input() {
let long: Vec<f64> = (0..37).map(|i| (i as f64) * 0.001 - 0.005).collect();
let bench: Vec<f64> = (0..37).map(|i| (i as f64) * 0.0005 - 0.002).collect();
let alpha = compute_vol_adjusted_alpha(&long, &bench, 252, 0.20).unwrap();
assert_eq!(alpha.len(), 37);
}
#[test]
fn yearly_metrics_marks_incomplete_year() {
let mut keys: Vec<i32> = Vec::new();
let mut strat: Vec<f64> = Vec::new();
let mut alpha: Vec<f64> = Vec::new();
for i in 0..130 {
let nd = chrono::NaiveDate::from_ymd_opt(2020, 1, 2).unwrap()
+ chrono::Duration::days(i as i64);
keys.push(date_key_from_nd(nd));
strat.push(0.001);
alpha.push(0.0005);
}
for i in 0..30 {
let nd = chrono::NaiveDate::from_ymd_opt(2021, 1, 4).unwrap()
+ chrono::Duration::days(i as i64);
keys.push(date_key_from_nd(nd));
strat.push(-0.001);
alpha.push(-0.0005);
}
let metrics = compute_yearly_metrics(&keys, &strat, &alpha, 120).unwrap();
assert_eq!(metrics.len(), 2);
assert!(
metrics
.iter()
.find(|m| m.year == 2020)
.unwrap()
.is_complete_year
);
assert!(
!metrics
.iter()
.find(|m| m.year == 2021)
.unwrap()
.is_complete_year
);
}
#[test]
fn yearly_metrics_uses_compound_formula() {
let keys: Vec<i32> = (0..5)
.map(|i| {
let nd = chrono::NaiveDate::from_ymd_opt(2020, 3, 2).unwrap()
+ chrono::Duration::days(i as i64);
date_key_from_nd(nd)
})
.collect();
let strat = vec![0.01_f64, 0.02, -0.01, 0.005, -0.002];
let alpha = vec![0.005_f64, -0.003, 0.001, 0.002, -0.001];
let metrics = compute_yearly_metrics(&keys, &strat, &alpha, 1).unwrap();
assert_eq!(metrics.len(), 1);
let m = &metrics[0];
assert_eq!(m.year, 2020);
assert_eq!(m.days, 5);
let expected_abs = strat.iter().fold(1.0_f64, |acc, r| acc * (1.0 + r)) - 1.0;
assert!((m.abs_return - expected_abs).abs() < 1e-12);
let expected_alpha = alpha.iter().fold(1.0_f64, |acc, r| acc * (1.0 + r)) - 1.0;
assert!((m.alpha_return - expected_alpha).abs() < 1e-12);
}
#[test]
fn yearly_metrics_year_passed_defaults_to_false() {
let keys = vec![date_key(2022, 6, 1)];
let metrics = compute_yearly_metrics(&keys, &[0.01], &[0.005], 1).unwrap();
assert_eq!(metrics.len(), 1);
assert!(!metrics[0].year_passed);
}
#[test]
fn yearly_metrics_length_mismatch_errors() {
let keys = vec![date_key(2020, 1, 1), date_key(2020, 1, 2)];
let r = compute_yearly_metrics(&keys, &[0.01], &[0.005, 0.001], 1);
assert!(matches!(r, Err(WbtError::InvalidInput(_))));
}
#[test]
fn yearly_metrics_invalid_date_key_errors() {
let keys = vec![20200230_i32];
let r = compute_yearly_metrics(&keys, &[0.01], &[0.005], 1);
assert!(matches!(r, Err(WbtError::InvalidInput(_))));
}
#[test]
fn recent_window_takes_tail_when_long_enough() {
let start = chrono::NaiveDate::from_ymd_opt(2020, 1, 1).unwrap();
let keys = consecutive_keys(start, 500);
let strat = vec![0.001_f64; 500];
let alpha = vec![0.0005_f64; 500];
let r = compute_recent_window(&keys, &strat, &alpha, 252).unwrap();
assert_eq!(r.actual_days, 252);
assert_eq!(r.start_date, start + chrono::Duration::days(248));
assert_eq!(r.end_date, start + chrono::Duration::days(499));
}
#[test]
fn recent_window_uses_all_when_short() {
let start = chrono::NaiveDate::from_ymd_opt(2023, 1, 1).unwrap();
let keys = consecutive_keys(start, 100);
let strat = vec![0.001_f64; 100];
let alpha = vec![0.0005_f64; 100];
let r = compute_recent_window(&keys, &strat, &alpha, 252).unwrap();
assert_eq!(r.actual_days, 100);
assert_eq!(r.start_date, start);
assert_eq!(r.end_date, start + chrono::Duration::days(99));
}
#[test]
fn recent_window_compound_formula_exact() {
let start = chrono::NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
let keys = consecutive_keys(start, 5);
let strat = vec![0.01_f64, -0.02, 0.015, 0.005, -0.01];
let alpha = vec![0.003_f64, -0.001, 0.002, 0.0, -0.0005];
let r = compute_recent_window(&keys, &strat, &alpha, 252).unwrap();
let expected_abs = strat.iter().fold(1.0_f64, |acc, x| acc * (1.0 + x)) - 1.0;
let expected_alpha = alpha.iter().fold(1.0_f64, |acc, x| acc * (1.0 + x)) - 1.0;
assert!((r.abs_return - expected_abs).abs() < 1e-12);
assert!((r.alpha_return - expected_alpha).abs() < 1e-12);
}
#[test]
fn recent_window_empty_date_keys_errors() {
let r = compute_recent_window(&[], &[], &[], 252);
assert!(matches!(r, Err(WbtError::InvalidInput(_))));
}
#[test]
fn recent_window_zero_recent_days_errors() {
let start = chrono::NaiveDate::from_ymd_opt(2020, 1, 1).unwrap();
let keys = consecutive_keys(start, 10);
let r = compute_recent_window(&keys, &[0.001_f64; 10], &[0.001_f64; 10], 0);
assert!(matches!(r, Err(WbtError::InvalidInput(_))));
}
#[test]
fn recent_window_length_mismatch_errors() {
let keys = vec![20200101_i32, 20200102];
let r = compute_recent_window(&keys, &[0.001], &[0.001, 0.002], 5);
assert!(matches!(r, Err(WbtError::InvalidInput(_))));
}
#[test]
fn history_max_dd_full_known_value() {
let alpha = vec![0.05_f64, -0.10, -0.05, 0.02, 0.03];
let dd = compute_history_max_dd_full(&alpha);
let expected = (1.05_f64 - 0.89775) / 1.05;
assert!((dd - expected).abs() < 1e-10);
}
#[test]
fn history_max_dd_excl_recent_disjoints_from_recent_window() {
let mut alpha: Vec<f64> = Vec::with_capacity(352);
alpha.extend(std::iter::repeat_n(0.001_f64, 100));
alpha.extend(std::iter::repeat_n(-0.005_f64, 252));
let excl = compute_history_max_dd_excl_recent(&alpha, 252, 0).unwrap();
let full = compute_history_max_dd_full(&alpha);
assert!(excl < 1e-6);
assert!(full > 0.5);
assert!((excl - full).abs() > 0.1);
}
#[test]
fn history_max_dd_excl_recent_returns_none_when_no_head() {
let alpha = vec![-0.01_f64; 200];
assert_eq!(compute_history_max_dd_excl_recent(&alpha, 252, 0), None);
}
#[test]
fn history_max_dd_excl_recent_returns_none_when_head_below_floor() {
let alpha = vec![0.001_f64; 255];
assert_eq!(compute_history_max_dd_excl_recent(&alpha, 252, 60), None);
}
#[test]
fn history_max_dd_excl_recent_zero_floor_accepts_any_head() {
let alpha = vec![0.001_f64; 255];
assert!(compute_history_max_dd_excl_recent(&alpha, 252, 0).is_some());
}
#[test]
fn judge_empty_input_errors() {
let r = judge(
Mode::History,
&[],
&[],
&[],
&[],
252,
0.20,
0.20,
120,
252,
60,
);
assert!(matches!(r, Err(WbtError::InvalidInput(_))));
}
#[test]
fn judge_length_mismatch_errors() {
let r = judge(
Mode::History,
&[20200101_i32],
&[0.01],
&[0.01, 0.01],
&[0.01],
252,
0.20,
0.20,
120,
252,
60,
);
assert!(matches!(r, Err(WbtError::InvalidInput(_))));
}
#[test]
fn judge_invalid_target_vol_errors() {
let keys = vec![20200101_i32];
let r = judge(
Mode::History,
&keys,
&[0.01],
&[0.01],
&[0.01],
252,
0.0,
0.20,
120,
252,
60,
);
assert!(matches!(r, Err(WbtError::InvalidInput(_))));
}
#[test]
fn judge_invalid_max_dd_threshold_errors() {
let keys = vec![20200101_i32];
let r = judge(
Mode::History,
&keys,
&[0.01],
&[0.01],
&[0.01],
252,
0.20,
-1.0,
120,
252,
60,
);
assert!(matches!(r, Err(WbtError::InvalidInput(_))));
}
#[test]
fn judge_recent_zero_recent_days_errors() {
let start = chrono::NaiveDate::from_ymd_opt(2020, 1, 1).unwrap();
let keys = consecutive_keys(start, 10);
let r = judge(
Mode::Recent,
&keys,
&[0.001_f64; 10],
&[0.001_f64; 10],
&[0.001_f64; 10],
252,
0.20,
0.20,
120,
0,
60,
);
assert!(matches!(r, Err(WbtError::InvalidInput(_))));
}
#[test]
fn judge_strategy_with_nan_errors() {
let start = chrono::NaiveDate::from_ymd_opt(2020, 1, 1).unwrap();
let keys = consecutive_keys(start, 10);
let mut s = vec![0.001_f64; 10];
s[3] = f64::NAN;
let r = judge(
Mode::History,
&keys,
&s,
&[0.001_f64; 10],
&[0.001_f64; 10],
252,
0.20,
0.20,
120,
252,
60,
);
assert!(matches!(r, Err(WbtError::InvalidInput(_))));
}
#[test]
fn judge_invalid_date_key_errors() {
let r = judge(
Mode::History,
&[0_i32, 20200101],
&[0.001, 0.002],
&[0.001, 0.002],
&[0.001, 0.002],
252,
0.20,
0.20,
120,
252,
60,
);
assert!(matches!(r, Err(WbtError::InvalidInput(_))));
}
#[test]
fn history_passes_when_all_conditions_met() {
let (k, s, b, l) = build_two_year_samples(0.001, 0.001);
let r = judge(Mode::History, &k, &s, &b, &l, 252, 0.20, 1.0, 120, 252, 0).unwrap();
assert_eq!(r.get("mode").and_then(|v| v.as_str()), Some("history"));
assert_eq!(
r.get("alpha_degenerate").and_then(|v| v.as_bool()),
Some(false)
);
assert_eq!(
r.get("cond_yearly_passed").and_then(|v| v.as_bool()),
Some(true)
);
assert_eq!(
r.get("cond_history_dd_passed").and_then(|v| v.as_bool()),
Some(true)
);
assert_eq!(r.get("is_good").and_then(|v| v.as_bool()), Some(true));
}
#[test]
fn history_fails_when_any_complete_year_both_metrics_negative() {
let (k, s, b, l) = build_two_year_samples(0.001, -0.001);
let r = judge(Mode::History, &k, &s, &b, &l, 252, 0.20, 1.0, 120, 252, 0).unwrap();
assert_eq!(r.get("is_good").and_then(|v| v.as_bool()), Some(false));
let reason = r.get("reason").and_then(|v| v.as_str()).unwrap_or("");
assert!(
reason.contains("2021") || reason.contains("2020"),
"reason should mention failing year, got: {reason}"
);
}
#[test]
fn history_fails_when_no_complete_year() {
let (k, s, b, l) = build_two_year_samples(0.001, 0.001);
let r = judge(Mode::History, &k, &s, &b, &l, 252, 0.20, 1.0, 500, 252, 0).unwrap();
assert_eq!(r.get("is_good").and_then(|v| v.as_bool()), Some(false));
let reason = r.get("reason").and_then(|v| v.as_str()).unwrap_or("");
assert!(reason.contains("no complete year") || reason.contains("complete year"));
}
#[test]
fn history_fails_when_max_dd_exceeds_threshold() {
let mut k = Vec::new();
let mut s = Vec::new();
let mut b = Vec::new();
let mut l = Vec::new();
for i in 0..150 {
let nd = chrono::NaiveDate::from_ymd_opt(2020, 1, 2).unwrap()
+ chrono::Duration::days(i as i64);
k.push(date_key_from_nd(nd));
s.push(0.002);
b.push(0.001 + 0.0005 * ((i % 3) as f64));
l.push(-(0.001 + 0.0005 * ((i % 3) as f64)));
}
let r = judge(Mode::History, &k, &s, &b, &l, 252, 0.20, 0.20, 120, 252, 0).unwrap();
assert_eq!(r.get("is_good").and_then(|v| v.as_bool()), Some(false));
let dd = r
.get("history_alpha_max_drawdown")
.and_then(|v| v.as_f64())
.expect("history_alpha_max_drawdown must be set when not degenerate");
assert!(dd > 0.20);
}
#[test]
fn history_alpha_degenerate_does_not_trivially_pass() {
let start = chrono::NaiveDate::from_ymd_opt(2020, 1, 2).unwrap();
let keys = consecutive_keys(start, 130);
let s = vec![0.001_f64; 130];
let long = vec![0.0_f64; 130];
let bench: Vec<f64> = (0..130)
.map(|i| 0.001 + 0.0005 * ((i % 3) as f64))
.collect();
let r = judge(
Mode::History,
&keys,
&s,
&bench,
&long,
252,
0.20,
1.0,
120,
252,
0,
)
.unwrap();
assert_eq!(
r.get("alpha_degenerate").and_then(|v| v.as_bool()),
Some(true)
);
assert_eq!(
r.get("cond_history_dd_passed").and_then(|v| v.as_bool()),
Some(false)
);
assert_eq!(r.get("is_good").and_then(|v| v.as_bool()), Some(false));
assert!(matches!(
r.get("history_alpha_max_drawdown"),
Some(Value::Null)
));
}
#[test]
fn history_alpha_max_drawdown_is_null_when_degenerate() {
let start = chrono::NaiveDate::from_ymd_opt(2020, 1, 2).unwrap();
let keys = consecutive_keys(start, 130);
let s = vec![0.001_f64; 130];
let long = vec![0.0_f64; 130];
let bench: Vec<f64> = (0..130)
.map(|i| 0.001 + 0.0005 * ((i % 3) as f64))
.collect();
let r = judge(
Mode::History,
&keys,
&s,
&bench,
&long,
252,
0.20,
1.0,
120,
252,
0,
)
.unwrap();
assert!(matches!(
r.get("history_alpha_max_drawdown"),
Some(Value::Null)
));
}
#[test]
fn history_year_passed_uses_epsilon() {
let start = chrono::NaiveDate::from_ymd_opt(2020, 1, 2).unwrap();
let keys = consecutive_keys(start, 130);
let s = vec![1e-12_f64; 130];
let same: Vec<f64> = (0..130)
.map(|i| 0.001 + 0.0005 * ((i % 3) as f64))
.collect();
let r = judge(
Mode::History,
&keys,
&s,
&same,
&same,
252,
0.20,
1.0,
120,
252,
0,
)
.unwrap();
assert_eq!(r.get("is_good").and_then(|v| v.as_bool()), Some(false));
}
#[test]
fn recent_returns_full_field_contract() {
let start = chrono::NaiveDate::from_ymd_opt(2020, 1, 1).unwrap();
let mut k = Vec::new();
let mut s = Vec::new();
let mut b = Vec::new();
let mut l = Vec::new();
for i in 0..500 {
k.push(date_key_from_nd(start + chrono::Duration::days(i as i64)));
s.push(0.001);
b.push(0.0003 * ((i % 3) as f64 - 1.0));
l.push(0.0003 * ((i % 5) as f64 - 2.0));
}
let r = judge(Mode::Recent, &k, &s, &b, &l, 252, 0.20, 0.20, 120, 252, 60).unwrap();
assert_eq!(r.get("mode").and_then(|v| v.as_str()), Some("recent"));
assert_eq!(
r.get("alpha_degenerate").and_then(|v| v.as_bool()),
Some(false)
);
for key in [
"recent_start_date",
"recent_end_date",
"recent_actual_days",
"recent_abs_return",
"recent_alpha_return",
"recent_alpha_max_drawdown",
"history_alpha_max_drawdown_excl_recent",
"history_window_empty",
"cond_recent_return_passed",
"cond_recent_dd_passed",
"is_good",
"reason",
] {
assert!(r.contains_key(key), "missing key: {key}");
}
assert_eq!(
r.get("recent_actual_days").and_then(|v| v.as_u64()),
Some(252)
);
}
#[test]
fn recent_fails_when_alpha_degenerate() {
let start = chrono::NaiveDate::from_ymd_opt(2020, 1, 1).unwrap();
let mut k = Vec::new();
let mut s = Vec::new();
let mut b = Vec::new();
let l = vec![0.0_f64; 500]; for i in 0..500 {
k.push(date_key_from_nd(start + chrono::Duration::days(i as i64)));
s.push(0.001);
b.push(0.001 + 0.0005 * ((i % 3) as f64));
}
let r = judge(Mode::Recent, &k, &s, &b, &l, 252, 0.20, 0.20, 120, 252, 60).unwrap();
assert_eq!(
r.get("alpha_degenerate").and_then(|v| v.as_bool()),
Some(true)
);
assert_eq!(r.get("is_good").and_then(|v| v.as_bool()), Some(false));
}
#[test]
fn recent_history_dd_excl_recent_is_null_when_short() {
let start = chrono::NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
let keys = consecutive_keys(start, 200);
let s = vec![0.001_f64; 200];
let b: Vec<f64> = (0..200).map(|i| 0.0003 * ((i % 3) as f64 - 1.0)).collect();
let l: Vec<f64> = (0..200).map(|i| 0.0003 * ((i % 5) as f64 - 2.0)).collect();
let r = judge(
Mode::Recent,
&keys,
&s,
&b,
&l,
252,
0.20,
0.20,
120,
252,
60,
)
.unwrap();
assert_eq!(
r.get("history_window_empty").and_then(|v| v.as_bool()),
Some(true)
);
assert!(matches!(
r.get("history_alpha_max_drawdown_excl_recent"),
Some(Value::Null)
));
assert_eq!(r.get("is_good").and_then(|v| v.as_bool()), Some(false));
}
#[test]
fn recent_history_short_below_min_history_days_is_window_empty() {
let start = chrono::NaiveDate::from_ymd_opt(2020, 1, 1).unwrap();
let keys = consecutive_keys(start, 255);
let s = vec![0.001_f64; 255];
let b: Vec<f64> = (0..255).map(|i| 0.0003 * ((i % 3) as f64 - 1.0)).collect();
let l: Vec<f64> = (0..255).map(|i| 0.0003 * ((i % 5) as f64 - 2.0)).collect();
let r = judge(
Mode::Recent,
&keys,
&s,
&b,
&l,
252,
0.20,
0.20,
120,
252,
60,
)
.unwrap();
assert_eq!(
r.get("history_window_empty").and_then(|v| v.as_bool()),
Some(true)
);
assert!(matches!(
r.get("history_alpha_max_drawdown_excl_recent"),
Some(Value::Null)
));
}
}