Compare commits

..

116 Commits

Author SHA1 Message Date
kingchenc fae60e0d54 release: bump 0.4.2 -> 0.4.3 (#125) 2026-06-01 20:49:11 +02:00
kingchenc 433b06367f ci: bump actions/checkout v4.3.1 -> v6.0.2 in codeql & scorecard (#124) 2026-06-01 20:24:20 +02:00
kingchenc 3dd7010129 feat: footprint microstructure indicator (part 4 of 4) (#123) 2026-06-01 20:00:58 +02:00
kingchenc 4f11df0e33 feat: microstructure price-impact & depth indicators (part 3 of 4) (#122)
* feat: effective spread microstructure indicator (part 3 of 4)

* feat: realized spread microstructure indicator (part 3 of 4)

* feat: kyle's lambda microstructure indicator (part 3 of 4)

* feat: depth slope microstructure indicator (part 3 of 4)
2026-06-01 19:45:38 +02:00
kingchenc b5d9e47a2e release: bump 0.4.1 -> 0.4.2 (#121) 2026-06-01 18:29:32 +02:00
kingchenc 511d3a27f7 ci: self-updating README banner + fix webpage count sync (#119)
* ci: fix webpage count sync crashing on removed public/hero.svg

The webpage indicator-count sync sed'd index.md, .vitepress/config.ts and
public/hero.svg, but hero.svg was removed from wickra-lib/webpage (the count is
now baked into og-banner.webp at build time from index.md). Under 'bash -e' the
missing file made sed exit 2 before the commit/push, so the count never reached
the webpage repo and its OG banner stayed stale — masked green by the step's
continue-on-error. Drops public/hero.svg from the sed and git add; index.md and
.vitepress/config.ts (both still carry the count) remain.

* docs: point README banner at the self-updating org profile image

Switches the top README banner from https://wickra.org/og-banner.webp (baked at
webpage deploy time) to the org profile banner that wickra-lib/.github's
banner.yml regenerates from the indicator count on every sync
(raw.githubusercontent.com/wickra-lib/.github/main/profile/wickra-banner.webp).
The ?v=227 query busts GitHub's Camo image cache; the next commit teaches
sync-about to bump it with the count.

* ci: bump README banner cache-buster alongside the indicator count

Extends the PR-head README counter patch to also rewrite wickra-banner.webp?v=N
to the current count. The banner now points at the org profile image, whose
content changes when .github/banner.yml regenerates it; bumping the ?v query
busts GitHub's Camo cache so the README shows the new banner immediately. Rides
in the existing count-sync commit, so no extra commit lands on main.

* docs: changelog entry for self-updating README banner and sync fix
2026-06-01 18:19:23 +02:00
kingchenc 5b23b36261 ci: pin CI dependency installs by hash (Scorecard PinnedDependencies) (#114)
* ci: use npm ci instead of npm install for reproducible installs

Pins the node binding dependency install to the committed package-lock.json
integrity hashes (OpenSSF Scorecard PinnedDependencies). npm ci installs
strictly from the lockfile; npm install could resolve newer patch versions.
Covers ci.yml and both release.yml node steps.

* ci: hash-pin Python dev tooling in ci.yml (Scorecard #19)

Replaces the unpinned 'pip install maturin pytest numpy hypothesis' with a
hash-locked '--require-hashes -r' install (OpenSSF Scorecard PinnedDependencies).

Two lock files are needed because numpy publishes no single release with wheels
for both cp39 and cp313 (<=2.0.2 has cp39 only, >=2.1 drops cp39):
  ci-dev-py39.txt  numpy 2.0.2  (Python 3.9, + tomli/exceptiongroup)
  ci-dev-py3.txt   numpy 2.4.6  (Python 3.10+)

The step selects the file by matrix.python-version under shell: bash. Both are
generated from ci-dev.in via uv (scripts/update-lockfiles.sh, added next).

* ci: hash-pin Python deps in bench.yml (Scorecard #16)

Replaces the unpinned 'pip install maturin numpy pandas talipp finta' with a
hash-locked '--require-hashes -r .github/requirements/bench.txt' install.
bench.yml runs on a single Python version (3.11), so one lock file (generated
from bench.in via uv) is sufficient.

* build: add scripts/update-lockfiles.sh to regenerate all lockfiles

One command refreshes every committed lockfile across languages: Cargo.lock and
fuzz/Cargo.lock (cargo update), the Node binding package-lock.json, and the
hash-pinned Python requirements under .github/requirements/ (uv pip compile
--generate-hashes). Uses uv for the Python locks so a target Python version's
hashed transitive closure can be resolved without that interpreter installed
(needed for the numpy cp39/cp313 split); bootstraps uv if absent.

.gitattributes pins *.sh to LF so the script stays runnable on Linux/macOS.

* ci: split ci-dev requirements per Python version + Dependabot rehash

Splits the single ci-dev.in into ci-dev-py39.in (numpy <2.1, the last series
with cp39 wheels) and ci-dev-py3.in (3.10+), giving a 1:1 .in->.txt layout.
The cap keeps Python 3.9 permanently installable and stops Dependabot from
proposing 3.9-breaking numpy bumps.

Adds a Dependabot pip entry on /.github/requirements so the hash-locked tooling
is kept current automatically; the canonical manual refresh stays
scripts/update-lockfiles.sh. Only the '# via -r' provenance lines in the .txt
change; no package versions or hashes move.

* ci: cache pip and npm downloads in the PR-loop jobs

Adds setup-python cache: pip (ci.yml python matrix + bench.yml, keyed on the
hash-locked requirements) and setup-node cache: npm (ci.yml node job, keyed on
bindings/node/package-lock.json), on both the primary and retry setup steps.

Scoped to jobs that actually install dependencies; the examples-smoke and
clippy-bindings jobs install nothing and are left uncached. release.yml is
intentionally left out: it runs only on tag push (not the PR loop) and is the
publish-critical path, so no caching is added there.

* docs: document hash-pinned requirements and update-lockfiles.sh

Updates the lockfile-policy table: the bindings/python row no longer claims CI
installs tooling unpinned, and a new .github/requirements row documents the
hash-locked CI/bench tooling and the per-Python-version ci-dev split. Adds a
paragraph pointing contributors at scripts/update-lockfiles.sh (uv-based,
self-bootstrapping) as the canonical lockfile refresh.

* docs: changelog entry for hash-pinned CI dependency installs
2026-06-01 17:58:31 +02:00
kingchenc 5867f71450 feat: trade-flow microstructure indicators (part 2 of 4) (#113)
* feat(core): add 3 trade-flow microstructure indicators

SignedVolume (per-trade size signed by aggressor), CumulativeVolumeDelta
(running signed-volume total), and TradeImbalance (rolling buy/sell volume
imbalance over a trade window). All consume the Trade type, with full unit
coverage. Extends the Microstructure family.

* feat(bindings): expose trade-flow microstructure indicators

Python, Node and WASM bindings for SignedVolume, CumulativeVolumeDelta and
TradeImbalance. Each takes a trade via update(price, size, is_buy); Python and
Node expose a batch over three parallel arrays, WASM exposes per-trade update.
Regenerates node index.d.ts/.js.

* test(bindings,fuzz,bench): cover trade-flow microstructure indicators

Python and Node: reference values, streaming-vs-batch, lifecycle/repr and input
validation (zero window, negative size, non-positive price, mismatched batch
lengths). New indicator_update_trade fuzz target. Synthetic trade-tape benches
(signed_volume cheapest, trade_imbalance windowed/expensive).

* docs: add trade-flow indicators + bump counter to 227

README Microstructure family row gains signed volume / CVD / trade imbalance and
the counter goes 224 -> 227; CHANGELOG records the trade-flow indicators.
2026-06-01 16:38:48 +02:00
kingchenc 2be21df803 feat: order-book microstructure indicators (part 1 of 4) (#112)
* feat(core): add microstructure input types (OrderBook, Trade, TradeQuote)

New non-OHLCV value types for the order-book / trade-flow indicator family:
Level, OrderBook (sorted, uncrossed depth snapshot), Side, Trade (with
aggressor side), and TradeQuote (trade paired with prevailing mid). Each has a
validating constructor plus a new_unchecked hot-path constructor, with full
unit coverage. Adds InvalidOrderBook / InvalidTrade error variants.

* feat(core): add 5 order-book microstructure indicators

OrderBookImbalanceTop1/TopN/Full (signed depth imbalance), Microprice
(size-weighted fair value), and QuotedSpread (top-of-book spread in bps). All
consume the OrderBook snapshot type, emit f64, are stateless and ready after
the first snapshot, with full unit coverage. Registers a new Microstructure
family in the taxonomy.

* feat(bindings): expose order-book microstructure indicators

Python, Node, and WASM bindings for OrderBookImbalanceTop1/TopN/Full,
Microprice and QuotedSpread. Each takes a depth snapshot via four equal-length
(bid_px, bid_sz, ask_px, ask_sz) arrays. Python and Node expose a batch over a
list of snapshots; WASM exposes per-snapshot update (the streaming model that
fits a browser book feed). Regenerates node index.d.ts/.js and registers the
new InvalidOrderBook/InvalidTrade arms in the Python error mapping.

* test(bindings,fuzz): cover order-book microstructure indicators

Python: smoke, reference values, streaming-vs-batch, lifecycle/repr and input
validation (mismatched lengths, crossed book, misordered levels, zero levels)
for all five order-book indicators. Node: reference values, streaming-vs-batch,
and rejection cases. Adds an indicator_update_orderbook fuzz target driving
every order-book indicator over arbitrary (incl. degenerate) snapshots.

* bench(microstructure): synthetic order-book benchmarks

Add a bench_orderbook_input harness and synthesise a five-level book around
each candle close (no order-book dataset ships with the repo). Benches the
cheapest (top-of-book imbalance) and most-expensive (full-depth imbalance) plus
microprice, matching the curated cheapest/expensive-per-family approach.

* docs: add Microstructure family + bump indicator counter to 224

README gains the Microstructure family row (order-book imbalance, microprice,
quoted spread) and the indicator counter goes 219 -> 224 across seventeen
families; CHANGELOG records the new order-book indicators and value types.
2026-06-01 16:06:22 +02:00
kingchenc 498b74a5ae chore(license): point package metadata at the modified license file
The LICENSE now carries an Additional Permissions section on top of
PolyForm Noncommercial 1.0.0, so the bare SPDX id no longer describes
it exactly. Update the package manifests to reference the actual file
instead of claiming the unmodified standard:

- Cargo (workspace + all crates): license -> license-file = "LICENSE"
- npm (main + 6 platform packages): LicenseRef-Wickra-Noncommercial-1.0.0
- PyPI: license text notes the additional personal-account permissions
2026-06-01 15:28:18 +02:00
kingchenc 921a250715 docs(license): grant personal-account trading use to match README
Append an Additional Permissions section to the PolyForm Noncommercial
License 1.0.0. The base PolyForm text is unmodified; the section only
broadens the grant. It explicitly permits a natural person to use the
software for their own personal account, including running a trading
bot on their own capital for profit, matching the README's plain-English
summary (hobby trading bots: all fine). Commercial sale of the software
or of services built around it still requires a commercial license.
2026-06-01 15:05:03 +02:00
kingchenc 0479191b66 feat: signed candlestick directional ±1 encoding (Doji signed mode) (#111)
* feat(core): add signed dragonfly/gravestone encoding to Doji

Doji gains an opt-in `.signed()` mode that classifies a detected Doji by the
position of its body within the bar range: dragonfly (long lower shadow) emits
+1.0 (bullish), gravestone (long upper shadow) emits -1.0 (bearish), and a
long-legged/standard Doji emits 0.0. The default detection-flag behaviour
(+1.0/0.0) is unchanged, so existing callers are unaffected.

The other 14 candlestick patterns already emit the uniform +1 bull / -1 bear /
0 none convention; document that explicitly with a "Signed +-1 encoding"
section on each so the whole family is a consistent drop-in ML feature.

* feat(bindings): expose Doji signed mode in python, node, wasm

Hand-write the Doji binding in all three language bindings (instead of the
shared candle-pattern macro) so it accepts an opt-in `signed` flag and exposes
an `is_signed`/`isSigned` accessor:

- Python: `Doji(signed=False)` keyword argument
- Node: `new Doji(signed?)` optional constructor argument (index.d.ts/.js
  regenerated via napi build)
- WASM: `new Doji(signed?)` optional constructor argument

The default construction is unchanged, so existing callers keep the
direction-less +1/0 detection flag.

* test(bindings,fuzz): cover Doji signed dragonfly/gravestone encoding

- python: dragonfly(+1)/gravestone(-1)/neutral(0) and default-flag cases in
  test_known_values
- node: equivalent signed/default assertions in indicators.test.js
- fuzz: drive a signed Doji alongside the default in indicator_update_candle

* docs: document signed candlestick convention and Doji signed mode

README gains a candlestick sign-convention note; CHANGELOG records the new
opt-in Doji signed dragonfly/gravestone encoding under [Unreleased].
2026-06-01 14:37:20 +02:00
kingchenc 4631519885 release: bump 0.4.0 -> 0.4.1 (#110)
Releases the cross-asset / pairwise indicator family (PR #109):
PairwiseBeta, PairSpreadZScore, LeadLagCrossCorrelation, Cointegration,
RelativeStrengthAB. Indicator count 214 -> 219.

Bumps workspace + binding versions and the CHANGELOG ([Unreleased] ->
[0.4.1]) with the new compare URL.
2026-06-01 13:58:50 +02:00
kingchenc 0b85142ad1 feat: cross-asset / pairwise indicators (5 new) (#109)
* feat(core): add PairwiseBeta cross-asset indicator

Rolling OLS slope of one asset's log-returns on another's. Unlike Beta,
which regresses the raw inputs it is fed, PairwiseBeta differences
consecutive prices into log-returns internally -- the conventional way to
measure cross-asset beta, where a beta on price levels would be dominated
by the shared trend.

Two-series Indicator<Input = (f64, f64)>, exposed in Rust, Python, Node
and WASM, with unit/known-value/streaming tests and a pair fuzz target.

* feat(core): add PairSpreadZScore cross-asset indicator

Standardised log-spread ln(a) - beta*ln(b) of a pair, where beta is a
rolling-OLS hedge ratio and the spread is z-scored over its own look-back.
The canonical mean-reversion / statistical-arbitrage entry signal, with
independent beta_period and z_period windows.

Two-series Indicator<Input = (f64, f64)>, exposed in Rust, Python, Node
and WASM, with sign/known-value/streaming tests and a pair fuzz target.

* feat(core): add LeadLagCrossCorrelation cross-asset indicator

Reports the integer offset k in [-max_lag, max_lag] that maximises
|corr(a[t], b[t+k])|, answering which of two assets leads the other and by
how many bars. A positive lag means a leads b. Fully causal: a's window is
held centred while b's window slides across the buffered history, so every
lag is evaluated only against data already seen.

Struct output { lag, correlation }, exposed in Rust, Python, Node and WASM
with lead-detection/streaming tests and a pair fuzz driver.

* feat(core): add Cointegration (Engle-Granger + ADF) indicator

Rolling pairs-trading screen: an OLS hedge ratio of a on b, the spread
(residual) a - (alpha + beta*b), and an augmented Dickey-Fuller t-statistic
on the spread with configurable lags. A strongly negative statistic flags a
mean-reverting, tradeable spread. Includes a small Gaussian-elimination
solver for the augmented regression.

Struct output { hedge_ratio, spread, adf_stat }, exposed in Rust, Python,
Node and WASM with stationarity/hedge-ratio/streaming tests and a pair fuzz
driver.

* feat(core): add RelativeStrengthAB cross-asset indicator

Comparative relative strength of two assets: the ratio line a/b together
with its moving average and its RSI, the classic asset-vs-asset /
asset-vs-index rotation screen. Composes the existing Sma and Rsi over the
ratio; a zero denominator or non-finite price is skipped.

Struct output { ratio, ratio_ma, ratio_rsi }, exposed in Rust, Python, Node
and WASM with flat/rising-ratio/streaming tests and a pair fuzz driver.

* test(cointegration): cover ADF guard branches

The ADF helper's short-series and degrees-of-freedom guards and the
zero-dispersion (perfect AR) path are unreachable through the public
Cointegration API (period >= 2*adf_lags + 4), so exercise them with direct
unit tests on adf_no_constant. The second linear solve cannot be singular
once the coefficient solve on the same matrix has succeeded, so it now uses
expect() instead of a dead error branch.
2026-06-01 13:45:21 +02:00
kingchenc 1ab9bc70d1 ci(release): make the release immutability-ready (draft then publish) (#108)
GitHub release immutability locks a release's assets at publish time. The
current flow publishes the release in github-release and only afterwards uploads
the Sigstore provenance bundle (P21.1e) via 'gh release upload', which
immutability would reject (actions/attest-build-provenance#734).

Reorder to draft -> attach everything -> publish:
- github-release now creates the release as a draft (draft: true) with all build
  artefacts.
- attestations attaches the provenance bundle to the draft (gh release upload
  works on drafts), unchanged otherwise.
- a new publish-release job flips the draft to published + latest, gated on
  'always() && needs.github-release.result == success' so a Sigstore hiccup in
  attestations costs only the provenance asset, never the release — the same
  isolation as before. A skipped github-release (failed publish) skips this too.

Correct with immutability off (today: release ends published with every asset)
and on (later, user toggle: all assets present before the lock). No behaviour
removed; nothing deleted.
2026-06-01 12:06:20 +02:00
kingchenc 2ab578bee8 docs: surface docs.wickra.org + keep the wiki pointer count in sync (#107)
* docs(readme): surface the documentation site (docs.wickra.org)

The README never linked the canonical docs at docs.wickra.org — visitors had
no path from the repo to the per-indicator deep dives, quickstarts, and
guides. Add a docs badge, a Documentation section mirroring the binding
READMEs and docs/README.md, and an Indicators-Overview link in the indicator
section.

* ci(sync-about): keep the wiki pointer page's indicator count in sync

The GitHub wiki was collapsed to a single Home.md that points at
docs.wickra.org but still names the indicator count ('… for all N indicators').
Add a count-sync step mirroring the docs/webpage steps — clone wickra.wiki into
its own dir, sed Home.md, commit as wickra-bot, push — with the same
continue-on-error soft-skip so a missing PAT scope never fails the run.
2026-06-01 04:10:00 +02:00
kingchenc 2be39b8b98 ci(release): attach Sigstore provenance bundle as a release asset (P21.1e) (#106)
OpenSSF Scorecard's Signed-Releases check scans the GitHub Release *assets* for
signed/provenance files (`*.intoto.jsonl`, `*.sig`, ...). It does not look at
GitHub's separate attestations store, so although the attestations job has signed
the published bytes since v0.4.0, the v0.4.0 release assets carried no provenance
file and the check stayed at 0.

Attach the Sigstore provenance bundle (already produced by
actions/attest-build-provenance) to the release as `wickra-<tag>.provenance.intoto.jsonl`:

- github-release now exposes its resolved tag as a job output.
- attestations `needs: github-release` (so the Release already exists), gains
  `contents: write`, gives the attest step an id, and uploads the bundle with
  `gh release upload --clobber` (idempotent on re-runs).

Publishes stay fully isolated — cargo/PyPI/npm all run upstream of github-release,
so a Sigstore hiccup here can never block or corrupt a publish; at worst the
release just lacks the provenance asset. Signed-Releases climbs over the next
releases as each tag carries the bundle.
2026-06-01 04:09:09 +02:00
kingchenc 99af5f8ee1 ci: retry transient registry/DNS flakes at the cargo/npm/pip tool level (#105)
The v0.4.0-era CI failure was a runner network blip — `napi build` invokes cargo,
whose fetch of index.crates.io hit "Could not resolve host: index.crates.io" and
failed the Node-on-macOS job, forcing a manual re-run. The earlier flake-hardening
(setup-node/setup-python + rust-cache retries) only covered toolchain download and
cache restore, not the registry fetches inside the actual build/publish steps.

Set tool-level network retries as workflow env so every cargo/napi/maturin/
wasm-pack/npm/pip invocation in every job inherits them — including the nested
cargo calls inside napi/maturin/wasm-pack:

- CARGO_NET_RETRY=10 (default 3): cargo classes DNS-resolve / connect / timeout
  errors as spurious and retries with backoff; 10 attempts ride out a transient
  blip instead of failing the job.
- CARGO_NET_GIT_FETCH_WITH_CLI=true: more robust git-dep fetches.
- npm_config_fetch_retries=5 / maxtimeout=120s: npm ci/install registry retries.
- PIP_RETRIES=5 / PIP_DEFAULT_TIMEOUT=120: pip install resilience.

Applied to ci.yml, release.yml and bench.yml (the workflows that build). No more
manual re-runs for transient registry flakes.
2026-06-01 04:08:18 +02:00
kingchenc bff1148d20 ci(sync-about): fix docs version-sync clone collision + webpage npm race (#104)
* ci(sync-about): fix docs version-sync clone collision + webpage npm race

Two real release-time bugs surfaced by the v0.4.0 release, where the docs
"Published versions" table never updated and the marketing-site Cloudflare
build failed:

1. docs version sync never ran. The "Sync docs version (wickra-docs)" step
   cloned into a directory literally named `docs`, but on a tag push the job
   checks out the wickra repo at the workspace root, which already contains a
   top-level `docs/` directory. `git clone … docs` therefore failed with
   "destination path 'docs' already exists", silenced by `2>/dev/null` and
   misreported as a missing-token warning, so the docs version table stayed at
   the previous release. Clone into `docs-ver` instead (mirrors the `docs-count`
   dir the count step already uses); it collides with nothing in the repo.

2. webpage build broke on a version race. The "Sync webpage version" step bumps
   package.json's `wickra-wasm` pin to the released version and pushes
   immediately, but release.yml publishes wickra-wasm to npm in parallel on the
   same tag and finishes minutes later. Cloudflare's `npm clean-install` then
   hit `ETARGET: No matching version found for wickra-wasm@^0.4.0`. Poll npm for
   wickra-wasm@<version> (up to ~15 min) before committing; if it never appears
   the step skips with a warning rather than pushing a build-breaking commit.

Both steps were designed to mirror each other across docs/webpage; these fixes
restore that symmetry so every release self-heals both sites.

* ci(sync-about): regenerate webpage package-lock on version bump

Third v0.4.0 release-sync defect: the webpage version step seds package.json's
wickra-wasm pin but never touched package-lock.json, so even after wickra-wasm
went live on npm the Cloudflare build still failed with
`npm ci` EUSAGE: "lock file's wickra-wasm@0.3.1 does not satisfy
wickra-wasm@0.4.0".

After the package.json sed, run `npm install --package-lock-only` so the lockfile
(version + resolved + integrity) matches the new pin; commit package-lock.json
alongside package.json. The earlier npm-wait already guarantees the version is
resolvable. Guarded: if the regen fails the step skips the whole commit rather
than push a package.json/lock mismatch.

The live site was unblocked out-of-band by a matching lockfile commit on the
webpage repo; this makes it self-heal on every future release.
2026-06-01 02:02:19 +02:00
kingchenc ebddc5e376 ci: set least-privilege top-level token permissions (P21.1b) (#103)
The auto-injected GITHUB_TOKEN defaulted to write-all in ci.yml, bench.yml
and release.yml (no top-level permissions block), and codeql.yml declared
its scopes only at job level. Add a top-level `permissions: contents: read`
to all four so the token starts read-only and only the jobs that genuinely
write through it raise the scope:

- release.yml: github-release keeps contents: write; node-/wasm-publish and
  attestations keep their id-token / attestations: write blocks. The
  cargo/python/node publish jobs push to crates.io/PyPI/npm via their own
  registry secrets, not the GITHUB_TOKEN, so read-only is correct for them.
- codeql.yml: analyze keeps security-events: write (job level).
- ci.yml / bench.yml: no job writes back to the repo (coverage uploads via
  CODECOV_TOKEN; bench only uploads an artifact), so no job override is needed.

sync-about.yml already had a top-level block but at contents: write; demote
the top level to read and move contents: write down to the single `sync` job
(the PR-head counter push is the only GITHUB_TOKEN write). The cross-repo
About/docs/webpage/org writes are unaffected — they run through the
fine-grained ABOUT_SYNC_TOKEN, which the permissions key does not govern.

Raises OpenSSF Scorecard Token-Permissions from 0 toward 10.
2026-06-01 02:01:31 +02:00
kingchenc eab2649f1c release: bump 0.3.1 -> 0.4.0 (#89)
Minor release. The headline user-facing change is the Node binding now
rejecting invalid indicator periods instead of silently clamping period 0
to 1 (matches Python/WASM/core); plus per-ecosystem binding READMEs and a
corrected MSRV statement in CONTRIBUTING. See CHANGELOG [0.4.0].

- Cargo.toml (workspace.package + wickra-core dep) + Cargo.lock (6 members)
- bindings/python/pyproject.toml
- bindings/node/package.json (version + 6 optionalDependencies) + package-lock.json
- bindings/node/npm/*/package.json (6 platform subpackages)
- examples/node/package-lock.json (wickra-* platform pins)
- CHANGELOG: [Unreleased] -> [0.4.0] - 2026-05-31 + compare URL

No tag pushed (release publish is a separate, user-confirmed step).
2026-06-01 01:28:53 +02:00
kingchenc f7f947e048 docs(changelog): record provenance attestations + CI security tooling (Unreleased) (#102) 2026-06-01 01:08:14 +02:00
kingchenc 01dd08714b ci: harden workflows against network/CDN flakes (resilient cache + setup retries) (#101) 2026-06-01 01:07:56 +02:00
kingchenc 0fa70c9882 ci: pin remaining GitHub Actions to commit SHAs (P21.1c) (#100) 2026-06-01 01:07:34 +02:00
kingchenc debe4523d5 ci: pass untrusted workflow contexts via env to prevent shell injection (P21.1a) (#99) 2026-06-01 00:32:24 +02:00
kingchenc a046c441e5 docs(readme): show the Wickra banner; drop the redundant heading (#98)
Embed the brand banner at the top of the README (linked to wickra.org) and
drop the now-redundant "# Wickra" heading — the banner shows the wordmark.

The image is served from https://wickra.org/og-banner.webp, which the webpage
build regenerates on every deploy from the current indicator count (4K WebP),
so the README banner stays current with no committed binary in this repo and
renders on GitHub, crates.io and docs.rs alike.
2026-06-01 00:07:36 +02:00
kingchenc f7b91f6fa5 chore: use support@wickra.org for contact/author email; drop dead sponsor link (#97)
Now that the wickra.org catch-all mailbox exists, move the project contact +
package-author email off the personal gmail to support@wickra.org across all
surfaces: CODE_OF_CONDUCT, SECURITY, CITATION.cff, Cargo.toml, the npm + PyPI
author fields, the release.yml npm author, and repo-metadata.toml. (The
package-author changes take effect on the next published release.)

repo-metadata.toml's [audit].forbidden still pins kingchencp@gmail.com (the
private commit email) as a banned substring — unchanged.

Also remove the FUNDING.yml custom "https://wickra.org/sponsor" entry: that
page 404s, so the Sponsor button linked to a dead URL. The GitHub Sponsors
entry (github: [kingchenc]) stays.
2026-05-31 23:22:57 +02:00
kingchenc 5030360a0c ci: CodeQL SAST workflow + badge (P13.6) (#96)
* ci: add OpenSSF Scorecard workflow + badge (P13.1)

* ci(release): attest build provenance for crates + Python artifacts (P13.2)

* docs(readme): add GitHub release badge (P13.4)

* ci: add CodeQL SAST workflow + badge (P13.6)
2026-05-31 22:34:00 +02:00
kingchenc 1dd487fabc docs(readme): GitHub release badge (P13.4) (#94)
* ci: add OpenSSF Scorecard workflow + badge (P13.1)

* ci(release): attest build provenance for crates + Python artifacts (P13.2)

* docs(readme): add GitHub release badge (P13.4)
2026-05-31 22:21:05 +02:00
kingchenc cee174c0de ci(release): build-provenance attestations for crates + Python (P13.2) (#91)
* ci: add OpenSSF Scorecard workflow + badge (P13.1)

* ci(release): attest build provenance for crates + Python artifacts (P13.2)
2026-05-31 22:13:06 +02:00
kingchenc c8e5d8a658 ci: add OpenSSF Scorecard workflow + badge (P13.1) (#90) 2026-05-31 22:05:00 +02:00
kingchenc dc2e19e762 ci(sync-about): self-update the marketing site count + version (P12.1) (#95)
* ci(sync-about): auto-sync the published version to wickra-docs on release

* ci(sync-about): retarget indicator-count sync from wiki to wickra-docs + point About homepage at docs.wickra.org (P8.3)

* ci(sync-about): self-update the marketing site (webpage) count + version (P12.1)
2026-05-31 22:04:32 +02:00
kingchenc d8212beff6 ci(sync-about): retarget count sync to wickra-docs + point About at docs.wickra.org (P8.3) (#88)
* ci(sync-about): auto-sync the published version to wickra-docs on release

* ci(sync-about): retarget indicator-count sync from wiki to wickra-docs + point About homepage at docs.wickra.org (P8.3)
2026-05-31 21:57:42 +02:00
kingchenc 86a595d505 ci(sync-about): auto-sync the published version to wickra-docs on release (#87) 2026-05-31 21:48:53 +02:00
kingchenc 9309bf9d60 docs(P6.4): repoint all doc links from the GitHub wiki to docs.wickra.org (#86)
The documentation now lives in the wickra-lib/wickra-docs VitePress repo and
will deploy to docs.wickra.org. Rewrite every tracked, user-facing wiki link
in the main repo to the new canonical site:

- docs/README.md, CONTRIBUTING.md, PULL_REQUEST_TEMPLATE.md
- bindings/{node,python,wasm}/README.md, examples/wasm/README.md
- repo-metadata.toml: wiki_url/wiki_git -> docs_url/docs_git

Page paths map 1:1 to VitePress clean URLs (e.g. /wiki/Quickstart-Rust.md ->
docs.wickra.org/Quickstart-Rust). The sync-about.yml wiki-sync step is left
untouched on purpose: it stays until the docs site is live (tracked as P8.3).
The GitHub wiki itself is not deleted yet for the same reason.
2026-05-31 21:48:24 +02:00
kingchenc 3f05342f72 docs(P7): per-ecosystem binding READMEs + correct MSRV documentation (#85)
* docs(contributing): correct MSRV to 1.86/1.88 and document the dep-forced floor (P7.1)

* docs(bindings): trim binding READMEs to per-ecosystem install + links (P7.2)

* docs(changelog): note per-ecosystem binding READMEs + MSRV doc fix (P7.1/P7.2)
2026-05-31 05:30:34 +02:00
kingchenc eb4454ab27 P5: track index.d.ts + reject invalid periods in the Node binding (#83)
* build(node): track the generated index.d.ts (P5.1)

index.js was committed but index.d.ts was gitignored, an inconsistency that
also contradicts CONTRIBUTING ('regenerate both .d.ts/.js when a binding API
changes'). Track index.d.ts too so the repo carries the TypeScript types as a
matched pair with index.js. Generated by napi build; ~214 indicator classes.

* fix(node): reject invalid periods instead of clamping them (P5.4)

The Node scalar-indicator macro clamped period 0 to 1 (via clamp_period + must)
and the multi-parameter constructors did the same, silently swallowing the
core's PeriodZero validation. The core rejects period 0 (Error::PeriodZero),
and the Python and WASM bindings already propagate it — Node was the outlier,
masking caller mistakes. Make the macro constructor fallible and let every
constructor propagate the core error via map_err, removing clamp_period/must.
Update the smoke test (period 0 now throws, matching core/Python/WASM).

* docs(changelog): note the Node period-validation change (P5.4)

Behavior change per CONTRIBUTING: Node constructors now reject invalid periods
instead of clamping. Add an [Unreleased] Changed entry.

* chore: drop accidentally committed scratch log
2026-05-31 05:22:56 +02:00
kingchenc 3093f194a2 docs: document the repo-wide lockfile policy (P4) (#82)
The .gitignore comment claimed package-lock.json is committed only under
bindings/node/, but examples/node/package-lock.json has also been tracked
since #80. Correct the comment and add a CONTRIBUTING 'Lockfile policy'
section spelling out every component: Cargo.lock + the two Node package-locks
are tracked; fuzz/Cargo.lock is ignored (cargo-fuzz default); the Python
package has no lockfile by PyO3 convention (pinned via Cargo.lock); the
ghost-ignored site keeps its lockfile local.
2026-05-31 05:11:08 +02:00
kingchenc 21c86f348f examples + bindings: Node/WASM strategy parity + test/benchmark parity (P2 + P3) (#81)
* examples(node): add RSI mean-reversion strategy

Node counterpart of strategy_rsi_mean_reversion.{py,rs}: RSI(14) < 30 long,
> 70 exit, 0.1% fees, hourly BTCUSDT. Output verified byte-identical to the
Rust reference (37 trades W24/L13, -17.84% return, 46.89% max drawdown).

* examples(node): add MACD + ADX trend-filter strategy

Node counterpart of strategy_macd_adx.{py,rs}: MACD(12,26,9) histogram
crossover entries gated by ADX(14) > 20, hourly BTCUSDT, 0.1% fees. Output
verified byte-identical to the Rust reference (246 trades W90/L156, -47.19%
return, 53.75% max drawdown).

* examples(node): add Bollinger-squeeze breakout strategy

Node counterpart of strategy_bollinger_squeeze.{py,rs}: enter on a fresh
180-bar Bollinger-bandwidth low + close above the upper band, exit on a
2*ATR(14) stop or upper-band collapse, daily BTCUSDT, 0.1% fees. Output
verified byte-identical to the Rust reference (1 trade, -7.82% return,
13.01% max drawdown).

* examples(wasm): add RSI mean-reversion strategy demo

Browser counterpart of strategy_rsi_mean_reversion.{py,js,rs}: RSI(14) < 30
long, > 70 exit, 0.1% fees, summary table. Same signal/fill/PnL/equity loop as
the runtime-verified Node example; loads via the established wickra_wasm.js
init + fetch-CSV pattern. (wasm32 build runs in CI.)

* examples(wasm): add MACD + ADX trend-filter strategy demo

Browser counterpart of strategy_macd_adx.{py,js,rs}: MACD(12,26,9) histogram
crossover gated by ADX(14) > 20, hourly BTCUSDT, 0.1% fees. Logic identical to
the runtime-verified Node example; standard wickra_wasm.js init + fetch-CSV
loader. (wasm32 build runs in CI.)

* examples(wasm): add Bollinger-squeeze breakout strategy demo

Browser counterpart of strategy_bollinger_squeeze.{py,js,rs}: fresh 180-bar
Bollinger-bandwidth low + upper-band breakout, 2*ATR(14) stop, daily BTCUSDT,
0.1% fees. Logic identical to the runtime-verified Node example; standard
wickra_wasm.js init + fetch-CSV loader. (wasm32 build runs in CI.)

* ci: add examples syntax-smoke job (P2.3)

The Rust examples are built by 'cargo build -p wickra-examples --bins'; the
Node, browser-WASM and Python examples had no build gate. New job parse-checks
every examples/{node,wasm}/*.js, extracts and node --checks each WASM .html
module script, and python -m py_compile's every examples/python/*.py — so a
broken example edit fails CI instead of landing silently.

* docs(examples): list the new Node + WASM strategy examples

Add the three Node strategy scripts and three WASM strategy demos to the
examples README tables, bringing Node and WASM to parity with the existing
Rust and Python strategy rows.

* chore(examples): refresh examples/node lockfile for the linked wickra binding

npm install rewrote the file: dependency snapshot of the local wickra binding
that the examples link against (version 0.1.4 -> 0.3.1, license + engines
fields), which had gone stale in the committed lockfile.

* test(node): add input-validation suite

Node counterpart of bindings/python/tests/test_input_validation.py: invalid
constructor parameters (ATR zero period, MACD non-increasing fast/slow,
BollingerBands negative multiplier, PSAR step > max, ValueArea period/pct,
InitialBalance/OpeningRange zero period, Ichimoku non-increasing periods,
Ehlers-family ordering) and unequal-length candle/ValueArea batch inputs all
throw a JS Error. Validated against the built binding.

* test(node): add indicator completeness contract

Introspects every exported indicator class and asserts the full interface
(update / batch / reset / isReady / warmupPeriod) plus the pre-warmup contract
for zero-arg indicators, and guards that the full catalogue (>= 200 classes)
is exported. Catches a new indicator wired without the standard methods, or a
stale/partial native build dropping exports, with no per-indicator boilerplate.

* test(wasm): broaden scalar streaming-vs-batch coverage

Extend the inline wasm-bindgen-test suite with a streaming==batch check across
~70 scalar indicators spanning moving averages, momentum, volatility,
statistics/regression, Ehlers/cycle and risk/performance families (previously
only EMA + the candle-input group were covered per-indicator), plus four more
invalid-constructor assertions. Constructor args mirror the CI-passing Node
factories. Host-compiles (cargo test -p wickra-wasm --no-run); executed in CI
via wasm-pack test --node.

* bench(node): add indicator throughput benchmark

Node counterpart of the Rust criterion benches / Python compare_libraries:
measures streaming (per-tick update) and batch throughput in Mupd/s across a
representative indicator set over a synthetic OHLCV series (--bars, default
200k). Dependency-free; wired as 'npm run bench'.

* docs(wasm): list strategy demos + document the benchmark story

Add the three new strategy demos to the WASM examples table and a Performance
section: parallel_assets.html is the in-browser benchmark, with raw throughput
covered by the Rust criterion / Python / Node benchmarks (the WASM engine is the
same core compiled to wasm32).
2026-05-31 05:09:26 +02:00
kingchenc a2ff35f5f9 ci(sync-about): auto-sync repo homepage + org profile from the indicator count (#84)
Extend the count-sync workflow to drive three more surfaces from the same
single count, so the org page never drifts again:

- Repo About **homepage** URL — enforced unconditionally via
  `gh repo edit --homepage` in the existing About step (fixes the stale
  kingchenc URL and self-heals). Uses the same Administration-write
  permission as --description, so NO extra token scope.
- Org **profile README** count (wickra-lib/.github, profile/README.md) —
  clone + sed + bot commit/push, same pattern as the Sync Wiki step.
- Org **description** field ('… N indicators, install-free.') — gh api PATCH.

The two org steps need ABOUT_SYNC_TOKEN scope the main-repo syncs do not
(write on wickra-lib/.github; admin:org for the description PATCH). Both are
written to soft-skip with a ::warning:: and continue-on-error, so the run
stays green until the scope is granted — then they sync with no further
change. Homepage swap-point to a docs domain is a one-line change.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* test(median_price): cover name metadata

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

* test(true_range): cover name metadata

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

* test(typical_price): cover name metadata

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

* test(weighted_close): cover name metadata

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* test(adl): cover name metadata

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Add two new tests:

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

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

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

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

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

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

bollinger.rs is now at 184/184 lines, no behavioural change.
2026-05-23 23:30:10 +02:00
kingchenc 36dc951f1b test(ohlcv): cover Candle::new_unchecked skip-validation constructor
Codecov flagged lines 86-102 in crates/wickra-core/src/ohlcv.rs as missed
(file at 90.00%) — the entire body of `Candle::new_unchecked`. Every
existing test routes through the validating `Candle::new`, so the unchecked
constructor (intended for callers like the aggregator and parsed-payload
paths that have already validated upstream) was dead.

Add candle_new_unchecked_preserves_fields_verbatim:
  - first assertion builds a candle with six distinct field values and
    verifies each reads back exactly.
  - second assertion feeds an OHLC combination (high < low) that the
    checked constructor rejects with Error::InvalidCandle, then proves
    Candle::new_unchecked still builds the struct as-is. This documents
    and enforces the API contract that the unchecked variant performs
    no validation.

ohlcv.rs is now at 170/170 lines, no behavioural change.
2026-05-23 23:28:26 +02:00
kingchenc 0abc5f71c1 test(vwap): cover Vwap value()/zero-volume/metadata + RollingVwap getters
Codecov flagged 17 uncovered lines in
crates/wickra-core/src/indicators/vwap.rs (file at 87.94%):

  - Vwap::value() Some branch (line 53) — the only test calling value()
    did so after reset() when sum_v == 0, exercising only the None branch
  - Vwap::update zero-volume early-return `return None;` (line 67) — all
    existing candles carried strictly positive volume
  - Vwap::warmup_period body returning 1 (79-81), Vwap::name body
    returning "VWAP" (87-89) — metadata never queried
  - RollingVwap::period accessor (134-136), RollingVwap::warmup_period
    body (165-167), RollingVwap::name body returning "RollingVWAP"
    (173-175) — same metadata gap on the rolling variant

Add four new tests:

  - cumulative_value_some_branch_after_update drives a single
    non-zero-volume candle then asserts value() == Some(typical_price).
  - cumulative_zero_volume_first_candle_returns_none feeds a candle
    with volume == 0.0, asserts update returns None and is_ready stays
    false, then adds a real candle to confirm the indicator still works.
  - cumulative_metadata asserts warmup_period() == 1 and name() == "VWAP".
  - rolling_accessors_and_metadata asserts period() == 7,
    warmup_period() == 7, name() == "RollingVWAP" on a RollingVwap::new(7).

vwap.rs is now at 141/141 lines, no behavioural change.
2026-05-23 23:27:15 +02:00
kingchenc 404fd29c31 test(percent_b): cover accessors, kill dead arm, fix flaky middle test
Codecov flagged 17 uncovered lines in
crates/wickra-core/src/indicators/percent_b.rs (file at 81.11%):

  - const accessors period (53-55), multiplier (58-60), value (63-65)
  - Indicator-impl bodies warmup_period (90-92) and name (98-100)
  - the unreachable `_ => panic!("warmup mismatch at {i}")` arm in
    matches_bands_definition (line 141)
  - the inner `assert_relative_eq!(*pv, 0.5, …)` (line 158) inside
    price_at_middle_is_half, gated by `(prices[i] - bv.middle).abs()
    < 1e-9` over a sin-based oscillation that, with period=20 over 60
    samples, never lands within 1e-9 of the rolling SMA — so the
    assertion was silently dead and the test made no checks.

Add accessors_and_metadata covering the five getter bodies, refactor
matches_bands_definition to assert the warmup-shape invariant via
assert_eq!(p.is_some(), b.is_some()) + if let (kills the panic arm),
and replace price_at_middle_is_half with a deterministic construction:
PercentB::new(3, 2.0) on [1.0, 5.0, 3.0] gives SMA=3.0 at index 2
which equals the third price exactly, stddev=√(8/3)≈1.633 keeps the
width strictly positive so the divide path runs, and %b lands on
exactly 0.5 because price sits on the centre line of symmetric bands.

percent_b.rs is now at 90/90 lines, no behavioural change.
2026-05-23 23:25:35 +02:00
kingchenc 050e5b74b8 test(bollinger_bandwidth): cover accessors, zero-middle branch, kill dead arm
Codecov flagged 17 uncovered lines in
crates/wickra-core/src/indicators/bollinger_bandwidth.rs (file at 79.51%):

  - the const accessors period (54-56), multiplier (59-61), value (64-66)
  - the zero-middle defensive fallback 0.0 (line 77) inside update
  - the Indicator-impl bodies warmup_period (90-92) and name (98-100)
  - the unreachable `_ => panic!("warmup mismatch")` arm (line 140) in
    the existing matches_bands_definition test

None of the existing tests inspected the metadata surface — every test
fed numeric updates and asserted on bandwidth values, leaving the five
getter bodies dead. The zero-middle path was unreachable because all
existing tests used positive price levels ≈100, so the rolling SMA was
always strictly positive and the divide-by-zero guard never fired.
The panic arm in matches_bands_definition was an invariant guard that
by design cannot fire when the two streams share a warmup period; that
invariant is now asserted directly with assert_eq!(w.is_some(),
b.is_some()), and the catch-all arm is gone.

Add two new tests and refactor one existing:

  - accessors_and_metadata asserts period == 20, multiplier == 2.0,
    value() == None before warmup, warmup_period == 20, name ==
    "BollingerBandwidth", then drives 20 updates so value() also
    exercises the Some branch.

  - zero_middle_band_yields_zero_bandwidth feeds [-2, -1, 0, 1, 2] so
    the 5-bar SMA lands on exactly 0.0 at the fifth input, and asserts
    the emitted bandwidth is exactly 0.0 (rather than inf/nan from the
    would-be divide-by-zero).

  - matches_bands_definition now uses an explicit assert_eq! on
    is_some() agreement plus an if let for the numeric compare,
    removing the unreachable panic arm without weakening the
    invariant check.

bollinger_bandwidth.rs is now at 83/83 lines, no behavioural change.
2026-05-23 23:22:54 +02:00
kingchenc 62fe7a81aa test(traits): cover Chain accessors + Identity/Doubler helper surface
Codecov flagged 30 uncovered lines in crates/wickra-core/src/traits.rs (file at
75.60%): the const borrow accessors Chain::first / Chain::second (140-147), the
Chain::warmup_period + Chain::name Indicator-impl bodies (167-178), the full
Identity test-helper Indicator surface — reset, warmup_period, is_ready, name
(198-209), and Doubler's warmup_period + name (228-236).

None of the existing tests touched those code paths: every chain test invoked
update/reset/is_ready through the Chain wrapper without ever inspecting the
borrow accessors, querying chain.warmup_period(), or asking for chain.name(),
and the Identity helper was only ever driven by batch (which calls update
only). Doubler's warmup_period and name were similarly dead because
Chain::warmup_period and Chain::name themselves were dead.

Add two new tests at the end of the Chain section in mod tests, immediately
before the parallel-feature-gated test:

  - chain_accessors_and_metadata exercises chain.first(), chain.second(),
    chain.warmup_period(), chain.name(), and pulls Doubler::warmup_period
    + Doubler::name in via the borrowed accessors.

  - identity_helper_full_indicator_surface asserts warmup_period == 0,
    name == "Identity", and walks is_ready through both seen=false and
    seen=true via an update/reset cycle.

traits.rs is now at 123/123 lines, no behavioural change.
2026-05-23 23:19:24 +02:00
kingchenc 3fcf2094f1 ci(wasm): install wasm-pack via taiki-e prebuilt instead of jetli's stale 0.10
jetli/wasm-pack-action@v0.4.0 with no version: input installs whatever
wasm-pack the action's bundled installer fetches — currently a ~0.10.x
release whose 'build' subcommand does not yet accept --features. Our
build invocation 'wasm-pack build … --features panic-hook' now fails
with

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

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

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

The wasm-pack invocations themselves are unchanged.
2026-05-23 23:00:13 +02:00
kingchenc a2ccd202aa test(resample): cover RolledBar::absorb low-update branch
Codecov flagged a single uncovered line in crates/wickra-data/src/resample.rs:
line 46, the `self.low = c.low;` assignment inside RolledBar::absorb.
None of the existing resampler tests fed a follow-up candle with a strictly
lower low than the first candle in the bucket, so the `c.low < self.low`
branch never fired. Coverage stayed at 122/123.

Add a small dedicated test that pushes a 10.0-low candle into bucket 0, then
a 8.0-low candle into the same bucket, and asserts the rolled bar's low
reflects the dip. Resample file is now at 123/123 lines, no behavioural change.
2026-05-23 22:56:40 +02:00
kingchenc 4aec5d544c docs(wiki): migrate documentation out of repo into GitHub Wiki
The 84 markdown files under docs/wiki/ are now published to the
project's GitHub Wiki at https://github.com/kingchenc/wickra/wiki —
a separate git repository (https://github.com/kingchenc/wickra.wiki.git)
that GitHub hosts natively with its own UI, search and history. The
flat layout that the GitHub Wiki requires has been generated, all
internal cross-links rewritten, and a _Sidebar.md groups the 71
indicators by their canonical 8 families.

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

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

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

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

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

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

Effect: same README on github.com/kingchenc/wickra, crates.io/crates/wickra,
pypi.org/project/wickra, and npmjs.com/package/wickra. Will be live on
the registries with the next tag-push.
2026-05-23 22:45:16 +02:00
kingchenc a876b145b0 chore: trigger CI to upload first Codecov coverage report
CODECOV_TOKEN was just added as a repository secret; the existing
Coverage job in .github/workflows/ci.yml will pick it up on the next
run. This empty commit fires that run.
2026-05-23 22:38:49 +02:00
429 changed files with 76344 additions and 15268 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
+3
View File
@@ -0,0 +1,3 @@
# Shell scripts must keep LF line endings so they run on Linux/macOS CI and
# local shells regardless of the committer's platform autocrlf setting.
*.sh text eol=lf
+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
+5
View File
@@ -0,0 +1,5 @@
# 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]
+2 -2
View File
@@ -1,7 +1,7 @@
---
name: Bug report
about: Report incorrect behaviour in Wickra
title: "[bug] "
title: "[Bug] "
labels: bug
assignees: ""
---
@@ -32,7 +32,7 @@ assignees: ""
- Wickra version:
- Language / binding: <!-- Rust crate / Python / Node / WASM -->
- OS and architecture:
- Rust / Python / Node version (if relevant):
- Rust / Python / Node version (If relevant):
## Additional context
@@ -0,0 +1,56 @@
---
name: Bug report (Detailed)
about: Long-form bug report with environment matrix, minimal reproducer, and expected-vs-actual sections.
title: "[Bug] <short description>"
labels: ["bug", "triage"]
assignees: []
---
## Summary
<!-- One or two sentences. What did you expect, what happened instead? -->
## Affected binding
- [ ] Rust crate (`wickra`)
- [ ] Python (`pip install wickra`)
- [ ] Node.js (`npm install wickra`)
- [ ] WebAssembly
- [ ] Docs / examples only
## Environment
| Field | Value |
| -------------------- | -------------------------------------- |
| Wickra version | `e.g. 0.4.2` |
| Binding version | `e.g. python 0.4.2 / node 0.4.2` |
| OS / arch | `e.g. Windows 11 x86_64, Linux glibc` |
| Rust toolchain | `rustc --version` (If building from source) |
| Python / Node version | `python --version` / `node --version` |
## Minimal reproducer
<!--
Paste the smallest possible code snippet that triggers the bug.
If the input data matters, attach a CSV/JSON or paste a few rows inline.
-->
```python
# or rust / js
import wickra as ta
...
```
## Actual output
```
<paste stack trace, panic, wrong values, etc.>
```
## Expected output
<!-- What should the indicator / API have returned? Reference a paper, TA-Lib, or another implementation if possible. -->
## Additional context
<!-- Logs, screenshots, links to related issues, anything else useful. -->
+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.
+33
View File
@@ -0,0 +1,33 @@
---
name: Documentation issue
about: Something in the README, rustdoc, examples, or guides is wrong, missing, or confusing.
title: "[Docs] <short description>"
labels: ["documentation", "good first issue"]
assignees: []
---
## Where
<!-- Link or path. e.g. README.md#streaming-vs-batch, docs/guide/ema.md, rustdoc for `wickra::Ema::update`. -->
## What's wrong / missing
<!--
- [ ] Incorrect information
- [ ] Outdated for current API
- [ ] Missing example
- [ ] Unclear wording
- [ ] Broken link / broken code block
- [ ] Other
-->
## Suggested change
<!--
Paste the corrected wording, a clearer example, or a sketch of the
section you'd like to see. PRs welcome.
-->
## Additional context
<!-- Quote of the confusing passage, screenshot, etc. -->
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: Feature request
about: Suggest a new indicator or capability for Wickra
title: "[feature] "
title: "[Feature] "
labels: enhancement
assignees: ""
---
@@ -0,0 +1,55 @@
---
name: Feature request (Detailed)
about: Long-form proposal with API sketch, scope checkboxes, prior-art links, and contribution intent.
title: "[Feat] <short description>"
labels: ["enhancement", "triage"]
assignees: []
---
## Problem / motivation
<!--
What are you trying to do that Wickra doesn't support today?
Describe the user-facing pain point, not the implementation.
-->
## Proposed solution
<!--
Sketch the API or behavior you'd like. A short code snippet of how
you'd want to call it is worth a thousand words.
-->
```python
import wickra as ta
# proposed API
ind = ta.SuperTrend(period=10, multiplier=3.0)
ind.update(close, high, low)
```
## Scope
- [ ] New indicator
- [ ] New method on an existing indicator
- [ ] New binding / platform target
- [ ] Performance improvement
- [ ] Ergonomics / API cleanup
- [ ] Other (Explain below)
## Reference / prior art
<!--
Link the paper, book chapter, TA-Lib function, TradingView Pine source,
or other implementations you'd like Wickra to match.
-->
## Alternatives considered
<!-- What workarounds exist today? Why aren't they enough? -->
## Willingness to contribute
- [ ] I'd like to implement this myself with guidance
- [ ] I can help review / test
- [ ] Requesting only — no bandwidth to implement
@@ -0,0 +1,54 @@
---
name: Performance regression
about: Report a measurable slowdown, memory blowup, or throughput drop.
title: "[Perf] <indicator / API> regressed in <version>"
labels: ["performance", "regression", "triage"]
assignees: []
---
## Summary
<!-- Which code path got slower, by how much, and since when? -->
## Affected code path
- Indicator / API: `e.g. EMA.update`
- Binding: `Rust / Python / Node / Wasm`
- Hot loop or one-shot call?
## Versions compared
| Version | Throughput / latency / memory | Notes |
| -------- | ----------------------------- | ----- |
| `0.4.1` | `e.g. 12.3 ns/iter` | baseline (Good) |
| `0.4.2` | `e.g. 38.7 ns/iter` | regressed |
## Benchmark / reproducer
<!--
Paste the criterion / pytest-benchmark / hyperfine command and its output.
For one-off measurements, include the timing snippet inline.
-->
```bash
cargo bench --bench ema -- --save-baseline new
```
```
ema/update time: [38.5 ns 38.7 ns 38.9 ns]
change: [+213.4% +214.8% +216.1%] (p = 0.00 < 0.05)
Performance has regressed.
```
## Hardware / environment
| Field | Value |
| ------------ | -------------------------------------- |
| CPU | `e.g. Ryzen 9 9950X, AVX2 + AVX512` |
| OS / arch | `e.g. Linux 6.8 x86_64` |
| Toolchain | `rustc 1.x.y` |
| Build flags | `RUSTFLAGS=...`, `--release`, profile |
## Suspected cause
<!-- Optional. Link the commit / PR if you've bisected it. -->
+36
View File
@@ -0,0 +1,36 @@
---
name: Question / usage help
about: Ask how to do something with Wickra. For open-ended discussion prefer GitHub Discussions.
title: "[Question] <short description>"
labels: ["question"]
assignees: []
---
> [!NOTE]
> If this is open-ended ("which indicator should I use for X?") please
> use **Discussions** instead — issues are for actionable items.
## What are you trying to do?
<!-- The end goal, not the API call. -->
## What have you tried?
<!--
Code, docs you've read, search terms that didn't help.
Show that you've spent a few minutes before asking.
-->
```python
import wickra as ta
...
```
## What's confusing or blocking you?
<!-- Specific question. "Why does X return NaN for the first N points?" beats "doesn't work". -->
## Environment (Only if relevant)
- Wickra version: `e.g. 0.4.2`
- Binding: `Rust / Python / Node / Wasm`
+4 -3
View File
@@ -23,9 +23,10 @@
- [ ] `cargo test --workspace` passes.
- [ ] New behaviour has tests; bug fixes have a regression test.
- [ ] Public API changes are mirrored in the Python / Node / WASM bindings
and their type stubs (if applicable).
- [ ] Documentation under `docs/wiki/` and the `README.md` is updated
(if applicable).
and their type stubs (If applicable).
- [ ] The relevant page on the [documentation site](https://docs.wickra.org)
and the `README.md` are updated (If applicable). Docs edits go to a
separate repository: `https://github.com/wickra-lib/wickra-docs`.
- [ ] An entry was added under `## [Unreleased]` in `CHANGELOG.md`.
## Notes for reviewers
+67
View File
@@ -0,0 +1,67 @@
<!--
Thanks for contributing to Wickra!
Please fill in the sections below. Delete any that don't apply.
-->
## Summary
<!-- 13 sentences: what does this PR change and why? -->
## Type of change
- [ ] Bug fix (Non-breaking change which fixes an issue)
- [ ] New feature (Non-breaking change which adds functionality)
- [ ] Breaking change (Fix or feature that changes existing public API)
- [ ] Performance improvement
- [ ] Refactor (No functional change)
- [ ] Documentation only
- [ ] CI / build / tooling
## Affected surfaces
- [ ] Rust crate (`crates/wickra`)
- [ ] Python binding (`bindings/python`)
- [ ] Node.js binding (`bindings/node`)
- [ ] WebAssembly binding (`bindings/wasm`)
- [ ] Examples / docs
## Linked issues
<!-- "Closes #123", "Refs #456". One per line. -->
Closes #
## How was this tested?
<!--
- Unit tests added / updated under `crates/*/tests/` or `bindings/*/tests/`
- Property / fuzz tests touched? (Under `fuzz/`)
- Manual repro steps, if applicable
- Benchmarks run (Paste before/after if perf-sensitive)
-->
## Numerical correctness (If you touched an indicator)
- [ ] Output matches an existing reference (TA-Lib, paper, prior Wickra release) within documented tolerance
- [ ] Streaming `update()` matches batch / `from_slice` output on the same input
- [ ] Edge cases covered: empty input, single point, NaN, leading warm-up window
## Performance impact (If applicable)
| Benchmark | Before | After | Δ |
| --------- | ------ | ----- | - |
| | | | |
## Checklist
- [ ] `cargo fmt --all` and `cargo clippy --all-targets -- -D warnings` are clean
- [ ] `cargo test --workspace` passes locally
- [ ] Binding tests run (If a binding changed)
- [ ] Public API changes are reflected in `CHANGELOG.md`
- [ ] Public API changes are reflected in rustdoc / README / examples
- [ ] No `todo*.md` or other local-only notes are staged
- [ ] License header / `LICENSE` reference unchanged (PolyForm-NC-1.0.0)
## Notes for reviewers
<!-- Anything reviewers should look at first, known follow-ups, deliberately out-of-scope items. -->
+13
View File
@@ -27,6 +27,19 @@ updates:
commit-message:
prefix: "deps(pip)"
# Hash-pinned CI/bench Python tooling under .github/requirements/. Each
# <name>.in is the loose source; the matching hash-locked <name>.txt is the
# output regenerated by scripts/update-lockfiles.sh (uv). Dependabot keeps the
# pins fresh; ci-dev-py39.in caps numpy <2.1 so 3.9 stays installable. Any
# bump that breaks a matrix row surfaces in the PR's CI run.
- package-ecosystem: pip
directory: "/.github/requirements"
schedule:
interval: weekly
open-pull-requests-limit: 10
commit-message:
prefix: "deps(ci-pip)"
# GitHub Actions — keeps the SHA-pinned actions current (Dependabot reads
# the version comment after each pinned SHA and bumps both together).
- package-ecosystem: github-actions
+9
View File
@@ -0,0 +1,9 @@
# Python deps + peer TA libraries for the bench.yml cross-library benchmark.
# Loose source spec — the pinned, hash-locked output is generated from this:
# bench.txt (Python 3.11) via scripts/update-lockfiles.sh
# bench.yml runs on a single Python version (3.11), so one output suffices.
maturin
numpy
pandas
talipp
finta
+167
View File
@@ -0,0 +1,167 @@
# This file was autogenerated by uv via the following command:
# ./scripts/update-lockfiles.sh
finta==1.3 \
--hash=sha256:b94b94df311c18bf5402eb2fe8fd2db5e1bdaff08baf58a7367d05c7abdd10d3 \
--hash=sha256:f2fa0673748f4be8f57e57cf6d5c00a4d44bc6071ea69dbb9a1d329d045cbba2
# via -r .github/requirements/bench.in
maturin==1.13.3 \
--hash=sha256:0ef257e692cc756c87af5bea95ddfe7d3ac49d3376a7a87f728d63f06e7b6f8b \
--hash=sha256:1cc0a110b224ca90406b668a3e3c1f5a515062e59e26292f6dbaf5fd4909c6f3 \
--hash=sha256:2389fe92d017cea9d94e521fa0175314a4c52f79a1057b901fbc9f8686ef7d0b \
--hash=sha256:3cc13929ca82aefa4adbf0f2c35419369796213c6fb0eb24e914945f50ef5d8c \
--hash=sha256:3db93337ed97e60ffc878aa8b493cd7ae44d3a5e1a37256db3a4491f57565018 \
--hash=sha256:4667ef609ab446c1b5e0bfe4f9fb99699ab6d8548433f8d1a684256e0b67217f \
--hash=sha256:49fd6ab08da28098ccf37afca24cdba72376ba9c1eedf9dd25ff82ed771961ff \
--hash=sha256:4cd478e6e4c56251e48ed079b8efd55b30bc5c09cf695a1bdafaeb582ee735a0 \
--hash=sha256:53b08bd075649ce96513ad9abf241a43cb685ed6e9e7790f8dbc2d66e95d8323 \
--hash=sha256:771e1e9e71a278e56db01552e0d1acfd1464259f9575b6e72842f893cd299079 \
--hash=sha256:a2675e25f313034ae6f57388cf14818f87d8961c4a96795287f3e155f59beb11 \
--hash=sha256:b6741d7bf4af97da937528fd1e523c6ab54f53d9a21870fa735d6e67fd88e273 \
--hash=sha256:c00ea6428dea17bf616fe93770837634454b28c2de1a876e42ef8036c616079a \
--hash=sha256:def4a435ea9d2ee93b18ba579dc8c9cf898889a66f312cd379b5e374ec3e3ad6
# via -r .github/requirements/bench.in
numpy==2.4.6 \
--hash=sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1 \
--hash=sha256:0280e0356c0829a18d9de1cb7eee50ec22ca639878d7240307ca0943d73cd2c4 \
--hash=sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f \
--hash=sha256:06ca2f61ec4385a07a6977c55ba998a4466c123642b4a32694d3128fce18c079 \
--hash=sha256:0a041d3d761dc3c35cc56ce0351506a02bcbc25f7b169f652435141a17db9096 \
--hash=sha256:0ab0a9c4ffb1a6d95ef519fe4247dba8eb6b18ad93999f76b7f657039acabd47 \
--hash=sha256:0c9136e14ed34a9e343a31c533d78a9813a69a3148332bce5e9821cb2f996e66 \
--hash=sha256:110f8b71aacb688ec69062bb7f6938a0f8acb01b7c1c4beb453c65b6d234584d \
--hash=sha256:112b06a867b235ef466ed3508ddf0238050df9c727cafb5301ac385b899189a1 \
--hash=sha256:17f9ade344e7d9b464a084d69bcf18fc691cb1db67c62ed80820bf4926d78f0e \
--hash=sha256:1e254a00cdf42b1e4d5b3d68d33af63268d41340d8885df2ab6470f2e1500147 \
--hash=sha256:1e978ec1e8bd0e0e4de6bb75de9d30cbb74db6b6a2bb727618613703ca0167dd \
--hash=sha256:25c692919ac5a01f170a3bfcd62d745b24fd095c353d50812637d6fcab442e75 \
--hash=sha256:260a5d70215b61ab4fadf5c7baacd64821842975eea312125ed3c39a6391b063 \
--hash=sha256:2803abfebfc990042cd494d8ce2d5f82e9d847af6d35ec486923aa19dbad5e73 \
--hash=sha256:29a287e0cf63ff528da061de6b9f64a4618da591ca1046aafc54062e40ca7eab \
--hash=sha256:29cb7f67d10b479ff07c17d33e39f78c07f71c40ef30d63c153d340e96cd3fb4 \
--hash=sha256:3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41 \
--hash=sha256:33111801a01c12a8a1e3721f0a9232f8cfc8ae2c6b7098167e6f623c6073f402 \
--hash=sha256:357cc07a6d7b0b182ff02249616a03742827ebb1277546b5c7cd7f7620a45698 \
--hash=sha256:38efbc8de75c7a0fc1ac190162d892787f3f47b57cc291231aafee36b80982b7 \
--hash=sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8 \
--hash=sha256:40fdc1ae7125e518ea98e53e69a4ebc27e1fd50510c47b7ea130cf21e5e1d42b \
--hash=sha256:4cfe66903cc32a9921a6733d96b19bb6abf310397581bbad89c228f5abaf0ee8 \
--hash=sha256:511dbaf848decaaaf4b4ca48032619fb3138710c4bf7da7617765edad1ef96b0 \
--hash=sha256:55cced7c52e981362f708ad635198e97a752dfba412cc03c23bbf3bd8d5cd662 \
--hash=sha256:56b39e5e0622a09a25bf5baf62f4bcf0cb8a41ae6e2819cf49bbc5a74c083f91 \
--hash=sha256:5dbbdb29840ca3d91ee0fece42fc29278886d908280bfec0a5846c6f901a3eb0 \
--hash=sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f \
--hash=sha256:6180d8b35af935aed8ece3a85e0a43f87393ae0ac87c8d2c8bd2c993f7270ef3 \
--hash=sha256:68a5124b13fa6cc2086764a20005d30bc0548146f7f5322f02fce212ca14317f \
--hash=sha256:68bb27509ac1b9a3443094260f6326150663b06abe40b73a2f81160623da5b67 \
--hash=sha256:6f41ae150c4e32db4f3310cdaf64b1593a03dbabe29eec77fc9b50fe64061df6 \
--hash=sha256:7265a2f3d436e54ef9f2b52b5c937e6be778781bd97a590319d7348f1c1ca997 \
--hash=sha256:72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b \
--hash=sha256:7d92c3819208a60205a12a245c91ad70cb0a85336659b19b834205573ac8456e \
--hash=sha256:8155154c7c691289fe18f510b5d4657c68c67989f293f0535a91360392ff6538 \
--hash=sha256:81a1cca95ed5bb92aa8b10dd2cdc9a0d3853a50fad926c28b5d7e8ea54389627 \
--hash=sha256:89cd468399cfd2504718f0ba50e410dca55a170b61a02ad92bb18c8a65186e93 \
--hash=sha256:8ad03c0965fb3c692200e74d458ca28c1dbb4ce96f9a479a8aa041ad5fabca02 \
--hash=sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853 \
--hash=sha256:948424b06129ce883307e8cff868c31396d8dc7630a59c61d70d98dbe70f222c \
--hash=sha256:9cd5ffd25db4e7ba6a375693b3fc0fc1791ec636c17db3720da19bde7180ec43 \
--hash=sha256:a0df0043bdb289bde1f62da130d20df23d58b45429f752bc7a8fc5325a225ecd \
--hash=sha256:a2c306dea656c12c68f51f4cea133cbe78ca7435eb28c735eac1d3ebe73be6e8 \
--hash=sha256:a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089 \
--hash=sha256:a7c711e21628b52034bb5ab8d1bce291f752fcc5e92accc615778acee1ff4778 \
--hash=sha256:aaf159caa35993cb1f56fb9b8e4610d35758e7ca005412eb1daa856a78c9c4b1 \
--hash=sha256:ae506e6902902557576a26ff33eda8695e7ecb3cb36c3b573a0765dee114ebdb \
--hash=sha256:b507f5c4c1d508876d1819b6bf9a49d365b96320b5d4993426b33a23ca4b8261 \
--hash=sha256:bf162abab1c1a736333192707cef898e735a5ca00f38f27eeedf44b39d9e85eb \
--hash=sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a \
--hash=sha256:c2d37ab77531417474168eb79d6d80b14f821a966818505d03013d0833edb7a8 \
--hash=sha256:c4fc99836233ea196540b17ab0983aff60ed07941751930f5f4d05bc3b3b7359 \
--hash=sha256:d581b735e177fdcdce6fed8e7e8880a3fb6ee4e3653a3ac6af01c6f4c03effc5 \
--hash=sha256:d6da64deb6b8ed903e7560180a92f2d804ee1ba5eeb849ac2748b8c1aba1f6d7 \
--hash=sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751 \
--hash=sha256:ddea102b48f9e339f3948bf22040944184627a30fdf7f858667673b9c5f033c8 \
--hash=sha256:dfa20cc6ca228e6b155b11da03825975ce66aea520985dbbddf0f2a5a495c605 \
--hash=sha256:e3e5193ef5a3dc73bceee50f7fdc2c90dbb76c42df8d8fae3d1067a583df579e \
--hash=sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45 \
--hash=sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2 \
--hash=sha256:e85b752a1e912b70eaad4fafbd4d1238007ab221de2009b9a2f5ae7461239895 \
--hash=sha256:eaf7fa2de5c0be8ae6ff8e9bea2ccd725e980541244521d8d4b5f3354a27babe \
--hash=sha256:ebfb099f8dcf083deef3ac1ca4c1503f387cf76296fcb3816b66f5ecb5f54fdb \
--hash=sha256:ece3d2cfe132e7d51f44a832b303895e6f2d499c5e74dfbdb06ee246147a304a \
--hash=sha256:ed9749eef4cbd126da3dc1d6bcb3a57f5eb7ac6a6484146bdbf743f552dfc577 \
--hash=sha256:ede83e07a75dd06bc501566c1eca2afc0d61677c1472ac9ad93fdee6e638a48d \
--hash=sha256:ef4aea96ce4d3b074422cb4f2f64e216bf9e213004bb58ecfdf50ea02ea8eb9a \
--hash=sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda \
--hash=sha256:f407cb6b8e9d6d8c626bc73c945db1706035af8fd632295547bf1c9e46d092d6 \
--hash=sha256:f74a575920ab21fe304421a3fc28793d82e299cae9eccb37084e9fc7f3617c20
# via
# -r .github/requirements/bench.in
# finta
# pandas
pandas==3.0.3 \
--hash=sha256:0383c72c75cdcca61a9e116e611143902dbfd08bff356829c2f6d1cf40a9ca8c \
--hash=sha256:05f1f1752b8533ea03f7f39a9c15b1a058d067bb48f4748948e7a8691e0510f2 \
--hash=sha256:08d789b41f87e0905880e293cedf6197ce71fe67cc081358b1e148a491b9bd13 \
--hash=sha256:0d589105b3c14645af1738ff279b2995102d8f7a03b0a66dc8d95550eb513e04 \
--hash=sha256:13fc1e853d9e04743d11ba75a985ccbc2a317fe07d8af61e445a6fd24dacd6a6 \
--hash=sha256:14da8316da4d0c5a77618425996bfb1248ca87fc2c1486e6fde4652bd18b5824 \
--hash=sha256:1928e07221f82db493cd4af1e23c1bfca524a19a4699887975bff68f49a72bfb \
--hash=sha256:261e308dfb22448384b7580cf719d2f998fe2966c92893c3e77d14008af1f066 \
--hash=sha256:275c14e0fce14a2ec20eee474aecd305478ea3c1e6f6a9d8fe219a165542717e \
--hash=sha256:335f62418ed562cfc3c49e9e196375c28b729dcef8543abf4f9438e381bf3c76 \
--hash=sha256:3650109c0f22879df8bd6179ab9ee3d7f1d1d4e7e0094a3f0032d9f51e2e64ac \
--hash=sha256:39436b377d56d2a2e52d0395bdbee171f01068e99af5250509aceeb929f765c7 \
--hash=sha256:3c20a521bbb85902f79f7270c80a59e1b5452d96d170c034f207181870f97ac5 \
--hash=sha256:3e91cec1879ada0624fc3dc9953c5cbd60208e59c0db28f540c5d6d47502422f \
--hash=sha256:455f6f8139d4282188f526868dbc3c828470e88a3d9d59a891bd46a455f21b98 \
--hash=sha256:46997386d528eb40376ecd6b033cf4a8a1e5282580f68f43de875b78cba2199d \
--hash=sha256:4db8c527972a821cf5286b40ccc57642a39bc62e62022b42f99f8a67fca8c3a1 \
--hash=sha256:4e15135e2ee5df1063313e2425ceef8ac0f4ae775893815b0923651b806a5639 \
--hash=sha256:51b1fe551acb77dac643c6fda86084d8d446c10fe64b06a9cc29c4cc8540e7f2 \
--hash=sha256:557409bc4178e70ee8d9ddb494798e51ebf6ea59330f6be22c51bab2a7db6c49 \
--hash=sha256:5cc09a68b3120e0f54870dede8287a7bb1fa463907e4fcec1ea77cab6179bf7a \
--hash=sha256:60ae316d3fd75d1858d450d0db0103ea2be3e7d4a95ec2f064f7e2ae63f7b028 \
--hash=sha256:6674ab18ad8c57802867264b00e15e7bb904700cdd9046e3b2fa1fce237439ea \
--hash=sha256:67b3b64c11910cfa29f4e94a14d3bff9ee693b6fc76055e7cad549cee0aec5fa \
--hash=sha256:696a4a00a2a2a35d4e5deb3fc946641b96c944f02230e4f76137fe35d806c4fc \
--hash=sha256:6dc0b3fd2169c9157deed50b4d519553a3655c8c6a96027136d654592be973a9 \
--hash=sha256:7e65d5407dc0b394f509699650e4a2ec01c0514f21850f453fa60f3be79a5dbf \
--hash=sha256:819959dab7bbd0049c15623fbac4e29a191b9528160a61fb1032242d8ced2d9c \
--hash=sha256:8a1e45c80cceb3b4a21bc5939d52e8cbd8d9b7305309219d59e9754d9ce09e27 \
--hash=sha256:9c39be2d709d01fa972a0cabc522389fceca4f3969332ba25a7d6c5802cf976a \
--hash=sha256:9d71c63ae4ebdbf70209742096f1fc46a83a0613c99d4b23766cced9ff8cd62a \
--hash=sha256:a2d2dff8a04f3917b55ab3910c32990f8ddf7eceba114947838cefa976a68977 \
--hash=sha256:a4eeb6830daf35a71cc09649bd823e2b542dac246cdee9614c6e4bd65028cd6a \
--hash=sha256:a55066a0505dae0ba2b50a46637db34b46f9094c65c5d4800794ef6335010938 \
--hash=sha256:a82d532a3351d435432cd913edbccaf8b8e01d4dd0e5ced5a8d2e8ecd94c7e44 \
--hash=sha256:b168fc218fd80a6cbdbdbc1a97ddc7889ed057d7eb45f50d866ceab5f39904c4 \
--hash=sha256:b2c95f8bfc1ee412bf482605d7bfd30c12d1d26bd59fdd91efeef1d4718decb1 \
--hash=sha256:ba7e08b9ac1d54569cd1e256e3668975ed624d6826f7b68df0342b012007bddb \
--hash=sha256:bab900348131a7db1f69a7309ef141fd5680f1487094193bcbbb61791573bf8f \
--hash=sha256:bd3a518890b400d32f9023722dc9a9a5c969f00b415419a3c06c043f09bb5d7d \
--hash=sha256:c7be265b62cef88e253a941e4698604973736dcfe242fdb5198f0f7bc473cdcc \
--hash=sha256:d26cbe1fcfc12e8fd900e2454163e466b2d3af84f7c75481df7683ffc073d870 \
--hash=sha256:d4be06d68f9ddcfc645b87534911da79a8fbffc7573c80e0edcf42a5020624d8 \
--hash=sha256:d72828c20c6d6e83e1e22a6a3b47b326b71664112fa9705dcbccfd7a39b62085 \
--hash=sha256:dd1a5d1def6a46002e964510bdc67c368aa0951df5d1d9f8365336f5a1f490cd \
--hash=sha256:e3a2ec42c98ffa2565a67e08e218d06d72576d758d90facb7c00805194d8f360 \
--hash=sha256:f8894dc474d648fe7b6ff0ca9b0bd73950d19952bc1a6534540762c5d79d305c \
--hash=sha256:fed2ff7fd9779120e388e285fc029bd5cf9490cdd2e4166a9ee22c0e49a9ab09
# via
# -r .github/requirements/bench.in
# finta
python-dateutil==2.9.0.post0 \
--hash=sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3 \
--hash=sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427
# via pandas
six==1.17.0 \
--hash=sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274 \
--hash=sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81
# via python-dateutil
talipp==2.7.0 \
--hash=sha256:567f59ad74366cb59a14a00d350f35fd9d22e6924d6228bad581e6dcf1de2205 \
--hash=sha256:f749f22b9ad615605e71faf26457bb7f5e3fe16f04d3287f4ca54fd16bc3d4eb
# via -r .github/requirements/bench.in
tzdata==2026.2 \
--hash=sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10 \
--hash=sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7
# via pandas
+7
View File
@@ -0,0 +1,7 @@
# Python 3.10+ dev/test tooling for the ci.yml binding test job
# (covers the 3.11 / 3.12 / 3.13 matrix rows). Locked output: ci-dev-py3.txt
# Refresh via scripts/update-lockfiles.sh.
maturin
pytest
numpy
hypothesis
+124
View File
@@ -0,0 +1,124 @@
# This file was autogenerated by uv via the following command:
# ./scripts/update-lockfiles.sh
colorama==0.4.6 \
--hash=sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44 \
--hash=sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6
# via pytest
hypothesis==6.155.1 \
--hash=sha256:07c102031612b98d7c1be15ca3608c43e1234d9d07e3a190a53fa01536700196 \
--hash=sha256:2753f469df3ba3c483b08e0c37dbcbc41d8316ebb921abcc07493ee9c8a7d187
# via -r .github/requirements/ci-dev-py3.in
iniconfig==2.3.0 \
--hash=sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730 \
--hash=sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12
# via pytest
maturin==1.13.3 \
--hash=sha256:0ef257e692cc756c87af5bea95ddfe7d3ac49d3376a7a87f728d63f06e7b6f8b \
--hash=sha256:1cc0a110b224ca90406b668a3e3c1f5a515062e59e26292f6dbaf5fd4909c6f3 \
--hash=sha256:2389fe92d017cea9d94e521fa0175314a4c52f79a1057b901fbc9f8686ef7d0b \
--hash=sha256:3cc13929ca82aefa4adbf0f2c35419369796213c6fb0eb24e914945f50ef5d8c \
--hash=sha256:3db93337ed97e60ffc878aa8b493cd7ae44d3a5e1a37256db3a4491f57565018 \
--hash=sha256:4667ef609ab446c1b5e0bfe4f9fb99699ab6d8548433f8d1a684256e0b67217f \
--hash=sha256:49fd6ab08da28098ccf37afca24cdba72376ba9c1eedf9dd25ff82ed771961ff \
--hash=sha256:4cd478e6e4c56251e48ed079b8efd55b30bc5c09cf695a1bdafaeb582ee735a0 \
--hash=sha256:53b08bd075649ce96513ad9abf241a43cb685ed6e9e7790f8dbc2d66e95d8323 \
--hash=sha256:771e1e9e71a278e56db01552e0d1acfd1464259f9575b6e72842f893cd299079 \
--hash=sha256:a2675e25f313034ae6f57388cf14818f87d8961c4a96795287f3e155f59beb11 \
--hash=sha256:b6741d7bf4af97da937528fd1e523c6ab54f53d9a21870fa735d6e67fd88e273 \
--hash=sha256:c00ea6428dea17bf616fe93770837634454b28c2de1a876e42ef8036c616079a \
--hash=sha256:def4a435ea9d2ee93b18ba579dc8c9cf898889a66f312cd379b5e374ec3e3ad6
# via -r .github/requirements/ci-dev-py3.in
numpy==2.4.6 \
--hash=sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1 \
--hash=sha256:0280e0356c0829a18d9de1cb7eee50ec22ca639878d7240307ca0943d73cd2c4 \
--hash=sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f \
--hash=sha256:06ca2f61ec4385a07a6977c55ba998a4466c123642b4a32694d3128fce18c079 \
--hash=sha256:0a041d3d761dc3c35cc56ce0351506a02bcbc25f7b169f652435141a17db9096 \
--hash=sha256:0ab0a9c4ffb1a6d95ef519fe4247dba8eb6b18ad93999f76b7f657039acabd47 \
--hash=sha256:0c9136e14ed34a9e343a31c533d78a9813a69a3148332bce5e9821cb2f996e66 \
--hash=sha256:110f8b71aacb688ec69062bb7f6938a0f8acb01b7c1c4beb453c65b6d234584d \
--hash=sha256:112b06a867b235ef466ed3508ddf0238050df9c727cafb5301ac385b899189a1 \
--hash=sha256:17f9ade344e7d9b464a084d69bcf18fc691cb1db67c62ed80820bf4926d78f0e \
--hash=sha256:1e254a00cdf42b1e4d5b3d68d33af63268d41340d8885df2ab6470f2e1500147 \
--hash=sha256:1e978ec1e8bd0e0e4de6bb75de9d30cbb74db6b6a2bb727618613703ca0167dd \
--hash=sha256:25c692919ac5a01f170a3bfcd62d745b24fd095c353d50812637d6fcab442e75 \
--hash=sha256:260a5d70215b61ab4fadf5c7baacd64821842975eea312125ed3c39a6391b063 \
--hash=sha256:2803abfebfc990042cd494d8ce2d5f82e9d847af6d35ec486923aa19dbad5e73 \
--hash=sha256:29a287e0cf63ff528da061de6b9f64a4618da591ca1046aafc54062e40ca7eab \
--hash=sha256:29cb7f67d10b479ff07c17d33e39f78c07f71c40ef30d63c153d340e96cd3fb4 \
--hash=sha256:3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41 \
--hash=sha256:33111801a01c12a8a1e3721f0a9232f8cfc8ae2c6b7098167e6f623c6073f402 \
--hash=sha256:357cc07a6d7b0b182ff02249616a03742827ebb1277546b5c7cd7f7620a45698 \
--hash=sha256:38efbc8de75c7a0fc1ac190162d892787f3f47b57cc291231aafee36b80982b7 \
--hash=sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8 \
--hash=sha256:40fdc1ae7125e518ea98e53e69a4ebc27e1fd50510c47b7ea130cf21e5e1d42b \
--hash=sha256:4cfe66903cc32a9921a6733d96b19bb6abf310397581bbad89c228f5abaf0ee8 \
--hash=sha256:511dbaf848decaaaf4b4ca48032619fb3138710c4bf7da7617765edad1ef96b0 \
--hash=sha256:55cced7c52e981362f708ad635198e97a752dfba412cc03c23bbf3bd8d5cd662 \
--hash=sha256:56b39e5e0622a09a25bf5baf62f4bcf0cb8a41ae6e2819cf49bbc5a74c083f91 \
--hash=sha256:5dbbdb29840ca3d91ee0fece42fc29278886d908280bfec0a5846c6f901a3eb0 \
--hash=sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f \
--hash=sha256:6180d8b35af935aed8ece3a85e0a43f87393ae0ac87c8d2c8bd2c993f7270ef3 \
--hash=sha256:68a5124b13fa6cc2086764a20005d30bc0548146f7f5322f02fce212ca14317f \
--hash=sha256:68bb27509ac1b9a3443094260f6326150663b06abe40b73a2f81160623da5b67 \
--hash=sha256:6f41ae150c4e32db4f3310cdaf64b1593a03dbabe29eec77fc9b50fe64061df6 \
--hash=sha256:7265a2f3d436e54ef9f2b52b5c937e6be778781bd97a590319d7348f1c1ca997 \
--hash=sha256:72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b \
--hash=sha256:7d92c3819208a60205a12a245c91ad70cb0a85336659b19b834205573ac8456e \
--hash=sha256:8155154c7c691289fe18f510b5d4657c68c67989f293f0535a91360392ff6538 \
--hash=sha256:81a1cca95ed5bb92aa8b10dd2cdc9a0d3853a50fad926c28b5d7e8ea54389627 \
--hash=sha256:89cd468399cfd2504718f0ba50e410dca55a170b61a02ad92bb18c8a65186e93 \
--hash=sha256:8ad03c0965fb3c692200e74d458ca28c1dbb4ce96f9a479a8aa041ad5fabca02 \
--hash=sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853 \
--hash=sha256:948424b06129ce883307e8cff868c31396d8dc7630a59c61d70d98dbe70f222c \
--hash=sha256:9cd5ffd25db4e7ba6a375693b3fc0fc1791ec636c17db3720da19bde7180ec43 \
--hash=sha256:a0df0043bdb289bde1f62da130d20df23d58b45429f752bc7a8fc5325a225ecd \
--hash=sha256:a2c306dea656c12c68f51f4cea133cbe78ca7435eb28c735eac1d3ebe73be6e8 \
--hash=sha256:a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089 \
--hash=sha256:a7c711e21628b52034bb5ab8d1bce291f752fcc5e92accc615778acee1ff4778 \
--hash=sha256:aaf159caa35993cb1f56fb9b8e4610d35758e7ca005412eb1daa856a78c9c4b1 \
--hash=sha256:ae506e6902902557576a26ff33eda8695e7ecb3cb36c3b573a0765dee114ebdb \
--hash=sha256:b507f5c4c1d508876d1819b6bf9a49d365b96320b5d4993426b33a23ca4b8261 \
--hash=sha256:bf162abab1c1a736333192707cef898e735a5ca00f38f27eeedf44b39d9e85eb \
--hash=sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a \
--hash=sha256:c2d37ab77531417474168eb79d6d80b14f821a966818505d03013d0833edb7a8 \
--hash=sha256:c4fc99836233ea196540b17ab0983aff60ed07941751930f5f4d05bc3b3b7359 \
--hash=sha256:d581b735e177fdcdce6fed8e7e8880a3fb6ee4e3653a3ac6af01c6f4c03effc5 \
--hash=sha256:d6da64deb6b8ed903e7560180a92f2d804ee1ba5eeb849ac2748b8c1aba1f6d7 \
--hash=sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751 \
--hash=sha256:ddea102b48f9e339f3948bf22040944184627a30fdf7f858667673b9c5f033c8 \
--hash=sha256:dfa20cc6ca228e6b155b11da03825975ce66aea520985dbbddf0f2a5a495c605 \
--hash=sha256:e3e5193ef5a3dc73bceee50f7fdc2c90dbb76c42df8d8fae3d1067a583df579e \
--hash=sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45 \
--hash=sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2 \
--hash=sha256:e85b752a1e912b70eaad4fafbd4d1238007ab221de2009b9a2f5ae7461239895 \
--hash=sha256:eaf7fa2de5c0be8ae6ff8e9bea2ccd725e980541244521d8d4b5f3354a27babe \
--hash=sha256:ebfb099f8dcf083deef3ac1ca4c1503f387cf76296fcb3816b66f5ecb5f54fdb \
--hash=sha256:ece3d2cfe132e7d51f44a832b303895e6f2d499c5e74dfbdb06ee246147a304a \
--hash=sha256:ed9749eef4cbd126da3dc1d6bcb3a57f5eb7ac6a6484146bdbf743f552dfc577 \
--hash=sha256:ede83e07a75dd06bc501566c1eca2afc0d61677c1472ac9ad93fdee6e638a48d \
--hash=sha256:ef4aea96ce4d3b074422cb4f2f64e216bf9e213004bb58ecfdf50ea02ea8eb9a \
--hash=sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda \
--hash=sha256:f407cb6b8e9d6d8c626bc73c945db1706035af8fd632295547bf1c9e46d092d6 \
--hash=sha256:f74a575920ab21fe304421a3fc28793d82e299cae9eccb37084e9fc7f3617c20
# via -r .github/requirements/ci-dev-py3.in
packaging==26.2 \
--hash=sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e \
--hash=sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661
# via pytest
pluggy==1.6.0 \
--hash=sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3 \
--hash=sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746
# via pytest
pygments==2.20.0 \
--hash=sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f \
--hash=sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176
# via pytest
pytest==9.0.3 \
--hash=sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9 \
--hash=sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c
# via -r .github/requirements/ci-dev-py3.in
sortedcontainers==2.4.0 \
--hash=sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88 \
--hash=sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0
# via hypothesis
+8
View File
@@ -0,0 +1,8 @@
# Python 3.9 dev/test tooling for the ci.yml binding test job.
# numpy is capped <2.1 because that is the last series shipping cp39 wheels
# (>=2.1 dropped Python 3.9). Locked output: ci-dev-py39.txt
# Refresh via scripts/update-lockfiles.sh.
maturin
pytest
numpy<2.1
hypothesis
+162
View File
@@ -0,0 +1,162 @@
# This file was autogenerated by uv via the following command:
# ./scripts/update-lockfiles.sh
attrs==26.1.0 \
--hash=sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309 \
--hash=sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32
# via hypothesis
colorama==0.4.6 \
--hash=sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44 \
--hash=sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6
# via pytest
exceptiongroup==1.3.1 \
--hash=sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219 \
--hash=sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598
# via
# hypothesis
# pytest
hypothesis==6.141.1 \
--hash=sha256:8ef356e1e18fbeaa8015aab3c805303b7fe4b868e5b506e87ad83c0bf951f46f \
--hash=sha256:a5b3c39c16d98b7b4c3c5c8d4262e511e3b2255e6814ced8023af49087ad60b3
# via -r .github/requirements/ci-dev-py39.in
iniconfig==2.1.0 \
--hash=sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7 \
--hash=sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760
# via pytest
maturin==1.13.3 \
--hash=sha256:0ef257e692cc756c87af5bea95ddfe7d3ac49d3376a7a87f728d63f06e7b6f8b \
--hash=sha256:1cc0a110b224ca90406b668a3e3c1f5a515062e59e26292f6dbaf5fd4909c6f3 \
--hash=sha256:2389fe92d017cea9d94e521fa0175314a4c52f79a1057b901fbc9f8686ef7d0b \
--hash=sha256:3cc13929ca82aefa4adbf0f2c35419369796213c6fb0eb24e914945f50ef5d8c \
--hash=sha256:3db93337ed97e60ffc878aa8b493cd7ae44d3a5e1a37256db3a4491f57565018 \
--hash=sha256:4667ef609ab446c1b5e0bfe4f9fb99699ab6d8548433f8d1a684256e0b67217f \
--hash=sha256:49fd6ab08da28098ccf37afca24cdba72376ba9c1eedf9dd25ff82ed771961ff \
--hash=sha256:4cd478e6e4c56251e48ed079b8efd55b30bc5c09cf695a1bdafaeb582ee735a0 \
--hash=sha256:53b08bd075649ce96513ad9abf241a43cb685ed6e9e7790f8dbc2d66e95d8323 \
--hash=sha256:771e1e9e71a278e56db01552e0d1acfd1464259f9575b6e72842f893cd299079 \
--hash=sha256:a2675e25f313034ae6f57388cf14818f87d8961c4a96795287f3e155f59beb11 \
--hash=sha256:b6741d7bf4af97da937528fd1e523c6ab54f53d9a21870fa735d6e67fd88e273 \
--hash=sha256:c00ea6428dea17bf616fe93770837634454b28c2de1a876e42ef8036c616079a \
--hash=sha256:def4a435ea9d2ee93b18ba579dc8c9cf898889a66f312cd379b5e374ec3e3ad6
# via -r .github/requirements/ci-dev-py39.in
numpy==2.0.2 \
--hash=sha256:0123ffdaa88fa4ab64835dcbde75dcdf89c453c922f18dced6e27c90d1d0ec5a \
--hash=sha256:11a76c372d1d37437857280aa142086476136a8c0f373b2e648ab2c8f18fb195 \
--hash=sha256:13e689d772146140a252c3a28501da66dfecd77490b498b168b501835041f951 \
--hash=sha256:1e795a8be3ddbac43274f18588329c72939870a16cae810c2b73461c40718ab1 \
--hash=sha256:26df23238872200f63518dd2aa984cfca675d82469535dc7162dc2ee52d9dd5c \
--hash=sha256:286cd40ce2b7d652a6f22efdfc6d1edf879440e53e76a75955bc0c826c7e64dc \
--hash=sha256:2b2955fa6f11907cf7a70dab0d0755159bca87755e831e47932367fc8f2f2d0b \
--hash=sha256:2da5960c3cf0df7eafefd806d4e612c5e19358de82cb3c343631188991566ccd \
--hash=sha256:312950fdd060354350ed123c0e25a71327d3711584beaef30cdaa93320c392d4 \
--hash=sha256:423e89b23490805d2a5a96fe40ec507407b8ee786d66f7328be214f9679df6dd \
--hash=sha256:496f71341824ed9f3d2fd36cf3ac57ae2e0165c143b55c3a035ee219413f3318 \
--hash=sha256:49ca4decb342d66018b01932139c0961a8f9ddc7589611158cb3c27cbcf76448 \
--hash=sha256:51129a29dbe56f9ca83438b706e2e69a39892b5eda6cedcb6b0c9fdc9b0d3ece \
--hash=sha256:5fec9451a7789926bcf7c2b8d187292c9f93ea30284802a0ab3f5be8ab36865d \
--hash=sha256:671bec6496f83202ed2d3c8fdc486a8fc86942f2e69ff0e986140339a63bcbe5 \
--hash=sha256:7f0a0c6f12e07fa94133c8a67404322845220c06a9e80e85999afe727f7438b8 \
--hash=sha256:807ec44583fd708a21d4a11d94aedf2f4f3c3719035c76a2bbe1fe8e217bdc57 \
--hash=sha256:883c987dee1880e2a864ab0dc9892292582510604156762362d9326444636e78 \
--hash=sha256:8c5713284ce4e282544c68d1c3b2c7161d38c256d2eefc93c1d683cf47683e66 \
--hash=sha256:8cafab480740e22f8d833acefed5cc87ce276f4ece12fdaa2e8903db2f82897a \
--hash=sha256:8df823f570d9adf0978347d1f926b2a867d5608f434a7cff7f7908c6570dcf5e \
--hash=sha256:9059e10581ce4093f735ed23f3b9d283b9d517ff46009ddd485f1747eb22653c \
--hash=sha256:905d16e0c60200656500c95b6b8dca5d109e23cb24abc701d41c02d74c6b3afa \
--hash=sha256:9189427407d88ff25ecf8f12469d4d39d35bee1db5d39fc5c168c6f088a6956d \
--hash=sha256:96a55f64139912d61de9137f11bf39a55ec8faec288c75a54f93dfd39f7eb40c \
--hash=sha256:97032a27bd9d8988b9a97a8c4d2c9f2c15a81f61e2f21404d7e8ef00cb5be729 \
--hash=sha256:984d96121c9f9616cd33fbd0618b7f08e0cfc9600a7ee1d6fd9b239186d19d97 \
--hash=sha256:9a92ae5c14811e390f3767053ff54eaee3bf84576d99a2456391401323f4ec2c \
--hash=sha256:9ea91dfb7c3d1c56a0e55657c0afb38cf1eeae4544c208dc465c3c9f3a7c09f9 \
--hash=sha256:a15f476a45e6e5a3a79d8a14e62161d27ad897381fecfa4a09ed5322f2085669 \
--hash=sha256:a392a68bd329eafac5817e5aefeb39038c48b671afd242710b451e76090e81f4 \
--hash=sha256:a3f4ab0caa7f053f6797fcd4e1e25caee367db3112ef2b6ef82d749530768c73 \
--hash=sha256:a46288ec55ebbd58947d31d72be2c63cbf839f0a63b49cb755022310792a3385 \
--hash=sha256:a61ec659f68ae254e4d237816e33171497e978140353c0c2038d46e63282d0c8 \
--hash=sha256:a842d573724391493a97a62ebbb8e731f8a5dcc5d285dfc99141ca15a3302d0c \
--hash=sha256:becfae3ddd30736fe1889a37f1f580e245ba79a5855bff5f2a29cb3ccc22dd7b \
--hash=sha256:c05e238064fc0610c840d1cf6a13bf63d7e391717d247f1bf0318172e759e692 \
--hash=sha256:c1c9307701fec8f3f7a1e6711f9089c06e6284b3afbbcd259f7791282d660a15 \
--hash=sha256:c7b0be4ef08607dd04da4092faee0b86607f111d5ae68036f16cc787e250a131 \
--hash=sha256:cfd41e13fdc257aa5778496b8caa5e856dc4896d4ccf01841daee1d96465467a \
--hash=sha256:d731a1c6116ba289c1e9ee714b08a8ff882944d4ad631fd411106a30f083c326 \
--hash=sha256:df55d490dea7934f330006d0f81e8551ba6010a5bf035a249ef61a94f21c500b \
--hash=sha256:ec9852fb39354b5a45a80bdab5ac02dd02b15f44b3804e9f00c556bf24b4bded \
--hash=sha256:f15975dfec0cf2239224d80e32c3170b1d168335eaedee69da84fbe9f1f9cd04 \
--hash=sha256:f26b258c385842546006213344c50655ff1555a9338e2e5e02a0756dc3e803dd
# via -r .github/requirements/ci-dev-py39.in
packaging==26.2 \
--hash=sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e \
--hash=sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661
# via pytest
pluggy==1.6.0 \
--hash=sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3 \
--hash=sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746
# via pytest
pygments==2.20.0 \
--hash=sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f \
--hash=sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176
# via pytest
pytest==8.4.2 \
--hash=sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01 \
--hash=sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79
# via -r .github/requirements/ci-dev-py39.in
sortedcontainers==2.4.0 \
--hash=sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88 \
--hash=sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0
# via hypothesis
tomli==2.4.1 \
--hash=sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853 \
--hash=sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe \
--hash=sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5 \
--hash=sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d \
--hash=sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd \
--hash=sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26 \
--hash=sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54 \
--hash=sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6 \
--hash=sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c \
--hash=sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a \
--hash=sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd \
--hash=sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f \
--hash=sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5 \
--hash=sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9 \
--hash=sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662 \
--hash=sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9 \
--hash=sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1 \
--hash=sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585 \
--hash=sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e \
--hash=sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c \
--hash=sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41 \
--hash=sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f \
--hash=sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085 \
--hash=sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15 \
--hash=sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7 \
--hash=sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c \
--hash=sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36 \
--hash=sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076 \
--hash=sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac \
--hash=sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8 \
--hash=sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232 \
--hash=sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece \
--hash=sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a \
--hash=sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897 \
--hash=sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d \
--hash=sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4 \
--hash=sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917 \
--hash=sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396 \
--hash=sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a \
--hash=sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc \
--hash=sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba \
--hash=sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f \
--hash=sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257 \
--hash=sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30 \
--hash=sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf \
--hash=sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9 \
--hash=sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049
# via
# maturin
# pytest
typing-extensions==4.15.0 \
--hash=sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466 \
--hash=sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548
# via exceptiongroup
+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())
+50 -3
View File
@@ -22,8 +22,26 @@ on:
required: false
default: "10"
# Least-privilege default for the auto-injected GITHUB_TOKEN. The single job
# only builds and uploads an artifact (upload-artifact uses the artifact
# storage API, not the contents scope), so it never needs repo write (OpenSSF
# Scorecard: Token-Permissions).
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
# Network-flake resilience: retry transient registry/DNS failures at the tool
# level so a blip fetching crates.io / PyPI inside any build step (cargo,
# maturin, pip) retries automatically instead of failing the job. Cargo treats
# "couldn't resolve host" / connect / timeout as spurious and retries with
# backoff; 10 attempts ride out a transient DNS blip on a runner.
CARGO_NET_RETRY: "10"
CARGO_NET_GIT_FETCH_WITH_CLI: "true"
npm_config_fetch_retries: "5"
npm_config_fetch_retry_maxtimeout: "120000"
PIP_RETRIES: "5"
PIP_DEFAULT_TIMEOUT: "120"
jobs:
cross-library-bench:
@@ -35,16 +53,39 @@ jobs:
- uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable branch, 2026-03-27
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
continue-on-error: true # cache is an optimisation; never block on a stuck/slow restore
timeout-minutes: 6
- name: Set up Python
id: setup_python
continue-on-error: true
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.11"
cache: pip
cache-dependency-path: .github/requirements/bench.txt
- 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: "3.11"
cache: pip
cache-dependency-path: .github/requirements/bench.txt
- name: Install Python deps + peer libs
run: |
python -m pip install --upgrade pip
python -m pip install maturin numpy pandas talipp finta
# Hash-locked deps (OpenSSF Scorecard PinnedDependencies). bench.yml
# runs on a single Python version (3.11), so one lock file suffices.
python -m pip install --require-hashes -r .github/requirements/bench.txt
- name: Build Wickra wheel
working-directory: bindings/python
@@ -56,10 +97,16 @@ jobs:
- name: Run cross-library benchmark
working-directory: bindings/python
# workflow_dispatch inputs are untrusted; pass them through the
# environment and quote them rather than interpolating into the shell
# command (OpenSSF Scorecard: Dangerous-Workflow).
env:
BENCH_SIZE: ${{ github.event.inputs.size || '20000' }}
BENCH_ITERATIONS: ${{ github.event.inputs.iterations || '10' }}
run: |
python -m benchmarks.compare_libraries \
--size ${{ github.event.inputs.size || '20000' }} \
--iterations ${{ github.event.inputs.iterations || '10' }} \
--size "$BENCH_SIZE" \
--iterations "$BENCH_ITERATIONS" \
--streaming-window 5000 --streaming-iterations 2 \
| tee benchmark.txt
+265 -7
View File
@@ -6,9 +6,29 @@ on:
pull_request:
branches: [main]
# Least-privilege default for the auto-injected GITHUB_TOKEN. None of the CI
# jobs write back to the repo — coverage uploads via CODECOV_TOKEN, everything
# else is build/test/lint — so a read-only token is sufficient (OpenSSF
# Scorecard: Token-Permissions).
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
RUSTFLAGS: "-D warnings"
# Network-flake resilience: retry transient registry/DNS failures at the tool
# level so a blip fetching crates.io / npm / PyPI inside any build step (cargo,
# napi, maturin, wasm-pack, npm ci, pip) retries automatically instead of
# failing the job and needing a manual re-run. Cargo treats "couldn't resolve
# host" / connect / timeout as spurious and retries with backoff; 10 attempts
# ride out a transient DNS blip on a runner. Complements the setup-action /
# cache retries (which only covered toolchain download + cache restore).
CARGO_NET_RETRY: "10"
CARGO_NET_GIT_FETCH_WITH_CLI: "true"
npm_config_fetch_retries: "5"
npm_config_fetch_retry_maxtimeout: "120000"
PIP_RETRIES: "5"
PIP_DEFAULT_TIMEOUT: "120"
jobs:
rust:
@@ -28,6 +48,8 @@ jobs:
- name: Cache cargo
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
continue-on-error: true # cache is an optimisation; never block on a stuck/slow restore
timeout-minutes: 6
- name: Format check
run: cargo fmt --all -- --check
@@ -55,6 +77,158 @@ jobs:
# streaming.
run: cargo build -p wickra-examples --bins
# Syntax/parse smoke for the non-Rust examples. The Rust examples are built
# in the `rust` job above (`cargo build -p wickra-examples --bins`); the Node,
# browser-WASM and Python examples otherwise have no build gate, so a broken
# edit could land unnoticed. This is a parse-only smoke — actually running the
# examples needs the built native binding / wasm module / wheel, which the
# binding jobs provide separately.
examples-smoke:
name: Examples (syntax smoke)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Node
id: setup_node
continue-on-error: true
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "20"
- 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: "20"
- name: Set up Python
id: setup_python
continue-on-error: true
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.12"
- 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: "3.12"
- name: Node examples — syntax check
run: |
shopt -s nullglob
count=0
for f in examples/node/*.js examples/wasm/*.js; do
echo "node --check $f"
node --check "$f"
count=$((count + 1))
done
echo "checked $count Node/WASM .js files"
- name: WASM demo module scripts — syntax check
# The .html demos embed an ES module; extract it and parse-check so a
# broken edit to the in-page strategy logic fails CI.
run: |
shopt -s nullglob
count=0
for f in examples/wasm/*.html; do
node -e 'const fs=require("fs");const h=fs.readFileSync(process.argv[1],"utf8");const m=h.match(/<script type="module">([\s\S]*?)<\/script>/);if(!m){console.error("no <script type=module> in "+process.argv[1]);process.exit(1);}fs.writeFileSync("module-check.mjs",m[1]);' "$f"
echo "node --check (module of) $f"
node --check module-check.mjs
count=$((count + 1))
done
rm -f module-check.mjs
echo "checked $count WASM .html module scripts"
- name: Python examples — byte-compile
run: |
shopt -s nullglob
count=0
for f in examples/python/*.py; do
echo "py_compile $f"
python -m py_compile "$f"
count=$((count + 1))
done
echo "compiled $count Python files"
# 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
id: setup_python
continue-on-error: true
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: "3.12"
- 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@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: "3.12"
- name: Set up Node
id: setup_node
continue-on-error: true
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "20"
- 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: "20"
- name: Cache cargo
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
continue-on-error: true # cache is an optimisation; never block on a stuck/slow restore
timeout-minutes: 6
- 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
@@ -87,6 +261,8 @@ jobs:
- name: Cache cargo
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
continue-on-error: true # cache is an optimisation; never block on a stuck/slow restore
timeout-minutes: 6
- name: Build on MSRV
run: cargo build ${{ matrix.packages }} --verbose
@@ -108,9 +284,12 @@ jobs:
- name: Cache cargo
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
continue-on-error: true # cache is an optimisation; never block on a stuck/slow restore
timeout-minutes: 6
- name: Install cargo-llvm-cov
uses: taiki-e/install-action@6c1f7cf125e42770ff087ea443901b487cc5471a # v2.79.5
uses: taiki-e/install-action@0fd46367812ee04360509b4169d9f659d6892bb2 # v2.79.15
timeout-minutes: 10 # fail fast on a stuck download instead of hanging the job
with:
tool: cargo-llvm-cov
@@ -138,7 +317,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
@@ -160,6 +339,8 @@ jobs:
- name: Cache cargo
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
continue-on-error: true # cache is an optimisation; never block on a stuck/slow restore
timeout-minutes: 6
with:
workspaces: fuzz
@@ -171,7 +352,8 @@ 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
timeout-minutes: 10 # fail fast on a stuck download instead of hanging the job
with:
tool: cargo-fuzz
@@ -211,16 +393,50 @@ jobs:
- name: Cache cargo
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
continue-on-error: true # cache is an optimisation; never block on a stuck/slow restore
timeout-minutes: 6
# 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 }}
cache: pip
cache-dependency-path: .github/requirements/ci-dev-*.txt
- 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 }}
cache: pip
cache-dependency-path: .github/requirements/ci-dev-*.txt
- name: Install Python dev dependencies
shell: bash
run: |
python -m pip install --upgrade pip
python -m pip install maturin pytest numpy hypothesis
# Hash-locked dev tooling (OpenSSF Scorecard PinnedDependencies).
# Split by Python version: numpy ships no single release with wheels
# for both cp39 and cp313 (<=2.0.2 has cp39 only, >=2.1 drops cp39).
if [ "${{ matrix.python-version }}" = "3.9" ]; then
python -m pip install --require-hashes -r .github/requirements/ci-dev-py39.txt
else
python -m pip install --require-hashes -r .github/requirements/ci-dev-py3.txt
fi
- name: Build wheel
working-directory: bindings/python
@@ -229,7 +445,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
@@ -248,9 +469,21 @@ jobs:
- name: Cache cargo
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
continue-on-error: true # cache is an optimisation; never block on a stuck/slow restore
timeout-minutes: 6
- name: Install wasm-pack
uses: jetli/wasm-pack-action@0d096b08b4e5a7de8c28de67e11e945404e9eefa # v0.4.0
# jetli/wasm-pack-action@v0.4.0 with no `version:` input installs an
# old wasm-pack (~0.10.x) whose `build` subcommand does not yet accept
# `--features`, so `wasm-pack build … --features panic-hook` fails
# with "Found argument '--features' which wasn't expected". Use the
# same taiki-e prebuilt-binary installer we already use for
# cargo-llvm-cov and cargo-fuzz; it tracks the latest wasm-pack
# release, which has `--features` as a top-level flag (since 0.12).
uses: taiki-e/install-action@0fd46367812ee04360509b4169d9f659d6892bb2 # v2.79.15
timeout-minutes: 10 # fail fast on a stuck download instead of hanging the job
with:
tool: wasm-pack
- name: Build WASM package
run: wasm-pack build bindings/wasm --target web --release --features panic-hook
@@ -280,15 +513,40 @@ jobs:
- name: Cache cargo
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
continue-on-error: true # cache is an optimisation; never block on a stuck/slow restore
timeout-minutes: 6
# 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 }}
cache: npm
cache-dependency-path: bindings/node/package-lock.json
- 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 }}
cache: npm
cache-dependency-path: bindings/node/package-lock.json
- name: Install Node dependencies
working-directory: bindings/node
run: npm install
run: npm ci
- name: Build native module
working-directory: bindings/node
+54
View File
@@ -0,0 +1,54 @@
name: CodeQL
# Static analysis security testing (findings P13.x). Analyses the Rust core and
# the Python / JavaScript binding surfaces with GitHub's CodeQL engine. Results
# appear under Security → Code scanning. `build-mode: none` analyses source
# directly — no compilation step — for every language here.
on:
push:
branches: [main]
pull_request:
branches: [main]
schedule:
- cron: '31 3 * * 0' # Sundays 03:31 UTC
# Least-privilege default for the auto-injected GITHUB_TOKEN. The analyze job
# raises exactly the scopes CodeQL needs (security-events: write to upload
# results) in its own job-level block below; this top-level read-only default
# covers any future job (OpenSSF Scorecard: Token-Permissions).
permissions:
contents: read
jobs:
analyze:
name: Analyze (${{ matrix.language }})
runs-on: ubuntu-latest
permissions:
security-events: write # upload CodeQL results to code-scanning
packages: read
actions: read
contents: read
strategy:
fail-fast: false
matrix:
include:
- language: rust
build-mode: none
- language: python
build-mode: none
- language: javascript-typescript
build-mode: none
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Initialize CodeQL
uses: github/codeql-action/init@03e4368ac7daa2bd82b3e85262f3bf87ee112f57 # v3.36.0
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
- name: Perform CodeQL analysis
uses: github/codeql-action/analyze@03e4368ac7daa2bd82b3e85262f3bf87ee112f57 # v3.36.0
with:
category: "/language:${{ matrix.language }}"
+302 -28
View File
@@ -5,8 +5,30 @@ on:
tags: ["v*"]
workflow_dispatch:
# Least-privilege default for the auto-injected GITHUB_TOKEN. The publish jobs
# (cargo/python/node) push to external registries via their own secrets
# (CARGO_REGISTRY_TOKEN / PYPI_API_TOKEN / NPM_TOKEN), not the GITHUB_TOKEN, so
# they need no repo write. The jobs that genuinely write through the
# GITHUB_TOKEN — github-release (contents: write), node-/wasm-publish and
# attestations (id-token / attestations: write) — declare those rights in their
# own job-level permissions blocks, which override this default (OpenSSF
# Scorecard: Token-Permissions).
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
# Network-flake resilience: retry transient registry/DNS failures at the tool
# level so a blip fetching crates.io / npm inside any build or publish step
# (cargo, napi, maturin, wasm-pack, npm) retries automatically instead of
# failing the job. Cargo treats "couldn't resolve host" / connect / timeout as
# spurious and retries with backoff; 10 attempts ride out a transient DNS blip.
CARGO_NET_RETRY: "10"
CARGO_NET_GIT_FETCH_WITH_CLI: "true"
npm_config_fetch_retries: "5"
npm_config_fetch_retry_maxtimeout: "120000"
PIP_RETRIES: "5"
PIP_DEFAULT_TIMEOUT: "120"
jobs:
# --------------------------------------------------------------------------
@@ -26,6 +48,8 @@ jobs:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable branch, 2026-03-27
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
continue-on-error: true # cache is an optimisation; never block on a stuck/slow restore
timeout-minutes: 6
# Idempotent publishing: if the version is already on crates.io we
# treat that as success so re-runs of the workflow don't fail.
@@ -79,6 +103,35 @@ 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
timeout-minutes: 10 # fail fast on a stuck download instead of hanging the job
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
# --------------------------------------------------------------------------
@@ -103,9 +156,26 @@ jobs:
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
- name: Set up Python
id: setup_python
continue-on-error: true
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.11"
- 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: "3.11"
- name: Sync root README into bindings/python so it ships with the wheel
shell: bash
run: cp README.md bindings/python/README.md
- uses: PyO3/maturin-action@e83996d129638aa358a18fbd1dfb82f0b0fb5d3b # v1.51.0
with:
working-directory: bindings/python
@@ -124,6 +194,8 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Sync root README into bindings/python so it ships in the sdist
run: cp README.md bindings/python/README.md
- uses: PyO3/maturin-action@e83996d129638aa358a18fbd1dfb82f0b0fb5d3b # v1.51.0
with:
working-directory: bindings/python
@@ -168,20 +240,28 @@ jobs:
- { host: macos-latest, target: x86_64-apple-darwin }
- { host: macos-latest, target: aarch64-apple-darwin }
- { host: windows-latest, target: x86_64-pc-windows-msvc }
# NOTE: aarch64-pc-windows-msvc is temporarily skipped for 0.2.1.
# 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
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
- name: Set up Node
id: setup_node
continue-on-error: true
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "20"
- 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: "20"
@@ -190,10 +270,12 @@ jobs:
targets: ${{ matrix.target }}
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
continue-on-error: true # cache is an optimisation; never block on a stuck/slow restore
timeout-minutes: 6
- name: Install Node deps
working-directory: bindings/node
run: npm install
run: npm ci
- name: Build native module
working-directory: bindings/node
@@ -211,17 +293,42 @@ 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
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
- name: Set up Node
id: setup_node
continue-on-error: true
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "20"
registry-url: "https://registry.npmjs.org"
- 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: "20"
registry-url: "https://registry.npmjs.org"
- name: Install Node deps
working-directory: bindings/node
run: npm install
run: npm ci
- name: Download all platform binaries
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
@@ -269,13 +376,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
@@ -289,6 +396,14 @@ jobs:
done
exit $fail
- name: Sync root README into bindings/node so it ships with the npm tarball
# npm reads README.md from the package directory at publish time. Copy
# the canonical root README in just before the publish so every
# registry shows the same project page.
shell: bash
run: cp ../../README.md README.md
working-directory: bindings/node
- name: Publish main package to npm (idempotent)
working-directory: bindings/node
env:
@@ -308,12 +423,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
@@ -341,14 +456,39 @@ 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
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
- name: Set up Node
id: setup_node
continue-on-error: true
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "20"
registry-url: "https://registry.npmjs.org"
- 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: "20"
registry-url: "https://registry.npmjs.org"
@@ -357,7 +497,19 @@ jobs:
with:
targets: wasm32-unknown-unknown
- uses: jetli/wasm-pack-action@0d096b08b4e5a7de8c28de67e11e945404e9eefa # v0.4.0
- name: Install wasm-pack (latest, via prebuilt binary)
# See the matching note in ci.yml: jetli's default installs an old
# 0.10.x wasm-pack whose build subcommand rejects --features.
uses: taiki-e/install-action@0fd46367812ee04360509b4169d9f659d6892bb2 # v2.79.15
timeout-minutes: 10 # fail fast on a stuck download instead of hanging the job
with:
tool: wasm-pack
- name: Sync root README into bindings/wasm so wasm-pack ships it in pkg/
# wasm-pack copies the crate's README.md into the generated pkg/
# directory it then publishes. Refresh it from the canonical root
# README right before the build.
run: cp README.md bindings/wasm/README.md
- name: Build WASM package (bundler target)
run: wasm-pack build bindings/wasm --target bundler --release --features panic-hook
@@ -368,10 +520,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 <support@wickra.org>';
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));
"
@@ -389,7 +541,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:
@@ -397,13 +549,24 @@ jobs:
# --------------------------------------------------------------------------
# GitHub Release: attach every built artefact to the tag's release page.
#
# The release is created as a DRAFT here and only flipped to published by the
# downstream publish-release job, after the provenance bundle is attached. That
# ordering (draft -> attach everything -> publish) makes the pipeline compatible
# with GitHub release immutability, which locks assets at publish time (P24):
# the old "publish, then upload provenance" order would have the provenance
# upload rejected once immutability is enabled.
# --------------------------------------------------------------------------
github-release:
name: Attach assets to the GitHub Release
name: Attach assets to the draft GitHub Release
needs: [cargo-publish, python-publish, node-publish, wasm-publish]
runs-on: ubuntu-latest
permissions:
contents: write
# Expose the resolved tag so the attestations job can attach the provenance
# bundle to this same release without re-resolving it.
outputs:
tag: ${{ steps.tag.outputs.tag }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
@@ -443,10 +606,12 @@ 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)"
- name: Create / update GitHub Release with assets
- name: Create / update the draft GitHub Release with assets
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
with:
tag_name: ${{ steps.tag.outputs.tag }}
@@ -454,6 +619,9 @@ jobs:
files: release-assets/*
generate_release_notes: true
fail_on_unmatched_files: false
# Created as a draft; publish-release flips it to published + latest once
# the provenance bundle is attached (P24, immutability-ready).
draft: true
body: |
Wickra ${{ github.ref_name }} — streaming-first technical indicators across 4 language registries.
@@ -479,4 +647,110 @@ jobs:
### Auto-generated changelog
See below; GitHub computes it from the commits since the previous tag.
See below; GitHub computes it from the commits since the previous tag.
# --------------------------------------------------------------------------
# Build provenance attestations (findings P13.2)
# --------------------------------------------------------------------------
attestations:
name: Attest build provenance
needs: [cargo-publish, python-wheels, python-sdist, github-release]
runs-on: ubuntu-latest
# Signed SLSA build-provenance attestations for the published crates and
# Python wheels/sdist. npm tarballs already carry inline Sigstore provenance
# from `npm publish --provenance`, so they are covered there.
#
# The job stays isolated from the *publishes*: cargo/PyPI/npm all run upstream
# of github-release, so a Sigstore hiccup here can never block or corrupt a
# publish (the isolation the SBOM step lacked before #79). It additionally
# `needs: github-release` so the (still-draft) GitHub Release already exists
# when it attaches the provenance bundle as a release asset (P21.1e) — OpenSSF
# Scorecard's Signed-Releases check scans release *assets* (*.intoto.jsonl),
# not GitHub's separate attestations store, so the bundle has to live on the
# release. The release is published afterwards by the publish-release job
# whether or not this attestation succeeds (P24), so a failure here still only
# costs the provenance asset, never the release.
permissions:
id-token: write # OIDC for keyless Sigstore signing
attestations: write # write the attestations to this repo
contents: write # upload the provenance bundle as a release asset
steps:
- name: Download crate files
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: crate-files
path: artifacts/crates
- name: Download wheels + sdist
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
pattern: wheels-*
path: artifacts/python
merge-multiple: true
- name: Attest build provenance
id: attest
uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0
with:
subject-path: |
artifacts/crates/*.crate
artifacts/python/*.whl
artifacts/python/*.tar.gz
# Attach the Sigstore provenance bundle to the GitHub Release as a
# `*.intoto.jsonl` asset so OpenSSF Scorecard's Signed-Releases check finds
# signed provenance on the release itself (P21.1e). attest-build-provenance
# writes a single JSONL bundle covering every subject above; copy it to a
# `.intoto.jsonl`-suffixed name and upload with --clobber so re-runs are
# idempotent. github.token has contents: write here, which is all gh needs.
- name: Attach provenance bundle to the GitHub Release
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ needs.github-release.outputs.tag }}
BUNDLE: ${{ steps.attest.outputs.bundle-path }}
run: |
if [ -z "$TAG" ]; then
echo "::error::no tag resolved from github-release; cannot attach provenance."
exit 1
fi
if [ -z "$BUNDLE" ] || [ ! -f "$BUNDLE" ]; then
echo "::error::attestation bundle not found at '$BUNDLE'."
exit 1
fi
dest="wickra-${TAG}.provenance.intoto.jsonl"
cp "$BUNDLE" "$dest"
echo "Uploading $dest to release $TAG"
gh release upload "$TAG" "$dest" --clobber --repo "${{ github.repository }}"
# --------------------------------------------------------------------------
# Publish the drafted release LAST (P24 — immutability-ready).
#
# github-release creates the release as a draft and attestations attaches the
# provenance bundle to it; only now, with every asset in place, is it flipped to
# published + latest. With GitHub release immutability enabled, assets lock at
# this publish step — so the provenance bundle and every build artefact are
# already present and never need a (rejected) post-publish upload.
#
# `if: always() && needs.github-release.result == 'success'` preserves the old
# robustness: the release is published whenever the draft was created, even if
# the attestations job hit a Sigstore hiccup — that only costs the provenance
# asset, exactly as before. If github-release was skipped (a publish job failed)
# there is no draft, so this is skipped too and no release is published.
# --------------------------------------------------------------------------
publish-release:
name: Publish the GitHub Release
needs: [github-release, attestations]
if: always() && needs.github-release.result == 'success'
runs-on: ubuntu-latest
permissions:
contents: write # flip the draft release to published
steps:
- name: Flip the draft release to published (latest)
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ needs.github-release.outputs.tag }}
run: |
if [ -z "$TAG" ]; then
echo "::error::no tag resolved from github-release; cannot publish."
exit 1
fi
echo "::notice::publishing release $TAG (draft -> published, latest)"
gh release edit "$TAG" --draft=false --latest=true --repo "${{ github.repository }}"
+49
View File
@@ -0,0 +1,49 @@
name: OpenSSF Scorecard
# Supply-chain / security-posture analysis (findings P13.1). Runs on a weekly
# schedule, on branch-protection changes, and on push to main. `publish_results`
# uploads the score to the public OpenSSF API so the README badge resolves, and
# the SARIF is surfaced under the repo's Security → Code scanning tab.
on:
branch_protection_rule:
schedule:
- cron: '27 7 * * 2' # Tuesdays 07:27 UTC
push:
branches: [main]
workflow_dispatch:
# Read-only by default; the analysis job widens to exactly what it needs.
permissions: read-all
jobs:
analysis:
name: Scorecard analysis
runs-on: ubuntu-latest
permissions:
security-events: write # upload the SARIF result to code-scanning
id-token: write # OIDC token to publish results to the OpenSSF API
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Run Scorecard analysis
uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3
with:
results_file: results.sarif
results_format: sarif
# Publish to the public OpenSSF endpoint that backs the README badge.
publish_results: true
- name: Upload SARIF artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: SARIF file
path: results.sarif
retention-days: 5
- name: Upload SARIF to code-scanning
uses: github/codeql-action/upload-sarif@03e4368ac7daa2bd82b3e85262f3bf87ee112f57 # v3.36.0
with:
sarif_file: results.sarif
+513
View File
@@ -0,0 +1,513 @@
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. Docs site: index.md / overview.md / Indicators-Overview.md
# (wickra-lib/wickra-docs) — synced on push to main / v* tag*
# 4. Marketing site count (wickra-lib/webpage: index.md /
# .vitepress/config.ts) — push to main / v* tag*
# 5. org profile README count (wickra-lib/.github, profile/README.md)
# — synced on push to main / v* tag*
# 6. org description ("… N indicators, install-free.")
# — synced on push to main / v* tag*
# 7. docs site published version (wickra-lib/wickra-docs: the
# "Published versions" table in overview.md + the Rust quickstart prose)
# — synced on v* tag only*
# 8. Marketing site version (wickra-lib/webpage: api/*.md "Latest" lines, the
# nav version label, and the wickra-wasm dep) — synced on v* tag only*
# 9. Wiki pointer page count (wickra-lib/wickra.wiki, Home.md — the wiki was
# collapsed to a single page that points at docs.wickra.org but still names
# the count) — synced on push to main / v* tag*
#
# *Surfaces 3 + 7 need the ABOUT_SYNC_TOKEN to have write on
# wickra-lib/wickra-docs, surfaces 4 + 8 on wickra-lib/webpage; surfaces 5 + 6
# need write on wickra-lib/.github and admin:org for the org-description PATCH;
# surface 9 needs write on wickra-lib/wickra (the wiki rides on the parent
# repo's permission).
# Until that scope is granted these steps emit a ::warning:: and soft-skip —
# they never fail the run. The repo "About" homepage URL is also enforced in
# step 2 (constant value, no extra scope); it points at docs.wickra.org.
#
# Note: surface 7 carries the release *version*, not the indicator count, so
# it is driven by the v* tag (which is the version) rather than the count.
#
# 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:
# Least-privilege default for the auto-injected GITHUB_TOKEN. The `contents:
# write` the workflow needs — to push the counter fix-up commit to the PR head
# branch — is raised at the job level below, not here, so the top-level default
# stays read-only (OpenSSF Scorecard: Token-Permissions). The wider About /
# docs / webpage / org writes still go through the fine-grained PAT
# (ABOUT_SYNC_TOKEN), which the `permissions:` key does not govern at all.
permissions:
contents: read
pull-requests: read
jobs:
sync:
runs-on: ubuntu-latest
# The only GITHUB_TOKEN write in this workflow: pushing the counter fix-up
# commit onto a same-repo PR head branch (git push origin HEAD:<ref>).
permissions:
contents: write
pull-requests: read
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
# Untrusted PR contexts (head.ref / head.repo.full_name are attacker
# controlled on fork PRs) are passed through the environment, never
# interpolated straight into the shell, so a crafted branch name cannot
# inject commands (OpenSSF Scorecard: Dangerous-Workflow).
env:
EVENT_NAME: ${{ github.event_name }}
HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }}
BASE_REPO: ${{ github.repository }}
HEAD_REF: ${{ github.event.pull_request.head.ref }}
run: |
if [ "$EVENT_NAME" = "pull_request" ]; then
if [ "$HEAD_REPO" = "$BASE_REPO" ]; then
echo "can_push=true" >> "$GITHUB_OUTPUT"
echo "head_ref=$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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
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
# Bump the banner cache-buster so GitHub's Camo proxy refetches the org
# profile image (regenerated with the new count by .github/banner.yml)
# instead of serving a stale cached copy.
sed -i -E "s|(wickra-banner\.webp\?v=)[0-9]+|\1${n}|" 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'
# head_ref still carries the (untrusted) PR branch name forwarded by the
# ctx step; pass it through the environment so the push refspec cannot be
# used to inject shell commands (OpenSSF Scorecard: Dangerous-Workflow).
env:
COUNT: ${{ steps.count.outputs.count }}
HEAD_REF: ${{ steps.ctx.outputs.head_ref }}
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 ${COUNT}"
git push origin "HEAD:${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 + homepage)
if: github.event_name != 'pull_request'
env:
GH_TOKEN: ${{ secrets.ABOUT_SYNC_TOKEN }}
run: |
n="${{ steps.count.outputs.count }}"
# Canonical homepage — the docs site (P8.3). This is enforced on every
# run, so it must only point at docs.wickra.org once that domain is
# actually live (Cloudflare Pages, P8.1); merging this PR is therefore
# gated on the domain resolving, otherwise the About link would 404.
homepage="https://docs.wickra.org"
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."
# Enforce the homepage unconditionally — it is a constant, so this both
# corrects the stale kingchenc URL and self-heals any future drift.
# Same Administration-write permission as --description (no extra scope).
gh repo edit --homepage "$homepage"
current=$(gh repo view --json description -q .description)
if [ "$current" = "$desc" ]; then
echo "About description unchanged; homepage enforced."
else
gh repo edit --description "$desc"
echo "About description + homepage updated."
fi
# Counter sync target moved from the retired GitHub wiki to the docs site
# repo (wickra-lib/wickra-docs). The count appears in index.md (hero),
# overview.md prose, and Indicators-Overview.md prose. Soft-skips like the
# org steps so a token/scope gap never fails the run. Uses its own clone
# dir (docs-count) so it cannot collide with the tag-only version step
# below, which clones the same repo into `docs`.
- name: Sync docs indicator count (wickra-docs)
if: github.event_name != 'pull_request'
continue-on-error: true
env:
GH_TOKEN: ${{ secrets.ABOUT_SYNC_TOKEN }}
run: |
n="${{ steps.count.outputs.count }}"
if ! git clone "https://x-access-token:${GH_TOKEN}@github.com/wickra-lib/wickra-docs.git" docs-count 2>/dev/null; then
echo "::warning::cannot clone wickra-lib/wickra-docs — ABOUT_SYNC_TOKEN likely lacks write on that repo (findings P10.0a). Skipping docs count sync."
exit 0
fi
cd docs-count
sed -i -E "s/[0-9]+ (streaming-first )?indicators/${n} \1indicators/g" index.md overview.md Indicators-Overview.md
if git diff --quiet; then
echo "Docs indicator count unchanged."
exit 0
fi
git config user.name "wickra-bot"
git config user.email "wickra-bot@users.noreply.github.com"
git add index.md overview.md Indicators-Overview.md
git commit -m "chore: sync indicator count to ${n}"
if ! git push 2>/dev/null; then
echo "::warning::push to wickra-lib/wickra-docs failed — ABOUT_SYNC_TOKEN likely lacks write (findings P10.0a)."
else
echo "Docs indicator count synced to ${n}."
fi
# The GitHub wiki (wickra-lib/wickra.wiki) was collapsed to a single
# Home.md pointer page that sends visitors to docs.wickra.org, but that
# page still names the count ("… for all N indicators"), so keep it in
# sync here too. Mirrors the docs/webpage count steps: own clone dir
# (wiki-count) and the same soft-skip contract. Wiki write rides on the
# parent repo's permission, so the PAT needs write on wickra-lib/wickra;
# the wiki has no signing gate, so a plain wickra-bot commit is fine.
- name: Sync wiki pointer indicator count (wickra.wiki)
if: github.event_name != 'pull_request'
continue-on-error: true
env:
GH_TOKEN: ${{ secrets.ABOUT_SYNC_TOKEN }}
run: |
n="${{ steps.count.outputs.count }}"
if ! git clone "https://x-access-token:${GH_TOKEN}@github.com/wickra-lib/wickra.wiki.git" wiki-count 2>/dev/null; then
echo "::warning::cannot clone wickra-lib/wickra.wiki — ABOUT_SYNC_TOKEN likely lacks write on the wiki. Skipping wiki count sync."
exit 0
fi
cd wiki-count
sed -i -E "s/[0-9]+ (streaming-first )?indicators/${n} \1indicators/g" Home.md
if git diff --quiet; then
echo "Wiki pointer indicator count unchanged."
exit 0
fi
git config user.name "wickra-bot"
git config user.email "wickra-bot@users.noreply.github.com"
git add Home.md
git commit -m "chore: sync indicator count to ${n}"
if ! git push 2>/dev/null; then
echo "::warning::push to wickra-lib/wickra.wiki failed — ABOUT_SYNC_TOKEN likely lacks write on the wiki."
else
echo "Wiki pointer indicator count synced to ${n}."
fi
# ----- org-profile sync (soft-skip until PAT scope lands) -------
#
# These two steps keep the org page (github.com/wickra-lib) in sync
# with the same count. They need ABOUT_SYNC_TOKEN scope the main-repo
# syncs do not: write on wickra-lib/.github, and admin:org for the org
# description PATCH. Both are written to soft-skip with a ::warning::
# (never fail the run) so this workflow stays green before the scope is
# granted — once it is, they start syncing with no further code change.
- name: Sync org profile README count
if: github.event_name != 'pull_request'
continue-on-error: true
env:
GH_TOKEN: ${{ secrets.ABOUT_SYNC_TOKEN }}
run: |
n="${{ steps.count.outputs.count }}"
if ! git clone "https://x-access-token:${GH_TOKEN}@github.com/wickra-lib/.github.git" orgprofile 2>/dev/null; then
echo "::warning::cannot clone wickra-lib/.github — ABOUT_SYNC_TOKEN likely lacks write on that repo (findings P10.0a). Skipping org profile sync."
exit 0
fi
cd orgprofile
sed -i -E "s/[0-9]+ (streaming-first )?indicators/${n} \1indicators/g" profile/README.md
if git diff --quiet; then
echo "Org profile README count unchanged."
exit 0
fi
git config user.name "wickra-bot"
git config user.email "wickra-bot@users.noreply.github.com"
git add profile/README.md
git commit -m "chore: sync indicator count to ${n}"
if ! git push 2>/dev/null; then
echo "::warning::push to wickra-lib/.github failed — ABOUT_SYNC_TOKEN likely lacks write (findings P10.0a)."
else
echo "Org profile README synced to ${n}."
fi
- name: Sync org description
if: github.event_name != 'pull_request'
continue-on-error: true
env:
GH_TOKEN: ${{ secrets.ABOUT_SYNC_TOKEN }}
run: |
n="${{ steps.count.outputs.count }}"
org="wickra-lib"
# Reading the org description is public; the PATCH needs admin:org.
current=$(gh api "orgs/${org}" --jq '.description // ""' 2>/dev/null || true)
if [ -z "$current" ]; then
echo "::warning::could not read org description (network/PAT?). Skipping."
exit 0
fi
updated=$(printf '%s' "$current" | sed -E "s/[0-9]+ indicators/${n} indicators/")
if [ "$current" = "$updated" ]; then
echo "Org description count unchanged."
exit 0
fi
if gh api -X PATCH "orgs/${org}" -f description="$updated" >/dev/null 2>&1; then
echo "Org description synced to ${n}."
else
echo "::warning::org description PATCH failed — ABOUT_SYNC_TOKEN likely lacks admin:org (findings P10.0b)."
fi
# ----- docs version sync (tag-only, soft-skip until PAT scope lands) -----
#
# Surface 7: the docs site (wickra-lib/wickra-docs) carries the published
# version in the "Published versions" table (overview.md) and the Rust
# quickstart prose. Unlike the indicator count these change only on a
# release, so this step runs on v* tag pushes only and takes the version
# straight from the tag. It needs ABOUT_SYNC_TOKEN to have write on
# wickra-lib/wickra-docs (findings P10.0a). Until that scope is granted it
# soft-skips with a ::warning:: and never fails the run; once granted, every
# release self-heals the docs version with no code change (replaces the old
# manual P0.5 post-release wiki bump).
- name: Sync docs version (wickra-docs)
if: startsWith(github.ref, 'refs/tags/v')
continue-on-error: true
env:
GH_TOKEN: ${{ secrets.ABOUT_SYNC_TOKEN }}
run: |
version="${GITHUB_REF#refs/tags/v}"
if ! printf '%s' "$version" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "::warning::tag '${GITHUB_REF}' is not a plain vMAJOR.MINOR.PATCH release; skipping docs version sync."
exit 0
fi
# Clone into `docs-ver`, NOT `docs`: on a tag push this job checks out
# the wickra repo at the workspace root, which already contains a
# top-level `docs/` directory, so `git clone … docs` fails with
# "destination path 'docs' already exists" — silently, because of the
# 2>/dev/null below — and the version sync never runs (this is exactly
# why v0.4.0 did not bump the docs table). `docs-ver` mirrors the
# `docs-count` dir used by the count step above and collides with
# nothing in the repo.
if ! git clone "https://x-access-token:${GH_TOKEN}@github.com/wickra-lib/wickra-docs.git" docs-ver 2>/dev/null; then
echo "::warning::cannot clone wickra-lib/wickra-docs — ABOUT_SYNC_TOKEN likely lacks write on that repo (findings P10.0a). Skipping docs version sync."
exit 0
fi
cd docs-ver
# Published-versions table rows (crates.io / PyPI / npm): replace only the
# version number, leaving the trailing padding + pipe intact. The '.' in
# the quickstart pattern matches the literal backtick around the version
# without needing a backtick in this shell string. Historical "since
# X.Y.Z" references contain no such anchor and are never matched.
sed -i -E "s/^(\| (crates\.io|PyPI|npm) .*\| )[0-9]+\.[0-9]+\.[0-9]+/\1${version}/" overview.md
sed -i -E "s/(published crate is at version .)[0-9]+\.[0-9]+\.[0-9]+/\1${version}/" Quickstart-Rust.md
if git diff --quiet; then
echo "Docs version already at ${version}."
exit 0
fi
git config user.name "wickra-bot"
git config user.email "wickra-bot@users.noreply.github.com"
git add overview.md Quickstart-Rust.md
git commit -m "chore: sync published version to ${version}"
if ! git push 2>/dev/null; then
echo "::warning::push to wickra-lib/wickra-docs failed — ABOUT_SYNC_TOKEN likely lacks write (findings P10.0a)."
else
echo "Docs version synced to ${version}."
fi
# ----- webpage (marketing site) self-update (findings P12.1) ------------
#
# The marketing site (wickra-lib/webpage) carries the same indicator count
# and published version as the docs. Mirrors the docs steps above: the
# count syncs on push-to-main + tag, the version syncs on v* tags only.
# Distinct clone dirs (webpage-count / webpage-ver) avoid any collision on
# a tag run. Soft-skips with a ::warning:: if the token can't reach the
# repo, so the run never fails.
- name: Sync webpage indicator count (wickra-lib/webpage)
if: github.event_name != 'pull_request'
continue-on-error: true
env:
GH_TOKEN: ${{ secrets.ABOUT_SYNC_TOKEN }}
run: |
n="${{ steps.count.outputs.count }}"
if ! git clone "https://x-access-token:${GH_TOKEN}@github.com/wickra-lib/webpage.git" webpage-count 2>/dev/null; then
echo "::warning::cannot clone wickra-lib/webpage — ABOUT_SYNC_TOKEN likely lacks write on that repo (findings P10.0a). Skipping webpage count sync."
exit 0
fi
cd webpage-count
sed -i -E "s/[0-9]+ (streaming-first )?indicators/${n} \1indicators/g" index.md .vitepress/config.ts
if git diff --quiet; then
echo "Webpage indicator count unchanged."
exit 0
fi
git config user.name "wickra-bot"
git config user.email "wickra-bot@users.noreply.github.com"
git add index.md .vitepress/config.ts
git commit -m "chore: sync indicator count to ${n}"
if ! git push 2>/dev/null; then
echo "::warning::push to wickra-lib/webpage failed — ABOUT_SYNC_TOKEN likely lacks write (findings P10.0a)."
else
echo "Webpage indicator count synced to ${n}."
fi
- name: Sync webpage version (wickra-lib/webpage)
if: startsWith(github.ref, 'refs/tags/v')
continue-on-error: true
env:
GH_TOKEN: ${{ secrets.ABOUT_SYNC_TOKEN }}
run: |
version="${GITHUB_REF#refs/tags/v}"
if ! printf '%s' "$version" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "::warning::tag '${GITHUB_REF}' is not a plain vMAJOR.MINOR.PATCH release; skipping webpage version sync."
exit 0
fi
# The webpage pins wickra-wasm to the released version in package.json,
# and its Cloudflare Pages build runs `npm clean-install`. release.yml
# publishes wickra-wasm to npm in parallel on this same tag and finishes
# minutes later, so committing the bump immediately would point the site
# at a version npm cannot resolve yet (ETARGET) and break the build —
# exactly what happened on v0.4.0. Wait until wickra-wasm@$version is
# actually live on npm before committing; if it never appears (the wasm
# publish failed), skip rather than push a build-breaking commit.
echo "Waiting for wickra-wasm@${version} on npm before bumping the webpage..."
attempts=0
until npm view "wickra-wasm@${version}" version >/dev/null 2>&1; do
attempts=$((attempts + 1))
if [ "$attempts" -ge 30 ]; then
echo "::warning::wickra-wasm@${version} not on npm after ~15 min; skipping webpage version sync to avoid a broken Cloudflare build."
exit 0
fi
echo " not on npm yet (attempt ${attempts}/30); waiting 30s..."
sleep 30
done
echo "wickra-wasm@${version} is live on npm; proceeding with the webpage version bump."
if ! git clone "https://x-access-token:${GH_TOKEN}@github.com/wickra-lib/webpage.git" webpage-ver 2>/dev/null; then
echo "::warning::cannot clone wickra-lib/webpage — ABOUT_SYNC_TOKEN likely lacks write (findings P10.0a). Skipping webpage version sync."
exit 0
fi
cd webpage-ver
# api/*.md "Latest" lines, the nav version label, and the wickra-wasm
# dep pin. The '.' anchors match the backtick / quote / caret without a
# literal in this shell string; historical "Since X.Y.Z" prose has no
# such anchor and is never matched.
sed -i -E "s/(Latest:\*\* \[.wickra(-wasm)? )[0-9]+\.[0-9]+\.[0-9]+/\1${version}/" api/*.md
sed -i -E "s/(text: .v)[0-9]+\.[0-9]+\.[0-9]+/\1${version}/" .vitepress/config.ts
sed -i -E "s/(.wickra-wasm.: .\^)[0-9]+\.[0-9]+\.[0-9]+/\1${version}/" package.json
# Keep package-lock.json in sync with the package.json bump. The site's
# Cloudflare build runs `npm clean-install` (npm ci), which hard-fails
# with EUSAGE if the lockfile still pins the previous wickra-wasm —
# editing package.json alone is not enough. The npm-wait above already
# proved wickra-wasm@$version is resolvable, so --package-lock-only
# regenerates the lock (version + resolved + integrity) without fetching
# node_modules. Guard it: if the regen fails, skip the whole commit so we
# never push a package.json/lock mismatch that would break the build.
if ! npm install --package-lock-only --no-audit --no-fund; then
echo "::warning::could not regenerate package-lock.json for wickra-wasm@${version}; skipping webpage version sync to avoid a lockfile-drift build break."
exit 0
fi
if git diff --quiet; then
echo "Webpage version already at ${version}."
exit 0
fi
git config user.name "wickra-bot"
git config user.email "wickra-bot@users.noreply.github.com"
git add api/*.md .vitepress/config.ts package.json package-lock.json
git commit -m "chore: sync published version to ${version}"
if ! git push 2>/dev/null; then
echo "::warning::push to wickra-lib/webpage failed — ABOUT_SYNC_TOKEN likely lacks write (findings P10.0a)."
else
echo "Webpage version synced to ${version}."
fi
+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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: "3.12"
- name: Audit repo-metadata.toml drift
run: python .github/scripts/sync-metadata.py --check
+7 -2
View File
@@ -44,9 +44,14 @@ tarpaulin-report.html
# Node binding artifacts
**/node_modules/
bindings/node/*.node
bindings/node/index.d.ts
bindings/node/npm-debug.log*
package-lock.json
# index.js + index.d.ts are generated by `napi build` but committed (a matched
# pair) so consumers and the repo get TypeScript types; CONTRIBUTING requires
# regenerating both when a binding's public API changes.
# package-lock.json is committed for the tracked Node packages — bindings/node/
# and examples/node/ — so contributors get reproducible npm installs. There is
# no top-level npm package, and the ghost-ignored site/ keeps its lockfile local.
# See CONTRIBUTING.md "Lockfile policy" for the full per-component breakdown.
# 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).
+668 -8
View File
@@ -7,6 +7,657 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [0.4.3] - 2026-06-01
### Added
- **Microstructure family — price impact & depth (part 3).** Indicators over a
trade paired with the prevailing mid (`TradeQuote`) and over the order-book
depth profile, exposed in Rust, Python, Node and WASM:
- **Effective Spread** — `2 · D · (tradePrice mid) / mid · 10_000` bps, the
realised round-trip cost of a single trade against the mid.
- **Realized Spread** — `2 · D · (tradePrice mid_{t+horizon}) / mid_t ·
10_000` bps, the share of the effective spread a liquidity provider keeps
once the mid has moved over a configurable horizon.
- **Kyle's Lambda** — the rolling OLS slope of mid changes on signed volume
(`cov(Δmid, q) / var(q)`), the canonical price-impact / market-depth proxy.
- **Depth Slope** — the mean per-side OLS slope of cumulative resting size
against distance from the mid, measuring how fast the book thickens away
from the touch.
- **Microstructure family — footprint (part 4).** **Footprint** decomposes the
volume traded in a bar across price buckets (`round(price / tick_size)`),
splitting each bucket into buy-initiated (ask) and sell-initiated (bid)
volume. A multi-output, variable-length indicator: every `update` returns the
full footprint accumulated since the last `reset`, exposed in Rust, Python
(`(k, 3)` arrays), Node (`{ price, bidVol, askVol }` rows) and WASM.
## [0.4.2] - 2026-06-01
### Added
- **Microstructure family — order book (part 1).** A new family of indicators
that consume an order-book depth snapshot (`OrderBook` of sorted, uncrossed
bid/ask `Level`s) rather than OHLCV, exposed in Rust, Python, Node and WASM:
- **Order-Book Imbalance** — `OrderBookImbalanceTop1`, `OrderBookImbalanceTopN`
(configurable depth) and `OrderBookImbalanceFull` measure signed depth
pressure `(bidDepth askDepth) / (bidDepth + askDepth)` over the top level,
the top-N levels, or the full book.
- **Microprice** — the size-weighted fair value
`(bidPx·askSz + askPx·bidSz) / (bidSz + askSz)`, tilting the mid toward the
side more likely to be hit.
- **Quoted Spread** — the top-of-book spread in basis points of the mid.
- **Microstructure family — trade flow (part 2).** Indicators over a trade tape
(`Trade` with an aggressor `Side`), exposed in Rust, Python, Node and WASM:
- **Signed Volume** — per-trade size signed by aggressor side (`+size` buy,
`size` sell).
- **Cumulative Volume Delta** — the running total of signed volume; reset to
re-anchor per session.
- **Trade Imbalance** — the rolling `(buyVol sellVol)/(buyVol + sellVol)`
over a configurable window of trades.
New public value types `Level`, `OrderBook`, `Side`, `Trade` and `TradeQuote`
back this and the upcoming trade-flow and price-impact indicators. Python and
Node accept a batch over a list of snapshots; WASM exposes per-snapshot
`update`.
- **Signed Doji encoding.** `Doji` gains an opt-in `.signed()` mode
(`Doji(signed=True)` in Python, `new Doji(true)` in Node and WASM) that
classifies a detected Doji by the position of its body within the bar range —
a dragonfly (long lower shadow) emits `+1.0` (bullish), a gravestone (long
upper shadow) emits `1.0` (bearish), and a long-legged / standard Doji emits
`0.0` (neutral). The default construction is unchanged — a direction-less
`+1.0` / `0.0` detection flag — so existing callers are unaffected. This
completes the uniform `+1` bull / `1` bear / `0` none sign convention across
every candlestick pattern, making the family a drop-in machine-learning
feature where bullish and bearish instances share a single dimension.
### Fixed
- **README banner now self-updates.** The top README banner points at the org
profile image that `.github/banner.yml` regenerates from the indicator count,
and `sync-about.yml` bumps a `?v=<count>` cache-buster so GitHub's Camo proxy
refetches it immediately. Also fixes the webpage indicator-count sync, which
silently crashed on a removed `public/hero.svg` and left the marketing site's
count (and its OG banner) stale.
### Security
- **CI dependency installs are pinned by hash.** The Node binding now installs
with `npm ci` (strict `package-lock.json`), and the Python CI/bench tooling is
installed from hash-locked `--require-hashes` requirements under
`.github/requirements/` (OpenSSF Scorecard PinnedDependencies). The `ci-dev`
tooling is locked twice — for Python 3.9 and for 3.10+ — because numpy ships no
single release with wheels for both cp39 and cp313. A new
`scripts/update-lockfiles.sh` regenerates every workspace lockfile (Rust, Node
and the hash-pinned Python requirements) via `uv`, and Dependabot keeps the
pinned requirements current.
## [0.4.1] - 2026-06-01
### Added
- **Cross-asset pairwise indicators.** A new two-series family of
`Indicator<Input = (f64, f64)>` implementations that relate two distinct
assets rather than a single OHLCV stream. Each is exposed in Rust, Python,
Node, and WASM:
- **Pairwise Beta** (`PairwiseBeta`) — rolling OLS slope of one asset's
**log-returns** on another's. Unlike `Beta`, which regresses the raw inputs
it is fed, `PairwiseBeta` differences consecutive prices into log-returns
internally — the conventional way to measure cross-asset beta, where a beta
on price levels would be dominated by the shared trend.
- **Pair Spread Z-Score** (`PairSpreadZScore`) — the standardised log-spread
`ln(a) β·ln(b)` of a pair, where `β` is a rolling-OLS hedge ratio and the
spread is z-scored over its own look-back. The canonical mean-reversion /
statistical-arbitrage entry signal, with independent `beta_period` and
`z_period` windows.
- **LeadLag Cross-Correlation** (`LeadLagCrossCorrelation`) — the integer
offset `k ∈ [max_lag, max_lag]` that maximises `|corr(a[t], b[t+k])|`,
answering which of two assets leads the other and by how many bars. Emits
`{ lag, correlation }`; a positive lag means `a` leads `b`.
- **Cointegration** (`Cointegration`) — the EngleGranger two-step screen for
pairs trading: a rolling OLS hedge ratio `β`, the spread (residual)
`a (α + β·b)`, and an augmented DickeyFuller `t`-statistic on the spread
(configurable `adf_lags`). A strongly negative statistic flags a
mean-reverting, tradeable spread. Emits `{ hedge_ratio, spread, adf_stat }`.
- **Relative Strength A-vs-B** (`RelativeStrengthAB`) — the comparative
relative strength of two assets: the ratio line `a / b` together with its
moving average and its RSI, the classic asset-vs-asset / asset-vs-index
rotation screen. Emits `{ ratio, ratio_ma, ratio_rsi }`.
## [0.4.0] - 2026-06-01
### Added
- **Build-provenance attestations for release artifacts.** The release workflow
now emits signed SLSA build-provenance attestations for the published crates
and Python wheels/sdist (`actions/attest-build-provenance`); npm packages
carry inline Sigstore provenance from `npm publish --provenance`. Every
published artifact is cryptographically traceable to this repository's release
workflow run.
### Security
- **CodeQL static analysis and OpenSSF Scorecard run in CI.** CodeQL (Rust,
Python, JavaScript) and the OpenSSF Scorecard workflow now run on every push;
results appear under Security → Code scanning and a public Scorecard badge is
shown in the README.
- **CI workflows hardened against script injection.** Untrusted event contexts
(PR branch names, `workflow_dispatch` inputs) are passed through the step
environment instead of being interpolated directly into shell commands.
### Changed
- **Node binding: invalid indicator periods now throw instead of being silently
clamped.** The scalar-indicator constructors previously clamped `period = 0`
to `1`; every Node constructor now propagates the core's validation error
(e.g. `period must be greater than zero`), matching the Python and WASM
bindings and the Rust core. Constructing with a valid period is unaffected.
- **Binding package READMEs are now per-ecosystem.** The Python, Node.js, and
WebAssembly READMEs were byte-identical 314-line copies of the workspace
README and had drifted out of sync (stale indicator count, Python snippets
shown on the Node and WASM package pages). Each is now a focused landing page
with the correct install command, a language-correct quick-start snippet, and
links to the canonical documentation — removing the manual three-way sync
burden. No code or API changes.
- **CONTRIBUTING now states the correct MSRV (1.86 workspace / 1.88
`bindings/node`)** and documents that these are the dependency-forced floors,
kept minimal on purpose. The previous text claimed 1.75 / 1.77, which the
`msrv` CI job has enforced against since the criterion and napi-build bumps.
## [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
- `BinanceConfig` plus `BinanceKlineStream::connect_with_config(symbols, interval, config)`
in `wickra-data`'s `live::binance` module. `connect()` keeps its previous
signature and now forwards to the new entry-point with the defaults, so the
public API is backwards-compatible. The config lets callers point the
stream at Binance Testnet (`wss://testnet.binance.vision`) or tune the
read timeout, reconnect attempt count, initial / capped backoff and frame
size limits without rewriting the connector.
- README **Disclaimer** section clarifying that Wickra is an indicator
toolkit (not a trading system) and that any production-trading use is at
the caller's own risk. The legal terms in [LICENSE](LICENSE) are
unchanged.
### Changed
- `BinanceKlineStream::next_event` now writes the Pong reply to a server
`Ping` on a best-effort basis. A failed write means the connection is
already dead, so the existing timeout / read-error reconnect arm one
loop iteration later picks it up — the previous explicit reconnect on
Pong-write failure is gone. Observable behaviour is unchanged for every
healthy connection.
## [0.2.1] - 2026-05-23
### Changed
@@ -329,11 +980,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
optional Binance live feed.
- Bindings for Python, Node.js, and WebAssembly.
[Unreleased]: https://github.com/kingchenc/wickra/compare/v0.2.1...HEAD
[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.4.3...HEAD
[0.4.3]: https://github.com/wickra-lib/wickra/compare/v0.4.2...v0.4.3
[0.4.2]: https://github.com/wickra-lib/wickra/compare/v0.4.1...v0.4.2
[0.4.1]: https://github.com/wickra-lib/wickra/compare/v0.4.0...v0.4.1
[0.4.0]: https://github.com/wickra-lib/wickra/compare/v0.3.1...v0.4.0
[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: support@wickra.org
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 **support@wickra.org**. All reports will be reviewed and investigated
promptly and fairly, and the maintainer will respect the privacy and security
of the reporter.
+35 -5
View File
@@ -22,7 +22,7 @@ when proposing features or depending on Wickra elsewhere.
| `bindings/node` | napi-rs bindings (`wickra` on npm). |
| `bindings/wasm` | wasm-bindgen bindings (`wickra-wasm` on npm). |
| `examples/` | Runnable examples. |
| `docs/wiki/` | Documentation sources. |
| `docs/` | Pointer to the project Wiki, which holds all documentation. |
## Building and testing
@@ -35,8 +35,13 @@ cargo test --workspace
cargo test -p wickra-data --features live-binance
```
The minimum supported Rust version is **1.75** for the workspace crates and
**1.77** for `bindings/node`; the `msrv` CI job enforces both.
The minimum supported Rust version is **1.86** for the workspace crates and
**1.88** for `bindings/node`; the `msrv` CI job enforces both. These floors are
not chosen freely — they are the lowest versions our dependencies allow
(criterion 0.8.2, the bench dev-dependency, requires 1.86; napi-build 2.3.2
requires 1.88). We keep the MSRV at that dependency-forced floor on purpose so
the library builds for the widest possible audience; please don't raise it
without a dependency that actually requires it.
### Python
@@ -63,6 +68,29 @@ wasm-pack build bindings/wasm --target web --release --features panic-hook
wasm-pack test --node bindings/wasm
```
## Lockfile policy
| Component | Lockfile | Tracked? | Why |
| --- | --- | --- | --- |
| Workspace (Rust) | `Cargo.lock` | **yes** | The workspace ships binaries (examples, fuzz harness) and CI builds, so the dependency graph is pinned for reproducible builds. |
| `bindings/node` | `package-lock.json` | **yes** | Reproducible `npm install` for the native binding. |
| `examples/node` | `package-lock.json` | **yes** | Same — the runnable Node examples link the binding via a `file:` dependency. |
| `bindings/python` | — | n/a (no lockfile) | The published package pins only `numpy>=1.22` at runtime; its native code is pinned through the workspace `Cargo.lock`. The CI/bench dev tooling it installs is hash-locked separately — see the `.github/requirements` row. |
| `.github/requirements` | `*.txt` (hash-pinned) | **yes** | CI/bench Python tooling, locked with `uv pip compile --generate-hashes` (OpenSSF Scorecard PinnedDependencies). `ci-dev` is split per Python version — `ci-dev-py39.txt` and `ci-dev-py3.txt` — because numpy ships no single release with wheels for both cp39 and cp313; `bench.txt` covers the single-version bench job. |
| `fuzz` | `fuzz/Cargo.lock` | **no** (ignored) | `fuzz/` is a detached crate; `cargo-fuzz init` generates `fuzz/.gitignore` which ignores its `Cargo.lock`. The fuzz smoke job resolves dependencies fresh, so the lock is not needed for reproducibility here. |
| `site` (marketing) | `package-lock.json` | **no** (ghost-ignored) | The VitePress site is a local-only project excluded via `.git/info/exclude`; its lockfile stays local. |
When adding a new committed Node package, commit its `package-lock.json` too and
remove any matching ignore rule. Do **not** add a top-level `package-lock.json`
the repository root is not an npm package.
To refresh every committed lockfile in the workspace — `Cargo.lock`,
`fuzz/Cargo.lock`, the Node binding lock, and the hash-pinned Python
requirements — run `./scripts/update-lockfiles.sh`. It uses `uv` for the Python
locks (and bootstraps it on Linux/macOS if absent) so each target Python
version's hashed transitive closure can be regenerated without that interpreter
installed. Dependabot also keeps the `.github/requirements` pins current.
## Standards for a change
- **Formatting & lints.** `cargo fmt` must leave the tree unchanged and
@@ -75,8 +103,10 @@ wasm-pack test --node bindings/wasm
of `update` calls.
- **Bindings.** A change to a public indicator API must be mirrored across the
Python, Node, and WASM bindings, including their type stubs / `.d.ts`.
- **Docs.** Update the relevant page under `docs/wiki/` and the `README.md`
when behaviour or the public API changes.
- **Docs.** Update the relevant page on the
[documentation site](https://docs.wickra.org) and the
`README.md` when behaviour or the public API changes. The docs live in
a separate git repository: `https://github.com/wickra-lib/wickra-docs`.
- **Changelog.** Add an entry under `## [Unreleased]` in `CHANGELOG.md`.
## Commit and pull-request workflow
Generated
+6 -6
View File
@@ -1867,7 +1867,7 @@ dependencies = [
[[package]]
name = "wickra"
version = "0.2.1"
version = "0.4.3"
dependencies = [
"approx",
"criterion",
@@ -1878,7 +1878,7 @@ dependencies = [
[[package]]
name = "wickra-core"
version = "0.2.1"
version = "0.4.3"
dependencies = [
"approx",
"proptest",
@@ -1888,7 +1888,7 @@ dependencies = [
[[package]]
name = "wickra-data"
version = "0.2.1"
version = "0.4.3"
dependencies = [
"approx",
"csv",
@@ -1915,7 +1915,7 @@ dependencies = [
[[package]]
name = "wickra-node"
version = "0.2.1"
version = "0.4.3"
dependencies = [
"napi",
"napi-build",
@@ -1925,7 +1925,7 @@ dependencies = [
[[package]]
name = "wickra-python"
version = "0.2.1"
version = "0.4.3"
dependencies = [
"numpy",
"pyo3",
@@ -1934,7 +1934,7 @@ dependencies = [
[[package]]
name = "wickra-wasm"
version = "0.2.1"
version = "0.4.3"
dependencies = [
"console_error_panic_hook",
"js-sys",
+6 -6
View File
@@ -12,19 +12,19 @@ members = [
exclude = ["fuzz"]
[workspace.package]
version = "0.2.1"
authors = ["kingchenc <kingchencp@gmail.com>"]
version = "0.4.3"
authors = ["kingchenc <support@wickra.org>"]
edition = "2021"
rust-version = "1.86"
license = "PolyForm-Noncommercial-1.0.0"
repository = "https://github.com/kingchenc/wickra"
homepage = "https://github.com/kingchenc/wickra"
license-file = "LICENSE"
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.1" }
wickra-core = { path = "crates/wickra-core", version = "0.4.3" }
thiserror = "2"
rayon = "1.10"
+27 -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
@@ -131,6 +131,31 @@ software under these terms.
**Use** means anything you do with the software requiring one
of your licenses.
## Additional Permissions Granted by the Licensor
These additional permissions supplement the PolyForm Noncommercial
License 1.0.0 above. They only broaden, and never narrow, the
licenses granted to you. The text of the PolyForm Noncommercial
License 1.0.0 above is unmodified.
Use by a natural person, acting for their own personal account and
not on behalf of any third party, is use for a permitted purpose.
This includes operating an automated trading bot or trading strategy
on that person's own capital, whether or not it earns that person
money.
For the avoidance of doubt, the licenses above already let you use,
fork, modify, and redistribute the software, and file issues and
contribute changes, for any permitted purpose. Personal projects,
research, education, nonprofit organizations, government use, and
hobby trading bots are permitted purposes.
Any other commercial use — in particular the commercial sale of the
software itself, or the commercial sale of services built around it —
requires a separate commercial license from the licensor. If you want
to use Wickra commercially, get in touch about a license at
<https://github.com/wickra-lib/wickra>.
---
Required Notice: Copyright 2026 kingchenc (https://github.com/kingchenc/wickra)
Required Notice: Copyright 2026 kingchenc (https://github.com/wickra-lib/wickra)
+86 -34
View File
@@ -1,11 +1,18 @@
# Wickra
<p align="center">
<a href="https://wickra.org"><img src="https://raw.githubusercontent.com/wickra-lib/.github/main/profile/wickra-banner.webp?v=232" alt="Wickra — streaming-first technical indicators" width="100%"></a>
</p>
[![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)
[![CodeQL](https://github.com/wickra-lib/wickra/actions/workflows/codeql.yml/badge.svg)](https://github.com/wickra-lib/wickra/actions/workflows/codeql.yml)
[![codecov](https://codecov.io/gh/wickra-lib/wickra/branch/main/graph/badge.svg)](https://codecov.io/gh/wickra-lib/wickra)
[![GitHub release](https://img.shields.io/github/v/release/wickra-lib/wickra?logo=github&color=green)](https://github.com/wickra-lib/wickra/releases/latest)
[![crates.io](https://img.shields.io/crates/v/wickra.svg?logo=rust&color=orange)](https://crates.io/crates/wickra)
[![PyPI](https://img.shields.io/pypi/v/wickra.svg?logo=pypi&color=blue)](https://pypi.org/project/wickra/)
[![npm](https://img.shields.io/npm/v/wickra.svg?logo=npm&color=red)](https://www.npmjs.com/package/wickra)
[![License: PolyForm-NC](https://img.shields.io/badge/license-PolyForm--NC--1.0.0-purple)](LICENSE)
[![OpenSSF Scorecard](https://api.securityscorecards.dev/projects/github.com/wickra-lib/wickra/badge)](https://scorecard.dev/viewer/?uri=github.com/wickra-lib/wickra)
[![Build provenance](https://img.shields.io/badge/provenance-attested-brightgreen?logo=github)](https://github.com/wickra-lib/wickra/attestations)
[![Docs](https://img.shields.io/badge/docs-docs.wickra.org-0ea5e9?logo=readthedocs&logoColor=white)](https://docs.wickra.org)
**Streaming-first technical indicators. Install with `pip install wickra` — no system dependencies.**
@@ -31,21 +38,40 @@ for price in live_feed:
print("overbought")
```
## Documentation
Full documentation lives at **[docs.wickra.org](https://docs.wickra.org)**:
- **Quickstarts** — [Rust](https://docs.wickra.org/Quickstart-Rust),
[Python](https://docs.wickra.org/Quickstart-Python),
[Node](https://docs.wickra.org/Quickstart-Node),
[WASM](https://docs.wickra.org/Quickstart-WASM).
- **Indicators** — a per-indicator deep dive (formula, parameters, warmup) for
every one of the 232 indicators; start at the
[indicators overview](https://docs.wickra.org/Indicators-Overview).
- **Reference** — [warmup periods](https://docs.wickra.org/Warmup-Periods),
[streaming vs batch](https://docs.wickra.org/Streaming-vs-Batch),
[indicator chaining](https://docs.wickra.org/Indicator-Chaining), the
[data layer](https://docs.wickra.org/Data-Layer).
- **Guides** — [Cookbook](https://docs.wickra.org/Cookbook),
[TA-Lib migration](https://docs.wickra.org/TA-Lib-Migration),
[FAQ](https://docs.wickra.org/FAQ).
## Why Wickra exists
The Python TA ecosystem has plenty of libraries — TA-Lib, pandas-ta, finta,
talipp, tulipy — and every one of them shares the same blind spot:
| Library | Install pain | Streaming | Multi-language | Active |
|--------------------|-----------------|-----------|----------------|--------|
| TA-Lib (Python) | yes (C deps) | no | no | barely |
| pandas-ta | clean | no | no | slow |
| finta | clean | no | no | stale |
| ta-lib-python | yes (C deps) | no | no | barely |
| talipp | clean | yes | no | yes |
| Tulip Indicators | yes (C deps) | no | partial | stale |
| ooples (C#) | clean | no | C# only | yes |
| **Wickra** | **clean** | **yes** | **Python+Node+WASM+Rust** | **yes** |
| 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 +84,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 +102,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 +116,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 +135,36 @@ python -m benchmarks.compare_libraries
## Indicators
71 streaming-first indicators across eight families. Every one passes the
232 streaming-first indicators across seventeen families. Every one passes the
`batch == streaming` equivalence test, reference-value tests, and reset
semantics tests.
semantics tests. Each has a per-indicator deep dive (formula, parameters,
warmup) at [docs.wickra.org](https://docs.wickra.org/Indicators-Overview).
| 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, Pairwise Beta, Pair Spread Z-Score, Lead-Lag Cross-Correlation, Cointegration, Relative Strength A-vs-B, 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 |
| Microstructure | Order-Book Imbalance (Top-1 / Top-N / Full), Microprice, Quoted Spread, Depth Slope, Signed Volume, Cumulative Volume Delta, Trade Imbalance, Effective Spread, Realized Spread, Kyle's Lambda, Footprint |
| 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) |
Every candlestick pattern emits a signed per-bar value — `+1.0` bullish,
`1.0` bearish, `0.0` none — so the family drops straight into a feature matrix
as one column each. `Doji` is direction-less by default (`+1.0` / `0.0`);
construct it in signed mode (`Doji::new().signed()`, `Doji(signed=True)`,
`new Doji(true)`) for a dragonfly / gravestone `±1` reading.
Adding a new indicator means implementing one trait in Rust; all four bindings
inherit it automatically.
@@ -195,7 +237,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 232 indicators
│ ├── wickra/ top-level facade crate (publishes on crates.io) + benches/
│ └── wickra-data/ CSV reader, tick aggregator, live exchange feeds
├── bindings/
@@ -256,7 +298,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:
@@ -287,17 +329,27 @@ government, hobby trading bots: all fine. The one thing that's not allowed is
commercial sale of the software or of services built around it. If you want to
use Wickra commercially, get in touch about a license.
## Disclaimer
Wickra is an indicator toolkit, not a trading system. Values it computes are
deterministic transforms of the input data — they are not financial advice and
they do not predict the market. Any use of this library in a production
trading context is at your own risk.
The library is provided **as is**, without warranty of any kind; see
[LICENSE](LICENSE) for the full terms.
---
<p align="center">
<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 **support@wickra.org** with a subject line starting with
`[wickra security]`.
Please include:
+1 -1
View File
@@ -9,7 +9,7 @@ edition.workspace = true
# also emits `cargo::` directives that require >= 1.77 — that older floor is
# subsumed by the 1.88 requirement now.
rust-version = "1.88"
license.workspace = true
license-file.workspace = true
repository.workspace = true
homepage.workspace = true
readme.workspace = true
+57 -29
View File
@@ -1,45 +1,73 @@
# wickra
# Wickra — Node.js
Node.js bindings for the Wickra streaming-first technical indicators library.
[![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)
[![npm](https://img.shields.io/npm/v/wickra.svg?logo=npm&color=red)](https://www.npmjs.com/package/wickra)
[![License: PolyForm-NC](https://img.shields.io/badge/license-PolyForm--NC--1.0.0-purple)](https://github.com/wickra-lib/wickra/blob/main/LICENSE)
**Streaming-first technical indicators for Node.js. `npm install wickra`
prebuilt native binary, no system dependencies.**
Wickra is a multi-language technical-analysis library with a Rust core and
bindings for Python, Node.js, and WebAssembly. Every indicator is an O(1)
streaming state machine, so live trading bots and historical backtests share
the exact same implementation. This package is the Node.js binding (napi-rs);
it exposes 200+ streaming-first indicators across sixteen families.
## Install
Once published, install per platform via the precompiled native package:
```bash
npm install wickra
```
## Build from source
The native addon ships as a prebuilt binary per platform (Linux, macOS,
Windows — x64 and arm64), selected automatically through optional
dependencies. There is nothing to compile.
```bash
cd bindings/node
npm install
npm run build
npm test
```
The native module is built via [napi-rs](https://napi.rs/). The build script
produces a `wickra.<platform>-<arch>.node` binary in the package root that
`index.js` loads at runtime.
## Usage
## Quick start
```js
import { SMA, RSI, MACD, version } from 'wickra';
const wickra = require('wickra');
console.log('wickra', version());
// Batch: run an indicator over a whole array.
const prices = Array.from({ length: 1000 }, (_, i) => 100 + i * 0.1);
const values = new wickra.RSI(14).batch(prices); // null during warmup
// Batch:
const prices = Array.from({ length: 1000 }, (_, i) => 100 + Math.sin(i * 0.1) * 5);
const rsi = new RSI(14).batch(prices);
// Streaming:
const macd = new MACD(12, 26, 9);
for (const p of livePriceStream) {
const v = macd.update(p);
if (v && v.histogram > 0) console.log('bullish crossover candidate');
// Streaming: the same indicator, fed tick by tick in O(1).
const rsi = new wickra.RSI(14);
for (const price of liveFeed) {
const value = rsi.update(price); // no recomputation over history
if (value !== null && value > 70) {
console.log('overbought');
}
}
```
See `index.d.ts` for the full TypeScript surface.
`batch(prices)` and feeding the same prices through `update()` produce
identical values — the equivalence is enforced by the test suite.
## Documentation
The full indicator catalogue, guides, quickstarts, and API reference live in
the main repository and documentation site:
- **Repository & full indicator list:** <https://github.com/wickra-lib/wickra>
- **Docs** (quickstarts, cookbook, TA-Lib migration): <https://docs.wickra.org>
- **Runnable examples:** [`examples/node/`](https://github.com/wickra-lib/wickra/tree/main/examples/node)
Wickra ships four bindings — Python, Node.js, WebAssembly, and Rust — that all
expose the same indicators from the shared, `unsafe`-forbidden Rust core.
## Disclaimer
Wickra is an indicator toolkit, not a trading system. The values it computes
are deterministic transforms of the input data — they are not financial advice
and do not predict the market. Any use in a live trading context is at your own
risk. The library is provided **as is**, without warranty of any kind.
## License
Licensed under the **PolyForm Noncommercial License 1.0.0**. Personal projects,
research, education, non-profits, and hobby trading bots are all fine; the one
thing not allowed is commercial sale of the software or of services built
around it. See [LICENSE](https://github.com/wickra-lib/wickra/blob/main/LICENSE).
@@ -0,0 +1,70 @@
// Completeness contract for the Wickra Node bindings: every exported indicator
// class must expose the full streaming + batch + lifecycle interface. This
// catches a new indicator being wired into the binding without the standard
// methods (or an export silently disappearing) without needing a hand-written
// test per indicator.
const test = require('node:test');
const assert = require('node:assert/strict');
const wickra = require('..');
// An "indicator class" is an exported constructor whose prototype carries the
// streaming `update` method. This excludes `version` (a plain function) and any
// non-indicator export.
function indicatorClasses() {
return Object.keys(wickra).filter((name) => {
const value = wickra[name];
return (
typeof value === 'function' &&
value.prototype &&
typeof value.prototype.update === 'function'
);
});
}
test('the binding exports the full indicator catalogue', () => {
const names = indicatorClasses();
// The published catalogue is 214 indicators. Guard against a regression that
// silently drops exported classes (e.g. a stale or partial native build).
assert.ok(
names.length >= 200,
`expected at least 200 indicator classes, got ${names.length}`,
);
});
test('every exported indicator exposes update / batch / reset / isReady / warmupPeriod', () => {
const required = ['update', 'batch', 'reset', 'isReady', 'warmupPeriod'];
const missing = [];
for (const name of indicatorClasses()) {
const proto = wickra[name].prototype;
for (const method of required) {
if (typeof proto[method] !== 'function') {
missing.push(`${name}.${method}`);
}
}
}
assert.deepEqual(
missing,
[],
`indicator classes missing required methods: ${missing.join(', ')}`,
);
});
test('a freshly constructed indicator reports not-ready with a positive warmup', () => {
// Every indicator that takes no constructor arguments must still satisfy the
// pre-warmup contract. (Indicators with required parameters are exercised by
// the dedicated suites; here we cover the zero-arg ones generically.)
let checked = 0;
for (const name of indicatorClasses()) {
let instance;
try {
instance = new wickra[name]();
} catch {
continue; // needs constructor arguments — covered elsewhere
}
assert.equal(instance.isReady(), false, `${name} should start un-ready`);
assert.ok(instance.warmupPeriod() >= 1, `${name} warmup must be >= 1`);
checked += 1;
}
assert.ok(checked > 0, 'expected at least one zero-arg indicator to check');
});
+836
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,668 @@ 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),
PairwiseBeta: () => new wickra.PairwiseBeta(14),
PairSpreadZScore: () => new wickra.PairSpreadZScore(14, 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('PairwiseBeta squared price is two', () => {
// b needs varying returns; a = b² ⇒ a's log-returns are exactly 2× b's.
const bench = Array.from({ length: 20 }, (_, i) => 100 + 10 * Math.sin(i * 0.5));
const asset = bench.map((v) => v * v);
const out = new wickra.PairwiseBeta(5).batch(asset, bench);
assert.ok(Math.abs(out[out.length - 1] - 2) < 1e-9);
});
test('PairSpreadZScore flat benchmark is sign of last move', () => {
// Flat b ⇒ hedge ratio 0 ⇒ spread = ln(a); z_period = 2 ⇒ z = sign of move.
const a = [100, 100, 110, 105, 130];
const b = [100, 100, 100, 100, 100];
const out = new wickra.PairSpreadZScore(2, 2).batch(a, b);
assert.ok(Math.abs(out[out.length - 1] - 1) < 1e-9);
assert.ok(Math.abs(out[out.length - 2] + 1) < 1e-9);
});
const llSignal = (t) =>
Math.sin(t * 0.4) + 0.4 * Math.sin(t * 1.1) + 0.2 * Math.cos(t * 0.27);
test('LeadLagCrossCorrelation detects positive lead (object output)', () => {
const ll = new wickra.LeadLagCrossCorrelation(12, 5);
let last = null;
// b is a delayed by 3 ⇒ a leads b ⇒ lag = +3.
for (let t = 0; t < 60; t++) last = ll.update(llSignal(t), llSignal(t - 3));
assert.equal(last.lag, 3);
assert.ok(last.correlation > 0.99);
});
test('LeadLagCrossCorrelation batch is flat 2*n with last row matching', () => {
const n = 60;
const a = Array.from({ length: n }, (_, t) => llSignal(t));
const b = Array.from({ length: n }, (_, t) => llSignal(t - 3));
const out = new wickra.LeadLagCrossCorrelation(12, 5).batch(a, b);
assert.equal(out.length, 2 * n);
assert.equal(out[2 * (n - 1)], 3);
assert.ok(out[2 * (n - 1) + 1] > 0.99);
});
test('Cointegration detects mean-reverting pair (object output)', () => {
const n = 80;
const b = Array.from({ length: n }, (_, t) => 50 + 0.5 * t);
const a = b.map((v, t) => 2 * v + 1 + 0.5 * Math.sin(t * 0.6));
const co = new wickra.Cointegration(40, 1);
let last = null;
for (let i = 0; i < n; i++) last = co.update(a[i], b[i]);
assert.ok(Math.abs(last.hedgeRatio - 2) < 0.1);
assert.ok(last.adfStat < -2);
});
test('Cointegration batch is flat 3*n with last row matching', () => {
const n = 80;
const b = Array.from({ length: n }, (_, t) => 50 + 0.5 * t);
const a = b.map((v, t) => 2 * v + 1 + 0.5 * Math.sin(t * 0.6));
const out = new wickra.Cointegration(40, 1).batch(a, b);
assert.equal(out.length, 3 * n);
assert.ok(Math.abs(out[3 * (n - 1)] - 2) < 0.1);
assert.ok(out[3 * (n - 1) + 2] < -2);
});
test('RelativeStrengthAB constant ratio is flat (object output)', () => {
const rs = new wickra.RelativeStrengthAB(5, 5);
let last = null;
for (let i = 0; i < 30; i++) last = rs.update(200, 100); // ratio is a constant 2
assert.ok(Math.abs(last.ratio - 2) < 1e-12);
assert.ok(Math.abs(last.ratioMa - 2) < 1e-12);
assert.ok(Math.abs(last.ratioRsi - 50) < 1e-9);
});
test('RelativeStrengthAB batch is flat 3*n with last row matching', () => {
const n = 30;
const a = Array.from({ length: n }, () => 200);
const b = Array.from({ length: n }, () => 100);
const out = new wickra.RelativeStrengthAB(5, 5).batch(a, b);
assert.equal(out.length, 3 * n);
assert.ok(Math.abs(out[3 * (n - 1)] - 2) < 1e-12);
assert.ok(Math.abs(out[3 * (n - 1) + 2] - 50) < 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);
});
test('Doji signed mode encodes dragonfly/gravestone/neutral direction', () => {
// Default: direction-less detection flag (+1 doji / 0 otherwise).
const flag = new wickra.Doji();
assert.equal(flag.isSigned(), false);
assert.equal(flag.update(10, 11, 9, 10), 1); // body 0, range 2 -> doji
assert.equal(flag.update(10, 12, 10, 12), 0); // body == range -> not a doji
// Signed: classify a detected doji by its body position within the range.
const d = new wickra.Doji(true);
assert.equal(d.isSigned(), true);
assert.equal(d.update(10, 10.05, 6, 10), 1); // dragonfly -> bullish +1
assert.equal(d.update(10, 14, 9.95, 10), -1); // gravestone -> bearish -1
assert.equal(d.update(10, 12, 8, 10), 0); // long-legged -> neutral 0
assert.equal(d.update(10, 12, 10, 12), 0); // not a doji -> 0
});
test('order-book indicators reference values', () => {
// Top-1: (3 - 1) / (3 + 1) = 0.5.
assert.equal(new wickra.OrderBookImbalanceTop1().update([100], [3], [101], [1]), 0.5);
// Top-2: bidDepth 3, askDepth 2 -> (3 - 2) / 5 = 0.2.
assert.ok(
Math.abs(new wickra.OrderBookImbalanceTopN(2).update([100, 99], [2, 1], [101, 102], [1, 1]) - 0.2) < 1e-12,
);
// Full: bidDepth 1, askDepth 3 -> -0.5.
assert.equal(new wickra.OrderBookImbalanceFull().update([100], [1], [101, 102], [2, 1]), -0.5);
// Microprice: (100*3 + 101*1) / 4 = 100.25.
assert.equal(new wickra.Microprice().update([100], [1], [101], [3]), 100.25);
// Quoted spread: 1 / 100.5 * 10000 ≈ 99.5025 bps.
assert.ok(Math.abs(new wickra.QuotedSpread().update([100], [1], [101], [1]) - 99.50248756) < 1e-6);
// Depth slope: each side distances 1,2 -> cumulative 1,3 -> OLS slope 2.
assert.ok(Math.abs(new wickra.DepthSlope().update([99, 98], [1, 2], [101, 102], [1, 2]) - 2.0) < 1e-9);
// Single level per side -> no slope -> 0.
assert.equal(new wickra.DepthSlope().update([100], [1], [101], [1]), 0.0);
});
test('order-book streaming update matches batch', () => {
const snaps = Array.from({ length: 30 }, (_, i) => ({
bidPx: [100, 99],
bidSz: [1 + (i % 5), 1],
askPx: [101, 102],
askSz: [1 + ((i + 1) % 3), 1],
}));
const batch = new wickra.Microprice().batch(snaps);
const streamer = new wickra.Microprice();
assert.equal(batch.length, snaps.length);
for (let i = 0; i < snaps.length; i++) {
const s = streamer.update(snaps[i].bidPx, snaps[i].bidSz, snaps[i].askPx, snaps[i].askSz);
assert.ok(Math.abs(s - batch[i]) < 1e-12, `mismatch at ${i}: ${s} vs ${batch[i]}`);
}
});
test('order-book TopN rejects zero levels', () => {
assert.throws(() => new wickra.OrderBookImbalanceTopN(0));
});
test('order-book update rejects a crossed book', () => {
assert.throws(() => new wickra.QuotedSpread().update([102], [1], [101], [1]));
});
test('trade-flow indicators reference values', () => {
assert.equal(new wickra.SignedVolume().update(100, 2, true), 2);
assert.equal(new wickra.SignedVolume().update(100, 3, false), -3);
const cvd = new wickra.CumulativeVolumeDelta();
assert.equal(cvd.update(100, 5, true), 5);
assert.equal(cvd.update(100, 2, false), 3);
const ti = new wickra.TradeImbalance(2);
assert.equal(ti.update(100, 3, true), null); // warming up
assert.equal(ti.update(100, 1, false), 0.5); // (3 - 1) / 4
});
test('trade-flow streaming update matches batch', () => {
const n = 30;
const price = Array.from({ length: n }, () => 100);
const size = Array.from({ length: n }, (_, i) => 1 + (i % 4));
const isBuy = Array.from({ length: n }, (_, i) => i % 3 !== 0);
const batch = new wickra.CumulativeVolumeDelta().batch(price, size, isBuy);
const streamer = new wickra.CumulativeVolumeDelta();
assert.equal(batch.length, n);
for (let i = 0; i < n; i++) {
const s = streamer.update(price[i], size[i], isBuy[i]);
assert.ok(Math.abs(s - batch[i]) < 1e-12, `mismatch at ${i}: ${s} vs ${batch[i]}`);
}
});
test('trade-flow rejects bad input', () => {
assert.throws(() => new wickra.TradeImbalance(0));
assert.throws(() => new wickra.SignedVolume().update(100, -1, true));
});
test('price-impact indicators reference values', () => {
// Buy at 100.05 vs mid 100.0: 2 * (100.05 - 100) / 100 * 10000 = 10 bps.
assert.ok(Math.abs(new wickra.EffectiveSpread().update(100.05, 1, true, 100.0) - 10.0) < 1e-9);
// Sell at 99.95 vs mid 100.0: 2 * -1 * (99.95 - 100) / 100 * 10000 = 10 bps.
assert.ok(Math.abs(new wickra.EffectiveSpread().update(99.95, 1, false, 100.0) - 10.0) < 1e-9);
// A buy filled below the mid is price improvement -> negative.
assert.ok(new wickra.EffectiveSpread().update(99.95, 1, true, 100.0) < 0.0);
});
test('price-impact streaming update matches batch', () => {
const n = 30;
const mid = Array.from({ length: n }, (_, i) => 100 + 0.25 * Math.sin(i * 0.5));
const isBuy = Array.from({ length: n }, (_, i) => i % 3 !== 0);
const price = Array.from({ length: n }, (_, i) => mid[i] + (isBuy[i] ? 0.03 : -0.03));
const size = Array.from({ length: n }, (_, i) => 1 + (i % 4));
const batch = new wickra.EffectiveSpread().batch(price, size, isBuy, mid);
const streamer = new wickra.EffectiveSpread();
assert.equal(batch.length, n);
for (let i = 0; i < n; i++) {
const s = streamer.update(price[i], size[i], isBuy[i], mid[i]);
assert.ok(Math.abs(s - batch[i]) < 1e-9, `mismatch at ${i}: ${s} vs ${batch[i]}`);
}
});
test('realized spread resolves against the future mid', () => {
const rs = new wickra.RealizedSpread(1);
assert.equal(rs.update(100.10, 1, true, 100.0), null); // buffered
// 2 * (+1) * (100.10 - 100.20) / 100.0 * 10000 = -20 bps.
assert.ok(Math.abs(rs.update(99.90, 1, false, 100.20) - -20.0) < 1e-9);
});
test('realized spread streaming update matches batch', () => {
const n = 30;
const mid = Array.from({ length: n }, (_, i) => 100 + 0.25 * Math.sin(i * 0.5));
const isBuy = Array.from({ length: n }, (_, i) => i % 3 !== 0);
const price = Array.from({ length: n }, (_, i) => mid[i] + (isBuy[i] ? 0.03 : -0.03));
const size = Array.from({ length: n }, (_, i) => 1 + (i % 4));
const batch = new wickra.RealizedSpread(4).batch(price, size, isBuy, mid);
const streamer = new wickra.RealizedSpread(4);
assert.equal(batch.length, n);
for (let i = 0; i < n; i++) {
const s = streamer.update(price[i], size[i], isBuy[i], mid[i]);
const got = s === null ? NaN : s;
assert.ok(
(Number.isNaN(got) && Number.isNaN(batch[i])) || Math.abs(got - batch[i]) < 1e-9,
`mismatch at ${i}: ${got} vs ${batch[i]}`,
);
}
});
test("kyle's lambda recovers a constant price-impact slope", () => {
// Each trade moves the mid by exactly 0.5 per unit of signed volume.
const impact = 0.5;
let mid = 100;
const price = [];
const size = [];
const isBuy = [];
const mids = [];
for (let i = 0; i < 20; i++) {
const buy = i % 2 === 0;
const sz = 1 + (i % 3);
const signed = buy ? sz : -sz;
mid += impact * signed;
price.push(mid);
size.push(sz);
isBuy.push(buy);
mids.push(mid);
}
const out = new wickra.KylesLambda(6).batch(price, size, isBuy, mids);
assert.ok(Math.abs(out[out.length - 1] - 0.5) < 1e-9);
});
test('price-impact rejects bad input', () => {
assert.throws(() => new wickra.EffectiveSpread().update(100, 1, true, 0));
assert.throws(() => new wickra.RealizedSpread(0));
assert.throws(() => new wickra.KylesLambda(1));
});
test('footprint buckets buy and sell volume per price level', () => {
const fp = new wickra.Footprint(1.0);
fp.update(100.2, 2, true); // bucket 100 -> ask 2
fp.update(100.7, 3, false); // bucket 101 -> bid 3
const out = fp.update(100.1, 1, true); // bucket 100 -> ask 3
assert.equal(out.length, 2);
assert.deepEqual(
{ price: out[0].price, bidVol: out[0].bidVol, askVol: out[0].askVol },
{ price: 100.0, bidVol: 0.0, askVol: 3.0 },
);
assert.deepEqual(
{ price: out[1].price, bidVol: out[1].bidVol, askVol: out[1].askVol },
{ price: 101.0, bidVol: 3.0, askVol: 0.0 },
);
});
test('footprint streaming update matches batch and rejects bad tick', () => {
const n = 12;
const price = Array.from({ length: n }, (_, i) => 100 + (i % 5) * 0.3);
const size = Array.from({ length: n }, (_, i) => 1 + (i % 3));
const isBuy = Array.from({ length: n }, (_, i) => i % 2 === 0);
const batch = new wickra.Footprint(1.0).batch(price, size, isBuy);
const streamer = new wickra.Footprint(1.0);
assert.equal(batch.length, n);
for (let i = 0; i < n; i++) {
const s = streamer.update(price[i], size[i], isBuy[i]);
assert.deepEqual(s, batch[i], `mismatch at ${i}`);
}
assert.throws(() => new wickra.Footprint(0));
});
@@ -0,0 +1,84 @@
// Input-validation tests for the Wickra Node bindings: malformed constructor
// parameters and mismatched batch inputs must raise a JS Error (the napi
// wrapper turns the Rust `Err` into a thrown Error), not crash the process.
// Node counterpart of bindings/python/tests/test_input_validation.py.
const test = require('node:test');
const assert = require('node:assert/strict');
const wickra = require('..');
// --- Constructors reject invalid periods / parameters ---
test('ATR rejects a zero period at construction', () => {
// ATR validates its period (it drives the Wilder-smoothing length). The
// plain moving averages (SMA/EMA/RSI/StdDev) instead treat period 0 as a
// warmup-1 pass-through rather than an error, so they are not asserted here.
assert.throws(() => new wickra.ATR(0), /.*/);
});
test('MACD rejects zero and non-increasing fast/slow periods', () => {
assert.throws(() => new wickra.MACD(0, 0, 0), /.*/);
// fast must be strictly less than slow.
assert.throws(() => new wickra.MACD(26, 12, 9), /.*/);
});
test('BollingerBands rejects a negative standard-deviation multiplier', () => {
assert.throws(() => new wickra.BollingerBands(20, -1), /.*/);
});
test('PSAR rejects a step greater than its maximum', () => {
assert.throws(() => new wickra.PSAR(0.3, 0.02, 0.2), /.*/);
});
test('ValueArea rejects zero periods and out-of-range value-area percentages', () => {
assert.throws(() => new wickra.ValueArea(0, 50, 0.7), /.*/);
assert.throws(() => new wickra.ValueArea(20, 0, 0.7), /.*/);
assert.throws(() => new wickra.ValueArea(20, 50, 0.0), /.*/);
assert.throws(() => new wickra.ValueArea(20, 50, 1.5), /.*/);
});
test('InitialBalance and OpeningRange reject a zero period', () => {
assert.throws(() => new wickra.InitialBalance(0), /.*/);
assert.throws(() => new wickra.OpeningRange(0), /.*/);
});
test('Ichimoku rejects zero and non-increasing periods', () => {
assert.throws(() => new wickra.Ichimoku(0, 26, 52, 26), /.*/);
assert.throws(() => new wickra.Ichimoku(9, 26, 52, 0), /.*/);
// Periods must satisfy tenkan < kijun < senkouB.
assert.throws(() => new wickra.Ichimoku(26, 9, 52, 26), /.*/);
assert.throws(() => new wickra.Ichimoku(9, 52, 52, 26), /.*/);
});
test('Family 10 (Ehlers / cycle) indicators reject invalid parameters', () => {
// InverseFisherTransform needs a non-zero scaling factor.
assert.throws(() => new wickra.InverseFisherTransform(0.0), /.*/);
// DecyclerOscillator / RoofingFilter need the short cutoff below the long one.
assert.throws(() => new wickra.DecyclerOscillator(30, 10), /.*/);
assert.throws(() => new wickra.RoofingFilter(48, 10), /.*/);
// MAMA needs fast limit > slow limit.
assert.throws(() => new wickra.MAMA(0.05, 0.5), /.*/);
// EmpiricalModeDecomposition needs a positive fraction.
assert.throws(() => new wickra.EmpiricalModeDecomposition(20, 0.0), /.*/);
// NOTE: SuperSmoother(0) / FisherTransform(0) are NOT asserted: the Node
// binding treats their period 0 as a warmup-1 pass-through (same as the
// simple moving averages) rather than an error.
});
// --- Batch methods reject mismatched input lengths ---
test('candle batch methods reject unequal-length columns', () => {
const high = [10, 11, 12];
const low = [9, 10]; // one short
const close = [9.5, 10.5, 11.5];
assert.throws(() => new wickra.ATR(14).batch(high, low, close), /.*/);
assert.throws(() => new wickra.WilliamsR(14).batch(high, low, close), /.*/);
assert.throws(() => new wickra.Aroon(14).batch(high, low), /.*/);
});
test('ValueArea batch rejects unequal-length columns', () => {
const high = [1, 2, 3];
const low = [0.5, 1.5]; // short
const volume = [10, 10, 10];
assert.throws(() => new wickra.ValueArea(2, 10, 0.7).batch(high, low, volume), /.*/);
});
+6 -6
View File
@@ -73,10 +73,10 @@ test('ATR batch shape', () => {
}
});
test('zero period is clamped to a valid window', () => {
// Constructors cannot throw from JS (napi-rs 2.16 limitation), so they
// clamp pathological values like period=0 to the smallest valid window.
const sma = new wickra.SMA(0);
assert.equal(sma.warmupPeriod(), 1);
assert.equal(sma.update(42), 42);
test('zero period is rejected at construction', () => {
// The core rejects period 0 (Error::PeriodZero); the Node binding propagates
// it as a thrown JS error, consistent with the Python and WASM bindings.
assert.throws(() => new wickra.SMA(0), /period must be greater than zero/);
// A valid period still constructs and runs.
assert.equal(new wickra.SMA(1).update(42), 42);
});
+99
View File
@@ -0,0 +1,99 @@
// Throughput benchmark for the Wickra Node bindings.
//
// Measures how many indicator updates per second the native binding sustains,
// both per-tick (streaming `update`) and bulk (`batch`), over a synthetic
// OHLCV series. It is the Node counterpart of the Rust criterion benches and
// the Python `benchmarks/compare_libraries.py`; it benchmarks Wickra's own
// O(1) streaming engine (there is no install-free TA library on npm with a
// comparable surface to compare against), so the headline number is raw
// throughput, not a cross-library ratio.
//
// Run after building the binding:
//
// cd bindings/node && npm install && npx napi build --platform --release
// node benchmarks/throughput.js # 200k bars (default)
// node benchmarks/throughput.js --bars 1000000
const wickra = require('..');
function parseBars() {
const idx = process.argv.indexOf('--bars');
if (idx !== -1 && process.argv[idx + 1]) {
const n = Number(process.argv[idx + 1]);
if (Number.isFinite(n) && n >= 1000) return Math.floor(n);
console.error('--bars must be a number >= 1000');
process.exit(1);
}
return 200_000;
}
const BARS = parseBars();
// Deterministic synthetic OHLCV (no RNG, so runs are comparable).
const close = new Array(BARS);
const high = new Array(BARS);
const low = new Array(BARS);
const volume = new Array(BARS);
for (let i = 0; i < BARS; i++) {
const mid = 100 + Math.sin(i * 0.001) * 20 + i * 1e-4;
close[i] = mid + Math.sin(i * 0.05) * 2;
high[i] = Math.max(close[i], mid) + 1.5;
low[i] = Math.min(close[i], mid) - 1.5;
volume[i] = 1000 + (i % 97) * 13;
}
// Median elapsed-ns over a few repetitions, after one warmup pass.
function timeNs(fn, reps = 3) {
fn(); // warmup (JIT + cache)
const samples = [];
for (let r = 0; r < reps; r++) {
const t0 = process.hrtime.bigint();
fn();
samples.push(Number(process.hrtime.bigint() - t0));
}
samples.sort((a, b) => a - b);
return samples[Math.floor(samples.length / 2)];
}
function mupsFromNs(ns) {
return (BARS / (ns / 1e9)) / 1e6; // million updates per second
}
// Each indicator: a streaming step and a batch call over the full series.
const indicators = [
{ name: 'SMA(20)', make: () => new wickra.SMA(20), step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
{ name: 'EMA(20)', make: () => new wickra.EMA(20), step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
{ name: 'RSI(14)', make: () => new wickra.RSI(14), step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
{ name: 'StdDev(20)', make: () => new wickra.StdDev(20), step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
{ name: 'MACD(12,26,9)', make: () => new wickra.MACD(12, 26, 9), step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
{ name: 'BollingerBands(20,2)', make: () => new wickra.BollingerBands(20, 2), step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
{ name: 'KAMA(10,2,30)', make: () => new wickra.KAMA(10, 2, 30), step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
{ name: 'ATR(14)', make: () => new wickra.ATR(14), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
{ name: 'ADX(14)', make: () => new wickra.ADX(14), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
{ name: 'Stochastic(14,3)', make: () => new wickra.Stochastic(14, 3), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
{ name: 'SuperTrend(10,3)', make: () => new wickra.SuperTrend(10, 3), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
{ name: 'OBV', make: () => new wickra.OBV(), step: (ind, i) => ind.update(close[i], volume[i]), batch: (ind) => ind.batch(close, volume) },
];
console.log(`Wickra Node throughput — ${BARS.toLocaleString('en-US')} bars (median of 3 runs)\n`);
console.log(`${'Indicator'.padEnd(22)}${'streaming (Mupd/s)'.padStart(20)}${'batch (Mupd/s)'.padStart(18)}`);
console.log('-'.repeat(60));
for (const ind of indicators) {
const streamNs = timeNs(() => {
const inst = ind.make();
for (let i = 0; i < BARS; i++) ind.step(inst, i);
});
const batchNs = timeNs(() => {
ind.batch(ind.make());
});
console.log(
`${ind.name.padEnd(22)}${mupsFromNs(streamNs).toFixed(1).padStart(20)}${mupsFromNs(batchNs).toFixed(1).padStart(18)}`,
);
}
console.log(
'\nMupd/s = million indicator updates per second. Streaming is the per-tick\n' +
'`update` path (one value at a time); batch is the bulk array path. Higher is\n' +
'better. Numbers are machine-dependent — use them for relative comparison.',
);
+2474
View File
File diff suppressed because it is too large Load Diff
+161 -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, McGinleyDynamic, FRAMA, SuperSmoother, FisherTransform, Decycler, CenterOfGravity, CyberneticCycle, InstantaneousTrendline, EhlersStochastic, RVIVolatility, Variance, CoefficientOfVariation, Skewness, Kurtosis, StandardError, DetrendedStdDev, RSquared, MedianAbsoluteDeviation, Autocorrelation, HurstExponent, PearsonCorrelation, Beta, PairwiseBeta, SpearmanCorrelation, PairSpreadZScore, LeadLagCrossCorrelation, Cointegration, RelativeStrengthAB, MACD, BollingerBands, ATR, Stochastic, OBV, ADX, ADXR, CCI, WilliamsR, MFI, PSAR, Keltner, Donchian, VWAP, RollingVWAP, AwesomeOscillator, Aroon, Inertia, ConnorsRSI, LaguerreRSI, SMI, KST, PGO, RVI, AwesomeOscillatorHistogram, STC, ElderImpulse, ZeroLagMACD, CFO, APO, KAMA, EVWMA, Alligator, JMA, VIDYA, ALMA, T3, TSI, PMO, TII, ADL, VolumePriceTrend, ChaikinMoneyFlow, ChaikinOscillator, ForceIndex, NVI, PVI, VolumeOscillator, KVO, WilliamsAD, AnchoredVWAP, DemandIndex, TSV, VZO, MarketFacilitationIndex, EaseOfMovement, SuperTrend, ChandelierExit, ChandeKrollStop, AtrTrailingStop, HiLoActivator, VoltyStop, YoyoExit, DonchianStop, PercentageTrailingStop, StepTrailingStop, RenkoTrailingStop, TypicalPrice, MedianPrice, WeightedClose, LinearRegression, LinRegSlope, AcceleratorOscillator, BalanceOfPower, ChoppinessIndex, TrueRange, ChaikinVolatility, YangZhangVolatility, RogersSatchellVolatility, GarmanKlassVolatility, ParkinsonVolatility, LinRegAngle, BollingerBandwidth, PercentB, NATR, HistoricalVolatility, AroonOscillator, WaveTrend, RWI, Vortex, MassIndex, StochRSI, UltimateOscillator, PPO, Coppock, VWMA, 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, InverseFisherTransform, DecyclerOscillator, RoofingFilter, EmpiricalModeDecomposition, HilbertDominantCycle, AdaptiveCycle, SineWave, MAMA, FAMA, Ichimoku, HeikinAshi, ValueArea, InitialBalance, OpeningRange, Doji, Hammer, InvertedHammer, HangingMan, ShootingStar, Engulfing, Harami, MorningEveningStar, ThreeSoldiersOrCrows, PiercingDarkCloud, Marubozu, Tweezer, SpinningTop, ThreeInside, ThreeOutside, OrderBookImbalanceTop1, OrderBookImbalanceFull, Microprice, QuotedSpread, DepthSlope, OrderBookImbalanceTopN, SignedVolume, CumulativeVolumeDelta, TradeImbalance, EffectiveSpread, RealizedSpread, KylesLambda, Footprint, 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
@@ -332,12 +332,41 @@ module.exports.StdDev = StdDev
module.exports.UlcerIndex = UlcerIndex
module.exports.VerticalHorizontalFilter = VerticalHorizontalFilter
module.exports.ZScore = ZScore
module.exports.McGinleyDynamic = McGinleyDynamic
module.exports.FRAMA = FRAMA
module.exports.SuperSmoother = SuperSmoother
module.exports.FisherTransform = FisherTransform
module.exports.Decycler = Decycler
module.exports.CenterOfGravity = CenterOfGravity
module.exports.CyberneticCycle = CyberneticCycle
module.exports.InstantaneousTrendline = InstantaneousTrendline
module.exports.EhlersStochastic = EhlersStochastic
module.exports.RVIVolatility = RVIVolatility
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.PairwiseBeta = PairwiseBeta
module.exports.SpearmanCorrelation = SpearmanCorrelation
module.exports.PairSpreadZScore = PairSpreadZScore
module.exports.LeadLagCrossCorrelation = LeadLagCrossCorrelation
module.exports.Cointegration = Cointegration
module.exports.RelativeStrengthAB = RelativeStrengthAB
module.exports.MACD = MACD
module.exports.BollingerBands = BollingerBands
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
@@ -348,20 +377,56 @@ module.exports.VWAP = VWAP
module.exports.RollingVWAP = RollingVWAP
module.exports.AwesomeOscillator = AwesomeOscillator
module.exports.Aroon = Aroon
module.exports.Inertia = Inertia
module.exports.ConnorsRSI = ConnorsRSI
module.exports.LaguerreRSI = LaguerreRSI
module.exports.SMI = SMI
module.exports.KST = KST
module.exports.PGO = PGO
module.exports.RVI = RVI
module.exports.AwesomeOscillatorHistogram = AwesomeOscillatorHistogram
module.exports.STC = STC
module.exports.ElderImpulse = ElderImpulse
module.exports.ZeroLagMACD = ZeroLagMACD
module.exports.CFO = CFO
module.exports.APO = APO
module.exports.KAMA = KAMA
module.exports.EVWMA = EVWMA
module.exports.Alligator = Alligator
module.exports.JMA = JMA
module.exports.VIDYA = VIDYA
module.exports.ALMA = ALMA
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.NVI = NVI
module.exports.PVI = PVI
module.exports.VolumeOscillator = VolumeOscillator
module.exports.KVO = KVO
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.EaseOfMovement = EaseOfMovement
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
@@ -372,12 +437,18 @@ module.exports.BalanceOfPower = BalanceOfPower
module.exports.ChoppinessIndex = ChoppinessIndex
module.exports.TrueRange = TrueRange
module.exports.ChaikinVolatility = ChaikinVolatility
module.exports.YangZhangVolatility = YangZhangVolatility
module.exports.RogersSatchellVolatility = RogersSatchellVolatility
module.exports.GarmanKlassVolatility = GarmanKlassVolatility
module.exports.ParkinsonVolatility = ParkinsonVolatility
module.exports.LinRegAngle = LinRegAngle
module.exports.BollingerBandwidth = BollingerBandwidth
module.exports.PercentB = PercentB
module.exports.NATR = NATR
module.exports.HistoricalVolatility = HistoricalVolatility
module.exports.AroonOscillator = AroonOscillator
module.exports.WaveTrend = WaveTrend
module.exports.RWI = RWI
module.exports.Vortex = Vortex
module.exports.MassIndex = MassIndex
module.exports.StochRSI = StochRSI
@@ -385,3 +456,92 @@ module.exports.UltimateOscillator = UltimateOscillator
module.exports.PPO = PPO
module.exports.Coppock = Coppock
module.exports.VWMA = VWMA
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.InverseFisherTransform = InverseFisherTransform
module.exports.DecyclerOscillator = DecyclerOscillator
module.exports.RoofingFilter = RoofingFilter
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.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
module.exports.OrderBookImbalanceTop1 = OrderBookImbalanceTop1
module.exports.OrderBookImbalanceFull = OrderBookImbalanceFull
module.exports.Microprice = Microprice
module.exports.QuotedSpread = QuotedSpread
module.exports.DepthSlope = DepthSlope
module.exports.OrderBookImbalanceTopN = OrderBookImbalanceTopN
module.exports.SignedVolume = SignedVolume
module.exports.CumulativeVolumeDelta = CumulativeVolumeDelta
module.exports.TradeImbalance = TradeImbalance
module.exports.EffectiveSpread = EffectiveSpread
module.exports.RealizedSpread = RealizedSpread
module.exports.KylesLambda = KylesLambda
module.exports.Footprint = Footprint
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
+4 -4
View File
@@ -1,12 +1,12 @@
{
"name": "wickra-darwin-arm64",
"version": "0.2.1",
"version": "0.4.3",
"description": "Native binding for wickra (macOS Apple Silicon). Installed automatically as an optional dependency of wickra on matching platforms.",
"main": "wickra.darwin-arm64.node",
"files": [
"wickra.darwin-arm64.node"
],
"license": "PolyForm-Noncommercial-1.0.0",
"license": "LicenseRef-Wickra-Noncommercial-1.0.0",
"engines": {
"node": ">= 18"
},
@@ -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"
}
+4 -4
View File
@@ -1,12 +1,12 @@
{
"name": "wickra-darwin-x64",
"version": "0.2.1",
"version": "0.4.3",
"description": "Native binding for wickra (macOS Intel). Installed automatically as an optional dependency of wickra on matching platforms.",
"main": "wickra.darwin-x64.node",
"files": [
"wickra.darwin-x64.node"
],
"license": "PolyForm-Noncommercial-1.0.0",
"license": "LicenseRef-Wickra-Noncommercial-1.0.0",
"engines": {
"node": ">= 18"
},
@@ -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,12 +1,12 @@
{
"name": "wickra-linux-arm64-gnu",
"version": "0.2.1",
"version": "0.4.3",
"description": "Native binding for wickra (linux arm64 GNU). Installed automatically as an optional dependency of wickra on matching platforms.",
"main": "wickra.linux-arm64-gnu.node",
"files": [
"wickra.linux-arm64-gnu.node"
],
"license": "PolyForm-Noncommercial-1.0.0",
"license": "LicenseRef-Wickra-Noncommercial-1.0.0",
"engines": {
"node": ">= 18"
},
@@ -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"
}
+4 -4
View File
@@ -1,12 +1,12 @@
{
"name": "wickra-linux-x64-gnu",
"version": "0.2.1",
"version": "0.4.3",
"description": "Native binding for wickra (linux x64 GNU). Installed automatically as an optional dependency of wickra on matching platforms.",
"main": "wickra.linux-x64-gnu.node",
"files": [
"wickra.linux-x64-gnu.node"
],
"license": "PolyForm-Noncommercial-1.0.0",
"license": "LicenseRef-Wickra-Noncommercial-1.0.0",
"engines": {
"node": ">= 18"
},
@@ -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.4.3",
"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": "LicenseRef-Wickra-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,12 +1,12 @@
{
"name": "wickra-win32-x64-msvc",
"version": "0.2.1",
"version": "0.4.3",
"description": "Native binding for wickra (Windows x64 MSVC). Installed automatically as an optional dependency of wickra on matching platforms.",
"main": "wickra.win32-x64-msvc.node",
"files": [
"wickra.win32-x64-msvc.node"
],
"license": "PolyForm-Noncommercial-1.0.0",
"license": "LicenseRef-Wickra-Noncommercial-1.0.0",
"engines": {
"node": ">= 18"
},
@@ -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.4.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "wickra",
"version": "0.4.3",
"license": "PolyForm-Noncommercial-1.0.0",
"devDependencies": {
"@napi-rs/cli": "^2.18.0"
},
"engines": {
"node": ">= 18"
},
"optionalDependencies": {
"wickra-darwin-arm64": "0.4.3",
"wickra-darwin-x64": "0.4.3",
"wickra-linux-arm64-gnu": "0.4.3",
"wickra-linux-x64-gnu": "0.4.3",
"wickra-win32-arm64-msvc": "0.4.3",
"wickra-win32-x64-msvc": "0.4.3"
}
},
"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.4.3",
"resolved": "https://registry.npmjs.org/wickra-darwin-arm64/-/wickra-darwin-arm64-0.4.3.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.4.3",
"resolved": "https://registry.npmjs.org/wickra-darwin-x64/-/wickra-darwin-x64-0.4.3.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.4.3",
"resolved": "https://registry.npmjs.org/wickra-linux-arm64-gnu/-/wickra-linux-arm64-gnu-0.4.3.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.4.3",
"resolved": "https://registry.npmjs.org/wickra-linux-x64-gnu/-/wickra-linux-x64-gnu-0.4.3.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.4.3",
"resolved": "https://registry.npmjs.org/wickra-win32-arm64-msvc/-/wickra-win32-arm64-msvc-0.4.3.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.4.3",
"resolved": "https://registry.npmjs.org/wickra-win32-x64-msvc/-/wickra-win32-x64-msvc-0.4.3.tgz",
"integrity": "sha512-Yfsqq1Xwp6hdxMyLze411vNdo7BDwI6+lPSe7A9XdqyPecNDbtKwYLpsal2r8EHbNzqM+R8XnuRtUaEQS5VlUQ==",
"cpu": [
"x64"
],
"license": "PolyForm-Noncommercial-1.0.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 18"
}
}
}
}
+16 -13
View File
@@ -1,11 +1,11 @@
{
"name": "wickra",
"version": "0.2.1",
"version": "0.4.3",
"description": "Streaming-first technical indicators: incremental, fast, install-free. Node bindings powered by Rust.",
"author": "kingchenc <kingchencp@gmail.com>",
"author": "kingchenc <support@wickra.org>",
"main": "index.js",
"types": "index.d.ts",
"license": "PolyForm-Noncommercial-1.0.0",
"license": "LicenseRef-Wickra-Noncommercial-1.0.0",
"keywords": [
"trading",
"indicators",
@@ -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.1",
"wickra-linux-arm64-gnu": "0.2.1",
"wickra-darwin-x64": "0.2.1",
"wickra-darwin-arm64": "0.2.1",
"wickra-win32-x64-msvc": "0.2.1"
"wickra-linux-x64-gnu": "0.4.3",
"wickra-linux-arm64-gnu": "0.4.3",
"wickra-darwin-x64": "0.4.3",
"wickra-darwin-arm64": "0.4.3",
"wickra-win32-x64-msvc": "0.4.3",
"wickra-win32-arm64-msvc": "0.4.3"
},
"scripts": {
"build": "napi build --platform --release",
@@ -58,7 +60,8 @@
"artifacts": "napi artifacts",
"universal": "napi universal",
"version": "napi version",
"test": "node --test __tests__/"
"test": "node --test __tests__/",
"bench": "node benchmarks/throughput.js"
},
"devDependencies": {
"@napi-rs/cli": "^2.18.0"
+7057 -24
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -5,7 +5,7 @@ version.workspace = true
authors.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
license-file.workspace = true
repository.workspace = true
homepage.workspace = true
readme.workspace = true
+47 -41
View File
@@ -1,66 +1,72 @@
# Wickra — Python bindings
# Wickra — Python
Streaming-first technical indicators powered by a Rust core.
[![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)
[![PyPI](https://img.shields.io/pypi/v/wickra.svg?logo=pypi&color=blue)](https://pypi.org/project/wickra/)
[![License: PolyForm-NC](https://img.shields.io/badge/license-PolyForm--NC--1.0.0-purple)](https://github.com/wickra-lib/wickra/blob/main/LICENSE)
**Streaming-first technical indicators for Python. `pip install wickra` — no
system dependencies, no C build tooling.**
Wickra is a multi-language technical-analysis library with a Rust core and
bindings for Python, Node.js, and WebAssembly. Every indicator is an O(1)
streaming state machine, so live trading bots and historical backtests share
the exact same implementation. This package is the Python binding (PyO3); it
exposes 200+ streaming-first indicators across sixteen families.
## Install
```bash
pip install wickra
```
Pre-built wheels ship for Linux, macOS, and Windows — there is nothing to
compile and no C library to track down.
## Quick start
```python
import numpy as np
import wickra as ta
# Batch TA-Lib-style usage
# Batch: classic TA-Lib-style usage over a whole array.
prices = np.linspace(100, 200, 1000)
rsi = ta.RSI(14).batch(prices) # NumPy array; NaN during warmup
# Streaming — feed ticks one at a time
rsi = ta.RSI(14)
for price in live_prices:
v = rsi.update(price) # O(1) per tick
if v is not None and v > 70:
...
values = rsi.batch(prices) # numpy array, NaN during warmup
# Streaming: the same indicator, fed tick by tick in O(1).
rsi = ta.RSI(14)
for price in live_feed:
value = rsi.update(price) # no recomputation over history
if value is not None and value > 70:
print("overbought")
```
## What's included
`batch(prices)` and feeding the same prices through `update()` produce
identical values — the equivalence is enforced by the test suite.
71 streaming-first indicators across eight families. Every one passes a
`batch == streaming` equivalence test and reference-value tests:
## Documentation
- **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
The full indicator catalogue, guides, quickstarts, and API reference live in
the main repository and documentation site:
## Why streaming-first matters
- **Repository & full indicator list:** <https://github.com/wickra-lib/wickra>
- **Docs** (quickstarts, cookbook, TA-Lib migration): <https://docs.wickra.org>
- **Runnable examples:** [`examples/python/`](https://github.com/wickra-lib/wickra/tree/main/examples/python)
Classic TA libraries are batch-only: every live tick triggers a full
recomputation over the entire history. Wickra updates indicator state in
O(1) per tick. On a 5K-bar history the streaming RSI gap is ~17× over the
nearest peer with a streaming API and 100×+ over batch-only libraries.
Wickra ships four bindings — Python, Node.js, WebAssembly, and Rust — that all
expose the same indicators from the shared, `unsafe`-forbidden Rust core.
## Full project
## Disclaimer
See <https://github.com/kingchenc/wickra> for benchmarks, the Rust core,
Node.js and WebAssembly bindings, examples, and CI.
Wickra is an indicator toolkit, not a trading system. The values it computes
are deterministic transforms of the input data — they are not financial advice
and do not predict the market. Any use in a live trading context is at your own
risk. The library is provided **as is**, without warranty of any kind.
## License
Licensed under the **PolyForm Noncommercial License 1.0.0**. Personal,
research, educational, and non-profit use are all permitted. Commercial
sale requires a separate license — contact via the GitHub repo.
Licensed under the **PolyForm Noncommercial License 1.0.0**. Personal projects,
research, education, non-profits, and hobby trading bots are all fine; the one
thing not allowed is commercial sale of the software or of services built
around it. See [LICENSE](https://github.com/wickra-lib/wickra/blob/main/LICENSE).
+6 -6
View File
@@ -4,11 +4,11 @@ build-backend = "maturin"
[project]
name = "wickra"
version = "0.2.1"
version = "0.4.3"
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" }]
license = { text = "PolyForm-Noncommercial-1.0.0 with additional personal-account permissions; see LICENSE" }
authors = [{ name = "kingchenc", email = "support@wickra.org" }]
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"
+346
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,132 @@ from ._wickra import (
LinRegSlope,
ZScore,
LinRegAngle,
Variance,
CoefficientOfVariation,
Skewness,
Kurtosis,
StandardError,
DetrendedStdDev,
RSquared,
Autocorrelation,
MedianAbsoluteDeviation,
HurstExponent,
PearsonCorrelation,
Beta,
PairwiseBeta,
PairSpreadZScore,
LeadLagCrossCorrelation,
Cointegration,
RelativeStrengthAB,
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,
# Microstructure: order book
OrderBookImbalanceTop1,
OrderBookImbalanceTopN,
OrderBookImbalanceFull,
Microprice,
QuotedSpread,
DepthSlope,
# Microstructure: trade flow
SignedVolume,
CumulativeVolumeDelta,
TradeImbalance,
# Microstructure: price impact
EffectiveSpread,
RealizedSpread,
KylesLambda,
# Microstructure: footprint
Footprint,
# Risk / Performance
SharpeRatio,
SortinoRatio,
CalmarRatio,
OmegaRatio,
MaxDrawdown,
AverageDrawdown,
DrawdownDuration,
PainIndex,
ValueAtRisk,
ConditionalValueAtRisk,
ProfitFactor,
GainLossRatio,
RecoveryFactor,
KellyCriterion,
TreynorRatio,
InformationRatio,
Alpha,
)
__all__ = [
@@ -119,6 +292,13 @@ __all__ = [
"ZLEMA",
"T3",
"VWMA",
"ALMA",
"McGinleyDynamic",
"FRAMA",
"VIDYA",
"JMA",
"Alligator",
"EVWMA",
# Momentum
"RSI",
"MACD",
@@ -127,6 +307,7 @@ __all__ = [
"ROC",
"WilliamsR",
"ADX",
"ADXR",
"MFI",
"TRIX",
"AwesomeOscillator",
@@ -135,13 +316,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 +361,20 @@ __all__ = [
"ChandelierExit",
"ChandeKrollStop",
"AtrTrailingStop",
"HiLoActivator",
"VoltyStop",
"YoyoExit",
"DonchianStop",
"PercentageTrailingStop",
"StepTrailingStop",
"RenkoTrailingStop",
"TrueRange",
"ChaikinVolatility",
"RVIVolatility",
"ParkinsonVolatility",
"GarmanKlassVolatility",
"RogersSatchellVolatility",
"YangZhangVolatility",
# Volume
"OBV",
"VWAP",
@@ -174,6 +384,16 @@ __all__ = [
"ChaikinMoneyFlow",
"ChaikinOscillator",
"ForceIndex",
"KVO",
"VolumeOscillator",
"NVI",
"PVI",
"WilliamsAD",
"AnchoredVWAP",
"DemandIndex",
"TSV",
"VZO",
"MarketFacilitationIndex",
"EaseOfMovement",
# Statistics
"TypicalPrice",
@@ -183,4 +403,130 @@ __all__ = [
"LinRegSlope",
"ZScore",
"LinRegAngle",
"Variance",
"CoefficientOfVariation",
"Skewness",
"Kurtosis",
"StandardError",
"DetrendedStdDev",
"RSquared",
"Autocorrelation",
"MedianAbsoluteDeviation",
"HurstExponent",
"PearsonCorrelation",
"Beta",
"PairwiseBeta",
"PairSpreadZScore",
"LeadLagCrossCorrelation",
"Cointegration",
"RelativeStrengthAB",
"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",
# Microstructure: order book
"OrderBookImbalanceTop1",
"OrderBookImbalanceTopN",
"OrderBookImbalanceFull",
"Microprice",
"QuotedSpread",
"DepthSlope",
# Microstructure: trade flow
"SignedVolume",
"CumulativeVolumeDelta",
"TradeImbalance",
# Microstructure: price impact
"EffectiveSpread",
"RealizedSpread",
"KylesLambda",
# Microstructure: footprint
"Footprint",
# 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
@@ -35,7 +35,206 @@ def test_unequal_length_candle_batch_raises(ohlc_series):
ta.Aroon(14).batch(high, short)
def test_pairwise_beta_rejects_bad_period():
with pytest.raises(ValueError):
ta.PairwiseBeta(0)
with pytest.raises(ValueError):
ta.PairwiseBeta(1)
def test_unequal_length_pair_batch_raises(sine_prices):
a = np.ascontiguousarray((sine_prices + 100.0).astype(np.float64))
b = a[:-1]
with pytest.raises(ValueError):
ta.PairwiseBeta(20).batch(a, b)
with pytest.raises(ValueError):
ta.PairSpreadZScore(20, 20).batch(a, b)
def test_pair_spread_zscore_rejects_bad_periods():
with pytest.raises(ValueError):
ta.PairSpreadZScore(1, 20)
with pytest.raises(ValueError):
ta.PairSpreadZScore(20, 1)
def test_lead_lag_rejects_bad_params():
with pytest.raises(ValueError):
ta.LeadLagCrossCorrelation(1, 5)
with pytest.raises(ValueError):
ta.LeadLagCrossCorrelation(10, 0)
def test_lead_lag_unequal_length_batch_raises(sine_prices):
a = np.ascontiguousarray((sine_prices + 100.0).astype(np.float64))
b = a[:-1]
with pytest.raises(ValueError):
ta.LeadLagCrossCorrelation(12, 5).batch(a, b)
def test_cointegration_rejects_too_small_period():
# period must be >= 2*adf_lags + 4.
with pytest.raises(ValueError):
ta.Cointegration(3, 0)
with pytest.raises(ValueError):
ta.Cointegration(5, 1)
def test_cointegration_unequal_length_batch_raises(sine_prices):
a = np.ascontiguousarray((sine_prices + 100.0).astype(np.float64))
b = a[:-1]
with pytest.raises(ValueError):
ta.Cointegration(20, 1).batch(a, b)
def test_relative_strength_rejects_zero_periods():
with pytest.raises(ValueError):
ta.RelativeStrengthAB(0, 14)
with pytest.raises(ValueError):
ta.RelativeStrengthAB(20, 0)
def test_relative_strength_unequal_length_batch_raises(sine_prices):
a = np.ascontiguousarray((sine_prices + 100.0).astype(np.float64))
b = a[:-1]
with pytest.raises(ValueError):
ta.RelativeStrengthAB(10, 14).batch(a, b)
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)
def test_orderbook_topn_zero_levels_raises():
with pytest.raises(ValueError):
ta.OrderBookImbalanceTopN(0)
def test_orderbook_unequal_price_size_lengths_raise():
# bid_px has 2 entries but bid_sz has 1 -> mismatched -> ValueError.
with pytest.raises(ValueError):
ta.OrderBookImbalanceTop1().update([100.0, 99.0], [1.0], [101.0], [1.0])
with pytest.raises(ValueError):
ta.Microprice().update([100.0], [1.0], [101.0, 102.0], [1.0])
def test_orderbook_crossed_book_raises():
# best_bid (102) >= best_ask (101) is a crossed book -> rejected.
with pytest.raises(ValueError):
ta.QuotedSpread().update([102.0], [1.0], [101.0], [1.0])
def test_orderbook_misordered_levels_raise():
# Bids must be strictly descending in price.
with pytest.raises(ValueError):
ta.OrderBookImbalanceFull().update([99.0, 100.0], [1.0, 1.0], [101.0], [1.0])
def test_trade_imbalance_zero_window_raises():
with pytest.raises(ValueError):
ta.TradeImbalance(0)
def test_trade_negative_size_raises():
with pytest.raises(ValueError):
ta.SignedVolume().update(100.0, -1.0, True)
def test_trade_non_positive_price_raises():
with pytest.raises(ValueError):
ta.CumulativeVolumeDelta().update(0.0, 1.0, True)
def test_trade_batch_unequal_lengths_raise():
with pytest.raises(ValueError):
ta.SignedVolume().batch([100.0, 100.0], [1.0], [True, False])
def test_effective_spread_non_positive_mid_raises():
with pytest.raises(ValueError):
ta.EffectiveSpread().update(100.0, 1.0, True, 0.0)
def test_effective_spread_batch_unequal_lengths_raise():
with pytest.raises(ValueError):
ta.EffectiveSpread().batch([100.0, 100.0], [1.0, 1.0], [True, False], [100.0])
def test_realized_spread_zero_horizon_raises():
with pytest.raises(ValueError):
ta.RealizedSpread(0)
def test_kyles_lambda_window_below_two_raises():
with pytest.raises(ValueError):
ta.KylesLambda(1)
def test_footprint_non_positive_tick_raises():
with pytest.raises(ValueError):
ta.Footprint(0.0)
with pytest.raises(ValueError):
ta.Footprint(-1.0)
+834
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,619 @@ 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_pairwise_beta_squared_price_is_two():
# a = b² ⇒ a's log-returns are exactly 2× b's ⇒ pairwise beta = 2.
# b must have *varying* returns (a constant-return path has zero variance
# and an undefined slope, which the indicator reports as 0).
b = np.array([100.0 + 10.0 * math.sin(i * 0.5) for i in range(20)])
a = b**2
out = ta.PairwiseBeta(5).batch(a, b)
assert math.isclose(out[-1], 2.0, rel_tol=1e-9)
def test_pairwise_beta_inverse_price_is_minus_one():
# a = 1/b ⇒ a's log-returns are 1× b's ⇒ pairwise beta = 1.
b = np.array([100.0 + 10.0 * math.sin(i * 0.5) for i in range(20)])
a = 1.0 / b
out = ta.PairwiseBeta(5).batch(a, b)
assert math.isclose(out[-1], -1.0, rel_tol=1e-9)
def test_pair_spread_zscore_flat_benchmark_sign():
# Flat b ⇒ hedge ratio 0 ⇒ spread = ln(a). With z_period = 2 the z-score
# collapses to the sign of the last move: rising a ⇒ +1, falling a ⇒ 1.
a = np.array([100.0, 100.0, 110.0, 105.0, 130.0])
b = np.full_like(a, 100.0)
out = ta.PairSpreadZScore(2, 2).batch(a, b)
assert math.isclose(out[-1], 1.0, abs_tol=1e-9)
assert math.isclose(out[-2], -1.0, abs_tol=1e-9)
def test_lead_lag_cross_correlation_negative_lead():
# a is a delayed copy of b ⇒ b leads a ⇒ lag = 2, correlation ≈ 1.
def sig(t):
return math.sin(t * 0.4) + 0.4 * math.sin(t * 1.1) + 0.2 * math.cos(t * 0.27)
n = 60
a = np.array([sig(t - 2) for t in range(n)])
b = np.array([sig(t) for t in range(n)])
out = ta.LeadLagCrossCorrelation(12, 5).batch(a, b)
assert int(out[-1, 0]) == -2
assert out[-1, 1] > 0.99
def test_cointegration_perfect_pair():
# a = 2*b + 5 exactly ⇒ hedge ratio 2, zero spread, degenerate ADF ⇒ 0.
b = np.array([100.0 + t for t in range(40)])
a = 2.0 * b + 5.0
out = ta.Cointegration(20, 1).batch(a, b)
assert math.isclose(out[-1, 0], 2.0, rel_tol=1e-9)
assert math.isclose(out[-1, 1], 0.0, abs_tol=1e-6)
assert math.isclose(out[-1, 2], 0.0, abs_tol=1e-12)
def test_relative_strength_rising_ratio_is_overbought():
# a rises while b is flat ⇒ ratio strictly increases ⇒ RSI saturates at 100.
n = 20
a = np.array([100.0 + 2.0 * t for t in range(n)])
b = np.full(n, 100.0)
out = ta.RelativeStrengthAB(5, 5).batch(a, b)
assert out[-1, 0] > 1.0
assert math.isclose(out[-1, 2], 100.0, abs_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)
def test_doji_default_is_directionless_flag():
# Default Doji is a direction-less detection flag: +1 on a doji, 0 else.
d = ta.Doji()
assert d.is_signed() is False
# body 0, range 2 -> doji.
assert d.update((10.0, 11.0, 9.0, 10.0, 1.0, 0)) == pytest.approx(1.0)
# body 2 == range -> not a doji.
assert d.update((10.0, 12.0, 10.0, 12.0, 1.0, 1)) == pytest.approx(0.0)
def test_doji_signed_dragonfly_gravestone_neutral():
# Signed Doji classifies by body position within the range.
d = ta.Doji(signed=True)
assert d.is_signed() is True
# Dragonfly: body at the top, long lower shadow -> bullish +1.
assert d.update((10.0, 10.05, 6.0, 10.0, 1.0, 0)) == pytest.approx(1.0)
# Gravestone: body at the bottom, long upper shadow -> bearish -1.
assert d.update((10.0, 14.0, 9.95, 10.0, 1.0, 1)) == pytest.approx(-1.0)
# Long-legged: body centred, symmetric shadows -> neutral 0.
assert d.update((10.0, 12.0, 8.0, 10.0, 1.0, 2)) == pytest.approx(0.0)
# A large body is not a doji at all -> 0 regardless of position.
assert d.update((10.0, 12.0, 10.0, 12.0, 1.0, 3)) == pytest.approx(0.0)
def test_orderbook_imbalance_reference_values():
# Top-1: (3 - 1) / (3 + 1) = 0.5.
assert ta.OrderBookImbalanceTop1().update([100.0], [3.0], [101.0], [1.0]) == pytest.approx(0.5)
# Top-2: bidDepth 3, askDepth 2 -> (3 - 2) / 5 = 0.2.
topn = ta.OrderBookImbalanceTopN(2)
assert topn.update([100.0, 99.0], [2.0, 1.0], [101.0, 102.0], [1.0, 1.0]) == pytest.approx(0.2)
# Full: bidDepth 1, askDepth 3 -> (1 - 3) / 4 = -0.5.
full = ta.OrderBookImbalanceFull()
assert full.update([100.0], [1.0], [101.0, 102.0], [2.0, 1.0]) == pytest.approx(-0.5)
def test_microprice_reference_value():
# (100*3 + 101*1) / (1 + 3) = 401 / 4 = 100.25 — heavy ask pulls toward bid.
mp = ta.Microprice()
assert mp.update([100.0], [1.0], [101.0], [3.0]) == pytest.approx(100.25)
def test_quoted_spread_reference_value():
# spread 1.0, mid 100.5 -> 1 / 100.5 * 10_000 ≈ 99.5025 bps.
qs = ta.QuotedSpread()
assert qs.update([100.0], [1.0], [101.0], [1.0]) == pytest.approx(99.50248756, abs=1e-6)
def test_depth_slope_reference_value():
# Symmetric book, each side distances 1, 2 with cumulative sizes 1, 3.
# OLS slope of (1->1, 2->3) = 2; mean of two equal sides = 2.
ds = ta.DepthSlope()
out = ds.update([99.0, 98.0], [1.0, 2.0], [101.0, 102.0], [1.0, 2.0])
assert out == pytest.approx(2.0, abs=1e-9)
# A book with a single level per side has no slope -> 0.
assert ta.DepthSlope().update([100.0], [1.0], [101.0], [1.0]) == pytest.approx(0.0)
def test_footprint_buckets_buy_and_sell_volume():
fp = ta.Footprint(1.0)
fp.update(100.2, 2.0, True) # bucket 100 -> ask 2
fp.update(100.7, 3.0, False) # bucket 101 -> bid 3
out = fp.update(100.1, 1.0, True) # bucket 100 -> ask 3
# Columns are [price, bid_vol, ask_vol], rows sorted ascending by price.
assert out.shape == (2, 3)
assert list(out[0]) == [100.0, 0.0, 3.0]
assert list(out[1]) == [101.0, 3.0, 0.0]
def test_signed_volume_reference_values():
assert ta.SignedVolume().update(100.0, 2.0, True) == pytest.approx(2.0)
assert ta.SignedVolume().update(100.0, 3.0, False) == pytest.approx(-3.0)
def test_cumulative_volume_delta_reference_values():
cvd = ta.CumulativeVolumeDelta()
assert cvd.update(100.0, 5.0, True) == pytest.approx(5.0)
assert cvd.update(100.0, 2.0, False) == pytest.approx(3.0)
assert cvd.update(100.0, 4.0, False) == pytest.approx(-1.0)
def test_trade_imbalance_reference_value():
ti = ta.TradeImbalance(2)
assert ti.update(100.0, 3.0, True) is None # warming up
# Window full: buyVol 3, sellVol 1 -> (3 - 1) / 4 = 0.5.
assert ti.update(100.0, 1.0, False) == pytest.approx(0.5)
def test_effective_spread_reference_values():
# Buy at 100.05 vs mid 100.0: 2 * (100.05 - 100) / 100 * 10000 = 10 bps.
assert ta.EffectiveSpread().update(100.05, 1.0, True, 100.0) == pytest.approx(10.0)
# Sell at 99.95 vs mid 100.0: 2 * -1 * (99.95 - 100) / 100 * 10000 = 10 bps.
assert ta.EffectiveSpread().update(99.95, 1.0, False, 100.0) == pytest.approx(10.0)
# A buy filled below the mid is price improvement -> negative.
assert ta.EffectiveSpread().update(99.95, 1.0, True, 100.0) < 0.0
def test_realized_spread_reference_value():
rs = ta.RealizedSpread(1)
assert rs.update(100.10, 1.0, True, 100.0) is None # buffered
# Resolved against mid 100.20 one trade later:
# 2 * (+1) * (100.10 - 100.20) / 100.0 * 10000 = -20 bps (adverse selection).
assert rs.update(99.90, 1.0, False, 100.20) == pytest.approx(-20.0)
def test_kyles_lambda_recovers_constant_impact():
# Build a tape where each trade moves the mid by exactly 0.5 per unit of
# signed volume -> the rolling OLS slope is 0.5.
impact = 0.5
mid = 100.0
price, size, is_buy, mids = [], [], [], []
for i in range(20):
buy = i % 2 == 0
sz = 1.0 + (i % 3)
signed = sz if buy else -sz
mid += impact * signed
price.append(mid)
size.append(sz)
is_buy.append(buy)
mids.append(mid)
out = ta.KylesLambda(6).batch(price, size, is_buy, mids)
assert out[-1] == pytest.approx(0.5, abs=1e-9)
+132
View File
@@ -86,3 +86,135 @@ 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()
def test_orderbook_lifecycle():
snapshot = ([100.0], [1.0], [101.0], [1.0])
for ind in [
ta.OrderBookImbalanceTop1(),
ta.OrderBookImbalanceTopN(3),
ta.OrderBookImbalanceFull(),
ta.Microprice(),
ta.QuotedSpread(),
ta.DepthSlope(),
]:
assert ind.warmup_period() == 1
assert not ind.is_ready()
ind.update(*snapshot)
assert ind.is_ready()
ind.reset()
assert not ind.is_ready()
def test_orderbook_topn_repr():
assert repr(ta.OrderBookImbalanceTopN(5)) == "OrderBookImbalanceTopN(levels=5)"
def test_tradeflow_lifecycle():
for ind in [ta.SignedVolume(), ta.CumulativeVolumeDelta()]:
assert ind.warmup_period() == 1
assert not ind.is_ready()
ind.update(100.0, 1.0, True)
assert ind.is_ready()
ind.reset()
assert not ind.is_ready()
def test_trade_imbalance_lifecycle_and_repr():
ti = ta.TradeImbalance(3)
assert ti.warmup_period() == 3
assert not ti.is_ready()
for _ in range(3):
ti.update(100.0, 1.0, True)
assert ti.is_ready()
ti.reset()
assert not ti.is_ready()
assert repr(ta.TradeImbalance(4)) == "TradeImbalance(window=4)"
def test_effective_spread_lifecycle():
es = ta.EffectiveSpread()
assert es.warmup_period() == 1
assert not es.is_ready()
es.update(100.05, 1.0, True, 100.0)
assert es.is_ready()
es.reset()
assert not es.is_ready()
def test_realized_spread_lifecycle_and_repr():
rs = ta.RealizedSpread(3)
assert rs.warmup_period() == 4
assert not rs.is_ready()
for _ in range(4):
rs.update(100.0, 1.0, True, 100.0)
assert rs.is_ready()
rs.reset()
assert not rs.is_ready()
assert repr(ta.RealizedSpread(5)) == "RealizedSpread(horizon=5)"
def test_kyles_lambda_lifecycle_and_repr():
kl = ta.KylesLambda(3)
assert kl.warmup_period() == 4
assert not kl.is_ready()
for i in range(4):
kl.update(100.0 + i, 1.0 + (i % 2), i % 2 == 0, 100.0 + i)
assert kl.is_ready()
kl.reset()
assert not kl.is_ready()
assert repr(ta.KylesLambda(7)) == "KylesLambda(window=7)"
def test_footprint_lifecycle_and_repr():
fp = ta.Footprint(0.5)
assert fp.warmup_period() == 1
assert not fp.is_ready()
fp.update(100.0, 1.0, True)
assert fp.is_ready()
fp.reset()
assert not fp.is_ready()
assert repr(ta.Footprint(0.25)) == "Footprint(tick_size=0.25)"
File diff suppressed because it is too large Load Diff
+112
View File
@@ -55,3 +55,115 @@ 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)
def test_orderbook_indicators_construct_and_emit():
# All five order-book indicators accept a four-array snapshot and emit a float.
snapshot = ([100.0, 99.0], [2.0, 1.0], [101.0, 102.0], [1.0, 1.0])
indicators = [
ta.OrderBookImbalanceTop1(),
ta.OrderBookImbalanceTopN(2),
ta.OrderBookImbalanceFull(),
ta.Microprice(),
ta.QuotedSpread(),
ta.DepthSlope(),
]
for ind in indicators:
out = ind.update(*snapshot)
assert isinstance(out, float)
def test_orderbook_batch_returns_one_value_per_snapshot():
snapshots = [([100.0], [3.0], [101.0], [1.0])] * 5
out = ta.OrderBookImbalanceTop1().batch(snapshots)
assert out.shape == (5,)
assert out.dtype == np.float64
def test_tradeflow_indicators_construct_and_emit():
# SignedVolume and CVD emit from the first trade; TradeImbalance(1) too.
assert isinstance(ta.SignedVolume().update(100.0, 2.0, True), float)
assert isinstance(ta.CumulativeVolumeDelta().update(100.0, 2.0, True), float)
assert isinstance(ta.TradeImbalance(1).update(100.0, 2.0, True), float)
def test_tradeflow_batch_returns_one_value_per_trade():
price = np.full(6, 100.0)
size = np.array([1.0, 2.0, 3.0, 1.0, 2.0, 3.0])
is_buy = [True, False, True, False, True, False]
out = ta.CumulativeVolumeDelta().batch(price, size, is_buy)
assert out.shape == (6,)
assert out.dtype == np.float64
def test_price_impact_indicators_construct_and_emit():
# Price-impact indicators take a trade paired with the prevailing mid.
assert isinstance(ta.EffectiveSpread().update(100.05, 1.0, True, 100.0), float)
# RealizedSpread buffers until its horizon elapses.
assert ta.RealizedSpread(1).update(100.05, 1.0, True, 100.0) is None
def test_price_impact_batch_returns_one_value_per_trade():
price = np.array([100.05, 99.95, 100.10, 99.90])
size = np.array([1.0, 2.0, 1.0, 2.0])
is_buy = [True, False, True, False]
mid = np.full(4, 100.0)
for ind in (ta.EffectiveSpread(), ta.RealizedSpread(2), ta.KylesLambda(2)):
out = ind.batch(price, size, is_buy, mid)
assert out.shape == (4,)
assert out.dtype == np.float64
def test_footprint_constructs_and_emits():
out = ta.Footprint(1.0).update(100.2, 2.0, True)
assert out.shape == (1, 3)
assert out.dtype == np.float64
def test_footprint_batch_returns_list_of_arrays():
res = ta.Footprint(1.0).batch([100.2, 100.7], [2.0, 3.0], [True, False])
assert isinstance(res, list)
assert len(res) == 2
assert res[-1].shape[1] == 3
@@ -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,92 @@ 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)
def test_orderbook_streaming_matches_batch():
snaps = [
(
[100.0, 99.0],
[1.0 + (i % 5), 1.0],
[101.0, 102.0],
[1.0 + ((i + 1) % 3), 1.0],
)
for i in range(30)
]
batch = ta.Microprice().batch(snaps)
streamer = ta.Microprice()
streamed = np.array([streamer.update(*snap) for snap in snaps], dtype=np.float64)
assert _equal_with_nan(batch, streamed)
def test_tradeflow_streaming_matches_batch():
n = 30
price = np.full(n, 100.0)
size = np.array([1.0 + (i % 4) for i in range(n)], dtype=np.float64)
is_buy = [i % 3 != 0 for i in range(n)]
batch = ta.CumulativeVolumeDelta().batch(price, size, is_buy)
streamer = ta.CumulativeVolumeDelta()
streamed = np.array(
[streamer.update(price[i], size[i], is_buy[i]) for i in range(n)],
dtype=np.float64,
)
assert _equal_with_nan(batch, streamed)
def test_price_impact_streaming_matches_batch():
n = 30
mid = np.array([100.0 + 0.25 * math.sin(i * 0.5) for i in range(n)], dtype=np.float64)
is_buy = [i % 3 != 0 for i in range(n)]
price = np.array(
[mid[i] + (0.03 if is_buy[i] else -0.03) for i in range(n)], dtype=np.float64
)
size = np.array([1.0 + (i % 4) for i in range(n)], dtype=np.float64)
batch = ta.EffectiveSpread().batch(price, size, is_buy, mid)
streamer = ta.EffectiveSpread()
streamed = np.array(
[streamer.update(price[i], size[i], is_buy[i], mid[i]) for i in range(n)],
dtype=np.float64,
)
assert _equal_with_nan(batch, streamed)
+1 -1
View File
@@ -5,7 +5,7 @@ version.workspace = true
authors.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
license-file.workspace = true
repository.workspace = true
homepage.workspace = true
readme.workspace = true
+56 -33
View File
@@ -1,49 +1,72 @@
# wickra-wasm
# Wickra — WebAssembly
WebAssembly bindings for the Wickra streaming-first technical indicators library.
[![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)
[![npm](https://img.shields.io/npm/v/wickra-wasm.svg?logo=npm&color=red)](https://www.npmjs.com/package/wickra-wasm)
[![License: PolyForm-NC](https://img.shields.io/badge/license-PolyForm--NC--1.0.0-purple)](https://github.com/wickra-lib/wickra/blob/main/LICENSE)
## Build
**Streaming-first technical indicators in the browser. `npm install
wickra-wasm` — pure WebAssembly, runs anywhere a modern JS engine does.**
You need [`wasm-pack`](https://rustwasm.github.io/wasm-pack/) and the
`wasm32-unknown-unknown` Rust target:
Wickra is a multi-language technical-analysis library with a Rust core and
bindings for Python, Node.js, and WebAssembly. Every indicator is an O(1)
streaming state machine, so live trading dashboards and historical backtests
share the exact same implementation. This package is the WebAssembly binding
(wasm-bindgen, built for the `web` target); it exposes 200+ streaming-first
indicators across sixteen families.
## Install
```bash
rustup target add wasm32-unknown-unknown
cargo install wasm-pack
npm install wickra-wasm
```
Then from the repository root:
## Quick start
```bash
wasm-pack build bindings/wasm --target web --release --features panic-hook
```
The compiled package lands in `bindings/wasm/pkg/`. Targets:
- `--target web` for native ES modules in browsers
- `--target bundler` for webpack/Vite/Rollup
- `--target nodejs` for Node.js
## Example
The module ships a default `init` export that loads the `.wasm` payload; await
it once before constructing indicators.
```js
import init, { SMA, RSI, MACD, version } from "./pkg/wickra_wasm.js";
import init, { RSI } from 'wickra-wasm';
await init();
console.log("wickra:", version());
await init(); // load the WebAssembly module once
// Streaming
// Streaming: feed prices tick by tick in O(1).
const rsi = new RSI(14);
for (const price of livePrices) {
const v = rsi.update(price);
if (v !== undefined && v > 70) console.log("overbought");
for (const price of liveFeed) {
const value = rsi.update(price); // null during warmup
if (value !== null && value > 70) {
console.log('overbought');
}
}
// Batch (returns a Float64Array; NaN for warmup positions)
const sma = new SMA(20).batch(new Float64Array(historicalPrices));
```
An interactive demo lives in [`examples/wasm/index.html`](../../examples/wasm/index.html)
(top-level alongside the other language examples). After building the package
with `wasm-pack build`, serve the repository root and open
`examples/wasm/index.html` in a browser.
Constructors mirror the other bindings (`new SMA(20)`, `new MACD(12, 26, 9)`,
`new BollingerBands(20, 2.0)`, …); `update()` returns the latest value or
`null` while the indicator is still warming up.
## Documentation
The full indicator catalogue, guides, quickstarts, and API reference live in
the main repository and documentation site:
- **Repository & full indicator list:** <https://github.com/wickra-lib/wickra>
- **Docs** (quickstarts, cookbook, TA-Lib migration): <https://docs.wickra.org>
- **Runnable browser examples:** [`examples/wasm/`](https://github.com/wickra-lib/wickra/tree/main/examples/wasm)
Wickra ships four bindings — Python, Node.js, WebAssembly, and Rust — that all
expose the same indicators from the shared, `unsafe`-forbidden Rust core.
## Disclaimer
Wickra is an indicator toolkit, not a trading system. The values it computes
are deterministic transforms of the input data — they are not financial advice
and do not predict the market. Any use in a live trading context is at your own
risk. The library is provided **as is**, without warranty of any kind.
## License
Licensed under the **PolyForm Noncommercial License 1.0.0**. Personal projects,
research, education, non-profits, and hobby trading bots are all fine; the one
thing not allowed is commercial sale of the software or of services built
around it. See [LICENSE](https://github.com/wickra-lib/wickra/blob/main/LICENSE).
+5326 -1
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -5,7 +5,7 @@ version.workspace = true
authors.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
license-file.workspace = true
repository.workspace = true
homepage.workspace = true
readme.workspace = true
+12
View File
@@ -31,6 +31,18 @@ pub enum Error {
/// A multiplier or factor must be strictly positive.
#[error("multiplier must be greater than zero")]
NonPositiveMultiplier,
/// An order-book snapshot whose levels do not satisfy the book invariants
/// (e.g. a crossed book, non-finite price, negative size, or mis-sorted
/// levels) was provided. Order books are a microstructure input distinct
/// from candles and ticks, so they surface as their own variant.
#[error("invalid order book: {message}")]
InvalidOrderBook { message: &'static str },
/// A trade whose components do not satisfy the trade invariants (e.g.
/// non-finite price or negative size) was provided.
#[error("invalid trade: {message}")]
InvalidTrade { message: &'static str },
}
/// Convenience alias for `Result<T, wickra_core::Error>`.
@@ -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);
}
}
@@ -165,6 +165,16 @@ mod tests {
assert!(AcceleratorOscillator::new(34, 5, 5).is_err());
}
/// Cover the const accessor `params` (69-71) and the Indicator-impl
/// `name` body (99-101). Existing tests inspect numeric output but
/// never query the metadata.
#[test]
fn accessors_and_metadata() {
let ac = AcceleratorOscillator::classic();
assert_eq!(ac.params(), (5, 34, 5));
assert_eq!(ac.name(), "AcceleratorOscillator");
}
#[test]
fn reset_clears_state() {
let candles: Vec<Candle> = (0..60).map(|i| c(11.0, 9.0, 10.0, i)).collect();
@@ -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());
}
}
+8
View File
@@ -127,6 +127,14 @@ mod tests {
assert!(adl.update(candle(8.0, 10.0, 8.0, 9.0, 50.0, 0)).is_some());
}
/// Cover the Indicator-impl `name` body (94-96). The other accessors
/// are exercised by existing tests; `name` was never queried.
#[test]
fn accessors_and_metadata() {
let adl = Adl::new();
assert_eq!(adl.name(), "ADL");
}
#[test]
fn close_at_high_accumulates_full_volume() {
// Every bar closes at its high: MFM = +1, so ADL grows by `volume`.
+31
View File
@@ -269,6 +269,37 @@ mod tests {
assert!(Adx::new(0).is_err());
}
/// Cover the const accessor `period` (lines 89-91) and the Indicator-impl
/// `warmup_period` (199-201) + `name` (207-209). None of the trend tests
/// inspect these metadata methods.
#[test]
fn accessors_and_metadata() {
let adx = Adx::new(14).unwrap();
assert_eq!(adx.period(), 14);
assert_eq!(adx.warmup_period(), 28);
assert_eq!(adx.name(), "ADX");
}
/// Cover the `tr_v == 0.0` defensive branches in `update` (lines 142,
/// 147) — feeding a stream of perfectly flat candles (H == L == close
/// every bar) gives true-range 0 each step, so the smoothed `tr_smooth`
/// stays at 0.0 and the `plus_di` / `minus_di` divisions would otherwise
/// blow up. The indicator must emit zeros (DX denominator is also 0).
#[test]
fn zero_true_range_yields_zero_di_and_zero_adx() {
let candles: Vec<Candle> = (0..30).map(|_| c(10.0, 10.0, 10.0)).collect();
let mut adx = Adx::new(5).unwrap();
let last = adx
.batch(&candles)
.into_iter()
.flatten()
.last()
.expect("ADX emits after 2 * period candles");
assert_eq!(last.plus_di, 0.0);
assert_eq!(last.minus_di, 0.0);
assert_eq!(last.adx, 0.0);
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..60)
+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);
}
}
@@ -182,4 +182,13 @@ mod tests {
assert!(!a.is_ready());
assert_eq!(a.update(candles[0]), None);
}
/// Cover the const accessor `period` (56-58) and the Indicator-impl
/// `name` body (104-106). `warmup_period` is exercised elsewhere.
#[test]
fn accessors_and_metadata() {
let a = Aroon::new(14).unwrap();
assert_eq!(a.period(), 14);
assert_eq!(a.name(), "Aroon");
}
}
@@ -107,6 +107,21 @@ mod tests {
assert!(AroonOscillator::new(0).is_err());
}
/// Cover the const accessors `period` / `value` (57-64) and the
/// Indicator-impl `name` body (90-92). `warmup_period` is covered
/// already by `warmup_period_matches_aroon`.
#[test]
fn accessors_and_metadata() {
let mut osc = AroonOscillator::new(7).unwrap();
assert_eq!(osc.period(), 7);
assert_eq!(osc.name(), "AroonOscillator");
assert_eq!(osc.value(), None);
for i in 0..8 {
osc.update(candle(100.0 + f64::from(i), 90.0, 95.0, i64::from(i)));
}
assert!(osc.value().is_some());
}
#[test]
fn pure_uptrend_yields_plus_100() {
// Every bar a fresh high, no fresh low: AroonUp = 100, AroonDown = 0.
+15
View File
@@ -151,6 +151,21 @@ mod tests {
assert!(matches!(Atr::new(0), Err(Error::PeriodZero)));
}
/// Cover the const accessors `period` / `value` (54-62) and the
/// Indicator-impl `name` body (103-105). Existing tests inspect
/// numeric ATR output but never query the metadata.
#[test]
fn accessors_and_metadata() {
let mut atr = Atr::new(14).unwrap();
assert_eq!(atr.period(), 14);
assert_eq!(atr.name(), "ATR");
assert_eq!(atr.value(), None);
for _ in 0..14 {
atr.update(c(11.0, 9.0, 10.0));
}
assert!(atr.value().is_some());
}
#[test]
fn warmup_emits_on_period_th_candle() {
let candles = vec![
@@ -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);
}
}
@@ -234,6 +234,17 @@ mod tests {
assert!(AtrTrailingStop::new(14, f64::NAN).is_err());
}
/// Cover the const accessor `params` (77-79) and the Indicator-impl
/// `name` body (130-132). `warmup_period` is exercised elsewhere.
#[test]
fn accessors_and_metadata() {
let s = AtrTrailingStop::classic();
let (atr_p, mult) = s.params();
assert_eq!(atr_p, 14);
assert!((mult - 3.0).abs() < 1e-12);
assert_eq!(s.name(), "AtrTrailingStop");
}
#[test]
fn reset_clears_state() {
let candles: Vec<Candle> = (0..40)
@@ -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);
}
}
}
@@ -118,6 +118,17 @@ mod tests {
assert!(AwesomeOscillator::new(0, 5).is_err());
}
/// Cover the const accessor `periods` (59-61) and the Indicator-impl
/// `warmup_period` (83-85) + `name` (91-93). Existing tests never
/// inspect these metadata methods.
#[test]
fn accessors_and_metadata() {
let ao = AwesomeOscillator::classic();
assert_eq!(ao.periods(), (5, 34));
assert_eq!(ao.warmup_period(), 34);
assert_eq!(ao.name(), "AwesomeOscillator");
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..50)
@@ -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());
}
}
@@ -132,6 +132,13 @@ mod tests {
);
}
/// Cover the Indicator-impl `name` body (73-75).
#[test]
fn name_metadata() {
let bop = BalanceOfPower::new();
assert_eq!(bop.name(), "BalanceOfPower");
}
#[test]
fn emits_from_first_candle() {
let mut bop = BalanceOfPower::new();
+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);
}
}
+23 -9
View File
@@ -172,20 +172,21 @@ mod tests {
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn naive(prices: &[f64], period: usize, mult: f64) -> Option<BollingerOutput> {
if prices.len() < period {
return None;
}
fn naive(prices: &[f64], period: usize, mult: f64) -> BollingerOutput {
assert!(
prices.len() >= period,
"naive requires at least `period` prices"
);
let w = &prices[prices.len() - period..];
let mean = w.iter().sum::<f64>() / period as f64;
let var = w.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / period as f64;
let s = var.sqrt();
Some(BollingerOutput {
BollingerOutput {
upper: mean + mult * s,
middle: mean,
lower: mean - mult * s,
stddev: s,
})
}
}
#[test]
@@ -212,6 +213,20 @@ mod tests {
));
}
/// Cover the convenience constructor `BollingerBands::classic()` plus the
/// const accessors `period` / `multiplier` and the Indicator-impl
/// metadata methods `warmup_period` / `name`. Existing tests never
/// invoked `classic()` (every test passed explicit parameters to
/// `new`) and never queried any of the four getters.
#[test]
fn classic_and_accessors_and_metadata() {
let bb = BollingerBands::classic();
assert_eq!(bb.period(), 20);
assert_relative_eq!(bb.multiplier(), 2.0, epsilon = 1e-12);
assert_eq!(bb.warmup_period(), 20);
assert_eq!(bb.name(), "BollingerBands");
}
#[test]
fn warmup_returns_none() {
let mut bb = BollingerBands::new(5, 2.0).unwrap();
@@ -241,7 +256,7 @@ mod tests {
let out = bb.batch(&prices);
for i in 19..prices.len() {
let got = out[i].unwrap();
let want = naive(&prices[..=i], 20, 2.0).unwrap();
let want = naive(&prices[..=i], 20, 2.0);
assert_relative_eq!(got.middle, want.middle, epsilon = 1e-9);
assert_relative_eq!(got.stddev, want.stddev, epsilon = 1e-9);
assert_relative_eq!(got.upper, want.upper, epsilon = 1e-9);
@@ -301,8 +316,7 @@ mod tests {
}
window.push_back(v);
}
let scratch =
naive(&window.iter().copied().collect::<Vec<_>>(), period, mult).expect("warmed up");
let scratch = naive(&window.iter().copied().collect::<Vec<_>>(), period, mult);
let got = last.expect("warmed up");
assert!(
(got.middle - scratch.middle).abs() < 1e-3,
@@ -113,6 +113,27 @@ mod tests {
assert!(BollingerBandwidth::new(20, -1.0).is_err());
}
/// Cover the public const accessors `period`, `multiplier`, `value` and
/// the Indicator-impl `warmup_period` + `name` methods. None of the
/// pre-existing tests inspected the metadata surface — they only fed
/// numeric updates and asserted on the bandwidth values, leaving the
/// five getter bodies (lines 54-66, 90-92, 98-100) untouched.
#[test]
fn accessors_and_metadata() {
let mut bbw = BollingerBandwidth::new(20, 2.0).unwrap();
assert_eq!(bbw.period(), 20);
assert_relative_eq!(bbw.multiplier(), 2.0, epsilon = 1e-12);
// value() before warmup must be the literal None branch of self.last.
assert_eq!(bbw.value(), None);
assert_eq!(bbw.warmup_period(), 20);
assert_eq!(bbw.name(), "BollingerBandwidth");
// Drive past warmup so value() exercises the Some branch as well.
for i in 1..=20 {
bbw.update(f64::from(i));
}
assert!(bbw.value().is_some());
}
#[test]
fn constant_series_yields_zero() {
// Flat prices: the bands collapse onto the middle, so width is 0.
@@ -123,6 +144,23 @@ mod tests {
}
}
/// Cover the defensive `o.middle == 0.0` branch in `update` (line 77).
/// All other tests use price levels ≈100, so the rolling SMA is always
/// strictly positive and the zero-middle fallback is unreachable. Feed
/// a symmetric series whose 5-bar mean is exactly 0 to force the branch
/// and assert the indicator yields exactly 0.0 (rather than inf/nan).
#[test]
fn zero_middle_band_yields_zero_bandwidth() {
let mut bbw = BollingerBandwidth::new(5, 2.0).unwrap();
// sum(-2, -1, 0, 1, 2) = 0 exactly in IEEE-754, so the SMA middle
// lands on exactly 0.0 at the fifth input. Stddev > 0, so absent
// the guard the next line would divide by zero.
let out = bbw.batch(&[-2.0, -1.0, 0.0, 1.0, 2.0]);
assert_eq!(out[..4], [None, None, None, None]);
let v = out[4].expect("warmed up");
assert_eq!(v, 0.0, "zero-middle fallback must emit exactly 0.0");
}
#[test]
fn matches_bands_definition() {
// Bandwidth must equal (upper - lower) / middle from BollingerBands.
@@ -131,13 +169,11 @@ mod tests {
.collect();
let bbw_out = BollingerBandwidth::new(20, 2.0).unwrap().batch(&prices);
let bands_out = BollingerBands::new(20, 2.0).unwrap().batch(&prices);
for (w, b) in bbw_out.iter().zip(bands_out.iter()) {
match (w, b) {
(Some(wv), Some(bv)) => {
assert_relative_eq!(*wv, (bv.upper - bv.lower) / bv.middle, epsilon = 1e-12);
}
(None, None) => {}
_ => panic!("warmup mismatch"),
for (i, (w, b)) in bbw_out.iter().zip(bands_out.iter()).enumerate() {
// Same warmup period on both — emission shape must agree at every index.
assert_eq!(w.is_some(), b.is_some(), "warmup mismatch at index {i}");
if let (Some(wv), Some(bv)) = (w, b) {
assert_relative_eq!(*wv, (bv.upper - bv.lower) / bv.middle, epsilon = 1e-12);
}
}
}
@@ -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);
}
}

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