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
+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)]