Two unrelated newer-toolchain breakages bundled because they hit on the
same CI run and have the same shape (newer Rust got stricter about
patterns we used):
1. clippy 1.95 added the manual_midpoint lint which fires on every
instance of (a + b) / 2.0 with a help suggesting f64::midpoint.
CI runs with -D warnings so it became a hard error. Twelve sites
were affected — three real call sites in src/ohlcv.rs (median_price),
src/indicators/donchian.rs (DonchianOutput.middle),
src/indicators/ease_of_movement.rs (mid), and
src/indicators/super_trend.rs (hl2); plus eight test-helper
Candle::new constructions across accelerator_oscillator,
atr_trailing_stop, chaikin_volatility, chandelier_exit,
chande_kroll_stop, choppiness_index, super_trend, true_range.
All twelve switched to f64::midpoint (stable since Rust 1.85,
our workspace MSRV).
2. usize::is_multiple_of is still unstable (rust-lang/rust#128101) and
only stabilizes in Rust 1.87, but the MSRV CI job uses 1.85. The
two call sites in bollinger.rs and sma.rs (added with the R7
periodic-reseed tests) switched back to i % 2 == 0.
The mathematical result of HistoricalVolatility on a perfectly geometric
price series is exactly zero — but the underlying 1.01_f64.powi(i) +
log-return + std-dev cascade accumulates platform-sensitive FP drift on
the order of 1e-7 on x86_64 Linux and macOS (the Windows result happened
to round closer to zero, which is why the test passed locally and on the
Windows CI runner but failed on Linux and macOS).
Bump the tolerance from 1e-9 to 1e-6. That stays four decimal places
below any realistic annualised volatility value while comfortably
absorbing the observed cross-platform drift.
Also extend the comment to document the rationale so the next person
who reads the test does not tighten it back down.
R13 — `HistoricalVolatility::update` previously substituted `0.0` for
the log-return whenever `prev <= 0` or `input <= 0`. The log-return is
undefined there, and silently treating bad ticks as "no movement"
underreports realised volatility on broken data feeds. The fix skips
non-positive prices entirely: `self.last` is returned, state is left
untouched, and the next real tick re-anchors against the previous
*valid* `prev_price`. This matches how every other indicator handles
invalid inputs (SMA / EMA / ROC / Bollinger).
A new test `skips_non_positive_prices` proves the invariant: after a
warmed-up indicator, two consecutive bad ticks (`-5.0` and `0.0`) must
return the baseline value, and a subsequent real positive tick must
produce the same output as a control indicator that simply never saw
the bad ticks.
R14 — `Tick::new` previously returned `Error::InvalidCandle` for
negative volume. A tick is not a candle; downstream tick-stream
pipelines should be able to match on a semantically-correct error. A
new `Error::InvalidTick { message }` variant is added; the existing
test is updated to assert against it. Python's `map_err` is extended
to forward the new variant as `PyValueError`; the Node and WASM
bindings format via `Error::to_string()` and pick the new variant up
automatically without source changes.
`Sma` and `BollingerBands` both maintained their running `sum` (and
`sum_sq` for Bollinger) with a single-subtract incremental update. That
is correct in exact arithmetic, but in f64 the sequence `sum -= old;
sum += new` on long streams with alternating large/small magnitudes
can accumulate catastrophic-cancellation error. Bollinger's existing
`.max(0.0)` clamp on the computed variance was a band-aid for the same
root cause — the drift had already driven the running variance below
zero.
The fix: every `16 · period` finite updates, reseed `sum` (and `sum_sq`
for Bollinger) from the live window. Amortised cost stays at O(1) —
`O(period)` work amortised over `O(period)` updates — and the reseed
strategy is named after the constant `RECOMPUTE_EVERY` so the
intention is clear at the call site.
Behaviour is unchanged on inputs that did not drift to begin with
(every existing test still passes, including `batch_equals_streaming`
and the SMA proptest). Two new stress tests
(`long_stream_drift_stays_bounded` in each module) feed a
magnitude-alternating stream for `5 · RECOMPUTE_EVERY · period`
updates and assert the reported value tracks a fresh from-scratch
computation over the live window to within tight tolerance — these
would have failed without the reseed on Bollinger's `sum_sq`.
The misleading `sma.rs` comment that claimed drift was already
bounded by recomputing the sum after each pop is rewritten to
describe the actual reseed strategy (audit finding L2-Rust).
Follow-up to b340ecd — the doc comment on
`warmup_period_matches_first_some_for_every_parameter_set` had an
unbalanced inline-code span ("`Some``") that tripped
`clippy::doc_invalid_doc_attributes` (caught by `clippy -D warnings`
but not by `cargo build` or `cargo test`). Rephrased the sentence so
every backtick is paired. No code change, no test change.
Audit finding R12 claimed `Coppock::warmup_period()` was off by one
because it returns `max(roc_long, roc_short) + wma`, while
`Roc::warmup_period() = period + 1`. After tracing the actual emission
sequence the existing formula is correct: when both ROCs reach `Some`
at 0-based index L (the slower of `roc_long_period` and
`roc_short_period`), the WMA receives its first input there and emits
its `wma_period`-th value at 0-based index `L + wma_period − 1`. The
`warmup_period()` is the 1-based count of inputs needed before the
first `Some`, i.e. `L + wma_period`. R12 was a misread by both Sonnet
audit agents and the Opus verifier — none of them traced the actual
emission timeline.
This commit:
- Expands the doc comment on `warmup_period` with the precise emission
argument and a worked example for `Coppock::new(6, 4, 3)` (the
existing test) so a future reader cannot mis-derive the formula.
- Adds `warmup_period_matches_first_some_for_every_parameter_set`,
which asserts `out[warmup - 1].is_some()` for five parameter
combinations — including the audit's smoking gun `(4, 2, 3)`. The
audit's proposed `max + 1 + wma` formula would have predicted index
7 (the 8th input) for that combination; the real first `Some` lands
at index 6 (the 7th input), exactly what the current formula
reports.
No behaviour change — the audit was wrong and the test makes the
contract regression-proof.
`LinearRegression::fit` and `LinRegSlope::update` previously iterated the
full `period`-window on every tick to recompute `Σy` and `Σxy` from
scratch — O(period) per update, in violation of the `Indicator` trait's
O(1) contract. `LinRegAngle` inherits the cost transitively because it
delegates to `LinRegSlope`.
This commit slides the OLS state in closed form. The constant terms
(`Σx`, `Σxx`, the denominator `n·Σxx − (Σx)²`) were already precomputed
in `new`. The new running state is:
- `sum_y: f64` — running sum of the values currently in the window.
- `sum_xy: f64` — running Σ(x · y) where `x` is the position of each
value inside the trailing window (`0` for the oldest, `n−1` for the
newest).
On every push, when the window is already full the front value `y₀` is
popped and the indices of every remaining value shift down by 1; the
identity
new_Σxy = old_Σxy − old_Σy + y₀
closes the slide in O(1). The new value is then pushed at position `k`
(the current length before the push), contributing `k · new_value` to
`sum_xy` and `new_value` to `sum_y`. The output is the same TA-Lib OLS
formula evaluated against the incremental accumulators.
Behaviour is unchanged: same per-tick values, same warmup, same NaN
semantics. Two new tests compare the O(1) result bar-by-bar against a
fresh O(n) refit on a noisy ramp (sliding-phase dominated), a step
function (large pop/push deltas), and constants (tests floating-point
drift) — agreement is within `1e-9`.
`LinRegAngle` benefits automatically through its `LinRegSlope` field.
`Psar::is_ready` previously returned `self.initialised`, which flips to
`true` *after* the seed candle — but the seed candle itself returns
`None`. The contract every other indicator honours is
`is_ready() == true` ↔ "the most recent update produced (or could
produce) a real value". Streaming consumers writing
`if ind.is_ready() { use(ind.update(c)?) }` would hit an unexpected
`None` on the first post-seed update.
Fix: add a `has_emitted: bool` field that flips on the first
`Some(sar)` return; `is_ready` now reads that. New test
`is_ready_only_after_first_some_value` pins the contract.
While in the same file, `reset()` is corrected to restore the compute
fields (`prev_high`, `prev_low`, `sar`, `ep`) to `f64::NAN` sentinels
instead of `0.0` (Opus bonus finding). The fields are gated by
`initialised` today, so the `0.0` sentinel never leaked into output —
but a future refactor that read them pre-init would have silently
treated `0.0` as a real price. A `debug_assert!` at the read site makes
the invariant explicit and catches a re-introduction of the bug in
debug builds.
Bit-equivalence with the previous behaviour is preserved
(`reset_allows_clean_reuse` and `batch_equals_streaming` continue to
pass unchanged).
`UlcerIndex::update` previously scanned the full `period`-window every
tick via `prices.iter().fold(NEG_INFINITY, f64::max)`, breaking the
`Indicator` trait's O(1) contract. For long windows (e.g. period 50+ on
a live tick stream) this turned a constant-time update into an O(period)
one, and full-history batch replays into O(n · period).
The window of raw prices is replaced with a monotonically-decreasing
deque of `(index, price)` pairs. On every push, all back entries
`<= input` are popped (they can never be the trailing max again, since
they are dominated and at least as old). On every step, the front is
popped if its index is older than `count - period + 1`. The deque's
front is therefore always the trailing max in O(1). `count: u64` is the
1-based input counter that drives expiration; on `reset()` it returns
to zero alongside the deque and the drawdown state.
Behaviour is unchanged: same per-tick values, same warmup
(`2 * period - 1`), same non-finite-input semantics. A new test
`monotone_deque_matches_naive_max_on_adversarial_inputs` compares the
deque output bar-by-bar against an independent O(n) trailing-max scan on
inputs designed to hit every code path: strictly increasing (full tail
pops), strictly decreasing (head expirations only), constants (the
`<= input` pop rule keeps a single newest entry), and a sawtooth.
The doc comment on `warmup_period()` is also corrected (B-Opus-2): the
two windows overlap by one bar, so the formula is `2 * period - 1`, not
`2 * period`.
Second half of the eight indicators that fill out the new family taxonomy.
- Rust core: true_range.rs (TrueRange — the raw single-bar volatility ATR
averages), chaikin_volatility.rs (ChaikinVolatility — rate of change of a
smoothed high-low spread), z_score.rs (ZScore — price normalised against
its rolling mean and standard deviation) and linreg_angle.rs (LinRegAngle
— the rolling regression slope as a degree angle). Each with a full
Indicator impl, runnable doctest and reference / property / warmup /
reset / batch==streaming tests.
- Python / Node / WASM: classes wired through all three bindings (ZScore
and LinRegAngle ride the scalar macros where possible) plus .pyi stubs
and __init__.py / __all__ entries.
- Wiki: four new Indicator-*.md pages.
The eight-family taxonomy restructure (Overview / Home / README / folder
layout) lands next in F13c.
cargo fmt + clippy (core/wickra/data/wasm/node) clean; 508 core tests,
25 data tests and 74 doctests green.
First half of the eight indicators that fill out the new family taxonomy.
- Rust core: accelerator_oscillator.rs (AcceleratorOscillator — AO minus a
short SMA of itself), balance_of_power.rs (BalanceOfPower — per-bar
(close-open)/(high-low)), choppiness_index.rs (ChoppinessIndex — summed
true range over the high-low span, log-scaled) and
vertical_horizontal_filter.rs (VerticalHorizontalFilter — net move over
total move). Each with a full Indicator impl, runnable doctest and
reference / property / warmup / reset / batch==streaming tests.
- Python / Node / WASM: classes wired through all three bindings
(BalanceOfPower carries an explicit open column; VHF rides the scalar
macros) plus .pyi stubs and __init__.py / __all__ entries.
- Wiki: four new Indicator-*.md pages.
The eight-family taxonomy restructure (Overview / Home / README / folder
layout) lands in F13c once F13b's four indicators are in.
cargo fmt + clippy (core/wickra/data/wasm/node) clean; 481 core tests,
25 data tests and 70 doctests green.
- Rust core: cmf.rs (Chaikin Money Flow — summed money-flow volume over
summed volume, bounded to [-1, +1]), chaikin_oscillator.rs (Chaikin
Oscillator — the MACD of the ADL, EMA(ADL, fast) - EMA(ADL, slow)),
force_index.rs (Elder's Force Index — EMA of price change scaled by
volume), ease_of_movement.rs (Arms' Ease of Movement — SMA of distance
travelled per unit of volume). Each with a full Indicator impl,
runnable doctest and reference / property / warmup / reset /
batch==streaming tests.
- Python: PyChaikinMoneyFlow / PyChaikinOscillator / PyForceIndex /
PyEaseOfMovement PyO3 classes + module registration + .pyi stubs.
- Node: explicit ChaikinMoneyFlowNode / ChaikinOscillatorNode /
ForceIndexNode / EaseOfMovementNode; index.d.ts and index.js updated.
- WASM: WasmChaikinMoneyFlow / WasmChaikinOscillator / WasmForceIndex /
WasmEaseOfMovement.
- Wiki: Indicator-ChaikinMoneyFlow/ChaikinOscillator/ForceIndex/
EaseOfMovement.md plus a new "Oscillators" sub-table in
Indicators-Overview.md and entries in Home.md.
cargo fmt + clippy (core/wickra/data/wasm/node) clean; 402 core tests,
25 data tests and 57 doctests green.
Completes the F9 family (Cumulative volume) end to end:
- Rust core: adl.rs (Accumulation/Distribution Line — cumulative
range-weighted volume) and vpt.rs (Volume-Price Trend — cumulative
volume scaled by percentage price change). Each with a full Indicator
impl, runnable doctest and reference / cumulative-property / warmup /
reset / batch==streaming tests.
- Python: PyAdl / PyVolumePriceTrend PyO3 classes + module registration
+ .pyi stubs (no parameters, like OBV/VWAP).
- Node: explicit AdlNode and VolumePriceTrendNode; index.d.ts and
index.js updated.
- WASM: WasmAdl and WasmVolumePriceTrend.
- Wiki: Indicator-Adl.md and Indicator-VolumePriceTrend.md plus rows in
Indicators-Overview.md and entries in Home.md.
cargo fmt + clippy (core/wickra/data/wasm/node) clean; 373 core tests,
25 data tests and 53 doctests green.
Completes the F7 family (Volatility) end to end:
- Rust core: natr.rs (ATR as a percentage of close), std_dev.rs
(rolling population standard deviation), ulcer_index.rs (RMS of
trailing-high drawdowns — downside-only risk), historical_volatility.rs
(annualised sample stddev of log returns). Each with a full Indicator
impl, runnable doctest and reference / constant-series / warmup /
reset / batch==streaming tests.
- Python: PyNatr / PyStdDev / PyUlcerIndex / PyHistoricalVolatility
PyO3 classes + module registration + .pyi stubs.
- Node: StdDevNode / UlcerIndexNode via the scalar macro, explicit
NatrNode and HistoricalVolatilityNode; index.d.ts and index.js updated.
- WASM: WasmStdDev / WasmUlcerIndex / WasmHistoricalVolatility via the
scalar macro, explicit WasmNatr.
- Wiki: Indicator-Natr/StdDev/UlcerIndex/HistoricalVolatility.md plus
rows in Indicators-Overview.md and entries in Home.md.
cargo fmt + clippy (core/wickra/data/wasm/node) clean; 350 core tests,
25 data tests and 49 doctests green.
Completes the F5 family (Price oscillators) end to end:
- Rust core: ppo.rs (Percentage Price Oscillator — MACD as a percentage
of the slow EMA), dpo.rs (Detrended Price Oscillator — shifted price
minus its SMA), coppock.rs (Coppock Curve — WMA of two summed ROCs).
Each with a full Indicator impl, runnable doctest and reference /
constant-series / warmup / reset / batch==streaming / non-finite tests.
- Python: PyPpo / PyDpo / PyCoppock PyO3 classes + module registration
+ .pyi stubs (defaults PPO=(12,26), DPO=20, Coppock=(14,11,10)).
- Node: DpoNode via the scalar macro, explicit PpoNode and CoppockNode;
index.d.ts and index.js updated.
- WASM: WasmDpo / WasmPpo / WasmCoppock via the scalar macro.
- Wiki: Indicator-Ppo/Dpo/Coppock.md plus rows in Indicators-Overview.md
and entries in Home.md.
cargo fmt + clippy (core/wickra/data/wasm/node) clean; 300 core tests,
25 data tests and 42 doctests green.
Completes the F4 family (Stochastic oscillators) end to end:
- Rust core: stoch_rsi.rs (Stochastic Oscillator applied to the RSI
series, bounded [0,100]) and ultimate_oscillator.rs (Larry Williams'
weighted three-timeframe buying-pressure oscillator). Each with a full
Indicator impl, runnable doctest and reference / saturation / bounds /
warmup / reset / batch==streaming tests.
- Python: PyStochRsi / PyUltimateOscillator PyO3 classes + module
registration + .pyi stubs (defaults StochRSI=(14,14), UO=(7,14,28)).
- Node: explicit StochRsiNode and UltimateOscillatorNode; index.d.ts
and index.js updated.
- WASM: WasmStochRsi via the scalar macro, explicit
WasmUltimateOscillator.
- Wiki: Indicator-StochRsi.md and Indicator-UltimateOscillator.md plus
rows in Indicators-Overview.md and entries in Home.md.
cargo fmt + clippy (core/wickra/data/wasm/node) clean; 278 core tests,
25 data tests and 39 doctests green.
Completes the F2 family (Advanced MAs) end to end:
- Rust core: zlema.rs (Zero-Lag EMA over the de-lagged series
2·price − price[lag]), t3.rs (Tillson's six-EMA cascade with the
volume-factor polynomial), vwma.rs (volume-weighted rolling mean with
a zero-volume fallback to the unweighted mean). Each with a full
Indicator impl, runnable doctest and reference-value / warmup /
reset / batch==streaming / non-finite tests.
- Python: PyZlema / PyT3 / PyVwma PyO3 classes + module registration
+ .pyi stubs (T3 defaults v=0.7).
- Node: ZlemaNode via the scalar macro, explicit T3Node and VwmaNode
classes; index.d.ts and index.js updated.
- WASM: WasmZlema / WasmT3 via the scalar macro, explicit WasmVwma.
- Wiki: Indicator-Zlema.md, Indicator-T3.md, Indicator-Vwma.md plus
rows in Indicators-Overview.md and entries in Home.md.
cargo fmt + clippy (core/wickra/data/wasm/node) clean; 232 core tests,
25 data tests and 33 doctests green.
First step of the indicator-family expansion (see the F section of
todo-detailed.md). Family F1 — Simple & Weighted MAs — gains two
members alongside the existing Sma/Ema/Wma:
- Smma — Wilder's smoothed moving average (RMA): SMA-seeded, then the
(prev*(n-1)+x)/n recurrence. The average underlying RSI and ATR.
- Trima — triangular moving average: two stacked SMAs (n1/n2 split by
parity) that triangular-weight the window. Genuine stacking — the
outer SMA consumes the inner SMA's output.
Both implement the full Indicator trait with reference-value, warmup,
reset, batch==streaming and non-finite-input tests, a runnable doctest,
and are re-exported from the crate root. 208 core tests + 30 doctests
pass; clippy and fmt clean.
Only two doctests existed in wickra-core; none of the 25 indicator
types carried a runnable rustdoc example.
Add an "# Example" doctest to every public indicator type (all 26,
including RollingVwap): construct the indicator and stream 80 inputs
through update, asserting a value is produced. The candle-input
indicators build valid OHLCV candles inline. cargo test --doc
-p wickra-core now runs 28 doctests, all passing; fmt and clippy clean.
Normalises whitespace in sources committed earlier in this branch
before rustfmt was run over them (Node/WASM bindings, and three core
indicator test modules), and records the wasm-bindgen-test dependency
tree added in B6 into Cargo.lock. No functional change; cargo fmt --all
--check is now clean.
Keltner::update gated atr.update behind ema.update(...)? and Hma::update
gated full_wma.update behind half_wma.update(...)?. The ? short-circuit
starved the trailing sibling of every candle consumed during the leading
one's warmup, so warmup_period() understated the true first emission
(Keltner classic: 29 instead of 20; HMA(9): 14 instead of 11) and
Keltner's ATR seeded over the wrong window.
Both now feed every sub-indicator unconditionally and gate only the
output, matching the MACD / Awesome Oscillator pattern. warmup_period()
is now exact. Adds first-emission tests and cross-checks against
independent EMA+ATR (Keltner) and independent WMAs (HMA).
ROC now stores its last emitted value and returns it on a non-finite
input instead of None, leaving the window untouched. This matches the
SMA / EMA convention. reset() clears the new field. Adds a
non-finite-input test.
Adds the reset tests the audit named as missing (aroon, awesome
oscillator, donchian, keltner, williams_r, and both VWAP variants),
non-finite-input tests for every scalar indicator that guards is_finite
(WMA, RSI, MACD, Bollinger, KAMA), and naive-reference proptests for EMA,
RSI and ATR. 189 core tests pass.
reset() now restores prev_high, prev_low and trend in addition to the
previously reset fields, keeping the struct fully consistent for
inspection. The misleading inline comment that claimed direction-dependent
seeding is corrected to describe the actual fixed-Up seed, which
self-corrects through PSAR's reversal logic. Adds a reset-reuse test.
The first candle now only seeds the previous typical price instead of
pushing a fabricated (0,0) money-flow pair into the window, matching the
TA-Lib / pandas-ta convention. warmup_period() returns period + 1 and the
dead prev_tp.is_none() guard is removed. Adds a first-emission test and a
hand-computed reference-value test (MFI(2) = 1200/23).
A multi-language technical analysis library: 25 indicators across trend,
momentum, volatility, and volume families, every one a state machine with
O(1) per-tick updates. Batch evaluation is provided by a blanket extension
trait over the streaming primitive, so live trading bots and historical
backtests run the same code path.
What ships in this initial drop:
crates/wickra-core - 25 indicators, Indicator/BatchExt/Chain traits,
OHLCV types with validation; 171 unit tests,
property tests, Wilder/Bollinger textbook tests.
crates/wickra - top-level facade + criterion benches for every
indicator at 1K/10K/100K series sizes.
crates/wickra-data - streaming CSV reader, tick-to-candle aggregator,
multi-timeframe resampler, Binance Spot kline
WebSocket adapter behind feature live-binance;
11 unit + 1 doctest.
bindings/python - PyO3 + maturin, NumPy I/O, type stubs (.pyi),
56 pytest tests including streaming==batch
equivalence, Wilder reference values, lifecycle.
bindings/node - napi-rs native module, TypeScript .d.ts
auto-generated, 7 node --test cases.
bindings/wasm - wasm-bindgen ES module for browser/bundler/Node;
interactive HTML demo at examples/index.html.
examples/ - Python and Rust scripts: backtest, live trading,
parallel multi-asset, multi-timeframe, Binance.
benchmarks/ - cross-library comparison against TA-Lib,
pandas-ta, finta, talipp; Wickra wins every
category by 11-1030x (batch) and 17x+ streaming.
.github/workflows/ - CI matrix (Rust + Python + Node + WASM on
Linux/macOS/Windows), release pipeline for
PyPI wheels and npm.
Indicators (25):
Trend SMA EMA WMA DEMA TEMA HMA KAMA
Momentum RSI MACD Stochastic CCI ROC WilliamsR ADX MFI TRIX
AwesomeOscillator Aroon
Volatility BollingerBands ATR Keltner Donchian PSAR
Volume OBV VWAP (cumulative + rolling)
cargo clippy --workspace --all-targets -D warnings is clean. License: Apache-2.0.