Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
963 lines
30 KiB
Rust
963 lines
30 KiB
Rust
//! 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 || 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.
|
|
#[allow(clippy::type_complexity)]
|
|
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).
|
|
#[allow(clippy::type_complexity)]
|
|
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());
|
|
}
|
|
}
|