Add B7 Trailing Stops family (6 indicators) (#193)
Adds the **Trailing Stops** family deepening (B7), six new indicators (434 -> 440):
- **KaseDevStop** — Cynthia Kase's volatility stop on the standard deviation of the two-bar true range.
- **ElderSafeZone** — Alexander Elder's stop offset by a multiple of average market noise.
- **AtrRatchet** — Kaufman ATR ratchet that tightens its multiple by a per-bar increment.
- **Nrtr** — Nick Rypock Trailing Reverse (percentage band).
- **TimeBasedStop** — exits after a fixed number of bars (scalar fraction of elapsed life).
- **ModifiedMaStop** — moving-average based trailing stop.
("Wilder Volatility System" is intentionally skipped — it overlaps the existing VoltyStop/Psar/SarExt.)
Each takes Candle input; the five band/structure stops emit a {value, direction} struct, TimeBasedStop a scalar. Wired across core, Python/Node/WASM bindings, fuzz target and tests. Verified locally: 3560 core lib + 398 doc tests, clippy clean, 515 node tests, 852 pytest, counter 440.
This commit is contained in:
@@ -0,0 +1,279 @@
|
||||
//! ATR Ratchet (Kaufman) — a trailing stop that creeps toward price each bar.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::indicators::atr::Atr;
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Output of [`AtrRatchet`]: the active stop level and the trend direction.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct AtrRatchetOutput {
|
||||
/// The ratchet stop level — below price when long, above price when short.
|
||||
pub value: f64,
|
||||
/// Trend direction: `+1.0` long, `-1.0` short.
|
||||
pub direction: f64,
|
||||
}
|
||||
|
||||
/// ATR Ratchet — Perry Kaufman's time-based volatility stop that tightens by a
|
||||
/// fixed fraction of ATR **every bar**, whether or not price moves.
|
||||
///
|
||||
/// ```text
|
||||
/// on entry (long): stop = close − start_mult · ATR
|
||||
/// each later bar: stop = stop + increment · ATR (ratchets toward price)
|
||||
/// flip to short when close < stop, reseeding stop = close + start_mult · ATR
|
||||
/// ```
|
||||
///
|
||||
/// Most trailing stops only move when price makes a new extreme. Kaufman's ratchet
|
||||
/// instead advances the stop a little each bar — `increment · ATR` — so a trade
|
||||
/// that stalls is squeezed out over time even in a flat market. The initial
|
||||
/// distance (`start_mult · ATR`) gives the position room to breathe; the per-bar
|
||||
/// `increment` controls how aggressively the leash shortens. When price closes
|
||||
/// through the stop the system reverses and reseeds at the full initial distance.
|
||||
///
|
||||
/// The first stop lands once ATR is ready (`atr_period` inputs). Each `update` is
|
||||
/// O(1).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, AtrRatchet};
|
||||
///
|
||||
/// let mut indicator = AtrRatchet::new(14, 4.0, 0.1).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..60 {
|
||||
/// let base = 100.0 + f64::from(i);
|
||||
/// let c = Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 1_000.0, 0).unwrap();
|
||||
/// last = indicator.update(c);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AtrRatchet {
|
||||
atr: Atr,
|
||||
atr_period: usize,
|
||||
start_mult: f64,
|
||||
increment: f64,
|
||||
direction: f64,
|
||||
stop: f64,
|
||||
last: Option<AtrRatchetOutput>,
|
||||
}
|
||||
|
||||
impl AtrRatchet {
|
||||
/// Construct an ATR Ratchet stop.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if `atr_period == 0` and
|
||||
/// [`Error::NonPositiveMultiplier`] if `start_mult` or `increment` is not
|
||||
/// finite and positive.
|
||||
pub fn new(atr_period: usize, start_mult: f64, increment: f64) -> Result<Self> {
|
||||
if !start_mult.is_finite()
|
||||
|| start_mult <= 0.0
|
||||
|| !increment.is_finite()
|
||||
|| increment <= 0.0
|
||||
{
|
||||
return Err(Error::NonPositiveMultiplier);
|
||||
}
|
||||
Ok(Self {
|
||||
atr: Atr::new(atr_period)?,
|
||||
atr_period,
|
||||
start_mult,
|
||||
increment,
|
||||
direction: 0.0,
|
||||
stop: 0.0,
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured `(atr_period, start_mult, increment)`.
|
||||
pub const fn params(&self) -> (usize, f64, f64) {
|
||||
(self.atr_period, self.start_mult, self.increment)
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<AtrRatchetOutput> {
|
||||
self.last
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for AtrRatchet {
|
||||
type Input = Candle;
|
||||
type Output = AtrRatchetOutput;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<AtrRatchetOutput> {
|
||||
let atr = self.atr.update(candle)?;
|
||||
let close = candle.close;
|
||||
|
||||
if self.direction == 0.0 {
|
||||
self.direction = 1.0;
|
||||
self.stop = close - self.start_mult * atr;
|
||||
} else if self.direction > 0.0 {
|
||||
self.stop += self.increment * atr;
|
||||
if close < self.stop {
|
||||
self.direction = -1.0;
|
||||
self.stop = close + self.start_mult * atr;
|
||||
}
|
||||
} else {
|
||||
self.stop -= self.increment * atr;
|
||||
if close > self.stop {
|
||||
self.direction = 1.0;
|
||||
self.stop = close - self.start_mult * atr;
|
||||
}
|
||||
}
|
||||
|
||||
let out = AtrRatchetOutput {
|
||||
value: self.stop,
|
||||
direction: self.direction,
|
||||
};
|
||||
self.last = Some(out);
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.atr.reset();
|
||||
self.direction = 0.0;
|
||||
self.stop = 0.0;
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.atr_period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"AtrRatchet"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
fn c(high: f64, low: f64, close: f64) -> Candle {
|
||||
Candle::new_unchecked(f64::midpoint(high, low), high, low, close, 1_000.0, 0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_params() {
|
||||
assert!(matches!(
|
||||
AtrRatchet::new(0, 4.0, 0.1),
|
||||
Err(Error::PeriodZero)
|
||||
));
|
||||
assert!(matches!(
|
||||
AtrRatchet::new(14, 0.0, 0.1),
|
||||
Err(Error::NonPositiveMultiplier)
|
||||
));
|
||||
assert!(matches!(
|
||||
AtrRatchet::new(14, 4.0, 0.0),
|
||||
Err(Error::NonPositiveMultiplier)
|
||||
));
|
||||
assert!(matches!(
|
||||
AtrRatchet::new(14, 4.0, f64::NAN),
|
||||
Err(Error::NonPositiveMultiplier)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let r = AtrRatchet::new(14, 4.0, 0.1).unwrap();
|
||||
assert_eq!(r.params(), (14, 4.0, 0.1));
|
||||
assert_eq!(r.warmup_period(), 14);
|
||||
assert_eq!(r.name(), "AtrRatchet");
|
||||
assert!(!r.is_ready());
|
||||
assert_eq!(r.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_at_warmup_period() {
|
||||
let mut r = AtrRatchet::new(5, 4.0, 0.1).unwrap();
|
||||
let candles: Vec<Candle> = (0..12)
|
||||
.map(|i| {
|
||||
let base = 100.0 + f64::from(i);
|
||||
c(base + 1.0, base - 1.0, base)
|
||||
})
|
||||
.collect();
|
||||
let out = r.batch(&candles);
|
||||
for v in out.iter().take(4) {
|
||||
assert!(v.is_none());
|
||||
}
|
||||
assert!(out[4].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uptrend_keeps_stop_below_price() {
|
||||
let mut r = AtrRatchet::new(5, 4.0, 0.05).unwrap();
|
||||
let candles: Vec<Candle> = (0..60)
|
||||
.map(|i| {
|
||||
let base = 100.0 + 2.0 * f64::from(i);
|
||||
c(base + 1.0, base - 1.0, base + 0.5)
|
||||
})
|
||||
.collect();
|
||||
for (o, candle) in r.batch(&candles).into_iter().zip(candles.iter()) {
|
||||
if let Some(o) = o {
|
||||
assert_eq!(o.direction, 1.0);
|
||||
assert!(o.value < candle.close);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stall_eventually_triggers_flip() {
|
||||
// A long trend then a long flat stretch: the ratchet creeps up each bar
|
||||
// and eventually overtakes the flat close, flipping to short.
|
||||
let mut r = AtrRatchet::new(5, 2.0, 0.5).unwrap();
|
||||
let mut candles: Vec<Candle> = (0..20)
|
||||
.map(|i| {
|
||||
let base = 100.0 + f64::from(i);
|
||||
c(base + 1.0, base - 1.0, base + 0.5)
|
||||
})
|
||||
.collect();
|
||||
// Flat stretch at the last price.
|
||||
candles.extend((0..40).map(|_| c(120.6, 118.6, 119.5)));
|
||||
let dirs: Vec<f64> = r
|
||||
.batch(&candles)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|o| o.direction)
|
||||
.collect();
|
||||
assert!(
|
||||
dirs.iter().any(|&d| d < 0.0),
|
||||
"the ratchet should eventually flip short"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut r = AtrRatchet::new(5, 4.0, 0.1).unwrap();
|
||||
let candles: Vec<Candle> = (0..40)
|
||||
.map(|i| {
|
||||
let base = 100.0 + f64::from(i);
|
||||
c(base + 1.0, base - 1.0, base + 0.5)
|
||||
})
|
||||
.collect();
|
||||
r.batch(&candles);
|
||||
assert!(r.is_ready());
|
||||
r.reset();
|
||||
assert!(!r.is_ready());
|
||||
assert_eq!(r.value(), None);
|
||||
assert_eq!(r.update(candles[0]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..120)
|
||||
.map(|i| {
|
||||
let base = 100.0 + (f64::from(i) * 0.25).sin() * 9.0;
|
||||
c(base + 2.0, base - 1.5, base + 0.5)
|
||||
})
|
||||
.collect();
|
||||
let batch = AtrRatchet::new(14, 4.0, 0.1).unwrap().batch(&candles);
|
||||
let mut b = AtrRatchet::new(14, 4.0, 0.1).unwrap();
|
||||
let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
//! Elder `SafeZone` Stop — a trailing stop set by the average noise penetration.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Output of [`ElderSafeZone`]: the active stop level and the trend direction.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct ElderSafeZoneOutput {
|
||||
/// The `SafeZone` stop level — below price when long, above price when short.
|
||||
pub value: f64,
|
||||
/// Trend direction: `+1.0` long, `-1.0` short.
|
||||
pub direction: f64,
|
||||
}
|
||||
|
||||
/// Elder `SafeZone` Stop — Alexander Elder's stop placed a multiple of the
|
||||
/// **average market noise** away from price.
|
||||
///
|
||||
/// ```text
|
||||
/// long market noise = average downside penetration = mean( prev_low − low | low < prev_low )
|
||||
/// short market noise = average upside penetration = mean( high − prev_high | high > prev_high )
|
||||
/// long stop = ratchet_up( low_t − coeff · avg_down_penetration )
|
||||
/// short stop = ratchet_down( high_t + coeff · avg_up_penetration )
|
||||
/// ```
|
||||
///
|
||||
/// Elder defines *noise* in an uptrend as the part of each bar that pokes below
|
||||
/// the previous bar's low (a "downside penetration"). Averaging those
|
||||
/// penetrations over a lookback and placing the stop `coeff` multiples below the
|
||||
/// current low keeps the stop just outside normal pullbacks while still exiting on
|
||||
/// a genuine reversal. The stop trails in the trend's favour and flips when price
|
||||
/// closes through it. The average uses only the bars that actually penetrated
|
||||
/// (Elder's definition), so a noiseless trend gives a tight stop at the bar's
|
||||
/// extreme.
|
||||
///
|
||||
/// The first bar seeds the prior candle; the next `period` bars accumulate the
|
||||
/// penetration statistics, so the first stop lands after `period + 1` inputs.
|
||||
/// Each `update` is O(1).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, ElderSafeZone};
|
||||
///
|
||||
/// let mut indicator = ElderSafeZone::new(14, 2.0).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..60 {
|
||||
/// let base = 100.0 + f64::from(i);
|
||||
/// let c = Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 1_000.0, 0).unwrap();
|
||||
/// last = indicator.update(c);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ElderSafeZone {
|
||||
period: usize,
|
||||
coeff: f64,
|
||||
prev: Option<Candle>,
|
||||
down_pen: VecDeque<f64>,
|
||||
up_pen: VecDeque<f64>,
|
||||
down_sum: f64,
|
||||
up_sum: f64,
|
||||
down_count: usize,
|
||||
up_count: usize,
|
||||
direction: f64,
|
||||
stop: f64,
|
||||
last: Option<ElderSafeZoneOutput>,
|
||||
}
|
||||
|
||||
impl ElderSafeZone {
|
||||
/// Construct an Elder `SafeZone` stop with the given averaging `period` and
|
||||
/// noise `coeff`icient.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if `period == 0` and
|
||||
/// [`Error::NonPositiveMultiplier`] if `coeff` is not finite and positive.
|
||||
pub fn new(period: usize, coeff: f64) -> Result<Self> {
|
||||
if period == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
if !coeff.is_finite() || coeff <= 0.0 {
|
||||
return Err(Error::NonPositiveMultiplier);
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
coeff,
|
||||
prev: None,
|
||||
down_pen: VecDeque::with_capacity(period),
|
||||
up_pen: VecDeque::with_capacity(period),
|
||||
down_sum: 0.0,
|
||||
up_sum: 0.0,
|
||||
down_count: 0,
|
||||
up_count: 0,
|
||||
direction: 0.0,
|
||||
stop: 0.0,
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured `(period, coeff)`.
|
||||
pub const fn params(&self) -> (usize, f64) {
|
||||
(self.period, self.coeff)
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<ElderSafeZoneOutput> {
|
||||
self.last
|
||||
}
|
||||
|
||||
fn push(window: &mut VecDeque<f64>, sum: &mut f64, count: &mut usize, period: usize, pen: f64) {
|
||||
if window.len() == period {
|
||||
let old = window.pop_front().expect("non-empty");
|
||||
*sum -= old;
|
||||
if old > 0.0 {
|
||||
*count -= 1;
|
||||
}
|
||||
}
|
||||
window.push_back(pen);
|
||||
*sum += pen;
|
||||
if pen > 0.0 {
|
||||
*count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn avg(sum: f64, count: usize) -> f64 {
|
||||
if count == 0 {
|
||||
0.0
|
||||
} else {
|
||||
sum / count as f64
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for ElderSafeZone {
|
||||
type Input = Candle;
|
||||
type Output = ElderSafeZoneOutput;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<ElderSafeZoneOutput> {
|
||||
let Some(prev) = self.prev else {
|
||||
self.prev = Some(candle);
|
||||
return None;
|
||||
};
|
||||
let dp = (prev.low - candle.low).max(0.0);
|
||||
let up = (candle.high - prev.high).max(0.0);
|
||||
self.prev = Some(candle);
|
||||
|
||||
Self::push(
|
||||
&mut self.down_pen,
|
||||
&mut self.down_sum,
|
||||
&mut self.down_count,
|
||||
self.period,
|
||||
dp,
|
||||
);
|
||||
Self::push(
|
||||
&mut self.up_pen,
|
||||
&mut self.up_sum,
|
||||
&mut self.up_count,
|
||||
self.period,
|
||||
up,
|
||||
);
|
||||
if self.down_pen.len() < self.period {
|
||||
return None;
|
||||
}
|
||||
|
||||
let avg_down = Self::avg(self.down_sum, self.down_count);
|
||||
let avg_up = Self::avg(self.up_sum, self.up_count);
|
||||
|
||||
if self.direction == 0.0 {
|
||||
self.direction = 1.0;
|
||||
self.stop = candle.low - self.coeff * avg_down;
|
||||
} else if self.direction > 0.0 {
|
||||
let raw = candle.low - self.coeff * avg_down;
|
||||
self.stop = self.stop.max(raw);
|
||||
if candle.close < self.stop {
|
||||
self.direction = -1.0;
|
||||
self.stop = candle.high + self.coeff * avg_up;
|
||||
}
|
||||
} else {
|
||||
let raw = candle.high + self.coeff * avg_up;
|
||||
self.stop = self.stop.min(raw);
|
||||
if candle.close > self.stop {
|
||||
self.direction = 1.0;
|
||||
self.stop = candle.low - self.coeff * avg_down;
|
||||
}
|
||||
}
|
||||
|
||||
let out = ElderSafeZoneOutput {
|
||||
value: self.stop,
|
||||
direction: self.direction,
|
||||
};
|
||||
self.last = Some(out);
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.prev = None;
|
||||
self.down_pen.clear();
|
||||
self.up_pen.clear();
|
||||
self.down_sum = 0.0;
|
||||
self.up_sum = 0.0;
|
||||
self.down_count = 0;
|
||||
self.up_count = 0;
|
||||
self.direction = 0.0;
|
||||
self.stop = 0.0;
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period + 1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"ElderSafeZone"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
fn c(high: f64, low: f64, close: f64) -> Candle {
|
||||
Candle::new_unchecked(f64::midpoint(high, low), high, low, close, 1_000.0, 0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_params() {
|
||||
assert!(matches!(ElderSafeZone::new(0, 2.0), Err(Error::PeriodZero)));
|
||||
assert!(matches!(
|
||||
ElderSafeZone::new(14, 0.0),
|
||||
Err(Error::NonPositiveMultiplier)
|
||||
));
|
||||
assert!(matches!(
|
||||
ElderSafeZone::new(14, -1.0),
|
||||
Err(Error::NonPositiveMultiplier)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let e = ElderSafeZone::new(14, 2.0).unwrap();
|
||||
assert_eq!(e.params(), (14, 2.0));
|
||||
assert_eq!(e.warmup_period(), 15);
|
||||
assert_eq!(e.name(), "ElderSafeZone");
|
||||
assert!(!e.is_ready());
|
||||
assert_eq!(e.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_at_warmup_period() {
|
||||
let mut e = ElderSafeZone::new(3, 2.0).unwrap();
|
||||
let candles: Vec<Candle> = (0..8)
|
||||
.map(|i| {
|
||||
let base = 100.0 + f64::from(i);
|
||||
c(base + 1.0, base - 1.0, base)
|
||||
})
|
||||
.collect();
|
||||
let out = e.batch(&candles);
|
||||
let warmup = e.warmup_period(); // 4
|
||||
assert_eq!(warmup, 4);
|
||||
for v in out.iter().take(warmup - 1) {
|
||||
assert!(v.is_none());
|
||||
}
|
||||
assert!(out[warmup - 1].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uptrend_keeps_stop_below_price() {
|
||||
let mut e = ElderSafeZone::new(5, 2.0).unwrap();
|
||||
let candles: Vec<Candle> = (0..60)
|
||||
.map(|i| {
|
||||
let base = 100.0 + 2.0 * f64::from(i);
|
||||
c(base + 1.0, base - 1.0, base + 0.5)
|
||||
})
|
||||
.collect();
|
||||
for (o, candle) in e.batch(&candles).into_iter().zip(candles.iter()) {
|
||||
if let Some(o) = o {
|
||||
assert_eq!(o.direction, 1.0);
|
||||
assert!(o.value <= candle.close);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn noiseless_trend_stop_sits_at_low() {
|
||||
// Every bar makes a higher low -> no downside penetration -> avg 0 ->
|
||||
// the stop sits exactly at the bar's low.
|
||||
let mut e = ElderSafeZone::new(3, 2.0).unwrap();
|
||||
let candles: Vec<Candle> = (0..10)
|
||||
.map(|i| {
|
||||
let base = 100.0 + f64::from(i);
|
||||
c(base + 1.0, base - 1.0, base + 0.5)
|
||||
})
|
||||
.collect();
|
||||
let out = e.batch(&candles);
|
||||
let last_candle = candles.last().unwrap();
|
||||
let last = out.last().unwrap().unwrap();
|
||||
assert!((last.value - last_candle.low).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flips_on_reversal() {
|
||||
let mut candles: Vec<Candle> = (0..40)
|
||||
.map(|i| {
|
||||
let base = 100.0 + f64::from(i);
|
||||
c(base + 1.0, base - 1.0, base + 0.5)
|
||||
})
|
||||
.collect();
|
||||
candles.extend((0..40).map(|i| {
|
||||
let base = 140.0 - f64::from(i);
|
||||
c(base + 1.0, base - 1.0, base - 0.5)
|
||||
}));
|
||||
let mut e = ElderSafeZone::new(5, 2.0).unwrap();
|
||||
let dirs: Vec<f64> = e
|
||||
.batch(&candles)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|o| o.direction)
|
||||
.collect();
|
||||
assert!(dirs.iter().any(|&d| d > 0.0));
|
||||
assert!(dirs.iter().any(|&d| d < 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut e = ElderSafeZone::new(5, 2.0).unwrap();
|
||||
let candles: Vec<Candle> = (0..40)
|
||||
.map(|i| {
|
||||
let base = 100.0 + f64::from(i);
|
||||
c(base + 1.0, base - 1.0, base + 0.5)
|
||||
})
|
||||
.collect();
|
||||
e.batch(&candles);
|
||||
assert!(e.is_ready());
|
||||
e.reset();
|
||||
assert!(!e.is_ready());
|
||||
assert_eq!(e.value(), None);
|
||||
assert_eq!(e.update(candles[0]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..120)
|
||||
.map(|i| {
|
||||
let base = 100.0 + (f64::from(i) * 0.25).sin() * 9.0;
|
||||
c(base + 2.0, base - 1.5, base + 0.5)
|
||||
})
|
||||
.collect();
|
||||
let batch = ElderSafeZone::new(14, 2.0).unwrap().batch(&candles);
|
||||
let mut b = ElderSafeZone::new(14, 2.0).unwrap();
|
||||
let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
//! Kase `DevStop` — a volatility trailing stop on the standard deviation of the
|
||||
//! two-bar true range.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Output of [`KaseDevStop`]: the active trailing-stop level and the trend
|
||||
/// direction it protects.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct KaseDevStopOutput {
|
||||
/// The `DevStop` level — below price in an uptrend, above price in a downtrend.
|
||||
pub value: f64,
|
||||
/// Trend direction: `+1.0` long (stop below price), `-1.0` short.
|
||||
pub direction: f64,
|
||||
}
|
||||
|
||||
/// Sample standard deviation from a running `(sum, sum_of_squares, count)`.
|
||||
fn sample_stddev(sum: f64, sum_sq: f64, count: usize) -> f64 {
|
||||
let n = count as f64;
|
||||
let mean = sum / n;
|
||||
(((sum_sq - n * mean * mean) / (n - 1.0)).max(0.0)).sqrt()
|
||||
}
|
||||
|
||||
/// Kase `DevStop` — Cynthia Kase's volatility stop, built on the **standard
|
||||
/// deviation of the two-bar true range** rather than a single-bar ATR.
|
||||
///
|
||||
/// ```text
|
||||
/// DTR_t = max(high_t, high_{t−1}) − min(low_t, low_{t−1}) (two-bar range)
|
||||
/// band = mean(DTR, period) + dev · stddev(DTR, period)
|
||||
/// long stop = ratchet_up( highest_high_since_flip − band )
|
||||
/// short stop = ratchet_down( lowest_low_since_flip + band )
|
||||
/// ```
|
||||
///
|
||||
/// Kase observed that range expansion is better captured by a two-bar range than
|
||||
/// a one-bar one, and that subtracting a *standard-deviation* band (not a fixed
|
||||
/// ATR multiple) adapts the stop to changing volatility. The stop trails the
|
||||
/// extreme reached since the last reversal — ratcheting only in the trend's favour
|
||||
/// — and flips sides when price closes through it. `dev` selects which `DevStop`
|
||||
/// line to follow (`1`, `2` or `3` standard deviations are Kase's warning lines).
|
||||
///
|
||||
/// The first bar seeds the prior candle; the next `period` two-bar ranges seed the
|
||||
/// mean and standard deviation, so the first stop lands after `period + 1` inputs.
|
||||
/// Each `update` is O(1).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, KaseDevStop};
|
||||
///
|
||||
/// let mut indicator = KaseDevStop::new(30, 1.0).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..80 {
|
||||
/// let base = 100.0 + f64::from(i);
|
||||
/// let c = Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 1_000.0, 0).unwrap();
|
||||
/// last = indicator.update(c);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct KaseDevStop {
|
||||
period: usize,
|
||||
dev: f64,
|
||||
prev: Option<Candle>,
|
||||
window: VecDeque<f64>,
|
||||
sum: f64,
|
||||
sum_sq: f64,
|
||||
direction: f64,
|
||||
extreme: f64,
|
||||
stop: f64,
|
||||
last: Option<KaseDevStopOutput>,
|
||||
}
|
||||
|
||||
impl KaseDevStop {
|
||||
/// Construct a Kase `DevStop` with the given lookback `period` and
|
||||
/// standard-deviation multiplier `dev`.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::InvalidPeriod`] if `period < 2` (a standard deviation
|
||||
/// needs at least two samples) and [`Error::NonPositiveMultiplier`] if `dev`
|
||||
/// is not finite and positive.
|
||||
pub fn new(period: usize, dev: f64) -> Result<Self> {
|
||||
if period < 2 {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "Kase DevStop period must be >= 2",
|
||||
});
|
||||
}
|
||||
if !dev.is_finite() || dev <= 0.0 {
|
||||
return Err(Error::NonPositiveMultiplier);
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
dev,
|
||||
prev: None,
|
||||
window: VecDeque::with_capacity(period),
|
||||
sum: 0.0,
|
||||
sum_sq: 0.0,
|
||||
direction: 0.0,
|
||||
extreme: 0.0,
|
||||
stop: 0.0,
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured `(period, dev)`.
|
||||
pub const fn params(&self) -> (usize, f64) {
|
||||
(self.period, self.dev)
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<KaseDevStopOutput> {
|
||||
self.last
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for KaseDevStop {
|
||||
type Input = Candle;
|
||||
type Output = KaseDevStopOutput;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<KaseDevStopOutput> {
|
||||
let Some(prev) = self.prev else {
|
||||
self.prev = Some(candle);
|
||||
return None;
|
||||
};
|
||||
let dtr = candle.high.max(prev.high) - candle.low.min(prev.low);
|
||||
self.prev = Some(candle);
|
||||
|
||||
if self.window.len() == self.period {
|
||||
let old = self.window.pop_front().expect("non-empty");
|
||||
self.sum -= old;
|
||||
self.sum_sq -= old * old;
|
||||
}
|
||||
self.window.push_back(dtr);
|
||||
self.sum += dtr;
|
||||
self.sum_sq += dtr * dtr;
|
||||
if self.window.len() < self.period {
|
||||
return None;
|
||||
}
|
||||
let mean = self.sum / self.period as f64;
|
||||
let band = mean + self.dev * sample_stddev(self.sum, self.sum_sq, self.period);
|
||||
|
||||
if self.direction == 0.0 {
|
||||
// Seed the trend as long off the first fully-warmed bar.
|
||||
self.direction = 1.0;
|
||||
self.extreme = candle.high;
|
||||
self.stop = candle.high - band;
|
||||
} else if self.direction > 0.0 {
|
||||
self.extreme = self.extreme.max(candle.high);
|
||||
let raw = self.extreme - band;
|
||||
self.stop = self.stop.max(raw);
|
||||
if candle.close < self.stop {
|
||||
self.direction = -1.0;
|
||||
self.extreme = candle.low;
|
||||
self.stop = candle.low + band;
|
||||
}
|
||||
} else {
|
||||
self.extreme = self.extreme.min(candle.low);
|
||||
let raw = self.extreme + band;
|
||||
self.stop = self.stop.min(raw);
|
||||
if candle.close > self.stop {
|
||||
self.direction = 1.0;
|
||||
self.extreme = candle.high;
|
||||
self.stop = candle.high - band;
|
||||
}
|
||||
}
|
||||
|
||||
let out = KaseDevStopOutput {
|
||||
value: self.stop,
|
||||
direction: self.direction,
|
||||
};
|
||||
self.last = Some(out);
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.prev = None;
|
||||
self.window.clear();
|
||||
self.sum = 0.0;
|
||||
self.sum_sq = 0.0;
|
||||
self.direction = 0.0;
|
||||
self.extreme = 0.0;
|
||||
self.stop = 0.0;
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period + 1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"KaseDevStop"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
fn c(high: f64, low: f64, close: f64) -> Candle {
|
||||
Candle::new_unchecked(f64::midpoint(high, low), high, low, close, 1_000.0, 0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_params() {
|
||||
assert!(matches!(
|
||||
KaseDevStop::new(1, 1.0),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
KaseDevStop::new(30, 0.0),
|
||||
Err(Error::NonPositiveMultiplier)
|
||||
));
|
||||
assert!(matches!(
|
||||
KaseDevStop::new(30, -1.0),
|
||||
Err(Error::NonPositiveMultiplier)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let k = KaseDevStop::new(30, 1.0).unwrap();
|
||||
assert_eq!(k.params(), (30, 1.0));
|
||||
assert_eq!(k.warmup_period(), 31);
|
||||
assert_eq!(k.name(), "KaseDevStop");
|
||||
assert!(!k.is_ready());
|
||||
assert_eq!(k.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_at_warmup_period() {
|
||||
let mut k = KaseDevStop::new(3, 1.0).unwrap();
|
||||
let candles: Vec<Candle> = (0..8)
|
||||
.map(|i| {
|
||||
let base = 100.0 + f64::from(i);
|
||||
c(base + 1.0, base - 1.0, base)
|
||||
})
|
||||
.collect();
|
||||
let out = k.batch(&candles);
|
||||
let warmup = k.warmup_period(); // 4
|
||||
assert_eq!(warmup, 4);
|
||||
for v in out.iter().take(warmup - 1) {
|
||||
assert!(v.is_none());
|
||||
}
|
||||
assert!(out[warmup - 1].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uptrend_keeps_stop_below_price() {
|
||||
let mut k = KaseDevStop::new(5, 1.0).unwrap();
|
||||
let candles: Vec<Candle> = (0..60)
|
||||
.map(|i| {
|
||||
let base = 100.0 + 2.0 * f64::from(i);
|
||||
c(base + 1.0, base - 1.0, base + 0.5)
|
||||
})
|
||||
.collect();
|
||||
for (o, candle) in k.batch(&candles).into_iter().zip(candles.iter()) {
|
||||
if let Some(o) = o {
|
||||
assert_eq!(o.direction, 1.0, "pure uptrend stays long");
|
||||
assert!(o.value < candle.close, "stop below price");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stop_ratchets_up_in_uptrend() {
|
||||
let mut k = KaseDevStop::new(5, 1.0).unwrap();
|
||||
let candles: Vec<Candle> = (0..60)
|
||||
.map(|i| {
|
||||
let base = 100.0 + 2.0 * f64::from(i);
|
||||
c(base + 1.0, base - 1.0, base + 0.5)
|
||||
})
|
||||
.collect();
|
||||
let mut prev = f64::NEG_INFINITY;
|
||||
for o in k.batch(&candles).into_iter().flatten() {
|
||||
assert!(o.value >= prev, "long stop must not fall");
|
||||
prev = o.value;
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flips_on_reversal() {
|
||||
let mut candles: Vec<Candle> = (0..40)
|
||||
.map(|i| {
|
||||
let base = 100.0 + f64::from(i);
|
||||
c(base + 1.0, base - 1.0, base + 0.5)
|
||||
})
|
||||
.collect();
|
||||
candles.extend((0..40).map(|i| {
|
||||
let base = 140.0 - f64::from(i);
|
||||
c(base + 1.0, base - 1.0, base - 0.5)
|
||||
}));
|
||||
let mut k = KaseDevStop::new(5, 1.0).unwrap();
|
||||
let dirs: Vec<f64> = k
|
||||
.batch(&candles)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|o| o.direction)
|
||||
.collect();
|
||||
assert!(dirs.iter().any(|&d| d > 0.0));
|
||||
assert!(dirs.iter().any(|&d| d < 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut k = KaseDevStop::new(5, 1.0).unwrap();
|
||||
let candles: Vec<Candle> = (0..40)
|
||||
.map(|i| {
|
||||
let base = 100.0 + f64::from(i);
|
||||
c(base + 1.0, base - 1.0, base + 0.5)
|
||||
})
|
||||
.collect();
|
||||
k.batch(&candles);
|
||||
assert!(k.is_ready());
|
||||
k.reset();
|
||||
assert!(!k.is_ready());
|
||||
assert_eq!(k.value(), None);
|
||||
assert_eq!(k.update(candles[0]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..120)
|
||||
.map(|i| {
|
||||
let base = 100.0 + (f64::from(i) * 0.25).sin() * 9.0;
|
||||
c(base + 2.0, base - 1.5, base + 0.5)
|
||||
})
|
||||
.collect();
|
||||
let batch = KaseDevStop::new(20, 2.0).unwrap().batch(&candles);
|
||||
let mut b = KaseDevStop::new(20, 2.0).unwrap();
|
||||
let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,7 @@ mod aroon;
|
||||
mod aroon_oscillator;
|
||||
mod atr;
|
||||
mod atr_bands;
|
||||
mod atr_ratchet;
|
||||
mod atr_trailing_stop;
|
||||
mod auto_fib;
|
||||
mod autocorrelation;
|
||||
@@ -115,6 +116,7 @@ mod ehlers_stochastic;
|
||||
mod ehma;
|
||||
mod elder_impulse;
|
||||
mod elder_ray;
|
||||
mod elder_safezone;
|
||||
mod ema;
|
||||
mod empirical_mode_decomposition;
|
||||
mod engulfing;
|
||||
@@ -192,6 +194,7 @@ mod jump_indicator;
|
||||
mod kagi_bars;
|
||||
mod kalman_hedge_ratio;
|
||||
mod kama;
|
||||
mod kase_devstop;
|
||||
mod kase_permission_stochastic;
|
||||
mod kelly_criterion;
|
||||
mod keltner;
|
||||
@@ -239,11 +242,13 @@ mod mid_point;
|
||||
mod mid_price;
|
||||
mod minus_di;
|
||||
mod minus_dm;
|
||||
mod modified_ma_stop;
|
||||
mod mom;
|
||||
mod morning_doji_star;
|
||||
mod morning_evening_star;
|
||||
mod natr;
|
||||
mod new_highs_new_lows;
|
||||
mod nrtr;
|
||||
mod nvi;
|
||||
mod ob_imbalance_full;
|
||||
mod ob_imbalance_top1;
|
||||
@@ -378,6 +383,7 @@ mod three_stars_in_south;
|
||||
mod thrusting;
|
||||
mod tick_index;
|
||||
mod tii;
|
||||
mod time_based_stop;
|
||||
mod time_of_day_return_profile;
|
||||
mod tpo_profile;
|
||||
mod trade_imbalance;
|
||||
@@ -469,6 +475,7 @@ pub use aroon::{Aroon, AroonOutput};
|
||||
pub use aroon_oscillator::AroonOscillator;
|
||||
pub use atr::Atr;
|
||||
pub use atr_bands::{AtrBands, AtrBandsOutput};
|
||||
pub use atr_ratchet::{AtrRatchet, AtrRatchetOutput};
|
||||
pub use atr_trailing_stop::AtrTrailingStop;
|
||||
pub use auto_fib::{AutoFib, AutoFibOutput};
|
||||
pub use autocorrelation::Autocorrelation;
|
||||
@@ -549,6 +556,7 @@ pub use ehlers_stochastic::EhlersStochastic;
|
||||
pub use ehma::Ehma;
|
||||
pub use elder_impulse::ElderImpulse;
|
||||
pub use elder_ray::{ElderRay, ElderRayOutput};
|
||||
pub use elder_safezone::{ElderSafeZone, ElderSafeZoneOutput};
|
||||
pub use ema::Ema;
|
||||
pub use empirical_mode_decomposition::EmpiricalModeDecomposition;
|
||||
pub use engulfing::Engulfing;
|
||||
@@ -626,6 +634,7 @@ pub use jump_indicator::JumpIndicator;
|
||||
pub use kagi_bars::{KagiBar, KagiBars};
|
||||
pub use kalman_hedge_ratio::{KalmanHedgeRatio, KalmanHedgeRatioOutput};
|
||||
pub use kama::Kama;
|
||||
pub use kase_devstop::{KaseDevStop, KaseDevStopOutput};
|
||||
pub use kase_permission_stochastic::{KasePermissionStochastic, KasePermissionStochasticOutput};
|
||||
pub use kelly_criterion::KellyCriterion;
|
||||
pub use keltner::{Keltner, KeltnerOutput};
|
||||
@@ -673,11 +682,13 @@ pub use mid_point::MidPoint;
|
||||
pub use mid_price::MidPrice;
|
||||
pub use minus_di::MinusDi;
|
||||
pub use minus_dm::MinusDm;
|
||||
pub use modified_ma_stop::{ModifiedMaStop, ModifiedMaStopOutput};
|
||||
pub use mom::Mom;
|
||||
pub use morning_doji_star::MorningDojiStar;
|
||||
pub use morning_evening_star::MorningEveningStar;
|
||||
pub use natr::Natr;
|
||||
pub use new_highs_new_lows::NewHighsNewLows;
|
||||
pub use nrtr::{Nrtr, NrtrOutput};
|
||||
pub use nvi::Nvi;
|
||||
pub use ob_imbalance_full::OrderBookImbalanceFull;
|
||||
pub use ob_imbalance_top1::OrderBookImbalanceTop1;
|
||||
@@ -812,6 +823,7 @@ pub use three_stars_in_south::ThreeStarsInSouth;
|
||||
pub use thrusting::Thrusting;
|
||||
pub use tick_index::TickIndex;
|
||||
pub use tii::Tii;
|
||||
pub use time_based_stop::TimeBasedStop;
|
||||
pub use time_of_day_return_profile::{TimeOfDayReturnProfile, TimeOfDayReturnProfileOutput};
|
||||
pub use tpo_profile::{TpoProfile, TpoProfileOutput};
|
||||
pub use trade_imbalance::TradeImbalance;
|
||||
@@ -1073,6 +1085,12 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
|
||||
"StepTrailingStop",
|
||||
"RenkoTrailingStop",
|
||||
"SarExt",
|
||||
"KaseDevStop",
|
||||
"ElderSafeZone",
|
||||
"AtrRatchet",
|
||||
"Nrtr",
|
||||
"TimeBasedStop",
|
||||
"ModifiedMaStop",
|
||||
],
|
||||
),
|
||||
(
|
||||
@@ -1456,6 +1474,6 @@ mod family_tests {
|
||||
// the actual indicator count is the early-warning signal that an
|
||||
// indicator was added without being assigned a family.
|
||||
let total: usize = FAMILIES.iter().map(|(_, ns)| ns.len()).sum();
|
||||
assert_eq!(total, 434, "FAMILIES total drifted from indicator count");
|
||||
assert_eq!(total, 440, "FAMILIES total drifted from indicator count");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
//! Modified-MA Stop — a trailing stop riding the Modified Moving Average (SMMA).
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::indicators::smma::Smma;
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Output of [`ModifiedMaStop`]: the active stop level and the trend direction.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct ModifiedMaStopOutput {
|
||||
/// The stop level (a directionally-ratcheted Modified Moving Average).
|
||||
pub value: f64,
|
||||
/// Trend direction: `+1.0` long (stop below price), `-1.0` short.
|
||||
pub direction: f64,
|
||||
}
|
||||
|
||||
/// Modified-MA Stop — a trailing stop whose line is the **Modified Moving
|
||||
/// Average** (SMMA / Wilder's RMA) of price, allowed to move only in the trend's
|
||||
/// favour.
|
||||
///
|
||||
/// ```text
|
||||
/// ma = SMMA(close, period) (Modified Moving Average)
|
||||
/// long: stop = max(prev_stop, ma); flip short when close < stop
|
||||
/// short: stop = min(prev_stop, ma); flip long when close > stop
|
||||
/// ```
|
||||
///
|
||||
/// The Modified Moving Average (also called the smoothed or running moving
|
||||
/// average) is the slow, low-lag average Wilder used throughout his systems. Using
|
||||
/// it directly as a trailing line — but **ratcheting** so the long stop never
|
||||
/// falls and the short stop never rises — turns the smooth average into a stop
|
||||
/// that hugs price in a trend and flips when price decisively crosses it. Because
|
||||
/// the SMMA lags, the stop gives trends room while still exiting clean reversals.
|
||||
///
|
||||
/// The first stop lands once the SMMA is ready (`period` inputs). Each `update` is
|
||||
/// O(1).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, ModifiedMaStop};
|
||||
///
|
||||
/// let mut indicator = ModifiedMaStop::new(14).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..60 {
|
||||
/// let base = 100.0 + f64::from(i);
|
||||
/// let c = Candle::new(base, base + 1.0, base - 1.0, base + 0.5, 1_000.0, 0).unwrap();
|
||||
/// last = indicator.update(c);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ModifiedMaStop {
|
||||
smma: Smma,
|
||||
period: usize,
|
||||
direction: f64,
|
||||
stop: f64,
|
||||
last: Option<ModifiedMaStopOutput>,
|
||||
}
|
||||
|
||||
impl ModifiedMaStop {
|
||||
/// Construct a Modified-MA stop with the given SMMA `period`.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if `period == 0`.
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
if period == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
Ok(Self {
|
||||
smma: Smma::new(period)?,
|
||||
period,
|
||||
direction: 0.0,
|
||||
stop: 0.0,
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured SMMA period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<ModifiedMaStopOutput> {
|
||||
self.last
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for ModifiedMaStop {
|
||||
type Input = Candle;
|
||||
type Output = ModifiedMaStopOutput;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<ModifiedMaStopOutput> {
|
||||
let ma = self.smma.update(candle.close)?;
|
||||
let close = candle.close;
|
||||
|
||||
if self.direction == 0.0 {
|
||||
self.direction = if close >= ma { 1.0 } else { -1.0 };
|
||||
self.stop = ma;
|
||||
} else if self.direction > 0.0 {
|
||||
self.stop = self.stop.max(ma);
|
||||
if close < self.stop {
|
||||
self.direction = -1.0;
|
||||
self.stop = ma;
|
||||
}
|
||||
} else {
|
||||
self.stop = self.stop.min(ma);
|
||||
if close > self.stop {
|
||||
self.direction = 1.0;
|
||||
self.stop = ma;
|
||||
}
|
||||
}
|
||||
|
||||
let out = ModifiedMaStopOutput {
|
||||
value: self.stop,
|
||||
direction: self.direction,
|
||||
};
|
||||
self.last = Some(out);
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.smma.reset();
|
||||
self.direction = 0.0;
|
||||
self.stop = 0.0;
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"ModifiedMaStop"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
fn c(close: f64) -> Candle {
|
||||
Candle::new_unchecked(close, close + 1.0, close - 1.0, close, 1_000.0, 0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(matches!(ModifiedMaStop::new(0), Err(Error::PeriodZero)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let m = ModifiedMaStop::new(14).unwrap();
|
||||
assert_eq!(m.period(), 14);
|
||||
assert_eq!(m.warmup_period(), 14);
|
||||
assert_eq!(m.name(), "ModifiedMaStop");
|
||||
assert!(!m.is_ready());
|
||||
assert_eq!(m.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_at_warmup_period() {
|
||||
let mut m = ModifiedMaStop::new(5).unwrap();
|
||||
let candles: Vec<Candle> = (0..12).map(|i| c(100.0 + f64::from(i))).collect();
|
||||
let out = m.batch(&candles);
|
||||
for v in out.iter().take(4) {
|
||||
assert!(v.is_none());
|
||||
}
|
||||
assert!(out[4].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uptrend_keeps_stop_below_price() {
|
||||
let mut m = ModifiedMaStop::new(5).unwrap();
|
||||
let candles: Vec<Candle> = (0..60).map(|i| c(100.0 + 2.0 * f64::from(i))).collect();
|
||||
for (o, candle) in m.batch(&candles).into_iter().zip(candles.iter()) {
|
||||
if let Some(o) = o {
|
||||
assert_eq!(o.direction, 1.0);
|
||||
assert!(o.value < candle.close);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_stop_ratchets_up() {
|
||||
let mut m = ModifiedMaStop::new(5).unwrap();
|
||||
let candles: Vec<Candle> = (0..60).map(|i| c(100.0 + 2.0 * f64::from(i))).collect();
|
||||
let mut prev = f64::NEG_INFINITY;
|
||||
for o in m.batch(&candles).into_iter().flatten() {
|
||||
assert_eq!(o.direction, 1.0, "pure uptrend stays long");
|
||||
assert!(o.value >= prev, "long stop must not fall");
|
||||
prev = o.value;
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flips_on_reversal() {
|
||||
let mut candles: Vec<Candle> = (0..40).map(|i| c(100.0 + f64::from(i))).collect();
|
||||
candles.extend((0..40).map(|i| c(140.0 - f64::from(i))));
|
||||
let mut m = ModifiedMaStop::new(5).unwrap();
|
||||
let dirs: Vec<f64> = m
|
||||
.batch(&candles)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|o| o.direction)
|
||||
.collect();
|
||||
assert!(dirs.iter().any(|&d| d > 0.0));
|
||||
assert!(dirs.iter().any(|&d| d < 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut m = ModifiedMaStop::new(5).unwrap();
|
||||
m.batch(&(0..40).map(|i| c(100.0 + f64::from(i))).collect::<Vec<_>>());
|
||||
assert!(m.is_ready());
|
||||
m.reset();
|
||||
assert!(!m.is_ready());
|
||||
assert_eq!(m.value(), None);
|
||||
assert_eq!(m.update(c(100.0)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..120)
|
||||
.map(|i| c(100.0 + (f64::from(i) * 0.25).sin() * 9.0))
|
||||
.collect();
|
||||
let batch = ModifiedMaStop::new(14).unwrap().batch(&candles);
|
||||
let mut b = ModifiedMaStop::new(14).unwrap();
|
||||
let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
//! NRTR — Nick Rypock Trailing Reverse, a percentage trailing-reverse stop.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Output of [`Nrtr`]: the trailing-reverse line and the trend direction.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct NrtrOutput {
|
||||
/// The NRTR line — below price in an uptrend, above price in a downtrend.
|
||||
pub value: f64,
|
||||
/// Trend direction: `+1.0` up (line below price), `-1.0` down.
|
||||
pub direction: f64,
|
||||
}
|
||||
|
||||
/// NRTR (Nick Rypock Trailing Reverse) — a **percentage** trailing-reverse stop
|
||||
/// that follows the trend extreme and flips when price retraces by a fixed
|
||||
/// percentage.
|
||||
///
|
||||
/// ```text
|
||||
/// uptrend: high_water = max(high_water, close)
|
||||
/// line = high_water · (1 − pct/100)
|
||||
/// flip down when close < line (reseed low_water = close)
|
||||
/// downtrend: low_water = min(low_water, close)
|
||||
/// line = low_water · (1 + pct/100)
|
||||
/// flip up when close > line (reseed high_water = close)
|
||||
/// ```
|
||||
///
|
||||
/// Unlike volatility stops (ATR, σ-of-range), NRTR uses a pure **percentage**
|
||||
/// retracement: the line trails the highest close reached in the up-leg at a
|
||||
/// fixed `pct` below it, and a close that gives back that percentage reverses the
|
||||
/// trend, handing the line to the opposite extreme. This makes it scale-free and
|
||||
/// trivially tunable — one number sets how much retracement you tolerate. It
|
||||
/// differs from a fixed percentage *stop-loss* in that it **reverses** (tracks
|
||||
/// both directions) rather than just exiting.
|
||||
///
|
||||
/// The first bar seeds the up-trend and emits a line immediately. Each `update` is
|
||||
/// O(1).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, Nrtr};
|
||||
///
|
||||
/// let mut indicator = Nrtr::new(2.0).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..40 {
|
||||
/// let close = 100.0 + f64::from(i);
|
||||
/// let c = Candle::new(close, close + 0.5, close - 0.5, close, 1_000.0, 0).unwrap();
|
||||
/// last = indicator.update(c);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Nrtr {
|
||||
pct: f64,
|
||||
direction: f64,
|
||||
water: f64,
|
||||
last: Option<NrtrOutput>,
|
||||
}
|
||||
|
||||
impl Nrtr {
|
||||
/// Construct an NRTR with the given trailing percentage (e.g. `2.0` for 2%).
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::InvalidParameter`] if `pct` is not finite or is outside
|
||||
/// `(0, 100)`.
|
||||
pub fn new(pct: f64) -> Result<Self> {
|
||||
if !pct.is_finite() || pct <= 0.0 || pct >= 100.0 {
|
||||
return Err(Error::InvalidParameter {
|
||||
message: "NRTR percentage must be in (0, 100)",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
pct,
|
||||
direction: 0.0,
|
||||
water: 0.0,
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured trailing percentage.
|
||||
pub const fn pct(&self) -> f64 {
|
||||
self.pct
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<NrtrOutput> {
|
||||
self.last
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for Nrtr {
|
||||
type Input = Candle;
|
||||
type Output = NrtrOutput;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<NrtrOutput> {
|
||||
let close = candle.close;
|
||||
let down = self.pct / 100.0;
|
||||
let up = self.pct / 100.0;
|
||||
|
||||
if self.direction == 0.0 {
|
||||
self.direction = 1.0;
|
||||
self.water = close;
|
||||
} else if self.direction > 0.0 {
|
||||
self.water = self.water.max(close);
|
||||
let line = self.water * (1.0 - down);
|
||||
if close < line {
|
||||
self.direction = -1.0;
|
||||
self.water = close;
|
||||
}
|
||||
} else {
|
||||
self.water = self.water.min(close);
|
||||
let line = self.water * (1.0 + up);
|
||||
if close > line {
|
||||
self.direction = 1.0;
|
||||
self.water = close;
|
||||
}
|
||||
}
|
||||
|
||||
let line = if self.direction > 0.0 {
|
||||
self.water * (1.0 - down)
|
||||
} else {
|
||||
self.water * (1.0 + up)
|
||||
};
|
||||
let out = NrtrOutput {
|
||||
value: line,
|
||||
direction: self.direction,
|
||||
};
|
||||
self.last = Some(out);
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.direction = 0.0;
|
||||
self.water = 0.0;
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"Nrtr"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
fn c(close: f64) -> Candle {
|
||||
Candle::new_unchecked(close, close, close, close, 1_000.0, 0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_pct() {
|
||||
assert!(matches!(
|
||||
Nrtr::new(0.0),
|
||||
Err(Error::InvalidParameter { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
Nrtr::new(100.0),
|
||||
Err(Error::InvalidParameter { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
Nrtr::new(f64::NAN),
|
||||
Err(Error::InvalidParameter { .. })
|
||||
));
|
||||
assert!(Nrtr::new(2.0).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let n = Nrtr::new(2.0).unwrap();
|
||||
assert_eq!(n.pct(), 2.0);
|
||||
assert_eq!(n.warmup_period(), 1);
|
||||
assert_eq!(n.name(), "Nrtr");
|
||||
assert!(!n.is_ready());
|
||||
assert_eq!(n.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_bar_emits_up_line() {
|
||||
let mut n = Nrtr::new(10.0).unwrap();
|
||||
let o = n.update(c(100.0)).unwrap();
|
||||
assert_eq!(o.direction, 1.0);
|
||||
// line = 100 * (1 - 0.10) = 90.
|
||||
assert!((o.value - 90.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uptrend_keeps_line_below_price() {
|
||||
let mut n = Nrtr::new(5.0).unwrap();
|
||||
let candles: Vec<Candle> = (0..40).map(|i| c(100.0 + f64::from(i))).collect();
|
||||
for (o, candle) in n.batch(&candles).into_iter().zip(candles.iter()) {
|
||||
let o = o.unwrap();
|
||||
assert_eq!(o.direction, 1.0);
|
||||
assert!(o.value < candle.close);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reverses_on_retracement() {
|
||||
let mut n = Nrtr::new(5.0).unwrap();
|
||||
// Rise to 120, then drop sharply -> a >5% retracement reverses the trend.
|
||||
let mut candles: Vec<Candle> = (0..20).map(|i| c(100.0 + f64::from(i))).collect();
|
||||
candles.extend((0..10).map(|i| c(119.0 - 3.0 * f64::from(i))));
|
||||
let dirs: Vec<f64> = n
|
||||
.batch(&candles)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|o| o.direction)
|
||||
.collect();
|
||||
assert!(dirs.iter().any(|&d| d > 0.0));
|
||||
assert!(dirs.iter().any(|&d| d < 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn downtrend_keeps_line_above_price() {
|
||||
let mut n = Nrtr::new(5.0).unwrap();
|
||||
// Establish a downtrend after an initial bar.
|
||||
let mut candles = vec![c(100.0)];
|
||||
candles.extend((0..30).map(|i| c(80.0 - f64::from(i))));
|
||||
let out = n.batch(&candles);
|
||||
let o = out.last().unwrap().unwrap();
|
||||
let candle = candles.last().unwrap();
|
||||
assert_eq!(o.direction, -1.0);
|
||||
assert!(o.value > candle.close);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut n = Nrtr::new(2.0).unwrap();
|
||||
n.batch(&(0..20).map(|i| c(100.0 + f64::from(i))).collect::<Vec<_>>());
|
||||
assert!(n.is_ready());
|
||||
n.reset();
|
||||
assert!(!n.is_ready());
|
||||
assert_eq!(n.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..120)
|
||||
.map(|i| c(100.0 + (f64::from(i) * 0.25).sin() * 15.0))
|
||||
.collect();
|
||||
let batch = Nrtr::new(3.0).unwrap().batch(&candles);
|
||||
let mut b = Nrtr::new(3.0).unwrap();
|
||||
let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
//! Time-Based Stop — a holding-period timer that fires after a fixed bar count.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Time-Based Stop — exits a position purely on **elapsed bars**, independent of
|
||||
/// price.
|
||||
///
|
||||
/// ```text
|
||||
/// bars_held increments by 1 each bar (since the last reset)
|
||||
/// progress = min(bars_held / max_bars, 1.0) in [0, 1]
|
||||
/// stop fires when progress == 1.0 (bars_held >= max_bars)
|
||||
/// ```
|
||||
///
|
||||
/// Some setups should not be given unlimited time to work: a mean-reversion entry
|
||||
/// that has not reverted within `max_bars`, or an event trade whose catalyst has
|
||||
/// passed, is best closed regardless of price. This indicator is a pure timer —
|
||||
/// it ignores the candle's prices entirely and reports the fraction of the
|
||||
/// holding window that has elapsed, reaching `1.0` (the stop) after `max_bars`
|
||||
/// bars. **Call [`reset`](Indicator::reset) on each new entry** so the timer
|
||||
/// restarts from the position open.
|
||||
///
|
||||
/// Each `update` is O(1) and the first bar already emits a value
|
||||
/// (`1 / max_bars`).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, TimeBasedStop};
|
||||
///
|
||||
/// let mut indicator = TimeBasedStop::new(5).unwrap();
|
||||
/// let c = Candle::new(100.0, 101.0, 99.0, 100.0, 1.0, 0).unwrap();
|
||||
/// // Five bars reach the stop.
|
||||
/// let mut last = 0.0;
|
||||
/// for _ in 0..5 {
|
||||
/// last = indicator.update(c).unwrap();
|
||||
/// }
|
||||
/// assert_eq!(last, 1.0);
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TimeBasedStop {
|
||||
max_bars: usize,
|
||||
bars_held: usize,
|
||||
last: Option<f64>,
|
||||
}
|
||||
|
||||
impl TimeBasedStop {
|
||||
/// Construct a time-based stop that fires after `max_bars` bars.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if `max_bars == 0`.
|
||||
pub fn new(max_bars: usize) -> Result<Self> {
|
||||
if max_bars == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
Ok(Self {
|
||||
max_bars,
|
||||
bars_held: 0,
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured maximum holding period in bars.
|
||||
pub const fn max_bars(&self) -> usize {
|
||||
self.max_bars
|
||||
}
|
||||
|
||||
/// Number of bars held since the last reset.
|
||||
pub const fn bars_held(&self) -> usize {
|
||||
self.bars_held
|
||||
}
|
||||
|
||||
/// Whether the stop has fired (the holding period has fully elapsed).
|
||||
pub const fn triggered(&self) -> bool {
|
||||
self.bars_held >= self.max_bars
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for TimeBasedStop {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, _candle: Candle) -> Option<f64> {
|
||||
self.bars_held += 1;
|
||||
let progress = (self.bars_held as f64 / self.max_bars as f64).min(1.0);
|
||||
self.last = Some(progress);
|
||||
Some(progress)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.bars_held = 0;
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"TimeBasedStop"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn c() -> Candle {
|
||||
Candle::new_unchecked(100.0, 101.0, 99.0, 100.0, 1.0, 0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_max_bars() {
|
||||
assert!(matches!(TimeBasedStop::new(0), Err(Error::PeriodZero)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let t = TimeBasedStop::new(5).unwrap();
|
||||
assert_eq!(t.max_bars(), 5);
|
||||
assert_eq!(t.bars_held(), 0);
|
||||
assert!(!t.triggered());
|
||||
assert_eq!(t.warmup_period(), 1);
|
||||
assert_eq!(t.name(), "TimeBasedStop");
|
||||
assert!(!t.is_ready());
|
||||
assert_eq!(t.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn progress_climbs_to_one() {
|
||||
let mut t = TimeBasedStop::new(4).unwrap();
|
||||
let out = t.batch(&[c(), c(), c(), c()]);
|
||||
assert_relative_eq!(out[0].unwrap(), 0.25, epsilon = 1e-12);
|
||||
assert_relative_eq!(out[1].unwrap(), 0.50, epsilon = 1e-12);
|
||||
assert_relative_eq!(out[2].unwrap(), 0.75, epsilon = 1e-12);
|
||||
assert_relative_eq!(out[3].unwrap(), 1.00, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn triggers_after_max_bars() {
|
||||
let mut t = TimeBasedStop::new(3).unwrap();
|
||||
t.update(c());
|
||||
assert!(!t.triggered());
|
||||
t.update(c());
|
||||
assert!(!t.triggered());
|
||||
t.update(c());
|
||||
assert!(t.triggered());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn progress_saturates_at_one() {
|
||||
// Beyond max_bars the progress stays clamped at 1.0.
|
||||
let mut t = TimeBasedStop::new(2).unwrap();
|
||||
let out = t.batch(&[c(), c(), c(), c()]);
|
||||
assert_relative_eq!(out[2].unwrap(), 1.0, epsilon = 1e-12);
|
||||
assert_relative_eq!(out[3].unwrap(), 1.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_restarts_timer() {
|
||||
let mut t = TimeBasedStop::new(3).unwrap();
|
||||
t.batch(&[c(), c(), c()]);
|
||||
assert!(t.triggered());
|
||||
t.reset();
|
||||
assert!(!t.is_ready());
|
||||
assert_eq!(t.bars_held(), 0);
|
||||
assert!(!t.triggered());
|
||||
assert_relative_eq!(t.update(c()).unwrap(), 1.0 / 3.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles = [c(); 10];
|
||||
let batch = TimeBasedStop::new(4).unwrap().batch(&candles);
|
||||
let mut b = TimeBasedStop::new(4).unwrap();
|
||||
let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -60,9 +60,9 @@ pub use indicators::{
|
||||
AcceleratorOscillator, AdOscillator, AdVolumeLine, AdaptiveCycle, AdaptiveLaguerreFilter, Adl,
|
||||
AdvanceBlock, AdvanceDecline, AdvanceDeclineRatio, Adx, AdxOutput, Adxr, Alligator,
|
||||
AlligatorOutput, Alma, Alpha, AmihudIlliquidity, AnchoredRsi, AnchoredVwap, Apo, Aroon,
|
||||
AroonOscillator, AroonOutput, Atr, AtrBands, AtrBandsOutput, AtrTrailingStop, AutoFib,
|
||||
AutoFibOutput, Autocorrelation, AverageDailyRange, AverageDrawdown, AvgPrice,
|
||||
AwesomeOscillator, AwesomeOscillatorHistogram, BalanceOfPower, Bat, BeltHold, Beta,
|
||||
AroonOscillator, AroonOutput, Atr, AtrBands, AtrBandsOutput, AtrRatchet, AtrRatchetOutput,
|
||||
AtrTrailingStop, AutoFib, AutoFibOutput, Autocorrelation, AverageDailyRange, AverageDrawdown,
|
||||
AvgPrice, AwesomeOscillator, AwesomeOscillatorHistogram, BalanceOfPower, Bat, BeltHold, Beta,
|
||||
BetaNeutralSpread, BipowerVariation, BodySizePct, BollingerBands, BollingerBandwidth,
|
||||
BollingerOutput, BomarBands, BomarBandsOutput, BreadthThrust, Breakaway, BullishPercentIndex,
|
||||
Butterfly, CalendarSpread, CalmarRatio, Camarilla, CamarillaPivotsOutput, Cci, CenterOfGravity,
|
||||
@@ -77,34 +77,35 @@ pub use indicators::{
|
||||
DonchianOutput, DonchianStop, DonchianStopOutput, DoubleBollinger, DoubleBollingerOutput,
|
||||
DoubleTopBottom, DownsideGapThreeMethods, Dpo, DragonflyDoji, DrawdownDuration, Dx,
|
||||
DynamicMomentumIndex, EaseOfMovement, EffectiveSpread, EhlersStochastic, Ehma, ElderImpulse,
|
||||
ElderRay, ElderRayOutput, Ema, EmpiricalModeDecomposition, Engulfing, EveningDojiStar, Evwma,
|
||||
EwmaVolatility, Expectancy, FallingThreeMethods, Fama, FibArcs, FibArcsOutput, FibChannel,
|
||||
FibChannelOutput, FibConfluence, FibConfluenceOutput, FibExtension, FibExtensionOutput, FibFan,
|
||||
FibFanOutput, FibProjection, FibProjectionOutput, FibRetracement, FibRetracementOutput,
|
||||
FibTimeZones, FibTimeZonesOutput, FibonacciPivots, FibonacciPivotsOutput, FisherRsi,
|
||||
FisherTransform, FlagPennant, Footprint, FootprintOutput, ForceIndex, FractalChaosBands,
|
||||
FractalChaosBandsOutput, Frama, FundingBasis, FundingRate, FundingRateMean, FundingRateZScore,
|
||||
GainLossRatio, GapSideBySideWhite, Garch11, GarmanKlassVolatility, Gartley, GatorOscillator,
|
||||
GatorOscillatorOutput, GeneralizedDema, GeometricMa, GoldenPocket, GoldenPocketOutput,
|
||||
GrangerCausality, GravestoneDoji, Hammer, HangingMan, Harami, HeadAndShoulders, HeikinAshi,
|
||||
HeikinAshiOutput, HiLoActivator, HighLowIndex, HighLowRange, HighWave, Hikkake,
|
||||
HikkakeModified, HilbertDominantCycle, HistoricalVolatility, Hma, HoltWinters, HomingPigeon,
|
||||
HtDcPhase, HtPhasor, HtPhasorOutput, HtTrendMode, HurstChannel, HurstChannelOutput,
|
||||
HurstExponent, Ichimoku, IchimokuOutput, IdenticalThreeCrows, InNeck, Inertia,
|
||||
InformationRatio, InitialBalance, InitialBalanceOutput, InstantaneousTrendline,
|
||||
IntradayMomentumIndex, IntradayVolatilityProfile, IntradayVolatilityProfileOutput,
|
||||
InverseFisherTransform, InvertedHammer, Jma, JumpIndicator, KagiBars, KalmanHedgeRatio,
|
||||
KalmanHedgeRatioOutput, Kama, KasePermissionStochastic, KasePermissionStochasticOutput,
|
||||
KellyCriterion, Keltner, KeltnerOutput, Kicking, KickingByLength, Kst, KstOutput, Kurtosis,
|
||||
Kvo, KylesLambda, LadderBottom, LaguerreRsi, LeadLagCrossCorrelation,
|
||||
LeadLagCrossCorrelationOutput, LinRegAngle, LinRegChannel, LinRegChannelOutput,
|
||||
LinRegIntercept, LinRegSlope, LinearRegression, LiquidationFeatures, LiquidationFeaturesOutput,
|
||||
LogReturn, LongLeggedDoji, LongLine, LongShortRatio, MaEnvelope, MaEnvelopeOutput, MacdExt,
|
||||
MacdFix, MacdHistogram, MacdIndicator, MacdOutput, Mama, MamaOutput, MarketFacilitationIndex,
|
||||
Marubozu, MassIndex, MatHold, MatchingLow, MaxDrawdown, McClellanOscillator,
|
||||
McClellanSummationIndex, McGinleyDynamic, MedianAbsoluteDeviation, MedianChannel,
|
||||
MedianChannelOutput, MedianMa, MedianPrice, Mfi, Microprice, MidPoint, MidPrice, MinusDi,
|
||||
MinusDm, Mom, MorningDojiStar, MorningEveningStar, Natr, NewHighsNewLows, Nvi,
|
||||
ElderRay, ElderRayOutput, ElderSafeZone, ElderSafeZoneOutput, Ema, EmpiricalModeDecomposition,
|
||||
Engulfing, EveningDojiStar, Evwma, EwmaVolatility, Expectancy, FallingThreeMethods, Fama,
|
||||
FibArcs, FibArcsOutput, FibChannel, FibChannelOutput, FibConfluence, FibConfluenceOutput,
|
||||
FibExtension, FibExtensionOutput, FibFan, FibFanOutput, FibProjection, FibProjectionOutput,
|
||||
FibRetracement, FibRetracementOutput, FibTimeZones, FibTimeZonesOutput, FibonacciPivots,
|
||||
FibonacciPivotsOutput, FisherRsi, FisherTransform, FlagPennant, Footprint, FootprintOutput,
|
||||
ForceIndex, FractalChaosBands, FractalChaosBandsOutput, Frama, FundingBasis, FundingRate,
|
||||
FundingRateMean, FundingRateZScore, GainLossRatio, GapSideBySideWhite, Garch11,
|
||||
GarmanKlassVolatility, Gartley, GatorOscillator, GatorOscillatorOutput, GeneralizedDema,
|
||||
GeometricMa, GoldenPocket, GoldenPocketOutput, GrangerCausality, GravestoneDoji, Hammer,
|
||||
HangingMan, Harami, HeadAndShoulders, HeikinAshi, HeikinAshiOutput, HiLoActivator,
|
||||
HighLowIndex, HighLowRange, HighWave, Hikkake, HikkakeModified, HilbertDominantCycle,
|
||||
HistoricalVolatility, Hma, HoltWinters, HomingPigeon, HtDcPhase, HtPhasor, HtPhasorOutput,
|
||||
HtTrendMode, HurstChannel, HurstChannelOutput, HurstExponent, Ichimoku, IchimokuOutput,
|
||||
IdenticalThreeCrows, InNeck, Inertia, InformationRatio, InitialBalance, InitialBalanceOutput,
|
||||
InstantaneousTrendline, IntradayMomentumIndex, IntradayVolatilityProfile,
|
||||
IntradayVolatilityProfileOutput, InverseFisherTransform, InvertedHammer, Jma, JumpIndicator,
|
||||
KagiBars, KalmanHedgeRatio, KalmanHedgeRatioOutput, Kama, KaseDevStop, KaseDevStopOutput,
|
||||
KasePermissionStochastic, KasePermissionStochasticOutput, KellyCriterion, Keltner,
|
||||
KeltnerOutput, Kicking, KickingByLength, Kst, KstOutput, Kurtosis, Kvo, KylesLambda,
|
||||
LadderBottom, LaguerreRsi, LeadLagCrossCorrelation, LeadLagCrossCorrelationOutput, LinRegAngle,
|
||||
LinRegChannel, LinRegChannelOutput, LinRegIntercept, LinRegSlope, LinearRegression,
|
||||
LiquidationFeatures, LiquidationFeaturesOutput, LogReturn, LongLeggedDoji, LongLine,
|
||||
LongShortRatio, MaEnvelope, MaEnvelopeOutput, MacdExt, MacdFix, MacdHistogram, MacdIndicator,
|
||||
MacdOutput, Mama, MamaOutput, MarketFacilitationIndex, Marubozu, MassIndex, MatHold,
|
||||
MatchingLow, MaxDrawdown, McClellanOscillator, McClellanSummationIndex, McGinleyDynamic,
|
||||
MedianAbsoluteDeviation, MedianChannel, MedianChannelOutput, MedianMa, MedianPrice, Mfi,
|
||||
Microprice, MidPoint, MidPrice, MinusDi, MinusDm, ModifiedMaStop, ModifiedMaStopOutput, Mom,
|
||||
MorningDojiStar, MorningEveningStar, Natr, NewHighsNewLows, Nrtr, NrtrOutput, Nvi,
|
||||
OIPriceDivergence, OIWeighted, Obv, OmegaRatio, OnNeck, OpenInterestDelta, OpeningMarubozu,
|
||||
OpeningRange, OpeningRangeOutput, OrderBookImbalanceFull, OrderBookImbalanceTop1,
|
||||
OrderBookImbalanceTopN, OrderFlowImbalance, OuHalfLife, OvernightGap, OvernightIntradayReturn,
|
||||
@@ -129,18 +130,18 @@ pub use indicators::{
|
||||
TdLines, TdLinesOutput, TdOpen, TdPressure, TdRangeProjection, TdRangeProjectionOutput, TdRei,
|
||||
TdRiskLevel, TdRiskLevelOutput, TdSequential, TdSequentialOutput, TdSetup, Tema,
|
||||
TermStructureBasis, ThreeDrives, ThreeInside, ThreeLineStrike, ThreeOutside,
|
||||
ThreeSoldiersOrCrows, ThreeStarsInSouth, Thrusting, TickIndex, Tii, TimeOfDayReturnProfile,
|
||||
TimeOfDayReturnProfileOutput, TpoProfile, TpoProfileOutput, TradeImbalance, TrendLabel,
|
||||
TrendStrengthIndex, TreynorRatio, Triangle, Trima, Trin, TripleTopBottom, Trix, TrueRange, Tsf,
|
||||
TsfOscillator, Tsi, Tsv, TtmSqueeze, TtmSqueezeOutput, TtmTrend, TurnOfMonth, Tweezer,
|
||||
TwoCrows, TypicalPrice, UlcerIndex, UltimateOscillator, UniqueThreeRiver, UpDownVolumeRatio,
|
||||
UpsideGapThreeMethods, UpsideGapTwoCrows, ValueArea, ValueAreaOutput, ValueAtRisk, Variance,
|
||||
VarianceRatio, VerticalHorizontalFilter, Vidya, VolatilityCone, VolatilityConeOutput,
|
||||
VolatilityOfVolatility, VolatilityRatio, VoltyStop, VolumeByTimeProfile,
|
||||
VolumeByTimeProfileOutput, VolumeOscillator, VolumePriceTrend, VolumeProfile,
|
||||
VolumeProfileOutput, Vortex, VortexOutput, Vpin, Vwap, VwapStdDevBands, VwapStdDevBandsOutput,
|
||||
Vwma, Vzo, WavePm, WaveTrend, WaveTrendOutput, Wedge, WeightedClose, WickRatio,
|
||||
WilliamsFractals, WilliamsFractalsOutput, WilliamsR, WinRate, Wma, WoodiePivots,
|
||||
ThreeSoldiersOrCrows, ThreeStarsInSouth, Thrusting, TickIndex, Tii, TimeBasedStop,
|
||||
TimeOfDayReturnProfile, TimeOfDayReturnProfileOutput, TpoProfile, TpoProfileOutput,
|
||||
TradeImbalance, TrendLabel, TrendStrengthIndex, TreynorRatio, Triangle, Trima, Trin,
|
||||
TripleTopBottom, Trix, TrueRange, Tsf, TsfOscillator, Tsi, Tsv, TtmSqueeze, TtmSqueezeOutput,
|
||||
TtmTrend, TurnOfMonth, Tweezer, TwoCrows, TypicalPrice, UlcerIndex, UltimateOscillator,
|
||||
UniqueThreeRiver, UpDownVolumeRatio, UpsideGapThreeMethods, UpsideGapTwoCrows, ValueArea,
|
||||
ValueAreaOutput, ValueAtRisk, Variance, VarianceRatio, VerticalHorizontalFilter, Vidya,
|
||||
VolatilityCone, VolatilityConeOutput, VolatilityOfVolatility, VolatilityRatio, VoltyStop,
|
||||
VolumeByTimeProfile, VolumeByTimeProfileOutput, VolumeOscillator, VolumePriceTrend,
|
||||
VolumeProfile, VolumeProfileOutput, Vortex, VortexOutput, Vpin, Vwap, VwapStdDevBands,
|
||||
VwapStdDevBandsOutput, Vwma, Vzo, WavePm, WaveTrend, WaveTrendOutput, Wedge, WeightedClose,
|
||||
WickRatio, WilliamsFractals, WilliamsFractalsOutput, WilliamsR, WinRate, Wma, WoodiePivots,
|
||||
WoodiePivotsOutput, YangZhangVolatility, YoyoExit, ZScore, ZeroLagMacd, ZeroLagMacdOutput,
|
||||
ZigZag, ZigZagOutput, Zlema, FAMILIES, T3,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user