From 4f9ed348841e511d870eff891a9a3035d13578b5 Mon Sep 17 00:00:00 2001 From: kingchenc Date: Mon, 25 May 2026 20:36:36 +0200 Subject: [PATCH] feat(family-11): add DeMark suite (TD Setup, Sequential, DeMarker, REI, Pressure) (#48) * feat(family-11): add DeMark suite (TD Setup, Sequential, DeMarker, REI, Pressure) Family 11 (DeMark) was previously empty; this PR adds five streaming-first DeMark indicators in one batch. - **TD Setup** (`TdSetup`): parameterised buy/sell setup counter. Counts consecutive bars whose close is less-than (buy) or greater-than (sell) the close `lookback` bars earlier, saturating at `target`. Emits a signed `f64` so callers read direction from the sign and run length from the magnitude. Classic config: `lookback = 4`, `target = 9`. - **TD Sequential** (`TdSequential`): the canonical Setup + Countdown exhaustion pattern. Output struct `{ setup, countdown, direction }` exposes both phase counts as signed numbers plus the active countdown direction (+1 buy / -1 sell / 0 none). Countdown activates when a setup completes and tracks the close-vs-high/low comparison `countdown_lookback` bars back, capped at `countdown_target`. Classic: 4/9/2/13. - **TD DeMarker** (`TdDeMarker`): bounded [0, 1] oscillator from the rolling average of upward high expansion (DeMax) and downward low expansion (DeMin). Falls back to the neutral 0.5 on a flat market (denominator zero). - **TD REI** (`TdRei`): Range Expansion Index, bounded [-100, 100]. Per-bar numerator gated on a range-overlap condition vs the bars 5 and 6 back, normalised by a `period`-bar sum of absolute moves. Classic period = 5. Saturates at +100 in a slow steady uptrend and at -100 in the mirror downtrend; emits 0 on a flat market. - **TD Pressure** (`TdPressure`): volume-weighted buying / selling pressure normalised to [-100, 100]. Per-bar pressure is the intra-bar close-vs-open ratio scaled by volume; the output is the rolling mean divided by the rolling mean volume. Zero-range bars contribute zero (avoid the undefined ratio) and a flat zero-volume window falls back to 0. Bindings: all five exposed in Python (`ta.TDSetup`, `ta.TDSequential`, `ta.TDDeMarker`, `ta.TDREI`, `ta.TDPressure`), Node (`wickra.TDSetup` etc.), and WASM. Multi-output classes (`TDSequential`) return either a struct `{ setup, countdown, direction }` per bar (streaming) or a flat interleaved Float64Array of length `3 * n` (batch). Tests: 47 unit tests across the five new core files (pure-trend saturation, flat-market neutral fallback, batch-equals-streaming, zero-parameter rejection, reset semantics, accessors). Python test_new_indicators.py picks up all five plus a multi-output TD Sequential block. Node indicators.test.js picks up all five. Reference values added to test_known_values.py. Fuzz: candle fuzz target sweeps all five DeMark indicators with the existing `Vec` -> `Vec` driver. Benches: BTCUSDT 1-minute dataset benches for each DeMark indicator in `crates/wickra/benches/indicators.rs`. Docs: README family table gains a "DeMark" row; indicator counter bumped 71 -> 76. CHANGELOG entry added under [Unreleased]. Wiki drafts (deep-dive pages + Sidebar / Overview / Warmup-Periods / Home deltas) live under `indicator-ideas/families/wiki/family-11-demark/` for manual merge into the wiki repo. * feat(family-11): add 7 missing DeMark indicators Complete the DeMark suite (family 11) with the seven indicators not covered by the first commit: TD Combo, TD Countdown, TD Lines (TDST), TD Range Projection, TD Differential, TD Open, and TD Risk Level. - TdCombo: aggressive countdown variant with three strictness rules on top of the classic close-vs-low/high lookback rule (monotone low/high, monotone close vs prior bar). - TdCountdown: standalone 13-bar countdown packaging only the signed countdown count (the setup machine runs internally). - TdLines: TDST horizontal support/resistance levels from the highest-high / lowest-low bars of the most-recently-completed setup, exposed as a multi-output struct. - TdRangeProjection: DeMark X-projection of the next bar's high and low from the current bar's OHLC via an open-vs-close-weighted pivot (three branches: closeopen, close==open). - TdDifferential: two-bar buying-pressure vs selling-pressure reversal pattern emitting +1/-1/0. - TdOpen: gap-and-fade reversal pattern (open outside prior range with subsequent recovery into it) emitting +1/-1/0. - TdRiskLevel: protective stop levels derived from the setup extreme bar +/- its true range. All seven are wired through Rust core, Python, Node and WASM bindings, registered in the candle-stream fuzz target, given benchmark entries on the BTCUSDT 1-minute dataset, and covered by streaming-vs-batch equivalence, reference-value, lifecycle and input-validation tests on the Python and Node sides. README counter moves 76 -> 83 and the CHANGELOG "family 11" entry is extended to list all twelve indicators. * fix(td_risk_level tests): check first emission at idx 12, not last bar TdRiskLevel re-ratchets the sell-risk level on each subsequent setup completion, so a strictly rising series produces 22.0 at idx 19 (latest setup) rather than 15.0 (first setup). The test comment already named idx 12 as the reference; switch the assertion from out[-1] to out[12] to match the reference computation. * test(family-11): cover buy-direction branches in TD indicators Add downtrend tests to TdSequential, TdCombo and TdCountdown so the buy-side countdown/combo increment branches are exercised; remove an empty `if buy_countdown == target {}` block in TdSequential whose behavior is already enforced by the outer strict `<` guard. Closes codecov/patch gaps reported on PR #48 (10 missed lines across the three files). --- CHANGELOG.md | 26 + README.md | 5 +- bindings/node/__tests__/indicators.test.js | 13 + bindings/node/index.js | 21 +- bindings/node/src/lib.rs | 782 ++++++++++++++++ bindings/python/python/wickra/__init__.py | 26 + bindings/python/src/lib.rs | 854 ++++++++++++++++++ bindings/python/tests/test_known_values.py | 107 +++ bindings/python/tests/test_new_indicators.py | 199 ++++ bindings/wasm/src/lib.rs | 690 ++++++++++++++ crates/wickra-core/src/indicators/mod.rs | 24 + crates/wickra-core/src/indicators/td_combo.rs | 358 ++++++++ .../src/indicators/td_countdown.rs | 340 +++++++ .../wickra-core/src/indicators/td_demarker.rs | 246 +++++ .../src/indicators/td_differential.rs | 191 ++++ crates/wickra-core/src/indicators/td_lines.rs | 325 +++++++ crates/wickra-core/src/indicators/td_open.rs | 172 ++++ .../wickra-core/src/indicators/td_pressure.rs | 240 +++++ .../src/indicators/td_range_projection.rs | 169 ++++ crates/wickra-core/src/indicators/td_rei.rs | 286 ++++++ .../src/indicators/td_risk_level.rs | 316 +++++++ .../src/indicators/td_sequential.rs | 415 +++++++++ crates/wickra-core/src/indicators/td_setup.rs | 262 ++++++ crates/wickra-core/src/lib.rs | 17 +- crates/wickra/benches/indicators.rs | 22 +- fuzz/fuzz_targets/indicator_update_candle.rs | 41 +- 26 files changed, 6130 insertions(+), 17 deletions(-) create mode 100644 crates/wickra-core/src/indicators/td_combo.rs create mode 100644 crates/wickra-core/src/indicators/td_countdown.rs create mode 100644 crates/wickra-core/src/indicators/td_demarker.rs create mode 100644 crates/wickra-core/src/indicators/td_differential.rs create mode 100644 crates/wickra-core/src/indicators/td_lines.rs create mode 100644 crates/wickra-core/src/indicators/td_open.rs create mode 100644 crates/wickra-core/src/indicators/td_pressure.rs create mode 100644 crates/wickra-core/src/indicators/td_range_projection.rs create mode 100644 crates/wickra-core/src/indicators/td_rei.rs create mode 100644 crates/wickra-core/src/indicators/td_risk_level.rs create mode 100644 crates/wickra-core/src/indicators/td_sequential.rs create mode 100644 crates/wickra-core/src/indicators/td_setup.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index fb88321e..d74852f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **DeMark family (family 11) — 12 new indicators.** TD Setup (9-bar + buy/sell setup counter with parameterised lookback and target), TD + Sequential (Setup + Countdown phase machine emitting setup count, + countdown count and active countdown direction), TD DeMarker + (bounded [0, 1] range oscillator built from high/low expansions), + TD REI (Range Expansion Index — bounded ±100 oscillator with the + classic 5-bar default), TD Pressure (volume-weighted buying / + selling pressure normalised to ±100), TD Combo (aggressive + countdown variant with extra monotone-low / monotone-close + strictness conditions on top of the classic countdown rule), TD + Countdown (standalone 13-bar countdown phase machine emitting + only the signed countdown count and direction — smaller streaming + payload than the full TD Sequential), TD Lines (TDST horizontal + support / resistance levels derived from the highs and lows of + the most-recently-completed setup), TD Range Projection (next-bar + high / low projection from the current bar's OHLC via DeMark's + open-vs-close-weighted pivot), TD Differential (2-bar + buying-pressure-vs-selling-pressure reversal pattern emitting + +1 / -1 / 0), TD Open (gap-and-fade reversal pattern emitting + +1 / -1 / 0 when the open prints outside the prior bar's range + but the subsequent action recovers back into it), and TD Risk + Level (protective stop levels derived from the lowest-low / highest- + high setup bar's true range). All twelve are exposed through the + Rust, Python, Node, and WASM bindings with `batch == streaming` + equivalence tests, candle-stream fuzz coverage, and benchmark + entries on the BTCUSDT 1-minute dataset. - **Family 08 — Pivots & Support/Resistance.** Seven new indicators land the previously empty pivot family: Classic (Floor-Trader) Pivot Points with three resistance and support tiers, Fibonacci Pivots spaced by diff --git a/README.md b/README.md index a8aaa6c6..cfc48ce9 100644 --- a/README.md +++ b/README.md @@ -109,7 +109,7 @@ python -m benchmarks.compare_libraries ## Indicators -135 streaming-first indicators across ten families. Every one passes the +147 streaming-first indicators across eleven families. Every one passes the `batch == streaming` equivalence test, reference-value tests, and reset semantics tests. @@ -125,6 +125,7 @@ semantics tests. | Volume | OBV, VWAP (cumulative + rolling), ADL, Volume-Price Trend, Chaikin Money Flow, Chaikin Oscillator, Force Index, Ease of Movement, Klinger Volume Oscillator, Volume Oscillator, NVI, PVI, Williams A/D, Anchored VWAP, Demand Index, TSV, VZO, Market Facilitation Index | | Price Statistics | Typical Price, Median Price, Weighted Close, Linear Regression, Linear Regression Slope, Z-Score, Linear Regression Angle | | 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 | Adding a new indicator means implementing one trait in Rust; all four bindings inherit it automatically. @@ -197,7 +198,7 @@ A Python live-trading example using the public `websockets` package lives at ``` wickra/ ├── crates/ -│ ├── wickra-core/ core engine + all 135 indicators +│ ├── wickra-core/ core engine + all 147 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 0ea4fc5b..6d648274 100644 --- a/bindings/node/__tests__/indicators.test.js +++ b/bindings/node/__tests__/indicators.test.js @@ -145,6 +145,14 @@ const candleScalar = { GarmanKlassVolatility: { make: () => new wickra.GarmanKlassVolatility(20, 252), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) }, RogersSatchellVolatility: { make: () => new wickra.RogersSatchellVolatility(20, 252), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) }, YangZhangVolatility: { make: () => new wickra.YangZhangVolatility(20, 252), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) }, + TDSetup: { make: () => new wickra.TDSetup(4, 9), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) }, + TDDeMarker: { make: () => new wickra.TDDeMarker(14), step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) }, + TDREI: { make: () => new wickra.TDREI(5), step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) }, + TDPressure: { make: () => new wickra.TDPressure(5), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i], volume[i]), batch: (ind) => ind.batch(open, high, low, close, volume) }, + TDCombo: { make: () => new wickra.TDCombo(4, 9, 2, 13), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) }, + 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) }, }; for (const [name, d] of Object.entries(candleScalar)) { @@ -200,6 +208,11 @@ const multi = { DemarkPivots: { make: () => new wickra.DemarkPivots(), fields: ['pp', 'r1', 's1'], step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) }, WilliamsFractals: { make: () => new wickra.WilliamsFractals(), fields: ['up', 'down'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) }, ZigZag: { make: () => new wickra.ZigZag(0.02), fields: ['swing', 'direction'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) }, + // Family 11: DeMark + TDSequential: { make: () => new wickra.TDSequential(4, 9, 2, 13), fields: ['setup', 'countdown', 'direction'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) }, + TDLines: { make: () => new wickra.TDLines(4, 9), fields: ['resistance', 'support'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) }, + TDRangeProjection: { make: () => new wickra.TDRangeProjection(), fields: ['high', 'low'], step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) }, + TDRiskLevel: { make: () => new wickra.TDRiskLevel(4, 9), fields: ['buyRisk', 'sellRisk'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) }, }; for (const [name, d] of Object.entries(multi)) { diff --git a/bindings/node/index.js b/bindings/node/index.js index 34c006e2..10bea8d7 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 } = 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 } = nativeBinding module.exports.version = version module.exports.SMA = SMA @@ -442,3 +442,22 @@ module.exports.DoubleBollinger = DoubleBollinger module.exports.TtmSqueeze = TtmSqueeze module.exports.FractalChaosBands = FractalChaosBands module.exports.VwapStdDevBands = VwapStdDevBands +module.exports.ClassicPivots = ClassicPivots +module.exports.FibonacciPivots = FibonacciPivots +module.exports.Camarilla = Camarilla +module.exports.WoodiePivots = WoodiePivots +module.exports.DemarkPivots = DemarkPivots +module.exports.WilliamsFractals = WilliamsFractals +module.exports.ZigZag = ZigZag +module.exports.TDSetup = TDSetup +module.exports.TDSequential = TDSequential +module.exports.TDDeMarker = TDDeMarker +module.exports.TDREI = TDREI +module.exports.TDPressure = TDPressure +module.exports.TDCombo = TDCombo +module.exports.TDCountdown = TDCountdown +module.exports.TDLines = TDLines +module.exports.TDRangeProjection = TDRangeProjection +module.exports.TDDifferential = TDDifferential +module.exports.TDOpen = TDOpen +module.exports.TDRiskLevel = TDRiskLevel diff --git a/bindings/node/src/lib.rs b/bindings/node/src/lib.rs index 5b3285f5..746bd50b 100644 --- a/bindings/node/src/lib.rs +++ b/bindings/node/src/lib.rs @@ -6607,3 +6607,785 @@ impl ZigZagNode { self.inner.warmup_period() as u32 } } +// ============================== TD Setup ============================== + +#[napi(js_name = "TDSetup")] +pub struct TdSetupNode { + inner: wc::TdSetup, +} + +#[napi] +impl TdSetupNode { + #[napi(constructor)] + pub fn new(lookback: u32, target: u32) -> napi::Result { + Ok(Self { + inner: wc::TdSetup::new(lookback as usize, target as usize).map_err(map_err)?, + }) + } + #[napi] + pub fn update(&mut self, high: f64, low: f64, close: f64) -> napi::Result> { + Ok(self.inner.update(cnd(high, low, close, 0.0)?)) + } + #[napi] + pub fn batch( + &mut self, + high: Vec, + low: Vec, + close: Vec, + ) -> napi::Result> { + if high.len() != low.len() || low.len() != close.len() { + return Err(NapiError::from_reason( + "high, low, close must be equal length".to_string(), + )); + } + let mut out = Vec::with_capacity(high.len()); + for i in 0..high.len() { + out.push( + self.inner + .update(cnd(high[i], low[i], close[i], 0.0)?) + .unwrap_or(f64::NAN), + ); + } + Ok(out) + } + #[napi] + pub fn reset(&mut self) { + self.inner.reset(); + } + #[napi(js_name = "isReady")] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[napi(js_name = "warmupPeriod")] + pub fn warmup_period(&self) -> u32 { + self.inner.warmup_period() as u32 + } +} + +// ============================== TD Sequential ============================== + +/// TD Sequential output triple: setup count, countdown count, direction. +#[napi(object)] +pub struct TdSequentialValue { + pub setup: f64, + pub countdown: f64, + pub direction: f64, +} + +#[napi(js_name = "TDSequential")] +pub struct TdSequentialNode { + inner: wc::TdSequential, +} + +#[napi] +impl TdSequentialNode { + #[napi(constructor)] + pub fn new( + setup_lookback: u32, + setup_target: u32, + countdown_lookback: u32, + countdown_target: u32, + ) -> napi::Result { + Ok(Self { + inner: wc::TdSequential::new( + setup_lookback as usize, + setup_target as usize, + countdown_lookback as usize, + countdown_target as usize, + ) + .map_err(map_err)?, + }) + } + #[napi] + pub fn update( + &mut self, + high: f64, + low: f64, + close: f64, + ) -> napi::Result> { + Ok(self + .inner + .update(cnd(high, low, close, 0.0)?) + .map(|o| TdSequentialValue { + setup: o.setup, + countdown: o.countdown, + direction: o.direction, + })) + } + /// Batch returns a flat array `[setup0, countdown0, direction0, setup1, ...]`. + #[napi] + pub fn batch( + &mut self, + high: Vec, + low: Vec, + close: Vec, + ) -> napi::Result> { + if high.len() != low.len() || low.len() != close.len() { + return Err(NapiError::from_reason( + "high, low, close must be equal length".to_string(), + )); + } + let n = high.len(); + let mut out = vec![f64::NAN; n * 3]; + for i in 0..n { + if let Some(o) = self.inner.update(cnd(high[i], low[i], close[i], 0.0)?) { + out[i * 3] = o.setup; + out[i * 3 + 1] = o.countdown; + out[i * 3 + 2] = o.direction; + } + } + 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 + } +} + +// ============================== TD DeMarker ============================== + +#[napi(js_name = "TDDeMarker")] +pub struct TdDeMarkerNode { + inner: wc::TdDeMarker, +} + +#[napi] +impl TdDeMarkerNode { + #[napi(constructor)] + pub fn new(period: u32) -> napi::Result { + Ok(Self { + inner: wc::TdDeMarker::new(period as usize).map_err(map_err)?, + }) + } + #[napi] + pub fn update(&mut self, high: f64, low: f64) -> napi::Result> { + Ok(self.inner.update(cnd(high, low, low, 0.0)?)) + } + #[napi] + pub fn batch(&mut self, high: Vec, low: Vec) -> napi::Result> { + if high.len() != low.len() { + return Err(NapiError::from_reason( + "high and low must be equal length".to_string(), + )); + } + let mut out = Vec::with_capacity(high.len()); + for i in 0..high.len() { + out.push( + self.inner + .update(cnd(high[i], low[i], low[i], 0.0)?) + .unwrap_or(f64::NAN), + ); + } + Ok(out) + } + #[napi] + pub fn reset(&mut self) { + self.inner.reset(); + } + #[napi(js_name = "isReady")] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[napi(js_name = "warmupPeriod")] + pub fn warmup_period(&self) -> u32 { + self.inner.warmup_period() as u32 + } +} + +// ============================== TD REI ============================== + +#[napi(js_name = "TDREI")] +pub struct TdReiNode { + inner: wc::TdRei, +} + +#[napi] +impl TdReiNode { + #[napi(constructor)] + pub fn new(period: u32) -> napi::Result { + Ok(Self { + inner: wc::TdRei::new(period as usize).map_err(map_err)?, + }) + } + #[napi] + pub fn update(&mut self, high: f64, low: f64) -> napi::Result> { + Ok(self.inner.update(cnd(high, low, low, 0.0)?)) + } + #[napi] + pub fn batch(&mut self, high: Vec, low: Vec) -> napi::Result> { + if high.len() != low.len() { + return Err(NapiError::from_reason( + "high and low must be equal length".to_string(), + )); + } + let mut out = Vec::with_capacity(high.len()); + for i in 0..high.len() { + out.push( + self.inner + .update(cnd(high[i], low[i], low[i], 0.0)?) + .unwrap_or(f64::NAN), + ); + } + Ok(out) + } + #[napi] + pub fn reset(&mut self) { + self.inner.reset(); + } + #[napi(js_name = "isReady")] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[napi(js_name = "warmupPeriod")] + pub fn warmup_period(&self) -> u32 { + self.inner.warmup_period() as u32 + } +} + +// ============================== TD Pressure ============================== + +#[napi(js_name = "TDPressure")] +pub struct TdPressureNode { + inner: wc::TdPressure, +} + +#[napi] +impl TdPressureNode { + #[napi(constructor)] + pub fn new(period: u32) -> napi::Result { + Ok(Self { + inner: wc::TdPressure::new(period as usize).map_err(map_err)?, + }) + } + #[napi] + pub fn update( + &mut self, + open: f64, + high: f64, + low: f64, + close: f64, + volume: f64, + ) -> napi::Result> { + let candle = wc::Candle::new(open, high, low, close, volume, 0).map_err(map_err)?; + Ok(self.inner.update(candle)) + } + #[napi] + pub fn batch( + &mut self, + open: Vec, + high: Vec, + low: Vec, + close: Vec, + volume: Vec, + ) -> napi::Result> { + if open.len() != high.len() + || high.len() != low.len() + || low.len() != close.len() + || close.len() != volume.len() + { + return Err(NapiError::from_reason( + "open, high, low, close, volume 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], volume[i], 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 + } +} + +// ============================== TD Combo ============================== + +#[napi(js_name = "TDCombo")] +pub struct TdComboNode { + inner: wc::TdCombo, +} + +#[napi] +impl TdComboNode { + #[napi(constructor)] + pub fn new( + setup_lookback: u32, + setup_target: u32, + countdown_lookback: u32, + countdown_target: u32, + ) -> napi::Result { + Ok(Self { + inner: wc::TdCombo::new( + setup_lookback as usize, + setup_target as usize, + countdown_lookback as usize, + countdown_target as usize, + ) + .map_err(map_err)?, + }) + } + #[napi] + pub fn update(&mut self, high: f64, low: f64, close: f64) -> napi::Result> { + Ok(self.inner.update(cnd(high, low, close, 0.0)?)) + } + #[napi] + pub fn batch( + &mut self, + high: Vec, + low: Vec, + close: Vec, + ) -> napi::Result> { + if high.len() != low.len() || low.len() != close.len() { + return Err(NapiError::from_reason( + "high, low, close must be equal length".to_string(), + )); + } + let mut out = Vec::with_capacity(high.len()); + for i in 0..high.len() { + out.push( + self.inner + .update(cnd(high[i], low[i], close[i], 0.0)?) + .unwrap_or(f64::NAN), + ); + } + Ok(out) + } + #[napi] + pub fn reset(&mut self) { + self.inner.reset(); + } + #[napi(js_name = "isReady")] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[napi(js_name = "warmupPeriod")] + pub fn warmup_period(&self) -> u32 { + self.inner.warmup_period() as u32 + } +} + +// ============================== TD Countdown ============================== + +#[napi(js_name = "TDCountdown")] +pub struct TdCountdownNode { + inner: wc::TdCountdown, +} + +#[napi] +impl TdCountdownNode { + #[napi(constructor)] + pub fn new( + setup_lookback: u32, + setup_target: u32, + countdown_lookback: u32, + countdown_target: u32, + ) -> napi::Result { + Ok(Self { + inner: wc::TdCountdown::new( + setup_lookback as usize, + setup_target as usize, + countdown_lookback as usize, + countdown_target as usize, + ) + .map_err(map_err)?, + }) + } + #[napi] + pub fn update(&mut self, high: f64, low: f64, close: f64) -> napi::Result> { + Ok(self.inner.update(cnd(high, low, close, 0.0)?)) + } + #[napi] + pub fn batch( + &mut self, + high: Vec, + low: Vec, + close: Vec, + ) -> napi::Result> { + if high.len() != low.len() || low.len() != close.len() { + return Err(NapiError::from_reason( + "high, low, close must be equal length".to_string(), + )); + } + let mut out = Vec::with_capacity(high.len()); + for i in 0..high.len() { + out.push( + self.inner + .update(cnd(high[i], low[i], close[i], 0.0)?) + .unwrap_or(f64::NAN), + ); + } + Ok(out) + } + #[napi] + pub fn reset(&mut self) { + self.inner.reset(); + } + #[napi(js_name = "isReady")] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[napi(js_name = "warmupPeriod")] + pub fn warmup_period(&self) -> u32 { + self.inner.warmup_period() as u32 + } +} + +// ============================== TD Lines ============================== + +/// TD Lines output pair: latest TDST resistance / support (NaN if unset). +#[napi(object)] +pub struct TdLinesValue { + pub resistance: f64, + pub support: f64, +} + +#[napi(js_name = "TDLines")] +pub struct TdLinesNode { + inner: wc::TdLines, +} + +#[napi] +impl TdLinesNode { + #[napi(constructor)] + pub fn new(lookback: u32, target: u32) -> napi::Result { + Ok(Self { + inner: wc::TdLines::new(lookback as usize, target as usize).map_err(map_err)?, + }) + } + #[napi] + pub fn update( + &mut self, + high: f64, + low: f64, + close: f64, + ) -> napi::Result> { + Ok(self + .inner + .update(cnd(high, low, close, 0.0)?) + .map(|o| TdLinesValue { + resistance: o.resistance, + support: o.support, + })) + } + /// Batch returns a flat array `[resistance0, support0, resistance1, ...]`. + #[napi] + pub fn batch( + &mut self, + high: Vec, + low: Vec, + close: Vec, + ) -> napi::Result> { + if high.len() != low.len() || low.len() != close.len() { + return Err(NapiError::from_reason( + "high, low, close must be equal length".to_string(), + )); + } + let n = high.len(); + let mut out = vec![f64::NAN; n * 2]; + for i in 0..n { + if let Some(o) = self.inner.update(cnd(high[i], low[i], close[i], 0.0)?) { + out[i * 2] = o.resistance; + out[i * 2 + 1] = o.support; + } + } + 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 + } +} + +// ============================== TD Range Projection ============================== + +/// TD Range Projection output pair: projected next-bar high / low. +#[napi(object)] +pub struct TdRangeProjectionValue { + pub high: f64, + pub low: f64, +} + +#[napi(js_name = "TDRangeProjection")] +pub struct TdRangeProjectionNode { + inner: wc::TdRangeProjection, +} + +#[napi] +impl TdRangeProjectionNode { + #[napi(constructor)] + pub fn new() -> Self { + Self { + inner: wc::TdRangeProjection::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).map(|o| TdRangeProjectionValue { + high: o.high, + low: o.low, + })) + } + /// Batch returns a flat array `[high0, low0, high1, low1, ...]`. + #[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 n = open.len(); + let mut out = vec![f64::NAN; n * 2]; + for i in 0..n { + let candle = + wc::Candle::new(open[i], high[i], low[i], close[i], 0.0, 0).map_err(map_err)?; + if let Some(p) = self.inner.update(candle) { + out[i * 2] = p.high; + out[i * 2 + 1] = p.low; + } + } + 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 + } +} + +// ============================== TD Differential ============================== + +#[napi(js_name = "TDDifferential")] +pub struct TdDifferentialNode { + inner: wc::TdDifferential, +} + +#[napi] +impl TdDifferentialNode { + #[napi(constructor)] + pub fn new() -> Self { + Self { + inner: wc::TdDifferential::new(), + } + } + #[napi] + pub fn update(&mut self, high: f64, low: f64, close: f64) -> napi::Result> { + Ok(self.inner.update(cnd(high, low, close, 0.0)?)) + } + #[napi] + pub fn batch( + &mut self, + high: Vec, + low: Vec, + close: Vec, + ) -> napi::Result> { + if high.len() != low.len() || low.len() != close.len() { + return Err(NapiError::from_reason( + "high, low, close must be equal length".to_string(), + )); + } + let mut out = Vec::with_capacity(high.len()); + for i in 0..high.len() { + out.push( + self.inner + .update(cnd(high[i], low[i], close[i], 0.0)?) + .unwrap_or(f64::NAN), + ); + } + Ok(out) + } + #[napi] + pub fn reset(&mut self) { + self.inner.reset(); + } + #[napi(js_name = "isReady")] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[napi(js_name = "warmupPeriod")] + pub fn warmup_period(&self) -> u32 { + self.inner.warmup_period() as u32 + } +} + +// ============================== TD Open ============================== + +#[napi(js_name = "TDOpen")] +pub struct TdOpenNode { + inner: wc::TdOpen, +} + +#[napi] +impl TdOpenNode { + #[napi(constructor)] + pub fn new() -> Self { + Self { + inner: wc::TdOpen::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 + } +} + +// ============================== TD Risk Level ============================== + +/// TD Risk Level output pair: buy-side / sell-side protective stop levels +/// (NaN if unset). +#[napi(object)] +pub struct TdRiskLevelValue { + pub buy_risk: f64, + pub sell_risk: f64, +} + +#[napi(js_name = "TDRiskLevel")] +pub struct TdRiskLevelNode { + inner: wc::TdRiskLevel, +} + +#[napi] +impl TdRiskLevelNode { + #[napi(constructor)] + pub fn new(lookback: u32, target: u32) -> napi::Result { + Ok(Self { + inner: wc::TdRiskLevel::new(lookback as usize, target as usize).map_err(map_err)?, + }) + } + #[napi] + pub fn update( + &mut self, + high: f64, + low: f64, + close: f64, + ) -> napi::Result> { + Ok(self + .inner + .update(cnd(high, low, close, 0.0)?) + .map(|o| TdRiskLevelValue { + buy_risk: o.buy_risk, + sell_risk: o.sell_risk, + })) + } + /// Batch returns a flat array `[buyRisk0, sellRisk0, buyRisk1, ...]`. + #[napi] + pub fn batch( + &mut self, + high: Vec, + low: Vec, + close: Vec, + ) -> napi::Result> { + if high.len() != low.len() || low.len() != close.len() { + return Err(NapiError::from_reason( + "high, low, close must be equal length".to_string(), + )); + } + let n = high.len(); + let mut out = vec![f64::NAN; n * 2]; + for i in 0..n { + if let Some(o) = self.inner.update(cnd(high[i], low[i], close[i], 0.0)?) { + out[i * 2] = o.buy_risk; + out[i * 2 + 1] = o.sell_risk; + } + } + 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 + } +} diff --git a/bindings/python/python/wickra/__init__.py b/bindings/python/python/wickra/__init__.py index 26d42212..b90184b5 100644 --- a/bindings/python/python/wickra/__init__.py +++ b/bindings/python/python/wickra/__init__.py @@ -169,6 +169,19 @@ from ._wickra import ( DemarkPivots, WilliamsFractals, ZigZag, + # DeMark + TDSetup, + TDSequential, + TDDeMarker, + TDREI, + TDPressure, + TDCombo, + TDCountdown, + TDLines, + TDRangeProjection, + TDDifferential, + TDOpen, + TDRiskLevel, ) __all__ = [ @@ -317,4 +330,17 @@ __all__ = [ "DemarkPivots", "WilliamsFractals", "ZigZag", + # DeMark + "TDSetup", + "TDSequential", + "TDDeMarker", + "TDREI", + "TDPressure", + "TDCombo", + "TDCountdown", + "TDLines", + "TDRangeProjection", + "TDDifferential", + "TDOpen", + "TDRiskLevel", ] diff --git a/bindings/python/src/lib.rs b/bindings/python/src/lib.rs index a1f21e89..6e78c2f1 100644 --- a/bindings/python/src/lib.rs +++ b/bindings/python/src/lib.rs @@ -8575,6 +8575,848 @@ impl PyZigZag { self.inner.warmup_period() } } +// ============================== TD Setup ============================== + +#[pyclass(name = "TDSetup", module = "wickra._wickra", skip_from_py_object)] +#[derive(Clone)] +struct PyTdSetup { + inner: wc::TdSetup, +} + +#[pymethods] +impl PyTdSetup { + #[new] + #[pyo3(signature = (lookback=4, target=9))] + fn new(lookback: usize, target: usize) -> PyResult { + Ok(Self { + inner: wc::TdSetup::new(lookback, target).map_err(map_err)?, + }) + } + 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>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let h = high + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let l = low + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let c = close + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + if h.len() != l.len() || l.len() != c.len() { + return Err(PyValueError::new_err( + "high, low, close must be equal length", + )); + } + let mut out = Vec::with_capacity(h.len()); + for i in 0..h.len() { + let candle = wc::Candle::new(c[i], h[i], l[i], c[i], 0.0, 0).map_err(map_err)?; + out.push(self.inner.update(candle).unwrap_or(f64::NAN)); + } + Ok(out.into_pyarray(py)) + } + 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() + } + #[getter] + fn value(&self) -> Option { + self.inner.value() + } + fn __repr__(&self) -> String { + let (lb, tg) = self.inner.params(); + format!("TDSetup(lookback={lb}, target={tg})") + } +} + +// ============================== TD Sequential ============================== + +#[pyclass(name = "TDSequential", module = "wickra._wickra", skip_from_py_object)] +#[derive(Clone)] +struct PyTdSequential { + inner: wc::TdSequential, +} + +#[pymethods] +impl PyTdSequential { + #[new] + #[pyo3(signature = (setup_lookback=4, setup_target=9, countdown_lookback=2, countdown_target=13))] + fn new( + setup_lookback: usize, + setup_target: usize, + countdown_lookback: usize, + countdown_target: usize, + ) -> PyResult { + Ok(Self { + inner: wc::TdSequential::new( + setup_lookback, + setup_target, + countdown_lookback, + countdown_target, + ) + .map_err(map_err)?, + }) + } + /// Returns `(setup, countdown, direction)` or `None` during warmup. + fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult> { + let c = extract_candle(candle)?; + Ok(self + .inner + .update(c) + .map(|o| (o.setup, o.countdown, o.direction))) + } + /// Batch returns shape `(n, 3)`: `[setup, countdown, direction]`. + fn batch<'py>( + &mut self, + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let h = high + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let l = low + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let c = close + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + if h.len() != l.len() || l.len() != c.len() { + return Err(PyValueError::new_err( + "high, low, close must be equal length", + )); + } + let n = h.len(); + let mut out = vec![f64::NAN; n * 3]; + for i in 0..n { + let candle = wc::Candle::new(c[i], h[i], l[i], c[i], 0.0, 0).map_err(map_err)?; + if let Some(o) = self.inner.update(candle) { + out[i * 3] = o.setup; + out[i * 3 + 1] = o.countdown; + out[i * 3 + 2] = o.direction; + } + } + Ok(numpy::ndarray::Array2::from_shape_vec((n, 3), out) + .expect("shape consistent") + .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() + } +} + +// ============================== TD DeMarker ============================== + +#[pyclass(name = "TDDeMarker", module = "wickra._wickra", skip_from_py_object)] +#[derive(Clone)] +struct PyTdDeMarker { + inner: wc::TdDeMarker, +} + +#[pymethods] +impl PyTdDeMarker { + #[new] + #[pyo3(signature = (period=14))] + fn new(period: usize) -> PyResult { + Ok(Self { + inner: wc::TdDeMarker::new(period).map_err(map_err)?, + }) + } + 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>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let h = high + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let l = low + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + if h.len() != l.len() { + return Err(PyValueError::new_err("high and low must be equal length")); + } + let mut out = Vec::with_capacity(h.len()); + for i in 0..h.len() { + let candle = wc::Candle::new(l[i], h[i], l[i], l[i], 0.0, 0).map_err(map_err)?; + out.push(self.inner.update(candle).unwrap_or(f64::NAN)); + } + Ok(out.into_pyarray(py)) + } + #[getter] + fn period(&self) -> usize { + self.inner.period() + } + #[getter] + fn value(&self) -> Option { + self.inner.value() + } + 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!("TDDeMarker(period={})", self.inner.period()) + } +} + +// ============================== TD REI ============================== + +#[pyclass(name = "TDREI", module = "wickra._wickra", skip_from_py_object)] +#[derive(Clone)] +struct PyTdRei { + inner: wc::TdRei, +} + +#[pymethods] +impl PyTdRei { + #[new] + #[pyo3(signature = (period=5))] + fn new(period: usize) -> PyResult { + Ok(Self { + inner: wc::TdRei::new(period).map_err(map_err)?, + }) + } + 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>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let h = high + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let l = low + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + if h.len() != l.len() { + return Err(PyValueError::new_err("high and low must be equal length")); + } + let mut out = Vec::with_capacity(h.len()); + for i in 0..h.len() { + let candle = wc::Candle::new(l[i], h[i], l[i], l[i], 0.0, 0).map_err(map_err)?; + out.push(self.inner.update(candle).unwrap_or(f64::NAN)); + } + Ok(out.into_pyarray(py)) + } + #[getter] + fn period(&self) -> usize { + self.inner.period() + } + #[getter] + fn value(&self) -> Option { + self.inner.value() + } + 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!("TDREI(period={})", self.inner.period()) + } +} + +// ============================== TD Pressure ============================== + +#[pyclass(name = "TDPressure", module = "wickra._wickra", skip_from_py_object)] +#[derive(Clone)] +struct PyTdPressure { + inner: wc::TdPressure, +} + +#[pymethods] +impl PyTdPressure { + #[new] + #[pyo3(signature = (period=5))] + fn new(period: usize) -> PyResult { + Ok(Self { + inner: wc::TdPressure::new(period).map_err(map_err)?, + }) + } + fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult> { + let c = extract_candle(candle)?; + Ok(self.inner.update(c)) + } + /// Batch over numpy columns: open, high, low, close, volume. + fn batch<'py>( + &mut self, + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, + volume: 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))?; + let v = volume + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + if o.len() != h.len() || h.len() != l.len() || l.len() != c.len() || c.len() != v.len() { + return Err(PyValueError::new_err( + "open, high, low, close, volume 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], v[i], 0).map_err(map_err)?; + out.push(self.inner.update(candle).unwrap_or(f64::NAN)); + } + Ok(out.into_pyarray(py)) + } + #[getter] + fn period(&self) -> usize { + self.inner.period() + } + #[getter] + fn value(&self) -> Option { + self.inner.value() + } + 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!("TDPressure(period={})", self.inner.period()) + } +} + +// ============================== TD Combo ============================== + +#[pyclass(name = "TDCombo", module = "wickra._wickra", skip_from_py_object)] +#[derive(Clone)] +struct PyTdCombo { + inner: wc::TdCombo, +} + +#[pymethods] +impl PyTdCombo { + #[new] + #[pyo3(signature = (setup_lookback=4, setup_target=9, countdown_lookback=2, countdown_target=13))] + fn new( + setup_lookback: usize, + setup_target: usize, + countdown_lookback: usize, + countdown_target: usize, + ) -> PyResult { + Ok(Self { + inner: wc::TdCombo::new( + setup_lookback, + setup_target, + countdown_lookback, + countdown_target, + ) + .map_err(map_err)?, + }) + } + 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>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let h = high + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let l = low + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let c = close + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + if h.len() != l.len() || l.len() != c.len() { + return Err(PyValueError::new_err( + "high, low, close must be equal length", + )); + } + let mut out = Vec::with_capacity(h.len()); + for i in 0..h.len() { + let candle = wc::Candle::new(c[i], h[i], l[i], c[i], 0.0, 0).map_err(map_err)?; + out.push(self.inner.update(candle).unwrap_or(f64::NAN)); + } + Ok(out.into_pyarray(py)) + } + 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() + } +} + +// ============================== TD Countdown ============================== + +#[pyclass(name = "TDCountdown", module = "wickra._wickra", skip_from_py_object)] +#[derive(Clone)] +struct PyTdCountdown { + inner: wc::TdCountdown, +} + +#[pymethods] +impl PyTdCountdown { + #[new] + #[pyo3(signature = (setup_lookback=4, setup_target=9, countdown_lookback=2, countdown_target=13))] + fn new( + setup_lookback: usize, + setup_target: usize, + countdown_lookback: usize, + countdown_target: usize, + ) -> PyResult { + Ok(Self { + inner: wc::TdCountdown::new( + setup_lookback, + setup_target, + countdown_lookback, + countdown_target, + ) + .map_err(map_err)?, + }) + } + 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>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let h = high + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let l = low + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let c = close + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + if h.len() != l.len() || l.len() != c.len() { + return Err(PyValueError::new_err( + "high, low, close must be equal length", + )); + } + let mut out = Vec::with_capacity(h.len()); + for i in 0..h.len() { + let candle = wc::Candle::new(c[i], h[i], l[i], c[i], 0.0, 0).map_err(map_err)?; + out.push(self.inner.update(candle).unwrap_or(f64::NAN)); + } + Ok(out.into_pyarray(py)) + } + 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() + } +} + +// ============================== TD Lines ============================== + +#[pyclass(name = "TDLines", module = "wickra._wickra", skip_from_py_object)] +#[derive(Clone)] +struct PyTdLines { + inner: wc::TdLines, +} + +#[pymethods] +impl PyTdLines { + #[new] + #[pyo3(signature = (lookback=4, target=9))] + fn new(lookback: usize, target: usize) -> PyResult { + Ok(Self { + inner: wc::TdLines::new(lookback, target).map_err(map_err)?, + }) + } + /// Returns `(resistance, support)` (with `NaN` for unset levels) or + /// `None` during warmup. + fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult> { + let c = extract_candle(candle)?; + Ok(self.inner.update(c).map(|o| (o.resistance, o.support))) + } + /// Batch returns shape `(n, 2)`: `[resistance, support]`. + fn batch<'py>( + &mut self, + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let h = high + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let l = low + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let c = close + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + if h.len() != l.len() || l.len() != c.len() { + return Err(PyValueError::new_err( + "high, low, close must be equal length", + )); + } + let n = h.len(); + let mut out = vec![f64::NAN; n * 2]; + for i in 0..n { + let candle = wc::Candle::new(c[i], h[i], l[i], c[i], 0.0, 0).map_err(map_err)?; + if let Some(o) = self.inner.update(candle) { + out[i * 2] = o.resistance; + out[i * 2 + 1] = o.support; + } + } + Ok(numpy::ndarray::Array2::from_shape_vec((n, 2), out) + .expect("shape consistent") + .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() + } +} + +// ============================== TD Range Projection ============================== + +#[pyclass( + name = "TDRangeProjection", + module = "wickra._wickra", + skip_from_py_object +)] +#[derive(Clone, Default)] +struct PyTdRangeProjection { + inner: wc::TdRangeProjection, +} + +#[pymethods] +impl PyTdRangeProjection { + #[new] + fn new() -> Self { + Self { + inner: wc::TdRangeProjection::new(), + } + } + /// Returns `(projected_high, projected_low)` for the next bar. + fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult> { + let c = extract_candle(candle)?; + Ok(self.inner.update(c).map(|o| (o.high, o.low))) + } + /// Batch returns shape `(n, 2)`: `[projected_high, projected_low]`. + 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 n = o.len(); + let mut out = vec![f64::NAN; n * 2]; + for i in 0..n { + let candle = wc::Candle::new(o[i], h[i], l[i], c[i], 0.0, 0).map_err(map_err)?; + if let Some(p) = self.inner.update(candle) { + out[i * 2] = p.high; + out[i * 2 + 1] = p.low; + } + } + Ok(numpy::ndarray::Array2::from_shape_vec((n, 2), out) + .expect("shape consistent") + .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() + } +} + +// ============================== TD Differential ============================== + +#[pyclass( + name = "TDDifferential", + module = "wickra._wickra", + skip_from_py_object +)] +#[derive(Clone, Default)] +struct PyTdDifferential { + inner: wc::TdDifferential, +} + +#[pymethods] +impl PyTdDifferential { + #[new] + fn new() -> Self { + Self { + inner: wc::TdDifferential::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>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let h = high + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let l = low + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let c = close + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + if h.len() != l.len() || l.len() != c.len() { + return Err(PyValueError::new_err( + "high, low, close must be equal length", + )); + } + let mut out = Vec::with_capacity(h.len()); + for i in 0..h.len() { + let candle = wc::Candle::new(c[i], h[i], l[i], c[i], 0.0, 0).map_err(map_err)?; + out.push(self.inner.update(candle).unwrap_or(f64::NAN)); + } + Ok(out.into_pyarray(py)) + } + 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() + } +} + +// ============================== TD Open ============================== + +#[pyclass(name = "TDOpen", module = "wickra._wickra", skip_from_py_object)] +#[derive(Clone, Default)] +struct PyTdOpen { + inner: wc::TdOpen, +} + +#[pymethods] +impl PyTdOpen { + #[new] + fn new() -> Self { + Self { + inner: wc::TdOpen::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() + } +} + +// ============================== TD Risk Level ============================== + +#[pyclass(name = "TDRiskLevel", module = "wickra._wickra", skip_from_py_object)] +#[derive(Clone)] +struct PyTdRiskLevel { + inner: wc::TdRiskLevel, +} + +#[pymethods] +impl PyTdRiskLevel { + #[new] + #[pyo3(signature = (lookback=4, target=9))] + fn new(lookback: usize, target: usize) -> PyResult { + Ok(Self { + inner: wc::TdRiskLevel::new(lookback, target).map_err(map_err)?, + }) + } + /// Returns `(buy_risk, sell_risk)` (with `NaN` for unset levels) or + /// `None` during warmup. + fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult> { + let c = extract_candle(candle)?; + Ok(self.inner.update(c).map(|o| (o.buy_risk, o.sell_risk))) + } + /// Batch returns shape `(n, 2)`: `[buy_risk, sell_risk]`. + fn batch<'py>( + &mut self, + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let h = high + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let l = low + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let c = close + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + if h.len() != l.len() || l.len() != c.len() { + return Err(PyValueError::new_err( + "high, low, close must be equal length", + )); + } + let n = h.len(); + let mut out = vec![f64::NAN; n * 2]; + for i in 0..n { + let candle = wc::Candle::new(c[i], h[i], l[i], c[i], 0.0, 0).map_err(map_err)?; + if let Some(o) = self.inner.update(candle) { + out[i * 2] = o.buy_risk; + out[i * 2 + 1] = o.sell_risk; + } + } + Ok(numpy::ndarray::Array2::from_shape_vec((n, 2), out) + .expect("shape consistent") + .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() + } +} + // ============================== Module ============================== #[pymodule] @@ -8718,5 +9560,17 @@ fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> { 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_known_values.py b/bindings/python/tests/test_known_values.py index ff145a62..eb19b20a 100644 --- a/bindings/python/tests/test_known_values.py +++ b/bindings/python/tests/test_known_values.py @@ -332,6 +332,113 @@ def test_obv_cumulative_known_sequence(): np.testing.assert_allclose(out, [0.0, 20.0, -10.0, -10.0, 0.0]) +# --- DeMark family --------------------------------------------------------- + + +def test_td_setup_buy_setup_completes_at_minus_9_uptrend(): + # Strictly rising closes -> every bar has close > close[-4] (sell setup); + # the streak hits -9 at index 12 and caps there. + h = np.arange(2.0, 22.0) + l = h - 1.0 + c = h - 0.5 + out = ta.TDSetup(4, 9).batch(h, l, c) + assert out[12] == pytest.approx(-9.0) + assert out[-1] == pytest.approx(-9.0) + + +def test_td_demarker_downtrend_pegs_at_zero(): + n = 20 + h = np.arange(30.0, 30.0 - n, -1.0) + l = h - 2.0 + out = ta.TDDeMarker(5).batch(h, l) + assert out[-1] == pytest.approx(0.0) + + +def test_td_pressure_pure_bearish_yields_minus_100(): + n = 20 + open_ = np.full(n, 11.0) + high = np.full(n, 11.0) + low = np.full(n, 9.0) + close = np.full(n, 9.0) + volume = np.full(n, 100.0) + out = ta.TDPressure(5).batch(open_, high, low, close, volume) + assert out[-1] == pytest.approx(-100.0) + + +def test_td_combo_uptrend_completes_to_minus_13(): + # Pure uptrend -> setup completes, then combo conditions (close>=high[-2], + # high>=prev.high, close>prev.close) all hold for every subsequent bar + # -> sell combo saturates at -13. + n = 40 + high = np.arange(1.0, 1.0 + n) + 0.5 + low = high - 1.0 + close = high - 0.5 + out = ta.TDCombo().batch(high, low, close) + assert out[-1] == pytest.approx(-13.0) + + +def test_td_countdown_uptrend_completes_to_minus_13(): + n = 40 + high = np.arange(1.0, 1.0 + n) + 0.5 + low = high - 1.0 + close = high - 0.5 + out = ta.TDCountdown().batch(high, low, close) + assert out[-1] == pytest.approx(-13.0) + + +def test_td_range_projection_doji_reference(): + # open=close=10, high=12, low=9 -> doji branch. + # pivot_sum = 12 + 9 + 2*10 = 41; half = 20.5. + # projHigh = 20.5 - 9 = 11.5; projLow = 20.5 - 12 = 8.5. + out = ta.TDRangeProjection().batch( + np.array([10.0]), np.array([12.0]), np.array([9.0]), np.array([10.0]) + ) + assert out[0, 0] == pytest.approx(11.5) + assert out[0, 1] == pytest.approx(8.5) + + +def test_td_open_sell_signal_reference(): + # Prev high=12. Curr open=13 > 12, curr low=11 < 12 -> -1. + td = ta.TDOpen() + assert td.update((10.0, 12.0, 9.0, 11.0, 1.0, 0)) is None + assert td.update((13.0, 13.5, 11.0, 11.5, 1.0, 1)) == pytest.approx(-1.0) + + +def test_td_differential_sell_signal_reference(): + # Prev high=10, low=8, close=9: buying=1, selling=1. + # Curr high=12, low=9.8, close=10.5: close>prev.close, selling=1.5>1, + # buying=0.7<1 -> sell signal -1. + td = ta.TDDifferential() + assert td.update((9.0, 10.0, 8.0, 9.0, 1.0, 0)) is None + assert td.update((10.5, 12.0, 9.8, 10.5, 1.0, 1)) == pytest.approx(-1.0) + + +def test_td_lines_uptrend_support_reference(): + # Strictly rising series -> sell setup completes at idx 12, the + # lowest low across bars 4..=12 is the low at idx 4 = 4.5. + n = 20 + high = np.arange(1.0, 1.0 + n) + 0.5 + low = high - 1.0 + close = high - 0.5 + out = ta.TDLines().batch(high, low, close) + assert math.isnan(out[-1, 0]) + assert out[-1, 1] == pytest.approx(4.5) + + +def test_td_risk_level_uptrend_sell_risk_reference(): + # Strictly rising series -> sell setup completes at idx 12 with high + # 13.5 and true range 1.5 -> sell_risk = 13.5 + 1.5 = 15.0. + # Subsequent setups re-ratchet the level, so we check the first emission + # at idx 12 rather than the latest value. + n = 20 + high = np.arange(1.0, 1.0 + n) + 0.5 + low = high - 1.0 + close = high - 0.5 + out = ta.TDRiskLevel().batch(high, low, close) + assert math.isnan(out[12, 0]) + assert out[12, 1] == pytest.approx(15.0) + + def test_percentage_trailing_stop_seed_and_ratchet(): # 10% trail: first close 100 -> stop 90; next 110 -> stop max(90, 99) = 99. s = ta.PercentageTrailingStop(10.0) diff --git a/bindings/python/tests/test_new_indicators.py b/bindings/python/tests/test_new_indicators.py index d49cf58c..6210c185 100644 --- a/bindings/python/tests/test_new_indicators.py +++ b/bindings/python/tests/test_new_indicators.py @@ -274,6 +274,30 @@ CANDLE_SCALAR = { lambda: ta.YangZhangVolatility(20, 252), lambda ind, h, l, c, v: ind.batch(c, h, l, c), ), + "TDSetup": ( + lambda: ta.TDSetup(4, 9), + lambda ind, h, l, c, v: ind.batch(h, l, c), + ), + "TDDeMarker": ( + lambda: ta.TDDeMarker(14), + lambda ind, h, l, c, v: ind.batch(h, l), + ), + "TDREI": ( + lambda: ta.TDREI(5), + lambda ind, h, l, c, v: ind.batch(h, l), + ), + "TDCombo": ( + lambda: ta.TDCombo(4, 9, 2, 13), + lambda ind, h, l, c, v: ind.batch(h, l, c), + ), + "TDCountdown": ( + lambda: ta.TDCountdown(4, 9, 2, 13), + lambda ind, h, l, c, v: ind.batch(h, l, c), + ), + "TDDifferential": ( + lambda: ta.TDDifferential(), + lambda ind, h, l, c, v: ind.batch(h, l, c), + ), } @@ -526,6 +550,55 @@ def test_multi_scalar_streaming_matches_batch(name, ohlcv): assert _eq_nan(batch, np.array(rows, dtype=np.float64)), f"{name} mismatch" +# --- TD Pressure (OHLCV-input) ------------------------------------------- + + +def test_td_pressure_streaming_matches_batch(ohlcv): + high, low, close, volume = ohlcv + open_ = close.copy() # TD Pressure needs open; reuse close as the open column. + batch = ta.TDPressure(5).batch(open_, high, low, close, volume) + assert batch.shape == close.shape + + streamer = ta.TDPressure(5) + streamed = [] + for i in range(close.size): + candle = ( + float(open_[i]), + float(high[i]), + float(low[i]), + float(close[i]), + float(volume[i]), + i, + ) + v = streamer.update(candle) + streamed.append(math.nan if v is None else float(v)) + assert _eq_nan(batch, np.array(streamed, dtype=np.float64)) + + +# --- TD Sequential (3-column multi-output) ------------------------------ + + +def test_td_sequential_streaming_matches_batch(ohlcv): + high, low, close, volume = ohlcv + batch = ta.TDSequential().batch(high, low, close) + assert batch.shape == (close.size, 3) + + streamer = ta.TDSequential() + rows = [] + for i in range(close.size): + candle = ( + float(close[i]), + float(high[i]), + float(low[i]), + float(close[i]), + float(volume[i]), + i, + ) + v = streamer.update(candle) + rows.append([math.nan, math.nan, math.nan] if v is None else list(v)) + assert _eq_nan(batch, np.array(rows, dtype=np.float64)) + + # --- ZeroLagMACD (scalar input, 3-tuple output: macd / signal / histogram) - @@ -815,6 +888,124 @@ def test_z_score_reference(): assert out[1] == pytest.approx(1.0) +def test_td_setup_pure_uptrend_reaches_minus_9(): + # Every close is strictly greater than four bars ago -> sell-setup -9. + h = np.arange(2.0, 22.0) + l = h - 1.0 + c = h - 0.5 + out = ta.TDSetup(4, 9).batch(h, l, c) + # Setup completes at index 12 (warmup is 5 -> first emit at index 4 with + # value -1, increments to -9 at index 12). + assert out[12] == pytest.approx(-9.0) + + +def test_td_demarker_uptrend_pegs_at_one(): + # Strictly higher highs, strictly higher lows -> DeMax > 0, DeMin == 0 + # -> indicator == 1 after warmup. + h = np.arange(11.0, 31.0) + l = h - 2.0 + out = ta.TDDeMarker(5).batch(h, l) + assert out[-1] == pytest.approx(1.0) + + +def test_td_demarker_flat_market_emits_05(): + # All highs and lows equal -> denominator is zero -> neutral fallback 0.5. + h = np.full(20, 11.0) + l = np.full(20, 9.0) + out = ta.TDDeMarker(5).batch(h, l) + assert out[-1] == pytest.approx(0.5) + + +def test_td_pressure_pure_bullish_yields_100(): + # Every bar closes at its high (close == high, open == low) -> per-bar + # pressure ratio is +1 -> indicator == 100. + n = 20 + open_ = np.full(n, 9.0) + high = np.full(n, 11.0) + low = np.full(n, 9.0) + close = np.full(n, 11.0) + volume = np.full(n, 100.0) + out = ta.TDPressure(5).batch(open_, high, low, close, volume) + assert out[-1] == pytest.approx(100.0) + + +def test_td_combo_uptrend_saturates_at_minus_13(): + # Strictly increasing closes -> sell setup completes; combo conditions + # (close >= high[i-2], high[i] >= high[i-1], close > close[i-1]) all + # hold so combo saturates at -13. + n = 40 + high = np.arange(1.0, 1.0 + n) + 0.5 + low = high - 1.0 + close = high - 0.5 + out = ta.TDCombo().batch(high, low, close) + assert out[-1] == pytest.approx(-13.0) + + +def test_td_countdown_uptrend_saturates_at_minus_13(): + n = 40 + high = np.arange(1.0, 1.0 + n) + 0.5 + low = high - 1.0 + close = high - 0.5 + out = ta.TDCountdown().batch(high, low, close) + assert out[-1] == pytest.approx(-13.0) + + +def test_td_lines_uptrend_sets_support_at_first_run_low(): + # Strictly rising closes -> sell setup completes at idx 12; the + # lowest low among the setup bars (idx 4..=12) is the low at idx 4. + n = 20 + high = np.arange(1.0, 1.0 + n) + 0.5 + low = high - 1.0 + close = high - 0.5 + out = ta.TDLines().batch(high, low, close) + # support is column 1; resistance is NaN at -1. + assert math.isnan(out[-1, 0]) + # low at idx 4 = 5 + 0.5 - 1.0 = 4.5. + assert out[-1, 1] == pytest.approx(4.5) + + +def test_td_range_projection_bullish_bar_reference(): + # open=10, high=12, low=9, close=11 (close > open) -> + # pivot_sum = 2*12 + 9 + 11 = 44; half = 22. + # projHigh = 22 - 9 = 13; projLow = 22 - 12 = 10. + out = ta.TDRangeProjection().batch( + np.array([10.0]), np.array([12.0]), np.array([9.0]), np.array([11.0]) + ) + assert out[0, 0] == pytest.approx(13.0) + assert out[0, 1] == pytest.approx(10.0) + + +def test_td_differential_buy_signal_reference(): + # Bar 0: high=10, low=8, close=9 -> warmup, returns None. + # Bar 1: high=9, low=7, close=8.5 -> close < prev.close, more buying + # pressure (1.5 > 1), less selling pressure (0.5 < 1) -> +1. + td = ta.TDDifferential() + assert td.update((9.0, 10.0, 8.0, 9.0, 1.0, 0)) is None + assert td.update((8.5, 9.0, 7.0, 8.5, 1.0, 1)) == pytest.approx(1.0) + + +def test_td_open_buy_signal_reference(): + # Prev bar low=10. Curr open=9 < 10, curr high=11 > 10 -> +1. + td = ta.TDOpen() + assert td.update((10.0, 11.0, 10.0, 10.5, 1.0, 0)) is None + assert td.update((9.0, 11.0, 8.5, 9.5, 1.0, 1)) == pytest.approx(1.0) + + +def test_td_risk_level_uptrend_sets_sell_risk(): + # Strictly rising closes -> sell setup completes at idx 12. + # The highest high is at idx 12 (= 13.5) with true range 1.5 -> + # sell_risk = 13.5 + 1.5 = 15.0. Subsequent setups re-ratchet the level + # so we check the first emission at idx 12. + n = 20 + high = np.arange(1.0, 1.0 + n) + 0.5 + low = high - 1.0 + close = high - 0.5 + out = ta.TDRiskLevel().batch(high, low, close) + # buy_risk is column 0; sell_risk is column 1. + assert math.isnan(out[12, 0]) + assert out[12, 1] == pytest.approx(15.0) + + def test_classic_pivots_reference(): # H=110, L=90, C=105 -> PP = 305/3, R1 = 2·PP − L, S1 = 2·PP − H. cp = ta.ClassicPivots() @@ -1013,6 +1204,14 @@ def test_new_indicators_expose_lifecycle(): instances += [ta.VwapStdDevBands(2.0)] instances.append(ta.Alligator(13, 8, 5)) instances.append(ta.ZeroLagMACD(12, 26, 9)) + instances += [ + ta.TDPressure(5), + ta.TDSequential(), + ta.TDLines(), + ta.TDRiskLevel(), + ta.TDRangeProjection(), + ta.TDOpen(), + ] for ind in instances: assert ind.is_ready() is False assert ind.warmup_period() >= 1 diff --git a/bindings/wasm/src/lib.rs b/bindings/wasm/src/lib.rs index fe3f53b6..215f3304 100644 --- a/bindings/wasm/src/lib.rs +++ b/bindings/wasm/src/lib.rs @@ -4645,6 +4645,696 @@ impl WasmZigZag { self.inner.warmup_period() } } +// ---------- TD Setup ---------- + +#[wasm_bindgen(js_name = TDSetup)] +pub struct WasmTdSetup { + inner: wc::TdSetup, +} + +#[wasm_bindgen(js_class = TDSetup)] +impl WasmTdSetup { + #[wasm_bindgen(constructor)] + pub fn new(lookback: usize, target: usize) -> Result { + Ok(Self { + inner: wc::TdSetup::new(lookback, target).map_err(map_err)?, + }) + } + pub fn update(&mut self, high: f64, low: f64, close: f64) -> Result, JsError> { + let c = make_candle(high, low, close, 0.0)?; + Ok(self.inner.update(c)) + } + pub fn batch( + &mut self, + high: &[f64], + low: &[f64], + close: &[f64], + ) -> Result { + if high.len() != low.len() || low.len() != close.len() { + return Err(JsError::new("high, low, close must be equal length")); + } + let mut out = Vec::with_capacity(high.len()); + for i in 0..high.len() { + let c = make_candle(high[i], low[i], close[i], 0.0)?; + out.push(self.inner.update(c).unwrap_or(f64::NAN)); + } + Ok(Float64Array::from(out.as_slice())) + } + pub fn reset(&mut self) { + self.inner.reset(); + } + #[wasm_bindgen(js_name = 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() + } +} + +// ---------- TD Sequential ---------- + +#[wasm_bindgen(js_name = TDSequential)] +pub struct WasmTdSequential { + inner: wc::TdSequential, +} + +#[wasm_bindgen(js_class = TDSequential)] +impl WasmTdSequential { + #[wasm_bindgen(constructor)] + pub fn new( + setup_lookback: usize, + setup_target: usize, + countdown_lookback: usize, + countdown_target: usize, + ) -> Result { + Ok(Self { + inner: wc::TdSequential::new( + setup_lookback, + setup_target, + countdown_lookback, + countdown_target, + ) + .map_err(map_err)?, + }) + } + /// Streaming update. Returns `{ setup, countdown, direction }` once warm, + /// else `null`. + pub fn update(&mut self, high: f64, low: f64, close: f64) -> Result { + let c = make_candle(high, low, close, 0.0)?; + Ok(match self.inner.update(c) { + Some(o) => { + let obj = Object::new(); + Reflect::set(&obj, &"setup".into(), &o.setup.into()).ok(); + Reflect::set(&obj, &"countdown".into(), &o.countdown.into()).ok(); + Reflect::set(&obj, &"direction".into(), &o.direction.into()).ok(); + obj.into() + } + None => JsValue::NULL, + }) + } + /// Batch returns a flat `Float64Array` `[setup0, countdown0, direction0, ...]`. + pub fn batch( + &mut self, + high: &[f64], + low: &[f64], + close: &[f64], + ) -> Result { + if high.len() != low.len() || low.len() != close.len() { + return Err(JsError::new("high, low, close must be equal length")); + } + let n = high.len(); + let mut out = vec![f64::NAN; n * 3]; + for i in 0..n { + let c = make_candle(high[i], low[i], close[i], 0.0)?; + if let Some(o) = self.inner.update(c) { + out[i * 3] = o.setup; + out[i * 3 + 1] = o.countdown; + out[i * 3 + 2] = o.direction; + } + } + 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() + } +} + +// ---------- TD DeMarker ---------- + +#[wasm_bindgen(js_name = TDDeMarker)] +pub struct WasmTdDeMarker { + inner: wc::TdDeMarker, +} + +#[wasm_bindgen(js_class = TDDeMarker)] +impl WasmTdDeMarker { + #[wasm_bindgen(constructor)] + pub fn new(period: usize) -> Result { + Ok(Self { + inner: wc::TdDeMarker::new(period).map_err(map_err)?, + }) + } + pub fn update(&mut self, high: f64, low: f64) -> Result, JsError> { + let c = make_candle(high, low, low, 0.0)?; + Ok(self.inner.update(c)) + } + pub fn batch(&mut self, high: &[f64], low: &[f64]) -> Result { + if high.len() != low.len() { + return Err(JsError::new("high and low must be equal length")); + } + let mut out = Vec::with_capacity(high.len()); + for i in 0..high.len() { + let c = make_candle(high[i], low[i], low[i], 0.0)?; + out.push(self.inner.update(c).unwrap_or(f64::NAN)); + } + Ok(Float64Array::from(out.as_slice())) + } + pub fn reset(&mut self) { + self.inner.reset(); + } + #[wasm_bindgen(js_name = 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() + } +} + +// ---------- TD REI ---------- + +#[wasm_bindgen(js_name = TDREI)] +pub struct WasmTdRei { + inner: wc::TdRei, +} + +#[wasm_bindgen(js_class = TDREI)] +impl WasmTdRei { + #[wasm_bindgen(constructor)] + pub fn new(period: usize) -> Result { + Ok(Self { + inner: wc::TdRei::new(period).map_err(map_err)?, + }) + } + pub fn update(&mut self, high: f64, low: f64) -> Result, JsError> { + let c = make_candle(high, low, low, 0.0)?; + Ok(self.inner.update(c)) + } + pub fn batch(&mut self, high: &[f64], low: &[f64]) -> Result { + if high.len() != low.len() { + return Err(JsError::new("high and low must be equal length")); + } + let mut out = Vec::with_capacity(high.len()); + for i in 0..high.len() { + let c = make_candle(high[i], low[i], low[i], 0.0)?; + out.push(self.inner.update(c).unwrap_or(f64::NAN)); + } + Ok(Float64Array::from(out.as_slice())) + } + pub fn reset(&mut self) { + self.inner.reset(); + } + #[wasm_bindgen(js_name = 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() + } +} + +// ---------- TD Pressure ---------- + +#[wasm_bindgen(js_name = TDPressure)] +pub struct WasmTdPressure { + inner: wc::TdPressure, +} + +#[wasm_bindgen(js_class = TDPressure)] +impl WasmTdPressure { + #[wasm_bindgen(constructor)] + pub fn new(period: usize) -> Result { + Ok(Self { + inner: wc::TdPressure::new(period).map_err(map_err)?, + }) + } + pub fn update( + &mut self, + open: f64, + high: f64, + low: f64, + close: f64, + volume: f64, + ) -> Result, JsError> { + let c = wc::Candle::new(open, high, low, close, volume, 0).map_err(map_err)?; + Ok(self.inner.update(c)) + } + pub fn batch( + &mut self, + open: &[f64], + high: &[f64], + low: &[f64], + close: &[f64], + volume: &[f64], + ) -> Result { + if open.len() != high.len() + || high.len() != low.len() + || low.len() != close.len() + || close.len() != volume.len() + { + return Err(JsError::new( + "open, high, low, close, volume must be equal length", + )); + } + let mut out = Vec::with_capacity(open.len()); + for i in 0..open.len() { + let c = wc::Candle::new(open[i], high[i], low[i], close[i], volume[i], 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() + } +} + +// ---------- TD Combo ---------- + +#[wasm_bindgen(js_name = TDCombo)] +pub struct WasmTdCombo { + inner: wc::TdCombo, +} + +#[wasm_bindgen(js_class = TDCombo)] +impl WasmTdCombo { + #[wasm_bindgen(constructor)] + pub fn new( + setup_lookback: usize, + setup_target: usize, + countdown_lookback: usize, + countdown_target: usize, + ) -> Result { + Ok(Self { + inner: wc::TdCombo::new( + setup_lookback, + setup_target, + countdown_lookback, + countdown_target, + ) + .map_err(map_err)?, + }) + } + pub fn update(&mut self, high: f64, low: f64, close: f64) -> Result, JsError> { + let c = make_candle(high, low, close, 0.0)?; + Ok(self.inner.update(c)) + } + pub fn batch( + &mut self, + high: &[f64], + low: &[f64], + close: &[f64], + ) -> Result { + if high.len() != low.len() || low.len() != close.len() { + return Err(JsError::new("high, low, close must be equal length")); + } + let mut out = Vec::with_capacity(high.len()); + for i in 0..high.len() { + let c = make_candle(high[i], low[i], close[i], 0.0)?; + out.push(self.inner.update(c).unwrap_or(f64::NAN)); + } + Ok(Float64Array::from(out.as_slice())) + } + pub fn reset(&mut self) { + self.inner.reset(); + } + #[wasm_bindgen(js_name = 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() + } +} + +// ---------- TD Countdown ---------- + +#[wasm_bindgen(js_name = TDCountdown)] +pub struct WasmTdCountdown { + inner: wc::TdCountdown, +} + +#[wasm_bindgen(js_class = TDCountdown)] +impl WasmTdCountdown { + #[wasm_bindgen(constructor)] + pub fn new( + setup_lookback: usize, + setup_target: usize, + countdown_lookback: usize, + countdown_target: usize, + ) -> Result { + Ok(Self { + inner: wc::TdCountdown::new( + setup_lookback, + setup_target, + countdown_lookback, + countdown_target, + ) + .map_err(map_err)?, + }) + } + pub fn update(&mut self, high: f64, low: f64, close: f64) -> Result, JsError> { + let c = make_candle(high, low, close, 0.0)?; + Ok(self.inner.update(c)) + } + pub fn batch( + &mut self, + high: &[f64], + low: &[f64], + close: &[f64], + ) -> Result { + if high.len() != low.len() || low.len() != close.len() { + return Err(JsError::new("high, low, close must be equal length")); + } + let mut out = Vec::with_capacity(high.len()); + for i in 0..high.len() { + let c = make_candle(high[i], low[i], close[i], 0.0)?; + out.push(self.inner.update(c).unwrap_or(f64::NAN)); + } + Ok(Float64Array::from(out.as_slice())) + } + pub fn reset(&mut self) { + self.inner.reset(); + } + #[wasm_bindgen(js_name = 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() + } +} + +// ---------- TD Lines ---------- + +#[wasm_bindgen(js_name = TDLines)] +pub struct WasmTdLines { + inner: wc::TdLines, +} + +#[wasm_bindgen(js_class = TDLines)] +impl WasmTdLines { + #[wasm_bindgen(constructor)] + pub fn new(lookback: usize, target: usize) -> Result { + Ok(Self { + inner: wc::TdLines::new(lookback, target).map_err(map_err)?, + }) + } + /// Streaming update. Returns `{ resistance, support }` once warm, else `null`. + pub fn update(&mut self, high: f64, low: f64, close: f64) -> Result { + let c = make_candle(high, low, close, 0.0)?; + Ok(match self.inner.update(c) { + Some(o) => { + let obj = Object::new(); + Reflect::set(&obj, &"resistance".into(), &o.resistance.into()).ok(); + Reflect::set(&obj, &"support".into(), &o.support.into()).ok(); + obj.into() + } + None => JsValue::NULL, + }) + } + /// Batch returns a flat `Float64Array` `[resistance0, support0, ...]`. + pub fn batch( + &mut self, + high: &[f64], + low: &[f64], + close: &[f64], + ) -> Result { + if high.len() != low.len() || low.len() != close.len() { + return Err(JsError::new("high, low, close must be equal length")); + } + let n = high.len(); + let mut out = vec![f64::NAN; n * 2]; + for i in 0..n { + let c = make_candle(high[i], low[i], close[i], 0.0)?; + if let Some(o) = self.inner.update(c) { + out[i * 2] = o.resistance; + out[i * 2 + 1] = o.support; + } + } + 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() + } +} + +// ---------- TD Range Projection ---------- + +#[wasm_bindgen(js_name = TDRangeProjection)] +pub struct WasmTdRangeProjection { + inner: wc::TdRangeProjection, +} + +#[wasm_bindgen(js_class = TDRangeProjection)] +impl WasmTdRangeProjection { + #[wasm_bindgen(constructor)] + #[allow(clippy::new_without_default)] + pub fn new() -> WasmTdRangeProjection { + Self { + inner: wc::TdRangeProjection::new(), + } + } + /// Streaming update. Returns `{ high, low }` projected for the next bar. + pub fn update( + &mut self, + open: f64, + high: f64, + low: f64, + close: f64, + ) -> Result { + let c = wc::Candle::new(open, high, low, close, 0.0, 0).map_err(map_err)?; + Ok(match self.inner.update(c) { + Some(o) => { + let obj = Object::new(); + Reflect::set(&obj, &"high".into(), &o.high.into()).ok(); + Reflect::set(&obj, &"low".into(), &o.low.into()).ok(); + obj.into() + } + None => JsValue::NULL, + }) + } + /// Batch returns a flat `Float64Array` `[projHigh0, projLow0, ...]`. + pub fn batch( + &mut self, + open: &[f64], + high: &[f64], + low: &[f64], + close: &[f64], + ) -> Result { + if open.len() != high.len() || high.len() != low.len() || low.len() != close.len() { + return Err(JsError::new("open, high, low, close must be equal length")); + } + let n = open.len(); + let mut out = vec![f64::NAN; n * 2]; + 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)?; + if let Some(p) = self.inner.update(c) { + out[i * 2] = p.high; + out[i * 2 + 1] = p.low; + } + } + 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() + } +} + +// ---------- TD Differential ---------- + +#[wasm_bindgen(js_name = TDDifferential)] +pub struct WasmTdDifferential { + inner: wc::TdDifferential, +} + +#[wasm_bindgen(js_class = TDDifferential)] +impl WasmTdDifferential { + #[wasm_bindgen(constructor)] + #[allow(clippy::new_without_default)] + pub fn new() -> WasmTdDifferential { + Self { + inner: wc::TdDifferential::new(), + } + } + pub fn update(&mut self, high: f64, low: f64, close: f64) -> Result, JsError> { + let c = make_candle(high, low, close, 0.0)?; + Ok(self.inner.update(c)) + } + pub fn batch( + &mut self, + high: &[f64], + low: &[f64], + close: &[f64], + ) -> Result { + if high.len() != low.len() || low.len() != close.len() { + return Err(JsError::new("high, low, close must be equal length")); + } + let mut out = Vec::with_capacity(high.len()); + for i in 0..high.len() { + let c = make_candle(high[i], low[i], close[i], 0.0)?; + out.push(self.inner.update(c).unwrap_or(f64::NAN)); + } + Ok(Float64Array::from(out.as_slice())) + } + pub fn reset(&mut self) { + self.inner.reset(); + } + #[wasm_bindgen(js_name = 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() + } +} + +// ---------- TD Open ---------- + +#[wasm_bindgen(js_name = TDOpen)] +pub struct WasmTdOpen { + inner: wc::TdOpen, +} + +#[wasm_bindgen(js_class = TDOpen)] +impl WasmTdOpen { + #[wasm_bindgen(constructor)] + #[allow(clippy::new_without_default)] + pub fn new() -> WasmTdOpen { + Self { + inner: wc::TdOpen::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 { + if open.len() != high.len() || high.len() != low.len() || low.len() != close.len() { + return Err(JsError::new("open, high, low, close must be equal length")); + } + let mut out = Vec::with_capacity(open.len()); + for i in 0..open.len() { + 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() + } +} + +// ---------- TD Risk Level ---------- + +#[wasm_bindgen(js_name = TDRiskLevel)] +pub struct WasmTdRiskLevel { + inner: wc::TdRiskLevel, +} + +#[wasm_bindgen(js_class = TDRiskLevel)] +impl WasmTdRiskLevel { + #[wasm_bindgen(constructor)] + pub fn new(lookback: usize, target: usize) -> Result { + Ok(Self { + inner: wc::TdRiskLevel::new(lookback, target).map_err(map_err)?, + }) + } + /// Streaming update. Returns `{ buyRisk, sellRisk }` once warm, else `null`. + pub fn update(&mut self, high: f64, low: f64, close: f64) -> Result { + let c = make_candle(high, low, close, 0.0)?; + Ok(match self.inner.update(c) { + Some(o) => { + let obj = Object::new(); + Reflect::set(&obj, &"buyRisk".into(), &o.buy_risk.into()).ok(); + Reflect::set(&obj, &"sellRisk".into(), &o.sell_risk.into()).ok(); + obj.into() + } + None => JsValue::NULL, + }) + } + /// Batch returns a flat `Float64Array` `[buyRisk0, sellRisk0, ...]`. + pub fn batch( + &mut self, + high: &[f64], + low: &[f64], + close: &[f64], + ) -> Result { + if high.len() != low.len() || low.len() != close.len() { + return Err(JsError::new("high, low, close must be equal length")); + } + let n = high.len(); + let mut out = vec![f64::NAN; n * 2]; + for i in 0..n { + let c = make_candle(high[i], low[i], close[i], 0.0)?; + if let Some(o) = self.inner.update(c) { + out[i * 2] = o.buy_risk; + out[i * 2 + 1] = o.sell_risk; + } + } + 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() + } +} #[cfg(test)] mod tests { use super::*; diff --git a/crates/wickra-core/src/indicators/mod.rs b/crates/wickra-core/src/indicators/mod.rs index 5752319d..ddd55851 100644 --- a/crates/wickra-core/src/indicators/mod.rs +++ b/crates/wickra-core/src/indicators/mod.rs @@ -106,6 +106,18 @@ mod stoch_rsi; mod stochastic; mod super_trend; mod t3; +mod td_combo; +mod td_countdown; +mod td_demarker; +mod td_differential; +mod td_lines; +mod td_open; +mod td_pressure; +mod td_range_projection; +mod td_rei; +mod td_risk_level; +mod td_sequential; +mod td_setup; mod tema; mod tii; mod trima; @@ -242,6 +254,18 @@ pub use stoch_rsi::StochRsi; pub use stochastic::{Stochastic, StochasticOutput}; pub use super_trend::{SuperTrend, SuperTrendOutput}; pub use t3::T3; +pub use td_combo::TdCombo; +pub use td_countdown::TdCountdown; +pub use td_demarker::TdDeMarker; +pub use td_differential::TdDifferential; +pub use td_lines::{TdLines, TdLinesOutput}; +pub use td_open::TdOpen; +pub use td_pressure::TdPressure; +pub use td_range_projection::{TdRangeProjection, TdRangeProjectionOutput}; +pub use td_rei::TdRei; +pub use td_risk_level::{TdRiskLevel, TdRiskLevelOutput}; +pub use td_sequential::{TdSequential, TdSequentialOutput}; +pub use td_setup::TdSetup; pub use tema::Tema; pub use tii::Tii; pub use trima::Trima; diff --git a/crates/wickra-core/src/indicators/td_combo.rs b/crates/wickra-core/src/indicators/td_combo.rs new file mode 100644 index 00000000..edfa600c --- /dev/null +++ b/crates/wickra-core/src/indicators/td_combo.rs @@ -0,0 +1,358 @@ +#![allow(clippy::doc_markdown)] + +//! Tom DeMark TD Combo — an aggressive variant of TD Countdown. +//! +//! TD Combo is DeMark's stricter countdown variant. Unlike vanilla TD +//! Sequential (which only requires `close <= low[i - 2]` for a buy +//! countdown), Combo adds two strictness conditions that prevent the +//! countdown from advancing on weak bars: +//! +//! - **Buy combo** bars must satisfy: +//! 1. `close[i] <= low[i - 2]` (the classic countdown rule) +//! 2. `low[i] <= low[i - 1]` (monotone strictly-non-rising lows) +//! 3. `close[i] < close[i - 1]` (each combo bar must close strictly lower) +//! - **Sell combo** bars must satisfy the mirror set: +//! 1. `close[i] >= high[i - 2]` +//! 2. `high[i] >= high[i - 1]` +//! 3. `close[i] > close[i - 1]` +//! +//! Like vanilla countdown, the combo is *armed* by a completed 9-bar setup +//! (same definition as [`crate::TdSetup`]) in the same direction. The combo +//! count saturates at `target` (DeMark's classic value is `13`). +//! +//! Output is a signed counter: positive for an active buy-combo run, +//! negative for a sell-combo run, `0.0` when no combo is currently armed. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Direction of an active TD Combo run. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Direction { + None, + Buy, + Sell, +} + +/// TD Combo — aggressive countdown variant. +#[derive(Debug, Clone)] +pub struct TdCombo { + setup_lookback: usize, + setup_target: usize, + countdown_lookback: usize, + countdown_target: usize, + candles: VecDeque, + buy_setup: usize, + sell_setup: usize, + buy_combo: usize, + sell_combo: usize, + direction: Direction, + ready: bool, +} + +impl TdCombo { + /// Construct a TD Combo with explicit lookbacks and targets. The + /// canonical DeMark configuration is `setup_lookback = 4`, + /// `setup_target = 9`, `countdown_lookback = 2`, `countdown_target = 13`. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if any argument is zero. + pub fn new( + setup_lookback: usize, + setup_target: usize, + countdown_lookback: usize, + countdown_target: usize, + ) -> Result { + if setup_lookback == 0 + || setup_target == 0 + || countdown_lookback == 0 + || countdown_target == 0 + { + return Err(Error::PeriodZero); + } + let cap = setup_lookback.max(countdown_lookback) + 1; + Ok(Self { + setup_lookback, + setup_target, + countdown_lookback, + countdown_target, + candles: VecDeque::with_capacity(cap), + buy_setup: 0, + sell_setup: 0, + buy_combo: 0, + sell_combo: 0, + direction: Direction::None, + ready: false, + }) + } + + /// DeMark's classic configuration: setup `lookback = 4, target = 9`, + /// combo `lookback = 2, target = 13`. + pub fn classic() -> Self { + Self::new(4, 9, 2, 13).expect("classic TD Combo parameters are valid") + } + + /// Configured `(setup_lookback, setup_target, countdown_lookback, + /// countdown_target)`. + pub const fn params(&self) -> (usize, usize, usize, usize) { + ( + self.setup_lookback, + self.setup_target, + self.countdown_lookback, + self.countdown_target, + ) + } +} + +impl Indicator for TdCombo { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + let need = self.setup_lookback.max(self.countdown_lookback); + let cap = need + 1; + if self.candles.len() == cap { + self.candles.pop_front(); + } + if self.candles.len() < need { + self.candles.push_back(candle); + return None; + } + + // Setup rule: compare to close[setup_lookback bars ago]. + let setup_ref_idx = need - self.setup_lookback; + let setup_ref_close = self.candles[setup_ref_idx].close; + if candle.close < setup_ref_close { + self.buy_setup = (self.buy_setup + 1).min(self.setup_target); + self.sell_setup = 0; + } else if candle.close > setup_ref_close { + self.sell_setup = (self.sell_setup + 1).min(self.setup_target); + self.buy_setup = 0; + } else { + self.buy_setup = 0; + self.sell_setup = 0; + } + + // Combo arming: a completed setup in either direction arms the + // combo in the same direction (resetting any opposite-direction + // combo count first). + if self.buy_setup == self.setup_target { + if self.direction != Direction::Buy { + self.buy_combo = 0; + self.sell_combo = 0; + } + self.direction = Direction::Buy; + } else if self.sell_setup == self.setup_target { + if self.direction != Direction::Sell { + self.buy_combo = 0; + self.sell_combo = 0; + } + self.direction = Direction::Sell; + } + + // Combo rule references the candle `countdown_lookback` bars ago + // (high / low) and the immediately-prior candle (low / high / + // close monotone strictness). + let combo_ref = self.candles[need - self.countdown_lookback]; + let prev = self.candles[need - 1]; + match self.direction { + Direction::Buy => { + let cond_classic = candle.close <= combo_ref.low; + let cond_low = candle.low <= prev.low; + let cond_close = candle.close < prev.close; + if cond_classic && cond_low && cond_close && self.buy_combo < self.countdown_target + { + self.buy_combo += 1; + } + } + Direction::Sell => { + let cond_classic = candle.close >= combo_ref.high; + let cond_high = candle.high >= prev.high; + let cond_close = candle.close > prev.close; + if cond_classic + && cond_high + && cond_close + && self.sell_combo < self.countdown_target + { + self.sell_combo += 1; + } + } + Direction::None => {} + } + + self.candles.push_back(candle); + self.ready = true; + + let v = match self.direction { + Direction::Buy => self.buy_combo as f64, + Direction::Sell => -(self.sell_combo as f64), + Direction::None => 0.0, + }; + Some(v) + } + + fn reset(&mut self) { + self.candles.clear(); + self.buy_setup = 0; + self.sell_setup = 0; + self.buy_combo = 0; + self.sell_combo = 0; + self.direction = Direction::None; + self.ready = false; + } + + fn warmup_period(&self) -> usize { + self.setup_lookback.max(self.countdown_lookback) + 1 + } + + fn is_ready(&self) -> bool { + self.ready + } + + fn name(&self) -> &'static str { + "TDCombo" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn c(high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new_unchecked(close, high, low, close, 0.0, ts) + } + + #[test] + fn pure_uptrend_arms_sell_combo_and_advances() { + // Strictly increasing closes -> sell setup completes at idx 12, + // then every subsequent bar satisfies the three sell-combo + // strictness conditions, so combo advances by one per bar and + // saturates at -13. + let candles: Vec = (1..=40) + .map(|i| { + c( + f64::from(i) + 0.5, + f64::from(i) - 0.5, + f64::from(i), + i64::from(i), + ) + }) + .collect(); + let mut combo = TdCombo::classic(); + let out = combo.batch(&candles); + // First emit is at index 4 (warmup is 5). + for v in out.iter().take(4) { + assert!(v.is_none()); + } + // At idx 12 the setup completes and combo direction is sell; on + // the same bar the combo rule fires once because the + // monotone-strictness conditions hold for a strictly-rising + // series, so combo == -1. + let at_12 = out[12].expect("ready"); + assert_eq!(at_12, -1.0); + // By idx 30 the combo has saturated at -13. + let later = out[30].expect("ready"); + assert_eq!(later, -13.0); + } + + #[test] + fn pure_downtrend_arms_buy_combo_and_advances() { + // Strictly decreasing closes -> buy setup completes at idx 12, + // then every subsequent bar satisfies the three buy-combo + // strictness conditions, so combo advances by one per bar and + // saturates at +13. + let candles: Vec = (1..=40) + .rev() + .enumerate() + .map(|(k, i)| { + c( + f64::from(i) + 0.5, + f64::from(i) - 0.5, + f64::from(i), + i64::try_from(k).unwrap(), + ) + }) + .collect(); + let mut combo = TdCombo::classic(); + let out = combo.batch(&candles); + for v in out.iter().take(4) { + assert!(v.is_none()); + } + // At idx 12 the setup completes and combo direction is buy; on + // the same bar the combo rule fires once because the + // monotone-strictness conditions hold for a strictly-falling + // series, so combo == +1. + let at_12 = out[12].expect("ready"); + assert_eq!(at_12, 1.0); + // By idx 30 the combo has saturated at +13. + let later = out[30].expect("ready"); + assert_eq!(later, 13.0); + } + + #[test] + fn flat_series_never_arms_combo() { + // All closes equal -> setup never completes -> combo never arms. + let candles: Vec = (0..40).map(|i| c(10.5, 9.5, 10.0, i64::from(i))).collect(); + let mut combo = TdCombo::classic(); + for v in combo.batch(&candles).into_iter().flatten() { + assert_eq!(v, 0.0); + } + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..80) + .map(|i| { + let m = 100.0 + (f64::from(i) * 0.3).sin() * 5.0; + c(m + 1.0, m - 1.0, m, i64::from(i)) + }) + .collect(); + let mut a = TdCombo::classic(); + let mut b = TdCombo::classic(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn rejects_invalid_params() { + assert!(matches!(TdCombo::new(0, 9, 2, 13), Err(Error::PeriodZero))); + assert!(matches!(TdCombo::new(4, 0, 2, 13), Err(Error::PeriodZero))); + assert!(matches!(TdCombo::new(4, 9, 0, 13), Err(Error::PeriodZero))); + assert!(matches!(TdCombo::new(4, 9, 2, 0), Err(Error::PeriodZero))); + } + + #[test] + fn reset_clears_state() { + let candles: Vec = (1..=30) + .map(|i| { + c( + f64::from(i) + 0.5, + f64::from(i) - 0.5, + f64::from(i), + i64::from(i), + ) + }) + .collect(); + let mut combo = TdCombo::classic(); + combo.batch(&candles); + assert!(combo.is_ready()); + combo.reset(); + assert!(!combo.is_ready()); + assert_eq!(combo.update(candles[0]), None); + } + + #[test] + fn accessors_and_metadata() { + let combo = TdCombo::classic(); + assert_eq!(combo.params(), (4, 9, 2, 13)); + assert_eq!(combo.warmup_period(), 5); + assert_eq!(combo.name(), "TDCombo"); + } +} diff --git a/crates/wickra-core/src/indicators/td_countdown.rs b/crates/wickra-core/src/indicators/td_countdown.rs new file mode 100644 index 00000000..75babf04 --- /dev/null +++ b/crates/wickra-core/src/indicators/td_countdown.rs @@ -0,0 +1,340 @@ +#![allow(clippy::doc_markdown)] + +//! Tom DeMark TD Countdown (standalone 13-bar countdown). +//! +//! The Countdown is the second half of DeMark's TD Sequential, packaged +//! here as a standalone indicator that runs the setup-detection phase +//! internally and then exposes only the countdown count (and direction) +//! to callers who don't need the running setup state. +//! +//! - **Setup detection** (internal): 9 consecutive bars whose close is +//! less-than (buy setup) or greater-than (sell setup) the close +//! `setup_lookback` bars earlier. +//! - **Buy countdown** advances on bars where `close[i] <= low[i - +//! countdown_lookback]` (need not be consecutive). Saturates at +//! `countdown_target` (13 in DeMark's classic configuration). +//! - **Sell countdown** advances on bars where `close[i] >= high[i - +//! countdown_lookback]`. +//! - An opposite-direction setup completion invalidates the active +//! countdown (count resets to zero in the new direction). +//! +//! Output is a signed counter: positive for an active buy countdown, +//! negative for an active sell countdown, and `0.0` when no countdown is +//! currently armed. +//! +//! This indicator differs from [`crate::TdSequential`] only in its +//! output shape: callers who only need the countdown value (and not the +//! running setup count) can use this for a smaller streaming payload. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Direction of an active TD Countdown phase. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Direction { + None, + Buy, + Sell, +} + +/// TD Countdown — standalone 13-bar countdown. +#[derive(Debug, Clone)] +pub struct TdCountdown { + setup_lookback: usize, + setup_target: usize, + countdown_lookback: usize, + countdown_target: usize, + candles: VecDeque, + buy_setup: usize, + sell_setup: usize, + buy_countdown: usize, + sell_countdown: usize, + direction: Direction, + ready: bool, +} + +impl TdCountdown { + /// Construct a TD Countdown with explicit lookbacks and targets. The + /// canonical DeMark configuration is `setup_lookback = 4`, + /// `setup_target = 9`, `countdown_lookback = 2`, `countdown_target = 13`. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if any argument is zero. + pub fn new( + setup_lookback: usize, + setup_target: usize, + countdown_lookback: usize, + countdown_target: usize, + ) -> Result { + if setup_lookback == 0 + || setup_target == 0 + || countdown_lookback == 0 + || countdown_target == 0 + { + return Err(Error::PeriodZero); + } + let cap = setup_lookback.max(countdown_lookback) + 1; + Ok(Self { + setup_lookback, + setup_target, + countdown_lookback, + countdown_target, + candles: VecDeque::with_capacity(cap), + buy_setup: 0, + sell_setup: 0, + buy_countdown: 0, + sell_countdown: 0, + direction: Direction::None, + ready: false, + }) + } + + /// DeMark's classic configuration: setup `lookback = 4, target = 9`, + /// countdown `lookback = 2, target = 13`. + pub fn classic() -> Self { + Self::new(4, 9, 2, 13).expect("classic TD Countdown parameters are valid") + } + + /// Configured `(setup_lookback, setup_target, countdown_lookback, + /// countdown_target)`. + pub const fn params(&self) -> (usize, usize, usize, usize) { + ( + self.setup_lookback, + self.setup_target, + self.countdown_lookback, + self.countdown_target, + ) + } +} + +impl Indicator for TdCountdown { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + let need = self.setup_lookback.max(self.countdown_lookback); + let cap = need + 1; + if self.candles.len() == cap { + self.candles.pop_front(); + } + if self.candles.len() < need { + self.candles.push_back(candle); + return None; + } + + // Setup rule: compare to close[setup_lookback bars ago]. + let setup_ref_idx = need - self.setup_lookback; + let setup_ref_close = self.candles[setup_ref_idx].close; + if candle.close < setup_ref_close { + self.buy_setup = (self.buy_setup + 1).min(self.setup_target); + self.sell_setup = 0; + } else if candle.close > setup_ref_close { + self.sell_setup = (self.sell_setup + 1).min(self.setup_target); + self.buy_setup = 0; + } else { + self.buy_setup = 0; + self.sell_setup = 0; + } + + if self.buy_setup == self.setup_target { + if self.direction != Direction::Buy { + self.buy_countdown = 0; + self.sell_countdown = 0; + } + self.direction = Direction::Buy; + } else if self.sell_setup == self.setup_target { + if self.direction != Direction::Sell { + self.buy_countdown = 0; + self.sell_countdown = 0; + } + self.direction = Direction::Sell; + } + + let cd_ref = self.candles[need - self.countdown_lookback]; + match self.direction { + Direction::Buy => { + if candle.close <= cd_ref.low && self.buy_countdown < self.countdown_target { + self.buy_countdown += 1; + } + } + Direction::Sell => { + if candle.close >= cd_ref.high && self.sell_countdown < self.countdown_target { + self.sell_countdown += 1; + } + } + Direction::None => {} + } + + self.candles.push_back(candle); + self.ready = true; + + let v = match self.direction { + Direction::Buy => self.buy_countdown as f64, + Direction::Sell => -(self.sell_countdown as f64), + Direction::None => 0.0, + }; + Some(v) + } + + fn reset(&mut self) { + self.candles.clear(); + self.buy_setup = 0; + self.sell_setup = 0; + self.buy_countdown = 0; + self.sell_countdown = 0; + self.direction = Direction::None; + self.ready = false; + } + + fn warmup_period(&self) -> usize { + self.setup_lookback.max(self.countdown_lookback) + 1 + } + + fn is_ready(&self) -> bool { + self.ready + } + + fn name(&self) -> &'static str { + "TDCountdown" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn c(high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new_unchecked(close, high, low, close, 0.0, ts) + } + + #[test] + fn pure_uptrend_completes_setup_then_runs_sell_countdown_to_minus_13() { + let candles: Vec = (1..=40) + .map(|i| { + c( + f64::from(i) + 0.5, + f64::from(i) - 0.5, + f64::from(i), + i64::from(i), + ) + }) + .collect(); + let mut td = TdCountdown::classic(); + let out = td.batch(&candles); + // Warmup: 4 None values. + for v in out.iter().take(4) { + assert!(v.is_none()); + } + // At idx 12 the sell setup completes; on the same bar the + // countdown rule fires once because close > high[i-2] for a + // strictly-rising series, so countdown == -1. + assert_eq!(out[12].expect("ready"), -1.0); + // After enough bars the countdown saturates at -13. + assert_eq!(out[30].expect("ready"), -13.0); + } + + #[test] + fn pure_downtrend_completes_setup_then_runs_buy_countdown_to_plus_13() { + let candles: Vec = (1..=40) + .rev() + .enumerate() + .map(|(k, i)| { + c( + f64::from(i) + 0.5, + f64::from(i) - 0.5, + f64::from(i), + i64::try_from(k).unwrap(), + ) + }) + .collect(); + let mut td = TdCountdown::classic(); + let out = td.batch(&candles); + for v in out.iter().take(4) { + assert!(v.is_none()); + } + // At idx 12 the buy setup completes; on the same bar the + // countdown rule fires once because close < low[i-2] for a + // strictly-falling series, so countdown == +1. + assert_eq!(out[12].expect("ready"), 1.0); + // After enough bars the countdown saturates at +13. + assert_eq!(out[30].expect("ready"), 13.0); + } + + #[test] + fn flat_series_never_arms_countdown() { + let candles: Vec = (0..30).map(|i| c(10.5, 9.5, 10.0, i64::from(i))).collect(); + let mut td = TdCountdown::classic(); + for v in td.batch(&candles).into_iter().flatten() { + assert_eq!(v, 0.0); + } + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..80) + .map(|i| { + let m = 100.0 + (f64::from(i) * 0.3).sin() * 5.0; + c(m + 1.0, m - 1.0, m, i64::from(i)) + }) + .collect(); + let mut a = TdCountdown::classic(); + let mut b = TdCountdown::classic(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn rejects_invalid_params() { + assert!(matches!( + TdCountdown::new(0, 9, 2, 13), + Err(Error::PeriodZero) + )); + assert!(matches!( + TdCountdown::new(4, 0, 2, 13), + Err(Error::PeriodZero) + )); + assert!(matches!( + TdCountdown::new(4, 9, 0, 13), + Err(Error::PeriodZero) + )); + assert!(matches!( + TdCountdown::new(4, 9, 2, 0), + Err(Error::PeriodZero) + )); + } + + #[test] + fn reset_clears_state() { + let candles: Vec = (1..=30) + .map(|i| { + c( + f64::from(i) + 0.5, + f64::from(i) - 0.5, + f64::from(i), + i64::from(i), + ) + }) + .collect(); + let mut td = TdCountdown::classic(); + td.batch(&candles); + assert!(td.is_ready()); + td.reset(); + assert!(!td.is_ready()); + assert_eq!(td.update(candles[0]), None); + } + + #[test] + fn accessors_and_metadata() { + let td = TdCountdown::classic(); + assert_eq!(td.params(), (4, 9, 2, 13)); + assert_eq!(td.warmup_period(), 5); + assert_eq!(td.name(), "TDCountdown"); + } +} diff --git a/crates/wickra-core/src/indicators/td_demarker.rs b/crates/wickra-core/src/indicators/td_demarker.rs new file mode 100644 index 00000000..6860c5a8 --- /dev/null +++ b/crates/wickra-core/src/indicators/td_demarker.rs @@ -0,0 +1,246 @@ +#![allow(clippy::doc_markdown)] + +//! Tom DeMark DeMarker (TD DeMarker) — bounded [0, 1] oscillator built from +//! highs and lows. +//! +//! For each bar `i`: +//! +//! ```text +//! DeMax(i) = max(high[i] - high[i-1], 0) +//! DeMin(i) = max(low[i-1] - low[i], 0) +//! ``` +//! +//! Then the indicator is the simple moving average of `DeMax` over `period` +//! bars divided by the sum of the simple moving averages of `DeMax` and +//! `DeMin` over the same window: +//! +//! ```text +//! DeMarker = SMA(DeMax, period) / (SMA(DeMax, period) + SMA(DeMin, period)) +//! ``` +//! +//! When both averages are zero (a perfectly flat market) the indicator emits +//! the neutral midpoint `0.5`. Values above `0.7` mark overbought conditions, +//! values below `0.3` mark oversold. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// TD DeMarker bounded oscillator. +#[derive(Debug, Clone)] +pub struct TdDeMarker { + period: usize, + prev: Option, + demax: VecDeque, + demin: VecDeque, + last_value: Option, +} + +impl TdDeMarker { + /// Construct a TD DeMarker with the given window length. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + period, + prev: None, + demax: VecDeque::with_capacity(period), + demin: VecDeque::with_capacity(period), + last_value: None, + }) + } + + /// Configured window. + pub const fn period(&self) -> usize { + self.period + } + + /// Latest emitted value if available. + pub const fn value(&self) -> Option { + self.last_value + } +} + +impl Indicator for TdDeMarker { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + let Some(prev) = self.prev else { + self.prev = Some(candle); + return None; + }; + let demax = (candle.high - prev.high).max(0.0); + let demin = (prev.low - candle.low).max(0.0); + self.prev = Some(candle); + if self.demax.len() == self.period { + self.demax.pop_front(); + self.demin.pop_front(); + } + self.demax.push_back(demax); + self.demin.push_back(demin); + if self.demax.len() < self.period { + return None; + } + let n = self.period as f64; + let sum_max: f64 = self.demax.iter().sum::() / n; + let sum_min: f64 = self.demin.iter().sum::() / n; + let denom = sum_max + sum_min; + let v = if denom == 0.0 { 0.5 } else { sum_max / denom }; + self.last_value = Some(v); + Some(v) + } + + fn reset(&mut self) { + self.prev = None; + self.demax.clear(); + self.demin.clear(); + self.last_value = None; + } + + fn warmup_period(&self) -> usize { + self.period + 1 + } + + fn is_ready(&self) -> bool { + self.last_value.is_some() + } + + fn name(&self) -> &'static str { + "TDDeMarker" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn c(high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new_unchecked(close, high, low, close, 0.0, ts) + } + + #[test] + fn flat_market_emits_neutral_05() { + // All highs and lows equal -> DeMax == DeMin == 0 every bar -> the + // denominator is zero and the indicator must fall back to 0.5. + let candles: Vec = (0..30).map(|i| c(11.0, 9.0, 10.0, i)).collect(); + let mut dm = TdDeMarker::new(14).unwrap(); + let out = dm.batch(&candles); + for v in out.iter().skip(14).copied().flatten() { + assert_relative_eq!(v, 0.5, epsilon = 1e-12); + } + } + + #[test] + fn pure_uptrend_pegs_indicator_at_one() { + // Every bar makes a higher high and higher low. DeMax is always + // positive, DeMin is always zero -> indicator = 1. + let candles: Vec = (0..20) + .map(|i: i32| { + c( + 11.0 + f64::from(i), + 9.0 + f64::from(i), + 10.0 + f64::from(i), + i64::from(i), + ) + }) + .collect(); + let mut dm = TdDeMarker::new(5).unwrap(); + let out = dm.batch(&candles); + for v in out.iter().skip(6).copied().flatten() { + assert_relative_eq!(v, 1.0, epsilon = 1e-12); + } + } + + #[test] + fn pure_downtrend_pegs_indicator_at_zero() { + let candles: Vec = (0..20) + .map(|i: i32| { + c( + 11.0 - f64::from(i), + 9.0 - f64::from(i), + 10.0 - f64::from(i), + i64::from(i), + ) + }) + .collect(); + let mut dm = TdDeMarker::new(5).unwrap(); + let out = dm.batch(&candles); + for v in out.iter().skip(6).copied().flatten() { + assert_relative_eq!(v, 0.0, epsilon = 1e-12); + } + } + + #[test] + fn stays_in_unit_interval() { + let candles: Vec = (0..200) + .map(|i| { + let m = 50.0 + (f64::from(i) * 0.2).sin() * 5.0; + c(m + 1.0, m - 1.0, m, i64::from(i)) + }) + .collect(); + let mut dm = TdDeMarker::new(14).unwrap(); + for v in dm.batch(&candles).into_iter().flatten() { + assert!((0.0..=1.0).contains(&v), "out of range: {v}"); + } + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..60) + .map(|i| { + let m = 100.0 + (f64::from(i) * 0.3).sin() * 5.0; + c(m + 1.0, m - 1.0, m, i64::from(i)) + }) + .collect(); + let mut a = TdDeMarker::new(14).unwrap(); + let mut b = TdDeMarker::new(14).unwrap(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn rejects_zero_period() { + assert!(matches!(TdDeMarker::new(0), Err(Error::PeriodZero))); + } + + #[test] + fn reset_clears_state() { + let candles: Vec = (0..30) + .map(|i: i32| { + c( + 11.0 + f64::from(i), + 9.0 + f64::from(i), + 10.0 + f64::from(i), + i64::from(i), + ) + }) + .collect(); + let mut dm = TdDeMarker::new(14).unwrap(); + dm.batch(&candles); + assert!(dm.is_ready()); + dm.reset(); + assert!(!dm.is_ready()); + assert_eq!(dm.update(candles[0]), None); + assert_eq!(dm.value(), None); + } + + #[test] + fn accessors_and_metadata() { + let dm = TdDeMarker::new(14).unwrap(); + assert_eq!(dm.period(), 14); + assert_eq!(dm.warmup_period(), 15); + assert_eq!(dm.name(), "TDDeMarker"); + } +} diff --git a/crates/wickra-core/src/indicators/td_differential.rs b/crates/wickra-core/src/indicators/td_differential.rs new file mode 100644 index 00000000..7f5f33ec --- /dev/null +++ b/crates/wickra-core/src/indicators/td_differential.rs @@ -0,0 +1,191 @@ +#![allow(clippy::doc_markdown)] + +//! Tom DeMark TD Differential — 2-bar momentum-divergence reversal pattern. +//! +//! TD Differential flags an exhaustion-and-reversal candle whose buying or +//! selling pressure has shifted from the prior bar. The rules use the +//! current bar's close vs the prior bar's close (direction filter), the +//! buying pressure `close - low` and the selling pressure `high - close`. +//! +//! - **Buy signal** (`+1.0`) on bar `i` when: +//! 1. `close[i] < close[i - 1]` (down day) +//! 2. `close[i] - low[i] > close[i - 1] - low[i - 1]` (more buying pressure than the prior bar) +//! 3. `high[i] - close[i] < high[i - 1] - close[i - 1]` (less selling pressure than the prior bar) +//! - **Sell signal** (`-1.0`) on bar `i` when: +//! 1. `close[i] > close[i - 1]` +//! 2. `high[i] - close[i] > high[i - 1] - close[i - 1]` +//! 3. `close[i] - low[i] < close[i - 1] - low[i - 1]` +//! - Otherwise the output is `0.0`. +//! +//! The two-bar lookback means the indicator emits its first value on the +//! second input candle. + +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// TD Differential — 2-bar reversal pattern detector. +#[derive(Debug, Clone, Default)] +pub struct TdDifferential { + prev: Option, + last_value: Option, +} + +impl TdDifferential { + /// Construct a new `TdDifferential`. + pub fn new() -> Self { + Self::default() + } + + /// Latest emitted signal if available. + pub const fn value(&self) -> Option { + self.last_value + } +} + +impl Indicator for TdDifferential { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + let Some(prev) = self.prev else { + self.prev = Some(candle); + return None; + }; + let buying_now = candle.close - candle.low; + let buying_prev = prev.close - prev.low; + let selling_now = candle.high - candle.close; + let selling_prev = prev.high - prev.close; + + let v = if candle.close < prev.close + && buying_now > buying_prev + && selling_now < selling_prev + { + 1.0 + } else if candle.close > prev.close + && selling_now > selling_prev + && buying_now < buying_prev + { + -1.0 + } else { + 0.0 + }; + + self.prev = Some(candle); + self.last_value = Some(v); + Some(v) + } + + fn reset(&mut self) { + self.prev = None; + self.last_value = None; + } + + fn warmup_period(&self) -> usize { + 2 + } + + fn is_ready(&self) -> bool { + self.last_value.is_some() + } + + fn name(&self) -> &'static str { + "TDDifferential" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn c(high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new_unchecked(close, high, low, close, 0.0, ts) + } + + #[test] + fn buy_signal_on_strong_down_close_with_more_buying_pressure() { + // Prev bar: high=10, low=8, close=9 -> buying=1, selling=1. + // Curr bar: high=9, low=7, close=8.5 -> close 1, selling=0.5 < 1 -> buy signal +1. + let mut td = TdDifferential::new(); + assert_eq!(td.update(c(10.0, 8.0, 9.0, 0)), None); + assert_eq!(td.update(c(9.0, 7.0, 8.5, 1)), Some(1.0)); + } + + #[test] + fn sell_signal_on_strong_up_close_with_more_selling_pressure() { + // Prev bar: high=10, low=8, close=9 -> buying=1, selling=1. + // Curr bar: high=12, low=9, close=10.5 -> close>prev.close (10.5>9), + // selling=1.5 > 1, buying=1.5 > 1 -> condition 3 fails -> no signal. + // Build a real sell case: + // Curr bar: high=12, low=9.5, close=10.5 -> + // close>prev.close: 10.5>9 ✓ + // selling = 12 - 10.5 = 1.5 > prev.selling 1 ✓ + // buying = 10.5 - 9.5 = 1.0 < prev.buying 1 → NO (need strict <). + // Curr bar: high=12, low=9.8, close=10.5 -> + // buying = 0.7 < 1 ✓; selling = 1.5 > 1 ✓; close>prev ✓ -> sell. + let mut td = TdDifferential::new(); + assert_eq!(td.update(c(10.0, 8.0, 9.0, 0)), None); + assert_relative_eq!(td.update(c(12.0, 9.8, 10.5, 1)).unwrap(), -1.0); + } + + #[test] + fn no_signal_on_neutral_bar() { + // Identical bars -> equality everywhere -> zero. + let mut td = TdDifferential::new(); + assert_eq!(td.update(c(10.0, 8.0, 9.0, 0)), None); + assert_eq!(td.update(c(10.0, 8.0, 9.0, 1)), Some(0.0)); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40) + .map(|i| { + let m = 100.0 + (f64::from(i) * 0.3).sin() * 5.0; + c(m + 1.0, m - 1.0, m, i64::from(i)) + }) + .collect(); + let mut a = TdDifferential::new(); + let mut b = TdDifferential::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn output_only_in_canonical_set() { + // Every emitted value is in {-1, 0, +1}. + let candles: Vec = (0..120) + .map(|i| { + let m = 100.0 + (f64::from(i) * 0.5).sin() * 5.0; + c(m + 1.0, m - 1.0, m, i64::from(i)) + }) + .collect(); + let mut td = TdDifferential::new(); + for v in td.batch(&candles).into_iter().flatten() { + assert!(v == -1.0 || v == 0.0 || v == 1.0, "unexpected value {v}"); + } + } + + #[test] + fn reset_clears_state() { + let mut td = TdDifferential::new(); + td.update(c(10.0, 8.0, 9.0, 0)); + td.update(c(11.0, 9.0, 10.0, 1)); + assert!(td.is_ready()); + td.reset(); + assert!(!td.is_ready()); + assert_eq!(td.update(c(10.0, 8.0, 9.0, 2)), None); + assert_eq!(td.value(), None); + } + + #[test] + fn accessors_and_metadata() { + let td = TdDifferential::new(); + assert_eq!(td.warmup_period(), 2); + assert_eq!(td.name(), "TDDifferential"); + assert_eq!(td.value(), None); + } +} diff --git a/crates/wickra-core/src/indicators/td_lines.rs b/crates/wickra-core/src/indicators/td_lines.rs new file mode 100644 index 00000000..6fbddfc1 --- /dev/null +++ b/crates/wickra-core/src/indicators/td_lines.rs @@ -0,0 +1,325 @@ +#![allow(clippy::doc_markdown)] + +//! Tom DeMark TD Lines (TDST — TD Setup Trend Support / Resistance levels). +//! +//! Once a TD Setup completes in either direction, DeMark defines two +//! horizontal trend levels derived from the nine bars of that setup: +//! +//! - **TDST resistance** is the highest high among the nine bars of the +//! most-recently-completed **buy** setup. A break above resistance +//! invalidates the setup's bullish reversal thesis. +//! - **TDST support** is the lowest low among the nine bars of the +//! most-recently-completed **sell** setup. A break below support +//! invalidates the setup's bearish reversal thesis. +//! +//! Until a setup completes in a given direction, the corresponding level +//! is `f64::NAN` (no level defined). Once a level is set it stays at its +//! value until the next completed setup in that direction updates it. +//! +//! This implementation tracks both the buy and sell setup state machines +//! in parallel (sharing the same `lookback` / `target` parameters as +//! [`crate::TdSetup`]) and records the bar extremes during the active +//! streak so the level can be emitted the moment the setup completes. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Output of [`TdLines`]: the latest TDST resistance / support pair. +/// +/// `resistance` is set after a completed buy setup (the highest high of +/// the nine setup bars); `support` is set after a completed sell setup +/// (the lowest low of the nine setup bars). Either field is `f64::NAN` +/// until the first setup in that direction completes. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct TdLinesOutput { + /// Latest TDST resistance, or `NAN` if no buy setup has completed yet. + pub resistance: f64, + /// Latest TDST support, or `NAN` if no sell setup has completed yet. + pub support: f64, +} + +/// TD Lines (TDST) — setup-derived horizontal support / resistance. +#[derive(Debug, Clone)] +pub struct TdLines { + lookback: usize, + target: usize, + closes: VecDeque, + buy_count: usize, + sell_count: usize, + /// Highest high observed during the *current* buy-setup run (running + /// extreme, resets when the buy run resets). + buy_run_max_high: f64, + /// Lowest low observed during the *current* sell-setup run. + sell_run_min_low: f64, + resistance: f64, + support: f64, + ready: bool, +} + +impl TdLines { + /// Construct a TD Lines with explicit lookback and target. The + /// canonical DeMark configuration is `lookback = 4`, `target = 9`. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if either argument is zero. + pub fn new(lookback: usize, target: usize) -> Result { + if lookback == 0 || target == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + lookback, + target, + closes: VecDeque::with_capacity(lookback + 1), + buy_count: 0, + sell_count: 0, + buy_run_max_high: f64::NEG_INFINITY, + sell_run_min_low: f64::INFINITY, + resistance: f64::NAN, + support: f64::NAN, + ready: false, + }) + } + + /// DeMark's classic configuration: `lookback = 4`, `target = 9`. + pub fn classic() -> Self { + Self::new(4, 9).expect("classic TD Lines parameters are valid") + } + + /// Configured `(lookback, target)`. + pub const fn params(&self) -> (usize, usize) { + (self.lookback, self.target) + } +} + +impl Indicator for TdLines { + type Input = Candle; + type Output = TdLinesOutput; + + fn update(&mut self, candle: Candle) -> Option { + if self.closes.len() > self.lookback { + self.closes.pop_front(); + } + if self.closes.len() < self.lookback { + self.closes.push_back(candle.close); + return None; + } + let reference = *self.closes.front().expect("non-empty after the guard"); + self.closes.push_back(candle.close); + + if candle.close < reference { + // Continue / start a buy-setup run; if the sell run breaks + // here, reset its running extreme. + if self.buy_count == 0 { + self.buy_run_max_high = candle.high; + } else { + self.buy_run_max_high = self.buy_run_max_high.max(candle.high); + } + self.buy_count = (self.buy_count + 1).min(self.target); + self.sell_count = 0; + self.sell_run_min_low = f64::INFINITY; + if self.buy_count == self.target { + self.resistance = self.buy_run_max_high; + } + } else if candle.close > reference { + if self.sell_count == 0 { + self.sell_run_min_low = candle.low; + } else { + self.sell_run_min_low = self.sell_run_min_low.min(candle.low); + } + self.sell_count = (self.sell_count + 1).min(self.target); + self.buy_count = 0; + self.buy_run_max_high = f64::NEG_INFINITY; + if self.sell_count == self.target { + self.support = self.sell_run_min_low; + } + } else { + // Equality breaks both runs. + self.buy_count = 0; + self.sell_count = 0; + self.buy_run_max_high = f64::NEG_INFINITY; + self.sell_run_min_low = f64::INFINITY; + } + + self.ready = true; + Some(TdLinesOutput { + resistance: self.resistance, + support: self.support, + }) + } + + fn reset(&mut self) { + self.closes.clear(); + self.buy_count = 0; + self.sell_count = 0; + self.buy_run_max_high = f64::NEG_INFINITY; + self.sell_run_min_low = f64::INFINITY; + self.resistance = f64::NAN; + self.support = f64::NAN; + self.ready = false; + } + + fn warmup_period(&self) -> usize { + self.lookback + 1 + } + + fn is_ready(&self) -> bool { + self.ready + } + + fn name(&self) -> &'static str { + "TDLines" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn c(high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new_unchecked(close, high, low, close, 0.0, ts) + } + + #[test] + fn uptrend_completes_sell_setup_and_sets_support() { + // Strictly rising series -> sell setup completes at bar index 12 + // (warmup 5 + 8 advances). The lowest low across bars 4..=12 is + // the low at idx 4 since the series is strictly rising. + let candles: Vec = (1..=20) + .map(|i| { + c( + f64::from(i) + 0.5, + f64::from(i) - 0.5, + f64::from(i), + i64::from(i), + ) + }) + .collect(); + let mut lines = TdLines::classic(); + let out = lines.batch(&candles); + // Before completion, support is NaN; resistance is NaN throughout + // (no buy setup ever completes). + let early = out[5].expect("ready"); + assert!(early.support.is_nan()); + assert!(early.resistance.is_nan()); + // After completion at idx 12, support is the low of bar idx 4 = 4.5. + let after = out[12].expect("ready"); + assert!(after.resistance.is_nan()); + assert_relative_eq!(after.support, 4.5, epsilon = 1e-12); + // Subsequent bars (still increasing, sell setup saturating) keep + // the running extreme at the original low. + let final_out = out[19].expect("ready"); + assert_relative_eq!(final_out.support, 4.5, epsilon = 1e-12); + } + + #[test] + fn downtrend_completes_buy_setup_and_sets_resistance() { + let candles: Vec = (1..=20) + .rev() + .enumerate() + .map(|(i, v)| { + c( + f64::from(v) + 0.5, + f64::from(v) - 0.5, + f64::from(v), + i64::try_from(i).unwrap(), + ) + }) + .collect(); + let mut lines = TdLines::classic(); + let out = lines.batch(&candles); + // Buy setup completes at idx 12. The highest high during the + // buy run is the high of bar idx 4 (since the series is strictly + // decreasing): low/high of bar 4 are computed below. + let after = out[12].expect("ready"); + assert!(after.support.is_nan()); + // The high at idx 4 in the reversed series is value 16 + 0.5. + assert_relative_eq!(after.resistance, 16.5, epsilon = 1e-12); + } + + #[test] + fn flat_series_never_sets_levels() { + // All closes equal -> neither setup advances -> both levels stay NaN. + let candles: Vec = (0..30).map(|i| c(10.5, 9.5, 10.0, i64::from(i))).collect(); + let mut lines = TdLines::classic(); + for v in lines.batch(&candles).into_iter().flatten() { + assert!(v.support.is_nan()); + assert!(v.resistance.is_nan()); + } + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..80) + .map(|i| { + let m = 100.0 + (f64::from(i) * 0.3).sin() * 5.0; + c(m + 1.0, m - 1.0, m, i64::from(i)) + }) + .collect(); + let mut a = TdLines::classic(); + let mut b = TdLines::classic(); + let av = a.batch(&candles); + let bv: Vec<_> = candles.iter().map(|x| b.update(*x)).collect(); + assert_eq!(av.len(), bv.len()); + for (i, (x, y)) in av.iter().zip(bv.iter()).enumerate() { + assert_eq!(x.is_some(), y.is_some(), "row {i} option mismatch"); + if let (Some(a), Some(b)) = (x, y) { + assert_eq!( + a.support.is_nan(), + b.support.is_nan(), + "row {i} support nan flag" + ); + assert_eq!( + a.resistance.is_nan(), + b.resistance.is_nan(), + "row {i} resistance nan flag" + ); + if !a.support.is_nan() { + assert_relative_eq!(a.support, b.support, epsilon = 1e-12); + } + if !a.resistance.is_nan() { + assert_relative_eq!(a.resistance, b.resistance, epsilon = 1e-12); + } + } + } + } + + #[test] + fn rejects_invalid_params() { + assert!(matches!(TdLines::new(0, 9), Err(Error::PeriodZero))); + assert!(matches!(TdLines::new(4, 0), Err(Error::PeriodZero))); + } + + #[test] + fn reset_clears_state() { + let candles: Vec = (1..=20) + .map(|i| { + c( + f64::from(i) + 0.5, + f64::from(i) - 0.5, + f64::from(i), + i64::from(i), + ) + }) + .collect(); + let mut lines = TdLines::classic(); + lines.batch(&candles); + assert!(lines.is_ready()); + lines.reset(); + assert!(!lines.is_ready()); + assert_eq!(lines.update(candles[0]), None); + } + + #[test] + fn accessors_and_metadata() { + let lines = TdLines::classic(); + assert_eq!(lines.params(), (4, 9)); + assert_eq!(lines.warmup_period(), 5); + assert_eq!(lines.name(), "TDLines"); + } +} diff --git a/crates/wickra-core/src/indicators/td_open.rs b/crates/wickra-core/src/indicators/td_open.rs new file mode 100644 index 00000000..9decc867 --- /dev/null +++ b/crates/wickra-core/src/indicators/td_open.rs @@ -0,0 +1,172 @@ +#![allow(clippy::doc_markdown)] + +//! Tom DeMark TD Open — open-vs-prior-range gap-reversal signal. +//! +//! TD Open flags bars whose open prints *outside* the prior bar's range +//! but whose subsequent action recovers back inside it — a classic +//! gap-and-fade reversal pattern. +//! +//! - **Buy signal** (`+1.0`) on bar `i` when: +//! 1. `open[i] < low[i - 1]` (gap-down open) +//! 2. `high[i] > low[i - 1]` (high recovers above the prior low) +//! - **Sell signal** (`-1.0`) on bar `i` when: +//! 1. `open[i] > high[i - 1]` (gap-up open) +//! 2. `low[i] < high[i - 1]` (low fades back under the prior high) +//! - Otherwise the output is `0.0`. +//! +//! The one-bar lookback means the indicator emits its first value on the +//! second input candle. + +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// TD Open — gap-and-fade reversal detector. +#[derive(Debug, Clone, Default)] +pub struct TdOpen { + prev: Option, + last_value: Option, +} + +impl TdOpen { + /// Construct a new `TdOpen`. + pub fn new() -> Self { + Self::default() + } + + /// Latest emitted signal if available. + pub const fn value(&self) -> Option { + self.last_value + } +} + +impl Indicator for TdOpen { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + let Some(prev) = self.prev else { + self.prev = Some(candle); + return None; + }; + let v = if candle.open < prev.low && candle.high > prev.low { + 1.0 + } else if candle.open > prev.high && candle.low < prev.high { + -1.0 + } else { + 0.0 + }; + self.prev = Some(candle); + self.last_value = Some(v); + Some(v) + } + + fn reset(&mut self) { + self.prev = None; + self.last_value = None; + } + + fn warmup_period(&self) -> usize { + 2 + } + + fn is_ready(&self) -> bool { + self.last_value.is_some() + } + + fn name(&self) -> &'static str { + "TDOpen" + } +} + +#[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_unchecked(open, high, low, close, 0.0, ts) + } + + #[test] + fn buy_signal_on_gap_down_with_recovery() { + // Prev bar: low=10. Curr open=9 < 10, curr high=11 > 10 -> buy +1. + let mut td = TdOpen::new(); + assert_eq!(td.update(c(10.0, 11.0, 10.0, 10.5, 0)), None); + assert_eq!(td.update(c(9.0, 11.0, 8.5, 9.5, 1)), Some(1.0)); + } + + #[test] + fn sell_signal_on_gap_up_with_fade() { + // Prev bar: high=12. Curr open=13 > 12, curr low=11 < 12 -> sell -1. + let mut td = TdOpen::new(); + assert_eq!(td.update(c(10.0, 12.0, 9.0, 11.0, 0)), None); + assert_eq!(td.update(c(13.0, 13.5, 11.0, 11.5, 1)), Some(-1.0)); + } + + #[test] + fn no_signal_on_normal_open_within_range() { + // Open within previous range -> neither gap condition fires. + let mut td = TdOpen::new(); + assert_eq!(td.update(c(10.0, 12.0, 9.0, 11.0, 0)), None); + assert_eq!(td.update(c(10.5, 11.5, 9.5, 11.0, 1)), Some(0.0)); + } + + #[test] + fn gap_down_without_recovery_is_zero() { + // Open below prev.low, but high stays below prev.low too -> no signal. + let mut td = TdOpen::new(); + assert_eq!(td.update(c(10.0, 12.0, 10.0, 11.0, 0)), None); + // Curr open=9, curr high=9.5 -> high < prev.low (10) -> no buy. + assert_eq!(td.update(c(9.0, 9.5, 8.5, 9.0, 1)), Some(0.0)); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40) + .map(|i| { + let m = 100.0 + (f64::from(i) * 0.3).sin() * 5.0; + c(m, m + 1.0, m - 1.0, m + 0.3, i64::from(i)) + }) + .collect(); + let mut a = TdOpen::new(); + let mut b = TdOpen::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn output_only_in_canonical_set() { + let candles: Vec = (0..120) + .map(|i| { + let m = 100.0 + (f64::from(i) * 0.5).sin() * 5.0; + c(m, m + 1.0, m - 1.0, m + 0.3, i64::from(i)) + }) + .collect(); + let mut td = TdOpen::new(); + for v in td.batch(&candles).into_iter().flatten() { + assert!(v == -1.0 || v == 0.0 || v == 1.0, "unexpected value {v}"); + } + } + + #[test] + fn reset_clears_state() { + let mut td = TdOpen::new(); + td.update(c(10.0, 11.0, 9.0, 10.0, 0)); + td.update(c(10.5, 11.5, 9.5, 10.5, 1)); + assert!(td.is_ready()); + td.reset(); + assert!(!td.is_ready()); + assert_eq!(td.update(c(10.0, 11.0, 9.0, 10.0, 2)), None); + assert_eq!(td.value(), None); + } + + #[test] + fn accessors_and_metadata() { + let td = TdOpen::new(); + assert_eq!(td.warmup_period(), 2); + assert_eq!(td.name(), "TDOpen"); + assert_eq!(td.value(), None); + } +} diff --git a/crates/wickra-core/src/indicators/td_pressure.rs b/crates/wickra-core/src/indicators/td_pressure.rs new file mode 100644 index 00000000..c833ce7e --- /dev/null +++ b/crates/wickra-core/src/indicators/td_pressure.rs @@ -0,0 +1,240 @@ +#![allow(clippy::doc_markdown)] + +//! Tom DeMark TD Pressure — volume-weighted buying / selling pressure +//! oscillator. +//! +//! For each bar `i` with strictly positive range: +//! +//! ```text +//! bar_pressure(i) = ((close[i] - open[i]) / (high[i] - low[i])) * volume[i] +//! ``` +//! +//! Bars whose range is zero (`high == low`) contribute zero pressure (the +//! ratio is undefined; DeMark's convention is to treat such bars as neutral). +//! The output is the SMA of bar pressure normalised by the SMA of volume over +//! a configurable `period`, scaled by 100: +//! +//! ```text +//! TD_Pressure = 100 * SMA(bar_pressure, period) / SMA(volume, period) +//! ``` +//! +//! When the windowed volume is zero (a flat zero-volume window) the +//! indicator emits `0`. Positive readings indicate net buying pressure; +//! negative readings indicate net selling pressure. The numerator is bounded +//! by `± volume_per_bar`, so the result is bounded by `±100`. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// TD Pressure volume-weighted pressure oscillator. +#[derive(Debug, Clone)] +pub struct TdPressure { + period: usize, + pressures: VecDeque, + volumes: VecDeque, + last_value: Option, +} + +impl TdPressure { + /// Construct a TD Pressure with the given averaging window. A common + /// default in DeMark's literature is `period = 5`. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + period, + pressures: VecDeque::with_capacity(period), + volumes: VecDeque::with_capacity(period), + last_value: None, + }) + } + + /// Configured window. + pub const fn period(&self) -> usize { + self.period + } + + /// Latest emitted value if available. + pub const fn value(&self) -> Option { + self.last_value + } +} + +impl Indicator for TdPressure { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + let range = candle.high - candle.low; + let bar_pressure = if range > 0.0 { + ((candle.close - candle.open) / range) * candle.volume + } else { + 0.0 + }; + + if self.pressures.len() == self.period { + self.pressures.pop_front(); + self.volumes.pop_front(); + } + self.pressures.push_back(bar_pressure); + self.volumes.push_back(candle.volume); + if self.pressures.len() < self.period { + return None; + } + let n = self.period as f64; + let mean_p: f64 = self.pressures.iter().sum::() / n; + let mean_v: f64 = self.volumes.iter().sum::() / n; + let v = if mean_v == 0.0 { + 0.0 + } else { + 100.0 * mean_p / mean_v + }; + self.last_value = Some(v); + Some(v) + } + + fn reset(&mut self) { + self.pressures.clear(); + self.volumes.clear(); + self.last_value = None; + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.last_value.is_some() + } + + fn name(&self) -> &'static str { + "TDPressure" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn c(open: f64, high: f64, low: f64, close: f64, volume: f64, ts: i64) -> Candle { + Candle::new_unchecked(open, high, low, close, volume, ts) + } + + #[test] + fn pure_bullish_candles_yield_full_positive_pressure() { + // Every bar closes at its high (close == high, open == low), so the + // per-bar pressure ratio is +1. Volume cancels in the ratio and the + // indicator must read +100. + let candles: Vec = (0..20) + .map(|i| c(9.0, 11.0, 9.0, 11.0, 100.0, i64::from(i))) + .collect(); + let mut p = TdPressure::new(5).unwrap(); + let last = p.batch(&candles).into_iter().flatten().last().unwrap(); + assert_relative_eq!(last, 100.0, epsilon = 1e-12); + } + + #[test] + fn pure_bearish_candles_yield_full_negative_pressure() { + let candles: Vec = (0..20) + .map(|i| c(11.0, 11.0, 9.0, 9.0, 100.0, i64::from(i))) + .collect(); + let mut p = TdPressure::new(5).unwrap(); + let last = p.batch(&candles).into_iter().flatten().last().unwrap(); + assert_relative_eq!(last, -100.0, epsilon = 1e-12); + } + + #[test] + fn neutral_doji_close_eq_open_yields_zero() { + let candles: Vec = (0..20) + .map(|i| c(10.0, 11.0, 9.0, 10.0, 100.0, i64::from(i))) + .collect(); + let mut p = TdPressure::new(5).unwrap(); + let last = p.batch(&candles).into_iter().flatten().last().unwrap(); + assert_relative_eq!(last, 0.0, epsilon = 1e-12); + } + + #[test] + fn zero_range_bars_contribute_zero() { + // Mix one zero-range bar with otherwise-bullish bars; the zero-range + // bar must be silently skipped (not produce NaN or inf). + let mut candles = Vec::new(); + for i in 0..5 { + candles.push(c(9.0, 11.0, 9.0, 11.0, 100.0, i64::from(i))); + } + // Zero-range, zero-volume bar in the middle. + candles.push(c(10.0, 10.0, 10.0, 10.0, 0.0, 5)); + for i in 6..11 { + candles.push(c(9.0, 11.0, 9.0, 11.0, 100.0, i64::from(i))); + } + let mut p = TdPressure::new(5).unwrap(); + for v in p.batch(&candles).into_iter().flatten() { + assert!(v.is_finite(), "non-finite output: {v}"); + assert!((-100.0..=100.0).contains(&v), "out of range: {v}"); + } + } + + #[test] + fn flat_zero_volume_window_emits_zero() { + let candles: Vec = (0..10) + .map(|i| c(10.0, 11.0, 9.0, 10.5, 0.0, i64::from(i))) + .collect(); + let mut p = TdPressure::new(5).unwrap(); + // Every bar has zero volume -> per-bar pressure is zero AND the + // denominator is zero. The indicator must fall back to 0. + let last = p.batch(&candles).into_iter().flatten().last().unwrap(); + assert_relative_eq!(last, 0.0, epsilon = 1e-12); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..60) + .map(|i| { + let m = 100.0 + (f64::from(i) * 0.3).sin() * 5.0; + c(m, m + 1.0, m - 1.0, m + 0.3, 100.0, i64::from(i)) + }) + .collect(); + let mut a = TdPressure::new(5).unwrap(); + let mut b = TdPressure::new(5).unwrap(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn rejects_zero_period() { + assert!(matches!(TdPressure::new(0), Err(Error::PeriodZero))); + } + + #[test] + fn reset_clears_state() { + let candles: Vec = (0..20) + .map(|i| c(9.0, 11.0, 9.0, 11.0, 100.0, i64::from(i))) + .collect(); + let mut p = TdPressure::new(5).unwrap(); + p.batch(&candles); + assert!(p.is_ready()); + p.reset(); + assert!(!p.is_ready()); + assert_eq!(p.update(candles[0]), None); + assert_eq!(p.value(), None); + } + + #[test] + fn accessors_and_metadata() { + let p = TdPressure::new(5).unwrap(); + assert_eq!(p.period(), 5); + assert_eq!(p.warmup_period(), 5); + assert_eq!(p.name(), "TDPressure"); + } +} diff --git a/crates/wickra-core/src/indicators/td_range_projection.rs b/crates/wickra-core/src/indicators/td_range_projection.rs new file mode 100644 index 00000000..66fa2efb --- /dev/null +++ b/crates/wickra-core/src/indicators/td_range_projection.rs @@ -0,0 +1,169 @@ +#![allow(clippy::doc_markdown)] + +//! Tom DeMark TD Range Projection — next-bar high/low projection from the +//! current bar's open/high/low/close (DeMark's "X-projection" pivot). +//! +//! After each bar closes, DeMark proposes a projected high and low for the +//! *next* bar derived from a pivot weighted by the relationship between +//! the close and the open: +//! +//! ```text +//! if close < open: pivot_sum = high + 2*low + close +//! if close > open: pivot_sum = 2*high + low + close +//! if close == open: pivot_sum = high + low + 2*close +//! +//! projected_high = pivot_sum / 2 - low +//! projected_low = pivot_sum / 2 - high +//! ``` +//! +//! The indicator is stateless beyond the current bar — every bar's input +//! deterministically produces a projection — but it is wrapped in the same +//! `Indicator` state-machine API as the rest of Wickra so it composes with +//! the streaming/batch infrastructure. + +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Output of [`TdRangeProjection`]: the projected high and low for the +/// next bar. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct TdRangeProjectionOutput { + /// Projected high for the next bar. + pub high: f64, + /// Projected low for the next bar. + pub low: f64, +} + +/// TD Range Projection — next-bar high/low pivot. +#[derive(Debug, Clone, Default)] +pub struct TdRangeProjection { + last_value: Option, +} + +impl TdRangeProjection { + /// Construct a new `TdRangeProjection`. + pub fn new() -> Self { + Self::default() + } + + /// Latest projection if available. + pub const fn value(&self) -> Option { + self.last_value + } +} + +impl Indicator for TdRangeProjection { + type Input = Candle; + type Output = TdRangeProjectionOutput; + + fn update(&mut self, candle: Candle) -> Option { + let pivot_sum = if candle.close < candle.open { + candle.high + 2.0 * candle.low + candle.close + } else if candle.close > candle.open { + 2.0 * candle.high + candle.low + candle.close + } else { + candle.high + candle.low + 2.0 * candle.close + }; + let half = pivot_sum / 2.0; + let out = TdRangeProjectionOutput { + high: half - candle.low, + low: half - candle.high, + }; + self.last_value = Some(out); + Some(out) + } + + fn reset(&mut self) { + self.last_value = None; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.last_value.is_some() + } + + fn name(&self) -> &'static str { + "TDRangeProjection" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new_unchecked(open, high, low, close, 0.0, ts) + } + + #[test] + fn bullish_bar_close_above_open_uses_double_high_pivot() { + // open=10, high=12, low=9, close=11 -> close > open + // pivot_sum = 2*12 + 9 + 11 = 44; half = 22. + // projHigh = 22 - 9 = 13; projLow = 22 - 12 = 10. + let mut p = TdRangeProjection::new(); + let v = p.update(c(10.0, 12.0, 9.0, 11.0, 0)).unwrap(); + assert_relative_eq!(v.high, 13.0, epsilon = 1e-12); + assert_relative_eq!(v.low, 10.0, epsilon = 1e-12); + } + + #[test] + fn bearish_bar_close_below_open_uses_double_low_pivot() { + // open=11, high=12, low=9, close=10 -> close < open + // pivot_sum = 12 + 2*9 + 10 = 40; half = 20. + // projHigh = 20 - 9 = 11; projLow = 20 - 12 = 8. + let mut p = TdRangeProjection::new(); + let v = p.update(c(11.0, 12.0, 9.0, 10.0, 0)).unwrap(); + assert_relative_eq!(v.high, 11.0, epsilon = 1e-12); + assert_relative_eq!(v.low, 8.0, epsilon = 1e-12); + } + + #[test] + fn doji_close_equals_open_uses_double_close_pivot() { + // open=close=10, high=12, low=9 -> doji branch. + // pivot_sum = 12 + 9 + 2*10 = 41; half = 20.5. + // projHigh = 20.5 - 9 = 11.5; projLow = 20.5 - 12 = 8.5. + let mut p = TdRangeProjection::new(); + let v = p.update(c(10.0, 12.0, 9.0, 10.0, 0)).unwrap(); + assert_relative_eq!(v.high, 11.5, epsilon = 1e-12); + assert_relative_eq!(v.low, 8.5, epsilon = 1e-12); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..30) + .map(|i| { + let m = 100.0 + (f64::from(i) * 0.3).sin() * 5.0; + c(m, m + 1.0, m - 1.0, m + 0.3, i64::from(i)) + }) + .collect(); + let mut a = TdRangeProjection::new(); + let mut b = TdRangeProjection::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut p = TdRangeProjection::new(); + p.update(c(10.0, 12.0, 9.0, 11.0, 0)); + assert!(p.is_ready()); + p.reset(); + assert!(!p.is_ready()); + assert_eq!(p.value(), None); + } + + #[test] + fn accessors_and_metadata() { + let p = TdRangeProjection::new(); + assert_eq!(p.warmup_period(), 1); + assert_eq!(p.name(), "TDRangeProjection"); + assert_eq!(p.value(), None); + } +} diff --git a/crates/wickra-core/src/indicators/td_rei.rs b/crates/wickra-core/src/indicators/td_rei.rs new file mode 100644 index 00000000..0a224404 --- /dev/null +++ b/crates/wickra-core/src/indicators/td_rei.rs @@ -0,0 +1,286 @@ +#![allow(clippy::doc_markdown)] + +//! Tom DeMark Range Expansion Index (TD REI). +//! +//! The TD REI is a `period`-bar bounded oscillator in `[-100, 100]` that +//! detects exhaustion via comparisons of the current bar's range to the bars +//! two and five-or-six bars earlier. The canonical TD REI uses a `period` of +//! 5. +//! +//! Per bar `i` (requires history through `i - 7`): +//! +//! ```text +//! cond1 = (high[i] >= low[i-5]) OR (high[i] >= low[i-6]) +//! cond2 = (low[i] <= high[i-5]) OR (low[i] <= high[i-6]) +//! +//! if cond1 AND cond2: +//! numerator = (high[i] - high[i-2]) + (low[i] - low[i-2]) +//! else: +//! numerator = 0 +//! +//! denominator = |high[i] - high[i-2]| + |low[i] - low[i-2]| +//! +//! REI(i) = 100 * sum(numerator, period) / sum(denominator, period) +//! ``` +//! +//! When the windowed denominator is zero the indicator falls back to `0` (the +//! neutral midpoint). Readings above `+60` are typically considered +//! overbought; below `-60` oversold. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// TD Range Expansion Index oscillator. +#[derive(Debug, Clone)] +pub struct TdRei { + period: usize, + // Need at least the last 7 candles for the lookback comparisons; we keep a + // rolling window long enough for the rule plus enough numerator/ + // denominator history. + candles: VecDeque, + numerators: VecDeque, + denominators: VecDeque, + last_value: Option, +} + +/// Minimum history required to evaluate the TD REI per-bar rule. The +/// numerator and denominator both reference `bar[i-2]` and the long +/// conditional references `bar[i-5]` and `bar[i-6]`, so we need the candle +/// six bars before the current one to be available. +const LOOKBACK: usize = 7; + +impl TdRei { + /// Construct a TD REI with the given averaging window. The classic + /// DeMark configuration is `period = 5`. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + period, + candles: VecDeque::with_capacity(LOOKBACK), + numerators: VecDeque::with_capacity(period), + denominators: VecDeque::with_capacity(period), + last_value: None, + }) + } + + /// DeMark's classic configuration: `period = 5`. + pub fn classic() -> Self { + Self::new(5).expect("classic TD REI parameters are valid") + } + + /// Configured window. + pub const fn period(&self) -> usize { + self.period + } + + /// Latest emitted value if available. + pub const fn value(&self) -> Option { + self.last_value + } +} + +impl Indicator for TdRei { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + // Maintain a rolling window of the last `LOOKBACK` candles (front = + // 6 bars ago when full). + if self.candles.len() == LOOKBACK { + self.candles.pop_front(); + } + if self.candles.len() < LOOKBACK - 1 { + // Need 6 previous candles before we can evaluate the rule on the + // current one. + self.candles.push_back(candle); + return None; + } + // candles currently holds the 6 most recent bars (in order); the new + // candle is the 7th. After the rule fires we push it onto the back. + // Indexing convention: index 0 is the oldest in the window (i.e. 6 + // bars ago); index 5 is the bar just before the current one. + // For the rule we need: + // bar[i-2] -> candles[len-2] (here len == 6) + // bar[i-5] -> candles[1] + // bar[i-6] -> candles[0] + let prev2 = self.candles[self.candles.len() - 2]; + let prev5 = self.candles[1]; + let prev6 = self.candles[0]; + + let cond1 = candle.high >= prev5.low || candle.high >= prev6.low; + let cond2 = candle.low <= prev5.high || candle.low <= prev6.high; + + let raw_num = (candle.high - prev2.high) + (candle.low - prev2.low); + let denominator = (candle.high - prev2.high).abs() + (candle.low - prev2.low).abs(); + let numerator = if cond1 && cond2 { raw_num } else { 0.0 }; + + if self.numerators.len() == self.period { + self.numerators.pop_front(); + self.denominators.pop_front(); + } + self.numerators.push_back(numerator); + self.denominators.push_back(denominator); + self.candles.push_back(candle); + + if self.numerators.len() < self.period { + return None; + } + let sum_num: f64 = self.numerators.iter().sum(); + let sum_den: f64 = self.denominators.iter().sum(); + let v = if sum_den == 0.0 { + 0.0 + } else { + 100.0 * sum_num / sum_den + }; + self.last_value = Some(v); + Some(v) + } + + fn reset(&mut self) { + self.candles.clear(); + self.numerators.clear(); + self.denominators.clear(); + self.last_value = None; + } + + fn warmup_period(&self) -> usize { + // 6 bars to fill the lookback plus `period` updates to fill the + // numerator / denominator buffers. + (LOOKBACK - 1) + self.period + } + + fn is_ready(&self) -> bool { + self.last_value.is_some() + } + + fn name(&self) -> &'static str { + "TDREI" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn c(high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new_unchecked(close, high, low, close, 0.0, ts) + } + + #[test] + fn flat_market_yields_neutral_zero() { + // All highs and lows equal -> denominator is identically zero, so the + // indicator emits its neutral fallback of 0. + let candles: Vec = (0..40).map(|i| c(11.0, 9.0, 10.0, i)).collect(); + let mut rei = TdRei::classic(); + let out = rei.batch(&candles); + for v in out.iter().skip(rei.warmup_period()).copied().flatten() { + assert_relative_eq!(v, 0.0, epsilon = 1e-12); + } + } + + #[test] + fn pure_uptrend_pegs_indicator_at_100() { + // Every bar makes strictly higher highs and lows. Both range-overlap + // conditions hold (current high > all previous lows; current low > all + // previous highs is false, but we need current low <= some prev + // high). For a slow steady uptrend cond2 still holds because + // current low < prev5/prev6 highs as long as the slope is moderate. + // With slope 1 and spread 2 (low to high), cond2 fails after ~3 bars. + // Use a smaller slope so cond2 holds throughout. + let candles: Vec = (0..40) + .map(|i| { + let m = 100.0 + f64::from(i) * 0.1; + c(m + 1.0, m - 1.0, m, i64::from(i)) + }) + .collect(); + let mut rei = TdRei::classic(); + let last = rei.batch(&candles).into_iter().flatten().last().unwrap(); + // Every numerator is positive (price moving up) and equals the + // denominator in magnitude (no sign flips), so REI saturates at 100. + assert_relative_eq!(last, 100.0, epsilon = 1e-9); + } + + #[test] + fn pure_downtrend_pegs_indicator_at_minus_100() { + let candles: Vec = (0..40) + .map(|i| { + let m = 100.0 - f64::from(i) * 0.1; + c(m + 1.0, m - 1.0, m, i64::from(i)) + }) + .collect(); + let mut rei = TdRei::classic(); + let last = rei.batch(&candles).into_iter().flatten().last().unwrap(); + assert_relative_eq!(last, -100.0, epsilon = 1e-9); + } + + #[test] + fn stays_in_minus_100_to_100() { + let candles: Vec = (0..200) + .map(|i| { + let m = 50.0 + (f64::from(i) * 0.2).sin() * 5.0; + c(m + 1.0, m - 1.0, m, i64::from(i)) + }) + .collect(); + let mut rei = TdRei::classic(); + for v in rei.batch(&candles).into_iter().flatten() { + assert!((-100.0..=100.0).contains(&v), "out of range: {v}"); + } + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..80) + .map(|i| { + let m = 100.0 + (f64::from(i) * 0.3).sin() * 5.0; + c(m + 1.0, m - 1.0, m, i64::from(i)) + }) + .collect(); + let mut a = TdRei::classic(); + let mut b = TdRei::classic(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn rejects_zero_period() { + assert!(matches!(TdRei::new(0), Err(Error::PeriodZero))); + } + + #[test] + fn reset_clears_state() { + let candles: Vec = (0..40) + .map(|i| { + let m = 100.0 + f64::from(i) * 0.1; + c(m + 1.0, m - 1.0, m, i64::from(i)) + }) + .collect(); + let mut rei = TdRei::classic(); + rei.batch(&candles); + assert!(rei.is_ready()); + rei.reset(); + assert!(!rei.is_ready()); + assert_eq!(rei.update(candles[0]), None); + assert_eq!(rei.value(), None); + } + + #[test] + fn accessors_and_metadata() { + let rei = TdRei::classic(); + assert_eq!(rei.period(), 5); + assert_eq!(rei.warmup_period(), 6 + 5); + assert_eq!(rei.name(), "TDREI"); + } +} diff --git a/crates/wickra-core/src/indicators/td_risk_level.rs b/crates/wickra-core/src/indicators/td_risk_level.rs new file mode 100644 index 00000000..daab9cf6 --- /dev/null +++ b/crates/wickra-core/src/indicators/td_risk_level.rs @@ -0,0 +1,316 @@ +#![allow(clippy::doc_markdown)] + +//! Tom DeMark TD Risk Level — protective-stop levels derived from setup +//! extremes. +//! +//! DeMark proposes a quantitative stop level for trades taken on the back +//! of a completed setup. The risk level is computed from the bar that +//! made the most-extreme price during the setup run and that bar's true +//! range: +//! +//! - **Buy risk** (the protective stop for a long position taken on a +//! completed buy setup) is `low_extreme_bar.low - true_range_extreme_bar`. +//! `low_extreme_bar` is the bar with the lowest low among the setup's +//! bars; `true_range_extreme_bar` is its true range +//! (`max(high - low, |high - prev_close|, |low - prev_close|)`). +//! - **Sell risk** (the protective stop for a short position taken on a +//! completed sell setup) is `high_extreme_bar.high + +//! true_range_extreme_bar`. +//! +//! The level is set the moment a setup completes and stays at that value +//! until the next setup in that direction completes. Either field is +//! `f64::NAN` until the first setup in that direction completes. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Output of [`TdRiskLevel`]: the latest buy- and sell-side protective +/// stop levels derived from the most-recently-completed setup in each +/// direction. Either field is `f64::NAN` until the first setup in that +/// direction completes. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct TdRiskLevelOutput { + /// Protective-stop level for a long position taken on a completed + /// buy setup. `NAN` until the first buy setup completes. + pub buy_risk: f64, + /// Protective-stop level for a short position taken on a completed + /// sell setup. `NAN` until the first sell setup completes. + pub sell_risk: f64, +} + +/// Track the bar making the running extreme of the current run, together +/// with its true range. +#[derive(Debug, Clone, Copy)] +struct ExtremeBar { + price: f64, + true_range: f64, +} + +/// TD Risk Level — setup-derived protective-stop levels. +#[derive(Debug, Clone)] +pub struct TdRiskLevel { + lookback: usize, + target: usize, + closes: VecDeque, + prev: Option, + buy_count: usize, + sell_count: usize, + /// Extreme (lowest low) bar of the active buy-setup run. + buy_extreme: Option, + /// Extreme (highest high) bar of the active sell-setup run. + sell_extreme: Option, + buy_risk: f64, + sell_risk: f64, + ready: bool, +} + +fn true_range(candle: Candle, prev: Option) -> f64 { + let hl = candle.high - candle.low; + if let Some(p) = prev { + let hc = (candle.high - p.close).abs(); + let lc = (candle.low - p.close).abs(); + hl.max(hc).max(lc) + } else { + hl + } +} + +impl TdRiskLevel { + /// Construct a TD Risk Level with explicit lookback and target. The + /// canonical DeMark configuration is `lookback = 4`, `target = 9`. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if either argument is zero. + pub fn new(lookback: usize, target: usize) -> Result { + if lookback == 0 || target == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + lookback, + target, + closes: VecDeque::with_capacity(lookback + 1), + prev: None, + buy_count: 0, + sell_count: 0, + buy_extreme: None, + sell_extreme: None, + buy_risk: f64::NAN, + sell_risk: f64::NAN, + ready: false, + }) + } + + /// DeMark's classic configuration: `lookback = 4`, `target = 9`. + pub fn classic() -> Self { + Self::new(4, 9).expect("classic TD Risk Level parameters are valid") + } + + /// Configured `(lookback, target)`. + pub const fn params(&self) -> (usize, usize) { + (self.lookback, self.target) + } +} + +impl Indicator for TdRiskLevel { + type Input = Candle; + type Output = TdRiskLevelOutput; + + fn update(&mut self, candle: Candle) -> Option { + let tr = true_range(candle, self.prev); + if self.closes.len() > self.lookback { + self.closes.pop_front(); + } + if self.closes.len() < self.lookback { + self.closes.push_back(candle.close); + self.prev = Some(candle); + return None; + } + let reference = *self.closes.front().expect("non-empty after the guard"); + self.closes.push_back(candle.close); + + if candle.close < reference { + // Buy setup run. + let new_extreme = ExtremeBar { + price: candle.low, + true_range: tr, + }; + self.buy_extreme = Some(match self.buy_extreme { + Some(e) if e.price <= candle.low => e, + _ => new_extreme, + }); + self.buy_count = (self.buy_count + 1).min(self.target); + self.sell_count = 0; + self.sell_extreme = None; + if self.buy_count == self.target { + let e = self.buy_extreme.expect("set above when buy_count > 0"); + self.buy_risk = e.price - e.true_range; + } + } else if candle.close > reference { + // Sell setup run. + let new_extreme = ExtremeBar { + price: candle.high, + true_range: tr, + }; + self.sell_extreme = Some(match self.sell_extreme { + Some(e) if e.price >= candle.high => e, + _ => new_extreme, + }); + self.sell_count = (self.sell_count + 1).min(self.target); + self.buy_count = 0; + self.buy_extreme = None; + if self.sell_count == self.target { + let e = self.sell_extreme.expect("set above when sell_count > 0"); + self.sell_risk = e.price + e.true_range; + } + } else { + self.buy_count = 0; + self.sell_count = 0; + self.buy_extreme = None; + self.sell_extreme = None; + } + + self.prev = Some(candle); + self.ready = true; + Some(TdRiskLevelOutput { + buy_risk: self.buy_risk, + sell_risk: self.sell_risk, + }) + } + + fn reset(&mut self) { + self.closes.clear(); + self.prev = None; + self.buy_count = 0; + self.sell_count = 0; + self.buy_extreme = None; + self.sell_extreme = None; + self.buy_risk = f64::NAN; + self.sell_risk = f64::NAN; + self.ready = false; + } + + fn warmup_period(&self) -> usize { + self.lookback + 1 + } + + fn is_ready(&self) -> bool { + self.ready + } + + fn name(&self) -> &'static str { + "TDRiskLevel" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn c(high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new_unchecked(close, high, low, close, 0.0, ts) + } + + #[test] + fn uptrend_sets_sell_risk_above_highest_high_of_setup() { + // Strictly rising closes -> sell setup completes at idx 12. + // The sell run starts at idx 4 (first bar that has close > + // close[i-4]). The highest high during the run is the bar at + // idx 12 (since the series is strictly increasing). + let candles: Vec = (1..=20) + .map(|i| { + c( + f64::from(i) + 0.5, + f64::from(i) - 0.5, + f64::from(i), + i64::from(i), + ) + }) + .collect(); + let mut td = TdRiskLevel::classic(); + let out = td.batch(&candles); + let after = out[12].expect("ready"); + assert!(after.buy_risk.is_nan()); + // High at idx 12 is 13.5; the true range there is 1.0 (1.0 vs + // |13.5-12|=1.5 vs |12.5-12|=0.5 -> max=1.5). So sell_risk = + // 13.5 + 1.5 = 15.0. + assert_relative_eq!(after.sell_risk, 15.0, epsilon = 1e-12); + } + + #[test] + fn flat_series_never_sets_levels() { + let candles: Vec = (0..30).map(|i| c(10.5, 9.5, 10.0, i64::from(i))).collect(); + let mut td = TdRiskLevel::classic(); + for v in td.batch(&candles).into_iter().flatten() { + assert!(v.buy_risk.is_nan()); + assert!(v.sell_risk.is_nan()); + } + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..80) + .map(|i| { + let m = 100.0 + (f64::from(i) * 0.3).sin() * 5.0; + c(m + 1.0, m - 1.0, m, i64::from(i)) + }) + .collect(); + let mut a = TdRiskLevel::classic(); + let mut b = TdRiskLevel::classic(); + let av = a.batch(&candles); + let bv: Vec<_> = candles.iter().map(|x| b.update(*x)).collect(); + assert_eq!(av.len(), bv.len()); + for (i, (x, y)) in av.iter().zip(bv.iter()).enumerate() { + assert_eq!(x.is_some(), y.is_some(), "row {i} option mismatch"); + if let (Some(a), Some(b)) = (x, y) { + assert_eq!(a.buy_risk.is_nan(), b.buy_risk.is_nan()); + assert_eq!(a.sell_risk.is_nan(), b.sell_risk.is_nan()); + if !a.buy_risk.is_nan() { + assert_relative_eq!(a.buy_risk, b.buy_risk, epsilon = 1e-12); + } + if !a.sell_risk.is_nan() { + assert_relative_eq!(a.sell_risk, b.sell_risk, epsilon = 1e-12); + } + } + } + } + + #[test] + fn rejects_invalid_params() { + assert!(matches!(TdRiskLevel::new(0, 9), Err(Error::PeriodZero))); + assert!(matches!(TdRiskLevel::new(4, 0), Err(Error::PeriodZero))); + } + + #[test] + fn reset_clears_state() { + let candles: Vec = (1..=20) + .map(|i| { + c( + f64::from(i) + 0.5, + f64::from(i) - 0.5, + f64::from(i), + i64::from(i), + ) + }) + .collect(); + let mut td = TdRiskLevel::classic(); + td.batch(&candles); + assert!(td.is_ready()); + td.reset(); + assert!(!td.is_ready()); + assert_eq!(td.update(candles[0]), None); + } + + #[test] + fn accessors_and_metadata() { + let td = TdRiskLevel::classic(); + assert_eq!(td.params(), (4, 9)); + assert_eq!(td.warmup_period(), 5); + assert_eq!(td.name(), "TDRiskLevel"); + } +} diff --git a/crates/wickra-core/src/indicators/td_sequential.rs b/crates/wickra-core/src/indicators/td_sequential.rs new file mode 100644 index 00000000..bedd9a0e --- /dev/null +++ b/crates/wickra-core/src/indicators/td_sequential.rs @@ -0,0 +1,415 @@ +#![allow(clippy::doc_markdown)] + +//! Tom DeMark TD Sequential (Setup + Countdown). +//! +//! TD Sequential is DeMark's flagship two-phase exhaustion pattern: +//! +//! 1. **Setup phase** — 9 consecutive bars whose close is less-than (buy +//! setup) or greater-than (sell setup) the close 4 bars earlier. The +//! setup *completes* on the 9th bar. +//! 2. **Countdown phase** — after a completed setup, count up to 13 bars +//! that satisfy the countdown comparison (buy countdown: `close <= low` +//! two bars earlier; sell countdown: `close >= high` two bars earlier). +//! Countdown bars do not need to be consecutive. +//! +//! A completed countdown (13) signals exhaustion in the direction of the +//! original setup and is the canonical DeMark reversal signal. +//! +//! Output struct `TdSequentialOutput`: +//! +//! - `setup`: signed setup count (positive for buy setup, negative for sell +//! setup, 0 when no streak is active; capped at ±9). +//! - `countdown`: signed countdown count (positive for buy countdown, negative +//! for sell countdown, 0 when no countdown is active; capped at ±13). +//! - `direction`: `+1.0` if a buy countdown is currently active, `-1.0` if a +//! sell countdown is active, `0.0` otherwise. The countdown direction is +//! set when the originating setup completes and stays valid until the +//! countdown finishes or is invalidated by an opposite-direction setup. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Direction of an active TD Sequential countdown phase. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Direction { + None, + Buy, + Sell, +} + +/// Output of [`TdSequential`]: setup count, countdown count, and active +/// countdown direction. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct TdSequentialOutput { + /// Signed setup count: +N for an active buy setup of length `N`, −N for + /// a sell setup of length `N`, 0 if neither streak is active. Capped at + /// ±9 (the canonical setup target). + pub setup: f64, + /// Signed countdown count: +N for an active buy countdown of length `N`, + /// −N for a sell countdown of length `N`, 0 if no countdown is active. + /// Capped at ±13. + pub countdown: f64, + /// Direction of the active countdown: `+1.0` for buy, `−1.0` for sell, + /// `0.0` if no countdown is currently active. + pub direction: f64, +} + +/// TD Sequential state machine: combined Setup (1-9) + Countdown (1-13). +#[derive(Debug, Clone)] +pub struct TdSequential { + // Rolling window of recent candles. We need up to 5 closes back (for the + // setup rule which compares close[i] vs close[i-4]) and the high/low from + // 2 bars ago (for the countdown rule). + candles: VecDeque, + setup_lookback: usize, + setup_target: usize, + countdown_lookback: usize, + countdown_target: usize, + buy_setup: usize, + sell_setup: usize, + buy_countdown: usize, + sell_countdown: usize, + countdown_dir: Direction, + ready: bool, +} + +impl TdSequential { + /// Construct a TD Sequential with explicit lookbacks and targets. The + /// canonical DeMark configuration is `setup_lookback = 4`, `setup_target = + /// 9`, `countdown_lookback = 2`, `countdown_target = 13`. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if any argument is zero. + pub fn new( + setup_lookback: usize, + setup_target: usize, + countdown_lookback: usize, + countdown_target: usize, + ) -> Result { + if setup_lookback == 0 + || setup_target == 0 + || countdown_lookback == 0 + || countdown_target == 0 + { + return Err(Error::PeriodZero); + } + // Need to keep enough candles for both rules: setup uses close[-N]; + // countdown uses high/low[-M]. Reserve `max(N, M) + 1` slots. + let cap = setup_lookback.max(countdown_lookback) + 1; + Ok(Self { + candles: VecDeque::with_capacity(cap), + setup_lookback, + setup_target, + countdown_lookback, + countdown_target, + buy_setup: 0, + sell_setup: 0, + buy_countdown: 0, + sell_countdown: 0, + countdown_dir: Direction::None, + ready: false, + }) + } + + /// DeMark's classic configuration: setup `lookback = 4, target = 9`, + /// countdown `lookback = 2, target = 13`. + pub fn classic() -> Self { + Self::new(4, 9, 2, 13).expect("classic TD Sequential parameters are valid") + } + + /// Configured `(setup_lookback, setup_target, countdown_lookback, + /// countdown_target)`. + pub const fn params(&self) -> (usize, usize, usize, usize) { + ( + self.setup_lookback, + self.setup_target, + self.countdown_lookback, + self.countdown_target, + ) + } +} + +impl Indicator for TdSequential { + type Input = Candle; + type Output = TdSequentialOutput; + + fn update(&mut self, candle: Candle) -> Option { + let cap = self.setup_lookback.max(self.countdown_lookback) + 1; + if self.candles.len() == cap { + self.candles.pop_front(); + } + // The required minimum history is `max(setup_lookback, + // countdown_lookback)` previous bars. Once we have that many, we can + // evaluate both rules. + let need = self.setup_lookback.max(self.countdown_lookback); + if self.candles.len() < need { + self.candles.push_back(candle); + return None; + } + + // --- Setup rule: compare to close[setup_lookback bars ago] --- + // After `need` candles are buffered, the candle at offset `need - L` + // from the front is the one `L` bars before the new candle (0-based + // count: `front()` is `need` bars ago). + let setup_ref_idx = need - self.setup_lookback; + let setup_ref_close = self.candles[setup_ref_idx].close; + + if candle.close < setup_ref_close { + self.buy_setup = (self.buy_setup + 1).min(self.setup_target); + self.sell_setup = 0; + } else if candle.close > setup_ref_close { + self.sell_setup = (self.sell_setup + 1).min(self.setup_target); + self.buy_setup = 0; + } else { + self.buy_setup = 0; + self.sell_setup = 0; + } + + // --- Countdown activation: when a setup completes, arm the countdown + // in the same direction; an opposite-direction setup invalidates any + // active countdown. + if self.buy_setup == self.setup_target { + if self.countdown_dir != Direction::Buy { + self.buy_countdown = 0; + self.sell_countdown = 0; + } + self.countdown_dir = Direction::Buy; + } else if self.sell_setup == self.setup_target { + if self.countdown_dir != Direction::Sell { + self.buy_countdown = 0; + self.sell_countdown = 0; + } + self.countdown_dir = Direction::Sell; + } + + // --- Countdown rule: compare close to high/low `countdown_lookback` + // bars ago. Only the active direction advances. Once a countdown + // reaches `countdown_target`, the strict `< countdown_target` guard + // keeps it pinned so the caller can detect the "13" signal on this + // bar and any subsequent bar until a new setup arms a fresh run. + let cd_ref_idx = need - self.countdown_lookback; + let cd_ref = &self.candles[cd_ref_idx]; + match self.countdown_dir { + Direction::Buy => { + if candle.close <= cd_ref.low && self.buy_countdown < self.countdown_target { + self.buy_countdown += 1; + } + } + Direction::Sell => { + if candle.close >= cd_ref.high && self.sell_countdown < self.countdown_target { + self.sell_countdown += 1; + } + } + Direction::None => {} + } + + self.candles.push_back(candle); + self.ready = true; + + let setup = if self.buy_setup > 0 { + self.buy_setup as f64 + } else if self.sell_setup > 0 { + -(self.sell_setup as f64) + } else { + 0.0 + }; + let (countdown, direction) = match self.countdown_dir { + Direction::Buy => (self.buy_countdown as f64, 1.0), + Direction::Sell => (-(self.sell_countdown as f64), -1.0), + Direction::None => (0.0, 0.0), + }; + + Some(TdSequentialOutput { + setup, + countdown, + direction, + }) + } + + fn reset(&mut self) { + self.candles.clear(); + self.buy_setup = 0; + self.sell_setup = 0; + self.buy_countdown = 0; + self.sell_countdown = 0; + self.countdown_dir = Direction::None; + self.ready = false; + } + + fn warmup_period(&self) -> usize { + self.setup_lookback.max(self.countdown_lookback) + 1 + } + + fn is_ready(&self) -> bool { + self.ready + } + + fn name(&self) -> &'static str { + "TDSequential" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn c(high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new_unchecked(close, high, low, close, 0.0, ts) + } + + #[test] + fn pure_uptrend_completes_sell_setup_then_progresses_countdown() { + // Strictly increasing closes -> sell setup increments every bar past + // warmup, reaching -9 by index 12 (warmup is 4 + 1). After that, + // every bar continues to make a higher close, so each subsequent bar + // also makes a higher close than the high 2 bars ago — the sell + // countdown increments on each bar after activation. + let candles: Vec = (1..=40) + .map(|i| { + c( + f64::from(i) + 0.5, + f64::from(i) - 0.5, + f64::from(i), + i64::from(i), + ) + }) + .collect(); + let mut td = TdSequential::classic(); + let out = td.batch(&candles); + + // Warmup: indices 0..3 yield None (need=4 prior closes). + for v in out.iter().take(4) { + assert!(v.is_none()); + } + // After index 12, setup reaches -9 (completed). From the next bar on, + // countdown begins to increment. + let at_12 = out[12].expect("setup ready"); + assert_eq!(at_12.setup, -9.0); + assert_eq!(at_12.direction, -1.0); // countdown direction armed + + // Each subsequent bar makes close > high[i-2], so the sell countdown + // advances by one per bar; by some later index it caps at -13. + let later = out[30].expect("ready"); + assert_eq!(later.direction, -1.0); + assert_eq!(later.countdown, -13.0); + } + + #[test] + fn pure_downtrend_completes_buy_setup_then_progresses_countdown() { + // Strictly decreasing closes -> buy setup increments every bar past + // warmup, reaching 9 by index 12. After activation, every subsequent + // bar satisfies close <= low[i-2], so the buy countdown advances by + // one per bar and pins at +13. + let candles: Vec = (1..=40) + .rev() + .enumerate() + .map(|(k, i)| { + c( + f64::from(i) + 0.5, + f64::from(i) - 0.5, + f64::from(i), + i64::try_from(k).unwrap(), + ) + }) + .collect(); + let mut td = TdSequential::classic(); + let out = td.batch(&candles); + + // Warmup: indices 0..3 yield None. + for v in out.iter().take(4) { + assert!(v.is_none()); + } + let at_12 = out[12].expect("setup ready"); + assert_eq!(at_12.setup, 9.0); + assert_eq!(at_12.direction, 1.0); // buy direction armed + + // By idx 30 the buy countdown has saturated at +13. + let later = out[30].expect("ready"); + assert_eq!(later.direction, 1.0); + assert_eq!(later.countdown, 13.0); + } + + #[test] + fn flat_series_emits_zero_setup_and_no_countdown() { + // All closes equal -> never completes any setup; countdown never + // activates; setup, countdown, direction all stay at 0. + let candles: Vec = (0..30).map(|i| c(10.5, 9.5, 10.0, i64::from(i))).collect(); + let mut td = TdSequential::classic(); + let out = td.batch(&candles); + for v in out.iter().skip(5) { + let o = v.expect("ready post-warmup"); + assert_eq!(o.setup, 0.0); + assert_eq!(o.countdown, 0.0); + assert_eq!(o.direction, 0.0); + } + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..60) + .map(|i| { + let m = 100.0 + (f64::from(i) * 0.3).sin() * 5.0; + c(m + 1.0, m - 1.0, m, i64::from(i)) + }) + .collect(); + let mut a = TdSequential::classic(); + let mut b = TdSequential::classic(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn rejects_invalid_params() { + assert!(matches!( + TdSequential::new(0, 9, 2, 13), + Err(Error::PeriodZero) + )); + assert!(matches!( + TdSequential::new(4, 0, 2, 13), + Err(Error::PeriodZero) + )); + assert!(matches!( + TdSequential::new(4, 9, 0, 13), + Err(Error::PeriodZero) + )); + assert!(matches!( + TdSequential::new(4, 9, 2, 0), + Err(Error::PeriodZero) + )); + } + + #[test] + fn reset_clears_state() { + let candles: Vec = (1..=20) + .map(|i| { + c( + f64::from(i) + 0.5, + f64::from(i) - 0.5, + f64::from(i), + i64::from(i), + ) + }) + .collect(); + let mut td = TdSequential::classic(); + td.batch(&candles); + assert!(td.is_ready()); + td.reset(); + assert!(!td.is_ready()); + assert_eq!(td.update(candles[0]), None); + } + + #[test] + fn accessors_and_metadata() { + let td = TdSequential::classic(); + assert_eq!(td.params(), (4, 9, 2, 13)); + assert_eq!(td.warmup_period(), 5); + assert_eq!(td.name(), "TDSequential"); + } +} diff --git a/crates/wickra-core/src/indicators/td_setup.rs b/crates/wickra-core/src/indicators/td_setup.rs new file mode 100644 index 00000000..3c5a3837 --- /dev/null +++ b/crates/wickra-core/src/indicators/td_setup.rs @@ -0,0 +1,262 @@ +#![allow(clippy::doc_markdown)] + +//! Tom DeMark TD Setup (9-bar buy / sell setup). +//! +//! The TD Setup is the first half of DeMark's TD Sequential. It counts how many +//! consecutive bars satisfy a fixed price-comparison rule relative to the close +//! `lookback` bars earlier (the canonical lookback is 4 — i.e. compare `close[i]` +//! to `close[i-4]`). +//! +//! - A **buy setup** advances by one for each bar whose close is *less than* the +//! close `lookback` bars earlier. The streak resets to zero as soon as the +//! condition fails. A "completed" buy setup is a streak of 9 (DeMark's +//! default `target`). +//! - A **sell setup** advances symmetrically when the close is *greater than* +//! the close `lookback` bars earlier. +//! +//! Only one direction can be active on a given bar: the same bar cannot satisfy +//! both `close < close[-4]` and `close > close[-4]`. If neither condition +//! holds (equality with the lookback close) both streaks reset. +//! +//! This indicator emits a signed count: positive values mean the buy-setup +//! streak is active, negative values mean the sell-setup streak is active, +//! and `0` means neither streak is active on the current bar. The magnitude is +//! the current run length, capped at `target` once the setup completes — the +//! caller can detect "perfected" setups by waiting for `value.abs() == +//! target`. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// TD Setup state machine: counts consecutive bars meeting DeMark's setup +/// comparison rule against the close `lookback` bars earlier. +#[derive(Debug, Clone)] +pub struct TdSetup { + lookback: usize, + target: usize, + closes: VecDeque, + buy_count: usize, + sell_count: usize, + last_value: Option, +} + +impl TdSetup { + /// Construct a TD Setup with an explicit lookback and target count. + /// + /// The classic DeMark configuration is `lookback = 4` and `target = 9`. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if either argument is zero. + pub fn new(lookback: usize, target: usize) -> Result { + if lookback == 0 || target == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + lookback, + target, + closes: VecDeque::with_capacity(lookback + 1), + buy_count: 0, + sell_count: 0, + last_value: None, + }) + } + + /// DeMark's classic configuration: `lookback = 4`, `target = 9`. + pub fn classic() -> Self { + Self::new(4, 9).expect("classic TD Setup parameters are valid") + } + + /// Configured `(lookback, target)`. + pub const fn params(&self) -> (usize, usize) { + (self.lookback, self.target) + } + + /// Current signed setup value if available. + pub const fn value(&self) -> Option { + self.last_value + } +} + +impl Indicator for TdSetup { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + // Maintain a rolling window of the last `lookback + 1` closes so the + // oldest entry (front) is exactly the close `lookback` bars ago. + if self.closes.len() > self.lookback { + self.closes.pop_front(); + } + if self.closes.len() < self.lookback { + self.closes.push_back(candle.close); + return None; + } + // We now have exactly `lookback` historical closes buffered; the oldest + // is the comparison reference. + let reference = *self.closes.front().expect("non-empty after the guard"); + self.closes.push_back(candle.close); + + if candle.close < reference { + self.buy_count = (self.buy_count + 1).min(self.target); + self.sell_count = 0; + let v = self.buy_count as f64; + self.last_value = Some(v); + Some(v) + } else if candle.close > reference { + self.sell_count = (self.sell_count + 1).min(self.target); + self.buy_count = 0; + let v = -(self.sell_count as f64); + self.last_value = Some(v); + Some(v) + } else { + // Equality breaks both streaks; the bar emits zero. + self.buy_count = 0; + self.sell_count = 0; + self.last_value = Some(0.0); + Some(0.0) + } + } + + fn reset(&mut self) { + self.closes.clear(); + self.buy_count = 0; + self.sell_count = 0; + self.last_value = None; + } + + fn warmup_period(&self) -> usize { + self.lookback + 1 + } + + fn is_ready(&self) -> bool { + self.last_value.is_some() + } + + fn name(&self) -> &'static str { + "TDSetup" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn c(close: f64, ts: i64) -> Candle { + Candle::new_unchecked(close, close, close, close, 0.0, ts) + } + + #[test] + fn pure_uptrend_reaches_sell_setup_9() { + // Every close is strictly greater than four bars ago, so the sell + // streak advances by one per bar from the moment lookback is filled. + let candles: Vec = (1..=20).map(|i| c(f64::from(i), i64::from(i))).collect(); + let mut setup = TdSetup::classic(); + let out = setup.batch(&candles); + // Indices 0..4 are warmup. Index 4 is the first bar with a reference. + // Sell-setup advances each bar: -1 at idx 4, -2 at idx 5, …, -9 at + // idx 12; from there it caps at -9 because target is 9. + for (i, v) in out.iter().enumerate().take(4) { + assert!(v.is_none(), "index {i} must be None during warmup"); + } + assert_eq!(out[4], Some(-1.0)); + assert_eq!(out[5], Some(-2.0)); + assert_eq!(out[12], Some(-9.0)); + assert_eq!(out[13], Some(-9.0)); + assert_eq!(out[19], Some(-9.0)); + } + + #[test] + fn pure_downtrend_reaches_buy_setup_9() { + let candles: Vec = (1..=20) + .rev() + .enumerate() + .map(|(i, v)| c(f64::from(v), i64::try_from(i).unwrap())) + .collect(); + let mut setup = TdSetup::classic(); + let out = setup.batch(&candles); + // Buy streak should mirror the sell case: +1 at idx 4, capping at +9. + assert_eq!(out[4], Some(1.0)); + assert_eq!(out[12], Some(9.0)); + assert_eq!(out[19], Some(9.0)); + } + + #[test] + fn flat_series_emits_zero_after_warmup() { + // Every close equals the reference close (lookback bars earlier), so + // neither streak ever advances; the indicator emits 0 every bar. + let candles: Vec = (0..20).map(|i| c(42.0, i)).collect(); + let mut setup = TdSetup::classic(); + let out = setup.batch(&candles); + for v in out.iter().skip(4) { + assert_eq!(*v, Some(0.0)); + } + } + + #[test] + fn streak_resets_on_direction_flip() { + // First 4 closes are warmup. Then 4 strictly-lower closes -> buy + // streak 1..=4. The next close is higher than its reference -> the + // buy streak resets and the sell streak starts at 1. + let candles = [ + c(10.0, 0), + c(10.0, 1), + c(10.0, 2), + c(10.0, 3), + c(9.0, 4), + c(8.0, 5), + c(7.0, 6), + c(6.0, 7), + c(11.0, 8), + ]; + let mut setup = TdSetup::classic(); + let out = setup.batch(&candles); + assert_eq!(out[4], Some(1.0)); + assert_eq!(out[7], Some(4.0)); + assert_eq!(out[8], Some(-1.0)); + } + + #[test] + fn rejects_zero_arguments() { + assert!(matches!(TdSetup::new(0, 9), Err(Error::PeriodZero))); + assert!(matches!(TdSetup::new(4, 0), Err(Error::PeriodZero))); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..80) + .map(|i| c(100.0 + (f64::from(i) * 0.3).sin() * 5.0, i64::from(i))) + .collect(); + let mut a = TdSetup::classic(); + let mut b = TdSetup::classic(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let candles: Vec = (1..=20).map(|i| c(f64::from(i), i64::from(i))).collect(); + let mut setup = TdSetup::classic(); + setup.batch(&candles); + assert!(setup.is_ready()); + setup.reset(); + assert!(!setup.is_ready()); + assert_eq!(setup.update(candles[0]), None); + assert_eq!(setup.value(), None); + } + + #[test] + fn accessors_and_metadata() { + let setup = TdSetup::new(4, 9).unwrap(); + assert_eq!(setup.params(), (4, 9)); + assert_eq!(setup.warmup_period(), 5); + assert_eq!(setup.name(), "TDSetup"); + assert_eq!(setup.value(), None); + } +} diff --git a/crates/wickra-core/src/lib.rs b/crates/wickra-core/src/lib.rs index 2a81e38e..421f30e3 100644 --- a/crates/wickra-core/src/lib.rs +++ b/crates/wickra-core/src/lib.rs @@ -63,13 +63,16 @@ pub use indicators::{ Psar, Pvi, RenkoTrailingStop, Roc, RogersSatchellVolatility, RollingVwap, Rsi, Rvi, RviVolatility, Rwi, RwiOutput, Sma, Smi, Smma, StandardErrorBands, StandardErrorBandsOutput, StarcBands, StarcBandsOutput, Stc, StdDev, StepTrailingStop, StochRsi, Stochastic, - StochasticOutput, SuperTrend, SuperTrendOutput, Tema, Tii, Trima, Trix, TrueRange, Tsi, Tsv, - TtmSqueeze, TtmSqueezeOutput, TypicalPrice, UlcerIndex, UltimateOscillator, - VerticalHorizontalFilter, Vidya, VoltyStop, VolumeOscillator, VolumePriceTrend, Vortex, - VortexOutput, Vwap, VwapStdDevBands, VwapStdDevBandsOutput, Vwma, Vzo, WaveTrend, - WaveTrendOutput, WeightedClose, WilliamsFractals, WilliamsFractalsOutput, WilliamsR, Wma, - WoodiePivots, WoodiePivotsOutput, YangZhangVolatility, YoyoExit, ZScore, ZeroLagMacd, - ZeroLagMacdOutput, ZigZag, ZigZagOutput, Zlema, T3, + StochasticOutput, 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, 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 47e4d489..632ec2f7 100644 --- a/crates/wickra/benches/indicators.rs +++ b/crates/wickra/benches/indicators.rs @@ -25,9 +25,11 @@ use wickra::{ HiLoActivator, HurstChannel, Indicator, Jma, Kst, Kvo, LinRegChannel, MaEnvelope, MacdIndicator, MarketFacilitationIndex, McGinleyDynamic, Nvi, Obv, ParkinsonVolatility, PercentageTrailingStop, Pgo, Pvi, RenkoTrailingStop, RogersSatchellVolatility, Rsi, Rvi, - RviVolatility, Rwi, Sma, StandardErrorBands, StarcBands, StepTrailingStop, Stochastic, Tii, - Tsv, TtmSqueeze, Vidya, VoltyStop, VolumeOscillator, VwapStdDevBands, Vzo, WaveTrend, - WilliamsFractals, Wma, WoodiePivots, YangZhangVolatility, YoyoExit, ZigZag, + RviVolatility, Rwi, Sma, StandardErrorBands, StarcBands, StepTrailingStop, Stochastic, TdCombo, + TdCountdown, TdDeMarker, TdDifferential, TdLines, TdOpen, TdPressure, TdRangeProjection, TdRei, + TdRiskLevel, TdSequential, TdSetup, Tii, Tsv, TtmSqueeze, Vidya, VoltyStop, VolumeOscillator, + VwapStdDevBands, Vzo, WaveTrend, WilliamsFractals, Wma, WoodiePivots, YangZhangVolatility, + YoyoExit, ZigZag, }; use wickra_data::csv::CandleReader; @@ -183,6 +185,20 @@ fn benches(c: &mut Criterion) { bench_candle_input(c, "stochastic", &candles, Stochastic::classic); bench_candle_input(c, "obv", &candles, Obv::new); + // --- Family 11: DeMark --- + bench_candle_input(c, "td_setup", &candles, TdSetup::classic); + bench_candle_input(c, "td_sequential", &candles, TdSequential::classic); + bench_candle_input(c, "td_demarker", &candles, || TdDeMarker::new(14).unwrap()); + bench_candle_input(c, "td_rei", &candles, TdRei::classic); + bench_candle_input(c, "td_pressure", &candles, || TdPressure::new(5).unwrap()); + bench_candle_input(c, "td_combo", &candles, TdCombo::classic); + bench_candle_input(c, "td_countdown", &candles, TdCountdown::classic); + bench_candle_input(c, "td_lines", &candles, TdLines::classic); + bench_candle_input(c, "td_risk_level", &candles, TdRiskLevel::classic); + bench_candle_input(c, "td_range_projection", &candles, TdRangeProjection::new); + bench_candle_input(c, "td_differential", &candles, TdDifferential::new); + bench_candle_input(c, "td_open", &candles, TdOpen::new); + // --- Family 08: Pivots & Support/Resistance --- bench_candle_input(c, "classic_pivots", &candles, ClassicPivots::new); bench_candle_input(c, "fibonacci_pivots", &candles, FibonacciPivots::new); diff --git a/fuzz/fuzz_targets/indicator_update_candle.rs b/fuzz/fuzz_targets/indicator_update_candle.rs index 70ace913..24a4973e 100644 --- a/fuzz/fuzz_targets/indicator_update_candle.rs +++ b/fuzz/fuzz_targets/indicator_update_candle.rs @@ -31,10 +31,11 @@ use wickra_core::{ FibonacciPivots, ForceIndex, FractalChaosBands, GarmanKlassVolatility, HiLoActivator, HurstChannel, Indicator, Inertia, Keltner, Kvo, MarketFacilitationIndex, MassIndex, MedianPrice, Mfi, Natr, Nvi, Obv, ParkinsonVolatility, Pgo, Psar, Pvi, RogersSatchellVolatility, RollingVwap, - Rvi, Rwi, Smi, StarcBands, Stochastic, SuperTrend, TrueRange, Tsv, TtmSqueeze, TypicalPrice, - UltimateOscillator, VoltyStop, VolumeOscillator, VolumePriceTrend, Vortex, Vwap, VwapStdDevBands, - Vwma, Vzo, WaveTrend, WeightedClose, WilliamsFractals, WilliamsR, WoodiePivots, - YangZhangVolatility, YoyoExit, ZigZag, + Rvi, Rwi, Smi, StarcBands, Stochastic, SuperTrend, TdCombo, TdCountdown, TdDeMarker, + TdDifferential, TdLines, TdOpen, TdPressure, TdRangeProjection, TdRei, TdRiskLevel, + TdSequential, TdSetup, TrueRange, Tsv, TtmSqueeze, TypicalPrice, UltimateOscillator, VoltyStop, + VolumeOscillator, VolumePriceTrend, Vortex, Vwap, VwapStdDevBands, Vwma, Vzo, WaveTrend, + WeightedClose, WilliamsFractals, WilliamsR, WoodiePivots, YangZhangVolatility, YoyoExit, ZigZag, }; /// Convert a flat `f64` stream into a `Vec` by chunking it into @@ -164,6 +165,38 @@ fuzz_target!(|data: Vec| { let _ = Stochastic::new(14, 3).unwrap().batch(&candles); } + // --- DeMark family --- + drive(|| TdSetup::new(4, 9).unwrap(), &candles); + drive(|| TdDeMarker::new(14).unwrap(), &candles); + drive(|| TdRei::new(5).unwrap(), &candles); + drive(|| TdPressure::new(5).unwrap(), &candles); + drive(|| TdCombo::new(4, 9, 2, 13).unwrap(), &candles); + drive(|| TdCountdown::new(4, 9, 2, 13).unwrap(), &candles); + drive(TdDifferential::new, &candles); + drive(TdOpen::new, &candles); + drive(TdRangeProjection::new, &candles); + { + let mut s = TdSequential::new(4, 9, 2, 13).unwrap(); + for c in &candles { + let _ = s.update(*c); + } + let _ = TdSequential::new(4, 9, 2, 13).unwrap().batch(&candles); + } + { + let mut s = TdLines::new(4, 9).unwrap(); + for c in &candles { + let _ = s.update(*c); + } + let _ = TdLines::new(4, 9).unwrap().batch(&candles); + } + { + let mut s = TdRiskLevel::new(4, 9).unwrap(); + for c in &candles { + let _ = s.update(*c); + } + let _ = TdRiskLevel::new(4, 9).unwrap().batch(&candles); + } + // --- Pivots & Support/Resistance (multi-output) --- drive(ClassicPivots::new, &candles); drive(FibonacciPivots::new, &candles);