Compare commits

..

119 Commits

Author SHA1 Message Date
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
252 changed files with 129123 additions and 990 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. -->
+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.
+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. -->
+33
View File
@@ -0,0 +1,33 @@
<!-- 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).
- [ ] Documentation under `docs/wiki/` and the `README.md` is updated
(if applicable).
- [ ] An entry was added under `## [Unreleased]` in `CHANGELOG.md`.
## Notes for reviewers
<!-- Anything that needs extra attention, trade-offs, follow-ups. -->
+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@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
- uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable branch, 2026-03-27
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.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@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: cross-library-bench
path: bindings/python/benchmark.txt
+149 -57
View File
@@ -19,15 +19,15 @@ jobs:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
- 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
@@ -52,6 +52,132 @@ jobs:
cargo build -p wickra --example backtest
cargo build -p wickra-data --example live_binance --features live-binance
# Verify the crates still build and test on their declared minimum supported
# Rust version. The workspace pins rust-version = "1.75"; bindings/node needs
# 1.77 because napi-build emits `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.75)
toolchain: "1.75"
packages: "-p wickra-core -p wickra -p wickra-data"
- name: MSRV node binding (Rust 1.77)
toolchain: "1.77"
packages: "-p wickra-node"
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
- 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@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
- 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@e0eafa9a0d485c37f97c0f7beb930a58a2facbac # v2.79.4
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@75cd11691c0faa626561e295848008c8a7dddffe # v5.5.4
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@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
- 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@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
- 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
run: cargo install cargo-fuzz --locked
- name: Fuzz csv_reader (30 s)
run: cargo +nightly fuzz run csv_reader -- -max_total_time=30
working-directory: fuzz
- name: Fuzz binance_envelope (30 s)
run: cargo +nightly fuzz run binance_envelope -- -max_total_time=30
working-directory: fuzz
- name: Fuzz indicator_update (30 s)
run: cargo +nightly fuzz run indicator_update -- -max_total_time=30
working-directory: fuzz
- name: Fuzz indicator_update_candle (30 s)
run: cargo +nightly fuzz run indicator_update_candle -- -max_total_time=30
working-directory: fuzz
- name: Fuzz tick_aggregator (30 s)
run: cargo +nightly fuzz run tick_aggregator -- -max_total_time=30
working-directory: fuzz
python:
name: Python ${{ matrix.python-version }} on ${{ matrix.os }}
runs-on: ${{ matrix.os }}
@@ -59,18 +185,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@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
- 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@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: ${{ matrix.python-version }}
@@ -96,22 +222,25 @@ jobs:
name: WASM build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
- 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
uses: jetli/wasm-pack-action@0d096b08b4e5a7de8c28de67e11e945404e9eefa # v0.4.0
- 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 +256,16 @@ jobs:
os: [ubuntu-latest, macos-latest, windows-latest]
node-version: ["18", "20"]
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
- 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@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: ${{ matrix.node-version }}
@@ -156,44 +285,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.
+205 -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@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
- 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,77 @@ 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@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
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@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: "3.11"
- uses: PyO3/maturin-action@v1
- 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@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
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@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
- 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@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: wheels-sdist
path: bindings/python/dist/*
@@ -102,15 +138,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@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
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 +164,24 @@ 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 }
- { host: windows-11-arm, target: aarch64-pc-windows-msvc }
runs-on: ${{ matrix.host }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
- uses: actions/setup-node@v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.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 +192,7 @@ jobs:
run: npx napi build --platform --release --target ${{ matrix.target }}
- name: Upload artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: bindings-${{ matrix.target }}
path: bindings/node/wickra.*.node
@@ -163,10 +202,11 @@ jobs:
name: Publish to npm
needs: node-build
runs-on: ubuntu-latest
environment: release
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
- uses: actions/setup-node@v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: "20"
registry-url: "https://registry.npmjs.org"
@@ -176,7 +216,7 @@ jobs:
run: npm install
- name: Download all platform binaries
uses: actions/download-artifact@v4
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
path: bindings/node/artifacts
pattern: bindings-*
@@ -188,10 +228,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 +245,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 +257,29 @@ 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: Publish main package to npm (idempotent)
working-directory: bindings/node
@@ -257,25 +310,46 @@ 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@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
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@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
- uses: actions/setup-node@v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.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
- uses: jetli/wasm-pack-action@0d096b08b4e5a7de8c28de67e11e945404e9eefa # v0.4.0
- name: Build WASM package (bundler target)
run: wasm-pack build bindings/wasm --target bundler --release --features panic-hook
@@ -290,10 +364,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@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
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 +386,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@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
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@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
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@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2
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*
+258
View File
@@ -0,0 +1,258 @@
# 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.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.0...HEAD
[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.
+94
View File
@@ -0,0 +1,94 @@
# 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/wiki/` | Documentation sources. |
## 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 under `docs/wiki/` and the `README.md`
when behaviour or the public API changes.
- **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
+115 -47
View File
@@ -38,6 +38,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"
@@ -679,15 +690,6 @@ 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"
@@ -748,6 +750,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 +791,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 +900,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 +934,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 +949,7 @@ dependencies = [
"num-integer",
"num-traits",
"pyo3",
"pyo3-build-config",
"rustc-hash",
]
@@ -1107,37 +1127,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 +1160,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 +1172,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",
@@ -1320,9 +1335,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"
@@ -1531,9 +1546,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"
@@ -1707,12 +1722,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"
@@ -1805,6 +1814,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 +1856,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 +1941,7 @@ dependencies = [
[[package]]
name = "wickra"
version = "0.1.3"
version = "0.2.0"
dependencies = [
"approx",
"criterion",
@@ -1894,7 +1952,7 @@ dependencies = [
[[package]]
name = "wickra-core"
version = "0.1.3"
version = "0.2.0"
dependencies = [
"approx",
"proptest",
@@ -1904,7 +1962,7 @@ dependencies = [
[[package]]
name = "wickra-data"
version = "0.1.3"
version = "0.2.0"
dependencies = [
"approx",
"csv",
@@ -1916,13 +1974,22 @@ dependencies = [
"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.0"
dependencies = [
"napi",
"napi-build",
@@ -1932,7 +1999,7 @@ dependencies = [
[[package]]
name = "wickra-python"
version = "0.1.3"
version = "0.2.0"
dependencies = [
"numpy",
"pyo3",
@@ -1941,13 +2008,14 @@ dependencies = [
[[package]]
name = "wickra-wasm"
version = "0.1.3"
version = "0.2.0"
dependencies = [
"console_error_panic_hook",
"js-sys",
"serde",
"serde-wasm-bindgen",
"wasm-bindgen",
"wasm-bindgen-test",
"wickra-core",
]
+7 -6
View File
@@ -7,12 +7,13 @@ members = [
"bindings/python",
"bindings/wasm",
"bindings/node",
"examples/rust",
]
exclude = []
exclude = ["fuzz"]
[workspace.package]
version = "0.1.3"
authors = ["Wickra Contributors"]
version = "0.2.0"
authors = ["kingchenc <kingchencp@gmail.com>"]
edition = "2021"
rust-version = "1.75"
license = "PolyForm-Noncommercial-1.0.0"
@@ -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.0" }
thiserror = "2"
rayon = "1.10"
@@ -34,8 +35,8 @@ approx = "0.5"
criterion = { version = "0.5", 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"
+73 -37
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)
@@ -51,33 +52,47 @@ 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 | 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) |
|-----------|---------------------|---------------------------|
| 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
+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`).
+3 -3
View File
@@ -1,4 +1,4 @@
# @wickra/wickra
# wickra
Node.js bindings for the Wickra streaming-first technical indicators library.
@@ -7,7 +7,7 @@ Node.js bindings for the Wickra streaming-first technical indicators library.
Once published, install per platform via the precompiled native package:
```bash
npm install @wickra/wickra
npm install wickra
```
## Build from source
@@ -26,7 +26,7 @@ produces a `wickra.<platform>-<arch>.node` binary in the package root that
## Usage
```js
import { SMA, RSI, MACD, version } from '@wickra/wickra';
import { SMA, RSI, MACD, version } from 'wickra';
console.log('wickra', version());
+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.0",
"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.0",
"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.0",
"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.0",
"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"
@@ -0,0 +1,24 @@
{
"name": "wickra-win32-arm64-msvc",
"version": "0.2.0",
"description": "Native binding for wickra (Windows arm64 MSVC). Installed automatically as an optional dependency of wickra on matching platforms.",
"main": "wickra.win32-arm64-msvc.node",
"files": [
"wickra.win32-arm64-msvc.node"
],
"license": "PolyForm-Noncommercial-1.0.0",
"engines": {
"node": ">= 18"
},
"os": [
"win32"
],
"cpu": [
"arm64"
],
"repository": {
"type": "git",
"url": "https://github.com/kingchenc/wickra"
},
"homepage": "https://github.com/kingchenc/wickra"
}
@@ -1,14 +1,14 @@
{
"name": "wickra-win32-x64-msvc",
"version": "0.1.3",
"version": "0.2.0",
"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"
+12 -8
View File
@@ -1,11 +1,11 @@
{
"name": "wickra",
"version": "0.1.3",
"version": "0.2.0",
"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,20 +35,24 @@
"defaults": false,
"additional": [
"x86_64-unknown-linux-gnu",
"aarch64-unknown-linux-gnu",
"x86_64-apple-darwin",
"aarch64-apple-darwin",
"x86_64-pc-windows-msvc"
"x86_64-pc-windows-msvc",
"aarch64-pc-windows-msvc"
]
}
},
"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.0",
"wickra-linux-arm64-gnu": "0.2.0",
"wickra-darwin-x64": "0.2.0",
"wickra-darwin-arm64": "0.2.0",
"wickra-win32-x64-msvc": "0.2.0",
"wickra-win32-arm64-msvc": "0.2.0"
},
"scripts": {
"build": "napi build --platform --release",
+2673 -413
View File
File diff suppressed because it is too large Load Diff
+18 -6
View File
@@ -26,14 +26,26 @@ for price in live_prices:
## What's included
25 streaming-first indicators across four families. Every one passes a
71 streaming-first indicators across eight families. Every one passes a
`batch == streaming` equivalence test and reference-value tests:
- **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
- **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
## Why streaming-first matters
+3 -1
View File
@@ -4,10 +4,11 @@ build-backend = "maturin"
[project]
name = "wickra"
version = "0.1.3"
version = "0.2.0"
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
+4 -2
View File
@@ -43,5 +43,7 @@ for (const price of livePrices) {
const sma = new SMA(20).batch(new Float64Array(historicalPrices));
```
An interactive demo lives in `bindings/wasm/examples/index.html`. After building
the package serve the `bindings/wasm/` directory and open `examples/index.html`.
An interactive demo lives in [`examples/wasm/index.html`](../../examples/wasm/index.html)
(top-level alongside the other language examples). After building the package
with `wasm-pack build`, serve the repository root and open
`examples/wasm/index.html` in a browser.
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,194 @@
//! 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((high + low) / 2.0, 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());
}
#[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<_>>()
);
}
}
+185
View File
@@ -0,0 +1,185 @@
//! 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());
}
#[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);
}
}
+16
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 {
@@ -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,17 @@ 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);
}
}
@@ -0,0 +1,185 @@
//! 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());
}
#[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);
}
}
+79
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,6 +116,36 @@ 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)));
@@ -187,4 +233,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,268 @@
//! 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((high + low) / 2.0, 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());
}
#[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,
@@ -114,4 +130,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,168 @@
//! 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
);
}
#[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<_>>()
);
}
}
@@ -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 {
@@ -240,4 +278,60 @@ 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.is_multiple_of(2) { 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).expect("warmed up");
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,176 @@
//! 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());
}
#[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);
}
}
#[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 (w, b) in bbw_out.iter().zip(bands_out.iter()) {
match (w, b) {
(Some(wv), Some(bv)) => {
assert_relative_eq!(*wv, (bv.upper - bv.lower) / bv.middle, epsilon = 1e-12);
}
(None, None) => {}
_ => panic!("warmup mismatch"),
}
}
}
#[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);
}
}
+16
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,
@@ -0,0 +1,233 @@
//! 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());
}
#[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,223 @@
//! 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((high + low) / 2.0, 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());
}
#[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,248 @@
//! 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((high + low) / 2.0, 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());
}
#[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,231 @@
//! 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((high + low) / 2.0, 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());
}
#[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,220 @@
//! 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((high + low) / 2.0, 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());
}
#[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<_>>()
);
}
}
+252
View File
@@ -0,0 +1,252 @@
//! 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)));
}
#[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<_>>()
);
}
}
+218
View File
@@ -0,0 +1,218 @@
//! 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)));
}
#[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,238 @@
//! 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)));
}
#[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 index {} is None",
warmup - 1,
);
}
}
#[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);
}
}
+13
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,
@@ -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,
@@ -138,4 +154,17 @@ mod tests {
fn rejects_zero_period() {
assert!(Donchian::new(0).is_err());
}
#[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);
}
}
+208
View File
@@ -0,0 +1,208 @@
//! 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)));
}
#[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,277 @@
//! 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 = (candle.high + candle.low) / 2.0;
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());
}
#[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<_>>()
);
}
}
+58
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,6 +139,27 @@ 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)));
@@ -221,4 +255,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,189 @@
//! 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());
}
#[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,288 @@
//! 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)
));
}
#[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.
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-9);
}
}
#[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);
}
}
+61 -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,45 @@ mod tests {
fn rejects_zero_period() {
assert!(Hma::new(0).is_err());
}
#[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 &p in &prices {
let got = hma.update(p);
let want = match (half.update(p), full.update(p)) {
(Some(h), Some(f)) => smooth.update(2.0 * h - f),
_ => None,
};
match (got, want) {
(None, None) => {}
(Some(a), Some(b)) => assert_relative_eq!(a, b, epsilon = 1e-9),
_ => panic!("HMA and the independent-WMA reference disagree on readiness"),
}
}
}
}
+24
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,
@@ -154,4 +167,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);
}
}
+91 -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,71 @@ mod tests {
assert!(Keltner::new(20, 10, 0.0).is_err());
assert!(Keltner::new(20, 10, -1.0).is_err());
}
#[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})"
),
}
}
}
}
+285
View File
@@ -0,0 +1,285 @@
//! 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());
}
#[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,163 @@
//! 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());
}
#[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,261 @@
//! 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());
}
#[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);
}
}
+25
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,
@@ -227,4 +240,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,219 @@
//! 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)));
}
#[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,121 @@
//! 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
);
}
#[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<_>>()
);
}
}
+69 -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 {
@@ -163,4 +185,35 @@ mod tests {
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;
+167
View File
@@ -0,0 +1,167 @@
//! 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)));
}
#[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);
}
}
+185
View File
@@ -0,0 +1,185 @@
//! 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);
}
#[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() {
match (n, a) {
(Some(nv), Some(av)) => {
let want = 100.0 * av / candles[i].close;
assert_relative_eq!(*nv, want, epsilon = 1e-9);
}
(None, None) => {}
_ => panic!("warmup mismatch at {i}"),
}
}
}
#[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);
}
}
+16
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>,
@@ -0,0 +1,184 @@
//! 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());
}
#[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() {
match (p, b) {
(Some(pv), Some(bv)) => {
let want = (prices[i] - bv.lower) / (bv.upper - bv.lower);
assert_relative_eq!(*pv, want, epsilon = 1e-12);
}
(None, None) => {}
_ => panic!("warmup mismatch at {i}"),
}
}
}
#[test]
fn price_at_middle_is_half() {
// A symmetric oscillation keeps the SMA centred; when price crosses
// the SMA, %b passes through 0.5. Verified via the bands definition.
let prices: Vec<f64> = (1..=60)
.map(|i| 100.0 + (f64::from(i) * 0.5).sin() * 5.0)
.collect();
let pb_out = PercentB::new(20, 2.0).unwrap().batch(&prices);
let bands_out = BollingerBands::new(20, 2.0).unwrap().batch(&prices);
for (i, (p, b)) in pb_out.iter().zip(bands_out.iter()).enumerate() {
if let (Some(pv), Some(bv)) = (p, b) {
if (prices[i] - bv.middle).abs() < 1e-9 {
assert_relative_eq!(*pv, 0.5, epsilon = 1e-6);
}
}
}
}
#[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);
}
}
+213
View File
@@ -0,0 +1,213 @@
//! 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 { .. })));
}
#[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);
}
}
+205
View File
@@ -0,0 +1,205 @@
//! 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 { .. })));
}
#[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);
}
}
+110 -9
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 {
@@ -238,4 +299,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);
}
}
+44 -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,20 @@ mod tests {
fn rejects_zero_period() {
assert!(Roc::new(0).is_err());
}
#[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
);
}
}
+92
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,6 +153,50 @@ 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)));
@@ -244,4 +301,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"),
}
}
}
}
}
+71 -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 {
@@ -197,4 +236,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.is_multiple_of(2) { 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}"
);
}
}
+180
View File
@@ -0,0 +1,180 @@
//! 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)));
}
#[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,200 @@
//! 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)));
}
#[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,227 @@
//! 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)));
}
#[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,
@@ -0,0 +1,315 @@
//! `SuperTrend`.
use crate::error::{Error, Result};
use crate::indicators::atr::Atr;
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// `SuperTrend` output: the trailing-stop level and the trend direction.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SuperTrendOutput {
/// The `SuperTrend` line — the active trailing-stop level for this bar.
pub value: f64,
/// Trend direction: `+1.0` in an uptrend (the line sits below price),
/// `-1.0` in a downtrend (the line sits above price).
pub direction: f64,
}
/// Previous-bar state carried forward by the `SuperTrend` recurrence.
#[derive(Debug, Clone, Copy)]
struct PrevState {
final_upper: f64,
final_lower: f64,
close: f64,
direction: f64,
}
/// `SuperTrend` — an ATR-banded trailing stop that flips sides on a close
/// through the band.
///
/// ```text
/// hl2 = (high + low) / 2
/// basic_upper = hl2 + multiplier · ATR
/// basic_lower = hl2 multiplier · ATR
///
/// final_upper = basic_upper if basic_upper < prev_final_upper or prev_close > prev_final_upper
/// else prev_final_upper
/// final_lower = basic_lower if basic_lower > prev_final_lower or prev_close < prev_final_lower
/// else prev_final_lower
///
/// in a downtrend: stay down while close <= final_upper, else flip up
/// in an uptrend: stay up while close >= final_lower, else flip down
/// SuperTrend = final_lower in an uptrend, final_upper in a downtrend
/// ```
///
/// The final bands ratchet — the upper band only moves down (and the lower
/// band only moves up) until price closes through it, which flips the trend
/// and hands the role of trailing stop to the opposite band. The first
/// ATR-ready bar seeds the trend as up. Wilder's classic configuration is
/// `ATR(10)` with a `3.0` multiplier.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, SuperTrend};
///
/// let mut indicator = SuperTrend::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 SuperTrend {
atr: Atr,
multiplier: f64,
atr_period: usize,
prev: Option<PrevState>,
}
impl SuperTrend {
/// Construct a `SuperTrend` with an explicit ATR period and band multiplier.
///
/// # 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: None,
})
}
/// Wilder's classic configuration: `ATR(10)` with a `3.0` multiplier.
pub fn classic() -> Self {
Self::new(10, 3.0).expect("classic SuperTrend params are valid")
}
/// Configured `(atr_period, multiplier)`.
pub const fn params(&self) -> (usize, f64) {
(self.atr_period, self.multiplier)
}
}
impl Indicator for SuperTrend {
type Input = Candle;
type Output = SuperTrendOutput;
fn update(&mut self, candle: Candle) -> Option<SuperTrendOutput> {
let atr = self.atr.update(candle)?;
let hl2 = (candle.high + candle.low) / 2.0;
let basic_upper = hl2 + self.multiplier * atr;
let basic_lower = hl2 - self.multiplier * atr;
let (final_upper, final_lower, direction) = match self.prev {
None => {
// First ATR-ready bar: no prior bands, seed the trend as up.
(basic_upper, basic_lower, 1.0)
}
Some(p) => {
let final_upper = if basic_upper < p.final_upper || p.close > p.final_upper {
basic_upper
} else {
p.final_upper
};
let final_lower = if basic_lower > p.final_lower || p.close < p.final_lower {
basic_lower
} else {
p.final_lower
};
let direction = if p.direction < 0.0 {
// Previous downtrend — the line was the upper band.
if candle.close <= final_upper {
-1.0
} else {
1.0
}
} else {
// Previous uptrend — the line was the lower band.
if candle.close >= final_lower {
1.0
} else {
-1.0
}
};
(final_upper, final_lower, direction)
}
};
let value = if direction > 0.0 {
final_lower
} else {
final_upper
};
self.prev = Some(PrevState {
final_upper,
final_lower,
close: candle.close,
direction,
});
Some(SuperTrendOutput { value, direction })
}
fn reset(&mut self) {
self.atr.reset();
self.prev = None;
}
fn warmup_period(&self) -> usize {
self.atr_period
}
fn is_ready(&self) -> bool {
self.prev.is_some()
}
fn name(&self) -> &'static str {
"SuperTrend"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
fn c(high: f64, low: f64, close: f64, ts: i64) -> Candle {
Candle::new((high + low) / 2.0, high, low, close, 1.0, ts).unwrap()
}
#[test]
fn uptrend_keeps_line_below_price_and_direction_up() {
let candles: Vec<Candle> = (0..60)
.map(|i| {
let base = 100.0 + 2.0 * i as f64;
c(base + 1.0, base - 1.0, base + 0.5, i)
})
.collect();
let mut st = SuperTrend::classic();
for (o, candle) in st.batch(&candles).into_iter().zip(candles.iter()) {
if let Some(o) = o {
assert_eq!(o.direction, 1.0, "a pure uptrend stays in direction +1");
assert!(o.value < candle.close, "the stop line sits below price");
}
}
}
#[test]
fn downtrend_keeps_line_above_price_and_direction_down() {
let candles: Vec<Candle> = (0..60)
.map(|i| {
let base = 220.0 - 2.0 * i as f64;
c(base + 1.0, base - 1.0, base - 0.5, i)
})
.collect();
let mut st = SuperTrend::classic();
let emitted: Vec<(SuperTrendOutput, f64)> = st
.batch(&candles)
.into_iter()
.zip(candles.iter())
.filter_map(|(o, c)| o.map(|v| (v, c.close)))
.collect();
// The seed bar starts the trend up; a steep decline flips it within a
// few bars. The settled tail must be a clean downtrend.
for &(o, close) in emitted.iter().skip(10) {
assert_eq!(
o.direction, -1.0,
"a steep downtrend settles to direction -1"
);
assert!(o.value > close, "the stop line sits above price");
}
}
#[test]
fn trend_flips_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 + 0.5, i)
})
.collect();
candles.extend((0..40).map(|i| {
let base = 140.0 - i as f64;
c(base + 1.0, base - 1.0, base - 0.5, 40 + i)
}));
let mut st = SuperTrend::classic();
let dirs: Vec<f64> = st
.batch(&candles)
.into_iter()
.flatten()
.map(|o| o.direction)
.collect();
assert!(dirs.iter().any(|&d| d > 0.0), "expected an uptrend stretch");
assert!(
dirs.iter().any(|&d| d < 0.0),
"expected a downtrend stretch"
);
}
#[test]
fn first_emission_matches_warmup_period() {
let candles: Vec<Candle> = (0..30)
.map(|i| {
let base = 100.0 + i as f64;
c(base + 1.0, base - 1.0, base, i)
})
.collect();
let mut st = SuperTrend::classic();
let out = st.batch(&candles);
assert_eq!(st.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!(SuperTrend::new(0, 3.0).is_err());
assert!(SuperTrend::new(10, 0.0).is_err());
assert!(SuperTrend::new(10, -1.0).is_err());
assert!(SuperTrend::new(10, f64::NAN).is_err());
}
#[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 st = SuperTrend::classic();
st.batch(&candles);
assert!(st.is_ready());
st.reset();
assert!(!st.is_ready());
assert_eq!(st.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 = SuperTrend::classic();
let mut b = SuperTrend::classic();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
+261
View File
@@ -0,0 +1,261 @@
//! Tillson T3 Moving Average.
use crate::error::{Error, Result};
use crate::traits::Indicator;
use super::Ema;
/// Tillson's T3 — a six-fold cascaded EMA recombined with a *volume factor* `v`.
///
/// T3 is the generalised DEMA applied three times. Tim Tillson's expansion of
/// that triple application over six chained EMAs (`e1 … e6`, each of the same
/// `period`) gives the closed form used here:
///
/// ```text
/// c1 =
/// c2 = 3v² + 3v³
/// c3 = 6v² 3v 3v³
/// c4 = 1 + 3v + v³ + 3v²
/// T3 = c1·e6 + c2·e5 + c3·e4 + c4·e3
/// ```
///
/// The volume factor `v ∈ [0, 1]` controls the lag/smoothness trade-off:
/// `v = 0` collapses T3 to the plain triple-cascaded EMA `e3`, while the
/// conventional `v = 0.7` adds a hump that sharpens the response to turns.
/// The coefficients always sum to `1`, so a constant series maps to itself.
///
/// The first output lands after `6·period 5` inputs — the index at which the
/// sixth cascaded EMA seeds.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, T3};
///
/// let mut indicator = T3::new(5, 0.7).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 T3 {
period: usize,
v: f64,
c1: f64,
c2: f64,
c3: f64,
c4: f64,
e1: Ema,
e2: Ema,
e3: Ema,
e4: Ema,
e5: Ema,
e6: Ema,
current: Option<f64>,
}
impl T3 {
/// Construct a new T3 with the given `period` and volume factor `v`.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `period == 0`, or
/// [`Error::InvalidPeriod`] if `v` is non-finite or outside `[0.0, 1.0]`.
pub fn new(period: usize, v: f64) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
if !v.is_finite() || !(0.0..=1.0).contains(&v) {
return Err(Error::InvalidPeriod {
message: "T3 volume factor must be a finite value in [0.0, 1.0]",
});
}
let v2 = v * v;
let v3 = v2 * v;
Ok(Self {
period,
v,
c1: -v3,
c2: 3.0 * v2 + 3.0 * v3,
c3: -6.0 * v2 - 3.0 * v - 3.0 * v3,
c4: 1.0 + 3.0 * v + v3 + 3.0 * v2,
e1: Ema::new(period)?,
e2: Ema::new(period)?,
e3: Ema::new(period)?,
e4: Ema::new(period)?,
e5: Ema::new(period)?,
e6: Ema::new(period)?,
current: None,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
/// Configured volume factor `v`.
pub const fn volume_factor(&self) -> f64 {
self.v
}
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.current
}
}
impl Indicator for T3 {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() {
// Non-finite input is ignored; the cascade is not advanced.
return self.current;
}
let e1 = self.e1.update(input)?;
let e2 = self.e2.update(e1)?;
let e3 = self.e3.update(e2)?;
let e4 = self.e4.update(e3)?;
let e5 = self.e5.update(e4)?;
let e6 = self.e6.update(e5)?;
let out = self.c1 * e6 + self.c2 * e5 + self.c3 * e4 + self.c4 * e3;
self.current = Some(out);
Some(out)
}
fn reset(&mut self) {
self.e1.reset();
self.e2.reset();
self.e3.reset();
self.e4.reset();
self.e5.reset();
self.e6.reset();
self.current = None;
}
fn warmup_period(&self) -> usize {
6 * self.period - 5
}
fn is_ready(&self) -> bool {
self.current.is_some()
}
fn name(&self) -> &'static str {
"T3"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn new_rejects_zero_period() {
assert!(matches!(T3::new(0, 0.7), Err(Error::PeriodZero)));
}
#[test]
fn new_rejects_out_of_range_volume_factor() {
assert!(matches!(T3::new(5, -0.1), Err(Error::InvalidPeriod { .. })));
assert!(matches!(T3::new(5, 1.5), Err(Error::InvalidPeriod { .. })));
assert!(matches!(
T3::new(5, f64::NAN),
Err(Error::InvalidPeriod { .. })
));
assert!(T3::new(5, 0.0).is_ok());
assert!(T3::new(5, 1.0).is_ok());
}
#[test]
fn coefficients_sum_to_one() {
// c1 + c2 + c3 + c4 == 1 for any v, so a constant series is preserved.
for &v in &[0.0, 0.3, 0.7, 1.0] {
let t3 = T3::new(5, v).unwrap();
assert_relative_eq!(t3.c1 + t3.c2 + t3.c3 + t3.c4, 1.0, epsilon = 1e-12);
}
}
#[test]
fn first_emission_at_warmup_period() {
let mut t3 = T3::new(4, 0.7).unwrap();
assert_eq!(t3.warmup_period(), 6 * 4 - 5);
let out = t3.batch(&(1..=60).map(f64::from).collect::<Vec<_>>());
for v in out.iter().take(t3.warmup_period() - 1) {
assert!(v.is_none());
}
assert!(out[t3.warmup_period() - 1].is_some());
}
#[test]
fn constant_series_yields_the_constant() {
let mut t3 = T3::new(6, 0.7).unwrap();
let out = t3.batch(&[50.0; 80]);
let last = out.iter().rev().flatten().next().unwrap();
assert_relative_eq!(*last, 50.0, epsilon = 1e-9);
}
#[test]
fn zero_volume_factor_collapses_to_triple_cascaded_ema() {
// With v = 0 the coefficients are c1=c2=c3=0, c4=1, so T3 == e3,
// the third stage of the EMA cascade.
let prices: Vec<f64> = (1..=80)
.map(|i| 100.0 + (f64::from(i) * 0.2).sin() * 9.0)
.collect();
let mut t3 = T3::new(5, 0.0).unwrap();
let got = t3.batch(&prices);
let mut e1 = Ema::new(5).unwrap();
let mut e2 = Ema::new(5).unwrap();
let mut e3 = Ema::new(5).unwrap();
let want: Vec<Option<f64>> = prices
.iter()
.map(|p| {
e1.update(*p)
.and_then(|a| e2.update(a))
.and_then(|b| e3.update(b))
})
.collect();
for i in (t3.warmup_period() - 1)..prices.len() {
assert_relative_eq!(got[i].unwrap(), want[i].unwrap(), epsilon = 1e-9);
}
}
#[test]
fn ignores_non_finite_input() {
let mut t3 = T3::new(4, 0.7).unwrap();
let out = t3.batch(&(1..=60).map(f64::from).collect::<Vec<_>>());
let last = *out.last().unwrap();
assert!(last.is_some());
assert_eq!(t3.update(f64::NAN), last);
assert_eq!(t3.update(f64::INFINITY), last);
}
#[test]
fn reset_clears_state() {
let mut t3 = T3::new(4, 0.7).unwrap();
t3.batch(&(1..=60).map(f64::from).collect::<Vec<_>>());
assert!(t3.is_ready());
t3.reset();
assert!(!t3.is_ready());
assert_eq!(t3.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() * 7.0)
.collect();
let batch = T3::new(7, 0.7).unwrap().batch(&prices);
let mut b = T3::new(7, 0.7).unwrap();
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
assert_eq!(batch, streamed);
}
}
+13
View File
@@ -8,6 +8,19 @@ use crate::traits::Indicator;
/// where `EMA2 = EMA(EMA1)` and `EMA3 = EMA(EMA2)`.
///
/// Reduces lag further than DEMA at the cost of more responsiveness to noise.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, Tema};
///
/// let mut indicator = Tema::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 Tema {
ema1: Ema,
+176
View File
@@ -0,0 +1,176 @@
//! Triangular Moving Average.
use crate::error::{Error, Result};
use crate::traits::Indicator;
use super::Sma;
/// Triangular Moving Average — a simple moving average applied twice, which
/// triangular-weights the window so the middle bars carry the most weight and
/// the edges the least.
///
/// For period `n` the two stacked SMAs use lengths `n1` and `n2`:
/// an odd `n` uses `n1 = n2 = (n + 1) / 2`; an even `n` uses `n1 = n / 2` and
/// `n2 = n / 2 + 1`. Either way the first output lands after exactly `n`
/// inputs.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, Trima};
///
/// let mut indicator = Trima::new(5).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 Trima {
period: usize,
inner: Sma,
outer: Sma,
}
impl Trima {
/// Construct a new TRIMA 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 (n1, n2) = if period % 2 == 1 {
(period.div_ceil(2), period.div_ceil(2))
} else {
(period / 2, period / 2 + 1)
};
Ok(Self {
period,
inner: Sma::new(n1)?,
outer: Sma::new(n2)?,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
/// Current value if available.
pub fn value(&self) -> Option<f64> {
self.outer.value()
}
}
impl Indicator for Trima {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() {
// Non-finite input is ignored; do not double-feed the inner SMA's
// stale value into the outer SMA.
return self.outer.value();
}
// Genuine stacking: the outer SMA consumes the inner SMA's output.
match self.inner.update(input) {
Some(v) => self.outer.update(v),
None => None,
}
}
fn reset(&mut self) {
self.inner.reset();
self.outer.reset();
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.outer.is_ready()
}
fn name(&self) -> &'static str {
"TRIMA"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn new_rejects_zero_period() {
assert!(matches!(Trima::new(0), Err(Error::PeriodZero)));
}
#[test]
fn odd_period_reference_values() {
// TRIMA(5) is SMA(3) of SMA(3).
// SMA(3) of 1..=7 -> [_,_,2,3,4,5,6]; SMA(3) of that -> [_,_,_,_,3,4,5].
let mut trima = Trima::new(5).unwrap();
let out = trima.batch(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0]);
assert_eq!(out[0], None);
assert_eq!(out[3], None);
assert_relative_eq!(out[4].unwrap(), 3.0, epsilon = 1e-12);
assert_relative_eq!(out[5].unwrap(), 4.0, epsilon = 1e-12);
assert_relative_eq!(out[6].unwrap(), 5.0, epsilon = 1e-12);
}
#[test]
fn first_emission_at_warmup_period() {
// Even period: TRIMA(6) -> SMA(3) of SMA(4); first value at input 6.
let mut trima = Trima::new(6).unwrap();
let out = trima.batch(&(1..=10).map(f64::from).collect::<Vec<_>>());
assert_eq!(trima.warmup_period(), 6);
for v in out.iter().take(5) {
assert!(v.is_none());
}
assert!(out[5].is_some());
}
#[test]
fn constant_series_yields_the_constant() {
let mut trima = Trima::new(7).unwrap();
let out = trima.batch(&[42.0; 20]);
for x in out.iter().skip(6) {
assert_relative_eq!(x.unwrap(), 42.0, epsilon = 1e-12);
}
}
#[test]
fn ignores_non_finite_input() {
let mut trima = Trima::new(5).unwrap();
let ready = trima.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
let last = ready[4];
assert!(last.is_some());
assert_eq!(trima.update(f64::NAN), last);
}
#[test]
fn reset_clears_state() {
let mut trima = Trima::new(5).unwrap();
trima.batch(&(1..=10).map(f64::from).collect::<Vec<_>>());
assert!(trima.is_ready());
trima.reset();
assert!(!trima.is_ready());
assert_eq!(trima.update(1.0), None);
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (1..=40).map(f64::from).collect();
let batch = Trima::new(8).unwrap().batch(&prices);
let mut b = Trima::new(8).unwrap();
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
assert_eq!(batch, streamed);
}
}
+13
View File
@@ -8,6 +8,19 @@ use crate::traits::Indicator;
///
/// `TRIX = 100 * (TR_t - TR_{t-1}) / TR_{t-1}` where
/// `TR_t = EMA(EMA(EMA(price)))`.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, Trix};
///
/// let mut indicator = Trix::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 Trix {
ema1: Ema,
@@ -0,0 +1,151 @@
//! True Range.
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// True Range — the single-bar building block of every ATR-based indicator.
///
/// ```text
/// TR = max( high low, |high close_prev|, |low close_prev| )
/// ```
///
/// True Range is the greatest of the bar's own range and the two gaps to the
/// previous close, so it captures volatility that opens *between* bars rather
/// than only within them. The first bar has no previous close and falls back
/// to `high low`. Where [`Atr`](crate::Atr) smooths this series, `TrueRange`
/// exposes it raw, one value per bar.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, TrueRange};
///
/// let mut indicator = TrueRange::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 TrueRange {
prev_close: Option<f64>,
has_emitted: bool,
}
impl TrueRange {
/// Construct a new True Range indicator.
pub const fn new() -> Self {
Self {
prev_close: None,
has_emitted: false,
}
}
}
impl Indicator for TrueRange {
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);
self.has_emitted = true;
Some(tr)
}
fn reset(&mut self) {
self.prev_close = None;
self.has_emitted = false;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"TrueRange"
}
}
#[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((high + low) / 2.0, high, low, close, 1.0, ts).unwrap()
}
#[test]
fn reference_values() {
// Bar 1 has no previous close -> TR = high - low = 12 - 8 = 4.
// Bar 2: prev close 11, TR = max(10-9, |10-11|, |9-11|) = max(1, 1, 2) = 2.
let mut tr = TrueRange::new();
let out = tr.batch(&[c(12.0, 8.0, 11.0, 0), c(10.0, 9.0, 9.5, 1)]);
assert_relative_eq!(out[0].unwrap(), 4.0, epsilon = 1e-12);
assert_relative_eq!(out[1].unwrap(), 2.0, epsilon = 1e-12);
}
#[test]
fn emits_from_first_candle() {
let mut tr = TrueRange::new();
assert_eq!(tr.warmup_period(), 1);
assert!(!tr.is_ready());
assert!(tr.update(c(11.0, 9.0, 10.0, 0)).is_some());
assert!(tr.is_ready());
}
#[test]
fn never_negative() {
let candles: Vec<Candle> = (0..120)
.map(|i| {
let base = 100.0 + (i as f64 * 0.3).sin() * 5.0;
c(base + 1.0, base - 1.0, base, i)
})
.collect();
let mut tr = TrueRange::new();
for v in tr.batch(&candles).into_iter().flatten() {
assert!(v >= 0.0, "true range must be non-negative, got {v}");
}
}
#[test]
fn reset_clears_state() {
let mut tr = TrueRange::new();
tr.batch(&[c(12.0, 8.0, 10.0, 0), c(13.0, 9.0, 11.0, 1)]);
assert!(tr.is_ready());
tr.reset();
assert!(!tr.is_ready());
// After reset the next bar again has no previous close.
assert_relative_eq!(
tr.update(c(12.0, 8.0, 10.0, 0)).unwrap(),
4.0,
epsilon = 1e-12
);
}
#[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;
c(mid + 1.5, mid - 1.5, mid + 0.5, i)
})
.collect();
let mut a = TrueRange::new();
let mut b = TrueRange::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}

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