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<f64>` -> `Vec<Candle>` 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: close<open, close>open, 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).
This commit is contained in:
kingchenc
2026-05-25 20:36:36 +02:00
committed by GitHub
parent 7e1e988596
commit 4f9ed34884
26 changed files with 6130 additions and 17 deletions
+24
View File
@@ -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;
@@ -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<Candle>,
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<Self> {
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<f64> {
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<Candle> = (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<Candle> = (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<Candle> = (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<Candle> = (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::<Vec<_>>()
);
}
#[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<Candle> = (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");
}
}
@@ -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<Candle>,
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<Self> {
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<f64> {
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<Candle> = (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<Candle> = (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<Candle> = (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<Candle> = (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::<Vec<_>>()
);
}
#[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<Candle> = (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");
}
}
@@ -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<Candle>,
demax: VecDeque<f64>,
demin: VecDeque<f64>,
last_value: Option<f64>,
}
impl TdDeMarker {
/// Construct a TD DeMarker with the given window length.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
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<f64> {
self.last_value
}
}
impl Indicator for TdDeMarker {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
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::<f64>() / n;
let sum_min: f64 = self.demin.iter().sum::<f64>() / 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<Candle> = (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<Candle> = (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<Candle> = (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<Candle> = (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<Candle> = (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::<Vec<_>>()
);
}
#[test]
fn rejects_zero_period() {
assert!(matches!(TdDeMarker::new(0), Err(Error::PeriodZero)));
}
#[test]
fn reset_clears_state() {
let candles: Vec<Candle> = (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");
}
}
@@ -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<Candle>,
last_value: Option<f64>,
}
impl TdDifferential {
/// Construct a new `TdDifferential`.
pub fn new() -> Self {
Self::default()
}
/// Latest emitted signal if available.
pub const fn value(&self) -> Option<f64> {
self.last_value
}
}
impl Indicator for TdDifferential {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
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<prev.close (8.5<9),
// buying=1.5 > 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<Candle> = (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::<Vec<_>>()
);
}
#[test]
fn output_only_in_canonical_set() {
// Every emitted value is in {-1, 0, +1}.
let candles: Vec<Candle> = (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);
}
}
@@ -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<f64>,
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<Self> {
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<TdLinesOutput> {
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<Candle> = (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<Candle> = (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<Candle> = (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<Candle> = (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<Candle> = (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");
}
}
@@ -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<Candle>,
last_value: Option<f64>,
}
impl TdOpen {
/// Construct a new `TdOpen`.
pub fn new() -> Self {
Self::default()
}
/// Latest emitted signal if available.
pub const fn value(&self) -> Option<f64> {
self.last_value
}
}
impl Indicator for TdOpen {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
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<Candle> = (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::<Vec<_>>()
);
}
#[test]
fn output_only_in_canonical_set() {
let candles: Vec<Candle> = (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);
}
}
@@ -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<f64>,
volumes: VecDeque<f64>,
last_value: Option<f64>,
}
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<Self> {
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<f64> {
self.last_value
}
}
impl Indicator for TdPressure {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
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::<f64>() / n;
let mean_v: f64 = self.volumes.iter().sum::<f64>() / 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<Candle> = (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<Candle> = (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<Candle> = (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<Candle> = (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<Candle> = (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::<Vec<_>>()
);
}
#[test]
fn rejects_zero_period() {
assert!(matches!(TdPressure::new(0), Err(Error::PeriodZero)));
}
#[test]
fn reset_clears_state() {
let candles: Vec<Candle> = (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");
}
}
@@ -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<TdRangeProjectionOutput>,
}
impl TdRangeProjection {
/// Construct a new `TdRangeProjection`.
pub fn new() -> Self {
Self::default()
}
/// Latest projection if available.
pub const fn value(&self) -> Option<TdRangeProjectionOutput> {
self.last_value
}
}
impl Indicator for TdRangeProjection {
type Input = Candle;
type Output = TdRangeProjectionOutput;
fn update(&mut self, candle: Candle) -> Option<TdRangeProjectionOutput> {
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<Candle> = (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::<Vec<_>>()
);
}
#[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);
}
}
+286
View File
@@ -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<Candle>,
numerators: VecDeque<f64>,
denominators: VecDeque<f64>,
last_value: Option<f64>,
}
/// 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<Self> {
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<f64> {
self.last_value
}
}
impl Indicator for TdRei {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
// 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<Candle> = (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<Candle> = (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<Candle> = (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<Candle> = (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<Candle> = (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::<Vec<_>>()
);
}
#[test]
fn rejects_zero_period() {
assert!(matches!(TdRei::new(0), Err(Error::PeriodZero)));
}
#[test]
fn reset_clears_state() {
let candles: Vec<Candle> = (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");
}
}
@@ -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<f64>,
prev: Option<Candle>,
buy_count: usize,
sell_count: usize,
/// Extreme (lowest low) bar of the active buy-setup run.
buy_extreme: Option<ExtremeBar>,
/// Extreme (highest high) bar of the active sell-setup run.
sell_extreme: Option<ExtremeBar>,
buy_risk: f64,
sell_risk: f64,
ready: bool,
}
fn true_range(candle: Candle, prev: Option<Candle>) -> 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<Self> {
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<TdRiskLevelOutput> {
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<Candle> = (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<Candle> = (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<Candle> = (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<Candle> = (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");
}
}
@@ -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<Candle>,
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<Self> {
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<TdSequentialOutput> {
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<Candle> = (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<Candle> = (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<Candle> = (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<Candle> = (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::<Vec<_>>()
);
}
#[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<Candle> = (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");
}
}
@@ -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<f64>,
buy_count: usize,
sell_count: usize,
last_value: Option<f64>,
}
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<Self> {
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<f64> {
self.last_value
}
}
impl Indicator for TdSetup {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
// 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<Candle> = (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<Candle> = (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<Candle> = (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<Candle> = (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::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let candles: Vec<Candle> = (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);
}
}