From 21bbd521b3b2c193c3ea42ccc7f664b74fc002d6 Mon Sep 17 00:00:00 2001 From: kingchenc Date: Fri, 22 May 2026 19:42:14 +0200 Subject: [PATCH] F11: add SuperTrend, Chandelier Exit, Chande Kroll Stop and ATR Trailing Stop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- bindings/node/index.js | 6 +- bindings/node/src/lib.rs | 282 ++++++++++++++++ bindings/python/python/wickra/__init__.pyi | 68 ++++ bindings/python/src/lib.rs | 304 +++++++++++++++++ bindings/wasm/src/lib.rs | 201 +++++++++++ clippy.toml | 4 + .../src/indicators/atr_trailing_stop.rs | 268 +++++++++++++++ .../src/indicators/chande_kroll_stop.rs | 248 ++++++++++++++ .../src/indicators/chandelier_exit.rs | 231 +++++++++++++ crates/wickra-core/src/indicators/mod.rs | 8 + .../wickra-core/src/indicators/super_trend.rs | 315 ++++++++++++++++++ crates/wickra-core/src/lib.rs | 16 +- docs/wiki/Home.md | 4 + docs/wiki/Indicators-Overview.md | 14 +- .../volatility/Indicator-AtrTrailingStop.md | 170 ++++++++++ .../volatility/Indicator-ChandeKrollStop.md | 177 ++++++++++ .../volatility/Indicator-ChandelierExit.md | 166 +++++++++ .../volatility/Indicator-SuperTrend.md | 174 ++++++++++ 18 files changed, 2643 insertions(+), 13 deletions(-) create mode 100644 clippy.toml create mode 100644 crates/wickra-core/src/indicators/atr_trailing_stop.rs create mode 100644 crates/wickra-core/src/indicators/chande_kroll_stop.rs create mode 100644 crates/wickra-core/src/indicators/chandelier_exit.rs create mode 100644 crates/wickra-core/src/indicators/super_trend.rs create mode 100644 docs/wiki/indicators/volatility/Indicator-AtrTrailingStop.md create mode 100644 docs/wiki/indicators/volatility/Indicator-ChandeKrollStop.md create mode 100644 docs/wiki/indicators/volatility/Indicator-ChandelierExit.md create mode 100644 docs/wiki/indicators/volatility/Indicator-SuperTrend.md diff --git a/bindings/node/index.js b/bindings/node/index.js index 4bc9c9e8..c0900276 100644 --- a/bindings/node/index.js +++ b/bindings/node/index.js @@ -310,7 +310,7 @@ if (!nativeBinding) { throw new Error(`Failed to load native binding`) } -const { version, SMA, EMA, WMA, RSI, DEMA, TEMA, HMA, ROC, TRIX, SMMA, TRIMA, ZLEMA, T3, VWMA, MOM, CMO, TSI, PMO, StochRSI, UltimateOscillator, PPO, DPO, Coppock, AroonOscillator, Vortex, MassIndex, NATR, StdDev, UlcerIndex, HistoricalVolatility, BollingerBandwidth, PercentB, ADL, VolumePriceTrend, ChaikinMoneyFlow, ChaikinOscillator, ForceIndex, EaseOfMovement, MACD, BollingerBands, ATR, Stochastic, OBV, ADX, CCI, WilliamsR, MFI, PSAR, Keltner, Donchian, VWAP, AwesomeOscillator, Aroon, KAMA } = nativeBinding +const { version, SMA, EMA, WMA, RSI, DEMA, TEMA, HMA, ROC, TRIX, SMMA, TRIMA, ZLEMA, T3, VWMA, MOM, CMO, TSI, PMO, StochRSI, UltimateOscillator, PPO, DPO, Coppock, AroonOscillator, Vortex, MassIndex, NATR, StdDev, UlcerIndex, HistoricalVolatility, BollingerBandwidth, PercentB, ADL, VolumePriceTrend, ChaikinMoneyFlow, ChaikinOscillator, ForceIndex, EaseOfMovement, SuperTrend, ChandelierExit, ChandeKrollStop, AtrTrailingStop, MACD, BollingerBands, ATR, Stochastic, OBV, ADX, CCI, WilliamsR, MFI, PSAR, Keltner, Donchian, VWAP, AwesomeOscillator, Aroon, KAMA } = nativeBinding module.exports.version = version module.exports.SMA = SMA @@ -351,6 +351,10 @@ module.exports.ChaikinMoneyFlow = ChaikinMoneyFlow module.exports.ChaikinOscillator = ChaikinOscillator module.exports.ForceIndex = ForceIndex module.exports.EaseOfMovement = EaseOfMovement +module.exports.SuperTrend = SuperTrend +module.exports.ChandelierExit = ChandelierExit +module.exports.ChandeKrollStop = ChandeKrollStop +module.exports.AtrTrailingStop = AtrTrailingStop module.exports.MACD = MACD module.exports.BollingerBands = BollingerBands module.exports.ATR = ATR diff --git a/bindings/node/src/lib.rs b/bindings/node/src/lib.rs index 2e039167..667298ad 100644 --- a/bindings/node/src/lib.rs +++ b/bindings/node/src/lib.rs @@ -1500,6 +1500,288 @@ impl EaseOfMovementNode { } } +// ============================== SuperTrend ============================== + +#[napi(object)] +pub struct SuperTrendValue { + pub value: f64, + pub direction: f64, +} + +#[napi(js_name = "SuperTrend")] +pub struct SuperTrendNode { + inner: wc::SuperTrend, +} + +#[napi] +impl SuperTrendNode { + #[napi(constructor)] + pub fn new(atr_period: u32, multiplier: f64) -> napi::Result { + Ok(Self { + inner: wc::SuperTrend::new(atr_period as usize, multiplier).map_err(map_err)?, + }) + } + #[napi] + pub fn update( + &mut self, + high: f64, + low: f64, + close: f64, + ) -> napi::Result> { + Ok(self + .inner + .update(cnd(high, low, close, 0.0)?) + .map(|o| SuperTrendValue { + value: o.value, + direction: o.direction, + })) + } + /// Returns `[value0, direction0, value1, direction1, ...]`, length `2 * n`. + /// Warmup positions are `NaN`. + #[napi] + pub fn batch( + &mut self, + high: Vec, + low: Vec, + close: Vec, + ) -> napi::Result> { + if high.len() != low.len() || low.len() != close.len() { + return Err(NapiError::from_reason( + "high, low, close must be equal length".to_string(), + )); + } + let n = high.len(); + let mut out = vec![f64::NAN; n * 2]; + for i in 0..n { + if let Some(o) = self.inner.update(cnd(high[i], low[i], close[i], 0.0)?) { + out[i * 2] = o.value; + out[i * 2 + 1] = o.direction; + } + } + Ok(out) + } + #[napi] + pub fn reset(&mut self) { + self.inner.reset(); + } + #[napi(js_name = "isReady")] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[napi(js_name = "warmupPeriod")] + pub fn warmup_period(&self) -> u32 { + self.inner.warmup_period() as u32 + } +} + +// ============================== Chandelier Exit ============================== + +#[napi(object)] +pub struct ChandelierExitValue { + pub long_stop: f64, + pub short_stop: f64, +} + +#[napi(js_name = "ChandelierExit")] +pub struct ChandelierExitNode { + inner: wc::ChandelierExit, +} + +#[napi] +impl ChandelierExitNode { + #[napi(constructor)] + pub fn new(period: u32, multiplier: f64) -> napi::Result { + Ok(Self { + inner: wc::ChandelierExit::new(period as usize, multiplier).map_err(map_err)?, + }) + } + #[napi] + pub fn update( + &mut self, + high: f64, + low: f64, + close: f64, + ) -> napi::Result> { + Ok(self + .inner + .update(cnd(high, low, close, 0.0)?) + .map(|o| ChandelierExitValue { + long_stop: o.long_stop, + short_stop: o.short_stop, + })) + } + /// Returns `[long0, short0, long1, short1, ...]`, length `2 * n`. Warmup + /// positions are `NaN`. + #[napi] + pub fn batch( + &mut self, + high: Vec, + low: Vec, + close: Vec, + ) -> napi::Result> { + if high.len() != low.len() || low.len() != close.len() { + return Err(NapiError::from_reason( + "high, low, close must be equal length".to_string(), + )); + } + let n = high.len(); + let mut out = vec![f64::NAN; n * 2]; + for i in 0..n { + if let Some(o) = self.inner.update(cnd(high[i], low[i], close[i], 0.0)?) { + out[i * 2] = o.long_stop; + out[i * 2 + 1] = o.short_stop; + } + } + Ok(out) + } + #[napi] + pub fn reset(&mut self) { + self.inner.reset(); + } + #[napi(js_name = "isReady")] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[napi(js_name = "warmupPeriod")] + pub fn warmup_period(&self) -> u32 { + self.inner.warmup_period() as u32 + } +} + +// ============================== Chande Kroll Stop ============================== + +#[napi(object)] +pub struct ChandeKrollStopValue { + pub stop_long: f64, + pub stop_short: f64, +} + +#[napi(js_name = "ChandeKrollStop")] +pub struct ChandeKrollStopNode { + inner: wc::ChandeKrollStop, +} + +#[napi] +impl ChandeKrollStopNode { + #[napi(constructor)] + pub fn new(atr_period: u32, atr_multiplier: f64, stop_period: u32) -> napi::Result { + Ok(Self { + inner: wc::ChandeKrollStop::new( + atr_period as usize, + atr_multiplier, + stop_period as usize, + ) + .map_err(map_err)?, + }) + } + #[napi] + pub fn update( + &mut self, + high: f64, + low: f64, + close: f64, + ) -> napi::Result> { + Ok(self + .inner + .update(cnd(high, low, close, 0.0)?) + .map(|o| ChandeKrollStopValue { + stop_long: o.stop_long, + stop_short: o.stop_short, + })) + } + /// Returns `[long0, short0, long1, short1, ...]`, length `2 * n`. Warmup + /// positions are `NaN`. + #[napi] + pub fn batch( + &mut self, + high: Vec, + low: Vec, + close: Vec, + ) -> napi::Result> { + if high.len() != low.len() || low.len() != close.len() { + return Err(NapiError::from_reason( + "high, low, close must be equal length".to_string(), + )); + } + let n = high.len(); + let mut out = vec![f64::NAN; n * 2]; + for i in 0..n { + if let Some(o) = self.inner.update(cnd(high[i], low[i], close[i], 0.0)?) { + out[i * 2] = o.stop_long; + out[i * 2 + 1] = o.stop_short; + } + } + Ok(out) + } + #[napi] + pub fn reset(&mut self) { + self.inner.reset(); + } + #[napi(js_name = "isReady")] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[napi(js_name = "warmupPeriod")] + pub fn warmup_period(&self) -> u32 { + self.inner.warmup_period() as u32 + } +} + +// ============================== ATR Trailing Stop ============================== + +#[napi(js_name = "AtrTrailingStop")] +pub struct AtrTrailingStopNode { + inner: wc::AtrTrailingStop, +} + +#[napi] +impl AtrTrailingStopNode { + #[napi(constructor)] + pub fn new(atr_period: u32, multiplier: f64) -> napi::Result { + Ok(Self { + inner: wc::AtrTrailingStop::new(atr_period as usize, multiplier).map_err(map_err)?, + }) + } + #[napi] + pub fn update(&mut self, high: f64, low: f64, close: f64) -> napi::Result> { + Ok(self.inner.update(cnd(high, low, close, 0.0)?)) + } + #[napi] + pub fn batch( + &mut self, + high: Vec, + low: Vec, + close: Vec, + ) -> napi::Result> { + if high.len() != low.len() || low.len() != close.len() { + return Err(NapiError::from_reason( + "high, low, close must be equal length".to_string(), + )); + } + let mut out = Vec::with_capacity(high.len()); + for i in 0..high.len() { + out.push( + self.inner + .update(cnd(high[i], low[i], close[i], 0.0)?) + .unwrap_or(f64::NAN), + ); + } + Ok(out) + } + #[napi] + pub fn reset(&mut self) { + self.inner.reset(); + } + #[napi(js_name = "isReady")] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[napi(js_name = "warmupPeriod")] + pub fn warmup_period(&self) -> u32 { + self.inner.warmup_period() as u32 + } +} + // ============================== Bollinger Bandwidth ============================== #[napi(js_name = "BollingerBandwidth")] diff --git a/bindings/python/python/wickra/__init__.pyi b/bindings/python/python/wickra/__init__.pyi index 3bfd09e1..84570533 100644 --- a/bindings/python/python/wickra/__init__.pyi +++ b/bindings/python/python/wickra/__init__.pyi @@ -169,6 +169,74 @@ class EaseOfMovement: @property def divisor(self) -> float: ... +class SuperTrend: + def __init__(self, atr_period: int = 10, multiplier: float = 3.0) -> None: ... + def update(self, candle: CandleLike) -> Optional[Tuple[float, float]]: ... + def batch( + self, + high: NDArray[np.float64], + low: NDArray[np.float64], + close: NDArray[np.float64], + ) -> NDArray[np.float64]: + """Returns shape ``(n, 2)`` with columns ``[value, direction]``.""" + ... + def reset(self) -> None: ... + def is_ready(self) -> bool: ... + def warmup_period(self) -> int: ... + @property + def params(self) -> Tuple[int, float]: ... + +class ChandelierExit: + def __init__(self, period: int = 22, multiplier: float = 3.0) -> None: ... + def update(self, candle: CandleLike) -> Optional[Tuple[float, float]]: ... + def batch( + self, + high: NDArray[np.float64], + low: NDArray[np.float64], + close: NDArray[np.float64], + ) -> NDArray[np.float64]: + """Returns shape ``(n, 2)`` with columns ``[long_stop, short_stop]``.""" + ... + def reset(self) -> None: ... + def is_ready(self) -> bool: ... + def warmup_period(self) -> int: ... + @property + def params(self) -> Tuple[int, float]: ... + +class ChandeKrollStop: + def __init__( + self, atr_period: int = 10, atr_multiplier: float = 1.0, stop_period: int = 9 + ) -> None: ... + def update(self, candle: CandleLike) -> Optional[Tuple[float, float]]: ... + def batch( + self, + high: NDArray[np.float64], + low: NDArray[np.float64], + close: NDArray[np.float64], + ) -> NDArray[np.float64]: + """Returns shape ``(n, 2)`` with columns ``[stop_long, stop_short]``.""" + ... + def reset(self) -> None: ... + def is_ready(self) -> bool: ... + def warmup_period(self) -> int: ... + @property + def params(self) -> Tuple[int, float, int]: ... + +class AtrTrailingStop: + def __init__(self, atr_period: int = 14, multiplier: float = 3.0) -> None: ... + def update(self, candle: CandleLike) -> Optional[float]: ... + def batch( + self, + high: NDArray[np.float64], + low: NDArray[np.float64], + close: NDArray[np.float64], + ) -> NDArray[np.float64]: ... + def reset(self) -> None: ... + def is_ready(self) -> bool: ... + def warmup_period(self) -> int: ... + @property + def params(self) -> Tuple[int, float]: ... + class BollingerBandwidth: def __init__(self, period: int = 20, multiplier: float = 2.0) -> None: ... def update(self, value: float) -> Optional[float]: ... diff --git a/bindings/python/src/lib.rs b/bindings/python/src/lib.rs index 26079e90..14b1431f 100644 --- a/bindings/python/src/lib.rs +++ b/bindings/python/src/lib.rs @@ -3277,6 +3277,306 @@ impl PyEaseOfMovement { } } +// ============================== SuperTrend ============================== + +#[pyclass(name = "SuperTrend", module = "wickra._wickra")] +#[derive(Clone)] +struct PySuperTrend { + inner: wc::SuperTrend, +} + +#[pymethods] +impl PySuperTrend { + #[new] + #[pyo3(signature = (atr_period=10, multiplier=3.0))] + fn new(atr_period: usize, multiplier: f64) -> PyResult { + Ok(Self { + inner: wc::SuperTrend::new(atr_period, multiplier).map_err(map_err)?, + }) + } + fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult> { + let c = extract_candle(candle)?; + Ok(self.inner.update(c).map(|o| (o.value, o.direction))) + } + /// Batch over numpy columns high, low, close. Returns shape `(n, 2)` with + /// columns `[value, direction]`; warmup rows are `NaN`. + fn batch<'py>( + &mut self, + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let h = high + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let l = low + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let c = close + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + if h.len() != l.len() || l.len() != c.len() { + return Err(PyValueError::new_err( + "high, low, close must be equal length", + )); + } + let n = h.len(); + let mut out = vec![f64::NAN; n * 2]; + for i in 0..n { + let candle = wc::Candle::new(c[i], h[i], l[i], c[i], 0.0, 0).map_err(map_err)?; + if let Some(o) = self.inner.update(candle) { + out[i * 2] = o.value; + out[i * 2 + 1] = o.direction; + } + } + Ok(numpy::ndarray::Array2::from_shape_vec((n, 2), out) + .expect("shape consistent") + .into_pyarray_bound(py)) + } + #[getter] + fn params(&self) -> (usize, f64) { + self.inner.params() + } + fn reset(&mut self) { + self.inner.reset(); + } + fn is_ready(&self) -> bool { + self.inner.is_ready() + } + fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } + fn __repr__(&self) -> String { + let (atr_period, multiplier) = self.inner.params(); + format!("SuperTrend(atr_period={atr_period}, multiplier={multiplier})") + } +} + +// ============================== Chandelier Exit ============================== + +#[pyclass(name = "ChandelierExit", module = "wickra._wickra")] +#[derive(Clone)] +struct PyChandelierExit { + inner: wc::ChandelierExit, +} + +#[pymethods] +impl PyChandelierExit { + #[new] + #[pyo3(signature = (period=22, multiplier=3.0))] + fn new(period: usize, multiplier: f64) -> PyResult { + Ok(Self { + inner: wc::ChandelierExit::new(period, multiplier).map_err(map_err)?, + }) + } + fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult> { + let c = extract_candle(candle)?; + Ok(self.inner.update(c).map(|o| (o.long_stop, o.short_stop))) + } + /// Batch over numpy columns high, low, close. Returns shape `(n, 2)` with + /// columns `[long_stop, short_stop]`; warmup rows are `NaN`. + fn batch<'py>( + &mut self, + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let h = high + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let l = low + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let c = close + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + if h.len() != l.len() || l.len() != c.len() { + return Err(PyValueError::new_err( + "high, low, close must be equal length", + )); + } + let n = h.len(); + let mut out = vec![f64::NAN; n * 2]; + for i in 0..n { + let candle = wc::Candle::new(c[i], h[i], l[i], c[i], 0.0, 0).map_err(map_err)?; + if let Some(o) = self.inner.update(candle) { + out[i * 2] = o.long_stop; + out[i * 2 + 1] = o.short_stop; + } + } + Ok(numpy::ndarray::Array2::from_shape_vec((n, 2), out) + .expect("shape consistent") + .into_pyarray_bound(py)) + } + #[getter] + fn params(&self) -> (usize, f64) { + self.inner.params() + } + fn reset(&mut self) { + self.inner.reset(); + } + fn is_ready(&self) -> bool { + self.inner.is_ready() + } + fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } + fn __repr__(&self) -> String { + let (period, multiplier) = self.inner.params(); + format!("ChandelierExit(period={period}, multiplier={multiplier})") + } +} + +// ============================== Chande Kroll Stop ============================== + +#[pyclass(name = "ChandeKrollStop", module = "wickra._wickra")] +#[derive(Clone)] +struct PyChandeKrollStop { + inner: wc::ChandeKrollStop, +} + +#[pymethods] +impl PyChandeKrollStop { + #[new] + #[pyo3(signature = (atr_period=10, atr_multiplier=1.0, stop_period=9))] + fn new(atr_period: usize, atr_multiplier: f64, stop_period: usize) -> PyResult { + Ok(Self { + inner: wc::ChandeKrollStop::new(atr_period, atr_multiplier, stop_period) + .map_err(map_err)?, + }) + } + fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult> { + let c = extract_candle(candle)?; + Ok(self.inner.update(c).map(|o| (o.stop_long, o.stop_short))) + } + /// Batch over numpy columns high, low, close. Returns shape `(n, 2)` with + /// columns `[stop_long, stop_short]`; warmup rows are `NaN`. + fn batch<'py>( + &mut self, + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let h = high + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let l = low + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let c = close + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + if h.len() != l.len() || l.len() != c.len() { + return Err(PyValueError::new_err( + "high, low, close must be equal length", + )); + } + let n = h.len(); + let mut out = vec![f64::NAN; n * 2]; + for i in 0..n { + let candle = wc::Candle::new(c[i], h[i], l[i], c[i], 0.0, 0).map_err(map_err)?; + if let Some(o) = self.inner.update(candle) { + out[i * 2] = o.stop_long; + out[i * 2 + 1] = o.stop_short; + } + } + Ok(numpy::ndarray::Array2::from_shape_vec((n, 2), out) + .expect("shape consistent") + .into_pyarray_bound(py)) + } + #[getter] + fn params(&self) -> (usize, f64, usize) { + self.inner.params() + } + fn reset(&mut self) { + self.inner.reset(); + } + fn is_ready(&self) -> bool { + self.inner.is_ready() + } + fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } + fn __repr__(&self) -> String { + let (atr_period, atr_multiplier, stop_period) = self.inner.params(); + format!( + "ChandeKrollStop(atr_period={atr_period}, atr_multiplier={atr_multiplier}, stop_period={stop_period})" + ) + } +} + +// ============================== ATR Trailing Stop ============================== + +#[pyclass(name = "AtrTrailingStop", module = "wickra._wickra")] +#[derive(Clone)] +struct PyAtrTrailingStop { + inner: wc::AtrTrailingStop, +} + +#[pymethods] +impl PyAtrTrailingStop { + #[new] + #[pyo3(signature = (atr_period=14, multiplier=3.0))] + fn new(atr_period: usize, multiplier: f64) -> PyResult { + Ok(Self { + inner: wc::AtrTrailingStop::new(atr_period, multiplier).map_err(map_err)?, + }) + } + fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult> { + let c = extract_candle(candle)?; + Ok(self.inner.update(c)) + } + /// Batch over numpy columns high, low, close (all equal length). + fn batch<'py>( + &mut self, + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let h = high + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let l = low + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let c = close + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + if h.len() != l.len() || l.len() != c.len() { + return Err(PyValueError::new_err( + "high, low, close must be equal length", + )); + } + let mut out = Vec::with_capacity(h.len()); + for i in 0..h.len() { + let candle = wc::Candle::new(c[i], h[i], l[i], c[i], 0.0, 0).map_err(map_err)?; + out.push(self.inner.update(candle).unwrap_or(f64::NAN)); + } + Ok(out.into_pyarray_bound(py)) + } + #[getter] + fn params(&self) -> (usize, f64) { + self.inner.params() + } + fn reset(&mut self) { + self.inner.reset(); + } + fn is_ready(&self) -> bool { + self.inner.is_ready() + } + fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } + fn __repr__(&self) -> String { + let (atr_period, multiplier) = self.inner.params(); + format!("AtrTrailingStop(atr_period={atr_period}, multiplier={multiplier})") + } +} + // ============================== Module ============================== #[pymodule] @@ -3336,5 +3636,9 @@ fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; m.add_class::()?; m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; Ok(()) } diff --git a/bindings/wasm/src/lib.rs b/bindings/wasm/src/lib.rs index 7e0356af..a4961d49 100644 --- a/bindings/wasm/src/lib.rs +++ b/bindings/wasm/src/lib.rs @@ -638,6 +638,207 @@ impl WasmEaseOfMovement { } } +#[wasm_bindgen(js_name = SuperTrend)] +pub struct WasmSuperTrend { + inner: wc::SuperTrend, +} + +#[wasm_bindgen(js_class = SuperTrend)] +impl WasmSuperTrend { + #[wasm_bindgen(constructor)] + pub fn new(atr_period: usize, multiplier: f64) -> Result { + Ok(Self { + inner: wc::SuperTrend::new(atr_period, multiplier).map_err(map_err)?, + }) + } + /// Returns `{ value, direction }` once warm, else `null`. + pub fn update(&mut self, high: f64, low: f64, close: f64) -> Result { + let c = make_candle(high, low, close, 0.0)?; + Ok(match self.inner.update(c) { + Some(o) => { + let obj = Object::new(); + Reflect::set(&obj, &"value".into(), &o.value.into()).ok(); + Reflect::set(&obj, &"direction".into(), &o.direction.into()).ok(); + obj.into() + } + None => JsValue::NULL, + }) + } + /// Returns `[value0, direction0, value1, direction1, ...]`, length `2 * n`. + /// Warmup positions are NaN. + pub fn batch( + &mut self, + high: &[f64], + low: &[f64], + close: &[f64], + ) -> Result { + let n = high.len(); + if low.len() != n || close.len() != n { + return Err(JsError::new("high, low, close must be equal length")); + } + let mut out = vec![f64::NAN; n * 2]; + for i in 0..n { + let c = make_candle(high[i], low[i], close[i], 0.0)?; + if let Some(o) = self.inner.update(c) { + out[i * 2] = o.value; + out[i * 2 + 1] = o.direction; + } + } + Ok(Float64Array::from(out.as_slice())) + } + pub fn reset(&mut self) { + self.inner.reset(); + } +} + +#[wasm_bindgen(js_name = ChandelierExit)] +pub struct WasmChandelierExit { + inner: wc::ChandelierExit, +} + +#[wasm_bindgen(js_class = ChandelierExit)] +impl WasmChandelierExit { + #[wasm_bindgen(constructor)] + pub fn new(period: usize, multiplier: f64) -> Result { + Ok(Self { + inner: wc::ChandelierExit::new(period, multiplier).map_err(map_err)?, + }) + } + /// Returns `{ longStop, shortStop }` once warm, else `null`. + pub fn update(&mut self, high: f64, low: f64, close: f64) -> Result { + let c = make_candle(high, low, close, 0.0)?; + Ok(match self.inner.update(c) { + Some(o) => { + let obj = Object::new(); + Reflect::set(&obj, &"longStop".into(), &o.long_stop.into()).ok(); + Reflect::set(&obj, &"shortStop".into(), &o.short_stop.into()).ok(); + obj.into() + } + None => JsValue::NULL, + }) + } + /// Returns `[long0, short0, long1, short1, ...]`, length `2 * n`. Warmup is NaN. + pub fn batch( + &mut self, + high: &[f64], + low: &[f64], + close: &[f64], + ) -> Result { + let n = high.len(); + if low.len() != n || close.len() != n { + return Err(JsError::new("high, low, close must be equal length")); + } + let mut out = vec![f64::NAN; n * 2]; + for i in 0..n { + let c = make_candle(high[i], low[i], close[i], 0.0)?; + if let Some(o) = self.inner.update(c) { + out[i * 2] = o.long_stop; + out[i * 2 + 1] = o.short_stop; + } + } + Ok(Float64Array::from(out.as_slice())) + } + pub fn reset(&mut self) { + self.inner.reset(); + } +} + +#[wasm_bindgen(js_name = ChandeKrollStop)] +pub struct WasmChandeKrollStop { + inner: wc::ChandeKrollStop, +} + +#[wasm_bindgen(js_class = ChandeKrollStop)] +impl WasmChandeKrollStop { + #[wasm_bindgen(constructor)] + pub fn new( + atr_period: usize, + atr_multiplier: f64, + stop_period: usize, + ) -> Result { + Ok(Self { + inner: wc::ChandeKrollStop::new(atr_period, atr_multiplier, stop_period) + .map_err(map_err)?, + }) + } + /// Returns `{ stopLong, stopShort }` once warm, else `null`. + pub fn update(&mut self, high: f64, low: f64, close: f64) -> Result { + let c = make_candle(high, low, close, 0.0)?; + Ok(match self.inner.update(c) { + Some(o) => { + let obj = Object::new(); + Reflect::set(&obj, &"stopLong".into(), &o.stop_long.into()).ok(); + Reflect::set(&obj, &"stopShort".into(), &o.stop_short.into()).ok(); + obj.into() + } + None => JsValue::NULL, + }) + } + /// Returns `[long0, short0, long1, short1, ...]`, length `2 * n`. Warmup is NaN. + pub fn batch( + &mut self, + high: &[f64], + low: &[f64], + close: &[f64], + ) -> Result { + let n = high.len(); + if low.len() != n || close.len() != n { + return Err(JsError::new("high, low, close must be equal length")); + } + let mut out = vec![f64::NAN; n * 2]; + for i in 0..n { + let c = make_candle(high[i], low[i], close[i], 0.0)?; + if let Some(o) = self.inner.update(c) { + out[i * 2] = o.stop_long; + out[i * 2 + 1] = o.stop_short; + } + } + Ok(Float64Array::from(out.as_slice())) + } + pub fn reset(&mut self) { + self.inner.reset(); + } +} + +#[wasm_bindgen(js_name = AtrTrailingStop)] +pub struct WasmAtrTrailingStop { + inner: wc::AtrTrailingStop, +} + +#[wasm_bindgen(js_class = AtrTrailingStop)] +impl WasmAtrTrailingStop { + #[wasm_bindgen(constructor)] + pub fn new(atr_period: usize, multiplier: f64) -> Result { + Ok(Self { + inner: wc::AtrTrailingStop::new(atr_period, multiplier).map_err(map_err)?, + }) + } + pub fn update(&mut self, high: f64, low: f64, close: f64) -> Result, JsError> { + let c = make_candle(high, low, close, 0.0)?; + Ok(self.inner.update(c)) + } + pub fn batch( + &mut self, + high: &[f64], + low: &[f64], + close: &[f64], + ) -> Result { + let n = high.len(); + if low.len() != n || close.len() != n { + return Err(JsError::new("high, low, close must be equal length")); + } + let mut out = Vec::with_capacity(n); + for i in 0..n { + let c = make_candle(high[i], low[i], close[i], 0.0)?; + out.push(self.inner.update(c).unwrap_or(f64::NAN)); + } + Ok(Float64Array::from(out.as_slice())) + } + pub fn reset(&mut self) { + self.inner.reset(); + } +} + #[wasm_bindgen(js_name = NATR)] pub struct WasmNatr { inner: wc::Natr, diff --git a/clippy.toml b/clippy.toml new file mode 100644 index 00000000..b2af704d --- /dev/null +++ b/clippy.toml @@ -0,0 +1,4 @@ +# Proper nouns that appear in indicator documentation. They are real names, +# not code identifiers, so `clippy::doc_markdown` must not demand backticks. +# `..` keeps clippy's built-in default identifier list in addition to these. +doc-valid-idents = ["LeBeau", ".."] diff --git a/crates/wickra-core/src/indicators/atr_trailing_stop.rs b/crates/wickra-core/src/indicators/atr_trailing_stop.rs new file mode 100644 index 00000000..a5bcdfda --- /dev/null +++ b/crates/wickra-core/src/indicators/atr_trailing_stop.rs @@ -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_{t−1}, close − loss) while price holds above the stop +/// = min(stop_{t−1}, 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, + prev_stop: Option, +} + +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 { + 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 { + 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 = (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 = (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 = (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 = (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 = (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 = (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::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/chande_kroll_stop.rs b/crates/wickra-core/src/indicators/chande_kroll_stop.rs new file mode 100644 index 00000000..443978ed --- /dev/null +++ b/crates/wickra-core/src/indicators/chande_kroll_stop.rs @@ -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, + lows: VecDeque, + high_stops: VecDeque, + low_stops: VecDeque, +} + +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 { + 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 { + 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 = (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 = (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 = (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 = (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::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/chandelier_exit.rs b/crates/wickra-core/src/indicators/chandelier_exit.rs new file mode 100644 index 00000000..321301ec --- /dev/null +++ b/crates/wickra-core/src/indicators/chandelier_exit.rs @@ -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, + lows: VecDeque, +} + +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 { + 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 { + 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 = (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 = (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 = (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 = (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 = (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::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/mod.rs b/crates/wickra-core/src/indicators/mod.rs index f8ae5677..d4e21342 100644 --- a/crates/wickra-core/src/indicators/mod.rs +++ b/crates/wickra-core/src/indicators/mod.rs @@ -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; diff --git a/crates/wickra-core/src/indicators/super_trend.rs b/crates/wickra-core/src/indicators/super_trend.rs new file mode 100644 index 00000000..52f906e9 --- /dev/null +++ b/crates/wickra-core/src/indicators/super_trend.rs @@ -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, +} + +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 { + 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 { + 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 = (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 = (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 = (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 = 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 = (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 = (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 = (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::>() + ); + } +} diff --git a/crates/wickra-core/src/lib.rs b/crates/wickra-core/src/lib.rs index 5d854395..1e948a06 100644 --- a/crates/wickra-core/src/lib.rs +++ b/crates/wickra-core/src/lib.rs @@ -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}; diff --git a/docs/wiki/Home.md b/docs/wiki/Home.md index 488a961a..42a584b0 100644 --- a/docs/wiki/Home.md +++ b/docs/wiki/Home.md @@ -124,6 +124,10 @@ Rust / Python / Node examples. They are grouped by family, mirroring the - [Indicator-HistoricalVolatility.md](indicators/volatility/Indicator-HistoricalVolatility.md) - [Indicator-BollingerBandwidth.md](indicators/volatility/Indicator-BollingerBandwidth.md) - [Indicator-PercentB.md](indicators/volatility/Indicator-PercentB.md) +- [Indicator-SuperTrend.md](indicators/volatility/Indicator-SuperTrend.md) +- [Indicator-ChandelierExit.md](indicators/volatility/Indicator-ChandelierExit.md) +- [Indicator-ChandeKrollStop.md](indicators/volatility/Indicator-ChandeKrollStop.md) +- [Indicator-AtrTrailingStop.md](indicators/volatility/Indicator-AtrTrailingStop.md) **Volume** — price moves weighted or confirmed by traded volume. diff --git a/docs/wiki/Indicators-Overview.md b/docs/wiki/Indicators-Overview.md index e04c5ff0..782ce3b8 100644 --- a/docs/wiki/Indicators-Overview.md +++ b/docs/wiki/Indicators-Overview.md @@ -1,6 +1,6 @@ # Indicators Overview -Wickra ships 54 indicators, organised in source under the four classical +Wickra ships 58 indicators, organised in source under the four classical families — trend, momentum, volatility, volume — that map directly to the directory structure of `crates/wickra-core/src/indicators/`. The same family labels are used here, plus a second-level grouping that reflects how the @@ -118,10 +118,10 @@ Centered on zero or driven by raw price differences; no fixed cap. ## Volatility -Volatility indicators sit in two functional groups: those that draw an -envelope around price, and those that report a scalar dispersion/range. -PSAR is a special case — a trailing-stop tracker rather than a width -measure — that lives in the volatility module by source convention. +Volatility indicators sit in three functional groups: those that draw an +envelope around price, those that report a scalar dispersion/range, and a +set of trailing stops — ATR-driven stop-loss trackers rather than width +measures — that live in the volatility module by source convention. ### Envelopes @@ -148,6 +148,10 @@ measure — that lives in the volatility module by source convention. | Indicator | One-liner | Input | Output | Range | Defaults | Warmup | Deep dive | |-----------|-----------|-------|--------|-------|----------|--------|-----------| | `Psar` | Wilder's Parabolic Stop-and-Reverse; per-bar stop level that flips sides on price crossing. | `Candle` | `f64` | unbounded (price scale) | `(af_start=0.02, af_step=0.02, af_max=0.20)` (Python) | `2` | [Indicator-Psar.md](indicators/volatility/Indicator-Psar.md) | +| `SuperTrend` | ATR-banded trailing stop that flips on a close through the band; reports the line and the trend direction. | `Candle` | `(value, direction)` | `value` price scale; `direction` `±1` | `(atr_period=10, multiplier=3.0)` (Python) | `atr_period` | [Indicator-SuperTrend.md](indicators/volatility/Indicator-SuperTrend.md) | +| `ChandelierExit` | `highest_high − k·ATR` (long stop) and `lowest_low + k·ATR` (short stop). | `Candle` | `(long_stop, short_stop)` | unbounded (price scale) | `(period=22, multiplier=3.0)` (Python) | `period` | [Indicator-ChandelierExit.md](indicators/volatility/Indicator-ChandelierExit.md) | +| `ChandeKrollStop` | Two-stage ATR stop: an extreme-based stop, then smoothed over a shorter window. | `Candle` | `(stop_long, stop_short)` | unbounded (price scale) | `(atr_period=10, atr_multiplier=1.0, stop_period=9)` (Python) | `atr_period + stop_period − 1` | [Indicator-ChandeKrollStop.md](indicators/volatility/Indicator-ChandeKrollStop.md) | +| `AtrTrailingStop` | A single line trailing the close by `k·ATR`, ratcheting toward the trend and flipping on a cross. | `Candle` | `f64` | unbounded (price scale) | `(atr_period=14, multiplier=3.0)` (Python) | `atr_period` | [Indicator-AtrTrailingStop.md](indicators/volatility/Indicator-AtrTrailingStop.md) | ## Volume diff --git a/docs/wiki/indicators/volatility/Indicator-AtrTrailingStop.md b/docs/wiki/indicators/volatility/Indicator-AtrTrailingStop.md new file mode 100644 index 00000000..d87ab082 --- /dev/null +++ b/docs/wiki/indicators/volatility/Indicator-AtrTrailingStop.md @@ -0,0 +1,170 @@ +# AtrTrailingStop + +> ATR Trailing Stop — a single stop level that trails price by a fixed ATR +> multiple, ratcheting toward the trend and flipping on a close through it. + +## Quick reference + +| Field | Value | +|-------|-------| +| Family | Volatility | +| Sub-category | Trailing stop | +| Input type | `Candle` (uses `high`, `low`, `close`) | +| Output type | `f64` | +| Output range | unbounded (price scale) | +| Default parameters | `atr_period = 14`, `multiplier = 3.0` (Python) | +| Warmup period | `atr_period` | +| Interpretation | One trailing stop line; price closing through it flips the trade. | + +## Formula + +``` +loss = multiplier · ATR + +stop_t = max(stop_{t−1}, close − loss) while price holds above the stop + = min(stop_{t−1}, 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 +``` + +This is the trailing stop popularised by the "UT Bot": a single line that sits +`multiplier · ATR` away from the close. While price holds on one side of the +stop the level only ratchets *toward* price — up in an uptrend, down in a +downtrend — and never away from it. When a close crosses the stop the level +snaps to the opposite side of the new close, flipping the trade. Unlike the +[`ChandelierExit`](Indicator-ChandelierExit.md), it hangs off the close +itself, not the window's extreme, and reports one line rather than two. + +## Parameters + +- `atr_period` — the ATR lookback (Python default `14`). +- `multiplier` — the ATR multiple the stop trails by (Python default `3.0`). + +`AtrTrailingStop::classic()` returns the `(14, 3.0)` configuration. + +## Inputs / Outputs + +From `crates/wickra-core/src/indicators/atr_trailing_stop.rs`: + +```rust +impl Indicator for AtrTrailingStop { + type Input = Candle; + type Output = f64; + // update(&mut self, input: Candle) -> Option +} +``` + +`AtrTrailingStop` is a **candle-input** indicator (it reads `high`, `low`, +`close`). In Python the streaming `update` accepts a 6-tuple or a dict; the +batch helper takes `high`, `low`, `close` numpy arrays. Node and WASM expose +`update(high, low, close)` and the matching `batch`. + +## Warmup + +`AtrTrailingStop::classic().warmup_period() == 14`. The first value lands once +the inner ATR is ready, on input index `atr_period − 1`. That first bar seeds +the stop below price (a long). + +## Edge cases + +- **Seed bar.** The first emitted stop is `close − loss` — the indicator + starts on the long side. +- **Ratchet.** While price holds above the stop it never moves down, and + while price holds below it never moves up + (`uptrend_stop_ratchets_up_and_stays_below_price` pins this). +- **Flat market.** Constant candles hold the stop at a fixed `close − loss`. +- **Reset.** `ts.reset()` clears the ATR and the carried stop / close. + +## Examples + +### Rust + +```rust +use wickra::{BatchExt, Candle, Indicator, AtrTrailingStop}; + +fn main() -> Result<(), Box> { + let mut ts = AtrTrailingStop::new(5, 3.0)?; + // Flat market: ATR = 2, loss = 3·2 = 6, stop = 10 - 6 = 4. + let candles: Vec = (0..20) + .map(|i| Candle::new(10.0, 11.0, 9.0, 10.0, 1.0, i).unwrap()) + .collect(); + let out = ts.batch(&candles); + println!("{:?}", out.last().unwrap()); + Ok(()) +} +``` + +Output: + +``` +Some(4.0) +``` + +On a flat market the seeded long stop holds at `close − loss = 10 − 6 = 4`. +This matches the `reference_values_flat_market` test in +`crates/wickra-core/src/indicators/atr_trailing_stop.rs`. + +### Python + +```python +import numpy as np +import wickra as ta + +ts = ta.AtrTrailingStop(5, 3.0) +n = 20 +high = np.full(n, 11.0) +low = np.full(n, 9.0) +close = np.full(n, 10.0) +print(ts.batch(high, low, close)[-1]) +``` + +Output: + +``` +4.0 +``` + +### Node + +```javascript +const ta = require('wickra'); +const ts = new ta.AtrTrailingStop(5, 3.0); +const n = 20; +const high = Array(n).fill(11), low = Array(n).fill(9), close = Array(n).fill(10); +const out = ts.batch(high, low, close); +console.log(out[out.length - 1]); +``` + +Output: + +``` +4 +``` + +## Interpretation + +Read it as a stop-and-reverse line: while the stop sits below the close you +are long and it trails your profit up; the bar a close prints below the stop, +it flips above the new close and you are short. A larger `multiplier` gives +the trade more room — fewer flips, wider risk; a smaller one flips sooner. + +## Common pitfalls + +- **Expecting it off the window high.** It trails the *close*, so it can sit + closer to price than a [`ChandelierExit`](Indicator-ChandelierExit.md). +- **Feeding it scalar prices.** It needs the full `high`/`low`/`close` bar to + drive the ATR. + +## References + +The ATR Trailing Stop used by the well-known "UT Bot"; the four-branch ratchet +here matches the common Sylvain Vervoort formulation. + +## See also + +- [Indicator-SuperTrend.md](Indicator-SuperTrend.md) — an ATR trailing stop + with band ratcheting and an explicit direction flag. +- [Indicator-ChandelierExit.md](Indicator-ChandelierExit.md) — an ATR stop hung + off the window's extreme instead of the close. +- [Indicator-Atr.md](Indicator-Atr.md) — the volatility measure underneath. +- [Indicators-Overview.md](../../Indicators-Overview.md) — the full taxonomy. diff --git a/docs/wiki/indicators/volatility/Indicator-ChandeKrollStop.md b/docs/wiki/indicators/volatility/Indicator-ChandeKrollStop.md new file mode 100644 index 00000000..8a743654 --- /dev/null +++ b/docs/wiki/indicators/volatility/Indicator-ChandeKrollStop.md @@ -0,0 +1,177 @@ +# ChandeKrollStop + +> Chande Kroll Stop — a two-stage ATR stop: an ATR stop off the recent +> extreme, then smoothed by taking the most extreme such stop over a +> shorter window. + +## Quick reference + +| Field | Value | +|-------|-------| +| Family | Volatility | +| Sub-category | Trailing stop | +| Input type | `Candle` (uses `high`, `low`, `close`) | +| Output type | `(stop_long, stop_short)` | +| Output range | unbounded (price scale) | +| Default parameters | `atr_period = 10`, `atr_multiplier = 1.0`, `stop_period = 9` (Python) | +| Warmup period | `atr_period + stop_period − 1` | +| Interpretation | Smoothed long/short stop levels, less prone to single-bar whipsaw. | + +## Formula + +``` +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) +``` + +Tushar Chande and Stanley Kroll's stop runs in two stages. The first builds a +preliminary ATR stop off the recent extreme — the same idea as a +[`ChandelierExit`](Indicator-ChandelierExit.md). The second smooths it: rather +than use that preliminary stop directly, it takes the *most extreme* +preliminary stop seen over a shorter window `q`. That second pass keeps a +single unusually wide bar from yanking the stop around. The classic +configuration from *The New Technical Trader* is `ATR(10)`, multiplier `1.0`, +smoothing window `9`. + +## Parameters + +- `atr_period` — window for the preliminary ATR and the highest high / lowest + low (Python default `10`). +- `atr_multiplier` — how many ATRs the preliminary stop sits off the extreme + (default `1.0`). +- `stop_period` — the smoothing window `q` (default `9`). + +`ChandeKrollStop::classic()` returns the `(10, 1.0, 9)` configuration. + +## Inputs / Outputs + +From `crates/wickra-core/src/indicators/chande_kroll_stop.rs`: + +```rust +impl Indicator for ChandeKrollStop { + type Input = Candle; + type Output = ChandeKrollStopOutput; // { stop_long: f64, stop_short: f64 } + // update(&mut self, input: Candle) -> Option +} +``` + +`ChandeKrollStop` is a **candle-input** indicator (it reads `high`, `low`, +`close`). Python's streaming `update` returns a `(stop_long, stop_short)` +tuple; the batch helper returns an `(n, 2)` array with columns +`[stop_long, stop_short]`. Node's `update` returns `{ stopLong, stopShort }` +and `batch` a flat `[l0, s0, l1, s1, …]` array; WASM matches Node. + +## Warmup + +`ChandeKrollStop::classic().warmup_period() == 18` (`atr_period + stop_period − +1`). The preliminary stop first appears on candle `atr_period`; the smoothing +window then needs `stop_period` of them. + +## Edge cases + +- **Two-stage warmup.** Nothing is emitted until both the preliminary window + and the smoothing window have filled. +- **Flat market.** Constant candles collapse both stages to fixed levels. +- **Reset.** `cks.reset()` clears the ATR and all four windows. + +## Examples + +### Rust + +```rust +use wickra::{BatchExt, Candle, Indicator, ChandeKrollStop}; + +fn main() -> Result<(), Box> { + let mut cks = ChandeKrollStop::new(5, 1.0, 3)?; + // Flat market: ATR = 2, HH = 11, LL = 9. + let candles: Vec = (0..20) + .map(|i| Candle::new(10.0, 11.0, 9.0, 10.0, 1.0, i).unwrap()) + .collect(); + let out = cks.batch(&candles); + println!("{:?}", out.last().unwrap()); + Ok(()) +} +``` + +Output: + +``` +Some(ChandeKrollStopOutput { stop_long: 11.0, stop_short: 9.0 }) +``` + +`high_stop = 11 − 1·2 = 9`, `low_stop = 9 + 1·2 = 11`; the smoothing pass over +constant values leaves `stop_short = 9` and `stop_long = 11`. This matches the +`reference_values_flat_market` test in +`crates/wickra-core/src/indicators/chande_kroll_stop.rs`. + +### Python + +```python +import numpy as np +import wickra as ta + +cks = ta.ChandeKrollStop(5, 1.0, 3) +n = 20 +high = np.full(n, 11.0) +low = np.full(n, 9.0) +close = np.full(n, 10.0) +print(cks.batch(high, low, close)[-1]) # [stop_long, stop_short] +``` + +Output: + +``` +[11. 9.] +``` + +### Node + +```javascript +const ta = require('wickra'); +const cks = new ta.ChandeKrollStop(5, 1.0, 3); +const n = 20; +const high = Array(n).fill(11), low = Array(n).fill(9), close = Array(n).fill(10); +const out = cks.batch(high, low, close); +console.log(out.slice(-2)); // [stop_long, stop_short] of the last bar +``` + +Output: + +``` +[ 11, 9 ] +``` + +## Interpretation + +Use `stop_long` to trail a long position and `stop_short` to trail a short. +Compared with a one-stage [`ChandelierExit`](Indicator-ChandelierExit.md), the +extra smoothing window makes the Chande Kroll Stop steadier — it will not lurch +on a single wide-range bar — at the cost of reacting a little slower to a +genuine trend change. + +## Common pitfalls + +- **Forgetting the longer warmup.** Two stacked windows mean `atr_period + + stop_period − 1` bars before the first value. +- **Confusing the labels.** `stop_short` is generally the lower level and + `stop_long` the higher — they bracket recent price, but each only applies to + its own side. + +## References + +Tushar Chande and Stanley Kroll's stop, from *The New Technical Trader* (1994); +the two-stage formulation here matches the common TradingView implementation. + +## See also + +- [Indicator-ChandelierExit.md](Indicator-ChandelierExit.md) — the one-stage + ATR stop this smooths. +- [Indicator-SuperTrend.md](Indicator-SuperTrend.md) — an ATR trailing stop + with explicit flip logic. +- [Indicator-Atr.md](Indicator-Atr.md) — the volatility measure underneath. +- [Indicators-Overview.md](../../Indicators-Overview.md) — the full taxonomy. diff --git a/docs/wiki/indicators/volatility/Indicator-ChandelierExit.md b/docs/wiki/indicators/volatility/Indicator-ChandelierExit.md new file mode 100644 index 00000000..047f510e --- /dev/null +++ b/docs/wiki/indicators/volatility/Indicator-ChandelierExit.md @@ -0,0 +1,166 @@ +# ChandelierExit + +> Chandelier Exit — an ATR trailing stop hung a fixed number of ATRs off +> the highest high (for longs) or the lowest low (for shorts) of a window. + +## Quick reference + +| Field | Value | +|-------|-------| +| Family | Volatility | +| Sub-category | Trailing stop | +| Input type | `Candle` (uses `high`, `low`, `close`) | +| Output type | `(long_stop, short_stop)` | +| Output range | unbounded (price scale) | +| Default parameters | `period = 22`, `multiplier = 3.0` (Python) | +| Warmup period | `period` | +| Interpretation | Long/short trailing-stop levels; a close past one exits the trade. | + +## Formula + +``` +long_stop = highest_high(period) − multiplier · ATR(period) +short_stop = lowest_low(period) + multiplier · ATR(period) +``` + +Chuck LeBeau's Chandelier Exit hangs the stop off the extreme of the lookback +window — like a chandelier off a ceiling — a fixed `multiplier · ATR` below the +highest high (for a long) or above the lowest low (for a short). Because the +extreme only moves favourably while a trend runs, the stop trails price up +(or down) and never loosens. A long is exited when price closes below +`long_stop`; a short when it closes above `short_stop`. The classic +configuration is a `22`-bar window with a `3.0` multiplier. + +## Parameters + +- `period` — the window for both the highest high / lowest low and the ATR + (Python default `22`). +- `multiplier` — how many ATRs the stop hangs off the extreme (default `3.0`). + +`ChandelierExit::classic()` returns the `(22, 3.0)` configuration. + +## Inputs / Outputs + +From `crates/wickra-core/src/indicators/chandelier_exit.rs`: + +```rust +impl Indicator for ChandelierExit { + type Input = Candle; + type Output = ChandelierExitOutput; // { long_stop: f64, short_stop: f64 } + // update(&mut self, input: Candle) -> Option +} +``` + +`ChandelierExit` is a **candle-input** indicator (it reads `high`, `low`, +`close`). Python's streaming `update` returns a `(long_stop, short_stop)` +tuple; the batch helper returns an `(n, 2)` array with columns +`[long_stop, short_stop]`. Node's `update` returns `{ longStop, shortStop }` +and `batch` a flat `[l0, s0, l1, s1, …]` array; WASM matches Node. + +## Warmup + +`ChandelierExit::classic().warmup_period() == 22`. The highest-high / lowest-low +window and the inner ATR become ready on the same candle — input index +`period − 1`. + +## Edge cases + +- **Window bound.** `long_stop` never exceeds the window's highest high, and + `short_stop` never drops below its lowest low + (`long_stop_below_highest_short_stop_above_lowest` pins this). +- **Flat market.** Constant candles give constant `ATR` and equal extremes, so + both stops sit a fixed `multiplier · ATR` from the price. +- **Reset.** `ce.reset()` clears the ATR and both extreme windows. + +## Examples + +### Rust + +```rust +use wickra::{BatchExt, Candle, Indicator, ChandelierExit}; + +fn main() -> Result<(), Box> { + let mut ce = ChandelierExit::new(5, 3.0)?; + // Flat market: ATR = 2, HH = 11, LL = 9. + let candles: Vec = (0..20) + .map(|i| Candle::new(10.0, 11.0, 9.0, 10.0, 1.0, i).unwrap()) + .collect(); + let out = ce.batch(&candles); + println!("{:?}", out.last().unwrap()); + Ok(()) +} +``` + +Output: + +``` +Some(ChandelierExitOutput { long_stop: 5.0, short_stop: 15.0 }) +``` + +`long_stop = 11 − 3·2 = 5`, `short_stop = 9 + 3·2 = 15`. This matches the +`reference_values_flat_market` test in +`crates/wickra-core/src/indicators/chandelier_exit.rs`. + +### Python + +```python +import numpy as np +import wickra as ta + +ce = ta.ChandelierExit(5, 3.0) +n = 20 +high = np.full(n, 11.0) +low = np.full(n, 9.0) +close = np.full(n, 10.0) +print(ce.batch(high, low, close)[-1]) # [long_stop, short_stop] +``` + +Output: + +``` +[ 5. 15.] +``` + +### Node + +```javascript +const ta = require('wickra'); +const ce = new ta.ChandelierExit(5, 3.0); +const n = 20; +const high = Array(n).fill(11), low = Array(n).fill(9), close = Array(n).fill(10); +const out = ce.batch(high, low, close); +console.log(out.slice(-2)); // [long_stop, short_stop] of the last bar +``` + +Output: + +``` +[ 5, 15 ] +``` + +## Interpretation + +While long, watch `long_stop`: it climbs as new highs print and never falls, +so a close beneath it is a disciplined exit. While short, `short_stop` is the +mirror. The `3.0` multiplier is wide enough to ride a trend through normal +pullbacks; tightening it exits sooner at the cost of more whipsaws. + +## Common pitfalls + +- **Using the wrong stop for the position.** `long_stop` only applies to + longs, `short_stop` only to shorts — they are not a channel. +- **Feeding it scalar prices.** It needs the full `high`/`low`/`close` bar. + +## References + +Chuck LeBeau's Chandelier Exit; the highest-high-minus-ATR formulation here +matches the standard definition. + +## See also + +- [Indicator-SuperTrend.md](Indicator-SuperTrend.md) — an ATR trailing stop + with explicit flip logic and a single line. +- [Indicator-ChandeKrollStop.md](Indicator-ChandeKrollStop.md) — a two-stage + ATR stop that smooths the preliminary level. +- [Indicator-Atr.md](Indicator-Atr.md) — the volatility measure underneath. +- [Indicators-Overview.md](../../Indicators-Overview.md) — the full taxonomy. diff --git a/docs/wiki/indicators/volatility/Indicator-SuperTrend.md b/docs/wiki/indicators/volatility/Indicator-SuperTrend.md new file mode 100644 index 00000000..352bf16d --- /dev/null +++ b/docs/wiki/indicators/volatility/Indicator-SuperTrend.md @@ -0,0 +1,174 @@ +# SuperTrend + +> SuperTrend — an ATR-banded trailing stop that flips sides when price +> closes through the band, reporting both the stop level and the trend +> direction. + +## Quick reference + +| Field | Value | +|-------|-------| +| Family | Volatility | +| Sub-category | Trailing stop | +| Input type | `Candle` (uses `high`, `low`, `close`) | +| Output type | `(value, direction)` | +| Output range | `value`: unbounded (price scale); `direction`: `−1.0` or `+1.0` | +| Default parameters | `atr_period = 10`, `multiplier = 3.0` (Python) | +| Warmup period | `atr_period` | +| Interpretation | Trend-following stop; a direction flip marks a trend change. | + +## Formula + +``` +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 + +downtrend: stay down while close <= final_upper, else flip up +uptrend: stay up while close >= final_lower, else flip down +SuperTrend = final_lower in an uptrend, final_upper in a downtrend +``` + +The two final bands ratchet — the upper band only moves down, the lower band +only moves up — until price closes through the active one. That close flips +the trend and hands the trailing-stop role to the opposite band. The result is +a single line that sits below price in an uptrend and above it in a downtrend, +plus a `direction` flag (`+1.0` / `-1.0`) that names which regime you are in. + +## Parameters + +- `atr_period` — the ATR lookback (Python default `10`). +- `multiplier` — how many ATRs wide the bands sit (Python default `3.0`). + +`SuperTrend::classic()` returns Wilder's `(10, 3.0)` configuration. + +## Inputs / Outputs + +From `crates/wickra-core/src/indicators/super_trend.rs`: + +```rust +impl Indicator for SuperTrend { + type Input = Candle; + type Output = SuperTrendOutput; // { value: f64, direction: f64 } + // update(&mut self, input: Candle) -> Option +} +``` + +`SuperTrend` is a **candle-input** indicator (it reads `high`, `low`, `close`). +Python's streaming `update` returns a `(value, direction)` tuple; the batch +helper returns an `(n, 2)` array with columns `[value, direction]`. Node's +`update` returns `{ value, direction }` and `batch` a flat `[v0, d0, v1, d1, …]` +array; WASM matches Node. + +## Warmup + +`SuperTrend::classic().warmup_period() == 10`. The first value lands once the +inner ATR is ready, on input index `atr_period − 1`. The first ATR-ready bar +seeds the trend as up; the flip logic corrects it within a few bars if the +market is actually falling. + +## Edge cases + +- **Seed direction.** The first emitted bar is always `direction = +1.0`; a + genuine downtrend flips it within a handful of bars. +- **Flat market.** Constant candles give a constant ATR, so both bands and the + line are flat and the trend never flips. +- **Reset.** `st.reset()` clears the ATR and the carried band state. + +## Examples + +### Rust + +```rust +use wickra::{BatchExt, Candle, Indicator, SuperTrend}; + +fn main() -> Result<(), Box> { + let mut st = SuperTrend::new(5, 3.0)?; + // Flat market: ATR = 2, hl2 = 10, lower band = 10 - 3·2 = 4. + let candles: Vec = (0..20) + .map(|i| Candle::new(10.0, 11.0, 9.0, 10.0, 1.0, i).unwrap()) + .collect(); + let out = st.batch(&candles); + println!("{:?}", out.last().unwrap()); + Ok(()) +} +``` + +Output: + +``` +Some(SuperTrendOutput { value: 4.0, direction: 1.0 }) +``` + +On a flat market the seeded uptrend never flips and the line holds at the +lower band, `4.0`. + +### Python + +```python +import numpy as np +import wickra as ta + +st = ta.SuperTrend(5, 3.0) +n = 20 +high = np.full(n, 11.0) +low = np.full(n, 9.0) +close = np.full(n, 10.0) +print(st.batch(high, low, close)[-1]) # [value, direction] +``` + +Output: + +``` +[4. 1.] +``` + +### Node + +```javascript +const ta = require('wickra'); +const st = new ta.SuperTrend(5, 3.0); +const n = 20; +const high = Array(n).fill(11), low = Array(n).fill(9), close = Array(n).fill(10); +const out = st.batch(high, low, close); +console.log(out.slice(-2)); // [value, direction] of the last bar +``` + +Output: + +``` +[ 4, 1 ] +``` + +## Interpretation + +`SuperTrend` is used as a stop-and-reverse system: stay long while +`direction == +1` and the line trails below price, flip to short the bar the +`direction` turns `-1` and the line jumps above price. A larger `multiplier` +widens the bands — fewer whipsaws, later flips; a smaller one flips sooner. +The line itself doubles as a concrete stop-loss level. + +## Common pitfalls + +- **Expecting an exact flip bar.** The seed bar is always an uptrend; on + genuinely falling data the flip lands a few bars in. +- **Reading `value` without `direction`.** The line means "support" in an + uptrend and "resistance" in a downtrend — `direction` tells you which. + +## References + +The SuperTrend trailing stop; the final-band ratchet formulation here matches +the widely used TradingView / Olivier Seban definition. + +## See also + +- [Indicator-Psar.md](Indicator-Psar.md) — Wilder's parabolic stop-and-reverse. +- [Indicator-AtrTrailingStop.md](Indicator-AtrTrailingStop.md) — a plain + ATR trailing stop without the band ratchet. +- [Indicator-Atr.md](Indicator-Atr.md) — the volatility measure underneath. +- [Indicators-Overview.md](../../Indicators-Overview.md) — the full taxonomy.