Compare commits

..

42 Commits

Author SHA1 Message Date
kingchenc 01aeb965d1 release: bump 0.3.0 -> 0.3.1 (#80)
* chore: remove ROADMAP.md from the public repo

ROADMAP is kept as a local-only draft (ghost-ignored via
.git/info/exclude); it is not part of the published package surface.

* release: bump 0.3.0 -> 0.3.1

CI-only patch: fixes the release.yml CycloneDX SBOM step (cargo-cyclonedx
has no -p flag, see #79) that skipped the GitHub Release attach-assets job
on 0.3.0. No library changes — republishes the same code with a working
release pipeline.

- Cargo.toml (workspace.package + wickra-core dep) + Cargo.lock
- bindings/python/pyproject.toml
- bindings/node/package.json (version + 6 optionalDependencies) + package-lock.json
- bindings/node/npm/*/package.json (6 platform subpackages)
- CHANGELOG: finalize [0.3.0] (was still under [Unreleased]), add [0.3.1]

* chore: track examples/node/package-lock.json

Since the global package-lock ignore rule was dropped (#68) this file was
left untracked. Commit it for reproducible example installs, consistent
with bindings/node (findings P4.1).
2026-05-30 19:50:45 +02:00
kingchenc f1fed6cdd5 ci(release): fix CycloneDX SBOM generation (cargo-cyclonedx has no -p flag) (#79)
cargo-cyclonedx 0.5.9 walks the whole workspace in a single pass and
writes a <package>.cdx.json next to each member's Cargo.toml; it has no
-p/--package selector. The previous three 'cargo cyclonedx ... -p <crate>'
invocations aborted with 'error: unexpected argument -p found', failing
the cargo-publish job after the crates were already published and, in
turn, skipping the github-release attach-assets job (it needs all four
publish jobs). v0.3.0 published to every registry but got no GitHub
Release page or SBOM assets as a result.

Replace the three invalid calls with a single workspace pass and copy
the three crates.io crate SBOMs into the upload dir.
2026-05-30 19:26:30 +02:00
kingchenc 70e9cbb397 release: bump 0.2.7 -> 0.3.0 (supersedes PR #61) (#69)
Minor bump (not patch) because the [Unreleased] section since 0.2.7 has
accumulated a sweep of additive changes that justify a new minor:

- Family 9-16 indicator catalogue expansion (Bands & Channels, Trailing
  Stops, Volume, Statistics, Ehlers/Cycle DSP, Pivots, DeMark, Ichimoku,
  Candlestick Patterns, Market Profile, Risk/Performance) — roughly
  100+ new indicators since 0.2.7 across all four bindings.
- New `wickra_core::FAMILIES` const + family-taxonomy guard tests.
- GitHub org migration (kingchenc -> wickra-lib) and new maintainer
  email (wickra.lib@gmail.com).
- New `repo-metadata.toml` + `sync-metadata.yml` audit workflow.
- WASM CI tests now run on every PR (existing tests had been
  manually-only).
- CycloneDX SBOMs + npm provenance attestations attached to releases.
- Three end-to-end strategy examples.
- Governance polish: ARCHITECTURE / ROADMAP / CITATION / FUNDING /
  .editorconfig.
- Curated benchmark suite (~33 representative indicators).
- Three cold-path coverage fixes (mama, rsi, sine_wave).
- bindings/node/package-lock.json now committed.

Workspace + bindings (Rust crate, Python wheel, Node main + 6 platform
sub-packages, WASM) all step to 0.3.0. CHANGELOG opens the [0.3.0]
section dated 2026-05-28 with the full Changed / Added inventory.
Compare-URL block adds the v0.2.7...v0.3.0 line under [Unreleased] and
points [Unreleased] at v0.3.0...HEAD using the new wickra-lib org.

**Supersedes PR #61** (0.2.7 -> 0.2.8 patch bump). Close #61 when this
one merges. Merge ordering remains: #59 (org migration) + #60
(family-api) + the polish PRs first, rebase this PR on top of the new
main, then merge.

Tag-push `v0.3.0` is a SEPARATE, manual step after merge — it triggers
release.yml's irreversible publish to crates.io / PyPI / npm.
2026-05-30 19:06:48 +02:00
dependabot[bot] 37e5e19b57 deps(actions): bump taiki-e/install-action from 2.79.5 to 2.79.15 (#76)
Bumps [taiki-e/install-action](https://github.com/taiki-e/install-action) from 2.79.5 to 2.79.15.
- [Release notes](https://github.com/taiki-e/install-action/releases)
- [Changelog](https://github.com/taiki-e/install-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/taiki-e/install-action/compare/6c1f7cf125e42770ff087ea443901b487cc5471a...0fd46367812ee04360509b4169d9f659d6892bb2)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-30 18:54:31 +02:00
dependabot[bot] c189075491 deps(actions): bump EmbarkStudios/cargo-deny-action (#74)
Bumps [EmbarkStudios/cargo-deny-action](https://github.com/embarkstudios/cargo-deny-action) from 2.0.19 to 2.0.20.
- [Release notes](https://github.com/embarkstudios/cargo-deny-action/releases)
- [Commits](https://github.com/embarkstudios/cargo-deny-action/compare/a531616d8ce3b9177443e48a1159bc945a099823...bb137d7af7e4fb67e5f82a49c4fce4fad40782fe)

---
updated-dependencies:
- dependency-name: EmbarkStudios/cargo-deny-action
  dependency-version: 2.0.20
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-30 18:52:23 +02:00
dependabot[bot] 74dd82a867 deps(actions): bump actions/checkout from 4 to 6 (#75)
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 6.
- [Release notes](https://github.com/actions/checkout/releases)
- [Commits](https://github.com/actions/checkout/compare/v4...v6)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-30 18:52:15 +02:00
kingchenc b654db312e docs: fix unresolved and private intra-doc links (#78)
A full `cargo doc --workspace` (and docs.rs, which builds with
`-D rustdoc::all`) emitted five broken intra-doc links. docs.rs treats
these as hard errors, so any 0.2.x doc build was at risk of aborting.

- mama.rs: `[`Fama`]` -> `[`crate::Fama`]` (Fama lives in another module)
- standard_error.rs: `[`crate::Bollinger`]` -> `[`crate::BollingerBands`]`
  (the public type is `BollingerBands`, not `Bollinger`)
- aggregator.rs: drop the link to the private `OpenBar::into_candle`,
  keep it as plain code text
- resample.rs: drop the link to the private `RolledBar::into_candle`
- csv.rs: `CandleReader::with_timestamp_parser` never existed; reword to
  state plainly that ISO/RFC timestamps must be converted to integers

Verified with `RUSTDOCFLAGS="-D rustdoc::broken_intra_doc_links \
-D rustdoc::private_intra_doc_links" cargo doc --workspace --no-deps`:
clean, zero warnings.
2026-05-30 18:46:49 +02:00
kingchenc 88f119109d chore(node): commit bindings/node/package-lock.json for reproducible installs (#68)
Until now `package-lock.json` was globally ignored. Two practical
consequences for the Node binding:

- A fresh `git clone && cd bindings/node && npm install` resolved
  `@napi-rs/cli` and any transitive deps to whatever the npm registry
  currently considered the latest matching the package.json semver
  ranges. Contributors could get different dep graphs on different days.
- No protection against transitive-dep tampering at install time
  (lockfile records resolved versions + integrity hashes, npm verifies
  on subsequent installs).

Drop the global `package-lock.json` ignore and commit the freshly
generated `bindings/node/package-lock.json` (140 lines, only a couple
of direct deps because the binding is small). The `.gitignore` comment
notes that we still don't expect lockfiles at the workspace root.

No CI workflow changes — the existing `npm install` in the Node-test job
will now consume the committed lockfile, which is exactly what we want.
2026-05-30 18:24:46 +02:00
kingchenc 2945b47e1a feat(release): add CycloneDX SBOMs and npm provenance attestations (#66)
Two modern supply-chain-trust additions to the release pipeline,
neither of which changes what gets published — only adds verifiable
signals attached to existing releases.

1. **CycloneDX SBOMs.** `cargo-cyclonedx` is installed in the
   cargo-publish job after the .crate files are built, and runs once
   per published crate (`wickra-core`, `wickra-data`, `wickra`). The
   resulting `*.cdx.json` files are uploaded as the `sboms` artifact,
   then attached to the GitHub Release alongside the existing wheels,
   tarballs, .node binaries and .crate files. Future security advisories
   can answer "is my version of crate X transitive in wickra Y.Z?" by
   reading the SBOM directly instead of resolving the lockfile.

2. **npm `--provenance` flag** on every npm publish call:
   - main `wickra` package (node-publish, first + retry)
   - per-platform `wickra-<triple>` subpackages (node-publish loop)
   - `wickra-wasm` (wasm-publish)

   Provenance attestations are generated server-side by npm from the
   GitHub Actions OIDC token. The publishing jobs gain
   `permissions: id-token: write` so the runner can exchange that
   token. The npm page for each published version will then carry the
   "Verified provenance" badge, which proves the tarball was built by
   *this* workflow run and not by an arbitrary local laptop with the
   NPM_TOKEN.

Skipped deliberately (to keep this PR focused, possibly follow-ups):
- Sigstore cosign signing of artefacts (different audit story; can be
  layered on after npm-provenance lands).
- SLSA build-provenance attestations via `actions/attest-build-provenance`
  (would target every artefact uniformly; the npm-provenance flag is
  the more pragmatic first cut).

YAML structure validated (8 jobs intact). No production code touched.
This PR conflicts with PR #59 only in line-by-line URL substitutions
on release.yml — rebase after #59 should be clean.
2026-05-30 18:23:47 +02:00
kingchenc c212f91256 docs(examples): add 3 end-to-end strategy examples (Rust + Python) (#65)
Wires real indicators into complete signal -> fill -> PnL -> equity
loops over the checked-in BTCUSDT datasets, with per-trade Sharpe and
max-drawdown reported on stdout. Closes the gap where existing
examples showed only the mechanics of calling `update`/`batch` but not
how Wickra plugs into a trading-system shape.

Three strategies, each in Rust + Python (six files total):

- strategy_rsi_mean_reversion — RSI(14) thresholds (30/70) on 1h
  BTCUSDT. Binary position, 0.1% per-trade fee.
- strategy_macd_adx — MACD crossover entries gated by ADX(14) > 20 on
  1h BTCUSDT. Trend-follower demo of multi-indicator gating.
- strategy_bollinger_squeeze — Bollinger-bandwidth 180-day-low squeeze
  + upper-band breakout entry, ATR(14) * 2 stop. On 1d BTCUSDT for
  interpretable lookback.

Each file is self-contained — print_summary is inlined per script so
the example stays a single-file read. Every script prints a
NOT-financial-advice notice next to its results.

examples/README.md updated to list the new bins/scripts.
2026-05-30 18:23:03 +02:00
kingchenc 6c2ddf319f feat(wasm): wire wasm-bindgen-test into CI and expand coverage (#64)
* feat(wasm): wire wasm-bindgen-test into CI and expand binding coverage

The WASM binding already had 8 wasm_bindgen_test cases checked in but
they ran only when someone manually invoked `wasm-pack test` locally —
CI never executed them, so a breakage between the Rust core API and the
JS surface would only surface in user reports.

Two changes:

1. **New CI job `wasm-test`** in `.github/workflows/ci.yml` runs
   `wasm-pack test --node bindings/wasm` on every push and pull-request.
   Uses Node-runtime mode (no headless browser needed), pins
   `taiki-e/install-action` for `wasm-pack`, installs the
   `wasm32-unknown-unknown` target.

2. **10 new `#[wasm_bindgen_test]` cases** in `bindings/wasm/src/lib.rs`
   covering families that the existing 8 tests did not touch:
   - Family 4 — Macd multi-output batch shape (3-component packing)
   - Family 5 — Bollinger {upper, middle, lower} ordering invariant
   - Family 10 — FisherTransform streaming roundtrip (recursive DSP)
   - Family 16 — SharpeRatio reset semantics, MaxDrawdown monotone-
     uptrend invariant, ValueAtRisk constructor validation
   - Cross-family — reset returns indicator to warmup (3 shapes),
     warmup_period parity with wickra-core, NaN input rejection
     does not advance warmup counter

No production code changed; additive tests only. Total wasm test count
goes from 8 to 18.

* fix(wasm-tests): WasmAtr's hand-coded wrapper has no is_ready accessor

The new `reset_returns_indicator_to_warmup` test in this branch called
`atr.is_ready()` but `WasmAtr` is hand-coded (not generated by the
`wasm_scalar_indicator!` macro) and does not expose `is_ready` on the
JS surface — that pattern is reserved for macro-generated scalar
wrappers.

Replace the second leg of the test with `WasmEma` so the test exercises
two macro-generated scalars whose `is_ready`/`reset` semantics are
guaranteed by the same code path. The candle-input lifecycle is
already covered by `candle_input_streaming_matches_batch_and_lifecycle`.

Workspace clippy locally green:
- cargo clippy -p wickra-wasm --all-targets -- -D warnings
- cargo check -p wickra-wasm --target wasm32-unknown-unknown --tests

* fix(wasm-tests): Bollinger batch layout is 4 floats per bar, not 3

The new `bollinger_batch_orders_upper_mid_lower` test indexed the batch
output as `[u0, m0, l0, u1, m1, l1, ...]` (3 floats per bar) but the
actual WasmBb::batch layout is `[u0, m0, l0, sd0, u1, m1, l1, sd1, ...]`
— four floats per bar including stddev. The assertion read an upper
band against a middle from the previous bar, which can violate the
expected upper >= middle ordering and made the test fail intermittently.

Switch the stride from `* 3` to `* 4` so we read upper / middle / lower
from the same bar. Also add an `is_finite()` precondition so the test
fails with a clear "warmup positions unexpectedly NaN" message rather
than a confusing NaN-comparison if the dataset ever shrinks.

* fix(wasm-tests): drop redundant wasm-test job; existing WASM build covers it

The CI workflow already had a `WASM build` job that runs
`wasm-pack test --node bindings/wasm` after building. The
`wasm-test` job I added in cc961b3 duplicated that step and was
the only one whose `taiki-e/install-action` SHA was wrong, so it
also kept failing on a non-resolvable action reference.

Removing the duplicate keeps the test coverage (it lives in the
existing WASM build job) and gets rid of the unresolvable SHA.
2026-05-30 18:21:36 +02:00
kingchenc 9db1ff8023 chore(bench): curate benchmarks to ~30 representative indicators (#63)
Previously every push of `cargo bench -p wickra` ran 114 indicators at
three workload sizes each (1k / 10k / 50k candles), which inflated bench
runtime past ten minutes for diminishing signal — most family members
are linear scalings of the same hot loop, so a regression in any one of
them shows up identically in the cheapest member.

This commit replaces the exhaustive list with a curated selection: the
cheapest baseline and the most expensive representative from each of
the sixteen families, totalling ~33 indicators across scalar, candle,
and multi-output APIs.

If you need to profile a specific indicator that is not in the curated
set, add it temporarily and run `cargo bench -- <name>` to target just
that bench; it does not need to be committed.

Fixed in passing:
- `Cci` is `Indicator<Input = Candle>`, not f64; corrected the bench
  selector to `bench_candle_input`.
- `Psar::new` takes (af_start, af_step, af_max) — supplied all three.
- `TdSequential` uses `::classic` for the textbook (4, 9, 2, 13)
  parameters; the old call was missing arguments.
2026-05-30 18:21:19 +02:00
kingchenc fb6eae7fe2 docs: add ARCHITECTURE, ROADMAP, CITATION, FUNDING, editorconfig (#62)
Five governance + onboarding files that collectively close the
"no high-level documentation outside of README" gap.

- ARCHITECTURE.md: workspace layout, Indicator trait contract,
  per-indicator file conventions, numerical-stability notes,
  cross-crate flow diagram, navigation cheatsheet, deliberate
  non-goals, performance characteristics, stability commitments.
- ROADMAP.md: north star, 0.3 / 0.4 / 0.5 release windows,
  indicator-wishlist intake rules, explicit non-goals, versioning policy.
- CITATION.cff: GitHub-renders as "Cite this repository" button;
  enables academic adoption.
- .github/FUNDING.yml: surfaces Sponsor button on the repo page.
- .editorconfig: normalises indent/EOL across IDEs (Rust/Python 4 spaces,
  JS/TS/JSON/YAML/MD 2 spaces, Makefile tabs).

Touches no Rust source, no CI workflows, no behaviour. Additive only.
URLs in ROADMAP/CITATION already reference the post-transfer
`wickra-lib/wickra` org so the files stay valid once the org migration
in PR #59 lands; merge this PR only after #59 to keep main consistent.
2026-05-30 18:20:52 +02:00
kingchenc 0edb9f4857 fix(bindings): clear clippy pedantic lints and lint bindings in CI (#77)
* fix(bindings): clear clippy pedantic lints and lint bindings in CI

Resolve the pedantic lints that only surfaced under a full
`cargo clippy --workspace` (the CI clippy job covered only the core
crates, so the Python/Node bindings drifted):

- manual_midpoint: `(a + b) / 2.0` -> `f64::midpoint(a, b)` (node + python)
- new_without_default: add `Default` impls for the six no-arg Node nodes
- type_complexity: factor the pivot/Ichimoku return tuples into
  `PivotLevels` / `WoodieLevels` / `IchimokuLines` aliases (python)
- many_single_char_names: allow at crate level — OHLCV batch helpers bind
  the conventional o/h/l/c/v column names

Add a dedicated `clippy-bindings` CI job (ubuntu-only, with Python + Node
toolchains) so future binding lints fail CI instead of slipping through.

* ci: lint Python and Node bindings in a dedicated clippy job

The main `rust` job's clippy step only covers wickra-core/wickra/
wickra-data/wickra-wasm, so pedantic lints in the PyO3/napi bindings
slipped through. Add an ubuntu-only `clippy-bindings` job that
provisions Python + Node (needed by the build scripts) and runs
`cargo clippy -p wickra-node -p wickra-python --all-targets -- -D warnings`.
2026-05-30 18:16:21 +02:00
kingchenc ea684e0d48 feat(family-api): add FAMILIES const and family-taxonomy tests (#60)
* feat(family-api): add FAMILIES const and family-taxonomy tests

Introduce `wickra_core::FAMILIES`, a `&'static [(&str, &[&str])]` mapping
every built-in indicator to one of 16 families (Moving Averages, Momentum
Oscillators, Trend & Directional, Price Oscillators, Volatility & Bands,
Bands & Channels, Trailing Stops, Volume, Price Statistics, Ehlers /
Cycle (DSP), Pivots & S/R, DeMark, Ichimoku & Charts, Candlestick
Patterns, Market Profile, Risk / Performance).

Two compile-time-anchored guards make sure the const stays trustworthy:
- `no_duplicates_across_families`: no indicator is listed in two families
- `total_count_matches_expected`: hard-coded total bumps in lockstep with
  the indicator catalogue (214 today), so any new indicator added without
  being filed under a family trips the test.

Also corrects the module doc comment which falsely claimed
indicators were grouped by category internally; the `mod` block is
alphabetical, and the canonical taxonomy now lives in `FAMILIES`.

* chore: sync indicator count to 214

---------

Co-authored-by: wickra-bot <wickra-bot@users.noreply.github.com>
2026-05-30 14:58:16 +02:00
kingchenc 62ab84c472 chore(migration): switch org to wickra-lib and maintainer email to wickra.lib@gmail.com (#59)
Introduce repo-metadata.toml as single source of truth for repo identity
(org slug, maintainer email, canonical URLs) and add sync-metadata.yml
workflow with a Python audit script that fails CI if any tracked file
drifts back to pre-migration values.

Bulk-replace across 24 tracked files:
- kingchenc/wickra -> wickra-lib/wickra (URL segment)
- kingchencp@gmail.com -> wickra.lib@gmail.com (maintainer email)
- @kingchenc -> @wickra-lib (CODEOWNERS mention only)

Person-name credits are preserved: LICENSE copyright holder, Cargo.toml
authors handle, and CHANGELOG historical @kingchenc reference all remain
unchanged. Crate / PyPI / npm package names also untouched.

Merge this PR only after the kingchenc/wickra -> wickra-lib/wickra org
transfer has happened on the GitHub side, otherwise all badges and
repository links 404 until the transfer is performed.
2026-05-30 12:18:10 +02:00
kingchenc 0f2ff9c3c7 chore(sync-about): count public indicator types, not module files (#70)
* chore(sync-about): count public indicator types, not module files

The sync-about workflow counted `mod xxx;` lines in
crates/wickra-core/src/indicators/mod.rs to derive the indicator count
that gets propagated to README, the GitHub About description, and the
wiki. That under-reported by one because `vwap.rs` exports two public
types — `Vwap` (cumulative) and `RollingVwap` (finite window) — from
the same module. The bindings reach both, so users see 214 indicators
even though there are only 213 source files.

Fix the count by parsing the canonical `pub use indicators::{ ... }`
block in lib.rs, dropping the `FAMILIES` constant and any `*Output`
companion structs, and counting the remaining public types. Pure-shell
implementation so the workflow doesn't grow a python dependency.

Sync README to the corrected count (213 -> 214) in the same commit so
the PR validates cleanly through the workflow's PR-flow.

* docs(bindings): sync per-binding READMEs and docs/ pointer to 214 / 16

The bindings/{node,python,wasm}/README.md files (which ship to
npm / PyPI / npm again) and docs/README.md (the pointer to the
wiki) all carried the stale '71 streaming-first indicators across
eight families' header from before the family expansion. Pull the
canonical 16-family table out of the main README into all three
binding READMEs and update docs/README.md to list every family by
name, so a user landing on PyPI / npm sees the current catalogue
shape.
2026-05-30 00:40:13 +02:00
kingchenc e85334a2e9 test(cold-paths): cover 3 lines in mama/rsi/sine_wave (#58)
* test(cold-paths): cover phase fallback in mama/sine_wave and rsi naive helper saturation

* fix(rsi): drop redundant closure in test helper
2026-05-26 21:08:50 +02:00
kingchenc 4e3c41ea80 feat(family-15): add 17 risk/performance metrics (#54)
* feat(family-15): add 17 risk/performance metrics

Implements Family 15 pragmatically as standard `Indicator`s instead of a
separate `wickra-metrics` crate. Input is scalar `f64` per bar — period
return, equity sample, or per-trade P&L depending on the metric.

Scalar `Indicator<f64>` (14):
- SharpeRatio(period, risk_free)
- SortinoRatio(period, mar)
- CalmarRatio(period)
- OmegaRatio(period, threshold)
- MaxDrawdown(period)          — rolling, peak-to-trough
- AverageDrawdown(period)
- DrawdownDuration             — cumulative, bars under water (u32 output)
- PainIndex(period)
- ValueAtRisk(period, confidence)
- ConditionalValueAtRisk(period, confidence)
- ProfitFactor(period)
- GainLossRatio(period)
- RecoveryFactor               — cumulative, net return / max drawdown
- KellyCriterion(period)

Two-series `Indicator<(f64, f64)>` for (asset, benchmark) returns (3):
- TreynorRatio(period, risk_free)
- InformationRatio(period)
- Alpha(period, risk_free)     — Jensen / CAPM

Touchpoints:
- 17 new files under `crates/wickra-core/src/indicators/`.
- `mod.rs` + `lib.rs` re-exports.
- Python bindings (`bindings/python/src/lib.rs`, `__init__.py`).
- Node bindings (`bindings/node/src/lib.rs`, `index.js`).
- WASM bindings (`bindings/wasm/src/lib.rs`).
- Fuzz: scalar metrics appended to `indicator_update.rs`; new
  `indicator_update_pair.rs` fuzz target for `(f64, f64)` indicators.
- Python tests: SCALAR + new PAIR parameter lists in `test_new_indicators.py`,
  reference-value cases in `test_known_values.py`.
- Node tests: scalar factories + new pair-factory block in
  `bindings/node/__tests__/indicators.test.js`.
- Benches: 5 Family-15 benches added in `crates/wickra/benches/indicators.rs`.
- Docs: README family-table row + counter (71 -> 88), CHANGELOG entry under
  [Unreleased].

Note: Family 12 (statistik-regression, PR #51) introduces
`node_pair_indicator!` and `wasm_pair_indicator!` macros for Pearson /
Beta / Spearman. Family 15 needs the same pair-input pattern but Family 12
is not yet in main, so the three pair wrappers below are written by hand
in this PR. When PR #51 lands, the trivial merge-conflict is resolved by
keeping the macros from Family 12 and re-using them for Treynor / IR /
Alpha (drop the three handwritten wrappers).

cargo check --workspace --all-features: green.

* fix(family-15): satisfy clippy doc_markdown / if_not_else / digit_grouping

* fix(family-15): unused TreynorRatio import, duplicate pairFactories, _eq_nan inf handling

* fix(family-15): node eq() handles matching infinities for ratio indicators

* test(family-15): cover cold paths flagged by codecov patch
2026-05-26 20:44:21 +02:00
kingchenc 55284a3042 feat(family-14): add 15 candlestick patterns (#53)
* feat(family-14): add 15 candlestick patterns

Introduces the Candlestick Patterns family (block A of the family-14 spec)
as scalar f64 indicators on Candle inputs. Each detector emits +1.0 for a
bullish reading, -1.0 for a bearish reading, and 0.0 when no pattern is
present. Doji is direction-less and emits +1.0 / 0.0 only.

New indicators (15):

- Doji
- Hammer
- InvertedHammer
- HangingMan
- ShootingStar
- Engulfing
- Harami
- MorningEveningStar (signed: +1.0 morning star, -1.0 evening star)
- ThreeSoldiersOrCrows (signed: +1.0 soldiers, -1.0 crows)
- PiercingDarkCloud (signed: +1.0 piercing, -1.0 dark cloud)
- Marubozu (signed: +1.0 bullish, -1.0 bearish, 5 percent shadow tolerance default)
- Tweezer (signed: +1.0 bottom, -1.0 top, 10 bps relative tolerance default)
- SpinningTop (direction-signed indecision)
- ThreeInside (confirmed Harami)
- ThreeOutside (confirmed Engulfing)

MVP scope notes:

- Pattern-shape check only, no trend filter applied. Caller combines with a
  trend indicator for actionable signals. Documented in every doc comment.
- Block B (Harmonic patterns) and block C (Chart patterns) remain
  out-of-scope and will follow when the pattern-detection framework (pivot
  detector, multi-bar state machines) lands.

Touched across all bindings: Python, Node, WASM. Fuzz target, Python tests
(streaming-vs-batch + reference values), Node tests (streaming-vs-batch +
reference values), and a representative bench subset (1-, 2- and 3-bar
patterns) added. README family table + indicator counter (71 -> 86, eight
-> nine families) and CHANGELOG [Unreleased] updated.

* fix(family-14): unpack MULTI values with *_ to handle 3-element tuples

* cov(family-14): cover Default impl cold paths and MorningEveningStar guard branches
2026-05-26 00:54:11 +02:00
kingchenc 9b8e1346ed feat(family-16): add ValueArea + InitialBalance + OpeningRange (#52)
* feat(family-16): add ValueArea + InitialBalance + OpeningRange

Opens family #16 (Market Profile) with the three OHLCV-compatible scalar /
multi-output indicators:

- ValueArea(period, bin_count, value_area_pct) -> {poc, vah, val}.
  Rolling bin-approximation volume profile over the last `period`
  candles. Each candle's volume is spread uniformly across [low, high];
  POC is the bin with highest cumulative volume; the value area expands
  symmetrically from POC and always absorbs the higher-volume neighbour
  next, until `value_area_pct` (default 0.70) of total volume is
  enclosed. Defaults (20, 50, 0.70).

- InitialBalance(period) -> {high, low}. Tracks session-opening high
  and low over the first `period` bars, then locks. Default period = 12
  (one-hour IB on 5-minute bars for US equities). Callers MUST invoke
  reset() at every session boundary, otherwise IB stays fixed for the
  lifetime of the instance.

- OpeningRange(period) -> {high, low, breakout_distance}. Same
  lock-after-N-bars semantics as IB with a shorter default period
  (6 = 30 min on 5-minute bars) and a third output that tracks
  close - or_mid (positive above the range mid, negative below).

Histogram-output Market Profile variants (Volume Profile, VPVR,
Composite Profile) are deferred because they need a new histogram
output API layer rather than fixed-arity scalars. Tick-data-only
variants (TPO Profile, Single Print, Order Flow Delta, Cumulative
Delta, Volume-Weighted Open) are out of scope because `wickra-data`
does not currently expose tick / L2 data.

All four bindings (Rust core, Python, Node, WASM) ship the new
indicators with parity tests; benches added; fuzz target extended.
Counter 71 -> 74 across 8 -> 9 families. cargo check --workspace
--all-features green.

* fix(family-16): cover cold paths in InitialBalance + ValueArea

InitialBalance::value() public getter had no test covering the post-update
Some(...) branch — extended accessors_and_metadata to call value() after one
update. ValueArea single-print bar path (c.high == c.low) was unreachable in
existing tests since the only single-print test used a uniform 100-price
window which exits early via the span == 0 guard; added a mixed-window test
that triggers the c.high <= c.low branch directly. The (None, None) arm of
the expansion match was by-construction unreachable (the loop condition
already requires at least one neighbour) and has been folded into an
if/else.
2026-05-26 00:14:30 +02:00
kingchenc 05fcdd9a5e feat(family-12): add 13 Statistik/Regression indicators (#51)
* feat(family-12): add 13 Statistik/Regression indicators

Brings the Price Statistics family to 20 indicators (7 → 20) and the
total catalogue to 84 (71 → 84). Every indicator ships in the Rust
core plus Python, Node, and WASM bindings with full streaming ↔ batch
parity, fuzz coverage, and benches.

Scalar (f64 → f64):
- Variance, CoefficientOfVariation: rolling population variance and
  its dimensionless ratio with the mean. O(1) updates.
- Skewness, Kurtosis: rolling Pearson skewness and excess kurtosis,
  derived from running sums of x, x², x³, x⁴ via the binomial
  identities — also O(1) per bar.
- StandardError, DetrendedStdDev: standard error of estimate (n − 2)
  and population StdDev (n) of OLS residuals, sharing the LinReg
  O(1) sliding sums.
- RSquared: coefficient of determination of the rolling OLS fit; the
  trend-quality filter, clamped to [0, 1].
- MedianAbsoluteDeviation: robust dispersion estimator; O(period log
  period) per emission via two in-place sorts of a reusable scratch
  buffer.
- Autocorrelation(period, lag): rolling lag-k Pearson autocorrelation.
- HurstExponent(period, chunks): R/S-analysis trend-persistence
  estimator clamped to [0, 1].

Pair indicators (Input = (f64, f64)):
- PearsonCorrelation: rolling cross-series Pearson, O(1).
- Beta: rolling OLS slope of asset vs. benchmark (CAPM).
- SpearmanCorrelation: rolling rank correlation with mid-rank tie
  handling; O(period log period).

Touchpoints:
- crates/wickra-core: 13 new indicator modules + mod.rs / lib.rs
  re-exports.
- bindings/python: pyclasses + add_class registration + __init__.py
  import & __all__ updates. The pair indicators expose
  update(x, y) and batch(x, y) over two equally-sized numpy arrays.
- bindings/node: scalar indicators via node_scalar_indicator! macro;
  pair indicators via new node_pair_indicator! macro; explicit
  structs for Autocorrelation and HurstExponent (two-arg ctors).
  index.js extended with the new exports.
- bindings/wasm: scalar wrappers via wasm_scalar_indicator!; pair
  wrappers via new wasm_pair_indicator! macro.
- fuzz: every scalar drove through the generic helper; pair
  indicators stress-tested by pairing adjacent samples of the fuzz
  input.
- Python tests (test_new_indicators.py): added to SCALAR
  parametrisation, plus algebraic reference values
  (variance of [2,4,6] = 8/3, MAD ignoring outlier = 0, monotone
  non-linear Spearman = 1, two-to-one Beta = 2, etc.) and a
  streaming-vs-batch test for the pair indicators.
- Node tests (indicators.test.js): extended the scalar factories
  map and added a pair-indicator section with the same algebraic
  reference values.
- crates/wickra/benches: bench_scalar entries for all 10 single-
  input new indicators.
- README: counter 71 → 84; Price Statistics family-table row
  expanded with the 13 new indicators.
- CHANGELOG: Unreleased section documents the family addition.

Wiki drafts (ghost-ignored, manual sync to wickra.wiki at release
time): indicator-ideas/families/wiki/family-12-statistik-regression/
contains 13 deep-dive pages plus _Sidebar / Indicators-Overview /
Warmup-Periods / Home fragments for the curator merge.

cargo check --workspace --all-features: clean.

* fix(family-12): remove unreachable defensive guards in hurst_exponent

The three guards (m < 2 continue, end > buf.len() break, denom == 0.0
return) are by-construction unreachable given the constructor invariant
period >= 2 * chunks: m = period / k for k in 1..=chunks always
satisfies m >= 2 and end = (c+1) * m <= k * m <= period = buf.len(),
and m_1 = period and m_2 = period / 2 are always distinct so the slope
denominator is strictly positive. Removing them brings codecov/patch
back to 100%.
2026-05-25 23:42:05 +02:00
kingchenc 5aa0949bce feat(family-13): add Ichimoku + Heikin-Ashi (#50)
Two new indicators in a brand-new "Ichimoku & alternative charts"
family:

- `Ichimoku` (Ichimoku Kinko Hyo): the full five-line cloud system
  (Tenkan-sen, Kijun-sen, Senkou Span A/B, Chikou Span). Classic
  (9, 26, 52, 26) defaults; configurable. Forward displacement is
  handled in an O(1) ring buffer so the visible Senkou A/B at bar n
  are the values computed at bar n-displacement.
- `HeikinAshi`: recursive candle smoothing transform emitting a
  four-field synthetic candle. Seeds ha_open from (open+close)/2 on
  the first bar.

Touchpoints: core + unit tests, mod.rs/lib.rs re-exports, Python +
Node + WASM bindings (multi-output via PyArray2 / interleaved Vec<f64>
/ Object+Float64Array), Python tests across smoke/new-indicators/
input-validation, Node parity tests, fuzz target (Candle), benches,
README family table + counter (71 -> 73, 8 -> 9 families), CHANGELOG.

Note: Renko, Kagi, and Point & Figure from the family-13 ideas list
are intentionally skipped. They are bar generators (the bar boundary
is defined by price moves, not by a fixed time interval) rather than
indicators that consume a candle stream, and belong in wickra-data
as candle/tick transforms alongside the existing tick-to-candle
aggregator and resampler.
2026-05-25 23:02:29 +02:00
kingchenc b971e671b4 test(family-10): cover cold paths flagged by codecov (#57)
Add tests that exercise the protective fallbacks reachable via flat or
zero-valued input:

- `CenterOfGravity::zero_window_uses_zero_fallback` — den == 0 branch.
- `EhlersStochastic::flat_window_emits_zero` — range == 0 branch.
- `Mama::flat_input_uses_phase_fallback` and the matching
  `SineWave` variant — `i1` collapses to zero on a constant series.
- `Fama::new_with_valid_limits_constructs_via_mama` exercises the
  `Ok(Self { inner: Mama::new(..)? })` arm that no other test reaches.

Three branches were genuinely unreachable by construction, so the dead
code is removed rather than masked with an attribute:

- `Mama` clamped `alpha > fast_limit` after the lower-bound clamp; the
  upper bound is implied by `delta_phase >= 1` and `alpha = fast / delta_phase`.
- `CyberneticCycle` had a `0.0` fallback after the warmup gate that the
  3-slot ring buffers preclude (`count >= 7` => all five `Some`s).
- `DecyclerOscillator` used a `let-else { return None }` over a pair of
  `Decycler::update` calls that always emit `Some` from the first bar.
2026-05-25 22:32:00 +02:00
kingchenc 7a18a26daf feat(family-10): add 16 Ehlers / Cycle (DSP) indicators (#49)
Implements Family 10 (Ehlers / Cycle) end-to-end across Rust core,
Python / Node / WASM bindings, fuzz, tests, benches and docs. This
is an entirely new family covering John Ehlers' digital-signal-
processing school of cycle analytics — a strong differentiator
versus TA-Lib and pandas-ta, which ship only fragments.

Indicators:
- MAMA (Mesa Adaptive MA) — multi-output { mama, fama }
- FAMA (Following Adaptive MA) — scalar wrapper around MAMA's slow line
- Fisher Transform — Gaussian-normalising price transform
- Inverse Fisher Transform — bounded oscillator (tanh-based)
- SuperSmoother — 2-pole Butterworth lowpass
- Roofing Filter — high-pass + SuperSmoother bandpass
- Decycler — price minus 2-pole high-pass (lag-free trend)
- Decycler Oscillator — fast / slow Decycler difference (MACD-like)
- Hilbert Dominant Cycle — phase-derived period estimator [6, 50]
- Sine Wave Indicator — sin(phase) with 45° lead companion
- Adaptive Cycle Indicator — half-period driver for adaptive oscillators
- Center of Gravity Oscillator — weighted-mass momentum
- Cybernetic Cycle Component — EasyLanguage classic
- Empirical Mode Decomposition — bandpass + envelope mean
- Ehlers Stochastic — Stochastic on Roofing Filter input, [-1, +1]
- Instantaneous Trendline — Ehlers 2-pole lag-free trend

Indicator count rises 71 -> 87 across nine families (was eight).

All sixteen pass batch == streaming equivalence, expose the standard
Indicator surface (update / batch / reset / is_ready / warmup_period
/ name), are fuzz-tested, benchmarked against the checked-in BTCUSDT
1-minute dataset and reach across all four bindings.

Wiki deep-dive drafts for every indicator + Sidebar / Overview /
Home / Warmup updates are staged under indicator-ideas/families/
wiki/family-10-ehlers-cycle/ in the main repo (ghost-ignored) for
the maintainer to publish to the wiki repo manually.
2026-05-25 22:14:27 +02:00
kingchenc 4f9ed34884 feat(family-11): add DeMark suite (TD Setup, Sequential, DeMarker, REI, Pressure) (#48)
* feat(family-11): add DeMark suite (TD Setup, Sequential, DeMarker, REI, Pressure)

Family 11 (DeMark) was previously empty; this PR adds five
streaming-first DeMark indicators in one batch.

- **TD Setup** (`TdSetup`): parameterised buy/sell setup counter.
  Counts consecutive bars whose close is less-than (buy) or
  greater-than (sell) the close `lookback` bars earlier, saturating
  at `target`. Emits a signed `f64` so callers read direction from
  the sign and run length from the magnitude. Classic config:
  `lookback = 4`, `target = 9`.

- **TD Sequential** (`TdSequential`): the canonical Setup + Countdown
  exhaustion pattern. Output struct `{ setup, countdown, direction }`
  exposes both phase counts as signed numbers plus the active
  countdown direction (+1 buy / -1 sell / 0 none). Countdown
  activates when a setup completes and tracks the close-vs-high/low
  comparison `countdown_lookback` bars back, capped at
  `countdown_target`. Classic: 4/9/2/13.

- **TD DeMarker** (`TdDeMarker`): bounded [0, 1] oscillator from the
  rolling average of upward high expansion (DeMax) and downward low
  expansion (DeMin). Falls back to the neutral 0.5 on a flat market
  (denominator zero).

- **TD REI** (`TdRei`): Range Expansion Index, bounded [-100, 100].
  Per-bar numerator gated on a range-overlap condition vs the bars
  5 and 6 back, normalised by a `period`-bar sum of absolute moves.
  Classic period = 5. Saturates at +100 in a slow steady uptrend
  and at -100 in the mirror downtrend; emits 0 on a flat market.

- **TD Pressure** (`TdPressure`): volume-weighted buying / selling
  pressure normalised to [-100, 100]. Per-bar pressure is the
  intra-bar close-vs-open ratio scaled by volume; the output is the
  rolling mean divided by the rolling mean volume. Zero-range bars
  contribute zero (avoid the undefined ratio) and a flat zero-volume
  window falls back to 0.

Bindings: all five exposed in Python (`ta.TDSetup`, `ta.TDSequential`,
`ta.TDDeMarker`, `ta.TDREI`, `ta.TDPressure`), Node (`wickra.TDSetup`
etc.), and WASM. Multi-output classes (`TDSequential`) return either
a struct `{ setup, countdown, direction }` per bar (streaming) or a
flat interleaved Float64Array of length `3 * n` (batch).

Tests: 47 unit tests across the five new core files (pure-trend
saturation, flat-market neutral fallback, batch-equals-streaming,
zero-parameter rejection, reset semantics, accessors). Python
test_new_indicators.py picks up all five plus a multi-output TD
Sequential block. Node indicators.test.js picks up all five.
Reference values added to test_known_values.py.

Fuzz: candle fuzz target sweeps all five DeMark indicators with the
existing `Vec<f64>` -> `Vec<Candle>` driver.

Benches: BTCUSDT 1-minute dataset benches for each DeMark indicator
in `crates/wickra/benches/indicators.rs`.

Docs: README family table gains a "DeMark" row; indicator counter
bumped 71 -> 76. CHANGELOG entry added under [Unreleased]. Wiki
drafts (deep-dive pages + Sidebar / Overview / Warmup-Periods / Home
deltas) live under `indicator-ideas/families/wiki/family-11-demark/`
for manual merge into the wiki repo.

* feat(family-11): add 7 missing DeMark indicators

Complete the DeMark suite (family 11) with the seven indicators not
covered by the first commit: TD Combo, TD Countdown, TD Lines (TDST),
TD Range Projection, TD Differential, TD Open, and TD Risk Level.

- TdCombo: aggressive countdown variant with three strictness rules
  on top of the classic close-vs-low/high lookback rule (monotone
  low/high, monotone close vs prior bar).
- TdCountdown: standalone 13-bar countdown packaging only the signed
  countdown count (the setup machine runs internally).
- TdLines: TDST horizontal support/resistance levels from the
  highest-high / lowest-low bars of the most-recently-completed
  setup, exposed as a multi-output struct.
- TdRangeProjection: DeMark X-projection of the next bar's high and
  low from the current bar's OHLC via an open-vs-close-weighted
  pivot (three branches: close<open, close>open, close==open).
- TdDifferential: two-bar buying-pressure vs selling-pressure
  reversal pattern emitting +1/-1/0.
- TdOpen: gap-and-fade reversal pattern (open outside prior range
  with subsequent recovery into it) emitting +1/-1/0.
- TdRiskLevel: protective stop levels derived from the setup
  extreme bar +/- its true range.

All seven are wired through Rust core, Python, Node and WASM
bindings, registered in the candle-stream fuzz target, given
benchmark entries on the BTCUSDT 1-minute dataset, and covered by
streaming-vs-batch equivalence, reference-value, lifecycle and
input-validation tests on the Python and Node sides. README counter
moves 76 -> 83 and the CHANGELOG "family 11" entry is extended to
list all twelve indicators.

* fix(td_risk_level tests): check first emission at idx 12, not last bar

TdRiskLevel re-ratchets the sell-risk level on each subsequent setup
completion, so a strictly rising series produces 22.0 at idx 19 (latest
setup) rather than 15.0 (first setup). The test comment already named
idx 12 as the reference; switch the assertion from out[-1] to out[12]
to match the reference computation.

* test(family-11): cover buy-direction branches in TD indicators

Add downtrend tests to TdSequential, TdCombo and TdCountdown so the
buy-side countdown/combo increment branches are exercised; remove an
empty `if buy_countdown == target {}` block in TdSequential whose
behavior is already enforced by the outer strict `<` guard.

Closes codecov/patch gaps reported on PR #48 (10 missed lines across
the three files).
2026-05-25 20:36:36 +02:00
kingchenc 7e1e988596 feat(family-08): Pivots & Support/Resistance (7 indicators) (#47)
* feat(family-08): add Classic, Fibonacci, Camarilla, Woodie and DeMark pivots + Williams Fractals + ZigZag

Seven new indicators land the previously empty Pivots & S/R family
(family 08), each implemented in wickra-core with the full Indicator
trait surface (update / reset / warmup_period / is_ready / name),
exposed across Python (PyO3), Node (napi-rs) and WASM (wasm-bindgen)
with the standard streaming + batch APIs, and covered by Rust unit
tests, Python streaming-vs-batch + reference-value tests, Node
streaming-vs-batch tests, the candle-input fuzz target and Rust
microbenchmarks.

- ClassicPivots (7 levels): PP = (H+L+C)/3, three R/S tiers per the
  floor-trader formulas.
- FibonacciPivots (7 levels): PP plus R/S spaced by 0.382 / 0.618 /
  1.000 of the prior range.
- Camarilla (9 levels): Nick Stott's four-tier `C +/- (H - L) * 1.1 /
  {12, 6, 4, 2}` levels.
- WoodiePivots (5 levels): close-weighted PP = (H + L + 2*C) / 4 plus
  two R/S tiers.
- DemarkPivots (3 levels): conditional X sum based on the previous
  bar's open-vs-close relationship.
- WilliamsFractals: five-bar swing detector emitting optional up/down
  fractal prices at the centre of each window.
- ZigZag: percent-threshold swing tracker, non-repainting; emits the
  just-completed extreme and direction on confirmed reversals only.

README family table updated to nine families / 78 indicators;
CHANGELOG records the family-08 addition under [Unreleased].

* fix(family-08 tests): unify MULTI dict to 3-tuple (factory, batch_call, k)

The HEAD-side family-08 test parametrised MULTI[name] as
`(factory, batch_call, output_arity)` so that pivots with arity 3/5/7/9
fit the same harness. Main's entries arrived as 2-tuples; convert them
all to the 3-tuple shape so `make, batch_call, k = MULTI[name]` unpacks
cleanly. Lifecycle test now indexes the tuple instead of destructuring.

* test(zig_zag): tighten flat-oscillation test (drop dead counter branch)

The previous version of `small_oscillations_yield_no_swings` counted
emitted swings, but the assertion proves the counter never increments
so codecov flagged `emitted += 1` as uncovered. Switch to a per-bar
`assert!(...is_none())` — same coverage of the no-swing path, no dead
branch.
2026-05-25 20:06:46 +02:00
kingchenc f10b8c2e2d feat(family-09): add 7 trailing stops (HiLo, Volty, Yo-Yo, Donchian, Pct, Step, Renko) (#46)
* feat(family-09): add 7 trailing stops (HiLo, Volty, Yo-Yo, Donchian, Pct, Step, Renko)

Rounds out the Trailing Stops family from 5 to 12 indicators:

- HiLoActivator (Crabel): SMA-of-high/SMA-of-low trail with a one-bar
  lag; emits the opposite-side SMA as the trailing stop.
- VoltyStop (Cynthia Kase): ATR trail anchored on the extreme close
  since the trade was opened — tighter than AtrTrailingStop on
  pullbacks.
- YoyoExit: long-only ATR trail with an explicit re-entry trigger at
  trail + multiplier*ATR; exposes an in_trade flag.
- DonchianStop (Turtle): lowest low / highest high over the window;
  multi-output {stop_long, stop_short}.
- PercentageTrailingStop: fixed-percent trail that scales across
  instruments without per-asset tuning.
- StepTrailingStop: snaps to a step_size-aligned grid; mirrors
  discretionary stop-by-hand workflow.
- RenkoTrailingStop: block-anchored trail; only moves on full-block
  advances, ignores intra-block noise.

All seven are wired into wickra-core, the Python / Node / WASM
bindings, the indicator_update + indicator_update_candle fuzz targets,
the wickra bench harness, and the Python + Node test suites. README
counter bumps from 71 to 78; CHANGELOG entry under [Unreleased].

* fix(family-09): satisfy pedantic clippy lints

- hilo_activator: rewrite match-Some/None as if-let-else (single_match_else),
  add backticks around the HiLo identifier in module/struct doc (doc_markdown).
- percentage / step / renko trailing stop tests: use f64::from(i32) instead
  of `as f64` (cast_lossless).
- bench `benches()` is now >100 lines after Family 09 was wired in; allow
  too_many_lines (matches the python pymodule fn).
2026-05-25 19:36:14 +02:00
kingchenc 880a0e7430 feat: Family 07 Volume - 6 new volume-flow indicators (#45)
* feat(kvo): add Klinger Volume Oscillator

Stephen J. Klinger's trend-aware volume-force MACD. Each bar produces a 'volume force' (vf) signed by the local trend (+1 / -1 / carry) and scaled by the ratio of the current accumulation horizon to its previous trend. KVO = EMA(vf, fast) - EMA(vf, slow), classic (34, 55).

Rust core (Kvo) with 7 unit tests (rejects zero / fast>=slow, accessors, constant series collapses to 0, warmup lands at slow+1, batch == streaming, reset clears state), plus Python (PyKvo + KVO export), Node (KvoNode), and WASM (WasmKvo) bindings. Fuzz target adds Kvo to the candle-input sweep, bench adds the candle-input KVO benchmark, README counter 71 -> 72 + family table row, CHANGELOG [Unreleased].

* feat(volume-oscillator): add Volume Oscillator (VO)

Percent difference between a fast and a slow SMA of the bar volume: 100 * (SMA(vol, fast) - SMA(vol, slow)) / SMA(vol, slow). Default (14, 28). The line stays near zero in stable conditions; positive readings show rising short-term participation, negative readings show waning interest.

Rust core (VolumeOscillator) with 8 unit tests (period validation, accessors, constant volume == 0, zero-volume window defensive branch, two reference values verified algebraically, batch == streaming, reset), plus Python (PyVolumeOscillator + VolumeOscillator export), Node (VolumeOscillatorNode), and WASM (WasmVolumeOscillator) bindings. Fuzz target adds VolumeOscillator to the candle-input sweep, bench adds the volume_oscillator benchmark, README counter 72 -> 73 + family table row, CHANGELOG [Unreleased].

* feat(nvi-pvi): add Negative & Positive Volume Index

Paul Dysart's cumulative volume-flow indices, popularised by Norman Fosback in 'Stock Market Logic'. Both run from a 1000.0 baseline and only update on a specific direction of volume change:

- NVI updates on volume-contraction bars (volume_t < volume_{t-1}), absorbing the percent close change. Tracks the 'smart money' leg per Fosback.
- PVI updates on volume-expansion bars (volume_t > volume_{t-1}). Tracks the 'crowd' leg.

Both expose with_baseline(f64) for custom starting indexes. The NVI/PVI pair is listed as a single line in indicator-ideas/families/07-volume.md and shares the same lifecycle/test/binding surface, so they ship as one commit.

Rust core (Nvi, Pvi) with 9 unit tests each (accessors, baseline seed, volume direction branches, zero-prev-close guard, custom baseline, batch == streaming, reset), plus Python (PyNvi/PyPvi + NVI/PVI exports), Node (NviNode/PviNode), and WASM (WasmNvi/WasmPvi) bindings. Fuzz target adds Nvi+Pvi to the candle-input sweep, bench adds nvi+pvi entries, README counter 73 -> 75 + family table row, CHANGELOG [Unreleased].

* feat(family-07): add Williams A/D, Anchored VWAP, Demand Index, TSV, VZO, Market Facilitation Index

Finishes the volume-flow family with the remaining (new) entries from
indicator-ideas/families/07-volume.md.

Indicators added:

- Williams A/D (`WilliamsAD`): Larry Williams' volume-less cumulative
  accumulation/distribution line. Anchors each bar's contribution to
  the previous close via true-high/true-low (gap-aware).
- Anchored VWAP (`AnchoredVwap`): cumulative VWAP whose accumulation
  starts at a user-chosen anchor bar. Exposes `set_anchor()` (queued
  to the next `update`) for click-to-anchor workflows. Reset clears
  both state and pending-anchor flag.
- Demand Index (`DemandIndex`): James Sibbet's smoothed buying-vs-
  selling pressure, in the streaming-friendly textbook form
  `EMA(volume * close-return * (1 + range/close), period)`.
- Time Segmented Volume (`Tsv`): Don Worden's rolling window-sum of
  `(close_t - close_{t-1}) * volume_t`. Default `period = 18`.
- Volume Zone Oscillator (`Vzo`): Walid Khalil's normalised volume-flow
  oscillator bounded in `[-100, +100]`, defined as
  `100 * EMA(signed_volume) / EMA(volume)`.
- Market Facilitation Index (`MarketFacilitationIndex`): Bill Williams'
  per-bar `(high - low) / volume`. Returns `None` on zero-volume bars.

All six indicators ship with unit tests (`rejects_zero_period` where
applicable, `accessors_and_metadata`, constant-series behaviour,
batch == streaming equivalence, reset semantics, and reference-value
or saturation-extreme tests), Python / Node / WASM bindings, fuzz
coverage in `indicator_update_candle`, a `bench_candle_input` line per
indicator, README + CHANGELOG entries, and Python reference-value
tests in `test_new_indicators.py`.

The README indicator counter advances 75 -> 81.

* test(family-07): cover defensive cold paths + Default impls

- ad_oscillator: exercise `value()` after first emission.
- kvo: cover the `cm == 0.0` zero-OHLC defensive branch.
- nvi / pvi: exercise the Default impls.
2026-05-25 19:15:22 +02:00
kingchenc 6287bd48c1 feat: Family 06 Trend-Strength - 5 new directional/random-walk indicators (#44)
* feat(adxr): add Wilder Average Directional Movement Index Rating

ADXR is the trend-strength smoother Wilder published alongside ADX in
*New Concepts in Technical Trading Systems* (1978):

    ADXR_t = (ADX_t + ADX_{t - (period - 1)}) / 2

The lookback length is the same period that feeds the underlying ADX.
Because the older ADX is period - 1 bars stale, ADXR responds more
slowly than ADX and is the canonical metric for comparing
trend-strength across instruments.

Implementation reuses the existing wickra_core::Adx engine plus a
period-length ring of past ADX values; warmup is 3 * period - 1
(41 for period = 14). Bindings: Python PyAdxr (PyArray1 batch),
Node AdxrNode (number scalar), WASM WasmAdxr. Fuzz target covers
the candle-input path. Python + Node streaming-vs-batch tests
parametrised, plus a pure-uptrend reference value (ADXR == 100
when ADX saturates at 100). Criterion bench added under crates/
wickra/benches/indicators.rs.

README family table and indicator counter updated (71 -> 72).

* feat(rwi): add Mike Poulos Random Walk Index

RWI compares actual price displacement to what a random walk would
produce over the same horizon: for each lookback i in [2, period],

    RWI_High_t(i) = (high_t - low_{t-i+1}) / (ATR_i(t) * sqrt(i))
    RWI_Low_t(i)  = (high_{t-i+1} - low_t) / (ATR_i(t) * sqrt(i))

Per-bar output is the maximum across lookbacks for each direction;
a reading > 1 means the trend beats random-walk noise, > 2 is the
typical strong-trend threshold. Multi-output (high, low). period
must be >= 2 (the shortest meaningful lookback); period < 2 returns
InvalidPeriod. Warmup = period (e.g. 14 for the standard default).

Bindings: Python PyRwi (PyArray2 shape (n, 2)), Node RwiNode +
RwiValue struct, WASM WasmRwi (Object/Reflect for update,
Float64Array interleaved for batch). Fuzz target adds the candle
input case. Python parametric streaming-vs-batch test and pure
uptrend reference test (RWI_High dominates RWI_Low and exceeds 1).
Node parametric streaming-vs-interleaved-batch test. Criterion
bench under crates/wickra/benches/indicators.rs.

README family table and indicator counter updated (72 -> 73).

* feat(tii): add M.H. Pee Trend Intensity Index

TII is a [0, 100] oscillator that asks 'what fraction of the recent
SMA deviations are positive?'. The construction is

    dev_t  = close_t - SMA(close, sma_period)_t
    SD_pos = sum of positive dev_t over the last dev_period bars
    SD_neg = sum of |negative dev_t| over the last dev_period bars
    TII    = 100 * SD_pos / (SD_pos + SD_neg)

Saturates at 100 on a pure uptrend (every close above the lagging
SMA), at 0 on a pure downtrend, and returns the neutral mid-point 50
on a perfectly flat window. The output is clamped to [0, 100] as
the rolling-sum subtraction loop can accumulate a few ULP of error
on long histories. Canonical Pee parameters (sma_period=60,
dev_period=30) wired as Python defaults; warmup is
sma_period + dev_period - 1 (89 for the defaults).

Bindings: Python PyTii (PyArray1 batch), Node TiiNode (scalar
update + batch), WASM WasmTii via the two-arg wasm_scalar_indicator!
macro. Fuzz target adds the scalar path. Python parametric
streaming-vs-batch test plus pure-uptrend (TII == 100) and
flat-market (TII == 50) reference tests. Node parametric
streaming-vs-batch test. Criterion bench under crates/wickra/
benches/indicators.rs.

README family table and indicator counter updated (73 -> 74).

* feat(kst): add Pring Know Sure Thing oscillator

KST is Martin Pring's long-horizon momentum gauge: four smoothed
rate-of-change components combined with fixed weights (1, 2, 3, 4),
plus an SMA signal line.

    RCMA_i = SMA(ROC(close, roc_i), sma_i)        for i in 1..=4
    KST    = 1*RCMA_1 + 2*RCMA_2 + 3*RCMA_3 + 4*RCMA_4
    Signal = SMA(KST, signal_period)

Kst::classic() exposes Pring's recommended parameter set
(roc = (10, 15, 20, 30), sma = (10, 10, 10, 15), signal = 9);
warmup = max(roc_i + sma_i) + signal_period - 1 (53 for the classic
parameters). All four parallel branches are fed unconditionally so
they warm in lock-step.

Bindings: Python PyKst (PyArray2 shape (n, 2)) with a KST.classic()
staticmethod, Node KstNode + KstValue with a KST.classic() factory,
WASM WasmKst with both new(...) and classic() constructors plus
Object/Reflect for update and Float64Array for batch. Fuzz target
adds the scalar multi-output path. Python tests gain a new
MULTI_SCALAR section parametric over scalar-input/multi-output
indicators, plus a classic-on-constant-series reference test. Node
tests gain a KST entry in the multi-output section. Criterion
benchmark added under crates/wickra/benches/indicators.rs.

README family table and indicator counter updated (74 -> 75).

* feat(wave-trend): add LazyBear Wave Trend Oscillator

Two-line mean-reverting momentum gauge built from the typical price
and three cascaded EMAs:

    ap   = (high + low + close) / 3
    esa  = EMA(ap, channel_period)
    d    = EMA(|ap - esa|, channel_period)
    ci   = (ap - esa) / (0.015 * d)
    wt1  = EMA(ci, average_period)
    wt2  = SMA(wt1, signal_period)

WaveTrend::classic() exposes LazyBear's defaults
(channel = 10, average = 21, signal = 4); warmup is
2 * channel_period + average_period + signal_period - 3 (42 for the
classic defaults). On a perfectly flat market the SMA-seeded EMA
introduces a single-ULP drift between ap and esa, which on a tiny d
would make the ratio explode to -1/0.015 = -66.67; a price-scaled
flat-tolerance guard (d <= 16 * EPSILON * max(|esa|, 1)) collapses
the channel index to 0 in that regime so both lines remain at zero.

Bindings: Python PyWaveTrend (PyArray2 shape (n, 2)) with a
WaveTrend.classic() staticmethod, Node WaveTrendNode + WaveTrendValue
with a WaveTrend.classic() factory, WASM WasmWaveTrend with both
new(...) and classic() constructors. Fuzz target adds the candle
multi-output path (sorted alphabetically). Python parametric
streaming-vs-batch test plus a flat-market reference test. Node
parametric streaming-vs-interleaved-batch test. Criterion bench
under crates/wickra/benches/indicators.rs.

README family table and indicator counter updated (75 -> 76).

* fix(family-06): re-add KST::classic() factory + drop dup fuzz block

Family-06 PR's tests call ta.KST.classic() / wickra.KST.classic() — main's
KST binding shipped without the static factory. Add classic() in Python
(staticmethod) and Node (napi factory); WASM already had it. Also drop the
duplicate Kst::classic().unwrap() block in fuzz/indicator_update.rs that
the merge left behind (main's API no longer returns Result).

* test(rwi): drop dead count==0 guard

The loop `for i in 2..=period` makes `count = tr_end - tr_start = i - 1`
which is always >= 1, so the `if count == 0 { continue; }` branch was
unreachable defensive code that codecov flagged on the family-06 PR.
2026-05-25 19:00:13 +02:00
kingchenc 54194a4ff8 feat: Family 05 Bands & Channels - 11 new price-envelope indicators (#43)
* feat(bands-channels): add Family 05 with 11 indicators

Eleven price-envelope overlays organised into a new "Bands & Channels"
family, exposed across all four bindings (Rust core, Python, Node, WASM)
plus fuzz/test/bench/docs coverage:

- MaEnvelope - SMA centerline with fixed-percent envelope (the oldest
  band overlay still in regular use).
- AccelerationBands (Price Headley) - momentum-biased bands that widen
  with the bar's relative range (H - L) / (H + L).
- StarcBands (Stoller Average Range Channel) - SMA(close) +/- k*ATR;
  Keltner's SMA-centerline sibling.
- AtrBands - close-anchored envelope of width k*ATR; the standard
  volatility-targeting stop/target band.
- HurstChannel - SMA centerline wrapped by the rolling high-low range
  (Brian Millard / Hurst-cycle channel).
- LinRegChannel - rolling OLS endpoint +/- k * population stddev of the
  residuals; dispersion about the trend rather than the mean.
- StandardErrorBands - regression line +/- k * OLS standard error
  (denominator n - 2) for prediction-interval bands.
- DoubleBollinger (Kathy Lien) - two concentric BB envelopes
  (typically +/- 1 sigma and +/- 2 sigma) for the zone-partition setup.
- TtmSqueeze (John Carter) - BB-inside-KC squeeze flag paired with a
  detrended-close linear-regression momentum reading.
- FractalChaosBands - Bill Williams 5-bar fractal high/low envelope.
- VwapStdDevBands - cumulative VWAP with volume-weighted population
  standard deviation bands.

Each indicator ships:
- Core impl with the full Indicator trait, classic() where applicable,
  and unit tests (rejects_zero_period / multiplier, accessors, flat
  market, monotonic ordering, batch == streaming, reset, plus
  algebraically verifiable reference values).
- Python PyO3 binding with multi-column NumPy batch (PyArray2).
- Node napi binding with #[napi(object)] struct + interleaved flat
  batch.
- WASM wasm-bindgen binding via Object/Reflect for update +
  Float64Array for batch.
- Fuzz coverage in fuzz_targets/indicator_update{,_candle}.rs.
- Python streaming-vs-batch parametric test + reference test.
- Node streaming-vs-interleaved-batch test + reference test.
- Criterion microbench under crates/wickra/benches/indicators.rs.

README family table, README indicator-count line, and CHANGELOG
Unreleased entry updated: indicator total rises from 71 to 82 across
nine families. Wiki pages are updated in a separate commit in the
wickra.wiki repo.

* test(acceleration-bands): cover sum_hl==0 zero-price guard

Exercises line 104 (`0.0` branch of the `sum_hl == 0.0` guard) which
was the last patch-coverage miss on the family-05 PR. `Candle::new`
accepts a fully-zero bar so the branch is reachable in principle —
add a degenerate-candle unit test to hit it.
2026-05-25 18:37:12 +02:00
kingchenc 3ea0f12b7a feat: Family 04 Volatility — RVI / Parkinson / Garman-Klass / Rogers-Satchell / Yang-Zhang (#42)
* feat(rvi): add Relative Volatility Index

Donald Dorsey's RSI-shaped volatility gauge. Partitions the rolling
population standard deviation of close into "up" samples (close rose
since the previous bar) and "down" samples (close fell), Wilder-smooths
each side, and reports 100 * AvgUp / (AvgUp + AvgDown). Output bounded
on [0, 100]; saturates at 100 in pure uptrends, 0 in pure downtrends,
and falls back to 50 on a completely flat series (same undefined-RS
convention as RSI).

Single period parameter (default 10) drives both the stddev window and
the Wilder smoothing constant. First emit lands at index 2*period - 2
(2*period - 1 bars are needed: period to fill the stddev window plus
period - 1 to seed the Wilder averages, overlapping by one bar).

Touchpoints: rvi.rs + mod.rs + lib.rs re-export, PyRvi + __init__.py +
test_new_indicators SCALAR + test_known_values uptrend reference,
RviNode + index.d.ts/index.js + indicators.test.js factory +
reference, WasmRvi via scalar macro, scalar-fuzz target, bench_scalar
entry, README + CHANGELOG.

* feat(parkinson): add Parkinson Volatility

Michael Parkinson's (1980) high-low realised volatility estimator.
Under a driftless Geometric-Brownian-Motion assumption, the extreme
range of a bar carries roughly 5x the variance information of the
close-to-close estimator, so for a given statistical efficiency
Parkinson needs five times fewer samples.

Formula:
    sigma^2 = (1 / (4n * ln 2)) * Sum_{i=1..n} (ln(H_i / L_i))^2
    out     = sqrt(sigma^2) * sqrt(trading_periods) * 100

The output is annualised to a percent in the same style as
HistoricalVolatility (pass `trading_periods = 1` for the raw per-bar
sigma * 100 figure). Two parameters: `period` (default 20) for the
rolling window, `trading_periods` (default 252) for the annualisation
factor. First emit at index `period - 1`.

Touchpoints: parkinson.rs + mod.rs + lib.rs re-export,
PyParkinsonVolatility + __init__.py + test_new_indicators CANDLE_SCALAR
+ test_known_values zero-range reference, ParkinsonVolatilityNode +
index.d.ts/index.js + indicators.test.js factory + reference,
WasmParkinsonVolatility hand-rolled, candle-fuzz target,
bench_candle_input entry, README + CHANGELOG.

* feat(garman-klass): add Garman-Klass Volatility

Garman & Klass (1980) OHLC realised-volatility estimator. Extends
Parkinson's high-low estimator with an open-to-close term, lifting
statistical efficiency from ~5x to ~7.4x relative to close-to-close
stddev under driftless Geometric Brownian Motion.

Formula (per bar):
    s_t  = 0.5 * (ln(H_t / L_t))^2 - (2*ln(2) - 1) * (ln(C_t / O_t))^2
    out  = sqrt(max(mean(s_t over `period`), 0)) * sqrt(trading_periods) * 100

The per-bar sample can be marginally negative when the bar has a small
range relative to its open-to-close move; a max(., 0) clamp on the
rolling mean absorbs that and the FP cancellation noise before the
square root.

Still biased on data with meaningful overnight drift -- use Yang-Zhang
when gaps matter. Defaults: `period = 20`, `trading_periods = 252`
(annualised percent, same convention as HistoricalVolatility).

Touchpoints: garman_klass.rs + mod.rs + lib.rs re-export,
PyGarmanKlassVolatility + __init__.py + test_new_indicators
CANDLE_SCALAR + test_known_values zero-movement reference,
GarmanKlassVolatilityNode + index.d.ts/index.js + indicators.test.js
factory + reference, WasmGarmanKlassVolatility hand-rolled,
candle-fuzz target, bench_candle_input entry, README + CHANGELOG.

* feat(rogers-satchell): add Rogers-Satchell Volatility

Rogers, Satchell & Yoon (1994) OHLC realised-volatility estimator.
Unlike Garman-Klass, the per-bar sample is exact under arbitrary
Brownian drift -- the drift component cancels algebraically.

Formula (per bar):
    s_t  = ln(H_t / C_t) * ln(H_t / O_t) + ln(L_t / C_t) * ln(L_t / O_t)
    out  = sqrt(max(mean(s_t over `period`), 0)) * sqrt(trading_periods) * 100

Each per-bar sample is also non-negative by construction: with
`Candle::new` guaranteeing H >= max(O, L, C) and L <= min(O, H, C), the
four log factors have predictable signs (ln(H/.) >= 0, ln(L/.) <= 0),
so both products contribute >= 0. The max(., 0) clamp on the rolling
mean is only there to absorb FP cancellation.

Defaults: `period = 20`, `trading_periods = 252` (annualised percent,
same convention as HistoricalVolatility / Parkinson / Garman-Klass).

Touchpoints: rogers_satchell.rs + mod.rs + lib.rs re-export,
PyRogersSatchellVolatility + __init__.py + test_new_indicators
CANDLE_SCALAR + test_known_values zero-movement reference,
RogersSatchellVolatilityNode + index.d.ts/index.js + indicators.test.js
factory + reference, WasmRogersSatchellVolatility hand-rolled,
candle-fuzz target, bench_candle_input entry, README + CHANGELOG.

* feat(yang-zhang): add Yang-Zhang Volatility

Yang & Zhang (2000) drift- and gap-robust OHLC realised-volatility
estimator. Combines three independent components into a single estimate
with minimum variance:

    overnight    = sample_var(ln(O_t / C_{t-1}))   over n bars  (close-to-open)
    open_close   = sample_var(ln(C_t / O_t))       over n bars
    rs           = mean(ln(H/C)*ln(H/O) + ln(L/C)*ln(L/O)) over n bars
    sigma^2_YZ   = overnight + k*open_close + (1-k)*rs
    k            = 0.34 / (1.34 + (n+1)/(n-1))
    out          = sqrt(max(sigma^2_YZ, 0)) * sqrt(trading_periods) * 100

The overnight and open-to-close variances use Bessel's correction (the
sample estimator, divisor n-1), same convention as
HistoricalVolatility. The blending factor `k` is the one that
minimises estimator variance under driftless Geometric Brownian Motion
with overnight gaps.

This is the gold-standard OHLC estimator for assets with both
close-to-open gaps and intraday drift: equities, futures, and any
market that does not trade continuously. For pure intraday data (where
O_t == C_{t-1} and the open-to-close return is constant), the
overnight and open-close terms vanish and the estimator collapses to
(1-k) * Rogers-Satchell -- this is the indicator's
intraday_data_collapses_to_rs_only unit test.

Period >= 2 (Bessel correction needs >= 2 samples). First emit at
index `period` (the (period+1)-th bar): one bar seeds prev_close, the
next `period` fill the rolling windows. Defaults: `period = 20`,
`trading_periods = 252`.

Touchpoints: yang_zhang.rs + mod.rs + lib.rs re-export,
PyYangZhangVolatility + __init__.py + test_new_indicators
CANDLE_SCALAR + test_known_values zero-movement reference,
YangZhangVolatilityNode + index.d.ts/index.js + indicators.test.js
factory + reference, WasmYangZhangVolatility hand-rolled, candle-fuzz
target, bench_candle_input entry, README + CHANGELOG.

* fix(rvi): rename to RviVolatility to avoid clash with family-02 RVI

Family 02 (PR #40) ships a separate `Rvi` struct for Relative Vigor
Index. The two indicators have nothing to do with each other beyond
sharing the acronym, so disambiguate by giving the volatility one a
longer name everywhere:

- Rust crate: `Rvi`        -> `RviVolatility`
- Rust file:  `rvi.rs`     -> `rvi_volatility.rs`
- Python:     `RVI`        -> `RVIVolatility`
- Node:       `RVI`        -> `RVIVolatility`
- WASM:       `RVI`        -> `RVIVolatility`

Once the two PRs are both merged, callers get `wickra::Rvi` for Vigor
and `wickra::RviVolatility` for Volatility. The shorter `RVI` acronym
stays with the Momentum family per the existing wiki pages and the
implementation that shipped first.

Updates: rvi_volatility.rs (renamed), mod.rs, lib.rs re-export,
bindings/python/src/lib.rs + __init__.py + tests, bindings/node/src/lib.rs
+ index.d.ts + index.js + __tests__, bindings/wasm/src/lib.rs,
fuzz/fuzz_targets/indicator_update.rs, crates/wickra/benches/indicators.rs,
README family-table label, CHANGELOG entry.

* test(volatility): Rename test_rvi -> test_rvi_volatility + drop dead match arms

The Python test test_rvi_pure_uptrend_saturates_at_one_hundred was
calling ta.RVI() expecting the volatility version, but ta.RVI now
means Family 02's Relative Vigor Index (candle input). Renamed to
ta.RVIVolatility to match the binding rename done at merge time.

In all four OHLC volatility tests, the existing `match (r, a) { ...,
_ => panic!() }` arm is dead in passing runs (every aligned pair is
either (None, None) or (Some, Some)). Codecov flagged it as a patch
miss on each of parkinson / garman_klass / rogers_satchell /
yang_zhang. Refactored per CLAUDE.md cold-path guidance to
`assert_eq!(r.is_some(), a.is_some()); if let (Some, Some) ...`.
2026-05-25 18:18:20 +02:00
kingchenc d9d3ad18aa feat: Family 03 MACD & Price Oscillators — APO / AO-Hist / CFO / Zero-Lag MACD / Elder Impulse / STC (#41)
* feat(apo): add Absolute Price Oscillator

EMA(close, fast) - EMA(close, slow). Like MACD without the signal EMA.
Defaults to (fast = 12, slow = 26); fast must be strictly less than
slow.

Touchpoints: apo.rs + mod.rs + lib.rs re-export, PyApo + __init__.py
+ test_new_indicators SCALAR + test_known_values flat reference,
ApoNode + index.d.ts/index.js + indicators.test.js factory + reference,
WasmApo via scalar macro, scalar-fuzz target, README + CHANGELOG.

* fix(apo): add PyApo + ApoNode + WasmApo bindings missed from ec269d8

The previous APO commit (ec269d8) only registered APO in the Python
__init__.py / Node index.js / Node index.d.ts / fuzz / tests / docs.
The actual PyApo pyclass, ApoNode napi class, and WasmApo wasm class
edits silently no-op'd because the underlying lib.rs files had been
touched by a branch switch between Read and Edit. The bindings were
therefore advertising APO from the Python module / Node package /
WASM module but not actually exposing it.

Fix: insert PyApo block + add_class call in bindings/python/src/lib.rs,
ApoNode block in bindings/node/src/lib.rs, WasmApo macro line in
bindings/wasm/src/lib.rs. cargo test workspace stays at 615 (no new
tests added; the existing test_known_values + indicators.test.js
references would have failed at import once the bindings rebuilt
without these classes).

* feat(ao-histogram): add Awesome Oscillator Histogram

AO - SMA(AO, sma_period). A configurable variant of the existing
AcceleratorOscillator (which fixes fast=5, slow=34, sma=5).
Three parameters; defaults match Bill Williams' Accelerator.

Touchpoints: awesome_oscillator_histogram.rs + mod.rs + lib.rs
re-export, PyAoHist + __init__.py + test_new_indicators CANDLE_SCALAR
+ test_known_values flat reference, AwesomeOscillatorHistogramNode +
index.d.ts/index.js + indicators.test.js factory + reference,
WasmAoHist, candle-fuzz target, README + CHANGELOG.

* feat(cfo): add Chande Forecast Oscillator

100 * (close - LinReg(close, period)) / close. Positive when close
overshoots the linear forecast, negative when it undershoots. Holds
the previous value if the close is zero (percentage form undefined).
Single param period (default 14).

Touchpoints: cfo.rs + mod.rs + lib.rs re-export, PyCfo + __init__.py
+ test_new_indicators SCALAR + test_known_values linear reference,
CfoNode + index.d.ts/index.js + indicators.test.js factory + reference,
WasmCfo via scalar macro, scalar-fuzz target, README + CHANGELOG.

* fix(cfo): add WasmCfo binding missed from 733afd9

* feat(zero-lag-macd): add Zero-Lag MACD

Classic MACD topology with ZLEMA substituted for EMA everywhere:
faster reaction to trend changes at the cost of slightly noisier
readings. Multi-output ZeroLagMacdOutput { macd, signal, histogram }.
Three parameters (fast = 12, slow = 26, signal = 9); fast must be
strictly less than slow.

Touchpoints: zero_lag_macd.rs + mod.rs + lib.rs re-export, PyZeroLagMacd
+ __init__.py + test_new_indicators MULTI + test_known_values flat
reference, ZeroLagMacdNode + ZeroLagMacdValue + index.d.ts/index.js +
indicators.test.js multi factory + reference, WasmZeroLagMacd, scalar
fuzz with hand-rolled drive (multi-output bypasses the f64-only
helper), README + CHANGELOG.

* feat(elder-impulse): add Alexander Elder Impulse System

Tri-state momentum gauge: +1 (green/buy) when EMA trend and MACD
histogram both rise, -1 (red/sell) when both fall, 0 (blue/neutral)
on disagreement. Four parameters (ema_period, macd_fast, macd_slow,
macd_signal); defaults (13, 12, 26, 9) match Elder.

Internally feeds both branches on every input so they warm in parallel;
needs one bar past the slowest branch to seed direction state.

Touchpoints: elder_impulse.rs + mod.rs + lib.rs re-export, PyElderImpulse
+ __init__.py + test_new_indicators SCALAR + test_known_values neutral
reference, ElderImpulseNode + index.d.ts/index.js + indicators.test.js
factory + reference, WasmElderImpulse via scalar macro, scalar-fuzz
target, README + CHANGELOG.

* feat(stc): add Schaff Trend Cycle

Doug Schaff's doubly-Stochastic-smoothed MACD. Bounded [0, 100]
reading that reacts faster than MACD by extracting the percentile of
MACD within a recent window, half-EMA-smoothing it, and re-stochasing
the smoothed series. Four parameters (fast = 23, slow = 50,
schaff_period = 10, factor = 0.5); fast must be strictly less than
slow and factor must lie in (0, 1].

Output clamped to [0, 100] to absorb floating-point rounding. The
stochastic stages clamp to 0 when their rolling range collapses (flat
input or perfectly monotone trend), so a flat series settles
deterministically at 0 after warmup.

Touchpoints: stc.rs + mod.rs + lib.rs re-export, PyStc + __init__.py
+ test_new_indicators SCALAR + test_known_values flat reference,
StcNode + index.d.ts/index.js + indicators.test.js factory + reference,
WasmStc via scalar macro, scalar-fuzz target, README + CHANGELOG.

* fix(stc): rename last_stc -> last_value to satisfy clippy

* ci: Retry setup-node and setup-python on CDN flakes

Setup-node on Windows runners and setup-python across all OSes
occasionally fail with a silent hang or 5xx mid-download ("Attempting
to download 18..." → fail in <1s) — pure upstream CDN flake. The fix
ran on this branch's previous merge commit (24e723f) had to be
re-triggered manually via `gh run rerun --failed`.

Wrap both setup actions with continue-on-error and a follow-up retry
step that waits 30s and re-runs the same setup. The retry only fires
when the first attempt failed (steps.<id>.outcome == 'failure'), so a
green setup costs nothing extra. The retry uses the identical pinned
SHA so we still get supply-chain verification on both attempts.

Applied to ci.yml (Python matrix and Node matrix). release.yml has
the same setup-node / setup-python steps but is rarely re-run, so
the existing manual rerun pattern stays sufficient for now.

* test(zero-lag-macd): Fix MULTI dict shape mismatch + cover warmup_period

ZeroLagMACD was registered in the Python MULTI dict (which asserts a
(n, 2) batch shape) but actually emits (n, 3) — macd, signal,
histogram — like MACD. Moved out into its own standalone test
test_zero_lag_macd_streaming_matches_batch (3-tuple shape), and
included in the lifecycle sweep. Mirrors the existing Alligator
pattern for 3-output candle indicators.

Also adds a unit test for ZeroLagMacd::warmup_period that pins both
the (12, 26, 9) classic case and a small-period config — these four
lines were the codecov/patch miss on PR 41.
2026-05-25 17:26:46 +02:00
kingchenc 7f1a6df202 ci(sync-about): Push counter fix to PR branch instead of main (#56)
Previously the workflow patched README.md on main after every push,
producing an unsigned 'chore: sync indicator count' commit per merge.
Now the README counter is kept in sync on the PR side instead: on
every pull_request event, the workflow checks out the PR's head ref,
compares grep -c '^mod ' to the README counter, and if they differ,
pushes a fix-up commit back onto the PR branch using the default
GITHUB_TOKEN.

When the PR is squash-merged, that fix-up commit is folded into the
single web-flow-signed merge commit on main — so main's history never
shows a separate bot commit. About description and Wiki sync still
run on push to main / v* tags via the existing PAT, since both reach
outside the main repo (Administration:write and the .wiki repo).

For PRs from forks the workflow cannot push back; it emits a hard
::error:: pointing at README.md so the contributor can fix the
counter manually.

Pushes via GITHUB_TOKEN do not re-trigger downstream workflows
(GitHub's anti-recursion policy), so the fix-up commit costs zero
extra CI minutes — only sync-about itself re-runs on the next
synchronize event and no-ops once the counter matches.
2026-05-25 17:22:38 +02:00
wickra-bot 1ea05fb2a1 chore: sync indicator count to 85 [skip ci] 2026-05-25 13:29:06 +00:00
kingchenc 24e723fa7d feat: Family 02 Momentum Oscillators — RVI / PGO / KST / SMI / Laguerre / Connors / Inertia (#40)
* feat(rvi): add Relative Vigor Index

Dorsey's RVI = SMA(close - open, period) / SMA(high - low, period) over
a rolling window of period candles. Candle input, single parameter
period (default 10). Positive on average-bullish windows, negative on
average-bearish. Holds the previous value if the entire window has
zero range (denominator undefined).

Reference: Donald Dorsey, also pandas-ta rvi.

Touchpoints: rvi.rs + mod.rs + lib.rs re-export, PyRvi + __init__.py
+ test_new_indicators CANDLE_SCALAR + test_known_values reference,
RviNode (4-column OHLC batch) + index.d.ts/index.js + indicators.test
.js factory + reference, WasmRvi + make_candle_ohlc helper, candle-fuzz
target + criterion bench, README + CHANGELOG.

* feat(pgo): add Pretty Good Oscillator

Mark Johnson's PGO = (close - SMA(close, period)) / EMA(TR, period).
Counts roughly how many ATR-equivalents the close sits from its
period-bar mean. Candle input, single parameter period (default 14).
Johnson's heuristic uses +3/-3 crossings as entry signals.

Touchpoints: pgo.rs + mod.rs + lib.rs re-export, PyPgo + __init__.py
+ test_new_indicators CANDLE_SCALAR + test_known_values flat-close
reference, PgoNode (h/l/c) + index.d.ts/index.js + indicators.test.js
factory + reference, WasmPgo, candle-fuzz target + bench, README +
CHANGELOG.

* feat(kst): add Know Sure Thing (Pring)

Pring's long-horizon momentum oscillator: weighted sum of four
SMA-smoothed ROC series with fixed weights 1, 2, 3, 4, plus an SMA
signal line. Nine parameters (four ROC periods, four SMA periods, one
signal period); classic() applies Pring's recommended defaults.
Multi-output indicator emitting KstOutput { kst, signal }.

Touchpoints: kst.rs + mod.rs + lib.rs re-export, PyKst + __init__.py
+ test_new_indicators MULTI + test_known_values flat-input reference,
KstNode + KstValue + index.d.ts/index.js + indicators.test.js multi
factory + reference, WasmKst (manual JsValue object), scalar-fuzz
target (handled outside the f64-output drive helper), README +
CHANGELOG.

* feat(smi): add Stochastic Momentum Index (Blau)

Blau's doubly-EMA-smoothed bounded oscillator: measures the close's
displacement from the centre of the recent high-low range, scaled by
the smoothed range. Candle input, three parameters (period, d_period,
d2_period) with defaults 5 / 3 / 3.

Internally feeds both the displacement-EMA stack and the range-EMA
stack on every candle so they warm up in parallel (gating either
behind the other starves the second by one input).

Touchpoints: smi.rs + mod.rs + lib.rs re-export, PySmi + __init__.py
+ test_new_indicators CANDLE_SCALAR + test_known_values flat-input
reference, SmiNode + index.d.ts/index.js + indicators.test.js factory
+ reference, WasmSmi, candle-fuzz target, README + CHANGELOG.

* feat(laguerre-rsi): add Ehlers Laguerre RSI

Four-stage Laguerre polynomial filter wrapped in an RSI-style up/down
accumulator. Single gamma in [0, 1] (default 0.5) trades lag for
smoothness. State is seeded by setting all four L_i to the first input
so a constant series stays at the neutral 50. Output clamped to
[0, 100] to absorb floating-point rounding.

Reference: Ehlers, Time Warp - Without Space Travel, 2002.

Touchpoints: laguerre_rsi.rs + mod.rs + lib.rs re-export, PyLaguerreRsi
+ __init__.py + test_new_indicators SCALAR + test_known_values neutral
reference, LaguerreRsiNode + index.d.ts/index.js + indicators.test.js
factory + reference, WasmLaguerreRsi via scalar macro, scalar-fuzz
target, README + CHANGELOG.

* feat(connors-rsi): add Connors RSI (CRSI)

Larry Connors' 3-component aggregate: RSI(close), RSI(streak), and
PercentRank of the 1-period return over the last period_rank returns.
Each component is bounded in [0, 100] so the aggregate is too.
Three parameters (period_rsi, period_streak, period_rank) with
defaults 3 / 2 / 100. Streak tracks consecutive up/down runs (resets
to 0 on unchanged close).

Touchpoints: connors_rsi.rs + mod.rs + lib.rs re-export, PyConnorsRsi
+ __init__.py + test_new_indicators SCALAR + test_known_values bounded
reference, ConnorsRsiNode + index.d.ts/index.js + indicators.test.js
factory + reference, WasmConnorsRsi via scalar macro, scalar-fuzz
target, README + CHANGELOG.

* feat(inertia): add Dorsey Inertia (RVI + LinReg)

Donald Dorsey's Inertia — a LinearRegression smoothing of the RVI
series. Endpoint of an n-bar least-squares fit of RVI is the indicator
reading. Preserves trend direction while damping the ratio. Candle
input, two parameters (rvi_period, linreg_period) with defaults 14 / 20.

Touchpoints: inertia.rs + mod.rs + lib.rs re-export, PyInertia +
__init__.py + test_new_indicators CANDLE_SCALAR + test_known_values
constant reference, InertiaNode (4-column OHLC batch) + index.d.ts /
index.js + indicators.test.js factory + reference, WasmInertia,
candle-fuzz target, README + CHANGELOG.

* test(kst): Move KST out of MULTI dict (it is scalar-input)

KST sits in the MULTI dict (candle-input, multi-output) but its
update() takes a single f64, not a candle tuple. The shared streaming
loop in test_multi_streaming_matches_batch fed the OHLCV tuple in,
which crashed with `TypeError: argument 'value': must be real number,
not tuple` on every Python matrix entry.

Split into a new MULTI_SCALAR_INPUT dict with its own test function
that feeds the close-price stream as floats. KST is currently the
only such indicator; structure is ready for future scalar-input
multi-output additions (e.g. some MACD-shaped indicators).

* test(coverage): Cover SMI zero-range and ConnorsRsi zero-prev cold paths

codecov/patch on PR 40 flagged two uncovered defensive branches:
- SMI returns self.current early when the smoothed range collapses to
  zero (`r2 <= 0.0`) so the formula stays defined. Exercised by feeding
  bars where high == low.
- ConnorsRsi skips the ROC ring-buffer update when the previous price
  is exactly zero so the divide-by-zero in `(input - prev) / prev` is
  impossible. Exercised by seeding the first bar at 0.0.
2026-05-25 15:28:56 +02:00
wickra-bot a39adb9dae chore: sync indicator count to 78 [skip ci] 2026-05-25 13:08:09 +00:00
kingchenc 1cd5d1d8da fix(ci): Drop site/index.md from sync-about workflow (#55)
site/ is local-only (listed in .git/info/exclude), so the sed call in
the Patch step aborted the workflow on every push to main with
"sed: can't read site/index.md: No such file or directory". That kept
the README counter from being committed and skipped the wiki sync.

Patches README only now. site/ stays out of CI until the marketing
site is promoted.
2026-05-25 15:08:01 +02:00
kingchenc 466faddd87 feat: Family 01 Moving Averages — ALMA / McGinley / FRAMA / VIDYA / JMA / Alligator / EVWMA (#39)
* feat(alma): add Arnaud Legoux Moving Average

Gaussian-weighted moving average with configurable centre (offset in
[0, 1]) and kernel width (sigma > 0). Pre-computes normalised weights
at construction so each update is a single rolling window dot product.

Reference: Arnaud Legoux and Dimitrios Kouzis-Loukas, 2009.

Touchpoints:
- crates/wickra-core: alma.rs + mod.rs + lib.rs re-export
- bindings/python: PyAlma + __init__.py + test_new_indicators +
  test_known_values reference
- bindings/node: AlmaNode + index.d.ts/index.js + indicators.test.js
  factory + reference value
- bindings/wasm: wasm_scalar_indicator! macro
- fuzz: indicator_update target covers ALMA(9, 0.85, 6.0)
- crates/wickra/benches: bench_scalar entry
- README + CHANGELOG: Moving Averages row + Unreleased entry

* feat(mcginley): add McGinley Dynamic moving average

John McGinley's self-adjusting moving average with the recurrence
MD + (price - MD) / (0.6 * period * (price / MD)^4). Speeds up when
price falls below the indicator and damps when price runs above the
indicator. Seeded with the simple average of the first period inputs.

Reference: McGinley, Technical Analysis of Stocks & Commodities, 1990.

Touchpoints:
- crates/wickra-core: mcginley_dynamic.rs + mod.rs + lib.rs re-export
- bindings/python: PyMcGinleyDynamic + __init__.py + test_new_indicators
  + test_known_values reference
- bindings/node: McGinleyDynamicNode (scalar macro) + index.d.ts/index.js
  + indicators.test.js factory + reference value
- bindings/wasm: wasm_scalar_indicator! macro
- fuzz: indicator_update target covers McGinleyDynamic(10)
- crates/wickra/benches: bench_scalar entry
- README + CHANGELOG: Moving Averages row + Unreleased entry

* feat(frama): add Fractal Adaptive Moving Average

Ehlers' FRAMA adapts its smoothing constant to the fractal dimension of
the recent window: tight tracking in trends, heavy smoothing in chop.
Uses the close-only variant where max/min over each window half drive
the dimension estimate. Period must be even (default 16).

Reference: Ehlers, Fractal Adaptive Moving Average, 2005.

Touchpoints:
- crates/wickra-core: frama.rs + mod.rs + lib.rs re-export
- bindings/python: PyFrama + __init__.py + test_new_indicators +
  test_known_values reference (constant series + uptrend tracking)
- bindings/node: FramaNode (scalar macro) + index.d.ts/index.js +
  indicators.test.js factory + reference value
- bindings/wasm: wasm_scalar_indicator! macro
- fuzz: indicator_update target covers Frama(16)
- crates/wickra/benches: bench_scalar entry
- README + CHANGELOG: Moving Averages row + Unreleased entry

* feat(vidya): add Variable Index Dynamic Average

Chande's VIDYA — an EMA whose alpha scales with |CMO(cmo_period)| / 100.
Strong directional momentum lifts the smoothing constant toward the
EMA-of-period rate; flat or choppy windows shrink it toward zero so
VIDYA coasts on its previous value. Two parameters: period (14) and
cmo_period (9). Reuses the existing wickra-core Cmo internally.

Reference: Chande, Stocks & Commodities, 1992.

Also fixes a silent gap from d37fbd1 (feat(frama)): the PyFrama Python
class wrapper and its add_class registration were dropped because the
two edits hit "File has not been read yet" errors that scrolled past
in a batch. Adds them here alongside VIDYA's bindings.

Touchpoints (VIDYA): vidya.rs + mod.rs + lib.rs re-export, PyVidya +
__init__.py + test_new_indicators + test_known_values reference,
VidyaNode (manual two-param binding) + index.d.ts/index.js +
indicators.test.js factory + reference, wasm_scalar_indicator! macro,
fuzz target, bench, README + CHANGELOG.

* feat(jma): add Jurik Moving Average

Three-stage filter reconstruction of Mark Jurik's adaptive MA (the
algorithm is proprietary; this is the form used by most open-source
ports since the 1999 TASC article). Parameters: period (14), phase in
[-100, 100] (0), power in 1..=4 (2). State is seeded by setting
e0 = JMA = first input so a constant input stream is reproduced exactly.

Touchpoints: jma.rs + mod.rs + lib.rs re-export, PyJma + __init__.py +
test_new_indicators + test_known_values reference, JmaNode (manual
three-param binding) + index.d.ts/index.js + indicators.test.js factory
+ reference, wasm_scalar_indicator! macro, fuzz target, bench, README +
CHANGELOG.

* feat(alligator): add Bill Williams Alligator

Three SMMA lines (Jaw / Teeth / Lips) over the median price
(high + low) / 2 with default periods 13 / 8 / 5. Multi-output
indicator returning AlligatorOutput { jaw, teeth, lips }. The
original chart variant shifts each line forward for display; we
publish the unshifted SMMA values and leave the visual shift to
the consumer.

Reference: Bill Williams, Trading Chaos, 1995.

Touchpoints: alligator.rs + mod.rs + lib.rs re-export, PyAlligator
(Candle input, returns 3-tuple, ndarray (n, 3) batch) + __init__.py
+ test_new_indicators + test_known_values reference, AlligatorNode +
AlligatorValue + index.d.ts/index.js + indicators.test.js multi
factory + reference, WasmAlligator (manual JsValue object) +
candle-fuzz target + README + CHANGELOG.

* feat(evwma): add Elastic Volume-Weighted Moving Average

Christian P. Fries' elastic recurrence where the smoothing weight is the
bar's volume relative to the running window total:

  V_sum_t = sum of volumes over the last period candles
  EVWMA_t = ((V_sum_t - v_t) * EVWMA_{t-1} + v_t * close_t) / V_sum_t

A bar whose volume is small barely moves the average; a bar that
dominates the window pulls it strongly toward that bar's close. Seeded
with the close of the first full window; holds its previous value if
the entire window has zero volume.

Reference: Fries, Wilmott Magazine, 2001.

Touchpoints: evwma.rs + mod.rs + lib.rs re-export, PyEvwma (close +
volume batch) + __init__.py + test_new_indicators CANDLE_SCALAR +
test_known_values reference, EvwmaNode + index.d.ts/index.js +
indicators.test.js candleScalar factory + reference, WasmEvwma,
candle-fuzz target + README + CHANGELOG.

* ci: Force local wheel install in Python jobs

Use --no-index --no-deps so the Python matrix installs the freshly
built wheel from dist/ and never falls back to PyPI. Previously pip
sometimes picked the released 0.2.x wheel on macOS / Windows when its
platform tag was a wider match than the local build, which made the
job test the released package and miss any new symbols added in the
PR (e.g. AttributeError: module 'wickra' has no attribute 'ALMA').
numpy is already installed by the preceding pip step, so --no-deps
is safe.
2026-05-25 15:01:14 +02:00
kingchenc 178fbfd68e ci: Add sync-about workflow to auto-update indicator count (#38)
Counts `mod xxx;` declarations in crates/wickra-core/src/indicators/mod.rs
on every push to main, every PR, and every v* tag push. On non-PR runs
it syncs the count into:

- GitHub repo About description (via `gh repo edit`)
- README.md + site/index.md (commit with [skip ci] back to main)
- Wiki: Home.md, FAQ.md, Streaming-vs-Batch.md

Requires a `ABOUT_SYNC_TOKEN` secret (classic PAT with `repo` scope, or
fine-grained PAT with Administration+Contents write on the wickra repo).
PR runs are read-only: count is logged but nothing is mutated, so forks
cannot trigger writes.
2026-05-25 14:52:58 +02:00
kingchenc e30b3c6b35 release: 0.2.7 (Windows ARM64 restored + CPU label fix) (#37)
* chore(docs): rename benchmark CPU from 7950X3D to 9950X

The "Reproduced on" line in the umbrella + binding READMEs and the
benchmark page on the site listed the wrong AMD CPU. The benchmarks
were actually produced on a Ryzen 9 9950X, not a 7950X3D. Same
column for absolute µs values applies — the speedup ratios in the
tables are unchanged either way because they're relative across
libraries on the same machine.

The performance-regression issue template's CPU example also
updated for consistency (it was a generic placeholder, but matching
the canonical machine makes the example concrete).

* chore(npm): restore Windows ARM64 sub-package + napi matrix entry

npm Support unblocked the `wickra-win32-arm64-msvc` package name and
transferred write access to @kingchenc (placeholder 0.0.1-security
was published from their side; we ship our first real version on
top of that). This re-enables every change 8aa74cb temporarily
backed out for 0.2.1:

- bindings/node/package.json: re-add `aarch64-pc-windows-msvc` to
  napi.triples.additional and `wickra-win32-arm64-msvc` to
  optionalDependencies.
- bindings/node/npm/win32-arm64-msvc/package.json: restored — name,
  cpu = arm64, os = win32, version pinned to the workspace.
- .github/workflows/release.yml: re-enable the
  `windows-11-arm / aarch64-pc-windows-msvc` row in the node-build
  matrix and drop the "temporarily skipped" comment block.

After the next tag-push this binding will be published alongside
the other five platforms and `npm install wickra` on Windows ARM64
will resolve to a native build instead of failing the loader's
optional-dep lookup.

* release: bump workspace + bindings to 0.2.7

Workspace, every binding (Python, Node, six platform stubs incl. the
restored win32-arm64-msvc), and the CHANGELOG all move together to
0.2.7. wickra-win32-arm64-msvc is now part of the standard publish
matrix and will land on npm alongside the other five binaries.

The 0.2.7 CHANGELOG entry consolidates the two changes this cycle:
- Windows ARM64 binding restored (npm Support unblocked the name).
- Benchmark CPU label corrected (Ryzen 9 9950X, not 7950X3D).
2026-05-24 11:46:49 +02:00
kingchenc 070be2eb27 release: 0.2.6 (docs.rs fix + README table reordering) (#36)
* fix(docs-rs): rename `doc_auto_cfg` to `doc_cfg` after Rust 1.92 merge

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

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

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

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

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

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

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

* release: bump workspace + bindings to 0.2.6

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

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

wickra-win32-arm64-msvc stays excluded for this release with the same
npm spam-filter rationale that held for 0.2.5.
2026-05-24 03:20:13 +02:00
212 changed files with 59394 additions and 315 deletions
+34
View File
@@ -0,0 +1,34 @@
# EditorConfig: https://editorconfig.org
# Keeps indentation and line-endings consistent across IDEs.
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space
# Rust + Python + most config files use 4-space indents.
[*.{rs,py,toml}]
indent_size = 4
# JS / TS / JSON / YAML / Markdown use 2-space indents per ecosystem conventions.
[*.{js,ts,jsx,tsx,json,yml,yaml,md}]
indent_size = 2
# Markdown allows trailing whitespace as a hard line break — keep it intact.
[*.md]
trim_trailing_whitespace = false
# Makefiles must use tabs.
[Makefile]
indent_style = tab
# Generated files are not authored by humans; leave them alone.
[bindings/node/index.{js,d.ts}]
indent_style = unset
indent_size = unset
trim_trailing_whitespace = unset
insert_final_newline = unset
+1 -1
View File
@@ -3,4 +3,4 @@
# The owner listed here is requested for review automatically on every pull
# request. See https://docs.github.com/articles/about-code-owners.
* @kingchenc
* @wickra-lib
+6
View File
@@ -0,0 +1,6 @@
# Funding sources surfaced on the repository "Sponsor" button.
# Each platform's value is the username/handle on that platform.
# Leave a key empty (e.g. patreon:) to skip a platform.
github: [kingchenc]
custom: ["https://wickra.org/sponsor"]
+2 -2
View File
@@ -1,8 +1,8 @@
blank_issues_enabled: false
contact_links:
- name: Security vulnerability
url: https://github.com/kingchenc/wickra/security/advisories/new
url: https://github.com/wickra-lib/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
url: https://github.com/wickra-lib/wickra/discussions
about: Ask usage questions and discuss ideas here.
@@ -44,7 +44,7 @@ ema/update time: [38.5 ns 38.7 ns 38.9 ns]
| Field | Value |
| ------------ | -------------------------------------- |
| CPU | `e.g. Ryzen 9 7950X, AVX2 + AVX512` |
| CPU | `e.g. Ryzen 9 9950X, AVX2 + AVX512` |
| OS / arch | `e.g. Linux 6.8 x86_64` |
| Toolchain | `rustc 1.x.y` |
| Build flags | `RUSTFLAGS=...`, `--release`, profile |
+2 -2
View File
@@ -24,9 +24,9 @@
- [ ] New behaviour has tests; bug fixes have a regression test.
- [ ] Public API changes are mirrored in the Python / Node / WASM bindings
and their type stubs (If applicable).
- [ ] The relevant page on the [project Wiki](https://github.com/kingchenc/wickra/wiki)
- [ ] The relevant page on the [project Wiki](https://github.com/wickra-lib/wickra/wiki)
and the `README.md` are updated (If applicable). Wiki edits go to a
separate repository: `https://github.com/kingchenc/wickra.wiki.git`.
separate repository: `https://github.com/wickra-lib/wickra.wiki.git`.
- [ ] An entry was added under `## [Unreleased]` in `CHANGELOG.md`.
## Notes for reviewers
+113
View File
@@ -0,0 +1,113 @@
"""Audit that no file in the repo contains the pre-migration org slug or
maintainer email. Driven by `repo-metadata.toml` at the repo root.
This is the read-only side of the metadata pipeline. It does not patch any
files — it just fails CI when drift sneaks in. Pair with a future
`--write` mode (auto-fix + signed commit on main) once the migration has
settled.
"""
from __future__ import annotations
import argparse
import os
import subprocess
import sys
import tomllib
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[2]
METADATA_PATH = REPO_ROOT / "repo-metadata.toml"
def load_metadata() -> dict:
with METADATA_PATH.open("rb") as f:
return tomllib.load(f)
def is_allowlisted(rel_path: str, allowlist: list[str]) -> bool:
norm = rel_path.replace(os.sep, "/")
for entry in allowlist:
entry_norm = entry.replace(os.sep, "/")
if entry_norm.endswith("/"):
if norm.startswith(entry_norm):
return True
else:
if norm == entry_norm:
return True
return False
def tracked_files() -> list[str]:
"""List git-tracked files relative to the repo root."""
out = subprocess.run(
["git", "ls-files"],
cwd=REPO_ROOT,
check=True,
capture_output=True,
text=True,
)
return [line for line in out.stdout.splitlines() if line]
def scan(forbidden: list[str], allowlist: list[str]) -> list[tuple[str, int, str, str]]:
"""Return a list of (rel_path, line_no, needle, line_text) findings.
Only git-tracked files are scanned, so local-only ghost-ignored files
(`.claude/`, drafts) never trigger false positives.
"""
findings: list[tuple[str, int, str, str]] = []
for rel_path in tracked_files():
if is_allowlisted(rel_path, allowlist):
continue
abs_path = REPO_ROOT / rel_path
if not abs_path.is_file():
continue
try:
lines = abs_path.read_text(encoding="utf-8", errors="replace").splitlines()
except (OSError, UnicodeDecodeError):
continue
for lineno, line in enumerate(lines, start=1):
for needle in forbidden:
if needle in line:
findings.append((rel_path, lineno, needle, line.strip()))
return findings
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--check", action="store_true", help="audit-only (default)")
args = parser.parse_args()
_ = args # currently only --check is supported
meta = load_metadata()
audit = meta.get("audit", {})
forbidden: list[str] = list(audit.get("forbidden", []))
allowlist: list[str] = list(audit.get("allowlist", []))
if not forbidden:
print("repo-metadata.toml [audit].forbidden is empty — nothing to scan.")
return 0
findings = scan(forbidden, allowlist)
if findings:
print(f"sync-metadata: {len(findings)} forbidden-substring hits:", file=sys.stderr)
for rel_path, lineno, needle, text in findings:
print(f" {rel_path}:{lineno}: matched {needle!r}", file=sys.stderr)
print(f" {text}", file=sys.stderr)
print(
"\nUpdate the offending lines to use the values from repo-metadata.toml,",
"or add the path to [audit].allowlist if the reference is intentional",
"(e.g. historical CHANGELOG entries).",
file=sys.stderr,
)
return 1
org = meta["repo"]["org"]
email = meta["maintainer"]["email"]
print(f"sync-metadata: clean. org={org!r} email={email!r}")
return 0
if __name__ == "__main__":
sys.exit(main())
+80 -5
View File
@@ -55,6 +55,37 @@ jobs:
# streaming.
run: cargo build -p wickra-examples --bins
# Clippy for the Python and Node bindings. These are kept out of the main
# `rust` job because PyO3 / napi build scripts need a Python interpreter and
# a Node toolchain on PATH, which the 3-OS matrix job does not provision.
# Ubuntu-only is sufficient: the lints are platform-independent.
clippy-bindings:
name: Clippy bindings
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable branch, 2026-03-27
with:
components: clippy
- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: "3.12"
- name: Set up Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "20"
- name: Cache cargo
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
- name: Clippy (bindings, all targets)
run: cargo clippy -p wickra-node -p wickra-python --all-targets -- -D warnings
# Verify the crates still build and test on their declared minimum supported
# Rust version. The workspace pins rust-version = "1.86" — that floor is
# set by criterion 0.8.2 (the bench dev-dep), which itself rolled past the
@@ -110,7 +141,7 @@ jobs:
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
- name: Install cargo-llvm-cov
uses: taiki-e/install-action@6c1f7cf125e42770ff087ea443901b487cc5471a # v2.79.5
uses: taiki-e/install-action@0fd46367812ee04360509b4169d9f659d6892bb2 # v2.79.15
with:
tool: cargo-llvm-cov
@@ -138,7 +169,7 @@ jobs:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: cargo-deny
uses: EmbarkStudios/cargo-deny-action@a531616d8ce3b9177443e48a1159bc945a099823 # v2.0.19
uses: EmbarkStudios/cargo-deny-action@bb137d7af7e4fb67e5f82a49c4fce4fad40782fe # v2.0.20
with:
command: check
@@ -171,7 +202,7 @@ jobs:
# attributes the modern nightly compiler rejects, so the install
# never gets off the ground. The prebuilt binary avoids the entire
# transitive-dep compile.
uses: taiki-e/install-action@6c1f7cf125e42770ff087ea443901b487cc5471a # v2.79.5
uses: taiki-e/install-action@0fd46367812ee04360509b4169d9f659d6892bb2 # v2.79.15
with:
tool: cargo-fuzz
@@ -212,7 +243,27 @@ jobs:
- name: Cache cargo
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
# setup-python downloads the interpreter from the Actions tool cache /
# nodejs CDN and occasionally hangs or 5xx's on the Windows runners.
# Run it with continue-on-error, then retry once after a backoff so a
# single CDN flake does not fail the whole job (see also: GitHub
# Actions runner-images#7061).
- name: Set up Python
id: setup_python
continue-on-error: true
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: ${{ matrix.python-version }}
- name: Wait before Python retry
if: steps.setup_python.outcome == 'failure'
shell: bash
run: |
echo "::warning::setup-python failed (likely CDN flake), waiting 30s before retry..."
sleep 30
- name: Set up Python (retry)
if: steps.setup_python.outcome == 'failure'
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: ${{ matrix.python-version }}
@@ -229,7 +280,12 @@ jobs:
- name: Install wheel
shell: bash
working-directory: bindings/python
run: python -m pip install --find-links dist --force-reinstall wickra
# --no-index forces pip to ignore PyPI; --no-deps skips re-resolving
# numpy (already installed in the previous step). Without --no-index
# pip prefers the PyPI 0.2.x wheel over our freshly built one when
# platform tags overlap (e.g. macOS arm64), so tests would run
# against the released package and miss any new symbols the PR adds.
run: python -m pip install --no-index --find-links dist --force-reinstall --no-deps wickra
- name: Run Python tests
working-directory: bindings/python
@@ -257,7 +313,7 @@ jobs:
# same taiki-e prebuilt-binary installer we already use for
# cargo-llvm-cov and cargo-fuzz; it tracks the latest wasm-pack
# release, which has `--features` as a top-level flag (since 0.12).
uses: taiki-e/install-action@6c1f7cf125e42770ff087ea443901b487cc5471a # v2.79.5
uses: taiki-e/install-action@0fd46367812ee04360509b4169d9f659d6892bb2 # v2.79.15
with:
tool: wasm-pack
@@ -290,7 +346,26 @@ jobs:
- name: Cache cargo
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
# setup-node downloads Node from nodejs.org and we've seen it fail on
# Windows runners with "Attempting to download 18..." followed by a
# silent hang or curl error. Retry once after a backoff so a single
# CDN flake does not fail the whole job.
- name: Set up Node
id: setup_node
continue-on-error: true
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: ${{ matrix.node-version }}
- name: Wait before Node retry
if: steps.setup_node.outcome == 'failure'
shell: bash
run: |
echo "::warning::setup-node failed (likely CDN flake), waiting 30s before retry..."
sleep 30
- name: Set up Node (retry)
if: steps.setup_node.outcome == 'failure'
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: ${{ matrix.node-version }}
+57 -19
View File
@@ -79,6 +79,34 @@ jobs:
name: crate-files
path: target/package/*.crate
# CycloneDX SBOM per published crate. Attached to the GitHub Release
# alongside the .crate / .whl / .tgz artefacts so downstream
# consumers can audit the published dependency tree without
# re-resolving Cargo.lock.
- name: Install cargo-cyclonedx
uses: taiki-e/install-action@0fd46367812ee04360509b4169d9f659d6892bb2 # v2.79.15
with:
tool: cargo-cyclonedx
- name: Generate CycloneDX SBOMs
run: |
# cargo-cyclonedx walks the whole workspace in a single pass and
# writes a <package>.cdx.json next to each member's Cargo.toml; it
# has no -p/--package selector. Collect the three crates.io crates
# (the .crate files published by this job) into the upload dir.
cargo cyclonedx --format json --top-level
mkdir -p sboms
cp crates/wickra-core/wickra-core.cdx.json sboms/
cp crates/wickra-data/wickra-data.cdx.json sboms/
cp crates/wickra/wickra.cdx.json sboms/
ls -lh sboms/
- name: Upload SBOMs
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: sboms
path: sboms/*.cdx.json
# --------------------------------------------------------------------------
# PyPI: cross-platform wheels + sdist
# --------------------------------------------------------------------------
@@ -173,15 +201,7 @@ jobs:
- { host: macos-latest, target: x86_64-apple-darwin }
- { host: macos-latest, target: aarch64-apple-darwin }
- { host: windows-latest, target: x86_64-pc-windows-msvc }
# NOTE: aarch64-pc-windows-msvc is temporarily skipped for 0.2.5.
# The wickra-win32-arm64-msvc npm subpackage name is blocked by the
# npm spam-detection filter for new accounts (same situation that
# affected wickra-win32-x64-msvc through 0.1.4 until npm Support
# unblocked it). A support ticket is open; once the new arm64 name
# is unblocked this matrix entry will be restored alongside the
# corresponding optionalDependencies / napi.triples / npm/<target>
# entries in a follow-up release.
# - { host: windows-11-arm, target: aarch64-pc-windows-msvc }
- { host: windows-11-arm, target: aarch64-pc-windows-msvc }
runs-on: ${{ matrix.host }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -216,6 +236,14 @@ jobs:
needs: node-build
runs-on: ubuntu-latest
environment: release
# `id-token: write` lets npm publish embed a Sigstore provenance
# attestation generated from the GitHub Actions OIDC token. The npm
# registry then shows a "Verified provenance" badge and lets
# consumers verify the package was built from this exact workflow
# run.
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -274,13 +302,13 @@ jobs:
# 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)
(cd "$dir" && npm publish --access public --ignore-scripts --provenance)
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 --ignore-scripts)
(cd "$dir" && npm publish --access public --ignore-scripts --provenance)
rc=$?
fi
if [ "$rc" -ne 0 ]; then
@@ -321,12 +349,12 @@ jobs:
# --ignore-scripts so any leftover prepublish hooks (which would
# otherwise try to republish the already-published platform
# subpackages) can't sabotage the main publish.
npm publish --access public --ignore-scripts
npm publish --access public --ignore-scripts --provenance
rc=$?
if [ "$rc" -ne 0 ]; then
echo "::warning::first attempt failed (rc=$rc); retrying after 30s"
sleep 30
npm publish --access public --ignore-scripts
npm publish --access public --ignore-scripts --provenance
rc=$?
fi
exit $rc
@@ -354,10 +382,18 @@ jobs:
# --------------------------------------------------------------------------
# WASM: wasm-pack build + npm publish (as `wickra-wasm`)
# --------------------------------------------------------------------------
# Note: this job's npm publish call uses `--provenance` (see below),
# which requires the `id-token: write` permission set at the job level.
wasm-publish:
name: Publish wickra-wasm to npm
runs-on: ubuntu-latest
environment: release
# `id-token: write` lets npm publish embed a Sigstore provenance
# attestation generated from the GitHub Actions OIDC token (same
# mechanism as the node-publish job above).
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -373,7 +409,7 @@ jobs:
- name: Install wasm-pack (latest, via prebuilt binary)
# See the matching note in ci.yml: jetli's default installs an old
# 0.10.x wasm-pack whose build subcommand rejects --features.
uses: taiki-e/install-action@6c1f7cf125e42770ff087ea443901b487cc5471a # v2.79.5
uses: taiki-e/install-action@0fd46367812ee04360509b4169d9f659d6892bb2 # v2.79.15
with:
tool: wasm-pack
@@ -392,10 +428,10 @@ jobs:
node -e "
const fs = require('fs');
const pkg = JSON.parse(fs.readFileSync('package.json'));
pkg.author = 'kingchenc <kingchencp@gmail.com>';
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.author = 'kingchenc <wickra.lib@gmail.com>';
pkg.repository = { type: 'git', url: 'https://github.com/wickra-lib/wickra' };
pkg.homepage = 'https://github.com/wickra-lib/wickra';
pkg.bugs = { url: 'https://github.com/wickra-lib/wickra/issues' };
pkg.license = 'PolyForm-Noncommercial-1.0.0';
fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2));
"
@@ -413,7 +449,7 @@ jobs:
- name: Publish wickra-wasm to npm (idempotent)
working-directory: bindings/wasm/pkg
run: |
out=$(npm publish --access public 2>&1) && echo "$out" \
out=$(npm publish --access public --provenance 2>&1) && echo "$out" \
|| (echo "$out" | grep -q "You cannot publish over" && echo "skip: version already on npm" \
|| (echo "$out"; exit 1))
env:
@@ -467,6 +503,8 @@ jobs:
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/ \;
# CycloneDX SBOMs (one per published crate).
find artifacts -type f -name "*.cdx.json" -exec cp {} release-assets/ \;
ls -lh release-assets/
echo "asset-count=$(ls release-assets/ | wc -l)"
+185
View File
@@ -0,0 +1,185 @@
name: Sync indicator count
# Indicator count appears in four places that must stay in sync with
# the number of public indicator types exported from
# crates/wickra-core/src/lib.rs (the `pub use indicators::{ ... }` block,
# minus the `FAMILIES` constant and any `*Output` companion structs):
#
# 1. README.md prose — synced on PR branches (this workflow)
# 2. GitHub repo "About" description — synced on push to main / v* tag
# 3. Wiki: Home.md / FAQ.md / Streaming-vs-Batch.md
# — synced on push to main / v* tag
# 4. site/index.md (local-only marketing site, not synced from CI)
#
# We count public types (not `mod xxx;` lines) because some modules export
# more than one indicator — e.g. `vwap.rs` exposes both `Vwap` and
# `RollingVwap`, so the mod-count under-reports by one. lib.rs is the
# single source of truth for what the bindings reach.
#
# Design: keep README in sync *before* a PR is merged, by pushing a
# fix-up commit to the PR head branch. After squash-merge into main
# the bot commit is folded into the single signed merge commit, so
# main's history never shows an unsigned "sync indicator count" entry.
#
# The push to PR head uses the default `GITHUB_TOKEN`, whose pushes
# explicitly do NOT trigger downstream workflows (anti-recursion
# policy). So a counter fix-up does not re-trigger ci.yml on the PR
# — it does, however, re-trigger sync-about.yml on the next PR
# `synchronize` event, which is what we want (a no-op if the counter
# is now correct).
on:
push:
branches: [main]
tags: ['v*']
pull_request:
types: [opened, synchronize, reopened]
workflow_dispatch:
# `contents: write` is needed so the workflow can push the counter
# fix-up commit to the PR head branch via the auto-provided
# GITHUB_TOKEN. The wider About / Wiki writes still go through the
# fine-grained PAT (ABOUT_SYNC_TOKEN) because they need
# `Administration: write` (gh repo edit) which GITHUB_TOKEN lacks.
permissions:
contents: write
pull-requests: read
jobs:
sync:
runs-on: ubuntu-latest
steps:
# On PRs from forks the head ref lives in another repo; pushing
# back to it from this workflow is blocked by GitHub. We still
# want the PR to surface the missing counter, so the check below
# falls back to a hard failure when push isn't possible.
- name: Determine if push to PR head is possible
id: ctx
run: |
if [ "${{ github.event_name }}" = "pull_request" ]; then
if [ "${{ github.event.pull_request.head.repo.full_name }}" = "${{ github.repository }}" ]; then
echo "can_push=true" >> "$GITHUB_OUTPUT"
echo "head_ref=${{ github.event.pull_request.head.ref }}" >> "$GITHUB_OUTPUT"
else
echo "can_push=false" >> "$GITHUB_OUTPUT"
echo "head_ref=" >> "$GITHUB_OUTPUT"
fi
else
echo "can_push=false" >> "$GITHUB_OUTPUT"
echo "head_ref=" >> "$GITHUB_OUTPUT"
fi
# On PRs we check out the *head* commit (not the merge ref) so
# any fix-up commit we make goes onto the PR branch itself. On
# push events we check out the default ref. fetch-depth: 0 lets
# us push back without "shallow update not allowed".
- uses: actions/checkout@v6
with:
fetch-depth: 0
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.ref || github.ref }}
repository: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name || github.repository }}
# Default GITHUB_TOKEN is fine for the same-repo PR-branch
# push; the About / Wiki steps re-authenticate with the PAT
# below where needed.
- name: Count indicators
id: count
run: |
# Parse the `pub use indicators::{ ... }` block from lib.rs, strip
# the FAMILIES constant and any `*Output` companion structs, count
# the remaining identifiers. Pure-shell so the workflow doesn't
# require a python runtime.
n=$(sed -n '/^pub use indicators::{/,/^};/p' crates/wickra-core/src/lib.rs \
| tr ',{}' '\n' \
| sed 's/[[:space:]]//g' \
| grep -E '^[A-Z][A-Za-z0-9_]*$' \
| grep -vE '^FAMILIES$|Output$' \
| sort -u | wc -l)
echo "count=$n" >> "$GITHUB_OUTPUT"
echo "Indicator count: $n"
# ----- PR flow ---------------------------------------------------
- name: Check README counter (PR)
if: github.event_name == 'pull_request'
id: pr_check
run: |
n="${{ steps.count.outputs.count }}"
if grep -qE "^${n} streaming-first indicators" README.md; then
echo "matches=true" >> "$GITHUB_OUTPUT"
echo "README counter already at ${n}; nothing to do."
else
echo "matches=false" >> "$GITHUB_OUTPUT"
echo "README counter does not match ${n}; will fix up."
fi
- name: Fix counter on fork PR head (read-only, fail loud)
if: github.event_name == 'pull_request' && steps.pr_check.outputs.matches == 'false' && steps.ctx.outputs.can_push == 'false'
run: |
n="${{ steps.count.outputs.count }}"
echo "::error::README.md says a different indicator count than mod.rs (${n}). This PR is from a fork, so the workflow cannot push the fix; please update README.md to '${n} streaming-first indicators' and push again."
exit 1
- name: Patch README on PR head
if: github.event_name == 'pull_request' && steps.pr_check.outputs.matches == 'false' && steps.ctx.outputs.can_push == 'true'
id: pr_patch
run: |
n="${{ steps.count.outputs.count }}"
sed -i -E "s/[0-9]+ (streaming-first )?indicators/${n} \1indicators/g" README.md
if git diff --quiet; then
echo "No README changes after sed (counter regex did not match anything); skipping push."
echo "changed=false" >> "$GITHUB_OUTPUT"
else
echo "changed=true" >> "$GITHUB_OUTPUT"
fi
- name: Commit & push counter fix to PR head
if: github.event_name == 'pull_request' && steps.pr_patch.outputs.changed == 'true'
run: |
git config user.name "wickra-bot"
git config user.email "wickra-bot@users.noreply.github.com"
git add README.md
git commit -m "chore: sync indicator count to ${{ steps.count.outputs.count }}"
git push origin "HEAD:${{ steps.ctx.outputs.head_ref }}"
# ----- main / tag flow ------------------------------------------
#
# After a PR squash-merges, this workflow runs again on the push
# to main. README is already correct (it was fixed on the PR
# branch before the merge); the only outward syncs left are the
# GitHub About description (repo metadata, not a commit) and the
# wiki repo (separate repo, no main history pollution). README is
# not touched on main any more.
- name: Update GitHub About description
if: github.event_name != 'pull_request'
env:
GH_TOKEN: ${{ secrets.ABOUT_SYNC_TOKEN }}
run: |
n="${{ steps.count.outputs.count }}"
desc="Streaming-first technical indicators with a Rust core and Python, Node.js, and WebAssembly bindings. ${n} indicators, O(1) per-tick updates, no system dependencies. Drop-in TA-Lib replacement."
current=$(gh repo view --json description -q .description)
if [ "$current" = "$desc" ]; then
echo "About unchanged."
else
gh repo edit --description "$desc"
echo "About updated."
fi
- name: Sync Wiki
if: github.event_name != 'pull_request'
env:
GH_TOKEN: ${{ secrets.ABOUT_SYNC_TOKEN }}
run: |
n="${{ steps.count.outputs.count }}"
git clone "https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.wiki.git" wiki
cd wiki
sed -i -E "s/[0-9]+ (streaming-first )?indicators/${n} \1indicators/g" Home.md FAQ.md Streaming-vs-Batch.md
if git diff --quiet; then
echo "Wiki unchanged."
exit 0
fi
git config user.name "wickra-bot"
git config user.email "wickra-bot@users.noreply.github.com"
git add Home.md FAQ.md Streaming-vs-Batch.md
git commit -m "chore: sync indicator count to ${n}"
git push
+22
View File
@@ -0,0 +1,22 @@
name: sync-metadata
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
permissions:
contents: read
jobs:
audit:
name: metadata audit
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Audit repo-metadata.toml drift
run: python .github/scripts/sync-metadata.py --check
+2 -1
View File
@@ -46,7 +46,8 @@ tarpaulin-report.html
bindings/node/*.node
bindings/node/index.d.ts
bindings/node/npm-debug.log*
package-lock.json
# package-lock.json is committed (under bindings/node/) so contributors
# get reproducible npm installs. Top-level lockfiles still aren't expected.
# WASM build output
bindings/wasm/pkg/
+321
View File
@@ -0,0 +1,321 @@
# Architecture
A walkthrough of how Wickra is organised internally — written for new
contributors who want to know **where the code lives, why it's split that
way, and which invariants they must not break**. Pair it with [`CONTRIBUTING.md`](CONTRIBUTING.md)
for the day-to-day workflow.
## Workspace layout
Wickra is a Cargo workspace of three Rust crates plus three binding crates.
The split is deliberate: every concern that one user might want to disable
or replace lives behind a separate crate boundary.
```
┌────────────────────────────────────────────────────────────────────┐
│ wickra (facade) │
│ re-exports wickra-core::* + wickra-data::* │
└──────────────┬──────────────────────────────────┬──────────────────┘
│ │
┌───────────▼──────────┐ ┌──────────▼─────────┐
│ wickra-core │ │ wickra-data │
│ indicator engine │ │ i/o + aggregation │
│ • 214 indicators │ │ • CSV reader │
│ • Indicator trait │ │ • Tick aggregator │
│ • BatchExt impl │ │ • Resampler │
│ • OHLCV / Candle │ │ • Live feeds │
│ no I/O, no deps │ │ optional features │
└──────────────────────┘ └────────────────────┘
│ (every binding wraps the same core)
┌────────────┴───────────┬─────────────────────┐
│ │ │
┌──▼──────┐ ┌───────▼──────┐ ┌───────▼────────┐
│ Python │ │ Node │ │ WASM │
│ (PyO3) │ │ (napi-rs) │ │ (wasm-bindgen) │
└─────────┘ └──────────────┘ └────────────────┘
```
| Crate | Path | What it owns | Public deps |
|---|---|---|---|
| `wickra-core` | `crates/wickra-core` | every indicator, the `Indicator` trait, `BatchExt`, `Candle`/`Tick` types, `Error` | `thiserror`, `rayon` (parallel batch) |
| `wickra` | `crates/wickra` | thin facade — re-exports everything user-facing from `wickra-core` and `wickra-data` | both internal crates |
| `wickra-data` | `crates/wickra-data` | CSV reader, tick aggregator, resampler, live exchange feeds (feature-gated) | `tokio`, `tokio-tungstenite` (live), `serde_json` |
| `wickra-python` | `bindings/python` | `_wickra` PyO3 module + Python package | `pyo3`, `numpy`, depends on `wickra-core` |
| `wickra-node` | `bindings/node` | NAPI-RS native binding | `napi`, depends on `wickra-core` |
| `wickra-wasm` | `bindings/wasm` | WebAssembly binding | `wasm-bindgen`, depends on `wickra-core` |
| `wickra-examples` | `examples/rust` | runnable binary examples | depends on `wickra`, `wickra-data` |
The `fuzz/` directory is **excluded** from the workspace (it has its own
`Cargo.toml`) because the libfuzzer-sys harness requires a nightly
toolchain, which would otherwise infect the stable workspace lints.
## The `Indicator` trait
Every indicator in Wickra implements one trait, defined in
`crates/wickra-core/src/traits.rs`:
```rust
pub trait Indicator {
type Input;
type Output;
fn update(&mut self, input: Self::Input) -> Option<Self::Output>;
fn reset(&mut self);
fn warmup_period(&self) -> usize;
fn is_ready(&self) -> bool;
fn name(&self) -> &'static str;
}
```
Four design choices that are non-negotiable:
1. **Streaming-first.** `update` is the only computation entry point. Each
call must be O(1) amortised — no replays over history, no `clone`s of
the input window unless absolutely necessary.
2. **`Option<Output>` warmup.** A new indicator returns `None` until it has
ingested `warmup_period()` inputs. After that it returns `Some(value)`
on every call. The `None``Some` transition happens exactly once per
`reset()`.
3. **Reset is mandatory.** Calling `reset()` returns the indicator to the
state of a newly constructed one. Tests verify this for every indicator.
4. **No interior mutability across `update` calls.** Indicators may hold
`VecDeque` / array state, but no `Cell`/`RefCell`/`Mutex` should be
needed — `&mut self` is the only mutation channel.
### Batch is free
`BatchExt` is a blanket impl over `Indicator`:
```rust
impl<I: Indicator> BatchExt for I {
fn batch<'a>(&mut self, input: &'a [I::Input]) -> Vec<Option<I::Output>>
where I::Input: Copy
{
input.iter().map(|x| self.update(*x)).collect()
}
fn batch_parallel(...) // rayon-based for multi-asset processing
}
```
Consequence: **every indicator gets batch and parallel-batch for free** as
soon as `Indicator` is implemented. Tests verify `batch == streaming`
equivalence on every indicator — this is the `batch_equals_streaming` test
that appears in every indicator module.
## Indicator-module convention
Each indicator lives in its own file under
`crates/wickra-core/src/indicators/`. Naming: snake-case of the struct,
e.g. `Sma``sma.rs`, `MacdIndicator``macd.rs`.
Layout inside an indicator file is uniform:
```rust
//! Doc-comment with the formula and one-line summary.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Public struct + rustdoc with mathematical definition + a runnable example.
#[derive(Debug, Clone)]
pub struct Foo { /* state fields */ }
impl Foo {
/// Constructor with parameter validation.
pub fn new(period: usize, ...) -> Result<Self> { ... }
/// Const accessors for configured params.
pub const fn period(&self) -> usize { ... }
}
impl Indicator for Foo {
type Input = f64; // or (f64, f64), or Candle
type Output = f64; // or FooOutput { ... }
fn update(...) -> ... { ... }
fn reset(...) { ... }
fn warmup_period(...) -> usize { ... }
fn is_ready(...) -> bool { ... }
fn name(...) -> &'static str { "Foo" }
}
#[cfg(test)]
mod tests {
// mandatory tests (every indicator):
// - rejects_invalid_params
// - accessors_and_metadata
// - reference_value (vs TA-Lib / pandas-ta / hand-calculated)
// - ignores_non_finite_input
// - reset_clears_state
// - batch_equals_streaming
// plus indicator-specific edge cases
}
```
The `FAMILIES` constant in `mod.rs` (introduced in PR #60) is the
machine-readable index of which family every indicator belongs to. It is
the canonical taxonomy; README and Wiki tables should be derived from it.
## Input types
| Input | Used for | Examples |
|---|---|---|
| `f64` | Scalar inputs — usually a price or a return | SMA, EMA, RSI, ROC |
| `Candle` | OHLCV bar — `{open, high, low, close, volume, timestamp}` | ATR, Bollinger, Ichimoku, all candlestick patterns |
| `(f64, f64)` | Two-series indicators — `(asset, benchmark)` or `(x, y)` | PearsonCorrelation, Beta, Alpha, TreynorRatio |
The `Candle` type lives in `wickra-core::ohlcv` and is the binding
contract across bindings — Python's `Candle` namedtuple, Node's
`Candle` object, and WASM's `Candle` JS class all map 1:1.
## Output types
Most indicators emit `f64`. Multi-output indicators emit a dedicated
struct in the same module, named `FooOutput`:
```rust
pub struct BollingerOutput {
pub upper: f64,
pub middle: f64,
pub lower: f64,
}
```
Bindings flatten these into matrix outputs (NumPy 2-D array for Python,
typed object arrays for Node/WASM).
## Numerical-stability notes
A handful of indicators need care beyond naive accumulation:
- **Welford's online variance** is used in `StdDev`, `Variance`, `ZScore`,
`BollingerBands`, and several others. Standard sum-of-squares is
catastrophically lossy for low-variance inputs; Welford's recurrence
keeps O(eps) error.
- **Kahan summation** is used wherever rolling sums could span > 1e6
elements without resetting — currently only Hurst-exponent's R/S
chunks. Most rolling sums are bounded by the window size and don't need
it.
- **Logarithm bases** matter for some indicators (Hurst, MFI). Wickra
uses natural log everywhere unless the reference math explicitly
requires `log10` or `log2` — and then it documents the choice in the
rustdoc.
- **NaN / infinity guards.** Every indicator's `update` rejects
non-finite input early (returns `None` without state mutation). Tests
cover this with `ignores_non_finite_input`.
## Cross-crate flow
A typical full-stack call sequence for a Python live-trading example:
```
[ Python: live_trading.py ]
[ binance.AsyncClient WebSocket ] ──── wickra_data live feed ───┐
┌──────────────────┘
[ Candle struct conversion ]
[ PyRsi.update(close) ]
wraps │
[ wickra_core::Rsi::update(f64) ] <-- the only place math runs
[ Option<f64> -> Py<PyFloat> ]
[ Python user code ]
```
The same call sequence happens identically for Node (via NAPI),
WASM (via wasm-bindgen → JS), and Rust (no FFI overhead, just direct
calls).
## What lives where — the navigation cheat sheet
| You want to … | Look in |
|---|---|
| add a new indicator | `crates/wickra-core/src/indicators/<name>.rs` + add to `mod.rs` + add to `FAMILIES` + re-export in `lib.rs` |
| change the `Indicator` trait surface | `crates/wickra-core/src/traits.rs` — this affects every indicator, treat as breaking |
| add a new Candle field | `crates/wickra-core/src/ohlcv.rs` — also propagates to every binding's `Candle` mapping |
| add a new exchange / data source | `crates/wickra-data/src/live/<exchange>.rs`, feature-gated under `live-<exchange>` |
| expose a new binding | new crate under `bindings/` + macro-driven boilerplate in `bindings/<lang>/src/lib.rs` |
| change benchmark coverage | `crates/wickra/benches/indicators.rs` |
| add a new fuzz target | `fuzz/fuzz_targets/<name>.rs` + register in `fuzz/Cargo.toml` |
| change CI matrix | `.github/workflows/ci.yml` |
| change release pipeline | `.github/workflows/release.yml` (irreversible on `v*` tag — test on a throwaway tag first) |
## What is **deliberately** not in this repo
- **Backtest framework.** Wickra is an indicator library, not a backtester.
Strategy + PnL + fills logic is for the user (see `examples/` for
illustrative scripts).
- **Multi-exchange aggregation.** Binance is the demo feed; full
exchange-agnostic aggregation is `ccxt`'s job. Wickra's
`wickra-data::live` is intentionally minimal.
- **Order-book / L2 data.** Wickra works on OHLCV bars and ticks, not
full depth. Tick-data variants (cumulative delta, single print) are on
the roadmap but require new input types.
- **Charting / visualization.** Out of scope for the Rust core. The
WASM examples include a `lightweight-charts` integration as a
starting point, but no charting code lives in the published packages.
- **GPU / SIMD optimisation.** Indicators are O(1) per update — the
bottleneck is not vector throughput. SIMD would only help large-batch
workloads, which already saturate memory bandwidth via the cache-
friendly `VecDeque` window.
## Performance characteristics
Every indicator is amortised O(1) per `update`. The constant factor
varies:
| Class | Indicators | Per-`update` cost (approx) |
|---|---|---|
| Simple rolling | SMA, EMA, WMA, Mom | 1-2 floating-point ops |
| Recursive smoothers | KAMA, FRAMA, VIDYA, JMA | 5-15 ops |
| Window-sort | OmegaRatio, percentile-based VaR | O(period · log period) per update |
| Multi-buffer DSP | MAMA, HilbertDominantCycle, EmpiricalModeDecomposition | 30-80 ops |
| Multi-component | MacdIndicator, TtmSqueeze, Alligator | sum of components |
Benchmarks against real BTCUSDT 1-minute data live in
`crates/wickra/benches/indicators.rs`. Cross-library comparison vs
TA-Lib / pandas-ta / talipp / finta lives in
`bindings/python/benchmarks/compare_libraries.py`.
## Stability commitments
- **MSRV.** Workspace: Rust 1.86. Node binding: 1.88 (NAPI-RS pins it).
- **`Indicator` trait surface.** Breaking changes here are major-version
events. Adding a new method with a default impl is minor.
- **Indicator removal.** Once an indicator ships in a release, it stays
callable. Renames go through a deprecation period of at least one
minor version.
- **Output structs.** Adding a field to a `FooOutput` is non-breaking
because the binding contracts go through serde and accept extra keys.
## Open questions / known sharp edges
These are documented for contributors so you don't waste time
re-discovering them.
- **`Rvi`** (Relative Vigor Index) and `RviVolatility` (Relative
Volatility Index) are different indicators with the same short
acronym — make sure you import the right one.
- **Fuzz coverage of pair indicators** uses `indicator_update_pair.rs`,
which is small because pair indicators are simpler — but coverage
should grow as more pair indicators land.
- **`FAMILIES` (from PR #60) is hand-maintained.** Adding a new
indicator requires a separate entry in `FAMILIES`. The
`total_count_matches_expected` test will fail if you forget.
- **WASM does not have automated tests yet.** Smoke-validated only
through the manual examples. Adding `wasm-bindgen-test` coverage is
on the roadmap.
For the high-level project goals see [`ROADMAP.md`](ROADMAP.md); for
day-to-day contribution mechanics see [`CONTRIBUTING.md`](CONTRIBUTING.md).
+493 -9
View File
@@ -7,6 +7,486 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [0.3.1] - 2026-05-30
### Fixed
- **Release pipeline — CycloneDX SBOM generation.** `cargo-cyclonedx` has no
`-p`/`--package` selector; it walks the whole workspace in a single pass.
The `release.yml` SBOM step invoked it as `cargo cyclonedx … -p <crate>` and
aborted with `error: unexpected argument '-p' found`, which failed the
crates.io publish job *after* the crates were already published and skipped
the GitHub Release attach-assets job (no release page, no SBOM artefacts).
The step now runs a single workspace pass and collects the three crates.io
crate SBOMs. No library changes relative to 0.3.0 — this patch republishes
the same code with a working release pipeline.
## [0.3.0] - 2026-05-30
### Added
- **Family 15 — Risk / Performance metrics (17 new indicators).** Implemented
pragmatically as standard `Indicator`s rather than a separate
`wickra-metrics` crate; the input is a scalar `f64` per bar (period return,
equity sample, or trade P&L depending on the metric).
- **Scalar `Indicator<f64>` — 14 metrics:** Sharpe Ratio, Sortino Ratio,
Calmar Ratio, Omega Ratio, Max Drawdown (rolling), Average Drawdown,
Drawdown Duration (time-under-water), Pain Index, Value at Risk
(historical, linear-interpolated percentile), Conditional Value at Risk
(Expected Shortfall), Profit Factor, Gain/Loss Ratio, Recovery Factor,
Kelly Criterion.
- **Two-series `Indicator<(f64, f64)>` — 3 metrics on `(asset_return,
benchmark_return)` pairs:** Treynor Ratio, Information Ratio,
Jensen's Alpha (CAPM).
- **Candlestick patterns family (15 indicators).** A new "Candlestick
Patterns" family covers the standard 1- to 3-bar reversal and
continuation shapes: `Doji`, `Hammer`, `InvertedHammer`, `HangingMan`,
`ShootingStar`, `Engulfing`, `Harami`, `MorningEveningStar`,
`ThreeSoldiersOrCrows`, `PiercingDarkCloud`, `Marubozu`, `Tweezer`,
`SpinningTop`, `ThreeInside` and `ThreeOutside`. Every detector takes a
`Candle` and emits a signed `f64` (`+1.0` bullish, `-1.0` bearish, `0.0`
no pattern; `Doji` is direction-less and emits `+1.0`/`0.0`). The MVP is
a pattern-shape check only — no trend filter is applied. Available
across Rust, Python, Node and WASM bindings. Harmonic and chart
patterns remain out of scope and will follow once the pattern-detection
framework (pivot detector + multi-bar state machines) lands.
- **Market Profile family** (3 new indicators, opens family #9 across the
catalogue):
- `ValueArea(period, bin_count, value_area_pct)` — rolling
bin-approximation volume profile over the last `period` candles.
Outputs `{poc, vah, val}`: Point of Control is the bin with the highest
cumulative volume; the Value Area expands symmetrically from POC and
always absorbs the higher-volume neighbour next, until the configured
percentage of total volume (default 70%) is enclosed. Each candle's
volume is spread uniformly across its `[low, high]` range; single-print
bars (`low == high`) drop their entire volume into one bin.
- `InitialBalance(period)` — first-N-bar session high / low, frozen
once `period` bars have been ingested. Outputs `{high, low}`. Default
`period = 12` (one-hour IB on 5-minute bars for US equities). Callers
MUST invoke `reset()` at every session boundary, otherwise the IB
locks and stays fixed for the lifetime of the instance.
- `OpeningRange(period)` — same lock-after-N-bars semantics as IB but
with a smaller default window (`period = 6`, 30 min on 5-minute
bars) and a third output `breakout_distance` = `close - or_mid`,
signed (positive above the range, negative below).
- Histogram-output Market Profile variants (Volume Profile / VPVR /
Composite Profile) and tick-data-only variants (TPO / Single Print /
Cumulative Delta / Order Flow Delta / Volume-Weighted Open) are
deliberately out of scope of this PR: the former need a new
histogram-output API layer, the latter need tick / L2 data which
`wickra-data` does not yet expose.
- **Family 12 — Statistik / Regression (13 indicators).** A complete
statistical toolkit for analysing rolling price distributions and
cross-series relationships. Every indicator ships in the Rust core
plus all three bindings (Python, Node, WASM), with full streaming +
batch parity, fuzz coverage, and benches against the BTCUSDT
dataset:
- **Variance** — rolling population variance (`StdDev` squared).
- **CoefficientOfVariation** — `StdDev / Mean`, dimensionless dispersion.
- **Skewness** — rolling third standardised moment (Pearson skewness).
- **Kurtosis** — rolling excess kurtosis (fourth moment minus `3`).
- **StandardError** — standard error of estimate for the rolling OLS
fit, with `n 2` residual degrees of freedom.
- **DetrendedStdDev** — population standard deviation of OLS
residuals (the StdDev that remains after subtracting the linear
trend).
- **RSquared** — coefficient of determination of the rolling OLS
fit; the trend-quality filter.
- **MedianAbsoluteDeviation** — robust dispersion measure that
survives outliers (median of absolute deviations from the median).
- **Autocorrelation** — rolling lag-`k` Pearson autocorrelation;
detects periodicity and tests for white-noise behaviour.
- **HurstExponent** — R/S-analysis estimator of trend-persistence
vs. mean-reversion regime (`0.5` is random walk).
- **PearsonCorrelation** — rolling correlation between two
synchronised series; takes `(x, y)` pairs.
- **Beta** — rolling OLS slope of an asset on a benchmark; the CAPM
sensitivity coefficient.
- **SpearmanCorrelation** — rolling rank correlation (monotone,
outlier-robust analogue of Pearson).
Indicator count: 71 → 84.
- **Family 13 — Ichimoku & alternative charts.** Two new indicators:
- `Ichimoku` (Ichimoku Kinko Hyo) — the full five-line cloud system
(Tenkan-sen, Kijun-sen, Senkou Span A/B, Chikou Span) with the
classic `(9, 26, 52, 26)` defaults and configurable periods. Forward
displacement is handled in a streaming ring buffer so the
currently-visible Senkou A/B at bar *n* are the values computed
from bar *n displacement*.
- `HeikinAshi` — the candle smoothing transform that recursively
averages OHLC into a four-component output (`ha_open`, `ha_high`,
`ha_low`, `ha_close`). Seeds `ha_open` from the first bar's
`(open + close) / 2`.
Exposed in all four bindings (Rust, Python, Node, WASM). Renko,
Kagi, and Point & Figure from the family ideas list are deferred:
they are custom bar generators rather than indicators and belong in
`wickra-data`.
- **Family 10 — Ehlers / Cycle (DSP) indicators.** 16 new
streaming-first indicators implementing John Ehlers'
digital-signal-processing school of cycle analytics — a strong
differentiation feature versus TA-Lib and pandas-ta, which only
ship fragments of this catalogue:
- **MAMA / FAMA** (MESA Adaptive Moving Average + Following
Adaptive Moving Average) — phase-rate-adaptive smoothing pair
from the 2001 MESA paper, exposed both jointly via `Mama` (multi-
output) and as a scalar `Fama` wrapper.
- **Fisher Transform** and **Inverse Fisher Transform** — Gaussian
normalisation of price (Ehlers 2002) and its tanh-based bounded
counterpart for oscillators.
- **SuperSmoother**, **Roofing Filter**, **Decycler** and **Decycler
Oscillator** — 2-pole Butterworth lowpass, bandpass and
high-pass complement building blocks from *Cycle Analytics for
Traders* (2013).
- **Hilbert Dominant Cycle**, **Sine Wave** and **Adaptive Cycle**
— Hilbert-transform-based period estimation from *Rocket Science
for Traders* (2001).
- **Center of Gravity**, **Cybernetic Cycle Component**,
**Instantaneous Trendline**, **Ehlers Stochastic** and
**Empirical Mode Decomposition** — EasyLanguage classics from
Ehlers' published catalogue.
- All sixteen are exposed across Rust, Python, Node.js and WASM
bindings, fuzz-tested, benchmarked against real BTCUSDT
1-minute data, and pass `batch == streaming` equivalence.
- Indicator count rises from 71 to **87** across **nine** families.
- **DeMark family (family 11) — 12 new indicators.** TD Setup (9-bar
buy/sell setup counter with parameterised lookback and target), TD
Sequential (Setup + Countdown phase machine emitting setup count,
countdown count and active countdown direction), TD DeMarker
(bounded [0, 1] range oscillator built from high/low expansions),
TD REI (Range Expansion Index — bounded ±100 oscillator with the
classic 5-bar default), TD Pressure (volume-weighted buying /
selling pressure normalised to ±100), TD Combo (aggressive
countdown variant with extra monotone-low / monotone-close
strictness conditions on top of the classic countdown rule), TD
Countdown (standalone 13-bar countdown phase machine emitting
only the signed countdown count and direction — smaller streaming
payload than the full TD Sequential), TD Lines (TDST horizontal
support / resistance levels derived from the highs and lows of
the most-recently-completed setup), TD Range Projection (next-bar
high / low projection from the current bar's OHLC via DeMark's
open-vs-close-weighted pivot), TD Differential (2-bar
buying-pressure-vs-selling-pressure reversal pattern emitting
+1 / -1 / 0), TD Open (gap-and-fade reversal pattern emitting
+1 / -1 / 0 when the open prints outside the prior bar's range
but the subsequent action recovers back into it), and TD Risk
Level (protective stop levels derived from the lowest-low / highest-
high setup bar's true range). All twelve are exposed through the
Rust, Python, Node, and WASM bindings with `batch == streaming`
equivalence tests, candle-stream fuzz coverage, and benchmark
entries on the BTCUSDT 1-minute dataset.
- **Family 08 — Pivots & Support/Resistance.** Seven new indicators land
the previously empty pivot family: Classic (Floor-Trader) Pivot Points
with three resistance and support tiers, Fibonacci Pivots spaced by
0.382 / 0.618 / 1.000 of the prior range, Camarilla Pivots
(Nick Stott's four-tier `(H L) · 1.1 / {12, 6, 4, 2}` levels),
Woodie Pivots with the close-weighted `PP = (H + L + 2·C) / 4`,
DeMark Pivots whose conditional `X` depends on whether the bar closed
up, down or flat, Williams Fractals as a five-bar swing detector and
ZigZag as a percent-threshold swing tracker. Every level/swing is
exposed across Rust, Python, Node and WASM with the standard
`update` / `batch` / `reset` / `is_ready` / `warmup_period` surface
and matching streaming-vs-batch and reference-value tests. The fuzz
candle target now covers all seven.
- **Family 09 — Trailing Stops, seven new indicators.** Rounds out the
trailing-stop family from 5 to 12: `HiLoActivator` (Crabel's
SMA-of-high / SMA-of-low trail), `VoltyStop` (Cynthia Kase's
extreme-anchor ATR stop), `YoyoExit` (long-only ATR trail with a
re-entry trigger), `DonchianStop` (the original Turtle exit, lowest
low / highest high), `PercentageTrailingStop` (fixed-percent trail),
`StepTrailingStop` (round-number grid trail) and `RenkoTrailingStop`
(block-anchored Renko-style trail). All wired into the four bindings
(Rust, Python, Node, WASM), the streaming + batch fuzz targets, and
the bench harness.
- **Klinger Volume Oscillator (KVO).** Stephen J. Klinger's trend-aware
volume-force oscillator: `EMA(vf, fast) EMA(vf, slow)` over a daily
volume force scaled by cumulative-measurement ratio. Classic
`(fast, slow) = (34, 55)` exposed via `Kvo::classic()`.
- **Volume Oscillator (VO).** Percent difference between a fast and a
slow SMA of bar volume: `100 · (SMA(vol, fast) SMA(vol, slow)) /
SMA(vol, slow)`. Default `(14, 28)`.
- **Negative Volume Index (NVI).** Paul Dysart's cumulative index that
only updates on volume-contraction bars (`volume_t < volume_{t1}`),
absorbing the percent close change on those quiet days. Fosback
baseline `1000.0`, configurable via `Nvi::with_baseline`.
- **Positive Volume Index (PVI).** The complementary index that
updates on volume-expansion bars (`volume_t > volume_{t1}`).
- **Williams Accumulation/Distribution.** Larry Williams' volume-less
cumulative flow that anchors to the previous close (true high/low) and
classifies each bar as accumulation, distribution, or neutral by the
sign of the close-to-close change.
- **Anchored VWAP.** A cumulative VWAP whose accumulation begins at a
user-chosen anchor bar rather than the session open. Re-anchor at
runtime via `AnchoredVwap::set_anchor` for click-to-anchor trader
workflows.
- **Demand Index (Sibbet).** James Sibbet's smoothed buying-vs-selling
pressure ratio in the streaming-friendly textbook form
`EMA(volume · close-return · (1 + range/close), period)`.
- **Time Segmented Volume (TSV).** Don Worden's rolling sum of signed
volume weighted by the close-to-close move: a window-sum measure of
net accumulation/distribution.
- **Volume Zone Oscillator (VZO).** Walid Khalil's normalised
volume-flow oscillator bounded in `[100, 100]`, defined as
`100 · EMA(signed_volume) / EMA(volume)`.
- **Market Facilitation Index (Bill Williams).** Per-bar
`(high low) / volume` — how much price movement the market produces
per unit of volume.
- **ADXR (Average Directional Movement Index Rating)** in the Trend &
Directional family. Wilder's directional-strength smoother: the
average of the current `ADX` and the `ADX` from `period - 1` bars
ago. Warmup is `3 * period - 1` (e.g. 41 for the default `period =
14`). Shipped across all four bindings (Rust core, Python, Node,
WASM) plus fuzz/test/bench coverage.
- **Random Walk Index (RWI)** in the Trend & Directional family. Mike
Poulos' trend-vs.-random-walk gauge: for each lookback `i ∈ [2,
period]` the ratio of actual displacement to the random-walk
expectation `ATR_i * sqrt(i)` is taken; the per-bar output is the
maximum across lookbacks for both the high (`RWI_High`) and low
(`RWI_Low`) directions. Multi-output `(high, low)` across all four
bindings; warmup `= period`.
- **Trend Intensity Index (TII)** in the Trend & Directional family.
M.H. Pee's `[0, 100]` oscillator: the share of the most recent
`dev_period` SMA-deviations that are positive, scaled to
`[0, 100]`. Saturates at 100 on a pure uptrend, at 0 on a pure
downtrend, and returns the neutral 50 on a perfectly flat market.
Canonical Python defaults `(sma_period=60, dev_period=30)`; warmup
`= sma_period + dev_period 1`.
- **Wave Trend Oscillator (LazyBear)** in the Trend & Directional
family. Two-line mean-reverting momentum gauge built from the
typical price and three cascaded EMAs:
`esa = EMA(ap, channel)`, `d = EMA(|ap esa|, channel)`,
`ci = (ap esa) / (0.015 · d)`, `wt1 = EMA(ci, average)`,
`wt2 = SMA(wt1, signal)`. `WaveTrend::classic()` exposes the
LazyBear defaults `(channel = 10, average = 21, signal = 4)`;
warmup `= 2 · channel + average + signal 3` (42 for the classic
defaults). Includes a sub-ULP flat-tolerance guard on `ci` so a
perfectly flat market reports `(0, 0)` instead of the
mathematically indeterminate `1 / 0.015 = 66.67`. Multi-output
`(wt1, wt2)` across all four bindings.
- **Family 05 — Bands & Channels (11 new indicators).** Eleven additional
price-envelope overlays organised into the new "Bands & Channels"
family, exposed across all four bindings (Rust, Python, Node, WASM):
- `MaEnvelope` — SMA centerline with fixed-percent envelope (the oldest
band overlay still in use).
- `AccelerationBands` — Price Headley's momentum-biased bands that widen
with the bar's relative range `(H L) / (H + L)`.
- `StarcBands` — Stoller Average Range Channel: SMA(close) ± k·ATR
(Keltner's SMA-centerline sibling).
- `AtrBands` — Close-anchored envelope of width `k · ATR`, the standard
volatility-targeting stop/target band.
- `HurstChannel` — SMA centerline wrapped by the rolling high-low range
(Brian Millard / Hurst-cycle channel).
- `LinRegChannel` — Linear-regression endpoint ± k·σ of the residuals,
measuring dispersion about the *trend* rather than the mean.
- `StandardErrorBands` — Linear regression with the OLS standard error
(denominator `n 2`) for prediction-interval bands.
- `DoubleBollinger` — Kathy Lien's `±1σ` plus `±2σ` zone-partition setup.
- `TtmSqueeze` — John Carter's BB-inside-KC squeeze flag paired with a
detrended-close momentum reading.
- `FractalChaosBands` — Bill Williams 5-bar fractal high/low envelope.
- `VwapStdDevBands` — Cumulative VWAP with volume-weighted standard
deviation bands.
Indicator count rises from 71 to 82 across nine families; the README
family table and the wiki overview/sidebar/warmup pages were updated to
match.
- **Yang-Zhang Volatility.** Yang & Zhang (2000) gold-standard OHLC
estimator: a convex blend of overnight (close-to-open), open-to-close
and Rogers-Satchell variances. The blending factor
`k = 0.34 / (1.34 + (n+1)/(n-1))` is the one that minimises
estimator variance under driftless GBM with overnight gaps. The
overnight and open-to-close pieces use sample variance (Bessel's
correction, divisor `n1`), so the indicator needs `period + 1` bars
to emit. Output annualised to a percent. Defaults: `period = 20`,
`trading_periods = 252`. The recommended OHLC estimator for equities,
futures, and any asset with material close-to-open gaps.
- **Rogers-Satchell Volatility.** Drift-free OHLC realised-volatility
estimator from Rogers, Satchell & Yoon (1994). Per-bar sample is
`ln(H/C)·ln(H/O) + ln(L/C)·ln(L/O)`; every term is non-negative by
construction (high >= open, close; low <= open, close), so the
rolling mean is exact, not biased, under arbitrary drift. The
algebraic drift-cancellation is what differentiates it from
Garman-Klass. Output annualised to a percent. Defaults:
`period = 20`, `trading_periods = 252`.
- **Garman-Klass Volatility.** Garman & Klass (1980) OHLC realised
volatility estimator: per-bar sample is
`0.5·(ln H/L)² (2·ln2 1)·(ln C/O)²`, then take the annualised
square root of the rolling mean. Roughly 7.4× more statistically
efficient than close-to-close stddev under driftless GBM. Output
annualised to a percent. Defaults: `period = 20`,
`trading_periods = 252`.
- **Parkinson Volatility.** Michael Parkinson's (1980) high-low realised
volatility estimator: `sigma² = (1 / (4n·ln2)) · Σ (ln(H/L))²`. Output
annualised to a percent in the same style as `HistoricalVolatility`
(pass `trading_periods = 1` for the raw per-bar `sigma·100` figure).
Roughly 5× more statistically efficient than close-to-close stddev
under a driftless-GBM assumption. Defaults: `period = 20`,
`trading_periods = 252`.
- **RVIVolatility (Relative Volatility Index).** Donald Dorsey's
RSI-shaped volatility gauge: partition the rolling standard
deviation of close into "up" (close rose) and "down" (close fell)
samples, Wilder-smooth each side, and compute
`100 · AvgUp / (AvgUp + AvgDown)`. Bounded on `[0, 100]`; saturates
at `100` in pure uptrends, `0` in pure downtrends, and falls back to
`50` on a completely flat series (same undefined-RS convention as
`RSI`). Single `period` parameter (default `10`) drives both the
stddev window and the Wilder smoothing. Named `RVIVolatility` rather
than plain `RVI` to disambiguate from Relative Vigor Index, which
ships in Family 02 under the shorter `RVI` name.
- **Family 03 — MACD & Price Oscillators.** `Stc` (Schaff Trend Cycle,
Doug Schaff): doubly-`Stochastic`-smoothed MACD producing a bounded
`[0, 100]` reading that reacts faster than `MACD` itself. Four
parameters `(fast = 23, slow = 50, schaff_period = 10, factor = 0.5)`.
Output is clamped to `[0, 100]` to absorb floating-point rounding.
Exposed in all four bindings.
- **Family 03 — MACD & Price Oscillators.** `ElderImpulse` (Alexander
Elder's Impulse System): tri-state momentum gauge combining `EMA`
trend slope with `MACD` histogram slope. Returns `+1` (green/buy)
when both rise, `1` (red/sell) when both fall, `0` (blue/neutral)
on disagreement. Four parameters
`(ema_period, macd_fast, macd_slow, macd_signal)`; defaults
`(13, 12, 26, 9)` track *Come Into My Trading Room*. Exposed in all
four bindings.
- **Family 03 — MACD & Price Oscillators.** `ZeroLagMacd`: classic
MACD topology with `ZLEMA` substituted for `EMA` everywhere — faster
reaction to trend changes at the cost of slightly noisier readings.
Multi-output `ZeroLagMacdOutput { macd, signal, histogram }`. Three
parameters `(fast = 12, slow = 26, signal = 9)`; `fast` must be
strictly less than `slow`. Exposed in all four bindings.
- **Family 03 — MACD & Price Oscillators.** `CFO` (Chande Forecast
Oscillator): `100 · (close LinReg(close, period)) / close`. Positive
when the close overshoots the linear forecast, negative when it
undershoots. Holds the previous value if the close is zero. Default
period 14. Exposed in all four bindings.
- **Family 03 — MACD & Price Oscillators.** `AwesomeOscillatorHistogram`:
`AO SMA(AO, sma_period)`. A configurable variant of the existing
`AcceleratorOscillator` (which fixes `(fast, slow, sma) = (5, 34, 5)`).
Three parameters; defaults match Bill Williams' Accelerator. Exposed
in all four bindings.
- **Family 03 — MACD & Price Oscillators.** `APO` (Absolute Price
Oscillator): `EMA(close, fast) EMA(close, slow)`. Like MACD's line
without the signal EMA. Default `(fast = 12, slow = 26)`. `fast` must
be strictly less than `slow`. Exposed in all four bindings.
- **Family 02 — Momentum Oscillators.** `Inertia` (Dorsey): a
`LinearRegression` smoothing of the `RVI` series — preserves trend
direction while damping the underlying ratio. Candle input, two
parameters `(rvi_period, linreg_period)` (defaults 14 / 20). Exposed
in all four bindings.
- **Family 02 — Momentum Oscillators.** `ConnorsRsi`: Larry Connors'
3-component aggregate — `RSI(close)`, `RSI(streak)`, and the
percentile rank of the 1-bar return over the recent `period_rank`
returns. Bounded in `[0, 100]`. Three parameters
`(period_rsi, period_streak, period_rank)` (defaults 3 / 2 / 100).
Exposed in all four bindings.
- **Family 02 — Momentum Oscillators.** `LaguerreRsi` (Ehlers):
four-stage Laguerre polynomial filter wrapped in an RSI-style up/down
accumulator. Single parameter `gamma` in `[0, 1]` (default 0.5) trades
lag for smoothness. State is seeded to the first input so a constant
series stays at the neutral 50. Output clamped to `[0, 100]`. Exposed
in all four bindings.
- **Family 02 — Momentum Oscillators.** `SMI` (Stochastic Momentum
Index, Blau): doubly-`EMA`-smoothed bounded oscillator measuring the
close's displacement from the centre of the recent high-low range,
scaled by the smoothed range. Candle input, three parameters
`(period, d_period, d2_period)` (defaults 5 / 3 / 3). Exposed in all
four bindings.
- **Family 02 — Momentum Oscillators.** `KST` (Know Sure Thing, Pring):
weighted sum of four `SMA`-smoothed `ROC` series with Pring's fixed
weights `1, 2, 3, 4`, plus an `SMA` signal line. Nine parameters
(four ROC periods, four SMA periods, signal period); `Kst::classic()`
uses Pring's recommended defaults. Multi-output indicator emitting
`KstOutput { kst, signal }`. Exposed in all four bindings.
- **Family 02 — Momentum Oscillators.** `PGO` (Pretty Good Oscillator,
Mark Johnson): `(close SMA(close, period)) / EMA(TR, period)`.
Candle input, single parameter `period` (default 14). Roughly counts
how many ATR-equivalents the close is from its mean. Exposed in all
four bindings.
- **Family 02 — Momentum Oscillators.** `RVI` (Relative Vigor Index,
Dorsey): per-bar ratio `SMA(close - open, period) / SMA(high - low,
period)`. Candle input, single parameter `period` (default 10).
Positive on average-bullish windows, negative on average-bearish.
Holds previous value if the entire window has zero range. Exposed in
all four bindings.
- **Family 01 — Moving Averages.** `ALMA` (Arnaud Legoux Moving Average):
Gaussian-weighted moving average with configurable centre (`offset` in
`[0, 1]`) and kernel width (`sigma > 0`). Community-standard defaults
`(period = 9, offset = 0.85, sigma = 6.0)` available via `Alma::classic()`.
Exposed in all four bindings (Rust, Python, Node, WASM).
- **Family 01 — Moving Averages.** `EVWMA` (Elastic Volume-Weighted
Moving Average, Fries 2001): an "elastic" recurrence whose smoothing
weight is the bar's volume relative to the running window-volume.
Candle input (uses close + volume), single parameter `period`
(default 20). Holds its previous value if the entire window has zero
volume. Exposed in all four bindings.
- **Family 01 — Moving Averages.** `Alligator` (Bill Williams): three
SMMA lines (Jaw / Teeth / Lips) of the median price `(high + low) / 2`
with default periods 13 / 8 / 5. Multi-output indicator emitting
`AlligatorOutput { jaw, teeth, lips }`. Visual chart shift is left to
the consumer. Exposed in all four bindings.
- **Family 01 — Moving Averages.** `JMA` (Jurik Moving Average):
three-stage filter reconstruction of Mark Jurik's adaptive MA.
Three parameters: `period` (14), `phase` in `[-100, 100]` (0), `power`
in `1..=4` (2). State is seeded to the first input so a constant series
is reproduced exactly. Exposed in all four bindings.
- **Family 01 — Moving Averages.** `VIDYA` (Variable Index Dynamic
Average, Chande 1992): EMA whose smoothing factor is scaled by the
absolute Chande Momentum Oscillator. Two parameters `period` and
`cmo_period` (defaults 14 / 9). Exposed in all four bindings.
- **Family 01 — Moving Averages.** `FRAMA` (Fractal Adaptive Moving
Average, Ehlers 2005): adapts its smoothing constant to the fractal
dimension of the recent window — fast in trends, slow in chop. Single
parameter `period` (must be even, default 16). Exposed in all four
bindings.
- **Family 01 — Moving Averages.** `McGinleyDynamic`: John McGinley's
self-adjusting MA. Single parameter `period`; the recurrence
`MD + (price - MD) / (0.6 * period * (price / MD)^4)` speeds up when price
falls below the indicator and damps when price runs above. Seeded with the
simple average of the first `period` inputs. Exposed in all four bindings.
## [0.2.7] - 2026-05-24
### Added
- **Windows ARM64 is back.** npm Support unblocked the
`wickra-win32-arm64-msvc` sub-package name (same path
`wickra-win32-x64-msvc` took through 0.1.4) and transferred write
access to @kingchenc. 0.2.7 ships the binding for
`aarch64-pc-windows-msvc` alongside the existing five platforms:
the `napi.triples.additional` entry, the `optionalDependencies`
pin, the `bindings/node/npm/win32-arm64-msvc/` sub-package and the
`windows-11-arm` row of the release.yml node-build matrix are all
restored from 8aa74cb. `npm install wickra` on Windows ARM64 now
resolves to a native build instead of failing the loader's
optional-dep lookup. PyPI's `win_arm64` wheel was unaffected and
carries through as before.
### Changed
- **Benchmark CPU renamed.** The "Reproduced on" line in every
README listed an AMD Ryzen 9 7950X3D; the canonical machine is
actually a Ryzen 9 9950X. Speedup ratios in the tables are
unchanged (they're relative across libraries on the same machine),
only the labelling is corrected. The performance-regression issue
template's CPU example was updated for consistency.
## [0.2.6] - 2026-05-24
### Fixed
- **docs.rs build.** Rust 1.92 removed the `doc_auto_cfg` feature gate
and folded it back into `doc_cfg` (rust-lang/rust#138907). docs.rs
builds against the latest nightly and sets `--cfg docsrs`, so every
published 0.2.x failed with E0557 on the
`#![cfg_attr(docsrs, feature(doc_auto_cfg))]` line at the top of
`wickra`, `wickra-core`, and `wickra-data`. GitHub CI didn't see
this — stable rustc never enables the `docsrs` cfg. The three
library crates now gate on `doc_cfg` (same intent, same rendered
output on docs.rs, builds again on nightly).
### Changed
- **README — Wickra is now the top row of every comparison table.**
The "Why Wickra exists" library matrix and the per-indicator
benchmark tables previously placed Wickra at the bottom; a reader
landing on the README is here to compare *against* Wickra, so the
pivot row belongs at the top with a ★ marker. Same column data,
same winner annotations — only row order changed. Mirrored across
the umbrella README and every binding README so crates.io / PyPI /
npm landing pages stay in sync.
## [0.2.5] - 2026-05-24
### Added
@@ -352,12 +832,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
optional Binance live feed.
- Bindings for Python, Node.js, and WebAssembly.
[Unreleased]: https://github.com/kingchenc/wickra/compare/v0.2.5...HEAD
[0.2.5]: https://github.com/kingchenc/wickra/compare/v0.2.1...v0.2.5
[0.2.1]: https://github.com/kingchenc/wickra/compare/v0.2.0...v0.2.1
[0.2.0]: https://github.com/kingchenc/wickra/compare/v0.1.4...v0.2.0
[0.1.4]: https://github.com/kingchenc/wickra/compare/v0.1.3...v0.1.4
[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
[Unreleased]: https://github.com/wickra-lib/wickra/compare/v0.3.1...HEAD
[0.3.1]: https://github.com/wickra-lib/wickra/compare/v0.3.0...v0.3.1
[0.3.0]: https://github.com/wickra-lib/wickra/compare/v0.2.7...v0.3.0
[0.2.7]: https://github.com/wickra-lib/wickra/compare/v0.2.6...v0.2.7
[0.2.6]: https://github.com/wickra-lib/wickra/compare/v0.2.5...v0.2.6
[0.2.5]: https://github.com/wickra-lib/wickra/compare/v0.2.1...v0.2.5
[0.2.1]: https://github.com/wickra-lib/wickra/compare/v0.2.0...v0.2.1
[0.2.0]: https://github.com/wickra-lib/wickra/compare/v0.1.4...v0.2.0
[0.1.4]: https://github.com/wickra-lib/wickra/compare/v0.1.3...v0.1.4
[0.1.3]: https://github.com/wickra-lib/wickra/compare/v0.1.2...v0.1.3
[0.1.2]: https://github.com/wickra-lib/wickra/compare/v0.1.1...v0.1.2
[0.1.1]: https://github.com/wickra-lib/wickra/compare/v0.1.0...v0.1.1
[0.1.0]: https://github.com/wickra-lib/wickra/releases/tag/v0.1.0
+29
View File
@@ -0,0 +1,29 @@
cff-version: 1.2.0
title: Wickra
message: >-
If you use Wickra in academic work, please cite it using the metadata
below.
type: software
authors:
- alias: kingchenc
email: wickra.lib@gmail.com
repository-code: "https://github.com/wickra-lib/wickra"
url: "https://wickra.org"
abstract: >-
Wickra is a streaming-first technical-analysis library implemented in
Rust with bindings for Python, Node.js and WebAssembly. Each indicator
is a state machine that updates in constant time per new input, so
identical code paths serve live-trading workloads and historical
back-testing. The library covers 214 indicators across 16 families
(moving averages, momentum, volatility, volume, statistics, Ehlers
digital-signal-processing cycles, pivots, DeMark, Ichimoku, candlestick
patterns, market profile, and risk/performance metrics).
keywords:
- technical-analysis
- technical-indicators
- streaming
- algorithmic-trading
- quantitative-finance
- rust
- time-series
license: PolyForm-Noncommercial-1.0.0
+1 -1
View File
@@ -33,7 +33,7 @@ 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
at **wickra.lib@gmail.com**. All reports will be reviewed and investigated
promptly and fairly, and the maintainer will respect the privacy and security
of the reporter.
+2 -2
View File
@@ -76,9 +76,9 @@ wasm-pack test --node bindings/wasm
- **Bindings.** A change to a public indicator API must be mirrored across the
Python, Node, and WASM bindings, including their type stubs / `.d.ts`.
- **Docs.** Update the relevant page on the
[project Wiki](https://github.com/kingchenc/wickra/wiki) and the
[project Wiki](https://github.com/wickra-lib/wickra/wiki) and the
`README.md` when behaviour or the public API changes. The Wiki lives in
a separate git repository: `https://github.com/kingchenc/wickra.wiki.git`.
a separate git repository: `https://github.com/wickra-lib/wickra.wiki.git`.
- **Changelog.** Add an entry under `## [Unreleased]` in `CHANGELOG.md`.
## Commit and pull-request workflow
Generated
+6 -6
View File
@@ -1867,7 +1867,7 @@ dependencies = [
[[package]]
name = "wickra"
version = "0.2.5"
version = "0.3.1"
dependencies = [
"approx",
"criterion",
@@ -1878,7 +1878,7 @@ dependencies = [
[[package]]
name = "wickra-core"
version = "0.2.5"
version = "0.3.1"
dependencies = [
"approx",
"proptest",
@@ -1888,7 +1888,7 @@ dependencies = [
[[package]]
name = "wickra-data"
version = "0.2.5"
version = "0.3.1"
dependencies = [
"approx",
"csv",
@@ -1915,7 +1915,7 @@ dependencies = [
[[package]]
name = "wickra-node"
version = "0.2.5"
version = "0.3.1"
dependencies = [
"napi",
"napi-build",
@@ -1925,7 +1925,7 @@ dependencies = [
[[package]]
name = "wickra-python"
version = "0.2.5"
version = "0.3.1"
dependencies = [
"numpy",
"pyo3",
@@ -1934,7 +1934,7 @@ dependencies = [
[[package]]
name = "wickra-wasm"
version = "0.2.5"
version = "0.3.1"
dependencies = [
"console_error_panic_hook",
"js-sys",
+5 -5
View File
@@ -12,19 +12,19 @@ members = [
exclude = ["fuzz"]
[workspace.package]
version = "0.2.5"
authors = ["kingchenc <kingchencp@gmail.com>"]
version = "0.3.1"
authors = ["kingchenc <wickra.lib@gmail.com>"]
edition = "2021"
rust-version = "1.86"
license = "PolyForm-Noncommercial-1.0.0"
repository = "https://github.com/kingchenc/wickra"
homepage = "https://github.com/kingchenc/wickra"
repository = "https://github.com/wickra-lib/wickra"
homepage = "https://github.com/wickra-lib/wickra"
readme = "README.md"
keywords = ["finance", "trading", "indicators", "technical-analysis", "ta"]
categories = ["finance", "mathematics", "science"]
[workspace.dependencies]
wickra-core = { path = "crates/wickra-core", version = "0.2.5" }
wickra-core = { path = "crates/wickra-core", version = "0.3.1" }
thiserror = "2"
rayon = "1.10"
+2 -2
View File
@@ -35,7 +35,7 @@ URL for them above, as well as copies of any plain-text lines
beginning with `Required Notice:` that the licensor provided
with the software. For example:
> Required Notice: Copyright 2026 kingchenc (https://github.com/kingchenc/wickra)
> Required Notice: Copyright 2026 kingchenc (https://github.com/wickra-lib/wickra)
## Changes and New Works License
@@ -133,4 +133,4 @@ of your licenses.
---
Required Notice: Copyright 2026 kingchenc (https://github.com/kingchenc/wickra)
Required Notice: Copyright 2026 kingchenc (https://github.com/wickra-lib/wickra)
+40 -32
View File
@@ -1,7 +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)
[![CI](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml/badge.svg)](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml)
[![codecov](https://codecov.io/gh/wickra-lib/wickra/branch/main/graph/badge.svg)](https://codecov.io/gh/wickra-lib/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)
@@ -36,16 +36,16 @@ for price in live_feed:
The Python TA ecosystem has plenty of libraries — TA-Lib, pandas-ta, finta,
talipp, tulipy — and every one of them shares the same blind spot:
| Library | Install pain | Streaming | Multi-language | Active |
|--------------------|-----------------|-----------|----------------|--------|
| TA-Lib (Python) | yes (C deps) | no | no | barely |
| pandas-ta | clean | no | no | slow |
| finta | clean | no | no | stale |
| ta-lib-python | yes (C deps) | no | no | barely |
| talipp | clean | yes | no | yes |
| Tulip Indicators | yes (C deps) | no | partial | stale |
| ooples (C#) | clean | no | C# only | yes |
| **Wickra** | **clean** | **yes** | **Python+Node+WASM+Rust** | **yes** |
| Library | Install pain | Streaming | Multi-language | Active |
|------------------------|-----------------|-----------|----------------|--------|
| **★&nbsp;Wickra** | **clean** | **yes** | **Python + Node + WASM + Rust** | **yes** |
| TA-Lib (Python) | yes (C deps) | no | no | barely |
| pandas-ta | clean | no | no | slow |
| finta | clean | no | no | stale |
| ta-lib-python | yes (C deps) | no | no | barely |
| talipp | clean | yes | no | yes |
| Tulip Indicators | yes (C deps) | no | partial | stale |
| ooples (C#) | clean | no | C# only | yes |
Wickra is the only library that combines all of: clean install, streaming,
multi-language reach, and active maintenance.
@@ -58,7 +58,7 @@ 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,
- **Reproduced on:** Windows 11 Pro 26200, AMD Ryzen 9 9950X, 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
@@ -76,7 +76,7 @@ to recompute on every tick.
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 |
| Indicator | **★&nbsp;Wickra** | finta | talipp |
|---------------------|---------------------|-----------------------------|-------------------------------|
| SMA(20) | **95.6 µs ★** | 343.5 µs (3.6× slower) | 7 640.6 µs (79.9× slower) |
| EMA(20) | **64.6 µs ★** | 223.1 µs (3.5× slower) | 12 160.9 µs (188.2× slower) |
@@ -90,7 +90,7 @@ slower it is than Wickra in parentheses. **★** marks the winner per row.
A batch-only library has to re-run its full indicator over the entire history on
every new tick; Wickra updates state in O(1).
| Indicator | Wickra (per tick) | talipp (per tick) |
| Indicator | **★&nbsp;Wickra (per tick)** | talipp (per tick) |
|-----------|---------------------|---------------------------|
| RSI(14) | **0.119 µs ★** | 1.644 µs (13.8× slower) |
@@ -109,20 +109,28 @@ python -m benchmarks.compare_libraries
## Indicators
71 streaming-first indicators across eight families. Every one passes the
214 streaming-first indicators across sixteen families. Every one passes the
`batch == streaming` equivalence test, reference-value tests, and reset
semantics tests.
| Family | Indicators |
|--------|-----------|
| Moving Averages | SMA, EMA, WMA, DEMA, TEMA, HMA, KAMA, SMMA, TRIMA, ZLEMA, T3, VWMA |
| Momentum Oscillators | RSI (Wilder), Stochastic, CCI, ROC, Williams %R, MFI, Awesome Oscillator, MOM, CMO, TSI, PMO, StochRSI, Ultimate Oscillator |
| Trend & Directional | MACD, ADX (+DI/-DI), Aroon, TRIX, Aroon Oscillator, Vortex, Mass Index, Choppiness Index, Vertical Horizontal Filter |
| Price Oscillators | PPO, DPO, Coppock, Accelerator Oscillator, Balance of Power |
| Volatility & Bands | ATR, Bollinger Bands, Keltner Channels, Donchian Channels, NATR, StdDev, Ulcer Index, Historical Volatility, Bollinger Bandwidth, %B, True Range, Chaikin Volatility |
| Trailing Stops | Parabolic SAR, SuperTrend, Chandelier Exit, Chande Kroll Stop, ATR Trailing Stop |
| Volume | OBV, VWAP (cumulative + rolling), ADL, Volume-Price Trend, Chaikin Money Flow, Chaikin Oscillator, Force Index, Ease of Movement |
| Price Statistics | Typical Price, Median Price, Weighted Close, Linear Regression, Linear Regression Slope, Z-Score, Linear Regression Angle |
| Moving Averages | SMA, EMA, WMA, DEMA, TEMA, HMA, KAMA, SMMA, TRIMA, ZLEMA, T3, VWMA, ALMA, McGinley Dynamic, FRAMA, VIDYA, JMA, Alligator, EVWMA |
| Momentum Oscillators | RSI (Wilder), Stochastic, CCI, ROC, Williams %R, MFI, Awesome Oscillator, MOM, CMO, TSI, PMO, StochRSI, Ultimate Oscillator, RVI, PGO, KST, SMI, Laguerre RSI, Connors RSI, Inertia |
| Trend & Directional | MACD, ADX (+DI/-DI), ADXR, Aroon, TRIX, Aroon Oscillator, Vortex, Random Walk Index, Trend Intensity Index, Wave Trend Oscillator, Mass Index, Choppiness Index, Vertical Horizontal Filter |
| Price Oscillators | PPO, DPO, Coppock, Accelerator Oscillator, Balance of Power, APO, AO Histogram, CFO, Zero-Lag MACD, Elder Impulse, STC |
| Volatility & Bands | ATR, Bollinger Bands, Keltner Channels, Donchian Channels, NATR, StdDev, Ulcer Index, Historical Volatility, Bollinger Bandwidth, %B, True Range, Chaikin Volatility, RVI (Relative Volatility Index), Parkinson Volatility, Garman-Klass Volatility, Rogers-Satchell Volatility, Yang-Zhang Volatility |
| Bands & Channels | MA Envelope, Acceleration Bands, STARC Bands, ATR Bands, Hurst Channel, LinReg Channel, Standard Error Bands, Double Bollinger Bands, TTM Squeeze, Fractal Chaos Bands, VWAP StdDev Bands |
| Trailing Stops | Parabolic SAR, SuperTrend, Chandelier Exit, Chande Kroll Stop, ATR Trailing Stop, HiLo Activator, Volty Stop, Yo-Yo Exit, Donchian Channel Stop, Percentage Trailing Stop, Step Trailing Stop, Renko Trailing Stop |
| Volume | OBV, VWAP (cumulative + rolling), ADL, Volume-Price Trend, Chaikin Money Flow, Chaikin Oscillator, Force Index, Ease of Movement, Klinger Volume Oscillator, Volume Oscillator, NVI, PVI, Williams A/D, Anchored VWAP, Demand Index, TSV, VZO, Market Facilitation Index |
| Price Statistics | Typical Price, Median Price, Weighted Close, Linear Regression, Linear Regression Slope, Z-Score, Linear Regression Angle, Variance, Coefficient of Variation, Skewness, Kurtosis, Standard Error, Detrended StdDev, R², Median Absolute Deviation, Autocorrelation, Hurst Exponent, Pearson Correlation, Beta, Spearman Correlation |
| Ehlers / Cycle (DSP) | MAMA, FAMA, Fisher Transform, Inverse Fisher Transform, SuperSmoother, Hilbert Dominant Cycle, Sine Wave, Decycler, Decycler Oscillator, Roofing Filter, Center of Gravity, Cybernetic Cycle, Adaptive Cycle, Empirical Mode Decomposition, Ehlers Stochastic, Instantaneous Trendline |
| Pivots & S/R | Classic Pivots, Fibonacci Pivots, Camarilla, Woodie Pivots, DeMark Pivots, Williams Fractals, ZigZag |
| DeMark | TD Setup, TD Sequential, TD DeMarker, TD REI, TD Pressure, TD Combo, TD Countdown, TD Lines, TD Range Projection, TD Differential, TD Open, TD Risk Level |
| Ichimoku & Charts | Ichimoku Kinko Hyo (Tenkan, Kijun, Senkou A/B, Chikou), Heikin-Ashi |
| Candlestick Patterns | Doji, Hammer, Inverted Hammer, Hanging Man, Shooting Star, Engulfing, Harami, Morning/Evening Star, Three White Soldiers/Black Crows, Piercing Line/Dark Cloud Cover, Marubozu, Tweezer, Spinning Top, Three Inside Up/Down, Three Outside Up/Down |
| Market Profile | Value Area (POC / VAH / VAL), Initial Balance, Opening Range |
| Risk / Performance | Sharpe Ratio, Sortino Ratio, Calmar Ratio, Omega Ratio, Max Drawdown, Average Drawdown, Drawdown Duration, Pain Index, Value at Risk, Conditional Value at Risk (CVaR), Profit Factor, Gain/Loss Ratio, Recovery Factor, Kelly Criterion, Treynor Ratio, Information Ratio, Alpha (Jensen) |
Adding a new indicator means implementing one trait in Rust; all four bindings
inherit it automatically.
@@ -195,7 +203,7 @@ A Python live-trading example using the public `websockets` package lives at
```
wickra/
├── crates/
│ ├── wickra-core/ core engine + all 71 indicators
│ ├── wickra-core/ core engine + all 214 indicators
│ ├── wickra/ top-level facade crate (publishes on crates.io) + benches/
│ └── wickra-data/ CSV reader, tick aggregator, live exchange feeds
├── bindings/
@@ -256,7 +264,7 @@ Every layer is covered; run the suites with the commands in
## Contributing
Contributions are very welcome — issues, bug reports, ideas, and pull requests
all land in the same place: <https://github.com/kingchenc/wickra>.
all land in the same place: <https://github.com/wickra-lib/wickra>.
A short orientation for first-time contributors:
@@ -300,14 +308,14 @@ The library is provided **as is**, without warranty of any kind; see
---
<p align="center">
<a href="https://github.com/kingchenc/wickra/stargazers">
<img alt="GitHub stars" src="https://img.shields.io/github/stars/kingchenc/wickra?style=for-the-badge&logo=github&logoColor=white&color=ffd866">
<a href="https://github.com/wickra-lib/wickra/stargazers">
<img alt="GitHub stars" src="https://img.shields.io/github/stars/wickra-lib/wickra?style=for-the-badge&logo=github&logoColor=white&color=ffd866">
</a>
<a href="https://github.com/kingchenc/wickra/network/members">
<img alt="GitHub forks" src="https://img.shields.io/github/forks/kingchenc/wickra?style=for-the-badge&logo=github&logoColor=white&color=78dce8">
<a href="https://github.com/wickra-lib/wickra/network/members">
<img alt="GitHub forks" src="https://img.shields.io/github/forks/wickra-lib/wickra?style=for-the-badge&logo=github&logoColor=white&color=78dce8">
</a>
<a href="https://github.com/kingchenc/wickra/issues">
<img alt="GitHub issues" src="https://img.shields.io/github/issues/kingchenc/wickra?style=for-the-badge&logo=github&logoColor=white&color=ff6188">
<a href="https://github.com/wickra-lib/wickra/issues">
<img alt="GitHub issues" src="https://img.shields.io/github/issues/wickra-lib/wickra?style=for-the-badge&logo=github&logoColor=white&color=ff6188">
</a>
</p>
+2 -2
View File
@@ -16,9 +16,9 @@ version only; please upgrade to the newest release before reporting an issue.
Report it privately through one of:
- GitHub's [private vulnerability reporting](https://github.com/kingchenc/wickra/security/advisories/new)
- GitHub's [private vulnerability reporting](https://github.com/wickra-lib/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
- email to **wickra.lib@gmail.com** with a subject line starting with
`[wickra security]`.
Please include:
+39 -31
View File
@@ -1,7 +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)
[![CI](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml/badge.svg)](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml)
[![codecov](https://codecov.io/gh/wickra-lib/wickra/branch/main/graph/badge.svg)](https://codecov.io/gh/wickra-lib/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)
@@ -36,16 +36,16 @@ for price in live_feed:
The Python TA ecosystem has plenty of libraries — TA-Lib, pandas-ta, finta,
talipp, tulipy — and every one of them shares the same blind spot:
| Library | Install pain | Streaming | Multi-language | Active |
|--------------------|-----------------|-----------|----------------|--------|
| TA-Lib (Python) | yes (C deps) | no | no | barely |
| pandas-ta | clean | no | no | slow |
| finta | clean | no | no | stale |
| ta-lib-python | yes (C deps) | no | no | barely |
| talipp | clean | yes | no | yes |
| Tulip Indicators | yes (C deps) | no | partial | stale |
| ooples (C#) | clean | no | C# only | yes |
| **Wickra** | **clean** | **yes** | **Python+Node+WASM+Rust** | **yes** |
| Library | Install pain | Streaming | Multi-language | Active |
|------------------------|-----------------|-----------|----------------|--------|
| **★&nbsp;Wickra** | **clean** | **yes** | **Python + Node + WASM + Rust** | **yes** |
| TA-Lib (Python) | yes (C deps) | no | no | barely |
| pandas-ta | clean | no | no | slow |
| finta | clean | no | no | stale |
| ta-lib-python | yes (C deps) | no | no | barely |
| talipp | clean | yes | no | yes |
| Tulip Indicators | yes (C deps) | no | partial | stale |
| ooples (C#) | clean | no | C# only | yes |
Wickra is the only library that combines all of: clean install, streaming,
multi-language reach, and active maintenance.
@@ -58,7 +58,7 @@ 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,
- **Reproduced on:** Windows 11 Pro 26200, AMD Ryzen 9 9950X, 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
@@ -76,7 +76,7 @@ to recompute on every tick.
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 |
| Indicator | **★&nbsp;Wickra** | finta | talipp |
|---------------------|---------------------|-----------------------------|-------------------------------|
| SMA(20) | **95.6 µs ★** | 343.5 µs (3.6× slower) | 7 640.6 µs (79.9× slower) |
| EMA(20) | **64.6 µs ★** | 223.1 µs (3.5× slower) | 12 160.9 µs (188.2× slower) |
@@ -90,7 +90,7 @@ slower it is than Wickra in parentheses. **★** marks the winner per row.
A batch-only library has to re-run its full indicator over the entire history on
every new tick; Wickra updates state in O(1).
| Indicator | Wickra (per tick) | talipp (per tick) |
| Indicator | **★&nbsp;Wickra (per tick)** | talipp (per tick) |
|-----------|---------------------|---------------------------|
| RSI(14) | **0.119 µs ★** | 1.644 µs (13.8× slower) |
@@ -109,20 +109,28 @@ python -m benchmarks.compare_libraries
## Indicators
71 streaming-first indicators across eight families. Every one passes the
214 streaming-first indicators across sixteen families. Every one passes the
`batch == streaming` equivalence test, reference-value tests, and reset
semantics tests.
| Family | Indicators |
|--------|-----------|
| Moving Averages | SMA, EMA, WMA, DEMA, TEMA, HMA, KAMA, SMMA, TRIMA, ZLEMA, T3, VWMA |
| Momentum Oscillators | RSI (Wilder), Stochastic, CCI, ROC, Williams %R, MFI, Awesome Oscillator, MOM, CMO, TSI, PMO, StochRSI, Ultimate Oscillator |
| Trend & Directional | MACD, ADX (+DI/-DI), Aroon, TRIX, Aroon Oscillator, Vortex, Mass Index, Choppiness Index, Vertical Horizontal Filter |
| Price Oscillators | PPO, DPO, Coppock, Accelerator Oscillator, Balance of Power |
| Volatility & Bands | ATR, Bollinger Bands, Keltner Channels, Donchian Channels, NATR, StdDev, Ulcer Index, Historical Volatility, Bollinger Bandwidth, %B, True Range, Chaikin Volatility |
| Trailing Stops | Parabolic SAR, SuperTrend, Chandelier Exit, Chande Kroll Stop, ATR Trailing Stop |
| Volume | OBV, VWAP (cumulative + rolling), ADL, Volume-Price Trend, Chaikin Money Flow, Chaikin Oscillator, Force Index, Ease of Movement |
| Price Statistics | Typical Price, Median Price, Weighted Close, Linear Regression, Linear Regression Slope, Z-Score, Linear Regression Angle |
| Moving Averages | SMA, EMA, WMA, DEMA, TEMA, HMA, KAMA, SMMA, TRIMA, ZLEMA, T3, VWMA, ALMA, McGinley Dynamic, FRAMA, VIDYA, JMA, Alligator, EVWMA |
| Momentum Oscillators | RSI (Wilder), Stochastic, CCI, ROC, Williams %R, MFI, Awesome Oscillator, MOM, CMO, TSI, PMO, StochRSI, Ultimate Oscillator, RVI, PGO, KST, SMI, Laguerre RSI, Connors RSI, Inertia |
| Trend & Directional | MACD, ADX (+DI/-DI), ADXR, Aroon, TRIX, Aroon Oscillator, Vortex, Random Walk Index, Trend Intensity Index, Wave Trend Oscillator, Mass Index, Choppiness Index, Vertical Horizontal Filter |
| Price Oscillators | PPO, DPO, Coppock, Accelerator Oscillator, Balance of Power, APO, AO Histogram, CFO, Zero-Lag MACD, Elder Impulse, STC |
| Volatility & Bands | ATR, Bollinger Bands, Keltner Channels, Donchian Channels, NATR, StdDev, Ulcer Index, Historical Volatility, Bollinger Bandwidth, %B, True Range, Chaikin Volatility, RVI (Relative Volatility Index), Parkinson Volatility, Garman-Klass Volatility, Rogers-Satchell Volatility, Yang-Zhang Volatility, Detrended StdDev |
| Bands & Channels | MA Envelope, Acceleration Bands, STARC Bands, ATR Bands, Hurst Channel, LinReg Channel, Standard Error Bands, Double Bollinger Bands, TTM Squeeze, Fractal Chaos Bands, VWAP StdDev Bands |
| Trailing Stops | Parabolic SAR, SuperTrend, Chandelier Exit, Chande Kroll Stop, ATR Trailing Stop, HiLo Activator, Volty Stop, Yo-Yo Exit, Donchian Channel Stop, Percentage Trailing Stop, Step Trailing Stop, Renko Trailing Stop |
| Volume | OBV, VWAP (cumulative + rolling), ADL, Volume-Price Trend, Chaikin Money Flow, Chaikin Oscillator, Force Index, Ease of Movement, Klinger Volume Oscillator, Volume Oscillator, NVI, PVI, Williams A/D, Anchored VWAP, Demand Index, TSV, VZO, Market Facilitation Index |
| Price Statistics | Typical Price, Median Price, Weighted Close, Linear Regression, Linear Regression Slope, Z-Score, Linear Regression Angle, Variance, Coefficient of Variation, Skewness, Kurtosis, Standard Error, R², Median Absolute Deviation, Autocorrelation, Hurst Exponent, Pearson Correlation, Beta, Spearman Correlation |
| Ehlers / Cycle (DSP) | MAMA, FAMA, Fisher Transform, Inverse Fisher Transform, SuperSmoother, Hilbert Dominant Cycle, Sine Wave, Decycler, Decycler Oscillator, Roofing Filter, Center of Gravity, Cybernetic Cycle, Adaptive Cycle, Empirical Mode Decomposition, Ehlers Stochastic, Instantaneous Trendline |
| Pivots & S/R | Classic Pivots, Fibonacci Pivots, Camarilla, Woodie Pivots, DeMark Pivots, Williams Fractals, ZigZag |
| DeMark | TD Setup, TD Sequential, TD DeMarker, TD REI, TD Pressure, TD Combo, TD Countdown, TD Lines, TD Range Projection, TD Differential, TD Open, TD Risk Level |
| Ichimoku & Charts | Ichimoku Kinko Hyo (Tenkan, Kijun, Senkou A/B, Chikou), Heikin-Ashi |
| Candlestick Patterns | Doji, Hammer, Inverted Hammer, Hanging Man, Shooting Star, Engulfing, Harami, Morning/Evening Star, Three White Soldiers/Black Crows, Piercing Line/Dark Cloud Cover, Marubozu, Tweezer, Spinning Top, Three Inside Up/Down, Three Outside Up/Down |
| Market Profile | Value Area (POC / VAH / VAL), Initial Balance, Opening Range |
| Risk / Performance | Sharpe Ratio, Sortino Ratio, Calmar Ratio, Omega Ratio, Max Drawdown, Average Drawdown, Drawdown Duration, Pain Index, Value at Risk, Conditional Value at Risk (CVaR), Profit Factor, Gain/Loss Ratio, Recovery Factor, Kelly Criterion, Treynor Ratio, Information Ratio, Alpha (Jensen) |
Adding a new indicator means implementing one trait in Rust; all four bindings
inherit it automatically.
@@ -256,7 +264,7 @@ Every layer is covered; run the suites with the commands in
## Contributing
Contributions are very welcome — issues, bug reports, ideas, and pull requests
all land in the same place: <https://github.com/kingchenc/wickra>.
all land in the same place: <https://github.com/wickra-lib/wickra>.
A short orientation for first-time contributors:
@@ -290,14 +298,14 @@ use Wickra commercially, get in touch about a license.
---
<p align="center">
<a href="https://github.com/kingchenc/wickra/stargazers">
<img alt="GitHub stars" src="https://img.shields.io/github/stars/kingchenc/wickra?style=for-the-badge&logo=github&logoColor=white&color=ffd866">
<a href="https://github.com/wickra-lib/wickra/stargazers">
<img alt="GitHub stars" src="https://img.shields.io/github/stars/wickra-lib/wickra?style=for-the-badge&logo=github&logoColor=white&color=ffd866">
</a>
<a href="https://github.com/kingchenc/wickra/network/members">
<img alt="GitHub forks" src="https://img.shields.io/github/forks/kingchenc/wickra?style=for-the-badge&logo=github&logoColor=white&color=78dce8">
<a href="https://github.com/wickra-lib/wickra/network/members">
<img alt="GitHub forks" src="https://img.shields.io/github/forks/wickra-lib/wickra?style=for-the-badge&logo=github&logoColor=white&color=78dce8">
</a>
<a href="https://github.com/kingchenc/wickra/issues">
<img alt="GitHub issues" src="https://img.shields.io/github/issues/kingchenc/wickra?style=for-the-badge&logo=github&logoColor=white&color=ff6188">
<a href="https://github.com/wickra-lib/wickra/issues">
<img alt="GitHub issues" src="https://img.shields.io/github/issues/wickra-lib/wickra?style=for-the-badge&logo=github&logoColor=white&color=ff6188">
</a>
</p>
+557
View File
@@ -17,6 +17,7 @@ const open = close.map((c) => c - 0.5);
function eq(a, b) {
if (Number.isNaN(a)) return Number.isNaN(b);
if (!Number.isFinite(a) || !Number.isFinite(b)) return a === b;
return Math.abs(a - b) < 1e-9;
}
@@ -37,6 +38,11 @@ const scalarFactories = {
ROC: () => new wickra.ROC(12),
TRIX: () => new wickra.TRIX(9),
KAMA: () => new wickra.KAMA(10, 2, 30),
ALMA: () => new wickra.ALMA(9, 0.85, 6.0),
McGinleyDynamic: () => new wickra.McGinleyDynamic(10),
FRAMA: () => new wickra.FRAMA(16),
VIDYA: () => new wickra.VIDYA(14, 9),
JMA: () => new wickra.JMA(14, 0, 2),
SMMA: () => new wickra.SMMA(14),
TRIMA: () => new wickra.TRIMA(20),
ZLEMA: () => new wickra.ZLEMA(14),
@@ -45,8 +51,13 @@ const scalarFactories = {
CMO: () => new wickra.CMO(14),
TSI: () => new wickra.TSI(25, 13),
PMO: () => new wickra.PMO(35, 20),
TII: () => new wickra.TII(20, 10),
StochRSI: () => new wickra.StochRSI(14, 14),
PPO: () => new wickra.PPO(12, 26),
APO: () => new wickra.APO(12, 26),
CFO: () => new wickra.CFO(14),
ElderImpulse: () => new wickra.ElderImpulse(13, 12, 26, 9),
STC: () => new wickra.STC(23, 50, 10, 0.5),
DPO: () => new wickra.DPO(20),
Coppock: () => new wickra.Coppock(14, 11, 10),
StdDev: () => new wickra.StdDev(20),
@@ -59,8 +70,79 @@ const scalarFactories = {
VerticalHorizontalFilter: () => new wickra.VerticalHorizontalFilter(28),
ZScore: () => new wickra.ZScore(20),
LinRegAngle: () => new wickra.LinRegAngle(14),
PercentageTrailingStop: () => new wickra.PercentageTrailingStop(5),
StepTrailingStop: () => new wickra.StepTrailingStop(1),
RenkoTrailingStop: () => new wickra.RenkoTrailingStop(1),
LaguerreRSI: () => new wickra.LaguerreRSI(0.5),
ConnorsRSI: () => new wickra.ConnorsRSI(3, 2, 100),
RVIVolatility: () => new wickra.RVIVolatility(10),
// Family 10 — Ehlers / Cycle
SuperSmoother: () => new wickra.SuperSmoother(10),
FisherTransform: () => new wickra.FisherTransform(10),
InverseFisherTransform: () => new wickra.InverseFisherTransform(1.0),
Decycler: () => new wickra.Decycler(20),
DecyclerOscillator: () => new wickra.DecyclerOscillator(10, 30),
RoofingFilter: () => new wickra.RoofingFilter(10, 48),
CenterOfGravity: () => new wickra.CenterOfGravity(10),
CyberneticCycle: () => new wickra.CyberneticCycle(10),
InstantaneousTrendline: () => new wickra.InstantaneousTrendline(20),
EhlersStochastic: () => new wickra.EhlersStochastic(20),
EmpiricalModeDecomposition: () => new wickra.EmpiricalModeDecomposition(20, 0.5),
HilbertDominantCycle: () => new wickra.HilbertDominantCycle(),
AdaptiveCycle: () => new wickra.AdaptiveCycle(),
SineWave: () => new wickra.SineWave(),
FAMA: () => new wickra.FAMA(0.5, 0.05),
// Family 12 — Statistik / Regression
Variance: () => new wickra.Variance(20),
CoefficientOfVariation: () => new wickra.CoefficientOfVariation(20),
Skewness: () => new wickra.Skewness(20),
Kurtosis: () => new wickra.Kurtosis(20),
StandardError: () => new wickra.StandardError(14),
DetrendedStdDev: () => new wickra.DetrendedStdDev(14),
RSquared: () => new wickra.RSquared(14),
MedianAbsoluteDeviation: () => new wickra.MedianAbsoluteDeviation(20),
Autocorrelation: () => new wickra.Autocorrelation(20, 1),
HurstExponent: () => new wickra.HurstExponent(40, 4),
// Family 15 — Risk / Performance metrics (scalar f64 input).
SharpeRatio: () => new wickra.SharpeRatio(20, 0),
SortinoRatio: () => new wickra.SortinoRatio(20, 0),
CalmarRatio: () => new wickra.CalmarRatio(20),
OmegaRatio: () => new wickra.OmegaRatio(20, 0),
MaxDrawdown: () => new wickra.MaxDrawdown(20),
AverageDrawdown: () => new wickra.AverageDrawdown(20),
DrawdownDuration: () => new wickra.DrawdownDuration(),
PainIndex: () => new wickra.PainIndex(20),
ValueAtRisk: () => new wickra.ValueAtRisk(20, 0.95),
ConditionalValueAtRisk: () => new wickra.ConditionalValueAtRisk(20, 0.95),
ProfitFactor: () => new wickra.ProfitFactor(20),
GainLossRatio: () => new wickra.GainLossRatio(20),
RecoveryFactor: () => new wickra.RecoveryFactor(),
KellyCriterion: () => new wickra.KellyCriterion(20),
};
// --- Two-series (asset, benchmark) ratio indicators ---
const ratioPairFactories = {
TreynorRatio: () => new wickra.TreynorRatio(20, 0),
InformationRatio: () => new wickra.InformationRatio(20),
Alpha: () => new wickra.Alpha(20, 0),
};
const asset = Array.from({ length: N }, (_, i) => 0.001 + Math.sin(i * 0.15) * 0.01);
const bench = Array.from({ length: N }, (_, i) => 0.001 + Math.sin(i * 0.15) * 0.007);
for (const [name, make] of Object.entries(ratioPairFactories)) {
test(`${name}: streaming update matches batch (pair)`, () => {
const batch = make().batch(asset, bench);
const streaming = make();
assert.equal(batch.length, N);
for (let i = 0; i < N; i++) {
const s = num(streaming.update(asset[i], bench[i]));
assert.ok(eq(s, batch[i]), `${name} mismatch at ${i}: ${s} vs ${batch[i]}`);
}
});
}
for (const [name, make] of Object.entries(scalarFactories)) {
test(`${name}: streaming update matches batch`, () => {
const batch = make().batch(close);
@@ -86,6 +168,11 @@ const candleScalar = {
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) },
RVI: { make: () => new wickra.RVI(10), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
Inertia: { make: () => new wickra.Inertia(14, 20), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
PGO: { make: () => new wickra.PGO(14), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
SMI: { make: () => new wickra.SMI(5, 3, 3), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
EVWMA: { make: () => new wickra.EVWMA(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) },
@@ -96,15 +183,58 @@ const candleScalar = {
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) },
KVO: { make: () => new wickra.KVO(34, 55), step: (ind, i) => ind.update(high[i], low[i], close[i], volume[i]), batch: (ind) => ind.batch(high, low, close, volume) },
VolumeOscillator: { make: () => new wickra.VolumeOscillator(14, 28), step: (ind, i) => ind.update(volume[i]), batch: (ind) => ind.batch(volume) },
NVI: { make: () => new wickra.NVI(), step: (ind, i) => ind.update(close[i], volume[i]), batch: (ind) => ind.batch(close, volume) },
PVI: { make: () => new wickra.PVI(), step: (ind, i) => ind.update(close[i], volume[i]), batch: (ind) => ind.batch(close, volume) },
WilliamsAD: { make: () => new wickra.WilliamsAD(), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
AnchoredVWAP: { make: () => new wickra.AnchoredVWAP(), step: (ind, i) => ind.update(high[i], low[i], close[i], volume[i]), batch: (ind) => ind.batch(high, low, close, volume) },
DemandIndex: { make: () => new wickra.DemandIndex(10), step: (ind, i) => ind.update(high[i], low[i], close[i], volume[i]), batch: (ind) => ind.batch(high, low, close, volume) },
TSV: { make: () => new wickra.TSV(18), step: (ind, i) => ind.update(close[i], volume[i]), batch: (ind) => ind.batch(close, volume) },
VZO: { make: () => new wickra.VZO(14), step: (ind, i) => ind.update(close[i], volume[i]), batch: (ind) => ind.batch(close, volume) },
MarketFacilitationIndex: { make: () => new wickra.MarketFacilitationIndex(), 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) },
HiLoActivator: { make: () => new wickra.HiLoActivator(3), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
VoltyStop: { make: () => new wickra.VoltyStop(14, 2), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
YoyoExit: { make: () => new wickra.YoyoExit(14, 2), 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) },
AwesomeOscillatorHistogram: { make: () => new wickra.AwesomeOscillatorHistogram(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) },
ADXR: { make: () => new wickra.ADXR(7), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
ParkinsonVolatility: { make: () => new wickra.ParkinsonVolatility(20, 252), step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
GarmanKlassVolatility: { make: () => new wickra.GarmanKlassVolatility(20, 252), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
RogersSatchellVolatility: { make: () => new wickra.RogersSatchellVolatility(20, 252), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
YangZhangVolatility: { make: () => new wickra.YangZhangVolatility(20, 252), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
TDSetup: { make: () => new wickra.TDSetup(4, 9), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
TDDeMarker: { make: () => new wickra.TDDeMarker(14), step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
TDREI: { make: () => new wickra.TDREI(5), step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
TDPressure: { make: () => new wickra.TDPressure(5), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i], volume[i]), batch: (ind) => ind.batch(open, high, low, close, volume) },
TDCombo: { make: () => new wickra.TDCombo(4, 9, 2, 13), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
TDCountdown: { make: () => new wickra.TDCountdown(4, 9, 2, 13), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
TDDifferential: { make: () => new wickra.TDDifferential(), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
TDOpen: { make: () => new wickra.TDOpen(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
// Family 14 — Candlestick patterns
Doji: { make: () => new wickra.Doji(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
Hammer: { make: () => new wickra.Hammer(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
InvertedHammer: { make: () => new wickra.InvertedHammer(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
HangingMan: { make: () => new wickra.HangingMan(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
ShootingStar: { make: () => new wickra.ShootingStar(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
Engulfing: { make: () => new wickra.Engulfing(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
Harami: { make: () => new wickra.Harami(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
MorningEveningStar: { make: () => new wickra.MorningEveningStar(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
ThreeSoldiersOrCrows: { make: () => new wickra.ThreeSoldiersOrCrows(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
PiercingDarkCloud: { make: () => new wickra.PiercingDarkCloud(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
Marubozu: { make: () => new wickra.Marubozu(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
Tweezer: { make: () => new wickra.Tweezer(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
SpinningTop: { make: () => new wickra.SpinningTop(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
ThreeInside: { make: () => new wickra.ThreeInside(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
ThreeOutside: { make: () => new wickra.ThreeOutside(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
};
for (const [name, d] of Object.entries(candleScalar)) {
@@ -122,7 +252,11 @@ for (const [name, d] of Object.entries(candleScalar)) {
// --- Multi-output indicators: object update vs interleaved batch ---
const multi = {
KST: { make: () => new wickra.KST(10, 15, 20, 30, 10, 10, 10, 15, 9), fields: ['kst', 'signal'], step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
Alligator: { make: () => new wickra.Alligator(13, 8, 5), fields: ['jaw', 'teeth', 'lips'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
ZeroLagMACD: { make: () => new wickra.ZeroLagMACD(12, 26, 9), fields: ['macd', 'signal', 'histogram'], step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
MACD: { make: () => new wickra.MACD(12, 26, 9), fields: ['macd', 'signal', 'histogram'], step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
KST: { make: () => wickra.KST.classic(), fields: ['kst', 'signal'], 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) },
@@ -130,9 +264,46 @@ const multi = {
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) },
RWI: { make: () => new wickra.RWI(14), fields: ['high', 'low'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
WaveTrend: { make: () => wickra.WaveTrend.classic(), fields: ['wt1', 'wt2'], 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) },
// Family 16: Market Profile
ValueArea: { make: () => new wickra.ValueArea(20, 50, 0.70), fields: ['poc', 'vah', 'val'], step: (ind, i) => ind.update(high[i], low[i], volume[i]), batch: (ind) => ind.batch(high, low, volume) },
InitialBalance: { make: () => new wickra.InitialBalance(12), fields: ['high', 'low'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
OpeningRange: { make: () => new wickra.OpeningRange(6), fields: ['high', 'low', 'breakoutDistance'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
DonchianStop: { make: () => new wickra.DonchianStop(10), fields: ['stopLong', 'stopShort'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
// Family 05: bands & channels
MaEnvelope: { make: () => new wickra.MaEnvelope(20, 0.025), fields: ['upper', 'middle', 'lower'], step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
AccelerationBands: { make: () => new wickra.AccelerationBands(20, 0.001), fields: ['upper', 'middle', 'lower'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
StarcBands: { make: () => new wickra.StarcBands(6, 15, 2), fields: ['upper', 'middle', 'lower'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
AtrBands: { make: () => new wickra.AtrBands(14, 3), fields: ['upper', 'middle', 'lower'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
HurstChannel: { make: () => new wickra.HurstChannel(10, 0.5), fields: ['upper', 'middle', 'lower'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
LinRegChannel: { make: () => new wickra.LinRegChannel(20, 2), fields: ['upper', 'middle', 'lower'], step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
StandardErrorBands: { make: () => new wickra.StandardErrorBands(21, 2), fields: ['upper', 'middle', 'lower'], step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
DoubleBollinger: { make: () => new wickra.DoubleBollinger(20, 1, 2), fields: ['upperOuter', 'upperInner', 'middle', 'lowerInner', 'lowerOuter'], step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
TtmSqueeze: { make: () => new wickra.TtmSqueeze(20, 2, 1.5), fields: ['squeeze', 'momentum'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
FractalChaosBands: { make: () => new wickra.FractalChaosBands(2), fields: ['upper', 'lower'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
VwapStdDevBands: { make: () => new wickra.VwapStdDevBands(2), fields: ['upper', 'middle', 'lower', 'stddev'], step: (ind, i) => ind.update(high[i], low[i], close[i], volume[i]), batch: (ind) => ind.batch(high, low, close, volume) },
// Family 08: Pivots & Support/Resistance
ClassicPivots: { make: () => new wickra.ClassicPivots(), fields: ['pp', 'r1', 'r2', 'r3', 's1', 's2', 's3'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
FibonacciPivots: { make: () => new wickra.FibonacciPivots(), fields: ['pp', 'r1', 'r2', 'r3', 's1', 's2', 's3'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
Camarilla: { make: () => new wickra.Camarilla(), fields: ['pp', 'r1', 'r2', 'r3', 'r4', 's1', 's2', 's3', 's4'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
WoodiePivots: { make: () => new wickra.WoodiePivots(), fields: ['pp', 'r1', 'r2', 's1', 's2'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
DemarkPivots: { make: () => new wickra.DemarkPivots(), fields: ['pp', 'r1', 's1'], step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
WilliamsFractals: { make: () => new wickra.WilliamsFractals(), fields: ['up', 'down'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
ZigZag: { make: () => new wickra.ZigZag(0.02), fields: ['swing', 'direction'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
// Family 11: DeMark
TDSequential: { make: () => new wickra.TDSequential(4, 9, 2, 13), fields: ['setup', 'countdown', 'direction'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
TDLines: { make: () => new wickra.TDLines(4, 9), fields: ['resistance', 'support'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
TDRangeProjection: { make: () => new wickra.TDRangeProjection(), fields: ['high', 'low'], step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
TDRiskLevel: { make: () => new wickra.TDRiskLevel(4, 9), fields: ['buyRisk', 'sellRisk'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
// Family 10: Ehlers / Cycle (multi-output)
MAMA: { make: () => new wickra.MAMA(0.5, 0.05), fields: ['mama', 'fama'], step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
// Family 13: Ichimoku & alternative charts
Ichimoku: { make: () => new wickra.Ichimoku(9, 26, 52, 26), fields: ['tenkan', 'kijun', 'senkouA', 'senkouB', 'chikou'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
HeikinAshi: { make: () => new wickra.HeikinAshi(), fields: ['open', 'high', 'low', 'close'], step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
};
for (const [name, d] of Object.entries(multi)) {
@@ -258,3 +429,389 @@ 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);
});
test('InitialBalance(2) locks after period and ignores subsequent bars', () => {
const ib = new wickra.InitialBalance(2);
let v = ib.update(102, 100);
assert.equal(v.high, 102);
assert.equal(v.low, 100);
v = ib.update(103, 99);
assert.equal(v.high, 103);
assert.equal(v.low, 99);
assert.equal(ib.isLocked(), true);
// Extreme bar after lock must not modify the IB.
v = ib.update(200, 50);
assert.equal(v.high, 103);
assert.equal(v.low, 99);
});
test('OpeningRange(2) breakout distance is signed close minus midpoint', () => {
const or = new wickra.OpeningRange(2);
or.update(102, 100, 101);
or.update(103, 101, 102);
// OR locked at high 103 / low 100 / mid 101.5. Close 105 -> +3.5.
const v = or.update(110, 102, 105);
assert.equal(v.high, 103);
assert.equal(v.low, 100);
assert.ok(Math.abs(v.breakoutDistance - 3.5) < 1e-9);
});
// --- Family 12: two-series indicators (Pearson / Beta / Spearman) ---
const pairFactories = {
PearsonCorrelation: () => new wickra.PearsonCorrelation(14),
Beta: () => new wickra.Beta(14),
SpearmanCorrelation: () => new wickra.SpearmanCorrelation(14),
};
for (const [name, make] of Object.entries(pairFactories)) {
test(`${name}: streaming update matches batch over a pair of series`, () => {
const xs = Array.from({ length: N }, (_, i) => Math.sin(i * 0.2) + 0.05 * i);
const ys = Array.from({ length: N }, (_, i) => Math.cos(i * 0.3) + 0.02 * i);
const batch = make().batch(xs, ys);
const streaming = make();
assert.equal(batch.length, N);
for (let i = 0; i < N; i++) {
const s = num(streaming.update(xs[i], ys[i]));
assert.ok(eq(s, batch[i]), `${name} mismatch at ${i}: ${s} vs ${batch[i]}`);
}
});
}
test('PearsonCorrelation perfect positive is 1', () => {
const x = Array.from({ length: 10 }, (_, i) => i);
const y = x.map((v) => 2 * v + 3);
const out = new wickra.PearsonCorrelation(5).batch(x, y);
assert.ok(Math.abs(out[out.length - 1] - 1) < 1e-9);
});
test('Beta perfect two-to-one', () => {
const bench = Array.from({ length: 10 }, (_, i) => i);
const asset = bench.map((v) => 2 * v);
const out = new wickra.Beta(5).batch(asset, bench);
assert.ok(Math.abs(out[out.length - 1] - 2) < 1e-9);
});
test('SpearmanCorrelation monotone non-linear is 1', () => {
const x = Array.from({ length: 10 }, (_, i) => i + 1);
const y = x.map((v) => v ** 3);
const out = new wickra.SpearmanCorrelation(5).batch(x, y);
assert.ok(Math.abs(out[out.length - 1] - 1) < 1e-9);
});
test('Variance(3) of [2, 4, 6] equals 8/3', () => {
const out = new wickra.Variance(3).batch([2, 4, 6]);
assert.ok(Math.abs(out[2] - 8 / 3) < 1e-12);
});
test('RSquared on a perfect line is 1', () => {
const xs = Array.from({ length: 20 }, (_, i) => 2 * i + 5);
const out = new wickra.RSquared(5).batch(xs);
for (let i = 5; i < out.length; i++) {
assert.ok(Math.abs(out[i] - 1) < 1e-9);
}
});
test('MedianAbsoluteDeviation ignores a single huge outlier', () => {
const xs = Array(9).fill(5).concat([1000]);
const out = new wickra.MedianAbsoluteDeviation(10).batch(xs);
assert.ok(Math.abs(out[9]) < 1e-12);
});
test('Autocorrelation of an alternating series is strongly negative at lag 1', () => {
const xs = Array.from({ length: 20 }, (_, i) => (i % 2 === 0 ? -1 : 1));
const out = new wickra.Autocorrelation(10, 1).batch(xs);
assert.ok(out[out.length - 1] < -0.5);
});
test('HurstExponent of a monotone ramp is above 0.5', () => {
const xs = Array.from({ length: 200 }, (_, i) => i);
const out = new wickra.HurstExponent(100, 4).batch(xs);
assert.ok(out[out.length - 1] > 0.5);
});
test('Ichimoku classic warmup is 77 and tenkan emits at bar 9', () => {
const ichi = new wickra.Ichimoku(9, 26, 52, 26);
assert.equal(ichi.warmupPeriod(), 77);
const n = 30;
const h = Array.from({ length: n }, (_, i) => 100 + i + 2);
const l = Array.from({ length: n }, (_, i) => 100 + i - 2);
const c = Array.from({ length: n }, (_, i) => 100 + i + 1);
const out = ichi.batch(h, l, c);
for (let i = 0; i < 8; i++) {
assert.ok(Number.isNaN(out[i * 5]), `tenkan should be NaN at bar ${i}`);
}
assert.ok(!Number.isNaN(out[8 * 5]), 'tenkan should be defined at bar 9');
});
test('HeikinAshi first bar seeds from real open and close', () => {
const ha = new wickra.HeikinAshi();
const out = ha.update(10, 12, 9, 11);
assert.ok(Math.abs(out.open - (10 + 11) / 2) < 1e-12);
assert.ok(Math.abs(out.close - (10 + 12 + 9 + 11) / 4) < 1e-12);
});
test('PercentageTrailingStop seeds and ratchets', () => {
const s = new wickra.PercentageTrailingStop(10);
assert.ok(Math.abs(s.update(100) - 90) < 1e-9);
assert.ok(Math.abs(s.update(110) - 99) < 1e-9);
});
test('RenkoTrailingStop only advances after a full block', () => {
const s = new wickra.RenkoTrailingStop(1);
assert.ok(Math.abs(s.update(100) - 99) < 1e-9);
assert.ok(Math.abs(s.update(100.5) - 99) < 1e-9);
assert.ok(Math.abs(s.update(101) - 100) < 1e-9);
});
test('DonchianStop window extremes', () => {
const out = new wickra.DonchianStop(5).batch([1, 2, 3, 4, 5], [0, 1, 2, 3, 4]);
// [long0, short0, long1, short1, ...]: idx 8 (=4*2) = long_5th, idx 9 = short_5th.
assert.ok(Math.abs(out[8] - 0) < 1e-9);
assert.ok(Math.abs(out[9] - 5) < 1e-9);
});
test('MaEnvelope reference values', () => {
// SMA([10, 20, 30]) = 20; with percent 0.10: upper=22, lower=18.
const out = new wickra.MaEnvelope(3, 0.10).batch([10, 20, 30]);
assert.ok(Number.isNaN(out[0]) && Number.isNaN(out[3]));
assert.ok(Math.abs(out[2 * 3 + 0] - 22) < 1e-9); // upper
assert.ok(Math.abs(out[2 * 3 + 1] - 20) < 1e-9); // middle
assert.ok(Math.abs(out[2 * 3 + 2] - 18) < 1e-9); // lower
});
test('AccelerationBands single-bar reference', () => {
// high=12, low=8, close=10, factor=0.5, period=1.
// ratio=0.2, raw_up=13.2, raw_lo=7.2.
const v = new wickra.AccelerationBands(1, 0.5).update(12, 8, 10);
assert.ok(Math.abs(v.upper - 13.2) < 1e-9);
assert.ok(Math.abs(v.middle - 10) < 1e-9);
assert.ok(Math.abs(v.lower - 7.2) < 1e-9);
});
test('LinRegChannel reference values for [1, 2, 9]', () => {
// Line y=4x, endpoint=8, residuals=[1,-2,1], sigma=sqrt(2).
const out = new wickra.LinRegChannel(3, 2).batch([1, 2, 9]);
const s = Math.sqrt(2);
const i = 2;
assert.ok(Math.abs(out[i * 3 + 0] - (8 + 2 * s)) < 1e-9);
assert.ok(Math.abs(out[i * 3 + 1] - 8) < 1e-9);
assert.ok(Math.abs(out[i * 3 + 2] - (8 - 2 * s)) < 1e-9);
});
test('VwapStdDevBands two-bar reference', () => {
const v = new wickra.VwapStdDevBands(1.5);
v.update(8, 8, 8, 1);
const o = v.update(12, 12, 12, 1);
assert.ok(Math.abs(o.upper - 13) < 1e-9);
assert.ok(Math.abs(o.middle - 10) < 1e-9);
assert.ok(Math.abs(o.lower - 7) < 1e-9);
assert.ok(Math.abs(o.stddev - 2) < 1e-9);
});
test('RVIVolatility pure uptrend saturates at 100', () => {
const prices = Array.from({ length: 40 }, (_, i) => i + 1);
const out = new wickra.RVIVolatility(5).batch(prices);
for (let i = 9; i < out.length; i++) {
assert.ok(Math.abs(out[i] - 100) < 1e-9, `RVIVolatility[${i}] = ${out[i]}`);
}
});
test('ParkinsonVolatility zero-range bars yield zero', () => {
const n = 30;
const h = Array(n).fill(10);
const l = Array(n).fill(10);
const out = new wickra.ParkinsonVolatility(14, 252).batch(h, l);
for (let i = 13; i < n; i++) {
assert.ok(Math.abs(out[i]) < 1e-12, `Parkinson[${i}] = ${out[i]}`);
}
});
test('GarmanKlassVolatility zero-movement bars yield zero', () => {
const n = 30;
const flat = Array(n).fill(10);
const out = new wickra.GarmanKlassVolatility(14, 252).batch(flat, flat, flat, flat);
for (let i = 13; i < n; i++) {
assert.ok(Math.abs(out[i]) < 1e-12, `GK[${i}] = ${out[i]}`);
}
});
test('RogersSatchellVolatility zero-movement bars yield zero', () => {
const n = 30;
const flat = Array(n).fill(10);
const out = new wickra.RogersSatchellVolatility(14, 252).batch(flat, flat, flat, flat);
for (let i = 13; i < n; i++) {
assert.ok(Math.abs(out[i]) < 1e-12, `RS[${i}] = ${out[i]}`);
}
});
test('YangZhangVolatility zero-movement bars yield zero', () => {
const n = 30;
const flat = Array(n).fill(10);
const out = new wickra.YangZhangVolatility(14, 252).batch(flat, flat, flat, flat);
for (let i = 14; i < n; i++) {
assert.ok(Math.abs(out[i]) < 1e-12, `YZ[${i}] = ${out[i]}`);
}
});
test('ZeroLagMACD on a flat series converges to zero', () => {
const out = new wickra.ZeroLagMACD(3, 5, 3).batch(Array(60).fill(42));
// Last interleaved row: macd, signal, histogram all 0.
const n = 60;
assert.ok(Math.abs(out[(n - 1) * 3]) < 1e-12);
assert.ok(Math.abs(out[(n - 1) * 3 + 1]) < 1e-12);
assert.ok(Math.abs(out[(n - 1) * 3 + 2]) < 1e-12);
});
test('AwesomeOscillatorHistogram on a flat median converges to zero', () => {
const n = 50;
const out = new wickra.AwesomeOscillatorHistogram(3, 5, 3).batch(
Array(n).fill(11),
Array(n).fill(9),
);
// warmup = 5 + 3 - 1 = 7.
for (let i = 6; i < n; i++) assert.ok(Math.abs(out[i]) < 1e-12);
});
test('STC on a flat series stays at zero', () => {
const out = new wickra.STC(3, 5, 4, 0.5).batch(Array(60).fill(42));
// Latest values must be exactly zero.
for (let i = out.length - 5; i < out.length; i++) {
if (Number.isNaN(out[i])) continue;
assert.equal(out[i], 0);
}
});
test('ElderImpulse on a flat series stays neutral (0)', () => {
const out = new wickra.ElderImpulse(13, 12, 26, 9).batch(Array(120).fill(42));
for (let i = 0; i < out.length; i++) {
if (Number.isNaN(out[i])) continue;
assert.equal(out[i], 0);
}
});
test('CFO(5) on a perfectly linear series yields zero', () => {
const prices = Array.from({ length: 20 }, (_, i) => (i + 1) * 2);
const out = new wickra.CFO(5).batch(prices);
for (let i = 4; i < 20; i++) assert.ok(Math.abs(out[i]) < 1e-9);
});
test('APO(3, 5) on a flat series converges to zero', () => {
const out = new wickra.APO(3, 5).batch(Array(30).fill(42));
for (let i = 0; i < 4; i++) assert.ok(Number.isNaN(out[i]));
for (let i = 4; i < 30; i++) assert.ok(Math.abs(out[i]) < 1e-12);
});
test('Inertia(3, 4) on a constant RVI series equals that RVI', () => {
const n = 60;
// Every bar (open, high, low, close) = (10, 11, 9, 10.5) -> RVI = 0.25.
const out = new wickra.Inertia(3, 4).batch(
Array(n).fill(10),
Array(n).fill(11),
Array(n).fill(9),
Array(n).fill(10.5),
);
for (let i = 5; i < n; i++) assert.ok(Math.abs(out[i] - 0.25) < 1e-12);
});
test('ConnorsRSI stays bounded in [0, 100]', () => {
const prices = Array.from({ length: 250 }, (_, i) => 100 + 20 * Math.sin(i * 0.12));
const out = new wickra.ConnorsRSI(3, 2, 100).batch(prices);
for (let i = 0; i < out.length; i++) {
if (Number.isNaN(out[i])) continue;
assert.ok(out[i] >= 0 && out[i] <= 100, `out[${i}] = ${out[i]}`);
}
});
test('LaguerreRSI on a flat series stays at the neutral 50', () => {
const out = new wickra.LaguerreRSI(0.5).batch(Array(40).fill(42));
for (let i = 0; i < out.length; i++) assert.ok(Math.abs(out[i] - 50) < 1e-12);
});
test('SMI with close at range centre emits zero after warmup', () => {
const n = 60;
const out = new wickra.SMI(5, 3, 3).batch(Array(n).fill(11), Array(n).fill(9), Array(n).fill(10));
// warmup_period = 5 + 3 + 3 - 2 = 9.
for (let i = 8; i < n; i++) assert.ok(Math.abs(out[i]) < 1e-12);
});
test('KST on a flat series emits zero after warmup', () => {
const kst = new wickra.KST(10, 15, 20, 30, 10, 10, 10, 15, 9);
const n = 80;
const out = kst.batch(Array(n).fill(42));
const warmup = kst.warmupPeriod();
for (let i = warmup - 1; i < n; i++) {
assert.ok(Math.abs(out[i * 2]) < 1e-12, `kst[${i}] = ${out[i * 2]}`);
assert.ok(Math.abs(out[i * 2 + 1]) < 1e-12, `signal[${i}] = ${out[i * 2 + 1]}`);
}
});
test('PGO(5) on a flat close emits zero after warmup', () => {
const n = 20;
const out = new wickra.PGO(5).batch(Array(n).fill(11), Array(n).fill(9), Array(n).fill(10));
for (let i = 0; i < 4; i++) assert.ok(Number.isNaN(out[i]));
for (let i = 4; i < n; i++) assert.ok(Math.abs(out[i]) < 1e-12, `out[${i}] = ${out[i]}`);
});
test('RVI(2) reference value on two bars', () => {
// Bars (open, high, low, close): (10, 11, 9, 10.5), (10.5, 11.5, 10, 11).
const out = new wickra.RVI(2).batch([10, 10.5], [11, 11.5], [9, 10], [10.5, 11]);
assert.ok(Number.isNaN(out[0]));
assert.ok(Math.abs(out[1] - 1 / 3.5) < 1e-12);
});
test('EVWMA(2) reference values on [10, 20, 30] with volumes [1, 3, 1]', () => {
const out = new wickra.EVWMA(2).batch([10, 20, 30], [1, 3, 1]);
assert.ok(Number.isNaN(out[0]));
assert.ok(Math.abs(out[1] - 20) < 1e-12);
assert.ok(Math.abs(out[2] - 22.5) < 1e-12);
});
test('Alligator on a flat median price seeds to that median', () => {
const n = 30;
const out = new wickra.Alligator(13, 8, 5).batch(Array(n).fill(11), Array(n).fill(9));
// All three SMMAs see median (11 + 9) / 2 = 10 every bar.
for (let i = 12; i < n; i++) {
assert.ok(Math.abs(out[i * 3] - 10) < 1e-12, `jaw at ${i}: ${out[i * 3]}`);
assert.ok(Math.abs(out[i * 3 + 1] - 10) < 1e-12);
assert.ok(Math.abs(out[i * 3 + 2] - 10) < 1e-12);
}
});
test('JMA on a flat series reproduces the constant', () => {
const out = new wickra.JMA(14, 0, 2).batch(Array(30).fill(42));
for (let i = 0; i < 30; i++) assert.ok(Math.abs(out[i] - 42) < 1e-12);
});
test('VIDYA on a flat series holds the seed', () => {
const out = new wickra.VIDYA(14, 4).batch(Array(20).fill(42));
for (let i = 0; i < 4; i++) assert.ok(Number.isNaN(out[i]));
for (let i = 4; i < 20; i++) assert.ok(Math.abs(out[i] - 42) < 1e-12);
});
test('FRAMA pure uptrend hugs the latest close', () => {
const out = new wickra.FRAMA(4).batch([1, 2, 3, 4, 5, 6, 7, 8]);
assert.ok(Math.abs(out[out.length - 1] - 8) < 0.05);
});
test('McGinleyDynamic(3) seeds with SMA and recurses on the next price', () => {
// Seed = SMA([10, 20, 30]) = 20. On 40: ratio = 2, divisor = 0.6*3*16 = 28.8.
const out = new wickra.McGinleyDynamic(3).batch([10, 20, 30, 40]);
assert.ok(Number.isNaN(out[0]) && Number.isNaN(out[1]));
assert.ok(Math.abs(out[2] - 20) < 1e-12);
const expected = 20 + 20 / (0.6 * 3 * 16);
assert.ok(Math.abs(out[3] - expected) < 1e-12);
});
test('ALMA(3, 0.85, 6) reference value on [10, 20, 30]', () => {
// m = 0.85 * 2 = 1.7; s = 3 / 6 = 0.5; 2*s^2 = 0.5.
const out = new wickra.ALMA(3, 0.85, 6).batch([10, 20, 30]);
assert.ok(Number.isNaN(out[0]) && Number.isNaN(out[1]));
const w = [0, 1, 2].map((i) => Math.exp(-Math.pow(i - 1.7, 2) / 0.5));
const s = w[0] + w[1] + w[2];
const expected = (10 * w[0] + 20 * w[1] + 30 * w[2]) / s;
assert.ok(Math.abs(out[2] - expected) < 1e-12);
// The heavy offset toward the newest sample lifts the average above the
// simple mean of 20.
assert.ok(out[2] > 20);
});
+144 -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, 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
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, ADXR, CCI, WilliamsR, MFI, PSAR, Keltner, Donchian, VWAP, RollingVWAP, AwesomeOscillator, Aroon, KAMA, RVI, PGO, KST, SMI, LaguerreRSI, ConnorsRSI, Inertia, ALMA, McGinleyDynamic, FRAMA, VIDYA, JMA, Alligator, EVWMA, APO, AwesomeOscillatorHistogram, CFO, ZeroLagMACD, ElderImpulse, STC, T3, TSI, PMO, TII, ADL, VolumePriceTrend, ChaikinMoneyFlow, ChaikinOscillator, ForceIndex, EaseOfMovement, KVO, VolumeOscillator, NVI, PVI, WilliamsAD, AnchoredVWAP, DemandIndex, TSV, VZO, MarketFacilitationIndex, SuperTrend, ChandelierExit, ChandeKrollStop, AtrTrailingStop, HiLoActivator, VoltyStop, YoyoExit, DonchianStop, PercentageTrailingStop, StepTrailingStop, RenkoTrailingStop, TypicalPrice, MedianPrice, WeightedClose, LinearRegression, LinRegSlope, AcceleratorOscillator, BalanceOfPower, ChoppinessIndex, TrueRange, ChaikinVolatility, LinRegAngle, BollingerBandwidth, PercentB, NATR, HistoricalVolatility, AroonOscillator, Vortex, RWI, WaveTrend, MassIndex, StochRSI, UltimateOscillator, PPO, Coppock, VWMA, RVIVolatility, ParkinsonVolatility, GarmanKlassVolatility, RogersSatchellVolatility, YangZhangVolatility, MaEnvelope, AccelerationBands, StarcBands, AtrBands, HurstChannel, LinRegChannel, StandardErrorBands, DoubleBollinger, TtmSqueeze, FractalChaosBands, VwapStdDevBands, ClassicPivots, FibonacciPivots, Camarilla, WoodiePivots, DemarkPivots, WilliamsFractals, ZigZag, TDSetup, TDSequential, TDDeMarker, TDREI, TDPressure, TDCombo, TDCountdown, TDLines, TDRangeProjection, TDDifferential, TDOpen, TDRiskLevel, SuperSmoother, FisherTransform, InverseFisherTransform, Decycler, DecyclerOscillator, RoofingFilter, CenterOfGravity, CyberneticCycle, InstantaneousTrendline, EhlersStochastic, EmpiricalModeDecomposition, HilbertDominantCycle, AdaptiveCycle, SineWave, MAMA, FAMA, Ichimoku, HeikinAshi, Variance, CoefficientOfVariation, Skewness, Kurtosis, StandardError, DetrendedStdDev, RSquared, MedianAbsoluteDeviation, Autocorrelation, HurstExponent, PearsonCorrelation, Beta, SpearmanCorrelation, ValueArea, InitialBalance, OpeningRange, Doji, Hammer, InvertedHammer, HangingMan, ShootingStar, Engulfing, Harami, MorningEveningStar, ThreeSoldiersOrCrows, PiercingDarkCloud, Marubozu, Tweezer, SpinningTop, ThreeInside, ThreeOutside, SharpeRatio, SortinoRatio, CalmarRatio, OmegaRatio, MaxDrawdown, AverageDrawdown, DrawdownDuration, PainIndex, ValueAtRisk, ConditionalValueAtRisk, ProfitFactor, GainLossRatio, RecoveryFactor, KellyCriterion, TreynorRatio, InformationRatio, Alpha } = nativeBinding
module.exports.version = version
module.exports.SMA = SMA
@@ -338,6 +338,7 @@ module.exports.ATR = ATR
module.exports.Stochastic = Stochastic
module.exports.OBV = OBV
module.exports.ADX = ADX
module.exports.ADXR = ADXR
module.exports.CCI = CCI
module.exports.WilliamsR = WilliamsR
module.exports.MFI = MFI
@@ -349,19 +350,57 @@ module.exports.RollingVWAP = RollingVWAP
module.exports.AwesomeOscillator = AwesomeOscillator
module.exports.Aroon = Aroon
module.exports.KAMA = KAMA
module.exports.RVI = RVI
module.exports.PGO = PGO
module.exports.KST = KST
module.exports.SMI = SMI
module.exports.LaguerreRSI = LaguerreRSI
module.exports.ConnorsRSI = ConnorsRSI
module.exports.Inertia = Inertia
module.exports.ALMA = ALMA
module.exports.McGinleyDynamic = McGinleyDynamic
module.exports.FRAMA = FRAMA
module.exports.VIDYA = VIDYA
module.exports.JMA = JMA
module.exports.Alligator = Alligator
module.exports.EVWMA = EVWMA
module.exports.APO = APO
module.exports.AwesomeOscillatorHistogram = AwesomeOscillatorHistogram
module.exports.CFO = CFO
module.exports.ZeroLagMACD = ZeroLagMACD
module.exports.ElderImpulse = ElderImpulse
module.exports.STC = STC
module.exports.T3 = T3
module.exports.TSI = TSI
module.exports.PMO = PMO
module.exports.TII = TII
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.KVO = KVO
module.exports.VolumeOscillator = VolumeOscillator
module.exports.NVI = NVI
module.exports.PVI = PVI
module.exports.WilliamsAD = WilliamsAD
module.exports.AnchoredVWAP = AnchoredVWAP
module.exports.DemandIndex = DemandIndex
module.exports.TSV = TSV
module.exports.VZO = VZO
module.exports.MarketFacilitationIndex = MarketFacilitationIndex
module.exports.SuperTrend = SuperTrend
module.exports.ChandelierExit = ChandelierExit
module.exports.ChandeKrollStop = ChandeKrollStop
module.exports.AtrTrailingStop = AtrTrailingStop
module.exports.HiLoActivator = HiLoActivator
module.exports.VoltyStop = VoltyStop
module.exports.YoyoExit = YoyoExit
module.exports.DonchianStop = DonchianStop
module.exports.PercentageTrailingStop = PercentageTrailingStop
module.exports.StepTrailingStop = StepTrailingStop
module.exports.RenkoTrailingStop = RenkoTrailingStop
module.exports.TypicalPrice = TypicalPrice
module.exports.MedianPrice = MedianPrice
module.exports.WeightedClose = WeightedClose
@@ -379,9 +418,113 @@ module.exports.NATR = NATR
module.exports.HistoricalVolatility = HistoricalVolatility
module.exports.AroonOscillator = AroonOscillator
module.exports.Vortex = Vortex
module.exports.RWI = RWI
module.exports.WaveTrend = WaveTrend
module.exports.MassIndex = MassIndex
module.exports.StochRSI = StochRSI
module.exports.UltimateOscillator = UltimateOscillator
module.exports.PPO = PPO
module.exports.Coppock = Coppock
module.exports.VWMA = VWMA
module.exports.RVIVolatility = RVIVolatility
module.exports.ParkinsonVolatility = ParkinsonVolatility
module.exports.GarmanKlassVolatility = GarmanKlassVolatility
module.exports.RogersSatchellVolatility = RogersSatchellVolatility
module.exports.YangZhangVolatility = YangZhangVolatility
module.exports.MaEnvelope = MaEnvelope
module.exports.AccelerationBands = AccelerationBands
module.exports.StarcBands = StarcBands
module.exports.AtrBands = AtrBands
module.exports.HurstChannel = HurstChannel
module.exports.LinRegChannel = LinRegChannel
module.exports.StandardErrorBands = StandardErrorBands
module.exports.DoubleBollinger = DoubleBollinger
module.exports.TtmSqueeze = TtmSqueeze
module.exports.FractalChaosBands = FractalChaosBands
module.exports.VwapStdDevBands = VwapStdDevBands
module.exports.ClassicPivots = ClassicPivots
module.exports.FibonacciPivots = FibonacciPivots
module.exports.Camarilla = Camarilla
module.exports.WoodiePivots = WoodiePivots
module.exports.DemarkPivots = DemarkPivots
module.exports.WilliamsFractals = WilliamsFractals
module.exports.ZigZag = ZigZag
module.exports.TDSetup = TDSetup
module.exports.TDSequential = TDSequential
module.exports.TDDeMarker = TDDeMarker
module.exports.TDREI = TDREI
module.exports.TDPressure = TDPressure
module.exports.TDCombo = TDCombo
module.exports.TDCountdown = TDCountdown
module.exports.TDLines = TDLines
module.exports.TDRangeProjection = TDRangeProjection
module.exports.TDDifferential = TDDifferential
module.exports.TDOpen = TDOpen
module.exports.TDRiskLevel = TDRiskLevel
module.exports.SuperSmoother = SuperSmoother
module.exports.FisherTransform = FisherTransform
module.exports.InverseFisherTransform = InverseFisherTransform
module.exports.Decycler = Decycler
module.exports.DecyclerOscillator = DecyclerOscillator
module.exports.RoofingFilter = RoofingFilter
module.exports.CenterOfGravity = CenterOfGravity
module.exports.CyberneticCycle = CyberneticCycle
module.exports.InstantaneousTrendline = InstantaneousTrendline
module.exports.EhlersStochastic = EhlersStochastic
module.exports.EmpiricalModeDecomposition = EmpiricalModeDecomposition
module.exports.HilbertDominantCycle = HilbertDominantCycle
module.exports.AdaptiveCycle = AdaptiveCycle
module.exports.SineWave = SineWave
module.exports.MAMA = MAMA
module.exports.FAMA = FAMA
module.exports.Ichimoku = Ichimoku
module.exports.HeikinAshi = HeikinAshi
module.exports.Variance = Variance
module.exports.CoefficientOfVariation = CoefficientOfVariation
module.exports.Skewness = Skewness
module.exports.Kurtosis = Kurtosis
module.exports.StandardError = StandardError
module.exports.DetrendedStdDev = DetrendedStdDev
module.exports.RSquared = RSquared
module.exports.MedianAbsoluteDeviation = MedianAbsoluteDeviation
module.exports.Autocorrelation = Autocorrelation
module.exports.HurstExponent = HurstExponent
module.exports.PearsonCorrelation = PearsonCorrelation
module.exports.Beta = Beta
module.exports.SpearmanCorrelation = SpearmanCorrelation
module.exports.ValueArea = ValueArea
module.exports.InitialBalance = InitialBalance
module.exports.OpeningRange = OpeningRange
module.exports.Doji = Doji
module.exports.Hammer = Hammer
module.exports.InvertedHammer = InvertedHammer
module.exports.HangingMan = HangingMan
module.exports.ShootingStar = ShootingStar
module.exports.Engulfing = Engulfing
module.exports.Harami = Harami
module.exports.MorningEveningStar = MorningEveningStar
module.exports.ThreeSoldiersOrCrows = ThreeSoldiersOrCrows
module.exports.PiercingDarkCloud = PiercingDarkCloud
module.exports.Marubozu = Marubozu
module.exports.Tweezer = Tweezer
module.exports.SpinningTop = SpinningTop
module.exports.ThreeInside = ThreeInside
module.exports.ThreeOutside = ThreeOutside
// Family 15: Risk / Performance metrics
module.exports.SharpeRatio = SharpeRatio
module.exports.SortinoRatio = SortinoRatio
module.exports.CalmarRatio = CalmarRatio
module.exports.OmegaRatio = OmegaRatio
module.exports.MaxDrawdown = MaxDrawdown
module.exports.AverageDrawdown = AverageDrawdown
module.exports.DrawdownDuration = DrawdownDuration
module.exports.PainIndex = PainIndex
module.exports.ValueAtRisk = ValueAtRisk
module.exports.ConditionalValueAtRisk = ConditionalValueAtRisk
module.exports.ProfitFactor = ProfitFactor
module.exports.GainLossRatio = GainLossRatio
module.exports.RecoveryFactor = RecoveryFactor
module.exports.KellyCriterion = KellyCriterion
module.exports.TreynorRatio = TreynorRatio
module.exports.InformationRatio = InformationRatio
module.exports.Alpha = Alpha
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "wickra-darwin-arm64",
"version": "0.2.5",
"version": "0.3.1",
"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": [
@@ -18,7 +18,7 @@
],
"repository": {
"type": "git",
"url": "https://github.com/kingchenc/wickra"
"url": "https://github.com/wickra-lib/wickra"
},
"homepage": "https://github.com/kingchenc/wickra"
"homepage": "https://github.com/wickra-lib/wickra"
}
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "wickra-darwin-x64",
"version": "0.2.5",
"version": "0.3.1",
"description": "Native binding for wickra (macOS Intel). Installed automatically as an optional dependency of wickra on matching platforms.",
"main": "wickra.darwin-x64.node",
"files": [
@@ -18,7 +18,7 @@
],
"repository": {
"type": "git",
"url": "https://github.com/kingchenc/wickra"
"url": "https://github.com/wickra-lib/wickra"
},
"homepage": "https://github.com/kingchenc/wickra"
"homepage": "https://github.com/wickra-lib/wickra"
}
@@ -1,6 +1,6 @@
{
"name": "wickra-linux-arm64-gnu",
"version": "0.2.5",
"version": "0.3.1",
"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": [
@@ -21,7 +21,7 @@
],
"repository": {
"type": "git",
"url": "https://github.com/kingchenc/wickra"
"url": "https://github.com/wickra-lib/wickra"
},
"homepage": "https://github.com/kingchenc/wickra"
"homepage": "https://github.com/wickra-lib/wickra"
}
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "wickra-linux-x64-gnu",
"version": "0.2.5",
"version": "0.3.1",
"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": [
@@ -21,7 +21,7 @@
],
"repository": {
"type": "git",
"url": "https://github.com/kingchenc/wickra"
"url": "https://github.com/wickra-lib/wickra"
},
"homepage": "https://github.com/kingchenc/wickra"
"homepage": "https://github.com/wickra-lib/wickra"
}
@@ -0,0 +1,24 @@
{
"name": "wickra-win32-arm64-msvc",
"version": "0.3.1",
"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/wickra-lib/wickra"
},
"homepage": "https://github.com/wickra-lib/wickra"
}
@@ -1,6 +1,6 @@
{
"name": "wickra-win32-x64-msvc",
"version": "0.2.5",
"version": "0.3.1",
"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": [
@@ -18,7 +18,7 @@
],
"repository": {
"type": "git",
"url": "https://github.com/kingchenc/wickra"
"url": "https://github.com/wickra-lib/wickra"
},
"homepage": "https://github.com/kingchenc/wickra"
"homepage": "https://github.com/wickra-lib/wickra"
}
+140
View File
@@ -0,0 +1,140 @@
{
"name": "wickra",
"version": "0.3.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "wickra",
"version": "0.3.1",
"license": "PolyForm-Noncommercial-1.0.0",
"devDependencies": {
"@napi-rs/cli": "^2.18.0"
},
"engines": {
"node": ">= 18"
},
"optionalDependencies": {
"wickra-darwin-arm64": "0.3.1",
"wickra-darwin-x64": "0.3.1",
"wickra-linux-arm64-gnu": "0.3.1",
"wickra-linux-x64-gnu": "0.3.1",
"wickra-win32-arm64-msvc": "0.3.1",
"wickra-win32-x64-msvc": "0.3.1"
}
},
"node_modules/@napi-rs/cli": {
"version": "2.18.4",
"resolved": "https://registry.npmjs.org/@napi-rs/cli/-/cli-2.18.4.tgz",
"integrity": "sha512-SgJeA4df9DE2iAEpr3M2H0OKl/yjtg1BnRI5/JyowS71tUWhrfSu2LT0V3vlHET+g1hBVlrO60PmEXwUEKp8Mg==",
"dev": true,
"license": "MIT",
"bin": {
"napi": "scripts/index.js"
},
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/wickra-darwin-arm64": {
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/wickra-darwin-arm64/-/wickra-darwin-arm64-0.3.1.tgz",
"integrity": "sha512-4eZiBR/yGUdr4nzhEUFy2i69XgNx64iI2ax/LPamsThgylC0KpHOZKK19QzJ2d9KbK4C8nMjME5FLuR+4GNEwQ==",
"cpu": [
"arm64"
],
"license": "PolyForm-Noncommercial-1.0.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 18"
}
},
"node_modules/wickra-darwin-x64": {
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/wickra-darwin-x64/-/wickra-darwin-x64-0.3.1.tgz",
"integrity": "sha512-6hf8zI3QPjTFp4zCpmgUwDvNtu6jHqNUHKD5e55POo0CgA52HkpyxSPtVm8TGTIZDI7kPjlbOdBM8CJ76mmXwA==",
"cpu": [
"x64"
],
"license": "PolyForm-Noncommercial-1.0.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 18"
}
},
"node_modules/wickra-linux-arm64-gnu": {
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/wickra-linux-arm64-gnu/-/wickra-linux-arm64-gnu-0.3.1.tgz",
"integrity": "sha512-kSe6y0xBMSiqdPLXNjwop5WZdHtvdBNKSEBCwZ4hFq33p4apW25/wrlzv9/oDuyD4kuPabJEhCCnFOplh58CUg==",
"cpu": [
"arm64"
],
"license": "PolyForm-Noncommercial-1.0.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 18"
}
},
"node_modules/wickra-linux-x64-gnu": {
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/wickra-linux-x64-gnu/-/wickra-linux-x64-gnu-0.3.1.tgz",
"integrity": "sha512-tWBWS4qz7hxM4xnpFb59bhf6TaLwXq0Z3jEa/2l7r8PiHA94g8r8S53NRMiT+4yiL5hSWe/nUiC/YXdRrhEZ4g==",
"cpu": [
"x64"
],
"license": "PolyForm-Noncommercial-1.0.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 18"
}
},
"node_modules/wickra-win32-arm64-msvc": {
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/wickra-win32-arm64-msvc/-/wickra-win32-arm64-msvc-0.3.1.tgz",
"integrity": "sha512-EXIckHxAtF75PUGDKRzXyqMe9ldP0JjSdu68WFN6iJfp+McYrGu6h40TEJlQ/oUEIoPqiZB/xhVyo/el5Lg7zw==",
"cpu": [
"arm64"
],
"license": "PolyForm-Noncommercial-1.0.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 18"
}
},
"node_modules/wickra-win32-x64-msvc": {
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/wickra-win32-x64-msvc/-/wickra-win32-x64-msvc-0.3.1.tgz",
"integrity": "sha512-Yfsqq1Xwp6hdxMyLze411vNdo7BDwI6+lPSe7A9XdqyPecNDbtKwYLpsal2r8EHbNzqM+R8XnuRtUaEQS5VlUQ==",
"cpu": [
"x64"
],
"license": "PolyForm-Noncommercial-1.0.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 18"
}
}
}
}
+13 -11
View File
@@ -1,8 +1,8 @@
{
"name": "wickra",
"version": "0.2.5",
"version": "0.3.1",
"description": "Streaming-first technical indicators: incremental, fast, install-free. Node bindings powered by Rust.",
"author": "kingchenc <kingchencp@gmail.com>",
"author": "kingchenc <wickra.lib@gmail.com>",
"main": "index.js",
"types": "index.d.ts",
"license": "PolyForm-Noncommercial-1.0.0",
@@ -17,12 +17,12 @@
],
"repository": {
"type": "git",
"url": "https://github.com/kingchenc/wickra"
"url": "https://github.com/wickra-lib/wickra"
},
"bugs": {
"url": "https://github.com/kingchenc/wickra/issues"
"url": "https://github.com/wickra-lib/wickra/issues"
},
"homepage": "https://github.com/kingchenc/wickra",
"homepage": "https://github.com/wickra-lib/wickra",
"files": [
"index.js",
"index.d.ts",
@@ -38,7 +38,8 @@
"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"
]
}
},
@@ -46,11 +47,12 @@
"node": ">= 18"
},
"optionalDependencies": {
"wickra-linux-x64-gnu": "0.2.5",
"wickra-linux-arm64-gnu": "0.2.5",
"wickra-darwin-x64": "0.2.5",
"wickra-darwin-arm64": "0.2.5",
"wickra-win32-x64-msvc": "0.2.5"
"wickra-linux-x64-gnu": "0.3.1",
"wickra-linux-arm64-gnu": "0.3.1",
"wickra-darwin-x64": "0.3.1",
"wickra-darwin-arm64": "0.3.1",
"wickra-win32-x64-msvc": "0.3.1",
"wickra-win32-arm64-msvc": "0.3.1"
},
"scripts": {
"build": "napi build --platform --release",
File diff suppressed because it is too large Load Diff
+39 -31
View File
@@ -1,7 +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)
[![CI](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml/badge.svg)](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml)
[![codecov](https://codecov.io/gh/wickra-lib/wickra/branch/main/graph/badge.svg)](https://codecov.io/gh/wickra-lib/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)
@@ -36,16 +36,16 @@ for price in live_feed:
The Python TA ecosystem has plenty of libraries — TA-Lib, pandas-ta, finta,
talipp, tulipy — and every one of them shares the same blind spot:
| Library | Install pain | Streaming | Multi-language | Active |
|--------------------|-----------------|-----------|----------------|--------|
| TA-Lib (Python) | yes (C deps) | no | no | barely |
| pandas-ta | clean | no | no | slow |
| finta | clean | no | no | stale |
| ta-lib-python | yes (C deps) | no | no | barely |
| talipp | clean | yes | no | yes |
| Tulip Indicators | yes (C deps) | no | partial | stale |
| ooples (C#) | clean | no | C# only | yes |
| **Wickra** | **clean** | **yes** | **Python+Node+WASM+Rust** | **yes** |
| Library | Install pain | Streaming | Multi-language | Active |
|------------------------|-----------------|-----------|----------------|--------|
| **★&nbsp;Wickra** | **clean** | **yes** | **Python + Node + WASM + Rust** | **yes** |
| TA-Lib (Python) | yes (C deps) | no | no | barely |
| pandas-ta | clean | no | no | slow |
| finta | clean | no | no | stale |
| ta-lib-python | yes (C deps) | no | no | barely |
| talipp | clean | yes | no | yes |
| Tulip Indicators | yes (C deps) | no | partial | stale |
| ooples (C#) | clean | no | C# only | yes |
Wickra is the only library that combines all of: clean install, streaming,
multi-language reach, and active maintenance.
@@ -58,7 +58,7 @@ 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,
- **Reproduced on:** Windows 11 Pro 26200, AMD Ryzen 9 9950X, 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
@@ -76,7 +76,7 @@ to recompute on every tick.
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 |
| Indicator | **★&nbsp;Wickra** | finta | talipp |
|---------------------|---------------------|-----------------------------|-------------------------------|
| SMA(20) | **95.6 µs ★** | 343.5 µs (3.6× slower) | 7 640.6 µs (79.9× slower) |
| EMA(20) | **64.6 µs ★** | 223.1 µs (3.5× slower) | 12 160.9 µs (188.2× slower) |
@@ -90,7 +90,7 @@ slower it is than Wickra in parentheses. **★** marks the winner per row.
A batch-only library has to re-run its full indicator over the entire history on
every new tick; Wickra updates state in O(1).
| Indicator | Wickra (per tick) | talipp (per tick) |
| Indicator | **★&nbsp;Wickra (per tick)** | talipp (per tick) |
|-----------|---------------------|---------------------------|
| RSI(14) | **0.119 µs ★** | 1.644 µs (13.8× slower) |
@@ -109,20 +109,28 @@ python -m benchmarks.compare_libraries
## Indicators
71 streaming-first indicators across eight families. Every one passes the
214 streaming-first indicators across sixteen families. Every one passes the
`batch == streaming` equivalence test, reference-value tests, and reset
semantics tests.
| Family | Indicators |
|--------|-----------|
| Moving Averages | SMA, EMA, WMA, DEMA, TEMA, HMA, KAMA, SMMA, TRIMA, ZLEMA, T3, VWMA |
| Momentum Oscillators | RSI (Wilder), Stochastic, CCI, ROC, Williams %R, MFI, Awesome Oscillator, MOM, CMO, TSI, PMO, StochRSI, Ultimate Oscillator |
| Trend & Directional | MACD, ADX (+DI/-DI), Aroon, TRIX, Aroon Oscillator, Vortex, Mass Index, Choppiness Index, Vertical Horizontal Filter |
| Price Oscillators | PPO, DPO, Coppock, Accelerator Oscillator, Balance of Power |
| Volatility & Bands | ATR, Bollinger Bands, Keltner Channels, Donchian Channels, NATR, StdDev, Ulcer Index, Historical Volatility, Bollinger Bandwidth, %B, True Range, Chaikin Volatility |
| Trailing Stops | Parabolic SAR, SuperTrend, Chandelier Exit, Chande Kroll Stop, ATR Trailing Stop |
| Volume | OBV, VWAP (cumulative + rolling), ADL, Volume-Price Trend, Chaikin Money Flow, Chaikin Oscillator, Force Index, Ease of Movement |
| Price Statistics | Typical Price, Median Price, Weighted Close, Linear Regression, Linear Regression Slope, Z-Score, Linear Regression Angle |
| Moving Averages | SMA, EMA, WMA, DEMA, TEMA, HMA, KAMA, SMMA, TRIMA, ZLEMA, T3, VWMA, ALMA, McGinley Dynamic, FRAMA, VIDYA, JMA, Alligator, EVWMA |
| Momentum Oscillators | RSI (Wilder), Stochastic, CCI, ROC, Williams %R, MFI, Awesome Oscillator, MOM, CMO, TSI, PMO, StochRSI, Ultimate Oscillator, RVI, PGO, KST, SMI, Laguerre RSI, Connors RSI, Inertia |
| Trend & Directional | MACD, ADX (+DI/-DI), ADXR, Aroon, TRIX, Aroon Oscillator, Vortex, Random Walk Index, Trend Intensity Index, Wave Trend Oscillator, Mass Index, Choppiness Index, Vertical Horizontal Filter |
| Price Oscillators | PPO, DPO, Coppock, Accelerator Oscillator, Balance of Power, APO, AO Histogram, CFO, Zero-Lag MACD, Elder Impulse, STC |
| Volatility & Bands | ATR, Bollinger Bands, Keltner Channels, Donchian Channels, NATR, StdDev, Ulcer Index, Historical Volatility, Bollinger Bandwidth, %B, True Range, Chaikin Volatility, RVI (Relative Volatility Index), Parkinson Volatility, Garman-Klass Volatility, Rogers-Satchell Volatility, Yang-Zhang Volatility, Detrended StdDev |
| Bands & Channels | MA Envelope, Acceleration Bands, STARC Bands, ATR Bands, Hurst Channel, LinReg Channel, Standard Error Bands, Double Bollinger Bands, TTM Squeeze, Fractal Chaos Bands, VWAP StdDev Bands |
| Trailing Stops | Parabolic SAR, SuperTrend, Chandelier Exit, Chande Kroll Stop, ATR Trailing Stop, HiLo Activator, Volty Stop, Yo-Yo Exit, Donchian Channel Stop, Percentage Trailing Stop, Step Trailing Stop, Renko Trailing Stop |
| Volume | OBV, VWAP (cumulative + rolling), ADL, Volume-Price Trend, Chaikin Money Flow, Chaikin Oscillator, Force Index, Ease of Movement, Klinger Volume Oscillator, Volume Oscillator, NVI, PVI, Williams A/D, Anchored VWAP, Demand Index, TSV, VZO, Market Facilitation Index |
| Price Statistics | Typical Price, Median Price, Weighted Close, Linear Regression, Linear Regression Slope, Z-Score, Linear Regression Angle, Variance, Coefficient of Variation, Skewness, Kurtosis, Standard Error, R², Median Absolute Deviation, Autocorrelation, Hurst Exponent, Pearson Correlation, Beta, Spearman Correlation |
| Ehlers / Cycle (DSP) | MAMA, FAMA, Fisher Transform, Inverse Fisher Transform, SuperSmoother, Hilbert Dominant Cycle, Sine Wave, Decycler, Decycler Oscillator, Roofing Filter, Center of Gravity, Cybernetic Cycle, Adaptive Cycle, Empirical Mode Decomposition, Ehlers Stochastic, Instantaneous Trendline |
| Pivots & S/R | Classic Pivots, Fibonacci Pivots, Camarilla, Woodie Pivots, DeMark Pivots, Williams Fractals, ZigZag |
| DeMark | TD Setup, TD Sequential, TD DeMarker, TD REI, TD Pressure, TD Combo, TD Countdown, TD Lines, TD Range Projection, TD Differential, TD Open, TD Risk Level |
| Ichimoku & Charts | Ichimoku Kinko Hyo (Tenkan, Kijun, Senkou A/B, Chikou), Heikin-Ashi |
| Candlestick Patterns | Doji, Hammer, Inverted Hammer, Hanging Man, Shooting Star, Engulfing, Harami, Morning/Evening Star, Three White Soldiers/Black Crows, Piercing Line/Dark Cloud Cover, Marubozu, Tweezer, Spinning Top, Three Inside Up/Down, Three Outside Up/Down |
| Market Profile | Value Area (POC / VAH / VAL), Initial Balance, Opening Range |
| Risk / Performance | Sharpe Ratio, Sortino Ratio, Calmar Ratio, Omega Ratio, Max Drawdown, Average Drawdown, Drawdown Duration, Pain Index, Value at Risk, Conditional Value at Risk (CVaR), Profit Factor, Gain/Loss Ratio, Recovery Factor, Kelly Criterion, Treynor Ratio, Information Ratio, Alpha (Jensen) |
Adding a new indicator means implementing one trait in Rust; all four bindings
inherit it automatically.
@@ -256,7 +264,7 @@ Every layer is covered; run the suites with the commands in
## Contributing
Contributions are very welcome — issues, bug reports, ideas, and pull requests
all land in the same place: <https://github.com/kingchenc/wickra>.
all land in the same place: <https://github.com/wickra-lib/wickra>.
A short orientation for first-time contributors:
@@ -290,14 +298,14 @@ use Wickra commercially, get in touch about a license.
---
<p align="center">
<a href="https://github.com/kingchenc/wickra/stargazers">
<img alt="GitHub stars" src="https://img.shields.io/github/stars/kingchenc/wickra?style=for-the-badge&logo=github&logoColor=white&color=ffd866">
<a href="https://github.com/wickra-lib/wickra/stargazers">
<img alt="GitHub stars" src="https://img.shields.io/github/stars/wickra-lib/wickra?style=for-the-badge&logo=github&logoColor=white&color=ffd866">
</a>
<a href="https://github.com/kingchenc/wickra/network/members">
<img alt="GitHub forks" src="https://img.shields.io/github/forks/kingchenc/wickra?style=for-the-badge&logo=github&logoColor=white&color=78dce8">
<a href="https://github.com/wickra-lib/wickra/network/members">
<img alt="GitHub forks" src="https://img.shields.io/github/forks/wickra-lib/wickra?style=for-the-badge&logo=github&logoColor=white&color=78dce8">
</a>
<a href="https://github.com/kingchenc/wickra/issues">
<img alt="GitHub issues" src="https://img.shields.io/github/issues/kingchenc/wickra?style=for-the-badge&logo=github&logoColor=white&color=ff6188">
<a href="https://github.com/wickra-lib/wickra/issues">
<img alt="GitHub issues" src="https://img.shields.io/github/issues/wickra-lib/wickra?style=for-the-badge&logo=github&logoColor=white&color=ff6188">
</a>
</p>
+5 -5
View File
@@ -4,11 +4,11 @@ build-backend = "maturin"
[project]
name = "wickra"
version = "0.2.5"
version = "0.3.1"
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" }]
authors = [{ name = "kingchenc", email = "wickra.lib@gmail.com" }]
requires-python = ">=3.9"
keywords = ["finance", "trading", "indicators", "technical-analysis", "ta-lib"]
classifiers = [
@@ -47,9 +47,9 @@ bench = [
]
[project.urls]
Homepage = "https://github.com/kingchenc/wickra"
Repository = "https://github.com/kingchenc/wickra"
Issues = "https://github.com/kingchenc/wickra/issues"
Homepage = "https://github.com/wickra-lib/wickra"
Repository = "https://github.com/wickra-lib/wickra"
Issues = "https://github.com/wickra-lib/wickra/issues"
[tool.maturin]
manifest-path = "Cargo.toml"
+302
View File
@@ -38,6 +38,13 @@ from ._wickra import (
ZLEMA,
T3,
VWMA,
ALMA,
McGinleyDynamic,
FRAMA,
VIDYA,
JMA,
Alligator,
EVWMA,
# Momentum
RSI,
MACD,
@@ -46,6 +53,7 @@ from ._wickra import (
ROC,
WilliamsR,
ADX,
ADXR,
MFI,
TRIX,
AwesomeOscillator,
@@ -54,13 +62,30 @@ from ._wickra import (
CMO,
TSI,
PMO,
TII,
KST,
StochRSI,
UltimateOscillator,
RVI,
PGO,
KST,
SMI,
LaguerreRSI,
ConnorsRSI,
Inertia,
APO,
AwesomeOscillatorHistogram,
CFO,
ZeroLagMACD,
ElderImpulse,
STC,
PPO,
DPO,
Coppock,
AroonOscillator,
Vortex,
RWI,
WaveTrend,
MassIndex,
AcceleratorOscillator,
BalanceOfPower,
@@ -82,8 +107,20 @@ from ._wickra import (
ChandelierExit,
ChandeKrollStop,
AtrTrailingStop,
HiLoActivator,
VoltyStop,
YoyoExit,
DonchianStop,
PercentageTrailingStop,
StepTrailingStop,
RenkoTrailingStop,
TrueRange,
ChaikinVolatility,
RVIVolatility,
ParkinsonVolatility,
GarmanKlassVolatility,
RogersSatchellVolatility,
YangZhangVolatility,
# Volume
OBV,
VWAP,
@@ -93,6 +130,16 @@ from ._wickra import (
ChaikinMoneyFlow,
ChaikinOscillator,
ForceIndex,
KVO,
VolumeOscillator,
NVI,
PVI,
WilliamsAD,
AnchoredVWAP,
DemandIndex,
TSV,
VZO,
MarketFacilitationIndex,
EaseOfMovement,
# Statistics
TypicalPrice,
@@ -102,6 +149,110 @@ from ._wickra import (
LinRegSlope,
ZScore,
LinRegAngle,
Variance,
CoefficientOfVariation,
Skewness,
Kurtosis,
StandardError,
DetrendedStdDev,
RSquared,
Autocorrelation,
MedianAbsoluteDeviation,
HurstExponent,
PearsonCorrelation,
Beta,
SpearmanCorrelation,
# Ehlers / Cycle
SuperSmoother,
FisherTransform,
InverseFisherTransform,
Decycler,
DecyclerOscillator,
RoofingFilter,
CenterOfGravity,
CyberneticCycle,
InstantaneousTrendline,
EhlersStochastic,
EmpiricalModeDecomposition,
HilbertDominantCycle,
AdaptiveCycle,
SineWave,
MAMA,
FAMA,
# Bands & Channels
MaEnvelope,
AccelerationBands,
StarcBands,
AtrBands,
HurstChannel,
LinRegChannel,
StandardErrorBands,
DoubleBollinger,
TtmSqueeze,
FractalChaosBands,
VwapStdDevBands,
# Pivots & S/R
ClassicPivots,
FibonacciPivots,
Camarilla,
WoodiePivots,
DemarkPivots,
WilliamsFractals,
ZigZag,
# DeMark
TDSetup,
TDSequential,
TDDeMarker,
TDREI,
TDPressure,
TDCombo,
TDCountdown,
TDLines,
TDRangeProjection,
TDDifferential,
TDOpen,
TDRiskLevel,
# Ichimoku & alternative charts
Ichimoku,
HeikinAshi,
# Market Profile
ValueArea,
InitialBalance,
OpeningRange,
# Candlestick patterns
Doji,
Hammer,
InvertedHammer,
HangingMan,
ShootingStar,
Engulfing,
Harami,
MorningEveningStar,
ThreeSoldiersOrCrows,
PiercingDarkCloud,
Marubozu,
Tweezer,
SpinningTop,
ThreeInside,
ThreeOutside,
# Risk / Performance
SharpeRatio,
SortinoRatio,
CalmarRatio,
OmegaRatio,
MaxDrawdown,
AverageDrawdown,
DrawdownDuration,
PainIndex,
ValueAtRisk,
ConditionalValueAtRisk,
ProfitFactor,
GainLossRatio,
RecoveryFactor,
KellyCriterion,
TreynorRatio,
InformationRatio,
Alpha,
)
__all__ = [
@@ -119,6 +270,13 @@ __all__ = [
"ZLEMA",
"T3",
"VWMA",
"ALMA",
"McGinleyDynamic",
"FRAMA",
"VIDYA",
"JMA",
"Alligator",
"EVWMA",
# Momentum
"RSI",
"MACD",
@@ -127,6 +285,7 @@ __all__ = [
"ROC",
"WilliamsR",
"ADX",
"ADXR",
"MFI",
"TRIX",
"AwesomeOscillator",
@@ -135,13 +294,30 @@ __all__ = [
"CMO",
"TSI",
"PMO",
"TII",
"KST",
"StochRSI",
"UltimateOscillator",
"RVI",
"PGO",
"KST",
"SMI",
"LaguerreRSI",
"ConnorsRSI",
"Inertia",
"APO",
"AwesomeOscillatorHistogram",
"CFO",
"ZeroLagMACD",
"ElderImpulse",
"STC",
"PPO",
"DPO",
"Coppock",
"AroonOscillator",
"Vortex",
"RWI",
"WaveTrend",
"MassIndex",
"AcceleratorOscillator",
"BalanceOfPower",
@@ -163,8 +339,20 @@ __all__ = [
"ChandelierExit",
"ChandeKrollStop",
"AtrTrailingStop",
"HiLoActivator",
"VoltyStop",
"YoyoExit",
"DonchianStop",
"PercentageTrailingStop",
"StepTrailingStop",
"RenkoTrailingStop",
"TrueRange",
"ChaikinVolatility",
"RVIVolatility",
"ParkinsonVolatility",
"GarmanKlassVolatility",
"RogersSatchellVolatility",
"YangZhangVolatility",
# Volume
"OBV",
"VWAP",
@@ -174,6 +362,16 @@ __all__ = [
"ChaikinMoneyFlow",
"ChaikinOscillator",
"ForceIndex",
"KVO",
"VolumeOscillator",
"NVI",
"PVI",
"WilliamsAD",
"AnchoredVWAP",
"DemandIndex",
"TSV",
"VZO",
"MarketFacilitationIndex",
"EaseOfMovement",
# Statistics
"TypicalPrice",
@@ -183,4 +381,108 @@ __all__ = [
"LinRegSlope",
"ZScore",
"LinRegAngle",
"Variance",
"CoefficientOfVariation",
"Skewness",
"Kurtosis",
"StandardError",
"DetrendedStdDev",
"RSquared",
"Autocorrelation",
"MedianAbsoluteDeviation",
"HurstExponent",
"PearsonCorrelation",
"Beta",
"SpearmanCorrelation",
# Ehlers / Cycle
"SuperSmoother",
"FisherTransform",
"InverseFisherTransform",
"Decycler",
"DecyclerOscillator",
"RoofingFilter",
"CenterOfGravity",
"CyberneticCycle",
"InstantaneousTrendline",
"EhlersStochastic",
"EmpiricalModeDecomposition",
"HilbertDominantCycle",
"AdaptiveCycle",
"SineWave",
"MAMA",
"FAMA",
# Bands & Channels
"MaEnvelope",
"AccelerationBands",
"StarcBands",
"AtrBands",
"HurstChannel",
"LinRegChannel",
"StandardErrorBands",
"DoubleBollinger",
"TtmSqueeze",
"FractalChaosBands",
"VwapStdDevBands",
# Pivots & S/R
"ClassicPivots",
"FibonacciPivots",
"Camarilla",
"WoodiePivots",
"DemarkPivots",
"WilliamsFractals",
"ZigZag",
# DeMark
"TDSetup",
"TDSequential",
"TDDeMarker",
"TDREI",
"TDPressure",
"TDCombo",
"TDCountdown",
"TDLines",
"TDRangeProjection",
"TDDifferential",
"TDOpen",
"TDRiskLevel",
# Ichimoku & alternative charts
"Ichimoku",
"HeikinAshi",
# Market Profile
"ValueArea",
"InitialBalance",
"OpeningRange",
# Candlestick patterns
"Doji",
"Hammer",
"InvertedHammer",
"HangingMan",
"ShootingStar",
"Engulfing",
"Harami",
"MorningEveningStar",
"ThreeSoldiersOrCrows",
"PiercingDarkCloud",
"Marubozu",
"Tweezer",
"SpinningTop",
"ThreeInside",
"ThreeOutside",
# Risk / Performance
"SharpeRatio",
"SortinoRatio",
"CalmarRatio",
"OmegaRatio",
"MaxDrawdown",
"AverageDrawdown",
"DrawdownDuration",
"PainIndex",
"ValueAtRisk",
"ConditionalValueAtRisk",
"ProfitFactor",
"GainLossRatio",
"RecoveryFactor",
"KellyCriterion",
"TreynorRatio",
"InformationRatio",
"Alpha",
]
File diff suppressed because it is too large Load Diff
@@ -39,3 +39,64 @@ 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
def test_value_area_rejects_zero_period():
with pytest.raises(ValueError):
ta.ValueArea(0, 50, 0.7)
with pytest.raises(ValueError):
ta.ValueArea(20, 0, 0.7)
def test_value_area_rejects_invalid_pct():
with pytest.raises(ValueError):
ta.ValueArea(20, 50, 0.0)
with pytest.raises(ValueError):
ta.ValueArea(20, 50, 1.5)
def test_initial_balance_rejects_zero_period():
with pytest.raises(ValueError):
ta.InitialBalance(0)
def test_opening_range_rejects_zero_period():
with pytest.raises(ValueError):
ta.OpeningRange(0)
def test_value_area_unequal_length_raises():
high = np.array([1.0, 2.0, 3.0])
low = np.array([0.5, 1.5])
volume = np.array([10.0, 10.0, 10.0])
with pytest.raises(ValueError):
ta.ValueArea(2, 10, 0.7).batch(high, low, volume)
def test_ichimoku_rejects_zero_and_non_increasing_periods():
with pytest.raises(ValueError):
ta.Ichimoku(0, 26, 52, 26)
with pytest.raises(ValueError):
ta.Ichimoku(9, 26, 52, 0)
# Periods must satisfy tenkan < kijun < senkou_b.
with pytest.raises(ValueError):
ta.Ichimoku(26, 9, 52, 26)
with pytest.raises(ValueError):
ta.Ichimoku(9, 52, 52, 26)
def test_family_10_ehlers_rejects_invalid_parameters():
with pytest.raises(ValueError):
ta.SuperSmoother(0)
with pytest.raises(ValueError):
ta.FisherTransform(0)
with pytest.raises(ValueError):
ta.InverseFisherTransform(0.0)
with pytest.raises(ValueError):
ta.DecyclerOscillator(30, 10)
with pytest.raises(ValueError):
ta.RoofingFilter(48, 10)
with pytest.raises(ValueError):
ta.MAMA(0.05, 0.5)
with pytest.raises(ValueError):
ta.EmpiricalModeDecomposition(20, 0.0)
+650
View File
@@ -66,6 +66,224 @@ def test_rsi_wilder_textbook_first_value():
assert math.isclose(out[14], 70.464, abs_tol=0.05)
def test_inertia_constant_rvi_passes_through_linreg():
# Every bar identical (open, high, low, close) = (10, 11, 9, 10.5):
# RVI = (c-o) / (h-l) = 0.5 / 2 = 0.25 every bar. LinReg of a constant
# series equals that constant after warmup.
n = 60
out = ta.Inertia(3, 4).batch(
np.full(n, 10.0), np.full(n, 11.0), np.full(n, 9.0), np.full(n, 10.5)
)
# warmup_period = 3 + 4 - 1 = 6.
np.testing.assert_allclose(out[5:], 0.25, atol=1e-12)
def test_connors_rsi_output_is_bounded():
# CRSI is the average of three [0, 100] components, so the aggregate must
# also sit in [0, 100] after warmup.
prices = 100.0 + 20.0 * np.sin(np.linspace(0, 30, 250))
out = ta.ConnorsRSI(3, 2, 100).batch(prices.astype(np.float64))
ready = out[~np.isnan(out)]
assert ready.size > 0
assert ready.min() >= 0.0
assert ready.max() <= 100.0
def test_laguerre_rsi_constant_series_stays_at_mid_band():
# All four Laguerre stages seed to the first input, so subsequent flat
# inputs keep them equal and the up/down accumulator is 0 — Wickra maps
# that to the neutral 50.
out = ta.LaguerreRSI(0.5).batch(np.full(40, 42.0, dtype=np.float64))
np.testing.assert_allclose(out, 50.0, atol=1e-12)
def test_smi_close_at_centre_yields_zero():
# Close at the midpoint of a flat high/low range -> displacement is
# always zero -> SMI converges to 0.
n = 60
out = ta.SMI(5, 3, 3).batch(np.full(n, 11.0), np.full(n, 9.0), np.full(n, 10.0))
# warmup_period = 5 + 3 + 3 - 2 = 9.
np.testing.assert_allclose(out[8:], 0.0, atol=1e-12)
def test_kst_constant_series_yields_zero():
# ROC is zero on a flat input, so every RCMA is zero, so KST and its
# signal SMA are both zero after warmup.
kst = ta.KST(10, 15, 20, 30, 10, 10, 10, 15, 9)
out = kst.batch(np.full(80, 42.0, dtype=np.float64))
warmup = kst.warmup_period()
# Use NaN-safe comparison on the post-warmup tail.
tail = out[warmup - 1 :]
assert np.all(np.isfinite(tail))
np.testing.assert_allclose(tail, 0.0, atol=1e-12)
def test_pgo_flat_close_yields_zero():
# On a constant close the numerator (close SMA) is zero, so PGO emits 0
# regardless of the TR-EMA in the denominator.
n = 20
high = np.full(n, 11.0)
low = np.full(n, 9.0)
close = np.full(n, 10.0)
out = ta.PGO(5).batch(high, low, close)
assert np.all(np.isnan(out[:4]))
np.testing.assert_allclose(out[4:], 0.0, atol=1e-12)
def test_rvi_reference_value_period_2():
# Two bars: (open, high, low, close) = (10, 11, 9, 10.5), (10.5, 11.5, 10, 11).
# num = (0.5 + 0.5) = 1.0; den = (2.0 + 1.5) = 3.5; RVI = 1 / 3.5.
out = ta.RVI(2).batch(
np.array([10.0, 10.5]),
np.array([11.0, 11.5]),
np.array([9.0, 10.0]),
np.array([10.5, 11.0]),
)
assert math.isnan(out[0])
assert math.isclose(out[1], 1.0 / 3.5, abs_tol=1e-12)
def test_alma_constant_series_yields_the_constant():
# ALMA's Gaussian weights are normalised, so any constant series is
# reproduced exactly after warmup.
out = ta.ALMA(9, 0.85, 6.0).batch(np.full(30, 42.0, dtype=np.float64))
assert np.all(np.isnan(out[:8]))
np.testing.assert_allclose(out[8:], 42.0, atol=1e-12)
def test_alma_reference_value_period_3():
# ALMA(period=3, offset=0.85, sigma=6) on [10, 20, 30].
# m = 0.85 * 2 = 1.7; s = 3 / 6 = 0.5; 2*s^2 = 0.5.
out = ta.ALMA(3, 0.85, 6.0).batch(np.array([10.0, 20.0, 30.0]))
assert math.isnan(out[0]) and math.isnan(out[1])
# Independently compute the expected Gaussian-weighted sum.
w = np.exp(-((np.arange(3, dtype=np.float64) - 1.7) ** 2) / 0.5)
expected = float(np.dot([10.0, 20.0, 30.0], w) / w.sum())
assert math.isclose(out[2], expected, abs_tol=1e-12)
# Sanity: heavy offset toward the newest sample lifts the average above
# the simple mean of 20.
assert out[2] > 20.0
def test_mcginley_dynamic_constant_series_yields_the_constant():
# ratio = 1, so the recurrence collapses to MD + 0 / divisor = MD.
out = ta.McGinleyDynamic(5).batch(np.full(30, 42.0, dtype=np.float64))
assert np.all(np.isnan(out[:4]))
np.testing.assert_allclose(out[4:], 42.0, atol=1e-12)
def test_mcginley_dynamic_reference_value():
# Period 3, seed = SMA([10, 20, 30]) = 20.0. Next price 40.0:
# ratio = 2; divisor = 0.6 * 3 * 16 = 28.8; next = 20 + 20/28.8.
out = ta.McGinleyDynamic(3).batch(np.array([10.0, 20.0, 30.0, 40.0]))
assert math.isnan(out[0]) and math.isnan(out[1])
assert math.isclose(out[2], 20.0, abs_tol=1e-12)
expected = 20.0 + 20.0 / (0.6 * 3.0 * 16.0)
assert math.isclose(out[3], expected, abs_tol=1e-12)
def test_frama_constant_series_yields_the_constant():
# Flat input -> degenerate ranges -> alpha clamps to 0.01 and the EMA
# recurrence holds the seed value.
out = ta.FRAMA(4).batch(np.full(20, 42.0, dtype=np.float64))
assert np.all(np.isnan(out[:3]))
np.testing.assert_allclose(out[3:], 42.0, atol=1e-12)
def test_frama_pure_uptrend_hugs_latest():
# Monotonic uptrend -> alpha pushed toward 1.0, FRAMA tracks close.
out = ta.FRAMA(4).batch(np.arange(1.0, 9.0, dtype=np.float64))
assert math.isclose(out[-1], 8.0, abs_tol=0.05)
def test_jma_constant_series_yields_the_constant():
# JMA seeds e0 and the output to the first input, so a constant series
# is reproduced exactly from the first sample.
out = ta.JMA(14, 0.0, 2).batch(np.full(30, 42.0, dtype=np.float64))
np.testing.assert_allclose(out, 42.0, atol=1e-12)
def test_evwma_reference_value_period_2():
# EVWMA(2). Bars: (close, volume) = (10, 1), (20, 3), (30, 1).
# Bar 2: sum_v = 4, seeded prev = 20, EVWMA = (1*20 + 3*20)/4 = 20.
# Bar 3: sum_v = 4 (drops 1, gains 1), EVWMA = (3*20 + 1*30)/4 = 22.5.
out = ta.EVWMA(2).batch(np.array([10.0, 20.0, 30.0]), np.array([1.0, 3.0, 1.0]))
assert math.isnan(out[0])
assert math.isclose(out[1], 20.0, abs_tol=1e-12)
assert math.isclose(out[2], 22.5, abs_tol=1e-12)
def test_alligator_constant_series_holds_at_median_price():
# Median price = (11 + 9) / 2 = 10 on every candle, so all three SMMAs
# seed at 10 and stay there.
n = 30
high = np.full(n, 11.0)
low = np.full(n, 9.0)
out = ta.Alligator(13, 8, 5).batch(high, low)
assert out.shape == (n, 3)
for row in out[12:]:
assert math.isclose(row[0], 10.0, abs_tol=1e-12)
assert math.isclose(row[1], 10.0, abs_tol=1e-12)
assert math.isclose(row[2], 10.0, abs_tol=1e-12)
def test_vidya_constant_series_holds_seed():
# CMO = 0 on a flat series -> alpha = 0 -> VIDYA holds its seed value.
out = ta.VIDYA(14, 4).batch(np.full(20, 42.0, dtype=np.float64))
assert np.all(np.isnan(out[:4]))
np.testing.assert_allclose(out[4:], 42.0, atol=1e-12)
def test_zero_lag_macd_constant_series_converges_to_zero():
# Each inner ZLEMA reproduces a constant, so macd, signal and histogram
# are all 0 once the slowest branch warms up.
out = ta.ZeroLagMACD(3, 5, 3).batch(np.full(60, 42.0, dtype=np.float64))
# Take the last row and verify all three columns are 0.
last = out[-1]
assert math.isclose(last[0], 0.0, abs_tol=1e-12)
assert math.isclose(last[1], 0.0, abs_tol=1e-12)
assert math.isclose(last[2], 0.0, abs_tol=1e-12)
def test_awesome_oscillator_histogram_flat_series_converges_to_zero():
# Flat median price -> AO = 0 -> SMA(AO) = 0 -> AOHist = 0.
n = 50
high = np.full(n, 11.0)
low = np.full(n, 9.0)
out = ta.AwesomeOscillatorHistogram(3, 5, 3).batch(high, low)
# warmup = slow + sma - 1 = 5 + 3 - 1 = 7.
np.testing.assert_allclose(out[6:], 0.0, atol=1e-12)
def test_stc_constant_series_yields_zero():
# Flat input collapses both stochastic stages to zero -> STC stays at 0.
out = ta.STC(3, 5, 4, 0.5).batch(np.full(60, 42.0, dtype=np.float64))
ready = out[~np.isnan(out)]
assert ready.size > 0
np.testing.assert_array_equal(ready[-5:], np.zeros(5))
def test_elder_impulse_constant_series_is_neutral():
# Flat input -> neither EMA nor MACD histogram moves -> Impulse stays at 0.
out = ta.ElderImpulse(13, 12, 26, 9).batch(np.full(120, 42.0, dtype=np.float64))
ready = out[~np.isnan(out)]
assert ready.size > 0
np.testing.assert_array_equal(ready, np.zeros_like(ready))
def test_cfo_perfect_linear_series_yields_zero():
# LinReg of a perfectly linear series fits exactly, so CFO = 0 after warmup.
out = ta.CFO(5).batch(np.arange(1.0, 21.0, dtype=np.float64) * 2.0)
np.testing.assert_allclose(out[4:], 0.0, atol=1e-9)
def test_apo_constant_series_converges_to_zero():
# Both EMAs reproduce a constant exactly, so APO = 0 after warmup.
out = ta.APO(3, 5).batch(np.full(30, 42.0, dtype=np.float64))
assert np.all(np.isnan(out[:4]))
np.testing.assert_allclose(out[4:], 0.0, atol=1e-12)
def test_macd_constant_series_converges_to_zero():
out = ta.MACD().batch(np.full(200, 100.0))
# Last row's MACD and signal must be ~0.
@@ -112,3 +330,435 @@ def test_obv_cumulative_known_sequence():
volume = np.array([100.0, 20.0, 30.0, 40.0, 10.0])
out = ta.OBV().batch(close, volume)
np.testing.assert_allclose(out, [0.0, 20.0, -10.0, -10.0, 0.0])
# --- Family 15: Risk / Performance ---------------------------------------
def test_sharpe_ratio_known_window():
# returns [0.01, 0.02, 0.03, 0.04], rf = 0; mean = 0.025;
# sample-var = 0.000166...; Sharpe = 0.025 / sqrt(var).
out = ta.SharpeRatio(4, 0.0).batch(np.array([0.01, 0.02, 0.03, 0.04]))
expected = 0.025 / math.sqrt(0.000_166_666_666_666_666_67)
assert math.isclose(out[3], expected, rel_tol=1e-9)
def test_sortino_ratio_known_window():
# returns [-0.02, 0.01, -0.01, 0.03], mar = 0; mean = 0.0025;
# downside_sq = 0.0005; dd = sqrt(0.0005/4); Sortino = 0.0025/dd.
out = ta.SortinoRatio(4, 0.0).batch(np.array([-0.02, 0.01, -0.01, 0.03]))
expected = 0.0025 / math.sqrt(0.000_125)
assert math.isclose(out[3], expected, rel_tol=1e-9)
def test_max_drawdown_known_window():
# window [100, 120, 90] -> peak 120, trough 90 -> 25% drawdown.
out = ta.MaxDrawdown(3).batch(np.array([100.0, 120.0, 90.0]))
assert math.isclose(out[2], 0.25, abs_tol=1e-12)
def test_pain_index_known_window():
# dd[0..2] = 0, 0, 0.25; mean = 0.25/3.
out = ta.PainIndex(3).batch(np.array([100.0, 120.0, 90.0]))
assert math.isclose(out[2], 0.25 / 3.0, abs_tol=1e-12)
def test_profit_factor_known_window():
# gains 0.05, losses 0.03 -> PF = 5/3.
out = ta.ProfitFactor(4).batch(np.array([0.02, -0.01, 0.03, -0.02]))
assert math.isclose(out[3], 5.0 / 3.0, rel_tol=1e-9)
def test_gain_loss_ratio_known_window():
# avg_win 0.03, avg_loss 0.02 -> GLR = 1.5.
out = ta.GainLossRatio(4).batch(np.array([0.02, -0.01, 0.04, -0.03]))
assert math.isclose(out[3], 1.5, rel_tol=1e-9)
def test_omega_ratio_known_window():
# gains 0.04, losses 0.03 -> Omega = 4/3.
out = ta.OmegaRatio(4, 0.0).batch(np.array([-0.02, 0.01, -0.01, 0.03]))
assert math.isclose(out[3], 4.0 / 3.0, rel_tol=1e-9)
def test_kelly_criterion_known_window():
# n_win=n_loss=2, payoff=2 -> Kelly = 0.5 - 0.5/2 = 0.25.
out = ta.KellyCriterion(4).batch(np.array([0.02, 0.04, -0.01, -0.02]))
assert math.isclose(out[3], 0.25, rel_tol=1e-9)
def test_drawdown_duration_under_water_counter():
out = ta.DrawdownDuration().batch(np.array([100.0, 95.0, 90.0, 85.0]))
np.testing.assert_allclose(out, [0.0, 1.0, 2.0, 3.0])
def test_recovery_factor_known_path():
# Start 100, peak 110, trough 88 -> max_dd = 0.20; end 130 ->
# net_return = 0.30 -> Recovery = 1.5.
prices = np.array([100.0, 110.0, 105.0, 95.0, 88.0, 100.0, 120.0, 130.0])
out = ta.RecoveryFactor().batch(prices)
assert math.isclose(out[-1], 1.5, rel_tol=1e-9)
def test_alpha_perfect_capm_fit_yields_zero():
bench = np.array([0.01 * i for i in range(1, 21)])
asset = 2.0 * bench
out = ta.Alpha(20, 0.0).batch(asset, bench)
assert math.isclose(out[-1], 0.0, abs_tol=1e-12)
def test_alpha_additive_offset_recovered():
bench = np.array([0.01 * i for i in range(1, 21)])
asset = bench + 0.005
out = ta.Alpha(20, 0.0).batch(asset, bench)
assert math.isclose(out[-1], 0.005, rel_tol=1e-9)
def test_treynor_ratio_known_window():
bench = np.array([0.01 * i for i in range(1, 21)])
asset = 2.0 * bench
out = ta.TreynorRatio(20, 0.0).batch(asset, bench)
assert math.isclose(out[-1], bench.mean(), rel_tol=1e-9)
def test_information_ratio_known_window():
asset = np.array([0.02, 0.04, 0.06, 0.08])
bench = np.array([0.01, 0.02, 0.03, 0.04])
out = ta.InformationRatio(4).batch(asset, bench)
expected = 0.025 / math.sqrt(0.000_166_666_666_666_666_67)
assert math.isclose(out[-1], expected, rel_tol=1e-9)
def test_value_at_risk_known_window():
# returns -5..4 *0.01; q=0.05*9=0.45 -> -0.0455; VaR = 0.0455.
returns = np.array([i * 0.01 for i in range(-5, 5)])
out = ta.ValueAtRisk(10, 0.95).batch(returns)
assert math.isclose(out[-1], 0.0455, rel_tol=1e-9)
def test_conditional_value_at_risk_known_window():
# tail = {-0.10}; CVaR = 0.10.
returns = np.array([i * 0.01 for i in range(-10, 10)])
out = ta.ConditionalValueAtRisk(20, 0.95).batch(returns)
assert math.isclose(out[-1], 0.10, rel_tol=1e-9)
def test_calmar_ratio_known_path():
# returns [0.10, -0.20, 0.05]; equity 1.0->1.10->0.88->0.924;
# mdd = 0.20; mean = -0.01666...; Calmar = mean / 0.20.
out = ta.CalmarRatio(3).batch(np.array([0.10, -0.20, 0.05]))
expected = ((0.10 - 0.20 + 0.05) / 3.0) / 0.20
assert math.isclose(out[-1], expected, rel_tol=1e-9)
def test_average_drawdown_known_window():
# window [100, 120, 90, 110]: dd = 0, 0, 0.25, 10/120;
# mean = (0.25 + 10/120) / 4.
out = ta.AverageDrawdown(4).batch(np.array([100.0, 120.0, 90.0, 110.0]))
expected = (0.25 + 10.0 / 120.0) / 4.0
assert math.isclose(out[-1], expected, rel_tol=1e-12)
def test_value_area_concentrated_volume_locates_poc():
# Bars 0..3 sit at price 100 with low volume; bar 4 dumps massive volume
# at price 110. POC must fall inside the high-volume bar's [low, high]
# range; ties resolve to the lowest-index bin, so the POC may sit on the
# left edge of bar 4's range rather than at its midpoint.
high = np.array([100.5, 100.5, 100.5, 100.5, 110.5])
low = np.array([99.5, 99.5, 99.5, 99.5, 109.5])
volume = np.array([1.0, 1.0, 1.0, 1.0, 1000.0])
out = ta.ValueArea(5, 50, 0.70).batch(high, low, volume)
poc = out[-1, 0]
assert 109.5 <= poc <= 110.5
# VAH >= POC >= VAL.
assert out[-1, 1] >= poc >= out[-1, 2]
def test_initial_balance_locks_after_period():
# First two bars set IB = [99, 103]. Third bar (extreme) must be ignored.
high = np.array([102.0, 103.0, 200.0])
low = np.array([100.0, 99.0, 50.0])
out = ta.InitialBalance(2).batch(high, low)
# Bar 0: IB = [100, 102]; Bar 1: IB locked at [99, 103]; Bar 2: unchanged.
np.testing.assert_allclose(out[0], [102.0, 100.0])
np.testing.assert_allclose(out[1], [103.0, 99.0])
np.testing.assert_allclose(out[2], [103.0, 99.0])
def test_opening_range_breakout_distance_signed():
# OR locks after 2 bars at high 103 / low 100; mid 101.5. Third bar
# closes at 105 -> breakout +3.5; fourth bar closes at 95 -> -6.5.
high = np.array([102.0, 103.0, 110.0, 110.0])
low = np.array([100.0, 101.0, 102.0, 90.0])
close = np.array([101.0, 102.0, 105.0, 95.0])
out = ta.OpeningRange(2).batch(high, low, close)
assert math.isclose(out[2, 0], 103.0)
assert math.isclose(out[2, 1], 100.0)
assert math.isclose(out[2, 2], 105.0 - 101.5)
assert math.isclose(out[3, 2], 95.0 - 101.5)
# --- Family 10 — Ehlers / Cycle reference values ---
def test_inverse_fisher_saturates_for_large_input():
# tanh(10) ~ 0.99999996; very close to +1 without exceeding.
v = ta.InverseFisherTransform(1.0).batch(np.array([10.0]))[0]
assert v < 1.0
assert v > 0.999
def test_super_smoother_constant_input_is_constant():
out = ta.SuperSmoother(20).batch(np.full(200, 50.0))
# Steady-state gain is 1, so a flat input stays flat.
np.testing.assert_allclose(out[-50:], 50.0, atol=1e-9)
def test_decycler_oscillator_flat_series_is_zero():
out = ta.DecyclerOscillator(10, 30).batch(np.full(80, 42.0))
ready = out[~np.isnan(out)]
np.testing.assert_allclose(ready, 0.0, atol=1e-9)
def test_mama_constant_series_both_lines_converge_to_price():
out = ta.MAMA().batch(np.full(200, 100.0))
last = out[-1]
# MAMA and FAMA both track price closely on a flat series.
assert abs(last[0] - 100.0) < 1.0
assert abs(last[1] - 100.0) < 1.0
# --- DeMark family ---------------------------------------------------------
def test_td_setup_buy_setup_completes_at_minus_9_uptrend():
# Strictly rising closes -> every bar has close > close[-4] (sell setup);
# the streak hits -9 at index 12 and caps there.
h = np.arange(2.0, 22.0)
l = h - 1.0
c = h - 0.5
out = ta.TDSetup(4, 9).batch(h, l, c)
assert out[12] == pytest.approx(-9.0)
assert out[-1] == pytest.approx(-9.0)
def test_td_demarker_downtrend_pegs_at_zero():
n = 20
h = np.arange(30.0, 30.0 - n, -1.0)
l = h - 2.0
out = ta.TDDeMarker(5).batch(h, l)
assert out[-1] == pytest.approx(0.0)
def test_td_pressure_pure_bearish_yields_minus_100():
n = 20
open_ = np.full(n, 11.0)
high = np.full(n, 11.0)
low = np.full(n, 9.0)
close = np.full(n, 9.0)
volume = np.full(n, 100.0)
out = ta.TDPressure(5).batch(open_, high, low, close, volume)
assert out[-1] == pytest.approx(-100.0)
def test_td_combo_uptrend_completes_to_minus_13():
# Pure uptrend -> setup completes, then combo conditions (close>=high[-2],
# high>=prev.high, close>prev.close) all hold for every subsequent bar
# -> sell combo saturates at -13.
n = 40
high = np.arange(1.0, 1.0 + n) + 0.5
low = high - 1.0
close = high - 0.5
out = ta.TDCombo().batch(high, low, close)
assert out[-1] == pytest.approx(-13.0)
def test_td_countdown_uptrend_completes_to_minus_13():
n = 40
high = np.arange(1.0, 1.0 + n) + 0.5
low = high - 1.0
close = high - 0.5
out = ta.TDCountdown().batch(high, low, close)
assert out[-1] == pytest.approx(-13.0)
def test_td_range_projection_doji_reference():
# open=close=10, high=12, low=9 -> doji branch.
# pivot_sum = 12 + 9 + 2*10 = 41; half = 20.5.
# projHigh = 20.5 - 9 = 11.5; projLow = 20.5 - 12 = 8.5.
out = ta.TDRangeProjection().batch(
np.array([10.0]), np.array([12.0]), np.array([9.0]), np.array([10.0])
)
assert out[0, 0] == pytest.approx(11.5)
assert out[0, 1] == pytest.approx(8.5)
def test_td_open_sell_signal_reference():
# Prev high=12. Curr open=13 > 12, curr low=11 < 12 -> -1.
td = ta.TDOpen()
assert td.update((10.0, 12.0, 9.0, 11.0, 1.0, 0)) is None
assert td.update((13.0, 13.5, 11.0, 11.5, 1.0, 1)) == pytest.approx(-1.0)
def test_td_differential_sell_signal_reference():
# Prev high=10, low=8, close=9: buying=1, selling=1.
# Curr high=12, low=9.8, close=10.5: close>prev.close, selling=1.5>1,
# buying=0.7<1 -> sell signal -1.
td = ta.TDDifferential()
assert td.update((9.0, 10.0, 8.0, 9.0, 1.0, 0)) is None
assert td.update((10.5, 12.0, 9.8, 10.5, 1.0, 1)) == pytest.approx(-1.0)
def test_td_lines_uptrend_support_reference():
# Strictly rising series -> sell setup completes at idx 12, the
# lowest low across bars 4..=12 is the low at idx 4 = 4.5.
n = 20
high = np.arange(1.0, 1.0 + n) + 0.5
low = high - 1.0
close = high - 0.5
out = ta.TDLines().batch(high, low, close)
assert math.isnan(out[-1, 0])
assert out[-1, 1] == pytest.approx(4.5)
def test_td_risk_level_uptrend_sell_risk_reference():
# Strictly rising series -> sell setup completes at idx 12 with high
# 13.5 and true range 1.5 -> sell_risk = 13.5 + 1.5 = 15.0.
# Subsequent setups re-ratchet the level, so we check the first emission
# at idx 12 rather than the latest value.
n = 20
high = np.arange(1.0, 1.0 + n) + 0.5
low = high - 1.0
close = high - 0.5
out = ta.TDRiskLevel().batch(high, low, close)
assert math.isnan(out[12, 0])
assert out[12, 1] == pytest.approx(15.0)
def test_percentage_trailing_stop_seed_and_ratchet():
# 10% trail: first close 100 -> stop 90; next 110 -> stop max(90, 99) = 99.
s = ta.PercentageTrailingStop(10.0)
assert math.isclose(s.update(100.0), 90.0, abs_tol=1e-12)
assert math.isclose(s.update(110.0), 99.0, abs_tol=1e-12)
def test_step_trailing_stop_snaps_below_close():
# step 1: floor((100.4 - 1) / 1) = 99.
s = ta.StepTrailingStop(1.0)
assert math.isclose(s.update(100.4), 99.0, abs_tol=1e-12)
def test_renko_trailing_stop_holds_until_full_block():
# block 1: seed 100 -> stop 99; 100.5 still 99; 101 -> stop 100.
s = ta.RenkoTrailingStop(1.0)
assert math.isclose(s.update(100.0), 99.0, abs_tol=1e-12)
assert math.isclose(s.update(100.5), 99.0, abs_tol=1e-12)
assert math.isclose(s.update(101.0), 100.0, abs_tol=1e-12)
def test_donchian_stop_window_extremes():
# 5-bar window of highs 1..5 and lows 0..4.
high = np.array([1.0, 2.0, 3.0, 4.0, 5.0])
low = np.array([0.0, 1.0, 2.0, 3.0, 4.0])
out = ta.DonchianStop(5).batch(high, low)
# First 4 rows NaN, fifth row: stop_long = 0, stop_short = 5.
for i in range(4):
assert math.isnan(out[i, 0])
assert math.isnan(out[i, 1])
assert math.isclose(out[4, 0], 0.0, abs_tol=1e-12)
assert math.isclose(out[4, 1], 5.0, abs_tol=1e-12)
def test_hilo_activator_flat_market_holds_low_sma():
# Flat candles H=11, L=9, C=10 -> close (10) sits between bands, so the
# initial long seed is preserved: emitted stop = lo_sma = 9.
h = np.full(15, 11.0)
l = np.full(15, 9.0)
c = np.full(15, 10.0)
out = ta.HiLoActivator(3).batch(h, l, c)
# warmup_period == period + 1 == 4, so indices 0..2 are NaN; index 3 onwards is 9.
for i in range(3):
assert math.isnan(out[i])
for i in range(3, 15):
assert math.isclose(out[i], 9.0, abs_tol=1e-12)
def test_volty_stop_flat_market_constant_level():
# ATR=2, mult=2 -> band 4; anchor stays at close 10 -> stop = 10 - 4 = 6.
h = np.full(20, 11.0)
l = np.full(20, 9.0)
c = np.full(20, 10.0)
out = ta.VoltyStop(5, 2.0).batch(h, l, c)
for i in range(4):
assert math.isnan(out[i])
for i in range(4, 20):
assert math.isclose(out[i], 6.0, abs_tol=1e-12)
def test_yoyo_exit_flat_market_constant_level():
# ATR=2, mult=2 -> band 4; trail = close - band = 10 - 4 = 6 and holds.
h = np.full(20, 11.0)
l = np.full(20, 9.0)
c = np.full(20, 10.0)
out = ta.YoyoExit(5, 2.0).batch(h, l, c)
for i in range(4):
assert math.isnan(out[i])
for i in range(4, 20):
assert math.isclose(out[i], 6.0, abs_tol=1e-12)
def test_rvi_volatility_pure_uptrend_saturates_at_one_hundred():
# Strictly rising closes -> every stddev sample classified as "up" ->
# RVIVolatility saturates at 100. Renamed from the original ta.RVI in
# PR 42 to disambiguate from Family 02's Relative Vigor Index, which
# now owns the short ta.RVI name (candle input).
out = ta.RVIVolatility(5).batch(np.arange(1.0, 41.0, dtype=np.float64))
ready = out[~np.isnan(out)]
assert ready.size > 0
np.testing.assert_allclose(ready[-10:], 100.0, atol=1e-9)
def test_parkinson_volatility_zero_range_yields_zero():
# H == L every bar -> ln(H/L) = 0 -> Parkinson sigma is zero.
h = np.full(30, 10.0)
l = np.full(30, 10.0)
out = ta.ParkinsonVolatility(14, 252).batch(h, l)
ready = out[~np.isnan(out)]
assert ready.size > 0
np.testing.assert_allclose(ready, 0.0, atol=1e-12)
def test_garman_klass_zero_movement_yields_zero():
# O == H == L == C every bar -> both log terms are zero -> sigma is zero.
o = np.full(30, 10.0)
h = np.full(30, 10.0)
l = np.full(30, 10.0)
c = np.full(30, 10.0)
out = ta.GarmanKlassVolatility(14, 252).batch(o, h, l, c)
ready = out[~np.isnan(out)]
assert ready.size > 0
np.testing.assert_allclose(ready, 0.0, atol=1e-12)
def test_rogers_satchell_zero_movement_yields_zero():
o = np.full(30, 10.0)
h = np.full(30, 10.0)
l = np.full(30, 10.0)
c = np.full(30, 10.0)
out = ta.RogersSatchellVolatility(14, 252).batch(o, h, l, c)
ready = out[~np.isnan(out)]
assert ready.size > 0
np.testing.assert_allclose(ready, 0.0, atol=1e-12)
def test_yang_zhang_zero_movement_yields_zero():
# O == H == L == C and constant across bars -> every sub-component is
# zero -> Yang-Zhang sigma is zero.
o = np.full(30, 10.0)
h = np.full(30, 10.0)
l = np.full(30, 10.0)
c = np.full(30, 10.0)
out = ta.YangZhangVolatility(14, 252).batch(o, h, l, c)
ready = out[~np.isnan(out)]
assert ready.size > 0
np.testing.assert_allclose(ready, 0.0, atol=1e-12)
+43
View File
@@ -86,3 +86,46 @@ def test_candle_tuple_input_supported():
atr.update((10.0, 11.0, 9.0, 10.5, 1.0, 0))
v = atr.update((10.5, 12.0, 10.0, 11.0, 1.0, 1))
assert v is not None
def test_initial_balance_reset_unlocks():
ib = ta.InitialBalance(2)
assert not ib.is_ready()
ib.update((101.0, 102.0, 100.0, 101.0, 0.0, 0))
ib.update((102.0, 103.0, 101.0, 102.0, 0.0, 1))
assert ib.is_ready()
assert ib.is_locked()
ib.reset()
assert not ib.is_ready()
assert not ib.is_locked()
def test_opening_range_reset_unlocks():
or_ind = ta.OpeningRange(2)
or_ind.update((101.0, 102.0, 100.0, 101.0, 0.0, 0))
or_ind.update((102.0, 103.0, 101.0, 102.0, 0.0, 1))
assert or_ind.is_locked()
or_ind.reset()
assert not or_ind.is_locked()
def test_value_area_warmup_equals_period():
assert ta.ValueArea(20, 50, 0.70).warmup_period() == 20
assert ta.ValueArea(10, 30, 0.80).warmup_period() == 10
def test_ehlers_indicators_lifecycle():
# Spot-check a few Family-10 entries beyond what test_new_indicators covers.
series = np.linspace(1.0, 200.0, 200) + np.sin(np.arange(200) * 0.3) * 5.0
for ind in [
ta.SuperSmoother(10),
ta.FisherTransform(10),
ta.MAMA(),
ta.HilbertDominantCycle(),
ta.SineWave(),
]:
assert not ind.is_ready()
ind.batch(series)
assert ind.is_ready()
ind.reset()
assert not ind.is_ready()
File diff suppressed because it is too large Load Diff
+42
View File
@@ -55,3 +55,45 @@ def test_obv_batch_shape(ohlc_series):
volume = np.ones_like(close)
out = ta.OBV().batch(close, volume)
assert out.shape == close.shape
def test_value_area_batch_shape(ohlc_series):
high, low, close = ohlc_series
volume = np.ones_like(close)
out = ta.ValueArea(20, 50, 0.70).batch(high, low, volume)
assert out.shape == (close.size, 3)
def test_initial_balance_batch_shape(ohlc_series):
high, low, _close = ohlc_series
out = ta.InitialBalance(12).batch(high, low)
assert out.shape == (high.size, 2)
def test_opening_range_batch_shape(ohlc_series):
high, low, close = ohlc_series
out = ta.OpeningRange(6).batch(high, low, close)
assert out.shape == (close.size, 3)
def test_ichimoku_batch_returns_n_by_5(ohlc_series):
high, low, close = ohlc_series
out = ta.Ichimoku().batch(high, low, close)
assert out.shape == (close.size, 5)
def test_heikin_ashi_batch_returns_n_by_4(ohlc_series):
high, low, close = ohlc_series
open_ = (high + low) / 2.0
out = ta.HeikinAshi().batch(open_, high, low, close)
assert out.shape == (close.size, 4)
def test_ehlers_super_smoother_batch_shape(sine_prices):
out = ta.SuperSmoother(10).batch(sine_prices)
assert out.shape == sine_prices.shape
def test_mama_batch_shape(sine_prices):
out = ta.MAMA().batch(sine_prices)
assert out.shape == (sine_prices.size, 2)
@@ -117,6 +117,30 @@ def test_obv_streaming_matches_batch(ohlc_series):
assert _equal_with_nan(batch, streamed)
def test_mama_streaming_matches_batch(sine_prices):
batch = ta.MAMA().batch(sine_prices)
streamer = ta.MAMA()
rows = []
for p in sine_prices:
v = streamer.update(float(p))
if v is None:
rows.append([math.nan, math.nan])
else:
rows.append(list(v))
streamed = np.array(rows, dtype=np.float64)
assert _equal_with_nan(batch, streamed)
def test_super_smoother_streaming_matches_batch(sine_prices):
batch = ta.SuperSmoother(10).batch(sine_prices)
streamer = ta.SuperSmoother(10)
streamed = np.array(
[math.nan if (v := streamer.update(float(p))) is None else float(v) for p in sine_prices],
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.
@@ -135,3 +159,45 @@ def test_rolling_vwap_streaming_matches_batch(ohlc_series):
assert streamer.is_ready()
streamer.reset()
assert not streamer.is_ready()
def test_value_area_streaming_matches_batch(ohlc_series):
high, low, close = ohlc_series
volume = np.linspace(100.0, 200.0, num=close.size, dtype=np.float64)
batch = ta.ValueArea(20, 50, 0.70).batch(high, low, volume)
streamer = ta.ValueArea(20, 50, 0.70)
rows = []
for h, l, v in zip(high, low, volume):
mid = float((h + l) / 2.0)
out = streamer.update((mid, float(h), float(l), mid, float(v), 0))
rows.append([math.nan, math.nan, math.nan] if out is None else list(out))
streamed = np.array(rows, dtype=np.float64)
assert _equal_with_nan(batch, streamed)
def test_initial_balance_streaming_matches_batch(ohlc_series):
high, low, _close = ohlc_series
batch = ta.InitialBalance(12).batch(high, low)
streamer = ta.InitialBalance(12)
rows = []
for h, l in zip(high, low):
mid = float((h + l) / 2.0)
out = streamer.update((mid, float(h), float(l), mid, 0.0, 0))
rows.append([math.nan, math.nan] if out is None else list(out))
streamed = np.array(rows, dtype=np.float64)
assert _equal_with_nan(batch, streamed)
def test_opening_range_streaming_matches_batch(ohlc_series):
high, low, close = ohlc_series
batch = ta.OpeningRange(6).batch(high, low, close)
streamer = ta.OpeningRange(6)
rows = []
for h, l, c in zip(high, low, close):
out = streamer.update((float(c), float(h), float(l), float(c), 0.0, 0))
rows.append([math.nan, math.nan, math.nan] if out is None else list(out))
streamed = np.array(rows, dtype=np.float64)
assert _equal_with_nan(batch, streamed)
+39 -31
View File
@@ -1,7 +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)
[![CI](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml/badge.svg)](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml)
[![codecov](https://codecov.io/gh/wickra-lib/wickra/branch/main/graph/badge.svg)](https://codecov.io/gh/wickra-lib/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)
@@ -36,16 +36,16 @@ for price in live_feed:
The Python TA ecosystem has plenty of libraries — TA-Lib, pandas-ta, finta,
talipp, tulipy — and every one of them shares the same blind spot:
| Library | Install pain | Streaming | Multi-language | Active |
|--------------------|-----------------|-----------|----------------|--------|
| TA-Lib (Python) | yes (C deps) | no | no | barely |
| pandas-ta | clean | no | no | slow |
| finta | clean | no | no | stale |
| ta-lib-python | yes (C deps) | no | no | barely |
| talipp | clean | yes | no | yes |
| Tulip Indicators | yes (C deps) | no | partial | stale |
| ooples (C#) | clean | no | C# only | yes |
| **Wickra** | **clean** | **yes** | **Python+Node+WASM+Rust** | **yes** |
| Library | Install pain | Streaming | Multi-language | Active |
|------------------------|-----------------|-----------|----------------|--------|
| **★&nbsp;Wickra** | **clean** | **yes** | **Python + Node + WASM + Rust** | **yes** |
| TA-Lib (Python) | yes (C deps) | no | no | barely |
| pandas-ta | clean | no | no | slow |
| finta | clean | no | no | stale |
| ta-lib-python | yes (C deps) | no | no | barely |
| talipp | clean | yes | no | yes |
| Tulip Indicators | yes (C deps) | no | partial | stale |
| ooples (C#) | clean | no | C# only | yes |
Wickra is the only library that combines all of: clean install, streaming,
multi-language reach, and active maintenance.
@@ -58,7 +58,7 @@ 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,
- **Reproduced on:** Windows 11 Pro 26200, AMD Ryzen 9 9950X, 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
@@ -76,7 +76,7 @@ to recompute on every tick.
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 |
| Indicator | **★&nbsp;Wickra** | finta | talipp |
|---------------------|---------------------|-----------------------------|-------------------------------|
| SMA(20) | **95.6 µs ★** | 343.5 µs (3.6× slower) | 7 640.6 µs (79.9× slower) |
| EMA(20) | **64.6 µs ★** | 223.1 µs (3.5× slower) | 12 160.9 µs (188.2× slower) |
@@ -90,7 +90,7 @@ slower it is than Wickra in parentheses. **★** marks the winner per row.
A batch-only library has to re-run its full indicator over the entire history on
every new tick; Wickra updates state in O(1).
| Indicator | Wickra (per tick) | talipp (per tick) |
| Indicator | **★&nbsp;Wickra (per tick)** | talipp (per tick) |
|-----------|---------------------|---------------------------|
| RSI(14) | **0.119 µs ★** | 1.644 µs (13.8× slower) |
@@ -109,20 +109,28 @@ python -m benchmarks.compare_libraries
## Indicators
71 streaming-first indicators across eight families. Every one passes the
214 streaming-first indicators across sixteen families. Every one passes the
`batch == streaming` equivalence test, reference-value tests, and reset
semantics tests.
| Family | Indicators |
|--------|-----------|
| Moving Averages | SMA, EMA, WMA, DEMA, TEMA, HMA, KAMA, SMMA, TRIMA, ZLEMA, T3, VWMA |
| Momentum Oscillators | RSI (Wilder), Stochastic, CCI, ROC, Williams %R, MFI, Awesome Oscillator, MOM, CMO, TSI, PMO, StochRSI, Ultimate Oscillator |
| Trend & Directional | MACD, ADX (+DI/-DI), Aroon, TRIX, Aroon Oscillator, Vortex, Mass Index, Choppiness Index, Vertical Horizontal Filter |
| Price Oscillators | PPO, DPO, Coppock, Accelerator Oscillator, Balance of Power |
| Volatility & Bands | ATR, Bollinger Bands, Keltner Channels, Donchian Channels, NATR, StdDev, Ulcer Index, Historical Volatility, Bollinger Bandwidth, %B, True Range, Chaikin Volatility |
| Trailing Stops | Parabolic SAR, SuperTrend, Chandelier Exit, Chande Kroll Stop, ATR Trailing Stop |
| Volume | OBV, VWAP (cumulative + rolling), ADL, Volume-Price Trend, Chaikin Money Flow, Chaikin Oscillator, Force Index, Ease of Movement |
| Price Statistics | Typical Price, Median Price, Weighted Close, Linear Regression, Linear Regression Slope, Z-Score, Linear Regression Angle |
| Moving Averages | SMA, EMA, WMA, DEMA, TEMA, HMA, KAMA, SMMA, TRIMA, ZLEMA, T3, VWMA, ALMA, McGinley Dynamic, FRAMA, VIDYA, JMA, Alligator, EVWMA |
| Momentum Oscillators | RSI (Wilder), Stochastic, CCI, ROC, Williams %R, MFI, Awesome Oscillator, MOM, CMO, TSI, PMO, StochRSI, Ultimate Oscillator, RVI, PGO, KST, SMI, Laguerre RSI, Connors RSI, Inertia |
| Trend & Directional | MACD, ADX (+DI/-DI), ADXR, Aroon, TRIX, Aroon Oscillator, Vortex, Random Walk Index, Trend Intensity Index, Wave Trend Oscillator, Mass Index, Choppiness Index, Vertical Horizontal Filter |
| Price Oscillators | PPO, DPO, Coppock, Accelerator Oscillator, Balance of Power, APO, AO Histogram, CFO, Zero-Lag MACD, Elder Impulse, STC |
| Volatility & Bands | ATR, Bollinger Bands, Keltner Channels, Donchian Channels, NATR, StdDev, Ulcer Index, Historical Volatility, Bollinger Bandwidth, %B, True Range, Chaikin Volatility, RVI (Relative Volatility Index), Parkinson Volatility, Garman-Klass Volatility, Rogers-Satchell Volatility, Yang-Zhang Volatility, Detrended StdDev |
| Bands & Channels | MA Envelope, Acceleration Bands, STARC Bands, ATR Bands, Hurst Channel, LinReg Channel, Standard Error Bands, Double Bollinger Bands, TTM Squeeze, Fractal Chaos Bands, VWAP StdDev Bands |
| Trailing Stops | Parabolic SAR, SuperTrend, Chandelier Exit, Chande Kroll Stop, ATR Trailing Stop, HiLo Activator, Volty Stop, Yo-Yo Exit, Donchian Channel Stop, Percentage Trailing Stop, Step Trailing Stop, Renko Trailing Stop |
| Volume | OBV, VWAP (cumulative + rolling), ADL, Volume-Price Trend, Chaikin Money Flow, Chaikin Oscillator, Force Index, Ease of Movement, Klinger Volume Oscillator, Volume Oscillator, NVI, PVI, Williams A/D, Anchored VWAP, Demand Index, TSV, VZO, Market Facilitation Index |
| Price Statistics | Typical Price, Median Price, Weighted Close, Linear Regression, Linear Regression Slope, Z-Score, Linear Regression Angle, Variance, Coefficient of Variation, Skewness, Kurtosis, Standard Error, R², Median Absolute Deviation, Autocorrelation, Hurst Exponent, Pearson Correlation, Beta, Spearman Correlation |
| Ehlers / Cycle (DSP) | MAMA, FAMA, Fisher Transform, Inverse Fisher Transform, SuperSmoother, Hilbert Dominant Cycle, Sine Wave, Decycler, Decycler Oscillator, Roofing Filter, Center of Gravity, Cybernetic Cycle, Adaptive Cycle, Empirical Mode Decomposition, Ehlers Stochastic, Instantaneous Trendline |
| Pivots & S/R | Classic Pivots, Fibonacci Pivots, Camarilla, Woodie Pivots, DeMark Pivots, Williams Fractals, ZigZag |
| DeMark | TD Setup, TD Sequential, TD DeMarker, TD REI, TD Pressure, TD Combo, TD Countdown, TD Lines, TD Range Projection, TD Differential, TD Open, TD Risk Level |
| Ichimoku & Charts | Ichimoku Kinko Hyo (Tenkan, Kijun, Senkou A/B, Chikou), Heikin-Ashi |
| Candlestick Patterns | Doji, Hammer, Inverted Hammer, Hanging Man, Shooting Star, Engulfing, Harami, Morning/Evening Star, Three White Soldiers/Black Crows, Piercing Line/Dark Cloud Cover, Marubozu, Tweezer, Spinning Top, Three Inside Up/Down, Three Outside Up/Down |
| Market Profile | Value Area (POC / VAH / VAL), Initial Balance, Opening Range |
| Risk / Performance | Sharpe Ratio, Sortino Ratio, Calmar Ratio, Omega Ratio, Max Drawdown, Average Drawdown, Drawdown Duration, Pain Index, Value at Risk, Conditional Value at Risk (CVaR), Profit Factor, Gain/Loss Ratio, Recovery Factor, Kelly Criterion, Treynor Ratio, Information Ratio, Alpha (Jensen) |
Adding a new indicator means implementing one trait in Rust; all four bindings
inherit it automatically.
@@ -256,7 +264,7 @@ Every layer is covered; run the suites with the commands in
## Contributing
Contributions are very welcome — issues, bug reports, ideas, and pull requests
all land in the same place: <https://github.com/kingchenc/wickra>.
all land in the same place: <https://github.com/wickra-lib/wickra>.
A short orientation for first-time contributors:
@@ -290,14 +298,14 @@ use Wickra commercially, get in touch about a license.
---
<p align="center">
<a href="https://github.com/kingchenc/wickra/stargazers">
<img alt="GitHub stars" src="https://img.shields.io/github/stars/kingchenc/wickra?style=for-the-badge&logo=github&logoColor=white&color=ffd866">
<a href="https://github.com/wickra-lib/wickra/stargazers">
<img alt="GitHub stars" src="https://img.shields.io/github/stars/wickra-lib/wickra?style=for-the-badge&logo=github&logoColor=white&color=ffd866">
</a>
<a href="https://github.com/kingchenc/wickra/network/members">
<img alt="GitHub forks" src="https://img.shields.io/github/forks/kingchenc/wickra?style=for-the-badge&logo=github&logoColor=white&color=78dce8">
<a href="https://github.com/wickra-lib/wickra/network/members">
<img alt="GitHub forks" src="https://img.shields.io/github/forks/wickra-lib/wickra?style=for-the-badge&logo=github&logoColor=white&color=78dce8">
</a>
<a href="https://github.com/kingchenc/wickra/issues">
<img alt="GitHub issues" src="https://img.shields.io/github/issues/kingchenc/wickra?style=for-the-badge&logo=github&logoColor=white&color=ff6188">
<a href="https://github.com/wickra-lib/wickra/issues">
<img alt="GitHub issues" src="https://img.shields.io/github/issues/wickra-lib/wickra?style=for-the-badge&logo=github&logoColor=white&color=ff6188">
</a>
</p>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,277 @@
//! Acceleration Bands (Price Headley).
use crate::error::{Error, Result};
use crate::indicators::sma::Sma;
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Acceleration Bands output: SMA of close with momentum-biased envelopes
/// driven by the bar's high/low geometry.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct AccelerationBandsOutput {
/// Upper band: SMA of `high · (1 + factor · (high low) / (high + low))`.
pub upper: f64,
/// Middle band: SMA of close.
pub middle: f64,
/// Lower band: SMA of `low · (1 factor · (high low) / (high + low))`.
pub lower: f64,
}
/// Acceleration Bands (Price Headley): SMA-smoothed bands that widen with each
/// bar's relative range `(high low) / (high + low)`.
///
/// ```text
/// ratio = (high low) / (high + low)
/// raw_up = high · (1 + factor · ratio)
/// raw_lo = low · (1 factor · ratio)
/// upper = SMA(raw_up, period)
/// middle = SMA(close, period)
/// lower = SMA(raw_lo, period)
/// ```
///
/// Headley's reference parameters are `period = 20`, `factor = 0.001` for
/// intraday equity markets — the geometric `ratio` term tends to scale on
/// fractional moves, so the literal `factor` is small. The bands compress in
/// quiet markets and flare on impulsive bars, making them a momentum-biased
/// alternative to the volatility-driven Bollinger or Keltner envelopes.
///
/// # Example
///
/// ```
/// use wickra_core::{AccelerationBands, Candle, Indicator};
///
/// let mut indicator = AccelerationBands::new(20, 0.001).unwrap();
/// let mut last = None;
/// for i in 0..40 {
/// 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 AccelerationBands {
upper_sma: Sma,
middle_sma: Sma,
lower_sma: Sma,
factor: f64,
period: usize,
}
impl AccelerationBands {
/// Construct a new Acceleration Bands indicator.
///
/// # Errors
/// Returns [`Error::PeriodZero`] if `period == 0` and
/// [`Error::NonPositiveMultiplier`] if `factor` is not strictly positive
/// and finite.
pub fn new(period: usize, factor: f64) -> Result<Self> {
if !factor.is_finite() || factor <= 0.0 {
return Err(Error::NonPositiveMultiplier);
}
Ok(Self {
upper_sma: Sma::new(period)?,
middle_sma: Sma::new(period)?,
lower_sma: Sma::new(period)?,
factor,
period,
})
}
/// Headley's classic configuration: `period = 20`, `factor = 0.001`.
pub fn classic() -> Self {
Self::new(20, 0.001).expect("classic Acceleration Bands parameters are valid")
}
/// Configured `(period, factor)`.
pub const fn parameters(&self) -> (usize, f64) {
(self.period, self.factor)
}
}
impl Indicator for AccelerationBands {
type Input = Candle;
type Output = AccelerationBandsOutput;
fn update(&mut self, candle: Candle) -> Option<AccelerationBandsOutput> {
// (high + low) == 0 is geometrically impossible for valid OHLC
// (high >= low and a zero-sum requires both equal to 0, which would
// make the bar degenerate). Guard anyway so a hypothetical zero-price
// bar collapses the ratio to zero rather than emitting NaN.
let sum_hl = candle.high + candle.low;
let ratio = if sum_hl == 0.0 {
0.0
} else {
(candle.high - candle.low) / sum_hl
};
let raw_up = candle.high * self.factor.mul_add(ratio, 1.0);
let raw_lo = candle.low * (-self.factor).mul_add(ratio, 1.0);
// Feed all three SMAs unconditionally so they warm up in lock-step.
let upper = self.upper_sma.update(raw_up);
let middle = self.middle_sma.update(candle.close);
let lower = self.lower_sma.update(raw_lo);
let (upper, middle, lower) = (upper?, middle?, lower?);
Some(AccelerationBandsOutput {
upper,
middle,
lower,
})
}
fn reset(&mut self) {
self.upper_sma.reset();
self.middle_sma.reset();
self.lower_sma.reset();
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.middle_sma.is_ready()
}
fn name(&self) -> &'static str {
"AccelerationBands"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn c(h: f64, l: f64, cl: f64) -> Candle {
Candle::new(cl, h, l, cl, 1.0, 0).unwrap()
}
#[test]
fn rejects_zero_period() {
assert!(matches!(
AccelerationBands::new(0, 0.001),
Err(Error::PeriodZero)
));
}
#[test]
fn rejects_non_positive_factor() {
assert!(matches!(
AccelerationBands::new(20, 0.0),
Err(Error::NonPositiveMultiplier)
));
assert!(matches!(
AccelerationBands::new(20, -1.0),
Err(Error::NonPositiveMultiplier)
));
assert!(matches!(
AccelerationBands::new(20, f64::NAN),
Err(Error::NonPositiveMultiplier)
));
}
#[test]
fn accessors_and_metadata() {
let ab = AccelerationBands::classic();
let (p, f) = ab.parameters();
assert_eq!(p, 20);
assert_relative_eq!(f, 0.001, epsilon = 1e-12);
assert_eq!(ab.warmup_period(), 20);
assert_eq!(ab.name(), "AccelerationBands");
}
#[test]
fn flat_market_collapses_to_constant() {
// high == low so the ratio term is zero; all three SMAs converge to
// the same constant.
let candles: Vec<Candle> = (0..30).map(|_| c(10.0, 10.0, 10.0)).collect();
let mut ab = AccelerationBands::new(5, 0.5).unwrap();
let last = ab.batch(&candles).into_iter().flatten().last().unwrap();
assert_relative_eq!(last.middle, 10.0, epsilon = 1e-9);
assert_relative_eq!(last.upper, 10.0, epsilon = 1e-9);
assert_relative_eq!(last.lower, 10.0, epsilon = 1e-9);
}
#[test]
fn warmup_returns_none() {
let mut ab = AccelerationBands::new(5, 0.001).unwrap();
for i in 0..4 {
let base = 100.0 + f64::from(i);
assert!(ab.update(c(base + 1.0, base - 1.0, base)).is_none());
}
assert!(ab.update(c(105.0, 103.0, 104.0)).is_some());
}
#[test]
fn upper_above_middle_above_lower() {
let candles: Vec<Candle> = (0..50)
.map(|i| {
let m = 100.0 + (f64::from(i) * 0.2).sin() * 5.0;
c(m + 1.0, m - 1.0, m)
})
.collect();
let mut ab = AccelerationBands::new(20, 0.5).unwrap();
for o in ab.batch(&candles).into_iter().flatten() {
assert!(o.upper >= o.middle, "{} < {}", o.upper, o.middle);
assert!(o.middle >= o.lower, "{} < {}", o.middle, o.lower);
}
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..40)
.map(|i| c(f64::from(i) + 2.0, f64::from(i), f64::from(i) + 1.0))
.collect();
let mut a = AccelerationBands::new(10, 0.5).unwrap();
let mut b = AccelerationBands::new(10, 0.5).unwrap();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let candles: Vec<Candle> = (0..10)
.map(|i| c(f64::from(i) + 2.0, f64::from(i), f64::from(i) + 1.0))
.collect();
let mut ab = AccelerationBands::new(5, 0.5).unwrap();
ab.batch(&candles);
assert!(ab.is_ready());
ab.reset();
assert!(!ab.is_ready());
assert_eq!(ab.update(candles[0]), None);
}
#[test]
fn zero_price_candle_collapses_ratio_to_zero() {
// `high + low == 0` is geometrically only reachable with a fully-zero
// bar (high >= low and both non-negative for a real market, but
// `Candle::new` accepts the degenerate `(0, 0, 0, 0)` case). The
// ratio guard must fire and the bands all collapse to zero.
let zero = Candle::new(0.0, 0.0, 0.0, 0.0, 1.0, 0).unwrap();
let mut ab = AccelerationBands::new(1, 0.5).unwrap();
let v = ab.update(zero).unwrap();
assert_relative_eq!(v.upper, 0.0, epsilon = 1e-12);
assert_relative_eq!(v.middle, 0.0, epsilon = 1e-12);
assert_relative_eq!(v.lower, 0.0, epsilon = 1e-12);
}
/// Hand-computed reference. Single bar with `high = 12`, `low = 8`,
/// `close = 10`, `factor = 0.5`, `period = 1`.
/// `ratio = (12 8) / (12 + 8) = 0.2`
/// `raw_up = 12 · (1 + 0.5 · 0.2) = 12 · 1.1 = 13.2`
/// `raw_lo = 8 · (1 0.5 · 0.2) = 8 · 0.9 = 7.2`
/// `middle = SMA(close, 1) = 10`
#[test]
fn reference_value_single_bar() {
let mut ab = AccelerationBands::new(1, 0.5).unwrap();
let v = ab.update(c(12.0, 8.0, 10.0)).unwrap();
assert_relative_eq!(v.upper, 13.2, epsilon = 1e-12);
assert_relative_eq!(v.middle, 10.0, epsilon = 1e-12);
assert_relative_eq!(v.lower, 7.2, epsilon = 1e-12);
}
}
@@ -0,0 +1,220 @@
//! Williams Accumulation/Distribution.
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Larry Williams' Accumulation/Distribution — a cumulative volume-less price
/// flow that classifies each bar as accumulation or distribution based on its
/// close relative to the previous close, then sums the directional component.
///
/// Williams' definition (1972) uses a *true* high/low that includes the prior
/// close as an anchor — the same idea that motivates true range:
///
/// ```text
/// TR_h_t = max(close_{t1}, high_t)
/// TR_l_t = min(close_{t1}, low_t)
/// AD_t = AD_{t1} + (close_t TR_l_t) if close_t > close_{t1} (accumulation)
/// AD_t = AD_{t1} + (close_t TR_h_t) if close_t < close_{t1} (distribution)
/// AD_t = AD_{t1} if close_t == close_{t1} (no change)
/// ```
///
/// Unlike Chaikin's Accumulation/Distribution Line, the Williams A/D ignores
/// volume entirely — Williams argued that the relative position of the close
/// already encodes the day's "true" buying or selling pressure. The series is
/// unbounded and used primarily for divergence analysis. The first candle only
/// seeds the previous close; the first emission lands at bar 2.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, AdOscillator};
///
/// let mut indicator = AdOscillator::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 AdOscillator {
prev_close: Option<f64>,
total: f64,
has_emitted: bool,
}
impl AdOscillator {
/// Construct a new Williams A/D starting at zero.
pub const fn new() -> Self {
Self {
prev_close: None,
total: 0.0,
has_emitted: false,
}
}
/// Current cumulative value if at least one emission has happened.
pub const fn value(&self) -> Option<f64> {
if self.has_emitted {
Some(self.total)
} else {
None
}
}
}
impl Indicator for AdOscillator {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
let Some(prev) = self.prev_close else {
// The first bar only establishes the previous close anchor.
self.prev_close = Some(candle.close);
return None;
};
let delta = if candle.close > prev {
// Accumulation: distance from the true low.
let tr_l = prev.min(candle.low);
candle.close - tr_l
} else if candle.close < prev {
// Distribution: distance from the true high (negative).
let tr_h = prev.max(candle.high);
candle.close - tr_h
} else {
// Unchanged close contributes nothing.
0.0
};
self.total += delta;
self.prev_close = Some(candle.close);
self.has_emitted = true;
Some(self.total)
}
fn reset(&mut self) {
self.prev_close = None;
self.total = 0.0;
self.has_emitted = false;
}
fn warmup_period(&self) -> usize {
// One seed bar; the second bar is the first emission.
2
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"WilliamsAD"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle {
Candle::new(open, high, low, close, 100.0, ts).unwrap()
}
#[test]
fn accessors_and_metadata() {
let ad = AdOscillator::new();
assert_eq!(ad.name(), "WilliamsAD");
assert_eq!(ad.warmup_period(), 2);
assert_eq!(ad.value(), None);
}
#[test]
fn value_returns_total_after_first_emission() {
let mut ad = AdOscillator::new();
ad.update(c(10.0, 11.0, 9.0, 10.0, 0));
let v = ad.update(c(11.0, 13.0, 8.0, 12.0, 1)).unwrap();
assert_relative_eq!(ad.value().unwrap(), v, epsilon = 1e-12);
}
#[test]
fn first_bar_only_seeds() {
let mut ad = AdOscillator::new();
assert_eq!(ad.update(c(10.0, 11.0, 9.0, 10.0, 0)), None);
assert!(!ad.is_ready());
}
#[test]
fn accumulation_adds_distance_from_true_low() {
// prev close = 10, today low = 8, today close = 12 (up day).
// TR_l = min(10, 8) = 8, delta = 12 - 8 = 4. AD = 0 + 4 = 4.
let mut ad = AdOscillator::new();
ad.update(c(10.0, 11.0, 9.0, 10.0, 0));
let v = ad.update(c(11.0, 13.0, 8.0, 12.0, 1)).unwrap();
assert_relative_eq!(v, 4.0, epsilon = 1e-12);
}
#[test]
fn distribution_adds_distance_from_true_high() {
// prev close = 10, today high = 11, today close = 7 (down day).
// TR_h = max(10, 11) = 11, delta = 7 - 11 = -4. AD = -4.
let mut ad = AdOscillator::new();
ad.update(c(10.0, 11.0, 9.0, 10.0, 0));
let v = ad.update(c(10.0, 11.0, 7.0, 7.0, 1)).unwrap();
assert_relative_eq!(v, -4.0, epsilon = 1e-12);
}
#[test]
fn unchanged_close_keeps_total() {
// close equals prev close -> no contribution.
let mut ad = AdOscillator::new();
ad.update(c(10.0, 11.0, 9.0, 10.0, 0));
let v = ad.update(c(10.0, 12.0, 8.0, 10.0, 1)).unwrap();
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
}
#[test]
fn constant_series_yields_zero() {
// Every close equals the previous -> AD stays at zero forever.
let candles: Vec<Candle> = (0..40).map(|i| c(10.0, 11.0, 9.0, 10.0, i)).collect();
let mut ad = AdOscillator::new();
for v in ad.batch(&candles).into_iter().flatten() {
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
}
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..80i64)
.map(|i| {
let f = i as f64;
let mid = 100.0 + (f * 0.3).sin() * 5.0;
c(mid, mid + 2.0, mid - 2.0, mid + 0.5, i)
})
.collect();
let mut a = AdOscillator::new();
let mut b = AdOscillator::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut ad = AdOscillator::new();
ad.batch(&[
c(10.0, 11.0, 9.0, 10.0, 0),
c(10.0, 12.0, 9.0, 11.0, 1),
c(11.0, 13.0, 10.0, 12.0, 2),
]);
assert!(ad.is_ready());
ad.reset();
assert!(!ad.is_ready());
assert_eq!(ad.value(), None);
assert_eq!(ad.update(c(10.0, 11.0, 9.0, 10.0, 3)), None);
}
}
@@ -0,0 +1,143 @@
//! Ehlers Adaptive Cycle period estimator (for adaptive oscillators).
use crate::indicators::hilbert_dominant_cycle::HilbertDominantCycle;
use crate::traits::Indicator;
/// Ehlers' Adaptive Cycle Indicator.
///
/// Returns half the current dominant cycle period — the "best" lookback for
/// downstream oscillators like an adaptive RSI or adaptive Stochastic, per
/// Ehlers' *Cycle Analytics for Traders* (2013, ch. 11). Halving accounts for
/// the fact that an oscillator over a half-cycle captures the full peak-to-
/// trough swing without aliasing.
///
/// The output is rounded to an integer-valued `f64` and clamped to `[3, 25]`,
/// matching the typical operating range of period-adaptive oscillators.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, AdaptiveCycle};
///
/// let mut ac = AdaptiveCycle::new();
/// let mut last = None;
/// for i in 0..200 {
/// last = ac.update(100.0 + (f64::from(i) * 0.4).sin() * 5.0);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone, Default)]
pub struct AdaptiveCycle {
cycle: HilbertDominantCycle,
last_value: Option<f64>,
}
impl AdaptiveCycle {
/// Construct a new adaptive cycle estimator.
pub fn new() -> Self {
Self::default()
}
/// Current adaptive period if available.
pub const fn value(&self) -> Option<f64> {
self.last_value
}
}
impl Indicator for AdaptiveCycle {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
let period = self.cycle.update(input)?;
let half = (period * 0.5).round().clamp(3.0, 25.0);
self.last_value = Some(half);
Some(half)
}
fn reset(&mut self) {
self.cycle.reset();
self.last_value = None;
}
fn warmup_period(&self) -> usize {
self.cycle.warmup_period()
}
fn is_ready(&self) -> bool {
self.last_value.is_some()
}
fn name(&self) -> &'static str {
"AdaptiveCycle"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
#[test]
fn accessors_and_metadata() {
let mut ac = AdaptiveCycle::new();
assert_eq!(ac.warmup_period(), 50);
assert_eq!(ac.name(), "AdaptiveCycle");
assert!(!ac.is_ready());
assert!(ac.value().is_none());
let prices: Vec<f64> = (0..120)
.map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 5.0)
.collect();
ac.batch(&prices);
assert!(ac.is_ready());
assert!(ac.value().is_some());
}
#[test]
fn output_within_clamp_band() {
let prices: Vec<f64> = (0..200)
.map(|i| 100.0 + (f64::from(i) * 0.5).sin() * 5.0)
.collect();
let mut ac = AdaptiveCycle::new();
for v in ac.batch(&prices).into_iter().flatten() {
assert!((3.0..=25.0).contains(&v), "period {v} out of band");
assert_eq!(v, v.round(), "expected integer-valued output");
}
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (0..200)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0)
.collect();
let mut a = AdaptiveCycle::new();
let mut b = AdaptiveCycle::new();
let batch = a.batch(&prices);
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
assert_eq!(batch, streamed);
}
#[test]
fn ignores_non_finite_input() {
let mut ac = AdaptiveCycle::new();
let prices: Vec<f64> = (0..120)
.map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 5.0)
.collect();
ac.batch(&prices);
let before = ac.value();
assert!(before.is_some());
assert_eq!(ac.update(f64::NAN), before);
}
#[test]
fn reset_clears_state() {
let mut ac = AdaptiveCycle::new();
let prices: Vec<f64> = (0..120)
.map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 5.0)
.collect();
ac.batch(&prices);
assert!(ac.is_ready());
ac.reset();
assert!(!ac.is_ready());
}
}
+246
View File
@@ -0,0 +1,246 @@
//! Average Directional Movement Index Rating (ADXR).
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::indicators::adx::Adx;
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Wilder's Average Directional Movement Index Rating.
///
/// `ADXR` smooths the [`Adx`] line by averaging its current value with the value
/// it had `period` bars ago:
///
/// ```text
/// ADXR_t = (ADX_t + ADX_{t - (period - 1)}) / 2
/// ```
///
/// The lookback length is the same `period` that feeds the underlying ADX.
/// Wilder introduced ADXR alongside ADX in *New Concepts in Technical Trading
/// Systems* (1978) as a more stable directional-strength reading: because the
/// older `ADX` is `period - 1` bars stale, ADXR responds more slowly than ADX
/// and is used to compare trend-strength between different instruments.
///
/// The first complete `ADXR` is emitted after `3 * period - 1` candles
/// (`2 * period` to seed the ADX plus another `period - 1` to fill the
/// lookback ring).
///
/// # Example
///
/// ```
/// use wickra_core::{Adxr, Candle, Indicator};
///
/// let mut indicator = Adxr::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 Adxr {
period: usize,
adx: Adx,
/// Ring buffer of the most recent `period` `ADX` values; the front is the
/// oldest, the back is the newest. ADXR is `(back + front) / 2` once the
/// ring is full.
window: VecDeque<f64>,
last: Option<f64>,
}
impl Adxr {
/// Construct a new ADXR with the given Wilder smoothing 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,
adx: Adx::new(period)?,
window: VecDeque::with_capacity(period),
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 Adxr {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
let adx_value = self.adx.update(candle)?.adx;
if self.window.len() == self.period {
self.window.pop_front();
}
self.window.push_back(adx_value);
if self.window.len() < self.period {
return None;
}
let oldest = *self.window.front().expect("ring is full");
let adxr = f64::midpoint(adx_value, oldest);
self.last = Some(adxr);
Some(adxr)
}
fn reset(&mut self) {
self.adx.reset();
self.window.clear();
self.last = None;
}
fn warmup_period(&self) -> usize {
// ADX warmup is `2 * period` and emits one `ADX` per subsequent candle;
// the ADXR ring then needs `period - 1` more candles to fill, so the
// first ADXR lands at `2 * period + (period - 1) = 3 * period - 1`.
3 * self.period - 1
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"ADXR"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn candle(h: f64, l: f64, c: f64, ts: i64) -> Candle {
Candle::new(c, h, l, c, 1.0, ts).unwrap()
}
#[test]
fn rejects_zero_period() {
assert!(matches!(Adxr::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_and_metadata() {
let mut a = Adxr::new(14).unwrap();
assert_eq!(a.period(), 14);
assert_eq!(a.warmup_period(), 41);
assert_eq!(a.name(), "ADXR");
assert!(a.value().is_none());
// Drive past warmup.
for i in 0..50_i64 {
let base = 100.0 + (i as f64) * 2.0;
a.update(candle(base + 1.0, base - 0.5, base + 0.5, i));
}
assert!(a.value().is_some());
}
#[test]
fn pure_uptrend_yields_finite_positive_adxr() {
let candles: Vec<Candle> = (0..80_i64)
.map(|i| {
let base = 100.0 + (i as f64) * 2.0;
candle(base + 1.0, base - 0.5, base + 0.5, i)
})
.collect();
let mut a = Adxr::new(14).unwrap();
let last = a.batch(&candles).into_iter().flatten().last().unwrap();
assert!(last > 0.0 && last <= 100.0 + 1e-9);
}
#[test]
fn constant_series_yields_zero_adxr() {
let candles: Vec<Candle> = (0..50_i64).map(|i| candle(10.0, 10.0, 10.0, i)).collect();
let mut a = Adxr::new(5).unwrap();
let last = a.batch(&candles).into_iter().flatten().last().unwrap();
assert_eq!(last, 0.0);
}
#[test]
fn first_emission_at_warmup_period() {
let candles: Vec<Candle> = (0..80_i64)
.map(|i| {
let p = 100.0 + ((i as f64) * 0.3).sin() * 5.0;
candle(p + 1.0, p - 1.0, p, i)
})
.collect();
let mut a = Adxr::new(5).unwrap();
let out = a.batch(&candles);
let warmup = 3 * 5 - 1; // 14
for v in out.iter().take(warmup - 1) {
assert!(v.is_none());
}
assert!(out[warmup - 1].is_some());
}
#[test]
fn reference_value_against_explicit_adx_average() {
// The first ADXR(p) emits at index `3p - 2` (0-based), and equals
// (ADX[index] + ADX[index - (p - 1)]) / 2. Verify against a separate
// ADX run.
let candles: Vec<Candle> = (0..60_i64)
.map(|i| {
let p = 100.0 + ((i as f64) * 0.2).sin() * 6.0;
candle(p + 1.5, p - 1.5, p, i)
})
.collect();
let period = 5;
let mut adx = Adx::new(period).unwrap();
let adx_out: Vec<_> = adx
.batch(&candles)
.into_iter()
.map(|o| o.map(|x| x.adx))
.collect();
let mut adxr = Adxr::new(period).unwrap();
let adxr_out = adxr.batch(&candles);
// First ADXR index (0-based) = 3 * period - 2 = 13.
let first = 3 * period - 2;
let prev = first - (period - 1);
let expected = f64::midpoint(adx_out[first].unwrap(), adx_out[prev].unwrap());
assert_relative_eq!(adxr_out[first].unwrap(), expected, epsilon = 1e-12);
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..60_i64)
.map(|i| {
let p = 100.0 + ((i as f64) * 0.25).sin() * 5.0;
candle(p + 1.0, p - 1.0, p, i)
})
.collect();
let mut a = Adxr::new(7).unwrap();
let mut b = Adxr::new(7).unwrap();
assert_eq!(
a.batch(&candles),
candles.iter().map(|c| b.update(*c)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let candles: Vec<Candle> = (0..60_i64).map(|i| candle(11.0, 9.0, 10.0, i)).collect();
let mut a = Adxr::new(5).unwrap();
a.batch(&candles);
assert!(a.is_ready());
a.reset();
assert!(!a.is_ready());
assert_eq!(a.update(candles[0]), None);
}
}
@@ -0,0 +1,223 @@
//! Bill Williams' Alligator indicator.
use crate::error::{Error, Result};
use crate::indicators::smma::Smma;
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Alligator output: three smoothed moving averages of the median price
/// `(high + low) / 2`.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct AlligatorOutput {
/// `Jaw` — the slowest line (default period 13).
pub jaw: f64,
/// `Teeth` — the middle line (default period 8).
pub teeth: f64,
/// `Lips` — the fastest line (default period 5).
pub lips: f64,
}
/// Bill Williams' Alligator: three `SMMA`s of the median price `(high + low) / 2`
/// with different periods. Classic parameters are `(jaw = 13, teeth = 8, lips = 5)`.
///
/// The original chart variant additionally shifts each line forward by a fixed
/// number of bars for display (Jaw +8, Teeth +5, Lips +3). Wickra publishes the
/// *unshifted* `SMMA` values — the consumer can apply the visual shift on the
/// chart side. The indicator emits values once all three `SMMA`s have warmed
/// up, i.e. after `max(jaw, teeth, lips) = jaw` candles.
///
/// Reference: Bill Williams, *Trading Chaos*, 1995.
///
/// # Example
///
/// ```
/// use wickra_core::{Alligator, Candle, Indicator};
///
/// let mut alligator = Alligator::classic();
/// let mut last = None;
/// for i in 0..40 {
/// let base = 100.0 + f64::from(i);
/// let candle =
/// Candle::new(base, base + 1.0, base - 1.0, base, 1.0, i64::from(i)).unwrap();
/// last = alligator.update(candle);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct Alligator {
jaw_period: usize,
teeth_period: usize,
lips_period: usize,
jaw: Smma,
teeth: Smma,
lips: Smma,
}
impl Alligator {
/// # Errors
/// Returns [`Error::PeriodZero`] if any period is zero.
pub fn new(jaw_period: usize, teeth_period: usize, lips_period: usize) -> Result<Self> {
if jaw_period == 0 || teeth_period == 0 || lips_period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
jaw_period,
teeth_period,
lips_period,
jaw: Smma::new(jaw_period)?,
teeth: Smma::new(teeth_period)?,
lips: Smma::new(lips_period)?,
})
}
/// Bill Williams' classic parameters: `(jaw = 13, teeth = 8, lips = 5)`.
pub fn classic() -> Self {
Self::new(13, 8, 5).expect("classic Alligator parameters are valid")
}
/// Configured `(jaw_period, teeth_period, lips_period)`.
pub const fn periods(&self) -> (usize, usize, usize) {
(self.jaw_period, self.teeth_period, self.lips_period)
}
}
impl Indicator for Alligator {
type Input = Candle;
type Output = AlligatorOutput;
fn update(&mut self, candle: Candle) -> Option<AlligatorOutput> {
let median = f64::midpoint(candle.high, candle.low);
// Feed every `SMMA` on every bar so they warm up in parallel; gating
// the longer lines behind the shorter ones would starve them during
// their own warmup.
let lips = self.lips.update(median);
let teeth = self.teeth.update(median);
let jaw = self.jaw.update(median);
Some(AlligatorOutput {
jaw: jaw?,
teeth: teeth?,
lips: lips?,
})
}
fn reset(&mut self) {
self.jaw.reset();
self.teeth.reset();
self.lips.reset();
}
fn warmup_period(&self) -> usize {
// All three SMMAs run on every bar, so readiness is gated by the
// longest period — the Jaw with the default parameters.
self.jaw_period.max(self.teeth_period).max(self.lips_period)
}
fn is_ready(&self) -> bool {
self.jaw.is_ready() && self.teeth.is_ready() && self.lips.is_ready()
}
fn name(&self) -> &'static str {
"Alligator"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn candle(high: f64, low: f64, ts: i64) -> Candle {
let close = f64::midpoint(high, low);
Candle::new(close, high, low, close, 1.0, ts).unwrap()
}
#[test]
fn rejects_zero_period() {
assert!(matches!(Alligator::new(0, 8, 5), Err(Error::PeriodZero)));
assert!(matches!(Alligator::new(13, 0, 5), Err(Error::PeriodZero)));
assert!(matches!(Alligator::new(13, 8, 0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_and_metadata() {
let alligator = Alligator::classic();
assert_eq!(alligator.periods(), (13, 8, 5));
assert_eq!(alligator.warmup_period(), 13);
assert_eq!(alligator.name(), "Alligator");
}
#[test]
fn constant_series_yields_the_constant() {
// Median price = 10 for every bar, so each SMMA seeds to 10 and stays.
let mut alligator = Alligator::classic();
let candles: Vec<Candle> = (0..40).map(|i| candle(11.0, 9.0, i)).collect();
let out = alligator.batch(&candles);
for v in out.iter().skip(12).flatten() {
assert_relative_eq!(v.jaw, 10.0, epsilon = 1e-12);
assert_relative_eq!(v.teeth, 10.0, epsilon = 1e-12);
assert_relative_eq!(v.lips, 10.0, epsilon = 1e-12);
}
}
#[test]
fn warmup_emits_first_value_at_longest_period() {
let mut alligator = Alligator::new(5, 3, 2).unwrap();
let candles: Vec<Candle> = (0..6).map(|i| candle(11.0, 9.0, i)).collect();
let out = alligator.batch(&candles);
for v in out.iter().take(4) {
assert!(v.is_none());
}
assert!(out[4].is_some());
}
#[test]
fn pure_uptrend_ordering() {
// On a clean uptrend the fastest line (Lips, smallest SMMA) leads the
// slowest line (Jaw) — lips > teeth > jaw at the latest bar.
let mut alligator = Alligator::classic();
let candles: Vec<Candle> = (0_i64..80)
.map(|i| candle(10.0 + i as f64, 9.0 + i as f64, i))
.collect();
let out = alligator.batch(&candles);
let last = out.last().unwrap().unwrap();
assert!(
last.lips > last.teeth,
"lips {} > teeth {}",
last.lips,
last.teeth
);
assert!(
last.teeth > last.jaw,
"teeth {} > jaw {}",
last.teeth,
last.jaw
);
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..80_i64)
.map(|i| {
let base = 100.0 + (i as f64 * 0.2).sin() * 5.0;
candle(base + 1.0, base - 1.0, i)
})
.collect();
let mut a = Alligator::classic();
let mut b = Alligator::classic();
assert_eq!(
a.batch(&candles),
candles.iter().map(|c| b.update(*c)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut alligator = Alligator::classic();
let candles: Vec<Candle> = (0..40).map(|i| candle(11.0, 9.0, i)).collect();
alligator.batch(&candles);
assert!(alligator.is_ready());
alligator.reset();
assert!(!alligator.is_ready());
}
}
+335
View File
@@ -0,0 +1,335 @@
//! Arnaud Legoux Moving Average (ALMA).
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Arnaud Legoux Moving Average — a Gaussian-weighted moving average.
///
/// Each output is a weighted sum of the last `period` inputs:
///
/// ```text
/// w[i] = exp(-(i - m)^2 / (2 * s^2)) for i in 0..period
/// m = offset * (period - 1)
/// s = period / sigma
/// ALMA = sum(price[i] * w[i]) / sum(w[i])
/// ```
///
/// The Gaussian is centred on the relative index `offset * (period - 1)`, so
/// `offset = 0.85` puts the peak near the newest sample (responsive), while
/// `offset = 0.5` centres the peak in the middle of the window (smooth).
/// `sigma` controls how concentrated the Gaussian is: larger `sigma` ->
/// narrower kernel, smaller `sigma` -> broader (closer to SMA).
///
/// Reference: Arnaud Legoux and Dimitrios Kouzis-Loukas, 2009.
///
/// # Defaults
///
/// The community-standard parameters are `period = 9`, `offset = 0.85`,
/// `sigma = 6.0`. The first output lands after exactly `period` inputs.
///
/// # Example
///
/// ```
/// use wickra_core::{Alma, Indicator};
///
/// let mut alma = Alma::new(9, 0.85, 6.0).unwrap();
/// let mut last = None;
/// for i in 0..40 {
/// last = alma.update(100.0 + f64::from(i));
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct Alma {
period: usize,
offset: f64,
sigma: f64,
/// Pre-computed, normalised weights (sum to 1). `weights[0]` is the oldest
/// sample in the window, `weights[period - 1]` the newest.
weights: Vec<f64>,
window: VecDeque<f64>,
current: Option<f64>,
}
impl Alma {
/// Construct a new ALMA with the given period, offset and sigma.
///
/// # Errors
///
/// - [`Error::PeriodZero`] if `period == 0`.
/// - [`Error::InvalidPeriod`] if `offset` is outside `[0.0, 1.0]` or
/// `sigma <= 0.0` or either of `offset` / `sigma` is non-finite.
pub fn new(period: usize, offset: f64, sigma: f64) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
if !offset.is_finite() || !(0.0..=1.0).contains(&offset) {
return Err(Error::InvalidPeriod {
message: "ALMA offset must be a finite value in [0, 1]",
});
}
if !sigma.is_finite() || sigma <= 0.0 {
return Err(Error::InvalidPeriod {
message: "ALMA sigma must be a finite positive value",
});
}
let m = offset * (period as f64 - 1.0);
let s = period as f64 / sigma;
let denom = 2.0 * s * s;
// The raw Gaussian weights sum to a strictly positive value because
// every term is `exp(_) > 0`, so the normalisation below cannot divide
// by zero.
let mut raw: Vec<f64> = (0..period)
.map(|i| (-((i as f64 - m).powi(2)) / denom).exp())
.collect();
let sum: f64 = raw.iter().sum();
for w in &mut raw {
*w /= sum;
}
Ok(Self {
period,
offset,
sigma,
weights: raw,
window: VecDeque::with_capacity(period),
current: None,
})
}
/// Construct ALMA with the community-standard parameters
/// `(period = 9, offset = 0.85, sigma = 6.0)`.
pub fn classic() -> Self {
Self::new(9, 0.85, 6.0).expect("classic ALMA parameters are valid")
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
/// Configured offset.
pub const fn offset(&self) -> f64 {
self.offset
}
/// Configured sigma.
pub const fn sigma(&self) -> f64 {
self.sigma
}
}
impl Indicator for Alma {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() {
return self.current;
}
if self.window.len() == self.period {
self.window.pop_front();
}
self.window.push_back(input);
if self.window.len() < self.period {
return None;
}
let mut acc = 0.0;
for (w, p) in self.weights.iter().zip(self.window.iter()) {
acc += w * p;
}
self.current = Some(acc);
Some(acc)
}
fn reset(&mut self) {
self.window.clear();
self.current = None;
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.current.is_some()
}
fn name(&self) -> &'static str {
"ALMA"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_zero_period() {
assert!(matches!(Alma::new(0, 0.85, 6.0), Err(Error::PeriodZero)));
}
#[test]
fn rejects_invalid_offset() {
assert!(matches!(
Alma::new(9, -0.1, 6.0),
Err(Error::InvalidPeriod { .. })
));
assert!(matches!(
Alma::new(9, 1.1, 6.0),
Err(Error::InvalidPeriod { .. })
));
assert!(matches!(
Alma::new(9, f64::NAN, 6.0),
Err(Error::InvalidPeriod { .. })
));
}
#[test]
fn rejects_invalid_sigma() {
assert!(matches!(
Alma::new(9, 0.85, 0.0),
Err(Error::InvalidPeriod { .. })
));
assert!(matches!(
Alma::new(9, 0.85, -1.0),
Err(Error::InvalidPeriod { .. })
));
assert!(matches!(
Alma::new(9, 0.85, f64::INFINITY),
Err(Error::InvalidPeriod { .. })
));
}
#[test]
fn accessors_and_metadata() {
let alma = Alma::new(9, 0.85, 6.0).unwrap();
assert_eq!(alma.period(), 9);
assert_eq!(alma.warmup_period(), 9);
assert_eq!(alma.name(), "ALMA");
assert!((alma.offset() - 0.85).abs() < 1e-12);
assert!((alma.sigma() - 6.0).abs() < 1e-12);
// Weights are normalised by construction.
let sum: f64 = alma.weights.iter().sum();
assert_relative_eq!(sum, 1.0, epsilon = 1e-12);
}
#[test]
fn classic_factory() {
let a = Alma::classic();
assert_eq!(a.period(), 9);
assert!((a.offset() - 0.85).abs() < 1e-12);
assert!((a.sigma() - 6.0).abs() < 1e-12);
}
#[test]
fn constant_series_yields_the_constant() {
// Normalised weights sum to 1, so any constant is reproduced exactly.
let mut alma = Alma::new(9, 0.85, 6.0).unwrap();
let out = alma.batch(&[42.0_f64; 40]);
for v in out.iter().skip(8).flatten() {
assert_relative_eq!(*v, 42.0, epsilon = 1e-12);
}
}
#[test]
fn warmup_emits_first_value_at_period() {
let mut alma = Alma::new(5, 0.85, 6.0).unwrap();
for i in 0..4 {
assert_eq!(alma.update(f64::from(i)), None);
}
assert!(alma.update(4.0).is_some());
}
#[test]
fn reference_value_period_3() {
// ALMA(period=3, offset=0.85, sigma=6) on [10, 20, 30].
// m = 0.85 * 2 = 1.7; s = 3 / 6 = 0.5; 2*s^2 = 0.5.
// Independently compute the normalised Gaussian weights and the
// expected weighted sum, then check the indicator output matches.
// Computing the expectation here (rather than pinning a printed
// constant) keeps the test stable across libm `exp` implementations.
let mut alma = Alma::new(3, 0.85, 6.0).unwrap();
alma.update(10.0);
alma.update(20.0);
let v = alma.update(30.0).expect("ALMA emits after period");
let w0 = (-((0.0_f64 - 1.7).powi(2)) / 0.5).exp();
let w1 = (-((1.0_f64 - 1.7).powi(2)) / 0.5).exp();
let w2 = (-((2.0_f64 - 1.7).powi(2)) / 0.5).exp();
let s = w0 + w1 + w2;
let expected = (10.0 * w0 + 20.0 * w1 + 30.0 * w2) / s;
// The weighted sum is heavily skewed toward the newest sample so the
// output must sit close to but below the latest input (30).
assert!(v > 25.0 && v < 30.0, "ALMA(3) on [10,20,30] = {v}");
assert_relative_eq!(v, expected, epsilon = 1e-12);
}
#[test]
fn offset_zero_centres_on_oldest_sample() {
// With offset = 0 the Gaussian peaks at index 0, so ALMA leans toward
// the oldest sample in the window and away from the newest.
let mut alma = Alma::new(5, 0.0, 6.0).unwrap();
let series: Vec<f64> = (1..=5).map(f64::from).collect();
let mut last = None;
for p in &series {
last = alma.update(*p);
}
let v = last.unwrap();
let mean = series.iter().sum::<f64>() / series.len() as f64;
// Oldest sample is 1.0, mean is 3.0; an offset-0 ALMA should sit
// strictly below the mean.
assert!(v < mean, "{v} should be less than {mean}");
}
#[test]
fn offset_one_centres_on_newest_sample() {
// Symmetric to the above: offset = 1 leans toward the newest sample.
let mut alma = Alma::new(5, 1.0, 6.0).unwrap();
let series: Vec<f64> = (1..=5).map(f64::from).collect();
let mut last = None;
for p in &series {
last = alma.update(*p);
}
let v = last.unwrap();
let mean = series.iter().sum::<f64>() / series.len() as f64;
assert!(v > mean, "{v} should exceed {mean}");
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (1..=100)
.map(|i| (f64::from(i) * 0.2).sin() * 5.0 + f64::from(i) * 0.1)
.collect();
let mut a = Alma::new(9, 0.85, 6.0).unwrap();
let mut b = Alma::new(9, 0.85, 6.0).unwrap();
assert_eq!(
a.batch(&prices),
prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut alma = Alma::new(9, 0.85, 6.0).unwrap();
alma.batch(&(1..=40).map(f64::from).collect::<Vec<_>>());
assert!(alma.is_ready());
alma.reset();
assert!(!alma.is_ready());
assert_eq!(alma.update(1.0), None);
}
#[test]
fn ignores_non_finite_input() {
let mut alma = Alma::new(5, 0.85, 6.0).unwrap();
alma.batch(&(1..=5).map(f64::from).collect::<Vec<_>>());
let before = alma.update(6.0).unwrap();
// Non-finite inputs leave the window/current untouched.
assert_eq!(alma.update(f64::NAN), Some(before));
assert_eq!(alma.update(f64::INFINITY), Some(before));
}
}
+220
View File
@@ -0,0 +1,220 @@
//! Rolling Jensen's Alpha (CAPM).
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Rolling Jensen's Alpha.
///
/// Each `update` receives one `(asset_return, benchmark_return)` pair. Over
/// the trailing window of `period` pairs:
///
/// ```text
/// Beta = cov(asset, bench) / var(bench)
/// Alpha = mean(asset) ( risk_free + Beta · (mean(bench) risk_free) )
/// ```
///
/// Alpha is the *risk-adjusted excess return* — the slice of the asset's
/// performance that cannot be explained by simple exposure to the
/// benchmark. A positive alpha indicates outperformance net of the market
/// premium implied by the asset's beta; negative alpha is the opposite.
///
/// Population covariance and variance are used (matching common
/// implementations in pandas-ta / quantstats); the rolling estimator stays
/// unbiased in the steady state for fixed `period`.
///
/// If the benchmark is flat (`var(bench) = 0`) the indicator falls back to
/// `alpha = mean(asset) risk_free` — the asset's mean excess return, with
/// no market-risk adjustment, since the regression slope is undefined.
///
/// Each `update` is O(1).
#[derive(Debug, Clone)]
pub struct Alpha {
period: usize,
risk_free: f64,
window: VecDeque<(f64, f64)>,
sum_a: f64,
sum_b: f64,
sum_bb: f64,
sum_ab: f64,
}
impl Alpha {
/// Construct a new rolling Alpha.
///
/// # Errors
/// Returns [`Error::InvalidPeriod`] if `period < 2`.
pub fn new(period: usize, risk_free: f64) -> Result<Self> {
if period < 2 {
return Err(Error::InvalidPeriod {
message: "alpha needs period >= 2",
});
}
Ok(Self {
period,
risk_free,
window: VecDeque::with_capacity(period),
sum_a: 0.0,
sum_b: 0.0,
sum_bb: 0.0,
sum_ab: 0.0,
})
}
/// Configured window length.
pub const fn period(&self) -> usize {
self.period
}
/// Configured per-period risk-free rate.
pub const fn risk_free(&self) -> f64 {
self.risk_free
}
}
impl Indicator for Alpha {
type Input = (f64, f64);
type Output = f64;
fn update(&mut self, input: (f64, f64)) -> Option<f64> {
let (a, b) = input;
if !a.is_finite() || !b.is_finite() {
return None;
}
if self.window.len() == self.period {
let (oa, ob) = self.window.pop_front().expect("non-empty");
self.sum_a -= oa;
self.sum_b -= ob;
self.sum_bb -= ob * ob;
self.sum_ab -= oa * ob;
}
self.window.push_back((a, b));
self.sum_a += a;
self.sum_b += b;
self.sum_bb += b * b;
self.sum_ab += a * b;
if self.window.len() < self.period {
return None;
}
let n = self.period as f64;
let mean_a = self.sum_a / n;
let mean_b = self.sum_b / n;
let var_b = (self.sum_bb / n) - mean_b * mean_b;
if var_b <= 0.0 {
// Undefined beta: report unadjusted excess.
return Some(mean_a - self.risk_free);
}
let cov_ab = (self.sum_ab / n) - mean_a * mean_b;
let beta = cov_ab / var_b;
Some(mean_a - (self.risk_free + beta * (mean_b - self.risk_free)))
}
fn reset(&mut self) {
self.window.clear();
self.sum_a = 0.0;
self.sum_b = 0.0;
self.sum_bb = 0.0;
self.sum_ab = 0.0;
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.window.len() == self.period
}
fn name(&self) -> &'static str {
"Alpha"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_period_less_than_two() {
assert!(matches!(
Alpha::new(1, 0.0),
Err(Error::InvalidPeriod { .. })
));
}
#[test]
fn accessors_and_metadata() {
let a = Alpha::new(20, 0.001).unwrap();
assert_eq!(a.period(), 20);
assert_relative_eq!(a.risk_free(), 0.001, epsilon = 1e-12);
assert_eq!(a.name(), "Alpha");
assert_eq!(a.warmup_period(), 20);
}
#[test]
fn capm_perfect_fit_yields_zero_alpha() {
// asset = 2 * bench - constant beta of 2, no alpha; with rf = 0 the
// CAPM-implied return matches the asset's mean perfectly.
let mut a = Alpha::new(20, 0.0).unwrap();
let inputs: Vec<(f64, f64)> = (1..=20)
.map(|i| (2.0 * f64::from(i) * 0.01, f64::from(i) * 0.01))
.collect();
let out = a.batch(&inputs);
assert_relative_eq!(out[19].unwrap(), 0.0, epsilon = 1e-12);
}
#[test]
fn constant_alpha_offset_recovered() {
// asset = bench + 0.005 (additive alpha of 0.5%), beta == 1.
// Expected alpha = 0.005.
let mut a = Alpha::new(20, 0.0).unwrap();
let inputs: Vec<(f64, f64)> = (1..=20)
.map(|i| (f64::from(i) * 0.01 + 0.005, f64::from(i) * 0.01))
.collect();
let out = a.batch(&inputs);
assert_relative_eq!(out[19].unwrap(), 0.005, epsilon = 1e-9);
}
#[test]
fn flat_benchmark_falls_back_to_excess_return() {
// Benchmark all 0 -> beta undefined -> alpha = mean_a - rf.
let mut a = Alpha::new(4, 0.001).unwrap();
let out = a.batch(&[(0.01, 0.0), (0.02, 0.0), (-0.01, 0.0), (0.04, 0.0)]);
let mean = (0.01 + 0.02 - 0.01 + 0.04) / 4.0;
assert_relative_eq!(out[3].unwrap(), mean - 0.001, epsilon = 1e-12);
}
#[test]
fn ignores_non_finite_input() {
let mut a = Alpha::new(3, 0.0).unwrap();
assert_eq!(a.update((f64::NAN, 0.0)), None);
assert_eq!(a.update((0.0, f64::INFINITY)), None);
}
#[test]
fn reset_clears_state() {
let mut a = Alpha::new(3, 0.0).unwrap();
a.batch(&[(0.01, 0.005), (0.02, 0.01), (-0.01, -0.005)]);
assert!(a.is_ready());
a.reset();
assert!(!a.is_ready());
assert_eq!(a.update((0.01, 0.005)), None);
}
#[test]
fn batch_equals_streaming() {
let inputs: Vec<(f64, f64)> = (0..50)
.map(|i| {
let b = (f64::from(i) * 0.2).sin() * 0.01;
(1.5 * b + 0.002, b)
})
.collect();
let batch = Alpha::new(10, 0.0).unwrap().batch(&inputs);
let mut s = Alpha::new(10, 0.0).unwrap();
let streamed: Vec<_> = inputs.iter().map(|x| s.update(*x)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,207 @@
//! Anchored Volume-Weighted Average Price.
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Anchored VWAP — a cumulative VWAP whose accumulation begins at a
/// user-chosen anchor bar rather than the session open.
///
/// ```text
/// AVWAP_t = Σ_{i ≥ anchor} (typical_price_i · volume_i) / Σ_{i ≥ anchor} volume_i
/// ```
///
/// The indicator emits `None` until the first anchored bar has been ingested.
/// Calling [`AnchoredVwap::set_anchor`] re-anchors at the **next** bar that
/// arrives, clearing the running sums; this is the conventional behaviour for
/// "click to anchor" trader workflows where the anchor is set on the close of
/// a swing point and the next bar starts the new accumulation. The cumulative
/// total is unbounded; for finite-memory needs use [`crate::RollingVwap`].
///
/// Bars where the running volume is still zero (only happens if every anchored
/// bar so far carried zero volume) return `None` to avoid a zero-division.
///
/// # Example
///
/// ```
/// use wickra_core::{AnchoredVwap, Candle, Indicator};
///
/// let mut indicator = AnchoredVwap::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();
/// // Re-anchor at bar 40 (e.g. a major swing low).
/// if i == 40 {
/// indicator.set_anchor();
/// }
/// last = indicator.update(candle);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone, Default)]
pub struct AnchoredVwap {
sum_pv: f64,
sum_v: f64,
has_emitted: bool,
pending_anchor: bool,
}
impl AnchoredVwap {
/// Construct a fresh Anchored VWAP. The first bar to arrive is the anchor.
pub const fn new() -> Self {
Self {
sum_pv: 0.0,
sum_v: 0.0,
has_emitted: false,
pending_anchor: false,
}
}
/// Mark a re-anchor: the **next** [`Indicator::update`] call clears the
/// running sums before adding its own contribution, effectively starting a
/// fresh anchored window.
pub fn set_anchor(&mut self) {
self.pending_anchor = true;
}
/// Current anchored value if at least one bar with non-zero volume has
/// been observed in the current anchor window.
pub fn value(&self) -> Option<f64> {
if self.sum_v == 0.0 {
None
} else {
Some(self.sum_pv / self.sum_v)
}
}
}
impl Indicator for AnchoredVwap {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
if self.pending_anchor {
// Drop the old window before folding in this bar.
self.sum_pv = 0.0;
self.sum_v = 0.0;
self.has_emitted = false;
self.pending_anchor = false;
}
let tp = candle.typical_price();
self.sum_pv += tp * candle.volume;
self.sum_v += candle.volume;
if self.sum_v == 0.0 {
return None;
}
self.has_emitted = true;
Some(self.sum_pv / self.sum_v)
}
fn reset(&mut self) {
self.sum_pv = 0.0;
self.sum_v = 0.0;
self.has_emitted = false;
self.pending_anchor = false;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"AnchoredVWAP"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn c(price: f64, volume: f64, ts: i64) -> Candle {
Candle::new(price, price, price, price, volume, ts).unwrap()
}
#[test]
fn accessors_and_metadata() {
let v = AnchoredVwap::new();
assert_eq!(v.name(), "AnchoredVWAP");
assert_eq!(v.warmup_period(), 1);
assert_eq!(v.value(), None);
}
#[test]
fn first_bar_with_zero_volume_returns_none() {
let mut v = AnchoredVwap::new();
assert_eq!(v.update(c(50.0, 0.0, 0)), None);
assert!(!v.is_ready());
// The next bar with volume still works.
assert_relative_eq!(v.update(c(10.0, 4.0, 1)).unwrap(), 10.0, epsilon = 1e-12);
}
#[test]
fn equal_volumes_yield_mean_typical_price() {
// typical_price of a flat OHLC bar equals the price.
let mut v = AnchoredVwap::new();
let out = v.batch(&[c(10.0, 1.0, 0), c(20.0, 1.0, 1), c(30.0, 1.0, 2)]);
assert_relative_eq!(out[2].unwrap(), 20.0, epsilon = 1e-12);
}
#[test]
fn set_anchor_clears_old_window() {
// Run a few bars at price 10, then re-anchor and pump in price 100.
// After the re-anchor the running mean must be 100, not the mix.
let mut v = AnchoredVwap::new();
v.batch(&[c(10.0, 1.0, 0), c(10.0, 1.0, 1), c(10.0, 1.0, 2)]);
assert_relative_eq!(v.value().unwrap(), 10.0, epsilon = 1e-12);
v.set_anchor();
let after = v.update(c(100.0, 5.0, 3)).unwrap();
assert_relative_eq!(after, 100.0, epsilon = 1e-12);
}
#[test]
fn set_anchor_before_first_bar_acts_as_normal_first_bar() {
// Calling set_anchor on an empty indicator should be a no-op effect:
// the first bar still anchors the window.
let mut v = AnchoredVwap::new();
v.set_anchor();
assert_relative_eq!(v.update(c(42.0, 2.0, 0)).unwrap(), 42.0, epsilon = 1e-12);
}
#[test]
fn weighted_average_reference() {
// Two bars: 10@1, 20@3 -> (10 + 60) / 4 = 17.5.
let mut v = AnchoredVwap::new();
let out = v.batch(&[c(10.0, 1.0, 0), c(20.0, 3.0, 1)]);
assert_relative_eq!(out[1].unwrap(), 17.5, epsilon = 1e-12);
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (1..30).map(|i| c(f64::from(i), 1.0, i.into())).collect();
let mut a = AnchoredVwap::new();
let mut b = AnchoredVwap::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut v = AnchoredVwap::new();
v.batch(&[c(10.0, 1.0, 0), c(20.0, 1.0, 1)]);
assert!(v.is_ready());
v.reset();
assert!(!v.is_ready());
assert_eq!(v.value(), None);
// After reset the first bar acts as the new anchor.
assert_relative_eq!(v.update(c(50.0, 1.0, 2)).unwrap(), 50.0, epsilon = 1e-12);
}
}
+183
View File
@@ -0,0 +1,183 @@
//! Absolute Price Oscillator (APO).
use crate::error::{Error, Result};
use crate::indicators::ema::Ema;
use crate::traits::Indicator;
/// Absolute Price Oscillator — the raw difference between a fast and a slow
/// `EMA`. This is MACD's line without the signal-EMA — useful when only the
/// momentum-direction reading is needed.
///
/// ```text
/// APO_t = EMA(close, fast)_t EMA(close, slow)_t
/// ```
///
/// Default parameters mirror MACD: `(fast = 12, slow = 26)`. `fast` must be
/// strictly less than `slow`.
///
/// # Example
///
/// ```
/// use wickra_core::{Apo, Indicator};
///
/// let mut apo = Apo::new(12, 26).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// last = apo.update(100.0 + f64::from(i));
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct Apo {
fast_period: usize,
slow_period: usize,
fast: Ema,
slow: Ema,
}
impl Apo {
/// # Errors
/// - [`Error::PeriodZero`] if either period is zero.
/// - [`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: "APO fast period must be strictly less than slow",
});
}
Ok(Self {
fast_period: fast,
slow_period: slow,
fast: Ema::new(fast)?,
slow: Ema::new(slow)?,
})
}
/// MACD-style defaults: `(fast = 12, slow = 26)`.
pub fn classic() -> Self {
Self::new(12, 26).expect("classic APO parameters are valid")
}
/// Configured `(fast, slow)`.
pub const fn periods(&self) -> (usize, usize) {
(self.fast_period, self.slow_period)
}
}
impl Indicator for Apo {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
// Feed both EMAs on every input so the slow one warms in parallel.
let f = self.fast.update(input);
let s = self.slow.update(input);
Some(f? - s?)
}
fn reset(&mut self) {
self.fast.reset();
self.slow.reset();
}
fn warmup_period(&self) -> usize {
// Slow EMA dominates; both EMAs emit at their `period` th input.
self.slow_period
}
fn is_ready(&self) -> bool {
self.slow.is_ready()
}
fn name(&self) -> &'static str {
"APO"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_zero_period() {
assert!(matches!(Apo::new(0, 26), Err(Error::PeriodZero)));
assert!(matches!(Apo::new(12, 0), Err(Error::PeriodZero)));
}
#[test]
fn rejects_fast_geq_slow() {
assert!(matches!(Apo::new(26, 12), Err(Error::InvalidPeriod { .. })));
assert!(matches!(Apo::new(12, 12), Err(Error::InvalidPeriod { .. })));
}
#[test]
fn accessors_and_metadata() {
let apo = Apo::classic();
assert_eq!(apo.periods(), (12, 26));
assert_eq!(apo.warmup_period(), 26);
assert_eq!(apo.name(), "APO");
}
#[test]
fn classic_factory() {
assert_eq!(Apo::classic().periods(), (12, 26));
}
#[test]
fn constant_series_converges_to_zero() {
// Both EMAs reproduce the constant exactly, so APO is 0.
let mut apo = Apo::new(3, 5).unwrap();
let out = apo.batch(&[42.0_f64; 30]);
for v in out.iter().skip(4).flatten() {
assert_relative_eq!(*v, 0.0, epsilon = 1e-12);
}
}
#[test]
fn warmup_emits_first_value_at_slow_period() {
let mut apo = Apo::new(2, 4).unwrap();
assert_eq!(apo.warmup_period(), 4);
for i in 1..=3 {
assert_eq!(apo.update(f64::from(i)), None);
}
assert!(apo.update(4.0).is_some());
}
#[test]
fn pure_uptrend_is_positive() {
// Fast EMA leads the slow EMA on an uptrend, so APO > 0.
let mut apo = Apo::classic();
let prices: Vec<f64> = (1..=200).map(f64::from).collect();
let out = apo.batch(&prices);
let last = out.iter().rev().flatten().next().unwrap();
assert!(*last > 0.0, "APO on uptrend should be positive: {last}");
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (1..=120)
.map(|i| 100.0 + (f64::from(i) * 0.2).sin() * 5.0)
.collect();
let mut a = Apo::classic();
let mut b = Apo::classic();
assert_eq!(
a.batch(&prices),
prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut apo = Apo::classic();
apo.batch(&(1..=80).map(f64::from).collect::<Vec<_>>());
assert!(apo.is_ready());
apo.reset();
assert!(!apo.is_ready());
assert_eq!(apo.update(1.0), None);
}
}
@@ -0,0 +1,214 @@
//! ATR Bands.
use crate::error::{Error, Result};
use crate::indicators::atr::Atr;
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// ATR Bands output.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct AtrBandsOutput {
/// Upper band: `close + multiplier · ATR`.
pub upper: f64,
/// Middle band: the current close.
pub middle: f64,
/// Lower band: `close multiplier · ATR`.
pub lower: f64,
}
/// ATR Bands: a close-anchored envelope of width `multiplier · ATR`.
///
/// ```text
/// upper = close + multiplier · ATR(period)
/// lower = close multiplier · ATR(period)
/// ```
///
/// Unlike [`Keltner`](crate::Keltner) or [`StarcBands`](crate::StarcBands), the
/// centerline is the *raw close* rather than a smoothed average — the band
/// rides the price tick-for-tick. This is the standard volatility-targeting
/// envelope traders use to set initial stop-loss and profit targets: an entry
/// at the close sets a `multiplier · ATR` stop and the symmetric target
/// without ever needing to wait for a moving average to warm up.
///
/// # Example
///
/// ```
/// use wickra_core::{AtrBands, Candle, Indicator};
///
/// let mut indicator = AtrBands::new(14, 3.0).unwrap();
/// let mut last = None;
/// for i in 0..30 {
/// 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 AtrBands {
atr: Atr,
multiplier: f64,
}
impl AtrBands {
/// # Errors
/// Returns [`Error::PeriodZero`] / [`Error::NonPositiveMultiplier`] on
/// invalid inputs.
pub fn new(period: usize, multiplier: f64) -> Result<Self> {
if !multiplier.is_finite() || multiplier <= 0.0 {
return Err(Error::NonPositiveMultiplier);
}
Ok(Self {
atr: Atr::new(period)?,
multiplier,
})
}
/// Configured ATR period.
pub const fn period(&self) -> usize {
self.atr.period()
}
/// Configured ATR multiplier.
pub const fn multiplier(&self) -> f64 {
self.multiplier
}
}
impl Indicator for AtrBands {
type Input = Candle;
type Output = AtrBandsOutput;
fn update(&mut self, candle: Candle) -> Option<AtrBandsOutput> {
let atr = self.atr.update(candle)?;
Some(AtrBandsOutput {
upper: candle.close + self.multiplier * atr,
middle: candle.close,
lower: candle.close - self.multiplier * atr,
})
}
fn reset(&mut self) {
self.atr.reset();
}
fn warmup_period(&self) -> usize {
self.atr.warmup_period()
}
fn is_ready(&self) -> bool {
self.atr.is_ready()
}
fn name(&self) -> &'static str {
"AtrBands"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn c(h: f64, l: f64, cl: f64) -> Candle {
Candle::new(cl, h, l, cl, 1.0, 0).unwrap()
}
#[test]
fn rejects_zero_period() {
assert!(matches!(AtrBands::new(0, 3.0), Err(Error::PeriodZero)));
}
#[test]
fn rejects_non_positive_multiplier() {
assert!(matches!(
AtrBands::new(14, 0.0),
Err(Error::NonPositiveMultiplier)
));
assert!(matches!(
AtrBands::new(14, -1.0),
Err(Error::NonPositiveMultiplier)
));
assert!(matches!(
AtrBands::new(14, f64::INFINITY),
Err(Error::NonPositiveMultiplier)
));
}
#[test]
fn accessors_and_metadata() {
let ab = AtrBands::new(14, 3.0).unwrap();
assert_eq!(ab.period(), 14);
assert_relative_eq!(ab.multiplier(), 3.0, epsilon = 1e-12);
assert_eq!(ab.warmup_period(), 14);
assert_eq!(ab.name(), "AtrBands");
}
#[test]
fn flat_market_collapses_bands() {
let candles: Vec<Candle> = (0..30).map(|_| c(10.0, 10.0, 10.0)).collect();
let mut ab = AtrBands::new(5, 3.0).unwrap();
let last = ab.batch(&candles).into_iter().flatten().last().unwrap();
assert_relative_eq!(last.upper, 10.0, epsilon = 1e-9);
assert_relative_eq!(last.middle, 10.0, epsilon = 1e-9);
assert_relative_eq!(last.lower, 10.0, epsilon = 1e-9);
}
#[test]
fn upper_above_middle_above_lower() {
let candles: Vec<Candle> = (0..50)
.map(|i| {
let m = 100.0 + (f64::from(i) * 0.2).sin() * 5.0;
c(m + 1.0, m - 1.0, m)
})
.collect();
let mut ab = AtrBands::new(14, 3.0).unwrap();
for o in ab.batch(&candles).into_iter().flatten() {
assert!(o.upper >= o.middle);
assert!(o.middle >= o.lower);
}
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..40)
.map(|i| c(f64::from(i) + 2.0, f64::from(i), f64::from(i) + 1.0))
.collect();
let mut a = AtrBands::new(10, 2.5).unwrap();
let mut b = AtrBands::new(10, 2.5).unwrap();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
#[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 ab = AtrBands::new(5, 3.0).unwrap();
ab.batch(&candles);
assert!(ab.is_ready());
ab.reset();
assert!(!ab.is_ready());
assert_eq!(ab.update(candles[0]), None);
}
/// Reference: with constant high-low spread of 2, ATR(period) converges to
/// 2 immediately; for multiplier 3 the bands are at `close ± 6`.
#[test]
fn reference_values_constant_spread() {
// Five identical candles with TR = 2 each: ATR seeds to 2 on bar 5.
let candles: Vec<Candle> = (0..5).map(|_| c(11.0, 9.0, 10.0)).collect();
let mut ab = AtrBands::new(5, 3.0).unwrap();
let out = ab.batch(&candles);
assert!(out[0].is_none() && out[3].is_none());
let v = out[4].unwrap();
assert_relative_eq!(v.middle, 10.0, epsilon = 1e-9);
assert_relative_eq!(v.upper, 16.0, epsilon = 1e-9);
assert_relative_eq!(v.lower, 4.0, epsilon = 1e-9);
}
}
@@ -0,0 +1,221 @@
//! Rolling lag-`k` autocorrelation.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Rolling lag-`lag` autocorrelation of the last `period` inputs.
///
/// Over the trailing window the Pearson correlation between the series and
/// itself shifted by `lag` is computed:
///
/// ```text
/// y_i for i = 0..period 1
/// ACF(lag) = Σ ( (y_i ȳ) · (y_{i + lag} ȳ) ) / Σ ( y_i ȳ )²
/// ```
///
/// `+1` means a perfectly repeating pattern at the given lag; `1` means a
/// perfect alternation. Values near `0` mean the series at `t` and `t
/// lag` carry no linear relationship — a clean white-noise proxy. The
/// classic application is detecting periodicity (a peak in `|ACF(lag)|`
/// flags a cycle of that length) or testing whether returns are
/// uncorrelated (a key efficient-markets diagnostic).
///
/// `period` must be strictly greater than `lag` so that at least two
/// `(y, y_lagged)` pairs exist. A flat window has zero variance; the
/// indicator returns `0` rather than dividing by zero.
///
/// # Example
///
/// ```
/// use wickra_core::{Autocorrelation, Indicator};
///
/// let mut indicator = Autocorrelation::new(20, 1).unwrap();
/// let mut last = None;
/// for i in 0..40 {
/// last = indicator.update(f64::from(i));
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct Autocorrelation {
period: usize,
lag: usize,
window: VecDeque<f64>,
}
impl Autocorrelation {
/// Construct a new rolling lag-`lag` autocorrelation over `period` inputs.
///
/// # Errors
/// Returns [`Error::InvalidPeriod`] if `lag == 0` or `lag >= period`.
pub fn new(period: usize, lag: usize) -> Result<Self> {
if lag == 0 {
return Err(Error::InvalidPeriod {
message: "autocorrelation lag must be >= 1",
});
}
if period <= lag {
return Err(Error::InvalidPeriod {
message: "autocorrelation needs period > lag",
});
}
Ok(Self {
period,
lag,
window: VecDeque::with_capacity(period),
})
}
/// Configured window period.
pub const fn period(&self) -> usize {
self.period
}
/// Configured lag.
pub const fn lag(&self) -> usize {
self.lag
}
}
impl Indicator for Autocorrelation {
type Input = f64;
type Output = f64;
fn update(&mut self, value: f64) -> Option<f64> {
if self.window.len() == self.period {
self.window.pop_front();
}
self.window.push_back(value);
if self.window.len() < self.period {
return None;
}
// ACF over the current window with a single inner pass. The window is
// small relative to a typical input stream so the O(period) per-bar
// cost is bounded by the user-chosen `period`; the constant factor
// is dominated by two adds and one multiply per element.
let n = self.period as f64;
let mean = self.window.iter().sum::<f64>() / n;
let mut denom = 0.0;
let mut numer = 0.0;
// The window is a deque; index via slices for cache-friendly access.
let (front, back) = self.window.as_slices();
let get = |i: usize| -> f64 {
if i < front.len() {
front[i]
} else {
back[i - front.len()]
}
};
for i in 0..self.period {
let d = get(i) - mean;
denom += d * d;
}
let lag = self.lag;
for i in 0..(self.period - lag) {
numer += (get(i) - mean) * (get(i + lag) - mean);
}
if denom == 0.0 {
return Some(0.0);
}
Some(numer / denom)
}
fn reset(&mut self) {
self.window.clear();
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.window.len() == self.period
}
fn name(&self) -> &'static str {
"Autocorrelation"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_zero_lag() {
assert!(Autocorrelation::new(10, 0).is_err());
}
#[test]
fn rejects_lag_geq_period() {
assert!(Autocorrelation::new(5, 5).is_err());
assert!(Autocorrelation::new(5, 10).is_err());
}
#[test]
fn accessors_and_metadata() {
let a = Autocorrelation::new(14, 2).unwrap();
assert_eq!(a.period(), 14);
assert_eq!(a.lag(), 2);
assert_eq!(a.warmup_period(), 14);
assert_eq!(a.name(), "Autocorrelation");
}
#[test]
fn constant_series_yields_zero() {
let mut a = Autocorrelation::new(10, 1).unwrap();
for v in a.batch(&[42.0; 30]).into_iter().flatten() {
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
}
}
#[test]
fn alternating_series_lag_one_is_strongly_negative() {
// [1, 1, 1, 1, …] alternates each step.
let prices: Vec<f64> = (0..20)
.map(|i| if i % 2 == 0 { -1.0 } else { 1.0 })
.collect();
let mut a = Autocorrelation::new(10, 1).unwrap();
let last = a.batch(&prices).into_iter().flatten().last().unwrap();
assert!(
last < -0.5,
"alternating series should be strongly negative, got {last}"
);
}
#[test]
fn repeating_series_is_strongly_positive_at_period() {
// A series that repeats every 4 steps must have ACF(4) ≈ +1.
let pattern = [1.0, 2.0, 3.0, 4.0];
let prices: Vec<f64> = (0..32).map(|i| pattern[i % 4]).collect();
let mut a = Autocorrelation::new(16, 4).unwrap();
let last = a.batch(&prices).into_iter().flatten().last().unwrap();
assert!(
last > 0.5,
"period-4 repeat should ACF(4) > 0.5, got {last}"
);
}
#[test]
fn reset_clears_state() {
let mut a = Autocorrelation::new(5, 1).unwrap();
a.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
assert!(a.is_ready());
a.reset();
assert!(!a.is_ready());
assert_eq!(a.update(1.0), None);
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (0..60).map(|i| (f64::from(i) * 0.3).sin()).collect();
let batch = Autocorrelation::new(14, 2).unwrap().batch(&prices);
let mut b = Autocorrelation::new(14, 2).unwrap();
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,172 @@
//! Rolling Average Drawdown.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Rolling Average Drawdown.
///
/// Input is treated as an equity-curve sample. The indicator scans the
/// trailing window of `period` values, tracks the running peak inside the
/// window, and reports the **mean** of all bar-by-bar drawdowns (the average
/// "pain" of being under water):
///
/// ```text
/// drawdown_t = (peak_t equity_t) / peak_t (running peak inside window)
/// AvgDD = mean(drawdown_t over window)
/// ```
///
/// Output is non-negative (a fraction; `0.05` ≈ 5 % average drawdown). This
/// is the **Pain Index** under a different name — see [`crate::PainIndex`]
/// for the same metric exposed under its conventional label.
///
/// Each `update` is O(period).
#[derive(Debug, Clone)]
pub struct AverageDrawdown {
period: usize,
window: VecDeque<f64>,
}
impl AverageDrawdown {
/// Construct a new rolling Average Drawdown.
///
/// # 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),
})
}
/// Configured window length.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for AverageDrawdown {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() {
return None;
}
if self.window.len() == self.period {
self.window.pop_front();
}
self.window.push_back(input);
if self.window.len() < self.period {
return None;
}
let mut peak = f64::NEG_INFINITY;
let mut sum_dd = 0.0_f64;
for &v in &self.window {
if v > peak {
peak = v;
}
if peak > 0.0 {
sum_dd += (peak - v) / peak;
}
}
Some(sum_dd / self.period as f64)
}
fn reset(&mut self) {
self.window.clear();
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.window.len() == self.period
}
fn name(&self) -> &'static str {
"AverageDrawdown"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_zero_period() {
assert!(matches!(AverageDrawdown::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_and_metadata() {
let a = AverageDrawdown::new(10).unwrap();
assert_eq!(a.period(), 10);
assert_eq!(a.name(), "AverageDrawdown");
assert_eq!(a.warmup_period(), 10);
}
#[test]
fn pure_uptrend_yields_zero() {
let mut a = AverageDrawdown::new(5).unwrap();
let out = a.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
for v in out.into_iter().flatten() {
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
}
}
#[test]
fn reference_value() {
// window [100, 120, 90, 110]:
// peaks: 100, 120, 120, 120; dd: 0, 0, (30/120)=.25, (10/120)=.0833...
// avg = (.25 + .0833...) / 4 = .0833...
let mut a = AverageDrawdown::new(4).unwrap();
let out = a.batch(&[100.0, 120.0, 90.0, 110.0]);
let expected = (0.25 + (10.0 / 120.0)) / 4.0;
assert_relative_eq!(out[3].unwrap(), expected, epsilon = 1e-12);
}
#[test]
fn ignores_non_finite_input() {
let mut a = AverageDrawdown::new(3).unwrap();
assert_eq!(a.update(f64::NAN), None);
assert_eq!(a.update(f64::INFINITY), None);
}
#[test]
fn reset_clears_state() {
let mut a = AverageDrawdown::new(3).unwrap();
a.batch(&[100.0, 90.0, 110.0]);
assert!(a.is_ready());
a.reset();
assert!(!a.is_ready());
assert_eq!(a.update(100.0), None);
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (0..40)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 8.0)
.collect();
let batch = AverageDrawdown::new(10).unwrap().batch(&prices);
let mut s = AverageDrawdown::new(10).unwrap();
let streamed: Vec<_> = prices.iter().map(|p| s.update(*p)).collect();
assert_eq!(batch, streamed);
}
#[test]
fn non_positive_peak_yields_zero() {
let mut a = AverageDrawdown::new(3).unwrap();
let out = a.batch(&[0.0_f64; 6]);
for v in out.into_iter().flatten() {
assert_eq!(v, 0.0);
}
}
}
@@ -0,0 +1,198 @@
//! Awesome Oscillator Histogram.
use crate::error::{Error, Result};
use crate::indicators::awesome_oscillator::AwesomeOscillator;
use crate::indicators::sma::Sma;
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// "Awesome Oscillator Histogram" — the difference between the Awesome
/// Oscillator and its `sma_period`-bar `SMA`. Positive bars mean `AO` is
/// trending up (bullish acceleration); negative bars mean `AO` is trending
/// down (bearish acceleration).
///
/// ```text
/// AO = SMA(median, fast) SMA(median, slow)
/// AOHist = AO SMA(AO, sma_period)
/// ```
///
/// With Williams' default `sma_period = 5`, this collapses to the existing
/// `AcceleratorOscillator` for `fast = 5, slow = 34, sma_period = 5`; for any
/// other parameterisation this is a more flexible variant.
///
/// # Example
///
/// ```
/// use wickra_core::{AwesomeOscillatorHistogram, Candle, Indicator};
///
/// let mut hist = AwesomeOscillatorHistogram::classic();
/// let mut last = None;
/// for i in 0..80 {
/// let p = 100.0 + f64::from(i);
/// let candle = Candle::new(p, p + 0.5, p - 0.5, p, 1.0, i64::from(i)).unwrap();
/// last = hist.update(candle);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct AwesomeOscillatorHistogram {
fast_period: usize,
slow_period: usize,
sma_period: usize,
ao: AwesomeOscillator,
sma: Sma,
}
impl AwesomeOscillatorHistogram {
/// # Errors
/// - [`Error::PeriodZero`] if any period is zero.
/// - [`Error::InvalidPeriod`] if `fast >= slow`.
pub fn new(fast: usize, slow: usize, sma_period: usize) -> Result<Self> {
if fast == 0 || slow == 0 || sma_period == 0 {
return Err(Error::PeriodZero);
}
if fast >= slow {
return Err(Error::InvalidPeriod {
message: "AwesomeOscillatorHistogram fast must be strictly less than slow",
});
}
Ok(Self {
fast_period: fast,
slow_period: slow,
sma_period,
ao: AwesomeOscillator::new(fast, slow)?,
sma: Sma::new(sma_period)?,
})
}
/// Bill Williams' Accelerator-equivalent defaults `(5, 34, 5)`.
pub fn classic() -> Self {
Self::new(5, 34, 5).expect("classic Awesome Oscillator Histogram parameters are valid")
}
/// Configured `(fast_period, slow_period, sma_period)`.
pub const fn periods(&self) -> (usize, usize, usize) {
(self.fast_period, self.slow_period, self.sma_period)
}
}
impl Indicator for AwesomeOscillatorHistogram {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
let ao = self.ao.update(candle)?;
let sma = self.sma.update(ao)?;
Some(ao - sma)
}
fn reset(&mut self) {
self.ao.reset();
self.sma.reset();
}
fn warmup_period(&self) -> usize {
// AO emits at `slow` candles; the SMA then needs `sma_period - 1`
// more AO values to fill its window.
self.slow_period + self.sma_period - 1
}
fn is_ready(&self) -> bool {
self.sma.is_ready()
}
fn name(&self) -> &'static str {
"AwesomeOscillatorHistogram"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn candle(price: f64, ts: i64) -> Candle {
Candle::new(price, price + 0.5, price - 0.5, price, 1.0, ts).unwrap()
}
#[test]
fn rejects_zero_period() {
assert!(matches!(
AwesomeOscillatorHistogram::new(0, 34, 5),
Err(Error::PeriodZero)
));
assert!(matches!(
AwesomeOscillatorHistogram::new(5, 0, 5),
Err(Error::PeriodZero)
));
assert!(matches!(
AwesomeOscillatorHistogram::new(5, 34, 0),
Err(Error::PeriodZero)
));
}
#[test]
fn rejects_fast_geq_slow() {
assert!(matches!(
AwesomeOscillatorHistogram::new(34, 5, 5),
Err(Error::InvalidPeriod { .. })
));
}
#[test]
fn accessors_and_metadata() {
let hist = AwesomeOscillatorHistogram::classic();
assert_eq!(hist.periods(), (5, 34, 5));
assert_eq!(hist.warmup_period(), 38);
assert_eq!(hist.name(), "AwesomeOscillatorHistogram");
}
#[test]
fn constant_series_converges_to_zero() {
// AO of a flat series is 0; SMA of 0 is 0; difference is 0.
let mut hist = AwesomeOscillatorHistogram::new(3, 5, 3).unwrap();
let candles: Vec<Candle> = (0..30).map(|i| candle(42.0, i)).collect();
let out = hist.batch(&candles);
for v in out.iter().skip(hist.warmup_period() - 1).flatten() {
assert_relative_eq!(*v, 0.0, epsilon = 1e-12);
}
}
#[test]
fn warmup_emits_first_value_at_warmup_period() {
let mut hist = AwesomeOscillatorHistogram::new(2, 4, 3).unwrap();
assert_eq!(hist.warmup_period(), 6);
let candles: Vec<Candle> = (0..8)
.map(|i| candle(10.0 + f64::from(i), i64::from(i)))
.collect();
let out = hist.batch(&candles);
for v in out.iter().take(5) {
assert!(v.is_none());
}
assert!(out[5].is_some());
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..100_i64)
.map(|i| candle(100.0 + (i as f64 * 0.3).sin() * 5.0, i))
.collect();
let batch = AwesomeOscillatorHistogram::classic().batch(&candles);
let mut b = AwesomeOscillatorHistogram::classic();
let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
assert_eq!(batch, streamed);
}
#[test]
fn reset_clears_state() {
let mut hist = AwesomeOscillatorHistogram::classic();
let candles: Vec<Candle> = (0..80)
.map(|i| candle(10.0 + f64::from(i), i64::from(i)))
.collect();
hist.batch(&candles);
assert!(hist.is_ready());
hist.reset();
assert!(!hist.is_ready());
}
}
+228
View File
@@ -0,0 +1,228 @@
//! Rolling Beta — sensitivity of an asset to a benchmark.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Rolling Beta of an `asset` series relative to a `benchmark` series.
///
/// Each `update` receives one `(asset, benchmark)` pair. Over the trailing
/// window of `period` pairs:
///
/// ```text
/// cov_ab = (1/n) · Σ a·b ā·b̄
/// var_b = (1/n) · Σ b² b̄²
/// Beta = cov_ab / var_b
/// ```
///
/// Beta measures how much the asset moves for a unit move in the
/// benchmark. A reading of `1.0` means the two move together one-for-one;
/// `2.0` means the asset typically doubles the benchmark's moves;
/// `0.5` means it moves only half as much; `0.0` means moves are
/// uncorrelated; negative Betas signal a hedge. It is the slope of the
/// OLS regression of the asset on the benchmark and the foundation of the
/// CAPM. Unlike [`crate::PearsonCorrelation`], Beta is *not* unit-free —
/// it carries the ratio of standard deviations.
///
/// Each `update` is O(1): four running sums (`Σa`, `Σb`, `Σb²`, `Σa·b`)
/// are maintained as the window slides. A flat benchmark window has zero
/// variance and Beta is undefined; the indicator returns `0` in that
/// case rather than producing `NaN`.
///
/// Conventionally Beta is computed on **returns** (typically log-returns)
/// rather than raw prices; feed the indicator pre-computed returns if
/// that is your convention. The pure rolling OLS slope is the same
/// either way.
///
/// # Example
///
/// ```
/// use wickra_core::{Beta, Indicator};
///
/// let mut indicator = Beta::new(20).unwrap();
/// let mut last = None;
/// for i in 0..40 {
/// // Asset doubles every benchmark move.
/// last = indicator.update((2.0 * f64::from(i), f64::from(i)));
/// }
/// assert!((last.unwrap() - 2.0).abs() < 1e-9);
/// ```
#[derive(Debug, Clone)]
pub struct Beta {
period: usize,
window: VecDeque<(f64, f64)>,
sum_a: f64,
sum_b: f64,
sum_bb: f64,
sum_ab: f64,
}
impl Beta {
/// Construct a new rolling Beta.
///
/// # Errors
/// Returns [`Error::InvalidPeriod`] if `period < 2`.
pub fn new(period: usize) -> Result<Self> {
if period < 2 {
return Err(Error::InvalidPeriod {
message: "beta needs period >= 2",
});
}
Ok(Self {
period,
window: VecDeque::with_capacity(period),
sum_a: 0.0,
sum_b: 0.0,
sum_bb: 0.0,
sum_ab: 0.0,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for Beta {
/// `(asset, benchmark)` pair.
type Input = (f64, f64);
type Output = f64;
fn update(&mut self, input: (f64, f64)) -> Option<f64> {
let (a, b) = input;
if self.window.len() == self.period {
let (oa, ob) = self.window.pop_front().expect("non-empty");
self.sum_a -= oa;
self.sum_b -= ob;
self.sum_bb -= ob * ob;
self.sum_ab -= oa * ob;
}
self.window.push_back((a, b));
self.sum_a += a;
self.sum_b += b;
self.sum_bb += b * b;
self.sum_ab += a * b;
if self.window.len() < self.period {
return None;
}
let n = self.period as f64;
let mean_a = self.sum_a / n;
let mean_b = self.sum_b / n;
let var_b = (self.sum_bb / n - mean_b * mean_b).max(0.0);
let cov = self.sum_ab / n - mean_a * mean_b;
if var_b == 0.0 {
// A flat benchmark has no defined beta.
return Some(0.0);
}
Some(cov / var_b)
}
fn reset(&mut self) {
self.window.clear();
self.sum_a = 0.0;
self.sum_b = 0.0;
self.sum_bb = 0.0;
self.sum_ab = 0.0;
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.window.len() == self.period
}
fn name(&self) -> &'static str {
"Beta"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_period_below_two() {
assert!(Beta::new(0).is_err());
assert!(Beta::new(1).is_err());
assert!(Beta::new(2).is_ok());
}
#[test]
fn accessors_and_metadata() {
let b = Beta::new(14).unwrap();
assert_eq!(b.period(), 14);
assert_eq!(b.warmup_period(), 14);
assert_eq!(b.name(), "Beta");
}
#[test]
fn perfect_two_to_one_relationship() {
let pairs: Vec<(f64, f64)> = (0..10)
.map(|i| (2.0 * f64::from(i), f64::from(i)))
.collect();
let last = Beta::new(5)
.unwrap()
.batch(&pairs)
.into_iter()
.flatten()
.last()
.unwrap();
assert_relative_eq!(last, 2.0, epsilon = 1e-9);
}
#[test]
fn perfect_negative_one() {
let pairs: Vec<(f64, f64)> = (0..10).map(|i| (-f64::from(i), f64::from(i))).collect();
let last = Beta::new(5)
.unwrap()
.batch(&pairs)
.into_iter()
.flatten()
.last()
.unwrap();
assert_relative_eq!(last, -1.0, epsilon = 1e-9);
}
#[test]
fn constant_benchmark_yields_zero() {
let pairs: Vec<(f64, f64)> = (0..10).map(|i| (f64::from(i), 7.0)).collect();
let last = Beta::new(5)
.unwrap()
.batch(&pairs)
.into_iter()
.flatten()
.last()
.unwrap();
assert_relative_eq!(last, 0.0, epsilon = 1e-12);
}
#[test]
fn reset_clears_state() {
let mut b = Beta::new(5).unwrap();
b.batch(&[(1.0, 2.0), (2.0, 4.0), (3.0, 6.0), (4.0, 8.0), (5.0, 10.0)]);
assert!(b.is_ready());
b.reset();
assert!(!b.is_ready());
assert_eq!(b.update((1.0, 1.0)), None);
}
#[test]
fn batch_equals_streaming() {
let pairs: Vec<(f64, f64)> = (0..60)
.map(|i| {
let t = f64::from(i);
(t.sin() * 2.0 + 0.3 * t.cos(), t.sin())
})
.collect();
let batch = Beta::new(14).unwrap().batch(&pairs);
let mut b = Beta::new(14).unwrap();
let streamed: Vec<_> = pairs.iter().map(|p| b.update(*p)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,202 @@
//! Rolling Calmar Ratio — return over max drawdown.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Rolling Calmar Ratio.
///
/// Input is treated as a single period return. Over the trailing window of
/// `period` returns the indicator reconstructs the implied equity curve
/// (cumulative-compounded), measures the worst peak-to-trough drawdown, and
/// divides the mean return by that drawdown:
///
/// ```text
/// equity_t = ∏(1 + r_i) for i in window up to t
/// mdd = max peak-to-trough decline of equity over window
/// Calmar = mean(returns) / mdd
/// ```
///
/// If the drawdown is zero (monotonically non-decreasing equity in the
/// window) the indicator returns `0.0` rather than `NaN` / `Inf`.
///
/// The equity curve is recomputed inside the window each `update`, which
/// keeps each call O(period) — acceptable for typical backtest windows
/// (`period ≤ 252`).
///
/// # Example
///
/// ```
/// use wickra_core::{CalmarRatio, Indicator};
///
/// let mut cr = CalmarRatio::new(20).unwrap();
/// let mut last = None;
/// for i in 0..40 {
/// last = cr.update(0.001 + (f64::from(i) * 0.1).sin() * 0.005);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct CalmarRatio {
period: usize,
window: VecDeque<f64>,
sum: f64,
}
impl CalmarRatio {
/// Construct a new rolling Calmar Ratio.
///
/// # Errors
/// Returns [`Error::InvalidPeriod`] if `period < 2`.
pub fn new(period: usize) -> Result<Self> {
if period < 2 {
return Err(Error::InvalidPeriod {
message: "calmar ratio needs period >= 2",
});
}
Ok(Self {
period,
window: VecDeque::with_capacity(period),
sum: 0.0,
})
}
/// Configured window length.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for CalmarRatio {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() {
return None;
}
if self.window.len() == self.period {
let old = self.window.pop_front().expect("non-empty");
self.sum -= old;
}
self.window.push_back(input);
self.sum += input;
if self.window.len() < self.period {
return None;
}
let n = self.period as f64;
let mean = self.sum / n;
// Build equity curve and track the worst peak-to-trough drawdown.
let mut equity = 1.0_f64;
let mut peak = 1.0_f64;
let mut mdd = 0.0_f64;
for &r in &self.window {
equity *= 1.0 + r;
if equity > peak {
peak = equity;
}
// peak starts at 1.0 and never decreases, so peak > 0 by construction.
let dd = (peak - equity) / peak;
if dd > mdd {
mdd = dd;
}
}
if mdd == 0.0 {
return Some(0.0);
}
Some(mean / mdd)
}
fn reset(&mut self) {
self.window.clear();
self.sum = 0.0;
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.window.len() == self.period
}
fn name(&self) -> &'static str {
"CalmarRatio"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_period_less_than_two() {
assert!(matches!(
CalmarRatio::new(1),
Err(Error::InvalidPeriod { .. })
));
}
#[test]
fn accessors_and_metadata() {
let c = CalmarRatio::new(10).unwrap();
assert_eq!(c.period(), 10);
assert_eq!(c.name(), "CalmarRatio");
assert_eq!(c.warmup_period(), 10);
}
#[test]
fn pure_uptrend_yields_zero() {
// All positive returns -> no drawdown -> Calmar = 0 by convention.
let mut c = CalmarRatio::new(5).unwrap();
let out = c.batch(&[0.01; 10]);
for v in out.into_iter().flatten() {
assert_eq!(v, 0.0);
}
}
#[test]
fn reference_value() {
// returns = [0.10, -0.20, 0.05]
// equity: 1.0 -> 1.10 -> 0.88 -> 0.924
// peak 1.10, trough 0.88 -> mdd = 0.20.
// mean = (0.10 - 0.20 + 0.05) / 3 ≈ -0.01666...
// Calmar = -0.01666... / 0.20 ≈ -0.08333...
let mut c = CalmarRatio::new(3).unwrap();
let out = c.batch(&[0.10, -0.20, 0.05]);
let mean = (0.10 - 0.20 + 0.05) / 3.0;
let expected = mean / 0.20;
assert_relative_eq!(out[2].unwrap(), expected, epsilon = 1e-9);
}
#[test]
fn ignores_non_finite_input() {
let mut c = CalmarRatio::new(3).unwrap();
assert_eq!(c.update(f64::NAN), None);
assert_eq!(c.update(f64::INFINITY), None);
}
#[test]
fn reset_clears_state() {
let mut c = CalmarRatio::new(3).unwrap();
c.batch(&[0.10, -0.20, 0.05]);
assert!(c.is_ready());
c.reset();
assert!(!c.is_ready());
assert_eq!(c.update(0.01), None);
}
#[test]
fn batch_equals_streaming() {
let returns: Vec<f64> = (0..50)
.map(|i| 0.001 + (f64::from(i) * 0.25).sin() * 0.02)
.collect();
let batch = CalmarRatio::new(10).unwrap().batch(&returns);
let mut s = CalmarRatio::new(10).unwrap();
let streamed: Vec<_> = returns.iter().map(|r| s.update(*r)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,197 @@
//! Camarilla Pivot Points (Nick Stott).
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Camarilla Pivot Points output: four resistances, the pivot, four supports.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct CamarillaPivotsOutput {
/// Pivot Point: `(H + L + C) / 3` (informational, not in the Camarilla R/S formulas).
pub pp: f64,
/// Resistance 1: `C + (H L)·1.1/12`.
pub r1: f64,
/// Resistance 2: `C + (H L)·1.1/6`.
pub r2: f64,
/// Resistance 3: `C + (H L)·1.1/4`.
pub r3: f64,
/// Resistance 4: `C + (H L)·1.1/2`.
pub r4: f64,
/// Support 1: `C (H L)·1.1/12`.
pub s1: f64,
/// Support 2: `C (H L)·1.1/6`.
pub s2: f64,
/// Support 3: `C (H L)·1.1/4`.
pub s3: f64,
/// Support 4: `C (H L)·1.1/2`.
pub s4: f64,
}
/// Camarilla Pivot Points — Nick Stott's four-tier range-based level set.
/// Anchored on the prior close rather than the typical price, with widths
/// scaled by the constant `1.1` divided by `{12, 6, 4, 2}`.
///
/// ```text
/// PP = (H + L + C) / 3
/// R_n = C + (H L) · 1.1 / d_n S_n = C (H L) · 1.1 / d_n
/// where d_1 = 12, d_2 = 6, d_3 = 4, d_4 = 2
/// ```
///
/// R3/S3 are typically used as reversal levels; R4/S4 as breakout levels. As
/// with the other pivot variants there are no parameters and no warmup — the
/// first candle produces the first set of levels.
///
/// # Example
///
/// ```
/// use wickra_core::{Camarilla, Candle, Indicator};
///
/// let prev = Candle::new(100.0, 110.0, 90.0, 105.0, 1.0, 0).unwrap();
/// let levels = Camarilla::new().update(prev).unwrap();
/// assert!(levels.r4 > levels.r3);
/// assert!(levels.s4 < levels.s3);
/// ```
#[derive(Debug, Clone, Default)]
pub struct Camarilla {
ready: bool,
}
impl Camarilla {
/// Construct a new Camarilla Pivot Points indicator.
pub const fn new() -> Self {
Self { ready: false }
}
}
const CAM: f64 = 1.1;
impl Indicator for Camarilla {
type Input = Candle;
type Output = CamarillaPivotsOutput;
fn update(&mut self, candle: Candle) -> Option<CamarillaPivotsOutput> {
let (h, l, c) = (candle.high, candle.low, candle.close);
let range = h - l;
let pp = (h + l + c) / 3.0;
let w1 = range * CAM / 12.0;
let w2 = range * CAM / 6.0;
let w3 = range * CAM / 4.0;
let w4 = range * CAM / 2.0;
let out = CamarillaPivotsOutput {
pp,
r1: c + w1,
r2: c + w2,
r3: c + w3,
r4: c + w4,
s1: c - w1,
s2: c - w2,
s3: c - w3,
s4: c - w4,
};
self.ready = true;
Some(out)
}
fn reset(&mut self) {
self.ready = false;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.ready
}
fn name(&self) -> &'static str {
"Camarilla"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
fn c(h: f64, l: f64, close: f64, ts: i64) -> Candle {
Candle::new(close, h, l, close, 1.0, ts).unwrap()
}
#[test]
fn formula_reference_values() {
// H=110, L=90, C=105, range=20.
let levels = Camarilla::new().update(c(110.0, 90.0, 105.0, 0)).unwrap();
let range = 20.0;
assert!((levels.r1 - (105.0 + range * 1.1 / 12.0)).abs() < 1e-12);
assert!((levels.r2 - (105.0 + range * 1.1 / 6.0)).abs() < 1e-12);
assert!((levels.r3 - (105.0 + range * 1.1 / 4.0)).abs() < 1e-12);
assert!((levels.r4 - (105.0 + range * 1.1 / 2.0)).abs() < 1e-12);
assert!((levels.s1 - (105.0 - range * 1.1 / 12.0)).abs() < 1e-12);
assert!((levels.s4 - (105.0 - range * 1.1 / 2.0)).abs() < 1e-12);
}
#[test]
fn resistance_strictly_widens_with_index() {
let levels = Camarilla::new().update(c(120.0, 80.0, 110.0, 0)).unwrap();
assert!(levels.r4 > levels.r3);
assert!(levels.r3 > levels.r2);
assert!(levels.r2 > levels.r1);
assert!(levels.r1 > 110.0);
assert!(levels.s1 < 110.0);
assert!(levels.s2 < levels.s1);
assert!(levels.s3 < levels.s2);
assert!(levels.s4 < levels.s3);
}
#[test]
fn constant_series_collapses_levels() {
let levels = Camarilla::new().update(c(50.0, 50.0, 50.0, 0)).unwrap();
assert_eq!(levels.r4, 50.0);
assert_eq!(levels.s4, 50.0);
assert_eq!(levels.pp, 50.0);
}
#[test]
fn warmup_and_ready() {
let mut p = Camarilla::new();
assert!(!p.is_ready());
assert_eq!(p.warmup_period(), 1);
p.update(c(11.0, 9.0, 10.0, 0));
assert!(p.is_ready());
}
#[test]
fn reset_clears_state() {
let mut p = Camarilla::new();
p.update(c(11.0, 9.0, 10.0, 0));
p.reset();
assert!(!p.is_ready());
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0_i32..40)
.map(|i| {
c(
f64::from(i) + 2.0,
f64::from(i),
f64::from(i) + 1.0,
i.into(),
)
})
.collect();
let mut a = Camarilla::new();
let mut b = Camarilla::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
#[test]
fn accessors_and_metadata() {
let p = Camarilla::new();
assert_eq!(p.warmup_period(), 1);
assert_eq!(p.name(), "Camarilla");
}
}
@@ -0,0 +1,204 @@
//! Ehlers Center of Gravity Oscillator.
#![allow(clippy::manual_midpoint)]
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Ehlers' Center of Gravity (CG) oscillator.
///
/// Treats the most recent `period` prices as masses and reports the
/// weighted "center" of that mass distribution, negated so positive readings
/// correspond to recent strength:
///
/// ```text
/// num = sum_{k=0..period-1} (1 + k) * price[t - k]
/// den = sum_{k=0..period-1} price[t - k]
/// cg = - num / den + (period + 1) / 2
/// ```
///
/// The constant offset centres the oscillator around zero. From Ehlers,
/// *Cybernetic Analysis for Stocks and Futures* (2004, ch. 7).
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, CenterOfGravity};
///
/// let mut cg = CenterOfGravity::new(10).unwrap();
/// let mut last = None;
/// for i in 0..30 {
/// last = cg.update(100.0 + (f64::from(i) * 0.2).sin() * 5.0);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct CenterOfGravity {
period: usize,
window: VecDeque<f64>,
last_value: Option<f64>,
}
impl CenterOfGravity {
/// Construct with the rolling window length.
///
/// # 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),
last_value: None,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.last_value
}
}
impl Indicator for CenterOfGravity {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() {
return self.last_value;
}
if self.window.len() == self.period {
self.window.pop_front();
}
self.window.push_back(input);
if self.window.len() < self.period {
return None;
}
// Most recent has weight 1; oldest has weight `period`.
let mut num = 0.0;
let mut den = 0.0;
for (k, p) in self.window.iter().rev().enumerate() {
let w = 1.0 + k as f64;
num += w * p;
den += p;
}
let v = if den.abs() > f64::EPSILON {
-num / den + (self.period as f64 + 1.0) / 2.0
} else {
0.0
};
self.last_value = Some(v);
Some(v)
}
fn reset(&mut self) {
self.window.clear();
self.last_value = None;
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.last_value.is_some()
}
fn name(&self) -> &'static str {
"CenterOfGravity"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn new_rejects_zero_period() {
assert!(matches!(CenterOfGravity::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_and_metadata() {
let mut cg = CenterOfGravity::new(10).unwrap();
assert_eq!(cg.period(), 10);
assert_eq!(cg.warmup_period(), 10);
assert_eq!(cg.name(), "CenterOfGravity");
assert!(!cg.is_ready());
for i in 1..=10 {
cg.update(f64::from(i));
}
assert!(cg.is_ready());
assert!(cg.value().is_some());
}
#[test]
fn constant_series_yields_zero() {
// num = sum k * p, den = period * p, ratio = (period + 1) / 2,
// so cg = - (period+1)/2 + (period+1)/2 = 0.
let mut cg = CenterOfGravity::new(5).unwrap();
let out = cg.batch(&[7.0_f64; 30]);
for x in out.iter().skip(5).flatten() {
assert_relative_eq!(*x, 0.0, epsilon = 1e-12);
}
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (1..=50).map(f64::from).collect();
let mut a = CenterOfGravity::new(10).unwrap();
let mut b = CenterOfGravity::new(10).unwrap();
let batch = a.batch(&prices);
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
assert_eq!(batch, streamed);
}
#[test]
fn ignores_non_finite_input() {
let mut cg = CenterOfGravity::new(5).unwrap();
cg.batch(&(1..=10).map(f64::from).collect::<Vec<_>>());
let before = cg.value();
assert!(before.is_some());
assert_eq!(cg.update(f64::NAN), before);
}
#[test]
fn reset_clears_state() {
let mut cg = CenterOfGravity::new(5).unwrap();
cg.batch(&(1..=10).map(f64::from).collect::<Vec<_>>());
assert!(cg.is_ready());
cg.reset();
assert!(!cg.is_ready());
}
#[test]
fn warmup_returns_none_until_seed() {
let mut cg = CenterOfGravity::new(4).unwrap();
assert_eq!(cg.update(1.0), None);
assert_eq!(cg.update(2.0), None);
assert_eq!(cg.update(3.0), None);
assert!(cg.update(4.0).is_some());
}
#[test]
fn zero_window_uses_zero_fallback() {
// den == sum(prices) == 0 when the rolling window is all zeros, which
// exercises the protective fallback in the divisor guard.
let mut cg = CenterOfGravity::new(5).unwrap();
let out = cg.batch(&[0.0_f64; 10]);
for x in out.iter().skip(5).flatten() {
assert_relative_eq!(*x, 0.0, epsilon = 1e-12);
}
}
}
+173
View File
@@ -0,0 +1,173 @@
//! Chande Forecast Oscillator (CFO).
use crate::error::{Error, Result};
use crate::indicators::linreg::LinearRegression;
use crate::traits::Indicator;
/// Tushar Chande's Forecast Oscillator — the percentage difference between
/// the close and the endpoint of an `n`-bar linear-regression forecast of the
/// close.
///
/// ```text
/// CFO_t = 100 · (close_t LinearRegression(close, period)_t) / close_t
/// ```
///
/// Positive readings mean the close is *above* the linear forecast (price has
/// overshot trend); negative readings mean it sits below. Wraps the existing
/// `LinearRegression` so the warmup matches.
///
/// # Example
///
/// ```
/// use wickra_core::{Cfo, Indicator};
///
/// let mut cfo = Cfo::new(14).unwrap();
/// let mut last = None;
/// for i in 0..40 {
/// last = cfo.update(100.0 + f64::from(i));
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct Cfo {
period: usize,
linreg: LinearRegression,
current: Option<f64>,
}
impl Cfo {
/// # Errors
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
linreg: LinearRegression::new(period)?,
current: None,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for Cfo {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
let forecast = self.linreg.update(input)?;
// Hold the previous value if the close is zero — the percentage form
// is undefined and a return of inf would propagate badly.
if input == 0.0 {
return self.current;
}
let value = 100.0 * (input - forecast) / input;
self.current = Some(value);
Some(value)
}
fn reset(&mut self) {
self.linreg.reset();
self.current = None;
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.current.is_some()
}
fn name(&self) -> &'static str {
"CFO"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_zero_period() {
assert!(matches!(Cfo::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_and_metadata() {
let cfo = Cfo::new(14).unwrap();
assert_eq!(cfo.period(), 14);
assert_eq!(cfo.warmup_period(), 14);
assert_eq!(cfo.name(), "CFO");
}
#[test]
fn constant_series_yields_zero() {
// LinReg of a constant series equals the constant, so close forecast
// is 0 and CFO is 0.
let mut cfo = Cfo::new(5).unwrap();
let out = cfo.batch(&[42.0_f64; 30]);
for v in out.iter().skip(4).flatten() {
assert_relative_eq!(*v, 0.0, epsilon = 1e-12);
}
}
#[test]
fn perfect_linear_series_yields_zero() {
// LinReg of a perfectly linear input fits the line exactly, so the
// close lands on the forecast and CFO = 0.
let mut cfo = Cfo::new(5).unwrap();
let prices: Vec<f64> = (1..=20).map(|i| f64::from(i) * 2.0).collect();
let out = cfo.batch(&prices);
for v in out.iter().skip(4).flatten() {
assert_relative_eq!(*v, 0.0, epsilon = 1e-9);
}
}
#[test]
fn warmup_emits_first_value_at_period() {
let mut cfo = Cfo::new(3).unwrap();
for i in 1..=2 {
assert_eq!(cfo.update(f64::from(i)), None);
}
assert!(cfo.update(3.0).is_some());
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (1..=80)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0)
.collect();
let mut a = Cfo::new(14).unwrap();
let mut b = Cfo::new(14).unwrap();
assert_eq!(
a.batch(&prices),
prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut cfo = Cfo::new(5).unwrap();
cfo.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
assert!(cfo.is_ready());
cfo.reset();
assert!(!cfo.is_ready());
assert_eq!(cfo.update(1.0), None);
}
#[test]
fn zero_close_holds_value() {
let mut cfo = Cfo::new(3).unwrap();
cfo.batch(&[1.0_f64, 2.0, 3.0]);
let before = cfo.current;
assert_eq!(cfo.update(0.0), before);
}
}
@@ -0,0 +1,202 @@
//! Classic (Floor-Trader) Pivot Points.
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Classic Pivot Points output: pivot plus three resistances and three supports.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ClassicPivotsOutput {
/// Pivot Point: `(H + L + C) / 3`.
pub pp: f64,
/// Resistance 1: `2·PP L`.
pub r1: f64,
/// Resistance 2: `PP + (H L)`.
pub r2: f64,
/// Resistance 3: `H + 2·(PP L)`.
pub r3: f64,
/// Support 1: `2·PP H`.
pub s1: f64,
/// Support 2: `PP (H L)`.
pub s2: f64,
/// Support 3: `L 2·(H PP)`.
pub s3: f64,
}
/// Classic (Floor-Trader) Pivot Points — the standard pivot/resistance/support
/// levels computed from a completed candle's high, low and close.
///
/// ```text
/// PP = (H + L + C) / 3
/// R1 = 2·PP L S1 = 2·PP H
/// R2 = PP + (H L) S2 = PP (H L)
/// R3 = H + 2·(PP L) S3 = L 2·(H PP)
/// ```
///
/// Pivots are typically computed once per session (day, week, month) from the
/// **previous** session's bar and used as fixed reference levels for the next
/// session. The streaming API here simply re-evaluates the formula on every
/// candle it sees, which makes it a one-step transform you can wire to any
/// pre-aggregated session bar. There are no parameters and no warmup — the
/// first candle produces the first set of levels.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, ClassicPivots, Indicator};
///
/// let prev = Candle::new(100.0, 110.0, 90.0, 105.0, 1.0, 0).unwrap();
/// let mut pp = ClassicPivots::new();
/// let levels = pp.update(prev).unwrap();
/// assert!((levels.pp - 101.6666666666).abs() < 1e-9);
/// assert!(levels.r1 > levels.pp);
/// assert!(levels.s1 < levels.pp);
/// ```
#[derive(Debug, Clone, Default)]
pub struct ClassicPivots {
ready: bool,
}
impl ClassicPivots {
/// Construct a new Classic Pivot Points indicator. The indicator has no
/// parameters and no warmup.
pub const fn new() -> Self {
Self { ready: false }
}
}
impl Indicator for ClassicPivots {
type Input = Candle;
type Output = ClassicPivotsOutput;
fn update(&mut self, candle: Candle) -> Option<ClassicPivotsOutput> {
let (h, l, c) = (candle.high, candle.low, candle.close);
let pp = (h + l + c) / 3.0;
let range = h - l;
let out = ClassicPivotsOutput {
pp,
r1: 2.0 * pp - l,
r2: pp + range,
r3: h + 2.0 * (pp - l),
s1: 2.0 * pp - h,
s2: pp - range,
s3: l - 2.0 * (h - pp),
};
self.ready = true;
Some(out)
}
fn reset(&mut self) {
self.ready = false;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.ready
}
fn name(&self) -> &'static str {
"ClassicPivots"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
fn c(h: f64, l: f64, close: f64, ts: i64) -> Candle {
Candle::new(close, h, l, close, 1.0, ts).unwrap()
}
#[test]
fn formula_reference_values() {
// H=110, L=90, C=105 -> PP = 305/3 ≈ 101.6667.
let levels = ClassicPivots::new()
.update(c(110.0, 90.0, 105.0, 0))
.unwrap();
let pp = 305.0 / 3.0;
let range = 20.0;
assert!((levels.pp - pp).abs() < 1e-12);
assert!((levels.r1 - (2.0 * pp - 90.0)).abs() < 1e-12);
assert!((levels.s1 - (2.0 * pp - 110.0)).abs() < 1e-12);
assert!((levels.r2 - (pp + range)).abs() < 1e-12);
assert!((levels.s2 - (pp - range)).abs() < 1e-12);
assert!((levels.r3 - (110.0 + 2.0 * (pp - 90.0))).abs() < 1e-12);
assert!((levels.s3 - (90.0 - 2.0 * (110.0 - pp))).abs() < 1e-12);
}
#[test]
fn ordering_resistance_above_pivot_above_support() {
// For any non-degenerate bar with H > L, R-levels exceed PP and S-levels lie below.
let levels = ClassicPivots::new()
.update(c(200.0, 100.0, 150.0, 0))
.unwrap();
assert!(levels.r3 >= levels.r2);
assert!(levels.r2 >= levels.r1);
assert!(levels.r1 >= levels.pp);
assert!(levels.pp >= levels.s1);
assert!(levels.s1 >= levels.s2);
assert!(levels.s2 >= levels.s3);
}
#[test]
fn constant_series_collapses_levels() {
// H = L = C means range = 0 and every level equals the close.
let levels = ClassicPivots::new().update(c(50.0, 50.0, 50.0, 0)).unwrap();
assert_eq!(levels.pp, 50.0);
assert_eq!(levels.r1, 50.0);
assert_eq!(levels.s1, 50.0);
assert_eq!(levels.r2, 50.0);
assert_eq!(levels.s2, 50.0);
assert_eq!(levels.r3, 50.0);
assert_eq!(levels.s3, 50.0);
}
#[test]
fn ready_after_first_update_warmup_is_one() {
let mut pp = ClassicPivots::new();
assert!(!pp.is_ready());
assert_eq!(pp.warmup_period(), 1);
pp.update(c(11.0, 9.0, 10.0, 0));
assert!(pp.is_ready());
}
#[test]
fn reset_clears_state() {
let mut pp = ClassicPivots::new();
pp.update(c(11.0, 9.0, 10.0, 0));
assert!(pp.is_ready());
pp.reset();
assert!(!pp.is_ready());
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0_i32..40)
.map(|i| {
c(
f64::from(i) + 2.0,
f64::from(i),
f64::from(i) + 1.0,
i.into(),
)
})
.collect();
let mut a = ClassicPivots::new();
let mut b = ClassicPivots::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
#[test]
fn accessors_and_metadata() {
let pp = ClassicPivots::new();
assert_eq!(pp.warmup_period(), 1);
assert_eq!(pp.name(), "ClassicPivots");
}
}
@@ -0,0 +1,184 @@
//! Rolling Coefficient of Variation (`StdDev / Mean`).
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Coefficient of Variation — the rolling population standard deviation
/// divided by the rolling mean.
///
/// ```text
/// mean = (1/n) · Σ price
/// sd = √( (1/n) · Σ price² mean² )
/// CV = sd / mean
/// ```
///
/// CV is a dimensionless dispersion measure: it scales `StdDev` by the price
/// level so two assets at very different price magnitudes can be compared
/// directly. A higher CV means more relative variability for the same
/// average price.
///
/// When the rolling mean is exactly zero the ratio is undefined; the
/// indicator returns `0.0` in that degenerate case rather than producing a
/// `NaN`/infinity.
///
/// # Example
///
/// ```
/// use wickra_core::{CoefficientOfVariation, Indicator};
///
/// let mut indicator = CoefficientOfVariation::new(20).unwrap();
/// let mut last = None;
/// for i in 0..40 {
/// last = indicator.update(100.0 + f64::from(i));
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct CoefficientOfVariation {
period: usize,
window: VecDeque<f64>,
sum: f64,
sum_sq: f64,
}
impl CoefficientOfVariation {
/// Construct a new rolling CV 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,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for CoefficientOfVariation {
type Input = f64;
type Output = f64;
fn update(&mut self, value: f64) -> Option<f64> {
if self.window.len() == self.period {
let old = self.window.pop_front().expect("non-empty");
self.sum -= old;
self.sum_sq -= old * old;
}
self.window.push_back(value);
self.sum += value;
self.sum_sq += value * value;
if self.window.len() < self.period {
return None;
}
let n = self.period as f64;
let mean = self.sum / n;
let variance = (self.sum_sq / n - mean * mean).max(0.0);
let sd = variance.sqrt();
if mean == 0.0 {
// Undefined ratio: return 0 instead of NaN/inf so downstream
// consumers can keep arithmetic going on flat or zeroed series.
return Some(0.0);
}
Some(sd / mean)
}
fn reset(&mut self) {
self.window.clear();
self.sum = 0.0;
self.sum_sq = 0.0;
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.window.len() == self.period
}
fn name(&self) -> &'static str {
"CoefficientOfVariation"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_zero_period() {
assert!(matches!(
CoefficientOfVariation::new(0),
Err(Error::PeriodZero)
));
}
#[test]
fn accessors_and_metadata() {
let cv = CoefficientOfVariation::new(14).unwrap();
assert_eq!(cv.period(), 14);
assert_eq!(cv.warmup_period(), 14);
assert_eq!(cv.name(), "CoefficientOfVariation");
}
#[test]
fn reference_value() {
// CV(3) of [2, 4, 6]: mean = 4, variance = 8/3, sd = √(8/3); CV = sd / 4.
let mut cv = CoefficientOfVariation::new(3).unwrap();
let out = cv.batch(&[2.0, 4.0, 6.0]);
assert_eq!(out[0], None);
let expected = (8.0_f64 / 3.0).sqrt() / 4.0;
assert_relative_eq!(out[2].unwrap(), expected, epsilon = 1e-12);
}
#[test]
fn constant_series_yields_zero() {
let mut cv = CoefficientOfVariation::new(5).unwrap();
for o in cv.batch(&[42.0; 20]).into_iter().flatten() {
assert_relative_eq!(o, 0.0, epsilon = 1e-12);
}
}
#[test]
fn zero_mean_returns_zero() {
// [-1, 0, 1] has mean 0; the CV is defined to be 0 rather than NaN.
let mut cv = CoefficientOfVariation::new(3).unwrap();
let out = cv.batch(&[-1.0, 0.0, 1.0]);
assert_relative_eq!(out[2].unwrap(), 0.0, epsilon = 1e-12);
}
#[test]
fn reset_clears_state() {
let mut cv = CoefficientOfVariation::new(5).unwrap();
cv.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
assert!(cv.is_ready());
cv.reset();
assert!(!cv.is_ready());
assert_eq!(cv.update(1.0), None);
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (0..60)
.map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 5.0)
.collect();
let batch = CoefficientOfVariation::new(14).unwrap().batch(&prices);
let mut b = CoefficientOfVariation::new(14).unwrap();
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,221 @@
//! Rolling Conditional Value-at-Risk (`CVaR` / Expected Shortfall).
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Rolling Conditional Value-at-Risk (Expected Shortfall).
///
/// Where [`crate::ValueAtRisk`] reports the loss at the lower-tail quantile,
/// `CVaR` averages **all** returns below that quantile — the expected loss
/// conditional on being in the bad tail:
///
/// ```text
/// q = 1 confidence
/// tail = returns over window with rank fraction ≤ q
/// CVaR = mean(tail) if mean is negative
/// CVaR = 0 otherwise
/// ```
///
/// The tail comprises the `floor(q · n)` smallest returns; if `floor` rounds
/// down to zero the smallest single return is used so the metric stays
/// defined for any `period ≥ 2`. Output is the magnitude of the expected
/// shortfall (sign-flipped to be non-negative). `CVaR` is by construction
/// `≥ VaR` because it averages losses *beyond* the `VaR` threshold.
///
/// Each `update` is O(period · log period).
///
/// # Example
///
/// ```
/// use wickra_core::{ConditionalValueAtRisk, Indicator};
///
/// let mut c = ConditionalValueAtRisk::new(100, 0.95).unwrap();
/// let mut last = None;
/// for i in 0..120 {
/// last = c.update((f64::from(i) * 0.1).sin() * 0.02);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct ConditionalValueAtRisk {
period: usize,
confidence: f64,
window: VecDeque<f64>,
}
impl ConditionalValueAtRisk {
/// Construct a new rolling `CVaR`.
///
/// # Errors
/// Returns [`Error::InvalidPeriod`] if `period < 2`, or if
/// `confidence` is outside `(0, 1)`.
pub fn new(period: usize, confidence: f64) -> Result<Self> {
if period < 2 {
return Err(Error::InvalidPeriod {
message: "conditional value-at-risk needs period >= 2",
});
}
if !confidence.is_finite() || confidence <= 0.0 || confidence >= 1.0 {
return Err(Error::InvalidPeriod {
message: "confidence must lie strictly between 0 and 1",
});
}
Ok(Self {
period,
confidence,
window: VecDeque::with_capacity(period),
})
}
/// Configured window length.
pub const fn period(&self) -> usize {
self.period
}
/// Configured confidence level.
pub const fn confidence(&self) -> f64 {
self.confidence
}
}
impl Indicator for ConditionalValueAtRisk {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() {
return None;
}
if self.window.len() == self.period {
self.window.pop_front();
}
self.window.push_back(input);
if self.window.len() < self.period {
return None;
}
let mut sorted: Vec<f64> = self.window.iter().copied().collect();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let q = 1.0 - self.confidence;
let n = sorted.len();
// Number of samples in the tail. Floor, with a min of 1 so the
// expectation is always defined.
let k = ((q * n as f64).floor() as usize).max(1);
let tail = &sorted[..k];
let mean = tail.iter().sum::<f64>() / k as f64;
Some((-mean).max(0.0))
}
fn reset(&mut self) {
self.window.clear();
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.window.len() == self.period
}
fn name(&self) -> &'static str {
"ConditionalValueAtRisk"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_invalid_params() {
assert!(matches!(
ConditionalValueAtRisk::new(1, 0.95),
Err(Error::InvalidPeriod { .. })
));
assert!(matches!(
ConditionalValueAtRisk::new(20, 0.0),
Err(Error::InvalidPeriod { .. })
));
assert!(matches!(
ConditionalValueAtRisk::new(20, 1.0),
Err(Error::InvalidPeriod { .. })
));
}
#[test]
fn accessors_and_metadata() {
let c = ConditionalValueAtRisk::new(100, 0.95).unwrap();
assert_eq!(c.period(), 100);
assert_relative_eq!(c.confidence(), 0.95, epsilon = 1e-12);
assert_eq!(c.name(), "ConditionalValueAtRisk");
assert_eq!(c.warmup_period(), 100);
}
#[test]
fn reference_value() {
// 20 returns -10..9 (each *0.01); confidence 0.95.
// q = 0.05, n = 20, k = floor(0.05*20) = 1.
// Tail = {-0.10}, CVaR = 0.10.
let mut c = ConditionalValueAtRisk::new(20, 0.95).unwrap();
let returns: Vec<f64> = (-10..10).map(|i| f64::from(i) * 0.01).collect();
let out = c.batch(&returns);
assert_relative_eq!(out[19].unwrap(), 0.10, epsilon = 1e-9);
}
#[test]
fn cvar_geq_var_on_same_window() {
// Sanity: with confidence 0.9, the tail of 10 returns has 1 sample;
// VaR uses interpolation between 0 and 1, so CVaR (mean of just the
// worst) >= VaR.
use crate::ValueAtRisk;
let returns: Vec<f64> = vec![
-0.05, -0.02, -0.01, 0.0, 0.005, 0.01, 0.02, 0.03, 0.04, 0.05,
];
let mut v = ValueAtRisk::new(10, 0.9).unwrap();
let mut c = ConditionalValueAtRisk::new(10, 0.9).unwrap();
let v_out = v.batch(&returns);
let c_out = c.batch(&returns);
let var = v_out[9].unwrap();
let cvar = c_out[9].unwrap();
assert!(cvar >= var - 1e-12, "CVaR {cvar} should be >= VaR {var}");
}
#[test]
fn all_positive_returns_yield_zero() {
let mut c = ConditionalValueAtRisk::new(5, 0.95).unwrap();
let out = c.batch(&[0.01, 0.02, 0.03, 0.04, 0.05]);
assert_eq!(out[4], Some(0.0));
}
#[test]
fn ignores_non_finite_input() {
let mut c = ConditionalValueAtRisk::new(3, 0.95).unwrap();
assert_eq!(c.update(f64::NAN), None);
assert_eq!(c.update(f64::INFINITY), None);
}
#[test]
fn reset_clears_state() {
let mut c = ConditionalValueAtRisk::new(3, 0.95).unwrap();
c.batch(&[-0.01, -0.02, -0.03]);
assert!(c.is_ready());
c.reset();
assert!(!c.is_ready());
assert_eq!(c.update(0.01), None);
}
#[test]
fn batch_equals_streaming() {
let returns: Vec<f64> = (0..50).map(|i| (f64::from(i) * 0.2).sin() * 0.02).collect();
let batch = ConditionalValueAtRisk::new(10, 0.95)
.unwrap()
.batch(&returns);
let mut s = ConditionalValueAtRisk::new(10, 0.95).unwrap();
let streamed: Vec<_> = returns.iter().map(|r| s.update(*r)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,307 @@
//! Connors RSI (CRSI).
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::indicators::rsi::Rsi;
use crate::traits::Indicator;
/// Larry Connors' RSI — average of three short-term mean-reversion components,
/// each individually bounded in `[0, 100]` so the aggregate is too:
///
/// 1. `RSI(close, period_rsi)` — a fast `RSI` (Connors' default `3`).
/// 2. `RSI(streak, period_streak)` — `RSI` of the current up/down run length
/// (`+1, +2, ...` for consecutive up closes, `1, 2, ...` for down closes,
/// `0` for unchanged). Connors' default `2`.
/// 3. `PercentRank(ROC(1), period_rank)` — the percentile rank of yesterday's
/// 1-period return in the last `period_rank` returns. Connors' default `100`.
///
/// ```text
/// CRSI = (RSI(close)_t + RSI(streak)_t + PercentRank(roc1)_t) / 3
/// ```
///
/// All three components live in `[0, 100]`, so `CRSI ∈ [0, 100]`. Connors'
/// trading rule of thumb: `CRSI < 5` is oversold, `CRSI > 95` is overbought
/// — both rare conditions, hence the short lookbacks.
///
/// # Example
///
/// ```
/// use wickra_core::{ConnorsRsi, Indicator};
///
/// let mut crsi = ConnorsRsi::classic();
/// let mut last = None;
/// for i in 0..200 {
/// last = crsi.update(100.0 + f64::from(i));
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct ConnorsRsi {
period_rsi: usize,
period_streak: usize,
period_rank: usize,
rsi_close: Rsi,
rsi_streak: Rsi,
prev_price: Option<f64>,
streak: f64,
/// Rolling window of the last `period_rank` 1-period returns
/// (`(price_t price_{t-1}) / price_{t-1}`).
rocs: VecDeque<f64>,
current: Option<f64>,
}
impl ConnorsRsi {
/// # Errors
/// Returns [`Error::PeriodZero`] if any of the three periods is zero.
pub fn new(period_rsi: usize, period_streak: usize, period_rank: usize) -> Result<Self> {
if period_rsi == 0 || period_streak == 0 || period_rank == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period_rsi,
period_streak,
period_rank,
rsi_close: Rsi::new(period_rsi)?,
rsi_streak: Rsi::new(period_streak)?,
prev_price: None,
streak: 0.0,
rocs: VecDeque::with_capacity(period_rank),
current: None,
})
}
/// Connors' recommended defaults: `(period_rsi = 3, period_streak = 2, period_rank = 100)`.
pub fn classic() -> Self {
Self::new(3, 2, 100).expect("classic Connors RSI parameters are valid")
}
/// Configured `(period_rsi, period_streak, period_rank)`.
pub const fn periods(&self) -> (usize, usize, usize) {
(self.period_rsi, self.period_streak, self.period_rank)
}
}
impl Indicator for ConnorsRsi {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() {
return self.current;
}
// Run the close-RSI on every input so it warms up regardless of the
// streak / percent-rank branches.
let rsi_close = self.rsi_close.update(input);
let Some(prev) = self.prev_price else {
self.prev_price = Some(input);
return None;
};
// Update the up/down streak run length.
self.streak = if input > prev {
self.streak.max(0.0) + 1.0
} else if input < prev {
self.streak.min(0.0) - 1.0
} else {
0.0
};
let rsi_streak = self.rsi_streak.update(self.streak);
// 1-period return; defined only when the previous price is non-zero.
if prev != 0.0 {
let roc = (input - prev) / prev;
if self.rocs.len() == self.period_rank {
self.rocs.pop_front();
}
self.rocs.push_back(roc);
}
self.prev_price = Some(input);
// PercentRank emits once the ROC window has filled.
let percent_rank = if self.rocs.len() == self.period_rank {
let latest = *self.rocs.back().expect("non-empty window");
let below = self.rocs.iter().filter(|&&r| r < latest).count();
Some(100.0 * below as f64 / self.period_rank as f64)
} else {
None
};
let value = (rsi_close?, rsi_streak?, percent_rank?);
let crsi = (value.0 + value.1 + value.2) / 3.0;
self.current = Some(crsi);
Some(crsi)
}
fn reset(&mut self) {
self.rsi_close.reset();
self.rsi_streak.reset();
self.prev_price = None;
self.streak = 0.0;
self.rocs.clear();
self.current = None;
}
fn warmup_period(&self) -> usize {
// The slowest branch is the percent-rank: it needs period_rank + 1
// prices (period_rank one-period returns). The close-RSI needs
// period_rsi + 1 prices and the streak-RSI needs period_streak + 1
// streak values = period_streak + 2 prices. The rank branch dominates
// for Connors' defaults.
let rsi_close = self.period_rsi + 1;
let rsi_streak = self.period_streak + 2;
let rank = self.period_rank + 1;
rsi_close.max(rsi_streak).max(rank)
}
fn is_ready(&self) -> bool {
self.current.is_some()
}
fn name(&self) -> &'static str {
"ConnorsRSI"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_zero_period() {
assert!(matches!(ConnorsRsi::new(0, 2, 100), Err(Error::PeriodZero)));
assert!(matches!(ConnorsRsi::new(3, 0, 100), Err(Error::PeriodZero)));
assert!(matches!(ConnorsRsi::new(3, 2, 0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_and_metadata() {
let crsi = ConnorsRsi::classic();
assert_eq!(crsi.periods(), (3, 2, 100));
assert_eq!(crsi.name(), "ConnorsRSI");
// Slowest branch: percent_rank with period_rank + 1 = 101.
assert_eq!(crsi.warmup_period(), 101);
}
#[test]
fn classic_factory() {
assert_eq!(ConnorsRsi::classic().periods(), (3, 2, 100));
}
#[test]
fn warmup_emits_first_value_at_warmup_period() {
// Use small periods so the test is fast.
let mut crsi = ConnorsRsi::new(3, 2, 5).unwrap();
// Slowest: 5 + 1 = 6.
assert_eq!(crsi.warmup_period(), 6);
let prices: Vec<f64> = (1..=8).map(f64::from).collect();
let out = crsi.batch(&prices);
for v in out.iter().take(5) {
assert!(v.is_none());
}
assert!(out[5].is_some());
}
#[test]
fn pure_uptrend_saturates_high() {
// A monotonic uptrend drives all three components toward 100:
// RSI of monotonic ups is 100, streak stays positive and growing so
// its RSI is 100, and every new 1-period return matches the prior
// ones so percent rank stabilises near 0 — but the average of all
// three still climbs well above 50.
let mut crsi = ConnorsRsi::classic();
for i in 1..=200 {
crsi.update(f64::from(i));
}
let v = crsi.current.unwrap();
assert!(
v > 60.0,
"uptrend should drive Connors RSI well above 50: {v}"
);
}
#[test]
fn output_is_bounded() {
let mut crsi = ConnorsRsi::classic();
let prices: Vec<f64> = (0..300)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 20.0)
.collect();
for v in crsi.batch(&prices).iter().flatten() {
assert!(
(0.0..=100.0).contains(v),
"Connors RSI out of [0, 100]: {v}"
);
}
}
#[test]
fn streak_resets_to_zero_on_unchanged_close() {
// Helper: feed a sequence and inspect the internal streak.
let mut crsi = ConnorsRsi::new(3, 2, 100).unwrap();
crsi.update(10.0);
crsi.update(11.0);
crsi.update(12.0);
assert_eq!(crsi.streak, 2.0);
crsi.update(12.0);
assert_relative_eq!(crsi.streak, 0.0, epsilon = 1e-12);
crsi.update(11.0);
assert_eq!(crsi.streak, -1.0);
crsi.update(10.0);
assert_eq!(crsi.streak, -2.0);
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (1..=200)
.map(|i| 100.0 + (f64::from(i) * 0.2).sin() * 5.0 + f64::from(i) * 0.1)
.collect();
let mut a = ConnorsRsi::classic();
let mut b = ConnorsRsi::classic();
assert_eq!(
a.batch(&prices),
prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut crsi = ConnorsRsi::classic();
let prices: Vec<f64> = (1..=200).map(f64::from).collect();
crsi.batch(&prices);
assert!(crsi.is_ready());
crsi.reset();
assert!(!crsi.is_ready());
assert_eq!(crsi.streak, 0.0);
assert!(crsi.prev_price.is_none());
}
#[test]
fn ignores_non_finite_input() {
let mut crsi = ConnorsRsi::classic();
let prices: Vec<f64> = (1..=200).map(f64::from).collect();
crsi.batch(&prices);
let before = crsi.current;
assert_eq!(crsi.update(f64::NAN), before);
assert_eq!(crsi.update(f64::INFINITY), before);
}
#[test]
fn zero_prev_skips_roc_update() {
// A previous price of 0.0 makes the 1-bar return undefined; the
// ROC ring buffer must be left unchanged on that step. Feeding
// 0.0 as the very first price seeds `prev_price = Some(0.0)`, so
// the next bar takes the `prev == 0.0` branch.
let mut crsi = ConnorsRsi::new(3, 2, 4).unwrap();
// Bar 1 seeds prev_price to 0.0.
crsi.update(0.0);
// Bar 2 must not push onto the ROC window; we cannot observe the
// ring directly but the indicator must not panic and must not
// emit until at least period_rank distinct non-zero returns have
// accumulated.
let after = crsi.update(1.0);
assert!(after.is_none(), "CRSI cannot emit on bar 2: {after:?}");
}
}
@@ -0,0 +1,240 @@
//! Ehlers Cybernetic Cycle Component.
#![allow(clippy::doc_markdown)]
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Ehlers' Cybernetic Cycle Component (CCC).
///
/// Classic EasyLanguage construct from *Cybernetic Analysis for Stocks and
/// Futures* (Ehlers 2004, ch. 4):
///
/// ```text
/// smooth[t] = (x[t] + 2*x[t-1] + 2*x[t-2] + x[t-3]) / 6
/// cycle[t] = (1 - alpha/2)^2 * (smooth[t] - 2*smooth[t-1] + smooth[t-2])
/// + 2 * (1 - alpha) * cycle[t-1]
/// - (1 - alpha)^2 * cycle[t-2]
/// ```
///
/// The result is a near-zero-mean oscillator that tracks the dominant cycle
/// component while filtering trend. `alpha` is a smoothing fraction in
/// `(0, 1]`; Ehlers recommends `2 / (period + 1)` for a given critical period.
///
/// The first six outputs follow Ehlers' "use the input directly" initial
/// condition so downstream consumers stay reactive.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, CyberneticCycle};
///
/// let mut cc = CyberneticCycle::new(10).unwrap();
/// let mut last = None;
/// for i in 0..30 {
/// last = cc.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct CyberneticCycle {
period: usize,
alpha: f64,
in_buf: [Option<f64>; 4],
smooth_buf: [Option<f64>; 3],
cycle_buf: [Option<f64>; 3],
count: usize,
last_value: Option<f64>,
}
impl CyberneticCycle {
/// Construct with the dominant-cycle period (alpha = 2 / (period + 1)).
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
let alpha = 2.0 / (period as f64 + 1.0);
Ok(Self {
period,
alpha,
in_buf: [None; 4],
smooth_buf: [None; 3],
cycle_buf: [None; 3],
count: 0,
last_value: None,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
/// Smoothing alpha.
pub const fn alpha(&self) -> f64 {
self.alpha
}
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.last_value
}
/// Shift in `x` at position 0 of a 3-slot buffer.
fn push3(buf: &mut [Option<f64>; 3], x: f64) {
buf[2] = buf[1];
buf[1] = buf[0];
buf[0] = Some(x);
}
fn push4(buf: &mut [Option<f64>; 4], x: f64) {
buf[3] = buf[2];
buf[2] = buf[1];
buf[1] = buf[0];
buf[0] = Some(x);
}
}
impl Indicator for CyberneticCycle {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() {
return self.last_value;
}
self.count += 1;
Self::push4(&mut self.in_buf, input);
// Smooth needs four prior inputs (positions 0..=3).
let smooth = if let (Some(a), Some(b), Some(c), Some(d)) = (
self.in_buf[0],
self.in_buf[1],
self.in_buf[2],
self.in_buf[3],
) {
(a + 2.0 * b + 2.0 * c + d) / 6.0
} else {
// Initial condition: use the raw input.
input
};
Self::push3(&mut self.smooth_buf, smooth);
// Cycle needs two prior smooths and two prior cycles.
let one_minus_half_alpha = 1.0 - self.alpha / 2.0;
let one_minus_alpha = 1.0 - self.alpha;
let drv = one_minus_half_alpha * one_minus_half_alpha;
// The 3-slot `smooth_buf` and `cycle_buf` ring buffers fill within a
// few updates, so the pattern match only fails during warmup. The
// `else` branch is therefore the Ehlers initial condition: the
// second-difference of the raw input series, scaled by 0.5 — matches
// the EasyLanguage implementation's first-bar fallback.
let cycle = if let (Some(s0), Some(s1), Some(s2), Some(c1), Some(c2)) = (
self.smooth_buf[0],
self.smooth_buf[1],
self.smooth_buf[2],
self.cycle_buf[0],
self.cycle_buf[1],
) {
drv * (s0 - 2.0 * s1 + s2) + 2.0 * one_minus_alpha * c1
- one_minus_alpha * one_minus_alpha * c2
} else {
let (x0, x1, x2) = (
self.in_buf[0].unwrap_or(input),
self.in_buf[1].unwrap_or(input),
self.in_buf[2].unwrap_or(input),
);
(x0 - 2.0 * x1 + x2) / 4.0
};
Self::push3(&mut self.cycle_buf, cycle);
self.last_value = Some(cycle);
Some(cycle)
}
fn reset(&mut self) {
self.in_buf = [None; 4];
self.smooth_buf = [None; 3];
self.cycle_buf = [None; 3];
self.count = 0;
self.last_value = None;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.last_value.is_some()
}
fn name(&self) -> &'static str {
"CyberneticCycle"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn new_rejects_zero_period() {
assert!(matches!(CyberneticCycle::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_and_metadata() {
let mut cc = CyberneticCycle::new(10).unwrap();
assert_eq!(cc.period(), 10);
assert_relative_eq!(cc.alpha(), 2.0 / 11.0, epsilon = 1e-15);
assert_eq!(cc.warmup_period(), 1);
assert_eq!(cc.name(), "CyberneticCycle");
assert!(!cc.is_ready());
cc.update(100.0);
assert!(cc.is_ready());
}
#[test]
fn constant_series_converges_to_zero() {
let mut cc = CyberneticCycle::new(10).unwrap();
let out = cc.batch(&[50.0_f64; 200]);
for x in out.iter().skip(50).flatten() {
assert_relative_eq!(*x, 0.0, epsilon = 1e-9);
}
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (0..120)
.map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 5.0)
.collect();
let mut a = CyberneticCycle::new(15).unwrap();
let mut b = CyberneticCycle::new(15).unwrap();
let batch = a.batch(&prices);
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
assert_eq!(batch, streamed);
}
#[test]
fn ignores_non_finite_input() {
let mut cc = CyberneticCycle::new(10).unwrap();
cc.batch(&(1..=30).map(f64::from).collect::<Vec<_>>());
let before = cc.value();
assert!(before.is_some());
assert_eq!(cc.update(f64::NAN), before);
}
#[test]
fn reset_clears_state() {
let mut cc = CyberneticCycle::new(10).unwrap();
cc.batch(&(1..=30).map(f64::from).collect::<Vec<_>>());
assert!(cc.is_ready());
cc.reset();
assert!(!cc.is_ready());
}
}
@@ -0,0 +1,213 @@
//! Ehlers Decycler (single-pole high-pass complement).
use std::f64::consts::PI;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Ehlers' Decycler: price minus the dominant cycle component.
///
/// Implemented as `decycler = input - HP(input)`, where `HP` is a 2-pole
/// high-pass filter with critical period `period`. Subtracting the high-pass
/// from the raw price leaves the slow component — equivalent to a smoothed
/// trend line with no group delay at low frequencies. From *Cycle Analytics
/// for Traders* (Ehlers 2013, ch. 4).
///
/// The high-pass uses the standard 2-pole formulation:
///
/// ```text
/// alpha = (cos(.707*2*pi/period) + sin(.707*2*pi/period) - 1) / cos(.707*2*pi/period)
/// HP[t] = (1 - alpha/2)^2 * (x[t] - 2*x[t-1] + x[t-2])
/// + 2*(1 - alpha) * HP[t-1]
/// - (1 - alpha)^2 * HP[t-2]
/// ```
///
/// The first two outputs simply equal the input (warmup buffering), which is
/// the conventional Ehlers initialisation and keeps downstream consumers
/// reactive while the recursion fills.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, Decycler};
///
/// let mut dc = Decycler::new(20).unwrap();
/// let mut last = None;
/// for i in 0..50 {
/// last = dc.update(100.0 + f64::from(i) * 0.5);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct Decycler {
period: usize,
alpha: f64,
prev_in_1: Option<f64>,
prev_in_2: Option<f64>,
prev_hp_1: f64,
prev_hp_2: f64,
last_value: Option<f64>,
}
impl Decycler {
/// Construct a Decycler with the given critical period for the high-pass filter.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
let arg = 0.707 * 2.0 * PI / period as f64;
let c = arg.cos();
let alpha = (c + arg.sin() - 1.0) / c;
Ok(Self {
period,
alpha,
prev_in_1: None,
prev_in_2: None,
prev_hp_1: 0.0,
prev_hp_2: 0.0,
last_value: None,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
/// High-pass `alpha` coefficient derived from the period.
pub const fn alpha(&self) -> f64 {
self.alpha
}
/// Current decycler value if available.
pub const fn value(&self) -> Option<f64> {
self.last_value
}
/// Compute and store the high-pass output for the latest input.
fn step_hp(&mut self, input: f64) -> f64 {
let (Some(x1), Some(x2)) = (self.prev_in_1, self.prev_in_2) else {
self.prev_hp_2 = self.prev_hp_1;
self.prev_hp_1 = 0.0;
return 0.0;
};
let one_minus_half_alpha = 1.0 - self.alpha / 2.0;
let one_minus_alpha = 1.0 - self.alpha;
let drv = one_minus_half_alpha * one_minus_half_alpha;
let term1 = drv * (input - 2.0 * x1 + x2);
let term2 = 2.0 * one_minus_alpha * self.prev_hp_1;
let term3 = one_minus_alpha * one_minus_alpha * self.prev_hp_2;
let hp = term1 + term2 - term3;
self.prev_hp_2 = self.prev_hp_1;
self.prev_hp_1 = hp;
hp
}
}
impl Indicator for Decycler {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() {
return self.last_value;
}
let hp = self.step_hp(input);
let v = input - hp;
self.prev_in_2 = self.prev_in_1;
self.prev_in_1 = Some(input);
self.last_value = Some(v);
Some(v)
}
fn reset(&mut self) {
self.prev_in_1 = None;
self.prev_in_2 = None;
self.prev_hp_1 = 0.0;
self.prev_hp_2 = 0.0;
self.last_value = None;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.last_value.is_some()
}
fn name(&self) -> &'static str {
"Decycler"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn new_rejects_zero_period() {
assert!(matches!(Decycler::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_and_metadata() {
let mut dc = Decycler::new(20).unwrap();
assert_eq!(dc.period(), 20);
assert_eq!(dc.warmup_period(), 1);
assert_eq!(dc.name(), "Decycler");
assert!(dc.alpha() > 0.0 && dc.alpha() < 1.0);
assert!(!dc.is_ready());
dc.update(100.0);
assert!(dc.is_ready());
assert!(dc.value().is_some());
}
#[test]
fn constant_series_passes_through() {
// For a flat input, the high-pass output is zero, so the decycler
// equals the input.
let mut dc = Decycler::new(20).unwrap();
let out = dc.batch(&[42.0_f64; 80]);
for x in out.iter().flatten() {
assert_relative_eq!(*x, 42.0, epsilon = 1e-9);
}
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (0..100)
.map(|i| 100.0 + (f64::from(i) * 0.15).sin() * 5.0)
.collect();
let mut a = Decycler::new(20).unwrap();
let mut b = Decycler::new(20).unwrap();
let batch = a.batch(&prices);
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
assert_eq!(batch, streamed);
}
#[test]
fn ignores_non_finite_input() {
let mut dc = Decycler::new(20).unwrap();
dc.batch(&(1..=30).map(f64::from).collect::<Vec<_>>());
let before = dc.value();
assert!(before.is_some());
assert_eq!(dc.update(f64::NAN), before);
assert_eq!(dc.update(f64::INFINITY), before);
}
#[test]
fn reset_clears_state() {
let mut dc = Decycler::new(20).unwrap();
dc.batch(&(1..=40).map(f64::from).collect::<Vec<_>>());
assert!(dc.is_ready());
dc.reset();
assert!(!dc.is_ready());
}
}
@@ -0,0 +1,182 @@
//! Ehlers Decycler Oscillator (difference of two decyclers).
use crate::error::{Error, Result};
use crate::indicators::decycler::Decycler;
use crate::traits::Indicator;
/// Difference between a fast and a slow [`Decycler`], producing a smoothed
/// oscillator that crosses zero at trend changes.
///
/// Defined as `fast_decycler - slow_decycler` with `fast_period < slow_period`.
/// The construct removes the trend component that both decyclers share, leaving
/// the medium-frequency cycle band — analogous in spirit to MACD but with
/// Ehlers' zero-lag high-pass filters instead of EMAs.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, DecyclerOscillator};
///
/// let mut dco = DecyclerOscillator::new(10, 30).unwrap();
/// let mut last = None;
/// for i in 0..60 {
/// last = dco.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct DecyclerOscillator {
fast: Decycler,
slow: Decycler,
last_value: Option<f64>,
}
impl DecyclerOscillator {
/// Construct with the fast and slow periods.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if either period is zero, and
/// [`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: "fast period must be strictly less than slow period",
});
}
Ok(Self {
fast: Decycler::new(fast)?,
slow: Decycler::new(slow)?,
last_value: None,
})
}
/// Configured `(fast, slow)` periods.
pub fn periods(&self) -> (usize, usize) {
(self.fast.period(), self.slow.period())
}
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.last_value
}
}
impl Indicator for DecyclerOscillator {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() {
return self.last_value;
}
// Both child `Decycler` instances emit `Some` from the first bar
// (Ehlers' convention is "output = input" until the recursion warms),
// so the pair is always populated and the `?` short-circuit never
// fires in practice.
let f = self.fast.update(input)?;
let s = self.slow.update(input)?;
let v = f - s;
self.last_value = Some(v);
Some(v)
}
fn reset(&mut self) {
self.fast.reset();
self.slow.reset();
self.last_value = None;
}
fn warmup_period(&self) -> usize {
self.fast.warmup_period().max(self.slow.warmup_period())
}
fn is_ready(&self) -> bool {
self.last_value.is_some()
}
fn name(&self) -> &'static str {
"DecyclerOscillator"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn new_rejects_invalid_periods() {
assert!(matches!(
DecyclerOscillator::new(0, 20),
Err(Error::PeriodZero)
));
assert!(matches!(
DecyclerOscillator::new(10, 0),
Err(Error::PeriodZero)
));
assert!(matches!(
DecyclerOscillator::new(20, 10),
Err(Error::InvalidPeriod { .. })
));
assert!(matches!(
DecyclerOscillator::new(10, 10),
Err(Error::InvalidPeriod { .. })
));
}
#[test]
fn accessors_and_metadata() {
let mut dco = DecyclerOscillator::new(10, 30).unwrap();
assert_eq!(dco.periods(), (10, 30));
assert_eq!(dco.name(), "DecyclerOscillator");
assert!(dco.warmup_period() >= 1);
assert!(!dco.is_ready());
dco.update(100.0);
assert!(dco.is_ready());
assert!(dco.value().is_some());
}
#[test]
fn constant_series_yields_zero() {
let mut dco = DecyclerOscillator::new(10, 30).unwrap();
let out = dco.batch(&[42.0_f64; 80]);
for x in out.iter().flatten() {
assert_relative_eq!(*x, 0.0, epsilon = 1e-9);
}
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (0..100)
.map(|i| 100.0 + (f64::from(i) * 0.2).cos() * 6.0)
.collect();
let mut a = DecyclerOscillator::new(10, 30).unwrap();
let mut b = DecyclerOscillator::new(10, 30).unwrap();
let batch = a.batch(&prices);
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
assert_eq!(batch, streamed);
}
#[test]
fn ignores_non_finite_input() {
let mut dco = DecyclerOscillator::new(10, 30).unwrap();
dco.batch(&(1..=50).map(f64::from).collect::<Vec<_>>());
let before = dco.value();
assert!(before.is_some());
assert_eq!(dco.update(f64::NAN), before);
}
#[test]
fn reset_clears_state() {
let mut dco = DecyclerOscillator::new(10, 30).unwrap();
dco.batch(&(1..=50).map(f64::from).collect::<Vec<_>>());
assert!(dco.is_ready());
dco.reset();
assert!(!dco.is_ready());
}
}
@@ -0,0 +1,242 @@
//! Demand Index (James Sibbet).
use crate::error::{Error, Result};
use crate::indicators::ema::Ema;
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// James Sibbet's Demand Index — a smoothed ratio of buying pressure to
/// selling pressure, classifying each bar's volume by whether the close rose
/// or fell relative to the previous close.
///
/// Sibbet's original 1970s formulation runs the raw buying/selling pressure
/// through several smoothings and yields a number that swings in `[100, 100]`.
/// This implementation uses the textbook simplified form that captures the same
/// signal in a streaming-friendly shape:
///
/// ```text
/// pressure_t = volume_t · ((close_t close_{t1}) / max(close_{t1}, ε))
/// · (1 + (high_t low_t) / max(close_{t1}, ε))
/// DI_t = EMA(pressure, period)_t
/// ```
///
/// Positive readings mean the smoothed money flow is leaning to the buy side
/// (up-day volume dominates), negative to the sell side. The first candle only
/// establishes the previous close, so the first non-`None` value lands once the
/// EMA has accumulated `period` pressure samples. A previous close of zero
/// contributes no signal (avoids division by zero). The output is unbounded;
/// what matters is the sign and the divergence against price.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, DemandIndex, Indicator};
///
/// let mut indicator = DemandIndex::new(10).unwrap();
/// let mut last = None;
/// for i in 0..120 {
/// let base = 100.0 + f64::from(i);
/// let candle =
/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 50.0, i64::from(i)).unwrap();
/// last = indicator.update(candle);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct DemandIndex {
period: usize,
ema: Ema,
prev_close: Option<f64>,
}
impl DemandIndex {
/// Construct a new Demand Index with the given EMA smoothing 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,
ema: Ema::new(period)?,
prev_close: None,
})
}
/// Configured EMA smoothing period.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for DemandIndex {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
let Some(prev) = self.prev_close else {
self.prev_close = Some(candle.close);
return None;
};
let pressure = if prev == 0.0 {
// No prior baseline -> can't normalise; treat as no flow.
0.0
} else {
let ret = (candle.close - prev) / prev;
let range_norm = (candle.high - candle.low) / prev;
candle.volume * ret * (1.0 + range_norm)
};
self.prev_close = Some(candle.close);
self.ema.update(pressure)
}
fn reset(&mut self) {
self.ema.reset();
self.prev_close = None;
}
fn warmup_period(&self) -> usize {
// One seed bar to establish the previous close, then the EMA needs
// `period` samples to seed.
self.period + 1
}
fn is_ready(&self) -> bool {
self.ema.is_ready()
}
fn name(&self) -> &'static str {
"DemandIndex"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn c(open: f64, high: f64, low: f64, close: f64, volume: f64, ts: i64) -> Candle {
Candle::new(open, high, low, close, volume, ts).unwrap()
}
#[test]
fn rejects_zero_period() {
assert!(matches!(DemandIndex::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_and_metadata() {
let di = DemandIndex::new(10).unwrap();
assert_eq!(di.period(), 10);
assert_eq!(di.name(), "DemandIndex");
assert_eq!(di.warmup_period(), 11);
}
#[test]
fn constant_series_yields_zero() {
// No close change -> pressure = 0 on every bar -> EMA stays at 0.
let candles: Vec<Candle> = (0..40)
.map(|i| c(10.0, 10.0, 10.0, 10.0, 100.0, i))
.collect();
let mut di = DemandIndex::new(5).unwrap();
for v in di.batch(&candles).into_iter().flatten() {
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
}
}
#[test]
fn rising_series_yields_positive_signal() {
// Strictly rising closes on constant volume -> pressure is positive every
// bar -> smoothed DI must end up strictly positive.
let candles: Vec<Candle> = (0..40)
.map(|i| {
let f = i as f64;
c(100.0 + f, 101.0 + f, 99.0 + f, 100.5 + f, 100.0, i)
})
.collect();
let mut di = DemandIndex::new(5).unwrap();
let out = di.batch(&candles);
let last = out.iter().filter_map(|x| *x).next_back().unwrap();
assert!(
last > 0.0,
"rising series must yield positive DI, got {last}"
);
}
#[test]
fn falling_series_yields_negative_signal() {
let candles: Vec<Candle> = (0..40)
.map(|i| {
let f = i as f64;
c(200.0 - f, 201.0 - f, 199.0 - f, 199.5 - f, 100.0, i)
})
.collect();
let mut di = DemandIndex::new(5).unwrap();
let out = di.batch(&candles);
let last = out.iter().filter_map(|x| *x).next_back().unwrap();
assert!(
last < 0.0,
"falling series must yield negative DI, got {last}"
);
}
#[test]
fn zero_prev_close_contributes_no_signal() {
// First two bars: prev close is exactly zero -> pressure clipped to 0.
// We then continue with a non-zero series and confirm output behaves.
let mut di = DemandIndex::new(3).unwrap();
di.update(c(0.0, 0.0, 0.0, 0.0, 100.0, 0));
// Bar 2 sees prev_close == 0 -> pressure = 0.
di.update(c(0.0, 1.0, 0.0, 1.0, 100.0, 1));
// Subsequent bars now have non-zero prev_close.
di.update(c(1.0, 2.0, 1.0, 2.0, 100.0, 2));
// Just check that nothing exploded; an EMA(3) needs 3 samples post-seed.
// The first sample at bar 2 was zero, the second at bar 3 positive.
let v = di.update(c(2.0, 3.0, 2.0, 3.0, 100.0, 3));
assert!(v.is_some());
assert!(v.unwrap().is_finite());
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..100i64)
.map(|i| {
let f = i as f64;
let mid = 100.0 + (f * 0.2).sin() * 5.0;
c(
mid,
mid + 1.5,
mid - 1.5,
mid + 0.3,
80.0 + (i % 5) as f64,
i,
)
})
.collect();
let mut a = DemandIndex::new(10).unwrap();
let mut b = DemandIndex::new(10).unwrap();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let candles: Vec<Candle> = (0..40)
.map(|i| {
let f = i as f64;
c(100.0 + f, 101.0 + f, 99.0 + f, 100.5 + f, 100.0, i)
})
.collect();
let mut di = DemandIndex::new(5).unwrap();
di.batch(&candles);
assert!(di.is_ready());
di.reset();
assert!(!di.is_ready());
assert_eq!(di.update(candles[0]), None);
}
}
@@ -0,0 +1,192 @@
//! `DeMark` Pivot Points.
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// `DeMark` Pivot Points output: a single resistance, pivot and support.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct DemarkPivotsOutput {
/// Pivot Point: `X / 4` where `X` is the conditional sum (see [`DemarkPivots`]).
pub pp: f64,
/// Resistance 1: `X / 2 L`.
pub r1: f64,
/// Support 1: `X / 2 H`.
pub s1: f64,
}
/// `DeMark` Pivot Points — Tom `DeMark`'s conditional pivot formulation, derived
/// from a sum `X` that depends on whether the bar closed up, down or flat.
///
/// ```text
/// X = 2·H + L + C if C < O (down bar)
/// H + 2·L + C if C > O (up bar)
/// H + L + 2·C if C == O (doji)
///
/// PP = X / 4
/// R1 = X / 2 L
/// S1 = X / 2 H
/// ```
///
/// Unlike the classic pivots, only one resistance and one support are
/// produced; `DeMark`'s intent is a tighter, condition-sensitive set rather than
/// a multi-tier fan. The branching means a bar's open carries information that
/// other pivot variants discard.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, DemarkPivots, Indicator};
///
/// // Up bar: O=100, H=120, L=80, C=110 -> X = H + 2·L + C = 390.
/// let up = Candle::new(100.0, 120.0, 80.0, 110.0, 1.0, 0).unwrap();
/// let lv = DemarkPivots::new().update(up).unwrap();
/// assert!((lv.pp - 97.5).abs() < 1e-9);
/// ```
#[derive(Debug, Clone, Default)]
pub struct DemarkPivots {
ready: bool,
}
impl DemarkPivots {
/// Construct a new `DeMark` Pivot Points indicator.
pub const fn new() -> Self {
Self { ready: false }
}
}
impl Indicator for DemarkPivots {
type Input = Candle;
type Output = DemarkPivotsOutput;
fn update(&mut self, candle: Candle) -> Option<DemarkPivotsOutput> {
let open = candle.open;
let high = candle.high;
let low = candle.low;
let close = candle.close;
let x = if close < open {
2.0 * high + low + close
} else if close > open {
high + 2.0 * low + close
} else {
high + low + 2.0 * close
};
let pp = x / 4.0;
let half = x / 2.0;
let out = DemarkPivotsOutput {
pp,
r1: half - low,
s1: half - high,
};
self.ready = true;
Some(out)
}
fn reset(&mut self) {
self.ready = false;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.ready
}
fn name(&self) -> &'static str {
"DemarkPivots"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
#[test]
fn down_bar_uses_2h_plus_l_plus_c() {
// O=110, H=120, L=80, C=100 (close < open) -> X = 2·120 + 80 + 100 = 420.
let cd = Candle::new(110.0, 120.0, 80.0, 100.0, 1.0, 0).unwrap();
let lv = DemarkPivots::new().update(cd).unwrap();
assert!((lv.pp - 105.0).abs() < 1e-12);
assert!((lv.r1 - (210.0 - 80.0)).abs() < 1e-12);
assert!((lv.s1 - (210.0 - 120.0)).abs() < 1e-12);
}
#[test]
fn up_bar_uses_h_plus_2l_plus_c() {
// O=100, H=120, L=80, C=110 (close > open) -> X = 120 + 160 + 110 = 390.
let cd = Candle::new(100.0, 120.0, 80.0, 110.0, 1.0, 0).unwrap();
let lv = DemarkPivots::new().update(cd).unwrap();
assert!((lv.pp - 97.5).abs() < 1e-12);
assert!((lv.r1 - (195.0 - 80.0)).abs() < 1e-12);
assert!((lv.s1 - (195.0 - 120.0)).abs() < 1e-12);
}
#[test]
fn doji_uses_h_plus_l_plus_2c() {
// O = C = 100, H=120, L=80 -> X = 120 + 80 + 200 = 400.
let cd = Candle::new(100.0, 120.0, 80.0, 100.0, 1.0, 0).unwrap();
let lv = DemarkPivots::new().update(cd).unwrap();
assert!((lv.pp - 100.0).abs() < 1e-12);
}
#[test]
fn ordering_resistance_above_pivot_above_support() {
let cd = Candle::new(100.0, 120.0, 80.0, 110.0, 1.0, 0).unwrap();
let lv = DemarkPivots::new().update(cd).unwrap();
assert!(lv.r1 >= lv.pp);
assert!(lv.pp >= lv.s1);
}
#[test]
fn constant_series_collapses_levels() {
let cd = Candle::new(50.0, 50.0, 50.0, 50.0, 1.0, 0).unwrap();
let lv = DemarkPivots::new().update(cd).unwrap();
assert_eq!(lv.pp, 50.0);
assert_eq!(lv.r1, 50.0);
assert_eq!(lv.s1, 50.0);
}
#[test]
fn warmup_and_ready() {
let mut p = DemarkPivots::new();
assert!(!p.is_ready());
assert_eq!(p.warmup_period(), 1);
let cd = Candle::new(10.0, 11.0, 9.0, 10.0, 1.0, 0).unwrap();
p.update(cd);
assert!(p.is_ready());
}
#[test]
fn reset_clears_state() {
let mut p = DemarkPivots::new();
let cd = Candle::new(10.0, 11.0, 9.0, 10.0, 1.0, 0).unwrap();
p.update(cd);
p.reset();
assert!(!p.is_ready());
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..40)
.map(|i| {
let base = f64::from(i);
Candle::new(base, base + 2.0, base - 0.5, base + 1.0, 1.0, i64::from(i)).unwrap()
})
.collect();
let mut a = DemarkPivots::new();
let mut b = DemarkPivots::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
#[test]
fn accessors_and_metadata() {
let p = DemarkPivots::new();
assert_eq!(p.warmup_period(), 1);
assert_eq!(p.name(), "DemarkPivots");
}
}
@@ -0,0 +1,221 @@
//! Population standard deviation of residuals from a rolling OLS detrend.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Detrended (residual) standard deviation over the last `period` inputs.
///
/// Over the trailing window indexed `x = 0, 1, …, period 1` the OLS line
/// `y = a + b·x` is fitted and the residual sum of squares is then divided
/// by `n` (population convention):
///
/// ```text
/// slope = (n·Σxy Σx·Σy) / (n·Σxx (Σx)²)
/// SS_total = Σy² n·ȳ²
/// RSS = SS_total slope² · ( denom / n )
/// DetrendedStdDev = √( RSS / n )
/// ```
///
/// Unlike [`crate::StdDev`], which measures dispersion around the rolling
/// **mean**, `DetrendedStdDev` measures dispersion around the rolling
/// **linear trend** — the portion of the price action that is *not*
/// explained by the local slope. On a strongly trending series this is
/// much smaller than `StdDev`; on a sideways, mean-reverting series the
/// two converge.
///
/// The divisor is `n` (population), matching the convention of
/// [`crate::StdDev`]; use [`crate::StandardError`] when you want the
/// textbook standard error of estimate with `n 2` residual degrees of
/// freedom.
///
/// Each `update` is O(1) via the same rolling sums as
/// [`crate::LinearRegression`], plus a running `Σy²`. Floating-point
/// cancellation noise in the residual is clamped to zero before the square
/// root.
///
/// # Example
///
/// ```
/// use wickra_core::{DetrendedStdDev, Indicator};
///
/// let mut indicator = DetrendedStdDev::new(14).unwrap();
/// let mut last = None;
/// for i in 0..40 {
/// last = indicator.update(100.0 + f64::from(i) + (f64::from(i) * 0.3).sin());
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct DetrendedStdDev {
period: usize,
window: VecDeque<f64>,
sum_x: f64,
/// `n·Σxx (Σx)²` — OLS denominator, constant in `period`.
denom: f64,
sum_y: f64,
sum_xy: f64,
sum_y_sq: f64,
}
impl DetrendedStdDev {
/// Construct a new rolling detrended standard deviation.
///
/// # 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: "detrended stddev needs period >= 2",
});
}
let n = period as f64;
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,
sum_y_sq: 0.0,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for DetrendedStdDev {
type Input = f64;
type Output = f64;
fn update(&mut self, value: f64) -> Option<f64> {
if self.window.len() == self.period {
let y0 = self.window.pop_front().expect("non-empty");
self.sum_xy = self.sum_xy - self.sum_y + y0;
self.sum_y -= y0;
self.sum_y_sq -= y0 * y0;
}
let k = self.window.len() as f64;
self.window.push_back(value);
self.sum_y += value;
self.sum_xy += k * value;
self.sum_y_sq += value * 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 mean_y = self.sum_y / n;
let ss_total = self.sum_y_sq - n * mean_y * mean_y;
let s_xx = self.denom / n;
let rss = (ss_total - slope * slope * s_xx).max(0.0);
Some((rss / n).sqrt())
}
fn reset(&mut self) {
self.window.clear();
self.sum_y = 0.0;
self.sum_xy = 0.0;
self.sum_y_sq = 0.0;
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.window.len() == self.period
}
fn name(&self) -> &'static str {
"DetrendedStdDev"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_period_below_two() {
assert!(DetrendedStdDev::new(0).is_err());
assert!(DetrendedStdDev::new(1).is_err());
assert!(DetrendedStdDev::new(2).is_ok());
}
#[test]
fn accessors_and_metadata() {
let d = DetrendedStdDev::new(14).unwrap();
assert_eq!(d.period(), 14);
assert_eq!(d.warmup_period(), 14);
assert_eq!(d.name(), "DetrendedStdDev");
}
#[test]
fn perfect_line_has_zero_residual() {
// Residuals are zero on a perfectly linear series.
let prices: Vec<f64> = (0..30).map(|i| 2.0 * f64::from(i) + 5.0).collect();
let mut d = DetrendedStdDev::new(10).unwrap();
for v in d.batch(&prices).into_iter().flatten() {
assert_relative_eq!(v, 0.0, epsilon = 1e-9);
}
}
#[test]
fn constant_series_yields_zero() {
let mut d = DetrendedStdDev::new(5).unwrap();
for v in d.batch(&[42.0; 20]).into_iter().flatten() {
assert_relative_eq!(v, 0.0, epsilon = 1e-9);
}
}
#[test]
fn never_exceeds_stddev() {
// The detrended residual is the projection of (y - ȳ) orthogonal to
// the trend axis, so its norm cannot exceed the raw stddev. Equality
// holds iff the OLS slope is exactly zero.
let prices: Vec<f64> = (0..60)
.map(|i| 50.0 + f64::from(i) * 0.5 + (f64::from(i) * 0.7).sin() * 4.0)
.collect();
let mut d = DetrendedStdDev::new(14).unwrap();
let mut sd = crate::StdDev::new(14).unwrap();
for &p in &prices {
let (dv, sv) = (d.update(p), sd.update(p));
assert_eq!(dv.is_some(), sv.is_some());
if let (Some(dv), Some(sv)) = (dv, sv) {
assert!(dv <= sv + 1e-9, "detrended {dv} should be <= stddev {sv}");
}
}
}
#[test]
fn reset_clears_state() {
let mut d = DetrendedStdDev::new(5).unwrap();
d.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
assert!(d.is_ready());
d.reset();
assert!(!d.is_ready());
assert_eq!(d.update(1.0), None);
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (0..60)
.map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 10.0)
.collect();
let batch = DetrendedStdDev::new(14).unwrap().batch(&prices);
let mut b = DetrendedStdDev::new(14).unwrap();
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
assert_eq!(batch, streamed);
}
}
+185
View File
@@ -0,0 +1,185 @@
//! Doji candlestick pattern.
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Doji — a candle whose body is negligible relative to its range.
///
/// A Doji prints whenever the absolute distance between open and close is
/// small compared to the total `high low` range. It is the canonical
/// indecision bar and a building block for many three-bar reversal patterns.
///
/// ```text
/// body = |close open|
/// range = high low
/// doji = body <= body_threshold * range
/// ```
///
/// The output is `+1.0` when a Doji is detected and `0.0` otherwise. Doji is
/// directionless — no `1.0` is emitted. Pattern-shape check only — no trend
/// filter is applied; combine with a trend indicator for actionable signals.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Doji, Indicator};
///
/// let mut indicator = Doji::default();
/// let candle = Candle::new(10.0, 11.0, 9.0, 10.0, 1.0, 0).unwrap();
/// assert_eq!(indicator.update(candle), Some(1.0));
/// ```
#[derive(Debug, Clone)]
pub struct Doji {
body_threshold: f64,
has_emitted: bool,
}
impl Default for Doji {
fn default() -> Self {
Self::new()
}
}
impl Doji {
/// Construct a Doji detector with the default body threshold (`0.1`).
pub const fn new() -> Self {
Self {
body_threshold: 0.1,
has_emitted: false,
}
}
/// Construct a Doji detector with a custom body / range threshold.
///
/// `body_threshold` must lie in `(0, 1]`.
pub fn with_threshold(body_threshold: f64) -> Result<Self> {
if !(body_threshold > 0.0 && body_threshold <= 1.0) {
return Err(Error::InvalidPeriod {
message: "doji body threshold must lie in (0, 1]",
});
}
Ok(Self {
body_threshold,
has_emitted: false,
})
}
/// Configured body / range threshold.
pub fn body_threshold(&self) -> f64 {
self.body_threshold
}
}
impl Indicator for Doji {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
self.has_emitted = true;
let range = candle.high - candle.low;
if range <= 0.0 {
return Some(0.0);
}
let body = (candle.close - candle.open).abs();
Some(if body <= self.body_threshold * range {
1.0
} else {
0.0
})
}
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 {
"Doji"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle {
Candle::new(open, high, low, close, 1.0, ts).unwrap()
}
#[test]
fn rejects_invalid_threshold() {
assert!(Doji::with_threshold(0.0).is_err());
assert!(Doji::with_threshold(-0.1).is_err());
assert!(Doji::with_threshold(1.5).is_err());
}
#[test]
fn accepts_valid_threshold() {
let d = Doji::with_threshold(0.05).unwrap();
assert!((d.body_threshold() - 0.05).abs() < 1e-12);
}
#[test]
fn accessors_and_metadata() {
let d = Doji::default();
assert_eq!(d.name(), "Doji");
assert_eq!(d.warmup_period(), 1);
assert!(!d.is_ready());
assert!((d.body_threshold() - 0.1).abs() < 1e-12);
}
#[test]
fn obvious_doji_is_one() {
let mut d = Doji::new();
// open == close, full range -> body / range = 0.
assert_eq!(d.update(c(10.0, 11.0, 9.0, 10.0, 0)), Some(1.0));
assert!(d.is_ready());
}
#[test]
fn marubozu_is_not_doji() {
// Big body, no shadows -> body / range = 1.0 > 0.1.
let mut d = Doji::new();
assert_eq!(d.update(c(10.0, 12.0, 10.0, 12.0, 0)), Some(0.0));
}
#[test]
fn zero_range_yields_zero() {
let mut d = Doji::new();
assert_eq!(d.update(c(10.0, 10.0, 10.0, 10.0, 0)), Some(0.0));
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..40)
.map(|i| {
let base = 100.0 + i as f64;
c(base, base + 2.0, base - 2.0, base + 1.0, i)
})
.collect();
let mut a = Doji::new();
let mut b = Doji::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut d = Doji::new();
d.update(c(10.0, 11.0, 9.0, 10.0, 0));
assert!(d.is_ready());
d.reset();
assert!(!d.is_ready());
}
}
@@ -0,0 +1,218 @@
//! Donchian Channel Stop (Turtle).
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Donchian Channel Stop output: the long-side and short-side trailing stops.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct DonchianStopOutput {
/// Long-position stop: the lowest low over the lookback.
pub stop_long: f64,
/// Short-position stop: the highest high over the lookback.
pub stop_short: f64,
}
/// Donchian Channel Stop — the original Turtle-trader exit rule. A long is
/// trailed at the lowest low of the last `period` bars; a short at the highest
/// high. There is no ATR, no multiplier, and no flip-bit — the two levels are
/// always emitted and the caller selects whichever side matches the position.
///
/// ```text
/// stop_long = min(low, over period bars)
/// stop_short = max(high, over period bars)
/// ```
///
/// Richard Dennis' original Turtle System used a 20-bar entry channel and a
/// 10-bar exit channel — feed this indicator the exit window. The first
/// `period` candles are warmup; on the bar that fills the window it begins
/// emitting both stops.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, DonchianStop};
///
/// let mut indicator = DonchianStop::new(10).unwrap();
/// let mut last = None;
/// for i in 0..40 {
/// 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 DonchianStop {
period: usize,
highs: VecDeque<f64>,
lows: VecDeque<f64>,
}
impl DonchianStop {
/// Construct a Donchian Channel Stop with an explicit lookback.
///
/// # Errors
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
highs: VecDeque::with_capacity(period),
lows: VecDeque::with_capacity(period),
})
}
/// The Turtle-system exit window: a `10`-bar lookback.
pub fn classic() -> Self {
Self::new(10).expect("classic Donchian Stop period is valid")
}
/// Configured lookback.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for DonchianStop {
type Input = Candle;
type Output = DonchianStopOutput;
fn update(&mut self, candle: Candle) -> Option<DonchianStopOutput> {
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;
}
let stop_short = self.highs.iter().copied().fold(f64::NEG_INFINITY, f64::max);
let stop_long = self.lows.iter().copied().fold(f64::INFINITY, f64::min);
Some(DonchianStopOutput {
stop_long,
stop_short,
})
}
fn reset(&mut self) {
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 {
"DonchianStop"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn c(high: f64, low: f64, close: f64, ts: i64) -> Candle {
Candle::new(f64::midpoint(high, low), high, low, close, 1.0, ts).unwrap()
}
#[test]
fn rejects_zero_period() {
assert!(DonchianStop::new(0).is_err());
}
#[test]
fn accessors_and_metadata() {
let s = DonchianStop::classic();
assert_eq!(s.period(), 10);
assert_eq!(s.warmup_period(), 10);
assert_eq!(s.name(), "DonchianStop");
}
#[test]
fn first_emission_matches_warmup() {
let candles: Vec<Candle> = (0..10)
.map(|i| {
let base = 100.0 + i as f64;
c(base + 1.0, base - 1.0, base, i)
})
.collect();
let mut s = DonchianStop::new(5).unwrap();
let out = s.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());
}
#[test]
fn reference_values_uptrend_window() {
// Highs 0..5 = 1..6; lowest low = 0, highest high = 5.
let candles: Vec<Candle> = (0..5)
.map(|i| {
let base = i as f64 + 0.5;
c(base + 0.5, base - 0.5, base, i)
})
.collect();
let mut s = DonchianStop::new(5).unwrap();
let out = s.batch(&candles);
let v = out[4].expect("ready at index 4");
assert_relative_eq!(v.stop_short, 5.0, epsilon = 1e-12);
assert_relative_eq!(v.stop_long, 0.0, epsilon = 1e-12);
}
#[test]
fn constant_series_holds_both_stops() {
let candles: Vec<Candle> = (0..30).map(|i| c(11.0, 9.0, 10.0, i)).collect();
let mut s = DonchianStop::new(5).unwrap();
for v in s.batch(&candles).into_iter().flatten() {
assert_relative_eq!(v.stop_short, 11.0, epsilon = 1e-12);
assert_relative_eq!(v.stop_long, 9.0, epsilon = 1e-12);
}
}
#[test]
fn reset_clears_state() {
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 s = DonchianStop::classic();
s.batch(&candles);
assert!(s.is_ready());
s.reset();
assert!(!s.is_ready());
assert_eq!(s.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 = DonchianStop::classic();
let mut b = DonchianStop::classic();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,261 @@
//! Double Bollinger Bands (Kathy Lien).
use crate::error::{Error, Result};
use crate::indicators::bollinger::BollingerBands;
use crate::traits::Indicator;
/// Double Bollinger Bands output: two concentric bands at `k_inner` and
/// `k_outer` standard deviations around a shared SMA middle.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct DoubleBollingerOutput {
/// Outer upper band: `middle + k_outer · stddev`.
pub upper_outer: f64,
/// Inner upper band: `middle + k_inner · stddev`.
pub upper_inner: f64,
/// Middle band: SMA over the window.
pub middle: f64,
/// Inner lower band: `middle k_inner · stddev`.
pub lower_inner: f64,
/// Outer lower band: `middle k_outer · stddev`.
pub lower_outer: f64,
}
/// Double Bollinger Bands: two concentric Bollinger envelopes (Kathy Lien).
///
/// ```text
/// middle = SMA(period)
/// sigma = population stddev over the window
/// upper_outer = middle + k_outer · sigma // wide channel (often 2σ)
/// upper_inner = middle + k_inner · sigma // narrow channel (often 1σ)
/// lower_inner = middle k_inner · sigma
/// lower_outer = middle k_outer · sigma
/// ```
///
/// Lien's trading framework partitions price into three zones:
///
/// - **Sell zone:** close below `lower_inner`.
/// - **Neutral zone:** close between `lower_inner` and `upper_inner`.
/// - **Buy zone:** close above `upper_inner`.
///
/// A close beyond the outer band marks an extended move that traders typically
/// fade or trail. The constructor enforces `k_outer > k_inner` so the outputs
/// remain monotonically ordered.
///
/// # Example
///
/// ```
/// use wickra_core::{DoubleBollinger, Indicator};
///
/// let mut indicator = DoubleBollinger::new(20, 1.0, 2.0).unwrap();
/// let mut last = None;
/// for i in 0..40 {
/// last = indicator.update(100.0 + (f64::from(i) * 0.3).sin() * 6.0);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct DoubleBollinger {
inner: BollingerBands,
k_inner: f64,
k_outer: f64,
}
impl DoubleBollinger {
/// Construct a new Double Bollinger Bands indicator.
///
/// # Errors
/// Returns [`Error::PeriodZero`] if `period == 0`,
/// [`Error::NonPositiveMultiplier`] if either `k_inner` or `k_outer` is
/// non-positive or non-finite, and [`Error::InvalidPeriod`] if
/// `k_outer <= k_inner` (the outer band must strictly enclose the inner
/// band so the zone-partitioning interpretation holds).
pub fn new(period: usize, k_inner: f64, k_outer: f64) -> Result<Self> {
if !k_inner.is_finite() || k_inner <= 0.0 || !k_outer.is_finite() || k_outer <= 0.0 {
return Err(Error::NonPositiveMultiplier);
}
if k_outer <= k_inner {
return Err(Error::InvalidPeriod {
message: "double bollinger requires k_outer > k_inner",
});
}
// Build the inner state on the outer multiplier so the upper/lower
// outputs of `BollingerBands::update` already give us the outer band;
// the inner band is reconstructed from the same `stddev`.
Ok(Self {
inner: BollingerBands::new(period, k_outer)?,
k_inner,
k_outer,
})
}
/// Kathy Lien's classic configuration: SMA(20) with `±1σ` and `±2σ` bands.
pub fn classic() -> Self {
Self::new(20, 1.0, 2.0).expect("classic Double Bollinger parameters are valid")
}
/// Configured `(period, k_inner, k_outer)`.
pub const fn parameters(&self) -> (usize, f64, f64) {
(self.inner.period(), self.k_inner, self.k_outer)
}
}
impl Indicator for DoubleBollinger {
type Input = f64;
type Output = DoubleBollingerOutput;
fn update(&mut self, value: f64) -> Option<DoubleBollingerOutput> {
let o = self.inner.update(value)?;
Some(DoubleBollingerOutput {
upper_outer: o.upper,
upper_inner: o.middle + self.k_inner * o.stddev,
middle: o.middle,
lower_inner: o.middle - self.k_inner * o.stddev,
lower_outer: o.lower,
})
}
fn reset(&mut self) {
self.inner.reset();
}
fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
fn is_ready(&self) -> bool {
self.inner.is_ready()
}
fn name(&self) -> &'static str {
"DoubleBollinger"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_zero_period() {
assert!(matches!(
DoubleBollinger::new(0, 1.0, 2.0),
Err(Error::PeriodZero)
));
}
#[test]
fn rejects_non_positive_multiplier() {
assert!(matches!(
DoubleBollinger::new(20, 0.0, 2.0),
Err(Error::NonPositiveMultiplier)
));
assert!(matches!(
DoubleBollinger::new(20, 1.0, -2.0),
Err(Error::NonPositiveMultiplier)
));
assert!(matches!(
DoubleBollinger::new(20, f64::NAN, 2.0),
Err(Error::NonPositiveMultiplier)
));
}
#[test]
fn rejects_outer_not_greater_than_inner() {
assert!(matches!(
DoubleBollinger::new(20, 2.0, 1.0),
Err(Error::InvalidPeriod { .. })
));
assert!(matches!(
DoubleBollinger::new(20, 2.0, 2.0),
Err(Error::InvalidPeriod { .. })
));
}
#[test]
fn accessors_and_metadata() {
let db = DoubleBollinger::classic();
let (p, ki, ko) = db.parameters();
assert_eq!(p, 20);
assert_relative_eq!(ki, 1.0, epsilon = 1e-12);
assert_relative_eq!(ko, 2.0, epsilon = 1e-12);
assert_eq!(db.warmup_period(), 20);
assert_eq!(db.name(), "DoubleBollinger");
}
#[test]
fn constant_series_collapses_all_bands() {
let mut db = DoubleBollinger::new(10, 1.0, 2.0).unwrap();
let last = db
.batch(&[5.0_f64; 20])
.into_iter()
.flatten()
.last()
.unwrap();
assert_relative_eq!(last.middle, 5.0, epsilon = 1e-12);
assert_relative_eq!(last.upper_outer, 5.0, epsilon = 1e-12);
assert_relative_eq!(last.upper_inner, 5.0, epsilon = 1e-12);
assert_relative_eq!(last.lower_inner, 5.0, epsilon = 1e-12);
assert_relative_eq!(last.lower_outer, 5.0, epsilon = 1e-12);
}
#[test]
fn bands_strictly_ordered_with_dispersion() {
let prices: Vec<f64> = (0..80)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 6.0)
.collect();
let mut db = DoubleBollinger::classic();
for o in db.batch(&prices).into_iter().flatten() {
assert!(o.upper_outer >= o.upper_inner);
assert!(o.upper_inner >= o.middle);
assert!(o.middle >= o.lower_inner);
assert!(o.lower_inner >= o.lower_outer);
}
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (0..50).map(|i| f64::from(i) * 0.7).collect();
let mut a = DoubleBollinger::new(10, 1.0, 2.0).unwrap();
let mut b = DoubleBollinger::new(10, 1.0, 2.0).unwrap();
assert_eq!(
a.batch(&prices),
prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut db = DoubleBollinger::new(5, 1.0, 2.0).unwrap();
db.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
assert!(db.is_ready());
db.reset();
assert!(!db.is_ready());
assert_eq!(db.update(1.0), None);
}
/// The inner band must agree with running a separate `BollingerBands` at
/// the inner multiplier.
#[test]
fn inner_band_matches_separate_bollinger() {
let prices: Vec<f64> = (0..80)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 6.0)
.collect();
let mut db = DoubleBollinger::new(20, 1.0, 2.0).unwrap();
let mut bb_inner = BollingerBands::new(20, 1.0).unwrap();
let mut bb_outer = BollingerBands::new(20, 2.0).unwrap();
for p in &prices {
let d = db.update(*p);
let i = bb_inner.update(*p);
let o = bb_outer.update(*p);
if let (Some(d), Some(i), Some(o)) = (d, i, o) {
assert_relative_eq!(d.middle, i.middle, epsilon = 1e-9);
assert_relative_eq!(d.upper_inner, i.upper, epsilon = 1e-9);
assert_relative_eq!(d.lower_inner, i.lower, epsilon = 1e-9);
assert_relative_eq!(d.upper_outer, o.upper, epsilon = 1e-9);
assert_relative_eq!(d.lower_outer, o.lower, epsilon = 1e-9);
}
}
}
}
@@ -0,0 +1,174 @@
//! Drawdown Duration — bars since the last all-time peak ("time under water").
use crate::traits::Indicator;
/// Cumulative drawdown duration in bars.
///
/// Each `update` receives one equity-curve sample. The indicator tracks the
/// **running all-time peak** seen since construction (or last `reset`) and
/// reports how many bars have elapsed since that peak was set:
///
/// ```text
/// peak_t = max(input over [0..=t])
/// duration_t = bars elapsed since peak_t was first set
/// ```
///
/// A new peak resets the duration to `0`. As long as the series stays under
/// water the duration grows linearly with each bar.
///
/// The indicator emits a value on every bar (no warmup beyond the first
/// input) and runs in O(1) per `update`.
///
/// # Example
///
/// ```
/// use wickra_core::{DrawdownDuration, Indicator};
///
/// let mut dd = DrawdownDuration::new();
/// assert_eq!(dd.update(100.0), Some(0)); // first bar -> new peak
/// assert_eq!(dd.update(95.0), Some(1)); // 1 bar under water
/// assert_eq!(dd.update(90.0), Some(2)); // 2 bars under water
/// assert_eq!(dd.update(110.0), Some(0)); // new peak -> reset
/// ```
#[derive(Debug, Clone, Default)]
pub struct DrawdownDuration {
peak: f64,
bars_under_water: u32,
seen: bool,
}
impl DrawdownDuration {
/// Construct a new Drawdown Duration tracker.
pub const fn new() -> Self {
Self {
peak: f64::NEG_INFINITY,
bars_under_water: 0,
seen: false,
}
}
/// Bars elapsed since the running all-time peak was set.
pub const fn value(&self) -> Option<u32> {
if self.seen {
Some(self.bars_under_water)
} else {
None
}
}
}
impl Indicator for DrawdownDuration {
type Input = f64;
type Output = u32;
fn update(&mut self, input: f64) -> Option<u32> {
if !input.is_finite() {
return self.value();
}
if !self.seen || input >= self.peak {
self.peak = input;
self.bars_under_water = 0;
} else {
self.bars_under_water = self.bars_under_water.saturating_add(1);
}
self.seen = true;
Some(self.bars_under_water)
}
fn reset(&mut self) {
self.peak = f64::NEG_INFINITY;
self.bars_under_water = 0;
self.seen = false;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.seen
}
fn name(&self) -> &'static str {
"DrawdownDuration"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
#[test]
fn accessors_and_metadata() {
let mut d = DrawdownDuration::new();
assert_eq!(d.name(), "DrawdownDuration");
assert_eq!(d.warmup_period(), 1);
assert_eq!(d.value(), None);
d.update(100.0);
assert_eq!(d.value(), Some(0));
}
#[test]
fn first_bar_is_peak() {
let mut d = DrawdownDuration::new();
assert_eq!(d.update(100.0), Some(0));
}
#[test]
fn under_water_counter_increments() {
let mut d = DrawdownDuration::new();
d.update(100.0);
assert_eq!(d.update(90.0), Some(1));
assert_eq!(d.update(80.0), Some(2));
assert_eq!(d.update(85.0), Some(3));
}
#[test]
fn new_peak_resets_counter() {
let mut d = DrawdownDuration::new();
d.update(100.0);
d.update(90.0);
d.update(80.0);
assert_eq!(d.update(105.0), Some(0));
assert_eq!(d.update(95.0), Some(1));
}
#[test]
fn equal_value_is_treated_as_peak() {
let mut d = DrawdownDuration::new();
d.update(100.0);
assert_eq!(d.update(100.0), Some(0));
}
#[test]
fn ignores_non_finite_input() {
let mut d = DrawdownDuration::new();
d.update(100.0);
d.update(90.0);
let v = d.value();
assert_eq!(d.update(f64::NAN), v);
assert_eq!(d.update(f64::INFINITY), v);
}
#[test]
fn reset_clears_state() {
let mut d = DrawdownDuration::new();
d.batch(&[100.0, 90.0, 80.0]);
assert!(d.is_ready());
d.reset();
assert!(!d.is_ready());
assert_eq!(d.update(100.0), Some(0));
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (0..30)
.map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 5.0)
.collect();
let batch = DrawdownDuration::new().batch(&prices);
let mut s = DrawdownDuration::new();
let streamed: Vec<_> = prices.iter().map(|p| s.update(*p)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,224 @@
//! Ehlers Stochastic — Stochastic computed on a Roofing-Filter pre-filtered input.
#![allow(clippy::doc_markdown)]
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::indicators::roofing_filter::RoofingFilter;
use crate::traits::Indicator;
/// Ehlers' Adaptive Stochastic.
///
/// Implements the construction described in *Cycle Analytics for Traders*
/// (Ehlers 2013, ch. 7): the raw price is first passed through a
/// [`RoofingFilter`] (high-pass + SuperSmoother bandpass) to isolate the
/// tradable cycle band, then the classic Stochastic %K formula is applied
/// to the filtered output over `period` bars and finally re-smoothed by a
/// 2-bar SuperSmoother. The result is a ±1-normalised oscillator that
/// reacts to cycles without trending bias from low-frequency drift.
///
/// The output uses Ehlers' `2 * (X - MinX) / (MaxX - MinX) - 1` convention,
/// so the range is `[-1, +1]` rather than the conventional `[0, 100]`.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, EhlersStochastic};
///
/// let mut es = EhlersStochastic::new(20).unwrap();
/// let mut last = None;
/// for i in 0..120 {
/// last = es.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct EhlersStochastic {
period: usize,
roofing: RoofingFilter,
filtered_buf: VecDeque<f64>,
// Tiny 2-tap IIR (Ehlers uses a simple SMA(2) for the final smoothing).
prev_stoch: f64,
has_prev: bool,
last_value: Option<f64>,
}
impl EhlersStochastic {
/// Construct with the rolling window length used by the inner stochastic.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
// Defaults match Ehlers' (10, 48) roofing filter cutoffs.
roofing: RoofingFilter::new(10, 48)?,
filtered_buf: VecDeque::with_capacity(period),
prev_stoch: 0.0,
has_prev: false,
last_value: None,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.last_value
}
}
impl Indicator for EhlersStochastic {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() {
return self.last_value;
}
let filtered = self.roofing.update(input)?;
if self.filtered_buf.len() == self.period {
self.filtered_buf.pop_front();
}
self.filtered_buf.push_back(filtered);
if self.filtered_buf.len() < self.period {
return None;
}
let max = self
.filtered_buf
.iter()
.copied()
.fold(f64::NEG_INFINITY, f64::max);
let min = self
.filtered_buf
.iter()
.copied()
.fold(f64::INFINITY, f64::min);
let range = max - min;
let raw = if range > 0.0 {
((filtered - min) / range).mul_add(2.0, -1.0)
} else {
0.0
};
// 2-bar SMA smoothing.
let smoothed = if self.has_prev {
0.5 * (raw + self.prev_stoch)
} else {
raw
};
self.prev_stoch = raw;
self.has_prev = true;
self.last_value = Some(smoothed);
Some(smoothed)
}
fn reset(&mut self) {
self.roofing.reset();
self.filtered_buf.clear();
self.prev_stoch = 0.0;
self.has_prev = false;
self.last_value = None;
}
fn warmup_period(&self) -> usize {
self.period + self.roofing.warmup_period()
}
fn is_ready(&self) -> bool {
self.last_value.is_some()
}
fn name(&self) -> &'static str {
"EhlersStochastic"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
#[test]
fn new_rejects_zero_period() {
assert!(matches!(EhlersStochastic::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_and_metadata() {
let mut es = EhlersStochastic::new(20).unwrap();
assert_eq!(es.period(), 20);
assert_eq!(es.warmup_period(), 22);
assert_eq!(es.name(), "EhlersStochastic");
assert!(!es.is_ready());
let prices: Vec<f64> = (0..150)
.map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 5.0)
.collect();
es.batch(&prices);
assert!(es.is_ready());
assert!(es.value().is_some());
}
#[test]
fn output_bounded_in_unit_interval() {
let prices: Vec<f64> = (0..200)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0)
.collect();
let mut es = EhlersStochastic::new(20).unwrap();
for v in es.batch(&prices).into_iter().flatten() {
assert!((-1.0..=1.0).contains(&v), "value out of band: {v}");
}
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (0..150)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0)
.collect();
let mut a = EhlersStochastic::new(20).unwrap();
let mut b = EhlersStochastic::new(20).unwrap();
let batch = a.batch(&prices);
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
assert_eq!(batch, streamed);
}
#[test]
fn ignores_non_finite_input() {
let mut es = EhlersStochastic::new(20).unwrap();
let prices: Vec<f64> = (0..150)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0)
.collect();
es.batch(&prices);
let before = es.value();
assert!(before.is_some());
assert_eq!(es.update(f64::NAN), before);
}
#[test]
fn reset_clears_state() {
let mut es = EhlersStochastic::new(20).unwrap();
let prices: Vec<f64> = (0..150)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0)
.collect();
es.batch(&prices);
assert!(es.is_ready());
es.reset();
assert!(!es.is_ready());
}
#[test]
fn flat_window_emits_zero() {
// A constant series has zero high-pass output, so `max == min` and the
// `range > 0.0` guard takes the `0.0` fallback rather than dividing.
let mut es = EhlersStochastic::new(20).unwrap();
for v in es.batch(&[100.0_f64; 150]).into_iter().flatten() {
assert_eq!(v, 0.0);
}
}
}
@@ -0,0 +1,243 @@
//! Elder Impulse System.
use crate::error::{Error, Result};
use crate::indicators::ema::Ema;
use crate::indicators::macd::MacdIndicator;
use crate::traits::Indicator;
/// Alexander Elder's Impulse System — a tri-state momentum gauge combining the
/// slope of an `EMA` trend filter with the slope of the `MACD` histogram.
///
/// On each bar Wickra reports:
///
/// - `+1` ("green / buy") when both the `EMA` trend and the `MACD` histogram
/// are rising bar-over-bar.
/// - `1` ("red / sell") when both are falling.
/// - `0` ("blue / neutral") when the two disagree.
///
/// The defaults track Elder's *Come Into My Trading Room* parameterisation:
/// `EMA(13)` for the trend, `MACD(12, 26, 9)` for the histogram.
///
/// # Example
///
/// ```
/// use wickra_core::{ElderImpulse, Indicator};
///
/// let mut elder = ElderImpulse::classic();
/// let mut last = None;
/// for i in 0..120 {
/// last = elder.update(100.0 + f64::from(i));
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct ElderImpulse {
ema_period: usize,
macd_fast: usize,
macd_slow: usize,
macd_signal: usize,
ema: Ema,
macd: MacdIndicator,
prev_ema: Option<f64>,
prev_hist: Option<f64>,
current: Option<f64>,
}
impl ElderImpulse {
/// # Errors
/// Forwarded from [`Ema::new`] / [`MacdIndicator::new`].
pub fn new(
ema_period: usize,
macd_fast: usize,
macd_slow: usize,
macd_signal: usize,
) -> Result<Self> {
if ema_period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
ema_period,
macd_fast,
macd_slow,
macd_signal,
ema: Ema::new(ema_period)?,
macd: MacdIndicator::new(macd_fast, macd_slow, macd_signal)?,
prev_ema: None,
prev_hist: None,
current: None,
})
}
/// Elder's recommended defaults `(ema_period = 13, macd = 12/26/9)`.
pub fn classic() -> Self {
Self::new(13, 12, 26, 9).expect("classic Elder Impulse parameters are valid")
}
/// Configured `(ema_period, macd_fast, macd_slow, macd_signal)`.
pub const fn periods(&self) -> (usize, usize, usize, usize) {
(
self.ema_period,
self.macd_fast,
self.macd_slow,
self.macd_signal,
)
}
}
impl Indicator for ElderImpulse {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
// Feed both branches on every input so they warm in parallel.
let ema_now = self.ema.update(input);
let macd_now = self.macd.update(input);
let (ema_now, macd_now) = (ema_now?, macd_now?);
// The Impulse needs two consecutive readings on both branches to
// judge direction. The first ready bar seeds prev_*; the second emits.
let prev_ema = self.prev_ema;
let prev_hist = self.prev_hist;
self.prev_ema = Some(ema_now);
self.prev_hist = Some(macd_now.histogram);
let prev_ema = prev_ema?;
let prev_hist = prev_hist?;
let ema_rising = ema_now > prev_ema;
let ema_falling = ema_now < prev_ema;
let hist_rising = macd_now.histogram > prev_hist;
let hist_falling = macd_now.histogram < prev_hist;
let value = if ema_rising && hist_rising {
1.0
} else if ema_falling && hist_falling {
-1.0
} else {
0.0
};
self.current = Some(value);
Some(value)
}
fn reset(&mut self) {
self.ema.reset();
self.macd.reset();
self.prev_ema = None;
self.prev_hist = None;
self.current = None;
}
fn warmup_period(&self) -> usize {
// MACD's warmup is slow + signal 1; EMA's is ema_period. The
// slowest branch fires the *first* impulse-ready reading, but
// judging direction needs one *more* bar on top.
let macd_warmup = self.macd_slow + self.macd_signal - 1;
self.ema_period.max(macd_warmup) + 1
}
fn is_ready(&self) -> bool {
self.current.is_some()
}
fn name(&self) -> &'static str {
"ElderImpulse"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
#[test]
fn rejects_zero_period() {
assert!(matches!(
ElderImpulse::new(0, 12, 26, 9),
Err(Error::PeriodZero)
));
assert!(matches!(
ElderImpulse::new(13, 0, 26, 9),
Err(Error::PeriodZero)
));
}
#[test]
fn rejects_invalid_macd_params() {
// MacdIndicator validates fast < slow.
assert!(ElderImpulse::new(13, 26, 12, 9).is_err());
}
#[test]
fn accessors_and_metadata() {
let elder = ElderImpulse::classic();
assert_eq!(elder.periods(), (13, 12, 26, 9));
assert_eq!(elder.name(), "ElderImpulse");
}
#[test]
fn classic_factory() {
assert_eq!(ElderImpulse::classic().periods(), (13, 12, 26, 9));
}
#[test]
fn constant_series_yields_neutral() {
// Both EMA and MACD-histogram are flat on a constant series, so
// neither is rising nor falling -> Impulse = 0.
let mut elder = ElderImpulse::classic();
let out = elder.batch(&[42.0_f64; 120]);
// Take values from the post-warmup region.
for v in out.iter().skip(40).flatten() {
assert_eq!(*v, 0.0);
}
}
#[test]
fn pure_uptrend_signals_buy() {
// Monotonic uptrend: EMA rises every bar; MACD histogram is positive
// and (after the slow EMA catches up) also rising bar-over-bar.
let mut elder = ElderImpulse::classic();
for i in 1..=300 {
elder.update(f64::from(i));
}
// The final reading should be +1 (buy) or 0 — never -1 on a clean
// up trend.
let v = elder.current.unwrap();
assert!(v >= 0.0, "uptrend should not signal sell: {v}");
}
#[test]
fn warmup_emits_first_value_at_warmup_period() {
let mut elder = ElderImpulse::new(3, 2, 4, 3).unwrap();
// MACD warmup: 4 + 3 - 1 = 6; EMA warmup: 3; max = 6; +1 for the
// direction bar = 7.
assert_eq!(elder.warmup_period(), 7);
let prices: Vec<f64> = (1..=10).map(f64::from).collect();
let out = elder.batch(&prices);
for v in out.iter().take(6) {
assert!(v.is_none());
}
assert!(out[6].is_some());
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (1..=200)
.map(|i| 100.0 + (f64::from(i) * 0.2).sin() * 5.0)
.collect();
let mut a = ElderImpulse::classic();
let mut b = ElderImpulse::classic();
assert_eq!(
a.batch(&prices),
prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut elder = ElderImpulse::classic();
elder.batch(&(1..=200).map(f64::from).collect::<Vec<_>>());
assert!(elder.is_ready());
elder.reset();
assert!(!elder.is_ready());
}
}
@@ -0,0 +1,266 @@
//! Ehlers Empirical Mode Decomposition (bandpass + envelope).
use std::collections::VecDeque;
use std::f64::consts::PI;
use crate::error::{Error, Result};
use crate::indicators::super_smoother::SuperSmoother;
use crate::traits::Indicator;
/// Ehlers' adaptation of Empirical Mode Decomposition (EMD).
///
/// Implementation per *Cycle Analytics for Traders* (Ehlers 2013, ch. 14).
/// The procedure is:
///
/// 1. Apply a bandpass filter centred on `period` to the price.
/// 2. Detect peaks and valleys of the bandpassed signal over a `fraction`
/// of the period.
/// 3. Average the peaks and valleys separately to form an upper / lower
/// envelope, then return the centred bandpass minus the envelope mean
/// (the "EMD" line).
///
/// The output crosses zero at trend changes and stays near zero in
/// non-trending markets — the classic visual cue Ehlers documents.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, EmpiricalModeDecomposition};
///
/// let mut emd = EmpiricalModeDecomposition::new(20, 0.5).unwrap();
/// let mut last = None;
/// for i in 0..200 {
/// last = emd.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct EmpiricalModeDecomposition {
period: usize,
fraction: f64,
bandpass: f64,
prev_bp_1: f64,
prev_bp_2: f64,
prev_in_1: Option<f64>,
prev_in_2: Option<f64>,
beta: f64,
alpha: f64,
smoother: SuperSmoother,
peak_smoother: SuperSmoother,
valley_smoother: SuperSmoother,
bp_buf: VecDeque<f64>,
bp_history_len: usize,
last_value: Option<f64>,
}
impl EmpiricalModeDecomposition {
/// Construct with the bandpass centre period and the peak-detection
/// window fraction.
///
/// `fraction` is multiplied by `period` to size the rolling peak/valley
/// window; Ehlers recommends `0.5`. Both must be positive.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `period == 0`, and
/// [`Error::InvalidPeriod`] if `fraction <= 0` or non-finite.
pub fn new(period: usize, fraction: f64) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
if !fraction.is_finite() || fraction <= 0.0 || fraction > 1.0 {
return Err(Error::InvalidPeriod {
message: "fraction must be in (0, 1]",
});
}
let beta = (2.0 * PI / period as f64).cos();
let gamma = 1.0 / (2.0 * PI * 0.25 / period as f64).cos();
let alpha = gamma - (gamma * gamma - 1.0).sqrt();
let history = (period as f64 * fraction).round().max(1.0) as usize;
Ok(Self {
period,
fraction,
bandpass: 0.0,
prev_bp_1: 0.0,
prev_bp_2: 0.0,
prev_in_1: None,
prev_in_2: None,
beta,
alpha,
smoother: SuperSmoother::new(period.max(2))?,
peak_smoother: SuperSmoother::new(period.max(2))?,
valley_smoother: SuperSmoother::new(period.max(2))?,
bp_buf: VecDeque::with_capacity(history),
bp_history_len: history,
last_value: None,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
/// Configured fraction.
pub const fn fraction(&self) -> f64 {
self.fraction
}
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.last_value
}
}
impl Indicator for EmpiricalModeDecomposition {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() {
return self.last_value;
}
// 2nd-order resonant bandpass per Ehlers ch. 6.
let bp = if let (Some(_x1), Some(x2)) = (self.prev_in_1, self.prev_in_2) {
0.5 * (1.0 - self.alpha) * (input - x2)
+ self.beta * (1.0 + self.alpha) * self.prev_bp_1
- self.alpha * self.prev_bp_2
} else {
0.0
};
self.prev_bp_2 = self.prev_bp_1;
self.prev_bp_1 = bp;
self.bandpass = bp;
self.prev_in_2 = self.prev_in_1;
self.prev_in_1 = Some(input);
if self.bp_buf.len() == self.bp_history_len {
self.bp_buf.pop_front();
}
self.bp_buf.push_back(bp);
if self.bp_buf.len() < self.bp_history_len {
return None;
}
// Identify the current peak (largest), valley (smallest) within the window.
let peak = self
.bp_buf
.iter()
.copied()
.fold(f64::NEG_INFINITY, f64::max);
let valley = self.bp_buf.iter().copied().fold(f64::INFINITY, f64::min);
let avg_peak = self.peak_smoother.update(peak)?;
let avg_valley = self.valley_smoother.update(valley)?;
// The EMD line is the bandpass minus the smoothed mean envelope.
let mean = 0.5 * (avg_peak + avg_valley);
let raw = bp - mean;
let v = self.smoother.update(raw)?;
self.last_value = Some(v);
Some(v)
}
fn reset(&mut self) {
self.bandpass = 0.0;
self.prev_bp_1 = 0.0;
self.prev_bp_2 = 0.0;
self.prev_in_1 = None;
self.prev_in_2 = None;
self.smoother.reset();
self.peak_smoother.reset();
self.valley_smoother.reset();
self.bp_buf.clear();
self.last_value = None;
}
fn warmup_period(&self) -> usize {
self.bp_history_len
}
fn is_ready(&self) -> bool {
self.last_value.is_some()
}
fn name(&self) -> &'static str {
"EmpiricalModeDecomposition"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
#[test]
fn new_rejects_invalid_params() {
assert!(matches!(
EmpiricalModeDecomposition::new(0, 0.5),
Err(Error::PeriodZero)
));
assert!(matches!(
EmpiricalModeDecomposition::new(20, 0.0),
Err(Error::InvalidPeriod { .. })
));
assert!(matches!(
EmpiricalModeDecomposition::new(20, 1.5),
Err(Error::InvalidPeriod { .. })
));
assert!(matches!(
EmpiricalModeDecomposition::new(20, f64::NAN),
Err(Error::InvalidPeriod { .. })
));
}
#[test]
fn accessors_and_metadata() {
let mut emd = EmpiricalModeDecomposition::new(20, 0.5).unwrap();
assert_eq!(emd.period(), 20);
assert!((emd.fraction() - 0.5).abs() < 1e-15);
assert_eq!(emd.name(), "EmpiricalModeDecomposition");
assert!(emd.warmup_period() >= 1);
assert!(!emd.is_ready());
let prices: Vec<f64> = (0..200)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0)
.collect();
emd.batch(&prices);
assert!(emd.is_ready());
assert!(emd.value().is_some());
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (0..200)
.map(|i| 100.0 + (f64::from(i) * 0.2).cos() * 5.0)
.collect();
let mut a = EmpiricalModeDecomposition::new(20, 0.5).unwrap();
let mut b = EmpiricalModeDecomposition::new(20, 0.5).unwrap();
let batch = a.batch(&prices);
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
assert_eq!(batch, streamed);
}
#[test]
fn ignores_non_finite_input() {
let mut emd = EmpiricalModeDecomposition::new(20, 0.5).unwrap();
let prices: Vec<f64> = (0..200)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0)
.collect();
emd.batch(&prices);
let before = emd.value();
assert!(before.is_some());
assert_eq!(emd.update(f64::NAN), before);
}
#[test]
fn reset_clears_state() {
let mut emd = EmpiricalModeDecomposition::new(20, 0.5).unwrap();
let prices: Vec<f64> = (0..200)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0)
.collect();
emd.batch(&prices);
assert!(emd.is_ready());
emd.reset();
assert!(!emd.is_ready());
}
}
@@ -0,0 +1,186 @@
//! Bullish / Bearish Engulfing candlestick pattern.
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Engulfing — a 2-bar reversal pattern. The current candle's body fully
/// engulfs the prior candle's body and points in the opposite direction.
///
/// ```text
/// prev_body = |prev.close prev.open|
/// curr_body = |curr.close curr.open|
/// bullish = prev red & curr green
/// & curr.open <= prev.close & curr.close >= prev.open
/// & curr_body > prev_body
/// bearish = prev green & curr red
/// & curr.open >= prev.close & curr.close <= prev.open
/// & curr_body > prev_body
/// ```
///
/// Output is `+1.0` for a bullish engulfing, `1.0` for a bearish one, and
/// `0.0` otherwise. The first bar always returns `0.0` because no previous
/// body exists to engulf. Pattern-shape check only — no trend filter is
/// applied; combine with a trend indicator for actionable signals.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Engulfing, Indicator};
///
/// let mut indicator = Engulfing::new();
/// // Prior red candle followed by a larger green engulfing candle.
/// indicator.update(Candle::new(11.0, 11.2, 9.8, 10.0, 1.0, 0).unwrap());
/// let out = indicator
/// .update(Candle::new(9.5, 12.0, 9.5, 11.5, 1.0, 1).unwrap());
/// assert_eq!(out, Some(1.0));
/// ```
#[derive(Debug, Clone, Default)]
pub struct Engulfing {
prev: Option<Candle>,
has_emitted: bool,
}
impl Engulfing {
/// Construct a new Engulfing detector.
pub const fn new() -> Self {
Self {
prev: None,
has_emitted: false,
}
}
}
impl Indicator for Engulfing {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
self.has_emitted = true;
let prev = self.prev;
self.prev = Some(candle);
let Some(p) = prev else {
return Some(0.0);
};
let prev_body = (p.close - p.open).abs();
let curr_body = (candle.close - candle.open).abs();
if prev_body <= 0.0 || curr_body <= prev_body {
return Some(0.0);
}
let prev_red = p.close < p.open;
let prev_green = p.close > p.open;
let curr_green = candle.close > candle.open;
let curr_red = candle.close < candle.open;
if prev_red && curr_green && candle.open <= p.close && candle.close >= p.open {
Some(1.0)
} else if prev_green && curr_red && candle.open >= p.close && candle.close <= p.open {
Some(-1.0)
} else {
Some(0.0)
}
}
fn reset(&mut self) {
self.prev = None;
self.has_emitted = false;
}
fn warmup_period(&self) -> usize {
2
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"Engulfing"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle {
Candle::new(open, high, low, close, 1.0, ts).unwrap()
}
#[test]
fn accessors_and_metadata() {
let e = Engulfing::new();
assert_eq!(e.name(), "Engulfing");
assert_eq!(e.warmup_period(), 2);
assert!(!e.is_ready());
}
#[test]
fn bullish_engulfing_is_plus_one() {
let mut e = Engulfing::new();
// Prior red 11 -> 10, current green 9.5 -> 11.5 (body 2 > 1).
assert_eq!(e.update(c(11.0, 11.2, 9.8, 10.0, 0)), Some(0.0));
assert_eq!(e.update(c(9.5, 12.0, 9.5, 11.5, 1)), Some(1.0));
}
#[test]
fn bearish_engulfing_is_minus_one() {
let mut e = Engulfing::new();
// Prior green 10 -> 11, current red 12 -> 9.
assert_eq!(e.update(c(10.0, 11.2, 9.8, 11.0, 0)), Some(0.0));
assert_eq!(e.update(c(12.0, 12.0, 9.0, 9.0, 1)), Some(-1.0));
}
#[test]
fn same_direction_is_not_engulfing() {
let mut e = Engulfing::new();
e.update(c(10.0, 11.0, 9.8, 11.0, 0));
// Another green candle that engulfs but matches direction -> 0.
assert_eq!(e.update(c(9.5, 12.0, 9.5, 11.5, 1)), Some(0.0));
}
#[test]
fn smaller_body_is_not_engulfing() {
let mut e = Engulfing::new();
e.update(c(11.0, 11.2, 8.0, 8.5, 0));
// Body 0.5 < 2.5 -> not engulfing.
assert_eq!(e.update(c(8.6, 9.0, 8.4, 8.7, 1)), Some(0.0));
}
#[test]
fn first_bar_returns_zero() {
let mut e = Engulfing::new();
assert_eq!(e.update(c(10.0, 11.0, 9.0, 11.0, 0)), Some(0.0));
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..40)
.map(|i| {
let base = 100.0 + i as f64;
if i % 3 == 0 {
c(base + 1.0, base + 1.5, base - 0.5, base, i)
} else {
c(base - 1.0, base + 2.0, base - 1.5, base + 2.0, i)
}
})
.collect();
let mut a = Engulfing::new();
let mut b = Engulfing::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut e = Engulfing::new();
e.update(c(10.0, 11.0, 9.0, 11.0, 0));
e.update(c(11.0, 12.0, 10.0, 12.0, 1));
assert!(e.is_ready());
e.reset();
assert!(!e.is_ready());
// After reset the next bar again has no prev.
assert_eq!(e.update(c(11.0, 11.2, 9.8, 10.0, 0)), Some(0.0));
}
}
+238
View File
@@ -0,0 +1,238 @@
//! Elastic Volume-Weighted Moving Average (EVWMA).
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Christian P. Fries' Elastic Volume-Weighted Moving Average.
///
/// Unlike `VWMA` which is a per-bar weighted mean, `EVWMA` runs an
/// "elastic" recurrence whose smoothing weight is the bar's volume relative
/// to the running window-volume:
///
/// ```text
/// V_sum_t = Σ volume_i over the last `period` candles
/// EVWMA_t = ((V_sum_t - volume_t) * EVWMA_{t-1} + volume_t * close_t) / V_sum_t
/// ```
///
/// A bar whose volume is small compared to the window total barely moves the
/// average; a bar whose volume dominates the window pulls it strongly toward
/// the bar's close. The series is seeded with the close of the first candle
/// after the volume window has filled (i.e. after `period` candles).
///
/// If `V_sum_t == 0` (every candle in the window has zero volume), the
/// recurrence is undefined; the indicator holds its previous value.
///
/// Reference: Christian P. Fries, *Wilmott Magazine*, 2001.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Evwma, Indicator};
///
/// let mut evwma = Evwma::new(20).unwrap();
/// let mut last = None;
/// for i in 0..40 {
/// let p = 100.0 + f64::from(i);
/// let candle = Candle::new(p, p + 1.0, p - 1.0, p, 10.0, i64::from(i)).unwrap();
/// last = evwma.update(candle);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct Evwma {
period: usize,
/// Rolling window of `(close, volume)` pairs, oldest at the front.
window: VecDeque<(f64, f64)>,
sum_v: f64,
current: Option<f64>,
}
impl Evwma {
/// # 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_v: 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 Evwma {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
let close = candle.close;
let volume = candle.volume;
if self.window.len() == self.period {
let (_, old_v) = self.window.pop_front().expect("window is non-empty");
self.sum_v -= old_v;
}
self.window.push_back((close, volume));
self.sum_v += volume;
if self.window.len() < self.period {
return None;
}
// The volume sum may be zero (every bar in the window had zero
// volume); the recurrence is undefined, so seed/hold instead.
if self.sum_v <= 0.0 {
if self.current.is_none() {
self.current = Some(close);
}
return self.current;
}
let prev = self.current.unwrap_or(close);
let next = ((self.sum_v - volume) * prev + volume * close) / self.sum_v;
self.current = Some(next);
Some(next)
}
fn reset(&mut self) {
self.window.clear();
self.sum_v = 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 {
"EVWMA"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn candle(close: f64, volume: f64, ts: i64) -> Candle {
Candle::new(close, close, close, close, volume, ts).unwrap()
}
#[test]
fn rejects_zero_period() {
assert!(matches!(Evwma::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_and_metadata() {
let mut e = Evwma::new(5).unwrap();
assert_eq!(e.period(), 5);
assert_eq!(e.warmup_period(), 5);
assert_eq!(e.name(), "EVWMA");
assert_eq!(e.value(), None);
for i in 0..5 {
e.update(candle(10.0, 1.0, i));
}
assert!(e.value().is_some());
}
#[test]
fn constant_series_yields_the_constant() {
// A flat close — every (V_sum - v) * prev + v * close reduces to
// V_sum * close, so the recurrence preserves the constant after the
// first seeded sample.
let mut e = Evwma::new(5).unwrap();
let candles: Vec<Candle> = (0..30).map(|i| candle(42.0, 3.0, i)).collect();
let out = e.batch(&candles);
for v in out.iter().skip(4).flatten() {
assert_relative_eq!(*v, 42.0, epsilon = 1e-12);
}
}
#[test]
fn reference_value_period_2() {
// EVWMA(2). Bars: (close, volume) = (10, 1), (20, 3), (30, 1).
// Bar 1: window not full (size 1) -> None.
// Bar 2: window full, sum_v = 4, prev seeds to 20.
// EVWMA = ((4 - 3) * 20 + 3 * 20) / 4 = 80 / 4 = 20.
// Bar 3: window slides, sum_v = 4 (drops the 1, gains the 1).
// EVWMA = ((4 - 1) * 20 + 1 * 30) / 4 = (60 + 30) / 4 = 22.5.
let mut e = Evwma::new(2).unwrap();
assert_eq!(e.update(candle(10.0, 1.0, 0)), None);
assert_relative_eq!(
e.update(candle(20.0, 3.0, 1)).unwrap(),
20.0,
epsilon = 1e-12
);
assert_relative_eq!(
e.update(candle(30.0, 1.0, 2)).unwrap(),
22.5,
epsilon = 1e-12
);
}
#[test]
fn warmup_emits_first_value_at_period() {
let mut e = Evwma::new(4).unwrap();
for i in 0..3 {
assert_eq!(e.update(candle(10.0, 1.0, i)), None);
}
assert!(e.update(candle(10.0, 1.0, 3)).is_some());
}
#[test]
fn zero_volume_window_holds_value() {
// Every bar has zero volume: no participation, so the recurrence
// can't move and EVWMA simply seeds to the first close.
let mut e = Evwma::new(3).unwrap();
e.update(candle(10.0, 0.0, 0));
e.update(candle(15.0, 0.0, 1));
let v = e.update(candle(20.0, 0.0, 2)).unwrap();
assert_relative_eq!(v, 20.0, epsilon = 1e-12);
// Next bar still flat-zero volume: holds 20.
let v2 = e.update(candle(50.0, 0.0, 3)).unwrap();
assert_relative_eq!(v2, 20.0, epsilon = 1e-12);
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..60_i64)
.map(|i| {
let c = 100.0 + (i as f64 * 0.3).sin() * 8.0;
candle(c, 1.0 + (i % 7) as f64, i)
})
.collect();
let batch = Evwma::new(10).unwrap().batch(&candles);
let mut b = Evwma::new(10).unwrap();
let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
assert_eq!(batch, streamed);
}
#[test]
fn reset_clears_state() {
let mut e = Evwma::new(3).unwrap();
let candles: Vec<Candle> = (0..10).map(|i| candle(10.0 + i as f64, 2.0, i)).collect();
e.batch(&candles);
assert!(e.is_ready());
e.reset();
assert!(!e.is_ready());
assert_eq!(e.update(candle(10.0, 1.0, 0)), None);
}
}
+174
View File
@@ -0,0 +1,174 @@
//! Ehlers Following Adaptive Moving Average (FAMA).
use crate::error::Result;
use crate::indicators::mama::Mama;
use crate::traits::Indicator;
/// Scalar wrapper that exposes only the FAMA line from a [`Mama`] indicator.
///
/// FAMA (Following Adaptive Moving Average) is MAMA's lagging companion in
/// Ehlers' MESA construction. It uses half MAMA's adaptive alpha, so it
/// reacts later than MAMA — MAMA crossing above FAMA marks a trend
/// confirmation, MAMA below FAMA a reversal. See [`Mama`] for the joint
/// `(mama, fama)` output; this wrapper exposes the slow line as a plain
/// scalar indicator so it can be chained directly.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, Fama};
///
/// let mut fama = Fama::new(0.5, 0.05).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// last = fama.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct Fama {
inner: Mama,
last_value: Option<f64>,
}
impl Fama {
/// Construct with the same `(fast_limit, slow_limit)` semantics as [`Mama`].
///
/// # Errors
///
/// Forwards [`Mama::new`]'s validation errors.
pub fn new(fast_limit: f64, slow_limit: f64) -> Result<Self> {
Ok(Self {
inner: Mama::new(fast_limit, slow_limit)?,
last_value: None,
})
}
/// Default `(0.5, 0.05)` parameters.
pub fn classic() -> Self {
Self {
inner: Mama::classic(),
last_value: None,
}
}
/// Configured `(fast_limit, slow_limit)`.
pub const fn limits(&self) -> (f64, f64) {
self.inner.limits()
}
/// Current FAMA value if available.
pub const fn value(&self) -> Option<f64> {
self.last_value
}
}
impl Indicator for Fama {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
let v = self.inner.update(input)?.fama;
self.last_value = Some(v);
Some(v)
}
fn reset(&mut self) {
self.inner.reset();
self.last_value = None;
}
fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
fn is_ready(&self) -> bool {
self.last_value.is_some()
}
fn name(&self) -> &'static str {
"FAMA"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::error::Error;
use crate::traits::BatchExt;
#[test]
fn rejects_invalid_limits() {
assert!(matches!(
Fama::new(0.0, 0.05),
Err(Error::InvalidPeriod { .. })
));
assert!(matches!(
Fama::new(0.05, 0.5),
Err(Error::InvalidPeriod { .. })
));
}
#[test]
fn new_with_valid_limits_constructs_via_mama() {
// `classic()` bypasses `new` by going through `Mama::classic`; this
// test exercises the happy-path `Ok(Self { inner: Mama::new(..)? })`
// arm so the `?` doesn't only collapse to the error path.
let mut fama = Fama::new(0.5, 0.05).expect("valid Mama limits");
assert_eq!(fama.limits(), (0.5, 0.05));
for i in 0..60 {
fama.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0);
}
assert!(fama.value().is_some());
}
#[test]
fn accessors_and_metadata() {
let mut fama = Fama::classic();
assert_eq!(fama.limits(), (0.5, 0.05));
assert_eq!(fama.warmup_period(), 33);
assert_eq!(fama.name(), "FAMA");
assert!(!fama.is_ready());
for i in 0..60 {
fama.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0);
}
assert!(fama.is_ready());
assert!(fama.value().is_some());
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (0..120)
.map(|i| 100.0 + (f64::from(i) * 0.25).cos() * 5.0)
.collect();
let mut a = Fama::classic();
let mut b = Fama::classic();
let batch = a.batch(&prices);
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
assert_eq!(batch, streamed);
}
#[test]
fn ignores_non_finite_input() {
let mut fama = Fama::classic();
let prices: Vec<f64> = (0..100)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0)
.collect();
fama.batch(&prices);
let before = fama.value();
assert!(before.is_some());
assert_eq!(fama.update(f64::NAN), before);
}
#[test]
fn reset_clears_state() {
let mut fama = Fama::classic();
let prices: Vec<f64> = (0..100)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0)
.collect();
fama.batch(&prices);
assert!(fama.is_ready());
fama.reset();
assert!(!fama.is_ready());
}
}
@@ -0,0 +1,195 @@
//! Fibonacci Pivot Points.
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Fibonacci Pivot Points output: pivot plus three Fib-spaced resistances and
/// supports.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct FibonacciPivotsOutput {
/// Pivot Point: `(H + L + C) / 3`.
pub pp: f64,
/// Resistance 1: `PP + 0.382·(H L)`.
pub r1: f64,
/// Resistance 2: `PP + 0.618·(H L)`.
pub r2: f64,
/// Resistance 3: `PP + 1.000·(H L)`.
pub r3: f64,
/// Support 1: `PP 0.382·(H L)`.
pub s1: f64,
/// Support 2: `PP 0.618·(H L)`.
pub s2: f64,
/// Support 3: `PP 1.000·(H L)`.
pub s3: f64,
}
/// Fibonacci Pivot Points — the classic pivot plus three resistances and
/// supports spaced by the Fibonacci ratios 0.382 / 0.618 / 1.000 applied to
/// the prior bar's range.
///
/// ```text
/// PP = (H + L + C) / 3
/// R1 = PP + 0.382·(H L) S1 = PP 0.382·(H L)
/// R2 = PP + 0.618·(H L) S2 = PP 0.618·(H L)
/// R3 = PP + 1.000·(H L) S3 = PP 1.000·(H L)
/// ```
///
/// As with [`crate::ClassicPivots`], levels are typically built from the
/// previous session's bar. There are no parameters and no warmup.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, FibonacciPivots, Indicator};
///
/// let prev = Candle::new(100.0, 110.0, 90.0, 105.0, 1.0, 0).unwrap();
/// let levels = FibonacciPivots::new().update(prev).unwrap();
/// assert!(levels.r3 > levels.r2);
/// assert!(levels.r2 > levels.r1);
/// assert!(levels.s1 > levels.s2);
/// ```
#[derive(Debug, Clone, Default)]
pub struct FibonacciPivots {
ready: bool,
}
impl FibonacciPivots {
/// Construct a new Fibonacci Pivot Points indicator.
pub const fn new() -> Self {
Self { ready: false }
}
}
const FIB1: f64 = 0.382;
const FIB2: f64 = 0.618;
const FIB3: f64 = 1.000;
impl Indicator for FibonacciPivots {
type Input = Candle;
type Output = FibonacciPivotsOutput;
fn update(&mut self, candle: Candle) -> Option<FibonacciPivotsOutput> {
let (h, l, c) = (candle.high, candle.low, candle.close);
let pp = (h + l + c) / 3.0;
let range = h - l;
let out = FibonacciPivotsOutput {
pp,
r1: pp + FIB1 * range,
r2: pp + FIB2 * range,
r3: pp + FIB3 * range,
s1: pp - FIB1 * range,
s2: pp - FIB2 * range,
s3: pp - FIB3 * range,
};
self.ready = true;
Some(out)
}
fn reset(&mut self) {
self.ready = false;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.ready
}
fn name(&self) -> &'static str {
"FibonacciPivots"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
fn c(h: f64, l: f64, close: f64, ts: i64) -> Candle {
Candle::new(close, h, l, close, 1.0, ts).unwrap()
}
#[test]
fn formula_reference_values() {
// H=110, L=90, range=20, PP = (110+90+100)/3 = 100.
let levels = FibonacciPivots::new()
.update(c(110.0, 90.0, 100.0, 0))
.unwrap();
assert!((levels.pp - 100.0).abs() < 1e-12);
assert!((levels.r1 - (100.0 + 0.382 * 20.0)).abs() < 1e-12);
assert!((levels.r2 - (100.0 + 0.618 * 20.0)).abs() < 1e-12);
assert!((levels.r3 - (100.0 + 20.0)).abs() < 1e-12);
assert!((levels.s1 - (100.0 - 0.382 * 20.0)).abs() < 1e-12);
assert!((levels.s2 - (100.0 - 0.618 * 20.0)).abs() < 1e-12);
assert!((levels.s3 - (100.0 - 20.0)).abs() < 1e-12);
}
#[test]
fn resistances_strictly_above_pp_supports_strictly_below() {
let levels = FibonacciPivots::new()
.update(c(120.0, 80.0, 110.0, 0))
.unwrap();
assert!(levels.r3 > levels.r2);
assert!(levels.r2 > levels.r1);
assert!(levels.r1 > levels.pp);
assert!(levels.pp > levels.s1);
assert!(levels.s1 > levels.s2);
assert!(levels.s2 > levels.s3);
}
#[test]
fn constant_series_collapses_levels() {
let levels = FibonacciPivots::new()
.update(c(50.0, 50.0, 50.0, 0))
.unwrap();
assert_eq!(levels.pp, 50.0);
assert_eq!(levels.r1, 50.0);
assert_eq!(levels.s3, 50.0);
}
#[test]
fn warmup_and_ready() {
let mut p = FibonacciPivots::new();
assert!(!p.is_ready());
assert_eq!(p.warmup_period(), 1);
p.update(c(11.0, 9.0, 10.0, 0));
assert!(p.is_ready());
}
#[test]
fn reset_clears_state() {
let mut p = FibonacciPivots::new();
p.update(c(11.0, 9.0, 10.0, 0));
p.reset();
assert!(!p.is_ready());
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0_i32..40)
.map(|i| {
c(
f64::from(i) + 2.0,
f64::from(i),
f64::from(i) + 1.0,
i.into(),
)
})
.collect();
let mut a = FibonacciPivots::new();
let mut b = FibonacciPivots::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
#[test]
fn accessors_and_metadata() {
let p = FibonacciPivots::new();
assert_eq!(p.warmup_period(), 1);
assert_eq!(p.name(), "FibonacciPivots");
}
}
@@ -0,0 +1,199 @@
//! Ehlers Fisher Transform.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Ehlers' Fisher Transform of price.
///
/// Normalises the most recent price to `[-1, +1]` via min/max over a `period`
/// window, smooths the normalised value with a 0.33 / 0.67 IIR step, and
/// applies the Fisher transform `0.5 * ln((1+x)/(1-x))`. The result has a
/// near-Gaussian distribution, so extreme readings stand out cleanly. A
/// secondary signal is produced by lagging the Fisher value by one bar (the
/// classic trigger), making the indicator a two-line crossover system in
/// charts.
///
/// Only the primary Fisher value is exposed here as a scalar; the lagged
/// trigger is one update behind by construction.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, FisherTransform};
///
/// let mut ft = FisherTransform::new(10).unwrap();
/// let mut last = None;
/// for i in 0..30 {
/// last = ft.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct FisherTransform {
period: usize,
window: VecDeque<f64>,
smoothed: f64,
last_fisher: Option<f64>,
}
impl FisherTransform {
/// Construct with the rolling extrema window length.
///
/// # 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),
smoothed: 0.0,
last_fisher: None,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
/// Current Fisher value if available.
pub const fn value(&self) -> Option<f64> {
self.last_fisher
}
}
impl Indicator for FisherTransform {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() {
return self.last_fisher;
}
if self.window.len() == self.period {
self.window.pop_front();
}
self.window.push_back(input);
if self.window.len() < self.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;
// Normalise to roughly [-1, +1]; centred midpoint when range == 0.
let raw = if range > 0.0 {
((input - min) / range).mul_add(2.0, -1.0)
} else {
0.0
};
// Ehlers IIR: 0.33 * raw + 0.67 * prev_smoothed, then clamp.
self.smoothed = 0.33f64.mul_add(raw, 0.67 * self.smoothed);
// Clamp strictly inside (-1, +1) to keep the log finite.
let clamped = self.smoothed.clamp(-0.999, 0.999);
let fisher = 0.5 * ((1.0 + clamped) / (1.0 - clamped)).ln();
self.last_fisher = Some(fisher);
Some(fisher)
}
fn reset(&mut self) {
self.window.clear();
self.smoothed = 0.0;
self.last_fisher = None;
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.last_fisher.is_some()
}
fn name(&self) -> &'static str {
"FisherTransform"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
#[test]
fn new_rejects_zero_period() {
assert!(matches!(FisherTransform::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_and_metadata() {
let mut ft = FisherTransform::new(10).unwrap();
assert_eq!(ft.period(), 10);
assert_eq!(ft.warmup_period(), 10);
assert_eq!(ft.name(), "FisherTransform");
assert!(ft.value().is_none());
for i in 1..=10 {
ft.update(f64::from(i));
}
assert!(ft.value().is_some());
assert!(ft.is_ready());
}
#[test]
fn warmup_returns_none_until_seed() {
let mut ft = FisherTransform::new(5).unwrap();
for i in 1..=4 {
assert_eq!(ft.update(f64::from(i)), None);
}
assert!(ft.update(5.0).is_some());
}
#[test]
fn constant_series_zero_range_yields_zero() {
let mut ft = FisherTransform::new(5).unwrap();
let out = ft.batch(&[42.0_f64; 30]);
for x in out.iter().skip(5).flatten() {
assert!(x.abs() < 1e-6, "expected near-zero, got {x}");
}
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (0..60)
.map(|i| 100.0 + (f64::from(i) * 0.2).sin() * 8.0)
.collect();
let mut a = FisherTransform::new(10).unwrap();
let mut b = FisherTransform::new(10).unwrap();
let batch = a.batch(&prices);
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
assert_eq!(batch, streamed);
}
#[test]
fn ignores_non_finite_input() {
let mut ft = FisherTransform::new(5).unwrap();
ft.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
let before = ft.value();
assert!(before.is_some());
assert_eq!(ft.update(f64::NAN), before);
assert_eq!(ft.update(f64::INFINITY), before);
}
#[test]
fn reset_clears_state() {
let mut ft = FisherTransform::new(5).unwrap();
ft.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
assert!(ft.is_ready());
ft.reset();
assert!(!ft.is_ready());
assert_eq!(ft.update(1.0), None);
}
}
@@ -0,0 +1,271 @@
//! Fractal Chaos Bands (Bill Williams Fractals).
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Fractal Chaos Bands output.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct FractalChaosBandsOutput {
/// Upper band: high of the most recent confirmed fractal high.
pub upper: f64,
/// Lower band: low of the most recent confirmed fractal low.
pub lower: f64,
}
/// Fractal Chaos Bands: a step-function envelope of the most recent Bill
/// Williams fractal highs and lows.
///
/// A bar is a **fractal high** when its high is the maximum of the window
/// `[i k, …, i + k]`. A **fractal low** is defined symmetrically on lows.
/// The bands hold the high (low) of the latest confirmed fractal high (low),
/// stepping outwards whenever a new fractal forms and otherwise staying flat:
///
/// ```text
/// confirmation_lag = k // the centre bar is known only k bars later
/// upper = high of the most recent confirmed fractal high
/// lower = low of the most recent confirmed fractal low
/// ```
///
/// `k = 2` (5-bar fractals) is the canonical Williams setting and matches the
/// "Fractal Chaos Bands" oscillator shipped with several chart vendors. With
/// `k` bars of look-ahead, every band update reflects price `k` bars ago —
/// strict streaming preserves this lag rather than peeking into the future.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, FractalChaosBands, Indicator};
///
/// let mut indicator = FractalChaosBands::new(2).unwrap();
/// let mut last = None;
/// for i in 0..30 {
/// let base = 100.0 + (f64::from(i) * 0.5).sin() * 5.0;
/// let candle =
/// Candle::new(base, base + 1.0, base - 1.0, base, 10.0, i64::from(i)).unwrap();
/// last = indicator.update(candle);
/// }
/// // Confirmation requires `2k + 1` bars plus at least one fractal of each
/// // kind, so `last` may legitimately be `None` on a single sweep without
/// // both a peak and a trough in the window.
/// let _ = last;
/// ```
#[derive(Debug, Clone)]
pub struct FractalChaosBands {
k: usize,
window: VecDeque<Candle>,
last_upper: Option<f64>,
last_lower: Option<f64>,
}
impl FractalChaosBands {
/// Construct a new Fractal Chaos Bands indicator with the given fractal
/// half-width `k` (a bar is a fractal high if its high exceeds the highs
/// of the `k` bars on either side; canonical `k = 2`).
///
/// # Errors
/// Returns [`Error::PeriodZero`] if `k == 0` (a single bar is always its
/// own trivial fractal).
pub fn new(k: usize) -> Result<Self> {
if k == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
k,
window: VecDeque::with_capacity(2 * k + 1),
last_upper: None,
last_lower: None,
})
}
/// Canonical Bill Williams configuration: `k = 2` (5-bar fractals).
pub fn classic() -> Self {
Self::new(2).expect("classic Fractal Chaos Bands parameters are valid")
}
/// Configured half-width `k`.
pub const fn k(&self) -> usize {
self.k
}
}
impl Indicator for FractalChaosBands {
type Input = Candle;
type Output = FractalChaosBandsOutput;
fn update(&mut self, candle: Candle) -> Option<FractalChaosBandsOutput> {
let window_len = 2 * self.k + 1;
if self.window.len() == window_len {
self.window.pop_front();
}
self.window.push_back(candle);
if self.window.len() < window_len {
return None;
}
// The centre bar is at index `k`. Strictly compare against the `k`
// bars on either side: `>` for the high and `<` for the low (a ties-
// included pattern would fire on flat tops/bottoms, against Williams'
// intent).
let center = &self.window[self.k];
let mut is_high = true;
let mut is_low = true;
for (i, c) in self.window.iter().enumerate() {
if i == self.k {
continue;
}
if c.high >= center.high {
is_high = false;
}
if c.low <= center.low {
is_low = false;
}
}
if is_high {
self.last_upper = Some(center.high);
}
if is_low {
self.last_lower = Some(center.low);
}
// Both bands must have been seen at least once before we can emit.
match (self.last_upper, self.last_lower) {
(Some(u), Some(l)) => Some(FractalChaosBandsOutput { upper: u, lower: l }),
_ => None,
}
}
fn reset(&mut self) {
self.window.clear();
self.last_upper = None;
self.last_lower = None;
}
fn warmup_period(&self) -> usize {
2 * self.k + 1
}
fn is_ready(&self) -> bool {
self.last_upper.is_some() && self.last_lower.is_some()
}
fn name(&self) -> &'static str {
"FractalChaosBands"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn c(h: f64, l: f64, cl: f64) -> Candle {
Candle::new(cl, h, l, cl, 1.0, 0).unwrap()
}
#[test]
fn rejects_zero_k() {
assert!(matches!(FractalChaosBands::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_and_metadata() {
let f = FractalChaosBands::classic();
assert_eq!(f.k(), 2);
assert_eq!(f.warmup_period(), 5);
assert_eq!(f.name(), "FractalChaosBands");
}
/// Detect a single peak and a single trough with `k = 2`.
/// Bars (high, low, close): (1,1,1), (2,2,2), (5,3,4), (3,1,2),
/// (2,2,2), (1,1,1), (2,2,2), (5,3,4).
/// Indices: 0..7. The peak at i=2 is `>` its 2 neighbours on each side
/// (after index 4 lands). The trough at i=3 is `<` its 2 neighbours on
/// each side (after index 5 lands). Both bands first emit on index 5.
#[test]
fn detects_simple_peak_and_trough() {
let candles = vec![
c(1.0, 1.0, 1.0),
c(2.0, 2.0, 2.0),
c(5.0, 3.0, 4.0), // peak: high 5 is the max of neighbouring 4
c(3.0, 0.5, 1.0), // trough: low 0.5 is the min
c(2.0, 2.0, 2.0),
c(1.0, 1.0, 1.0),
c(2.0, 2.0, 2.0),
];
let mut f = FractalChaosBands::new(2).unwrap();
let out = f.batch(&candles);
// Bars 0..4 are warmup or single-band only — both bands haven't been
// confirmed yet.
for v in out.iter().take(5) {
assert!(v.is_none());
}
// Bar 5 confirms the trough at i=3 (low 0.5); the peak at i=2 was
// confirmed by bar 4 (centre 2, look-ahead 2 → index 4). So index 5
// is the first bar with *both* upper and lower set.
let v = out[5].unwrap();
assert_relative_eq!(v.upper, 5.0, epsilon = 1e-12);
assert_relative_eq!(v.lower, 0.5, epsilon = 1e-12);
}
/// In a flat market no bar is strictly higher (or lower) than its
/// neighbours, so no fractal ever confirms and the indicator never emits.
#[test]
fn flat_market_never_emits() {
let candles: Vec<Candle> = (0..30).map(|_| c(10.0, 10.0, 10.0)).collect();
let mut f = FractalChaosBands::new(2).unwrap();
for v in f.batch(&candles) {
assert!(v.is_none());
}
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..40)
.map(|i| {
let m = 100.0 + (f64::from(i) * 0.5).sin() * 3.0;
c(m + 1.0, m - 1.0, m)
})
.collect();
let mut a = FractalChaosBands::new(2).unwrap();
let mut b = FractalChaosBands::new(2).unwrap();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let candles = vec![
c(1.0, 1.0, 1.0),
c(2.0, 2.0, 2.0),
c(5.0, 3.0, 4.0),
c(3.0, 0.5, 1.0),
c(2.0, 2.0, 2.0),
c(1.0, 1.0, 1.0),
c(2.0, 2.0, 2.0),
];
let mut f = FractalChaosBands::new(2).unwrap();
f.batch(&candles);
assert!(f.is_ready());
f.reset();
assert!(!f.is_ready());
assert_eq!(f.update(candles[0]), None);
}
#[test]
fn upper_above_lower_when_both_set() {
let candles: Vec<Candle> = (0..60)
.map(|i| {
let m = 100.0 + (f64::from(i) * 0.4).sin() * 5.0;
c(m + 1.0, m - 1.0, m)
})
.collect();
let mut f = FractalChaosBands::new(2).unwrap();
for o in f.batch(&candles).into_iter().flatten() {
assert!(o.upper >= o.lower);
}
}
}
+259
View File
@@ -0,0 +1,259 @@
//! Fractal Adaptive Moving Average (FRAMA).
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Ehlers' Fractal Adaptive Moving Average.
///
/// FRAMA picks its smoothing constant from the fractal dimension `D` of the
/// recent window: in a trending (low-`D`) market it follows price tightly, in
/// a choppy (high-`D`) market it smooths heavily. The window of `period`
/// closes is split into two equal halves; the fractal dimension comes from
/// the price ranges of the halves vs. the whole window:
///
/// ```text
/// N1 = (max(first half) - min(first half)) / (period / 2)
/// N2 = (max(second half) - min(second half)) / (period / 2)
/// N3 = (max(window) - min(window)) / period
/// D = (log(N1 + N2) - log(N3)) / log(2)
/// alpha = exp(-4.6 * (D - 1)) clamped to [0.01, 1.0]
/// ```
///
/// The output is an EMA-like recurrence
/// `FRAMA_t = alpha * close_t + (1 - alpha) * FRAMA_{t - 1}`, seeded with the
/// first close. `period` must be even and at least 2.
///
/// Reference: John F. Ehlers, *Fractal Adaptive Moving Average*, 2005.
///
/// # Example
///
/// ```
/// use wickra_core::{Frama, Indicator};
///
/// let mut frama = Frama::new(16).unwrap();
/// let mut last = None;
/// for i in 0..40 {
/// last = frama.update(100.0 + f64::from(i));
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct Frama {
period: usize,
half: usize,
window: VecDeque<f64>,
current: Option<f64>,
}
impl Frama {
/// # Errors
/// - [`Error::PeriodZero`] if `period == 0`.
/// - [`Error::InvalidPeriod`] if `period` is odd or below 2.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
if period < 2 {
return Err(Error::InvalidPeriod {
message: "FRAMA period must be at least 2",
});
}
if period % 2 != 0 {
return Err(Error::InvalidPeriod {
message: "FRAMA period must be even",
});
}
Ok(Self {
period,
half: period / 2,
window: VecDeque::with_capacity(period),
current: None,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for Frama {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() {
return self.current;
}
if self.window.len() == self.period {
self.window.pop_front();
}
self.window.push_back(input);
if self.window.len() < self.period {
return None;
}
let half = self.half;
let mut h_first = f64::NEG_INFINITY;
let mut l_first = f64::INFINITY;
let mut h_second = f64::NEG_INFINITY;
let mut l_second = f64::INFINITY;
let mut h_whole = f64::NEG_INFINITY;
let mut l_whole = f64::INFINITY;
for (i, &p) in self.window.iter().enumerate() {
if p > h_whole {
h_whole = p;
}
if p < l_whole {
l_whole = p;
}
if i < half {
if p > h_first {
h_first = p;
}
if p < l_first {
l_first = p;
}
} else {
if p > h_second {
h_second = p;
}
if p < l_second {
l_second = p;
}
}
}
let half_f = half as f64;
let period_f = self.period as f64;
let n1 = (h_first - l_first) / half_f;
let n2 = (h_second - l_second) / half_f;
let n3 = (h_whole - l_whole) / period_f;
let alpha = if n1 > 0.0 && n2 > 0.0 && n3 > 0.0 {
let d = ((n1 + n2).ln() - n3.ln()) / 2.0_f64.ln();
(-4.6 * (d - 1.0)).exp().clamp(0.01, 1.0)
} else {
// Degenerate (perfectly flat half or whole window): use the slowest
// smoothing so the indicator coasts on its previous value.
0.01
};
let prev = self.current.unwrap_or(input);
let next = alpha * input + (1.0 - alpha) * prev;
self.current = Some(next);
Some(next)
}
fn reset(&mut self) {
self.window.clear();
self.current = None;
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.current.is_some()
}
fn name(&self) -> &'static str {
"FRAMA"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_zero_period() {
assert!(matches!(Frama::new(0), Err(Error::PeriodZero)));
}
#[test]
fn rejects_invalid_period() {
assert!(matches!(Frama::new(1), Err(Error::InvalidPeriod { .. })));
assert!(matches!(Frama::new(3), Err(Error::InvalidPeriod { .. })));
assert!(matches!(Frama::new(15), Err(Error::InvalidPeriod { .. })));
}
#[test]
fn accessors_and_metadata() {
let frama = Frama::new(16).unwrap();
assert_eq!(frama.period(), 16);
assert_eq!(frama.warmup_period(), 16);
assert_eq!(frama.name(), "FRAMA");
}
#[test]
fn constant_series_yields_the_constant() {
// Flat input -> alpha clamps to 0.01 (degenerate ranges) and the
// EMA recurrence holds the seed value forever.
let mut frama = Frama::new(4).unwrap();
let out = frama.batch(&[42.0_f64; 30]);
for v in out.iter().skip(3).flatten() {
assert_relative_eq!(*v, 42.0, epsilon = 1e-12);
}
}
#[test]
fn warmup_emits_first_value_at_period() {
let mut frama = Frama::new(4).unwrap();
assert_eq!(frama.update(1.0), None);
assert_eq!(frama.update(2.0), None);
assert_eq!(frama.update(3.0), None);
assert!(frama.update(4.0).is_some());
}
#[test]
fn pure_uptrend_alpha_close_to_one() {
// A strict monotonic uptrend has fractal dimension ~1, so alpha is
// pushed to 1.0 and FRAMA reduces to the latest price.
let mut frama = Frama::new(4).unwrap();
let prices: Vec<f64> = (1..=8).map(f64::from).collect();
let out = frama.batch(&prices);
let last = out.last().unwrap().unwrap();
assert!(
(last - 8.0).abs() < 0.05,
"FRAMA on a clean uptrend should hug the latest close: {last}"
);
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (1..=80)
.map(|i| 100.0 + (f64::from(i) * 0.2).sin() * 5.0)
.collect();
let mut a = Frama::new(8).unwrap();
let mut b = Frama::new(8).unwrap();
assert_eq!(
a.batch(&prices),
prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut frama = Frama::new(4).unwrap();
frama.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
assert!(frama.is_ready());
frama.reset();
assert!(!frama.is_ready());
assert_eq!(frama.update(1.0), None);
}
#[test]
fn ignores_non_finite_input() {
let mut frama = Frama::new(4).unwrap();
frama.batch(&[1.0, 2.0, 3.0, 4.0]);
let before = frama.update(5.0).unwrap();
assert_eq!(frama.update(f64::NAN), Some(before));
assert_eq!(frama.update(f64::INFINITY), Some(before));
}
}
@@ -0,0 +1,184 @@
//! Rolling Gain/Loss Ratio.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Rolling Gain/Loss Ratio.
///
/// Over the trailing window:
///
/// ```text
/// avg_win = mean(r for r in window if r > 0)
/// avg_loss = mean(r for r in window if r < 0)
/// GLR = avg_win / avg_loss
/// ```
///
/// Where Profit Factor sums gains and losses, the Gain/Loss Ratio averages
/// them: it answers "for the typical winning bar, how big is the win
/// compared to the typical losing bar?". If there are no losers the
/// indicator returns `f64::INFINITY`; if there are no winners and no losers
/// it returns `0.0`.
///
/// Each `update` is O(period).
#[derive(Debug, Clone)]
pub struct GainLossRatio {
period: usize,
window: VecDeque<f64>,
}
impl GainLossRatio {
/// Construct a new rolling Gain/Loss Ratio.
///
/// # 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),
})
}
/// Configured window length.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for GainLossRatio {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() {
return None;
}
if self.window.len() == self.period {
self.window.pop_front();
}
self.window.push_back(input);
if self.window.len() < self.period {
return None;
}
let mut sum_win = 0.0_f64;
let mut n_win = 0_u32;
let mut sum_loss = 0.0_f64;
let mut n_loss = 0_u32;
for &r in &self.window {
if r > 0.0 {
sum_win += r;
n_win += 1;
} else if r < 0.0 {
sum_loss += -r;
n_loss += 1;
}
}
if n_loss == 0 {
return Some(if n_win == 0 { 0.0 } else { f64::INFINITY });
}
let avg_win = if n_win == 0 {
0.0
} else {
sum_win / f64::from(n_win)
};
let avg_loss = sum_loss / f64::from(n_loss);
Some(avg_win / avg_loss)
}
fn reset(&mut self) {
self.window.clear();
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.window.len() == self.period
}
fn name(&self) -> &'static str {
"GainLossRatio"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_zero_period() {
assert!(matches!(GainLossRatio::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_and_metadata() {
let g = GainLossRatio::new(10).unwrap();
assert_eq!(g.period(), 10);
assert_eq!(g.name(), "GainLossRatio");
assert_eq!(g.warmup_period(), 10);
}
#[test]
fn reference_value() {
// returns = [0.02, -0.01, 0.04, -0.03]
// avg_win = 0.03, avg_loss = 0.02, GLR = 1.5.
let mut g = GainLossRatio::new(4).unwrap();
let out = g.batch(&[0.02, -0.01, 0.04, -0.03]);
assert_relative_eq!(out[3].unwrap(), 1.5, epsilon = 1e-9);
}
#[test]
fn no_losses_yields_infinity() {
let mut g = GainLossRatio::new(3).unwrap();
let out = g.batch(&[0.01, 0.02, 0.03]);
assert!(out[2].unwrap().is_infinite());
}
#[test]
fn flat_window_yields_zero() {
let mut g = GainLossRatio::new(3).unwrap();
let out = g.batch(&[0.0_f64; 3]);
assert_eq!(out[2], Some(0.0));
}
#[test]
fn ignores_non_finite_input() {
let mut g = GainLossRatio::new(3).unwrap();
assert_eq!(g.update(f64::NAN), None);
assert_eq!(g.update(f64::INFINITY), None);
}
#[test]
fn no_wins_but_losses_yields_zero() {
// Window with only losses: avg_win is 0, GLR = 0.
let mut g = GainLossRatio::new(3).unwrap();
let out = g.batch(&[-0.01, -0.02, -0.03]);
assert_eq!(out[2], Some(0.0));
}
#[test]
fn reset_clears_state() {
let mut g = GainLossRatio::new(3).unwrap();
g.batch(&[0.01, -0.02, 0.03]);
assert!(g.is_ready());
g.reset();
assert!(!g.is_ready());
assert_eq!(g.update(0.01), None);
}
#[test]
fn batch_equals_streaming() {
let returns: Vec<f64> = (0..40).map(|i| (f64::from(i) * 0.3).sin() * 0.01).collect();
let batch = GainLossRatio::new(10).unwrap().batch(&returns);
let mut s = GainLossRatio::new(10).unwrap();
let streamed: Vec<_> = returns.iter().map(|r| s.update(*r)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,286 @@
//! Garman-Klass Volatility (OHLC estimator).
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Garman-Klass Volatility — an OHLC realised-volatility estimator.
///
/// Garman & Klass (1980) extended Parkinson's high-low estimator by adding
/// an open-to-close term, removing some of the bias introduced when the
/// closing price drifts within the bar. The per-bar sample is
///
/// ```text
/// s_t = 0.5 · (ln(H_t / L_t))² (2·ln 2 1) · (ln(C_t / O_t))²
/// ```
///
/// and the indicator returns the annualised square root of the rolling
/// mean of `s_t`:
///
/// ```text
/// out = sqrt(max(mean(s_t over `period`), 0)) · sqrt(trading_periods) · 100
/// ```
///
/// Garman & Klass showed the estimator is ~7.4× more statistically efficient
/// than the close-to-close estimator under driftless Geometric Brownian
/// Motion (Parkinson sits at ~5.0×). It is still biased when there is
/// significant overnight drift between bars — use the Yang-Zhang estimator
/// when the dataset has meaningful close-to-open gaps.
///
/// The per-bar sample `s_t` can be slightly negative when the bar's range
/// is small relative to its open-to-close move; this matches the original
/// paper's algebra and is handled by clamping the rolling mean to zero
/// before taking the square root.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, GarmanKlassVolatility, Indicator};
///
/// let mut indicator = GarmanKlassVolatility::new(20, 252).unwrap();
/// let mut last = None;
/// for i in 0..40 {
/// let base = 100.0 + f64::from(i);
/// let candle = Candle::new(base, base + 2.0, base - 2.0, base + 0.5, 1.0, i64::from(i))
/// .unwrap();
/// last = indicator.update(candle);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct GarmanKlassVolatility {
period: usize,
trading_periods: usize,
window: VecDeque<f64>,
sum: f64,
last: Option<f64>,
}
/// `2 · ln 2 1` — the Garman-Klass open-to-close weight.
const GK_OC_COEFF: f64 = 0.386_294_361_119_890_6;
impl GarmanKlassVolatility {
/// Construct a Garman-Klass Volatility estimator.
///
/// `period` is the rolling window of bars; `trading_periods` is the
/// annualisation factor (`252` daily, `52` weekly, `12` monthly, or
/// `1` for raw per-bar volatility).
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if either parameter is `0`.
pub fn new(period: usize, trading_periods: usize) -> Result<Self> {
if period == 0 || trading_periods == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
trading_periods,
window: VecDeque::with_capacity(period),
sum: 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 GarmanKlassVolatility {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
// `Candle::new` enforces finite, positive OHLC with `high >= max(open,
// low, close)` and `low <= min(open, high, close)`, so every log
// ratio below is well-defined and `ln(H/L) >= 0`.
let log_hl = (candle.high / candle.low).ln();
let log_co = (candle.close / candle.open).ln();
let sample = 0.5 * log_hl * log_hl - GK_OC_COEFF * log_co * log_co;
if self.window.len() == self.period {
let old = self.window.pop_front().expect("window is non-empty");
self.sum -= old;
}
self.window.push_back(sample);
self.sum += sample;
if self.window.len() < self.period {
return None;
}
let n = self.period as f64;
// Rolling mean. Garman-Klass samples can be marginally negative on
// narrow-range bars with large O-to-C moves; the rolling mean is
// theoretically `>= 0` but a clamp absorbs FP cancellation and the
// pathological all-negative case.
let variance = (self.sum / n).max(0.0);
let sigma = variance.sqrt();
let out = sigma * (self.trading_periods as f64).sqrt() * 100.0;
self.last = Some(out);
Some(out)
}
fn reset(&mut self) {
self.window.clear();
self.sum = 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 {
"GarmanKlassVolatility"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn candle(o: f64, h: f64, l: f64, c: f64, ts: i64) -> Candle {
Candle::new(o, h, l, c, 1.0, ts).unwrap()
}
#[test]
fn rejects_zero_period() {
assert!(matches!(
GarmanKlassVolatility::new(0, 252),
Err(Error::PeriodZero)
));
assert!(matches!(
GarmanKlassVolatility::new(20, 0),
Err(Error::PeriodZero)
));
}
#[test]
fn accessors_and_metadata() {
let gk = GarmanKlassVolatility::new(20, 252).unwrap();
assert_eq!(gk.periods(), (20, 252));
assert_eq!(gk.value(), None);
assert_eq!(gk.warmup_period(), 20);
assert_eq!(gk.name(), "GarmanKlassVolatility");
assert!(!gk.is_ready());
}
#[test]
fn zero_movement_yields_zero() {
// O == H == L == C -> both log terms are zero -> sigma is zero.
let candles: Vec<Candle> = (0..30).map(|i| candle(10.0, 10.0, 10.0, 10.0, i)).collect();
let mut gk = GarmanKlassVolatility::new(14, 1).unwrap();
for v in gk.batch(&candles).into_iter().flatten() {
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
}
}
#[test]
fn constant_bar_shape_yields_constant_sigma() {
// Every bar has identical O/H/L/C ratios -> per-bar sample is a
// constant `k`, so the rolling mean is `k` and the output is
// `sqrt(k) * 100` (trading_periods = 1).
let candles: Vec<Candle> = (0..30).map(|i| candle(10.0, 11.0, 9.0, 10.2, i)).collect();
let log_hl = (11.0_f64 / 9.0_f64).ln();
let log_co = (10.2_f64 / 10.0_f64).ln();
let k = 0.5 * log_hl * log_hl - GK_OC_COEFF * log_co * log_co;
let expected = k.max(0.0).sqrt() * 100.0;
let mut gk = GarmanKlassVolatility::new(10, 1).unwrap();
let out = gk.batch(&candles);
for v in out.iter().skip(9).flatten() {
assert_relative_eq!(*v, expected, epsilon = 1e-9);
}
}
#[test]
fn output_is_non_negative() {
let mut gk = GarmanKlassVolatility::new(14, 252).unwrap();
let candles: Vec<Candle> = (0..200)
.map(|i| {
let base = 100.0 + (f64::from(i) * 0.3).sin() * 12.0;
let half = 0.5 + (f64::from(i) * 0.13).cos().abs() * 1.5;
let open = base - 0.1;
let close = base + 0.2;
candle(open, base + half, base - half, close, i64::from(i))
})
.collect();
for v in gk.batch(&candles).into_iter().flatten() {
assert!(v >= 0.0, "Garman-Klass must be non-negative: {v}");
}
}
#[test]
fn annualisation_scales_by_sqrt_trading_periods() {
let candles: Vec<Candle> = (0..40)
.map(|i| {
let base = 100.0 + (f64::from(i) * 0.3).sin() * 5.0;
let half = 1.0 + (f64::from(i) * 0.2).cos().abs();
candle(base, base + half, base - half, base + 0.3, i64::from(i))
})
.collect();
let raw = GarmanKlassVolatility::new(10, 1).unwrap().batch(&candles);
let annual = GarmanKlassVolatility::new(10, 252).unwrap().batch(&candles);
let scale = (252.0_f64).sqrt();
for (r, a) in raw.iter().zip(annual.iter()) {
assert_eq!(r.is_some(), a.is_some(), "warmup mismatch");
if let (Some(r), Some(a)) = (r, a) {
assert_relative_eq!(*a, r * scale, epsilon = 1e-9);
}
}
}
#[test]
fn first_emission_at_warmup_period() {
let candles: Vec<Candle> = (0..20).map(|i| candle(10.0, 11.0, 9.0, 10.2, i)).collect();
let mut gk = GarmanKlassVolatility::new(5, 1).unwrap();
let out = gk.batch(&candles);
for v in out.iter().take(4) {
assert!(v.is_none());
}
assert!(out[4].is_some());
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..80)
.map(|i| {
let base = 100.0 + (f64::from(i) * 0.25).sin() * 6.0;
let half = 1.0 + (f64::from(i) * 0.15).cos().abs();
candle(base, base + half, base - half, base + 0.5, i64::from(i))
})
.collect();
let batch = GarmanKlassVolatility::new(14, 252).unwrap().batch(&candles);
let mut streamer = GarmanKlassVolatility::new(14, 252).unwrap();
let streamed: Vec<_> = candles.iter().map(|c| streamer.update(*c)).collect();
assert_eq!(batch, streamed);
}
#[test]
fn reset_clears_state() {
let candles: Vec<Candle> = (0..30).map(|i| candle(10.0, 11.0, 9.0, 10.2, i)).collect();
let mut gk = GarmanKlassVolatility::new(14, 252).unwrap();
gk.batch(&candles);
assert!(gk.is_ready());
gk.reset();
assert!(!gk.is_ready());
assert_eq!(gk.value(), None);
assert_eq!(gk.update(candles[0]), None);
}
}
+160
View File
@@ -0,0 +1,160 @@
//! Hammer candlestick pattern.
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Hammer — a single-bar bullish reversal candidate.
///
/// A Hammer has a small real body sitting near the top of the bar, a long
/// lower shadow at least twice the body, and a short or absent upper shadow.
/// It is traditionally read as a rejection of lower prices.
///
/// ```text
/// body = |close open|
/// upper_shadow = high max(open, close)
/// lower_shadow = min(open, close) low
/// hammer = lower_shadow >= 2 * body
/// && upper_shadow <= body
/// && body > 0
/// ```
///
/// Output is `+1.0` when the shape matches, `0.0` otherwise. Pattern-shape
/// check only — no trend filter is applied; combine with a trend indicator
/// for actionable signals.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Hammer, Indicator};
///
/// let mut indicator = Hammer::new();
/// // Open 10, close 10.5, low 5, high 10.6: long lower shadow, tiny upper.
/// let candle = Candle::new(10.0, 10.6, 5.0, 10.5, 1.0, 0).unwrap();
/// assert_eq!(indicator.update(candle), Some(1.0));
/// ```
#[derive(Debug, Clone, Default)]
pub struct Hammer {
has_emitted: bool,
}
impl Hammer {
/// Construct a new Hammer detector.
pub const fn new() -> Self {
Self { has_emitted: false }
}
}
impl Indicator for Hammer {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
self.has_emitted = true;
let range = candle.high - candle.low;
if range <= 0.0 {
return Some(0.0);
}
let body = (candle.close - candle.open).abs();
if body <= 0.0 {
return Some(0.0);
}
let upper = candle.high - candle.open.max(candle.close);
let lower = candle.open.min(candle.close) - candle.low;
Some(if lower >= 2.0 * body && upper <= body {
1.0
} else {
0.0
})
}
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 {
"Hammer"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle {
Candle::new(open, high, low, close, 1.0, ts).unwrap()
}
#[test]
fn accessors_and_metadata() {
let h = Hammer::new();
assert_eq!(h.name(), "Hammer");
assert_eq!(h.warmup_period(), 1);
assert!(!h.is_ready());
}
#[test]
fn clean_hammer_is_one() {
let mut h = Hammer::new();
// body 0.5 (10 -> 10.5), lower shadow 5.0, upper shadow 0.1.
assert_eq!(h.update(c(10.0, 10.6, 5.0, 10.5, 0)), Some(1.0));
}
#[test]
fn marubozu_is_not_hammer() {
let mut h = Hammer::new();
assert_eq!(h.update(c(10.0, 12.0, 10.0, 12.0, 0)), Some(0.0));
}
#[test]
fn shooting_star_shape_is_not_hammer() {
// Long upper, short lower -> not a hammer.
let mut h = Hammer::new();
assert_eq!(h.update(c(10.5, 15.0, 10.0, 10.0, 0)), Some(0.0));
}
#[test]
fn doji_is_not_hammer() {
let mut h = Hammer::new();
assert_eq!(h.update(c(10.0, 11.0, 9.0, 10.0, 0)), Some(0.0));
}
#[test]
fn zero_range_yields_zero() {
let mut h = Hammer::new();
assert_eq!(h.update(c(10.0, 10.0, 10.0, 10.0, 0)), Some(0.0));
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..40)
.map(|i| {
let base = 100.0 + i as f64;
c(base, base + 2.0, base - 4.0, base + 0.5, i)
})
.collect();
let mut a = Hammer::new();
let mut b = Hammer::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut h = Hammer::new();
h.update(c(10.0, 10.6, 5.0, 10.5, 0));
assert!(h.is_ready());
h.reset();
assert!(!h.is_ready());
}
}
@@ -0,0 +1,151 @@
//! Hanging Man candlestick pattern.
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Hanging Man — a single-bar bearish reversal candidate.
///
/// A Hanging Man has the same geometry as a Hammer (small body near the top,
/// long lower shadow ≥ 2× body, short upper shadow) but is read bearishly
/// because it appears at the top of an uptrend.
///
/// ```text
/// body = |close open|
/// upper_shadow = high max(open, close)
/// lower_shadow = min(open, close) low
/// hanging = lower_shadow >= 2 * body
/// && upper_shadow <= body
/// && body > 0
/// ```
///
/// Output is `1.0` when the shape matches, `0.0` otherwise. Pattern-shape
/// check only — no trend filter is applied; combine with a trend indicator
/// for actionable signals.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, HangingMan, Indicator};
///
/// let mut indicator = HangingMan::new();
/// let candle = Candle::new(10.0, 10.6, 5.0, 10.5, 1.0, 0).unwrap();
/// assert_eq!(indicator.update(candle), Some(-1.0));
/// ```
#[derive(Debug, Clone, Default)]
pub struct HangingMan {
has_emitted: bool,
}
impl HangingMan {
/// Construct a new Hanging Man detector.
pub const fn new() -> Self {
Self { has_emitted: false }
}
}
impl Indicator for HangingMan {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
self.has_emitted = true;
let range = candle.high - candle.low;
if range <= 0.0 {
return Some(0.0);
}
let body = (candle.close - candle.open).abs();
if body <= 0.0 {
return Some(0.0);
}
let upper = candle.high - candle.open.max(candle.close);
let lower = candle.open.min(candle.close) - candle.low;
Some(if lower >= 2.0 * body && upper <= body {
-1.0
} else {
0.0
})
}
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 {
"HangingMan"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle {
Candle::new(open, high, low, close, 1.0, ts).unwrap()
}
#[test]
fn accessors_and_metadata() {
let h = HangingMan::new();
assert_eq!(h.name(), "HangingMan");
assert_eq!(h.warmup_period(), 1);
assert!(!h.is_ready());
}
#[test]
fn clean_hanging_man_is_minus_one() {
let mut h = HangingMan::new();
assert_eq!(h.update(c(10.0, 10.6, 5.0, 10.5, 0)), Some(-1.0));
}
#[test]
fn marubozu_is_not_hanging_man() {
let mut h = HangingMan::new();
assert_eq!(h.update(c(10.0, 12.0, 10.0, 12.0, 0)), Some(0.0));
}
#[test]
fn doji_is_not_hanging_man() {
let mut h = HangingMan::new();
assert_eq!(h.update(c(10.0, 11.0, 9.0, 10.0, 0)), Some(0.0));
}
#[test]
fn zero_range_yields_zero() {
let mut h = HangingMan::new();
assert_eq!(h.update(c(10.0, 10.0, 10.0, 10.0, 0)), Some(0.0));
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..40)
.map(|i| {
let base = 100.0 + i as f64;
c(base, base + 2.0, base - 4.0, base + 0.5, i)
})
.collect();
let mut a = HangingMan::new();
let mut b = HangingMan::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut h = HangingMan::new();
h.update(c(10.0, 10.6, 5.0, 10.5, 0));
assert!(h.is_ready());
h.reset();
assert!(!h.is_ready());
}
}
+187
View File
@@ -0,0 +1,187 @@
//! Bullish / Bearish Harami candlestick pattern.
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Harami — a 2-bar reversal pattern. The current candle's body sits entirely
/// inside the previous candle's body and points in the opposite direction.
///
/// ```text
/// prev_body = |prev.close prev.open|
/// curr_body = |curr.close curr.open|
/// bullish = prev red & curr green
/// & curr.open >= prev.close & curr.close <= prev.open
/// & curr_body < prev_body
/// bearish = prev green & curr red
/// & curr.open <= prev.close & curr.close >= prev.open
/// & curr_body < prev_body
/// ```
///
/// Output is `+1.0` for a bullish harami (small green inside a prior red),
/// `1.0` for a bearish harami (small red inside a prior green), `0.0`
/// otherwise. The first bar always returns `0.0`. Pattern-shape check only —
/// no trend filter is applied; combine with a trend indicator for actionable
/// signals.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Harami, Indicator};
///
/// let mut indicator = Harami::new();
/// indicator.update(Candle::new(12.0, 12.5, 9.5, 10.0, 1.0, 0).unwrap());
/// let out = indicator
/// .update(Candle::new(10.5, 11.5, 10.4, 11.0, 1.0, 1).unwrap());
/// assert_eq!(out, Some(1.0));
/// ```
#[derive(Debug, Clone, Default)]
pub struct Harami {
prev: Option<Candle>,
has_emitted: bool,
}
impl Harami {
/// Construct a new Harami detector.
pub const fn new() -> Self {
Self {
prev: None,
has_emitted: false,
}
}
}
impl Indicator for Harami {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
self.has_emitted = true;
let prev = self.prev;
self.prev = Some(candle);
let Some(p) = prev else {
return Some(0.0);
};
let prev_body = (p.close - p.open).abs();
let curr_body = (candle.close - candle.open).abs();
if prev_body <= 0.0 || curr_body <= 0.0 || curr_body >= prev_body {
return Some(0.0);
}
let prev_red = p.close < p.open;
let prev_green = p.close > p.open;
let curr_green = candle.close > candle.open;
let curr_red = candle.close < candle.open;
// Bullish: small green strictly inside prior red body (open >= prev.close, close <= prev.open).
if prev_red && curr_green && candle.open >= p.close && candle.close <= p.open {
Some(1.0)
} else if prev_green && curr_red && candle.open <= p.close && candle.close >= p.open {
Some(-1.0)
} else {
Some(0.0)
}
}
fn reset(&mut self) {
self.prev = None;
self.has_emitted = false;
}
fn warmup_period(&self) -> usize {
2
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"Harami"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle {
Candle::new(open, high, low, close, 1.0, ts).unwrap()
}
#[test]
fn accessors_and_metadata() {
let h = Harami::new();
assert_eq!(h.name(), "Harami");
assert_eq!(h.warmup_period(), 2);
assert!(!h.is_ready());
}
#[test]
fn bullish_harami_is_plus_one() {
let mut h = Harami::new();
// Prior red 12 -> 10 (body 2). Current green 10.5 -> 11 inside.
assert_eq!(h.update(c(12.0, 12.5, 9.5, 10.0, 0)), Some(0.0));
assert_eq!(h.update(c(10.5, 11.5, 10.4, 11.0, 1)), Some(1.0));
}
#[test]
fn bearish_harami_is_minus_one() {
let mut h = Harami::new();
// Prior green 10 -> 12 (body 2). Current red 11.5 -> 11 inside.
assert_eq!(h.update(c(10.0, 12.5, 9.5, 12.0, 0)), Some(0.0));
assert_eq!(h.update(c(11.5, 11.6, 10.9, 11.0, 1)), Some(-1.0));
}
#[test]
fn larger_body_is_not_harami() {
let mut h = Harami::new();
h.update(c(11.0, 11.2, 9.8, 10.0, 0));
// Current body bigger than prior.
assert_eq!(h.update(c(9.5, 12.0, 9.5, 11.5, 1)), Some(0.0));
}
#[test]
fn same_direction_is_not_harami() {
let mut h = Harami::new();
h.update(c(10.0, 12.5, 9.5, 12.0, 0));
// Smaller candle but also green -> 0.
assert_eq!(h.update(c(11.0, 11.6, 10.9, 11.5, 1)), Some(0.0));
}
#[test]
fn first_bar_returns_zero() {
let mut h = Harami::new();
assert_eq!(h.update(c(10.0, 11.0, 9.0, 11.0, 0)), Some(0.0));
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..40)
.map(|i| {
let base = 100.0 + i as f64;
if i % 2 == 0 {
c(base + 2.0, base + 2.5, base - 0.5, base, i)
} else {
c(base + 1.0, base + 1.5, base + 0.7, base + 1.3, i)
}
})
.collect();
let mut a = Harami::new();
let mut b = Harami::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut h = Harami::new();
h.update(c(12.0, 12.5, 9.5, 10.0, 0));
h.update(c(10.5, 11.5, 10.4, 11.0, 1));
assert!(h.is_ready());
h.reset();
assert!(!h.is_ready());
// After reset the next bar again has no prev.
assert_eq!(h.update(c(12.0, 12.5, 9.5, 10.0, 0)), Some(0.0));
}
}
@@ -0,0 +1,198 @@
//! Heikin-Ashi candle transform.
#![allow(clippy::manual_midpoint)]
//!
//! Heikin-Ashi ("average bar" in Japanese) smooths an OHLC candle stream so
//! trends are easier to read at a glance. The transform is purely local except
//! that `ha_open` depends on the *previous* Heikin-Ashi candle, so it remains
//! a streaming O(1) state machine.
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// One Heikin-Ashi candle.
///
/// Fields use the same names as the source `Candle` but represent the
/// transformed OHLC.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct HeikinAshiOutput {
/// Heikin-Ashi open: midpoint of the previous Heikin-Ashi open and close.
pub open: f64,
/// Heikin-Ashi high: `max(real high, ha_open, ha_close)`.
pub high: f64,
/// Heikin-Ashi low: `min(real low, ha_open, ha_close)`.
pub low: f64,
/// Heikin-Ashi close: average of the real open, high, low, close.
pub close: f64,
}
/// Streaming Heikin-Ashi transform.
///
/// Emits a [`HeikinAshiOutput`] for every input bar starting with the very
/// first, so `warmup_period` is 1 and `batch` returns `n` outputs for `n`
/// inputs.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, HeikinAshi, Indicator};
///
/// let mut ha = HeikinAshi::new();
/// let c = Candle::new(10.0, 11.0, 9.0, 10.5, 0.0, 0).unwrap();
/// let out = ha.update(c).unwrap();
/// // First bar: ha_open = (open + close) / 2 = 10.25.
/// assert!((out.open - 10.25).abs() < 1e-12);
/// ```
#[derive(Debug, Clone, Default)]
pub struct HeikinAshi {
prev: Option<HeikinAshiOutput>,
}
impl HeikinAshi {
/// Construct a fresh transform with no prior state.
#[must_use]
pub const fn new() -> Self {
Self { prev: None }
}
/// Most recently emitted Heikin-Ashi candle, if any.
pub const fn value(&self) -> Option<HeikinAshiOutput> {
self.prev
}
}
impl Indicator for HeikinAshi {
type Input = Candle;
type Output = HeikinAshiOutput;
fn update(&mut self, candle: Candle) -> Option<HeikinAshiOutput> {
let ha_close = (candle.open + candle.high + candle.low + candle.close) / 4.0;
let ha_open = match self.prev {
Some(p) => f64::midpoint(p.open, p.close),
// Seed: average of the real open and close.
None => f64::midpoint(candle.open, candle.close),
};
let ha_high = candle.high.max(ha_open).max(ha_close);
let ha_low = candle.low.min(ha_open).min(ha_close);
let out = HeikinAshiOutput {
open: ha_open,
high: ha_high,
low: ha_low,
close: ha_close,
};
self.prev = Some(out);
Some(out)
}
fn reset(&mut self) {
self.prev = None;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.prev.is_some()
}
fn name(&self) -> &'static str {
"HeikinAshi"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn cnd(o: f64, h: f64, l: f64, c: f64) -> Candle {
Candle::new(o, h, l, c, 0.0, 0).unwrap()
}
#[test]
fn first_bar_seeds_open_from_real_open_close() {
let mut ha = HeikinAshi::new();
let out = ha.update(cnd(10.0, 12.0, 9.0, 11.0)).unwrap();
assert_relative_eq!(out.open, (10.0 + 11.0) / 2.0, epsilon = 1e-12);
assert_relative_eq!(out.close, (10.0 + 12.0 + 9.0 + 11.0) / 4.0, epsilon = 1e-12);
// high/low must envelope ha_open & ha_close along with the real H/L.
assert!(out.high >= out.open);
assert!(out.high >= out.close);
assert!(out.low <= out.open);
assert!(out.low <= out.close);
}
#[test]
fn second_bar_uses_previous_ha_midpoint_as_open() {
let mut ha = HeikinAshi::new();
let first = ha.update(cnd(10.0, 12.0, 9.0, 11.0)).unwrap();
let second = ha.update(cnd(11.5, 13.0, 10.5, 12.0)).unwrap();
assert_relative_eq!(
second.open,
(first.open + first.close) / 2.0,
epsilon = 1e-12
);
assert_relative_eq!(
second.close,
(11.5 + 13.0 + 10.5 + 12.0) / 4.0,
epsilon = 1e-12
);
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..50)
.map(|i| {
let p = 100.0 + f64::from(i);
cnd(p, p + 1.5, p - 1.5, p + 0.5)
})
.collect();
let mut a = HeikinAshi::new();
let mut b = HeikinAshi::new();
let batched = a.batch(&candles);
let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
assert_eq!(batched, streamed);
}
#[test]
fn ready_after_first_update() {
let mut ha = HeikinAshi::new();
assert!(!ha.is_ready());
ha.update(cnd(10.0, 11.0, 9.0, 10.5));
assert!(ha.is_ready());
}
#[test]
fn reset_clears_state() {
let mut ha = HeikinAshi::new();
ha.update(cnd(10.0, 11.0, 9.0, 10.5));
assert!(ha.is_ready());
ha.reset();
assert!(!ha.is_ready());
assert!(ha.value().is_none());
// After reset, the next bar re-seeds from real open/close.
let out = ha.update(cnd(20.0, 22.0, 18.0, 21.0)).unwrap();
assert_relative_eq!(out.open, (20.0 + 21.0) / 2.0, epsilon = 1e-12);
}
#[test]
fn metadata() {
let ha = HeikinAshi::new();
assert_eq!(ha.warmup_period(), 1);
assert_eq!(ha.name(), "HeikinAshi");
}
#[test]
fn high_envelopes_open_and_close() {
// Real high below the synthetic ha_open/close still inflates ha_high.
let mut ha = HeikinAshi::new();
// Bar 1 sets a baseline.
ha.update(cnd(100.0, 101.0, 99.0, 100.5));
// Bar 2 with an extreme close — ha_close = (50+50+50+200)/4 = 87.5,
// ha_open = midpoint of prev open/close — and a real high of 200.
let out = ha.update(cnd(50.0, 200.0, 50.0, 200.0)).unwrap();
assert_eq!(out.high, 200.0);
assert!(out.low <= out.open.min(out.close));
}
}
@@ -0,0 +1,272 @@
//! Ehlers Hilbert Transform Dominant Cycle period estimator.
#![allow(clippy::manual_clamp)]
use std::f64::consts::PI;
use crate::traits::Indicator;
/// Ehlers' Hilbert Transformbased Dominant Cycle period estimator.
///
/// Decomposes price into in-phase and quadrature components via Ehlers'
/// truncated Hilbert transform, then derives the instantaneous phase. The
/// dominant cycle period is recovered from the phase rate of change and
/// median-smoothed. From *Rocket Science for Traders* (Ehlers 2001, ch. 7),
/// implementation aligned with the formulation used in TA-Lib's `HT_DCPERIOD`.
///
/// The output is clamped to the band `[6, 50]` bars, which Ehlers identifies
/// as the meaningful tradable cycle range. The estimator emits its first
/// value after ~50 inputs as the moving-average chain fills.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, HilbertDominantCycle};
///
/// let mut ht = HilbertDominantCycle::new();
/// let mut last = None;
/// for i in 0..200 {
/// last = ht.update(100.0 + (f64::from(i) * 0.4).sin() * 5.0);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone, Default)]
pub struct HilbertDominantCycle {
// Rolling 7-tap smoother input buffer.
smooth_buf: Vec<f64>,
// Detrender / Q1 / I1 ring history (need 6 prior).
detrender_buf: Vec<f64>,
q1_buf: Vec<f64>,
i1_buf: Vec<f64>,
// Smoothed I/Q lines for phase computation.
prev_i2: f64,
prev_q2: f64,
prev_re: f64,
prev_im: f64,
prev_period: f64,
prev_smooth_period: f64,
count: usize,
last_value: Option<f64>,
}
impl HilbertDominantCycle {
/// Construct a new dominant cycle estimator.
pub fn new() -> Self {
Self::default()
}
/// Current period estimate if available.
pub const fn value(&self) -> Option<f64> {
self.last_value
}
}
impl Indicator for HilbertDominantCycle {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() {
return self.last_value;
}
self.count += 1;
// 4-bar weighted moving average of the input (smoothed price).
// Ehlers: (4*x[0] + 3*x[1] + 2*x[2] + x[3]) / 10.
Self::push_front(&mut self.smooth_buf, input, 7);
if self.smooth_buf.len() < 4 {
return None;
}
let smooth = (4.0 * self.smooth_buf[0]
+ 3.0 * self.smooth_buf[1]
+ 2.0 * self.smooth_buf[2]
+ self.smooth_buf[3])
/ 10.0;
// Adaptive coefficient based on the previous period estimate.
let period = self.prev_period.max(6.0).min(50.0);
let adj = 0.075 * period + 0.54;
// We need the smooth buffer to hold ≥ 7 samples for the Hilbert taps.
if self.smooth_buf.len() < 7 {
return None;
}
// Ehlers' Hilbert transform of `smooth` (using current + 2/4/6 lags).
let s0 = smooth;
let s2 = self.smooth_buf[2];
let s4 = self.smooth_buf[4];
let s6 = self.smooth_buf[6];
let detrender = (0.0962 * s0 + 0.5769 * s2 - 0.5769 * s4 - 0.0962 * s6) * adj;
Self::push_front(&mut self.detrender_buf, detrender, 7);
if self.detrender_buf.len() < 7 {
return None;
}
// In-phase and quadrature components.
let q1 = (0.0962 * self.detrender_buf[0] + 0.5769 * self.detrender_buf[2]
- 0.5769 * self.detrender_buf[4]
- 0.0962 * self.detrender_buf[6])
* adj;
let i1 = self.detrender_buf[3];
Self::push_front(&mut self.q1_buf, q1, 7);
Self::push_front(&mut self.i1_buf, i1, 7);
if self.q1_buf.len() < 7 || self.i1_buf.len() < 7 {
return None;
}
// Advance the phase 90 deg via a second Hilbert pass.
let ji = (0.0962 * self.i1_buf[0] + 0.5769 * self.i1_buf[2]
- 0.5769 * self.i1_buf[4]
- 0.0962 * self.i1_buf[6])
* adj;
let jq = (0.0962 * self.q1_buf[0] + 0.5769 * self.q1_buf[2]
- 0.5769 * self.q1_buf[4]
- 0.0962 * self.q1_buf[6])
* adj;
// Phasor smoothing.
let mut i2 = i1 - jq;
let mut q2 = q1 + ji;
i2 = 0.2 * i2 + 0.8 * self.prev_i2;
q2 = 0.2 * q2 + 0.8 * self.prev_q2;
// Homodyne discriminator.
let mut re = i2 * self.prev_i2 + q2 * self.prev_q2;
let mut im = i2 * self.prev_q2 - q2 * self.prev_i2;
re = 0.2 * re + 0.8 * self.prev_re;
im = 0.2 * im + 0.8 * self.prev_im;
self.prev_i2 = i2;
self.prev_q2 = q2;
self.prev_re = re;
self.prev_im = im;
let mut new_period = if im.abs() > f64::EPSILON && re.abs() > f64::EPSILON {
2.0 * PI / im.atan2(re)
} else {
self.prev_period
};
// Rate-of-change clamp per Ehlers.
new_period = new_period.min(1.5 * self.prev_period);
new_period = new_period.max(0.67 * self.prev_period);
new_period = new_period.clamp(6.0, 50.0);
// EMA smoothing of the period.
self.prev_period = 0.2 * new_period + 0.8 * self.prev_period;
// Second smoothing step (TA-Lib uses 0.33/0.67).
self.prev_smooth_period = 0.33 * self.prev_period + 0.67 * self.prev_smooth_period;
if self.count < 50 {
return None;
}
self.last_value = Some(self.prev_smooth_period);
Some(self.prev_smooth_period)
}
fn reset(&mut self) {
self.smooth_buf.clear();
self.detrender_buf.clear();
self.q1_buf.clear();
self.i1_buf.clear();
self.prev_i2 = 0.0;
self.prev_q2 = 0.0;
self.prev_re = 0.0;
self.prev_im = 0.0;
self.prev_period = 0.0;
self.prev_smooth_period = 0.0;
self.count = 0;
self.last_value = None;
}
fn warmup_period(&self) -> usize {
50
}
fn is_ready(&self) -> bool {
self.last_value.is_some()
}
fn name(&self) -> &'static str {
"HilbertDominantCycle"
}
}
impl HilbertDominantCycle {
/// Push `v` at the front of `buf`, capping the length at `cap`.
fn push_front(buf: &mut Vec<f64>, v: f64, cap: usize) {
buf.insert(0, v);
if buf.len() > cap {
buf.truncate(cap);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
#[test]
fn accessors_and_metadata() {
let mut ht = HilbertDominantCycle::new();
assert_eq!(ht.warmup_period(), 50);
assert_eq!(ht.name(), "HilbertDominantCycle");
assert!(!ht.is_ready());
assert!(ht.value().is_none());
for i in 0..120 {
ht.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0);
}
assert!(ht.is_ready());
assert!(ht.value().is_some());
}
#[test]
fn output_within_clamp_band() {
let mut ht = HilbertDominantCycle::new();
let prices: Vec<f64> = (0..200)
.map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 5.0)
.collect();
let out = ht.batch(&prices);
for v in out.iter().flatten() {
assert!((6.0..=50.0).contains(v), "period {v} outside [6, 50]");
}
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (0..200)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0)
.collect();
let mut a = HilbertDominantCycle::new();
let mut b = HilbertDominantCycle::new();
let batch = a.batch(&prices);
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
assert_eq!(batch, streamed);
}
#[test]
fn ignores_non_finite_input() {
let mut ht = HilbertDominantCycle::new();
let prices: Vec<f64> = (0..120)
.map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 5.0)
.collect();
ht.batch(&prices);
let before = ht.value();
assert!(before.is_some());
assert_eq!(ht.update(f64::NAN), before);
}
#[test]
fn reset_clears_state() {
let mut ht = HilbertDominantCycle::new();
let prices: Vec<f64> = (0..120)
.map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 5.0)
.collect();
ht.batch(&prices);
assert!(ht.is_ready());
ht.reset();
assert!(!ht.is_ready());
assert!(ht.value().is_none());
}
}
@@ -0,0 +1,264 @@
//! `HiLo` Activator (Crabel).
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// `HiLo` Activator — Robert Krausz's adaptation of Linda Bradford Raschke and
/// Larry Connors' "`HiLo`" rule, popularised by Toby Crabel. Two simple moving
/// averages — of the high and of the low — bracket price; the trailing stop
/// for a long sits at the SMA-of-low, and for a short at the SMA-of-high.
///
/// ```text
/// hi_sma = SMA(high, period) // potential short stop
/// lo_sma = SMA(low, period) // potential long stop
///
/// state-machine:
/// long while close > hi_sma_prev -> emit lo_sma_prev
/// short while close < lo_sma_prev -> emit hi_sma_prev
/// else: hold the previous side
/// ```
///
/// Comparing the close to the *previous* bar's SMA avoids look-ahead and gives
/// the indicator a one-bar lag — the classic Crabel formulation. A long signal
/// fires the bar after price closes above the high-SMA; the stop then trails
/// at the low-SMA. The first input that fills the SMA window seeds a long.
/// A common configuration is a `3`-period window.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, HiLoActivator};
///
/// let mut indicator = HiLoActivator::new(3).unwrap();
/// let mut last = None;
/// for i in 0..40 {
/// let base = 100.0 + f64::from(i);
/// let candle =
/// Candle::new(base, base + 1.0, base - 1.0, base, 10.0, i64::from(i)).unwrap();
/// last = indicator.update(candle);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct HiLoActivator {
period: usize,
highs: VecDeque<f64>,
lows: VecDeque<f64>,
sum_high: f64,
sum_low: f64,
/// Last bar's `(hi_sma, lo_sma)`, used so today's signal is based on
/// yesterday's SMAs (no look-ahead).
prev_smas: Option<(f64, f64)>,
/// `true` while the current trail is on the long side.
long: bool,
/// `true` once a signal has been emitted at least once.
started: bool,
}
impl HiLoActivator {
/// Construct a `HiLo` Activator with an explicit SMA window.
///
/// # Errors
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
highs: VecDeque::with_capacity(period),
lows: VecDeque::with_capacity(period),
sum_high: 0.0,
sum_low: 0.0,
prev_smas: None,
long: true,
started: false,
})
}
/// Crabel's classic configuration: a `3`-bar window.
pub fn classic() -> Self {
Self::new(3).expect("classic period is valid")
}
/// Configured SMA window.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for HiLoActivator {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
if self.highs.len() == self.period {
self.sum_high -= self.highs.pop_front().expect("non-empty by check");
self.sum_low -= self.lows.pop_front().expect("non-empty by check");
}
self.highs.push_back(candle.high);
self.lows.push_back(candle.low);
self.sum_high += candle.high;
self.sum_low += candle.low;
// Need today's SMA + yesterday's SMA to compare close vs the *previous*
// bar's bands — so the very first ready bar only computes today's SMA
// and stores it; emission begins on the next bar.
if self.highs.len() < self.period {
return None;
}
let p = self.period as f64;
let hi_sma = self.sum_high / p;
let lo_sma = self.sum_low / p;
let out = if let Some((prev_hi, prev_lo)) = self.prev_smas {
if candle.close > prev_hi {
self.long = true;
} else if candle.close < prev_lo {
self.long = false;
}
self.started = true;
if self.long {
prev_lo
} else {
prev_hi
}
} else {
// First SMA-ready bar seeds yesterday's bands for the next call.
self.prev_smas = Some((hi_sma, lo_sma));
return None;
};
self.prev_smas = Some((hi_sma, lo_sma));
Some(out)
}
fn reset(&mut self) {
self.highs.clear();
self.lows.clear();
self.sum_high = 0.0;
self.sum_low = 0.0;
self.prev_smas = None;
self.long = true;
self.started = false;
}
fn warmup_period(&self) -> usize {
self.period + 1
}
fn is_ready(&self) -> bool {
self.started
}
fn name(&self) -> &'static str {
"HiLoActivator"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn c(high: f64, low: f64, close: f64, ts: i64) -> Candle {
Candle::new(f64::midpoint(high, low), high, low, close, 1.0, ts).unwrap()
}
#[test]
fn rejects_zero_period() {
assert!(HiLoActivator::new(0).is_err());
}
#[test]
fn accessors_and_metadata() {
let s = HiLoActivator::classic();
assert_eq!(s.period(), 3);
assert_eq!(s.warmup_period(), 4);
assert_eq!(s.name(), "HiLoActivator");
}
#[test]
fn warmup_emits_none_until_period_plus_one() {
let mut s = HiLoActivator::new(3).unwrap();
// The first 3 candles fill the SMA; the 4th is the first emission.
let candles: Vec<Candle> = (0..6)
.map(|i| {
let base = 100.0 + i as f64;
c(base + 1.0, base - 1.0, base, i)
})
.collect();
let out = s.batch(&candles);
assert!(out[0].is_none());
assert!(out[1].is_none());
assert!(out[2].is_none());
assert!(out[3].is_some(), "first emission lands at index period");
}
#[test]
fn constant_series_stays_long_on_lo_sma() {
let mut s = HiLoActivator::new(3).unwrap();
// Flat candles: H=11, L=9, C=10. Both SMAs are constant.
let candles: Vec<Candle> = (0..10).map(|i| c(11.0, 9.0, 10.0, i)).collect();
for v in s.batch(&candles).into_iter().flatten() {
// close (10) is not > 11 nor < 9, so the long seed persists -> lo_sma = 9.
assert_relative_eq!(v, 9.0, epsilon = 1e-12);
}
}
#[test]
fn uptrend_keeps_emitting_low_sma_below_close() {
let mut s = HiLoActivator::new(3).unwrap();
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 paired: Vec<(f64, f64)> = s
.batch(&candles)
.into_iter()
.zip(candles.iter())
.filter_map(|(o, c)| o.map(|v| (v, c.close)))
.collect();
assert!(
paired.iter().all(|(stop, close)| stop < close),
"uptrend stop should sit below the close"
);
}
#[test]
fn reset_clears_state() {
let mut s = HiLoActivator::new(3).unwrap();
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();
s.batch(&candles);
assert!(s.is_ready());
s.reset();
assert!(!s.is_ready());
assert_eq!(s.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 = HiLoActivator::classic();
let mut b = HiLoActivator::classic();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,236 @@
//! Hurst Channel (Brian Millard / Hurst-cycle channel).
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::indicators::sma::Sma;
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Hurst Channel output.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct HurstChannelOutput {
/// Upper channel: `middle + multiplier · (highest_high lowest_low)`.
pub upper: f64,
/// Middle line: SMA of close over the period.
pub middle: f64,
/// Lower channel: `middle multiplier · (highest_high lowest_low)`.
pub lower: f64,
}
/// Hurst Channel: an SMA centerline wrapped by a rolling high-low range.
///
/// ```text
/// middle = SMA(close, period)
/// range = max(high, period) min(low, period)
/// upper = middle + multiplier · range
/// lower = middle multiplier · range
/// ```
///
/// The Hurst Channel sizes its envelope by the *realised* high-low range of
/// the window — a simpler, range-based volatility proxy than Bollinger's
/// rolling stddev or Keltner's ATR. With a `multiplier` of `0.5` the channel
/// reduces to a centerline that hugs the midpoint of the Donchian envelope;
/// chart vendors that follow Hurst's cycle work commonly use `period = 10` and
/// `multiplier = 0.5` for the "inner" channel.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, HurstChannel, Indicator};
///
/// let mut indicator = HurstChannel::new(10, 0.5).unwrap();
/// let mut last = None;
/// for i in 0..30 {
/// 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 HurstChannel {
period: usize,
multiplier: f64,
sma: Sma,
highs: VecDeque<f64>,
lows: VecDeque<f64>,
}
impl HurstChannel {
/// # Errors
/// Returns [`Error::PeriodZero`] / [`Error::NonPositiveMultiplier`] on
/// invalid inputs.
pub fn new(period: usize, multiplier: f64) -> Result<Self> {
if !multiplier.is_finite() || multiplier <= 0.0 {
return Err(Error::NonPositiveMultiplier);
}
Ok(Self {
period,
multiplier,
sma: Sma::new(period)?,
highs: VecDeque::with_capacity(period),
lows: VecDeque::with_capacity(period),
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
/// Configured range multiplier.
pub const fn multiplier(&self) -> f64 {
self.multiplier
}
}
impl Indicator for HurstChannel {
type Input = Candle;
type Output = HurstChannelOutput;
fn update(&mut self, candle: Candle) -> Option<HurstChannelOutput> {
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);
let middle = self.sma.update(candle.close)?;
let hi = self.highs.iter().copied().fold(f64::NEG_INFINITY, f64::max);
let lo = self.lows.iter().copied().fold(f64::INFINITY, f64::min);
let range = hi - lo;
Some(HurstChannelOutput {
upper: middle + self.multiplier * range,
middle,
lower: middle - self.multiplier * range,
})
}
fn reset(&mut self) {
self.sma.reset();
self.highs.clear();
self.lows.clear();
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.sma.is_ready()
}
fn name(&self) -> &'static str {
"HurstChannel"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn c(h: f64, l: f64, cl: f64) -> Candle {
Candle::new(cl, h, l, cl, 1.0, 0).unwrap()
}
#[test]
fn rejects_zero_period() {
assert!(matches!(HurstChannel::new(0, 0.5), Err(Error::PeriodZero)));
}
#[test]
fn rejects_non_positive_multiplier() {
assert!(matches!(
HurstChannel::new(10, 0.0),
Err(Error::NonPositiveMultiplier)
));
assert!(matches!(
HurstChannel::new(10, -0.5),
Err(Error::NonPositiveMultiplier)
));
assert!(matches!(
HurstChannel::new(10, f64::NAN),
Err(Error::NonPositiveMultiplier)
));
}
#[test]
fn accessors_and_metadata() {
let h = HurstChannel::new(10, 0.5).unwrap();
assert_eq!(h.period(), 10);
assert_relative_eq!(h.multiplier(), 0.5, epsilon = 1e-12);
assert_eq!(h.warmup_period(), 10);
assert_eq!(h.name(), "HurstChannel");
}
#[test]
fn flat_market_collapses_bands() {
let candles: Vec<Candle> = (0..20).map(|_| c(10.0, 10.0, 10.0)).collect();
let mut h = HurstChannel::new(5, 0.5).unwrap();
let last = h.batch(&candles).into_iter().flatten().last().unwrap();
assert_relative_eq!(last.upper, 10.0, epsilon = 1e-9);
assert_relative_eq!(last.middle, 10.0, epsilon = 1e-9);
assert_relative_eq!(last.lower, 10.0, epsilon = 1e-9);
}
#[test]
fn upper_above_middle_above_lower() {
let candles: Vec<Candle> = (0..50)
.map(|i| {
let m = 100.0 + (f64::from(i) * 0.2).sin() * 5.0;
c(m + 1.0, m - 1.0, m)
})
.collect();
let mut h = HurstChannel::new(10, 0.5).unwrap();
for o in h.batch(&candles).into_iter().flatten() {
assert!(o.upper >= o.middle);
assert!(o.middle >= o.lower);
}
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..40)
.map(|i| c(f64::from(i) + 2.0, f64::from(i), f64::from(i) + 1.0))
.collect();
let mut a = HurstChannel::new(10, 0.5).unwrap();
let mut b = HurstChannel::new(10, 0.5).unwrap();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let candles: Vec<Candle> = (0..10)
.map(|i| c(f64::from(i) + 1.0, f64::from(i) - 1.0, f64::from(i)))
.collect();
let mut h = HurstChannel::new(5, 0.5).unwrap();
h.batch(&candles);
assert!(h.is_ready());
h.reset();
assert!(!h.is_ready());
assert_eq!(h.update(candles[0]), None);
}
/// Reference: five identical candles `(high=12, low=8, close=10)`:
/// SMA(close, 5) = 10, range = 12 8 = 4, multiplier = 0.5
/// upper = 10 + 0.5·4 = 12, lower = 10 0.5·4 = 8.
#[test]
fn reference_values() {
let candles: Vec<Candle> = (0..5).map(|_| c(12.0, 8.0, 10.0)).collect();
let mut h = HurstChannel::new(5, 0.5).unwrap();
let out = h.batch(&candles);
assert!(out[0].is_none() && out[3].is_none());
let v = out[4].unwrap();
assert_relative_eq!(v.middle, 10.0, epsilon = 1e-9);
assert_relative_eq!(v.upper, 12.0, epsilon = 1e-9);
assert_relative_eq!(v.lower, 8.0, epsilon = 1e-9);
}
}
@@ -0,0 +1,299 @@
//! Rolling Hurst Exponent via simplified R/S analysis.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Hurst Exponent of the last `period` values, estimated by rescaled-range
/// (R/S) analysis.
///
/// The classic Hurst-Mandelbrot estimator forms log-log pairs of `(n,
/// R(n)/S(n))` for several window lengths `n` and reports the slope of the
/// least-squares fit. Wickra uses a streaming-friendly variant that
/// partitions the trailing window into `chunks` of equal size,
/// computes `(R/S)` for each chunk length, and fits a log-log line to the
/// resulting points:
///
/// ```text
/// for each chunk size m ∈ {n/2, n/3, …, n/chunks}:
/// mean_m = (1/m) · Σ x_i over the chunk
/// dev_m_i = (Σ_{j ≤ i} (x_j mean_m)) // cumulative deviation
/// R_m = max(dev_m) min(dev_m)
/// S_m = population_stddev(chunk)
/// pair = (log m, log(R_m / S_m))
/// H = slope of OLS line through the (log m, log(R/S)) points
/// ```
///
/// The interpretation is unchanged from the textbook:
///
/// - `H ≈ 0.5` → random walk; recent moves carry no information about
/// future direction (the efficient-markets baseline).
/// - `H > 0.5` → persistent / trending; up moves are likelier to be
/// followed by more up moves.
/// - `H < 0.5` → anti-persistent / mean-reverting; up moves tend to
/// reverse.
///
/// Use it as a regime filter: trend-following strategies prefer
/// `H > 0.55`; mean-reversion prefers `H < 0.45`. The output is clamped
/// to `[0, 1]` to absorb degenerate fits on very small windows.
///
/// `period` must be at least `2 · chunks` so every chunk has at least two
/// points (otherwise its stddev is zero). A perfectly flat window has all
/// `R/S = 0` and the indicator returns `0.5` (random-walk baseline) to
/// avoid divide-by-zero / log-zero failures.
///
/// Each `update` is O(period); the window is stored in a deque and the
/// chunked R/S computation runs once per emission, not per input.
///
/// # Example
///
/// ```
/// use wickra_core::{HurstExponent, Indicator};
///
/// let mut indicator = HurstExponent::new(100, 4).unwrap();
/// let mut last = None;
/// for i in 0..200 {
/// last = indicator.update(f64::from(i));
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct HurstExponent {
period: usize,
chunks: usize,
window: VecDeque<f64>,
}
impl HurstExponent {
/// Construct a new Hurst Exponent over a window of `period` inputs,
/// fitted across `chunks` log-log points.
///
/// `chunks` controls the number of R/S pairs that go into the slope
/// fit; the typical value is `4` (the original Hurst paper used 5 — 9
/// points; smaller windows constrain the choice).
///
/// # Errors
/// Returns [`Error::InvalidPeriod`] if `chunks < 2` or
/// `period < 2 · chunks`.
pub fn new(period: usize, chunks: usize) -> Result<Self> {
if chunks < 2 {
return Err(Error::InvalidPeriod {
message: "Hurst chunks must be >= 2",
});
}
if period < 2 * chunks {
return Err(Error::InvalidPeriod {
message: "Hurst period must be >= 2 * chunks",
});
}
Ok(Self {
period,
chunks,
window: VecDeque::with_capacity(period),
})
}
/// Configured window period.
pub const fn period(&self) -> usize {
self.period
}
/// Configured chunk count.
pub const fn chunks(&self) -> usize {
self.chunks
}
}
/// R/S over a single chunk; returns `None` if the chunk has zero dispersion
/// (its stddev is zero, so the ratio is undefined).
fn rescaled_range(chunk: &[f64]) -> Option<f64> {
let n = chunk.len() as f64;
let mean = chunk.iter().sum::<f64>() / n;
let mut cum = 0.0;
let mut hi = f64::NEG_INFINITY;
let mut lo = f64::INFINITY;
let mut sum_sq = 0.0;
for &x in chunk {
let d = x - mean;
cum += d;
if cum > hi {
hi = cum;
}
if cum < lo {
lo = cum;
}
sum_sq += d * d;
}
let r = hi - lo;
let s = (sum_sq / n).sqrt();
if s == 0.0 || r == 0.0 {
return None;
}
Some(r / s)
}
impl Indicator for HurstExponent {
type Input = f64;
type Output = f64;
fn update(&mut self, value: f64) -> Option<f64> {
if self.window.len() == self.period {
self.window.pop_front();
}
self.window.push_back(value);
if self.window.len() < self.period {
return None;
}
// Materialise the window contiguously so chunk slicing is trivial.
let buf: Vec<f64> = self.window.iter().copied().collect();
// Build (log m, log(R/S)) points. The chunk size sweeps from period
// (one big chunk) down to period / chunks (chunks small chunks).
let mut sum_x = 0.0;
let mut sum_y = 0.0;
let mut sum_xy = 0.0;
let mut sum_xx = 0.0;
let mut count = 0usize;
for k in 1..=self.chunks {
// k chunks each of size m; ignore the integer-division leftover
// bars at the end of the window. The `period >= 2 * chunks`
// constructor invariant guarantees m >= 2 for every k in range.
let m = self.period / k;
// Average R/S across the k chunks of size m to reduce noise.
let mut acc = 0.0;
let mut chunks_used = 0;
for c in 0..k {
let start = c * m;
let end = start + m;
if let Some(rs) = rescaled_range(&buf[start..end]) {
acc += rs;
chunks_used += 1;
}
}
if chunks_used == 0 {
continue;
}
let avg_rs = acc / f64::from(chunks_used);
let x = (m as f64).ln();
let y = avg_rs.ln();
sum_x += x;
sum_y += y;
sum_xy += x * y;
sum_xx += x * x;
count += 1;
}
if count < 2 {
// A perfectly flat window yields no usable R/S point; the
// canonical fallback for R/S on white noise is H = 0.5.
return Some(0.5);
}
// With chunks >= 2 and period >= 2 * chunks, m_1 = period and
// m_2 = period / 2 are always distinct, so the variance of the
// log-m values is strictly positive and `denom > 0`.
let n = count as f64;
let denom = n * sum_xx - sum_x * sum_x;
let slope = (n * sum_xy - sum_x * sum_y) / denom;
Some(slope.clamp(0.0, 1.0))
}
fn reset(&mut self) {
self.window.clear();
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.window.len() == self.period
}
fn name(&self) -> &'static str {
"HurstExponent"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_invalid_parameters() {
assert!(HurstExponent::new(10, 0).is_err());
assert!(HurstExponent::new(10, 1).is_err());
assert!(HurstExponent::new(3, 2).is_err());
assert!(HurstExponent::new(4, 2).is_ok());
}
#[test]
fn accessors_and_metadata() {
let h = HurstExponent::new(100, 4).unwrap();
assert_eq!(h.period(), 100);
assert_eq!(h.chunks(), 4);
assert_eq!(h.warmup_period(), 100);
assert_eq!(h.name(), "HurstExponent");
}
#[test]
fn constant_series_is_one_half() {
let mut h = HurstExponent::new(40, 4).unwrap();
for v in h.batch(&[42.0; 80]).into_iter().flatten() {
assert_relative_eq!(v, 0.5, epsilon = 1e-12);
}
}
#[test]
fn output_stays_in_zero_one_range() {
let prices: Vec<f64> = (0..400)
.map(|i| {
100.0
+ (f64::from(i) * 0.05).sin() * 8.0
+ (f64::from(i) * 0.21).cos() * 3.0
+ f64::from(i) * 0.1
})
.collect();
let mut h = HurstExponent::new(100, 4).unwrap();
for v in h.batch(&prices).into_iter().flatten() {
assert!((0.0..=1.0).contains(&v), "Hurst out of range: {v}");
}
}
#[test]
fn trending_series_above_half() {
// A clean monotonic ramp is the textbook persistent series; the R/S
// pairs must lie above the random-walk baseline.
let prices: Vec<f64> = (0..200).map(f64::from).collect();
let mut h = HurstExponent::new(100, 4).unwrap();
let last = h.batch(&prices).into_iter().flatten().last().unwrap();
assert!(
last > 0.5,
"trending series should have H > 0.5, got {last}"
);
}
#[test]
fn reset_clears_state() {
let mut h = HurstExponent::new(20, 4).unwrap();
for i in 0..20 {
h.update(f64::from(i));
}
assert!(h.is_ready());
h.reset();
assert!(!h.is_ready());
assert_eq!(h.update(1.0), None);
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (0..200)
.map(|i| 100.0 + (f64::from(i) * 0.1).sin() * 5.0)
.collect();
let batch = HurstExponent::new(50, 4).unwrap().batch(&prices);
let mut b = HurstExponent::new(50, 4).unwrap();
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,434 @@
//! Ichimoku Kinko Hyo — the five-line cloud chart.
//!
//! The Ichimoku system bundles five distinct lines computed from highs, lows
//! and closes:
//!
//! - **Tenkan-sen** (Conversion Line): midpoint of the last `tenkan_period`
//! highs and lows (default 9).
//! - **Kijun-sen** (Base Line): midpoint over `kijun_period` (default 26).
//! - **Senkou Span A** (Leading A): `(tenkan + kijun) / 2`, shifted *forward*
//! `displacement` bars.
//! - **Senkou Span B** (Leading B): midpoint over `senkou_b_period` (default
//! 52), also shifted forward `displacement` bars.
//! - **Chikou Span** (Lagging Span): the current close, displayed `displacement`
//! bars *backwards*.
//!
//! The two Senkou Spans form the **Kumo** (cloud). At step *n* the visible
//! Senkou A/B are computed from data at step *n displacement*; the visible
//! Chikou is the close from step *n + displacement* in a chart, but in a
//! streaming setting the only Chikou we can emit at step *n* is the close from
//! *n displacement*. That convention matches every TA library that processes
//! candles in chronological order.
#![allow(clippy::too_many_arguments)]
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// All five Ichimoku lines at one step.
///
/// `tenkan` and `kijun` reflect data up to and including the current bar.
/// `senkou_a` / `senkou_b` are the leading-span values *visible at the current
/// bar*, computed from `displacement` bars ago. `chikou` is the close from
/// `displacement` bars ago (its "lagging" placement on charts).
///
/// Any field that is not yet defined (insufficient history) is `None`.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct IchimokuOutput {
/// Tenkan-sen — midpoint of the last `tenkan_period` highs/lows.
pub tenkan: Option<f64>,
/// Kijun-sen — midpoint of the last `kijun_period` highs/lows.
pub kijun: Option<f64>,
/// Senkou Span A as visible at the current bar (computed from
/// `(tenkan + kijun) / 2` at step `n - displacement`).
pub senkou_a: Option<f64>,
/// Senkou Span B as visible at the current bar (computed from the
/// `senkou_b_period` midpoint at step `n - displacement`).
pub senkou_b: Option<f64>,
/// Chikou Span — the close from `displacement` bars ago.
pub chikou: Option<f64>,
}
/// Ichimoku Kinko Hyo indicator.
///
/// Standard parameters are `(9, 26, 52, 26)`. The first fully-populated output
/// (every field `Some`) appears after `senkou_b_period + displacement - 1`
/// candles — 77 bars at the defaults — because Senkou B needs its own 52-bar
/// midpoint *and* a 26-bar history of those midpoints to displace from.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Ichimoku, Indicator};
///
/// let mut ichi = Ichimoku::classic();
/// for i in 0..120 {
/// let p = 100.0 + f64::from(i);
/// let candle = Candle::new(p, p + 2.0, p - 2.0, p + 1.0, 0.0, i64::from(i)).unwrap();
/// ichi.update(candle);
/// }
/// let out = ichi.value().unwrap();
/// assert!(out.tenkan.is_some() && out.kijun.is_some());
/// assert!(out.senkou_a.is_some() && out.senkou_b.is_some());
/// assert!(out.chikou.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct Ichimoku {
tenkan_period: usize,
kijun_period: usize,
senkou_b_period: usize,
displacement: usize,
// Rolling window of recent highs/lows for the longest lookback we need.
highs: VecDeque<f64>,
lows: VecDeque<f64>,
// Past (tenkan+kijun)/2 values used to emit the displaced Senkou A.
senkou_a_history: VecDeque<f64>,
// Past Senkou B midpoint values used to emit the displaced Senkou B.
senkou_b_history: VecDeque<f64>,
// Past closes for the lagging Chikou span.
close_history: VecDeque<f64>,
last: Option<IchimokuOutput>,
}
impl Ichimoku {
/// Construct an Ichimoku indicator with custom periods.
///
/// `tenkan_period` is the short midpoint window (default 9), `kijun_period`
/// the medium (default 26), `senkou_b_period` the long (default 52), and
/// `displacement` the forward/backward shift in bars (default 26).
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if any of `tenkan_period`, `kijun_period`,
/// `senkou_b_period`, or `displacement` is zero, and [`Error::InvalidPeriod`]
/// if the periods are not in strictly increasing order
/// (`tenkan < kijun < senkou_b`).
pub fn new(
tenkan_period: usize,
kijun_period: usize,
senkou_b_period: usize,
displacement: usize,
) -> Result<Self> {
if tenkan_period == 0 || kijun_period == 0 || senkou_b_period == 0 || displacement == 0 {
return Err(Error::PeriodZero);
}
if tenkan_period >= kijun_period || kijun_period >= senkou_b_period {
return Err(Error::InvalidPeriod {
message: "Ichimoku periods must satisfy tenkan < kijun < senkou_b",
});
}
let cap = senkou_b_period;
Ok(Self {
tenkan_period,
kijun_period,
senkou_b_period,
displacement,
highs: VecDeque::with_capacity(cap),
lows: VecDeque::with_capacity(cap),
senkou_a_history: VecDeque::with_capacity(displacement),
senkou_b_history: VecDeque::with_capacity(displacement),
close_history: VecDeque::with_capacity(displacement),
last: None,
})
}
/// Classical `(9, 26, 52, 26)` configuration.
pub fn classic() -> Self {
Self::new(9, 26, 52, 26).expect("classic Ichimoku periods are valid")
}
/// Configured periods as `(tenkan, kijun, senkou_b, displacement)`.
pub const fn periods(&self) -> (usize, usize, usize, usize) {
(
self.tenkan_period,
self.kijun_period,
self.senkou_b_period,
self.displacement,
)
}
/// Most recent output if at least one bar has been consumed.
pub const fn value(&self) -> Option<IchimokuOutput> {
self.last
}
/// Midpoint of the last `n` highs/lows. Assumes `self.highs.len() >= n`
/// (the caller checks).
fn midpoint(&self, n: usize) -> f64 {
let len = self.highs.len();
let start = len - n;
let mut hi = f64::NEG_INFINITY;
let mut lo = f64::INFINITY;
for i in start..len {
hi = hi.max(self.highs[i]);
lo = lo.min(self.lows[i]);
}
f64::midpoint(hi, lo)
}
}
impl Indicator for Ichimoku {
type Input = Candle;
type Output = IchimokuOutput;
fn update(&mut self, candle: Candle) -> Option<IchimokuOutput> {
// Ring-buffer the new bar; cap at the longest lookback.
if self.highs.len() == self.senkou_b_period {
self.highs.pop_front();
self.lows.pop_front();
}
self.highs.push_back(candle.high);
self.lows.push_back(candle.low);
let tenkan =
(self.highs.len() >= self.tenkan_period).then(|| self.midpoint(self.tenkan_period));
let kijun =
(self.highs.len() >= self.kijun_period).then(|| self.midpoint(self.kijun_period));
let senkou_b_now =
(self.highs.len() >= self.senkou_b_period).then(|| self.midpoint(self.senkou_b_period));
// Today's contribution to the leading spans (will become visible after
// `displacement` more bars).
let senkou_a_now = match (tenkan, kijun) {
(Some(t), Some(k)) => Some(f64::midpoint(t, k)),
_ => None,
};
// The currently-visible Senkou A/B at this bar are the values that were
// computed `displacement` bars ago. We always push the freshly-computed
// `senkou_a_now` / `senkou_b_now` to keep the history aligned 1:1 with
// bars; NaN encodes "no value yet" so the buffer indices stay simple.
let push_or_nan = |q: &mut VecDeque<f64>, v: Option<f64>, cap: usize| {
if q.len() == cap {
q.pop_front();
}
q.push_back(v.unwrap_or(f64::NAN));
};
push_or_nan(&mut self.senkou_a_history, senkou_a_now, self.displacement);
push_or_nan(&mut self.senkou_b_history, senkou_b_now, self.displacement);
// The visible Senkou A/B at the current bar were buffered exactly
// `displacement` updates ago, which is `self.senkou_*_history.front()`
// once the buffer is full.
let take_front = |q: &VecDeque<f64>, cap: usize| -> Option<f64> {
if q.len() == cap {
let v = q[0];
if v.is_nan() {
None
} else {
Some(v)
}
} else {
None
}
};
let senkou_a = take_front(&self.senkou_a_history, self.displacement);
let senkou_b = take_front(&self.senkou_b_history, self.displacement);
// Chikou: close from `displacement` bars ago.
if self.close_history.len() == self.displacement {
self.close_history.pop_front();
}
self.close_history.push_back(candle.close);
let chikou = (self.close_history.len() == self.displacement).then(|| self.close_history[0]);
let out = IchimokuOutput {
tenkan,
kijun,
senkou_a,
senkou_b,
chikou,
};
self.last = Some(out);
Some(out)
}
fn reset(&mut self) {
self.highs.clear();
self.lows.clear();
self.senkou_a_history.clear();
self.senkou_b_history.clear();
self.close_history.clear();
self.last = None;
}
fn warmup_period(&self) -> usize {
// First fully-populated row needs senkou_b's midpoint to have travelled
// `displacement` bars forward.
self.senkou_b_period + self.displacement - 1
}
fn is_ready(&self) -> bool {
self.last.is_some_and(|o| {
o.tenkan.is_some()
&& o.kijun.is_some()
&& o.senkou_a.is_some()
&& o.senkou_b.is_some()
&& o.chikou.is_some()
})
}
fn name(&self) -> &'static str {
"Ichimoku"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn c(h: f64, l: f64, cl: f64, i: i64) -> Candle {
Candle::new(cl, h, l, cl, 0.0, i).unwrap()
}
fn ramp(n: i64) -> Vec<Candle> {
(0..n)
.map(|i| {
let p = 100.0 + f64::from(i32::try_from(i).unwrap());
c(p + 2.0, p - 2.0, p + 1.0, i)
})
.collect()
}
#[test]
fn rejects_zero_periods() {
assert!(matches!(
Ichimoku::new(0, 26, 52, 26),
Err(Error::PeriodZero)
));
assert!(matches!(
Ichimoku::new(9, 0, 52, 26),
Err(Error::PeriodZero)
));
assert!(matches!(
Ichimoku::new(9, 26, 0, 26),
Err(Error::PeriodZero)
));
assert!(matches!(
Ichimoku::new(9, 26, 52, 0),
Err(Error::PeriodZero)
));
}
#[test]
fn rejects_non_increasing_periods() {
assert!(matches!(
Ichimoku::new(26, 26, 52, 26),
Err(Error::InvalidPeriod { .. })
));
assert!(matches!(
Ichimoku::new(9, 52, 52, 26),
Err(Error::InvalidPeriod { .. })
));
assert!(matches!(
Ichimoku::new(52, 26, 9, 26),
Err(Error::InvalidPeriod { .. })
));
}
#[test]
fn accessors_and_metadata() {
let ichi = Ichimoku::classic();
assert_eq!(ichi.periods(), (9, 26, 52, 26));
assert_eq!(ichi.warmup_period(), 77);
assert_eq!(ichi.name(), "Ichimoku");
assert!(ichi.value().is_none());
}
#[test]
fn tenkan_emits_at_period() {
let mut ichi = Ichimoku::classic();
let candles = ramp(10);
let out = ichi.batch(&candles);
// The 9th update is the first time tenkan has 9 highs/lows.
for (i, o) in out.iter().enumerate() {
let v = o.unwrap();
if i < 8 {
assert!(v.tenkan.is_none(), "tenkan must be None until 9 bars");
} else {
assert!(v.tenkan.is_some(), "tenkan must be Some from bar 9 on");
}
}
}
#[test]
fn fully_populated_after_warmup() {
let mut ichi = Ichimoku::classic();
let candles = ramp(120);
let out = ichi.batch(&candles);
let last = out.last().unwrap().unwrap();
assert!(last.tenkan.is_some());
assert!(last.kijun.is_some());
assert!(last.senkou_a.is_some());
assert!(last.senkou_b.is_some());
assert!(last.chikou.is_some());
assert!(ichi.is_ready());
}
#[test]
fn ramp_tenkan_equals_window_midpoint() {
// On a strict ramp the midpoint of the last 9 (high, low) candles is
// the midpoint of the first and last bar in that window.
let mut ichi = Ichimoku::classic();
let candles = ramp(20);
let out = ichi.batch(&candles);
// At index 8 (9th bar), the window is bars 0..=8 with highs 102..110
// and lows 98..106. Midpoint = (110 + 98) / 2 = 104.
let v = out[8].unwrap();
assert_relative_eq!(v.tenkan.unwrap(), 104.0, epsilon = 1e-12);
}
#[test]
fn chikou_is_close_displacement_bars_back() {
let mut ichi = Ichimoku::classic();
let candles = ramp(60);
let out = ichi.batch(&candles);
// Displacement = 26; at bar index 25, chikou is the close from bar 0.
let v = out[25].unwrap();
assert_relative_eq!(v.chikou.unwrap(), candles[0].close, epsilon = 1e-12);
let v = out[50].unwrap();
assert_relative_eq!(v.chikou.unwrap(), candles[25].close, epsilon = 1e-12);
}
#[test]
fn batch_equals_streaming() {
let candles = ramp(120);
let mut a = Ichimoku::classic();
let mut b = Ichimoku::classic();
let batched = a.batch(&candles);
let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
assert_eq!(batched.len(), streamed.len());
for (lhs, rhs) in batched.iter().zip(streamed.iter()) {
let (l, r) = (lhs.unwrap(), rhs.unwrap());
assert_eq!(l.tenkan, r.tenkan);
assert_eq!(l.kijun, r.kijun);
assert_eq!(l.senkou_a, r.senkou_a);
assert_eq!(l.senkou_b, r.senkou_b);
assert_eq!(l.chikou, r.chikou);
}
}
#[test]
fn reset_clears_state() {
let mut ichi = Ichimoku::classic();
ichi.batch(&ramp(100));
assert!(ichi.is_ready());
ichi.reset();
assert!(!ichi.is_ready());
assert!(ichi.value().is_none());
}
#[test]
fn custom_periods_accepted() {
let mut ichi = Ichimoku::new(5, 10, 20, 10).unwrap();
let out = ichi.batch(&ramp(40));
let last = out.last().unwrap().unwrap();
assert!(last.tenkan.is_some());
assert!(last.senkou_a.is_some());
}
}
@@ -0,0 +1,179 @@
//! Inertia (Donald Dorsey).
use crate::error::{Error, Result};
use crate::indicators::linreg::LinearRegression;
use crate::indicators::rvi::Rvi;
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Donald Dorsey's Inertia — a Linear-Regression-smoothed `RVI` (Relative Vigor
/// Index). The endpoint of an `n`-bar least-squares fit of the `RVI` series is
/// taken as the indicator's reading, smoothing the underlying ratio while
/// preserving its trend direction.
///
/// ```text
/// Inertia_t = LinearRegression(RVI(close - open, high - low; rvi_period), linreg_period)_t
/// ```
///
/// Dorsey's recommended defaults are `(rvi_period = 14, linreg_period = 20)`.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, Inertia};
///
/// let mut inertia = Inertia::new(14, 20).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// let o = 100.0 + f64::from(i);
/// let c = o + 0.5;
/// let candle = Candle::new(o, c + 0.2, o - 0.2, c, 1.0, i64::from(i)).unwrap();
/// last = inertia.update(candle);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct Inertia {
rvi_period: usize,
linreg_period: usize,
rvi: Rvi,
linreg: LinearRegression,
}
impl Inertia {
/// # Errors
/// Returns [`Error::PeriodZero`] if either period is zero.
pub fn new(rvi_period: usize, linreg_period: usize) -> Result<Self> {
if rvi_period == 0 || linreg_period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
rvi_period,
linreg_period,
rvi: Rvi::new(rvi_period)?,
linreg: LinearRegression::new(linreg_period)?,
})
}
/// Dorsey's recommended defaults `(rvi_period = 14, linreg_period = 20)`.
pub fn classic() -> Self {
Self::new(14, 20).expect("classic Inertia parameters are valid")
}
/// Configured `(rvi_period, linreg_period)`.
pub const fn periods(&self) -> (usize, usize) {
(self.rvi_period, self.linreg_period)
}
}
impl Indicator for Inertia {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
let rvi = self.rvi.update(candle)?;
self.linreg.update(rvi)
}
fn reset(&mut self) {
self.rvi.reset();
self.linreg.reset();
}
fn warmup_period(&self) -> usize {
// RVI emits at `rvi_period` candles; the LinearRegression then needs
// `linreg_period 1` more RVI values to fill its window.
self.rvi_period + self.linreg_period - 1
}
fn is_ready(&self) -> bool {
self.linreg.is_ready()
}
fn name(&self) -> &'static str {
"Inertia"
}
}
#[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 rejects_zero_period() {
assert!(matches!(Inertia::new(0, 20), Err(Error::PeriodZero)));
assert!(matches!(Inertia::new(14, 0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_and_metadata() {
let inertia = Inertia::classic();
assert_eq!(inertia.periods(), (14, 20));
assert_eq!(inertia.warmup_period(), 33);
assert_eq!(inertia.name(), "Inertia");
}
#[test]
fn classic_factory() {
assert_eq!(Inertia::classic().periods(), (14, 20));
}
#[test]
fn warmup_emits_first_value_at_warmup_period() {
// Smaller periods for a fast test: RVI(3) emits at 3 candles, then
// LinReg(4) needs 4 RVI values -> total 3 + 4 - 1 = 6.
let mut inertia = Inertia::new(3, 4).unwrap();
assert_eq!(inertia.warmup_period(), 6);
for i in 0..5 {
assert_eq!(inertia.update(candle(10.0, 11.0, 9.0, 10.5, i)), None);
}
assert!(inertia.update(candle(10.0, 11.0, 9.0, 10.5, 5)).is_some());
}
#[test]
fn constant_rvi_yields_constant_inertia() {
// Every bar identical -> RVI is constant -> LinReg of a constant
// series equals that constant after warmup.
let mut inertia = Inertia::new(3, 4).unwrap();
let mut last = None;
for i in 0..40 {
last = inertia.update(candle(10.0, 11.0, 9.0, 10.5, i));
}
// RVI = SMA(c-o, 3) / SMA(h-l, 3) = 0.5 / 2.0 = 0.25 on every bar.
let v = last.unwrap();
assert_relative_eq!(v, 0.25, epsilon = 1e-12);
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..80_i64)
.map(|i| {
let o = 100.0 + (i as f64 * 0.3).sin() * 5.0;
let c = o + (i as f64 * 0.1).cos();
candle(o, o.max(c) + 0.5, o.min(c) - 0.5, c, i)
})
.collect();
let batch = Inertia::classic().batch(&candles);
let mut b = Inertia::classic();
let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
assert_eq!(batch, streamed);
}
#[test]
fn reset_clears_state() {
let mut inertia = Inertia::classic();
for i in 0..50 {
inertia.update(candle(10.0, 11.0, 9.0, 10.5, i));
}
assert!(inertia.is_ready());
inertia.reset();
assert!(!inertia.is_ready());
assert_eq!(inertia.update(candle(10.0, 11.0, 9.0, 10.5, 0)), None);
}
}

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