F11: add SuperTrend, Chandelier Exit, Chande Kroll Stop and ATR Trailing Stop

- Rust core: super_trend.rs (SuperTrend — ATR-banded trailing stop with
  flip logic; SuperTrendOutput { value, direction }), chandelier_exit.rs
  (Chandelier Exit — ATR stop hung off the window's highest high / lowest
  low; ChandelierExitOutput { long_stop, short_stop }),
  chande_kroll_stop.rs (Chande Kroll Stop — a two-stage ATR stop;
  ChandeKrollStopOutput { stop_long, stop_short }), atr_trailing_stop.rs
  (ATR Trailing Stop — a single ratcheting close-based stop). Each with a
  full Indicator impl, runnable doctest and reference / property / warmup
  / reset / batch==streaming tests.
- Python: PySuperTrend / PyChandelierExit / PyChandeKrollStop /
  PyAtrTrailingStop PyO3 classes (struct outputs as tuples and (n, 2)
  arrays) + module registration + .pyi stubs.
- Node: explicit SuperTrendNode / ChandelierExitNode / ChandeKrollStopNode
  / AtrTrailingStopNode with SuperTrendValue / ChandelierExitValue /
  ChandeKrollStopValue objects; index.d.ts and index.js updated.
- WASM: WasmSuperTrend / WasmChandelierExit / WasmChandeKrollStop /
  WasmAtrTrailingStop.
- Wiki: Indicator-SuperTrend/ChandelierExit/ChandeKrollStop/
  AtrTrailingStop.md plus rows in the "Trailing stop" table of
  Indicators-Overview.md and entries in Home.md.
- Add clippy.toml with doc-valid-idents for the proper noun "LeBeau".

cargo fmt + clippy (core/wickra/data/wasm/node) clean; 427 core tests,
25 data tests and 61 doctests green.
This commit is contained in:
kingchenc
2026-05-22 19:42:14 +02:00
parent 0b11a523a0
commit 21bbd521b3
18 changed files with 2643 additions and 13 deletions
@@ -0,0 +1,268 @@
//! ATR Trailing Stop.
use crate::error::{Error, Result};
use crate::indicators::atr::Atr;
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// ATR Trailing Stop — a stop level that trails price by a fixed ATR multiple
/// and ratchets in the direction of the trend.
///
/// ```text
/// loss = multiplier · ATR
///
/// stop_t = max(stop_{t1}, close loss) while price holds above the stop
/// = min(stop_{t1}, close + loss) while price holds below the stop
/// = close loss on a fresh break above the stop
/// = close + loss on a fresh break below the stop
/// ```
///
/// While price stays on one side of the stop the level only ratchets toward
/// price — up in an uptrend, down in a downtrend — never away from it. When a
/// close crosses the stop the level snaps to the opposite side, `loss` away
/// from the new close, flipping the trade. This is the trailing stop used by
/// the well-known "UT Bot"; the first ATR-ready bar seeds the stop below
/// price (a long).
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, AtrTrailingStop};
///
/// let mut indicator = AtrTrailingStop::new(14, 3.0).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// let base = 100.0 + f64::from(i);
/// let candle =
/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
/// last = indicator.update(candle);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct AtrTrailingStop {
atr: Atr,
multiplier: f64,
atr_period: usize,
prev_close: Option<f64>,
prev_stop: Option<f64>,
}
impl AtrTrailingStop {
/// Construct an ATR Trailing Stop with an explicit ATR period and multiple.
///
/// # Errors
/// Returns [`Error::PeriodZero`] if `atr_period == 0` and
/// [`Error::NonPositiveMultiplier`] if `multiplier` is not strictly
/// positive and finite.
pub fn new(atr_period: usize, multiplier: f64) -> Result<Self> {
if !multiplier.is_finite() || multiplier <= 0.0 {
return Err(Error::NonPositiveMultiplier);
}
Ok(Self {
atr: Atr::new(atr_period)?,
multiplier,
atr_period,
prev_close: None,
prev_stop: None,
})
}
/// A common configuration: `ATR(14)` with a `3.0` multiplier.
pub fn classic() -> Self {
Self::new(14, 3.0).expect("classic ATR Trailing Stop params are valid")
}
/// Configured `(atr_period, multiplier)`.
pub const fn params(&self) -> (usize, f64) {
(self.atr_period, self.multiplier)
}
}
impl Indicator for AtrTrailingStop {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
let atr = self.atr.update(candle)?;
let loss = self.multiplier * atr;
let close = candle.close;
let stop = match (self.prev_stop, self.prev_close) {
(Some(prev_stop), Some(prev_close)) => {
if close > prev_stop && prev_close > prev_stop {
// Holding above the stop — ratchet it up only.
(close - loss).max(prev_stop)
} else if close < prev_stop && prev_close < prev_stop {
// Holding below the stop — ratchet it down only.
(close + loss).min(prev_stop)
} else if close > prev_stop {
// Fresh break above — place the stop below the new close.
close - loss
} else {
// Fresh break below — place the stop above the new close.
close + loss
}
}
// First ATR-ready bar: seed the stop below price (a long).
_ => close - loss,
};
self.prev_close = Some(close);
self.prev_stop = Some(stop);
Some(stop)
}
fn reset(&mut self) {
self.atr.reset();
self.prev_close = None;
self.prev_stop = None;
}
fn warmup_period(&self) -> usize {
self.atr_period
}
fn is_ready(&self) -> bool {
self.prev_stop.is_some()
}
fn name(&self) -> &'static str {
"AtrTrailingStop"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn c(high: f64, low: f64, close: f64, ts: i64) -> Candle {
Candle::new((high + low) / 2.0, high, low, close, 1.0, ts).unwrap()
}
#[test]
fn reference_values_flat_market() {
// Flat candles H=11, L=9, C=10 -> TR=2 -> ATR=2; loss = 3·2 = 6.
// Seed stop = close - loss = 10 - 6 = 4, and it holds there.
let candles: Vec<Candle> = (0..20).map(|i| c(11.0, 9.0, 10.0, i)).collect();
let mut ts = AtrTrailingStop::new(5, 3.0).unwrap();
for v in ts.batch(&candles).into_iter().flatten() {
assert_relative_eq!(v, 4.0, epsilon = 1e-12);
}
}
#[test]
fn uptrend_stop_ratchets_up_and_stays_below_price() {
let candles: Vec<Candle> = (0..50)
.map(|i| {
let base = 100.0 + i as f64;
c(base + 1.0, base - 1.0, base, i)
})
.collect();
let mut ts = AtrTrailingStop::new(14, 3.0).unwrap();
let emitted: Vec<(f64, f64)> = ts
.batch(&candles)
.into_iter()
.zip(candles.iter())
.filter_map(|(o, c)| o.map(|v| (v, c.close)))
.collect();
for w in emitted.windows(2) {
assert!(
w[1].0 >= w[0].0 - 1e-9,
"stop must not loosen in an uptrend"
);
}
for &(stop, close) in &emitted {
assert!(stop < close, "uptrend stop should sit below the close");
}
}
#[test]
fn stop_flips_to_the_other_side_when_price_reverses() {
let mut candles: Vec<Candle> = (0..40)
.map(|i| {
let base = 100.0 + i as f64;
c(base + 1.0, base - 1.0, base, i)
})
.collect();
// A steep decline drags price through the trailing stop.
candles.extend((0..40).map(|i| {
let base = 140.0 - 3.0 * i as f64;
c(base + 1.0, base - 1.0, base, 40 + i)
}));
let mut ts = AtrTrailingStop::new(14, 3.0).unwrap();
let paired: Vec<(f64, f64)> = ts
.batch(&candles)
.into_iter()
.zip(candles.iter())
.filter_map(|(o, c)| o.map(|v| (v, c.close)))
.collect();
assert!(
paired.iter().any(|&(stop, close)| stop < close),
"expected a long stretch with the stop below price"
);
assert!(
paired.iter().any(|&(stop, close)| stop > close),
"expected the stop to flip above price after the reversal"
);
}
#[test]
fn first_emission_matches_warmup_period() {
let candles: Vec<Candle> = (0..20)
.map(|i| {
let base = 100.0 + i as f64;
c(base + 1.0, base - 1.0, base, i)
})
.collect();
let mut ts = AtrTrailingStop::new(8, 3.0).unwrap();
let out = ts.batch(&candles);
assert_eq!(ts.warmup_period(), 8);
for (i, v) in out.iter().enumerate().take(7) {
assert!(v.is_none(), "index {i} must be None during warmup");
}
assert!(out[7].is_some(), "first value lands at warmup_period - 1");
}
#[test]
fn rejects_invalid_params() {
assert!(AtrTrailingStop::new(0, 3.0).is_err());
assert!(AtrTrailingStop::new(14, 0.0).is_err());
assert!(AtrTrailingStop::new(14, -1.0).is_err());
assert!(AtrTrailingStop::new(14, f64::NAN).is_err());
}
#[test]
fn reset_clears_state() {
let candles: Vec<Candle> = (0..40)
.map(|i| {
let base = 100.0 + i as f64;
c(base + 1.0, base - 1.0, base, i)
})
.collect();
let mut ts = AtrTrailingStop::classic();
ts.batch(&candles);
assert!(ts.is_ready());
ts.reset();
assert!(!ts.is_ready());
assert_eq!(ts.update(candles[0]), None);
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..80)
.map(|i| {
let mid = 100.0 + (i as f64 * 0.3).sin() * 8.0;
c(mid + 1.5, mid - 1.5, mid + 0.5, i)
})
.collect();
let mut a = AtrTrailingStop::classic();
let mut b = AtrTrailingStop::classic();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,248 @@
//! Chande Kroll Stop.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::indicators::atr::Atr;
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Chande Kroll Stop output: the long-side and short-side stop levels.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ChandeKrollStopOutput {
/// Long-position stop — the lowest preliminary low-stop over `stop_period`.
pub stop_long: f64,
/// Short-position stop — the highest preliminary high-stop over `stop_period`.
pub stop_short: f64,
}
/// Chande Kroll Stop — Tushar Chande and Stanley Kroll's two-stage ATR stop.
///
/// ```text
/// preliminary (window p = atr_period, x = atr_multiplier):
/// high_stop = highest_high(p) x · ATR(p)
/// low_stop = lowest_low(p) + x · ATR(p)
///
/// final (window q = stop_period):
/// stop_short = highest(high_stop, q)
/// stop_long = lowest(low_stop, q)
/// ```
///
/// The first stage builds an ATR stop off the recent extreme, exactly like a
/// [`ChandelierExit`](crate::ChandelierExit); the second stage smooths it by
/// taking the most extreme preliminary stop over a shorter window, which keeps
/// the stop from whipsawing on a single wide bar. The classic configuration
/// from *The New Technical Trader* is `ATR(10)`, multiplier `1.0`, smoothing
/// window `9`.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, ChandeKrollStop};
///
/// let mut indicator = ChandeKrollStop::new(10, 1.0, 9).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// let base = 100.0 + f64::from(i);
/// let candle =
/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
/// last = indicator.update(candle);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct ChandeKrollStop {
atr_period: usize,
atr_multiplier: f64,
stop_period: usize,
atr: Atr,
highs: VecDeque<f64>,
lows: VecDeque<f64>,
high_stops: VecDeque<f64>,
low_stops: VecDeque<f64>,
}
impl ChandeKrollStop {
/// Construct a Chande Kroll Stop with explicit ATR and smoothing windows.
///
/// # Errors
/// Returns [`Error::PeriodZero`] if `atr_period` or `stop_period` is zero,
/// and [`Error::NonPositiveMultiplier`] if `atr_multiplier` is not strictly
/// positive and finite.
pub fn new(atr_period: usize, atr_multiplier: f64, stop_period: usize) -> Result<Self> {
if !atr_multiplier.is_finite() || atr_multiplier <= 0.0 {
return Err(Error::NonPositiveMultiplier);
}
if stop_period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
atr_period,
atr_multiplier,
stop_period,
atr: Atr::new(atr_period)?,
highs: VecDeque::with_capacity(atr_period),
lows: VecDeque::with_capacity(atr_period),
high_stops: VecDeque::with_capacity(stop_period),
low_stops: VecDeque::with_capacity(stop_period),
})
}
/// The classic configuration: `ATR(10)`, multiplier `1.0`, window `9`.
pub fn classic() -> Self {
Self::new(10, 1.0, 9).expect("classic Chande Kroll Stop params are valid")
}
/// Configured `(atr_period, atr_multiplier, stop_period)`.
pub const fn params(&self) -> (usize, f64, usize) {
(self.atr_period, self.atr_multiplier, self.stop_period)
}
}
impl Indicator for ChandeKrollStop {
type Input = Candle;
type Output = ChandeKrollStopOutput;
fn update(&mut self, candle: Candle) -> Option<ChandeKrollStopOutput> {
let atr = self.atr.update(candle);
if self.highs.len() == self.atr_period {
self.highs.pop_front();
self.lows.pop_front();
}
self.highs.push_back(candle.high);
self.lows.push_back(candle.low);
if self.highs.len() < self.atr_period {
return None;
}
// ATR(atr_period) becomes ready on exactly the candle that fills the
// preliminary window, so this never discards a value.
let atr = atr?;
let highest = self.highs.iter().copied().fold(f64::NEG_INFINITY, f64::max);
let lowest = self.lows.iter().copied().fold(f64::INFINITY, f64::min);
let high_stop = highest - self.atr_multiplier * atr;
let low_stop = lowest + self.atr_multiplier * atr;
if self.high_stops.len() == self.stop_period {
self.high_stops.pop_front();
self.low_stops.pop_front();
}
self.high_stops.push_back(high_stop);
self.low_stops.push_back(low_stop);
if self.high_stops.len() < self.stop_period {
return None;
}
let stop_short = self
.high_stops
.iter()
.copied()
.fold(f64::NEG_INFINITY, f64::max);
let stop_long = self.low_stops.iter().copied().fold(f64::INFINITY, f64::min);
Some(ChandeKrollStopOutput {
stop_long,
stop_short,
})
}
fn reset(&mut self) {
self.atr.reset();
self.highs.clear();
self.lows.clear();
self.high_stops.clear();
self.low_stops.clear();
}
fn warmup_period(&self) -> usize {
// The preliminary stop first appears on candle `atr_period`; the
// smoothing window then needs `stop_period` of them.
self.atr_period + self.stop_period - 1
}
fn is_ready(&self) -> bool {
self.high_stops.len() == self.stop_period
}
fn name(&self) -> &'static str {
"ChandeKrollStop"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn c(high: f64, low: f64, close: f64, ts: i64) -> Candle {
Candle::new((high + low) / 2.0, high, low, close, 1.0, ts).unwrap()
}
#[test]
fn reference_values_flat_market() {
// Flat candles H=11, L=9, C=10 -> TR=2 -> ATR=2; HH=11, LL=9.
// high_stop = 11 - 1·2 = 9; low_stop = 9 + 1·2 = 11.
// stop_short = highest(high_stop, q) = 9; stop_long = lowest(low_stop, q) = 11.
let candles: Vec<Candle> = (0..20).map(|i| c(11.0, 9.0, 10.0, i)).collect();
let mut cks = ChandeKrollStop::new(5, 1.0, 3).unwrap();
let last = cks.batch(&candles).into_iter().flatten().last().unwrap();
assert_relative_eq!(last.stop_short, 9.0, epsilon = 1e-12);
assert_relative_eq!(last.stop_long, 11.0, epsilon = 1e-12);
}
#[test]
fn first_emission_matches_warmup_period() {
let candles: Vec<Candle> = (0..16)
.map(|i| {
let base = 100.0 + i as f64;
c(base + 1.0, base - 1.0, base, i)
})
.collect();
let mut cks = ChandeKrollStop::new(4, 1.0, 3).unwrap();
let out = cks.batch(&candles);
assert_eq!(cks.warmup_period(), 6);
for (i, v) in out.iter().enumerate().take(5) {
assert!(v.is_none(), "index {i} must be None during warmup");
}
assert!(out[5].is_some(), "first value lands at warmup_period - 1");
}
#[test]
fn rejects_invalid_params() {
assert!(ChandeKrollStop::new(0, 1.0, 9).is_err());
assert!(ChandeKrollStop::new(10, 1.0, 0).is_err());
assert!(ChandeKrollStop::new(10, 0.0, 9).is_err());
assert!(ChandeKrollStop::new(10, -1.0, 9).is_err());
assert!(ChandeKrollStop::new(10, f64::NAN, 9).is_err());
}
#[test]
fn reset_clears_state() {
let candles: Vec<Candle> = (0..40)
.map(|i| {
let base = 100.0 + i as f64;
c(base + 1.0, base - 1.0, base, i)
})
.collect();
let mut cks = ChandeKrollStop::classic();
cks.batch(&candles);
assert!(cks.is_ready());
cks.reset();
assert!(!cks.is_ready());
assert_eq!(cks.update(candles[0]), None);
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..80)
.map(|i| {
let mid = 100.0 + (i as f64 * 0.3).sin() * 8.0;
c(mid + 1.5, mid - 1.5, mid + 0.5, i)
})
.collect();
let mut a = ChandeKrollStop::classic();
let mut b = ChandeKrollStop::classic();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,231 @@
//! Chandelier Exit.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::indicators::atr::Atr;
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Chandelier Exit output: the long-side and short-side trailing stops.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ChandelierExitOutput {
/// Long-position stop: `highest_high multiplier · ATR`.
pub long_stop: f64,
/// Short-position stop: `lowest_low + multiplier · ATR`.
pub short_stop: f64,
}
/// Chandelier Exit — Chuck LeBeau's ATR trailing stop, hung from the highest
/// high (for longs) or the lowest low (for shorts) of the lookback window.
///
/// ```text
/// long_stop = highest_high(period) multiplier · ATR(period)
/// short_stop = lowest_low(period) + multiplier · ATR(period)
/// ```
///
/// A long position is exited when price closes below `long_stop`; a short
/// when it closes above `short_stop`. Because the stop hangs a fixed number
/// of ATRs off the extreme of the window — like a chandelier off a ceiling —
/// it follows price up but never loosens. LeBeau's classic configuration is a
/// `22`-bar window with a `3.0` multiplier.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, ChandelierExit};
///
/// let mut indicator = ChandelierExit::new(22, 3.0).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// let base = 100.0 + f64::from(i);
/// let candle =
/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
/// last = indicator.update(candle);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct ChandelierExit {
period: usize,
multiplier: f64,
atr: Atr,
highs: VecDeque<f64>,
lows: VecDeque<f64>,
}
impl ChandelierExit {
/// Construct a Chandelier Exit with an explicit window and band multiplier.
///
/// # Errors
/// Returns [`Error::PeriodZero`] if `period == 0` and
/// [`Error::NonPositiveMultiplier`] if `multiplier` is not strictly
/// positive and finite.
pub fn new(period: usize, multiplier: f64) -> Result<Self> {
if !multiplier.is_finite() || multiplier <= 0.0 {
return Err(Error::NonPositiveMultiplier);
}
Ok(Self {
period,
multiplier,
atr: Atr::new(period)?,
highs: VecDeque::with_capacity(period),
lows: VecDeque::with_capacity(period),
})
}
/// LeBeau's classic configuration: a `22`-bar window, `3.0` multiplier.
pub fn classic() -> Self {
Self::new(22, 3.0).expect("classic Chandelier Exit params are valid")
}
/// Configured `(period, multiplier)`.
pub const fn params(&self) -> (usize, f64) {
(self.period, self.multiplier)
}
}
impl Indicator for ChandelierExit {
type Input = Candle;
type Output = ChandelierExitOutput;
fn update(&mut self, candle: Candle) -> Option<ChandelierExitOutput> {
let atr = self.atr.update(candle);
if self.highs.len() == self.period {
self.highs.pop_front();
self.lows.pop_front();
}
self.highs.push_back(candle.high);
self.lows.push_back(candle.low);
if self.highs.len() < self.period {
return None;
}
// ATR(period) becomes ready on exactly the candle that fills the
// highest-high / lowest-low window, so this never discards a value.
let atr = atr?;
let highest = self.highs.iter().copied().fold(f64::NEG_INFINITY, f64::max);
let lowest = self.lows.iter().copied().fold(f64::INFINITY, f64::min);
Some(ChandelierExitOutput {
long_stop: highest - self.multiplier * atr,
short_stop: lowest + self.multiplier * atr,
})
}
fn reset(&mut self) {
self.atr.reset();
self.highs.clear();
self.lows.clear();
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.highs.len() == self.period
}
fn name(&self) -> &'static str {
"ChandelierExit"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn c(high: f64, low: f64, close: f64, ts: i64) -> Candle {
Candle::new((high + low) / 2.0, high, low, close, 1.0, ts).unwrap()
}
#[test]
fn reference_values_flat_market() {
// Flat candles H=11, L=9, C=10 -> TR=2 -> ATR=2; HH=11, LL=9.
// long_stop = 11 - 3·2 = 5; short_stop = 9 + 3·2 = 15.
let candles: Vec<Candle> = (0..20).map(|i| c(11.0, 9.0, 10.0, i)).collect();
let mut ce = ChandelierExit::new(5, 3.0).unwrap();
let last = ce.batch(&candles).into_iter().flatten().last().unwrap();
assert_relative_eq!(last.long_stop, 5.0, epsilon = 1e-12);
assert_relative_eq!(last.short_stop, 15.0, epsilon = 1e-12);
}
#[test]
fn long_stop_below_highest_short_stop_above_lowest() {
let candles: Vec<Candle> = (0..120)
.map(|i| {
let mid = 100.0 + (i as f64 * 0.2).sin() * 9.0;
c(mid + 1.5, mid - 1.5, mid + 0.4, i)
})
.collect();
let mut ce = ChandelierExit::classic();
for (i, o) in ce.batch(&candles).into_iter().enumerate() {
if let Some(o) = o {
// The window's extremes bound the stops from one side.
let win = &candles[i + 1 - 22..=i];
let hh = win.iter().map(|c| c.high).fold(f64::NEG_INFINITY, f64::max);
let ll = win.iter().map(|c| c.low).fold(f64::INFINITY, f64::min);
assert!(o.long_stop <= hh + 1e-9);
assert!(o.short_stop >= ll - 1e-9);
}
}
}
#[test]
fn first_emission_matches_warmup_period() {
let candles: Vec<Candle> = (0..20)
.map(|i| {
let base = 100.0 + i as f64;
c(base + 1.0, base - 1.0, base, i)
})
.collect();
let mut ce = ChandelierExit::new(8, 3.0).unwrap();
let out = ce.batch(&candles);
assert_eq!(ce.warmup_period(), 8);
for (i, v) in out.iter().enumerate().take(7) {
assert!(v.is_none(), "index {i} must be None during warmup");
}
assert!(out[7].is_some(), "first value lands at warmup_period - 1");
}
#[test]
fn rejects_invalid_params() {
assert!(ChandelierExit::new(0, 3.0).is_err());
assert!(ChandelierExit::new(22, 0.0).is_err());
assert!(ChandelierExit::new(22, -1.0).is_err());
assert!(ChandelierExit::new(22, f64::NAN).is_err());
}
#[test]
fn reset_clears_state() {
let candles: Vec<Candle> = (0..40)
.map(|i| {
let base = 100.0 + i as f64;
c(base + 1.0, base - 1.0, base, i)
})
.collect();
let mut ce = ChandelierExit::classic();
ce.batch(&candles);
assert!(ce.is_ready());
ce.reset();
assert!(!ce.is_ready());
assert_eq!(ce.update(candles[0]), None);
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..80)
.map(|i| {
let mid = 100.0 + (i as f64 * 0.3).sin() * 8.0;
c(mid + 1.5, mid - 1.5, mid + 0.5, i)
})
.collect();
let mut a = ChandelierExit::classic();
let mut b = ChandelierExit::classic();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
+8
View File
@@ -9,11 +9,14 @@ mod adx;
mod aroon;
mod aroon_oscillator;
mod atr;
mod atr_trailing_stop;
mod awesome_oscillator;
mod bollinger;
mod bollinger_bandwidth;
mod cci;
mod chaikin_oscillator;
mod chande_kroll_stop;
mod chandelier_exit;
mod cmf;
mod cmo;
mod coppock;
@@ -44,6 +47,7 @@ mod smma;
mod std_dev;
mod stoch_rsi;
mod stochastic;
mod super_trend;
mod t3;
mod tema;
mod trima;
@@ -64,11 +68,14 @@ pub use adx::{Adx, AdxOutput};
pub use aroon::{Aroon, AroonOutput};
pub use aroon_oscillator::AroonOscillator;
pub use atr::Atr;
pub use atr_trailing_stop::AtrTrailingStop;
pub use awesome_oscillator::AwesomeOscillator;
pub use bollinger::{BollingerBands, BollingerOutput};
pub use bollinger_bandwidth::BollingerBandwidth;
pub use cci::Cci;
pub use chaikin_oscillator::ChaikinOscillator;
pub use chande_kroll_stop::{ChandeKrollStop, ChandeKrollStopOutput};
pub use chandelier_exit::{ChandelierExit, ChandelierExitOutput};
pub use cmf::ChaikinMoneyFlow;
pub use cmo::Cmo;
pub use coppock::Coppock;
@@ -99,6 +106,7 @@ pub use smma::Smma;
pub use std_dev::StdDev;
pub use stoch_rsi::StochRsi;
pub use stochastic::{Stochastic, StochasticOutput};
pub use super_trend::{SuperTrend, SuperTrendOutput};
pub use t3::T3;
pub use tema::Tema;
pub use trima::Trima;
@@ -0,0 +1,315 @@
//! `SuperTrend`.
use crate::error::{Error, Result};
use crate::indicators::atr::Atr;
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// `SuperTrend` output: the trailing-stop level and the trend direction.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SuperTrendOutput {
/// The `SuperTrend` line — the active trailing-stop level for this bar.
pub value: f64,
/// Trend direction: `+1.0` in an uptrend (the line sits below price),
/// `-1.0` in a downtrend (the line sits above price).
pub direction: f64,
}
/// Previous-bar state carried forward by the `SuperTrend` recurrence.
#[derive(Debug, Clone, Copy)]
struct PrevState {
final_upper: f64,
final_lower: f64,
close: f64,
direction: f64,
}
/// `SuperTrend` — an ATR-banded trailing stop that flips sides on a close
/// through the band.
///
/// ```text
/// hl2 = (high + low) / 2
/// basic_upper = hl2 + multiplier · ATR
/// basic_lower = hl2 multiplier · ATR
///
/// final_upper = basic_upper if basic_upper < prev_final_upper or prev_close > prev_final_upper
/// else prev_final_upper
/// final_lower = basic_lower if basic_lower > prev_final_lower or prev_close < prev_final_lower
/// else prev_final_lower
///
/// in a downtrend: stay down while close <= final_upper, else flip up
/// in an uptrend: stay up while close >= final_lower, else flip down
/// SuperTrend = final_lower in an uptrend, final_upper in a downtrend
/// ```
///
/// The final bands ratchet — the upper band only moves down (and the lower
/// band only moves up) until price closes through it, which flips the trend
/// and hands the role of trailing stop to the opposite band. The first
/// ATR-ready bar seeds the trend as up. Wilder's classic configuration is
/// `ATR(10)` with a `3.0` multiplier.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, SuperTrend};
///
/// let mut indicator = SuperTrend::classic();
/// let mut last = None;
/// for i in 0..80 {
/// let base = 100.0 + f64::from(i);
/// let candle =
/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
/// last = indicator.update(candle);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct SuperTrend {
atr: Atr,
multiplier: f64,
atr_period: usize,
prev: Option<PrevState>,
}
impl SuperTrend {
/// Construct a `SuperTrend` with an explicit ATR period and band multiplier.
///
/// # Errors
/// Returns [`Error::PeriodZero`] if `atr_period == 0` and
/// [`Error::NonPositiveMultiplier`] if `multiplier` is not strictly
/// positive and finite.
pub fn new(atr_period: usize, multiplier: f64) -> Result<Self> {
if !multiplier.is_finite() || multiplier <= 0.0 {
return Err(Error::NonPositiveMultiplier);
}
Ok(Self {
atr: Atr::new(atr_period)?,
multiplier,
atr_period,
prev: None,
})
}
/// Wilder's classic configuration: `ATR(10)` with a `3.0` multiplier.
pub fn classic() -> Self {
Self::new(10, 3.0).expect("classic SuperTrend params are valid")
}
/// Configured `(atr_period, multiplier)`.
pub const fn params(&self) -> (usize, f64) {
(self.atr_period, self.multiplier)
}
}
impl Indicator for SuperTrend {
type Input = Candle;
type Output = SuperTrendOutput;
fn update(&mut self, candle: Candle) -> Option<SuperTrendOutput> {
let atr = self.atr.update(candle)?;
let hl2 = (candle.high + candle.low) / 2.0;
let basic_upper = hl2 + self.multiplier * atr;
let basic_lower = hl2 - self.multiplier * atr;
let (final_upper, final_lower, direction) = match self.prev {
None => {
// First ATR-ready bar: no prior bands, seed the trend as up.
(basic_upper, basic_lower, 1.0)
}
Some(p) => {
let final_upper = if basic_upper < p.final_upper || p.close > p.final_upper {
basic_upper
} else {
p.final_upper
};
let final_lower = if basic_lower > p.final_lower || p.close < p.final_lower {
basic_lower
} else {
p.final_lower
};
let direction = if p.direction < 0.0 {
// Previous downtrend — the line was the upper band.
if candle.close <= final_upper {
-1.0
} else {
1.0
}
} else {
// Previous uptrend — the line was the lower band.
if candle.close >= final_lower {
1.0
} else {
-1.0
}
};
(final_upper, final_lower, direction)
}
};
let value = if direction > 0.0 {
final_lower
} else {
final_upper
};
self.prev = Some(PrevState {
final_upper,
final_lower,
close: candle.close,
direction,
});
Some(SuperTrendOutput { value, direction })
}
fn reset(&mut self) {
self.atr.reset();
self.prev = None;
}
fn warmup_period(&self) -> usize {
self.atr_period
}
fn is_ready(&self) -> bool {
self.prev.is_some()
}
fn name(&self) -> &'static str {
"SuperTrend"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
fn c(high: f64, low: f64, close: f64, ts: i64) -> Candle {
Candle::new((high + low) / 2.0, high, low, close, 1.0, ts).unwrap()
}
#[test]
fn uptrend_keeps_line_below_price_and_direction_up() {
let candles: Vec<Candle> = (0..60)
.map(|i| {
let base = 100.0 + 2.0 * i as f64;
c(base + 1.0, base - 1.0, base + 0.5, i)
})
.collect();
let mut st = SuperTrend::classic();
for (o, candle) in st.batch(&candles).into_iter().zip(candles.iter()) {
if let Some(o) = o {
assert_eq!(o.direction, 1.0, "a pure uptrend stays in direction +1");
assert!(o.value < candle.close, "the stop line sits below price");
}
}
}
#[test]
fn downtrend_keeps_line_above_price_and_direction_down() {
let candles: Vec<Candle> = (0..60)
.map(|i| {
let base = 220.0 - 2.0 * i as f64;
c(base + 1.0, base - 1.0, base - 0.5, i)
})
.collect();
let mut st = SuperTrend::classic();
let emitted: Vec<(SuperTrendOutput, f64)> = st
.batch(&candles)
.into_iter()
.zip(candles.iter())
.filter_map(|(o, c)| o.map(|v| (v, c.close)))
.collect();
// The seed bar starts the trend up; a steep decline flips it within a
// few bars. The settled tail must be a clean downtrend.
for &(o, close) in emitted.iter().skip(10) {
assert_eq!(
o.direction, -1.0,
"a steep downtrend settles to direction -1"
);
assert!(o.value > close, "the stop line sits above price");
}
}
#[test]
fn trend_flips_when_price_reverses() {
let mut candles: Vec<Candle> = (0..40)
.map(|i| {
let base = 100.0 + i as f64;
c(base + 1.0, base - 1.0, base + 0.5, i)
})
.collect();
candles.extend((0..40).map(|i| {
let base = 140.0 - i as f64;
c(base + 1.0, base - 1.0, base - 0.5, 40 + i)
}));
let mut st = SuperTrend::classic();
let dirs: Vec<f64> = st
.batch(&candles)
.into_iter()
.flatten()
.map(|o| o.direction)
.collect();
assert!(dirs.iter().any(|&d| d > 0.0), "expected an uptrend stretch");
assert!(
dirs.iter().any(|&d| d < 0.0),
"expected a downtrend stretch"
);
}
#[test]
fn first_emission_matches_warmup_period() {
let candles: Vec<Candle> = (0..30)
.map(|i| {
let base = 100.0 + i as f64;
c(base + 1.0, base - 1.0, base, i)
})
.collect();
let mut st = SuperTrend::classic();
let out = st.batch(&candles);
assert_eq!(st.warmup_period(), 10);
for (i, v) in out.iter().enumerate().take(9) {
assert!(v.is_none(), "index {i} must be None during warmup");
}
assert!(out[9].is_some(), "first value lands at warmup_period - 1");
}
#[test]
fn rejects_invalid_params() {
assert!(SuperTrend::new(0, 3.0).is_err());
assert!(SuperTrend::new(10, 0.0).is_err());
assert!(SuperTrend::new(10, -1.0).is_err());
assert!(SuperTrend::new(10, f64::NAN).is_err());
}
#[test]
fn reset_clears_state() {
let candles: Vec<Candle> = (0..40)
.map(|i| {
let base = 100.0 + i as f64;
c(base + 1.0, base - 1.0, base, i)
})
.collect();
let mut st = SuperTrend::classic();
st.batch(&candles);
assert!(st.is_ready());
st.reset();
assert!(!st.is_ready());
assert_eq!(st.update(candles[0]), None);
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..80)
.map(|i| {
let mid = 100.0 + (i as f64 * 0.3).sin() * 8.0;
c(mid + 1.5, mid - 1.5, mid + 0.5, i)
})
.collect();
let mut a = SuperTrend::classic();
let mut b = SuperTrend::classic();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
+9 -7
View File
@@ -44,13 +44,15 @@ pub mod indicators;
pub use error::{Error, Result};
pub use indicators::{
Adl, Adx, AdxOutput, Aroon, AroonOscillator, AroonOutput, Atr, AwesomeOscillator,
BollingerBands, BollingerBandwidth, BollingerOutput, Cci, ChaikinMoneyFlow, ChaikinOscillator,
Cmo, Coppock, Dema, Donchian, DonchianOutput, Dpo, EaseOfMovement, Ema, ForceIndex,
HistoricalVolatility, Hma, Kama, Keltner, KeltnerOutput, MacdIndicator, MacdOutput, MassIndex,
Mfi, Mom, Natr, Obv, PercentB, Pmo, Ppo, Psar, Roc, RollingVwap, Rsi, Sma, Smma, StdDev,
StochRsi, Stochastic, StochasticOutput, Tema, Trima, Trix, Tsi, UlcerIndex, UltimateOscillator,
VolumePriceTrend, Vortex, VortexOutput, Vwap, Vwma, WilliamsR, Wma, Zlema, T3,
Adl, Adx, AdxOutput, Aroon, AroonOscillator, AroonOutput, Atr, AtrTrailingStop,
AwesomeOscillator, BollingerBands, BollingerBandwidth, BollingerOutput, Cci, ChaikinMoneyFlow,
ChaikinOscillator, ChandeKrollStop, ChandeKrollStopOutput, ChandelierExit,
ChandelierExitOutput, Cmo, Coppock, Dema, Donchian, DonchianOutput, Dpo, EaseOfMovement, Ema,
ForceIndex, HistoricalVolatility, Hma, Kama, Keltner, KeltnerOutput, MacdIndicator, MacdOutput,
MassIndex, Mfi, Mom, Natr, Obv, PercentB, Pmo, Ppo, Psar, Roc, RollingVwap, Rsi, Sma, Smma,
StdDev, StochRsi, Stochastic, StochasticOutput, SuperTrend, SuperTrendOutput, Tema, Trima,
Trix, Tsi, UlcerIndex, UltimateOscillator, VolumePriceTrend, Vortex, VortexOutput, Vwap, Vwma,
WilliamsR, Wma, Zlema, T3,
};
pub use ohlcv::{Candle, Tick};
pub use traits::{BatchExt, Chain, Indicator};