use std::path::PathBuf;
use serde::Deserialize;
use serde_json::Value;
#[derive(Debug, Deserialize)]
struct RegistryFile {
indicators: Vec<RegistryEntry>,
}
#[derive(Debug, Deserialize)]
struct RegistryEntry {
golden_key: Option<String>,
golden_keys: Option<Vec<String>>,
name: String,
#[allow(dead_code)]
talib_func: String,
#[allow(dead_code)]
input_type: String,
rust_fn: String,
rust_args: Vec<serde_json::Value>,
tol: f64,
}
fn registry() -> RegistryFile {
let path: PathBuf = [
env!("CARGO_MANIFEST_DIR"),
"tests",
"parity",
"indicator_registry.json",
]
.iter()
.collect();
let raw = std::fs::read_to_string(&path).unwrap_or_else(|e| {
panic!(
"failed to read {}: {e}\n\
Add entries to indicator_registry.json (see tests/parity/README.md)",
path.display()
)
});
serde_json::from_str(&raw).expect("indicator_registry.json is not valid JSON")
}
fn golden_keys(entry: &RegistryEntry) -> Vec<String> {
if let Some(keys) = &entry.golden_keys {
keys.clone()
} else {
vec![
entry
.golden_key
.clone()
.expect("golden_key or golden_keys required"),
]
}
}
fn usize_arg(v: &serde_json::Value, label: &str) -> usize {
v.as_u64()
.or_else(|| v.as_f64().map(|f| f as u64))
.expect(label) as usize
}
fn f64_arg(v: &serde_json::Value, label: &str) -> f64 {
v.as_f64().expect(label)
}
fn run_indicator(
entry: &RegistryEntry,
close: &[f64],
highs: &[f64],
lows: &[f64],
volume: &[f64],
) -> Vec<Vec<f64>> {
match entry.rust_fn.as_str() {
"sma" => {
let period = usize_arg(&entry.rust_args[0], "sma period");
vec![nanobook::indicators::sma(close, period)]
}
"ema" => {
let period = usize_arg(&entry.rust_args[0], "ema period");
vec![nanobook::indicators::ema(close, period)]
}
"rsi" => {
let period = usize_arg(&entry.rust_args[0], "rsi period");
vec![nanobook::indicators::rsi(close, period)]
}
"macd" => {
let fast = usize_arg(&entry.rust_args[0], "macd fast");
let slow = usize_arg(&entry.rust_args[1], "macd slow");
let signal = usize_arg(&entry.rust_args[2], "macd signal");
let (macd, sig, hist) = nanobook::indicators::macd(close, fast, slow, signal);
vec![macd, sig, hist]
}
"bbands" => {
let period = usize_arg(&entry.rust_args[0], "bbands period");
let up = f64_arg(&entry.rust_args[1], "bbands nbdevup");
let dn = f64_arg(&entry.rust_args[2], "bbands nbdevdn");
let (upper, middle, lower) = nanobook::indicators::bbands(close, period, up, dn);
vec![upper, middle, lower]
}
"atr" => {
let period = usize_arg(&entry.rust_args[0], "atr period");
vec![nanobook::indicators::atr(highs, lows, close, period)]
}
"stoch" => {
let fk = usize_arg(&entry.rust_args[0], "stoch fastk");
let sk = usize_arg(&entry.rust_args[1], "stoch slowk");
let sd = usize_arg(&entry.rust_args[2], "stoch slowd");
let (k, d) = nanobook::indicators::stoch(highs, lows, close, fk, sk, sd);
vec![k, d]
}
"stochf" => {
let fk = usize_arg(&entry.rust_args[0], "stochf fastk");
let fd = usize_arg(&entry.rust_args[1], "stochf fastd");
let (k, d) = nanobook::indicators::stochf(highs, lows, close, fk, fd);
vec![k, d]
}
"stochrsi" => {
let tp = usize_arg(&entry.rust_args[0], "stochrsi timeperiod");
let fk = usize_arg(&entry.rust_args[1], "stochrsi fastk");
let fd = usize_arg(&entry.rust_args[2], "stochrsi fastd");
let (k, d) = nanobook::indicators::stochrsi(close, tp, fk, fd);
vec![k, d]
}
"plus_di" => {
let period = usize_arg(&entry.rust_args[0], "plus_di period");
vec![nanobook::indicators::plus_di(highs, lows, close, period)]
}
"minus_di" => {
let period = usize_arg(&entry.rust_args[0], "minus_di period");
vec![nanobook::indicators::minus_di(highs, lows, close, period)]
}
"dx" => {
let period = usize_arg(&entry.rust_args[0], "dx period");
vec![nanobook::indicators::dx(highs, lows, close, period)]
}
"adx" => {
let period = usize_arg(&entry.rust_args[0], "adx period");
vec![nanobook::indicators::adx(highs, lows, close, period)]
}
"cci" => {
let period = usize_arg(&entry.rust_args[0], "cci period");
vec![nanobook::indicators::cci(highs, lows, close, period)]
}
"willr" => {
let period = usize_arg(&entry.rust_args[0], "willr period");
vec![nanobook::indicators::willr(highs, lows, close, period)]
}
"ultosc" => {
let p1 = usize_arg(&entry.rust_args[0], "ultosc period1");
let p2 = usize_arg(&entry.rust_args[1], "ultosc period2");
let p3 = usize_arg(&entry.rust_args[2], "ultosc period3");
vec![nanobook::indicators::ultosc(highs, lows, close, p1, p2, p3)]
}
"mom" => {
let period = usize_arg(&entry.rust_args[0], "mom period");
vec![nanobook::indicators::mom(close, period)]
}
"roc" => {
let period = usize_arg(&entry.rust_args[0], "roc period");
vec![nanobook::indicators::roc(close, period)]
}
"rocp" => {
let period = usize_arg(&entry.rust_args[0], "rocp period");
vec![nanobook::indicators::rocp(close, period)]
}
"rocr" => {
let period = usize_arg(&entry.rust_args[0], "rocr period");
vec![nanobook::indicators::rocr(close, period)]
}
"obv" => vec![nanobook::indicators::obv(close, volume)],
"ad" => vec![nanobook::indicators::ad(highs, lows, close, volume)],
"adosc" => {
let fast = usize_arg(&entry.rust_args[0], "adosc fast");
let slow = usize_arg(&entry.rust_args[1], "adosc slow");
vec![nanobook::indicators::adosc(
highs, lows, close, volume, fast, slow,
)]
}
"natr" => {
let period = usize_arg(&entry.rust_args[0], "natr period");
vec![nanobook::indicators::natr(highs, lows, close, period)]
}
"trange" => vec![nanobook::indicators::trange(highs, lows, close)],
other => panic!("unknown rust_fn in registry: {other}"),
}
}
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", "volume"] {
let v = f64_vec(&g, &["inputs", field]);
assert_eq!(v.len(), 500, "inputs.{field} wrong length");
}
}
#[test]
fn talib_registry_matches_golden() {
let g = golden();
let close = f64_vec(&g, &["inputs", "close"]);
let highs = f64_vec(&g, &["inputs", "highs"]);
let lows = f64_vec(&g, &["inputs", "lows"]);
let volume = f64_vec(&g, &["inputs", "volume"]);
let reg = registry();
assert!(
!reg.indicators.is_empty(),
"indicator_registry.json has no entries"
);
for entry in ®.indicators {
let keys = golden_keys(entry);
let ours = run_indicator(entry, &close, &highs, &lows, &volume);
assert_eq!(
ours.len(),
keys.len(),
"{}: {} rust outputs vs {} golden keys",
entry.name,
ours.len(),
keys.len()
);
for (key, series) in keys.iter().zip(ours.iter()) {
let expected = f64_nullable(&g, &["talib", key]);
assert_indicator_parity(series, &expected, entry.tol, key);
let first_valid = expected
.iter()
.position(|v| v.is_some())
.unwrap_or(usize::MAX);
eprintln!(
"checking {key}: first_valid_index={first_valid}, tol={:.3e}",
entry.tol
);
}
}
}
#[test]
fn talib_golden_keys_known() {
let g = golden();
let reg = registry();
let known: std::collections::HashSet<String> =
reg.indicators.iter().flat_map(golden_keys).collect();
let talib_obj = g
.get("talib")
.and_then(|v| v.as_object())
.expect("golden.json missing talib section");
for key in talib_obj.keys() {
assert!(
known.contains(key),
"golden talib key {key:?} has no registry entry"
);
}
}
#[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}"
);
}