From 55284a30427b13fa43a7392de4c07bd417944b6f Mon Sep 17 00:00:00 2001 From: kingchenc Date: Tue, 26 May 2026 00:54:11 +0200 Subject: [PATCH] feat(family-14): add 15 candlestick patterns (#53) * feat(family-14): add 15 candlestick patterns Introduces the Candlestick Patterns family (block A of the family-14 spec) as scalar f64 indicators on Candle inputs. Each detector emits +1.0 for a bullish reading, -1.0 for a bearish reading, and 0.0 when no pattern is present. Doji is direction-less and emits +1.0 / 0.0 only. New indicators (15): - Doji - Hammer - InvertedHammer - HangingMan - ShootingStar - Engulfing - Harami - MorningEveningStar (signed: +1.0 morning star, -1.0 evening star) - ThreeSoldiersOrCrows (signed: +1.0 soldiers, -1.0 crows) - PiercingDarkCloud (signed: +1.0 piercing, -1.0 dark cloud) - Marubozu (signed: +1.0 bullish, -1.0 bearish, 5 percent shadow tolerance default) - Tweezer (signed: +1.0 bottom, -1.0 top, 10 bps relative tolerance default) - SpinningTop (direction-signed indecision) - ThreeInside (confirmed Harami) - ThreeOutside (confirmed Engulfing) MVP scope notes: - Pattern-shape check only, no trend filter applied. Caller combines with a trend indicator for actionable signals. Documented in every doc comment. - Block B (Harmonic patterns) and block C (Chart patterns) remain out-of-scope and will follow when the pattern-detection framework (pivot detector, multi-bar state machines) lands. Touched across all bindings: Python, Node, WASM. Fuzz target, Python tests (streaming-vs-batch + reference values), Node tests (streaming-vs-batch + reference values), and a representative bench subset (1-, 2- and 3-bar patterns) added. README family table + indicator counter (71 -> 86, eight -> nine families) and CHANGELOG [Unreleased] updated. * fix(family-14): unpack MULTI values with *_ to handle 3-element tuples * cov(family-14): cover Default impl cold paths and MorningEveningStar guard branches --- CHANGELOG.md | 12 + README.md | 5 +- bindings/node/__tests__/indicators.test.js | 16 ++ bindings/node/index.js | 20 +- bindings/node/src/lib.rs | 102 +++++++++ bindings/python/python/wickra/__init__.py | 32 +++ bindings/python/src/lib.rs | 119 ++++++++++ bindings/python/tests/test_new_indicators.py | 176 ++++++++++++++ bindings/wasm/src/lib.rs | 99 ++++++++ crates/wickra-core/src/indicators/doji.rs | 185 +++++++++++++++ .../wickra-core/src/indicators/engulfing.rs | 186 +++++++++++++++ crates/wickra-core/src/indicators/hammer.rs | 160 +++++++++++++ .../wickra-core/src/indicators/hanging_man.rs | 151 ++++++++++++ crates/wickra-core/src/indicators/harami.rs | 187 +++++++++++++++ .../src/indicators/inverted_hammer.rs | 153 +++++++++++++ crates/wickra-core/src/indicators/marubozu.rs | 207 +++++++++++++++++ crates/wickra-core/src/indicators/mod.rs | 30 +++ .../src/indicators/morning_evening_star.rs | 211 +++++++++++++++++ .../src/indicators/piercing_dark_cloud.rs | 197 ++++++++++++++++ .../src/indicators/shooting_star.rs | 151 ++++++++++++ .../src/indicators/spinning_top.rs | 214 +++++++++++++++++ .../src/indicators/three_inside.rs | 195 ++++++++++++++++ .../src/indicators/three_outside.rs | 194 ++++++++++++++++ .../src/indicators/three_soldiers_or_crows.rs | 197 ++++++++++++++++ crates/wickra-core/src/indicators/tweezer.rs | 216 ++++++++++++++++++ crates/wickra-core/src/lib.rs | 54 ++--- crates/wickra/benches/indicators.rs | 36 +-- fuzz/fuzz_targets/indicator_update_candle.rs | 35 ++- 28 files changed, 3490 insertions(+), 50 deletions(-) create mode 100644 crates/wickra-core/src/indicators/doji.rs create mode 100644 crates/wickra-core/src/indicators/engulfing.rs create mode 100644 crates/wickra-core/src/indicators/hammer.rs create mode 100644 crates/wickra-core/src/indicators/hanging_man.rs create mode 100644 crates/wickra-core/src/indicators/harami.rs create mode 100644 crates/wickra-core/src/indicators/inverted_hammer.rs create mode 100644 crates/wickra-core/src/indicators/marubozu.rs create mode 100644 crates/wickra-core/src/indicators/morning_evening_star.rs create mode 100644 crates/wickra-core/src/indicators/piercing_dark_cloud.rs create mode 100644 crates/wickra-core/src/indicators/shooting_star.rs create mode 100644 crates/wickra-core/src/indicators/spinning_top.rs create mode 100644 crates/wickra-core/src/indicators/three_inside.rs create mode 100644 crates/wickra-core/src/indicators/three_outside.rs create mode 100644 crates/wickra-core/src/indicators/three_soldiers_or_crows.rs create mode 100644 crates/wickra-core/src/indicators/tweezer.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 02b67ed1..39824c02 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **Candlestick patterns family (15 indicators).** A new "Candlestick + Patterns" family covers the standard 1- to 3-bar reversal and + continuation shapes: `Doji`, `Hammer`, `InvertedHammer`, `HangingMan`, + `ShootingStar`, `Engulfing`, `Harami`, `MorningEveningStar`, + `ThreeSoldiersOrCrows`, `PiercingDarkCloud`, `Marubozu`, `Tweezer`, + `SpinningTop`, `ThreeInside` and `ThreeOutside`. Every detector takes a + `Candle` and emits a signed `f64` (`+1.0` bullish, `-1.0` bearish, `0.0` + no pattern; `Doji` is direction-less and emits `+1.0`/`0.0`). The MVP is + a pattern-shape check only — no trend filter is applied. Available + across Rust, Python, Node and WASM bindings. Harmonic and chart + patterns remain out of scope and will follow once the pattern-detection + framework (pivot detector + multi-bar state machines) lands. - **Market Profile family** (3 new indicators, opens family #9 across the catalogue): - `ValueArea(period, bin_count, value_area_pct)` — rolling diff --git a/README.md b/README.md index 71a8bc0d..ebf2ad64 100644 --- a/README.md +++ b/README.md @@ -109,7 +109,7 @@ python -m benchmarks.compare_libraries ## Indicators -181 streaming-first indicators across fourteen families. Every one passes the +196 streaming-first indicators across fifteen families. Every one passes the `batch == streaming` equivalence test, reference-value tests, and reset semantics tests. @@ -128,6 +128,7 @@ semantics tests. | Pivots & S/R | Classic Pivots, Fibonacci Pivots, Camarilla, Woodie Pivots, DeMark Pivots, Williams Fractals, ZigZag | | DeMark | TD Setup, TD Sequential, TD DeMarker, TD REI, TD Pressure, TD Combo, TD Countdown, TD Lines, TD Range Projection, TD Differential, TD Open, TD Risk Level | | Ichimoku & Charts | Ichimoku Kinko Hyo (Tenkan, Kijun, Senkou A/B, Chikou), Heikin-Ashi | +| Candlestick Patterns | Doji, Hammer, Inverted Hammer, Hanging Man, Shooting Star, Engulfing, Harami, Morning/Evening Star, Three White Soldiers/Black Crows, Piercing Line/Dark Cloud Cover, Marubozu, Tweezer, Spinning Top, Three Inside Up/Down, Three Outside Up/Down | | Market Profile | Value Area (POC / VAH / VAL), Initial Balance, Opening Range | Adding a new indicator means implementing one trait in Rust; all four bindings @@ -201,7 +202,7 @@ A Python live-trading example using the public `websockets` package lives at ``` wickra/ ├── crates/ -│ ├── wickra-core/ core engine + all 181 indicators +│ ├── wickra-core/ core engine + all 196 indicators │ ├── wickra/ top-level facade crate (publishes on crates.io) + benches/ │ └── wickra-data/ CSV reader, tick aggregator, live exchange feeds ├── bindings/ diff --git a/bindings/node/__tests__/indicators.test.js b/bindings/node/__tests__/indicators.test.js index 3d663394..5e533c2f 100644 --- a/bindings/node/__tests__/indicators.test.js +++ b/bindings/node/__tests__/indicators.test.js @@ -180,6 +180,22 @@ const candleScalar = { TDCountdown: { make: () => new wickra.TDCountdown(4, 9, 2, 13), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) }, TDDifferential: { make: () => new wickra.TDDifferential(), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) }, TDOpen: { make: () => new wickra.TDOpen(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) }, + // Family 14 — Candlestick patterns + Doji: { make: () => new wickra.Doji(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) }, + Hammer: { make: () => new wickra.Hammer(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) }, + InvertedHammer: { make: () => new wickra.InvertedHammer(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) }, + HangingMan: { make: () => new wickra.HangingMan(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) }, + ShootingStar: { make: () => new wickra.ShootingStar(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) }, + Engulfing: { make: () => new wickra.Engulfing(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) }, + Harami: { make: () => new wickra.Harami(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) }, + MorningEveningStar: { make: () => new wickra.MorningEveningStar(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) }, + ThreeSoldiersOrCrows: { make: () => new wickra.ThreeSoldiersOrCrows(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) }, + PiercingDarkCloud: { make: () => new wickra.PiercingDarkCloud(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) }, + Marubozu: { make: () => new wickra.Marubozu(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) }, + Tweezer: { make: () => new wickra.Tweezer(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) }, + SpinningTop: { make: () => new wickra.SpinningTop(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) }, + ThreeInside: { make: () => new wickra.ThreeInside(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) }, + ThreeOutside: { make: () => new wickra.ThreeOutside(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) }, }; for (const [name, d] of Object.entries(candleScalar)) { diff --git a/bindings/node/index.js b/bindings/node/index.js index 04026514..f786840c 100644 --- a/bindings/node/index.js +++ b/bindings/node/index.js @@ -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, 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, ClassicPivots, FibonacciPivots, Camarilla, WoodiePivots, DemarkPivots, WilliamsFractals, ZigZag, TDSetup, TDSequential, TDDeMarker, TDREI, TDPressure, TDCombo, TDCountdown, TDLines, TDRangeProjection, TDDifferential, TDOpen, TDRiskLevel, SuperSmoother, FisherTransform, InverseFisherTransform, Decycler, DecyclerOscillator, RoofingFilter, CenterOfGravity, CyberneticCycle, InstantaneousTrendline, EhlersStochastic, EmpiricalModeDecomposition, HilbertDominantCycle, AdaptiveCycle, SineWave, MAMA, FAMA, Ichimoku, HeikinAshi, Variance, CoefficientOfVariation, Skewness, Kurtosis, StandardError, DetrendedStdDev, RSquared, MedianAbsoluteDeviation, Autocorrelation, HurstExponent, PearsonCorrelation, Beta, SpearmanCorrelation } = 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, ClassicPivots, FibonacciPivots, Camarilla, WoodiePivots, DemarkPivots, WilliamsFractals, ZigZag, TDSetup, TDSequential, TDDeMarker, TDREI, TDPressure, TDCombo, TDCountdown, TDLines, TDRangeProjection, TDDifferential, TDOpen, TDRiskLevel, SuperSmoother, FisherTransform, InverseFisherTransform, Decycler, DecyclerOscillator, RoofingFilter, CenterOfGravity, CyberneticCycle, InstantaneousTrendline, EhlersStochastic, EmpiricalModeDecomposition, HilbertDominantCycle, AdaptiveCycle, SineWave, MAMA, FAMA, Ichimoku, HeikinAshi, Variance, CoefficientOfVariation, Skewness, Kurtosis, StandardError, DetrendedStdDev, RSquared, MedianAbsoluteDeviation, Autocorrelation, HurstExponent, PearsonCorrelation, Beta, SpearmanCorrelation, ValueArea, InitialBalance, OpeningRange, Doji, Hammer, InvertedHammer, HangingMan, ShootingStar, Engulfing, Harami, MorningEveningStar, ThreeSoldiersOrCrows, PiercingDarkCloud, Marubozu, Tweezer, SpinningTop, ThreeInside, ThreeOutside } = nativeBinding module.exports.version = version module.exports.SMA = SMA @@ -492,3 +492,21 @@ module.exports.HurstExponent = HurstExponent module.exports.PearsonCorrelation = PearsonCorrelation module.exports.Beta = Beta module.exports.SpearmanCorrelation = SpearmanCorrelation +module.exports.ValueArea = ValueArea +module.exports.InitialBalance = InitialBalance +module.exports.OpeningRange = OpeningRange +module.exports.Doji = Doji +module.exports.Hammer = Hammer +module.exports.InvertedHammer = InvertedHammer +module.exports.HangingMan = HangingMan +module.exports.ShootingStar = ShootingStar +module.exports.Engulfing = Engulfing +module.exports.Harami = Harami +module.exports.MorningEveningStar = MorningEveningStar +module.exports.ThreeSoldiersOrCrows = ThreeSoldiersOrCrows +module.exports.PiercingDarkCloud = PiercingDarkCloud +module.exports.Marubozu = Marubozu +module.exports.Tweezer = Tweezer +module.exports.SpinningTop = SpinningTop +module.exports.ThreeInside = ThreeInside +module.exports.ThreeOutside = ThreeOutside diff --git a/bindings/node/src/lib.rs b/bindings/node/src/lib.rs index 086f3934..8fbc6aad 100644 --- a/bindings/node/src/lib.rs +++ b/bindings/node/src/lib.rs @@ -8317,3 +8317,105 @@ impl OpeningRangeNode { Ok(out) } } +// ============================== Candlestick Patterns ============================== +// +// All 15 patterns take Candles (open, high, low, close) and emit a signed f64 +// signal per bar: +1.0 bullish, -1.0 bearish, 0.0 no pattern. Doji is +// direction-less and emits 0/+1 only. + +macro_rules! node_candle_pattern { + ($node:ident, $inner:ty, $js:literal) => { + #[napi(js_name = $js)] + pub struct $node { + inner: $inner, + } + + impl Default for $node { + fn default() -> Self { + Self::new() + } + } + + #[napi] + impl $node { + #[napi(constructor)] + pub fn new() -> Self { + Self { + inner: <$inner>::new(), + } + } + #[napi] + pub fn update( + &mut self, + open: f64, + high: f64, + low: f64, + close: f64, + ) -> napi::Result> { + let candle = wc::Candle::new(open, high, low, close, 0.0, 0).map_err(map_err)?; + Ok(self.inner.update(candle)) + } + #[napi] + pub fn batch( + &mut self, + open: Vec, + high: Vec, + low: Vec, + close: Vec, + ) -> napi::Result> { + if open.len() != high.len() || high.len() != low.len() || low.len() != close.len() { + return Err(NapiError::from_reason( + "open, high, low, close must be equal length".to_string(), + )); + } + let mut out = Vec::with_capacity(open.len()); + for i in 0..open.len() { + let candle = wc::Candle::new(open[i], high[i], low[i], close[i], 0.0, 0) + .map_err(map_err)?; + out.push(self.inner.update(candle).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 + } + } + }; +} + +node_candle_pattern!(DojiNode, wc::Doji, "Doji"); +node_candle_pattern!(HammerNode, wc::Hammer, "Hammer"); +node_candle_pattern!(InvertedHammerNode, wc::InvertedHammer, "InvertedHammer"); +node_candle_pattern!(HangingManNode, wc::HangingMan, "HangingMan"); +node_candle_pattern!(ShootingStarNode, wc::ShootingStar, "ShootingStar"); +node_candle_pattern!(EngulfingNode, wc::Engulfing, "Engulfing"); +node_candle_pattern!(HaramiNode, wc::Harami, "Harami"); +node_candle_pattern!( + MorningEveningStarNode, + wc::MorningEveningStar, + "MorningEveningStar" +); +node_candle_pattern!( + ThreeSoldiersOrCrowsNode, + wc::ThreeSoldiersOrCrows, + "ThreeSoldiersOrCrows" +); +node_candle_pattern!( + PiercingDarkCloudNode, + wc::PiercingDarkCloud, + "PiercingDarkCloud" +); +node_candle_pattern!(MarubozuNode, wc::Marubozu, "Marubozu"); +node_candle_pattern!(TweezerNode, wc::Tweezer, "Tweezer"); +node_candle_pattern!(SpinningTopNode, wc::SpinningTop, "SpinningTop"); +node_candle_pattern!(ThreeInsideNode, wc::ThreeInside, "ThreeInside"); +node_candle_pattern!(ThreeOutsideNode, wc::ThreeOutside, "ThreeOutside"); diff --git a/bindings/python/python/wickra/__init__.py b/bindings/python/python/wickra/__init__.py index 8ce9f4f2..9b1fabcd 100644 --- a/bindings/python/python/wickra/__init__.py +++ b/bindings/python/python/wickra/__init__.py @@ -219,6 +219,22 @@ from ._wickra import ( ValueArea, InitialBalance, OpeningRange, + # Candlestick patterns + Doji, + Hammer, + InvertedHammer, + HangingMan, + ShootingStar, + Engulfing, + Harami, + MorningEveningStar, + ThreeSoldiersOrCrows, + PiercingDarkCloud, + Marubozu, + Tweezer, + SpinningTop, + ThreeInside, + ThreeOutside, ) __all__ = [ @@ -417,4 +433,20 @@ __all__ = [ "ValueArea", "InitialBalance", "OpeningRange", + # Candlestick patterns + "Doji", + "Hammer", + "InvertedHammer", + "HangingMan", + "ShootingStar", + "Engulfing", + "Harami", + "MorningEveningStar", + "ThreeSoldiersOrCrows", + "PiercingDarkCloud", + "Marubozu", + "Tweezer", + "SpinningTop", + "ThreeInside", + "ThreeOutside", ] diff --git a/bindings/python/src/lib.rs b/bindings/python/src/lib.rs index 5701fd20..3034f405 100644 --- a/bindings/python/src/lib.rs +++ b/bindings/python/src/lib.rs @@ -11054,6 +11054,109 @@ impl PyOpeningRange { } } +// ============================== Candlestick Patterns ============================== +// +// All 15 patterns take Candles and emit a signed f64 signal per bar: +// +1.0 bullish, -1.0 bearish, 0.0 no pattern. Doji is direction-less, so it +// uses +1.0 / 0.0 only. + +macro_rules! candle_pattern_no_param { + ($name:ident, $inner:ty, $repr:expr) => { + #[pyclass(name = $repr, module = "wickra._wickra", skip_from_py_object)] + #[derive(Clone)] + struct $name { + inner: $inner, + } + + #[pymethods] + impl $name { + #[new] + fn new() -> Self { + Self { + inner: <$inner>::new(), + } + } + fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult> { + let c = extract_candle(candle)?; + Ok(self.inner.update(c)) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let o = open + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + 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 o.len() != h.len() || h.len() != l.len() || l.len() != c.len() { + return Err(PyValueError::new_err( + "open, high, low, close must be equal length", + )); + } + let mut out = Vec::with_capacity(o.len()); + for i in 0..o.len() { + let candle = + wc::Candle::new(o[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)) + } + 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!("{}()", $repr) + } + } + }; +} + +candle_pattern_no_param!(PyDoji, wc::Doji, "Doji"); +candle_pattern_no_param!(PyHammer, wc::Hammer, "Hammer"); +candle_pattern_no_param!(PyInvertedHammer, wc::InvertedHammer, "InvertedHammer"); +candle_pattern_no_param!(PyHangingMan, wc::HangingMan, "HangingMan"); +candle_pattern_no_param!(PyShootingStar, wc::ShootingStar, "ShootingStar"); +candle_pattern_no_param!(PyEngulfing, wc::Engulfing, "Engulfing"); +candle_pattern_no_param!(PyHarami, wc::Harami, "Harami"); +candle_pattern_no_param!( + PyMorningEveningStar, + wc::MorningEveningStar, + "MorningEveningStar" +); +candle_pattern_no_param!( + PyThreeSoldiersOrCrows, + wc::ThreeSoldiersOrCrows, + "ThreeSoldiersOrCrows" +); +candle_pattern_no_param!( + PyPiercingDarkCloud, + wc::PiercingDarkCloud, + "PiercingDarkCloud" +); +candle_pattern_no_param!(PyMarubozu, wc::Marubozu, "Marubozu"); +candle_pattern_no_param!(PyTweezer, wc::Tweezer, "Tweezer"); +candle_pattern_no_param!(PySpinningTop, wc::SpinningTop, "SpinningTop"); +candle_pattern_no_param!(PyThreeInside, wc::ThreeInside, "ThreeInside"); +candle_pattern_no_param!(PyThreeOutside, wc::ThreeOutside, "ThreeOutside"); + // ============================== Module ============================== #[pymodule] @@ -11245,5 +11348,21 @@ fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; m.add_class::()?; m.add_class::()?; + // Candlestick patterns. + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; Ok(()) } diff --git a/bindings/python/tests/test_new_indicators.py b/bindings/python/tests/test_new_indicators.py index feb634f0..9586e512 100644 --- a/bindings/python/tests/test_new_indicators.py +++ b/bindings/python/tests/test_new_indicators.py @@ -325,6 +325,68 @@ CANDLE_SCALAR = { lambda: ta.TDDifferential(), lambda ind, h, l, c, v: ind.batch(h, l, c), ), + # Candlestick patterns -- batch takes (open, high, low, close) and the + # streaming side feeds close as open (same convention as BalanceOfPower). + "Doji": ( + lambda: ta.Doji(), + lambda ind, h, l, c, v: ind.batch(c, h, l, c), + ), + "Hammer": ( + lambda: ta.Hammer(), + lambda ind, h, l, c, v: ind.batch(c, h, l, c), + ), + "InvertedHammer": ( + lambda: ta.InvertedHammer(), + lambda ind, h, l, c, v: ind.batch(c, h, l, c), + ), + "HangingMan": ( + lambda: ta.HangingMan(), + lambda ind, h, l, c, v: ind.batch(c, h, l, c), + ), + "ShootingStar": ( + lambda: ta.ShootingStar(), + lambda ind, h, l, c, v: ind.batch(c, h, l, c), + ), + "Engulfing": ( + lambda: ta.Engulfing(), + lambda ind, h, l, c, v: ind.batch(c, h, l, c), + ), + "Harami": ( + lambda: ta.Harami(), + lambda ind, h, l, c, v: ind.batch(c, h, l, c), + ), + "MorningEveningStar": ( + lambda: ta.MorningEveningStar(), + lambda ind, h, l, c, v: ind.batch(c, h, l, c), + ), + "ThreeSoldiersOrCrows": ( + lambda: ta.ThreeSoldiersOrCrows(), + lambda ind, h, l, c, v: ind.batch(c, h, l, c), + ), + "PiercingDarkCloud": ( + lambda: ta.PiercingDarkCloud(), + lambda ind, h, l, c, v: ind.batch(c, h, l, c), + ), + "Marubozu": ( + lambda: ta.Marubozu(), + lambda ind, h, l, c, v: ind.batch(c, h, l, c), + ), + "Tweezer": ( + lambda: ta.Tweezer(), + lambda ind, h, l, c, v: ind.batch(c, h, l, c), + ), + "SpinningTop": ( + lambda: ta.SpinningTop(), + lambda ind, h, l, c, v: ind.batch(c, h, l, c), + ), + "ThreeInside": ( + lambda: ta.ThreeInside(), + lambda ind, h, l, c, v: ind.batch(c, h, l, c), + ), + "ThreeOutside": ( + lambda: ta.ThreeOutside(), + lambda ind, h, l, c, v: ind.batch(c, h, l, c), + ), } @@ -1551,3 +1613,117 @@ def test_heikin_ashi_lifecycle_and_reset(): assert ha.is_ready() is True ha.reset() assert ha.is_ready() is False +# --- Candlestick pattern reference values -------------------------------- + + +def test_doji_reference(): + # body 0, range 2 -> doji. + assert ta.Doji().update((10.0, 11.0, 9.0, 10.0, 1.0, 0)) == pytest.approx(1.0) + # Marubozu shape -> not a doji. + assert ta.Doji().update((10.0, 12.0, 10.0, 12.0, 1.0, 0)) == pytest.approx(0.0) + + +def test_hammer_reference(): + # body 0.5, lower shadow 5.0, upper 0.1. + assert ta.Hammer().update((10.0, 10.6, 5.0, 10.5, 1.0, 0)) == pytest.approx(1.0) + + +def test_inverted_hammer_reference(): + assert ta.InvertedHammer().update( + (10.0, 15.0, 9.9, 10.5, 1.0, 0) + ) == pytest.approx(1.0) + + +def test_hanging_man_reference(): + assert ta.HangingMan().update((10.0, 10.6, 5.0, 10.5, 1.0, 0)) == pytest.approx( + -1.0 + ) + + +def test_shooting_star_reference(): + assert ta.ShootingStar().update( + (10.0, 15.0, 9.9, 10.5, 1.0, 0) + ) == pytest.approx(-1.0) + + +def test_engulfing_reference(): + e = ta.Engulfing() + assert e.update((11.0, 11.2, 9.8, 10.0, 1.0, 0)) == pytest.approx(0.0) + assert e.update((9.5, 12.0, 9.5, 11.5, 1.0, 1)) == pytest.approx(1.0) + + +def test_harami_reference(): + h = ta.Harami() + assert h.update((12.0, 12.5, 9.5, 10.0, 1.0, 0)) == pytest.approx(0.0) + assert h.update((10.5, 11.5, 10.4, 11.0, 1.0, 1)) == pytest.approx(1.0) + + +def test_morning_evening_star_reference(): + m = ta.MorningEveningStar() + assert m.update((12.0, 12.2, 9.5, 10.0, 1.0, 0)) == pytest.approx(0.0) + assert m.update((9.9, 10.1, 9.7, 9.95, 1.0, 1)) == pytest.approx(0.0) + assert m.update((10.1, 12.0, 10.0, 11.8, 1.0, 2)) == pytest.approx(1.0) + + +def test_three_soldiers_reference(): + t = ta.ThreeSoldiersOrCrows() + assert t.update((10.0, 11.5, 9.9, 11.0, 1.0, 0)) == pytest.approx(0.0) + assert t.update((10.5, 12.5, 10.4, 12.0, 1.0, 1)) == pytest.approx(0.0) + assert t.update((11.5, 13.5, 11.4, 13.0, 1.0, 2)) == pytest.approx(1.0) + + +def test_piercing_dark_cloud_reference(): + p = ta.PiercingDarkCloud() + assert p.update((12.0, 12.5, 10.0, 10.0, 1.0, 0)) == pytest.approx(0.0) + assert p.update((9.8, 11.8, 9.5, 11.5, 1.0, 1)) == pytest.approx(1.0) + + +def test_marubozu_reference(): + # Bullish marubozu: open == low, close == high. + assert ta.Marubozu().update( + (10.0, 12.0, 10.0, 12.0, 1.0, 0) + ) == pytest.approx(1.0) + assert ta.Marubozu().update( + (12.0, 12.0, 10.0, 10.0, 1.0, 0) + ) == pytest.approx(-1.0) + + +def test_tweezer_reference(): + t = ta.Tweezer() + assert t.update((11.0, 12.0, 9.5, 9.6, 1.0, 0)) == pytest.approx(0.0) + assert t.update((9.7, 10.5, 9.5, 10.2, 1.0, 1)) == pytest.approx(1.0) + + +def test_spinning_top_reference(): + # body 0.5, both shadows 3.0, range 6.5 -> body/range ~= 0.077. + assert ta.SpinningTop().update( + (10.0, 13.5, 7.0, 10.5, 1.0, 0) + ) == pytest.approx(1.0) + + +def test_three_inside_reference(): + t = ta.ThreeInside() + assert t.update((12.0, 12.5, 9.5, 10.0, 1.0, 0)) == pytest.approx(0.0) + assert t.update((10.5, 11.5, 10.4, 11.0, 1.0, 1)) == pytest.approx(0.0) + assert t.update((11.0, 13.0, 10.9, 12.5, 1.0, 2)) == pytest.approx(1.0) + + +def test_three_outside_reference(): + t = ta.ThreeOutside() + assert t.update((11.0, 11.2, 9.8, 10.0, 1.0, 0)) == pytest.approx(0.0) + assert t.update((9.5, 12.0, 9.5, 11.5, 1.0, 1)) == pytest.approx(0.0) + assert t.update((11.5, 13.0, 11.4, 12.5, 1.0, 2)) == pytest.approx(1.0) + + +# --- Lifecycle ------------------------------------------------------------ + + +def test_new_indicators_expose_lifecycle(): + instances = [make() for make, _ in CANDLE_SCALAR.values()] + instances += [make() for make, *_ in MULTI.values()] + instances += [cls(*args) for cls, args in SCALAR] + for ind in instances: + assert ind.is_ready() is False + assert ind.warmup_period() >= 1 + ind.reset() + assert ind.is_ready() is False diff --git a/bindings/wasm/src/lib.rs b/bindings/wasm/src/lib.rs index 9899c71a..69d5c884 100644 --- a/bindings/wasm/src/lib.rs +++ b/bindings/wasm/src/lib.rs @@ -5946,6 +5946,105 @@ impl WasmOpeningRange { } } +// ============================== Candlestick Patterns ============================== +// +// All 15 patterns take Candles (open, high, low, close) and emit a signed f64 +// signal per bar: +1.0 bullish, -1.0 bearish, 0.0 no pattern. Doji is +// direction-less and emits 0/+1 only. + +macro_rules! wasm_candle_pattern { + ($wasm:ident, $inner:ty, $js:ident) => { + #[wasm_bindgen(js_name = $js)] + pub struct $wasm { + inner: $inner, + } + + impl Default for $wasm { + fn default() -> Self { + Self::new() + } + } + + #[wasm_bindgen(js_class = $js)] + impl $wasm { + #[wasm_bindgen(constructor)] + pub fn new() -> $wasm { + Self { + inner: <$inner>::new(), + } + } + pub fn update( + &mut self, + open: f64, + high: f64, + low: f64, + close: f64, + ) -> Result, JsError> { + let c = wc::Candle::new(open, high, low, close, 0.0, 0).map_err(map_err)?; + Ok(self.inner.update(c)) + } + pub fn batch( + &mut self, + open: &[f64], + high: &[f64], + low: &[f64], + close: &[f64], + ) -> Result { + let n = open.len(); + if high.len() != n || low.len() != n || close.len() != n { + return Err(JsError::new("open, high, low, close must be equal length")); + } + let mut out = Vec::with_capacity(n); + for i in 0..n { + let c = wc::Candle::new(open[i], high[i], low[i], close[i], 0.0, 0) + .map_err(map_err)?; + 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 = isReady)] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[wasm_bindgen(js_name = warmupPeriod)] + pub fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } + } + }; +} + +wasm_candle_pattern!(WasmDoji, wc::Doji, Doji); +wasm_candle_pattern!(WasmHammer, wc::Hammer, Hammer); +wasm_candle_pattern!(WasmInvertedHammer, wc::InvertedHammer, InvertedHammer); +wasm_candle_pattern!(WasmHangingMan, wc::HangingMan, HangingMan); +wasm_candle_pattern!(WasmShootingStar, wc::ShootingStar, ShootingStar); +wasm_candle_pattern!(WasmEngulfing, wc::Engulfing, Engulfing); +wasm_candle_pattern!(WasmHarami, wc::Harami, Harami); +wasm_candle_pattern!( + WasmMorningEveningStar, + wc::MorningEveningStar, + MorningEveningStar +); +wasm_candle_pattern!( + WasmThreeSoldiersOrCrows, + wc::ThreeSoldiersOrCrows, + ThreeSoldiersOrCrows +); +wasm_candle_pattern!( + WasmPiercingDarkCloud, + wc::PiercingDarkCloud, + PiercingDarkCloud +); +wasm_candle_pattern!(WasmMarubozu, wc::Marubozu, Marubozu); +wasm_candle_pattern!(WasmTweezer, wc::Tweezer, Tweezer); +wasm_candle_pattern!(WasmSpinningTop, wc::SpinningTop, SpinningTop); +wasm_candle_pattern!(WasmThreeInside, wc::ThreeInside, ThreeInside); +wasm_candle_pattern!(WasmThreeOutside, wc::ThreeOutside, ThreeOutside); + #[cfg(test)] mod tests { use super::*; diff --git a/crates/wickra-core/src/indicators/doji.rs b/crates/wickra-core/src/indicators/doji.rs new file mode 100644 index 00000000..ff32f2bd --- /dev/null +++ b/crates/wickra-core/src/indicators/doji.rs @@ -0,0 +1,185 @@ +//! Doji candlestick pattern. + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Doji — a candle whose body is negligible relative to its range. +/// +/// A Doji prints whenever the absolute distance between open and close is +/// small compared to the total `high − low` range. It is the canonical +/// indecision bar and a building block for many three-bar reversal patterns. +/// +/// ```text +/// body = |close − open| +/// range = high − low +/// doji = body <= body_threshold * range +/// ``` +/// +/// The output is `+1.0` when a Doji is detected and `0.0` otherwise. Doji is +/// directionless — no `−1.0` is emitted. Pattern-shape check only — no trend +/// filter is applied; combine with a trend indicator for actionable signals. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Doji, Indicator}; +/// +/// let mut indicator = Doji::default(); +/// let candle = Candle::new(10.0, 11.0, 9.0, 10.0, 1.0, 0).unwrap(); +/// assert_eq!(indicator.update(candle), Some(1.0)); +/// ``` +#[derive(Debug, Clone)] +pub struct Doji { + body_threshold: f64, + has_emitted: bool, +} + +impl Default for Doji { + fn default() -> Self { + Self::new() + } +} + +impl Doji { + /// Construct a Doji detector with the default body threshold (`0.1`). + pub const fn new() -> Self { + Self { + body_threshold: 0.1, + has_emitted: false, + } + } + + /// Construct a Doji detector with a custom body / range threshold. + /// + /// `body_threshold` must lie in `(0, 1]`. + pub fn with_threshold(body_threshold: f64) -> Result { + if !(body_threshold > 0.0 && body_threshold <= 1.0) { + return Err(Error::InvalidPeriod { + message: "doji body threshold must lie in (0, 1]", + }); + } + Ok(Self { + body_threshold, + has_emitted: false, + }) + } + + /// Configured body / range threshold. + pub fn body_threshold(&self) -> f64 { + self.body_threshold + } +} + +impl Indicator for Doji { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + self.has_emitted = true; + let range = candle.high - candle.low; + if range <= 0.0 { + return Some(0.0); + } + let body = (candle.close - candle.open).abs(); + Some(if body <= self.body_threshold * range { + 1.0 + } else { + 0.0 + }) + } + + fn reset(&mut self) { + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "Doji" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new(open, high, low, close, 1.0, ts).unwrap() + } + + #[test] + fn rejects_invalid_threshold() { + assert!(Doji::with_threshold(0.0).is_err()); + assert!(Doji::with_threshold(-0.1).is_err()); + assert!(Doji::with_threshold(1.5).is_err()); + } + + #[test] + fn accepts_valid_threshold() { + let d = Doji::with_threshold(0.05).unwrap(); + assert!((d.body_threshold() - 0.05).abs() < 1e-12); + } + + #[test] + fn accessors_and_metadata() { + let d = Doji::default(); + assert_eq!(d.name(), "Doji"); + assert_eq!(d.warmup_period(), 1); + assert!(!d.is_ready()); + assert!((d.body_threshold() - 0.1).abs() < 1e-12); + } + + #[test] + fn obvious_doji_is_one() { + let mut d = Doji::new(); + // open == close, full range -> body / range = 0. + assert_eq!(d.update(c(10.0, 11.0, 9.0, 10.0, 0)), Some(1.0)); + assert!(d.is_ready()); + } + + #[test] + fn marubozu_is_not_doji() { + // Big body, no shadows -> body / range = 1.0 > 0.1. + let mut d = Doji::new(); + assert_eq!(d.update(c(10.0, 12.0, 10.0, 12.0, 0)), Some(0.0)); + } + + #[test] + fn zero_range_yields_zero() { + let mut d = Doji::new(); + assert_eq!(d.update(c(10.0, 10.0, 10.0, 10.0, 0)), Some(0.0)); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40) + .map(|i| { + let base = 100.0 + i as f64; + c(base, base + 2.0, base - 2.0, base + 1.0, i) + }) + .collect(); + let mut a = Doji::new(); + let mut b = Doji::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut d = Doji::new(); + d.update(c(10.0, 11.0, 9.0, 10.0, 0)); + assert!(d.is_ready()); + d.reset(); + assert!(!d.is_ready()); + } +} diff --git a/crates/wickra-core/src/indicators/engulfing.rs b/crates/wickra-core/src/indicators/engulfing.rs new file mode 100644 index 00000000..21e0f019 --- /dev/null +++ b/crates/wickra-core/src/indicators/engulfing.rs @@ -0,0 +1,186 @@ +//! Bullish / Bearish Engulfing candlestick pattern. + +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Engulfing — a 2-bar reversal pattern. The current candle's body fully +/// engulfs the prior candle's body and points in the opposite direction. +/// +/// ```text +/// prev_body = |prev.close − prev.open| +/// curr_body = |curr.close − curr.open| +/// bullish = prev red & curr green +/// & curr.open <= prev.close & curr.close >= prev.open +/// & curr_body > prev_body +/// bearish = prev green & curr red +/// & curr.open >= prev.close & curr.close <= prev.open +/// & curr_body > prev_body +/// ``` +/// +/// Output is `+1.0` for a bullish engulfing, `−1.0` for a bearish one, and +/// `0.0` otherwise. The first bar always returns `0.0` because no previous +/// body exists to engulf. Pattern-shape check only — no trend filter is +/// applied; combine with a trend indicator for actionable signals. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Engulfing, Indicator}; +/// +/// let mut indicator = Engulfing::new(); +/// // Prior red candle followed by a larger green engulfing candle. +/// indicator.update(Candle::new(11.0, 11.2, 9.8, 10.0, 1.0, 0).unwrap()); +/// let out = indicator +/// .update(Candle::new(9.5, 12.0, 9.5, 11.5, 1.0, 1).unwrap()); +/// assert_eq!(out, Some(1.0)); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct Engulfing { + prev: Option, + has_emitted: bool, +} + +impl Engulfing { + /// Construct a new Engulfing detector. + pub const fn new() -> Self { + Self { + prev: None, + has_emitted: false, + } + } +} + +impl Indicator for Engulfing { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + self.has_emitted = true; + let prev = self.prev; + self.prev = Some(candle); + let Some(p) = prev else { + return Some(0.0); + }; + let prev_body = (p.close - p.open).abs(); + let curr_body = (candle.close - candle.open).abs(); + if prev_body <= 0.0 || curr_body <= prev_body { + return Some(0.0); + } + let prev_red = p.close < p.open; + let prev_green = p.close > p.open; + let curr_green = candle.close > candle.open; + let curr_red = candle.close < candle.open; + if prev_red && curr_green && candle.open <= p.close && candle.close >= p.open { + Some(1.0) + } else if prev_green && curr_red && candle.open >= p.close && candle.close <= p.open { + Some(-1.0) + } else { + Some(0.0) + } + } + + fn reset(&mut self) { + self.prev = None; + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 2 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "Engulfing" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new(open, high, low, close, 1.0, ts).unwrap() + } + + #[test] + fn accessors_and_metadata() { + let e = Engulfing::new(); + assert_eq!(e.name(), "Engulfing"); + assert_eq!(e.warmup_period(), 2); + assert!(!e.is_ready()); + } + + #[test] + fn bullish_engulfing_is_plus_one() { + let mut e = Engulfing::new(); + // Prior red 11 -> 10, current green 9.5 -> 11.5 (body 2 > 1). + assert_eq!(e.update(c(11.0, 11.2, 9.8, 10.0, 0)), Some(0.0)); + assert_eq!(e.update(c(9.5, 12.0, 9.5, 11.5, 1)), Some(1.0)); + } + + #[test] + fn bearish_engulfing_is_minus_one() { + let mut e = Engulfing::new(); + // Prior green 10 -> 11, current red 12 -> 9. + assert_eq!(e.update(c(10.0, 11.2, 9.8, 11.0, 0)), Some(0.0)); + assert_eq!(e.update(c(12.0, 12.0, 9.0, 9.0, 1)), Some(-1.0)); + } + + #[test] + fn same_direction_is_not_engulfing() { + let mut e = Engulfing::new(); + e.update(c(10.0, 11.0, 9.8, 11.0, 0)); + // Another green candle that engulfs but matches direction -> 0. + assert_eq!(e.update(c(9.5, 12.0, 9.5, 11.5, 1)), Some(0.0)); + } + + #[test] + fn smaller_body_is_not_engulfing() { + let mut e = Engulfing::new(); + e.update(c(11.0, 11.2, 8.0, 8.5, 0)); + // Body 0.5 < 2.5 -> not engulfing. + assert_eq!(e.update(c(8.6, 9.0, 8.4, 8.7, 1)), Some(0.0)); + } + + #[test] + fn first_bar_returns_zero() { + let mut e = Engulfing::new(); + assert_eq!(e.update(c(10.0, 11.0, 9.0, 11.0, 0)), Some(0.0)); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40) + .map(|i| { + let base = 100.0 + i as f64; + if i % 3 == 0 { + c(base + 1.0, base + 1.5, base - 0.5, base, i) + } else { + c(base - 1.0, base + 2.0, base - 1.5, base + 2.0, i) + } + }) + .collect(); + let mut a = Engulfing::new(); + let mut b = Engulfing::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut e = Engulfing::new(); + e.update(c(10.0, 11.0, 9.0, 11.0, 0)); + e.update(c(11.0, 12.0, 10.0, 12.0, 1)); + assert!(e.is_ready()); + e.reset(); + assert!(!e.is_ready()); + // After reset the next bar again has no prev. + assert_eq!(e.update(c(11.0, 11.2, 9.8, 10.0, 0)), Some(0.0)); + } +} diff --git a/crates/wickra-core/src/indicators/hammer.rs b/crates/wickra-core/src/indicators/hammer.rs new file mode 100644 index 00000000..8add55c2 --- /dev/null +++ b/crates/wickra-core/src/indicators/hammer.rs @@ -0,0 +1,160 @@ +//! Hammer candlestick pattern. + +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Hammer — a single-bar bullish reversal candidate. +/// +/// A Hammer has a small real body sitting near the top of the bar, a long +/// lower shadow at least twice the body, and a short or absent upper shadow. +/// It is traditionally read as a rejection of lower prices. +/// +/// ```text +/// body = |close − open| +/// upper_shadow = high − max(open, close) +/// lower_shadow = min(open, close) − low +/// hammer = lower_shadow >= 2 * body +/// && upper_shadow <= body +/// && body > 0 +/// ``` +/// +/// Output is `+1.0` when the shape matches, `0.0` otherwise. Pattern-shape +/// check only — no trend filter is applied; combine with a trend indicator +/// for actionable signals. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Hammer, Indicator}; +/// +/// let mut indicator = Hammer::new(); +/// // Open 10, close 10.5, low 5, high 10.6: long lower shadow, tiny upper. +/// let candle = Candle::new(10.0, 10.6, 5.0, 10.5, 1.0, 0).unwrap(); +/// assert_eq!(indicator.update(candle), Some(1.0)); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct Hammer { + has_emitted: bool, +} + +impl Hammer { + /// Construct a new Hammer detector. + pub const fn new() -> Self { + Self { has_emitted: false } + } +} + +impl Indicator for Hammer { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + self.has_emitted = true; + let range = candle.high - candle.low; + if range <= 0.0 { + return Some(0.0); + } + let body = (candle.close - candle.open).abs(); + if body <= 0.0 { + return Some(0.0); + } + let upper = candle.high - candle.open.max(candle.close); + let lower = candle.open.min(candle.close) - candle.low; + Some(if lower >= 2.0 * body && upper <= body { + 1.0 + } else { + 0.0 + }) + } + + fn reset(&mut self) { + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "Hammer" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new(open, high, low, close, 1.0, ts).unwrap() + } + + #[test] + fn accessors_and_metadata() { + let h = Hammer::new(); + assert_eq!(h.name(), "Hammer"); + assert_eq!(h.warmup_period(), 1); + assert!(!h.is_ready()); + } + + #[test] + fn clean_hammer_is_one() { + let mut h = Hammer::new(); + // body 0.5 (10 -> 10.5), lower shadow 5.0, upper shadow 0.1. + assert_eq!(h.update(c(10.0, 10.6, 5.0, 10.5, 0)), Some(1.0)); + } + + #[test] + fn marubozu_is_not_hammer() { + let mut h = Hammer::new(); + assert_eq!(h.update(c(10.0, 12.0, 10.0, 12.0, 0)), Some(0.0)); + } + + #[test] + fn shooting_star_shape_is_not_hammer() { + // Long upper, short lower -> not a hammer. + let mut h = Hammer::new(); + assert_eq!(h.update(c(10.5, 15.0, 10.0, 10.0, 0)), Some(0.0)); + } + + #[test] + fn doji_is_not_hammer() { + let mut h = Hammer::new(); + assert_eq!(h.update(c(10.0, 11.0, 9.0, 10.0, 0)), Some(0.0)); + } + + #[test] + fn zero_range_yields_zero() { + let mut h = Hammer::new(); + assert_eq!(h.update(c(10.0, 10.0, 10.0, 10.0, 0)), Some(0.0)); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40) + .map(|i| { + let base = 100.0 + i as f64; + c(base, base + 2.0, base - 4.0, base + 0.5, i) + }) + .collect(); + let mut a = Hammer::new(); + let mut b = Hammer::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut h = Hammer::new(); + h.update(c(10.0, 10.6, 5.0, 10.5, 0)); + assert!(h.is_ready()); + h.reset(); + assert!(!h.is_ready()); + } +} diff --git a/crates/wickra-core/src/indicators/hanging_man.rs b/crates/wickra-core/src/indicators/hanging_man.rs new file mode 100644 index 00000000..f8ab1ace --- /dev/null +++ b/crates/wickra-core/src/indicators/hanging_man.rs @@ -0,0 +1,151 @@ +//! Hanging Man candlestick pattern. + +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Hanging Man — a single-bar bearish reversal candidate. +/// +/// A Hanging Man has the same geometry as a Hammer (small body near the top, +/// long lower shadow ≥ 2× body, short upper shadow) but is read bearishly +/// because it appears at the top of an uptrend. +/// +/// ```text +/// body = |close − open| +/// upper_shadow = high − max(open, close) +/// lower_shadow = min(open, close) − low +/// hanging = lower_shadow >= 2 * body +/// && upper_shadow <= body +/// && body > 0 +/// ``` +/// +/// Output is `−1.0` when the shape matches, `0.0` otherwise. Pattern-shape +/// check only — no trend filter is applied; combine with a trend indicator +/// for actionable signals. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, HangingMan, Indicator}; +/// +/// let mut indicator = HangingMan::new(); +/// let candle = Candle::new(10.0, 10.6, 5.0, 10.5, 1.0, 0).unwrap(); +/// assert_eq!(indicator.update(candle), Some(-1.0)); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct HangingMan { + has_emitted: bool, +} + +impl HangingMan { + /// Construct a new Hanging Man detector. + pub const fn new() -> Self { + Self { has_emitted: false } + } +} + +impl Indicator for HangingMan { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + self.has_emitted = true; + let range = candle.high - candle.low; + if range <= 0.0 { + return Some(0.0); + } + let body = (candle.close - candle.open).abs(); + if body <= 0.0 { + return Some(0.0); + } + let upper = candle.high - candle.open.max(candle.close); + let lower = candle.open.min(candle.close) - candle.low; + Some(if lower >= 2.0 * body && upper <= body { + -1.0 + } else { + 0.0 + }) + } + + fn reset(&mut self) { + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "HangingMan" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new(open, high, low, close, 1.0, ts).unwrap() + } + + #[test] + fn accessors_and_metadata() { + let h = HangingMan::new(); + assert_eq!(h.name(), "HangingMan"); + assert_eq!(h.warmup_period(), 1); + assert!(!h.is_ready()); + } + + #[test] + fn clean_hanging_man_is_minus_one() { + let mut h = HangingMan::new(); + assert_eq!(h.update(c(10.0, 10.6, 5.0, 10.5, 0)), Some(-1.0)); + } + + #[test] + fn marubozu_is_not_hanging_man() { + let mut h = HangingMan::new(); + assert_eq!(h.update(c(10.0, 12.0, 10.0, 12.0, 0)), Some(0.0)); + } + + #[test] + fn doji_is_not_hanging_man() { + let mut h = HangingMan::new(); + assert_eq!(h.update(c(10.0, 11.0, 9.0, 10.0, 0)), Some(0.0)); + } + + #[test] + fn zero_range_yields_zero() { + let mut h = HangingMan::new(); + assert_eq!(h.update(c(10.0, 10.0, 10.0, 10.0, 0)), Some(0.0)); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40) + .map(|i| { + let base = 100.0 + i as f64; + c(base, base + 2.0, base - 4.0, base + 0.5, i) + }) + .collect(); + let mut a = HangingMan::new(); + let mut b = HangingMan::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut h = HangingMan::new(); + h.update(c(10.0, 10.6, 5.0, 10.5, 0)); + assert!(h.is_ready()); + h.reset(); + assert!(!h.is_ready()); + } +} diff --git a/crates/wickra-core/src/indicators/harami.rs b/crates/wickra-core/src/indicators/harami.rs new file mode 100644 index 00000000..89e6e2df --- /dev/null +++ b/crates/wickra-core/src/indicators/harami.rs @@ -0,0 +1,187 @@ +//! Bullish / Bearish Harami candlestick pattern. + +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Harami — a 2-bar reversal pattern. The current candle's body sits entirely +/// inside the previous candle's body and points in the opposite direction. +/// +/// ```text +/// prev_body = |prev.close − prev.open| +/// curr_body = |curr.close − curr.open| +/// bullish = prev red & curr green +/// & curr.open >= prev.close & curr.close <= prev.open +/// & curr_body < prev_body +/// bearish = prev green & curr red +/// & curr.open <= prev.close & curr.close >= prev.open +/// & curr_body < prev_body +/// ``` +/// +/// Output is `+1.0` for a bullish harami (small green inside a prior red), +/// `−1.0` for a bearish harami (small red inside a prior green), `0.0` +/// otherwise. The first bar always returns `0.0`. Pattern-shape check only — +/// no trend filter is applied; combine with a trend indicator for actionable +/// signals. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Harami, Indicator}; +/// +/// let mut indicator = Harami::new(); +/// indicator.update(Candle::new(12.0, 12.5, 9.5, 10.0, 1.0, 0).unwrap()); +/// let out = indicator +/// .update(Candle::new(10.5, 11.5, 10.4, 11.0, 1.0, 1).unwrap()); +/// assert_eq!(out, Some(1.0)); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct Harami { + prev: Option, + has_emitted: bool, +} + +impl Harami { + /// Construct a new Harami detector. + pub const fn new() -> Self { + Self { + prev: None, + has_emitted: false, + } + } +} + +impl Indicator for Harami { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + self.has_emitted = true; + let prev = self.prev; + self.prev = Some(candle); + let Some(p) = prev else { + return Some(0.0); + }; + let prev_body = (p.close - p.open).abs(); + let curr_body = (candle.close - candle.open).abs(); + if prev_body <= 0.0 || curr_body <= 0.0 || curr_body >= prev_body { + return Some(0.0); + } + let prev_red = p.close < p.open; + let prev_green = p.close > p.open; + let curr_green = candle.close > candle.open; + let curr_red = candle.close < candle.open; + // Bullish: small green strictly inside prior red body (open >= prev.close, close <= prev.open). + if prev_red && curr_green && candle.open >= p.close && candle.close <= p.open { + Some(1.0) + } else if prev_green && curr_red && candle.open <= p.close && candle.close >= p.open { + Some(-1.0) + } else { + Some(0.0) + } + } + + fn reset(&mut self) { + self.prev = None; + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 2 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "Harami" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new(open, high, low, close, 1.0, ts).unwrap() + } + + #[test] + fn accessors_and_metadata() { + let h = Harami::new(); + assert_eq!(h.name(), "Harami"); + assert_eq!(h.warmup_period(), 2); + assert!(!h.is_ready()); + } + + #[test] + fn bullish_harami_is_plus_one() { + let mut h = Harami::new(); + // Prior red 12 -> 10 (body 2). Current green 10.5 -> 11 inside. + assert_eq!(h.update(c(12.0, 12.5, 9.5, 10.0, 0)), Some(0.0)); + assert_eq!(h.update(c(10.5, 11.5, 10.4, 11.0, 1)), Some(1.0)); + } + + #[test] + fn bearish_harami_is_minus_one() { + let mut h = Harami::new(); + // Prior green 10 -> 12 (body 2). Current red 11.5 -> 11 inside. + assert_eq!(h.update(c(10.0, 12.5, 9.5, 12.0, 0)), Some(0.0)); + assert_eq!(h.update(c(11.5, 11.6, 10.9, 11.0, 1)), Some(-1.0)); + } + + #[test] + fn larger_body_is_not_harami() { + let mut h = Harami::new(); + h.update(c(11.0, 11.2, 9.8, 10.0, 0)); + // Current body bigger than prior. + assert_eq!(h.update(c(9.5, 12.0, 9.5, 11.5, 1)), Some(0.0)); + } + + #[test] + fn same_direction_is_not_harami() { + let mut h = Harami::new(); + h.update(c(10.0, 12.5, 9.5, 12.0, 0)); + // Smaller candle but also green -> 0. + assert_eq!(h.update(c(11.0, 11.6, 10.9, 11.5, 1)), Some(0.0)); + } + + #[test] + fn first_bar_returns_zero() { + let mut h = Harami::new(); + assert_eq!(h.update(c(10.0, 11.0, 9.0, 11.0, 0)), Some(0.0)); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40) + .map(|i| { + let base = 100.0 + i as f64; + if i % 2 == 0 { + c(base + 2.0, base + 2.5, base - 0.5, base, i) + } else { + c(base + 1.0, base + 1.5, base + 0.7, base + 1.3, i) + } + }) + .collect(); + let mut a = Harami::new(); + let mut b = Harami::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut h = Harami::new(); + h.update(c(12.0, 12.5, 9.5, 10.0, 0)); + h.update(c(10.5, 11.5, 10.4, 11.0, 1)); + assert!(h.is_ready()); + h.reset(); + assert!(!h.is_ready()); + // After reset the next bar again has no prev. + assert_eq!(h.update(c(12.0, 12.5, 9.5, 10.0, 0)), Some(0.0)); + } +} diff --git a/crates/wickra-core/src/indicators/inverted_hammer.rs b/crates/wickra-core/src/indicators/inverted_hammer.rs new file mode 100644 index 00000000..0928c739 --- /dev/null +++ b/crates/wickra-core/src/indicators/inverted_hammer.rs @@ -0,0 +1,153 @@ +//! Inverted Hammer candlestick pattern. + +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Inverted Hammer — a single-bar bullish reversal candidate. +/// +/// An Inverted Hammer is the mirror of a Hammer: small real body near the +/// bottom of the bar, a long upper shadow at least twice the body and a +/// short or absent lower shadow. +/// +/// ```text +/// body = |close − open| +/// upper_shadow = high − max(open, close) +/// lower_shadow = min(open, close) − low +/// inverted = upper_shadow >= 2 * body +/// && lower_shadow <= body +/// && body > 0 +/// ``` +/// +/// Output is `+1.0` when the shape matches, `0.0` otherwise. Pattern-shape +/// check only — no trend filter is applied; combine with a trend indicator +/// for actionable signals. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, InvertedHammer}; +/// +/// let mut indicator = InvertedHammer::new(); +/// // Open 10, close 10.5, low 9.9, high 15: long upper shadow, tiny lower. +/// let candle = Candle::new(10.0, 15.0, 9.9, 10.5, 1.0, 0).unwrap(); +/// assert_eq!(indicator.update(candle), Some(1.0)); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct InvertedHammer { + has_emitted: bool, +} + +impl InvertedHammer { + /// Construct a new Inverted Hammer detector. + pub const fn new() -> Self { + Self { has_emitted: false } + } +} + +impl Indicator for InvertedHammer { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + self.has_emitted = true; + let range = candle.high - candle.low; + if range <= 0.0 { + return Some(0.0); + } + let body = (candle.close - candle.open).abs(); + if body <= 0.0 { + return Some(0.0); + } + let upper = candle.high - candle.open.max(candle.close); + let lower = candle.open.min(candle.close) - candle.low; + Some(if upper >= 2.0 * body && lower <= body { + 1.0 + } else { + 0.0 + }) + } + + fn reset(&mut self) { + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "InvertedHammer" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new(open, high, low, close, 1.0, ts).unwrap() + } + + #[test] + fn accessors_and_metadata() { + let h = InvertedHammer::new(); + assert_eq!(h.name(), "InvertedHammer"); + assert_eq!(h.warmup_period(), 1); + assert!(!h.is_ready()); + } + + #[test] + fn clean_inverted_hammer_is_one() { + let mut h = InvertedHammer::new(); + // body 0.5, upper shadow 4.5, lower shadow 0.1. + assert_eq!(h.update(c(10.0, 15.0, 9.9, 10.5, 0)), Some(1.0)); + } + + #[test] + fn hammer_shape_is_not_inverted() { + let mut h = InvertedHammer::new(); + assert_eq!(h.update(c(10.0, 10.6, 5.0, 10.5, 0)), Some(0.0)); + } + + #[test] + fn doji_is_not_inverted_hammer() { + let mut h = InvertedHammer::new(); + assert_eq!(h.update(c(10.0, 11.0, 9.0, 10.0, 0)), Some(0.0)); + } + + #[test] + fn zero_range_yields_zero() { + let mut h = InvertedHammer::new(); + assert_eq!(h.update(c(10.0, 10.0, 10.0, 10.0, 0)), Some(0.0)); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40) + .map(|i| { + let base = 100.0 + i as f64; + c(base, base + 4.0, base - 0.1, base + 0.5, i) + }) + .collect(); + let mut a = InvertedHammer::new(); + let mut b = InvertedHammer::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut h = InvertedHammer::new(); + h.update(c(10.0, 15.0, 9.9, 10.5, 0)); + assert!(h.is_ready()); + h.reset(); + assert!(!h.is_ready()); + } +} diff --git a/crates/wickra-core/src/indicators/marubozu.rs b/crates/wickra-core/src/indicators/marubozu.rs new file mode 100644 index 00000000..65519470 --- /dev/null +++ b/crates/wickra-core/src/indicators/marubozu.rs @@ -0,0 +1,207 @@ +//! Marubozu candlestick pattern. + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Marubozu — a single-bar strong-continuation candle with body equal to range +/// and (almost) no shadows. +/// +/// ```text +/// range = high − low +/// upper_shadow = high − max(open, close) +/// lower_shadow = min(open, close) − low +/// shadows OK = upper_shadow <= tol * range && lower_shadow <= tol * range +/// ``` +/// +/// When the shadow tolerance is satisfied the output is `+1.0` for a bullish +/// Marubozu (close > open) and `−1.0` for a bearish one (close < open). Any +/// candle whose shadows exceed the tolerance — or whose body is zero — yields +/// `0.0`. +/// +/// `shadow_tolerance` defaults to `0.05` (5 % of the bar range allowed on each +/// side) and must lie in `[0, 1)`. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, Marubozu}; +/// +/// let mut indicator = Marubozu::new(); +/// // Bullish marubozu: open == low, close == high. +/// let candle = Candle::new(10.0, 12.0, 10.0, 12.0, 1.0, 0).unwrap(); +/// assert_eq!(indicator.update(candle), Some(1.0)); +/// ``` +#[derive(Debug, Clone)] +pub struct Marubozu { + shadow_tolerance: f64, + has_emitted: bool, +} + +impl Default for Marubozu { + fn default() -> Self { + Self::new() + } +} + +impl Marubozu { + /// Construct a Marubozu detector with the default 5 % shadow tolerance. + pub const fn new() -> Self { + Self { + shadow_tolerance: 0.05, + has_emitted: false, + } + } + + /// Construct a Marubozu detector with a custom shadow tolerance. + /// + /// `shadow_tolerance` must lie in `[0, 1)`. + pub fn with_tolerance(shadow_tolerance: f64) -> Result { + if !(0.0..1.0).contains(&shadow_tolerance) { + return Err(Error::InvalidPeriod { + message: "marubozu shadow tolerance must lie in [0, 1)", + }); + } + Ok(Self { + shadow_tolerance, + has_emitted: false, + }) + } + + /// Configured shadow tolerance. + pub fn shadow_tolerance(&self) -> f64 { + self.shadow_tolerance + } +} + +impl Indicator for Marubozu { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + self.has_emitted = true; + let range = candle.high - candle.low; + if range <= 0.0 { + return Some(0.0); + } + let body = candle.close - candle.open; + if body == 0.0 { + return Some(0.0); + } + let upper = candle.high - candle.open.max(candle.close); + let lower = candle.open.min(candle.close) - candle.low; + let tol = self.shadow_tolerance * range; + if upper <= tol && lower <= tol { + Some(if body > 0.0 { 1.0 } else { -1.0 }) + } else { + Some(0.0) + } + } + + fn reset(&mut self) { + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "Marubozu" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new(open, high, low, close, 1.0, ts).unwrap() + } + + #[test] + fn rejects_invalid_tolerance() { + assert!(Marubozu::with_tolerance(-0.01).is_err()); + assert!(Marubozu::with_tolerance(1.0).is_err()); + assert!(Marubozu::with_tolerance(2.0).is_err()); + } + + #[test] + fn accepts_valid_tolerance() { + let m = Marubozu::with_tolerance(0.0).unwrap(); + assert!((m.shadow_tolerance() - 0.0).abs() < 1e-12); + let m = Marubozu::with_tolerance(0.5).unwrap(); + assert!((m.shadow_tolerance() - 0.5).abs() < 1e-12); + } + + #[test] + fn accessors_and_metadata() { + let m = Marubozu::default(); + assert_eq!(m.name(), "Marubozu"); + assert_eq!(m.warmup_period(), 1); + assert!(!m.is_ready()); + assert!((m.shadow_tolerance() - 0.05).abs() < 1e-12); + } + + #[test] + fn bullish_marubozu_is_plus_one() { + let mut m = Marubozu::new(); + assert_eq!(m.update(c(10.0, 12.0, 10.0, 12.0, 0)), Some(1.0)); + } + + #[test] + fn bearish_marubozu_is_minus_one() { + let mut m = Marubozu::new(); + assert_eq!(m.update(c(12.0, 12.0, 10.0, 10.0, 0)), Some(-1.0)); + } + + #[test] + fn candle_with_long_shadows_is_zero() { + let mut m = Marubozu::new(); + // Big upper shadow violates tolerance. + assert_eq!(m.update(c(10.0, 15.0, 10.0, 12.0, 0)), Some(0.0)); + } + + #[test] + fn doji_is_zero() { + let mut m = Marubozu::new(); + // body == 0 -> not a marubozu. + assert_eq!(m.update(c(10.0, 11.0, 9.0, 10.0, 0)), Some(0.0)); + } + + #[test] + fn zero_range_yields_zero() { + let mut m = Marubozu::new(); + assert_eq!(m.update(c(10.0, 10.0, 10.0, 10.0, 0)), Some(0.0)); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40) + .map(|i| { + let base = 100.0 + i as f64; + c(base, base + 2.0, base, base + 2.0, i) + }) + .collect(); + let mut a = Marubozu::new(); + let mut b = Marubozu::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut m = Marubozu::new(); + m.update(c(10.0, 12.0, 10.0, 12.0, 0)); + assert!(m.is_ready()); + m.reset(); + assert!(!m.is_ready()); + } +} diff --git a/crates/wickra-core/src/indicators/mod.rs b/crates/wickra-core/src/indicators/mod.rs index 2c99d672..b0ecc56b 100644 --- a/crates/wickra-core/src/indicators/mod.rs +++ b/crates/wickra-core/src/indicators/mod.rs @@ -49,6 +49,7 @@ mod dema; mod demand_index; mod demark_pivots; mod detrended_std_dev; +mod doji; mod donchian; mod donchian_stop; mod double_bollinger; @@ -58,6 +59,7 @@ mod ehlers_stochastic; mod elder_impulse; mod ema; mod empirical_mode_decomposition; +mod engulfing; mod evwma; mod fama; mod fibonacci_pivots; @@ -66,6 +68,9 @@ mod force_index; mod fractal_chaos_bands; mod frama; mod garman_klass; +mod hammer; +mod hanging_man; +mod harami; mod heikin_ashi; mod hilbert_dominant_cycle; mod hilo_activator; @@ -78,6 +83,7 @@ mod inertia; mod initial_balance; mod instantaneous_trendline; mod inverse_fisher_transform; +mod inverted_hammer; mod jma; mod kama; mod keltner; @@ -93,12 +99,14 @@ mod ma_envelope; mod macd; mod mama; mod market_facilitation_index; +mod marubozu; mod mass_index; mod mcginley_dynamic; mod median_absolute_deviation; mod median_price; mod mfi; mod mom; +mod morning_evening_star; mod natr; mod nvi; mod obv; @@ -108,6 +116,7 @@ mod pearson_correlation; mod percent_b; mod percentage_trailing_stop; mod pgo; +mod piercing_dark_cloud; mod pmo; mod ppo; mod psar; @@ -121,12 +130,14 @@ mod rsi; mod rvi; mod rvi_volatility; mod rwi; +mod shooting_star; mod sine_wave; mod skewness; mod sma; mod smi; mod smma; mod spearman_correlation; +mod spinning_top; mod standard_error; mod standard_error_bands; mod starc_bands; @@ -151,6 +162,9 @@ mod td_risk_level; mod td_sequential; mod td_setup; mod tema; +mod three_inside; +mod three_outside; +mod three_soldiers_or_crows; mod tii; mod trima; mod trix; @@ -158,6 +172,7 @@ mod true_range; mod tsi; mod tsv; mod ttm_squeeze; +mod tweezer; mod typical_price; mod ulcer_index; mod ultimate_oscillator; @@ -231,6 +246,7 @@ pub use dema::Dema; pub use demand_index::DemandIndex; pub use demark_pivots::{DemarkPivots, DemarkPivotsOutput}; pub use detrended_std_dev::DetrendedStdDev; +pub use doji::Doji; pub use donchian::{Donchian, DonchianOutput}; pub use donchian_stop::{DonchianStop, DonchianStopOutput}; pub use double_bollinger::{DoubleBollinger, DoubleBollingerOutput}; @@ -240,6 +256,7 @@ pub use ehlers_stochastic::EhlersStochastic; pub use elder_impulse::ElderImpulse; pub use ema::Ema; pub use empirical_mode_decomposition::EmpiricalModeDecomposition; +pub use engulfing::Engulfing; pub use evwma::Evwma; pub use fama::Fama; pub use fibonacci_pivots::{FibonacciPivots, FibonacciPivotsOutput}; @@ -248,6 +265,9 @@ pub use force_index::ForceIndex; pub use fractal_chaos_bands::{FractalChaosBands, FractalChaosBandsOutput}; pub use frama::Frama; pub use garman_klass::GarmanKlassVolatility; +pub use hammer::Hammer; +pub use hanging_man::HangingMan; +pub use harami::Harami; pub use heikin_ashi::{HeikinAshi, HeikinAshiOutput}; pub use hilbert_dominant_cycle::HilbertDominantCycle; pub use hilo_activator::HiLoActivator; @@ -260,6 +280,7 @@ pub use inertia::Inertia; pub use initial_balance::{InitialBalance, InitialBalanceOutput}; pub use instantaneous_trendline::InstantaneousTrendline; pub use inverse_fisher_transform::InverseFisherTransform; +pub use inverted_hammer::InvertedHammer; pub use jma::Jma; pub use kama::Kama; pub use keltner::{Keltner, KeltnerOutput}; @@ -275,12 +296,14 @@ pub use ma_envelope::{MaEnvelope, MaEnvelopeOutput}; pub use macd::{MacdIndicator, MacdOutput}; pub use mama::{Mama, MamaOutput}; pub use market_facilitation_index::MarketFacilitationIndex; +pub use marubozu::Marubozu; pub use mass_index::MassIndex; pub use mcginley_dynamic::McGinleyDynamic; pub use median_absolute_deviation::MedianAbsoluteDeviation; pub use median_price::MedianPrice; pub use mfi::Mfi; pub use mom::Mom; +pub use morning_evening_star::MorningEveningStar; pub use natr::Natr; pub use nvi::Nvi; pub use obv::Obv; @@ -290,6 +313,7 @@ pub use pearson_correlation::PearsonCorrelation; pub use percent_b::PercentB; pub use percentage_trailing_stop::PercentageTrailingStop; pub use pgo::Pgo; +pub use piercing_dark_cloud::PiercingDarkCloud; pub use pmo::Pmo; pub use ppo::Ppo; pub use psar::Psar; @@ -303,12 +327,14 @@ pub use rsi::Rsi; pub use rvi::Rvi; pub use rvi_volatility::RviVolatility; pub use rwi::{Rwi, RwiOutput}; +pub use shooting_star::ShootingStar; pub use sine_wave::SineWave; pub use skewness::Skewness; pub use sma::Sma; pub use smi::Smi; pub use smma::Smma; pub use spearman_correlation::SpearmanCorrelation; +pub use spinning_top::SpinningTop; pub use standard_error::StandardError; pub use standard_error_bands::{StandardErrorBands, StandardErrorBandsOutput}; pub use starc_bands::{StarcBands, StarcBandsOutput}; @@ -333,6 +359,9 @@ pub use td_risk_level::{TdRiskLevel, TdRiskLevelOutput}; pub use td_sequential::{TdSequential, TdSequentialOutput}; pub use td_setup::TdSetup; pub use tema::Tema; +pub use three_inside::ThreeInside; +pub use three_outside::ThreeOutside; +pub use three_soldiers_or_crows::ThreeSoldiersOrCrows; pub use tii::Tii; pub use trima::Trima; pub use trix::Trix; @@ -340,6 +369,7 @@ pub use true_range::TrueRange; pub use tsi::Tsi; pub use tsv::Tsv; pub use ttm_squeeze::{TtmSqueeze, TtmSqueezeOutput}; +pub use tweezer::Tweezer; pub use typical_price::TypicalPrice; pub use ulcer_index::UlcerIndex; pub use ultimate_oscillator::UltimateOscillator; diff --git a/crates/wickra-core/src/indicators/morning_evening_star.rs b/crates/wickra-core/src/indicators/morning_evening_star.rs new file mode 100644 index 00000000..1469ba50 --- /dev/null +++ b/crates/wickra-core/src/indicators/morning_evening_star.rs @@ -0,0 +1,211 @@ +//! Morning Star / Evening Star candlestick pattern. + +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Morning Star / Evening Star — a 3-bar reversal pattern. +/// +/// **Morning Star** (bullish, `+1.0`): +/// 1. Bar 1 is a long red candle. +/// 2. Bar 2 has a small body (the "star") — colour does not matter. +/// 3. Bar 3 is a long green candle that closes above the midpoint of Bar 1. +/// +/// **Evening Star** (bearish, `−1.0`): the mirror image — long green, small +/// body, long red closing below Bar 1's midpoint. +/// +/// The "long" qualifier is enforced by requiring the outer bars' bodies to be +/// at least twice the size of the star's body. Pattern-shape check only — no +/// trend filter is applied; combine with a trend indicator for actionable +/// signals. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, MorningEveningStar}; +/// +/// let mut indicator = MorningEveningStar::new(); +/// indicator.update(Candle::new(12.0, 12.2, 9.5, 10.0, 1.0, 0).unwrap()); +/// indicator.update(Candle::new(9.9, 10.1, 9.7, 9.95, 1.0, 1).unwrap()); +/// let out = indicator +/// .update(Candle::new(10.1, 12.0, 10.0, 11.8, 1.0, 2).unwrap()); +/// assert_eq!(out, Some(1.0)); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct MorningEveningStar { + prev: Option, + prev_prev: Option, + has_emitted: bool, +} + +impl MorningEveningStar { + /// Construct a new Morning / Evening Star detector. + pub const fn new() -> Self { + Self { + prev: None, + prev_prev: None, + has_emitted: false, + } + } +} + +impl Indicator for MorningEveningStar { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + self.has_emitted = true; + let pp = self.prev_prev; + let p = self.prev; + self.prev_prev = self.prev; + self.prev = Some(candle); + let (Some(b1), Some(b2)) = (pp, p) else { + return Some(0.0); + }; + let body1 = (b1.close - b1.open).abs(); + let body2 = (b2.close - b2.open).abs(); + let body3 = (candle.close - candle.open).abs(); + if body1 <= 0.0 || body3 <= 0.0 { + return Some(0.0); + } + // Star body must be small relative to the outer bars. + if body1 < 2.0 * body2 || body3 < 2.0 * body2 { + return Some(0.0); + } + let mid1 = f64::midpoint(b1.open, b1.close); + let bar1_red = b1.close < b1.open; + let bar1_green = b1.close > b1.open; + let bar3_green = candle.close > candle.open; + let bar3_red = candle.close < candle.open; + if bar1_red && bar3_green && candle.close > mid1 { + Some(1.0) + } else if bar1_green && bar3_red && candle.close < mid1 { + Some(-1.0) + } else { + Some(0.0) + } + } + + fn reset(&mut self) { + self.prev = None; + self.prev_prev = None; + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 3 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "MorningEveningStar" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new(open, high, low, close, 1.0, ts).unwrap() + } + + #[test] + fn accessors_and_metadata() { + let m = MorningEveningStar::new(); + assert_eq!(m.name(), "MorningEveningStar"); + assert_eq!(m.warmup_period(), 3); + assert!(!m.is_ready()); + } + + #[test] + fn morning_star_is_plus_one() { + let mut m = MorningEveningStar::new(); + // Long red 12 -> 10 (body 2). Star small body. Long green 10.1 -> 11.8 (body 1.7). + // Mid of bar 1 = 11. Bar 3 closes at 11.8 > 11. + assert_eq!(m.update(c(12.0, 12.2, 9.5, 10.0, 0)), Some(0.0)); + assert_eq!(m.update(c(9.9, 10.1, 9.7, 9.95, 1)), Some(0.0)); + assert_eq!(m.update(c(10.1, 12.0, 10.0, 11.8, 2)), Some(1.0)); + } + + #[test] + fn evening_star_is_minus_one() { + let mut m = MorningEveningStar::new(); + // Long green 10 -> 12, star, long red 11.9 -> 10.2 (body 1.7). + // Mid of bar 1 = 11. Bar 3 closes at 10.2 < 11. + assert_eq!(m.update(c(10.0, 12.2, 9.8, 12.0, 0)), Some(0.0)); + assert_eq!(m.update(c(12.1, 12.3, 11.9, 12.05, 1)), Some(0.0)); + assert_eq!(m.update(c(11.9, 12.0, 10.1, 10.2, 2)), Some(-1.0)); + } + + #[test] + fn big_star_body_is_not_star() { + let mut m = MorningEveningStar::new(); + m.update(c(12.0, 12.2, 9.5, 10.0, 0)); + // Star body 1.5 -> body1=2, body3 needs to be >= 3 to satisfy 2*body2. + m.update(c(9.5, 11.5, 9.5, 11.0, 1)); + assert_eq!(m.update(c(10.1, 12.0, 10.0, 11.8, 2)), Some(0.0)); + } + + #[test] + fn first_two_bars_return_zero() { + let mut m = MorningEveningStar::new(); + assert_eq!(m.update(c(12.0, 12.2, 9.5, 10.0, 0)), Some(0.0)); + assert_eq!(m.update(c(9.9, 10.1, 9.7, 9.95, 1)), Some(0.0)); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40) + .map(|i| { + let base = 100.0 + i as f64; + match i % 3 { + 0 => c(base + 2.0, base + 2.5, base - 0.5, base, i), + 1 => c(base + 0.1, base + 0.3, base - 0.1, base + 0.15, i), + _ => c(base, base + 2.5, base - 0.5, base + 2.0, i), + } + }) + .collect(); + let mut a = MorningEveningStar::new(); + let mut b = MorningEveningStar::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut m = MorningEveningStar::new(); + m.update(c(12.0, 12.2, 9.5, 10.0, 0)); + m.update(c(9.9, 10.1, 9.7, 9.95, 1)); + m.update(c(10.1, 12.0, 10.0, 11.8, 2)); + assert!(m.is_ready()); + m.reset(); + assert!(!m.is_ready()); + assert_eq!(m.update(c(12.0, 12.2, 9.5, 10.0, 0)), Some(0.0)); + } + + #[test] + fn doji_outer_bar_yields_zero() { + // Bar1 is a doji (body == 0): body1 == 0 -> guard triggers, returns 0. + let mut m = MorningEveningStar::new(); + m.update(c(10.0, 11.0, 9.0, 10.0, 0)); // doji bar1 + m.update(c(9.9, 10.1, 9.7, 9.95, 1)); + assert_eq!(m.update(c(10.1, 12.0, 10.0, 11.8, 2)), Some(0.0)); + } + + #[test] + fn same_direction_bars_yield_zero() { + // Bar1 red, star small, bar3 also red (wrong direction) -> falls through to else 0. + let mut m = MorningEveningStar::new(); + m.update(c(12.0, 12.2, 9.5, 10.0, 0)); // long red (body 2) + m.update(c(9.9, 10.1, 9.7, 9.95, 1)); // small star + // Bar3 red, closes below mid (11); doesn't match morning star (bar3 must be green) + // and also doesn't match evening star (bar1 must be green). + assert_eq!(m.update(c(11.0, 11.2, 9.0, 9.5, 2)), Some(0.0)); + } +} diff --git a/crates/wickra-core/src/indicators/piercing_dark_cloud.rs b/crates/wickra-core/src/indicators/piercing_dark_cloud.rs new file mode 100644 index 00000000..6b314627 --- /dev/null +++ b/crates/wickra-core/src/indicators/piercing_dark_cloud.rs @@ -0,0 +1,197 @@ +//! Piercing Line / Dark Cloud Cover candlestick pattern. + +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Piercing Line / Dark Cloud Cover — a 2-bar reversal pattern. +/// +/// **Piercing Line** (bullish, `+1.0`): +/// ```text +/// prev_red & curr_green +/// & curr.open < prev.low +/// & curr.close > (prev.open + prev.close) / 2 +/// & curr.close < prev.open +/// ``` +/// +/// **Dark Cloud Cover** (bearish, `−1.0`): +/// ```text +/// prev_green & curr_red +/// & curr.open > prev.high +/// & curr.close < (prev.open + prev.close) / 2 +/// & curr.close > prev.open +/// ``` +/// +/// Output is `+1.0` for a Piercing Line, `−1.0` for a Dark Cloud Cover, and +/// `0.0` otherwise. The first bar always returns `0.0`. Pattern-shape check +/// only — no trend filter is applied; combine with a trend indicator for +/// actionable signals. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, PiercingDarkCloud}; +/// +/// let mut indicator = PiercingDarkCloud::new(); +/// indicator.update(Candle::new(12.0, 12.5, 10.0, 10.0, 1.0, 0).unwrap()); +/// // Open below prev low, close above midpoint (11) but below prev open (12). +/// let out = indicator +/// .update(Candle::new(9.8, 11.8, 9.5, 11.5, 1.0, 1).unwrap()); +/// assert_eq!(out, Some(1.0)); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct PiercingDarkCloud { + prev: Option, + has_emitted: bool, +} + +impl PiercingDarkCloud { + /// Construct a new Piercing Line / Dark Cloud Cover detector. + pub const fn new() -> Self { + Self { + prev: None, + has_emitted: false, + } + } +} + +impl Indicator for PiercingDarkCloud { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + self.has_emitted = true; + let prev = self.prev; + self.prev = Some(candle); + let Some(p) = prev else { + return Some(0.0); + }; + let prev_red = p.close < p.open; + let prev_green = p.close > p.open; + let curr_green = candle.close > candle.open; + let curr_red = candle.close < candle.open; + let mid = f64::midpoint(p.open, p.close); + if prev_red + && curr_green + && candle.open < p.low + && candle.close > mid + && candle.close < p.open + { + Some(1.0) + } else if prev_green + && curr_red + && candle.open > p.high + && candle.close < mid + && candle.close > p.open + { + Some(-1.0) + } else { + Some(0.0) + } + } + + fn reset(&mut self) { + self.prev = None; + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 2 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "PiercingDarkCloud" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new(open, high, low, close, 1.0, ts).unwrap() + } + + #[test] + fn accessors_and_metadata() { + let p = PiercingDarkCloud::new(); + assert_eq!(p.name(), "PiercingDarkCloud"); + assert_eq!(p.warmup_period(), 2); + assert!(!p.is_ready()); + } + + #[test] + fn piercing_line_is_plus_one() { + let mut p = PiercingDarkCloud::new(); + // Prev red: open 12, close 10. Curr green: opens at 9.8 (< prev low 10), + // closes at 11.5 (> midpoint 11, < prev open 12). + assert_eq!(p.update(c(12.0, 12.5, 10.0, 10.0, 0)), Some(0.0)); + assert_eq!(p.update(c(9.8, 11.8, 9.5, 11.5, 1)), Some(1.0)); + } + + #[test] + fn dark_cloud_cover_is_minus_one() { + let mut p = PiercingDarkCloud::new(); + // Prev green: open 10, close 12. Curr red: opens 12.3 (> prev high 12.2), + // closes 10.5 (< midpoint 11, > prev open 10). + assert_eq!(p.update(c(10.0, 12.2, 9.5, 12.0, 0)), Some(0.0)); + assert_eq!(p.update(c(12.3, 12.4, 10.4, 10.5, 1)), Some(-1.0)); + } + + #[test] + fn close_below_midpoint_is_not_piercing() { + let mut p = PiercingDarkCloud::new(); + p.update(c(12.0, 12.5, 10.0, 10.0, 0)); + // Closes only at 10.8 (below midpoint 11) -> not piercing. + assert_eq!(p.update(c(9.8, 11.0, 9.5, 10.8, 1)), Some(0.0)); + } + + #[test] + fn full_engulf_is_not_piercing() { + let mut p = PiercingDarkCloud::new(); + p.update(c(12.0, 12.5, 10.0, 10.0, 0)); + // Closes above prev.open (12) -> engulfs, not piercing. + assert_eq!(p.update(c(9.8, 13.0, 9.5, 12.5, 1)), Some(0.0)); + } + + #[test] + fn first_bar_returns_zero() { + let mut p = PiercingDarkCloud::new(); + assert_eq!(p.update(c(12.0, 12.5, 10.0, 10.0, 0)), Some(0.0)); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40) + .map(|i| { + let base = 100.0 + i as f64; + if i % 2 == 0 { + c(base + 2.0, base + 2.5, base, base, i) + } else { + c(base - 0.2, base + 1.8, base - 0.5, base + 1.5, i) + } + }) + .collect(); + let mut a = PiercingDarkCloud::new(); + let mut b = PiercingDarkCloud::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut p = PiercingDarkCloud::new(); + p.update(c(12.0, 12.5, 10.0, 10.0, 0)); + p.update(c(9.8, 11.8, 9.5, 11.5, 1)); + assert!(p.is_ready()); + p.reset(); + assert!(!p.is_ready()); + assert_eq!(p.update(c(12.0, 12.5, 10.0, 10.0, 0)), Some(0.0)); + } +} diff --git a/crates/wickra-core/src/indicators/shooting_star.rs b/crates/wickra-core/src/indicators/shooting_star.rs new file mode 100644 index 00000000..3d5201d5 --- /dev/null +++ b/crates/wickra-core/src/indicators/shooting_star.rs @@ -0,0 +1,151 @@ +//! Shooting Star candlestick pattern. + +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Shooting Star — a single-bar bearish reversal candidate. +/// +/// A Shooting Star has the same geometry as an Inverted Hammer (small body +/// near the bottom, long upper shadow ≥ 2× body, short lower shadow) but is +/// read bearishly because it appears at the top of an uptrend. +/// +/// ```text +/// body = |close − open| +/// upper_shadow = high − max(open, close) +/// lower_shadow = min(open, close) − low +/// star = upper_shadow >= 2 * body +/// && lower_shadow <= body +/// && body > 0 +/// ``` +/// +/// Output is `−1.0` when the shape matches, `0.0` otherwise. Pattern-shape +/// check only — no trend filter is applied; combine with a trend indicator +/// for actionable signals. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, ShootingStar}; +/// +/// let mut indicator = ShootingStar::new(); +/// let candle = Candle::new(10.0, 15.0, 9.9, 10.5, 1.0, 0).unwrap(); +/// assert_eq!(indicator.update(candle), Some(-1.0)); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct ShootingStar { + has_emitted: bool, +} + +impl ShootingStar { + /// Construct a new Shooting Star detector. + pub const fn new() -> Self { + Self { has_emitted: false } + } +} + +impl Indicator for ShootingStar { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + self.has_emitted = true; + let range = candle.high - candle.low; + if range <= 0.0 { + return Some(0.0); + } + let body = (candle.close - candle.open).abs(); + if body <= 0.0 { + return Some(0.0); + } + let upper = candle.high - candle.open.max(candle.close); + let lower = candle.open.min(candle.close) - candle.low; + Some(if upper >= 2.0 * body && lower <= body { + -1.0 + } else { + 0.0 + }) + } + + fn reset(&mut self) { + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "ShootingStar" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new(open, high, low, close, 1.0, ts).unwrap() + } + + #[test] + fn accessors_and_metadata() { + let s = ShootingStar::new(); + assert_eq!(s.name(), "ShootingStar"); + assert_eq!(s.warmup_period(), 1); + assert!(!s.is_ready()); + } + + #[test] + fn clean_shooting_star_is_minus_one() { + let mut s = ShootingStar::new(); + assert_eq!(s.update(c(10.0, 15.0, 9.9, 10.5, 0)), Some(-1.0)); + } + + #[test] + fn hammer_shape_is_not_shooting_star() { + let mut s = ShootingStar::new(); + assert_eq!(s.update(c(10.0, 10.6, 5.0, 10.5, 0)), Some(0.0)); + } + + #[test] + fn doji_is_not_shooting_star() { + let mut s = ShootingStar::new(); + assert_eq!(s.update(c(10.0, 11.0, 9.0, 10.0, 0)), Some(0.0)); + } + + #[test] + fn zero_range_yields_zero() { + let mut s = ShootingStar::new(); + assert_eq!(s.update(c(10.0, 10.0, 10.0, 10.0, 0)), Some(0.0)); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40) + .map(|i| { + let base = 100.0 + i as f64; + c(base, base + 4.0, base - 0.1, base + 0.5, i) + }) + .collect(); + let mut a = ShootingStar::new(); + let mut b = ShootingStar::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut s = ShootingStar::new(); + s.update(c(10.0, 15.0, 9.9, 10.5, 0)); + assert!(s.is_ready()); + s.reset(); + assert!(!s.is_ready()); + } +} diff --git a/crates/wickra-core/src/indicators/spinning_top.rs b/crates/wickra-core/src/indicators/spinning_top.rs new file mode 100644 index 00000000..cfe7c69d --- /dev/null +++ b/crates/wickra-core/src/indicators/spinning_top.rs @@ -0,0 +1,214 @@ +//! Spinning Top candlestick pattern. + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Spinning Top — a single-bar indecision candle with a small body and two +/// long shadows. +/// +/// ```text +/// body = |close − open| +/// upper_shadow = high − max(open, close) +/// lower_shadow = min(open, close) − low +/// range = high − low +/// spinning = body <= body_threshold * range +/// && upper_shadow >= 2 * body +/// && lower_shadow >= 2 * body +/// && body > 0 +/// ``` +/// +/// While direction is ambiguous by intent, the output is direction-signed so +/// downstream filters can distinguish a green spinning top (`+1.0`) from a red +/// one (`−1.0`). A clean Doji (body == 0) is *not* a Spinning Top. +/// +/// `body_threshold` defaults to `0.3` and must lie in `(0, 1]`. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, SpinningTop}; +/// +/// let mut indicator = SpinningTop::new(); +/// // Body 0.5, both shadows 3.0 -> spinning. +/// let candle = Candle::new(10.0, 13.5, 7.0, 10.5, 1.0, 0).unwrap(); +/// assert_eq!(indicator.update(candle), Some(1.0)); +/// ``` +#[derive(Debug, Clone)] +pub struct SpinningTop { + body_threshold: f64, + has_emitted: bool, +} + +impl Default for SpinningTop { + fn default() -> Self { + Self::new() + } +} + +impl SpinningTop { + /// Construct a Spinning Top detector with the default body threshold. + pub const fn new() -> Self { + Self { + body_threshold: 0.3, + has_emitted: false, + } + } + + /// Construct a Spinning Top detector with a custom body / range threshold. + pub fn with_threshold(body_threshold: f64) -> Result { + if !(body_threshold > 0.0 && body_threshold <= 1.0) { + return Err(Error::InvalidPeriod { + message: "spinning top body threshold must lie in (0, 1]", + }); + } + Ok(Self { + body_threshold, + has_emitted: false, + }) + } + + /// Configured body / range threshold. + pub fn body_threshold(&self) -> f64 { + self.body_threshold + } +} + +impl Indicator for SpinningTop { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + self.has_emitted = true; + let range = candle.high - candle.low; + if range <= 0.0 { + return Some(0.0); + } + let body_signed = candle.close - candle.open; + let body = body_signed.abs(); + if body <= 0.0 { + return Some(0.0); + } + if body > self.body_threshold * range { + return Some(0.0); + } + let upper = candle.high - candle.open.max(candle.close); + let lower = candle.open.min(candle.close) - candle.low; + if upper >= 2.0 * body && lower >= 2.0 * body { + Some(if body_signed > 0.0 { 1.0 } else { -1.0 }) + } else { + Some(0.0) + } + } + + fn reset(&mut self) { + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "SpinningTop" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new(open, high, low, close, 1.0, ts).unwrap() + } + + #[test] + fn rejects_invalid_threshold() { + assert!(SpinningTop::with_threshold(0.0).is_err()); + assert!(SpinningTop::with_threshold(1.5).is_err()); + } + + #[test] + fn accepts_valid_threshold() { + let s = SpinningTop::with_threshold(0.25).unwrap(); + assert!((s.body_threshold() - 0.25).abs() < 1e-12); + } + + #[test] + fn accessors_and_metadata() { + let s = SpinningTop::default(); + assert_eq!(s.name(), "SpinningTop"); + assert_eq!(s.warmup_period(), 1); + assert!(!s.is_ready()); + assert!((s.body_threshold() - 0.3).abs() < 1e-12); + } + + #[test] + fn green_spinning_top_is_plus_one() { + let mut s = SpinningTop::new(); + // body 0.5 (10 -> 10.5), upper 3.0, lower 3.0, range 6.5 -> 0.5/6.5 < 0.3. + assert_eq!(s.update(c(10.0, 13.5, 7.0, 10.5, 0)), Some(1.0)); + } + + #[test] + fn red_spinning_top_is_minus_one() { + let mut s = SpinningTop::new(); + assert_eq!(s.update(c(10.5, 13.5, 7.0, 10.0, 0)), Some(-1.0)); + } + + #[test] + fn marubozu_is_not_spinning() { + let mut s = SpinningTop::new(); + assert_eq!(s.update(c(10.0, 12.0, 10.0, 12.0, 0)), Some(0.0)); + } + + #[test] + fn doji_is_not_spinning() { + // body == 0 fails the body > 0 guard. + let mut s = SpinningTop::new(); + assert_eq!(s.update(c(10.0, 11.0, 9.0, 10.0, 0)), Some(0.0)); + } + + #[test] + fn hammer_shape_is_not_spinning_top() { + // Lower shadow is long but upper is tiny -> only one long shadow. + let mut s = SpinningTop::new(); + assert_eq!(s.update(c(10.0, 10.6, 5.0, 10.5, 0)), Some(0.0)); + } + + #[test] + fn zero_range_yields_zero() { + let mut s = SpinningTop::new(); + assert_eq!(s.update(c(10.0, 10.0, 10.0, 10.0, 0)), Some(0.0)); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40) + .map(|i| { + let base = 100.0 + i as f64; + c(base, base + 3.0, base - 3.0, base + 0.5, i) + }) + .collect(); + let mut a = SpinningTop::new(); + let mut b = SpinningTop::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut s = SpinningTop::new(); + s.update(c(10.0, 13.5, 7.0, 10.5, 0)); + assert!(s.is_ready()); + s.reset(); + assert!(!s.is_ready()); + } +} diff --git a/crates/wickra-core/src/indicators/three_inside.rs b/crates/wickra-core/src/indicators/three_inside.rs new file mode 100644 index 00000000..583018e0 --- /dev/null +++ b/crates/wickra-core/src/indicators/three_inside.rs @@ -0,0 +1,195 @@ +//! Three Inside Up / Down candlestick pattern. + +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Three Inside Up / Down — a confirmed Harami: the first two bars form a +/// Harami and the third bar confirms direction by closing beyond the first +/// bar's body. +/// +/// **Three Inside Up** (`+1.0`): +/// 1. Bar 1 is a long red candle. +/// 2. Bar 2 is a small green candle whose body sits inside Bar 1's body. +/// 3. Bar 3 is a green candle whose close exceeds Bar 1's open. +/// +/// **Three Inside Down** (`−1.0`): the mirror — long green, small red inside, +/// red closing below Bar 1's open. +/// +/// Pattern-shape check only — no trend filter is applied; combine with a trend +/// indicator for actionable signals. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, ThreeInside}; +/// +/// let mut indicator = ThreeInside::new(); +/// indicator.update(Candle::new(12.0, 12.5, 9.5, 10.0, 1.0, 0).unwrap()); +/// indicator.update(Candle::new(10.5, 11.5, 10.4, 11.0, 1.0, 1).unwrap()); +/// let out = indicator +/// .update(Candle::new(11.0, 13.0, 10.9, 12.5, 1.0, 2).unwrap()); +/// assert_eq!(out, Some(1.0)); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct ThreeInside { + prev: Option, + prev_prev: Option, + has_emitted: bool, +} + +impl ThreeInside { + /// Construct a new Three Inside Up / Down detector. + pub const fn new() -> Self { + Self { + prev: None, + prev_prev: None, + has_emitted: false, + } + } +} + +impl Indicator for ThreeInside { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + self.has_emitted = true; + let pp = self.prev_prev; + let p = self.prev; + self.prev_prev = self.prev; + self.prev = Some(candle); + let (Some(b1), Some(b2)) = (pp, p) else { + return Some(0.0); + }; + let body1 = (b1.close - b1.open).abs(); + let body2 = (b2.close - b2.open).abs(); + if body1 <= 0.0 || body2 <= 0.0 || body2 >= body1 { + return Some(0.0); + } + let b1_red = b1.close < b1.open; + let b1_green = b1.close > b1.open; + let b2_green = b2.close > b2.open; + let b2_red = b2.close < b2.open; + let b3_green = candle.close > candle.open; + let b3_red = candle.close < candle.open; + // Bullish: prior red, inside green harami, then green confirms above b1.open. + if b1_red + && b2_green + && b2.open >= b1.close + && b2.close <= b1.open + && b3_green + && candle.close > b1.open + { + return Some(1.0); + } + // Bearish: prior green, inside red harami, then red confirms below b1.open. + if b1_green + && b2_red + && b2.open <= b1.close + && b2.close >= b1.open + && b3_red + && candle.close < b1.open + { + return Some(-1.0); + } + Some(0.0) + } + + fn reset(&mut self) { + self.prev = None; + self.prev_prev = None; + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 3 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "ThreeInside" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new(open, high, low, close, 1.0, ts).unwrap() + } + + #[test] + fn accessors_and_metadata() { + let t = ThreeInside::new(); + assert_eq!(t.name(), "ThreeInside"); + assert_eq!(t.warmup_period(), 3); + assert!(!t.is_ready()); + } + + #[test] + fn three_inside_up_is_plus_one() { + let mut t = ThreeInside::new(); + assert_eq!(t.update(c(12.0, 12.5, 9.5, 10.0, 0)), Some(0.0)); + assert_eq!(t.update(c(10.5, 11.5, 10.4, 11.0, 1)), Some(0.0)); + // Bar 3 closes 12.5 > Bar 1 open 12. + assert_eq!(t.update(c(11.0, 13.0, 10.9, 12.5, 2)), Some(1.0)); + } + + #[test] + fn three_inside_down_is_minus_one() { + let mut t = ThreeInside::new(); + assert_eq!(t.update(c(10.0, 12.5, 9.5, 12.0, 0)), Some(0.0)); + assert_eq!(t.update(c(11.5, 11.6, 10.9, 11.0, 1)), Some(0.0)); + // Bar 3 closes 9.5 < Bar 1 open 10. + assert_eq!(t.update(c(11.0, 11.1, 9.3, 9.5, 2)), Some(-1.0)); + } + + #[test] + fn unconfirmed_third_bar_yields_zero() { + let mut t = ThreeInside::new(); + t.update(c(12.0, 12.5, 9.5, 10.0, 0)); + t.update(c(10.5, 11.5, 10.4, 11.0, 1)); + // Bar 3 green but closes below b1.open (12). + assert_eq!(t.update(c(11.0, 11.8, 10.9, 11.5, 2)), Some(0.0)); + } + + #[test] + fn first_two_bars_return_zero() { + let mut t = ThreeInside::new(); + assert_eq!(t.update(c(12.0, 12.5, 9.5, 10.0, 0)), Some(0.0)); + assert_eq!(t.update(c(10.5, 11.5, 10.4, 11.0, 1)), Some(0.0)); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40) + .map(|i| { + let base = 100.0 + i as f64; + c(base, base + 1.0, base - 0.5, base + 0.5, i) + }) + .collect(); + let mut a = ThreeInside::new(); + let mut b = ThreeInside::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut t = ThreeInside::new(); + t.update(c(12.0, 12.5, 9.5, 10.0, 0)); + t.update(c(10.5, 11.5, 10.4, 11.0, 1)); + t.update(c(11.0, 13.0, 10.9, 12.5, 2)); + assert!(t.is_ready()); + t.reset(); + assert!(!t.is_ready()); + assert_eq!(t.update(c(12.0, 12.5, 9.5, 10.0, 0)), Some(0.0)); + } +} diff --git a/crates/wickra-core/src/indicators/three_outside.rs b/crates/wickra-core/src/indicators/three_outside.rs new file mode 100644 index 00000000..e8ef1041 --- /dev/null +++ b/crates/wickra-core/src/indicators/three_outside.rs @@ -0,0 +1,194 @@ +//! Three Outside Up / Down candlestick pattern. + +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Three Outside Up / Down — a confirmed Engulfing: the first two bars form +/// an Engulfing pattern and the third bar confirms direction. +/// +/// **Three Outside Up** (`+1.0`): +/// 1. Bar 1 is a red candle. +/// 2. Bar 2 is a green candle that engulfs Bar 1's body. +/// 3. Bar 3 is a green candle with `close > b2.close`. +/// +/// **Three Outside Down** (`−1.0`): the mirror — green, bearish engulfing, +/// followed by a red bar closing below Bar 2's close. +/// +/// Pattern-shape check only — no trend filter is applied; combine with a trend +/// indicator for actionable signals. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, ThreeOutside}; +/// +/// let mut indicator = ThreeOutside::new(); +/// indicator.update(Candle::new(11.0, 11.2, 9.8, 10.0, 1.0, 0).unwrap()); +/// indicator.update(Candle::new(9.5, 12.0, 9.5, 11.5, 1.0, 1).unwrap()); +/// let out = indicator +/// .update(Candle::new(11.5, 13.0, 11.4, 12.5, 1.0, 2).unwrap()); +/// assert_eq!(out, Some(1.0)); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct ThreeOutside { + prev: Option, + prev_prev: Option, + has_emitted: bool, +} + +impl ThreeOutside { + /// Construct a new Three Outside Up / Down detector. + pub const fn new() -> Self { + Self { + prev: None, + prev_prev: None, + has_emitted: false, + } + } +} + +impl Indicator for ThreeOutside { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + self.has_emitted = true; + let pp = self.prev_prev; + let p = self.prev; + self.prev_prev = self.prev; + self.prev = Some(candle); + let (Some(b1), Some(b2)) = (pp, p) else { + return Some(0.0); + }; + let body1 = (b1.close - b1.open).abs(); + let body2 = (b2.close - b2.open).abs(); + if body1 <= 0.0 || body2 <= body1 { + return Some(0.0); + } + let b1_red = b1.close < b1.open; + let b1_green = b1.close > b1.open; + let b2_green = b2.close > b2.open; + let b2_red = b2.close < b2.open; + let b3_green = candle.close > candle.open; + let b3_red = candle.close < candle.open; + // Bullish: prior red, engulfing green (b2 fully engulfs b1 body), then green confirms above b2.close. + if b1_red + && b2_green + && b2.open <= b1.close + && b2.close >= b1.open + && b3_green + && candle.close > b2.close + { + return Some(1.0); + } + // Bearish: prior green, engulfing red, then red confirms below b2.close. + if b1_green + && b2_red + && b2.open >= b1.close + && b2.close <= b1.open + && b3_red + && candle.close < b2.close + { + return Some(-1.0); + } + Some(0.0) + } + + fn reset(&mut self) { + self.prev = None; + self.prev_prev = None; + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 3 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "ThreeOutside" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new(open, high, low, close, 1.0, ts).unwrap() + } + + #[test] + fn accessors_and_metadata() { + let t = ThreeOutside::new(); + assert_eq!(t.name(), "ThreeOutside"); + assert_eq!(t.warmup_period(), 3); + assert!(!t.is_ready()); + } + + #[test] + fn three_outside_up_is_plus_one() { + let mut t = ThreeOutside::new(); + assert_eq!(t.update(c(11.0, 11.2, 9.8, 10.0, 0)), Some(0.0)); + assert_eq!(t.update(c(9.5, 12.0, 9.5, 11.5, 1)), Some(0.0)); + // Bar 3 green close 12.5 > b2.close 11.5. + assert_eq!(t.update(c(11.5, 13.0, 11.4, 12.5, 2)), Some(1.0)); + } + + #[test] + fn three_outside_down_is_minus_one() { + let mut t = ThreeOutside::new(); + assert_eq!(t.update(c(10.0, 11.2, 9.8, 11.0, 0)), Some(0.0)); + assert_eq!(t.update(c(12.0, 12.0, 9.0, 9.0, 1)), Some(0.0)); + // Bar 3 red close 8.0 < b2.close 9.0. + assert_eq!(t.update(c(9.0, 9.1, 7.9, 8.0, 2)), Some(-1.0)); + } + + #[test] + fn unconfirmed_third_bar_yields_zero() { + let mut t = ThreeOutside::new(); + t.update(c(11.0, 11.2, 9.8, 10.0, 0)); + t.update(c(9.5, 12.0, 9.5, 11.5, 1)); + // Bar 3 green but does not exceed b2.close 11.5. + assert_eq!(t.update(c(11.0, 11.4, 10.9, 11.3, 2)), Some(0.0)); + } + + #[test] + fn first_two_bars_return_zero() { + let mut t = ThreeOutside::new(); + assert_eq!(t.update(c(11.0, 11.2, 9.8, 10.0, 0)), Some(0.0)); + assert_eq!(t.update(c(9.5, 12.0, 9.5, 11.5, 1)), Some(0.0)); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40) + .map(|i| { + let base = 100.0 + i as f64; + c(base, base + 1.5, base - 0.5, base + 1.0, i) + }) + .collect(); + let mut a = ThreeOutside::new(); + let mut b = ThreeOutside::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut t = ThreeOutside::new(); + t.update(c(11.0, 11.2, 9.8, 10.0, 0)); + t.update(c(9.5, 12.0, 9.5, 11.5, 1)); + t.update(c(11.5, 13.0, 11.4, 12.5, 2)); + assert!(t.is_ready()); + t.reset(); + assert!(!t.is_ready()); + assert_eq!(t.update(c(11.0, 11.2, 9.8, 10.0, 0)), Some(0.0)); + } +} diff --git a/crates/wickra-core/src/indicators/three_soldiers_or_crows.rs b/crates/wickra-core/src/indicators/three_soldiers_or_crows.rs new file mode 100644 index 00000000..c9178f0b --- /dev/null +++ b/crates/wickra-core/src/indicators/three_soldiers_or_crows.rs @@ -0,0 +1,197 @@ +//! Three White Soldiers / Three Black Crows candlestick pattern. + +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Three White Soldiers / Three Black Crows — a 3-bar continuation pattern of +/// three consecutive long candles in the same direction, each opening inside +/// the previous body and closing beyond it. +/// +/// **Three White Soldiers** (`+1.0`): +/// ```text +/// all three green & monotonically rising closes +/// & each open in [prev.open, prev.close] +/// & each close > prev.close +/// ``` +/// +/// **Three Black Crows** (`−1.0`): the mirror — three red candles with +/// monotonically falling closes. +/// +/// Pattern-shape check only — no trend filter is applied; combine with a trend +/// indicator for actionable signals. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, ThreeSoldiersOrCrows}; +/// +/// let mut indicator = ThreeSoldiersOrCrows::new(); +/// indicator.update(Candle::new(10.0, 11.5, 9.9, 11.0, 1.0, 0).unwrap()); +/// indicator.update(Candle::new(10.5, 12.5, 10.4, 12.0, 1.0, 1).unwrap()); +/// let out = indicator +/// .update(Candle::new(11.5, 13.5, 11.4, 13.0, 1.0, 2).unwrap()); +/// assert_eq!(out, Some(1.0)); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct ThreeSoldiersOrCrows { + prev: Option, + prev_prev: Option, + has_emitted: bool, +} + +impl ThreeSoldiersOrCrows { + /// Construct a new Three White Soldiers / Black Crows detector. + pub const fn new() -> Self { + Self { + prev: None, + prev_prev: None, + has_emitted: false, + } + } +} + +impl Indicator for ThreeSoldiersOrCrows { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + self.has_emitted = true; + let pp = self.prev_prev; + let p = self.prev; + self.prev_prev = self.prev; + self.prev = Some(candle); + let (Some(b1), Some(b2)) = (pp, p) else { + return Some(0.0); + }; + let g1 = b1.close > b1.open; + let g2 = b2.close > b2.open; + let g3 = candle.close > candle.open; + let r1 = b1.close < b1.open; + let r2 = b2.close < b2.open; + let r3 = candle.close < candle.open; + // Three White Soldiers + if g1 + && g2 + && g3 + && b2.close > b1.close + && candle.close > b2.close + && b2.open >= b1.open + && b2.open <= b1.close + && candle.open >= b2.open + && candle.open <= b2.close + { + return Some(1.0); + } + // Three Black Crows + if r1 + && r2 + && r3 + && b2.close < b1.close + && candle.close < b2.close + && b2.open <= b1.open + && b2.open >= b1.close + && candle.open <= b2.open + && candle.open >= b2.close + { + return Some(-1.0); + } + Some(0.0) + } + + fn reset(&mut self) { + self.prev = None; + self.prev_prev = None; + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 3 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "ThreeSoldiersOrCrows" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new(open, high, low, close, 1.0, ts).unwrap() + } + + #[test] + fn accessors_and_metadata() { + let t = ThreeSoldiersOrCrows::new(); + assert_eq!(t.name(), "ThreeSoldiersOrCrows"); + assert_eq!(t.warmup_period(), 3); + assert!(!t.is_ready()); + } + + #[test] + fn three_white_soldiers_is_plus_one() { + let mut t = ThreeSoldiersOrCrows::new(); + assert_eq!(t.update(c(10.0, 11.5, 9.9, 11.0, 0)), Some(0.0)); + assert_eq!(t.update(c(10.5, 12.5, 10.4, 12.0, 1)), Some(0.0)); + assert_eq!(t.update(c(11.5, 13.5, 11.4, 13.0, 2)), Some(1.0)); + } + + #[test] + fn three_black_crows_is_minus_one() { + let mut t = ThreeSoldiersOrCrows::new(); + // Bar1 13->12 (red), Bar2 opens inside [12,13] at 12.5, closes 11. + // Bar3 opens inside [11,12.5] at 11.5, closes 10. + assert_eq!(t.update(c(13.0, 13.1, 11.9, 12.0, 0)), Some(0.0)); + assert_eq!(t.update(c(12.5, 12.6, 10.9, 11.0, 1)), Some(0.0)); + assert_eq!(t.update(c(11.5, 11.6, 9.9, 10.0, 2)), Some(-1.0)); + } + + #[test] + fn mixed_directions_yield_zero() { + let mut t = ThreeSoldiersOrCrows::new(); + t.update(c(10.0, 11.5, 9.9, 11.0, 0)); + t.update(c(11.0, 11.2, 10.0, 10.5, 1)); + assert_eq!(t.update(c(10.5, 11.5, 10.4, 11.4, 2)), Some(0.0)); + } + + #[test] + fn first_two_bars_return_zero() { + let mut t = ThreeSoldiersOrCrows::new(); + assert_eq!(t.update(c(10.0, 11.5, 9.9, 11.0, 0)), Some(0.0)); + assert_eq!(t.update(c(10.5, 12.5, 10.4, 12.0, 1)), Some(0.0)); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40) + .map(|i| { + let base = 100.0 + i as f64; + c(base, base + 1.5, base - 0.1, base + 1.0, i) + }) + .collect(); + let mut a = ThreeSoldiersOrCrows::new(); + let mut b = ThreeSoldiersOrCrows::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut t = ThreeSoldiersOrCrows::new(); + t.update(c(10.0, 11.5, 9.9, 11.0, 0)); + t.update(c(10.5, 12.5, 10.4, 12.0, 1)); + t.update(c(11.5, 13.5, 11.4, 13.0, 2)); + assert!(t.is_ready()); + t.reset(); + assert!(!t.is_ready()); + assert_eq!(t.update(c(10.0, 11.5, 9.9, 11.0, 0)), Some(0.0)); + } +} diff --git a/crates/wickra-core/src/indicators/tweezer.rs b/crates/wickra-core/src/indicators/tweezer.rs new file mode 100644 index 00000000..57057cc3 --- /dev/null +++ b/crates/wickra-core/src/indicators/tweezer.rs @@ -0,0 +1,216 @@ +//! Tweezer Top / Bottom candlestick pattern. + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Tweezer — a 2-bar reversal pattern where two consecutive candles share an +/// extreme. +/// +/// ```text +/// tol = tolerance * |prev.high| + tolerance * |prev.low| (per leg) +/// tweezer_top = |curr.high − prev.high| <= tol_high +/// tweezer_bot = |curr.low − prev.low| <= tol_low +/// ``` +/// +/// The output is `−1.0` for a Tweezer Top (matched highs), `+1.0` for a +/// Tweezer Bottom (matched lows), and `0.0` otherwise. If *both* extremes +/// match — a flat pair of candles — the bottom wins by convention (bullish +/// rejection of the low). `tolerance` defaults to `0.001` (10 bps relative) +/// and must lie in `[0, 1)`. +/// +/// Pattern-shape check only — no trend filter is applied; combine with a trend +/// indicator for actionable signals. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, Tweezer}; +/// +/// let mut indicator = Tweezer::new(); +/// indicator.update(Candle::new(11.0, 12.0, 9.5, 9.6, 1.0, 0).unwrap()); +/// // Matching low. +/// let out = indicator.update(Candle::new(9.7, 10.5, 9.5, 10.2, 1.0, 1).unwrap()); +/// assert_eq!(out, Some(1.0)); +/// ``` +#[derive(Debug, Clone)] +pub struct Tweezer { + tolerance: f64, + prev: Option, + has_emitted: bool, +} + +impl Default for Tweezer { + fn default() -> Self { + Self::new() + } +} + +impl Tweezer { + /// Construct a Tweezer detector with the default relative tolerance (1e-3). + pub const fn new() -> Self { + Self { + tolerance: 0.001, + prev: None, + has_emitted: false, + } + } + + /// Construct a Tweezer detector with a custom relative tolerance. + /// + /// `tolerance` must lie in `[0, 1)`. + pub fn with_tolerance(tolerance: f64) -> Result { + if !(0.0..1.0).contains(&tolerance) { + return Err(Error::InvalidPeriod { + message: "tweezer tolerance must lie in [0, 1)", + }); + } + Ok(Self { + tolerance, + prev: None, + has_emitted: false, + }) + } + + /// Configured relative tolerance. + pub fn tolerance(&self) -> f64 { + self.tolerance + } +} + +impl Indicator for Tweezer { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + self.has_emitted = true; + let prev = self.prev; + self.prev = Some(candle); + let Some(p) = prev else { + return Some(0.0); + }; + let tol_high = self.tolerance * p.high.abs().max(candle.high.abs()); + let tol_low = self.tolerance * p.low.abs().max(candle.low.abs()); + let match_low = (candle.low - p.low).abs() <= tol_low; + let match_high = (candle.high - p.high).abs() <= tol_high; + if match_low { + Some(1.0) + } else if match_high { + Some(-1.0) + } else { + Some(0.0) + } + } + + fn reset(&mut self) { + self.prev = None; + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 2 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "Tweezer" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new(open, high, low, close, 1.0, ts).unwrap() + } + + #[test] + fn rejects_invalid_tolerance() { + assert!(Tweezer::with_tolerance(-0.01).is_err()); + assert!(Tweezer::with_tolerance(1.0).is_err()); + } + + #[test] + fn accepts_valid_tolerance() { + let t = Tweezer::with_tolerance(0.0).unwrap(); + assert!((t.tolerance() - 0.0).abs() < 1e-12); + } + + #[test] + fn accessors_and_metadata() { + let t = Tweezer::default(); + assert_eq!(t.name(), "Tweezer"); + assert_eq!(t.warmup_period(), 2); + assert!(!t.is_ready()); + assert!((t.tolerance() - 0.001).abs() < 1e-12); + } + + #[test] + fn tweezer_bottom_is_plus_one() { + let mut t = Tweezer::new(); + assert_eq!(t.update(c(11.0, 12.0, 9.5, 9.6, 0)), Some(0.0)); + // Matching low 9.5. + assert_eq!(t.update(c(9.7, 10.5, 9.5, 10.2, 1)), Some(1.0)); + } + + #[test] + fn tweezer_top_is_minus_one() { + let mut t = Tweezer::new(); + assert_eq!(t.update(c(9.0, 12.0, 8.5, 11.0, 0)), Some(0.0)); + // Matching high 12.0. + assert_eq!(t.update(c(11.5, 12.0, 11.0, 11.4, 1)), Some(-1.0)); + } + + #[test] + fn distinct_extremes_yield_zero() { + let mut t = Tweezer::new(); + t.update(c(10.0, 11.0, 9.0, 10.5, 0)); + assert_eq!(t.update(c(10.6, 11.5, 9.6, 11.2, 1)), Some(0.0)); + } + + #[test] + fn first_bar_returns_zero() { + let mut t = Tweezer::new(); + assert_eq!(t.update(c(10.0, 11.0, 9.0, 10.5, 0)), Some(0.0)); + } + + #[test] + fn matched_both_extremes_prefers_bottom() { + // Identical candles match both highs and lows -> bottom (+1.0). + let mut t = Tweezer::new(); + t.update(c(10.0, 11.0, 9.0, 10.5, 0)); + assert_eq!(t.update(c(10.0, 11.0, 9.0, 10.5, 1)), Some(1.0)); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40) + .map(|i| { + let base = 100.0 + (i as f64 * 0.1).sin(); + c(base, base + 2.0, base - 2.0, base + 0.5, i) + }) + .collect(); + let mut a = Tweezer::new(); + let mut b = Tweezer::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut t = Tweezer::new(); + t.update(c(10.0, 11.0, 9.0, 10.5, 0)); + t.update(c(10.0, 11.0, 9.0, 10.5, 1)); + assert!(t.is_ready()); + t.reset(); + assert!(!t.is_ready()); + assert_eq!(t.update(c(10.0, 11.0, 9.0, 10.5, 0)), Some(0.0)); + } +} diff --git a/crates/wickra-core/src/lib.rs b/crates/wickra-core/src/lib.rs index b5446402..0f6ad526 100644 --- a/crates/wickra-core/src/lib.rs +++ b/crates/wickra-core/src/lib.rs @@ -53,32 +53,34 @@ pub use indicators::{ ChandeKrollStopOutput, ChandelierExit, ChandelierExitOutput, ChoppinessIndex, ClassicPivots, ClassicPivotsOutput, Cmo, CoefficientOfVariation, ConnorsRsi, Coppock, CyberneticCycle, Decycler, DecyclerOscillator, Dema, DemandIndex, DemarkPivots, DemarkPivotsOutput, - DetrendedStdDev, Donchian, DonchianOutput, DonchianStop, DonchianStopOutput, DoubleBollinger, - DoubleBollingerOutput, Dpo, EaseOfMovement, EhlersStochastic, ElderImpulse, Ema, - EmpiricalModeDecomposition, Evwma, Fama, FibonacciPivots, FibonacciPivotsOutput, - FisherTransform, ForceIndex, FractalChaosBands, FractalChaosBandsOutput, Frama, - GarmanKlassVolatility, HeikinAshi, HeikinAshiOutput, HiLoActivator, HilbertDominantCycle, - HistoricalVolatility, Hma, HurstChannel, HurstChannelOutput, HurstExponent, Ichimoku, - IchimokuOutput, Inertia, InitialBalance, InitialBalanceOutput, InstantaneousTrendline, - InverseFisherTransform, Jma, Kama, Keltner, KeltnerOutput, Kst, KstOutput, Kurtosis, Kvo, - LaguerreRsi, LinRegAngle, LinRegChannel, LinRegChannelOutput, LinRegSlope, LinearRegression, - MaEnvelope, MaEnvelopeOutput, MacdIndicator, MacdOutput, Mama, MamaOutput, - MarketFacilitationIndex, MassIndex, McGinleyDynamic, MedianAbsoluteDeviation, MedianPrice, Mfi, - Mom, Natr, Nvi, Obv, OpeningRange, OpeningRangeOutput, ParkinsonVolatility, PearsonCorrelation, - PercentB, PercentageTrailingStop, Pgo, Pmo, Ppo, Psar, Pvi, RSquared, RenkoTrailingStop, Roc, - RogersSatchellVolatility, RollingVwap, RoofingFilter, Rsi, Rvi, RviVolatility, Rwi, RwiOutput, - SineWave, Skewness, Sma, Smi, Smma, SpearmanCorrelation, StandardError, StandardErrorBands, - StandardErrorBandsOutput, StarcBands, StarcBandsOutput, Stc, StdDev, StepTrailingStop, - StochRsi, Stochastic, StochasticOutput, SuperSmoother, SuperTrend, SuperTrendOutput, TdCombo, - TdCountdown, TdDeMarker, TdDifferential, TdLines, TdLinesOutput, TdOpen, TdPressure, - TdRangeProjection, TdRangeProjectionOutput, TdRei, TdRiskLevel, TdRiskLevelOutput, - TdSequential, TdSequentialOutput, TdSetup, Tema, Tii, Trima, Trix, TrueRange, Tsi, Tsv, - TtmSqueeze, TtmSqueezeOutput, TypicalPrice, UlcerIndex, UltimateOscillator, ValueArea, - ValueAreaOutput, Variance, VerticalHorizontalFilter, Vidya, VoltyStop, VolumeOscillator, - VolumePriceTrend, Vortex, VortexOutput, Vwap, VwapStdDevBands, VwapStdDevBandsOutput, Vwma, - Vzo, WaveTrend, WaveTrendOutput, WeightedClose, WilliamsFractals, WilliamsFractalsOutput, - WilliamsR, Wma, WoodiePivots, WoodiePivotsOutput, YangZhangVolatility, YoyoExit, ZScore, - ZeroLagMacd, ZeroLagMacdOutput, ZigZag, ZigZagOutput, Zlema, T3, + DetrendedStdDev, Doji, Donchian, DonchianOutput, DonchianStop, DonchianStopOutput, + DoubleBollinger, DoubleBollingerOutput, Dpo, EaseOfMovement, EhlersStochastic, ElderImpulse, + Ema, EmpiricalModeDecomposition, Engulfing, Evwma, Fama, FibonacciPivots, + FibonacciPivotsOutput, FisherTransform, ForceIndex, FractalChaosBands, FractalChaosBandsOutput, + Frama, GarmanKlassVolatility, Hammer, HangingMan, Harami, HeikinAshi, HeikinAshiOutput, + HiLoActivator, HilbertDominantCycle, HistoricalVolatility, Hma, HurstChannel, + HurstChannelOutput, HurstExponent, Ichimoku, IchimokuOutput, Inertia, InitialBalance, + InitialBalanceOutput, InstantaneousTrendline, InverseFisherTransform, InvertedHammer, Jma, + Kama, Keltner, KeltnerOutput, Kst, KstOutput, Kurtosis, Kvo, LaguerreRsi, LinRegAngle, + LinRegChannel, LinRegChannelOutput, LinRegSlope, LinearRegression, MaEnvelope, + MaEnvelopeOutput, MacdIndicator, MacdOutput, Mama, MamaOutput, MarketFacilitationIndex, + Marubozu, MassIndex, McGinleyDynamic, MedianAbsoluteDeviation, MedianPrice, Mfi, Mom, + MorningEveningStar, Natr, Nvi, Obv, OpeningRange, OpeningRangeOutput, ParkinsonVolatility, + PearsonCorrelation, PercentB, PercentageTrailingStop, Pgo, PiercingDarkCloud, Pmo, Ppo, Psar, + Pvi, RSquared, RenkoTrailingStop, Roc, RogersSatchellVolatility, RollingVwap, RoofingFilter, + Rsi, Rvi, RviVolatility, Rwi, RwiOutput, ShootingStar, SineWave, Skewness, Sma, Smi, Smma, + SpearmanCorrelation, SpinningTop, StandardError, StandardErrorBands, StandardErrorBandsOutput, + StarcBands, StarcBandsOutput, Stc, StdDev, StepTrailingStop, StochRsi, Stochastic, + StochasticOutput, SuperSmoother, SuperTrend, SuperTrendOutput, TdCombo, TdCountdown, + TdDeMarker, TdDifferential, TdLines, TdLinesOutput, TdOpen, TdPressure, TdRangeProjection, + TdRangeProjectionOutput, TdRei, TdRiskLevel, TdRiskLevelOutput, TdSequential, + TdSequentialOutput, TdSetup, Tema, ThreeInside, ThreeOutside, ThreeSoldiersOrCrows, Tii, Trima, + Trix, TrueRange, Tsi, Tsv, TtmSqueeze, TtmSqueezeOutput, Tweezer, TypicalPrice, UlcerIndex, + UltimateOscillator, ValueArea, ValueAreaOutput, Variance, VerticalHorizontalFilter, Vidya, + VoltyStop, VolumeOscillator, VolumePriceTrend, Vortex, VortexOutput, Vwap, VwapStdDevBands, + VwapStdDevBandsOutput, Vwma, Vzo, WaveTrend, WaveTrendOutput, WeightedClose, WilliamsFractals, + WilliamsFractalsOutput, WilliamsR, Wma, WoodiePivots, WoodiePivotsOutput, YangZhangVolatility, + YoyoExit, ZScore, ZeroLagMacd, ZeroLagMacdOutput, ZigZag, ZigZagOutput, Zlema, T3, }; pub use ohlcv::{Candle, Tick}; pub use traits::{BatchExt, Chain, Indicator}; diff --git a/crates/wickra/benches/indicators.rs b/crates/wickra/benches/indicators.rs index 831f30a5..4e3f11b2 100644 --- a/crates/wickra/benches/indicators.rs +++ b/crates/wickra/benches/indicators.rs @@ -22,19 +22,20 @@ use wickra::{ AccelerationBands, AdOscillator, AdaptiveCycle, Adxr, Alma, AnchoredVwap, Atr, AtrBands, Autocorrelation, BatchExt, BollingerBands, Camarilla, Candle, CenterOfGravity, ClassicPivots, CoefficientOfVariation, CyberneticCycle, Decycler, DecyclerOscillator, DemandIndex, - DemarkPivots, DetrendedStdDev, DonchianStop, DoubleBollinger, EhlersStochastic, Ema, - EmpiricalModeDecomposition, Fama, FibonacciPivots, FisherTransform, FractalChaosBands, Frama, - GarmanKlassVolatility, HeikinAshi, HiLoActivator, HilbertDominantCycle, HurstChannel, - HurstExponent, Ichimoku, Indicator, InitialBalance, InstantaneousTrendline, - InverseFisherTransform, Jma, Kst, Kurtosis, Kvo, LinRegChannel, MaEnvelope, MacdIndicator, - Mama, MarketFacilitationIndex, McGinleyDynamic, MedianAbsoluteDeviation, Nvi, Obv, - OpeningRange, ParkinsonVolatility, PercentageTrailingStop, Pgo, Pvi, RSquared, - RenkoTrailingStop, RogersSatchellVolatility, RoofingFilter, Rsi, Rvi, RviVolatility, Rwi, - SineWave, Skewness, Sma, StandardError, StandardErrorBands, StarcBands, StepTrailingStop, - Stochastic, SuperSmoother, TdCombo, TdCountdown, TdDeMarker, TdDifferential, TdLines, TdOpen, - TdPressure, TdRangeProjection, TdRei, TdRiskLevel, TdSequential, TdSetup, Tii, Tsv, TtmSqueeze, - ValueArea, Variance, Vidya, VoltyStop, VolumeOscillator, VwapStdDevBands, Vzo, WaveTrend, - WilliamsFractals, Wma, WoodiePivots, YangZhangVolatility, YoyoExit, ZigZag, + DemarkPivots, DetrendedStdDev, Doji, DonchianStop, DoubleBollinger, EhlersStochastic, Ema, + EmpiricalModeDecomposition, Engulfing, Fama, FibonacciPivots, FisherTransform, + FractalChaosBands, Frama, GarmanKlassVolatility, Hammer, HeikinAshi, HiLoActivator, + HilbertDominantCycle, HurstChannel, HurstExponent, Ichimoku, Indicator, InitialBalance, + InstantaneousTrendline, InverseFisherTransform, Jma, Kst, Kurtosis, Kvo, LinRegChannel, + MaEnvelope, MacdIndicator, Mama, MarketFacilitationIndex, McGinleyDynamic, + MedianAbsoluteDeviation, MorningEveningStar, Nvi, Obv, OpeningRange, ParkinsonVolatility, + PercentageTrailingStop, Pgo, Pvi, RSquared, RenkoTrailingStop, RogersSatchellVolatility, + RoofingFilter, Rsi, Rvi, RviVolatility, Rwi, SineWave, Skewness, Sma, StandardError, + StandardErrorBands, StarcBands, StepTrailingStop, Stochastic, SuperSmoother, TdCombo, + TdCountdown, TdDeMarker, TdDifferential, TdLines, TdOpen, TdPressure, TdRangeProjection, TdRei, + TdRiskLevel, TdSequential, TdSetup, ThreeInside, Tii, Tsv, TtmSqueeze, ValueArea, Variance, + Vidya, VoltyStop, VolumeOscillator, VwapStdDevBands, Vzo, WaveTrend, WilliamsFractals, Wma, + WoodiePivots, YangZhangVolatility, YoyoExit, ZigZag, }; use wickra_data::csv::CandleReader; @@ -192,6 +193,15 @@ fn benches(c: &mut Criterion) { bench_candle_input(c, "ichimoku", &candles, Ichimoku::classic); bench_candle_input(c, "heikin_ashi", &candles, HeikinAshi::new); + // Family 14 — Candlestick patterns. + // 1-bar, 2-bar and 3-bar representatives. The shape check itself is + // stateless arithmetic, so this also serves as a cost-floor reference. + bench_candle_input(c, "doji", &candles, Doji::new); + bench_candle_input(c, "hammer", &candles, Hammer::new); + bench_candle_input(c, "engulfing", &candles, Engulfing::new); + bench_candle_input(c, "morning_evening_star", &candles, MorningEveningStar::new); + bench_candle_input(c, "three_inside", &candles, ThreeInside::new); + // Family 10 — Ehlers / Cycle scalar benchmarks. bench_scalar(c, "super_smoother", &closes, || { SuperSmoother::new(10).unwrap() diff --git a/fuzz/fuzz_targets/indicator_update_candle.rs b/fuzz/fuzz_targets/indicator_update_candle.rs index e8288ae4..e548b5de 100644 --- a/fuzz/fuzz_targets/indicator_update_candle.rs +++ b/fuzz/fuzz_targets/indicator_update_candle.rs @@ -27,14 +27,16 @@ use wickra_core::{ AnchoredVwap, Aroon, AroonOscillator, Atr, AtrBands, AtrTrailingStop, AwesomeOscillator, AwesomeOscillatorHistogram, BalanceOfPower, BatchExt, Camarilla, Candle, Cci, ChaikinMoneyFlow, ChaikinOscillator, ChaikinVolatility, ChandeKrollStop, ChandelierExit, - ChoppinessIndex, ClassicPivots, DemandIndex, DemarkPivots, Donchian, DonchianStop, - EaseOfMovement, Evwma, FibonacciPivots, ForceIndex, FractalChaosBands, GarmanKlassVolatility, - HeikinAshi, HiLoActivator, HurstChannel, Ichimoku, Indicator, Inertia, InitialBalance, - Keltner, Kvo, MarketFacilitationIndex, MassIndex, MedianPrice, Mfi, Natr, Nvi, Obv, - OpeningRange, ParkinsonVolatility, Pgo, Psar, Pvi, RogersSatchellVolatility, RollingVwap, Rvi, - Rwi, Smi, StarcBands, Stochastic, SuperTrend, TdCombo, TdCountdown, TdDeMarker, TdDifferential, - TdLines, TdOpen, TdPressure, TdRangeProjection, TdRei, TdRiskLevel, TdSequential, TdSetup, - TrueRange, Tsv, TtmSqueeze, TypicalPrice, UltimateOscillator, ValueArea, VoltyStop, + ChoppinessIndex, ClassicPivots, DemandIndex, DemarkPivots, Doji, Donchian, DonchianStop, + EaseOfMovement, Engulfing, Evwma, FibonacciPivots, ForceIndex, FractalChaosBands, + GarmanKlassVolatility, Hammer, HangingMan, Harami, HeikinAshi, HiLoActivator, HurstChannel, Ichimoku, + Indicator, Inertia, InitialBalance, InvertedHammer, Keltner, Kvo, MarketFacilitationIndex, + Marubozu, MassIndex, MedianPrice, Mfi, MorningEveningStar, Natr, Nvi, Obv, OpeningRange, + ParkinsonVolatility, Pgo, PiercingDarkCloud, Psar, Pvi, RogersSatchellVolatility, RollingVwap, + Rvi, Rwi, ShootingStar, Smi, SpinningTop, StarcBands, Stochastic, SuperTrend, TdCombo, + TdCountdown, TdDeMarker, TdDifferential, TdLines, TdOpen, TdPressure, TdRangeProjection, + TdRei, TdRiskLevel, TdSequential, TdSetup, ThreeInside, ThreeOutside, ThreeSoldiersOrCrows, + TrueRange, Tsv, TtmSqueeze, Tweezer, TypicalPrice, UltimateOscillator, ValueArea, VoltyStop, VolumeOscillator, VolumePriceTrend, Vortex, Vwap, VwapStdDevBands, Vwma, Vzo, WaveTrend, WeightedClose, WilliamsFractals, WilliamsR, WoodiePivots, YangZhangVolatility, YoyoExit, ZigZag, @@ -290,4 +292,21 @@ fuzz_target!(|data: Vec| { } let _ = VwapStdDevBands::new(2.0).unwrap().batch(&candles); } + + // --- Candlestick Patterns (family 14) --- + drive(Doji::new, &candles); + drive(Hammer::new, &candles); + drive(InvertedHammer::new, &candles); + drive(HangingMan::new, &candles); + drive(ShootingStar::new, &candles); + drive(Engulfing::new, &candles); + drive(Harami::new, &candles); + drive(MorningEveningStar::new, &candles); + drive(ThreeSoldiersOrCrows::new, &candles); + drive(PiercingDarkCloud::new, &candles); + drive(Marubozu::new, &candles); + drive(Tweezer::new, &candles); + drive(SpinningTop::new, &candles); + drive(ThreeInside::new, &candles); + drive(ThreeOutside::new, &candles); });