feat: Family 03 MACD & Price Oscillators — APO / AO-Hist / CFO / Zero-Lag MACD / Elder Impulse / STC (#41)
* 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 fromec269d8The 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 from733afd9* 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.<id>.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.
This commit is contained in:
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<Self> {
|
||||
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<Option<f64>> {
|
||||
Ok(self.inner.update(cnd(high, low, low, 0.0)?))
|
||||
}
|
||||
#[napi]
|
||||
pub fn batch(&mut self, high: Vec<f64>, low: Vec<f64>) -> napi::Result<Vec<f64>> {
|
||||
if high.len() != low.len() {
|
||||
return Err(NapiError::from_reason(
|
||||
"high and low must be equal length".to_string(),
|
||||
));
|
||||
}
|
||||
let 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<Self> {
|
||||
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<f64> {
|
||||
self.inner.update(value)
|
||||
}
|
||||
#[napi]
|
||||
pub fn batch(&mut self, prices: Vec<f64>) -> Vec<f64> {
|
||||
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<Self> {
|
||||
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<f64> {
|
||||
self.inner.update(value)
|
||||
}
|
||||
#[napi]
|
||||
pub fn batch(&mut self, prices: Vec<f64>) -> Vec<f64> {
|
||||
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<Self> {
|
||||
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<ZeroLagMacdValue> {
|
||||
self.inner.update(value).map(|o| ZeroLagMacdValue {
|
||||
macd: o.macd,
|
||||
signal: o.signal,
|
||||
histogram: o.histogram,
|
||||
})
|
||||
}
|
||||
#[napi]
|
||||
pub fn batch(&mut self, prices: Vec<f64>) -> Vec<f64> {
|
||||
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<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::Cfo::new(clamp_period(period)).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
#[napi]
|
||||
pub fn update(&mut self, value: f64) -> Option<f64> {
|
||||
self.inner.update(value)
|
||||
}
|
||||
#[napi]
|
||||
pub fn batch(&mut self, prices: Vec<f64>) -> Vec<f64> {
|
||||
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<Self> {
|
||||
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<f64> {
|
||||
self.inner.update(value)
|
||||
}
|
||||
#[napi]
|
||||
pub fn batch(&mut self, prices: Vec<f64>) -> Vec<f64> {
|
||||
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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::AwesomeOscillatorHistogram::new(fast, slow, sma_period).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult<Option<f64>> {
|
||||
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<Bound<'py, PyArray1<f64>>> {
|
||||
let h = high
|
||||
.as_slice()
|
||||
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
|
||||
let l = low
|
||||
.as_slice()
|
||||
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
|
||||
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<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::Stc::new(fast, slow, schaff_period, factor).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
fn update(&mut self, value: f64) -> Option<f64> {
|
||||
self.inner.update(value)
|
||||
}
|
||||
fn batch<'py>(
|
||||
&mut self,
|
||||
py: Python<'py>,
|
||||
prices: PyReadonlyArray1<'py, f64>,
|
||||
) -> PyResult<Bound<'py, PyArray1<f64>>> {
|
||||
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<Self> {
|
||||
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<f64> {
|
||||
self.inner.update(value)
|
||||
}
|
||||
fn batch<'py>(
|
||||
&mut self,
|
||||
py: Python<'py>,
|
||||
prices: PyReadonlyArray1<'py, f64>,
|
||||
) -> PyResult<Bound<'py, PyArray1<f64>>> {
|
||||
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<Self> {
|
||||
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<Bound<'py, PyArray2<f64>>> {
|
||||
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<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::Cfo::new(period).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
fn update(&mut self, value: f64) -> Option<f64> {
|
||||
self.inner.update(value)
|
||||
}
|
||||
fn batch<'py>(
|
||||
&mut self,
|
||||
py: Python<'py>,
|
||||
prices: PyReadonlyArray1<'py, f64>,
|
||||
) -> PyResult<Bound<'py, PyArray1<f64>>> {
|
||||
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<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::Apo::new(fast, slow).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
fn update(&mut self, value: f64) -> Option<f64> {
|
||||
self.inner.update(value)
|
||||
}
|
||||
fn batch<'py>(
|
||||
&mut self,
|
||||
py: Python<'py>,
|
||||
prices: PyReadonlyArray1<'py, f64>,
|
||||
) -> PyResult<Bound<'py, PyArray1<f64>>> {
|
||||
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::<PyJma>()?;
|
||||
m.add_class::<PyAlligator>()?;
|
||||
m.add_class::<PyEvwma>()?;
|
||||
m.add_class::<PyApo>()?;
|
||||
m.add_class::<PyAoHist>()?;
|
||||
m.add_class::<PyCfo>()?;
|
||||
m.add_class::<PyZeroLagMacd>()?;
|
||||
m.add_class::<PyElderImpulse>()?;
|
||||
m.add_class::<PyStc>()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<WasmZeroLagMacd, JsError> {
|
||||
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<WasmAoHist, JsError> {
|
||||
Ok(Self {
|
||||
inner: wc::AwesomeOscillatorHistogram::new(fast, slow, sma_period).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
pub fn update(&mut self, high: f64, low: f64) -> Result<Option<f64>, 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<Float64Array, JsError> {
|
||||
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,
|
||||
|
||||
@@ -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<Self> {
|
||||
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<f64> {
|
||||
// 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<f64> = (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<f64> = (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::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut apo = Apo::classic();
|
||||
apo.batch(&(1..=80).map(f64::from).collect::<Vec<_>>());
|
||||
assert!(apo.is_ready());
|
||||
apo.reset();
|
||||
assert!(!apo.is_ready());
|
||||
assert_eq!(apo.update(1.0), None);
|
||||
}
|
||||
}
|
||||
@@ -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<Self> {
|
||||
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<f64> {
|
||||
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<Candle> = (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<Candle> = (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<Candle> = (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<Candle> = (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());
|
||||
}
|
||||
}
|
||||
@@ -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<f64>,
|
||||
}
|
||||
|
||||
impl Cfo {
|
||||
/// # Errors
|
||||
/// Returns [`Error::PeriodZero`] if `period == 0`.
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
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<f64> {
|
||||
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<f64> = (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<f64> = (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::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut cfo = Cfo::new(5).unwrap();
|
||||
cfo.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<f64>,
|
||||
prev_hist: Option<f64>,
|
||||
current: Option<f64>,
|
||||
}
|
||||
|
||||
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<Self> {
|
||||
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<f64> {
|
||||
// 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<f64> = (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<f64> = (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::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut elder = ElderImpulse::classic();
|
||||
elder.batch(&(1..=200).map(f64::from).collect::<Vec<_>>());
|
||||
assert!(elder.is_ready());
|
||||
elder.reset();
|
||||
assert!(!elder.is_ready());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<f64>,
|
||||
d_window: VecDeque<f64>,
|
||||
last_d: Option<f64>,
|
||||
last_value: Option<f64>,
|
||||
}
|
||||
|
||||
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<Self> {
|
||||
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, 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<f64> {
|
||||
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<f64> = (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<f64> = (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<f64> = (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<f64> = (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::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut stc = Stc::classic();
|
||||
stc.batch(&(1..=200).map(f64::from).collect::<Vec<_>>());
|
||||
assert!(stc.is_ready());
|
||||
stc.reset();
|
||||
assert!(!stc.is_ready());
|
||||
assert!(stc.last_value.is_none());
|
||||
}
|
||||
}
|
||||
@@ -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<Self> {
|
||||
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<ZeroLagMacdOutput> {
|
||||
// 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<f64> = (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<f64> = (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::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut z = ZeroLagMacd::classic();
|
||||
z.batch(&(1..=120).map(f64::from).collect::<Vec<_>>());
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
@@ -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<f64>| {
|
||||
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<f64>| {
|
||||
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.
|
||||
{
|
||||
|
||||
@@ -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<f64>| {
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user