use std::path::PathBuf;
use serde_json::Value;
fn golden() -> Value {
let path: PathBuf = [env!("CARGO_MANIFEST_DIR"), "tests", "parity", "golden.json"]
.iter()
.collect();
let raw = std::fs::read_to_string(&path).unwrap_or_else(|e| {
panic!(
"failed to read {}: {e}\n\
Regenerate with `uv run python tests/parity/generate_golden.py` \
(see tests/parity/README.md)",
path.display()
)
});
serde_json::from_str(&raw).expect("golden.json is not valid JSON")
}
fn f64_vec(g: &Value, path: &[&str]) -> Vec<f64> {
let mut cur = g;
for key in path {
cur = cur
.get(*key)
.unwrap_or_else(|| panic!("golden.json missing path: {}", path.join(".")));
}
cur.as_array()
.expect("not an array")
.iter()
.map(|v| v.as_f64().expect("non-numeric entry"))
.collect()
}
fn f64_nullable(g: &Value, path: &[&str]) -> Vec<Option<f64>> {
let mut cur = g;
for key in path {
cur = cur
.get(*key)
.unwrap_or_else(|| panic!("golden.json missing path: {}", path.join(".")));
}
cur.as_array()
.expect("not an array")
.iter()
.map(|v| {
if v.is_null() {
None
} else {
Some(v.as_f64().expect("non-numeric entry"))
}
})
.collect()
}
fn f64_scalar(g: &Value, path: &[&str]) -> f64 {
let mut cur = g;
for key in path {
cur = cur
.get(*key)
.unwrap_or_else(|| panic!("golden.json missing path: {}", path.join(".")));
}
cur.as_f64().expect("not a number")
}
#[track_caller]
fn assert_indicator_parity(ours: &[f64], theirs: &[Option<f64>], tol: f64, label: &str) {
assert_eq!(
ours.len(),
theirs.len(),
"{label}: length mismatch ({} vs {})",
ours.len(),
theirs.len()
);
let mut max_diff = 0.0_f64;
let mut max_diff_idx = usize::MAX;
for (i, (o, t)) in ours.iter().zip(theirs.iter()).enumerate() {
match (o.is_nan(), t) {
(true, None) => {}
(false, Some(tv)) => {
let diff = (o - tv).abs();
if diff > max_diff {
max_diff = diff;
max_diff_idx = i;
}
assert!(
diff <= tol,
"{label}[{i}]: ours={o}, reference={tv}, diff={diff} > tol={tol}"
);
}
(true, Some(tv)) => panic!(
"{label}[{i}]: ours=NaN, reference={tv} (nanobook NaN where reference is finite)"
),
(false, None) => panic!(
"{label}[{i}]: ours={o}, reference=NaN (nanobook finite where reference is NaN)"
),
}
}
eprintln!("{label}: max_diff={max_diff:.3e} at index {max_diff_idx} (tol={tol:.3e})");
}
#[test]
fn golden_fixture_loads() {
let g = golden();
assert_eq!(g["_meta"]["seed"].as_i64(), Some(42));
assert_eq!(g["_meta"]["n"].as_i64(), Some(500));
}
#[test]
fn input_series_have_expected_length() {
let g = golden();
for field in ["returns", "close", "highs", "lows"] {
let v = f64_vec(&g, &["inputs", field]);
assert_eq!(v.len(), 500, "inputs.{field} wrong length");
}
}
#[test]
fn rsi_matches_talib() {
let g = golden();
let close = f64_vec(&g, &["inputs", "close"]);
let expected = f64_nullable(&g, &["talib", "rsi_14"]);
let ours = nanobook::indicators::rsi(&close, 14);
assert_indicator_parity(&ours, &expected, 1e-6, "rsi_14");
}
#[test]
fn atr_matches_talib() {
let g = golden();
let highs = f64_vec(&g, &["inputs", "highs"]);
let lows = f64_vec(&g, &["inputs", "lows"]);
let close = f64_vec(&g, &["inputs", "close"]);
let expected = f64_nullable(&g, &["talib", "atr_14"]);
let ours = nanobook::indicators::atr(&highs, &lows, &close, 14);
assert_indicator_parity(&ours, &expected, 1e-6, "atr_14");
}
#[test]
fn sharpe_matches_quantstats() {
let g = golden();
let returns = f64_vec(&g, &["inputs", "returns"]);
let expected = f64_scalar(&g, &["quantstats", "sharpe_annual_252"]);
let metrics = nanobook::portfolio::metrics::compute_metrics(&returns, 252.0, 0.0)
.expect("non-empty return series");
let ours = metrics.sharpe;
let diff = (ours - expected).abs();
assert!(
diff <= 1e-9,
"sharpe: ours={ours}, quantstats={expected}, diff={diff}"
);
}
#[test]
fn max_drawdown_matches_quantstats() {
let g = golden();
let returns = f64_vec(&g, &["inputs", "returns"]);
let expected = f64_scalar(&g, &["quantstats", "max_drawdown"]);
let metrics = nanobook::portfolio::metrics::compute_metrics(&returns, 252.0, 0.0)
.expect("non-empty return series");
let ours = metrics.max_drawdown;
let diff = (ours - expected.abs()).abs();
assert!(
diff <= 1e-9,
"max_drawdown: ours={ours} (positive fraction), \
quantstats={expected} (signed), |our - |theirs||={diff}"
);
}
#[test]
fn cvar_historical_matches_empirical() {
use nanobook::portfolio::metrics::{CVaRMethod, cvar};
let g = golden();
let returns = f64_vec(&g, &["inputs", "returns"]);
let expected = f64_scalar(&g, &["empirical", "cvar_95"]);
let ours_direct = cvar(&returns, 0.05, CVaRMethod::Historical);
let diff = (ours_direct - expected).abs();
assert!(
diff <= 1e-12,
"cvar(Historical): ours={ours_direct}, empirical={expected}, diff={diff}"
);
let metrics = nanobook::portfolio::metrics::compute_metrics(&returns, 252.0, 0.0)
.expect("non-empty return series");
let diff = (metrics.cvar_95 - expected).abs();
assert!(
diff <= 1e-12,
"metrics.cvar_95 (Historical default): ours={}, empirical={expected}, diff={diff}",
metrics.cvar_95
);
}
#[test]
fn cvar_parametric_matches_quantstats() {
use nanobook::portfolio::metrics::{CVaRMethod, cvar};
let g = golden();
let returns = f64_vec(&g, &["inputs", "returns"]);
let expected = f64_scalar(&g, &["quantstats", "cvar_95_parametric"]);
let ours = cvar(&returns, 0.05, CVaRMethod::ParametricNormal);
let diff = (ours - expected).abs();
assert!(
diff <= 1e-9,
"cvar(ParametricNormal): ours={ours}, quantstats={expected}, diff={diff}"
);
}
#[test]
fn sortino_matches_quantstats() {
use nanobook::portfolio::metrics::sortino;
let g = golden();
let returns = f64_vec(&g, &["inputs", "returns"]);
let expected = f64_scalar(&g, &["quantstats", "sortino_annual_252"]);
let ours_direct = sortino(&returns, 0.0, 252.0, 0);
let diff = (ours_direct - expected).abs();
assert!(
diff <= 1e-9,
"sortino(ddof=0) direct: ours={ours_direct}, quantstats={expected}, diff={diff}"
);
let metrics = nanobook::portfolio::metrics::compute_metrics(&returns, 252.0, 0.0)
.expect("non-empty return series");
let diff = (metrics.sortino - expected).abs();
assert!(
diff <= 1e-9,
"metrics.sortino (ddof=0 default): ours={}, quantstats={expected}, diff={diff}",
metrics.sortino
);
}
#[test]
fn sortino_ddof1_matches_scaled_ddof0() {
use nanobook::portfolio::metrics::sortino;
let g = golden();
let returns = f64_vec(&g, &["inputs", "returns"]);
let n = returns.len() as f64;
let s0 = sortino(&returns, 0.0, 252.0, 0);
let s1 = sortino(&returns, 0.0, 252.0, 1);
let ratio = s1 / s0;
let expected_ratio = ((n - 1.0) / n).sqrt();
let diff = (ratio - expected_ratio).abs();
assert!(
diff <= 1e-12,
"sortino ddof ratio: got s1/s0={ratio}, expected sqrt((n-1)/n)={expected_ratio}, diff={diff}"
);
}