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:
@@ -4152,6 +4152,499 @@ impl WasmVwapStdDevBands {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================== Pivots & S/R ==============================
|
||||
|
||||
#[wasm_bindgen(js_name = ClassicPivots)]
|
||||
pub struct WasmClassicPivots {
|
||||
inner: wc::ClassicPivots,
|
||||
}
|
||||
|
||||
impl Default for WasmClassicPivots {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_class = ClassicPivots)]
|
||||
impl WasmClassicPivots {
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new() -> WasmClassicPivots {
|
||||
Self {
|
||||
inner: wc::ClassicPivots::new(),
|
||||
}
|
||||
}
|
||||
pub fn update(&mut self, high: f64, low: f64, close: f64) -> Result<JsValue, JsError> {
|
||||
let c = make_candle(high, low, close, 0.0)?;
|
||||
Ok(match self.inner.update(c) {
|
||||
Some(o) => {
|
||||
let obj = Object::new();
|
||||
Reflect::set(&obj, &"pp".into(), &o.pp.into()).ok();
|
||||
Reflect::set(&obj, &"r1".into(), &o.r1.into()).ok();
|
||||
Reflect::set(&obj, &"r2".into(), &o.r2.into()).ok();
|
||||
Reflect::set(&obj, &"r3".into(), &o.r3.into()).ok();
|
||||
Reflect::set(&obj, &"s1".into(), &o.s1.into()).ok();
|
||||
Reflect::set(&obj, &"s2".into(), &o.s2.into()).ok();
|
||||
Reflect::set(&obj, &"s3".into(), &o.s3.into()).ok();
|
||||
obj.into()
|
||||
}
|
||||
None => 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 * 7];
|
||||
for i in 0..n {
|
||||
let c = make_candle(high[i], low[i], close[i], 0.0)?;
|
||||
if let Some(o) = self.inner.update(c) {
|
||||
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(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()
|
||||
}
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = FibonacciPivots)]
|
||||
pub struct WasmFibonacciPivots {
|
||||
inner: wc::FibonacciPivots,
|
||||
}
|
||||
|
||||
impl Default for WasmFibonacciPivots {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_class = FibonacciPivots)]
|
||||
impl WasmFibonacciPivots {
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new() -> WasmFibonacciPivots {
|
||||
Self {
|
||||
inner: wc::FibonacciPivots::new(),
|
||||
}
|
||||
}
|
||||
pub fn update(&mut self, high: f64, low: f64, close: f64) -> Result<JsValue, JsError> {
|
||||
let c = make_candle(high, low, close, 0.0)?;
|
||||
Ok(match self.inner.update(c) {
|
||||
Some(o) => {
|
||||
let obj = Object::new();
|
||||
Reflect::set(&obj, &"pp".into(), &o.pp.into()).ok();
|
||||
Reflect::set(&obj, &"r1".into(), &o.r1.into()).ok();
|
||||
Reflect::set(&obj, &"r2".into(), &o.r2.into()).ok();
|
||||
Reflect::set(&obj, &"r3".into(), &o.r3.into()).ok();
|
||||
Reflect::set(&obj, &"s1".into(), &o.s1.into()).ok();
|
||||
Reflect::set(&obj, &"s2".into(), &o.s2.into()).ok();
|
||||
Reflect::set(&obj, &"s3".into(), &o.s3.into()).ok();
|
||||
obj.into()
|
||||
}
|
||||
None => 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 * 7];
|
||||
for i in 0..n {
|
||||
let c = make_candle(high[i], low[i], close[i], 0.0)?;
|
||||
if let Some(o) = self.inner.update(c) {
|
||||
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(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()
|
||||
}
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = Camarilla)]
|
||||
pub struct WasmCamarilla {
|
||||
inner: wc::Camarilla,
|
||||
}
|
||||
|
||||
impl Default for WasmCamarilla {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_class = Camarilla)]
|
||||
impl WasmCamarilla {
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new() -> WasmCamarilla {
|
||||
Self {
|
||||
inner: wc::Camarilla::new(),
|
||||
}
|
||||
}
|
||||
pub fn update(&mut self, high: f64, low: f64, close: f64) -> Result<JsValue, JsError> {
|
||||
let c = make_candle(high, low, close, 0.0)?;
|
||||
Ok(match self.inner.update(c) {
|
||||
Some(o) => {
|
||||
let obj = Object::new();
|
||||
Reflect::set(&obj, &"pp".into(), &o.pp.into()).ok();
|
||||
Reflect::set(&obj, &"r1".into(), &o.r1.into()).ok();
|
||||
Reflect::set(&obj, &"r2".into(), &o.r2.into()).ok();
|
||||
Reflect::set(&obj, &"r3".into(), &o.r3.into()).ok();
|
||||
Reflect::set(&obj, &"r4".into(), &o.r4.into()).ok();
|
||||
Reflect::set(&obj, &"s1".into(), &o.s1.into()).ok();
|
||||
Reflect::set(&obj, &"s2".into(), &o.s2.into()).ok();
|
||||
Reflect::set(&obj, &"s3".into(), &o.s3.into()).ok();
|
||||
Reflect::set(&obj, &"s4".into(), &o.s4.into()).ok();
|
||||
obj.into()
|
||||
}
|
||||
None => 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 * 9];
|
||||
for i in 0..n {
|
||||
let c = make_candle(high[i], low[i], close[i], 0.0)?;
|
||||
if let Some(o) = self.inner.update(c) {
|
||||
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(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()
|
||||
}
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = WoodiePivots)]
|
||||
pub struct WasmWoodiePivots {
|
||||
inner: wc::WoodiePivots,
|
||||
}
|
||||
|
||||
impl Default for WasmWoodiePivots {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_class = WoodiePivots)]
|
||||
impl WasmWoodiePivots {
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new() -> WasmWoodiePivots {
|
||||
Self {
|
||||
inner: wc::WoodiePivots::new(),
|
||||
}
|
||||
}
|
||||
pub fn update(&mut self, high: f64, low: f64, close: f64) -> Result<JsValue, JsError> {
|
||||
let c = make_candle(high, low, close, 0.0)?;
|
||||
Ok(match self.inner.update(c) {
|
||||
Some(o) => {
|
||||
let obj = Object::new();
|
||||
Reflect::set(&obj, &"pp".into(), &o.pp.into()).ok();
|
||||
Reflect::set(&obj, &"r1".into(), &o.r1.into()).ok();
|
||||
Reflect::set(&obj, &"r2".into(), &o.r2.into()).ok();
|
||||
Reflect::set(&obj, &"s1".into(), &o.s1.into()).ok();
|
||||
Reflect::set(&obj, &"s2".into(), &o.s2.into()).ok();
|
||||
obj.into()
|
||||
}
|
||||
None => 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 * 5];
|
||||
for i in 0..n {
|
||||
let c = make_candle(high[i], low[i], close[i], 0.0)?;
|
||||
if let Some(o) = self.inner.update(c) {
|
||||
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(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()
|
||||
}
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = DemarkPivots)]
|
||||
pub struct WasmDemarkPivots {
|
||||
inner: wc::DemarkPivots,
|
||||
}
|
||||
|
||||
impl Default for WasmDemarkPivots {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_class = DemarkPivots)]
|
||||
impl WasmDemarkPivots {
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new() -> WasmDemarkPivots {
|
||||
Self {
|
||||
inner: wc::DemarkPivots::new(),
|
||||
}
|
||||
}
|
||||
pub fn update(
|
||||
&mut self,
|
||||
open: f64,
|
||||
high: f64,
|
||||
low: f64,
|
||||
close: f64,
|
||||
) -> Result<JsValue, JsError> {
|
||||
let c = wc::Candle::new(open, high, low, close, 0.0, 0).map_err(map_err)?;
|
||||
Ok(match self.inner.update(c) {
|
||||
Some(o) => {
|
||||
let obj = Object::new();
|
||||
Reflect::set(&obj, &"pp".into(), &o.pp.into()).ok();
|
||||
Reflect::set(&obj, &"r1".into(), &o.r1.into()).ok();
|
||||
Reflect::set(&obj, &"s1".into(), &o.s1.into()).ok();
|
||||
obj.into()
|
||||
}
|
||||
None => JsValue::NULL,
|
||||
})
|
||||
}
|
||||
pub fn batch(
|
||||
&mut self,
|
||||
open: &[f64],
|
||||
high: &[f64],
|
||||
low: &[f64],
|
||||
close: &[f64],
|
||||
) -> Result<Float64Array, JsError> {
|
||||
if open.len() != high.len() || high.len() != low.len() || low.len() != close.len() {
|
||||
return Err(JsError::new("open, high, low, close must be equal length"));
|
||||
}
|
||||
let n = open.len();
|
||||
let mut out = vec![f64::NAN; n * 3];
|
||||
for i in 0..n {
|
||||
let c = wc::Candle::new(open[i], high[i], low[i], close[i], 0.0, 0).map_err(map_err)?;
|
||||
if let Some(o) = self.inner.update(c) {
|
||||
out[i * 3] = o.pp;
|
||||
out[i * 3 + 1] = o.r1;
|
||||
out[i * 3 + 2] = o.s1;
|
||||
}
|
||||
}
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = WilliamsFractals)]
|
||||
pub struct WasmWilliamsFractals {
|
||||
inner: wc::WilliamsFractals,
|
||||
}
|
||||
|
||||
impl Default for WasmWilliamsFractals {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_class = WilliamsFractals)]
|
||||
impl WasmWilliamsFractals {
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new() -> WasmWilliamsFractals {
|
||||
Self {
|
||||
inner: wc::WilliamsFractals::new(),
|
||||
}
|
||||
}
|
||||
/// Returns `{ up, down }` where each is the fractal price or `NaN` when no
|
||||
/// fractal was confirmed at the centre of the most recent 5-bar window.
|
||||
/// Returns `null` during the four-bar warmup.
|
||||
pub fn update(&mut self, high: f64, low: f64) -> Result<JsValue, JsError> {
|
||||
let c = make_candle(high, low, low, 0.0)?;
|
||||
Ok(match self.inner.update(c) {
|
||||
Some(o) => {
|
||||
let obj = Object::new();
|
||||
Reflect::set(&obj, &"up".into(), &o.up.unwrap_or(f64::NAN).into()).ok();
|
||||
Reflect::set(&obj, &"down".into(), &o.down.unwrap_or(f64::NAN).into()).ok();
|
||||
obj.into()
|
||||
}
|
||||
None => 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 * 2];
|
||||
for i in 0..n {
|
||||
let c = make_candle(high[i], low[i], low[i], 0.0)?;
|
||||
if let Some(o) = self.inner.update(c) {
|
||||
if let Some(v) = o.up {
|
||||
out[i * 2] = v;
|
||||
}
|
||||
if let Some(v) = o.down {
|
||||
out[i * 2 + 1] = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = ZigZag)]
|
||||
pub struct WasmZigZag {
|
||||
inner: wc::ZigZag,
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_class = ZigZag)]
|
||||
impl WasmZigZag {
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new(threshold: f64) -> Result<WasmZigZag, JsError> {
|
||||
Ok(Self {
|
||||
inner: wc::ZigZag::new(threshold).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
pub fn update(&mut self, high: f64, low: f64) -> Result<JsValue, JsError> {
|
||||
let c = make_candle(high, low, low, 0.0)?;
|
||||
Ok(match self.inner.update(c) {
|
||||
Some(o) => {
|
||||
let obj = Object::new();
|
||||
Reflect::set(&obj, &"swing".into(), &o.swing.into()).ok();
|
||||
Reflect::set(&obj, &"direction".into(), &o.direction.into()).ok();
|
||||
obj.into()
|
||||
}
|
||||
None => 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 * 2];
|
||||
for i in 0..n {
|
||||
let c = make_candle(high[i], low[i], low[i], 0.0)?;
|
||||
if let Some(o) = self.inner.update(c) {
|
||||
out[i * 2] = o.swing;
|
||||
out[i * 2 + 1] = o.direction;
|
||||
}
|
||||
}
|
||||
Ok(Float64Array::from(out.as_slice()))
|
||||
}
|
||||
#[wasm_bindgen(js_name = threshold)]
|
||||
pub fn threshold(&self) -> f64 {
|
||||
self.inner.threshold()
|
||||
}
|
||||
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()
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
Reference in New Issue
Block a user