feat: add DeMark deepening (B12, 7 indicators) (#204)

B12 of the family-deepening roadmap — seven Tom DeMark indicators (467 -> 474).

**Candle -> +1/0 qualifier patterns (candlestick macro bindings):**
- **TD Camouflage** — hidden intrabar strength/weakness against the prior close.
- **TD Clop** — two-bar open/close engulfing reversal.
- **TD Clopwin** — the inside-body cousin of TD Clop (compression bar).
- **TD Propulsion** — continuation thrust closing beyond the prior extreme.
- **TD Trap** — inside ("trap") bar followed by a range breakout.

**Hand-bound:**
- **TD D-Wave** — streaming Elliott-style 1-5 / A-C swing-wave counter (candle -> f64, `strength` param).
- **TD Moving Averages** — ST1/ST2 median-price trend ribbon (candle -> struct {st1, st2}).

All seven join the existing **DeMark** family. Patterns follow the house-style
+1/0 candle-pattern convention (neutral 0.0 during warmup). Public binding names
use the family-consistent `TD...` casing.

Wiring complete across core, Python, Node, WASM, fuzz, tests, README + docs
counter (474) and CHANGELOG. Verified: core 3874 + doc 427, clippy clean,
node 549, python 903.
This commit is contained in:
kingchenc
2026-06-08 01:12:46 +02:00
committed by GitHub
parent ed01604a18
commit 8431b1400c
21 changed files with 1899 additions and 15 deletions
+22 -1
View File
@@ -379,18 +379,25 @@ mod t3;
mod taker_buy_sell_ratio;
mod takuri;
mod tasuki_gap;
mod td_camouflage;
mod td_clop;
mod td_clopwin;
mod td_combo;
mod td_countdown;
mod td_demarker;
mod td_differential;
mod td_dwave;
mod td_lines;
mod td_moving_average;
mod td_open;
mod td_pressure;
mod td_propulsion;
mod td_range_projection;
mod td_rei;
mod td_risk_level;
mod td_sequential;
mod td_setup;
mod td_trap;
mod tema;
mod term_structure_basis;
mod three_drives;
@@ -846,18 +853,25 @@ pub use t3::T3;
pub use taker_buy_sell_ratio::TakerBuySellRatio;
pub use takuri::Takuri;
pub use tasuki_gap::TasukiGap;
pub use td_camouflage::TdCamouflage;
pub use td_clop::TdClop;
pub use td_clopwin::TdClopwin;
pub use td_combo::TdCombo;
pub use td_countdown::TdCountdown;
pub use td_demarker::TdDeMarker;
pub use td_differential::TdDifferential;
pub use td_dwave::TdDWave;
pub use td_lines::{TdLines, TdLinesOutput};
pub use td_moving_average::{TdMovingAverage, TdMovingAverageOutput};
pub use td_open::TdOpen;
pub use td_pressure::TdPressure;
pub use td_propulsion::TdPropulsion;
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 td_trap::TdTrap;
pub use tema::Tema;
pub use term_structure_basis::TermStructureBasis;
pub use three_drives::ThreeDrives;
@@ -1304,6 +1318,13 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
"TdDifferential",
"TdOpen",
"TdRiskLevel",
"TdCamouflage",
"TdClop",
"TdClopwin",
"TdPropulsion",
"TdTrap",
"TdDWave",
"TdMovingAverage",
],
),
("Ichimoku & Charts", &["Ichimoku", "HeikinAshi"]),
@@ -1555,6 +1576,6 @@ mod family_tests {
// the actual indicator count is the early-warning signal that an
// indicator was added without being assigned a family.
let total: usize = FAMILIES.iter().map(|(_, ns)| ns.len()).sum();
assert_eq!(total, 467, "FAMILIES total drifted from indicator count");
assert_eq!(total, 474, "FAMILIES total drifted from indicator count");
}
}
@@ -0,0 +1,156 @@
#![allow(clippy::doc_markdown)]
//! Tom DeMark TD Camouflage — a hidden-strength/weakness 1-bar reversal pattern.
//!
//! TD Camouflage spots a bar that *looks* weak (or strong) on its close-to-close
//! comparison but reveals the opposite intrabar, "camouflaging" a reversal.
//!
//! - **Buy signal** (`+1.0`): `close < close[-1]` (a lower close, looks bearish),
//! yet `close > open` (it actually closed up on the bar) and `low < low[-1]`
//! (it dipped to a new low and was bought back) — hidden accumulation.
//! - **Sell signal** (`-1.0`): `close > close[-1]`, `close < open`, and
//! `high > high[-1]` — hidden distribution.
//! - Otherwise the output is `0.0`.
//!
//! The one-bar lookback means the first value lands on the second candle.
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// TD Camouflage — 1-bar hidden-strength/weakness reversal detector.
#[derive(Debug, Clone, Default)]
pub struct TdCamouflage {
prev: Option<Candle>,
last_value: Option<f64>,
}
impl TdCamouflage {
/// Construct a new `TdCamouflage`.
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// Latest emitted signal if available.
pub const fn value(&self) -> Option<f64> {
self.last_value
}
}
impl Indicator for TdCamouflage {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
let Some(prev) = self.prev else {
self.prev = Some(candle);
self.last_value = Some(0.0);
return Some(0.0);
};
let v = if candle.close < prev.close && candle.close > candle.open && candle.low < prev.low
{
1.0
} else if candle.close > prev.close && candle.close < candle.open && candle.high > 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 {
"TDCamouflage"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
fn c(open: f64, high: f64, low: f64, close: f64) -> Candle {
Candle::new_unchecked(open, high, low, close, 0.0, 0)
}
#[test]
fn accessors_and_metadata() {
let td = TdCamouflage::new();
assert_eq!(td.warmup_period(), 2);
assert_eq!(td.name(), "TDCamouflage");
assert!(!td.is_ready());
assert_eq!(td.value(), None);
}
#[test]
fn first_bar_seeds_without_signal() {
let mut td = TdCamouflage::new();
assert_eq!(td.update(c(10.0, 11.0, 9.0, 10.0)), Some(0.0));
assert!(td.update(c(10.0, 11.0, 8.0, 9.5)).is_some());
}
#[test]
fn bullish_camouflage_buy() {
// prev close 10. Current: close 9.5 < 10 (lower close), close 9.5 > open 9.0,
// low 7.0 < prev low 8.0 -> buy.
let mut td = TdCamouflage::new();
td.update(c(10.0, 11.0, 8.0, 10.0));
assert_eq!(td.update(c(9.0, 10.0, 7.0, 9.5)), Some(1.0));
}
#[test]
fn bearish_camouflage_sell() {
// prev close 10. Current: close 10.5 > 10, close 10.5 < open 11.0,
// high 12.0 > prev high 11.0 -> sell.
let mut td = TdCamouflage::new();
td.update(c(10.0, 11.0, 8.0, 10.0));
assert_eq!(td.update(c(11.0, 12.0, 10.0, 10.5)), Some(-1.0));
}
#[test]
fn no_pattern_is_zero() {
let mut td = TdCamouflage::new();
td.update(c(10.0, 11.0, 9.0, 10.0));
assert_eq!(td.update(c(10.0, 11.5, 9.5, 11.0)), Some(0.0));
}
#[test]
fn reset_clears_state() {
let mut td = TdCamouflage::new();
td.update(c(10.0, 11.0, 9.0, 10.0));
td.update(c(9.0, 10.0, 7.0, 9.5));
assert!(td.is_ready());
td.reset();
assert!(!td.is_ready());
assert_eq!(td.update(c(10.0, 11.0, 9.0, 10.0)), Some(0.0));
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..40)
.map(|i| {
let b = 100.0 + (f64::from(i) * 0.4).sin() * 5.0;
c(b, b + 1.0, b - 1.0, b + 0.2)
})
.collect();
let batch = TdCamouflage::new().batch(&candles);
let mut b = TdCamouflage::new();
let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,159 @@
#![allow(clippy::doc_markdown)]
//! Tom DeMark TD Clop — a 2-bar open/close engulfing reversal.
//!
//! TD Clop ("CLose/OPen") fires when the current bar's open opens beyond **both**
//! the prior bar's open and close, and its close finishes back beyond both — an
//! open-gap that fully reverses, signalling a turn.
//!
//! - **Buy signal** (`+1.0`): `open < open[-1]` AND `open < close[-1]`
//! (opens below the whole prior body) AND `close > open[-1]` AND
//! `close > close[-1]` (closes above it).
//! - **Sell signal** (`-1.0`): `open > open[-1]` AND `open > close[-1]` AND
//! `close < open[-1]` AND `close < close[-1]`.
//! - Otherwise the output is `0.0`.
//!
//! The one-bar lookback means the first value lands on the second candle.
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// TD Clop — 2-bar open/close engulfing reversal detector.
#[derive(Debug, Clone, Default)]
pub struct TdClop {
prev: Option<Candle>,
last_value: Option<f64>,
}
impl TdClop {
/// Construct a new `TdClop`.
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// Latest emitted signal if available.
pub const fn value(&self) -> Option<f64> {
self.last_value
}
}
impl Indicator for TdClop {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
let Some(prev) = self.prev else {
self.prev = Some(candle);
self.last_value = Some(0.0);
return Some(0.0);
};
let below_body = candle.open < prev.open && candle.open < prev.close;
let above_body = candle.close > prev.open && candle.close > prev.close;
let over_body = candle.open > prev.open && candle.open > prev.close;
let under_body = candle.close < prev.open && candle.close < prev.close;
let v = if below_body && above_body {
1.0
} else if over_body && under_body {
-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 {
"TDClop"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
fn c(open: f64, close: f64) -> Candle {
let high = open.max(close) + 1.0;
let low = open.min(close) - 1.0;
Candle::new_unchecked(open, high, low, close, 0.0, 0)
}
#[test]
fn accessors_and_metadata() {
let td = TdClop::new();
assert_eq!(td.warmup_period(), 2);
assert_eq!(td.name(), "TDClop");
assert!(!td.is_ready());
assert_eq!(td.value(), None);
}
#[test]
fn first_bar_seeds_without_signal() {
let mut td = TdClop::new();
assert_eq!(td.update(c(10.0, 11.0)), Some(0.0));
assert!(td.update(c(9.0, 12.0)).is_some());
}
#[test]
fn bullish_clop_buy() {
// prev body [10, 11]. Current open 9 < both, close 12 > both -> buy.
let mut td = TdClop::new();
td.update(c(10.0, 11.0));
assert_eq!(td.update(c(9.0, 12.0)), Some(1.0));
}
#[test]
fn bearish_clop_sell() {
// prev body [10, 11]. Current open 12 > both, close 9 < both -> sell.
let mut td = TdClop::new();
td.update(c(10.0, 11.0));
assert_eq!(td.update(c(12.0, 9.0)), Some(-1.0));
}
#[test]
fn no_pattern_is_zero() {
let mut td = TdClop::new();
td.update(c(10.0, 11.0));
assert_eq!(td.update(c(10.5, 11.5)), Some(0.0));
}
#[test]
fn reset_clears_state() {
let mut td = TdClop::new();
td.update(c(10.0, 11.0));
td.update(c(9.0, 12.0));
assert!(td.is_ready());
td.reset();
assert!(!td.is_ready());
assert_eq!(td.update(c(10.0, 11.0)), Some(0.0));
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..40)
.map(|i| {
let b = 100.0 + (f64::from(i) * 0.4).sin() * 5.0;
c(b, b + 0.5)
})
.collect();
let batch = TdClop::new().batch(&candles);
let mut b = TdClop::new();
let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,162 @@
#![allow(clippy::doc_markdown)]
//! Tom DeMark TD Clopwin — a 2-bar "close/open within" inside-body pattern.
//!
//! TD Clopwin ("CLose/OPen WInthIN") is the inside-body cousin of TD Clop: the
//! current bar's open **and** close both sit within the prior bar's real body,
//! marking a compression bar whose direction hints at the next move.
//!
//! - **Buy signal** (`+1.0`): current `open` and `close` are both inside the prior
//! bar's body `[min(open,close)[-1], max(open,close)[-1]]` AND `close >= open`
//! (a bullish inside bar).
//! - **Sell signal** (`-1.0`): both inside the prior body AND `close < open`
//! (a bearish inside bar).
//! - Otherwise the output is `0.0`.
//!
//! The one-bar lookback means the first value lands on the second candle.
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// TD Clopwin — 2-bar inside-body compression pattern detector.
#[derive(Debug, Clone, Default)]
pub struct TdClopwin {
prev: Option<Candle>,
last_value: Option<f64>,
}
impl TdClopwin {
/// Construct a new `TdClopwin`.
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// Latest emitted signal if available.
pub const fn value(&self) -> Option<f64> {
self.last_value
}
}
impl Indicator for TdClopwin {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
let Some(prev) = self.prev else {
self.prev = Some(candle);
self.last_value = Some(0.0);
return Some(0.0);
};
let body_low = prev.open.min(prev.close);
let body_high = prev.open.max(prev.close);
let open_in = candle.open >= body_low && candle.open <= body_high;
let close_in = candle.close >= body_low && candle.close <= body_high;
let v = if open_in && close_in {
if candle.close >= candle.open {
1.0
} else {
-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 {
"TDClopwin"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
fn c(open: f64, close: f64) -> Candle {
let high = open.max(close) + 1.0;
let low = open.min(close) - 1.0;
Candle::new_unchecked(open, high, low, close, 0.0, 0)
}
#[test]
fn accessors_and_metadata() {
let td = TdClopwin::new();
assert_eq!(td.warmup_period(), 2);
assert_eq!(td.name(), "TDClopwin");
assert!(!td.is_ready());
assert_eq!(td.value(), None);
}
#[test]
fn first_bar_seeds_without_signal() {
let mut td = TdClopwin::new();
assert_eq!(td.update(c(10.0, 14.0)), Some(0.0));
assert!(td.update(c(11.0, 13.0)).is_some());
}
#[test]
fn bullish_inside_body_buy() {
// prev body [10, 14]. Current open 11, close 13 both inside, close>open -> +1.
let mut td = TdClopwin::new();
td.update(c(10.0, 14.0));
assert_eq!(td.update(c(11.0, 13.0)), Some(1.0));
}
#[test]
fn bearish_inside_body_sell() {
// prev body [10, 14]. Current open 13, close 11 inside, close<open -> -1.
let mut td = TdClopwin::new();
td.update(c(10.0, 14.0));
assert_eq!(td.update(c(13.0, 11.0)), Some(-1.0));
}
#[test]
fn outside_body_is_zero() {
let mut td = TdClopwin::new();
td.update(c(10.0, 14.0));
// close 16 outside the prior body -> 0.
assert_eq!(td.update(c(11.0, 16.0)), Some(0.0));
}
#[test]
fn reset_clears_state() {
let mut td = TdClopwin::new();
td.update(c(10.0, 14.0));
td.update(c(11.0, 13.0));
assert!(td.is_ready());
td.reset();
assert!(!td.is_ready());
assert_eq!(td.update(c(10.0, 14.0)), Some(0.0));
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..40)
.map(|i| {
let b = 100.0 + (f64::from(i) * 0.4).sin() * 5.0;
c(b, b + 0.3)
})
.collect();
let batch = TdClopwin::new().batch(&candles);
let mut b = TdClopwin::new();
let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,295 @@
#![allow(clippy::doc_markdown)]
//! Tom DeMark TD D-Wave — a simplified Elliott-style swing-wave counter.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Tom DeMark **TD D-Wave** — a streaming wave counter that labels the market's
/// swing sequence with an Elliott-style `15` impulse / `AC` correction count.
///
/// TD D-Wave is DeMark's objective alternative to discretionary Elliott Wave
/// counting. This streaming implementation detects alternating swing pivots with a
/// symmetric fractal of half-width `strength`, and advances a counter through the
/// eight-leg cycle each time a new swing leg is confirmed:
///
/// ```text
/// legs: 1 → 2 → 3 → 4 → 5 → A(6) → B(7) → C(8) → 1 …
/// output = current wave number, 1.0..8.0 (6/7/8 = corrective A/B/C)
/// ```
///
/// The number tells you which wave of the cycle price is currently working on — a
/// running map of impulse versus correction that updates as each swing confirms.
/// This is a **simplified** swing-leg count (it does not enforce Elliott's price
/// ratio and overlap rules); treat it as a structural guide, not a strict wave
/// label.
///
/// Readiness is data-dependent: the first value appears once the first swing pivot
/// confirms (`strength` bars after it forms). `warmup_period` returns the minimum
/// bars to confirm one pivot. Each `update` is O(`strength`).
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, TdDWave};
///
/// let mut indicator = TdDWave::new(2).unwrap();
/// let mut last = None;
/// for i in 0..120 {
/// let base = 100.0 + (f64::from(i) * 0.5).sin() * 10.0;
/// let c = Candle::new(base, base + 1.0, base - 1.0, base, 1_000.0, 0).unwrap();
/// last = indicator.update(c);
/// }
/// let _ = last;
/// ```
#[derive(Debug, Clone)]
pub struct TdDWave {
strength: usize,
window: VecDeque<Candle>,
last_is_high: Option<bool>,
last_extreme: f64,
wave: usize,
last_value: Option<f64>,
}
impl TdDWave {
/// Construct a TD D-Wave with the given fractal `strength`.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `strength == 0`.
pub fn new(strength: usize) -> Result<Self> {
if strength == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
strength,
window: VecDeque::with_capacity(2 * strength + 1),
last_is_high: None,
last_extreme: 0.0,
wave: 0,
last_value: None,
})
}
/// Configured fractal strength.
pub const fn strength(&self) -> usize {
self.strength
}
/// Current wave number if available.
pub const fn value(&self) -> Option<f64> {
self.last_value
}
fn advance(&mut self, is_high: bool, price: f64) {
match self.last_is_high {
Some(prev) if prev == is_high => {
// Same-direction extreme: extend the current leg if more extreme.
let extends = if is_high {
price > self.last_extreme
} else {
price < self.last_extreme
};
if extends {
self.last_extreme = price;
}
}
_ => {
// A new alternating leg: advance the wave counter (1..8 cycle).
self.wave = self.wave % 8 + 1;
self.last_is_high = Some(is_high);
self.last_extreme = price;
self.last_value = Some(self.wave as f64);
}
}
}
}
impl Indicator for TdDWave {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
let span = 2 * self.strength + 1;
if self.window.len() == span {
self.window.pop_front();
}
self.window.push_back(candle);
if self.window.len() == span {
let center = self.window[self.strength];
let is_high = self
.window
.iter()
.enumerate()
.all(|(i, c)| i == self.strength || c.high < center.high);
let is_low = self
.window
.iter()
.enumerate()
.all(|(i, c)| i == self.strength || c.low > center.low);
if is_high && !is_low {
self.advance(true, center.high);
} else if is_low && !is_high {
self.advance(false, center.low);
}
}
self.last_value
}
fn reset(&mut self) {
self.window.clear();
self.last_is_high = None;
self.last_extreme = 0.0;
self.wave = 0;
self.last_value = None;
}
fn warmup_period(&self) -> usize {
2 * self.strength + 1
}
fn is_ready(&self) -> bool {
self.last_value.is_some()
}
fn name(&self) -> &'static str {
"TDDWave"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
fn c(high: f64, low: f64) -> Candle {
Candle::new_unchecked(
f64::midpoint(high, low),
high,
low,
f64::midpoint(high, low),
1_000.0,
0,
)
}
fn zigzag() -> Vec<Candle> {
(0..200)
.map(|i| {
let base = 100.0 + (f64::from(i) * 0.5).sin() * 10.0;
c(base + 1.0, base - 1.0)
})
.collect()
}
#[test]
fn rejects_zero_strength() {
assert!(matches!(TdDWave::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_and_metadata() {
let td = TdDWave::new(2).unwrap();
assert_eq!(td.strength(), 2);
assert_eq!(td.warmup_period(), 5);
assert_eq!(td.name(), "TDDWave");
assert!(!td.is_ready());
assert_eq!(td.value(), None);
}
#[test]
fn counts_waves_on_swings() {
let mut td = TdDWave::new(2).unwrap();
let out = td.batch(&zigzag());
assert!(out.iter().any(Option::is_some));
assert!(td.is_ready());
}
#[test]
fn same_direction_pivots_extend_one_leg() {
// Strictly decreasing lows mean no bar is ever a low pivot, so the
// confirmed pivots are all highs. Consecutive same-direction highs
// exercise the `extends` branch (true at 30 > 20, false at 25 < 30)
// without ever advancing the wave past leg 1.
let mut td = TdDWave::new(1).unwrap();
let bars = [
(10.0, 100.0),
(20.0, 99.0),
(12.0, 98.0),
(30.0, 97.0),
(15.0, 96.0),
(25.0, 95.0),
(14.0, 94.0),
(14.0, 93.0),
];
let vals: Vec<f64> = bars
.iter()
.filter_map(|&(high, low)| td.update(c(high, low)))
.collect();
assert!(!vals.is_empty());
assert!(vals.iter().all(|&v| v == 1.0));
}
#[test]
fn same_direction_low_pivots_extend_one_leg() {
// Mirror of the high-pivot case: strictly increasing highs mean no bar
// is ever a high pivot, so the confirmed pivots are all lows. The
// `extends` else-branch fires (true at 2 < 5, false at 4 > 2).
let mut td = TdDWave::new(1).unwrap();
let bars = [
(100.0, 10.0),
(101.0, 5.0),
(102.0, 8.0),
(103.0, 2.0),
(104.0, 6.0),
(105.0, 4.0),
(106.0, 7.0),
(107.0, 7.0),
];
let vals: Vec<f64> = bars
.iter()
.filter_map(|&(high, low)| td.update(c(high, low)))
.collect();
assert!(!vals.is_empty());
assert!(vals.iter().all(|&v| v == 1.0));
}
#[test]
fn wave_stays_in_one_to_eight() {
let mut td = TdDWave::new(2).unwrap();
for v in td.batch(&zigzag()).into_iter().flatten() {
assert!((1.0..=8.0).contains(&v), "wave out of range: {v}");
}
}
#[test]
fn flat_input_never_counts() {
// A perfectly flat series has no distinct swing highs/lows.
let mut td = TdDWave::new(2).unwrap();
let candles: Vec<Candle> = (0..40).map(|_| c(100.0, 100.0)).collect();
assert!(td.batch(&candles).iter().all(Option::is_none));
}
#[test]
fn reset_clears_state() {
let mut td = TdDWave::new(2).unwrap();
td.batch(&zigzag());
assert!(td.is_ready());
td.reset();
assert!(!td.is_ready());
assert_eq!(td.value(), None);
}
#[test]
fn batch_equals_streaming() {
let candles = zigzag();
let batch = TdDWave::new(2).unwrap().batch(&candles);
let mut b = TdDWave::new(2).unwrap();
let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,229 @@
#![allow(clippy::doc_markdown)]
//! Tom DeMark TD Moving Averages — the ST1 (fast) and ST2 (slow) trend ribbon.
use crate::error::{Error, Result};
use crate::indicators::sma::Sma;
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Output of [`TdMovingAverage`]: the fast (`st1`) and slow (`st2`) moving-average
/// lines.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct TdMovingAverageOutput {
/// ST1 — the fast (short) moving average.
pub st1: f64,
/// ST2 — the slow (long) moving average.
pub st2: f64,
}
/// Tom DeMark **TD Moving Averages** — a two-line trend ribbon (ST1 fast, ST2
/// slow) computed on the median price, whose relationship defines the trend.
///
/// ```text
/// price = (high + low) / 2 (median price)
/// st1 = SMA(price, period_st1) (fast / "Sequential Trend 1")
/// st2 = SMA(price, period_st2) (slow / "Sequential Trend 2")
/// ```
///
/// DeMark's moving-average pair frames the trend objectively: when `st1` is above
/// `st2` the trend is up, below it down, and the cross marks the change. Using the
/// **median price** rather than the close de-emphasises closing noise. This is a
/// streaming dual-SMA implementation of the ST1/ST2 ribbon; read the lines and
/// their crossover exactly as a fast/slow moving-average system.
///
/// `period_st1` must be strictly smaller than `period_st2`. The first value lands
/// once the slow average is seeded (`period_st2` inputs). Each `update` is O(1).
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, TdMovingAverage};
///
/// let mut indicator = TdMovingAverage::new(5, 13).unwrap();
/// let mut last = None;
/// for i in 0..40 {
/// let base = 100.0 + f64::from(i);
/// let c = Candle::new(base, base + 1.0, base - 1.0, base, 1_000.0, 0).unwrap();
/// last = indicator.update(c);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct TdMovingAverage {
st1: Sma,
st2: Sma,
period_st1: usize,
period_st2: usize,
last: Option<TdMovingAverageOutput>,
}
impl TdMovingAverage {
/// Construct TD Moving Averages with the given fast and slow periods.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if either period is `0`, and
/// [`Error::InvalidPeriod`] if `period_st1 >= period_st2`.
pub fn new(period_st1: usize, period_st2: usize) -> Result<Self> {
if period_st1 == 0 || period_st2 == 0 {
return Err(Error::PeriodZero);
}
if period_st1 >= period_st2 {
return Err(Error::InvalidPeriod {
message: "TD moving average ST1 period must be strictly less than ST2",
});
}
Ok(Self {
st1: Sma::new(period_st1)?,
st2: Sma::new(period_st2)?,
period_st1,
period_st2,
last: None,
})
}
/// Configured `(period_st1, period_st2)`.
pub const fn periods(&self) -> (usize, usize) {
(self.period_st1, self.period_st2)
}
/// Current value if available.
pub const fn value(&self) -> Option<TdMovingAverageOutput> {
self.last
}
}
impl Indicator for TdMovingAverage {
type Input = Candle;
type Output = TdMovingAverageOutput;
fn update(&mut self, candle: Candle) -> Option<TdMovingAverageOutput> {
let price = candle.median_price();
let fast = self.st1.update(price);
let slow = self.st2.update(price);
if let (Some(st1), Some(st2)) = (fast, slow) {
let out = TdMovingAverageOutput { st1, st2 };
self.last = Some(out);
return Some(out);
}
None
}
fn reset(&mut self) {
self.st1.reset();
self.st2.reset();
self.last = None;
}
fn warmup_period(&self) -> usize {
self.period_st2
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"TDMovingAverage"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn c(median: f64) -> Candle {
Candle::new_unchecked(median, median + 1.0, median - 1.0, median, 1_000.0, 0)
}
#[test]
fn rejects_invalid_periods() {
assert!(matches!(
TdMovingAverage::new(0, 13),
Err(Error::PeriodZero)
));
assert!(matches!(
TdMovingAverage::new(13, 5),
Err(Error::InvalidPeriod { .. })
));
assert!(matches!(
TdMovingAverage::new(5, 5),
Err(Error::InvalidPeriod { .. })
));
}
#[test]
fn accessors_and_metadata() {
let td = TdMovingAverage::new(5, 13).unwrap();
assert_eq!(td.periods(), (5, 13));
assert_eq!(td.warmup_period(), 13);
assert_eq!(td.name(), "TDMovingAverage");
assert!(!td.is_ready());
assert_eq!(td.value(), None);
}
#[test]
fn first_emission_at_warmup_period() {
let mut td = TdMovingAverage::new(2, 4).unwrap();
let candles: Vec<Candle> = (0..8).map(|i| c(100.0 + f64::from(i))).collect();
let out = td.batch(&candles);
for v in out.iter().take(3) {
assert!(v.is_none());
}
assert!(out[3].is_some());
}
#[test]
fn fast_leads_slow_in_uptrend() {
let mut td = TdMovingAverage::new(3, 7).unwrap();
let candles: Vec<Candle> = (0..40).map(|i| c(100.0 + f64::from(i))).collect();
let out = td.batch(&candles).into_iter().flatten().last().unwrap();
assert!(out.st1 > out.st2, "fast MA should lead in an uptrend");
}
#[test]
fn fast_below_slow_in_downtrend() {
let mut td = TdMovingAverage::new(3, 7).unwrap();
let candles: Vec<Candle> = (0..40).map(|i| c(200.0 - f64::from(i))).collect();
let out = td.batch(&candles).into_iter().flatten().last().unwrap();
assert!(out.st1 < out.st2, "fast MA should trail in a downtrend");
}
#[test]
fn flat_series_equal_lines() {
let mut td = TdMovingAverage::new(2, 4).unwrap();
let out = td
.batch(&[c(50.0); 10])
.into_iter()
.flatten()
.last()
.unwrap();
assert_relative_eq!(out.st1, 50.0, epsilon = 1e-9);
assert_relative_eq!(out.st2, 50.0, epsilon = 1e-9);
}
#[test]
fn reset_clears_state() {
let mut td = TdMovingAverage::new(2, 4).unwrap();
td.batch(&(0..10).map(|i| c(100.0 + f64::from(i))).collect::<Vec<_>>());
assert!(td.is_ready());
td.reset();
assert!(!td.is_ready());
assert_eq!(td.value(), None);
assert_eq!(td.update(c(100.0)), None);
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..80)
.map(|i| c(100.0 + (f64::from(i) * 0.25).sin() * 9.0))
.collect();
let batch = TdMovingAverage::new(5, 13).unwrap().batch(&candles);
let mut b = TdMovingAverage::new(5, 13).unwrap();
let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,152 @@
#![allow(clippy::doc_markdown)]
//! Tom DeMark TD Propulsion — a 2-bar trend-continuation thrust signal.
//!
//! TD Propulsion qualifies a continuation thrust: the bar opens on the trend side
//! of the prior close and then closes beyond the prior bar's extreme, "propelling"
//! the move forward.
//!
//! - **Propulsion up** (`+1.0`): `open >= close[-1]` (opens at or above the prior
//! close) AND `close > high[-1]` (closes above the prior high).
//! - **Propulsion down** (`-1.0`): `open <= close[-1]` AND `close < low[-1]`.
//! - Otherwise the output is `0.0`.
//!
//! The one-bar lookback means the first value lands on the second candle.
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// TD Propulsion — 2-bar trend-continuation thrust detector.
#[derive(Debug, Clone, Default)]
pub struct TdPropulsion {
prev: Option<Candle>,
last_value: Option<f64>,
}
impl TdPropulsion {
/// Construct a new `TdPropulsion`.
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// Latest emitted signal if available.
pub const fn value(&self) -> Option<f64> {
self.last_value
}
}
impl Indicator for TdPropulsion {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
let Some(prev) = self.prev else {
self.prev = Some(candle);
self.last_value = Some(0.0);
return Some(0.0);
};
let v = if candle.open >= prev.close && candle.close > prev.high {
1.0
} else if candle.open <= prev.close && candle.close < prev.low {
-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 {
"TDPropulsion"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
fn c(open: f64, high: f64, low: f64, close: f64) -> Candle {
Candle::new_unchecked(open, high, low, close, 0.0, 0)
}
#[test]
fn accessors_and_metadata() {
let td = TdPropulsion::new();
assert_eq!(td.warmup_period(), 2);
assert_eq!(td.name(), "TDPropulsion");
assert!(!td.is_ready());
assert_eq!(td.value(), None);
}
#[test]
fn first_bar_seeds_without_signal() {
let mut td = TdPropulsion::new();
assert_eq!(td.update(c(10.0, 11.0, 9.0, 10.0)), Some(0.0));
assert!(td.update(c(10.5, 12.0, 10.0, 11.5)).is_some());
}
#[test]
fn propulsion_up() {
// prev close 10, high 11. Current open 10.5 >= 10, close 11.5 > 11 -> +1.
let mut td = TdPropulsion::new();
td.update(c(9.5, 11.0, 9.0, 10.0));
assert_eq!(td.update(c(10.5, 12.0, 10.0, 11.5)), Some(1.0));
}
#[test]
fn propulsion_down() {
// prev close 10, low 9. Current open 9.5 <= 10, close 8.5 < 9 -> -1.
let mut td = TdPropulsion::new();
td.update(c(10.5, 11.0, 9.0, 10.0));
assert_eq!(td.update(c(9.5, 10.0, 8.0, 8.5)), Some(-1.0));
}
#[test]
fn no_thrust_is_zero() {
let mut td = TdPropulsion::new();
td.update(c(9.5, 11.0, 9.0, 10.0));
// close 10.5 not above prior high 11 -> 0.
assert_eq!(td.update(c(10.5, 10.8, 10.0, 10.5)), Some(0.0));
}
#[test]
fn reset_clears_state() {
let mut td = TdPropulsion::new();
td.update(c(9.5, 11.0, 9.0, 10.0));
td.update(c(10.5, 12.0, 10.0, 11.5));
assert!(td.is_ready());
td.reset();
assert!(!td.is_ready());
assert_eq!(td.update(c(9.5, 11.0, 9.0, 10.0)), Some(0.0));
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..40)
.map(|i| {
let b = 100.0 + (f64::from(i) * 0.4).sin() * 5.0;
c(b, b + 1.0, b - 1.0, b + 0.3)
})
.collect();
let batch = TdPropulsion::new().batch(&candles);
let mut b = TdPropulsion::new();
let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,171 @@
#![allow(clippy::doc_markdown)]
//! Tom DeMark TD Trap — an inside-bar ("trap") followed by a range breakout.
//!
//! A TD Trap forms when one bar is an **inside bar** (its high below and low above
//! the prior bar's), coiling the market; the next bar that closes beyond the trap
//! bar's high or low triggers the directional signal.
//!
//! - **Buy signal** (`+1.0`): the prior bar was an inside bar and the current
//! `close` is above that inside bar's `high`.
//! - **Sell signal** (`-1.0`): the prior bar was an inside bar and the current
//! `close` is below that inside bar's `low`.
//! - Otherwise the output is `0.0`.
//!
//! The two-bar lookback (one to set the inside bar, one before it) means the first
//! value lands on the third candle.
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// TD Trap — inside-bar breakout signal detector.
#[derive(Debug, Clone, Default)]
pub struct TdTrap {
prev1: Option<Candle>,
prev2: Option<Candle>,
last_value: Option<f64>,
}
impl TdTrap {
/// Construct a new `TdTrap`.
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// Latest emitted signal if available.
pub const fn value(&self) -> Option<f64> {
self.last_value
}
}
impl Indicator for TdTrap {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
let (Some(trap), Some(before)) = (self.prev1, self.prev2) else {
// Not enough history yet: emit a neutral 0.0 while seeding.
self.prev2 = self.prev1;
self.prev1 = Some(candle);
self.last_value = Some(0.0);
return Some(0.0);
};
let is_inside = trap.high < before.high && trap.low > before.low;
let v = if is_inside && candle.close > trap.high {
1.0
} else if is_inside && candle.close < trap.low {
-1.0
} else {
0.0
};
self.prev2 = self.prev1;
self.prev1 = Some(candle);
self.last_value = Some(v);
Some(v)
}
fn reset(&mut self) {
self.prev1 = None;
self.prev2 = None;
self.last_value = None;
}
fn warmup_period(&self) -> usize {
3
}
fn is_ready(&self) -> bool {
self.last_value.is_some()
}
fn name(&self) -> &'static str {
"TDTrap"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
fn c(high: f64, low: f64, close: f64) -> Candle {
Candle::new_unchecked(f64::midpoint(high, low), high, low, close, 0.0, 0)
}
#[test]
fn accessors_and_metadata() {
let td = TdTrap::new();
assert_eq!(td.warmup_period(), 3);
assert_eq!(td.name(), "TDTrap");
assert!(!td.is_ready());
assert_eq!(td.value(), None);
}
#[test]
fn first_two_bars_seed_without_signal() {
let mut td = TdTrap::new();
assert_eq!(td.update(c(110.0, 90.0, 100.0)), Some(0.0));
assert_eq!(td.update(c(108.0, 95.0, 102.0)), Some(0.0));
assert!(td.update(c(112.0, 100.0, 110.0)).is_some());
}
#[test]
fn inside_then_breakout_up_buys() {
// bar0 wide [90,110]; bar1 inside [95,108]; bar2 close 109 > 108 -> +1.
let mut td = TdTrap::new();
td.update(c(110.0, 90.0, 100.0));
td.update(c(108.0, 95.0, 102.0)); // inside bar (high<110, low>90)
assert_eq!(td.update(c(112.0, 100.0, 109.0)), Some(1.0));
}
#[test]
fn inside_then_breakdown_sells() {
let mut td = TdTrap::new();
td.update(c(110.0, 90.0, 100.0));
td.update(c(108.0, 95.0, 102.0)); // inside bar
assert_eq!(td.update(c(100.0, 92.0, 94.0)), Some(-1.0)); // close 94 < 95
}
#[test]
fn no_inside_bar_is_zero() {
let mut td = TdTrap::new();
td.update(c(110.0, 90.0, 100.0));
td.update(c(115.0, 85.0, 100.0)); // outside bar, not inside
assert_eq!(td.update(c(120.0, 110.0, 118.0)), Some(0.0));
}
#[test]
fn inside_but_no_breakout_is_zero() {
let mut td = TdTrap::new();
td.update(c(110.0, 90.0, 100.0));
td.update(c(108.0, 95.0, 102.0)); // inside bar
assert_eq!(td.update(c(107.0, 96.0, 103.0)), Some(0.0)); // close 103 within [95,108]
}
#[test]
fn reset_clears_state() {
let mut td = TdTrap::new();
td.update(c(110.0, 90.0, 100.0));
td.update(c(108.0, 95.0, 102.0));
td.update(c(112.0, 100.0, 109.0));
assert!(td.is_ready());
td.reset();
assert!(!td.is_ready());
assert_eq!(td.update(c(110.0, 90.0, 100.0)), Some(0.0));
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..40)
.map(|i| {
let b = 100.0 + (f64::from(i) * 0.4).sin() * 6.0;
c(b + 2.0, b - 2.0, b)
})
.collect();
let batch = TdTrap::new().batch(&candles);
let mut b = TdTrap::new();
let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
assert_eq!(batch, streamed);
}
}