#![allow(clippy::float_cmp)]
use std::collections::BTreeSet;
use std::fs;
use std::path::{Path, PathBuf};
use serde_json::Value;
use wickra_core::{
Atr, BollingerBands, Candle, Donchian, Ema, Indicator, MacdIndicator, Roc, Rsi, Sma, Wma,
};
const PINNED: [&str; 9] = [
"Atr",
"BollingerBands",
"Donchian",
"Ema",
"Macd",
"Roc",
"Rsi",
"Sma",
"Wma",
];
fn repo_root() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("../..")
}
const FLAT: f64 = 100.0;
#[test]
fn sma_averages_its_window() {
let mut sma = Sma::new(3).expect("period 3 is valid");
assert_eq!(sma.warmup_period(), 3);
assert_eq!(sma.update(1.0), None, "no value before the window is full");
assert_eq!(sma.update(2.0), None);
assert_eq!(sma.update(3.0), Some(2.0));
assert_eq!(sma.update(4.0), Some(3.0));
let mut flat = Sma::new(10).expect("period 10 is valid");
for _ in 0..9 {
flat.update(FLAT);
}
assert_eq!(
flat.update(FLAT),
Some(FLAT),
"the mean of a constant is that constant"
);
}
#[test]
fn ema_starts_at_the_seed_and_weights_the_newest_input() {
let mut ema = Ema::new(5).expect("period 5 is valid");
assert_eq!(ema.warmup_period(), 5);
for _ in 0..4 {
ema.update(FLAT);
}
assert_eq!(ema.update(FLAT), Some(FLAT));
let stepped = ema.update(200.0).expect("ready");
assert!(
stepped > FLAT && stepped < 200.0,
"an EMA must move toward a step without reaching it, got {stepped}"
);
}
#[test]
fn rsi_is_bounded_and_saturates() {
let mut rsi = Rsi::new(14).expect("period 14 is valid");
assert_eq!(rsi.warmup_period(), 15);
let mut last = None;
for i in 0..40 {
last = rsi.update(100.0 + f64::from(i));
}
let value = last.expect("ready after 40 inputs");
assert!(
(value - 100.0).abs() < 1e-9,
"a series with no losses must sit at 100, got {value}"
);
let mut mixed = Rsi::new(14).expect("period 14 is valid");
for i in 0..60 {
if let Some(v) = mixed.update(100.0 + (f64::from(i) * 0.4).sin() * 8.0) {
assert!((0.0..=100.0).contains(&v), "RSI left its range: {v}");
}
}
}
#[test]
fn donchian_tracks_the_extremes_of_its_window() {
let mut donchian = Donchian::new(3).expect("period 3 is valid");
assert_eq!(donchian.warmup_period(), 3);
let candle = |high: f64, low: f64| {
Candle::new(low, high, low, high, 0.0, 0).expect("high >= low is a valid candle")
};
assert!(donchian.update(candle(10.0, 5.0)).is_none());
assert!(donchian.update(candle(12.0, 4.0)).is_none());
let out = donchian
.update(candle(11.0, 6.0))
.expect("ready on the third");
assert_eq!(out.upper, 12.0, "upper is the highest high in the window");
assert_eq!(out.lower, 4.0, "lower is the lowest low in the window");
let out = donchian.update(candle(9.0, 7.0)).expect("ready");
assert_eq!(out.upper, 12.0);
assert_eq!(out.lower, 4.0);
let out = donchian.update(candle(9.5, 7.5)).expect("ready");
assert_eq!(out.upper, 11.0, "the 12.0 bar has left the window");
assert_eq!(out.lower, 6.0, "so has the 4.0 bar");
}
#[test]
fn reset_returns_an_indicator_to_its_starting_state() {
let mut sma = Sma::new(3).expect("period 3 is valid");
for value in [1.0, 2.0, 3.0] {
sma.update(value);
}
assert!(sma.is_ready());
sma.reset();
assert!(!sma.is_ready(), "reset must undo readiness");
assert_eq!(sma.update(1.0), None, "and the window with it");
}
#[test]
fn wma_weights_the_newest_input_hardest() {
let mut wma = Wma::new(3).expect("period 3 is valid");
assert_eq!(wma.warmup_period(), 3);
assert_eq!(wma.update(0.0), None, "no value before the window is full");
assert_eq!(wma.update(0.0), None);
assert_eq!(wma.update(6.0), Some(3.0));
let mut flat = Wma::new(10).expect("period 10 is valid");
for _ in 0..9 {
flat.update(FLAT);
}
assert_eq!(
flat.update(FLAT),
Some(FLAT),
"a weighted mean of a constant is that constant"
);
}
#[test]
fn roc_is_a_percentage_of_the_earlier_price() {
let mut roc = Roc::new(1).expect("period 1 is valid");
assert_eq!(roc.warmup_period(), 2);
assert_eq!(roc.update(100.0), None, "one price is not a change");
assert_eq!(roc.update(200.0), Some(100.0));
assert_eq!(roc.update(100.0), Some(-50.0));
let mut flat = Roc::new(14).expect("period 14 is valid");
let mut last = None;
for _ in 0..20 {
last = flat.update(FLAT);
}
assert_eq!(
last,
Some(0.0),
"a series that never moves has no rate of change"
);
}
#[test]
fn atr_averages_a_constant_true_range_to_itself() {
let mut atr = Atr::new(14).expect("period 14 is valid");
assert_eq!(atr.warmup_period(), 14);
let bar = Candle::new(100.0, 101.0, 99.0, 100.0, 0.0, 0).expect("high >= low");
let mut last = None;
for _ in 0..14 {
last = atr.update(bar);
}
assert_eq!(
last,
Some(2.0),
"the average of a constant range is that range"
);
assert_eq!(atr.update(bar), Some(2.0), "and smoothing does not move it");
}
#[test]
fn bollinger_bands_collapse_onto_a_flat_series() {
let mut bands = BollingerBands::new(20, 2.0).expect("period 20, multiplier 2 are valid");
assert_eq!(bands.warmup_period(), 20);
let mut last = None;
for _ in 0..20 {
last = bands.update(FLAT);
}
let out = last.expect("ready after 20 inputs");
assert_eq!(out.middle, FLAT, "the middle band is the mean");
assert_eq!(out.stddev, 0.0, "a constant series has no deviation");
assert_eq!(out.upper, FLAT, "so both bands sit on the mean");
assert_eq!(out.lower, FLAT);
let mut moving = BollingerBands::new(20, 2.0).expect("period 20 is valid");
for i in 0..60 {
if let Some(out) = moving.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0) {
assert!(
out.lower <= out.middle && out.middle <= out.upper,
"bands crossed: {} {} {}",
out.lower,
out.middle,
out.upper
);
}
}
}
#[test]
fn macd_is_zero_while_both_averages_agree() {
let mut macd = MacdIndicator::new(12, 26, 9).expect("12/26/9 is valid");
assert_eq!(macd.warmup_period(), 34);
let mut last = None;
for _ in 0..34 {
last = macd.update(FLAT);
}
let out = last.expect("ready after 34 inputs");
assert_eq!(out.macd, 0.0, "two averages of one constant cannot differ");
assert_eq!(out.signal, 0.0);
assert_eq!(out.histogram, 0.0, "and their difference is zero");
let stepped = macd.update(200.0).expect("ready");
assert!(
stepped.macd > 0.0,
"the fast average must lead on a step up, got {}",
stepped.macd
);
}
#[test]
fn assert_families_are_covered() {
let dir = repo_root().join("cases");
let mut named: BTreeSet<String> = BTreeSet::new();
for entry in fs::read_dir(&dir).expect("cases/") {
let path = entry.expect("dir entry").path();
if path.extension().and_then(|e| e.to_str()) != Some("json") {
continue;
}
let text = fs::read_to_string(&path).expect("read case");
let value: Value = serde_json::from_str(&text).expect("parse case");
let strategies = value.get("strategy").map_or_else(
|| {
value
.get("cases")
.and_then(Value::as_array)
.map(|cases| cases.iter().filter_map(|c| c.get("strategy")).collect())
.unwrap_or_default()
},
|s| vec![s],
);
for strategy in strategies {
let Some(indicators) = strategy.get("indicators").and_then(Value::as_object) else {
continue;
};
for indicator in indicators.values() {
if let Some(kind) = indicator.get("type").and_then(Value::as_str) {
named.insert(kind.to_string());
}
}
}
}
assert!(!named.is_empty(), "no indicators found under cases/");
let unpinned: Vec<&String> = named
.iter()
.filter(|k| !PINNED.contains(&k.as_str()))
.collect();
assert!(
unpinned.is_empty(),
"cases/ names indicator families this file does not pin: {unpinned:?}. \
Add a test for each, then list it in PINNED -- otherwise a change to one \
of them moves a committed hash with nothing here to say so."
);
}