feat: add Pivots & S/R indicators (B11) (#201)

Adds five support/resistance and pivot indicators, growing the catalog 462 -> 467.

## Indicators
- **CentralPivotRange** (Candle -> struct) — the classic pivot `(H+L+C)/3` flanked by two central levels (TC/BC); range width gauges trending vs balanced days.
- **MurreyMathLines** (Candle -> struct) — T. H. Murrey's eighths grid over a rolling high-low frame; nine levels (0/8 .. 8/8) acting as support/resistance.
- **AndrewsPitchfork** (Candle -> struct) — median line and two parallels projected forward from the last three auto-detected swing pivots (symmetric fractal of half-width `strength`).
- **VolumeWeightedSr** (Candle -> struct) — a band whose edges are the volume-weighted average of recent highs (resistance) and lows (support); falls back to equal weighting when window volume is zero.
- **PivotReversal** (Candle -> f64) — a `+1`/`-1` breakout signal fired on the bar where price closes through the most recently confirmed swing pivot.

## Wiring
Core structs with branch-complete unit tests, Python/Node/WASM bindings, fuzz drives, reference + streaming-vs-batch tests, README + docs counter sync (FAMILIES "Pivots & S/R"), and CHANGELOG entries.

Verified locally: `cargo fmt`, `cargo test -p wickra-core` (3798 lib + 425 doc), `cargo clippy --workspace --all-targets --all-features -D warnings`, `npm run build && npm test` (542), `maturin develop` + `pytest` (891).
This commit is contained in:
kingchenc
2026-06-08 00:13:42 +02:00
committed by GitHub
parent 4526278fa0
commit e97c3389fe
19 changed files with 2614 additions and 43 deletions
@@ -377,6 +377,7 @@ const candleScalar = {
IntradayIntensity: { make: () => new wickra.IntradayIntensity(), step: (ind, i) => ind.update(high[i], low[i], close[i], volume[i]), batch: (ind) => ind.batch(high, low, close, volume) },
BetterVolume: { make: () => new wickra.BetterVolume(14), step: (ind, i) => ind.update(high[i], low[i], close[i], volume[i]), batch: (ind) => ind.batch(high, low, close, volume) },
ADAPTIVECCI: { make: () => new wickra.ADAPTIVECCI(20), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
PivotReversal: { make: () => new wickra.PivotReversal(1, 1), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
};
for (const [name, d] of Object.entries(candleScalar)) {
@@ -474,6 +475,10 @@ const multi = {
Nrtr: { make: () => new wickra.Nrtr(2.0), fields: ['value', 'direction'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
ModifiedMaStop: { make: () => new wickra.ModifiedMaStop(14), fields: ['value', 'direction'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
VolumeWeightedMacd: { make: () => new wickra.VolumeWeightedMacd(12, 26, 9), fields: ['macd', 'signal', 'histogram'], step: (ind, i) => ind.update(close[i], volume[i]), batch: (ind) => ind.batch(close, volume) },
CentralPivotRange: { make: () => new wickra.CentralPivotRange(), fields: ['pivot', 'tc', 'bc'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
MurreyMathLines: { make: () => new wickra.MurreyMathLines(4), fields: ['mm8_8', 'mm7_8', 'mm6_8', 'mm5_8', 'mm4_8', 'mm3_8', 'mm2_8', 'mm1_8', 'mm0_8'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
AndrewsPitchfork: { make: () => new wickra.AndrewsPitchfork(2), fields: ['median', 'upper', 'lower'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
VolumeWeightedSr: { make: () => new wickra.VolumeWeightedSr(3), fields: ['support', 'resistance'], step: (ind, i) => ind.update(high[i], low[i], volume[i]), batch: (ind) => ind.batch(high, low, volume) },
};
for (const [name, d] of Object.entries(multi)) {
+70
View File
@@ -239,6 +239,31 @@ export interface ProjectionBandsValue {
middle: number
lower: number
}
export interface CentralPivotRangeValue {
pivot: number
tc: number
bc: number
}
export interface MurreyMathLinesValue {
mm8_8: number
mm7_8: number
mm6_8: number
mm5_8: number
mm4_8: number
mm3_8: number
mm2_8: number
mm1_8: number
mm0_8: number
}
export interface AndrewsPitchforkValue {
median: number
upper: number
lower: number
}
export interface VolumeWeightedSrValue {
support: number
resistance: number
}
export interface DoubleBollingerValue {
upperOuter: number
upperInner: number
@@ -2930,6 +2955,51 @@ export declare class ProjectionBands {
isReady(): boolean
warmupPeriod(): number
}
export type CentralPivotRangeNode = CentralPivotRange
export declare class CentralPivotRange {
constructor()
update(high: number, low: number, close: number): CentralPivotRangeValue | null
batch(high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type MurreyMathLinesNode = MurreyMathLines
export declare class MurreyMathLines {
constructor(period: number)
update(high: number, low: number): MurreyMathLinesValue | null
batch(high: Array<number>, low: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type AndrewsPitchforkNode = AndrewsPitchfork
export declare class AndrewsPitchfork {
constructor(strength: number)
update(high: number, low: number): AndrewsPitchforkValue | null
batch(high: Array<number>, low: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type VolumeWeightedSrNode = VolumeWeightedSr
export declare class VolumeWeightedSr {
constructor(period: number)
update(high: number, low: number, volume: number): VolumeWeightedSrValue | null
batch(high: Array<number>, low: Array<number>, volume: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type PivotReversalNode = PivotReversal
export declare class PivotReversal {
constructor(left: number, right: number)
update(high: number, low: number, close: number): number | null
batch(high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type DoubleBollingerNode = DoubleBollinger
export declare class DoubleBollinger {
constructor(period: number, kInner: number, kOuter: number)
File diff suppressed because one or more lines are too long
+365
View File
@@ -9491,6 +9491,371 @@ impl ProjectionBandsNode {
}
}
// ---------- Central Pivot Range ----------
#[napi(object)]
pub struct CentralPivotRangeValue {
pub pivot: f64,
pub tc: f64,
pub bc: f64,
}
#[napi(js_name = "CentralPivotRange")]
pub struct CentralPivotRangeNode {
inner: wc::CentralPivotRange,
}
impl Default for CentralPivotRangeNode {
fn default() -> Self {
Self::new()
}
}
#[napi]
impl CentralPivotRangeNode {
#[napi(constructor)]
pub fn new() -> Self {
Self {
inner: wc::CentralPivotRange::new(),
}
}
#[napi]
pub fn update(
&mut self,
high: f64,
low: f64,
close: f64,
) -> napi::Result<Option<CentralPivotRangeValue>> {
Ok(self
.inner
.update(cnd(high, low, close, 0.0)?)
.map(|o| CentralPivotRangeValue {
pivot: o.pivot,
tc: o.tc,
bc: o.bc,
}))
}
#[napi]
pub fn batch(
&mut self,
high: Vec<f64>,
low: Vec<f64>,
close: Vec<f64>,
) -> napi::Result<Vec<f64>> {
if high.len() != low.len() || low.len() != close.len() {
return Err(NapiError::from_reason(
"high, low, close must be equal length".to_string(),
));
}
let n = high.len();
let mut out = vec![f64::NAN; n * 3];
for i in 0..n {
if let Some(o) = self.inner.update(cnd(high[i], low[i], close[i], 0.0)?) {
out[i * 3] = o.pivot;
out[i * 3 + 1] = o.tc;
out[i * 3 + 2] = o.bc;
}
}
Ok(out)
}
#[napi]
pub fn reset(&mut self) {
self.inner.reset();
}
#[napi(js_name = "isReady")]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[napi(js_name = "warmupPeriod")]
pub fn warmup_period(&self) -> u32 {
self.inner.warmup_period() as u32
}
}
// ---------- Murrey Math Lines ----------
#[napi(object)]
pub struct MurreyMathLinesValue {
#[napi(js_name = "mm8_8")]
pub mm8_8: f64,
#[napi(js_name = "mm7_8")]
pub mm7_8: f64,
#[napi(js_name = "mm6_8")]
pub mm6_8: f64,
#[napi(js_name = "mm5_8")]
pub mm5_8: f64,
#[napi(js_name = "mm4_8")]
pub mm4_8: f64,
#[napi(js_name = "mm3_8")]
pub mm3_8: f64,
#[napi(js_name = "mm2_8")]
pub mm2_8: f64,
#[napi(js_name = "mm1_8")]
pub mm1_8: f64,
#[napi(js_name = "mm0_8")]
pub mm0_8: f64,
}
#[napi(js_name = "MurreyMathLines")]
pub struct MurreyMathLinesNode {
inner: wc::MurreyMathLines,
}
#[napi]
impl MurreyMathLinesNode {
#[napi(constructor)]
pub fn new(period: u32) -> napi::Result<Self> {
Ok(Self {
inner: wc::MurreyMathLines::new(period as usize).map_err(map_err)?,
})
}
#[napi]
pub fn update(&mut self, high: f64, low: f64) -> napi::Result<Option<MurreyMathLinesValue>> {
Ok(self
.inner
.update(cnd(high, low, low, 0.0)?)
.map(|o| MurreyMathLinesValue {
mm8_8: o.mm8_8,
mm7_8: o.mm7_8,
mm6_8: o.mm6_8,
mm5_8: o.mm5_8,
mm4_8: o.mm4_8,
mm3_8: o.mm3_8,
mm2_8: o.mm2_8,
mm1_8: o.mm1_8,
mm0_8: o.mm0_8,
}))
}
#[napi]
pub fn batch(&mut self, high: Vec<f64>, low: Vec<f64>) -> napi::Result<Vec<f64>> {
if high.len() != low.len() {
return Err(NapiError::from_reason(
"high and low must be equal length".to_string(),
));
}
let n = high.len();
let mut out = vec![f64::NAN; n * 9];
for i in 0..n {
if let Some(o) = self.inner.update(cnd(high[i], low[i], low[i], 0.0)?) {
out[i * 9] = o.mm8_8;
out[i * 9 + 1] = o.mm7_8;
out[i * 9 + 2] = o.mm6_8;
out[i * 9 + 3] = o.mm5_8;
out[i * 9 + 4] = o.mm4_8;
out[i * 9 + 5] = o.mm3_8;
out[i * 9 + 6] = o.mm2_8;
out[i * 9 + 7] = o.mm1_8;
out[i * 9 + 8] = o.mm0_8;
}
}
Ok(out)
}
#[napi]
pub fn reset(&mut self) {
self.inner.reset();
}
#[napi(js_name = "isReady")]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[napi(js_name = "warmupPeriod")]
pub fn warmup_period(&self) -> u32 {
self.inner.warmup_period() as u32
}
}
// ---------- Andrews Pitchfork ----------
#[napi(object)]
pub struct AndrewsPitchforkValue {
pub median: f64,
pub upper: f64,
pub lower: f64,
}
#[napi(js_name = "AndrewsPitchfork")]
pub struct AndrewsPitchforkNode {
inner: wc::AndrewsPitchfork,
}
#[napi]
impl AndrewsPitchforkNode {
#[napi(constructor)]
pub fn new(strength: u32) -> napi::Result<Self> {
Ok(Self {
inner: wc::AndrewsPitchfork::new(strength as usize).map_err(map_err)?,
})
}
#[napi]
pub fn update(&mut self, high: f64, low: f64) -> napi::Result<Option<AndrewsPitchforkValue>> {
Ok(self
.inner
.update(cnd(high, low, low, 0.0)?)
.map(|o| AndrewsPitchforkValue {
median: o.median,
upper: o.upper,
lower: o.lower,
}))
}
#[napi]
pub fn batch(&mut self, high: Vec<f64>, low: Vec<f64>) -> napi::Result<Vec<f64>> {
if high.len() != low.len() {
return Err(NapiError::from_reason(
"high and low must be equal length".to_string(),
));
}
let n = high.len();
let mut out = vec![f64::NAN; n * 3];
for i in 0..n {
if let Some(o) = self.inner.update(cnd(high[i], low[i], low[i], 0.0)?) {
out[i * 3] = o.median;
out[i * 3 + 1] = o.upper;
out[i * 3 + 2] = o.lower;
}
}
Ok(out)
}
#[napi]
pub fn reset(&mut self) {
self.inner.reset();
}
#[napi(js_name = "isReady")]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[napi(js_name = "warmupPeriod")]
pub fn warmup_period(&self) -> u32 {
self.inner.warmup_period() as u32
}
}
// ---------- Volume-Weighted S/R ----------
#[napi(object)]
pub struct VolumeWeightedSrValue {
pub support: f64,
pub resistance: f64,
}
#[napi(js_name = "VolumeWeightedSr")]
pub struct VolumeWeightedSrNode {
inner: wc::VolumeWeightedSr,
}
#[napi]
impl VolumeWeightedSrNode {
#[napi(constructor)]
pub fn new(period: u32) -> napi::Result<Self> {
Ok(Self {
inner: wc::VolumeWeightedSr::new(period as usize).map_err(map_err)?,
})
}
#[napi]
pub fn update(
&mut self,
high: f64,
low: f64,
volume: f64,
) -> napi::Result<Option<VolumeWeightedSrValue>> {
Ok(self
.inner
.update(cnd(high, low, low, volume)?)
.map(|o| VolumeWeightedSrValue {
support: o.support,
resistance: o.resistance,
}))
}
#[napi]
pub fn batch(
&mut self,
high: Vec<f64>,
low: Vec<f64>,
volume: Vec<f64>,
) -> napi::Result<Vec<f64>> {
if high.len() != low.len() || low.len() != volume.len() {
return Err(NapiError::from_reason(
"high, low, volume must be equal length".to_string(),
));
}
let n = high.len();
let mut out = vec![f64::NAN; n * 2];
for i in 0..n {
if let Some(o) = self.inner.update(cnd(high[i], low[i], low[i], volume[i])?) {
out[i * 2] = o.support;
out[i * 2 + 1] = o.resistance;
}
}
Ok(out)
}
#[napi]
pub fn reset(&mut self) {
self.inner.reset();
}
#[napi(js_name = "isReady")]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[napi(js_name = "warmupPeriod")]
pub fn warmup_period(&self) -> u32 {
self.inner.warmup_period() as u32
}
}
// ---------- Pivot Reversal ----------
#[napi(js_name = "PivotReversal")]
pub struct PivotReversalNode {
inner: wc::PivotReversal,
}
#[napi]
impl PivotReversalNode {
#[napi(constructor)]
pub fn new(left: u32, right: u32) -> napi::Result<Self> {
Ok(Self {
inner: wc::PivotReversal::new(left as usize, right as usize).map_err(map_err)?,
})
}
#[napi]
pub fn update(&mut self, high: f64, low: f64, close: f64) -> napi::Result<Option<f64>> {
Ok(self.inner.update(cnd(high, low, close, 0.0)?))
}
#[napi]
pub fn batch(
&mut self,
high: Vec<f64>,
low: Vec<f64>,
close: Vec<f64>,
) -> napi::Result<Vec<f64>> {
if high.len() != low.len() || low.len() != close.len() {
return Err(NapiError::from_reason(
"high, low, close must be equal length".to_string(),
));
}
let mut out = Vec::with_capacity(high.len());
for i in 0..high.len() {
out.push(
self.inner
.update(cnd(high[i], low[i], close[i], 0.0)?)
.unwrap_or(f64::NAN),
);
}
Ok(out)
}
#[napi]
pub fn reset(&mut self) {
self.inner.reset();
}
#[napi(js_name = "isReady")]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[napi(js_name = "warmupPeriod")]
pub fn warmup_period(&self) -> u32 {
self.inner.warmup_period() as u32
}
}
// ---------- Double Bollinger ----------
#[napi(object)]
+10
View File
@@ -309,6 +309,11 @@ from ._wickra import (
FractalChaosBands,
VwapStdDevBands,
# Pivots & S/R
PivotReversal,
VolumeWeightedSr,
AndrewsPitchfork,
MurreyMathLines,
CentralPivotRange,
ClassicPivots,
FibonacciPivots,
Camarilla,
@@ -801,6 +806,11 @@ __all__ = [
"FractalChaosBands",
"VwapStdDevBands",
# Pivots & S/R
"PivotReversal",
"VolumeWeightedSr",
"AndrewsPitchfork",
"MurreyMathLines",
"CentralPivotRange",
"ClassicPivots",
"FibonacciPivots",
"Camarilla",
+354
View File
@@ -12513,6 +12513,355 @@ impl PyProjectionBands {
}
}
// ============================== Central Pivot Range ==============================
#[pyclass(
name = "CentralPivotRange",
module = "wickra._wickra",
skip_from_py_object
)]
#[derive(Clone)]
struct PyCentralPivotRange {
inner: wc::CentralPivotRange,
}
#[pymethods]
impl PyCentralPivotRange {
#[new]
fn new() -> Self {
Self {
inner: wc::CentralPivotRange::new(),
}
}
/// Returns `(pivot, tc, bc)`.
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.pivot, o.tc, o.bc)))
}
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.pivot;
out[i * 3 + 1] = o.tc;
out[i * 3 + 2] = o.bc;
}
}
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()
}
}
// ============================== Murrey Math Lines ==============================
#[pyclass(
name = "MurreyMathLines",
module = "wickra._wickra",
skip_from_py_object
)]
#[derive(Clone)]
struct PyMurreyMathLines {
inner: wc::MurreyMathLines,
}
#[pymethods]
impl PyMurreyMathLines {
#[new]
#[pyo3(signature = (period=64))]
fn new(period: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::MurreyMathLines::new(period).map_err(map_err)?,
})
}
/// Returns `(mm8_8, mm7_8, mm6_8, mm5_8, mm4_8, mm3_8, mm2_8, mm1_8, mm0_8)`.
#[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.mm8_8, o.mm7_8, o.mm6_8, o.mm5_8, o.mm4_8, o.mm3_8, o.mm2_8, o.mm1_8, o.mm0_8,
)
}))
}
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 * 9];
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 * 9] = o.mm8_8;
out[i * 9 + 1] = o.mm7_8;
out[i * 9 + 2] = o.mm6_8;
out[i * 9 + 3] = o.mm5_8;
out[i * 9 + 4] = o.mm4_8;
out[i * 9 + 5] = o.mm3_8;
out[i * 9 + 6] = o.mm2_8;
out[i * 9 + 7] = o.mm1_8;
out[i * 9 + 8] = o.mm0_8;
}
}
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()
}
}
// ============================== Andrews Pitchfork ==============================
#[pyclass(
name = "AndrewsPitchfork",
module = "wickra._wickra",
skip_from_py_object
)]
#[derive(Clone)]
struct PyAndrewsPitchfork {
inner: wc::AndrewsPitchfork,
}
#[pymethods]
impl PyAndrewsPitchfork {
#[new]
#[pyo3(signature = (strength=2))]
fn new(strength: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::AndrewsPitchfork::new(strength).map_err(map_err)?,
})
}
/// Returns `(median, upper, lower)`.
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.median, o.upper, o.lower)))
}
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 * 3];
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 * 3] = o.median;
out[i * 3 + 1] = o.upper;
out[i * 3 + 2] = o.lower;
}
}
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()
}
}
// ============================== Volume-Weighted S/R ==============================
#[pyclass(
name = "VolumeWeightedSr",
module = "wickra._wickra",
skip_from_py_object
)]
#[derive(Clone)]
struct PyVolumeWeightedSr {
inner: wc::VolumeWeightedSr,
}
#[pymethods]
impl PyVolumeWeightedSr {
#[new]
#[pyo3(signature = (period=20))]
fn new(period: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::VolumeWeightedSr::new(period).map_err(map_err)?,
})
}
/// Returns `(support, resistance)`.
fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult<Option<(f64, f64)>> {
let c = extract_candle(candle)?;
Ok(self.inner.update(c).map(|o| (o.support, o.resistance)))
}
fn batch<'py>(
&mut self,
py: Python<'py>,
high: PyReadonlyArray1<'py, f64>,
low: PyReadonlyArray1<'py, f64>,
volume: 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 v = volume
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
if h.len() != l.len() || l.len() != v.len() {
return Err(PyValueError::new_err(
"high, low, volume 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], v[i], 0).map_err(map_err)?;
if let Some(o) = self.inner.update(candle) {
out[i * 2] = o.support;
out[i * 2 + 1] = o.resistance;
}
}
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()
}
}
// ============================== Pivot Reversal ==============================
#[pyclass(name = "PivotReversal", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PyPivotReversal {
inner: wc::PivotReversal,
}
#[pymethods]
impl PyPivotReversal {
#[new]
#[pyo3(signature = (left=2, right=2))]
fn new(left: usize, right: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::PivotReversal::new(left, right).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: high, low, close (all 1-D, equal length).
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()
}
}
// ============================== Double Bollinger ==============================
#[pyclass(
@@ -23684,6 +24033,11 @@ fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_class::<PyDemarkPivots>()?;
m.add_class::<PyWilliamsFractals>()?;
m.add_class::<PyZigZag>()?;
m.add_class::<PyCentralPivotRange>()?;
m.add_class::<PyMurreyMathLines>()?;
m.add_class::<PyAndrewsPitchfork>()?;
m.add_class::<PyVolumeWeightedSr>()?;
m.add_class::<PyPivotReversal>()?;
m.add_class::<PyTdSetup>()?;
m.add_class::<PyTdSequential>()?;
m.add_class::<PyTdDeMarker>()?;
@@ -382,6 +382,10 @@ def test_relative_strength_streaming_matches_batch():
# 6-tuple candle; the batch helper takes only the columns it needs.
CANDLE_SCALAR = {
"PivotReversal": (
lambda: ta.PivotReversal(1, 1),
lambda ind, h, l, c, v: ind.batch(h, l, c),
),
"ADAPTIVECCI": (lambda: ta.ADAPTIVECCI(20), lambda ind, h, l, c, v: ind.batch(h, l, c)),
"BetterVolume": (
lambda: ta.BetterVolume(14),
@@ -948,6 +952,26 @@ def test_candle_scalar_streaming_matches_batch(name, ohlcv):
# --- Candle-input, multi-output indicators --------------------------------
MULTI = {
"VolumeWeightedSr": (
lambda: ta.VolumeWeightedSr(3),
lambda ind, h, l, c, v: ind.batch(h, l, v),
2,
),
"AndrewsPitchfork": (
lambda: ta.AndrewsPitchfork(2),
lambda ind, h, l, c, v: ind.batch(h, l),
3,
),
"MurreyMathLines": (
lambda: ta.MurreyMathLines(4),
lambda ind, h, l, c, v: ind.batch(h, l),
9,
),
"CentralPivotRange": (
lambda: ta.CentralPivotRange(),
lambda ind, h, l, c, v: ind.batch(h, l, c),
3,
),
"VolumeWeightedMacd": (
lambda: ta.VolumeWeightedMacd(12, 26, 9),
lambda ind, h, l, c, v: ind.batch(c, v),
@@ -3112,6 +3136,44 @@ def test_volume_weighted_macd_reference():
def test_kendall_tau_reference():
t = ta.KendallTau(20)
def test_central_pivot_range_reference():
t = ta.CentralPivotRange()
assert t.update((105.0, 110.0, 90.0, 105.0, 1.0, 0)) == pytest.approx((101.66666666666667, 103.33333333333334, 100.0))
def test_murrey_math_lines_reference():
t = ta.MurreyMathLines(4)
assert t.update((140.0, 180.0, 100.0, 140.0, 1.0, 0)) is None
assert t.update((140.0, 180.0, 100.0, 140.0, 1.0, 1)) is None
assert t.update((140.0, 180.0, 100.0, 140.0, 1.0, 2)) is None
assert t.update((140.0, 180.0, 100.0, 140.0, 1.0, 3)) == pytest.approx((180.0, 170.0, 160.0, 150.0, 140.0, 130.0, 120.0, 110.0, 100.0))
def test_andrews_pitchfork_reference():
t = ta.AndrewsPitchfork(2)
# Warmup: no pitchfork until three alternating swing pivots are confirmed.
assert t.update((100.0, 101.0, 99.0, 100.0, 1.0, 0)) is None
def test_volume_weighted_sr_reference():
t = ta.VolumeWeightedSr(3)
assert t.update((100.0, 102.0, 98.0, 100.0, 1.0, 0)) is None
assert t.update((100.0, 104.0, 96.0, 100.0, 1.0, 1)) is None
assert t.update((100.0, 106.0, 94.0, 100.0, 1.0, 2)) == pytest.approx((96.0, 104.0))
def test_pivot_reversal_reference():
t = ta.PivotReversal(1, 1)
assert t.update((9.5, 10.0, 9.0, 9.5, 1.0, 0)) is None
assert t.update((11.5, 12.0, 11.0, 11.5, 1.0, 1)) is None
# Pivot high = 12 confirmed; close 9.5 has not crossed it.
assert t.update((9.5, 10.0, 9.0, 9.5, 1.0, 2)) == pytest.approx(0.0)
assert t.update((9.0, 11.0, 9.0, 9.0, 1.0, 3)) == pytest.approx(0.0)
# Close 13 > pivot high 12 with prev close 9 below it -> bullish reversal.
assert t.update((13.0, 14.0, 12.5, 13.0, 1.0, 4)) == pytest.approx(1.0)
# --- Lifecycle ------------------------------------------------------------
+302
View File
@@ -6441,6 +6441,308 @@ impl WasmProjectionBands {
}
}
// ---------- Central Pivot Range (high/low/close input, 3 outputs) ----------
#[wasm_bindgen(js_name = CentralPivotRange)]
pub struct WasmCentralPivotRange {
inner: wc::CentralPivotRange,
}
impl Default for WasmCentralPivotRange {
fn default() -> Self {
Self::new()
}
}
#[wasm_bindgen(js_class = CentralPivotRange)]
impl WasmCentralPivotRange {
#[wasm_bindgen(constructor)]
pub fn new() -> WasmCentralPivotRange {
Self {
inner: wc::CentralPivotRange::new(),
}
}
pub fn update(&mut self, high: f64, low: f64, close: f64) -> Result<JsValue, JsError> {
let candle = make_candle(high, low, close, 0.0)?;
match self.inner.update(candle) {
Some(o) => {
let obj = Object::new();
Reflect::set(&obj, &"pivot".into(), &o.pivot.into()).ok();
Reflect::set(&obj, &"tc".into(), &o.tc.into()).ok();
Reflect::set(&obj, &"bc".into(), &o.bc.into()).ok();
Ok(obj.into())
}
None => Ok(JsValue::NULL),
}
}
pub fn batch(
&mut self,
high: &[f64],
low: &[f64],
close: &[f64],
) -> Result<Float64Array, JsError> {
if high.len() != low.len() || low.len() != close.len() {
return Err(JsError::new("high, low, close must be equal length"));
}
let n = high.len();
let mut out = vec![f64::NAN; n * 3];
for i in 0..n {
let candle = make_candle(high[i], low[i], close[i], 0.0)?;
if let Some(o) = self.inner.update(candle) {
out[i * 3] = o.pivot;
out[i * 3 + 1] = o.tc;
out[i * 3 + 2] = o.bc;
}
}
Ok(Float64Array::from(out.as_slice()))
}
pub fn reset(&mut self) {
self.inner.reset();
}
#[wasm_bindgen(js_name = isReady)]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[wasm_bindgen(js_name = warmupPeriod)]
pub fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
}
// ---------- Murrey Math Lines (high/low input, 9 outputs) ----------
#[wasm_bindgen(js_name = MurreyMathLines)]
pub struct WasmMurreyMathLines {
inner: wc::MurreyMathLines,
}
#[wasm_bindgen(js_class = MurreyMathLines)]
impl WasmMurreyMathLines {
#[wasm_bindgen(constructor)]
pub fn new(period: usize) -> Result<WasmMurreyMathLines, JsError> {
Ok(Self {
inner: wc::MurreyMathLines::new(period).map_err(map_err)?,
})
}
pub fn update(&mut self, high: f64, low: f64) -> Result<JsValue, JsError> {
let candle = make_candle(high, low, low, 0.0)?;
match self.inner.update(candle) {
Some(o) => {
let obj = Object::new();
Reflect::set(&obj, &"mm8_8".into(), &o.mm8_8.into()).ok();
Reflect::set(&obj, &"mm7_8".into(), &o.mm7_8.into()).ok();
Reflect::set(&obj, &"mm6_8".into(), &o.mm6_8.into()).ok();
Reflect::set(&obj, &"mm5_8".into(), &o.mm5_8.into()).ok();
Reflect::set(&obj, &"mm4_8".into(), &o.mm4_8.into()).ok();
Reflect::set(&obj, &"mm3_8".into(), &o.mm3_8.into()).ok();
Reflect::set(&obj, &"mm2_8".into(), &o.mm2_8.into()).ok();
Reflect::set(&obj, &"mm1_8".into(), &o.mm1_8.into()).ok();
Reflect::set(&obj, &"mm0_8".into(), &o.mm0_8.into()).ok();
Ok(obj.into())
}
None => Ok(JsValue::NULL),
}
}
pub fn batch(&mut self, high: &[f64], low: &[f64]) -> Result<Float64Array, JsError> {
if high.len() != low.len() {
return Err(JsError::new("high and low must be equal length"));
}
let n = high.len();
let mut out = vec![f64::NAN; n * 9];
for i in 0..n {
let candle = make_candle(high[i], low[i], low[i], 0.0)?;
if let Some(o) = self.inner.update(candle) {
out[i * 9] = o.mm8_8;
out[i * 9 + 1] = o.mm7_8;
out[i * 9 + 2] = o.mm6_8;
out[i * 9 + 3] = o.mm5_8;
out[i * 9 + 4] = o.mm4_8;
out[i * 9 + 5] = o.mm3_8;
out[i * 9 + 6] = o.mm2_8;
out[i * 9 + 7] = o.mm1_8;
out[i * 9 + 8] = o.mm0_8;
}
}
Ok(Float64Array::from(out.as_slice()))
}
pub fn reset(&mut self) {
self.inner.reset();
}
#[wasm_bindgen(js_name = isReady)]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[wasm_bindgen(js_name = warmupPeriod)]
pub fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
}
// ---------- Andrews Pitchfork (high/low input, 3 outputs) ----------
#[wasm_bindgen(js_name = AndrewsPitchfork)]
pub struct WasmAndrewsPitchfork {
inner: wc::AndrewsPitchfork,
}
#[wasm_bindgen(js_class = AndrewsPitchfork)]
impl WasmAndrewsPitchfork {
#[wasm_bindgen(constructor)]
pub fn new(strength: usize) -> Result<WasmAndrewsPitchfork, JsError> {
Ok(Self {
inner: wc::AndrewsPitchfork::new(strength).map_err(map_err)?,
})
}
pub fn update(&mut self, high: f64, low: f64) -> Result<JsValue, JsError> {
let candle = make_candle(high, low, low, 0.0)?;
match self.inner.update(candle) {
Some(o) => {
let obj = Object::new();
Reflect::set(&obj, &"median".into(), &o.median.into()).ok();
Reflect::set(&obj, &"upper".into(), &o.upper.into()).ok();
Reflect::set(&obj, &"lower".into(), &o.lower.into()).ok();
Ok(obj.into())
}
None => Ok(JsValue::NULL),
}
}
pub fn batch(&mut self, high: &[f64], low: &[f64]) -> Result<Float64Array, JsError> {
if high.len() != low.len() {
return Err(JsError::new("high and low must be equal length"));
}
let n = high.len();
let mut out = vec![f64::NAN; n * 3];
for i in 0..n {
let candle = make_candle(high[i], low[i], low[i], 0.0)?;
if let Some(o) = self.inner.update(candle) {
out[i * 3] = o.median;
out[i * 3 + 1] = o.upper;
out[i * 3 + 2] = o.lower;
}
}
Ok(Float64Array::from(out.as_slice()))
}
pub fn reset(&mut self) {
self.inner.reset();
}
#[wasm_bindgen(js_name = isReady)]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[wasm_bindgen(js_name = warmupPeriod)]
pub fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
}
// ---------- Volume-Weighted S/R (high/low/volume input, 2 outputs) ----------
#[wasm_bindgen(js_name = VolumeWeightedSr)]
pub struct WasmVolumeWeightedSr {
inner: wc::VolumeWeightedSr,
}
#[wasm_bindgen(js_class = VolumeWeightedSr)]
impl WasmVolumeWeightedSr {
#[wasm_bindgen(constructor)]
pub fn new(period: usize) -> Result<WasmVolumeWeightedSr, JsError> {
Ok(Self {
inner: wc::VolumeWeightedSr::new(period).map_err(map_err)?,
})
}
pub fn update(&mut self, high: f64, low: f64, volume: f64) -> Result<JsValue, JsError> {
let candle = make_candle(high, low, low, volume)?;
match self.inner.update(candle) {
Some(o) => {
let obj = Object::new();
Reflect::set(&obj, &"support".into(), &o.support.into()).ok();
Reflect::set(&obj, &"resistance".into(), &o.resistance.into()).ok();
Ok(obj.into())
}
None => Ok(JsValue::NULL),
}
}
pub fn batch(
&mut self,
high: &[f64],
low: &[f64],
volume: &[f64],
) -> Result<Float64Array, JsError> {
if high.len() != low.len() || low.len() != volume.len() {
return Err(JsError::new("high, low, volume must be equal length"));
}
let n = high.len();
let mut out = vec![f64::NAN; n * 2];
for i in 0..n {
let candle = make_candle(high[i], low[i], low[i], volume[i])?;
if let Some(o) = self.inner.update(candle) {
out[i * 2] = o.support;
out[i * 2 + 1] = o.resistance;
}
}
Ok(Float64Array::from(out.as_slice()))
}
pub fn reset(&mut self) {
self.inner.reset();
}
#[wasm_bindgen(js_name = isReady)]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[wasm_bindgen(js_name = warmupPeriod)]
pub fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
}
// ---------- Pivot Reversal (high/low/close input, scalar signal) ----------
#[wasm_bindgen(js_name = PivotReversal)]
pub struct WasmPivotReversal {
inner: wc::PivotReversal,
}
#[wasm_bindgen(js_class = PivotReversal)]
impl WasmPivotReversal {
#[wasm_bindgen(constructor)]
pub fn new(left: usize, right: usize) -> Result<WasmPivotReversal, JsError> {
Ok(Self {
inner: wc::PivotReversal::new(left, right).map_err(map_err)?,
})
}
pub fn update(&mut self, high: f64, low: f64, close: f64) -> Result<Option<f64>, JsError> {
let candle = make_candle(high, low, close, 0.0)?;
Ok(self.inner.update(candle))
}
pub fn batch(
&mut self,
high: &[f64],
low: &[f64],
close: &[f64],
) -> Result<Float64Array, JsError> {
if high.len() != low.len() || low.len() != close.len() {
return Err(JsError::new("high, low, close must be equal length"));
}
let mut out = Vec::with_capacity(high.len());
for i in 0..high.len() {
let candle = make_candle(high[i], low[i], close[i], 0.0)?;
out.push(self.inner.update(candle).unwrap_or(f64::NAN));
}
Ok(Float64Array::from(out.as_slice()))
}
pub fn reset(&mut self) {
self.inner.reset();
}
#[wasm_bindgen(js_name = isReady)]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[wasm_bindgen(js_name = warmupPeriod)]
pub fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
}
// ---------- Double Bollinger (scalar input, 5 outputs) ----------
#[wasm_bindgen(js_name = DoubleBollinger)]