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 from ec269d8

The previous APO commit (ec269d8) only registered APO in the Python
__init__.py / Node index.js / Node index.d.ts / fuzz / tests / docs.
The actual PyApo pyclass, ApoNode napi class, and WasmApo wasm class
edits silently no-op'd because the underlying lib.rs files had been
touched by a branch switch between Read and Edit. The bindings were
therefore advertising APO from the Python module / Node package /
WASM module but not actually exposing it.

Fix: insert PyApo block + add_class call in bindings/python/src/lib.rs,
ApoNode block in bindings/node/src/lib.rs, WasmApo macro line in
bindings/wasm/src/lib.rs. cargo test workspace stays at 615 (no new
tests added; the existing test_known_values + indicators.test.js
references would have failed at import once the bindings rebuilt
without these classes).

* feat(ao-histogram): add Awesome Oscillator Histogram

AO - SMA(AO, sma_period). A configurable variant of the existing
AcceleratorOscillator (which fixes fast=5, slow=34, sma=5).
Three parameters; defaults match Bill Williams' Accelerator.

Touchpoints: awesome_oscillator_histogram.rs + mod.rs + lib.rs
re-export, PyAoHist + __init__.py + test_new_indicators CANDLE_SCALAR
+ test_known_values flat reference, AwesomeOscillatorHistogramNode +
index.d.ts/index.js + indicators.test.js factory + reference,
WasmAoHist, candle-fuzz target, README + CHANGELOG.

* feat(cfo): add Chande Forecast Oscillator

100 * (close - LinReg(close, period)) / close. Positive when close
overshoots the linear forecast, negative when it undershoots. Holds
the previous value if the close is zero (percentage form undefined).
Single param period (default 14).

Touchpoints: cfo.rs + mod.rs + lib.rs re-export, PyCfo + __init__.py
+ test_new_indicators SCALAR + test_known_values linear reference,
CfoNode + index.d.ts/index.js + indicators.test.js factory + reference,
WasmCfo via scalar macro, scalar-fuzz target, README + CHANGELOG.

* fix(cfo): add WasmCfo binding missed from 733afd9

* feat(zero-lag-macd): add Zero-Lag MACD

Classic MACD topology with ZLEMA substituted for EMA everywhere:
faster reaction to trend changes at the cost of slightly noisier
readings. Multi-output ZeroLagMacdOutput { macd, signal, histogram }.
Three parameters (fast = 12, slow = 26, signal = 9); fast must be
strictly less than slow.

Touchpoints: zero_lag_macd.rs + mod.rs + lib.rs re-export, PyZeroLagMacd
+ __init__.py + test_new_indicators MULTI + test_known_values flat
reference, ZeroLagMacdNode + ZeroLagMacdValue + index.d.ts/index.js +
indicators.test.js multi factory + reference, WasmZeroLagMacd, scalar
fuzz with hand-rolled drive (multi-output bypasses the f64-only
helper), README + CHANGELOG.

* feat(elder-impulse): add Alexander Elder Impulse System

Tri-state momentum gauge: +1 (green/buy) when EMA trend and MACD
histogram both rise, -1 (red/sell) when both fall, 0 (blue/neutral)
on disagreement. Four parameters (ema_period, macd_fast, macd_slow,
macd_signal); defaults (13, 12, 26, 9) match Elder.

Internally feeds both branches on every input so they warm in parallel;
needs one bar past the slowest branch to seed direction state.

Touchpoints: elder_impulse.rs + mod.rs + lib.rs re-export, PyElderImpulse
+ __init__.py + test_new_indicators SCALAR + test_known_values neutral
reference, ElderImpulseNode + index.d.ts/index.js + indicators.test.js
factory + reference, WasmElderImpulse via scalar macro, scalar-fuzz
target, README + CHANGELOG.

* feat(stc): add Schaff Trend Cycle

Doug Schaff's doubly-Stochastic-smoothed MACD. Bounded [0, 100]
reading that reacts faster than MACD by extracting the percentile of
MACD within a recent window, half-EMA-smoothing it, and re-stochasing
the smoothed series. Four parameters (fast = 23, slow = 50,
schaff_period = 10, factor = 0.5); fast must be strictly less than
slow and factor must lie in (0, 1].

Output clamped to [0, 100] to absorb floating-point rounding. The
stochastic stages clamp to 0 when their rolling range collapses (flat
input or perfectly monotone trend), so a flat series settles
deterministically at 0 after warmup.

Touchpoints: stc.rs + mod.rs + lib.rs re-export, PyStc + __init__.py
+ test_new_indicators SCALAR + test_known_values flat reference,
StcNode + index.d.ts/index.js + indicators.test.js factory + reference,
WasmStc via scalar macro, scalar-fuzz target, README + CHANGELOG.

* fix(stc): rename last_stc -> last_value to satisfy clippy

* ci: Retry setup-node and setup-python on CDN flakes

Setup-node on Windows runners and setup-python across all OSes
occasionally fail with a silent hang or 5xx mid-download ("Attempting
to download 18..." → fail in <1s) — pure upstream CDN flake. The fix
ran on this branch's previous merge commit (24e723f) had to be
re-triggered manually via `gh run rerun --failed`.

Wrap both setup actions with continue-on-error and a follow-up retry
step that waits 30s and re-runs the same setup. The retry only fires
when the first attempt failed (steps.<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:
kingchenc
2026-05-25 17:26:46 +02:00
committed by GitHub
parent 7f1a6df202
commit d9d3ad18aa
21 changed files with 2303 additions and 22 deletions
+39
View File
@@ -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 }}
+34
View File
@@ -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
+2 -2
View File
@@ -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.
+7 -1
View File
@@ -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
+264
View File
@@ -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,
+12
View File
@@ -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",
+315
View File
@@ -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
+97
View File
@@ -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,
+183
View File
@@ -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());
}
}
+173
View File
@@ -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());
}
}
+12
View File
@@ -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;
+329
View File
@@ -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);
}
}
+14 -13
View File
@@ -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};
+19 -5
View File
@@ -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.
{
+6 -1
View File
@@ -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);