feat(family-08): Pivots & Support/Resistance (7 indicators) (#47)
* feat(family-08): add Classic, Fibonacci, Camarilla, Woodie and DeMark pivots + Williams Fractals + ZigZag
Seven new indicators land the previously empty Pivots & S/R family
(family 08), each implemented in wickra-core with the full Indicator
trait surface (update / reset / warmup_period / is_ready / name),
exposed across Python (PyO3), Node (napi-rs) and WASM (wasm-bindgen)
with the standard streaming + batch APIs, and covered by Rust unit
tests, Python streaming-vs-batch + reference-value tests, Node
streaming-vs-batch tests, the candle-input fuzz target and Rust
microbenchmarks.
- ClassicPivots (7 levels): PP = (H+L+C)/3, three R/S tiers per the
floor-trader formulas.
- FibonacciPivots (7 levels): PP plus R/S spaced by 0.382 / 0.618 /
1.000 of the prior range.
- Camarilla (9 levels): Nick Stott's four-tier `C +/- (H - L) * 1.1 /
{12, 6, 4, 2}` levels.
- WoodiePivots (5 levels): close-weighted PP = (H + L + 2*C) / 4 plus
two R/S tiers.
- DemarkPivots (3 levels): conditional X sum based on the previous
bar's open-vs-close relationship.
- WilliamsFractals: five-bar swing detector emitting optional up/down
fractal prices at the centre of each window.
- ZigZag: percent-threshold swing tracker, non-repainting; emits the
just-completed extreme and direction on confirmed reversals only.
README family table updated to nine families / 78 indicators;
CHANGELOG records the family-08 addition under [Unreleased].
* fix(family-08 tests): unify MULTI dict to 3-tuple (factory, batch_call, k)
The HEAD-side family-08 test parametrised MULTI[name] as
`(factory, batch_call, output_arity)` so that pivots with arity 3/5/7/9
fit the same harness. Main's entries arrived as 2-tuples; convert them
all to the 3-tuple shape so `make, batch_call, k = MULTI[name]` unpacks
cleanly. Lifecycle test now indexes the tuple instead of destructuring.
* test(zig_zag): tighten flat-oscillation test (drop dead counter branch)
The previous version of `small_oscillations_yield_no_swings` counted
emitted swings, but the assertion proves the counter never increments
so codecov flagged `emitted += 1` as uncovered. Switch to a per-bar
`assert!(...is_none())` — same coverage of the no-swing path, no dead
branch.
This commit is contained in:
@@ -161,6 +161,14 @@ from ._wickra import (
|
||||
TtmSqueeze,
|
||||
FractalChaosBands,
|
||||
VwapStdDevBands,
|
||||
# Pivots & S/R
|
||||
ClassicPivots,
|
||||
FibonacciPivots,
|
||||
Camarilla,
|
||||
WoodiePivots,
|
||||
DemarkPivots,
|
||||
WilliamsFractals,
|
||||
ZigZag,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
@@ -301,4 +309,12 @@ __all__ = [
|
||||
"TtmSqueeze",
|
||||
"FractalChaosBands",
|
||||
"VwapStdDevBands",
|
||||
# Pivots & S/R
|
||||
"ClassicPivots",
|
||||
"FibonacciPivots",
|
||||
"Camarilla",
|
||||
"WoodiePivots",
|
||||
"DemarkPivots",
|
||||
"WilliamsFractals",
|
||||
"ZigZag",
|
||||
]
|
||||
|
||||
@@ -8047,6 +8047,534 @@ impl PyVwapStdDevBands {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================== Classic Pivots ==============================
|
||||
|
||||
#[pyclass(name = "ClassicPivots", module = "wickra._wickra", skip_from_py_object)]
|
||||
#[derive(Clone)]
|
||||
struct PyClassicPivots {
|
||||
inner: wc::ClassicPivots,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyClassicPivots {
|
||||
#[new]
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
inner: wc::ClassicPivots::new(),
|
||||
}
|
||||
}
|
||||
/// Returns `(pp, r1, r2, r3, s1, s2, s3)` or None during warmup.
|
||||
fn update(
|
||||
&mut self,
|
||||
candle: &Bound<'_, PyAny>,
|
||||
) -> PyResult<Option<(f64, f64, f64, f64, f64, f64, f64)>> {
|
||||
let c = extract_candle(candle)?;
|
||||
Ok(self
|
||||
.inner
|
||||
.update(c)
|
||||
.map(|o| (o.pp, o.r1, o.r2, o.r3, o.s1, o.s2, o.s3)))
|
||||
}
|
||||
/// Batch over numpy columns high, low, close. Returns shape `(n, 7)` for
|
||||
/// `[pp, r1, r2, r3, s1, s2, s3]`.
|
||||
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 * 7];
|
||||
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 * 7] = o.pp;
|
||||
out[i * 7 + 1] = o.r1;
|
||||
out[i * 7 + 2] = o.r2;
|
||||
out[i * 7 + 3] = o.r3;
|
||||
out[i * 7 + 4] = o.s1;
|
||||
out[i * 7 + 5] = o.s2;
|
||||
out[i * 7 + 6] = o.s3;
|
||||
}
|
||||
}
|
||||
Ok(numpy::ndarray::Array2::from_shape_vec((n, 7), 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()
|
||||
}
|
||||
}
|
||||
|
||||
// ============================== Fibonacci Pivots ==============================
|
||||
|
||||
#[pyclass(
|
||||
name = "FibonacciPivots",
|
||||
module = "wickra._wickra",
|
||||
skip_from_py_object
|
||||
)]
|
||||
#[derive(Clone)]
|
||||
struct PyFibonacciPivots {
|
||||
inner: wc::FibonacciPivots,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyFibonacciPivots {
|
||||
#[new]
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
inner: wc::FibonacciPivots::new(),
|
||||
}
|
||||
}
|
||||
fn update(
|
||||
&mut self,
|
||||
candle: &Bound<'_, PyAny>,
|
||||
) -> PyResult<Option<(f64, f64, f64, f64, f64, f64, f64)>> {
|
||||
let c = extract_candle(candle)?;
|
||||
Ok(self
|
||||
.inner
|
||||
.update(c)
|
||||
.map(|o| (o.pp, o.r1, o.r2, o.r3, o.s1, o.s2, o.s3)))
|
||||
}
|
||||
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 * 7];
|
||||
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 * 7] = o.pp;
|
||||
out[i * 7 + 1] = o.r1;
|
||||
out[i * 7 + 2] = o.r2;
|
||||
out[i * 7 + 3] = o.r3;
|
||||
out[i * 7 + 4] = o.s1;
|
||||
out[i * 7 + 5] = o.s2;
|
||||
out[i * 7 + 6] = o.s3;
|
||||
}
|
||||
}
|
||||
Ok(numpy::ndarray::Array2::from_shape_vec((n, 7), 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()
|
||||
}
|
||||
}
|
||||
|
||||
// ============================== Camarilla Pivots ==============================
|
||||
|
||||
#[pyclass(name = "Camarilla", module = "wickra._wickra", skip_from_py_object)]
|
||||
#[derive(Clone)]
|
||||
struct PyCamarilla {
|
||||
inner: wc::Camarilla,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyCamarilla {
|
||||
#[new]
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
inner: wc::Camarilla::new(),
|
||||
}
|
||||
}
|
||||
/// Returns `(pp, r1, r2, r3, r4, s1, s2, s3, s4)` or None during warmup.
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn update(
|
||||
&mut self,
|
||||
candle: &Bound<'_, PyAny>,
|
||||
) -> PyResult<Option<(f64, f64, f64, f64, f64, f64, f64, f64, f64)>> {
|
||||
let c = extract_candle(candle)?;
|
||||
Ok(self
|
||||
.inner
|
||||
.update(c)
|
||||
.map(|o| (o.pp, o.r1, o.r2, o.r3, o.r4, o.s1, o.s2, o.s3, o.s4)))
|
||||
}
|
||||
/// Batch over numpy columns high, low, close. Returns shape `(n, 9)` for
|
||||
/// `[pp, r1, r2, r3, r4, s1, s2, s3, s4]`.
|
||||
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 * 9];
|
||||
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 * 9] = o.pp;
|
||||
out[i * 9 + 1] = o.r1;
|
||||
out[i * 9 + 2] = o.r2;
|
||||
out[i * 9 + 3] = o.r3;
|
||||
out[i * 9 + 4] = o.r4;
|
||||
out[i * 9 + 5] = o.s1;
|
||||
out[i * 9 + 6] = o.s2;
|
||||
out[i * 9 + 7] = o.s3;
|
||||
out[i * 9 + 8] = o.s4;
|
||||
}
|
||||
}
|
||||
Ok(numpy::ndarray::Array2::from_shape_vec((n, 9), 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()
|
||||
}
|
||||
}
|
||||
|
||||
// ============================== Woodie Pivots ==============================
|
||||
|
||||
#[pyclass(name = "WoodiePivots", module = "wickra._wickra", skip_from_py_object)]
|
||||
#[derive(Clone)]
|
||||
struct PyWoodiePivots {
|
||||
inner: wc::WoodiePivots,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyWoodiePivots {
|
||||
#[new]
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
inner: wc::WoodiePivots::new(),
|
||||
}
|
||||
}
|
||||
/// Returns `(pp, r1, r2, s1, s2)` or None during warmup.
|
||||
fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult<Option<(f64, f64, f64, f64, f64)>> {
|
||||
let c = extract_candle(candle)?;
|
||||
Ok(self.inner.update(c).map(|o| (o.pp, o.r1, o.r2, o.s1, o.s2)))
|
||||
}
|
||||
/// Batch over numpy columns high, low, close. Returns shape `(n, 5)` for
|
||||
/// `[pp, r1, r2, s1, s2]`.
|
||||
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 * 5];
|
||||
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 * 5] = o.pp;
|
||||
out[i * 5 + 1] = o.r1;
|
||||
out[i * 5 + 2] = o.r2;
|
||||
out[i * 5 + 3] = o.s1;
|
||||
out[i * 5 + 4] = o.s2;
|
||||
}
|
||||
}
|
||||
Ok(numpy::ndarray::Array2::from_shape_vec((n, 5), 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()
|
||||
}
|
||||
}
|
||||
|
||||
// ============================== DeMark Pivots ==============================
|
||||
|
||||
#[pyclass(name = "DemarkPivots", module = "wickra._wickra", skip_from_py_object)]
|
||||
#[derive(Clone)]
|
||||
struct PyDemarkPivots {
|
||||
inner: wc::DemarkPivots,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyDemarkPivots {
|
||||
#[new]
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
inner: wc::DemarkPivots::new(),
|
||||
}
|
||||
}
|
||||
/// Returns `(pp, r1, s1)` 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.pp, o.r1, o.s1)))
|
||||
}
|
||||
/// Batch over numpy columns open, high, low, close. Returns shape `(n, 3)`
|
||||
/// for `[pp, r1, s1]`.
|
||||
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 * 3];
|
||||
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(v) = self.inner.update(candle) {
|
||||
out[i * 3] = v.pp;
|
||||
out[i * 3 + 1] = v.r1;
|
||||
out[i * 3 + 2] = v.s1;
|
||||
}
|
||||
}
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
// ============================== Williams Fractals ==============================
|
||||
|
||||
#[pyclass(
|
||||
name = "WilliamsFractals",
|
||||
module = "wickra._wickra",
|
||||
skip_from_py_object
|
||||
)]
|
||||
#[derive(Clone)]
|
||||
struct PyWilliamsFractals {
|
||||
inner: wc::WilliamsFractals,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyWilliamsFractals {
|
||||
#[new]
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
inner: wc::WilliamsFractals::new(),
|
||||
}
|
||||
}
|
||||
/// Returns `(up, down)` where each component is either the fractal price
|
||||
/// or `None` if no fractal was confirmed at the centre of the current
|
||||
/// 5-bar window. The outer `None` is returned during warmup (first 4 bars).
|
||||
fn update(
|
||||
&mut self,
|
||||
candle: &Bound<'_, PyAny>,
|
||||
) -> PyResult<Option<(Option<f64>, Option<f64>)>> {
|
||||
let c = extract_candle(candle)?;
|
||||
Ok(self.inner.update(c).map(|o| (o.up, o.down)))
|
||||
}
|
||||
/// Batch over numpy columns high, low. Returns shape `(n, 2)` for
|
||||
/// `[up_fractal, down_fractal]`. Values are NaN both during warmup and on
|
||||
/// bars where no fractal was confirmed.
|
||||
fn batch<'py>(
|
||||
&mut self,
|
||||
py: Python<'py>,
|
||||
high: PyReadonlyArray1<'py, f64>,
|
||||
low: 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))?;
|
||||
if h.len() != l.len() {
|
||||
return Err(PyValueError::new_err("high and low 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(l[i], h[i], l[i], l[i], 0.0, 0).map_err(map_err)?;
|
||||
if let Some(o) = self.inner.update(candle) {
|
||||
if let Some(v) = o.up {
|
||||
out[i * 2] = v;
|
||||
}
|
||||
if let Some(v) = o.down {
|
||||
out[i * 2 + 1] = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
// ============================== ZigZag ==============================
|
||||
|
||||
#[pyclass(name = "ZigZag", module = "wickra._wickra", skip_from_py_object)]
|
||||
#[derive(Clone)]
|
||||
struct PyZigZag {
|
||||
inner: wc::ZigZag,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyZigZag {
|
||||
#[new]
|
||||
#[pyo3(signature = (threshold=0.05))]
|
||||
fn new(threshold: f64) -> PyResult<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::ZigZag::new(threshold).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
/// Returns `(swing, direction)` if a swing was confirmed on this bar,
|
||||
/// else `None`.
|
||||
fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult<Option<(f64, f64)>> {
|
||||
let c = extract_candle(candle)?;
|
||||
Ok(self.inner.update(c).map(|o| (o.swing, o.direction)))
|
||||
}
|
||||
/// Batch over numpy columns high, low. Returns shape `(n, 2)` for
|
||||
/// `[swing_price, direction]`. NaN on bars without a confirmed swing.
|
||||
fn batch<'py>(
|
||||
&mut self,
|
||||
py: Python<'py>,
|
||||
high: PyReadonlyArray1<'py, f64>,
|
||||
low: 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))?;
|
||||
if h.len() != l.len() {
|
||||
return Err(PyValueError::new_err("high and low 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(l[i], h[i], l[i], l[i], 0.0, 0).map_err(map_err)?;
|
||||
if let Some(o) = self.inner.update(candle) {
|
||||
out[i * 2] = o.swing;
|
||||
out[i * 2 + 1] = o.direction;
|
||||
}
|
||||
}
|
||||
Ok(numpy::ndarray::Array2::from_shape_vec((n, 2), out)
|
||||
.expect("shape consistent")
|
||||
.into_pyarray(py))
|
||||
}
|
||||
#[getter]
|
||||
fn threshold(&self) -> f64 {
|
||||
self.inner.threshold()
|
||||
}
|
||||
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]
|
||||
@@ -8183,5 +8711,12 @@ fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
|
||||
m.add_class::<PyTtmSqueeze>()?;
|
||||
m.add_class::<PyFractalChaosBands>()?;
|
||||
m.add_class::<PyVwapStdDevBands>()?;
|
||||
m.add_class::<PyClassicPivots>()?;
|
||||
m.add_class::<PyFibonacciPivots>()?;
|
||||
m.add_class::<PyCamarilla>()?;
|
||||
m.add_class::<PyWoodiePivots>()?;
|
||||
m.add_class::<PyDemarkPivots>()?;
|
||||
m.add_class::<PyWilliamsFractals>()?;
|
||||
m.add_class::<PyZigZag>()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -304,38 +304,90 @@ def test_candle_scalar_streaming_matches_batch(name, ohlcv):
|
||||
# --- Candle-input, multi-output indicators --------------------------------
|
||||
|
||||
MULTI = {
|
||||
"Vortex": (lambda: ta.Vortex(14), lambda ind, h, l, c, v: ind.batch(h, l, c)),
|
||||
"RWI": (lambda: ta.RWI(14), lambda ind, h, l, c, v: ind.batch(h, l, c)),
|
||||
"Vortex": (
|
||||
lambda: ta.Vortex(14),
|
||||
lambda ind, h, l, c, v: ind.batch(h, l, c),
|
||||
2,
|
||||
),
|
||||
"RWI": (
|
||||
lambda: ta.RWI(14),
|
||||
lambda ind, h, l, c, v: ind.batch(h, l, c),
|
||||
2,
|
||||
),
|
||||
"WaveTrend": (
|
||||
lambda: ta.WaveTrend.classic(),
|
||||
lambda ind, h, l, c, v: ind.batch(h, l, c),
|
||||
2,
|
||||
),
|
||||
"SuperTrend": (
|
||||
lambda: ta.SuperTrend(10, 3.0),
|
||||
lambda ind, h, l, c, v: ind.batch(h, l, c),
|
||||
2,
|
||||
),
|
||||
"ChandelierExit": (
|
||||
lambda: ta.ChandelierExit(22, 3.0),
|
||||
lambda ind, h, l, c, v: ind.batch(h, l, c),
|
||||
2,
|
||||
),
|
||||
"ChandeKrollStop": (
|
||||
lambda: ta.ChandeKrollStop(10, 1.0, 9),
|
||||
lambda ind, h, l, c, v: ind.batch(h, l, c),
|
||||
2,
|
||||
),
|
||||
"ClassicPivots": (
|
||||
lambda: ta.ClassicPivots(),
|
||||
lambda ind, h, l, c, v: ind.batch(h, l, c),
|
||||
7,
|
||||
),
|
||||
"FibonacciPivots": (
|
||||
lambda: ta.FibonacciPivots(),
|
||||
lambda ind, h, l, c, v: ind.batch(h, l, c),
|
||||
7,
|
||||
),
|
||||
"Camarilla": (
|
||||
lambda: ta.Camarilla(),
|
||||
lambda ind, h, l, c, v: ind.batch(h, l, c),
|
||||
9,
|
||||
),
|
||||
"WoodiePivots": (
|
||||
lambda: ta.WoodiePivots(),
|
||||
lambda ind, h, l, c, v: ind.batch(h, l, c),
|
||||
5,
|
||||
),
|
||||
"DemarkPivots": (
|
||||
# batch needs open; pass close in for open since the synthetic OHLCV
|
||||
# streaming feeds open=close as well.
|
||||
lambda: ta.DemarkPivots(),
|
||||
lambda ind, h, l, c, v: ind.batch(c, h, l, c),
|
||||
3,
|
||||
),
|
||||
"WilliamsFractals": (
|
||||
lambda: ta.WilliamsFractals(),
|
||||
lambda ind, h, l, c, v: ind.batch(h, l),
|
||||
2,
|
||||
),
|
||||
"ZigZag": (
|
||||
lambda: ta.ZigZag(0.02),
|
||||
lambda ind, h, l, c, v: ind.batch(h, l),
|
||||
2,
|
||||
),
|
||||
"DonchianStop": (
|
||||
lambda: ta.DonchianStop(10),
|
||||
lambda ind, h, l, c, v: ind.batch(h, l),
|
||||
2,
|
||||
),
|
||||
# Family 05 candle-input bands. Each entry is
|
||||
# `(factory, batch_call, output_arity, streaming_fields)` where
|
||||
# `streaming_fields` is the tuple shape returned by `update(...)`.
|
||||
# `(factory, batch_call, output_arity)` where the third element is the
|
||||
# tuple shape returned by `update(...)`.
|
||||
"TtmSqueeze": (
|
||||
lambda: ta.TtmSqueeze(20, 2.0, 1.5),
|
||||
lambda ind, h, l, c, v: ind.batch(h, l, c),
|
||||
2,
|
||||
),
|
||||
"FractalChaosBands": (
|
||||
lambda: ta.FractalChaosBands(2),
|
||||
lambda ind, h, l, c, v: ind.batch(h, l),
|
||||
2,
|
||||
),
|
||||
}
|
||||
|
||||
@@ -364,10 +416,10 @@ MULTI_SCALAR_INPUT = {
|
||||
@pytest.mark.parametrize("name", list(MULTI))
|
||||
def test_multi_streaming_matches_batch(name, ohlcv):
|
||||
high, low, close, volume = ohlcv
|
||||
make, batch_call = MULTI[name]
|
||||
make, batch_call, k = MULTI[name]
|
||||
|
||||
batch = batch_call(make(), high, low, close, volume)
|
||||
assert batch.shape == (close.size, 2)
|
||||
assert batch.shape == (close.size, k)
|
||||
|
||||
streamer = make()
|
||||
rows = []
|
||||
@@ -381,7 +433,13 @@ def test_multi_streaming_matches_batch(name, ohlcv):
|
||||
i,
|
||||
)
|
||||
v = streamer.update(candle)
|
||||
rows.append([math.nan, math.nan] if v is None else list(v))
|
||||
if v is None:
|
||||
rows.append([math.nan] * k)
|
||||
else:
|
||||
# WilliamsFractals returns (Optional[float], Optional[float]); the
|
||||
# batch helper writes NaN for None. Normalise tuple/list entries
|
||||
# the same way before the equality check.
|
||||
rows.append([math.nan if x is None else float(x) for x in v])
|
||||
assert _eq_nan(batch, np.array(rows, dtype=np.float64)), f"{name} mismatch"
|
||||
|
||||
|
||||
@@ -757,6 +815,89 @@ def test_z_score_reference():
|
||||
assert out[1] == pytest.approx(1.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()
|
||||
pp, r1, r2, r3, s1, s2, s3 = cp.update((105.0, 110.0, 90.0, 105.0, 1.0, 0))
|
||||
expected_pp = 305.0 / 3.0
|
||||
assert pp == pytest.approx(expected_pp)
|
||||
assert r1 == pytest.approx(2 * expected_pp - 90.0)
|
||||
assert s1 == pytest.approx(2 * expected_pp - 110.0)
|
||||
assert r2 == pytest.approx(expected_pp + 20.0)
|
||||
assert s2 == pytest.approx(expected_pp - 20.0)
|
||||
assert r3 > r2 and s3 < s2
|
||||
|
||||
|
||||
def test_fibonacci_pivots_reference():
|
||||
# H=110, L=90, C=100 -> PP=100, range=20, R1=PP+0.382·range, etc.
|
||||
fp = ta.FibonacciPivots()
|
||||
pp, r1, r2, r3, s1, s2, s3 = fp.update((100.0, 110.0, 90.0, 100.0, 1.0, 0))
|
||||
assert pp == pytest.approx(100.0)
|
||||
assert r1 == pytest.approx(100.0 + 0.382 * 20.0)
|
||||
assert r2 == pytest.approx(100.0 + 0.618 * 20.0)
|
||||
assert r3 == pytest.approx(100.0 + 20.0)
|
||||
assert s1 == pytest.approx(100.0 - 0.382 * 20.0)
|
||||
assert s3 == pytest.approx(100.0 - 20.0)
|
||||
|
||||
|
||||
def test_camarilla_pivots_reference():
|
||||
# H=110, L=90, C=105 -> R4 = C + range · 1.1 / 2 = 105 + 11 = 116.
|
||||
cm = ta.Camarilla()
|
||||
pp, r1, r2, r3, r4, s1, s2, s3, s4 = cm.update((105.0, 110.0, 90.0, 105.0, 1.0, 0))
|
||||
range_ = 20.0
|
||||
assert r1 == pytest.approx(105.0 + range_ * 1.1 / 12.0)
|
||||
assert r4 == pytest.approx(105.0 + range_ * 1.1 / 2.0)
|
||||
assert s4 == pytest.approx(105.0 - range_ * 1.1 / 2.0)
|
||||
assert pp == pytest.approx(305.0 / 3.0)
|
||||
# Strict widening with index.
|
||||
assert r4 > r3 > r2 > r1
|
||||
assert s4 < s3 < s2 < s1
|
||||
|
||||
|
||||
def test_woodie_pivots_reference():
|
||||
# H=110, L=90, C=108 -> PP = (110 + 90 + 216) / 4 = 104.
|
||||
wp = ta.WoodiePivots()
|
||||
pp, r1, r2, s1, s2 = wp.update((108.0, 110.0, 90.0, 108.0, 1.0, 0))
|
||||
assert pp == pytest.approx(104.0)
|
||||
assert r1 == pytest.approx(2 * 104.0 - 90.0)
|
||||
assert s1 == pytest.approx(2 * 104.0 - 110.0)
|
||||
assert r2 == pytest.approx(104.0 + 20.0)
|
||||
assert s2 == pytest.approx(104.0 - 20.0)
|
||||
|
||||
|
||||
def test_demark_pivots_up_bar_reference():
|
||||
# Up bar: O=100, H=120, L=80, C=110 -> X = H + 2L + C = 390, PP = 97.5.
|
||||
dp = ta.DemarkPivots()
|
||||
pp, r1, s1 = dp.update((100.0, 120.0, 80.0, 110.0, 1.0, 0))
|
||||
assert pp == pytest.approx(97.5)
|
||||
assert r1 == pytest.approx(195.0 - 80.0)
|
||||
assert s1 == pytest.approx(195.0 - 120.0)
|
||||
|
||||
|
||||
def test_williams_fractals_isolated_peak():
|
||||
# Highs 1, 2, 5, 2, 1; the centre is strictly above both neighbours.
|
||||
wf = ta.WilliamsFractals()
|
||||
last = None
|
||||
for i, h in enumerate([1.0, 2.0, 5.0, 2.0, 1.0]):
|
||||
last = wf.update((h, h, h - 0.5, h, 1.0, i))
|
||||
assert last is not None
|
||||
up, down = last
|
||||
assert up == pytest.approx(5.0)
|
||||
assert down is None
|
||||
|
||||
|
||||
def test_zigzag_confirms_after_threshold_reversal():
|
||||
# 100 -> 120 (uptrend pivot) -> 100 (16.7% drop confirms swing high).
|
||||
zz = ta.ZigZag(0.10)
|
||||
assert zz.update((100.0, 100.5, 99.5, 100.0, 1.0, 0)) is None
|
||||
assert zz.update((120.0, 120.5, 119.5, 120.0, 1.0, 1)) is None
|
||||
confirmed = zz.update((100.0, 100.5, 99.5, 100.0, 1.0, 2))
|
||||
assert confirmed is not None
|
||||
swing, direction = confirmed
|
||||
assert swing == pytest.approx(120.5)
|
||||
assert direction == 1.0
|
||||
|
||||
|
||||
# --- Family 05 reference values ---------------------------------------------
|
||||
|
||||
|
||||
@@ -864,7 +1005,7 @@ def test_fractal_chaos_bands_detects_peak_and_trough():
|
||||
|
||||
def test_new_indicators_expose_lifecycle():
|
||||
instances = [make() for make, _ in CANDLE_SCALAR.values()]
|
||||
instances += [make() for make, _ in MULTI.values()]
|
||||
instances += [t[0]() for t in MULTI.values()]
|
||||
instances += [make() for make, _ in MULTI_SCALAR_INPUT.values()]
|
||||
instances += [cls(*args) for cls, args in SCALAR]
|
||||
instances += [make() for make, _ in SCALAR_MULTI.values()]
|
||||
|
||||
Reference in New Issue
Block a user