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
+5 -1
View File
@@ -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
+282
View File
@@ -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<Self> {
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<Option<SuperTrendValue>> {
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<f64>,
low: Vec<f64>,
close: Vec<f64>,
) -> napi::Result<Vec<f64>> {
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<Self> {
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<Option<ChandelierExitValue>> {
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<f64>,
low: Vec<f64>,
close: Vec<f64>,
) -> napi::Result<Vec<f64>> {
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<Self> {
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<Option<ChandeKrollStopValue>> {
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<f64>,
low: Vec<f64>,
close: Vec<f64>,
) -> napi::Result<Vec<f64>> {
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<Self> {
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<Option<f64>> {
Ok(self.inner.update(cnd(high, low, close, 0.0)?))
}
#[napi]
pub fn batch(
&mut self,
high: Vec<f64>,
low: Vec<f64>,
close: Vec<f64>,
) -> napi::Result<Vec<f64>> {
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")]
@@ -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]: ...
+304
View File
@@ -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<Self> {
Ok(Self {
inner: wc::SuperTrend::new(atr_period, multiplier).map_err(map_err)?,
})
}
fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult<Option<(f64, f64)>> {
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<Bound<'py, PyArray2<f64>>> {
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<Self> {
Ok(Self {
inner: wc::ChandelierExit::new(period, multiplier).map_err(map_err)?,
})
}
fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult<Option<(f64, f64)>> {
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<Bound<'py, PyArray2<f64>>> {
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<Self> {
Ok(Self {
inner: wc::ChandeKrollStop::new(atr_period, atr_multiplier, stop_period)
.map_err(map_err)?,
})
}
fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult<Option<(f64, f64)>> {
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<Bound<'py, PyArray2<f64>>> {
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<Self> {
Ok(Self {
inner: wc::AtrTrailingStop::new(atr_period, multiplier).map_err(map_err)?,
})
}
fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult<Option<f64>> {
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<Bound<'py, PyArray1<f64>>> {
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::<PyChaikinOscillator>()?;
m.add_class::<PyForceIndex>()?;
m.add_class::<PyEaseOfMovement>()?;
m.add_class::<PySuperTrend>()?;
m.add_class::<PyChandelierExit>()?;
m.add_class::<PyChandeKrollStop>()?;
m.add_class::<PyAtrTrailingStop>()?;
Ok(())
}
+201
View File
@@ -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<WasmSuperTrend, JsError> {
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<JsValue, JsError> {
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<Float64Array, JsError> {
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<WasmChandelierExit, JsError> {
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<JsValue, JsError> {
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<Float64Array, JsError> {
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<WasmChandeKrollStop, JsError> {
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<JsValue, JsError> {
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<Float64Array, JsError> {
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<WasmAtrTrailingStop, JsError> {
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<Option<f64>, 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<Float64Array, JsError> {
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,
+4
View File
@@ -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", ".."]
@@ -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};
+4
View File
@@ -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.
+9 -5
View File
@@ -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
@@ -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_{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
```
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<f64>
}
```
`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<dyn std::error::Error>> {
let mut ts = AtrTrailingStop::new(5, 3.0)?;
// Flat market: ATR = 2, loss = 3·2 = 6, stop = 10 - 6 = 4.
let candles: Vec<Candle> = (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.
@@ -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<ChandeKrollStopOutput>
}
```
`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<dyn std::error::Error>> {
let mut cks = ChandeKrollStop::new(5, 1.0, 3)?;
// Flat market: ATR = 2, HH = 11, LL = 9.
let candles: Vec<Candle> = (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.
@@ -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<ChandelierExitOutput>
}
```
`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<dyn std::error::Error>> {
let mut ce = ChandelierExit::new(5, 3.0)?;
// Flat market: ATR = 2, HH = 11, LL = 9.
let candles: Vec<Candle> = (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.
@@ -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<SuperTrendOutput>
}
```
`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<dyn std::error::Error>> {
let mut st = SuperTrend::new(5, 3.0)?;
// Flat market: ATR = 2, hl2 = 10, lower band = 10 - 3·2 = 4.
let candles: Vec<Candle> = (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.