Compare commits

..

187 Commits

Author SHA1 Message Date
kingchenc 070be2eb27 release: 0.2.6 (docs.rs fix + README table reordering) (#36)
* fix(docs-rs): rename `doc_auto_cfg` to `doc_cfg` after Rust 1.92 merge

`doc_auto_cfg` was removed in Rust 1.92.0 and folded back into
`doc_cfg` (rust-lang/rust#138907). docs.rs builds with the latest
nightly and sets `--cfg docsrs`, so the previous

    #![cfg_attr(docsrs, feature(doc_auto_cfg))]

aborts compilation with E0557 on every published 0.2.x. GitHub CI
never tripped this — stable rustc ignores the line because nothing
sets the `docsrs` cfg there.

Switch all three published library crates (`wickra`, `wickra-core`,
`wickra-data`) to the merged-into `doc_cfg` gate. Same intent, same
on-docs.rs output, builds again on nightly.

* docs(readme): float Wickra to the top of the comparison tables

Reorders the "Why Wickra exists" library-comparison table and the two
benchmark headers so Wickra is the first row (with a ★ marker) instead
of the last. The previous order placed Wickra at the bottom, which
buries the only row a reader landing on the README is here to compare
against. Same column data, same ★/winner annotations, just the row
order flipped and a ★ prefix on the Wickra label.

Mirrored across the umbrella README and every binding README so the
crates.io / PyPI / npm landing pages stay in sync.

* release: bump workspace + bindings to 0.2.6

Workspace, every binding (Python, Node, Node platform stubs), the
release.yml comment and the CHANGELOG all move together to 0.2.6 so
the next tagged release lines every artefact up.

0.2.6 carries two changes from the [0.2.6] CHANGELOG entry:
- fix(docs-rs): swap the now-removed `doc_auto_cfg` feature gate for
  the merged-into `doc_cfg` so docs.rs nightly builds resume.
- docs(readme): float ★ Wickra to the top of every comparison table
  across the umbrella + binding READMEs.

wickra-win32-arm64-msvc stays excluded for this release with the same
npm spam-filter rationale that held for 0.2.5.
2026-05-24 03:20:13 +02:00
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
kingchenc 5a573650d2 Merge pull request #18 from kingchenc/release/0.2.1
release(0.2.1): bump to 0.2.1, Windows ARM64 skipped this cycle
2026-05-23 22:24:47 +02:00
kingchenc 8aa74cb638 release(0.2.1): bump to 0.2.1, skipping Windows ARM64 this cycle
The 0.2.0 release left wickra@npm stuck at 0.1.4 and never created a
GitHub Release entry because the brand-new `wickra-win32-arm64-msvc`
sub-package name was caught by npm's spam-detection filter on its first
publish attempt (same situation that affected `wickra-win32-x64-msvc`
through 0.1.4 until npm Support unblocked it). A support ticket is open;
until it is resolved, ship 0.2.1 for the five platforms whose
sub-packages are already on npm and re-add Windows ARM64 in a follow-up
release.

Changes for this cycle:
- bindings/node/package.json: remove "wickra-win32-arm64-msvc" from
  optionalDependencies and "aarch64-pc-windows-msvc" from
  napi.triples.additional.
- bindings/node/npm/win32-arm64-msvc/: removed (will be restored fresh
  once the npm name is unblocked).
- .github/workflows/release.yml: comment out the
  aarch64-pc-windows-msvc entry of the node-build matrix with a
  TODO/restore note.
- Bump every workspace and binding version to 0.2.1 (Cargo.toml,
  pyproject.toml, bindings/node/package.json, five npm/<target>
  templates, the wiki version table). Cargo.lock regenerated.
- CHANGELOG: new [0.2.1] block consolidating every fix that has landed
  on main since 0.2.0 (HV epsilon, examples CI step, fuzz cargo-fuzz
  install, MSRV 1.85 -> 1.86 / 1.77 -> 1.88, criterion 0.5 -> 0.8,
  tokio-tungstenite 0.24 -> 0.29, tick_aggregator gap-fill cap, every
  GitHub Action SHA-pin bump). Compare-link added.

The arm64 loader branch in bindings/node/index.js is left untouched: a
Windows ARM64 user installing 0.2.1 will get the standard
`Cannot find module 'wickra-win32-arm64-msvc'` error from the loader,
which is accurate. PyPI's win-arm64 wheel is unaffected.

Verified locally:
  cargo fmt/clippy/test --workspace --all-features -> 630 passed / 0 failed
  cargo build -p wickra-examples --bins -> clean
  cargo build -p wickra-node -> clean
2026-05-23 22:20:20 +02:00
kingchenc aea17a87af Merge pull request #16 from kingchenc/fix/criterion-0.8
deps: criterion 0.5 → 0.8 (replaces #10)
2026-05-23 21:43:28 +02:00
kingchenc 23c649aca9 Merge pull request #17 from kingchenc/fix/tokio-tungstenite-0.29
deps: tokio-tungstenite 0.24 → 0.29 (replaces #13)
2026-05-23 21:41:04 +02:00
kingchenc 57d3b785c2 Merge pull request #9 from kingchenc/dependabot/github_actions/actions/download-artifact-8.0.1
deps(actions): bump actions/download-artifact from 4.3.0 to 8.0.1
2026-05-23 21:37:55 +02:00
kingchenc d94dc43fb2 Merge pull request #5 from kingchenc/dependabot/github_actions/actions/upload-artifact-7.0.1
deps(actions): bump actions/upload-artifact from 4.6.2 to 7.0.1
2026-05-23 21:37:51 +02:00
kingchenc ed57632576 build: lift workspace MSRV to 1.86 to satisfy criterion 0.8.2
criterion 0.8.2 raised its MSRV from 1.85 to 1.86 (rustc 1.85.1 is now
explicitly rejected by its Cargo.toml `rust-version`). One more notch
on the same upward drift that already took us from 1.75 -> 1.80 (rayon)
-> 1.85 (clap_lex/edition2024).

Updated:
- Cargo.toml workspace rust-version 1.85 -> 1.86
- .github/workflows/ci.yml MSRV matrix name + toolchain
- ci.yml comment refreshed to reflect the new driving dep
2026-05-23 21:36:56 +02:00
kingchenc 64faa707a4 deps: bump tokio-tungstenite 0.24 -> 0.29 (replaces #13)
Dependabot opened #13 to bump tokio-tungstenite from 0.24 to 0.29 but
the bare-version bump fails to compile: WebSocketConfig became
#[non_exhaustive] starting with 0.27, so the existing struct-literal
construction

    let ws_config = WebSocketConfig {
        max_message_size: Some(MAX_MESSAGE_SIZE),
        max_frame_size: Some(MAX_FRAME_SIZE),
        ..WebSocketConfig::default()
    };

produces

    error[E0639]: cannot create non-exhaustive struct using struct expression

Switch to the builder-style setters that 0.29 exposes on the default
value. Semantics are unchanged; both fields still carry the
MAX_MESSAGE_SIZE / MAX_FRAME_SIZE caps from the original config and the
rest of the WebSocketConfig defaults are preserved by starting from
WebSocketConfig::default().

This supersedes #13 — same target version, plus the code change
Dependabot can't make on its own.

Verified locally:
  cargo check -p wickra-data --features live-binance  # clean
  cargo test --workspace --all-features  # 630 passed / 0 failed
  cargo clippy --workspace --all-targets --all-features -- -D warnings
2026-05-23 21:31:57 +02:00
kingchenc 1011fa2bbd deps: bump criterion 0.5 -> 0.8 and switch to std::hint::black_box
Dependabot opened #10 to bump criterion from 0.5.1 to 0.8.2 but the
straight version bump fails to build: criterion::black_box was
deprecated in 0.6 and removed/marked deny-warn in 0.8, so the existing
`use criterion::{black_box, ...}` produces

    error: use of deprecated function `criterion::black_box`: use
    `std::hint::black_box()` instead

across every bench callsite. Switch the import to std::hint::black_box
(stable since Rust 1.66, well under our MSRV of 1.85) and drop the
criterion re-export.

This supersedes #10 — same target version, plus the code change
Dependabot can't make on its own.

Verified locally:
  cargo bench -p wickra --no-run  # builds clean
  cargo clippy --workspace --all-targets --all-features -- -D warnings
  cargo test --workspace  # 630 passed / 0 failed
2026-05-23 21:30:09 +02:00
dependabot[bot] 7a501a39cf deps(actions): bump actions/download-artifact from 4.3.0 to 8.0.1
Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 4.3.0 to 8.0.1.
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/d3f86a106a0bac45b974a628896c90dbdf5c8093...3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c)

---
updated-dependencies:
- dependency-name: actions/download-artifact
  dependency-version: 8.0.1
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-23 19:26:58 +00:00
dependabot[bot] 4acb2c7d68 deps(actions): bump actions/upload-artifact from 4.6.2 to 7.0.1
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4.6.2 to 7.0.1.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/ea165f8d65b6e75b540449e92b4886f43607fa02...043fb46d1a93c77aae656e7c1c64a875d1fc6a0a)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: 7.0.1
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-23 19:26:45 +00:00
kingchenc 7ba4380693 Merge pull request #1 from kingchenc/dependabot/github_actions/actions/checkout-6.0.2
deps(actions): bump actions/checkout from 4.3.1 to 6.0.2
2026-05-23 21:21:32 +02:00
dependabot[bot] 862a130d8c deps(actions): bump actions/checkout from 4.3.1 to 6.0.2
Bumps [actions/checkout](https://github.com/actions/checkout) from 4.3.1 to 6.0.2.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/34e114876b0b11c390a56381ad16ebd13914f8d5...de0fac2e4500dabe0009e67214ff5f5447ce83dd)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: 6.0.2
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-23 19:21:27 +00:00
kingchenc c009ecc7f9 Merge pull request #8 from kingchenc/dependabot/github_actions/actions/setup-python-6.2.0
deps(actions): bump actions/setup-python from 5.6.0 to 6.2.0
2026-05-23 21:19:29 +02:00
kingchenc bf4faddf0f Merge pull request #6 from kingchenc/dependabot/github_actions/actions/setup-node-6.4.0
deps(actions): bump actions/setup-node from 4.4.0 to 6.4.0
2026-05-23 21:19:26 +02:00
kingchenc 0112693ec3 Merge pull request #4 from kingchenc/dependabot/github_actions/softprops/action-gh-release-3.0.0
deps(actions): bump softprops/action-gh-release from 2.6.2 to 3.0.0
2026-05-23 21:19:23 +02:00
kingchenc 4dd36953c3 Merge pull request #2 from kingchenc/dependabot/github_actions/codecov/codecov-action-6.0.1
deps(actions): bump codecov/codecov-action from 5.5.4 to 6.0.1
2026-05-23 21:19:20 +02:00
dependabot[bot] 9b34c84d86 deps(actions): bump actions/setup-python from 5.6.0 to 6.2.0
Bumps [actions/setup-python](https://github.com/actions/setup-python) from 5.6.0 to 6.2.0.
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](https://github.com/actions/setup-python/compare/a26af69be951a213d495a4c3e4e4022e16d87065...a309ff8b426b58ec0e2a45f0f869d46889d02405)

---
updated-dependencies:
- dependency-name: actions/setup-python
  dependency-version: 6.2.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-23 18:54:30 +00:00
dependabot[bot] 08928c5b17 deps(actions): bump actions/setup-node from 4.4.0 to 6.4.0
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 4.4.0 to 6.4.0.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/49933ea5288caeca8642d1e84afbd3f7d6820020...48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: 6.4.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-23 18:54:20 +00:00
dependabot[bot] 86b4f73ec7 deps(actions): bump softprops/action-gh-release from 2.6.2 to 3.0.0
Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 2.6.2 to 3.0.0.
- [Release notes](https://github.com/softprops/action-gh-release/releases)
- [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md)
- [Commits](https://github.com/softprops/action-gh-release/compare/3bb12739c298aeb8a4eeaf626c5b8d85266b0e65...b4309332981a82ec1c5618f44dd2e27cc8bfbfda)

---
updated-dependencies:
- dependency-name: softprops/action-gh-release
  dependency-version: 3.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-23 18:54:12 +00:00
dependabot[bot] 246d982e93 deps(actions): bump codecov/codecov-action from 5.5.4 to 6.0.1
Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 5.5.4 to 6.0.1.
- [Release notes](https://github.com/codecov/codecov-action/releases)
- [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codecov/codecov-action/compare/75cd11691c0faa626561e295848008c8a7dddffe...e79a6962e0d4c0c17b229090214935d2e33f8354)

---
updated-dependencies:
- dependency-name: codecov/codecov-action
  dependency-version: 6.0.1
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-23 18:53:50 +00:00
kingchenc 3d2e58c531 Merge pull request #14 from kingchenc/dependabot/cargo/serde_json-1.0.150
deps(cargo): bump serde_json from 1.0.149 to 1.0.150
2026-05-23 20:52:11 +02:00
kingchenc f966841ced Merge pull request #3 from kingchenc/dependabot/github_actions/taiki-e/install-action-2.79.5
deps(actions): bump taiki-e/install-action from 2.79.4 to 2.79.5
2026-05-23 20:51:56 +02:00
dependabot[bot] 99490f8604 deps(cargo): bump serde_json from 1.0.149 to 1.0.150
Bumps [serde_json](https://github.com/serde-rs/json) from 1.0.149 to 1.0.150.
- [Release notes](https://github.com/serde-rs/json/releases)
- [Commits](https://github.com/serde-rs/json/compare/v1.0.149...v1.0.150)

---
updated-dependencies:
- dependency-name: serde_json
  dependency-version: 1.0.150
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-23 18:49:27 +00:00
dependabot[bot] 4d8c0ce1a5 deps(actions): bump taiki-e/install-action from 2.79.4 to 2.79.5
Bumps [taiki-e/install-action](https://github.com/taiki-e/install-action) from 2.79.4 to 2.79.5.
- [Release notes](https://github.com/taiki-e/install-action/releases)
- [Changelog](https://github.com/taiki-e/install-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/taiki-e/install-action/compare/e0eafa9a0d485c37f97c0f7beb930a58a2facbac...6c1f7cf125e42770ff087ea443901b487cc5471a)

---
updated-dependencies:
- dependency-name: taiki-e/install-action
  dependency-version: 2.79.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-23 18:49:22 +00:00
kingchenc d063555cd1 Merge pull request #15 from kingchenc/fix/ci-and-deps
fix(ci): green up the matrix — HV epsilon, examples step, fuzz install, MSRV bump
2026-05-23 20:47:41 +02:00
kingchenc df179fa579 fix(aggregator): cap gap-fill at 1_000_000 candles per push (fuzz finding)
The tick_aggregator fuzz target found that TickAggregator::fill_between
allocates one placeholder Candle per skipped bucket without bounding the
gap size. An adversarial input (a clock-glitch tick years in the future)
produced an OOM on libFuzzer after malloc(~3 GB):

    SUMMARY: libFuzzer: out-of-memory (malloc(3221225472))

A real-world failure mode too: a single bad timestamp from a flaky feed
could OOM the host process even though every individual tick passed
Tick::new validation.

Fix:
- Compute the gap size up-front via saturating arithmetic, before any
  allocation, and refuse with Error::Malformed when it exceeds the new
  MAX_GAP_FILL_CANDLES = 1_000_000 cap (≈ 1.9 years of contiguous
  one-minute bars, well above any realistic missing-data window).
- Reserve the right Vec capacity in advance once we know the gap fits,
  avoiding intermediate reallocations.
- Add two regression tests: gap_fill_rejects_runaway_timestamp_jump
  (the fuzz scenario) and gap_fill_at_the_cap_succeeds (exact-cap input
  still works).

The cap is exposed as pub const so callers can pre-validate their input
without relying on the error string.
2026-05-23 20:41:03 +02:00
kingchenc d52ddeaccb fix(core): use f64::midpoint and stable %2 to satisfy newer toolchains
Two unrelated newer-toolchain breakages bundled because they hit on the
same CI run and have the same shape (newer Rust got stricter about
patterns we used):

1. clippy 1.95 added the manual_midpoint lint which fires on every
   instance of (a + b) / 2.0 with a help suggesting f64::midpoint.
   CI runs with -D warnings so it became a hard error. Twelve sites
   were affected — three real call sites in src/ohlcv.rs (median_price),
   src/indicators/donchian.rs (DonchianOutput.middle),
   src/indicators/ease_of_movement.rs (mid), and
   src/indicators/super_trend.rs (hl2); plus eight test-helper
   Candle::new constructions across accelerator_oscillator,
   atr_trailing_stop, chaikin_volatility, chandelier_exit,
   chande_kroll_stop, choppiness_index, super_trend, true_range.
   All twelve switched to f64::midpoint (stable since Rust 1.85,
   our workspace MSRV).

2. usize::is_multiple_of is still unstable (rust-lang/rust#128101) and
   only stabilizes in Rust 1.87, but the MSRV CI job uses 1.85. The
   two call sites in bollinger.rs and sma.rs (added with the R7
   periodic-reseed tests) switched back to i % 2 == 0.
2026-05-23 20:37:35 +02:00
kingchenc 3f9429dd2c build: lift workspace MSRV further to 1.85 for criterion's clap_lex
The first MSRV bump to 1.80 fixed the rayon-core floor but ran into a
second transitive-dep floor on the same CI run:

    error: failed to parse manifest at clap_lex-1.1.0/Cargo.toml
    Caused by: feature `edition2024` is required
    The package requires the Cargo feature called `edition2024`, but
    that feature is not stabilized in this version of Cargo (1.80.1).

clap_lex 1.1.0 is pulled in by criterion (dev-dep on the wickra crate)
via clap 4.6 -> clap_builder 4.6 -> clap_lex 1.1. edition2024 was
stabilized in Rust 1.85, so lift the workspace MSRV one more step. The
1.85 floor subsumes the rayon-core 1.80 requirement; bindings/node
stays at 1.88 (napi-build) which already covers everything below it.

Also fix the fuzz job: cargo-fuzz defaulted to building for
x86_64-unknown-linux-musl, which is not installed on the GitHub-hosted
ubuntu runner. Pass --target x86_64-unknown-linux-gnu explicitly on
every cargo fuzz run invocation so it builds for the actual host
target.
2026-05-23 20:30:46 +02:00
kingchenc 30444ce296 build: lift MSRV to 1.80 workspace / 1.88 node binding
The previous MSRV pins were below what current transitive dependencies
need:

  - rayon-core 1.13.0 (pulled in by the optional `parallel` feature on
    wickra-core via rayon) requires rustc >= 1.80; under the old 1.75
    workspace MSRV the CI MSRV job broke with "package `rayon-core
    v1.13.0` cannot be built because it requires rustc 1.80 or newer".
  - napi-build 2.3.2 (the build-script crate that napi-derive 2.x calls
    into) requires rustc >= 1.88; under the old 1.77 node-binding MSRV
    the CI MSRV-node job broke with "package `napi-build v2.3.2` cannot
    be built because it requires rustc 1.88 or newer".

Pinning the deps backwards would have frozen us out of upstream
security/fix releases for both crates. Lifting the MSRV is the cleaner
path for a young 0.x library — downstream consumers on older toolchains
can stay on the already-published 0.2.0.

Updated:
  - Cargo.toml workspace rust-version 1.75 -> 1.80
  - bindings/node/Cargo.toml rust-version 1.77 -> 1.88
  - .github/workflows/ci.yml MSRV matrix names + toolchain values + comment
  - CHANGELOG.md [Unreleased] documents the bump
2026-05-23 20:22:35 +02:00
kingchenc 90d8a299c3 ci(fuzz): install cargo-fuzz from a prebuilt binary
The previous `cargo install cargo-fuzz --locked` resolved against
cargo-fuzz's own Cargo.lock which pins to rustix 0.36.5. That rustix
version still annotates its source with internal #[rustc_*] attributes,
and the current nightly compiler rejects those attributes from
out-of-tree crates, so cargo-fuzz never finished compiling on CI:

    error: attributes starting with `rustc` are reserved for use by the
    `rustc` compiler
      --> rustix-0.36.5/src/backend/linux_raw/io/errno.rs:28
    error: could not compile `rustix` (lib) due to 4 previous errors
    error: failed to compile `cargo-fuzz v0.13.1`

Switch the install to taiki-e/install-action, the same prebuilt-binary
provider we already use for cargo-llvm-cov. That skips the full
transitive-dependency compile and lets the fuzz-smoke job actually run.
2026-05-23 20:21:03 +02:00
kingchenc 73f8da49bd ci: build wickra-examples bins instead of removed cargo examples
The Z5 reorganisation moved every runnable example out of the per-crate
examples/ folders and into a dedicated wickra-examples crate at
examples/rust/, with the binaries living under src/bin/<name>.rs. The
old ci.yml Compile-examples step still pointed at the now-deleted
cargo example targets backtest (wickra) and live_binance (wickra-data),
which is why Rust windows-latest failed with 'no example target named
backtest in wickra package' and 'no example target named live_binance
in wickra-data package'.

Replace both calls with a single cargo build -p wickra-examples --bins.
That covers backtest, live_binance, fetch_btcusdt, multi_timeframe,
parallel_assets and streaming in one shot, and the wickra-examples
crate already enables the live-binance feature on its wickra-data dep
so no extra --features flag is needed.
2026-05-23 20:19:14 +02:00
kingchenc ae8fcd9051 test(hv): widen geometric_series_yields_zero tolerance to 1e-6
The mathematical result of HistoricalVolatility on a perfectly geometric
price series is exactly zero — but the underlying 1.01_f64.powi(i) +
log-return + std-dev cascade accumulates platform-sensitive FP drift on
the order of 1e-7 on x86_64 Linux and macOS (the Windows result happened
to round closer to zero, which is why the test passed locally and on the
Windows CI runner but failed on Linux and macOS).

Bump the tolerance from 1e-9 to 1e-6. That stays four decimal places
below any realistic annualised volatility value while comfortably
absorbing the observed cross-platform drift.

Also extend the comment to document the rationale so the next person
who reads the test does not tighten it back down.
2026-05-23 20:18:24 +02:00
kingchenc 39a252ea66 release(0.2.0): bump version from 0.1.5 to 0.2.0
This release carries the full post-audit work — 46 new indicators
(25 → 71), an eight-family taxonomy restructure, new bindings for
RollingVWAP, the WASM streaming-update parity, the pyo3/numpy CVE
fix, the SMA/Bollinger drift bound, the O(1) LinearRegression
refactor, the UlcerIndex deque and the PSAR is_ready/reset fixes
plus a refreshed example suite and wiki. The earlier 0.1.5 number
was never published; jumping straight to 0.2.0 is the cleaner signal
for the scope of the change.

Bumped:
- Cargo.toml workspace + wickra-core workspace-dep version
- bindings/python/pyproject.toml
- bindings/node/package.json + optionalDependencies (six platform pins)
- 6 x bindings/node/npm/<target>/package.json
- Cargo.lock regenerated
- CHANGELOG.md [0.2.0] header + compare-link
- docs/wiki/Home.md published-versions table
- docs/wiki/Quickstart-{Rust,Node,WASM}.md + Warmup-Periods.md
  version-pinned narrative lines

Verified locally:
- cargo fmt/clippy/test (628 passed, 0 failed)
- cargo deny check (no suppression)
- bindings/node node --test (92/92)
- bindings/python pytest (118/118)
- import wickra reports 0.2.0 with 72 indicator classes
2026-05-23 19:58:02 +02:00
kingchenc 11c52b8e86 release(0.1.5): stamp the CHANGELOG date
Replace the TBD placeholder under ## [0.1.5] with today's date so the
release header is complete. The compare-link at v0.1.4...v0.1.5 is
already in place and points at the right pair.
2026-05-23 19:12:11 +02:00
kingchenc c6d2030103 chore(python): add canonical authors metadata to pyproject.toml
Every other manifest in the repo carries the author string
kingchenc <kingchencp@gmail.com> — workspace Cargo.toml,
bindings/node/package.json, all per-platform npm package.jsons,
release.yml. pyproject.toml had neither authors nor maintainers, so
the PyPI listing fell back to the implicit empty value. Align with
the rest of the repo.
2026-05-23 19:11:53 +02:00
kingchenc a10bda188f docs(wiki): align RollingVwap count with Home and README
Indicators-Overview.md listed RollingVwap as its own row in the Volume
table (count = 9, total = 72), but Home.md and README.md treat it as
a sub-variant of Vwap (count = 8, total = 71). Drop the standalone row
in Indicators-Overview and fold the rolling variant into the Vwap row.
Warmup-Periods.md keeps both constructors (Vwap::new() and
RollingVwap::new(n) have different warmup periods, which is the whole
purpose of that page) but adds a note explaining that the row count
exceeds the 71 canonical indicators by one.
2026-05-23 19:11:34 +02:00
kingchenc c4ffcc2138 docs(node): correct doc-comment package name to wickra
The module doc-comment in bindings/node/src/lib.rs still pointed at
@wickra/wickra. The package is published as bare "wickra" — every
README, Quickstart and example already uses that name. Align the
inline doc with the published name.
2026-05-23 19:10:18 +02:00
kingchenc f3ad0940c3 docs(wiki): correct Node warmupPeriod() coverage note (R3, R8)
The Quickstart-Node API-surface table claimed `warmupPeriod()` was
"not exposed on every multi-output class". After the B-series fixes
in `todo-detailed.md` and the R3/R8 pass on this branch, every Node
indicator class — single- and multi-output, scalar- and candle-input —
exposes `warmupPeriod()`. Updated the note accordingly.
2026-05-23 11:04:15 +02:00
kingchenc a32719d709 docs(wiki): expand WASM Quickstart for the full multi-output surface (R3)
The Quickstart-WASM page only mentioned `MACD` and `BollingerBands` as
multi-output indicators. After R3 (this branch) every candle-input
WASM class also exposes a structured `update`: `Stochastic`, `ADX`,
`Keltner`, `Donchian`, `Aroon` (plus `SuperTrend`, which has always
been there).

The "Multi-output indicators" section now lists every multi-output
shape in a table, and a short note at the end calls out the 0.1.5
parity: every candle-input indicator ships `update` / `batch` /
`reset` / `isReady` / `warmupPeriod`, so browser code doesn't need
to replay `batch` on each tick anymore.
2026-05-23 11:03:53 +02:00
kingchenc 6fd110b4ce docs(wiki): document O(1) regression update and long-stream sum reseed (R2, R7)
Three indicator pages get a short follow-up paragraph that surfaces an
internal implementation detail the audit findings made user-visible:

- `Indicator-LinearRegression.md` gains a "Complexity" section explaining
  the O(1) update (precomputed `Σx`, `Σxx`; incrementally slid `Σy`,
  `Σxy` via the closed-form sliding identity), and the existing
  "Reset" bullet mentions the additional running accumulators. The same
  story applies to `LinRegSlope` and `LinRegAngle` (the page now links
  to both rather than repeating the derivation three times).

- `Indicator-Sma.md` and `Indicator-BollingerBands.md` mention the
  periodic reseed (`16 · period` updates) that caps floating-point
  drift on long-running streams. Amortised cost is still O(1) and the
  user-facing behaviour on benign inputs is unchanged.

No behavioural claim, no API claim, no example changes — just narrative
catching up with the implementation.
2026-05-23 11:03:18 +02:00
kingchenc 896b71fc62 release(0.1.5): bump versions, finalize CHANGELOG, fail loud on missing platform binaries (R20, Z2)
Versions bumped to 0.1.5 in every authoritative location:

- workspace `Cargo.toml` (`[workspace.package].version`, the
  `wickra-core` path dependency pin).
- `bindings/python/pyproject.toml`.
- `bindings/node/package.json` (main + all six `optionalDependencies`
  pins).
- All six per-platform `bindings/node/npm/<target>/package.json`
  templates.

CHANGELOG: the accumulated `[Unreleased]` block is promoted to
`[0.1.5] - TBD` (date left for the user to set at tag time); the new
`[Unreleased]` header sits empty above it; the compare link table is
extended with `[0.1.5]: …compare/v0.1.4...v0.1.5` and the
`[Unreleased]` link is repointed to `…compare/v0.1.5...HEAD`.

Wiki refresh for 0.1.5 (R20 + Z2):

- `Home.md` version pin table updated; the Quickstart-Node hint replaces
  the "spam filter holding back Windows" caveat with "0.1.5 is the
  first release in which `npm install wickra` works end-to-end on
  Windows" (npm Support released the name on 2026-05-22).
- `Quickstart-Node.md`'s Windows caveat is rewritten to explain the
  history (`0.1.1`–`0.1.4` of `wickra-win32-x64-msvc` are burned) and
  the resolution (0.1.5+ installs cleanly).
- `Quickstart-Rust.md` version mention bumped.
- `Warmup-Periods.md` note bumped + corrected: every Node and WASM
  class — single- and multi-output — now exposes `warmupPeriod()` after
  R3 (this branch), not only the single-output ones.

`release.yml` `publish_dir` no longer silently swallows a
second-attempt platform-package publish failure with a `::warning::`
and `return 0`. A real failure (after the existing 30s retry) now
emits an `::error::` and fails the job. The original mask is exactly
what allowed the `wickra-win32-x64-msvc@0.1.1–0.1.4` spam-filter
rejections to land four times in a row without anyone noticing (audit
finding R20). Failing loud means the next regression of this shape is
caught at the release run, not by a Windows user trying to
`require('wickra')`.

This commit does NOT push, tag, or trigger a release — the user
publishes the 0.1.5 tag themselves once the manual npm-republish
smoke test confirms `wickra-win32-x64-msvc@0.1.5` accepts publish on
the freshly-released name.
2026-05-23 10:58:08 +02:00
kingchenc 62fcab1c86 docs(wiki): refresh PSAR streaming exposure and HV non-positive behaviour
PSAR — the wiki page still claimed "Node streaming. Not exposed in the
Node binding." That was true for an early release but is no longer:
the Node binding has exposed `psar.update(high, low, close)` since the
B1 fix in todo-detailed.md, and the WASM binding now exposes the same
streaming surface (audit finding R3, this branch). The page lists all
three streaming + batch shapes and adds a paragraph on the new
`is_ready` convention (audit finding R6 — flips on the first non-None
SAR, not on the seed candle).

HistoricalVolatility — the "Non-positive prices" edge-case note still
described the old behaviour ("that return is treated as 0"). The new
behaviour skips the bad tick entirely (audit finding R13): the
indicator's state is left untouched, the previous valid value is
returned, and the next real tick re-anchors against the previous
*valid* price. Updated the note to describe the new behaviour and
explain why (silently treating bad ticks as "no movement" underreports
realised volatility).
2026-05-23 10:54:51 +02:00
kingchenc 6786917cee ci: move bench to scheduled workflow + add Python 3.13 (R10, R11)
R10 — `cross-library-bench` previously ran on every push and every PR
to `main`, adding 5–10 minutes of build + bench time per CI run with
no automated consumer of the artefact. It moves to a dedicated
workflow (`.github/workflows/bench.yml`) that fires nightly at 03:00
UTC and on-demand via `workflow_dispatch` (with optional `size` /
`iterations` inputs). The job in `ci.yml` is removed and a pointer
comment is left in its place so future readers find the new home.

R11 — `pyproject.toml`'s `requires-python = ">=3.9"` already allowed
Python 3.13 installs, but the classifier list stopped at 3.12 and CI
tested only 3.9 / 3.11 / 3.12. The Python CI matrix gains `"3.13"`
and the matching `Programming Language :: Python :: 3.13` classifier
is added so PyPI listings and version-search tooling reflect the
actually-tested range.
2026-05-23 10:52:20 +02:00
kingchenc 961fc1f3dc chore(node, readme): SPDX license, Node 18 engines, bench hardware spec (R15, R18, R19)
R15 — `bindings/node/package.json` and all six per-platform subpackage
templates (`npm/{darwin-arm64,darwin-x64,linux-arm64-gnu,linux-x64-gnu,
win32-arm64-msvc,win32-x64-msvc}/package.json`) plus the WASM
`release.yml` enrich step switch the `license` field from the npm
convention `SEE LICENSE IN LICENSE` to the SPDX identifier
`PolyForm-Noncommercial-1.0.0`. npm's license search and downstream
tooling can now surface the actual license.

R18 — every `engines.node` field is bumped from `>= 16` to `>= 18`.
The package's test script (`node --test __tests__/`) uses the built-in
`node --test` runner that landed in Node 18; advertising support for
Node 16 / 17 was a guarantee we never verified (CI tests on Node 18 /
20 only) and would have produced a confusing error on those versions.

R19 — README's benchmark section gains an explicit hardware /
software-version block and reframes the absolute µs values as a
relative-speedup snapshot rather than a universal contract. Also tells
the reader how to reproduce locally (`pip install -e
bindings/python[bench]` + `python -m benchmarks.compare_libraries`) and
where the CI artefact lives.

Side effects:

- R16 / R17 (built wheel under `bindings/python/dist/` and
  `examples/node/node_modules/`) — verified that both are already
  covered by `.gitignore` (`*.whl`, `dist/`, `**/node_modules/`) and
  were never tracked by git. The local directories have been cleared;
  no `.gitignore` change needed and no committed file removed.
2026-05-23 10:50:29 +02:00
kingchenc 183ebec7ba fix(core): skip non-positive HV prices and add Error::InvalidTick (R13, R14)
R13 — `HistoricalVolatility::update` previously substituted `0.0` for
the log-return whenever `prev <= 0` or `input <= 0`. The log-return is
undefined there, and silently treating bad ticks as "no movement"
underreports realised volatility on broken data feeds. The fix skips
non-positive prices entirely: `self.last` is returned, state is left
untouched, and the next real tick re-anchors against the previous
*valid* `prev_price`. This matches how every other indicator handles
invalid inputs (SMA / EMA / ROC / Bollinger).

A new test `skips_non_positive_prices` proves the invariant: after a
warmed-up indicator, two consecutive bad ticks (`-5.0` and `0.0`) must
return the baseline value, and a subsequent real positive tick must
produce the same output as a control indicator that simply never saw
the bad ticks.

R14 — `Tick::new` previously returned `Error::InvalidCandle` for
negative volume. A tick is not a candle; downstream tick-stream
pipelines should be able to match on a semantically-correct error. A
new `Error::InvalidTick { message }` variant is added; the existing
test is updated to assert against it. Python's `map_err` is extended
to forward the new variant as `PyValueError`; the Node and WASM
bindings format via `Error::to_string()` and pick the new variant up
automatically without source changes.
2026-05-23 10:46:52 +02:00
kingchenc 510013fc5a fix(sma, bollinger): periodic recompute to bound long-stream drift (R7, L2-Rust)
`Sma` and `BollingerBands` both maintained their running `sum` (and
`sum_sq` for Bollinger) with a single-subtract incremental update. That
is correct in exact arithmetic, but in f64 the sequence `sum -= old;
sum += new` on long streams with alternating large/small magnitudes
can accumulate catastrophic-cancellation error. Bollinger's existing
`.max(0.0)` clamp on the computed variance was a band-aid for the same
root cause — the drift had already driven the running variance below
zero.

The fix: every `16 · period` finite updates, reseed `sum` (and `sum_sq`
for Bollinger) from the live window. Amortised cost stays at O(1) —
`O(period)` work amortised over `O(period)` updates — and the reseed
strategy is named after the constant `RECOMPUTE_EVERY` so the
intention is clear at the call site.

Behaviour is unchanged on inputs that did not drift to begin with
(every existing test still passes, including `batch_equals_streaming`
and the SMA proptest). Two new stress tests
(`long_stream_drift_stays_bounded` in each module) feed a
magnitude-alternating stream for `5 · RECOMPUTE_EVERY · period`
updates and assert the reported value tracks a fresh from-scratch
computation over the live window to within tight tolerance — these
would have failed without the reseed on Bollinger's `sum_sq`.

The misleading `sma.rs` comment that claimed drift was already
bounded by recomputing the sum after each pop is rewritten to
describe the actual reseed strategy (audit finding L2-Rust).
2026-05-23 10:42:50 +02:00
kingchenc 2db546ac12 chore(coppock): fix unbalanced backticks in the new doc comment
Follow-up to b340ecd — the doc comment on
`warmup_period_matches_first_some_for_every_parameter_set` had an
unbalanced inline-code span ("`Some``") that tripped
`clippy::doc_invalid_doc_attributes` (caught by `clippy -D warnings`
but not by `cargo build` or `cargo test`). Rephrased the sentence so
every backtick is paired. No code change, no test change.
2026-05-23 10:38:46 +02:00
kingchenc b340ecd3d6 test(coppock): lock in warmup_period for every parameter set (refutes R12)
Audit finding R12 claimed `Coppock::warmup_period()` was off by one
because it returns `max(roc_long, roc_short) + wma`, while
`Roc::warmup_period() = period + 1`. After tracing the actual emission
sequence the existing formula is correct: when both ROCs reach `Some`
at 0-based index L (the slower of `roc_long_period` and
`roc_short_period`), the WMA receives its first input there and emits
its `wma_period`-th value at 0-based index `L + wma_period − 1`. The
`warmup_period()` is the 1-based count of inputs needed before the
first `Some`, i.e. `L + wma_period`. R12 was a misread by both Sonnet
audit agents and the Opus verifier — none of them traced the actual
emission timeline.

This commit:

- Expands the doc comment on `warmup_period` with the precise emission
  argument and a worked example for `Coppock::new(6, 4, 3)` (the
  existing test) so a future reader cannot mis-derive the formula.
- Adds `warmup_period_matches_first_some_for_every_parameter_set`,
  which asserts `out[warmup - 1].is_some()` for five parameter
  combinations — including the audit's smoking gun `(4, 2, 3)`. The
  audit's proposed `max + 1 + wma` formula would have predicted index
  7 (the 8th input) for that combination; the real first `Some` lands
  at index 6 (the 7th input), exactly what the current formula
  reports.

No behaviour change — the audit was wrong and the test makes the
contract regression-proof.
2026-05-23 10:38:24 +02:00
kingchenc 2aef8c8db5 perf(linreg): incremental O(1) OLS for LinearRegression and LinRegSlope (R2)
`LinearRegression::fit` and `LinRegSlope::update` previously iterated the
full `period`-window on every tick to recompute `Σy` and `Σxy` from
scratch — O(period) per update, in violation of the `Indicator` trait's
O(1) contract. `LinRegAngle` inherits the cost transitively because it
delegates to `LinRegSlope`.

This commit slides the OLS state in closed form. The constant terms
(`Σx`, `Σxx`, the denominator `n·Σxx − (Σx)²`) were already precomputed
in `new`. The new running state is:

- `sum_y: f64` — running sum of the values currently in the window.
- `sum_xy: f64` — running Σ(x · y) where `x` is the position of each
  value inside the trailing window (`0` for the oldest, `n−1` for the
  newest).

On every push, when the window is already full the front value `y₀` is
popped and the indices of every remaining value shift down by 1; the
identity

    new_Σxy = old_Σxy − old_Σy + y₀

closes the slide in O(1). The new value is then pushed at position `k`
(the current length before the push), contributing `k · new_value` to
`sum_xy` and `new_value` to `sum_y`. The output is the same TA-Lib OLS
formula evaluated against the incremental accumulators.

Behaviour is unchanged: same per-tick values, same warmup, same NaN
semantics. Two new tests compare the O(1) result bar-by-bar against a
fresh O(n) refit on a noisy ramp (sliding-phase dominated), a step
function (large pop/push deltas), and constants (tests floating-point
drift) — agreement is within `1e-9`.

`LinRegAngle` benefits automatically through its `LinRegSlope` field.
2026-05-23 10:36:45 +02:00
kingchenc b003321562 test(fuzz): cover every indicator, scalar and candle inputs (R9)
The fuzz suite previously covered only `Rsi(14)` and `Ema(20)` — 2 of
71 indicators, no OHLCV coverage at all. Audit finding R9 asked for
ATR/ADX/Stochastic/PSAR as a minimum; this commit goes further and
brings every indicator under fuzz.

- `indicator_update` (rewritten): drives every scalar-input indicator
  through one streaming pass + one batch call per iteration. Covers
  SMA, EMA, WMA, RSI, DEMA, TEMA, HMA, ROC, TRIX, SMMA, TRIMA, ZLEMA,
  KAMA, T3, MOM, CMO, TSI, PMO, StochRSI, DPO, PPO, Coppock, StdDev,
  UlcerIndex, HistoricalVolatility, LinearRegression, LinRegSlope,
  LinRegAngle, VHF, ZScore, MACD, BollingerBands. A `drive` helper
  marked `#[inline(never)]` keeps each indicator on its own panic
  backtrace frame.

- `indicator_update_candle` (new): chunks the fuzz `f64` stream into
  `[open, high, low, close, volume]` tuples, builds candles via
  `Candle::new` (skipping ones that fail OHLCV validation — that path
  is fuzz-tested separately), then drives every candle-input indicator
  through streaming + batch. Covers ATR, NATR, TrueRange,
  ChaikinVolatility, Keltner, Donchian, PSAR, SuperTrend,
  ChandelierExit, ChandeKrollStop, ATRTrailingStop, ADX, Aroon,
  AroonOscillator, Vortex, MassIndex, ChoppinessIndex, CCI, WilliamsR,
  AwesomeOscillator, AcceleratorOscillator, UltimateOscillator,
  BalanceOfPower, OBV, MFI, VWAP, RollingVWAP, VWMA, ADL, VPT, CMF,
  ChaikinOscillator, ForceIndex, EaseOfMovement, TypicalPrice,
  MedianPrice, WeightedClose, Stochastic.

- `fuzz/Cargo.toml` registers the new target; `fuzz/README.md`
  describes both expanded targets.

- A `fuzz-smoke` CI job runs each of the five targets for 30 s on
  every push and pull-request — enough to catch a regression in the
  harness without slowing CI to a crawl. Long fuzz campaigns belong
  on dedicated infrastructure with persistent corpora.
2026-05-23 10:33:05 +02:00
kingchenc 0995f8d66a fix(psar): correct is_ready convention and use NaN sentinels (R6, B-Opus-1)
`Psar::is_ready` previously returned `self.initialised`, which flips to
`true` *after* the seed candle — but the seed candle itself returns
`None`. The contract every other indicator honours is
`is_ready() == true` ↔ "the most recent update produced (or could
produce) a real value". Streaming consumers writing
`if ind.is_ready() { use(ind.update(c)?) }` would hit an unexpected
`None` on the first post-seed update.

Fix: add a `has_emitted: bool` field that flips on the first
`Some(sar)` return; `is_ready` now reads that. New test
`is_ready_only_after_first_some_value` pins the contract.

While in the same file, `reset()` is corrected to restore the compute
fields (`prev_high`, `prev_low`, `sar`, `ep`) to `f64::NAN` sentinels
instead of `0.0` (Opus bonus finding). The fields are gated by
`initialised` today, so the `0.0` sentinel never leaked into output —
but a future refactor that read them pre-init would have silently
treated `0.0` as a real price. A `debug_assert!` at the read site makes
the invariant explicit and catches a re-introduction of the bug in
debug builds.

Bit-equivalence with the previous behaviour is preserved
(`reset_allows_clean_reuse` and `batch_equals_streaming` continue to
pass unchanged).
2026-05-23 10:28:18 +02:00
kingchenc a530f1b4cb perf(ulcer-index): track trailing max with a monotone deque (R1, B-Opus-2)
`UlcerIndex::update` previously scanned the full `period`-window every
tick via `prices.iter().fold(NEG_INFINITY, f64::max)`, breaking the
`Indicator` trait's O(1) contract. For long windows (e.g. period 50+ on
a live tick stream) this turned a constant-time update into an O(period)
one, and full-history batch replays into O(n · period).

The window of raw prices is replaced with a monotonically-decreasing
deque of `(index, price)` pairs. On every push, all back entries
`<= input` are popped (they can never be the trailing max again, since
they are dominated and at least as old). On every step, the front is
popped if its index is older than `count - period + 1`. The deque's
front is therefore always the trailing max in O(1). `count: u64` is the
1-based input counter that drives expiration; on `reset()` it returns
to zero alongside the deque and the drawdown state.

Behaviour is unchanged: same per-tick values, same warmup
(`2 * period - 1`), same non-finite-input semantics. A new test
`monotone_deque_matches_naive_max_on_adversarial_inputs` compares the
deque output bar-by-bar against an independent O(n) trailing-max scan on
inputs designed to hit every code path: strictly increasing (full tail
pops), strictly decreasing (head expirations only), constants (the
`<= input` pop rule keeps a single newest entry), and a sawtooth.

The doc comment on `warmup_period()` is also corrected (B-Opus-2): the
two windows overlap by one bar, so the formula is `2 * period - 1`, not
`2 * period`.
2026-05-23 01:46:24 +02:00
kingchenc efcd6216c1 feat(bindings): expose RollingVWAP in Python, Node and WASM (R4)
The rolling-window VWAP indicator (`wickra_core::RollingVwap`) was only
available in the Rust crate, even though the README's Volume-family
table already advertised "VWAP (cumulative + rolling)" as a cross-
language feature. Users on Python, Node or in the browser had to fall
back to the cumulative `VWAP` or re-implement the rolling variant
themselves.

This commit closes the gap end-to-end:

- Python: `wickra.RollingVWAP(period)` — same constructor / `update` /
  `batch` / `reset` / `is_ready` / `warmup_period` surface as `VWAP`,
  plus a `period` property and a typed `__repr__`. The `__init__.py`
  re-exports it and `__all__` lists it; the `.pyi` stub matches.
- Node: `RollingVWAP(period)` — napi class with the same lifecycle,
  exported from `index.js` and declared in `index.d.ts`.
- WASM: `RollingVWAP(period)` — wasm-bindgen class with the same
  `Float64Array` I/O as `VWAP`.

Tests added:

- Python: `test_rolling_vwap_streaming_matches_batch` — exercises
  `update == batch` plus the full lifecycle on the shared OHLC fixture.
- Node: `RollingVWAP` row in the `candleScalar` parity table — covered
  by the generic streaming-vs-batch + lifecycle harness.
- WASM: dedicated `wasm-bindgen-test` mirrors the Python test.

The wiki page `Indicator-Vwap.md` drops the "Rust-only" caveat and
gains Python / Node / WASM examples.
2026-05-23 01:43:00 +02:00
kingchenc 3a6b5ebae3 feat(wasm): expose streaming update/isReady/warmupPeriod for 12 candle indicators (R3, R8)
Twelve WASM classes previously exposed only `batch()` (and not even
`reset()` for ten of them): ADX, WilliamsR, CCI, MFI, PSAR, Keltner,
Donchian, VWAP, AwesomeOscillator, Aroon, Stochastic, OBV. Browser
consumers wanting per-tick updates had to replay `batch()` on every new
candle — the opposite of the library's streaming-first promise.

Each class now exposes:

- `update(...)` — per-tick streaming update with the same column inputs
  as `batch()`. Single-output indicators return `Option<f64>`. Multi-
  output indicators (ADX, Keltner, Donchian, Aroon, Stochastic) return a
  named JS object (`{ plusDi, minusDi, adx }`, `{ upper, middle, lower }`,
  `{ up, down }`, `{ k, d }`) once warm, or `null` during warmup. This
  matches the existing `SuperTrend` convention so JS code can treat all
  multi-output WASM indicators uniformly.
- `reset()`, `isReady()`, `warmupPeriod()` — bring the lifecycle API to
  full parity with Python and Node.

`WasmKama` also gains the previously missing `warmupPeriod()` (R8). A
single new `wasm-bindgen-test` exercises every newly wired class against
a deterministic 40-bar synthetic OHLCV stream, asserting that
streaming `update` matches `batch` value-by-value and that the lifecycle
contract behaves the same as the core indicator.
2026-05-23 01:34:54 +02:00
kingchenc c99cf54a1f fix(security): upgrade pyo3 and numpy to 0.28, fix RUSTSEC-2025-0020
Bumps the Python binding from pyo3 0.22 / numpy 0.22 to 0.28 / 0.28,
which resolves RUSTSEC-2025-0020 — a buffer overflow in
`PyString::from_object` that affected every published Python wheel.

Migration:

- `into_pyarray_bound(py)` → `into_pyarray(py)` (numpy 0.23 dropped the
  `_bound` transitional suffix; the method now returns `Bound<'py, _>`
  directly).
- `downcast::<PyDict>` → `cast::<PyDict>` (pyo3 renamed the method on
  `PyAnyMethods`).
- Every `#[pyclass]` declares `skip_from_py_object` to opt out of the
  now-deprecated automatic `FromPyObject` derive for `Clone` types.
  Indicators are stateful — silently extracting them by value-clone is
  never the intended FFI semantics.
- Workspace clippy gains `unused_self = "allow"` on the python crate
  only: Python's `__repr__` protocol forces `&self` even for parameter-
  less indicators where the body does not read state.
- `map_err` arms collapsed into a single `PyValueError` arm
  (clippy::match_same_arms).

`deny.toml` no longer suppresses RUSTSEC-2025-0020; `cargo deny check`
is green on advisories, bans, licenses and sources without exceptions.
2026-05-23 01:26:55 +02:00
kingchenc 2bc6cc5505 docs: round out the docs for Z7's example additions
* examples/README.md — replace the single-row WASM section with a build
  block (one-time `wasm-pack` build), a serve note, and the full
  five-row table (`index`, `backtest`, `live_trading`, `multi_timeframe`,
  `parallel_assets`).
* examples/wasm/README.md — new dedicated index for the WASM demos
  with the build and serve commands and a description of every file
  including the module worker companion.
* CHANGELOG.md `[Unreleased]` gains three bullets: the Python and Node
  `fetch_btcusdt` siblings; the four new WASM browser demos; and the
  three new wiki pages from Z6 (TA-Lib-Migration, Cookbook, FAQ).
2026-05-23 00:45:51 +02:00
kingchenc 6e3190a44a examples(wasm): add a browser parallel-assets demo via Web Workers
Close the final "parallel assets" cell of the cross-language matrix for
WASM.

* examples/wasm/parallel_assets.html — generates a synthetic
  `(assets, bars)` panel deterministically (the LCG matches the Node
  and Rust siblings so timings are directly comparable), runs the
  serial baseline on the main thread, then dispatches the same workload
  to a pool of module Workers and reports the speedup. The render is
  three cards (serial / parallel / speedup) plus a sanity-check line
  asserting per-asset agreement between the two paths.
* examples/wasm/parallel_worker.js — companion module worker that
  loads its own copy of the WebAssembly module via `init()` and
  processes whichever slice of the panel its parent dispatches.

Modern browsers ship module-worker support (`new Worker(url, {
type: "module" })`) which lets every worker do `import init, { SMA, RSI
} from "../../bindings/wasm/pkg/wickra_wasm.js"` without bundler
glue. The inline page module and the worker module both syntax-check
cleanly under `node --check`.
2026-05-23 00:45:01 +02:00
kingchenc 04c3a83fdb examples(wasm): add a browser multi-timeframe demo
Close the "multi-timeframe" cell of the cross-language matrix for WASM.

* examples/wasm/multi_timeframe.html — fetches the bundled 1-minute
  BTCUSDT CSV (or any 1-minute OHLCV CSV), rolls it up in-page to 5m,
  15m, 1h, 4h and 1d buckets, and prints RSI(14), MACD(12,26,9)
  histogram and ADX(14) per timeframe via the WebAssembly bindings.
  Same inline-bucket aggregation as the Node sibling, same indicator
  set as the Python and Rust siblings — the Rust version uses
  `wickra-data::Resampler` directly which is currently a Rust-only API.

The render is a single table (one row per timeframe) so the cross-
language outputs sit side-by-side cleanly. The inline module script
syntax-checks cleanly under `node --check`.
2026-05-23 00:43:54 +02:00
kingchenc c87a16953b examples(wasm): add a browser live-trading demo
Close the "live trading" cell of the cross-language matrix for WASM.

* examples/wasm/live_trading.html — opens a native browser `WebSocket`
  to Binance's public kline stream, feeds every close through RSI(14),
  MACD(12,26,9) and Bollinger(20, 2.0) via the WebAssembly bindings, and
  flags BUY/SELL candidates when all three indicators agree. Mirrors
  the Node and Python live-trading examples in indicator set, signal
  logic and symbol/interval validation — the symbol is checked against
  `^[A-Za-z0-9]+$` before being spliced into the stream URL, the
  interval against the public-API allow-list.

The UI shows live close / RSI / MACD-histogram / Bollinger-band cards,
plus a scrolling log of the last 200 ticks with signal rows highlighted.
Browser-native `WebSocket` means no library dependency. Build the WASM
module once (`wasm-pack build bindings/wasm --target web --release
--features panic-hook`), serve the repository root and open
`examples/wasm/live_trading.html`.
2026-05-23 00:43:05 +02:00
kingchenc 5ea36a6064 examples(wasm): add a browser backtest demo
The WASM example set had only `index.html` (the streaming canvas demo);
the "backtest" cell of the cross-language matrix was empty. Close it.

* examples/wasm/backtest.html — loads the wasm-pack `--target web`
  bundle, fetches an OHLCV CSV from the same `examples/data/` directory
  the other languages use (default: `btcusdt-1d.csv`), parses it
  in-page and streams every candle through SMA, EMA, RSI, MACD,
  Bollinger, ATR, ADX and OBV via the WebAssembly bindings. Renders a
  summary table with mean / min / max / last per series — mirrors the
  Rust, Python and Node backtest examples both in indicator set and in
  output shape.

Build the WASM module once (`wasm-pack build bindings/wasm --target web
--release --features panic-hook`), then serve the repository root and
open `examples/wasm/backtest.html`. The inline module script
syntax-checks cleanly under `node --check` on the extracted body.
2026-05-23 00:42:14 +02:00
kingchenc 303ff0a163 examples(node): add a fetch_btcusdt script using built-in fetch
Node had no sibling for the Rust and Python `fetch_btcusdt`
data-generators — adding it closes the "fetch (data-gen)" cell for the
last remaining row of the cross-language example matrix where the
pattern makes sense.

* examples/node/fetch_btcusdt.js — uses Node 18+'s built-in global
  `fetch` (no npm dependencies); same pagination logic as the Rust and
  Python siblings (paginate backwards via `endTime`, drop the
  in-progress bucket, sort and trim to the configured target). Applies
  the same OHLC validity check the Rust `Candle::new` constructor
  enforces so a malformed kline is skipped rather than written.
* JavaScript's `String(v)` already gives the shortest round-trip
  representation and strips the `.0` suffix for whole-number floats, so
  the CSV output is byte-for-byte identical to what the Rust and
  Python fetchers produce on the same Binance snapshot. Verified by
  running it and `git diff`-ing against the checked-in dataset: every
  row older than the run is unchanged; only the most recent ~24 hours
  drift because the market kept moving.

examples/README.md gains the new row.
2026-05-23 00:40:27 +02:00
kingchenc b948b0b9cf examples(python): add a stdlib-only fetch_btcusdt script
Python had no sibling for the Rust `fetch_btcusdt` data-generator —
adding it closes the "fetch (data-gen)" cell for Python and lets users
without a Rust toolchain regenerate the bundled BTCUSDT datasets.

* examples/python/fetch_btcusdt.py — uses only the standard library
  (urllib.request + json + csv); same pagination strategy as the Rust
  version (paginate backwards via `endTime`, drop the in-progress
  bucket, sort and trim to the configured target). Applies the same
  OHLC validity check the Rust `Candle::new` constructor enforces
  (finite fields, high >= low/open/close, low <= open/close,
  volume >= 0) so a malformed kline is skipped rather than written.
* Number formatting matches Rust's `f64` Display: shortest round-trip,
  no trailing `.0` for whole-number floats. Verified by running the
  script and `git diff`-ing against the checked-in dataset: every row
  older than the run is byte-identical to Rust's output; the diff only
  shows the most recent ~24 hours where Binance has produced fresh
  candles since the original snapshot.

examples/README.md gains the new row.
2026-05-23 00:38:38 +02:00
kingchenc a707eb5d62 docs: refresh the cross-library benchmark numbers
The README and Streaming-vs-Batch benchmark tables were a stale snapshot
("5 000-bar series", numbers from an older machine). Re-run
`python -m benchmarks.compare_libraries` on the current hardware against
the same peer set (finta + talipp; TA-Lib and pandas-ta stay excluded on
Windows) and replace the tables with the fresh numbers.

The new run uses the script's current defaults: a 20 000-bar batch series
and a 5 000-bar seed + 15 000-bar live streaming workload — both more
representative of real backtests than the previous 5 000 / 2 000-bar
sizes. Wickra still wins every batch row outright (3.5× to 1 244× faster
than the nearest peer) and the streaming RSI is ~13.8× faster than
talipp's incremental implementation.
2026-05-23 00:24:41 +02:00
kingchenc 8b4a847d24 docs(wiki): add Cookbook, TA-Lib migration table and FAQ
Three content gaps in the wiki: there was no migration story for users
porting from TA-Lib, no strategy cookbook, and no FAQ. Add all three as
self-contained pages and link them from Home.md's "Wiki contents".

* docs/wiki/TA-Lib-Migration.md — full one-to-one mapping table from
  every common talib.X(...) call to the equivalent Wickra expression,
  plus a "what Wickra has that TA-Lib does not" / "what TA-Lib has that
  Wickra does not (yet)" delta.
* docs/wiki/Cookbook.md — seven concrete strategy recipes (RSI mean
  reversion, MACD histogram crossover, Bollinger breakout, ADX-gated
  trend, multi-timeframe confirmation, SuperTrend trailing stop,
  Chain<EMA, RSI>) with Rust or Python snippets.
* docs/wiki/FAQ.md — common questions on warmup, NaN handling, thread
  safety, installation, performance and comparing Wickra to TA-Lib /
  pandas-ta / talipp / finta.

Also extend the [Unreleased] CHANGELOG entry that records the
examples/<lang>/ restructure with the wiki additions; Home.md gains
three new bullets under "Wiki contents".
2026-05-23 00:23:00 +02:00
kingchenc 43b0b26736 examples: add parallel-assets demos for Rust and Node
Python's parallel_assets.py demoed GIL-release multi-core throughput;
Rust and Node both lacked a sibling that shows their own native
parallelism. Close the gap with two real, runnable examples.

* examples/rust/src/bin/parallel_assets.rs — synthesises an (assets,
  bars) panel with a deterministic per-asset LCG, runs a serial baseline,
  then `Sma::batch_parallel` / `Rsi::batch_parallel` via rayon, asserts
  the two outputs are element-wise identical and prints the speedup.
  Toggle indicator with `--indicator sma|rsi`.
* examples/node/parallel_assets.js — same shape, but the parallel run is
  a `worker_threads` pool that re-loads the native binding in each
  worker. Each worker computes the last non-null indicator value for its
  slice; the main thread aggregates and verifies serial == parallel
  per asset.

Both examples report timings and the serial-vs-parallel sanity check
passes. Defaults (200 × 5000) keep the example fast on dev hardware;
larger `--assets`/`--bars` is where the speedup numbers move (Node's
worker spawn cost dominates the smallest sizes, which is honest and
educational).

examples/README.md gains the two new rows.
2026-05-23 00:18:46 +02:00
kingchenc 962ced0712 examples: add multi-timeframe demos for Rust and Node
Python's examples/python/multi_timeframe.py had no Rust or Node sibling.
Add both — the Rust version uses wickra-data's `Resampler` /
`resample_all` (the canonical path; no manual roll-up), the Node version
mirrors the Python one's inline aggregation because wickra-data's
resampler is currently Rust-only.

* examples/rust/src/bin/multi_timeframe.rs — reads the bundled 1m CSV via
  `CandleReader`, resamples to 5m / 15m / 1h / 4h / 1d via `resample_all`,
  prints last RSI(14), MACD(12,26,9) histogram and ADX(14) per timeframe.
* examples/node/multi_timeframe.js — same outputs from a hand-rolled
  bucket aggregator; reuses the new examples/data/ default path.
* examples/README.md gains the new rows.

Run side by side: the Rust and Node summaries are bit-identical at every
timeframe (50000 / 10000 / 3334 / 834 / 209 / 35 bars; same RSI, MACD
histogram and ADX to two decimals) — confirming both the Rust resampler
and the inline Node aggregator produce the same OHLC buckets.
2026-05-23 00:16:08 +02:00
kingchenc 5a4cf66022 examples: add streaming demos for Python and Rust
Python and Rust both lacked a standalone "streaming indicators" example
that mirrors examples/node/streaming.js — the quickstart docs cover the
pattern, but a runnable file makes the parity visible across all four
languages.

* examples/python/streaming.py — argparse-driven synthetic streaming demo
  feeding SMA(20) / EMA(20) / RSI(14) / MACD(12,26,9), tagging BUY?/SELL?
  candidates when RSI extremes and MACD-histogram direction agree.
* examples/rust/src/bin/streaming.rs — same demo as a wickra-examples
  binary, reusing the seeded LCG so its first 40 rows are bit-identical
  to the Python (and Node) sibling — a strong cross-language consistency
  signal verified by running both side by side.
* examples/README.md gains a `streaming` row in the Rust and Python tables.
2026-05-23 00:13:38 +02:00
kingchenc d87005577e examples: move the WASM browser demo into a top-level examples/wasm/
Finish the per-language `examples/<lang>/` restructure by relocating the
WASM browser demo from bindings/wasm/examples/ to examples/wasm/.

* `examples/wasm/index.html` is the moved file; its WASM module import
  becomes `../../bindings/wasm/pkg/wickra_wasm.js` so the demo still loads
  the wasm-pack output without copying it.
* bindings/wasm/README.md, Quickstart-WASM.md, examples/README.md and the
  root README "Languages" + project-layout block all point at the new
  path. The serve command in the docs now says "serve the repository root
  and open examples/wasm/index.html".

`bindings/wasm/examples/` is empty after the move; the now-empty
directory is removed.
2026-05-23 00:11:07 +02:00
kingchenc 8b9e8e30b9 examples: move Node examples into a top-level examples/node/
Continue the per-language `examples/<lang>/` restructure: move the three
Node example files (streaming.js, backtest.js, live_trading.js) out of
bindings/node/examples/ and into a top-level examples/node/ directory.

* `examples/node/package.json` is a `private` package that pulls the
  native binding via `file:../../bindings/node` and lists `ws` as a
  dev-dependency for the live-trading example. `require('..')` in each
  file becomes `require('wickra')` — exactly what a downstream user would
  write — and the file-header run instructions are updated to the new
  two-step workflow (`npm install` in bindings/node, then in
  examples/node).
* `backtest.js`'s default-CSV path becomes the much shorter
  `__dirname/../data/btcusdt-1d.csv` from the new location.
* `bindings/node/package.json` drops the now-unused `ws` devDependency.
* `.gitignore` is broadened from `bindings/node/node_modules/` to
  `**/node_modules/` so the new `examples/node/node_modules/` directory is
  not tracked.
* The README "Languages" table, project-layout block and
  `examples/README.md` Node section are updated for the new paths and run
  commands.

Verified by running `node backtest.js` (3200 BTCUSDT daily bars, matching
output), `node streaming.js`, and `node --check live_trading.js` from the
new location.
2026-05-23 00:10:06 +02:00
kingchenc 747d1a5b1b examples: move Rust examples into a top-level examples/rust/ crate
The three Rust examples (backtest, fetch_btcusdt, live_binance) used to
live each in their own crate's examples/ dir, splitting the example set
across crates and burying it inside the source tree. Move them into a new
workspace member crate at `examples/rust/` (package `wickra-examples`,
`publish = false`) so all language examples sit under one top-level
`examples/<lang>/` tree.

* `examples/rust/Cargo.toml` declares the per-binary deps (wickra,
  wickra-data with the `live-binance` feature always on, serde_json, tokio
  for the macro and current-thread runtime).
* `examples/rust/src/bin/{backtest,fetch_btcusdt,live_binance}.rs` are the
  three migrated binaries; their doc-comments and the fetch_btcusdt output
  path are updated for the new location and run command
  (`cargo run -p wickra-examples --bin <name>`).
* Workspace `Cargo.toml` lists the new member; the now-empty
  `[dev-dependencies]` extras (`wickra`, `tokio` in wickra-data and
  `serde_json` in wickra) that existed only for these examples are dropped.
* The `[[example]] live_binance` table is removed from wickra-data's
  manifest since the file moved out.
* README "Languages" + project-layout, examples/README.md, Quickstart-Rust
  and Data-Layer are pointed at the new paths and commands.

`cargo build -p wickra-examples` and `cargo run --release -p wickra-examples
--bin backtest -- examples/data/btcusdt-1d.csv` both succeed; the rest of
the workspace (core, data, wickra) builds, clippies (`--all-targets -D
warnings`) and tests (508 core + 28 data + 1 integration + 74+3+1
doctests) all stay green.
2026-05-23 00:07:07 +02:00
kingchenc a1c646ae7c examples: move the bundled BTCUSDT datasets to a top-level examples/data/
The seven BTCUSDT OHLCV datasets used to live under
crates/wickra/examples/data/, which buried them inside a Rust crate even
though the Node backtest example and the upcoming Rust/Node/WASM example
restructure need to reach them too. Move them to the workspace-level
examples/data/ so every language's examples can resolve the same path.

The bench (crates/wickra/benches/indicators.rs), the example_data
integration test, fetch_btcusdt.rs and the Node backtest example all take
the new ../../examples/data/ path; Data-Layer.md, examples/README.md and
the CHANGELOG entry are updated to match. No data file content changes.
2026-05-23 00:01:52 +02:00
kingchenc ba10898801 docs: add a cross-language examples index
The top-level examples/ directory held only python/, which made the
examples look Python-only even though Rust, Node and WASM all ship their
own. Add examples/README.md: a single index of every runnable example
across Rust, Python, Node and WASM, each with its run command, plus a note
on the bundled BTCUSDT datasets.

Point the README "Languages" table at the Node backtest example and link
the new index from both the table and the project-layout section.
2026-05-22 22:47:13 +02:00
kingchenc 25454fa89a examples(node): add a live Binance trading example
Mirror examples/python/live_trading.py for the Node binding: connect to the
public Binance kline WebSocket, stream close prices through RSI / MACD /
Bollinger Bands, and print BUY/SELL candidate signals when all three agree.
The symbol and interval are validated before being spliced into the stream
URL, and non-kline frames (acks, heartbeats) are skipped.

Uses the standard `ws` package, added as a devDependency so it installs
with `npm install` for anyone running the examples but never reaches a
consumer of the published package.
2026-05-22 22:45:52 +02:00
kingchenc 2eabda5fa3 examples(node): add an offline backtest example
The Node binding shipped only one example (a synthetic streaming demo),
while Python and Rust both have a CSV backtest. Add the Node counterpart of
examples/python/backtest.py and crates/wickra/examples/backtest.rs: it reads
an OHLCV CSV, streams every candle through a basket of indicators (SMA, EMA,
RSI, MACD, Bollinger Bands, ATR, ADX, OBV) via the O(1) update call, and
prints a per-series summary.

With no argument it runs against the bundled BTCUSDT daily dataset, so it is
runnable out of the box; pass a path to use any other OHLCV CSV.
2026-05-22 22:43:21 +02:00
kingchenc c6938e8473 docs: refresh the stale Python and Node binding READMEs
The Python binding README still advertised "63 indicators across four
families" with the pre-restructure five-group taxonomy, missing the eight
indicators added since. Update it to "71 indicators across eight families"
with the catalogue grouped to match the main README.

The Node binding README referred to the package as @wickra/wickra in its
title, install command and import example; the published package is named
wickra (per bindings/node/package.json). Correct all three.
2026-05-22 22:21:49 +02:00
kingchenc 91f24946a6 docs(changelog): record the example datasets and Timeframe constructors
Add the still-unreleased Z1/Z2 work to the [Unreleased] section: the seven
real-BTCUSDT example datasets plus the fetch_btcusdt example, the
Timeframe::minutes/hours/days constructors, and the switch of the indicator
benchmarks from a synthetic series to the checked-in BTCUSDT dataset.
2026-05-22 22:20:26 +02:00
kingchenc d5ff0a9df6 wickra-data: add minutes/hours/days Timeframe constructors
Timeframe gained new/millis/seconds/one_minute_ms; add minutes, hours and
days alongside them. Each builds on seconds (minutes(5) -> a 300-second
bucket), consistent with Timeframe::seconds, and guards the multiplication
with checked_mul so an oversized n yields Error::InvalidTimeframe instead
of an overflow panic. A non-positive n is rejected by Timeframe::new.

Each method carries a runnable doctest, and unit tests cover the known
bucket sizes, non-positive rejection and overflow rejection.
2026-05-22 21:49:21 +02:00
kingchenc 2b3a1b7384 examples: add real BTCUSDT candle datasets from Binance
Add seven OHLCV datasets under crates/wickra/examples/data/, one per
timeframe (1m/5m/15m/1h/12h/1d/1month), holding real BTCUSDT spot klines
fetched from the Binance REST API. The new fetch_btcusdt example
regenerates them: it paginates the klines endpoint through the system
curl, parses with serde_json, validates every candle via Candle::new and
keeps only fully closed buckets.

The indicator benchmarks now run against the 1m dataset instead of a
synthetic series, and a new example_data integration test checks that
every file parses and carries evenly spaced, monotonic timestamps.

The monthly file is named btcusdt-1month.csv rather than btcusdt-1M.csv
so it does not collide with btcusdt-1m.csv on case-insensitive
filesystems (Windows, default macOS).
2026-05-22 21:47:17 +02:00
kingchenc d2f99efd78 F13c: restructure the indicator catalogue into eight families
The original taxonomy was four classical families plus a statistics group,
with the F1-F12 expansion slotted in as sub-categories. This regroups the
whole 71-indicator catalogue into eight top-level families, each with at
least five members:

  Moving Averages (12), Momentum Oscillators (13), Trend & Directional (9),
  Price Oscillators (5), Volatility & Bands (12), Trailing Stops (5),
  Volume (9), Price Statistics (7).

- Wiki: docs/wiki/indicators/ reorganised into eight family folders; all 71
  indicator pages moved with `git mv`. Every internal cross-link is
  normalised to `../<family>/Indicator-X.md`, each page's `Family` field is
  set to its new family, and two pre-existing `../Indicator-Chaining.md`
  links (should have been `../../`) are corrected. A link check confirms
  every relative wiki link resolves.
- Indicators-Overview.md fully rewritten around the eight families;
  Home.md indicator reference and the README family table follow suit.
- Warmup-Periods.md gains the eight F13 indicators; CHANGELOG records the
  46-indicator expansion (25 -> 71) and the eight-family taxonomy.
- Tests: Node indicators.test.js and Python test_new_indicators.py cover
  all eight new indicators (Node 91/91, Python 117/117 green).

cargo fmt + clippy (core/wickra/data/wasm/node) clean; 508 core tests,
25 data tests and 74 doctests green.
2026-05-22 21:21:56 +02:00
kingchenc 6643f7a81d F13b: add True Range, Chaikin Volatility, Z-Score and Linear Regression Angle
Second half of the eight indicators that fill out the new family taxonomy.

- Rust core: true_range.rs (TrueRange — the raw single-bar volatility ATR
  averages), chaikin_volatility.rs (ChaikinVolatility — rate of change of a
  smoothed high-low spread), z_score.rs (ZScore — price normalised against
  its rolling mean and standard deviation) and linreg_angle.rs (LinRegAngle
  — the rolling regression slope as a degree angle). Each with a full
  Indicator impl, runnable doctest and reference / property / warmup /
  reset / batch==streaming tests.
- Python / Node / WASM: classes wired through all three bindings (ZScore
  and LinRegAngle ride the scalar macros where possible) plus .pyi stubs
  and __init__.py / __all__ entries.
- Wiki: four new Indicator-*.md pages.

The eight-family taxonomy restructure (Overview / Home / README / folder
layout) lands next in F13c.

cargo fmt + clippy (core/wickra/data/wasm/node) clean; 508 core tests,
25 data tests and 74 doctests green.
2026-05-22 21:06:36 +02:00
kingchenc e452d35a27 F13a: add Accelerator Oscillator, Balance of Power, Choppiness Index and Vertical Horizontal Filter
First half of the eight indicators that fill out the new family taxonomy.

- Rust core: accelerator_oscillator.rs (AcceleratorOscillator — AO minus a
  short SMA of itself), balance_of_power.rs (BalanceOfPower — per-bar
  (close-open)/(high-low)), choppiness_index.rs (ChoppinessIndex — summed
  true range over the high-low span, log-scaled) and
  vertical_horizontal_filter.rs (VerticalHorizontalFilter — net move over
  total move). Each with a full Indicator impl, runnable doctest and
  reference / property / warmup / reset / batch==streaming tests.
- Python / Node / WASM: classes wired through all three bindings
  (BalanceOfPower carries an explicit open column; VHF rides the scalar
  macros) plus .pyi stubs and __init__.py / __all__ entries.
- Wiki: four new Indicator-*.md pages.

The eight-family taxonomy restructure (Overview / Home / README / folder
layout) lands in F13c once F13b's four indicators are in.

cargo fmt + clippy (core/wickra/data/wasm/node) clean; 481 core tests,
25 data tests and 70 doctests green.
2026-05-22 20:57:52 +02:00
kingchenc 27f37f5347 docs(python): refresh the binding README's indicator list to 63
The Python binding README still advertised the original 25 indicators and
the four-family list. Bring it in line with the root README: 63 indicators
across the four classical families plus the statistics group.
2026-05-22 20:31:52 +02:00
kingchenc 2f3b5cc3be F-Abschluss: wire the Python package, refresh docs and extend the test suites
Finalises the F1-F12 indicator expansion (25 -> 63 indicators).

- Python `wickra/__init__.py`: import and re-export all 63 indicators,
  grouped by family, with a matching `__all__`. The package previously
  exposed only the original 25 even though the compiled module and the
  `.pyi` stubs already carried the rest.
- Docs: `Home.md` and `README.md` indicator counts and family tables
  updated to 63; `Indicators-Overview.md` already restructured per family
  in F10-F12; `Warmup-Periods.md` gains all 38 new indicators across the
  single- and multi-output tables (and the stale two-arg `Psar::new`
  example is corrected to three args); `CHANGELOG.md` `[Unreleased]` lists
  every new indicator by family.
- Tests: `bindings/node/__tests__/indicators.test.js` covers all 63
  indicators (streaming==batch plus four new reference-value checks),
  80/80 green; new `bindings/python/tests/test_new_indicators.py` covers
  the 38 additions (streaming==batch, shapes, reference values,
  lifecycle), Python suite 105/105 green.
- `bindings/node/index.js` regenerated by `napi build`.

cargo fmt + clippy (core/wickra/data/wasm/node) clean; 454 core tests,
25 data tests, 66 doctests, 80 Node tests and 105 Python tests green;
`cargo check -p wickra-wasm --tests` green.
2026-05-22 20:04:13 +02:00
kingchenc 2d0ee926c5 F12: add price transforms and rolling linear regression
- Rust core: typical_price.rs ((H+L+C)/3), median_price.rs ((H+L)/2),
  weighted_close.rs ((H+L+2C)/4) — stateless per-bar OHLC transforms — and
  linreg.rs (LinearRegression — endpoint of a rolling ordinary-least-squares
  fit) and linreg_slope.rs (LinRegSlope — slope of that fit). Each with a
  full Indicator impl, runnable doctest and reference / property / warmup /
  reset / batch==streaming tests.
- Python: PyTypicalPrice / PyMedianPrice / PyWeightedClose /
  PyLinearRegression / PyLinRegSlope PyO3 classes + module registration +
  .pyi stubs.
- Node: explicit TypicalPriceNode / MedianPriceNode / WeightedCloseNode /
  LinearRegressionNode / LinRegSlopeNode; index.d.ts and index.js updated.
- WASM: explicit WasmTypicalPrice / WasmMedianPrice / WasmWeightedClose;
  WasmLinearRegression / WasmLinRegSlope via the scalar macro.
- Wiki: a new indicators/statistics/ folder with five Indicator-*.md pages,
  a new "Statistics" family in Indicators-Overview.md and Home.md.

cargo fmt + clippy (core/wickra/data/wasm/node) clean; 454 core tests,
25 data tests and 66 doctests green.
2026-05-22 19:52:04 +02:00
kingchenc 21bbd521b3 F11: add SuperTrend, Chandelier Exit, Chande Kroll Stop and ATR Trailing Stop
- Rust core: super_trend.rs (SuperTrend — ATR-banded trailing stop with
  flip logic; SuperTrendOutput { value, direction }), chandelier_exit.rs
  (Chandelier Exit — ATR stop hung off the window's highest high / lowest
  low; ChandelierExitOutput { long_stop, short_stop }),
  chande_kroll_stop.rs (Chande Kroll Stop — a two-stage ATR stop;
  ChandeKrollStopOutput { stop_long, stop_short }), atr_trailing_stop.rs
  (ATR Trailing Stop — a single ratcheting close-based stop). Each with a
  full Indicator impl, runnable doctest and reference / property / warmup
  / reset / batch==streaming tests.
- Python: PySuperTrend / PyChandelierExit / PyChandeKrollStop /
  PyAtrTrailingStop PyO3 classes (struct outputs as tuples and (n, 2)
  arrays) + module registration + .pyi stubs.
- Node: explicit SuperTrendNode / ChandelierExitNode / ChandeKrollStopNode
  / AtrTrailingStopNode with SuperTrendValue / ChandelierExitValue /
  ChandeKrollStopValue objects; index.d.ts and index.js updated.
- WASM: WasmSuperTrend / WasmChandelierExit / WasmChandeKrollStop /
  WasmAtrTrailingStop.
- Wiki: Indicator-SuperTrend/ChandelierExit/ChandeKrollStop/
  AtrTrailingStop.md plus rows in the "Trailing stop" table of
  Indicators-Overview.md and entries in Home.md.
- Add clippy.toml with doc-valid-idents for the proper noun "LeBeau".

cargo fmt + clippy (core/wickra/data/wasm/node) clean; 427 core tests,
25 data tests and 61 doctests green.
2026-05-22 19:42:14 +02:00
kingchenc 0b11a523a0 F10: add Chaikin Money Flow, Chaikin Oscillator, Force Index and Ease of Movement
- Rust core: cmf.rs (Chaikin Money Flow — summed money-flow volume over
  summed volume, bounded to [-1, +1]), chaikin_oscillator.rs (Chaikin
  Oscillator — the MACD of the ADL, EMA(ADL, fast) - EMA(ADL, slow)),
  force_index.rs (Elder's Force Index — EMA of price change scaled by
  volume), ease_of_movement.rs (Arms' Ease of Movement — SMA of distance
  travelled per unit of volume). Each with a full Indicator impl,
  runnable doctest and reference / property / warmup / reset /
  batch==streaming tests.
- Python: PyChaikinMoneyFlow / PyChaikinOscillator / PyForceIndex /
  PyEaseOfMovement PyO3 classes + module registration + .pyi stubs.
- Node: explicit ChaikinMoneyFlowNode / ChaikinOscillatorNode /
  ForceIndexNode / EaseOfMovementNode; index.d.ts and index.js updated.
- WASM: WasmChaikinMoneyFlow / WasmChaikinOscillator / WasmForceIndex /
  WasmEaseOfMovement.
- Wiki: Indicator-ChaikinMoneyFlow/ChaikinOscillator/ForceIndex/
  EaseOfMovement.md plus a new "Oscillators" sub-table in
  Indicators-Overview.md and entries in Home.md.

cargo fmt + clippy (core/wickra/data/wasm/node) clean; 402 core tests,
25 data tests and 57 doctests green.
2026-05-22 19:25:32 +02:00
kingchenc 81962485af F9: add Accumulation/Distribution Line and Volume-Price Trend
Completes the F9 family (Cumulative volume) end to end:

- Rust core: adl.rs (Accumulation/Distribution Line — cumulative
  range-weighted volume) and vpt.rs (Volume-Price Trend — cumulative
  volume scaled by percentage price change). Each with a full Indicator
  impl, runnable doctest and reference / cumulative-property / warmup /
  reset / batch==streaming tests.
- Python: PyAdl / PyVolumePriceTrend PyO3 classes + module registration
  + .pyi stubs (no parameters, like OBV/VWAP).
- Node: explicit AdlNode and VolumePriceTrendNode; index.d.ts and
  index.js updated.
- WASM: WasmAdl and WasmVolumePriceTrend.
- Wiki: Indicator-Adl.md and Indicator-VolumePriceTrend.md plus rows in
  Indicators-Overview.md and entries in Home.md.

cargo fmt + clippy (core/wickra/data/wasm/node) clean; 373 core tests,
25 data tests and 53 doctests green.
2026-05-22 18:38:21 +02:00
kingchenc 99dd144576 F8: add Bollinger Bandwidth and %b
Completes the F8 family (Bands & channels) end to end:

- Rust core: bollinger_bandwidth.rs ((upper - lower) / middle — the
  squeeze gauge) and percent_b.rs ((price - lower) / (upper - lower) —
  price position within the bands, unclamped). Both wrap BollingerBands
  and carry a full Indicator impl, runnable doctest and reference /
  constant-series / definition-consistency / warmup / reset /
  batch==streaming tests.
- Python: PyBollingerBandwidth / PyPercentB PyO3 classes + module
  registration + .pyi stubs (defaults (20, 2.0)).
- Node: explicit BollingerBandwidthNode and PercentBNode; index.d.ts
  and index.js updated.
- WASM: WasmBollingerBandwidth / WasmPercentB via the scalar macro.
- Wiki: Indicator-BollingerBandwidth.md and Indicator-PercentB.md plus
  rows in Indicators-Overview.md and entries in Home.md.

cargo fmt + clippy (core/wickra/data/wasm/node) clean; 362 core tests,
25 data tests and 51 doctests green.
2026-05-22 18:30:49 +02:00
kingchenc 6c58d3827c F7: add NATR, StdDev, Ulcer Index and Historical Volatility
Completes the F7 family (Volatility) end to end:

- Rust core: natr.rs (ATR as a percentage of close), std_dev.rs
  (rolling population standard deviation), ulcer_index.rs (RMS of
  trailing-high drawdowns — downside-only risk), historical_volatility.rs
  (annualised sample stddev of log returns). Each with a full Indicator
  impl, runnable doctest and reference / constant-series / warmup /
  reset / batch==streaming tests.
- Python: PyNatr / PyStdDev / PyUlcerIndex / PyHistoricalVolatility
  PyO3 classes + module registration + .pyi stubs.
- Node: StdDevNode / UlcerIndexNode via the scalar macro, explicit
  NatrNode and HistoricalVolatilityNode; index.d.ts and index.js updated.
- WASM: WasmStdDev / WasmUlcerIndex / WasmHistoricalVolatility via the
  scalar macro, explicit WasmNatr.
- Wiki: Indicator-Natr/StdDev/UlcerIndex/HistoricalVolatility.md plus
  rows in Indicators-Overview.md and entries in Home.md.

cargo fmt + clippy (core/wickra/data/wasm/node) clean; 350 core tests,
25 data tests and 49 doctests green.
2026-05-22 18:26:29 +02:00
kingchenc 16c0639f0c F6: add Aroon Oscillator, Vortex and Mass Index
Completes the F6 family (Trend strength) end to end:

- Rust core: aroon_oscillator.rs (AroonUp - AroonDown, one-line trend
  gauge), vortex.rs (Vortex Indicator VI+/VI- with the VortexOutput
  struct), mass_index.rs (Dorsey's range-expansion sum of the
  EMA-of-range ratio). Each with a full Indicator impl, runnable doctest
  and reference / saturation / warmup / reset / batch==streaming tests.
- Python: PyAroonOscillator / PyVortex / PyMassIndex PyO3 classes +
  module registration + .pyi stubs (defaults Aroon=14, Vortex=14,
  MassIndex=(9,25)).
- Node: explicit AroonOscillatorNode, VortexNode (with VortexValue
  object) and MassIndexNode; index.d.ts and index.js updated.
- WASM: WasmAroonOscillator, WasmVortex, WasmMassIndex.
- Wiki: Indicator-AroonOscillator/Vortex/MassIndex.md plus rows in
  Indicators-Overview.md and entries in Home.md.

cargo fmt + clippy (core/wickra/data/wasm/node) clean; 320 core tests,
25 data tests and 45 doctests green.
2026-05-22 18:17:38 +02:00
kingchenc 54148cad5b F5: add PPO, DPO and Coppock Curve price oscillators
Completes the F5 family (Price oscillators) end to end:

- Rust core: ppo.rs (Percentage Price Oscillator — MACD as a percentage
  of the slow EMA), dpo.rs (Detrended Price Oscillator — shifted price
  minus its SMA), coppock.rs (Coppock Curve — WMA of two summed ROCs).
  Each with a full Indicator impl, runnable doctest and reference /
  constant-series / warmup / reset / batch==streaming / non-finite tests.
- Python: PyPpo / PyDpo / PyCoppock PyO3 classes + module registration
  + .pyi stubs (defaults PPO=(12,26), DPO=20, Coppock=(14,11,10)).
- Node: DpoNode via the scalar macro, explicit PpoNode and CoppockNode;
  index.d.ts and index.js updated.
- WASM: WasmDpo / WasmPpo / WasmCoppock via the scalar macro.
- Wiki: Indicator-Ppo/Dpo/Coppock.md plus rows in Indicators-Overview.md
  and entries in Home.md.

cargo fmt + clippy (core/wickra/data/wasm/node) clean; 300 core tests,
25 data tests and 42 doctests green.
2026-05-22 18:09:10 +02:00
kingchenc e24e7726ce F4: add StochRSI and Ultimate Oscillator
Completes the F4 family (Stochastic oscillators) end to end:

- Rust core: stoch_rsi.rs (Stochastic Oscillator applied to the RSI
  series, bounded [0,100]) and ultimate_oscillator.rs (Larry Williams'
  weighted three-timeframe buying-pressure oscillator). Each with a full
  Indicator impl, runnable doctest and reference / saturation / bounds /
  warmup / reset / batch==streaming tests.
- Python: PyStochRsi / PyUltimateOscillator PyO3 classes + module
  registration + .pyi stubs (defaults StochRSI=(14,14), UO=(7,14,28)).
- Node: explicit StochRsiNode and UltimateOscillatorNode; index.d.ts
  and index.js updated.
- WASM: WasmStochRsi via the scalar macro, explicit
  WasmUltimateOscillator.
- Wiki: Indicator-StochRsi.md and Indicator-UltimateOscillator.md plus
  rows in Indicators-Overview.md and entries in Home.md.

cargo fmt + clippy (core/wickra/data/wasm/node) clean; 278 core tests,
25 data tests and 39 doctests green.
2026-05-22 18:02:44 +02:00
kingchenc 7728151c87 F3: add MOM, CMO, TSI and PMO momentum indicators
Completes the F3 family (Momentum) end to end:

- Rust core: mom.rs (raw price-difference momentum), cmo.rs (Chande
  Momentum Oscillator — unsmoothed gain/loss sum, bounded [-100,100]),
  tsi.rs (True Strength Index — double-EMA-smoothed momentum ratio),
  pmo.rs (DecisionPoint Price Momentum Oscillator — doubly-smoothed ROC
  with the 2/period custom smoothing). Each with a full Indicator impl,
  runnable doctest and reference-value / saturation / warmup / reset /
  batch==streaming / non-finite tests.
- Python: PyMom / PyCmo / PyTsi / PyPmo PyO3 classes + module
  registration + .pyi stubs (defaults MOM=10, CMO=14, TSI=(25,13),
  PMO=(35,20)).
- Node: MomNode / CmoNode via the scalar macro, explicit TsiNode and
  PmoNode; index.d.ts and index.js updated.
- WASM: WasmMom / WasmCmo / WasmTsi / WasmPmo via the scalar macro.
- Wiki: Indicator-Mom/Cmo/Tsi/Pmo.md plus rows in Indicators-Overview.md
  and entries in Home.md.

cargo fmt + clippy (core/wickra/data/wasm/node) clean; 262 core tests,
25 data tests and 37 doctests green.
2026-05-22 17:53:46 +02:00
kingchenc 780a176072 F2: add ZLEMA, T3 and VWMA advanced moving averages
Completes the F2 family (Advanced MAs) end to end:

- Rust core: zlema.rs (Zero-Lag EMA over the de-lagged series
  2·price − price[lag]), t3.rs (Tillson's six-EMA cascade with the
  volume-factor polynomial), vwma.rs (volume-weighted rolling mean with
  a zero-volume fallback to the unweighted mean). Each with a full
  Indicator impl, runnable doctest and reference-value / warmup /
  reset / batch==streaming / non-finite tests.
- Python: PyZlema / PyT3 / PyVwma PyO3 classes + module registration
  + .pyi stubs (T3 defaults v=0.7).
- Node: ZlemaNode via the scalar macro, explicit T3Node and VwmaNode
  classes; index.d.ts and index.js updated.
- WASM: WasmZlema / WasmT3 via the scalar macro, explicit WasmVwma.
- Wiki: Indicator-Zlema.md, Indicator-T3.md, Indicator-Vwma.md plus
  rows in Indicators-Overview.md and entries in Home.md.

cargo fmt + clippy (core/wickra/data/wasm/node) clean; 232 core tests,
25 data tests and 33 doctests green.
2026-05-22 17:45:02 +02:00
kingchenc ed7324115c F1: wire SMMA and TRIMA through every binding and the wiki
Completes the F1 family (Simple & Weighted MAs). The Rust core for both
SMMA (Wilder's RMA) and TRIMA (triangular MA) already landed; this adds
the remaining Definition-of-Done steps:

- Python: PySmma / PyTrima PyO3 classes + module registration + .pyi stubs.
- Node: SmmaNode / TrimaNode via the scalar-indicator macro; index.d.ts
  and index.js updated for the two new classes.
- WASM: WasmSmma / WasmTrima via the scalar-indicator macro.
- Wiki: Indicator-Smma.md and Indicator-Trima.md (full pages) plus rows
  in Indicators-Overview.md and entries in Home.md.

cargo fmt + clippy (core/wickra/data/wasm/node) clean; 208 core tests,
25 data tests and 31 doctests green.
2026-05-22 17:34:38 +02:00
kingchenc abd2d80f8d F1: add SMMA and TRIMA moving averages (core)
First step of the indicator-family expansion (see the F section of
todo-detailed.md). Family F1 — Simple & Weighted MAs — gains two
members alongside the existing Sma/Ema/Wma:

- Smma — Wilder's smoothed moving average (RMA): SMA-seeded, then the
  (prev*(n-1)+x)/n recurrence. The average underlying RSI and ATR.
- Trima — triangular moving average: two stacked SMAs (n1/n2 split by
  parity) that triangular-weight the window. Genuine stacking — the
  outer SMA consumes the inner SMA's output.

Both implement the full Indicator trait with reference-value, warmup,
reset, batch==streaming and non-finite-input tests, a runnable doctest,
and are re-exported from the crate root. 208 core tests + 30 doctests
pass; clippy and fmt clean.
2026-05-22 17:10:52 +02:00
kingchenc 3e8c48eefc fix(wasm): call expect() directly instead of ok().expect() in tests
The WASM binding's test module (added in B6) used `.ok().expect(...)`
on the Result-returning constructors. clippy's ok_expect lint rejects
this under the workspace's `-D warnings`, and the CI rust job lints
wickra-wasm with --all-targets — so the branch would fail CI.

Replace all seven `.ok().expect(...)` with `.expect(...)` directly;
JsError implements Debug, so this compiles and gives a better panic
message. clippy and fmt are now clean for wickra-wasm.
2026-05-22 16:47:16 +02:00
kingchenc a4d8c40dc2 E6: extend the Python release matrix to musl and Windows arm64
python-wheels built only glibc Linux (x86_64/aarch64), macOS, and
Windows x64 — Alpine/musl users and Windows arm64 had no wheel.

Add musllinux_1_2 wheels for x86_64 and aarch64 Linux and an
aarch64 wheel on the windows-11-arm runner. The upload artifact name
now includes the manylinux value so the glibc and musl builds of the
same architecture do not collide. The Node release matrix already
covers linux-arm64 and win32-arm64 (added in B11); Node musl is left
out, as B11 documented, because it needs a cross/container setup.
2026-05-22 16:45:20 +02:00
kingchenc 78c31d1bed E16: add a cargo-fuzz harness
The repository had no fuzzing setup despite several natural targets —
the CSV parser, the Binance envelope deserializer, and the stateful
indicator/aggregator update paths.

Add a fuzz/ cargo-fuzz crate (detached from the workspace via its own
[workspace] table and the parent's exclude) with four targets:

- csv_reader      — CandleReader over arbitrary bytes
- binance_envelope — RawWsEnvelope deserialization from arbitrary strings
- indicator_update — RSI/EMA streaming + batch over arbitrary f64 series
- tick_aggregator — TickAggregator over arbitrary tick triples

Each target asserts the no-panic contract: malformed input must surface
as an Err. fuzz/README.md documents running them (nightly + cargo-fuzz).
2026-05-22 16:44:19 +02:00
kingchenc 4b3227a15f E15: add a runnable doctest to every indicator type
Only two doctests existed in wickra-core; none of the 25 indicator
types carried a runnable rustdoc example.

Add an "# Example" doctest to every public indicator type (all 26,
including RollingVwap): construct the indicator and stream 80 inputs
through update, asserting a value is produced. The candle-input
indicators build valid OHLCV candles inline. cargo test --doc
-p wickra-core now runs 28 doctests, all passing; fmt and clippy clean.
2026-05-22 16:41:43 +02:00
kingchenc 5568596e18 E8: add code coverage to CI
CI had no coverage measurement, and although .gitignore listed coverage
artefacts nothing produced them.

Add a `coverage` job that runs cargo-llvm-cov over the three pure-Rust
crates (with wickra-data's live-binance feature so the Binance parser
tests count), emits lcov, and uploads to Codecov. The Codecov upload
uses fail_ci_if_error: false so a Codecov outage cannot break CI. Both
new actions (taiki-e/install-action, codecov/codecov-action) are
SHA-pinned. Add a coverage badge to the README.
2026-05-22 16:38:01 +02:00
kingchenc 5917d4928f E13: add the WASM quickstart and the data-layer wiki page
The wiki had quickstarts for Python, Rust, and Node but none for the
WebAssembly binding, and the wickra-data crate (CSV reader, tick
aggregator, resampler, Binance feed) was not documented anywhere.

- Quickstart-WASM.md: install via npm, building with wasm-pack, and
  streaming/batch/multi-output usage in a browser or bundler.
- Data-Layer.md: the wickra-data crate — CandleReader, TickAggregator
  (including the opt-in gap fill), Resampler/resample_all, and the
  feature-gated Binance live feed.
- Home.md links both from the wiki contents list.
2026-05-22 16:36:29 +02:00
kingchenc 9d822d26aa E18: make the RollingVwap documentation directly linkable
RollingVwap is a separate public type (pub struct RollingVwap in
vwap.rs) and Indicator-Vwap.md already documents it in a full
"## RollingVwap (finite window)" section, but it was not directly
reachable: the Overview row added in E12 pointed at a #rollingvwap
anchor that does not exist.

Fix the Overview link to the real #rollingvwap-finite-window anchor and
add a jump-to note at the top of Indicator-Vwap.md so both public types
are reachable in one click.
2026-05-22 16:35:01 +02:00
kingchenc 0d20973a4d E12: link every Overview row to its deep-dive page
Indicators-Overview.md only had a "Deep dive" column in the Trend
tables; the Momentum, Volatility and Volume tables left readers without
a path to the per-indicator pages.

Add a "Deep dive" column to all eight remaining tables, linking each of
the 18 rows to its indicators/<family>/Indicator-*.md page. RollingVwap
points at the RollingVwap section of Indicator-Vwap.md (added in E18).
All link targets verified to exist.
2026-05-22 16:33:44 +02:00
kingchenc f628bd5fb7 E11: classify TRIX as a momentum indicator consistently
Indicators-Overview.md listed Trix in the Trend section's EMA-family
table, while the README and the docs folder layout
(indicators/momentum/Indicator-Trix.md) place it under Momentum.

Move the Trix row into the Momentum "Unbounded oscillators" table — it
emits a rate of change, not a price-scale trend line — and leave a note
in the Trend section pointing there. Momentum is now the single
canonical family across the README, the folder layout, and the
overview.
2026-05-22 16:31:43 +02:00
kingchenc 87b3f383d6 E5: update the warmup docs to the post-A5 behavior
A5 changed Keltner and HMA to feed their sibling sub-indicators
unconditionally, so warmup_period() is now the exact first-emission
index for every indicator. The wiki still described the old
?-starvation behavior as correct.

- Indicator-Keltner.md: the Warmup section, the worked example output
  (first emission now at i=2, not i=4), the summary table row, and the
  "reported warmup understates" pitfall now state that warmup_period()
  is exact. Example output regenerated by running the code.
- Indicator-Hma.md: the Warmup section, all three language examples
  (first Some at index 10, not 13), the table row, and the chaining
  pitfall corrected. Outputs regenerated.
- Indicators-Overview.md: dropped the claim that Hma and Kama lag their
  reported warmup — both were verified exact.
2026-05-22 16:30:56 +02:00
kingchenc 71e46a1ea6 E10: add cargo-deny supply-chain checks
The repository had no supply-chain auditing — no deny.toml and no CI
job to catch vulnerable, unmaintained, wrongly-licensed, or
unexpectedly-sourced dependencies.

Add deny.toml covering advisories, bans, licenses and sources:

- licenses: an allow-list of the permissive licenses the dependency
  tree actually uses, plus the workspace's own PolyForm-Noncommercial
  license and a scoped LLVM-exception for target-lexicon.
- bans: warn on duplicate versions, deny external wildcard deps
  (internal path deps are allowed).
- sources: only crates.io.
- advisories: RUSTSEC-2025-0020 (pyo3 0.22) is ignored with a documented
  reason — it is reachable only through bindings/python and the pyo3
  upgrade is tracked separately; the published crates do not use pyo3.

Add a `supply-chain` CI job running cargo-deny-action (SHA-pinned).
`cargo deny check` passes locally: advisories/bans/licenses/sources ok.
2026-05-22 16:25:58 +02:00
kingchenc 51f64b53c0 E7: correct inaccuracies in the README
- "## Indicators in 0.1.0" -> "## Indicators" (the heading drifted from
  the actual version; making it version-neutral stops the drift).
- Project layout: there is no top-level benches/ directory — Rust
  benches and examples live inside their crate. The tree now shows
  crates/wickra/benches, the per-crate examples, and the new
  bindings/node and bindings/wasm examples/ directories.
- Node "Example" pointed at a test file; it now points at the real
  bindings/node/examples/streaming.js (added in E17).
- "## Test counts" hardcoded numbers (171/11/56/7) that drift on every
  added test. Replaced with a version-neutral "## Testing" section that
  describes what each suite covers, including the WASM tests.
2026-05-22 16:23:43 +02:00
kingchenc b919c33dee E17: add a runnable Node example
bindings/node had no examples/ directory — the README pointed at a test
file as its "Example". (bindings/wasm/examples/index.html already
exists and is a complete browser demo, so only the Node side was
missing.)

Add bindings/node/examples/streaming.js: a deterministic synthetic
price series fed tick by tick through SMA, EMA, RSI and MACD, printing
a status line and flagging overbought/oversold candidates — the same
O(1)-per-update streaming model a live bot would use. Verified against
the built native module.
2026-05-22 16:22:52 +02:00
kingchenc a79606b4ce E9: complete the published crate manifests
The three published crates had no documentation link and no docs.rs
configuration, so wickra-data's feature-gated live-binance module would
not render on docs.rs.

Add documentation = "https://docs.rs/<crate>" and a
[package.metadata.docs.rs] section with all-features = true to
wickra-core, wickra, and wickra-data. No `exclude` is added: each crate
directory contains only src/ (plus benches/examples that are useful
source), so there is nothing irrelevant to drop from the .crate.
2026-05-22 16:20:43 +02:00
kingchenc 9fd926ecd8 E19: unify the author metadata
The workspace Cargo.toml declared authors = ["Wickra Contributors"]
while the npm package.json and the WASM package.json enrich step in
release.yml both use "kingchenc <kingchencp@gmail.com>".

Make the canonical author "kingchenc <kingchencp@gmail.com>" — the
actual maintainer, already used by both npm packages — and align the
workspace manifest to it. All three registries now agree.
2026-05-22 16:19:22 +02:00
kingchenc b3ddbea584 E14: remove hardcoded local paths from the docs
Indicators-Overview.md referenced the absolute author-machine paths
D:\Coding\Wickra\crates\... and D:\Coding\Wickra\bindings\... in its
"Source-of-truth files" section, and seven trend-indicator pages had
Node examples that did require('D:/Coding/Wickra/bindings/node').

Replace the overview paths with repo-relative GitHub links and change
the Node examples to require('wickra'), the published npm package name
a reader would actually use. No D:/Coding path remains anywhere in docs.
2026-05-22 16:18:48 +02:00
kingchenc 278b6afaa4 E4: commit the documentation sources
The 33 Markdown files under docs/wiki/ were never tracked. Commit them
into the repository so the documentation is versioned alongside the
code: 8 top-level pages plus 25 per-indicator deep dives under
indicators/{momentum,trend,volatility,volume}/.

The pages are kept in-repo (not pushed to a flat GitHub Wiki), so the
relative indicators/<family>/... links in Home.md resolve correctly
when rendered on GitHub.
2026-05-22 16:18:04 +02:00
kingchenc 94cab88278 E3: add community health files
The repository had no contributor-facing documentation or automation
config. Add the standard set:

- CHANGELOG.md (Keep a Changelog format, 0.1.0-0.1.4 plus Unreleased)
- CONTRIBUTING.md (build/test steps, change standards, PolyForm-NC note)
- SECURITY.md (private reporting, supported versions)
- CODE_OF_CONDUCT.md (Contributor Covenant 2.1)
- .github/ISSUE_TEMPLATE (bug report, feature request, config)
- .github/PULL_REQUEST_TEMPLATE.md
- .github/dependabot.yml (cargo, npm, pip, github-actions — the last
  keeps the D1 SHA pins current)
- .github/CODEOWNERS
2026-05-22 16:17:15 +02:00
kingchenc 53b8b6e282 E2: add an MSRV verification job to CI
Every CI job used dtolnay/rust-toolchain on stable, so the declared
minimum supported Rust version was never exercised — an accidental use
of a newer API would only break for downstream users on an older
compiler.

Add an `msrv` job with a two-row matrix: the workspace crates
(wickra-core, wickra, wickra-data) build and test on Rust 1.75, and the
node binding on Rust 1.77, matching the rust-version each manifest
declares. Both rows use the SHA-pinned toolchain action.
2026-05-22 12:37:15 +02:00
kingchenc 9b11d73273 D1: pin all GitHub Actions to commit SHAs
Every action in ci.yml and release.yml was pinned to a movable tag
(actions/checkout@v4, dtolnay/rust-toolchain@stable, ...). A compromised
upstream tag would run with access to the crates.io / PyPI / npm
publish tokens.

Pin every `uses:` to the full 40-character commit SHA the referenced
ref currently resolves to, with the human-readable version kept as a
trailing comment so Dependabot can still bump them:

  actions/checkout            v4.3.1
  actions/setup-python        v5.6.0
  actions/setup-node          v4.4.0
  actions/upload-artifact     v4.6.2
  actions/download-artifact   v4.3.0
  dtolnay/rust-toolchain      stable branch @ 2026-03-27
  Swatinem/rust-cache         v2
  jetli/wasm-pack-action      v0.4.0
  PyO3/maturin-action         v1.51.0
  softprops/action-gh-release v2.6.2

SHAs were resolved against the GitHub API. The github-actions Dependabot
ecosystem that keeps these pins current is added with E3.
2026-05-22 12:35:02 +02:00
kingchenc 0d451ac584 D2: gate the publish jobs behind a protected environment
release.yml triggers on every v* tag push and the four publish jobs
(crates.io, PyPI, npm, wasm) inject long-lived registry tokens straight
from secrets with no environment, no reviewer and no tag restriction.

Bind all four jobs to a `release` GitHub environment. With the
environment's protection rules (required reviewers, tag/branch
restrictions) configured under repo Settings -> Environments, the
registry secrets become reachable only from an approved release run
rather than from any workflow execution.
2026-05-22 12:32:47 +02:00
kingchenc 8ccb885906 D4: pass --ignore-scripts to per-platform npm publish/pack
The main npm package already publishes and packs with --ignore-scripts,
but the per-platform subpackage loop did not: `npm publish --access
public`, its retry, and the per-platform `npm pack` all ran lifecycle
scripts from the package directory with the npm token in scope.

Add --ignore-scripts to all three, matching the main package, so no
prepublish/prepare hook can execute during a release.
2026-05-22 12:32:23 +02:00
kingchenc ad17915e49 D3: drop --allow-dirty --no-verify from release cargo package
The release workflow built the .crate attachments with
`cargo package --allow-dirty --no-verify`, so the attached artefact
could diverge from the tagged tree and was never proven to build.

Remove both flags. actions/checkout provides a clean tree and no prior
step mutates it, so --allow-dirty is unnecessary. The crates are
published to crates.io earlier in the same job, so the verification
build now resolves workspace dependencies from the registry and
confirms each .crate compiles before it is attached.
2026-05-22 12:32:04 +02:00
kingchenc 79d705a746 C13: report clear CSV errors in the offline examples
backtest.py and multi_timeframe.py read OHLCV CSVs with csv.DictReader
and crashed opaquely on malformed input: a missing column raised a bare
KeyError, a non-numeric cell surfaced NumPy's column-less ValueError,
and an empty or all-NaN series hit IndexError deep in summarize.

Validate the header against the required columns up front, catch
non-numeric cells and report the offending row/column, reject a
header-only file distinctly from a headerless one, and guard resample
and summarize against empty input. Every failure mode now raises a
ValueError naming the file, row and column.
2026-05-22 12:29:40 +02:00
kingchenc a8fb0b8181 C12: fix falsy-value display and validate symbol/interval
Two issues in the live_trading example:

- The status line rendered indicator values with `... if snap.rsi
  else "--"`. A genuine reading of 0.0 is falsy, so an RSI / MACD
  histogram / Bollinger value of exactly zero was misreported as "--".
  Switch to explicit `is not None` checks.

- --symbol and --interval were interpolated straight into the stream
  name and WebSocket URL with no checks. Add validate_args: the symbol
  must be strictly alphanumeric and the interval must be one Binance
  recognises. main() validates before connecting and exits with a clear
  error and code 2 otherwise; the strict values keep the URL well-formed
  without escaping.
2026-05-22 12:27:13 +02:00
kingchenc f0471ba824 C11: validate volume when finalising aggregated candles
OpenBar::into_candle and RolledBar::into_candle built their result with
Candle::new_unchecked, skipping the finiteness check. volume is summed
across every absorbed tick/candle, so a long or large run can drift it
to +inf — and an inf-volume candle would silently poison every
downstream indicator.

Switch both to Candle::new, which validates volume finiteness, and
return Result<Candle>. The OHLC fields are finite and correctly ordered
by construction, so the only invariant Candle::new can reject here is a
non-finite volume. push propagates the error with `?`; both flush
methods now return Result<Option<Candle>> and resample_all pulls the
result through.
2026-05-22 12:25:56 +02:00
kingchenc 2cebb3cca1 C10: reject same-bucket out-of-order ticks
push rejected ticks that went backwards across buckets but absorbed any
tick whose timestamp fell inside the open bucket — including one older
than the last tick already absorbed. Such a stale tick silently
overwrote the bar's close with an outdated price.

Track last_ts on OpenBar (set in from_tick, advanced in absorb) and, on
the same-bucket path, reject a tick whose timestamp predates it with
Error::Malformed, leaving the open bar untouched. Ticks that share a
timestamp are still accepted, since several trades can land in the same
millisecond.
2026-05-22 12:22:33 +02:00
kingchenc f33f59ad68 C9: saturate Timeframe::floor instead of overflowing at i64::MIN
Timeframe::floor computed `ts - ts.rem_euclid(bucket)`. For a timestamp
within one bucket of i64::MIN the subtrahend is a positive remainder
and the true boundary lies below i64::MIN, so the subtraction overflowed
and panicked in debug builds.

Switch to saturating_sub: the result clamps to i64::MIN in that
practically unreachable case and stays exact everywhere else. floor
keeps its infallible `-> i64` signature, so neither push path changes.
2026-05-22 12:21:25 +02:00
kingchenc 783e40069d C8: skip non-kline frames in the live_trading example
The combined Binance stream interleaves the kline payloads with
subscription acks, heartbeats and error objects. The example pulled
k = payload.get("k", {}) and immediately did float(k.get("c")) — for
any non-kline frame k is {}, k.get("c") is None, and float(None) raises
TypeError, crashing the script the moment it connects.

Skip frames without a kline payload (no "k" object, or no "c" close
field) with a debug log line, matching the C2 fix on the Rust adapter.
2026-05-22 12:20:19 +02:00
kingchenc 6b468824ce C7: validate the CSV header and tolerate BOM / whitespace
The CSV reader set has_headers(true) with no trimming and no header
check, so three real-world inputs failed silently or opaquely:

- A file with no header row had its first data row consumed as the
  header and silently dropped.
- A leading UTF-8 BOM (Excel exports it) became part of the first
  header name, breaking the `timestamp` column mapping.
- Leading/trailing whitespace around values broke serde parsing.

Add a BomStripReader<R> Read adapter that discards a leading EF BB BF,
set csv::Trim::All on the builder, and validate after opening that the
header names every required OHLCV column — a missing column now yields
a clear Error::Malformed instead of a silent misread. open/from_reader
route through a shared build() helper; from_reader and from_csv_reader
now return Result because header validation can fail.
2026-05-22 12:19:46 +02:00
kingchenc 81680bbb4b C6: add opt-in gap filling to the tick aggregator
A tick that jumped across one or more empty buckets previously opened
the next non-empty bar directly, so the candle series silently grew
time holes — downstream indicators (EMA, ATR, ...) computed over such a
series drift from one computed over an unbroken series.

Add an opt-in gap-fill mode: with_gap_fill(true) makes push emit a flat
placeholder candle (open == high == low == close = the pre-gap close,
volume = 0) for every skipped bucket. push now returns Result<Vec<Candle>>
so a single tick can yield the closed bar plus its trailing fillers;
the empty vector replaces the former Ok(None). Timestamp overflow while
filling is reported as Error::Malformed. Default behaviour is unchanged
(gaps skipped) and is now documented on the type and on push.
2026-05-22 12:14:21 +02:00
kingchenc 80295eec87 C5: reject out-of-order candles in the resampler
Resampler::push previously closed the open bar and opened a new one for
any candle whose bucket differed from the open bar, including buckets
strictly before it — silently corrupting the output for out-of-order
input. TickAggregator::push already rejects this case with an error.

Change push to return Result<Option<Candle>>: candles in an earlier
bucket than the open bar now yield Error::Malformed, matching the
aggregator. resample_all propagates the error via `?`. The doc comment
keeps the input/output multiple relationship as a documented caller
responsibility, since Resampler does not know the input timeframe.
2026-05-22 12:11:21 +02:00
kingchenc 0910ee6d37 C1: reconnect the Binance stream with exponential backoff
A 24-hour forced disconnect or a network blip permanently killed the
feed: next_event returned Ok(None)/Err and the stream was dead. The
struct now retains the subscribed symbols, an open() helper rebuilds the
socket, and reconnect() retries with exponential backoff (1s..30s, up to
MAX_RECONNECT_ATTEMPTS). next_event transparently reconnects on a
protocol error, a server close or a read stall, and only reports Ok(None)
after the caller has closed the stream. close() now takes &mut self.
2026-05-22 04:26:23 +02:00
kingchenc 62d0fb623e C3: add a read timeout and message-size limits to the Binance stream
connect() used connect_async with no WebSocketConfig and next_event
awaited the socket with no deadline, so a stalled server hung the feed
forever and an oversized message could force an unbounded allocation.
connect() now passes a WebSocketConfig capping message/frame size, and
next_event wraps the read in a 300s tokio timeout (well above Binance's
~3-minute ping), surfacing a stall as the new Error::Timeout.
2026-05-22 04:22:46 +02:00
kingchenc 23e5890265 C4: track a closed flag on the Binance stream
BinanceKlineStream had no closed-state flag, so after the server closed
the connection (Ok(None)) a caller could keep calling next_event and
poll a dead socket. A closed: bool is now set when the server closes or
sends a Close frame; next_event short-circuits to Ok(None) once set, and
is_closed() exposes the state.
2026-05-22 04:21:09 +02:00
kingchenc 90aa695a02 C2: skip non-kline Binance frames instead of aborting the stream
next_event deserialized every text frame straight into RawWsEnvelope, so
a subscription acknowledgement, heartbeat or error object propagated a
decode Err and killed the feed. Frames are now routed through
parse_frame, which inspects data.e: kline frames yield an event,
everything else is skipped, and an Err is raised only when a genuine
kline frame fails to decode. Adds tests for skipped acks/errors.
2026-05-22 04:19:31 +02:00
kingchenc 2fc7a90ec0 B11: ship npm binaries for linux-arm64 and win32-arm64
The napi loader resolves wickra-linux-arm64-gnu and
wickra-win32-arm64-msvc, but neither was published, so require('wickra')
failed on those platforms. Adds both to napi.triples and
optionalDependencies, adds their npm/ package templates, and extends the
release node-build matrix to build them on GitHub's native ARM runners
(ubuntu-24.04-arm, windows-11-arm).
2026-05-22 04:17:23 +02:00
kingchenc 490ce28645 B10: document the interleaved layout of Node MACD/Bollinger batch
MacdNode.batch and BollingerNode.batch return flat interleaved arrays
(3*n and 4*n) but index.d.ts only said Array<number>. Adds /// doc
comments describing the layout; napi-rs now propagates them into the
generated index.d.ts as JSDoc.
2026-05-22 04:15:31 +02:00
kingchenc 59c435fbd5 chore: apply rustfmt to binding/core sources and sync Cargo.lock
Normalises whitespace in sources committed earlier in this branch
before rustfmt was run over them (Node/WASM bindings, and three core
indicator test modules), and records the wasm-bindgen-test dependency
tree added in B6 into Cargo.lock. No functional change; cargo fmt --all
--check is now clean.
2026-05-22 04:14:18 +02:00
kingchenc 41d52ec5be B9: raise ValueError instead of panicking on non-contiguous arrays
Every Python batch() did prices.as_slice().expect("contiguous"), so a
non-contiguous NumPy input (e.g. a strided view) aborted with a Rust
panic instead of a catchable exception. as_slice() failures now map to a
PyValueError pointing at np.ascontiguousarray; the scalar / MACD /
Bollinger batch methods that returned a bare array were lifted to
PyResult so the error can propagate. Adds input-validation tests
(non-contiguous arrays, unequal-length candle batches, ROC/TRIX
defaults). All 60 Python tests pass against the freshly built wheel.
2026-05-22 04:14:11 +02:00
kingchenc aeea6a89bb B8: add a comprehensive Node binding test suite
The Node binding had only 7 smoke cases with no streaming-vs-batch or
candle-indicator coverage. Adds indicators.test.js: streaming update()
matches batch() for all 25 indicators (scalar, candle-scalar and
interleaved multi-output), a lifecycle check that every indicator
exposes reset/isReady/warmupPeriod, and reference values (SMA(3),
MFI(2)=1200/23, RSI uptrend, MACD histogram). 38 Node tests pass.
2026-05-22 04:07:42 +02:00
kingchenc 4a9d27fb52 B7: give Python ROC and TRIX constructor defaults
Every other Python momentum indicator (RSI, CCI, ...) carries a
#[pyo3(signature)] default, but ROC and TRIX required an explicit
period. Both now default to the TA-Lib convention (ROC period=10, TRIX
period=30), and the .pyi stubs reflect the defaults. Node and WASM
constructors deliberately stay explicit-only -- napi-rs and
wasm-bindgen do not support default arguments, and every constructor in
those bindings is uniformly explicit.
2026-05-22 04:06:18 +02:00
kingchenc 8192e576cf B6: add a WASM binding test suite
The WASM binding had no tests; CI only checked that artefacts existed.
Adds a wasm-bindgen-test suite covering SMA reference values, EMA
batch==streaming equivalence, RSI pure-uptrend behaviour, fallible
constructors returning JsError, and the unequal-length batch guards from
B3. Wires wasm-pack test --node into the CI wasm job. The suite
type-checks on the host; it executes under wasm-pack in CI (the local
environment is a non-rustup Rust install without the wasm32 target).
2026-05-22 04:05:15 +02:00
kingchenc 4880fea075 B5: complete the Python type stubs for all 25 indicators
The .pyi shipped stubs for only 9 of the 25 exported classes, so with
py.typed set, type checkers flagged DEMA, TEMA, HMA, KAMA, CCI, ROC,
WilliamsR, ADX, MFI, TRIX, PSAR, Keltner, Donchian, VWAP,
AwesomeOscillator and Aroon as missing. All 16 are now stubbed with
signatures matching python/src/lib.rs (constructor defaults, update
return types, batch array shapes, lifecycle methods). Verified: the
stub set equals the 25 registered classes and mypy type-checks a script
exercising every class with no issues.
2026-05-22 04:02:17 +02:00
kingchenc ad3b068d47 B4: complete reset/isReady/warmupPeriod across Node indicators
KAMA and the nine candle indicators (CCI, WilliamsR, MFI, PSAR,
Keltner, Donchian, VWAP, AO, Aroon) exposed none of the three lifecycle
methods; Stochastic/OBV/ADX exposed only reset; MACD/Bollinger/ATR
lacked warmupPeriod. Every indicator now exposes reset(), isReady() and
warmupPeriod(), matching the scalar-macro surface. Verified against the
rebuilt module (e.g. MACD warmupPeriod 34, Stochastic 16, KAMA 11).
2026-05-22 03:59:20 +02:00
kingchenc b4613a74c8 B3: guard candle batch methods against unequal-length arrays
Candle batch() methods that index parallel high/low/close/volume arrays
without first checking their lengths panic on a length mismatch. Adds an
equal-length guard returning a clean error to the 11 affected Node
methods (Stochastic, ADX, CCI, WilliamsR, MFI, PSAR, Keltner, Donchian,
VWAP, AO, Aroon), the 10 affected WASM methods, and the 8 affected
Python methods (WilliamsR, ADX, MFI, PSAR, Keltner, VWAP, AO, Aroon) --
matching the guard ATR/OBV already had. Verified in Node: mismatched
arrays now throw instead of crashing.
2026-05-22 03:55:27 +02:00
kingchenc a0884fa010 B2: make fallible Node constructors throw instead of panicking
The 14 candle and multi-parameter indicator constructors passed raw
parameters through must() (an expect()), so invalid arguments such as
new MACD(0,0,0) or new BollingerBands(20,-1) aborted the whole process
over the napi boundary (the release profile sets panic=abort).

napi-rs 2.16 does accept a #[napi(constructor)] returning
napi::Result<Self> (the old must() comment was wrong), so each now
returns napi::Result<Self> and throws a clean JS error via map_err.
must()/clamp_period stay for the scalar macro, where period is clamped
and the Result is provably Ok. Verified: every invalid constructor
throws, valid ones still build.
2026-05-22 03:44:56 +02:00
kingchenc 4b6f25a32c B1: add update() to the 12 Node candle indicators
Stochastic, OBV, ADX, CCI, WilliamsR, MFI, PSAR, Keltner, Donchian,
VWAP, AwesomeOscillator and Aroon previously exposed only batch() in the
Node binding, so a streaming-first library could not actually stream
them from Node. Each now has an update() mirroring the AtrNode pattern,
with the same input arity and output type as its batch() counterpart.
Verified against the rebuilt native module.
2026-05-22 03:41:47 +02:00
kingchenc 014e9afa51 A5: feed Keltner and HMA sibling sub-indicators in parallel
Keltner::update gated atr.update behind ema.update(...)? and Hma::update
gated full_wma.update behind half_wma.update(...)?. The ? short-circuit
starved the trailing sibling of every candle consumed during the leading
one's warmup, so warmup_period() understated the true first emission
(Keltner classic: 29 instead of 20; HMA(9): 14 instead of 11) and
Keltner's ATR seeded over the wrong window.

Both now feed every sub-indicator unconditionally and gate only the
output, matching the MACD / Awesome Oscillator pattern. warmup_period()
is now exact. Adds first-emission tests and cross-checks against
independent EMA+ATR (Keltner) and independent WMAs (HMA).
2026-05-22 03:37:40 +02:00
kingchenc 8aa480101e A4: align ROC non-finite handling with SMA/EMA
ROC now stores its last emitted value and returns it on a non-finite
input instead of None, leaving the window untouched. This matches the
SMA / EMA convention. reset() clears the new field. Adds a
non-finite-input test.
2026-05-22 03:35:49 +02:00
kingchenc 5627612d44 A3: close core test-coverage gaps
Adds the reset tests the audit named as missing (aroon, awesome
oscillator, donchian, keltner, williams_r, and both VWAP variants),
non-finite-input tests for every scalar indicator that guards is_finite
(WMA, RSI, MACD, Bollinger, KAMA), and naive-reference proptests for EMA,
RSI and ATR. 189 core tests pass.
2026-05-22 03:34:19 +02:00
kingchenc f3dcee1cb5 A2: complete PSAR reset() and correct the seeding comment
reset() now restores prev_high, prev_low and trend in addition to the
previously reset fields, keeping the struct fully consistent for
inspection. The misleading inline comment that claimed direction-dependent
seeding is corrected to describe the actual fixed-Up seed, which
self-corrects through PSAR's reversal logic. Adds a reset-reuse test.
2026-05-22 03:34:11 +02:00
kingchenc bdc4c744f2 A1: MFI emits first value on the (period+1)-th candle
The first candle now only seeds the previous typical price instead of
pushing a fabricated (0,0) money-flow pair into the window, matching the
TA-Lib / pandas-ta convention. warmup_period() returns period + 1 and the
dead prev_tp.is_none() guard is removed. Adds a first-emission test and a
hand-computed reference-value test (MFI(2) = 1200/23).
2026-05-22 03:34:06 +02:00
kingchenc 528e5c9174 Release 0.1.4: add GitHub Release asset attachments
Pure tooling release on top of 0.1.3. The library code is unchanged;
only the release workflow grew a new github-release job that attaches
every built artefact to the GitHub Release page so users have direct
download links next to the source archives:

- Python wheels (5 platforms) + sdist
- Native Node bindings (linux-x64-gnu, darwin-x64, darwin-arm64, win32-x64-msvc)
- npm-pack tarballs for the main wickra package, every per-platform
  subpackage, and wickra-wasm
- Cargo .crate files for wickra-core, wickra-data, wickra

The job runs at the end of the release pipeline and also accepts
workflow_dispatch so future asset-only fixups don't require a version
bump.
2026-05-21 22:29:12 +02:00
kingchenc 323e9ce153 ci(release): attach all build artefacts to the GitHub Release
The Releases page previously showed only the source archives GitHub
auto-generates for every tag. Add an explicit github-release job that
runs after every publish job finishes, downloads every uploaded
artefact, and attaches them all to the release.

New per-publish-job upload-artifact additions:
- cargo-publish: 'cargo package' each crate and upload the .crate files
- wasm-publish:  'npm pack' inside pkg/ and upload the wickra-wasm tgz
- node-publish:  'npm pack' the main package + each per-platform subpackage,
                  upload all six tgz files (main + linux + 2*darwin + win32)

Wheels and .node binaries were already being uploaded earlier in the
matrix builds, so the new job just consumes them via download-artifact.

github-release job:
- Runs on tag pushes (normal) AND workflow_dispatch (so we can backfill
  assets to an existing release without rebumping the version).
- Resolves the target tag from github.ref on a tag push, or from
  'git tag --sort=-v:refname | head' on dispatch.
- Stages all files into release-assets/, uses softprops/action-gh-release
  to create or update the release. generate_release_notes lets GitHub
  fill in the commit-list changelog automatically.
2026-05-21 22:28:25 +02:00
178 changed files with 117629 additions and 1237 deletions
+6
View File
@@ -0,0 +1,6 @@
# Code owners for Wickra.
#
# The owner listed here is requested for review automatically on every pull
# request. See https://docs.github.com/articles/about-code-owners.
* @kingchenc
+39
View File
@@ -0,0 +1,39 @@
---
name: Bug report
about: Report incorrect behaviour in Wickra
title: "[Bug] "
labels: bug
assignees: ""
---
## Description
<!-- A clear description of what is wrong. -->
## Reproduction
<!-- Minimal code that reproduces the problem. -->
```rust
// or python / javascript
```
## Expected behaviour
<!-- What you expected to happen, ideally with a reference value
(TA-Lib, pandas-ta, hand-computed). -->
## Actual behaviour
<!-- What happened instead. -->
## Environment
- Wickra version:
- Language / binding: <!-- Rust crate / Python / Node / WASM -->
- OS and architecture:
- Rust / Python / Node version (If relevant):
## Additional context
<!-- Logs, screenshots, anything else. -->
@@ -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. -->
+8
View File
@@ -0,0 +1,8 @@
blank_issues_enabled: false
contact_links:
- name: Security vulnerability
url: https://github.com/kingchenc/wickra/security/advisories/new
about: Report security issues privately — do not open a public issue.
- name: Question or discussion
url: https://github.com/kingchenc/wickra/discussions
about: Ask usage questions and discuss ideas here.
+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. -->
+31
View File
@@ -0,0 +1,31 @@
---
name: Feature request
about: Suggest a new indicator or capability for Wickra
title: "[Feature] "
labels: enhancement
assignees: ""
---
## Problem
<!-- What are you trying to do that Wickra cannot do today? -->
## Proposed solution
<!-- For a new indicator: its name, formula, and the standard parameters.
Link a reference implementation (TA-Lib, pandas-ta) if one exists. -->
## Alternatives considered
<!-- Other approaches and why they fall short. -->
## Scope
- [ ] Affects the Rust core
- [ ] Should be exposed in the Python binding
- [ ] Should be exposed in the Node binding
- [ ] Should be exposed in the WASM binding
## Additional context
<!-- Anything else that helps. -->
@@ -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`
+34
View File
@@ -0,0 +1,34 @@
<!-- Thanks for contributing to Wickra. Please fill in the sections below. -->
## Summary
<!-- What does this PR change, and why? -->
## Related issue
<!-- e.g. Closes #123 -->
## Type of change
- [ ] Bug fix
- [ ] New feature
- [ ] Indicator addition / change
- [ ] Documentation
- [ ] CI / build / tooling
## Checklist
- [ ] `cargo fmt --all --check` is clean.
- [ ] `cargo clippy --workspace --all-targets -- -D warnings` is clean.
- [ ] `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).
- [ ] 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
<!-- Anything that needs extra attention, trade-offs, follow-ups. -->
+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. -->
+38
View File
@@ -0,0 +1,38 @@
version: 2
updates:
# Rust workspace (root Cargo.toml + all member crates).
- package-ecosystem: cargo
directory: "/"
schedule:
interval: weekly
open-pull-requests-limit: 10
commit-message:
prefix: "deps(cargo)"
# Node binding npm dependencies.
- package-ecosystem: npm
directory: "/bindings/node"
schedule:
interval: weekly
open-pull-requests-limit: 10
commit-message:
prefix: "deps(npm)"
# Python binding pip dependencies.
- package-ecosystem: pip
directory: "/bindings/python"
schedule:
interval: weekly
open-pull-requests-limit: 10
commit-message:
prefix: "deps(pip)"
# GitHub Actions — keeps the SHA-pinned actions current (Dependabot reads
# the version comment after each pinned SHA and bumps both together).
- package-ecosystem: github-actions
directory: "/"
schedule:
interval: weekly
open-pull-requests-limit: 10
commit-message:
prefix: "deps(actions)"
+70
View File
@@ -0,0 +1,70 @@
name: Cross-library benchmark
# Audit finding R10: previously the cross-library benchmark ran on every push
# and every pull-request to `main`, adding 510 minutes of build + bench time
# per CI run with no consumer of the resulting artefact. The benchmark is now
# scheduled (nightly at 03:00 UTC) and on-demand via `workflow_dispatch`. The
# CI pipeline proper (.github/workflows/ci.yml) still verifies build / tests /
# lints on every push and pull-request.
on:
schedule:
# Nightly at 03:00 UTC. Pick a slot well away from common European /
# American working-hours pushes to keep this off the critical path.
- cron: "0 3 * * *"
workflow_dispatch:
inputs:
size:
description: "Number of bars per indicator (default 20000)"
required: false
default: "20000"
iterations:
description: "Batch iterations per indicator (default 10)"
required: false
default: "10"
env:
CARGO_TERM_COLOR: always
jobs:
cross-library-bench:
name: Cross-library benchmark report
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable branch, 2026-03-27
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.11"
- name: Install Python deps + peer libs
run: |
python -m pip install --upgrade pip
python -m pip install maturin numpy pandas talipp finta
- name: Build Wickra wheel
working-directory: bindings/python
run: maturin build --release --out dist
- name: Install Wickra wheel
working-directory: bindings/python
run: python -m pip install --find-links dist --force-reinstall wickra
- name: Run cross-library benchmark
working-directory: bindings/python
run: |
python -m benchmarks.compare_libraries \
--size ${{ github.event.inputs.size || '20000' }} \
--iterations ${{ github.event.inputs.iterations || '10' }} \
--streaming-window 5000 --streaming-iterations 2 \
| tee benchmark.txt
- name: Upload report
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: cross-library-bench
path: bindings/python/benchmark.txt
+178 -60
View File
@@ -19,15 +19,15 @@ jobs:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable branch, 2026-03-27
with:
components: rustfmt, clippy
- name: Cache cargo
uses: Swatinem/rust-cache@v2
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
- name: Format check
run: cargo fmt --all -- --check
@@ -48,9 +48,152 @@ jobs:
run: cargo build -p wickra --benches --verbose
- name: Compile examples
run: |
cargo build -p wickra --example backtest
cargo build -p wickra-data --example live_binance --features live-binance
# All runnable examples now live in the dedicated wickra-examples crate
# (examples/rust/src/bin/*.rs) which enables the live-binance feature
# on its wickra-data dep, so a single --bins build covers backtest,
# live_binance, fetch_btcusdt, multi_timeframe, parallel_assets and
# streaming.
run: cargo build -p wickra-examples --bins
# Verify the crates still build and test on their declared minimum supported
# Rust version. The workspace pins rust-version = "1.86" — that floor is
# set by criterion 0.8.2 (the bench dev-dep), which itself rolled past the
# clap_lex 1.1.0 / edition2024 / Rust 1.85 floor and now needs 1.86; the
# earlier rayon-core 1.13.0 (1.80) and clap_lex (1.85) requirements are
# subsumed. bindings/node pins rust-version = "1.88" because napi-build
# 2.3.2 requires it (and that subsumes the older 1.77 floor needed for
# `cargo::` directives). Without this job an accidental use of a newer
# API would only surface for downstream users.
msrv:
name: ${{ matrix.name }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- name: MSRV workspace (Rust 1.86)
toolchain: "1.86"
packages: "-p wickra-core -p wickra -p wickra-data"
- name: MSRV node binding (Rust 1.88)
toolchain: "1.88"
packages: "-p wickra-node"
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install Rust ${{ matrix.toolchain }}
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable branch, 2026-03-27
with:
toolchain: ${{ matrix.toolchain }}
- name: Cache cargo
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
- name: Build on MSRV
run: cargo build ${{ matrix.packages }} --verbose
- name: Test on MSRV
run: cargo test ${{ matrix.packages }} --verbose
# Code coverage for the pure-Rust crates, uploaded to Codecov.
coverage:
name: Coverage
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable branch, 2026-03-27
with:
components: llvm-tools-preview
- name: Cache cargo
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
- name: Install cargo-llvm-cov
uses: taiki-e/install-action@6c1f7cf125e42770ff087ea443901b487cc5471a # v2.79.5
with:
tool: cargo-llvm-cov
- name: Generate coverage (lcov)
run: >
cargo llvm-cov
-p wickra-core -p wickra -p wickra-data
--features wickra-data/live-binance
--lcov --output-path lcov.info
- name: Upload to Codecov
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
with:
files: lcov.info
fail_ci_if_error: false
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
# Supply-chain audit: security advisories, license policy, banned crates,
# and source restrictions. Configured by deny.toml at the repo root.
supply-chain:
name: Supply-chain (cargo-deny)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: cargo-deny
uses: EmbarkStudios/cargo-deny-action@a531616d8ce3b9177443e48a1159bc945a099823 # v2.0.19
with:
command: check
# Time-boxed fuzz smoke. Each target runs for ~30 s with libfuzzer; any panic
# fails the job. The goal is to catch a regression in the harness (e.g. a
# newly added indicator that panics on a particular input shape), not to
# discover novel bugs — long fuzz campaigns should be run on dedicated
# infrastructure with persistent corpora.
fuzz-smoke:
name: Fuzz (smoke)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install nightly Rust
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable branch, 2026-03-27
with:
toolchain: nightly
- name: Cache cargo
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
with:
workspaces: fuzz
- name: Install cargo-fuzz
# Use the prebuilt binary from taiki-e/install-action instead of
# `cargo install cargo-fuzz --locked`. The latter resolves the
# version graph from cargo-fuzz's own Cargo.lock, which pins to
# rustix 0.36.5 — a version that still uses internal `rustc_*`
# attributes the modern nightly compiler rejects, so the install
# never gets off the ground. The prebuilt binary avoids the entire
# transitive-dep compile.
uses: taiki-e/install-action@6c1f7cf125e42770ff087ea443901b487cc5471a # v2.79.5
with:
tool: cargo-fuzz
- name: Fuzz csv_reader (30 s)
run: cargo +nightly fuzz run --target x86_64-unknown-linux-gnu csv_reader -- -max_total_time=30
working-directory: fuzz
- name: Fuzz binance_envelope (30 s)
run: cargo +nightly fuzz run --target x86_64-unknown-linux-gnu binance_envelope -- -max_total_time=30
working-directory: fuzz
- name: Fuzz indicator_update (30 s)
run: cargo +nightly fuzz run --target x86_64-unknown-linux-gnu indicator_update -- -max_total_time=30
working-directory: fuzz
- name: Fuzz indicator_update_candle (30 s)
run: cargo +nightly fuzz run --target x86_64-unknown-linux-gnu indicator_update_candle -- -max_total_time=30
working-directory: fuzz
- name: Fuzz tick_aggregator (30 s)
run: cargo +nightly fuzz run --target x86_64-unknown-linux-gnu tick_aggregator -- -max_total_time=30
working-directory: fuzz
python:
name: Python ${{ matrix.python-version }} on ${{ matrix.os }}
@@ -59,18 +202,18 @@ jobs:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
python-version: ["3.9", "3.11", "3.12"]
python-version: ["3.9", "3.11", "3.12", "3.13"]
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable branch, 2026-03-27
- name: Cache cargo
uses: Swatinem/rust-cache@v2
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
- name: Set up Python
uses: actions/setup-python@v5
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: ${{ matrix.python-version }}
@@ -96,22 +239,34 @@ jobs:
name: WASM build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install Rust toolchain (with wasm target)
uses: dtolnay/rust-toolchain@stable
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable branch, 2026-03-27
with:
targets: wasm32-unknown-unknown
- name: Cache cargo
uses: Swatinem/rust-cache@v2
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
- name: Install wasm-pack
uses: jetli/wasm-pack-action@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
- name: Run WASM tests
run: wasm-pack test --node bindings/wasm
- name: Verify generated artefacts
run: |
test -f bindings/wasm/pkg/wickra_wasm.js
@@ -127,16 +282,16 @@ jobs:
os: [ubuntu-latest, macos-latest, windows-latest]
node-version: ["18", "20"]
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable branch, 2026-03-27
- name: Cache cargo
uses: Swatinem/rust-cache@v2
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
- name: Set up Node
uses: actions/setup-node@v4
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: ${{ matrix.node-version }}
@@ -156,44 +311,7 @@ jobs:
working-directory: bindings/node
run: node --test __tests__/
cross-library-bench:
name: Cross-library benchmark report
runs-on: ubuntu-latest
needs: [python]
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install Python deps + peer libs
run: |
python -m pip install --upgrade pip
python -m pip install maturin numpy pandas talipp finta
- name: Build Wickra wheel
working-directory: bindings/python
run: maturin build --release --out dist
- name: Install Wickra wheel
working-directory: bindings/python
run: python -m pip install --find-links dist --force-reinstall wickra
- name: Run cross-library benchmark
working-directory: bindings/python
run: |
python -m benchmarks.compare_libraries --size 20000 --iterations 10 \
--streaming-window 5000 --streaming-iterations 2 \
| tee benchmark.txt
- name: Upload report
uses: actions/upload-artifact@v4
with:
name: cross-library-bench
path: bindings/python/benchmark.txt
# The cross-library benchmark has moved to a dedicated scheduled workflow
# (.github/workflows/bench.yml) — see audit finding R10. It runs nightly
# at 03:00 UTC and on-demand via `workflow_dispatch`, and is no longer on
# the every-push / every-PR critical path.
+237 -35
View File
@@ -15,10 +15,17 @@ jobs:
cargo-publish:
name: Publish to crates.io
runs-on: ubuntu-latest
# The publish jobs run with long-lived registry tokens. Binding them to a
# protected GitHub environment lets the org require a reviewer to approve
# each release and restrict which tags/branches may deploy, so the secrets
# are not reachable from an arbitrary workflow run. The `release`
# environment and its protection rules are configured under repo
# Settings -> Environments.
environment: release
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable branch, 2026-03-27
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
# Idempotent publishing: if the version is already on crates.io we
# treat that as success so re-runs of the workflow don't fail.
@@ -52,48 +59,82 @@ jobs:
env:
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
# Produce .crate files for the GitHub Release attachments. `cargo package`
# writes them to target/package/<name>-<version>.crate.
#
# No --allow-dirty: `actions/checkout` gives a clean tree and nothing
# above mutates it. No --no-verify: every crate was just published to
# crates.io in the steps above, so the verification build resolves its
# workspace dependencies from the registry and confirms each .crate
# actually builds before it is attached to the release.
- name: Build .crate files for release attachment
run: |
cargo package -p wickra-core
cargo package -p wickra-data
cargo package -p wickra
- name: Upload .crate files
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: crate-files
path: target/package/*.crate
# --------------------------------------------------------------------------
# PyPI: cross-platform wheels + sdist
# --------------------------------------------------------------------------
python-wheels:
name: Build wheels (${{ matrix.target }} on ${{ matrix.os }})
name: Build wheels (${{ matrix.target }}/${{ matrix.manylinux }} on ${{ matrix.os }})
strategy:
fail-fast: false
matrix:
include:
# glibc Linux (manylinux)
- { os: ubuntu-latest, target: x86_64, manylinux: auto }
- { os: ubuntu-latest, target: aarch64, manylinux: auto }
# musl Linux (Alpine and other musl distros)
- { os: ubuntu-latest, target: x86_64, manylinux: musllinux_1_2 }
- { os: ubuntu-latest, target: aarch64, manylinux: musllinux_1_2 }
# macOS
- { os: macos-latest, target: x86_64, manylinux: auto }
- { os: macos-latest, target: aarch64, manylinux: auto }
# Windows
- { os: windows-latest, target: x64, manylinux: auto }
- { os: windows-11-arm, target: aarch64, manylinux: auto }
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.11"
- uses: PyO3/maturin-action@v1
- 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
target: ${{ matrix.target }}
args: --release --strip --out dist
manylinux: ${{ matrix.manylinux }}
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: wheels-${{ matrix.os }}-${{ matrix.target }}
# Include manylinux in the name so the glibc and musl x86_64/aarch64
# builds do not collide on the same artifact name.
name: wheels-${{ matrix.os }}-${{ matrix.target }}-${{ matrix.manylinux }}
path: bindings/python/dist/*
python-sdist:
name: Build Python sdist
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: PyO3/maturin-action@v1
- 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
command: sdist
args: --out dist
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: wheels-sdist
path: bindings/python/dist/*
@@ -102,15 +143,16 @@ jobs:
name: Publish to PyPI
needs: [python-wheels, python-sdist]
runs-on: ubuntu-latest
environment: release
steps:
- uses: actions/download-artifact@v4
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
path: dist
pattern: wheels-*
merge-multiple: true
- name: Upload to PyPI
uses: PyO3/maturin-action@v1
uses: PyO3/maturin-action@e83996d129638aa358a18fbd1dfb82f0b0fb5d3b # v1.51.0
with:
command: upload
args: --skip-existing dist/*
@@ -127,22 +169,32 @@ jobs:
matrix:
include:
- { host: ubuntu-latest, target: x86_64-unknown-linux-gnu }
- { host: ubuntu-24.04-arm, target: aarch64-unknown-linux-gnu }
- { 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.6.
# 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
# unblocked it). A support ticket is open; once the new arm64 name
# is unblocked this matrix entry will be restored alongside the
# corresponding optionalDependencies / napi.triples / npm/<target>
# entries in a follow-up release.
# - { host: windows-11-arm, target: aarch64-pc-windows-msvc }
runs-on: ${{ matrix.host }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-node@v4
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "20"
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable branch, 2026-03-27
with:
targets: ${{ matrix.target }}
- uses: Swatinem/rust-cache@v2
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
- name: Install Node deps
working-directory: bindings/node
@@ -153,7 +205,7 @@ jobs:
run: npx napi build --platform --release --target ${{ matrix.target }}
- name: Upload artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: bindings-${{ matrix.target }}
path: bindings/node/wickra.*.node
@@ -163,10 +215,11 @@ jobs:
name: Publish to npm
needs: node-build
runs-on: ubuntu-latest
environment: release
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-node@v4
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "20"
registry-url: "https://registry.npmjs.org"
@@ -176,7 +229,7 @@ jobs:
run: npm install
- name: Download all platform binaries
uses: actions/download-artifact@v4
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
path: bindings/node/artifacts
pattern: bindings-*
@@ -188,10 +241,16 @@ jobs:
working-directory: bindings/node
run: npx napi artifacts --dir artifacts
# Publish each platform package individually so one failure doesn't kill
# the others. Skip versions that are already on npm. Tolerate spam-filter
# 403s with a one-time retry after a short delay (spam detection is
# often rate-limit-based and clears on the next request).
# Publish each platform package individually. Skip versions that are
# already on npm. A first-attempt 403 from npm's spam filter is
# tolerated for a single 30-second retry — that historically clears
# rate-limit-driven false positives. Anything that still fails after
# the retry is a *real* failure (the platform binary will be missing
# from `optionalDependencies` and Windows-style installs will break,
# exactly the regression that produced audit finding R20) — fail the
# job loudly so the release does not silently land in a half-published
# state. Previously this loop swallowed the second-attempt failure with
# a `::warning::` and `return 0`; that mask is removed.
- name: Publish platform packages (idempotent)
working-directory: bindings/node
env:
@@ -199,6 +258,7 @@ jobs:
run: |
set +e
version=$(node -p "require('./package.json').version")
fail=0
publish_dir() {
local dir=$1
local pkg=$(basename "$dir")
@@ -210,23 +270,37 @@ jobs:
return 0
fi
echo "::group::publish $pkgname@$version"
(cd "$dir" && npm publish --access public)
# --ignore-scripts: a per-platform package must never run lifecycle
# scripts during publish (npm runs prepublishOnly/prepare/etc. from
# the package being published — a malicious or stray script would
# execute with the npm token in the environment).
(cd "$dir" && npm publish --access public --ignore-scripts)
local rc=$?
echo "::endgroup::"
if [ "$rc" -ne 0 ]; then
echo "::warning::first attempt of $pkgname failed (rc=$rc); retrying after 30s"
sleep 30
(cd "$dir" && npm publish --access public)
(cd "$dir" && npm publish --access public --ignore-scripts)
rc=$?
fi
if [ "$rc" -ne 0 ]; then
echo "::warning::$pkgname could not be published; the main package will skip the missing optional dep"
echo "::error::$pkgname could not be published the release would land with a missing platform binary; failing the job."
return 1
fi
return 0
}
for dir in npm/*/; do
publish_dir "$dir"
publish_dir "$dir" || fail=1
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
@@ -257,25 +331,57 @@ jobs:
fi
exit $rc
- name: Pack node tarballs for release attachment
working-directory: bindings/node
run: |
# Main package
npm pack --ignore-scripts
# Each per-platform package (the binaries were already moved in by
# napi artifacts). --ignore-scripts for the same reason as publish:
# packing must not execute lifecycle scripts from the packed dir.
for d in npm/*/; do
(cd "$d" && npm pack --ignore-scripts)
done
- name: Upload Node tarballs
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: node-tarballs
path: |
bindings/node/*.tgz
bindings/node/npm/*/*.tgz
# --------------------------------------------------------------------------
# WASM: wasm-pack build + npm publish (as `wickra-wasm`)
# --------------------------------------------------------------------------
wasm-publish:
name: Publish wickra-wasm to npm
runs-on: ubuntu-latest
environment: release
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-node@v4
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "20"
registry-url: "https://registry.npmjs.org"
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable branch, 2026-03-27
with:
targets: wasm32-unknown-unknown
- uses: jetli/wasm-pack-action@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
@@ -290,10 +396,20 @@ jobs:
pkg.repository = { type: 'git', url: 'https://github.com/kingchenc/wickra' };
pkg.homepage = 'https://github.com/kingchenc/wickra';
pkg.bugs = { url: 'https://github.com/kingchenc/wickra/issues' };
pkg.license = 'SEE LICENSE IN LICENSE';
pkg.license = 'PolyForm-Noncommercial-1.0.0';
fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2));
"
- name: Pack wickra-wasm for release attachment
working-directory: bindings/wasm/pkg
run: npm pack
- name: Upload WASM tarball
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: wasm-tarball
path: bindings/wasm/pkg/wickra-wasm-*.tgz
- name: Publish wickra-wasm to npm (idempotent)
working-directory: bindings/wasm/pkg
run: |
@@ -302,3 +418,89 @@ jobs:
|| (echo "$out"; exit 1))
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
# --------------------------------------------------------------------------
# GitHub Release: attach every built artefact to the tag's release page.
# --------------------------------------------------------------------------
github-release:
name: Attach assets to the GitHub Release
needs: [cargo-publish, python-publish, node-publish, wasm-publish]
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
- name: Resolve target tag
id: tag
run: |
if [ "${{ github.event_name }}" = "push" ] && [[ "${{ github.ref }}" == refs/tags/* ]]; then
tag="${{ github.ref_name }}"
else
# workflow_dispatch / non-tag push: attach to the latest v* tag.
tag=$(git tag --list 'v*' --sort=-v:refname | head -n1)
fi
if [ -z "$tag" ]; then
echo "::error::no v* tag found to attach assets to"
exit 1
fi
echo "tag=$tag" >> "$GITHUB_OUTPUT"
echo "::notice::attaching assets to release $tag"
- name: Download all build artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
path: artifacts
- name: Stage release assets
run: |
set -e
mkdir -p release-assets
# Python wheels + sdist (5 wheel artifacts + 1 sdist artifact).
find artifacts -type f -name "*.whl" -exec cp {} release-assets/ \;
find artifacts -type f -name "*.tar.gz" -exec cp {} release-assets/ \;
# Native Node binaries (one per platform).
find artifacts -type f -name "*.node" -exec cp {} release-assets/ \;
# Node npm-pack tarballs (main + per-platform).
find artifacts -type f -name "wickra-*.tgz" -exec cp {} release-assets/ \;
# Cargo .crate files (one per workspace member).
find artifacts -type f -name "*.crate" -exec cp {} release-assets/ \;
ls -lh release-assets/
echo "asset-count=$(ls release-assets/ | wc -l)"
- name: Create / update GitHub Release with assets
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
with:
tag_name: ${{ steps.tag.outputs.tag }}
name: Wickra ${{ steps.tag.outputs.tag }}
files: release-assets/*
generate_release_notes: true
fail_on_unmatched_files: false
body: |
Wickra ${{ github.ref_name }} — streaming-first technical indicators across 4 language registries.
### Install
```bash
cargo add wickra
pip install wickra
npm install wickra
npm install wickra-wasm
```
### Attached assets
Pre-built artefacts for every supported platform — the same files that
were uploaded to crates.io, PyPI, and npm by this workflow run.
- `*.whl` / `wickra-*.tar.gz` — Python wheels + sdist (5 platforms, ABI3 ≥ 3.9)
- `wickra.*.node` — native Node bindings (linux-x64-gnu, darwin-x64,
darwin-arm64, win32-x64-msvc)
- `wickra-*.tgz` — npm-pack tarballs (main package + per-platform subpackages + WASM)
- `*.crate` — cargo source crates (wickra-core, wickra-data, wickra)
### Auto-generated changelog
See below; GitHub computes it from the commits since the previous tag.
+1 -1
View File
@@ -42,7 +42,7 @@ tarpaulin-report.html
*.local.toml
# Node binding artifacts
bindings/node/node_modules/
**/node_modules/
bindings/node/*.node
bindings/node/index.d.ts
bindings/node/npm-debug.log*
+387
View File
@@ -0,0 +1,387 @@
# Changelog
All notable changes to Wickra are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [0.2.6] - 2026-05-24
### Fixed
- **docs.rs build.** Rust 1.92 removed the `doc_auto_cfg` feature gate
and folded it back into `doc_cfg` (rust-lang/rust#138907). docs.rs
builds against the latest nightly and sets `--cfg docsrs`, so every
published 0.2.x failed with E0557 on the
`#![cfg_attr(docsrs, feature(doc_auto_cfg))]` line at the top of
`wickra`, `wickra-core`, and `wickra-data`. GitHub CI didn't see
this — stable rustc never enables the `docsrs` cfg. The three
library crates now gate on `doc_cfg` (same intent, same rendered
output on docs.rs, builds again on nightly).
### Changed
- **README — Wickra is now the top row of every comparison table.**
The "Why Wickra exists" library matrix and the per-indicator
benchmark tables previously placed Wickra at the bottom; a reader
landing on the README is here to compare *against* Wickra, so the
pivot row belongs at the top with a ★ marker. Same column data,
same winner annotations — only row order changed. Mirrored across
the umbrella README and every binding README so crates.io / PyPI /
npm landing pages stay in sync.
## [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
- **MSRV bumped.** Workspace minimum supported Rust version is now **1.86**
(was 1.75) and the Node binding (`wickra-node`) is now **1.88** (was 1.77).
The bumps are driven by transitive-dependency floors that were lifted in
recent updates: `criterion 0.8.2` (the bench dev-dep) requires Rust 1.86,
and `napi-build >= 2.3.2` requires Rust 1.88. Pinning those deps to the
older versions would have frozen us out of future security fixes from
those upstreams, so lifting the MSRV is the cleaner path for a young 0.x
library. Downstream consumers on older Rust toolchains can stay on
Wickra 0.2.0.
- Bumped the bench dev-dep `criterion` from 0.5 to 0.8 and migrated
`bindings/wickra/benches/indicators.rs` from the deprecated
`criterion::black_box` re-export to the stable `std::hint::black_box`.
- Bumped `tokio-tungstenite` from 0.24 to 0.29. `WebSocketConfig` became
`#[non_exhaustive]` upstream, so the struct-literal construction in
`crates/wickra-data/src/live/binance.rs` is rewritten to the
builder-style `WebSocketConfig::default().max_message_size(..).max_frame_size(..)`.
Same caps, same semantics, same default carry-over.
- Bumped every committed CI/release GitHub Action to its latest pinned
SHA: `actions/checkout` 4 → 6, `actions/setup-node` 4 → 6,
`actions/setup-python` 5 → 6, `actions/upload-artifact` 4 → 7,
`actions/download-artifact` 4 → 8, `softprops/action-gh-release` 2 → 3,
`codecov/codecov-action` 5 → 6, `taiki-e/install-action` patch.
### Fixed
- `tick_aggregator` gap-fill no longer allocates an unbounded number of
placeholder candles. The new `MAX_GAP_FILL_CANDLES = 1_000_000` cap
surfaces an adversarial timestamp jump (e.g. a clock-glitch tick years
in the future) as `Error::Malformed` instead of an OOM panic. Found by
the new `tick_aggregator` fuzz target.
- `HistoricalVolatility::geometric_series_yields_zero` now uses an `1e-6`
tolerance instead of `1e-9`. The mathematical result on a perfectly
geometric price series is exactly zero, but the underlying
`1.01_f64.powi(i)` + log-return + std-dev cascade accumulates
platform-sensitive FP drift on the order of 1e-7 on x86_64 Linux and
macOS. The widened tolerance stays four decimal places below any
realistic annualised volatility value while absorbing the drift across
every supported platform.
- Replaced every `(high + low) / 2.0` test-helper and three real call
sites (`Ohlcv::median_price`, `Donchian.middle`, `EaseOfMovement.mid`,
`SuperTrend.hl2`) with `f64::midpoint(high, low)`. The change satisfies
clippy 1.95's new `manual_midpoint` lint without affecting values
(`f64::midpoint` matches the naive average to better than 1 ULP for the
inputs used here).
- Replaced `i.is_multiple_of(2)` (unstable on Rust 1.85) with `i % 2 == 0`
in the SMA / Bollinger long-stream-drift tests so the workspace MSRV
job builds cleanly on Rust 1.86.
- The `Compile examples` CI step now invokes
`cargo build -p wickra-examples --bins` instead of the now-deleted
`cargo build -p wickra --example backtest` / `-p wickra-data --example
live_binance` (the Z5 reorganisation moved every runnable example into
the dedicated `wickra-examples` crate, but the CI step had not been
updated).
- The `Fuzz (smoke)` CI job installs `cargo-fuzz` from a prebuilt binary
via `taiki-e/install-action` instead of `cargo install cargo-fuzz`.
The source install resolved against `rustix 0.36.5`, which uses
internal `#[rustc_*]` attributes the current nightly compiler rejects.
- The fuzz targets now build with an explicit
`--target x86_64-unknown-linux-gnu`; cargo-fuzz was defaulting to
`x86_64-unknown-linux-musl`, which is not installed on the standard
GitHub-hosted Ubuntu runner.
### Removed
- **`wickra-win32-arm64-msvc` is temporarily omitted from this release.**
The npm spam-detection filter blocks the first publish of this brand-new
package name (same situation that affected `wickra-win32-x64-msvc`
through 0.1.4 until npm Support unblocked it). A support ticket is open;
once the new name is unblocked the
`aarch64-pc-windows-msvc` triple will be restored in
`bindings/node/package.json` (`napi.triples.additional` +
`optionalDependencies`), in the `release.yml` `node-build` matrix, and
as a fresh `bindings/node/npm/win32-arm64-msvc/` template. Until then,
`npm install wickra@0.2.1` on Windows ARM64 will surface the loader's
standard `Cannot find module 'wickra-win32-arm64-msvc'` error; every
other platform (Linux x64 / Linux ARM64 / macOS x64 / macOS ARM64 /
Windows x64) ships normally. The PyPI wheel for Windows ARM64 is
unaffected and still published.
## [0.2.0] - 2026-05-23
### Fixed
- `HistoricalVolatility::update` no longer substitutes a `0.0` log-return on
non-positive prices (audit finding R13). Negative or zero prices are
semantically invalid for a log-return calculation; silently treating them as
"no movement" underreported realised volatility. They are now skipped — the
previous valid value is returned and the indicator's state (`prev_price`,
window, sums) is left untouched — matching how every other indicator handles
invalid inputs.
- `Tick::new` now returns the new `Error::InvalidTick` variant for negative
volume instead of `Error::InvalidCandle` (audit finding R14). A tick is not
a candle, and downstream tick-stream pipelines should be able to match on a
semantically-correct error. The Python binding's `map_err` was extended to
forward the new variant as a `ValueError`; the Node and WASM bindings format
via `Error::to_string()` and pick the new variant up automatically.
- `Psar::is_ready` now matches the convention shared by every other indicator:
`is_ready() == true` iff a real value has been produced (audit finding R6).
The previous implementation returned `self.initialised`, which flipped to
`true` after the seed candle even though the seed candle itself returns
`None`. A streaming consumer that wrote
`if ind.is_ready() { use(ind.update(c)?) }` would hit an unexpected `None`
on the first post-seed update. The fix introduces a `has_emitted` gate set
when the first `Some` value is returned.
- `Psar::reset` now restores the compute fields (`prev_high`, `prev_low`,
`sar`, `ep`) to `f64::NAN` sentinels instead of `0.0` (audit Opus-Bonus 1).
The fields are gated by `initialised` today, so the `0.0` sentinel never
leaked into output — but a future refactor that read them pre-init would
have silently treated `0.0` as a real price. A `debug_assert!` at the read
site makes the invariant explicit.
### Changed
- `Sma` and `BollingerBands` now reseed their incremental `sum` (and `sum_sq`
for Bollinger) from the live window every `16 · period` finite updates,
capping floating-point drift on long-running streams (audit findings R7 and
L2-Rust). Previously the incremental single-subtract `sum -= old` could
accumulate catastrophic-cancellation error on streams with alternating
large/small magnitudes; the misleading `sma.rs` comment that claimed the
drift was already bounded "by recomputing the sum after each pop" is
replaced with an accurate description of the new reseed strategy. Amortised
cost stays at O(1) (`O(period)` work amortised over `O(period)` updates),
values are bit-identical on inputs that did not drift to begin with, and
two new `long_stream_drift_stays_bounded` tests stress the recompute by
alternating `1e9` / `1.0` (SMA) and `1e6` / `1.0` (Bollinger) for several
recompute cycles and verify the reported values track a fresh from-scratch
computation over the live window.
- `LinearRegression`, `LinRegSlope` and `LinRegAngle` (via composition over
`LinRegSlope`) now run their rolling ordinary-least-squares fit
**incrementally** in O(1) per update (audit finding R2). Previously every
tick refit the line from scratch in O(period). The OLS denominators (`Σx`
and `Σxx`) depend only on `period`, so they were already precomputed; this
release adds running `Σy` and `Σxy` accumulators and slides them in closed
form via the identity
`new_Σxy = old_Σxy old_Σy + popped_y₀` (then `Σxy += (n 1) · new_value`
and `Σy += new_value`). New per-bar equivalence tests compare the O(1)
output against a fresh O(n) refit on noisy ramps, step functions, and
constants — values agree to within 1e-9.
- Fuzz suite expanded from 2 indicators to the full catalogue (audit finding
R9). The existing `indicator_update` target now exercises every scalar-input
indicator (~33 classes including MACD and Bollinger Bands); a new
`indicator_update_candle` target exercises every candle-input indicator (~37
classes, including ATR, ADX, Stochastic, PSAR, Keltner, SuperTrend,
ChandelierExit, AwesomeOscillator, OBV, MFI, VWAP, RollingVWAP, and the rest
of the volume / volatility / trailing-stop / price-statistics families). Each
iteration sweeps every indicator through both the streaming `update` loop
and a full `batch` call so any state-mutation bug surfaces on either path.
CI gains a `fuzz-smoke` job that runs each of the five targets for 30 s on
every push and pull-request.
- `UlcerIndex::update` now tracks the trailing maximum with a monotonically-
decreasing deque of `(index, price)` pairs instead of scanning the whole
trailing window on every tick. The indicator now honours the `Indicator`
trait's O(1)-per-tick contract; values and warmup semantics are unchanged
(verified by a new adversarial-input test that compares the deque output
bar-by-bar against a naive O(n) trailing-max scan on strictly increasing,
strictly decreasing, constant, and sawtooth inputs). The doc comment on
`warmup_period()` is also corrected: the two windows overlap by one bar, so
the formula is `2 * period - 1`.
### Added
- `RollingVWAP` is now exposed in Python, Node and WASM under that name
(previously the rolling-window VWAP existed only in the Rust core, even
though the README's volume-family table already advertised
`VWAP (cumulative + rolling)`). All four bindings now ship the same
cumulative `VWAP` plus the finite-window `RollingVWAP(period)`. The wiki page
`Indicator-Vwap.md` adds Python, Node and WASM examples and drops the
"Rust-only" caveat.
- WASM binding now exposes the streaming `update()` method on every candle-input
indicator: `Adx`, `WilliamsR`, `Cci`, `Mfi`, `Psar`, `Keltner`, `Donchian`,
`Vwap`, `AwesomeOscillator`, `Aroon`, `Stochastic`, and `Obv`. Multi-output
indicators (`Adx`, `Keltner`, `Donchian`, `Aroon`, `Stochastic`) return a
named JS object (`{ plusDi, minusDi, adx }`, `{ upper, middle, lower }`,
`{ up, down }`, `{ k, d }`) once warm, or `null` during warmup — matching the
existing `SuperTrend` convention. Each class also gains `reset()`, `isReady()`
and `warmupPeriod()`, bringing the WASM surface to full parity with Python
and Node so browser-side streaming code no longer has to replay `batch()`
on every tick. `WasmKama` gains the previously missing `warmupPeriod()`.
- New `wasm-bindgen` integration test exercises `update == batch` plus the full
lifecycle (`reset` / `isReady` / `warmupPeriod`) for all twelve newly wired
classes against a deterministic 40-bar synthetic OHLCV stream.
### Security
- Upgrade `pyo3` (0.22 → 0.28) and `numpy` (0.22 → 0.28) in the Python binding.
Fixes [RUSTSEC-2025-0020](https://rustsec.org/advisories/RUSTSEC-2025-0020) —
a buffer overflow in `PyString::from_object` that affected the published
Python wheels. The `cargo-deny` ignore entry that previously suppressed the
advisory has been removed; `cargo deny check` is now clean without
suppression. Migrated `into_pyarray_bound` to `into_pyarray`,
`downcast::<PyDict>` to `cast::<PyDict>`, and opted every `#[pyclass]` out of
the deprecated automatic `FromPyObject` derive via `skip_from_py_object`.
### Added
- 46 new technical indicators, taking the library from 25 to 71 and
reorganising the catalogue into **eight families**, each with at least five
members. Every indicator is implemented once in the Rust core and wired
through the Python, Node and WASM bindings, with reference-value tests and a
dedicated wiki page:
- Moving Averages: `Smma`, `Trima`, `Zlema`, `T3`, `Vwma`.
- Momentum Oscillators: `Mom`, `Cmo`, `Tsi`, `Pmo`, `StochRsi`,
`UltimateOscillator`.
- Trend & Directional: `AroonOscillator`, `Vortex`, `MassIndex`,
`ChoppinessIndex`, `VerticalHorizontalFilter`.
- Price Oscillators: `Ppo`, `Dpo`, `Coppock`, `AcceleratorOscillator`,
`BalanceOfPower`.
- Volatility & Bands: `Natr`, `StdDev`, `UlcerIndex`,
`HistoricalVolatility`, `BollingerBandwidth`, `PercentB`, `TrueRange`,
`ChaikinVolatility`.
- Trailing Stops: `SuperTrend`, `ChandelierExit`, `ChandeKrollStop`,
`AtrTrailingStop`.
- Volume: `Adl`, `VolumePriceTrend`, `ChaikinMoneyFlow`,
`ChaikinOscillator`, `ForceIndex`, `EaseOfMovement`.
- Price Statistics: `TypicalPrice`, `MedianPrice`, `WeightedClose`,
`LinearRegression`, `LinRegSlope`, `ZScore`, `LinRegAngle`.
- `TickAggregator::with_gap_fill` — opt-in mode that emits a flat placeholder
candle for every empty bucket between two ticks, keeping the candle series
evenly spaced for downstream indicators.
- CSV reader: a leading UTF-8 byte-order mark is stripped, fields are trimmed,
and the header is validated against the required OHLCV columns.
- CI: an `msrv` job that builds and tests the workspace on Rust 1.75 and the
node binding on Rust 1.77.
- Community health files: `CONTRIBUTING.md`, `SECURITY.md`,
`CODE_OF_CONDUCT.md`, issue / pull-request templates, `CODEOWNERS`, and a
Dependabot configuration.
- Seven example OHLCV datasets under `examples/data/`, one per timeframe
(1m / 5m / 15m / 1h / 12h / 1d / 1month), holding real BTCUSDT spot klines,
alongside the `fetch_btcusdt` example that regenerates them from the
Binance REST API.
- `Timeframe::minutes`, `Timeframe::hours` and `Timeframe::days` convenience
constructors, each building on seconds with a checked-multiplication
overflow guard.
### Changed
- The indicator wiki is reorganised into eight family folders under
`docs/wiki/indicators/` (`moving-averages/`, `momentum-oscillators/`,
`trend-directional/`, `price-oscillators/`, `volatility-bands/`,
`trailing-stops/`, `volume/`, `price-statistics/`); `Indicators-Overview.md`,
`Home.md` and the README indicator table follow the same eight families.
- `TickAggregator::push` returns `Result<Vec<Candle>>` (was
`Result<Option<Candle>>`) so a single tick can yield a closed bar plus gap
fillers.
- `Resampler::push` returns `Result<Option<Candle>>`: a candle in a bucket
earlier than the open bar is now rejected as out of order.
- Aggregated candles are finalised through the validating `Candle::new`, so a
volume that overflows to a non-finite value is surfaced as an error instead
of producing a poisoned candle.
- All GitHub Actions are pinned to commit SHAs; the four publish jobs run in a
protected `release` environment.
- The indicator benchmarks (`crates/wickra/benches/indicators.rs`) now run
against the checked-in real BTCUSDT 1-minute dataset instead of a synthetic
price series.
- Every language's examples now live under a uniform `examples/<lang>/`
tree: Rust moved into a new `examples/rust/` workspace member crate
(`wickra-examples`, run via `cargo run -p wickra-examples --bin <name>`),
Node into `examples/node/` with its own `package.json` linking `wickra` via
`file:../../bindings/node`, and the WASM browser demos into
`examples/wasm/`. The bundled BTCUSDT datasets move alongside them at
`examples/data/`. Six new examples close the cross-language parity matrix:
streaming demos for Python and Rust; multi-timeframe and parallel-assets
demos for both Rust and Node.
- Cross-language data-generator parity: `examples/python/fetch_btcusdt.py`
(stdlib only: `urllib` + `json` + `csv`) and `examples/node/fetch_btcusdt.js`
(Node 18+ built-in `fetch`) mirror the Rust `fetch_btcusdt` binary —
byte-for-byte identical CSV output on the same Binance snapshot.
- Four additional WebAssembly browser demos under `examples/wasm/`
alongside the original `index.html`: `backtest.html` (fetch + basket of
indicators), `live_trading.html` (browser-native `WebSocket` to
Binance), `multi_timeframe.html` (in-page resample) and
`parallel_assets.html` + `parallel_worker.js` (module-Worker pool with
serial-vs-parallel speedup). The cross-language matrix is now closed
for every cell where the pattern makes sense.
- Three new wiki pages: `TA-Lib-Migration.md` (full mapping table from
`talib.X(...)` calls to Wickra), `Cookbook.md` (seven concrete
strategy recipes — RSI mean reversion, MACD crossover, Bollinger
breakout, ADX-gated trend, multi-timeframe confirmation, SuperTrend,
chained indicators) and `FAQ.md`. All three linked from `Home.md`.
### Fixed
- `Timeframe::floor` no longer overflows for timestamps near `i64::MIN`.
- The aggregator rejects same-bucket ticks that arrive out of order instead of
silently overwriting the bar's close with a stale price.
- The Binance live stream reconnects with exponential backoff, skips non-kline
frames, applies a read timeout and message-size limits, and tracks a closed
flag.
- Example scripts: `live_trading.py` skips non-kline frames and validates the
symbol/interval; `backtest.py` and `multi_timeframe.py` report clear errors
for malformed CSV input.
## [0.1.4] - 2026-05-21
### Added
- GitHub Release runs now attach every built artefact (wheels, sdist, native
Node binaries, npm-pack tarballs, cargo `.crate` files) to the tag's
release page.
## [0.1.3] - 2026-05-21
### Fixed
- npm package ships the napi-generated loader and is built with `--platform`
so the per-platform binary is resolved correctly.
## [0.1.2] - 2026-05-21
### Fixed
- Release pipeline: per-platform idempotent npm publishing with a spam-filter
retry, and committed `npm/<platform>/` package templates.
## [0.1.1] - 2026-05-21
### Fixed
- Node publish step and coordinated version bump across all bindings.
## [0.1.0] - 2026-05-21
### Added
- Initial release: a streaming-first technical-analysis library with 25
indicators (SMA, EMA, WMA, DEMA, TEMA, HMA, KAMA, RSI, MACD, ROC, Stochastic,
CCI, Williams %R, ADX, MFI, TRIX, Aroon, Awesome Oscillator, Bollinger Bands,
ATR, Keltner Channels, Donchian Channels, Parabolic SAR, OBV, VWAP).
- Rust core (`wickra-core`), umbrella crate (`wickra`), and a data layer
(`wickra-data`) with a CSV reader, tick aggregator, resampler, and an
optional Binance live feed.
- Bindings for Python, Node.js, and WebAssembly.
[Unreleased]: https://github.com/kingchenc/wickra/compare/v0.2.6...HEAD
[0.2.6]: https://github.com/kingchenc/wickra/compare/v0.2.5...v0.2.6
[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
[0.1.3]: https://github.com/kingchenc/wickra/compare/v0.1.2...v0.1.3
[0.1.2]: https://github.com/kingchenc/wickra/compare/v0.1.1...v0.1.2
[0.1.1]: https://github.com/kingchenc/wickra/compare/v0.1.0...v0.1.1
[0.1.0]: https://github.com/kingchenc/wickra/releases/tag/v0.1.0
+47
View File
@@ -0,0 +1,47 @@
# Code of Conduct
## Our pledge
We as members, contributors, and maintainers pledge to make participation in
the Wickra project a respectful and welcoming experience for everyone,
regardless of background or identity.
## Our standards
Behaviour that helps build a positive community includes:
- Showing empathy and kindness toward others.
- Respecting differing opinions, viewpoints, and experiences.
- Giving and gracefully accepting constructive feedback.
- Taking responsibility for mistakes and learning from them.
- Focusing on what is best for the project and the community.
Behaviour that is not acceptable includes:
- Personal attacks, insults, or derogatory comments.
- Harassment of any kind, public or private.
- Publishing others' private information without explicit permission.
- Other conduct that would reasonably be considered inappropriate in a
professional setting.
## Scope
This Code of Conduct applies in all project spaces — the repository, issues,
pull requests, and discussions — and when an individual is representing the
project in public spaces.
## Enforcement
Instances of unacceptable behaviour may be reported to the project maintainer
at **kingchencp@gmail.com**. All reports will be reviewed and investigated
promptly and fairly, and the maintainer will respect the privacy and security
of the reporter.
Maintainers may take any action they deem appropriate, including warnings,
temporary bans, or permanent removal from the project, for behaviour that
violates this Code of Conduct.
## Attribution
This Code of Conduct is adapted from the
[Contributor Covenant](https://www.contributor-covenant.org), version 2.1.
+96
View File
@@ -0,0 +1,96 @@
# Contributing to Wickra
Thanks for your interest in improving Wickra. This document explains how to
build the project, the standards a change must meet, and how to get it merged.
## License of contributions
Wickra is licensed under the **PolyForm Noncommercial License 1.0.0** (see
[`LICENSE`](LICENSE)). By submitting a contribution you agree that it is
licensed to the project under those same terms. The Noncommercial license
permits use for any purpose **other than** a commercial one; keep that in mind
when proposing features or depending on Wickra elsewhere.
## Project layout
| Path | Contents |
| --- | --- |
| `crates/wickra-core` | The indicator engine — every indicator lives here. |
| `crates/wickra` | Thin umbrella crate re-exporting `wickra-core`. |
| `crates/wickra-data` | CSV reader, tick aggregator, resampler, Binance feed. |
| `bindings/python` | PyO3 bindings (`wickra` on PyPI). |
| `bindings/node` | napi-rs bindings (`wickra` on npm). |
| `bindings/wasm` | wasm-bindgen bindings (`wickra-wasm` on npm). |
| `examples/` | Runnable examples. |
| `docs/` | Pointer to the project Wiki, which holds all documentation. |
## Building and testing
### Rust
```bash
cargo fmt --all --check
cargo clippy --workspace --all-targets -- -D warnings
cargo test --workspace
cargo test -p wickra-data --features live-binance
```
The minimum supported Rust version is **1.75** for the workspace crates and
**1.77** for `bindings/node`; the `msrv` CI job enforces both.
### Python
```bash
cd bindings/python
python -m maturin build --release --out dist
python -m pip install --force-reinstall --no-deps dist/wickra-*.whl
python -m pytest -q
```
### Node
```bash
cd bindings/node
npm install
npx napi build --platform --release
node --test __tests__/
```
### WASM
```bash
wasm-pack build bindings/wasm --target web --release --features panic-hook
wasm-pack test --node bindings/wasm
```
## Standards for a change
- **Formatting & lints.** `cargo fmt` must leave the tree unchanged and
`cargo clippy ... -D warnings` must be clean. CI gates both.
- **Tests.** New behaviour needs tests; bug fixes need a regression test.
- **Indicator correctness.** A new or changed indicator must have a
reference-value test against a known-good source (TA-Lib, pandas-ta, or a
hand-computed value) and a `reset` test.
- **Streaming parity.** An indicator's `batch` output must equal the sequence
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 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
1. Branch off `main`.
2. Keep commits focused — one logical change per commit, with an imperative
subject line and a body explaining *why*.
3. Open a pull request against `main` and fill in the template.
4. CI must be green before review.
## Reporting bugs and proposing features
Use the issue templates under
[`.github/ISSUE_TEMPLATE`](.github/ISSUE_TEMPLATE). For security-sensitive
reports, follow [`SECURITY.md`](SECURITY.md) instead of opening a public issue.
Generated
+182 -166
View File
@@ -1,6 +1,6 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 3
version = 4
[[package]]
name = "aho-corasick"
@@ -11,6 +11,15 @@ dependencies = [
"memchr",
]
[[package]]
name = "alloca"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5a7d05ea6aea7e9e64d25b9156ba2fee3fdd659e34e41063cd2fc7cd020d7f4"
dependencies = [
"cc",
]
[[package]]
name = "anes"
version = "0.1.6"
@@ -38,6 +47,17 @@ dependencies = [
"num-traits",
]
[[package]]
name = "async-trait"
version = "0.1.89"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "autocfg"
version = "1.5.0"
@@ -80,12 +100,6 @@ version = "3.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
[[package]]
name = "byteorder"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "bytes"
version = "1.11.1"
@@ -212,25 +226,24 @@ dependencies = [
[[package]]
name = "criterion"
version = "0.5.1"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f"
checksum = "950046b2aa2492f9a536f5f4f9a3de7b9e2476e575e05bd6c333371add4d98f3"
dependencies = [
"alloca",
"anes",
"cast",
"ciborium",
"clap",
"criterion-plot",
"is-terminal",
"itertools",
"num-traits",
"once_cell",
"oorandom",
"page_size",
"plotters",
"rayon",
"regex",
"serde",
"serde_derive",
"serde_json",
"tinytemplate",
"walkdir",
@@ -238,9 +251,9 @@ dependencies = [
[[package]]
name = "criterion-plot"
version = "0.5.0"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1"
checksum = "d8d80a2f4f5b554395e47b5d8305bc3d27813bacb73493eb1001e8f76dae29ea"
dependencies = [
"cast",
"itertools",
@@ -468,17 +481,6 @@ dependencies = [
"version_check",
]
[[package]]
name = "getrandom"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"libc",
"wasi",
]
[[package]]
name = "getrandom"
version = "0.3.4"
@@ -536,12 +538,6 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "hermit-abi"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
[[package]]
name = "http"
version = "1.4.0"
@@ -679,31 +675,11 @@ dependencies = [
"serde_core",
]
[[package]]
name = "indoc"
version = "2.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706"
dependencies = [
"rustversion",
]
[[package]]
name = "is-terminal"
version = "0.4.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46"
dependencies = [
"hermit-abi",
"libc",
"windows-sys",
]
[[package]]
name = "itertools"
version = "0.10.5"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473"
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
dependencies = [
"either",
]
@@ -748,6 +724,12 @@ dependencies = [
"windows-link",
]
[[package]]
name = "libm"
version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
@@ -783,12 +765,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
[[package]]
name = "memoffset"
version = "0.9.1"
name = "minicov"
version = "0.3.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"
checksum = "4869b6a491569605d66d3952bcdf03df789e5b536e5f0cf7758a7f08a55ae24d"
dependencies = [
"autocfg",
"cc",
"walkdir",
]
[[package]]
@@ -891,6 +874,15 @@ dependencies = [
"rawpointer",
]
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys",
]
[[package]]
name = "num-complex"
version = "0.4.6"
@@ -916,13 +908,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
"libm",
]
[[package]]
name = "numpy"
version = "0.22.1"
version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edb929bc0da91a4d85ed6c0a84deaa53d411abfb387fc271124f91bf6b89f14e"
checksum = "778da78c64ddc928ebf5ad9df5edf0789410ff3bdbf3619aed51cd789a6af1e2"
dependencies = [
"libc",
"ndarray",
@@ -930,6 +923,7 @@ dependencies = [
"num-integer",
"num-traits",
"pyo3",
"pyo3-build-config",
"rustc-hash",
]
@@ -988,6 +982,16 @@ dependencies = [
"vcpkg",
]
[[package]]
name = "page_size"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30d5b2194ed13191c1999ae0704b7839fb18384fa22e49b57eeaa97d79ce40da"
dependencies = [
"libc",
"winapi",
]
[[package]]
name = "percent-encoding"
version = "2.3.2"
@@ -1096,8 +1100,8 @@ dependencies = [
"bit-vec",
"bitflags",
"num-traits",
"rand 0.9.4",
"rand_chacha 0.9.0",
"rand",
"rand_chacha",
"rand_xorshift",
"regex-syntax",
"rusty-fork",
@@ -1107,37 +1111,32 @@ dependencies = [
[[package]]
name = "pyo3"
version = "0.22.6"
version = "0.28.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f402062616ab18202ae8319da13fa4279883a2b8a9d9f83f20dbade813ce1884"
checksum = "91fd8e38a3b50ed1167fb981cd6fd60147e091784c427b8f7183a7ee32c31c12"
dependencies = [
"cfg-if",
"indoc",
"libc",
"memoffset",
"once_cell",
"portable-atomic",
"pyo3-build-config",
"pyo3-ffi",
"pyo3-macros",
"unindent",
]
[[package]]
name = "pyo3-build-config"
version = "0.22.6"
version = "0.28.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b14b5775b5ff446dd1056212d778012cbe8a0fbffd368029fd9e25b514479c38"
checksum = "e368e7ddfdeb98c9bca7f8383be1648fd84ab466bf2bc015e94008db6d35611e"
dependencies = [
"once_cell",
"target-lexicon",
]
[[package]]
name = "pyo3-ffi"
version = "0.22.6"
version = "0.28.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ab5bcf04a2cdcbb50c7d6105de943f543f9ed92af55818fd17b660390fc8636"
checksum = "7f29e10af80b1f7ccaf7f69eace800a03ecd13e883acfacc1e5d0988605f651e"
dependencies = [
"libc",
"pyo3-build-config",
@@ -1145,9 +1144,9 @@ dependencies = [
[[package]]
name = "pyo3-macros"
version = "0.22.6"
version = "0.28.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fd24d897903a9e6d80b968368a34e1525aeb719d568dba8b3d4bfa5dc67d453"
checksum = "df6e520eff47c45997d2fc7dd8214b25dd1310918bbb2642156ef66a67f29813"
dependencies = [
"proc-macro2",
"pyo3-macros-backend",
@@ -1157,9 +1156,9 @@ dependencies = [
[[package]]
name = "pyo3-macros-backend"
version = "0.22.6"
version = "0.28.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "36c011a03ba1e50152b4b394b479826cad97e7a21eb52df179cd91ac411cbfbe"
checksum = "c4cdc218d835738f81c2338f822078af45b4afdf8b2e33cbb5916f108b813acb"
dependencies = [
"heck",
"proc-macro2",
@@ -1195,35 +1194,14 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rand"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
dependencies = [
"libc",
"rand_chacha 0.3.1",
"rand_core 0.6.4",
]
[[package]]
name = "rand"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
dependencies = [
"rand_chacha 0.9.0",
"rand_core 0.9.5",
]
[[package]]
name = "rand_chacha"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [
"ppv-lite86",
"rand_core 0.6.4",
"rand_chacha",
"rand_core",
]
[[package]]
@@ -1233,16 +1211,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core 0.9.5",
]
[[package]]
name = "rand_core"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
dependencies = [
"getrandom 0.2.17",
"rand_core",
]
[[package]]
@@ -1260,7 +1229,7 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a"
dependencies = [
"rand_core 0.9.5",
"rand_core",
]
[[package]]
@@ -1320,9 +1289,9 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[package]]
name = "rustc-hash"
version = "1.1.0"
version = "2.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2"
checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
[[package]]
name = "rustix"
@@ -1451,9 +1420,9 @@ dependencies = [
[[package]]
name = "serde_json"
version = "1.0.149"
version = "1.0.150"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
dependencies = [
"itoa",
"memchr",
@@ -1531,9 +1500,9 @@ dependencies = [
[[package]]
name = "target-lexicon"
version = "0.12.16"
version = "0.13.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca"
[[package]]
name = "tempfile"
@@ -1548,33 +1517,13 @@ dependencies = [
"windows-sys",
]
[[package]]
name = "thiserror"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
dependencies = [
"thiserror-impl 1.0.69",
]
[[package]]
name = "thiserror"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
dependencies = [
"thiserror-impl 2.0.18",
]
[[package]]
name = "thiserror-impl"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [
"proc-macro2",
"quote",
"syn",
"thiserror-impl",
]
[[package]]
@@ -1646,9 +1595,9 @@ dependencies = [
[[package]]
name = "tokio-tungstenite"
version = "0.24.0"
version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9"
checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c"
dependencies = [
"futures-util",
"log",
@@ -1660,21 +1609,19 @@ dependencies = [
[[package]]
name = "tungstenite"
version = "0.24.0"
version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a"
checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8"
dependencies = [
"byteorder",
"bytes",
"data-encoding",
"http",
"httparse",
"log",
"native-tls",
"rand 0.8.6",
"rand",
"sha1",
"thiserror 1.0.69",
"utf-8",
"thiserror",
]
[[package]]
@@ -1707,12 +1654,6 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "unindent"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3"
[[package]]
name = "url"
version = "2.5.8"
@@ -1725,12 +1666,6 @@ dependencies = [
"serde",
]
[[package]]
name = "utf-8"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
[[package]]
name = "utf8_iter"
version = "1.0.4"
@@ -1805,6 +1740,16 @@ dependencies = [
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-futures"
version = "0.4.71"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.121"
@@ -1837,6 +1782,45 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "wasm-bindgen-test"
version = "0.3.71"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af5ec93229ad9ccd0a545a516dec76dc276613f278f6a91aa6b463d5b33d42d0"
dependencies = [
"async-trait",
"cast",
"js-sys",
"libm",
"minicov",
"nu-ansi-term",
"num-traits",
"oorandom",
"serde",
"serde_json",
"wasm-bindgen",
"wasm-bindgen-futures",
"wasm-bindgen-test-macro",
"wasm-bindgen-test-shared",
]
[[package]]
name = "wasm-bindgen-test-macro"
version = "0.3.71"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c81b9fef827e575e0e54431736d1baa0d700315d8c62cfef1f61fa3aad0cbeb"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "wasm-bindgen-test-shared"
version = "0.2.121"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4f4d8ae7ad5440360e9799dfd42857d126454a88441ddf72d288ef83fa47f527"
[[package]]
name = "wasm-encoder"
version = "0.244.0"
@@ -1883,7 +1867,7 @@ dependencies = [
[[package]]
name = "wickra"
version = "0.1.3"
version = "0.2.6"
dependencies = [
"approx",
"criterion",
@@ -1894,17 +1878,17 @@ dependencies = [
[[package]]
name = "wickra-core"
version = "0.1.3"
version = "0.2.6"
dependencies = [
"approx",
"proptest",
"rayon",
"thiserror 2.0.18",
"thiserror",
]
[[package]]
name = "wickra-data"
version = "0.1.3"
version = "0.2.6"
dependencies = [
"approx",
"csv",
@@ -1912,17 +1896,26 @@ dependencies = [
"serde",
"serde_json",
"tempfile",
"thiserror 2.0.18",
"thiserror",
"tokio",
"tokio-tungstenite",
"url",
"wickra",
"wickra-core",
]
[[package]]
name = "wickra-examples"
version = "0.0.0"
dependencies = [
"serde_json",
"tokio",
"wickra",
"wickra-data",
]
[[package]]
name = "wickra-node"
version = "0.1.3"
version = "0.2.6"
dependencies = [
"napi",
"napi-build",
@@ -1932,7 +1925,7 @@ dependencies = [
[[package]]
name = "wickra-python"
version = "0.1.3"
version = "0.2.6"
dependencies = [
"numpy",
"pyo3",
@@ -1941,16 +1934,33 @@ dependencies = [
[[package]]
name = "wickra-wasm"
version = "0.1.3"
version = "0.2.6"
dependencies = [
"console_error_panic_hook",
"js-sys",
"serde",
"serde-wasm-bindgen",
"wasm-bindgen",
"wasm-bindgen-test",
"wickra-core",
]
[[package]]
name = "winapi"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
dependencies = [
"winapi-i686-pc-windows-gnu",
"winapi-x86_64-pc-windows-gnu",
]
[[package]]
name = "winapi-i686-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]]
name = "winapi-util"
version = "0.1.11"
@@ -1960,6 +1970,12 @@ dependencies = [
"windows-sys",
]
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows-link"
version = "0.2.1"
+9 -8
View File
@@ -7,14 +7,15 @@ members = [
"bindings/python",
"bindings/wasm",
"bindings/node",
"examples/rust",
]
exclude = []
exclude = ["fuzz"]
[workspace.package]
version = "0.1.3"
authors = ["Wickra Contributors"]
version = "0.2.6"
authors = ["kingchenc <kingchencp@gmail.com>"]
edition = "2021"
rust-version = "1.75"
rust-version = "1.86"
license = "PolyForm-Noncommercial-1.0.0"
repository = "https://github.com/kingchenc/wickra"
homepage = "https://github.com/kingchenc/wickra"
@@ -23,7 +24,7 @@ keywords = ["finance", "trading", "indicators", "technical-analysis", "ta"]
categories = ["finance", "mathematics", "science"]
[workspace.dependencies]
wickra-core = { path = "crates/wickra-core", version = "0.1.3" }
wickra-core = { path = "crates/wickra-core", version = "0.2.6" }
thiserror = "2"
rayon = "1.10"
@@ -31,11 +32,11 @@ rayon = "1.10"
# Testing
proptest = "1.5"
approx = "0.5"
criterion = { version = "0.5", features = ["html_reports"] }
criterion = { version = "0.8", features = ["html_reports"] }
# Python binding
pyo3 = { version = "0.22", features = ["extension-module", "abi3-py39"] }
numpy = "0.22"
pyo3 = { version = "0.28", features = ["extension-module", "abi3-py39"] }
numpy = "0.28"
[workspace.lints.rust]
unsafe_code = "forbid"
+94 -48
View File
@@ -1,6 +1,7 @@
# Wickra
[![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)
@@ -35,49 +36,63 @@ for price in live_feed:
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** |
| Library | Install pain | Streaming | Multi-language | Active |
|------------------------|-----------------|-----------|----------------|--------|
| **★&nbsp;Wickra** | **clean** | **yes** | **Python + Node + WASM + Rust** | **yes** |
| 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 is the only library that combines all of: clean install, streaming,
multi-language reach, and active maintenance.
## Benchmark: how much faster is "streaming-first"?
Reproduced on this machine with `python -m benchmarks.compare_libraries`.
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 5 000-bar series
### 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) | **26.0 µs ★** | 295.3 µs (11.4× slower) | 1 812.8 µs (69.7× slower) |
| EMA(20) | **16.8 µs ★** | 205.5 µs (12.2× slower) | 2 534.4 µs (150.9× slower) |
| RSI(14) | **31.2 µs ★** | 714.1 µs (22.9× slower) | 3 751.7 µs (120.2× slower) |
| MACD(12, 26, 9) | **30.8 µs ★** | 359.5 µs (11.7× slower) | 11 642.2 µs (378.0× slower) |
| Bollinger(20, 2.0) | **26.7 µs ★** | 690.6 µs (25.9× slower) | 27 482.4 µs (1 030.1× slower) |
| ATR(14) | **40.6 µs ★** | 1 120.3 µs (27.6× slower) | 3 760.2 µs (92.7× slower) |
| Indicator | **★&nbsp;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 2 000 historical bars
### 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) |
| Indicator | **★&nbsp;Wickra (per tick)** | talipp (per tick) |
|-----------|---------------------|---------------------------|
| RSI(14) | **0.07 µs ★** | 1.16 µs (17.5× slower) |
| 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
@@ -92,18 +107,22 @@ pip install -e bindings/python[bench]
python -m benchmarks.compare_libraries
```
## Indicators in 0.1.0
## Indicators
25 streaming-first indicators across four families. Every one passes the
71 streaming-first indicators across eight families. Every one passes the
`batch == streaming` equivalence test, reference-value tests, and reset
semantics tests.
| Family | Indicators |
|-------------|-----------|
| Trend | SMA, EMA, WMA, DEMA, TEMA, HMA, KAMA |
| Momentum | RSI (Wilder), MACD, Stochastic, CCI, ROC, Williams %R, ADX (+DI/-DI), MFI, TRIX, Awesome Oscillator, Aroon |
| Volatility | Bollinger Bands, ATR, Keltner Channels, Donchian Channels, Parabolic SAR |
| Volume | OBV, VWAP (cumulative + rolling) |
| 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.
@@ -113,9 +132,12 @@ inherit it automatically.
| Binding | Install | Example |
|-------------------|-----------------------------------------------|---------|
| Python (PyO3) | `pip install wickra` | `examples/python/backtest.py` |
| Node.js (napi-rs) | `npm install wickra` | `bindings/node/__tests__/smoke.test.js` |
| Browser / WASM | `npm install wickra-wasm` | `bindings/wasm/examples/index.html` |
| Rust | `cargo add wickra` | `crates/wickra/examples/backtest.rs` |
| 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.
@@ -173,20 +195,26 @@ A Python live-trading example using the public `websockets` package lives at
```
wickra/
├── crates/
│ ├── wickra-core/ core engine + all 25 indicators
│ ├── wickra/ top-level facade crate (publishes on crates.io)
│ ├── 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/
── python/ backtest, live trading, parallel assets, multi-tf
(Rust examples live inside their crate at crates/<name>/examples/)
├── benches/ cargo bench targets
├── 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
@@ -207,15 +235,23 @@ wasm-pack build bindings/wasm --target web --release --features panic-hook
cd bindings/node && npm install && npm run build && npm test
```
## Test counts
## Testing
- `wickra-core`: 171 unit tests + 2 doctests, including textbook-value tests
for Wilder RSI, Bollinger Bands, MACD, ATR, and Stochastic.
- `wickra-data`: 11 unit tests + 1 doctest, covers CSV decoding, the tick
aggregator, the resampler, and the Binance payload parser.
- `bindings/python`: 56 pytest tests covering smoke checks, streaming==batch
equivalence, reference values, lifecycle, and dict/tuple candle inputs.
- `bindings/node`: 7 Node test-runner cases via `node --test`.
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
@@ -251,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">
+43
View File
@@ -0,0 +1,43 @@
# Security Policy
## Supported versions
Wickra is pre-1.0. Security fixes are applied to the latest released `0.1.x`
version only; please upgrade to the newest release before reporting an issue.
| Version | Supported |
| --- | --- |
| 0.1.x (latest) | :white_check_mark: |
| older 0.1.x | :x: |
## Reporting a vulnerability
**Do not open a public issue for a security vulnerability.**
Report it privately through one of:
- GitHub's [private vulnerability reporting](https://github.com/kingchenc/wickra/security/advisories/new)
("Report a vulnerability" under the repository's *Security* tab), or
- email to **kingchencp@gmail.com** with a subject line starting with
`[wickra security]`.
Please include:
- the affected version(s) and platform / language binding,
- a description of the issue and its impact,
- steps to reproduce, ideally a minimal proof of concept.
## What to expect
- An acknowledgement within **5 working days**.
- An assessment and, if confirmed, a planned fix with a target release.
- Coordinated disclosure: we will agree on a disclosure date with you and
credit you in the release notes unless you prefer to stay anonymous.
## Scope
In scope: the published crates (`wickra-core`, `wickra-data`, `wickra`), the
PyPI/npm packages, and the build/release workflows in `.github/workflows/`.
Out of scope: vulnerabilities in third-party dependencies (report those
upstream; we track them via Dependabot and `cargo-deny`).
+5 -3
View File
@@ -4,9 +4,11 @@ description = "Node.js bindings for the Wickra streaming-first technical indicat
version.workspace = true
authors.workspace = true
edition.workspace = true
# napi-build emits `cargo::` directives that require Rust >= 1.77; the rest of
# the workspace stays at 1.75 because the core crate has no such dependency.
rust-version = "1.77"
# napi-build 2.3.2 requires Rust >= 1.88 (newer than the workspace 1.80
# minimum, which itself was lifted to satisfy rayon-core 1.13.0). napi-build
# also emits `cargo::` directives that require >= 1.77 — that older floor is
# subsumed by the 1.88 requirement now.
rust-version = "1.88"
license.workspace = true
repository.workspace = true
homepage.workspace = true
+288 -27
View File
@@ -1,45 +1,306 @@
# @wickra/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/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 |
|------------------------|-----------------|-----------|----------------|--------|
| **★&nbsp;Wickra** | **clean** | **yes** | **Python + Node + WASM + Rust** | **yes** |
| 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 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 | **★&nbsp;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 | **★&nbsp;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/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>
+260
View File
@@ -0,0 +1,260 @@
// Comprehensive tests for the Wickra Node bindings: streaming-vs-batch
// equivalence, reference values, and lifecycle methods across all 71
// indicators. Ported from the Python test_streaming_vs_batch / test_known_values
// suites.
const test = require('node:test');
const assert = require('node:assert/strict');
const wickra = require('..');
// Synthetic OHLCV series long enough to warm up every indicator.
const N = 120;
const close = Array.from({ length: N }, (_, i) => 100 + Math.sin(i * 0.2) * 10 + i * 0.1);
const high = close.map((c) => c + 1.5);
const low = close.map((c) => c - 1.5);
const volume = Array.from({ length: N }, (_, i) => 1000 + (i % 7) * 50);
const open = close.map((c) => c - 0.5);
function eq(a, b) {
if (Number.isNaN(a)) return Number.isNaN(b);
return Math.abs(a - b) < 1e-9;
}
function num(v) {
return v === null || v === undefined ? NaN : v;
}
// --- Scalar indicators: update(value) vs batch(prices) ---
const scalarFactories = {
SMA: () => new wickra.SMA(14),
EMA: () => new wickra.EMA(14),
WMA: () => new wickra.WMA(14),
RSI: () => new wickra.RSI(14),
DEMA: () => new wickra.DEMA(10),
TEMA: () => new wickra.TEMA(10),
HMA: () => new wickra.HMA(9),
ROC: () => new wickra.ROC(12),
TRIX: () => new wickra.TRIX(9),
KAMA: () => new wickra.KAMA(10, 2, 30),
SMMA: () => new wickra.SMMA(14),
TRIMA: () => new wickra.TRIMA(20),
ZLEMA: () => new wickra.ZLEMA(14),
T3: () => new wickra.T3(5, 0.7),
MOM: () => new wickra.MOM(10),
CMO: () => new wickra.CMO(14),
TSI: () => new wickra.TSI(25, 13),
PMO: () => new wickra.PMO(35, 20),
StochRSI: () => new wickra.StochRSI(14, 14),
PPO: () => new wickra.PPO(12, 26),
DPO: () => new wickra.DPO(20),
Coppock: () => new wickra.Coppock(14, 11, 10),
StdDev: () => new wickra.StdDev(20),
UlcerIndex: () => new wickra.UlcerIndex(14),
HistoricalVolatility: () => new wickra.HistoricalVolatility(20, 252),
BollingerBandwidth: () => new wickra.BollingerBandwidth(20, 2),
PercentB: () => new wickra.PercentB(20, 2),
LinearRegression: () => new wickra.LinearRegression(14),
LinRegSlope: () => new wickra.LinRegSlope(14),
VerticalHorizontalFilter: () => new wickra.VerticalHorizontalFilter(28),
ZScore: () => new wickra.ZScore(20),
LinRegAngle: () => new wickra.LinRegAngle(14),
};
for (const [name, make] of Object.entries(scalarFactories)) {
test(`${name}: streaming update matches batch`, () => {
const batch = make().batch(close);
const streaming = make();
assert.equal(batch.length, N);
for (let i = 0; i < N; i++) {
const s = num(streaming.update(close[i]));
assert.ok(eq(s, batch[i]), `${name} mismatch at ${i}: ${s} vs ${batch[i]}`);
}
});
}
// --- Scalar-output candle indicators: update(...) vs batch(...) ---
const candleScalar = {
ATR: { make: () => new wickra.ATR(14), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
CCI: { make: () => new wickra.CCI(20), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
WilliamsR: { make: () => new wickra.WilliamsR(14), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
PSAR: { make: () => new wickra.PSAR(0.02, 0.02, 0.2), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
MFI: { make: () => new wickra.MFI(14), step: (ind, i) => ind.update(high[i], low[i], close[i], volume[i]), batch: (ind) => ind.batch(high, low, close, volume) },
VWAP: { make: () => new wickra.VWAP(), step: (ind, i) => ind.update(high[i], low[i], close[i], volume[i]), batch: (ind) => ind.batch(high, low, close, volume) },
RollingVWAP: { make: () => new wickra.RollingVWAP(20), step: (ind, i) => ind.update(high[i], low[i], close[i], volume[i]), batch: (ind) => ind.batch(high, low, close, volume) },
AwesomeOscillator: { make: () => new wickra.AwesomeOscillator(5, 34), step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
OBV: { make: () => new wickra.OBV(), step: (ind, i) => ind.update(close[i], volume[i]), batch: (ind) => ind.batch(close, volume) },
VWMA: { make: () => new wickra.VWMA(20), step: (ind, i) => ind.update(close[i], volume[i]), batch: (ind) => ind.batch(close, volume) },
UltimateOscillator: { make: () => new wickra.UltimateOscillator(7, 14, 28), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
AroonOscillator: { make: () => new wickra.AroonOscillator(14), step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
NATR: { make: () => new wickra.NATR(14), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
MassIndex: { make: () => new wickra.MassIndex(9, 25), step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
ADL: { make: () => new wickra.ADL(), step: (ind, i) => ind.update(high[i], low[i], close[i], volume[i]), batch: (ind) => ind.batch(high, low, close, volume) },
VolumePriceTrend: { make: () => new wickra.VolumePriceTrend(), step: (ind, i) => ind.update(close[i], volume[i]), batch: (ind) => ind.batch(close, volume) },
ChaikinMoneyFlow: { make: () => new wickra.ChaikinMoneyFlow(20), step: (ind, i) => ind.update(high[i], low[i], close[i], volume[i]), batch: (ind) => ind.batch(high, low, close, volume) },
ChaikinOscillator: { make: () => new wickra.ChaikinOscillator(3, 10), step: (ind, i) => ind.update(high[i], low[i], close[i], volume[i]), batch: (ind) => ind.batch(high, low, close, volume) },
ForceIndex: { make: () => new wickra.ForceIndex(13), step: (ind, i) => ind.update(close[i], volume[i]), batch: (ind) => ind.batch(close, volume) },
EaseOfMovement: { make: () => new wickra.EaseOfMovement(14, 1e8), step: (ind, i) => ind.update(high[i], low[i], volume[i]), batch: (ind) => ind.batch(high, low, volume) },
AtrTrailingStop: { make: () => new wickra.AtrTrailingStop(14, 3), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
TypicalPrice: { make: () => new wickra.TypicalPrice(), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
MedianPrice: { make: () => new wickra.MedianPrice(), step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
WeightedClose: { make: () => new wickra.WeightedClose(), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
AcceleratorOscillator: { make: () => new wickra.AcceleratorOscillator(5, 34, 5), step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
BalanceOfPower: { make: () => new wickra.BalanceOfPower(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
ChoppinessIndex: { make: () => new wickra.ChoppinessIndex(14), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
TrueRange: { make: () => new wickra.TrueRange(), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
ChaikinVolatility: { make: () => new wickra.ChaikinVolatility(10, 10), step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
};
for (const [name, d] of Object.entries(candleScalar)) {
test(`${name}: streaming update matches batch`, () => {
const batch = d.batch(d.make());
const streaming = d.make();
assert.equal(batch.length, N);
for (let i = 0; i < N; i++) {
const s = num(d.step(streaming, i));
assert.ok(eq(s, batch[i]), `${name} mismatch at ${i}: ${s} vs ${batch[i]}`);
}
});
}
// --- Multi-output indicators: object update vs interleaved batch ---
const multi = {
MACD: { make: () => new wickra.MACD(12, 26, 9), fields: ['macd', 'signal', 'histogram'], step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
BollingerBands: { make: () => new wickra.BollingerBands(20, 2), fields: ['upper', 'middle', 'lower', 'stddev'], step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
Stochastic: { make: () => new wickra.Stochastic(14, 3), fields: ['k', 'd'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
ADX: { make: () => new wickra.ADX(14), fields: ['plusDi', 'minusDi', 'adx'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
Keltner: { make: () => new wickra.Keltner(20, 10, 2), fields: ['upper', 'middle', 'lower'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
Donchian: { make: () => new wickra.Donchian(20), fields: ['upper', 'middle', 'lower'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
Aroon: { make: () => new wickra.Aroon(14), fields: ['up', 'down'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
Vortex: { make: () => new wickra.Vortex(14), fields: ['plus', 'minus'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
SuperTrend: { make: () => new wickra.SuperTrend(10, 3), fields: ['value', 'direction'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
ChandelierExit: { make: () => new wickra.ChandelierExit(22, 3), fields: ['longStop', 'shortStop'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
ChandeKrollStop: { make: () => new wickra.ChandeKrollStop(10, 1, 9), fields: ['stopLong', 'stopShort'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
};
for (const [name, d] of Object.entries(multi)) {
test(`${name}: streaming update matches interleaved batch`, () => {
const k = d.fields.length;
const batch = d.batch(d.make());
const streaming = d.make();
assert.equal(batch.length, N * k);
for (let i = 0; i < N; i++) {
const o = d.step(streaming, i);
d.fields.forEach((field, j) => {
const s = o === null || o === undefined ? NaN : o[field];
assert.ok(eq(s, batch[i * k + j]), `${name}.${field} mismatch at ${i}`);
});
}
});
}
// --- Lifecycle: every indicator exposes reset / isReady / warmupPeriod ---
test('every indicator exposes reset, isReady and warmupPeriod', () => {
const all = [
...Object.values(scalarFactories).map((f) => f()),
...Object.values(candleScalar).map((d) => d.make()),
...Object.values(multi).map((d) => d.make()),
];
for (const ind of all) {
assert.equal(typeof ind.reset, 'function');
assert.equal(typeof ind.isReady, 'function');
assert.equal(typeof ind.warmupPeriod, 'function');
assert.equal(ind.isReady(), false);
assert.ok(ind.warmupPeriod() >= 1);
}
});
test('reset returns an indicator to its un-warmed state', () => {
const sma = new wickra.SMA(5);
sma.batch([1, 2, 3, 4, 5]);
assert.equal(sma.isReady(), true);
sma.reset();
assert.equal(sma.isReady(), false);
assert.equal(sma.update(10), null);
});
// --- Reference values ---
test('SMA(3) reference values', () => {
const out = new wickra.SMA(3).batch([2, 4, 6, 8, 10]);
assert.ok(Number.isNaN(out[0]) && Number.isNaN(out[1]));
assert.equal(out[2], 4);
assert.equal(out[3], 6);
assert.equal(out[4], 8);
});
test('MFI(2) reference value equals 1200/23', () => {
// Candle 1 seeds; candle 2 (tp 12 > 10) +mf 1200; candle 3 (tp 11 < 12) -mf 1100.
const mfi = new wickra.MFI(2);
assert.equal(mfi.update(10, 10, 10, 100), null);
assert.equal(mfi.update(12, 12, 12, 100), null);
const v = mfi.update(11, 11, 11, 100);
assert.ok(Math.abs(v - 1200 / 23) < 1e-9);
});
test('RSI pure uptrend yields 100', () => {
const prices = Array.from({ length: 20 }, (_, i) => i + 1);
const out = new wickra.RSI(14).batch(prices);
for (let i = 14; i < out.length; i++) {
assert.equal(out[i], 100);
}
});
test('MACD histogram equals macd minus signal', () => {
const macd = new wickra.MACD(12, 26, 9);
let v = null;
for (let i = 1; i <= 60; i++) v = macd.update(i);
assert.ok(v);
assert.ok(Math.abs(v.histogram - (v.macd - v.signal)) < 1e-9);
});
test('TypicalPrice reference value', () => {
// (high + low + close) / 3 = (12 + 6 + 9) / 3 = 9.
assert.equal(new wickra.TypicalPrice().update(12, 6, 9), 9);
});
test('ChaikinMoneyFlow(2) reference value equals 0.5', () => {
// Bar 1 closes at the high (MFV +100); bar 2 closes mid-range (MFV 0).
const cmf = new wickra.ChaikinMoneyFlow(2);
assert.equal(cmf.update(10, 8, 10, 100), null);
assert.ok(Math.abs(cmf.update(12, 8, 10, 100) - 0.5) < 1e-9);
});
test('LinearRegression(3) reference values', () => {
// Least-squares line through [1, 2, 9] is y = 4x; endpoint 4·2 = 8.
const out = new wickra.LinearRegression(3).batch([1, 2, 9]);
assert.ok(Number.isNaN(out[0]) && Number.isNaN(out[1]));
assert.ok(Math.abs(out[2] - 8) < 1e-9);
});
test('SuperTrend flat market holds the lower band and an uptrend', () => {
// Flat candles: ATR 2, hl2 10, lower band 10 - 3·2 = 4.
const n = 20;
const out = new wickra.SuperTrend(5, 3).batch(
Array(n).fill(11),
Array(n).fill(9),
Array(n).fill(10),
);
assert.ok(Math.abs(out[2 * n - 2] - 4) < 1e-9); // value
assert.equal(out[2 * n - 1], 1); // direction
});
test('BalanceOfPower reference value', () => {
// (close - open) / (high - low) = (12 - 10) / (14 - 10) = 0.5.
assert.ok(Math.abs(new wickra.BalanceOfPower().update(10, 14, 10, 12) - 0.5) < 1e-9);
});
test('TrueRange reference values', () => {
const tr = new wickra.TrueRange();
assert.equal(tr.update(12, 8, 11), 4); // no prev close -> high - low
assert.equal(tr.update(10, 9, 9.5), 2); // prev close 11 -> max(1, 1, 2)
});
test('LinRegAngle of a unit-slope series is 45 degrees', () => {
const out = new wickra.LinRegAngle(5).batch([1, 2, 3, 4, 5, 6]);
assert.ok(Math.abs(out[4] - 45) < 1e-9);
});
+48 -1
View File
@@ -310,7 +310,7 @@ if (!nativeBinding) {
throw new Error(`Failed to load native binding`)
}
const { version, SMA, EMA, WMA, RSI, DEMA, TEMA, HMA, ROC, TRIX, MACD, BollingerBands, ATR, Stochastic, OBV, ADX, CCI, WilliamsR, MFI, PSAR, Keltner, Donchian, VWAP, AwesomeOscillator, Aroon, KAMA } = nativeBinding
const { version, SMA, EMA, WMA, RSI, DEMA, TEMA, HMA, ROC, TRIX, SMMA, TRIMA, ZLEMA, MOM, CMO, DPO, StdDev, UlcerIndex, VerticalHorizontalFilter, ZScore, MACD, BollingerBands, ATR, Stochastic, OBV, ADX, CCI, WilliamsR, MFI, PSAR, Keltner, Donchian, VWAP, RollingVWAP, AwesomeOscillator, Aroon, KAMA, T3, TSI, PMO, ADL, VolumePriceTrend, ChaikinMoneyFlow, ChaikinOscillator, ForceIndex, EaseOfMovement, SuperTrend, ChandelierExit, ChandeKrollStop, AtrTrailingStop, TypicalPrice, MedianPrice, WeightedClose, LinearRegression, LinRegSlope, AcceleratorOscillator, BalanceOfPower, ChoppinessIndex, TrueRange, ChaikinVolatility, LinRegAngle, BollingerBandwidth, PercentB, NATR, HistoricalVolatility, AroonOscillator, Vortex, MassIndex, StochRSI, UltimateOscillator, PPO, Coppock, VWMA } = nativeBinding
module.exports.version = version
module.exports.SMA = SMA
@@ -322,6 +322,16 @@ module.exports.TEMA = TEMA
module.exports.HMA = HMA
module.exports.ROC = ROC
module.exports.TRIX = TRIX
module.exports.SMMA = SMMA
module.exports.TRIMA = TRIMA
module.exports.ZLEMA = ZLEMA
module.exports.MOM = MOM
module.exports.CMO = CMO
module.exports.DPO = DPO
module.exports.StdDev = StdDev
module.exports.UlcerIndex = UlcerIndex
module.exports.VerticalHorizontalFilter = VerticalHorizontalFilter
module.exports.ZScore = ZScore
module.exports.MACD = MACD
module.exports.BollingerBands = BollingerBands
module.exports.ATR = ATR
@@ -335,6 +345,43 @@ module.exports.PSAR = PSAR
module.exports.Keltner = Keltner
module.exports.Donchian = Donchian
module.exports.VWAP = VWAP
module.exports.RollingVWAP = RollingVWAP
module.exports.AwesomeOscillator = AwesomeOscillator
module.exports.Aroon = Aroon
module.exports.KAMA = KAMA
module.exports.T3 = T3
module.exports.TSI = TSI
module.exports.PMO = PMO
module.exports.ADL = ADL
module.exports.VolumePriceTrend = VolumePriceTrend
module.exports.ChaikinMoneyFlow = ChaikinMoneyFlow
module.exports.ChaikinOscillator = ChaikinOscillator
module.exports.ForceIndex = ForceIndex
module.exports.EaseOfMovement = EaseOfMovement
module.exports.SuperTrend = SuperTrend
module.exports.ChandelierExit = ChandelierExit
module.exports.ChandeKrollStop = ChandeKrollStop
module.exports.AtrTrailingStop = AtrTrailingStop
module.exports.TypicalPrice = TypicalPrice
module.exports.MedianPrice = MedianPrice
module.exports.WeightedClose = WeightedClose
module.exports.LinearRegression = LinearRegression
module.exports.LinRegSlope = LinRegSlope
module.exports.AcceleratorOscillator = AcceleratorOscillator
module.exports.BalanceOfPower = BalanceOfPower
module.exports.ChoppinessIndex = ChoppinessIndex
module.exports.TrueRange = TrueRange
module.exports.ChaikinVolatility = ChaikinVolatility
module.exports.LinRegAngle = LinRegAngle
module.exports.BollingerBandwidth = BollingerBandwidth
module.exports.PercentB = PercentB
module.exports.NATR = NATR
module.exports.HistoricalVolatility = HistoricalVolatility
module.exports.AroonOscillator = AroonOscillator
module.exports.Vortex = Vortex
module.exports.MassIndex = MassIndex
module.exports.StochRSI = StochRSI
module.exports.UltimateOscillator = UltimateOscillator
module.exports.PPO = PPO
module.exports.Coppock = Coppock
module.exports.VWMA = VWMA
+3 -3
View File
@@ -1,14 +1,14 @@
{
"name": "wickra-darwin-arm64",
"version": "0.1.3",
"version": "0.2.6",
"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": [
"wickra.darwin-arm64.node"
],
"license": "SEE LICENSE IN LICENSE",
"license": "PolyForm-Noncommercial-1.0.0",
"engines": {
"node": ">= 16"
"node": ">= 18"
},
"os": [
"darwin"
+3 -3
View File
@@ -1,14 +1,14 @@
{
"name": "wickra-darwin-x64",
"version": "0.1.3",
"version": "0.2.6",
"description": "Native binding for wickra (macOS Intel). Installed automatically as an optional dependency of wickra on matching platforms.",
"main": "wickra.darwin-x64.node",
"files": [
"wickra.darwin-x64.node"
],
"license": "SEE LICENSE IN LICENSE",
"license": "PolyForm-Noncommercial-1.0.0",
"engines": {
"node": ">= 16"
"node": ">= 18"
},
"os": [
"darwin"
@@ -0,0 +1,27 @@
{
"name": "wickra-linux-arm64-gnu",
"version": "0.2.6",
"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": [
"wickra.linux-arm64-gnu.node"
],
"license": "PolyForm-Noncommercial-1.0.0",
"engines": {
"node": ">= 18"
},
"os": [
"linux"
],
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"repository": {
"type": "git",
"url": "https://github.com/kingchenc/wickra"
},
"homepage": "https://github.com/kingchenc/wickra"
}
+3 -3
View File
@@ -1,14 +1,14 @@
{
"name": "wickra-linux-x64-gnu",
"version": "0.1.3",
"version": "0.2.6",
"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": [
"wickra.linux-x64-gnu.node"
],
"license": "SEE LICENSE IN LICENSE",
"license": "PolyForm-Noncommercial-1.0.0",
"engines": {
"node": ">= 16"
"node": ">= 18"
},
"os": [
"linux"
@@ -1,14 +1,14 @@
{
"name": "wickra-win32-x64-msvc",
"version": "0.1.3",
"version": "0.2.6",
"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": [
"wickra.win32-x64-msvc.node"
],
"license": "SEE LICENSE IN LICENSE",
"license": "PolyForm-Noncommercial-1.0.0",
"engines": {
"node": ">= 16"
"node": ">= 18"
},
"os": [
"win32"
+9 -7
View File
@@ -1,11 +1,11 @@
{
"name": "wickra",
"version": "0.1.3",
"version": "0.2.6",
"description": "Streaming-first technical indicators: incremental, fast, install-free. Node bindings powered by Rust.",
"author": "kingchenc <kingchencp@gmail.com>",
"main": "index.js",
"types": "index.d.ts",
"license": "SEE LICENSE IN LICENSE",
"license": "PolyForm-Noncommercial-1.0.0",
"keywords": [
"trading",
"indicators",
@@ -35,6 +35,7 @@
"defaults": false,
"additional": [
"x86_64-unknown-linux-gnu",
"aarch64-unknown-linux-gnu",
"x86_64-apple-darwin",
"aarch64-apple-darwin",
"x86_64-pc-windows-msvc"
@@ -42,13 +43,14 @@
}
},
"engines": {
"node": ">= 16"
"node": ">= 18"
},
"optionalDependencies": {
"wickra-linux-x64-gnu": "0.1.3",
"wickra-darwin-x64": "0.1.3",
"wickra-darwin-arm64": "0.1.3",
"wickra-win32-x64-msvc": "0.1.3"
"wickra-linux-x64-gnu": "0.2.6",
"wickra-linux-arm64-gnu": "0.2.6",
"wickra-darwin-x64": "0.2.6",
"wickra-darwin-arm64": "0.2.6",
"wickra-win32-x64-msvc": "0.2.6"
},
"scripts": {
"build": "napi build --platform --release",
+2673 -413
View File
File diff suppressed because it is too large Load Diff
+285 -33
View File
@@ -1,54 +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
25 streaming-first indicators across four 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:
- **Trend** — SMA, EMA, WMA, DEMA, TEMA, HMA, KAMA
- **Momentum** — RSI (Wilder), MACD, Stochastic, CCI, ROC, WilliamsR, ADX,
MFI, TRIX, AwesomeOscillator, Aroon
- **Volatility** — BollingerBands, ATR, Keltner, Donchian, PSAR
- **Volume** — OBV, VWAP
| Library | Install pain | Streaming | Multi-language | Active |
|------------------------|-----------------|-----------|----------------|--------|
| **★&nbsp;Wickra** | **clean** | **yes** | **Python + Node + WASM + Rust** | **yes** |
| 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 |
## 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 | **★&nbsp;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 | **★&nbsp;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>
+3 -1
View File
@@ -4,10 +4,11 @@ build-backend = "maturin"
[project]
name = "wickra"
version = "0.1.3"
version = "0.2.6"
description = "Streaming-first technical indicators: incremental, fast, install-free."
readme = "README.md"
license = { text = "PolyForm-Noncommercial-1.0.0" }
authors = [{ name = "kingchenc", email = "kingchencp@gmail.com" }]
requires-python = ">=3.9"
keywords = ["finance", "trading", "indicators", "technical-analysis", "ta-lib"]
classifiers = [
@@ -20,6 +21,7 @@ classifiers = [
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Rust",
"Topic :: Office/Business :: Financial :: Investment",
"Topic :: Scientific/Engineering :: Mathematics",
+133 -29
View File
@@ -25,58 +25,162 @@ from __future__ import annotations
from ._wickra import (
__version__,
ADX,
ATR,
Aroon,
AwesomeOscillator,
BollingerBands,
CCI,
DEMA,
Donchian,
# Trend
SMA,
EMA,
WMA,
DEMA,
TEMA,
HMA,
KAMA,
Keltner,
MACD,
MFI,
OBV,
PSAR,
ROC,
SMMA,
TRIMA,
ZLEMA,
T3,
VWMA,
# Momentum
RSI,
SMA,
MACD,
Stochastic,
TEMA,
TRIX,
VWAP,
CCI,
ROC,
WilliamsR,
WMA,
ADX,
MFI,
TRIX,
AwesomeOscillator,
Aroon,
MOM,
CMO,
TSI,
PMO,
StochRSI,
UltimateOscillator,
PPO,
DPO,
Coppock,
AroonOscillator,
Vortex,
MassIndex,
AcceleratorOscillator,
BalanceOfPower,
ChoppinessIndex,
VerticalHorizontalFilter,
# Volatility
BollingerBands,
ATR,
Keltner,
Donchian,
PSAR,
NATR,
StdDev,
UlcerIndex,
HistoricalVolatility,
BollingerBandwidth,
PercentB,
SuperTrend,
ChandelierExit,
ChandeKrollStop,
AtrTrailingStop,
TrueRange,
ChaikinVolatility,
# Volume
OBV,
VWAP,
RollingVWAP,
ADL,
VolumePriceTrend,
ChaikinMoneyFlow,
ChaikinOscillator,
ForceIndex,
EaseOfMovement,
# Statistics
TypicalPrice,
MedianPrice,
WeightedClose,
LinearRegression,
LinRegSlope,
ZScore,
LinRegAngle,
)
__all__ = [
"__version__",
# Trend
"SMA",
"EMA",
"WMA",
"RSI",
"MACD",
"BollingerBands",
"ATR",
"Stochastic",
"OBV",
"DEMA",
"TEMA",
"HMA",
"KAMA",
"SMMA",
"TRIMA",
"ZLEMA",
"T3",
"VWMA",
# Momentum
"RSI",
"MACD",
"Stochastic",
"CCI",
"ROC",
"WilliamsR",
"ADX",
"MFI",
"TRIX",
"PSAR",
"Keltner",
"Donchian",
"VWAP",
"AwesomeOscillator",
"Aroon",
"MOM",
"CMO",
"TSI",
"PMO",
"StochRSI",
"UltimateOscillator",
"PPO",
"DPO",
"Coppock",
"AroonOscillator",
"Vortex",
"MassIndex",
"AcceleratorOscillator",
"BalanceOfPower",
"ChoppinessIndex",
"VerticalHorizontalFilter",
# Volatility
"BollingerBands",
"ATR",
"Keltner",
"Donchian",
"PSAR",
"NATR",
"StdDev",
"UlcerIndex",
"HistoricalVolatility",
"BollingerBandwidth",
"PercentB",
"SuperTrend",
"ChandelierExit",
"ChandeKrollStop",
"AtrTrailingStop",
"TrueRange",
"ChaikinVolatility",
# Volume
"OBV",
"VWAP",
"RollingVWAP",
"ADL",
"VolumePriceTrend",
"ChaikinMoneyFlow",
"ChaikinOscillator",
"ForceIndex",
"EaseOfMovement",
# Statistics
"TypicalPrice",
"MedianPrice",
"WeightedClose",
"LinearRegression",
"LinRegSlope",
"ZScore",
"LinRegAngle",
]
+851
View File
@@ -52,6 +52,642 @@ class WMA:
@property
def value(self) -> Optional[float]: ...
class SMMA:
def __init__(self, period: int) -> None: ...
def update(self, value: float) -> Optional[float]: ...
def batch(self, prices: NDArray[np.float64]) -> NDArray[np.float64]: ...
def reset(self) -> None: ...
def is_ready(self) -> bool: ...
def warmup_period(self) -> int: ...
@property
def period(self) -> int: ...
@property
def value(self) -> Optional[float]: ...
class TRIMA:
def __init__(self, period: int) -> None: ...
def update(self, value: float) -> Optional[float]: ...
def batch(self, prices: NDArray[np.float64]) -> NDArray[np.float64]: ...
def reset(self) -> None: ...
def is_ready(self) -> bool: ...
def warmup_period(self) -> int: ...
@property
def period(self) -> int: ...
@property
def value(self) -> Optional[float]: ...
class ADL:
def __init__(self) -> None: ...
def update(self, candle: CandleLike) -> Optional[float]: ...
def batch(
self,
high: NDArray[np.float64],
low: NDArray[np.float64],
close: NDArray[np.float64],
volume: NDArray[np.float64],
) -> NDArray[np.float64]: ...
def reset(self) -> None: ...
def is_ready(self) -> bool: ...
def warmup_period(self) -> int: ...
@property
def value(self) -> Optional[float]: ...
class VolumePriceTrend:
def __init__(self) -> None: ...
def update(self, candle: CandleLike) -> Optional[float]: ...
def batch(
self,
close: NDArray[np.float64],
volume: NDArray[np.float64],
) -> NDArray[np.float64]: ...
def reset(self) -> None: ...
def is_ready(self) -> bool: ...
def warmup_period(self) -> int: ...
@property
def value(self) -> Optional[float]: ...
class ChaikinMoneyFlow:
def __init__(self, period: int = 20) -> None: ...
def update(self, candle: CandleLike) -> Optional[float]: ...
def batch(
self,
high: NDArray[np.float64],
low: NDArray[np.float64],
close: NDArray[np.float64],
volume: NDArray[np.float64],
) -> NDArray[np.float64]: ...
def reset(self) -> None: ...
def is_ready(self) -> bool: ...
def warmup_period(self) -> int: ...
@property
def period(self) -> int: ...
class ChaikinOscillator:
def __init__(self, fast: int = 3, slow: int = 10) -> None: ...
def update(self, candle: CandleLike) -> Optional[float]: ...
def batch(
self,
high: NDArray[np.float64],
low: NDArray[np.float64],
close: NDArray[np.float64],
volume: NDArray[np.float64],
) -> NDArray[np.float64]: ...
def reset(self) -> None: ...
def is_ready(self) -> bool: ...
def warmup_period(self) -> int: ...
@property
def periods(self) -> Tuple[int, int]: ...
class ForceIndex:
def __init__(self, period: int = 13) -> None: ...
def update(self, candle: CandleLike) -> Optional[float]: ...
def batch(
self,
close: NDArray[np.float64],
volume: NDArray[np.float64],
) -> NDArray[np.float64]: ...
def reset(self) -> None: ...
def is_ready(self) -> bool: ...
def warmup_period(self) -> int: ...
@property
def period(self) -> int: ...
class EaseOfMovement:
def __init__(self, period: int = 14, divisor: float = 100000000.0) -> None: ...
def update(self, candle: CandleLike) -> Optional[float]: ...
def batch(
self,
high: NDArray[np.float64],
low: NDArray[np.float64],
volume: NDArray[np.float64],
) -> NDArray[np.float64]: ...
def reset(self) -> None: ...
def is_ready(self) -> bool: ...
def warmup_period(self) -> int: ...
@property
def period(self) -> int: ...
@property
def divisor(self) -> float: ...
class SuperTrend:
def __init__(self, atr_period: int = 10, multiplier: float = 3.0) -> None: ...
def update(self, candle: CandleLike) -> Optional[Tuple[float, float]]: ...
def batch(
self,
high: NDArray[np.float64],
low: NDArray[np.float64],
close: NDArray[np.float64],
) -> NDArray[np.float64]:
"""Returns shape ``(n, 2)`` with columns ``[value, direction]``."""
...
def reset(self) -> None: ...
def is_ready(self) -> bool: ...
def warmup_period(self) -> int: ...
@property
def params(self) -> Tuple[int, float]: ...
class ChandelierExit:
def __init__(self, period: int = 22, multiplier: float = 3.0) -> None: ...
def update(self, candle: CandleLike) -> Optional[Tuple[float, float]]: ...
def batch(
self,
high: NDArray[np.float64],
low: NDArray[np.float64],
close: NDArray[np.float64],
) -> NDArray[np.float64]:
"""Returns shape ``(n, 2)`` with columns ``[long_stop, short_stop]``."""
...
def reset(self) -> None: ...
def is_ready(self) -> bool: ...
def warmup_period(self) -> int: ...
@property
def params(self) -> Tuple[int, float]: ...
class ChandeKrollStop:
def __init__(
self, atr_period: int = 10, atr_multiplier: float = 1.0, stop_period: int = 9
) -> None: ...
def update(self, candle: CandleLike) -> Optional[Tuple[float, float]]: ...
def batch(
self,
high: NDArray[np.float64],
low: NDArray[np.float64],
close: NDArray[np.float64],
) -> NDArray[np.float64]:
"""Returns shape ``(n, 2)`` with columns ``[stop_long, stop_short]``."""
...
def reset(self) -> None: ...
def is_ready(self) -> bool: ...
def warmup_period(self) -> int: ...
@property
def params(self) -> Tuple[int, float, int]: ...
class AtrTrailingStop:
def __init__(self, atr_period: int = 14, multiplier: float = 3.0) -> None: ...
def update(self, candle: CandleLike) -> Optional[float]: ...
def batch(
self,
high: NDArray[np.float64],
low: NDArray[np.float64],
close: NDArray[np.float64],
) -> NDArray[np.float64]: ...
def reset(self) -> None: ...
def is_ready(self) -> bool: ...
def warmup_period(self) -> int: ...
@property
def params(self) -> Tuple[int, float]: ...
class TypicalPrice:
def __init__(self) -> None: ...
def update(self, candle: CandleLike) -> Optional[float]: ...
def batch(
self,
high: NDArray[np.float64],
low: NDArray[np.float64],
close: NDArray[np.float64],
) -> NDArray[np.float64]: ...
def reset(self) -> None: ...
def is_ready(self) -> bool: ...
def warmup_period(self) -> int: ...
class MedianPrice:
def __init__(self) -> None: ...
def update(self, candle: CandleLike) -> Optional[float]: ...
def batch(
self,
high: NDArray[np.float64],
low: NDArray[np.float64],
) -> NDArray[np.float64]: ...
def reset(self) -> None: ...
def is_ready(self) -> bool: ...
def warmup_period(self) -> int: ...
class WeightedClose:
def __init__(self) -> None: ...
def update(self, candle: CandleLike) -> Optional[float]: ...
def batch(
self,
high: NDArray[np.float64],
low: NDArray[np.float64],
close: NDArray[np.float64],
) -> NDArray[np.float64]: ...
def reset(self) -> None: ...
def is_ready(self) -> bool: ...
def warmup_period(self) -> int: ...
class LinearRegression:
def __init__(self, period: int = 14) -> None: ...
def update(self, value: float) -> Optional[float]: ...
def batch(self, prices: NDArray[np.float64]) -> NDArray[np.float64]: ...
def reset(self) -> None: ...
def is_ready(self) -> bool: ...
def warmup_period(self) -> int: ...
@property
def period(self) -> int: ...
class LinRegSlope:
def __init__(self, period: int = 14) -> None: ...
def update(self, value: float) -> Optional[float]: ...
def batch(self, prices: NDArray[np.float64]) -> NDArray[np.float64]: ...
def reset(self) -> None: ...
def is_ready(self) -> bool: ...
def warmup_period(self) -> int: ...
@property
def period(self) -> int: ...
class AcceleratorOscillator:
def __init__(
self, ao_fast: int = 5, ao_slow: int = 34, signal_period: int = 5
) -> None: ...
def update(self, candle: CandleLike) -> Optional[float]: ...
def batch(
self,
high: NDArray[np.float64],
low: NDArray[np.float64],
) -> NDArray[np.float64]: ...
def reset(self) -> None: ...
def is_ready(self) -> bool: ...
def warmup_period(self) -> int: ...
@property
def params(self) -> Tuple[int, int, int]: ...
class BalanceOfPower:
def __init__(self) -> None: ...
def update(self, candle: CandleLike) -> Optional[float]: ...
def batch(
self,
open: NDArray[np.float64],
high: NDArray[np.float64],
low: NDArray[np.float64],
close: NDArray[np.float64],
) -> NDArray[np.float64]: ...
def reset(self) -> None: ...
def is_ready(self) -> bool: ...
def warmup_period(self) -> int: ...
class ChoppinessIndex:
def __init__(self, period: int = 14) -> None: ...
def update(self, candle: CandleLike) -> Optional[float]: ...
def batch(
self,
high: NDArray[np.float64],
low: NDArray[np.float64],
close: NDArray[np.float64],
) -> NDArray[np.float64]: ...
def reset(self) -> None: ...
def is_ready(self) -> bool: ...
def warmup_period(self) -> int: ...
@property
def period(self) -> int: ...
class VerticalHorizontalFilter:
def __init__(self, period: int = 28) -> None: ...
def update(self, value: float) -> Optional[float]: ...
def batch(self, prices: NDArray[np.float64]) -> NDArray[np.float64]: ...
def reset(self) -> None: ...
def is_ready(self) -> bool: ...
def warmup_period(self) -> int: ...
@property
def period(self) -> int: ...
class TrueRange:
def __init__(self) -> None: ...
def update(self, candle: CandleLike) -> Optional[float]: ...
def batch(
self,
high: NDArray[np.float64],
low: NDArray[np.float64],
close: NDArray[np.float64],
) -> NDArray[np.float64]: ...
def reset(self) -> None: ...
def is_ready(self) -> bool: ...
def warmup_period(self) -> int: ...
class ChaikinVolatility:
def __init__(self, ema_period: int = 10, roc_period: int = 10) -> None: ...
def update(self, candle: CandleLike) -> Optional[float]: ...
def batch(
self,
high: NDArray[np.float64],
low: NDArray[np.float64],
) -> NDArray[np.float64]: ...
def reset(self) -> None: ...
def is_ready(self) -> bool: ...
def warmup_period(self) -> int: ...
@property
def periods(self) -> Tuple[int, int]: ...
class ZScore:
def __init__(self, period: int = 20) -> None: ...
def update(self, value: float) -> Optional[float]: ...
def batch(self, prices: NDArray[np.float64]) -> NDArray[np.float64]: ...
def reset(self) -> None: ...
def is_ready(self) -> bool: ...
def warmup_period(self) -> int: ...
@property
def period(self) -> int: ...
class LinRegAngle:
def __init__(self, period: int = 14) -> None: ...
def update(self, value: float) -> Optional[float]: ...
def batch(self, prices: NDArray[np.float64]) -> NDArray[np.float64]: ...
def reset(self) -> None: ...
def is_ready(self) -> bool: ...
def warmup_period(self) -> int: ...
@property
def period(self) -> int: ...
class BollingerBandwidth:
def __init__(self, period: int = 20, multiplier: float = 2.0) -> None: ...
def update(self, value: float) -> Optional[float]: ...
def batch(self, prices: NDArray[np.float64]) -> NDArray[np.float64]: ...
def reset(self) -> None: ...
def is_ready(self) -> bool: ...
def warmup_period(self) -> int: ...
@property
def period(self) -> int: ...
@property
def multiplier(self) -> float: ...
@property
def value(self) -> Optional[float]: ...
class PercentB:
def __init__(self, period: int = 20, multiplier: float = 2.0) -> None: ...
def update(self, value: float) -> Optional[float]: ...
def batch(self, prices: NDArray[np.float64]) -> NDArray[np.float64]: ...
def reset(self) -> None: ...
def is_ready(self) -> bool: ...
def warmup_period(self) -> int: ...
@property
def period(self) -> int: ...
@property
def multiplier(self) -> float: ...
@property
def value(self) -> Optional[float]: ...
class NATR:
def __init__(self, period: int = 14) -> None: ...
def update(self, candle: CandleLike) -> Optional[float]: ...
def batch(
self,
high: NDArray[np.float64],
low: NDArray[np.float64],
close: NDArray[np.float64],
) -> NDArray[np.float64]: ...
def reset(self) -> None: ...
def is_ready(self) -> bool: ...
def warmup_period(self) -> int: ...
@property
def period(self) -> int: ...
@property
def value(self) -> Optional[float]: ...
class StdDev:
def __init__(self, period: int = 20) -> None: ...
def update(self, value: float) -> Optional[float]: ...
def batch(self, prices: NDArray[np.float64]) -> NDArray[np.float64]: ...
def reset(self) -> None: ...
def is_ready(self) -> bool: ...
def warmup_period(self) -> int: ...
@property
def period(self) -> int: ...
@property
def value(self) -> Optional[float]: ...
class UlcerIndex:
def __init__(self, period: int = 14) -> None: ...
def update(self, value: float) -> Optional[float]: ...
def batch(self, prices: NDArray[np.float64]) -> NDArray[np.float64]: ...
def reset(self) -> None: ...
def is_ready(self) -> bool: ...
def warmup_period(self) -> int: ...
@property
def period(self) -> int: ...
@property
def value(self) -> Optional[float]: ...
class HistoricalVolatility:
def __init__(self, period: int = 20, trading_periods: int = 252) -> None: ...
def update(self, value: float) -> Optional[float]: ...
def batch(self, prices: NDArray[np.float64]) -> NDArray[np.float64]: ...
def reset(self) -> None: ...
def is_ready(self) -> bool: ...
def warmup_period(self) -> int: ...
@property
def periods(self) -> Tuple[int, int]: ...
@property
def value(self) -> Optional[float]: ...
class AroonOscillator:
def __init__(self, period: int = 14) -> None: ...
def update(self, candle: CandleLike) -> Optional[float]: ...
def batch(
self,
high: NDArray[np.float64],
low: NDArray[np.float64],
) -> NDArray[np.float64]: ...
def reset(self) -> None: ...
def is_ready(self) -> bool: ...
def warmup_period(self) -> int: ...
@property
def period(self) -> int: ...
@property
def value(self) -> Optional[float]: ...
class Vortex:
def __init__(self, period: int = 14) -> None: ...
def update(self, candle: CandleLike) -> Optional[Tuple[float, float]]: ...
def batch(
self,
high: NDArray[np.float64],
low: NDArray[np.float64],
close: NDArray[np.float64],
) -> NDArray[np.float64]:
"""Returns shape ``(n, 2)`` with columns ``[plus, minus]``."""
...
def reset(self) -> None: ...
def is_ready(self) -> bool: ...
def warmup_period(self) -> int: ...
@property
def period(self) -> int: ...
class MassIndex:
def __init__(self, ema_period: int = 9, sum_period: int = 25) -> None: ...
def update(self, candle: CandleLike) -> Optional[float]: ...
def batch(
self,
high: NDArray[np.float64],
low: NDArray[np.float64],
) -> NDArray[np.float64]: ...
def reset(self) -> None: ...
def is_ready(self) -> bool: ...
def warmup_period(self) -> int: ...
@property
def periods(self) -> Tuple[int, int]: ...
@property
def value(self) -> Optional[float]: ...
class PPO:
def __init__(self, fast: int = 12, slow: int = 26) -> None: ...
def update(self, value: float) -> Optional[float]: ...
def batch(self, prices: NDArray[np.float64]) -> NDArray[np.float64]: ...
def reset(self) -> None: ...
def is_ready(self) -> bool: ...
def warmup_period(self) -> int: ...
@property
def periods(self) -> Tuple[int, int]: ...
@property
def value(self) -> Optional[float]: ...
class DPO:
def __init__(self, period: int = 20) -> None: ...
def update(self, value: float) -> Optional[float]: ...
def batch(self, prices: NDArray[np.float64]) -> NDArray[np.float64]: ...
def reset(self) -> None: ...
def is_ready(self) -> bool: ...
def warmup_period(self) -> int: ...
@property
def period(self) -> int: ...
@property
def shift(self) -> int: ...
@property
def value(self) -> Optional[float]: ...
class Coppock:
def __init__(
self, roc_long: int = 14, roc_short: int = 11, wma_period: int = 10
) -> None: ...
def update(self, value: float) -> Optional[float]: ...
def batch(self, prices: NDArray[np.float64]) -> NDArray[np.float64]: ...
def reset(self) -> None: ...
def is_ready(self) -> bool: ...
def warmup_period(self) -> int: ...
@property
def periods(self) -> Tuple[int, int, int]: ...
@property
def value(self) -> Optional[float]: ...
class StochRSI:
def __init__(self, rsi_period: int = 14, stoch_period: int = 14) -> None: ...
def update(self, value: float) -> Optional[float]: ...
def batch(self, prices: NDArray[np.float64]) -> NDArray[np.float64]: ...
def reset(self) -> None: ...
def is_ready(self) -> bool: ...
def warmup_period(self) -> int: ...
@property
def periods(self) -> Tuple[int, int]: ...
@property
def value(self) -> Optional[float]: ...
class UltimateOscillator:
def __init__(self, short: int = 7, mid: int = 14, long: int = 28) -> None: ...
def update(self, candle: CandleLike) -> Optional[float]: ...
def batch(
self,
high: NDArray[np.float64],
low: NDArray[np.float64],
close: NDArray[np.float64],
) -> NDArray[np.float64]: ...
def reset(self) -> None: ...
def is_ready(self) -> bool: ...
def warmup_period(self) -> int: ...
@property
def periods(self) -> Tuple[int, int, int]: ...
@property
def value(self) -> Optional[float]: ...
class MOM:
def __init__(self, period: int = 10) -> None: ...
def update(self, value: float) -> Optional[float]: ...
def batch(self, prices: NDArray[np.float64]) -> NDArray[np.float64]: ...
def reset(self) -> None: ...
def is_ready(self) -> bool: ...
def warmup_period(self) -> int: ...
@property
def period(self) -> int: ...
@property
def value(self) -> Optional[float]: ...
class CMO:
def __init__(self, period: int = 14) -> None: ...
def update(self, value: float) -> Optional[float]: ...
def batch(self, prices: NDArray[np.float64]) -> NDArray[np.float64]: ...
def reset(self) -> None: ...
def is_ready(self) -> bool: ...
def warmup_period(self) -> int: ...
@property
def period(self) -> int: ...
@property
def value(self) -> Optional[float]: ...
class TSI:
def __init__(self, long: int = 25, short: int = 13) -> None: ...
def update(self, value: float) -> Optional[float]: ...
def batch(self, prices: NDArray[np.float64]) -> NDArray[np.float64]: ...
def reset(self) -> None: ...
def is_ready(self) -> bool: ...
def warmup_period(self) -> int: ...
@property
def periods(self) -> Tuple[int, int]: ...
@property
def value(self) -> Optional[float]: ...
class PMO:
def __init__(self, smoothing1: int = 35, smoothing2: int = 20) -> None: ...
def update(self, value: float) -> Optional[float]: ...
def batch(self, prices: NDArray[np.float64]) -> NDArray[np.float64]: ...
def reset(self) -> None: ...
def is_ready(self) -> bool: ...
def warmup_period(self) -> int: ...
@property
def periods(self) -> Tuple[int, int]: ...
@property
def value(self) -> Optional[float]: ...
class ZLEMA:
def __init__(self, period: int) -> None: ...
def update(self, value: float) -> Optional[float]: ...
def batch(self, prices: NDArray[np.float64]) -> NDArray[np.float64]: ...
def reset(self) -> None: ...
def is_ready(self) -> bool: ...
def warmup_period(self) -> int: ...
@property
def period(self) -> int: ...
@property
def lag(self) -> int: ...
@property
def value(self) -> Optional[float]: ...
class T3:
def __init__(self, period: int, v: float = 0.7) -> None: ...
def update(self, value: float) -> Optional[float]: ...
def batch(self, prices: NDArray[np.float64]) -> NDArray[np.float64]: ...
def reset(self) -> None: ...
def is_ready(self) -> bool: ...
def warmup_period(self) -> int: ...
@property
def period(self) -> int: ...
@property
def volume_factor(self) -> float: ...
@property
def value(self) -> Optional[float]: ...
class VWMA:
def __init__(self, period: int) -> None: ...
def update(self, candle: CandleLike) -> Optional[float]: ...
def batch(
self,
close: NDArray[np.float64],
volume: NDArray[np.float64],
) -> NDArray[np.float64]: ...
def reset(self) -> None: ...
def is_ready(self) -> bool: ...
def warmup_period(self) -> int: ...
@property
def period(self) -> int: ...
@property
def value(self) -> Optional[float]: ...
class RSI:
def __init__(self, period: int = 14) -> None: ...
def update(self, value: float) -> Optional[float]: ...
@@ -135,3 +771,218 @@ class OBV:
def warmup_period(self) -> int: ...
@property
def value(self) -> Optional[float]: ...
class DEMA:
def __init__(self, period: int) -> None: ...
def update(self, value: float) -> Optional[float]: ...
def batch(self, prices: NDArray[np.float64]) -> NDArray[np.float64]: ...
def reset(self) -> None: ...
def is_ready(self) -> bool: ...
def warmup_period(self) -> int: ...
@property
def period(self) -> int: ...
class TEMA:
def __init__(self, period: int) -> None: ...
def update(self, value: float) -> Optional[float]: ...
def batch(self, prices: NDArray[np.float64]) -> NDArray[np.float64]: ...
def reset(self) -> None: ...
def is_ready(self) -> bool: ...
def warmup_period(self) -> int: ...
@property
def period(self) -> int: ...
class HMA:
def __init__(self, period: int) -> None: ...
def update(self, value: float) -> Optional[float]: ...
def batch(self, prices: NDArray[np.float64]) -> NDArray[np.float64]: ...
def reset(self) -> None: ...
def is_ready(self) -> bool: ...
def warmup_period(self) -> int: ...
@property
def period(self) -> int: ...
class KAMA:
def __init__(self, er_period: int = 10, fast: int = 2, slow: int = 30) -> None: ...
def update(self, value: float) -> Optional[float]: ...
def batch(self, prices: NDArray[np.float64]) -> NDArray[np.float64]: ...
def reset(self) -> None: ...
def is_ready(self) -> bool: ...
def warmup_period(self) -> int: ...
class CCI:
def __init__(self, period: int = 20) -> None: ...
def update(self, candle: CandleLike) -> Optional[float]: ...
def batch(
self,
high: NDArray[np.float64],
low: NDArray[np.float64],
close: NDArray[np.float64],
) -> NDArray[np.float64]: ...
def reset(self) -> None: ...
def is_ready(self) -> bool: ...
def warmup_period(self) -> int: ...
@property
def period(self) -> int: ...
class ROC:
def __init__(self, period: int = 10) -> None: ...
def update(self, value: float) -> Optional[float]: ...
def batch(self, prices: NDArray[np.float64]) -> NDArray[np.float64]: ...
def reset(self) -> None: ...
def is_ready(self) -> bool: ...
def warmup_period(self) -> int: ...
@property
def period(self) -> int: ...
class WilliamsR:
def __init__(self, period: int = 14) -> None: ...
def update(self, candle: CandleLike) -> Optional[float]: ...
def batch(
self,
high: NDArray[np.float64],
low: NDArray[np.float64],
close: NDArray[np.float64],
) -> NDArray[np.float64]: ...
def reset(self) -> None: ...
def is_ready(self) -> bool: ...
def warmup_period(self) -> int: ...
class ADX:
def __init__(self, period: int = 14) -> None: ...
def update(self, candle: CandleLike) -> Optional[Tuple[float, float, float]]: ...
def batch(
self,
high: NDArray[np.float64],
low: NDArray[np.float64],
close: NDArray[np.float64],
) -> NDArray[np.float64]:
"""Returns shape ``(n, 3)`` with columns ``[plus_di, minus_di, adx]``."""
...
def reset(self) -> None: ...
def is_ready(self) -> bool: ...
def warmup_period(self) -> int: ...
class MFI:
def __init__(self, period: int = 14) -> None: ...
def update(self, candle: CandleLike) -> Optional[float]: ...
def batch(
self,
high: NDArray[np.float64],
low: NDArray[np.float64],
close: NDArray[np.float64],
volume: NDArray[np.float64],
) -> NDArray[np.float64]: ...
def reset(self) -> None: ...
def is_ready(self) -> bool: ...
def warmup_period(self) -> int: ...
class TRIX:
def __init__(self, period: int = 30) -> None: ...
def update(self, value: float) -> Optional[float]: ...
def batch(self, prices: NDArray[np.float64]) -> NDArray[np.float64]: ...
def reset(self) -> None: ...
def is_ready(self) -> bool: ...
def warmup_period(self) -> int: ...
class PSAR:
def __init__(
self, af_start: float = 0.02, af_step: float = 0.02, af_max: float = 0.20
) -> None: ...
def update(self, candle: CandleLike) -> Optional[float]: ...
def batch(
self,
high: NDArray[np.float64],
low: NDArray[np.float64],
close: NDArray[np.float64],
) -> NDArray[np.float64]: ...
def reset(self) -> None: ...
def is_ready(self) -> bool: ...
def warmup_period(self) -> int: ...
class Keltner:
def __init__(
self, ema_period: int = 20, atr_period: int = 10, multiplier: float = 2.0
) -> None: ...
def update(self, candle: CandleLike) -> Optional[Tuple[float, float, float]]: ...
def batch(
self,
high: NDArray[np.float64],
low: NDArray[np.float64],
close: NDArray[np.float64],
) -> NDArray[np.float64]:
"""Returns shape ``(n, 3)`` with columns ``[upper, middle, lower]``."""
...
def reset(self) -> None: ...
def is_ready(self) -> bool: ...
def warmup_period(self) -> int: ...
class Donchian:
def __init__(self, period: int = 20) -> None: ...
def update(self, candle: CandleLike) -> Optional[Tuple[float, float, float]]: ...
def batch(
self,
high: NDArray[np.float64],
low: NDArray[np.float64],
) -> NDArray[np.float64]:
"""Returns shape ``(n, 3)`` with columns ``[upper, middle, lower]``."""
...
def reset(self) -> None: ...
def is_ready(self) -> bool: ...
def warmup_period(self) -> int: ...
class VWAP:
def __init__(self) -> None: ...
def update(self, candle: CandleLike) -> Optional[float]: ...
def batch(
self,
high: NDArray[np.float64],
low: NDArray[np.float64],
close: NDArray[np.float64],
volume: NDArray[np.float64],
) -> NDArray[np.float64]: ...
def reset(self) -> None: ...
def is_ready(self) -> bool: ...
def warmup_period(self) -> int: ...
class RollingVWAP:
def __init__(self, period: int) -> None: ...
def update(self, candle: CandleLike) -> Optional[float]: ...
def batch(
self,
high: NDArray[np.float64],
low: NDArray[np.float64],
close: NDArray[np.float64],
volume: NDArray[np.float64],
) -> NDArray[np.float64]: ...
@property
def period(self) -> int: ...
def reset(self) -> None: ...
def is_ready(self) -> bool: ...
def warmup_period(self) -> int: ...
class AwesomeOscillator:
def __init__(self, fast: int = 5, slow: int = 34) -> None: ...
def update(self, candle: CandleLike) -> Optional[float]: ...
def batch(
self,
high: NDArray[np.float64],
low: NDArray[np.float64],
) -> NDArray[np.float64]: ...
def reset(self) -> None: ...
def is_ready(self) -> bool: ...
def warmup_period(self) -> int: ...
class Aroon:
def __init__(self, period: int = 14) -> None: ...
def update(self, candle: CandleLike) -> Optional[Tuple[float, float]]: ...
def batch(
self,
high: NDArray[np.float64],
low: NDArray[np.float64],
) -> NDArray[np.float64]:
"""Returns shape ``(n, 2)`` with columns ``[up, down]``."""
...
def reset(self) -> None: ...
def is_ready(self) -> bool: ...
def warmup_period(self) -> int: ...
+3261 -118
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,41 @@
"""Input-validation tests: malformed NumPy inputs raise ValueError, not panics."""
from __future__ import annotations
import numpy as np
import pytest
import wickra as ta
def test_non_contiguous_array_raises_value_error():
# A strided view is not C-contiguous; batch() must reject it cleanly.
base = np.linspace(1.0, 100.0, 60)
non_contiguous = base[::2]
assert not non_contiguous.flags["C_CONTIGUOUS"]
with pytest.raises(ValueError):
ta.SMA(5).batch(non_contiguous)
def test_ascontiguousarray_recovers():
base = np.linspace(1.0, 100.0, 60)
fixed = np.ascontiguousarray(base[::2])
out = ta.SMA(5).batch(fixed)
assert out.shape == fixed.shape
def test_unequal_length_candle_batch_raises(ohlc_series):
high, low, close = ohlc_series
short = low[:-1]
with pytest.raises(ValueError):
ta.ATR(14).batch(high, short, close)
with pytest.raises(ValueError):
ta.WilliamsR(14).batch(high, short, close)
with pytest.raises(ValueError):
ta.Aroon(14).batch(high, short)
def test_roc_and_trix_have_default_periods():
# ROC/TRIX gained constructor defaults matching the TA-Lib convention.
assert ta.ROC().period == 10
assert ta.TRIX() is not None
@@ -0,0 +1,303 @@
"""Streaming-vs-batch, shape and reference-value tests for the F1-F12 families.
Every indicator added since the original 25 is exercised here. The central
contract is the same as the rest of the suite: ``batch(...)`` must equal
repeated streaming ``update(...)`` across the whole warmup -> steady-state
transition, and batch shapes must match the input length.
"""
from __future__ import annotations
import math
import numpy as np
import pytest
import wickra as ta
def _eq_nan(a: np.ndarray, b: np.ndarray, tol: float = 1e-9) -> bool:
"""Compare two float arrays treating NaN positions as equal."""
a = np.asarray(a, dtype=np.float64)
b = np.asarray(b, dtype=np.float64)
if a.shape != b.shape:
return False
both_nan = np.isnan(a) & np.isnan(b)
return bool(np.all(np.where(both_nan, 0.0, np.abs(a - b)) <= tol))
@pytest.fixture
def ohlcv() -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
"""Synthetic high / low / close / volume series, 200 bars."""
t = np.arange(200, dtype=np.float64)
close = 100.0 + np.sin(t * 0.15) * 8.0 + np.cos(t * 0.32) * 3.0
spread = 0.5 + np.abs(np.sin(t * 0.07))
high = close + spread
low = close - spread
volume = 1000.0 + (t % 7) * 50.0
return high, low, close, volume
# --- Scalar (f64 -> f64) indicators ---------------------------------------
SCALAR = [
(ta.SMMA, (14,)),
(ta.TRIMA, (20,)),
(ta.ZLEMA, (14,)),
(ta.T3, (5, 0.7)),
(ta.MOM, (10,)),
(ta.CMO, (14,)),
(ta.TSI, (25, 13)),
(ta.PMO, (35, 20)),
(ta.StochRSI, (14, 14)),
(ta.PPO, (12, 26)),
(ta.DPO, (20,)),
(ta.Coppock, (14, 11, 10)),
(ta.StdDev, (20,)),
(ta.UlcerIndex, (14,)),
(ta.HistoricalVolatility, (20, 252)),
(ta.BollingerBandwidth, (20, 2.0)),
(ta.PercentB, (20, 2.0)),
(ta.LinearRegression, (14,)),
(ta.LinRegSlope, (14,)),
(ta.VerticalHorizontalFilter, (28,)),
(ta.ZScore, (20,)),
(ta.LinRegAngle, (14,)),
]
@pytest.mark.parametrize("cls, args", SCALAR, ids=[c.__name__ for c, _ in SCALAR])
def test_scalar_streaming_matches_batch(cls, args, sine_prices):
batch = cls(*args).batch(sine_prices)
assert batch.shape == sine_prices.shape
assert batch.dtype == np.float64
streamer = cls(*args)
streamed = []
for p in sine_prices:
v = streamer.update(float(p))
streamed.append(math.nan if v is None else float(v))
assert _eq_nan(batch, np.array(streamed, dtype=np.float64))
# --- Candle-input, single-output indicators -------------------------------
#
# Each entry is (factory, batch-call). Streaming always feeds the full
# 6-tuple candle; the batch helper takes only the columns it needs.
CANDLE_SCALAR = {
"VWMA": (lambda: ta.VWMA(20), lambda ind, h, l, c, v: ind.batch(c, v)),
"UltimateOscillator": (
lambda: ta.UltimateOscillator(7, 14, 28),
lambda ind, h, l, c, v: ind.batch(h, l, c),
),
"AroonOscillator": (
lambda: ta.AroonOscillator(14),
lambda ind, h, l, c, v: ind.batch(h, l),
),
"NATR": (lambda: ta.NATR(14), lambda ind, h, l, c, v: ind.batch(h, l, c)),
"MassIndex": (lambda: ta.MassIndex(9, 25), lambda ind, h, l, c, v: ind.batch(h, l)),
"ADL": (lambda: ta.ADL(), lambda ind, h, l, c, v: ind.batch(h, l, c, v)),
"VolumePriceTrend": (
lambda: ta.VolumePriceTrend(),
lambda ind, h, l, c, v: ind.batch(c, v),
),
"ChaikinMoneyFlow": (
lambda: ta.ChaikinMoneyFlow(20),
lambda ind, h, l, c, v: ind.batch(h, l, c, v),
),
"ChaikinOscillator": (
lambda: ta.ChaikinOscillator(3, 10),
lambda ind, h, l, c, v: ind.batch(h, l, c, v),
),
"ForceIndex": (
lambda: ta.ForceIndex(13),
lambda ind, h, l, c, v: ind.batch(c, v),
),
"EaseOfMovement": (
lambda: ta.EaseOfMovement(14),
lambda ind, h, l, c, v: ind.batch(h, l, v),
),
"AtrTrailingStop": (
lambda: ta.AtrTrailingStop(14, 3.0),
lambda ind, h, l, c, v: ind.batch(h, l, c),
),
"TypicalPrice": (
lambda: ta.TypicalPrice(),
lambda ind, h, l, c, v: ind.batch(h, l, c),
),
"MedianPrice": (
lambda: ta.MedianPrice(),
lambda ind, h, l, c, v: ind.batch(h, l),
),
"WeightedClose": (
lambda: ta.WeightedClose(),
lambda ind, h, l, c, v: ind.batch(h, l, c),
),
"AcceleratorOscillator": (
lambda: ta.AcceleratorOscillator(5, 34, 5),
lambda ind, h, l, c, v: ind.batch(h, l),
),
"BalanceOfPower": (
# The streaming 6-tuple feeds open == close, so batch matches with
# the close column standing in for open.
lambda: ta.BalanceOfPower(),
lambda ind, h, l, c, v: ind.batch(c, h, l, c),
),
"ChoppinessIndex": (
lambda: ta.ChoppinessIndex(14),
lambda ind, h, l, c, v: ind.batch(h, l, c),
),
"TrueRange": (
lambda: ta.TrueRange(),
lambda ind, h, l, c, v: ind.batch(h, l, c),
),
"ChaikinVolatility": (
lambda: ta.ChaikinVolatility(10, 10),
lambda ind, h, l, c, v: ind.batch(h, l),
),
}
@pytest.mark.parametrize("name", list(CANDLE_SCALAR))
def test_candle_scalar_streaming_matches_batch(name, ohlcv):
high, low, close, volume = ohlcv
make, batch_call = CANDLE_SCALAR[name]
batch = batch_call(make(), high, low, close, volume)
assert batch.shape == close.shape
streamer = make()
streamed = []
for i in range(close.size):
candle = (
float(close[i]),
float(high[i]),
float(low[i]),
float(close[i]),
float(volume[i]),
i,
)
v = streamer.update(candle)
streamed.append(math.nan if v is None else float(v))
assert _eq_nan(batch, np.array(streamed, dtype=np.float64)), f"{name} mismatch"
# --- Candle-input, multi-output indicators --------------------------------
MULTI = {
"Vortex": (lambda: ta.Vortex(14), lambda ind, h, l, c, v: ind.batch(h, l, c)),
"SuperTrend": (
lambda: ta.SuperTrend(10, 3.0),
lambda ind, h, l, c, v: ind.batch(h, l, c),
),
"ChandelierExit": (
lambda: ta.ChandelierExit(22, 3.0),
lambda ind, h, l, c, v: ind.batch(h, l, c),
),
"ChandeKrollStop": (
lambda: ta.ChandeKrollStop(10, 1.0, 9),
lambda ind, h, l, c, v: ind.batch(h, l, c),
),
}
@pytest.mark.parametrize("name", list(MULTI))
def test_multi_streaming_matches_batch(name, ohlcv):
high, low, close, volume = ohlcv
make, batch_call = MULTI[name]
batch = batch_call(make(), high, low, close, volume)
assert batch.shape == (close.size, 2)
streamer = make()
rows = []
for i in range(close.size):
candle = (
float(close[i]),
float(high[i]),
float(low[i]),
float(close[i]),
float(volume[i]),
i,
)
v = streamer.update(candle)
rows.append([math.nan, math.nan] if v is None else list(v))
assert _eq_nan(batch, np.array(rows, dtype=np.float64)), f"{name} mismatch"
# --- Reference values -----------------------------------------------------
def test_typical_price_reference():
# (high + low + close) / 3 = (12 + 6 + 9) / 3 = 9.
assert ta.TypicalPrice().update((9.0, 12.0, 6.0, 9.0, 1.0, 0)) == pytest.approx(9.0)
def test_median_price_reference():
# (high + low) / 2 = (12 + 8) / 2 = 10.
assert ta.MedianPrice().update((10.0, 12.0, 8.0, 11.0, 1.0, 0)) == pytest.approx(10.0)
def test_weighted_close_reference():
# (high + low + 2*close) / 4 = (12 + 8 + 22) / 4 = 10.5.
assert ta.WeightedClose().update((10.0, 12.0, 8.0, 11.0, 1.0, 0)) == pytest.approx(
10.5
)
def test_chaikin_money_flow_reference():
cmf = ta.ChaikinMoneyFlow(2)
assert cmf.update((8.0, 10.0, 8.0, 10.0, 100.0, 0)) is None
assert cmf.update((10.0, 12.0, 8.0, 10.0, 100.0, 1)) == pytest.approx(0.5)
def test_linear_regression_reference():
out = ta.LinearRegression(3).batch(np.array([1.0, 2.0, 9.0]))
assert math.isnan(out[0]) and math.isnan(out[1])
assert out[2] == pytest.approx(8.0)
def test_linreg_slope_reference():
out = ta.LinRegSlope(3).batch(np.array([1.0, 2.0, 9.0]))
assert math.isnan(out[0]) and math.isnan(out[1])
assert out[2] == pytest.approx(4.0)
def test_balance_of_power_reference():
# (close - open) / (high - low) = (12 - 10) / (14 - 10) = 0.5.
bop = ta.BalanceOfPower()
assert bop.update((10.0, 14.0, 10.0, 12.0, 1.0, 0)) == pytest.approx(0.5)
def test_true_range_reference():
tr = ta.TrueRange()
assert tr.update((11.0, 12.0, 8.0, 11.0, 1.0, 0)) == pytest.approx(4.0)
assert tr.update((9.5, 10.0, 9.0, 9.5, 1.0, 1)) == pytest.approx(2.0)
def test_linreg_angle_reference():
# A series rising by 1 per step has slope 1, and atan(1) = 45 degrees.
out = ta.LinRegAngle(5).batch(np.array([1.0, 2.0, 3.0, 4.0, 5.0, 6.0]))
assert out[4] == pytest.approx(45.0)
def test_z_score_reference():
# Window [1, 3]: mean 2, population stddev 1; latest 3 -> z = 1.
out = ta.ZScore(2).batch(np.array([1.0, 3.0]))
assert math.isnan(out[0])
assert out[1] == pytest.approx(1.0)
# --- Lifecycle ------------------------------------------------------------
def test_new_indicators_expose_lifecycle():
instances = [make() for make, _ in CANDLE_SCALAR.values()]
instances += [make() for make, _ in MULTI.values()]
instances += [cls(*args) for cls, args in SCALAR]
for ind in instances:
assert ind.is_ready() is False
assert ind.warmup_period() >= 1
ind.reset()
assert ind.is_ready() is False
@@ -115,3 +115,23 @@ def test_obv_streaming_matches_batch(ohlc_series):
rows.append(streamer.update((float(c), float(c), float(c), float(c), float(v), 0)))
streamed = np.array([math.nan if x is None else x for x in rows], dtype=np.float64)
assert _equal_with_nan(batch, streamed)
def test_rolling_vwap_streaming_matches_batch(ohlc_series):
# RollingVWAP(20) on the shared OHLC series. Provides finite-memory VWAP
# parity coverage now that the indicator is exposed across all bindings.
high, low, close = ohlc_series
volume = np.linspace(100.0, 200.0, num=close.size, dtype=np.float64)
batch = ta.RollingVWAP(20).batch(high, low, close, volume)
streamer = ta.RollingVWAP(20)
rows = []
for h, l, c, v in zip(high, low, close, volume):
rows.append(streamer.update((float(c), float(h), float(l), float(c), float(v), 0)))
streamed = np.array([math.nan if x is None else x for x in rows], dtype=np.float64)
assert _equal_with_nan(batch, streamed)
assert streamer.period == 20
assert streamer.warmup_period() == 20
assert streamer.is_ready()
streamer.reset()
assert not streamer.is_ready()
+3
View File
@@ -34,6 +34,9 @@ console_error_panic_hook = { version = "0.1", optional = true }
default = []
panic-hook = ["dep:console_error_panic_hook"]
[dev-dependencies]
wasm-bindgen-test = "0.3"
[package.metadata.wasm-pack.profile.release.wasm-bindgen]
debug-js-glue = false
demangle-name-section = true
+287 -28
View File
@@ -1,47 +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 |
|------------------------|-----------------|-----------|----------------|--------|
| **★&nbsp;Wickra** | **clean** | **yes** | **Python + Node + WASM + Rust** | **yes** |
| 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 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 | **★&nbsp;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 | **★&nbsp;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 `bindings/wasm/examples/index.html`. After building
the package serve the `bindings/wasm/` directory and open `examples/index.html`.
## 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>
File diff suppressed because it is too large Load Diff
+4
View File
@@ -0,0 +1,4 @@
# Proper nouns that appear in indicator documentation. They are real names,
# not code identifiers, so `clippy::doc_markdown` must not demand backticks.
# `..` keeps clippy's built-in default identifier list in addition to these.
doc-valid-idents = ["LeBeau", ".."]
+6
View File
@@ -11,6 +11,12 @@ homepage.workspace = true
readme.workspace = true
keywords.workspace = true
categories.workspace = true
documentation = "https://docs.rs/wickra-core"
# Render the docs on docs.rs with every feature enabled so the parallel
# (rayon-backed) batch APIs are documented.
[package.metadata.docs.rs]
all-features = true
[lints]
workspace = true
+7
View File
@@ -21,6 +21,13 @@ pub enum Error {
#[error("invalid candle: {message}")]
InvalidCandle { message: &'static str },
/// A tick whose components do not satisfy the tick invariants (e.g. negative
/// volume) was provided. Ticks are a different concept from candles and
/// surface as their own variant so consumers of a tick-stream pipeline
/// can match on a semantically-correct error instead of `InvalidCandle`.
#[error("invalid tick: {message}")]
InvalidTick { message: &'static str },
/// A multiplier or factor must be strictly positive.
#[error("multiplier must be greater than zero")]
NonPositiveMultiplier,
@@ -0,0 +1,204 @@
//! Accelerator Oscillator (Bill Williams).
use crate::error::Result;
use crate::indicators::awesome_oscillator::AwesomeOscillator;
use crate::indicators::sma::Sma;
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Accelerator Oscillator — Bill Williams' gauge of *momentum's acceleration*.
///
/// ```text
/// AO = SMA(median, fast) SMA(median, slow) (the Awesome Oscillator)
/// AC = AO SMA(AO, signal)
/// ```
///
/// Where the [`AwesomeOscillator`](crate::AwesomeOscillator) tracks momentum,
/// the Accelerator tracks the *change* in momentum: it is the AO minus a short
/// moving average of itself. Because acceleration leads speed, `AC` tends to
/// turn before the `AO` does. Bill Williams' classic configuration is the
/// `(5, 34)` AO with a `5`-period signal average.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, AcceleratorOscillator};
///
/// let mut indicator = AcceleratorOscillator::classic();
/// let mut last = None;
/// for i in 0..80 {
/// let base = 100.0 + f64::from(i);
/// let candle =
/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
/// last = indicator.update(candle);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct AcceleratorOscillator {
ao: AwesomeOscillator,
signal: Sma,
ao_fast: usize,
ao_slow: usize,
signal_period: usize,
}
impl AcceleratorOscillator {
/// Construct an Accelerator Oscillator with explicit AO and signal periods.
///
/// # Errors
/// Returns [`Error::PeriodZero`](crate::Error::PeriodZero) for a zero
/// period and [`Error::InvalidPeriod`](crate::Error::InvalidPeriod) if the
/// AO `fast` period is not strictly below `slow`.
pub fn new(ao_fast: usize, ao_slow: usize, signal_period: usize) -> Result<Self> {
Ok(Self {
ao: AwesomeOscillator::new(ao_fast, ao_slow)?,
signal: Sma::new(signal_period)?,
ao_fast,
ao_slow,
signal_period,
})
}
/// Bill Williams' classic configuration: `AO(5, 34)` with a `5`-period signal.
pub fn classic() -> Self {
Self::new(5, 34, 5).expect("classic Accelerator Oscillator params are valid")
}
/// Configured `(ao_fast, ao_slow, signal_period)`.
pub const fn params(&self) -> (usize, usize, usize) {
(self.ao_fast, self.ao_slow, self.signal_period)
}
}
impl Indicator for AcceleratorOscillator {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
let ao = self.ao.update(candle)?;
let signal = self.signal.update(ao)?;
Some(ao - signal)
}
fn reset(&mut self) {
self.ao.reset();
self.signal.reset();
}
fn warmup_period(&self) -> usize {
// The AO emits at candle `ao_slow`; the signal SMA then needs
// `signal_period` AO values.
self.ao_slow + self.signal_period - 1
}
fn is_ready(&self) -> bool {
self.signal.is_ready()
}
fn name(&self) -> &'static str {
"AcceleratorOscillator"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn c(high: f64, low: f64, close: f64, ts: i64) -> Candle {
Candle::new(f64::midpoint(high, low), high, low, close, 1.0, ts).unwrap()
}
#[test]
fn constant_series_yields_zero() {
// A flat market gives AO = 0, so its signal average and AC are 0 too.
let candles: Vec<Candle> = (0..80).map(|i| c(11.0, 9.0, 10.0, i)).collect();
let mut ac = AcceleratorOscillator::classic();
for v in ac.batch(&candles).into_iter().flatten() {
assert_relative_eq!(v, 0.0, epsilon = 1e-9);
}
}
#[test]
fn matches_independent_ao_and_signal() {
let candles: Vec<Candle> = (0..90)
.map(|i| {
let m = 100.0 + (i as f64 * 0.2).sin() * 6.0;
c(m + 1.5, m - 1.5, m + 0.3, i)
})
.collect();
let mut ac = AcceleratorOscillator::classic();
let mut ao = AwesomeOscillator::classic();
let mut signal = Sma::new(5).unwrap();
for (i, candle) in candles.iter().enumerate() {
let got = ac.update(*candle);
match ao.update(*candle) {
Some(ao_val) => match signal.update(ao_val) {
Some(sig) => {
assert_relative_eq!(got.unwrap(), ao_val - sig, epsilon = 1e-9);
}
None => assert!(got.is_none(), "i={i}"),
},
None => assert!(got.is_none(), "i={i}"),
}
}
}
#[test]
fn first_emission_matches_warmup_period() {
let candles: Vec<Candle> = (0..60).map(|i| c(11.0, 9.0, 10.0, i)).collect();
let mut ac = AcceleratorOscillator::classic();
let out = ac.batch(&candles);
assert_eq!(ac.warmup_period(), 38);
for (i, v) in out.iter().enumerate().take(37) {
assert!(v.is_none(), "index {i} must be None during warmup");
}
assert!(out[37].is_some(), "first value lands at warmup_period - 1");
}
#[test]
fn rejects_invalid_params() {
assert!(AcceleratorOscillator::new(0, 34, 5).is_err());
assert!(AcceleratorOscillator::new(5, 34, 0).is_err());
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();
let mut ac = AcceleratorOscillator::classic();
ac.batch(&candles);
assert!(ac.is_ready());
ac.reset();
assert!(!ac.is_ready());
assert_eq!(ac.update(candles[0]), None);
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..90)
.map(|i| {
let m = 100.0 + (i as f64 * 0.3).sin() * 8.0;
c(m + 1.5, m - 1.5, m + 0.5, i)
})
.collect();
let mut a = AcceleratorOscillator::classic();
let mut b = AcceleratorOscillator::classic();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
+193
View File
@@ -0,0 +1,193 @@
//! Accumulation/Distribution Line.
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Accumulation/Distribution Line — Marc Chaikin's cumulative volume-flow
/// indicator.
///
/// Each bar contributes a *money-flow volume*: the bar's volume weighted by
/// where the close fell within the bar's range.
///
/// ```text
/// MFM_t = ((close low) (high close)) / (high low) (the money-flow multiplier, 1..+1)
/// MFV_t = MFM_t · volume_t
/// ADL_t = ADL_{t1} + MFV_t
/// ```
///
/// A close near the high makes the multiplier near `+1` (accumulation), near
/// the low near `1` (distribution). The running total is unbounded and drifts
/// with cumulative volume — what matters is its slope and its divergence from
/// price. A bar with `high == low` contributes `0`.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, Adl};
///
/// let mut indicator = Adl::new();
/// let mut last = None;
/// for i in 0..80 {
/// let base = 100.0 + f64::from(i);
/// let candle =
/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
/// last = indicator.update(candle);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone, Default)]
pub struct Adl {
total: f64,
has_emitted: bool,
}
impl Adl {
/// Construct a new Accumulation/Distribution Line starting at zero.
pub const fn new() -> Self {
Self {
total: 0.0,
has_emitted: false,
}
}
/// Current cumulative value if at least one candle has been ingested.
pub const fn value(&self) -> Option<f64> {
if self.has_emitted {
Some(self.total)
} else {
None
}
}
}
impl Indicator for Adl {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
let range = candle.high - candle.low;
let mfv = if range == 0.0 {
// A zero-range bar carries no positional information.
0.0
} else {
let mfm = ((candle.close - candle.low) - (candle.high - candle.close)) / range;
mfm * candle.volume
};
self.total += mfv;
self.has_emitted = true;
Some(self.total)
}
fn reset(&mut self) {
self.total = 0.0;
self.has_emitted = false;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"ADL"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn candle(open: f64, high: f64, low: f64, close: f64, volume: f64, ts: i64) -> Candle {
Candle::new(open, high, low, close, volume, ts).unwrap()
}
#[test]
fn reference_values() {
// bar 1: close at high -> MFM = +1 -> MFV = +100; ADL = 100.
// bar 2: h=12 l=8 c=9 -> MFM = ((9-8)-(12-9))/4 = -0.5 -> MFV = -100;
// ADL = 100 - 100 = 0.
let mut adl = Adl::new();
let out = adl.batch(&[
candle(8.0, 10.0, 8.0, 10.0, 100.0, 0),
candle(10.0, 12.0, 8.0, 9.0, 200.0, 1),
]);
assert_relative_eq!(out[0].unwrap(), 100.0, epsilon = 1e-12);
assert_relative_eq!(out[1].unwrap(), 0.0, epsilon = 1e-12);
}
#[test]
fn emits_from_first_candle() {
let mut adl = Adl::new();
assert_eq!(adl.warmup_period(), 1);
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`.
let mut adl = Adl::new();
let mut expected = 0.0;
for i in 0..10 {
let c = candle(8.0, 10.0, 8.0, 10.0, 25.0, i);
expected += 25.0;
assert_relative_eq!(adl.update(c).unwrap(), expected, epsilon = 1e-9);
}
}
#[test]
fn zero_range_bar_contributes_nothing() {
let mut adl = Adl::new();
adl.update(candle(8.0, 10.0, 8.0, 10.0, 100.0, 0));
let before = adl.value().unwrap();
// A flat candle (high == low) adds zero.
let after = adl.update(candle(9.0, 9.0, 9.0, 9.0, 999.0, 1)).unwrap();
assert_relative_eq!(after, before, epsilon = 1e-12);
}
#[test]
fn reset_clears_state() {
let mut adl = Adl::new();
adl.batch(&[
candle(8.0, 10.0, 8.0, 9.0, 100.0, 0),
candle(9.0, 11.0, 9.0, 10.0, 100.0, 1),
]);
assert!(adl.is_ready());
adl.reset();
assert!(!adl.is_ready());
assert_eq!(adl.value(), None);
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..60)
.map(|i| {
let mid = 100.0 + (i as f64 * 0.3).sin() * 8.0;
candle(
mid,
mid + 2.0,
mid - 2.0,
mid + 0.5,
10.0 + (i % 5) as f64,
i,
)
})
.collect();
let batch = Adl::new().batch(&candles);
let mut b = Adl::new();
let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
assert_eq!(batch, streamed);
}
}
+47
View File
@@ -21,6 +21,22 @@ pub struct AdxOutput {
/// movement / true range sums; the next `period` candles produce DX values that
/// seed the ADX. The first complete `AdxOutput` is emitted after `2 * period`
/// candles.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, Adx};
///
/// let mut indicator = Adx::new(5).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// let base = 100.0 + f64::from(i);
/// let candle =
/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
/// last = indicator.update(candle);
/// }
/// assert!(last.is_some());
/// ```
#[allow(clippy::struct_field_names)] // adx_value pairs with adx (the output line) — renaming hurts clarity
#[derive(Debug, Clone)]
pub struct Adx {
@@ -253,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)
@@ -17,6 +17,22 @@ pub struct AroonOutput {
/// Aroon indicator: tracks how many bars since the highest high and lowest low
/// inside a `period + 1`-bar window. Returned as a percentage.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, Aroon};
///
/// let mut indicator = Aroon::new(5).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// let base = 100.0 + f64::from(i);
/// let candle =
/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
/// last = indicator.update(candle);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct Aroon {
period: usize,
@@ -153,4 +169,26 @@ mod tests {
assert!((0.0..=100.0).contains(&o.down));
}
}
#[test]
fn reset_clears_state() {
let candles: Vec<Candle> = (1..=20)
.map(|i| c(f64::from(i) + 1.0, f64::from(i) - 1.0, f64::from(i)))
.collect();
let mut a = Aroon::new(14).unwrap();
a.batch(&candles);
assert!(a.is_ready());
a.reset();
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");
}
}
@@ -0,0 +1,200 @@
//! Aroon Oscillator.
use crate::error::Result;
use crate::ohlcv::Candle;
use crate::traits::Indicator;
use super::Aroon;
/// Aroon Oscillator — the single-line difference `AroonUp AroonDown`.
///
/// The [`Aroon`] indicator reports two `[0, 100]` lines; the Aroon Oscillator
/// collapses them into one value in `[100, 100]`:
///
/// ```text
/// AroonOscillator = AroonUp AroonDown
/// ```
///
/// Strongly positive means the most recent high is much fresher than the most
/// recent low (an up-trend); strongly negative is the mirror image. Readings
/// near zero mean neither extreme is recent — a range.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, AroonOscillator};
///
/// let mut indicator = AroonOscillator::new(5).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// let base = 100.0 + i as f64;
/// let candle =
/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
/// last = indicator.update(candle);
/// }
/// assert_eq!(last, Some(100.0)); // pure uptrend
/// ```
#[derive(Debug, Clone)]
pub struct AroonOscillator {
aroon: Aroon,
last: Option<f64>,
}
impl AroonOscillator {
/// Construct a new Aroon Oscillator with the given period.
///
/// # Errors
///
/// Returns [`crate::Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
Ok(Self {
aroon: Aroon::new(period)?,
last: None,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.aroon.period()
}
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.last
}
}
impl Indicator for AroonOscillator {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
let osc = self.aroon.update(candle).map(|o| o.up - o.down)?;
self.last = Some(osc);
Some(osc)
}
fn reset(&mut self) {
self.aroon.reset();
self.last = None;
}
fn warmup_period(&self) -> usize {
self.aroon.warmup_period()
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"AroonOscillator"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn candle(high: f64, low: f64, close: f64, ts: i64) -> Candle {
Candle::new(close, high, low, close, 1.0, ts).unwrap()
}
#[test]
fn new_rejects_zero_period() {
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.
let mut osc = AroonOscillator::new(5).unwrap();
let candles: Vec<Candle> = (0..30)
.map(|i| {
let p = 100.0 + i as f64;
candle(p + 1.0, p - 1.0, p, i)
})
.collect();
for v in osc.batch(&candles).into_iter().flatten() {
assert_relative_eq!(v, 100.0, epsilon = 1e-12);
}
}
#[test]
fn pure_downtrend_yields_minus_100() {
let mut osc = AroonOscillator::new(5).unwrap();
let candles: Vec<Candle> = (0..30)
.map(|i| {
let p = 100.0 - i as f64;
candle(p + 1.0, p - 1.0, p, i)
})
.collect();
for v in osc.batch(&candles).into_iter().flatten() {
assert_relative_eq!(v, -100.0, epsilon = 1e-12);
}
}
#[test]
fn output_stays_within_minus_100_and_100() {
let mut osc = AroonOscillator::new(14).unwrap();
let candles: Vec<Candle> = (0..200)
.map(|i| {
let mid = 100.0 + (i as f64 * 0.25).sin() * 12.0;
candle(mid + 2.0, mid - 2.0, mid, i)
})
.collect();
for v in osc.batch(&candles).into_iter().flatten() {
assert!((-100.0..=100.0).contains(&v), "out of range: {v}");
}
}
#[test]
fn warmup_period_matches_aroon() {
let osc = AroonOscillator::new(7).unwrap();
assert_eq!(osc.warmup_period(), 8);
}
#[test]
fn reset_clears_state() {
let mut osc = AroonOscillator::new(5).unwrap();
let candles: Vec<Candle> = (0..20)
.map(|i| candle(100.0 + i as f64, 90.0, 95.0, i))
.collect();
osc.batch(&candles);
assert!(osc.is_ready());
osc.reset();
assert!(!osc.is_ready());
assert_eq!(osc.update(candles[0]), None);
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..60)
.map(|i| {
let mid = 100.0 + (i as f64 * 0.3).sin() * 8.0;
candle(mid + 2.0, mid - 2.0, mid, i)
})
.collect();
let batch = AroonOscillator::new(14).unwrap().batch(&candles);
let mut b = AroonOscillator::new(14).unwrap();
let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
assert_eq!(batch, streamed);
}
}
+94
View File
@@ -9,6 +9,22 @@ use crate::traits::Indicator;
/// The first emitted value, by convention, appears after `period` candles: the
/// first `period 1` true-range values seed the Wilder average alongside the
/// `period`-th, then the smoothed update begins.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, Atr};
///
/// let mut indicator = Atr::new(5).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// let base = 100.0 + f64::from(i);
/// let candle =
/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
/// last = indicator.update(candle);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct Atr {
period: usize,
@@ -100,11 +116,56 @@ mod tests {
Candle::new(cl, h, l, cl, 1.0, 0).unwrap()
}
/// Independent reference: Wilder ATR computed straight from the definition.
fn atr_naive(hlc: &[(f64, f64, f64)], period: usize) -> Vec<Option<f64>> {
let n = period as f64;
let mut out = Vec::with_capacity(hlc.len());
let mut trs: Vec<f64> = Vec::new();
let mut avg: Option<f64> = None;
let mut prev_close: Option<f64> = None;
for &(h, l, cl) in hlc {
let tr = match prev_close {
None => h - l,
Some(pc) => (h - l).max((h - pc).abs()).max((l - pc).abs()),
};
prev_close = Some(cl);
if let Some(a) = avg {
let na = (a * (n - 1.0) + tr) / n;
avg = Some(na);
out.push(Some(na));
} else {
trs.push(tr);
if trs.len() == period {
avg = Some(trs.iter().sum::<f64>() / n);
out.push(avg);
} else {
out.push(None);
}
}
}
out
}
#[test]
fn rejects_zero_period() {
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![
@@ -187,4 +248,37 @@ mod tests {
assert!(v >= 0.0, "ATR must be non-negative: {v}");
}
}
proptest::proptest! {
#![proptest_config(proptest::test_runner::Config::with_cases(48))]
#[test]
fn atr_matches_naive(
period in 1usize..15,
bars in proptest::collection::vec(
(10.0_f64..1000.0, 0.0_f64..50.0, 0.0_f64..1.0),
0..120,
),
) {
// bars: (low, range, close_fraction) -> a valid OHLC candle.
let hlc: Vec<(f64, f64, f64)> = bars
.iter()
.map(|&(low, range, frac)| (low + range, low, low + range * frac))
.collect();
let candles: Vec<Candle> = hlc.iter().map(|&(h, l, cl)| c(h, l, cl)).collect();
let mut atr = Atr::new(period).unwrap();
let got = atr.batch(&candles);
let want = atr_naive(&hlc, period);
proptest::prop_assert_eq!(got.len(), want.len());
for (g, w) in got.iter().zip(want.iter()) {
match (g, w) {
(None, None) => {}
(Some(a), Some(b)) => proptest::prop_assert!(
(a - b).abs() <= 1e-9 * a.abs().max(1.0),
"got={a} want={b}"
),
_ => proptest::prop_assert!(false, "warmup mismatch"),
}
}
}
}
}
@@ -0,0 +1,279 @@
//! ATR Trailing Stop.
use crate::error::{Error, Result};
use crate::indicators::atr::Atr;
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// ATR Trailing Stop — a stop level that trails price by a fixed ATR multiple
/// and ratchets in the direction of the trend.
///
/// ```text
/// loss = multiplier · ATR
///
/// stop_t = max(stop_{t1}, close loss) while price holds above the stop
/// = min(stop_{t1}, close + loss) while price holds below the stop
/// = close loss on a fresh break above the stop
/// = close + loss on a fresh break below the stop
/// ```
///
/// While price stays on one side of the stop the level only ratchets toward
/// price — up in an uptrend, down in a downtrend — never away from it. When a
/// close crosses the stop the level snaps to the opposite side, `loss` away
/// from the new close, flipping the trade. This is the trailing stop used by
/// the well-known "UT Bot"; the first ATR-ready bar seeds the stop below
/// price (a long).
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, AtrTrailingStop};
///
/// let mut indicator = AtrTrailingStop::new(14, 3.0).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// let base = 100.0 + f64::from(i);
/// let candle =
/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
/// last = indicator.update(candle);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct AtrTrailingStop {
atr: Atr,
multiplier: f64,
atr_period: usize,
prev_close: Option<f64>,
prev_stop: Option<f64>,
}
impl AtrTrailingStop {
/// Construct an ATR Trailing Stop with an explicit ATR period and multiple.
///
/// # Errors
/// Returns [`Error::PeriodZero`] if `atr_period == 0` and
/// [`Error::NonPositiveMultiplier`] if `multiplier` is not strictly
/// positive and finite.
pub fn new(atr_period: usize, multiplier: f64) -> Result<Self> {
if !multiplier.is_finite() || multiplier <= 0.0 {
return Err(Error::NonPositiveMultiplier);
}
Ok(Self {
atr: Atr::new(atr_period)?,
multiplier,
atr_period,
prev_close: None,
prev_stop: None,
})
}
/// A common configuration: `ATR(14)` with a `3.0` multiplier.
pub fn classic() -> Self {
Self::new(14, 3.0).expect("classic ATR Trailing Stop params are valid")
}
/// Configured `(atr_period, multiplier)`.
pub const fn params(&self) -> (usize, f64) {
(self.atr_period, self.multiplier)
}
}
impl Indicator for AtrTrailingStop {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
let atr = self.atr.update(candle)?;
let loss = self.multiplier * atr;
let close = candle.close;
let stop = match (self.prev_stop, self.prev_close) {
(Some(prev_stop), Some(prev_close)) => {
if close > prev_stop && prev_close > prev_stop {
// Holding above the stop — ratchet it up only.
(close - loss).max(prev_stop)
} else if close < prev_stop && prev_close < prev_stop {
// Holding below the stop — ratchet it down only.
(close + loss).min(prev_stop)
} else if close > prev_stop {
// Fresh break above — place the stop below the new close.
close - loss
} else {
// Fresh break below — place the stop above the new close.
close + loss
}
}
// First ATR-ready bar: seed the stop below price (a long).
_ => close - loss,
};
self.prev_close = Some(close);
self.prev_stop = Some(stop);
Some(stop)
}
fn reset(&mut self) {
self.atr.reset();
self.prev_close = None;
self.prev_stop = None;
}
fn warmup_period(&self) -> usize {
self.atr_period
}
fn is_ready(&self) -> bool {
self.prev_stop.is_some()
}
fn name(&self) -> &'static str {
"AtrTrailingStop"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn c(high: f64, low: f64, close: f64, ts: i64) -> Candle {
Candle::new(f64::midpoint(high, low), high, low, close, 1.0, ts).unwrap()
}
#[test]
fn reference_values_flat_market() {
// Flat candles H=11, L=9, C=10 -> TR=2 -> ATR=2; loss = 3·2 = 6.
// Seed stop = close - loss = 10 - 6 = 4, and it holds there.
let candles: Vec<Candle> = (0..20).map(|i| c(11.0, 9.0, 10.0, i)).collect();
let mut ts = AtrTrailingStop::new(5, 3.0).unwrap();
for v in ts.batch(&candles).into_iter().flatten() {
assert_relative_eq!(v, 4.0, epsilon = 1e-12);
}
}
#[test]
fn uptrend_stop_ratchets_up_and_stays_below_price() {
let candles: Vec<Candle> = (0..50)
.map(|i| {
let base = 100.0 + i as f64;
c(base + 1.0, base - 1.0, base, i)
})
.collect();
let mut ts = AtrTrailingStop::new(14, 3.0).unwrap();
let emitted: Vec<(f64, f64)> = ts
.batch(&candles)
.into_iter()
.zip(candles.iter())
.filter_map(|(o, c)| o.map(|v| (v, c.close)))
.collect();
for w in emitted.windows(2) {
assert!(
w[1].0 >= w[0].0 - 1e-9,
"stop must not loosen in an uptrend"
);
}
for &(stop, close) in &emitted {
assert!(stop < close, "uptrend stop should sit below the close");
}
}
#[test]
fn stop_flips_to_the_other_side_when_price_reverses() {
let mut candles: Vec<Candle> = (0..40)
.map(|i| {
let base = 100.0 + i as f64;
c(base + 1.0, base - 1.0, base, i)
})
.collect();
// A steep decline drags price through the trailing stop.
candles.extend((0..40).map(|i| {
let base = 140.0 - 3.0 * i as f64;
c(base + 1.0, base - 1.0, base, 40 + i)
}));
let mut ts = AtrTrailingStop::new(14, 3.0).unwrap();
let paired: Vec<(f64, f64)> = ts
.batch(&candles)
.into_iter()
.zip(candles.iter())
.filter_map(|(o, c)| o.map(|v| (v, c.close)))
.collect();
assert!(
paired.iter().any(|&(stop, close)| stop < close),
"expected a long stretch with the stop below price"
);
assert!(
paired.iter().any(|&(stop, close)| stop > close),
"expected the stop to flip above price after the reversal"
);
}
#[test]
fn first_emission_matches_warmup_period() {
let candles: Vec<Candle> = (0..20)
.map(|i| {
let base = 100.0 + i as f64;
c(base + 1.0, base - 1.0, base, i)
})
.collect();
let mut ts = AtrTrailingStop::new(8, 3.0).unwrap();
let out = ts.batch(&candles);
assert_eq!(ts.warmup_period(), 8);
for (i, v) in out.iter().enumerate().take(7) {
assert!(v.is_none(), "index {i} must be None during warmup");
}
assert!(out[7].is_some(), "first value lands at warmup_period - 1");
}
#[test]
fn rejects_invalid_params() {
assert!(AtrTrailingStop::new(0, 3.0).is_err());
assert!(AtrTrailingStop::new(14, 0.0).is_err());
assert!(AtrTrailingStop::new(14, -1.0).is_err());
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)
.map(|i| {
let base = 100.0 + i as f64;
c(base + 1.0, base - 1.0, base, i)
})
.collect();
let mut ts = AtrTrailingStop::classic();
ts.batch(&candles);
assert!(ts.is_ready());
ts.reset();
assert!(!ts.is_ready());
assert_eq!(ts.update(candles[0]), None);
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..80)
.map(|i| {
let mid = 100.0 + (i as f64 * 0.3).sin() * 8.0;
c(mid + 1.5, mid - 1.5, mid + 0.5, i)
})
.collect();
let mut a = AtrTrailingStop::classic();
let mut b = AtrTrailingStop::classic();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
@@ -6,6 +6,22 @@ use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Awesome Oscillator: `SMA(median_price, 5) - SMA(median_price, 34)`.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, AwesomeOscillator};
///
/// let mut indicator = AwesomeOscillator::new(3, 10).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// let base = 100.0 + f64::from(i);
/// let candle =
/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
/// last = indicator.update(candle);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct AwesomeOscillator {
fast: Sma,
@@ -102,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)
@@ -114,4 +141,17 @@ mod tests {
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let candles: Vec<Candle> = (0..50)
.map(|i| c(f64::from(i) + 1.0, f64::from(i) - 1.0, f64::from(i)))
.collect();
let mut ao = AwesomeOscillator::classic();
ao.batch(&candles);
assert!(ao.is_ready());
ao.reset();
assert!(!ao.is_ready());
assert_eq!(ao.update(candles[0]), None);
}
}
@@ -0,0 +1,175 @@
//! Balance of Power.
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Balance of Power — where the close settled within the bar's range relative
/// to the open.
///
/// ```text
/// BOP = (close open) / (high low)
/// ```
///
/// The result lives in `[1, +1]`: `+1` is a bar that opened on its low and
/// closed on its high (buyers in full control), `1` the mirror image. It is
/// a stateless per-bar reading — a quick gauge of intrabar conviction. A
/// zero-range bar carries no information and yields `0`.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, BalanceOfPower};
///
/// let mut indicator = BalanceOfPower::new();
/// let mut last = None;
/// for i in 0..80 {
/// let base = 100.0 + f64::from(i);
/// let candle =
/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
/// last = indicator.update(candle);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone, Default)]
pub struct BalanceOfPower {
has_emitted: bool,
}
impl BalanceOfPower {
/// Construct a new Balance of Power transform.
pub const fn new() -> Self {
Self { has_emitted: false }
}
}
impl Indicator for BalanceOfPower {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
self.has_emitted = true;
let range = candle.high - candle.low;
let bop = if range == 0.0 {
// A zero-range bar carries no directional information.
0.0
} else {
(candle.close - candle.open) / range
};
Some(bop)
}
fn reset(&mut self) {
self.has_emitted = false;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"BalanceOfPower"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn candle(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle {
Candle::new(open, high, low, close, 1.0, ts).unwrap()
}
#[test]
fn reference_value() {
// (close - open) / (high - low) = (12 - 10) / (14 - 10) = 0.5.
let mut bop = BalanceOfPower::new();
assert_relative_eq!(
bop.update(candle(10.0, 14.0, 10.0, 12.0, 0)).unwrap(),
0.5,
epsilon = 1e-12
);
}
#[test]
fn close_on_high_after_open_on_low_is_plus_one() {
let mut bop = BalanceOfPower::new();
// open == low, close == high -> BOP = +1.
assert_relative_eq!(
bop.update(candle(9.0, 11.0, 9.0, 11.0, 0)).unwrap(),
1.0,
epsilon = 1e-12
);
}
#[test]
fn stays_within_unit_range() {
let candles: Vec<Candle> = (0..100)
.map(|i| {
let mid = 100.0 + (i as f64 * 0.2).sin() * 8.0;
let close = mid + (i as f64 * 0.5).cos() * 2.0;
candle(mid, mid + 3.0, mid - 3.0, close, i)
})
.collect();
let mut bop = BalanceOfPower::new();
for v in bop.batch(&candles).into_iter().flatten() {
assert!((-1.0..=1.0).contains(&v), "BOP {v} outside [-1, 1]");
}
}
#[test]
fn zero_range_bar_yields_zero() {
let mut bop = BalanceOfPower::new();
assert_relative_eq!(
bop.update(candle(10.0, 10.0, 10.0, 10.0, 0)).unwrap(),
0.0,
epsilon = 1e-12
);
}
/// 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();
assert_eq!(bop.warmup_period(), 1);
assert!(!bop.is_ready());
assert!(bop.update(candle(10.0, 11.0, 9.0, 10.0, 0)).is_some());
assert!(bop.is_ready());
}
#[test]
fn reset_clears_state() {
let mut bop = BalanceOfPower::new();
bop.update(candle(10.0, 11.0, 9.0, 10.0, 0));
assert!(bop.is_ready());
bop.reset();
assert!(!bop.is_ready());
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..40)
.map(|i| {
let base = 100.0 + i as f64;
candle(base, base + 2.0, base - 2.0, base + 1.0, i)
})
.collect();
let mut a = BalanceOfPower::new();
let mut b = BalanceOfPower::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
+115 -7
View File
@@ -24,6 +24,27 @@ pub struct BollingerOutput {
/// Standard parameters are `period = 20`, `multiplier = 2.0`. Bollinger's original
/// publication uses population (not sample) standard deviation, which matches every
/// reference implementation (TA-Lib, pandas-ta, etc.).
///
/// The running `sum` and `sum_sq` are reseeded from the live window every
/// `16 · period` updates to cap floating-point drift on long streams. This is
/// amortised O(1), preserves bit-equivalence with the previous behaviour on
/// inputs that did not drift, and is particularly important for `sum_sq`,
/// where catastrophic cancellation between large add/subtract pairs can drive
/// the computed variance negative (the `.max(0.0)` clamp below is the
/// safety-net for the rare cases where the reseed has not happened yet).
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, BollingerBands};
///
/// let mut indicator = BollingerBands::new(5, 2.0).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// last = indicator.update(100.0 + f64::from(i));
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct BollingerBands {
period: usize,
@@ -31,8 +52,17 @@ pub struct BollingerBands {
window: VecDeque<f64>,
sum: f64,
sum_sq: f64,
/// Number of finite updates since the running sums were last reseeded
/// from the live window. See [`RECOMPUTE_EVERY`] below.
updates_since_recompute: usize,
}
/// How often (in finite updates) the incremental `sum` / `sum_sq` are reseeded
/// from the live window. The multiplier `16` keeps the amortised cost flat and
/// caps any cancellation drift to roughly `16 · period · ULP · max(|x|²)` —
/// negligible on real-world price scales.
const RECOMPUTE_EVERY: usize = 16;
impl BollingerBands {
/// Construct a new Bollinger Bands indicator.
///
@@ -53,6 +83,7 @@ impl BollingerBands {
window: VecDeque::with_capacity(period),
sum: 0.0,
sum_sq: 0.0,
updates_since_recompute: 0,
})
}
@@ -106,6 +137,12 @@ impl Indicator for BollingerBands {
self.window.push_back(input);
self.sum += input;
self.sum_sq += input * input;
self.updates_since_recompute += 1;
if self.updates_since_recompute >= RECOMPUTE_EVERY * self.period {
self.sum = self.window.iter().copied().sum();
self.sum_sq = self.window.iter().copied().map(|x| x * x).sum();
self.updates_since_recompute = 0;
}
self.current()
}
@@ -113,6 +150,7 @@ impl Indicator for BollingerBands {
self.window.clear();
self.sum = 0.0;
self.sum_sq = 0.0;
self.updates_since_recompute = 0;
}
fn warmup_period(&self) -> usize {
@@ -134,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]
@@ -174,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();
@@ -203,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);
@@ -240,4 +293,59 @@ mod tests {
bb.reset();
assert!(!bb.is_ready());
}
/// Long-running stability check. After several recompute cycles the
/// reported Bollinger bands must still equal a fresh from-scratch
/// computation over the live window — even on inputs designed to cause
/// catastrophic cancellation in the `sum_sq` accumulator (alternating
/// between two very different magnitudes).
#[test]
fn long_stream_drift_stays_bounded() {
let period = 20;
let mult = 2.0;
let mut bb = BollingerBands::new(period, mult).unwrap();
let mut window: VecDeque<f64> = VecDeque::with_capacity(period);
// Forces the periodic reseed to fire 5+ times.
let n_updates = 16 * period * 5;
let mut last = None;
for i in 0..n_updates {
let v = if i % 2 == 0 { 1e6 } else { 1.0 };
last = bb.update(v);
if window.len() == period {
window.pop_front();
}
window.push_back(v);
}
let scratch = naive(&window.iter().copied().collect::<Vec<_>>(), period, mult);
let got = last.expect("warmed up");
assert!(
(got.middle - scratch.middle).abs() < 1e-3,
"middle drift: got={}, scratch={}",
got.middle,
scratch.middle,
);
assert!(
(got.stddev - scratch.stddev).abs() < 1e-3,
"stddev drift: got={}, scratch={}",
got.stddev,
scratch.stddev,
);
}
#[test]
fn ignores_non_finite_input() {
let mut bb = BollingerBands::new(5, 2.0).unwrap();
let ready = bb.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
let last = ready.last().unwrap().unwrap();
// Non-finite inputs return the current bands without mutating the window.
assert_eq!(bb.update(f64::NAN).unwrap(), last);
assert_eq!(bb.update(f64::INFINITY).unwrap(), last);
// The window still holds 1..=5, so a real input slides it to 2..=6.
let after = bb.update(6.0).unwrap();
assert_relative_eq!(
after.middle,
(2.0 + 3.0 + 4.0 + 5.0 + 6.0) / 5.0,
epsilon = 1e-12
);
}
}
@@ -0,0 +1,212 @@
//! Bollinger Bandwidth.
use crate::error::Result;
use crate::traits::Indicator;
use super::BollingerBands;
/// Bollinger Bandwidth — the width of the Bollinger Bands relative to the
/// middle band.
///
/// ```text
/// Bandwidth = (upper lower) / middle
/// ```
///
/// Because the bands are `middle ± multiplier · stddev`, the bandwidth is
/// `2 · multiplier · stddev / middle` — a normalised volatility reading. Its
/// value is the basis of two classic patterns: the **squeeze** (bandwidth at a
/// multi-month low, signalling a coiled, low-volatility market about to
/// expand) and the **bulge** (bandwidth at an extreme high).
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, BollingerBandwidth};
///
/// let mut indicator = BollingerBandwidth::new(20, 2.0).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// last = indicator.update(100.0 + (f64::from(i) * 0.3).sin() * 6.0);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct BollingerBandwidth {
bands: BollingerBands,
last: Option<f64>,
}
impl BollingerBandwidth {
/// Construct a new Bollinger Bandwidth indicator.
///
/// # Errors
///
/// Returns [`crate::Error::PeriodZero`] for `period == 0` and
/// [`crate::Error::NonPositiveMultiplier`] for `multiplier <= 0`.
pub fn new(period: usize, multiplier: f64) -> Result<Self> {
Ok(Self {
bands: BollingerBands::new(period, multiplier)?,
last: None,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.bands.period()
}
/// Configured multiplier.
pub const fn multiplier(&self) -> f64 {
self.bands.multiplier()
}
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.last
}
}
impl Indicator for BollingerBandwidth {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
let o = self.bands.update(input)?;
let bandwidth = if o.middle == 0.0 {
// Undefined against a zero middle band.
0.0
} else {
(o.upper - o.lower) / o.middle
};
self.last = Some(bandwidth);
Some(bandwidth)
}
fn reset(&mut self) {
self.bands.reset();
self.last = None;
}
fn warmup_period(&self) -> usize {
self.bands.warmup_period()
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"BollingerBandwidth"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn new_rejects_invalid_parameters() {
assert!(BollingerBandwidth::new(0, 2.0).is_err());
assert!(BollingerBandwidth::new(20, 0.0).is_err());
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.
let mut bbw = BollingerBandwidth::new(5, 2.0).unwrap();
let out = bbw.batch(&[100.0; 20]);
for v in out.iter().skip(4).flatten() {
assert_relative_eq!(*v, 0.0, epsilon = 1e-12);
}
}
/// 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.
let prices: Vec<f64> = (1..=60)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 8.0)
.collect();
let bbw_out = BollingerBandwidth::new(20, 2.0).unwrap().batch(&prices);
let bands_out = BollingerBands::new(20, 2.0).unwrap().batch(&prices);
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);
}
}
}
#[test]
fn output_is_non_negative() {
let mut bbw = BollingerBandwidth::new(20, 2.0).unwrap();
let prices: Vec<f64> = (1..=120)
.map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 12.0)
.collect();
for v in bbw.batch(&prices).into_iter().flatten() {
assert!(v >= 0.0, "bandwidth must be non-negative, got {v}");
}
}
#[test]
fn reset_clears_state() {
let mut bbw = BollingerBandwidth::new(5, 2.0).unwrap();
bbw.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
assert!(bbw.is_ready());
bbw.reset();
assert!(!bbw.is_ready());
assert_eq!(bbw.update(1.0), None);
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (1..=80)
.map(|i| 100.0 + (f64::from(i) * 0.3).cos() * 7.0)
.collect();
let batch = BollingerBandwidth::new(20, 2.0).unwrap().batch(&prices);
let mut b = BollingerBandwidth::new(20, 2.0).unwrap();
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
assert_eq!(batch, streamed);
}
}
+27
View File
@@ -10,6 +10,22 @@ use crate::traits::Indicator;
///
/// `CCI = (TP - SMA(TP)) / (0.015 * mean absolute deviation of TP)`, where
/// `TP = (high + low + close) / 3`.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, Cci};
///
/// let mut indicator = Cci::new(5).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// let base = 100.0 + f64::from(i);
/// let candle =
/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
/// last = indicator.update(candle);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct Cci {
period: usize,
@@ -122,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)
@@ -0,0 +1,242 @@
//! Chaikin Oscillator.
use crate::error::{Error, Result};
use crate::indicators::adl::Adl;
use crate::indicators::ema::Ema;
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Chaikin Oscillator — the MACD of the Accumulation/Distribution Line.
///
/// ```text
/// ChaikinOsc_t = EMA(ADL, fast)_t EMA(ADL, slow)_t
/// ```
///
/// It turns the unbounded, ever-drifting [`Adl`](crate::Adl) into a
/// zero-centred momentum oscillator: positive when short-term accumulation
/// outpaces the longer trend, negative when distribution leads. Because the
/// ADL emits from the very first candle, the slow EMA gates the first output —
/// the warmup period is exactly `slow`. Chaikin's classic configuration is
/// `fast = 3`, `slow = 10`.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, ChaikinOscillator};
///
/// let mut indicator = ChaikinOscillator::classic();
/// let mut last = None;
/// for i in 0..80 {
/// let base = 100.0 + f64::from(i);
/// let candle =
/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
/// last = indicator.update(candle);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct ChaikinOscillator {
adl: Adl,
fast: Ema,
slow: Ema,
fast_period: usize,
slow_period: usize,
}
impl ChaikinOscillator {
/// Construct a Chaikin Oscillator with explicit fast / slow EMA periods.
///
/// # Errors
/// Returns [`Error::PeriodZero`] if either period is zero, or
/// [`Error::InvalidPeriod`] if `fast >= slow`.
pub fn new(fast: usize, slow: usize) -> Result<Self> {
if fast == 0 || slow == 0 {
return Err(Error::PeriodZero);
}
if fast >= slow {
return Err(Error::InvalidPeriod {
message: "Chaikin Oscillator needs fast < slow",
});
}
Ok(Self {
adl: Adl::new(),
fast: Ema::new(fast)?,
slow: Ema::new(slow)?,
fast_period: fast,
slow_period: slow,
})
}
/// Chaikin's classic configuration: `EMA(ADL, 3) EMA(ADL, 10)`.
pub fn classic() -> Self {
Self::new(3, 10).expect("classic Chaikin Oscillator params are valid")
}
/// Configured `(fast, slow)` periods.
pub const fn periods(&self) -> (usize, usize) {
(self.fast_period, self.slow_period)
}
}
impl Indicator for ChaikinOscillator {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
// The ADL emits a value from the very first candle, so both EMAs are
// fed on every bar and warm up in parallel.
let adl = self.adl.update(candle)?;
let fast = self.fast.update(adl);
let slow = self.slow.update(adl);
Some(fast? - slow?)
}
fn reset(&mut self) {
self.adl.reset();
self.fast.reset();
self.slow.reset();
}
fn warmup_period(&self) -> usize {
// ADL is ready at candle 1; the slow EMA gates the first emission.
self.slow_period
}
fn is_ready(&self) -> bool {
self.fast.is_ready() && self.slow.is_ready()
}
fn name(&self) -> &'static str {
"ChaikinOscillator"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn cdl(base: f64, volume: f64, ts: i64) -> Candle {
Candle::new(base, base + 1.0, base - 1.0, base, volume, ts).unwrap()
}
fn flat(price: f64, ts: i64) -> Candle {
Candle::new(price, price, price, price, 100.0, ts).unwrap()
}
#[test]
fn matches_independent_adl_and_emas() {
// The oscillator must equal feeding a standalone ADL into two
// standalone EMAs and differencing them once both are ready.
let candles: Vec<Candle> = (0..80)
.map(|i| {
let mid = 100.0 + (i as f64 * 0.2).sin() * 6.0;
Candle::new(
mid,
mid + 1.5,
mid - 1.5,
mid + 0.3,
10.0 + (i % 6) as f64,
i,
)
.unwrap()
})
.collect();
let mut osc = ChaikinOscillator::classic();
let mut adl = Adl::new();
let mut fast = Ema::new(3).unwrap();
let mut slow = Ema::new(10).unwrap();
for (i, candle) in candles.iter().enumerate() {
let got = osc.update(*candle);
let a = adl.update(*candle).expect("ADL emits from candle 1");
let f = fast.update(a);
let s = slow.update(a);
match (f, s) {
(Some(fv), Some(sv)) => {
assert_relative_eq!(
got.expect("oscillator ready once slow EMA is"),
fv - sv,
epsilon = 1e-9
);
}
_ => assert!(got.is_none(), "must be None until slow EMA ready (i={i})"),
}
}
}
#[test]
fn flat_market_yields_zero() {
// A flat candle has zero money-flow volume, so the ADL never moves and
// both EMAs of a constant-zero series stay at zero.
let candles: Vec<Candle> = (0..60).map(|i| flat(10.0, i)).collect();
let mut osc = ChaikinOscillator::classic();
for v in osc.batch(&candles).into_iter().flatten() {
assert_relative_eq!(v, 0.0, epsilon = 1e-9);
}
}
#[test]
fn first_emission_matches_warmup_period() {
let candles: Vec<Candle> = (0..40).map(|i| cdl(100.0 + i as f64, 50.0, i)).collect();
let mut osc = ChaikinOscillator::classic();
let out = osc.batch(&candles);
assert_eq!(osc.warmup_period(), 10);
for (i, v) in out.iter().enumerate().take(9) {
assert!(v.is_none(), "index {i} must be None during warmup");
}
assert!(out[9].is_some(), "first value lands at warmup_period - 1");
}
#[test]
fn rejects_invalid_params() {
assert!(ChaikinOscillator::new(0, 10).is_err());
assert!(ChaikinOscillator::new(3, 0).is_err());
assert!(ChaikinOscillator::new(10, 3).is_err());
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();
let mut osc = ChaikinOscillator::classic();
osc.batch(&candles);
assert!(osc.is_ready());
osc.reset();
assert!(!osc.is_ready());
assert_eq!(osc.update(candles[0]), None);
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..80)
.map(|i| {
let mid = 100.0 + (i as f64 * 0.3).sin() * 8.0;
Candle::new(
mid,
mid + 2.0,
mid - 2.0,
mid + 0.5,
10.0 + (i % 5) as f64,
i,
)
.unwrap()
})
.collect();
let mut a = ChaikinOscillator::classic();
let mut b = ChaikinOscillator::classic();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,232 @@
//! Chaikin Volatility.
use crate::error::Result;
use crate::indicators::ema::Ema;
use crate::indicators::roc::Roc;
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Chaikin Volatility — the rate of change of a smoothed high-low spread.
///
/// ```text
/// spread_t = high_t low_t
/// smoothed_t = EMA(spread, ema_period)_t
/// ChaikinVol = 100 · (smoothed_t smoothed_{troc_period}) / smoothed_{troc_period}
/// ```
///
/// Marc Chaikin's volatility measure tracks not the *level* of the trading
/// range but how fast it is *widening or narrowing*. A rising value means
/// ranges are expanding (often near a top, as fear spikes); a falling value
/// means they are contracting (often a quiet, complacent market). The classic
/// configuration smooths the spread with a `10`-period EMA and takes its
/// `10`-period rate of change.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, ChaikinVolatility};
///
/// let mut indicator = ChaikinVolatility::new(10, 10).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// let base = 100.0 + f64::from(i);
/// let candle =
/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
/// last = indicator.update(candle);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct ChaikinVolatility {
ema: Ema,
roc: Roc,
ema_period: usize,
roc_period: usize,
}
impl ChaikinVolatility {
/// Construct a Chaikin Volatility with explicit EMA and rate-of-change
/// periods.
///
/// # Errors
/// Returns [`Error::PeriodZero`](crate::Error::PeriodZero) if either period
/// is zero.
pub fn new(ema_period: usize, roc_period: usize) -> Result<Self> {
Ok(Self {
ema: Ema::new(ema_period)?,
roc: Roc::new(roc_period)?,
ema_period,
roc_period,
})
}
/// Marc Chaikin's classic configuration: `EMA(10)` of the spread, `ROC(10)`.
pub fn classic() -> Self {
Self::new(10, 10).expect("classic Chaikin Volatility params are valid")
}
/// Configured `(ema_period, roc_period)`.
pub const fn periods(&self) -> (usize, usize) {
(self.ema_period, self.roc_period)
}
}
impl Indicator for ChaikinVolatility {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
let spread = candle.high - candle.low;
let smoothed = self.ema.update(spread)?;
self.roc.update(smoothed)
}
fn reset(&mut self) {
self.ema.reset();
self.roc.reset();
}
fn warmup_period(&self) -> usize {
// The EMA emits at candle `ema_period`; the ROC then needs
// `roc_period` more smoothed values to span its lookback.
self.ema_period + self.roc_period
}
fn is_ready(&self) -> bool {
self.roc.is_ready()
}
fn name(&self) -> &'static str {
"ChaikinVolatility"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn c(high: f64, low: f64, close: f64, ts: i64) -> Candle {
Candle::new(f64::midpoint(high, low), high, low, close, 1.0, ts).unwrap()
}
#[test]
fn constant_range_yields_zero() {
// A constant high-low spread smooths to a constant EMA, whose rate of
// change is zero.
let candles: Vec<Candle> = (0..60)
.map(|i| {
let base = 100.0 + i as f64;
c(base + 1.0, base - 1.0, base, i)
})
.collect();
let mut cv = ChaikinVolatility::new(10, 10).unwrap();
for v in cv.batch(&candles).into_iter().flatten() {
assert_relative_eq!(v, 0.0, epsilon = 1e-9);
}
}
#[test]
fn widening_range_reads_positive() {
// Each bar's range is strictly wider than the last -> expanding
// volatility -> positive Chaikin Volatility.
let candles: Vec<Candle> = (0..60)
.map(|i| {
let half = 1.0 + i as f64 * 0.1;
c(100.0 + half, 100.0 - half, 100.0, i)
})
.collect();
let mut cv = ChaikinVolatility::new(10, 10).unwrap();
for v in cv.batch(&candles).into_iter().flatten() {
assert!(v > 0.0, "an expanding range should read positive, got {v}");
}
}
#[test]
fn matches_independent_ema_and_roc() {
let candles: Vec<Candle> = (0..80)
.map(|i| {
let half = 1.0 + (i as f64 * 0.2).sin().abs() * 2.0;
c(100.0 + half, 100.0 - half, 100.0, i)
})
.collect();
let mut cv = ChaikinVolatility::new(10, 10).unwrap();
let mut ema = Ema::new(10).unwrap();
let mut roc = Roc::new(10).unwrap();
for (i, candle) in candles.iter().enumerate() {
let got = cv.update(*candle);
match ema.update(candle.high - candle.low) {
Some(e) => {
let want = roc.update(e);
assert_eq!(got, want, "i={i}");
}
None => assert!(got.is_none(), "i={i}"),
}
}
}
#[test]
fn first_emission_matches_warmup_period() {
let candles: Vec<Candle> = (0..40)
.map(|i| {
let base = 100.0 + i as f64;
c(base + 1.0, base - 1.0, base, i)
})
.collect();
let mut cv = ChaikinVolatility::new(5, 5).unwrap();
let out = cv.batch(&candles);
assert_eq!(cv.warmup_period(), 10);
for (i, v) in out.iter().enumerate().take(9) {
assert!(v.is_none(), "index {i} must be None during warmup");
}
assert!(out[9].is_some(), "first value lands at warmup_period - 1");
}
#[test]
fn rejects_zero_period() {
assert!(ChaikinVolatility::new(0, 10).is_err());
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)
.map(|i| {
let base = 100.0 + i as f64;
c(base + 1.0, base - 1.0, base, i)
})
.collect();
let mut cv = ChaikinVolatility::classic();
cv.batch(&candles);
assert!(cv.is_ready());
cv.reset();
assert!(!cv.is_ready());
assert_eq!(cv.update(candles[0]), None);
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..80)
.map(|i| {
let half = 1.0 + (i as f64 * 0.25).sin().abs() * 3.0;
c(100.0 + half, 100.0 - half, 100.0, i)
})
.collect();
let mut a = ChaikinVolatility::classic();
let mut b = ChaikinVolatility::classic();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,260 @@
//! Chande Kroll Stop.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::indicators::atr::Atr;
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Chande Kroll Stop output: the long-side and short-side stop levels.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ChandeKrollStopOutput {
/// Long-position stop — the lowest preliminary low-stop over `stop_period`.
pub stop_long: f64,
/// Short-position stop — the highest preliminary high-stop over `stop_period`.
pub stop_short: f64,
}
/// Chande Kroll Stop — Tushar Chande and Stanley Kroll's two-stage ATR stop.
///
/// ```text
/// preliminary (window p = atr_period, x = atr_multiplier):
/// high_stop = highest_high(p) x · ATR(p)
/// low_stop = lowest_low(p) + x · ATR(p)
///
/// final (window q = stop_period):
/// stop_short = highest(high_stop, q)
/// stop_long = lowest(low_stop, q)
/// ```
///
/// The first stage builds an ATR stop off the recent extreme, exactly like a
/// [`ChandelierExit`](crate::ChandelierExit); the second stage smooths it by
/// taking the most extreme preliminary stop over a shorter window, which keeps
/// the stop from whipsawing on a single wide bar. The classic configuration
/// from *The New Technical Trader* is `ATR(10)`, multiplier `1.0`, smoothing
/// window `9`.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, ChandeKrollStop};
///
/// let mut indicator = ChandeKrollStop::new(10, 1.0, 9).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// let base = 100.0 + f64::from(i);
/// let candle =
/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
/// last = indicator.update(candle);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct ChandeKrollStop {
atr_period: usize,
atr_multiplier: f64,
stop_period: usize,
atr: Atr,
highs: VecDeque<f64>,
lows: VecDeque<f64>,
high_stops: VecDeque<f64>,
low_stops: VecDeque<f64>,
}
impl ChandeKrollStop {
/// Construct a Chande Kroll Stop with explicit ATR and smoothing windows.
///
/// # Errors
/// Returns [`Error::PeriodZero`] if `atr_period` or `stop_period` is zero,
/// and [`Error::NonPositiveMultiplier`] if `atr_multiplier` is not strictly
/// positive and finite.
pub fn new(atr_period: usize, atr_multiplier: f64, stop_period: usize) -> Result<Self> {
if !atr_multiplier.is_finite() || atr_multiplier <= 0.0 {
return Err(Error::NonPositiveMultiplier);
}
if stop_period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
atr_period,
atr_multiplier,
stop_period,
atr: Atr::new(atr_period)?,
highs: VecDeque::with_capacity(atr_period),
lows: VecDeque::with_capacity(atr_period),
high_stops: VecDeque::with_capacity(stop_period),
low_stops: VecDeque::with_capacity(stop_period),
})
}
/// The classic configuration: `ATR(10)`, multiplier `1.0`, window `9`.
pub fn classic() -> Self {
Self::new(10, 1.0, 9).expect("classic Chande Kroll Stop params are valid")
}
/// Configured `(atr_period, atr_multiplier, stop_period)`.
pub const fn params(&self) -> (usize, f64, usize) {
(self.atr_period, self.atr_multiplier, self.stop_period)
}
}
impl Indicator for ChandeKrollStop {
type Input = Candle;
type Output = ChandeKrollStopOutput;
fn update(&mut self, candle: Candle) -> Option<ChandeKrollStopOutput> {
let atr = self.atr.update(candle);
if self.highs.len() == self.atr_period {
self.highs.pop_front();
self.lows.pop_front();
}
self.highs.push_back(candle.high);
self.lows.push_back(candle.low);
if self.highs.len() < self.atr_period {
return None;
}
// ATR(atr_period) becomes ready on exactly the candle that fills the
// preliminary window, so this never discards a value.
let atr = atr?;
let highest = self.highs.iter().copied().fold(f64::NEG_INFINITY, f64::max);
let lowest = self.lows.iter().copied().fold(f64::INFINITY, f64::min);
let high_stop = highest - self.atr_multiplier * atr;
let low_stop = lowest + self.atr_multiplier * atr;
if self.high_stops.len() == self.stop_period {
self.high_stops.pop_front();
self.low_stops.pop_front();
}
self.high_stops.push_back(high_stop);
self.low_stops.push_back(low_stop);
if self.high_stops.len() < self.stop_period {
return None;
}
let stop_short = self
.high_stops
.iter()
.copied()
.fold(f64::NEG_INFINITY, f64::max);
let stop_long = self.low_stops.iter().copied().fold(f64::INFINITY, f64::min);
Some(ChandeKrollStopOutput {
stop_long,
stop_short,
})
}
fn reset(&mut self) {
self.atr.reset();
self.highs.clear();
self.lows.clear();
self.high_stops.clear();
self.low_stops.clear();
}
fn warmup_period(&self) -> usize {
// The preliminary stop first appears on candle `atr_period`; the
// smoothing window then needs `stop_period` of them.
self.atr_period + self.stop_period - 1
}
fn is_ready(&self) -> bool {
self.high_stops.len() == self.stop_period
}
fn name(&self) -> &'static str {
"ChandeKrollStop"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn c(high: f64, low: f64, close: f64, ts: i64) -> Candle {
Candle::new(f64::midpoint(high, low), high, low, close, 1.0, ts).unwrap()
}
#[test]
fn reference_values_flat_market() {
// Flat candles H=11, L=9, C=10 -> TR=2 -> ATR=2; HH=11, LL=9.
// high_stop = 11 - 1·2 = 9; low_stop = 9 + 1·2 = 11.
// stop_short = highest(high_stop, q) = 9; stop_long = lowest(low_stop, q) = 11.
let candles: Vec<Candle> = (0..20).map(|i| c(11.0, 9.0, 10.0, i)).collect();
let mut cks = ChandeKrollStop::new(5, 1.0, 3).unwrap();
let last = cks.batch(&candles).into_iter().flatten().last().unwrap();
assert_relative_eq!(last.stop_short, 9.0, epsilon = 1e-12);
assert_relative_eq!(last.stop_long, 11.0, epsilon = 1e-12);
}
#[test]
fn first_emission_matches_warmup_period() {
let candles: Vec<Candle> = (0..16)
.map(|i| {
let base = 100.0 + i as f64;
c(base + 1.0, base - 1.0, base, i)
})
.collect();
let mut cks = ChandeKrollStop::new(4, 1.0, 3).unwrap();
let out = cks.batch(&candles);
assert_eq!(cks.warmup_period(), 6);
for (i, v) in out.iter().enumerate().take(5) {
assert!(v.is_none(), "index {i} must be None during warmup");
}
assert!(out[5].is_some(), "first value lands at warmup_period - 1");
}
#[test]
fn rejects_invalid_params() {
assert!(ChandeKrollStop::new(0, 1.0, 9).is_err());
assert!(ChandeKrollStop::new(10, 1.0, 0).is_err());
assert!(ChandeKrollStop::new(10, 0.0, 9).is_err());
assert!(ChandeKrollStop::new(10, -1.0, 9).is_err());
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)
.map(|i| {
let base = 100.0 + i as f64;
c(base + 1.0, base - 1.0, base, i)
})
.collect();
let mut cks = ChandeKrollStop::classic();
cks.batch(&candles);
assert!(cks.is_ready());
cks.reset();
assert!(!cks.is_ready());
assert_eq!(cks.update(candles[0]), None);
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..80)
.map(|i| {
let mid = 100.0 + (i as f64 * 0.3).sin() * 8.0;
c(mid + 1.5, mid - 1.5, mid + 0.5, i)
})
.collect();
let mut a = ChandeKrollStop::classic();
let mut b = ChandeKrollStop::classic();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,242 @@
//! Chandelier Exit.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::indicators::atr::Atr;
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Chandelier Exit output: the long-side and short-side trailing stops.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ChandelierExitOutput {
/// Long-position stop: `highest_high multiplier · ATR`.
pub long_stop: f64,
/// Short-position stop: `lowest_low + multiplier · ATR`.
pub short_stop: f64,
}
/// Chandelier Exit — Chuck LeBeau's ATR trailing stop, hung from the highest
/// high (for longs) or the lowest low (for shorts) of the lookback window.
///
/// ```text
/// long_stop = highest_high(period) multiplier · ATR(period)
/// short_stop = lowest_low(period) + multiplier · ATR(period)
/// ```
///
/// A long position is exited when price closes below `long_stop`; a short
/// when it closes above `short_stop`. Because the stop hangs a fixed number
/// of ATRs off the extreme of the window — like a chandelier off a ceiling —
/// it follows price up but never loosens. LeBeau's classic configuration is a
/// `22`-bar window with a `3.0` multiplier.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, ChandelierExit};
///
/// let mut indicator = ChandelierExit::new(22, 3.0).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// let base = 100.0 + f64::from(i);
/// let candle =
/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
/// last = indicator.update(candle);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct ChandelierExit {
period: usize,
multiplier: f64,
atr: Atr,
highs: VecDeque<f64>,
lows: VecDeque<f64>,
}
impl ChandelierExit {
/// Construct a Chandelier Exit with an explicit window and band multiplier.
///
/// # Errors
/// Returns [`Error::PeriodZero`] if `period == 0` and
/// [`Error::NonPositiveMultiplier`] if `multiplier` is not strictly
/// positive and finite.
pub fn new(period: usize, multiplier: f64) -> Result<Self> {
if !multiplier.is_finite() || multiplier <= 0.0 {
return Err(Error::NonPositiveMultiplier);
}
Ok(Self {
period,
multiplier,
atr: Atr::new(period)?,
highs: VecDeque::with_capacity(period),
lows: VecDeque::with_capacity(period),
})
}
/// LeBeau's classic configuration: a `22`-bar window, `3.0` multiplier.
pub fn classic() -> Self {
Self::new(22, 3.0).expect("classic Chandelier Exit params are valid")
}
/// Configured `(period, multiplier)`.
pub const fn params(&self) -> (usize, f64) {
(self.period, self.multiplier)
}
}
impl Indicator for ChandelierExit {
type Input = Candle;
type Output = ChandelierExitOutput;
fn update(&mut self, candle: Candle) -> Option<ChandelierExitOutput> {
let atr = self.atr.update(candle);
if self.highs.len() == self.period {
self.highs.pop_front();
self.lows.pop_front();
}
self.highs.push_back(candle.high);
self.lows.push_back(candle.low);
if self.highs.len() < self.period {
return None;
}
// ATR(period) becomes ready on exactly the candle that fills the
// highest-high / lowest-low window, so this never discards a value.
let atr = atr?;
let highest = self.highs.iter().copied().fold(f64::NEG_INFINITY, f64::max);
let lowest = self.lows.iter().copied().fold(f64::INFINITY, f64::min);
Some(ChandelierExitOutput {
long_stop: highest - self.multiplier * atr,
short_stop: lowest + self.multiplier * atr,
})
}
fn reset(&mut self) {
self.atr.reset();
self.highs.clear();
self.lows.clear();
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.highs.len() == self.period
}
fn name(&self) -> &'static str {
"ChandelierExit"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn c(high: f64, low: f64, close: f64, ts: i64) -> Candle {
Candle::new(f64::midpoint(high, low), high, low, close, 1.0, ts).unwrap()
}
#[test]
fn reference_values_flat_market() {
// Flat candles H=11, L=9, C=10 -> TR=2 -> ATR=2; HH=11, LL=9.
// long_stop = 11 - 3·2 = 5; short_stop = 9 + 3·2 = 15.
let candles: Vec<Candle> = (0..20).map(|i| c(11.0, 9.0, 10.0, i)).collect();
let mut ce = ChandelierExit::new(5, 3.0).unwrap();
let last = ce.batch(&candles).into_iter().flatten().last().unwrap();
assert_relative_eq!(last.long_stop, 5.0, epsilon = 1e-12);
assert_relative_eq!(last.short_stop, 15.0, epsilon = 1e-12);
}
#[test]
fn long_stop_below_highest_short_stop_above_lowest() {
let candles: Vec<Candle> = (0..120)
.map(|i| {
let mid = 100.0 + (i as f64 * 0.2).sin() * 9.0;
c(mid + 1.5, mid - 1.5, mid + 0.4, i)
})
.collect();
let mut ce = ChandelierExit::classic();
for (i, o) in ce.batch(&candles).into_iter().enumerate() {
if let Some(o) = o {
// The window's extremes bound the stops from one side.
let win = &candles[i + 1 - 22..=i];
let hh = win.iter().map(|c| c.high).fold(f64::NEG_INFINITY, f64::max);
let ll = win.iter().map(|c| c.low).fold(f64::INFINITY, f64::min);
assert!(o.long_stop <= hh + 1e-9);
assert!(o.short_stop >= ll - 1e-9);
}
}
}
#[test]
fn first_emission_matches_warmup_period() {
let candles: Vec<Candle> = (0..20)
.map(|i| {
let base = 100.0 + i as f64;
c(base + 1.0, base - 1.0, base, i)
})
.collect();
let mut ce = ChandelierExit::new(8, 3.0).unwrap();
let out = ce.batch(&candles);
assert_eq!(ce.warmup_period(), 8);
for (i, v) in out.iter().enumerate().take(7) {
assert!(v.is_none(), "index {i} must be None during warmup");
}
assert!(out[7].is_some(), "first value lands at warmup_period - 1");
}
#[test]
fn rejects_invalid_params() {
assert!(ChandelierExit::new(0, 3.0).is_err());
assert!(ChandelierExit::new(22, 0.0).is_err());
assert!(ChandelierExit::new(22, -1.0).is_err());
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)
.map(|i| {
let base = 100.0 + i as f64;
c(base + 1.0, base - 1.0, base, i)
})
.collect();
let mut ce = ChandelierExit::classic();
ce.batch(&candles);
assert!(ce.is_ready());
ce.reset();
assert!(!ce.is_ready());
assert_eq!(ce.update(candles[0]), None);
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..80)
.map(|i| {
let mid = 100.0 + (i as f64 * 0.3).sin() * 8.0;
c(mid + 1.5, mid - 1.5, mid + 0.5, i)
})
.collect();
let mut a = ChandelierExit::classic();
let mut b = ChandelierExit::classic();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,229 @@
//! Choppiness Index.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Choppiness Index — is the market trending or just chopping sideways?
///
/// ```text
/// CI = 100 · log10( Σ(TR, n) / (highest_high(n) lowest_low(n)) ) / log10(n)
/// ```
///
/// The ratio compares the *distance price actually travelled* (the summed true
/// range) with the *net ground it covered* (the high-low span of the window).
/// A clean trend travels almost exactly its span, so the ratio is near `1` and
/// `CI` near `0`; a choppy market criss-crosses far more than its span, so the
/// ratio is large and `CI` climbs toward `100`. The conventional reading is
/// `CI > 61.8` ranging, `CI < 38.2` trending. A perfectly flat window yields
/// `100` by convention.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, ChoppinessIndex};
///
/// let mut indicator = ChoppinessIndex::new(14).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// let base = 100.0 + f64::from(i);
/// let candle =
/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
/// last = indicator.update(candle);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct ChoppinessIndex {
period: usize,
log_n: f64,
prev_close: Option<f64>,
tr_window: VecDeque<f64>,
tr_sum: f64,
highs: VecDeque<f64>,
lows: VecDeque<f64>,
}
impl ChoppinessIndex {
/// Construct a new Choppiness Index over `period` bars.
///
/// # Errors
/// Returns [`Error::InvalidPeriod`] if `period < 2` — the `log10(period)`
/// denominator is zero for `period == 1` and undefined for `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period < 2 {
return Err(Error::InvalidPeriod {
message: "choppiness index needs period >= 2",
});
}
Ok(Self {
period,
log_n: (period as f64).log10(),
prev_close: None,
tr_window: VecDeque::with_capacity(period),
tr_sum: 0.0,
highs: VecDeque::with_capacity(period),
lows: VecDeque::with_capacity(period),
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for ChoppinessIndex {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
let tr = candle.true_range(self.prev_close);
self.prev_close = Some(candle.close);
if self.tr_window.len() == self.period {
self.tr_sum -= self.tr_window.pop_front().expect("non-empty");
self.highs.pop_front();
self.lows.pop_front();
}
self.tr_window.push_back(tr);
self.tr_sum += tr;
self.highs.push_back(candle.high);
self.lows.push_back(candle.low);
if self.tr_window.len() < self.period {
return None;
}
let highest = self.highs.iter().copied().fold(f64::NEG_INFINITY, f64::max);
let lowest = self.lows.iter().copied().fold(f64::INFINITY, f64::min);
let span = highest - lowest;
if span == 0.0 {
// A perfectly flat window: maximal choppiness by convention.
return Some(100.0);
}
Some(100.0 * (self.tr_sum / span).log10() / self.log_n)
}
fn reset(&mut self) {
self.prev_close = None;
self.tr_window.clear();
self.tr_sum = 0.0;
self.highs.clear();
self.lows.clear();
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.tr_window.len() == self.period
}
fn name(&self) -> &'static str {
"ChoppinessIndex"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn c(high: f64, low: f64, close: f64, ts: i64) -> Candle {
Candle::new(f64::midpoint(high, low), high, low, close, 1.0, ts).unwrap()
}
#[test]
fn reference_value_equal_range_bars() {
// Two H=11 L=9 C=10 bars: TR = 2 each, ΣTR = 4; span = 11 - 9 = 2.
// CI = 100 · log10(4 / 2) / log10(2) = 100.
let mut ci = ChoppinessIndex::new(2).unwrap();
let out = ci.batch(&[c(11.0, 9.0, 10.0, 0), c(11.0, 9.0, 10.0, 1)]);
assert!(out[0].is_none());
assert_relative_eq!(out[1].unwrap(), 100.0, epsilon = 1e-9);
}
#[test]
fn flat_window_yields_hundred() {
let candles: Vec<Candle> = (0..20).map(|i| c(10.0, 10.0, 10.0, i)).collect();
let mut ci = ChoppinessIndex::new(14).unwrap();
for v in ci.batch(&candles).into_iter().flatten() {
assert_relative_eq!(v, 100.0, epsilon = 1e-9);
}
}
#[test]
fn steady_trend_reads_low() {
// A clean one-directional march travels close to its span -> low CI.
let candles: Vec<Candle> = (0..60)
.map(|i| {
let base = 100.0 + i as f64;
c(base + 1.0, base - 1.0, base, i)
})
.collect();
let mut ci = ChoppinessIndex::new(14).unwrap();
for v in ci.batch(&candles).into_iter().flatten() {
assert!(v < 50.0, "a steady trend should read below 50, got {v}");
assert!(v >= 0.0, "CI must be non-negative, got {v}");
}
}
#[test]
fn first_emission_matches_warmup_period() {
let candles: Vec<Candle> = (0..20).map(|i| c(11.0, 9.0, 10.0, i)).collect();
let mut ci = ChoppinessIndex::new(8).unwrap();
let out = ci.batch(&candles);
assert_eq!(ci.warmup_period(), 8);
for (i, v) in out.iter().enumerate().take(7) {
assert!(v.is_none(), "index {i} must be None during warmup");
}
assert!(out[7].is_some(), "first value lands at warmup_period - 1");
}
#[test]
fn rejects_period_below_two() {
assert!(ChoppinessIndex::new(0).is_err());
assert!(ChoppinessIndex::new(1).is_err());
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();
let mut ci = ChoppinessIndex::new(14).unwrap();
ci.batch(&candles);
assert!(ci.is_ready());
ci.reset();
assert!(!ci.is_ready());
assert_eq!(ci.update(candles[0]), None);
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..80)
.map(|i| {
let mid = 100.0 + (i as f64 * 0.3).sin() * 8.0;
c(mid + 1.5, mid - 1.5, mid + 0.5, i)
})
.collect();
let mut a = ChoppinessIndex::new(14).unwrap();
let mut b = ChoppinessIndex::new(14).unwrap();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
+280
View File
@@ -0,0 +1,280 @@
//! Chaikin Money Flow (CMF).
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Chaikin Money Flow — Marc Chaikin's `period`-window money-flow oscillator.
///
/// Each bar produces a *money-flow volume*: the bar's volume weighted by where
/// the close fell within its range (the same money-flow multiplier the
/// [`Adl`](crate::Adl) uses). CMF is the ratio of summed money-flow volume to
/// summed volume over the lookback window:
///
/// ```text
/// MFM_t = ((close low) (high close)) / (high low) (1..+1)
/// MFV_t = MFM_t · volume_t
/// CMF_t = Σ(MFV, period) / Σ(volume, period)
/// ```
///
/// The result lives in `[1, +1]`: sustained closes near the high push CMF
/// toward `+1` (accumulation), near the low toward `1` (distribution). A bar
/// with `high == low` carries no positional information and contributes a
/// money-flow volume of `0`; a window whose total volume is zero yields `0.0`
/// by convention.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, ChaikinMoneyFlow};
///
/// let mut indicator = ChaikinMoneyFlow::new(20).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// let base = 100.0 + f64::from(i);
/// let candle =
/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
/// last = indicator.update(candle);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct ChaikinMoneyFlow {
period: usize,
mfv_window: VecDeque<f64>,
vol_window: VecDeque<f64>,
mfv_sum: f64,
vol_sum: f64,
}
impl ChaikinMoneyFlow {
/// Construct a new Chaikin Money Flow over `period` bars.
///
/// # Errors
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
mfv_window: VecDeque::with_capacity(period),
vol_window: VecDeque::with_capacity(period),
mfv_sum: 0.0,
vol_sum: 0.0,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for ChaikinMoneyFlow {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
let range = candle.high - candle.low;
let mfv = if range == 0.0 {
// A zero-range bar carries no positional information.
0.0
} else {
let mfm = ((candle.close - candle.low) - (candle.high - candle.close)) / range;
mfm * candle.volume
};
if self.mfv_window.len() == self.period {
self.mfv_sum -= self.mfv_window.pop_front().expect("non-empty");
self.vol_sum -= self.vol_window.pop_front().expect("non-empty");
}
self.mfv_window.push_back(mfv);
self.vol_window.push_back(candle.volume);
self.mfv_sum += mfv;
self.vol_sum += candle.volume;
if self.mfv_window.len() < self.period {
return None;
}
if self.vol_sum == 0.0 {
// No volume traded across the whole window — no flow to report.
return Some(0.0);
}
Some(self.mfv_sum / self.vol_sum)
}
fn reset(&mut self) {
self.mfv_window.clear();
self.vol_window.clear();
self.mfv_sum = 0.0;
self.vol_sum = 0.0;
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.mfv_window.len() == self.period
}
fn name(&self) -> &'static str {
"CMF"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn candle(open: f64, high: f64, low: f64, close: f64, volume: f64, ts: i64) -> Candle {
Candle::new(open, high, low, close, volume, ts).unwrap()
}
#[test]
fn reference_values() {
// CMF(2): bar 1 closes at the high -> MFM = +1, MFV = +100.
// bar 2 closes mid-range -> MFM = 0, MFV = 0.
// CMF = (100 + 0) / (100 + 100) = 0.5.
let mut cmf = ChaikinMoneyFlow::new(2).unwrap();
let out = cmf.batch(&[
candle(8.0, 10.0, 8.0, 10.0, 100.0, 0),
candle(10.0, 12.0, 8.0, 10.0, 100.0, 1),
]);
assert!(out[0].is_none());
assert_relative_eq!(out[1].unwrap(), 0.5, epsilon = 1e-12);
}
#[test]
fn stays_within_unit_range() {
let candles: Vec<Candle> = (0..120)
.map(|i| {
let mid = 100.0 + (i as f64 * 0.25).sin() * 10.0;
candle(
mid,
mid + 3.0,
mid - 3.0,
mid + (i as f64 * 0.5).cos() * 2.0,
10.0 + (i % 7) as f64,
i,
)
})
.collect();
let mut cmf = ChaikinMoneyFlow::new(20).unwrap();
for v in cmf.batch(&candles).into_iter().flatten() {
assert!((-1.0..=1.0).contains(&v), "CMF {v} outside [-1, 1]");
}
}
#[test]
fn closes_at_high_yield_cmf_one() {
// Every bar closes on its high -> MFM = +1 -> CMF saturates at +1.
let candles: Vec<Candle> = (0..30)
.map(|i| candle(9.0, 10.0, 8.0, 10.0, 50.0, i))
.collect();
let mut cmf = ChaikinMoneyFlow::new(14).unwrap();
for v in cmf.batch(&candles).into_iter().flatten() {
assert_relative_eq!(v, 1.0, epsilon = 1e-12);
}
}
#[test]
fn zero_volume_window_yields_zero() {
// A window with no traded volume divides 0/0 — defined as 0.0.
let candles: Vec<Candle> = (0..20)
.map(|i| candle(9.0, 10.0, 8.0, 10.0, 0.0, i))
.collect();
let mut cmf = ChaikinMoneyFlow::new(10).unwrap();
for v in cmf.batch(&candles).into_iter().flatten() {
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
}
}
#[test]
fn first_value_on_period_th_candle() {
let candles: Vec<Candle> = (0..10)
.map(|i| candle(9.0, 10.0, 8.0, 9.5, 50.0, i))
.collect();
let mut cmf = ChaikinMoneyFlow::new(5).unwrap();
let out = cmf.batch(&candles);
for (i, v) in out.iter().enumerate().take(4) {
assert!(v.is_none(), "index {i} must be None during warmup");
}
assert!(out[4].is_some(), "first CMF lands at index period - 1");
assert_eq!(cmf.warmup_period(), 5);
}
#[test]
fn rejects_zero_period() {
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)
.map(|i| candle(9.0, 11.0, 8.0, 10.0, 50.0, i))
.collect();
let mut cmf = ChaikinMoneyFlow::new(10).unwrap();
cmf.batch(&candles);
assert!(cmf.is_ready());
cmf.reset();
assert!(!cmf.is_ready());
assert_eq!(cmf.update(candles[0]), None);
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..80)
.map(|i| {
let mid = 100.0 + (i as f64 * 0.3).sin() * 8.0;
candle(
mid,
mid + 2.0,
mid - 2.0,
mid + 0.5,
10.0 + (i % 5) as f64,
i,
)
})
.collect();
let mut a = ChaikinMoneyFlow::new(20).unwrap();
let mut b = ChaikinMoneyFlow::new(20).unwrap();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
+233
View File
@@ -0,0 +1,233 @@
//! Chande Momentum Oscillator.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Chande Momentum Oscillator — Tushar Chande's bounded momentum gauge.
///
/// Over the last `period` price *changes* it sums the gains and the losses
/// separately and reports:
///
/// ```text
/// CMO = 100 · (Σ gains Σ losses) / (Σ gains + Σ losses)
/// ```
///
/// The result is bounded in `[100, 100]`: `+100` is a window of pure gains,
/// `100` a window of pure losses, `0` a perfect balance. Unlike RSI the sums
/// are *unsmoothed* — every change in the window carries equal weight — so CMO
/// reacts faster and swings wider.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, Cmo};
///
/// let mut indicator = Cmo::new(14).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// last = indicator.update(100.0 + f64::from(i));
/// }
/// assert_eq!(last, Some(100.0)); // pure uptrend saturates at +100
/// ```
#[derive(Debug, Clone)]
pub struct Cmo {
period: usize,
prev_price: Option<f64>,
/// Rolling window of `(gain, loss)` pairs, oldest at the front.
window: VecDeque<(f64, f64)>,
sum_gain: f64,
sum_loss: f64,
current: Option<f64>,
}
impl Cmo {
/// Construct a new CMO with the given period.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
prev_price: None,
window: VecDeque::with_capacity(period),
sum_gain: 0.0,
sum_loss: 0.0,
current: None,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.current
}
}
impl Indicator for Cmo {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() {
// Non-finite input is ignored; state is left untouched.
return self.current;
}
let Some(prev) = self.prev_price else {
self.prev_price = Some(input);
return None;
};
self.prev_price = Some(input);
let change = input - prev;
let gain = change.max(0.0);
let loss = (-change).max(0.0);
if self.window.len() == self.period {
let (old_gain, old_loss) = self.window.pop_front().expect("window is non-empty");
self.sum_gain -= old_gain;
self.sum_loss -= old_loss;
}
self.window.push_back((gain, loss));
self.sum_gain += gain;
self.sum_loss += loss;
if self.window.len() < self.period {
return None;
}
let denom = self.sum_gain + self.sum_loss;
let cmo = if denom == 0.0 {
// A flat window (no gains and no losses): momentum is exactly zero.
0.0
} else {
100.0 * (self.sum_gain - self.sum_loss) / denom
};
self.current = Some(cmo);
Some(cmo)
}
fn reset(&mut self) {
self.prev_price = None;
self.window.clear();
self.sum_gain = 0.0;
self.sum_loss = 0.0;
self.current = None;
}
fn warmup_period(&self) -> usize {
self.period + 1
}
fn is_ready(&self) -> bool {
self.current.is_some()
}
fn name(&self) -> &'static str {
"CMO"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn new_rejects_zero_period() {
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.
// Σgain = 3, Σloss = 1 -> 100·(31)/(3+1) = 50.
let mut cmo = Cmo::new(3).unwrap();
let out = cmo.batch(&[10.0, 11.0, 10.0, 12.0]);
assert_eq!(cmo.warmup_period(), 4);
assert_eq!(out[0], None);
assert_eq!(out[2], None);
assert_relative_eq!(out[3].unwrap(), 50.0, epsilon = 1e-12);
}
#[test]
fn pure_uptrend_saturates_at_plus_100() {
let mut cmo = Cmo::new(5).unwrap();
let out = cmo.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
for v in out.iter().skip(6).flatten() {
assert_relative_eq!(*v, 100.0, epsilon = 1e-12);
}
}
#[test]
fn pure_downtrend_saturates_at_minus_100() {
let mut cmo = Cmo::new(5).unwrap();
let out = cmo.batch(&(1..=20).rev().map(f64::from).collect::<Vec<_>>());
for v in out.iter().skip(6).flatten() {
assert_relative_eq!(*v, -100.0, epsilon = 1e-12);
}
}
#[test]
fn constant_series_yields_zero() {
let mut cmo = Cmo::new(5).unwrap();
let out = cmo.batch(&[42.0; 20]);
for v in out.iter().skip(6).flatten() {
assert_relative_eq!(*v, 0.0, epsilon = 1e-12);
}
}
#[test]
fn ignores_non_finite_input() {
let mut cmo = Cmo::new(3).unwrap();
let out = cmo.batch(&[10.0, 11.0, 10.0, 12.0]);
let ready = out[3].expect("CMO(3) ready after four inputs");
assert_eq!(cmo.update(f64::NAN), Some(ready));
assert_eq!(cmo.update(f64::INFINITY), Some(ready));
}
#[test]
fn reset_clears_state() {
let mut cmo = Cmo::new(3).unwrap();
cmo.batch(&[10.0, 11.0, 12.0, 13.0, 14.0]);
assert!(cmo.is_ready());
cmo.reset();
assert!(!cmo.is_ready());
assert_eq!(cmo.update(10.0), None);
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (1..=60)
.map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 6.0)
.collect();
let batch = Cmo::new(9).unwrap().batch(&prices);
let mut b = Cmo::new(9).unwrap();
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,254 @@
//! Coppock Curve.
use crate::error::{Error, Result};
use crate::traits::Indicator;
use super::{Roc, Wma};
/// Coppock Curve — Edwin Coppock's long-term momentum indicator.
///
/// The Coppock Curve is a weighted moving average of the sum of two rates of
/// change:
///
/// ```text
/// Coppock = WMA( ROC(long) + ROC(short), wma_period )
/// ```
///
/// Coppock designed it (1962) as a long-horizon buy signal for stock indices:
/// on a monthly chart with the conventional `(long = 14, short = 11,
/// wma_period = 10)`, a turn upward from below zero has historically marked
/// the start of a new bull phase. The two ROCs blend a slightly longer and a
/// slightly shorter momentum horizon; the WMA smooths the result.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, Coppock};
///
/// let mut indicator = Coppock::new(14, 11, 10).unwrap();
/// let mut last = None;
/// for i in 0..120 {
/// last = indicator.update(100.0 + f64::from(i));
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct Coppock {
roc_long_period: usize,
roc_short_period: usize,
wma_period: usize,
roc_long: Roc,
roc_short: Roc,
wma: Wma,
current: Option<f64>,
}
impl Coppock {
/// Construct a new Coppock Curve with the two ROC periods and the WMA period.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if any period is `0`.
pub fn new(roc_long_period: usize, roc_short_period: usize, wma_period: usize) -> Result<Self> {
if roc_long_period == 0 || roc_short_period == 0 || wma_period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
roc_long_period,
roc_short_period,
wma_period,
roc_long: Roc::new(roc_long_period)?,
roc_short: Roc::new(roc_short_period)?,
wma: Wma::new(wma_period)?,
current: None,
})
}
/// The `(roc_long, roc_short, wma)` periods.
pub const fn periods(&self) -> (usize, usize, usize) {
(self.roc_long_period, self.roc_short_period, self.wma_period)
}
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.current
}
}
impl Indicator for Coppock {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() {
// Non-finite input is ignored; no component is advanced.
return self.current;
}
let long = self.roc_long.update(input);
let short = self.roc_short.update(input);
let result = match (long, short) {
(Some(l), Some(s)) => self.wma.update(l + s),
_ => None,
};
if result.is_some() {
self.current = result;
}
result
}
fn reset(&mut self) {
self.roc_long.reset();
self.roc_short.reset();
self.wma.reset();
self.current = None;
}
fn warmup_period(&self) -> usize {
// Let `L = max(roc_long_period, roc_short_period)` and `W = wma_period`.
// Both ROCs need `period + 1` inputs to emit; the slower one therefore
// first emits at **0-based index L** (= the `(L + 1)`-th input). From
// that bar onward both ROCs feed the WMA in lock-step, so the WMA
// sees its `W`-th input at 0-based index `L + W 1` — the first bar
// it emits. `warmup_period` is the 1-based count of inputs needed for
// the first `Some` value, which is `(L + W 1) + 1 = L + W`.
//
// Worked example for `Coppock::new(6, 4, 3)`:
// - ROC(6).first_some at index 6 (the 7th input).
// - ROC(4).first_some at index 4 (the 5th input). Both available
// from index 6 onward.
// - WMA(3) consumes 3 inputs at indices 6, 7, 8 → first WMA `Some`
// at index 8 (the 9th input). `warmup_period() == 9`.
self.roc_long_period.max(self.roc_short_period) + self.wma_period
}
fn is_ready(&self) -> bool {
self.current.is_some()
}
fn name(&self) -> &'static str {
"Coppock"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn new_rejects_zero_period() {
assert!(matches!(Coppock::new(0, 11, 10), Err(Error::PeriodZero)));
assert!(matches!(Coppock::new(14, 0, 10), Err(Error::PeriodZero)));
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();
assert_eq!(c.warmup_period(), 9);
let out = c.batch(&(1..=40).map(f64::from).collect::<Vec<_>>());
for v in out.iter().take(8) {
assert!(v.is_none());
}
assert!(out[8].is_some());
}
/// `warmup_period()` equals the 1-based index of the first emitted
/// `Some` for every legal parameter combination — including the
/// parameter set `(roc_long=4, roc_short=2, wma=3)` that an external
/// audit claimed would prove the formula off by one. It does not: the
/// slower ROC first emits at 0-based index 4, the WMA needs 3 such inputs
/// and emits at 0-based index 6 (the 7th input), which is what
/// `roc_long.max(roc_short) + wma = max(4, 2) + 3 = 7` reports.
#[test]
fn warmup_period_matches_first_some_for_every_parameter_set() {
let prices: Vec<f64> = (1..=80).map(|i| 100.0 + f64::from(i)).collect();
for &(long, short, wma) in &[(6, 4, 3), (14, 11, 10), (4, 2, 3), (10, 3, 5), (3, 3, 3)] {
let mut c = Coppock::new(long, short, wma).unwrap();
let warmup = c.warmup_period();
let out = c.batch(&prices);
for (i, v) in out.iter().enumerate().take(warmup - 1) {
assert!(
v.is_none(),
"Coppock({long}, {short}, {wma}): index {i} expected None during warmup, got {v:?}"
);
}
assert!(
out[warmup - 1].is_some(),
"Coppock({long}, {short}, {wma}): warmup_period() = {warmup} but the warmup index is None",
);
}
}
#[test]
fn constant_series_yields_zero() {
// Both ROCs are 0 on a flat series, so the WMA of zeros is 0.
let mut c = Coppock::new(6, 4, 3).unwrap();
let out = c.batch(&[100.0; 40]);
for v in out.iter().skip(c.warmup_period() - 1).flatten() {
assert_relative_eq!(*v, 0.0, epsilon = 1e-12);
}
}
#[test]
fn uptrend_is_positive() {
// A steady uptrend has positive ROCs, so the Coppock Curve is positive.
let mut c = Coppock::new(14, 11, 10).unwrap();
let prices: Vec<f64> = (1..=120).map(|i| 100.0 * 1.01_f64.powi(i)).collect();
let out = c.batch(&prices);
let last = out.iter().rev().flatten().next().unwrap();
assert!(
*last > 0.0,
"uptrend Coppock should be positive, got {last}"
);
}
#[test]
fn ignores_non_finite_input() {
let mut c = Coppock::new(6, 4, 3).unwrap();
let out = c.batch(&(1..=40).map(f64::from).collect::<Vec<_>>());
let last = *out.last().unwrap();
assert!(last.is_some());
assert_eq!(c.update(f64::NAN), last);
assert_eq!(c.update(f64::INFINITY), last);
}
#[test]
fn reset_clears_state() {
let mut c = Coppock::new(6, 4, 3).unwrap();
c.batch(&(1..=40).map(f64::from).collect::<Vec<_>>());
assert!(c.is_ready());
c.reset();
assert!(!c.is_ready());
assert_eq!(c.update(1.0), None);
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (1..=120)
.map(|i| 100.0 + (f64::from(i) * 0.2).sin() * 10.0)
.collect();
let batch = Coppock::new(14, 11, 10).unwrap().batch(&prices);
let mut b = Coppock::new(14, 11, 10).unwrap();
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
assert_eq!(batch, streamed);
}
}
+26
View File
@@ -8,6 +8,19 @@ use crate::traits::Indicator;
///
/// Designed by Patrick Mulloy to reduce the lag of a single EMA while keeping
/// the smoothing benefit.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, Dema};
///
/// let mut indicator = Dema::new(3).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// last = indicator.update(100.0 + f64::from(i));
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct Dema {
ema1: Ema,
@@ -114,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");
}
}
+41 -1
View File
@@ -18,6 +18,22 @@ pub struct DonchianOutput {
}
/// Donchian Channels: rolling highest high / lowest low envelopes.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, Donchian};
///
/// let mut indicator = Donchian::new(5).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// let base = 100.0 + f64::from(i);
/// let candle =
/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
/// last = indicator.update(candle);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct Donchian {
period: usize,
@@ -67,7 +83,7 @@ impl Indicator for Donchian {
.fold(f64::INFINITY, f64::min);
Some(DonchianOutput {
upper,
middle: (upper + lower) / 2.0,
middle: f64::midpoint(upper, lower),
lower,
})
}
@@ -138,4 +154,28 @@ mod tests {
fn rejects_zero_period() {
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)
.map(|i| c(f64::from(i) + 1.0, f64::from(i) - 1.0, f64::from(i)))
.collect();
let mut d = Donchian::new(5).unwrap();
d.batch(&candles);
assert!(d.is_ready());
d.reset();
assert!(!d.is_ready());
assert_eq!(d.update(candles[0]), None);
}
}
+224
View File
@@ -0,0 +1,224 @@
//! Detrended Price Oscillator.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Detrended Price Oscillator — strips the trend out of price to expose its
/// shorter cycles.
///
/// Instead of comparing price to a *current* moving average, DPO compares a
/// **past** price — shifted back by `period / 2 + 1` bars — to the moving
/// average of the window:
///
/// ```text
/// shift = period / 2 + 1
/// DPO_t = price_{t shift} SMA(period)_t
/// ```
///
/// Because the price is taken from roughly half a cycle back, the dominant
/// trend cancels out and what remains oscillates around zero — making the
/// peak-to-peak cycle length easy to read. DPO is **not** a momentum
/// indicator and is not meant to track the latest bar.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, Dpo};
///
/// let mut indicator = Dpo::new(20).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// last = indicator.update(100.0 + (f64::from(i) * 0.3).sin() * 10.0);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct Dpo {
period: usize,
shift: usize,
/// Window of the most recent `capacity` prices, oldest at the front.
capacity: usize,
window: VecDeque<f64>,
sum: f64,
last: Option<f64>,
}
impl Dpo {
/// Construct a new DPO with the given period.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
let shift = period / 2 + 1;
// The window must cover both the SMA (`period` prices) and the
// look-back (`shift + 1` prices: the current bar plus `shift` history).
let capacity = period.max(shift + 1);
Ok(Self {
period,
shift,
capacity,
window: VecDeque::with_capacity(capacity),
sum: 0.0,
last: None,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
/// The look-back shift `period / 2 + 1`.
pub const fn shift(&self) -> usize {
self.shift
}
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.last
}
}
impl Indicator for Dpo {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() {
// Non-finite input is ignored; the window is left untouched.
return self.last;
}
self.window.push_back(input);
self.sum += input;
let len = self.window.len();
if len > self.period {
// The price that just left the SMA window.
self.sum -= self.window[len - 1 - self.period];
}
if self.window.len() > self.capacity {
self.window.pop_front();
}
if self.window.len() < self.capacity {
return None;
}
let sma = self.sum / self.period as f64;
// `price_{t - shift}` — index counts back from the newest bar.
let shifted = self.window[self.window.len() - 1 - self.shift];
let dpo = shifted - sma;
self.last = Some(dpo);
Some(dpo)
}
fn reset(&mut self) {
self.window.clear();
self.sum = 0.0;
self.last = None;
}
fn warmup_period(&self) -> usize {
self.capacity
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"DPO"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn new_rejects_zero_period() {
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);
assert_eq!(Dpo::new(4).unwrap().shift(), 3);
}
#[test]
fn reference_values() {
// DPO(4): shift = 3, capacity = max(4, 4) = 4.
// At input 4: window [1,2,3,4], SMA = 2.5, price[t-3] = 1 -> 1 - 2.5 = -1.5.
let mut dpo = Dpo::new(4).unwrap();
let out = dpo.batch(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0]);
assert_eq!(dpo.warmup_period(), 4);
assert_eq!(out[0], None);
assert_eq!(out[2], None);
assert_relative_eq!(out[3].unwrap(), -1.5, epsilon = 1e-12);
assert_relative_eq!(out[4].unwrap(), -1.5, epsilon = 1e-12);
assert_relative_eq!(out[5].unwrap(), -1.5, epsilon = 1e-12);
}
#[test]
fn constant_series_yields_zero() {
// A flat series: the shifted price equals the SMA, so DPO is 0.
let mut dpo = Dpo::new(10).unwrap();
let out = dpo.batch(&[50.0; 40]);
for v in out.iter().skip(dpo.warmup_period() - 1).flatten() {
assert_relative_eq!(*v, 0.0, epsilon = 1e-12);
}
}
#[test]
fn ignores_non_finite_input() {
let mut dpo = Dpo::new(4).unwrap();
let out = dpo.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
let last = *out.last().unwrap();
assert!(last.is_some());
assert_eq!(dpo.update(f64::NAN), last);
assert_eq!(dpo.update(f64::INFINITY), last);
}
#[test]
fn reset_clears_state() {
let mut dpo = Dpo::new(4).unwrap();
dpo.batch(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0]);
assert!(dpo.is_ready());
dpo.reset();
assert!(!dpo.is_ready());
assert_eq!(dpo.update(1.0), None);
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (1..=80)
.map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 7.0)
.collect();
let batch = Dpo::new(20).unwrap().batch(&prices);
let mut b = Dpo::new(20).unwrap();
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,289 @@
//! Ease of Movement (Arms).
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Richard Arms' Ease of Movement — how far price travels per unit of volume.
///
/// ```text
/// distance_t = (high_t + low_t)/2 (high_{t1} + low_{t1})/2
/// EMV_t = distance_t · (high_t low_t) · divisor / volume_t
/// EOM_t = SMA(EMV, period)_t
/// ```
///
/// A large positive EMV means price climbed a long way on light volume — it
/// moved "easily"; a value near zero means heavy volume was needed to shift
/// price at all. The `divisor` only rescales the output: the conventional
/// `1e8` keeps `EMV` in a readable range for typical share volumes. A bar with
/// zero volume contributes `EMV = 0` (no trading carries no signal), as does a
/// zero-range bar. The first candle only seeds the previous midpoint, so the
/// first value appears on candle `period + 1`.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, EaseOfMovement};
///
/// let mut indicator = EaseOfMovement::new(14).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// let base = 100.0 + f64::from(i);
/// let candle =
/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
/// last = indicator.update(candle);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct EaseOfMovement {
period: usize,
divisor: f64,
prev_mid: Option<f64>,
window: VecDeque<f64>,
sum: f64,
}
impl EaseOfMovement {
/// Construct an Ease of Movement with the conventional `1e8` volume divisor.
///
/// # Errors
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
Self::with_divisor(period, 100_000_000.0)
}
/// Construct an Ease of Movement with an explicit volume divisor. The
/// divisor is a pure output-scaling constant; pick whatever keeps `EMV`
/// readable for your instrument's volume magnitude.
///
/// # Errors
/// Returns [`Error::PeriodZero`] if `period == 0` and
/// [`Error::NonPositiveMultiplier`] if `divisor` is not strictly positive
/// and finite.
pub fn with_divisor(period: usize, divisor: f64) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
if !divisor.is_finite() || divisor <= 0.0 {
return Err(Error::NonPositiveMultiplier);
}
Ok(Self {
period,
divisor,
prev_mid: None,
window: VecDeque::with_capacity(period),
sum: 0.0,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
/// Configured volume divisor.
pub const fn divisor(&self) -> f64 {
self.divisor
}
}
impl Indicator for EaseOfMovement {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
let mid = f64::midpoint(candle.high, candle.low);
let Some(prev_mid) = self.prev_mid else {
// The first candle only establishes the previous midpoint.
self.prev_mid = Some(mid);
return None;
};
let distance = mid - prev_mid;
let range = candle.high - candle.low;
let emv = if candle.volume == 0.0 {
// No volume traded — the move carries no ease-of-movement signal.
0.0
} else {
distance * range * self.divisor / candle.volume
};
self.prev_mid = Some(mid);
if self.window.len() == self.period {
self.sum -= self.window.pop_front().expect("non-empty");
}
self.window.push_back(emv);
self.sum += emv;
if self.window.len() < self.period {
return None;
}
Some(self.sum / self.period as f64)
}
fn reset(&mut self) {
self.prev_mid = None;
self.window.clear();
self.sum = 0.0;
}
fn warmup_period(&self) -> usize {
// One seed candle establishes the first previous midpoint, then
// `period` EMV values fill the averaging window.
self.period + 1
}
fn is_ready(&self) -> bool {
self.window.len() == self.period
}
fn name(&self) -> &'static str {
"EaseOfMovement"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn candle(open: f64, high: f64, low: f64, close: f64, volume: f64, ts: i64) -> Candle {
Candle::new(open, high, low, close, volume, ts).unwrap()
}
#[test]
fn reference_values() {
// EOM(period = 1, divisor = 1): one EMV value is its own average.
// candle 1: midpoint (10 + 8)/2 = 9 only seeds the previous mid.
// candle 2: mid = (14 + 10)/2 = 12, distance = 3, range = 4,
// EMV = 3 * 4 * 1 / 100 = 0.12.
let mut eom = EaseOfMovement::with_divisor(1, 1.0).unwrap();
let out = eom.batch(&[
candle(9.0, 10.0, 8.0, 9.0, 50.0, 0),
candle(12.0, 14.0, 10.0, 12.0, 100.0, 1),
]);
assert!(out[0].is_none());
assert_relative_eq!(out[1].unwrap(), 0.12, epsilon = 1e-12);
}
#[test]
fn rising_midpoints_yield_positive_eom() {
// Strictly rising midpoints on constant volume -> every EMV is
// positive, so the averaged EOM is positive.
let candles: Vec<Candle> = (0..40)
.map(|i| {
let base = 100.0 + i as f64;
candle(base, base + 1.0, base - 1.0, base, 100.0, i)
})
.collect();
let mut eom = EaseOfMovement::new(14).unwrap();
for v in eom.batch(&candles).into_iter().flatten() {
assert!(v > 0.0, "EOM {v} should be positive on a rising series");
}
}
#[test]
fn constant_series_yields_zero() {
// Unchanging candles -> zero distance -> EMV is zero throughout.
let candles: Vec<Candle> = (0..30)
.map(|i| candle(10.0, 11.0, 9.0, 10.0, 50.0, i))
.collect();
let mut eom = EaseOfMovement::new(10).unwrap();
for v in eom.batch(&candles).into_iter().flatten() {
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
}
}
#[test]
fn zero_volume_contributes_zero() {
// A zero-volume bar yields EMV = 0 instead of dividing by zero.
let candles: Vec<Candle> = (0..20)
.map(|i| {
let base = 100.0 + i as f64;
candle(base, base + 1.0, base - 1.0, base, 0.0, i)
})
.collect();
let mut eom = EaseOfMovement::new(10).unwrap();
for v in eom.batch(&candles).into_iter().flatten() {
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
}
}
#[test]
fn first_value_on_period_plus_one_candle() {
let candles: Vec<Candle> = (0..12)
.map(|i| {
let base = 100.0 + i as f64;
candle(base, base + 1.0, base - 1.0, base, 50.0, i)
})
.collect();
let mut eom = EaseOfMovement::new(5).unwrap();
let out = eom.batch(&candles);
for (i, v) in out.iter().enumerate().take(5) {
assert!(v.is_none(), "index {i} must be None during warmup");
}
assert!(out[5].is_some(), "first EOM lands at index period");
assert_eq!(eom.warmup_period(), 6);
}
#[test]
fn rejects_invalid_input() {
assert!(EaseOfMovement::new(0).is_err());
assert!(EaseOfMovement::with_divisor(14, 0.0).is_err());
assert!(EaseOfMovement::with_divisor(14, -1.0).is_err());
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)
.map(|i| {
let base = 100.0 + i as f64;
candle(base, base + 1.0, base - 1.0, base, 50.0, i)
})
.collect();
let mut eom = EaseOfMovement::new(10).unwrap();
eom.batch(&candles);
assert!(eom.is_ready());
eom.reset();
assert!(!eom.is_ready());
assert_eq!(eom.update(candles[0]), None);
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..80)
.map(|i| {
let mid = 100.0 + (i as f64 * 0.3).sin() * 8.0;
candle(
mid,
mid + 2.0,
mid - 2.0,
mid + 0.5,
10.0 + (i % 5) as f64,
i,
)
})
.collect();
let mut a = EaseOfMovement::new(14).unwrap();
let mut b = EaseOfMovement::new(14).unwrap();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
+70
View File
@@ -8,6 +8,19 @@ use crate::traits::Indicator;
/// The first value is seeded with the simple mean of the first `period` inputs
/// (the classical TA-Lib convention). From then on each new input contributes
/// `alpha * input + (1 - alpha) * previous`.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, Ema};
///
/// let mut indicator = Ema::new(3).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// last = indicator.update(100.0 + f64::from(i));
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct Ema {
period: usize,
@@ -126,11 +139,44 @@ mod tests {
use crate::traits::BatchExt;
use approx::assert_relative_eq;
/// Independent reference: SMA-seeded EMA computed straight from the definition.
fn ema_naive(prices: &[f64], period: usize) -> Vec<Option<f64>> {
let alpha = 2.0 / (period as f64 + 1.0);
let mut out = Vec::with_capacity(prices.len());
let mut state: Option<f64> = None;
for (i, &p) in prices.iter().enumerate() {
if let Some(prev) = state {
let v = alpha * p + (1.0 - alpha) * prev;
state = Some(v);
out.push(Some(v));
} else if i + 1 == period {
let seed = prices[..period].iter().sum::<f64>() / period as f64;
state = Some(seed);
out.push(Some(seed));
} else {
out.push(None);
}
}
out
}
#[test]
fn new_rejects_zero_period() {
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();
@@ -221,4 +267,28 @@ mod tests {
assert_eq!(ema.update(f64::NAN), before);
assert_eq!(ema.update(f64::INFINITY), before);
}
proptest::proptest! {
#![proptest_config(proptest::test_runner::Config::with_cases(48))]
#[test]
fn ema_matches_naive(
period in 1usize..20,
prices in proptest::collection::vec(-1000.0_f64..1000.0, 0..150),
) {
let mut ema = Ema::new(period).unwrap();
let got = ema.batch(&prices);
let want = ema_naive(&prices, period);
proptest::prop_assert_eq!(got.len(), want.len());
for (g, w) in got.iter().zip(want.iter()) {
match (g, w) {
(None, None) => {}
(Some(a), Some(b)) => proptest::prop_assert!(
(a - b).abs() <= 1e-9 * a.abs().max(1.0),
"got={a} want={b}"
),
_ => proptest::prop_assert!(false, "warmup mismatch"),
}
}
}
}
}
@@ -0,0 +1,198 @@
//! Force Index (Elder).
use crate::error::Result;
use crate::indicators::ema::Ema;
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Alexander Elder's Force Index — price change scaled by volume, EMA-smoothed.
///
/// ```text
/// raw_t = (close_t close_{t1}) · volume_t
/// Force_t = EMA(raw, period)_t
/// ```
///
/// The raw force is positive on an up-close and negative on a down-close, and
/// its magnitude grows with the volume that backed the move — a big move on
/// heavy volume registers a large force. Smoothing the raw series with an EMA
/// gives a tradeable line; Elder's classic period is `13`. The first candle
/// only establishes the previous close, so the first raw value appears on
/// candle 2 and the first smoothed value on candle `period + 1`.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, ForceIndex};
///
/// let mut indicator = ForceIndex::new(13).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// let base = 100.0 + f64::from(i);
/// let candle =
/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
/// last = indicator.update(candle);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct ForceIndex {
period: usize,
prev_close: Option<f64>,
ema: Ema,
}
impl ForceIndex {
/// Construct a new Force Index with the given EMA smoothing period.
///
/// # Errors
/// Returns [`Error::PeriodZero`](crate::Error::PeriodZero) if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
Ok(Self {
period,
prev_close: None,
ema: Ema::new(period)?,
})
}
/// Configured smoothing period.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for ForceIndex {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
let Some(prev) = self.prev_close else {
// The first candle only establishes the previous close.
self.prev_close = Some(candle.close);
return None;
};
let raw = (candle.close - prev) * candle.volume;
self.prev_close = Some(candle.close);
self.ema.update(raw)
}
fn reset(&mut self) {
self.prev_close = None;
self.ema.reset();
}
fn warmup_period(&self) -> usize {
// One seed candle establishes the first previous close, then the EMA
// needs `period` raw values.
self.period + 1
}
fn is_ready(&self) -> bool {
self.ema.is_ready()
}
fn name(&self) -> &'static str {
"ForceIndex"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn c(close: f64, volume: f64, ts: i64) -> Candle {
Candle::new(close, close, close, close, volume, ts).unwrap()
}
#[test]
fn reference_values() {
// ForceIndex(1): EMA(1) has alpha = 1, so it passes raw force through.
// candle 1 (close 10) only seeds the previous close -> None.
// candle 2: raw = (12 - 10) * 100 = +200.
// candle 3: raw = (11 - 12) * 200 = -200.
let mut fi = ForceIndex::new(1).unwrap();
let out = fi.batch(&[c(10.0, 100.0, 0), c(12.0, 100.0, 1), c(11.0, 200.0, 2)]);
assert!(out[0].is_none());
assert_relative_eq!(out[1].unwrap(), 200.0, epsilon = 1e-9);
assert_relative_eq!(out[2].unwrap(), -200.0, epsilon = 1e-9);
}
#[test]
fn pure_uptrend_is_positive() {
// Strictly rising closes on constant volume -> every raw force is
// positive, so the smoothed force is positive too.
let candles: Vec<Candle> = (1..40)
.map(|i| c(f64::from(i), 100.0, i64::from(i)))
.collect();
let mut fi = ForceIndex::new(13).unwrap();
for v in fi.batch(&candles).into_iter().flatten() {
assert!(v > 0.0, "force {v} should be positive in an uptrend");
}
}
#[test]
fn pure_downtrend_is_negative() {
let candles: Vec<Candle> = (1..40)
.rev()
.map(|i| c(f64::from(i), 100.0, i64::from(i)))
.collect();
let mut fi = ForceIndex::new(13).unwrap();
for v in fi.batch(&candles).into_iter().flatten() {
assert!(v < 0.0, "force {v} should be negative in a downtrend");
}
}
#[test]
fn first_value_on_period_plus_one_candle() {
let candles: Vec<Candle> = (0..12).map(|i| c(10.0 + i as f64, 50.0, i)).collect();
let mut fi = ForceIndex::new(5).unwrap();
let out = fi.batch(&candles);
for (i, v) in out.iter().enumerate().take(5) {
assert!(v.is_none(), "index {i} must be None during warmup");
}
assert!(out[5].is_some(), "first force lands at index period");
assert_eq!(fi.warmup_period(), 6);
}
#[test]
fn rejects_zero_period() {
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();
let mut fi = ForceIndex::new(13).unwrap();
fi.batch(&candles);
assert!(fi.is_ready());
fi.reset();
assert!(!fi.is_ready());
assert_eq!(fi.update(candles[0]), None);
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..80)
.map(|i| {
let close = 100.0 + (i as f64 * 0.3).sin() * 8.0;
c(close, 10.0 + (i % 5) as f64, i)
})
.collect();
let mut a = ForceIndex::new(13).unwrap();
let mut b = ForceIndex::new(13).unwrap();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,309 @@
//! Historical Volatility.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Historical Volatility — the annualised standard deviation of log returns.
///
/// This is the realised (backward-looking) volatility used to price options
/// and size risk:
///
/// ```text
/// r_t = ln(price_t / price_{t1})
/// HV = stddev_sample(r over period) · √trading_periods · 100
/// ```
///
/// The log returns over the window are measured with the **sample** standard
/// deviation (divisor `n 1`, the unbiased estimator), then scaled to an
/// annual figure by `√trading_periods` — `252` for daily bars, `52` for
/// weekly, `12` for monthly — and expressed as a percentage.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, HistoricalVolatility};
///
/// // 20-bar window, 252 trading days per year.
/// let mut indicator = HistoricalVolatility::new(20, 252).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// last = indicator.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct HistoricalVolatility {
period: usize,
trading_periods: usize,
prev_price: Option<f64>,
/// Rolling window of the last `period` log returns.
window: VecDeque<f64>,
sum: f64,
sum_sq: f64,
last: Option<f64>,
}
impl HistoricalVolatility {
/// Construct a new Historical Volatility indicator.
///
/// `period` is the number of log returns in the rolling window;
/// `trading_periods` is the annualisation factor (`252` daily, `52`
/// weekly, `12` monthly).
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `period` or `trading_periods` is `0`,
/// or [`Error::InvalidPeriod`] if `period == 1` (the sample standard
/// deviation needs at least two returns).
pub fn new(period: usize, trading_periods: usize) -> Result<Self> {
if period == 0 || trading_periods == 0 {
return Err(Error::PeriodZero);
}
if period < 2 {
return Err(Error::InvalidPeriod {
message: "historical volatility period must be >= 2",
});
}
Ok(Self {
period,
trading_periods,
prev_price: None,
window: VecDeque::with_capacity(period),
sum: 0.0,
sum_sq: 0.0,
last: None,
})
}
/// Configured `(period, trading_periods)`.
pub const fn periods(&self) -> (usize, usize) {
(self.period, self.trading_periods)
}
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.last
}
}
impl Indicator for HistoricalVolatility {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
// Non-finite *and* non-positive prices are both ignored: state is left
// untouched and `self.last` is returned. The log-return `ln(input /
// prev)` is undefined for non-positive prices, and silently
// substituting `0.0` (the previous behaviour, audit finding R13) would
// underreport realised volatility by treating bad ticks as "no
// movement". Skipping them entirely is consistent with how the rest
// of the library handles invalid inputs (see SMA / EMA / ROC).
if !input.is_finite() || input <= 0.0 {
return self.last;
}
let Some(prev) = self.prev_price else {
self.prev_price = Some(input);
return None;
};
// `prev` was assigned from `self.prev_price`, which only ever holds
// valid (finite, positive) inputs because the guard above gates every
// assignment to it — so `(input / prev).ln()` is always well-defined.
self.prev_price = Some(input);
let log_return = (input / prev).ln();
if self.window.len() == self.period {
let old = self.window.pop_front().expect("window is non-empty");
self.sum -= old;
self.sum_sq -= old * old;
}
self.window.push_back(log_return);
self.sum += log_return;
self.sum_sq += log_return * log_return;
if self.window.len() < self.period {
return None;
}
let n = self.period as f64;
let mean = self.sum / n;
// Sample variance (Bessel's correction): Σ(xmean)² / (n1).
let variance = ((self.sum_sq - n * mean * mean) / (n - 1.0)).max(0.0);
let hv = variance.sqrt() * (self.trading_periods as f64).sqrt() * 100.0;
self.last = Some(hv);
Some(hv)
}
fn reset(&mut self) {
self.prev_price = None;
self.window.clear();
self.sum = 0.0;
self.sum_sq = 0.0;
self.last = None;
}
fn warmup_period(&self) -> usize {
// The first log return needs a previous price, then the window fills.
self.period + 1
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"HistoricalVolatility"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn new_rejects_zero_period() {
assert!(matches!(
HistoricalVolatility::new(0, 252),
Err(Error::PeriodZero)
));
assert!(matches!(
HistoricalVolatility::new(20, 0),
Err(Error::PeriodZero)
));
}
/// 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!(
HistoricalVolatility::new(1, 252),
Err(Error::InvalidPeriod { .. })
));
}
#[test]
fn first_emission_at_warmup_period() {
let mut hv = HistoricalVolatility::new(5, 252).unwrap();
assert_eq!(hv.warmup_period(), 6);
let out = hv.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
for v in out.iter().take(5) {
assert!(v.is_none());
}
assert!(out[5].is_some());
}
#[test]
fn constant_series_yields_zero() {
// Flat prices -> all log returns are 0 -> zero volatility.
let mut hv = HistoricalVolatility::new(10, 252).unwrap();
let out = hv.batch(&[100.0; 40]);
for v in out.iter().skip(10).flatten() {
assert_relative_eq!(*v, 0.0, epsilon = 1e-12);
}
}
#[test]
fn geometric_series_yields_zero() {
// A constant growth factor gives a constant log return -> zero stddev.
// The mathematical result is exactly zero, but `1.01_f64.powi(i)` and
// the subsequent log / std-dev cascade accumulate platform-sensitive
// floating-point drift on the order of 1e-7 (observed on x86_64 Linux
// and macOS; Windows happens to round closer to zero). The 1e-6
// tolerance stays four decimal places below any realistic volatility
// value while absorbing this drift across every supported platform.
let mut hv = HistoricalVolatility::new(10, 252).unwrap();
let prices: Vec<f64> = (0..40).map(|i| 100.0 * 1.01_f64.powi(i)).collect();
let out = hv.batch(&prices);
for v in out.iter().skip(10).flatten() {
assert_relative_eq!(*v, 0.0, epsilon = 1e-6);
}
}
#[test]
fn output_is_non_negative() {
let mut hv = HistoricalVolatility::new(20, 252).unwrap();
let prices: Vec<f64> = (1..=200)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 12.0)
.collect();
for v in hv.batch(&prices).into_iter().flatten() {
assert!(v >= 0.0, "volatility must be non-negative, got {v}");
}
}
#[test]
fn ignores_non_finite_input() {
let mut hv = HistoricalVolatility::new(5, 252).unwrap();
let out = hv.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
let last = *out.last().unwrap();
assert!(last.is_some());
assert_eq!(hv.update(f64::NAN), last);
assert_eq!(hv.update(f64::INFINITY), last);
}
/// Audit finding R13. Non-positive prices are now skipped (state left
/// untouched) instead of silently treated as a `0.0` log-return — the old
/// behaviour underreported realised volatility by treating bad ticks as
/// "no movement".
#[test]
fn skips_non_positive_prices() {
let mut hv = HistoricalVolatility::new(5, 252).unwrap();
// Warm up with positive prices.
let warmup_prices = (1..=20).map(f64::from).collect::<Vec<_>>();
let warmup = hv.batch(&warmup_prices);
let baseline = warmup
.last()
.copied()
.flatten()
.expect("warmed up by index 5");
// A negative tick must be ignored: returned value equals the previous
// baseline, and the next real positive tick must use the previous
// valid price as `prev` (not the bad one), so the next log return is
// exactly `ln(21 / 20)`, not `ln(21 / -5)` or anything else.
assert_eq!(hv.update(-5.0), Some(baseline));
assert_eq!(hv.update(0.0), Some(baseline));
// Snapshot the indicator's state, then advance with a real positive
// tick on a clone. The clone must agree with a from-scratch run that
// simply skipped the bad ticks — proving the state was untouched.
let mut control = hv.clone();
let after_real = hv.update(21.0).expect("ready");
assert_eq!(control.update(21.0).expect("ready"), after_real);
}
#[test]
fn reset_clears_state() {
let mut hv = HistoricalVolatility::new(5, 252).unwrap();
hv.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
assert!(hv.is_ready());
hv.reset();
assert!(!hv.is_ready());
assert_eq!(hv.update(1.0), None);
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (1..=120)
.map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 9.0)
.collect();
let batch = HistoricalVolatility::new(20, 252).unwrap().batch(&prices);
let mut b = HistoricalVolatility::new(20, 252).unwrap();
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
assert_eq!(batch, streamed);
}
}
+71 -2
View File
@@ -8,6 +8,19 @@ use crate::traits::Indicator;
///
/// Designed by Alan Hull as a lag-free moving average that is also responsive.
/// The square root of the period is rounded to the nearest integer (minimum 1).
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, Hma};
///
/// let mut indicator = Hma::new(9).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// last = indicator.update(100.0 + f64::from(i));
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct Hma {
period: usize,
@@ -45,8 +58,13 @@ impl Indicator for Hma {
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
let h = self.half_wma.update(input)?;
let f = self.full_wma.update(input)?;
// Feed both windowed WMAs on every input so they warm up in parallel.
// Gating `full_wma.update` behind `half_wma.update(...)?` would starve
// the longer WMA during the shorter one's warmup, delaying the first
// emission past `warmup_period()`.
let h = self.half_wma.update(input);
let f = self.full_wma.update(input);
let (h, f) = (h?, f?);
let diff = 2.0 * h - f;
self.smooth_wma.update(diff)
}
@@ -109,4 +127,55 @@ mod tests {
fn rejects_zero_period() {
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();
let mut hma = Hma::new(9).unwrap();
let out = hma.batch(&prices);
let warmup = hma.warmup_period();
assert_eq!(warmup, 11);
for (i, v) in out.iter().enumerate().take(warmup - 1) {
assert!(v.is_none(), "index {i} must be None during warmup");
}
assert!(
out[warmup - 1].is_some(),
"first HMA value must land at warmup_period - 1"
);
}
#[test]
fn matches_independent_wmas() {
// The two inner WMAs run as independent siblings on the price stream;
// HMA must equal feeding three standalone WMAs and combining them.
let prices: Vec<f64> = (1..=50)
.map(|i| (f64::from(i) * 0.3).sin() * 10.0 + 50.0)
.collect();
let mut hma = Hma::new(9).unwrap();
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 (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,
};
// 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);
}
}
}
}
+38
View File
@@ -11,6 +11,19 @@ use crate::traits::Indicator;
/// get a fast smoothing constant, choppy markets get a slow one. Parameters are
/// the efficiency-ratio lookback (`er_period`, default 10), the fast EMA period
/// (`fast`, default 2) and the slow EMA period (`slow`, default 30).
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, Kama};
///
/// let mut indicator = Kama::new(10, 2, 30).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// last = indicator.update(100.0 + f64::from(i));
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct Kama {
er_period: usize,
@@ -118,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();
@@ -154,4 +181,15 @@ mod tests {
k.reset();
assert!(!k.is_ready());
}
#[test]
fn ignores_non_finite_input() {
let mut k = Kama::classic();
k.batch(&(1..=40).map(f64::from).collect::<Vec<_>>());
let before = k.update(41.0);
assert!(before.is_some());
// Non-finite inputs return the last state without sliding the window.
assert_eq!(k.update(f64::NAN), before);
assert_eq!(k.update(f64::INFINITY), before);
}
}
+104 -2
View File
@@ -18,6 +18,22 @@ pub struct KeltnerOutput {
}
/// Keltner Channels: an EMA centerline with bands sized by ATR.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, Keltner};
///
/// let mut indicator = Keltner::new(5, 5, 2.0).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// let base = 100.0 + f64::from(i);
/// let candle =
/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
/// last = indicator.update(candle);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct Keltner {
ema: Ema,
@@ -59,8 +75,14 @@ impl Indicator for Keltner {
type Output = KeltnerOutput;
fn update(&mut self, candle: Candle) -> Option<KeltnerOutput> {
let mid = self.ema.update(candle.typical_price())?;
let atr = self.atr.update(candle)?;
// Feed both sub-indicators on every candle so they warm up in parallel.
// Gating `atr.update` behind `ema.update(...)?` would starve the ATR of
// every candle consumed during the EMA's warmup, delaying the first
// emission past `warmup_period()` and seeding the ATR over the wrong
// window.
let mid = self.ema.update(candle.typical_price());
let atr = self.atr.update(candle);
let (mid, atr) = (mid?, atr?);
Some(KeltnerOutput {
upper: mid + self.multiplier * atr,
middle: mid,
@@ -139,4 +161,84 @@ mod tests {
assert!(Keltner::new(20, 10, 0.0).is_err());
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)
.map(|i| c(f64::from(i) + 1.0, f64::from(i) - 1.0, f64::from(i)))
.collect();
let mut k = Keltner::classic();
k.batch(&candles);
assert!(k.is_ready());
k.reset();
assert!(!k.is_ready());
assert_eq!(k.update(candles[0]), None);
}
#[test]
fn first_emission_matches_warmup_period() {
let candles: Vec<Candle> = (0..60)
.map(|i| {
let base = 100.0 + f64::from(i);
c(base + 1.0, base - 1.0, base)
})
.collect();
let mut k = Keltner::classic();
let out = k.batch(&candles);
let warmup = k.warmup_period();
assert_eq!(warmup, 20);
for (i, v) in out.iter().enumerate().take(warmup - 1) {
assert!(v.is_none(), "index {i} must be None during warmup");
}
assert!(
out[warmup - 1].is_some(),
"first KeltnerOutput must land at warmup_period - 1"
);
}
#[test]
fn matches_independent_ema_and_atr() {
// The EMA (on typical price) and the ATR (on the candle) run as
// independent siblings; Keltner must equal feeding two standalone
// instances and combining them once both are ready.
let candles: Vec<Candle> = (0..60)
.map(|i| {
let m = 100.0 + (f64::from(i) * 0.2).sin() * 5.0;
c(m + 1.5, m - 1.5, m)
})
.collect();
let mut k = Keltner::classic();
let mut ema = Ema::new(20).unwrap();
let mut atr = Atr::new(10).unwrap();
for (i, candle) in candles.iter().enumerate() {
let got = k.update(*candle);
let mid = ema.update(candle.typical_price());
let a = atr.update(*candle);
match (mid, a) {
(Some(m), Some(av)) => {
let o = got.expect("Keltner emits once EMA and ATR are both ready");
assert_relative_eq!(o.middle, m, epsilon = 1e-9);
assert_relative_eq!(o.upper, m + 2.0 * av, epsilon = 1e-9);
assert_relative_eq!(o.lower, m - 2.0 * av, epsilon = 1e-9);
}
_ => assert!(
got.is_none(),
"Keltner must be None until both ready (i={i})"
),
}
}
}
}
+294
View File
@@ -0,0 +1,294 @@
//! Linear Regression (rolling least-squares endpoint).
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Linear Regression — the endpoint of a rolling least-squares fit.
///
/// Over the last `period` inputs, indexed `x = 0, 1, …, period 1`, it fits
/// the line `y = a + b·x` by ordinary least squares and reports the line's
/// value at the most recent point:
///
/// ```text
/// b (slope) = (n·Σxy Σx·Σy) / (n·Σxx (Σx)²)
/// a (intercept) = (Σy b·Σx) / n
/// LinearReg = a + b·(period 1)
/// ```
///
/// This is TA-Lib's `LINEARREG`: a smoothed price that lags less than an SMA
/// because it extrapolates the *local trend* forward to the current bar
/// instead of averaging it away.
///
/// Each `update` is O(1): the `Σx` and `Σxx` terms depend only on `period` and
/// are precomputed once, while `Σy` and `Σxy` are maintained incrementally as
/// the window slides. The closed-form sliding-window identity for
/// `x = 0, 1, …, period 1` is
///
/// ```text
/// new_sum_xy = old_sum_xy old_sum_y + popped_y0 // index shift by 1
/// new_sum_y = old_sum_y popped_y0
/// // then push the new value at index n1:
/// sum_xy += (n 1) · new_value
/// sum_y += new_value
/// ```
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, LinearRegression};
///
/// let mut indicator = LinearRegression::new(14).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// last = indicator.update(f64::from(i));
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct LinearRegression {
period: usize,
window: VecDeque<f64>,
/// Closed form of `Σx` over `x = 0, 1, …, period 1` — constant in `period`.
sum_x: f64,
/// Closed form of `n · Σxx (Σx)²` — constant in `period`, the OLS
/// denominator.
denom: f64,
/// Running sum of the values currently in the window.
sum_y: f64,
/// Running `Σ(x · y)` where `x` is the position of each value within the
/// trailing window (`0` for the oldest, `period 1` for the newest).
sum_xy: f64,
}
impl LinearRegression {
/// Construct a new rolling linear regression over `period` inputs.
///
/// # Errors
/// Returns [`Error::InvalidPeriod`] if `period < 2` — a regression line is
/// undefined for fewer than two points.
pub fn new(period: usize) -> Result<Self> {
if period < 2 {
return Err(Error::InvalidPeriod {
message: "linear regression needs period >= 2",
});
}
let n = period as f64;
// Closed forms for x = 0, 1, …, period 1.
let sum_x = n * (n - 1.0) / 2.0;
let sum_xx = (n - 1.0) * n * (2.0 * n - 1.0) / 6.0;
Ok(Self {
period,
window: VecDeque::with_capacity(period),
sum_x,
denom: n * sum_xx - sum_x * sum_x,
sum_y: 0.0,
sum_xy: 0.0,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for LinearRegression {
type Input = f64;
type Output = f64;
fn update(&mut self, value: f64) -> Option<f64> {
if self.window.len() == self.period {
// Sliding phase: pop the oldest, then shift every remaining index
// down by 1 in the running `sum_xy`. The identity
// Σ((i 1) · y_i for i = 1..n1) = Σ(i · y_i) Σ(y_i) + y_0
// gives the closed-form update below.
let y0 = self.window.pop_front().expect("non-empty");
self.sum_xy = self.sum_xy - self.sum_y + y0;
self.sum_y -= y0;
}
// Append at position `k = current length` before the push. During
// warmup `k` ranges over `0..period 1`; once the window is full it
// is always `period 1`.
let k = self.window.len() as f64;
self.window.push_back(value);
self.sum_y += value;
self.sum_xy += k * value;
if self.window.len() < self.period {
return None;
}
let n = self.period as f64;
let slope = (n * self.sum_xy - self.sum_x * self.sum_y) / self.denom;
let intercept = (self.sum_y - slope * self.sum_x) / n;
Some(intercept + slope * (n - 1.0))
}
fn reset(&mut self) {
self.window.clear();
self.sum_y = 0.0;
self.sum_xy = 0.0;
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.window.len() == self.period
}
fn name(&self) -> &'static str {
"LinearRegression"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn reference_values() {
// period 3 over [1, 2, 9]: fit y = 0 + 4x, endpoint = 0 + 4·2 = 8.
let mut lr = LinearRegression::new(3).unwrap();
let out = lr.batch(&[1.0, 2.0, 9.0]);
assert!(out[0].is_none());
assert!(out[1].is_none());
assert_relative_eq!(out[2].unwrap(), 8.0, epsilon = 1e-9);
}
#[test]
fn perfect_line_returns_current_value() {
// The regression of a perfectly linear series is that line itself, so
// its endpoint equals the current value.
let prices: Vec<f64> = (0..40).map(|i| 2.0 * f64::from(i) + 5.0).collect();
let mut lr = LinearRegression::new(10).unwrap();
for (i, v) in lr.batch(&prices).into_iter().enumerate() {
if let Some(v) = v {
assert_relative_eq!(v, 2.0 * i as f64 + 5.0, epsilon = 1e-6);
}
}
}
#[test]
fn constant_series_returns_the_constant() {
let mut lr = LinearRegression::new(8).unwrap();
for v in lr.batch(&[42.0; 20]).into_iter().flatten() {
assert_relative_eq!(v, 42.0, epsilon = 1e-9);
}
}
#[test]
fn first_value_on_period_th_input() {
let mut lr = LinearRegression::new(5).unwrap();
let out = lr.batch(&[1.0, 3.0, 2.0, 5.0, 4.0, 6.0]);
for (i, v) in out.iter().enumerate().take(4) {
assert!(v.is_none(), "index {i} must be None during warmup");
}
assert!(out[4].is_some(), "first value lands at index period - 1");
assert_eq!(lr.warmup_period(), 5);
}
#[test]
fn rejects_period_below_two() {
assert!(LinearRegression::new(0).is_err());
assert!(LinearRegression::new(1).is_err());
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();
lr.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
assert!(lr.is_ready());
lr.reset();
assert!(!lr.is_ready());
assert_eq!(lr.update(1.0), None);
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (0..60)
.map(|i| 50.0 + (f64::from(i) * 0.3).sin() * 10.0)
.collect();
let mut a = LinearRegression::new(14).unwrap();
let mut b = LinearRegression::new(14).unwrap();
assert_eq!(
a.batch(&prices),
prices.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
/// Incremental OLS equivalence: the O(1) implementation must agree to
/// `1e-9` with a fresh-from-scratch O(n) refit on every bar, on inputs
/// chosen to stress every code path: a noisy ramp (sliding phase
/// dominates), a step function (the new value differs sharply from the
/// popped one), and constants (the floating-point accumulators must not
/// drift).
#[test]
fn incremental_matches_naive_fit_bar_by_bar() {
fn naive_endpoint(window: &[f64]) -> f64 {
let n = window.len() as f64;
let mut sum_y = 0.0;
let mut sum_xy = 0.0;
let mut sum_x = 0.0;
let mut sum_xx = 0.0;
for (i, &y) in window.iter().enumerate() {
let x = i as f64;
sum_y += y;
sum_xy += x * y;
sum_x += x;
sum_xx += x * x;
}
let denom = n * sum_xx - sum_x * sum_x;
let slope = (n * sum_xy - sum_x * sum_y) / denom;
let intercept = (sum_y - slope * sum_x) / n;
intercept + slope * (n - 1.0)
}
fn check(prices: &[f64], period: usize) {
let mut lr = LinearRegression::new(period).unwrap();
for (t, p) in prices.iter().enumerate() {
let streaming = lr.update(*p);
if t + 1 >= period {
let lo = t + 1 - period;
let expected = naive_endpoint(&prices[lo..=t]);
let got = streaming.expect("warmed up");
assert!(
(got - expected).abs() < 1e-9,
"endpoint diverges at t={t}, period={period}: got={got}, expected={expected}",
);
}
}
}
let noisy_ramp: Vec<f64> = (0..120)
.map(|i| 100.0 + f64::from(i) * 0.5 + (f64::from(i) * 0.7).sin() * 3.0)
.collect();
check(&noisy_ramp, 5);
check(&noisy_ramp, 14);
check(&noisy_ramp, 30);
let mut step = vec![1.0; 30];
step.extend(std::iter::repeat_n(100.0, 30));
step.extend(std::iter::repeat_n(0.001, 30));
check(&step, 5);
check(&step, 14);
let constant = vec![42.0; 50];
check(&constant, 8);
check(&constant, 25);
}
}
@@ -0,0 +1,174 @@
//! Linear Regression Angle.
use crate::error::Result;
use crate::indicators::linreg_slope::LinRegSlope;
use crate::traits::Indicator;
/// Linear Regression Angle — the slope of the rolling least-squares fit,
/// expressed as an angle in degrees.
///
/// ```text
/// LinRegAngle = atan(LinRegSlope) · 180 / π
/// ```
///
/// It carries exactly the same information as [`LinRegSlope`](crate::LinRegSlope)
/// — positive while price trends up, negative while it trends down — but maps
/// the unbounded slope through `atan` onto `(90°, +90°)`. That bounded,
/// price-unit-free scale makes "how steep is the trend" comparable at a glance
/// and across instruments. This is TA-Lib's `LINEARREG_ANGLE`.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, LinRegAngle};
///
/// let mut indicator = LinRegAngle::new(14).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// last = indicator.update(f64::from(i));
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct LinRegAngle {
slope: LinRegSlope,
}
impl LinRegAngle {
/// Construct a new rolling linear-regression angle over `period` inputs.
///
/// # Errors
/// Returns [`Error::InvalidPeriod`](crate::Error::InvalidPeriod) if
/// `period < 2` — a regression line is undefined for fewer than two points.
pub fn new(period: usize) -> Result<Self> {
Ok(Self {
slope: LinRegSlope::new(period)?,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.slope.period()
}
}
impl Indicator for LinRegAngle {
type Input = f64;
type Output = f64;
fn update(&mut self, value: f64) -> Option<f64> {
self.slope.update(value).map(|s| s.atan().to_degrees())
}
fn reset(&mut self) {
self.slope.reset();
}
fn warmup_period(&self) -> usize {
self.slope.warmup_period()
}
fn is_ready(&self) -> bool {
self.slope.is_ready()
}
fn name(&self) -> &'static str {
"LinRegAngle"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn unit_slope_is_forty_five_degrees() {
// A series rising by exactly 1 per step has slope 1, and atan(1) = 45°.
let mut angle = LinRegAngle::new(5).unwrap();
let out = angle.batch(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0]);
for (i, v) in out.iter().enumerate().take(4) {
assert!(v.is_none(), "index {i} must be None during warmup");
}
assert_relative_eq!(out[4].unwrap(), 45.0, epsilon = 1e-9);
assert_relative_eq!(out[5].unwrap(), 45.0, epsilon = 1e-9);
}
#[test]
fn reference_value_steep_slope() {
// period 3 over [1, 2, 9]: slope 4, angle = atan(4) in degrees.
let mut angle = LinRegAngle::new(3).unwrap();
let out = angle.batch(&[1.0, 2.0, 9.0]);
assert_relative_eq!(out[2].unwrap(), 4.0_f64.atan().to_degrees(), epsilon = 1e-9);
}
#[test]
fn constant_series_has_zero_angle() {
let mut angle = LinRegAngle::new(8).unwrap();
for v in angle.batch(&[42.0; 20]).into_iter().flatten() {
assert_relative_eq!(v, 0.0, epsilon = 1e-9);
}
}
#[test]
fn falling_series_has_negative_angle() {
let prices: Vec<f64> = (0..30).map(|i| 100.0 - f64::from(i)).collect();
let mut angle = LinRegAngle::new(10).unwrap();
for v in angle.batch(&prices).into_iter().flatten() {
assert!(v < 0.0, "a falling series must have a negative angle");
}
}
#[test]
fn stays_within_ninety_degrees() {
let prices: Vec<f64> = (0..60)
.map(|i| 50.0 + (f64::from(i) * 0.3).sin() * 1000.0)
.collect();
let mut angle = LinRegAngle::new(14).unwrap();
for v in angle.batch(&prices).into_iter().flatten() {
assert!(v > -90.0 && v < 90.0, "angle {v} outside (-90, 90)");
}
}
#[test]
fn rejects_period_below_two() {
assert!(LinRegAngle::new(0).is_err());
assert!(LinRegAngle::new(1).is_err());
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();
angle.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
assert!(angle.is_ready());
angle.reset();
assert!(!angle.is_ready());
assert_eq!(angle.update(1.0), None);
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (0..60)
.map(|i| 50.0 + (f64::from(i) * 0.3).sin() * 10.0)
.collect();
let mut a = LinRegAngle::new(14).unwrap();
let mut b = LinRegAngle::new(14).unwrap();
assert_eq!(
a.batch(&prices),
prices.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,270 @@
//! Linear Regression Slope.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Linear Regression Slope — the slope of a rolling least-squares fit.
///
/// Over the last `period` inputs, indexed `x = 0, 1, …, period 1`, it fits
/// the line `y = a + b·x` by ordinary least squares and reports the slope:
///
/// ```text
/// b = (n·Σxy Σx·Σy) / (n·Σxx (Σx)²)
/// ```
///
/// This is TA-Lib's `LINEARREG_SLOPE`: a momentum-like reading of how steeply
/// price is trending over the window — positive while it rises, negative
/// while it falls, near zero when it is flat — without the band-pass quirks
/// of a difference-based oscillator.
///
/// Each `update` is O(1): the same incremental OLS state as
/// [`LinearRegression`](crate::LinearRegression) is maintained — `Σx` and
/// `Σxx` are precomputed once from `period`, while `Σy` and `Σxy` are slid
/// forward in closed form on every push.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, LinRegSlope};
///
/// let mut indicator = LinRegSlope::new(14).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// last = indicator.update(f64::from(i));
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct LinRegSlope {
period: usize,
window: VecDeque<f64>,
/// Closed form of `Σx` over `x = 0, 1, …, period 1` — constant in `period`.
sum_x: f64,
/// Closed form of `n · Σxx (Σx)²` — constant in `period`.
denom: f64,
/// Running sum of the values currently in the window.
sum_y: f64,
/// Running `Σ(x · y)` where `x` is the position within the trailing window.
sum_xy: f64,
}
impl LinRegSlope {
/// Construct a new rolling linear-regression slope over `period` inputs.
///
/// # Errors
/// Returns [`Error::InvalidPeriod`] if `period < 2` — a regression line is
/// undefined for fewer than two points.
pub fn new(period: usize) -> Result<Self> {
if period < 2 {
return Err(Error::InvalidPeriod {
message: "linear regression slope needs period >= 2",
});
}
let n = period as f64;
// Closed forms for x = 0, 1, …, period 1.
let sum_x = n * (n - 1.0) / 2.0;
let sum_xx = (n - 1.0) * n * (2.0 * n - 1.0) / 6.0;
Ok(Self {
period,
window: VecDeque::with_capacity(period),
sum_x,
denom: n * sum_xx - sum_x * sum_x,
sum_y: 0.0,
sum_xy: 0.0,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for LinRegSlope {
type Input = f64;
type Output = f64;
fn update(&mut self, value: f64) -> Option<f64> {
if self.window.len() == self.period {
// Sliding-window identity: when the window slides one step forward
// the indices `x` for every kept entry shift down by 1, so
// new_sum_xy = old_sum_xy old_sum_y + y0
// (`y0` is the popped front value).
let y0 = self.window.pop_front().expect("non-empty");
self.sum_xy = self.sum_xy - self.sum_y + y0;
self.sum_y -= y0;
}
let k = self.window.len() as f64;
self.window.push_back(value);
self.sum_y += value;
self.sum_xy += k * value;
if self.window.len() < self.period {
return None;
}
let n = self.period as f64;
Some((n * self.sum_xy - self.sum_x * self.sum_y) / self.denom)
}
fn reset(&mut self) {
self.window.clear();
self.sum_y = 0.0;
self.sum_xy = 0.0;
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.window.len() == self.period
}
fn name(&self) -> &'static str {
"LinRegSlope"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn reference_values() {
// period 3 over [1, 2, 9]: fit y = 0 + 4x, so the slope is 4.
let mut ls = LinRegSlope::new(3).unwrap();
let out = ls.batch(&[1.0, 2.0, 9.0]);
assert!(out[0].is_none());
assert!(out[1].is_none());
assert_relative_eq!(out[2].unwrap(), 4.0, epsilon = 1e-9);
}
#[test]
fn perfect_line_returns_its_step() {
// A series rising by a fixed step has exactly that slope.
let prices: Vec<f64> = (0..40).map(|i| 2.5 * f64::from(i) + 7.0).collect();
let mut ls = LinRegSlope::new(10).unwrap();
for v in ls.batch(&prices).into_iter().flatten() {
assert_relative_eq!(v, 2.5, epsilon = 1e-6);
}
}
#[test]
fn constant_series_has_zero_slope() {
let mut ls = LinRegSlope::new(8).unwrap();
for v in ls.batch(&[42.0; 20]).into_iter().flatten() {
assert_relative_eq!(v, 0.0, epsilon = 1e-9);
}
}
#[test]
fn falling_series_has_negative_slope() {
let prices: Vec<f64> = (0..30).map(|i| 100.0 - f64::from(i)).collect();
let mut ls = LinRegSlope::new(10).unwrap();
for v in ls.batch(&prices).into_iter().flatten() {
assert!(v < 0.0, "a falling series must have a negative slope");
}
}
#[test]
fn first_value_on_period_th_input() {
let mut ls = LinRegSlope::new(5).unwrap();
let out = ls.batch(&[1.0, 3.0, 2.0, 5.0, 4.0, 6.0]);
for (i, v) in out.iter().enumerate().take(4) {
assert!(v.is_none(), "index {i} must be None during warmup");
}
assert!(out[4].is_some(), "first value lands at index period - 1");
assert_eq!(ls.warmup_period(), 5);
}
#[test]
fn rejects_period_below_two() {
assert!(LinRegSlope::new(0).is_err());
assert!(LinRegSlope::new(1).is_err());
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();
ls.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
assert!(ls.is_ready());
ls.reset();
assert!(!ls.is_ready());
assert_eq!(ls.update(1.0), None);
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (0..60)
.map(|i| 50.0 + (f64::from(i) * 0.3).sin() * 10.0)
.collect();
let mut a = LinRegSlope::new(14).unwrap();
let mut b = LinRegSlope::new(14).unwrap();
assert_eq!(
a.batch(&prices),
prices.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
/// Incremental OLS equivalence for the slope: the O(1) implementation must
/// agree bar-by-bar with a fresh-from-scratch O(n) refit, on a noisy ramp
/// (sliding-phase dominated) and a step function (large pop/push deltas).
#[test]
fn incremental_matches_naive_slope_bar_by_bar() {
fn naive_slope(window: &[f64]) -> f64 {
let n = window.len() as f64;
let mut sum_y = 0.0;
let mut sum_xy = 0.0;
let mut sum_x = 0.0;
let mut sum_xx = 0.0;
for (i, &y) in window.iter().enumerate() {
let x = i as f64;
sum_y += y;
sum_xy += x * y;
sum_x += x;
sum_xx += x * x;
}
(n * sum_xy - sum_x * sum_y) / (n * sum_xx - sum_x * sum_x)
}
fn check(prices: &[f64], period: usize) {
let mut ls = LinRegSlope::new(period).unwrap();
for (t, p) in prices.iter().enumerate() {
let streaming = ls.update(*p);
if t + 1 >= period {
let lo = t + 1 - period;
let expected = naive_slope(&prices[lo..=t]);
let got = streaming.expect("warmed up");
assert!(
(got - expected).abs() < 1e-9,
"slope diverges at t={t}, period={period}: got={got}, expected={expected}",
);
}
}
}
let noisy_ramp: Vec<f64> = (0..120)
.map(|i| 100.0 + f64::from(i) * 0.5 + (f64::from(i) * 0.7).sin() * 3.0)
.collect();
check(&noisy_ramp, 5);
check(&noisy_ramp, 14);
let mut step = vec![1.0; 30];
step.extend(std::iter::repeat_n(100.0, 30));
check(&step, 7);
}
}
+40
View File
@@ -21,6 +21,19 @@ pub struct MacdOutput {
/// is seeded from the first `signal` raw MACD values, so the first full
/// [`MacdOutput`] is emitted after `slow + signal 1` inputs (assuming the
/// slow EMA seeded by then).
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, MacdIndicator};
///
/// let mut indicator = MacdIndicator::new(3, 6, 3).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// last = indicator.update(100.0 + f64::from(i));
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct MacdIndicator {
fast: Ema,
@@ -142,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!(
@@ -227,4 +255,16 @@ mod tests {
assert!(!macd.is_ready());
assert_eq!(macd.update(1.0), None);
}
#[test]
fn ignores_non_finite_input() {
let mut macd = MacdIndicator::classic();
macd.batch(&(1..=80).map(f64::from).collect::<Vec<_>>());
let before = macd.value();
assert!(before.is_some());
// Non-finite inputs return the last value without advancing any EMA.
assert_eq!(macd.update(f64::NAN), before);
assert_eq!(macd.update(f64::INFINITY), before);
assert_eq!(macd.value(), before);
}
}
@@ -0,0 +1,234 @@
//! Mass Index.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
use super::Ema;
/// Mass Index — Donald Dorsey's range-expansion indicator.
///
/// The Mass Index watches the highlow range, not direction. It smooths the
/// range with an EMA, smooths that again, takes the ratio of the two, and sums
/// the ratio over a window:
///
/// ```text
/// range_t = high_t low_t
/// ratio_t = EMA(range, ema_period) / EMA(EMA(range, ema_period), ema_period)
/// MassIndex = Σ ratio over sum_period
/// ```
///
/// When the range widens, the single EMA pulls ahead of the double EMA, the
/// ratio rises above `1`, and the sum climbs. Dorsey's "reversal bulge" is the
/// Mass Index rising above `27` and then falling back below `26.5` — a sign
/// that a range expansion is about to resolve into a trend reversal. With the
/// conventional `(ema_period = 9, sum_period = 25)` a flat-range market sits at
/// `25`.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, MassIndex};
///
/// let mut indicator = MassIndex::new(9, 25).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// let base = 100.0 + i as f64;
/// let candle =
/// Candle::new(base, base + 2.0, base - 2.0, base, 10.0, i64::from(i)).unwrap();
/// last = indicator.update(candle);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct MassIndex {
ema_period: usize,
sum_period: usize,
ema1: Ema,
ema2: Ema,
/// Rolling window of the last `sum_period` EMA ratios.
window: VecDeque<f64>,
sum: f64,
last: Option<f64>,
}
impl MassIndex {
/// Construct a new Mass Index with the EMA smoothing period and the sum
/// window length.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if either period is `0`.
pub fn new(ema_period: usize, sum_period: usize) -> Result<Self> {
if ema_period == 0 || sum_period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
ema_period,
sum_period,
ema1: Ema::new(ema_period)?,
ema2: Ema::new(ema_period)?,
window: VecDeque::with_capacity(sum_period),
sum: 0.0,
last: None,
})
}
/// The `(ema_period, sum_period)` pair.
pub const fn periods(&self) -> (usize, usize) {
(self.ema_period, self.sum_period)
}
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.last
}
}
impl Indicator for MassIndex {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
let range = candle.high - candle.low;
let single = self.ema1.update(range)?;
let double = self.ema2.update(single)?;
let ratio = if double == 0.0 {
// A zero-range market: no expansion, neutral ratio.
1.0
} else {
single / double
};
if self.window.len() == self.sum_period {
self.sum -= self.window.pop_front().expect("window is non-empty");
}
self.window.push_back(ratio);
self.sum += ratio;
if self.window.len() < self.sum_period {
return None;
}
self.last = Some(self.sum);
Some(self.sum)
}
fn reset(&mut self) {
self.ema1.reset();
self.ema2.reset();
self.window.clear();
self.sum = 0.0;
self.last = None;
}
fn warmup_period(&self) -> usize {
// ema1 seeds at `ema_period`, ema2 at `2·ema_period 1`, then the sum
// window needs `sum_period` ratios.
2 * self.ema_period + self.sum_period - 2
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"MassIndex"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
/// A candle with a fixed highlow range `span` centred on `mid`.
fn candle(mid: f64, span: f64, ts: i64) -> Candle {
Candle::new(mid, mid + span / 2.0, mid - span / 2.0, mid, 1.0, ts).unwrap()
}
#[test]
fn new_rejects_zero_period() {
assert!(matches!(MassIndex::new(0, 25), Err(Error::PeriodZero)));
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();
assert_eq!(mi.warmup_period(), 2 * 9 + 25 - 2);
}
#[test]
fn first_emission_at_warmup_period() {
let mut mi = MassIndex::new(3, 4).unwrap();
let warmup = mi.warmup_period(); // 2*3 + 4 - 2 = 8
assert_eq!(warmup, 8);
let candles: Vec<Candle> = (0..20).map(|i| candle(100.0 + i as f64, 2.0, i)).collect();
let out = mi.batch(&candles);
for v in out.iter().take(warmup - 1) {
assert!(v.is_none());
}
assert!(out[warmup - 1].is_some());
}
#[test]
fn constant_range_sums_to_sum_period() {
// A constant highlow range makes both EMAs converge to the same
// value, so every ratio is 1 and the Mass Index equals `sum_period`.
let mut mi = MassIndex::new(3, 4).unwrap();
let candles: Vec<Candle> = (0..40).map(|i| candle(100.0 + i as f64, 2.0, i)).collect();
for v in mi.batch(&candles).into_iter().flatten() {
assert_relative_eq!(v, 4.0, epsilon = 1e-9);
}
}
#[test]
fn zero_range_market_sums_to_sum_period() {
let mut mi = MassIndex::new(3, 4).unwrap();
let candles: Vec<Candle> = (0..40).map(|i| candle(100.0, 0.0, i)).collect();
for v in mi.batch(&candles).into_iter().flatten() {
assert_relative_eq!(v, 4.0, epsilon = 1e-12);
}
}
#[test]
fn reset_clears_state() {
let mut mi = MassIndex::new(3, 4).unwrap();
let candles: Vec<Candle> = (0..20).map(|i| candle(100.0 + i as f64, 2.0, i)).collect();
mi.batch(&candles);
assert!(mi.is_ready());
mi.reset();
assert!(!mi.is_ready());
assert_eq!(mi.update(candles[0]), None);
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..120)
.map(|i| {
let span = 2.0 + (i as f64 * 0.3).sin().abs() * 3.0;
candle(100.0 + (i as f64 * 0.2).cos() * 5.0, span, i)
})
.collect();
let batch = MassIndex::new(9, 25).unwrap().batch(&candles);
let mut b = MassIndex::new(9, 25).unwrap();
let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,128 @@
//! Median Price.
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Median Price — the bar's `(high + low) / 2`.
///
/// The midpoint of the bar's range, ignoring where it opened or closed. It is
/// the price series Bill Williams' [`AwesomeOscillator`](crate::AwesomeOscillator)
/// is built on, and a smoother stand-in for the close when feeding other
/// indicators. As a stateless per-bar transform it emits a value from the
/// very first candle.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, MedianPrice};
///
/// let mut indicator = MedianPrice::new();
/// let mut last = None;
/// for i in 0..80 {
/// let base = 100.0 + f64::from(i);
/// let candle =
/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
/// last = indicator.update(candle);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone, Default)]
pub struct MedianPrice {
has_emitted: bool,
}
impl MedianPrice {
/// Construct a new Median Price transform.
pub const fn new() -> Self {
Self { has_emitted: false }
}
}
impl Indicator for MedianPrice {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
self.has_emitted = true;
Some(candle.median_price())
}
fn reset(&mut self) {
self.has_emitted = false;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"MedianPrice"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn candle(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle {
Candle::new(open, high, low, close, 1.0, ts).unwrap()
}
#[test]
fn reference_value() {
// (high + low) / 2 = (12 + 8) / 2 = 10.
let mut mp = MedianPrice::new();
assert_relative_eq!(
mp.update(candle(10.0, 12.0, 8.0, 11.0, 0)).unwrap(),
10.0,
epsilon = 1e-12
);
}
/// 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();
assert_eq!(mp.warmup_period(), 1);
assert!(!mp.is_ready());
assert!(mp.update(candle(10.0, 11.0, 9.0, 10.0, 0)).is_some());
assert!(mp.is_ready());
}
#[test]
fn reset_clears_state() {
let mut mp = MedianPrice::new();
mp.update(candle(10.0, 11.0, 9.0, 10.0, 0));
assert!(mp.is_ready());
mp.reset();
assert!(!mp.is_ready());
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..40)
.map(|i| {
let base = 100.0 + i as f64;
candle(base, base + 2.0, base - 2.0, base + 1.0, i)
})
.collect();
let mut a = MedianPrice::new();
let mut b = MedianPrice::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
+97 -16
View File
@@ -11,6 +11,22 @@ use crate::traits::Indicator;
/// `MFI = 100 - 100 / (1 + positive_money_flow / negative_money_flow)` where
/// money flow is `typical_price * volume`, classified positive when TP increases
/// and negative when it decreases.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, Mfi};
///
/// let mut indicator = Mfi::new(5).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// let base = 100.0 + f64::from(i);
/// let candle =
/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
/// last = indicator.update(candle);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct Mfi {
period: usize,
@@ -50,18 +66,23 @@ impl Indicator for Mfi {
fn update(&mut self, candle: Candle) -> Option<f64> {
let tp = candle.typical_price();
// The very first candle only establishes the previous typical price.
// It carries no money-flow direction, so it is not pushed into the
// window. This matches TA-Lib / pandas-ta, which need `period + 1`
// candles before the first MFI value.
let Some(prev) = self.prev_tp else {
self.prev_tp = Some(tp);
return None;
};
let mf = tp * candle.volume;
let (pos_flow, neg_flow) = match self.prev_tp {
None => (0.0, 0.0),
Some(prev) => {
if tp > prev {
(mf, 0.0)
} else if tp < prev {
(0.0, mf)
} else {
(0.0, 0.0)
}
}
let (pos_flow, neg_flow) = if tp > prev {
(mf, 0.0)
} else if tp < prev {
(0.0, mf)
} else {
(0.0, 0.0)
};
if self.pos_window.len() == self.period {
@@ -75,12 +96,11 @@ impl Indicator for Mfi {
self.prev_tp = Some(tp);
// Need period+1 candles total (the first one only gives prev_tp).
if self.prev_tp.is_none() || self.pos_window.len() < self.period {
if self.pos_window.len() < self.period {
return None;
}
// Need at least one comparison-based flow inside the window, otherwise we
// are still on the very first candle.
// A fully flat window (every typical price equal) has zero flow on
// both sides; by convention MFI is then 50.
if self.pos_sum == 0.0 && self.neg_sum == 0.0 {
return Some(50.0);
}
@@ -100,7 +120,9 @@ impl Indicator for Mfi {
}
fn warmup_period(&self) -> usize {
self.period
// One seed candle establishes the first previous typical price, then
// `period` flow comparisons fill the window.
self.period + 1
}
fn is_ready(&self) -> bool {
@@ -159,8 +181,67 @@ 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());
}
#[test]
fn first_value_emitted_on_period_plus_one_candle() {
// The seed candle plus `period` flow comparisons -> first MFI on the
// (period + 1)-th candle (index `period`).
let candles: Vec<Candle> = (1..=20).map(|i| c(f64::from(i), 100.0)).collect();
let mut mfi = Mfi::new(5).unwrap();
let out = mfi.batch(&candles);
for (i, v) in out.iter().enumerate().take(5) {
assert!(v.is_none(), "candle index {i} must be None during warmup");
}
assert!(
out[5].is_some(),
"first MFI value lands at index period (5)"
);
assert_eq!(mfi.warmup_period(), 6);
}
#[test]
fn known_value_period_2() {
// Three candles, MFI(2). Candle 1 (tp=10) only seeds the previous TP.
// Candle 2 (tp=12 > 10): positive money flow 12 * 100 = 1200.
// Candle 3 (tp=11 < 12): negative money flow 11 * 100 = 1100.
// money ratio = 1200 / 1100; MFI = 100 - 100 / (1 + 1200/1100) = 1200/23.
let candles = vec![c(10.0, 100.0), c(12.0, 100.0), c(11.0, 100.0)];
let mut mfi = Mfi::new(2).unwrap();
let out = mfi.batch(&candles);
assert!(out[0].is_none());
assert!(out[1].is_none());
assert_relative_eq!(out[2].unwrap(), 1200.0 / 23.0, epsilon = 1e-9);
}
}
+92
View File
@@ -4,54 +4,146 @@
//! volume) but every public name is also re-exported flat from this module and
//! from the crate root for convenience.
mod accelerator_oscillator;
mod adl;
mod adx;
mod aroon;
mod aroon_oscillator;
mod atr;
mod atr_trailing_stop;
mod awesome_oscillator;
mod balance_of_power;
mod bollinger;
mod bollinger_bandwidth;
mod cci;
mod chaikin_oscillator;
mod chaikin_volatility;
mod chande_kroll_stop;
mod chandelier_exit;
mod choppiness_index;
mod cmf;
mod cmo;
mod coppock;
mod dema;
mod donchian;
mod dpo;
mod ease_of_movement;
mod ema;
mod force_index;
mod historical_volatility;
mod hma;
mod kama;
mod keltner;
mod linreg;
mod linreg_angle;
mod linreg_slope;
mod macd;
mod mass_index;
mod median_price;
mod mfi;
mod mom;
mod natr;
mod obv;
mod percent_b;
mod pmo;
mod ppo;
mod psar;
mod roc;
mod rsi;
mod sma;
mod smma;
mod std_dev;
mod stoch_rsi;
mod stochastic;
mod super_trend;
mod t3;
mod tema;
mod trima;
mod trix;
mod true_range;
mod tsi;
mod typical_price;
mod ulcer_index;
mod ultimate_oscillator;
mod vertical_horizontal_filter;
mod vortex;
mod vpt;
mod vwap;
mod vwma;
mod weighted_close;
mod williams_r;
mod wma;
mod z_score;
mod zlema;
pub use accelerator_oscillator::AcceleratorOscillator;
pub use adl::Adl;
pub use adx::{Adx, AdxOutput};
pub use aroon::{Aroon, AroonOutput};
pub use aroon_oscillator::AroonOscillator;
pub use atr::Atr;
pub use atr_trailing_stop::AtrTrailingStop;
pub use awesome_oscillator::AwesomeOscillator;
pub use balance_of_power::BalanceOfPower;
pub use bollinger::{BollingerBands, BollingerOutput};
pub use bollinger_bandwidth::BollingerBandwidth;
pub use cci::Cci;
pub use chaikin_oscillator::ChaikinOscillator;
pub use chaikin_volatility::ChaikinVolatility;
pub use chande_kroll_stop::{ChandeKrollStop, ChandeKrollStopOutput};
pub use chandelier_exit::{ChandelierExit, ChandelierExitOutput};
pub use choppiness_index::ChoppinessIndex;
pub use cmf::ChaikinMoneyFlow;
pub use cmo::Cmo;
pub use coppock::Coppock;
pub use dema::Dema;
pub use donchian::{Donchian, DonchianOutput};
pub use dpo::Dpo;
pub use ease_of_movement::EaseOfMovement;
pub use ema::Ema;
pub use force_index::ForceIndex;
pub use historical_volatility::HistoricalVolatility;
pub use hma::Hma;
pub use kama::Kama;
pub use keltner::{Keltner, KeltnerOutput};
pub use linreg::LinearRegression;
pub use linreg_angle::LinRegAngle;
pub use linreg_slope::LinRegSlope;
pub use macd::{MacdIndicator, MacdOutput};
pub use mass_index::MassIndex;
pub use median_price::MedianPrice;
pub use mfi::Mfi;
pub use mom::Mom;
pub use natr::Natr;
pub use obv::Obv;
pub use percent_b::PercentB;
pub use pmo::Pmo;
pub use ppo::Ppo;
pub use psar::Psar;
pub use roc::Roc;
pub use rsi::Rsi;
pub use sma::Sma;
pub use smma::Smma;
pub use std_dev::StdDev;
pub use stoch_rsi::StochRsi;
pub use stochastic::{Stochastic, StochasticOutput};
pub use super_trend::{SuperTrend, SuperTrendOutput};
pub use t3::T3;
pub use tema::Tema;
pub use trima::Trima;
pub use trix::Trix;
pub use true_range::TrueRange;
pub use tsi::Tsi;
pub use typical_price::TypicalPrice;
pub use ulcer_index::UlcerIndex;
pub use ultimate_oscillator::UltimateOscillator;
pub use vertical_horizontal_filter::VerticalHorizontalFilter;
pub use vortex::{Vortex, VortexOutput};
pub use vpt::VolumePriceTrend;
pub use vwap::{RollingVwap, Vwap};
pub use vwma::Vwma;
pub use weighted_close::WeightedClose;
pub use williams_r::WilliamsR;
pub use wma::Wma;
pub use z_score::ZScore;
pub use zlema::Zlema;
+182
View File
@@ -0,0 +1,182 @@
//! Momentum (absolute price change over a fixed lookback).
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Momentum: the raw price change over `period` bars, `price_t price_{tperiod}`.
///
/// Unlike [`Roc`](crate::Roc), which divides by the old price to give a
/// percentage, `Mom` reports the change in absolute price units. It is the
/// simplest momentum primitive: positive values mean price is higher than it
/// was `period` bars ago, negative values mean lower.
///
/// Non-finite inputs are ignored and leave the window untouched; the last
/// computed value is returned instead.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, Mom};
///
/// let mut indicator = Mom::new(3).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// last = indicator.update(100.0 + f64::from(i));
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct Mom {
period: usize,
/// Rolling buffer of the last `period + 1` inputs, oldest at the front.
window: VecDeque<f64>,
last: Option<f64>,
}
impl Mom {
/// Construct a new momentum indicator with the given lookback period.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
window: VecDeque::with_capacity(period + 1),
last: None,
})
}
/// Configured lookback period.
pub const fn period(&self) -> usize {
self.period
}
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.last
}
}
impl Indicator for Mom {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() {
// Non-finite input is ignored; the window is left untouched.
return self.last;
}
if self.window.len() == self.period + 1 {
self.window.pop_front();
}
self.window.push_back(input);
if self.window.len() < self.period + 1 {
return None;
}
let prev = *self.window.front().expect("window is non-empty");
let mom = input - prev;
self.last = Some(mom);
Some(mom)
}
fn reset(&mut self) {
self.window.clear();
self.last = None;
}
fn warmup_period(&self) -> usize {
self.period + 1
}
fn is_ready(&self) -> bool {
self.window.len() == self.period + 1
}
fn name(&self) -> &'static str {
"MOM"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn new_rejects_zero_period() {
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}.
let mut mom = Mom::new(3).unwrap();
let out = mom.batch(&[1.0, 2.0, 3.0, 4.0, 7.0]);
assert_eq!(mom.warmup_period(), 4);
assert_eq!(out[0], None);
assert_eq!(out[2], None);
assert_relative_eq!(out[3].unwrap(), 4.0 - 1.0, epsilon = 1e-12);
assert_relative_eq!(out[4].unwrap(), 7.0 - 2.0, epsilon = 1e-12);
}
#[test]
fn constant_series_yields_zero() {
let mut mom = Mom::new(5).unwrap();
let out = mom.batch(&[10.0; 20]);
for v in out.iter().skip(5).flatten() {
assert_relative_eq!(*v, 0.0, epsilon = 1e-12);
}
}
#[test]
fn ignores_non_finite_input() {
let mut mom = Mom::new(3).unwrap();
let out = mom.batch(&[1.0, 2.0, 3.0, 4.0]);
let ready = out[3].expect("MOM(3) ready after four inputs");
assert_eq!(mom.update(f64::NAN), Some(ready));
assert_eq!(mom.update(f64::INFINITY), Some(ready));
// Window untouched: the next finite input still references price 2.
assert_relative_eq!(mom.update(10.0).unwrap(), 10.0 - 2.0, epsilon = 1e-12);
}
#[test]
fn reset_clears_state() {
let mut mom = Mom::new(3).unwrap();
mom.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
assert!(mom.is_ready());
mom.reset();
assert!(!mom.is_ready());
assert_eq!(mom.update(1.0), None);
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (1..=40).map(|i| f64::from(i) * 1.5).collect();
let batch = Mom::new(7).unwrap().batch(&prices);
let mut b = Mom::new(7).unwrap();
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
assert_eq!(batch, streamed);
}
}
+216
View File
@@ -0,0 +1,216 @@
//! Normalized Average True Range.
use crate::error::Result;
use crate::ohlcv::Candle;
use crate::traits::Indicator;
use super::Atr;
/// Normalized Average True Range — [`Atr`] expressed as a percentage of price.
///
/// `Atr` reports volatility in raw price units, which makes its readings
/// impossible to compare across instruments at different price levels. NATR
/// fixes that by dividing by the current close:
///
/// ```text
/// NATR = 100 · ATR / close
/// ```
///
/// A NATR of `2.0` always means "the average true range is 2 % of price",
/// whether the instrument trades at $10 or $10 000 — so NATR values are
/// directly comparable, and stop distances or position sizes expressed as a
/// NATR multiple behave consistently across a portfolio.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, Natr};
///
/// let mut indicator = Natr::new(14).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// let base = 100.0 + f64::from(i);
/// let candle =
/// Candle::new(base, base + 2.0, base - 2.0, base, 10.0, i64::from(i)).unwrap();
/// last = indicator.update(candle);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct Natr {
atr: Atr,
last: Option<f64>,
}
impl Natr {
/// Construct a new NATR with the given ATR period.
///
/// # Errors
///
/// Returns [`crate::Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
Ok(Self {
atr: Atr::new(period)?,
last: None,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.atr.period()
}
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.last
}
}
impl Indicator for Natr {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
let atr = self.atr.update(candle)?;
let natr = if candle.close == 0.0 {
// NATR is undefined against a zero close.
0.0
} else {
100.0 * atr / candle.close
};
self.last = Some(natr);
Some(natr)
}
fn reset(&mut self) {
self.atr.reset();
self.last = None;
}
fn warmup_period(&self) -> usize {
self.atr.warmup_period()
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"NATR"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn candle(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle {
Candle::new(open, high, low, close, 1.0, ts).unwrap()
}
#[test]
fn new_rejects_zero_period() {
assert!(Natr::new(0).is_err());
}
#[test]
fn warmup_period_matches_atr() {
let natr = Natr::new(14).unwrap();
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.
let candles: Vec<Candle> = (0..60)
.map(|i| {
let mid = 100.0 + (i as f64 * 0.3).sin() * 10.0;
candle(mid, mid + 3.0, mid - 3.0, mid + 1.0, i)
})
.collect();
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() {
// 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);
}
}
}
#[test]
fn flat_market_yields_zero() {
// No range -> ATR is 0 -> NATR is 0.
let mut natr = Natr::new(5).unwrap();
let candles: Vec<Candle> = (0..30)
.map(|i| candle(100.0, 100.0, 100.0, 100.0, i))
.collect();
for v in natr.batch(&candles).into_iter().flatten() {
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
}
}
#[test]
fn reset_clears_state() {
let mut natr = Natr::new(5).unwrap();
let candles: Vec<Candle> = (0..20)
.map(|i| candle(100.0, 102.0, 98.0, 101.0, i))
.collect();
natr.batch(&candles);
assert!(natr.is_ready());
natr.reset();
assert!(!natr.is_ready());
assert_eq!(natr.update(candles[0]), None);
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..80)
.map(|i| {
let mid = 100.0 + (i as f64 * 0.35).sin() * 9.0;
candle(mid, mid + 2.5, mid - 2.5, mid + 0.5, i)
})
.collect();
let batch = Natr::new(14).unwrap().batch(&candles);
let mut b = Natr::new(14).unwrap();
let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
assert_eq!(batch, streamed);
}
}
+31
View File
@@ -8,6 +8,22 @@ use crate::traits::Indicator;
/// Each candle adds `+volume`, `-volume`, or `0` depending on whether its close
/// is above, below, or equal to the previous close. The first value (after the
/// first candle) is conventionally `0`.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, Obv};
///
/// let mut indicator = Obv::new();
/// let mut last = None;
/// for i in 0..80 {
/// let base = 100.0 + f64::from(i);
/// let candle =
/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
/// last = indicator.update(candle);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone, Default)]
pub struct Obv {
prev_close: Option<f64>,
@@ -83,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();
@@ -0,0 +1,203 @@
//! Bollinger %b.
use crate::error::Result;
use crate::traits::Indicator;
use super::BollingerBands;
/// Bollinger %b — where price sits within the Bollinger Bands.
///
/// ```text
/// %b = (price lower) / (upper lower)
/// ```
///
/// `%b = 1` means price is exactly on the upper band, `%b = 0` on the lower
/// band, `%b = 0.5` on the middle band. The value is **not** clamped: price
/// breaking above the upper band gives `%b > 1`, breaking below the lower band
/// gives `%b < 0`. That makes %b a clean, scale-free way to compare a price's
/// band position across instruments and to spot band overshoots.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, PercentB};
///
/// let mut indicator = PercentB::new(20, 2.0).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// last = indicator.update(100.0 + (f64::from(i) * 0.3).sin() * 6.0);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct PercentB {
bands: BollingerBands,
last: Option<f64>,
}
impl PercentB {
/// Construct a new %b indicator.
///
/// # Errors
///
/// Returns [`crate::Error::PeriodZero`] for `period == 0` and
/// [`crate::Error::NonPositiveMultiplier`] for `multiplier <= 0`.
pub fn new(period: usize, multiplier: f64) -> Result<Self> {
Ok(Self {
bands: BollingerBands::new(period, multiplier)?,
last: None,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.bands.period()
}
/// Configured multiplier.
pub const fn multiplier(&self) -> f64 {
self.bands.multiplier()
}
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.last
}
}
impl Indicator for PercentB {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
let o = self.bands.update(input)?;
let width = o.upper - o.lower;
let percent_b = if width == 0.0 {
// Bands collapsed onto the middle: price is exactly mid-band.
0.5
} else {
(input - o.lower) / width
};
self.last = Some(percent_b);
Some(percent_b)
}
fn reset(&mut self) {
self.bands.reset();
self.last = None;
}
fn warmup_period(&self) -> usize {
self.bands.warmup_period()
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"PercentB"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn new_rejects_invalid_parameters() {
assert!(PercentB::new(0, 2.0).is_err());
assert!(PercentB::new(20, 0.0).is_err());
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.
let mut pb = PercentB::new(5, 2.0).unwrap();
let out = pb.batch(&[100.0; 20]);
for v in out.iter().skip(4).flatten() {
assert_relative_eq!(*v, 0.5, epsilon = 1e-12);
}
}
#[test]
fn matches_bands_definition() {
// %b must equal (price - lower) / (upper - lower) from BollingerBands.
let prices: Vec<f64> = (1..=60)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 8.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() {
// 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() {
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]
fn reset_clears_state() {
let mut pb = PercentB::new(5, 2.0).unwrap();
pb.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
assert!(pb.is_ready());
pb.reset();
assert!(!pb.is_ready());
assert_eq!(pb.update(1.0), None);
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (1..=80)
.map(|i| 100.0 + (f64::from(i) * 0.3).cos() * 7.0)
.collect();
let batch = PercentB::new(20, 2.0).unwrap().batch(&prices);
let mut b = PercentB::new(20, 2.0).unwrap();
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
assert_eq!(batch, streamed);
}
}
+244
View File
@@ -0,0 +1,244 @@
//! Price Momentum Oscillator (`DecisionPoint`).
use crate::error::{Error, Result};
use crate::traits::Indicator;
use super::Ema;
/// Price Momentum Oscillator — Carl Swenlin's `DecisionPoint` PMO line.
///
/// PMO is a doubly-smoothed rate of change. The 1-bar percentage change is
/// smoothed once, scaled by `10`, then smoothed again:
///
/// ```text
/// roc_t = (price_t / price_{t1} 1) · 100
/// smoothed_t = customEMA(roc, smoothing1)_t
/// PMO_t = customEMA(10 · smoothed, smoothing2)_t
/// ```
///
/// `customEMA` is the `DecisionPoint` smoothing: an exponential average whose
/// smoothing constant is `2 / period` (not the textbook `2 / (period + 1)`),
/// seeded from the very first value. The conventional periods are `35` and
/// `20`. The classic PMO **signal line** is simply a 10-period EMA of this
/// PMO line — compose it with [`Chain`](crate::Chain) and an [`Ema`] if you
/// need it.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, Pmo};
///
/// let mut indicator = Pmo::new(35, 20).unwrap();
/// let mut last = None;
/// for i in 0..120 {
/// last = indicator.update(100.0 + f64::from(i));
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct Pmo {
smoothing1: usize,
smoothing2: usize,
prev_price: Option<f64>,
ema1: Ema,
ema2: Ema,
current: Option<f64>,
}
impl Pmo {
/// Construct a new PMO with the two smoothing periods.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if either period is `0`, or
/// [`Error::InvalidPeriod`] if either is `1` (the smoothing constant
/// `2 / period` must not exceed `1`).
pub fn new(smoothing1: usize, smoothing2: usize) -> Result<Self> {
if smoothing1 == 0 || smoothing2 == 0 {
return Err(Error::PeriodZero);
}
if smoothing1 < 2 || smoothing2 < 2 {
return Err(Error::InvalidPeriod {
message: "PMO smoothing periods must be >= 2",
});
}
Ok(Self {
smoothing1,
smoothing2,
prev_price: None,
ema1: Ema::with_alpha(2.0 / smoothing1 as f64)?,
ema2: Ema::with_alpha(2.0 / smoothing2 as f64)?,
current: None,
})
}
/// The `(smoothing1, smoothing2)` periods.
pub const fn periods(&self) -> (usize, usize) {
(self.smoothing1, self.smoothing2)
}
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.current
}
}
impl Indicator for Pmo {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() {
// Non-finite input is ignored; state is left untouched.
return self.current;
}
let Some(prev) = self.prev_price else {
self.prev_price = Some(input);
return None;
};
self.prev_price = Some(input);
let roc = if prev == 0.0 {
// Undefined ratio against a zero price: treat momentum as flat.
0.0
} else {
(input / prev - 1.0) * 100.0
};
let smoothed = self.ema1.update(roc)?;
let pmo = self.ema2.update(10.0 * smoothed)?;
self.current = Some(pmo);
Some(pmo)
}
fn reset(&mut self) {
self.prev_price = None;
self.ema1.reset();
self.ema2.reset();
self.current = None;
}
fn warmup_period(&self) -> usize {
// The first ROC needs a previous price; both customEMAs seed from
// their first input, so the first PMO lands on the second update.
2
}
fn is_ready(&self) -> bool {
self.current.is_some()
}
fn name(&self) -> &'static str {
"PMO"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn new_rejects_zero_period() {
assert!(matches!(Pmo::new(0, 20), Err(Error::PeriodZero)));
assert!(matches!(Pmo::new(35, 0), Err(Error::PeriodZero)));
}
#[test]
fn new_rejects_period_one() {
assert!(matches!(Pmo::new(1, 20), Err(Error::InvalidPeriod { .. })));
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();
assert_eq!(pmo.warmup_period(), 2);
assert_eq!(pmo.update(100.0), None);
assert!(pmo.update(101.0).is_some());
}
#[test]
fn constant_series_yields_zero() {
// Flat prices -> ROC is always 0 -> both smoothings stay at 0.
let mut pmo = Pmo::new(35, 20).unwrap();
let out = pmo.batch(&[100.0; 60]);
for v in out.iter().skip(2).flatten() {
assert_relative_eq!(*v, 0.0, epsilon = 1e-12);
}
}
#[test]
fn steady_uptrend_is_positive() {
let mut pmo = Pmo::new(35, 20).unwrap();
let prices: Vec<f64> = (1..=120).map(|i| 100.0 * 1.01_f64.powi(i)).collect();
let out = pmo.batch(&prices);
let last = out.iter().rev().flatten().next().unwrap();
assert!(
*last > 0.0,
"steady uptrend PMO should be positive, got {last}"
);
}
#[test]
fn ignores_non_finite_input() {
let mut pmo = Pmo::new(35, 20).unwrap();
let out = pmo.batch(&(1..=60).map(f64::from).collect::<Vec<_>>());
let last = *out.last().unwrap();
assert!(last.is_some());
assert_eq!(pmo.update(f64::NAN), last);
assert_eq!(pmo.update(f64::INFINITY), last);
}
#[test]
fn reset_clears_state() {
let mut pmo = Pmo::new(35, 20).unwrap();
pmo.batch(&(1..=60).map(f64::from).collect::<Vec<_>>());
assert!(pmo.is_ready());
pmo.reset();
assert!(!pmo.is_ready());
assert_eq!(pmo.update(1.0), None);
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (1..=120)
.map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 8.0)
.collect();
let batch = Pmo::new(35, 20).unwrap().batch(&prices);
let mut b = Pmo::new(35, 20).unwrap();
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
assert_eq!(batch, streamed);
}
}
+233
View File
@@ -0,0 +1,233 @@
//! Percentage Price Oscillator.
use crate::error::{Error, Result};
use crate::traits::Indicator;
use super::Ema;
/// Percentage Price Oscillator — MACD expressed as a percentage.
///
/// PPO is the gap between a fast and a slow EMA, divided by the slow EMA and
/// scaled to a percentage:
///
/// ```text
/// PPO = 100 · (EMA_fast EMA_slow) / EMA_slow
/// ```
///
/// Dividing by the slow EMA makes PPO **scale-free**: a `PPO` of `1.5` means
/// "the fast EMA is 1.5 % above the slow EMA" on any instrument, so PPO
/// readings *are* comparable across assets — unlike the raw price-unit
/// [`MacdIndicator`](crate::MacdIndicator). The classic PPO **signal line** is
/// a 9-period EMA of this PPO line; compose it with [`Chain`](crate::Chain)
/// and an [`Ema`] if you need it.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, Ppo};
///
/// let mut indicator = Ppo::new(12, 26).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// last = indicator.update(100.0 + f64::from(i));
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct Ppo {
fast: usize,
slow: usize,
ema_fast: Ema,
ema_slow: Ema,
current: Option<f64>,
}
impl Ppo {
/// Construct a new PPO with the `fast` and `slow` EMA periods.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if either period is `0`, or
/// [`Error::InvalidPeriod`] if `fast >= slow`.
pub fn new(fast: usize, slow: usize) -> Result<Self> {
if fast == 0 || slow == 0 {
return Err(Error::PeriodZero);
}
if fast >= slow {
return Err(Error::InvalidPeriod {
message: "PPO fast period must be < slow period",
});
}
Ok(Self {
fast,
slow,
ema_fast: Ema::new(fast)?,
ema_slow: Ema::new(slow)?,
current: None,
})
}
/// The `(fast, slow)` periods.
pub const fn periods(&self) -> (usize, usize) {
(self.fast, self.slow)
}
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.current
}
}
impl Indicator for Ppo {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() {
// Non-finite input is ignored; the EMAs are not advanced.
return self.current;
}
let fast = self.ema_fast.update(input);
let slow = self.ema_slow.update(input);
match (fast, slow) {
(Some(f), Some(s)) => {
let ppo = if s == 0.0 {
// Undefined ratio against a zero slow EMA: report flat.
0.0
} else {
100.0 * (f - s) / s
};
self.current = Some(ppo);
Some(ppo)
}
_ => None,
}
}
fn reset(&mut self) {
self.ema_fast.reset();
self.ema_slow.reset();
self.current = None;
}
fn warmup_period(&self) -> usize {
// The slow EMA is the last to seed.
self.slow
}
fn is_ready(&self) -> bool {
self.current.is_some()
}
fn name(&self) -> &'static str {
"PPO"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn new_rejects_zero_period() {
assert!(matches!(Ppo::new(0, 26), Err(Error::PeriodZero)));
assert!(matches!(Ppo::new(12, 0), Err(Error::PeriodZero)));
}
#[test]
fn new_rejects_fast_not_less_than_slow() {
assert!(matches!(Ppo::new(26, 12), Err(Error::InvalidPeriod { .. })));
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();
assert_eq!(ppo.warmup_period(), 6);
let out = ppo.batch(&(1..=30).map(f64::from).collect::<Vec<_>>());
for v in out.iter().take(5) {
assert!(v.is_none());
}
assert!(out[5].is_some());
}
#[test]
fn constant_series_yields_zero() {
// Both EMAs converge to the constant, so their gap is zero.
let mut ppo = Ppo::new(3, 6).unwrap();
let out = ppo.batch(&[100.0; 60]);
for v in out.iter().skip(5).flatten() {
assert_relative_eq!(*v, 0.0, epsilon = 1e-9);
}
}
#[test]
fn uptrend_is_positive() {
// In a rising series the fast EMA leads the slow EMA, so PPO > 0.
let mut ppo = Ppo::new(5, 12).unwrap();
let out = ppo.batch(&(1..=80).map(f64::from).collect::<Vec<_>>());
let last = out.iter().rev().flatten().next().unwrap();
assert!(*last > 0.0, "uptrend PPO should be positive, got {last}");
}
#[test]
fn ignores_non_finite_input() {
let mut ppo = Ppo::new(3, 6).unwrap();
let out = ppo.batch(&(1..=30).map(f64::from).collect::<Vec<_>>());
let last = *out.last().unwrap();
assert!(last.is_some());
assert_eq!(ppo.update(f64::NAN), last);
assert_eq!(ppo.update(f64::INFINITY), last);
}
#[test]
fn reset_clears_state() {
let mut ppo = Ppo::new(3, 6).unwrap();
ppo.batch(&(1..=30).map(f64::from).collect::<Vec<_>>());
assert!(ppo.is_ready());
ppo.reset();
assert!(!ppo.is_ready());
assert_eq!(ppo.update(1.0), None);
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (1..=120)
.map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 9.0)
.collect();
let batch = Ppo::new(12, 26).unwrap().batch(&prices);
let mut b = Ppo::new(12, 26).unwrap();
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
assert_eq!(batch, streamed);
}
}
+139 -24
View File
@@ -16,13 +16,40 @@ enum Trend {
/// Implementation follows Wilder's original recursion: each step computes a new
/// SAR from the previous SAR, extreme point (EP) and acceleration factor (AF);
/// the trend flips when price crosses the SAR.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, Psar};
///
/// let mut indicator = Psar::new(0.02, 0.02, 0.2).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// let base = 100.0 + f64::from(i);
/// let candle =
/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
/// last = indicator.update(candle);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct Psar {
af_start: f64,
af_step: f64,
af_max: f64,
/// `true` once the first candle has been observed and the seed values
/// (`prev_high`, `prev_low`, `sar`, `ep`) are valid. `false` is the
/// constructor / `reset()` state in which the compute-fields hold
/// `f64::NAN` sentinels.
initialised: bool,
/// `true` once `update` has returned the first `Some(sar)`. Drives
/// [`Indicator::is_ready`] so it matches the convention of every other
/// indicator: `is_ready() == true` ↔ the most recent `update` produced
/// (or could produce) a real value. PSAR's seed candle returns `None`
/// while `initialised` flips to `true`, which is why `is_ready` cannot
/// just mirror `initialised`.
has_emitted: bool,
prev_high: f64,
prev_low: f64,
trend: Trend,
@@ -53,11 +80,17 @@ impl Psar {
af_step,
af_max,
initialised: false,
prev_high: 0.0,
prev_low: 0.0,
has_emitted: false,
// NaN sentinels: any read of these fields before the seed candle
// overwrites them is a logic bug. The `initialised` flag gates
// every read, and the `debug_assert!` in `update` makes the
// invariant explicit so a future refactor cannot silently treat a
// sentinel as a real price.
prev_high: f64::NAN,
prev_low: f64::NAN,
trend: Trend::Up,
sar: 0.0,
ep: 0.0,
sar: f64::NAN,
ep: f64::NAN,
af: af_start,
})
}
@@ -74,8 +107,9 @@ impl Indicator for Psar {
fn update(&mut self, candle: Candle) -> Option<f64> {
if !self.initialised {
// Seed: the first emitted SAR comes on the second candle. Initial trend
// is chosen by whether the second close is above or below the first.
// Seed on the first candle; the first SAR is emitted on the second.
// The initial trend is assumed Up — PSAR's reversal logic flips it
// within the first few bars if the market is actually falling.
self.prev_high = candle.high;
self.prev_low = candle.low;
self.sar = candle.low;
@@ -83,9 +117,22 @@ impl Indicator for Psar {
self.trend = Trend::Up;
self.af = self.af_start;
self.initialised = true;
// `has_emitted` stays false — this is the seed bar; the first
// `Some` lands on the next call.
return None;
}
// After `initialised` flips to `true`, every compute field is guaranteed
// finite. This guards against a future refactor that changes the seed
// gate but leaves a NaN sentinel reachable.
debug_assert!(
self.prev_high.is_finite()
&& self.prev_low.is_finite()
&& self.sar.is_finite()
&& self.ep.is_finite(),
"PSAR seed state must be finite once initialised"
);
// Predicted SAR for this period (before clamping to prior two extremes).
let mut new_sar = self.sar + self.af * (self.ep - self.sar);
@@ -138,14 +185,22 @@ impl Indicator for Psar {
self.sar = output_sar;
self.prev_high = candle.high;
self.prev_low = candle.low;
self.has_emitted = true;
Some(output_sar)
}
fn reset(&mut self) {
// Restore every field to its constructor state. The compute fields
// return to `f64::NAN` sentinels so a future refactor that reads them
// before re-seeding cannot silently treat `0.0` as a real price.
self.initialised = false;
self.has_emitted = false;
self.prev_high = f64::NAN;
self.prev_low = f64::NAN;
self.trend = Trend::Up;
self.sar = f64::NAN;
self.ep = f64::NAN;
self.af = self.af_start;
self.sar = 0.0;
self.ep = 0.0;
}
fn warmup_period(&self) -> usize {
@@ -153,7 +208,13 @@ impl Indicator for Psar {
}
fn is_ready(&self) -> bool {
self.initialised
// Match the convention of every other indicator: `is_ready` flips to
// `true` only once a real value has been returned. The previous
// implementation returned `self.initialised`, which is `true` *after*
// the seed candle (which itself returns `None`) — so a streaming
// consumer that wrote `if ind.is_ready() { use(ind.update(c)?) }`
// would hit a `None` it didn't expect. (Audit finding R6.)
self.has_emitted
}
fn name(&self) -> &'static str {
@@ -185,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]
@@ -206,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]
@@ -231,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());
@@ -238,4 +313,44 @@ mod tests {
assert!(Psar::new(0.30, 0.02, 0.20).is_err());
assert!(Psar::new(f64::NAN, 0.02, 0.20).is_err());
}
#[test]
fn is_ready_only_after_first_some_value() {
// Audit R6: the previous implementation flipped `is_ready` to true on
// the seed candle (which returns `None`), making the convention
// `is_ready == last_value.is_some()` a lie. The new gate is
// `has_emitted`, set when `update` returns its first `Some`.
let mut psar = Psar::classic();
assert!(!psar.is_ready(), "fresh PSAR must not be ready");
let first = psar.update(c(11.0, 9.0, 10.0));
assert!(first.is_none(), "seed candle returns None by design");
assert!(
!psar.is_ready(),
"is_ready must stay false until a Some value is produced"
);
let second = psar.update(c(12.0, 10.0, 11.0));
assert!(second.is_some(), "second candle must emit");
assert!(
psar.is_ready(),
"is_ready must flip to true once a real value has been returned"
);
}
#[test]
fn reset_allows_clean_reuse() {
let candles: Vec<Candle> = (0..40)
.map(|i| {
let base = 100.0 + f64::from(i);
c(base + 0.5, base - 0.5, base)
})
.collect();
let mut psar = Psar::classic();
let first = psar.batch(&candles);
assert!(psar.is_ready());
psar.reset();
assert!(!psar.is_ready());
// A reset instance must reproduce a pristine run bit for bit.
let second = psar.batch(&candles);
assert_eq!(first, second);
}
}
+68 -5
View File
@@ -6,10 +6,27 @@ use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Rate of Change as a percentage: `(close - close[period]) / close[period] * 100`.
///
/// Non-finite inputs are ignored and leave the window untouched; the last
/// computed value is returned instead, matching the SMA / EMA convention.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, Roc};
///
/// let mut indicator = Roc::new(3).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// last = indicator.update(100.0 + f64::from(i));
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct Roc {
period: usize,
window: VecDeque<f64>,
last: Option<f64>,
}
impl Roc {
@@ -22,6 +39,7 @@ impl Roc {
Ok(Self {
period,
window: VecDeque::with_capacity(period + 1),
last: None,
})
}
@@ -36,8 +54,9 @@ impl Indicator for Roc {
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
// Non-finite inputs are ignored: return the last value, leave state as is.
if !input.is_finite() {
return None;
return self.last;
}
if self.window.len() == self.period + 1 {
self.window.pop_front();
@@ -47,14 +66,18 @@ impl Indicator for Roc {
return None;
}
let prev = *self.window.front().expect("non-empty");
if prev == 0.0 {
return Some(0.0);
}
Some((input - prev) / prev * 100.0)
let roc = if prev == 0.0 {
0.0
} else {
(input - prev) / prev * 100.0
};
self.last = Some(roc);
Some(roc)
}
fn reset(&mut self) {
self.window.clear();
self.last = None;
}
fn warmup_period(&self) -> usize {
@@ -117,4 +140,44 @@ mod tests {
fn rejects_zero_period() {
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();
let out = roc.batch(&[100.0, 105.0, 108.0, 110.0]);
let ready = out[3].expect("ROC(3) ready after four inputs");
// Non-finite inputs return the last value without sliding the window.
assert_eq!(roc.update(f64::NAN), Some(ready));
assert_eq!(roc.update(f64::INFINITY), Some(ready));
// Window untouched: the next finite input still references prev = 105.
assert_relative_eq!(
roc.update(115.0).unwrap(),
(115.0 - 105.0) / 105.0 * 100.0,
epsilon = 1e-12
);
}
}
+120
View File
@@ -9,6 +9,19 @@ use crate::traits::Indicator;
/// is produced after `period + 1` inputs: the seed averages the first `period`
/// gains and losses, and the first emitted RSI corresponds to the input at
/// index `period`.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, Rsi};
///
/// let mut indicator = Rsi::new(3).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// last = indicator.update(100.0 + f64::from(i));
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct Rsi {
period: usize,
@@ -140,11 +153,83 @@ mod tests {
use crate::traits::BatchExt;
use approx::assert_relative_eq;
/// Independent reference: Wilder RSI computed straight from the definition.
fn rsi_naive(prices: &[f64], period: usize) -> Vec<Option<f64>> {
let n = period as f64;
let mut out = vec![None; prices.len()];
let mut gains: Vec<f64> = Vec::new();
let mut losses: Vec<f64> = Vec::new();
let mut avg_gain: Option<f64> = None;
let mut avg_loss: Option<f64> = None;
let rsi_val = |ag: f64, al: f64| -> f64 {
if al == 0.0 {
if ag == 0.0 {
50.0
} else {
100.0
}
} else {
100.0 - 100.0 / (1.0 + ag / al)
}
};
for i in 1..prices.len() {
let diff = prices[i] - prices[i - 1];
let gain = if diff > 0.0 { diff } else { 0.0 };
let loss = if diff < 0.0 { -diff } else { 0.0 };
if let (Some(ag), Some(al)) = (avg_gain, avg_loss) {
let nag = (ag * (n - 1.0) + gain) / n;
let nal = (al * (n - 1.0) + loss) / n;
avg_gain = Some(nag);
avg_loss = Some(nal);
out[i] = Some(rsi_val(nag, nal));
} else {
gains.push(gain);
losses.push(loss);
if gains.len() == period {
let ag = gains.iter().sum::<f64>() / n;
let al = losses.iter().sum::<f64>() / n;
avg_gain = Some(ag);
avg_loss = Some(al);
out[i] = Some(rsi_val(ag, al));
}
}
}
out
}
#[test]
fn new_rejects_zero_period() {
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();
@@ -244,4 +329,39 @@ mod tests {
prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
);
}
#[test]
fn ignores_non_finite_input() {
let mut rsi = Rsi::new(3).unwrap();
rsi.batch(&[1.0, 2.0, 3.0, 4.0]);
let before = rsi.value();
assert!(before.is_some());
assert_eq!(rsi.update(f64::NAN), before);
assert_eq!(rsi.update(f64::INFINITY), before);
assert_eq!(rsi.value(), before);
}
proptest::proptest! {
#![proptest_config(proptest::test_runner::Config::with_cases(48))]
#[test]
fn rsi_matches_naive(
period in 1usize..20,
prices in proptest::collection::vec(1.0_f64..1000.0, 0..150),
) {
let mut rsi = Rsi::new(period).unwrap();
let got = rsi.batch(&prices);
let want = rsi_naive(&prices, period);
proptest::prop_assert_eq!(got.len(), want.len());
for (g, w) in got.iter().zip(want.iter()) {
match (g, w) {
(None, None) => {}
(Some(a), Some(b)) => proptest::prop_assert!(
(a - b).abs() < 1e-7,
"got={a} want={b}"
),
_ => proptest::prop_assert!(false, "warmup mismatch"),
}
}
}
}
}
+82 -3
View File
@@ -9,13 +9,45 @@ use crate::traits::Indicator;
///
/// Maintains a rolling sum so each update is O(1). Output equals
/// `sum(last `period` prices) / period` once the window is full; `None` before.
///
/// On long-running streams a single-subtract incremental sum can accumulate
/// rounding error (catastrophic cancellation when values of very different
/// magnitudes are alternately added and removed). To keep drift bounded, the
/// running sum is reseeded from the live window every `16 · period` updates —
/// O(1) amortised cost (`O(period)` work amortised over `O(period)` updates),
/// zero observable behaviour change on inputs that did not drift to begin
/// with, and a strict cap on accumulated rounding for streams that did.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, Sma};
///
/// let mut indicator = Sma::new(3).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// last = indicator.update(100.0 + f64::from(i));
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct Sma {
period: usize,
window: VecDeque<f64>,
sum: f64,
/// Number of finite updates since the running `sum` was last reseeded from
/// the live window. Caps accumulated floating-point drift on long streams.
/// See [`RECOMPUTE_EVERY`] below.
updates_since_recompute: usize,
}
/// How often (in finite updates) the incremental sum is reseeded from the live
/// window. The multiplier `16` is the smallest power of two that keeps the
/// amortised cost flat under any `period` while still bounding any drift to
/// roughly `16 · period · ULP · max(|x|)` — sub-picodollar on real-world price
/// scales.
const RECOMPUTE_EVERY: usize = 16;
impl Sma {
/// Construct a new SMA with the given window length.
///
@@ -30,6 +62,7 @@ impl Sma {
period,
window: VecDeque::with_capacity(period),
sum: 0.0,
updates_since_recompute: 0,
})
}
@@ -57,20 +90,26 @@ impl Indicator for Sma {
return self.value();
}
if self.window.len() == self.period {
// Drop the oldest from the sum to keep numerical drift bounded by recomputing
// the sum after each pop; a single subtract works in O(1) and is acceptable
// here because we use f64 throughout.
// Slide: drop the oldest, then add the new. Each step is a single
// f64 add/subtract — O(1) but introduces ~1 ULP of rounding noise.
// The periodic reseed below caps the accumulated drift.
let old = self.window.pop_front().expect("window non-empty");
self.sum -= old;
}
self.window.push_back(input);
self.sum += input;
self.updates_since_recompute += 1;
if self.updates_since_recompute >= RECOMPUTE_EVERY * self.period {
self.sum = self.window.iter().copied().sum();
self.updates_since_recompute = 0;
}
self.value()
}
fn reset(&mut self) {
self.window.clear();
self.sum = 0.0;
self.updates_since_recompute = 0;
}
fn warmup_period(&self) -> usize {
@@ -97,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();
@@ -197,4 +247,33 @@ mod tests {
}
}
}
/// Long-running stability check. Runs more updates than `RECOMPUTE_EVERY *
/// period` so the periodic reseed must fire several times, then asserts
/// that the reported SMA still equals a fresh from-scratch mean over the
/// live window to within tight floating-point tolerance. Inputs swing
/// between two magnitudes (`1e9` and `1.0`) — a pattern designed to
/// expose catastrophic cancellation in a naive single-subtract sum.
#[test]
fn long_stream_drift_stays_bounded() {
let period = 20;
let mut sma = Sma::new(period).unwrap();
let mut window: VecDeque<f64> = VecDeque::with_capacity(period);
// `RECOMPUTE_EVERY * period * 5` updates → recompute fires 5+ times.
let n_updates = 16 * period * 5;
for i in 0..n_updates {
let v = if i % 2 == 0 { 1e9 } else { 1.0 };
sma.update(v);
if window.len() == period {
window.pop_front();
}
window.push_back(v);
}
let from_scratch: f64 = window.iter().sum::<f64>() / period as f64;
let got = sma.value().expect("warmed up");
assert!(
(got - from_scratch).abs() < 1e-6,
"SMA drift exceeds 1e-6 over {n_updates} updates: got={got}, scratch={from_scratch}"
);
}
}
+198
View File
@@ -0,0 +1,198 @@
//! Smoothed Moving Average (Wilder's RMA).
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Smoothed Moving Average — Wilder's running moving average, also known as
/// RMA.
///
/// Seeded with the simple average of the first `period` inputs, then advanced
/// by `SMMA_t = (SMMA_{t-1} * (period - 1) + price_t) / period`. This is an
/// exponential average with a slow `1 / period` smoothing factor and is the
/// average underlying Wilder's RSI and ATR. The first output lands after
/// exactly `period` inputs.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, Smma};
///
/// let mut indicator = Smma::new(3).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// last = indicator.update(100.0 + f64::from(i));
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct Smma {
period: usize,
/// Inputs collected while seeding (before the first value is produced).
seed: VecDeque<f64>,
seed_sum: f64,
current: Option<f64>,
}
impl Smma {
/// Construct a new SMMA with the given period.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
seed: VecDeque::with_capacity(period),
seed_sum: 0.0,
current: None,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.current
}
}
impl Indicator for Smma {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() {
// Non-finite input is ignored, leaving state untouched.
return self.current;
}
if let Some(prev) = self.current {
let period = self.period as f64;
self.current = Some((prev * (period - 1.0) + input) / period);
} else {
self.seed.push_back(input);
self.seed_sum += input;
if self.seed.len() == self.period {
self.current = Some(self.seed_sum / self.period as f64);
}
}
self.current
}
fn reset(&mut self) {
self.seed.clear();
self.seed_sum = 0.0;
self.current = None;
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.current.is_some()
}
fn name(&self) -> &'static str {
"SMMA"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn new_rejects_zero_period() {
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.
let mut smma = Smma::new(3).unwrap();
assert_eq!(smma.update(1.0), None);
assert_eq!(smma.update(2.0), None);
assert_eq!(smma.update(3.0), Some(2.0));
assert_relative_eq!(
smma.update(4.0).unwrap(),
(2.0 * 2.0 + 4.0) / 3.0,
epsilon = 1e-12
);
assert_relative_eq!(
smma.update(5.0).unwrap(),
((2.0 * 2.0 + 4.0) / 3.0 * 2.0 + 5.0) / 3.0,
epsilon = 1e-12
);
}
#[test]
fn period_one_is_pass_through() {
let mut smma = Smma::new(1).unwrap();
assert_eq!(smma.update(5.0), Some(5.0));
assert_eq!(smma.update(10.0), Some(10.0));
}
#[test]
fn constant_series_yields_the_constant() {
let mut smma = Smma::new(5).unwrap();
let out = smma.batch(&[7.0; 20]);
for x in out.iter().skip(4) {
assert_relative_eq!(x.unwrap(), 7.0, epsilon = 1e-12);
}
}
#[test]
fn ignores_non_finite_input() {
let mut smma = Smma::new(3).unwrap();
smma.batch(&[1.0, 2.0, 3.0]);
assert_eq!(smma.update(f64::NAN), Some(2.0));
assert_eq!(smma.update(f64::INFINITY), Some(2.0));
}
#[test]
fn reset_clears_state() {
let mut smma = Smma::new(3).unwrap();
smma.batch(&[1.0, 2.0, 3.0, 4.0]);
assert!(smma.is_ready());
smma.reset();
assert!(!smma.is_ready());
assert_eq!(smma.update(10.0), None);
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (1..=30).map(f64::from).collect();
let batch = Smma::new(7).unwrap().batch(&prices);
let mut b = Smma::new(7).unwrap();
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,216 @@
//! Rolling population standard deviation.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Rolling population standard deviation over the last `period` values.
///
/// ```text
/// mean = (1/n) · Σ price
/// variance = (1/n) · Σ price² mean²
/// StdDev = √variance
/// ```
///
/// This is the **population** standard deviation (divisor `n`, not `n 1`) —
/// the same dispersion measure that drives [`BollingerBands`](crate::BollingerBands).
/// It is maintained as an O(1) rolling state machine: a running sum and a
/// running sum-of-squares, updated by one add and one subtract per bar. Tiny
/// negative variances from floating-point cancellation are clamped to zero
/// before the square root.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, StdDev};
///
/// let mut indicator = StdDev::new(20).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// last = indicator.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct StdDev {
period: usize,
window: VecDeque<f64>,
sum: f64,
sum_sq: f64,
last: Option<f64>,
}
impl StdDev {
/// Construct a new rolling standard deviation with the given period.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
window: VecDeque::with_capacity(period),
sum: 0.0,
sum_sq: 0.0,
last: None,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.last
}
}
impl Indicator for StdDev {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() {
// Non-finite input is ignored; the window is left untouched.
return self.last;
}
if self.window.len() == self.period {
let old = self.window.pop_front().expect("window is non-empty");
self.sum -= old;
self.sum_sq -= old * old;
}
self.window.push_back(input);
self.sum += input;
self.sum_sq += input * input;
if self.window.len() < self.period {
return None;
}
let n = self.period as f64;
let mean = self.sum / n;
// Clamp floating-point cancellation noise: variance is never negative.
let variance = (self.sum_sq / n - mean * mean).max(0.0);
let sd = variance.sqrt();
self.last = Some(sd);
Some(sd)
}
fn reset(&mut self) {
self.window.clear();
self.sum = 0.0;
self.sum_sq = 0.0;
self.last = None;
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"StdDev"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn new_rejects_zero_period() {
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.
let mut sd = StdDev::new(3).unwrap();
let out = sd.batch(&[2.0, 4.0, 6.0]);
assert_eq!(out[0], None);
assert_eq!(out[1], None);
assert_relative_eq!(out[2].unwrap(), (8.0_f64 / 3.0).sqrt(), epsilon = 1e-12);
}
#[test]
fn constant_series_yields_zero() {
let mut sd = StdDev::new(5).unwrap();
let out = sd.batch(&[42.0; 20]);
for v in out.iter().skip(4).flatten() {
assert_relative_eq!(*v, 0.0, epsilon = 1e-12);
}
}
#[test]
fn matches_naive_definition() {
let prices: Vec<f64> = (1..=60)
.map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 8.0)
.collect();
let period = 10;
let got = StdDev::new(period).unwrap().batch(&prices);
for (i, g) in got.iter().enumerate() {
if let Some(value) = g {
let window = &prices[i + 1 - period..=i];
let mean = window.iter().sum::<f64>() / period as f64;
let var = window.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / period as f64;
assert_relative_eq!(*value, var.sqrt(), epsilon = 1e-9);
}
}
}
#[test]
fn ignores_non_finite_input() {
let mut sd = StdDev::new(3).unwrap();
let out = sd.batch(&[2.0, 4.0, 6.0]);
let last = out[2];
assert!(last.is_some());
assert_eq!(sd.update(f64::NAN), last);
assert_eq!(sd.update(f64::INFINITY), last);
}
#[test]
fn reset_clears_state() {
let mut sd = StdDev::new(3).unwrap();
sd.batch(&[1.0, 2.0, 3.0, 4.0]);
assert!(sd.is_ready());
sd.reset();
assert!(!sd.is_ready());
assert_eq!(sd.update(1.0), None);
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (1..=60)
.map(|i| 100.0 + (f64::from(i) * 0.3).cos() * 7.0)
.collect();
let batch = StdDev::new(14).unwrap().batch(&prices);
let mut b = StdDev::new(14).unwrap();
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,242 @@
//! Stochastic RSI.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
use super::Rsi;
/// Stochastic RSI — the Stochastic Oscillator formula applied to the RSI series
/// instead of to price.
///
/// RSI itself rarely reaches its `[0, 100]` extremes, so it spends most of its
/// life bunched in the middle of the range. `StochRSI` re-scales it: it reports
/// where the *current* RSI sits within its own high/low range over the last
/// `stoch_period` bars, which makes overbought/oversold turns far easier to
/// see.
///
/// ```text
/// StochRSI = 100 · (RSI min(RSI, stoch_period)) / (max(RSI, …) min(RSI, …))
/// ```
///
/// The output is bounded in `[0, 100]`. A flat RSI window (zero range) is
/// reported as the neutral `50.0`, matching the [`Stochastic`](crate::Stochastic)
/// convention.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, StochRsi};
///
/// let mut indicator = StochRsi::new(14, 14).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// last = indicator.update(100.0 + (f64::from(i) * 0.5).sin() * 10.0);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct StochRsi {
rsi_period: usize,
stoch_period: usize,
rsi: Rsi,
/// Rolling window of the last `stoch_period` RSI values.
window: VecDeque<f64>,
last: Option<f64>,
}
impl StochRsi {
/// Construct a new `StochRSI` with the RSI period and the stochastic lookback.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if either period is `0`.
pub fn new(rsi_period: usize, stoch_period: usize) -> Result<Self> {
if rsi_period == 0 || stoch_period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
rsi_period,
stoch_period,
rsi: Rsi::new(rsi_period)?,
window: VecDeque::with_capacity(stoch_period),
last: None,
})
}
/// The `(rsi_period, stoch_period)` pair.
pub const fn periods(&self) -> (usize, usize) {
(self.rsi_period, self.stoch_period)
}
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.last
}
}
impl Indicator for StochRsi {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() {
// Non-finite input is ignored; state is left untouched.
return self.last;
}
let rsi_value = self.rsi.update(input)?;
if self.window.len() == self.stoch_period {
self.window.pop_front();
}
self.window.push_back(rsi_value);
if self.window.len() < self.stoch_period {
return None;
}
let max = self
.window
.iter()
.copied()
.fold(f64::NEG_INFINITY, f64::max);
let min = self.window.iter().copied().fold(f64::INFINITY, f64::min);
let range = max - min;
let stoch = if range == 0.0 {
// Flat RSI window: report the neutral midpoint.
50.0
} else {
100.0 * (rsi_value - min) / range
};
self.last = Some(stoch);
Some(stoch)
}
fn reset(&mut self) {
self.rsi.reset();
self.window.clear();
self.last = None;
}
fn warmup_period(&self) -> usize {
// RSI emits its first value at input `rsi_period + 1`; the stochastic
// window then needs `stoch_period` RSI values.
self.rsi_period + self.stoch_period
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"StochRSI"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn new_rejects_zero_period() {
assert!(matches!(StochRsi::new(0, 14), Err(Error::PeriodZero)));
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();
assert_eq!(sr.warmup_period(), 9);
let prices: Vec<f64> = (1..=40)
.map(|i| 100.0 + (f64::from(i) * 0.6).sin() * 8.0)
.collect();
let out = sr.batch(&prices);
for v in out.iter().take(8) {
assert!(v.is_none());
}
assert!(out[8].is_some());
}
#[test]
fn flat_rsi_window_yields_50() {
// A constant price series gives a constant RSI (50.0), so the StochRSI
// window has zero range and reports the neutral midpoint.
let mut sr = StochRsi::new(5, 4).unwrap();
let out = sr.batch(&[100.0; 40]);
for v in out.iter().skip(9).flatten() {
assert_relative_eq!(*v, 50.0, epsilon = 1e-12);
}
}
#[test]
fn pure_uptrend_yields_50() {
// A pure uptrend pins RSI at 100, so its window is again flat.
let mut sr = StochRsi::new(5, 4).unwrap();
let out = sr.batch(&(1..=40).map(f64::from).collect::<Vec<_>>());
for v in out.iter().skip(9).flatten() {
assert_relative_eq!(*v, 50.0, epsilon = 1e-12);
}
}
#[test]
fn output_stays_within_0_100() {
let mut sr = StochRsi::new(14, 14).unwrap();
let prices: Vec<f64> = (1..=200)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 15.0 + (f64::from(i) * 0.07).cos() * 6.0)
.collect();
for v in sr.batch(&prices).into_iter().flatten() {
assert!((0.0..=100.0).contains(&v), "StochRSI out of range: {v}");
}
}
#[test]
fn ignores_non_finite_input() {
let mut sr = StochRsi::new(5, 4).unwrap();
let prices: Vec<f64> = (1..=40)
.map(|i| 100.0 + (f64::from(i) * 0.6).sin() * 8.0)
.collect();
let out = sr.batch(&prices);
let last = *out.last().unwrap();
assert!(last.is_some());
assert_eq!(sr.update(f64::NAN), last);
assert_eq!(sr.update(f64::INFINITY), last);
}
#[test]
fn reset_clears_state() {
let mut sr = StochRsi::new(5, 4).unwrap();
sr.batch(&(1..=40).map(f64::from).collect::<Vec<_>>());
assert!(sr.is_ready());
sr.reset();
assert!(!sr.is_ready());
assert_eq!(sr.update(1.0), None);
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (1..=120)
.map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 12.0)
.collect();
let batch = StochRsi::new(14, 14).unwrap().batch(&prices);
let mut b = StochRsi::new(14, 14).unwrap();
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
assert_eq!(batch, streamed);
}
}
@@ -20,6 +20,22 @@ pub struct StochasticOutput {
///
/// Maintains rolling highest-high and lowest-low over the lookback period via a
/// monotonic deque, giving O(1) amortized updates. %D is an SMA of the %K series.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, Stochastic};
///
/// let mut indicator = Stochastic::new(5, 3).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// let base = 100.0 + f64::from(i);
/// let candle =
/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
/// last = indicator.update(candle);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct Stochastic {
k_period: usize,
@@ -204,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![
@@ -236,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]

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