feat(family-11): add DeMark suite (TD Setup, Sequential, DeMarker, REI, Pressure) (#48)

* feat(family-11): add DeMark suite (TD Setup, Sequential, DeMarker, REI, Pressure)

Family 11 (DeMark) was previously empty; this PR adds five
streaming-first DeMark indicators in one batch.

- **TD Setup** (`TdSetup`): parameterised buy/sell setup counter.
  Counts consecutive bars whose close is less-than (buy) or
  greater-than (sell) the close `lookback` bars earlier, saturating
  at `target`. Emits a signed `f64` so callers read direction from
  the sign and run length from the magnitude. Classic config:
  `lookback = 4`, `target = 9`.

- **TD Sequential** (`TdSequential`): the canonical Setup + Countdown
  exhaustion pattern. Output struct `{ setup, countdown, direction }`
  exposes both phase counts as signed numbers plus the active
  countdown direction (+1 buy / -1 sell / 0 none). Countdown
  activates when a setup completes and tracks the close-vs-high/low
  comparison `countdown_lookback` bars back, capped at
  `countdown_target`. Classic: 4/9/2/13.

- **TD DeMarker** (`TdDeMarker`): bounded [0, 1] oscillator from the
  rolling average of upward high expansion (DeMax) and downward low
  expansion (DeMin). Falls back to the neutral 0.5 on a flat market
  (denominator zero).

- **TD REI** (`TdRei`): Range Expansion Index, bounded [-100, 100].
  Per-bar numerator gated on a range-overlap condition vs the bars
  5 and 6 back, normalised by a `period`-bar sum of absolute moves.
  Classic period = 5. Saturates at +100 in a slow steady uptrend
  and at -100 in the mirror downtrend; emits 0 on a flat market.

- **TD Pressure** (`TdPressure`): volume-weighted buying / selling
  pressure normalised to [-100, 100]. Per-bar pressure is the
  intra-bar close-vs-open ratio scaled by volume; the output is the
  rolling mean divided by the rolling mean volume. Zero-range bars
  contribute zero (avoid the undefined ratio) and a flat zero-volume
  window falls back to 0.

Bindings: all five exposed in Python (`ta.TDSetup`, `ta.TDSequential`,
`ta.TDDeMarker`, `ta.TDREI`, `ta.TDPressure`), Node (`wickra.TDSetup`
etc.), and WASM. Multi-output classes (`TDSequential`) return either
a struct `{ setup, countdown, direction }` per bar (streaming) or a
flat interleaved Float64Array of length `3 * n` (batch).

Tests: 47 unit tests across the five new core files (pure-trend
saturation, flat-market neutral fallback, batch-equals-streaming,
zero-parameter rejection, reset semantics, accessors). Python
test_new_indicators.py picks up all five plus a multi-output TD
Sequential block. Node indicators.test.js picks up all five.
Reference values added to test_known_values.py.

Fuzz: candle fuzz target sweeps all five DeMark indicators with the
existing `Vec<f64>` -> `Vec<Candle>` driver.

Benches: BTCUSDT 1-minute dataset benches for each DeMark indicator
in `crates/wickra/benches/indicators.rs`.

Docs: README family table gains a "DeMark" row; indicator counter
bumped 71 -> 76. CHANGELOG entry added under [Unreleased]. Wiki
drafts (deep-dive pages + Sidebar / Overview / Warmup-Periods / Home
deltas) live under `indicator-ideas/families/wiki/family-11-demark/`
for manual merge into the wiki repo.

* feat(family-11): add 7 missing DeMark indicators

Complete the DeMark suite (family 11) with the seven indicators not
covered by the first commit: TD Combo, TD Countdown, TD Lines (TDST),
TD Range Projection, TD Differential, TD Open, and TD Risk Level.

- TdCombo: aggressive countdown variant with three strictness rules
  on top of the classic close-vs-low/high lookback rule (monotone
  low/high, monotone close vs prior bar).
- TdCountdown: standalone 13-bar countdown packaging only the signed
  countdown count (the setup machine runs internally).
- TdLines: TDST horizontal support/resistance levels from the
  highest-high / lowest-low bars of the most-recently-completed
  setup, exposed as a multi-output struct.
- TdRangeProjection: DeMark X-projection of the next bar's high and
  low from the current bar's OHLC via an open-vs-close-weighted
  pivot (three branches: close<open, close>open, close==open).
- TdDifferential: two-bar buying-pressure vs selling-pressure
  reversal pattern emitting +1/-1/0.
- TdOpen: gap-and-fade reversal pattern (open outside prior range
  with subsequent recovery into it) emitting +1/-1/0.
- TdRiskLevel: protective stop levels derived from the setup
  extreme bar +/- its true range.

All seven are wired through Rust core, Python, Node and WASM
bindings, registered in the candle-stream fuzz target, given
benchmark entries on the BTCUSDT 1-minute dataset, and covered by
streaming-vs-batch equivalence, reference-value, lifecycle and
input-validation tests on the Python and Node sides. README counter
moves 76 -> 83 and the CHANGELOG "family 11" entry is extended to
list all twelve indicators.

* fix(td_risk_level tests): check first emission at idx 12, not last bar

TdRiskLevel re-ratchets the sell-risk level on each subsequent setup
completion, so a strictly rising series produces 22.0 at idx 19 (latest
setup) rather than 15.0 (first setup). The test comment already named
idx 12 as the reference; switch the assertion from out[-1] to out[12]
to match the reference computation.

* test(family-11): cover buy-direction branches in TD indicators

Add downtrend tests to TdSequential, TdCombo and TdCountdown so the
buy-side countdown/combo increment branches are exercised; remove an
empty `if buy_countdown == target {}` block in TdSequential whose
behavior is already enforced by the outer strict `<` guard.

Closes codecov/patch gaps reported on PR #48 (10 missed lines across
the three files).
This commit is contained in:
kingchenc
2026-05-25 20:36:36 +02:00
committed by GitHub
parent 7e1e988596
commit 4f9ed34884
26 changed files with 6130 additions and 17 deletions
+26
View File
@@ -169,6 +169,19 @@ from ._wickra import (
DemarkPivots,
WilliamsFractals,
ZigZag,
# DeMark
TDSetup,
TDSequential,
TDDeMarker,
TDREI,
TDPressure,
TDCombo,
TDCountdown,
TDLines,
TDRangeProjection,
TDDifferential,
TDOpen,
TDRiskLevel,
)
__all__ = [
@@ -317,4 +330,17 @@ __all__ = [
"DemarkPivots",
"WilliamsFractals",
"ZigZag",
# DeMark
"TDSetup",
"TDSequential",
"TDDeMarker",
"TDREI",
"TDPressure",
"TDCombo",
"TDCountdown",
"TDLines",
"TDRangeProjection",
"TDDifferential",
"TDOpen",
"TDRiskLevel",
]
+854
View File
@@ -8575,6 +8575,848 @@ impl PyZigZag {
self.inner.warmup_period()
}
}
// ============================== TD Setup ==============================
#[pyclass(name = "TDSetup", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PyTdSetup {
inner: wc::TdSetup,
}
#[pymethods]
impl PyTdSetup {
#[new]
#[pyo3(signature = (lookback=4, target=9))]
fn new(lookback: usize, target: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::TdSetup::new(lookback, target).map_err(map_err)?,
})
}
fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult<Option<f64>> {
let c = extract_candle(candle)?;
Ok(self.inner.update(c))
}
fn batch<'py>(
&mut self,
py: Python<'py>,
high: PyReadonlyArray1<'py, f64>,
low: PyReadonlyArray1<'py, f64>,
close: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
let h = high
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
let l = low
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
let c = close
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
if h.len() != l.len() || l.len() != c.len() {
return Err(PyValueError::new_err(
"high, low, close must be equal length",
));
}
let mut out = Vec::with_capacity(h.len());
for i in 0..h.len() {
let candle = wc::Candle::new(c[i], h[i], l[i], c[i], 0.0, 0).map_err(map_err)?;
out.push(self.inner.update(candle).unwrap_or(f64::NAN));
}
Ok(out.into_pyarray(py))
}
fn reset(&mut self) {
self.inner.reset();
}
fn is_ready(&self) -> bool {
self.inner.is_ready()
}
fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
#[getter]
fn value(&self) -> Option<f64> {
self.inner.value()
}
fn __repr__(&self) -> String {
let (lb, tg) = self.inner.params();
format!("TDSetup(lookback={lb}, target={tg})")
}
}
// ============================== TD Sequential ==============================
#[pyclass(name = "TDSequential", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PyTdSequential {
inner: wc::TdSequential,
}
#[pymethods]
impl PyTdSequential {
#[new]
#[pyo3(signature = (setup_lookback=4, setup_target=9, countdown_lookback=2, countdown_target=13))]
fn new(
setup_lookback: usize,
setup_target: usize,
countdown_lookback: usize,
countdown_target: usize,
) -> PyResult<Self> {
Ok(Self {
inner: wc::TdSequential::new(
setup_lookback,
setup_target,
countdown_lookback,
countdown_target,
)
.map_err(map_err)?,
})
}
/// Returns `(setup, countdown, direction)` or `None` during warmup.
fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult<Option<(f64, f64, f64)>> {
let c = extract_candle(candle)?;
Ok(self
.inner
.update(c)
.map(|o| (o.setup, o.countdown, o.direction)))
}
/// Batch returns shape `(n, 3)`: `[setup, countdown, direction]`.
fn batch<'py>(
&mut self,
py: Python<'py>,
high: PyReadonlyArray1<'py, f64>,
low: PyReadonlyArray1<'py, f64>,
close: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray2<f64>>> {
let h = high
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
let l = low
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
let c = close
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
if h.len() != l.len() || l.len() != c.len() {
return Err(PyValueError::new_err(
"high, low, close must be equal length",
));
}
let n = h.len();
let mut out = vec![f64::NAN; n * 3];
for i in 0..n {
let candle = wc::Candle::new(c[i], h[i], l[i], c[i], 0.0, 0).map_err(map_err)?;
if let Some(o) = self.inner.update(candle) {
out[i * 3] = o.setup;
out[i * 3 + 1] = o.countdown;
out[i * 3 + 2] = o.direction;
}
}
Ok(numpy::ndarray::Array2::from_shape_vec((n, 3), out)
.expect("shape consistent")
.into_pyarray(py))
}
fn reset(&mut self) {
self.inner.reset();
}
fn is_ready(&self) -> bool {
self.inner.is_ready()
}
fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
}
// ============================== TD DeMarker ==============================
#[pyclass(name = "TDDeMarker", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PyTdDeMarker {
inner: wc::TdDeMarker,
}
#[pymethods]
impl PyTdDeMarker {
#[new]
#[pyo3(signature = (period=14))]
fn new(period: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::TdDeMarker::new(period).map_err(map_err)?,
})
}
fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult<Option<f64>> {
let c = extract_candle(candle)?;
Ok(self.inner.update(c))
}
fn batch<'py>(
&mut self,
py: Python<'py>,
high: PyReadonlyArray1<'py, f64>,
low: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
let h = high
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
let l = low
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
if h.len() != l.len() {
return Err(PyValueError::new_err("high and low must be equal length"));
}
let mut out = Vec::with_capacity(h.len());
for i in 0..h.len() {
let candle = wc::Candle::new(l[i], h[i], l[i], l[i], 0.0, 0).map_err(map_err)?;
out.push(self.inner.update(candle).unwrap_or(f64::NAN));
}
Ok(out.into_pyarray(py))
}
#[getter]
fn period(&self) -> usize {
self.inner.period()
}
#[getter]
fn value(&self) -> Option<f64> {
self.inner.value()
}
fn reset(&mut self) {
self.inner.reset();
}
fn is_ready(&self) -> bool {
self.inner.is_ready()
}
fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
fn __repr__(&self) -> String {
format!("TDDeMarker(period={})", self.inner.period())
}
}
// ============================== TD REI ==============================
#[pyclass(name = "TDREI", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PyTdRei {
inner: wc::TdRei,
}
#[pymethods]
impl PyTdRei {
#[new]
#[pyo3(signature = (period=5))]
fn new(period: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::TdRei::new(period).map_err(map_err)?,
})
}
fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult<Option<f64>> {
let c = extract_candle(candle)?;
Ok(self.inner.update(c))
}
fn batch<'py>(
&mut self,
py: Python<'py>,
high: PyReadonlyArray1<'py, f64>,
low: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
let h = high
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
let l = low
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
if h.len() != l.len() {
return Err(PyValueError::new_err("high and low must be equal length"));
}
let mut out = Vec::with_capacity(h.len());
for i in 0..h.len() {
let candle = wc::Candle::new(l[i], h[i], l[i], l[i], 0.0, 0).map_err(map_err)?;
out.push(self.inner.update(candle).unwrap_or(f64::NAN));
}
Ok(out.into_pyarray(py))
}
#[getter]
fn period(&self) -> usize {
self.inner.period()
}
#[getter]
fn value(&self) -> Option<f64> {
self.inner.value()
}
fn reset(&mut self) {
self.inner.reset();
}
fn is_ready(&self) -> bool {
self.inner.is_ready()
}
fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
fn __repr__(&self) -> String {
format!("TDREI(period={})", self.inner.period())
}
}
// ============================== TD Pressure ==============================
#[pyclass(name = "TDPressure", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PyTdPressure {
inner: wc::TdPressure,
}
#[pymethods]
impl PyTdPressure {
#[new]
#[pyo3(signature = (period=5))]
fn new(period: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::TdPressure::new(period).map_err(map_err)?,
})
}
fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult<Option<f64>> {
let c = extract_candle(candle)?;
Ok(self.inner.update(c))
}
/// Batch over numpy columns: open, high, low, close, volume.
fn batch<'py>(
&mut self,
py: Python<'py>,
open: PyReadonlyArray1<'py, f64>,
high: PyReadonlyArray1<'py, f64>,
low: PyReadonlyArray1<'py, f64>,
close: PyReadonlyArray1<'py, f64>,
volume: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
let o = open
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
let h = high
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
let l = low
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
let c = close
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
let v = volume
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
if o.len() != h.len() || h.len() != l.len() || l.len() != c.len() || c.len() != v.len() {
return Err(PyValueError::new_err(
"open, high, low, close, volume must be equal length",
));
}
let mut out = Vec::with_capacity(o.len());
for i in 0..o.len() {
let candle = wc::Candle::new(o[i], h[i], l[i], c[i], v[i], 0).map_err(map_err)?;
out.push(self.inner.update(candle).unwrap_or(f64::NAN));
}
Ok(out.into_pyarray(py))
}
#[getter]
fn period(&self) -> usize {
self.inner.period()
}
#[getter]
fn value(&self) -> Option<f64> {
self.inner.value()
}
fn reset(&mut self) {
self.inner.reset();
}
fn is_ready(&self) -> bool {
self.inner.is_ready()
}
fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
fn __repr__(&self) -> String {
format!("TDPressure(period={})", self.inner.period())
}
}
// ============================== TD Combo ==============================
#[pyclass(name = "TDCombo", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PyTdCombo {
inner: wc::TdCombo,
}
#[pymethods]
impl PyTdCombo {
#[new]
#[pyo3(signature = (setup_lookback=4, setup_target=9, countdown_lookback=2, countdown_target=13))]
fn new(
setup_lookback: usize,
setup_target: usize,
countdown_lookback: usize,
countdown_target: usize,
) -> PyResult<Self> {
Ok(Self {
inner: wc::TdCombo::new(
setup_lookback,
setup_target,
countdown_lookback,
countdown_target,
)
.map_err(map_err)?,
})
}
fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult<Option<f64>> {
let c = extract_candle(candle)?;
Ok(self.inner.update(c))
}
fn batch<'py>(
&mut self,
py: Python<'py>,
high: PyReadonlyArray1<'py, f64>,
low: PyReadonlyArray1<'py, f64>,
close: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
let h = high
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
let l = low
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
let c = close
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
if h.len() != l.len() || l.len() != c.len() {
return Err(PyValueError::new_err(
"high, low, close must be equal length",
));
}
let mut out = Vec::with_capacity(h.len());
for i in 0..h.len() {
let candle = wc::Candle::new(c[i], h[i], l[i], c[i], 0.0, 0).map_err(map_err)?;
out.push(self.inner.update(candle).unwrap_or(f64::NAN));
}
Ok(out.into_pyarray(py))
}
fn reset(&mut self) {
self.inner.reset();
}
fn is_ready(&self) -> bool {
self.inner.is_ready()
}
fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
}
// ============================== TD Countdown ==============================
#[pyclass(name = "TDCountdown", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PyTdCountdown {
inner: wc::TdCountdown,
}
#[pymethods]
impl PyTdCountdown {
#[new]
#[pyo3(signature = (setup_lookback=4, setup_target=9, countdown_lookback=2, countdown_target=13))]
fn new(
setup_lookback: usize,
setup_target: usize,
countdown_lookback: usize,
countdown_target: usize,
) -> PyResult<Self> {
Ok(Self {
inner: wc::TdCountdown::new(
setup_lookback,
setup_target,
countdown_lookback,
countdown_target,
)
.map_err(map_err)?,
})
}
fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult<Option<f64>> {
let c = extract_candle(candle)?;
Ok(self.inner.update(c))
}
fn batch<'py>(
&mut self,
py: Python<'py>,
high: PyReadonlyArray1<'py, f64>,
low: PyReadonlyArray1<'py, f64>,
close: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
let h = high
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
let l = low
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
let c = close
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
if h.len() != l.len() || l.len() != c.len() {
return Err(PyValueError::new_err(
"high, low, close must be equal length",
));
}
let mut out = Vec::with_capacity(h.len());
for i in 0..h.len() {
let candle = wc::Candle::new(c[i], h[i], l[i], c[i], 0.0, 0).map_err(map_err)?;
out.push(self.inner.update(candle).unwrap_or(f64::NAN));
}
Ok(out.into_pyarray(py))
}
fn reset(&mut self) {
self.inner.reset();
}
fn is_ready(&self) -> bool {
self.inner.is_ready()
}
fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
}
// ============================== TD Lines ==============================
#[pyclass(name = "TDLines", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PyTdLines {
inner: wc::TdLines,
}
#[pymethods]
impl PyTdLines {
#[new]
#[pyo3(signature = (lookback=4, target=9))]
fn new(lookback: usize, target: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::TdLines::new(lookback, target).map_err(map_err)?,
})
}
/// Returns `(resistance, support)` (with `NaN` for unset levels) or
/// `None` during warmup.
fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult<Option<(f64, f64)>> {
let c = extract_candle(candle)?;
Ok(self.inner.update(c).map(|o| (o.resistance, o.support)))
}
/// Batch returns shape `(n, 2)`: `[resistance, support]`.
fn batch<'py>(
&mut self,
py: Python<'py>,
high: PyReadonlyArray1<'py, f64>,
low: PyReadonlyArray1<'py, f64>,
close: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray2<f64>>> {
let h = high
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
let l = low
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
let c = close
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
if h.len() != l.len() || l.len() != c.len() {
return Err(PyValueError::new_err(
"high, low, close must be equal length",
));
}
let n = h.len();
let mut out = vec![f64::NAN; n * 2];
for i in 0..n {
let candle = wc::Candle::new(c[i], h[i], l[i], c[i], 0.0, 0).map_err(map_err)?;
if let Some(o) = self.inner.update(candle) {
out[i * 2] = o.resistance;
out[i * 2 + 1] = o.support;
}
}
Ok(numpy::ndarray::Array2::from_shape_vec((n, 2), out)
.expect("shape consistent")
.into_pyarray(py))
}
fn reset(&mut self) {
self.inner.reset();
}
fn is_ready(&self) -> bool {
self.inner.is_ready()
}
fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
}
// ============================== TD Range Projection ==============================
#[pyclass(
name = "TDRangeProjection",
module = "wickra._wickra",
skip_from_py_object
)]
#[derive(Clone, Default)]
struct PyTdRangeProjection {
inner: wc::TdRangeProjection,
}
#[pymethods]
impl PyTdRangeProjection {
#[new]
fn new() -> Self {
Self {
inner: wc::TdRangeProjection::new(),
}
}
/// Returns `(projected_high, projected_low)` for the next bar.
fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult<Option<(f64, f64)>> {
let c = extract_candle(candle)?;
Ok(self.inner.update(c).map(|o| (o.high, o.low)))
}
/// Batch returns shape `(n, 2)`: `[projected_high, projected_low]`.
fn batch<'py>(
&mut self,
py: Python<'py>,
open: PyReadonlyArray1<'py, f64>,
high: PyReadonlyArray1<'py, f64>,
low: PyReadonlyArray1<'py, f64>,
close: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray2<f64>>> {
let o = open
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
let h = high
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
let l = low
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
let c = close
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
if o.len() != h.len() || h.len() != l.len() || l.len() != c.len() {
return Err(PyValueError::new_err(
"open, high, low, close must be equal length",
));
}
let n = o.len();
let mut out = vec![f64::NAN; n * 2];
for i in 0..n {
let candle = wc::Candle::new(o[i], h[i], l[i], c[i], 0.0, 0).map_err(map_err)?;
if let Some(p) = self.inner.update(candle) {
out[i * 2] = p.high;
out[i * 2 + 1] = p.low;
}
}
Ok(numpy::ndarray::Array2::from_shape_vec((n, 2), out)
.expect("shape consistent")
.into_pyarray(py))
}
fn reset(&mut self) {
self.inner.reset();
}
fn is_ready(&self) -> bool {
self.inner.is_ready()
}
fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
}
// ============================== TD Differential ==============================
#[pyclass(
name = "TDDifferential",
module = "wickra._wickra",
skip_from_py_object
)]
#[derive(Clone, Default)]
struct PyTdDifferential {
inner: wc::TdDifferential,
}
#[pymethods]
impl PyTdDifferential {
#[new]
fn new() -> Self {
Self {
inner: wc::TdDifferential::new(),
}
}
fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult<Option<f64>> {
let c = extract_candle(candle)?;
Ok(self.inner.update(c))
}
fn batch<'py>(
&mut self,
py: Python<'py>,
high: PyReadonlyArray1<'py, f64>,
low: PyReadonlyArray1<'py, f64>,
close: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
let h = high
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
let l = low
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
let c = close
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
if h.len() != l.len() || l.len() != c.len() {
return Err(PyValueError::new_err(
"high, low, close must be equal length",
));
}
let mut out = Vec::with_capacity(h.len());
for i in 0..h.len() {
let candle = wc::Candle::new(c[i], h[i], l[i], c[i], 0.0, 0).map_err(map_err)?;
out.push(self.inner.update(candle).unwrap_or(f64::NAN));
}
Ok(out.into_pyarray(py))
}
fn reset(&mut self) {
self.inner.reset();
}
fn is_ready(&self) -> bool {
self.inner.is_ready()
}
fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
}
// ============================== TD Open ==============================
#[pyclass(name = "TDOpen", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone, Default)]
struct PyTdOpen {
inner: wc::TdOpen,
}
#[pymethods]
impl PyTdOpen {
#[new]
fn new() -> Self {
Self {
inner: wc::TdOpen::new(),
}
}
fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult<Option<f64>> {
let c = extract_candle(candle)?;
Ok(self.inner.update(c))
}
fn batch<'py>(
&mut self,
py: Python<'py>,
open: PyReadonlyArray1<'py, f64>,
high: PyReadonlyArray1<'py, f64>,
low: PyReadonlyArray1<'py, f64>,
close: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
let o = open
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
let h = high
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
let l = low
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
let c = close
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
if o.len() != h.len() || h.len() != l.len() || l.len() != c.len() {
return Err(PyValueError::new_err(
"open, high, low, close must be equal length",
));
}
let mut out = Vec::with_capacity(o.len());
for i in 0..o.len() {
let candle = wc::Candle::new(o[i], h[i], l[i], c[i], 0.0, 0).map_err(map_err)?;
out.push(self.inner.update(candle).unwrap_or(f64::NAN));
}
Ok(out.into_pyarray(py))
}
fn reset(&mut self) {
self.inner.reset();
}
fn is_ready(&self) -> bool {
self.inner.is_ready()
}
fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
}
// ============================== TD Risk Level ==============================
#[pyclass(name = "TDRiskLevel", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PyTdRiskLevel {
inner: wc::TdRiskLevel,
}
#[pymethods]
impl PyTdRiskLevel {
#[new]
#[pyo3(signature = (lookback=4, target=9))]
fn new(lookback: usize, target: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::TdRiskLevel::new(lookback, target).map_err(map_err)?,
})
}
/// Returns `(buy_risk, sell_risk)` (with `NaN` for unset levels) or
/// `None` during warmup.
fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult<Option<(f64, f64)>> {
let c = extract_candle(candle)?;
Ok(self.inner.update(c).map(|o| (o.buy_risk, o.sell_risk)))
}
/// Batch returns shape `(n, 2)`: `[buy_risk, sell_risk]`.
fn batch<'py>(
&mut self,
py: Python<'py>,
high: PyReadonlyArray1<'py, f64>,
low: PyReadonlyArray1<'py, f64>,
close: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray2<f64>>> {
let h = high
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
let l = low
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
let c = close
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
if h.len() != l.len() || l.len() != c.len() {
return Err(PyValueError::new_err(
"high, low, close must be equal length",
));
}
let n = h.len();
let mut out = vec![f64::NAN; n * 2];
for i in 0..n {
let candle = wc::Candle::new(c[i], h[i], l[i], c[i], 0.0, 0).map_err(map_err)?;
if let Some(o) = self.inner.update(candle) {
out[i * 2] = o.buy_risk;
out[i * 2 + 1] = o.sell_risk;
}
}
Ok(numpy::ndarray::Array2::from_shape_vec((n, 2), out)
.expect("shape consistent")
.into_pyarray(py))
}
fn reset(&mut self) {
self.inner.reset();
}
fn is_ready(&self) -> bool {
self.inner.is_ready()
}
fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
}
// ============================== Module ==============================
#[pymodule]
@@ -8718,5 +9560,17 @@ fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_class::<PyDemarkPivots>()?;
m.add_class::<PyWilliamsFractals>()?;
m.add_class::<PyZigZag>()?;
m.add_class::<PyTdSetup>()?;
m.add_class::<PyTdSequential>()?;
m.add_class::<PyTdDeMarker>()?;
m.add_class::<PyTdRei>()?;
m.add_class::<PyTdPressure>()?;
m.add_class::<PyTdCombo>()?;
m.add_class::<PyTdCountdown>()?;
m.add_class::<PyTdLines>()?;
m.add_class::<PyTdRangeProjection>()?;
m.add_class::<PyTdDifferential>()?;
m.add_class::<PyTdOpen>()?;
m.add_class::<PyTdRiskLevel>()?;
Ok(())
}
+107
View File
@@ -332,6 +332,113 @@ def test_obv_cumulative_known_sequence():
np.testing.assert_allclose(out, [0.0, 20.0, -10.0, -10.0, 0.0])
# --- DeMark family ---------------------------------------------------------
def test_td_setup_buy_setup_completes_at_minus_9_uptrend():
# Strictly rising closes -> every bar has close > close[-4] (sell setup);
# the streak hits -9 at index 12 and caps there.
h = np.arange(2.0, 22.0)
l = h - 1.0
c = h - 0.5
out = ta.TDSetup(4, 9).batch(h, l, c)
assert out[12] == pytest.approx(-9.0)
assert out[-1] == pytest.approx(-9.0)
def test_td_demarker_downtrend_pegs_at_zero():
n = 20
h = np.arange(30.0, 30.0 - n, -1.0)
l = h - 2.0
out = ta.TDDeMarker(5).batch(h, l)
assert out[-1] == pytest.approx(0.0)
def test_td_pressure_pure_bearish_yields_minus_100():
n = 20
open_ = np.full(n, 11.0)
high = np.full(n, 11.0)
low = np.full(n, 9.0)
close = np.full(n, 9.0)
volume = np.full(n, 100.0)
out = ta.TDPressure(5).batch(open_, high, low, close, volume)
assert out[-1] == pytest.approx(-100.0)
def test_td_combo_uptrend_completes_to_minus_13():
# Pure uptrend -> setup completes, then combo conditions (close>=high[-2],
# high>=prev.high, close>prev.close) all hold for every subsequent bar
# -> sell combo saturates at -13.
n = 40
high = np.arange(1.0, 1.0 + n) + 0.5
low = high - 1.0
close = high - 0.5
out = ta.TDCombo().batch(high, low, close)
assert out[-1] == pytest.approx(-13.0)
def test_td_countdown_uptrend_completes_to_minus_13():
n = 40
high = np.arange(1.0, 1.0 + n) + 0.5
low = high - 1.0
close = high - 0.5
out = ta.TDCountdown().batch(high, low, close)
assert out[-1] == pytest.approx(-13.0)
def test_td_range_projection_doji_reference():
# open=close=10, high=12, low=9 -> doji branch.
# pivot_sum = 12 + 9 + 2*10 = 41; half = 20.5.
# projHigh = 20.5 - 9 = 11.5; projLow = 20.5 - 12 = 8.5.
out = ta.TDRangeProjection().batch(
np.array([10.0]), np.array([12.0]), np.array([9.0]), np.array([10.0])
)
assert out[0, 0] == pytest.approx(11.5)
assert out[0, 1] == pytest.approx(8.5)
def test_td_open_sell_signal_reference():
# Prev high=12. Curr open=13 > 12, curr low=11 < 12 -> -1.
td = ta.TDOpen()
assert td.update((10.0, 12.0, 9.0, 11.0, 1.0, 0)) is None
assert td.update((13.0, 13.5, 11.0, 11.5, 1.0, 1)) == pytest.approx(-1.0)
def test_td_differential_sell_signal_reference():
# Prev high=10, low=8, close=9: buying=1, selling=1.
# Curr high=12, low=9.8, close=10.5: close>prev.close, selling=1.5>1,
# buying=0.7<1 -> sell signal -1.
td = ta.TDDifferential()
assert td.update((9.0, 10.0, 8.0, 9.0, 1.0, 0)) is None
assert td.update((10.5, 12.0, 9.8, 10.5, 1.0, 1)) == pytest.approx(-1.0)
def test_td_lines_uptrend_support_reference():
# Strictly rising series -> sell setup completes at idx 12, the
# lowest low across bars 4..=12 is the low at idx 4 = 4.5.
n = 20
high = np.arange(1.0, 1.0 + n) + 0.5
low = high - 1.0
close = high - 0.5
out = ta.TDLines().batch(high, low, close)
assert math.isnan(out[-1, 0])
assert out[-1, 1] == pytest.approx(4.5)
def test_td_risk_level_uptrend_sell_risk_reference():
# Strictly rising series -> sell setup completes at idx 12 with high
# 13.5 and true range 1.5 -> sell_risk = 13.5 + 1.5 = 15.0.
# Subsequent setups re-ratchet the level, so we check the first emission
# at idx 12 rather than the latest value.
n = 20
high = np.arange(1.0, 1.0 + n) + 0.5
low = high - 1.0
close = high - 0.5
out = ta.TDRiskLevel().batch(high, low, close)
assert math.isnan(out[12, 0])
assert out[12, 1] == pytest.approx(15.0)
def test_percentage_trailing_stop_seed_and_ratchet():
# 10% trail: first close 100 -> stop 90; next 110 -> stop max(90, 99) = 99.
s = ta.PercentageTrailingStop(10.0)
@@ -274,6 +274,30 @@ CANDLE_SCALAR = {
lambda: ta.YangZhangVolatility(20, 252),
lambda ind, h, l, c, v: ind.batch(c, h, l, c),
),
"TDSetup": (
lambda: ta.TDSetup(4, 9),
lambda ind, h, l, c, v: ind.batch(h, l, c),
),
"TDDeMarker": (
lambda: ta.TDDeMarker(14),
lambda ind, h, l, c, v: ind.batch(h, l),
),
"TDREI": (
lambda: ta.TDREI(5),
lambda ind, h, l, c, v: ind.batch(h, l),
),
"TDCombo": (
lambda: ta.TDCombo(4, 9, 2, 13),
lambda ind, h, l, c, v: ind.batch(h, l, c),
),
"TDCountdown": (
lambda: ta.TDCountdown(4, 9, 2, 13),
lambda ind, h, l, c, v: ind.batch(h, l, c),
),
"TDDifferential": (
lambda: ta.TDDifferential(),
lambda ind, h, l, c, v: ind.batch(h, l, c),
),
}
@@ -526,6 +550,55 @@ def test_multi_scalar_streaming_matches_batch(name, ohlcv):
assert _eq_nan(batch, np.array(rows, dtype=np.float64)), f"{name} mismatch"
# --- TD Pressure (OHLCV-input) -------------------------------------------
def test_td_pressure_streaming_matches_batch(ohlcv):
high, low, close, volume = ohlcv
open_ = close.copy() # TD Pressure needs open; reuse close as the open column.
batch = ta.TDPressure(5).batch(open_, high, low, close, volume)
assert batch.shape == close.shape
streamer = ta.TDPressure(5)
streamed = []
for i in range(close.size):
candle = (
float(open_[i]),
float(high[i]),
float(low[i]),
float(close[i]),
float(volume[i]),
i,
)
v = streamer.update(candle)
streamed.append(math.nan if v is None else float(v))
assert _eq_nan(batch, np.array(streamed, dtype=np.float64))
# --- TD Sequential (3-column multi-output) ------------------------------
def test_td_sequential_streaming_matches_batch(ohlcv):
high, low, close, volume = ohlcv
batch = ta.TDSequential().batch(high, low, close)
assert batch.shape == (close.size, 3)
streamer = ta.TDSequential()
rows = []
for i in range(close.size):
candle = (
float(close[i]),
float(high[i]),
float(low[i]),
float(close[i]),
float(volume[i]),
i,
)
v = streamer.update(candle)
rows.append([math.nan, math.nan, math.nan] if v is None else list(v))
assert _eq_nan(batch, np.array(rows, dtype=np.float64))
# --- ZeroLagMACD (scalar input, 3-tuple output: macd / signal / histogram) -
@@ -815,6 +888,124 @@ def test_z_score_reference():
assert out[1] == pytest.approx(1.0)
def test_td_setup_pure_uptrend_reaches_minus_9():
# Every close is strictly greater than four bars ago -> sell-setup -9.
h = np.arange(2.0, 22.0)
l = h - 1.0
c = h - 0.5
out = ta.TDSetup(4, 9).batch(h, l, c)
# Setup completes at index 12 (warmup is 5 -> first emit at index 4 with
# value -1, increments to -9 at index 12).
assert out[12] == pytest.approx(-9.0)
def test_td_demarker_uptrend_pegs_at_one():
# Strictly higher highs, strictly higher lows -> DeMax > 0, DeMin == 0
# -> indicator == 1 after warmup.
h = np.arange(11.0, 31.0)
l = h - 2.0
out = ta.TDDeMarker(5).batch(h, l)
assert out[-1] == pytest.approx(1.0)
def test_td_demarker_flat_market_emits_05():
# All highs and lows equal -> denominator is zero -> neutral fallback 0.5.
h = np.full(20, 11.0)
l = np.full(20, 9.0)
out = ta.TDDeMarker(5).batch(h, l)
assert out[-1] == pytest.approx(0.5)
def test_td_pressure_pure_bullish_yields_100():
# Every bar closes at its high (close == high, open == low) -> per-bar
# pressure ratio is +1 -> indicator == 100.
n = 20
open_ = np.full(n, 9.0)
high = np.full(n, 11.0)
low = np.full(n, 9.0)
close = np.full(n, 11.0)
volume = np.full(n, 100.0)
out = ta.TDPressure(5).batch(open_, high, low, close, volume)
assert out[-1] == pytest.approx(100.0)
def test_td_combo_uptrend_saturates_at_minus_13():
# Strictly increasing closes -> sell setup completes; combo conditions
# (close >= high[i-2], high[i] >= high[i-1], close > close[i-1]) all
# hold so combo saturates at -13.
n = 40
high = np.arange(1.0, 1.0 + n) + 0.5
low = high - 1.0
close = high - 0.5
out = ta.TDCombo().batch(high, low, close)
assert out[-1] == pytest.approx(-13.0)
def test_td_countdown_uptrend_saturates_at_minus_13():
n = 40
high = np.arange(1.0, 1.0 + n) + 0.5
low = high - 1.0
close = high - 0.5
out = ta.TDCountdown().batch(high, low, close)
assert out[-1] == pytest.approx(-13.0)
def test_td_lines_uptrend_sets_support_at_first_run_low():
# Strictly rising closes -> sell setup completes at idx 12; the
# lowest low among the setup bars (idx 4..=12) is the low at idx 4.
n = 20
high = np.arange(1.0, 1.0 + n) + 0.5
low = high - 1.0
close = high - 0.5
out = ta.TDLines().batch(high, low, close)
# support is column 1; resistance is NaN at -1.
assert math.isnan(out[-1, 0])
# low at idx 4 = 5 + 0.5 - 1.0 = 4.5.
assert out[-1, 1] == pytest.approx(4.5)
def test_td_range_projection_bullish_bar_reference():
# open=10, high=12, low=9, close=11 (close > open) ->
# pivot_sum = 2*12 + 9 + 11 = 44; half = 22.
# projHigh = 22 - 9 = 13; projLow = 22 - 12 = 10.
out = ta.TDRangeProjection().batch(
np.array([10.0]), np.array([12.0]), np.array([9.0]), np.array([11.0])
)
assert out[0, 0] == pytest.approx(13.0)
assert out[0, 1] == pytest.approx(10.0)
def test_td_differential_buy_signal_reference():
# Bar 0: high=10, low=8, close=9 -> warmup, returns None.
# Bar 1: high=9, low=7, close=8.5 -> close < prev.close, more buying
# pressure (1.5 > 1), less selling pressure (0.5 < 1) -> +1.
td = ta.TDDifferential()
assert td.update((9.0, 10.0, 8.0, 9.0, 1.0, 0)) is None
assert td.update((8.5, 9.0, 7.0, 8.5, 1.0, 1)) == pytest.approx(1.0)
def test_td_open_buy_signal_reference():
# Prev bar low=10. Curr open=9 < 10, curr high=11 > 10 -> +1.
td = ta.TDOpen()
assert td.update((10.0, 11.0, 10.0, 10.5, 1.0, 0)) is None
assert td.update((9.0, 11.0, 8.5, 9.5, 1.0, 1)) == pytest.approx(1.0)
def test_td_risk_level_uptrend_sets_sell_risk():
# Strictly rising closes -> sell setup completes at idx 12.
# The highest high is at idx 12 (= 13.5) with true range 1.5 ->
# sell_risk = 13.5 + 1.5 = 15.0. Subsequent setups re-ratchet the level
# so we check the first emission at idx 12.
n = 20
high = np.arange(1.0, 1.0 + n) + 0.5
low = high - 1.0
close = high - 0.5
out = ta.TDRiskLevel().batch(high, low, close)
# buy_risk is column 0; sell_risk is column 1.
assert math.isnan(out[12, 0])
assert out[12, 1] == pytest.approx(15.0)
def test_classic_pivots_reference():
# H=110, L=90, C=105 -> PP = 305/3, R1 = 2·PP L, S1 = 2·PP H.
cp = ta.ClassicPivots()
@@ -1013,6 +1204,14 @@ def test_new_indicators_expose_lifecycle():
instances += [ta.VwapStdDevBands(2.0)]
instances.append(ta.Alligator(13, 8, 5))
instances.append(ta.ZeroLagMACD(12, 26, 9))
instances += [
ta.TDPressure(5),
ta.TDSequential(),
ta.TDLines(),
ta.TDRiskLevel(),
ta.TDRangeProjection(),
ta.TDOpen(),
]
for ind in instances:
assert ind.is_ready() is False
assert ind.warmup_period() >= 1