From f10b8c2e2ddc80c0125fc04972176a6c67b04b0d Mon Sep 17 00:00:00 2001 From: kingchenc Date: Mon, 25 May 2026 19:36:14 +0200 Subject: [PATCH] feat(family-09): add 7 trailing stops (HiLo, Volty, Yo-Yo, Donchian, Pct, Step, Renko) (#46) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(family-09): add 7 trailing stops (HiLo, Volty, Yo-Yo, Donchian, Pct, Step, Renko) Rounds out the Trailing Stops family from 5 to 12 indicators: - HiLoActivator (Crabel): SMA-of-high/SMA-of-low trail with a one-bar lag; emits the opposite-side SMA as the trailing stop. - VoltyStop (Cynthia Kase): ATR trail anchored on the extreme close since the trade was opened — tighter than AtrTrailingStop on pullbacks. - YoyoExit: long-only ATR trail with an explicit re-entry trigger at trail + multiplier*ATR; exposes an in_trade flag. - DonchianStop (Turtle): lowest low / highest high over the window; multi-output {stop_long, stop_short}. - PercentageTrailingStop: fixed-percent trail that scales across instruments without per-asset tuning. - StepTrailingStop: snaps to a step_size-aligned grid; mirrors discretionary stop-by-hand workflow. - RenkoTrailingStop: block-anchored trail; only moves on full-block advances, ignores intra-block noise. All seven are wired into wickra-core, the Python / Node / WASM bindings, the indicator_update + indicator_update_candle fuzz targets, the wickra bench harness, and the Python + Node test suites. README counter bumps from 71 to 78; CHANGELOG entry under [Unreleased]. * fix(family-09): satisfy pedantic clippy lints - hilo_activator: rewrite match-Some/None as if-let-else (single_match_else), add backticks around the HiLo identifier in module/struct doc (doc_markdown). - percentage / step / renko trailing stop tests: use f64::from(i32) instead of `as f64` (cast_lossless). - bench `benches()` is now >100 lines after Family 09 was wired in; allow too_many_lines (matches the python pymodule fn). --- CHANGELOG.md | 10 + README.md | 6 +- bindings/node/__tests__/indicators.test.js | 27 ++ bindings/node/index.js | 9 +- bindings/node/src/lib.rs | 344 ++++++++++++++ bindings/python/python/wickra/__init__.py | 14 + bindings/python/src/lib.rs | 437 ++++++++++++++++++ bindings/python/tests/test_known_values.py | 72 +++ bindings/python/tests/test_new_indicators.py | 19 + bindings/wasm/src/lib.rs | 245 ++++++++++ .../src/indicators/donchian_stop.rs | 218 +++++++++ .../src/indicators/hilo_activator.rs | 264 +++++++++++ crates/wickra-core/src/indicators/mod.rs | 14 + .../indicators/percentage_trailing_stop.rs | 221 +++++++++ .../src/indicators/renko_trailing_stop.rs | 233 ++++++++++ .../src/indicators/step_trailing_stop.rs | 225 +++++++++ .../wickra-core/src/indicators/volty_stop.rs | 274 +++++++++++ .../wickra-core/src/indicators/yoyo_exit.rs | 269 +++++++++++ crates/wickra-core/src/lib.rs | 33 +- crates/wickra/benches/indicators.rs | 29 +- fuzz/fuzz_targets/indicator_update.rs | 12 +- fuzz/fuzz_targets/indicator_update_candle.rs | 26 +- 22 files changed, 2965 insertions(+), 36 deletions(-) create mode 100644 crates/wickra-core/src/indicators/donchian_stop.rs create mode 100644 crates/wickra-core/src/indicators/hilo_activator.rs create mode 100644 crates/wickra-core/src/indicators/percentage_trailing_stop.rs create mode 100644 crates/wickra-core/src/indicators/renko_trailing_stop.rs create mode 100644 crates/wickra-core/src/indicators/step_trailing_stop.rs create mode 100644 crates/wickra-core/src/indicators/volty_stop.rs create mode 100644 crates/wickra-core/src/indicators/yoyo_exit.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 356eb657..0bcc19e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **Family 09 — Trailing Stops, seven new indicators.** Rounds out the + trailing-stop family from 5 to 12: `HiLoActivator` (Crabel's + SMA-of-high / SMA-of-low trail), `VoltyStop` (Cynthia Kase's + extreme-anchor ATR stop), `YoyoExit` (long-only ATR trail with a + re-entry trigger), `DonchianStop` (the original Turtle exit, lowest + low / highest high), `PercentageTrailingStop` (fixed-percent trail), + `StepTrailingStop` (round-number grid trail) and `RenkoTrailingStop` + (block-anchored Renko-style trail). All wired into the four bindings + (Rust, Python, Node, WASM), the streaming + batch fuzz targets, and + the bench harness. - **Klinger Volume Oscillator (KVO).** Stephen J. Klinger's trend-aware volume-force oscillator: `EMA(vf, fast) − EMA(vf, slow)` over a daily volume force scaled by cumulative-measurement ratio. Classic diff --git a/README.md b/README.md index 8872607f..742c6ec0 100644 --- a/README.md +++ b/README.md @@ -109,7 +109,7 @@ python -m benchmarks.compare_libraries ## Indicators -121 streaming-first indicators across nine families. Every one passes the +128 streaming-first indicators across nine families. Every one passes the `batch == streaming` equivalence test, reference-value tests, and reset semantics tests. @@ -121,7 +121,7 @@ semantics tests. | Price Oscillators | PPO, DPO, Coppock, Accelerator Oscillator, Balance of Power, APO, AO Histogram, CFO, Zero-Lag MACD, Elder Impulse, STC | | Volatility & Bands | ATR, Bollinger Bands, Keltner Channels, Donchian Channels, NATR, StdDev, Ulcer Index, Historical Volatility, Bollinger Bandwidth, %B, True Range, Chaikin Volatility, RVI (Relative Volatility Index), Parkinson Volatility, Garman-Klass Volatility, Rogers-Satchell Volatility, Yang-Zhang Volatility | | Bands & Channels | MA Envelope, Acceleration Bands, STARC Bands, ATR Bands, Hurst Channel, LinReg Channel, Standard Error Bands, Double Bollinger Bands, TTM Squeeze, Fractal Chaos Bands, VWAP StdDev Bands | -| Trailing Stops | Parabolic SAR, SuperTrend, Chandelier Exit, Chande Kroll Stop, ATR Trailing Stop | +| Trailing Stops | Parabolic SAR, SuperTrend, Chandelier Exit, Chande Kroll Stop, ATR Trailing Stop, HiLo Activator, Volty Stop, Yo-Yo Exit, Donchian Channel Stop, Percentage Trailing Stop, Step Trailing Stop, Renko Trailing Stop | | Volume | OBV, VWAP (cumulative + rolling), ADL, Volume-Price Trend, Chaikin Money Flow, Chaikin Oscillator, Force Index, Ease of Movement, Klinger Volume Oscillator, Volume Oscillator, NVI, PVI, Williams A/D, Anchored VWAP, Demand Index, TSV, VZO, Market Facilitation Index | | Price Statistics | Typical Price, Median Price, Weighted Close, Linear Regression, Linear Regression Slope, Z-Score, Linear Regression Angle | @@ -196,7 +196,7 @@ A Python live-trading example using the public `websockets` package lives at ``` wickra/ ├── crates/ -│ ├── wickra-core/ core engine + all 121 indicators +│ ├── wickra-core/ core engine + all 128 indicators │ ├── wickra/ top-level facade crate (publishes on crates.io) + benches/ │ └── wickra-data/ CSV reader, tick aggregator, live exchange feeds ├── bindings/ diff --git a/bindings/node/__tests__/indicators.test.js b/bindings/node/__tests__/indicators.test.js index 5543d7ed..52807277 100644 --- a/bindings/node/__tests__/indicators.test.js +++ b/bindings/node/__tests__/indicators.test.js @@ -69,6 +69,9 @@ const scalarFactories = { VerticalHorizontalFilter: () => new wickra.VerticalHorizontalFilter(28), ZScore: () => new wickra.ZScore(20), LinRegAngle: () => new wickra.LinRegAngle(14), + PercentageTrailingStop: () => new wickra.PercentageTrailingStop(5), + StepTrailingStop: () => new wickra.StepTrailingStop(1), + RenkoTrailingStop: () => new wickra.RenkoTrailingStop(1), LaguerreRSI: () => new wickra.LaguerreRSI(0.5), ConnorsRSI: () => new wickra.ConnorsRSI(3, 2, 100), RVIVolatility: () => new wickra.RVIVolatility(10), @@ -125,6 +128,9 @@ const candleScalar = { VZO: { make: () => new wickra.VZO(14), step: (ind, i) => ind.update(close[i], volume[i]), batch: (ind) => ind.batch(close, volume) }, MarketFacilitationIndex: { make: () => new wickra.MarketFacilitationIndex(), step: (ind, i) => ind.update(high[i], low[i], volume[i]), batch: (ind) => ind.batch(high, low, volume) }, AtrTrailingStop: { make: () => new wickra.AtrTrailingStop(14, 3), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) }, + HiLoActivator: { make: () => new wickra.HiLoActivator(3), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) }, + VoltyStop: { make: () => new wickra.VoltyStop(14, 2), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) }, + YoyoExit: { make: () => new wickra.YoyoExit(14, 2), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) }, TypicalPrice: { make: () => new wickra.TypicalPrice(), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) }, MedianPrice: { make: () => new wickra.MedianPrice(), step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) }, WeightedClose: { make: () => new wickra.WeightedClose(), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) }, @@ -173,6 +179,7 @@ const multi = { SuperTrend: { make: () => new wickra.SuperTrend(10, 3), fields: ['value', 'direction'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) }, ChandelierExit: { make: () => new wickra.ChandelierExit(22, 3), fields: ['longStop', 'shortStop'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) }, ChandeKrollStop: { make: () => new wickra.ChandeKrollStop(10, 1, 9), fields: ['stopLong', 'stopShort'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) }, + DonchianStop: { make: () => new wickra.DonchianStop(10), fields: ['stopLong', 'stopShort'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) }, // Family 05: bands & channels MaEnvelope: { make: () => new wickra.MaEnvelope(20, 0.025), fields: ['upper', 'middle', 'lower'], step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) }, AccelerationBands: { make: () => new wickra.AccelerationBands(20, 0.001), fields: ['upper', 'middle', 'lower'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) }, @@ -311,6 +318,26 @@ test('LinRegAngle of a unit-slope series is 45 degrees', () => { assert.ok(Math.abs(out[4] - 45) < 1e-9); }); +test('PercentageTrailingStop seeds and ratchets', () => { + const s = new wickra.PercentageTrailingStop(10); + assert.ok(Math.abs(s.update(100) - 90) < 1e-9); + assert.ok(Math.abs(s.update(110) - 99) < 1e-9); +}); + +test('RenkoTrailingStop only advances after a full block', () => { + const s = new wickra.RenkoTrailingStop(1); + assert.ok(Math.abs(s.update(100) - 99) < 1e-9); + assert.ok(Math.abs(s.update(100.5) - 99) < 1e-9); + assert.ok(Math.abs(s.update(101) - 100) < 1e-9); +}); + +test('DonchianStop window extremes', () => { + const out = new wickra.DonchianStop(5).batch([1, 2, 3, 4, 5], [0, 1, 2, 3, 4]); + // [long0, short0, long1, short1, ...]: idx 8 (=4*2) = long_5th, idx 9 = short_5th. + assert.ok(Math.abs(out[8] - 0) < 1e-9); + assert.ok(Math.abs(out[9] - 5) < 1e-9); +}); + test('MaEnvelope reference values', () => { // SMA([10, 20, 30]) = 20; with percent 0.10: upper=22, lower=18. const out = new wickra.MaEnvelope(3, 0.10).batch([10, 20, 30]); diff --git a/bindings/node/index.js b/bindings/node/index.js index c93879fe..34c006e2 100644 --- a/bindings/node/index.js +++ b/bindings/node/index.js @@ -310,7 +310,7 @@ if (!nativeBinding) { throw new Error(`Failed to load native binding`) } -const { version, SMA, EMA, WMA, RSI, DEMA, TEMA, HMA, ROC, TRIX, SMMA, TRIMA, ZLEMA, MOM, CMO, DPO, StdDev, UlcerIndex, VerticalHorizontalFilter, ZScore, MACD, BollingerBands, ATR, Stochastic, OBV, ADX, ADXR, CCI, WilliamsR, MFI, PSAR, Keltner, Donchian, VWAP, RollingVWAP, AwesomeOscillator, Aroon, KAMA, RVI, PGO, KST, SMI, LaguerreRSI, ConnorsRSI, Inertia, ALMA, McGinleyDynamic, FRAMA, VIDYA, JMA, Alligator, EVWMA, APO, AwesomeOscillatorHistogram, CFO, ZeroLagMACD, ElderImpulse, STC, T3, TSI, PMO, TII, ADL, VolumePriceTrend, ChaikinMoneyFlow, ChaikinOscillator, ForceIndex, EaseOfMovement, KVO, VolumeOscillator, NVI, PVI, WilliamsAD, AnchoredVWAP, DemandIndex, TSV, VZO, MarketFacilitationIndex, SuperTrend, ChandelierExit, ChandeKrollStop, AtrTrailingStop, TypicalPrice, MedianPrice, WeightedClose, LinearRegression, LinRegSlope, AcceleratorOscillator, BalanceOfPower, ChoppinessIndex, TrueRange, ChaikinVolatility, LinRegAngle, BollingerBandwidth, PercentB, NATR, HistoricalVolatility, AroonOscillator, Vortex, RWI, WaveTrend, MassIndex, StochRSI, UltimateOscillator, PPO, Coppock, VWMA, RVIVolatility, ParkinsonVolatility, GarmanKlassVolatility, RogersSatchellVolatility, YangZhangVolatility, MaEnvelope, AccelerationBands, StarcBands, AtrBands, HurstChannel, LinRegChannel, StandardErrorBands, DoubleBollinger, TtmSqueeze, FractalChaosBands, VwapStdDevBands } = nativeBinding +const { version, SMA, EMA, WMA, RSI, DEMA, TEMA, HMA, ROC, TRIX, SMMA, TRIMA, ZLEMA, MOM, CMO, DPO, StdDev, UlcerIndex, VerticalHorizontalFilter, ZScore, MACD, BollingerBands, ATR, Stochastic, OBV, ADX, ADXR, CCI, WilliamsR, MFI, PSAR, Keltner, Donchian, VWAP, RollingVWAP, AwesomeOscillator, Aroon, KAMA, RVI, PGO, KST, SMI, LaguerreRSI, ConnorsRSI, Inertia, ALMA, McGinleyDynamic, FRAMA, VIDYA, JMA, Alligator, EVWMA, APO, AwesomeOscillatorHistogram, CFO, ZeroLagMACD, ElderImpulse, STC, T3, TSI, PMO, TII, ADL, VolumePriceTrend, ChaikinMoneyFlow, ChaikinOscillator, ForceIndex, EaseOfMovement, KVO, VolumeOscillator, NVI, PVI, WilliamsAD, AnchoredVWAP, DemandIndex, TSV, VZO, MarketFacilitationIndex, SuperTrend, ChandelierExit, ChandeKrollStop, AtrTrailingStop, HiLoActivator, VoltyStop, YoyoExit, DonchianStop, PercentageTrailingStop, StepTrailingStop, RenkoTrailingStop, TypicalPrice, MedianPrice, WeightedClose, LinearRegression, LinRegSlope, AcceleratorOscillator, BalanceOfPower, ChoppinessIndex, TrueRange, ChaikinVolatility, LinRegAngle, BollingerBandwidth, PercentB, NATR, HistoricalVolatility, AroonOscillator, Vortex, RWI, WaveTrend, MassIndex, StochRSI, UltimateOscillator, PPO, Coppock, VWMA, RVIVolatility, ParkinsonVolatility, GarmanKlassVolatility, RogersSatchellVolatility, YangZhangVolatility, MaEnvelope, AccelerationBands, StarcBands, AtrBands, HurstChannel, LinRegChannel, StandardErrorBands, DoubleBollinger, TtmSqueeze, FractalChaosBands, VwapStdDevBands } = nativeBinding module.exports.version = version module.exports.SMA = SMA @@ -394,6 +394,13 @@ module.exports.SuperTrend = SuperTrend module.exports.ChandelierExit = ChandelierExit module.exports.ChandeKrollStop = ChandeKrollStop module.exports.AtrTrailingStop = AtrTrailingStop +module.exports.HiLoActivator = HiLoActivator +module.exports.VoltyStop = VoltyStop +module.exports.YoyoExit = YoyoExit +module.exports.DonchianStop = DonchianStop +module.exports.PercentageTrailingStop = PercentageTrailingStop +module.exports.StepTrailingStop = StepTrailingStop +module.exports.RenkoTrailingStop = RenkoTrailingStop module.exports.TypicalPrice = TypicalPrice module.exports.MedianPrice = MedianPrice module.exports.WeightedClose = WeightedClose diff --git a/bindings/node/src/lib.rs b/bindings/node/src/lib.rs index 1260c83e..be24a585 100644 --- a/bindings/node/src/lib.rs +++ b/bindings/node/src/lib.rs @@ -3404,6 +3404,350 @@ impl AtrTrailingStopNode { } } +// ============================== HiLo Activator ============================== + +#[napi(js_name = "HiLoActivator")] +pub struct HiLoActivatorNode { + inner: wc::HiLoActivator, +} + +#[napi] +impl HiLoActivatorNode { + #[napi(constructor)] + pub fn new(period: u32) -> napi::Result { + Ok(Self { + inner: wc::HiLoActivator::new(period as usize).map_err(map_err)?, + }) + } + #[napi] + pub fn update(&mut self, high: f64, low: f64, close: f64) -> napi::Result> { + Ok(self.inner.update(cnd(high, low, close, 0.0)?)) + } + #[napi] + pub fn batch( + &mut self, + high: Vec, + low: Vec, + close: Vec, + ) -> napi::Result> { + if high.len() != low.len() || low.len() != close.len() { + return Err(NapiError::from_reason( + "high, low, close must be equal length".to_string(), + )); + } + let mut out = Vec::with_capacity(high.len()); + for i in 0..high.len() { + out.push( + self.inner + .update(cnd(high[i], low[i], close[i], 0.0)?) + .unwrap_or(f64::NAN), + ); + } + Ok(out) + } + #[napi] + pub fn reset(&mut self) { + self.inner.reset(); + } + #[napi(js_name = "isReady")] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[napi(js_name = "warmupPeriod")] + pub fn warmup_period(&self) -> u32 { + self.inner.warmup_period() as u32 + } +} + +// ============================== Volty Stop ============================== + +#[napi(js_name = "VoltyStop")] +pub struct VoltyStopNode { + inner: wc::VoltyStop, +} + +#[napi] +impl VoltyStopNode { + #[napi(constructor)] + pub fn new(atr_period: u32, multiplier: f64) -> napi::Result { + Ok(Self { + inner: wc::VoltyStop::new(atr_period as usize, multiplier).map_err(map_err)?, + }) + } + #[napi] + pub fn update(&mut self, high: f64, low: f64, close: f64) -> napi::Result> { + Ok(self.inner.update(cnd(high, low, close, 0.0)?)) + } + #[napi] + pub fn batch( + &mut self, + high: Vec, + low: Vec, + close: Vec, + ) -> napi::Result> { + if high.len() != low.len() || low.len() != close.len() { + return Err(NapiError::from_reason( + "high, low, close must be equal length".to_string(), + )); + } + let mut out = Vec::with_capacity(high.len()); + for i in 0..high.len() { + out.push( + self.inner + .update(cnd(high[i], low[i], close[i], 0.0)?) + .unwrap_or(f64::NAN), + ); + } + Ok(out) + } + #[napi] + pub fn reset(&mut self) { + self.inner.reset(); + } + #[napi(js_name = "isReady")] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[napi(js_name = "warmupPeriod")] + pub fn warmup_period(&self) -> u32 { + self.inner.warmup_period() as u32 + } +} + +// ============================== Yo-Yo Exit ============================== + +#[napi(js_name = "YoyoExit")] +pub struct YoyoExitNode { + inner: wc::YoyoExit, +} + +#[napi] +impl YoyoExitNode { + #[napi(constructor)] + pub fn new(atr_period: u32, multiplier: f64) -> napi::Result { + Ok(Self { + inner: wc::YoyoExit::new(atr_period as usize, multiplier).map_err(map_err)?, + }) + } + #[napi] + pub fn update(&mut self, high: f64, low: f64, close: f64) -> napi::Result> { + Ok(self.inner.update(cnd(high, low, close, 0.0)?)) + } + #[napi] + pub fn batch( + &mut self, + high: Vec, + low: Vec, + close: Vec, + ) -> napi::Result> { + if high.len() != low.len() || low.len() != close.len() { + return Err(NapiError::from_reason( + "high, low, close must be equal length".to_string(), + )); + } + let mut out = Vec::with_capacity(high.len()); + for i in 0..high.len() { + out.push( + self.inner + .update(cnd(high[i], low[i], close[i], 0.0)?) + .unwrap_or(f64::NAN), + ); + } + Ok(out) + } + #[napi] + pub fn reset(&mut self) { + self.inner.reset(); + } + #[napi(js_name = "isReady")] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[napi(js_name = "warmupPeriod")] + pub fn warmup_period(&self) -> u32 { + self.inner.warmup_period() as u32 + } + #[napi(js_name = "inTrade")] + pub fn in_trade(&self) -> bool { + self.inner.in_trade() + } +} + +// ============================== Donchian Stop ============================== + +#[napi(object)] +pub struct DonchianStopValue { + pub stop_long: f64, + pub stop_short: f64, +} + +#[napi(js_name = "DonchianStop")] +pub struct DonchianStopNode { + inner: wc::DonchianStop, +} + +#[napi] +impl DonchianStopNode { + #[napi(constructor)] + pub fn new(period: u32) -> napi::Result { + Ok(Self { + inner: wc::DonchianStop::new(period as usize).map_err(map_err)?, + }) + } + #[napi] + pub fn update(&mut self, high: f64, low: f64) -> napi::Result> { + Ok(self + .inner + .update(cnd(high, low, low, 0.0)?) + .map(|o| DonchianStopValue { + stop_long: o.stop_long, + stop_short: o.stop_short, + })) + } + /// Returns `[long0, short0, long1, short1, ...]`, length `2 * n`. Warmup + /// positions are `NaN`. + #[napi] + pub fn batch(&mut self, high: Vec, low: Vec) -> napi::Result> { + if high.len() != low.len() { + return Err(NapiError::from_reason( + "high and low 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], low[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 + } +} + +// ============================== Percentage Trailing Stop ============================== + +#[napi(js_name = "PercentageTrailingStop")] +pub struct PercentageTrailingStopNode { + inner: wc::PercentageTrailingStop, +} + +#[napi] +impl PercentageTrailingStopNode { + #[napi(constructor)] + pub fn new(percent: f64) -> napi::Result { + Ok(Self { + inner: wc::PercentageTrailingStop::new(percent).map_err(map_err)?, + }) + } + #[napi] + pub fn update(&mut self, value: f64) -> Option { + self.inner.update(value) + } + #[napi] + pub fn batch(&mut self, prices: Vec) -> Vec { + flatten(self.inner.batch(&prices)) + } + #[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 + } +} + +// ============================== Step Trailing Stop ============================== + +#[napi(js_name = "StepTrailingStop")] +pub struct StepTrailingStopNode { + inner: wc::StepTrailingStop, +} + +#[napi] +impl StepTrailingStopNode { + #[napi(constructor)] + pub fn new(step_size: f64) -> napi::Result { + Ok(Self { + inner: wc::StepTrailingStop::new(step_size).map_err(map_err)?, + }) + } + #[napi] + pub fn update(&mut self, value: f64) -> Option { + self.inner.update(value) + } + #[napi] + pub fn batch(&mut self, prices: Vec) -> Vec { + flatten(self.inner.batch(&prices)) + } + #[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 + } +} + +// ============================== Renko Trailing Stop ============================== + +#[napi(js_name = "RenkoTrailingStop")] +pub struct RenkoTrailingStopNode { + inner: wc::RenkoTrailingStop, +} + +#[napi] +impl RenkoTrailingStopNode { + #[napi(constructor)] + pub fn new(block_size: f64) -> napi::Result { + Ok(Self { + inner: wc::RenkoTrailingStop::new(block_size).map_err(map_err)?, + }) + } + #[napi] + pub fn update(&mut self, value: f64) -> Option { + self.inner.update(value) + } + #[napi] + pub fn batch(&mut self, prices: Vec) -> Vec { + flatten(self.inner.batch(&prices)) + } + #[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 + } +} + // ============================== Typical Price ============================== #[napi(js_name = "TypicalPrice")] diff --git a/bindings/python/python/wickra/__init__.py b/bindings/python/python/wickra/__init__.py index 2367dc42..ff21bccf 100644 --- a/bindings/python/python/wickra/__init__.py +++ b/bindings/python/python/wickra/__init__.py @@ -107,6 +107,13 @@ from ._wickra import ( ChandelierExit, ChandeKrollStop, AtrTrailingStop, + HiLoActivator, + VoltyStop, + YoyoExit, + DonchianStop, + PercentageTrailingStop, + StepTrailingStop, + RenkoTrailingStop, TrueRange, ChaikinVolatility, RVIVolatility, @@ -240,6 +247,13 @@ __all__ = [ "ChandelierExit", "ChandeKrollStop", "AtrTrailingStop", + "HiLoActivator", + "VoltyStop", + "YoyoExit", + "DonchianStop", + "PercentageTrailingStop", + "StepTrailingStop", + "RenkoTrailingStop", "TrueRange", "ChaikinVolatility", "RVIVolatility", diff --git a/bindings/python/src/lib.rs b/bindings/python/src/lib.rs index 5d0be0cc..fef438f4 100644 --- a/bindings/python/src/lib.rs +++ b/bindings/python/src/lib.rs @@ -5763,6 +5763,436 @@ impl PyAtrTrailingStop { } } +// ============================== HiLo Activator ============================== + +#[pyclass(name = "HiLoActivator", module = "wickra._wickra", skip_from_py_object)] +#[derive(Clone)] +struct PyHiLoActivator { + inner: wc::HiLoActivator, +} + +#[pymethods] +impl PyHiLoActivator { + #[new] + #[pyo3(signature = (period=3))] + fn new(period: usize) -> PyResult { + Ok(Self { + inner: wc::HiLoActivator::new(period).map_err(map_err)?, + }) + } + fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult> { + let c = extract_candle(candle)?; + Ok(self.inner.update(c)) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let h = high + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let l = low + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let c = close + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + if h.len() != l.len() || l.len() != c.len() { + return Err(PyValueError::new_err( + "high, low, close must be equal length", + )); + } + let mut out = Vec::with_capacity(h.len()); + for i in 0..h.len() { + let candle = wc::Candle::new(c[i], h[i], l[i], c[i], 0.0, 0).map_err(map_err)?; + out.push(self.inner.update(candle).unwrap_or(f64::NAN)); + } + Ok(out.into_pyarray(py)) + } + #[getter] + fn period(&self) -> usize { + self.inner.period() + } + 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 { + format!("HiLoActivator(period={})", self.inner.period()) + } +} + +// ============================== Volty Stop ============================== + +#[pyclass(name = "VoltyStop", module = "wickra._wickra", skip_from_py_object)] +#[derive(Clone)] +struct PyVoltyStop { + inner: wc::VoltyStop, +} + +#[pymethods] +impl PyVoltyStop { + #[new] + #[pyo3(signature = (atr_period=14, multiplier=2.0))] + fn new(atr_period: usize, multiplier: f64) -> PyResult { + Ok(Self { + inner: wc::VoltyStop::new(atr_period, multiplier).map_err(map_err)?, + }) + } + fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult> { + let c = extract_candle(candle)?; + Ok(self.inner.update(c)) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let h = high + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let l = low + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let c = close + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + if h.len() != l.len() || l.len() != c.len() { + return Err(PyValueError::new_err( + "high, low, close must be equal length", + )); + } + let mut out = Vec::with_capacity(h.len()); + for i in 0..h.len() { + let candle = wc::Candle::new(c[i], h[i], l[i], c[i], 0.0, 0).map_err(map_err)?; + out.push(self.inner.update(candle).unwrap_or(f64::NAN)); + } + Ok(out.into_pyarray(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 (p, m) = self.inner.params(); + format!("VoltyStop(atr_period={p}, multiplier={m})") + } +} + +// ============================== Yo-Yo Exit ============================== + +#[pyclass(name = "YoyoExit", module = "wickra._wickra", skip_from_py_object)] +#[derive(Clone)] +struct PyYoyoExit { + inner: wc::YoyoExit, +} + +#[pymethods] +impl PyYoyoExit { + #[new] + #[pyo3(signature = (atr_period=14, multiplier=2.0))] + fn new(atr_period: usize, multiplier: f64) -> PyResult { + Ok(Self { + inner: wc::YoyoExit::new(atr_period, multiplier).map_err(map_err)?, + }) + } + fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult> { + let c = extract_candle(candle)?; + Ok(self.inner.update(c)) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let h = high + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let l = low + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let c = close + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + if h.len() != l.len() || l.len() != c.len() { + return Err(PyValueError::new_err( + "high, low, close must be equal length", + )); + } + let mut out = Vec::with_capacity(h.len()); + for i in 0..h.len() { + let candle = wc::Candle::new(c[i], h[i], l[i], c[i], 0.0, 0).map_err(map_err)?; + out.push(self.inner.update(candle).unwrap_or(f64::NAN)); + } + Ok(out.into_pyarray(py)) + } + #[getter] + fn params(&self) -> (usize, f64) { + self.inner.params() + } + #[getter] + fn in_trade(&self) -> bool { + self.inner.in_trade() + } + 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 (p, m) = self.inner.params(); + format!("YoyoExit(atr_period={p}, multiplier={m})") + } +} + +// ============================== Donchian Stop ============================== + +#[pyclass(name = "DonchianStop", module = "wickra._wickra", skip_from_py_object)] +#[derive(Clone)] +struct PyDonchianStop { + inner: wc::DonchianStop, +} + +#[pymethods] +impl PyDonchianStop { + #[new] + #[pyo3(signature = (period=10))] + fn new(period: usize) -> PyResult { + Ok(Self { + inner: wc::DonchianStop::new(period).map_err(map_err)?, + }) + } + fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult> { + let c = extract_candle(candle)?; + Ok(self.inner.update(c).map(|o| (o.stop_long, o.stop_short))) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let h = high + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let l = low + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + if h.len() != l.len() { + return Err(PyValueError::new_err("high and low 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(l[i], h[i], l[i], l[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(py)) + } + #[getter] + fn period(&self) -> usize { + self.inner.period() + } + 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 { + format!("DonchianStop(period={})", self.inner.period()) + } +} + +// ============================== Percentage Trailing Stop ============================== + +#[pyclass( + name = "PercentageTrailingStop", + module = "wickra._wickra", + skip_from_py_object +)] +#[derive(Clone)] +struct PyPercentageTrailingStop { + inner: wc::PercentageTrailingStop, +} + +#[pymethods] +impl PyPercentageTrailingStop { + #[new] + #[pyo3(signature = (percent=5.0))] + fn new(percent: f64) -> PyResult { + Ok(Self { + inner: wc::PercentageTrailingStop::new(percent).map_err(map_err)?, + }) + } + fn update(&mut self, value: f64) -> Option { + self.inner.update(value) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + prices: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let slice = prices + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + Ok(flatten(self.inner.batch(slice)).into_pyarray(py)) + } + #[getter] + fn percent(&self) -> f64 { + self.inner.percent() + } + 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 { + format!("PercentageTrailingStop(percent={})", self.inner.percent()) + } +} + +// ============================== Step Trailing Stop ============================== + +#[pyclass( + name = "StepTrailingStop", + module = "wickra._wickra", + skip_from_py_object +)] +#[derive(Clone)] +struct PyStepTrailingStop { + inner: wc::StepTrailingStop, +} + +#[pymethods] +impl PyStepTrailingStop { + #[new] + #[pyo3(signature = (step_size=1.0))] + fn new(step_size: f64) -> PyResult { + Ok(Self { + inner: wc::StepTrailingStop::new(step_size).map_err(map_err)?, + }) + } + fn update(&mut self, value: f64) -> Option { + self.inner.update(value) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + prices: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let slice = prices + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + Ok(flatten(self.inner.batch(slice)).into_pyarray(py)) + } + #[getter] + fn step_size(&self) -> f64 { + self.inner.step_size() + } + 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 { + format!("StepTrailingStop(step_size={})", self.inner.step_size()) + } +} + +// ============================== Renko Trailing Stop ============================== + +#[pyclass( + name = "RenkoTrailingStop", + module = "wickra._wickra", + skip_from_py_object +)] +#[derive(Clone)] +struct PyRenkoTrailingStop { + inner: wc::RenkoTrailingStop, +} + +#[pymethods] +impl PyRenkoTrailingStop { + #[new] + #[pyo3(signature = (block_size=1.0))] + fn new(block_size: f64) -> PyResult { + Ok(Self { + inner: wc::RenkoTrailingStop::new(block_size).map_err(map_err)?, + }) + } + fn update(&mut self, value: f64) -> Option { + self.inner.update(value) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + prices: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let slice = prices + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + Ok(flatten(self.inner.batch(slice)).into_pyarray(py)) + } + #[getter] + fn block_size(&self) -> f64 { + self.inner.block_size() + } + 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 { + format!("RenkoTrailingStop(block_size={})", self.inner.block_size()) + } +} + // ============================== Typical Price ============================== #[pyclass(name = "TypicalPrice", module = "wickra._wickra", skip_from_py_object)] @@ -7704,6 +8134,13 @@ fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; m.add_class::()?; m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; m.add_class::()?; m.add_class::()?; m.add_class::()?; diff --git a/bindings/python/tests/test_known_values.py b/bindings/python/tests/test_known_values.py index 17aa4a86..ff145a62 100644 --- a/bindings/python/tests/test_known_values.py +++ b/bindings/python/tests/test_known_values.py @@ -332,6 +332,78 @@ def test_obv_cumulative_known_sequence(): np.testing.assert_allclose(out, [0.0, 20.0, -10.0, -10.0, 0.0]) +def test_percentage_trailing_stop_seed_and_ratchet(): + # 10% trail: first close 100 -> stop 90; next 110 -> stop max(90, 99) = 99. + s = ta.PercentageTrailingStop(10.0) + assert math.isclose(s.update(100.0), 90.0, abs_tol=1e-12) + assert math.isclose(s.update(110.0), 99.0, abs_tol=1e-12) + + +def test_step_trailing_stop_snaps_below_close(): + # step 1: floor((100.4 - 1) / 1) = 99. + s = ta.StepTrailingStop(1.0) + assert math.isclose(s.update(100.4), 99.0, abs_tol=1e-12) + + +def test_renko_trailing_stop_holds_until_full_block(): + # block 1: seed 100 -> stop 99; 100.5 still 99; 101 -> stop 100. + s = ta.RenkoTrailingStop(1.0) + assert math.isclose(s.update(100.0), 99.0, abs_tol=1e-12) + assert math.isclose(s.update(100.5), 99.0, abs_tol=1e-12) + assert math.isclose(s.update(101.0), 100.0, abs_tol=1e-12) + + +def test_donchian_stop_window_extremes(): + # 5-bar window of highs 1..5 and lows 0..4. + high = np.array([1.0, 2.0, 3.0, 4.0, 5.0]) + low = np.array([0.0, 1.0, 2.0, 3.0, 4.0]) + out = ta.DonchianStop(5).batch(high, low) + # First 4 rows NaN, fifth row: stop_long = 0, stop_short = 5. + for i in range(4): + assert math.isnan(out[i, 0]) + assert math.isnan(out[i, 1]) + assert math.isclose(out[4, 0], 0.0, abs_tol=1e-12) + assert math.isclose(out[4, 1], 5.0, abs_tol=1e-12) + + +def test_hilo_activator_flat_market_holds_low_sma(): + # Flat candles H=11, L=9, C=10 -> close (10) sits between bands, so the + # initial long seed is preserved: emitted stop = lo_sma = 9. + h = np.full(15, 11.0) + l = np.full(15, 9.0) + c = np.full(15, 10.0) + out = ta.HiLoActivator(3).batch(h, l, c) + # warmup_period == period + 1 == 4, so indices 0..2 are NaN; index 3 onwards is 9. + for i in range(3): + assert math.isnan(out[i]) + for i in range(3, 15): + assert math.isclose(out[i], 9.0, abs_tol=1e-12) + + +def test_volty_stop_flat_market_constant_level(): + # ATR=2, mult=2 -> band 4; anchor stays at close 10 -> stop = 10 - 4 = 6. + h = np.full(20, 11.0) + l = np.full(20, 9.0) + c = np.full(20, 10.0) + out = ta.VoltyStop(5, 2.0).batch(h, l, c) + for i in range(4): + assert math.isnan(out[i]) + for i in range(4, 20): + assert math.isclose(out[i], 6.0, abs_tol=1e-12) + + +def test_yoyo_exit_flat_market_constant_level(): + # ATR=2, mult=2 -> band 4; trail = close - band = 10 - 4 = 6 and holds. + h = np.full(20, 11.0) + l = np.full(20, 9.0) + c = np.full(20, 10.0) + out = ta.YoyoExit(5, 2.0).batch(h, l, c) + for i in range(4): + assert math.isnan(out[i]) + for i in range(4, 20): + assert math.isclose(out[i], 6.0, abs_tol=1e-12) + + def test_rvi_volatility_pure_uptrend_saturates_at_one_hundred(): # Strictly rising closes -> every stddev sample classified as "up" -> # RVIVolatility saturates at 100. Renamed from the original ta.RVI in diff --git a/bindings/python/tests/test_new_indicators.py b/bindings/python/tests/test_new_indicators.py index 5098d62f..ea7a864c 100644 --- a/bindings/python/tests/test_new_indicators.py +++ b/bindings/python/tests/test_new_indicators.py @@ -73,6 +73,9 @@ SCALAR = [ (ta.VerticalHorizontalFilter, (28,)), (ta.ZScore, (20,)), (ta.LinRegAngle, (14,)), + (ta.PercentageTrailingStop, (5.0,)), + (ta.StepTrailingStop, (1.0,)), + (ta.RenkoTrailingStop, (1.0,)), (ta.LaguerreRSI, (0.5,)), (ta.ConnorsRSI, (3, 2, 100)), (ta.RVIVolatility, (10,)), @@ -199,6 +202,18 @@ CANDLE_SCALAR = { lambda: ta.AtrTrailingStop(14, 3.0), lambda ind, h, l, c, v: ind.batch(h, l, c), ), + "HiLoActivator": ( + lambda: ta.HiLoActivator(3), + lambda ind, h, l, c, v: ind.batch(h, l, c), + ), + "VoltyStop": ( + lambda: ta.VoltyStop(14, 2.0), + lambda ind, h, l, c, v: ind.batch(h, l, c), + ), + "YoyoExit": ( + lambda: ta.YoyoExit(14, 2.0), + lambda ind, h, l, c, v: ind.batch(h, l, c), + ), "TypicalPrice": ( lambda: ta.TypicalPrice(), lambda ind, h, l, c, v: ind.batch(h, l, c), @@ -307,6 +322,10 @@ MULTI = { lambda: ta.ChandeKrollStop(10, 1.0, 9), lambda ind, h, l, c, v: ind.batch(h, l, c), ), + "DonchianStop": ( + lambda: ta.DonchianStop(10), + lambda ind, h, l, c, v: ind.batch(h, l), + ), # Family 05 candle-input bands. Each entry is # `(factory, batch_call, output_arity, streaming_fields)` where # `streaming_fields` is the tuple shape returned by `update(...)`. diff --git a/bindings/wasm/src/lib.rs b/bindings/wasm/src/lib.rs index 0a2bc544..7a6456c0 100644 --- a/bindings/wasm/src/lib.rs +++ b/bindings/wasm/src/lib.rs @@ -1829,6 +1829,251 @@ impl WasmAtrTrailingStop { } } +// ---------- Trailing Stops (family 09) ---------- + +#[wasm_bindgen(js_name = HiLoActivator)] +pub struct WasmHiLoActivator { + inner: wc::HiLoActivator, +} + +#[wasm_bindgen(js_class = HiLoActivator)] +impl WasmHiLoActivator { + #[wasm_bindgen(constructor)] + pub fn new(period: usize) -> Result { + Ok(Self { + inner: wc::HiLoActivator::new(period).map_err(map_err)?, + }) + } + pub fn update(&mut self, high: f64, low: f64, close: f64) -> Result, JsError> { + let c = make_candle(high, low, close, 0.0)?; + Ok(self.inner.update(c)) + } + pub fn batch( + &mut self, + high: &[f64], + low: &[f64], + close: &[f64], + ) -> Result { + let n = high.len(); + if low.len() != n || close.len() != n { + return Err(JsError::new("high, low, close must be equal length")); + } + let mut out = Vec::with_capacity(n); + for i in 0..n { + let c = make_candle(high[i], low[i], close[i], 0.0)?; + out.push(self.inner.update(c).unwrap_or(f64::NAN)); + } + Ok(Float64Array::from(out.as_slice())) + } + pub fn reset(&mut self) { + self.inner.reset(); + } +} + +#[wasm_bindgen(js_name = VoltyStop)] +pub struct WasmVoltyStop { + inner: wc::VoltyStop, +} + +#[wasm_bindgen(js_class = VoltyStop)] +impl WasmVoltyStop { + #[wasm_bindgen(constructor)] + pub fn new(atr_period: usize, multiplier: f64) -> Result { + Ok(Self { + inner: wc::VoltyStop::new(atr_period, multiplier).map_err(map_err)?, + }) + } + pub fn update(&mut self, high: f64, low: f64, close: f64) -> Result, JsError> { + let c = make_candle(high, low, close, 0.0)?; + Ok(self.inner.update(c)) + } + pub fn batch( + &mut self, + high: &[f64], + low: &[f64], + close: &[f64], + ) -> Result { + let n = high.len(); + if low.len() != n || close.len() != n { + return Err(JsError::new("high, low, close must be equal length")); + } + let mut out = Vec::with_capacity(n); + for i in 0..n { + let c = make_candle(high[i], low[i], close[i], 0.0)?; + out.push(self.inner.update(c).unwrap_or(f64::NAN)); + } + Ok(Float64Array::from(out.as_slice())) + } + pub fn reset(&mut self) { + self.inner.reset(); + } +} + +#[wasm_bindgen(js_name = YoyoExit)] +pub struct WasmYoyoExit { + inner: wc::YoyoExit, +} + +#[wasm_bindgen(js_class = YoyoExit)] +impl WasmYoyoExit { + #[wasm_bindgen(constructor)] + pub fn new(atr_period: usize, multiplier: f64) -> Result { + Ok(Self { + inner: wc::YoyoExit::new(atr_period, multiplier).map_err(map_err)?, + }) + } + pub fn update(&mut self, high: f64, low: f64, close: f64) -> Result, JsError> { + let c = make_candle(high, low, close, 0.0)?; + Ok(self.inner.update(c)) + } + pub fn batch( + &mut self, + high: &[f64], + low: &[f64], + close: &[f64], + ) -> Result { + let n = high.len(); + if low.len() != n || close.len() != n { + return Err(JsError::new("high, low, close must be equal length")); + } + let mut out = Vec::with_capacity(n); + for i in 0..n { + let c = make_candle(high[i], low[i], close[i], 0.0)?; + out.push(self.inner.update(c).unwrap_or(f64::NAN)); + } + Ok(Float64Array::from(out.as_slice())) + } + pub fn reset(&mut self) { + self.inner.reset(); + } + #[wasm_bindgen(js_name = inTrade)] + pub fn in_trade(&self) -> bool { + self.inner.in_trade() + } +} + +#[wasm_bindgen(js_name = DonchianStop)] +pub struct WasmDonchianStop { + inner: wc::DonchianStop, +} + +#[wasm_bindgen(js_class = DonchianStop)] +impl WasmDonchianStop { + #[wasm_bindgen(constructor)] + pub fn new(period: usize) -> Result { + Ok(Self { + inner: wc::DonchianStop::new(period).map_err(map_err)?, + }) + } + /// Returns `{ stopLong, stopShort }` once warm, else `null`. + pub fn update(&mut self, high: f64, low: f64) -> Result { + let c = make_candle(high, low, low, 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]) -> Result { + let n = high.len(); + if low.len() != n { + return Err(JsError::new("high and low 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], low[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 = PercentageTrailingStop)] +pub struct WasmPercentageTrailingStop { + inner: wc::PercentageTrailingStop, +} + +#[wasm_bindgen(js_class = PercentageTrailingStop)] +impl WasmPercentageTrailingStop { + #[wasm_bindgen(constructor)] + pub fn new(percent: f64) -> Result { + Ok(Self { + inner: wc::PercentageTrailingStop::new(percent).map_err(map_err)?, + }) + } + pub fn update(&mut self, value: f64) -> Option { + self.inner.update(value) + } + pub fn batch(&mut self, prices: &[f64]) -> Float64Array { + let out = flatten(self.inner.batch(prices)); + Float64Array::from(out.as_slice()) + } + pub fn reset(&mut self) { + self.inner.reset(); + } +} + +#[wasm_bindgen(js_name = StepTrailingStop)] +pub struct WasmStepTrailingStop { + inner: wc::StepTrailingStop, +} + +#[wasm_bindgen(js_class = StepTrailingStop)] +impl WasmStepTrailingStop { + #[wasm_bindgen(constructor)] + pub fn new(step_size: f64) -> Result { + Ok(Self { + inner: wc::StepTrailingStop::new(step_size).map_err(map_err)?, + }) + } + pub fn update(&mut self, value: f64) -> Option { + self.inner.update(value) + } + pub fn batch(&mut self, prices: &[f64]) -> Float64Array { + let out = flatten(self.inner.batch(prices)); + Float64Array::from(out.as_slice()) + } + pub fn reset(&mut self) { + self.inner.reset(); + } +} + +#[wasm_bindgen(js_name = RenkoTrailingStop)] +pub struct WasmRenkoTrailingStop { + inner: wc::RenkoTrailingStop, +} + +#[wasm_bindgen(js_class = RenkoTrailingStop)] +impl WasmRenkoTrailingStop { + #[wasm_bindgen(constructor)] + pub fn new(block_size: f64) -> Result { + Ok(Self { + inner: wc::RenkoTrailingStop::new(block_size).map_err(map_err)?, + }) + } + pub fn update(&mut self, value: f64) -> Option { + self.inner.update(value) + } + pub fn batch(&mut self, prices: &[f64]) -> Float64Array { + let out = flatten(self.inner.batch(prices)); + Float64Array::from(out.as_slice()) + } + pub fn reset(&mut self) { + self.inner.reset(); + } +} + #[wasm_bindgen(js_name = TypicalPrice)] pub struct WasmTypicalPrice { inner: wc::TypicalPrice, diff --git a/crates/wickra-core/src/indicators/donchian_stop.rs b/crates/wickra-core/src/indicators/donchian_stop.rs new file mode 100644 index 00000000..4b99d0f1 --- /dev/null +++ b/crates/wickra-core/src/indicators/donchian_stop.rs @@ -0,0 +1,218 @@ +//! Donchian Channel Stop (Turtle). + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Donchian Channel Stop output: the long-side and short-side trailing stops. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct DonchianStopOutput { + /// Long-position stop: the lowest low over the lookback. + pub stop_long: f64, + /// Short-position stop: the highest high over the lookback. + pub stop_short: f64, +} + +/// Donchian Channel Stop — the original Turtle-trader exit rule. A long is +/// trailed at the lowest low of the last `period` bars; a short at the highest +/// high. There is no ATR, no multiplier, and no flip-bit — the two levels are +/// always emitted and the caller selects whichever side matches the position. +/// +/// ```text +/// stop_long = min(low, over period bars) +/// stop_short = max(high, over period bars) +/// ``` +/// +/// Richard Dennis' original Turtle System used a 20-bar entry channel and a +/// 10-bar exit channel — feed this indicator the exit window. The first +/// `period` candles are warmup; on the bar that fills the window it begins +/// emitting both stops. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, DonchianStop}; +/// +/// let mut indicator = DonchianStop::new(10).unwrap(); +/// let mut last = None; +/// for i in 0..40 { +/// 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 DonchianStop { + period: usize, + highs: VecDeque, + lows: VecDeque, +} + +impl DonchianStop { + /// Construct a Donchian Channel Stop with an explicit lookback. + /// + /// # Errors + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + period, + highs: VecDeque::with_capacity(period), + lows: VecDeque::with_capacity(period), + }) + } + + /// The Turtle-system exit window: a `10`-bar lookback. + pub fn classic() -> Self { + Self::new(10).expect("classic Donchian Stop period is valid") + } + + /// Configured lookback. + pub const fn period(&self) -> usize { + self.period + } +} + +impl Indicator for DonchianStop { + type Input = Candle; + type Output = DonchianStopOutput; + + fn update(&mut self, candle: Candle) -> Option { + 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; + } + let stop_short = self.highs.iter().copied().fold(f64::NEG_INFINITY, f64::max); + let stop_long = self.lows.iter().copied().fold(f64::INFINITY, f64::min); + Some(DonchianStopOutput { + stop_long, + stop_short, + }) + } + + fn reset(&mut self) { + 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 { + "DonchianStop" + } +} + +#[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(f64::midpoint(high, low), high, low, close, 1.0, ts).unwrap() + } + + #[test] + fn rejects_zero_period() { + assert!(DonchianStop::new(0).is_err()); + } + + #[test] + fn accessors_and_metadata() { + let s = DonchianStop::classic(); + assert_eq!(s.period(), 10); + assert_eq!(s.warmup_period(), 10); + assert_eq!(s.name(), "DonchianStop"); + } + + #[test] + fn first_emission_matches_warmup() { + let candles: Vec = (0..10) + .map(|i| { + let base = 100.0 + i as f64; + c(base + 1.0, base - 1.0, base, i) + }) + .collect(); + let mut s = DonchianStop::new(5).unwrap(); + let out = s.batch(&candles); + for (i, v) in out.iter().enumerate().take(4) { + assert!(v.is_none(), "index {i} must be None during warmup"); + } + assert!(out[4].is_some()); + } + + #[test] + fn reference_values_uptrend_window() { + // Highs 0..5 = 1..6; lowest low = 0, highest high = 5. + let candles: Vec = (0..5) + .map(|i| { + let base = i as f64 + 0.5; + c(base + 0.5, base - 0.5, base, i) + }) + .collect(); + let mut s = DonchianStop::new(5).unwrap(); + let out = s.batch(&candles); + let v = out[4].expect("ready at index 4"); + assert_relative_eq!(v.stop_short, 5.0, epsilon = 1e-12); + assert_relative_eq!(v.stop_long, 0.0, epsilon = 1e-12); + } + + #[test] + fn constant_series_holds_both_stops() { + let candles: Vec = (0..30).map(|i| c(11.0, 9.0, 10.0, i)).collect(); + let mut s = DonchianStop::new(5).unwrap(); + for v in s.batch(&candles).into_iter().flatten() { + assert_relative_eq!(v.stop_short, 11.0, epsilon = 1e-12); + assert_relative_eq!(v.stop_long, 9.0, epsilon = 1e-12); + } + } + + #[test] + fn reset_clears_state() { + let candles: Vec = (0..30) + .map(|i| { + let base = 100.0 + i as f64; + c(base + 1.0, base - 1.0, base, i) + }) + .collect(); + let mut s = DonchianStop::classic(); + s.batch(&candles); + assert!(s.is_ready()); + s.reset(); + assert!(!s.is_ready()); + assert_eq!(s.update(candles[0]), None); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..80) + .map(|i| { + let mid = 100.0 + (i as f64 * 0.3).sin() * 8.0; + c(mid + 1.5, mid - 1.5, mid + 0.5, i) + }) + .collect(); + let mut a = DonchianStop::classic(); + let mut b = DonchianStop::classic(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/hilo_activator.rs b/crates/wickra-core/src/indicators/hilo_activator.rs new file mode 100644 index 00000000..0939bddc --- /dev/null +++ b/crates/wickra-core/src/indicators/hilo_activator.rs @@ -0,0 +1,264 @@ +//! `HiLo` Activator (Crabel). + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// `HiLo` Activator — Robert Krausz's adaptation of Linda Bradford Raschke and +/// Larry Connors' "`HiLo`" rule, popularised by Toby Crabel. Two simple moving +/// averages — of the high and of the low — bracket price; the trailing stop +/// for a long sits at the SMA-of-low, and for a short at the SMA-of-high. +/// +/// ```text +/// hi_sma = SMA(high, period) // potential short stop +/// lo_sma = SMA(low, period) // potential long stop +/// +/// state-machine: +/// long while close > hi_sma_prev -> emit lo_sma_prev +/// short while close < lo_sma_prev -> emit hi_sma_prev +/// else: hold the previous side +/// ``` +/// +/// Comparing the close to the *previous* bar's SMA avoids look-ahead and gives +/// the indicator a one-bar lag — the classic Crabel formulation. A long signal +/// fires the bar after price closes above the high-SMA; the stop then trails +/// at the low-SMA. The first input that fills the SMA window seeds a long. +/// A common configuration is a `3`-period window. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, HiLoActivator}; +/// +/// let mut indicator = HiLoActivator::new(3).unwrap(); +/// let mut last = None; +/// for i in 0..40 { +/// let base = 100.0 + f64::from(i); +/// let candle = +/// Candle::new(base, base + 1.0, base - 1.0, base, 10.0, i64::from(i)).unwrap(); +/// last = indicator.update(candle); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct HiLoActivator { + period: usize, + highs: VecDeque, + lows: VecDeque, + sum_high: f64, + sum_low: f64, + /// Last bar's `(hi_sma, lo_sma)`, used so today's signal is based on + /// yesterday's SMAs (no look-ahead). + prev_smas: Option<(f64, f64)>, + /// `true` while the current trail is on the long side. + long: bool, + /// `true` once a signal has been emitted at least once. + started: bool, +} + +impl HiLoActivator { + /// Construct a `HiLo` Activator with an explicit SMA window. + /// + /// # Errors + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + period, + highs: VecDeque::with_capacity(period), + lows: VecDeque::with_capacity(period), + sum_high: 0.0, + sum_low: 0.0, + prev_smas: None, + long: true, + started: false, + }) + } + + /// Crabel's classic configuration: a `3`-bar window. + pub fn classic() -> Self { + Self::new(3).expect("classic period is valid") + } + + /// Configured SMA window. + pub const fn period(&self) -> usize { + self.period + } +} + +impl Indicator for HiLoActivator { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + if self.highs.len() == self.period { + self.sum_high -= self.highs.pop_front().expect("non-empty by check"); + self.sum_low -= self.lows.pop_front().expect("non-empty by check"); + } + self.highs.push_back(candle.high); + self.lows.push_back(candle.low); + self.sum_high += candle.high; + self.sum_low += candle.low; + + // Need today's SMA + yesterday's SMA to compare close vs the *previous* + // bar's bands — so the very first ready bar only computes today's SMA + // and stores it; emission begins on the next bar. + if self.highs.len() < self.period { + return None; + } + let p = self.period as f64; + let hi_sma = self.sum_high / p; + let lo_sma = self.sum_low / p; + + let out = if let Some((prev_hi, prev_lo)) = self.prev_smas { + if candle.close > prev_hi { + self.long = true; + } else if candle.close < prev_lo { + self.long = false; + } + self.started = true; + if self.long { + prev_lo + } else { + prev_hi + } + } else { + // First SMA-ready bar seeds yesterday's bands for the next call. + self.prev_smas = Some((hi_sma, lo_sma)); + return None; + }; + self.prev_smas = Some((hi_sma, lo_sma)); + Some(out) + } + + fn reset(&mut self) { + self.highs.clear(); + self.lows.clear(); + self.sum_high = 0.0; + self.sum_low = 0.0; + self.prev_smas = None; + self.long = true; + self.started = false; + } + + fn warmup_period(&self) -> usize { + self.period + 1 + } + + fn is_ready(&self) -> bool { + self.started + } + + fn name(&self) -> &'static str { + "HiLoActivator" + } +} + +#[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(f64::midpoint(high, low), high, low, close, 1.0, ts).unwrap() + } + + #[test] + fn rejects_zero_period() { + assert!(HiLoActivator::new(0).is_err()); + } + + #[test] + fn accessors_and_metadata() { + let s = HiLoActivator::classic(); + assert_eq!(s.period(), 3); + assert_eq!(s.warmup_period(), 4); + assert_eq!(s.name(), "HiLoActivator"); + } + + #[test] + fn warmup_emits_none_until_period_plus_one() { + let mut s = HiLoActivator::new(3).unwrap(); + // The first 3 candles fill the SMA; the 4th is the first emission. + let candles: Vec = (0..6) + .map(|i| { + let base = 100.0 + i as f64; + c(base + 1.0, base - 1.0, base, i) + }) + .collect(); + let out = s.batch(&candles); + assert!(out[0].is_none()); + assert!(out[1].is_none()); + assert!(out[2].is_none()); + assert!(out[3].is_some(), "first emission lands at index period"); + } + + #[test] + fn constant_series_stays_long_on_lo_sma() { + let mut s = HiLoActivator::new(3).unwrap(); + // Flat candles: H=11, L=9, C=10. Both SMAs are constant. + let candles: Vec = (0..10).map(|i| c(11.0, 9.0, 10.0, i)).collect(); + for v in s.batch(&candles).into_iter().flatten() { + // close (10) is not > 11 nor < 9, so the long seed persists -> lo_sma = 9. + assert_relative_eq!(v, 9.0, epsilon = 1e-12); + } + } + + #[test] + fn uptrend_keeps_emitting_low_sma_below_close() { + let mut s = HiLoActivator::new(3).unwrap(); + let candles: Vec = (0..30) + .map(|i| { + let base = 100.0 + i as f64; + c(base + 1.0, base - 1.0, base, i) + }) + .collect(); + let paired: Vec<(f64, f64)> = s + .batch(&candles) + .into_iter() + .zip(candles.iter()) + .filter_map(|(o, c)| o.map(|v| (v, c.close))) + .collect(); + assert!( + paired.iter().all(|(stop, close)| stop < close), + "uptrend stop should sit below the close" + ); + } + + #[test] + fn reset_clears_state() { + let mut s = HiLoActivator::new(3).unwrap(); + let candles: Vec = (0..20) + .map(|i| { + let base = 100.0 + i as f64; + c(base + 1.0, base - 1.0, base, i) + }) + .collect(); + s.batch(&candles); + assert!(s.is_ready()); + s.reset(); + assert!(!s.is_ready()); + assert_eq!(s.update(candles[0]), None); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..80) + .map(|i| { + let mid = 100.0 + (i as f64 * 0.3).sin() * 8.0; + c(mid + 1.5, mid - 1.5, mid + 0.5, i) + }) + .collect(); + let mut a = HiLoActivator::classic(); + let mut b = HiLoActivator::classic(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/mod.rs b/crates/wickra-core/src/indicators/mod.rs index 7eb83b0a..13c9fcb1 100644 --- a/crates/wickra-core/src/indicators/mod.rs +++ b/crates/wickra-core/src/indicators/mod.rs @@ -38,6 +38,7 @@ mod coppock; mod dema; mod demand_index; mod donchian; +mod donchian_stop; mod double_bollinger; mod dpo; mod ease_of_movement; @@ -48,6 +49,7 @@ mod force_index; mod fractal_chaos_bands; mod frama; mod garman_klass; +mod hilo_activator; mod historical_volatility; mod hma; mod hurst_channel; @@ -75,11 +77,13 @@ mod nvi; mod obv; mod parkinson; mod percent_b; +mod percentage_trailing_stop; mod pgo; mod pmo; mod ppo; mod psar; mod pvi; +mod renko_trailing_stop; mod roc; mod rogers_satchell; mod rsi; @@ -93,6 +97,7 @@ mod standard_error_bands; mod starc_bands; mod stc; mod std_dev; +mod step_trailing_stop; mod stoch_rsi; mod stochastic; mod super_trend; @@ -110,6 +115,7 @@ mod ulcer_index; mod ultimate_oscillator; mod vertical_horizontal_filter; mod vidya; +mod volty_stop; mod volume_oscillator; mod vortex; mod vpt; @@ -122,6 +128,7 @@ mod weighted_close; mod williams_r; mod wma; mod yang_zhang; +mod yoyo_exit; mod z_score; mod zero_lag_macd; mod zlema; @@ -160,6 +167,7 @@ pub use coppock::Coppock; pub use dema::Dema; pub use demand_index::DemandIndex; pub use donchian::{Donchian, DonchianOutput}; +pub use donchian_stop::{DonchianStop, DonchianStopOutput}; pub use double_bollinger::{DoubleBollinger, DoubleBollingerOutput}; pub use dpo::Dpo; pub use ease_of_movement::EaseOfMovement; @@ -170,6 +178,7 @@ pub use force_index::ForceIndex; pub use fractal_chaos_bands::{FractalChaosBands, FractalChaosBandsOutput}; pub use frama::Frama; pub use garman_klass::GarmanKlassVolatility; +pub use hilo_activator::HiLoActivator; pub use historical_volatility::HistoricalVolatility; pub use hma::Hma; pub use hurst_channel::{HurstChannel, HurstChannelOutput}; @@ -197,11 +206,13 @@ pub use nvi::Nvi; pub use obv::Obv; pub use parkinson::ParkinsonVolatility; pub use percent_b::PercentB; +pub use percentage_trailing_stop::PercentageTrailingStop; pub use pgo::Pgo; pub use pmo::Pmo; pub use ppo::Ppo; pub use psar::Psar; pub use pvi::Pvi; +pub use renko_trailing_stop::RenkoTrailingStop; pub use roc::Roc; pub use rogers_satchell::RogersSatchellVolatility; pub use rsi::Rsi; @@ -215,6 +226,7 @@ pub use standard_error_bands::{StandardErrorBands, StandardErrorBandsOutput}; pub use starc_bands::{StarcBands, StarcBandsOutput}; pub use stc::Stc; pub use std_dev::StdDev; +pub use step_trailing_stop::StepTrailingStop; pub use stoch_rsi::StochRsi; pub use stochastic::{Stochastic, StochasticOutput}; pub use super_trend::{SuperTrend, SuperTrendOutput}; @@ -232,6 +244,7 @@ pub use ulcer_index::UlcerIndex; pub use ultimate_oscillator::UltimateOscillator; pub use vertical_horizontal_filter::VerticalHorizontalFilter; pub use vidya::Vidya; +pub use volty_stop::VoltyStop; pub use volume_oscillator::VolumeOscillator; pub use vortex::{Vortex, VortexOutput}; pub use vpt::VolumePriceTrend; @@ -244,6 +257,7 @@ pub use weighted_close::WeightedClose; pub use williams_r::WilliamsR; pub use wma::Wma; pub use yang_zhang::YangZhangVolatility; +pub use yoyo_exit::YoyoExit; pub use z_score::ZScore; pub use zero_lag_macd::{ZeroLagMacd, ZeroLagMacdOutput}; pub use zlema::Zlema; diff --git a/crates/wickra-core/src/indicators/percentage_trailing_stop.rs b/crates/wickra-core/src/indicators/percentage_trailing_stop.rs new file mode 100644 index 00000000..a7520c9d --- /dev/null +++ b/crates/wickra-core/src/indicators/percentage_trailing_stop.rs @@ -0,0 +1,221 @@ +//! Percentage Trailing Stop. + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Percentage Trailing Stop — a fixed-percentage stop that ratchets with the +/// trend and flips to the opposite side on a close-through. +/// +/// ```text +/// step = price · percent / 100 +/// +/// long: stop_t = max(stop_{t−1}, close − step) while close ≥ stop_{t−1} +/// short: stop_t = min(stop_{t−1}, close + step) while close ≤ stop_{t−1} +/// flip-to-long on a close > prev short-stop -> stop = close − step +/// flip-to-short on a close < prev long-stop -> stop = close + step +/// ``` +/// +/// The first input seeds a long stop `percent` below price. Unlike the ATR +/// trailing stop the band is purely proportional to the latest price, so it +/// scales naturally across instruments without re-tuning. A common +/// configuration is `5.0` (a 5% trail). +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, PercentageTrailingStop}; +/// +/// let mut indicator = PercentageTrailingStop::new(5.0).unwrap(); +/// let mut last = None; +/// for i in 0..20 { +/// last = indicator.update(100.0 + f64::from(i)); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct PercentageTrailingStop { + percent: f64, + prev_stop: Option, + /// `true` while a long trail is active; flipped on a close-through. + long: bool, +} + +impl PercentageTrailingStop { + /// Construct a Percentage Trailing Stop with an explicit trail size + /// (e.g. `5.0` for a 5% trail). + /// + /// # Errors + /// Returns [`Error::NonPositiveMultiplier`] if `percent` is not strictly + /// positive and finite. + pub fn new(percent: f64) -> Result { + if !percent.is_finite() || percent <= 0.0 { + return Err(Error::NonPositiveMultiplier); + } + Ok(Self { + percent, + prev_stop: None, + long: true, + }) + } + + /// A common configuration: a `5.0%` trail. + pub fn classic() -> Self { + Self::new(5.0).expect("classic percent is valid") + } + + /// Configured trail percent. + pub const fn percent(&self) -> f64 { + self.percent + } +} + +impl Indicator for PercentageTrailingStop { + type Input = f64; + type Output = f64; + + fn update(&mut self, close: f64) -> Option { + let step = close.abs() * self.percent / 100.0; + let stop = match self.prev_stop { + Some(prev) => { + if self.long { + if close < prev { + // Close-through long stop — flip to short. + self.long = false; + close + step + } else { + prev.max(close - step) + } + } else if close > prev { + // Close-through short stop — flip to long. + self.long = true; + close - step + } else { + prev.min(close + step) + } + } + None => close - step, + }; + self.prev_stop = Some(stop); + Some(stop) + } + + fn reset(&mut self) { + self.prev_stop = None; + self.long = true; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.prev_stop.is_some() + } + + fn name(&self) -> &'static str { + "PercentageTrailingStop" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_invalid_percent() { + assert!(PercentageTrailingStop::new(0.0).is_err()); + assert!(PercentageTrailingStop::new(-1.0).is_err()); + assert!(PercentageTrailingStop::new(f64::NAN).is_err()); + assert!(PercentageTrailingStop::new(f64::INFINITY).is_err()); + } + + #[test] + fn accessors_and_metadata() { + let s = PercentageTrailingStop::classic(); + assert_relative_eq!(s.percent(), 5.0, epsilon = 1e-12); + assert_eq!(s.name(), "PercentageTrailingStop"); + assert_eq!(s.warmup_period(), 1); + } + + #[test] + fn first_value_is_step_below_price() { + let mut s = PercentageTrailingStop::new(10.0).unwrap(); + // 100 · 0.10 = 10, so seed stop = 90. + assert_relative_eq!(s.update(100.0).unwrap(), 90.0, epsilon = 1e-12); + assert!(s.is_ready()); + } + + #[test] + fn long_stop_ratchets_up_with_price() { + let mut s = PercentageTrailingStop::new(10.0).unwrap(); + // Rising series: each new high pulls the stop up. + let prices = [100.0, 110.0, 120.0, 130.0]; + let out: Vec = prices.iter().map(|&p| s.update(p).unwrap()).collect(); + assert_relative_eq!(out[0], 90.0, epsilon = 1e-9); + assert_relative_eq!(out[1], 99.0, epsilon = 1e-9); + assert_relative_eq!(out[2], 108.0, epsilon = 1e-9); + assert_relative_eq!(out[3], 117.0, epsilon = 1e-9); + } + + #[test] + fn flips_to_short_on_close_through() { + let mut s = PercentageTrailingStop::new(10.0).unwrap(); + // Seed long at 100 -> stop 90. + s.update(100.0); + // Climb to 130 -> stop ratchets to 117. + s.update(130.0); + // Crash to 50 -> closes through 117 -> flip to short stop at 55. + let flipped = s.update(50.0).unwrap(); + assert_relative_eq!(flipped, 55.0, epsilon = 1e-9); + } + + #[test] + fn short_stop_ratchets_down_and_flips_back() { + let mut s = PercentageTrailingStop::new(10.0).unwrap(); + s.update(100.0); // long stop 90 + s.update(50.0); // flip short, stop 55 + let v = s.update(40.0).unwrap(); + // Short side ratchets down: min(55, 44) = 44. + assert_relative_eq!(v, 44.0, epsilon = 1e-9); + // Rally back through 44 -> flip long, stop = 100 · 0.9 = 90. + let v = s.update(100.0).unwrap(); + assert_relative_eq!(v, 90.0, epsilon = 1e-9); + } + + #[test] + fn constant_series_holds_stop() { + let mut s = PercentageTrailingStop::new(5.0).unwrap(); + let out = s.batch(&[100.0; 30]); + for v in out.into_iter().flatten() { + assert_relative_eq!(v, 95.0, epsilon = 1e-12); + } + } + + #[test] + fn reset_clears_state() { + let mut s = PercentageTrailingStop::new(5.0).unwrap(); + s.update(100.0); + s.update(50.0); // flip short + assert!(s.is_ready()); + s.reset(); + assert!(!s.is_ready()); + // After reset the next update seeds a fresh long stop. + let v = s.update(200.0).unwrap(); + assert_relative_eq!(v, 190.0, epsilon = 1e-12); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (0..80) + .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 8.0) + .collect(); + let mut a = PercentageTrailingStop::classic(); + let mut b = PercentageTrailingStop::classic(); + assert_eq!( + a.batch(&prices), + prices.iter().map(|x| b.update(*x)).collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/renko_trailing_stop.rs b/crates/wickra-core/src/indicators/renko_trailing_stop.rs new file mode 100644 index 00000000..fab0c322 --- /dev/null +++ b/crates/wickra-core/src/indicators/renko_trailing_stop.rs @@ -0,0 +1,233 @@ +//! Renko Trailing Stop. + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Renko Trailing Stop — a trailing stop that follows a Renko-style brick +/// anchor: the stop only moves when price has advanced (or fallen) by at least +/// one full `block_size`, and then jumps the same fixed distance. +/// +/// ```text +/// long: advance = floor((close − anchor) / block_size) +/// if advance ≥ 1 -> anchor += advance · block_size +/// stop = anchor − block_size +/// flip-to-short on close < stop -> anchor = close, stop = anchor + block_size +/// short: advance = floor((anchor − close) / block_size) +/// if advance ≥ 1 -> anchor −= advance · block_size +/// stop = anchor + block_size +/// flip-to-long on close > stop -> anchor = close, stop = anchor − block_size +/// ``` +/// +/// Like a Renko chart the stop ignores intra-block noise — it sits one full +/// block behind the last "printed" brick and only ratchets in whole-block +/// increments. The first input seeds a long anchor at the close. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, RenkoTrailingStop}; +/// +/// let mut indicator = RenkoTrailingStop::new(1.0).unwrap(); +/// let mut last = None; +/// for i in 0..20 { +/// last = indicator.update(100.0 + f64::from(i)); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct RenkoTrailingStop { + block_size: f64, + anchor: Option, + long: bool, +} + +impl RenkoTrailingStop { + /// Construct a Renko Trailing Stop with an explicit block size. + /// + /// # Errors + /// Returns [`Error::NonPositiveMultiplier`] if `block_size` is not strictly + /// positive and finite. + pub fn new(block_size: f64) -> Result { + if !block_size.is_finite() || block_size <= 0.0 { + return Err(Error::NonPositiveMultiplier); + } + Ok(Self { + block_size, + anchor: None, + long: true, + }) + } + + /// A common configuration: a `1.0` block size. + pub fn classic() -> Self { + Self::new(1.0).expect("classic block size is valid") + } + + /// Configured block size. + pub const fn block_size(&self) -> f64 { + self.block_size + } +} + +impl Indicator for RenkoTrailingStop { + type Input = f64; + type Output = f64; + + fn update(&mut self, close: f64) -> Option { + let anchor = match self.anchor { + Some(prev) => { + if self.long { + let stop = prev - self.block_size; + if close < stop { + // Close-through long stop -> flip to short. + self.long = false; + close + } else { + let blocks = ((close - prev) / self.block_size).floor(); + if blocks >= 1.0 { + prev + blocks * self.block_size + } else { + prev + } + } + } else { + let stop = prev + self.block_size; + if close > stop { + self.long = true; + close + } else { + let blocks = ((prev - close) / self.block_size).floor(); + if blocks >= 1.0 { + prev - blocks * self.block_size + } else { + prev + } + } + } + } + None => close, + }; + self.anchor = Some(anchor); + let stop = if self.long { + anchor - self.block_size + } else { + anchor + self.block_size + }; + Some(stop) + } + + fn reset(&mut self) { + self.anchor = None; + self.long = true; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.anchor.is_some() + } + + fn name(&self) -> &'static str { + "RenkoTrailingStop" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_invalid_block() { + assert!(RenkoTrailingStop::new(0.0).is_err()); + assert!(RenkoTrailingStop::new(-1.0).is_err()); + assert!(RenkoTrailingStop::new(f64::NAN).is_err()); + assert!(RenkoTrailingStop::new(f64::INFINITY).is_err()); + } + + #[test] + fn accessors_and_metadata() { + let s = RenkoTrailingStop::classic(); + assert_relative_eq!(s.block_size(), 1.0, epsilon = 1e-12); + assert_eq!(s.name(), "RenkoTrailingStop"); + assert_eq!(s.warmup_period(), 1); + } + + #[test] + fn first_value_is_block_below_close() { + let mut s = RenkoTrailingStop::new(1.0).unwrap(); + // Seed anchor = 100, long stop = 99. + assert_relative_eq!(s.update(100.0).unwrap(), 99.0, epsilon = 1e-12); + } + + #[test] + fn stop_only_moves_after_full_block_advance() { + let mut s = RenkoTrailingStop::new(1.0).unwrap(); + s.update(100.0); + // Intra-block move -> stop unchanged at 99. + assert_relative_eq!(s.update(100.5).unwrap(), 99.0, epsilon = 1e-12); + // Full block: 101 -> anchor 101, stop 100. + assert_relative_eq!(s.update(101.0).unwrap(), 100.0, epsilon = 1e-12); + // Two blocks at once: 103.5 -> anchor 103, stop 102. + assert_relative_eq!(s.update(103.5).unwrap(), 102.0, epsilon = 1e-12); + } + + #[test] + fn flips_to_short_on_close_through_stop() { + let mut s = RenkoTrailingStop::new(1.0).unwrap(); + s.update(100.0); + s.update(105.0); // anchor 105, stop 104 + // Drop to 50 closes through 104 -> flip short, anchor=50, stop=51. + let flipped = s.update(50.0).unwrap(); + assert_relative_eq!(flipped, 51.0, epsilon = 1e-12); + } + + #[test] + fn short_anchor_ratchets_down_and_flips_back() { + let mut s = RenkoTrailingStop::new(1.0).unwrap(); + s.update(100.0); + s.update(50.0); // short, anchor 50, stop 51 + let v = s.update(48.0).unwrap(); + // Two blocks down: anchor 48, stop 49. + assert_relative_eq!(v, 49.0, epsilon = 1e-12); + // Rally to 60 closes through 49 -> flip long, anchor=60, stop=59. + let back = s.update(60.0).unwrap(); + assert_relative_eq!(back, 59.0, epsilon = 1e-12); + } + + #[test] + fn constant_series_holds_stop() { + let mut s = RenkoTrailingStop::new(1.0).unwrap(); + let out = s.batch(&[100.0; 30]); + for v in out.into_iter().flatten() { + assert_relative_eq!(v, 99.0, epsilon = 1e-12); + } + } + + #[test] + fn reset_clears_state() { + let mut s = RenkoTrailingStop::new(1.0).unwrap(); + s.update(100.0); + s.update(50.0); // flip short + assert!(s.is_ready()); + s.reset(); + assert!(!s.is_ready()); + assert_relative_eq!(s.update(200.0).unwrap(), 199.0, epsilon = 1e-12); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (0..80) + .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 8.0) + .collect(); + let mut a = RenkoTrailingStop::classic(); + let mut b = RenkoTrailingStop::classic(); + assert_eq!( + a.batch(&prices), + prices.iter().map(|x| b.update(*x)).collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/step_trailing_stop.rs b/crates/wickra-core/src/indicators/step_trailing_stop.rs new file mode 100644 index 00000000..4e5410f2 --- /dev/null +++ b/crates/wickra-core/src/indicators/step_trailing_stop.rs @@ -0,0 +1,225 @@ +//! Step Trailing Stop. + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Step Trailing Stop — a stop that ratchets in fixed-size discrete steps and +/// flips to the opposite side on a close-through. +/// +/// ```text +/// long: target = close − step_size +/// stop_t = max(stop_{t−1}, floor(target / step_size) · step_size) +/// while close ≥ stop_{t−1} +/// short: target = close + step_size +/// stop_t = min(stop_{t−1}, ceil(target / step_size) · step_size) +/// while close ≤ stop_{t−1} +/// flip-to-long on close > prev short-stop -> stop = floor((close − step) / step) · step +/// flip-to-short on close < prev long-stop -> stop = ceil((close + step) / step) · step +/// ``` +/// +/// Quantising the stop to a multiple of `step_size` keeps the level on a +/// round-number grid, which mirrors how many discretionary traders move stops +/// by hand (in $0.50, $1, or 10-pip increments). The first input seeds a long +/// stop one step below the snapped close. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, StepTrailingStop}; +/// +/// let mut indicator = StepTrailingStop::new(1.0).unwrap(); +/// let mut last = None; +/// for i in 0..20 { +/// last = indicator.update(100.0 + f64::from(i)); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct StepTrailingStop { + step_size: f64, + prev_stop: Option, + long: bool, +} + +impl StepTrailingStop { + /// Construct a Step Trailing Stop with an explicit step size. + /// + /// # Errors + /// Returns [`Error::NonPositiveMultiplier`] if `step_size` is not strictly + /// positive and finite. + pub fn new(step_size: f64) -> Result { + if !step_size.is_finite() || step_size <= 0.0 { + return Err(Error::NonPositiveMultiplier); + } + Ok(Self { + step_size, + prev_stop: None, + long: true, + }) + } + + /// A common configuration: a `1.0` step size. + pub fn classic() -> Self { + Self::new(1.0).expect("classic step is valid") + } + + /// Configured step size. + pub const fn step_size(&self) -> f64 { + self.step_size + } + + /// Snap `value` down to the nearest `step_size`-grid line below it. + fn snap_long(&self, close: f64) -> f64 { + ((close - self.step_size) / self.step_size).floor() * self.step_size + } + + /// Snap `value` up to the nearest `step_size`-grid line above it. + fn snap_short(&self, close: f64) -> f64 { + ((close + self.step_size) / self.step_size).ceil() * self.step_size + } +} + +impl Indicator for StepTrailingStop { + type Input = f64; + type Output = f64; + + fn update(&mut self, close: f64) -> Option { + let stop = match self.prev_stop { + Some(prev) => { + if self.long { + if close < prev { + self.long = false; + self.snap_short(close) + } else { + prev.max(self.snap_long(close)) + } + } else if close > prev { + self.long = true; + self.snap_long(close) + } else { + prev.min(self.snap_short(close)) + } + } + None => self.snap_long(close), + }; + self.prev_stop = Some(stop); + Some(stop) + } + + fn reset(&mut self) { + self.prev_stop = None; + self.long = true; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.prev_stop.is_some() + } + + fn name(&self) -> &'static str { + "StepTrailingStop" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_invalid_step() { + assert!(StepTrailingStop::new(0.0).is_err()); + assert!(StepTrailingStop::new(-1.0).is_err()); + assert!(StepTrailingStop::new(f64::NAN).is_err()); + } + + #[test] + fn accessors_and_metadata() { + let s = StepTrailingStop::classic(); + assert_relative_eq!(s.step_size(), 1.0, epsilon = 1e-12); + assert_eq!(s.name(), "StepTrailingStop"); + assert_eq!(s.warmup_period(), 1); + } + + #[test] + fn first_value_snaps_below_price() { + let mut s = StepTrailingStop::new(1.0).unwrap(); + // floor((100.4 - 1) / 1) · 1 = 99. + assert_relative_eq!(s.update(100.4).unwrap(), 99.0, epsilon = 1e-12); + } + + #[test] + fn long_stop_ratchets_in_discrete_steps() { + let mut s = StepTrailingStop::new(1.0).unwrap(); + let out: Vec = [100.0, 100.5, 101.0, 102.0, 103.5] + .iter() + .map(|&p| s.update(p).unwrap()) + .collect(); + // 100 -> 99, 100.5 -> 99 (no advance), 101 -> 100, 102 -> 101, 103.5 -> 102. + assert_relative_eq!(out[0], 99.0, epsilon = 1e-9); + assert_relative_eq!(out[1], 99.0, epsilon = 1e-9); + assert_relative_eq!(out[2], 100.0, epsilon = 1e-9); + assert_relative_eq!(out[3], 101.0, epsilon = 1e-9); + assert_relative_eq!(out[4], 102.0, epsilon = 1e-9); + } + + #[test] + fn flips_to_short_on_close_through_and_back() { + let mut s = StepTrailingStop::new(1.0).unwrap(); + s.update(100.0); // 99 + s.update(105.0); // 104 + let flipped = s.update(50.0).unwrap(); + // ceil((50+1)/1)·1 = 51. + assert_relative_eq!(flipped, 51.0, epsilon = 1e-9); + // Rally back through 51 -> flip long at 99. + let back = s.update(100.0).unwrap(); + assert_relative_eq!(back, 99.0, epsilon = 1e-9); + } + + #[test] + fn short_stop_ratchets_down() { + let mut s = StepTrailingStop::new(1.0).unwrap(); + s.update(100.0); + s.update(50.0); // short at 51 + let v = s.update(40.0).unwrap(); + // ceil((40+1)/1)·1 = 41 -> min(51, 41) = 41. + assert_relative_eq!(v, 41.0, epsilon = 1e-9); + } + + #[test] + fn constant_series_holds_stop() { + let mut s = StepTrailingStop::new(1.0).unwrap(); + let out = s.batch(&[100.0; 30]); + for v in out.into_iter().flatten() { + assert_relative_eq!(v, 99.0, epsilon = 1e-12); + } + } + + #[test] + fn reset_clears_state() { + let mut s = StepTrailingStop::new(1.0).unwrap(); + s.update(100.0); + s.update(50.0); + assert!(s.is_ready()); + s.reset(); + assert!(!s.is_ready()); + assert_relative_eq!(s.update(200.0).unwrap(), 199.0, epsilon = 1e-12); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (0..80) + .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 8.0) + .collect(); + let mut a = StepTrailingStop::classic(); + let mut b = StepTrailingStop::classic(); + assert_eq!( + a.batch(&prices), + prices.iter().map(|x| b.update(*x)).collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/volty_stop.rs b/crates/wickra-core/src/indicators/volty_stop.rs new file mode 100644 index 00000000..a189b769 --- /dev/null +++ b/crates/wickra-core/src/indicators/volty_stop.rs @@ -0,0 +1,274 @@ +//! Volty Stop (Volatility Stop, Kase). + +use crate::error::{Error, Result}; +use crate::indicators::atr::Atr; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Volty Stop — Cynthia Kase's volatility-anchored trailing stop. The stop is +/// hung off the *extreme close* recorded since the current trade was opened, +/// not off the most recent bar, which keeps it tight without giving back gains +/// when price pulls back inside the trend. +/// +/// ```text +/// long: anchor = max_close_since_long +/// stop_t = anchor − multiplier · ATR +/// flip-to-short on close < stop_t -> anchor = close, stop = close + mult · ATR +/// short: anchor = min_close_since_short +/// stop_t = anchor + multiplier · ATR +/// flip-to-long on close > stop_t -> anchor = close, stop = close − mult · ATR +/// ``` +/// +/// The anchor only ratchets in the trade's favour, so the stop tightens as +/// price reaches new extremes. Compared to the +/// [`AtrTrailingStop`](crate::AtrTrailingStop) — which re-anchors on every +/// bar's close — Volty Stop's extreme-anchor design gives back less on +/// pullbacks while keeping the same ATR-based volatility scaling. A common +/// configuration is `ATR(14)` with a `2.0` multiplier. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, VoltyStop}; +/// +/// let mut indicator = VoltyStop::new(14, 2.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 VoltyStop { + atr: Atr, + atr_period: usize, + multiplier: f64, + anchor: Option, + long: bool, +} + +impl VoltyStop { + /// Construct a Volty Stop with an explicit ATR period and band multiplier. + /// + /// # Errors + /// Returns [`Error::PeriodZero`] if `atr_period == 0` and + /// [`Error::NonPositiveMultiplier`] if `multiplier` is not strictly + /// positive and finite. + pub fn new(atr_period: usize, multiplier: f64) -> Result { + if !multiplier.is_finite() || multiplier <= 0.0 { + return Err(Error::NonPositiveMultiplier); + } + Ok(Self { + atr: Atr::new(atr_period)?, + atr_period, + multiplier, + anchor: None, + long: true, + }) + } + + /// A common configuration: `ATR(14)` with a `2.0` multiplier. + pub fn classic() -> Self { + Self::new(14, 2.0).expect("classic Volty Stop params are valid") + } + + /// Configured `(atr_period, multiplier)`. + pub const fn params(&self) -> (usize, f64) { + (self.atr_period, self.multiplier) + } +} + +impl Indicator for VoltyStop { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + let atr = self.atr.update(candle)?; + let band = self.multiplier * atr; + let close = candle.close; + + let (anchor, long) = match (self.anchor, self.long) { + (Some(prev_anchor), true) => { + let stop = prev_anchor - band; + if close < stop { + // Close-through long stop -> flip short, anchor at close. + (close, false) + } else { + // Ratchet the anchor up to today's close if higher. + (prev_anchor.max(close), true) + } + } + (Some(prev_anchor), false) => { + let stop = prev_anchor + band; + if close > stop { + (close, true) + } else { + (prev_anchor.min(close), false) + } + } + // First ATR-ready bar seeds a long anchor at the close. + (None, _) => (close, true), + }; + self.anchor = Some(anchor); + self.long = long; + let stop = if long { anchor - band } else { anchor + band }; + Some(stop) + } + + fn reset(&mut self) { + self.atr.reset(); + self.anchor = None; + self.long = true; + } + + fn warmup_period(&self) -> usize { + self.atr_period + } + + fn is_ready(&self) -> bool { + self.anchor.is_some() + } + + fn name(&self) -> &'static str { + "VoltyStop" + } +} + +#[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(f64::midpoint(high, low), high, low, close, 1.0, ts).unwrap() + } + + #[test] + fn rejects_invalid_params() { + assert!(VoltyStop::new(0, 2.0).is_err()); + assert!(VoltyStop::new(14, 0.0).is_err()); + assert!(VoltyStop::new(14, -1.0).is_err()); + assert!(VoltyStop::new(14, f64::NAN).is_err()); + } + + #[test] + fn accessors_and_metadata() { + let s = VoltyStop::classic(); + let (p, m) = s.params(); + assert_eq!(p, 14); + assert_relative_eq!(m, 2.0, epsilon = 1e-12); + assert_eq!(s.warmup_period(), 14); + assert_eq!(s.name(), "VoltyStop"); + } + + #[test] + fn first_emission_matches_warmup() { + let candles: Vec = (0..20) + .map(|i| { + let base = 100.0 + i as f64; + c(base + 1.0, base - 1.0, base, i) + }) + .collect(); + let mut s = VoltyStop::new(8, 2.0).unwrap(); + let out = s.batch(&candles); + 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()); + } + + #[test] + fn reference_values_flat_market() { + // H=11, L=9, C=10 -> TR=2 -> ATR=2; band = 2·2 = 4; anchor stays at 10; stop = 10-4 = 6. + let candles: Vec = (0..20).map(|i| c(11.0, 9.0, 10.0, i)).collect(); + let mut s = VoltyStop::new(5, 2.0).unwrap(); + for v in s.batch(&candles).into_iter().flatten() { + assert_relative_eq!(v, 6.0, epsilon = 1e-12); + } + } + + #[test] + fn uptrend_anchor_ratchets_up_with_close() { + let candles: Vec = (0..40) + .map(|i| { + let base = 100.0 + i as f64; + c(base + 1.0, base - 1.0, base, i) + }) + .collect(); + let mut s = VoltyStop::new(14, 3.0).unwrap(); + let emitted: Vec<(f64, f64)> = s + .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_on_reversal() { + let mut candles: Vec = (0..40) + .map(|i| { + let base = 100.0 + i as f64; + c(base + 1.0, base - 1.0, base, i) + }) + .collect(); + 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 s = VoltyStop::new(14, 3.0).unwrap(); + let paired: Vec<(f64, f64)> = s + .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)); + assert!(paired.iter().any(|&(stop, close)| stop > close)); + } + + #[test] + fn reset_clears_state() { + let candles: Vec = (0..40) + .map(|i| { + let base = 100.0 + i as f64; + c(base + 1.0, base - 1.0, base, i) + }) + .collect(); + let mut s = VoltyStop::classic(); + s.batch(&candles); + assert!(s.is_ready()); + s.reset(); + assert!(!s.is_ready()); + assert_eq!(s.update(candles[0]), None); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..80) + .map(|i| { + let mid = 100.0 + (i as f64 * 0.3).sin() * 8.0; + c(mid + 1.5, mid - 1.5, mid + 0.5, i) + }) + .collect(); + let mut a = VoltyStop::classic(); + let mut b = VoltyStop::classic(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/yoyo_exit.rs b/crates/wickra-core/src/indicators/yoyo_exit.rs new file mode 100644 index 00000000..27b192eb --- /dev/null +++ b/crates/wickra-core/src/indicators/yoyo_exit.rs @@ -0,0 +1,269 @@ +//! Yo-Yo Exit. + +use crate::error::{Error, Result}; +use crate::indicators::atr::Atr; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Yo-Yo Exit — an ATR-based long-only trailing stop that "yo-yos" in and out +/// of the market: when price closes below the trail it exits, and when price +/// recovers `multiplier · ATR` above the same trail it re-enters long. The +/// emitted level is always the *trail itself* (not a flip-to-short stop), so a +/// consumer reads a single line on the chart and toggles the position +/// depending on which side of it the close sits. +/// +/// ```text +/// band = multiplier · ATR +/// in-trade: trail_t = max(trail_{t−1}, close − band) +/// exit when close < trail +/// out: trail held flat at the last in-trade level +/// re-enter when close > trail + band +/// ``` +/// +/// Unlike [`AtrTrailingStop`](crate::AtrTrailingStop) — which always flips to +/// the opposite side — the Yo-Yo only takes longs and treats the off-period +/// as a "wait until price proves itself again" phase. A common configuration +/// is `ATR(14)` with a `2.0` multiplier. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, YoyoExit}; +/// +/// let mut indicator = YoyoExit::new(14, 2.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 YoyoExit { + atr: Atr, + atr_period: usize, + multiplier: f64, + trail: Option, + /// `true` while the trail is being ratcheted by new closes; `false` while + /// the strategy is sidelined waiting for a re-entry. + in_trade: bool, +} + +impl YoyoExit { + /// Construct a Yo-Yo Exit with an explicit ATR period and band multiplier. + /// + /// # Errors + /// Returns [`Error::PeriodZero`] if `atr_period == 0` and + /// [`Error::NonPositiveMultiplier`] if `multiplier` is not strictly + /// positive and finite. + pub fn new(atr_period: usize, multiplier: f64) -> Result { + if !multiplier.is_finite() || multiplier <= 0.0 { + return Err(Error::NonPositiveMultiplier); + } + Ok(Self { + atr: Atr::new(atr_period)?, + atr_period, + multiplier, + trail: None, + in_trade: true, + }) + } + + /// A common configuration: `ATR(14)` with a `2.0` multiplier. + pub fn classic() -> Self { + Self::new(14, 2.0).expect("classic Yo-Yo Exit params are valid") + } + + /// Configured `(atr_period, multiplier)`. + pub const fn params(&self) -> (usize, f64) { + (self.atr_period, self.multiplier) + } + + /// `true` while the strategy is currently long, `false` while sidelined. + pub const fn in_trade(&self) -> bool { + self.in_trade + } +} + +impl Indicator for YoyoExit { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + let atr = self.atr.update(candle)?; + let band = self.multiplier * atr; + let close = candle.close; + + let trail = match self.trail { + Some(prev) => { + if self.in_trade { + if close < prev { + // Stopped out — sideline, keep the trail flat. + self.in_trade = false; + prev + } else { + // Ratchet up only. + prev.max(close - band) + } + } else if close > prev + band { + // Re-entry trigger — start a new trail anchored on this close. + self.in_trade = true; + close - band + } else { + prev + } + } + // First ATR-ready bar starts a fresh long. + None => close - band, + }; + self.trail = Some(trail); + Some(trail) + } + + fn reset(&mut self) { + self.atr.reset(); + self.trail = None; + self.in_trade = true; + } + + fn warmup_period(&self) -> usize { + self.atr_period + } + + fn is_ready(&self) -> bool { + self.trail.is_some() + } + + fn name(&self) -> &'static str { + "YoyoExit" + } +} + +#[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(f64::midpoint(high, low), high, low, close, 1.0, ts).unwrap() + } + + #[test] + fn rejects_invalid_params() { + assert!(YoyoExit::new(0, 2.0).is_err()); + assert!(YoyoExit::new(14, 0.0).is_err()); + assert!(YoyoExit::new(14, -1.0).is_err()); + assert!(YoyoExit::new(14, f64::NAN).is_err()); + } + + #[test] + fn accessors_and_metadata() { + let s = YoyoExit::classic(); + let (p, m) = s.params(); + assert_eq!(p, 14); + assert_relative_eq!(m, 2.0, epsilon = 1e-12); + assert_eq!(s.warmup_period(), 14); + assert_eq!(s.name(), "YoyoExit"); + assert!(s.in_trade()); + } + + #[test] + fn first_emission_matches_warmup() { + let candles: Vec = (0..20) + .map(|i| { + let base = 100.0 + i as f64; + c(base + 1.0, base - 1.0, base, i) + }) + .collect(); + let mut s = YoyoExit::new(8, 2.0).unwrap(); + let out = s.batch(&candles); + 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()); + } + + #[test] + fn reference_values_flat_market() { + // ATR = 2; band = 4; trail starts at close - band = 10 - 4 = 6 and stays there. + let candles: Vec = (0..20).map(|i| c(11.0, 9.0, 10.0, i)).collect(); + let mut s = YoyoExit::new(5, 2.0).unwrap(); + for v in s.batch(&candles).into_iter().flatten() { + assert_relative_eq!(v, 6.0, epsilon = 1e-12); + } + } + + #[test] + fn uptrend_trail_ratchets_up() { + let candles: Vec = (0..40) + .map(|i| { + let base = 100.0 + i as f64; + c(base + 1.0, base - 1.0, base, i) + }) + .collect(); + let mut s = YoyoExit::new(14, 3.0).unwrap(); + let emitted: Vec = s.batch(&candles).into_iter().flatten().collect(); + for w in emitted.windows(2) { + assert!(w[1] >= w[0] - 1e-9, "trail must not loosen in an uptrend"); + } + } + + #[test] + fn reentry_after_stop_out() { + // Up-leg sets the trail, big drop stops out, recovery re-enters. + let mut candles: Vec = (0..30) + .map(|i| { + let base = 100.0 + i as f64; + c(base + 1.0, base - 1.0, base, i) + }) + .collect(); + candles.push(c(60.0, 40.0, 50.0, 30)); // stop-out + candles.push(c(60.0, 50.0, 55.0, 31)); // still out + candles.push(c(200.0, 100.0, 200.0, 32)); // strong rally -> re-entry + let mut s = YoyoExit::new(14, 3.0).unwrap(); + // Drive to completion; we just need it to not panic and to flip in_trade + // back to true once a re-entry trigger fires. + for c in &candles { + let _ = s.update(*c); + } + assert!(s.is_ready()); + // Final candle's close (200) is way above the trail, so we're back in. + assert!(s.in_trade()); + } + + #[test] + fn reset_clears_state() { + let candles: Vec = (0..40) + .map(|i| { + let base = 100.0 + i as f64; + c(base + 1.0, base - 1.0, base, i) + }) + .collect(); + let mut s = YoyoExit::classic(); + s.batch(&candles); + assert!(s.is_ready()); + s.reset(); + assert!(!s.is_ready()); + assert!(s.in_trade()); + assert_eq!(s.update(candles[0]), None); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..80) + .map(|i| { + let mid = 100.0 + (i as f64 * 0.3).sin() * 8.0; + c(mid + 1.5, mid - 1.5, mid + 0.5, i) + }) + .collect(); + let mut a = YoyoExit::classic(); + let mut b = YoyoExit::classic(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } +} diff --git a/crates/wickra-core/src/lib.rs b/crates/wickra-core/src/lib.rs index ed97ba3d..8ca8b655 100644 --- a/crates/wickra-core/src/lib.rs +++ b/crates/wickra-core/src/lib.rs @@ -50,22 +50,23 @@ pub use indicators::{ AwesomeOscillatorHistogram, BalanceOfPower, BollingerBands, BollingerBandwidth, BollingerOutput, Cci, Cfo, ChaikinMoneyFlow, ChaikinOscillator, ChaikinVolatility, ChandeKrollStop, ChandeKrollStopOutput, ChandelierExit, ChandelierExitOutput, ChoppinessIndex, - Cmo, ConnorsRsi, Coppock, Dema, DemandIndex, Donchian, DonchianOutput, DoubleBollinger, - DoubleBollingerOutput, Dpo, EaseOfMovement, ElderImpulse, Ema, Evwma, ForceIndex, - FractalChaosBands, FractalChaosBandsOutput, Frama, GarmanKlassVolatility, HistoricalVolatility, - Hma, HurstChannel, HurstChannelOutput, Inertia, Jma, Kama, Keltner, KeltnerOutput, Kst, - KstOutput, Kvo, LaguerreRsi, LinRegAngle, LinRegChannel, LinRegChannelOutput, LinRegSlope, - LinearRegression, MaEnvelope, MaEnvelopeOutput, MacdIndicator, MacdOutput, - MarketFacilitationIndex, MassIndex, McGinleyDynamic, MedianPrice, Mfi, Mom, Natr, Nvi, Obv, - ParkinsonVolatility, PercentB, Pgo, Pmo, Ppo, Psar, Pvi, Roc, RogersSatchellVolatility, - RollingVwap, Rsi, Rvi, RviVolatility, Rwi, RwiOutput, Sma, Smi, Smma, StandardErrorBands, - StandardErrorBandsOutput, StarcBands, StarcBandsOutput, Stc, StdDev, StochRsi, Stochastic, - StochasticOutput, SuperTrend, SuperTrendOutput, Tema, Tii, Trima, Trix, TrueRange, Tsi, Tsv, - TtmSqueeze, TtmSqueezeOutput, TypicalPrice, UlcerIndex, UltimateOscillator, - VerticalHorizontalFilter, Vidya, VolumeOscillator, VolumePriceTrend, Vortex, VortexOutput, - Vwap, VwapStdDevBands, VwapStdDevBandsOutput, Vwma, Vzo, WaveTrend, WaveTrendOutput, - WeightedClose, WilliamsR, Wma, YangZhangVolatility, ZScore, ZeroLagMacd, ZeroLagMacdOutput, - Zlema, T3, + Cmo, ConnorsRsi, Coppock, Dema, DemandIndex, Donchian, DonchianOutput, DonchianStop, + DonchianStopOutput, DoubleBollinger, DoubleBollingerOutput, Dpo, EaseOfMovement, ElderImpulse, + Ema, Evwma, ForceIndex, FractalChaosBands, FractalChaosBandsOutput, Frama, + GarmanKlassVolatility, HiLoActivator, HistoricalVolatility, Hma, HurstChannel, + HurstChannelOutput, Inertia, Jma, Kama, Keltner, KeltnerOutput, Kst, KstOutput, Kvo, + LaguerreRsi, LinRegAngle, LinRegChannel, LinRegChannelOutput, LinRegSlope, LinearRegression, + MaEnvelope, MaEnvelopeOutput, MacdIndicator, MacdOutput, MarketFacilitationIndex, MassIndex, + McGinleyDynamic, MedianPrice, Mfi, Mom, Natr, Nvi, Obv, ParkinsonVolatility, PercentB, + PercentageTrailingStop, Pgo, Pmo, Ppo, Psar, Pvi, RenkoTrailingStop, Roc, + RogersSatchellVolatility, RollingVwap, Rsi, Rvi, RviVolatility, Rwi, RwiOutput, Sma, Smi, Smma, + StandardErrorBands, StandardErrorBandsOutput, StarcBands, StarcBandsOutput, Stc, StdDev, + StepTrailingStop, StochRsi, Stochastic, StochasticOutput, SuperTrend, SuperTrendOutput, Tema, + Tii, Trima, Trix, TrueRange, Tsi, Tsv, TtmSqueeze, TtmSqueezeOutput, TypicalPrice, UlcerIndex, + UltimateOscillator, VerticalHorizontalFilter, Vidya, VoltyStop, VolumeOscillator, + VolumePriceTrend, Vortex, VortexOutput, Vwap, VwapStdDevBands, VwapStdDevBandsOutput, Vwma, + Vzo, WaveTrend, WaveTrendOutput, WeightedClose, WilliamsR, Wma, YangZhangVolatility, YoyoExit, + ZScore, ZeroLagMacd, ZeroLagMacdOutput, Zlema, T3, }; pub use ohlcv::{Candle, Tick}; pub use traits::{BatchExt, Chain, Indicator}; diff --git a/crates/wickra/benches/indicators.rs b/crates/wickra/benches/indicators.rs index 4a504950..95c72dcf 100644 --- a/crates/wickra/benches/indicators.rs +++ b/crates/wickra/benches/indicators.rs @@ -20,12 +20,13 @@ use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion, Through use std::hint::black_box; use wickra::{ AccelerationBands, AdOscillator, Adxr, Alma, AnchoredVwap, Atr, AtrBands, BatchExt, - BollingerBands, Candle, DemandIndex, DoubleBollinger, Ema, FractalChaosBands, Frama, - GarmanKlassVolatility, HurstChannel, Indicator, Jma, Kst, Kvo, LinRegChannel, MaEnvelope, - MacdIndicator, MarketFacilitationIndex, McGinleyDynamic, Nvi, Obv, ParkinsonVolatility, Pgo, - Pvi, RogersSatchellVolatility, Rsi, Rvi, RviVolatility, Rwi, Sma, StandardErrorBands, - StarcBands, Stochastic, Tii, Tsv, TtmSqueeze, Vidya, VolumeOscillator, VwapStdDevBands, Vzo, - WaveTrend, Wma, YangZhangVolatility, + BollingerBands, Candle, DemandIndex, DonchianStop, DoubleBollinger, Ema, FractalChaosBands, + Frama, GarmanKlassVolatility, HiLoActivator, HurstChannel, Indicator, Jma, Kst, Kvo, + LinRegChannel, MaEnvelope, MacdIndicator, MarketFacilitationIndex, McGinleyDynamic, Nvi, Obv, + ParkinsonVolatility, PercentageTrailingStop, Pgo, Pvi, RenkoTrailingStop, + RogersSatchellVolatility, Rsi, Rvi, RviVolatility, Rwi, Sma, StandardErrorBands, StarcBands, + StepTrailingStop, Stochastic, Tii, Tsv, TtmSqueeze, Vidya, VoltyStop, VolumeOscillator, + VwapStdDevBands, Vzo, WaveTrend, Wma, YangZhangVolatility, YoyoExit, }; use wickra_data::csv::CandleReader; @@ -154,6 +155,7 @@ where group.finish(); } +#[allow(clippy::too_many_lines)] fn benches(c: &mut Criterion) { let candles = load_candles(); let closes: Vec = candles.iter().map(|c| c.close).collect(); @@ -180,6 +182,21 @@ fn benches(c: &mut Criterion) { bench_candle_input(c, "stochastic", &candles, Stochastic::classic); bench_candle_input(c, "obv", &candles, Obv::new); + // --- Family 09: Trailing Stops --- + bench_candle_input(c, "hilo_activator", &candles, HiLoActivator::classic); + bench_candle_input(c, "volty_stop", &candles, VoltyStop::classic); + bench_candle_input(c, "yoyo_exit", &candles, YoyoExit::classic); + bench_candle_input(c, "donchian_stop", &candles, DonchianStop::classic); + bench_scalar(c, "percentage_trailing_stop", &closes, || { + PercentageTrailingStop::new(5.0).unwrap() + }); + bench_scalar(c, "step_trailing_stop", &closes, || { + StepTrailingStop::new(1.0).unwrap() + }); + bench_scalar(c, "renko_trailing_stop", &closes, || { + RenkoTrailingStop::new(1.0).unwrap() + }); + // --- Family 07: Volume --- bench_candle_input(c, "kvo", &candles, Kvo::classic); bench_candle_input(c, "volume_oscillator", &candles, || { diff --git a/fuzz/fuzz_targets/indicator_update.rs b/fuzz/fuzz_targets/indicator_update.rs index 13ee9c90..02752f94 100644 --- a/fuzz/fuzz_targets/indicator_update.rs +++ b/fuzz/fuzz_targets/indicator_update.rs @@ -18,9 +18,10 @@ use wickra_core::{ Alma, Apo, BatchExt, BollingerBands, Cfo, Cmo, ConnorsRsi, Coppock, Dema, DoubleBollinger, Dpo, ElderImpulse, Ema, Frama, HistoricalVolatility, Hma, Indicator, Jma, Kama, Kst, LaguerreRsi, LinRegAngle, LinRegChannel, LinRegSlope, LinearRegression, MaEnvelope, MacdIndicator, - McGinleyDynamic, Mom, Pmo, Ppo, Roc, Rsi, RviVolatility, Sma, Smma, StandardErrorBands, Stc, - StdDev, StochRsi, T3, Tema, Tii, Trima, Trix, Tsi, UlcerIndex, VerticalHorizontalFilter, Vidya, - Wma, ZScore, ZeroLagMacd, Zlema, + McGinleyDynamic, Mom, PercentageTrailingStop, Pmo, Ppo, RenkoTrailingStop, Roc, Rsi, + RviVolatility, Sma, Smma, StandardErrorBands, Stc, StdDev, StepTrailingStop, StochRsi, T3, Tema, + Tii, Trima, Trix, Tsi, UlcerIndex, VerticalHorizontalFilter, Vidya, Wma, ZScore, ZeroLagMacd, + Zlema, }; /// Drive a single streaming + batch run through one scalar indicator. Marked @@ -106,6 +107,11 @@ fuzz_target!(|data: Vec| { let _ = ZeroLagMacd::classic().batch(&data); } + // --- Trailing Stops (scalar) --- + drive(|| PercentageTrailingStop::new(5.0).unwrap(), &data); + drive(|| StepTrailingStop::new(1.0).unwrap(), &data); + drive(|| RenkoTrailingStop::new(1.0).unwrap(), &data); + // MACD and Bollinger Bands have non-`f64` outputs, so they cannot use the // generic `drive` helper above. Streaming + batch are still both exercised. { diff --git a/fuzz/fuzz_targets/indicator_update_candle.rs b/fuzz/fuzz_targets/indicator_update_candle.rs index 493fbe3a..98d9264c 100644 --- a/fuzz/fuzz_targets/indicator_update_candle.rs +++ b/fuzz/fuzz_targets/indicator_update_candle.rs @@ -27,13 +27,13 @@ use wickra_core::{ AnchoredVwap, Aroon, AroonOscillator, Atr, AtrBands, AtrTrailingStop, AwesomeOscillator, AwesomeOscillatorHistogram, BalanceOfPower, BatchExt, Candle, Cci, ChaikinMoneyFlow, ChaikinOscillator, ChaikinVolatility, ChandeKrollStop, ChandelierExit, ChoppinessIndex, - DemandIndex, Donchian, EaseOfMovement, Evwma, ForceIndex, FractalChaosBands, - GarmanKlassVolatility, HurstChannel, Indicator, Inertia, Keltner, Kvo, MarketFacilitationIndex, - MassIndex, MedianPrice, Mfi, Natr, Nvi, Obv, ParkinsonVolatility, Pgo, Psar, Pvi, - RogersSatchellVolatility, RollingVwap, Rvi, Rwi, Smi, StarcBands, Stochastic, SuperTrend, - TrueRange, Tsv, TtmSqueeze, TypicalPrice, UltimateOscillator, VolumeOscillator, - VolumePriceTrend, Vortex, Vwap, VwapStdDevBands, Vwma, Vzo, WaveTrend, WeightedClose, WilliamsR, - YangZhangVolatility, + DemandIndex, Donchian, DonchianStop, EaseOfMovement, Evwma, ForceIndex, FractalChaosBands, + GarmanKlassVolatility, HiLoActivator, HurstChannel, Indicator, Inertia, Keltner, Kvo, + MarketFacilitationIndex, MassIndex, MedianPrice, Mfi, Natr, Nvi, Obv, ParkinsonVolatility, Pgo, + Psar, Pvi, RogersSatchellVolatility, RollingVwap, Rvi, Rwi, Smi, StarcBands, Stochastic, + SuperTrend, TrueRange, Tsv, TtmSqueeze, TypicalPrice, UltimateOscillator, VoltyStop, + VolumeOscillator, VolumePriceTrend, Vortex, Vwap, VwapStdDevBands, Vwma, Vzo, WaveTrend, + WeightedClose, WilliamsR, YangZhangVolatility, YoyoExit, }; /// Convert a flat `f64` stream into a `Vec` by chunking it into @@ -93,6 +93,9 @@ fuzz_target!(|data: Vec| { drive(|| ChandelierExit::new(22, 3.0).unwrap(), &candles); drive(|| ChandeKrollStop::new(10, 1.0, 9).unwrap(), &candles); drive(|| AtrTrailingStop::new(14, 3.0).unwrap(), &candles); + drive(|| HiLoActivator::new(3).unwrap(), &candles); + drive(|| VoltyStop::new(14, 2.0).unwrap(), &candles); + drive(|| YoyoExit::new(14, 2.0).unwrap(), &candles); // --- Trend & Directional --- drive(|| Adx::new(14).unwrap(), &candles); @@ -160,6 +163,15 @@ fuzz_target!(|data: Vec| { let _ = Stochastic::new(14, 3).unwrap().batch(&candles); } + // --- Donchian Stop (multi-output) --- + { + let mut s = DonchianStop::new(10).unwrap(); + for c in &candles { + let _ = s.update(*c); + } + let _ = DonchianStop::new(10).unwrap().batch(&candles); + } + // --- Family 05: candle-input band/channel indicators (multi-output) --- { let mut ab = AccelerationBands::new(20, 0.001).unwrap();