Codecov flagged lines 86-102 in crates/wickra-core/src/ohlcv.rs as missed
(file at 90.00%) — the entire body of `Candle::new_unchecked`. Every
existing test routes through the validating `Candle::new`, so the unchecked
constructor (intended for callers like the aggregator and parsed-payload
paths that have already validated upstream) was dead.
Add candle_new_unchecked_preserves_fields_verbatim:
- first assertion builds a candle with six distinct field values and
verifies each reads back exactly.
- second assertion feeds an OHLC combination (high < low) that the
checked constructor rejects with Error::InvalidCandle, then proves
Candle::new_unchecked still builds the struct as-is. This documents
and enforces the API contract that the unchecked variant performs
no validation.
ohlcv.rs is now at 170/170 lines, no behavioural change.
Codecov flagged 17 uncovered lines in
crates/wickra-core/src/indicators/vwap.rs (file at 87.94%):
- Vwap::value() Some branch (line 53) — the only test calling value()
did so after reset() when sum_v == 0, exercising only the None branch
- Vwap::update zero-volume early-return `return None;` (line 67) — all
existing candles carried strictly positive volume
- Vwap::warmup_period body returning 1 (79-81), Vwap::name body
returning "VWAP" (87-89) — metadata never queried
- RollingVwap::period accessor (134-136), RollingVwap::warmup_period
body (165-167), RollingVwap::name body returning "RollingVWAP"
(173-175) — same metadata gap on the rolling variant
Add four new tests:
- cumulative_value_some_branch_after_update drives a single
non-zero-volume candle then asserts value() == Some(typical_price).
- cumulative_zero_volume_first_candle_returns_none feeds a candle
with volume == 0.0, asserts update returns None and is_ready stays
false, then adds a real candle to confirm the indicator still works.
- cumulative_metadata asserts warmup_period() == 1 and name() == "VWAP".
- rolling_accessors_and_metadata asserts period() == 7,
warmup_period() == 7, name() == "RollingVWAP" on a RollingVwap::new(7).
vwap.rs is now at 141/141 lines, no behavioural change.
Codecov flagged 17 uncovered lines in
crates/wickra-core/src/indicators/percent_b.rs (file at 81.11%):
- const accessors period (53-55), multiplier (58-60), value (63-65)
- Indicator-impl bodies warmup_period (90-92) and name (98-100)
- the unreachable `_ => panic!("warmup mismatch at {i}")` arm in
matches_bands_definition (line 141)
- the inner `assert_relative_eq!(*pv, 0.5, …)` (line 158) inside
price_at_middle_is_half, gated by `(prices[i] - bv.middle).abs()
< 1e-9` over a sin-based oscillation that, with period=20 over 60
samples, never lands within 1e-9 of the rolling SMA — so the
assertion was silently dead and the test made no checks.
Add accessors_and_metadata covering the five getter bodies, refactor
matches_bands_definition to assert the warmup-shape invariant via
assert_eq!(p.is_some(), b.is_some()) + if let (kills the panic arm),
and replace price_at_middle_is_half with a deterministic construction:
PercentB::new(3, 2.0) on [1.0, 5.0, 3.0] gives SMA=3.0 at index 2
which equals the third price exactly, stddev=√(8/3)≈1.633 keeps the
width strictly positive so the divide path runs, and %b lands on
exactly 0.5 because price sits on the centre line of symmetric bands.
percent_b.rs is now at 90/90 lines, no behavioural change.
Codecov flagged 17 uncovered lines in
crates/wickra-core/src/indicators/bollinger_bandwidth.rs (file at 79.51%):
- the const accessors period (54-56), multiplier (59-61), value (64-66)
- the zero-middle defensive fallback 0.0 (line 77) inside update
- the Indicator-impl bodies warmup_period (90-92) and name (98-100)
- the unreachable `_ => panic!("warmup mismatch")` arm (line 140) in
the existing matches_bands_definition test
None of the existing tests inspected the metadata surface — every test
fed numeric updates and asserted on bandwidth values, leaving the five
getter bodies dead. The zero-middle path was unreachable because all
existing tests used positive price levels ≈100, so the rolling SMA was
always strictly positive and the divide-by-zero guard never fired.
The panic arm in matches_bands_definition was an invariant guard that
by design cannot fire when the two streams share a warmup period; that
invariant is now asserted directly with assert_eq!(w.is_some(),
b.is_some()), and the catch-all arm is gone.
Add two new tests and refactor one existing:
- accessors_and_metadata asserts period == 20, multiplier == 2.0,
value() == None before warmup, warmup_period == 20, name ==
"BollingerBandwidth", then drives 20 updates so value() also
exercises the Some branch.
- zero_middle_band_yields_zero_bandwidth feeds [-2, -1, 0, 1, 2] so
the 5-bar SMA lands on exactly 0.0 at the fifth input, and asserts
the emitted bandwidth is exactly 0.0 (rather than inf/nan from the
would-be divide-by-zero).
- matches_bands_definition now uses an explicit assert_eq! on
is_some() agreement plus an if let for the numeric compare,
removing the unreachable panic arm without weakening the
invariant check.
bollinger_bandwidth.rs is now at 83/83 lines, no behavioural change.
Codecov flagged 30 uncovered lines in crates/wickra-core/src/traits.rs (file at
75.60%): the const borrow accessors Chain::first / Chain::second (140-147), the
Chain::warmup_period + Chain::name Indicator-impl bodies (167-178), the full
Identity test-helper Indicator surface — reset, warmup_period, is_ready, name
(198-209), and Doubler's warmup_period + name (228-236).
None of the existing tests touched those code paths: every chain test invoked
update/reset/is_ready through the Chain wrapper without ever inspecting the
borrow accessors, querying chain.warmup_period(), or asking for chain.name(),
and the Identity helper was only ever driven by batch (which calls update
only). Doubler's warmup_period and name were similarly dead because
Chain::warmup_period and Chain::name themselves were dead.
Add two new tests at the end of the Chain section in mod tests, immediately
before the parallel-feature-gated test:
- chain_accessors_and_metadata exercises chain.first(), chain.second(),
chain.warmup_period(), chain.name(), and pulls Doubler::warmup_period
+ Doubler::name in via the borrowed accessors.
- identity_helper_full_indicator_surface asserts warmup_period == 0,
name == "Identity", and walks is_ready through both seen=false and
seen=true via an update/reset cycle.
traits.rs is now at 123/123 lines, no behavioural change.
Codecov flagged a single uncovered line in crates/wickra-data/src/resample.rs:
line 46, the `self.low = c.low;` assignment inside RolledBar::absorb.
None of the existing resampler tests fed a follow-up candle with a strictly
lower low than the first candle in the bucket, so the `c.low < self.low`
branch never fired. Coverage stayed at 122/123.
Add a small dedicated test that pushes a 10.0-low candle into bucket 0, then
a 8.0-low candle into the same bucket, and asserts the rolled bar's low
reflects the dip. Resample file is now at 123/123 lines, no behavioural change.
Dependabot opened #13 to bump tokio-tungstenite from 0.24 to 0.29 but
the bare-version bump fails to compile: WebSocketConfig became
#[non_exhaustive] starting with 0.27, so the existing struct-literal
construction
let ws_config = WebSocketConfig {
max_message_size: Some(MAX_MESSAGE_SIZE),
max_frame_size: Some(MAX_FRAME_SIZE),
..WebSocketConfig::default()
};
produces
error[E0639]: cannot create non-exhaustive struct using struct expression
Switch to the builder-style setters that 0.29 exposes on the default
value. Semantics are unchanged; both fields still carry the
MAX_MESSAGE_SIZE / MAX_FRAME_SIZE caps from the original config and the
rest of the WebSocketConfig defaults are preserved by starting from
WebSocketConfig::default().
This supersedes #13 — same target version, plus the code change
Dependabot can't make on its own.
Verified locally:
cargo check -p wickra-data --features live-binance # clean
cargo test --workspace --all-features # 630 passed / 0 failed
cargo clippy --workspace --all-targets --all-features -- -D warnings
Dependabot opened #10 to bump criterion from 0.5.1 to 0.8.2 but the
straight version bump fails to build: criterion::black_box was
deprecated in 0.6 and removed/marked deny-warn in 0.8, so the existing
`use criterion::{black_box, ...}` produces
error: use of deprecated function `criterion::black_box`: use
`std::hint::black_box()` instead
across every bench callsite. Switch the import to std::hint::black_box
(stable since Rust 1.66, well under our MSRV of 1.85) and drop the
criterion re-export.
This supersedes #10 — same target version, plus the code change
Dependabot can't make on its own.
Verified locally:
cargo bench -p wickra --no-run # builds clean
cargo clippy --workspace --all-targets --all-features -- -D warnings
cargo test --workspace # 630 passed / 0 failed
The tick_aggregator fuzz target found that TickAggregator::fill_between
allocates one placeholder Candle per skipped bucket without bounding the
gap size. An adversarial input (a clock-glitch tick years in the future)
produced an OOM on libFuzzer after malloc(~3 GB):
SUMMARY: libFuzzer: out-of-memory (malloc(3221225472))
A real-world failure mode too: a single bad timestamp from a flaky feed
could OOM the host process even though every individual tick passed
Tick::new validation.
Fix:
- Compute the gap size up-front via saturating arithmetic, before any
allocation, and refuse with Error::Malformed when it exceeds the new
MAX_GAP_FILL_CANDLES = 1_000_000 cap (≈ 1.9 years of contiguous
one-minute bars, well above any realistic missing-data window).
- Reserve the right Vec capacity in advance once we know the gap fits,
avoiding intermediate reallocations.
- Add two regression tests: gap_fill_rejects_runaway_timestamp_jump
(the fuzz scenario) and gap_fill_at_the_cap_succeeds (exact-cap input
still works).
The cap is exposed as pub const so callers can pre-validate their input
without relying on the error string.
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`.
The three Rust examples (backtest, fetch_btcusdt, live_binance) used to
live each in their own crate's examples/ dir, splitting the example set
across crates and burying it inside the source tree. Move them into a new
workspace member crate at `examples/rust/` (package `wickra-examples`,
`publish = false`) so all language examples sit under one top-level
`examples/<lang>/` tree.
* `examples/rust/Cargo.toml` declares the per-binary deps (wickra,
wickra-data with the `live-binance` feature always on, serde_json, tokio
for the macro and current-thread runtime).
* `examples/rust/src/bin/{backtest,fetch_btcusdt,live_binance}.rs` are the
three migrated binaries; their doc-comments and the fetch_btcusdt output
path are updated for the new location and run command
(`cargo run -p wickra-examples --bin <name>`).
* Workspace `Cargo.toml` lists the new member; the now-empty
`[dev-dependencies]` extras (`wickra`, `tokio` in wickra-data and
`serde_json` in wickra) that existed only for these examples are dropped.
* The `[[example]] live_binance` table is removed from wickra-data's
manifest since the file moved out.
* README "Languages" + project-layout, examples/README.md, Quickstart-Rust
and Data-Layer are pointed at the new paths and commands.
`cargo build -p wickra-examples` and `cargo run --release -p wickra-examples
--bin backtest -- examples/data/btcusdt-1d.csv` both succeed; the rest of
the workspace (core, data, wickra) builds, clippies (`--all-targets -D
warnings`) and tests (508 core + 28 data + 1 integration + 74+3+1
doctests) all stay green.
The seven BTCUSDT OHLCV datasets used to live under
crates/wickra/examples/data/, which buried them inside a Rust crate even
though the Node backtest example and the upcoming Rust/Node/WASM example
restructure need to reach them too. Move them to the workspace-level
examples/data/ so every language's examples can resolve the same path.
The bench (crates/wickra/benches/indicators.rs), the example_data
integration test, fetch_btcusdt.rs and the Node backtest example all take
the new ../../examples/data/ path; Data-Layer.md, examples/README.md and
the CHANGELOG entry are updated to match. No data file content changes.
Timeframe gained new/millis/seconds/one_minute_ms; add minutes, hours and
days alongside them. Each builds on seconds (minutes(5) -> a 300-second
bucket), consistent with Timeframe::seconds, and guards the multiplication
with checked_mul so an oversized n yields Error::InvalidTimeframe instead
of an overflow panic. A non-positive n is rejected by Timeframe::new.
Each method carries a runnable doctest, and unit tests cover the known
bucket sizes, non-positive rejection and overflow rejection.
Add seven OHLCV datasets under crates/wickra/examples/data/, one per
timeframe (1m/5m/15m/1h/12h/1d/1month), holding real BTCUSDT spot klines
fetched from the Binance REST API. The new fetch_btcusdt example
regenerates them: it paginates the klines endpoint through the system
curl, parses with serde_json, validates every candle via Candle::new and
keeps only fully closed buckets.
The indicator benchmarks now run against the 1m dataset instead of a
synthetic series, and a new example_data integration test checks that
every file parses and carries evenly spaced, monotonic timestamps.
The monthly file is named btcusdt-1month.csv rather than btcusdt-1M.csv
so it does not collide with btcusdt-1m.csv on case-insensitive
filesystems (Windows, default macOS).
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.
The three published crates had no documentation link and no docs.rs
configuration, so wickra-data's feature-gated live-binance module would
not render on docs.rs.
Add documentation = "https://docs.rs/<crate>" and a
[package.metadata.docs.rs] section with all-features = true to
wickra-core, wickra, and wickra-data. No `exclude` is added: each crate
directory contains only src/ (plus benches/examples that are useful
source), so there is nothing irrelevant to drop from the .crate.
OpenBar::into_candle and RolledBar::into_candle built their result with
Candle::new_unchecked, skipping the finiteness check. volume is summed
across every absorbed tick/candle, so a long or large run can drift it
to +inf — and an inf-volume candle would silently poison every
downstream indicator.
Switch both to Candle::new, which validates volume finiteness, and
return Result<Candle>. The OHLC fields are finite and correctly ordered
by construction, so the only invariant Candle::new can reject here is a
non-finite volume. push propagates the error with `?`; both flush
methods now return Result<Option<Candle>> and resample_all pulls the
result through.
push rejected ticks that went backwards across buckets but absorbed any
tick whose timestamp fell inside the open bucket — including one older
than the last tick already absorbed. Such a stale tick silently
overwrote the bar's close with an outdated price.
Track last_ts on OpenBar (set in from_tick, advanced in absorb) and, on
the same-bucket path, reject a tick whose timestamp predates it with
Error::Malformed, leaving the open bar untouched. Ticks that share a
timestamp are still accepted, since several trades can land in the same
millisecond.
Timeframe::floor computed `ts - ts.rem_euclid(bucket)`. For a timestamp
within one bucket of i64::MIN the subtrahend is a positive remainder
and the true boundary lies below i64::MIN, so the subtraction overflowed
and panicked in debug builds.
Switch to saturating_sub: the result clamps to i64::MIN in that
practically unreachable case and stays exact everywhere else. floor
keeps its infallible `-> i64` signature, so neither push path changes.
The CSV reader set has_headers(true) with no trimming and no header
check, so three real-world inputs failed silently or opaquely:
- A file with no header row had its first data row consumed as the
header and silently dropped.
- A leading UTF-8 BOM (Excel exports it) became part of the first
header name, breaking the `timestamp` column mapping.
- Leading/trailing whitespace around values broke serde parsing.
Add a BomStripReader<R> Read adapter that discards a leading EF BB BF,
set csv::Trim::All on the builder, and validate after opening that the
header names every required OHLCV column — a missing column now yields
a clear Error::Malformed instead of a silent misread. open/from_reader
route through a shared build() helper; from_reader and from_csv_reader
now return Result because header validation can fail.
A tick that jumped across one or more empty buckets previously opened
the next non-empty bar directly, so the candle series silently grew
time holes — downstream indicators (EMA, ATR, ...) computed over such a
series drift from one computed over an unbroken series.
Add an opt-in gap-fill mode: with_gap_fill(true) makes push emit a flat
placeholder candle (open == high == low == close = the pre-gap close,
volume = 0) for every skipped bucket. push now returns Result<Vec<Candle>>
so a single tick can yield the closed bar plus its trailing fillers;
the empty vector replaces the former Ok(None). Timestamp overflow while
filling is reported as Error::Malformed. Default behaviour is unchanged
(gaps skipped) and is now documented on the type and on push.
Resampler::push previously closed the open bar and opened a new one for
any candle whose bucket differed from the open bar, including buckets
strictly before it — silently corrupting the output for out-of-order
input. TickAggregator::push already rejects this case with an error.
Change push to return Result<Option<Candle>>: candles in an earlier
bucket than the open bar now yield Error::Malformed, matching the
aggregator. resample_all propagates the error via `?`. The doc comment
keeps the input/output multiple relationship as a documented caller
responsibility, since Resampler does not know the input timeframe.
A 24-hour forced disconnect or a network blip permanently killed the
feed: next_event returned Ok(None)/Err and the stream was dead. The
struct now retains the subscribed symbols, an open() helper rebuilds the
socket, and reconnect() retries with exponential backoff (1s..30s, up to
MAX_RECONNECT_ATTEMPTS). next_event transparently reconnects on a
protocol error, a server close or a read stall, and only reports Ok(None)
after the caller has closed the stream. close() now takes &mut self.
connect() used connect_async with no WebSocketConfig and next_event
awaited the socket with no deadline, so a stalled server hung the feed
forever and an oversized message could force an unbounded allocation.
connect() now passes a WebSocketConfig capping message/frame size, and
next_event wraps the read in a 300s tokio timeout (well above Binance's
~3-minute ping), surfacing a stall as the new Error::Timeout.
BinanceKlineStream had no closed-state flag, so after the server closed
the connection (Ok(None)) a caller could keep calling next_event and
poll a dead socket. A closed: bool is now set when the server closes or
sends a Close frame; next_event short-circuits to Ok(None) once set, and
is_closed() exposes the state.
next_event deserialized every text frame straight into RawWsEnvelope, so
a subscription acknowledgement, heartbeat or error object propagated a
decode Err and killed the feed. Frames are now routed through
parse_frame, which inspects data.e: kline frames yield an event,
everything else is skipped, and an Err is raised only when a genuine
kline frame fails to decode. Adds tests for skipped acks/errors.
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.