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:
@@ -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 `1–5` impulse / `A–C` 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user