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

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

Rounds out the Trailing Stops family from 5 to 12 indicators:

- HiLoActivator (Crabel): SMA-of-high/SMA-of-low trail with a one-bar
  lag; emits the opposite-side SMA as the trailing stop.
- VoltyStop (Cynthia Kase): ATR trail anchored on the extreme close
  since the trade was opened — tighter than AtrTrailingStop on
  pullbacks.
- YoyoExit: long-only ATR trail with an explicit re-entry trigger at
  trail + multiplier*ATR; exposes an in_trade flag.
- DonchianStop (Turtle): lowest low / highest high over the window;
  multi-output {stop_long, stop_short}.
- PercentageTrailingStop: fixed-percent trail that scales across
  instruments without per-asset tuning.
- StepTrailingStop: snaps to a step_size-aligned grid; mirrors
  discretionary stop-by-hand workflow.
- RenkoTrailingStop: block-anchored trail; only moves on full-block
  advances, ignores intra-block noise.

All seven are wired into wickra-core, the Python / Node / WASM
bindings, the indicator_update + indicator_update_candle fuzz targets,
the wickra bench harness, and the Python + Node test suites. README
counter bumps from 71 to 78; CHANGELOG entry under [Unreleased].

* fix(family-09): satisfy pedantic clippy lints

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