From d9d3ad18aa21d5a59d622108d9bae42c9695c1ba Mon Sep 17 00:00:00 2001 From: kingchenc Date: Mon, 25 May 2026 17:26:46 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Family=2003=20MACD=20&=20Price=20Oscill?= =?UTF-8?q?ators=20=E2=80=94=20APO=20/=20AO-Hist=20/=20CFO=20/=20Zero-Lag?= =?UTF-8?q?=20MACD=20/=20Elder=20Impulse=20/=20STC=20(#41)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(apo): add Absolute Price Oscillator EMA(close, fast) - EMA(close, slow). Like MACD without the signal EMA. Defaults to (fast = 12, slow = 26); fast must be strictly less than slow. Touchpoints: apo.rs + mod.rs + lib.rs re-export, PyApo + __init__.py + test_new_indicators SCALAR + test_known_values flat reference, ApoNode + index.d.ts/index.js + indicators.test.js factory + reference, WasmApo via scalar macro, scalar-fuzz target, README + CHANGELOG. * fix(apo): add PyApo + ApoNode + WasmApo bindings missed from ec269d8 The previous APO commit (ec269d8) only registered APO in the Python __init__.py / Node index.js / Node index.d.ts / fuzz / tests / docs. The actual PyApo pyclass, ApoNode napi class, and WasmApo wasm class edits silently no-op'd because the underlying lib.rs files had been touched by a branch switch between Read and Edit. The bindings were therefore advertising APO from the Python module / Node package / WASM module but not actually exposing it. Fix: insert PyApo block + add_class call in bindings/python/src/lib.rs, ApoNode block in bindings/node/src/lib.rs, WasmApo macro line in bindings/wasm/src/lib.rs. cargo test workspace stays at 615 (no new tests added; the existing test_known_values + indicators.test.js references would have failed at import once the bindings rebuilt without these classes). * feat(ao-histogram): add Awesome Oscillator Histogram AO - SMA(AO, sma_period). A configurable variant of the existing AcceleratorOscillator (which fixes fast=5, slow=34, sma=5). Three parameters; defaults match Bill Williams' Accelerator. Touchpoints: awesome_oscillator_histogram.rs + mod.rs + lib.rs re-export, PyAoHist + __init__.py + test_new_indicators CANDLE_SCALAR + test_known_values flat reference, AwesomeOscillatorHistogramNode + index.d.ts/index.js + indicators.test.js factory + reference, WasmAoHist, candle-fuzz target, README + CHANGELOG. * feat(cfo): add Chande Forecast Oscillator 100 * (close - LinReg(close, period)) / close. Positive when close overshoots the linear forecast, negative when it undershoots. Holds the previous value if the close is zero (percentage form undefined). Single param period (default 14). Touchpoints: cfo.rs + mod.rs + lib.rs re-export, PyCfo + __init__.py + test_new_indicators SCALAR + test_known_values linear reference, CfoNode + index.d.ts/index.js + indicators.test.js factory + reference, WasmCfo via scalar macro, scalar-fuzz target, README + CHANGELOG. * fix(cfo): add WasmCfo binding missed from 733afd9 * feat(zero-lag-macd): add Zero-Lag MACD Classic MACD topology with ZLEMA substituted for EMA everywhere: faster reaction to trend changes at the cost of slightly noisier readings. Multi-output ZeroLagMacdOutput { macd, signal, histogram }. Three parameters (fast = 12, slow = 26, signal = 9); fast must be strictly less than slow. Touchpoints: zero_lag_macd.rs + mod.rs + lib.rs re-export, PyZeroLagMacd + __init__.py + test_new_indicators MULTI + test_known_values flat reference, ZeroLagMacdNode + ZeroLagMacdValue + index.d.ts/index.js + indicators.test.js multi factory + reference, WasmZeroLagMacd, scalar fuzz with hand-rolled drive (multi-output bypasses the f64-only helper), README + CHANGELOG. * feat(elder-impulse): add Alexander Elder Impulse System Tri-state momentum gauge: +1 (green/buy) when EMA trend and MACD histogram both rise, -1 (red/sell) when both fall, 0 (blue/neutral) on disagreement. Four parameters (ema_period, macd_fast, macd_slow, macd_signal); defaults (13, 12, 26, 9) match Elder. Internally feeds both branches on every input so they warm in parallel; needs one bar past the slowest branch to seed direction state. Touchpoints: elder_impulse.rs + mod.rs + lib.rs re-export, PyElderImpulse + __init__.py + test_new_indicators SCALAR + test_known_values neutral reference, ElderImpulseNode + index.d.ts/index.js + indicators.test.js factory + reference, WasmElderImpulse via scalar macro, scalar-fuzz target, README + CHANGELOG. * feat(stc): add Schaff Trend Cycle Doug Schaff's doubly-Stochastic-smoothed MACD. Bounded [0, 100] reading that reacts faster than MACD by extracting the percentile of MACD within a recent window, half-EMA-smoothing it, and re-stochasing the smoothed series. Four parameters (fast = 23, slow = 50, schaff_period = 10, factor = 0.5); fast must be strictly less than slow and factor must lie in (0, 1]. Output clamped to [0, 100] to absorb floating-point rounding. The stochastic stages clamp to 0 when their rolling range collapses (flat input or perfectly monotone trend), so a flat series settles deterministically at 0 after warmup. Touchpoints: stc.rs + mod.rs + lib.rs re-export, PyStc + __init__.py + test_new_indicators SCALAR + test_known_values flat reference, StcNode + index.d.ts/index.js + indicators.test.js factory + reference, WasmStc via scalar macro, scalar-fuzz target, README + CHANGELOG. * fix(stc): rename last_stc -> last_value to satisfy clippy * ci: Retry setup-node and setup-python on CDN flakes Setup-node on Windows runners and setup-python across all OSes occasionally fail with a silent hang or 5xx mid-download ("Attempting to download 18..." → fail in <1s) — pure upstream CDN flake. The fix ran on this branch's previous merge commit (24e723f) had to be re-triggered manually via `gh run rerun --failed`. Wrap both setup actions with continue-on-error and a follow-up retry step that waits 30s and re-runs the same setup. The retry only fires when the first attempt failed (steps..outcome == 'failure'), so a green setup costs nothing extra. The retry uses the identical pinned SHA so we still get supply-chain verification on both attempts. Applied to ci.yml (Python matrix and Node matrix). release.yml has the same setup-node / setup-python steps but is rarely re-run, so the existing manual rerun pattern stays sufficient for now. * test(zero-lag-macd): Fix MULTI dict shape mismatch + cover warmup_period ZeroLagMACD was registered in the Python MULTI dict (which asserts a (n, 2) batch shape) but actually emits (n, 3) — macd, signal, histogram — like MACD. Moved out into its own standalone test test_zero_lag_macd_streaming_matches_batch (3-tuple shape), and included in the lifecycle sweep. Mirrors the existing Alligator pattern for 3-output candle indicators. Also adds a unit test for ZeroLagMacd::warmup_period that pins both the (12, 26, 9) classic case and a small-period config — these four lines were the codecov/patch miss on PR 41. --- .github/workflows/ci.yml | 39 +++ CHANGELOG.md | 34 ++ README.md | 4 +- bindings/node/__tests__/indicators.test.js | 54 +++ bindings/node/index.js | 8 +- bindings/node/src/lib.rs | 264 ++++++++++++++ bindings/python/python/wickra/__init__.py | 12 + bindings/python/src/lib.rs | 315 +++++++++++++++++ bindings/python/tests/test_known_values.py | 50 +++ bindings/python/tests/test_new_indicators.py | 26 ++ bindings/wasm/src/lib.rs | 97 ++++++ crates/wickra-core/src/indicators/apo.rs | 183 ++++++++++ .../awesome_oscillator_histogram.rs | 198 +++++++++++ crates/wickra-core/src/indicators/cfo.rs | 173 +++++++++ .../src/indicators/elder_impulse.rs | 243 +++++++++++++ crates/wickra-core/src/indicators/mod.rs | 12 + crates/wickra-core/src/indicators/stc.rs | 329 ++++++++++++++++++ .../src/indicators/zero_lag_macd.rs | 226 ++++++++++++ crates/wickra-core/src/lib.rs | 27 +- fuzz/fuzz_targets/indicator_update.rs | 24 +- fuzz/fuzz_targets/indicator_update_candle.rs | 7 +- 21 files changed, 2303 insertions(+), 22 deletions(-) create mode 100644 crates/wickra-core/src/indicators/apo.rs create mode 100644 crates/wickra-core/src/indicators/awesome_oscillator_histogram.rs create mode 100644 crates/wickra-core/src/indicators/cfo.rs create mode 100644 crates/wickra-core/src/indicators/elder_impulse.rs create mode 100644 crates/wickra-core/src/indicators/stc.rs create mode 100644 crates/wickra-core/src/indicators/zero_lag_macd.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5ed2c55c..f4adc5f9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -212,7 +212,27 @@ jobs: - name: Cache cargo uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + # setup-python downloads the interpreter from the Actions tool cache / + # nodejs CDN and occasionally hangs or 5xx's on the Windows runners. + # Run it with continue-on-error, then retry once after a backoff so a + # single CDN flake does not fail the whole job (see also: GitHub + # Actions runner-images#7061). - name: Set up Python + id: setup_python + continue-on-error: true + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: ${{ matrix.python-version }} + + - name: Wait before Python retry + if: steps.setup_python.outcome == 'failure' + shell: bash + run: | + echo "::warning::setup-python failed (likely CDN flake), waiting 30s before retry..." + sleep 30 + + - name: Set up Python (retry) + if: steps.setup_python.outcome == 'failure' uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: ${{ matrix.python-version }} @@ -295,7 +315,26 @@ jobs: - name: Cache cargo uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + # setup-node downloads Node from nodejs.org and we've seen it fail on + # Windows runners with "Attempting to download 18..." followed by a + # silent hang or curl error. Retry once after a backoff so a single + # CDN flake does not fail the whole job. - name: Set up Node + id: setup_node + continue-on-error: true + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: ${{ matrix.node-version }} + + - name: Wait before Node retry + if: steps.setup_node.outcome == 'failure' + shell: bash + run: | + echo "::warning::setup-node failed (likely CDN flake), waiting 30s before retry..." + sleep 30 + + - name: Set up Node (retry) + if: steps.setup_node.outcome == 'failure' uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: ${{ matrix.node-version }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 97f85ce0..68973403 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,40 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **Family 03 — MACD & Price Oscillators.** `Stc` (Schaff Trend Cycle, + Doug Schaff): doubly-`Stochastic`-smoothed MACD producing a bounded + `[0, 100]` reading that reacts faster than `MACD` itself. Four + parameters `(fast = 23, slow = 50, schaff_period = 10, factor = 0.5)`. + Output is clamped to `[0, 100]` to absorb floating-point rounding. + Exposed in all four bindings. +- **Family 03 — MACD & Price Oscillators.** `ElderImpulse` (Alexander + Elder's Impulse System): tri-state momentum gauge combining `EMA` + trend slope with `MACD` histogram slope. Returns `+1` (green/buy) + when both rise, `−1` (red/sell) when both fall, `0` (blue/neutral) + on disagreement. Four parameters + `(ema_period, macd_fast, macd_slow, macd_signal)`; defaults + `(13, 12, 26, 9)` track *Come Into My Trading Room*. Exposed in all + four bindings. +- **Family 03 — MACD & Price Oscillators.** `ZeroLagMacd`: classic + MACD topology with `ZLEMA` substituted for `EMA` everywhere — faster + reaction to trend changes at the cost of slightly noisier readings. + Multi-output `ZeroLagMacdOutput { macd, signal, histogram }`. Three + parameters `(fast = 12, slow = 26, signal = 9)`; `fast` must be + strictly less than `slow`. Exposed in all four bindings. +- **Family 03 — MACD & Price Oscillators.** `CFO` (Chande Forecast + Oscillator): `100 · (close − LinReg(close, period)) / close`. Positive + when the close overshoots the linear forecast, negative when it + undershoots. Holds the previous value if the close is zero. Default + period 14. Exposed in all four bindings. +- **Family 03 — MACD & Price Oscillators.** `AwesomeOscillatorHistogram`: + `AO − SMA(AO, sma_period)`. A configurable variant of the existing + `AcceleratorOscillator` (which fixes `(fast, slow, sma) = (5, 34, 5)`). + Three parameters; defaults match Bill Williams' Accelerator. Exposed + in all four bindings. +- **Family 03 — MACD & Price Oscillators.** `APO` (Absolute Price + Oscillator): `EMA(close, fast) − EMA(close, slow)`. Like MACD's line + without the signal EMA. Default `(fast = 12, slow = 26)`. `fast` must + be strictly less than `slow`. Exposed in all four bindings. - **Family 02 — Momentum Oscillators.** `Inertia` (Dorsey): a `LinearRegression` smoothing of the `RVI` series — preserves trend direction while damping the underlying ratio. Candle input, two diff --git a/README.md b/README.md index 939e9767..b0a38415 100644 --- a/README.md +++ b/README.md @@ -109,7 +109,7 @@ python -m benchmarks.compare_libraries ## Indicators -85 streaming-first indicators across eight families. Every one passes the +91 streaming-first indicators across eight families. Every one passes the `batch == streaming` equivalence test, reference-value tests, and reset semantics tests. @@ -118,7 +118,7 @@ semantics tests. | Moving Averages | SMA, EMA, WMA, DEMA, TEMA, HMA, KAMA, SMMA, TRIMA, ZLEMA, T3, VWMA, ALMA, McGinley Dynamic, FRAMA, VIDYA, JMA, Alligator, EVWMA | | Momentum Oscillators | RSI (Wilder), Stochastic, CCI, ROC, Williams %R, MFI, Awesome Oscillator, MOM, CMO, TSI, PMO, StochRSI, Ultimate Oscillator, RVI, PGO, KST, SMI, Laguerre RSI, Connors RSI, Inertia | | Trend & Directional | MACD, ADX (+DI/-DI), Aroon, TRIX, Aroon Oscillator, Vortex, Mass Index, Choppiness Index, Vertical Horizontal Filter | -| Price Oscillators | PPO, DPO, Coppock, Accelerator Oscillator, Balance of Power | +| Price Oscillators | PPO, DPO, Coppock, Accelerator Oscillator, Balance of Power, APO, AO Histogram, CFO, Zero-Lag MACD, Elder Impulse, STC | | Volatility & Bands | ATR, Bollinger Bands, Keltner Channels, Donchian Channels, NATR, StdDev, Ulcer Index, Historical Volatility, Bollinger Bandwidth, %B, True Range, Chaikin Volatility | | Trailing Stops | Parabolic SAR, SuperTrend, Chandelier Exit, Chande Kroll Stop, ATR Trailing Stop | | Volume | OBV, VWAP (cumulative + rolling), ADL, Volume-Price Trend, Chaikin Money Flow, Chaikin Oscillator, Force Index, Ease of Movement | diff --git a/bindings/node/__tests__/indicators.test.js b/bindings/node/__tests__/indicators.test.js index fbde1d85..24da5a22 100644 --- a/bindings/node/__tests__/indicators.test.js +++ b/bindings/node/__tests__/indicators.test.js @@ -52,6 +52,10 @@ const scalarFactories = { PMO: () => new wickra.PMO(35, 20), StochRSI: () => new wickra.StochRSI(14, 14), PPO: () => new wickra.PPO(12, 26), + APO: () => new wickra.APO(12, 26), + CFO: () => new wickra.CFO(14), + ElderImpulse: () => new wickra.ElderImpulse(13, 12, 26, 9), + STC: () => new wickra.STC(23, 50, 10, 0.5), DPO: () => new wickra.DPO(20), Coppock: () => new wickra.Coppock(14, 11, 10), StdDev: () => new wickra.StdDev(20), @@ -113,6 +117,7 @@ const candleScalar = { MedianPrice: { make: () => new wickra.MedianPrice(), step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) }, WeightedClose: { make: () => new wickra.WeightedClose(), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) }, AcceleratorOscillator: { make: () => new wickra.AcceleratorOscillator(5, 34, 5), step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) }, + AwesomeOscillatorHistogram: { make: () => new wickra.AwesomeOscillatorHistogram(5, 34, 5), step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) }, BalanceOfPower: { make: () => new wickra.BalanceOfPower(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) }, ChoppinessIndex: { make: () => new wickra.ChoppinessIndex(14), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) }, TrueRange: { make: () => new wickra.TrueRange(), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) }, @@ -136,6 +141,7 @@ for (const [name, d] of Object.entries(candleScalar)) { const multi = { KST: { make: () => new wickra.KST(10, 15, 20, 30, 10, 10, 10, 15, 9), fields: ['kst', 'signal'], step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) }, Alligator: { make: () => new wickra.Alligator(13, 8, 5), fields: ['jaw', 'teeth', 'lips'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) }, + ZeroLagMACD: { make: () => new wickra.ZeroLagMACD(12, 26, 9), fields: ['macd', 'signal', 'histogram'], step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) }, MACD: { make: () => new wickra.MACD(12, 26, 9), fields: ['macd', 'signal', 'histogram'], step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) }, BollingerBands: { make: () => new wickra.BollingerBands(20, 2), fields: ['upper', 'middle', 'lower', 'stddev'], step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) }, Stochastic: { make: () => new wickra.Stochastic(14, 3), fields: ['k', 'd'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) }, @@ -273,6 +279,54 @@ test('LinRegAngle of a unit-slope series is 45 degrees', () => { assert.ok(Math.abs(out[4] - 45) < 1e-9); }); +test('ZeroLagMACD on a flat series converges to zero', () => { + const out = new wickra.ZeroLagMACD(3, 5, 3).batch(Array(60).fill(42)); + // Last interleaved row: macd, signal, histogram all 0. + const n = 60; + assert.ok(Math.abs(out[(n - 1) * 3]) < 1e-12); + assert.ok(Math.abs(out[(n - 1) * 3 + 1]) < 1e-12); + assert.ok(Math.abs(out[(n - 1) * 3 + 2]) < 1e-12); +}); + +test('AwesomeOscillatorHistogram on a flat median converges to zero', () => { + const n = 50; + const out = new wickra.AwesomeOscillatorHistogram(3, 5, 3).batch( + Array(n).fill(11), + Array(n).fill(9), + ); + // warmup = 5 + 3 - 1 = 7. + for (let i = 6; i < n; i++) assert.ok(Math.abs(out[i]) < 1e-12); +}); + +test('STC on a flat series stays at zero', () => { + const out = new wickra.STC(3, 5, 4, 0.5).batch(Array(60).fill(42)); + // Latest values must be exactly zero. + for (let i = out.length - 5; i < out.length; i++) { + if (Number.isNaN(out[i])) continue; + assert.equal(out[i], 0); + } +}); + +test('ElderImpulse on a flat series stays neutral (0)', () => { + const out = new wickra.ElderImpulse(13, 12, 26, 9).batch(Array(120).fill(42)); + for (let i = 0; i < out.length; i++) { + if (Number.isNaN(out[i])) continue; + assert.equal(out[i], 0); + } +}); + +test('CFO(5) on a perfectly linear series yields zero', () => { + const prices = Array.from({ length: 20 }, (_, i) => (i + 1) * 2); + const out = new wickra.CFO(5).batch(prices); + for (let i = 4; i < 20; i++) assert.ok(Math.abs(out[i]) < 1e-9); +}); + +test('APO(3, 5) on a flat series converges to zero', () => { + const out = new wickra.APO(3, 5).batch(Array(30).fill(42)); + for (let i = 0; i < 4; i++) assert.ok(Number.isNaN(out[i])); + for (let i = 4; i < 30; i++) assert.ok(Math.abs(out[i]) < 1e-12); +}); + test('Inertia(3, 4) on a constant RVI series equals that RVI', () => { const n = 60; // Every bar (open, high, low, close) = (10, 11, 9, 10.5) -> RVI = 0.25. diff --git a/bindings/node/index.js b/bindings/node/index.js index ca15f937..39986fca 100644 --- a/bindings/node/index.js +++ b/bindings/node/index.js @@ -310,7 +310,7 @@ if (!nativeBinding) { throw new Error(`Failed to load native binding`) } -const { version, SMA, EMA, WMA, RSI, DEMA, TEMA, HMA, ROC, TRIX, SMMA, TRIMA, ZLEMA, MOM, CMO, DPO, StdDev, UlcerIndex, VerticalHorizontalFilter, ZScore, MACD, BollingerBands, ATR, Stochastic, OBV, ADX, CCI, WilliamsR, MFI, PSAR, Keltner, Donchian, VWAP, RollingVWAP, AwesomeOscillator, Aroon, KAMA, RVI, PGO, KST, SMI, LaguerreRSI, ConnorsRSI, Inertia, ALMA, McGinleyDynamic, FRAMA, VIDYA, JMA, Alligator, EVWMA, T3, TSI, PMO, ADL, VolumePriceTrend, ChaikinMoneyFlow, ChaikinOscillator, ForceIndex, EaseOfMovement, SuperTrend, ChandelierExit, ChandeKrollStop, AtrTrailingStop, TypicalPrice, MedianPrice, WeightedClose, LinearRegression, LinRegSlope, AcceleratorOscillator, BalanceOfPower, ChoppinessIndex, TrueRange, ChaikinVolatility, LinRegAngle, BollingerBandwidth, PercentB, NATR, HistoricalVolatility, AroonOscillator, Vortex, MassIndex, StochRSI, UltimateOscillator, PPO, Coppock, VWMA } = nativeBinding +const { version, SMA, EMA, WMA, RSI, DEMA, TEMA, HMA, ROC, TRIX, SMMA, TRIMA, ZLEMA, MOM, CMO, DPO, StdDev, UlcerIndex, VerticalHorizontalFilter, ZScore, MACD, BollingerBands, ATR, Stochastic, OBV, ADX, CCI, WilliamsR, MFI, PSAR, Keltner, Donchian, VWAP, RollingVWAP, AwesomeOscillator, Aroon, KAMA, RVI, PGO, KST, SMI, LaguerreRSI, ConnorsRSI, Inertia, ALMA, McGinleyDynamic, FRAMA, VIDYA, JMA, Alligator, EVWMA, APO, AwesomeOscillatorHistogram, CFO, ZeroLagMACD, ElderImpulse, STC, T3, TSI, PMO, ADL, VolumePriceTrend, ChaikinMoneyFlow, ChaikinOscillator, ForceIndex, EaseOfMovement, SuperTrend, ChandelierExit, ChandeKrollStop, AtrTrailingStop, TypicalPrice, MedianPrice, WeightedClose, LinearRegression, LinRegSlope, AcceleratorOscillator, BalanceOfPower, ChoppinessIndex, TrueRange, ChaikinVolatility, LinRegAngle, BollingerBandwidth, PercentB, NATR, HistoricalVolatility, AroonOscillator, Vortex, MassIndex, StochRSI, UltimateOscillator, PPO, Coppock, VWMA } = nativeBinding module.exports.version = version module.exports.SMA = SMA @@ -363,6 +363,12 @@ module.exports.VIDYA = VIDYA module.exports.JMA = JMA module.exports.Alligator = Alligator module.exports.EVWMA = EVWMA +module.exports.APO = APO +module.exports.AwesomeOscillatorHistogram = AwesomeOscillatorHistogram +module.exports.CFO = CFO +module.exports.ZeroLagMACD = ZeroLagMACD +module.exports.ElderImpulse = ElderImpulse +module.exports.STC = STC module.exports.T3 = T3 module.exports.TSI = TSI module.exports.PMO = PMO diff --git a/bindings/node/src/lib.rs b/bindings/node/src/lib.rs index 57d04472..a6bc6fde 100644 --- a/bindings/node/src/lib.rs +++ b/bindings/node/src/lib.rs @@ -1450,6 +1450,270 @@ impl RviNode { } } +#[napi(js_name = "AwesomeOscillatorHistogram")] +pub struct AwesomeOscillatorHistogramNode { + inner: wc::AwesomeOscillatorHistogram, +} +#[napi] +impl AwesomeOscillatorHistogramNode { + #[napi(constructor)] + pub fn new(fast: u32, slow: u32, sma_period: u32) -> napi::Result { + Ok(Self { + inner: wc::AwesomeOscillatorHistogram::new( + clamp_period(fast), + clamp_period(slow), + clamp_period(sma_period), + ) + .map_err(map_err)?, + }) + } + #[napi] + pub fn update(&mut self, high: f64, low: f64) -> napi::Result> { + Ok(self.inner.update(cnd(high, low, low, 0.0)?)) + } + #[napi] + pub fn batch(&mut self, high: Vec, low: Vec) -> napi::Result> { + if high.len() != low.len() { + return Err(NapiError::from_reason( + "high and low 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], low[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 + } +} + +#[napi(js_name = "STC")] +pub struct StcNode { + inner: wc::Stc, +} +#[napi] +impl StcNode { + #[napi(constructor)] + pub fn new(fast: u32, slow: u32, schaff_period: u32, factor: f64) -> napi::Result { + Ok(Self { + inner: wc::Stc::new( + clamp_period(fast), + clamp_period(slow), + clamp_period(schaff_period), + factor, + ) + .map_err(map_err)?, + }) + } + #[napi] + pub fn update(&mut self, value: f64) -> Option { + self.inner.update(value) + } + #[napi] + pub fn batch(&mut self, prices: Vec) -> Vec { + flatten(self.inner.batch(&prices)) + } + #[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 + } +} + +#[napi(js_name = "ElderImpulse")] +pub struct ElderImpulseNode { + inner: wc::ElderImpulse, +} +#[napi] +impl ElderImpulseNode { + #[napi(constructor)] + pub fn new( + ema_period: u32, + macd_fast: u32, + macd_slow: u32, + macd_signal: u32, + ) -> napi::Result { + Ok(Self { + inner: wc::ElderImpulse::new( + clamp_period(ema_period), + clamp_period(macd_fast), + clamp_period(macd_slow), + clamp_period(macd_signal), + ) + .map_err(map_err)?, + }) + } + #[napi] + pub fn update(&mut self, value: f64) -> Option { + self.inner.update(value) + } + #[napi] + pub fn batch(&mut self, prices: Vec) -> Vec { + flatten(self.inner.batch(&prices)) + } + #[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 + } +} + +#[napi(object)] +pub struct ZeroLagMacdValue { + pub macd: f64, + pub signal: f64, + pub histogram: f64, +} + +#[napi(js_name = "ZeroLagMACD")] +pub struct ZeroLagMacdNode { + inner: wc::ZeroLagMacd, +} +#[napi] +impl ZeroLagMacdNode { + #[napi(constructor)] + pub fn new(fast: u32, slow: u32, signal: u32) -> napi::Result { + Ok(Self { + inner: wc::ZeroLagMacd::new( + clamp_period(fast), + clamp_period(slow), + clamp_period(signal), + ) + .map_err(map_err)?, + }) + } + #[napi] + pub fn update(&mut self, value: f64) -> Option { + self.inner.update(value).map(|o| ZeroLagMacdValue { + macd: o.macd, + signal: o.signal, + histogram: o.histogram, + }) + } + #[napi] + pub fn batch(&mut self, prices: Vec) -> Vec { + let n = prices.len(); + let mut out = vec![f64::NAN; n * 3]; + for (i, p) in prices.iter().enumerate() { + if let Some(o) = self.inner.update(*p) { + out[i * 3] = o.macd; + out[i * 3 + 1] = o.signal; + out[i * 3 + 2] = o.histogram; + } + } + 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 + } +} + +#[napi(js_name = "CFO")] +pub struct CfoNode { + inner: wc::Cfo, +} +#[napi] +impl CfoNode { + #[napi(constructor)] + pub fn new(period: u32) -> napi::Result { + Ok(Self { + inner: wc::Cfo::new(clamp_period(period)).map_err(map_err)?, + }) + } + #[napi] + pub fn update(&mut self, value: f64) -> Option { + self.inner.update(value) + } + #[napi] + pub fn batch(&mut self, prices: Vec) -> Vec { + flatten(self.inner.batch(&prices)) + } + #[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 + } +} + +#[napi(js_name = "APO")] +pub struct ApoNode { + inner: wc::Apo, +} +#[napi] +impl ApoNode { + #[napi(constructor)] + pub fn new(fast: u32, slow: u32) -> napi::Result { + Ok(Self { + inner: wc::Apo::new(clamp_period(fast), clamp_period(slow)).map_err(map_err)?, + }) + } + #[napi] + pub fn update(&mut self, value: f64) -> Option { + self.inner.update(value) + } + #[napi] + pub fn batch(&mut self, prices: Vec) -> Vec { + flatten(self.inner.batch(&prices)) + } + #[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 + } +} + #[napi(js_name = "KAMA")] pub struct KamaNode { inner: wc::Kama, diff --git a/bindings/python/python/wickra/__init__.py b/bindings/python/python/wickra/__init__.py index c0d05495..dc78bda6 100644 --- a/bindings/python/python/wickra/__init__.py +++ b/bindings/python/python/wickra/__init__.py @@ -70,6 +70,12 @@ from ._wickra import ( LaguerreRSI, ConnorsRSI, Inertia, + APO, + AwesomeOscillatorHistogram, + CFO, + ZeroLagMACD, + ElderImpulse, + STC, PPO, DPO, Coppock, @@ -165,6 +171,12 @@ __all__ = [ "LaguerreRSI", "ConnorsRSI", "Inertia", + "APO", + "AwesomeOscillatorHistogram", + "CFO", + "ZeroLagMACD", + "ElderImpulse", + "STC", "PPO", "DPO", "Coppock", diff --git a/bindings/python/src/lib.rs b/bindings/python/src/lib.rs index 78491455..30461aab 100644 --- a/bindings/python/src/lib.rs +++ b/bindings/python/src/lib.rs @@ -1620,6 +1620,315 @@ impl PyAlma { } } +// ============================== AwesomeOscillatorHistogram ============================== + +#[pyclass( + name = "AwesomeOscillatorHistogram", + module = "wickra._wickra", + skip_from_py_object +)] +#[derive(Clone)] +struct PyAoHist { + inner: wc::AwesomeOscillatorHistogram, +} + +#[pymethods] +impl PyAoHist { + #[new] + #[pyo3(signature = (fast=5, slow=34, sma_period=5))] + fn new(fast: usize, slow: usize, sma_period: usize) -> PyResult { + Ok(Self { + inner: wc::AwesomeOscillatorHistogram::new(fast, slow, sma_period).map_err(map_err)?, + }) + } + fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult> { + let c = extract_candle(candle)?; + Ok(self.inner.update(c)) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + 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 mut out = Vec::with_capacity(h.len()); + for i in 0..h.len() { + let candle = wc::Candle::new(l[i], h[i], l[i], l[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() + } + fn __repr__(&self) -> String { + let (f, s, k) = self.inner.periods(); + format!("AwesomeOscillatorHistogram(fast={f}, slow={s}, sma_period={k})") + } +} + +// ============================== STC ============================== + +#[pyclass(name = "STC", module = "wickra._wickra", skip_from_py_object)] +#[derive(Clone)] +struct PyStc { + inner: wc::Stc, +} + +#[pymethods] +impl PyStc { + #[new] + #[pyo3(signature = (fast=23, slow=50, schaff_period=10, factor=0.5))] + fn new(fast: usize, slow: usize, schaff_period: usize, factor: f64) -> PyResult { + Ok(Self { + inner: wc::Stc::new(fast, slow, schaff_period, factor).map_err(map_err)?, + }) + } + fn update(&mut self, value: f64) -> Option { + self.inner.update(value) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + prices: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let s = prices + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + Ok(flatten(self.inner.batch(s)).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 { + let (f, s, p, k) = self.inner.params(); + format!("STC(fast={f}, slow={s}, schaff_period={p}, factor={k})") + } +} + +// ============================== ElderImpulse ============================== + +#[pyclass(name = "ElderImpulse", module = "wickra._wickra", skip_from_py_object)] +#[derive(Clone)] +struct PyElderImpulse { + inner: wc::ElderImpulse, +} + +#[pymethods] +impl PyElderImpulse { + #[new] + #[pyo3(signature = (ema_period=13, macd_fast=12, macd_slow=26, macd_signal=9))] + fn new( + ema_period: usize, + macd_fast: usize, + macd_slow: usize, + macd_signal: usize, + ) -> PyResult { + Ok(Self { + inner: wc::ElderImpulse::new(ema_period, macd_fast, macd_slow, macd_signal) + .map_err(map_err)?, + }) + } + fn update(&mut self, value: f64) -> Option { + self.inner.update(value) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + prices: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let s = prices + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + Ok(flatten(self.inner.batch(s)).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 { + let (e, f, s, sig) = self.inner.periods(); + format!("ElderImpulse(ema_period={e}, macd_fast={f}, macd_slow={s}, macd_signal={sig})") + } +} + +// ============================== ZeroLagMACD ============================== + +#[pyclass(name = "ZeroLagMACD", module = "wickra._wickra", skip_from_py_object)] +#[derive(Clone)] +struct PyZeroLagMacd { + inner: wc::ZeroLagMacd, +} + +#[pymethods] +impl PyZeroLagMacd { + #[new] + #[pyo3(signature = (fast=12, slow=26, signal=9))] + fn new(fast: usize, slow: usize, signal: usize) -> PyResult { + Ok(Self { + inner: wc::ZeroLagMacd::new(fast, slow, signal).map_err(map_err)?, + }) + } + fn update(&mut self, value: f64) -> Option<(f64, f64, f64)> { + self.inner + .update(value) + .map(|o| (o.macd, o.signal, o.histogram)) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + prices: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let slice = prices + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let n = slice.len(); + let mut out = vec![f64::NAN; n * 3]; + for (i, p) in slice.iter().enumerate() { + if let Some(o) = self.inner.update(*p) { + out[i * 3] = o.macd; + out[i * 3 + 1] = o.signal; + out[i * 3 + 2] = o.histogram; + } + } + 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 { + let (f, s, sig) = self.inner.periods(); + format!("ZeroLagMACD(fast={f}, slow={s}, signal={sig})") + } +} + +// ============================== CFO ============================== + +#[pyclass(name = "CFO", module = "wickra._wickra", skip_from_py_object)] +#[derive(Clone)] +struct PyCfo { + inner: wc::Cfo, +} + +#[pymethods] +impl PyCfo { + #[new] + #[pyo3(signature = (period=14))] + fn new(period: usize) -> PyResult { + Ok(Self { + inner: wc::Cfo::new(period).map_err(map_err)?, + }) + } + fn update(&mut self, value: f64) -> Option { + self.inner.update(value) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + prices: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let s = prices + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + Ok(flatten(self.inner.batch(s)).into_pyarray(py)) + } + #[getter] + fn period(&self) -> usize { + self.inner.period() + } + 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 { + format!("CFO(period={})", self.inner.period()) + } +} + +// ============================== APO ============================== + +#[pyclass(name = "APO", module = "wickra._wickra", skip_from_py_object)] +#[derive(Clone)] +struct PyApo { + inner: wc::Apo, +} + +#[pymethods] +impl PyApo { + #[new] + #[pyo3(signature = (fast=12, slow=26))] + fn new(fast: usize, slow: usize) -> PyResult { + Ok(Self { + inner: wc::Apo::new(fast, slow).map_err(map_err)?, + }) + } + fn update(&mut self, value: f64) -> Option { + self.inner.update(value) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + prices: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let s = prices + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + Ok(flatten(self.inner.batch(s)).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 { + let (f, s) = self.inner.periods(); + format!("APO(fast={f}, slow={s})") + } +} + // ============================== CCI ============================== #[pyclass(name = "CCI", module = "wickra._wickra", skip_from_py_object)] @@ -5375,5 +5684,11 @@ fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; m.add_class::()?; m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; Ok(()) } diff --git a/bindings/python/tests/test_known_values.py b/bindings/python/tests/test_known_values.py index abed1385..8cb47c86 100644 --- a/bindings/python/tests/test_known_values.py +++ b/bindings/python/tests/test_known_values.py @@ -234,6 +234,56 @@ def test_vidya_constant_series_holds_seed(): np.testing.assert_allclose(out[4:], 42.0, atol=1e-12) +def test_zero_lag_macd_constant_series_converges_to_zero(): + # Each inner ZLEMA reproduces a constant, so macd, signal and histogram + # are all 0 once the slowest branch warms up. + out = ta.ZeroLagMACD(3, 5, 3).batch(np.full(60, 42.0, dtype=np.float64)) + # Take the last row and verify all three columns are 0. + last = out[-1] + assert math.isclose(last[0], 0.0, abs_tol=1e-12) + assert math.isclose(last[1], 0.0, abs_tol=1e-12) + assert math.isclose(last[2], 0.0, abs_tol=1e-12) + + +def test_awesome_oscillator_histogram_flat_series_converges_to_zero(): + # Flat median price -> AO = 0 -> SMA(AO) = 0 -> AOHist = 0. + n = 50 + high = np.full(n, 11.0) + low = np.full(n, 9.0) + out = ta.AwesomeOscillatorHistogram(3, 5, 3).batch(high, low) + # warmup = slow + sma - 1 = 5 + 3 - 1 = 7. + np.testing.assert_allclose(out[6:], 0.0, atol=1e-12) + + +def test_stc_constant_series_yields_zero(): + # Flat input collapses both stochastic stages to zero -> STC stays at 0. + out = ta.STC(3, 5, 4, 0.5).batch(np.full(60, 42.0, dtype=np.float64)) + ready = out[~np.isnan(out)] + assert ready.size > 0 + np.testing.assert_array_equal(ready[-5:], np.zeros(5)) + + +def test_elder_impulse_constant_series_is_neutral(): + # Flat input -> neither EMA nor MACD histogram moves -> Impulse stays at 0. + out = ta.ElderImpulse(13, 12, 26, 9).batch(np.full(120, 42.0, dtype=np.float64)) + ready = out[~np.isnan(out)] + assert ready.size > 0 + np.testing.assert_array_equal(ready, np.zeros_like(ready)) + + +def test_cfo_perfect_linear_series_yields_zero(): + # LinReg of a perfectly linear series fits exactly, so CFO = 0 after warmup. + out = ta.CFO(5).batch(np.arange(1.0, 21.0, dtype=np.float64) * 2.0) + np.testing.assert_allclose(out[4:], 0.0, atol=1e-9) + + +def test_apo_constant_series_converges_to_zero(): + # Both EMAs reproduce a constant exactly, so APO = 0 after warmup. + out = ta.APO(3, 5).batch(np.full(30, 42.0, dtype=np.float64)) + assert np.all(np.isnan(out[:4])) + np.testing.assert_allclose(out[4:], 0.0, atol=1e-12) + + def test_macd_constant_series_converges_to_zero(): out = ta.MACD().batch(np.full(200, 100.0)) # Last row's MACD and signal must be ~0. diff --git a/bindings/python/tests/test_new_indicators.py b/bindings/python/tests/test_new_indicators.py index efdfdc3b..aedcbd14 100644 --- a/bindings/python/tests/test_new_indicators.py +++ b/bindings/python/tests/test_new_indicators.py @@ -56,6 +56,10 @@ SCALAR = [ (ta.PMO, (35, 20)), (ta.StochRSI, (14, 14)), (ta.PPO, (12, 26)), + (ta.APO, (12, 26)), + (ta.CFO, (14,)), + (ta.ElderImpulse, (13, 12, 26, 9)), + (ta.STC, (23, 50, 10, 0.5)), (ta.DPO, (20,)), (ta.Coppock, (14, 11, 10)), (ta.StdDev, (20,)), @@ -159,6 +163,10 @@ CANDLE_SCALAR = { lambda: ta.AcceleratorOscillator(5, 34, 5), lambda ind, h, l, c, v: ind.batch(h, l), ), + "AwesomeOscillatorHistogram": ( + lambda: ta.AwesomeOscillatorHistogram(5, 34, 5), + lambda ind, h, l, c, v: ind.batch(h, l), + ), "BalanceOfPower": ( # The streaming 6-tuple feeds open == close, so batch matches with # the close column standing in for open. @@ -275,6 +283,22 @@ def test_multi_scalar_streaming_matches_batch(name, ohlcv): assert _eq_nan(batch, np.array(rows, dtype=np.float64)), f"{name} mismatch" +# --- ZeroLagMACD (scalar input, 3-tuple output: macd / signal / histogram) - + + +def test_zero_lag_macd_streaming_matches_batch(ohlcv): + _, _, close, _ = ohlcv + batch = ta.ZeroLagMACD(12, 26, 9).batch(close) + assert batch.shape == (close.size, 3) + + streamer = ta.ZeroLagMACD(12, 26, 9) + rows = [] + for p in close: + v = streamer.update(float(p)) + rows.append([math.nan, math.nan, math.nan] if v is None else list(v)) + assert _eq_nan(batch, np.array(rows, dtype=np.float64)), "ZeroLagMACD mismatch" + + # --- Alligator (3-tuple output) ------------------------------------------- @@ -362,8 +386,10 @@ def test_z_score_reference(): def test_new_indicators_expose_lifecycle(): instances = [make() for make, _ in CANDLE_SCALAR.values()] instances += [make() for make, _ in MULTI.values()] + instances += [make() for make, _ in MULTI_SCALAR_INPUT.values()] instances += [cls(*args) for cls, args in SCALAR] instances.append(ta.Alligator(13, 8, 5)) + instances.append(ta.ZeroLagMACD(12, 26, 9)) for ind in instances: assert ind.is_ready() is False assert ind.warmup_period() >= 1 diff --git a/bindings/wasm/src/lib.rs b/bindings/wasm/src/lib.rs index b67afc75..3c158a01 100644 --- a/bindings/wasm/src/lib.rs +++ b/bindings/wasm/src/lib.rs @@ -91,6 +91,62 @@ wasm_scalar_indicator!(WasmPmo, "PMO", wc::Pmo, smoothing1: usize, smoothing2: u wasm_scalar_indicator!(WasmStochRsi, "StochRSI", wc::StochRsi, rsi_period: usize, stoch_period: usize); wasm_scalar_indicator!(WasmDpo, "DPO", wc::Dpo, period: usize); wasm_scalar_indicator!(WasmPpo, "PPO", wc::Ppo, fast: usize, slow: usize); +wasm_scalar_indicator!(WasmApo, "APO", wc::Apo, fast: usize, slow: usize); +wasm_scalar_indicator!(WasmCfo, "CFO", wc::Cfo, period: usize); +wasm_scalar_indicator!(WasmElderImpulse, "ElderImpulse", wc::ElderImpulse, ema_period: usize, macd_fast: usize, macd_slow: usize, macd_signal: usize); +wasm_scalar_indicator!(WasmStc, "STC", wc::Stc, fast: usize, slow: usize, schaff_period: usize, factor: f64); + +#[wasm_bindgen(js_name = ZeroLagMACD)] +pub struct WasmZeroLagMacd { + inner: wc::ZeroLagMacd, +} + +#[wasm_bindgen(js_class = ZeroLagMACD)] +impl WasmZeroLagMacd { + #[wasm_bindgen(constructor)] + pub fn new(fast: usize, slow: usize, signal: usize) -> Result { + Ok(Self { + inner: wc::ZeroLagMacd::new(fast, slow, signal).map_err(map_err)?, + }) + } + /// Returns `[macd0, signal0, histogram0, ...]`, length `3n`. + pub fn batch(&mut self, prices: &[f64]) -> Float64Array { + let n = prices.len(); + let mut out = vec![f64::NAN; n * 3]; + for (i, p) in prices.iter().enumerate() { + if let Some(o) = self.inner.update(*p) { + out[i * 3] = o.macd; + out[i * 3 + 1] = o.signal; + out[i * 3 + 2] = o.histogram; + } + } + Float64Array::from(out.as_slice()) + } + /// Returns `{ macd, signal, histogram }` once warm, else `null`. + pub fn update(&mut self, value: f64) -> JsValue { + match self.inner.update(value) { + Some(o) => { + let obj = Object::new(); + Reflect::set(&obj, &"macd".into(), &o.macd.into()).ok(); + Reflect::set(&obj, &"signal".into(), &o.signal.into()).ok(); + Reflect::set(&obj, &"histogram".into(), &o.histogram.into()).ok(); + obj.into() + } + None => JsValue::NULL, + } + } + 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_scalar_indicator!(WasmCoppock, "Coppock", wc::Coppock, roc_long: usize, roc_short: usize, wma_period: usize); wasm_scalar_indicator!(WasmStdDev, "StdDev", wc::StdDev, period: usize); wasm_scalar_indicator!(WasmUlcerIndex, "UlcerIndex", wc::UlcerIndex, period: usize); @@ -2218,6 +2274,47 @@ impl WasmRollingVwap { } } +#[wasm_bindgen(js_name = AwesomeOscillatorHistogram)] +pub struct WasmAoHist { + inner: wc::AwesomeOscillatorHistogram, +} + +#[wasm_bindgen(js_class = AwesomeOscillatorHistogram)] +impl WasmAoHist { + #[wasm_bindgen(constructor)] + pub fn new(fast: usize, slow: usize, sma_period: usize) -> Result { + Ok(Self { + inner: wc::AwesomeOscillatorHistogram::new(fast, slow, sma_period).map_err(map_err)?, + }) + } + pub fn update(&mut self, high: f64, low: f64) -> Result, JsError> { + let c = make_candle(high, low, low, 0.0)?; + Ok(self.inner.update(c)) + } + pub fn batch(&mut self, high: &[f64], low: &[f64]) -> Result { + if high.len() != low.len() { + return Err(JsError::new("high and low must be equal length")); + } + let mut out = Vec::with_capacity(high.len()); + for i in 0..high.len() { + let c = make_candle(high[i], low[i], low[i], 0.0)?; + out.push(self.inner.update(c).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() + } +} + #[wasm_bindgen(js_name = AwesomeOscillator)] pub struct WasmAo { inner: wc::AwesomeOscillator, diff --git a/crates/wickra-core/src/indicators/apo.rs b/crates/wickra-core/src/indicators/apo.rs new file mode 100644 index 00000000..6b5e242e --- /dev/null +++ b/crates/wickra-core/src/indicators/apo.rs @@ -0,0 +1,183 @@ +//! Absolute Price Oscillator (APO). + +use crate::error::{Error, Result}; +use crate::indicators::ema::Ema; +use crate::traits::Indicator; + +/// Absolute Price Oscillator — the raw difference between a fast and a slow +/// `EMA`. This is MACD's line without the signal-EMA — useful when only the +/// momentum-direction reading is needed. +/// +/// ```text +/// APO_t = EMA(close, fast)_t − EMA(close, slow)_t +/// ``` +/// +/// Default parameters mirror MACD: `(fast = 12, slow = 26)`. `fast` must be +/// strictly less than `slow`. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Apo, Indicator}; +/// +/// let mut apo = Apo::new(12, 26).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// last = apo.update(100.0 + f64::from(i)); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct Apo { + fast_period: usize, + slow_period: usize, + fast: Ema, + slow: Ema, +} + +impl Apo { + /// # Errors + /// - [`Error::PeriodZero`] if either period is zero. + /// - [`Error::InvalidPeriod`] if `fast >= slow`. + pub fn new(fast: usize, slow: usize) -> Result { + if fast == 0 || slow == 0 { + return Err(Error::PeriodZero); + } + if fast >= slow { + return Err(Error::InvalidPeriod { + message: "APO fast period must be strictly less than slow", + }); + } + Ok(Self { + fast_period: fast, + slow_period: slow, + fast: Ema::new(fast)?, + slow: Ema::new(slow)?, + }) + } + + /// MACD-style defaults: `(fast = 12, slow = 26)`. + pub fn classic() -> Self { + Self::new(12, 26).expect("classic APO parameters are valid") + } + + /// Configured `(fast, slow)`. + pub const fn periods(&self) -> (usize, usize) { + (self.fast_period, self.slow_period) + } +} + +impl Indicator for Apo { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + // Feed both EMAs on every input so the slow one warms in parallel. + let f = self.fast.update(input); + let s = self.slow.update(input); + Some(f? - s?) + } + + fn reset(&mut self) { + self.fast.reset(); + self.slow.reset(); + } + + fn warmup_period(&self) -> usize { + // Slow EMA dominates; both EMAs emit at their `period` th input. + self.slow_period + } + + fn is_ready(&self) -> bool { + self.slow.is_ready() + } + + fn name(&self) -> &'static str { + "APO" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_zero_period() { + assert!(matches!(Apo::new(0, 26), Err(Error::PeriodZero))); + assert!(matches!(Apo::new(12, 0), Err(Error::PeriodZero))); + } + + #[test] + fn rejects_fast_geq_slow() { + assert!(matches!(Apo::new(26, 12), Err(Error::InvalidPeriod { .. }))); + assert!(matches!(Apo::new(12, 12), Err(Error::InvalidPeriod { .. }))); + } + + #[test] + fn accessors_and_metadata() { + let apo = Apo::classic(); + assert_eq!(apo.periods(), (12, 26)); + assert_eq!(apo.warmup_period(), 26); + assert_eq!(apo.name(), "APO"); + } + + #[test] + fn classic_factory() { + assert_eq!(Apo::classic().periods(), (12, 26)); + } + + #[test] + fn constant_series_converges_to_zero() { + // Both EMAs reproduce the constant exactly, so APO is 0. + let mut apo = Apo::new(3, 5).unwrap(); + let out = apo.batch(&[42.0_f64; 30]); + for v in out.iter().skip(4).flatten() { + assert_relative_eq!(*v, 0.0, epsilon = 1e-12); + } + } + + #[test] + fn warmup_emits_first_value_at_slow_period() { + let mut apo = Apo::new(2, 4).unwrap(); + assert_eq!(apo.warmup_period(), 4); + for i in 1..=3 { + assert_eq!(apo.update(f64::from(i)), None); + } + assert!(apo.update(4.0).is_some()); + } + + #[test] + fn pure_uptrend_is_positive() { + // Fast EMA leads the slow EMA on an uptrend, so APO > 0. + let mut apo = Apo::classic(); + let prices: Vec = (1..=200).map(f64::from).collect(); + let out = apo.batch(&prices); + let last = out.iter().rev().flatten().next().unwrap(); + assert!(*last > 0.0, "APO on uptrend should be positive: {last}"); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (1..=120) + .map(|i| 100.0 + (f64::from(i) * 0.2).sin() * 5.0) + .collect(); + let mut a = Apo::classic(); + let mut b = Apo::classic(); + assert_eq!( + a.batch(&prices), + prices.iter().map(|p| b.update(*p)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut apo = Apo::classic(); + apo.batch(&(1..=80).map(f64::from).collect::>()); + assert!(apo.is_ready()); + apo.reset(); + assert!(!apo.is_ready()); + assert_eq!(apo.update(1.0), None); + } +} diff --git a/crates/wickra-core/src/indicators/awesome_oscillator_histogram.rs b/crates/wickra-core/src/indicators/awesome_oscillator_histogram.rs new file mode 100644 index 00000000..610e03da --- /dev/null +++ b/crates/wickra-core/src/indicators/awesome_oscillator_histogram.rs @@ -0,0 +1,198 @@ +//! Awesome Oscillator Histogram. + +use crate::error::{Error, Result}; +use crate::indicators::awesome_oscillator::AwesomeOscillator; +use crate::indicators::sma::Sma; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// "Awesome Oscillator Histogram" — the difference between the Awesome +/// Oscillator and its `sma_period`-bar `SMA`. Positive bars mean `AO` is +/// trending up (bullish acceleration); negative bars mean `AO` is trending +/// down (bearish acceleration). +/// +/// ```text +/// AO = SMA(median, fast) − SMA(median, slow) +/// AOHist = AO − SMA(AO, sma_period) +/// ``` +/// +/// With Williams' default `sma_period = 5`, this collapses to the existing +/// `AcceleratorOscillator` for `fast = 5, slow = 34, sma_period = 5`; for any +/// other parameterisation this is a more flexible variant. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{AwesomeOscillatorHistogram, Candle, Indicator}; +/// +/// let mut hist = AwesomeOscillatorHistogram::classic(); +/// let mut last = None; +/// for i in 0..80 { +/// let p = 100.0 + f64::from(i); +/// let candle = Candle::new(p, p + 0.5, p - 0.5, p, 1.0, i64::from(i)).unwrap(); +/// last = hist.update(candle); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct AwesomeOscillatorHistogram { + fast_period: usize, + slow_period: usize, + sma_period: usize, + ao: AwesomeOscillator, + sma: Sma, +} + +impl AwesomeOscillatorHistogram { + /// # Errors + /// - [`Error::PeriodZero`] if any period is zero. + /// - [`Error::InvalidPeriod`] if `fast >= slow`. + pub fn new(fast: usize, slow: usize, sma_period: usize) -> Result { + if fast == 0 || slow == 0 || sma_period == 0 { + return Err(Error::PeriodZero); + } + if fast >= slow { + return Err(Error::InvalidPeriod { + message: "AwesomeOscillatorHistogram fast must be strictly less than slow", + }); + } + Ok(Self { + fast_period: fast, + slow_period: slow, + sma_period, + ao: AwesomeOscillator::new(fast, slow)?, + sma: Sma::new(sma_period)?, + }) + } + + /// Bill Williams' Accelerator-equivalent defaults `(5, 34, 5)`. + pub fn classic() -> Self { + Self::new(5, 34, 5).expect("classic Awesome Oscillator Histogram parameters are valid") + } + + /// Configured `(fast_period, slow_period, sma_period)`. + pub const fn periods(&self) -> (usize, usize, usize) { + (self.fast_period, self.slow_period, self.sma_period) + } +} + +impl Indicator for AwesomeOscillatorHistogram { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + let ao = self.ao.update(candle)?; + let sma = self.sma.update(ao)?; + Some(ao - sma) + } + + fn reset(&mut self) { + self.ao.reset(); + self.sma.reset(); + } + + fn warmup_period(&self) -> usize { + // AO emits at `slow` candles; the SMA then needs `sma_period - 1` + // more AO values to fill its window. + self.slow_period + self.sma_period - 1 + } + + fn is_ready(&self) -> bool { + self.sma.is_ready() + } + + fn name(&self) -> &'static str { + "AwesomeOscillatorHistogram" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn candle(price: f64, ts: i64) -> Candle { + Candle::new(price, price + 0.5, price - 0.5, price, 1.0, ts).unwrap() + } + + #[test] + fn rejects_zero_period() { + assert!(matches!( + AwesomeOscillatorHistogram::new(0, 34, 5), + Err(Error::PeriodZero) + )); + assert!(matches!( + AwesomeOscillatorHistogram::new(5, 0, 5), + Err(Error::PeriodZero) + )); + assert!(matches!( + AwesomeOscillatorHistogram::new(5, 34, 0), + Err(Error::PeriodZero) + )); + } + + #[test] + fn rejects_fast_geq_slow() { + assert!(matches!( + AwesomeOscillatorHistogram::new(34, 5, 5), + Err(Error::InvalidPeriod { .. }) + )); + } + + #[test] + fn accessors_and_metadata() { + let hist = AwesomeOscillatorHistogram::classic(); + assert_eq!(hist.periods(), (5, 34, 5)); + assert_eq!(hist.warmup_period(), 38); + assert_eq!(hist.name(), "AwesomeOscillatorHistogram"); + } + + #[test] + fn constant_series_converges_to_zero() { + // AO of a flat series is 0; SMA of 0 is 0; difference is 0. + let mut hist = AwesomeOscillatorHistogram::new(3, 5, 3).unwrap(); + let candles: Vec = (0..30).map(|i| candle(42.0, i)).collect(); + let out = hist.batch(&candles); + for v in out.iter().skip(hist.warmup_period() - 1).flatten() { + assert_relative_eq!(*v, 0.0, epsilon = 1e-12); + } + } + + #[test] + fn warmup_emits_first_value_at_warmup_period() { + let mut hist = AwesomeOscillatorHistogram::new(2, 4, 3).unwrap(); + assert_eq!(hist.warmup_period(), 6); + let candles: Vec = (0..8) + .map(|i| candle(10.0 + f64::from(i), i64::from(i))) + .collect(); + let out = hist.batch(&candles); + for v in out.iter().take(5) { + assert!(v.is_none()); + } + assert!(out[5].is_some()); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..100_i64) + .map(|i| candle(100.0 + (i as f64 * 0.3).sin() * 5.0, i)) + .collect(); + let batch = AwesomeOscillatorHistogram::classic().batch(&candles); + let mut b = AwesomeOscillatorHistogram::classic(); + let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect(); + assert_eq!(batch, streamed); + } + + #[test] + fn reset_clears_state() { + let mut hist = AwesomeOscillatorHistogram::classic(); + let candles: Vec = (0..80) + .map(|i| candle(10.0 + f64::from(i), i64::from(i))) + .collect(); + hist.batch(&candles); + assert!(hist.is_ready()); + hist.reset(); + assert!(!hist.is_ready()); + } +} diff --git a/crates/wickra-core/src/indicators/cfo.rs b/crates/wickra-core/src/indicators/cfo.rs new file mode 100644 index 00000000..6c2168f4 --- /dev/null +++ b/crates/wickra-core/src/indicators/cfo.rs @@ -0,0 +1,173 @@ +//! Chande Forecast Oscillator (CFO). + +use crate::error::{Error, Result}; +use crate::indicators::linreg::LinearRegression; +use crate::traits::Indicator; + +/// Tushar Chande's Forecast Oscillator — the percentage difference between +/// the close and the endpoint of an `n`-bar linear-regression forecast of the +/// close. +/// +/// ```text +/// CFO_t = 100 · (close_t − LinearRegression(close, period)_t) / close_t +/// ``` +/// +/// Positive readings mean the close is *above* the linear forecast (price has +/// overshot trend); negative readings mean it sits below. Wraps the existing +/// `LinearRegression` so the warmup matches. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Cfo, Indicator}; +/// +/// let mut cfo = Cfo::new(14).unwrap(); +/// let mut last = None; +/// for i in 0..40 { +/// last = cfo.update(100.0 + f64::from(i)); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct Cfo { + period: usize, + linreg: LinearRegression, + current: Option, +} + +impl Cfo { + /// # Errors + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + period, + linreg: LinearRegression::new(period)?, + current: None, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } +} + +impl Indicator for Cfo { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + let forecast = self.linreg.update(input)?; + // Hold the previous value if the close is zero — the percentage form + // is undefined and a return of inf would propagate badly. + if input == 0.0 { + return self.current; + } + let value = 100.0 * (input - forecast) / input; + self.current = Some(value); + Some(value) + } + + fn reset(&mut self) { + self.linreg.reset(); + self.current = None; + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.current.is_some() + } + + fn name(&self) -> &'static str { + "CFO" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_zero_period() { + assert!(matches!(Cfo::new(0), Err(Error::PeriodZero))); + } + + #[test] + fn accessors_and_metadata() { + let cfo = Cfo::new(14).unwrap(); + assert_eq!(cfo.period(), 14); + assert_eq!(cfo.warmup_period(), 14); + assert_eq!(cfo.name(), "CFO"); + } + + #[test] + fn constant_series_yields_zero() { + // LinReg of a constant series equals the constant, so close − forecast + // is 0 and CFO is 0. + let mut cfo = Cfo::new(5).unwrap(); + let out = cfo.batch(&[42.0_f64; 30]); + for v in out.iter().skip(4).flatten() { + assert_relative_eq!(*v, 0.0, epsilon = 1e-12); + } + } + + #[test] + fn perfect_linear_series_yields_zero() { + // LinReg of a perfectly linear input fits the line exactly, so the + // close lands on the forecast and CFO = 0. + let mut cfo = Cfo::new(5).unwrap(); + let prices: Vec = (1..=20).map(|i| f64::from(i) * 2.0).collect(); + let out = cfo.batch(&prices); + for v in out.iter().skip(4).flatten() { + assert_relative_eq!(*v, 0.0, epsilon = 1e-9); + } + } + + #[test] + fn warmup_emits_first_value_at_period() { + let mut cfo = Cfo::new(3).unwrap(); + for i in 1..=2 { + assert_eq!(cfo.update(f64::from(i)), None); + } + assert!(cfo.update(3.0).is_some()); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (1..=80) + .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0) + .collect(); + let mut a = Cfo::new(14).unwrap(); + let mut b = Cfo::new(14).unwrap(); + assert_eq!( + a.batch(&prices), + prices.iter().map(|p| b.update(*p)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut cfo = Cfo::new(5).unwrap(); + cfo.batch(&(1..=20).map(f64::from).collect::>()); + assert!(cfo.is_ready()); + cfo.reset(); + assert!(!cfo.is_ready()); + assert_eq!(cfo.update(1.0), None); + } + + #[test] + fn zero_close_holds_value() { + let mut cfo = Cfo::new(3).unwrap(); + cfo.batch(&[1.0_f64, 2.0, 3.0]); + let before = cfo.current; + assert_eq!(cfo.update(0.0), before); + } +} diff --git a/crates/wickra-core/src/indicators/elder_impulse.rs b/crates/wickra-core/src/indicators/elder_impulse.rs new file mode 100644 index 00000000..919474c6 --- /dev/null +++ b/crates/wickra-core/src/indicators/elder_impulse.rs @@ -0,0 +1,243 @@ +//! Elder Impulse System. + +use crate::error::{Error, Result}; +use crate::indicators::ema::Ema; +use crate::indicators::macd::MacdIndicator; +use crate::traits::Indicator; + +/// Alexander Elder's Impulse System — a tri-state momentum gauge combining the +/// slope of an `EMA` trend filter with the slope of the `MACD` histogram. +/// +/// On each bar Wickra reports: +/// +/// - `+1` ("green / buy") when both the `EMA` trend and the `MACD` histogram +/// are rising bar-over-bar. +/// - `−1` ("red / sell") when both are falling. +/// - `0` ("blue / neutral") when the two disagree. +/// +/// The defaults track Elder's *Come Into My Trading Room* parameterisation: +/// `EMA(13)` for the trend, `MACD(12, 26, 9)` for the histogram. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{ElderImpulse, Indicator}; +/// +/// let mut elder = ElderImpulse::classic(); +/// let mut last = None; +/// for i in 0..120 { +/// last = elder.update(100.0 + f64::from(i)); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct ElderImpulse { + ema_period: usize, + macd_fast: usize, + macd_slow: usize, + macd_signal: usize, + ema: Ema, + macd: MacdIndicator, + prev_ema: Option, + prev_hist: Option, + current: Option, +} + +impl ElderImpulse { + /// # Errors + /// Forwarded from [`Ema::new`] / [`MacdIndicator::new`]. + pub fn new( + ema_period: usize, + macd_fast: usize, + macd_slow: usize, + macd_signal: usize, + ) -> Result { + if ema_period == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + ema_period, + macd_fast, + macd_slow, + macd_signal, + ema: Ema::new(ema_period)?, + macd: MacdIndicator::new(macd_fast, macd_slow, macd_signal)?, + prev_ema: None, + prev_hist: None, + current: None, + }) + } + + /// Elder's recommended defaults `(ema_period = 13, macd = 12/26/9)`. + pub fn classic() -> Self { + Self::new(13, 12, 26, 9).expect("classic Elder Impulse parameters are valid") + } + + /// Configured `(ema_period, macd_fast, macd_slow, macd_signal)`. + pub const fn periods(&self) -> (usize, usize, usize, usize) { + ( + self.ema_period, + self.macd_fast, + self.macd_slow, + self.macd_signal, + ) + } +} + +impl Indicator for ElderImpulse { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + // Feed both branches on every input so they warm in parallel. + let ema_now = self.ema.update(input); + let macd_now = self.macd.update(input); + let (ema_now, macd_now) = (ema_now?, macd_now?); + + // The Impulse needs two consecutive readings on both branches to + // judge direction. The first ready bar seeds prev_*; the second emits. + let prev_ema = self.prev_ema; + let prev_hist = self.prev_hist; + self.prev_ema = Some(ema_now); + self.prev_hist = Some(macd_now.histogram); + let prev_ema = prev_ema?; + let prev_hist = prev_hist?; + + let ema_rising = ema_now > prev_ema; + let ema_falling = ema_now < prev_ema; + let hist_rising = macd_now.histogram > prev_hist; + let hist_falling = macd_now.histogram < prev_hist; + + let value = if ema_rising && hist_rising { + 1.0 + } else if ema_falling && hist_falling { + -1.0 + } else { + 0.0 + }; + self.current = Some(value); + Some(value) + } + + fn reset(&mut self) { + self.ema.reset(); + self.macd.reset(); + self.prev_ema = None; + self.prev_hist = None; + self.current = None; + } + + fn warmup_period(&self) -> usize { + // MACD's warmup is slow + signal − 1; EMA's is ema_period. The + // slowest branch fires the *first* impulse-ready reading, but + // judging direction needs one *more* bar on top. + let macd_warmup = self.macd_slow + self.macd_signal - 1; + self.ema_period.max(macd_warmup) + 1 + } + + fn is_ready(&self) -> bool { + self.current.is_some() + } + + fn name(&self) -> &'static str { + "ElderImpulse" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + #[test] + fn rejects_zero_period() { + assert!(matches!( + ElderImpulse::new(0, 12, 26, 9), + Err(Error::PeriodZero) + )); + assert!(matches!( + ElderImpulse::new(13, 0, 26, 9), + Err(Error::PeriodZero) + )); + } + + #[test] + fn rejects_invalid_macd_params() { + // MacdIndicator validates fast < slow. + assert!(ElderImpulse::new(13, 26, 12, 9).is_err()); + } + + #[test] + fn accessors_and_metadata() { + let elder = ElderImpulse::classic(); + assert_eq!(elder.periods(), (13, 12, 26, 9)); + assert_eq!(elder.name(), "ElderImpulse"); + } + + #[test] + fn classic_factory() { + assert_eq!(ElderImpulse::classic().periods(), (13, 12, 26, 9)); + } + + #[test] + fn constant_series_yields_neutral() { + // Both EMA and MACD-histogram are flat on a constant series, so + // neither is rising nor falling -> Impulse = 0. + let mut elder = ElderImpulse::classic(); + let out = elder.batch(&[42.0_f64; 120]); + // Take values from the post-warmup region. + for v in out.iter().skip(40).flatten() { + assert_eq!(*v, 0.0); + } + } + + #[test] + fn pure_uptrend_signals_buy() { + // Monotonic uptrend: EMA rises every bar; MACD histogram is positive + // and (after the slow EMA catches up) also rising bar-over-bar. + let mut elder = ElderImpulse::classic(); + for i in 1..=300 { + elder.update(f64::from(i)); + } + // The final reading should be +1 (buy) or 0 — never -1 on a clean + // up trend. + let v = elder.current.unwrap(); + assert!(v >= 0.0, "uptrend should not signal sell: {v}"); + } + + #[test] + fn warmup_emits_first_value_at_warmup_period() { + let mut elder = ElderImpulse::new(3, 2, 4, 3).unwrap(); + // MACD warmup: 4 + 3 - 1 = 6; EMA warmup: 3; max = 6; +1 for the + // direction bar = 7. + assert_eq!(elder.warmup_period(), 7); + let prices: Vec = (1..=10).map(f64::from).collect(); + let out = elder.batch(&prices); + for v in out.iter().take(6) { + assert!(v.is_none()); + } + assert!(out[6].is_some()); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (1..=200) + .map(|i| 100.0 + (f64::from(i) * 0.2).sin() * 5.0) + .collect(); + let mut a = ElderImpulse::classic(); + let mut b = ElderImpulse::classic(); + assert_eq!( + a.batch(&prices), + prices.iter().map(|p| b.update(*p)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut elder = ElderImpulse::classic(); + elder.batch(&(1..=200).map(f64::from).collect::>()); + assert!(elder.is_ready()); + elder.reset(); + assert!(!elder.is_ready()); + } +} diff --git a/crates/wickra-core/src/indicators/mod.rs b/crates/wickra-core/src/indicators/mod.rs index dac8d8e4..10e8d486 100644 --- a/crates/wickra-core/src/indicators/mod.rs +++ b/crates/wickra-core/src/indicators/mod.rs @@ -9,15 +9,18 @@ mod adl; mod adx; mod alligator; mod alma; +mod apo; mod aroon; mod aroon_oscillator; mod atr; mod atr_trailing_stop; mod awesome_oscillator; +mod awesome_oscillator_histogram; mod balance_of_power; mod bollinger; mod bollinger_bandwidth; mod cci; +mod cfo; mod chaikin_oscillator; mod chaikin_volatility; mod chande_kroll_stop; @@ -31,6 +34,7 @@ mod dema; mod donchian; mod dpo; mod ease_of_movement; +mod elder_impulse; mod ema; mod evwma; mod force_index; @@ -65,6 +69,7 @@ mod rvi; mod sma; mod smi; mod smma; +mod stc; mod std_dev; mod stoch_rsi; mod stochastic; @@ -88,6 +93,7 @@ mod weighted_close; mod williams_r; mod wma; mod z_score; +mod zero_lag_macd; mod zlema; pub use accelerator_oscillator::AcceleratorOscillator; @@ -95,15 +101,18 @@ pub use adl::Adl; pub use adx::{Adx, AdxOutput}; pub use alligator::{Alligator, AlligatorOutput}; pub use alma::Alma; +pub use apo::Apo; pub use aroon::{Aroon, AroonOutput}; pub use aroon_oscillator::AroonOscillator; pub use atr::Atr; pub use atr_trailing_stop::AtrTrailingStop; pub use awesome_oscillator::AwesomeOscillator; +pub use awesome_oscillator_histogram::AwesomeOscillatorHistogram; pub use balance_of_power::BalanceOfPower; pub use bollinger::{BollingerBands, BollingerOutput}; pub use bollinger_bandwidth::BollingerBandwidth; pub use cci::Cci; +pub use cfo::Cfo; pub use chaikin_oscillator::ChaikinOscillator; pub use chaikin_volatility::ChaikinVolatility; pub use chande_kroll_stop::{ChandeKrollStop, ChandeKrollStopOutput}; @@ -117,6 +126,7 @@ pub use dema::Dema; pub use donchian::{Donchian, DonchianOutput}; pub use dpo::Dpo; pub use ease_of_movement::EaseOfMovement; +pub use elder_impulse::ElderImpulse; pub use ema::Ema; pub use evwma::Evwma; pub use force_index::ForceIndex; @@ -151,6 +161,7 @@ pub use rvi::Rvi; pub use sma::Sma; pub use smi::Smi; pub use smma::Smma; +pub use stc::Stc; pub use std_dev::StdDev; pub use stoch_rsi::StochRsi; pub use stochastic::{Stochastic, StochasticOutput}; @@ -174,4 +185,5 @@ pub use weighted_close::WeightedClose; pub use williams_r::WilliamsR; pub use wma::Wma; pub use z_score::ZScore; +pub use zero_lag_macd::{ZeroLagMacd, ZeroLagMacdOutput}; pub use zlema::Zlema; diff --git a/crates/wickra-core/src/indicators/stc.rs b/crates/wickra-core/src/indicators/stc.rs new file mode 100644 index 00000000..8ad8657a --- /dev/null +++ b/crates/wickra-core/src/indicators/stc.rs @@ -0,0 +1,329 @@ +//! Schaff Trend Cycle (STC). + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::indicators::ema::Ema; +use crate::traits::Indicator; + +/// Doug Schaff's Trend Cycle — a doubly-`Stochastic`-smoothed MACD that +/// produces a bounded `[0, 100]` reading reacting faster than `MACD` itself. +/// +/// ```text +/// macd_t = EMA(close, fast)_t − EMA(close, slow)_t +/// %K_t = 100 · (macd − LL(macd, period)) / (HH(macd, period) − LL(macd, period)) +/// %D_t = %D_{t-1} + factor · (%K_t − %D_{t-1}) // half-EMA when factor = 0.5 +/// %K2_t = 100 · (%D − LL(%D, period)) / (HH(%D, period) − LL(%D, period)) +/// STC_t = STC_{t-1} + factor · (%K2_t − STC_{t-1}) +/// ``` +/// +/// Wickra uses `factor = 0.5` and Schaff's recommended defaults +/// `(fast = 23, slow = 50, period = 10)`. The stochastic stages clamp to `0` +/// when the window range collapses (perfectly flat input), and the smoothing +/// stages hold their previous value if the upstream stage is not yet ready — +/// so a flat input series settles deterministically at `0` after warmup. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, Stc}; +/// +/// let mut stc = Stc::classic(); +/// let mut last = None; +/// for i in 0..200 { +/// last = stc.update(100.0 + f64::from(i)); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct Stc { + fast_period: usize, + slow_period: usize, + schaff_period: usize, + factor: f64, + fast_ema: Ema, + slow_ema: Ema, + macd_window: VecDeque, + d_window: VecDeque, + last_d: Option, + last_value: Option, +} + +impl Stc { + /// # Errors + /// - [`Error::PeriodZero`] if any period is zero. + /// - [`Error::InvalidPeriod`] if `fast >= slow` or `factor` is not in `(0, 1]`. + pub fn new(fast: usize, slow: usize, schaff_period: usize, factor: f64) -> Result { + if fast == 0 || slow == 0 || schaff_period == 0 { + return Err(Error::PeriodZero); + } + if fast >= slow { + return Err(Error::InvalidPeriod { + message: "STC fast period must be strictly less than slow", + }); + } + if !factor.is_finite() || factor <= 0.0 || factor > 1.0 { + return Err(Error::InvalidPeriod { + message: "STC factor must be a finite value in (0, 1]", + }); + } + Ok(Self { + fast_period: fast, + slow_period: slow, + schaff_period, + factor, + fast_ema: Ema::new(fast)?, + slow_ema: Ema::new(slow)?, + macd_window: VecDeque::with_capacity(schaff_period), + d_window: VecDeque::with_capacity(schaff_period), + last_d: None, + last_value: None, + }) + } + + /// Schaff's recommended defaults `(fast = 23, slow = 50, period = 10, factor = 0.5)`. + pub fn classic() -> Self { + Self::new(23, 50, 10, 0.5).expect("classic STC parameters are valid") + } + + /// Configured `(fast, slow, schaff_period, factor)`. + pub const fn params(&self) -> (usize, usize, usize, f64) { + ( + self.fast_period, + self.slow_period, + self.schaff_period, + self.factor, + ) + } +} + +fn rolling_minmax(window: &VecDeque) -> (f64, f64) { + let mut lo = f64::INFINITY; + let mut hi = f64::NEG_INFINITY; + for &v in window { + if v < lo { + lo = v; + } + if v > hi { + hi = v; + } + } + (lo, hi) +} + +impl Indicator for Stc { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + let f = self.fast_ema.update(input); + let s = self.slow_ema.update(input); + let (f, s) = (f?, s?); + let macd = f - s; + + if self.macd_window.len() == self.schaff_period { + self.macd_window.pop_front(); + } + self.macd_window.push_back(macd); + if self.macd_window.len() < self.schaff_period { + return None; + } + + let (lo, hi) = rolling_minmax(&self.macd_window); + let k = if hi > lo { + 100.0 * (macd - lo) / (hi - lo) + } else { + 0.0 + }; + + let d = match self.last_d { + Some(prev) => prev + self.factor * (k - prev), + None => k, + }; + self.last_d = Some(d); + + if self.d_window.len() == self.schaff_period { + self.d_window.pop_front(); + } + self.d_window.push_back(d); + if self.d_window.len() < self.schaff_period { + return None; + } + + let (lo_d, hi_d) = rolling_minmax(&self.d_window); + let k2 = if hi_d > lo_d { + 100.0 * (d - lo_d) / (hi_d - lo_d) + } else { + 0.0 + }; + + let stc = match self.last_value { + Some(prev) => prev + self.factor * (k2 - prev), + None => k2, + }; + self.last_value = Some(stc); + Some(stc.clamp(0.0, 100.0)) + } + + fn reset(&mut self) { + self.fast_ema.reset(); + self.slow_ema.reset(); + self.macd_window.clear(); + self.d_window.clear(); + self.last_d = None; + self.last_value = None; + } + + fn warmup_period(&self) -> usize { + // Slow EMA emits at `slow` inputs. Then the macd-window needs + // `schaff_period − 1` more inputs to fill, and the d-window another + // `schaff_period − 1` after that. + self.slow_period + 2 * (self.schaff_period - 1) + } + + fn is_ready(&self) -> bool { + self.last_value.is_some() && self.d_window.len() == self.schaff_period + } + + fn name(&self) -> &'static str { + "STC" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + #[test] + fn rejects_zero_period() { + assert!(matches!(Stc::new(0, 50, 10, 0.5), Err(Error::PeriodZero))); + assert!(matches!(Stc::new(23, 0, 10, 0.5), Err(Error::PeriodZero))); + assert!(matches!(Stc::new(23, 50, 0, 0.5), Err(Error::PeriodZero))); + } + + #[test] + fn rejects_invalid_params() { + assert!(matches!( + Stc::new(50, 23, 10, 0.5), + Err(Error::InvalidPeriod { .. }) + )); + assert!(matches!( + Stc::new(23, 50, 10, 0.0), + Err(Error::InvalidPeriod { .. }) + )); + assert!(matches!( + Stc::new(23, 50, 10, 1.5), + Err(Error::InvalidPeriod { .. }) + )); + assert!(matches!( + Stc::new(23, 50, 10, f64::NAN), + Err(Error::InvalidPeriod { .. }) + )); + } + + #[test] + fn accessors_and_metadata() { + let stc = Stc::classic(); + let (f, s, p, k) = stc.params(); + assert_eq!((f, s, p), (23, 50, 10)); + assert!((k - 0.5).abs() < 1e-12); + assert_eq!(stc.warmup_period(), 50 + 18); + assert_eq!(stc.name(), "STC"); + } + + #[test] + fn classic_factory() { + let (f, s, p, k) = Stc::classic().params(); + assert_eq!((f, s, p), (23, 50, 10)); + assert!((k - 0.5).abs() < 1e-12); + } + + #[test] + fn constant_series_yields_zero() { + // Flat input -> macd is 0 every bar -> stochastic-on-flat-window + // returns 0 -> d stays at 0 -> %K2 returns 0 -> STC stays at 0. + let mut stc = Stc::new(3, 5, 4, 0.5).unwrap(); + let out = stc.batch(&[42.0_f64; 80]); + for v in out.iter().rev().take(5).flatten() { + assert_eq!(*v, 0.0); + } + } + + #[test] + fn warmup_emits_first_value_at_warmup_period() { + let mut stc = Stc::new(2, 4, 3, 0.5).unwrap(); + // slow(4) + 2*(3-1) = 8. + assert_eq!(stc.warmup_period(), 8); + let prices: Vec = (1..=10).map(f64::from).collect(); + let out = stc.batch(&prices); + for v in out.iter().take(7) { + assert!(v.is_none()); + } + assert!(out[7].is_some()); + } + + #[test] + fn output_is_bounded() { + let mut stc = Stc::classic(); + let prices: Vec = (0..400) + .map(|i| 100.0 + (f64::from(i) * 0.2).sin() * 25.0) + .collect(); + for v in stc.batch(&prices).iter().flatten() { + assert!((0.0..=100.0).contains(v), "STC out of [0, 100]: {v}"); + } + } + + #[test] + fn oscillating_series_visits_full_range() { + // STC needs a non-degenerate MACD range to exercise the two + // stochastic stages. A purely monotone series collapses the rolling + // window (constant MACD) and a purely flat one collapses both + // stages — in either case both inner ranges become zero and STC + // sticks at 0. A sinusoidal trend with enough amplitude makes the + // stages cycle through the full [0, 100] band. + let mut stc = Stc::classic(); + let prices: Vec = (0..400) + .map(|i| 100.0 + (f64::from(i) * 0.15).sin() * 30.0) + .collect(); + let out = stc.batch(&prices); + let mut saw_high = false; + let mut saw_low = false; + for v in out.iter().flatten() { + if *v > 80.0 { + saw_high = true; + } + if *v < 20.0 { + saw_low = true; + } + } + assert!( + saw_high, + "STC should reach above 80 on a strong oscillation" + ); + assert!(saw_low, "STC should reach below 20 on a strong oscillation"); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (1..=200) + .map(|i| 100.0 + (f64::from(i) * 0.2).sin() * 5.0) + .collect(); + let mut a = Stc::classic(); + let mut b = Stc::classic(); + assert_eq!( + a.batch(&prices), + prices.iter().map(|p| b.update(*p)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut stc = Stc::classic(); + stc.batch(&(1..=200).map(f64::from).collect::>()); + assert!(stc.is_ready()); + stc.reset(); + assert!(!stc.is_ready()); + assert!(stc.last_value.is_none()); + } +} diff --git a/crates/wickra-core/src/indicators/zero_lag_macd.rs b/crates/wickra-core/src/indicators/zero_lag_macd.rs new file mode 100644 index 00000000..b542e406 --- /dev/null +++ b/crates/wickra-core/src/indicators/zero_lag_macd.rs @@ -0,0 +1,226 @@ +//! Zero-Lag MACD — MACD computed on `ZLEMA` instead of `EMA`. + +use crate::error::{Error, Result}; +use crate::indicators::zlema::Zlema; +use crate::traits::Indicator; + +/// Multi-output for Zero-Lag MACD: the MACD line, its signal line, and the +/// histogram (line − signal). +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct ZeroLagMacdOutput { + /// Fast `ZLEMA` minus slow `ZLEMA`. + pub macd: f64, + /// `ZLEMA(macd, signal_period)`. + pub signal: f64, + /// `macd − signal`. + pub histogram: f64, +} + +/// Zero-Lag MACD — the standard `MACD` topology with `ZLEMA` substituted for +/// `EMA` everywhere. `ZLEMA`'s de-lagged construction makes the MACD line +/// react faster to trend changes at the cost of slightly noisier readings. +/// +/// ```text +/// macd_t = ZLEMA(close, fast)_t − ZLEMA(close, slow)_t +/// signal_t = ZLEMA(macd, signal_period)_t +/// histogram_t = macd_t − signal_t +/// ``` +/// +/// Default parameters mirror MACD: `(fast = 12, slow = 26, signal = 9)`. +/// `fast` must be strictly less than `slow`. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, ZeroLagMacd}; +/// +/// let mut zmacd = ZeroLagMacd::classic(); +/// let mut last = None; +/// for i in 0..120 { +/// last = zmacd.update(100.0 + f64::from(i)); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct ZeroLagMacd { + fast_period: usize, + slow_period: usize, + signal_period: usize, + fast: Zlema, + slow: Zlema, + signal: Zlema, +} + +impl ZeroLagMacd { + /// # Errors + /// - [`Error::PeriodZero`] if any period is zero. + /// - [`Error::InvalidPeriod`] if `fast >= slow`. + pub fn new(fast: usize, slow: usize, signal: usize) -> Result { + if fast == 0 || slow == 0 || signal == 0 { + return Err(Error::PeriodZero); + } + if fast >= slow { + return Err(Error::InvalidPeriod { + message: "ZeroLagMACD fast period must be strictly less than slow", + }); + } + Ok(Self { + fast_period: fast, + slow_period: slow, + signal_period: signal, + fast: Zlema::new(fast)?, + slow: Zlema::new(slow)?, + signal: Zlema::new(signal)?, + }) + } + + /// MACD-style defaults: `(fast = 12, slow = 26, signal = 9)`. + pub fn classic() -> Self { + Self::new(12, 26, 9).expect("classic Zero-Lag MACD parameters are valid") + } + + /// Configured `(fast, slow, signal)`. + pub const fn periods(&self) -> (usize, usize, usize) { + (self.fast_period, self.slow_period, self.signal_period) + } +} + +impl Indicator for ZeroLagMacd { + type Input = f64; + type Output = ZeroLagMacdOutput; + + fn update(&mut self, input: f64) -> Option { + // Feed both inner ZLEMAs on every input so the slow one warms in + // parallel with the fast one. + let f = self.fast.update(input); + let s = self.slow.update(input); + let (f, s) = (f?, s?); + let macd = f - s; + let signal = self.signal.update(macd)?; + Some(ZeroLagMacdOutput { + macd, + signal, + histogram: macd - signal, + }) + } + + fn reset(&mut self) { + self.fast.reset(); + self.slow.reset(); + self.signal.reset(); + } + + fn warmup_period(&self) -> usize { + // ZLEMA(period) warmup is `(period − 1) / 2 + period` = `lag + period`. + // Both fast and slow run in parallel; the slow one dominates. The + // signal ZLEMA then needs its own `lag + period` MACD values on top. + let zlema_warmup = |period: usize| ((period - 1) / 2).saturating_add(period); + zlema_warmup(self.slow_period) + zlema_warmup(self.signal_period) - 1 + } + + fn is_ready(&self) -> bool { + self.signal.is_ready() + } + + fn name(&self) -> &'static str { + "ZeroLagMACD" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_zero_period() { + assert!(matches!(ZeroLagMacd::new(0, 26, 9), Err(Error::PeriodZero))); + assert!(matches!(ZeroLagMacd::new(12, 0, 9), Err(Error::PeriodZero))); + assert!(matches!( + ZeroLagMacd::new(12, 26, 0), + Err(Error::PeriodZero) + )); + } + + #[test] + fn rejects_fast_geq_slow() { + assert!(matches!( + ZeroLagMacd::new(26, 12, 9), + Err(Error::InvalidPeriod { .. }) + )); + } + + #[test] + fn accessors_and_metadata() { + let z = ZeroLagMacd::classic(); + assert_eq!(z.periods(), (12, 26, 9)); + assert_eq!(z.name(), "ZeroLagMACD"); + } + + #[test] + fn classic_factory() { + assert_eq!(ZeroLagMacd::classic().periods(), (12, 26, 9)); + } + + #[test] + fn constant_series_converges_to_zero() { + // Each ZLEMA reproduces a constant, so macd, signal and histogram + // are all 0 after the slowest branch warms. + let mut z = ZeroLagMacd::new(3, 5, 3).unwrap(); + let out = z.batch(&[42.0_f64; 60]); + for v in out.iter().rev().take(5).flatten() { + assert_relative_eq!(v.macd, 0.0, epsilon = 1e-12); + assert_relative_eq!(v.signal, 0.0, epsilon = 1e-12); + assert_relative_eq!(v.histogram, 0.0, epsilon = 1e-12); + } + } + + #[test] + fn histogram_is_macd_minus_signal() { + let mut z = ZeroLagMacd::classic(); + let prices: Vec = (1..=120) + .map(|i| 100.0 + (f64::from(i) * 0.2).sin() * 5.0) + .collect(); + for v in z.batch(&prices).iter().flatten() { + assert_relative_eq!(v.histogram, v.macd - v.signal, epsilon = 1e-12); + } + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (1..=120) + .map(|i| 100.0 + (f64::from(i) * 0.2).sin() * 5.0) + .collect(); + let mut a = ZeroLagMacd::classic(); + let mut b = ZeroLagMacd::classic(); + assert_eq!( + a.batch(&prices), + prices.iter().map(|p| b.update(*p)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut z = ZeroLagMacd::classic(); + z.batch(&(1..=120).map(f64::from).collect::>()); + assert!(z.is_ready()); + z.reset(); + assert!(!z.is_ready()); + } + + #[test] + fn warmup_period_matches_zlema_chain() { + // warmup = zlema_warmup(slow) + zlema_warmup(signal) - 1 + // zlema_warmup(p) = (p - 1) / 2 + p + // (12, 26, 9): zlema_warmup(26) = 12 + 26 = 38; + // zlema_warmup(9) = 4 + 9 = 13. + // warmup = 38 + 13 - 1 = 50. + let z = ZeroLagMacd::new(12, 26, 9).unwrap(); + assert_eq!(z.warmup_period(), 50); + // (3, 5, 3): zlema_warmup(5) = 2 + 5 = 7; zlema_warmup(3) = 1 + 3 = 4. + // warmup = 7 + 4 - 1 = 10. + let z = ZeroLagMacd::new(3, 5, 3).unwrap(); + assert_eq!(z.warmup_period(), 10); + } +} diff --git a/crates/wickra-core/src/lib.rs b/crates/wickra-core/src/lib.rs index 4b3a011d..4de18042 100644 --- a/crates/wickra-core/src/lib.rs +++ b/crates/wickra-core/src/lib.rs @@ -44,19 +44,20 @@ pub mod indicators; pub use error::{Error, Result}; pub use indicators::{ - AcceleratorOscillator, Adl, Adx, AdxOutput, Alligator, AlligatorOutput, Alma, Aroon, - AroonOscillator, AroonOutput, Atr, AtrTrailingStop, AwesomeOscillator, BalanceOfPower, - BollingerBands, BollingerBandwidth, BollingerOutput, Cci, ChaikinMoneyFlow, ChaikinOscillator, - ChaikinVolatility, ChandeKrollStop, ChandeKrollStopOutput, ChandelierExit, - ChandelierExitOutput, ChoppinessIndex, Cmo, ConnorsRsi, Coppock, Dema, Donchian, - DonchianOutput, Dpo, EaseOfMovement, Ema, Evwma, ForceIndex, Frama, HistoricalVolatility, Hma, - Inertia, Jma, Kama, Keltner, KeltnerOutput, Kst, KstOutput, LaguerreRsi, LinRegAngle, - LinRegSlope, LinearRegression, MacdIndicator, MacdOutput, MassIndex, McGinleyDynamic, - MedianPrice, Mfi, Mom, Natr, Obv, PercentB, Pgo, Pmo, Ppo, Psar, Roc, RollingVwap, Rsi, Rvi, - Sma, Smi, Smma, StdDev, StochRsi, Stochastic, StochasticOutput, SuperTrend, SuperTrendOutput, - Tema, Trima, Trix, TrueRange, Tsi, TypicalPrice, UlcerIndex, UltimateOscillator, - VerticalHorizontalFilter, Vidya, VolumePriceTrend, Vortex, VortexOutput, Vwap, Vwma, - WeightedClose, WilliamsR, Wma, ZScore, Zlema, T3, + AcceleratorOscillator, Adl, Adx, AdxOutput, Alligator, AlligatorOutput, Alma, Apo, Aroon, + AroonOscillator, AroonOutput, Atr, AtrTrailingStop, AwesomeOscillator, + AwesomeOscillatorHistogram, BalanceOfPower, BollingerBands, BollingerBandwidth, + BollingerOutput, Cci, Cfo, ChaikinMoneyFlow, ChaikinOscillator, ChaikinVolatility, + ChandeKrollStop, ChandeKrollStopOutput, ChandelierExit, ChandelierExitOutput, ChoppinessIndex, + Cmo, ConnorsRsi, Coppock, Dema, Donchian, DonchianOutput, Dpo, EaseOfMovement, ElderImpulse, + Ema, Evwma, ForceIndex, Frama, HistoricalVolatility, Hma, Inertia, Jma, Kama, Keltner, + KeltnerOutput, Kst, KstOutput, LaguerreRsi, LinRegAngle, LinRegSlope, LinearRegression, + MacdIndicator, MacdOutput, MassIndex, McGinleyDynamic, MedianPrice, Mfi, Mom, Natr, Obv, + PercentB, Pgo, Pmo, Ppo, Psar, Roc, RollingVwap, Rsi, Rvi, Sma, Smi, Smma, Stc, StdDev, + StochRsi, Stochastic, StochasticOutput, SuperTrend, SuperTrendOutput, Tema, Trima, Trix, + TrueRange, Tsi, TypicalPrice, UlcerIndex, UltimateOscillator, VerticalHorizontalFilter, Vidya, + VolumePriceTrend, Vortex, VortexOutput, Vwap, Vwma, WeightedClose, WilliamsR, Wma, ZScore, + ZeroLagMacd, ZeroLagMacdOutput, Zlema, T3, }; pub use ohlcv::{Candle, Tick}; pub use traits::{BatchExt, Chain, Indicator}; diff --git a/fuzz/fuzz_targets/indicator_update.rs b/fuzz/fuzz_targets/indicator_update.rs index 77b0393e..b30a9fd0 100644 --- a/fuzz/fuzz_targets/indicator_update.rs +++ b/fuzz/fuzz_targets/indicator_update.rs @@ -15,11 +15,11 @@ use libfuzzer_sys::fuzz_target; use wickra_core::{ - Alma, BatchExt, BollingerBands, Cmo, ConnorsRsi, Coppock, Dema, Dpo, Ema, Frama, - HistoricalVolatility, Hma, Indicator, Jma, Kama, Kst, LaguerreRsi, LinRegAngle, LinRegSlope, - LinearRegression, MacdIndicator, McGinleyDynamic, Mom, Pmo, Ppo, Roc, Rsi, Sma, Smma, StdDev, - StochRsi, T3, Tema, Trima, Trix, Tsi, UlcerIndex, VerticalHorizontalFilter, Vidya, Wma, ZScore, - Zlema, + Alma, Apo, BatchExt, BollingerBands, Cfo, Cmo, ConnorsRsi, Coppock, Dema, Dpo, ElderImpulse, + Ema, Frama, HistoricalVolatility, Hma, Indicator, Jma, Kama, Kst, LaguerreRsi, LinRegAngle, + LinRegSlope, LinearRegression, MacdIndicator, McGinleyDynamic, Mom, Pmo, Ppo, Roc, Rsi, Sma, + Smma, Stc, StdDev, StochRsi, T3, Tema, Trima, Trix, Tsi, UlcerIndex, VerticalHorizontalFilter, + Vidya, Wma, ZScore, ZeroLagMacd, Zlema, }; /// Drive a single streaming + batch run through one scalar indicator. Marked @@ -67,6 +67,10 @@ fuzz_target!(|data: Vec| { drive(|| StochRsi::new(14, 14).unwrap(), &data); drive(|| Dpo::new(14).unwrap(), &data); drive(|| Ppo::new(12, 26).unwrap(), &data); + drive(|| Apo::new(12, 26).unwrap(), &data); + drive(|| Cfo::new(14).unwrap(), &data); + drive(|| ElderImpulse::classic(), &data); + drive(|| Stc::classic(), &data); drive(|| Coppock::new(14, 11, 10).unwrap(), &data); drive(|| StdDev::new(14).unwrap(), &data); drive(|| UlcerIndex::new(14).unwrap(), &data); @@ -89,6 +93,16 @@ fuzz_target!(|data: Vec| { let _ = Kst::classic().batch(&data); } + // Zero-Lag MACD shares MACD's multi-output topology, so it gets the + // same hand-rolled streaming + batch drive as classic MACD below. + { + let mut z = ZeroLagMacd::classic(); + for &x in &data { + let _ = z.update(x); + } + let _ = ZeroLagMacd::classic().batch(&data); + } + // MACD and Bollinger Bands have non-`f64` outputs, so they cannot use the // generic `drive` helper above. Streaming + batch are still both exercised. { diff --git a/fuzz/fuzz_targets/indicator_update_candle.rs b/fuzz/fuzz_targets/indicator_update_candle.rs index ce1e36e2..e1fc2edf 100644 --- a/fuzz/fuzz_targets/indicator_update_candle.rs +++ b/fuzz/fuzz_targets/indicator_update_candle.rs @@ -24,7 +24,8 @@ use libfuzzer_sys::fuzz_target; use wickra_core::{ AcceleratorOscillator, Adl, Adx, Alligator, Aroon, AroonOscillator, Atr, AtrTrailingStop, - AwesomeOscillator, BalanceOfPower, BatchExt, Candle, Cci, ChaikinMoneyFlow, ChaikinOscillator, + AwesomeOscillator, AwesomeOscillatorHistogram, BalanceOfPower, BatchExt, Candle, Cci, + ChaikinMoneyFlow, ChaikinOscillator, ChaikinVolatility, ChandeKrollStop, ChandelierExit, ChoppinessIndex, Donchian, EaseOfMovement, Evwma, ForceIndex, Indicator, Inertia, Keltner, MassIndex, MedianPrice, Mfi, Natr, Obv, Pgo, Psar, RollingVwap, Rvi, Smi, @@ -103,6 +104,10 @@ fuzz_target!(|data: Vec| { drive(|| Smi::classic(), &candles); drive(|| WilliamsR::new(14).unwrap(), &candles); drive(|| AwesomeOscillator::new(5, 34).unwrap(), &candles); + drive( + || AwesomeOscillatorHistogram::new(5, 34, 5).unwrap(), + &candles, + ); drive(|| AcceleratorOscillator::new(5, 34, 5).unwrap(), &candles); drive(|| UltimateOscillator::new(7, 14, 28).unwrap(), &candles); drive(BalanceOfPower::new, &candles);