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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user