feat(indicators): A5a Fibonacci tools (price-level) (#171)

Adds the six price-level Fibonacci tools as a new **Fibonacci** family (catalogue 367 -> 373, twenty-four families). All build on the internal `pattern_swing` ZigZag tracker, are parameter-free (baked 5% swing threshold), and emit `Candle -> struct` outputs via custom Python/Node/WASM bindings.

| Tool | Output |
|------|--------|
| `FibRetracement` | seven levels (0/23.6/38.2/50/61.8/78.6/100%) of the last swing leg |
| `FibExtension` | five extension ratios (127.2/141.4/161.8/200/261.8%) projected beyond the leg |
| `FibProjection` | A-B-C measured-move target zone (61.8/100/161.8/261.8%) |
| `AutoFib` | retracement anchored on the dominant (largest-magnitude) recent leg |
| `GoldenPocket` | the 0.618-0.65 optimal-trade-entry band (low/mid/high) |
| `FibConfluence` | densest cluster of retracement levels across recent legs (price + strength) |

Fully wired: core (100% unit-tested branches), Python/Node/WASM struct bindings, fuzz driver, reference + streaming-vs-batch tests, README/docs counter. The four geometric/time tools (Fan, Arcs, Channel, Time Zones) follow in A5b.

Verification: `cargo test --workspace` green, clippy `-D warnings` clean, node 450 tests, python 760 tests.
This commit is contained in:
kingchenc
2026-06-04 00:47:00 +02:00
committed by GitHub
parent 8115d3b33d
commit 716eb40206
20 changed files with 2615 additions and 18 deletions
+14
View File
@@ -339,6 +339,13 @@ from ._wickra import (
Butterfly,
Gartley,
Abcd,
# Fibonacci
FibConfluence,
GoldenPocket,
AutoFib,
FibProjection,
FibExtension,
FibRetracement,
# Microstructure: order book
OrderBookImbalanceTop1,
OrderBookImbalanceTopN,
@@ -734,6 +741,13 @@ __all__ = [
"Butterfly",
"Gartley",
"Abcd",
# Fibonacci
"FibConfluence",
"GoldenPocket",
"AutoFib",
"FibProjection",
"FibExtension",
"FibRetracement",
# Microstructure: order book
"OrderBookImbalanceTop1",
"OrderBookImbalanceTopN",
+454
View File
@@ -49,6 +49,8 @@ const NON_CONTIGUOUS: &str = "array must be C-contiguous; pass np.ascontiguousar
/// `(pp, r1, r2, r3, s1, s2, s3)` pivot levels returned by Classic/Fibonacci pivots.
type PivotLevels = (f64, f64, f64, f64, f64, f64, f64);
/// The five Fibonacci-extension levels returned by `FibExtension`.
type FibExtLevels = (f64, f64, f64, f64, f64);
/// `(pp, r1, r2, s1, s2)` pivot levels returned by Woodie pivots.
type WoodieLevels = (f64, f64, f64, f64, f64);
/// `(tenkan, kijun, senkou_a, senkou_b, chikou)` Ichimoku lines, each optional during warmup.
@@ -17846,6 +17848,451 @@ impl PyOvernightIntradayReturn {
}
}
// ============================== Fibonacci ==============================
/// Build a candle for the swing-based Fibonacci tools from a `high`/`low` pair.
/// Only the high and low drive the swing tracker, so open and close are pinned
/// to the midpoint to keep the OHLC invariants valid.
fn swing_candle(high: f64, low: f64) -> Result<wc::Candle, wc::Error> {
let mid = f64::midpoint(high, low);
wc::Candle::new(mid, high, low, mid, 0.0, 0)
}
#[pyclass(
name = "FibRetracement",
module = "wickra._wickra",
skip_from_py_object
)]
#[derive(Clone)]
struct PyFibRetracement {
inner: wc::FibRetracement,
}
#[pymethods]
impl PyFibRetracement {
#[new]
fn new() -> Self {
Self {
inner: wc::FibRetracement::new(),
}
}
/// Returns `(level_0, …, level_1000)` (seven levels) or None during warmup.
fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult<Option<PivotLevels>> {
let c = extract_candle(candle)?;
Ok(self.inner.update(c).map(|o| {
(
o.level_0,
o.level_236,
o.level_382,
o.level_500,
o.level_618,
o.level_786,
o.level_1000,
)
}))
}
/// Batch over numpy columns high, low. Returns shape `(n, 7)`.
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 * 7];
for i in 0..n {
if let Some(o) = self
.inner
.update(swing_candle(h[i], l[i]).map_err(map_err)?)
{
out[i * 7] = o.level_0;
out[i * 7 + 1] = o.level_236;
out[i * 7 + 2] = o.level_382;
out[i * 7 + 3] = o.level_500;
out[i * 7 + 4] = o.level_618;
out[i * 7 + 5] = o.level_786;
out[i * 7 + 6] = o.level_1000;
}
}
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()
}
fn __repr__(&self) -> String {
"FibRetracement()".to_string()
}
}
#[pyclass(name = "FibExtension", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PyFibExtension {
inner: wc::FibExtension,
}
#[pymethods]
impl PyFibExtension {
#[new]
fn new() -> Self {
Self {
inner: wc::FibExtension::new(),
}
}
/// Returns `(level_1272, level_1414, level_1618, level_2000, level_2618)` or None.
fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult<Option<FibExtLevels>> {
let c = extract_candle(candle)?;
Ok(self.inner.update(c).map(|o| {
(
o.level_1272,
o.level_1414,
o.level_1618,
o.level_2000,
o.level_2618,
)
}))
}
/// Batch over numpy columns high, low. Returns shape `(n, 5)`.
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 * 5];
for i in 0..n {
if let Some(o) = self
.inner
.update(swing_candle(h[i], l[i]).map_err(map_err)?)
{
out[i * 5] = o.level_1272;
out[i * 5 + 1] = o.level_1414;
out[i * 5 + 2] = o.level_1618;
out[i * 5 + 3] = o.level_2000;
out[i * 5 + 4] = o.level_2618;
}
}
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()
}
fn __repr__(&self) -> String {
"FibExtension()".to_string()
}
}
#[pyclass(name = "FibProjection", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PyFibProjection {
inner: wc::FibProjection,
}
#[pymethods]
impl PyFibProjection {
#[new]
fn new() -> Self {
Self {
inner: wc::FibProjection::new(),
}
}
/// Returns `(level_618, level_1000, level_1618, level_2618)` or None during warmup.
fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult<Option<(f64, f64, f64, f64)>> {
let c = extract_candle(candle)?;
Ok(self
.inner
.update(c)
.map(|o| (o.level_618, o.level_1000, o.level_1618, o.level_2618)))
}
/// Batch over numpy columns high, low. Returns shape `(n, 4)`.
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 * 4];
for i in 0..n {
if let Some(o) = self
.inner
.update(swing_candle(h[i], l[i]).map_err(map_err)?)
{
out[i * 4] = o.level_618;
out[i * 4 + 1] = o.level_1000;
out[i * 4 + 2] = o.level_1618;
out[i * 4 + 3] = o.level_2618;
}
}
Ok(numpy::ndarray::Array2::from_shape_vec((n, 4), 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()
}
fn __repr__(&self) -> String {
"FibProjection()".to_string()
}
}
#[pyclass(name = "AutoFib", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PyAutoFib {
inner: wc::AutoFib,
}
#[pymethods]
impl PyAutoFib {
#[new]
fn new() -> Self {
Self {
inner: wc::AutoFib::new(),
}
}
/// Returns `(level_0, …, level_1000)` for the dominant leg, or None during warmup.
fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult<Option<PivotLevels>> {
let c = extract_candle(candle)?;
Ok(self.inner.update(c).map(|o| {
(
o.level_0,
o.level_236,
o.level_382,
o.level_500,
o.level_618,
o.level_786,
o.level_1000,
)
}))
}
/// Batch over numpy columns high, low. Returns shape `(n, 7)`.
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 * 7];
for i in 0..n {
if let Some(o) = self
.inner
.update(swing_candle(h[i], l[i]).map_err(map_err)?)
{
out[i * 7] = o.level_0;
out[i * 7 + 1] = o.level_236;
out[i * 7 + 2] = o.level_382;
out[i * 7 + 3] = o.level_500;
out[i * 7 + 4] = o.level_618;
out[i * 7 + 5] = o.level_786;
out[i * 7 + 6] = o.level_1000;
}
}
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()
}
fn __repr__(&self) -> String {
"AutoFib()".to_string()
}
}
#[pyclass(name = "GoldenPocket", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PyGoldenPocket {
inner: wc::GoldenPocket,
}
#[pymethods]
impl PyGoldenPocket {
#[new]
fn new() -> Self {
Self {
inner: wc::GoldenPocket::new(),
}
}
/// Returns `(low, mid, high)` of the golden-pocket band, 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.low, o.mid, o.high)))
}
/// Batch over numpy columns high, low. Returns shape `(n, 3)`.
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 {
if let Some(o) = self
.inner
.update(swing_candle(h[i], l[i]).map_err(map_err)?)
{
out[i * 3] = o.low;
out[i * 3 + 1] = o.mid;
out[i * 3 + 2] = o.high;
}
}
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()
}
fn __repr__(&self) -> String {
"GoldenPocket()".to_string()
}
}
#[pyclass(name = "FibConfluence", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PyFibConfluence {
inner: wc::FibConfluence,
}
#[pymethods]
impl PyFibConfluence {
#[new]
fn new() -> Self {
Self {
inner: wc::FibConfluence::new(),
}
}
/// Returns `(price, strength)` of the densest cluster, 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.price, o.strength)))
}
/// Batch over numpy columns high, low. Returns shape `(n, 2)`.
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 {
if let Some(o) = self
.inner
.update(swing_candle(h[i], l[i]).map_err(map_err)?)
{
out[i * 2] = o.price;
out[i * 2 + 1] = o.strength;
}
}
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()
}
fn __repr__(&self) -> String {
"FibConfluence()".to_string()
}
}
#[pymodule]
#[allow(clippy::too_many_lines)]
fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
@@ -18228,5 +18675,12 @@ fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_class::<PyShark>()?;
m.add_class::<PyCypher>()?;
m.add_class::<PyThreeDrives>()?;
// Fibonacci.
m.add_class::<PyFibRetracement>()?;
m.add_class::<PyFibExtension>()?;
m.add_class::<PyFibProjection>()?;
m.add_class::<PyAutoFib>()?;
m.add_class::<PyGoldenPocket>()?;
m.add_class::<PyFibConfluence>()?;
Ok(())
}
@@ -860,6 +860,36 @@ def test_candle_scalar_streaming_matches_batch(name, ohlcv):
# --- Candle-input, multi-output indicators --------------------------------
MULTI = {
"FibRetracement": (
lambda: ta.FibRetracement(),
lambda ind, h, l, c, v: ind.batch(h, l),
7,
),
"FibExtension": (
lambda: ta.FibExtension(),
lambda ind, h, l, c, v: ind.batch(h, l),
5,
),
"FibProjection": (
lambda: ta.FibProjection(),
lambda ind, h, l, c, v: ind.batch(h, l),
4,
),
"AutoFib": (
lambda: ta.AutoFib(),
lambda ind, h, l, c, v: ind.batch(h, l),
7,
),
"GoldenPocket": (
lambda: ta.GoldenPocket(),
lambda ind, h, l, c, v: ind.batch(h, l),
3,
),
"FibConfluence": (
lambda: ta.FibConfluence(),
lambda ind, h, l, c, v: ind.batch(h, l),
2,
),
"Vortex": (
lambda: ta.Vortex(14),
lambda ind, h, l, c, v: ind.batch(h, l, c),
@@ -2578,6 +2608,50 @@ def test_three_drives_reference():
assert t.update((109.08, 136.0, 109.08, 109.08, 1.0, 4)) == pytest.approx(0.0)
assert t.update((122.4, 134.64, 122.4, 122.4, 1.0, 5)) == pytest.approx(-1.0)
def test_fib_retracement_reference():
t = ta.FibRetracement()
assert t.update((199.8, 200.0, 199.8, 199.8, 1.0, 0)) is None
assert t.update((100.0, 198.0, 100.0, 100.0, 1.0, 1)) is None
assert t.update((101.0, 110.0, 101.0, 101.0, 1.0, 2)) == pytest.approx((100.0, 123.6, 138.2, 150.0, 161.8, 178.6, 200.0))
def test_fib_extension_reference():
t = ta.FibExtension()
assert t.update((199.8, 200.0, 199.8, 199.8, 1.0, 0)) is None
assert t.update((100.0, 198.0, 100.0, 100.0, 1.0, 1)) is None
assert t.update((101.0, 110.0, 101.0, 101.0, 1.0, 2)) == pytest.approx((72.8, 58.6, 38.2, 0.0, -61.8))
def test_fib_projection_reference():
t = ta.FibProjection()
assert t.update((199.8, 200.0, 199.8, 199.8, 1.0, 0)) is None
assert t.update((160.0, 198.0, 160.0, 160.0, 1.0, 1)) is None
assert t.update((161.6, 190.0, 161.6, 161.6, 1.0, 2)) is None
assert t.update((171.0, 188.1, 171.0, 171.0, 1.0, 3)) == pytest.approx((165.28, 150.0, 125.28, 85.28))
def test_auto_fib_reference():
t = ta.AutoFib()
assert t.update((199.8, 200.0, 199.8, 199.8, 1.0, 0)) is None
assert t.update((100.0, 198.0, 100.0, 100.0, 1.0, 1)) is None
assert t.update((101.0, 110.0, 101.0, 101.0, 1.0, 2)) == pytest.approx((100.0, 123.6, 138.2, 150.0, 161.8, 178.6, 200.0))
def test_golden_pocket_reference():
t = ta.GoldenPocket()
assert t.update((199.8, 200.0, 199.8, 199.8, 1.0, 0)) is None
assert t.update((100.0, 198.0, 100.0, 100.0, 1.0, 1)) is None
assert t.update((101.0, 110.0, 101.0, 101.0, 1.0, 2)) == pytest.approx((161.8, 163.4, 165.0))
def test_fib_confluence_reference():
t = ta.FibConfluence()
assert t.update((199.8, 200.0, 199.8, 199.8, 1.0, 0)) is None
assert t.update((100.0, 198.0, 100.0, 100.0, 1.0, 1)) is None
assert t.update((101.0, 160.0, 101.0, 101.0, 1.0, 2)) is None
assert t.update((144.0, 158.4, 144.0, 144.0, 1.0, 3)) == pytest.approx((137.64, 2.0))
# --- Lifecycle ------------------------------------------------------------