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