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:
@@ -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
|
||||
|
||||
@@ -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]: ...
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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_{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<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<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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_{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<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.
|
||||
Reference in New Issue
Block a user