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:
@@ -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
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(...)`.
|
||||
|
||||
@@ -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<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,7 @@ mod coppock;
|
||||
mod dema;
|
||||
mod demand_index;
|
||||
mod donchian;
|
||||
mod donchian_stop;
|
||||
mod double_bollinger;
|
||||
mod dpo;
|
||||
mod ease_of_movement;
|
||||
@@ -48,6 +49,7 @@ mod force_index;
|
||||
mod fractal_chaos_bands;
|
||||
mod frama;
|
||||
mod garman_klass;
|
||||
mod hilo_activator;
|
||||
mod historical_volatility;
|
||||
mod hma;
|
||||
mod hurst_channel;
|
||||
@@ -75,11 +77,13 @@ mod nvi;
|
||||
mod obv;
|
||||
mod parkinson;
|
||||
mod percent_b;
|
||||
mod percentage_trailing_stop;
|
||||
mod pgo;
|
||||
mod pmo;
|
||||
mod ppo;
|
||||
mod psar;
|
||||
mod pvi;
|
||||
mod renko_trailing_stop;
|
||||
mod roc;
|
||||
mod rogers_satchell;
|
||||
mod rsi;
|
||||
@@ -93,6 +97,7 @@ mod standard_error_bands;
|
||||
mod starc_bands;
|
||||
mod stc;
|
||||
mod std_dev;
|
||||
mod step_trailing_stop;
|
||||
mod stoch_rsi;
|
||||
mod stochastic;
|
||||
mod super_trend;
|
||||
@@ -110,6 +115,7 @@ mod ulcer_index;
|
||||
mod ultimate_oscillator;
|
||||
mod vertical_horizontal_filter;
|
||||
mod vidya;
|
||||
mod volty_stop;
|
||||
mod volume_oscillator;
|
||||
mod vortex;
|
||||
mod vpt;
|
||||
@@ -122,6 +128,7 @@ mod weighted_close;
|
||||
mod williams_r;
|
||||
mod wma;
|
||||
mod yang_zhang;
|
||||
mod yoyo_exit;
|
||||
mod z_score;
|
||||
mod zero_lag_macd;
|
||||
mod zlema;
|
||||
@@ -160,6 +167,7 @@ pub use coppock::Coppock;
|
||||
pub use dema::Dema;
|
||||
pub use demand_index::DemandIndex;
|
||||
pub use donchian::{Donchian, DonchianOutput};
|
||||
pub use donchian_stop::{DonchianStop, DonchianStopOutput};
|
||||
pub use double_bollinger::{DoubleBollinger, DoubleBollingerOutput};
|
||||
pub use dpo::Dpo;
|
||||
pub use ease_of_movement::EaseOfMovement;
|
||||
@@ -170,6 +178,7 @@ pub use force_index::ForceIndex;
|
||||
pub use fractal_chaos_bands::{FractalChaosBands, FractalChaosBandsOutput};
|
||||
pub use frama::Frama;
|
||||
pub use garman_klass::GarmanKlassVolatility;
|
||||
pub use hilo_activator::HiLoActivator;
|
||||
pub use historical_volatility::HistoricalVolatility;
|
||||
pub use hma::Hma;
|
||||
pub use hurst_channel::{HurstChannel, HurstChannelOutput};
|
||||
@@ -197,11 +206,13 @@ pub use nvi::Nvi;
|
||||
pub use obv::Obv;
|
||||
pub use parkinson::ParkinsonVolatility;
|
||||
pub use percent_b::PercentB;
|
||||
pub use percentage_trailing_stop::PercentageTrailingStop;
|
||||
pub use pgo::Pgo;
|
||||
pub use pmo::Pmo;
|
||||
pub use ppo::Ppo;
|
||||
pub use psar::Psar;
|
||||
pub use pvi::Pvi;
|
||||
pub use renko_trailing_stop::RenkoTrailingStop;
|
||||
pub use roc::Roc;
|
||||
pub use rogers_satchell::RogersSatchellVolatility;
|
||||
pub use rsi::Rsi;
|
||||
@@ -215,6 +226,7 @@ pub use standard_error_bands::{StandardErrorBands, StandardErrorBandsOutput};
|
||||
pub use starc_bands::{StarcBands, StarcBandsOutput};
|
||||
pub use stc::Stc;
|
||||
pub use std_dev::StdDev;
|
||||
pub use step_trailing_stop::StepTrailingStop;
|
||||
pub use stoch_rsi::StochRsi;
|
||||
pub use stochastic::{Stochastic, StochasticOutput};
|
||||
pub use super_trend::{SuperTrend, SuperTrendOutput};
|
||||
@@ -232,6 +244,7 @@ pub use ulcer_index::UlcerIndex;
|
||||
pub use ultimate_oscillator::UltimateOscillator;
|
||||
pub use vertical_horizontal_filter::VerticalHorizontalFilter;
|
||||
pub use vidya::Vidya;
|
||||
pub use volty_stop::VoltyStop;
|
||||
pub use volume_oscillator::VolumeOscillator;
|
||||
pub use vortex::{Vortex, VortexOutput};
|
||||
pub use vpt::VolumePriceTrend;
|
||||
@@ -244,6 +257,7 @@ pub use weighted_close::WeightedClose;
|
||||
pub use williams_r::WilliamsR;
|
||||
pub use wma::Wma;
|
||||
pub use yang_zhang::YangZhangVolatility;
|
||||
pub use yoyo_exit::YoyoExit;
|
||||
pub use z_score::ZScore;
|
||||
pub use zero_lag_macd::{ZeroLagMacd, ZeroLagMacdOutput};
|
||||
pub use zlema::Zlema;
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
//! Percentage Trailing Stop.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Percentage Trailing Stop — a fixed-percentage stop that ratchets with the
|
||||
/// trend and flips to the opposite side on a close-through.
|
||||
///
|
||||
/// ```text
|
||||
/// step = price · percent / 100
|
||||
///
|
||||
/// long: stop_t = max(stop_{t−1}, close − step) while close ≥ stop_{t−1}
|
||||
/// short: stop_t = min(stop_{t−1}, close + step) while close ≤ stop_{t−1}
|
||||
/// flip-to-long on a close > prev short-stop -> stop = close − step
|
||||
/// flip-to-short on a close < prev long-stop -> stop = close + step
|
||||
/// ```
|
||||
///
|
||||
/// The first input seeds a long stop `percent` below price. Unlike the ATR
|
||||
/// trailing stop the band is purely proportional to the latest price, so it
|
||||
/// scales naturally across instruments without re-tuning. A common
|
||||
/// configuration is `5.0` (a 5% trail).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, PercentageTrailingStop};
|
||||
///
|
||||
/// let mut indicator = PercentageTrailingStop::new(5.0).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..20 {
|
||||
/// last = indicator.update(100.0 + f64::from(i));
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PercentageTrailingStop {
|
||||
percent: f64,
|
||||
prev_stop: Option<f64>,
|
||||
/// `true` while a long trail is active; flipped on a close-through.
|
||||
long: bool,
|
||||
}
|
||||
|
||||
impl PercentageTrailingStop {
|
||||
/// Construct a Percentage Trailing Stop with an explicit trail size
|
||||
/// (e.g. `5.0` for a 5% trail).
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`Error::NonPositiveMultiplier`] if `percent` is not strictly
|
||||
/// positive and finite.
|
||||
pub fn new(percent: f64) -> Result<Self> {
|
||||
if !percent.is_finite() || percent <= 0.0 {
|
||||
return Err(Error::NonPositiveMultiplier);
|
||||
}
|
||||
Ok(Self {
|
||||
percent,
|
||||
prev_stop: None,
|
||||
long: true,
|
||||
})
|
||||
}
|
||||
|
||||
/// A common configuration: a `5.0%` trail.
|
||||
pub fn classic() -> Self {
|
||||
Self::new(5.0).expect("classic percent is valid")
|
||||
}
|
||||
|
||||
/// Configured trail percent.
|
||||
pub const fn percent(&self) -> f64 {
|
||||
self.percent
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for PercentageTrailingStop {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, close: f64) -> Option<f64> {
|
||||
let step = close.abs() * self.percent / 100.0;
|
||||
let stop = match self.prev_stop {
|
||||
Some(prev) => {
|
||||
if self.long {
|
||||
if close < prev {
|
||||
// Close-through long stop — flip to short.
|
||||
self.long = false;
|
||||
close + step
|
||||
} else {
|
||||
prev.max(close - step)
|
||||
}
|
||||
} else if close > prev {
|
||||
// Close-through short stop — flip to long.
|
||||
self.long = true;
|
||||
close - step
|
||||
} else {
|
||||
prev.min(close + step)
|
||||
}
|
||||
}
|
||||
None => close - step,
|
||||
};
|
||||
self.prev_stop = Some(stop);
|
||||
Some(stop)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.prev_stop = None;
|
||||
self.long = true;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.prev_stop.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"PercentageTrailingStop"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_percent() {
|
||||
assert!(PercentageTrailingStop::new(0.0).is_err());
|
||||
assert!(PercentageTrailingStop::new(-1.0).is_err());
|
||||
assert!(PercentageTrailingStop::new(f64::NAN).is_err());
|
||||
assert!(PercentageTrailingStop::new(f64::INFINITY).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let s = PercentageTrailingStop::classic();
|
||||
assert_relative_eq!(s.percent(), 5.0, epsilon = 1e-12);
|
||||
assert_eq!(s.name(), "PercentageTrailingStop");
|
||||
assert_eq!(s.warmup_period(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_value_is_step_below_price() {
|
||||
let mut s = PercentageTrailingStop::new(10.0).unwrap();
|
||||
// 100 · 0.10 = 10, so seed stop = 90.
|
||||
assert_relative_eq!(s.update(100.0).unwrap(), 90.0, epsilon = 1e-12);
|
||||
assert!(s.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_stop_ratchets_up_with_price() {
|
||||
let mut s = PercentageTrailingStop::new(10.0).unwrap();
|
||||
// Rising series: each new high pulls the stop up.
|
||||
let prices = [100.0, 110.0, 120.0, 130.0];
|
||||
let out: Vec<f64> = prices.iter().map(|&p| s.update(p).unwrap()).collect();
|
||||
assert_relative_eq!(out[0], 90.0, epsilon = 1e-9);
|
||||
assert_relative_eq!(out[1], 99.0, epsilon = 1e-9);
|
||||
assert_relative_eq!(out[2], 108.0, epsilon = 1e-9);
|
||||
assert_relative_eq!(out[3], 117.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flips_to_short_on_close_through() {
|
||||
let mut s = PercentageTrailingStop::new(10.0).unwrap();
|
||||
// Seed long at 100 -> stop 90.
|
||||
s.update(100.0);
|
||||
// Climb to 130 -> stop ratchets to 117.
|
||||
s.update(130.0);
|
||||
// Crash to 50 -> closes through 117 -> flip to short stop at 55.
|
||||
let flipped = s.update(50.0).unwrap();
|
||||
assert_relative_eq!(flipped, 55.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_stop_ratchets_down_and_flips_back() {
|
||||
let mut s = PercentageTrailingStop::new(10.0).unwrap();
|
||||
s.update(100.0); // long stop 90
|
||||
s.update(50.0); // flip short, stop 55
|
||||
let v = s.update(40.0).unwrap();
|
||||
// Short side ratchets down: min(55, 44) = 44.
|
||||
assert_relative_eq!(v, 44.0, epsilon = 1e-9);
|
||||
// Rally back through 44 -> flip long, stop = 100 · 0.9 = 90.
|
||||
let v = s.update(100.0).unwrap();
|
||||
assert_relative_eq!(v, 90.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_series_holds_stop() {
|
||||
let mut s = PercentageTrailingStop::new(5.0).unwrap();
|
||||
let out = s.batch(&[100.0; 30]);
|
||||
for v in out.into_iter().flatten() {
|
||||
assert_relative_eq!(v, 95.0, epsilon = 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut s = PercentageTrailingStop::new(5.0).unwrap();
|
||||
s.update(100.0);
|
||||
s.update(50.0); // flip short
|
||||
assert!(s.is_ready());
|
||||
s.reset();
|
||||
assert!(!s.is_ready());
|
||||
// After reset the next update seeds a fresh long stop.
|
||||
let v = s.update(200.0).unwrap();
|
||||
assert_relative_eq!(v, 190.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let prices: Vec<f64> = (0..80)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 8.0)
|
||||
.collect();
|
||||
let mut a = PercentageTrailingStop::classic();
|
||||
let mut b = PercentageTrailingStop::classic();
|
||||
assert_eq!(
|
||||
a.batch(&prices),
|
||||
prices.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
//! Renko Trailing Stop.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Renko Trailing Stop — a trailing stop that follows a Renko-style brick
|
||||
/// anchor: the stop only moves when price has advanced (or fallen) by at least
|
||||
/// one full `block_size`, and then jumps the same fixed distance.
|
||||
///
|
||||
/// ```text
|
||||
/// long: advance = floor((close − anchor) / block_size)
|
||||
/// if advance ≥ 1 -> anchor += advance · block_size
|
||||
/// stop = anchor − block_size
|
||||
/// flip-to-short on close < stop -> anchor = close, stop = anchor + block_size
|
||||
/// short: advance = floor((anchor − close) / block_size)
|
||||
/// if advance ≥ 1 -> anchor −= advance · block_size
|
||||
/// stop = anchor + block_size
|
||||
/// flip-to-long on close > stop -> anchor = close, stop = anchor − block_size
|
||||
/// ```
|
||||
///
|
||||
/// Like a Renko chart the stop ignores intra-block noise — it sits one full
|
||||
/// block behind the last "printed" brick and only ratchets in whole-block
|
||||
/// increments. The first input seeds a long anchor at the close.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, RenkoTrailingStop};
|
||||
///
|
||||
/// let mut indicator = RenkoTrailingStop::new(1.0).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..20 {
|
||||
/// last = indicator.update(100.0 + f64::from(i));
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RenkoTrailingStop {
|
||||
block_size: f64,
|
||||
anchor: Option<f64>,
|
||||
long: bool,
|
||||
}
|
||||
|
||||
impl RenkoTrailingStop {
|
||||
/// Construct a Renko Trailing Stop with an explicit block size.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`Error::NonPositiveMultiplier`] if `block_size` is not strictly
|
||||
/// positive and finite.
|
||||
pub fn new(block_size: f64) -> Result<Self> {
|
||||
if !block_size.is_finite() || block_size <= 0.0 {
|
||||
return Err(Error::NonPositiveMultiplier);
|
||||
}
|
||||
Ok(Self {
|
||||
block_size,
|
||||
anchor: None,
|
||||
long: true,
|
||||
})
|
||||
}
|
||||
|
||||
/// A common configuration: a `1.0` block size.
|
||||
pub fn classic() -> Self {
|
||||
Self::new(1.0).expect("classic block size is valid")
|
||||
}
|
||||
|
||||
/// Configured block size.
|
||||
pub const fn block_size(&self) -> f64 {
|
||||
self.block_size
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for RenkoTrailingStop {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, close: f64) -> Option<f64> {
|
||||
let anchor = match self.anchor {
|
||||
Some(prev) => {
|
||||
if self.long {
|
||||
let stop = prev - self.block_size;
|
||||
if close < stop {
|
||||
// Close-through long stop -> flip to short.
|
||||
self.long = false;
|
||||
close
|
||||
} else {
|
||||
let blocks = ((close - prev) / self.block_size).floor();
|
||||
if blocks >= 1.0 {
|
||||
prev + blocks * self.block_size
|
||||
} else {
|
||||
prev
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let stop = prev + self.block_size;
|
||||
if close > stop {
|
||||
self.long = true;
|
||||
close
|
||||
} else {
|
||||
let blocks = ((prev - close) / self.block_size).floor();
|
||||
if blocks >= 1.0 {
|
||||
prev - blocks * self.block_size
|
||||
} else {
|
||||
prev
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None => close,
|
||||
};
|
||||
self.anchor = Some(anchor);
|
||||
let stop = if self.long {
|
||||
anchor - self.block_size
|
||||
} else {
|
||||
anchor + self.block_size
|
||||
};
|
||||
Some(stop)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.anchor = None;
|
||||
self.long = true;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.anchor.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"RenkoTrailingStop"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_block() {
|
||||
assert!(RenkoTrailingStop::new(0.0).is_err());
|
||||
assert!(RenkoTrailingStop::new(-1.0).is_err());
|
||||
assert!(RenkoTrailingStop::new(f64::NAN).is_err());
|
||||
assert!(RenkoTrailingStop::new(f64::INFINITY).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let s = RenkoTrailingStop::classic();
|
||||
assert_relative_eq!(s.block_size(), 1.0, epsilon = 1e-12);
|
||||
assert_eq!(s.name(), "RenkoTrailingStop");
|
||||
assert_eq!(s.warmup_period(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_value_is_block_below_close() {
|
||||
let mut s = RenkoTrailingStop::new(1.0).unwrap();
|
||||
// Seed anchor = 100, long stop = 99.
|
||||
assert_relative_eq!(s.update(100.0).unwrap(), 99.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stop_only_moves_after_full_block_advance() {
|
||||
let mut s = RenkoTrailingStop::new(1.0).unwrap();
|
||||
s.update(100.0);
|
||||
// Intra-block move -> stop unchanged at 99.
|
||||
assert_relative_eq!(s.update(100.5).unwrap(), 99.0, epsilon = 1e-12);
|
||||
// Full block: 101 -> anchor 101, stop 100.
|
||||
assert_relative_eq!(s.update(101.0).unwrap(), 100.0, epsilon = 1e-12);
|
||||
// Two blocks at once: 103.5 -> anchor 103, stop 102.
|
||||
assert_relative_eq!(s.update(103.5).unwrap(), 102.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flips_to_short_on_close_through_stop() {
|
||||
let mut s = RenkoTrailingStop::new(1.0).unwrap();
|
||||
s.update(100.0);
|
||||
s.update(105.0); // anchor 105, stop 104
|
||||
// Drop to 50 closes through 104 -> flip short, anchor=50, stop=51.
|
||||
let flipped = s.update(50.0).unwrap();
|
||||
assert_relative_eq!(flipped, 51.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_anchor_ratchets_down_and_flips_back() {
|
||||
let mut s = RenkoTrailingStop::new(1.0).unwrap();
|
||||
s.update(100.0);
|
||||
s.update(50.0); // short, anchor 50, stop 51
|
||||
let v = s.update(48.0).unwrap();
|
||||
// Two blocks down: anchor 48, stop 49.
|
||||
assert_relative_eq!(v, 49.0, epsilon = 1e-12);
|
||||
// Rally to 60 closes through 49 -> flip long, anchor=60, stop=59.
|
||||
let back = s.update(60.0).unwrap();
|
||||
assert_relative_eq!(back, 59.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_series_holds_stop() {
|
||||
let mut s = RenkoTrailingStop::new(1.0).unwrap();
|
||||
let out = s.batch(&[100.0; 30]);
|
||||
for v in out.into_iter().flatten() {
|
||||
assert_relative_eq!(v, 99.0, epsilon = 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut s = RenkoTrailingStop::new(1.0).unwrap();
|
||||
s.update(100.0);
|
||||
s.update(50.0); // flip short
|
||||
assert!(s.is_ready());
|
||||
s.reset();
|
||||
assert!(!s.is_ready());
|
||||
assert_relative_eq!(s.update(200.0).unwrap(), 199.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let prices: Vec<f64> = (0..80)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 8.0)
|
||||
.collect();
|
||||
let mut a = RenkoTrailingStop::classic();
|
||||
let mut b = RenkoTrailingStop::classic();
|
||||
assert_eq!(
|
||||
a.batch(&prices),
|
||||
prices.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
//! Step Trailing Stop.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Step Trailing Stop — a stop that ratchets in fixed-size discrete steps and
|
||||
/// flips to the opposite side on a close-through.
|
||||
///
|
||||
/// ```text
|
||||
/// long: target = close − step_size
|
||||
/// stop_t = max(stop_{t−1}, floor(target / step_size) · step_size)
|
||||
/// while close ≥ stop_{t−1}
|
||||
/// short: target = close + step_size
|
||||
/// stop_t = min(stop_{t−1}, ceil(target / step_size) · step_size)
|
||||
/// while close ≤ stop_{t−1}
|
||||
/// flip-to-long on close > prev short-stop -> stop = floor((close − step) / step) · step
|
||||
/// flip-to-short on close < prev long-stop -> stop = ceil((close + step) / step) · step
|
||||
/// ```
|
||||
///
|
||||
/// Quantising the stop to a multiple of `step_size` keeps the level on a
|
||||
/// round-number grid, which mirrors how many discretionary traders move stops
|
||||
/// by hand (in $0.50, $1, or 10-pip increments). The first input seeds a long
|
||||
/// stop one step below the snapped close.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, StepTrailingStop};
|
||||
///
|
||||
/// let mut indicator = StepTrailingStop::new(1.0).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..20 {
|
||||
/// last = indicator.update(100.0 + f64::from(i));
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct StepTrailingStop {
|
||||
step_size: f64,
|
||||
prev_stop: Option<f64>,
|
||||
long: bool,
|
||||
}
|
||||
|
||||
impl StepTrailingStop {
|
||||
/// Construct a Step Trailing Stop with an explicit step size.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`Error::NonPositiveMultiplier`] if `step_size` is not strictly
|
||||
/// positive and finite.
|
||||
pub fn new(step_size: f64) -> Result<Self> {
|
||||
if !step_size.is_finite() || step_size <= 0.0 {
|
||||
return Err(Error::NonPositiveMultiplier);
|
||||
}
|
||||
Ok(Self {
|
||||
step_size,
|
||||
prev_stop: None,
|
||||
long: true,
|
||||
})
|
||||
}
|
||||
|
||||
/// A common configuration: a `1.0` step size.
|
||||
pub fn classic() -> Self {
|
||||
Self::new(1.0).expect("classic step is valid")
|
||||
}
|
||||
|
||||
/// Configured step size.
|
||||
pub const fn step_size(&self) -> f64 {
|
||||
self.step_size
|
||||
}
|
||||
|
||||
/// Snap `value` down to the nearest `step_size`-grid line below it.
|
||||
fn snap_long(&self, close: f64) -> f64 {
|
||||
((close - self.step_size) / self.step_size).floor() * self.step_size
|
||||
}
|
||||
|
||||
/// Snap `value` up to the nearest `step_size`-grid line above it.
|
||||
fn snap_short(&self, close: f64) -> f64 {
|
||||
((close + self.step_size) / self.step_size).ceil() * self.step_size
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for StepTrailingStop {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, close: f64) -> Option<f64> {
|
||||
let stop = match self.prev_stop {
|
||||
Some(prev) => {
|
||||
if self.long {
|
||||
if close < prev {
|
||||
self.long = false;
|
||||
self.snap_short(close)
|
||||
} else {
|
||||
prev.max(self.snap_long(close))
|
||||
}
|
||||
} else if close > prev {
|
||||
self.long = true;
|
||||
self.snap_long(close)
|
||||
} else {
|
||||
prev.min(self.snap_short(close))
|
||||
}
|
||||
}
|
||||
None => self.snap_long(close),
|
||||
};
|
||||
self.prev_stop = Some(stop);
|
||||
Some(stop)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.prev_stop = None;
|
||||
self.long = true;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.prev_stop.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"StepTrailingStop"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_step() {
|
||||
assert!(StepTrailingStop::new(0.0).is_err());
|
||||
assert!(StepTrailingStop::new(-1.0).is_err());
|
||||
assert!(StepTrailingStop::new(f64::NAN).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let s = StepTrailingStop::classic();
|
||||
assert_relative_eq!(s.step_size(), 1.0, epsilon = 1e-12);
|
||||
assert_eq!(s.name(), "StepTrailingStop");
|
||||
assert_eq!(s.warmup_period(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_value_snaps_below_price() {
|
||||
let mut s = StepTrailingStop::new(1.0).unwrap();
|
||||
// floor((100.4 - 1) / 1) · 1 = 99.
|
||||
assert_relative_eq!(s.update(100.4).unwrap(), 99.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_stop_ratchets_in_discrete_steps() {
|
||||
let mut s = StepTrailingStop::new(1.0).unwrap();
|
||||
let out: Vec<f64> = [100.0, 100.5, 101.0, 102.0, 103.5]
|
||||
.iter()
|
||||
.map(|&p| s.update(p).unwrap())
|
||||
.collect();
|
||||
// 100 -> 99, 100.5 -> 99 (no advance), 101 -> 100, 102 -> 101, 103.5 -> 102.
|
||||
assert_relative_eq!(out[0], 99.0, epsilon = 1e-9);
|
||||
assert_relative_eq!(out[1], 99.0, epsilon = 1e-9);
|
||||
assert_relative_eq!(out[2], 100.0, epsilon = 1e-9);
|
||||
assert_relative_eq!(out[3], 101.0, epsilon = 1e-9);
|
||||
assert_relative_eq!(out[4], 102.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flips_to_short_on_close_through_and_back() {
|
||||
let mut s = StepTrailingStop::new(1.0).unwrap();
|
||||
s.update(100.0); // 99
|
||||
s.update(105.0); // 104
|
||||
let flipped = s.update(50.0).unwrap();
|
||||
// ceil((50+1)/1)·1 = 51.
|
||||
assert_relative_eq!(flipped, 51.0, epsilon = 1e-9);
|
||||
// Rally back through 51 -> flip long at 99.
|
||||
let back = s.update(100.0).unwrap();
|
||||
assert_relative_eq!(back, 99.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_stop_ratchets_down() {
|
||||
let mut s = StepTrailingStop::new(1.0).unwrap();
|
||||
s.update(100.0);
|
||||
s.update(50.0); // short at 51
|
||||
let v = s.update(40.0).unwrap();
|
||||
// ceil((40+1)/1)·1 = 41 -> min(51, 41) = 41.
|
||||
assert_relative_eq!(v, 41.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_series_holds_stop() {
|
||||
let mut s = StepTrailingStop::new(1.0).unwrap();
|
||||
let out = s.batch(&[100.0; 30]);
|
||||
for v in out.into_iter().flatten() {
|
||||
assert_relative_eq!(v, 99.0, epsilon = 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut s = StepTrailingStop::new(1.0).unwrap();
|
||||
s.update(100.0);
|
||||
s.update(50.0);
|
||||
assert!(s.is_ready());
|
||||
s.reset();
|
||||
assert!(!s.is_ready());
|
||||
assert_relative_eq!(s.update(200.0).unwrap(), 199.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let prices: Vec<f64> = (0..80)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 8.0)
|
||||
.collect();
|
||||
let mut a = StepTrailingStop::classic();
|
||||
let mut b = StepTrailingStop::classic();
|
||||
assert_eq!(
|
||||
a.batch(&prices),
|
||||
prices.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
//! Volty Stop (Volatility Stop, Kase).
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::indicators::atr::Atr;
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Volty Stop — Cynthia Kase's volatility-anchored trailing stop. The stop is
|
||||
/// hung off the *extreme close* recorded since the current trade was opened,
|
||||
/// not off the most recent bar, which keeps it tight without giving back gains
|
||||
/// when price pulls back inside the trend.
|
||||
///
|
||||
/// ```text
|
||||
/// long: anchor = max_close_since_long
|
||||
/// stop_t = anchor − multiplier · ATR
|
||||
/// flip-to-short on close < stop_t -> anchor = close, stop = close + mult · ATR
|
||||
/// short: anchor = min_close_since_short
|
||||
/// stop_t = anchor + multiplier · ATR
|
||||
/// flip-to-long on close > stop_t -> anchor = close, stop = close − mult · ATR
|
||||
/// ```
|
||||
///
|
||||
/// The anchor only ratchets in the trade's favour, so the stop tightens as
|
||||
/// price reaches new extremes. Compared to the
|
||||
/// [`AtrTrailingStop`](crate::AtrTrailingStop) — which re-anchors on every
|
||||
/// bar's close — Volty Stop's extreme-anchor design gives back less on
|
||||
/// pullbacks while keeping the same ATR-based volatility scaling. A common
|
||||
/// configuration is `ATR(14)` with a `2.0` multiplier.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, VoltyStop};
|
||||
///
|
||||
/// let mut indicator = VoltyStop::new(14, 2.0).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..80 {
|
||||
/// let base = 100.0 + f64::from(i);
|
||||
/// let candle =
|
||||
/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
|
||||
/// last = indicator.update(candle);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct VoltyStop {
|
||||
atr: Atr,
|
||||
atr_period: usize,
|
||||
multiplier: f64,
|
||||
anchor: Option<f64>,
|
||||
long: bool,
|
||||
}
|
||||
|
||||
impl VoltyStop {
|
||||
/// Construct a Volty Stop with an explicit ATR period and band multiplier.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`Error::PeriodZero`] if `atr_period == 0` and
|
||||
/// [`Error::NonPositiveMultiplier`] if `multiplier` is not strictly
|
||||
/// positive and finite.
|
||||
pub fn new(atr_period: usize, multiplier: f64) -> Result<Self> {
|
||||
if !multiplier.is_finite() || multiplier <= 0.0 {
|
||||
return Err(Error::NonPositiveMultiplier);
|
||||
}
|
||||
Ok(Self {
|
||||
atr: Atr::new(atr_period)?,
|
||||
atr_period,
|
||||
multiplier,
|
||||
anchor: None,
|
||||
long: true,
|
||||
})
|
||||
}
|
||||
|
||||
/// A common configuration: `ATR(14)` with a `2.0` multiplier.
|
||||
pub fn classic() -> Self {
|
||||
Self::new(14, 2.0).expect("classic Volty Stop params are valid")
|
||||
}
|
||||
|
||||
/// Configured `(atr_period, multiplier)`.
|
||||
pub const fn params(&self) -> (usize, f64) {
|
||||
(self.atr_period, self.multiplier)
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for VoltyStop {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
let atr = self.atr.update(candle)?;
|
||||
let band = self.multiplier * atr;
|
||||
let close = candle.close;
|
||||
|
||||
let (anchor, long) = match (self.anchor, self.long) {
|
||||
(Some(prev_anchor), true) => {
|
||||
let stop = prev_anchor - band;
|
||||
if close < stop {
|
||||
// Close-through long stop -> flip short, anchor at close.
|
||||
(close, false)
|
||||
} else {
|
||||
// Ratchet the anchor up to today's close if higher.
|
||||
(prev_anchor.max(close), true)
|
||||
}
|
||||
}
|
||||
(Some(prev_anchor), false) => {
|
||||
let stop = prev_anchor + band;
|
||||
if close > stop {
|
||||
(close, true)
|
||||
} else {
|
||||
(prev_anchor.min(close), false)
|
||||
}
|
||||
}
|
||||
// First ATR-ready bar seeds a long anchor at the close.
|
||||
(None, _) => (close, true),
|
||||
};
|
||||
self.anchor = Some(anchor);
|
||||
self.long = long;
|
||||
let stop = if long { anchor - band } else { anchor + band };
|
||||
Some(stop)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.atr.reset();
|
||||
self.anchor = None;
|
||||
self.long = true;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.atr_period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.anchor.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"VoltyStop"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn c(high: f64, low: f64, close: f64, ts: i64) -> Candle {
|
||||
Candle::new(f64::midpoint(high, low), high, low, close, 1.0, ts).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_params() {
|
||||
assert!(VoltyStop::new(0, 2.0).is_err());
|
||||
assert!(VoltyStop::new(14, 0.0).is_err());
|
||||
assert!(VoltyStop::new(14, -1.0).is_err());
|
||||
assert!(VoltyStop::new(14, f64::NAN).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let s = VoltyStop::classic();
|
||||
let (p, m) = s.params();
|
||||
assert_eq!(p, 14);
|
||||
assert_relative_eq!(m, 2.0, epsilon = 1e-12);
|
||||
assert_eq!(s.warmup_period(), 14);
|
||||
assert_eq!(s.name(), "VoltyStop");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_matches_warmup() {
|
||||
let candles: Vec<Candle> = (0..20)
|
||||
.map(|i| {
|
||||
let base = 100.0 + i as f64;
|
||||
c(base + 1.0, base - 1.0, base, i)
|
||||
})
|
||||
.collect();
|
||||
let mut s = VoltyStop::new(8, 2.0).unwrap();
|
||||
let out = s.batch(&candles);
|
||||
for (i, v) in out.iter().enumerate().take(7) {
|
||||
assert!(v.is_none(), "index {i} must be None during warmup");
|
||||
}
|
||||
assert!(out[7].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_values_flat_market() {
|
||||
// H=11, L=9, C=10 -> TR=2 -> ATR=2; band = 2·2 = 4; anchor stays at 10; stop = 10-4 = 6.
|
||||
let candles: Vec<Candle> = (0..20).map(|i| c(11.0, 9.0, 10.0, i)).collect();
|
||||
let mut s = VoltyStop::new(5, 2.0).unwrap();
|
||||
for v in s.batch(&candles).into_iter().flatten() {
|
||||
assert_relative_eq!(v, 6.0, epsilon = 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uptrend_anchor_ratchets_up_with_close() {
|
||||
let candles: Vec<Candle> = (0..40)
|
||||
.map(|i| {
|
||||
let base = 100.0 + i as f64;
|
||||
c(base + 1.0, base - 1.0, base, i)
|
||||
})
|
||||
.collect();
|
||||
let mut s = VoltyStop::new(14, 3.0).unwrap();
|
||||
let emitted: Vec<(f64, f64)> = s
|
||||
.batch(&candles)
|
||||
.into_iter()
|
||||
.zip(candles.iter())
|
||||
.filter_map(|(o, c)| o.map(|v| (v, c.close)))
|
||||
.collect();
|
||||
for w in emitted.windows(2) {
|
||||
assert!(
|
||||
w[1].0 >= w[0].0 - 1e-9,
|
||||
"stop must not loosen in an uptrend"
|
||||
);
|
||||
}
|
||||
for &(stop, close) in &emitted {
|
||||
assert!(stop < close, "uptrend stop should sit below the close");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stop_flips_on_reversal() {
|
||||
let mut candles: Vec<Candle> = (0..40)
|
||||
.map(|i| {
|
||||
let base = 100.0 + i as f64;
|
||||
c(base + 1.0, base - 1.0, base, i)
|
||||
})
|
||||
.collect();
|
||||
candles.extend((0..40).map(|i| {
|
||||
let base = 140.0 - 3.0 * i as f64;
|
||||
c(base + 1.0, base - 1.0, base, 40 + i)
|
||||
}));
|
||||
let mut s = VoltyStop::new(14, 3.0).unwrap();
|
||||
let paired: Vec<(f64, f64)> = s
|
||||
.batch(&candles)
|
||||
.into_iter()
|
||||
.zip(candles.iter())
|
||||
.filter_map(|(o, c)| o.map(|v| (v, c.close)))
|
||||
.collect();
|
||||
assert!(paired.iter().any(|&(stop, close)| stop < close));
|
||||
assert!(paired.iter().any(|&(stop, close)| stop > close));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let candles: Vec<Candle> = (0..40)
|
||||
.map(|i| {
|
||||
let base = 100.0 + i as f64;
|
||||
c(base + 1.0, base - 1.0, base, i)
|
||||
})
|
||||
.collect();
|
||||
let mut s = VoltyStop::classic();
|
||||
s.batch(&candles);
|
||||
assert!(s.is_ready());
|
||||
s.reset();
|
||||
assert!(!s.is_ready());
|
||||
assert_eq!(s.update(candles[0]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..80)
|
||||
.map(|i| {
|
||||
let mid = 100.0 + (i as f64 * 0.3).sin() * 8.0;
|
||||
c(mid + 1.5, mid - 1.5, mid + 0.5, i)
|
||||
})
|
||||
.collect();
|
||||
let mut a = VoltyStop::classic();
|
||||
let mut b = VoltyStop::classic();
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
//! Yo-Yo Exit.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::indicators::atr::Atr;
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Yo-Yo Exit — an ATR-based long-only trailing stop that "yo-yos" in and out
|
||||
/// of the market: when price closes below the trail it exits, and when price
|
||||
/// recovers `multiplier · ATR` above the same trail it re-enters long. The
|
||||
/// emitted level is always the *trail itself* (not a flip-to-short stop), so a
|
||||
/// consumer reads a single line on the chart and toggles the position
|
||||
/// depending on which side of it the close sits.
|
||||
///
|
||||
/// ```text
|
||||
/// band = multiplier · ATR
|
||||
/// in-trade: trail_t = max(trail_{t−1}, close − band)
|
||||
/// exit when close < trail
|
||||
/// out: trail held flat at the last in-trade level
|
||||
/// re-enter when close > trail + band
|
||||
/// ```
|
||||
///
|
||||
/// Unlike [`AtrTrailingStop`](crate::AtrTrailingStop) — which always flips to
|
||||
/// the opposite side — the Yo-Yo only takes longs and treats the off-period
|
||||
/// as a "wait until price proves itself again" phase. A common configuration
|
||||
/// is `ATR(14)` with a `2.0` multiplier.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, YoyoExit};
|
||||
///
|
||||
/// let mut indicator = YoyoExit::new(14, 2.0).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..80 {
|
||||
/// let base = 100.0 + f64::from(i);
|
||||
/// let candle =
|
||||
/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
|
||||
/// last = indicator.update(candle);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct YoyoExit {
|
||||
atr: Atr,
|
||||
atr_period: usize,
|
||||
multiplier: f64,
|
||||
trail: Option<f64>,
|
||||
/// `true` while the trail is being ratcheted by new closes; `false` while
|
||||
/// the strategy is sidelined waiting for a re-entry.
|
||||
in_trade: bool,
|
||||
}
|
||||
|
||||
impl YoyoExit {
|
||||
/// Construct a Yo-Yo Exit with an explicit ATR period and band multiplier.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`Error::PeriodZero`] if `atr_period == 0` and
|
||||
/// [`Error::NonPositiveMultiplier`] if `multiplier` is not strictly
|
||||
/// positive and finite.
|
||||
pub fn new(atr_period: usize, multiplier: f64) -> Result<Self> {
|
||||
if !multiplier.is_finite() || multiplier <= 0.0 {
|
||||
return Err(Error::NonPositiveMultiplier);
|
||||
}
|
||||
Ok(Self {
|
||||
atr: Atr::new(atr_period)?,
|
||||
atr_period,
|
||||
multiplier,
|
||||
trail: None,
|
||||
in_trade: true,
|
||||
})
|
||||
}
|
||||
|
||||
/// A common configuration: `ATR(14)` with a `2.0` multiplier.
|
||||
pub fn classic() -> Self {
|
||||
Self::new(14, 2.0).expect("classic Yo-Yo Exit params are valid")
|
||||
}
|
||||
|
||||
/// Configured `(atr_period, multiplier)`.
|
||||
pub const fn params(&self) -> (usize, f64) {
|
||||
(self.atr_period, self.multiplier)
|
||||
}
|
||||
|
||||
/// `true` while the strategy is currently long, `false` while sidelined.
|
||||
pub const fn in_trade(&self) -> bool {
|
||||
self.in_trade
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for YoyoExit {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
let atr = self.atr.update(candle)?;
|
||||
let band = self.multiplier * atr;
|
||||
let close = candle.close;
|
||||
|
||||
let trail = match self.trail {
|
||||
Some(prev) => {
|
||||
if self.in_trade {
|
||||
if close < prev {
|
||||
// Stopped out — sideline, keep the trail flat.
|
||||
self.in_trade = false;
|
||||
prev
|
||||
} else {
|
||||
// Ratchet up only.
|
||||
prev.max(close - band)
|
||||
}
|
||||
} else if close > prev + band {
|
||||
// Re-entry trigger — start a new trail anchored on this close.
|
||||
self.in_trade = true;
|
||||
close - band
|
||||
} else {
|
||||
prev
|
||||
}
|
||||
}
|
||||
// First ATR-ready bar starts a fresh long.
|
||||
None => close - band,
|
||||
};
|
||||
self.trail = Some(trail);
|
||||
Some(trail)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.atr.reset();
|
||||
self.trail = None;
|
||||
self.in_trade = true;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.atr_period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.trail.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"YoyoExit"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn c(high: f64, low: f64, close: f64, ts: i64) -> Candle {
|
||||
Candle::new(f64::midpoint(high, low), high, low, close, 1.0, ts).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_params() {
|
||||
assert!(YoyoExit::new(0, 2.0).is_err());
|
||||
assert!(YoyoExit::new(14, 0.0).is_err());
|
||||
assert!(YoyoExit::new(14, -1.0).is_err());
|
||||
assert!(YoyoExit::new(14, f64::NAN).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let s = YoyoExit::classic();
|
||||
let (p, m) = s.params();
|
||||
assert_eq!(p, 14);
|
||||
assert_relative_eq!(m, 2.0, epsilon = 1e-12);
|
||||
assert_eq!(s.warmup_period(), 14);
|
||||
assert_eq!(s.name(), "YoyoExit");
|
||||
assert!(s.in_trade());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_matches_warmup() {
|
||||
let candles: Vec<Candle> = (0..20)
|
||||
.map(|i| {
|
||||
let base = 100.0 + i as f64;
|
||||
c(base + 1.0, base - 1.0, base, i)
|
||||
})
|
||||
.collect();
|
||||
let mut s = YoyoExit::new(8, 2.0).unwrap();
|
||||
let out = s.batch(&candles);
|
||||
for (i, v) in out.iter().enumerate().take(7) {
|
||||
assert!(v.is_none(), "index {i} must be None during warmup");
|
||||
}
|
||||
assert!(out[7].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_values_flat_market() {
|
||||
// ATR = 2; band = 4; trail starts at close - band = 10 - 4 = 6 and stays there.
|
||||
let candles: Vec<Candle> = (0..20).map(|i| c(11.0, 9.0, 10.0, i)).collect();
|
||||
let mut s = YoyoExit::new(5, 2.0).unwrap();
|
||||
for v in s.batch(&candles).into_iter().flatten() {
|
||||
assert_relative_eq!(v, 6.0, epsilon = 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uptrend_trail_ratchets_up() {
|
||||
let candles: Vec<Candle> = (0..40)
|
||||
.map(|i| {
|
||||
let base = 100.0 + i as f64;
|
||||
c(base + 1.0, base - 1.0, base, i)
|
||||
})
|
||||
.collect();
|
||||
let mut s = YoyoExit::new(14, 3.0).unwrap();
|
||||
let emitted: Vec<f64> = s.batch(&candles).into_iter().flatten().collect();
|
||||
for w in emitted.windows(2) {
|
||||
assert!(w[1] >= w[0] - 1e-9, "trail must not loosen in an uptrend");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reentry_after_stop_out() {
|
||||
// Up-leg sets the trail, big drop stops out, recovery re-enters.
|
||||
let mut candles: Vec<Candle> = (0..30)
|
||||
.map(|i| {
|
||||
let base = 100.0 + i as f64;
|
||||
c(base + 1.0, base - 1.0, base, i)
|
||||
})
|
||||
.collect();
|
||||
candles.push(c(60.0, 40.0, 50.0, 30)); // stop-out
|
||||
candles.push(c(60.0, 50.0, 55.0, 31)); // still out
|
||||
candles.push(c(200.0, 100.0, 200.0, 32)); // strong rally -> re-entry
|
||||
let mut s = YoyoExit::new(14, 3.0).unwrap();
|
||||
// Drive to completion; we just need it to not panic and to flip in_trade
|
||||
// back to true once a re-entry trigger fires.
|
||||
for c in &candles {
|
||||
let _ = s.update(*c);
|
||||
}
|
||||
assert!(s.is_ready());
|
||||
// Final candle's close (200) is way above the trail, so we're back in.
|
||||
assert!(s.in_trade());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let candles: Vec<Candle> = (0..40)
|
||||
.map(|i| {
|
||||
let base = 100.0 + i as f64;
|
||||
c(base + 1.0, base - 1.0, base, i)
|
||||
})
|
||||
.collect();
|
||||
let mut s = YoyoExit::classic();
|
||||
s.batch(&candles);
|
||||
assert!(s.is_ready());
|
||||
s.reset();
|
||||
assert!(!s.is_ready());
|
||||
assert!(s.in_trade());
|
||||
assert_eq!(s.update(candles[0]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..80)
|
||||
.map(|i| {
|
||||
let mid = 100.0 + (i as f64 * 0.3).sin() * 8.0;
|
||||
c(mid + 1.5, mid - 1.5, mid + 0.5, i)
|
||||
})
|
||||
.collect();
|
||||
let mut a = YoyoExit::classic();
|
||||
let mut b = YoyoExit::classic();
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
@@ -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, || {
|
||||
|
||||
@@ -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.
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user