Compare commits

..

79 Commits

Author SHA1 Message Date
kingchenc 83e34c6f71 release: bump 0.6.0 -> 0.6.1 (#192)
Version bump for the v0.6.1 release shipping the B6 Bands & Channels family (#191): 429 -> 434 indicators.
2026-06-07 00:13:58 +02:00
kingchenc 67feec598a feat(indicators): add B6 Bands & Channels family (429 -> 434) (#191)
Adds the **B6 Bands & Channels** batch — five band/channel indicators, taking the catalogue from 429 to 434.

| Indicator | Input → Output | Summary |
|-----------|----------------|---------|
| `ProjectionBands` | `Candle` → `{upper,middle,lower}` | Widner forward-projected high/low regression envelope |
| `ProjectionOscillator` | `Candle` → `f64` | Close position inside the projection bands, scaled 0..100 |
| `QuartileBands` | `f64` → `{upper,middle,lower}` | Rolling 25th/50th/75th-percentile (Q1/median/Q3) envelope |
| `BomarBands` | `f64` → `{upper,middle,lower}` | Adaptive percentage bands containing a target coverage fraction of recent closes |
| `MedianChannel` | `f64` → `{upper,middle,lower}` | Robust median ± multiplier·MAD envelope |

All five are distinct from existing indicators (verified against the core: `LinRegChannel`, `StandardErrorBands`, `Donchian`, `RollingQuantile`, `HurstChannel`). SKIPped from the roadmap: Price Channel (= `Donchian`) and Moving-Average Channel (≈ `MaEnvelope`/`Keltner`).

Each ships:
- Core indicator with per-branch unit tests (Codecov-strict 100%).
- python / node / wasm bindings (struct outputs are hand-written; `ProjectionOscillator` uses the generated candle→f64 path).
- Fuzz drives, python (`MULTI`/`SCALAR_MULTI`/`CANDLE_SCALAR`) + node test registries, README + CHANGELOG counter bump to 434.

Verified locally: `cargo fmt`, `clippy --workspace --all-targets --all-features -D warnings` (clean), `wickra-core` 3511 lib + 392 doc tests, node 509 tests, pytest 840.
2026-06-07 00:03:02 +02:00
kingchenc 3dfbc415c5 release: bump 0.5.9 -> 0.6.0 (#190)
Version bump for the **v0.6.0** release (ships the B5 Volatility & Bands batch, #189 — 423 -> 429 indicators).

Bumps version strings across Cargo workspace, pyproject, node package.json + 6 platform packages, both package-lock.json files, and Cargo.lock; CHANGELOG `[Unreleased]` -> `[0.6.0]`. No code changes.

Versioning note: patch never reaches two digits — `0.5.9` rolls to the next minor `0.6.0` (not 0.5.10).
2026-06-06 22:48:56 +02:00
kingchenc 6b8c6a0e7f B5 volatility & bands batch (423 -> 429) (#189)
Adds six **Volatility & Bands** indicators (Part B5 of the expansion roadmap), 423 → 429.

| Indicator | Input → Output | Summary |
|-----------|----------------|---------|
| `EwmaVolatility` | `f64` → `f64` | RiskMetrics exponentially-weighted volatility (λ decay) |
| `Garch11` | `f64` → `f64` | GARCH(1,1) conditional volatility with a long-run-variance anchor |
| `BipowerVariation` | `f64` → `f64` | jump-robust realized bipower variation (π/2 · Σ\|rₜ\|\|rₜ₋₁\|) |
| `VolatilityRatio` | `Candle` → `f64` | Schwager's true range over the EMA of prior true ranges (>2 = wide-ranging day) |
| `VolatilityCone` | `Candle` → `VolatilityConeOutput` | current realized volatility within its min/median/max envelope + percentile |
| `VolatilityOfVolatility` | `f64` → `f64` | sample stddev of a rolling realized-volatility series |

### Notes
- Two B5 roadmap items were dropped as duplicates/by-construction: `RealizedVolatility` already ships (v0.5.4); `Downside Semi-Deviation` is internal to Sortino. `Bipower Variation` confirmed distinct from `JumpIndicator` (a ±1 flag, not a variance measure).
- `VolatilityRatio` implements the widely-charted EMA-of-true-range convention (denominator excludes the current bar so the 2.0 threshold means "twice typical"), distinct from the existing pairwise `variance_ratio`.
- `Garch11` mean-reverts to `ω/(1−β)` on a flat series (does not decay to 0 like EWMA) — pinned by a dedicated test.

### Coverage / verification
- Full core + Python/Node/WASM bindings, fuzz drivers (scalar + candle), registries, CHANGELOG, README + docs counter sync.
- 100% unit-test coverage per indicator (every branch).
- Green locally: `cargo clippy --workspace --all-targets --all-features -D warnings`, core lib (3479) + doc (387), node (504), python (830).

Deep-dive docs for all six are staged for `wickra-docs` and pushed after release (gated).
2026-06-06 22:38:34 +02:00
kingchenc db186b18d3 docs(readme): star-history chart + ci(sync-about): sync docs config count (#188)
Add a dark-mode star-history chart under the README footer thank-you line (all existing badges kept), and make sync-about also patch the indicator count into wickra-docs .vitepress/config.ts.
2026-06-06 21:32:35 +02:00
kingchenc 654da5722f release: bump 0.5.8 -> 0.5.9 (#187)
Patch release: streaming/batch perf (SMA, Bollinger, RSI, EMA, ATR; outputs unchanged), cross-library benchmark harness, honest tiered README. No new indicators, no API changes.
2026-06-06 21:09:05 +02:00
kingchenc aacb9280f1 Honest tiered cross-library benchmark + streaming/batch perf (#186)
## Summary

An honest, tiered cross-library benchmark — and the optimization pass it triggered.

### Performance (wickra-core, outputs unchanged)
Profiling against the other Rust TA crates exposed real inefficiencies. Each
benchmarked indicator is now **5–79% faster** in both streaming and batch:

- **SMA, Bollinger**: flat `Box<[f64]>` ring buffers replace `VecDeque` (−69…79%).
- **RSI**: `100·ag/(ag+al)` collapses three divisions into one; Wilder smoothing
  hoists `1/period` out of the hot path (−46%).
- **ATR**: reciprocal hoisted (−42%).
- **EMA/RSI/ATR**: per-tick `Option<f64>` hot state → bare `f64` + ready flag.

Net result vs `kand`: Wickra now wins **RSI, Bollinger and ATR** (streaming), and
ties `ta-rs` on SMA — up from losing every indicator 1.5–6× before.

### Benchmark harness
New `crates/wickra-bench` (publish=false): a Criterion benchmark comparing Wickra
against `kand`, `ta-rs` and `yata` on an identical BTCUSDT candle series, in
streaming and batch modes. Peer APIs were verified against their source, not
guessed. Wired into the nightly `cross-library-bench` workflow as a separate job.

### Honest README
The benchmark section is rewritten into three layered tables (Rust core vs Rust
crates; Python vs the Python ecosystem) that **show the losses as well as the
wins**. The "only library that combines…" claim is gone; the new framing is
breadth + multi-language reach + the deliberate safety trade-off that costs raw
speed. Added an origin/why-slower rationale and a star CTA.

### Python benchmark
Added `tulipy` runners and expanded per-tick streaming coverage to SMA/EMA/RSI/
MACD/Bollinger. `bench.in`/`bench.txt` now lock `TA-Lib` + `tulipy` (hash-pinned);
`pandas-ta` stays out (it requires Python ≥ 3.12, the bench runs on 3.11).

### Notes
- TA-Lib/tulipy numbers in the README Python table are marked ⧗ — they are
  produced by the CI Linux job (C extensions don't build cleanly on every
  desktop), not measured locally.
- The matching `wickra-docs` prose update is committed separately and will be
  pushed with the release, per the docs-don't-lead-the-registries rule.

Verified locally: `cargo fmt`, `cargo test --workspace --all-features` (3413 core
+ bindings), `cargo clippy --workspace --all-targets --all-features -D warnings`,
Node build + 498 tests, and pytest all green.
2026-06-06 20:57:31 +02:00
kingchenc d2bc000892 release: bump 0.5.7 -> 0.5.8 (#185)
Version bump for the B4 price oscillators release (#184): `TsfOscillator`, `MacdHistogram`, `PpoHistogram` — 420 → 423 indicators.

Bumps workspace + bindings (Cargo.toml/lock, pyproject, node package.json + 6 platform manifests + lockfiles) and rolls CHANGELOG `[Unreleased]` into `[0.5.8]`.
2026-06-04 19:47:22 +02:00
kingchenc 1f4bf9e3a6 feat(core): B4 price oscillators (TsfOscillator, MacdHistogram, PpoHistogram) (#184)
Adds three **Price Oscillators** family indicators (420 → 423).

## Indicators

- **TsfOscillator** — `100·(close − TSF)/close`, the percentage gap of the close to the **one-bar-ahead** time-series forecast. Close-relative companion to `Cfo`, which measures the same gap against the regression value at the *current* bar; the two differ by exactly the slope term `100·b/close`.
- **MacdHistogram** — the standalone `macd − signal` bar of MACD exposed as a plain `f64` series.
- **PpoHistogram** — the Percentage Price Oscillator with its 9-period signal EMA and the resulting scale-free, zero-centered histogram (PPO itself only emits the line).

All three are scalar `f64` indicators wrapping existing, already-tested building blocks (`MacdIndicator`, `Ppo` + `Ema`, `Tsf`).

## Scope notes (VORAB-CHECK)

The B4 roadmap listed six items; three were dropped to avoid duplicates:
- *Forecast Oscillator* already ships as `Cfo`.
- *Derivative Oscillator* already ships (`DerivativeOscillator`, B2).
- *Detrended Synthetic Price* deferred — no citable formula distinct from the existing `Apo`/`Dpo`.

## Touchpoints

Core (`tsf_oscillator.rs`, `macd_histogram.rs`, `ppo_histogram.rs`) with full per-branch unit tests, `mod.rs`/`lib.rs`, python/node/wasm bindings (wasm via typed-arg macro, python/node hand-written for the multi-arg histograms), fuzz drivers, python reference + streaming-vs-batch tests, node factories, README family row + counter, CHANGELOG.

Local verify: `cargo test --workspace` green, `clippy -D warnings` clean, node 498 tests, full python suite green.
2026-06-04 19:36:43 +02:00
kingchenc d36d514f56 fix(core): re-export GatorOscillatorOutput & KasePermissionStochasticOutput (#183)
The two market-profile struct-output indicators from the B3 batch (`GatorOscillator`, `KasePermissionStochastic`) exposed their public output structs from their own modules but did not re-export them from `indicators` / the crate root — unlike every other struct-output indicator (`ElderRayOutput`, `AlligatorOutput`, `QqeOutput`, …).

That left `wickra::GatorOscillatorOutput` / `wickra::KasePermissionStochasticOutput` un-nameable, so Rust callers could not annotate or store the `update` result by type. Surfaced by the wickra-docs Rust-snippet-compile check on the B3 deep-dives.

Re-export both alongside their structs. Both names end in `Output`, so the indicator counter strips them — catalog count stays **420**.
2026-06-04 18:43:26 +02:00
kingchenc 6e0464930e release: bump 0.5.6 -> 0.5.7 (#182)
Version bump 0.5.6 → 0.5.7 for the **B3 — Trend & Directional** batch (#181):
seven new indicators (`Qstick`, `TtmTrend`, `TrendStrengthIndex`,
`PolarizedFractalEfficiency`, `WavePm`, `GatorOscillator`,
`KasePermissionStochastic`), catalog 413 → 420.

Bumps: workspace `Cargo.toml` + `Cargo.lock`, `bindings/python/pyproject.toml`,
`bindings/node/package.json` + the six `npm/*/package.json` platform manifests,
both `package-lock.json` files, and the `CHANGELOG.md` `[Unreleased]` → `[0.5.7]`
roll with compare URLs.
2026-06-04 18:06:57 +02:00
kingchenc 13bc801f89 feat(indicators): B3 Trend & Directional batch (413 -> 420) (#181)
Adds the **B3 — Trend & Directional** batch: seven new indicators, taking the
catalog from 413 to 420 (Trend & Directional family).

| Indicator | Input → Output | Summary |
|-----------|----------------|---------|
| `Qstick` | candle → f64 | Chande's SMA of the candle body (close − open) |
| `TtmTrend` | candle → f64 (±1) | John Carter close-vs-median-SMA trend filter |
| `TrendStrengthIndex` | f64 → f64 | signed r² of an OLS regression of price vs time |
| `PolarizedFractalEfficiency` | f64 → f64 | Hannula directional trend efficiency |
| `WavePm` | f64 → f64 | Kase variance-normalised peak-momentum statistic (reconstruction) |
| `GatorOscillator` | candle → struct | Bill Williams Alligator convergence/divergence histogram |
| `KasePermissionStochastic` | candle → struct | double-smoothed stochastic permission filter |

Note: the roadmap's "Directional Indicator +DI/−DI" item is already covered by
the existing standalone `PlusDi` / `MinusDi` / `Dx`, so it is intentionally not
re-added.

All touchpoints wired: core (every-branch unit tests), Python/Node/WASM
bindings, fuzz drivers, Python test registries + reference tests, Node
factories, README/CHANGELOG counters.

Local verify: `cargo test -p wickra-core` (lib 3389 + doc 378), `cargo clippy
--workspace --all-targets --all-features -- -D warnings`, node build + 495
tests, maturin + 815 pytest, counter 420 == 420.
2026-06-04 17:57:24 +02:00
kingchenc ac8f6acf08 release: bump 0.5.5 -> 0.5.6 (#180)
Release bump `0.5.5 → 0.5.6` for the Momentum Oscillators family deepening
(#179): ten new indicators (DisparityIndex, FisherRsi, Rmi, DerivativeOscillator,
Rsx, DynamicMomentumIndex, IntradayMomentumIndex, StochasticCci, ElderRay, Qqe),
counter now 413.

Version strings only across all manifests + lockfiles; CHANGELOG `[Unreleased]`
rolled to `[0.5.6] - 2026-06-04` with the new compare links.
2026-06-04 15:35:41 +02:00
kingchenc 4f81222aed Deepen Momentum Oscillators family with ten additions (#179)
Deepens the **Momentum Oscillators** family with ten widely-used oscillators
(403 → 413 indicators), the second batch of Part B (family deepening).

| Indicator | Binding | Input → Output |
|-----------|---------|----------------|
| `DisparityIndex` | `DisparityIndex` | scalar → scalar |
| `FisherRsi` | `FisherRSI` | scalar → scalar |
| `Rmi` | `RMI` | scalar (period, momentum) → scalar |
| `DerivativeOscillator` | `DerivativeOscillator` | scalar (4 periods) → scalar |
| `Rsx` | `RSX` | scalar → scalar |
| `DynamicMomentumIndex` | `DynamicMomentumIndex` | scalar → scalar |
| `IntradayMomentumIndex` | `IMI` | candle (open+close) → scalar |
| `StochasticCci` | `StochasticCCI` | candle → scalar |
| `ElderRay` | `ElderRay` | candle → struct (bull/bear) |
| `Qqe` | `QQE` | scalar → struct (rsi_ma/trailing) |

LSMA was dropped from the planned set: it already ships as `LinearRegression`.

The single-period scalars use generated macro bindings; `Rmi` /
`DerivativeOscillator` use hand node/python bindings with the typed wasm macro;
`ElderRay`/`Qqe` use custom struct bindings; `IntradayMomentumIndex` uses custom
candle bindings carrying the open. Full coverage: core modules with per-branch
unit tests, mod/lib catalogue, FAMILIES + assert, README + docs counters,
CHANGELOG, all three bindings (regenerated `index.d.ts`/`index.js`), fuzz
drivers, and the python/node test registries.

Local verification: `cargo test -p wickra-core` (lib 3335 + doc 371),
`cargo clippy --workspace --all-targets --all-features -D warnings` clean,
node `npm run build && npm test` (488), python `pytest` (802).
2026-06-04 15:26:17 +02:00
kingchenc 0d2acad28d release: bump 0.5.4 -> 0.5.5 (#178)
Release bump `0.5.4 → 0.5.5` for the Moving Averages family deepening
(#177): seven new indicators (`SineWeightedMa`, `GeometricMa`, `Ehma`,
`MedianMa`, `AdaptiveLaguerreFilter`, `GeneralizedDema`, `HoltWinters`),
counter now 403.

Version strings only across all manifests + lockfiles; CHANGELOG `[Unreleased]`
rolled to `[0.5.5] - 2026-06-04` with the new compare links.
2026-06-04 13:55:26 +02:00
kingchenc b228a70d7d Deepen Moving Averages family with seven additions (#177)
Deepens the **Moving Averages** family with seven widely-used variants
(396 → 403 indicators), the first batch of Part B (family deepening).

All are scalar `f64 → f64`:

| Indicator | Binding | Notes |
|-----------|---------|-------|
| `SineWeightedMa` | `SWMA` | symmetric half-cycle sine-weighted window |
| `GeometricMa` | `GMA` | rolling geometric mean (log-space average) |
| `Ehma` | `EHMA` | exponential Hull MA (Hull construction over EMAs) |
| `MedianMa` | `MedianMA` | rolling median, robust to single outliers |
| `AdaptiveLaguerreFilter` | `AdaptiveLaguerre` | Ehlers' adaptive Laguerre filter (median-of-normalised-error γ) |
| `GeneralizedDema` | `GD` | Tillson's volume-factor double EMA; `v=1` is DEMA, `v=0` is EMA |
| `HoltWinters` | `HoltWinters` | Holt's linear double exponential smoothing (level + trend) |

LSMA was dropped from the planned set: it already ships as `LinearRegression`
(TA-Lib `LINEARREG`, the rolling least-squares endpoint).

The five single-period filters use the generated scalar macro bindings;
`GeneralizedDema` (period, v) and `HoltWinters` (alpha, beta) use hand-written
node/python bindings with the typed wasm macro (precedent `T3` / `Alma`).

Full coverage: core modules with per-branch unit tests (100% intent), mod/lib
catalogue, FAMILIES group + assert, README + docs counters, CHANGELOG, all three
bindings (regenerated `index.d.ts` / `index.js`), fuzz drivers, and the
python/node test registries.

Local verification: `cargo test -p wickra-core` (lib 3255 + doc 361),
`cargo clippy --workspace --all-targets --all-features -D warnings` clean,
node `npm run build && npm test` (478), python `pytest` (791).
2026-06-04 13:44:51 +02:00
kingchenc 8dc7158912 release: bump 0.5.3 -> 0.5.4 (#176)
Version bump 0.5.3 -> 0.5.4 for the release that ships the 19 external-feature-coverage indicators (#175, 377 -> 396).

Bumped: Cargo workspace + wickra-core dep, Cargo.lock (cargo build), pyproject.toml, node package.json (+6 optionalDependencies), 6 npm platform package.json, both package-lock.json, CHANGELOG ([Unreleased] -> [0.5.4]).

fmt/test/clippy green locally.
2026-06-04 12:14:29 +02:00
kingchenc fcb221ec03 feat: add 19 indicators for external feature-extractor coverage (377 -> 396) (#175)
Adds 19 streaming indicators so an external trading-bot feature extractor can replace its hand-built features with native, batch/streaming-equivalent ones. Each is a real gap (verified against the existing catalogue), production-only, with full Python/Node/WASM bindings, fuzz drivers, and tests. Five commits, one per family group; counter 377 -> 396.

## What's added

**Price Statistics (6)** — `LogReturn`, `RealizedVolatility` (raw quadratic variation, the un-annualised counterpart to `HistoricalVolatility`), `RollingQuantile`, `RollingIqr`, `RollingPercentileRank`, `SpreadAr1Coefficient` (pairwise AR(1) rho of the spread; complements `OuHalfLife`).

**Price Action (4)** — `CloseVsOpen`, `BodySizePct`, `WickRatio`, `HighLowRange` (stateless per-bar OHLC transforms).

**Regime / Trend / Jump labels (3)** — `TrendLabel` (sign of the rolling OLS slope), `JumpIndicator` (return outliers vs trailing volatility, measured as deviation from the trailing mean so steady drift is not flagged), `RegimeLabel` (volatility-quantile regime split).

**Risk / Performance (2)** — `WinRate`, `Expectancy` (R-multiple).

**Microstructure (4)** — `OrderFlowImbalance` (Cont-Kukanov-Stoikov OFI), `Vpin`, `AmihudIlliquidity`, `RollMeasure`. These reuse the existing `OrderBook` / `Trade` inputs (no new input type).

## Intentionally NOT added (already present, would be duplicates)

- **Population skew / kurtosis** — `skewness.rs` / `kurtosis.rs` are already population moments (divisor n).
- **Hurst R/S** — `hurst_exponent.rs` already uses rescaled-range (R/S) analysis.
- **Queue Imbalance** — exactly `OrderBookImbalanceTop1` ((bidSize - askSize) / (bidSize + askSize)).

## Verification

`cargo test -p wickra-core` (lib 3187 + doc 354), `cargo clippy --workspace --all-targets --all-features -D warnings` clean, node `npm run build && npm test` (471), python `pytest` (784). Counter consistent across `mod.rs`, lib block, README, and docs/README at 396.
2026-06-04 12:00:35 +02:00
kingchenc a93af60796 release: bump 0.5.2 -> 0.5.3 (#174)
Version bump publishing the **Fibonacci** family (10 tools across A5a + A5b, catalogue 377 indicators / twenty-four families):

`FibRetracement`, `FibExtension`, `FibProjection`, `AutoFib`, `GoldenPocket`, `FibConfluence`, `FibFan`, `FibArcs`, `FibChannel`, `FibTimeZones`.

Version strings + lockfiles only (Cargo.toml, pyproject.toml, package.json + 6 npm platform manifests, both package-lock.json, Cargo.lock); CHANGELOG `[0.5.3]` section + compare URLs.
2026-06-04 01:25:31 +02:00
kingchenc 5a1d607807 feat(indicators): A5b Fibonacci tools (geometric) (#172)
Completes the **Fibonacci** family with the four geometric/time tools (catalogue 373 -> 377). All extend the internal `pattern_swing` ZigZag tracker with a per-pivot bar index and a current-bar counter (additive — the chart/harmonic detectors are unaffected), and emit `Candle -> struct` outputs via custom Python/Node/WASM bindings.

| Tool | Output |
|------|--------|
| `FibFan` | three trendlines fanning from a swing start through its 38.2/50/61.8% retracement levels, extended to the current bar |
| `FibArcs` | semicircular retracement levels centred on the swing end, normalised by the leg's bar-width (chart-scale-free) |
| `FibChannel` | a sloped base trendline plus parallel lines at Fibonacci multiples of the channel width |
| `FibTimeZones` | markers at Fibonacci bar-distances (1/2/3/5/8/...) from the latest swing pivot |

The geometric tools are novel as streaming indicators; each normalises its geometry to the swing leg's bar-width so the output is chart-scale-free. Formulas are documented in each module and deep-dive.

Fully wired: core (100% unit-tested branches incl. the new `pattern_swing` bar tracking), Python/Node/WASM struct bindings, fuzz, reference + streaming-vs-batch tests.

Verification: `cargo test --workspace` green, clippy `-D warnings` clean, node 454 tests, python 768 tests.
2026-06-04 01:12:09 +02:00
kingchenc ea9da12d86 docs(governance): document continuity and succession plan (#173)
Add a Continuity and succession section to GOVERNANCE.md (trusted-contact emergency access to credentials enabling continuity within a week). Closes OpenSSF Silver access_continuity.
2026-06-04 01:09:52 +02:00
kingchenc 716eb40206 feat(indicators): A5a Fibonacci tools (price-level) (#171)
Adds the six price-level Fibonacci tools as a new **Fibonacci** family (catalogue 367 -> 373, twenty-four families). All build on the internal `pattern_swing` ZigZag tracker, are parameter-free (baked 5% swing threshold), and emit `Candle -> struct` outputs via custom Python/Node/WASM bindings.

| Tool | Output |
|------|--------|
| `FibRetracement` | seven levels (0/23.6/38.2/50/61.8/78.6/100%) of the last swing leg |
| `FibExtension` | five extension ratios (127.2/141.4/161.8/200/261.8%) projected beyond the leg |
| `FibProjection` | A-B-C measured-move target zone (61.8/100/161.8/261.8%) |
| `AutoFib` | retracement anchored on the dominant (largest-magnitude) recent leg |
| `GoldenPocket` | the 0.618-0.65 optimal-trade-entry band (low/mid/high) |
| `FibConfluence` | densest cluster of retracement levels across recent legs (price + strength) |

Fully wired: core (100% unit-tested branches), Python/Node/WASM struct bindings, fuzz driver, reference + streaming-vs-batch tests, README/docs counter. The four geometric/time tools (Fan, Arcs, Channel, Time Zones) follow in A5b.

Verification: `cargo test --workspace` green, clippy `-D warnings` clean, node 450 tests, python 760 tests.
2026-06-04 00:47:00 +02:00
kingchenc 8115d3b33d release: bump 0.5.1 -> 0.5.2 (#170)
Version bump to release the A4 Chart Patterns (#166) and Harmonic Patterns (#169) families (catalogue 351 -> 367).
2026-06-03 23:39:10 +02:00
kingchenc 4250ed99f4 feat(patterns): add the Harmonic Patterns family (8 XABCD detectors) (#169)
## Summary

Adds a new **Harmonic Patterns** indicator family (counter 359 → 367, families 22 → 23) — the second half of the A4 roadmap item, following the Chart Patterns family in #166.

Eight Fibonacci-ratio detectors built on the shared swing-pivot tracker (`indicators::pattern_swing`) plus two new helpers there — `xabcd` (reads the last five pivots as X-A-B-C-D) and `ratios_in` (checks a list of `(value, low, high)` Fibonacci windows in one expression, no multi-line `&&` coverage gaps). Each consumes candles and emits the uniform pattern sign convention — `+1.0` bullish (terminal point D a swing low), `-1.0` bearish (D a swing high), `0.0` otherwise, never `None`. Parameter-free, with the Fibonacci windows documented as constants per detector.

## Detectors

| Indicator | Defining ratio |
|-----------|----------------|
| `Abcd` | four-point AB=CD (BC retraces AB, CD ≈ AB) |
| `Gartley` | AD/XA ≈ 0.786 |
| `Butterfly` | AD/XA ∈ 1.27–1.618 (extended D) |
| `Bat` | AD/XA ≈ 0.886, shallow B |
| `Crab` | AD/XA ≈ 1.618 (deepest D) |
| `Shark` | expansion AB, AD/XA 0.886–1.13 |
| `Cypher` | BC on XA, CD/XC ≈ 0.786 |
| `ThreeDrives` | two symmetric extension drives |

## Touchpoints

Core modules + `FAMILIES` group/assert, crate root re-exports, Python/Node/WASM bindings via the candle-pattern macros (Node `index.d.ts`/`index.js` regenerated), the candle fuzz target (`// --- Harmonic Patterns ---` section), Python reference + `CANDLE_SCALAR` registry tests and the Node candle-scalar factory, README catalogue counter + banner cache-buster + family table row + family-count word, `docs/README.md` counter, and the changelog.

## Verification

- `cargo test -p wickra-core --lib` — 2966 passed
- `cargo test -p wickra-core --doc` — 335 passed
- `cargo clippy --workspace --all-targets --all-features -- -D warnings` — clean
- Node `npm run build && npm test` — 444 passed
- Python `maturin develop --release` + `pytest` — 748 passed

Every detector branch is unit-tested, including a bullish and a bearish match per pattern to cover both output arms, plus an out-of-ratio non-match. Fibonacci windows use standard harmonic-trading ranges with documented tolerance bands.
2026-06-03 23:24:25 +02:00
kingchenc 995f119010 feat(patterns): add the Chart Patterns family (8 swing-based detectors) (#166)
## Summary

Adds a new **Chart Patterns** indicator family (counter 351 → 359, families 21 → 22), the first half of the A4 roadmap item (the harmonic patterns follow in a second PR).

All eight detectors are built on a shared, non-repainting swing-pivot tracker — the internal, **uncounted** `indicators::pattern_swing` module (declared `pub(crate) mod`, re-exported nowhere). Each consumes candles and emits the uniform pattern sign convention already used by the candlestick family — `+1.0` bullish / `-1.0` bearish / `0.0` otherwise, never `None`. They are parameter-free, baking the swing threshold (5%) and level tolerance (3%) in as documented constants, mirroring how candlestick patterns bake in their geometric thresholds.

## Detectors

| Indicator | Signal |
|-----------|--------|
| `DoubleTopBottom` | twin-peak / twin-trough reversal |
| `TripleTopBottom` | three matching extremes (stronger reversal) |
| `HeadAndShoulders` | central head + matching shoulders + flat neckline (and inverse) |
| `Triangle` | ascending (+1) / descending (-1) / symmetrical |
| `Wedge` | rising wedge (-1) / falling wedge (+1) |
| `FlagPennant` | shallow consolidation against a pole → continuation |
| `RectangleRange` | flat support/resistance mean-reversion |
| `CupAndHandle` | rounded base + shallow handle (and inverse) |

## Touchpoints

Core modules + `FAMILIES` group and assert, crate root re-exports, Python/Node/WASM bindings via the candle-pattern macros (Node `index.d.ts`/`index.js` regenerated), the candle fuzz target, Python reference + `CANDLE_SCALAR` registry tests and the Node candle-scalar factory, README catalogue counter + banner cache-buster + family table row + family-count word, `docs/README.md` counter, and the changelog.

## Verification

- `cargo test -p wickra-core --lib` — 2915 passed
- `cargo test -p wickra-core --doc` — 335 passed
- `cargo clippy --workspace --all-targets --all-features -- -D warnings` — clean
- Node `npm run build && npm test` — 436 passed
- Python `maturin develop --release` + `pytest` — 732 passed

Every detector branch is unit-tested; multi-condition predicates were flattened to single-line precomputed booleans to keep patch coverage at 100%.
2026-06-03 22:55:36 +02:00
kingchenc 05d2e5dc61 ci(scorecard): pass a read-only PAT for the Branch-Protection check (#168)
Pass a read-only fine-grained PAT (SCORECARD_TOKEN) as repo_token so the OpenSSF Scorecard Branch-Protection check can read classic branch-protection rules instead of failing with an internal error.
2026-06-03 22:51:28 +02:00
kingchenc 404bcb040c docs: add threat model and security policies (#167)
Add THREAT_MODEL.md and SECURITY.md sections: secrets management, release verification, end-of-support, dependency/code-scanning remediation policy, and a VEX statement. Closes OSPS Baseline L3 documentation gaps (SA-03.02, BR-07.02, DO-03.01/03.02/05.01, VM-04.02/05.01/05.02/06.01). Additive only.
2026-06-03 22:40:56 +02:00
kingchenc 00ce899cc3 docs: add public ROADMAP (#165)
Add a public ROADMAP.md describing project direction and pointing to the issue tracker as the authoritative view. Closes the OpenSSF Silver documentation_roadmap gap.
2026-06-03 22:19:24 +02:00
kingchenc b6ead740e8 docs: add governance, support, DCO and security assurance case (#164)
Add GOVERNANCE.md, MAINTAINERS.md, SUPPORT.md, DCO; add a DCO sign-off requirement to CONTRIBUTING.md and a security assurance case to SECURITY.md. Closes OpenSSF Silver / OSPS Baseline documentation gaps. Additive only.
2026-06-03 22:16:03 +02:00
kingchenc 755f4aa0f6 docs: add OpenSSF Best Practices badge to README (#163)
Adds the OpenSSF Best Practices passing badge next to the OpenSSF Scorecard badge in the README header.

The project earned a passing badge: https://www.bestpractices.dev/projects/13094
2026-06-03 21:44:34 +02:00
kingchenc 4d602df8a3 release: bump 0.5.0 -> 0.5.1 (#162)
Version bump **0.5.0 → 0.5.1** for the Seasonality & Session family release (12 indicators, PR #161).

Bumped: `Cargo.toml` (workspace version + `wickra-core` dep), `Cargo.lock` (via `cargo build`), `bindings/python/pyproject.toml`, `bindings/node/package.json` (+ 6 `optionalDependencies`), the 6 `bindings/node/npm/<platform>/package.json`, both `package-lock.json` files, and `CHANGELOG.md` (`[Unreleased]` → `[0.5.1]` + compare URLs).

No code changes — version strings only.
2026-06-03 20:55:13 +02:00
kingchenc 3ab2d6ec2d feat(seasonality): add the Seasonality & Session family (12 indicators) (#161)
## Summary

Adds the **Seasonality & Session** family — the first family that reads the wall-clock fields of `Candle::timestamp`. A new private `calendar` module decomposes an epoch-millisecond instant (shifted by a per-indicator `utc_offset_minutes`) into civil fields via Howard Hinnant's branch-light `civil_from_days` algorithm. Session / day / month rollovers are detected automatically, so callers never have to invoke `reset()` at a boundary.

Indicator counter **339 → 351**; family count **20 → 21**.

## Indicators

| Shape | Indicators |
|-------|-----------|
| Scalar (`f64`) | `SessionVwap`, `AverageDailyRange`, `OvernightGap`, `TurnOfMonth`, `SeasonalZScore` |
| Struct | `SessionHighLow`, `SessionRange` (Asia/EU/US), `OvernightIntradayReturn` |
| Profile (`Vec<f64>`) | `TimeOfDayReturnProfile`, `DayOfWeekProfile`, `IntradayVolatilityProfile`, `VolumeByTimeProfile` |

## Bindings

The input is the **full** candle (`open, high, low, close, volume, timestamp`), not the `high/low/close` slice the value-indicator helper assumes, so the Python / Node / WASM bindings are custom full-candle implementations:

- **Python** — `update((o,h,l,c,v,ts))`; `batch(open, high, low, close, volume, timestamp)` → `PyArray1` (scalar) / `PyArray2` (struct & profile), warmup rows `NaN`.
- **Node** — `update(open, high, low, close, volume, timestamp)`; `batch(...)` → flat `Vec<f64>`; struct outputs as `#[napi(object)]` values.
- **WASM** — `update` only (multi-input precedent); profiles as `Float64Array`, structs as camelCase objects, `timestamp` as `BigInt`.

## Verification

- `wickra-core`: full per-branch unit tests, **100%** coverage target; 2852 lib tests + 334 doctests green.
- `cargo clippy --workspace --all-targets --all-features -- -D warnings`: clean.
- Node: 428 tests (dedicated `seasonality.test.js` streaming-vs-batch).
- Python: full suite + dedicated `test_seasonality.py` streaming-vs-batch.
- Counter check: mod-count == counted lib block == 351.
2026-06-03 20:31:32 +02:00
kingchenc 5e96d41916 chore: add REUSE-style LICENSES directory for license auto-detection (#160)
Adds a `LICENSES/` directory with SPDX-named copies of the existing license texts (`MIT.txt`, `Apache-2.0.txt`) per the [REUSE Specification](https://reuse.software/spec/).

## Why
Automated license scanners (the OpenSSF Best Practices BadgeApp, GitHub's license API, REUSE tooling) look for a top-level `LICENSE`/`COPYING` file or a `LICENSES/` directory with SPDX-named files. Our files are named `LICENSE-MIT` / `LICENSE-APACHE` (Rust convention), which these scanners do not recognize — so the BadgeApp's `license_location` check keeps auto-flipping to "Unmet".

## What
- New `LICENSES/MIT.txt` — byte-identical copy of `LICENSE-MIT`
- New `LICENSES/Apache-2.0.txt` — byte-identical copy of `LICENSE-APACHE`
- Existing `LICENSE-MIT` and `LICENSE-APACHE` are **unchanged**

The project remains dual-licensed under **MIT OR Apache-2.0**. This change is additive only.
2026-06-03 20:00:40 +02:00
kingchenc 099ae66b57 release: bump 0.4.7 -> 0.5.0 (#159)
Version bump for the 0.5.0 release, which ships the relicense to MIT OR Apache-2.0.

Stacked on #158 (base branch `chore/relicense-mit-apache`) so this PR's diff is the bump only. After #158 merges to main, GitHub retargets this PR to main; merge it, then tag `v0.5.0` to publish.

## Changes
- Bump 0.4.7 -> 0.5.0 across the Cargo workspace, Python `pyproject.toml`, Node `package.json` + 6 platform manifests + 2 lockfiles, and `Cargo.lock`.
- CHANGELOG: cut the [0.5.0] section (the relicense) and add compare URLs.
- SECURITY.md: supported versions 0.4.x -> 0.5.x.

Minor (not patch) bump: a relicense is a significant change. No code changes.

NOTE: do not tag/release until you give the go (irreversible publish to crates.io/PyPI/npm). Suggested merge order: #158 -> this -> tag `v0.5.0` -> then the downstream PRs.
2026-06-03 18:53:23 +02:00
kingchenc 11dd659b5f Relicense from PolyForm Noncommercial to MIT OR Apache-2.0 (#158)
Relicenses Wickra from PolyForm Noncommercial 1.0.0 to the dual, OSI-approved **MIT OR Apache-2.0** (the de-facto Rust convention). Wickra becomes permissive, commercial-use-permitted open source; users may choose either license.

## Changes
- Replace `LICENSE` (PolyForm) with `LICENSE-MIT` + `LICENSE-APACHE` (full texts).
- Cargo: workspace `license = "MIT OR Apache-2.0"` (SPDX) + all 7 sub-crates switched from `license-file.workspace` to `license.workspace`.
- `deny.toml`: drop PolyForm from the allowlist.
- Python: `pyproject.toml` PEP 639 SPDX expression; remove the non-commercial classifier (verified: sdist metadata emits `License-Expression: MIT OR Apache-2.0`).
- Node: `package.json`, the 6 platform manifests and both lockfiles.
- README + Python/Node/WASM binding READMEs, CONTRIBUTING, CITATION.cff, PR template, and the WASM `pkg.license` step in `release.yml`.
- SECURITY.md: refresh supported versions 0.1.x -> 0.4.x.
- CHANGELOG: note the relicense under [Unreleased].

## Notes
- No code changes; metadata/text only. `cargo build` and `cargo deny check licenses` pass locally.
- GitHub will auto-detect "MIT, Apache-2.0" once this lands (currently NOASSERTION).
- Matching downstream changes (org `.github` profile, webpage, docs) are in separate PRs; merge those together with the relicense release so the live sites and org profile do not claim MIT before the packages do.
2026-06-03 18:49:39 +02:00
kingchenc c096943bdf feat(breadth): complete the Market Breadth family (14 indicators) (#157)
Completes expansion-roadmap block **A2 — Market Breadth**: the 14 indicators that remained after the `AdvanceDecline` bootstrap, all built on the existing `CrossSection` input.

## Indicators (all scalar `Indicator<Input = CrossSection, Output = f64>`)

| Indicator | Reading |
|-----------|---------|
| `AdvanceDeclineRatio` | advancers / decliners |
| `AdVolumeLine` | cumulative net advancing volume |
| `McClellanOscillator` | 19/39 EMAs of ratio-adjusted net advances |
| `McClellanSummationIndex` | running total of the oscillator |
| `Trin` (Arms Index) | A/D ratio over up/down volume ratio |
| `BreadthThrust` (Zweig) | SMA of the advancing-issues share |
| `NewHighsNewLows` | new highs − new lows |
| `HighLowIndex` | SMA of the record-high percent |
| `PercentAboveMa` | % of the universe above its MA |
| `UpDownVolumeRatio` | advancing / declining volume |
| `BullishPercentIndex` | % on a point-and-figure buy signal |
| `CumulativeVolumeIndex` | volume-normalised cumulative net advancing volume |
| `AbsoluteBreadthIndex` | \|advancers − decliners\| |
| `TickIndex` | instantaneous net advancers − decliners |

## Input model

`AdVolumeLine` and `CumulativeVolumeIndex` are kept distinct (the latter normalises each tick's net advancing volume by total volume, so it stays comparable across volume regimes). `PercentAboveMa` and `BullishPercentIndex` need a per-symbol state signal that `Member` did not carry, so `Member` gains two additive flags (`above_ma`, `on_buy_signal`) via a new `Member::with_signals` constructor; the 4-arg `Member::new` leaves both cleared, so every existing caller and binding is unchanged. `CrossSection` gains volume / new-extreme / state aggregation helpers.

## Wiring

Fully wired across the Rust core, the python/node/wasm bindings, the cross-section fuzz target, the README + docs indicator counters (325 → 339), and dedicated python/node streaming-vs-batch tests. `fmt` / `test --workspace --all-features` / `clippy --workspace -D warnings` / node build+test / pytest all green locally.
2026-06-03 17:24:33 +02:00
kingchenc c44f625e69 release: bump 0.4.6 -> 0.4.7 (#156)
Routine patch release. Ships the 10 pairwise stat-arb indicators added to Price Statistics in #154 (Rolling Correlation, Rolling Covariance, OU Half-Life, Kalman Hedge Ratio, Variance Ratio, Spread Bollinger Bands, Spread Hurst, Distance SSD, Granger Causality, Beta-Neutral Spread) together with the new Market Breadth family and its `CrossSection` input type (AdvanceDecline).

Version strings bumped `0.4.6 -> 0.4.7` across:
- `Cargo.toml` (workspace + `wickra-core` dep), `Cargo.lock`
- `bindings/python/pyproject.toml`
- `bindings/node/package.json` (+ 6 optional platform deps) and the 6 `npm/<platform>/package.json`
- `bindings/node/package-lock.json`, `examples/node/package-lock.json`
- `CHANGELOG.md` — `[Unreleased]` rolled into `[0.4.7] - 2026-06-03` with refreshed compare links

No code changes. `fmt` / `test --workspace` / `clippy --workspace -D warnings` green locally.
2026-06-03 16:02:30 +02:00
kingchenc 46dc8f5a00 ci(sync-about): read-only PR counter check, no bot fix-up push (#155)
## Problem
On every indicator PR the `sync-about` workflow found `docs/README.md` lagging `lib.rs` (the wiring only bumped `README.md`) and pushed a `wickra-bot` *"sync indicator count"* commit onto the PR head. That push uses `GITHUB_TOKEN`, which **triggers no workflows**, so it moved the PR head onto a commit with no CI run — and the **Codecov patch status** (keyed to the PR head sha) stopped surfacing on the PR.

## Fix
- The indicator wiring (`ScriptHelpers/_common.py` `wire_readme_counter`) now bumps **both** `README.md` and `docs/README.md` in the author's code commit, so the counter is already correct when CI runs.
- This workflow's PR flow is reduced to a **read-only check** that fails loud (fork and same-repo PRs alike) if either counter is stale, and **never pushes**.
- The `GITHUB_TOKEN` job permission drops from `contents: write` back to `read` (OpenSSF Scorecard: Token-Permissions). The removed `ctx` step + push steps are gone.
- The `main`/tag outward syncs (About description, docs/webpage/wiki/org) are **unchanged** — they use the `ABOUT_SYNC_TOKEN` PAT, not `GITHUB_TOKEN`.

## Effect
Indicator PRs keep their head on the code commit → the Codecov patch status surfaces again. No functional change to merged-main state (the counts still land, now inside the squash-merged code commit).
2026-06-03 15:40:24 +02:00
kingchenc a3a1ae4dba Add 10 pairwise stat-arb indicators to Price Statistics (#154)
Adds ten pairwise `(f64, f64)` indicators to the **Price Statistics** family, completing the A1 stat-arb expansion block.

## Indicators

**Scalar output:**
- **RollingCorrelation** — rolling Pearson correlation of period-over-period *returns* (distinct from level-based `PearsonCorrelation`).
- **RollingCovariance** — rolling covariance of returns.
- **OuHalfLife** — Ornstein–Uhlenbeck half-life of mean reversion of the spread `a − b`.
- **SpreadHurst** — Hurst exponent of the spread (variance-of-lagged-differences fit) for regime detection.
- **DistanceSsd** — Gatev sum-of-squared-deviations between two start-normalised series.
- **BetaNeutralSpread** — rolling OLS regression residual `a − (α + β·b)`.
- **VarianceRatio** — Lo–MacKinlay variance-ratio test on the spread (two params: `period`, `q`).
- **GrangerCausality** — F-statistic for whether `b` predicts `a` (two params: `period`, `lag`).

**Struct output (custom bindings):**
- **KalmanHedgeRatio** — dynamic hedge ratio via a Kalman filter → `{ hedgeRatio, intercept, spread }`.
- **SpreadBollingerBands** — Bollinger bands on the spread → `{ middle, upper, lower, percentB }`.

## Notes
- No new traits or input families: all use the native `Indicator<Input = (f64, f64)>` (precedent `Beta`, `Cointegration`).
- Adds `Error::InvalidParameter` for floating-point constructor parameters (Kalman `delta`/`observation_var`, `num_std`).
- Full Python/Node/WASM bindings; the two struct-output indicators are hand-written, the rest use the pair macros.
- Indicator count 315 → 325; README, family rows, `__init__`, fuzz target, and CHANGELOG updated.

## Verification
- `cargo test --workspace --all-features` — green (2676 core lib + 308 doc).
- `cargo clippy --workspace --all-targets --all-features -- -D warnings` — clean.
- Node: `npm run build && npm test` — 410 passing (`index.d.ts`/`index.js` regenerated).
- Python: `pytest` — 684 passing.
2026-06-03 15:39:55 +02:00
kingchenc 53941b7b07 feat: add Market Breadth family with CrossSection input (#153)
## What

Adds a new indicator input type and family for **market-breadth** analysis — indicators that aggregate the state of an entire universe of symbols at each tick, rather than a single instrument's price. This is the last open input-type on the expansion roadmap (S10) and unblocks the remaining breadth indicators (McClellan, TRIN, High-Low Index, ...).

## Core

- **`CrossSection` input type** (`crates/wickra-core/src/cross_section.rs`) — one tick carrying the per-symbol state of the whole universe as a `Vec<Member>` + `timestamp`. Each `Member` precomputes a signed `change` (sign classifies advancing / declining / unchanged), a `volume`, and `new_high` / `new_low` extreme flags, so the breadth indicators stay stateless per tick. Both `Member` and `CrossSection` are `#[non_exhaustive]` for additive field growth. `CrossSection::new` validates the universe (non-empty, finite changes, finite non-negative volumes); `new_unchecked` skips validation for hot paths. `advancers()` / `decliners()` count by sign.
- **`Error::InvalidCrossSection`** variant for the validation failures.
- **`AdvanceDecline`** (`advance_decline.rs`) — the Advance/Decline Line: the running cumulative sum of net advancing-minus-declining issues. `Input = CrossSection`, `Output = f64`, ready after the first tick.
- New **"Market Breadth"** `FAMILIES` group; indicator count **314 → 315**, family count nineteen → twenty.

## Bindings

All custom (CrossSection is non-scalar, so no macros apply). The universe crosses each boundary as parallel arrays (`change`, `volume`, `new_high`, `new_low`):
- **Python / Node** expose `update` + `batch` (one array group per tick). Node satisfies the completeness contract (`update`/`batch`/`reset`/`isReady`/`warmupPeriod`).
- **WASM** exposes only `update` (the universe is ragged across ticks, matching the other multi-input wasm indicators) with numeric high/low flags.
- Python `map_err` gains the new error arm; `__init__.py` gets a `# Market Breadth` section in both the import and `__all__` blocks. `index.d.ts` / `index.js` regenerated.

## Tests / Fuzz

- Dedicated **streaming-vs-batch + reference-value + ragged-rejection** tests in Python (`test_new_indicators.py`) and Node (`indicators.test.js`) — kept out of the scalar/candle parametrize lists.
- Rust unit tests cover every reject branch (empty / non-finite change / negative & non-finite volume) and every indicator branch.
- New fuzz target `indicator_update_crosssection` drives `AdvanceDecline` over bounded ragged universes built with `new_unchecked`.

## Verify

- `cargo fmt --all` clean
- `cargo test -p wickra-core --lib` → 2593 passed; `--doc` → 298 passed
- `cargo clippy --workspace --all-targets --all-features -- -D warnings` clean
- `cd bindings/node && npm run build && npm test` → 398 passed
- `maturin develop --release` + `pytest bindings/python/tests` → all passed
- counter check: mod-count 315 == lib-block 315
2026-06-03 04:11:10 +02:00
kingchenc 72ec65bbde fix: classify pairwise indicators into Price Statistics family (#152)
## What

The `FAMILIES` table in `crates/wickra-core/src/indicators/mod.rs` had drifted from the indicator count: `mod`-count was **314** but the FAMILIES total asserted **309**.

The five pairwise indicators `Cointegration`, `LeadLagCrossCorrelation`, `PairSpreadZScore`, `PairwiseBeta` and `RelativeStrengthAB` were exported via `pub use` but never assigned to a `FAMILIES` group — even though the README and docs already list them under **Price Statistics**. The "−5 offset" was therefore unclassified drift, not an intentional cross-asset offset.

## Change

- Add the five indicators to the `Price Statistics` group, next to the existing pairwise cluster (`PearsonCorrelation` / `Beta` / `SpearmanCorrelation`).
- Bump the drift assert `309 → 314` so the FAMILIES total now equals the `mod`-count exactly (offset 0).

No new indicators, no binding or doc changes — purely re-classification. The `mod`-count stays 314, so no counter bump.

## Verify

- `cargo fmt --all` clean
- `cargo test -p wickra-core --lib` → 2578 passed (incl. the FAMILIES drift test)
- `cargo clippy --workspace --all-targets --all-features -- -D warnings` clean
2026-06-03 03:38:14 +02:00
kingchenc 82d1a4fe77 release: bump 0.4.5 -> 0.4.6 (#151)
Routine patch release. Ships the **19 TA-Lib parity indicators** (DM components, price transforms, ROC ratio forms, LinReg intercept / TSF, MACDFIX / MACDEXT / SAREXT, Hilbert phasor / DC-phase / trend-mode) added in #148, with the cold-path coverage fix from #150 — indicator count **314**, repo back at 100%.

Version strings bumped `0.4.5 -> 0.4.6` across:
- `Cargo.toml` (workspace + `wickra-core` dep), `Cargo.lock`
- `bindings/python/pyproject.toml`
- `bindings/node/package.json` (+ 6 optional platform deps) and the 6 `npm/<platform>/package.json`
- `bindings/node/package-lock.json`, `examples/node/package-lock.json`
- `CHANGELOG.md` — `[Unreleased]` rolled into `[0.4.6] - 2026-06-03` with refreshed compare links

No code changes. `fmt` / `clippy --workspace -D warnings` green locally.
2026-06-03 02:56:00 +02:00
kingchenc f71b3b6b49 test: cover the cold paths in the TA-Lib parity batch (100% patch) (#150)
PR #148 merged at **99.67%** patch coverage — `codecov/patch` flagged seven by-construction-rare lines in three of the new indicators that no test exercised. This brings the batch back to 100%.

**`ht_dcphase` / `ht_trendmode`** (6 lines) — the dominant-cycle phase recovery guards against a near-zero imaginary part (where `atan(real/imag)` is undefined) by collapsing to ±90° on the sign of the real part. That branch is unreachable with realistic price data. Extracted the phase-unwrap arithmetic into a private `compute_dc_phase(real, imag, smooth_period)` helper — a pure refactor with byte-identical output — and unit-tested it directly with crafted `(real, imag)` pairs, covering both the ±90 collapse and the normal `atan` path.

**`sar_ext`** (1 line) — `Accel::validate`'s non-finite guard was only ever hit for non-positive terms, never non-finite ones, despite the test comment claiming both. Added `NaN` / `infinity` cases on the long and short acceleration schedules.

No behaviour or public-API change. Locally: `cargo test -p wickra-core` (2578 + 297 doctests) and `clippy --workspace -D warnings` all green.
2026-06-03 02:48:11 +02:00
kingchenc d081cb9581 docs: list the 19 TA-Lib parity indicators in the README family rows (#149)
The TA-Lib parity batch (#148) bumped the indicator counter to 314, but `sync-about` only syncs the *number* — the family-table prose in the README still listed the pre-batch set. This fills the 19 new names into their existing family rows so the catalogue matches the count.

- **Momentum Oscillators**: ROC Percentage (ROCP), ROC Ratio (ROCR), ROC Ratio 100 (ROCR100)
- **Trend & Directional**: MACD Fixed (MACDFIX), MACD Extended (MACDEXT), Plus DM, Minus DM, Plus DI, Minus DI, DX
- **Trailing Stops**: Parabolic SAR Extended (SAREXT)
- **Price Statistics**: Mid Price, Mid Point, Average Price, Linear Regression Intercept, Time Series Forecast
- **Ehlers / Cycle (DSP)**: Hilbert Phasor, Hilbert DC Phase, Hilbert Trend Mode

No new family — the "nineteen families" wording and the 314 counter are untouched. Docs-only, no code changes.
2026-06-03 02:36:09 +02:00
kingchenc 9eb46f144a feat: TA-Lib parity — 19 standalone indicators (DM components, price transforms, ROC/LinReg/MACD/SAR variants, Hilbert outputs) (#148)
Closes the remaining TA-Lib function-name gap by shipping each missing or
bundled-only function as a real, standalone, fully-covered indicator. 19 new
indicators across 5 families; mod-count 295 -> 314.

### Trend & Directional — Directional Movement components
- `PlusDm` (`PLUS_DM`), `MinusDm` (`MINUS_DM`) — Wilder-smoothed ±DM.
- `PlusDi` (`PLUS_DI`), `MinusDi` (`MINUS_DI`) — `100·smoothed(±DM)/ATR`.
- `Dx` (`DX`) — `100·|+DI−−DI|/(+DI+−DI)`.

### Price Statistics
- `AvgPrice` (`AVGPRICE`) — `(O+H+L+C)/4`.
- `MidPoint` (`MIDPOINT`) — `(max+min)/2` of a scalar series over N.
- `MidPrice` (`MIDPRICE`) — `(highestHigh+lowestLow)/2` over N.
- `LinRegIntercept` (`LINEARREG_INTERCEPT`) — OLS intercept.
- `Tsf` (`TSF`) — time series forecast `a + b·period`.

### Momentum Oscillators
- `Rocp` (`ROCP`), `Rocr` (`ROCR`), `Rocr100` (`ROCR100`) — ROC ratio forms.

### Trailing Stops
- `SarExt` (`SAREXT`) — Parabolic SAR with start value, reversal offset,
  separate long/short acceleration, signed output.

### Trend & Directional — MACD variants
- `MacdFix` (`MACDFIX`) — MACD fixed 12/26.
- `MacdExt` (`MACDEXT`) — MACD with a selectable moving-average type per line
  (new public `MaType` enum: SMA/EMA/WMA/DEMA/TEMA/TRIMA).

### Ehlers / Cycle (DSP) — Hilbert transform outputs
- `HtPhasor` (`HT_PHASOR`) — in-phase / quadrature components.
- `HtDcPhase` (`HT_DCPHASE`) — dominant-cycle phase (degrees).
- `HtTrendMode` (`HT_TRENDMODE`) — trend (1) vs cycle (0) classification.

Each indicator ships the full chain: core + every-branch unit tests, Python /
Node / WASM bindings, fuzz coverage, README counter + family rows, CHANGELOG.
`cargo test`, doctests, `clippy -D warnings`, `npm test` and pytest all green
locally; mod-count == lib-block == README counter (314), FAMILIES total 309.
2026-06-03 02:26:38 +02:00
kingchenc 9a98e9bf55 release: bump 0.4.4 -> 0.4.5 (#147)
Version bump for 0.4.5: ships Anchored RSI, Volume Profile, TPO Profile and the Alt-Chart Bars family (Renko/Kagi/Point & Figure). Indicator count 289 -> 295.
2026-06-02 22:14:47 +02:00
kingchenc d4b3f9dbd1 feat: add Alt-Chart Bars (Renko, Kagi, Point & Figure) via a BarBuilder trait (#146)
Introduces a BarBuilder trait for price-driven chart constructors that emit a variable number of bars per candle (deliberately not Indicator). Adds Renko (box-size bricks, 2-box reversal), Kagi (reversal-amount segments) and Point & Figure (box-size X/O columns, N-box reversal) in a new Alt-Chart Bars family, with custom Python/Node/WASM bindings, a dedicated fuzz target, tests and docs. Indicator count 292 -> 295.
2026-06-02 21:56:00 +02:00
kingchenc f37eedd44e feat: add Volume Profile and TPO Profile to the market profile family (#145)
Volume Profile exposes the full per-bin volume histogram (price bounds plus raw distribution) that Value Area reduces to POC/VAH/VAL. TPO Profile is the volume-agnostic Time-Price-Opportunity letter count over a rolling window. Both candle-input, Vec-output, Market Profile family, with custom Python/Node/WASM bindings, fuzz, benches, tests and docs. Indicator count 290 -> 292.
2026-06-02 21:16:30 +02:00
kingchenc 93097db482 feat: add Anchored RSI to the momentum oscillators family (#144)
Cumulative Relative Strength Index whose averaging begins at a runtime-chosen anchor bar (set_anchor), the momentum counterpart to Anchored VWAP. Scalar f64 input, 0..=100 output; wired through core, Python, Node and WASM bindings, fuzz, benches, tests and docs. Indicator count 289 -> 290.
2026-06-02 20:50:56 +02:00
kingchenc 2f3a0b9149 release: bump 0.4.3 -> 0.4.4 (#143)
Release 0.4.4.

Version bump only — no code changes. Ships the 40 TA-Lib candlestick patterns
(parts 2–9, #132–#141, 249 → 289 indicators) plus the candlestick rejection-
guard coverage tests (#142) that landed on `main` since 0.4.3.

Bumped: workspace `Cargo.toml` (+ `wickra-core` dep) and `Cargo.lock`,
`bindings/python/pyproject.toml`, `bindings/node/package.json` (+ 6 platform
`optionalDependencies`), the 6 `bindings/node/npm/*/package.json`, both
`package-lock.json` files, and `CHANGELOG.md` ([Unreleased] → [0.4.4]).
2026-06-02 18:10:24 +02:00
kingchenc 49c0fd7dd5 ci: Dependabot cooldown + accept residual zizmor notes (#136)
Clears the remaining zizmor code-scanning findings on this repo.

**Fixed**
- `dependabot-cooldown` (5): a 7-day cooldown on every update ecosystem
  (cargo, npm, pip, ci-pip, github-actions) so Dependabot waits a week after a
  release before opening the bump PR.

**Accepted via `.github/zizmor.yml`** (no workflow code changed)
- `template-injection` (sync-about.yml): false positive — every expansion is the
  internal `grep -c` indicator count, not attacker-controllable.
- `use-trusted-publishing` (release.yml): OIDC migration tracked separately.
- `superfluous-actions` (release.yml): `softprops/action-gh-release` kept deliberately.

Verified with zizmor 1.25.2: 0 findings.
2026-06-02 17:59:46 +02:00
kingchenc 3d98592461 test: cover candlestick pattern rejection guards (#142)
Closes the coverage gaps in the candlestick-pattern family that landed across
PRs #132–#141. Codecov flagged 25 uncovered lines on `main` (99.93%) — all in
the new candlestick files, and all early-return rejection guards or unused
`Default` impls that the accept-path unit tests never exercised.

This PR adds focused white-box unit tests (one or two per affected file) that
drive each rejection branch through the public `update()` API:

- **Zero-range guards** — a flat bar (`high == low`) at the relevant window
  position: `DojiStar`, `InNeck`, `OnNeck`, `Thrusting`, `SeparatingLines`,
  `EveningDojiStar`, `MorningDojiStar`, `GapSideBySideWhite`,
  `FallingThreeMethods`, `RisingThreeMethods`, `MatHold`.
- **Too-short trigger body** — a wide-range bar with a tiny body that fails the
  "long body" check: the three-bar stars, the three-methods pair, `MatHold`,
  `SeparatingLines`.
- **Shape-specific guards** — `GapSideBySideWhite` body-size mismatch, `MatHold`
  bar-2 fails to gap up.
- **`Default` impls** — `LongLine` / `ShortLine` (`default()` was never called).

No production code changes; tests only. `cargo test -p wickra-core` and
`cargo clippy -p wickra-core --all-targets -- -D warnings` pass locally.
2026-06-02 17:52:04 +02:00
kingchenc 124efb4432 feat: TA-Lib candlestick patterns — tasuki-gap/unique-three-river/marubozu-pair/concealing-baby-swallow (part 9 of 9) (#141)
The final batch of the TA-Lib candlestick roadmap. Adds five patterns, each a streaming `Indicator<Input = Candle, Output = f64>` emitting the family's uniform `±1.0 / 0.0` sign convention, fully wired across the Rust core, Python / Node / WASM bindings, fuzz target and reference tests.

- **Tasuki Gap** (`CDLTASUKIGAP`) — a 3-bar continuation: two same-coloured candles gap in the trend direction, then an opposite candle opens within the second body and closes back into the gap without filling it; upside +1, downside -1.
- **Unique Three River** (`CDLUNIQUE3RIVER`) — a 3-bar bullish reversal: a long black candle, a black candle probing a new low with its body inside the first, then a small white candle held below it; bullish +1.
- **Closing Marubozu** (`CDLCLOSINGMARUBOZU`) — a single long-bodied candle with no shadow on the close end; +1 (white, closes at the high) or -1 (black, closes at the low).
- **Opening Marubozu** — a single long-bodied candle with no shadow on the open end; +1 (white, opens at the low) or -1 (black, opens at the high). No direct TA-Lib equivalent — completes the pair with the closing marubozu.
- **Concealing Baby Swallow** (`CDLCONCEALBABYSWALL`) — a rare 4-bar bullish capitulation: two black marubozu, a black candle gapping down with an upper shadow into the second, then a large black candle engulfing it entirely; bullish +1.

Body and shadow thresholds follow the geometric house style (fixed fractions of the bar range) rather than TA-Lib's rolling averages.

Counter 284 → 289 (mod-count == lib counted block; FAMILIES total 279 → 284).

Stacked on #140 (`feat/cdl-gap-methods`); base retargets to `main` as the stack merges down.
2026-06-02 17:27:39 +02:00
kingchenc 4d0bc08efd feat: TA-Lib candlestick patterns — gap-three-methods/stalled/stick-sandwich/takuri (part 8 of 9) (#140)
Adds five TA-Lib candlestick patterns, each a streaming `Indicator<Input = Candle, Output = f64>` emitting the family's uniform `±1.0 / 0.0` sign convention, fully wired across the Rust core, Python / Node / WASM bindings, fuzz target and reference tests.

- **Upside Gap Three Methods** (`CDLXSIDEGAP3METHODS`) — a 3-bar bullish continuation: two white candles gap up, then a black candle opens within the second body and closes within the first; bullish +1.
- **Downside Gap Three Methods** (`CDLXSIDEGAP3METHODS`) — the bearish mirror: two black candles gap down, then a white candle opens within the second body and closes within the first; bearish -1.
- **Stalled Pattern** (`CDLSTALLEDPATTERN`) — a 3-bar bearish reversal warning: two long white candles then a small white candle riding the shoulder, signalling the rally is stalling; bearish -1.
- **Stick Sandwich** (`CDLSTICKSANDWICH`) — a 3-bar bullish reversal: two black candles closing at the same level sandwich a white candle, marking a support floor; bullish +1.
- **Takuri** (`CDLTAKURI`) — a single-bar bullish reversal, a strict Dragonfly Doji with a negligible upper shadow and very long lower shadow; bullish +1.

Body and shadow thresholds follow the geometric house style (fixed fractions of the bar range) rather than TA-Lib's rolling averages. Upside / Downside Gap Three Methods share the `CDLXSIDEGAP3METHODS` code, so the second carries a manual CHANGELOG entry (as with Rising / Falling Three Methods).

Counter 279 → 284 (mod-count == lib counted block; FAMILIES total 274 → 279).

Stacked on #139 (`feat/cdl-lines`); base retargets to `main` once the predecessor merges.
2026-06-02 17:24:42 +02:00
kingchenc c2c85c7ecf feat: TA-Lib candlestick patterns — matching-low/lines/three-methods (part 7 of 9) (#139)
Adds five TA-Lib candlestick patterns, all `Input = Candle`, `Output = f64`
(`+1.0` bullish / `-1.0` bearish / `0.0` no pattern), wired across core,
Python/Node/WASM bindings, fuzz, and tests.

- **Matching Low** (`CDLMATCHINGLOW`) — 2-bar bullish reversal: two black candles in a decline share the same close, signalling selling pressure is exhausting; bullish +1.
- **Long Line** (`CDLLONGLINE`) — a candle whose range beats a rolling average of recent ranges with a body-dominated range; bullish +1 (white) / bearish -1 (black).
- **Short Line** (`CDLSHORTLINE`) — a compact candle whose range falls below the rolling average with a body-dominated range; bullish +1 (white) / bearish -1 (black).
- **Rising Three Methods** (`CDLRISEFALL3METHODS`) — 5-bar bullish continuation: a long white candle, three small bars holding within its range, then a white breakout to new highs; bullish +1.
- **Falling Three Methods** (`CDLRISEFALL3METHODS`) — the bearish mirror: a long black candle, three small bars within its range, then a black breakdown to new lows; bearish -1.

Counter 274 → 279 (mod-count == lib counted block; FAMILIES total 269 → 274).

Stacked on #138 (part 6 of 9); base retargets to `main` as the chain merges.
2026-06-02 17:16:15 +02:00
kingchenc 04ae145126 feat: TA-Lib candlestick patterns — separating/kicking/ladder/mat-hold (part 6 of 9) (#138) 2026-06-02 17:06:40 +02:00
kingchenc e4ca9c3f8f feat: TA-Lib candlestick patterns — hikkake-mod/pigeon/neck-lines (part 5 of 9) (#137)
* feat: add hikkake-modified, homing-pigeon and neck-line candlestick patterns

Five patterns, all `Input = Candle`, `Output = f64`:

- Modified Hikkake (CDLHIKKAKEMOD) — a close-confirmed Hikkake: an inside bar
  then a breakout that closes back inside the inside-bar range; bullish +1,
  bearish -1.
- Homing Pigeon (CDLHOMINGPIGEON) — two black candles, the second a small body
  inside the first, a bullish reversal; +1.
- On-Neck (CDLONNECK) — long black bar then a white bar closing at its low (the
  neckline), a bearish continuation; -1.
- In-Neck (CDLINNECK) — long black bar then a white bar closing just into its
  body, a bearish continuation; -1.
- Thrusting (CDLTHRUSTING) — long black bar then a white bar closing well into
  but below the midpoint of its body, a bearish continuation; -1.

Counter 264 -> 269 (mod-count == lib counted block; FAMILIES total 259 -> 264).

* chore: sync indicator count to 269

---------

Co-authored-by: wickra-bot <wickra-bot@users.noreply.github.com>
2026-06-02 17:03:49 +02:00
kingchenc d43bc9ddf3 feat: TA-Lib candlestick patterns — doji-star/gap/high-wave/hikkake (part 4 of 9) (#135)
* feat: add doji-star, gap, high-wave and hikkake candlestick patterns

Five patterns, all `Input = Candle`, `Output = f64`:

- Evening Doji Star (CDLEVENINGDOJISTAR) — bearish top reversal: long white bar,
  a doji gapping up, then a black bar closing deep into the first body; -1
  (penetration configurable, default 0.3).
- Morning Doji Star (CDLMORNINGDOJISTAR) — bullish bottom reversal mirror; +1.
- Gap Side-by-Side White (CDLGAPSIDESIDEWHITE) — two similar white candles
  opening side by side after a gap, a continuation; gap up +1, gap down -1.
- High-Wave (CDLHIGHWAVE) — a small body with very long shadows on both sides,
  an extreme indecision flag; +1 on detection.
- Hikkake (CDLHIKKAKE) — an inside bar followed by a failed breakout (a trap);
  bullish +1, bearish -1.

Counter 259 -> 264 (mod-count == lib counted block; FAMILIES total 254 -> 259).

* chore: sync indicator count to 264

---------

Co-authored-by: wickra-bot <wickra-bot@users.noreply.github.com>
2026-06-02 16:54:47 +02:00
kingchenc 244d754707 feat: TA-Lib candlestick patterns — Doji family (part 3 of 9) (#134)
* feat: add Doji-family candlestick patterns

Five single-/two-bar Doji patterns, all `Input = Candle`, `Output = f64`:

- Doji Star (CDLDOJISTAR) — a long body followed by a doji gapping away in the
  trend direction; bullish +1 (after a black bar), bearish -1 (after a white bar).
- Dragonfly Doji (CDLDRAGONFLYDOJI) — a doji opening and closing at the high with
  a long lower shadow; bullish +1.
- Gravestone Doji (CDLGRAVESTONEDOJI) — a doji opening and closing at the low with
  a long upper shadow; bearish -1.
- Long-Legged Doji (CDLLONGLEGGEDDOJI) — a doji with long shadows on both sides; a
  non-directional indecision flag, +1 on detection.
- Rickshaw Man (CDLRICKSHAWMAN) — a long-legged doji with the body centred in the
  range; a non-directional indecision flag, +1 on detection.

Counter 254 -> 259 (mod-count == lib counted block; FAMILIES total 249 -> 254).

* chore: sync indicator count to 259

---------

Co-authored-by: wickra-bot <wickra-bot@users.noreply.github.com>
2026-06-02 16:45:08 +02:00
kingchenc 03ceac1f3b feat: TA-Lib candlestick patterns — abandoned/advance/belt/break/counter (part 2 of 9) (#132)
* feat: add Abandoned Baby candlestick pattern (CDLABANDONEDBABY)

* feat: add Advance Block candlestick pattern (CDLADVANCEBLOCK)

* feat: add Belt Hold candlestick pattern (CDLBELTHOLD)

* feat: add Breakaway and Counterattack candlestick patterns (CDLBREAKAWAY, CDLCOUNTERATTACK)

Breakaway is a 5-bar reversal: a trend gaps away on the second bar, drifts
two more bars, then the fifth bar snaps back and closes inside the bar1/bar2
body gap (bullish +1, bearish -1). Counterattack is a 2-bar reversal where an
opposite-coloured long second bar closes level with the first (the counterattack
line; bullish +1, bearish -1).

Also suppress libtest's spanless `large_stack_arrays` false positive in
wickra-core test builds: the `#[test]` harness collects every test into a
compiler-generated array of references that crosses clippy's 16 KB threshold
once the suite passes ~2048 unit tests. The allow is scoped to `cfg(test)`, so
library code is still linted for genuinely large stack arrays.

* chore: sync indicator count to 254

---------

Co-authored-by: wickra-bot <wickra-bot@users.noreply.github.com>
2026-06-02 16:34:15 +02:00
kingchenc 73415cd2dc ci: zizmor security hardening (#133)
* ci: pass ref context through env in release tag step

zizmor flagged the "Resolve target tag" step in release.yml for
template-injection: github.event_name / github.ref / github.ref_name
were interpolated directly into the shell script. On a tag push the tag
name is attacker-influenceable, so a crafted tag could inject commands.

Move all three context values into the step env and reference them as
shell variables instead. Verified with zizmor 1.16.3: template-injection
findings on release.yml drop from 2 to 0.

* ci: accept release.yml build caches via zizmor config

The release pipeline restores Swatinem/rust-cache and actions/setup-node
caches as a deliberate optimisation. zizmor flags all eight under
cache-poisoning because release.yml publishes to crates.io / PyPI / npm.
The caches are maintainer-controlled and the restore speedup is kept on
purpose, so accept the finding via a zizmor config ignore for release.yml
rather than running cache-free release builds. (Six of the eight are
actions/setup-node, reported at Low confidence.)

Adds .github/zizmor.yml; release.yml now reports 0 high findings.

* ci: drop persisted checkout credentials on read-only jobs

zizmor's artipacked audit flags every actions/checkout that keeps the
default persisted credential: the token is written to the runner's
.git/config, where it can leak if a later step packs .git into an
uploaded artifact, or be read by another step in the same job.

Set persist-credentials: false on the 20 checkouts whose jobs never push
or authenticate to git (build/test/clippy/msrv/coverage/supply-chain/
fuzz/python/wasm/node in ci.yml, plus bench.yml, codeql.yml, the seven
release.yml build/publish jobs, and sync-metadata.yml). The publish and
release jobs authenticate to crates.io / npm / PyPI / the GitHub API with
their own tokens, not persisted git credentials, so this is safe.

sync-about.yml genuinely pushes the indicator-count fix-up to the PR
branch, so it keeps its credential and is accepted via .github/zizmor.yml.
zizmor artipacked for the repo drops to 0 (0 high, 0 medium remaining).
2026-06-02 02:08:40 +02:00
kingchenc ad51dbc1a3 fix: keep docs/README indicator count in sync-about (#131)
* fix: keep docs/README indicator count in sync-about

The docs/README.md pointer prose names the indicator count
("**N indicators**") but was never part of the sync-about counter
pipeline, so it drifted to 214 while the real count (lib.rs) is 249.

Add docs/README.md to the PR-flow gate check, the patch sed, and the
fix-up commit so future count changes keep it in sync, and correct the
current stale value to 249.

* docs: fix stale Wiki reference in CONTRIBUTING layout table

The project-layout table still described docs/ as a "Pointer to the
project Wiki" even though the wiki was retired and the docs moved to
docs.wickra.org (wickra-lib/wickra-docs). Align the row with the
already-correct doc-site section further down the same file.
2026-06-01 23:35:03 +02:00
kingchenc f09057aaf1 feat: TA-Lib candlestick patterns — crows & three-line (part 1 of 9) (#130)
* feat: add Two Crows candlestick pattern (CDL2CROWS)

* feat: add Upside Gap Two Crows candlestick pattern (CDLUPSIDEGAP2CROWS)

* feat: add Identical Three Crows candlestick pattern (CDLIDENTICAL3CROWS)

* feat: add Three Line Strike candlestick pattern (CDL3LINESTRIKE)

* feat: add Three Stars in the South candlestick pattern (CDL3STARSINSOUTH)
2026-06-01 23:20:10 +02:00
kingchenc 458ef2385e ci: add zizmor GitHub Actions security scanning (#129) 2026-06-01 22:34:03 +02:00
kingchenc 2d140419bb feat: derivatives basis & calendar-spread indicators (part 3 of 3) (#128)
* feat(derivatives): TermStructureBasis indicator (core)

* feat(derivatives): CalendarSpread indicator (core)

* feat(derivatives): Python, Node and WASM bindings for basis & calendar-spread indicators

* test(derivatives): Python and Node tests for basis & calendar-spread indicators

* docs(derivatives): README row + counter 242->244, CHANGELOG part 3; fuzz basis indicators
2026-06-01 22:07:35 +02:00
kingchenc 8e5bfd07ce feat: derivatives open-interest, flow & liquidation indicators (part 2 of 3) (#127)
* feat(derivatives): OIPriceDivergence indicator (core)

* feat(derivatives): OIWeighted indicator (core)

* feat(derivatives): LongShortRatio indicator (core)

* feat(derivatives): TakerBuySellRatio indicator (core)

* feat(derivatives): LiquidationFeatures multi-output indicator (core)

* feat(derivatives): Python, Node and WASM bindings for OI, flow & liquidation indicators

* test(derivatives): Python and Node tests for OI, flow & liquidation indicators

* fuzz(derivatives): drive OI, flow & liquidation indicators in derivatives target

* docs(derivatives): README row + counter 237->242, CHANGELOG part 2
2026-06-01 21:50:35 +02:00
kingchenc 5eb820a9c7 feat: derivatives funding & open-interest indicators (part 1 of 3) (#126)
* feat(derivatives): DerivativesTick input type + InvalidDerivatives error

* feat(derivatives): FundingRate indicator (core)

* feat(derivatives): FundingRateMean indicator (core)

* feat(derivatives): FundingRateZScore indicator (core)

* feat(derivatives): FundingBasis indicator (core)

* feat(derivatives): OpenInterestDelta indicator (core)

* feat(derivatives): Python, Node and WASM bindings for funding & OI-delta indicators

* test(derivatives): Python and Node tests for funding & OI-delta indicators

* bench(derivatives): synthetic-tick bench + derivatives fuzz target

* docs(derivatives): README family row + counter 232->237, CHANGELOG entry
2026-06-01 21:26:37 +02:00
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
331 changed files with 81126 additions and 627 deletions
+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
@@ -60,7 +60,7 @@ Closes #
- [ ] 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)
- [ ] License header / `LICENSE` reference unchanged (MIT OR Apache-2.0)
## Notes for reviewers
+23
View File
@@ -6,6 +6,8 @@ updates:
schedule:
interval: weekly
open-pull-requests-limit: 10
cooldown:
default-days: 7
commit-message:
prefix: "deps(cargo)"
@@ -15,6 +17,8 @@ updates:
schedule:
interval: weekly
open-pull-requests-limit: 10
cooldown:
default-days: 7
commit-message:
prefix: "deps(npm)"
@@ -24,9 +28,26 @@ updates:
schedule:
interval: weekly
open-pull-requests-limit: 10
cooldown:
default-days: 7
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
cooldown:
default-days: 7
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
@@ -34,5 +55,7 @@ updates:
schedule:
interval: weekly
open-pull-requests-limit: 10
cooldown:
default-days: 7
commit-message:
prefix: "deps(actions)"
+11
View File
@@ -0,0 +1,11 @@
# 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
TA-Lib
tulipy
talipp
finta
+247
View File
@@ -0,0 +1,247 @@
# This file was autogenerated by uv via the following command:
# ./scripts/update-lockfiles.sh
build==1.5.0 \
--hash=sha256:13f3eecb844759ab66efec90ca17639bbf14dc06cb2fdf37a9010322d9c50a6f \
--hash=sha256:302c22c3ba2a0fd5f3911918651341ebb3896176cbdec15bd421f80b1afc7647
# via ta-lib
colorama==0.4.6 \
--hash=sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44 \
--hash=sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6
# via build
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
# ta-lib
# tulipy
packaging==26.2 \
--hash=sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e \
--hash=sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661
# via build
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
pyproject-hooks==1.2.0 \
--hash=sha256:1e859bd5c40fae9448642dd871adf459e5e2084186e8d2c2a79a824c970da1f8 \
--hash=sha256:9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913
# via build
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
ta-lib==0.6.8 \
--hash=sha256:02388054c059945e5f02625f5075bac20a1803573cb43e7d096091027511961f \
--hash=sha256:094677b279a59c3f01c3aca8a889fda3523fd641a3805f69a2d642121b72e55e \
--hash=sha256:0a08a29690a922ba92a6cf42902a8a93c6fbda4cfed62c3c5b0471560ef60135 \
--hash=sha256:0ccd478ff5735831bf2a61d653466bfda8afadc26ad58ca6b1edb9e7521cc674 \
--hash=sha256:0e371d14b49e70caa973a234c8823341dd446f5c5d7acc826868bb42b272bdc0 \
--hash=sha256:11a373c9308eae3bac2d56d37017f9ab63968cc074a8b95be879aae3d13133aa \
--hash=sha256:128ec92e6a0e9ff7a38edef80e3b74f15bb2ed1c531d5d3252c8dca22677651b \
--hash=sha256:1fb4028437201e19014e4e374272b739867c8a3eb655da46675ef4c2ff14b616 \
--hash=sha256:282e49c766b5952dd8796f77d7ed3ae412cdd88e31f845b1fbbb86ac6cb7bebf \
--hash=sha256:2b369cabb48485fbf444beb3f5a878075367b99c2c86db2f796afeabebc749e0 \
--hash=sha256:2bf714333788bf5175f2512b86d2ed129e89ae6f6c2923e8a297a1e3395e13b5 \
--hash=sha256:30de46b55873b51be945a09edf486afcc190dc47eff9fb5d2b12c9f7e3d743da \
--hash=sha256:34e3b12407ddf99f6627435aa8a165f094339bb7dc33de92e1d7472e9f237304 \
--hash=sha256:36b2a516fce57309840f5ef3fa2fd0c4449293fc72536a0400d2e1e26b414da8 \
--hash=sha256:3a9195299df9d7d2a6e9d16bebd6b706b0ea99e4b871864c4b034c2577e21a77 \
--hash=sha256:3c32fc0f546ceecc47dd45f33d72ab4a1e341b80d9081c2d77b100add5d49104 \
--hash=sha256:3d7333e907bff3e3997e54f89733ffa8d619842a3e1cd962bca34bdc11944c28 \
--hash=sha256:4795e93d130c9b7fb661f0cead49752ae6a980437df74b99d5918026c212443e \
--hash=sha256:490e19a45cd3cdd6dfe6b46019f7ffe1103500750b41b51996a870e7c1c5f066 \
--hash=sha256:4aa0fe08383f3e5fc7d2f8cf9b42ac778f4d53fd75bcd2799a858225954eab89 \
--hash=sha256:559326d8f3d904cd4aa61f6a392d5626f35eec6a9f6cc83bcddb0abf88c40516 \
--hash=sha256:5929c83bd8cb7572d1c17ffdbf0eac235bf3c4d53cde1950cf89d944eaf97525 \
--hash=sha256:5bfd21b6acb32e20d4e279c34405a34e63da345be4b2b6eabd683e1a88857406 \
--hash=sha256:613cf06313331f49dd7b85a5a24fbddb1156c9723b6921a231906241726e5aee \
--hash=sha256:66a8e1c1e899d15a2f7510e43527fba22d895e7f6058d027db3e3837d88a69de \
--hash=sha256:691a62926ba09f2653ec0908554b3635497efb7751c5d46b916cd1ebbb1d3c25 \
--hash=sha256:6c1fd18e45c39d5a4be4b0d6a20c141e43fe46daeb1b2e2f304ebae7015ab6e6 \
--hash=sha256:6c6a1e8f98de92e817491b50aa4d01d69a1b41a4ed3173747e8f16f0d4cf81cc \
--hash=sha256:6cf029b886cfb28a2701503b7c602b811f2daa45276bd6459b0c71e051deb497 \
--hash=sha256:71506116eac0d3e3598d6325b4b818c3a0f6acb3222b24d30ad726e8c4bf7ea8 \
--hash=sha256:7993164e8e9f78ec31d38c47850ca6ba5451788b5b49a8a2dbb3322b36b5693b \
--hash=sha256:7a5cc6bf60791d8274edfdfe2dd7cec3f00f656dcc92e2b0a9af06c8b18ce6a6 \
--hash=sha256:87c1cc1057d903b78a8257a7c5f497db6fd5284f5080392bd57b66031d7389a3 \
--hash=sha256:98376c75bd6c103c74396953084a5e0798ffe476aecbfcc51ec6d100a685ac38 \
--hash=sha256:a395524b0fafa10446d11e11acb4742e919523de58aac03b791f26d7a783bcf0 \
--hash=sha256:a5100a4be91b7d4b7c8fe16a3600bd0951e10205eb1066b6873afd3996b51ee4 \
--hash=sha256:a63a52221f8c73f82f4e00493351d987f594931198589287aee96f8da673cfd5 \
--hash=sha256:a89734a7bcb2ea3b6fd600a74d6fbcdb8d3fa3f7917dbd978e039710b5509c9c \
--hash=sha256:b165f5e6de1ccc964e863bd2035807a4d3bad3e0481f9db2dc52034d6ad4f9de \
--hash=sha256:b3845e4c2fa32963fb7f384ebbaa2761b0e6b96145239bf80e956d4aff4b071c \
--hash=sha256:b3b017d9103e7a7372a146773be32b184ff7330bd708d40b1f56f06a686756ed \
--hash=sha256:b6c6e4858d8c3f88e19b7aa94b6a7619108f0bee51da9fa67b0785a8b59955f9 \
--hash=sha256:bfad1202fb1f9140e3810cc607058395f59032d9128cc0d716900c78bea5f337 \
--hash=sha256:c01809fb602e2fefc8cbfb3b603bb59d2a2eaee8708410896d48a835ba00e7c5 \
--hash=sha256:cce8de9d48289927ed18aaa420740efd52b2cd9289da32e3799afbb3a02822e8 \
--hash=sha256:ce2bc1ea01200b6d8130ab917296d05d77a1a571ec6c1ee25cfca6d55cd5db4a \
--hash=sha256:d4601e2a8b46ffbf540601a4926fd6cc5aae8a13b36fdd467f1040f01f9edaed \
--hash=sha256:d556d1c256b3700b60b6b061664a667b2e49d599c2772d46a9f2348f2dc4ab5c \
--hash=sha256:ddf7453acd03b966624ebefdb38169b5bbbeea1a1a58c90b095667247f9de327 \
--hash=sha256:e781eeb65b2007af553389c8a7fb7bc53cb856118b0fcffb2c26b0f49561c686 \
--hash=sha256:e920c272cd9e70a6b10eae9203cc96845da142e1dd4482de9343dda3738a9862 \
--hash=sha256:f5b6174bf4bf9152e368561dff410203c6921e4dd2afbcda3283a95957158112 \
--hash=sha256:f69bd42fd2515060af69b120668213121264bb7976b113954b6f9db327727c65 \
--hash=sha256:f823d0f6b04a6797fbe253bcf91666e71a6b63c290683819650c68b2468ebe64 \
--hash=sha256:fa7e9f2e80a9535f9692e113d02b4268b5f88675a730d1b0ef0abeb74c9a4e80
# via -r .github/requirements/bench.in
talipp==2.7.0 \
--hash=sha256:567f59ad74366cb59a14a00d350f35fd9d22e6924d6228bad581e6dcf1de2205 \
--hash=sha256:f749f22b9ad615605e71faf26457bb7f5e3fe16f04d3287f4ca54fd16bc3d4eb
# via -r .github/requirements/bench.in
tulipy==0.4.0 \
--hash=sha256:540704956b5b940a5f6306aa393a37536a6d7c3cbc07efe47512f3496e5203ab \
--hash=sha256:95542e40537afdd345d875baf37485eac993c6a819d00c51432e9de8df21eba8 \
--hash=sha256:fbc31727ef7657c93ad910bfdce65fecc6aaa7a5e961fe00240718e7a3fc79d8
# 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
+35 -1
View File
@@ -49,6 +49,8 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable branch, 2026-03-27
@@ -62,6 +64,8 @@ jobs:
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'
@@ -75,11 +79,15 @@ jobs:
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
@@ -109,3 +117,29 @@ jobs:
with:
name: cross-library-bench
path: bindings/python/benchmark.txt
rust-cross-bench:
name: Rust cross-library benchmark report
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- 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
# Wickra vs the other Rust TA crates (kand, ta-rs, yata) on an identical
# candle series — the like-for-like engine comparison with no binding
# overhead. Streaming + batch, in crates/wickra-bench/benches/cross_lib.rs.
- name: Run Rust cross-library benchmark
run: cargo bench -p wickra-bench --bench cross_lib | tee rust_cross_bench.txt
- name: Upload Rust report
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: rust-cross-bench
path: rust_cross_bench.txt
+38 -2
View File
@@ -40,6 +40,8 @@ jobs:
os: [ubuntu-latest, macos-latest, windows-latest]
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable branch, 2026-03-27
@@ -88,6 +90,8 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up Node
id: setup_node
@@ -175,6 +179,8 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable branch, 2026-03-27
@@ -253,6 +259,8 @@ jobs:
packages: "-p wickra-node"
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Install Rust ${{ matrix.toolchain }}
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable branch, 2026-03-27
@@ -276,6 +284,8 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable branch, 2026-03-27
@@ -315,6 +325,8 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: cargo-deny
uses: EmbarkStudios/cargo-deny-action@bb137d7af7e4fb67e5f82a49c4fce4fad40782fe # v2.0.20
@@ -331,6 +343,8 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Install nightly Rust
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable branch, 2026-03-27
@@ -387,6 +401,8 @@ jobs:
python-version: ["3.9", "3.11", "3.12", "3.13"]
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable branch, 2026-03-27
@@ -407,6 +423,8 @@ jobs:
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'
@@ -420,11 +438,21 @@ jobs:
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
@@ -449,6 +477,8 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Install Rust toolchain (with wasm target)
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable branch, 2026-03-27
@@ -495,6 +525,8 @@ jobs:
node-version: ["18", "20"]
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable branch, 2026-03-27
@@ -514,6 +546,8 @@ jobs:
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'
@@ -527,10 +561,12 @@ jobs:
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
+3 -1
View File
@@ -40,7 +40,9 @@ jobs:
build-mode: none
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Initialize CodeQL
uses: github/codeql-action/init@03e4368ac7daa2bd82b3e85262f3bf87ee112f57 # v3.36.0
+26 -5
View File
@@ -46,6 +46,8 @@ jobs:
environment: release
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- 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
@@ -156,6 +158,8 @@ jobs:
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up Python
id: setup_python
continue-on-error: true
@@ -194,6 +198,8 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- 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
@@ -244,6 +250,8 @@ jobs:
runs-on: ${{ matrix.host }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up Node
id: setup_node
@@ -275,7 +283,7 @@ jobs:
- name: Install Node deps
working-directory: bindings/node
run: npm install
run: npm ci
- name: Build native module
working-directory: bindings/node
@@ -303,6 +311,8 @@ jobs:
id-token: write
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up Node
id: setup_node
@@ -328,7 +338,7 @@ jobs:
- 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
@@ -470,6 +480,8 @@ jobs:
id-token: write
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up Node
id: setup_node
@@ -524,7 +536,7 @@ jobs:
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';
pkg.license = 'MIT OR Apache-2.0';
fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2));
"
@@ -570,13 +582,22 @@ jobs:
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
fetch-depth: 0
- name: Resolve target tag
id: tag
# Pass the (potentially attacker-influenceable on a tag push) ref context
# through the environment instead of interpolating it into the shell
# script, so a crafted tag name cannot inject commands (zizmor:
# template-injection).
env:
EVENT_NAME: ${{ github.event_name }}
REF: ${{ github.ref }}
REF_NAME: ${{ github.ref_name }}
run: |
if [ "${{ github.event_name }}" = "push" ] && [[ "${{ github.ref }}" == refs/tags/* ]]; then
tag="${{ github.ref_name }}"
if [ "$EVENT_NAME" = "push" ] && [[ "$REF" == refs/tags/* ]]; then
tag="$REF_NAME"
else
# workflow_dispatch / non-tag push: attach to the latest v* tag.
tag=$(git tag --list 'v*' --sort=-v:refname | head -n1)
+8 -1
View File
@@ -24,7 +24,7 @@ jobs:
id-token: write # OIDC token to publish results to the OpenSSF API
steps:
- name: Checkout code
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
@@ -33,6 +33,13 @@ jobs:
with:
results_file: results.sarif
results_format: sarif
# The default GITHUB_TOKEN cannot read classic branch-protection
# rules, so the Branch-Protection check fails with an internal error
# and scores -1. A read-only fine-grained PAT (Administration: read,
# Contents: read, Metadata: read) supplied as SCORECARD_TOKEN lets the
# check read the protection settings. See
# https://github.com/ossf/scorecard-action/blob/main/docs/authentication/fine-grained-auth-token.md
repo_token: ${{ secrets.SCORECARD_TOKEN }}
# Publish to the public OpenSSF endpoint that backs the README badge.
publish_results: true
+45 -103
View File
@@ -10,7 +10,7 @@ name: Sync indicator count
# 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 / public/hero.svg) — push to main / v* tag*
# .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.")
@@ -41,17 +41,17 @@ name: Sync indicator count
# `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.
# Design: on PRs this workflow is a READ-ONLY check. The indicator wiring
# (ScriptHelpers/_common.py wire_readme_counter) bumps both README.md and
# docs/README.md inside the author's code commit, so the counter is already
# correct by the time CI runs. If it is not, the check below fails loud and
# asks the author to re-run the wiring — it never pushes a fix-up commit.
#
# 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).
# (An earlier version pushed a GITHUB_TOKEN "sync indicator count" commit to
# the PR head. Because GITHUB_TOKEN pushes trigger no workflows, that commit
# moved the PR head onto a commit with no CI run, which hid the Codecov patch
# status — keyed to the PR head sha — from the PR. Keeping the counter in the
# code commit avoids that entirely.)
on:
push:
branches: [main]
@@ -73,53 +73,24 @@ permissions:
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>).
# This workflow never writes to wickra-lib/wickra with GITHUB_TOKEN: the PR
# flow is a read-only check, and the main/tag flow writes only to other
# repos (About metadata, docs, webpage, wiki, org) through the fine-grained
# ABOUT_SYNC_TOKEN PAT. So GITHUB_TOKEN stays read-only (OpenSSF Scorecard:
# Token-Permissions).
permissions:
contents: write
contents: read
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".
# On PRs we check out the PR *head* commit (the author's code, not the
# merge ref) so the counter check validates exactly what will land. On
# push events we check out the default ref. No push is made, so a shallow
# checkout is enough.
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
fetch-depth: 1
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
@@ -139,62 +110,33 @@ jobs:
# ----- PR flow ---------------------------------------------------
- name: Check README counter (PR)
- name: Check README counter (PR, read-only)
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."
ok=true
if ! grep -qE "^${n} streaming-first indicators" README.md; then
echo "::error::README.md does not say '${n} streaming-first indicators' — lib.rs exports ${n}. Re-run the indicator wiring (it bumps README.md), then push again."
ok=false
fi
- name: Fix counter on fork PR head (read-only, fail loud)
if: github.event_name == 'pull_request' && steps.pr_check.outputs.matches == 'false' && steps.ctx.outputs.can_push == 'false'
run: |
n="${{ steps.count.outputs.count }}"
echo "::error::README.md says a different indicator count than mod.rs (${n}). This PR is from a fork, so the workflow cannot push the fix; please update README.md to '${n} streaming-first indicators' and push again."
exit 1
- name: Patch README on PR head
if: github.event_name == 'pull_request' && steps.pr_check.outputs.matches == 'false' && steps.ctx.outputs.can_push == 'true'
id: pr_patch
run: |
n="${{ steps.count.outputs.count }}"
sed -i -E "s/[0-9]+ (streaming-first )?indicators/${n} \1indicators/g" README.md
if git diff --quiet; then
echo "No README changes after sed (counter regex did not match anything); skipping push."
echo "changed=false" >> "$GITHUB_OUTPUT"
else
echo "changed=true" >> "$GITHUB_OUTPUT"
if ! grep -qE "\*\*${n} indicators\*\*" docs/README.md; then
echo "::error::docs/README.md does not say '**${n} indicators**' — lib.rs exports ${n}. Re-run the indicator wiring (it bumps docs/README.md), then push again."
ok=false
fi
if [ "$ok" = "true" ]; then
echo "README.md + docs/README.md counter already at ${n}; nothing to do."
else
exit 1
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.
# After a PR squash-merges, this workflow runs again on the push to main.
# README.md / docs/README.md are already correct (the indicator wiring
# bumped them in the merged code commit); the only outward syncs left are
# the GitHub About description (repo metadata, not a commit) and the docs /
# webpage / wiki / org repos (separate repos, no main history pollution).
# The wickra repo's own README is not touched on main any more.
- name: Update GitHub About (description + homepage)
if: github.event_name != 'pull_request'
@@ -238,14 +180,14 @@ jobs:
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
sed -i -E "s/[0-9]+ (streaming-first )?indicators/${n} \1indicators/g" index.md overview.md Indicators-Overview.md .vitepress/config.ts
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 add index.md overview.md Indicators-Overview.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/wickra-docs failed — ABOUT_SYNC_TOKEN likely lacks write (findings P10.0a)."
@@ -424,14 +366,14 @@ jobs:
exit 0
fi
cd webpage-count
sed -i -E "s/[0-9]+ (streaming-first )?indicators/${n} \1indicators/g" index.md .vitepress/config.ts public/hero.svg
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 public/hero.svg
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)."
+2
View File
@@ -15,6 +15,8 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: "3.12"
+40
View File
@@ -0,0 +1,40 @@
name: zizmor
# Static analysis of the GitHub Actions workflows themselves — the surface the
# CodeQL pass does not cover. zizmor flags template injection, overly broad
# GITHUB_TOKEN permissions, unpinned actions, cache poisoning, and dangerous
# triggers. Findings appear under Security -> Code scanning alongside CodeQL.
#
# Report-only: with `advanced-security: true` the action runs zizmor in SARIF
# mode, which exits 0 regardless of findings, so this job never blocks CI —
# triage happens in the Security tab. Switch to gating later (e.g. a
# `min-severity` input) once the existing findings are triaged.
on:
push:
branches: [main]
pull_request:
branches: [main]
schedule:
- cron: '17 4 * * 1' # Mondays 04:17 UTC
# Least-privilege default for the auto-injected GITHUB_TOKEN; the job raises
# exactly the scopes it needs below (matches codeql.yml's pattern).
permissions:
contents: read
jobs:
zizmor:
name: Audit workflows
runs-on: ubuntu-latest
permissions:
security-events: write # upload SARIF to code-scanning
contents: read # checkout
actions: read # online audits resolve referenced actions
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Run zizmor
uses: zizmorcore/zizmor-action@5f14fd08f7cf1cb1609c1e344975f152c7ee938d # v0.5.6
+49
View File
@@ -0,0 +1,49 @@
# zizmor configuration — https://docs.zizmor.sh/configuration/
#
# cache-poisoning (release.yml):
# The release pipeline restores build caches (Swatinem/rust-cache for the Rust
# compilation, actions/setup-node) as a deliberate, accepted optimisation.
# zizmor flags these under cache-poisoning because release.yml publishes
# artifacts to crates.io / PyPI / npm, so a poisoned cache could in theory
# reach a released build. Our caches are maintainer-controlled and the
# restore speedup is kept on purpose; we accept this risk rather than running
# cache-free release builds. (Six of the eight hits are actions/setup-node,
# which zizmor reports at "Low" confidence.)
#
# artipacked (sync-about.yml):
# The sync-about job checks out with persisted credentials on purpose: it
# pushes the indicator-count fix-up back to the PR head branch (git commit +
# git push), which needs the token in the runner's git config. It uploads no
# artifacts, so the persisted token is never packaged or leaked; accept it.
#
# template-injection (sync-about.yml):
# False positive. Every flagged expansion is steps.count.outputs.count, the
# indicator count produced by an internal `grep -c` over lib.rs. It is not
# attacker-controllable, so there is nothing to inject.
#
# use-trusted-publishing (release.yml):
# Informational suggestion to use OIDC trusted publishing for PyPI / npm
# instead of long-lived tokens. A worthwhile migration, but it reconfigures
# the live publish pipeline on the registry side; tracked separately rather
# than blocking on it here.
#
# superfluous-actions (release.yml):
# The GitHub release step uses softprops/action-gh-release. The runner ships
# `gh`, so this is replaceable by a script step, but the action is stable and
# battle-tested; we keep it deliberately.
rules:
cache-poisoning:
ignore:
- release.yml
artipacked:
ignore:
- sync-about.yml
template-injection:
ignore:
- sync-about.yml
use-trusted-publishing:
ignore:
- release.yml
superfluous-actions:
ignore:
- release.yml
+427 -1
View File
@@ -7,6 +7,414 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [0.6.1] - 2026-06-07
- **Projection Oscillator** — Widner projection oscillator: close position inside the projection bands, scaled 0..100 (`ProjectionOscillator`).
- **Projection Bands** — Widner projection bands: forward-projected high/low regression envelope (`ProjectionBands`).
- **Median Channel** — robust median +/- multiplier*MAD envelope (`MedianChannel`).
- **Bomar Bands** — adaptive percentage bands containing a target coverage fraction of recent closes (`BomarBands`).
- **Quartile Bands** — rolling 25th/50th/75th-percentile (Q1/median/Q3) envelope (`QuartileBands`).
## [0.6.0] - 2026-06-06
- **Volatility Cone** — volatility cone: current realized volatility within its historical min/median/max envelope (`VolatilityCone`).
- **VolatilityRatio** — Schwager's volatility ratio: true range over the EMA of prior true ranges (`VolatilityRatio`).
- **BipowerVariation** — jump-robust realized bipower variation (pi/2 sum of adjacent absolute log-return products) (`BipowerVariation`).
- **VolatilityOfVolatility** — vol-of-vol: sample stddev of a rolling realized-volatility series (`VolatilityOfVolatility`).
- **Garch11** — GARCH(1,1) conditional volatility with a long-run-variance anchor (`Garch11`).
- **EwmaVolatility** — RiskMetrics exponentially-weighted volatility of log returns (lambda decay) (`EwmaVolatility`).
## [0.5.9] - 2026-06-06
### Added
- Internal Rust cross-library benchmark harness (`crates/wickra-bench`, not
published) comparing Wickra against `kand`, `ta-rs` and `yata` on an identical
candle series in both streaming and batch modes; wired into the nightly
`cross-library-bench` workflow.
- `tulipy` runners and expanded per-tick streaming coverage (SMA, EMA, RSI,
MACD, Bollinger) in the Python `compare_libraries` benchmark.
### Changed
- Faster streaming and batch updates for SMA, Bollinger Bands, RSI, EMA and ATR
(flat ring buffers replacing `VecDeque`, hoisted reciprocals in the Wilder
smoothing, leaner hot state) — indicator outputs are unchanged.
- Rewrote the README benchmark section into honest, tiered tables (Rust core vs
the other Rust crates, and Python vs the Python ecosystem) that show where
Wickra wins and where it loses, not only the favourable comparisons.
## [0.5.8] - 2026-06-04
- **TSF Oscillator** — the percentage gap of the close to the one-bar-ahead time-series forecast, a close-relative companion to CFO (`TsfOscillator`).
- **MACD Histogram** — the standalone macd-minus-signal bar of MACD as a scalar series (`MacdHistogram`).
- **PPO Histogram** — the Percentage Price Oscillator with its signal EMA and the resulting zero-centered histogram (`PpoHistogram`).
## [0.5.7] - 2026-06-04
- **Qstick** — Qstick (Chande), the SMA of the candle body (close open) as a net buying/selling pressure gauge (`QSTICK`).
- **TTM Trend** — TTM Trend (John Carter), +1/1 by whether the close sits above the SMA of recent median prices (`TTM_TREND`).
- **Trend Strength Index** — trend strength index, the signed r² of a linear regression of price against time (`TREND_STRENGTH_INDEX`).
- **Polarized Fractal Efficiency** — polarized fractal efficiency (Hannula), directional trend efficiency over a fractal lookback (`POLARIZED_FRACTAL_EFFICIENCY`).
- **Wave PM** — Wave PM (Kase), a variance-normalised peak-momentum statistic (`WAVE_PM`).
- **Gator Oscillator** — Gator Oscillator (Bill Williams), the Alligator convergence/divergence histogram (`GATOR_OSCILLATOR`).
- **Kase Permission Stochastic** — Kase Permission Stochastic, a double-smoothed stochastic used as a trade-permission filter (`KASE_PERMISSION_STOCHASTIC`).
## [0.5.6] - 2026-06-04
- **QQE** — quantitative qualitative estimation, a smoothed RSI with an ATR-of-RSI trailing line (`QQE`).
- **Intraday Momentum Index** — intraday momentum index (Chande), RSI on the open-to-close body (`IMI`).
- **Elder Ray** — Elder Ray bull power and bear power around an EMA of close (`ElderRay`).
- **Derivative Oscillator** — derivative oscillator (Constance Brown), a double-smoothed RSI histogram (`DerivativeOscillator`).
- **RMI** — relative momentum index (RMI), RSI over a multi-bar momentum lookback (`RMI`).
- **Stochastic CCI** — stochastic CCI, a stochastic oscillator over the CCI (`StochasticCCI`).
- **Dynamic Momentum Index** — dynamic momentum index (Chande), a volatility-adaptive RSI (`DynamicMomentumIndex`).
- **RSX** — RSX, a Jurik-style three-stage smoothed RSI (`RSX`).
- **Fisher RSI** — Fisher RSI, the Fisher transform of a normalised RSI (`FisherRSI`).
- **Disparity Index** — disparity index, the percent gap between price and its moving average (`DisparityIndex`).
## [0.5.5] - 2026-06-04
- **GD** — generalized DEMA (GD), Tillson's volume-factor double EMA and the building block of T3 (`GD`).
- **GMA** — geometric moving average (GMA), the rolling geometric mean of prices (`GMA`).
- **Holt-Winters** — Holt's linear (double exponential) smoothing with level and trend components (`HoltWinters`).
- **Adaptive Laguerre** — Ehlers adaptive Laguerre filter with median-error-adaptive gamma (`AdaptiveLaguerre`).
- **Median MA** — median moving average, the rolling median of prices (`MedianMA`).
- **EHMA** — exponential Hull moving average (EHMA), the Hull construction built from EMAs (`EHMA`).
- **SWMA** — sine-weighted moving average (SWMA), a symmetric half-cycle sine window (`SWMA`).
## [0.5.4] - 2026-06-04
- **Roll Measure** — effective spread implied by the negative serial covariance of trade-price changes (Roll 1984) (`RollMeasure`).
- **Amihud Illiquidity** — average absolute log return per unit of traded value (price-impact liquidity proxy, Amihud 2002) (`AmihudIlliquidity`).
- **VPIN** — volume-synchronised probability of informed trading (volume-bucketed order-flow toxicity) (`Vpin`).
- **Order Flow Imbalance** — rolling sum of best-level order-flow events (Cont-Kukanov-Stoikov OFI) (`OrderFlowImbalance`).
- **Expectancy** — expected return per unit of average loss (R-multiple) over a rolling window of returns (`Expectancy`).
- **Win Rate** — fraction of strictly-positive returns over a rolling window (`WinRate`).
- **Regime Label** — volatility-quantile regime classification: 1 calm / 0 normal / +1 stressed, by where the rolling volatility sits in its own recent distribution (`RegimeLabel`).
- **Jump Indicator** — flags return outliers beyond `threshold ×` trailing return volatility (1 down / 0 / +1 up) (`JumpIndicator`).
- **Trend Label** — discrete trend state from the sign of the rolling least-squares slope (1 / 0 / +1) (`TrendLabel`).
- **High-Low Range** — bar high-low range as a fraction of close (scale-free per-bar volatility) (`HighLowRange`).
- **Wick Ratio** — signed upper-vs-lower shadow imbalance as a fraction of the range (`WickRatio`).
- **Body Size Percent** — absolute candle body as a fraction of the bar range (`BodySizePct`).
- **Close vs Open** — signed body as a fraction of the open price, `(close open) / open` (`CloseVsOpen`).
- **Spread AR(1) Coefficient** — first-order autoregression coefficient of the spread `a b` (direct cointegration / mean-reversion strength) (`SpreadAr1Coefficient`).
- **Rolling Quantile** — interpolated q-th quantile over a trailing window (type-7 / NumPy default) (`RollingQuantile`).
- **Rolling Percentile Rank** — percentile rank of the latest value within its trailing window (`RollingPercentileRank`).
- **Rolling IQR** — interquartile range (Q3 Q1) over a trailing window (robust dispersion) (`RollingIqr`).
- **Realized Volatility** — square root of the summed squared log returns (raw, un-annualised quadratic variation) (`RealizedVolatility`).
- **Log Return** — logarithmic return over a fixed lag, `ln(price_t / price_{tperiod})` (`LogReturn`).
## [0.5.3] - 2026-06-04
- **Fibonacci Time Zones** — vertical markers at Fibonacci bar-distances (1/2/3/5/8/...) from the latest swing pivot (`FIB_TIME_ZONES`).
- **Fibonacci Channel** — a sloped base trendline plus parallel lines at Fibonacci multiples of the channel width (`FIB_CHANNEL`).
- **Fibonacci Arcs** — semicircular retracement levels centred on the swing end, normalised by leg bar-width (`FIB_ARCS`).
- **Fibonacci Fan** — three trendlines fanning from a swing start through its 38.2/50/61.8% retracement levels (`FIB_FAN`).
- **Fibonacci Confluence** — densest cluster of retracement levels across recent swing legs (price + strength) (`FIB_CONFLUENCE`).
- **Golden Pocket** — the 0.618-0.65 optimal-trade-entry band of the most recent swing leg (`GOLDEN_POCKET`).
- **Auto-Fibonacci** — retracement anchored on the dominant (largest-magnitude) leg among recent swings (`AUTO_FIB`).
- **Fibonacci Projection** — measured-move target zone from the last three pivots (A-B-C), projecting A->B from C (`FIB_PROJECTION`).
- **Fibonacci Extension** — projects the latest swing leg to the canonical extension ratios (127.2/141.4/161.8/200/261.8%) (`FIB_EXTENSION`).
- **Fibonacci Retracement** — seven retracement levels (0/23.6/38.2/50/61.8/78.6/100%) of the most recent confirmed swing leg (`FIB_RETRACEMENT`).
## [0.5.2] - 2026-06-03
### Added
- **Three Drives** — three symmetric drives with extension legs; bullish +1, bearish -1 (`THREE_DRIVES`).
- **Cypher** — five-point harmonic whose D retraces XC by 0.786; bullish +1, bearish -1 (`CYPHER`).
- **Shark** — five-point harmonic with an expansion leg and 0.886-1.13 D; bullish +1, bearish -1 (`SHARK`).
- **Crab** — five-point harmonic with the deepest (1.618 XA) D completion; bullish +1, bearish -1 (`CRAB`).
- **Bat** — five-point harmonic with a shallow B and 0.886 D completion; bullish +1, bearish -1 (`BAT`).
- **Butterfly** — five-point harmonic with an extended (1.27-1.618 XA) D; bullish +1, bearish -1 (`BUTTERFLY`).
- **Gartley** — five-point harmonic with a 0.786 D completion; bullish +1, bearish -1 (`GARTLEY`).
- **AB=CD** — four-point AB=CD harmonic: BC retraces AB, CD mirrors AB; bullish +1, bearish -1 (`ABCD`).
- **Cup and Handle** — rounded base with a shallow handle near the rim; bullish +1, inverse -1 (`CUP_AND_HANDLE`).
- **Rectangle / Range** — flat support and resistance; mean-reversion signal off the just-touched boundary; support +1, resistance -1 (`RECTANGLE_RANGE`).
- **Flag / Pennant** — shallow consolidation against a sharp pole; continuation in the pole direction; bull +1, bear -1 (`FLAG_PENNANT`).
- **Wedge (rising/falling)** — both trendlines slope the same way but converge; rising wedge -1, falling wedge +1 (`WEDGE`).
- **Triangle (asc/desc/sym)** — converging trendlines; ascending +1, descending -1, symmetrical follows the last swing (`TRIANGLE`).
- **Head and Shoulders** — central head flanked by two matching shoulders over a flat neckline; top -1, inverse +1 (`HEAD_AND_SHOULDERS`).
- **Triple Top / Bottom** — three matching peaks / troughs; a stronger reversal than the double; bearish -1, bullish +1 (`TRIPLE_TOP_BOTTOM`).
- **Double Top / Bottom** — twin-peak / twin-trough reversal confirmed on the second matching swing extreme; bearish -1, bullish +1 (`DOUBLE_TOP_BOTTOM`).
## [0.5.1] - 2026-06-03
### Added — Seasonality & Session family (12 indicators)
- **Volume-by-Time Profile** — mean traded volume bucketed by intraday time (`VOLUME_BY_TIME_PROFILE`).
- **Intraday Volatility Profile** — return standard deviation bucketed by intraday time (`INTRADAY_VOLATILITY_PROFILE`).
- **Day-of-Week Profile** — mean bar return bucketed by weekday (`DAY_OF_WEEK_PROFILE`).
- **Time-of-Day Return Profile** — mean bar return bucketed by intraday time (`TIME_OF_DAY_RETURN_PROFILE`).
- **Seasonal Z-Score** — z-score of the current return versus the same hour-of-day history (`SEASONAL_Z_SCORE`).
- **Turn-of-Month** — mean daily return inside the turn-of-month window (`TURN_OF_MONTH`).
- **Overnight/Intraday Return** — decomposition of session return into overnight and intraday legs (`OVERNIGHT_INTRADAY_RETURN`).
- **Overnight Gap** — close-to-open return across the session boundary (`OVERNIGHT_GAP`).
- **Average Daily Range** — mean high-low range of the last N completed sessions (`AVERAGE_DAILY_RANGE`).
- **Session Range** — per-session (Asia/EU/US) high-low range (`SESSION_RANGE`).
- **Session High/Low** — running high and low of the current session (`SESSION_HIGH_LOW`).
- **Session VWAP** — session-anchored volume-weighted average price (`SESSION_VWAP`).
## [0.5.0] - 2026-06-03
### Added
- **TICK Index** — instantaneous net advancing-minus-declining issues (`TICK_INDEX`).
- **Absolute Breadth Index** — absolute value of net advancing-minus-declining issues (`ABSOLUTE_BREADTH_INDEX`).
- **Cumulative Volume Index** — running total of volume-normalised net advancing volume (`CUMULATIVE_VOLUME_INDEX`).
- **Bullish Percent Index** — percentage of the universe on a point-and-figure buy signal (`BULLISH_PERCENT_INDEX`).
- **Up/Down Volume Ratio** — advancing volume divided by declining volume (`UP_DOWN_VOLUME_RATIO`).
- **Percent Above Moving Average** — percentage of the universe trading above its reference moving average (`PERCENT_ABOVE_MA`).
- **High-Low Index** — moving average of the record-high percentage (`HIGH_LOW_INDEX`).
- **New Highs - New Lows** — net count of new period highs minus new period lows (`NEW_HIGHS_NEW_LOWS`).
- **Breadth Thrust** — moving average of the advancing-issues share (Zweig) (`BREADTH_THRUST`).
- **TRIN / Arms Index** — advance-decline ratio divided by the up-down volume ratio (`TRIN`).
- **McClellan Summation Index** — running cumulative total of the McClellan Oscillator (`MCCLELLAN_SUMMATION_INDEX`).
- **McClellan Oscillator** — spread between a 19- and 39-period EMA of ratio-adjusted net advances (`MCCLELLAN_OSCILLATOR`).
- **Advance/Decline Volume Line** — cumulative net advancing-minus-declining volume across the universe (`AD_VOLUME_LINE`).
- **Advance/Decline Ratio** — advancing issues divided by declining issues across the universe (`ADVANCE_DECLINE_RATIO`).
### Changed
- **Relicensed** from PolyForm Noncommercial 1.0.0 to dual **MIT OR Apache-2.0**. Wickra is now OSI-approved, permissive open source; commercial use is permitted under either license. See [`LICENSE-MIT`](LICENSE-MIT) and [`LICENSE-APACHE`](LICENSE-APACHE).
## [0.4.7] - 2026-06-03
### Added
- **Spread Bollinger Bands** — Bollinger bands on the spread of two series for pairs mean-reversion (`SPREAD_BOLLINGER_BANDS`).
- **Kalman Hedge Ratio** — Kalman-filter dynamic hedge ratio and spread between two series (`KALMAN_HEDGE_RATIO`).
- **Granger Causality** — Granger causality F-statistic measuring whether one series predicts another (`GRANGER_CAUSALITY`).
- **Variance Ratio** — Lo-MacKinlay variance-ratio test on the spread of two series (`VARIANCE_RATIO`).
- **Beta-Neutral Spread** — beta-neutral spread: the rolling OLS regression residual of two series (`BETA_NEUTRAL_SPREAD`).
- **Distance SSD** — Gatev sum-of-squared-deviations distance between two normalised series (`DISTANCE_SSD`).
- **Spread Hurst** — Hurst exponent of the spread of two series for regime detection (`SPREAD_HURST`).
- **OU Half-Life** — Ornstein-Uhlenbeck half-life of mean reversion for the spread of two series (`OU_HALF_LIFE`).
- **Rolling Covariance** — rolling covariance of the period-over-period returns of two series (`ROLLING_COVARIANCE`).
- **Rolling Correlation** — rolling Pearson correlation of the period-over-period returns of two series (`ROLLING_CORRELATION`).
- **Market Breadth family** — a new indicator family built on a new
`CrossSection` input type that carries the per-symbol state of an entire
universe in one tick (each `Member` holds a signed `change`, a `volume`, and
`new_high` / `new_low` flags). `CrossSection::new` validates the universe
(non-empty, finite changes, finite non-negative volumes); `new_unchecked`
skips validation for hot paths.
- `AdvanceDecline` (`ADVANCE_DECLINE`) — the Advance/Decline Line, the running
cumulative sum of net advancing-minus-declining issues across the universe.
## [0.4.6] - 2026-06-03
### Added
- **TA-Lib parity — Directional Movement components** — the ADX building blocks,
previously available only bundled inside `Adx`, as standalone single-output
indicators:
- `PlusDm` (`PLUS_DM`) — Wilder-smoothed plus directional movement.
- `MinusDm` (`MINUS_DM`) — Wilder-smoothed minus directional movement.
- `PlusDi` (`PLUS_DI`) — plus directional indicator, `100 · smoothed(+DM) / ATR`.
- `MinusDi` (`MINUS_DI`) — minus directional indicator, `100 · smoothed(-DM) / ATR`.
- `Dx` (`DX`) — directional movement index, `100 · |+DI DI| / (+DI + DI)`.
- **TA-Lib parity — price transforms** — window and per-bar price aggregates:
- `MidPrice` (`MIDPRICE`) — `(highest high + lowest low) / 2` over a window.
- `MidPoint` (`MIDPOINT`) — `(max + min) / 2` of a scalar series over a window.
- `AvgPrice` (`AVGPRICE`) — per-bar `(open + high + low + close) / 4`.
- **TA-Lib parity — rate-of-change variants** — the ratio forms of `Roc`:
- `Rocp` (`ROCP`) — `(close close[period]) / close[period]` (fraction).
- `Rocr` (`ROCR`) — `close / close[period]` (ratio).
- `Rocr100` (`ROCR100`) — `close / close[period] · 100`.
- **TA-Lib parity — linear-regression outputs** — the remaining OLS endpoints:
- `LinRegIntercept` (`LINEARREG_INTERCEPT`) — the OLS intercept `a`.
- `Tsf` (`TSF`) — time series forecast, `a + b·period` (one bar ahead).
- **TA-Lib parity — `MacdFix` (`MACDFIX`)** — MACD with fast/slow fixed at 12/26
and only the signal period configurable; output is the usual `{macd, signal,
histogram}` triple.
- **TA-Lib parity — `SarExt` (`SAREXT`)** — Parabolic SAR with a start value,
reversal offset, independent long/short acceleration, and a signed output
(positive in long phases, negative in short phases).
- **TA-Lib parity — `MacdExt` (`MACDEXT`)** — MACD with an independently
selectable moving-average type (new `MaType` enum: SMA/EMA/WMA/DEMA/TEMA/TRIMA)
for each of the fast, slow and signal lines.
- **TA-Lib parity — `HtPhasor` (`HT_PHASOR`)** — the in-phase and quadrature
components of the Hilbert-transform analytic signal, as a `{inphase,
quadrature}` pair.
- **TA-Lib parity — `HtDcPhase` (`HT_DCPHASE`)** — the phase angle (in degrees)
of the Hilbert-transform dominant cycle.
- **TA-Lib parity — `HtTrendMode` (`HT_TRENDMODE`)** — Ehlers' trend (`1`) vs
cycle (`0`) classification from the Hilbert-transform dominant cycle.
## [0.4.5] - 2026-06-02
### Added
- **Anchored RSI** — a cumulative Relative Strength Index whose averaging begins at a runtime-chosen anchor bar (`set_anchor`), the momentum counterpart to Anchored VWAP. Every up- and down-move since the anchor is weighted equally, so it reports the RSI of the entire move since the anchor point. Scalar input, Momentum Oscillators family; available in Rust, Python, Node and WASM.
- **Volume Profile** — the full per-bin volume distribution over a rolling window, exposing the raw histogram (price bounds plus per-bin volume) that Value Area reduces to POC/VAH/VAL. Market Profile family; candle input, available in Rust, Python, Node and WASM.
- **TPO Profile** — the Time-Price-Opportunity (market-profile letter) distribution: a volume-agnostic count of how many periods traded at each price level over a rolling window. Market Profile family; candle input, available in Rust, Python, Node and WASM.
- **Alt-Chart Bars** — a new `BarBuilder` trait and family of price-driven chart constructors that emit a variable number of completed bars per candle (so they are deliberately not `Indicator`s): **Renko** (fixed box-size bricks with the 2-box reversal rule), **Kagi** (reversal-amount line segments), and **Point & Figure** (box-size X/O columns with an N-box reversal). Available in Rust, Python, Node and WASM.
## [0.4.4] - 2026-06-02
### Added
- **TA-Lib candlestick patterns (part 1).** New candlestick pattern detectors
matching TA-Lib `CDL*`, emitting the family's signed `+1 / 0 / 1` convention
over OHLCV candles in Rust, Python, Node and WASM:
- **Two Crows** — a three-bar bearish reversal (`CDL2CROWS`): a long white
candle, a black candle whose body gaps up, then a black candle that opens
inside the second's body and closes inside the first's.
- **Upside Gap Two Crows** — a three-bar bearish reversal
(`CDLUPSIDEGAP2CROWS`): two black candles gap up over a long white candle,
the second engulfing the first crow yet still closing above the white body,
leaving the upside gap open.
- **Identical Three Crows** — a three-bar bearish reversal
(`CDLIDENTICAL3CROWS`): three red candles with steadily lower closes, each
opening at the prior candle's close so the bodies stack in an identical
staircase.
- **Three Line Strike** — a four-bar pattern (`CDL3LINESTRIKE`): a
three-candle advance or decline struck by a fourth opposite-colour candle
that engulfs the entire run; bullish `+1`, bearish `1`.
- **Three Stars in the South** — a rare three-bar bullish reversal
(`CDL3STARSINSOUTH`): three shrinking red candles each carving a higher low
and contracting toward a tiny black marubozu as selling exhausts.
- **Abandoned Baby** — a strong three-bar reversal (`CDLABANDONEDBABY`): a doji
isolated by price gaps on both sides; bullish `+1` after a decline, bearish
`1` after an advance.
- **Advance Block** — a three-bar bearish warning (`CDLADVANCEBLOCK`): three
green candles to higher closes whose bodies shrink as their upper shadows
lengthen, signalling the advance is stalling.
- **Belt-hold** — a single-bar reversal that opens at one extreme of its range and runs the other way; bullish +1, bearish -1 (`CDLBELTHOLD`).
- **Breakaway** — a 5-bar reversal that gaps with the trend, drifts two more bars, then snaps back into the bar1/bar2 body gap; bullish +1, bearish -1 (`CDLBREAKAWAY`).
- **Counterattack** — a 2-bar reversal where an opposite-coloured second bar closes level with the first (the counterattack line); bullish +1, bearish -1 (`CDLCOUNTERATTACK`).
- **Doji Star** — a long body followed by a doji gapping away in the trend direction; bullish +1, bearish -1 (`CDLDOJISTAR`).
- **Dragonfly Doji** — a doji opening and closing at the high with a long lower shadow, a bullish reversal; +1 (`CDLDRAGONFLYDOJI`).
- **Gravestone Doji** — a doji opening and closing at the low with a long upper shadow, a bearish reversal; -1 (`CDLGRAVESTONEDOJI`).
- **Long-Legged Doji** — a doji with long shadows on both sides, an indecision signal; +1 detection (`CDLLONGLEGGEDDOJI`).
- **Rickshaw Man** — a long-legged doji with the body centred in the range, an indecision signal; +1 detection (`CDLRICKSHAWMAN`).
- **Evening Doji Star** — a bearish top reversal: long white bar, a doji gapping up, then a black bar closing deep into the first body; -1 (`CDLEVENINGDOJISTAR`).
- **Morning Doji Star** — a bullish bottom reversal: long black bar, a doji gapping down, then a white bar closing deep into the first body; +1 (`CDLMORNINGDOJISTAR`).
- **Gap Side-by-Side White** — two similar white candles opening side by side after a gap, a continuation; gap up +1, gap down -1 (`CDLGAPSIDESIDEWHITE`).
- **High-Wave** — a small body with very long shadows on both sides, an extreme indecision signal; +1 detection (`CDLHIGHWAVE`).
- **Hikkake** — an inside bar followed by a failed breakout, a trap; bullish +1, bearish -1 (`CDLHIKKAKE`).
- **Modified Hikkake** — a close-confirmed Hikkake: an inside bar then a failed breakout closing back inside; bullish +1, bearish -1 (`CDLHIKKAKEMOD`).
- **Homing Pigeon** — two black candles, the second a small body inside the first, a bullish reversal; +1 (`CDLHOMINGPIGEON`).
- **On-Neck** — a long black candle then a white candle closing at its low (the neckline), a bearish continuation; -1 (`CDLONNECK`).
- **In-Neck** — a long black candle then a white candle closing just into its body, a bearish continuation; -1 (`CDLINNECK`).
- **Thrusting** — a long black candle then a white candle closing well into but below the midpoint of its body, a bearish continuation; -1 (`CDLTHRUSTING`).
- **Separating Lines** — opposite-coloured candles sharing the same open, the second an opening marubozu resuming the trend; bullish +1, bearish -1 (`CDLSEPARATINGLINES`).
- **Kicking** — two opposite-coloured marubozu separated by a gap; bullish +1, bearish -1 (`CDLKICKING`).
- **Kicking by Length** — a kicking pattern signalled by the colour of the longer marubozu; +1 / -1 (`CDLKICKINGBYLENGTH`).
- **Ladder Bottom** — three descending black candles, a fourth with an upper shadow, then a white candle gapping up, a bullish reversal; +1 (`CDLLADDERBOTTOM`).
- **Mat Hold** — a long white candle, a holding three-bar pullback, then a new-high white candle, a bullish continuation; +1 (`CDLMATHOLD`).
- **Matching Low** — a 2-bar bullish reversal where two black candles in a decline share the same close, signalling selling pressure is exhausting; bullish +1 (`CDLMATCHINGLOW`).
- **Long Line** — a single long-bodied candle with short shadows; bullish +1 (white) or bearish -1 (black) by colour (`CDLLONGLINE`).
- **Short Line** — a single short-bodied candle with short shadows; bullish +1 (white) or bearish -1 (black) by colour (`CDLSHORTLINE`).
- **Rising Three Methods** — a 5-bar bullish continuation: a long white candle, three small pullback bars holding within its range, then a white breakout to new highs; bullish +1 (`CDLRISEFALL3METHODS`).
- **Falling Three Methods** — the bearish mirror of rising three methods: a long black candle, three small bars holding within its range, then a black breakdown to new lows; bearish -1 (`CDLRISEFALL3METHODS`).
- **Upside Gap Three Methods** — a 3-bar bullish continuation: two white candles gap up, then a black candle opens within the second body and closes within the first; bullish +1 (`CDLXSIDEGAP3METHODS`).
- **Downside Gap Three Methods** — the bearish mirror of upside gap three methods: two black candles gap down, then a white candle opens within the second body and closes within the first; bearish -1 (`CDLXSIDEGAP3METHODS`).
- **Stalled Pattern** — a 3-bar bearish reversal warning: two long white candles then a small white candle riding the shoulder, signalling the rally is stalling; bearish -1 (`CDLSTALLEDPATTERN`).
- **Stick Sandwich** — a 3-bar bullish reversal: two black candles closing at the same level sandwich a white candle, marking a support floor; bullish +1 (`CDLSTICKSANDWICH`).
- **Takuri** — a single-bar bullish reversal, a strict Dragonfly Doji with a negligible upper shadow and very long lower shadow; bullish +1 (`CDLTAKURI`).
- **Closing Marubozu** — a single long-bodied candle with no shadow on the close end; bullish +1 (white, closes at the high) or bearish -1 (black, closes at the low) (`CDLCLOSINGMARUBOZU`).
- **Opening Marubozu** — a single long-bodied candle with no shadow on the open end; bullish +1 (white, opens at the low) or bearish -1 (black, opens at the high). No direct TA-Lib equivalent — completes the pair with the closing marubozu.
- **Tasuki Gap** — a 3-bar continuation: two same-coloured candles gap in the trend direction, then an opposite candle opens within the second body and closes back into the gap without filling it; upside +1, downside -1 (`CDLTASUKIGAP`).
- **Unique Three River** — a 3-bar bullish reversal: a long black candle, a black candle probing a new low with its body inside the first, then a small white candle held below it; bullish +1 (`CDLUNIQUE3RIVER`).
- **Concealing Baby Swallow** — a rare 4-bar bullish capitulation: two black marubozu, a black candle gapping down with an upper shadow into the second, then a large black candle engulfing it entirely; bullish +1 (`CDLCONCEALBABYSWALL`).
- **Derivatives family — funding & open interest (part 1).** A new family of
indicators that consume a perpetual / futures tick (`DerivativesTick`,
bundling funding rate, mark / index / futures price, open interest,
positioning, taker flow and liquidations) rather than OHLCV, exposed in Rust,
Python, Node and WASM:
- **Funding Rate** — the current perpetual funding rate.
- **Funding Rate Mean** — the rolling mean funding rate over a window.
- **Funding Rate Z-Score** — the latest funding rate in standard deviations
from its rolling mean.
- **Funding Basis** — the perpetual's relative premium to spot,
`(markPrice indexPrice) / indexPrice`.
- **Open-Interest Delta** — the tick-over-tick change in open interest.
- **Derivatives family — open interest, flow & liquidations (part 2).** More
indicators over the same `DerivativesTick` feed:
- **OI / Price Divergence** — relative open-interest change minus relative
price change over a window, the positioning-vs-price gap.
- **OI-Weighted Price** — the cumulative mark price weighted by open interest.
- **Long/Short Ratio** — aggregate long size over short size.
- **Taker Buy/Sell Ratio** — taker buy volume over taker sell volume.
- **Liquidation Features** — a multi-output breakdown of long/short
liquidation notional into net, total and a bounded imbalance.
- **Derivatives family — basis & term structure (part 3).** The final
perpetual-vs-futures basis indicators over the `DerivativesTick` feed:
- **Term-Structure Basis** — the dated future's relative premium to spot,
`(futuresPrice indexPrice) / indexPrice`.
- **Calendar Spread** — the dated future's relative premium to the perpetual,
`(futuresPrice markPrice) / markPrice`.
## [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
@@ -900,7 +1308,25 @@ 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/wickra-lib/wickra/compare/v0.4.1...HEAD
[Unreleased]: https://github.com/wickra-lib/wickra/compare/v0.6.1...HEAD
[0.6.1]: https://github.com/wickra-lib/wickra/compare/v0.6.0...v0.6.1
[0.6.0]: https://github.com/wickra-lib/wickra/compare/v0.5.9...v0.6.0
[0.5.9]: https://github.com/wickra-lib/wickra/compare/v0.5.8...v0.5.9
[0.5.8]: https://github.com/wickra-lib/wickra/compare/v0.5.7...v0.5.8
[0.5.7]: https://github.com/wickra-lib/wickra/compare/v0.5.6...v0.5.7
[0.5.6]: https://github.com/wickra-lib/wickra/compare/v0.5.5...v0.5.6
[0.5.5]: https://github.com/wickra-lib/wickra/compare/v0.5.4...v0.5.5
[0.5.4]: https://github.com/wickra-lib/wickra/compare/v0.5.3...v0.5.4
[0.5.3]: https://github.com/wickra-lib/wickra/compare/v0.5.2...v0.5.3
[0.5.2]: https://github.com/wickra-lib/wickra/compare/v0.5.1...v0.5.2
[0.5.1]: https://github.com/wickra-lib/wickra/compare/v0.5.0...v0.5.1
[0.5.0]: https://github.com/wickra-lib/wickra/compare/v0.4.7...v0.5.0
[0.4.7]: https://github.com/wickra-lib/wickra/compare/v0.4.6...v0.4.7
[0.4.6]: https://github.com/wickra-lib/wickra/compare/v0.4.5...v0.4.6
[0.4.5]: https://github.com/wickra-lib/wickra/compare/v0.4.4...v0.4.5
[0.4.4]: https://github.com/wickra-lib/wickra/compare/v0.4.3...v0.4.4
[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
+3 -1
View File
@@ -26,4 +26,6 @@ keywords:
- quantitative-finance
- rust
- time-series
license: PolyForm-Noncommercial-1.0.0
license:
- MIT
- Apache-2.0
+45 -7
View File
@@ -5,11 +5,11 @@ build the project, the standards a change must meet, and how to get it merged.
## License of contributions
Wickra is licensed under the **PolyForm Noncommercial License 1.0.0** (see
[`LICENSE`](LICENSE)). By submitting a contribution you agree that it is
licensed to the project under those same terms. The Noncommercial license
permits use for any purpose **other than** a commercial one; keep that in mind
when proposing features or depending on Wickra elsewhere.
Wickra is dual-licensed under the [MIT](LICENSE-MIT) and
[Apache-2.0](LICENSE-APACHE) licenses; users may choose either. Unless you
explicitly state otherwise, any contribution you intentionally submit for
inclusion in the work, as defined in the Apache-2.0 license, shall be dual
licensed as above, without any additional terms or conditions.
## Project layout
@@ -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/` | Pointer to the project Wiki, which holds all documentation. |
| `docs/` | Pointer to the documentation site (docs.wickra.org); the docs live in the `wickra-lib/wickra-docs` repo. |
## Building and testing
@@ -75,7 +75,8 @@ wasm-pack test --node bindings/wasm
| 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) | PyO3 convention: the Python package has no Python runtime dependencies of its own, and its native code is already pinned through the workspace `Cargo.lock`. CI installs build/test tooling (`maturin`, `pytest`, `numpy`, `hypothesis`) directly via `pip`. |
| `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. |
@@ -83,6 +84,13 @@ 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
@@ -114,3 +122,33 @@ the repository root is not an npm package.
Use the issue templates under
[`.github/ISSUE_TEMPLATE`](.github/ISSUE_TEMPLATE). For security-sensitive
reports, follow [`SECURITY.md`](SECURITY.md) instead of opening a public issue.
## Developer Certificate of Origin (DCO)
All contributions to Wickra are made under the [Developer Certificate of
Origin (DCO) 1.1](DCO). By signing off on your commits you certify that you
wrote the patch, or otherwise have the right to submit it under the project's
`MIT OR Apache-2.0` license.
Sign off every commit by adding a `Signed-off-by` trailer with your real name
and email — Git adds it automatically with the `-s` flag:
```bash
git commit -s -m "your message"
```
This produces a trailer of the form:
```
Signed-off-by: Your Name <you@example.com>
```
The name and email must match the commit author. Commits without a valid
sign-off line cannot be merged. To sign off a commit you already made, amend it
with `git commit -s --amend`, or sign off a range with an interactive rebase.
## Governance
Wickra's decision-making and maintainership are described in
[`GOVERNANCE.md`](GOVERNANCE.md); the current maintainers are listed in
[`MAINTAINERS.md`](MAINTAINERS.md).
Generated
+114 -7
View File
@@ -702,6 +702,16 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "kand"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af1f41590bd014ef6c3dd815b45f07deb4c3198e355a4319bb7521b6a3a6aeb5"
dependencies = [
"num_enum",
"thiserror",
]
[[package]]
name = "leb128fmt"
version = "0.1.0"
@@ -911,6 +921,28 @@ dependencies = [
"libm",
]
[[package]]
name = "num_enum"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26"
dependencies = [
"num_enum_derive",
"rustversion",
]
[[package]]
name = "num_enum_derive"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8"
dependencies = [
"proc-macro-crate",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "numpy"
version = "0.28.0"
@@ -1081,6 +1113,15 @@ dependencies = [
"syn",
]
[[package]]
name = "proc-macro-crate"
version = "3.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
dependencies = [
"toml_edit",
]
[[package]]
name = "proc-macro2"
version = "1.0.106"
@@ -1498,6 +1539,12 @@ dependencies = [
"syn",
]
[[package]]
name = "ta"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "609409d472a0a7d8d4dd9e19891bbdef546b9dce670c3057d0e02192dc541226"
[[package]]
name = "target-lexicon"
version = "0.13.5"
@@ -1607,6 +1654,36 @@ dependencies = [
"tungstenite",
]
[[package]]
name = "toml_datetime"
version = "1.1.1+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
dependencies = [
"serde_core",
]
[[package]]
name = "toml_edit"
version = "0.25.12+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7"
dependencies = [
"indexmap",
"toml_datetime",
"toml_parser",
"winnow",
]
[[package]]
name = "toml_parser"
version = "1.1.2+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526"
dependencies = [
"winnow",
]
[[package]]
name = "tungstenite"
version = "0.29.0"
@@ -1867,7 +1944,7 @@ dependencies = [
[[package]]
name = "wickra"
version = "0.4.1"
version = "0.6.1"
dependencies = [
"approx",
"criterion",
@@ -1876,9 +1953,21 @@ dependencies = [
"wickra-data",
]
[[package]]
name = "wickra-bench"
version = "0.6.1"
dependencies = [
"criterion",
"kand",
"ta",
"wickra",
"wickra-data",
"yata",
]
[[package]]
name = "wickra-core"
version = "0.4.1"
version = "0.6.1"
dependencies = [
"approx",
"proptest",
@@ -1888,7 +1977,7 @@ dependencies = [
[[package]]
name = "wickra-data"
version = "0.4.1"
version = "0.6.1"
dependencies = [
"approx",
"csv",
@@ -1905,7 +1994,7 @@ dependencies = [
[[package]]
name = "wickra-examples"
version = "0.0.0"
version = "0.6.1"
dependencies = [
"serde_json",
"tokio",
@@ -1915,7 +2004,7 @@ dependencies = [
[[package]]
name = "wickra-node"
version = "0.4.1"
version = "0.6.1"
dependencies = [
"napi",
"napi-build",
@@ -1925,7 +2014,7 @@ dependencies = [
[[package]]
name = "wickra-python"
version = "0.4.1"
version = "0.6.1"
dependencies = [
"numpy",
"pyo3",
@@ -1934,7 +2023,7 @@ dependencies = [
[[package]]
name = "wickra-wasm"
version = "0.4.1"
version = "0.6.1"
dependencies = [
"console_error_panic_hook",
"js-sys",
@@ -1991,6 +2080,15 @@ dependencies = [
"windows-link",
]
[[package]]
name = "winnow"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1"
dependencies = [
"memchr",
]
[[package]]
name = "wit-bindgen"
version = "0.51.0"
@@ -2091,6 +2189,15 @@ version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
[[package]]
name = "yata"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b4ef8ddfa3ccd93454262c0e60a43a2bbf403d404174e1815f7581d5028229f"
dependencies = [
"serde",
]
[[package]]
name = "yoke"
version = "0.8.2"
+4 -3
View File
@@ -8,15 +8,16 @@ members = [
"bindings/wasm",
"bindings/node",
"examples/rust",
"crates/wickra-bench",
]
exclude = ["fuzz"]
[workspace.package]
version = "0.4.1"
version = "0.6.1"
authors = ["kingchenc <support@wickra.org>"]
edition = "2021"
rust-version = "1.86"
license = "PolyForm-Noncommercial-1.0.0"
license = "MIT OR Apache-2.0"
repository = "https://github.com/wickra-lib/wickra"
homepage = "https://github.com/wickra-lib/wickra"
readme = "README.md"
@@ -24,7 +25,7 @@ keywords = ["finance", "trading", "indicators", "technical-analysis", "ta"]
categories = ["finance", "mathematics", "science"]
[workspace.dependencies]
wickra-core = { path = "crates/wickra-core", version = "0.4.1" }
wickra-core = { path = "crates/wickra-core", version = "0.6.1" }
thiserror = "2"
rayon = "1.10"
+34
View File
@@ -0,0 +1,34 @@
Developer Certificate of Origin
Version 1.1
Copyright (C) 2004, 2006 The Linux Foundation and its contributors.
Everyone is permitted to copy and distribute verbatim copies of this
license document, but changing it is not allowed.
Developer's Certificate of Origin 1.1
By making a contribution to this project, I certify that:
(a) The contribution was created in whole or in part by me and I
have the right to submit it under the open source license
indicated in the file; or
(b) The contribution is based upon previous work that, to the best
of my knowledge, is covered under an appropriate open source
license and I have the right under that license to submit that
work with modifications, whether created in whole or in part
by me, under the same open source license (unless I am
permitted to submit under a different license), as indicated
in the file; or
(c) The contribution was provided directly to me by some other
person who certified (a), (b) or (c) and I have not modified
it.
(d) I understand and agree that this project and the contribution
are public and that a record of the contribution (including all
personal information I submit with it, including my sign-off) is
maintained indefinitely and may be redistributed consistent with
this project or the open source license(s) involved.
+71
View File
@@ -0,0 +1,71 @@
# Governance
Wickra is an open-source project maintained under a **single-maintainer
("BDFL") model**. This document describes how decisions are made and how the
project is run, so contributors know what to expect.
## Roles
- **Maintainer.** The maintainer (see [`MAINTAINERS.md`](MAINTAINERS.md)) is
responsible for the project's direction, reviews and merges changes, cuts
releases, and has final say on all technical and project decisions.
- **Contributors.** Anyone who proposes changes via pull requests, files
issues, improves documentation, or otherwise participates. Contributors do
not need any special status to take part.
## Decision-making
- Day-to-day technical decisions (APIs, indicator implementations, refactors)
are made by the maintainer, informed by discussion on issues and pull
requests.
- Proposals are raised as GitHub issues or pull requests. Significant or
breaking changes should be opened as an issue first to agree on the approach
before implementation.
- The maintainer aims to act transparently: rationale for non-trivial decisions
is recorded in the relevant issue, pull request, or commit message.
## Contribution flow
All changes — including the maintainer's own — go through pull requests so that
CI (tests, linting, static analysis) runs against them, and so the change
history is reviewable. Contribution requirements are documented in
[`CONTRIBUTING.md`](CONTRIBUTING.md), including the Developer Certificate of
Origin sign-off that every commit must carry.
## Becoming a maintainer
The project currently has one maintainer. Maintainership may be extended to
contributors who have demonstrated sustained, high-quality involvement, at the
current maintainer's discretion. If the project grows to multiple maintainers,
this document will be updated to describe shared decision-making.
## Continuity and succession
The project is designed to survive the loss of any single individual, so that
issues can be triaged, proposed changes accepted, and releases published within
one week of confirmed loss of the maintainer:
- **Credentials.** All credentials required to operate the project — the
`wickra-lib` GitHub organization, the publishing tokens for crates.io, PyPI
and npm, and the `wickra.org` domain registrar — are stored in a password
manager. A trusted contact (a family member) holds **emergency access** to
that password manager and can obtain these credentials if the maintainer can
no longer continue.
- **Continuity actions.** With that access, the trusted contact (or a delegate
they appoint) can create and close issues, accept pull requests, and publish
releases through the existing CI/CD workflows.
- **Account recovery.** The maintainer's GitHub account has recovery configured,
and ownership of the `wickra-lib` organization can be transferred to a new
maintainer.
- **Legal rights.** Legal rights to the project name and DNS are covered by the
maintainer's estate arrangements.
## Code of conduct
All participants are expected to follow the
[Code of Conduct](CODE_OF_CONDUCT.md).
## Changes to this document
This governance model may evolve as the project grows. Changes are made via
pull request and take effect once merged.
-136
View File
@@ -1,136 +0,0 @@
# PolyForm Noncommercial License 1.0.0
<https://polyformproject.org/licenses/noncommercial/1.0.0>
## Acceptance
In order to get any license under these terms, you must agree
to them as both strict obligations and conditions to all
your licenses.
## Copyright License
The licensor grants you a copyright license for the
software to do everything you might do with the software
that would otherwise infringe the licensor's copyright
in it for any permitted purpose. However, you may
only distribute the software according to [Distribution
License](#distribution-license) and make changes or new works
based on the software according to [Changes and New Works
License](#changes-and-new-works-license).
## Distribution License
The licensor grants you an additional copyright license
to distribute copies of the software. Your license to
distribute covers distributing the software with changes
and new works permitted by [Changes and New Works
License](#changes-and-new-works-license).
## Notices
You must ensure that anyone who gets a copy of any part of
the software from you also gets a copy of these terms or the
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/wickra-lib/wickra)
## Changes and New Works License
The licensor grants you an additional copyright license
to make changes and new works based on the software for any
permitted purpose.
## Patent License
The licensor grants you a patent license for the software that
covers patent claims the licensor can license, or becomes able
to license, that you would infringe by using the software.
## Noncommercial Purposes
Any noncommercial purpose is a permitted purpose.
## Personal Uses
Personal use for research, experiment, and testing for
the benefit of public knowledge, personal study, private
entertainment, hobby projects, amateur pursuits, or religious
observance, without any anticipated commercial application,
is use for a permitted purpose.
## Noncommercial Organizations
Use by any charitable organization, educational institution,
public research organization, public safety or health
organization, environmental protection organization, or
government institution is use for a permitted purpose regardless
of the source of funding or obligations resulting from the
funding.
## Fair Use
You may have "fair use" rights for the software under the
law. These terms do not limit them.
## No Other Rights
These terms do not allow you to sublicense or transfer any of
your licenses to anyone else, or prevent the licensor from
granting licenses to anyone else. These terms do not imply
any other licenses.
## Patent Defense
If you make any written claim that the software infringes or
contributes to infringement of any patent, your patent license
for the software granted under these terms ends immediately. If
your company makes such a claim, your patent license ends
immediately for work on behalf of your company.
## Violations
The first time you are notified in writing that you have
violated any of these terms, or done anything with the software
not covered by your licenses, your licenses can nonetheless
continue if you come into full compliance with these terms,
and take practical steps to correct past violations, within 32
days of receiving notice. Otherwise, all your licenses end
immediately.
## No Liability
***As far as the law allows, the software comes as is, without
any warranty or condition, and the licensor will not be liable
to you for any damages arising out of these terms or the use
or nature of the software, under any kind of legal claim.***
## Definitions
The **licensor** is the individual or entity offering these
terms, and the **software** is the software the licensor makes
available under these terms.
**You** refers to the individual or entity agreeing to these
terms.
**Your company** is any legal entity, sole proprietorship,
or other kind of organization that you work for, plus all
organizations that have control over, are under the control
of, or are under common control with that organization.
**Control** means ownership of substantially all the assets
of an entity, or the power to direct its management and
policies by vote, contract, or otherwise. Control can be
direct or indirect.
**Your licenses** are all the licenses granted to you for the
software under these terms.
**Use** means anything you do with the software requiring one
of your licenses.
---
Required Notice: Copyright 2026 kingchenc (https://github.com/wickra-lib/wickra)
+201
View File
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or Derivative
Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2026 kingchenc and the Wickra contributors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 kingchenc and the Wickra contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+201
View File
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or Derivative
Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2026 kingchenc and the Wickra contributors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 kingchenc and the Wickra contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+17
View File
@@ -0,0 +1,17 @@
# Maintainers
This file lists the current maintainers of Wickra. See
[`GOVERNANCE.md`](GOVERNANCE.md) for what the role entails and how the project
is run.
| Maintainer | GitHub | Areas |
| --- | --- | --- |
| kingchenc | [@kingchenc](https://github.com/kingchenc) | All (core, bindings, CI/release, docs) |
## Contacting the maintainers
- General questions and support: see [`SUPPORT.md`](SUPPORT.md).
- Bug reports and feature requests: open an issue using the
[issue templates](.github/ISSUE_TEMPLATE).
- Security reports: follow [`SECURITY.md`](SECURITY.md) — do **not** open a
public issue.
+164 -80
View File
@@ -1,5 +1,5 @@
<p align="center">
<a href="https://wickra.org"><img src="https://wickra.org/og-banner.webp" alt="Wickra — streaming-first technical indicators" width="100%"></a>
<a href="https://wickra.org"><img src="https://raw.githubusercontent.com/wickra-lib/.github/main/profile/wickra-banner.webp?v=434" alt="Wickra — streaming-first technical indicators" width="100%"></a>
</p>
[![CI](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml/badge.svg)](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml)
@@ -9,8 +9,9 @@
[![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)
[![License: MIT OR Apache-2.0](https://img.shields.io/badge/license-MIT_OR_Apache--2.0-blue)](#license)
[![OpenSSF Scorecard](https://api.securityscorecards.dev/projects/github.com/wickra-lib/wickra/badge)](https://scorecard.dev/viewer/?uri=github.com/wickra-lib/wickra)
[![OpenSSF Best Practices](https://www.bestpractices.dev/projects/13094/badge)](https://www.bestpractices.dev/projects/13094)
[![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)
@@ -47,7 +48,7 @@ Full documentation lives at **[docs.wickra.org](https://docs.wickra.org)**:
[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 219 indicators; start at the
every one of the 434 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),
@@ -59,105 +60,171 @@ Full documentation lives at **[docs.wickra.org](https://docs.wickra.org)**:
## 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:
Wickra started as a personal itch. The existing TA libraries never quite fit the
projects I was building, so I decided to build one from the ground up — partly to
learn, partly because I genuinely enjoy taking something that already exists and
trying to do it differently (and, ideally, better). It's open source because the
useful version of that itch is the one other people can build on too.
| 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 |
Plenty of TA libraries are fast. Each one forces a trade-off Wickra does not:
Wickra is the only library that combines all of: clean install, streaming,
multi-language reach, and active maintenance.
| Library | Install | Streaming | Languages | Indicators | Active |
|------------------|-------------|-------------|-----------------------------|-----------:|--------|
| **★&nbsp;Wickra**| **clean** | **yes, O(1)** | **Python · Node · WASM · Rust** | **423** | **yes** |
| kand | clean | yes | Python · WASM · Rust | ~60 | yes |
| ta-rs | clean | yes | Rust only | ~30 | stale |
| yata | clean | partial | Rust only | ~35 | yes |
| TA-Lib | yes (C deps)| no | many bindings | ~150 | barely |
| pandas-ta | clean | no | Python | ~130 | slow |
| finta | clean | no | Python | ~80 | stale |
| talipp | clean | yes | Python | ~40 | yes |
## Benchmark: how much faster is "streaming-first"?
Wickra's edge is **breadth with reach**: 434 indicators that all update in O(1)
per tick and ship natively to Python, Node.js, WebAssembly and Rust from a
single engine.
The numbers below were measured on a single developer workstation and are not
guaranteed to reproduce identically on different hardware — absolute µs values
depend on CPU, memory clock and OS scheduler. Read them as **relative
speedups** between libraries on identical input, not as a universal
performance contract.
**On speed — and why Wickra isn't the fastest.** It deliberately isn't. The
leaner Rust crates (kand, ta-rs) win several of the micro-benchmarks below, and
those losses are shown rather than hidden. The gap is a *choice*, not a ceiling:
every `update` validates its input, runs a real warmup before it emits a value,
and returns an `Option` so a single bad tick can't silently poison the state.
ta-rs, by contrast, hands back a bare `f64` from the first tick with no
validation. If Wickra threw all of that away — raw `f64` out, no checks, no
warmup contract — it would match or beat the leanest crate on every row. It
keeps the guarantees instead, and still wins RSI, Bollinger and ATR against kand.
What no other library matches is the *combination*: catalogue size, native O(1)
streaming, NaN-safety, and four first-class language targets at once.
## Benchmarks
Three comparisons, split by layer and mode. Read them as **relative** speedups
on identical input — absolute µs depend on CPU, memory clock and OS scheduler,
not a universal contract.
- **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
`python -m benchmarks.compare_libraries`. The script auto-detects every
installed peer library and runs them on the same generated inputs as
Wickra. The CI job `cross-library-bench` runs the same script on every
push and uploads the raw report as a build artefact.
Rust 1.92 (release: `lto = "fat"`, `codegen-units = 1`), Python 3.12.
- **Reproduce yourself:**
- Rust core vs Rust crates: `cargo bench -p wickra-bench`
- Python vs Python libs: `pip install -e bindings/python[bench]` then
`python -m benchmarks.compare_libraries` (auto-detects installed peers).
Lower µs/op = faster. Wickra wins every batch category outright, and the
streaming gap widens linearly with how much history a batch-only library has
to recompute on every tick.
### 1. Rust core vs the other Rust TA crates
### Batch — single full pass over a 20 000-bar series
Like-for-like, no language-binding overhead, over a 50 000-bar series (µs for
the whole series, lower = faster). This is the honest engine comparison —
Wickra wins some and loses some, and both are shown.
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.
**Streaming** (one value fed per `update`):
| Indicator | **★&nbsp;Wickra** | finta | talipp |
|---------------------|---------------------|-----------------------------|-------------------------------|
| SMA(20) | **95.6 µs ★** | 343.5 µs (3.6× slower) | 7 640.6 µs (79.9× slower) |
| EMA(20) | **64.6 µs ★** | 223.1 µs (3.5× slower) | 12 160.9 µs (188.2× slower) |
| RSI(14) | **126.2 µs ★** | 1 107.1 µs (8.8× slower) | 15 792.2 µs (125.1× slower) |
| MACD(12, 26, 9) | **119.0 µs ★** | 531.8 µs (4.5× slower) | 49 788.1 µs (418.2× slower) |
| Bollinger(20, 2.0) | **105.3 µs** | 812.0 µs (7.7× slower) | 130 938.3 µs (1 243.7× slower)|
| ATR(14) | **123.5 µs ★** | 5 144.8 µs (41.7× slower) | 28 816.0 µs (233.4× slower) |
| Indicator | **★&nbsp;Wickra** | kand | ta-rs | yata |
|------------------|------------------:|-----:|------:|-----:|
| SMA(20) | 50 | 38 | 47 | 38 |
| EMA(20) | 154 | 69 | 56 | 69 |
| RSI(14) | 164 | 216 | 74 | — |
| MACD(12, 26, 9) | 275 | 143 | 66 | — |
| Bollinger(20, 2) | **128** | 248 | 168 | — |
| ATR(14) | 152 | 166 | 61 | — |
### Streaming — per-tick latency after seeding with 5 000 historical bars
**Batch** (whole series at once). Only Wickra and kand expose a batch API;
ta-rs and yata are streaming-only.
A batch-only library has to re-run its full indicator over the entire history on
every new tick; Wickra updates state in O(1).
| Indicator | **★&nbsp;Wickra** | kand |
|------------------|------------------:|-----:|
| SMA(20) | 82 | 42 |
| EMA(20) | 159 | 74 |
| RSI(14) | **253 ★** | 274 |
| MACD(12, 26, 9) | 681 | 283 |
| Bollinger(20, 2) | **445 ★** | 462 |
| ATR(14) | 175 | 173 |
| Indicator | **★&nbsp;Wickra (per tick)** | talipp (per tick) |
|-----------|---------------------|---------------------------|
| RSI(14) | **0.119 µs ★** | 1.644 µs (13.8× slower) |
ta-rs is the per-indicator speed champion on almost every row — it returns a
bare `f64` with no warmup state and no input validation, trading away the
`None`-warmup and NaN-safety semantics Wickra keeps. Against kand, Wickra wins
streaming RSI, Bollinger and ATR (and batch RSI + Bollinger); Bollinger is the
one row where Wickra is the outright fastest of all four. The leaner crates
still win the pure recurrences (EMA, MACD) and SMA. yata exposes only SMA/EMA as
raw-value methods, so its other rows are omitted rather than faked.
> TA-Lib and pandas-ta are not included here because both fail to install
> cleanly on Windows without C build tooling — which is precisely the install
> pain Wickra was built to remove. The benchmark script auto-detects every
> peer library it can find and runs them on the same inputs as Wickra; install
> them in your environment to see those rows light up too.
### 2. Python vs the Python TA ecosystem — batch
Full pass over a 20 000-bar series, µs/op (lower = faster). **★** per row.
| Indicator | **★&nbsp;Wickra** | finta | TA-Lib | tulipy |
|------------------|------------------:|---------------------|--------|--------|
| SMA(20) | **59.6 ★** | 354.2 (5.9× slower) | ⧗ | ⧗ |
| EMA(20) | **88.4 ★** | 309.3 (3.5× slower) | ⧗ | ⧗ |
| RSI(14) | **77.3 ★** | 1 283 (16.6× slower)| ⧗ | ⧗ |
| MACD(12, 26, 9) | **116.4 ★** | 529.5 (4.6× slower) | ⧗ | ⧗ |
| Bollinger(20, 2) | **146.0 ★** | 1 246 (8.5× slower) | ⧗ | ⧗ |
| ATR(14) | **135.8 ★** | 3 812 (28× slower) | ⧗ | ⧗ |
> ⧗ = published by the CI Linux job. TA-Lib and tulipy ship C extensions that
> don't build cleanly on every desktop, so their canonical numbers come from the
> `cross-library-bench` workflow rather than this local table. pandas-ta needs
> Python ≥ 3.12 and isn't in the 3.11 CI matrix. The script auto-detects
> whichever peers are installed in your environment.
### 3. Python — streaming (per-tick latency)
Seed 5 000 bars, then feed ticks one at a time. talipp is the only Python peer
with a true incremental API; batch-only libraries like TA-Lib must recompute the
entire history on every tick — Wickra updates in O(1).
| Indicator | **★&nbsp;Wickra (per tick)** | talipp (per tick) |
|------------------|------------------------------:|-------------------------|
| SMA(20) | **0.067 µs ★** | 0.63 µs (9.4× slower) |
| EMA(20) | **0.051 µs ★** | 0.63 µs (12.2× slower) |
| RSI(14) | **0.053 µs ★** | 1.00 µs (19.1× slower) |
| MACD(12, 26, 9) | **0.071 µs ★** | 3.64 µs (51.5× slower) |
| Bollinger(20, 2) | **0.085 µs ★** | 4.87 µs (57.2× slower) |
Run the suite yourself:
```bash
pip install -e bindings/python[bench]
cargo bench -p wickra-bench # Rust core vs kand / ta-rs / yata
pip install -e bindings/python[bench] # Python peers
python -m benchmarks.compare_libraries
```
## Indicators
219 streaming-first indicators across sixteen families. Every one passes the
434 streaming-first indicators across twenty-four families. Every one passes the
`batch == streaming` equivalence test, reference-value tests, and reset
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, 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 |
| Moving Averages | SMA, EMA, WMA, DEMA, TEMA, HMA, KAMA, SMMA, TRIMA, ZLEMA, T3, VWMA, ALMA, McGinley Dynamic, FRAMA, VIDYA, JMA, Alligator, EVWMA, SWMA, GMA, EHMA, Median MA, Adaptive Laguerre, GD, Holt-Winters |
| Momentum Oscillators | RSI (Wilder), Anchored RSI, Stochastic, CCI, ROC, Williams %R, MFI, Awesome Oscillator, MOM, CMO, TSI, PMO, StochRSI, Ultimate Oscillator, RVI, PGO, KST, SMI, Laguerre RSI, Connors RSI, Inertia, ROC Percentage (ROCP), ROC Ratio (ROCR), ROC Ratio 100 (ROCR100), Disparity Index, Fisher RSI, RSX, Dynamic Momentum Index, Stochastic CCI, RMI, Derivative Oscillator, Elder Ray, Intraday Momentum Index, QQE |
| Trend & Directional | MACD, MACD Fixed (MACDFIX), MACD Extended (MACDEXT), 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, Plus DM, Minus DM, Plus DI, Minus DI, DX, TTM Trend, Trend Strength Index, Qstick, Polarized Fractal Efficiency, Wave PM, Gator Oscillator, Kase Permission Stochastic |
| Price Oscillators | PPO, DPO, Coppock, Accelerator Oscillator, Balance of Power, APO, AO Histogram, CFO, Zero-Lag MACD, Elder Impulse, STC, TSF Oscillator, MACD Histogram, PPO Histogram |
| 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, Volatility Cone |
| 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, Quartile Bands, Bomar Bands, Median Channel, Projection Bands, Projection Oscillator |
| Trailing Stops | Parabolic SAR, Parabolic SAR Extended (SAREXT), 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 |
| 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, Mid Price, Mid Point, Average Price, Linear Regression Intercept, Time Series Forecast, Rolling Correlation, Rolling Covariance, OU Half-Life, Spread Hurst, Distance SSD, Beta-Neutral Spread, Variance Ratio, Granger Causality, Kalman Hedge Ratio, Spread Bollinger Bands, Spread AR(1) Coefficient |
| Ehlers / Cycle (DSP) | MAMA, FAMA, Fisher Transform, Inverse Fisher Transform, SuperSmoother, Hilbert Dominant Cycle, Hilbert Phasor, Hilbert DC Phase, Hilbert Trend Mode, Sine Wave, Decycler, Decycler Oscillator, Roofing Filter, Center of Gravity, Cybernetic Cycle, Adaptive Cycle, Empirical Mode Decomposition, Ehlers Stochastic, Instantaneous Trendline |
| Pivots & S/R | Classic Pivots, Fibonacci Pivots, Camarilla, Woodie Pivots, DeMark Pivots, Williams Fractals, ZigZag |
| DeMark | TD Setup, TD Sequential, TD DeMarker, TD REI, TD Pressure, TD Combo, TD Countdown, TD Lines, TD Range Projection, TD Differential, TD Open, TD Risk Level |
| Ichimoku & Charts | Ichimoku Kinko Hyo (Tenkan, Kijun, Senkou A/B, Chikou), Heikin-Ashi |
| Candlestick Patterns | Doji, Hammer, Inverted Hammer, Hanging Man, Shooting Star, Engulfing, Harami, Morning/Evening Star, Three White Soldiers/Black Crows, Piercing Line/Dark Cloud Cover, Marubozu, Tweezer, Spinning Top, Three Inside Up/Down, Three Outside Up/Down |
| Market Profile | Value Area (POC / VAH / VAL), Initial Balance, Opening Range |
| Alt-Chart Bars | Renko (box-size bricks), Kagi (reversal-amount lines), Point & Figure (X/O columns) |
| 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, Two Crows, Upside Gap Two Crows, Identical Three Crows, Three Line Strike, Three Stars in the South, Abandoned Baby, Advance Block, Belt-hold, Breakaway, Counterattack, Doji Star, Dragonfly Doji, Gravestone Doji, Long-Legged Doji, Rickshaw Man, Evening Doji Star, Morning Doji Star, Gap Side-by-Side White, High-Wave, Hikkake, Modified Hikkake, Homing Pigeon, On-Neck, In-Neck, Thrusting, Separating Lines, Kicking, Kicking by Length, Ladder Bottom, Mat Hold, Matching Low, Long Line, Short Line, Rising Three Methods, Falling Three Methods, Upside Gap Three Methods, Downside Gap Three Methods, Stalled Pattern, Stick Sandwich, Takuri, Closing Marubozu, Opening Marubozu, Tasuki Gap, Unique Three River, Concealing Baby Swallow |
| Chart Patterns | Double Top / Bottom, Triple Top / Bottom, Head and Shoulders, Triangle (asc/desc/sym), Wedge (rising/falling), Flag / Pennant, Rectangle / Range, Cup and Handle |
| Harmonic Patterns | AB=CD, Gartley, Butterfly, Bat, Crab, Shark, Cypher, Three Drives |
| Fibonacci | Fibonacci Retracement, Fibonacci Extension, Fibonacci Projection, Auto-Fibonacci, Golden Pocket, Fibonacci Confluence, Fibonacci Fan, Fibonacci Arcs, Fibonacci Channel, Fibonacci Time Zones |
| 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, Order Flow Imbalance, VPIN, Amihud Illiquidity, Roll Measure |
| Derivatives | Funding Rate, Funding Rate Mean, Funding Rate Z-Score, Funding Basis, Open-Interest Delta, OI / Price Divergence, OI-Weighted Price, Long/Short Ratio, Taker Buy/Sell Ratio, Liquidation Features, Term-Structure Basis, Calendar Spread |
| Market Profile | Value Area (POC / VAH / VAL), Volume Profile (histogram), TPO Profile, Initial Balance, Opening Range |
| Market Breadth | Advance/Decline Line, Advance/Decline Ratio, Advance/Decline Volume Line, McClellan Oscillator, McClellan Summation Index, TRIN / Arms Index, Breadth Thrust, New Highs - New Lows, High-Low Index, Percent Above Moving Average, Up/Down Volume Ratio, Bullish Percent Index, Cumulative Volume Index, Absolute Breadth Index, TICK Index |
| 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) |
| Seasonality & Session | Session VWAP, Session High/Low, Session Range, Average Daily Range, Overnight Gap, Overnight/Intraday Return, Turn-of-Month, Seasonal Z-Score, Time-of-Day Return Profile, Day-of-Week Profile, Intraday Volatility Profile, Volume-by-Time Profile |
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.
@@ -230,9 +297,10 @@ A Python live-trading example using the public `websockets` package lives at
```
wickra/
├── crates/
│ ├── wickra-core/ core engine + all 219 indicators
│ ├── wickra-core/ core engine + all 434 indicators
│ ├── wickra/ top-level facade crate (publishes on crates.io) + benches/
── wickra-data/ CSV reader, tick aggregator, live exchange feeds
── wickra-data/ CSV reader, tick aggregator, live exchange feeds
│ └── wickra-bench/ internal cross-library benchmark harness (not published)
├── bindings/
│ ├── python/ PyO3 + maturin (publishes on PyPI)
│ ├── node/ napi-rs (publishes on npm)
@@ -246,9 +314,10 @@ wickra/
└── .github/workflows/ CI and release pipelines
```
Rust benchmarks live in `crates/wickra/benches/`; runnable Rust examples live
in the workspace member crate at `examples/rust/`. There is no top-level
`benches/` directory.
Wickra's own regression benchmarks live in `crates/wickra/benches/`; the
cross-library comparison against kand, ta-rs and yata lives in the internal
`crates/wickra-bench/` crate. Runnable Rust examples live in the workspace member
crate at `examples/rust/`. There is no top-level `benches/` directory.
## Building everything from source
@@ -256,7 +325,8 @@ in the workspace member crate at `examples/rust/`. There is no top-level
# Rust core + tests
cargo test --workspace
cargo clippy --workspace --all-targets -- -D warnings
cargo bench -p wickra
cargo bench -p wickra # Wickra's own regression benchmarks
cargo bench -p wickra-bench # cross-library comparison (kand, ta-rs, yata)
# Python binding (requires Rust toolchain + maturin)
cd bindings/python
@@ -314,13 +384,20 @@ shape together before you invest the time.
## License
Licensed under the **PolyForm Noncommercial License 1.0.0**. See [LICENSE](LICENSE).
Licensed under either of
In plain English: use it, fork it, modify it, redistribute it, file issues, send
pull requests — all welcome. Personal projects, research, education, non-profits,
government, hobby trading bots: all fine. The one thing that's not allowed is
commercial sale of the software or of services built around it. If you want to
use Wickra commercially, get in touch about a license.
- Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE) or
<http://www.apache.org/licenses/LICENSE-2.0>)
- MIT license ([LICENSE-MIT](LICENSE-MIT) or <http://opensource.org/licenses/MIT>)
at your option. Use it, fork it, modify it, redistribute it — commercially or
not — file issues, send pull requests; all welcome.
### Contribution
Unless you explicitly state otherwise, any contribution intentionally submitted
for inclusion in the work by you, as defined in the Apache-2.0 license, shall be
dual licensed as above, without any additional terms or conditions.
## Disclaimer
@@ -349,3 +426,10 @@ The library is provided **as is**, without warranty of any kind; see
<p align="center">
If Wickra saved you time, the cheapest way to say thanks is to ⭐ the repo.
</p>
<p align="center">
<a href="https://star-history.com/#wickra-lib/wickra&Date">
<img alt="Wickra star history" width="640"
src="https://api.star-history.com/svg?repos=wickra-lib/wickra&type=Date&theme=dark">
</a>
</p>
+36
View File
@@ -0,0 +1,36 @@
# Roadmap
This roadmap describes the project's direction at a high level. It is
intentionally non-binding: priorities shift with feedback and available time,
and the authoritative, up-to-date view of planned work is the
[issue tracker](https://github.com/wickra-lib/wickra/issues). Shipped changes
are recorded in [`CHANGELOG.md`](CHANGELOG.md).
## Status
Wickra is **pre-1.0**. The public API is largely stable but may still change in
minor releases; breaking changes are called out in the changelog.
## Themes
- **Indicator coverage.** Continue broadening the indicator catalogue across
families (trend, momentum, volatility, volume, statistics, market profile,
and more), each with the same streaming/batch parity and test guarantees.
- **API stabilization toward 1.0.** Settle the public `Indicator` and
`BarBuilder` traits and the binding surfaces, then commit to semantic
versioning stability for a 1.0 release.
- **Performance.** Keep per-tick updates O(1) and maintain the benchmark suite;
investigate further allocation and cache improvements.
- **Bindings parity.** Keep the Python, Node.js and WebAssembly bindings in
lockstep with the Rust core, including type stubs and platform coverage.
- **Documentation.** Maintain a deep-dive page per indicator on
<https://docs.wickra.org>, plus quickstarts and cookbook material.
- **Project health.** Maintain test coverage, static and dynamic analysis,
signed releases, and supply-chain monitoring.
## How to influence the roadmap
Open or comment on an issue, or start with the
[feature-request template](.github/ISSUE_TEMPLATE/feature_request.md).
Well-scoped proposals and pull requests are the most effective way to move an
item forward.
+99 -3
View File
@@ -2,13 +2,13 @@
## Supported versions
Wickra is pre-1.0. Security fixes are applied to the latest released `0.1.x`
Wickra is pre-1.0. Security fixes are applied to the latest released `0.5.x`
version only; please upgrade to the newest release before reporting an issue.
| Version | Supported |
| --- | --- |
| 0.1.x (latest) | :white_check_mark: |
| older 0.1.x | :x: |
| 0.5.x (latest) | :white_check_mark: |
| older 0.5.x | :x: |
## Reporting a vulnerability
@@ -41,3 +41,99 @@ PyPI/npm packages, and the build/release workflows in `.github/workflows/`.
Out of scope: vulnerabilities in third-party dependencies (report those
upstream; we track them via Dependabot and `cargo-deny`).
## Security assurance case
This is a short, evidence-backed argument for why Wickra can be used safely.
**Security requirements.** Wickra is a computational library: it ingests
numeric market data and produces indicator values. It stores no user
credentials, authenticates no external users, and implements no cryptography of
its own. The requirements are therefore: (1) memory safety and freedom from
undefined behaviour, (2) robust handling of untrusted/degenerate numeric input
without panics or unbounded resource use, (3) integrity of the published
artifacts, and (4) a healthy dependency supply chain.
**How the requirements are met.**
- *Memory safety* — the core and all bindings are written in Rust. The crates
forbid or minimise `unsafe`, so the compiler guarantees memory and thread
safety for the indicator logic.
- *Input robustness* — every indicator validates its parameters and rejects
non-finite inputs at construction; behaviour on edge cases (flat markets,
warmup, reset) is pinned by unit tests, and the public update paths are
exercised by coverage-guided fuzzing (`cargo-fuzz` / libFuzzer) in CI.
- *Static and dynamic analysis* — every push and pull request runs Clippy
(`clippy::pedantic`, warnings-as-errors), CodeQL, fuzzing, and the full test
suite, with 100% line coverage on the core crate tracked by Codecov.
- *Artifact integrity* — releases are built in CI, commits and tags are signed,
the `main` branch requires signed commits, and release artifacts carry build
provenance attestations.
- *Supply chain* — dependencies are pinned and monitored with Dependabot and
audited with `cargo-deny` (license + advisory checks) on every change.
**Residual risk.** The optional `live-binance` feature opens a TLS WebSocket to
an exchange using the platform TLS library; transport security therefore
depends on that library, not on Wickra. Wickra is not a trading system and is
provided "as is" — see the disclaimers in `README.md` and the licenses.
## Secrets management
The project stores **no** secrets or credentials in the version control system.
Secrets required by automation (publishing tokens, the about-sync PAT) are kept
exclusively as **GitHub Actions encrypted secrets** and referenced via the
`secrets.*` context; they are never written to the repository, logs, or build
artifacts. GitHub **secret scanning with push protection** is enabled to block
accidental commits of credentials. Secrets follow least privilege (the narrowest
scope that works) and are rotated when a holder changes or on suspected
exposure.
## Verifying releases
Released artifacts can be verified for integrity and authenticity:
- **Build provenance.** Release assets carry GitHub build provenance
attestations. Verify a downloaded asset with the GitHub CLI:
`gh attestation verify <file> --repo wickra-lib/wickra`.
- **Signed tags.** Each release corresponds to a signed git tag (`vX.Y.Z`);
the tag signature identifies the maintainer who authorised the release.
- **Registry integrity.** Packages are distributed over HTTPS from crates.io,
PyPI and npm, which serve package checksums that package managers verify on
install.
The release is published only by the maintainer through the tag-triggered
release workflow, so a verified tag signature establishes the expected
publisher identity.
## Support timeline and end of support
Wickra is **pre-1.0**: only the **latest released `0.y.z`** version receives
security fixes. When a newer release is published, the previous version
**immediately reaches end of support** and will not receive further fixes;
users should upgrade to the latest release. The supported-versions table above
is authoritative. After the `1.0.0` release this policy will be revised to
support a defined window of releases.
## Remediation policy (dependencies and code scanning)
- **Severity threshold.** Vulnerabilities of **medium severity or higher** in
the project's own code or its dependencies are remediated promptly and before
the next release; lower-severity findings are addressed on a best-effort
basis.
- **Automated enforcement (SCA).** Every change is evaluated by `cargo-deny`
(RUSTSEC advisories + license policy) and Dependabot; a known-vulnerable
dependency fails CI and **blocks the change** until resolved or explicitly
waived with justification.
- **Automated enforcement (SAST).** Every change is evaluated by CodeQL and
Clippy (`-D warnings`); findings **block the change** in CI until fixed.
- **Pre-release gate.** A release is not cut while an unresolved medium-or-higher
SCA/SAST finding is outstanding.
## Vulnerability exploitability (VEX)
Advisories reported by `cargo-deny`/Dependabot for third-party dependencies that
do **not** affect Wickra (e.g. the vulnerable code path is not reachable, or the
affected feature is not enabled) are triaged and recorded — with the
not-affected justification — in the `cargo-deny` configuration (`deny.toml`) and
the relevant pull request, rather than forcing an unnecessary dependency bump.
This serves as the project's exploitability (VEX) record.
+37
View File
@@ -0,0 +1,37 @@
# Support
Thanks for using Wickra! Here is where to get help, depending on what you need.
## Documentation first
Most questions are answered in the documentation:
- **Docs site:** <https://docs.wickra.org> — quickstarts for Rust, Python,
Node.js and WebAssembly, a per-indicator reference, warmup periods, the data
layer, and an FAQ.
- **README:** <https://github.com/wickra-lib/wickra#readme> — installation and a
quick overview.
- **API docs (Rust):** <https://docs.rs/wickra>.
## Questions and help
- Ask a question with the
[question issue template](.github/ISSUE_TEMPLATE/question.md).
- Browse [existing issues](https://github.com/wickra-lib/wickra/issues) — your
question may already be answered.
## Bugs and feature requests
- **Bugs:** use the bug-report issue template.
- **Feature requests / new indicators:** use the feature-request template.
## Security issues
Please do **not** report security vulnerabilities through public issues. Follow
the process in [`SECURITY.md`](SECURITY.md) (private GitHub advisory or email).
## Support expectations
Wickra is maintained by a single maintainer on a best-effort basis. Issues are
triaged and acknowledged as time allows; there is no commercial support or SLA.
Clear, reproducible reports get help fastest.
+54
View File
@@ -0,0 +1,54 @@
# Threat model
This document describes Wickra's attack surface and the threats considered,
together with their mitigations. It complements the security assurance case in
[`SECURITY.md`](SECURITY.md). Wickra is a computational technical-analysis
library (a Rust core with Python, Node.js and WebAssembly bindings), not a
network service or trading system; the attack surface is correspondingly small.
## Assets
- **Integrity of computed indicator values** — consumers may use them in
automated decisions, so silently wrong output is the primary concern.
- **Availability of the calling process** — a library must not crash or hang
its host on malformed input.
- **Integrity of published artifacts** — the crates, wheels and npm packages
users install.
- **The build and release pipeline** and its secrets (publishing tokens).
## Actors / trust boundaries
- **Library consumer** (trusted) — calls the API with numeric data. Data may
originate from untrusted sources (e.g. a market feed), so *input values* are
treated as untrusted even though the caller is trusted.
- **Optional live feed** — with the `live-binance` feature, data crosses a
network boundary from an exchange over TLS.
- **Contributors** (semi-trusted) — propose changes via pull requests.
- **Supply chain** — upstream dependencies and the CI/CD platform.
## Threats and mitigations
| Threat | Mitigation |
| --- | --- |
| Memory-safety exploit (buffer overflow, UAF) via crafted input | Pure safe Rust; `unsafe` is forbidden/minimised, so the compiler precludes these classes. |
| Denial of service via malformed/degenerate input (NaN, infinities, extreme magnitudes) | Indicators reject non-finite inputs and validate parameters at construction; update paths are exercised by coverage-guided fuzzing and unit tests for edge cases. |
| Silently incorrect results | 100% line coverage on the core crate; reference-value tests against known-good sources; streaming/batch parity tests. |
| Integer overflow / panics | `clippy::pedantic` with `-D warnings`; debug assertions and overflow checks enabled in test/fuzz builds. |
| Adversary-in-the-middle on the optional live feed | Connection uses TLS via the platform library; transport security is delegated to that reviewed implementation. |
| Compromised dependency (supply chain) | Dependencies pinned (`Cargo.lock`, hash-locked CI requirements), monitored by Dependabot, audited by `cargo-deny` (advisories + licenses) on every change. |
| Malicious or accidental change to `main` | Branch protection requires signed commits and blocks force-push and deletion; all changes flow through pull requests with required CI; static analysis (CodeQL, Clippy) and fuzzing run on every change. |
| Compromised CI / leaked secrets | Workflows use least-privilege `permissions:`; secrets live only as encrypted GitHub Actions secrets; secret scanning with push protection is enabled; workflows are linted by `zizmor`. |
| Tampered release artifact | Releases are built in CI, tags are signed, and assets carry build provenance attestations (verifiable with `gh attestation verify`). |
## Out of scope
- Wickra implements no authentication, authorization or cryptography of its own,
stores no user data, and exposes no network listener; those threat classes do
not apply.
- Vulnerabilities in third-party dependencies that do not affect Wickra are
tracked as exploitability (VEX) records (see [`SECURITY.md`](SECURITY.md)).
## Maintenance
This threat model is reviewed when the architecture changes materially (for
example, a new input family, a new network feature, or a new release channel).
+3 -5
View File
@@ -3,7 +3,7 @@
[![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)
[![License: MIT OR Apache-2.0](https://img.shields.io/badge/license-MIT_OR_Apache--2.0-blue)](https://github.com/wickra-lib/wickra#license)
**Streaming-first technical indicators for Node.js. `npm install wickra`
prebuilt native binary, no system dependencies.**
@@ -67,7 +67,5 @@ 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).
Licensed under either of [Apache-2.0](https://github.com/wickra-lib/wickra/blob/main/LICENSE-APACHE)
or [MIT](https://github.com/wickra-lib/wickra/blob/main/LICENSE-MIT) at your option.
+12 -3
View File
@@ -8,16 +8,25 @@ const test = require('node:test');
const assert = require('node:assert/strict');
const wickra = require('..');
// Bar builders (Renko / Kagi / Point & Figure) implement the `BarBuilder`
// contract, not `Indicator`: they emit a variable number of completed bars per
// candle and have no fixed warmup or ready state. They expose update/batch/reset
// but intentionally not isReady/warmupPeriod, so they are excluded from the
// Indicator completeness contract below (their interface is covered by the
// dedicated bar-builder tests).
const BAR_BUILDERS = new Set(['RenkoBars', 'KagiBars', 'PointAndFigureBars']);
// 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.
// streaming `update` method. This excludes `version` (a plain function), the bar
// builders, 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'
typeof value.prototype.update === 'function' &&
!BAR_BUILDERS.has(name)
);
});
}
+747
View File
@@ -28,10 +28,50 @@ function num(v) {
// --- Scalar indicators: update(value) vs batch(prices) ---
const scalarFactories = {
BipowerVariation: () => new wickra.BipowerVariation(20),
VolatilityOfVolatility: () => new wickra.VolatilityOfVolatility(20, 20),
Garch11: () => new wickra.Garch11(0.000002, 0.1, 0.88),
EwmaVolatility: () => new wickra.EwmaVolatility(0.94),
PpoHistogram: () => new wickra.PpoHistogram(3, 6, 3),
MacdHistogram: () => new wickra.MacdHistogram(3, 6, 3),
TsfOscillator: () => new wickra.TsfOscillator(3),
WAVE_PM: () => new wickra.WAVE_PM(32, 3),
POLARIZED_FRACTAL_EFFICIENCY: () => new wickra.POLARIZED_FRACTAL_EFFICIENCY(10, 5),
TREND_STRENGTH_INDEX: () => new wickra.TREND_STRENGTH_INDEX(20),
DerivativeOscillator: () => new wickra.DerivativeOscillator(14, 5, 3, 9),
RMI: () => new wickra.RMI(14, 5),
DynamicMomentumIndex: () => new wickra.DynamicMomentumIndex(14),
RSX: () => new wickra.RSX(14),
FisherRSI: () => new wickra.FisherRSI(14),
DisparityIndex: () => new wickra.DisparityIndex(14),
HoltWinters: () => new wickra.HoltWinters(0.2, 0.1),
GD: () => new wickra.GD(5, 0.7),
AdaptiveLaguerre: () => new wickra.AdaptiveLaguerre(13),
MedianMA: () => new wickra.MedianMA(14),
EHMA: () => new wickra.EHMA(9),
GMA: () => new wickra.GMA(14),
SWMA: () => new wickra.SWMA(14),
Expectancy: () => new wickra.Expectancy(20),
WinRate: () => new wickra.WinRate(20),
RegimeLabel: () => new wickra.RegimeLabel(5, 20),
JumpIndicator: () => new wickra.JumpIndicator(20, 3.0),
TrendLabel: () => new wickra.TrendLabel(10),
RollingQuantile: () => new wickra.RollingQuantile(20, 0.5),
RollingPercentileRank: () => new wickra.RollingPercentileRank(14),
RollingIqr: () => new wickra.RollingIqr(14),
RealizedVolatility: () => new wickra.RealizedVolatility(20),
LogReturn: () => new wickra.LogReturn(1),
TSF: () => new wickra.TSF(14),
LINEARREG_INTERCEPT: () => new wickra.LINEARREG_INTERCEPT(14),
ROCR100: () => new wickra.ROCR100(10),
ROCR: () => new wickra.ROCR(10),
ROCP: () => new wickra.ROCP(10),
MIDPOINT: () => new wickra.MIDPOINT(14),
SMA: () => new wickra.SMA(14),
EMA: () => new wickra.EMA(14),
WMA: () => new wickra.WMA(14),
RSI: () => new wickra.RSI(14),
AnchoredRSI: () => new wickra.AnchoredRSI(),
DEMA: () => new wickra.DEMA(10),
TEMA: () => new wickra.TEMA(10),
HMA: () => new wickra.HMA(9),
@@ -89,6 +129,8 @@ const scalarFactories = {
EhlersStochastic: () => new wickra.EhlersStochastic(20),
EmpiricalModeDecomposition: () => new wickra.EmpiricalModeDecomposition(20, 0.5),
HilbertDominantCycle: () => new wickra.HilbertDominantCycle(),
HT_DCPHASE: () => new wickra.HT_DCPHASE(),
HT_TRENDMODE: () => new wickra.HT_TRENDMODE(),
AdaptiveCycle: () => new wickra.AdaptiveCycle(),
SineWave: () => new wickra.SineWave(),
FAMA: () => new wickra.FAMA(0.5, 0.05),
@@ -158,10 +200,17 @@ for (const [name, make] of Object.entries(scalarFactories)) {
// --- Scalar-output candle indicators: update(...) vs batch(...) ---
const candleScalar = {
MIDPRICE: { make: () => new wickra.MIDPRICE(14), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
DX: { make: () => new wickra.DX(14), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
MINUS_DI: { make: () => new wickra.MINUS_DI(14), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
PLUS_DI: { make: () => new wickra.PLUS_DI(14), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
ATR: { make: () => new wickra.ATR(14), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
PLUS_DM: { make: () => new wickra.PLUS_DM(14), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
MINUS_DM: { make: () => new wickra.MINUS_DM(14), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
CCI: { make: () => new wickra.CCI(20), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
WilliamsR: { make: () => new wickra.WilliamsR(14), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
PSAR: { make: () => new wickra.PSAR(0.02, 0.02, 0.2), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
SAREXT: { make: () => new wickra.SAREXT(0, 0, 0.02, 0.02, 0.2, 0.02, 0.02, 0.2), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
MFI: { make: () => new wickra.MFI(14), step: (ind, i) => ind.update(high[i], low[i], close[i], volume[i]), batch: (ind) => ind.batch(high, low, close, volume) },
VWAP: { make: () => new wickra.VWAP(), step: (ind, i) => ind.update(high[i], low[i], close[i], volume[i]), batch: (ind) => ind.batch(high, low, close, volume) },
RollingVWAP: { make: () => new wickra.RollingVWAP(20), step: (ind, i) => ind.update(high[i], low[i], close[i], volume[i]), batch: (ind) => ind.batch(high, low, close, volume) },
@@ -169,6 +218,7 @@ const candleScalar = {
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) },
AVGPRICE: { make: () => new wickra.AVGPRICE(), 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) },
@@ -235,6 +285,77 @@ const candleScalar = {
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) },
TwoCrows: { make: () => new wickra.TwoCrows(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
UpsideGapTwoCrows: { make: () => new wickra.UpsideGapTwoCrows(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
IdenticalThreeCrows: { make: () => new wickra.IdenticalThreeCrows(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
ThreeLineStrike: { make: () => new wickra.ThreeLineStrike(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
ThreeStarsInSouth: { make: () => new wickra.ThreeStarsInSouth(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
AbandonedBaby: { make: () => new wickra.AbandonedBaby(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
AdvanceBlock: { make: () => new wickra.AdvanceBlock(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
BeltHold: { make: () => new wickra.BeltHold(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
Breakaway: { make: () => new wickra.Breakaway(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
Counterattack: { make: () => new wickra.Counterattack(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
DojiStar: { make: () => new wickra.DojiStar(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
DragonflyDoji: { make: () => new wickra.DragonflyDoji(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
GravestoneDoji: { make: () => new wickra.GravestoneDoji(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
LongLeggedDoji: { make: () => new wickra.LongLeggedDoji(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
RickshawMan: { make: () => new wickra.RickshawMan(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
EveningDojiStar: { make: () => new wickra.EveningDojiStar(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
MorningDojiStar: { make: () => new wickra.MorningDojiStar(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
GapSideBySideWhite: { make: () => new wickra.GapSideBySideWhite(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
HighWave: { make: () => new wickra.HighWave(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
Hikkake: { make: () => new wickra.Hikkake(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
HikkakeModified: { make: () => new wickra.HikkakeModified(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
HomingPigeon: { make: () => new wickra.HomingPigeon(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
OnNeck: { make: () => new wickra.OnNeck(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
InNeck: { make: () => new wickra.InNeck(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
Thrusting: { make: () => new wickra.Thrusting(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
SeparatingLines: { make: () => new wickra.SeparatingLines(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
Kicking: { make: () => new wickra.Kicking(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
KickingByLength: { make: () => new wickra.KickingByLength(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
LadderBottom: { make: () => new wickra.LadderBottom(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
MatHold: { make: () => new wickra.MatHold(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
MatchingLow: { make: () => new wickra.MatchingLow(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
LongLine: { make: () => new wickra.LongLine(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
ShortLine: { make: () => new wickra.ShortLine(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
RisingThreeMethods: { make: () => new wickra.RisingThreeMethods(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
FallingThreeMethods: { make: () => new wickra.FallingThreeMethods(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
UpsideGapThreeMethods: { make: () => new wickra.UpsideGapThreeMethods(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
DownsideGapThreeMethods: { make: () => new wickra.DownsideGapThreeMethods(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
StalledPattern: { make: () => new wickra.StalledPattern(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
StickSandwich: { make: () => new wickra.StickSandwich(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
Takuri: { make: () => new wickra.Takuri(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
ClosingMarubozu: { make: () => new wickra.ClosingMarubozu(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
OpeningMarubozu: { make: () => new wickra.OpeningMarubozu(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
TasukiGap: { make: () => new wickra.TasukiGap(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
UniqueThreeRiver: { make: () => new wickra.UniqueThreeRiver(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
ConcealingBabySwallow: { make: () => new wickra.ConcealingBabySwallow(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
DoubleTopBottom: { make: () => new wickra.DoubleTopBottom(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
TripleTopBottom: { make: () => new wickra.TripleTopBottom(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
HeadAndShoulders: { make: () => new wickra.HeadAndShoulders(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
Triangle: { make: () => new wickra.Triangle(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
Wedge: { make: () => new wickra.Wedge(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
FlagPennant: { make: () => new wickra.FlagPennant(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
RectangleRange: { make: () => new wickra.RectangleRange(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
CupAndHandle: { make: () => new wickra.CupAndHandle(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
Abcd: { make: () => new wickra.Abcd(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
Gartley: { make: () => new wickra.Gartley(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
Butterfly: { make: () => new wickra.Butterfly(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
Bat: { make: () => new wickra.Bat(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
Crab: { make: () => new wickra.Crab(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
Shark: { make: () => new wickra.Shark(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
Cypher: { make: () => new wickra.Cypher(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
ThreeDrives: { make: () => new wickra.ThreeDrives(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
CloseVsOpen: { make: () => new wickra.CloseVsOpen(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
BodySizePct: { make: () => new wickra.BodySizePct(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
WickRatio: { make: () => new wickra.WickRatio(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
HighLowRange: { make: () => new wickra.HighLowRange(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
StochasticCCI: { make: () => new wickra.StochasticCCI(14), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
IMI: { make: () => new wickra.IMI(14), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
TTM_TREND: { make: () => new wickra.TTM_TREND(6), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
Qstick: { make: () => new wickra.Qstick(10), step: (ind, i) => ind.update(open[i], close[i]), batch: (ind) => ind.batch(open, close) },
VolatilityRatio: { make: () => new wickra.VolatilityRatio(14), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
ProjectionOscillator: { make: () => new wickra.ProjectionOscillator(14), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
};
for (const [name, d] of Object.entries(candleScalar)) {
@@ -256,6 +377,9 @@ const multi = {
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) },
HT_PHASOR: { make: () => new wickra.HT_PHASOR(), fields: ['inphase', 'quadrature'], step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
MACDFIX: { make: () => new wickra.MACDFIX(9), fields: ['macd', 'signal', 'histogram'], step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
MACDEXT: { make: () => new wickra.MACDEXT(12, 0, 26, 0, 9, 0), 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) },
@@ -304,6 +428,25 @@ const multi = {
// 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) },
FibRetracement: { make: () => new wickra.FibRetracement(), fields: ['level0', 'level236', 'level382', 'level500', 'level618', 'level786', 'level1000'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
FibExtension: { make: () => new wickra.FibExtension(), fields: ['level1272', 'level1414', 'level1618', 'level2000', 'level2618'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
FibProjection: { make: () => new wickra.FibProjection(), fields: ['level618', 'level1000', 'level1618', 'level2618'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
AutoFib: { make: () => new wickra.AutoFib(), fields: ['level0', 'level236', 'level382', 'level500', 'level618', 'level786', 'level1000'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
GoldenPocket: { make: () => new wickra.GoldenPocket(), fields: ['low', 'mid', 'high'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
FibConfluence: { make: () => new wickra.FibConfluence(), fields: ['price', 'strength'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
FibFan: { make: () => new wickra.FibFan(), fields: ['fan382', 'fan500', 'fan618'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
FibArcs: { make: () => new wickra.FibArcs(), fields: ['arc382', 'arc500', 'arc618'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
FibChannel: { make: () => new wickra.FibChannel(), fields: ['base', 'level618', 'level1000', 'level1618'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
FibTimeZones: { make: () => new wickra.FibTimeZones(), fields: ['onZone', 'barsToNext'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
ElderRay: { make: () => new wickra.ElderRay(13), fields: ['bullPower', 'bearPower'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
QQE: { make: () => new wickra.QQE(14, 5, 4.236), fields: ['rsiMa', 'trailingLine'], step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
GatorOscillator: { make: () => new wickra.GatorOscillator(13, 8, 5), fields: ['upper', 'lower'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
KasePermissionStochastic: { make: () => new wickra.KasePermissionStochastic(9, 3), fields: ['fast', 'slow'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
VolatilityCone: { make: () => new wickra.VolatilityCone(20, 60), fields: ['current', 'min', 'median', 'max', 'percentile'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
QuartileBands: { make: () => new wickra.QuartileBands(4), fields: ['upper', 'middle', 'lower'], step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
BomarBands: { make: () => new wickra.BomarBands(4, 0.85), fields: ['upper', 'middle', 'lower'], step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
MedianChannel: { make: () => new wickra.MedianChannel(5, 2.0), fields: ['upper', 'middle', 'lower'], step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
ProjectionBands: { make: () => new wickra.ProjectionBands(3), fields: ['upper', 'middle', 'lower'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
};
for (const [name, d] of Object.entries(multi)) {
@@ -464,6 +607,15 @@ const pairFactories = {
PairwiseBeta: () => new wickra.PairwiseBeta(14),
PairSpreadZScore: () => new wickra.PairSpreadZScore(14, 14),
SpearmanCorrelation: () => new wickra.SpearmanCorrelation(14),
RollingCorrelation: () => new wickra.RollingCorrelation(20),
RollingCovariance: () => new wickra.RollingCovariance(20),
OuHalfLife: () => new wickra.OuHalfLife(60),
SpreadHurst: () => new wickra.SpreadHurst(60),
DistanceSsd: () => new wickra.DistanceSsd(20),
BetaNeutralSpread: () => new wickra.BetaNeutralSpread(20),
VarianceRatio: () => new wickra.VarianceRatio(60, 2),
GrangerCausality: () => new wickra.GrangerCausality(60, 1),
SpreadAr1Coefficient: () => new wickra.SpreadAr1Coefficient(40),
};
for (const [name, make] of Object.entries(pairFactories)) {
@@ -554,6 +706,47 @@ test('Cointegration batch is flat 3*n with last row matching', () => {
assert.ok(out[3 * (n - 1) + 2] < -2);
});
test('KalmanHedgeRatio converges to a static hedge ratio (object output)', () => {
const n = 500;
const b = Array.from({ length: n }, (_, t) => 100 + 95 * Math.sin(t * 0.5));
const a = b.map((v) => 2 * v + 5);
const k = new wickra.KalmanHedgeRatio(1e-2, 1e-3);
let last = null;
for (let i = 0; i < n; i++) last = k.update(a[i], b[i]);
assert.ok(Math.abs(last.hedgeRatio - 2) < 0.05);
assert.ok(Math.abs(last.spread) < 0.05);
});
test('KalmanHedgeRatio batch is flat 3*n with last row matching', () => {
const n = 500;
const b = Array.from({ length: n }, (_, t) => 100 + 95 * Math.sin(t * 0.5));
const a = b.map((v) => 2 * v + 5);
const out = new wickra.KalmanHedgeRatio(1e-2, 1e-3).batch(a, b);
assert.equal(out.length, 3 * n);
assert.ok(Math.abs(out[3 * (n - 1)] - 2) < 0.05);
assert.ok(Math.abs(out[3 * (n - 1) + 2]) < 0.05);
});
test('SpreadBollingerBands bands are ordered (object output)', () => {
const n = 60;
const b = Array.from({ length: n }, (_, t) => 100 + t);
const a = b.map((v, t) => v + 3 * Math.sin(t * 0.4));
const bb = new wickra.SpreadBollingerBands(20, 2.0);
let last = null;
for (let i = 0; i < n; i++) last = bb.update(a[i], b[i]);
assert.ok(last.lower <= last.middle && last.middle <= last.upper);
});
test('SpreadBollingerBands batch is flat 4*n with last row matching', () => {
const n = 60;
const b = Array.from({ length: n }, (_, t) => 100 + t);
const a = b.map((v, t) => v + 3 * Math.sin(t * 0.4));
const out = new wickra.SpreadBollingerBands(20, 2.0).batch(a, b);
assert.equal(out.length, 4 * n);
const base = 4 * (n - 1);
assert.ok(out[base + 2] <= out[base] && out[base] <= out[base + 1]);
});
test('RelativeStrengthAB constant ratio is flat (object output)', () => {
const rs = new wickra.RelativeStrengthAB(5, 5);
let last = null;
@@ -896,3 +1089,557 @@ test('ALMA(3, 0.85, 6) reference value on [10, 20, 30]', () => {
// 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('order-flow imbalance reference + streaming matches batch', () => {
// Rising bid (px up, size 6) with an unchanged ask -> +6 flow.
const ofi = new wickra.OrderFlowImbalance(1);
assert.equal(ofi.update([100], [5], [101], [4]), null); // seeds the reference
assert.ok(Math.abs(ofi.update([100.5], [6], [101], [4]) - 6.0) < 1e-12);
const snaps = Array.from({ length: 30 }, (_, i) => ({
bidPx: [100 + Math.sin(i * 0.3)],
bidSz: [5 + Math.abs(Math.cos(i * 0.5))],
askPx: [101 + Math.sin(i * 0.3)],
askSz: [4 + Math.abs(Math.sin(i * 0.4))],
}));
const batch = new wickra.OrderFlowImbalance(10).batch(snaps);
const streamer = new wickra.OrderFlowImbalance(10);
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((Number.isNaN(batch[i]) && s === null) || Math.abs(s - batch[i]) < 1e-9, `mismatch at ${i}`);
}
});
test('vpin / amihud / roll reference + streaming matches batch', () => {
// VPIN: two pure-buy buckets of size 10 -> imbalance == size -> 1.
const v = new wickra.Vpin(10, 2);
let last;
for (let i = 0; i < 4; i++) last = v.update(100, 5, true);
assert.equal(last, 1.0);
// Amihud(1): |ln(101/100)| / (101 * 10).
const a = new wickra.AmihudIlliquidity(1);
assert.equal(a.update(100, 10, true), null);
assert.ok(Math.abs(a.update(101, 10, true) - Math.abs(Math.log(101 / 100)) / (101 * 10)) < 1e-15);
// Roll(6): a clean bid-ask bounce of ±1 implies a spread of 2.
const r = new wickra.RollMeasure(6);
let roll = null;
for (let i = 0; i < 20; i++) roll = r.update(i % 2 === 0 ? 100 : 101, 1, true);
assert.ok(Math.abs(roll - 2.0) < 1e-12);
// Streaming-vs-batch for the three trade-input indicators.
const n = 40;
const price = Array.from({ length: n }, (_, i) => 100 + Math.sin(i * 0.25) * 4);
const size = Array.from({ length: n }, (_, i) => 1 + (i % 5));
const isBuy = Array.from({ length: n }, (_, i) => i % 2 === 0);
for (const make of [() => new wickra.Vpin(8, 5), () => new wickra.AmihudIlliquidity(14), () => new wickra.RollMeasure(14)]) {
const batch = make().batch(price, size, isBuy);
const streamer = make();
assert.equal(batch.length, n);
for (let i = 0; i < n; i++) {
const s = streamer.update(price[i], size[i], isBuy[i]);
assert.ok((Number.isNaN(batch[i]) && s === null) || Math.abs(s - batch[i]) < 1e-9, `mismatch at ${i}`);
}
}
});
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));
});
test('derivatives indicators reference values', () => {
// Funding rate passes through (and may be negative).
assert.equal(new wickra.FundingRate().update(0.0001), 0.0001);
assert.equal(new wickra.FundingRate().update(-0.0003), -0.0003);
// Rolling mean: window [0.001, 0.003] -> 0.002.
const frm = new wickra.FundingRateMean(2);
assert.equal(frm.update(0.001), null); // warming up
assert.ok(Math.abs(frm.update(0.003) - 0.002) < 1e-12);
// Z-score: window [0.001, 0.003] -> +1.
const z = new wickra.FundingRateZScore(2);
assert.equal(z.update(0.001), null); // warming up
assert.ok(Math.abs(z.update(0.003) - 1.0) < 1e-9);
// Basis: mark 100.5 vs index 100.0 -> 0.005.
assert.ok(Math.abs(new wickra.FundingBasis().update(100.5, 100.0) - 0.005) < 1e-12);
// OI delta: seeds then emits the change.
const oid = new wickra.OpenInterestDelta();
assert.equal(oid.update(1000), null);
assert.equal(oid.update(1250), 250);
assert.equal(oid.update(1100), -150);
});
test('derivatives streaming update matches batch', () => {
const n = 30;
const rate = Array.from({ length: n }, (_, i) => 0.0001 * Math.sin(i * 0.3));
const batch = new wickra.FundingRateMean(5).batch(rate);
const streamer = new wickra.FundingRateMean(5);
assert.equal(batch.length, n);
for (let i = 0; i < n; i++) {
const s = streamer.update(rate[i]);
assert.ok(
(s === null && Number.isNaN(batch[i])) || Math.abs(s - batch[i]) < 1e-12,
`mismatch at ${i}: ${s} vs ${batch[i]}`,
);
}
});
test('derivatives reject bad input', () => {
assert.throws(() => new wickra.FundingRateMean(0));
assert.throws(() => new wickra.FundingRateZScore(0));
assert.throws(() => new wickra.FundingBasis().update(100, 0));
});
test('market breadth: AdvanceDecline reference values', () => {
// A breadth tick is the universe as parallel arrays; the sign of `change`
// classifies each symbol as advancing / declining / unchanged.
const change = [
[1.0, 0.5, 2.0, -1.0], // 3 up, 1 down -> net +2
[-1.0, -0.5, -2.0, 1.0], // 1 up, 3 down -> net -2
[0.0, 0.0, 1.0, -1.0], // 1 up, 1 down -> net 0
];
const volume = change.map((row) => row.map(() => 10.0));
const flags = change.map((row) => row.map(() => false));
const ad = new wickra.AdvanceDecline();
// Cumulative line: +2 -> 0 -> 0.
assert.equal(ad.update(change[0], volume[0], flags[0], flags[0]), 2.0);
assert.equal(ad.update(change[1], volume[1], flags[1], flags[1]), 0.0);
assert.equal(ad.update(change[2], volume[2], flags[2], flags[2]), 0.0);
// batch matches streaming.
const batch = new wickra.AdvanceDecline().batch(change, volume, flags, flags);
assert.deepEqual(Array.from(batch), [2.0, 0.0, 0.0]);
});
test('market breadth: AdvanceDecline rejects ragged universe', () => {
assert.throws(() =>
new wickra.AdvanceDecline().update(
[1.0, -1.0],
[10.0],
[false, false],
[false, false],
),
);
});
test('market breadth: 14 indicators reference values + batch parity', () => {
const flags4 = [false, false, false, false];
// Advance/Decline Ratio: 3/1 = 3 ; 0 advancers -> 0.
const adr = new wickra.AdvanceDeclineRatio();
assert.equal(adr.update([1, 1, 1, -1], [10, 10, 10, 10], flags4, flags4), 3.0);
assert.equal(adr.update([-1, -1, -1, -1], [10, 10, 10, 10], flags4, flags4), 0.0);
assert.deepEqual(
Array.from(
new wickra.AdvanceDeclineRatio().batch(
[[1, 1, 1, -1], [-1, -1, -1, -1]],
[[10, 10, 10, 10], [10, 10, 10, 10]],
[flags4, flags4],
[flags4, flags4],
),
),
[3.0, 0.0],
);
// AD Volume Line: cumulative net advancing volume.
const adv = new wickra.AdVolumeLine();
assert.equal(adv.update([1, -1], [150, 50], [false, false], [false, false]), 100.0);
assert.equal(adv.update([1, -1], [60, 60], [false, false], [false, false]), 100.0);
// McClellan Oscillator + Summation: seed 0, then -50.
const osc = new wickra.McClellanOscillator();
assert.ok(Math.abs(osc.update([1, 1, 1, -1], [10, 10, 10, 10], flags4, flags4)) < 1e-9);
assert.ok(Math.abs(osc.update([-1, -1, -1, 1], [10, 10, 10, 10], flags4, flags4) - -50.0) < 1e-9);
const msi = new wickra.McClellanSummationIndex();
assert.ok(Math.abs(msi.update([1, 1, 1, -1], [10, 10, 10, 10], flags4, flags4)) < 1e-9);
assert.ok(Math.abs(msi.update([-1, -1, -1, 1], [10, 10, 10, 10], flags4, flags4) - -50.0) < 1e-9);
// TRIN: balanced breadth -> 1.
assert.ok(
Math.abs(new wickra.Trin().update([1, 1, 1, -1], [50, 50, 50, 50], flags4, flags4) - 1.0) < 1e-9,
);
// Breadth Thrust(2): warmup null, then SMA(2) of [0.8, 0.6] = 0.7.
const bt = new wickra.BreadthThrust(2);
const up10 = Array(10).fill(false);
assert.equal(bt.update([...Array(8).fill(1), -1, -1], Array(10).fill(10), up10, up10), null);
assert.ok(
Math.abs(bt.update([...Array(6).fill(1), -1, -1, -1, -1], Array(10).fill(10), up10, up10) - 0.7) < 1e-9,
);
// New Highs - New Lows: 2 - 1 = 1.
assert.equal(
new wickra.NewHighsNewLows().update([1, 1, -1], [10, 10, 10], [true, true, false], [false, false, true]),
1.0,
);
// High-Low Index(2): warmup null, then SMA(2) of [80, 60] = 70.
const hli = new wickra.HighLowIndex(2);
assert.equal(
hli.update(Array(10).fill(1), Array(10).fill(10), [...Array(8).fill(true), false, false], [...Array(8).fill(false), true, true]),
null,
);
assert.ok(
Math.abs(
hli.update(Array(10).fill(1), Array(10).fill(10), [...Array(6).fill(true), false, false, false, false], [...Array(6).fill(false), true, true, true, true]) - 70.0,
) < 1e-9,
);
// Percent Above MA: 3/4 -> 75 (5-array update with aboveMa).
assert.equal(
new wickra.PercentAboveMa().update([1, 1, 1, -1], [10, 10, 10, 10], flags4, flags4, [true, true, true, false]),
75.0,
);
// Up/Down Volume Ratio: 150/50 = 3.
assert.equal(
new wickra.UpDownVolumeRatio().update([1, -1], [150, 50], [false, false], [false, false]),
3.0,
);
// Bullish Percent Index: 2/4 -> 50 (5-array update with onBuySignal).
assert.equal(
new wickra.BullishPercentIndex().update([1, 1, -1, -1], [10, 10, 10, 10], flags4, flags4, [true, true, false, false]),
50.0,
);
// Cumulative Volume Index: (100/200) -> 0.5.
assert.ok(
Math.abs(new wickra.CumulativeVolumeIndex().update([1, -1], [150, 50], [false, false], [false, false]) - 0.5) < 1e-9,
);
// Absolute Breadth Index: |2 - 3| = 1.
assert.equal(
new wickra.AbsoluteBreadthIndex().update([1, 1, -1, -1, -1], Array(5).fill(10), Array(5).fill(false), Array(5).fill(false)),
1.0,
);
// TICK Index: 2 - 3 = -1.
assert.equal(
new wickra.TickIndex().update([1, 1, -1, -1, -1], Array(5).fill(10), Array(5).fill(false), Array(5).fill(false)),
-1.0,
);
});
test('market breadth: rejects ragged universe', () => {
assert.throws(() => new wickra.Trin().update([1, -1], [10], [false, false], [false, false]));
assert.throws(() =>
new wickra.PercentAboveMa().update([1, -1], [10, 10], [false, false], [false, false], [true]),
);
});
test('OI / flow / liquidation indicators reference values', () => {
// OI +10% while price flat -> divergence +0.1.
const div = new wickra.OIPriceDivergence(1);
assert.equal(div.update(1000, 100), null); // warming up
assert.ok(Math.abs(div.update(1100, 100) - 0.1) < 1e-12);
// OI-weighted: (100·10 + 110·30) / 40 = 107.5.
const oiw = new wickra.OIWeighted();
assert.equal(oiw.update(100, 10), 100);
assert.ok(Math.abs(oiw.update(110, 30) - 107.5) < 1e-12);
// Long/short ratio.
assert.ok(Math.abs(new wickra.LongShortRatio().update(600, 400) - 1.5) < 1e-12);
assert.equal(new wickra.LongShortRatio().update(600, 0), 0);
// Taker buy/sell ratio.
assert.ok(Math.abs(new wickra.TakerBuySellRatio().update(60, 40) - 1.5) < 1e-12);
assert.equal(new wickra.TakerBuySellRatio().update(60, 0), 0);
// Liquidation features object.
const liq = new wickra.LiquidationFeatures().update(30, 10);
assert.equal(liq.net, 20);
assert.equal(liq.total, 40);
assert.equal(liq.imbalance, 0.5);
});
test('liquidation features batch is flat n*5', () => {
const longLiq = [10, 0, 30];
const shortLiq = [5, 20, 0];
const batch = new wickra.LiquidationFeatures().batch(longLiq, shortLiq);
assert.equal(batch.length, 15);
// Row 0: long 10, short 5, net 5, total 15.
assert.equal(batch[0], 10);
assert.equal(batch[1], 5);
assert.equal(batch[2], 5);
assert.equal(batch[3], 15);
});
test('OI flow rejects bad input', () => {
assert.throws(() => new wickra.OIPriceDivergence(0));
assert.throws(() => new wickra.OIWeighted().update(0, 100));
});
test('basis & calendar-spread reference values', () => {
// futures 102 vs index 100 -> 0.02 contango.
assert.ok(Math.abs(new wickra.TermStructureBasis().update(102, 100) - 0.02) < 1e-12);
assert.ok(Math.abs(new wickra.TermStructureBasis().update(98, 100) + 0.02) < 1e-12);
// futures 101 vs perpetual mark 100 -> 0.01.
assert.ok(Math.abs(new wickra.CalendarSpread().update(101, 100) - 0.01) < 1e-12);
});
test('basis streaming update matches batch', () => {
const n = 20;
const index = Array.from({ length: n }, (_, i) => 100 + Math.sin(i * 0.2));
const futures = Array.from({ length: n }, (_, i) => index[i] + 0.5);
const batch = new wickra.TermStructureBasis().batch(futures, index);
const streamer = new wickra.TermStructureBasis();
assert.equal(batch.length, n);
for (let i = 0; i < n; i++) {
assert.ok(Math.abs(streamer.update(futures[i], index[i]) - batch[i]) < 1e-12);
}
});
test('basis rejects bad input', () => {
assert.throws(() => new wickra.TermStructureBasis().update(100, 0));
assert.throws(() => new wickra.CalendarSpread().update(100, 0));
});
test('VolumeProfile exposes the full histogram', () => {
// bar0 single-print at 10 vol 100; bar1 spans 10..14 vol 80 over 4 bins.
const vp = new wickra.VolumeProfile(2, 4);
assert.equal(vp.update(10, 10, 100), null);
const out = vp.update(14, 10, 80);
assert.ok(out !== null);
assert.ok(Math.abs(out.priceLow - 10) < 1e-9);
assert.ok(Math.abs(out.priceHigh - 14) < 1e-9);
assert.deepEqual(out.bins.length, 4);
assert.ok(Math.abs(out.bins[0] - 120) < 1e-9);
for (let i = 1; i < 4; i++) {
assert.ok(Math.abs(out.bins[i] - 20) < 1e-9);
}
});
test('TpoProfile counts time at price, volume-agnostic', () => {
// bar0 spans 10..14 (+1 each bin); bar1 spans 11..12 (+1 bins 1,2).
const tpo = new wickra.TpoProfile(2, 4);
assert.equal(tpo.update(14, 10), null);
const out = tpo.update(12, 11);
assert.ok(out !== null);
assert.ok(Math.abs(out.priceLow - 10) < 1e-9);
assert.ok(Math.abs(out.priceHigh - 14) < 1e-9);
assert.deepEqual(out.counts, [1, 2, 2, 1]);
});
test('RenkoBars prints aligned bricks and reverses on two boxes', () => {
const r = new wickra.RenkoBars(1.0);
assert.deepEqual(r.update(10), []); // seed
const up = r.update(13);
assert.equal(up.length, 3);
assert.ok(Math.abs(up[0].open - 10) < 1e-9 && Math.abs(up[0].close - 11) < 1e-9);
assert.ok(up.every((b) => b.direction === 1));
const down = r.update(10);
assert.equal(down.length, 2);
assert.ok(down.every((b) => b.direction === -1));
});
test('KagiBars closes a segment on a reversal', () => {
const k = new wickra.KagiBars(2.0);
k.update(10);
k.update(11);
k.update(15);
const seg = k.update(12);
assert.equal(seg.length, 1);
assert.equal(seg[0].direction, 1);
assert.ok(Math.abs(seg[0].start - 10) < 1e-9 && Math.abs(seg[0].end - 15) < 1e-9);
});
test('PointAndFigureBars closes a column on a 3-box reversal', () => {
const pnf = new wickra.PointAndFigureBars(1.0, 3);
pnf.update(10);
pnf.update(13);
pnf.update(15);
const col = pnf.update(12);
assert.equal(col.length, 1);
assert.equal(col[0].direction, 1);
assert.ok(Math.abs(col[0].high - 15) < 1e-9 && Math.abs(col[0].low - 10) < 1e-9);
});
@@ -0,0 +1,96 @@
// Streaming-vs-batch equivalence and reference values for the Seasonality &
// Session family. These indicators consume the full candle (open, high, low,
// close, volume, timestamp), so they have a dedicated suite.
const test = require('node:test');
const assert = require('node:assert/strict');
const wickra = require('..');
const HOUR = 3_600_000;
const N = 240;
const close = Array.from({ length: N }, (_, i) => 100 + Math.sin(i * 0.3) * 5 + Math.cos(i * 0.1) * 3);
const open = close.map((c, i) => c + Math.sin(i * 0.5) * 0.5);
const high = close.map((c, i) => Math.max(open[i], c) + 1);
const low = close.map((c, i) => Math.min(open[i], c) - 1);
const volume = Array.from({ length: N }, (_, i) => 1000 + (i % 24) * 50);
const ts = Array.from({ length: N }, (_, i) => i * HOUR);
function eq(a, b) {
if (Number.isNaN(a)) return Number.isNaN(b);
return Math.abs(a - b) < 1e-9;
}
function streamScalar(ind, i) {
const v = ind.update(open[i], high[i], low[i], close[i], volume[i], ts[i]);
return v === null || v === undefined ? NaN : v;
}
function checkScalar(name, make) {
test(`${name} streaming equals batch`, () => {
const a = make();
const b = make();
const batch = b.batch(open, high, low, close, volume, ts);
for (let i = 0; i < N; i += 1) {
assert.ok(eq(streamScalar(a, i), batch[i]), `${name} row ${i}`);
}
});
}
function checkMatrix(name, make, k, pick) {
test(`${name} streaming equals batch`, () => {
const a = make();
const b = make();
const batch = b.batch(open, high, low, close, volume, ts);
for (let i = 0; i < N; i += 1) {
const out = a.update(open[i], high[i], low[i], close[i], volume[i], ts[i]);
for (let j = 0; j < k; j += 1) {
const s = out === null || out === undefined ? NaN : pick(out, j);
assert.ok(eq(s, batch[i * k + j]), `${name} row ${i} col ${j}`);
}
}
});
}
checkScalar('SessionVwap', () => new wickra.SessionVwap(0));
checkScalar('OvernightGap', () => new wickra.OvernightGap(0));
checkScalar('SeasonalZScore', () => new wickra.SeasonalZScore(0));
checkScalar('AverageDailyRange', () => new wickra.AverageDailyRange(3, 0));
checkScalar('TurnOfMonth', () => new wickra.TurnOfMonth(3, 1, 0));
checkMatrix('SessionHighLow', () => new wickra.SessionHighLow(0), 2, (o, j) => (j === 0 ? o.high : o.low));
checkMatrix('SessionRange', () => new wickra.SessionRange(0), 3, (o, j) => [o.asia, o.eu, o.us][j]);
checkMatrix(
'OvernightIntradayReturn',
() => new wickra.OvernightIntradayReturn(0),
2,
(o, j) => (j === 0 ? o.overnight : o.intraday),
);
checkMatrix('TimeOfDayReturnProfile', () => new wickra.TimeOfDayReturnProfile(24, 0), 24, (o, j) => o[j]);
checkMatrix('IntradayVolatilityProfile', () => new wickra.IntradayVolatilityProfile(12, 0), 12, (o, j) => o[j]);
checkMatrix('VolumeByTimeProfile', () => new wickra.VolumeByTimeProfile(24, 0), 24, (o, j) => o[j]);
checkMatrix('DayOfWeekProfile', () => new wickra.DayOfWeekProfile(0), 7, (o, j) => o[j]);
test('SessionVwap reference value', () => {
const vwap = new wickra.SessionVwap(0);
assert.ok(eq(vwap.update(100, 100, 100, 100, 10, 0), 100));
assert.ok(eq(vwap.update(110, 110, 110, 110, 30, HOUR), 107.5));
assert.ok(eq(vwap.update(200, 200, 200, 200, 5, 24 * HOUR), 200));
});
test('OvernightGap reference value', () => {
const gap = new wickra.OvernightGap(0);
assert.equal(gap.update(99, 101, 98, 100, 1, 0), null);
assert.ok(eq(gap.update(105, 106, 104, 105.5, 1, 24 * HOUR), 0.05));
});
test('SessionHighLow reference object', () => {
const shl = new wickra.SessionHighLow(0);
shl.update(100, 105, 99, 101, 1, 0);
const out = shl.update(101, 108, 100, 107, 1, HOUR);
assert.ok(eq(out.high, 108));
assert.ok(eq(out.low, 99));
});
test('AverageDailyRange rejects zero period', () => {
assert.throws(() => new wickra.AverageDailyRange(0, 0));
});
+2206 -1
View File
File diff suppressed because it is too large Load Diff
+216 -1
View File
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "wickra-darwin-arm64",
"version": "0.4.1",
"version": "0.6.1",
"description": "Native binding for wickra (macOS Apple Silicon). Installed automatically as an optional dependency of wickra on matching platforms.",
"main": "wickra.darwin-arm64.node",
"files": [
"wickra.darwin-arm64.node"
],
"license": "PolyForm-Noncommercial-1.0.0",
"license": "MIT OR Apache-2.0",
"engines": {
"node": ">= 18"
},
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "wickra-darwin-x64",
"version": "0.4.1",
"version": "0.6.1",
"description": "Native binding for wickra (macOS Intel). Installed automatically as an optional dependency of wickra on matching platforms.",
"main": "wickra.darwin-x64.node",
"files": [
"wickra.darwin-x64.node"
],
"license": "PolyForm-Noncommercial-1.0.0",
"license": "MIT OR Apache-2.0",
"engines": {
"node": ">= 18"
},
@@ -1,12 +1,12 @@
{
"name": "wickra-linux-arm64-gnu",
"version": "0.4.1",
"version": "0.6.1",
"description": "Native binding for wickra (linux arm64 GNU). Installed automatically as an optional dependency of wickra on matching platforms.",
"main": "wickra.linux-arm64-gnu.node",
"files": [
"wickra.linux-arm64-gnu.node"
],
"license": "PolyForm-Noncommercial-1.0.0",
"license": "MIT OR Apache-2.0",
"engines": {
"node": ">= 18"
},
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "wickra-linux-x64-gnu",
"version": "0.4.1",
"version": "0.6.1",
"description": "Native binding for wickra (linux x64 GNU). Installed automatically as an optional dependency of wickra on matching platforms.",
"main": "wickra.linux-x64-gnu.node",
"files": [
"wickra.linux-x64-gnu.node"
],
"license": "PolyForm-Noncommercial-1.0.0",
"license": "MIT OR Apache-2.0",
"engines": {
"node": ">= 18"
},
@@ -1,12 +1,12 @@
{
"name": "wickra-win32-arm64-msvc",
"version": "0.4.1",
"version": "0.6.1",
"description": "Native binding for wickra (Windows arm64 MSVC). Installed automatically as an optional dependency of wickra on matching platforms.",
"main": "wickra.win32-arm64-msvc.node",
"files": [
"wickra.win32-arm64-msvc.node"
],
"license": "PolyForm-Noncommercial-1.0.0",
"license": "MIT OR Apache-2.0",
"engines": {
"node": ">= 18"
},
@@ -1,12 +1,12 @@
{
"name": "wickra-win32-x64-msvc",
"version": "0.4.1",
"version": "0.6.1",
"description": "Native binding for wickra (Windows x64 MSVC). Installed automatically as an optional dependency of wickra on matching platforms.",
"main": "wickra.win32-x64-msvc.node",
"files": [
"wickra.win32-x64-msvc.node"
],
"license": "PolyForm-Noncommercial-1.0.0",
"license": "MIT OR Apache-2.0",
"engines": {
"node": ">= 18"
},
+27 -27
View File
@@ -1,13 +1,13 @@
{
"name": "wickra",
"version": "0.4.1",
"version": "0.6.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "wickra",
"version": "0.4.1",
"license": "PolyForm-Noncommercial-1.0.0",
"version": "0.6.1",
"license": "MIT OR Apache-2.0",
"devDependencies": {
"@napi-rs/cli": "^2.18.0"
},
@@ -15,12 +15,12 @@
"node": ">= 18"
},
"optionalDependencies": {
"wickra-darwin-arm64": "0.4.1",
"wickra-darwin-x64": "0.4.1",
"wickra-linux-arm64-gnu": "0.4.1",
"wickra-linux-x64-gnu": "0.4.1",
"wickra-win32-arm64-msvc": "0.4.1",
"wickra-win32-x64-msvc": "0.4.1"
"wickra-darwin-arm64": "0.6.1",
"wickra-darwin-x64": "0.6.1",
"wickra-linux-arm64-gnu": "0.6.1",
"wickra-linux-x64-gnu": "0.6.1",
"wickra-win32-arm64-msvc": "0.6.1",
"wickra-win32-x64-msvc": "0.6.1"
}
},
"node_modules/@napi-rs/cli": {
@@ -41,13 +41,13 @@
}
},
"node_modules/wickra-darwin-arm64": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/wickra-darwin-arm64/-/wickra-darwin-arm64-0.4.1.tgz",
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/wickra-darwin-arm64/-/wickra-darwin-arm64-0.6.1.tgz",
"integrity": "sha512-4eZiBR/yGUdr4nzhEUFy2i69XgNx64iI2ax/LPamsThgylC0KpHOZKK19QzJ2d9KbK4C8nMjME5FLuR+4GNEwQ==",
"cpu": [
"arm64"
],
"license": "PolyForm-Noncommercial-1.0.0",
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
"darwin"
@@ -57,13 +57,13 @@
}
},
"node_modules/wickra-darwin-x64": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/wickra-darwin-x64/-/wickra-darwin-x64-0.4.1.tgz",
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/wickra-darwin-x64/-/wickra-darwin-x64-0.6.1.tgz",
"integrity": "sha512-6hf8zI3QPjTFp4zCpmgUwDvNtu6jHqNUHKD5e55POo0CgA52HkpyxSPtVm8TGTIZDI7kPjlbOdBM8CJ76mmXwA==",
"cpu": [
"x64"
],
"license": "PolyForm-Noncommercial-1.0.0",
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
"darwin"
@@ -73,13 +73,13 @@
}
},
"node_modules/wickra-linux-arm64-gnu": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/wickra-linux-arm64-gnu/-/wickra-linux-arm64-gnu-0.4.1.tgz",
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/wickra-linux-arm64-gnu/-/wickra-linux-arm64-gnu-0.6.1.tgz",
"integrity": "sha512-kSe6y0xBMSiqdPLXNjwop5WZdHtvdBNKSEBCwZ4hFq33p4apW25/wrlzv9/oDuyD4kuPabJEhCCnFOplh58CUg==",
"cpu": [
"arm64"
],
"license": "PolyForm-Noncommercial-1.0.0",
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
"linux"
@@ -89,13 +89,13 @@
}
},
"node_modules/wickra-linux-x64-gnu": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/wickra-linux-x64-gnu/-/wickra-linux-x64-gnu-0.4.1.tgz",
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/wickra-linux-x64-gnu/-/wickra-linux-x64-gnu-0.6.1.tgz",
"integrity": "sha512-tWBWS4qz7hxM4xnpFb59bhf6TaLwXq0Z3jEa/2l7r8PiHA94g8r8S53NRMiT+4yiL5hSWe/nUiC/YXdRrhEZ4g==",
"cpu": [
"x64"
],
"license": "PolyForm-Noncommercial-1.0.0",
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
"linux"
@@ -105,13 +105,13 @@
}
},
"node_modules/wickra-win32-arm64-msvc": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/wickra-win32-arm64-msvc/-/wickra-win32-arm64-msvc-0.4.1.tgz",
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/wickra-win32-arm64-msvc/-/wickra-win32-arm64-msvc-0.6.1.tgz",
"integrity": "sha512-EXIckHxAtF75PUGDKRzXyqMe9ldP0JjSdu68WFN6iJfp+McYrGu6h40TEJlQ/oUEIoPqiZB/xhVyo/el5Lg7zw==",
"cpu": [
"arm64"
],
"license": "PolyForm-Noncommercial-1.0.0",
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
"win32"
@@ -121,13 +121,13 @@
}
},
"node_modules/wickra-win32-x64-msvc": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/wickra-win32-x64-msvc/-/wickra-win32-x64-msvc-0.4.1.tgz",
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/wickra-win32-x64-msvc/-/wickra-win32-x64-msvc-0.6.1.tgz",
"integrity": "sha512-Yfsqq1Xwp6hdxMyLze411vNdo7BDwI6+lPSe7A9XdqyPecNDbtKwYLpsal2r8EHbNzqM+R8XnuRtUaEQS5VlUQ==",
"cpu": [
"x64"
],
"license": "PolyForm-Noncommercial-1.0.0",
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
"win32"
+8 -8
View File
@@ -1,11 +1,11 @@
{
"name": "wickra",
"version": "0.4.1",
"version": "0.6.1",
"description": "Streaming-first technical indicators: incremental, fast, install-free. Node bindings powered by Rust.",
"author": "kingchenc <support@wickra.org>",
"main": "index.js",
"types": "index.d.ts",
"license": "PolyForm-Noncommercial-1.0.0",
"license": "MIT OR Apache-2.0",
"keywords": [
"trading",
"indicators",
@@ -47,12 +47,12 @@
"node": ">= 18"
},
"optionalDependencies": {
"wickra-linux-x64-gnu": "0.4.1",
"wickra-linux-arm64-gnu": "0.4.1",
"wickra-darwin-x64": "0.4.1",
"wickra-darwin-arm64": "0.4.1",
"wickra-win32-x64-msvc": "0.4.1",
"wickra-win32-arm64-msvc": "0.4.1"
"wickra-linux-x64-gnu": "0.6.1",
"wickra-linux-arm64-gnu": "0.6.1",
"wickra-darwin-x64": "0.6.1",
"wickra-darwin-arm64": "0.6.1",
"wickra-win32-x64-msvc": "0.6.1",
"wickra-win32-arm64-msvc": "0.6.1"
},
"scripts": {
"build": "napi build --platform --release",
+7352 -3
View File
File diff suppressed because it is too large Load Diff
+3 -5
View File
@@ -3,7 +3,7 @@
[![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)
[![License: MIT OR Apache-2.0](https://img.shields.io/badge/license-MIT_OR_Apache--2.0-blue)](https://github.com/wickra-lib/wickra#license)
**Streaming-first technical indicators for Python. `pip install wickra` — no
system dependencies, no C build tooling.**
@@ -66,7 +66,5 @@ 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).
Licensed under either of [Apache-2.0](https://github.com/wickra-lib/wickra/blob/main/LICENSE-APACHE)
or [MIT](https://github.com/wickra-lib/wickra/blob/main/LICENSE-MIT) at your option.
@@ -49,6 +49,7 @@ TALIB = _try_import("talib")
PANDAS_TA = _try_import("pandas_ta")
TALIPP = _try_import("talipp.indicators") or _try_import("talipp")
FINTA = _try_import("finta")
TULIPY = _try_import("tulipy")
PD = _try_import("pandas")
import wickra as WICKRA # noqa: E402 -- the library under test must be importable
@@ -275,6 +276,34 @@ def talipp_bollinger_batch(prices: np.ndarray) -> Optional[Callable[[], None]]:
return lambda: BB(period=20, std_dev_mult=2.0, input_values=list(prices))
# tulipy wraps the C "Tulip Indicators" library; it takes contiguous float64
# arrays and indicator options as positional arguments.
def tulipy_sma_batch(prices: np.ndarray) -> Optional[Callable[[], None]]:
return None if TULIPY is None else (lambda: TULIPY.sma(prices, 20))
def tulipy_ema_batch(prices: np.ndarray) -> Optional[Callable[[], None]]:
return None if TULIPY is None else (lambda: TULIPY.ema(prices, 20))
def tulipy_rsi_batch(prices: np.ndarray) -> Optional[Callable[[], None]]:
return None if TULIPY is None else (lambda: TULIPY.rsi(prices, 14))
def tulipy_macd_batch(prices: np.ndarray) -> Optional[Callable[[], None]]:
return None if TULIPY is None else (lambda: TULIPY.macd(prices, 12, 26, 9))
def tulipy_bollinger_batch(prices: np.ndarray) -> Optional[Callable[[], None]]:
return None if TULIPY is None else (lambda: TULIPY.bbands(prices, 20, 2.0))
def tulipy_atr_batch(high: np.ndarray, low: np.ndarray, close: np.ndarray) -> Optional[Callable[[], None]]:
return None if TULIPY is None else (lambda: TULIPY.atr(high, low, close, 14))
# --------------------------------------------------------------------------- #
# Streaming scenario: per-tick latency
# --------------------------------------------------------------------------- #
@@ -329,6 +358,105 @@ def talipp_rsi_streaming(seed: np.ndarray, live: np.ndarray) -> Optional[Callabl
return run
# Scalar streaming peers: Wickra and talipp both update incrementally in O(1),
# so this is the like-for-like per-tick comparison (batch-only libs are covered
# by the batch tables and the recompute contrast on RSI above).
def wickra_sma_streaming(seed: np.ndarray, live: np.ndarray) -> Callable[[], None]:
def run() -> None:
sma = WICKRA.SMA(20)
sma.batch(seed)
for p in live:
sma.update(float(p))
return run
def talipp_sma_streaming(seed: np.ndarray, live: np.ndarray) -> Optional[Callable[[], None]]:
if TALIPP is None:
return None
from talipp.indicators import SMA # type: ignore
def run() -> None:
sma = SMA(period=20, input_values=list(seed))
for p in live:
sma.add(float(p))
return run
def wickra_ema_streaming(seed: np.ndarray, live: np.ndarray) -> Callable[[], None]:
def run() -> None:
ema = WICKRA.EMA(20)
ema.batch(seed)
for p in live:
ema.update(float(p))
return run
def talipp_ema_streaming(seed: np.ndarray, live: np.ndarray) -> Optional[Callable[[], None]]:
if TALIPP is None:
return None
from talipp.indicators import EMA # type: ignore
def run() -> None:
ema = EMA(period=20, input_values=list(seed))
for p in live:
ema.add(float(p))
return run
def wickra_macd_streaming(seed: np.ndarray, live: np.ndarray) -> Callable[[], None]:
def run() -> None:
macd = WICKRA.MACD()
macd.batch(seed)
for p in live:
macd.update(float(p))
return run
def talipp_macd_streaming(seed: np.ndarray, live: np.ndarray) -> Optional[Callable[[], None]]:
if TALIPP is None:
return None
from talipp.indicators import MACD # type: ignore
def run() -> None:
macd = MACD(
fast_period=12, slow_period=26, signal_period=9, input_values=list(seed)
)
for p in live:
macd.add(float(p))
return run
def wickra_bollinger_streaming(seed: np.ndarray, live: np.ndarray) -> Callable[[], None]:
def run() -> None:
bb = WICKRA.BollingerBands(20, 2.0)
bb.batch(seed)
for p in live:
bb.update(float(p))
return run
def talipp_bollinger_streaming(seed: np.ndarray, live: np.ndarray) -> Optional[Callable[[], None]]:
if TALIPP is None:
return None
from talipp.indicators import BB # type: ignore
def run() -> None:
bb = BB(period=20, std_dev_mult=2.0, input_values=list(seed))
for p in live:
bb.add(float(p))
return run
# --------------------------------------------------------------------------- #
# Runner
# --------------------------------------------------------------------------- #
@@ -339,6 +467,7 @@ BATCH_INDICATORS = [
("Wickra", wickra_sma_batch),
("TA-Lib", talib_sma_batch),
("pandas-ta", pandas_ta_sma_batch),
("tulipy", tulipy_sma_batch),
("finta", finta_sma_batch),
("talipp", talipp_sma_batch),
]),
@@ -346,6 +475,7 @@ BATCH_INDICATORS = [
("Wickra", wickra_ema_batch),
("TA-Lib", talib_ema_batch),
("pandas-ta", pandas_ta_ema_batch),
("tulipy", tulipy_ema_batch),
("finta", finta_ema_batch),
("talipp", talipp_ema_batch),
]),
@@ -353,6 +483,7 @@ BATCH_INDICATORS = [
("Wickra", wickra_rsi_batch),
("TA-Lib", talib_rsi_batch),
("pandas-ta", pandas_ta_rsi_batch),
("tulipy", tulipy_rsi_batch),
("finta", finta_rsi_batch),
("talipp", talipp_rsi_batch),
]),
@@ -360,6 +491,7 @@ BATCH_INDICATORS = [
("Wickra", wickra_macd_batch),
("TA-Lib", talib_macd_batch),
("pandas-ta", pandas_ta_macd_batch),
("tulipy", tulipy_macd_batch),
("finta", finta_macd_batch),
("talipp", talipp_macd_batch),
]),
@@ -367,6 +499,7 @@ BATCH_INDICATORS = [
("Wickra", wickra_bollinger_batch),
("TA-Lib", talib_bollinger_batch),
("pandas-ta", pandas_ta_bollinger_batch),
("tulipy", tulipy_bollinger_batch),
("finta", finta_bollinger_batch),
("talipp", talipp_bollinger_batch),
]),
@@ -376,18 +509,35 @@ OHLC_INDICATORS = [
("ATR(14)", [
("Wickra", wickra_atr_batch),
("TA-Lib", talib_atr_batch),
("tulipy", tulipy_atr_batch),
("finta", finta_atr_batch),
("talipp", talipp_atr_batch),
]),
]
STREAMING_INDICATORS = [
("SMA(20)", [
("Wickra", wickra_sma_streaming),
("talipp", talipp_sma_streaming),
]),
("EMA(20)", [
("Wickra", wickra_ema_streaming),
("talipp", talipp_ema_streaming),
]),
("RSI(14)", [
("Wickra", wickra_rsi_streaming),
("TA-Lib", talib_rsi_streaming),
("pandas-ta", pandas_ta_rsi_streaming),
("talipp", talipp_rsi_streaming),
]),
("MACD(12, 26, 9)", [
("Wickra", wickra_macd_streaming),
("talipp", talipp_macd_streaming),
]),
("Bollinger(20, 2.0)", [
("Wickra", wickra_bollinger_streaming),
("talipp", talipp_bollinger_streaming),
]),
]
@@ -501,6 +651,7 @@ def main() -> None:
available = []
if TALIB is not None: available.append("TA-Lib")
if PANDAS_TA is not None: available.append("pandas-ta")
if TULIPY is not None: available.append("tulipy")
if FINTA is not None: available.append("finta")
if TALIPP is not None: available.append("talipp")
print(f"Wickra benchmark suite — wickra=v{WICKRA.__version__}")
+3 -3
View File
@@ -4,17 +4,16 @@ build-backend = "maturin"
[project]
name = "wickra"
version = "0.4.1"
version = "0.6.1"
description = "Streaming-first technical indicators: incremental, fast, install-free."
readme = "README.md"
license = { text = "PolyForm-Noncommercial-1.0.0" }
license = "MIT OR Apache-2.0"
authors = [{ name = "kingchenc", email = "support@wickra.org" }]
requires-python = ">=3.9"
keywords = ["finance", "trading", "indicators", "technical-analysis", "ta-lib"]
classifiers = [
"Development Status :: 4 - Beta",
"Intended Audience :: Financial and Insurance Industry",
"License :: Free for non-commercial use",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: 3.9",
@@ -40,6 +39,7 @@ bench = [
"pytest-benchmark>=4",
"TA-Lib; platform_system != 'Windows'",
"pandas-ta>=0.3.14b",
"tulipy>=0.4; platform_system != 'Windows'",
"talipp>=2",
"finta>=1.3",
"pandas>=2",
+452
View File
@@ -25,6 +25,65 @@ from __future__ import annotations
from ._wickra import (
__version__,
ProjectionOscillator,
VolatilityCone,
VolatilityRatio,
BipowerVariation,
VolatilityOfVolatility,
Garch11,
EwmaVolatility,
PpoHistogram,
MacdHistogram,
TsfOscillator,
Qstick,
GatorOscillator,
KasePermissionStochastic,
WAVE_PM,
POLARIZED_FRACTAL_EFFICIENCY,
TREND_STRENGTH_INDEX,
TTM_TREND,
QQE,
IMI,
ElderRay,
DerivativeOscillator,
RMI,
StochasticCCI,
DynamicMomentumIndex,
RSX,
FisherRSI,
DisparityIndex,
HoltWinters,
GD,
AdaptiveLaguerre,
MedianMA,
EHMA,
GMA,
SWMA,
Expectancy,
WinRate,
RegimeLabel,
JumpIndicator,
TrendLabel,
HighLowRange,
WickRatio,
BodySizePct,
CloseVsOpen,
RollingQuantile,
RollingPercentileRank,
RollingIqr,
RealizedVolatility,
LogReturn,
TSF,
LINEARREG_INTERCEPT,
ROCR100,
ROCR,
ROCP,
AVGPRICE,
MIDPOINT,
MIDPRICE,
DX,
MINUS_DI,
PLUS_DI,
# Trend
SMA,
EMA,
@@ -47,13 +106,18 @@ from ._wickra import (
EVWMA,
# Momentum
RSI,
AnchoredRSI,
MACD,
MACDFIX,
MACDEXT,
Stochastic,
CCI,
ROC,
WilliamsR,
ADX,
ADXR,
PLUS_DM,
MINUS_DM,
MFI,
TRIX,
AwesomeOscillator,
@@ -97,6 +161,7 @@ from ._wickra import (
Keltner,
Donchian,
PSAR,
SAREXT,
NATR,
StdDev,
UlcerIndex,
@@ -142,6 +207,16 @@ from ._wickra import (
MarketFacilitationIndex,
EaseOfMovement,
# Statistics
SpreadBollingerBands,
KalmanHedgeRatio,
GrangerCausality,
VarianceRatio,
BetaNeutralSpread,
DistanceSsd,
SpreadHurst,
OuHalfLife,
RollingCovariance,
RollingCorrelation,
TypicalPrice,
MedianPrice,
WeightedClose,
@@ -162,6 +237,7 @@ from ._wickra import (
PearsonCorrelation,
Beta,
PairwiseBeta,
SpreadAr1Coefficient,
PairSpreadZScore,
LeadLagCrossCorrelation,
Cointegration,
@@ -180,11 +256,18 @@ from ._wickra import (
EhlersStochastic,
EmpiricalModeDecomposition,
HilbertDominantCycle,
HT_DCPHASE,
HT_PHASOR,
HT_TRENDMODE,
AdaptiveCycle,
SineWave,
MAMA,
FAMA,
# Bands & Channels
ProjectionBands,
MedianChannel,
BomarBands,
QuartileBands,
MaEnvelope,
AccelerationBands,
StarcBands,
@@ -222,8 +305,14 @@ from ._wickra import (
HeikinAshi,
# Market Profile
ValueArea,
VolumeProfile,
TpoProfile,
InitialBalance,
OpeningRange,
# Alt-Chart Bars
RenkoBars,
KagiBars,
PointAndFigureBars,
# Candlestick patterns
Doji,
Hammer,
@@ -240,6 +329,130 @@ from ._wickra import (
SpinningTop,
ThreeInside,
ThreeOutside,
TwoCrows,
UpsideGapTwoCrows,
IdenticalThreeCrows,
ThreeLineStrike,
ThreeStarsInSouth,
AbandonedBaby,
AdvanceBlock,
BeltHold,
Breakaway,
Counterattack,
DojiStar,
DragonflyDoji,
GravestoneDoji,
LongLeggedDoji,
RickshawMan,
EveningDojiStar,
MorningDojiStar,
GapSideBySideWhite,
HighWave,
Hikkake,
HikkakeModified,
HomingPigeon,
OnNeck,
InNeck,
Thrusting,
SeparatingLines,
Kicking,
KickingByLength,
LadderBottom,
MatHold,
MatchingLow,
LongLine,
ShortLine,
RisingThreeMethods,
FallingThreeMethods,
UpsideGapThreeMethods,
DownsideGapThreeMethods,
StalledPattern,
StickSandwich,
Takuri,
ClosingMarubozu,
OpeningMarubozu,
TasukiGap,
UniqueThreeRiver,
ConcealingBabySwallow,
# Chart patterns
CupAndHandle,
RectangleRange,
FlagPennant,
Wedge,
Triangle,
HeadAndShoulders,
TripleTopBottom,
DoubleTopBottom,
# Harmonic patterns
ThreeDrives,
Cypher,
Shark,
Crab,
Bat,
Butterfly,
Gartley,
Abcd,
# Fibonacci
FibTimeZones,
FibChannel,
FibArcs,
FibFan,
FibConfluence,
GoldenPocket,
AutoFib,
FibProjection,
FibExtension,
FibRetracement,
# Microstructure: order book
OrderFlowImbalance,
OrderBookImbalanceTop1,
OrderBookImbalanceTopN,
OrderBookImbalanceFull,
Microprice,
QuotedSpread,
DepthSlope,
# Microstructure: trade flow
RollMeasure,
AmihudIlliquidity,
Vpin,
SignedVolume,
CumulativeVolumeDelta,
TradeImbalance,
# Microstructure: price impact
EffectiveSpread,
RealizedSpread,
KylesLambda,
# Microstructure: footprint
Footprint,
# Derivatives
FundingRate,
FundingRateMean,
FundingRateZScore,
FundingBasis,
OpenInterestDelta,
OIPriceDivergence,
OIWeighted,
LongShortRatio,
TakerBuySellRatio,
LiquidationFeatures,
TermStructureBasis,
CalendarSpread,
# Market Breadth
TickIndex,
AbsoluteBreadthIndex,
CumulativeVolumeIndex,
BullishPercentIndex,
UpDownVolumeRatio,
PercentAboveMa,
HighLowIndex,
NewHighsNewLows,
BreadthThrust,
Trin,
McClellanSummationIndex,
McClellanOscillator,
AdVolumeLine,
AdvanceDeclineRatio,
AdvanceDecline,
# Risk / Performance
SharpeRatio,
SortinoRatio,
@@ -258,9 +471,81 @@ from ._wickra import (
TreynorRatio,
InformationRatio,
Alpha,
# Seasonality & Session
SessionVwap,
SessionHighLow,
SessionRange,
AverageDailyRange,
OvernightGap,
OvernightIntradayReturn,
TurnOfMonth,
SeasonalZScore,
TimeOfDayReturnProfile,
DayOfWeekProfile,
IntradayVolatilityProfile,
VolumeByTimeProfile,
)
__all__ = [
"ProjectionOscillator",
"VolatilityCone",
"VolatilityRatio",
"BipowerVariation",
"VolatilityOfVolatility",
"Garch11",
"EwmaVolatility",
"PpoHistogram",
"MacdHistogram",
"TsfOscillator",
"Qstick",
"GatorOscillator",
"KasePermissionStochastic",
"WAVE_PM",
"POLARIZED_FRACTAL_EFFICIENCY",
"TREND_STRENGTH_INDEX",
"TTM_TREND",
"QQE",
"IMI",
"ElderRay",
"DerivativeOscillator",
"RMI",
"StochasticCCI",
"DynamicMomentumIndex",
"RSX",
"FisherRSI",
"DisparityIndex",
"HoltWinters",
"GD",
"AdaptiveLaguerre",
"MedianMA",
"EHMA",
"GMA",
"SWMA",
"Expectancy",
"WinRate",
"RegimeLabel",
"JumpIndicator",
"TrendLabel",
"HighLowRange",
"WickRatio",
"BodySizePct",
"CloseVsOpen",
"RollingQuantile",
"RollingPercentileRank",
"RollingIqr",
"RealizedVolatility",
"LogReturn",
"TSF",
"LINEARREG_INTERCEPT",
"ROCR100",
"ROCR",
"ROCP",
"AVGPRICE",
"MIDPOINT",
"MIDPRICE",
"DX",
"MINUS_DI",
"PLUS_DI",
"__version__",
# Trend
"SMA",
@@ -284,13 +569,18 @@ __all__ = [
"EVWMA",
# Momentum
"RSI",
"AnchoredRSI",
"MACD",
"MACDFIX",
"MACDEXT",
"Stochastic",
"CCI",
"ROC",
"WilliamsR",
"ADX",
"ADXR",
"PLUS_DM",
"MINUS_DM",
"MFI",
"TRIX",
"AwesomeOscillator",
@@ -334,6 +624,7 @@ __all__ = [
"Keltner",
"Donchian",
"PSAR",
"SAREXT",
"NATR",
"StdDev",
"UlcerIndex",
@@ -379,6 +670,16 @@ __all__ = [
"MarketFacilitationIndex",
"EaseOfMovement",
# Statistics
"SpreadBollingerBands",
"KalmanHedgeRatio",
"GrangerCausality",
"VarianceRatio",
"BetaNeutralSpread",
"DistanceSsd",
"SpreadHurst",
"OuHalfLife",
"RollingCovariance",
"RollingCorrelation",
"TypicalPrice",
"MedianPrice",
"WeightedClose",
@@ -399,6 +700,7 @@ __all__ = [
"PearsonCorrelation",
"Beta",
"PairwiseBeta",
"SpreadAr1Coefficient",
"PairSpreadZScore",
"LeadLagCrossCorrelation",
"Cointegration",
@@ -417,11 +719,18 @@ __all__ = [
"EhlersStochastic",
"EmpiricalModeDecomposition",
"HilbertDominantCycle",
"HT_DCPHASE",
"HT_PHASOR",
"HT_TRENDMODE",
"AdaptiveCycle",
"SineWave",
"MAMA",
"FAMA",
# Bands & Channels
"ProjectionBands",
"MedianChannel",
"BomarBands",
"QuartileBands",
"MaEnvelope",
"AccelerationBands",
"StarcBands",
@@ -459,8 +768,14 @@ __all__ = [
"HeikinAshi",
# Market Profile
"ValueArea",
"VolumeProfile",
"TpoProfile",
"InitialBalance",
"OpeningRange",
# Alt-Chart Bars
"RenkoBars",
"KagiBars",
"PointAndFigureBars",
# Candlestick patterns
"Doji",
"Hammer",
@@ -477,6 +792,130 @@ __all__ = [
"SpinningTop",
"ThreeInside",
"ThreeOutside",
"TwoCrows",
"UpsideGapTwoCrows",
"IdenticalThreeCrows",
"ThreeLineStrike",
"ThreeStarsInSouth",
"AbandonedBaby",
"AdvanceBlock",
"BeltHold",
"Breakaway",
"Counterattack",
"DojiStar",
"DragonflyDoji",
"GravestoneDoji",
"LongLeggedDoji",
"RickshawMan",
"EveningDojiStar",
"MorningDojiStar",
"GapSideBySideWhite",
"HighWave",
"Hikkake",
"HikkakeModified",
"HomingPigeon",
"OnNeck",
"InNeck",
"Thrusting",
"SeparatingLines",
"Kicking",
"KickingByLength",
"LadderBottom",
"MatHold",
"MatchingLow",
"LongLine",
"ShortLine",
"RisingThreeMethods",
"FallingThreeMethods",
"UpsideGapThreeMethods",
"DownsideGapThreeMethods",
"StalledPattern",
"StickSandwich",
"Takuri",
"ClosingMarubozu",
"OpeningMarubozu",
"TasukiGap",
"UniqueThreeRiver",
"ConcealingBabySwallow",
# Chart patterns
"CupAndHandle",
"RectangleRange",
"FlagPennant",
"Wedge",
"Triangle",
"HeadAndShoulders",
"TripleTopBottom",
"DoubleTopBottom",
# Harmonic patterns
"ThreeDrives",
"Cypher",
"Shark",
"Crab",
"Bat",
"Butterfly",
"Gartley",
"Abcd",
# Fibonacci
"FibTimeZones",
"FibChannel",
"FibArcs",
"FibFan",
"FibConfluence",
"GoldenPocket",
"AutoFib",
"FibProjection",
"FibExtension",
"FibRetracement",
# Microstructure: order book
"OrderFlowImbalance",
"OrderBookImbalanceTop1",
"OrderBookImbalanceTopN",
"OrderBookImbalanceFull",
"Microprice",
"QuotedSpread",
"DepthSlope",
# Microstructure: trade flow
"RollMeasure",
"AmihudIlliquidity",
"Vpin",
"SignedVolume",
"CumulativeVolumeDelta",
"TradeImbalance",
# Microstructure: price impact
"EffectiveSpread",
"RealizedSpread",
"KylesLambda",
# Microstructure: footprint
"Footprint",
# Derivatives
"FundingRate",
"FundingRateMean",
"FundingRateZScore",
"FundingBasis",
"OpenInterestDelta",
"OIPriceDivergence",
"OIWeighted",
"LongShortRatio",
"TakerBuySellRatio",
"LiquidationFeatures",
"TermStructureBasis",
"CalendarSpread",
# Market Breadth
"TickIndex",
"AbsoluteBreadthIndex",
"CumulativeVolumeIndex",
"BullishPercentIndex",
"UpDownVolumeRatio",
"PercentAboveMa",
"HighLowIndex",
"NewHighsNewLows",
"BreadthThrust",
"Trin",
"McClellanSummationIndex",
"McClellanOscillator",
"AdVolumeLine",
"AdvanceDeclineRatio",
"AdvanceDecline",
# Risk / Performance
"SharpeRatio",
"SortinoRatio",
@@ -495,4 +934,17 @@ __all__ = [
"TreynorRatio",
"InformationRatio",
"Alpha",
# Seasonality & Session
"SessionVwap",
"SessionHighLow",
"SessionRange",
"AverageDailyRange",
"OvernightGap",
"OvernightIntradayReturn",
"TurnOfMonth",
"SeasonalZScore",
"TimeOfDayReturnProfile",
"DayOfWeekProfile",
"IntradayVolatilityProfile",
"VolumeByTimeProfile",
]
File diff suppressed because it is too large Load Diff
@@ -166,3 +166,115 @@ def test_family_10_ehlers_rejects_invalid_parameters():
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)
def test_funding_rate_mean_zero_window_raises():
with pytest.raises(ValueError):
ta.FundingRateMean(0)
def test_funding_rate_zscore_zero_window_raises():
with pytest.raises(ValueError):
ta.FundingRateZScore(0)
def test_funding_basis_non_positive_index_raises():
with pytest.raises(ValueError):
ta.FundingBasis().update(100.0, 0.0)
def test_funding_rate_non_finite_raises():
with pytest.raises(ValueError):
ta.FundingRate().update(float("nan"))
def test_oi_price_divergence_zero_window_raises():
with pytest.raises(ValueError):
ta.OIPriceDivergence(0)
def test_oi_weighted_non_positive_mark_raises():
with pytest.raises(ValueError):
ta.OIWeighted().update(0.0, 100.0)
def test_term_structure_basis_non_positive_index_raises():
with pytest.raises(ValueError):
ta.TermStructureBasis().update(100.0, 0.0)
def test_calendar_spread_non_positive_mark_raises():
with pytest.raises(ValueError):
ta.CalendarSpread().update(100.0, 0.0)
+211
View File
@@ -66,6 +66,14 @@ def test_rsi_wilder_textbook_first_value():
assert math.isclose(out[14], 70.464, abs_tol=0.05)
def test_anchored_rsi_cumulative_reference():
"""Cumulative anchored RSI: 10 -> 11 (+1) -> 9 (-2) -> 12 (+3)."""
out = ta.AnchoredRSI().batch(np.array([10.0, 11.0, 9.0, 12.0]))
assert math.isclose(out[1], 100.0, abs_tol=1e-9)
assert math.isclose(out[2], 100.0 - 100.0 / 1.5, abs_tol=1e-6)
assert math.isclose(out[3], 100.0 - 100.0 / 3.0, abs_tol=1e-6)
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
@@ -823,3 +831,206 @@ def test_yang_zhang_zero_movement_yields_zero():
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)
def test_funding_rate_reference_values():
assert ta.FundingRate().update(0.0001) == pytest.approx(0.0001)
assert ta.FundingRate().update(-0.0003) == pytest.approx(-0.0003)
def test_funding_rate_mean_reference_value():
frm = ta.FundingRateMean(2)
assert frm.update(0.001) is None # warming up
# Window [0.001, 0.003] -> mean 0.002.
assert frm.update(0.003) == pytest.approx(0.002)
def test_funding_rate_zscore_reference_value():
z = ta.FundingRateZScore(2)
assert z.update(0.001) is None # warming up
# Window [0.001, 0.003]: mean 0.002, population stddev 0.001 -> +1.
assert z.update(0.003) == pytest.approx(1.0, abs=1e-9)
def test_funding_basis_reference_value():
# mark 100.5 vs index 100.0 -> (100.5 - 100.0) / 100.0 = 0.005.
assert ta.FundingBasis().update(100.5, 100.0) == pytest.approx(0.005)
# A discount reads negative.
assert ta.FundingBasis().update(99.5, 100.0) == pytest.approx(-0.005)
def test_open_interest_delta_reference_value():
oid = ta.OpenInterestDelta()
assert oid.update(1000.0) is None # seeds the previous OI
assert oid.update(1250.0) == pytest.approx(250.0)
assert oid.update(1100.0) == pytest.approx(-150.0)
def test_oi_price_divergence_reference_value():
div = ta.OIPriceDivergence(1)
assert div.update(1000.0, 100.0) is None # warming up
# OI +10% while price flat -> divergence +0.1.
assert div.update(1100.0, 100.0) == pytest.approx(0.1)
def test_oi_weighted_reference_value():
oiw = ta.OIWeighted()
assert oiw.update(100.0, 10.0) == pytest.approx(100.0)
# (100·10 + 110·30) / 40 = 107.5.
assert oiw.update(110.0, 30.0) == pytest.approx(107.5)
def test_long_short_ratio_reference_value():
# 600 longs vs 400 shorts -> 1.5.
assert ta.LongShortRatio().update(600.0, 400.0) == pytest.approx(1.5)
# No short side -> 0.0.
assert ta.LongShortRatio().update(600.0, 0.0) == pytest.approx(0.0)
def test_taker_buy_sell_ratio_reference_value():
# 60 taker buys vs 40 taker sells -> 1.5.
assert ta.TakerBuySellRatio().update(60.0, 40.0) == pytest.approx(1.5)
# No taker sell volume -> 0.0.
assert ta.TakerBuySellRatio().update(60.0, 0.0) == pytest.approx(0.0)
def test_liquidation_features_reference_value():
# 30 long vs 10 short: (long, short, net, total, imbalance).
out = ta.LiquidationFeatures().update(30.0, 10.0)
assert out == pytest.approx((30.0, 10.0, 20.0, 40.0, 0.5))
def test_term_structure_basis_reference_value():
# futures 102 vs index 100 -> 0.02 (contango).
assert ta.TermStructureBasis().update(102.0, 100.0) == pytest.approx(0.02)
# Backwardation reads negative.
assert ta.TermStructureBasis().update(98.0, 100.0) == pytest.approx(-0.02)
def test_calendar_spread_reference_value():
# futures 101 vs perpetual mark 100 -> 0.01.
assert ta.CalendarSpread().update(101.0, 100.0) == pytest.approx(0.01)
assert ta.CalendarSpread().update(99.0, 100.0) == pytest.approx(-0.01)
+91
View File
@@ -12,6 +12,7 @@ SCALAR_INDICATORS = [
(ta.EMA, (14,)),
(ta.WMA, (14,)),
(ta.RSI, (14,)),
(ta.AnchoredRSI, ()),
(ta.MACD, ()),
(ta.BollingerBands, ()),
]
@@ -42,6 +43,7 @@ def test_reset_returns_to_initial_state(cls, args):
(ta.EMA, (14,), 14),
(ta.WMA, (14,), 14),
(ta.RSI, (14,), 15),
(ta.AnchoredRSI, (), 2),
(ta.BollingerBands, (20, 2.0), 20),
],
)
@@ -129,3 +131,92 @@ def test_ehlers_indicators_lifecycle():
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
+132
View File
@@ -0,0 +1,132 @@
"""Streaming-vs-batch equivalence and reference values for the Seasonality &
Session family.
These indicators read the full candle (including ``timestamp``), so they have a
dedicated test rather than joining the timestamp-less parametrize harness in
``test_new_indicators.py``.
"""
import numpy as np
import pytest
import wickra as ta
HOUR_MS = 3_600_000
@pytest.fixture(scope="module")
def candle_columns():
"""240 hourly candles (10 days) with valid OHLCV and epoch-ms timestamps."""
n = 240
t = np.arange(n, dtype=np.float64)
close = 100.0 + np.sin(t * 0.3) * 5.0 + np.cos(t * 0.1) * 3.0
open_ = close + np.sin(t * 0.5) * 0.5
high = np.maximum(open_, close) + 1.0
low = np.minimum(open_, close) - 1.0
volume = 1000.0 + (t % 24) * 50.0
timestamp = (np.arange(n, dtype=np.int64)) * HOUR_MS
return open_, high, low, close, volume, timestamp
def _candles(cols):
open_, high, low, close, volume, timestamp = cols
return [
(open_[i], high[i], low[i], close[i], volume[i], int(timestamp[i]))
for i in range(len(close))
]
def _check_scalar(make, cols):
candles = _candles(cols)
a, b = make(), make()
stream = np.array(
[np.nan if (v := a.update(c)) is None else v for c in candles],
dtype=np.float64,
)
batch = np.asarray(b.batch(*cols))
np.testing.assert_allclose(stream, batch, equal_nan=True, rtol=1e-9, atol=1e-9)
def _check_matrix(make, k, cols):
candles = _candles(cols)
a, b = make(), make()
rows = []
for c in candles:
out = a.update(c)
rows.append(np.full(k, np.nan) if out is None else np.asarray(out, dtype=float))
stream = np.vstack(rows)
batch = np.asarray(b.batch(*cols))
assert batch.shape == (len(candles), k)
np.testing.assert_allclose(stream, batch, equal_nan=True, rtol=1e-9, atol=1e-9)
SCALAR = [
lambda: ta.SessionVwap(0),
lambda: ta.OvernightGap(0),
lambda: ta.SeasonalZScore(0),
lambda: ta.AverageDailyRange(3, 0),
lambda: ta.TurnOfMonth(3, 1, 0),
]
MATRIX = [
(lambda: ta.SessionHighLow(0), 2),
(lambda: ta.SessionRange(0), 3),
(lambda: ta.OvernightIntradayReturn(0), 2),
(lambda: ta.TimeOfDayReturnProfile(24, 0), 24),
(lambda: ta.IntradayVolatilityProfile(12, 0), 12),
(lambda: ta.VolumeByTimeProfile(24, 0), 24),
(lambda: ta.DayOfWeekProfile(0), 7),
]
@pytest.mark.parametrize("make", SCALAR)
def test_scalar_streaming_equals_batch(make, candle_columns):
_check_scalar(make, candle_columns)
@pytest.mark.parametrize("make,k", MATRIX)
def test_matrix_streaming_equals_batch(make, k, candle_columns):
_check_matrix(make, k, candle_columns)
def test_session_vwap_reference():
vwap = ta.SessionVwap(0)
# typical = close for a flat candle; volume-weighted within the day.
v1 = vwap.update((100.0, 100.0, 100.0, 100.0, 10.0, 0))
assert v1 == pytest.approx(100.0)
v2 = vwap.update((110.0, 110.0, 110.0, 110.0, 30.0, HOUR_MS))
assert v2 == pytest.approx(107.5)
# New day re-anchors.
v3 = vwap.update((200.0, 200.0, 200.0, 200.0, 5.0, 24 * HOUR_MS))
assert v3 == pytest.approx(200.0)
def test_overnight_gap_reference():
gap = ta.OvernightGap(0)
assert gap.update((99.0, 101.0, 98.0, 100.0, 1.0, 0)) is None
g = gap.update((105.0, 106.0, 104.0, 105.5, 1.0, 24 * HOUR_MS))
assert g == pytest.approx(0.05)
def test_session_high_low_reference():
shl = ta.SessionHighLow(0)
shl.update((100.0, 105.0, 99.0, 101.0, 1.0, 0))
out = shl.update((101.0, 108.0, 100.0, 107.0, 1.0, HOUR_MS))
assert out == (108.0, 99.0)
def test_volume_by_time_profile_reference():
prof = ta.VolumeByTimeProfile(24, 0)
out = prof.update((100.0, 100.0, 100.0, 100.0, 500.0, HOUR_MS)) # 01:00 -> bucket 1
assert out[1] == pytest.approx(500.0)
assert out[0] == pytest.approx(0.0)
def test_rejects_zero_buckets():
with pytest.raises(ValueError):
ta.TimeOfDayReturnProfile(0, 0)
def test_average_daily_range_rejects_zero_period():
with pytest.raises(ValueError):
ta.AverageDailyRange(0, 0)
+70
View File
@@ -97,3 +97,73 @@ def test_ehlers_super_smoother_batch_shape(sine_prices):
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
@@ -201,3 +201,50 @@ def test_opening_range_streaming_matches_batch(ohlc_series):
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)
+3 -5
View File
@@ -3,7 +3,7 @@
[![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)
[![License: MIT OR Apache-2.0](https://img.shields.io/badge/license-MIT_OR_Apache--2.0-blue)](https://github.com/wickra-lib/wickra#license)
**Streaming-first technical indicators in the browser. `npm install
wickra-wasm` — pure WebAssembly, runs anywhere a modern JS engine does.**
@@ -66,7 +66,5 @@ 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).
Licensed under either of [Apache-2.0](https://github.com/wickra-lib/wickra/blob/main/LICENSE-APACHE)
or [MIT](https://github.com/wickra-lib/wickra/blob/main/LICENSE-MIT) at your option.
+5177 -4
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,4 +1,4 @@
# Proper nouns that appear in indicator documentation. They are real names,
# not code identifiers, so `clippy::doc_markdown` must not demand backticks.
# `..` keeps clippy's built-in default identifier list in addition to these.
doc-valid-idents = ["LeBeau", ".."]
doc-valid-idents = ["LeBeau", "McClellan", ".."]
+22
View File
@@ -0,0 +1,22 @@
[package]
name = "wickra-bench"
version.workspace = true
edition.workspace = true
license.workspace = true
publish = false
description = "Internal cross-library benchmark harness (not published)."
[lints]
workspace = true
[dev-dependencies]
wickra = { path = "../wickra" }
wickra-data = { path = "../wickra-data" }
criterion = { workspace = true }
kand = "0.2.2"
ta = "0.5.0"
yata = "0.7.0"
[[bench]]
name = "cross_lib"
harness = false
+695
View File
@@ -0,0 +1,695 @@
//! Cross-library Criterion benchmark: Wickra vs `kand` vs `ta` (ta-rs) vs `yata`.
//!
//! All four are pure-Rust technical-analysis crates, so this is a like-for-like
//! Rust-vs-Rust comparison with no language-binding overhead. It feeds the exact
//! same BTCUSDT 1-minute candle series used by `crates/wickra/benches/indicators.rs`.
//!
//! Two arenas, kept honest:
//!
//! * **Streaming** (`*/stream`): one value fed at a time. Wickra (`Indicator::update`),
//! ta-rs (`Next::next`) and yata (`Method::next`) carry their own state; `kand`
//! exposes stateless `*_inc` helpers, so the per-tick state is threaded manually
//! here, seeded from `kand`'s own batch output (the seed is computed outside the
//! timed closure). yata only appears for SMA/EMA — its RSI/MACD/Bollinger/ATR are
//! exposed through a heavier signal-oriented indicator API, not a raw-value method,
//! so they are intentionally left out rather than compared unfairly.
//! * **Batch** (`*/batch`): the whole series at once. Only Wickra (`BatchExt::batch`)
//! and `kand` (TA-Lib-style fill-the-output-slice functions) have a real batch API;
//! ta-rs and yata are streaming-only and are deliberately absent from this arena.
//!
//! Run: `cargo bench -p wickra-bench`
// Each indicator's benchmark group spells out every library arm explicitly, which
// runs a few groups over the 100-line lint threshold; that verbosity is the point.
#![allow(clippy::too_many_lines)]
use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion, Throughput};
use std::hint::black_box;
use wickra::{Atr, BatchExt, BollingerBands, Candle, Ema, Indicator, MacdIndicator, Rsi, Sma};
use wickra_data::csv::CandleReader;
use yata::prelude::Method;
const SIZES: &[usize] = &[1_000, 10_000, 50_000];
const SMA_PERIOD: usize = 20;
const EMA_PERIOD: usize = 20;
const RSI_PERIOD: usize = 14;
const ATR_PERIOD: usize = 14;
const BB_PERIOD: usize = 20;
const BB_DEV: f64 = 2.0;
const MACD_FAST: usize = 12;
const MACD_SLOW: usize = 26;
const MACD_SIGNAL: usize = 9;
fn load_candles() -> Vec<Candle> {
let path = concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../examples/data/btcusdt-1m.csv"
);
CandleReader::open(path)
.expect("dataset present")
.read_all()
.expect("valid OHLCV rows")
}
/// Mean of the first `period` samples — the warmup seed for `kand`'s SMA/EMA `*_inc`.
fn window_mean(series: &[f64], period: usize) -> f64 {
series[..period].iter().sum::<f64>() / period as f64
}
fn sma_group(crit: &mut Criterion, closes: &[f64]) {
let mut group = crit.benchmark_group("sma_20");
for &len in SIZES {
let len = len.min(closes.len());
let series: &[f64] = &closes[..len];
group.throughput(Throughput::Elements(len as u64));
group.bench_with_input(
BenchmarkId::new("wickra/stream", len),
&series,
|bencher, &series| {
bencher.iter(|| {
let mut ind = Sma::new(SMA_PERIOD).unwrap();
for &price in series {
black_box(ind.update(price));
}
});
},
);
group.bench_with_input(
BenchmarkId::new("wickra/batch", len),
&series,
|bencher, &series| {
bencher.iter(|| {
let mut ind = Sma::new(SMA_PERIOD).unwrap();
black_box(ind.batch(series));
});
},
);
group.bench_with_input(
BenchmarkId::new("kand/stream", len),
&series,
|bencher, &series| {
let seed = window_mean(series, SMA_PERIOD);
bencher.iter(|| {
let mut prev = seed;
for idx in SMA_PERIOD..series.len() {
prev = kand::ohlcv::sma::sma_inc(
prev,
series[idx],
series[idx - SMA_PERIOD],
SMA_PERIOD,
)
.unwrap();
black_box(prev);
}
});
},
);
group.bench_with_input(
BenchmarkId::new("kand/batch", len),
&series,
|bencher, &series| {
bencher.iter(|| {
let mut out = vec![0.0; series.len()];
kand::ohlcv::sma::sma(series, SMA_PERIOD, &mut out).unwrap();
black_box(&out);
});
},
);
group.bench_with_input(
BenchmarkId::new("ta-rs/stream", len),
&series,
|bencher, &series| {
bencher.iter(|| {
let mut ind = ta::indicators::SimpleMovingAverage::new(SMA_PERIOD).unwrap();
for &price in series {
black_box(ta::Next::next(&mut ind, price));
}
});
},
);
group.bench_with_input(
BenchmarkId::new("yata/stream", len),
&series,
|bencher, &series| {
bencher.iter(|| {
let mut ind = yata::methods::SMA::new(SMA_PERIOD as u8, &series[0]).unwrap();
for price in series {
black_box(ind.next(price));
}
});
},
);
}
group.finish();
}
fn ema_group(crit: &mut Criterion, closes: &[f64]) {
let mut group = crit.benchmark_group("ema_20");
for &len in SIZES {
let len = len.min(closes.len());
let series: &[f64] = &closes[..len];
group.throughput(Throughput::Elements(len as u64));
group.bench_with_input(
BenchmarkId::new("wickra/stream", len),
&series,
|bencher, &series| {
bencher.iter(|| {
let mut ind = Ema::new(EMA_PERIOD).unwrap();
for &price in series {
black_box(ind.update(price));
}
});
},
);
group.bench_with_input(
BenchmarkId::new("wickra/batch", len),
&series,
|bencher, &series| {
bencher.iter(|| {
let mut ind = Ema::new(EMA_PERIOD).unwrap();
black_box(ind.batch(series));
});
},
);
group.bench_with_input(
BenchmarkId::new("kand/stream", len),
&series,
|bencher, &series| {
let seed = window_mean(series, EMA_PERIOD);
bencher.iter(|| {
let mut prev = seed;
for &price in &series[EMA_PERIOD..] {
prev = kand::ohlcv::ema::ema_inc(price, prev, EMA_PERIOD, None).unwrap();
black_box(prev);
}
});
},
);
group.bench_with_input(
BenchmarkId::new("kand/batch", len),
&series,
|bencher, &series| {
bencher.iter(|| {
let mut out = vec![0.0; series.len()];
kand::ohlcv::ema::ema(series, EMA_PERIOD, None, &mut out).unwrap();
black_box(&out);
});
},
);
group.bench_with_input(
BenchmarkId::new("ta-rs/stream", len),
&series,
|bencher, &series| {
bencher.iter(|| {
let mut ind =
ta::indicators::ExponentialMovingAverage::new(EMA_PERIOD).unwrap();
for &price in series {
black_box(ta::Next::next(&mut ind, price));
}
});
},
);
group.bench_with_input(
BenchmarkId::new("yata/stream", len),
&series,
|bencher, &series| {
bencher.iter(|| {
let mut ind = yata::methods::EMA::new(EMA_PERIOD as u8, &series[0]).unwrap();
for price in series {
black_box(ind.next(price));
}
});
},
);
}
group.finish();
}
fn rsi_group(crit: &mut Criterion, closes: &[f64]) {
let mut group = crit.benchmark_group("rsi_14");
for &len in SIZES {
let len = len.min(closes.len());
let series: &[f64] = &closes[..len];
group.throughput(Throughput::Elements(len as u64));
group.bench_with_input(
BenchmarkId::new("wickra/stream", len),
&series,
|bencher, &series| {
bencher.iter(|| {
let mut ind = Rsi::new(RSI_PERIOD).unwrap();
for &price in series {
black_box(ind.update(price));
}
});
},
);
group.bench_with_input(
BenchmarkId::new("wickra/batch", len),
&series,
|bencher, &series| {
bencher.iter(|| {
let mut ind = Rsi::new(RSI_PERIOD).unwrap();
black_box(ind.batch(series));
});
},
);
group.bench_with_input(
BenchmarkId::new("kand/stream", len),
&series,
|bencher, &series| {
// Wilder seed: simple average of the first `period` gains and losses.
let mut gain = 0.0;
let mut loss = 0.0;
for idx in 1..=RSI_PERIOD {
let delta = series[idx] - series[idx - 1];
if delta > 0.0 {
gain += delta;
} else {
loss -= delta;
}
}
let seed_gain = gain / RSI_PERIOD as f64;
let seed_loss = loss / RSI_PERIOD as f64;
bencher.iter(|| {
let mut avg_gain = seed_gain;
let mut avg_loss = seed_loss;
let mut prev_price = series[RSI_PERIOD];
for &price in &series[RSI_PERIOD + 1..] {
let (rsi, next_gain, next_loss) = kand::ohlcv::rsi::rsi_inc(
price, prev_price, avg_gain, avg_loss, RSI_PERIOD,
)
.unwrap();
avg_gain = next_gain;
avg_loss = next_loss;
prev_price = price;
black_box(rsi);
}
});
},
);
group.bench_with_input(
BenchmarkId::new("kand/batch", len),
&series,
|bencher, &series| {
bencher.iter(|| {
let mut rsi = vec![0.0; series.len()];
let mut avg_gain = vec![0.0; series.len()];
let mut avg_loss = vec![0.0; series.len()];
kand::ohlcv::rsi::rsi(
series,
RSI_PERIOD,
&mut rsi,
&mut avg_gain,
&mut avg_loss,
)
.unwrap();
black_box(&rsi);
});
},
);
group.bench_with_input(
BenchmarkId::new("ta-rs/stream", len),
&series,
|bencher, &series| {
bencher.iter(|| {
let mut ind = ta::indicators::RelativeStrengthIndex::new(RSI_PERIOD).unwrap();
for &price in series {
black_box(ta::Next::next(&mut ind, price));
}
});
},
);
}
group.finish();
}
fn macd_group(crit: &mut Criterion, closes: &[f64]) {
let mut group = crit.benchmark_group("macd_12_26_9");
for &len in SIZES {
let len = len.min(closes.len());
let series: &[f64] = &closes[..len];
group.throughput(Throughput::Elements(len as u64));
group.bench_with_input(
BenchmarkId::new("wickra/stream", len),
&series,
|bencher, &series| {
bencher.iter(|| {
let mut ind = MacdIndicator::classic();
for &price in series {
black_box(ind.update(price));
}
});
},
);
group.bench_with_input(
BenchmarkId::new("wickra/batch", len),
&series,
|bencher, &series| {
bencher.iter(|| {
let mut ind = MacdIndicator::classic();
black_box(ind.batch(series));
});
},
);
group.bench_with_input(
BenchmarkId::new("kand/stream", len),
&series,
|bencher, &series| {
// Seed the fast/slow/signal EMAs from kand's own warmed-up batch state.
let lookback =
kand::ohlcv::macd::lookback(MACD_FAST, MACD_SLOW, MACD_SIGNAL).unwrap();
let mut macd_line = vec![0.0; series.len()];
let mut signal_line = vec![0.0; series.len()];
let mut histogram = vec![0.0; series.len()];
let mut fast_ema = vec![0.0; series.len()];
let mut slow_ema = vec![0.0; series.len()];
kand::ohlcv::macd::macd(
series,
MACD_FAST,
MACD_SLOW,
MACD_SIGNAL,
&mut macd_line,
&mut signal_line,
&mut histogram,
&mut fast_ema,
&mut slow_ema,
)
.unwrap();
let seed_fast = fast_ema[lookback];
let seed_slow = slow_ema[lookback];
let seed_signal = signal_line[lookback];
bencher.iter(|| {
// macd_inc returns (macd, signal, hist) but not the new EMAs, so the
// fast/slow/signal state is threaded with kand's own ema_inc primitive.
let mut prev_fast = seed_fast;
let mut prev_slow = seed_slow;
let mut prev_signal = seed_signal;
for &price in &series[lookback + 1..] {
let fast =
kand::ohlcv::ema::ema_inc(price, prev_fast, MACD_FAST, None).unwrap();
let slow =
kand::ohlcv::ema::ema_inc(price, prev_slow, MACD_SLOW, None).unwrap();
let macd = fast - slow;
let signal =
kand::ohlcv::ema::ema_inc(macd, prev_signal, MACD_SIGNAL, None)
.unwrap();
prev_fast = fast;
prev_slow = slow;
prev_signal = signal;
black_box((macd, signal, macd - signal));
}
});
},
);
group.bench_with_input(
BenchmarkId::new("kand/batch", len),
&series,
|bencher, &series| {
bencher.iter(|| {
let mut macd_line = vec![0.0; series.len()];
let mut signal_line = vec![0.0; series.len()];
let mut histogram = vec![0.0; series.len()];
let mut fast_ema = vec![0.0; series.len()];
let mut slow_ema = vec![0.0; series.len()];
kand::ohlcv::macd::macd(
series,
MACD_FAST,
MACD_SLOW,
MACD_SIGNAL,
&mut macd_line,
&mut signal_line,
&mut histogram,
&mut fast_ema,
&mut slow_ema,
)
.unwrap();
black_box(&macd_line);
});
},
);
group.bench_with_input(
BenchmarkId::new("ta-rs/stream", len),
&series,
|bencher, &series| {
bencher.iter(|| {
let mut ind = ta::indicators::MovingAverageConvergenceDivergence::new(
MACD_FAST,
MACD_SLOW,
MACD_SIGNAL,
)
.unwrap();
for &price in series {
black_box(ta::Next::next(&mut ind, price));
}
});
},
);
}
group.finish();
}
fn bbands_group(crit: &mut Criterion, closes: &[f64]) {
let mut group = crit.benchmark_group("bollinger_20_2");
for &len in SIZES {
let len = len.min(closes.len());
let series: &[f64] = &closes[..len];
group.throughput(Throughput::Elements(len as u64));
group.bench_with_input(
BenchmarkId::new("wickra/stream", len),
&series,
|bencher, &series| {
bencher.iter(|| {
let mut ind = BollingerBands::new(BB_PERIOD, BB_DEV).unwrap();
for &price in series {
black_box(ind.update(price));
}
});
},
);
group.bench_with_input(
BenchmarkId::new("wickra/batch", len),
&series,
|bencher, &series| {
bencher.iter(|| {
let mut ind = BollingerBands::new(BB_PERIOD, BB_DEV).unwrap();
black_box(ind.batch(series));
});
},
);
group.bench_with_input(
BenchmarkId::new("kand/stream", len),
&series,
|bencher, &series| {
// Seed running sma/sum/sum_sq from kand's batch state at the warmup edge.
let mut upper = vec![0.0; series.len()];
let mut middle = vec![0.0; series.len()];
let mut lower = vec![0.0; series.len()];
let mut sma = vec![0.0; series.len()];
let mut variance = vec![0.0; series.len()];
let mut sum = vec![0.0; series.len()];
let mut sum_sq = vec![0.0; series.len()];
kand::ohlcv::bbands::bbands(
series,
BB_PERIOD,
BB_DEV,
BB_DEV,
&mut upper,
&mut middle,
&mut lower,
&mut sma,
&mut variance,
&mut sum,
&mut sum_sq,
)
.unwrap();
let seed_sma = sma[BB_PERIOD - 1];
let seed_sum = sum[BB_PERIOD - 1];
let seed_sum_sq = sum_sq[BB_PERIOD - 1];
bencher.iter(|| {
let mut prev_sma = seed_sma;
let mut prev_sum = seed_sum;
let mut prev_sum_sq = seed_sum_sq;
for idx in BB_PERIOD..series.len() {
let result = kand::ohlcv::bbands::bbands_inc(
series[idx],
prev_sma,
prev_sum,
prev_sum_sq,
series[idx - BB_PERIOD],
BB_PERIOD,
BB_DEV,
BB_DEV,
)
.unwrap();
prev_sma = result.1;
prev_sum = result.4;
prev_sum_sq = result.5;
black_box((result.0, result.1, result.2));
}
});
},
);
group.bench_with_input(
BenchmarkId::new("kand/batch", len),
&series,
|bencher, &series| {
bencher.iter(|| {
let mut upper = vec![0.0; series.len()];
let mut middle = vec![0.0; series.len()];
let mut lower = vec![0.0; series.len()];
let mut sma = vec![0.0; series.len()];
let mut variance = vec![0.0; series.len()];
let mut sum = vec![0.0; series.len()];
let mut sum_sq = vec![0.0; series.len()];
kand::ohlcv::bbands::bbands(
series,
BB_PERIOD,
BB_DEV,
BB_DEV,
&mut upper,
&mut middle,
&mut lower,
&mut sma,
&mut variance,
&mut sum,
&mut sum_sq,
)
.unwrap();
black_box(&upper);
});
},
);
group.bench_with_input(
BenchmarkId::new("ta-rs/stream", len),
&series,
|bencher, &series| {
bencher.iter(|| {
let mut ind = ta::indicators::BollingerBands::new(BB_PERIOD, BB_DEV).unwrap();
for &price in series {
black_box(ta::Next::next(&mut ind, price));
}
});
},
);
}
group.finish();
}
fn atr_group(crit: &mut Criterion, candles: &[Candle]) {
let mut group = crit.benchmark_group("atr_14");
for &len in SIZES {
let len = len.min(candles.len());
let series: &[Candle] = &candles[..len];
group.throughput(Throughput::Elements(len as u64));
group.bench_with_input(
BenchmarkId::new("wickra/stream", len),
&series,
|bencher, &series| {
bencher.iter(|| {
let mut ind = Atr::new(ATR_PERIOD).unwrap();
for &candle in series {
black_box(ind.update(candle));
}
});
},
);
group.bench_with_input(
BenchmarkId::new("wickra/batch", len),
&series,
|bencher, &series| {
bencher.iter(|| {
let mut ind = Atr::new(ATR_PERIOD).unwrap();
black_box(ind.batch(series));
});
},
);
group.bench_with_input(
BenchmarkId::new("kand/stream", len),
&series,
|bencher, &series| {
let high: Vec<f64> = series.iter().map(|candle| candle.high).collect();
let low: Vec<f64> = series.iter().map(|candle| candle.low).collect();
let close: Vec<f64> = series.iter().map(|candle| candle.close).collect();
// Seed prev_atr from kand's batch ATR at the first valid index (= period).
let mut atr_out = vec![0.0; series.len()];
kand::ohlcv::atr::atr(&high, &low, &close, ATR_PERIOD, &mut atr_out).unwrap();
let seed_atr = atr_out[ATR_PERIOD];
bencher.iter(|| {
let mut prev_atr = seed_atr;
for idx in ATR_PERIOD + 1..series.len() {
prev_atr = kand::ohlcv::atr::atr_inc(
high[idx],
low[idx],
close[idx - 1],
prev_atr,
ATR_PERIOD,
)
.unwrap();
black_box(prev_atr);
}
});
},
);
group.bench_with_input(
BenchmarkId::new("kand/batch", len),
&series,
|bencher, &series| {
let high: Vec<f64> = series.iter().map(|candle| candle.high).collect();
let low: Vec<f64> = series.iter().map(|candle| candle.low).collect();
let close: Vec<f64> = series.iter().map(|candle| candle.close).collect();
bencher.iter(|| {
let mut atr_out = vec![0.0; series.len()];
kand::ohlcv::atr::atr(&high, &low, &close, ATR_PERIOD, &mut atr_out).unwrap();
black_box(&atr_out);
});
},
);
group.bench_with_input(
BenchmarkId::new("ta-rs/stream", len),
&series,
|bencher, &series| {
let items: Vec<ta::DataItem> = series
.iter()
.map(|candle| {
ta::DataItem::builder()
.open(candle.open)
.high(candle.high)
.low(candle.low)
.close(candle.close)
.volume(candle.volume)
.build()
.unwrap()
})
.collect();
bencher.iter(|| {
let mut ind = ta::indicators::AverageTrueRange::new(ATR_PERIOD).unwrap();
for item in &items {
black_box(ta::Next::next(&mut ind, item));
}
});
},
);
}
group.finish();
}
fn benches(crit: &mut Criterion) {
let candles = load_candles();
let closes: Vec<f64> = candles.iter().map(|candle| candle.close).collect();
sma_group(crit, &closes);
ema_group(crit, &closes);
rsi_group(crit, &closes);
macd_group(crit, &closes);
bbands_group(crit, &closes);
atr_group(crit, &candles);
}
criterion_group!(name = cross_lib; config = Criterion::default(); targets = benches);
criterion_main!(cross_lib);
+6
View File
@@ -0,0 +1,6 @@
//! Internal cross-library benchmark harness for Wickra.
//!
//! This crate is `publish = false`. It exists only to host the Criterion
//! benchmark in `benches/cross_lib.rs`, which compares Wickra against the
//! Rust technical-analysis crates `kand`, `ta` (ta-rs) and `yata` on an
//! identical candle series. It deliberately carries no library code.
+203
View File
@@ -0,0 +1,203 @@
//! Pure calendar arithmetic for the timestamp-driven seasonality indicators.
//!
//! Every indicator in the *Seasonality & Session* family keys off the wall-clock
//! fields of [`Candle::timestamp`](crate::Candle) (epoch milliseconds), shifted
//! by a caller-supplied `utc_offset_minutes` so the buckets line up with the
//! relevant exchange session rather than UTC. This module turns an epoch
//! millisecond instant into its civil fields using Howard Hinnant's
//! branch-light `civil_from_days` algorithm (the same one libc++ ships).
//!
//! All arithmetic is floor-based (`div_euclid`/`rem_euclid`) so instants before
//! the Unix epoch decompose correctly without a dedicated negative-input branch.
/// Civil (wall-clock) decomposition of an epoch-millisecond instant.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct CivilTime {
/// Proleptic Gregorian year (can be negative for instants before year 1).
pub(crate) year: i64,
/// Month of year, `1..=12`.
pub(crate) month: u32,
/// Day of month, `1..=31`.
pub(crate) day: u32,
/// Hour of day, `0..=23`.
pub(crate) hour: u32,
/// Minute of hour, `0..=59`.
pub(crate) minute: u32,
/// Day of week with Monday as `0` through Sunday as `6`.
pub(crate) weekday: u32,
}
impl CivilTime {
/// Minute of day, `0..=1439`.
pub(crate) const fn minute_of_day(&self) -> u32 {
self.hour * 60 + self.minute
}
}
/// Decompose an epoch-millisecond instant into local civil fields.
///
/// `utc_offset_minutes` shifts the instant before decomposition: `0` yields
/// UTC, `-300` U.S. Eastern standard time, `60` Central European time, etc.
pub(crate) fn civil_from_timestamp(millis: i64, utc_offset_minutes: i32) -> CivilTime {
let local_secs = millis.div_euclid(1000) + i64::from(utc_offset_minutes) * 60;
let days = local_secs.div_euclid(86_400);
let secs_of_day = local_secs.rem_euclid(86_400);
let hour = (secs_of_day / 3600) as u32;
let minute = ((secs_of_day % 3600) / 60) as u32;
let (year, month, day) = civil_from_days(days);
// 1970-01-01 was a Thursday; Monday-based weekday is `(z + 3) mod 7`.
let weekday = (days + 3).rem_euclid(7) as u32;
CivilTime {
year,
month,
day,
hour,
minute,
weekday,
}
}
/// Gregorian `(year, month, day)` for a day count `z` relative to 1970-01-01.
///
/// Howard Hinnant, "chrono-Compatible Low-Level Date Algorithms".
fn civil_from_days(z: i64) -> (i64, u32, u32) {
let z = z + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = z - era * 146_097; // [0, 146096]
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399]
let year = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
let mp = (5 * doy + 2) / 153; // [0, 11]
let day = (doy - (153 * mp + 2) / 5 + 1) as u32; // [1, 31]
let month = if mp < 10 { mp + 3 } else { mp - 9 } as u32; // [1, 12]
(if month <= 2 { year + 1 } else { year }, month, day)
}
/// Whether `year` is a Gregorian leap year.
pub(crate) const fn is_leap(year: i64) -> bool {
(year % 4 == 0 && year % 100 != 0) || year % 400 == 0
}
/// Number of days in `month` (`1..=12`) of `year`.
pub(crate) const fn days_in_month(year: i64, month: u32) -> u32 {
match month {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
4 | 6 | 9 | 11 => 30,
_ => {
if is_leap(year) {
29
} else {
28
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn epoch_zero_is_thursday_midnight() {
let t = civil_from_timestamp(0, 0);
assert_eq!(
t,
CivilTime {
year: 1970,
month: 1,
day: 1,
hour: 0,
minute: 0,
weekday: 3, // Thursday
}
);
assert_eq!(t.minute_of_day(), 0);
}
#[test]
fn known_utc_instant_mid_year() {
// 2021-06-15 13:45:00 UTC = 1623764700 s.
let t = civil_from_timestamp(1_623_764_700_000, 0);
assert_eq!(t.year, 2021);
assert_eq!(t.month, 6);
assert_eq!(t.day, 15);
assert_eq!(t.hour, 13);
assert_eq!(t.minute, 45);
assert_eq!(t.weekday, 1); // Tuesday
assert_eq!(t.minute_of_day(), 13 * 60 + 45);
}
#[test]
fn new_year_2021_is_friday() {
// 2021-01-01 00:00:00 UTC = 1609459200 s — exercises the m<=2 year bump.
let t = civil_from_timestamp(1_609_459_200_000, 0);
assert_eq!((t.year, t.month, t.day), (2021, 1, 1));
assert_eq!(t.weekday, 4); // Friday
}
#[test]
fn positive_offset_rolls_to_next_day() {
// 2021-01-01 23:30 UTC shifted +60 min -> 2021-01-02 00:30 local.
let base = 1_609_459_200_000 + (23 * 3600 + 30 * 60) * 1000;
let t = civil_from_timestamp(base, 60);
assert_eq!((t.year, t.month, t.day), (2021, 1, 2));
assert_eq!((t.hour, t.minute), (0, 30));
assert_eq!(t.weekday, 5); // Saturday
}
#[test]
fn negative_offset_rolls_to_previous_day() {
// 2021-01-01 00:30 UTC shifted -60 min -> 2020-12-31 23:30 local.
let base = 1_609_459_200_000 + 30 * 60 * 1000;
let t = civil_from_timestamp(base, -60);
assert_eq!((t.year, t.month, t.day), (2020, 12, 31));
assert_eq!((t.hour, t.minute), (23, 30));
assert_eq!(t.weekday, 3); // Thursday
}
#[test]
fn sub_epoch_millis_floor_correctly() {
// -1 ms -> 1969-12-31 23:59:59.999, a Wednesday.
let t = civil_from_timestamp(-1, 0);
assert_eq!((t.year, t.month, t.day), (1969, 12, 31));
assert_eq!((t.hour, t.minute), (23, 59));
assert_eq!(t.weekday, 2); // Wednesday
}
#[test]
fn far_negative_day_count_hits_pre_era_branch() {
// A day count below -719468 drives `z + 719468` negative, exercising the
// `z - 146096` era branch in civil_from_days (year < 1).
let (year, month, day) = civil_from_days(-1_000_000);
// -1_000_000 days before 1970-01-01 is 0768-02-04 BCE (proleptic
// Gregorian, astronomical year numbering where year 0 exists).
assert_eq!((year, month, day), (-768, 2, 4));
}
#[test]
fn leap_year_rules() {
assert!(is_leap(2000));
assert!(!is_leap(1900));
assert!(is_leap(2024));
assert!(!is_leap(2023));
}
#[test]
fn days_in_month_all_cases() {
assert_eq!(days_in_month(2023, 1), 31);
assert_eq!(days_in_month(2023, 4), 30);
assert_eq!(days_in_month(2023, 2), 28);
assert_eq!(days_in_month(2024, 2), 29);
assert_eq!(days_in_month(2023, 12), 31);
assert_eq!(days_in_month(2023, 11), 30);
}
#[test]
fn leap_day_decodes() {
// 2024-02-29 12:00 UTC.
let secs = 1_709_208_000; // 2024-02-29T12:00:00Z
let t = civil_from_timestamp(secs * 1000, 0);
assert_eq!((t.year, t.month, t.day), (2024, 2, 29));
assert_eq!(t.hour, 12);
}
}
+387
View File
@@ -0,0 +1,387 @@
//! Cross-section value type: a market-breadth snapshot across a whole universe.
//!
//! A [`CrossSection`] is a single tick that carries the per-symbol state of
//! *every* symbol in a universe at one point in time. It is the non-OHLCV input
//! consumed by the market-breadth indicator family (advance/decline, `McClellan`,
//! the TRIN / Arms index, the high-low index, ...), each of which aggregates the
//! whole cross-section into a single breadth reading. This is the same
//! one-rich-type-per-family pattern as [`DerivativesTick`] and [`OrderBook`].
//!
//! Each [`Member`] precomputes the per-symbol signals the breadth indicators
//! need — a signed price `change` (whose sign classifies the symbol as
//! advancing, declining or unchanged), the period `volume`, the
//! `new_high` / `new_low` extreme flags, and the `above_ma` / `on_buy_signal`
//! state flags — so the indicators stay stateless per tick and never have to
//! track per-symbol history.
//!
//! [`DerivativesTick`]: crate::DerivativesTick
//! [`OrderBook`]: crate::OrderBook
use crate::error::{Error, Result};
/// One symbol's contribution to a [`CrossSection`] tick.
///
/// Field invariants enforced by [`CrossSection::new`] when the member is placed
/// into a tick:
///
/// - `change` is finite (its sign classifies the symbol — positive is
/// advancing, negative is declining, zero is unchanged).
/// - `volume` is finite and non-negative.
///
/// `new_high` / `new_low` are caller-supplied flags marking whether the symbol
/// printed a new period extreme; `above_ma` / `on_buy_signal` are caller-supplied
/// per-symbol state signals (whether the symbol trades above its reference moving
/// average, and whether it is on a point-and-figure buy signal). None of the four
/// flags carries a numeric invariant.
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq)]
#[allow(
clippy::struct_excessive_bools,
reason = "the four flags are independent per-symbol breadth signals, not a state machine"
)]
pub struct Member {
/// Price change versus the previous close. Sign classifies the symbol:
/// positive is advancing, negative is declining, zero is unchanged.
pub change: f64,
/// Period volume for the symbol (finite, non-negative).
pub volume: f64,
/// Whether the symbol printed a new period high.
pub new_high: bool,
/// Whether the symbol printed a new period low.
pub new_low: bool,
/// Whether the symbol is trading above its reference moving average
/// (consumed by the `% Above Moving Average` breadth indicator).
pub above_ma: bool,
/// Whether the symbol is on a point-and-figure buy signal
/// (consumed by the `Bullish Percent Index` breadth indicator).
pub on_buy_signal: bool,
}
impl Member {
/// Assemble a cross-section member from its core signals, leaving the
/// extended per-symbol state flags (`above_ma`, `on_buy_signal`) cleared.
///
/// The field invariants documented on [`Member`] are validated centrally by
/// [`CrossSection::new`] when the member is placed into a tick; this
/// constructor only assembles the value so the `#[non_exhaustive]` struct can
/// be built from outside the crate.
#[must_use]
pub const fn new(change: f64, volume: f64, new_high: bool, new_low: bool) -> Self {
Self {
change,
volume,
new_high,
new_low,
above_ma: false,
on_buy_signal: false,
}
}
/// Assemble a cross-section member including the extended per-symbol state
/// signals `above_ma` and `on_buy_signal`.
///
/// Use this constructor for the breadth indicators that read per-symbol
/// state (`% Above Moving Average`, `Bullish Percent Index`); [`new`](Member::new)
/// is the shorthand that leaves both flags `false`.
#[must_use]
#[allow(
clippy::fn_params_excessive_bools,
reason = "mirrors the four independent per-symbol flag fields of Member"
)]
pub const fn with_signals(
change: f64,
volume: f64,
new_high: bool,
new_low: bool,
above_ma: bool,
on_buy_signal: bool,
) -> Self {
Self {
change,
volume,
new_high,
new_low,
above_ma,
on_buy_signal,
}
}
}
/// A market-breadth cross-section: the per-symbol state of an entire universe at
/// a single point in time.
///
/// Invariants enforced by [`new`](CrossSection::new):
///
/// - `members` is non-empty (a breadth reading needs at least one symbol).
/// - every member's `change` is finite, and `volume` is finite and non-negative.
///
/// `timestamp` is a caller-defined epoch / resolution and is not validated.
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq)]
pub struct CrossSection {
/// Per-symbol members of the universe for this tick.
pub members: Vec<Member>,
/// Tick timestamp (caller-defined epoch / resolution).
pub timestamp: i64,
}
impl CrossSection {
/// Construct a cross-section, validating every member invariant.
///
/// # Errors
///
/// Returns [`Error::InvalidCrossSection`] if `members` is empty, if any
/// member has a non-finite `change`, or if any member has a `volume` that is
/// not a finite non-negative number.
pub fn new(members: Vec<Member>, timestamp: i64) -> Result<Self> {
if members.is_empty() {
return Err(Error::InvalidCrossSection {
message: "cross-section must contain at least one member",
});
}
for member in &members {
if !member.change.is_finite() {
return Err(Error::InvalidCrossSection {
message: "member change must be finite",
});
}
if !member.volume.is_finite() || member.volume < 0.0 {
return Err(Error::InvalidCrossSection {
message: "member volume must be finite and non-negative",
});
}
}
Ok(Self { members, timestamp })
}
/// Construct a cross-section without validation. The caller asserts that
/// every invariant documented on [`CrossSection`] holds.
#[must_use]
pub const fn new_unchecked(members: Vec<Member>, timestamp: i64) -> Self {
Self { members, timestamp }
}
/// Number of advancing symbols (those with a strictly positive `change`).
#[must_use]
pub fn advancers(&self) -> usize {
self.members.iter().filter(|m| m.change > 0.0).count()
}
/// Number of declining symbols (those with a strictly negative `change`).
#[must_use]
pub fn decliners(&self) -> usize {
self.members.iter().filter(|m| m.change < 0.0).count()
}
/// Total volume traded by advancing symbols (those with positive `change`).
#[must_use]
pub fn advancing_volume(&self) -> f64 {
self.members
.iter()
.filter(|m| m.change > 0.0)
.map(|m| m.volume)
.sum()
}
/// Total volume traded by declining symbols (those with negative `change`).
#[must_use]
pub fn declining_volume(&self) -> f64 {
self.members
.iter()
.filter(|m| m.change < 0.0)
.map(|m| m.volume)
.sum()
}
/// Total volume traded across the whole universe.
#[must_use]
pub fn total_volume(&self) -> f64 {
self.members.iter().map(|m| m.volume).sum()
}
/// Number of symbols that printed a new period high.
#[must_use]
pub fn new_highs(&self) -> usize {
self.members.iter().filter(|m| m.new_high).count()
}
/// Number of symbols that printed a new period low.
#[must_use]
pub fn new_lows(&self) -> usize {
self.members.iter().filter(|m| m.new_low).count()
}
/// Number of symbols trading above their reference moving average.
#[must_use]
pub fn above_ma_count(&self) -> usize {
self.members.iter().filter(|m| m.above_ma).count()
}
/// Number of symbols on a point-and-figure buy signal.
#[must_use]
pub fn on_buy_signal_count(&self) -> usize {
self.members.iter().filter(|m| m.on_buy_signal).count()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn members() -> Vec<Member> {
vec![
Member::new(1.5, 100.0, true, false),
Member::new(-0.5, 50.0, false, true),
Member::new(0.0, 0.0, false, false),
]
}
#[test]
fn new_accepts_valid() {
let cs = CrossSection::new(members(), 42).unwrap();
assert_eq!(cs.members.len(), 3);
assert_eq!(cs.timestamp, 42);
assert_eq!(cs.members[0].change, 1.5);
assert_eq!(cs.members[0].volume, 100.0);
assert!(cs.members[0].new_high);
assert!(cs.members[1].new_low);
}
#[test]
fn member_new_assembles_fields() {
let m = Member::new(2.0, 10.0, true, false);
assert_eq!(m.change, 2.0);
assert_eq!(m.volume, 10.0);
assert!(m.new_high);
assert!(!m.new_low);
}
#[test]
fn new_rejects_empty() {
assert!(matches!(
CrossSection::new(Vec::new(), 0),
Err(Error::InvalidCrossSection { .. })
));
}
#[test]
fn new_rejects_non_finite_change() {
assert!(matches!(
CrossSection::new(vec![Member::new(f64::NAN, 10.0, false, false)], 0),
Err(Error::InvalidCrossSection { .. })
));
assert!(matches!(
CrossSection::new(vec![Member::new(f64::INFINITY, 10.0, false, false)], 0),
Err(Error::InvalidCrossSection { .. })
));
}
#[test]
fn new_rejects_negative_volume() {
assert!(matches!(
CrossSection::new(vec![Member::new(1.0, -1.0, false, false)], 0),
Err(Error::InvalidCrossSection { .. })
));
}
#[test]
fn new_rejects_non_finite_volume() {
assert!(matches!(
CrossSection::new(vec![Member::new(1.0, f64::NAN, false, false)], 0),
Err(Error::InvalidCrossSection { .. })
));
}
#[test]
fn new_unchecked_skips_validation() {
let cs = CrossSection::new_unchecked(vec![Member::new(f64::NAN, -1.0, false, false)], 7);
assert_eq!(cs.members.len(), 1);
assert_eq!(cs.timestamp, 7);
}
#[test]
fn advancers_and_decliners_count_by_sign() {
let cs = CrossSection::new(members(), 0).unwrap();
assert_eq!(cs.advancers(), 1);
assert_eq!(cs.decliners(), 1);
}
#[test]
fn unchanged_members_count_as_neither() {
let cs = CrossSection::new(
vec![
Member::new(0.0, 1.0, false, false),
Member::new(0.0, 1.0, false, false),
],
0,
)
.unwrap();
assert_eq!(cs.advancers(), 0);
assert_eq!(cs.decliners(), 0);
}
#[test]
fn new_leaves_extended_flags_cleared() {
let m = Member::new(1.0, 10.0, true, false);
assert!(!m.above_ma);
assert!(!m.on_buy_signal);
}
#[test]
fn with_signals_assembles_all_fields() {
let m = Member::with_signals(2.0, 10.0, true, false, true, true);
assert_eq!(m.change, 2.0);
assert_eq!(m.volume, 10.0);
assert!(m.new_high);
assert!(!m.new_low);
assert!(m.above_ma);
assert!(m.on_buy_signal);
}
#[test]
fn volume_helpers_bucket_by_change_sign() {
let cs = CrossSection::new(
vec![
Member::new(1.5, 100.0, false, false), // advancing
Member::new(2.0, 40.0, false, false), // advancing
Member::new(-0.5, 50.0, false, false), // declining
Member::new(0.0, 7.0, false, false), // unchanged
],
0,
)
.unwrap();
assert_eq!(cs.advancing_volume(), 140.0);
assert_eq!(cs.declining_volume(), 50.0);
assert_eq!(cs.total_volume(), 197.0);
}
#[test]
fn high_low_helpers_count_flags() {
let cs = CrossSection::new(
vec![
Member::new(1.0, 1.0, true, false),
Member::new(1.0, 1.0, true, false),
Member::new(-1.0, 1.0, false, true),
],
0,
)
.unwrap();
assert_eq!(cs.new_highs(), 2);
assert_eq!(cs.new_lows(), 1);
}
#[test]
fn state_helpers_count_extended_flags() {
let cs = CrossSection::new(
vec![
Member::with_signals(1.0, 1.0, false, false, true, true),
Member::with_signals(1.0, 1.0, false, false, true, false),
Member::with_signals(-1.0, 1.0, false, false, false, true),
],
0,
)
.unwrap();
assert_eq!(cs.above_ma_count(), 2);
assert_eq!(cs.on_buy_signal_count(), 2);
}
}
+321
View File
@@ -0,0 +1,321 @@
//! Derivatives value type: the perpetual / futures tick.
//!
//! [`DerivativesTick`] is the non-OHLCV input consumed by the derivatives /
//! perpetual-futures indicator family. A single tick bundles the funding,
//! price, open-interest, positioning, taker-flow and liquidation fields a
//! perp/futures venue publishes per update; each indicator reads only the
//! subset it needs (the same one-rich-type-per-family pattern as [`Trade`] /
//! [`OrderBook`] in [`crate::microstructure`]).
//!
//! [`Trade`]: crate::microstructure::Trade
//! [`OrderBook`]: crate::microstructure::OrderBook
use crate::error::{Error, Result};
/// A single derivatives / perpetual-futures market tick.
///
/// Field invariants enforced by [`new`](DerivativesTick::new):
///
/// - `funding_rate` is finite and **may be negative** (a negative funding rate
/// means shorts pay longs).
/// - `mark_price`, `index_price` and `futures_price` are finite and strictly
/// positive.
/// - `open_interest`, `long_size`, `short_size`, `taker_buy_volume`,
/// `taker_sell_volume`, `long_liquidation` and `short_liquidation` are finite
/// and non-negative.
///
/// `timestamp` is a caller-defined epoch / resolution and is not validated.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct DerivativesTick {
/// Current funding rate for the interval (finite; may be negative).
pub funding_rate: f64,
/// Perpetual mark price (finite, strictly positive).
pub mark_price: f64,
/// Spot / index price the perpetual tracks (finite, strictly positive).
pub index_price: f64,
/// Dated (e.g. quarterly) futures mark price (finite, strictly positive).
pub futures_price: f64,
/// Open interest — outstanding contracts / notional (finite, non-negative).
pub open_interest: f64,
/// Aggregate long size / long account count (finite, non-negative).
pub long_size: f64,
/// Aggregate short size / short account count (finite, non-negative).
pub short_size: f64,
/// Taker buy (ask-lifting) volume (finite, non-negative).
pub taker_buy_volume: f64,
/// Taker sell (bid-hitting) volume (finite, non-negative).
pub taker_sell_volume: f64,
/// Long-side liquidation notional (finite, non-negative).
pub long_liquidation: f64,
/// Short-side liquidation notional (finite, non-negative).
pub short_liquidation: f64,
/// Tick timestamp (caller-defined epoch / resolution).
pub timestamp: i64,
}
impl DerivativesTick {
/// Construct a derivatives tick, validating every field invariant.
///
/// # Errors
///
/// Returns [`Error::InvalidDerivatives`] if `funding_rate` is not finite;
/// any of `mark_price`, `index_price`, `futures_price` is not a finite
/// positive number; or any of the six size / volume / liquidation fields is
/// not a finite non-negative number.
#[allow(clippy::too_many_arguments)]
pub fn new(
funding_rate: f64,
mark_price: f64,
index_price: f64,
futures_price: f64,
open_interest: f64,
long_size: f64,
short_size: f64,
taker_buy_volume: f64,
taker_sell_volume: f64,
long_liquidation: f64,
short_liquidation: f64,
timestamp: i64,
) -> Result<Self> {
if !funding_rate.is_finite() {
return Err(Error::InvalidDerivatives {
message: "funding_rate must be finite",
});
}
for price in [mark_price, index_price, futures_price] {
if !price.is_finite() || price <= 0.0 {
return Err(Error::InvalidDerivatives {
message:
"mark_price, index_price and futures_price must be finite and positive",
});
}
}
for amount in [
open_interest,
long_size,
short_size,
taker_buy_volume,
taker_sell_volume,
long_liquidation,
short_liquidation,
] {
if !amount.is_finite() || amount < 0.0 {
return Err(Error::InvalidDerivatives {
message: "open interest, sizes, volumes and liquidations must be finite and non-negative",
});
}
}
Ok(Self {
funding_rate,
mark_price,
index_price,
futures_price,
open_interest,
long_size,
short_size,
taker_buy_volume,
taker_sell_volume,
long_liquidation,
short_liquidation,
timestamp,
})
}
/// Construct a derivatives tick without validation. The caller asserts that
/// every field invariant documented on [`DerivativesTick`] holds.
#[allow(clippy::too_many_arguments)]
#[must_use]
pub const fn new_unchecked(
funding_rate: f64,
mark_price: f64,
index_price: f64,
futures_price: f64,
open_interest: f64,
long_size: f64,
short_size: f64,
taker_buy_volume: f64,
taker_sell_volume: f64,
long_liquidation: f64,
short_liquidation: f64,
timestamp: i64,
) -> Self {
Self {
funding_rate,
mark_price,
index_price,
futures_price,
open_interest,
long_size,
short_size,
taker_buy_volume,
taker_sell_volume,
long_liquidation,
short_liquidation,
timestamp,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A fully valid tick used as a baseline; individual tests override one
/// field to exercise a single reject branch.
fn valid() -> DerivativesTick {
DerivativesTick::new(
0.0001, 100.0, 99.5, 100.5, 1_000.0, 600.0, 400.0, 50.0, 40.0, 5.0, 3.0, 42,
)
.unwrap()
}
#[test]
fn new_accepts_valid() {
let tick = valid();
assert_eq!(tick.funding_rate, 0.0001);
assert_eq!(tick.mark_price, 100.0);
assert_eq!(tick.index_price, 99.5);
assert_eq!(tick.futures_price, 100.5);
assert_eq!(tick.open_interest, 1_000.0);
assert_eq!(tick.long_size, 600.0);
assert_eq!(tick.short_size, 400.0);
assert_eq!(tick.taker_buy_volume, 50.0);
assert_eq!(tick.taker_sell_volume, 40.0);
assert_eq!(tick.long_liquidation, 5.0);
assert_eq!(tick.short_liquidation, 3.0);
assert_eq!(tick.timestamp, 42);
}
#[test]
fn new_accepts_negative_funding_and_zero_amounts() {
let tick = DerivativesTick::new(
-0.0005, 100.0, 100.0, 100.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0,
)
.unwrap();
assert_eq!(tick.funding_rate, -0.0005);
assert_eq!(tick.open_interest, 0.0);
}
#[test]
fn new_rejects_non_finite_funding() {
assert!(matches!(
DerivativesTick::new(
f64::NAN,
100.0,
100.0,
100.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0
),
Err(Error::InvalidDerivatives { .. })
));
assert!(matches!(
DerivativesTick::new(
f64::INFINITY,
100.0,
100.0,
100.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0
),
Err(Error::InvalidDerivatives { .. })
));
}
#[test]
fn new_rejects_non_positive_mark() {
assert!(matches!(
DerivativesTick::new(0.0, 0.0, 100.0, 100.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0),
Err(Error::InvalidDerivatives { .. })
));
}
#[test]
fn new_rejects_non_positive_index() {
assert!(matches!(
DerivativesTick::new(0.0, 100.0, -1.0, 100.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0),
Err(Error::InvalidDerivatives { .. })
));
}
#[test]
fn new_rejects_non_finite_futures() {
assert!(matches!(
DerivativesTick::new(
0.0,
100.0,
100.0,
f64::NAN,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0
),
Err(Error::InvalidDerivatives { .. })
));
}
#[test]
fn new_rejects_negative_open_interest() {
assert!(matches!(
DerivativesTick::new(0.0, 100.0, 100.0, 100.0, -1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0),
Err(Error::InvalidDerivatives { .. })
));
}
#[test]
fn new_rejects_non_finite_size() {
assert!(matches!(
DerivativesTick::new(
0.0,
100.0,
100.0,
100.0,
0.0,
f64::INFINITY,
0.0,
0.0,
0.0,
0.0,
0.0,
0
),
Err(Error::InvalidDerivatives { .. })
));
}
#[test]
fn new_rejects_negative_liquidation() {
assert!(matches!(
DerivativesTick::new(0.0, 100.0, 100.0, 100.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, -2.0, 0),
Err(Error::InvalidDerivatives { .. })
));
}
#[test]
fn new_unchecked_preserves_fields() {
let tick = DerivativesTick::new_unchecked(
-1.0, -2.0, -3.0, -4.0, -5.0, -6.0, -7.0, -8.0, -9.0, -10.0, -11.0, 7,
);
assert_eq!(tick.funding_rate, -1.0);
assert_eq!(tick.mark_price, -2.0);
assert_eq!(tick.short_liquidation, -11.0);
assert_eq!(tick.timestamp, 7);
}
}
+36
View File
@@ -31,6 +31,42 @@ 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 },
/// A derivatives tick whose components do not satisfy the tick invariants
/// (e.g. a non-positive price, a non-finite funding rate, or a negative
/// size/volume/liquidation) was provided. Derivatives ticks (funding /
/// open-interest / liquidation feeds) are a perpetual-futures input
/// distinct from candles, order books and trades, so they surface as their
/// own variant.
#[error("invalid derivatives tick: {message}")]
InvalidDerivatives { message: &'static str },
/// A market-breadth cross-section whose members do not satisfy the
/// cross-section invariants (an empty universe, a non-finite change, or a
/// negative / non-finite volume) was provided. A cross-section is a
/// breadth input distinct from candles, ticks, order books and trades, so
/// it surfaces as its own variant.
#[error("invalid cross-section: {message}")]
InvalidCrossSection { message: &'static str },
/// A real-valued configuration parameter was outside its admissible range
/// (e.g. a non-positive standard-deviation multiplier, or a Kalman filter
/// covariance that is not strictly positive). This is the floating-point
/// analogue of [`Error::InvalidPeriod`], which only covers integer windows.
#[error("invalid parameter: {message}")]
InvalidParameter { message: &'static str },
}
/// Convenience alias for `Result<T, wickra_core::Error>`.
@@ -0,0 +1,247 @@
//! Abandoned Baby candlestick pattern.
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Abandoned Baby — a strong 3-bar reversal where a doji is "abandoned" by price
/// gaps on both sides, isolating it from the candles before and after.
///
/// ```text
/// tol = tolerance * max(|bar2.open|, |bar2.close|)
/// bar2 doji (|bar2.close bar2.open| <= tol)
///
/// bullish (+1.0): bar1 red, bar2 gaps fully below bar1 (bar2.high < bar1.low),
/// bar3 green and gaps fully above bar2 (bar3.low > bar2.high)
/// bearish (1.0): bar1 green, bar2 gaps fully above bar1 (bar2.low > bar1.high),
/// bar3 red and gaps fully below bar2 (bar3.high < bar2.low)
/// ```
///
/// Output is `0.0` otherwise. The first two bars always return `0.0` because the
/// three-bar window is not yet filled. `tolerance` defaults to `0.001` (10 bps
/// relative) and bounds how flat the middle candle must be to count as a doji; it
/// must lie in `[0, 1)`. Pattern-shape check only — no trend filter is applied;
/// combine with a trend indicator for actionable signals.
///
/// # Signed ±1 encoding
///
/// This detector emits the uniform candlestick sign convention shared across the
/// pattern family — `+1.0` bullish, `1.0` bearish, `0.0` no pattern — so it
/// drops straight into a machine-learning feature matrix where the bullish and
/// bearish variants occupy a single dimension.
///
/// # Example
///
/// ```
/// use wickra_core::{AbandonedBaby, Candle, Indicator};
///
/// let mut indicator = AbandonedBaby::new();
/// indicator.update(Candle::new(20.0, 20.1, 14.9, 15.0, 1.0, 0).unwrap());
/// indicator.update(Candle::new(13.0, 13.1, 12.9, 13.0, 1.0, 1).unwrap());
/// let out = indicator
/// .update(Candle::new(16.0, 18.1, 15.9, 18.0, 1.0, 2).unwrap());
/// assert_eq!(out, Some(1.0));
/// ```
#[derive(Debug, Clone)]
pub struct AbandonedBaby {
tolerance: f64,
prev: Option<Candle>,
prev_prev: Option<Candle>,
has_emitted: bool,
}
impl Default for AbandonedBaby {
fn default() -> Self {
Self::new()
}
}
impl AbandonedBaby {
/// Construct a detector with the default relative doji tolerance (1e-3).
pub const fn new() -> Self {
Self {
tolerance: 0.001,
prev: None,
prev_prev: None,
has_emitted: false,
}
}
/// Construct a detector with a custom relative doji tolerance.
///
/// `tolerance` must lie in `[0, 1)`.
pub fn with_tolerance(tolerance: f64) -> Result<Self> {
if !(0.0..1.0).contains(&tolerance) {
return Err(Error::InvalidPeriod {
message: "abandoned baby tolerance must lie in [0, 1)",
});
}
Ok(Self {
tolerance,
prev: None,
prev_prev: None,
has_emitted: false,
})
}
/// Configured relative doji tolerance.
pub fn tolerance(&self) -> f64 {
self.tolerance
}
}
impl Indicator for AbandonedBaby {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
self.has_emitted = true;
let pp = self.prev_prev;
let p = self.prev;
self.prev_prev = self.prev;
self.prev = Some(candle);
let (Some(bar1), Some(bar2)) = (pp, p) else {
return Some(0.0);
};
let tol = self.tolerance * bar2.open.abs().max(bar2.close.abs());
let bar2_is_doji = (bar2.close - bar2.open).abs() <= tol;
if !bar2_is_doji {
return Some(0.0);
}
// Bullish: red bar1, doji gaps below, green bar3 gaps above.
if bar1.close < bar1.open
&& bar2.high < bar1.low
&& candle.close > candle.open
&& candle.low > bar2.high
{
return Some(1.0);
}
// Bearish: green bar1, doji gaps above, red bar3 gaps below.
if bar1.close > bar1.open
&& bar2.low > bar1.high
&& candle.close < candle.open
&& candle.high < bar2.low
{
return Some(-1.0);
}
Some(0.0)
}
fn reset(&mut self) {
self.prev = None;
self.prev_prev = None;
self.has_emitted = false;
}
fn warmup_period(&self) -> usize {
3
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"AbandonedBaby"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle {
Candle::new(open, high, low, close, 1.0, ts).unwrap()
}
#[test]
fn rejects_invalid_tolerance() {
assert!(AbandonedBaby::with_tolerance(-0.01).is_err());
assert!(AbandonedBaby::with_tolerance(1.0).is_err());
}
#[test]
fn accepts_valid_tolerance() {
let t = AbandonedBaby::with_tolerance(0.0).unwrap();
assert!((t.tolerance() - 0.0).abs() < 1e-12);
}
#[test]
fn accessors_and_metadata() {
let t = AbandonedBaby::default();
assert_eq!(t.name(), "AbandonedBaby");
assert_eq!(t.warmup_period(), 3);
assert!(!t.is_ready());
assert!((t.tolerance() - 0.001).abs() < 1e-12);
}
#[test]
fn bullish_abandoned_baby_is_plus_one() {
let mut t = AbandonedBaby::new();
assert_eq!(t.update(c(20.0, 20.1, 14.9, 15.0, 0)), Some(0.0));
assert_eq!(t.update(c(13.0, 13.1, 12.9, 13.0, 1)), Some(0.0));
assert_eq!(t.update(c(16.0, 18.1, 15.9, 18.0, 2)), Some(1.0));
}
#[test]
fn bearish_abandoned_baby_is_minus_one() {
let mut t = AbandonedBaby::new();
assert_eq!(t.update(c(15.0, 20.1, 14.9, 20.0, 0)), Some(0.0));
assert_eq!(t.update(c(22.0, 22.1, 21.9, 22.0, 1)), Some(0.0));
assert_eq!(t.update(c(19.0, 19.1, 16.9, 17.0, 2)), Some(-1.0));
}
#[test]
fn middle_not_doji_yields_zero() {
let mut t = AbandonedBaby::new();
t.update(c(20.0, 20.1, 14.9, 15.0, 0));
// Middle bar has a wide body -> not a doji.
assert_eq!(t.update(c(13.0, 14.0, 11.0, 11.5, 1)), Some(0.0));
assert_eq!(t.update(c(16.0, 18.1, 15.9, 18.0, 2)), Some(0.0));
}
#[test]
fn no_gap_yields_zero() {
let mut t = AbandonedBaby::new();
t.update(c(20.0, 20.1, 14.9, 15.0, 0));
// Doji overlaps bar1's range -> no gap.
assert_eq!(t.update(c(15.0, 15.1, 14.9, 15.0, 1)), Some(0.0));
assert_eq!(t.update(c(16.0, 18.1, 15.9, 18.0, 2)), Some(0.0));
}
#[test]
fn first_two_bars_return_zero() {
let mut t = AbandonedBaby::new();
assert_eq!(t.update(c(20.0, 20.1, 14.9, 15.0, 0)), Some(0.0));
assert_eq!(t.update(c(13.0, 13.1, 12.9, 13.0, 1)), Some(0.0));
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..40)
.map(|i| {
let base = 100.0 + (i as f64 * 0.3).sin() * 5.0;
c(base, base + 1.0, base - 1.0, base + 0.5, i)
})
.collect();
let mut a = AbandonedBaby::new();
let mut b = AbandonedBaby::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut t = AbandonedBaby::new();
t.update(c(20.0, 20.1, 14.9, 15.0, 0));
t.update(c(13.0, 13.1, 12.9, 13.0, 1));
t.update(c(16.0, 18.1, 15.9, 18.0, 2));
assert!(t.is_ready());
t.reset();
assert!(!t.is_ready());
assert_eq!(t.update(c(20.0, 20.1, 14.9, 15.0, 0)), Some(0.0));
}
}
+154
View File
@@ -0,0 +1,154 @@
//! AB=CD harmonic pattern.
use crate::indicators::pattern_swing::{approx_equal, ratios_in, SwingTracker, SWING_THRESHOLD};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// AB=CD — the simplest four-point harmonic pattern: an A→B leg, a B→C
/// retracement, and a C→D leg that mirrors A→B in length:
///
/// ```text
/// BC / AB ∈ [0.382, 0.886] (C retraces AB)
/// CD / BC ∈ [1.13, 2.618] (D extends BC)
/// AB ≈ CD (within 10%) (the two legs are equal — the defining symmetry)
/// ```
///
/// Read from the last four confirmed pivots `A-B-C-D`. Output is `+1.0`
/// (bullish, D a swing low), `-1.0` (bearish, D a swing high), or `0.0`; never
/// `None`. See `crates/wickra-core/src/indicators/abcd.rs`.
#[derive(Debug, Clone)]
pub struct Abcd {
swing: SwingTracker,
has_emitted: bool,
}
impl Abcd {
/// Construct a new AB=CD detector.
pub const fn new() -> Self {
Self {
swing: SwingTracker::new(SWING_THRESHOLD, 4),
has_emitted: false,
}
}
}
impl Default for Abcd {
fn default() -> Self {
Self::new()
}
}
impl Indicator for Abcd {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
self.has_emitted = true;
if !self.swing.update(candle) {
return Some(0.0);
}
let pivots = self.swing.pivots();
if pivots.len() < 4 {
return Some(0.0);
}
let len = pivots.len();
let pa = pivots[len - 4];
let pb = pivots[len - 3];
let pc = pivots[len - 2];
let pd = pivots[len - 1];
let ab = (pb.price - pa.price).abs();
let bc = (pc.price - pb.price).abs();
let cd = (pd.price - pc.price).abs();
let ratios_ok = ratios_in(&[(bc / ab, 0.382, 0.886), (cd / bc, 1.13, 2.618)]);
let legs_equal = approx_equal(ab, cd, 0.10);
if ratios_ok && legs_equal {
return Some(if pd.direction < 0.0 { 1.0 } else { -1.0 });
}
Some(0.0)
}
fn reset(&mut self) {
self.swing.reset();
self.has_emitted = false;
}
fn warmup_period(&self) -> usize {
5
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"Abcd"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::indicators::pattern_swing::candles_for_pivots;
use crate::traits::BatchExt;
fn run(pivots: &[f64]) -> Vec<f64> {
let mut indicator = Abcd::new();
candles_for_pivots(pivots)
.into_iter()
.map(|c| indicator.update(c).unwrap())
.collect()
}
#[test]
fn accessors_and_metadata() {
let indicator = Abcd::new();
assert_eq!(indicator.name(), "Abcd");
assert_eq!(indicator.warmup_period(), 5);
assert!(!indicator.is_ready());
assert!(!Abcd::default().is_ready());
}
#[test]
fn bullish_abcd_is_plus_one() {
// AB = 40 down, BC = 24.7 up (0.618), CD = 40 down → AB = CD.
let out = run(&[140.0, 100.0, 124.7, 84.7]);
assert_eq!(*out.last().unwrap(), 1.0);
assert!(out[..out.len() - 1].iter().all(|&x| x == 0.0));
}
#[test]
fn bearish_abcd_is_minus_one() {
let out = run(&[150.0, 100.0, 140.0, 115.3, 155.3]);
assert_eq!(*out.last().unwrap(), -1.0);
}
#[test]
fn unequal_legs_do_not_trigger() {
// CD (82) far longer than AB (40) → not an AB=CD.
let out = run(&[150.0, 100.0, 140.0, 118.0, 200.0]);
assert_eq!(*out.last().unwrap(), 0.0);
}
#[test]
fn reset_clears_state() {
let mut indicator = Abcd::new();
for c in candles_for_pivots(&[140.0, 100.0, 124.7]) {
let _ = indicator.update(c);
}
indicator.reset();
assert!(!indicator.is_ready());
let c = Candle::new(99.5, 100.0, 99.5, 99.5, 1.0, 0).unwrap();
assert_eq!(indicator.update(c), Some(0.0));
}
#[test]
fn batch_equals_streaming() {
let candles = candles_for_pivots(&[140.0, 100.0, 124.7, 84.7]);
let mut a = Abcd::new();
let mut b = Abcd::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,144 @@
//! Absolute Breadth Index — the magnitude of net advancing-minus-declining issues.
use crate::cross_section::CrossSection;
use crate::traits::Indicator;
/// Absolute Breadth Index (ABI) — the absolute value of net advancing issues,
/// `|advancers - decliners|`.
///
/// The ABI ignores the *direction* of breadth and measures only its *magnitude*:
/// a high reading means the universe moved decisively one way or the other (high
/// internal activity / volatility), while a low reading means advances and
/// declines were nearly balanced (a quiet, directionless market). It is sometimes
/// called a "market thermometer" because elevated readings often cluster around
/// turning points.
///
/// `Input = CrossSection`, `Output = f64`, `warmup_period == 1`.
///
/// # Example
///
/// ```
/// use wickra_core::{AbsoluteBreadthIndex, CrossSection, Indicator, Member};
///
/// let mut abi = AbsoluteBreadthIndex::new();
/// // 2 advancers, 5 decliners -> |2 - 5| = 3.
/// let tick = CrossSection::new(
/// vec![
/// Member::new(1.0, 10.0, false, false),
/// Member::new(1.0, 10.0, false, false),
/// Member::new(-1.0, 10.0, false, false),
/// Member::new(-1.0, 10.0, false, false),
/// Member::new(-1.0, 10.0, false, false),
/// Member::new(-1.0, 10.0, false, false),
/// Member::new(-1.0, 10.0, false, false),
/// ],
/// 0,
/// )
/// .unwrap();
/// assert_eq!(abi.update(tick), Some(3.0));
/// ```
#[derive(Debug, Clone, Default)]
pub struct AbsoluteBreadthIndex {
has_emitted: bool,
}
impl AbsoluteBreadthIndex {
/// Construct a new Absolute Breadth Index indicator.
#[must_use]
pub const fn new() -> Self {
Self { has_emitted: false }
}
}
impl Indicator for AbsoluteBreadthIndex {
type Input = CrossSection;
type Output = f64;
fn update(&mut self, section: CrossSection) -> Option<f64> {
let net = section.advancers() as f64 - section.decliners() as f64;
self.has_emitted = true;
Some(net.abs())
}
fn reset(&mut self) {
self.has_emitted = false;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"AbsoluteBreadthIndex"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cross_section::Member;
use crate::traits::BatchExt;
fn section(up: usize, down: usize) -> CrossSection {
let mut members = Vec::new();
for _ in 0..up {
members.push(Member::new(1.0, 10.0, false, false));
}
for _ in 0..down {
members.push(Member::new(-1.0, 10.0, false, false));
}
members.push(Member::new(0.0, 10.0, false, false));
CrossSection::new(members, 0).unwrap()
}
#[test]
fn accessors_and_metadata() {
let abi = AbsoluteBreadthIndex::new();
assert_eq!(abi.name(), "AbsoluteBreadthIndex");
assert_eq!(abi.warmup_period(), 1);
assert!(!abi.is_ready());
}
#[test]
fn magnitude_ignores_direction() {
let mut abi = AbsoluteBreadthIndex::new();
assert_eq!(abi.update(section(2, 5)), Some(3.0));
// Same magnitude with the direction reversed.
let mut abi2 = AbsoluteBreadthIndex::new();
assert_eq!(abi2.update(section(5, 2)), Some(3.0));
}
#[test]
fn balanced_universe_yields_zero() {
let mut abi = AbsoluteBreadthIndex::new();
assert_eq!(abi.update(section(3, 3)), Some(0.0));
assert!(abi.is_ready());
}
#[test]
fn reset_clears_state() {
let mut abi = AbsoluteBreadthIndex::new();
abi.update(section(2, 5));
assert!(abi.is_ready());
abi.reset();
assert!(!abi.is_ready());
}
#[test]
fn batch_equals_streaming() {
let sections = vec![section(2, 5), section(5, 2), section(3, 3)];
let mut a = AbsoluteBreadthIndex::new();
let mut b = AbsoluteBreadthIndex::new();
assert_eq!(
a.batch(&sections),
sections
.iter()
.map(|s| b.update(s.clone()))
.collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,157 @@
//! Advance/Decline Volume Line — cumulative net advancing-minus-declining volume.
use crate::cross_section::CrossSection;
use crate::traits::Indicator;
/// Advance/Decline Volume Line (AD Volume Line) — the running cumulative sum of
/// net advancing volume across a universe.
///
/// On each [`CrossSection`] tick the net is `advancing volume - declining volume`,
/// where advancing volume is the total volume of symbols with a positive change
/// and declining volume the total volume of symbols with a negative change. The
/// line accumulates this net over time, so a rising line means volume is flowing
/// into advancing issues (healthy participation) while a falling line warns that
/// declining issues are carrying the volume — the volume-weighted analogue of the
/// plain Advance/Decline Line.
///
/// `Input = CrossSection`, `Output = f64`, `warmup_period == 1` (defined from the
/// first tick).
///
/// # Example
///
/// ```
/// use wickra_core::{AdVolumeLine, CrossSection, Indicator, Member};
///
/// let mut adv = AdVolumeLine::new();
/// // advancing volume 150, declining volume 50 -> net +100.
/// let tick = CrossSection::new(
/// vec![
/// Member::new(1.0, 150.0, false, false),
/// Member::new(-1.0, 50.0, false, false),
/// ],
/// 0,
/// )
/// .unwrap();
/// assert_eq!(adv.update(tick), Some(100.0));
/// ```
#[derive(Debug, Clone, Default)]
pub struct AdVolumeLine {
line: f64,
has_emitted: bool,
}
impl AdVolumeLine {
/// Construct a new Advance/Decline Volume Line indicator.
#[must_use]
pub const fn new() -> Self {
Self {
line: 0.0,
has_emitted: false,
}
}
}
impl Indicator for AdVolumeLine {
type Input = CrossSection;
type Output = f64;
fn update(&mut self, section: CrossSection) -> Option<f64> {
let net = section.advancing_volume() - section.declining_volume();
self.line += net;
self.has_emitted = true;
Some(self.line)
}
fn reset(&mut self) {
self.line = 0.0;
self.has_emitted = false;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"AdVolumeLine"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cross_section::Member;
use crate::traits::BatchExt;
fn tick(items: &[(f64, f64)]) -> CrossSection {
CrossSection::new(
items
.iter()
.map(|&(change, volume)| Member::new(change, volume, false, false))
.collect(),
0,
)
.unwrap()
}
#[test]
fn accessors_and_metadata() {
let adv = AdVolumeLine::new();
assert_eq!(adv.name(), "AdVolumeLine");
assert_eq!(adv.warmup_period(), 1);
assert!(!adv.is_ready());
}
#[test]
fn first_tick_emits_net_volume() {
let mut adv = AdVolumeLine::new();
assert_eq!(adv.update(tick(&[(1.0, 150.0), (-1.0, 50.0)])), Some(100.0));
assert!(adv.is_ready());
}
#[test]
fn line_accumulates_across_ticks() {
let mut adv = AdVolumeLine::new();
assert_eq!(adv.update(tick(&[(1.0, 150.0), (-1.0, 50.0)])), Some(100.0));
assert_eq!(adv.update(tick(&[(1.0, 60.0), (-1.0, 60.0)])), Some(100.0));
assert_eq!(adv.update(tick(&[(1.0, 30.0)])), Some(130.0));
}
#[test]
fn unchanged_volume_is_ignored() {
let mut adv = AdVolumeLine::new();
// Unchanged symbols (zero change) contribute to neither bucket.
assert_eq!(adv.update(tick(&[(0.0, 1000.0), (1.0, 10.0)])), Some(10.0));
}
#[test]
fn reset_clears_state() {
let mut adv = AdVolumeLine::new();
adv.update(tick(&[(1.0, 100.0)]));
assert!(adv.is_ready());
adv.reset();
assert!(!adv.is_ready());
assert_eq!(adv.update(tick(&[(1.0, 20.0)])), Some(20.0));
}
#[test]
fn batch_equals_streaming() {
let sections = vec![
tick(&[(1.0, 150.0), (-1.0, 50.0)]),
tick(&[(1.0, 60.0), (-1.0, 60.0)]),
tick(&[(1.0, 30.0)]),
];
let mut a = AdVolumeLine::new();
let mut b = AdVolumeLine::new();
assert_eq!(
a.batch(&sections),
sections
.iter()
.map(|s| b.update(s.clone()))
.collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,344 @@
//! Ehlers' Adaptive Laguerre Filter.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// John Ehlers' Adaptive Laguerre Filter — a four-stage Laguerre polynomial
/// smoother whose damping factor `gamma` is recomputed every bar from how well
/// the filter is currently tracking price.
///
/// The Laguerre cascade is the same one used by [`LaguerreRsi`](crate::LaguerreRsi),
/// but instead of a fixed `gamma` the filter adapts: it measures the recent
/// absolute error `|price filter|`, normalises those errors across a window of
/// `period` bars to `[0, 1]`, and takes their **median** as `gamma`. When price
/// is tracking smoothly the errors are small and uniform (low `gamma`, fast
/// response); when price jumps, the spread of errors widens and `gamma` rises,
/// slowing the filter to reject the noise.
///
/// ```text
/// diff_t = |price_t filter_{t-1}|
/// over the last `period` diffs:
/// HH = max(diff), LL = min(diff)
/// norm_i = (diff_i LL) / (HH LL) (0 if HH == LL)
/// gamma = median(norm)
/// alpha = 1 gamma
/// L0_t = alpha·price_t + gamma·L0_{t-1}
/// L1_t = gamma·L0_t + L0_{t-1} + gamma·L1_{t-1}
/// L2_t = gamma·L1_t + L1_{t-1} + gamma·L2_{t-1}
/// L3_t = gamma·L2_t + L2_{t-1} + gamma·L3_{t-1}
/// filter_t = (L0_t + 2·L1_t + 2·L2_t + L3_t) / 6
/// ```
///
/// The output is a smoothed price on the same scale as the input. The first
/// emission lands once the error window holds `period` values.
///
/// Reference: John F. Ehlers, *"Adaptive Laguerre Filter"*, Technical Analysis
/// of Stocks & Commodities, 2007.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, AdaptiveLaguerreFilter};
///
/// let mut indicator = AdaptiveLaguerreFilter::new(13).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// last = indicator.update(100.0 + f64::from(i));
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct AdaptiveLaguerreFilter {
period: usize,
l0: f64,
l1: f64,
l2: f64,
l3: f64,
/// Previous filter output, or `None` before the first bar.
filter: Option<f64>,
/// The last `period` absolute errors `|price filter|`.
diffs: VecDeque<f64>,
}
impl AdaptiveLaguerreFilter {
/// Construct a new adaptive Laguerre filter with the given error-window
/// length.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
l0: 0.0,
l1: 0.0,
l2: 0.0,
l3: 0.0,
filter: None,
diffs: VecDeque::with_capacity(period),
})
}
/// Configured error-window length.
pub const fn period(&self) -> usize {
self.period
}
/// Current value if the error window is full.
pub fn value(&self) -> Option<f64> {
if self.diffs.len() == self.period {
self.filter
} else {
None
}
}
/// Median of the normalised errors currently in the window. Returns `0.0`
/// when every error is equal (e.g. during a constant warmup), which makes
/// the filter maximally fast.
fn adaptive_gamma(&self) -> f64 {
let mut hh = f64::MIN;
let mut ll = f64::MAX;
for &d in &self.diffs {
if d > hh {
hh = d;
}
if d < ll {
ll = d;
}
}
let range = hh - ll;
if range <= 0.0 {
return 0.0;
}
let mut norm: Vec<f64> = self.diffs.iter().map(|&d| (d - ll) / range).collect();
// `total_cmp` never panics — under pathological (e.g. overflowing) fuzz
// inputs a normalised error can be non-finite; a total order keeps the
// sort sound where `partial_cmp` would return `None`.
norm.sort_by(f64::total_cmp);
let mid = norm.len() / 2;
if norm.len() % 2 == 1 {
norm[mid]
} else {
f64::midpoint(norm[mid - 1], norm[mid])
}
}
}
impl Indicator for AdaptiveLaguerreFilter {
type Input = f64;
type Output = f64;
fn update(&mut self, price: f64) -> Option<f64> {
if !price.is_finite() {
return self.value();
}
// Absolute tracking error against the previous filter (0 on the first
// bar, where there is no prior filter value).
let diff = self.filter.map_or(0.0, |f| (price - f).abs());
if self.diffs.len() == self.period {
self.diffs.pop_front();
}
self.diffs.push_back(diff);
let gamma = self.adaptive_gamma();
let alpha = 1.0 - gamma;
let l0 = alpha * price + gamma * self.l0;
let l1 = -gamma * l0 + self.l0 + gamma * self.l1;
let l2 = -gamma * l1 + self.l1 + gamma * self.l2;
let l3 = -gamma * l2 + self.l2 + gamma * self.l3;
self.l0 = l0;
self.l1 = l1;
self.l2 = l2;
self.l3 = l3;
let filter = (l0 + 2.0 * l1 + 2.0 * l2 + l3) / 6.0;
self.filter = Some(filter);
self.value()
}
fn reset(&mut self) {
self.l0 = 0.0;
self.l1 = 0.0;
self.l2 = 0.0;
self.l3 = 0.0;
self.filter = None;
self.diffs.clear();
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.diffs.len() == self.period
}
fn name(&self) -> &'static str {
"AdaptiveLaguerre"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
/// Independent reference: replays the exact recurrence from scratch.
fn naive(prices: &[f64], period: usize) -> Vec<Option<f64>> {
let (mut l0, mut l1, mut l2, mut l3) = (0.0_f64, 0.0_f64, 0.0_f64, 0.0_f64);
let mut filter: Option<f64> = None;
let mut diffs: Vec<f64> = Vec::new();
let mut out = Vec::with_capacity(prices.len());
for &price in prices {
let diff = filter.map_or(0.0, |f: f64| (price - f).abs());
diffs.push(diff);
if diffs.len() > period {
diffs.remove(0);
}
let hh = diffs.iter().copied().fold(f64::MIN, f64::max);
let ll = diffs.iter().copied().fold(f64::MAX, f64::min);
let range = hh - ll;
let gamma = if range <= 0.0 {
0.0
} else {
let mut norm: Vec<f64> = diffs.iter().map(|&d| (d - ll) / range).collect();
norm.sort_by(|a, b| a.partial_cmp(b).unwrap());
let mid = norm.len() / 2;
if norm.len() % 2 == 1 {
norm[mid]
} else {
f64::midpoint(norm[mid - 1], norm[mid])
}
};
let alpha = 1.0 - gamma;
let n0 = alpha * price + gamma * l0;
let n1 = -gamma * n0 + l0 + gamma * l1;
let n2 = -gamma * n1 + l1 + gamma * l2;
let n3 = -gamma * n2 + l2 + gamma * l3;
l0 = n0;
l1 = n1;
l2 = n2;
l3 = n3;
let f = (n0 + 2.0 * n1 + 2.0 * n2 + n3) / 6.0;
filter = Some(f);
out.push(if diffs.len() == period { Some(f) } else { None });
}
out
}
#[test]
fn new_rejects_zero_period() {
assert!(matches!(
AdaptiveLaguerreFilter::new(0),
Err(Error::PeriodZero)
));
}
/// Cover the const accessor `period` and the Indicator-impl `warmup_period`
/// + `name`.
#[test]
fn accessors_and_metadata() {
let alf = AdaptiveLaguerreFilter::new(13).unwrap();
assert_eq!(alf.period(), 13);
assert_eq!(alf.warmup_period(), 13);
assert_eq!(alf.name(), "AdaptiveLaguerre");
}
#[test]
fn warmup_returns_none_until_window_full() {
let mut alf = AdaptiveLaguerreFilter::new(3).unwrap();
assert_eq!(alf.update(10.0), None);
assert_eq!(alf.update(11.0), None);
assert!(alf.update(12.0).is_some());
}
#[test]
fn constant_series_converges_to_constant() {
// Errors are all zero -> gamma 0 -> the 4-stage delay line fills with
// the constant and the filter settles on it.
let mut alf = AdaptiveLaguerreFilter::new(5).unwrap();
let out = alf.batch(&[42.0_f64; 40]);
let last = out.iter().rev().flatten().next().unwrap();
assert_relative_eq!(*last, 42.0, epsilon = 1e-9);
}
#[test]
fn converged_output_stays_within_price_range() {
// Once the Laguerre cascade has filled (it cold-starts from zero, so the
// first few post-warmup values ramp up toward price), the filter is a
// convex blend of recent prices and must stay inside the data range.
let prices: Vec<f64> = (0..120)
.map(|i| 50.0 + (f64::from(i) * 0.4).sin() * 10.0)
.collect();
let lo = prices.iter().copied().fold(f64::MAX, f64::min);
let hi = prices.iter().copied().fold(f64::MIN, f64::max);
let period = 8;
let mut alf = AdaptiveLaguerreFilter::new(period).unwrap();
for (i, v) in alf.batch(&prices).into_iter().enumerate() {
// Skip the cold-start transient (a few multiples of the window).
if i < 4 * period {
continue;
}
let v = v.expect("filter is ready well past warmup");
assert!(
v >= lo - 1e-6 && v <= hi + 1e-6,
"filter out of range at {i}"
);
}
}
#[test]
fn matches_naive_recurrence() {
let prices: Vec<f64> = (0..80)
.map(|i| 100.0 + (f64::from(i) * 0.5).sin() * 8.0 + f64::from(i) * 0.1)
.collect();
let mut alf = AdaptiveLaguerreFilter::new(10).unwrap();
let got = alf.batch(&prices);
let want = naive(&prices, 10);
for (i, (g, w)) in got.iter().zip(want.iter()).enumerate() {
assert_eq!(g.is_some(), w.is_some(), "readiness mismatch at {i}");
if let (Some(a), Some(b)) = (g, w) {
assert_relative_eq!(*a, *b, epsilon = 1e-9);
}
}
}
#[test]
fn reset_clears_state() {
let mut alf = AdaptiveLaguerreFilter::new(5).unwrap();
alf.batch(&(1..=40).map(f64::from).collect::<Vec<_>>());
assert!(alf.is_ready());
alf.reset();
assert!(!alf.is_ready());
assert_eq!(alf.update(1.0), None);
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (1..=50).map(|i| f64::from(i) * 0.7).collect();
let mut a = AdaptiveLaguerreFilter::new(7).unwrap();
let mut b = AdaptiveLaguerreFilter::new(7).unwrap();
assert_eq!(
a.batch(&prices),
prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
);
}
#[test]
fn ignores_non_finite_input() {
let mut alf = AdaptiveLaguerreFilter::new(3).unwrap();
alf.update(10.0);
alf.update(11.0);
let ready = alf.update(12.0).expect("ready after three inputs");
assert_eq!(alf.update(f64::NAN), Some(ready));
assert_eq!(alf.update(f64::INFINITY), Some(ready));
}
}
@@ -0,0 +1,192 @@
//! Advance Block candlestick pattern.
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Advance Block — a 3-bar bearish warning: three green candles still pushing to
/// higher closes, but visibly running out of steam — each real body shrinks while
/// the upper shadows lengthen, hinting the advance is about to stall.
///
/// ```text
/// all three green & higher closes
/// each opens inside the prior body
/// shrinking bodies (body3 < body2 < body1)
/// upper shadow of bar3 >= upper shadow of bar2 and bar3 has an upper shadow
/// ```
///
/// Output is `1.0` when the pattern completes and `0.0` otherwise. Advance Block
/// is a single-direction (bearish-only) warning, so it never emits `+1.0`. The
/// first two bars always return `0.0` because the three-bar window is not yet
/// filled. Pattern-shape check only — no trend filter is applied; combine with a
/// trend indicator for actionable signals.
///
/// # Signed ±1 encoding
///
/// This detector emits the uniform candlestick sign convention shared across the
/// pattern family — `1.0` bearish, `0.0` no pattern — so it drops straight into
/// a machine-learning feature matrix as a single dimension.
///
/// # Example
///
/// ```
/// use wickra_core::{AdvanceBlock, Candle, Indicator};
///
/// let mut indicator = AdvanceBlock::new();
/// indicator.update(Candle::new(10.0, 13.1, 9.9, 13.0, 1.0, 0).unwrap());
/// indicator.update(Candle::new(12.0, 14.3, 11.9, 14.0, 1.0, 1).unwrap());
/// let out = indicator
/// .update(Candle::new(13.5, 15.0, 13.4, 14.5, 1.0, 2).unwrap());
/// assert_eq!(out, Some(-1.0));
/// ```
#[derive(Debug, Clone, Default)]
pub struct AdvanceBlock {
prev: Option<Candle>,
prev_prev: Option<Candle>,
has_emitted: bool,
}
impl AdvanceBlock {
/// Construct a new Advance Block detector.
pub const fn new() -> Self {
Self {
prev: None,
prev_prev: None,
has_emitted: false,
}
}
}
impl Indicator for AdvanceBlock {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
self.has_emitted = true;
let pp = self.prev_prev;
let p = self.prev;
self.prev_prev = self.prev;
self.prev = Some(candle);
let (Some(bar1), Some(bar2)) = (pp, p) else {
return Some(0.0);
};
let body1 = bar1.close - bar1.open;
let body2 = bar2.close - bar2.open;
let body3 = candle.close - candle.open;
let upper2 = bar2.high - bar2.close;
let upper3 = candle.high - candle.close;
if bar1.close > bar1.open
&& bar2.close > bar2.open
&& candle.close > candle.open
&& bar2.close > bar1.close
&& candle.close > bar2.close
&& bar2.open >= bar1.open
&& bar2.open <= bar1.close
&& candle.open >= bar2.open
&& candle.open <= bar2.close
&& body2 < body1
&& body3 < body2
&& upper3 >= upper2
&& upper3 > 0.0
{
return Some(-1.0);
}
Some(0.0)
}
fn reset(&mut self) {
self.prev = None;
self.prev_prev = None;
self.has_emitted = false;
}
fn warmup_period(&self) -> usize {
3
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"AdvanceBlock"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle {
Candle::new(open, high, low, close, 1.0, ts).unwrap()
}
#[test]
fn accessors_and_metadata() {
let t = AdvanceBlock::new();
assert_eq!(t.name(), "AdvanceBlock");
assert_eq!(t.warmup_period(), 3);
assert!(!t.is_ready());
}
#[test]
fn advance_block_is_minus_one() {
let mut t = AdvanceBlock::new();
assert_eq!(t.update(c(10.0, 13.1, 9.9, 13.0, 0)), Some(0.0));
assert_eq!(t.update(c(12.0, 14.3, 11.9, 14.0, 1)), Some(0.0));
assert_eq!(t.update(c(13.5, 15.0, 13.4, 14.5, 2)), Some(-1.0));
}
#[test]
fn strong_advance_yields_zero() {
let mut t = AdvanceBlock::new();
// Bodies grow instead of shrinking -> a strong advance, not blocked.
assert_eq!(t.update(c(10.0, 11.1, 9.9, 11.0, 0)), Some(0.0));
assert_eq!(t.update(c(10.5, 12.6, 10.4, 12.5, 1)), Some(0.0));
assert_eq!(t.update(c(11.5, 14.1, 11.4, 14.0, 2)), Some(0.0));
}
#[test]
fn no_upper_shadow_growth_yields_zero() {
let mut t = AdvanceBlock::new();
t.update(c(10.0, 13.1, 9.9, 13.0, 0));
t.update(c(12.0, 14.3, 11.9, 14.0, 1));
// bar3 shrinking body but no upper shadow -> not blocked.
assert_eq!(t.update(c(13.5, 14.5, 13.4, 14.5, 2)), Some(0.0));
}
#[test]
fn first_two_bars_return_zero() {
let mut t = AdvanceBlock::new();
assert_eq!(t.update(c(10.0, 13.1, 9.9, 13.0, 0)), Some(0.0));
assert_eq!(t.update(c(12.0, 14.3, 11.9, 14.0, 1)), Some(0.0));
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..40)
.map(|i| {
let base = 100.0 + i as f64;
c(base, base + 2.0, base - 0.2, base + 1.5, i)
})
.collect();
let mut a = AdvanceBlock::new();
let mut b = AdvanceBlock::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut t = AdvanceBlock::new();
t.update(c(10.0, 13.1, 9.9, 13.0, 0));
t.update(c(12.0, 14.3, 11.9, 14.0, 1));
t.update(c(13.5, 15.0, 13.4, 14.5, 2));
assert!(t.is_ready());
t.reset();
assert!(!t.is_ready());
assert_eq!(t.update(c(10.0, 13.1, 9.9, 13.0, 0)), Some(0.0));
}
}
@@ -0,0 +1,168 @@
//! Advance/Decline Line — cumulative net advancing-minus-declining issues.
use crate::cross_section::CrossSection;
use crate::traits::Indicator;
/// Advance/Decline Line (A/D Line) — the running cumulative sum of net advancing
/// issues across a universe.
///
/// On each [`CrossSection`] tick the net breadth is `advancers - decliners`:
/// the number of symbols with a positive price change minus the number with a
/// negative change (unchanged symbols are ignored). The line accumulates this
/// net value over time, so a rising line means advancers have persistently
/// outnumbered decliners — broad participation — while a falling line warns that
/// a rally is being carried by fewer and fewer names (a breadth divergence when
/// the index itself is still rising).
///
/// `Input = CrossSection`, `Output = f64`. The line is defined from the very
/// first tick, so `warmup_period == 1` and the indicator is ready after one
/// update.
///
/// # Example
///
/// ```
/// use wickra_core::{AdvanceDecline, CrossSection, Indicator, Member};
///
/// let mut ad = AdvanceDecline::new();
/// // 3 advancers, 1 decliner -> net +2.
/// let tick = CrossSection::new(
/// vec![
/// Member::new(1.0, 10.0, false, false),
/// Member::new(0.5, 10.0, false, false),
/// Member::new(2.0, 10.0, false, false),
/// Member::new(-1.0, 10.0, false, false),
/// ],
/// 0,
/// )
/// .unwrap();
/// assert_eq!(ad.update(tick), Some(2.0));
/// ```
#[derive(Debug, Clone, Default)]
pub struct AdvanceDecline {
line: f64,
has_emitted: bool,
}
impl AdvanceDecline {
/// Construct a new Advance/Decline Line indicator.
#[must_use]
pub const fn new() -> Self {
Self {
line: 0.0,
has_emitted: false,
}
}
}
impl Indicator for AdvanceDecline {
type Input = CrossSection;
type Output = f64;
fn update(&mut self, section: CrossSection) -> Option<f64> {
let net = section.advancers() as f64 - section.decliners() as f64;
self.line += net;
self.has_emitted = true;
Some(self.line)
}
fn reset(&mut self) {
self.line = 0.0;
self.has_emitted = false;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"AdvanceDecline"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cross_section::Member;
use crate::traits::BatchExt;
/// Build a cross-section with `up` advancers, `down` decliners and `flat`
/// unchanged symbols.
fn section(up: usize, down: usize, flat: usize) -> CrossSection {
let mut members = Vec::new();
for _ in 0..up {
members.push(Member::new(1.0, 10.0, false, false));
}
for _ in 0..down {
members.push(Member::new(-1.0, 10.0, false, false));
}
for _ in 0..flat {
members.push(Member::new(0.0, 10.0, false, false));
}
CrossSection::new(members, 0).unwrap()
}
#[test]
fn accessors_and_metadata() {
let ad = AdvanceDecline::new();
assert_eq!(ad.name(), "AdvanceDecline");
assert_eq!(ad.warmup_period(), 1);
assert!(!ad.is_ready());
}
#[test]
fn first_tick_emits_net_breadth() {
let mut ad = AdvanceDecline::new();
assert_eq!(ad.update(section(3, 1, 0)), Some(2.0));
assert!(ad.is_ready());
}
#[test]
fn line_accumulates_across_ticks() {
let mut ad = AdvanceDecline::new();
assert_eq!(ad.update(section(3, 1, 0)), Some(2.0)); // +2 -> 2
assert_eq!(ad.update(section(1, 4, 0)), Some(-1.0)); // -3 -> -1
assert_eq!(ad.update(section(2, 0, 0)), Some(1.0)); // +2 -> 1
}
#[test]
fn unchanged_symbols_are_ignored() {
let mut ad = AdvanceDecline::new();
// 2 up, 2 down, 5 unchanged -> net 0, line stays flat.
assert_eq!(ad.update(section(2, 2, 5)), Some(0.0));
assert_eq!(ad.update(section(2, 2, 5)), Some(0.0));
}
#[test]
fn reset_clears_state() {
let mut ad = AdvanceDecline::new();
ad.update(section(5, 0, 0));
assert!(ad.is_ready());
ad.reset();
assert!(!ad.is_ready());
// Line restarts from zero, not from the pre-reset value.
assert_eq!(ad.update(section(1, 0, 0)), Some(1.0));
}
#[test]
fn batch_equals_streaming() {
let sections = vec![
section(3, 1, 2),
section(1, 4, 0),
section(2, 2, 1),
section(5, 0, 3),
];
let mut a = AdvanceDecline::new();
let mut b = AdvanceDecline::new();
assert_eq!(
a.batch(&sections),
sections
.iter()
.map(|s| b.update(s.clone()))
.collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,151 @@
//! Advance/Decline Ratio — advancing issues divided by declining issues.
use crate::cross_section::CrossSection;
use crate::traits::Indicator;
/// Advance/Decline Ratio (ADR) — the number of advancing symbols divided by the
/// number of declining symbols across a universe.
///
/// On each [`CrossSection`] tick the ratio is `advancers / decliners`: a reading
/// above one means advancing issues outnumber declining ones (broad strength),
/// while a reading below one signals broad weakness. Because it is a ratio rather
/// than a difference, the ADR is comparable across universes of different sizes.
///
/// When a tick has no declining symbols the denominator is floored to one, so the
/// ratio degrades gracefully to the advancer count instead of dividing by zero.
///
/// `Input = CrossSection`, `Output = f64`. The ratio is defined from the first
/// tick, so `warmup_period == 1` and the indicator is ready after one update.
///
/// # Example
///
/// ```
/// use wickra_core::{AdvanceDeclineRatio, CrossSection, Indicator, Member};
///
/// let mut adr = AdvanceDeclineRatio::new();
/// // 3 advancers, 1 decliner -> ratio 3.0.
/// let tick = CrossSection::new(
/// vec![
/// Member::new(1.0, 10.0, false, false),
/// Member::new(0.5, 10.0, false, false),
/// Member::new(2.0, 10.0, false, false),
/// Member::new(-1.0, 10.0, false, false),
/// ],
/// 0,
/// )
/// .unwrap();
/// assert_eq!(adr.update(tick), Some(3.0));
/// ```
#[derive(Debug, Clone, Default)]
pub struct AdvanceDeclineRatio {
has_emitted: bool,
}
impl AdvanceDeclineRatio {
/// Construct a new Advance/Decline Ratio indicator.
#[must_use]
pub const fn new() -> Self {
Self { has_emitted: false }
}
}
impl Indicator for AdvanceDeclineRatio {
type Input = CrossSection;
type Output = f64;
fn update(&mut self, section: CrossSection) -> Option<f64> {
let advancers = section.advancers() as f64;
let decliners = section.decliners().max(1) as f64;
self.has_emitted = true;
Some(advancers / decliners)
}
fn reset(&mut self) {
self.has_emitted = false;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"AdvanceDeclineRatio"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cross_section::Member;
use crate::traits::BatchExt;
fn section(up: usize, down: usize) -> CrossSection {
let mut members = Vec::new();
for _ in 0..up {
members.push(Member::new(1.0, 10.0, false, false));
}
for _ in 0..down {
members.push(Member::new(-1.0, 10.0, false, false));
}
// A non-empty unchanged member guarantees a valid universe when both
// counts are zero.
members.push(Member::new(0.0, 10.0, false, false));
CrossSection::new(members, 0).unwrap()
}
#[test]
fn accessors_and_metadata() {
let adr = AdvanceDeclineRatio::new();
assert_eq!(adr.name(), "AdvanceDeclineRatio");
assert_eq!(adr.warmup_period(), 1);
assert!(!adr.is_ready());
}
#[test]
fn first_tick_emits_ratio() {
let mut adr = AdvanceDeclineRatio::new();
assert_eq!(adr.update(section(3, 1)), Some(3.0));
assert!(adr.is_ready());
}
#[test]
fn zero_decliners_floors_denominator() {
let mut adr = AdvanceDeclineRatio::new();
// 4 advancers, 0 decliners -> 4 / max(0, 1) = 4.0.
assert_eq!(adr.update(section(4, 0)), Some(4.0));
}
#[test]
fn no_advancers_yields_zero() {
let mut adr = AdvanceDeclineRatio::new();
assert_eq!(adr.update(section(0, 5)), Some(0.0));
}
#[test]
fn reset_clears_state() {
let mut adr = AdvanceDeclineRatio::new();
adr.update(section(3, 1));
assert!(adr.is_ready());
adr.reset();
assert!(!adr.is_ready());
assert_eq!(adr.update(section(2, 1)), Some(2.0));
}
#[test]
fn batch_equals_streaming() {
let sections = vec![section(3, 1), section(4, 0), section(0, 5), section(2, 2)];
let mut a = AdvanceDeclineRatio::new();
let mut b = AdvanceDeclineRatio::new();
assert_eq!(
a.batch(&sections),
sections
.iter()
.map(|s| b.update(s.clone()))
.collect::<Vec<_>>()
);
}
}
+1 -1
View File
@@ -91,7 +91,7 @@ impl Adx {
}
}
fn directional_movement(prev: &Candle, current: &Candle) -> (f64, f64) {
pub(crate) fn directional_movement(prev: &Candle, current: &Candle) -> (f64, f64) {
let up = current.high - prev.high;
let down = prev.low - current.low;
let plus_dm = if up > down && up > 0.0 { up } else { 0.0 };
@@ -0,0 +1,239 @@
//! Amihud Illiquidity — average price impact per unit traded value.
use std::collections::VecDeque;
use crate::microstructure::Trade;
use crate::traits::Indicator;
use crate::{Error, Result};
/// Amihud Illiquidity — the average absolute log return per unit of traded
/// value over the last `period` trades (Amihud, 2002).
///
/// ```text
/// rₜ = ln(priceₜ / priceₜ₋₁)
/// ILLIQₜ = |rₜ| / (priceₜ · sizeₜ) (return per dollar of volume)
/// Amihud = mean of ILLIQ over the last `period` trades
/// ```
///
/// Amihud's measure captures how much the price moves for a given amount of
/// traded value: a **high** reading means small volume already shifts the price
/// a lot (an illiquid, easily-moved market), a **low** reading means it takes
/// large volume to move the price (a deep, liquid market). It is the workhorse
/// cross-sectional liquidity proxy in market-microstructure research.
///
/// `Input = Trade`. Trades with zero size carry no traded value and are skipped
/// (the ratio is undefined); the last value is returned and state is untouched.
/// The first valid trade only seeds the reference price.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, Side, Trade, AmihudIlliquidity};
///
/// let mut amihud = AmihudIlliquidity::new(20).unwrap();
/// assert_eq!(amihud.update(Trade::new(100.0, 5.0, Side::Buy, 0).unwrap()), None);
/// ```
#[derive(Debug, Clone)]
pub struct AmihudIlliquidity {
period: usize,
prev_price: Option<f64>,
window: VecDeque<f64>,
sum: f64,
last: Option<f64>,
}
impl AmihudIlliquidity {
/// Construct a new Amihud Illiquidity over the given trade window.
///
/// # Errors
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
prev_price: None,
window: VecDeque::with_capacity(period),
sum: 0.0,
last: None,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for AmihudIlliquidity {
type Input = Trade;
type Output = f64;
fn update(&mut self, trade: Trade) -> Option<f64> {
// A zero-size trade has no traded value: the ratio is undefined, so the
// trade is skipped without touching the reference price.
if trade.size == 0.0 {
return self.last;
}
let Some(prev) = self.prev_price else {
self.prev_price = Some(trade.price);
return None;
};
self.prev_price = Some(trade.price);
// `prev` and `trade.price` are both finite and strictly positive
// (enforced by `Trade::new`), so the log return is well-defined and the
// traded value is strictly positive.
let ret = (trade.price / prev).ln().abs();
let illiq = ret / (trade.price * trade.size);
if self.window.len() == self.period {
let old = self.window.pop_front().expect("window is non-empty");
self.sum -= old;
}
self.window.push_back(illiq);
self.sum += illiq;
if self.window.len() < self.period {
return None;
}
let value = self.sum / self.period as f64;
self.last = Some(value);
Some(value)
}
fn reset(&mut self) {
self.prev_price = None;
self.window.clear();
self.sum = 0.0;
self.last = None;
}
fn warmup_period(&self) -> usize {
self.period + 1
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"AmihudIlliquidity"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::microstructure::Side;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn trade(price: f64, size: f64) -> Trade {
Trade::new(price, size, Side::Buy, 0).unwrap()
}
#[test]
fn rejects_zero_period() {
assert!(matches!(AmihudIlliquidity::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_and_metadata() {
let a = AmihudIlliquidity::new(20).unwrap();
assert_eq!(a.period(), 20);
assert_eq!(a.warmup_period(), 21);
assert_eq!(a.name(), "AmihudIlliquidity");
assert!(!a.is_ready());
}
#[test]
fn known_value() {
// period 1. Seed at 100, then 101 with size 10:
// |ln(101/100)| / (101 * 10).
let mut a = AmihudIlliquidity::new(1).unwrap();
assert_eq!(a.update(trade(100.0, 10.0)), None);
let out = a.update(trade(101.0, 10.0)).unwrap();
let expected = (101.0_f64 / 100.0).ln().abs() / (101.0 * 10.0);
assert_relative_eq!(out, expected, epsilon = 1e-15);
}
#[test]
fn higher_for_thinner_volume() {
// Same price move on smaller volume => larger illiquidity reading.
let thin = {
let mut a = AmihudIlliquidity::new(1).unwrap();
a.update(trade(100.0, 1.0));
a.update(trade(101.0, 1.0)).unwrap()
};
let thick = {
let mut a = AmihudIlliquidity::new(1).unwrap();
a.update(trade(100.0, 1000.0));
a.update(trade(101.0, 1000.0)).unwrap()
};
assert!(thin > thick, "thin {thin} should exceed thick {thick}");
}
#[test]
fn flat_price_is_zero() {
let mut a = AmihudIlliquidity::new(5).unwrap();
for v in a.batch(&[trade(100.0, 3.0); 20]).into_iter().flatten() {
assert_relative_eq!(v, 0.0, epsilon = 1e-15);
}
}
#[test]
fn skips_zero_size_trades() {
let mut a = AmihudIlliquidity::new(1).unwrap();
a.update(trade(100.0, 10.0));
let baseline = a.update(trade(101.0, 10.0)).unwrap();
// A zero-size trade is ignored; the previous reference price is kept.
assert_eq!(a.update(trade(200.0, 0.0)), Some(baseline));
// The next real trade still references price 101, not 200.
let mut control = a.clone();
let after = a.update(trade(102.0, 10.0)).unwrap();
assert_eq!(control.update(trade(102.0, 10.0)).unwrap(), after);
}
#[test]
fn output_is_non_negative() {
let mut a = AmihudIlliquidity::new(10).unwrap();
let trades: Vec<Trade> = (0..100)
.map(|i| {
trade(
100.0 + (f64::from(i) * 0.3).sin() * 5.0,
1.0 + f64::from(i % 7),
)
})
.collect();
for v in a.batch(&trades).into_iter().flatten() {
assert!(v >= 0.0, "illiquidity must be non-negative, got {v}");
}
}
#[test]
fn reset_clears_state() {
let mut a = AmihudIlliquidity::new(5).unwrap();
for i in 0..20 {
a.update(trade(100.0 + f64::from(i), 2.0));
}
assert!(a.is_ready());
a.reset();
assert!(!a.is_ready());
assert_eq!(a.update(trade(100.0, 1.0)), None);
}
#[test]
fn batch_equals_streaming() {
let trades: Vec<Trade> = (0..80)
.map(|i| {
trade(
100.0 + (f64::from(i) * 0.25).sin() * 4.0,
1.0 + f64::from(i % 5),
)
})
.collect();
let batch = AmihudIlliquidity::new(14).unwrap().batch(&trades);
let mut b = AmihudIlliquidity::new(14).unwrap();
let streamed: Vec<_> = trades.iter().map(|t| b.update(*t)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,284 @@
//! Anchored Relative Strength Index.
use crate::traits::Indicator;
/// Anchored RSI — a cumulative Relative Strength Index whose averaging begins at
/// a user-chosen anchor bar rather than over a fixed Wilder period.
///
/// Where [`crate::Rsi`] uses Wilder's `period`-length smoothing, Anchored RSI
/// accumulates *every* up- and down-move since the anchor with equal weight, so
/// it answers "what is the RSI of the entire move since the anchor point?". The
/// running relative strength is `Σ gains / Σ losses` over all bars in the
/// current anchor window (the bar count cancels, so this equals
/// `avg_gain / avg_loss`):
///
/// ```text
/// RSI_t = 100 - 100 / (1 + Σ_{i ≥ anchor} gain_i / Σ_{i ≥ anchor} loss_i)
/// ```
///
/// As with [`crate::AnchoredVwap`], the anchor is chosen at runtime:
/// [`AnchoredRsi::set_anchor`] re-anchors at the **next** bar that arrives,
/// clearing the running sums. Because RSI needs a price *change*, the first bar
/// of a fresh anchor window only seeds the previous close and emits `None`; the
/// first value follows on the second bar (warmup period 2).
///
/// Saturation follows the standard convention: a window with no losses yet (and
/// at least one gain) reads 100, no gains yet reads 0, and a perfectly flat
/// window reads the neutral 50. Non-finite inputs are ignored, leaving the last
/// value unchanged.
///
/// # Example
///
/// ```
/// use wickra_core::{AnchoredRsi, Indicator};
///
/// let mut indicator = AnchoredRsi::new();
/// let mut last = None;
/// for i in 0..80 {
/// let price = 100.0 + (f64::from(i) * 0.5).sin() * 5.0;
/// // Re-anchor at bar 40 (e.g. a major swing low).
/// if i == 40 {
/// indicator.set_anchor();
/// }
/// last = indicator.update(price);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone, Default)]
pub struct AnchoredRsi {
prev_close: Option<f64>,
sum_gain: f64,
sum_loss: f64,
last_value: Option<f64>,
pending_anchor: bool,
}
impl AnchoredRsi {
/// Construct a fresh Anchored RSI. The first bar to arrive is the anchor.
pub const fn new() -> Self {
Self {
prev_close: None,
sum_gain: 0.0,
sum_loss: 0.0,
last_value: None,
pending_anchor: false,
}
}
/// Mark a re-anchor: the **next** [`Indicator::update`] call clears the
/// running sums and previous close before folding in its own bar, starting
/// a fresh anchored window.
pub fn set_anchor(&mut self) {
self.pending_anchor = true;
}
/// Current anchored RSI value if at least one price change has been
/// observed in the current anchor window.
pub const fn value(&self) -> Option<f64> {
self.last_value
}
fn rsi_from_sums(sum_gain: f64, sum_loss: f64) -> f64 {
if sum_loss == 0.0 {
if sum_gain == 0.0 {
// No movement at all -> RSI undefined; standard convention returns 50.
50.0
} else {
100.0
}
} else {
let rs = sum_gain / sum_loss;
100.0 - 100.0 / (1.0 + rs)
}
}
}
impl Indicator for AnchoredRsi {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() {
return self.last_value;
}
if self.pending_anchor {
self.prev_close = None;
self.sum_gain = 0.0;
self.sum_loss = 0.0;
self.last_value = None;
self.pending_anchor = false;
}
let Some(prev) = self.prev_close else {
self.prev_close = Some(input);
return None;
};
self.prev_close = Some(input);
let diff = input - prev;
if diff > 0.0 {
self.sum_gain += diff;
} else if diff < 0.0 {
self.sum_loss -= diff;
}
let value = Self::rsi_from_sums(self.sum_gain, self.sum_loss);
self.last_value = Some(value);
Some(value)
}
fn reset(&mut self) {
self.prev_close = None;
self.sum_gain = 0.0;
self.sum_loss = 0.0;
self.last_value = None;
self.pending_anchor = false;
}
fn warmup_period(&self) -> usize {
2
}
fn is_ready(&self) -> bool {
self.last_value.is_some()
}
fn name(&self) -> &'static str {
"AnchoredRSI"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn accessors_and_metadata() {
let indicator = AnchoredRsi::new();
assert_eq!(indicator.name(), "AnchoredRSI");
assert_eq!(indicator.warmup_period(), 2);
assert_eq!(indicator.value(), None);
assert!(!indicator.is_ready());
}
#[test]
fn first_bar_seeds_and_returns_none() {
let mut indicator = AnchoredRsi::new();
assert_eq!(indicator.update(100.0), None);
assert!(!indicator.is_ready());
// Second bar produces the first value.
assert!(indicator.update(101.0).is_some());
assert!(indicator.is_ready());
}
#[test]
fn pure_uptrend_saturates_at_100() {
let mut indicator = AnchoredRsi::new();
let out = indicator.batch(&[10.0, 11.0, 12.0, 13.0]);
assert_relative_eq!(out[3].unwrap(), 100.0, epsilon = 1e-12);
}
#[test]
fn pure_downtrend_saturates_at_0() {
let mut indicator = AnchoredRsi::new();
let out = indicator.batch(&[13.0, 12.0, 11.0, 10.0]);
assert_relative_eq!(out[3].unwrap(), 0.0, epsilon = 1e-12);
}
#[test]
fn flat_window_reads_50() {
let mut indicator = AnchoredRsi::new();
let out = indicator.batch(&[42.0, 42.0, 42.0]);
assert_relative_eq!(out[2].unwrap(), 50.0, epsilon = 1e-12);
}
#[test]
fn cumulative_reference_values() {
// prices 10 -> 11 (+1) -> 9 (-2) -> 12 (+3)
// after bar2: sum_gain=1, sum_loss=2 -> rs=0.5 -> 100 - 100/1.5 = 33.3333
// after bar3: sum_gain=4, sum_loss=2 -> rs=2.0 -> 100 - 100/3 = 66.6667
let mut indicator = AnchoredRsi::new();
let out = indicator.batch(&[10.0, 11.0, 9.0, 12.0]);
assert_relative_eq!(out[1].unwrap(), 100.0, epsilon = 1e-9);
assert_relative_eq!(out[2].unwrap(), 33.333_333_333, epsilon = 1e-6);
assert_relative_eq!(out[3].unwrap(), 66.666_666_666, epsilon = 1e-6);
}
#[test]
fn set_anchor_clears_old_window() {
// Downtrend, then re-anchor and pump an uptrend: the new window must
// read 100, not the blended value.
let mut indicator = AnchoredRsi::new();
indicator.batch(&[20.0, 19.0, 18.0, 17.0]);
assert_relative_eq!(indicator.value().unwrap(), 0.0, epsilon = 1e-12);
indicator.set_anchor();
// First bar after anchor re-seeds (None), second bar emits.
assert_eq!(indicator.update(50.0), None);
let after = indicator.update(51.0).unwrap();
assert_relative_eq!(after, 100.0, epsilon = 1e-12);
}
#[test]
fn set_anchor_before_first_bar_acts_as_normal_start() {
let mut indicator = AnchoredRsi::new();
indicator.set_anchor();
assert_eq!(indicator.update(10.0), None);
assert_relative_eq!(indicator.update(11.0).unwrap(), 100.0, epsilon = 1e-12);
}
#[test]
fn ignores_non_finite_input() {
let mut indicator = AnchoredRsi::new();
indicator.batch(&[10.0, 11.0, 12.0]);
let before = indicator.value();
assert!(before.is_some());
assert_eq!(indicator.update(f64::NAN), before);
assert_eq!(indicator.update(f64::INFINITY), before);
assert_eq!(indicator.value(), before);
}
#[test]
fn non_finite_before_any_bar_returns_none() {
let mut indicator = AnchoredRsi::new();
assert_eq!(indicator.update(f64::NAN), None);
assert!(!indicator.is_ready());
}
#[test]
fn reset_clears_state() {
let mut indicator = AnchoredRsi::new();
indicator.batch(&[10.0, 11.0, 12.0]);
assert!(indicator.is_ready());
indicator.reset();
assert!(!indicator.is_ready());
assert_eq!(indicator.value(), None);
assert_eq!(indicator.update(50.0), None);
}
#[test]
fn stays_in_0_100_range() {
let prices: Vec<f64> = (0..200)
.map(|i| 100.0 + (f64::from(i) * 0.7).sin() * 10.0)
.collect();
let mut indicator = AnchoredRsi::new();
for value in indicator.batch(&prices).into_iter().flatten() {
assert!((0.0..=100.0).contains(&value), "RSI out of range: {value}");
}
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (1..=40)
.map(|i| (f64::from(i) * 0.3).sin() * 5.0 + f64::from(i))
.collect();
let mut a = AnchoredRsi::new();
let mut b = AnchoredRsi::new();
assert_eq!(
a.batch(&prices),
prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
);
}
}
+27 -10
View File
@@ -28,9 +28,17 @@ use crate::traits::Indicator;
#[derive(Debug, Clone)]
pub struct Atr {
period: usize,
/// `period - 1` as `f64`, precomputed for the Wilder smoothing step.
n_minus_1: f64,
/// `1 / period`, precomputed so the per-tick smoothing multiplies instead of
/// divides.
inv_period: f64,
prev_close: Option<f64>,
seed_buf: Vec<f64>,
avg: Option<f64>,
/// Smoothed ATR, valid once `seeded` is set. Bare `f64` + flag rather than
/// `Option<f64>` so the hot recurrence avoids an enum-tag read per tick.
avg: f64,
seeded: bool,
}
impl Atr {
@@ -45,9 +53,12 @@ impl Atr {
}
Ok(Self {
period,
n_minus_1: (period - 1) as f64,
inv_period: 1.0 / period as f64,
prev_close: None,
seed_buf: Vec::with_capacity(period),
avg: None,
avg: 0.0,
seeded: false,
})
}
@@ -58,7 +69,11 @@ impl Atr {
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.avg
if self.seeded {
Some(self.avg)
} else {
None
}
}
}
@@ -70,17 +85,18 @@ impl Indicator for Atr {
let tr = candle.true_range(self.prev_close);
self.prev_close = Some(candle.close);
if let Some(avg) = self.avg {
let n = self.period as f64;
let new_avg = avg.mul_add(n - 1.0, tr) / n;
self.avg = Some(new_avg);
if self.seeded {
// Wilder smoothing with the reciprocal hoisted out of the hot path.
let new_avg = self.avg.mul_add(self.n_minus_1, tr) * self.inv_period;
self.avg = new_avg;
return Some(new_avg);
}
self.seed_buf.push(tr);
if self.seed_buf.len() == self.period {
let seed = self.seed_buf.iter().copied().sum::<f64>() / self.period as f64;
self.avg = Some(seed);
self.avg = seed;
self.seeded = true;
return Some(seed);
}
None
@@ -89,7 +105,8 @@ impl Indicator for Atr {
fn reset(&mut self) {
self.prev_close = None;
self.seed_buf.clear();
self.avg = None;
self.avg = 0.0;
self.seeded = false;
}
fn warmup_period(&self) -> usize {
@@ -97,7 +114,7 @@ impl Indicator for Atr {
}
fn is_ready(&self) -> bool {
self.avg.is_some()
self.seeded
}
fn name(&self) -> &'static str {
@@ -0,0 +1,176 @@
//! Auto-Fibonacci — retracement of the most significant recent swing leg.
use crate::indicators::pattern_swing::{SwingTracker, SWING_THRESHOLD};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// How many recent pivots to consider when picking the dominant leg.
const PIVOT_HISTORY: usize = 6;
/// The seven canonical retracement ratios, in ascending order.
const RATIOS: [f64; 7] = [0.0, 0.236, 0.382, 0.5, 0.618, 0.786, 1.0];
/// Auto-Fibonacci retracement levels for the dominant recent swing leg.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct AutoFibOutput {
/// 0.0% — the dominant leg's end.
pub level_0: f64,
/// 23.6% retracement.
pub level_236: f64,
/// 38.2% retracement.
pub level_382: f64,
/// 50% retracement.
pub level_500: f64,
/// 61.8% retracement.
pub level_618: f64,
/// 78.6% retracement.
pub level_786: f64,
/// 100% — the dominant leg's start.
pub level_1000: f64,
}
/// Auto-Fibonacci (`AutoFib`).
///
/// Like [`crate::indicators::FibRetracement`], but instead of always using the
/// immediate last leg it scans the last six confirmed pivots and anchors the
/// retracement on the single largest-magnitude leg among them — the dominant
/// swing the market is most likely respecting.
///
/// Parameter-free; construction is infallible. Returns `None` until two pivots
/// have confirmed.
///
/// See `crates/wickra-core/src/indicators/auto_fib.rs`.
#[derive(Debug, Clone)]
pub struct AutoFib {
swing: SwingTracker,
}
impl AutoFib {
/// Construct a new Auto-Fibonacci tracker.
#[must_use]
pub const fn new() -> Self {
Self {
swing: SwingTracker::new(SWING_THRESHOLD, PIVOT_HISTORY),
}
}
fn levels(&self) -> Option<AutoFibOutput> {
let dominant = self.swing.pivots().windows(2).max_by(|x, y| {
(x[0].price - x[1].price)
.abs()
.total_cmp(&(y[0].price - y[1].price).abs())
})?;
let (start, end) = (dominant[0].price, dominant[1].price);
let level = |r: f64| end + r * (start - end);
Some(AutoFibOutput {
level_0: level(RATIOS[0]),
level_236: level(RATIOS[1]),
level_382: level(RATIOS[2]),
level_500: level(RATIOS[3]),
level_618: level(RATIOS[4]),
level_786: level(RATIOS[5]),
level_1000: level(RATIOS[6]),
})
}
}
impl Default for AutoFib {
fn default() -> Self {
Self::new()
}
}
impl Indicator for AutoFib {
type Input = Candle;
type Output = AutoFibOutput;
fn update(&mut self, candle: Candle) -> Option<AutoFibOutput> {
self.swing.update(candle);
self.levels()
}
fn reset(&mut self) {
self.swing.reset();
}
fn warmup_period(&self) -> usize {
2
}
fn is_ready(&self) -> bool {
self.swing.pivots().len() >= 2
}
fn name(&self) -> &'static str {
"AutoFib"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::indicators::pattern_swing::candles_for_pivots;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn accessors_and_metadata() {
let indicator = AutoFib::new();
assert_eq!(indicator.name(), "AutoFib");
assert_eq!(indicator.warmup_period(), 2);
assert!(!indicator.is_ready());
assert!(!AutoFib::default().is_ready());
}
#[test]
fn no_output_before_two_pivots() {
let mut indicator = AutoFib::new();
let outputs: Vec<_> = candles_for_pivots(&[120.0])
.into_iter()
.map(|c| indicator.update(c))
.collect();
assert!(outputs.iter().all(Option::is_none));
}
#[test]
fn anchors_on_the_largest_leg() {
// Pivots: 130 -> 120 (small, 10) -> 220 (large, 100) -> 200 (small, 20).
// The dominant leg is 120 -> 220; its retracement spans [120, 220].
let mut indicator = AutoFib::new();
let mut last = None;
for candle in candles_for_pivots(&[130.0, 120.0, 220.0, 200.0]) {
last = indicator.update(candle);
}
let v = last.unwrap();
assert!(indicator.is_ready());
// Largest leg 120 -> 220: 0% on 220 (end), 100% on 120 (start).
assert_relative_eq!(v.level_0, 220.0);
assert_relative_eq!(v.level_1000, 120.0);
assert_relative_eq!(v.level_500, 170.0);
assert_relative_eq!(v.level_618, 220.0 + 0.618 * (120.0 - 220.0));
}
#[test]
fn reset_clears_state() {
let mut indicator = AutoFib::new();
for candle in candles_for_pivots(&[200.0, 100.0]) {
let _ = indicator.update(candle);
}
assert!(indicator.is_ready());
indicator.reset();
assert!(!indicator.is_ready());
let c = Candle::new(99.5, 100.0, 99.5, 99.5, 1.0, 0).unwrap();
assert!(indicator.update(c).is_none());
}
#[test]
fn batch_equals_streaming() {
let candles = candles_for_pivots(&[130.0, 120.0, 220.0, 200.0]);
let mut a = AutoFib::new();
let mut b = AutoFib::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,231 @@
//! Average Daily Range (ADR) — the mean high-minus-low range of the last `period`
//! completed calendar-day sessions.
use std::collections::VecDeque;
use crate::calendar::civil_from_timestamp;
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Average Daily Range over the last `period` completed sessions.
///
/// The indicator tracks the running high / low of the current session (the
/// wall-clock day of [`Candle::timestamp`](crate::Candle) shifted by
/// `utc_offset_minutes`). When a new day begins, the just-finished session's
/// range (`high - low`) joins a rolling window of the last `period` completed
/// days, and the reported value is their mean. The current, still-forming day is
/// excluded until it closes. No value is produced until the first session
/// completes.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, AverageDailyRange};
///
/// let hour = 3_600_000;
/// let mut adr = AverageDailyRange::new(2, 0).unwrap();
/// // Day 1 range 10 (high 110, low 100) — still forming, so None.
/// assert!(adr.update(Candle::new(105.0, 110.0, 100.0, 108.0, 1.0, 0).unwrap()).is_none());
/// // First bar of day 2 closes day 1: ADR = 10.
/// let v = adr.update(Candle::new(108.0, 112.0, 106.0, 109.0, 1.0, 24 * hour).unwrap()).unwrap();
/// assert!((v - 10.0).abs() < 1e-9);
/// ```
#[derive(Debug, Clone)]
pub struct AverageDailyRange {
period: usize,
utc_offset_minutes: i32,
day_key: Option<(i64, u32, u32)>,
cur_high: f64,
cur_low: f64,
completed: VecDeque<f64>,
sum: f64,
}
impl AverageDailyRange {
/// Construct an ADR indicator over `period` completed days.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize, utc_offset_minutes: i32) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
utc_offset_minutes,
day_key: None,
cur_high: f64::NEG_INFINITY,
cur_low: f64::INFINITY,
completed: VecDeque::with_capacity(period),
sum: 0.0,
})
}
/// Configured `(period, utc_offset_minutes)`.
pub const fn params(&self) -> (usize, i32) {
(self.period, self.utc_offset_minutes)
}
/// Most recent ADR if at least one session has completed.
pub fn value(&self) -> Option<f64> {
if self.completed.is_empty() {
None
} else {
Some(self.sum / self.completed.len() as f64)
}
}
}
impl Indicator for AverageDailyRange {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
let civil = civil_from_timestamp(candle.timestamp, self.utc_offset_minutes);
let key = (civil.year, civil.month, civil.day);
match self.day_key {
Some(prev) if prev == key => {
if candle.high > self.cur_high {
self.cur_high = candle.high;
}
if candle.low < self.cur_low {
self.cur_low = candle.low;
}
}
Some(_) => {
let range = self.cur_high - self.cur_low;
self.completed.push_back(range);
self.sum += range;
if self.completed.len() > self.period {
self.sum -= self
.completed
.pop_front()
.expect("len > period implies a front element");
}
self.day_key = Some(key);
self.cur_high = candle.high;
self.cur_low = candle.low;
}
None => {
self.day_key = Some(key);
self.cur_high = candle.high;
self.cur_low = candle.low;
}
}
self.value()
}
fn reset(&mut self) {
self.day_key = None;
self.cur_high = f64::NEG_INFINITY;
self.cur_low = f64::INFINITY;
self.completed.clear();
self.sum = 0.0;
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
!self.completed.is_empty()
}
fn name(&self) -> &'static str {
"AverageDailyRange"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
const HOUR: i64 = 3_600_000;
const DAY: i64 = 24 * HOUR;
fn c(high: f64, low: f64, ts: i64) -> Candle {
let mid = f64::midpoint(high, low);
Candle::new(mid, high, low, mid, 1.0, ts).unwrap()
}
#[test]
fn rejects_zero_period() {
assert!(matches!(
AverageDailyRange::new(0, 0),
Err(Error::PeriodZero)
));
}
#[test]
fn metadata_and_accessors() {
let adr = AverageDailyRange::new(5, -60).unwrap();
assert_eq!(adr.params(), (5, -60));
assert_eq!(adr.name(), "AverageDailyRange");
assert_eq!(adr.warmup_period(), 5);
assert!(!adr.is_ready());
assert!(adr.value().is_none());
}
#[test]
fn averages_completed_day_ranges() {
let mut adr = AverageDailyRange::new(3, 0).unwrap();
// Day 1: range 10.
assert!(adr.update(c(110.0, 100.0, 0)).is_none());
assert!(adr.update(c(108.0, 104.0, HOUR)).is_none());
// Day 2 opens -> day 1 (range 10) completes.
let v = adr.update(c(120.0, 110.0, DAY)).unwrap();
assert_relative_eq!(v, 10.0);
assert!(adr.is_ready());
// Day 3 opens -> day 2 (range 10) completes: mean of [10, 10] = 10.
let v = adr.update(c(130.0, 100.0, 2 * DAY)).unwrap();
assert_relative_eq!(v, 10.0);
}
#[test]
fn rolls_off_oldest_day_beyond_period() {
let mut adr = AverageDailyRange::new(2, 0).unwrap();
adr.update(c(110.0, 100.0, 0)); // day 1 range 10
let v = adr.update(c(125.0, 110.0, DAY)).unwrap(); // close day 1 -> [10]
assert_relative_eq!(v, 10.0);
// Close day 2 (range 125-110=15) -> window [10, 15], mean 12.5.
let v = adr.update(c(130.0, 110.0, 2 * DAY)).unwrap();
assert_relative_eq!(v, 12.5);
// Close day 3 (range 130-110=20) -> window [15, 20], oldest (10) rolled off.
let v = adr.update(c(140.0, 138.0, 3 * DAY)).unwrap();
assert_relative_eq!(v, 17.5);
}
#[test]
fn reset_clears_state() {
let mut adr = AverageDailyRange::new(2, 0).unwrap();
adr.update(c(110.0, 100.0, 0));
adr.update(c(120.0, 110.0, DAY));
adr.reset();
assert!(!adr.is_ready());
assert!(adr.value().is_none());
assert!(adr.update(c(50.0, 40.0, 2 * DAY)).is_none());
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..60)
.map(|i| {
c(
110.0 + f64::from(i % 5),
100.0 - f64::from(i % 3),
i64::from(i) * 6 * HOUR,
)
})
.collect();
let mut a = AverageDailyRange::new(4, 0).unwrap();
let mut b = AverageDailyRange::new(4, 0).unwrap();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,92 @@
//! Average Price (AVGPRICE).
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Average Price (`AVGPRICE`) — the bar's `(open + high + low + close) / 4`.
///
/// A per-bar price aggregate that, unlike [`TypicalPrice`](crate::TypicalPrice)
/// and [`WeightedClose`](crate::WeightedClose), folds in the open as well as the
/// high, low and close. As a stateless transform it emits a value from the very
/// first candle.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, AvgPrice};
///
/// let mut indicator = AvgPrice::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 AvgPrice {
has_emitted: bool,
}
impl AvgPrice {
/// Construct a new Average Price transform.
pub const fn new() -> Self {
Self { has_emitted: false }
}
}
impl Indicator for AvgPrice {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
self.has_emitted = true;
Some(candle.avg_price())
}
fn reset(&mut self) {
self.has_emitted = false;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"AVGPRICE"
}
}
#[cfg(test)]
mod tests {
use super::*;
use approx::assert_relative_eq;
#[test]
fn averages_the_four_prices() {
// (open + high + low + close) / 4 = (10 + 14 + 6 + 12) / 4 = 10.5.
let candle = Candle::new(10.0, 14.0, 6.0, 12.0, 1.0, 0).unwrap();
let mut ap = AvgPrice::new();
assert!(!ap.is_ready());
assert_relative_eq!(ap.update(candle).unwrap(), 10.5, epsilon = 1e-12);
assert!(ap.is_ready());
}
#[test]
fn accessors_and_reset() {
let mut ap = AvgPrice::new();
assert_eq!(ap.name(), "AVGPRICE");
assert_eq!(ap.warmup_period(), 1);
let candle = Candle::new(10.0, 14.0, 6.0, 12.0, 1.0, 0).unwrap();
let _ = ap.update(candle);
assert!(ap.is_ready());
ap.reset();
assert!(!ap.is_ready());
}
}
+154
View File
@@ -0,0 +1,154 @@
//! Bat harmonic pattern.
use crate::indicators::pattern_swing::{ratios_in, xabcd, SwingTracker, SWING_THRESHOLD};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Bat — a 5-point (X-A-B-C-D) harmonic pattern with a shallow B and a deep
/// `0.886` D completion:
///
/// ```text
/// AB / XA ∈ [0.382, 0.50]
/// BC / AB ∈ [0.382, 0.886]
/// CD / BC ∈ [1.618, 2.618]
/// AD / XA ∈ [0.84, 0.93] (≈ 0.886 — the defining D completion)
/// ```
///
/// Output is `+1.0` (bullish, D a swing low), `-1.0` (bearish, D a swing high),
/// or `0.0`; never `None`. See `crates/wickra-core/src/indicators/bat.rs`.
#[derive(Debug, Clone)]
pub struct Bat {
swing: SwingTracker,
has_emitted: bool,
}
impl Bat {
/// Construct a new Bat detector.
pub const fn new() -> Self {
Self {
swing: SwingTracker::new(SWING_THRESHOLD, 5),
has_emitted: false,
}
}
}
impl Default for Bat {
fn default() -> Self {
Self::new()
}
}
impl Indicator for Bat {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
self.has_emitted = true;
if !self.swing.update(candle) {
return Some(0.0);
}
let pivots = self.swing.pivots();
if pivots.len() < 5 {
return Some(0.0);
}
let p = xabcd(pivots);
let xa = (p.a - p.x).abs();
let ab = (p.b - p.a).abs();
let bc = (p.c - p.b).abs();
let cd = (p.d - p.c).abs();
let ad = (p.d - p.a).abs();
let matched = ratios_in(&[
(ab / xa, 0.382, 0.50),
(bc / ab, 0.382, 0.886),
(cd / bc, 1.618, 2.618),
(ad / xa, 0.84, 0.93),
]);
if matched {
return Some(if p.bullish { 1.0 } else { -1.0 });
}
Some(0.0)
}
fn reset(&mut self) {
self.swing.reset();
self.has_emitted = false;
}
fn warmup_period(&self) -> usize {
6
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"Bat"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::indicators::pattern_swing::candles_for_pivots;
use crate::traits::BatchExt;
fn run(pivots: &[f64]) -> Vec<f64> {
let mut indicator = Bat::new();
candles_for_pivots(pivots)
.into_iter()
.map(|c| indicator.update(c).unwrap())
.collect()
}
#[test]
fn accessors_and_metadata() {
let indicator = Bat::new();
assert_eq!(indicator.name(), "Bat");
assert_eq!(indicator.warmup_period(), 6);
assert!(!indicator.is_ready());
assert!(!Bat::default().is_ready());
}
#[test]
fn bullish_bat_is_plus_one() {
let out = run(&[150.0, 100.0, 140.0, 122.0, 137.0, 104.56]);
assert_eq!(*out.last().unwrap(), 1.0);
assert!(out[..out.len() - 1].iter().all(|&x| x == 0.0));
}
#[test]
fn bearish_bat_is_minus_one() {
let out = run(&[150.0, 110.0, 128.0, 113.0, 145.44]);
assert_eq!(*out.last().unwrap(), -1.0);
}
#[test]
fn out_of_ratio_does_not_trigger() {
let out = run(&[150.0, 100.0, 140.0, 110.0, 135.0, 105.0]);
assert_eq!(*out.last().unwrap(), 0.0);
}
#[test]
fn reset_clears_state() {
let mut indicator = Bat::new();
for c in candles_for_pivots(&[150.0, 100.0, 140.0]) {
let _ = indicator.update(c);
}
indicator.reset();
assert!(!indicator.is_ready());
let c = Candle::new(99.5, 100.0, 99.5, 99.5, 1.0, 0).unwrap();
assert_eq!(indicator.update(c), Some(0.0));
}
#[test]
fn batch_equals_streaming() {
let candles = candles_for_pivots(&[150.0, 100.0, 140.0, 122.0, 137.0, 104.56]);
let mut a = Bat::new();
let mut b = Bat::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,211 @@
//! Belt-hold candlestick pattern.
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Belt-hold — a single-bar reversal: a long candle that opens at one extreme of
/// its range (an "opening marubozu") and runs the other way.
///
/// ```text
/// range = high low
/// bullish (+1.0): green, opens at the low (open low <= tol * range) & long body
/// bearish (1.0): red, opens at the high (high open <= tol * range) & long body
/// long body = |close open| >= 0.5 * range
/// ```
///
/// Output is `0.0` when the opening side carries a shadow, the body is short, or
/// the range is degenerate. `shadow_tolerance` defaults to `0.05` (5 % of the bar
/// range allowed on the opening side) and must lie in `[0, 1)`. Pattern-shape
/// check only — no trend filter is applied; combine with a trend indicator for
/// actionable signals.
///
/// # Signed ±1 encoding
///
/// This detector emits the uniform candlestick sign convention shared across the
/// pattern family — `+1.0` bullish, `1.0` bearish, `0.0` no pattern — so it
/// drops straight into a machine-learning feature matrix where the bullish and
/// bearish variants occupy a single dimension.
///
/// # Example
///
/// ```
/// use wickra_core::{BeltHold, Candle, Indicator};
///
/// let mut indicator = BeltHold::new();
/// // Bullish belt-hold: opens at the low, closes near the high.
/// let candle = Candle::new(10.0, 12.0, 10.0, 11.5, 1.0, 0).unwrap();
/// assert_eq!(indicator.update(candle), Some(1.0));
/// ```
#[derive(Debug, Clone)]
pub struct BeltHold {
shadow_tolerance: f64,
has_emitted: bool,
}
impl Default for BeltHold {
fn default() -> Self {
Self::new()
}
}
impl BeltHold {
/// Construct a Belt-hold detector with the default 5 % opening-shadow tolerance.
pub const fn new() -> Self {
Self {
shadow_tolerance: 0.05,
has_emitted: false,
}
}
/// Construct a Belt-hold detector with a custom opening-shadow tolerance.
///
/// `shadow_tolerance` must lie in `[0, 1)`.
pub fn with_tolerance(shadow_tolerance: f64) -> Result<Self> {
if !(0.0..1.0).contains(&shadow_tolerance) {
return Err(Error::InvalidPeriod {
message: "belt-hold shadow tolerance must lie in [0, 1)",
});
}
Ok(Self {
shadow_tolerance,
has_emitted: false,
})
}
/// Configured opening-shadow tolerance.
pub fn shadow_tolerance(&self) -> f64 {
self.shadow_tolerance
}
}
impl Indicator for BeltHold {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
self.has_emitted = true;
let range = candle.high - candle.low;
if range <= 0.0 {
return Some(0.0);
}
let body = candle.close - candle.open;
if body.abs() < 0.5 * range {
return Some(0.0);
}
let tol = self.shadow_tolerance * range;
// Bullish: opens at the low (no lower shadow), green body.
if body > 0.0 && candle.open - candle.low <= tol {
return Some(1.0);
}
// Bearish: opens at the high (no upper shadow), red body.
if body < 0.0 && candle.high - candle.open <= tol {
return Some(-1.0);
}
Some(0.0)
}
fn reset(&mut self) {
self.has_emitted = false;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"BeltHold"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle {
Candle::new(open, high, low, close, 1.0, ts).unwrap()
}
#[test]
fn rejects_invalid_tolerance() {
assert!(BeltHold::with_tolerance(-0.01).is_err());
assert!(BeltHold::with_tolerance(1.0).is_err());
}
#[test]
fn accepts_valid_tolerance() {
let t = BeltHold::with_tolerance(0.0).unwrap();
assert!((t.shadow_tolerance() - 0.0).abs() < 1e-12);
}
#[test]
fn accessors_and_metadata() {
let t = BeltHold::default();
assert_eq!(t.name(), "BeltHold");
assert_eq!(t.warmup_period(), 1);
assert!(!t.is_ready());
assert!((t.shadow_tolerance() - 0.05).abs() < 1e-12);
}
#[test]
fn bullish_belt_hold_is_plus_one() {
let mut t = BeltHold::new();
assert_eq!(t.update(c(10.0, 12.0, 10.0, 11.5, 0)), Some(1.0));
}
#[test]
fn bearish_belt_hold_is_minus_one() {
let mut t = BeltHold::new();
assert_eq!(t.update(c(12.0, 12.0, 10.0, 10.5, 0)), Some(-1.0));
}
#[test]
fn opening_shadow_yields_zero() {
let mut t = BeltHold::new();
// Opens 0.5 above the low -> lower shadow exceeds tolerance.
assert_eq!(t.update(c(10.5, 12.0, 10.0, 11.5, 0)), Some(0.0));
}
#[test]
fn short_body_yields_zero() {
let mut t = BeltHold::new();
// Body 0.5 < half the range (1.0) -> not a long belt-hold.
assert_eq!(t.update(c(10.0, 12.0, 10.0, 10.5, 0)), Some(0.0));
}
#[test]
fn zero_range_yields_zero() {
let mut t = BeltHold::new();
assert_eq!(t.update(c(10.0, 10.0, 10.0, 10.0, 0)), Some(0.0));
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..40)
.map(|i| {
let base = 100.0 + i as f64;
c(base, base + 2.0, base, base + 1.8, i)
})
.collect();
let mut a = BeltHold::new();
let mut b = BeltHold::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut t = BeltHold::new();
t.update(c(10.0, 12.0, 10.0, 11.5, 0));
assert!(t.is_ready());
t.reset();
assert!(!t.is_ready());
}
}
@@ -0,0 +1,247 @@
//! Beta-neutral spread: the rolling OLS regression residual of two series.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// The beta-neutral spread between two assets — the residual of a rolling
/// ordinary-least-squares regression of `a` on `b`.
///
/// Each `update` takes one `(a, b)` price pair. Over the trailing window of
/// `period` pairs the indicator fits the hedge ratio `β` (and intercept `α`) by
/// OLS and reports the **current** residual:
///
/// ```text
/// β = cov(a, b) / var(b) α = ā β · b̄
/// spread = a_now (α + β · b_now)
/// ```
///
/// Subtracting `β · b` removes `a`'s exposure to `b`, so the spread is market-
/// (beta-)neutral: it is what is left after the common factor is hedged out.
/// Positive means `a` is rich relative to its hedge, negative means cheap — the
/// raw signal a pairs trade fades. Where [`crate::PairSpreadZScore`] standardises
/// this residual into a z-score and [`crate::Cointegration`] bundles it with an
/// ADF test, this indicator returns the residual itself, in price units.
///
/// If `b` is flat over the window (`var(b) = 0`) there is no defined slope; the
/// indicator falls back to `β = 0`, so the spread becomes `a_now ā`.
///
/// Each `update` is `O(1)`: four running sums (`Σa`, `Σb`, `Σb²`, `Σab`) are
/// maintained as the window slides.
///
/// # Example
///
/// ```
/// use wickra_core::{BetaNeutralSpread, Indicator};
///
/// let mut s = BetaNeutralSpread::new(20).unwrap();
/// let mut last = None;
/// for t in 0..40 {
/// let b = 100.0 + f64::from(t);
/// // a = 2·b + 5 exactly ⇒ the regression explains a fully ⇒ spread ≈ 0.
/// last = s.update((2.0 * b + 5.0, b));
/// }
/// assert!(last.unwrap().abs() < 1e-6);
/// ```
#[derive(Debug, Clone)]
pub struct BetaNeutralSpread {
period: usize,
window: VecDeque<(f64, f64)>,
sum_a: f64,
sum_b: f64,
sum_bb: f64,
sum_ab: f64,
}
impl BetaNeutralSpread {
/// Construct a new beta-neutral spread.
///
/// # Errors
/// Returns [`Error::InvalidPeriod`] if `period < 2` — a regression slope
/// needs at least two points.
pub fn new(period: usize) -> Result<Self> {
if period < 2 {
return Err(Error::InvalidPeriod {
message: "beta-neutral spread 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 look-back window.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for BetaNeutralSpread {
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 (beta, intercept) = if var_b == 0.0 {
(0.0, mean_a)
} else {
let cov = self.sum_ab / n - mean_a * mean_b;
let slope = cov / var_b;
(slope, mean_a - slope * mean_b)
};
Some(a - (intercept + beta * 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 {
"BetaNeutralSpread"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_period_below_two() {
assert!(BetaNeutralSpread::new(1).is_err());
assert!(BetaNeutralSpread::new(2).is_ok());
}
#[test]
fn accessors_and_metadata() {
let s = BetaNeutralSpread::new(20).unwrap();
assert_eq!(s.period(), 20);
assert_eq!(s.warmup_period(), 20);
assert_eq!(s.name(), "BetaNeutralSpread");
assert!(!s.is_ready());
}
#[test]
fn warmup_returns_none() {
let mut s = BetaNeutralSpread::new(3).unwrap();
assert_eq!(s.update((1.0, 1.0)), None);
assert_eq!(s.update((2.0, 2.0)), None);
assert!(s.update((3.0, 3.0)).is_some());
assert!(s.is_ready());
}
#[test]
fn perfect_linear_relationship_has_zero_spread() {
let pairs: Vec<(f64, f64)> = (0..40)
.map(|t| {
let b = 100.0 + f64::from(t);
(2.0 * b + 5.0, b)
})
.collect();
let last = BetaNeutralSpread::new(20)
.unwrap()
.batch(&pairs)
.into_iter()
.flatten()
.last()
.unwrap();
assert_relative_eq!(last, 0.0, epsilon = 1e-6);
}
#[test]
fn dislocation_produces_nonzero_spread() {
// a tracks 2·b, then the last bar jumps up ⇒ positive residual.
let mut pairs: Vec<(f64, f64)> = (0..19)
.map(|t| {
let b = 100.0 + f64::from(t);
(2.0 * b + 5.0, b)
})
.collect();
pairs.push((2.0 * 119.0 + 5.0 + 10.0, 119.0));
let last = BetaNeutralSpread::new(20)
.unwrap()
.batch(&pairs)
.into_iter()
.flatten()
.last()
.unwrap();
assert!(last > 1.0, "spread {last}");
}
#[test]
fn flat_b_falls_back_to_demeaned_a() {
// b constant ⇒ β = 0 ⇒ spread = a mean(a). Last window of a = 0..9,
// mean = 4.5, last a = 9 ⇒ spread = 4.5.
let pairs: Vec<(f64, f64)> = (0..10).map(|t| (f64::from(t), 7.0)).collect();
let last = BetaNeutralSpread::new(10)
.unwrap()
.batch(&pairs)
.into_iter()
.flatten()
.last()
.unwrap();
assert_relative_eq!(last, 4.5, epsilon = 1e-12);
}
#[test]
fn reset_clears_state() {
let mut s = BetaNeutralSpread::new(4).unwrap();
s.batch(&[(1.0, 2.0), (2.0, 4.0), (3.0, 5.0), (4.0, 9.0), (5.0, 2.0)]);
assert!(s.is_ready());
s.reset();
assert!(!s.is_ready());
assert_eq!(s.update((1.0, 1.0)), None);
}
#[test]
fn batch_equals_streaming() {
let pairs: Vec<(f64, f64)> = (0..60)
.map(|t| {
let b = 30.0 + 0.7 * f64::from(t);
(1.8 * b + 2.0 + (f64::from(t) * 0.4).sin(), b)
})
.collect();
let batch = BetaNeutralSpread::new(20).unwrap().batch(&pairs);
let mut s = BetaNeutralSpread::new(20).unwrap();
let streamed: Vec<_> = pairs.iter().map(|p| s.update(*p)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,281 @@
//! Realized Bipower Variation — a jump-robust quadratic-variation estimator.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Realized Bipower Variation — the sum of *adjacent* absolute log-return
/// products over the trailing `period` returns, scaled to estimate integrated
/// variance.
///
/// ```text
/// r_t = ln(price_t / price_{t1})
/// BV = (π / 2) · Σ |r_t| · |r_{t1}| over the window
/// ```
///
/// Bipower variation (Barndorff-Nielsen & Shephard 2004) estimates the same
/// integrated variance as [`RealizedVolatility`](crate::RealizedVolatility)'s
/// `Σ r²`, but by multiplying *neighbouring* absolute returns rather than
/// squaring a single one. A price jump inflates exactly one return; because that
/// return appears in a product with its (ordinary) neighbour rather than squared,
/// its contribution stays bounded — so `BV` is **robust to jumps** while realized
/// variance is not. The constant `π / 2 = μ₁⁻²` (with `μ₁ = E|Z| = √(2/π)` for a
/// standard normal) debiases the product of two half-normal magnitudes back to a
/// variance scale.
///
/// The output is on the **variance** scale (the jump-robust counterpart of
/// realized *variance*, not volatility); take its square root for a volatility,
/// and compare `RV BV` to isolate the jump contribution. A window of `period`
/// returns contributes `period 1` adjacent products; each `update` is O(1) via
/// a running sum.
///
/// Non-finite and non-positive prices are ignored (the log return would be
/// undefined): the tick is dropped, state is left untouched, and the last value
/// is returned.
///
/// # Example
///
/// ```
/// use wickra_core::{BipowerVariation, Indicator};
///
/// let mut indicator = BipowerVariation::new(20).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// last = indicator.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct BipowerVariation {
period: usize,
prev_price: Option<f64>,
/// Rolling window of the last `period` log returns.
window: VecDeque<f64>,
/// Running sum of adjacent absolute-return products inside the window.
sum_adjacent: f64,
last: Option<f64>,
}
impl BipowerVariation {
/// Construct a new bipower-variation indicator.
///
/// `period` is the number of log returns in the rolling window; the estimate
/// uses the `period 1` adjacent products between them.
///
/// # Errors
/// Returns [`Error::PeriodZero`] if `period == 0`, or
/// [`Error::InvalidPeriod`] if `period == 1` (an adjacent product needs at
/// least two returns).
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
if period < 2 {
return Err(Error::InvalidPeriod {
message: "bipower variation period must be >= 2",
});
}
Ok(Self {
period,
prev_price: None,
window: VecDeque::with_capacity(period),
sum_adjacent: 0.0,
last: None,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
}
/// `μ₁⁻² = π / 2`, the debiasing constant for a product of half-normal returns.
const MU1_INV_SQ: f64 = std::f64::consts::FRAC_PI_2;
impl Indicator for BipowerVariation {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
// Non-finite / non-positive prices are skipped: `ln(input / prev)` is
// undefined, so the tick must not enter the return window.
if !input.is_finite() || input <= 0.0 {
return self.last;
}
let Some(prev) = self.prev_price else {
self.prev_price = Some(input);
return None;
};
self.prev_price = Some(input);
// `prev` came from `self.prev_price`, gated by the guard above, so it is
// finite and positive — the log return is always well-defined.
let r = (input / prev).ln();
// The incoming return forms a product with the current last return.
if let Some(&back) = self.window.back() {
self.sum_adjacent += back.abs() * r.abs();
}
self.window.push_back(r);
if self.window.len() > self.period {
let first = self.window.pop_front().expect("window is non-empty");
// The product between the dropped return and the new front leaves.
let second = *self.window.front().expect("window still has >= 1 element");
self.sum_adjacent -= first.abs() * second.abs();
}
if self.window.len() < self.period {
return None;
}
// Products are non-negative; the rolling subtraction can leave a tiny
// negative residual when returns are ~0, so clamp before scaling.
let bv = MU1_INV_SQ * self.sum_adjacent.max(0.0);
self.last = Some(bv);
Some(bv)
}
fn reset(&mut self) {
self.prev_price = None;
self.window.clear();
self.sum_adjacent = 0.0;
self.last = None;
}
fn warmup_period(&self) -> usize {
// The first log return needs a previous price, then the window fills.
self.period + 1
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"BipowerVariation"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_zero_period() {
assert!(matches!(BipowerVariation::new(0), Err(Error::PeriodZero)));
}
#[test]
fn rejects_period_one() {
assert!(matches!(
BipowerVariation::new(1),
Err(Error::InvalidPeriod { .. })
));
}
#[test]
fn accessors_and_metadata() {
let bv = BipowerVariation::new(20).unwrap();
assert_eq!(bv.period(), 20);
assert_eq!(bv.warmup_period(), 21);
assert_eq!(bv.name(), "BipowerVariation");
assert!(!bv.is_ready());
}
#[test]
fn first_emission_at_warmup_period() {
let mut bv = BipowerVariation::new(5).unwrap();
let out = bv.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
for v in out.iter().take(5) {
assert!(v.is_none());
}
assert!(out[5].is_some());
}
#[test]
fn known_value() {
// period = 2: one adjacent product. r1 = ln(1.1), r2 = ln(0.9).
// BV = (π/2)·|r1|·|r2|.
let mut bv = BipowerVariation::new(2).unwrap();
let out = bv.batch(&[100.0, 110.0, 99.0]);
assert!(out[1].is_none());
let r1 = (110.0_f64 / 100.0).ln();
let r2 = (99.0_f64 / 110.0).ln();
let expected = std::f64::consts::FRAC_PI_2 * r1.abs() * r2.abs();
assert_relative_eq!(out[2].unwrap(), expected, epsilon = 1e-12);
}
#[test]
fn rolling_window_drops_oldest_product() {
// period = 2, four prices -> two emissions, each a single product.
let mut bv = BipowerVariation::new(2).unwrap();
let out = bv.batch(&[100.0, 110.0, 99.0, 105.0]);
let r2 = (99.0_f64 / 110.0).ln();
let r3 = (105.0_f64 / 99.0).ln();
let expected = std::f64::consts::FRAC_PI_2 * r2.abs() * r3.abs();
assert_relative_eq!(out[3].unwrap(), expected, epsilon = 1e-12);
}
#[test]
fn constant_series_yields_zero() {
let mut bv = BipowerVariation::new(10).unwrap();
for v in bv.batch(&[100.0; 40]).into_iter().flatten() {
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
}
}
#[test]
fn output_is_non_negative() {
let mut bv = BipowerVariation::new(20).unwrap();
let prices: Vec<f64> = (1..=200)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 12.0)
.collect();
for v in bv.batch(&prices).into_iter().flatten() {
assert!(v >= 0.0, "bipower variation must be non-negative, got {v}");
}
}
#[test]
fn ignores_non_finite_input() {
let mut bv = BipowerVariation::new(5).unwrap();
let out = bv.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
let last = *out.last().unwrap();
assert!(last.is_some());
assert_eq!(bv.update(f64::NAN), last);
assert_eq!(bv.update(f64::INFINITY), last);
}
#[test]
fn skips_non_positive_prices() {
let mut bv = BipowerVariation::new(5).unwrap();
let warmup = bv.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
let baseline = warmup.last().copied().flatten().expect("warmed up");
assert_eq!(bv.update(-5.0), Some(baseline));
assert_eq!(bv.update(0.0), Some(baseline));
// State untouched: a clone advanced by the same real tick agrees.
let mut control = bv.clone();
let after = bv.update(21.0).expect("ready");
assert_eq!(control.update(21.0).expect("ready"), after);
}
#[test]
fn reset_clears_state() {
let mut bv = BipowerVariation::new(5).unwrap();
bv.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
assert!(bv.is_ready());
bv.reset();
assert!(!bv.is_ready());
assert_eq!(bv.update(1.0), None);
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (1..=120)
.map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 9.0)
.collect();
let batch = BipowerVariation::new(20).unwrap().batch(&prices);
let mut b = BipowerVariation::new(20).unwrap();
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,193 @@
//! Body Size Percent — candle body as a fraction of its range.
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Body Size Percent — the absolute body as a fraction of the bar's range.
///
/// ```text
/// BodySizePct = |close open| / (high low)
/// ```
///
/// The result lives in `[0, 1]`: `1` is a full-bodied marubozu (the bar opened
/// at one extreme and closed at the other, no wicks), `0` a doji (open equals
/// close, the bar is all wick). It is the *unsigned* magnitude companion to
/// [`BalanceOfPower`](crate::BalanceOfPower) — where `BoP` keeps the direction,
/// this keeps only the conviction, which is exactly what candlestick body /
/// range filters key on. A zero-range bar carries no information and yields `0`.
///
/// This is a stateless per-bar transform: every candle produces one value.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, BodySizePct};
///
/// let mut indicator = BodySizePct::new();
/// // body |12 - 10| = 2, range 14 - 10 = 4 -> 0.5.
/// let c = Candle::new(10.0, 14.0, 10.0, 12.0, 10.0, 0).unwrap();
/// assert!((indicator.update(c).unwrap() - 0.5).abs() < 1e-12);
/// ```
#[derive(Debug, Clone, Default)]
pub struct BodySizePct {
has_emitted: bool,
}
impl BodySizePct {
/// Construct a new Body Size Percent transform.
pub const fn new() -> Self {
Self { has_emitted: false }
}
}
impl Indicator for BodySizePct {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
self.has_emitted = true;
let range = candle.high - candle.low;
let out = if range == 0.0 {
// A zero-range bar has no body proportion to speak of.
0.0
} else {
(candle.close - candle.open).abs() / range
};
Some(out)
}
fn reset(&mut self) {
self.has_emitted = false;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"BodySizePct"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn candle(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle {
Candle::new(open, high, low, close, 1.0, ts).unwrap()
}
#[test]
fn reference_value() {
// |12 - 10| / (14 - 10) = 0.5.
let mut bsp = BodySizePct::new();
assert_relative_eq!(
bsp.update(candle(10.0, 14.0, 10.0, 12.0, 0)).unwrap(),
0.5,
epsilon = 1e-12
);
}
#[test]
fn marubozu_is_one() {
// open == low, close == high, no wicks -> full body -> 1.
let mut bsp = BodySizePct::new();
assert_relative_eq!(
bsp.update(candle(9.0, 11.0, 9.0, 11.0, 0)).unwrap(),
1.0,
epsilon = 1e-12
);
}
#[test]
fn doji_is_zero() {
// open == close with a real range -> body 0.
let mut bsp = BodySizePct::new();
assert_relative_eq!(
bsp.update(candle(10.0, 12.0, 8.0, 10.0, 0)).unwrap(),
0.0,
epsilon = 1e-12
);
}
#[test]
fn unsigned_regardless_of_direction() {
// A red bar with the same body magnitude reads identically to a green one.
let mut bsp = BodySizePct::new();
let green = bsp.update(candle(10.0, 14.0, 10.0, 12.0, 0)).unwrap();
let mut bsp2 = BodySizePct::new();
let red = bsp2.update(candle(12.0, 14.0, 10.0, 10.0, 0)).unwrap();
assert_relative_eq!(green, red, epsilon = 1e-12);
}
#[test]
fn zero_range_bar_yields_zero() {
let mut bsp = BodySizePct::new();
assert_relative_eq!(
bsp.update(candle(10.0, 10.0, 10.0, 10.0, 0)).unwrap(),
0.0,
epsilon = 1e-12
);
}
#[test]
fn stays_within_unit_range() {
let candles: Vec<Candle> = (0..100)
.map(|i| {
let mid = 100.0 + (f64::from(i) * 0.2).sin() * 8.0;
let close = mid + (f64::from(i) * 0.5).cos() * 2.0;
candle(mid, mid + 3.0, mid - 3.0, close, i64::from(i))
})
.collect();
let mut bsp = BodySizePct::new();
for v in bsp.batch(&candles).into_iter().flatten() {
assert!((0.0..=1.0).contains(&v), "BodySizePct {v} outside [0, 1]");
}
}
#[test]
fn name_metadata() {
let bsp = BodySizePct::new();
assert_eq!(bsp.name(), "BodySizePct");
}
#[test]
fn emits_from_first_candle() {
let mut bsp = BodySizePct::new();
assert_eq!(bsp.warmup_period(), 1);
assert!(!bsp.is_ready());
assert!(bsp.update(candle(10.0, 11.0, 9.0, 10.0, 0)).is_some());
assert!(bsp.is_ready());
}
#[test]
fn reset_clears_state() {
let mut bsp = BodySizePct::new();
bsp.update(candle(10.0, 11.0, 9.0, 10.0, 0));
assert!(bsp.is_ready());
bsp.reset();
assert!(!bsp.is_ready());
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..40)
.map(|i| {
let base = 100.0 + f64::from(i);
candle(base, base + 2.0, base - 2.0, base + 1.0, i64::from(i))
})
.collect();
let mut a = BodySizePct::new();
let mut b = BodySizePct::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
+34 -14
View File
@@ -1,7 +1,5 @@
//! Bollinger Bands.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
@@ -49,7 +47,13 @@ pub struct BollingerOutput {
pub struct BollingerBands {
period: usize,
multiplier: f64,
window: VecDeque<f64>,
/// Fixed-capacity ring buffer of the last `period` finite inputs. A flat
/// `Box<[f64]>` with a manual write cursor beats `VecDeque` on this hot path.
buf: Box<[f64]>,
/// Index of the next slot to write — also the oldest element once full.
head: usize,
/// Number of slots filled, saturating at `period`.
count: usize,
sum: f64,
sum_sq: f64,
/// Number of finite updates since the running sums were last reseeded
@@ -80,7 +84,9 @@ impl BollingerBands {
Ok(Self {
period,
multiplier,
window: VecDeque::with_capacity(period),
buf: vec![0.0; period].into_boxed_slice(),
head: 0,
count: 0,
sum: 0.0,
sum_sq: 0.0,
updates_since_recompute: 0,
@@ -103,7 +109,7 @@ impl BollingerBands {
}
fn current(&self) -> Option<BollingerOutput> {
if self.window.len() != self.period {
if self.count != self.period {
return None;
}
let n = self.period as f64;
@@ -129,25 +135,38 @@ impl Indicator for BollingerBands {
if !input.is_finite() {
return self.current();
}
if self.window.len() == self.period {
let old = self.window.pop_front().expect("non-empty");
if self.count == self.period {
let old = self.buf[self.head];
self.sum -= old;
self.sum_sq -= old * old;
self.buf[self.head] = input;
self.sum += input;
self.sum_sq += input * input;
} else {
self.buf[self.head] = input;
self.sum += input;
self.sum_sq += input * input;
self.count += 1;
}
self.head += 1;
if self.head == self.period {
self.head = 0;
}
self.window.push_back(input);
self.sum += input;
self.sum_sq += input * input;
self.updates_since_recompute += 1;
if self.updates_since_recompute >= RECOMPUTE_EVERY * self.period {
self.sum = self.window.iter().copied().sum();
self.sum_sq = self.window.iter().copied().map(|x| x * x).sum();
// Reseed in chronological order (oldest at `head`) to keep the running
// sums bit-equivalent to a fresh from-scratch pass on stable inputs.
let chronological = self.buf[self.head..].iter().chain(&self.buf[..self.head]);
self.sum = chronological.clone().copied().sum();
self.sum_sq = chronological.map(|&x| x * x).sum();
self.updates_since_recompute = 0;
}
self.current()
}
fn reset(&mut self) {
self.window.clear();
self.head = 0;
self.count = 0;
self.sum = 0.0;
self.sum_sq = 0.0;
self.updates_since_recompute = 0;
@@ -158,7 +177,7 @@ impl Indicator for BollingerBands {
}
fn is_ready(&self) -> bool {
self.window.len() == self.period
self.count == self.period
}
fn name(&self) -> &'static str {
@@ -171,6 +190,7 @@ mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
use std::collections::VecDeque;
fn naive(prices: &[f64], period: usize, mult: f64) -> BollingerOutput {
assert!(
@@ -0,0 +1,256 @@
//! Bomar Bands — adaptive percentage bands that contain a target fraction of
//! recent price.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::indicators::rolling_quantile::quantile_sorted;
use crate::traits::Indicator;
/// Bomar Bands output.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct BomarBandsOutput {
/// Upper band: `middle + |middle| · p`.
pub upper: f64,
/// Middle line: the simple moving average over the window.
pub middle: f64,
/// Lower band: `middle |middle| · p`.
pub lower: f64,
}
/// Bomar Bands: percentage bands whose width adapts so that a fixed `coverage`
/// fraction of recent closes falls inside them.
///
/// The Bomar Bands predate Bollinger Bands; John Bollinger cites them as an
/// inspiration — percentage bands around a moving average, with the percentage
/// tuned so a fixed share (classically ~85%) of price stayed within. Wickra
/// realises that idea deterministically: the half-width is the `coverage`
/// quantile of the relative deviations from the midline, so by construction
/// `coverage` of the window's closes lie inside the bands.
///
/// ```text
/// middle = SMA(close, period)
/// dev_i = | close_i / middle 1 | // relative distance from midline
/// p = coverage-quantile of { dev_i } // type-7 interpolation
/// upper = middle + |middle| · p
/// lower = middle |middle| · p
/// ```
///
/// Unlike the fixed-percentage [`MaEnvelope`](crate::MaEnvelope), the offset
/// here is data-driven: the bands widen in turbulent regimes and tighten in
/// quiet ones without a volatility input. Unlike Bollinger Bands, the width is
/// an order statistic of the actual deviations rather than a multiple of the
/// standard deviation, so it is unaffected by the shape of the tails beyond the
/// `coverage` rank. When the midline is zero the relative deviation is
/// undefined and the bands collapse onto the midline.
///
/// # Example
///
/// ```
/// use wickra_core::{BomarBands, Indicator};
///
/// let mut indicator = BomarBands::new(20, 0.85).unwrap();
/// let mut last = None;
/// for i in 0..40 {
/// last = indicator.update(100.0 + f64::from(i % 7));
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct BomarBands {
period: usize,
coverage: f64,
window: VecDeque<f64>,
scratch: Vec<f64>,
}
impl BomarBands {
/// Construct new Bomar Bands.
///
/// `coverage` is the target fraction of closes to contain, in `(0.0, 1.0]`.
///
/// # Errors
/// Returns [`Error::PeriodZero`] if `period == 0`, or
/// [`Error::InvalidParameter`] if `coverage` is not a finite value in
/// `(0.0, 1.0]`.
pub fn new(period: usize, coverage: f64) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
if !coverage.is_finite() || coverage <= 0.0 || coverage > 1.0 {
return Err(Error::InvalidParameter {
message: "bomar bands coverage must be a finite value in (0.0, 1.0]",
});
}
Ok(Self {
period,
coverage,
window: VecDeque::with_capacity(period),
scratch: Vec::with_capacity(period),
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
/// Configured coverage fraction.
pub const fn coverage(&self) -> f64 {
self.coverage
}
}
impl Indicator for BomarBands {
type Input = f64;
type Output = BomarBandsOutput;
fn update(&mut self, value: f64) -> Option<BomarBandsOutput> {
if self.window.len() == self.period {
self.window.pop_front();
}
self.window.push_back(value);
if self.window.len() < self.period {
return None;
}
let sum: f64 = self.window.iter().sum();
let middle = sum / (self.period as f64);
let denom = middle.abs();
self.scratch.clear();
for &v in &self.window {
let dev = if denom == 0.0 {
0.0
} else {
((v - middle) / denom).abs()
};
self.scratch.push(dev);
}
self.scratch.sort_by(f64::total_cmp);
let p = quantile_sorted(&self.scratch, self.coverage);
let offset = denom * p;
Some(BomarBandsOutput {
upper: middle + offset,
middle,
lower: middle - offset,
})
}
fn reset(&mut self) {
self.window.clear();
self.scratch.clear();
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.window.len() == self.period
}
fn name(&self) -> &'static str {
"BomarBands"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_zero_period() {
assert!(matches!(BomarBands::new(0, 0.85), Err(Error::PeriodZero)));
assert!(BomarBands::new(1, 0.85).is_ok());
}
#[test]
fn rejects_out_of_range_coverage() {
assert!(matches!(
BomarBands::new(20, 0.0),
Err(Error::InvalidParameter { .. })
));
assert!(matches!(
BomarBands::new(20, 1.1),
Err(Error::InvalidParameter { .. })
));
assert!(matches!(
BomarBands::new(20, -0.5),
Err(Error::InvalidParameter { .. })
));
assert!(matches!(
BomarBands::new(20, f64::NAN),
Err(Error::InvalidParameter { .. })
));
}
#[test]
fn accessors_and_metadata() {
let bb = BomarBands::new(20, 0.85).unwrap();
assert_eq!(bb.period(), 20);
assert_relative_eq!(bb.coverage(), 0.85, epsilon = 1e-12);
assert_eq!(bb.warmup_period(), 20);
assert_eq!(bb.name(), "BomarBands");
assert!(!bb.is_ready());
}
#[test]
fn warms_up_then_emits() {
let mut bb = BomarBands::new(4, 0.85).unwrap();
assert!(bb.update(100.0).is_none());
assert!(bb.update(102.0).is_none());
assert!(bb.update(98.0).is_none());
assert!(bb.update(104.0).is_some());
assert!(bb.is_ready());
}
#[test]
fn known_bands() {
// mean=101; |dev| = {1,1,3,3}/101; coverage 0.85 quantile -> 3/101.
// offset = 101 * 3/101 = 3 -> upper 104, lower 98.
let mut bb = BomarBands::new(4, 0.85).unwrap();
let out = bb.batch(&[100.0, 102.0, 98.0, 104.0]);
let last = out[3].unwrap();
assert_relative_eq!(last.middle, 101.0, epsilon = 1e-9);
assert_relative_eq!(last.upper, 104.0, epsilon = 1e-9);
assert_relative_eq!(last.lower, 98.0, epsilon = 1e-9);
}
#[test]
fn zero_midline_collapses_bands() {
// Window mean exactly zero -> relative deviation undefined -> collapse.
let mut bb = BomarBands::new(2, 0.85).unwrap();
let out = bb.batch(&[3.0, -3.0]);
let last = out[1].unwrap();
assert_relative_eq!(last.middle, 0.0, epsilon = 1e-12);
assert_relative_eq!(last.upper, 0.0, epsilon = 1e-12);
assert_relative_eq!(last.lower, 0.0, epsilon = 1e-12);
}
#[test]
fn rolling_window_evicts_oldest() {
// Eight values through a period-4 window: only the last four survive,
// reproducing the `known_bands` window.
let mut bb = BomarBands::new(4, 0.85).unwrap();
let out = bb.batch(&[50.0, 50.0, 50.0, 50.0, 100.0, 102.0, 98.0, 104.0]);
let last = out[7].unwrap();
assert_relative_eq!(last.middle, 101.0, epsilon = 1e-9);
assert_relative_eq!(last.upper, 104.0, epsilon = 1e-9);
assert_relative_eq!(last.lower, 98.0, epsilon = 1e-9);
}
#[test]
fn reset_clears_state() {
let mut bb = BomarBands::new(4, 0.85).unwrap();
for v in [100.0, 102.0, 98.0, 104.0] {
bb.update(v);
}
assert!(bb.is_ready());
bb.reset();
assert!(!bb.is_ready());
assert!(bb.update(100.0).is_none());
}
}
@@ -0,0 +1,165 @@
//! Breadth Thrust (Zweig) — a moving average of the advancing-issues share.
use crate::cross_section::CrossSection;
use crate::error::Result;
use crate::traits::Indicator;
use crate::Sma;
/// Breadth Thrust (Zweig) — a simple moving average of the advancing-issues
/// share, `advancers / (advancers + decliners)`.
///
/// Martin Zweig's breadth thrust smooths the fraction of participating issues
/// that are advancing over a short window (the classic period is 10). A "thrust"
/// fires when this average climbs from below ~0.40 (oversold, washed-out breadth)
/// to above ~0.615 within about ten sessions — historically a rare, reliable
/// signal that a powerful new advance has begun with broad participation.
///
/// Each tick's share floors the participating count to one, so a tick with no
/// advancing or declining issues contributes a defined `0.0` instead of dividing
/// by zero. The reading is `None` until `period` ticks have been seen.
///
/// `Input = CrossSection`, `Output = f64` (a share in `0..=1`),
/// `warmup_period == period`.
///
/// # Example
///
/// ```
/// use wickra_core::{BreadthThrust, CrossSection, Indicator, Member};
///
/// let mut bt = BreadthThrust::new(2).unwrap();
/// let up = CrossSection::new(vec![Member::new(1.0, 1.0, false, false)], 0).unwrap();
/// assert_eq!(bt.update(up.clone()), None); // warming up
/// assert_eq!(bt.update(up), Some(1.0)); // both ticks 100% advancing
/// ```
#[derive(Debug, Clone)]
pub struct BreadthThrust {
sma: Sma,
}
impl BreadthThrust {
/// Construct a new Breadth Thrust over the given window length.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`](crate::Error::PeriodZero) if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
Ok(Self {
sma: Sma::new(period)?,
})
}
/// Configured window length.
#[must_use]
pub const fn period(&self) -> usize {
self.sma.period()
}
}
impl Indicator for BreadthThrust {
type Input = CrossSection;
type Output = f64;
fn update(&mut self, section: CrossSection) -> Option<f64> {
let advancers = section.advancers();
let decliners = section.decliners();
let participating = (advancers + decliners).max(1) as f64;
let share = advancers as f64 / participating;
self.sma.update(share)
}
fn reset(&mut self) {
self.sma.reset();
}
fn warmup_period(&self) -> usize {
self.sma.period()
}
fn is_ready(&self) -> bool {
self.sma.value().is_some()
}
fn name(&self) -> &'static str {
"BreadthThrust"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cross_section::Member;
use crate::error::Error;
use crate::traits::BatchExt;
fn section(up: usize, down: usize) -> CrossSection {
let mut members = Vec::new();
for _ in 0..up {
members.push(Member::new(1.0, 10.0, false, false));
}
for _ in 0..down {
members.push(Member::new(-1.0, 10.0, false, false));
}
members.push(Member::new(0.0, 10.0, false, false));
CrossSection::new(members, 0).unwrap()
}
#[test]
fn accessors_and_metadata() {
let bt = BreadthThrust::new(10).unwrap();
assert_eq!(bt.name(), "BreadthThrust");
assert_eq!(bt.warmup_period(), 10);
assert_eq!(bt.period(), 10);
assert!(!bt.is_ready());
}
#[test]
fn rejects_zero_period() {
assert!(matches!(BreadthThrust::new(0), Err(Error::PeriodZero)));
}
#[test]
fn averages_the_advancing_share() {
let mut bt = BreadthThrust::new(2).unwrap();
// share = 8 / 10 = 0.8 ; window not full yet.
assert_eq!(bt.update(section(8, 2)), None);
// share = 6 / 10 = 0.6 ; SMA(2) = (0.8 + 0.6) / 2 = 0.7.
let value = bt.update(section(6, 4)).unwrap();
assert!((value - 0.7).abs() < 1e-9);
assert!(bt.is_ready());
// share = 5 / 10 = 0.5 ; SMA(2) = (0.6 + 0.5) / 2 = 0.55.
let value = bt.update(section(5, 5)).unwrap();
assert!((value - 0.55).abs() < 1e-9);
}
#[test]
fn empty_participation_floors_to_zero_share() {
let mut bt = BreadthThrust::new(1).unwrap();
// No advancers or decliners -> 0 / max(0, 1) = 0.0.
assert_eq!(bt.update(section(0, 0)), Some(0.0));
}
#[test]
fn reset_clears_state() {
let mut bt = BreadthThrust::new(2).unwrap();
bt.update(section(8, 2));
bt.update(section(6, 4));
assert!(bt.is_ready());
bt.reset();
assert!(!bt.is_ready());
assert_eq!(bt.update(section(8, 2)), None);
}
#[test]
fn batch_equals_streaming() {
let sections = vec![section(8, 2), section(6, 4), section(5, 5), section(0, 0)];
let mut a = BreadthThrust::new(2).unwrap();
let mut b = BreadthThrust::new(2).unwrap();
assert_eq!(
a.batch(&sections),
sections
.iter()
.map(|s| b.update(s.clone()))
.collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,250 @@
//! Breakaway candlestick pattern.
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Breakaway — a 5-bar reversal that fades an exhausted run. A trend gaps away on
/// the second bar, drifts two more bars in the same direction, then the fifth bar
/// snaps the other way and closes back inside the body gap left between the first
/// and second bars, signalling the move has broken away from the crowd and is
/// turning.
///
/// ```text
/// bullish (+1.0) — appears in a decline:
/// bar1 black (close < open)
/// bar2 black & its body gaps DOWN below bar1's body (bar2.open < bar1.close)
/// bar3 extends lower (high & low below bar2)
/// bar4 black & extends lower (high & low below bar3)
/// bar5 green & closes inside the bar1/bar2 body gap (bar2.open < close < bar1.close)
///
/// bearish (1.0) — the mirror in an advance:
/// bar1 white (close > open)
/// bar2 white & its body gaps UP above bar1's body (bar2.open > bar1.close)
/// bar3 extends higher (high & low above bar2)
/// bar4 white & extends higher (high & low above bar3)
/// bar5 red & closes inside the bar1/bar2 body gap (bar1.close < close < bar2.open)
/// ```
///
/// The middle bar (`bar3`) may be either colour — only its high/low must extend
/// the run. Output is `+1.0` bullish, `1.0` bearish, `0.0` otherwise. The first
/// four bars always return `0.0` because the five-bar window is not yet filled.
/// Pattern-shape check only — no trend filter is applied; combine with a trend
/// indicator for actionable signals. Recognition uses TA-Lib's
/// `CDLBREAKAWAY` body-gap and high/low ordering rules directly; it does not add
/// TA-Lib's rolling body-length average, matching the geometric house style of
/// the other multi-bar patterns in this family.
///
/// # Signed ±1 encoding
///
/// This detector emits the uniform candlestick sign convention shared across the
/// pattern family — `+1.0` bullish, `1.0` bearish, `0.0` no pattern — so it
/// drops straight into a machine-learning feature matrix where the bullish and
/// bearish variants occupy a single dimension.
///
/// # Example
///
/// ```
/// use wickra_core::{Breakaway, Candle, Indicator};
///
/// let mut indicator = Breakaway::new();
/// indicator.update(Candle::new(20.0, 20.2, 14.8, 15.0, 1.0, 0).unwrap());
/// indicator.update(Candle::new(14.0, 14.1, 11.9, 12.0, 1.0, 1).unwrap());
/// indicator.update(Candle::new(12.5, 13.0, 10.5, 11.0, 1.0, 2).unwrap());
/// indicator.update(Candle::new(11.0, 11.5, 9.0, 9.5, 1.0, 3).unwrap());
/// let out = indicator
/// .update(Candle::new(9.5, 14.7, 9.4, 14.5, 1.0, 4).unwrap());
/// assert_eq!(out, Some(1.0));
/// ```
#[derive(Debug, Clone, Default)]
pub struct Breakaway {
c1: Option<Candle>,
c2: Option<Candle>,
c3: Option<Candle>,
c4: Option<Candle>,
has_emitted: bool,
}
impl Breakaway {
/// Construct a new Breakaway detector.
pub const fn new() -> Self {
Self {
c1: None,
c2: None,
c3: None,
c4: None,
has_emitted: false,
}
}
}
impl Indicator for Breakaway {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
self.has_emitted = true;
let bar1 = self.c1;
let bar2 = self.c2;
let bar3 = self.c3;
let bar4 = self.c4;
self.c1 = self.c2;
self.c2 = self.c3;
self.c3 = self.c4;
self.c4 = Some(candle);
let (Some(bar1), Some(bar2), Some(bar3), Some(bar4)) = (bar1, bar2, bar3, bar4) else {
return Some(0.0);
};
// Bullish: a decline gaps lower, runs two more bars down, then a green
// bar5 closes back inside the bar1/bar2 body gap.
if bar1.close < bar1.open
&& bar2.close < bar2.open
&& bar2.open < bar1.close
&& bar3.high < bar2.high
&& bar3.low < bar2.low
&& bar4.close < bar4.open
&& bar4.high < bar3.high
&& bar4.low < bar3.low
&& candle.close > candle.open
&& candle.close > bar2.open
&& candle.close < bar1.close
{
return Some(1.0);
}
// Bearish: the mirror — an advance gaps higher, runs two more bars up,
// then a red bar5 closes back inside the bar1/bar2 body gap.
if bar1.close > bar1.open
&& bar2.close > bar2.open
&& bar2.open > bar1.close
&& bar3.high > bar2.high
&& bar3.low > bar2.low
&& bar4.close > bar4.open
&& bar4.high > bar3.high
&& bar4.low > bar3.low
&& candle.close < candle.open
&& candle.close < bar2.open
&& candle.close > bar1.close
{
return Some(-1.0);
}
Some(0.0)
}
fn reset(&mut self) {
self.c1 = None;
self.c2 = None;
self.c3 = None;
self.c4 = None;
self.has_emitted = false;
}
fn warmup_period(&self) -> usize {
5
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"Breakaway"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle {
Candle::new(open, high, low, close, 1.0, ts).unwrap()
}
#[test]
fn accessors_and_metadata() {
let t = Breakaway::new();
assert_eq!(t.name(), "Breakaway");
assert_eq!(t.warmup_period(), 5);
assert!(!t.is_ready());
}
#[test]
fn bullish_breakaway_is_plus_one() {
let mut t = Breakaway::new();
assert_eq!(t.update(c(20.0, 20.2, 14.8, 15.0, 0)), Some(0.0));
assert_eq!(t.update(c(14.0, 14.1, 11.9, 12.0, 1)), Some(0.0));
assert_eq!(t.update(c(12.5, 13.0, 10.5, 11.0, 2)), Some(0.0));
assert_eq!(t.update(c(11.0, 11.5, 9.0, 9.5, 3)), Some(0.0));
assert_eq!(t.update(c(9.5, 14.7, 9.4, 14.5, 4)), Some(1.0));
}
#[test]
fn bearish_breakaway_is_minus_one() {
let mut t = Breakaway::new();
assert_eq!(t.update(c(15.0, 20.2, 14.8, 20.0, 0)), Some(0.0));
assert_eq!(t.update(c(21.0, 23.1, 20.9, 23.0, 1)), Some(0.0));
assert_eq!(t.update(c(22.5, 24.5, 21.5, 24.0, 2)), Some(0.0));
assert_eq!(t.update(c(24.0, 26.5, 23.0, 26.0, 3)), Some(0.0));
assert_eq!(t.update(c(27.0, 27.2, 20.4, 20.5, 4)), Some(-1.0));
}
#[test]
fn no_body_gap_yields_zero() {
let mut t = Breakaway::new();
// bar2 does not gap below bar1's body (bar2.open >= bar1.close).
t.update(c(20.0, 20.2, 14.8, 15.0, 0));
t.update(c(16.0, 16.1, 13.9, 14.0, 1));
t.update(c(13.5, 14.0, 11.5, 12.0, 2));
t.update(c(12.0, 12.5, 10.0, 10.5, 3));
assert_eq!(t.update(c(10.5, 15.7, 10.4, 15.5, 4)), Some(0.0));
}
#[test]
fn bullish_close_outside_gap_yields_zero() {
let mut t = Breakaway::new();
t.update(c(20.0, 20.2, 14.8, 15.0, 0));
t.update(c(14.0, 14.1, 11.9, 12.0, 1));
t.update(c(12.5, 13.0, 10.5, 11.0, 2));
t.update(c(11.0, 11.5, 9.0, 9.5, 3));
// bar5 closes at 13.0 — below bar2.open (14), so outside the body gap.
assert_eq!(t.update(c(9.5, 13.2, 9.4, 13.0, 4)), Some(0.0));
}
#[test]
fn first_four_bars_return_zero() {
let mut t = Breakaway::new();
assert_eq!(t.update(c(20.0, 20.2, 14.8, 15.0, 0)), Some(0.0));
assert_eq!(t.update(c(14.0, 14.1, 11.9, 12.0, 1)), Some(0.0));
assert_eq!(t.update(c(12.5, 13.0, 10.5, 11.0, 2)), Some(0.0));
assert_eq!(t.update(c(11.0, 11.5, 9.0, 9.5, 3)), Some(0.0));
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..40)
.map(|i| {
let base = 100.0 + i as f64;
c(base, base + 2.0, base - 0.5, base + 1.5, i)
})
.collect();
let mut a = Breakaway::new();
let mut b = Breakaway::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut t = Breakaway::new();
t.update(c(20.0, 20.2, 14.8, 15.0, 0));
t.update(c(14.0, 14.1, 11.9, 12.0, 1));
t.update(c(12.5, 13.0, 10.5, 11.0, 2));
t.update(c(11.0, 11.5, 9.0, 9.5, 3));
t.update(c(9.5, 14.7, 9.4, 14.5, 4));
assert!(t.is_ready());
t.reset();
assert!(!t.is_ready());
assert_eq!(t.update(c(20.0, 20.2, 14.8, 15.0, 0)), Some(0.0));
}
}
@@ -0,0 +1,147 @@
//! Bullish Percent Index — share of a universe on a point-and-figure buy signal.
use crate::cross_section::CrossSection;
use crate::traits::Indicator;
/// Bullish Percent Index (BPI) — the percentage of symbols in a universe that are
/// currently on a point-and-figure buy signal.
///
/// On each [`CrossSection`] tick the value is `100 * on_buy_signal_count /
/// universe size`, read from the per-symbol `on_buy_signal` flag (the caller
/// evaluates each symbol's point-and-figure chart when it builds the tick). It is
/// a bounded `0..=100` gauge of how many issues are in a confirmed uptrend.
/// Readings above 70 are considered overbought (broad strength, but a crowded
/// market) and below 30 oversold; reversals from those zones are classic BPI
/// buy/sell triggers.
///
/// `Input = CrossSection`, `Output = f64` (a percentage in `0..=100`),
/// `warmup_period == 1`. The universe is non-empty by construction, so the share
/// is always defined.
///
/// # Example
///
/// ```
/// use wickra_core::{BullishPercentIndex, CrossSection, Indicator, Member};
///
/// let mut bpi = BullishPercentIndex::new();
/// // 2 of 4 symbols on a buy signal -> 50%.
/// let tick = CrossSection::new(
/// vec![
/// Member::with_signals(1.0, 10.0, false, false, false, true),
/// Member::with_signals(1.0, 10.0, false, false, false, true),
/// Member::with_signals(-1.0, 10.0, false, false, false, false),
/// Member::with_signals(-1.0, 10.0, false, false, false, false),
/// ],
/// 0,
/// )
/// .unwrap();
/// assert_eq!(bpi.update(tick), Some(50.0));
/// ```
#[derive(Debug, Clone, Default)]
pub struct BullishPercentIndex {
has_emitted: bool,
}
impl BullishPercentIndex {
/// Construct a new Bullish Percent Index indicator.
#[must_use]
pub const fn new() -> Self {
Self { has_emitted: false }
}
}
impl Indicator for BullishPercentIndex {
type Input = CrossSection;
type Output = f64;
fn update(&mut self, section: CrossSection) -> Option<f64> {
let bullish = section.on_buy_signal_count() as f64;
let total = section.members.len() as f64;
self.has_emitted = true;
Some(100.0 * bullish / total)
}
fn reset(&mut self) {
self.has_emitted = false;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"BullishPercentIndex"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cross_section::Member;
use crate::traits::BatchExt;
fn tick(bullish: usize, bearish: usize) -> CrossSection {
let mut members = Vec::new();
for _ in 0..bullish {
members.push(Member::with_signals(1.0, 10.0, false, false, false, true));
}
for _ in 0..bearish {
members.push(Member::with_signals(-1.0, 10.0, false, false, false, false));
}
CrossSection::new(members, 0).unwrap()
}
#[test]
fn accessors_and_metadata() {
let bpi = BullishPercentIndex::new();
assert_eq!(bpi.name(), "BullishPercentIndex");
assert_eq!(bpi.warmup_period(), 1);
assert!(!bpi.is_ready());
}
#[test]
fn first_tick_emits_percentage() {
let mut bpi = BullishPercentIndex::new();
assert_eq!(bpi.update(tick(2, 2)), Some(50.0));
assert!(bpi.is_ready());
}
#[test]
fn all_bullish_is_one_hundred() {
let mut bpi = BullishPercentIndex::new();
assert_eq!(bpi.update(tick(5, 0)), Some(100.0));
}
#[test]
fn none_bullish_is_zero() {
let mut bpi = BullishPercentIndex::new();
assert_eq!(bpi.update(tick(0, 4)), Some(0.0));
}
#[test]
fn reset_clears_state() {
let mut bpi = BullishPercentIndex::new();
bpi.update(tick(2, 2));
assert!(bpi.is_ready());
bpi.reset();
assert!(!bpi.is_ready());
}
#[test]
fn batch_equals_streaming() {
let sections = vec![tick(2, 2), tick(5, 0), tick(0, 4)];
let mut a = BullishPercentIndex::new();
let mut b = BullishPercentIndex::new();
assert_eq!(
a.batch(&sections),
sections
.iter()
.map(|s| b.update(s.clone()))
.collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,154 @@
//! Butterfly harmonic pattern.
use crate::indicators::pattern_swing::{ratios_in, xabcd, SwingTracker, SWING_THRESHOLD};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Butterfly — a 5-point (X-A-B-C-D) harmonic pattern with a `0.786` B and an
/// **extended** D that overshoots X:
///
/// ```text
/// AB / XA ∈ [0.74, 0.84] (≈ 0.786)
/// BC / AB ∈ [0.382, 0.886]
/// CD / BC ∈ [1.618, 2.618]
/// AD / XA ∈ [1.27, 1.618] (the defining extended D completion)
/// ```
///
/// Output is `+1.0` (bullish, D a swing low), `-1.0` (bearish, D a swing high),
/// or `0.0`; never `None`. See `crates/wickra-core/src/indicators/butterfly.rs`.
#[derive(Debug, Clone)]
pub struct Butterfly {
swing: SwingTracker,
has_emitted: bool,
}
impl Butterfly {
/// Construct a new Butterfly detector.
pub const fn new() -> Self {
Self {
swing: SwingTracker::new(SWING_THRESHOLD, 5),
has_emitted: false,
}
}
}
impl Default for Butterfly {
fn default() -> Self {
Self::new()
}
}
impl Indicator for Butterfly {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
self.has_emitted = true;
if !self.swing.update(candle) {
return Some(0.0);
}
let pivots = self.swing.pivots();
if pivots.len() < 5 {
return Some(0.0);
}
let p = xabcd(pivots);
let xa = (p.a - p.x).abs();
let ab = (p.b - p.a).abs();
let bc = (p.c - p.b).abs();
let cd = (p.d - p.c).abs();
let ad = (p.d - p.a).abs();
let matched = ratios_in(&[
(ab / xa, 0.74, 0.84),
(bc / ab, 0.382, 0.886),
(cd / bc, 1.618, 2.618),
(ad / xa, 1.27, 1.618),
]);
if matched {
return Some(if p.bullish { 1.0 } else { -1.0 });
}
Some(0.0)
}
fn reset(&mut self) {
self.swing.reset();
self.has_emitted = false;
}
fn warmup_period(&self) -> usize {
6
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"Butterfly"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::indicators::pattern_swing::candles_for_pivots;
use crate::traits::BatchExt;
fn run(pivots: &[f64]) -> Vec<f64> {
let mut indicator = Butterfly::new();
candles_for_pivots(pivots)
.into_iter()
.map(|c| indicator.update(c).unwrap())
.collect()
}
#[test]
fn accessors_and_metadata() {
let indicator = Butterfly::new();
assert_eq!(indicator.name(), "Butterfly");
assert_eq!(indicator.warmup_period(), 6);
assert!(!indicator.is_ready());
assert!(!Butterfly::default().is_ready());
}
#[test]
fn bullish_butterfly_is_plus_one() {
let out = run(&[150.0, 100.0, 140.0, 108.6, 128.0, 79.8]);
assert_eq!(*out.last().unwrap(), 1.0);
assert!(out[..out.len() - 1].iter().all(|&x| x == 0.0));
}
#[test]
fn bearish_butterfly_is_minus_one() {
let out = run(&[150.0, 110.0, 141.4, 121.4, 170.2]);
assert_eq!(*out.last().unwrap(), -1.0);
}
#[test]
fn out_of_ratio_does_not_trigger() {
let out = run(&[150.0, 100.0, 140.0, 110.0, 135.0, 105.0]);
assert_eq!(*out.last().unwrap(), 0.0);
}
#[test]
fn reset_clears_state() {
let mut indicator = Butterfly::new();
for c in candles_for_pivots(&[150.0, 100.0, 140.0]) {
let _ = indicator.update(c);
}
indicator.reset();
assert!(!indicator.is_ready());
let c = Candle::new(99.5, 100.0, 99.5, 99.5, 1.0, 0).unwrap();
assert_eq!(indicator.update(c), Some(0.0));
}
#[test]
fn batch_equals_streaming() {
let candles = candles_for_pivots(&[150.0, 100.0, 140.0, 108.6, 128.0, 79.8]);
let mut a = Butterfly::new();
let mut b = Butterfly::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,141 @@
//! Calendar Spread — the dated future's relative premium to the perpetual.
use crate::derivatives::DerivativesTick;
use crate::traits::Indicator;
/// Calendar Spread — the relative spread between a dated (e.g. quarterly)
/// futures price and the perpetual mark price.
///
/// ```text
/// spread = (futuresPrice markPrice) / markPrice
/// ```
///
/// A calendar (or inter-delivery) spread trades the *near* leg against the
/// *far* leg — here the perpetual against a dated future. The relative spread is
/// the roll yield available between the two contracts: positive when the future
/// trades over the perpetual (contango roll), negative when under
/// (backwardation). Where [`TermStructureBasis`] measures the future against
/// spot, this measures it against the perpetual — the leg a perp-vs-future
/// basis trade actually holds. The output is a fraction; multiply by `10_000`
/// for basis points.
///
/// `Input = DerivativesTick`, `Output = f64`. Stateless; ready after the first
/// tick.
///
/// [`TermStructureBasis`]: crate::TermStructureBasis
///
/// # Example
///
/// ```
/// use wickra_core::{CalendarSpread, DerivativesTick, Indicator};
///
/// fn tick(futures: f64, mark: f64) -> DerivativesTick {
/// DerivativesTick::new(0.0, mark, mark, futures, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0)
/// .unwrap()
/// }
///
/// let mut cs = CalendarSpread::new();
/// // futures 101 vs perpetual mark 100 -> 0.01.
/// assert!((cs.update(tick(101.0, 100.0)).unwrap() - 0.01).abs() < 1e-12);
/// ```
#[derive(Debug, Clone, Default)]
pub struct CalendarSpread {
has_emitted: bool,
}
impl CalendarSpread {
/// Construct a new calendar-spread indicator.
#[must_use]
pub const fn new() -> Self {
Self { has_emitted: false }
}
}
impl Indicator for CalendarSpread {
type Input = DerivativesTick;
type Output = f64;
fn update(&mut self, tick: DerivativesTick) -> Option<f64> {
self.has_emitted = true;
Some((tick.futures_price - tick.mark_price) / tick.mark_price)
}
fn reset(&mut self) {
self.has_emitted = false;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"CalendarSpread"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
fn tick(futures: f64, mark: f64) -> DerivativesTick {
DerivativesTick::new_unchecked(
0.0, mark, mark, futures, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0,
)
}
#[test]
fn accessors_and_metadata() {
let cs = CalendarSpread::new();
assert_eq!(cs.name(), "CalendarSpread");
assert_eq!(cs.warmup_period(), 1);
assert!(!cs.is_ready());
}
#[test]
fn future_over_perp_is_positive() {
let mut cs = CalendarSpread::new();
let out = cs.update(tick(101.0, 100.0)).unwrap();
assert!((out - 0.01).abs() < 1e-12);
assert!(cs.is_ready());
}
#[test]
fn future_under_perp_is_negative() {
let mut cs = CalendarSpread::new();
let out = cs.update(tick(99.0, 100.0)).unwrap();
assert!((out + 0.01).abs() < 1e-12);
}
#[test]
fn flat_is_zero() {
let mut cs = CalendarSpread::new();
assert_eq!(cs.update(tick(100.0, 100.0)), Some(0.0));
}
#[test]
fn batch_equals_streaming() {
let ticks: Vec<DerivativesTick> = (0..20)
.map(|i| tick(100.0 + f64::from(i % 5), 100.0))
.collect();
let mut a = CalendarSpread::new();
let mut b = CalendarSpread::new();
assert_eq!(
a.batch(&ticks),
ticks.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut cs = CalendarSpread::new();
cs.update(tick(101.0, 100.0));
assert!(cs.is_ready());
cs.reset();
assert!(!cs.is_ready());
}
}

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