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:
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user