Compare commits

..

34 Commits

Author SHA1 Message Date
kingchenc 221f7a71bc chore(github): add detailed issue & PR templates, capitalize title prefixes
Adds five new issue templates (bug_report_detailed, feature_request_detailed,
performance_regression, documentation, question) alongside the existing
short forms, plus an optional detailed PR template under
.github/PULL_REQUEST_TEMPLATE/ that contributors can opt into via the
?template=detailed.md URL. Existing templates keep their behavior; only
title prefixes and prose-paren wording were capitalized for consistency.
2026-05-24 02:33:01 +02:00
kingchenc b5afc0a7e7 release: bump workspace + bindings to 0.2.5 (#35)
Workspace, every binding (Python, Node, Node platform stubs), and the
release.yml comment are all updated together so the next tagged release
on `v0.2.5` lines every artefact up.

Also adds a short README "Disclaimer" section pointing out that Wickra
is an indicator toolkit, not a trading system, and that production
use is at the caller's own risk. The legal terms in LICENSE (PolyForm
Noncommercial 1.0.0, "No Liability") already cover the warranty / as-is
language — the README section just makes the trading-specific framing
visible without burying it in a click-through.

CHANGELOG carries the new 0.2.5 entry with the API addition
(`BinanceConfig` + `connect_with_config`) and the best-effort Pong
write change in `BinanceKlineStream::next_event`. wickra-win32-arm64-msvc
stays excluded for this release with the same npm-spam-filter rationale
that held for 0.2.1.
2026-05-24 02:16:40 +02:00
kingchenc 9acb2f607e test(binance): mock-WS suite drives async/reconnect paths to ~100% (#34)
* refactor(binance): introduce BinanceConfig for endpoint + timing knobs

Replaces the file-private READ_TIMEOUT / MAX_RECONNECT_ATTEMPTS / size
limit constants with a Default-equipped BinanceConfig the caller can hand
to a new connect_with_config(). connect() forwards to it with the
defaults, so the public API stays backwards-compatible.

Behaviour-preserving: every default matches the value of the constant it
replaces, and the WebSocketConfig is built the same way. The change
unlocks two real use-cases — pointing at Binance Testnet
(wss://testnet.binance.vision) and pointing at a local mock server with
millisecond-scale reconnect timing in tests.

* test(binance): cover the Interval table and the empty-symbol guard

Three quick wins that don't need a live or mock socket:
- interval_as_str_covers_every_variant pins every wire-format mapping in
  one table so a typo on any of the 14 variants is caught.
- binance_config_default_matches_production_endpoint guards the default
  base URL and timing knobs against an accidental drift.
- connect_rejects_an_empty_symbol_list exercises the guard before the
  WebSocket handshake — the one async path we can hit without a server.

* test(binance): cover the async / reconnect / control-frame paths

Adds a small mock-WebSocket scaffold built on a `127.0.0.1:0` listener
and tokio-tungstenite's `accept_async`, plus nine integration tests that
drive `BinanceKlineStream::next_event` through every branch:

- text + binary kline frames decode to a KlineEvent
- inbound Ping is answered with a Pong, then the kline arrives
- inbound Pong / Frame variants are silently skipped
- a server-side Close triggers a transparent reconnect that then
  serves the kline
- a stalled connection trips read_timeout and reconnects on its own
- close() flips the closed flag and next_event() yields None forever
- when every reconnect attempt is refused, next_event surfaces an Err
- a "kline" envelope whose numbers are unparseable bubbles up as
  Error::Malformed rather than being silently skipped

`one_shot_server` drops the listener as soon as the first accept is
done, so a follow-up reconnect lands on a refused port — that is what
lets the exhaustion test hit the final `last_err.expect(...)`.
The whole suite runs in ~4 s with millisecond-scale reconnect timings
supplied via the new test-only [`test_config`].

* test(binance): drop defensive cold-paths in the mock-WS scaffolding

Codecov's patch report on PR #34 flagged seven uncovered lines, all of
them in the test scaffolding rather than in production code:
- the `let Ok((stream, _)) = … else { return }` shortcut and the
  `if let Ok(ws) = accept_async(stream).await { … }` branch in the
  mock-server helpers — both error arms never fire on a passing test
- the closing braces of the spawned-task bodies in the close-frame and
  read-timeout reconnect tests — the spawned async blocks were getting
  killed mid-drain when the test asserted and returned

Refactor the helpers to `.unwrap()` every Result (a failure here is a
bug in the scaffold, not in production) and have `multi_shot_server`
accept a fixed `n_accepts`, await every spawned inner task, and hand
the outer JoinHandle back to the caller.

Refactor the two affected tests to capture that JoinHandle, collapse
the per-index `if/else` so both arms reach the same trailing
expression, swap the read-timeout drain for a bounded sleep, and
await `server_done` at the end. Every handler now reaches its closing
brace before the runtime is torn down, so coverage on the patch should
collapse from 97.89 % to 100 %.

* test(binance): cover the non-kline-skip path and simplify the Ping arm

After the scaffolding fix landed three lines on binance.rs were still
uncovered:
- L305 / L313: the Text- and Binary-arm "frame was not a kline, keep
  reading" fall-throughs. No existing test drove the loop through a
  non-kline frame followed by a kline; the new
  `next_event_skips_non_kline_frames_and_returns_the_next_kline` does
  exactly that (Text ack, Binary id frame, then a real kline).
- L317: the Ping-Err defensive arm that forced a reconnect when the
  Pong reply itself failed to write. A failed Pong reply means the
  socket is already dead, so the very next read will surface the error
  and reconnect through the existing timeout/err branch — one tokio
  scheduling iteration later. Drop the defensive arm and write the
  Pong reply best-effort. Same observable behaviour, no test back
  door, no dead-line guard.

Repo coverage on `cov/binance-mock-ws` now sits at 100 %.
2026-05-24 02:07:47 +02:00
kingchenc 32caf023dd test(psar): drop violation-tuple cold path in trend tests (99.03 -> 100) (#33)
After PR #27 brought psar.rs to 99.03 %, Codecov still flagged the
'violation found' tuple arms in the trend tests (line 256 in
pure_uptrend_sar_below_lows, line 285 in pure_downtrend_sar_above_highs)
as missed: both tests are designed to NEVER find a violation, so the
filter_map branch that constructs the (index, sar, bound) tuple is dead
by design.

Restructure both tests to use `.all(|(i, sar)| sar.is_none_or(|s|
<bound>))` instead of collecting violations into a Vec. The closure
runs on every emitted Some, asserts the SAR-vs-extreme bound directly,
and the iterator short-circuits on the first false — no cold tuple
construction left to count as uncovered. Semantics are identical (still
asserts every SAR sits on the correct side of every candle's extreme);
the diagnostic message loses the violating index list, which the tests
never printed in any green run anyway.

psar.rs is now at 207/207 lines, no behavioural change.
2026-05-24 01:31:20 +02:00
kingchenc 250b75d468 test: 100% coverage for balance_of_power + median_price + true_range + typical_price + weighted_close (#32)
* test(balance_of_power): cover name metadata

Codecov flagged 3 lines (file at 96.25%): Indicator-impl name body (73-75).

* test(median_price): cover name metadata

Codecov flagged 3 lines (file at 94.44%): Indicator-impl name body (62-64).

* test(true_range): cover name metadata

Codecov flagged 3 lines (file at 95.94%): Indicator-impl name body (73-75).

* test(typical_price): cover name metadata

Codecov flagged 3 lines (file at 94.44%): Indicator-impl name body (62-64).

* test(weighted_close): cover name metadata

Codecov flagged 3 lines (file at 94.44%): Indicator-impl name body (61-63).
2026-05-24 00:48:37 +02:00
kingchenc adc8488939 test: 100% coverage for ema + historical_volatility + kama + linreg_angle + mass_index (#24)
* test(ema): cover period accessor + warmup/name metadata

Codecov flagged 9 lines in crates/wickra-core/src/indicators/ema.rs
(file at 94.03%): const accessor period (74-77), Indicator-impl
warmup_period (123-125), name (131-133). ema.rs now at 151/151.

* test(historical_volatility): cover periods/value accessors + name metadata

Codecov flagged 9 lines in crates/wickra-core/src/indicators/historical_volatility.rs
(file at 93.87%): const accessors periods (80-83), value (85-88) and
Indicator-impl name (153-155). historical_volatility.rs now at 147/147.

* test(kama): cover periods accessor + warmup/name metadata

Codecov flagged 9 lines in crates/wickra-core/src/indicators/kama.rs
(file at 91.26%): accessor periods (65-67), Indicator-impl
warmup_period (115-117), name (123-125). kama.rs now at 103/103.

* test(linreg_angle): cover period accessor + warmup/name metadata

Codecov flagged 9 lines in crates/wickra-core/src/indicators/linreg_angle.rs
(file at 88.15%): const accessor period (50-52), Indicator-impl
warmup_period (67-69), name (75-77). linreg_angle.rs now at 76/76.

* test(mass_index): cover periods/value accessors + name metadata

Codecov flagged 9 lines in crates/wickra-core/src/indicators/mass_index.rs
(file at 91.42%): const accessors periods (80-82), value (85-87) and
Indicator-impl name (134-136). mass_index.rs now at 105/105.
2026-05-24 00:48:35 +02:00
kingchenc d55d3db3d1 test: 100% coverage for vertical_horizontal_filter + z_score + vpt + csv + adl (#31)
* test(vertical_horizontal_filter): cover period accessor + name metadata

Codecov flagged 6 lines (file at 94.44%): period (61-63) + name (119-121).

* test(z_score): cover period accessor + name metadata

Codecov flagged 6 lines (file at 93.75%): period (59-61) + name (106-108).

* test(vpt): cover value() Some branch, name, zero-prev fallback

Codecov flagged 5 lines (file at 94.38%): value() Some branch (57),
prev==0.0 ROC fallback (77), and Indicator-impl name (100-102).
Add accessors_and_metadata covering value()/name and zero_previous_
close_contributes_zero — feeding a 0.0 baseline + non-zero candle
proves the divide-by-zero guard yields a 0 contribution rather than NaN.

* test(csv): cover from_csv_reader + kill rejects_header dead panic arm

Codecov flagged 5 lines in csv.rs (file at 96.98%): from_csv_reader
(201-204) — never called by existing tests which use from_reader /
open — and the cold  arm in
rejects_header_missing_a_column (279). Add from_csv_reader_accepts_a_
prebuilt_reader (demonstrates the API by building a custom-delimited
csv::Reader and passing it in), and refactor the header-missing test
to use a single matches!() assertion so the panic arm is gone.

* test(adl): cover name metadata

Codecov flagged 3 lines (file at 96.84%): Indicator-impl name body (94-96).
2026-05-24 00:48:02 +02:00
kingchenc 512bbf75c4 test: 100% coverage for keltner + linreg + linreg_slope + macd + super_trend (#30)
* test(keltner): cover periods accessor + name metadata

Codecov flagged 6 lines (file at 95.23%): periods (68-70) + name (106-108).

* test(linreg): cover period accessor + name metadata

Codecov flagged 6 lines (file at 96.10%): period (92-94) + name (142-144).

* test(linreg_slope): cover period accessor + name metadata

Codecov flagged 6 lines (file at 95.91%): period (80-82) + name (125-127).

* test(macd): cover periods/value accessors + name metadata

Codecov flagged 6 lines (file at 95.45%): periods (81-83) + name (135-137).

* test(super_trend): cover params accessor + name metadata

Codecov flagged 6 lines (file at 96.36%): params (99-101) + name (176-178).
2026-05-24 00:47:59 +02:00
kingchenc 8f6ffe5a62 test: 100% coverage for chaikin_volatility + chande_kroll_stop + chandelier_exit + choppiness_index + force_index (#29)
* test(chaikin_volatility): cover periods accessor + name metadata

Codecov flagged 6 lines (file at 94.91%): periods (69-71) + name (99-101).

* test(chande_kroll_stop): cover params accessor + name metadata

Codecov flagged 6 lines (file at 95.45%): params (97-99) + name (164-166).

* test(chandelier_exit): cover params accessor + name metadata

Codecov flagged 6 lines (file at 95.12%): params (83-85) + name (128-130).

* test(choppiness_index): cover period accessor + name metadata

Codecov flagged 6 lines (file at 95.04%): period (73-75) + name (125-127).

* test(force_index): cover period accessor + name metadata

Codecov flagged 6 lines (file at 93.33%): period (58-60) + name (93-95).
2026-05-24 00:47:56 +02:00
kingchenc d9a1950007 test: 100% coverage for rsi + accelerator_oscillator + aroon + atr_trailing_stop + chaikin_oscillator (#28)
* test(rsi): cover period/value accessors, name, naive flat-series branch

Codecov flagged 7 lines in indicators/rsi.rs (file at 96.42%): const
accessors period (60-62), value (65-67), Indicator-impl name (145-147),
and line 167 in the test-helper rsi_naive's ag==0 fallback. The
proptest reference never lands on a fully flat series so the helper's
50.0 branch was dead.

Add accessors_and_metadata covering period/value/name and
naive_helper_flat_series_yields_50 driving rsi_naive on [42.0; 20] —
both avg_gain and avg_loss converge to 0, hitting the 50.0 branch.
rsi.rs now at 196/196.

* test(accelerator_oscillator): cover params accessor + name metadata

Codecov flagged 6 lines in indicators/accelerator_oscillator.rs (file
at 93.68%): const accessor params (69-71) and Indicator-impl name
(99-101). ac.rs now at 95/95.

* test(aroon): cover period accessor + name metadata

Codecov flagged 6 lines in indicators/aroon.rs (file at 94.28%): const
accessor period (56-58) and Indicator-impl name (104-106). aroon.rs
now at 105/105.

* test(atr_trailing_stop): cover params accessor + name metadata

Codecov flagged 6 lines in indicators/atr_trailing_stop.rs (file at
95.91%): const accessor params (77-79) and Indicator-impl name
(130-132). atr_trailing_stop.rs now at 147/147.

* test(chaikin_oscillator): cover periods accessor + name metadata

Codecov flagged 6 lines in indicators/chaikin_oscillator.rs (file at
95.27%): const accessor periods (76-78) and Indicator-impl name
(109-111). chaikin_oscillator.rs now at 127/127.
2026-05-24 00:47:53 +02:00
kingchenc c7f1e14629 test: 100% coverage for mfi + psar + cmf + hma + obv (#27)
* test(mfi): cover period accessor, name, flat-TP fallback

Codecov flagged 8 lines in indicators/mfi.rs (file at 93.10%): const
accessor period (58-60), (0.0, 0.0) arm when tp==prev (85), the
Some(50.0) flat-flow fallback (105), and Indicator-impl name body
(132-134). Add accessors_and_metadata and flat_typical_prices_default_to_50.
mfi.rs now at 116/116.

* test(psar): cover warmup/name, drop cold format-arg + panic-only asserts

Codecov flagged 8 lines in indicators/psar.rs (file at 95.69%):
warmup_period (206-208), name (220-222), the cold format-arg line
254 in pure_uptrend_sar_below_lows, and the in-loop assert! at line
275 in pure_downtrend_sar_above_highs (its panic body is dead).

Add accessors_and_metadata for warmup/name. Refactor both trend
tests to collect violations into a Vec and assert once outside the
loop — the single assert can now legitimately reach its panic body
in a regression, while removing the dead cold-path lines from the
happy-path coverage.

* test(cmf): cover period accessor, name, zero-range branch

Codecov flagged 7 lines in indicators/cmf.rs (file at 95.03%): const
accessor period (71-73), the range==0.0 zero-MFV branch (84), and
Indicator-impl name body (124-126). Add accessors_and_metadata and
zero_range_candle_contributes_zero_mfv (flat H=L=close candles).
cmf.rs now at 141/141.

* test(hma): cover period accessor + name, kill dead naive panic arm

Codecov flagged 7 lines in indicators/hma.rs (file at 92.22%): const
accessor period (51-53), Indicator-impl name body (87-89), and the
unreachable  arm at line 167 in matches_independent_wmas.
Refactor that test to assert the warmup-shape invariant via
assert_eq!(got.is_some(), want.is_some()) + if let, removing the
dead panic arm. Add accessors_and_metadata covering period/name.
hma.rs now at 90/90.

* test(obv): cover value() Some branch + warmup/name metadata

Codecov flagged 7 lines in indicators/obv.rs (file at 92.92%): the
Some(self.total) branch of value() (47) — only the None branch was
hit by reset_clears_state — plus Indicator-impl warmup_period
(79-81), name (87-89). Add accessors_and_metadata covering all four.
obv.rs now at 99/99.
2026-05-24 00:47:50 +02:00
kingchenc a5d4926718 test: 100% coverage for tsi + ultimate_oscillator + vortex + vwma + zlema (#26)
* test(tsi): cover periods/value accessors + name metadata

Codecov flagged 9 lines in indicators/tsi.rs (file at 92.30%): const
accessors periods (70-72), value (75-77) and Indicator-impl name
(137-139). tsi.rs now at 117/117.

* test(ultimate_oscillator): cover periods/value accessors + name metadata

Codecov flagged 9 lines in indicators/ultimate_oscillator.rs
(file at 94.76%): const accessors periods (96-98), value (101-103)
and Indicator-impl name (193-195). uo.rs now at 172/172.

* test(vortex): cover period/value accessors + name metadata

Codecov flagged 9 lines in indicators/vortex.rs (file at 93.18%): const
accessors period (84-86), value (89-91) and Indicator-impl name
(157-159). vortex.rs now at 132/132.

* test(vwma): cover period/value accessors + name metadata

Codecov flagged 9 lines in indicators/vwma.rs (file at 92.56%): const
accessors period (72-74), value (77-79) and Indicator-impl name
(129-131). vwma.rs now at 121/121.

* test(zlema): cover period/value accessors + name metadata

Codecov flagged 9 lines in indicators/zlema.rs (file at 90.62%): const
accessors period (62-64), value (72-74) and Indicator-impl name
(111-113). zlema.rs now at 96/96.
2026-05-24 00:47:46 +02:00
kingchenc 645b002958 test: 100% coverage for mom + sma + stoch_rsi + tema + trima (#25)
* test(mom): cover period/value accessors + name metadata

Codecov flagged 9 lines in indicators/mom.rs (file at 89.53%): const
accessors period (56-58), value (61-63) and Indicator-impl name
(101-103). mom.rs now at 86/86.

* test(sma): cover period accessor + warmup/name metadata

Codecov flagged 9 lines in indicators/sma.rs (file at 93.12%): const
accessor period (70-72), Indicator-impl warmup_period (115-117),
name (123-125). sma.rs now at 131/131.

* test(stoch_rsi): cover periods/value accessors + name metadata

Codecov flagged 9 lines in indicators/stoch_rsi.rs (file at 92.37%):
const accessors periods (69-71), value (74-76) and Indicator-impl
name (131-133). stoch_rsi.rs now at 118/118.

* test(tema): cover period accessor + warmup/name metadata

Codecov flagged 9 lines in indicators/tema.rs (file at 83.63%): const
accessor period (45-47), Indicator-impl warmup_period (67-69), name
(75-77). tema.rs now at 55/55.

* test(trima): cover period/value accessors + name metadata

Codecov flagged 9 lines in indicators/trima.rs (file at 89.53%): const
accessors period (59-61), value (64-66) and Indicator-impl name
(99-101). trima.rs now at 86/86.
2026-05-24 00:47:43 +02:00
kingchenc 5a6689cf1a test: 100% coverage for cmo + dema + donchian + dpo + ease_of_movement (#23)
* test(cmo): cover period/value accessors + name metadata

Codecov flagged 9 lines in crates/wickra-core/src/indicators/cmo.rs
(file at 92.30%): const accessors period (66-68), value (71-73) and
Indicator-impl name (134-136). cmo.rs now at 117/117.

* test(dema): cover period accessor + warmup/name metadata

Codecov flagged 9 lines in crates/wickra-core/src/indicators/dema.rs
(file at 85.00%): const accessor period (43-45), Indicator-impl
warmup_period (63,65,66) and name (72-74). dema.rs now at 60/60.

* test(donchian): cover period accessor + warmup/name metadata

Codecov flagged 9 lines in crates/wickra-core/src/indicators/donchian.rs
(file at 90.21%): const accessor period (57-59), Indicator-impl
warmup_period (95-97), name (103-105). donchian.rs now at 92/92.

* test(dpo): cover period/value accessors + name metadata

Codecov flagged 9 lines in crates/wickra-core/src/indicators/dpo.rs
(file at 91.74%): const accessors period (73-75), value (83-85) and
Indicator-impl name (132-134). dpo.rs now at 109/109.

* test(ease_of_movement): cover period/divisor accessors + name metadata

Codecov flagged 9 lines in crates/wickra-core/src/indicators/ease_of_movement.rs
(file at 94.15%): const accessors period (83-85), divisor (88-90) and
Indicator-impl name (141-143). ease_of_movement.rs now at 154/154.
2026-05-24 00:47:01 +02:00
kingchenc 8582338b5d test: 100% coverage for wma + aroon_oscillator + atr + awesome_oscillator + cci (#22)
* test(wma): cover period/warmup/name + kill dead naive panic arm

Codecov flagged 10 lines in crates/wickra-core/src/indicators/wma.rs
(file at 92.48%): const accessor period (56-58), Indicator-impl
warmup_period (111-113), name (119-121), and line 186 — the
`_ => panic!("warmup mismatch")` arm in matches_naive_over_random_
inputs, an invariant guard that never fires when both streams share
a warmup period.

Add accessors_and_metadata covering the three metadata methods.
Refactor matches_naive_over_random_inputs to assert the warmup-shape
invariant via assert_eq!(g.is_some(), w.is_some()) + if let,
removing the dead panic arm.

wma.rs is now at 133/133 lines, no behavioural change.

* test(aroon_oscillator): cover period/value accessors + name metadata

Codecov flagged 9 lines in crates/wickra-core/src/indicators/aroon_
oscillator.rs (file at 90.42%): const accessors period (57-59),
value (62-64) and Indicator-impl name (90-92). warmup_period is
already covered by warmup_period_matches_aroon.

Add accessors_and_metadata asserting period == 7, name ==
"AroonOscillator", and value() across the None (pre-warmup) and
Some (post-warmup) branches.

aroon_oscillator.rs is now at 94/94 lines, no behavioural change.

* test(atr): cover period/value accessors + name metadata

Codecov flagged 9 lines in crates/wickra-core/src/indicators/atr.rs
(file at 93.70%): const accessors period (54-57), value (59-62) and
Indicator-impl name body (103-105). warmup_period is exercised
indirectly via downstream indicators; the metadata getters were
never queried directly.

Add accessors_and_metadata asserting period == 14, name == "ATR",
and value() across the None (pre-warmup) and Some (post-warmup)
branches.

atr.rs is now at 143/143 lines, no behavioural change.

* test(awesome_oscillator): cover periods accessor + warmup/name metadata

Codecov flagged 9 lines in crates/wickra-core/src/indicators/awesome_
oscillator.rs (file at 88.15%): const accessor periods (59-61),
Indicator-impl warmup_period (83-85), name (91-93). The classic()
constructor is covered indirectly through the existing tests; only
the metadata methods were dead.

Add accessors_and_metadata asserting periods == (5, 34),
warmup_period == 34 (= slow_period), name == "AwesomeOscillator".

awesome_oscillator.rs is now at 76/76 lines, no behavioural change.

* test(cci): cover period accessor + warmup/name metadata

Codecov flagged 9 lines in crates/wickra-core/src/indicators/cci.rs
(file at 89.65%): const accessor period (68-70), Indicator-impl
warmup_period (102-104), name (110-112). Existing tests never
inspected the metadata surface.

Add accessors_and_metadata asserting period == 20, warmup_period ==
20, name == "CCI".

cci.rs is now at 87/87 lines, no behavioural change.
2026-05-24 00:46:59 +02:00
kingchenc a9670b0ad1 test: 100% coverage for pmo + ppo + roc + ulcer_index + williams_r (#21)
* test(pmo): cover periods/value accessors, name, zero-prev fallback

Codecov flagged 10 lines in crates/wickra-core/src/indicators/pmo.rs
(file at 90.56%):

  - const accessors periods (76-78), value (81-83) — never queried
  - line 103 (`0.0` in the prev == 0.0 ROC fallback) — every existing
    test used prices > 0, so the divide-by-zero guard never fired
  - Indicator-impl name body (130-132) — never queried

Add accessors_and_metadata covering periods/value/name. Add
zero_previous_price_treats_roc_as_flat seeding prev_price = 0 then
pushing a non-zero price — the ROC must take the flat-momentum
fallback (0.0) and the doubly-smoothed PMO emits exactly 0.0
rather than NaN.

pmo.rs is now at 106/106 lines, no behavioural change.

* test(ppo): cover periods/value accessors, name, zero-slow-EMA fallback

Codecov flagged 10 lines in crates/wickra-core/src/indicators/ppo.rs
(file at 90.29%):

  - const accessors periods (71-73), value (76-78) — never queried
  - line 96 (`0.0` in the s == 0.0 PPO fallback) — every existing test
    used prices ≈ 100, so the slow EMA was never 0 and the
    divide-by-zero guard never fired
  - Indicator-impl name body (122-124) — never queried

Add accessors_and_metadata covering periods/value/name. Add
zero_slow_ema_yields_zero_ppo feeding a stream of zeros — both EMAs
converge to 0.0 and the indicator must emit exactly 0.0 (flat
momentum) rather than NaN.

ppo.rs is now at 103/103 lines, no behavioural change.

* test(roc): cover period accessor, warmup/name, zero-prev fallback

Codecov flagged 10 lines in crates/wickra-core/src/indicators/roc.rs
(file at 87.80%):

  - const accessor period (47-49) — never queried
  - line 70 (`0.0` in the prev == 0.0 ROC fallback) — every test used
    prices ≥ 1.0, so the divide-by-zero guard never fired
  - Indicator-impl warmup_period (83-85), name (91-93) — never queried

Add accessors_and_metadata covering period == 5, warmup_period == 6
(= period + 1), name == "ROC". Add zero_previous_price_yields_zero_roc
feeding a leading zero followed by `period` more values so the front
of the window is exactly 0.0; the next emission must be the
flat-momentum fallback 0.0 (not NaN).

roc.rs is now at 82/82 lines, no behavioural change.

* test(ulcer_index): cover period/value accessors, name, zero-max fallback

Codecov flagged 10 lines in crates/wickra-core/src/indicators/ulcer_index.rs
(file at 93.86%):

  - const accessors period (77-80), value (82-85) — never queried
  - line 123 (`0.0` in the max_price == 0.0 drawdown fallback) — every
    test used prices > 0, so the trailing-max divisor was always positive
  - Indicator-impl name body (162-164) — never queried

Add accessors_and_metadata covering period/value/name. Add
zero_max_price_yields_zero_drawdown feeding a stream of zeros — the
trailing max is exactly 0.0 and the drawdown computation would
otherwise hit 0/0 NaN; the indicator must emit exactly 0.0
(drawdown is 0% by convention).

ulcer_index.rs is now at 163/163 lines, no behavioural change.

* test(williams_r): cover period accessor, warmup/name, zero-range branch

Codecov flagged 10 lines in crates/wickra-core/src/indicators/williams_r.rs
(file at 89.79%):

  - const accessor period (49-51) — never queried
  - line 78 (`Some(-50.0)` in the range == 0.0 fallback) — every test
    used H != L candles, so the lookback range was always positive
  - Indicator-impl warmup_period (87-89), name (95-97) — never queried

Add accessors_and_metadata covering period == 14, warmup_period == 14,
name == "WilliamsR". Add zero_range_yields_minus_fifty feeding flat
candles (H == L == close) — the lookback hi/lo coincide and the
divide-by-zero guard fires, returning the neutral mid-range value
-50.0.

williams_r.rs is now at 98/98 lines, no behavioural change.
2026-05-24 00:46:56 +02:00
kingchenc b86cf68eb8 test: 100% coverage for t3 + adx + natr + trix + coppock (#20)
* test(t3): cover period/volume_factor/value accessors + name metadata

Codecov flagged 12 lines in crates/wickra-core/src/indicators/t3.rs
(file at 91.48%): const accessors period (95-97), volume_factor
(100-102), value (105-107) and Indicator-impl name (148-150). The
warmup_period method is already covered by first_emission_at_warmup_
period; the other four metadata methods were never queried.

Add accessors_and_metadata asserting period == 5, volume_factor == 0.7,
name == "T3", and value() across both the None (pre-warmup) and Some
(post-warmup) branches.

t3.rs is now at 141/141 lines, no behavioural change.

* test(adx): cover period accessor, warmup/name metadata, zero-TR branch

Codecov flagged 11 lines in crates/wickra-core/src/indicators/adx.rs
(file at 94.17%): the const accessor period (89-91), the tr_v == 0.0
defensive branches inside update (142, 147), and the Indicator-impl
warmup_period (199-201) and name (207-209) bodies.

Add accessors_and_metadata asserting period == 14, warmup_period == 28,
name == "ADX". Add zero_true_range_yields_zero_di_and_zero_adx feeding
flat all-zero candles (H == L == close == 0) — every TR is 0, so the
smoothed tr_smooth stays at 0 and update must take the zero-denominator
fallback for both plus_di and minus_di, then the dx_den == 0 path for
ADX. The indicator must emit 0/0/0 rather than NaN.

adx.rs is now at 189/189 lines, no behavioural change.

* test(natr): cover accessors, zero-close branch, kill dead panic arm

Codecov flagged 11 lines in crates/wickra-core/src/indicators/natr.rs
(file at 87.64%):

  - const accessors period (59-61), value (64-66) — never queried
  - line 77 (`0.0` in the candle.close == 0.0 fallback) — every test
    used candles with close ≈ 100, so the divide-by-zero guard never
    fired
  - Indicator-impl name body (98-100) — never queried
  - line 142 (`_ => panic!("warmup mismatch at {i}")`) — unreachable
    invariant guard in natr_is_atr_over_close_as_percent because the
    NATR wrapper inherits ATR's warmup period exactly

Add accessors_and_metadata covering period/value/name. Add
zero_close_yields_zero_natr feeding an all-zero candle series (Candle
validator accepts open == high == low == close == 0 with positive
volume) — ATR is 0 each bar, so the indicator must emit exactly 0.0
rather than 100 * 0 / 0 = NaN. Refactor natr_is_atr_over_close_as_
percent to assert the warmup-shape invariant via assert_eq! on
is_some(), removing the dead panic arm.

natr.rs is now at 89/89 lines, no behavioural change.

* test(trix): cover period accessor, warmup/name metadata, zero-prev branch

Codecov flagged 11 lines in crates/wickra-core/src/indicators/trix.rs
(file at 84.05%):

  - const accessor period (47-49) — never queried
  - the Some(_) match arm (67-68) — the degenerate path where the
    previous triple-EMA value is exactly 0.0 (would otherwise divide
    by zero on the percent-rate formula). All other tests used
    inputs ≈ 100, so prev_tr was never 0.0
  - Indicator-impl warmup_period (84, 86-87) and name (93-95) — never
    queried

Add accessors_and_metadata asserting period == 5, warmup_period == 14
(= 3*5 - 1), name == "TRIX". Add zero_input_series_yields_zero_trix
feeding [0.0; 20] — every EMA stage collapses to 0.0, so once warmed
up prev_tr is Some(0.0) and every subsequent emission must take the
fallback arm returning 0.0.

trix.rs is now at 69/69 lines, no behavioural change.

* test(coppock): cover periods/value accessors + name + simplify assert

Codecov flagged 10 lines in crates/wickra-core/src/indicators/coppock.rs
(file at 91.07%):

  - const accessors periods (68-70), value (73-75) — never queried
  - Indicator-impl name body (128-130) — never queried
  - line 180 (`warmup - 1,` format-arg) inside the multi-line assert!
    in warmup_period_matches_first_some_for_every_parameter_set —
    only evaluated on assertion failure, which never happens, so
    Codecov flagged the cold path as uncovered

Add accessors_and_metadata covering periods/value/name. Simplify the
multi-line assert's format args to a static message — the {warmup}
binding already appears once in the cold path so dropping the literal
"warmup index" arg loses nothing diagnostic but kills the dead
expression-arg line.

coppock.rs is now at 112/112 lines, no behavioural change.
2026-05-24 00:46:20 +02:00
kingchenc 24919153dd style(bollinger): wrap naive helper assert to satisfy rustfmt
Commit aa2846c collapsed the multi-line assert in the test-only naive
helper to a single line to drop the cold expression-arg lines that
Codecov saw as uncovered. The single-line form exceeded the 100-col
limit, so cargo fmt --check failed on every supported toolchain in CI
(ubuntu-latest, macos-latest, windows-latest).

Let rustfmt wrap it back to the three-line form. The arms are still
just a literal expression and a static-string message — no expression
format args — so the cold-path lines that Codecov originally flagged
on line 179 stay covered. No behaviour change.
2026-05-23 23:44:16 +02:00
kingchenc 73507b1cb6 test(std_dev): cover period/value accessors + warmup/name metadata
Codecov flagged 12 lines in crates/wickra-core/src/indicators/std_dev.rs
(file at 89.09%): const accessors period (64-66), value (68-71) and
Indicator-impl bodies warmup_period (110-112), name (118-120). None
of the existing tests inspected the metadata surface.

Add accessors_and_metadata asserting period == 14, warmup_period == 14,
name == "StdDev", and value() across both the None (pre-warmup) and
Some (post-warmup) branches.

std_dev.rs is now at 110/110 lines, no behavioural change.
2026-05-23 23:41:24 +02:00
kingchenc 6dfa4ee134 test(smma): cover period/value accessors + warmup/name metadata
Codecov flagged 12 lines in crates/wickra-core/src/indicators/smma.rs
(file at 86.81%): the const accessors period (57-59), value (62-64)
and the Indicator-impl bodies warmup_period (95-97), name (103-105).
None of the existing tests inspected the metadata surface — they only
fed numeric updates and asserted on SMMA values.

Add accessors_and_metadata exercising period == 7, warmup_period == 7,
name == "SMMA", and value() across both the None (pre-warmup) and
Some (post-warmup) branches.

smma.rs is now at 91/91 lines, no behavioural change.
2026-05-23 23:40:34 +02:00
kingchenc 6969541bb1 test(stochastic): cover classic/periods/metadata + naive_k flat branch
Codecov flagged 13 uncovered lines in
crates/wickra-core/src/indicators/stochastic.rs (file at 93.43%):

  - classic() convenience constructor (76-78) — every test passed
    explicit (k_period, d_period) to new
  - periods() const accessor (81-83) — never queried
  - warmup_period (170-172), name (178-180) Indicator-impl bodies —
    never queried
  - line 208 (`50.0` literal) inside the test-only naive_k helper's
    flat-range branch — k_matches_naive feeds an oscillating price
    series, so the helper's range == 0 path was dead

Add classic_periods_and_metadata test asserting Stochastic::classic()
has periods (14, 3), warmup_period 16 (= 14 + 3 - 1) and name
"Stochastic". Extend flat_range_yields_k_50 to also call naive_k on
the flat candle series and verify the helper returns Some(50.0) for
every index ≥ k_period - 1 — exercises line 208 without diluting the
production-code assertion.

stochastic.rs is now at 198/198 lines, no behavioural change.
2026-05-23 23:39:32 +02:00
kingchenc ba4e126799 test(aggregator): cover convenience tf-ctors + getter, kill dead gap-fill arms
Codecov flagged 15 uncovered lines in crates/wickra-data/src/aggregator.rs
(file at 95.11%):

  - Timeframe::millis / Timeframe::seconds / Timeframe::one_minute_ms
    convenience constructors (40-52) — every existing test built
    Timeframes via new / minutes / hours / days, never via these three
  - the cold `?` Err arm on `Candle::new(...)?` for the flat gap-fill
    candle (line 334) — `prev.close` is already finite (came from a
    closed bar), volume is exactly 0.0, OHLC are trivially equal, so
    Candle::new's error path is unreachable here
  - the cold `ok_or_else` overflow closure on `t.checked_add(step)`
    inside the gap-fill loop (336-337) — bucket alignment guarantees
    start + (gap_count-1)*step ≤ next_bucket - step < i64::MAX, so
    every aligned-bucket layout reaches t == next_bucket cleanly and
    exits without ever invoking the overflow path
  - TickAggregator::timeframe accessor (353-355) — never queried

Add two new tests:

  - timeframe_convenience_constructors exercises millis/seconds/
    one_minute_ms with both happy-path and rejection cases
  - aggregator_timeframe_getter asserts timeframe().bucket() round-trips

Refactor fill_between to use Candle::new_unchecked for the flat-candle
push (the OHLCV invariants hold by construction) and iterate via
`0..gap_count` with `saturating_add(step)` instead of `while t <
next_bucket` with `checked_add(...).ok_or_else(...)?`. gap_count
already controls iteration count and saturating_add cannot panic,
preserving observable behaviour on every reachable input while
removing the unreachable overflow-error branch.

aggregator.rs is now at 307/307 lines, no observable behaviour change
on aligned-bucket inputs (which is every input fill_between can be
called with given the call site's preconditions).
2026-05-23 23:37:52 +02:00
kingchenc aa2846c250 test(bollinger): collapse naive helper assert to single-line message
Codecov re-check after 1255892 showed line 179 of
crates/wickra-core/src/indicators/bollinger.rs still uncovered: the
`prices.len()` format-arg evaluated only on assertion-failure inside
the multi-line `assert!(prices.len() >= period, "…got {}, period {}",
prices.len(), period)` — the cold panic path.

Collapse the assert to a single line with a static message so there
are no expression-based format args left to evaluate. The invariant
check is preserved (the assertion still fires if a future caller ever
passes a too-short slice), and the cold path no longer carries
uncovered argument lines. bollinger.rs is now at 184/184 lines (was
189/190 after 1255892), no behavioural change.
2026-05-23 23:31:50 +02:00
kingchenc 1255892b1e test(bollinger): cover classic()+accessors+metadata, drop dead naive arm
Codecov flagged 16 uncovered lines in
crates/wickra-core/src/indicators/bollinger.rs (file at 91.30%):

  - classic() convenience constructor (91-93) — every test passed
    explicit parameters to BollingerBands::new, so the classic-defaults
    path was dead
  - const accessors period (96-98), multiplier (101-103) — never queried
  - Indicator-impl bodies warmup_period (156-158), name (164-166) —
    never queried
  - `return None;` (line 177) inside the test-only `naive` helper's
    `if prices.len() < period` early-return — every caller passes a
    slice of length >= period (matches_naive_definition uses
    `&prices[..=i]` for `i in 19..` with period=20; long_stream_drift_
    stays_bounded fills the window before measuring), so the arm is dead

Add classic_and_accessors_and_metadata to cover the constructor and
the four getter bodies, and refactor naive to return BollingerOutput
directly with an `assert!(prices.len() >= period)` precondition. The
two existing callers were already using .unwrap()/.expect() on the
Option result and simplify to direct calls.

bollinger.rs is now at 184/184 lines, no behavioural change.
2026-05-23 23:30:10 +02:00
kingchenc 36dc951f1b test(ohlcv): cover Candle::new_unchecked skip-validation constructor
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.
2026-05-23 23:28:26 +02:00
kingchenc 0abc5f71c1 test(vwap): cover Vwap value()/zero-volume/metadata + RollingVwap getters
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.
2026-05-23 23:27:15 +02:00
kingchenc 404fd29c31 test(percent_b): cover accessors, kill dead arm, fix flaky middle test
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.
2026-05-23 23:25:35 +02:00
kingchenc 050e5b74b8 test(bollinger_bandwidth): cover accessors, zero-middle branch, kill dead arm
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.
2026-05-23 23:22:54 +02:00
kingchenc 62fe7a81aa test(traits): cover Chain accessors + Identity/Doubler helper surface
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.
2026-05-23 23:19:24 +02:00
kingchenc 3fcf2094f1 ci(wasm): install wasm-pack via taiki-e prebuilt instead of jetli's stale 0.10
jetli/wasm-pack-action@v0.4.0 with no version: input installs whatever
wasm-pack the action's bundled installer fetches — currently a ~0.10.x
release whose 'build' subcommand does not yet accept --features. Our
build invocation 'wasm-pack build … --features panic-hook' now fails
with

    error: Found argument '--features' which wasn't expected, or isn't
    valid in this context
    USAGE: wasm-pack build --release --target <target>

even though that exact command worked on past runs where the action
happened to install a newer wasm-pack. (The bundler-target release.yml
job passed for v0.2.1 only because it shared the same cached install
on that runner.)

wasm-pack's --features top-level flag has been stable since 0.12.0, so
the fix is to install a fresh wasm-pack each run. Switch both the ci.yml
'WASM build' step and the release.yml 'wasm-publish' job to the same
taiki-e/install-action prebuilt-binary installer we already use for
cargo-llvm-cov and cargo-fuzz. taiki-e tracks the latest wasm-pack
release and the install is a single binary download — no compile, no
shell installer.

The wasm-pack invocations themselves are unchanged.
2026-05-23 23:00:13 +02:00
kingchenc a2ccd202aa test(resample): cover RolledBar::absorb low-update branch
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.
2026-05-23 22:56:40 +02:00
kingchenc 4aec5d544c docs(wiki): migrate documentation out of repo into GitHub Wiki
The 84 markdown files under docs/wiki/ are now published to the
project's GitHub Wiki at https://github.com/kingchenc/wickra/wiki —
a separate git repository (https://github.com/kingchenc/wickra.wiki.git)
that GitHub hosts natively with its own UI, search and history. The
flat layout that the GitHub Wiki requires has been generated, all
internal cross-links rewritten, and a _Sidebar.md groups the 71
indicators by their canonical 8 families.

Effects:
- docs/wiki/ is removed from the main repo (-84 files). docs/README.md
  now just points readers at the Wiki.
- PR template + CONTRIBUTING text updated to point at the Wiki instead
  of the in-repo path. The Wiki repo is separately cloneable and
  editable via the GitHub web UI.
- examples/wasm/README.md cross-link fixed to use the Wiki URL.
- The (still in-repo) CHANGELOG keeps its historical references to
  docs/wiki/ paths — those describe what the tree looked like at past
  releases and stay accurate as history.
- README.md, license, all source unaffected.

The Wiki itself ships with _Sidebar.md / _Footer.md generated from the
8-families taxonomy and 503/503 cross-links resolved.
2026-05-23 22:48:48 +02:00
kingchenc e6375746d3 docs: unify README across crates.io / PyPI / npm / GitHub
Three separate README files (root, bindings/node, bindings/python) had
been drifting independently — each registry showed a different project
page, which is exactly the consistency debt I want to avoid.

Single source of truth: /README.md. The three binding READMEs are
overwritten with the root README content as a baseline, and release.yml
gets a one-line cp step right before every publishing call so future
edits to /README.md propagate automatically:

- python-wheels job: cp README.md bindings/python/README.md before
  PyO3/maturin-action runs the wheel build
- python-sdist job: same, before the sdist build
- node-publish job: cp ../../README.md README.md (working-directory
  bindings/node) before the main 'npm publish wickra'
- wasm-publish job: cp README.md bindings/wasm/README.md before
  wasm-pack build (which copies the crate README into pkg/ on its own)

Cargo crates (wickra, wickra-core, wickra-data) already inherit
readme.workspace = true pointing at /README.md, so crates.io was already
correct — no change needed there.

The per-platform npm subpackages (bindings/node/npm/<target>/) keep
their tiny package.json with no README; they are install-time
optionalDependencies that the loader reads through, never user-facing
on the registry.

Effect: same README on github.com/kingchenc/wickra, crates.io/crates/wickra,
pypi.org/project/wickra, and npmjs.com/package/wickra. Will be live on
the registries with the next tag-push.
2026-05-23 22:45:16 +02:00
kingchenc a876b145b0 chore: trigger CI to upload first Codecov coverage report
CODECOV_TOKEN was just added as a repository secret; the existing
Coverage job in .github/workflows/ci.yml will pick it up on the next
run. This empty commit fires that run.
2026-05-23 22:38:49 +02:00
189 changed files with 3152 additions and 15027 deletions
+2 -2
View File
@@ -1,7 +1,7 @@
---
name: Bug report
about: Report incorrect behaviour in Wickra
title: "[bug] "
title: "[Bug] "
labels: bug
assignees: ""
---
@@ -32,7 +32,7 @@ assignees: ""
- Wickra version:
- Language / binding: <!-- Rust crate / Python / Node / WASM -->
- OS and architecture:
- Rust / Python / Node version (if relevant):
- Rust / Python / Node version (If relevant):
## Additional context
@@ -0,0 +1,56 @@
---
name: Bug report (Detailed)
about: Long-form bug report with environment matrix, minimal reproducer, and expected-vs-actual sections.
title: "[Bug] <short description>"
labels: ["bug", "triage"]
assignees: []
---
## Summary
<!-- One or two sentences. What did you expect, what happened instead? -->
## Affected binding
- [ ] Rust crate (`wickra`)
- [ ] Python (`pip install wickra`)
- [ ] Node.js (`npm install wickra`)
- [ ] WebAssembly
- [ ] Docs / examples only
## Environment
| Field | Value |
| -------------------- | -------------------------------------- |
| Wickra version | `e.g. 0.4.2` |
| Binding version | `e.g. python 0.4.2 / node 0.4.2` |
| OS / arch | `e.g. Windows 11 x86_64, Linux glibc` |
| Rust toolchain | `rustc --version` (If building from source) |
| Python / Node version | `python --version` / `node --version` |
## Minimal reproducer
<!--
Paste the smallest possible code snippet that triggers the bug.
If the input data matters, attach a CSV/JSON or paste a few rows inline.
-->
```python
# or rust / js
import wickra as ta
...
```
## Actual output
```
<paste stack trace, panic, wrong values, etc.>
```
## Expected output
<!-- What should the indicator / API have returned? Reference a paper, TA-Lib, or another implementation if possible. -->
## Additional context
<!-- Logs, screenshots, links to related issues, anything else useful. -->
+33
View File
@@ -0,0 +1,33 @@
---
name: Documentation issue
about: Something in the README, rustdoc, examples, or guides is wrong, missing, or confusing.
title: "[Docs] <short description>"
labels: ["documentation", "good first issue"]
assignees: []
---
## Where
<!-- Link or path. e.g. README.md#streaming-vs-batch, docs/guide/ema.md, rustdoc for `wickra::Ema::update`. -->
## What's wrong / missing
<!--
- [ ] Incorrect information
- [ ] Outdated for current API
- [ ] Missing example
- [ ] Unclear wording
- [ ] Broken link / broken code block
- [ ] Other
-->
## Suggested change
<!--
Paste the corrected wording, a clearer example, or a sketch of the
section you'd like to see. PRs welcome.
-->
## Additional context
<!-- Quote of the confusing passage, screenshot, etc. -->
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: Feature request
about: Suggest a new indicator or capability for Wickra
title: "[feature] "
title: "[Feature] "
labels: enhancement
assignees: ""
---
@@ -0,0 +1,55 @@
---
name: Feature request (Detailed)
about: Long-form proposal with API sketch, scope checkboxes, prior-art links, and contribution intent.
title: "[Feat] <short description>"
labels: ["enhancement", "triage"]
assignees: []
---
## Problem / motivation
<!--
What are you trying to do that Wickra doesn't support today?
Describe the user-facing pain point, not the implementation.
-->
## Proposed solution
<!--
Sketch the API or behavior you'd like. A short code snippet of how
you'd want to call it is worth a thousand words.
-->
```python
import wickra as ta
# proposed API
ind = ta.SuperTrend(period=10, multiplier=3.0)
ind.update(close, high, low)
```
## Scope
- [ ] New indicator
- [ ] New method on an existing indicator
- [ ] New binding / platform target
- [ ] Performance improvement
- [ ] Ergonomics / API cleanup
- [ ] Other (Explain below)
## Reference / prior art
<!--
Link the paper, book chapter, TA-Lib function, TradingView Pine source,
or other implementations you'd like Wickra to match.
-->
## Alternatives considered
<!-- What workarounds exist today? Why aren't they enough? -->
## Willingness to contribute
- [ ] I'd like to implement this myself with guidance
- [ ] I can help review / test
- [ ] Requesting only — no bandwidth to implement
@@ -0,0 +1,54 @@
---
name: Performance regression
about: Report a measurable slowdown, memory blowup, or throughput drop.
title: "[Perf] <indicator / API> regressed in <version>"
labels: ["performance", "regression", "triage"]
assignees: []
---
## Summary
<!-- Which code path got slower, by how much, and since when? -->
## Affected code path
- Indicator / API: `e.g. EMA.update`
- Binding: `Rust / Python / Node / Wasm`
- Hot loop or one-shot call?
## Versions compared
| Version | Throughput / latency / memory | Notes |
| -------- | ----------------------------- | ----- |
| `0.4.1` | `e.g. 12.3 ns/iter` | baseline (Good) |
| `0.4.2` | `e.g. 38.7 ns/iter` | regressed |
## Benchmark / reproducer
<!--
Paste the criterion / pytest-benchmark / hyperfine command and its output.
For one-off measurements, include the timing snippet inline.
-->
```bash
cargo bench --bench ema -- --save-baseline new
```
```
ema/update time: [38.5 ns 38.7 ns 38.9 ns]
change: [+213.4% +214.8% +216.1%] (p = 0.00 < 0.05)
Performance has regressed.
```
## Hardware / environment
| Field | Value |
| ------------ | -------------------------------------- |
| CPU | `e.g. Ryzen 9 7950X, AVX2 + AVX512` |
| OS / arch | `e.g. Linux 6.8 x86_64` |
| Toolchain | `rustc 1.x.y` |
| Build flags | `RUSTFLAGS=...`, `--release`, profile |
## Suspected cause
<!-- Optional. Link the commit / PR if you've bisected it. -->
+36
View File
@@ -0,0 +1,36 @@
---
name: Question / usage help
about: Ask how to do something with Wickra. For open-ended discussion prefer GitHub Discussions.
title: "[Question] <short description>"
labels: ["question"]
assignees: []
---
> [!NOTE]
> If this is open-ended ("which indicator should I use for X?") please
> use **Discussions** instead — issues are for actionable items.
## What are you trying to do?
<!-- The end goal, not the API call. -->
## What have you tried?
<!--
Code, docs you've read, search terms that didn't help.
Show that you've spent a few minutes before asking.
-->
```python
import wickra as ta
...
```
## What's confusing or blocking you?
<!-- Specific question. "Why does X return NaN for the first N points?" beats "doesn't work". -->
## Environment (Only if relevant)
- Wickra version: `e.g. 0.4.2`
- Binding: `Rust / Python / Node / Wasm`
+4 -3
View File
@@ -23,9 +23,10 @@
- [ ] `cargo test --workspace` passes.
- [ ] New behaviour has tests; bug fixes have a regression test.
- [ ] Public API changes are mirrored in the Python / Node / WASM bindings
and their type stubs (if applicable).
- [ ] Documentation under `docs/wiki/` and the `README.md` is updated
(if applicable).
and their type stubs (If applicable).
- [ ] The relevant page on the [project Wiki](https://github.com/kingchenc/wickra/wiki)
and the `README.md` are updated (If applicable). Wiki edits go to a
separate repository: `https://github.com/kingchenc/wickra.wiki.git`.
- [ ] An entry was added under `## [Unreleased]` in `CHANGELOG.md`.
## Notes for reviewers
+67
View File
@@ -0,0 +1,67 @@
<!--
Thanks for contributing to Wickra!
Please fill in the sections below. Delete any that don't apply.
-->
## Summary
<!-- 13 sentences: what does this PR change and why? -->
## Type of change
- [ ] Bug fix (Non-breaking change which fixes an issue)
- [ ] New feature (Non-breaking change which adds functionality)
- [ ] Breaking change (Fix or feature that changes existing public API)
- [ ] Performance improvement
- [ ] Refactor (No functional change)
- [ ] Documentation only
- [ ] CI / build / tooling
## Affected surfaces
- [ ] Rust crate (`crates/wickra`)
- [ ] Python binding (`bindings/python`)
- [ ] Node.js binding (`bindings/node`)
- [ ] WebAssembly binding (`bindings/wasm`)
- [ ] Examples / docs
## Linked issues
<!-- "Closes #123", "Refs #456". One per line. -->
Closes #
## How was this tested?
<!--
- Unit tests added / updated under `crates/*/tests/` or `bindings/*/tests/`
- Property / fuzz tests touched? (Under `fuzz/`)
- Manual repro steps, if applicable
- Benchmarks run (Paste before/after if perf-sensitive)
-->
## Numerical correctness (If you touched an indicator)
- [ ] Output matches an existing reference (TA-Lib, paper, prior Wickra release) within documented tolerance
- [ ] Streaming `update()` matches batch / `from_slice` output on the same input
- [ ] Edge cases covered: empty input, single point, NaN, leading warm-up window
## Performance impact (If applicable)
| Benchmark | Before | After | Δ |
| --------- | ------ | ----- | - |
| | | | |
## Checklist
- [ ] `cargo fmt --all` and `cargo clippy --all-targets -- -D warnings` are clean
- [ ] `cargo test --workspace` passes locally
- [ ] Binding tests run (If a binding changed)
- [ ] Public API changes are reflected in `CHANGELOG.md`
- [ ] Public API changes are reflected in rustdoc / README / examples
- [ ] No `todo*.md` or other local-only notes are staged
- [ ] License header / `LICENSE` reference unchanged (PolyForm-NC-1.0.0)
## Notes for reviewers
<!-- Anything reviewers should look at first, known follow-ups, deliberately out-of-scope items. -->
+10 -1
View File
@@ -250,7 +250,16 @@ jobs:
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
- name: Install wasm-pack
uses: jetli/wasm-pack-action@0d096b08b4e5a7de8c28de67e11e945404e9eefa # v0.4.0
# jetli/wasm-pack-action@v0.4.0 with no `version:` input installs an
# old wasm-pack (~0.10.x) whose `build` subcommand does not yet accept
# `--features`, so `wasm-pack build … --features panic-hook` fails
# with "Found argument '--features' which wasn't expected". Use the
# same taiki-e prebuilt-binary installer we already use for
# cargo-llvm-cov and cargo-fuzz; it tracks the latest wasm-pack
# release, which has `--features` as a top-level flag (since 0.12).
uses: taiki-e/install-action@6c1f7cf125e42770ff087ea443901b487cc5471a # v2.79.5
with:
tool: wasm-pack
- name: Build WASM package
run: wasm-pack build bindings/wasm --target web --release --features panic-hook
+26 -2
View File
@@ -106,6 +106,9 @@ jobs:
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.11"
- name: Sync root README into bindings/python so it ships with the wheel
shell: bash
run: cp README.md bindings/python/README.md
- uses: PyO3/maturin-action@e83996d129638aa358a18fbd1dfb82f0b0fb5d3b # v1.51.0
with:
working-directory: bindings/python
@@ -124,6 +127,8 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Sync root README into bindings/python so it ships in the sdist
run: cp README.md bindings/python/README.md
- uses: PyO3/maturin-action@e83996d129638aa358a18fbd1dfb82f0b0fb5d3b # v1.51.0
with:
working-directory: bindings/python
@@ -168,7 +173,7 @@ jobs:
- { host: macos-latest, target: x86_64-apple-darwin }
- { host: macos-latest, target: aarch64-apple-darwin }
- { host: windows-latest, target: x86_64-pc-windows-msvc }
# NOTE: aarch64-pc-windows-msvc is temporarily skipped for 0.2.1.
# NOTE: aarch64-pc-windows-msvc is temporarily skipped for 0.2.5.
# The wickra-win32-arm64-msvc npm subpackage name is blocked by the
# npm spam-detection filter for new accounts (same situation that
# affected wickra-win32-x64-msvc through 0.1.4 until npm Support
@@ -289,6 +294,14 @@ jobs:
done
exit $fail
- name: Sync root README into bindings/node so it ships with the npm tarball
# npm reads README.md from the package directory at publish time. Copy
# the canonical root README in just before the publish so every
# registry shows the same project page.
shell: bash
run: cp ../../README.md README.md
working-directory: bindings/node
- name: Publish main package to npm (idempotent)
working-directory: bindings/node
env:
@@ -357,7 +370,18 @@ jobs:
with:
targets: wasm32-unknown-unknown
- uses: jetli/wasm-pack-action@0d096b08b4e5a7de8c28de67e11e945404e9eefa # v0.4.0
- name: Install wasm-pack (latest, via prebuilt binary)
# See the matching note in ci.yml: jetli's default installs an old
# 0.10.x wasm-pack whose build subcommand rejects --features.
uses: taiki-e/install-action@6c1f7cf125e42770ff087ea443901b487cc5471a # v2.79.5
with:
tool: wasm-pack
- name: Sync root README into bindings/wasm so wasm-pack ships it in pkg/
# wasm-pack copies the crate's README.md into the generated pkg/
# directory it then publishes. Refresh it from the canonical root
# README right before the build.
run: cp README.md bindings/wasm/README.md
- name: Build WASM package (bundler target)
run: wasm-pack build bindings/wasm --target bundler --release --features panic-hook
+25 -1
View File
@@ -7,6 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [0.2.5] - 2026-05-24
### Added
- `BinanceConfig` plus `BinanceKlineStream::connect_with_config(symbols, interval, config)`
in `wickra-data`'s `live::binance` module. `connect()` keeps its previous
signature and now forwards to the new entry-point with the defaults, so the
public API is backwards-compatible. The config lets callers point the
stream at Binance Testnet (`wss://testnet.binance.vision`) or tune the
read timeout, reconnect attempt count, initial / capped backoff and frame
size limits without rewriting the connector.
- README **Disclaimer** section clarifying that Wickra is an indicator
toolkit (not a trading system) and that any production-trading use is at
the caller's own risk. The legal terms in [LICENSE](LICENSE) are
unchanged.
### Changed
- `BinanceKlineStream::next_event` now writes the Pong reply to a server
`Ping` on a best-effort basis. A failed write means the connection is
already dead, so the existing timeout / read-error reconnect arm one
loop iteration later picks it up — the previous explicit reconnect on
Pong-write failure is gone. Observable behaviour is unchanged for every
healthy connection.
## [0.2.1] - 2026-05-23
### Changed
@@ -329,7 +352,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
optional Binance live feed.
- Bindings for Python, Node.js, and WebAssembly.
[Unreleased]: https://github.com/kingchenc/wickra/compare/v0.2.1...HEAD
[Unreleased]: https://github.com/kingchenc/wickra/compare/v0.2.5...HEAD
[0.2.5]: https://github.com/kingchenc/wickra/compare/v0.2.1...v0.2.5
[0.2.1]: https://github.com/kingchenc/wickra/compare/v0.2.0...v0.2.1
[0.2.0]: https://github.com/kingchenc/wickra/compare/v0.1.4...v0.2.0
[0.1.4]: https://github.com/kingchenc/wickra/compare/v0.1.3...v0.1.4
+5 -3
View File
@@ -22,7 +22,7 @@ when proposing features or depending on Wickra elsewhere.
| `bindings/node` | napi-rs bindings (`wickra` on npm). |
| `bindings/wasm` | wasm-bindgen bindings (`wickra-wasm` on npm). |
| `examples/` | Runnable examples. |
| `docs/wiki/` | Documentation sources. |
| `docs/` | Pointer to the project Wiki, which holds all documentation. |
## Building and testing
@@ -75,8 +75,10 @@ wasm-pack test --node bindings/wasm
of `update` calls.
- **Bindings.** A change to a public indicator API must be mirrored across the
Python, Node, and WASM bindings, including their type stubs / `.d.ts`.
- **Docs.** Update the relevant page under `docs/wiki/` and the `README.md`
when behaviour or the public API changes.
- **Docs.** Update the relevant page on the
[project Wiki](https://github.com/kingchenc/wickra/wiki) and the
`README.md` when behaviour or the public API changes. The Wiki lives in
a separate git repository: `https://github.com/kingchenc/wickra.wiki.git`.
- **Changelog.** Add an entry under `## [Unreleased]` in `CHANGELOG.md`.
## Commit and pull-request workflow
Generated
+6 -6
View File
@@ -1867,7 +1867,7 @@ dependencies = [
[[package]]
name = "wickra"
version = "0.2.1"
version = "0.2.5"
dependencies = [
"approx",
"criterion",
@@ -1878,7 +1878,7 @@ dependencies = [
[[package]]
name = "wickra-core"
version = "0.2.1"
version = "0.2.5"
dependencies = [
"approx",
"proptest",
@@ -1888,7 +1888,7 @@ dependencies = [
[[package]]
name = "wickra-data"
version = "0.2.1"
version = "0.2.5"
dependencies = [
"approx",
"csv",
@@ -1915,7 +1915,7 @@ dependencies = [
[[package]]
name = "wickra-node"
version = "0.2.1"
version = "0.2.5"
dependencies = [
"napi",
"napi-build",
@@ -1925,7 +1925,7 @@ dependencies = [
[[package]]
name = "wickra-python"
version = "0.2.1"
version = "0.2.5"
dependencies = [
"numpy",
"pyo3",
@@ -1934,7 +1934,7 @@ dependencies = [
[[package]]
name = "wickra-wasm"
version = "0.2.1"
version = "0.2.5"
dependencies = [
"console_error_panic_hook",
"js-sys",
+2 -2
View File
@@ -12,7 +12,7 @@ members = [
exclude = ["fuzz"]
[workspace.package]
version = "0.2.1"
version = "0.2.5"
authors = ["kingchenc <kingchencp@gmail.com>"]
edition = "2021"
rust-version = "1.86"
@@ -24,7 +24,7 @@ keywords = ["finance", "trading", "indicators", "technical-analysis", "ta"]
categories = ["finance", "mathematics", "science"]
[workspace.dependencies]
wickra-core = { path = "crates/wickra-core", version = "0.2.1" }
wickra-core = { path = "crates/wickra-core", version = "0.2.5" }
thiserror = "2"
rayon = "1.10"
+10
View File
@@ -287,6 +287,16 @@ government, hobby trading bots: all fine. The one thing that's not allowed is
commercial sale of the software or of services built around it. If you want to
use Wickra commercially, get in touch about a license.
## Disclaimer
Wickra is an indicator toolkit, not a trading system. Values it computes are
deterministic transforms of the input data — they are not financial advice and
they do not predict the market. Any use of this library in a production
trading context is at your own risk.
The library is provided **as is**, without warranty of any kind; see
[LICENSE](LICENSE) for the full terms.
---
<p align="center">
+288 -27
View File
@@ -1,45 +1,306 @@
# wickra
# Wickra
Node.js bindings for the Wickra streaming-first technical indicators library.
[![CI](https://github.com/kingchenc/wickra/actions/workflows/ci.yml/badge.svg)](https://github.com/kingchenc/wickra/actions/workflows/ci.yml)
[![codecov](https://codecov.io/gh/kingchenc/wickra/branch/main/graph/badge.svg)](https://codecov.io/gh/kingchenc/wickra)
[![crates.io](https://img.shields.io/crates/v/wickra.svg?logo=rust&color=orange)](https://crates.io/crates/wickra)
[![PyPI](https://img.shields.io/pypi/v/wickra.svg?logo=pypi&color=blue)](https://pypi.org/project/wickra/)
[![npm](https://img.shields.io/npm/v/wickra.svg?logo=npm&color=red)](https://www.npmjs.com/package/wickra)
[![License: PolyForm-NC](https://img.shields.io/badge/license-PolyForm--NC--1.0.0-purple)](LICENSE)
## Install
**Streaming-first technical indicators. Install with `pip install wickra` — no system dependencies.**
Once published, install per platform via the precompiled native package:
Wickra is a multi-language technical-analysis library with a Rust core and
bindings for Python, Node.js, and WebAssembly. Every indicator is a state
machine that updates in O(1) per new data point, so live trading bots and
historical backtests share the exact same implementation.
```bash
npm install wickra
```python
import numpy as np
import wickra as ta
# Batch: classic TA-Lib-style usage
prices = np.linspace(100, 200, 1000)
rsi = ta.RSI(14)
values = rsi.batch(prices) # numpy array, NaN during warmup
# Streaming: same indicator, fed tick by tick
rsi = ta.RSI(14)
for price in live_feed:
value = rsi.update(price) # O(1) — no recomputation over history
if value is not None and value > 70:
print("overbought")
```
## Build from source
## Why Wickra exists
The Python TA ecosystem has plenty of libraries — TA-Lib, pandas-ta, finta,
talipp, tulipy — and every one of them shares the same blind spot:
| Library | Install pain | Streaming | Multi-language | Active |
|--------------------|-----------------|-----------|----------------|--------|
| TA-Lib (Python) | yes (C deps) | no | no | barely |
| pandas-ta | clean | no | no | slow |
| finta | clean | no | no | stale |
| ta-lib-python | yes (C deps) | no | no | barely |
| talipp | clean | yes | no | yes |
| Tulip Indicators | yes (C deps) | no | partial | stale |
| ooples (C#) | clean | no | C# only | yes |
| **Wickra** | **clean** | **yes** | **Python+Node+WASM+Rust** | **yes** |
Wickra is the only library that combines all of: clean install, streaming,
multi-language reach, and active maintenance.
## Benchmark: how much faster is "streaming-first"?
The numbers below were measured on a single developer workstation and are not
guaranteed to reproduce identically on different hardware — absolute µs values
depend on CPU, memory clock and OS scheduler. Read them as **relative
speedups** between libraries on identical input, not as a universal
performance contract.
- **Reproduced on:** Windows 11 Pro 26200, AMD Ryzen 9 7950X3D, 64 GB DDR5,
Rust 1.92 (release profile, `lto = "fat"`, `codegen-units = 1`),
Python 3.12, Node 20.
- **Reproduce yourself:** `pip install -e bindings/python[bench]` then
`python -m benchmarks.compare_libraries`. The script auto-detects every
installed peer library and runs them on the same generated inputs as
Wickra. The CI job `cross-library-bench` runs the same script on every
push and uploads the raw report as a build artefact.
Lower µs/op = faster. Wickra wins every batch category outright, and the
streaming gap widens linearly with how much history a batch-only library has
to recompute on every tick.
### Batch — single full pass over a 20 000-bar series
Reading the table: each cell shows that library's runtime, plus how many times
slower it is than Wickra in parentheses. **★** marks the winner per row.
| Indicator | Wickra | finta | talipp |
|---------------------|---------------------|-----------------------------|-------------------------------|
| SMA(20) | **95.6 µs ★** | 343.5 µs (3.6× slower) | 7 640.6 µs (79.9× slower) |
| EMA(20) | **64.6 µs ★** | 223.1 µs (3.5× slower) | 12 160.9 µs (188.2× slower) |
| RSI(14) | **126.2 µs ★** | 1 107.1 µs (8.8× slower) | 15 792.2 µs (125.1× slower) |
| MACD(12, 26, 9) | **119.0 µs ★** | 531.8 µs (4.5× slower) | 49 788.1 µs (418.2× slower) |
| Bollinger(20, 2.0) | **105.3 µs ★** | 812.0 µs (7.7× slower) | 130 938.3 µs (1 243.7× slower)|
| ATR(14) | **123.5 µs ★** | 5 144.8 µs (41.7× slower) | 28 816.0 µs (233.4× slower) |
### Streaming — per-tick latency after seeding with 5 000 historical bars
A batch-only library has to re-run its full indicator over the entire history on
every new tick; Wickra updates state in O(1).
| Indicator | Wickra (per tick) | talipp (per tick) |
|-----------|---------------------|---------------------------|
| RSI(14) | **0.119 µs ★** | 1.644 µs (13.8× slower) |
> TA-Lib and pandas-ta are not included here because both fail to install
> cleanly on Windows without C build tooling — which is precisely the install
> pain Wickra was built to remove. The benchmark script auto-detects every
> peer library it can find and runs them on the same inputs as Wickra; install
> them in your environment to see those rows light up too.
Run the suite yourself:
```bash
cd bindings/node
npm install
npm run build
npm test
pip install -e bindings/python[bench]
python -m benchmarks.compare_libraries
```
The native module is built via [napi-rs](https://napi.rs/). The build script
produces a `wickra.<platform>-<arch>.node` binary in the package root that
`index.js` loads at runtime.
## Indicators
## Usage
71 streaming-first indicators across eight families. Every one passes the
`batch == streaming` equivalence test, reference-value tests, and reset
semantics tests.
```js
import { SMA, RSI, MACD, version } from 'wickra';
| Family | Indicators |
|--------|-----------|
| Moving Averages | SMA, EMA, WMA, DEMA, TEMA, HMA, KAMA, SMMA, TRIMA, ZLEMA, T3, VWMA |
| Momentum Oscillators | RSI (Wilder), Stochastic, CCI, ROC, Williams %R, MFI, Awesome Oscillator, MOM, CMO, TSI, PMO, StochRSI, Ultimate Oscillator |
| 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 |
| 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 |
| Price Statistics | Typical Price, Median Price, Weighted Close, Linear Regression, Linear Regression Slope, Z-Score, Linear Regression Angle |
console.log('wickra', version());
Adding a new indicator means implementing one trait in Rust; all four bindings
inherit it automatically.
// Batch:
const prices = Array.from({ length: 1000 }, (_, i) => 100 + Math.sin(i * 0.1) * 5);
const rsi = new RSI(14).batch(prices);
## Languages
// Streaming:
const macd = new MACD(12, 26, 9);
for (const p of livePriceStream) {
const v = macd.update(p);
if (v && v.histogram > 0) console.log('bullish crossover candidate');
| Binding | Install | Example |
|-------------------|-----------------------------------------------|---------|
| Python (PyO3) | `pip install wickra` | `examples/python/backtest.py` |
| Node.js (napi-rs) | `npm install wickra` | `examples/node/backtest.js` |
| Browser / WASM | `npm install wickra-wasm` | `examples/wasm/index.html` |
| Rust | `cargo add wickra` | `examples/rust/src/bin/backtest.rs` |
Each binding ships several runnable examples (streaming, backtest, live feed);
[`examples/README.md`](examples/README.md) is the full cross-language index.
The wickra-core crate is `unsafe`-forbidden, so every binding inherits a
memory-safe implementation.
## Rust API
```rust
use wickra::{Indicator, BatchExt, Chain, Ema, Rsi, Sma};
// Streaming or batch — same trait, same code.
let mut sma = Sma::new(14)?;
let out: Vec<Option<f64>> = sma.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
let mut rsi = Rsi::new(14)?;
for price in live_feed {
if let Some(v) = rsi.update(price) {
println!("RSI = {v}");
}
}
// Compose indicators: RSI(7) on top of EMA(14).
let mut chain = Chain::new(Ema::new(14)?, Rsi::new(7)?);
chain.update(price);
```
## Live data sources
`wickra-data` (separate crate, opt-in) ships:
- A streaming OHLCV **CSV reader**.
- A **tick-to-candle aggregator** with arbitrary timeframes.
- A **candle resampler** for multi-timeframe analysis (1m → 5m → 1h on the fly).
- A **Binance Spot WebSocket** kline adapter (feature `live-binance`).
```rust
use wickra::{Indicator, Rsi};
use wickra_data::live::binance::{BinanceKlineStream, Interval};
let mut stream = BinanceKlineStream::connect(&["BTCUSDT".into()], Interval::OneMinute).await?;
let mut rsi = Rsi::new(14)?;
while let Some(event) = stream.next_event().await? {
if event.is_closed {
if let Some(v) = rsi.update(event.candle.close) {
println!("RSI = {v:.2}");
}
}
}
```
See `index.d.ts` for the full TypeScript surface.
A Python live-trading example using the public `websockets` package lives at
`examples/python/live_trading.py`.
## Project layout
```
wickra/
├── crates/
│ ├── wickra-core/ core engine + all 71 indicators
│ ├── wickra/ top-level facade crate (publishes on crates.io) + benches/
│ └── wickra-data/ CSV reader, tick aggregator, live exchange feeds
├── bindings/
│ ├── python/ PyO3 + maturin (publishes on PyPI)
│ ├── node/ napi-rs (publishes on npm)
│ └── wasm/ wasm-bindgen (browsers, bundlers, Node)
├── examples/ examples/README.md indexes every language
│ ├── data/ real BTCUSDT OHLCV datasets, one per timeframe
│ ├── rust/ Rust workspace member (`wickra-examples`)
│ ├── python/ backtest, live trading, parallel assets, multi-tf
│ ├── node/ streaming, backtest, live trading (load `wickra`)
│ └── wasm/ browser demo for `wickra-wasm`
└── .github/workflows/ CI and release pipelines
```
Rust benchmarks live in `crates/wickra/benches/`; runnable Rust examples live
in the workspace member crate at `examples/rust/`. There is no top-level
`benches/` directory.
## Building everything from source
```bash
# Rust core + tests
cargo test --workspace
cargo clippy --workspace --all-targets -- -D warnings
cargo bench -p wickra
# Python binding (requires Rust toolchain + maturin)
cd bindings/python
maturin develop --release
pytest
# WASM binding (requires wasm-pack + wasm32-unknown-unknown target)
wasm-pack build bindings/wasm --target web --release --features panic-hook
# Node binding (requires @napi-rs/cli)
cd bindings/node && npm install && npm run build && npm test
```
## Testing
Every layer is covered; run the suites with the commands in
[Building everything from source](#building-everything-from-source).
- `wickra-core`: unit tests per indicator — textbook reference values
(Wilder RSI, Bollinger Bands, MACD, ATR, Stochastic), `batch == streaming`
equivalence, `reset` semantics, NaN/Inf handling, and property tests.
- `wickra-data`: unit tests for CSV decoding, the tick aggregator, the
resampler, and the Binance payload parser.
- `bindings/python`: pytest covering smoke checks, streaming/batch
equivalence, reference values, lifecycle, input validation, and
dict/tuple candle inputs.
- `bindings/node`: `node --test` cases for batch, streaming, and reference
values across all indicators.
- `bindings/wasm`: `wasm-bindgen-test` cases for constructors, equivalence,
and reference values.
## Contributing
Contributions are very welcome — issues, bug reports, ideas, and pull requests
all land in the same place: <https://github.com/kingchenc/wickra>.
A short orientation for first-time contributors:
- **Adding an indicator.** Implement the `Indicator` trait in
`crates/wickra-core/src/indicators/<name>.rs`, wire it into
`indicators/mod.rs` and the crate root, and add reference-value tests,
a `batch == streaming` equivalence test, and (where it makes sense) a
proptest. The four bindings inherit your indicator automatically once
you expose it in the language wrappers.
- **Fixing a numeric bug.** Add a failing test that pins the textbook value
first, then fix the math. Property tests in `crates/wickra-core` catch
most regressions; please don't disable them.
- **Improving a binding.** Each binding lives under `bindings/<lang>` with
its own tests; please keep the `batch == streaming` invariant.
- **Style.** `cargo fmt --all` + `cargo clippy --workspace --all-targets -- -D warnings`
are CI gates; running them locally before pushing keeps reviews short.
For larger architectural changes, open an issue first so we can sketch the
shape together before you invest the time.
## License
Licensed under the **PolyForm Noncommercial License 1.0.0**. See [LICENSE](LICENSE).
In plain English: use it, fork it, modify it, redistribute it, file issues, send
pull requests — all welcome. Personal projects, research, education, non-profits,
government, hobby trading bots: all fine. The one thing that's not allowed is
commercial sale of the software or of services built around it. If you want to
use Wickra commercially, get in touch about a license.
---
<p align="center">
<a href="https://github.com/kingchenc/wickra/stargazers">
<img alt="GitHub stars" src="https://img.shields.io/github/stars/kingchenc/wickra?style=for-the-badge&logo=github&logoColor=white&color=ffd866">
</a>
<a href="https://github.com/kingchenc/wickra/network/members">
<img alt="GitHub forks" src="https://img.shields.io/github/forks/kingchenc/wickra?style=for-the-badge&logo=github&logoColor=white&color=78dce8">
</a>
<a href="https://github.com/kingchenc/wickra/issues">
<img alt="GitHub issues" src="https://img.shields.io/github/issues/kingchenc/wickra?style=for-the-badge&logo=github&logoColor=white&color=ff6188">
</a>
</p>
<p align="center">
If Wickra saved you time, the cheapest way to say thanks is to ⭐ the repo.
</p>
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "wickra-darwin-arm64",
"version": "0.2.1",
"version": "0.2.5",
"description": "Native binding for wickra (macOS Apple Silicon). Installed automatically as an optional dependency of wickra on matching platforms.",
"main": "wickra.darwin-arm64.node",
"files": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "wickra-darwin-x64",
"version": "0.2.1",
"version": "0.2.5",
"description": "Native binding for wickra (macOS Intel). Installed automatically as an optional dependency of wickra on matching platforms.",
"main": "wickra.darwin-x64.node",
"files": [
@@ -1,6 +1,6 @@
{
"name": "wickra-linux-arm64-gnu",
"version": "0.2.1",
"version": "0.2.5",
"description": "Native binding for wickra (linux arm64 GNU). Installed automatically as an optional dependency of wickra on matching platforms.",
"main": "wickra.linux-arm64-gnu.node",
"files": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "wickra-linux-x64-gnu",
"version": "0.2.1",
"version": "0.2.5",
"description": "Native binding for wickra (linux x64 GNU). Installed automatically as an optional dependency of wickra on matching platforms.",
"main": "wickra.linux-x64-gnu.node",
"files": [
@@ -1,6 +1,6 @@
{
"name": "wickra-win32-x64-msvc",
"version": "0.2.1",
"version": "0.2.5",
"description": "Native binding for wickra (Windows x64 MSVC). Installed automatically as an optional dependency of wickra on matching platforms.",
"main": "wickra.win32-x64-msvc.node",
"files": [
+6 -6
View File
@@ -1,6 +1,6 @@
{
"name": "wickra",
"version": "0.2.1",
"version": "0.2.5",
"description": "Streaming-first technical indicators: incremental, fast, install-free. Node bindings powered by Rust.",
"author": "kingchenc <kingchencp@gmail.com>",
"main": "index.js",
@@ -46,11 +46,11 @@
"node": ">= 18"
},
"optionalDependencies": {
"wickra-linux-x64-gnu": "0.2.1",
"wickra-linux-arm64-gnu": "0.2.1",
"wickra-darwin-x64": "0.2.1",
"wickra-darwin-arm64": "0.2.1",
"wickra-win32-x64-msvc": "0.2.1"
"wickra-linux-x64-gnu": "0.2.5",
"wickra-linux-arm64-gnu": "0.2.5",
"wickra-darwin-x64": "0.2.5",
"wickra-darwin-arm64": "0.2.5",
"wickra-win32-x64-msvc": "0.2.5"
},
"scripts": {
"build": "napi build --platform --release",
+285 -45
View File
@@ -1,66 +1,306 @@
# Wickra — Python bindings
# Wickra
Streaming-first technical indicators powered by a Rust core.
[![CI](https://github.com/kingchenc/wickra/actions/workflows/ci.yml/badge.svg)](https://github.com/kingchenc/wickra/actions/workflows/ci.yml)
[![codecov](https://codecov.io/gh/kingchenc/wickra/branch/main/graph/badge.svg)](https://codecov.io/gh/kingchenc/wickra)
[![crates.io](https://img.shields.io/crates/v/wickra.svg?logo=rust&color=orange)](https://crates.io/crates/wickra)
[![PyPI](https://img.shields.io/pypi/v/wickra.svg?logo=pypi&color=blue)](https://pypi.org/project/wickra/)
[![npm](https://img.shields.io/npm/v/wickra.svg?logo=npm&color=red)](https://www.npmjs.com/package/wickra)
[![License: PolyForm-NC](https://img.shields.io/badge/license-PolyForm--NC--1.0.0-purple)](LICENSE)
```bash
pip install wickra
```
**Streaming-first technical indicators. Install with `pip install wickra` — no system dependencies.**
## Quick start
Wickra is a multi-language technical-analysis library with a Rust core and
bindings for Python, Node.js, and WebAssembly. Every indicator is a state
machine that updates in O(1) per new data point, so live trading bots and
historical backtests share the exact same implementation.
```python
import numpy as np
import wickra as ta
# Batch TA-Lib-style usage
# Batch: classic TA-Lib-style usage
prices = np.linspace(100, 200, 1000)
rsi = ta.RSI(14).batch(prices) # NumPy array; NaN during warmup
# Streaming — feed ticks one at a time
rsi = ta.RSI(14)
for price in live_prices:
v = rsi.update(price) # O(1) per tick
if v is not None and v > 70:
...
values = rsi.batch(prices) # numpy array, NaN during warmup
# Streaming: same indicator, fed tick by tick
rsi = ta.RSI(14)
for price in live_feed:
value = rsi.update(price) # O(1) — no recomputation over history
if value is not None and value > 70:
print("overbought")
```
## What's included
## Why Wickra exists
71 streaming-first indicators across eight families. Every one passes a
`batch == streaming` equivalence test and reference-value tests:
The Python TA ecosystem has plenty of libraries — TA-Lib, pandas-ta, finta,
talipp, tulipy — and every one of them shares the same blind spot:
- **Moving Averages** — SMA, EMA, WMA, DEMA, TEMA, HMA, KAMA, SMMA, TRIMA,
ZLEMA, T3, VWMA
- **Momentum Oscillators** — RSI (Wilder), Stochastic, CCI, ROC, Williams %R,
MFI, Awesome Oscillator, MOM, CMO, TSI, PMO, StochRSI, Ultimate Oscillator
- **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
- **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
- **Price Statistics** — Typical Price, Median Price, Weighted Close, Linear
Regression, Linear Regression Slope, Z-Score, Linear Regression Angle
| Library | Install pain | Streaming | Multi-language | Active |
|--------------------|-----------------|-----------|----------------|--------|
| TA-Lib (Python) | yes (C deps) | no | no | barely |
| pandas-ta | clean | no | no | slow |
| finta | clean | no | no | stale |
| ta-lib-python | yes (C deps) | no | no | barely |
| talipp | clean | yes | no | yes |
| Tulip Indicators | yes (C deps) | no | partial | stale |
| ooples (C#) | clean | no | C# only | yes |
| **Wickra** | **clean** | **yes** | **Python+Node+WASM+Rust** | **yes** |
## Why streaming-first matters
Wickra is the only library that combines all of: clean install, streaming,
multi-language reach, and active maintenance.
Classic TA libraries are batch-only: every live tick triggers a full
recomputation over the entire history. Wickra updates indicator state in
O(1) per tick. On a 5K-bar history the streaming RSI gap is ~17× over the
nearest peer with a streaming API and 100×+ over batch-only libraries.
## Benchmark: how much faster is "streaming-first"?
## Full project
The numbers below were measured on a single developer workstation and are not
guaranteed to reproduce identically on different hardware — absolute µs values
depend on CPU, memory clock and OS scheduler. Read them as **relative
speedups** between libraries on identical input, not as a universal
performance contract.
See <https://github.com/kingchenc/wickra> for benchmarks, the Rust core,
Node.js and WebAssembly bindings, examples, and CI.
- **Reproduced on:** Windows 11 Pro 26200, AMD Ryzen 9 7950X3D, 64 GB DDR5,
Rust 1.92 (release profile, `lto = "fat"`, `codegen-units = 1`),
Python 3.12, Node 20.
- **Reproduce yourself:** `pip install -e bindings/python[bench]` then
`python -m benchmarks.compare_libraries`. The script auto-detects every
installed peer library and runs them on the same generated inputs as
Wickra. The CI job `cross-library-bench` runs the same script on every
push and uploads the raw report as a build artefact.
Lower µs/op = faster. Wickra wins every batch category outright, and the
streaming gap widens linearly with how much history a batch-only library has
to recompute on every tick.
### Batch — single full pass over a 20 000-bar series
Reading the table: each cell shows that library's runtime, plus how many times
slower it is than Wickra in parentheses. **★** marks the winner per row.
| Indicator | Wickra | finta | talipp |
|---------------------|---------------------|-----------------------------|-------------------------------|
| SMA(20) | **95.6 µs ★** | 343.5 µs (3.6× slower) | 7 640.6 µs (79.9× slower) |
| EMA(20) | **64.6 µs ★** | 223.1 µs (3.5× slower) | 12 160.9 µs (188.2× slower) |
| RSI(14) | **126.2 µs ★** | 1 107.1 µs (8.8× slower) | 15 792.2 µs (125.1× slower) |
| MACD(12, 26, 9) | **119.0 µs ★** | 531.8 µs (4.5× slower) | 49 788.1 µs (418.2× slower) |
| Bollinger(20, 2.0) | **105.3 µs ★** | 812.0 µs (7.7× slower) | 130 938.3 µs (1 243.7× slower)|
| ATR(14) | **123.5 µs ★** | 5 144.8 µs (41.7× slower) | 28 816.0 µs (233.4× slower) |
### Streaming — per-tick latency after seeding with 5 000 historical bars
A batch-only library has to re-run its full indicator over the entire history on
every new tick; Wickra updates state in O(1).
| Indicator | Wickra (per tick) | talipp (per tick) |
|-----------|---------------------|---------------------------|
| RSI(14) | **0.119 µs ★** | 1.644 µs (13.8× slower) |
> TA-Lib and pandas-ta are not included here because both fail to install
> cleanly on Windows without C build tooling — which is precisely the install
> pain Wickra was built to remove. The benchmark script auto-detects every
> peer library it can find and runs them on the same inputs as Wickra; install
> them in your environment to see those rows light up too.
Run the suite yourself:
```bash
pip install -e bindings/python[bench]
python -m benchmarks.compare_libraries
```
## Indicators
71 streaming-first indicators across eight families. Every one passes the
`batch == streaming` equivalence test, reference-value tests, and reset
semantics tests.
| Family | Indicators |
|--------|-----------|
| Moving Averages | SMA, EMA, WMA, DEMA, TEMA, HMA, KAMA, SMMA, TRIMA, ZLEMA, T3, VWMA |
| Momentum Oscillators | RSI (Wilder), Stochastic, CCI, ROC, Williams %R, MFI, Awesome Oscillator, MOM, CMO, TSI, PMO, StochRSI, Ultimate Oscillator |
| 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 |
| 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 |
| Price Statistics | Typical Price, Median Price, Weighted Close, Linear Regression, Linear Regression Slope, Z-Score, Linear Regression Angle |
Adding a new indicator means implementing one trait in Rust; all four bindings
inherit it automatically.
## Languages
| Binding | Install | Example |
|-------------------|-----------------------------------------------|---------|
| Python (PyO3) | `pip install wickra` | `examples/python/backtest.py` |
| Node.js (napi-rs) | `npm install wickra` | `examples/node/backtest.js` |
| Browser / WASM | `npm install wickra-wasm` | `examples/wasm/index.html` |
| Rust | `cargo add wickra` | `examples/rust/src/bin/backtest.rs` |
Each binding ships several runnable examples (streaming, backtest, live feed);
[`examples/README.md`](examples/README.md) is the full cross-language index.
The wickra-core crate is `unsafe`-forbidden, so every binding inherits a
memory-safe implementation.
## Rust API
```rust
use wickra::{Indicator, BatchExt, Chain, Ema, Rsi, Sma};
// Streaming or batch — same trait, same code.
let mut sma = Sma::new(14)?;
let out: Vec<Option<f64>> = sma.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
let mut rsi = Rsi::new(14)?;
for price in live_feed {
if let Some(v) = rsi.update(price) {
println!("RSI = {v}");
}
}
// Compose indicators: RSI(7) on top of EMA(14).
let mut chain = Chain::new(Ema::new(14)?, Rsi::new(7)?);
chain.update(price);
```
## Live data sources
`wickra-data` (separate crate, opt-in) ships:
- A streaming OHLCV **CSV reader**.
- A **tick-to-candle aggregator** with arbitrary timeframes.
- A **candle resampler** for multi-timeframe analysis (1m → 5m → 1h on the fly).
- A **Binance Spot WebSocket** kline adapter (feature `live-binance`).
```rust
use wickra::{Indicator, Rsi};
use wickra_data::live::binance::{BinanceKlineStream, Interval};
let mut stream = BinanceKlineStream::connect(&["BTCUSDT".into()], Interval::OneMinute).await?;
let mut rsi = Rsi::new(14)?;
while let Some(event) = stream.next_event().await? {
if event.is_closed {
if let Some(v) = rsi.update(event.candle.close) {
println!("RSI = {v:.2}");
}
}
}
```
A Python live-trading example using the public `websockets` package lives at
`examples/python/live_trading.py`.
## Project layout
```
wickra/
├── crates/
│ ├── wickra-core/ core engine + all 71 indicators
│ ├── wickra/ top-level facade crate (publishes on crates.io) + benches/
│ └── wickra-data/ CSV reader, tick aggregator, live exchange feeds
├── bindings/
│ ├── python/ PyO3 + maturin (publishes on PyPI)
│ ├── node/ napi-rs (publishes on npm)
│ └── wasm/ wasm-bindgen (browsers, bundlers, Node)
├── examples/ examples/README.md indexes every language
│ ├── data/ real BTCUSDT OHLCV datasets, one per timeframe
│ ├── rust/ Rust workspace member (`wickra-examples`)
│ ├── python/ backtest, live trading, parallel assets, multi-tf
│ ├── node/ streaming, backtest, live trading (load `wickra`)
│ └── wasm/ browser demo for `wickra-wasm`
└── .github/workflows/ CI and release pipelines
```
Rust benchmarks live in `crates/wickra/benches/`; runnable Rust examples live
in the workspace member crate at `examples/rust/`. There is no top-level
`benches/` directory.
## Building everything from source
```bash
# Rust core + tests
cargo test --workspace
cargo clippy --workspace --all-targets -- -D warnings
cargo bench -p wickra
# Python binding (requires Rust toolchain + maturin)
cd bindings/python
maturin develop --release
pytest
# WASM binding (requires wasm-pack + wasm32-unknown-unknown target)
wasm-pack build bindings/wasm --target web --release --features panic-hook
# Node binding (requires @napi-rs/cli)
cd bindings/node && npm install && npm run build && npm test
```
## Testing
Every layer is covered; run the suites with the commands in
[Building everything from source](#building-everything-from-source).
- `wickra-core`: unit tests per indicator — textbook reference values
(Wilder RSI, Bollinger Bands, MACD, ATR, Stochastic), `batch == streaming`
equivalence, `reset` semantics, NaN/Inf handling, and property tests.
- `wickra-data`: unit tests for CSV decoding, the tick aggregator, the
resampler, and the Binance payload parser.
- `bindings/python`: pytest covering smoke checks, streaming/batch
equivalence, reference values, lifecycle, input validation, and
dict/tuple candle inputs.
- `bindings/node`: `node --test` cases for batch, streaming, and reference
values across all indicators.
- `bindings/wasm`: `wasm-bindgen-test` cases for constructors, equivalence,
and reference values.
## Contributing
Contributions are very welcome — issues, bug reports, ideas, and pull requests
all land in the same place: <https://github.com/kingchenc/wickra>.
A short orientation for first-time contributors:
- **Adding an indicator.** Implement the `Indicator` trait in
`crates/wickra-core/src/indicators/<name>.rs`, wire it into
`indicators/mod.rs` and the crate root, and add reference-value tests,
a `batch == streaming` equivalence test, and (where it makes sense) a
proptest. The four bindings inherit your indicator automatically once
you expose it in the language wrappers.
- **Fixing a numeric bug.** Add a failing test that pins the textbook value
first, then fix the math. Property tests in `crates/wickra-core` catch
most regressions; please don't disable them.
- **Improving a binding.** Each binding lives under `bindings/<lang>` with
its own tests; please keep the `batch == streaming` invariant.
- **Style.** `cargo fmt --all` + `cargo clippy --workspace --all-targets -- -D warnings`
are CI gates; running them locally before pushing keeps reviews short.
For larger architectural changes, open an issue first so we can sketch the
shape together before you invest the time.
## License
Licensed under the **PolyForm Noncommercial License 1.0.0**. Personal,
research, educational, and non-profit use are all permitted. Commercial
sale requires a separate license — contact via the GitHub repo.
Licensed under the **PolyForm Noncommercial License 1.0.0**. See [LICENSE](LICENSE).
In plain English: use it, fork it, modify it, redistribute it, file issues, send
pull requests — all welcome. Personal projects, research, education, non-profits,
government, hobby trading bots: all fine. The one thing that's not allowed is
commercial sale of the software or of services built around it. If you want to
use Wickra commercially, get in touch about a license.
---
<p align="center">
<a href="https://github.com/kingchenc/wickra/stargazers">
<img alt="GitHub stars" src="https://img.shields.io/github/stars/kingchenc/wickra?style=for-the-badge&logo=github&logoColor=white&color=ffd866">
</a>
<a href="https://github.com/kingchenc/wickra/network/members">
<img alt="GitHub forks" src="https://img.shields.io/github/forks/kingchenc/wickra?style=for-the-badge&logo=github&logoColor=white&color=78dce8">
</a>
<a href="https://github.com/kingchenc/wickra/issues">
<img alt="GitHub issues" src="https://img.shields.io/github/issues/kingchenc/wickra?style=for-the-badge&logo=github&logoColor=white&color=ff6188">
</a>
</p>
<p align="center">
If Wickra saved you time, the cheapest way to say thanks is to ⭐ the repo.
</p>
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "maturin"
[project]
name = "wickra"
version = "0.2.1"
version = "0.2.5"
description = "Streaming-first technical indicators: incremental, fast, install-free."
readme = "README.md"
license = { text = "PolyForm-Noncommercial-1.0.0" }
+287 -30
View File
@@ -1,49 +1,306 @@
# wickra-wasm
# Wickra
WebAssembly bindings for the Wickra streaming-first technical indicators library.
[![CI](https://github.com/kingchenc/wickra/actions/workflows/ci.yml/badge.svg)](https://github.com/kingchenc/wickra/actions/workflows/ci.yml)
[![codecov](https://codecov.io/gh/kingchenc/wickra/branch/main/graph/badge.svg)](https://codecov.io/gh/kingchenc/wickra)
[![crates.io](https://img.shields.io/crates/v/wickra.svg?logo=rust&color=orange)](https://crates.io/crates/wickra)
[![PyPI](https://img.shields.io/pypi/v/wickra.svg?logo=pypi&color=blue)](https://pypi.org/project/wickra/)
[![npm](https://img.shields.io/npm/v/wickra.svg?logo=npm&color=red)](https://www.npmjs.com/package/wickra)
[![License: PolyForm-NC](https://img.shields.io/badge/license-PolyForm--NC--1.0.0-purple)](LICENSE)
## Build
**Streaming-first technical indicators. Install with `pip install wickra` — no system dependencies.**
You need [`wasm-pack`](https://rustwasm.github.io/wasm-pack/) and the
`wasm32-unknown-unknown` Rust target:
Wickra is a multi-language technical-analysis library with a Rust core and
bindings for Python, Node.js, and WebAssembly. Every indicator is a state
machine that updates in O(1) per new data point, so live trading bots and
historical backtests share the exact same implementation.
```bash
rustup target add wasm32-unknown-unknown
cargo install wasm-pack
```python
import numpy as np
import wickra as ta
# Batch: classic TA-Lib-style usage
prices = np.linspace(100, 200, 1000)
rsi = ta.RSI(14)
values = rsi.batch(prices) # numpy array, NaN during warmup
# Streaming: same indicator, fed tick by tick
rsi = ta.RSI(14)
for price in live_feed:
value = rsi.update(price) # O(1) — no recomputation over history
if value is not None and value > 70:
print("overbought")
```
Then from the repository root:
## Why Wickra exists
The Python TA ecosystem has plenty of libraries — TA-Lib, pandas-ta, finta,
talipp, tulipy — and every one of them shares the same blind spot:
| Library | Install pain | Streaming | Multi-language | Active |
|--------------------|-----------------|-----------|----------------|--------|
| TA-Lib (Python) | yes (C deps) | no | no | barely |
| pandas-ta | clean | no | no | slow |
| finta | clean | no | no | stale |
| ta-lib-python | yes (C deps) | no | no | barely |
| talipp | clean | yes | no | yes |
| Tulip Indicators | yes (C deps) | no | partial | stale |
| ooples (C#) | clean | no | C# only | yes |
| **Wickra** | **clean** | **yes** | **Python+Node+WASM+Rust** | **yes** |
Wickra is the only library that combines all of: clean install, streaming,
multi-language reach, and active maintenance.
## Benchmark: how much faster is "streaming-first"?
The numbers below were measured on a single developer workstation and are not
guaranteed to reproduce identically on different hardware — absolute µs values
depend on CPU, memory clock and OS scheduler. Read them as **relative
speedups** between libraries on identical input, not as a universal
performance contract.
- **Reproduced on:** Windows 11 Pro 26200, AMD Ryzen 9 7950X3D, 64 GB DDR5,
Rust 1.92 (release profile, `lto = "fat"`, `codegen-units = 1`),
Python 3.12, Node 20.
- **Reproduce yourself:** `pip install -e bindings/python[bench]` then
`python -m benchmarks.compare_libraries`. The script auto-detects every
installed peer library and runs them on the same generated inputs as
Wickra. The CI job `cross-library-bench` runs the same script on every
push and uploads the raw report as a build artefact.
Lower µs/op = faster. Wickra wins every batch category outright, and the
streaming gap widens linearly with how much history a batch-only library has
to recompute on every tick.
### Batch — single full pass over a 20 000-bar series
Reading the table: each cell shows that library's runtime, plus how many times
slower it is than Wickra in parentheses. **★** marks the winner per row.
| Indicator | Wickra | finta | talipp |
|---------------------|---------------------|-----------------------------|-------------------------------|
| SMA(20) | **95.6 µs ★** | 343.5 µs (3.6× slower) | 7 640.6 µs (79.9× slower) |
| EMA(20) | **64.6 µs ★** | 223.1 µs (3.5× slower) | 12 160.9 µs (188.2× slower) |
| RSI(14) | **126.2 µs ★** | 1 107.1 µs (8.8× slower) | 15 792.2 µs (125.1× slower) |
| MACD(12, 26, 9) | **119.0 µs ★** | 531.8 µs (4.5× slower) | 49 788.1 µs (418.2× slower) |
| Bollinger(20, 2.0) | **105.3 µs ★** | 812.0 µs (7.7× slower) | 130 938.3 µs (1 243.7× slower)|
| ATR(14) | **123.5 µs ★** | 5 144.8 µs (41.7× slower) | 28 816.0 µs (233.4× slower) |
### Streaming — per-tick latency after seeding with 5 000 historical bars
A batch-only library has to re-run its full indicator over the entire history on
every new tick; Wickra updates state in O(1).
| Indicator | Wickra (per tick) | talipp (per tick) |
|-----------|---------------------|---------------------------|
| RSI(14) | **0.119 µs ★** | 1.644 µs (13.8× slower) |
> TA-Lib and pandas-ta are not included here because both fail to install
> cleanly on Windows without C build tooling — which is precisely the install
> pain Wickra was built to remove. The benchmark script auto-detects every
> peer library it can find and runs them on the same inputs as Wickra; install
> them in your environment to see those rows light up too.
Run the suite yourself:
```bash
wasm-pack build bindings/wasm --target web --release --features panic-hook
pip install -e bindings/python[bench]
python -m benchmarks.compare_libraries
```
The compiled package lands in `bindings/wasm/pkg/`. Targets:
## Indicators
- `--target web` for native ES modules in browsers
- `--target bundler` for webpack/Vite/Rollup
- `--target nodejs` for Node.js
71 streaming-first indicators across eight families. Every one passes the
`batch == streaming` equivalence test, reference-value tests, and reset
semantics tests.
## Example
| Family | Indicators |
|--------|-----------|
| Moving Averages | SMA, EMA, WMA, DEMA, TEMA, HMA, KAMA, SMMA, TRIMA, ZLEMA, T3, VWMA |
| Momentum Oscillators | RSI (Wilder), Stochastic, CCI, ROC, Williams %R, MFI, Awesome Oscillator, MOM, CMO, TSI, PMO, StochRSI, Ultimate Oscillator |
| 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 |
| 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 |
| Price Statistics | Typical Price, Median Price, Weighted Close, Linear Regression, Linear Regression Slope, Z-Score, Linear Regression Angle |
```js
import init, { SMA, RSI, MACD, version } from "./pkg/wickra_wasm.js";
Adding a new indicator means implementing one trait in Rust; all four bindings
inherit it automatically.
await init();
console.log("wickra:", version());
## Languages
// Streaming
const rsi = new RSI(14);
for (const price of livePrices) {
const v = rsi.update(price);
if (v !== undefined && v > 70) console.log("overbought");
| Binding | Install | Example |
|-------------------|-----------------------------------------------|---------|
| Python (PyO3) | `pip install wickra` | `examples/python/backtest.py` |
| Node.js (napi-rs) | `npm install wickra` | `examples/node/backtest.js` |
| Browser / WASM | `npm install wickra-wasm` | `examples/wasm/index.html` |
| Rust | `cargo add wickra` | `examples/rust/src/bin/backtest.rs` |
Each binding ships several runnable examples (streaming, backtest, live feed);
[`examples/README.md`](examples/README.md) is the full cross-language index.
The wickra-core crate is `unsafe`-forbidden, so every binding inherits a
memory-safe implementation.
## Rust API
```rust
use wickra::{Indicator, BatchExt, Chain, Ema, Rsi, Sma};
// Streaming or batch — same trait, same code.
let mut sma = Sma::new(14)?;
let out: Vec<Option<f64>> = sma.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
let mut rsi = Rsi::new(14)?;
for price in live_feed {
if let Some(v) = rsi.update(price) {
println!("RSI = {v}");
}
}
// Batch (returns a Float64Array; NaN for warmup positions)
const sma = new SMA(20).batch(new Float64Array(historicalPrices));
// Compose indicators: RSI(7) on top of EMA(14).
let mut chain = Chain::new(Ema::new(14)?, Rsi::new(7)?);
chain.update(price);
```
An interactive demo lives in [`examples/wasm/index.html`](../../examples/wasm/index.html)
(top-level alongside the other language examples). After building the package
with `wasm-pack build`, serve the repository root and open
`examples/wasm/index.html` in a browser.
## Live data sources
`wickra-data` (separate crate, opt-in) ships:
- A streaming OHLCV **CSV reader**.
- A **tick-to-candle aggregator** with arbitrary timeframes.
- A **candle resampler** for multi-timeframe analysis (1m → 5m → 1h on the fly).
- A **Binance Spot WebSocket** kline adapter (feature `live-binance`).
```rust
use wickra::{Indicator, Rsi};
use wickra_data::live::binance::{BinanceKlineStream, Interval};
let mut stream = BinanceKlineStream::connect(&["BTCUSDT".into()], Interval::OneMinute).await?;
let mut rsi = Rsi::new(14)?;
while let Some(event) = stream.next_event().await? {
if event.is_closed {
if let Some(v) = rsi.update(event.candle.close) {
println!("RSI = {v:.2}");
}
}
}
```
A Python live-trading example using the public `websockets` package lives at
`examples/python/live_trading.py`.
## Project layout
```
wickra/
├── crates/
│ ├── wickra-core/ core engine + all 71 indicators
│ ├── wickra/ top-level facade crate (publishes on crates.io) + benches/
│ └── wickra-data/ CSV reader, tick aggregator, live exchange feeds
├── bindings/
│ ├── python/ PyO3 + maturin (publishes on PyPI)
│ ├── node/ napi-rs (publishes on npm)
│ └── wasm/ wasm-bindgen (browsers, bundlers, Node)
├── examples/ examples/README.md indexes every language
│ ├── data/ real BTCUSDT OHLCV datasets, one per timeframe
│ ├── rust/ Rust workspace member (`wickra-examples`)
│ ├── python/ backtest, live trading, parallel assets, multi-tf
│ ├── node/ streaming, backtest, live trading (load `wickra`)
│ └── wasm/ browser demo for `wickra-wasm`
└── .github/workflows/ CI and release pipelines
```
Rust benchmarks live in `crates/wickra/benches/`; runnable Rust examples live
in the workspace member crate at `examples/rust/`. There is no top-level
`benches/` directory.
## Building everything from source
```bash
# Rust core + tests
cargo test --workspace
cargo clippy --workspace --all-targets -- -D warnings
cargo bench -p wickra
# Python binding (requires Rust toolchain + maturin)
cd bindings/python
maturin develop --release
pytest
# WASM binding (requires wasm-pack + wasm32-unknown-unknown target)
wasm-pack build bindings/wasm --target web --release --features panic-hook
# Node binding (requires @napi-rs/cli)
cd bindings/node && npm install && npm run build && npm test
```
## Testing
Every layer is covered; run the suites with the commands in
[Building everything from source](#building-everything-from-source).
- `wickra-core`: unit tests per indicator — textbook reference values
(Wilder RSI, Bollinger Bands, MACD, ATR, Stochastic), `batch == streaming`
equivalence, `reset` semantics, NaN/Inf handling, and property tests.
- `wickra-data`: unit tests for CSV decoding, the tick aggregator, the
resampler, and the Binance payload parser.
- `bindings/python`: pytest covering smoke checks, streaming/batch
equivalence, reference values, lifecycle, input validation, and
dict/tuple candle inputs.
- `bindings/node`: `node --test` cases for batch, streaming, and reference
values across all indicators.
- `bindings/wasm`: `wasm-bindgen-test` cases for constructors, equivalence,
and reference values.
## Contributing
Contributions are very welcome — issues, bug reports, ideas, and pull requests
all land in the same place: <https://github.com/kingchenc/wickra>.
A short orientation for first-time contributors:
- **Adding an indicator.** Implement the `Indicator` trait in
`crates/wickra-core/src/indicators/<name>.rs`, wire it into
`indicators/mod.rs` and the crate root, and add reference-value tests,
a `batch == streaming` equivalence test, and (where it makes sense) a
proptest. The four bindings inherit your indicator automatically once
you expose it in the language wrappers.
- **Fixing a numeric bug.** Add a failing test that pins the textbook value
first, then fix the math. Property tests in `crates/wickra-core` catch
most regressions; please don't disable them.
- **Improving a binding.** Each binding lives under `bindings/<lang>` with
its own tests; please keep the `batch == streaming` invariant.
- **Style.** `cargo fmt --all` + `cargo clippy --workspace --all-targets -- -D warnings`
are CI gates; running them locally before pushing keeps reviews short.
For larger architectural changes, open an issue first so we can sketch the
shape together before you invest the time.
## License
Licensed under the **PolyForm Noncommercial License 1.0.0**. See [LICENSE](LICENSE).
In plain English: use it, fork it, modify it, redistribute it, file issues, send
pull requests — all welcome. Personal projects, research, education, non-profits,
government, hobby trading bots: all fine. The one thing that's not allowed is
commercial sale of the software or of services built around it. If you want to
use Wickra commercially, get in touch about a license.
---
<p align="center">
<a href="https://github.com/kingchenc/wickra/stargazers">
<img alt="GitHub stars" src="https://img.shields.io/github/stars/kingchenc/wickra?style=for-the-badge&logo=github&logoColor=white&color=ffd866">
</a>
<a href="https://github.com/kingchenc/wickra/network/members">
<img alt="GitHub forks" src="https://img.shields.io/github/forks/kingchenc/wickra?style=for-the-badge&logo=github&logoColor=white&color=78dce8">
</a>
<a href="https://github.com/kingchenc/wickra/issues">
<img alt="GitHub issues" src="https://img.shields.io/github/issues/kingchenc/wickra?style=for-the-badge&logo=github&logoColor=white&color=ff6188">
</a>
</p>
<p align="center">
If Wickra saved you time, the cheapest way to say thanks is to ⭐ the repo.
</p>
@@ -165,6 +165,16 @@ mod tests {
assert!(AcceleratorOscillator::new(34, 5, 5).is_err());
}
/// Cover the const accessor `params` (69-71) and the Indicator-impl
/// `name` body (99-101). Existing tests inspect numeric output but
/// never query the metadata.
#[test]
fn accessors_and_metadata() {
let ac = AcceleratorOscillator::classic();
assert_eq!(ac.params(), (5, 34, 5));
assert_eq!(ac.name(), "AcceleratorOscillator");
}
#[test]
fn reset_clears_state() {
let candles: Vec<Candle> = (0..60).map(|i| c(11.0, 9.0, 10.0, i)).collect();
+8
View File
@@ -127,6 +127,14 @@ mod tests {
assert!(adl.update(candle(8.0, 10.0, 8.0, 9.0, 50.0, 0)).is_some());
}
/// Cover the Indicator-impl `name` body (94-96). The other accessors
/// are exercised by existing tests; `name` was never queried.
#[test]
fn accessors_and_metadata() {
let adl = Adl::new();
assert_eq!(adl.name(), "ADL");
}
#[test]
fn close_at_high_accumulates_full_volume() {
// Every bar closes at its high: MFM = +1, so ADL grows by `volume`.
+31
View File
@@ -269,6 +269,37 @@ mod tests {
assert!(Adx::new(0).is_err());
}
/// Cover the const accessor `period` (lines 89-91) and the Indicator-impl
/// `warmup_period` (199-201) + `name` (207-209). None of the trend tests
/// inspect these metadata methods.
#[test]
fn accessors_and_metadata() {
let adx = Adx::new(14).unwrap();
assert_eq!(adx.period(), 14);
assert_eq!(adx.warmup_period(), 28);
assert_eq!(adx.name(), "ADX");
}
/// Cover the `tr_v == 0.0` defensive branches in `update` (lines 142,
/// 147) — feeding a stream of perfectly flat candles (H == L == close
/// every bar) gives true-range 0 each step, so the smoothed `tr_smooth`
/// stays at 0.0 and the `plus_di` / `minus_di` divisions would otherwise
/// blow up. The indicator must emit zeros (DX denominator is also 0).
#[test]
fn zero_true_range_yields_zero_di_and_zero_adx() {
let candles: Vec<Candle> = (0..30).map(|_| c(10.0, 10.0, 10.0)).collect();
let mut adx = Adx::new(5).unwrap();
let last = adx
.batch(&candles)
.into_iter()
.flatten()
.last()
.expect("ADX emits after 2 * period candles");
assert_eq!(last.plus_di, 0.0);
assert_eq!(last.minus_di, 0.0);
assert_eq!(last.adx, 0.0);
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..60)
@@ -182,4 +182,13 @@ mod tests {
assert!(!a.is_ready());
assert_eq!(a.update(candles[0]), None);
}
/// Cover the const accessor `period` (56-58) and the Indicator-impl
/// `name` body (104-106). `warmup_period` is exercised elsewhere.
#[test]
fn accessors_and_metadata() {
let a = Aroon::new(14).unwrap();
assert_eq!(a.period(), 14);
assert_eq!(a.name(), "Aroon");
}
}
@@ -107,6 +107,21 @@ mod tests {
assert!(AroonOscillator::new(0).is_err());
}
/// Cover the const accessors `period` / `value` (57-64) and the
/// Indicator-impl `name` body (90-92). `warmup_period` is covered
/// already by `warmup_period_matches_aroon`.
#[test]
fn accessors_and_metadata() {
let mut osc = AroonOscillator::new(7).unwrap();
assert_eq!(osc.period(), 7);
assert_eq!(osc.name(), "AroonOscillator");
assert_eq!(osc.value(), None);
for i in 0..8 {
osc.update(candle(100.0 + f64::from(i), 90.0, 95.0, i64::from(i)));
}
assert!(osc.value().is_some());
}
#[test]
fn pure_uptrend_yields_plus_100() {
// Every bar a fresh high, no fresh low: AroonUp = 100, AroonDown = 0.
+15
View File
@@ -151,6 +151,21 @@ mod tests {
assert!(matches!(Atr::new(0), Err(Error::PeriodZero)));
}
/// Cover the const accessors `period` / `value` (54-62) and the
/// Indicator-impl `name` body (103-105). Existing tests inspect
/// numeric ATR output but never query the metadata.
#[test]
fn accessors_and_metadata() {
let mut atr = Atr::new(14).unwrap();
assert_eq!(atr.period(), 14);
assert_eq!(atr.name(), "ATR");
assert_eq!(atr.value(), None);
for _ in 0..14 {
atr.update(c(11.0, 9.0, 10.0));
}
assert!(atr.value().is_some());
}
#[test]
fn warmup_emits_on_period_th_candle() {
let candles = vec![
@@ -234,6 +234,17 @@ mod tests {
assert!(AtrTrailingStop::new(14, f64::NAN).is_err());
}
/// Cover the const accessor `params` (77-79) and the Indicator-impl
/// `name` body (130-132). `warmup_period` is exercised elsewhere.
#[test]
fn accessors_and_metadata() {
let s = AtrTrailingStop::classic();
let (atr_p, mult) = s.params();
assert_eq!(atr_p, 14);
assert!((mult - 3.0).abs() < 1e-12);
assert_eq!(s.name(), "AtrTrailingStop");
}
#[test]
fn reset_clears_state() {
let candles: Vec<Candle> = (0..40)
@@ -118,6 +118,17 @@ mod tests {
assert!(AwesomeOscillator::new(0, 5).is_err());
}
/// Cover the const accessor `periods` (59-61) and the Indicator-impl
/// `warmup_period` (83-85) + `name` (91-93). Existing tests never
/// inspect these metadata methods.
#[test]
fn accessors_and_metadata() {
let ao = AwesomeOscillator::classic();
assert_eq!(ao.periods(), (5, 34));
assert_eq!(ao.warmup_period(), 34);
assert_eq!(ao.name(), "AwesomeOscillator");
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..50)
@@ -132,6 +132,13 @@ mod tests {
);
}
/// Cover the Indicator-impl `name` body (73-75).
#[test]
fn name_metadata() {
let bop = BalanceOfPower::new();
assert_eq!(bop.name(), "BalanceOfPower");
}
#[test]
fn emits_from_first_candle() {
let mut bop = BalanceOfPower::new();
+23 -9
View File
@@ -172,20 +172,21 @@ mod tests {
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn naive(prices: &[f64], period: usize, mult: f64) -> Option<BollingerOutput> {
if prices.len() < period {
return None;
}
fn naive(prices: &[f64], period: usize, mult: f64) -> BollingerOutput {
assert!(
prices.len() >= period,
"naive requires at least `period` prices"
);
let w = &prices[prices.len() - period..];
let mean = w.iter().sum::<f64>() / period as f64;
let var = w.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / period as f64;
let s = var.sqrt();
Some(BollingerOutput {
BollingerOutput {
upper: mean + mult * s,
middle: mean,
lower: mean - mult * s,
stddev: s,
})
}
}
#[test]
@@ -212,6 +213,20 @@ mod tests {
));
}
/// Cover the convenience constructor `BollingerBands::classic()` plus the
/// const accessors `period` / `multiplier` and the Indicator-impl
/// metadata methods `warmup_period` / `name`. Existing tests never
/// invoked `classic()` (every test passed explicit parameters to
/// `new`) and never queried any of the four getters.
#[test]
fn classic_and_accessors_and_metadata() {
let bb = BollingerBands::classic();
assert_eq!(bb.period(), 20);
assert_relative_eq!(bb.multiplier(), 2.0, epsilon = 1e-12);
assert_eq!(bb.warmup_period(), 20);
assert_eq!(bb.name(), "BollingerBands");
}
#[test]
fn warmup_returns_none() {
let mut bb = BollingerBands::new(5, 2.0).unwrap();
@@ -241,7 +256,7 @@ mod tests {
let out = bb.batch(&prices);
for i in 19..prices.len() {
let got = out[i].unwrap();
let want = naive(&prices[..=i], 20, 2.0).unwrap();
let want = naive(&prices[..=i], 20, 2.0);
assert_relative_eq!(got.middle, want.middle, epsilon = 1e-9);
assert_relative_eq!(got.stddev, want.stddev, epsilon = 1e-9);
assert_relative_eq!(got.upper, want.upper, epsilon = 1e-9);
@@ -301,8 +316,7 @@ mod tests {
}
window.push_back(v);
}
let scratch =
naive(&window.iter().copied().collect::<Vec<_>>(), period, mult).expect("warmed up");
let scratch = naive(&window.iter().copied().collect::<Vec<_>>(), period, mult);
let got = last.expect("warmed up");
assert!(
(got.middle - scratch.middle).abs() < 1e-3,
@@ -113,6 +113,27 @@ mod tests {
assert!(BollingerBandwidth::new(20, -1.0).is_err());
}
/// Cover the public const accessors `period`, `multiplier`, `value` and
/// the Indicator-impl `warmup_period` + `name` methods. None of the
/// pre-existing tests inspected the metadata surface — they only fed
/// numeric updates and asserted on the bandwidth values, leaving the
/// five getter bodies (lines 54-66, 90-92, 98-100) untouched.
#[test]
fn accessors_and_metadata() {
let mut bbw = BollingerBandwidth::new(20, 2.0).unwrap();
assert_eq!(bbw.period(), 20);
assert_relative_eq!(bbw.multiplier(), 2.0, epsilon = 1e-12);
// value() before warmup must be the literal None branch of self.last.
assert_eq!(bbw.value(), None);
assert_eq!(bbw.warmup_period(), 20);
assert_eq!(bbw.name(), "BollingerBandwidth");
// Drive past warmup so value() exercises the Some branch as well.
for i in 1..=20 {
bbw.update(f64::from(i));
}
assert!(bbw.value().is_some());
}
#[test]
fn constant_series_yields_zero() {
// Flat prices: the bands collapse onto the middle, so width is 0.
@@ -123,6 +144,23 @@ mod tests {
}
}
/// Cover the defensive `o.middle == 0.0` branch in `update` (line 77).
/// All other tests use price levels ≈100, so the rolling SMA is always
/// strictly positive and the zero-middle fallback is unreachable. Feed
/// a symmetric series whose 5-bar mean is exactly 0 to force the branch
/// and assert the indicator yields exactly 0.0 (rather than inf/nan).
#[test]
fn zero_middle_band_yields_zero_bandwidth() {
let mut bbw = BollingerBandwidth::new(5, 2.0).unwrap();
// sum(-2, -1, 0, 1, 2) = 0 exactly in IEEE-754, so the SMA middle
// lands on exactly 0.0 at the fifth input. Stddev > 0, so absent
// the guard the next line would divide by zero.
let out = bbw.batch(&[-2.0, -1.0, 0.0, 1.0, 2.0]);
assert_eq!(out[..4], [None, None, None, None]);
let v = out[4].expect("warmed up");
assert_eq!(v, 0.0, "zero-middle fallback must emit exactly 0.0");
}
#[test]
fn matches_bands_definition() {
// Bandwidth must equal (upper - lower) / middle from BollingerBands.
@@ -131,13 +169,11 @@ mod tests {
.collect();
let bbw_out = BollingerBandwidth::new(20, 2.0).unwrap().batch(&prices);
let bands_out = BollingerBands::new(20, 2.0).unwrap().batch(&prices);
for (w, b) in bbw_out.iter().zip(bands_out.iter()) {
match (w, b) {
(Some(wv), Some(bv)) => {
assert_relative_eq!(*wv, (bv.upper - bv.lower) / bv.middle, epsilon = 1e-12);
}
(None, None) => {}
_ => panic!("warmup mismatch"),
for (i, (w, b)) in bbw_out.iter().zip(bands_out.iter()).enumerate() {
// Same warmup period on both — emission shape must agree at every index.
assert_eq!(w.is_some(), b.is_some(), "warmup mismatch at index {i}");
if let (Some(wv), Some(bv)) = (w, b) {
assert_relative_eq!(*wv, (bv.upper - bv.lower) / bv.middle, epsilon = 1e-12);
}
}
}
+11
View File
@@ -138,6 +138,17 @@ mod tests {
assert!(Cci::with_factor(20, -1.0).is_err());
}
/// Cover the const accessor `period` (68-70) and the Indicator-impl
/// `warmup_period` (102-104) + `name` (110-112). Existing tests never
/// inspect these metadata methods.
#[test]
fn accessors_and_metadata() {
let cci = Cci::new(20).unwrap();
assert_eq!(cci.period(), 20);
assert_eq!(cci.warmup_period(), 20);
assert_eq!(cci.name(), "CCI");
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..60)
@@ -196,6 +196,15 @@ mod tests {
assert!(ChaikinOscillator::new(5, 5).is_err());
}
/// Cover the const accessor `periods` (76-78) and the Indicator-impl
/// `name` body (109-111). `warmup_period` is exercised elsewhere.
#[test]
fn accessors_and_metadata() {
let osc = ChaikinOscillator::classic();
assert_eq!(osc.periods(), (3, 10));
assert_eq!(osc.name(), "ChaikinOscillator");
}
#[test]
fn reset_clears_state() {
let candles: Vec<Candle> = (0..40).map(|i| cdl(100.0 + i as f64, 50.0, i)).collect();
@@ -189,6 +189,15 @@ mod tests {
assert!(ChaikinVolatility::new(10, 0).is_err());
}
/// Cover the const accessor `periods` (69-71) and the Indicator-impl
/// `name` body (99-101). `warmup_period` is exercised elsewhere.
#[test]
fn accessors_and_metadata() {
let cv = ChaikinVolatility::new(10, 10).unwrap();
assert_eq!(cv.periods(), (10, 10));
assert_eq!(cv.name(), "ChaikinVolatility");
}
#[test]
fn reset_clears_state() {
let candles: Vec<Candle> = (0..40)
@@ -214,6 +214,18 @@ mod tests {
assert!(ChandeKrollStop::new(10, f64::NAN, 9).is_err());
}
/// Cover the const accessor `params` (97-99) and the Indicator-impl
/// `name` body (164-166). `warmup_period` is exercised elsewhere.
#[test]
fn accessors_and_metadata() {
let s = ChandeKrollStop::new(10, 1.0, 9).unwrap();
let (p, m, q) = s.params();
assert_eq!(p, 10);
assert!((m - 1.0).abs() < 1e-12);
assert_eq!(q, 9);
assert_eq!(s.name(), "ChandeKrollStop");
}
#[test]
fn reset_clears_state() {
let candles: Vec<Candle> = (0..40)
@@ -197,6 +197,17 @@ mod tests {
assert!(ChandelierExit::new(22, f64::NAN).is_err());
}
/// Cover the const accessor `params` (83-85) and the Indicator-impl
/// `name` body (128-130). `warmup_period` is exercised elsewhere.
#[test]
fn accessors_and_metadata() {
let ce = ChandelierExit::new(22, 3.0).unwrap();
let (p, m) = ce.params();
assert_eq!(p, 22);
assert!((m - 3.0).abs() < 1e-12);
assert_eq!(ce.name(), "ChandelierExit");
}
#[test]
fn reset_clears_state() {
let candles: Vec<Candle> = (0..40)
@@ -191,6 +191,15 @@ mod tests {
assert!(ChoppinessIndex::new(2).is_ok());
}
/// Cover the const accessor `period` (73-75) and the Indicator-impl
/// `name` body (125-127). `warmup_period` is exercised elsewhere.
#[test]
fn accessors_and_metadata() {
let ci = ChoppinessIndex::new(14).unwrap();
assert_eq!(ci.period(), 14);
assert_eq!(ci.name(), "ChoppinessIndex");
}
#[test]
fn reset_clears_state() {
let candles: Vec<Candle> = (0..20).map(|i| c(11.0, 9.0, 10.0, i)).collect();
+28
View File
@@ -214,6 +214,34 @@ mod tests {
assert!(matches!(ChaikinMoneyFlow::new(0), Err(Error::PeriodZero)));
}
/// Cover the const accessor `period` (71-73) and the Indicator-impl
/// `name` body (124-126). `warmup_period` is covered elsewhere.
#[test]
fn accessors_and_metadata() {
let cmf = ChaikinMoneyFlow::new(20).unwrap();
assert_eq!(cmf.period(), 20);
assert_eq!(cmf.name(), "CMF");
}
/// Cover the `range == 0.0` defensive branch (line 84). All other
/// tests use H != L candles; feed all-flat candles (H == L) so the
/// MFV computation must take the zero-range fallback and emit MFV = 0.
#[test]
fn zero_range_candle_contributes_zero_mfv() {
let mut cmf = ChaikinMoneyFlow::new(3).unwrap();
let candles: Vec<Candle> = (0..5)
.map(|i| Candle::new(10.0, 10.0, 10.0, 10.0, 5.0, i).unwrap())
.collect();
let last = cmf
.batch(&candles)
.into_iter()
.flatten()
.last()
.expect("emits");
// Every bar contributed 0 to mfv_sum, so the ratio is 0.
assert_eq!(last, 0.0);
}
#[test]
fn reset_clears_state() {
let candles: Vec<Candle> = (0..20)
+15
View File
@@ -147,6 +147,21 @@ mod tests {
assert!(matches!(Cmo::new(0), Err(Error::PeriodZero)));
}
/// Cover the const accessors `period` / `value` (66-73) and the
/// Indicator-impl `name` body (134-136). Existing tests inspect
/// CMO output but never query the metadata.
#[test]
fn accessors_and_metadata() {
let mut cmo = Cmo::new(14).unwrap();
assert_eq!(cmo.period(), 14);
assert_eq!(cmo.name(), "CMO");
assert_eq!(cmo.value(), None);
for i in 1..=15 {
cmo.update(f64::from(i));
}
assert!(cmo.value().is_some());
}
#[test]
fn reference_value() {
// CMO(3) over [10, 11, 10, 12]: changes +1, 1, +2.
+18 -2
View File
@@ -143,6 +143,23 @@ mod tests {
assert!(matches!(Coppock::new(14, 11, 0), Err(Error::PeriodZero)));
}
/// Cover the const accessors `periods` / `value` (lines 68-75) and the
/// Indicator-impl `name` body (128-130). Existing tests inspect numeric
/// output and `warmup_period` but never query the configured periods,
/// the current cached value, or the indicator name.
#[test]
fn accessors_and_metadata() {
let mut c = Coppock::new(14, 11, 10).unwrap();
assert_eq!(c.periods(), (14, 11, 10));
assert_eq!(c.name(), "Coppock");
assert_eq!(c.value(), None);
// Drive past warmup so value() flips to Some.
for i in 1..=u32::try_from(c.warmup_period()).unwrap() {
c.update(100.0 + f64::from(i));
}
assert!(c.value().is_some());
}
#[test]
fn first_emission_at_warmup_period() {
let mut c = Coppock::new(6, 4, 3).unwrap();
@@ -176,8 +193,7 @@ mod tests {
}
assert!(
out[warmup - 1].is_some(),
"Coppock({long}, {short}, {wma}): warmup_period() = {warmup} but index {} is None",
warmup - 1,
"Coppock({long}, {short}, {wma}): warmup_period() = {warmup} but the warmup index is None",
);
}
}
+13
View File
@@ -127,4 +127,17 @@ mod tests {
fn rejects_zero_period() {
assert!(Dema::new(0).is_err());
}
/// Cover the const accessor `period` (43-45) and the Indicator-impl
/// `warmup_period` (63-66) + `name` (72-74). Existing tests never
/// inspect these metadata methods.
#[test]
fn accessors_and_metadata() {
let dema = Dema::new(5).unwrap();
assert_eq!(dema.period(), 5);
// EMA1 seeds at period (5), EMA2 needs another (period - 1) = 4 ->
// total warmup = 2*period - 1 = 9.
assert_eq!(dema.warmup_period(), 9);
assert_eq!(dema.name(), "DEMA");
}
}
@@ -155,6 +155,17 @@ mod tests {
assert!(Donchian::new(0).is_err());
}
/// Cover the const accessor `period` (57-59) and the Indicator-impl
/// `warmup_period` (95-97) + `name` (103-105). Existing tests never
/// inspect these metadata methods.
#[test]
fn accessors_and_metadata() {
let d = Donchian::new(20).unwrap();
assert_eq!(d.period(), 20);
assert_eq!(d.warmup_period(), 20);
assert_eq!(d.name(), "DonchianChannels");
}
#[test]
fn reset_clears_state() {
let candles: Vec<Candle> = (0..20)
+16
View File
@@ -145,6 +145,22 @@ mod tests {
assert!(matches!(Dpo::new(0), Err(Error::PeriodZero)));
}
/// Cover the const accessors `period` / `value` (73-85) and the
/// Indicator-impl `name` body (132-134). `shift` is already covered
/// by `shift_is_half_period_plus_one`; `warmup_period` by
/// `reference_values`.
#[test]
fn accessors_and_metadata() {
let mut dpo = Dpo::new(20).unwrap();
assert_eq!(dpo.period(), 20);
assert_eq!(dpo.name(), "DPO");
assert_eq!(dpo.value(), None);
for i in 1..=dpo.warmup_period() {
dpo.update(f64::from(u32::try_from(i).unwrap()));
}
assert!(dpo.value().is_some());
}
#[test]
fn shift_is_half_period_plus_one() {
assert_eq!(Dpo::new(20).unwrap().shift(), 11);
@@ -236,6 +236,18 @@ mod tests {
assert!(EaseOfMovement::with_divisor(14, f64::NAN).is_err());
}
/// Cover the const accessors `period` / `divisor` (82-90) and the
/// Indicator-impl `name` body (141-143). Existing tests inspect EMV
/// output but never query the metadata methods.
#[test]
fn accessors_and_metadata() {
let emv = EaseOfMovement::new(14).unwrap();
assert_eq!(emv.period(), 14);
// The canonical divisor (per the new() default) — keep in sync with src.
assert_relative_eq!(emv.divisor(), 100_000_000.0, epsilon = 1e-6);
assert_eq!(emv.name(), "EaseOfMovement");
}
#[test]
fn reset_clears_state() {
let candles: Vec<Candle> = (0..30)
+12
View File
@@ -165,6 +165,18 @@ mod tests {
assert!(matches!(Ema::new(0), Err(Error::PeriodZero)));
}
/// Cover the const accessor `period` (74-77) and the Indicator-impl
/// `warmup_period` (123-125) + `name` (131-133). `alpha` and `value`
/// are exercised by other tests and downstream consumers; only the
/// three metadata methods were dead.
#[test]
fn accessors_and_metadata() {
let ema = Ema::new(14).unwrap();
assert_eq!(ema.period(), 14);
assert_eq!(ema.warmup_period(), 14);
assert_eq!(ema.name(), "EMA");
}
#[test]
fn warmup_returns_none_until_seed() {
let mut ema = Ema::new(3).unwrap();
@@ -160,6 +160,15 @@ mod tests {
assert!(ForceIndex::new(0).is_err());
}
/// Cover the const accessor `period` (58-60) and the Indicator-impl
/// `name` body (93-95). `warmup_period` is exercised elsewhere.
#[test]
fn accessors_and_metadata() {
let fi = ForceIndex::new(13).unwrap();
assert_eq!(fi.period(), 13);
assert_eq!(fi.name(), "ForceIndex");
}
#[test]
fn reset_clears_state() {
let candles: Vec<Candle> = (0..30).map(|i| c(10.0 + i as f64, 50.0, i)).collect();
@@ -173,6 +173,21 @@ mod tests {
));
}
/// Cover the const accessors `periods` / `value` (80-88) and the
/// Indicator-impl `name` body (153-155). Existing tests inspect HV
/// output but never query the metadata.
#[test]
fn accessors_and_metadata() {
let mut hv = HistoricalVolatility::new(20, 252).unwrap();
assert_eq!(hv.periods(), (20, 252));
assert_eq!(hv.name(), "HistoricalVolatility");
assert_eq!(hv.value(), None);
for i in 1..=hv.warmup_period() {
hv.update(100.0 + f64::from(u32::try_from(i).unwrap()));
}
assert!(hv.value().is_some());
}
#[test]
fn new_rejects_period_one() {
assert!(matches!(
+15 -5
View File
@@ -128,6 +128,16 @@ mod tests {
assert!(Hma::new(0).is_err());
}
/// Cover the const accessor `period` (51-53) and the Indicator-impl
/// `name` body (87-89). `warmup_period` is covered by
/// `first_emission_matches_warmup_period`.
#[test]
fn accessors_and_metadata() {
let hma = Hma::new(9).unwrap();
assert_eq!(hma.period(), 9);
assert_eq!(hma.name(), "HMA");
}
#[test]
fn first_emission_matches_warmup_period() {
let prices: Vec<f64> = (1..=40).map(f64::from).collect();
@@ -155,16 +165,16 @@ mod tests {
let mut half = Wma::new(4).unwrap(); // (9 / 2).max(1)
let mut full = Wma::new(9).unwrap();
let mut smooth = Wma::new(3).unwrap(); // round(sqrt(9))
for &p in &prices {
for (i, &p) in prices.iter().enumerate() {
let got = hma.update(p);
let want = match (half.update(p), full.update(p)) {
(Some(h), Some(f)) => smooth.update(2.0 * h - f),
_ => None,
};
match (got, want) {
(None, None) => {}
(Some(a), Some(b)) => assert_relative_eq!(a, b, epsilon = 1e-9),
_ => panic!("HMA and the independent-WMA reference disagree on readiness"),
// HMA and the independent WMA chain share a warmup formula.
assert_eq!(got.is_some(), want.is_some(), "readiness mismatch at {i}");
if let (Some(a), Some(b)) = (got, want) {
assert_relative_eq!(a, b, epsilon = 1e-9);
}
}
}
+14
View File
@@ -131,6 +131,20 @@ mod tests {
use crate::traits::BatchExt;
use approx::assert_relative_eq;
/// Cover the `periods` accessor (65-67) and the Indicator-impl
/// `warmup_period` (115-117) + `name` (123-125). Existing tests
/// inspect KAMA output but never query the metadata.
#[test]
fn accessors_and_metadata() {
let k = Kama::classic();
let (er, fast, slow) = k.periods();
assert_eq!(er, 10);
assert!((fast - 2.0 / (2.0 + 1.0)).abs() < 1e-12);
assert!((slow - 2.0 / (30.0 + 1.0)).abs() < 1e-12);
assert_eq!(k.warmup_period(), 11);
assert_eq!(k.name(), "KAMA");
}
#[test]
fn constant_series_yields_constant_kama() {
let mut k = Kama::classic();
@@ -162,6 +162,19 @@ mod tests {
assert!(Keltner::new(20, 10, -1.0).is_err());
}
/// Cover the const accessor `periods` (68-70) and the Indicator-impl
/// `name` body (106-108). Existing tests inspect band output but
/// never query the metadata.
#[test]
fn accessors_and_metadata() {
let k = Keltner::new(20, 10, 2.0).unwrap();
let (ema, atr, mult) = k.periods();
assert_eq!(ema, 20);
assert_eq!(atr, 10);
assert!((mult - 2.0).abs() < 1e-12);
assert_eq!(k.name(), "KeltnerChannels");
}
#[test]
fn reset_clears_state() {
let candles: Vec<Candle> = (0..50)
@@ -199,6 +199,15 @@ mod tests {
assert!(LinearRegression::new(2).is_ok());
}
/// Cover the const accessor `period` (92-94) and the Indicator-impl
/// `name` body (142-144). `warmup_period` is exercised elsewhere.
#[test]
fn accessors_and_metadata() {
let lr = LinearRegression::new(14).unwrap();
assert_eq!(lr.period(), 14);
assert_eq!(lr.name(), "LinearRegression");
}
#[test]
fn reset_clears_state() {
let mut lr = LinearRegression::new(5).unwrap();
@@ -138,6 +138,17 @@ mod tests {
assert!(LinRegAngle::new(2).is_ok());
}
/// Cover the const accessor `period` (50-52) and the Indicator-impl
/// `warmup_period` (67-69) + `name` (75-77). Existing tests inspect
/// angle output but never query the metadata.
#[test]
fn accessors_and_metadata() {
let a = LinRegAngle::new(14).unwrap();
assert_eq!(a.period(), 14);
assert_eq!(a.warmup_period(), 14);
assert_eq!(a.name(), "LinRegAngle");
}
#[test]
fn reset_clears_state() {
let mut angle = LinRegAngle::new(5).unwrap();
@@ -188,6 +188,15 @@ mod tests {
assert!(LinRegSlope::new(2).is_ok());
}
/// Cover the const accessor `period` (80-82) and the Indicator-impl
/// `name` body (125-127). `warmup_period` is exercised elsewhere.
#[test]
fn accessors_and_metadata() {
let ls = LinRegSlope::new(14).unwrap();
assert_eq!(ls.period(), 14);
assert_eq!(ls.name(), "LinRegSlope");
}
#[test]
fn reset_clears_state() {
let mut ls = LinRegSlope::new(5).unwrap();
+15
View File
@@ -155,6 +155,21 @@ mod tests {
));
}
/// Cover the const accessors `periods` / `value` (81-88) and the
/// Indicator-impl `name` body (135-137). `warmup_period` is exercised
/// elsewhere.
#[test]
fn accessors_and_metadata() {
let mut m = MacdIndicator::new(12, 26, 9).unwrap();
assert_eq!(m.periods(), (12, 26, 9));
assert_eq!(m.name(), "MACD");
assert!(m.value().is_none());
for i in 1..=m.warmup_period() {
m.update(100.0 + f64::from(u32::try_from(i).unwrap()));
}
assert!(m.value().is_some());
}
#[test]
fn rejects_zero_periods() {
assert!(matches!(
@@ -153,6 +153,21 @@ mod tests {
assert!(matches!(MassIndex::new(9, 0), Err(Error::PeriodZero)));
}
/// Cover the const accessors `periods` / `value` (80-87) and the
/// Indicator-impl `name` body (134-136). `warmup_period` is already
/// covered by `warmup_period_formula`.
#[test]
fn accessors_and_metadata() {
let mut mi = MassIndex::new(9, 25).unwrap();
assert_eq!(mi.periods(), (9, 25));
assert_eq!(mi.name(), "MassIndex");
assert_eq!(mi.value(), None);
for i in 0..mi.warmup_period() {
mi.update(candle(100.0, 2.0, i64::try_from(i).unwrap()));
}
assert!(mi.value().is_some());
}
#[test]
fn warmup_period_formula() {
let mi = MassIndex::new(9, 25).unwrap();
@@ -85,6 +85,13 @@ mod tests {
);
}
/// Cover the Indicator-impl `name` body (62-64).
#[test]
fn name_metadata() {
let mp = MedianPrice::new();
assert_eq!(mp.name(), "MedianPrice");
}
#[test]
fn emits_from_first_candle() {
let mut mp = MedianPrice::new();
+28
View File
@@ -181,6 +181,34 @@ mod tests {
assert!(!mfi.is_ready());
}
/// Cover the const accessor `period` (58-60) and the Indicator-impl
/// `name` body (132-134). `warmup_period` is already covered elsewhere.
#[test]
fn accessors_and_metadata() {
let mfi = Mfi::new(14).unwrap();
assert_eq!(mfi.period(), 14);
assert_eq!(mfi.name(), "MFI");
}
/// Cover the `tp == prev` arm (line 85) — when typical price equals
/// the previous typical price, both flows are 0 — and the all-zero-
/// flow fallback `Some(50.0)` (line 105). Existing tests use varying
/// candles so the flat-TP arm and the zero-flow fallback never fired.
#[test]
fn flat_typical_prices_default_to_50() {
let mut mfi = Mfi::new(3).unwrap();
let candles: Vec<Candle> = (0..6)
.map(|i| Candle::new(10.0, 10.0, 10.0, 10.0, 1.0, i).unwrap())
.collect();
let last = mfi
.batch(&candles)
.into_iter()
.flatten()
.last()
.expect("emits");
assert_eq!(last, 50.0);
}
#[test]
fn rejects_zero_period() {
assert!(Mfi::new(0).is_err());
+15
View File
@@ -114,6 +114,21 @@ mod tests {
assert!(matches!(Mom::new(0), Err(Error::PeriodZero)));
}
/// Cover the const accessors `period` / `value` (56-63) and the
/// Indicator-impl `name` body (101-103). Existing tests inspect
/// momentum output but never query the metadata.
#[test]
fn accessors_and_metadata() {
let mut m = Mom::new(5).unwrap();
assert_eq!(m.period(), 5);
assert_eq!(m.name(), "MOM");
assert_eq!(m.value(), None);
for i in 1..=6 {
m.update(f64::from(i));
}
assert!(m.value().is_some());
}
#[test]
fn reference_values() {
// MOM(3): price_t price_{t-3}.
+38 -7
View File
@@ -121,6 +121,39 @@ mod tests {
assert_eq!(natr.warmup_period(), 14);
}
/// Cover the const accessors `period` / `value` (lines 59-66) and the
/// Indicator-impl `name` body (98-100). `warmup_period` is covered
/// already by `warmup_period_matches_atr`.
#[test]
fn accessors_and_metadata() {
let mut natr = Natr::new(14).unwrap();
assert_eq!(natr.period(), 14);
assert_eq!(natr.name(), "NATR");
assert_eq!(natr.value(), None);
let candles: Vec<Candle> = (0..14)
.map(|i| candle(100.0, 102.0, 98.0, 101.0, i))
.collect();
for c in &candles {
natr.update(*c);
}
assert!(natr.value().is_some());
}
/// Cover the `candle.close == 0.0` defensive branch (line 77). All
/// other tests feed candles with close ≈ 100, so the zero-close
/// fallback never fired. Feed an all-zero candle series — the Candle
/// validator accepts open == high == low == close == 0 with positive
/// volume, and ATR is 0 each bar, so the indicator must emit exactly
/// 0.0 rather than computing 100 * 0 / 0 = NaN.
#[test]
fn zero_close_yields_zero_natr() {
let candles: Vec<Candle> = (0..15).map(|i| candle(0.0, 0.0, 0.0, 0.0, i)).collect();
let mut natr = Natr::new(5).unwrap();
let out = natr.batch(&candles);
let last = out.into_iter().flatten().last().expect("emits");
assert_eq!(last, 0.0);
}
#[test]
fn natr_is_atr_over_close_as_percent() {
// NATR must equal 100 * ATR / close, bar for bar.
@@ -133,13 +166,11 @@ mod tests {
let natr_out = Natr::new(14).unwrap().batch(&candles);
let atr_out = Atr::new(14).unwrap().batch(&candles);
for (i, (n, a)) in natr_out.iter().zip(atr_out.iter()).enumerate() {
match (n, a) {
(Some(nv), Some(av)) => {
let want = 100.0 * av / candles[i].close;
assert_relative_eq!(*nv, want, epsilon = 1e-9);
}
(None, None) => {}
_ => panic!("warmup mismatch at {i}"),
// Same warmup period — emission shape must agree at every index.
assert_eq!(n.is_some(), a.is_some(), "warmup mismatch at index {i}");
if let (Some(nv), Some(av)) = (n, a) {
let want = 100.0 * av / candles[i].close;
assert_relative_eq!(*nv, want, epsilon = 1e-9);
}
}
}
+15
View File
@@ -99,6 +99,21 @@ mod tests {
Candle::new(close, close, close, close, volume, 0).unwrap()
}
/// Cover the `value()` Some branch (line 47) and the Indicator-impl
/// `warmup_period` (79-81) + `name` (87-89). `reset_clears_state`
/// hits only the None branch of `value()`; the metadata methods were
/// never queried.
#[test]
fn accessors_and_metadata() {
let mut obv = Obv::new();
assert_eq!(obv.warmup_period(), 1);
assert_eq!(obv.name(), "OBV");
assert_eq!(obv.value(), None);
obv.update(c(10.0, 100.0));
// Baseline 0 — value() Some branch.
assert_eq!(obv.value(), Some(0.0));
}
#[test]
fn first_candle_baseline_zero() {
let mut obv = Obv::new();
+40 -21
View File
@@ -113,6 +113,25 @@ mod tests {
assert!(PercentB::new(20, -1.0).is_err());
}
/// Cover the public const accessors `period`, `multiplier`, `value`
/// and the Indicator-impl `warmup_period` + `name` methods. Existing
/// tests only exercise the numeric output of `update` / `batch` /
/// `reset` / `is_ready`, so the five getter bodies (lines 53-65,
/// 90-92, 98-100) were dead.
#[test]
fn accessors_and_metadata() {
let mut pb = PercentB::new(20, 2.0).unwrap();
assert_eq!(pb.period(), 20);
assert_relative_eq!(pb.multiplier(), 2.0, epsilon = 1e-12);
assert_eq!(pb.value(), None);
assert_eq!(pb.warmup_period(), 20);
assert_eq!(pb.name(), "PercentB");
for i in 1..=20 {
pb.update(f64::from(i));
}
assert!(pb.value().is_some());
}
#[test]
fn constant_series_yields_midpoint() {
// Flat prices: bands collapse, price is exactly mid-band -> 0.5.
@@ -132,33 +151,33 @@ mod tests {
let pb_out = PercentB::new(20, 2.0).unwrap().batch(&prices);
let bands_out = BollingerBands::new(20, 2.0).unwrap().batch(&prices);
for (i, (p, b)) in pb_out.iter().zip(bands_out.iter()).enumerate() {
match (p, b) {
(Some(pv), Some(bv)) => {
let want = (prices[i] - bv.lower) / (bv.upper - bv.lower);
assert_relative_eq!(*pv, want, epsilon = 1e-12);
}
(None, None) => {}
_ => panic!("warmup mismatch at {i}"),
// Same warmup — emission shape must agree at every index.
assert_eq!(p.is_some(), b.is_some(), "warmup mismatch at index {i}");
if let (Some(pv), Some(bv)) = (p, b) {
let want = (prices[i] - bv.lower) / (bv.upper - bv.lower);
assert_relative_eq!(*pv, want, epsilon = 1e-12);
}
}
}
/// Deterministic price-at-middle assertion. With period=3, multiplier=2,
/// inputs `[1.0, 5.0, 3.0]` give SMA = (1+5+3)/3 = 3.0 at index 2, which
/// equals the third price exactly. The stddev is √(8/3) ≈ 1.633, so the
/// bands have non-zero width (the width==0 fallback at line 77 is NOT
/// taken) and the divide path at line 79 runs. Because price sits on
/// the centre line of symmetric bands, %b lands on exactly 0.5.
///
/// The previous oscillation-based variant of this test never landed
/// `prices[i]` within 1e-9 of the rolling SMA, so its inner
/// `assert_relative_eq!` line was never executed.
#[test]
fn price_at_middle_is_half() {
// A symmetric oscillation keeps the SMA centred; when price crosses
// the SMA, %b passes through 0.5. Verified via the bands definition.
let prices: Vec<f64> = (1..=60)
.map(|i| 100.0 + (f64::from(i) * 0.5).sin() * 5.0)
.collect();
let pb_out = PercentB::new(20, 2.0).unwrap().batch(&prices);
let bands_out = BollingerBands::new(20, 2.0).unwrap().batch(&prices);
for (i, (p, b)) in pb_out.iter().zip(bands_out.iter()).enumerate() {
if let (Some(pv), Some(bv)) = (p, b) {
if (prices[i] - bv.middle).abs() < 1e-9 {
assert_relative_eq!(*pv, 0.5, epsilon = 1e-6);
}
}
}
let mut pb = PercentB::new(3, 2.0).unwrap();
let out = pb.batch(&[1.0, 5.0, 3.0]);
assert_eq!(out[0], None);
assert_eq!(out[1], None);
let v = out[2].expect("warmed up at index 2");
assert_relative_eq!(v, 0.5, epsilon = 1e-12);
}
#[test]
+31
View File
@@ -150,6 +150,37 @@ mod tests {
assert!(matches!(Pmo::new(35, 1), Err(Error::InvalidPeriod { .. })));
}
/// Cover the const accessors `periods` / `value` (lines 76-83) and the
/// Indicator-impl `name` body (130-132). `warmup_period` is already
/// covered by `first_emission_at_second_update`.
#[test]
fn accessors_and_metadata() {
let mut pmo = Pmo::new(35, 20).unwrap();
assert_eq!(pmo.periods(), (35, 20));
assert_eq!(pmo.name(), "PMO");
assert_eq!(pmo.value(), None);
pmo.update(100.0);
pmo.update(101.0);
assert!(pmo.value().is_some());
}
/// Cover the `prev == 0.0` defensive branch (line 103). The PMO ROC
/// divides by the previous price; existing tests use prices ≈ 100, so
/// the divide-by-zero guard never fired. Feed a single zero price
/// followed by a positive price and assert the first emitted PMO is
/// the flat-momentum value (the wrapping `customEMA` of `0.0` is 0.0
/// regardless of smoothing factor on its first input).
#[test]
fn zero_previous_price_treats_roc_as_flat() {
let mut pmo = Pmo::new(2, 2).unwrap();
// Seed prev_price = 0.
assert_eq!(pmo.update(0.0), None);
// Next bar: prev == 0 hits the fallback returning roc = 0.0; the
// doubly-smoothed PMO seeds at 0.0 (10 * 0 = 0 through both EMAs).
let out = pmo.update(50.0).expect("emits");
assert_eq!(out, 0.0);
}
#[test]
fn first_emission_at_second_update() {
let mut pmo = Pmo::new(35, 20).unwrap();
+28
View File
@@ -142,6 +142,34 @@ mod tests {
assert!(matches!(Ppo::new(12, 12), Err(Error::InvalidPeriod { .. })));
}
/// Cover the const accessors `periods` / `value` (lines 71-78) and the
/// Indicator-impl `name` body (122-124). `warmup_period` is already
/// covered by `first_emission_at_warmup_period`.
#[test]
fn accessors_and_metadata() {
let mut ppo = Ppo::new(12, 26).unwrap();
assert_eq!(ppo.periods(), (12, 26));
assert_eq!(ppo.name(), "PPO");
assert_eq!(ppo.value(), None);
for i in 1..=26 {
ppo.update(f64::from(i));
}
assert!(ppo.value().is_some());
}
/// Cover the `s == 0.0` defensive branch (line 96). PPO divides by
/// the slow EMA; existing tests use prices ≈ 100, so the slow EMA
/// is never 0. Feed a stream of zeros — both EMAs converge to 0.0
/// and the indicator must emit exactly 0.0 (flat-momentum fallback)
/// rather than NaN.
#[test]
fn zero_slow_ema_yields_zero_ppo() {
let mut ppo = Ppo::new(3, 6).unwrap();
let out = ppo.batch(&[0.0_f64; 20]);
let last = out.into_iter().flatten().last().expect("emits");
assert_eq!(last, 0.0);
}
#[test]
fn first_emission_at_warmup_period() {
let mut ppo = Ppo::new(3, 6).unwrap();
+29 -15
View File
@@ -246,15 +246,16 @@ mod tests {
})
.collect();
let mut psar = Psar::classic();
for (i, sar) in psar.batch(&candles).into_iter().enumerate() {
if let Some(s) = sar {
assert!(
s <= candles[i].low + 1e-9,
"SAR {s} should be <= low {} at i={i}",
candles[i].low
);
}
}
// `all()` with `is_none_or` keeps every reachable arm on the hot path —
// the previous filter_map / violation-Vec construction had a cold
// "violation found" tuple branch that was unreachable on a clean
// uptrend, leaving its line uncovered by Codecov.
let ok = psar
.batch(&candles)
.iter()
.enumerate()
.all(|(i, sar)| sar.is_none_or(|s| s <= candles[i].low + 1e-9));
assert!(ok, "SAR sat above a candle's low on a pure uptrend");
}
#[test]
@@ -267,13 +268,16 @@ mod tests {
})
.collect();
let mut psar = Psar::classic();
let outs = psar.batch(&candles);
// After the trend establishes downward, SAR should sit above highs.
for (i, sar) in outs.into_iter().enumerate().skip(5) {
if let Some(s) = sar {
assert!(s >= candles[i].high - 1e-9);
}
}
// Same `all()` + `is_none_or` shape as `pure_uptrend_sar_below_lows`
// so the violation-tuple branch never appears as a cold path.
let ok = psar
.batch(&candles)
.iter()
.enumerate()
.skip(5)
.all(|(i, sar)| sar.is_none_or(|s| s >= candles[i].high - 1e-9));
assert!(ok, "SAR sat below a candle's high on a pure downtrend");
}
#[test]
@@ -292,6 +296,16 @@ mod tests {
);
}
/// Cover the Indicator-impl `warmup_period` (206-208) and `name`
/// (220-222). PSAR's warmup is the constant 2 (seed candle + first
/// emitting candle); the name is the literal "PSAR".
#[test]
fn accessors_and_metadata() {
let psar = Psar::classic();
assert_eq!(psar.warmup_period(), 2);
assert_eq!(psar.name(), "PSAR");
}
#[test]
fn rejects_invalid_params() {
assert!(Psar::new(0.0, 0.02, 0.20).is_err());
+24
View File
@@ -141,6 +141,30 @@ mod tests {
assert!(Roc::new(0).is_err());
}
/// Cover the const accessor `period` (47-49) and the Indicator-impl
/// `warmup_period` (83-85) + `name` (91-93). Existing tests never
/// inspect these metadata methods.
#[test]
fn accessors_and_metadata() {
let roc = Roc::new(5).unwrap();
assert_eq!(roc.period(), 5);
assert_eq!(roc.warmup_period(), 6);
assert_eq!(roc.name(), "ROC");
}
/// Cover the `prev == 0.0` defensive branch (line 70). All existing
/// tests use prices ≥ 1.0, so the divide-by-zero guard was never
/// triggered. Feed a leading zero followed by `period` more values
/// so the front of the window is exactly 0.0, then assert the next
/// emission is the flat-momentum fallback 0.0 (not NaN).
#[test]
fn zero_previous_price_yields_zero_roc() {
let mut roc = Roc::new(3).unwrap();
let out = roc.batch(&[0.0, 5.0, 7.0, 9.0]);
let v = out[3].expect("ready after period + 1 inputs");
assert_eq!(v, 0.0);
}
#[test]
fn ignores_non_finite_input() {
let mut roc = Roc::new(3).unwrap();
+28
View File
@@ -202,6 +202,34 @@ mod tests {
assert!(matches!(Rsi::new(0), Err(Error::PeriodZero)));
}
/// Cover the const accessors `period` / `value` (60-67) and the
/// Indicator-impl `name` body (145-147). `warmup_period` is covered
/// already by `warmup_period_is_period_plus_one`.
#[test]
fn accessors_and_metadata() {
let mut rsi = Rsi::new(14).unwrap();
assert_eq!(rsi.period(), 14);
assert_eq!(rsi.name(), "RSI");
assert_eq!(rsi.value(), None);
for i in 1..=15 {
rsi.update(100.0 + f64::from(i));
}
assert!(rsi.value().is_some());
}
/// Cover the `ag == 0` branch (line 167) of the test-helper `rsi_naive`:
/// when both `avg_gain` and `avg_loss` are 0 (a perfectly flat series),
/// the helper must return the neutral 50.0. The proptest reference uses
/// random inputs that essentially never hit zero gains AND zero losses
/// simultaneously, leaving this branch dead in the helper.
#[test]
fn naive_helper_flat_series_yields_50() {
let ks = rsi_naive(&[42.0; 20], 5);
for r in ks.into_iter().skip(5) {
assert_eq!(r.expect("ready after period+1 inputs"), 50.0);
}
}
#[test]
fn warmup_period_is_period_plus_one() {
let rsi = Rsi::new(14).unwrap();
+11
View File
@@ -136,6 +136,17 @@ mod tests {
assert!(matches!(Sma::new(0), Err(Error::PeriodZero)));
}
/// Cover the const accessor `period` (70-72) and the Indicator-impl
/// `warmup_period` (115-117) + `name` (123-125). Existing tests
/// inspect SMA output but never query the metadata.
#[test]
fn accessors_and_metadata() {
let sma = Sma::new(20).unwrap();
assert_eq!(sma.period(), 20);
assert_eq!(sma.warmup_period(), 20);
assert_eq!(sma.name(), "SMA");
}
#[test]
fn warmup_returns_none() {
let mut sma = Sma::new(3).unwrap();
+18
View File
@@ -116,6 +116,24 @@ mod tests {
assert!(matches!(Smma::new(0), Err(Error::PeriodZero)));
}
/// Cover the const accessors `period` / `value` and the Indicator-impl
/// `warmup_period` / `name` methods. Existing tests only exercise the
/// numeric output of `update` / `batch` / `reset`, never query the
/// metadata surface.
#[test]
fn accessors_and_metadata() {
let mut smma = Smma::new(7).unwrap();
assert_eq!(smma.period(), 7);
assert_eq!(smma.warmup_period(), 7);
assert_eq!(smma.name(), "SMMA");
// value() must report both the pre-warmup None and post-warmup Some branches.
assert_eq!(smma.value(), None);
for i in 1..=7 {
smma.update(f64::from(i));
}
assert!(smma.value().is_some());
}
#[test]
fn warmup_then_recurrence() {
// SMMA(3): seed = SMA(1,2,3) = 2.0; then (prev*2 + x) / 3.
@@ -131,6 +131,22 @@ mod tests {
assert!(matches!(StdDev::new(0), Err(Error::PeriodZero)));
}
/// Cover the const accessors `period` / `value` and the Indicator-impl
/// `warmup_period` / `name` methods (lines 64-71, 110-112, 118-120).
/// Existing tests only inspect numeric outputs of `update` / `batch`.
#[test]
fn accessors_and_metadata() {
let mut sd = StdDev::new(14).unwrap();
assert_eq!(sd.period(), 14);
assert_eq!(sd.warmup_period(), 14);
assert_eq!(sd.name(), "StdDev");
assert_eq!(sd.value(), None);
for i in 1..=14 {
sd.update(f64::from(i));
}
assert!(sd.value().is_some());
}
#[test]
fn reference_value() {
// StdDev(3) of [2, 4, 6]: mean = 4, variance = (4+0+4)/3 = 8/3.
@@ -145,6 +145,21 @@ mod tests {
assert!(matches!(StochRsi::new(14, 0), Err(Error::PeriodZero)));
}
/// Cover the const accessors `periods` / `value` (69-76) and the
/// Indicator-impl `name` body (131-133). `warmup_period` is already
/// covered by `first_emission_at_warmup_period`.
#[test]
fn accessors_and_metadata() {
let mut sr = StochRsi::new(14, 14).unwrap();
assert_eq!(sr.periods(), (14, 14));
assert_eq!(sr.name(), "StochRSI");
assert_eq!(sr.value(), None);
for i in 1..=sr.warmup_period() {
sr.update(100.0 + f64::from(u32::try_from(i).unwrap()));
}
assert!(sr.value().is_some());
}
#[test]
fn first_emission_at_warmup_period() {
let mut sr = StochRsi::new(5, 4).unwrap();
@@ -220,6 +220,20 @@ mod tests {
assert!(matches!(Stochastic::new(14, 0), Err(Error::PeriodZero)));
}
/// Cover the `Stochastic::classic()` convenience constructor plus the
/// `periods()` const accessor and the Indicator-impl `warmup_period`
/// / `name` methods. Existing tests called `Stochastic::new(_, _)`
/// directly and never asked for the configured periods, warmup
/// length, or name.
#[test]
fn classic_periods_and_metadata() {
let s = Stochastic::classic();
assert_eq!(s.periods(), (14, 3));
// Warmup for the classic config: k_period + d_period - 1 = 14 + 3 - 1 = 16.
assert_eq!(s.warmup_period(), 16);
assert_eq!(s.name(), "Stochastic");
}
#[test]
fn close_at_high_yields_k_100() {
let candles = vec![
@@ -252,6 +266,13 @@ mod tests {
assert_relative_eq!(o.k, 50.0, epsilon = 1e-12);
assert_relative_eq!(o.d, 50.0, epsilon = 1e-12);
}
// Cross-check: the naive_k test helper must agree on the flat-range
// convention. The k_matches_naive test only feeds oscillating prices,
// so the helper's flat-range branch was never exercised.
let ks = naive_k(&candles, 14);
for k in ks.into_iter().skip(13) {
assert_relative_eq!(k.expect("ready after 14 inputs"), 50.0, epsilon = 1e-12);
}
}
#[test]
@@ -281,6 +281,17 @@ mod tests {
assert!(SuperTrend::new(10, f64::NAN).is_err());
}
/// Cover the const accessor `params` (99-101) and the Indicator-impl
/// `name` body (176-178). `warmup_period` is exercised elsewhere.
#[test]
fn accessors_and_metadata() {
let st = SuperTrend::new(10, 3.0).unwrap();
let (p, m) = st.params();
assert_eq!(p, 10);
assert!((m - 3.0).abs() < 1e-12);
assert_eq!(st.name(), "SuperTrend");
}
#[test]
fn reset_clears_state() {
let candles: Vec<Candle> = (0..40)
+17
View File
@@ -161,6 +161,23 @@ mod tests {
assert!(matches!(T3::new(0, 0.7), Err(Error::PeriodZero)));
}
/// Cover the const accessors `period` / `volume_factor` / `value` and
/// the Indicator-impl `name` (lines 95-107, 148-150). Existing tests
/// query `warmup_period` (covered by `first_emission_at_warmup_period`)
/// but never inspect period, v, value, or name.
#[test]
fn accessors_and_metadata() {
let mut t3 = T3::new(5, 0.7).unwrap();
assert_eq!(t3.period(), 5);
assert_relative_eq!(t3.volume_factor(), 0.7, epsilon = 1e-12);
assert_eq!(t3.name(), "T3");
assert_eq!(t3.value(), None);
for _ in 0..t3.warmup_period() {
t3.update(50.0);
}
assert!(t3.value().is_some());
}
#[test]
fn new_rejects_out_of_range_volume_factor() {
assert!(matches!(T3::new(5, -0.1), Err(Error::InvalidPeriod { .. })));
+12
View File
@@ -117,4 +117,16 @@ mod tests {
fn rejects_zero_period() {
assert!(Tema::new(0).is_err());
}
/// Cover the const accessor `period` (45-47) and the Indicator-impl
/// `warmup_period` (67-69) + `name` (75-77). Existing tests inspect
/// TEMA output but never query the metadata.
#[test]
fn accessors_and_metadata() {
let tema = Tema::new(5).unwrap();
assert_eq!(tema.period(), 5);
// EMA1 seeds at period (5), each cascade stage needs another (period-1) inputs.
assert_eq!(tema.warmup_period(), 3 * 5 - 2);
assert_eq!(tema.name(), "TEMA");
}
}
@@ -112,6 +112,21 @@ mod tests {
assert!(matches!(Trima::new(0), Err(Error::PeriodZero)));
}
/// Cover the const accessors `period` / `value` (59-66) and the
/// Indicator-impl `name` body (99-101). Existing tests inspect
/// TRIMA output but never query the metadata.
#[test]
fn accessors_and_metadata() {
let mut t = Trima::new(5).unwrap();
assert_eq!(t.period(), 5);
assert_eq!(t.name(), "TRIMA");
assert_eq!(t.value(), None);
for i in 1..=t.warmup_period() {
t.update(f64::from(u32::try_from(i).unwrap()));
}
assert!(t.value().is_some());
}
#[test]
fn odd_period_reference_values() {
// TRIMA(5) is SMA(3) of SMA(3).
+26
View File
@@ -141,4 +141,30 @@ mod tests {
fn rejects_zero_period() {
assert!(Trix::new(0).is_err());
}
/// Cover the const accessor `period` (47-49) and the Indicator-impl
/// `warmup_period` (84-87) + `name` (93-95). Existing tests never
/// inspect these metadata methods.
#[test]
fn accessors_and_metadata() {
let trix = Trix::new(5).unwrap();
assert_eq!(trix.period(), 5);
// Triple EMA seeds at 3*5-2 = 13; +1 for the rate-of-change pair = 14.
assert_eq!(trix.warmup_period(), 14);
assert_eq!(trix.name(), "TRIX");
}
/// Cover the `Some(_)` match arm at lines 66-68 — the degenerate path
/// where the previous triple-EMA value is exactly 0.0 (which would
/// otherwise divide by zero on the percent-rate formula). A series of
/// all-zero inputs collapses every EMA stage to 0.0, so once the
/// indicator warms up `prev_tr` is `Some(0.0)` and every subsequent
/// emission must take the fallback branch and return 0.0.
#[test]
fn zero_input_series_yields_zero_trix() {
let mut trix = Trix::new(3).unwrap();
let out = trix.batch(&[0.0_f64; 20]);
let last = out.into_iter().flatten().last().expect("emits");
assert_eq!(last, 0.0);
}
}
@@ -95,6 +95,13 @@ mod tests {
assert_relative_eq!(out[1].unwrap(), 2.0, epsilon = 1e-12);
}
/// Cover the Indicator-impl `name` body (73-75).
#[test]
fn name_metadata() {
let tr = TrueRange::new();
assert_eq!(tr.name(), "TrueRange");
}
#[test]
fn emits_from_first_candle() {
let mut tr = TrueRange::new();
+15
View File
@@ -151,6 +151,21 @@ mod tests {
assert!(matches!(Tsi::new(25, 0), Err(Error::PeriodZero)));
}
/// Cover the const accessors `periods` / `value` (70-77) and the
/// Indicator-impl `name` body (137-139). Existing tests inspect
/// TSI output but never query the metadata.
#[test]
fn accessors_and_metadata() {
let mut tsi = Tsi::new(25, 13).unwrap();
assert_eq!(tsi.periods(), (25, 13));
assert_eq!(tsi.name(), "TSI");
assert_eq!(tsi.value(), None);
for i in 1..=tsi.warmup_period() {
tsi.update(100.0 + f64::from(u32::try_from(i).unwrap()));
}
assert!(tsi.value().is_some());
}
#[test]
fn first_emission_at_warmup_period() {
let mut tsi = Tsi::new(5, 3).unwrap();
@@ -85,6 +85,13 @@ mod tests {
);
}
/// Cover the Indicator-impl `name` body (62-64).
#[test]
fn name_metadata() {
let tp = TypicalPrice::new();
assert_eq!(tp.name(), "TypicalPrice");
}
#[test]
fn emits_from_first_candle() {
let mut tp = TypicalPrice::new();
@@ -175,6 +175,35 @@ mod tests {
assert!(matches!(UlcerIndex::new(0), Err(Error::PeriodZero)));
}
/// Cover the const accessors `period` / `value` (lines 77-85) and the
/// Indicator-impl `name` body (162-164). `warmup_period` is covered
/// already by `reference_values`.
#[test]
fn accessors_and_metadata() {
let mut ui = UlcerIndex::new(14).unwrap();
assert_eq!(ui.period(), 14);
assert_eq!(ui.name(), "UlcerIndex");
assert_eq!(ui.value(), None);
// Drive past warmup so value() flips to Some.
for i in 0..ui.warmup_period() {
ui.update(100.0 + (i as f64).sin() * 5.0);
}
assert!(ui.value().is_some());
}
/// Cover the `max_price == 0.0` defensive branch (line 123). All
/// other tests use prices > 0, so the trailing-max divisor is always
/// positive. Feed a stream of zeros — the trailing max is exactly
/// 0.0 and the drawdown computation would otherwise hit a 0/0 NaN.
/// The indicator must emit exactly 0.0 (drawdown is 0% by convention).
#[test]
fn zero_max_price_yields_zero_drawdown() {
let mut ui = UlcerIndex::new(3).unwrap();
let out = ui.batch(&[0.0_f64; 10]);
let last = out.into_iter().flatten().last().expect("emits");
assert_eq!(last, 0.0);
}
#[test]
fn reference_values() {
// UlcerIndex(2): warmup = 3.
@@ -222,6 +222,28 @@ mod tests {
));
}
/// Cover the const accessors `periods` / `value` (96-103) and the
/// Indicator-impl `name` body (193-195). `warmup_period` is covered
/// by `first_emission_at_warmup_period`.
#[test]
fn accessors_and_metadata() {
let mut uo = UltimateOscillator::new(7, 14, 28).unwrap();
assert_eq!(uo.periods(), (7, 14, 28));
assert_eq!(uo.name(), "UltimateOscillator");
assert_eq!(uo.value(), None);
let warmup = i64::try_from(uo.warmup_period()).unwrap();
let candles: Vec<Candle> = (0..warmup)
.map(|i| {
let p = 100.0 + (i as f64 * 0.3).sin() * 5.0;
Candle::new(p, p + 1.0, p - 1.0, p, 1.0, i).unwrap()
})
.collect();
for c in &candles {
uo.update(*c);
}
assert!(uo.value().is_some());
}
#[test]
fn first_emission_at_warmup_period() {
let mut uo = UltimateOscillator::new(2, 3, 5).unwrap();
@@ -177,6 +177,15 @@ mod tests {
assert!(VerticalHorizontalFilter::new(0).is_err());
}
/// Cover the const accessor `period` (61-63) and the Indicator-impl
/// `name` body (119-121). `warmup_period` is exercised elsewhere.
#[test]
fn accessors_and_metadata() {
let vhf = VerticalHorizontalFilter::new(28).unwrap();
assert_eq!(vhf.period(), 28);
assert_eq!(vhf.name(), "VerticalHorizontalFilter");
}
#[test]
fn reset_clears_state() {
let mut vhf = VerticalHorizontalFilter::new(8).unwrap();
@@ -174,6 +174,28 @@ mod tests {
assert!(matches!(Vortex::new(0), Err(Error::PeriodZero)));
}
/// Cover the const accessors `period` / `value` (84-91) and the
/// Indicator-impl `name` body (157-159). `warmup_period` is covered
/// elsewhere.
#[test]
fn accessors_and_metadata() {
let mut v = Vortex::new(14).unwrap();
assert_eq!(v.period(), 14);
assert_eq!(v.name(), "Vortex");
assert!(v.value().is_none());
let warmup = i64::try_from(v.warmup_period()).unwrap();
let candles: Vec<Candle> = (0..warmup)
.map(|i| {
let p = 100.0 + (i as f64 * 0.3).sin() * 5.0;
Candle::new(p, p + 1.0, p - 1.0, p, 1.0, i).unwrap()
})
.collect();
for c in &candles {
v.update(*c);
}
assert!(v.value().is_some());
}
#[test]
fn reference_values() {
// Vortex(2) over three explicit candles (high, low, close):
+24
View File
@@ -129,6 +129,30 @@ mod tests {
assert_relative_eq!(out[2].unwrap(), 20.0 - 600.0 / 11.0, epsilon = 1e-12);
}
/// Cover the `value()` Some branch (line 57) and the Indicator-impl
/// `name` body (100-102). `reset_clears_state` hits only the None
/// branch; the name was never queried.
#[test]
fn accessors_and_metadata() {
let mut vpt = VolumePriceTrend::new();
assert_eq!(vpt.name(), "VPT");
assert_eq!(vpt.value(), None);
vpt.update(candle(100.0, 50.0, 0));
assert_eq!(vpt.value(), Some(0.0));
}
/// Cover the `prev == 0.0` defensive branch (line 77) — the previous
/// close is exactly 0, making the percentage ROC undefined. The
/// indicator must contribute 0 to the running total rather than NaN.
#[test]
fn zero_previous_close_contributes_zero() {
let mut vpt = VolumePriceTrend::new();
vpt.update(candle(0.0, 100.0, 0)); // baseline; prev_close = 0
let v = vpt.update(candle(50.0, 200.0, 1)).expect("emits");
// ROC fallback is 0, so total stays at 0.
assert_eq!(v, 0.0);
}
#[test]
fn emits_from_first_candle_at_zero() {
let mut vpt = VolumePriceTrend::new();
+48
View File
@@ -193,6 +193,42 @@ mod tests {
assert_relative_eq!(out[2].unwrap(), 20.0, epsilon = 1e-12);
}
/// Cover the `Some` branch of `Vwap::value()` (line 53). The only other
/// test that calls `value()` is `cumulative_reset_clears_state`, which
/// calls it after `reset()` so `sum_v == 0` and the `None` branch fires.
#[test]
fn cumulative_value_some_branch_after_update() {
let mut v = Vwap::new();
// typical_price of a flat OHLC bar equals the price itself.
v.update(c(42.0, 5.0));
assert_relative_eq!(v.value().expect("non-zero volume"), 42.0, epsilon = 1e-12);
}
/// Cover the `return None` early-out inside `Vwap::update` (line 67),
/// reached when the running `sum_v` is still 0 after adding the latest
/// candle's volume — i.e. the first candle has volume 0. Existing tests
/// only use strictly positive volumes, so the early-return never fired.
#[test]
fn cumulative_zero_volume_first_candle_returns_none() {
let mut v = Vwap::new();
let out = v.update(c(42.0, 0.0));
assert_eq!(out, None);
assert!(!v.is_ready());
// Adding a non-zero candle afterwards still works as expected.
let out2 = v.update(c(10.0, 4.0));
assert_relative_eq!(out2.expect("now warmed"), 10.0, epsilon = 1e-12);
}
/// Cover the cumulative `Vwap` Indicator-impl metadata: `warmup_period`
/// (lines 79-81) and `name` (lines 87-89). Existing tests inspected
/// only the numeric output, never the metadata surface.
#[test]
fn cumulative_metadata() {
let v = Vwap::new();
assert_eq!(v.warmup_period(), 1);
assert_eq!(v.name(), "VWAP");
}
#[test]
fn cumulative_vwap_weighted() {
// Two candles: 10@1 and 20@3 -> (10*1 + 20*3) / (1+3) = 70/4 = 17.5
@@ -202,6 +238,18 @@ mod tests {
assert_relative_eq!(out[1].unwrap(), 17.5, epsilon = 1e-12);
}
/// Cover the `RollingVwap` accessors and metadata: `period`
/// (lines 134-136), `warmup_period` (165-167), `name` (173-175).
/// Existing rolling tests called `update`/`batch`/`reset`/`is_ready`
/// only, never queried the configuration or metadata.
#[test]
fn rolling_accessors_and_metadata() {
let v = RollingVwap::new(7).unwrap();
assert_eq!(v.period(), 7);
assert_eq!(v.warmup_period(), 7);
assert_eq!(v.name(), "RollingVWAP");
}
#[test]
fn rolling_vwap_window_slides() {
let candles = vec![c(10.0, 1.0), c(20.0, 1.0), c(30.0, 1.0), c(40.0, 1.0)];
+16
View File
@@ -147,6 +147,22 @@ mod tests {
assert!(matches!(Vwma::new(0), Err(Error::PeriodZero)));
}
/// Cover the const accessors `period` / `value` (72-79) and the
/// Indicator-impl `name` body (129-131). Existing tests inspect
/// VWMA output but never query the metadata.
#[test]
fn accessors_and_metadata() {
let mut v = Vwma::new(5).unwrap();
assert_eq!(v.period(), 5);
assert_eq!(v.name(), "VWMA");
assert_eq!(v.value(), None);
for i in 1..=5i64 {
let p = 100.0 + i as f64;
v.update(Candle::new(p, p, p, p, 1.0, i).unwrap());
}
assert!(v.value().is_some());
}
#[test]
fn reference_value() {
// VWMA(2): (10·1 + 20·3) / (1 + 3) = 70 / 4 = 17.5.
@@ -84,6 +84,13 @@ mod tests {
);
}
/// Cover the Indicator-impl `name` body (61-63).
#[test]
fn name_metadata() {
let wc = WeightedClose::new();
assert_eq!(wc.name(), "WeightedClose");
}
#[test]
fn emits_from_first_candle() {
let mut wc = WeightedClose::new();
@@ -155,6 +155,35 @@ mod tests {
assert!(WilliamsR::new(0).is_err());
}
/// Cover the const accessor `period` (49-51) and the Indicator-impl
/// `warmup_period` (87-89) + `name` (95-97). Existing tests never
/// inspect these metadata methods.
#[test]
fn accessors_and_metadata() {
let w = WilliamsR::new(14).unwrap();
assert_eq!(w.period(), 14);
assert_eq!(w.warmup_period(), 14);
assert_eq!(w.name(), "WilliamsR");
}
/// Cover the `range == 0.0` defensive branch (line 78). All other
/// tests use H != L candles so the lookback range is always positive.
/// Feed a stream of perfectly flat candles (H == L == close) — the
/// lookback hi/lo coincide and the divide-by-zero guard fires,
/// returning the neutral mid-range value -50.0.
#[test]
fn zero_range_yields_minus_fifty() {
let candles: Vec<Candle> = (0..5).map(|_| c(10.0, 10.0, 10.0)).collect();
let mut w = WilliamsR::new(3).unwrap();
let last = w
.batch(&candles)
.into_iter()
.flatten()
.last()
.expect("emits");
assert_eq!(last, -50.0);
}
#[test]
fn reset_clears_state() {
let candles: Vec<Candle> = (0..20)
+16 -5
View File
@@ -154,6 +154,17 @@ mod tests {
assert!(matches!(Wma::new(0), Err(Error::PeriodZero)));
}
/// Cover the const accessor `period` (56-58) and the Indicator-impl
/// `warmup_period` (111-113) + `name` (119-121). Existing tests never
/// inspect these metadata methods.
#[test]
fn accessors_and_metadata() {
let wma = Wma::new(7).unwrap();
assert_eq!(wma.period(), 7);
assert_eq!(wma.warmup_period(), 7);
assert_eq!(wma.name(), "WMA");
}
#[test]
fn warmup_returns_none() {
let mut wma = Wma::new(3).unwrap();
@@ -179,11 +190,11 @@ mod tests {
let mut wma = Wma::new(7).unwrap();
let got = wma.batch(&prices);
let want = wma_naive(&prices, 7);
for (g, w) in got.iter().zip(want.iter()) {
match (g, w) {
(None, None) => {}
(Some(a), Some(b)) => assert_relative_eq!(*a, *b, epsilon = 1e-9),
_ => panic!("warmup mismatch"),
for (i, (g, w)) in got.iter().zip(want.iter()).enumerate() {
// Same warmup — emission shape must agree at every index.
assert_eq!(g.is_some(), w.is_some(), "warmup mismatch at index {i}");
if let (Some(a), Some(b)) = (g, w) {
assert_relative_eq!(*a, *b, epsilon = 1e-9);
}
}
}
@@ -161,6 +161,15 @@ mod tests {
assert!(ZScore::new(0).is_err());
}
/// Cover the const accessor `period` (59-61) and the Indicator-impl
/// `name` body (106-108). `warmup_period` is exercised elsewhere.
#[test]
fn accessors_and_metadata() {
let z = ZScore::new(20).unwrap();
assert_eq!(z.period(), 20);
assert_eq!(z.name(), "ZScore");
}
#[test]
fn reset_clears_state() {
let mut z = ZScore::new(5).unwrap();
@@ -124,6 +124,21 @@ mod tests {
assert!(matches!(Zlema::new(0), Err(Error::PeriodZero)));
}
/// Cover the const accessors `period` / `value` (62-64, 72-74) and
/// the Indicator-impl `name` body (111-113). `lag` is already covered
/// by `lag_is_half_of_period_minus_one`.
#[test]
fn accessors_and_metadata() {
let mut z = Zlema::new(5).unwrap();
assert_eq!(z.period(), 5);
assert_eq!(z.name(), "ZLEMA");
assert_eq!(z.value(), None);
for i in 1..=z.warmup_period() {
z.update(f64::from(u32::try_from(i).unwrap()));
}
assert!(z.value().is_some());
}
#[test]
fn lag_is_half_of_period_minus_one() {
assert_eq!(Zlema::new(3).unwrap().lag(), 1);
+28
View File
@@ -216,6 +216,34 @@ mod tests {
assert!(matches!(err, Error::InvalidCandle { .. }));
}
/// Cover the unchecked constructor `Candle::new_unchecked` (lines 86-102).
/// Every existing test routes through the validating `Candle::new`, so the
/// unchecked path is dead.
///
/// The first assertion shows that a valid set of fields round-trips
/// verbatim. The second feeds `high < low` (which `Candle::new` would
/// reject with `Error::InvalidCandle`) and asserts the unchecked
/// constructor still produces the struct as-is — documenting and
/// enforcing the API contract that the unchecked variant performs no
/// validation and is the caller's responsibility.
#[test]
fn candle_new_unchecked_preserves_fields_verbatim() {
let c = Candle::new_unchecked(1.0, 2.0, 0.5, 1.5, 100.0, 42);
assert_eq!(c.open, 1.0);
assert_eq!(c.high, 2.0);
assert_eq!(c.low, 0.5);
assert_eq!(c.close, 1.5);
assert_eq!(c.volume, 100.0);
assert_eq!(c.timestamp, 42);
// Skip-validation contract: an OHLC combination that the checked
// constructor rejects (high < low) is still built without error.
assert!(Candle::new(10.0, 9.0, 10.0, 10.0, 1.0, 0).is_err());
let unchecked = Candle::new_unchecked(10.0, 9.0, 10.0, 10.0, 1.0, 0);
assert_eq!(unchecked.high, 9.0);
assert_eq!(unchecked.low, 10.0);
}
#[test]
fn candle_typical_price() {
let c = Candle::new(10.0, 12.0, 9.0, 11.0, 1.0, 0).unwrap();
+48
View File
@@ -275,6 +275,54 @@ mod tests {
assert_eq!(c.update(1.0), Some(8.0));
}
/// Cover the `Chain::first` / `Chain::second` borrow accessors and the
/// `Chain::warmup_period` + `Chain::name` Indicator-impl bodies.
///
/// Existing chain tests only invoked the Indicator surface (`update`,
/// `reset`, `is_ready`) on the wrapped `Chain`. The const borrow accessors
/// and the `warmup_period` / `name` impls were never traversed, so Codecov
/// flagged traits.rs lines 140-142, 145-147, 167-170, 176-178 as missed.
/// `chain.warmup_period()` also reaches `Doubler::warmup_period`
/// (228-230), and `chain.first().name()` reaches `Doubler::name`
/// (234-236) — both helper methods were uncovered for the same reason.
#[test]
fn chain_accessors_and_metadata() {
let chain = Chain::new(Doubler::default(), Doubler::default());
// Borrow accessors return the wrapped stages; query each via .name()
// so Doubler::name (lines 234-236) is also exercised.
assert_eq!(chain.first().name(), "Doubler");
assert_eq!(chain.second().name(), "Doubler");
// Doubler::warmup_period (lines 228-230) is 0; Chain::warmup_period
// sums the two, so the result must also be 0.
assert_eq!(chain.first().warmup_period(), 0);
assert_eq!(chain.second().warmup_period(), 0);
assert_eq!(chain.warmup_period(), 0);
// Chain::name returns the literal "Chain" (line 177).
assert_eq!(chain.name(), "Chain");
}
/// Cover the full Indicator surface of the `Identity` test helper:
/// `reset` (198-200), `warmup_period` (201-203), `is_ready` (204-206),
/// and `name` (207-209). The only other test using `Identity`
/// (`batch_replays_update`) calls `batch`, which exercises `update`
/// alone, leaving the remaining four trait methods uncovered.
#[test]
fn identity_helper_full_indicator_surface() {
let mut id = Identity::default();
// warmup_period is the literal 0; name is the literal "Identity".
assert_eq!(id.warmup_period(), 0);
assert_eq!(id.name(), "Identity");
// is_ready exercises the `self.seen` return with seen=false first…
assert!(!id.is_ready());
// …then with seen=true after a single update.
let out = id.update(42.0);
assert_eq!(out, Some(42.0));
assert!(id.is_ready());
// reset() flips seen back to false; is_ready reflects it.
id.reset();
assert!(!id.is_ready());
}
#[cfg(feature = "parallel")]
#[test]
fn batch_parallel_runs_independent_instances() {
+34 -8
View File
@@ -325,16 +325,18 @@ impl TickAggregator {
}
out.reserve(gap_count as usize);
// Bucket alignment guarantees start + (gap_count - 1) * step ≤
// next_bucket - step < i64::MAX, so iterating `gap_count` times
// with `saturating_add(step)` cannot reach i64::MAX inside the
// loop body. `prev.close` is finite (it came from a validated
// bar) and volume is exactly 0.0, so the OHLCV invariants hold
// by construction — skip re-validation via Candle::new_unchecked.
let mut t = start;
while t < next_bucket {
// `prev.close` is finite (it came from a validated bar), so this
// flat candle always passes `Candle::new`'s checks.
out.push(Candle::new(
for _ in 0..gap_count {
out.push(Candle::new_unchecked(
prev.close, prev.close, prev.close, prev.close, 0.0, t,
)?);
t = t.checked_add(step).ok_or_else(|| {
Error::Malformed("timestamp overflow while gap-filling".to_string())
})?;
));
t = t.saturating_add(step);
}
Ok(())
}
@@ -369,6 +371,30 @@ mod tests {
assert!(Timeframe::new(-1).is_err());
}
/// Cover the `Timeframe::millis`, `Timeframe::seconds`, and
/// `Timeframe::one_minute_ms` convenience constructors (lines 40-52).
/// All existing tests build Timeframes via `new` / `minutes` / `hours` /
/// `days`, never via the three thin convenience wrappers.
#[test]
fn timeframe_convenience_constructors() {
assert_eq!(Timeframe::millis(250).unwrap().bucket(), 250);
assert!(Timeframe::millis(0).is_err());
assert_eq!(Timeframe::seconds(30).unwrap().bucket(), 30);
assert!(Timeframe::seconds(-1).is_err());
// one_minute_ms is the infallible 60_000-ms shortcut.
assert_eq!(Timeframe::one_minute_ms().bucket(), 60_000);
}
/// Cover the `TickAggregator::timeframe` const accessor (lines 353-355).
/// Existing tests only inspect emitted candles, never query the
/// configured timeframe back out.
#[test]
fn aggregator_timeframe_getter() {
let tf = Timeframe::new(60).unwrap();
let agg = TickAggregator::new(tf);
assert_eq!(agg.timeframe().bucket(), 60);
}
#[test]
fn minute_hour_day_constructors_compute_seconds() {
assert_eq!(Timeframe::minutes(1).unwrap().bucket(), 60);

Some files were not shown because too many files have changed in this diff Show More