chore: prepare v1.1.0 release

Update version numbers across Rust, Python, and documentation files to 1.1.0. Enhance the .gitignore to include macOS dSYM files and plans directory. Introduce new dependencies in the Rust core library and update the README to reflect recent performance benchmarks and backtesting engine capabilities. Add new artifacts to the benchmarks manifest and improve documentation for the backtesting engine API.
This commit is contained in:
Pratik Bhadane
2026-03-30 12:45:52 +05:30
parent 2d776b6f90
commit 436954138f
174 changed files with 29297 additions and 10773 deletions
+977
View File
@@ -0,0 +1,977 @@
//! Extended indicators — pure Rust implementations (no PyO3, no numpy).
//!
//! These indicators are not part of TA-Lib and provide additional technical
//! analysis capabilities. All functions operate on `&[f64]` slices and return
//! `Vec<f64>` (or tuples thereof).
#![allow(clippy::too_many_arguments)]
use crate::math;
use crate::overlap;
// Note: we use a local compute_atr helper (seeds from bar 0) rather than
// crate::volatility::atr (which seeds from bar 1, TA-Lib style).
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
/// Compute ATR array using Wilder smoothing (same algorithm as in the PyO3
/// extended module — seeds from bar 0, not bar 1 like TA-Lib's `volatility::atr`).
fn compute_atr(high: &[f64], low: &[f64], close: &[f64], timeperiod: usize) -> Vec<f64> {
let n = high.len();
let mut result = vec![f64::NAN; n];
if n <= timeperiod {
return result;
}
// Seed: SMA of first `timeperiod` true range values
let mut seed_sum = high[0] - low[0]; // first TR has no prev_close
for i in 1..timeperiod {
let hl = high[i] - low[i];
let hc = (high[i] - close[i - 1]).abs();
let lc = (low[i] - close[i - 1]).abs();
seed_sum += hl.max(hc).max(lc);
}
let mut atr = seed_sum / timeperiod as f64;
result[timeperiod - 1] = atr;
let pf = (timeperiod - 1) as f64;
for i in timeperiod..n {
let hl = high[i] - low[i];
let hc = (high[i] - close[i - 1]).abs();
let lc = (low[i] - close[i - 1]).abs();
let tr = hl.max(hc).max(lc);
atr = (atr * pf + tr) / timeperiod as f64;
result[i] = atr;
}
result
}
// ---------------------------------------------------------------------------
// VWAP
// ---------------------------------------------------------------------------
/// Volume Weighted Average Price (cumulative or rolling).
///
/// # Arguments
/// * `high`, `low`, `close`, `volume` — equal-length price/volume slices.
/// * `timeperiod` — 0 for cumulative VWAP from bar 0; >= 1 for a rolling window.
///
/// # Returns
/// A `Vec<f64>` of VWAP values. For rolling mode the first `timeperiod - 1`
/// entries are `NaN`.
pub fn vwap(
high: &[f64],
low: &[f64],
close: &[f64],
volume: &[f64],
timeperiod: usize,
) -> Vec<f64> {
let n = high.len();
let mut result = vec![f64::NAN; n];
if timeperiod == 0 {
let mut cum_tpv = 0.0_f64;
let mut cum_vol = 0.0_f64;
for i in 0..n {
let tp = (high[i] + low[i] + close[i]) / 3.0;
cum_tpv += tp * volume[i];
cum_vol += volume[i];
result[i] = if cum_vol != 0.0 {
cum_tpv / cum_vol
} else {
f64::NAN
};
}
} else {
// Pre-compute cumulative sums for O(n) rolling window
let mut cum_tpv_arr = vec![0.0_f64; n];
let mut cum_vol_arr = vec![0.0_f64; n];
for i in 0..n {
let tp = (high[i] + low[i] + close[i]) / 3.0;
let tpv = tp * volume[i];
cum_tpv_arr[i] = tpv + if i > 0 { cum_tpv_arr[i - 1] } else { 0.0 };
cum_vol_arr[i] = volume[i] + if i > 0 { cum_vol_arr[i - 1] } else { 0.0 };
}
for i in (timeperiod - 1)..n {
let prev_tpv = if i >= timeperiod {
cum_tpv_arr[i - timeperiod]
} else {
0.0
};
let prev_vol = if i >= timeperiod {
cum_vol_arr[i - timeperiod]
} else {
0.0
};
let w_tpv = cum_tpv_arr[i] - prev_tpv;
let w_vol = cum_vol_arr[i] - prev_vol;
result[i] = if w_vol != 0.0 {
w_tpv / w_vol
} else {
f64::NAN
};
}
}
result
}
// ---------------------------------------------------------------------------
// VWMA
// ---------------------------------------------------------------------------
/// Volume Weighted Moving Average.
///
/// `VWMA = sum(close * volume, n) / sum(volume, n)`
///
/// # Arguments
/// * `close` — price series.
/// * `volume` — volume series (same length as `close`).
/// * `timeperiod` — rolling window size (>= 1).
///
/// # Returns
/// A `Vec<f64>` with `NaN` for the first `timeperiod - 1` entries.
pub fn vwma(close: &[f64], volume: &[f64], timeperiod: usize) -> Vec<f64> {
let n = close.len();
let mut result = vec![f64::NAN; n];
if timeperiod < 1 || n < timeperiod {
return result;
}
let mut cum_cv = vec![0.0_f64; n];
let mut cum_v = vec![0.0_f64; n];
for i in 0..n {
cum_cv[i] = close[i] * volume[i] + if i > 0 { cum_cv[i - 1] } else { 0.0 };
cum_v[i] = volume[i] + if i > 0 { cum_v[i - 1] } else { 0.0 };
}
for i in (timeperiod - 1)..n {
let prev_cv = if i >= timeperiod {
cum_cv[i - timeperiod]
} else {
0.0
};
let prev_v = if i >= timeperiod {
cum_v[i - timeperiod]
} else {
0.0
};
let w_cv = cum_cv[i] - prev_cv;
let w_v = cum_v[i] - prev_v;
result[i] = if w_v != 0.0 { w_cv / w_v } else { f64::NAN };
}
result
}
// ---------------------------------------------------------------------------
// SUPERTREND
// ---------------------------------------------------------------------------
/// ATR-based Supertrend indicator.
///
/// # Returns
/// `(supertrend_line, direction)` where direction values are:
/// * `1` = uptrend
/// * `-1` = downtrend
/// * `0` = warmup (first `timeperiod` bars)
pub fn supertrend(
high: &[f64],
low: &[f64],
close: &[f64],
timeperiod: usize,
multiplier: f64,
) -> (Vec<f64>, Vec<i8>) {
let n = high.len();
let mut supertrend_out = vec![f64::NAN; n];
let mut direction = vec![0_i8; n];
if timeperiod < 1 || n <= timeperiod {
return (supertrend_out, direction);
}
let atr = compute_atr(high, low, close, timeperiod);
let mut upper_band = vec![f64::NAN; n];
let mut lower_band = vec![f64::NAN; n];
let first_valid = timeperiod - 1;
if first_valid >= n || atr[first_valid].is_nan() {
return (supertrend_out, direction);
}
// Initialize band state at first valid ATR bar (compute basic bands inline)
{
let hl2 = (high[first_valid] + low[first_valid]) / 2.0;
upper_band[first_valid] = hl2 + multiplier * atr[first_valid];
lower_band[first_valid] = hl2 - multiplier * atr[first_valid];
}
for i in (first_valid + 1)..n {
if atr[i].is_nan() {
continue;
}
// Compute basic bands as scalars — no Vec allocation needed
let hl2 = (high[i] + low[i]) / 2.0;
let upper_basic = hl2 + multiplier * atr[i];
let lower_basic = hl2 - multiplier * atr[i];
// Adjust lower band
lower_band[i] = if lower_basic > lower_band[i - 1] || close[i - 1] < lower_band[i - 1]
{
lower_basic
} else {
lower_band[i - 1]
};
// Adjust upper band
upper_band[i] = if upper_basic < upper_band[i - 1] || close[i - 1] > upper_band[i - 1]
{
upper_basic
} else {
upper_band[i - 1]
};
// Direction and output only from index timeperiod (warmup = 0, NaN)
if i >= timeperiod {
let prev_dir = direction[i - 1];
direction[i] = if prev_dir == 0 {
if close[i] > upper_band[i] {
1
} else {
-1
}
} else if prev_dir == -1 {
if close[i] > upper_band[i] {
1
} else {
-1
}
} else if close[i] < lower_band[i] {
-1
} else {
1
};
supertrend_out[i] = if direction[i] == 1 {
lower_band[i]
} else {
upper_band[i]
};
}
}
(supertrend_out, direction)
}
// ---------------------------------------------------------------------------
// DONCHIAN
// ---------------------------------------------------------------------------
/// Donchian Channels — rolling highest high / lowest low.
///
/// # Returns
/// `(upper, middle, lower)` arrays.
pub fn donchian(
high: &[f64],
low: &[f64],
timeperiod: usize,
) -> (Vec<f64>, Vec<f64>, Vec<f64>) {
let n = high.len();
let mut upper = vec![f64::NAN; n];
let mut lower = vec![f64::NAN; n];
let mut middle = vec![f64::NAN; n];
if timeperiod < 1 || n < timeperiod {
return (upper, middle, lower);
}
let hh = math::sliding_max(high, timeperiod);
let ll = math::sliding_min(low, timeperiod);
for i in 0..n {
if !hh[i].is_nan() {
upper[i] = hh[i];
lower[i] = ll[i];
middle[i] = (upper[i] + lower[i]) / 2.0;
}
}
(upper, middle, lower)
}
// ---------------------------------------------------------------------------
// CHOPPINESS_INDEX
// ---------------------------------------------------------------------------
/// Choppiness Index — measures market choppiness vs trending.
///
/// Values near 100 indicate a choppy market; near 0 indicates trending.
/// The first `timeperiod` values are `NaN`.
pub fn choppiness_index(
high: &[f64],
low: &[f64],
close: &[f64],
timeperiod: usize,
) -> Vec<f64> {
let n = high.len();
let mut result = vec![f64::NAN; n];
if timeperiod < 1 || n <= timeperiod {
return result;
}
// ATR(1) = True Range per bar
let mut tr = vec![0.0_f64; n];
tr[0] = high[0] - low[0];
for i in 1..n {
let hl = high[i] - low[i];
let hc = (high[i] - close[i - 1]).abs();
let lc = (low[i] - close[i - 1]).abs();
tr[i] = hl.max(hc).max(lc);
}
// Cumulative TR for rolling sum
let mut cum_tr = vec![0.0_f64; n];
cum_tr[0] = tr[0];
for i in 1..n {
cum_tr[i] = cum_tr[i - 1] + tr[i];
}
let log_n = (timeperiod as f64).log10();
let hh = math::sliding_max(high, timeperiod);
let ll = math::sliding_min(low, timeperiod);
for i in (timeperiod)..n {
let prev_cum = cum_tr[i - timeperiod];
let sum_tr = cum_tr[i] - prev_cum;
let hl_range = hh[i] - ll[i];
if hl_range > 0.0 && log_n > 0.0 {
result[i] = 100.0 * (sum_tr / hl_range).log10() / log_n;
}
}
result
}
// ---------------------------------------------------------------------------
// KELTNER_CHANNELS
// ---------------------------------------------------------------------------
/// Keltner Channels — EMA +/- (multiplier x ATR).
///
/// # Returns
/// `(upper, middle, lower)` arrays.
pub fn keltner_channels(
high: &[f64],
low: &[f64],
close: &[f64],
timeperiod: usize,
atr_period: usize,
multiplier: f64,
) -> (Vec<f64>, Vec<f64>, Vec<f64>) {
let n = high.len();
if timeperiod < 1 || atr_period < 1 || n < timeperiod || n < atr_period {
let nan = vec![f64::NAN; n];
return (nan.clone(), nan.clone(), nan);
}
let middle = overlap::ema(close, timeperiod);
let atr = compute_atr(high, low, close, atr_period);
let mut upper = vec![f64::NAN; n];
let mut lower = vec![f64::NAN; n];
for i in 0..n {
if !middle[i].is_nan() && !atr[i].is_nan() {
let band = multiplier * atr[i];
upper[i] = middle[i] + band;
lower[i] = middle[i] - band;
}
}
(upper, middle, lower)
}
// ---------------------------------------------------------------------------
// HULL_MA
// ---------------------------------------------------------------------------
/// Hull Moving Average (HMA).
///
/// `HMA(n) = WMA(2 * WMA(n/2) - WMA(n), sqrt(n))`
pub fn hull_ma(close: &[f64], timeperiod: usize) -> Vec<f64> {
let n = close.len();
if timeperiod < 1 || n < timeperiod {
return vec![f64::NAN; n];
}
let half = (timeperiod / 2).max(1);
let sqrt_p = ((timeperiod as f64).sqrt().round() as usize).max(1);
let wma_full = overlap::wma(close, timeperiod);
let wma_half = overlap::wma(close, half);
// raw = 2 * wma_half - wma_full
let mut raw = vec![f64::NAN; n];
for i in 0..n {
if !wma_full[i].is_nan() && !wma_half[i].is_nan() {
raw[i] = 2.0 * wma_half[i] - wma_full[i];
}
}
// Find first valid index in raw
let first_valid = raw.iter().position(|x| !x.is_nan()).unwrap_or(n);
let mut hull = vec![f64::NAN; n];
if first_valid < n {
let raw_valid = &raw[first_valid..];
let hma_slice = overlap::wma(raw_valid, sqrt_p);
for (k, &v) in hma_slice.iter().enumerate() {
hull[first_valid + k] = v;
}
}
hull
}
// ---------------------------------------------------------------------------
// CHANDELIER_EXIT
// ---------------------------------------------------------------------------
/// Chandelier Exit — ATR-based trailing stop levels.
///
/// # Returns
/// `(long_exit, short_exit)` arrays.
pub fn chandelier_exit(
high: &[f64],
low: &[f64],
close: &[f64],
timeperiod: usize,
multiplier: f64,
) -> (Vec<f64>, Vec<f64>) {
let n = high.len();
if timeperiod < 1 || n < timeperiod {
return (vec![f64::NAN; n], vec![f64::NAN; n]);
}
let atr = compute_atr(high, low, close, timeperiod);
let highest_high = math::sliding_max(high, timeperiod);
let lowest_low = math::sliding_min(low, timeperiod);
let mut long_exit = vec![f64::NAN; n];
let mut short_exit = vec![f64::NAN; n];
for i in 0..n {
if !highest_high[i].is_nan() && !atr[i].is_nan() {
long_exit[i] = highest_high[i] - multiplier * atr[i];
short_exit[i] = lowest_low[i] + multiplier * atr[i];
}
}
(long_exit, short_exit)
}
// ---------------------------------------------------------------------------
// ICHIMOKU
// ---------------------------------------------------------------------------
/// Ichimoku Cloud (Ichimoku Kinko Hyo).
///
/// # Returns
/// `(tenkan, kijun, senkou_a, senkou_b, chikou)` arrays.
pub fn ichimoku(
high: &[f64],
low: &[f64],
close: &[f64],
tenkan_period: usize,
kijun_period: usize,
senkou_b_period: usize,
displacement: usize,
) -> (Vec<f64>, Vec<f64>, Vec<f64>, Vec<f64>, Vec<f64>) {
let n = high.len();
let nan = || vec![f64::NAN; n];
if tenkan_period < 1 || kijun_period < 1 || senkou_b_period < 1 {
return (nan(), nan(), nan(), nan(), nan());
}
// Helper: rolling (H+L)/2 via shared sliding_max / sliding_min
let midpoint_rolling = |period: usize| -> Vec<f64> {
let hh = math::sliding_max(high, period);
let ll = math::sliding_min(low, period);
let mut result = vec![f64::NAN; n];
for i in 0..n {
if !hh[i].is_nan() {
result[i] = (hh[i] + ll[i]) / 2.0;
}
}
result
};
let tenkan = midpoint_rolling(tenkan_period);
let kijun = midpoint_rolling(kijun_period);
let raw_b = midpoint_rolling(senkou_b_period);
// Senkou A: (tenkan + kijun) / 2 shifted back `displacement` bars
let mut senkou_a = vec![f64::NAN; n];
if n > displacement {
for i in displacement..n {
if !tenkan[i].is_nan() && !kijun[i].is_nan() {
senkou_a[i - displacement] = (tenkan[i] + kijun[i]) / 2.0;
}
}
}
// Senkou B: raw_b shifted back `displacement` bars
let mut senkou_b = vec![f64::NAN; n];
if n > displacement {
senkou_b[..n - displacement].copy_from_slice(&raw_b[displacement..]);
}
// Chikou: close shifted forward `displacement` bars
let mut chikou = vec![f64::NAN; n];
if n > displacement {
chikou[displacement..].copy_from_slice(&close[..n - displacement]);
}
(tenkan, kijun, senkou_a, senkou_b, chikou)
}
// ---------------------------------------------------------------------------
// PIVOT_POINTS
// ---------------------------------------------------------------------------
/// Pivot Points — support / resistance levels computed from the previous bar.
///
/// # Arguments
/// * `method` — `"classic"`, `"fibonacci"`, or `"camarilla"`. Returns all-NaN
/// vectors for unknown methods.
///
/// # Returns
/// `(pivot, r1, s1, r2, s2)` arrays. Index 0 is always `NaN` (no previous bar).
pub fn pivot_points(
high: &[f64],
low: &[f64],
close: &[f64],
method: &str,
) -> (Vec<f64>, Vec<f64>, Vec<f64>, Vec<f64>, Vec<f64>) {
let n = high.len();
let mut pivot = vec![f64::NAN; n];
let mut r1 = vec![f64::NAN; n];
let mut s1 = vec![f64::NAN; n];
let mut r2 = vec![f64::NAN; n];
let mut s2 = vec![f64::NAN; n];
let method_lower = method.to_lowercase();
if !matches!(method_lower.as_str(), "classic" | "fibonacci" | "camarilla") {
// Unknown method — return all NaN
return (pivot, r1, s1, r2, s2);
}
for i in 1..n {
let ph = high[i - 1];
let pl = low[i - 1];
let pc = close[i - 1];
let hl = ph - pl;
let p = (ph + pl + pc) / 3.0;
pivot[i] = p;
match method_lower.as_str() {
"classic" => {
r1[i] = 2.0 * p - pl;
s1[i] = 2.0 * p - ph;
r2[i] = p + hl;
s2[i] = p - hl;
}
"fibonacci" => {
r1[i] = p + 0.382 * hl;
s1[i] = p - 0.382 * hl;
r2[i] = p + 0.618 * hl;
s2[i] = p - 0.618 * hl;
}
"camarilla" => {
r1[i] = pc + 1.1 * hl / 12.0;
s1[i] = pc - 1.1 * hl / 12.0;
r2[i] = pc + 1.1 * hl / 6.0;
s2[i] = pc - 1.1 * hl / 6.0;
}
_ => unreachable!(),
}
}
(pivot, r1, s1, r2, s2)
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
// Shared test data: 10-bar OHLCV
fn sample_ohlcv() -> (Vec<f64>, Vec<f64>, Vec<f64>, Vec<f64>) {
let high = vec![11.0, 12.0, 13.0, 14.0, 15.0, 14.5, 15.5, 16.0, 15.0, 14.0];
let low = vec![9.0, 10.0, 11.0, 12.0, 13.0, 12.5, 13.5, 14.0, 13.0, 12.0];
let close = vec![10.0, 11.0, 12.0, 13.0, 14.0, 13.5, 14.5, 15.0, 14.0, 13.0];
let volume = vec![
100.0, 150.0, 200.0, 250.0, 300.0, 200.0, 350.0, 400.0, 180.0, 220.0,
];
(high, low, close, volume)
}
// -----------------------------------------------------------------------
// VWAP tests
// -----------------------------------------------------------------------
#[test]
fn vwap_cumulative_basic() {
let (h, l, c, v) = sample_ohlcv();
let result = vwap(&h, &l, &c, &v, 0);
assert_eq!(result.len(), h.len());
// First bar: tp = (11+9+10)/3 = 10.0, tpv = 1000.0, vol = 100.0 => 10.0
assert!((result[0] - 10.0).abs() < 1e-10);
// All values should be non-NaN for cumulative
for val in &result {
assert!(!val.is_nan());
}
}
#[test]
fn vwap_empty_input() {
let result = vwap(&[], &[], &[], &[], 0);
assert!(result.is_empty());
}
#[test]
fn vwap_rolling_basic() {
let (h, l, c, v) = sample_ohlcv();
let result = vwap(&h, &l, &c, &v, 3);
assert_eq!(result.len(), h.len());
// First 2 values should be NaN
assert!(result[0].is_nan());
assert!(result[1].is_nan());
// From index 2 onward should be valid
assert!(!result[2].is_nan());
}
// -----------------------------------------------------------------------
// VWMA tests
// -----------------------------------------------------------------------
#[test]
fn vwma_basic() {
let (_, _, c, v) = sample_ohlcv();
let result = vwma(&c, &v, 3);
assert_eq!(result.len(), c.len());
assert!(result[0].is_nan());
assert!(result[1].is_nan());
// Index 2: sum(c*v, 0..3) / sum(v, 0..3) = (1000+1650+2400)/(100+150+200) = 5050/450
let expected = (10.0 * 100.0 + 11.0 * 150.0 + 12.0 * 200.0) / (100.0 + 150.0 + 200.0);
assert!((result[2] - expected).abs() < 1e-10);
}
#[test]
fn vwma_empty_input() {
let result = vwma(&[], &[], 3);
assert!(result.is_empty());
}
#[test]
fn vwma_period_larger_than_data() {
let result = vwma(&[1.0, 2.0], &[100.0, 200.0], 5);
assert_eq!(result.len(), 2);
assert!(result.iter().all(|v| v.is_nan()));
}
// -----------------------------------------------------------------------
// SUPERTREND tests
// -----------------------------------------------------------------------
#[test]
fn supertrend_basic() {
let (h, l, c, _) = sample_ohlcv();
let (st, dir) = supertrend(&h, &l, &c, 3, 2.0);
assert_eq!(st.len(), h.len());
assert_eq!(dir.len(), h.len());
// First 3 bars should be warmup (direction = 0, st = NaN)
for i in 0..3 {
assert_eq!(dir[i], 0);
assert!(st[i].is_nan());
}
// From bar 3 onward, direction should be 1 or -1
for i in 3..h.len() {
assert!(dir[i] == 1 || dir[i] == -1);
assert!(!st[i].is_nan());
}
}
#[test]
fn supertrend_empty_input() {
let (st, dir) = supertrend(&[], &[], &[], 3, 2.0);
assert!(st.is_empty());
assert!(dir.is_empty());
}
#[test]
fn supertrend_insufficient_data() {
let (st, dir) = supertrend(&[1.0, 2.0], &[0.5, 1.5], &[1.5, 1.8], 5, 2.0);
assert!(st.iter().all(|v| v.is_nan()));
assert!(dir.iter().all(|&d| d == 0));
}
// -----------------------------------------------------------------------
// DONCHIAN tests
// -----------------------------------------------------------------------
#[test]
fn donchian_basic() {
let (h, l, _, _) = sample_ohlcv();
let (upper, middle, lower) = donchian(&h, &l, 3);
assert_eq!(upper.len(), h.len());
// First 2 are NaN
assert!(upper[0].is_nan());
assert!(upper[1].is_nan());
// Index 2: max(11,12,13)=13, min(9,10,11)=9
assert!((upper[2] - 13.0).abs() < 1e-10);
assert!((lower[2] - 9.0).abs() < 1e-10);
assert!((middle[2] - 11.0).abs() < 1e-10);
}
#[test]
fn donchian_empty_input() {
let (u, m, l) = donchian(&[], &[], 3);
assert!(u.is_empty());
assert!(m.is_empty());
assert!(l.is_empty());
}
#[test]
fn donchian_period_1() {
let h = vec![5.0, 3.0, 7.0];
let l = vec![2.0, 1.0, 4.0];
let (upper, middle, lower) = donchian(&h, &l, 1);
// Every bar is its own window
assert!((upper[0] - 5.0).abs() < 1e-10);
assert!((lower[0] - 2.0).abs() < 1e-10);
assert!((middle[0] - 3.5).abs() < 1e-10);
}
// -----------------------------------------------------------------------
// CHOPPINESS_INDEX tests
// -----------------------------------------------------------------------
#[test]
fn choppiness_index_basic() {
let (h, l, c, _) = sample_ohlcv();
let result = choppiness_index(&h, &l, &c, 3);
assert_eq!(result.len(), h.len());
// First 3 values should be NaN (timeperiod=3, i+1 > 3 starts at i=3)
assert!(result[0].is_nan());
assert!(result[1].is_nan());
assert!(result[2].is_nan());
// Index 3 should have a valid value (i+1=4 > 3)
assert!(!result[3].is_nan());
// CI should be between 0 and 100
for val in result.iter().filter(|v| !v.is_nan()) {
assert!(*val >= 0.0 && *val <= 100.0);
}
}
#[test]
fn choppiness_index_empty_input() {
let result = choppiness_index(&[], &[], &[], 3);
assert!(result.is_empty());
}
// -----------------------------------------------------------------------
// KELTNER_CHANNELS tests
// -----------------------------------------------------------------------
#[test]
fn keltner_channels_basic() {
let (h, l, c, _) = sample_ohlcv();
let (upper, middle, lower) = keltner_channels(&h, &l, &c, 3, 3, 1.5);
assert_eq!(upper.len(), h.len());
// Where both EMA and ATR are valid, upper > middle > lower
for i in 0..h.len() {
if !upper[i].is_nan() && !lower[i].is_nan() {
assert!(upper[i] > middle[i]);
assert!(lower[i] < middle[i]);
}
}
}
#[test]
fn keltner_channels_empty_input() {
let (u, m, l) = keltner_channels(&[], &[], &[], 3, 3, 1.5);
assert!(u.is_empty());
assert!(m.is_empty());
assert!(l.is_empty());
}
// -----------------------------------------------------------------------
// HULL_MA tests
// -----------------------------------------------------------------------
#[test]
fn hull_ma_basic() {
let prices: Vec<f64> = (1..=20).map(|i| i as f64).collect();
let result = hull_ma(&prices, 4);
assert_eq!(result.len(), prices.len());
// Should have some NaN warmup, then valid values
let valid_count = result.iter().filter(|v| !v.is_nan()).count();
assert!(valid_count > 0);
}
#[test]
fn hull_ma_empty_input() {
let result = hull_ma(&[], 4);
assert!(result.is_empty());
}
#[test]
fn hull_ma_period_larger_than_data() {
let result = hull_ma(&[1.0, 2.0], 10);
assert!(result.iter().all(|v| v.is_nan()));
}
// -----------------------------------------------------------------------
// CHANDELIER_EXIT tests
// -----------------------------------------------------------------------
#[test]
fn chandelier_exit_basic() {
let (h, l, c, _) = sample_ohlcv();
let (long_exit, short_exit) = chandelier_exit(&h, &l, &c, 3, 2.0);
assert_eq!(long_exit.len(), h.len());
assert_eq!(short_exit.len(), h.len());
// Where valid, long_exit should be below highest high
for i in 0..h.len() {
if !long_exit[i].is_nan() {
// long_exit = highest_high - multiplier * atr, should be < max high
assert!(long_exit[i] < 20.0); // sanity
}
}
}
#[test]
fn chandelier_exit_empty_input() {
let (le, se) = chandelier_exit(&[], &[], &[], 3, 2.0);
assert!(le.is_empty());
assert!(se.is_empty());
}
// -----------------------------------------------------------------------
// ICHIMOKU tests
// -----------------------------------------------------------------------
#[test]
fn ichimoku_basic() {
// Use a larger dataset for ichimoku
let n = 60;
let high: Vec<f64> = (0..n).map(|i| 100.0 + i as f64 + 1.0).collect();
let low: Vec<f64> = (0..n).map(|i| 100.0 + i as f64 - 1.0).collect();
let close: Vec<f64> = (0..n).map(|i| 100.0 + i as f64).collect();
let (tenkan, kijun, senkou_a, senkou_b, chikou) =
ichimoku(&high, &low, &close, 9, 26, 52, 26);
assert_eq!(tenkan.len(), n);
assert_eq!(kijun.len(), n);
assert_eq!(senkou_a.len(), n);
assert_eq!(senkou_b.len(), n);
assert_eq!(chikou.len(), n);
// Tenkan: period 9, first valid at index 8
assert!(tenkan[7].is_nan());
assert!(!tenkan[8].is_nan());
// Kijun: period 26, first valid at index 25
assert!(kijun[24].is_nan());
assert!(!kijun[25].is_nan());
// Chikou: close shifted forward by 26 bars
assert!(chikou[25].is_nan());
assert!(!chikou[26].is_nan());
assert!((chikou[26] - close[0]).abs() < 1e-10);
}
#[test]
fn ichimoku_empty_input() {
let (t, k, sa, sb, ch) = ichimoku(&[], &[], &[], 9, 26, 52, 26);
assert!(t.is_empty());
assert!(k.is_empty());
assert!(sa.is_empty());
assert!(sb.is_empty());
assert!(ch.is_empty());
}
// -----------------------------------------------------------------------
// PIVOT_POINTS tests
// -----------------------------------------------------------------------
#[test]
fn pivot_points_classic() {
let h = vec![10.0, 12.0, 11.0];
let l = vec![8.0, 9.0, 8.5];
let c = vec![9.0, 11.0, 10.0];
let (pivot, r1, s1, r2, s2) = pivot_points(&h, &l, &c, "classic");
assert_eq!(pivot.len(), 3);
// Index 0 is NaN
assert!(pivot[0].is_nan());
// Index 1: prev bar H=10, L=8, C=9 => P=(10+8+9)/3=9.0
assert!((pivot[1] - 9.0).abs() < 1e-10);
// R1 = 2*P - L = 18 - 8 = 10
assert!((r1[1] - 10.0).abs() < 1e-10);
// S1 = 2*P - H = 18 - 10 = 8
assert!((s1[1] - 8.0).abs() < 1e-10);
// R2 = P + (H-L) = 9 + 2 = 11
assert!((r2[1] - 11.0).abs() < 1e-10);
// S2 = P - (H-L) = 9 - 2 = 7
assert!((s2[1] - 7.0).abs() < 1e-10);
}
#[test]
fn pivot_points_fibonacci() {
let h = vec![10.0, 12.0];
let l = vec![8.0, 9.0];
let c = vec![9.0, 11.0];
let (pivot, r1, s1, _, _) = pivot_points(&h, &l, &c, "fibonacci");
// Index 1: P = (10+8+9)/3 = 9.0, HL = 2
assert!((pivot[1] - 9.0).abs() < 1e-10);
assert!((r1[1] - (9.0 + 0.382 * 2.0)).abs() < 1e-10);
assert!((s1[1] - (9.0 - 0.382 * 2.0)).abs() < 1e-10);
}
#[test]
fn pivot_points_camarilla() {
let h = vec![10.0, 12.0];
let l = vec![8.0, 9.0];
let c = vec![9.0, 11.0];
let (pivot, r1, s1, _, _) = pivot_points(&h, &l, &c, "camarilla");
assert!((pivot[1] - 9.0).abs() < 1e-10);
// R1 = C + 1.1 * HL / 12 = 9 + 1.1*2/12
assert!((r1[1] - (9.0 + 1.1 * 2.0 / 12.0)).abs() < 1e-10);
assert!((s1[1] - (9.0 - 1.1 * 2.0 / 12.0)).abs() < 1e-10);
}
#[test]
fn pivot_points_unknown_method() {
let h = vec![10.0, 12.0];
let l = vec![8.0, 9.0];
let c = vec![9.0, 11.0];
let (pivot, r1, s1, r2, s2) = pivot_points(&h, &l, &c, "unknown");
assert!(pivot.iter().all(|v| v.is_nan()));
assert!(r1.iter().all(|v| v.is_nan()));
assert!(s1.iter().all(|v| v.is_nan()));
assert!(r2.iter().all(|v| v.is_nan()));
assert!(s2.iter().all(|v| v.is_nan()));
}
#[test]
fn pivot_points_empty_input() {
let (p, r1, s1, r2, s2) = pivot_points(&[], &[], &[], "classic");
assert!(p.is_empty());
assert!(r1.is_empty());
assert!(s1.is_empty());
assert!(r2.is_empty());
assert!(s2.is_empty());
}
}