feat(family-09): add 7 trailing stops (HiLo, Volty, Yo-Yo, Donchian, Pct, Step, Renko) (#46)

* 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).
This commit is contained in:
kingchenc
2026-05-25 19:36:14 +02:00
committed by GitHub
parent 880a0e7430
commit f10b8c2e2d
22 changed files with 2965 additions and 36 deletions
@@ -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<f64>,
lows: VecDeque<f64>,
}
impl DonchianStop {
/// Construct a Donchian Channel Stop with an explicit lookback.
///
/// # Errors
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
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<DonchianStopOutput> {
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<Candle> = (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<Candle> = (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<Candle> = (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<Candle> = (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<Candle> = (0..80)
.map(|i| {
let mid = 100.0 + (i as f64 * 0.3).sin() * 8.0;
c(mid + 1.5, mid - 1.5, mid + 0.5, i)
})
.collect();
let mut a = DonchianStop::classic();
let mut b = DonchianStop::classic();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
@@ -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<f64>,
lows: VecDeque<f64>,
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<Self> {
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<f64> {
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<Candle> = (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<Candle> = (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<Candle> = (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<Candle> = (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<Candle> = (0..80)
.map(|i| {
let mid = 100.0 + (i as f64 * 0.3).sin() * 8.0;
c(mid + 1.5, mid - 1.5, mid + 0.5, i)
})
.collect();
let mut a = HiLoActivator::classic();
let mut b = HiLoActivator::classic();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
+14
View File
@@ -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;
@@ -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_{t1}, close step) while close ≥ stop_{t1}
/// short: stop_t = min(stop_{t1}, close + step) while close ≤ stop_{t1}
/// 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<f64>,
/// `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<Self> {
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<f64> {
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<f64> = 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<f64> = (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::<Vec<_>>()
);
}
}
@@ -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<f64>,
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<Self> {
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<f64> {
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<f64> = (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::<Vec<_>>()
);
}
}
@@ -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_{t1}, floor(target / step_size) · step_size)
/// while close ≥ stop_{t1}
/// short: target = close + step_size
/// stop_t = min(stop_{t1}, ceil(target / step_size) · step_size)
/// while close ≤ stop_{t1}
/// 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<f64>,
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<Self> {
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<f64> {
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<f64> = [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<f64> = (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::<Vec<_>>()
);
}
}
@@ -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<f64>,
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<Self> {
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<f64> {
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<Candle> = (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<Candle> = (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<Candle> = (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<Candle> = (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<Candle> = (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<Candle> = (0..80)
.map(|i| {
let mid = 100.0 + (i as f64 * 0.3).sin() * 8.0;
c(mid + 1.5, mid - 1.5, mid + 0.5, i)
})
.collect();
let mut a = VoltyStop::classic();
let mut b = VoltyStop::classic();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
@@ -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_{t1}, 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<f64>,
/// `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<Self> {
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<f64> {
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<Candle> = (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<Candle> = (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<Candle> = (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<f64> = 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<Candle> = (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<Candle> = (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<Candle> = (0..80)
.map(|i| {
let mid = 100.0 + (i as f64 * 0.3).sin() * 8.0;
c(mid + 1.5, mid - 1.5, mid + 0.5, i)
})
.collect();
let mut a = YoyoExit::classic();
let mut b = YoyoExit::classic();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
+17 -16
View File
@@ -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};