Compare commits

..

38 Commits

Author SHA1 Message Date
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
189 changed files with 40353 additions and 493 deletions
+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
+10
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,6 +28,8 @@ updates:
schedule:
interval: weekly
open-pull-requests-limit: 10
cooldown:
default-days: 7
commit-message:
prefix: "deps(pip)"
@@ -37,6 +43,8 @@ updates:
schedule:
interval: weekly
open-pull-requests-limit: 10
cooldown:
default-days: 7
commit-message:
prefix: "deps(ci-pip)"
@@ -47,5 +55,7 @@ updates:
schedule:
interval: weekly
open-pull-requests-limit: 10
cooldown:
default-days: 7
commit-message:
prefix: "deps(actions)"
+2
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
+20
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
@@ -461,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
@@ -507,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
+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
+24 -3
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
@@ -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
@@ -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)
+1 -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
+40 -102
View File
@@ -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,66 +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
# Bump the banner cache-buster so GitHub's Camo proxy refetches the org
# profile image (regenerated with the new count by .github/banner.yml)
# instead of serving a stale cached copy.
sed -i -E "s|(wickra-banner\.webp\?v=)[0-9]+|\1${n}|" README.md
if git diff --quiet; then
echo "No README changes after sed (counter regex did not match anything); skipping push."
echo "changed=false" >> "$GITHUB_OUTPUT"
else
echo "changed=true" >> "$GITHUB_OUTPUT"
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'
+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
+218 -1
View File
@@ -7,6 +7,217 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [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
@@ -957,7 +1168,13 @@ 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.2...HEAD
[Unreleased]: https://github.com/wickra-lib/wickra/compare/v0.5.0...HEAD
[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
+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
+6 -6
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
Generated
+6 -6
View File
@@ -1867,7 +1867,7 @@ dependencies = [
[[package]]
name = "wickra"
version = "0.4.2"
version = "0.5.0"
dependencies = [
"approx",
"criterion",
@@ -1878,7 +1878,7 @@ dependencies = [
[[package]]
name = "wickra-core"
version = "0.4.2"
version = "0.5.0"
dependencies = [
"approx",
"proptest",
@@ -1888,7 +1888,7 @@ dependencies = [
[[package]]
name = "wickra-data"
version = "0.4.2"
version = "0.5.0"
dependencies = [
"approx",
"csv",
@@ -1915,7 +1915,7 @@ dependencies = [
[[package]]
name = "wickra-node"
version = "0.4.2"
version = "0.5.0"
dependencies = [
"napi",
"napi-build",
@@ -1925,7 +1925,7 @@ dependencies = [
[[package]]
name = "wickra-python"
version = "0.4.2"
version = "0.5.0"
dependencies = [
"numpy",
"pyo3",
@@ -1934,7 +1934,7 @@ dependencies = [
[[package]]
name = "wickra-wasm"
version = "0.4.2"
version = "0.5.0"
dependencies = [
"console_error_panic_hook",
"js-sys",
+3 -3
View File
@@ -12,11 +12,11 @@ members = [
exclude = ["fuzz"]
[workspace.package]
version = "0.4.2"
version = "0.5.0"
authors = ["kingchenc <support@wickra.org>"]
edition = "2021"
rust-version = "1.86"
license-file = "LICENSE"
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 +24,7 @@ keywords = ["finance", "trading", "indicators", "technical-analysis", "ta"]
categories = ["finance", "mathematics", "science"]
[workspace.dependencies]
wickra-core = { path = "crates/wickra-core", version = "0.4.2" }
wickra-core = { path = "crates/wickra-core", version = "0.5.0" }
thiserror = "2"
rayon = "1.10"
-161
View File
@@ -1,161 +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.
## Additional Permissions Granted by the Licensor
These additional permissions supplement the PolyForm Noncommercial
License 1.0.0 above. They only broaden, and never narrow, the
licenses granted to you. The text of the PolyForm Noncommercial
License 1.0.0 above is unmodified.
Use by a natural person, acting for their own personal account and
not on behalf of any third party, is use for a permitted purpose.
This includes operating an automated trading bot or trading strategy
on that person's own capital, whether or not it earns that person
money.
For the avoidance of doubt, the licenses above already let you use,
fork, modify, and redistribute the software, and file issues and
contribute changes, for any permitted purpose. Personal projects,
research, education, nonprofit organizations, government use, and
hobby trading bots are permitted purposes.
Any other commercial use — in particular the commercial sale of the
software itself, or the commercial sale of services built around it —
requires a separate commercial license from the licensor. If you want
to use Wickra commercially, get in touch about a license at
<https://github.com/wickra-lib/wickra>.
---
Required Notice: Copyright 2026 kingchenc (https://github.com/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.
+29 -19
View File
@@ -1,5 +1,5 @@
<p align="center">
<a href="https://wickra.org"><img src="https://raw.githubusercontent.com/wickra-lib/.github/main/profile/wickra-banner.webp?v=227" 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=339" 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,7 +9,7 @@
[![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)
[![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 +47,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 227 indicators; start at the
every one of the 339 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),
@@ -135,7 +135,7 @@ python -m benchmarks.compare_libraries
## Indicators
227 streaming-first indicators across seventeen families. Every one passes the
339 streaming-first indicators across twenty 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).
@@ -143,21 +143,24 @@ 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 |
| 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) |
| 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 |
| 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 |
| 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 |
| 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 |
| Microstructure | Order-Book Imbalance (Top-1 / Top-N / Full), Microprice, Quoted Spread, Signed Volume, Cumulative Volume Delta, Trade Imbalance |
| 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 |
| 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 |
| 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) |
Every candlestick pattern emits a signed per-bar value — `+1.0` bullish,
@@ -237,7 +240,7 @@ A Python live-trading example using the public `websockets` package lives at
```
wickra/
├── crates/
│ ├── wickra-core/ core engine + all 227 indicators
│ ├── wickra-core/ core engine + all 339 indicators
│ ├── wickra/ top-level facade crate (publishes on crates.io) + benches/
│ └── wickra-data/ CSV reader, tick aggregator, live exchange feeds
├── bindings/
@@ -321,13 +324,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
+3 -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
+1 -1
View File
@@ -9,7 +9,7 @@ edition.workspace = true
# also emits `cargo::` directives that require >= 1.77 — that older floor is
# subsumed by the 1.88 requirement now.
rust-version = "1.88"
license-file.workspace = true
license.workspace = true
repository.workspace = true
homepage.workspace = true
readme.workspace = true
+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)
);
});
}
+532
View File
@@ -28,10 +28,17 @@ function num(v) {
// --- Scalar indicators: update(value) vs batch(prices) ---
const scalarFactories = {
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 +96,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 +167,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 +185,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 +252,51 @@ 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) },
};
for (const [name, d] of Object.entries(candleScalar)) {
@@ -256,6 +318,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) },
@@ -464,6 +529,14 @@ 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),
};
for (const [name, make] of Object.entries(pairFactories)) {
@@ -554,6 +627,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;
@@ -926,6 +1040,10 @@ test('order-book indicators reference values', () => {
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', () => {
@@ -981,3 +1099,417 @@ test('trade-flow rejects bad input', () => {
assert.throws(() => new wickra.TradeImbalance(0));
assert.throws(() => new wickra.SignedVolume().update(100, -1, true));
});
test('price-impact indicators reference values', () => {
// Buy at 100.05 vs mid 100.0: 2 * (100.05 - 100) / 100 * 10000 = 10 bps.
assert.ok(Math.abs(new wickra.EffectiveSpread().update(100.05, 1, true, 100.0) - 10.0) < 1e-9);
// Sell at 99.95 vs mid 100.0: 2 * -1 * (99.95 - 100) / 100 * 10000 = 10 bps.
assert.ok(Math.abs(new wickra.EffectiveSpread().update(99.95, 1, false, 100.0) - 10.0) < 1e-9);
// A buy filled below the mid is price improvement -> negative.
assert.ok(new wickra.EffectiveSpread().update(99.95, 1, true, 100.0) < 0.0);
});
test('price-impact streaming update matches batch', () => {
const n = 30;
const mid = Array.from({ length: n }, (_, i) => 100 + 0.25 * Math.sin(i * 0.5));
const isBuy = Array.from({ length: n }, (_, i) => i % 3 !== 0);
const price = Array.from({ length: n }, (_, i) => mid[i] + (isBuy[i] ? 0.03 : -0.03));
const size = Array.from({ length: n }, (_, i) => 1 + (i % 4));
const batch = new wickra.EffectiveSpread().batch(price, size, isBuy, mid);
const streamer = new wickra.EffectiveSpread();
assert.equal(batch.length, n);
for (let i = 0; i < n; i++) {
const s = streamer.update(price[i], size[i], isBuy[i], mid[i]);
assert.ok(Math.abs(s - batch[i]) < 1e-9, `mismatch at ${i}: ${s} vs ${batch[i]}`);
}
});
test('realized spread resolves against the future mid', () => {
const rs = new wickra.RealizedSpread(1);
assert.equal(rs.update(100.10, 1, true, 100.0), null); // buffered
// 2 * (+1) * (100.10 - 100.20) / 100.0 * 10000 = -20 bps.
assert.ok(Math.abs(rs.update(99.90, 1, false, 100.20) - -20.0) < 1e-9);
});
test('realized spread streaming update matches batch', () => {
const n = 30;
const mid = Array.from({ length: n }, (_, i) => 100 + 0.25 * Math.sin(i * 0.5));
const isBuy = Array.from({ length: n }, (_, i) => i % 3 !== 0);
const price = Array.from({ length: n }, (_, i) => mid[i] + (isBuy[i] ? 0.03 : -0.03));
const size = Array.from({ length: n }, (_, i) => 1 + (i % 4));
const batch = new wickra.RealizedSpread(4).batch(price, size, isBuy, mid);
const streamer = new wickra.RealizedSpread(4);
assert.equal(batch.length, n);
for (let i = 0; i < n; i++) {
const s = streamer.update(price[i], size[i], isBuy[i], mid[i]);
const got = s === null ? NaN : s;
assert.ok(
(Number.isNaN(got) && Number.isNaN(batch[i])) || Math.abs(got - batch[i]) < 1e-9,
`mismatch at ${i}: ${got} vs ${batch[i]}`,
);
}
});
test("kyle's lambda recovers a constant price-impact slope", () => {
// Each trade moves the mid by exactly 0.5 per unit of signed volume.
const impact = 0.5;
let mid = 100;
const price = [];
const size = [];
const isBuy = [];
const mids = [];
for (let i = 0; i < 20; i++) {
const buy = i % 2 === 0;
const sz = 1 + (i % 3);
const signed = buy ? sz : -sz;
mid += impact * signed;
price.push(mid);
size.push(sz);
isBuy.push(buy);
mids.push(mid);
}
const out = new wickra.KylesLambda(6).batch(price, size, isBuy, mids);
assert.ok(Math.abs(out[out.length - 1] - 0.5) < 1e-9);
});
test('price-impact rejects bad input', () => {
assert.throws(() => new wickra.EffectiveSpread().update(100, 1, true, 0));
assert.throws(() => new wickra.RealizedSpread(0));
assert.throws(() => new wickra.KylesLambda(1));
});
test('footprint buckets buy and sell volume per price level', () => {
const fp = new wickra.Footprint(1.0);
fp.update(100.2, 2, true); // bucket 100 -> ask 2
fp.update(100.7, 3, false); // bucket 101 -> bid 3
const out = fp.update(100.1, 1, true); // bucket 100 -> ask 3
assert.equal(out.length, 2);
assert.deepEqual(
{ price: out[0].price, bidVol: out[0].bidVol, askVol: out[0].askVol },
{ price: 100.0, bidVol: 0.0, askVol: 3.0 },
);
assert.deepEqual(
{ price: out[1].price, bidVol: out[1].bidVol, askVol: out[1].askVol },
{ price: 101.0, bidVol: 3.0, askVol: 0.0 },
);
});
test('footprint streaming update matches batch and rejects bad tick', () => {
const n = 12;
const price = Array.from({ length: n }, (_, i) => 100 + (i % 5) * 0.3);
const size = Array.from({ length: n }, (_, i) => 1 + (i % 3));
const isBuy = Array.from({ length: n }, (_, i) => i % 2 === 0);
const batch = new wickra.Footprint(1.0).batch(price, size, isBuy);
const streamer = new wickra.Footprint(1.0);
assert.equal(batch.length, n);
for (let i = 0; i < n; i++) {
const s = streamer.update(price[i], size[i], isBuy[i]);
assert.deepEqual(s, batch[i], `mismatch at ${i}`);
}
assert.throws(() => new wickra.Footprint(0));
});
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);
});
+1136
View File
File diff suppressed because it is too large Load Diff
+113 -1
View File
@@ -310,7 +310,7 @@ if (!nativeBinding) {
throw new Error(`Failed to load native binding`)
}
const { version, SMA, EMA, WMA, RSI, DEMA, TEMA, HMA, ROC, TRIX, SMMA, TRIMA, ZLEMA, MOM, CMO, DPO, StdDev, UlcerIndex, VerticalHorizontalFilter, ZScore, McGinleyDynamic, FRAMA, SuperSmoother, FisherTransform, Decycler, CenterOfGravity, CyberneticCycle, InstantaneousTrendline, EhlersStochastic, RVIVolatility, Variance, CoefficientOfVariation, Skewness, Kurtosis, StandardError, DetrendedStdDev, RSquared, MedianAbsoluteDeviation, Autocorrelation, HurstExponent, PearsonCorrelation, Beta, PairwiseBeta, SpearmanCorrelation, PairSpreadZScore, LeadLagCrossCorrelation, Cointegration, RelativeStrengthAB, MACD, BollingerBands, ATR, Stochastic, OBV, ADX, ADXR, CCI, WilliamsR, MFI, PSAR, Keltner, Donchian, VWAP, RollingVWAP, AwesomeOscillator, Aroon, Inertia, ConnorsRSI, LaguerreRSI, SMI, KST, PGO, RVI, AwesomeOscillatorHistogram, STC, ElderImpulse, ZeroLagMACD, CFO, APO, KAMA, EVWMA, Alligator, JMA, VIDYA, ALMA, T3, TSI, PMO, TII, ADL, VolumePriceTrend, ChaikinMoneyFlow, ChaikinOscillator, ForceIndex, NVI, PVI, VolumeOscillator, KVO, WilliamsAD, AnchoredVWAP, DemandIndex, TSV, VZO, MarketFacilitationIndex, EaseOfMovement, SuperTrend, ChandelierExit, ChandeKrollStop, AtrTrailingStop, HiLoActivator, VoltyStop, YoyoExit, DonchianStop, PercentageTrailingStop, StepTrailingStop, RenkoTrailingStop, TypicalPrice, MedianPrice, WeightedClose, LinearRegression, LinRegSlope, AcceleratorOscillator, BalanceOfPower, ChoppinessIndex, TrueRange, ChaikinVolatility, YangZhangVolatility, RogersSatchellVolatility, GarmanKlassVolatility, ParkinsonVolatility, LinRegAngle, BollingerBandwidth, PercentB, NATR, HistoricalVolatility, AroonOscillator, WaveTrend, RWI, Vortex, MassIndex, StochRSI, UltimateOscillator, PPO, Coppock, VWMA, MaEnvelope, AccelerationBands, StarcBands, AtrBands, HurstChannel, LinRegChannel, StandardErrorBands, DoubleBollinger, TtmSqueeze, FractalChaosBands, VwapStdDevBands, ClassicPivots, FibonacciPivots, Camarilla, WoodiePivots, DemarkPivots, WilliamsFractals, ZigZag, TDSetup, TDSequential, TDDeMarker, TDREI, TDPressure, TDCombo, TDCountdown, TDLines, TDRangeProjection, TDDifferential, TDOpen, TDRiskLevel, InverseFisherTransform, DecyclerOscillator, RoofingFilter, EmpiricalModeDecomposition, HilbertDominantCycle, AdaptiveCycle, SineWave, MAMA, FAMA, Ichimoku, HeikinAshi, ValueArea, InitialBalance, OpeningRange, Doji, Hammer, InvertedHammer, HangingMan, ShootingStar, Engulfing, Harami, MorningEveningStar, ThreeSoldiersOrCrows, PiercingDarkCloud, Marubozu, Tweezer, SpinningTop, ThreeInside, ThreeOutside, OrderBookImbalanceTop1, OrderBookImbalanceFull, Microprice, QuotedSpread, OrderBookImbalanceTopN, SignedVolume, CumulativeVolumeDelta, TradeImbalance, SharpeRatio, SortinoRatio, CalmarRatio, OmegaRatio, MaxDrawdown, AverageDrawdown, DrawdownDuration, PainIndex, ValueAtRisk, ConditionalValueAtRisk, ProfitFactor, GainLossRatio, RecoveryFactor, KellyCriterion, TreynorRatio, InformationRatio, Alpha } = nativeBinding
const { version, SMA, EMA, WMA, RSI, DEMA, TEMA, HMA, ROC, TRIX, SMMA, TRIMA, ZLEMA, MOM, CMO, DPO, StdDev, UlcerIndex, VerticalHorizontalFilter, ZScore, McGinleyDynamic, FRAMA, SuperSmoother, FisherTransform, Decycler, CenterOfGravity, CyberneticCycle, InstantaneousTrendline, EhlersStochastic, RVIVolatility, Variance, CoefficientOfVariation, Skewness, Kurtosis, StandardError, DetrendedStdDev, RSquared, MedianAbsoluteDeviation, MIDPOINT, ROCP, ROCR, ROCR100, LINEARREG_INTERCEPT, TSF, Autocorrelation, HurstExponent, PearsonCorrelation, Beta, PairwiseBeta, SpearmanCorrelation, RollingCorrelation, RollingCovariance, OuHalfLife, SpreadHurst, DistanceSsd, BetaNeutralSpread, PairSpreadZScore, LeadLagCrossCorrelation, Cointegration, RelativeStrengthAB, VarianceRatio, GrangerCausality, KalmanHedgeRatio, SpreadBollingerBands, MACD, MACDFIX, MACDEXT, BollingerBands, ATR, PLUS_DM, MINUS_DM, PLUS_DI, MINUS_DI, DX, MIDPRICE, AVGPRICE, SAREXT, HT_PHASOR, Stochastic, OBV, ADX, ADXR, CCI, WilliamsR, MFI, PSAR, Keltner, Donchian, VWAP, RollingVWAP, AwesomeOscillator, Aroon, Inertia, ConnorsRSI, LaguerreRSI, SMI, KST, PGO, RVI, AwesomeOscillatorHistogram, STC, ElderImpulse, ZeroLagMACD, CFO, APO, KAMA, EVWMA, Alligator, JMA, VIDYA, ALMA, T3, TSI, PMO, TII, ADL, VolumePriceTrend, ChaikinMoneyFlow, ChaikinOscillator, ForceIndex, NVI, PVI, VolumeOscillator, KVO, WilliamsAD, AnchoredRSI, AnchoredVWAP, DemandIndex, TSV, VZO, MarketFacilitationIndex, EaseOfMovement, SuperTrend, ChandelierExit, ChandeKrollStop, AtrTrailingStop, HiLoActivator, VoltyStop, YoyoExit, DonchianStop, PercentageTrailingStop, StepTrailingStop, RenkoTrailingStop, TypicalPrice, MedianPrice, WeightedClose, LinearRegression, LinRegSlope, AcceleratorOscillator, BalanceOfPower, ChoppinessIndex, TrueRange, ChaikinVolatility, YangZhangVolatility, RogersSatchellVolatility, GarmanKlassVolatility, ParkinsonVolatility, LinRegAngle, BollingerBandwidth, PercentB, NATR, HistoricalVolatility, AroonOscillator, WaveTrend, RWI, Vortex, MassIndex, StochRSI, UltimateOscillator, PPO, Coppock, VWMA, MaEnvelope, AccelerationBands, StarcBands, AtrBands, HurstChannel, LinRegChannel, StandardErrorBands, DoubleBollinger, TtmSqueeze, FractalChaosBands, VwapStdDevBands, ClassicPivots, FibonacciPivots, Camarilla, WoodiePivots, DemarkPivots, WilliamsFractals, ZigZag, TDSetup, TDSequential, TDDeMarker, TDREI, TDPressure, TDCombo, TDCountdown, TDLines, TDRangeProjection, TDDifferential, TDOpen, TDRiskLevel, InverseFisherTransform, DecyclerOscillator, RoofingFilter, EmpiricalModeDecomposition, HT_DCPHASE, HT_TRENDMODE, HilbertDominantCycle, AdaptiveCycle, SineWave, MAMA, FAMA, Ichimoku, HeikinAshi, ValueArea, VolumeProfile, TpoProfile, InitialBalance, OpeningRange, Doji, Hammer, InvertedHammer, HangingMan, ShootingStar, Engulfing, Harami, MorningEveningStar, ThreeSoldiersOrCrows, PiercingDarkCloud, Marubozu, Tweezer, 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, OrderBookImbalanceTop1, OrderBookImbalanceFull, Microprice, QuotedSpread, DepthSlope, OrderBookImbalanceTopN, SignedVolume, CumulativeVolumeDelta, TradeImbalance, EffectiveSpread, RealizedSpread, KylesLambda, Footprint, FundingRate, FundingRateMean, FundingRateZScore, FundingBasis, OpenInterestDelta, OIPriceDivergence, OIWeighted, LongShortRatio, TakerBuySellRatio, LiquidationFeatures, TermStructureBasis, CalendarSpread, AdvanceDecline, AdvanceDeclineRatio, AdVolumeLine, McClellanOscillator, McClellanSummationIndex, Trin, BreadthThrust, NewHighsNewLows, HighLowIndex, PercentAboveMa, UpDownVolumeRatio, BullishPercentIndex, CumulativeVolumeIndex, AbsoluteBreadthIndex, TickIndex, SharpeRatio, SortinoRatio, CalmarRatio, OmegaRatio, MaxDrawdown, AverageDrawdown, DrawdownDuration, PainIndex, ValueAtRisk, ConditionalValueAtRisk, ProfitFactor, GainLossRatio, RecoveryFactor, KellyCriterion, TreynorRatio, InformationRatio, RenkoBars, KagiBars, PointAndFigureBars, Alpha } = nativeBinding
module.exports.version = version
module.exports.SMA = SMA
@@ -350,19 +350,46 @@ module.exports.StandardError = StandardError
module.exports.DetrendedStdDev = DetrendedStdDev
module.exports.RSquared = RSquared
module.exports.MedianAbsoluteDeviation = MedianAbsoluteDeviation
module.exports.MIDPOINT = MIDPOINT
module.exports.ROCP = ROCP
module.exports.ROCR = ROCR
module.exports.ROCR100 = ROCR100
module.exports.LINEARREG_INTERCEPT = LINEARREG_INTERCEPT
module.exports.TSF = TSF
module.exports.Autocorrelation = Autocorrelation
module.exports.HurstExponent = HurstExponent
module.exports.PearsonCorrelation = PearsonCorrelation
module.exports.Beta = Beta
module.exports.PairwiseBeta = PairwiseBeta
module.exports.SpearmanCorrelation = SpearmanCorrelation
module.exports.RollingCorrelation = RollingCorrelation
module.exports.RollingCovariance = RollingCovariance
module.exports.OuHalfLife = OuHalfLife
module.exports.SpreadHurst = SpreadHurst
module.exports.DistanceSsd = DistanceSsd
module.exports.BetaNeutralSpread = BetaNeutralSpread
module.exports.PairSpreadZScore = PairSpreadZScore
module.exports.LeadLagCrossCorrelation = LeadLagCrossCorrelation
module.exports.Cointegration = Cointegration
module.exports.RelativeStrengthAB = RelativeStrengthAB
module.exports.VarianceRatio = VarianceRatio
module.exports.GrangerCausality = GrangerCausality
module.exports.KalmanHedgeRatio = KalmanHedgeRatio
module.exports.SpreadBollingerBands = SpreadBollingerBands
module.exports.MACD = MACD
module.exports.MACDFIX = MACDFIX
module.exports.MACDEXT = MACDEXT
module.exports.BollingerBands = BollingerBands
module.exports.ATR = ATR
module.exports.PLUS_DM = PLUS_DM
module.exports.MINUS_DM = MINUS_DM
module.exports.PLUS_DI = PLUS_DI
module.exports.MINUS_DI = MINUS_DI
module.exports.DX = DX
module.exports.MIDPRICE = MIDPRICE
module.exports.AVGPRICE = AVGPRICE
module.exports.SAREXT = SAREXT
module.exports.HT_PHASOR = HT_PHASOR
module.exports.Stochastic = Stochastic
module.exports.OBV = OBV
module.exports.ADX = ADX
@@ -410,6 +437,7 @@ module.exports.PVI = PVI
module.exports.VolumeOscillator = VolumeOscillator
module.exports.KVO = KVO
module.exports.WilliamsAD = WilliamsAD
module.exports.AnchoredRSI = AnchoredRSI
module.exports.AnchoredVWAP = AnchoredVWAP
module.exports.DemandIndex = DemandIndex
module.exports.TSV = TSV
@@ -490,6 +518,8 @@ module.exports.InverseFisherTransform = InverseFisherTransform
module.exports.DecyclerOscillator = DecyclerOscillator
module.exports.RoofingFilter = RoofingFilter
module.exports.EmpiricalModeDecomposition = EmpiricalModeDecomposition
module.exports.HT_DCPHASE = HT_DCPHASE
module.exports.HT_TRENDMODE = HT_TRENDMODE
module.exports.HilbertDominantCycle = HilbertDominantCycle
module.exports.AdaptiveCycle = AdaptiveCycle
module.exports.SineWave = SineWave
@@ -498,6 +528,8 @@ module.exports.FAMA = FAMA
module.exports.Ichimoku = Ichimoku
module.exports.HeikinAshi = HeikinAshi
module.exports.ValueArea = ValueArea
module.exports.VolumeProfile = VolumeProfile
module.exports.TpoProfile = TpoProfile
module.exports.InitialBalance = InitialBalance
module.exports.OpeningRange = OpeningRange
module.exports.Doji = Doji
@@ -515,14 +547,91 @@ module.exports.Tweezer = Tweezer
module.exports.SpinningTop = SpinningTop
module.exports.ThreeInside = ThreeInside
module.exports.ThreeOutside = ThreeOutside
module.exports.TwoCrows = TwoCrows
module.exports.UpsideGapTwoCrows = UpsideGapTwoCrows
module.exports.IdenticalThreeCrows = IdenticalThreeCrows
module.exports.ThreeLineStrike = ThreeLineStrike
module.exports.ThreeStarsInSouth = ThreeStarsInSouth
module.exports.AbandonedBaby = AbandonedBaby
module.exports.AdvanceBlock = AdvanceBlock
module.exports.BeltHold = BeltHold
module.exports.Breakaway = Breakaway
module.exports.Counterattack = Counterattack
module.exports.DojiStar = DojiStar
module.exports.DragonflyDoji = DragonflyDoji
module.exports.GravestoneDoji = GravestoneDoji
module.exports.LongLeggedDoji = LongLeggedDoji
module.exports.RickshawMan = RickshawMan
module.exports.EveningDojiStar = EveningDojiStar
module.exports.MorningDojiStar = MorningDojiStar
module.exports.GapSideBySideWhite = GapSideBySideWhite
module.exports.HighWave = HighWave
module.exports.Hikkake = Hikkake
module.exports.HikkakeModified = HikkakeModified
module.exports.HomingPigeon = HomingPigeon
module.exports.OnNeck = OnNeck
module.exports.InNeck = InNeck
module.exports.Thrusting = Thrusting
module.exports.SeparatingLines = SeparatingLines
module.exports.Kicking = Kicking
module.exports.KickingByLength = KickingByLength
module.exports.LadderBottom = LadderBottom
module.exports.MatHold = MatHold
module.exports.MatchingLow = MatchingLow
module.exports.LongLine = LongLine
module.exports.ShortLine = ShortLine
module.exports.RisingThreeMethods = RisingThreeMethods
module.exports.FallingThreeMethods = FallingThreeMethods
module.exports.UpsideGapThreeMethods = UpsideGapThreeMethods
module.exports.DownsideGapThreeMethods = DownsideGapThreeMethods
module.exports.StalledPattern = StalledPattern
module.exports.StickSandwich = StickSandwich
module.exports.Takuri = Takuri
module.exports.ClosingMarubozu = ClosingMarubozu
module.exports.OpeningMarubozu = OpeningMarubozu
module.exports.TasukiGap = TasukiGap
module.exports.UniqueThreeRiver = UniqueThreeRiver
module.exports.ConcealingBabySwallow = ConcealingBabySwallow
module.exports.OrderBookImbalanceTop1 = OrderBookImbalanceTop1
module.exports.OrderBookImbalanceFull = OrderBookImbalanceFull
module.exports.Microprice = Microprice
module.exports.QuotedSpread = QuotedSpread
module.exports.DepthSlope = DepthSlope
module.exports.OrderBookImbalanceTopN = OrderBookImbalanceTopN
module.exports.SignedVolume = SignedVolume
module.exports.CumulativeVolumeDelta = CumulativeVolumeDelta
module.exports.TradeImbalance = TradeImbalance
module.exports.EffectiveSpread = EffectiveSpread
module.exports.RealizedSpread = RealizedSpread
module.exports.KylesLambda = KylesLambda
module.exports.Footprint = Footprint
module.exports.FundingRate = FundingRate
module.exports.FundingRateMean = FundingRateMean
module.exports.FundingRateZScore = FundingRateZScore
module.exports.FundingBasis = FundingBasis
module.exports.OpenInterestDelta = OpenInterestDelta
module.exports.OIPriceDivergence = OIPriceDivergence
module.exports.OIWeighted = OIWeighted
module.exports.LongShortRatio = LongShortRatio
module.exports.TakerBuySellRatio = TakerBuySellRatio
module.exports.LiquidationFeatures = LiquidationFeatures
module.exports.TermStructureBasis = TermStructureBasis
module.exports.CalendarSpread = CalendarSpread
module.exports.AdvanceDecline = AdvanceDecline
module.exports.AdvanceDeclineRatio = AdvanceDeclineRatio
module.exports.AdVolumeLine = AdVolumeLine
module.exports.McClellanOscillator = McClellanOscillator
module.exports.McClellanSummationIndex = McClellanSummationIndex
module.exports.Trin = Trin
module.exports.BreadthThrust = BreadthThrust
module.exports.NewHighsNewLows = NewHighsNewLows
module.exports.HighLowIndex = HighLowIndex
module.exports.PercentAboveMa = PercentAboveMa
module.exports.UpDownVolumeRatio = UpDownVolumeRatio
module.exports.BullishPercentIndex = BullishPercentIndex
module.exports.CumulativeVolumeIndex = CumulativeVolumeIndex
module.exports.AbsoluteBreadthIndex = AbsoluteBreadthIndex
module.exports.TickIndex = TickIndex
module.exports.SharpeRatio = SharpeRatio
module.exports.SortinoRatio = SortinoRatio
module.exports.CalmarRatio = CalmarRatio
@@ -539,4 +648,7 @@ module.exports.RecoveryFactor = RecoveryFactor
module.exports.KellyCriterion = KellyCriterion
module.exports.TreynorRatio = TreynorRatio
module.exports.InformationRatio = InformationRatio
module.exports.RenkoBars = RenkoBars
module.exports.KagiBars = KagiBars
module.exports.PointAndFigureBars = PointAndFigureBars
module.exports.Alpha = Alpha
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "wickra-darwin-arm64",
"version": "0.4.2",
"version": "0.5.0",
"description": "Native binding for wickra (macOS Apple Silicon). Installed automatically as an optional dependency of wickra on matching platforms.",
"main": "wickra.darwin-arm64.node",
"files": [
"wickra.darwin-arm64.node"
],
"license": "LicenseRef-Wickra-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.2",
"version": "0.5.0",
"description": "Native binding for wickra (macOS Intel). Installed automatically as an optional dependency of wickra on matching platforms.",
"main": "wickra.darwin-x64.node",
"files": [
"wickra.darwin-x64.node"
],
"license": "LicenseRef-Wickra-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.2",
"version": "0.5.0",
"description": "Native binding for wickra (linux arm64 GNU). Installed automatically as an optional dependency of wickra on matching platforms.",
"main": "wickra.linux-arm64-gnu.node",
"files": [
"wickra.linux-arm64-gnu.node"
],
"license": "LicenseRef-Wickra-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.2",
"version": "0.5.0",
"description": "Native binding for wickra (linux x64 GNU). Installed automatically as an optional dependency of wickra on matching platforms.",
"main": "wickra.linux-x64-gnu.node",
"files": [
"wickra.linux-x64-gnu.node"
],
"license": "LicenseRef-Wickra-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.2",
"version": "0.5.0",
"description": "Native binding for wickra (Windows arm64 MSVC). Installed automatically as an optional dependency of wickra on matching platforms.",
"main": "wickra.win32-arm64-msvc.node",
"files": [
"wickra.win32-arm64-msvc.node"
],
"license": "LicenseRef-Wickra-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.2",
"version": "0.5.0",
"description": "Native binding for wickra (Windows x64 MSVC). Installed automatically as an optional dependency of wickra on matching platforms.",
"main": "wickra.win32-x64-msvc.node",
"files": [
"wickra.win32-x64-msvc.node"
],
"license": "LicenseRef-Wickra-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.2",
"version": "0.5.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "wickra",
"version": "0.4.2",
"license": "PolyForm-Noncommercial-1.0.0",
"version": "0.5.0",
"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.2",
"wickra-darwin-x64": "0.4.2",
"wickra-linux-arm64-gnu": "0.4.2",
"wickra-linux-x64-gnu": "0.4.2",
"wickra-win32-arm64-msvc": "0.4.2",
"wickra-win32-x64-msvc": "0.4.2"
"wickra-darwin-arm64": "0.5.0",
"wickra-darwin-x64": "0.5.0",
"wickra-linux-arm64-gnu": "0.5.0",
"wickra-linux-x64-gnu": "0.5.0",
"wickra-win32-arm64-msvc": "0.5.0",
"wickra-win32-x64-msvc": "0.5.0"
}
},
"node_modules/@napi-rs/cli": {
@@ -41,13 +41,13 @@
}
},
"node_modules/wickra-darwin-arm64": {
"version": "0.4.2",
"resolved": "https://registry.npmjs.org/wickra-darwin-arm64/-/wickra-darwin-arm64-0.4.2.tgz",
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/wickra-darwin-arm64/-/wickra-darwin-arm64-0.5.0.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.2",
"resolved": "https://registry.npmjs.org/wickra-darwin-x64/-/wickra-darwin-x64-0.4.2.tgz",
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/wickra-darwin-x64/-/wickra-darwin-x64-0.5.0.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.2",
"resolved": "https://registry.npmjs.org/wickra-linux-arm64-gnu/-/wickra-linux-arm64-gnu-0.4.2.tgz",
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/wickra-linux-arm64-gnu/-/wickra-linux-arm64-gnu-0.5.0.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.2",
"resolved": "https://registry.npmjs.org/wickra-linux-x64-gnu/-/wickra-linux-x64-gnu-0.4.2.tgz",
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/wickra-linux-x64-gnu/-/wickra-linux-x64-gnu-0.5.0.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.2",
"resolved": "https://registry.npmjs.org/wickra-win32-arm64-msvc/-/wickra-win32-arm64-msvc-0.4.2.tgz",
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/wickra-win32-arm64-msvc/-/wickra-win32-arm64-msvc-0.5.0.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.2",
"resolved": "https://registry.npmjs.org/wickra-win32-x64-msvc/-/wickra-win32-x64-msvc-0.4.2.tgz",
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/wickra-win32-x64-msvc/-/wickra-win32-x64-msvc-0.5.0.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.2",
"version": "0.5.0",
"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": "LicenseRef-Wickra-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.2",
"wickra-linux-arm64-gnu": "0.4.2",
"wickra-darwin-x64": "0.4.2",
"wickra-darwin-arm64": "0.4.2",
"wickra-win32-x64-msvc": "0.4.2",
"wickra-win32-arm64-msvc": "0.4.2"
"wickra-linux-x64-gnu": "0.5.0",
"wickra-linux-arm64-gnu": "0.5.0",
"wickra-darwin-x64": "0.5.0",
"wickra-darwin-arm64": "0.5.0",
"wickra-win32-x64-msvc": "0.5.0",
"wickra-win32-arm64-msvc": "0.5.0"
},
"scripts": {
"build": "napi build --platform --release",
+3685 -1
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -5,7 +5,7 @@ version.workspace = true
authors.workspace = true
edition.workspace = true
rust-version.workspace = true
license-file.workspace = true
license.workspace = true
repository.workspace = true
homepage.workspace = true
readme.workspace = true
+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.
+2 -3
View File
@@ -4,17 +4,16 @@ build-backend = "maturin"
[project]
name = "wickra"
version = "0.4.2"
version = "0.5.0"
description = "Streaming-first technical indicators: incremental, fast, install-free."
readme = "README.md"
license = { text = "PolyForm-Noncommercial-1.0.0 with additional personal-account permissions; see LICENSE" }
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",
+234
View File
@@ -25,6 +25,17 @@ from __future__ import annotations
from ._wickra import (
__version__,
TSF,
LINEARREG_INTERCEPT,
ROCR100,
ROCR,
ROCP,
AVGPRICE,
MIDPOINT,
MIDPRICE,
DX,
MINUS_DI,
PLUS_DI,
# Trend
SMA,
EMA,
@@ -47,13 +58,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 +113,7 @@ from ._wickra import (
Keltner,
Donchian,
PSAR,
SAREXT,
NATR,
StdDev,
UlcerIndex,
@@ -142,6 +159,16 @@ from ._wickra import (
MarketFacilitationIndex,
EaseOfMovement,
# Statistics
SpreadBollingerBands,
KalmanHedgeRatio,
GrangerCausality,
VarianceRatio,
BetaNeutralSpread,
DistanceSsd,
SpreadHurst,
OuHalfLife,
RollingCovariance,
RollingCorrelation,
TypicalPrice,
MedianPrice,
WeightedClose,
@@ -180,6 +207,9 @@ from ._wickra import (
EhlersStochastic,
EmpiricalModeDecomposition,
HilbertDominantCycle,
HT_DCPHASE,
HT_PHASOR,
HT_TRENDMODE,
AdaptiveCycle,
SineWave,
MAMA,
@@ -222,8 +252,14 @@ from ._wickra import (
HeikinAshi,
# Market Profile
ValueArea,
VolumeProfile,
TpoProfile,
InitialBalance,
OpeningRange,
# Alt-Chart Bars
RenkoBars,
KagiBars,
PointAndFigureBars,
# Candlestick patterns
Doji,
Hammer,
@@ -240,16 +276,97 @@ 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,
# Microstructure: order book
OrderBookImbalanceTop1,
OrderBookImbalanceTopN,
OrderBookImbalanceFull,
Microprice,
QuotedSpread,
DepthSlope,
# Microstructure: trade flow
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,
@@ -271,6 +388,17 @@ from ._wickra import (
)
__all__ = [
"TSF",
"LINEARREG_INTERCEPT",
"ROCR100",
"ROCR",
"ROCP",
"AVGPRICE",
"MIDPOINT",
"MIDPRICE",
"DX",
"MINUS_DI",
"PLUS_DI",
"__version__",
# Trend
"SMA",
@@ -294,13 +422,18 @@ __all__ = [
"EVWMA",
# Momentum
"RSI",
"AnchoredRSI",
"MACD",
"MACDFIX",
"MACDEXT",
"Stochastic",
"CCI",
"ROC",
"WilliamsR",
"ADX",
"ADXR",
"PLUS_DM",
"MINUS_DM",
"MFI",
"TRIX",
"AwesomeOscillator",
@@ -344,6 +477,7 @@ __all__ = [
"Keltner",
"Donchian",
"PSAR",
"SAREXT",
"NATR",
"StdDev",
"UlcerIndex",
@@ -389,6 +523,16 @@ __all__ = [
"MarketFacilitationIndex",
"EaseOfMovement",
# Statistics
"SpreadBollingerBands",
"KalmanHedgeRatio",
"GrangerCausality",
"VarianceRatio",
"BetaNeutralSpread",
"DistanceSsd",
"SpreadHurst",
"OuHalfLife",
"RollingCovariance",
"RollingCorrelation",
"TypicalPrice",
"MedianPrice",
"WeightedClose",
@@ -427,6 +571,9 @@ __all__ = [
"EhlersStochastic",
"EmpiricalModeDecomposition",
"HilbertDominantCycle",
"HT_DCPHASE",
"HT_PHASOR",
"HT_TRENDMODE",
"AdaptiveCycle",
"SineWave",
"MAMA",
@@ -469,8 +616,14 @@ __all__ = [
"HeikinAshi",
# Market Profile
"ValueArea",
"VolumeProfile",
"TpoProfile",
"InitialBalance",
"OpeningRange",
# Alt-Chart Bars
"RenkoBars",
"KagiBars",
"PointAndFigureBars",
# Candlestick patterns
"Doji",
"Hammer",
@@ -487,16 +640,97 @@ __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",
# Microstructure: order book
"OrderBookImbalanceTop1",
"OrderBookImbalanceTopN",
"OrderBookImbalanceFull",
"Microprice",
"QuotedSpread",
"DepthSlope",
# Microstructure: trade flow
"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",
File diff suppressed because it is too large Load Diff
@@ -211,3 +211,70 @@ def test_trade_non_positive_price_raises():
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)
+145
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
@@ -872,6 +880,27 @@ def test_quoted_spread_reference_value():
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)
@@ -889,3 +918,119 @@ def test_trade_imbalance_reference_value():
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)
+48
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),
],
)
@@ -139,6 +141,7 @@ def test_orderbook_lifecycle():
ta.OrderBookImbalanceFull(),
ta.Microprice(),
ta.QuotedSpread(),
ta.DepthSlope(),
]:
assert ind.warmup_period() == 1
assert not ind.is_ready()
@@ -172,3 +175,48 @@ def test_trade_imbalance_lifecycle_and_repr():
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
+32
View File
@@ -108,6 +108,7 @@ def test_orderbook_indicators_construct_and_emit():
ta.OrderBookImbalanceFull(),
ta.Microprice(),
ta.QuotedSpread(),
ta.DepthSlope(),
]
for ind in indicators:
out = ind.update(*snapshot)
@@ -135,3 +136,34 @@ def test_tradeflow_batch_returns_one_value_per_trade():
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
@@ -231,3 +231,20 @@ def test_tradeflow_streaming_matches_batch():
dtype=np.float64,
)
assert _equal_with_nan(batch, streamed)
def test_price_impact_streaming_matches_batch():
n = 30
mid = np.array([100.0 + 0.25 * math.sin(i * 0.5) for i in range(n)], dtype=np.float64)
is_buy = [i % 3 != 0 for i in range(n)]
price = np.array(
[mid[i] + (0.03 if is_buy[i] else -0.03) for i in range(n)], dtype=np.float64
)
size = np.array([1.0 + (i % 4) for i in range(n)], dtype=np.float64)
batch = ta.EffectiveSpread().batch(price, size, is_buy, mid)
streamer = ta.EffectiveSpread()
streamed = np.array(
[streamer.update(price[i], size[i], is_buy[i], mid[i]) for i in range(n)],
dtype=np.float64,
)
assert _equal_with_nan(batch, streamed)
+1 -1
View File
@@ -5,7 +5,7 @@ version.workspace = true
authors.workspace = true
edition.workspace = true
rust-version.workspace = true
license-file.workspace = true
license.workspace = true
repository.workspace = true
homepage.workspace = true
readme.workspace = true
+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.
+2703 -2
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", ".."]
+1 -1
View File
@@ -5,7 +5,7 @@ version.workspace = true
authors.workspace = true
edition.workspace = true
rust-version.workspace = true
license-file.workspace = true
license.workspace = true
repository.workspace = true
homepage.workspace = true
readme.workspace = true
+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);
}
}
+24
View File
@@ -43,6 +43,30 @@ pub enum Error {
/// 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));
}
}
@@ -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,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,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<_>>()
);
}
}
@@ -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());
}
}
@@ -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,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,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());
}
}
@@ -0,0 +1,177 @@
//! Closing Marubozu candlestick pattern.
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Closing Marubozu — a single-bar strong-momentum candle with a long body and no
/// shadow on the *close* end. A white closing marubozu closes right at the high
/// (no upper shadow) and may carry an opening shadow below; a black one closes
/// right at the low (no lower shadow) and may carry an opening shadow above. The
/// shaved close end shows the move ran unopposed into the bell.
///
/// ```text
/// range = high low
/// long body: |close open| >= 0.7 * range
/// white: close > open and high close <= 0.05 * range (close at the high)
/// black: close < open and close low <= 0.05 * range (close at the low)
/// ```
///
/// Output is `+1.0` for a white closing marubozu, `1.0` for a black one, and
/// `0.0` otherwise. Body and shadow thresholds follow the geometric house style
/// rather than TA-Lib's rolling averages. The opposite shaved end is
/// [`crate::OpeningMarubozu`]. 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::{Candle, ClosingMarubozu, Indicator};
///
/// let mut indicator = ClosingMarubozu::new();
/// // White: closes at the high, small opening shadow below.
/// let candle = Candle::new(10.5, 15.0, 10.0, 15.0, 1.0, 0).unwrap();
/// assert_eq!(indicator.update(candle), Some(1.0));
/// ```
#[derive(Debug, Clone, Default)]
pub struct ClosingMarubozu {
has_emitted: bool,
}
impl ClosingMarubozu {
/// Construct a new Closing Marubozu detector.
pub const fn new() -> Self {
Self { has_emitted: false }
}
}
impl Indicator for ClosingMarubozu {
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.7 * range {
return Some(0.0);
}
let tol = 0.05 * range;
if body > 0.0 && candle.high - candle.close <= tol {
return Some(1.0);
}
if body < 0.0 && candle.close - candle.low <= 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 {
"ClosingMarubozu"
}
}
#[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 = ClosingMarubozu::new();
assert_eq!(t.name(), "ClosingMarubozu");
assert_eq!(t.warmup_period(), 1);
assert!(!t.is_ready());
}
#[test]
fn white_closing_marubozu_is_plus_one() {
let mut t = ClosingMarubozu::new();
// Closes at the high, opening shadow below.
assert_eq!(t.update(c(10.5, 15.0, 10.0, 15.0, 0)), Some(1.0));
}
#[test]
fn black_closing_marubozu_is_minus_one() {
let mut t = ClosingMarubozu::new();
// Closes at the low, opening shadow above.
assert_eq!(t.update(c(14.5, 15.0, 10.0, 10.0, 0)), Some(-1.0));
}
#[test]
fn white_with_upper_shadow_yields_zero() {
let mut t = ClosingMarubozu::new();
// Long white body but a clear upper shadow -> close is not at the high.
assert_eq!(t.update(c(10.5, 16.0, 10.0, 15.0, 0)), Some(0.0));
}
#[test]
fn black_with_lower_shadow_yields_zero() {
let mut t = ClosingMarubozu::new();
// Long black body but a clear lower shadow -> close is not at the low.
assert_eq!(t.update(c(14.5, 15.0, 9.0, 10.0, 0)), Some(0.0));
}
#[test]
fn short_body_yields_zero() {
let mut t = ClosingMarubozu::new();
// Body is short relative to range.
assert_eq!(t.update(c(12.0, 15.0, 10.0, 12.5, 0)), Some(0.0));
}
#[test]
fn zero_range_yields_zero() {
let mut t = ClosingMarubozu::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 + 0.5, base + 5.0, base, base + 5.0, i)
})
.collect();
let mut a = ClosingMarubozu::new();
let mut b = ClosingMarubozu::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut t = ClosingMarubozu::new();
t.update(c(10.5, 15.0, 10.0, 15.0, 0));
assert!(t.is_ready());
t.reset();
assert!(!t.is_ready());
}
}
@@ -0,0 +1,278 @@
//! Concealing Baby Swallow candlestick pattern.
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Returns `true` when `candle` is a black marubozu: a down candle whose body fills
/// the range with negligible shadows on both ends.
fn black_marubozu(candle: Candle) -> bool {
let range = candle.high - candle.low;
if range <= 0.0 {
return false;
}
let upper = candle.high - candle.open;
let lower = candle.close - candle.low;
candle.open > candle.close && upper <= 0.05 * range && lower <= 0.05 * range
}
/// Concealing Baby Swallow — a rare 4-bar bullish reversal. Two black marubozu lead
/// a steep decline; the third is a black candle that gaps down on the open yet
/// throws a long upper shadow back up into the second body; the fourth is a large
/// black candle that completely engulfs the third, shadows included. The relentless
/// selling that can no longer make ground signals capitulation.
///
/// ```text
/// bar1, bar2 black marubozu (body == range, negligible shadows)
/// bar3 black, opens below bar2's body (open3 < close2) with an upper
/// shadow into it (high3 > close2)
/// bar4 black, engulfs bar3 including shadows: open4 > high3 and close4 < low3
/// ```
///
/// Output is `+1.0` when the pattern completes and `0.0` otherwise. Concealing Baby
/// Swallow is a single-direction (bullish-only) reversal, so it never emits `1.0`.
/// The first three bars always return `0.0` because the four-bar window is not yet
/// filled. Body and shadow thresholds follow the geometric house style rather than
/// TA-Lib's rolling averages. 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, `0.0` no pattern — so it drops straight into
/// a machine-learning feature matrix as a single dimension.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, ConcealingBabySwallow, Indicator};
///
/// let mut indicator = ConcealingBabySwallow::new();
/// indicator.update(Candle::new(20.0, 20.1, 14.9, 15.0, 1.0, 0).unwrap());
/// indicator.update(Candle::new(16.0, 16.1, 11.9, 12.0, 1.0, 1).unwrap());
/// indicator.update(Candle::new(11.0, 13.0, 9.9, 10.0, 1.0, 2).unwrap());
/// let out = indicator
/// .update(Candle::new(14.0, 14.1, 8.9, 9.0, 1.0, 3).unwrap());
/// assert_eq!(out, Some(1.0));
/// ```
#[derive(Debug, Clone, Default)]
pub struct ConcealingBabySwallow {
c1: Option<Candle>,
c2: Option<Candle>,
c3: Option<Candle>,
has_emitted: bool,
}
impl ConcealingBabySwallow {
/// Construct a new Concealing Baby Swallow detector.
pub const fn new() -> Self {
Self {
c1: None,
c2: None,
c3: None,
has_emitted: false,
}
}
}
impl Indicator for ConcealingBabySwallow {
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;
self.c1 = self.c2;
self.c2 = self.c3;
self.c3 = Some(candle);
let (Some(bar1), Some(bar2), Some(bar3)) = (bar1, bar2, bar3) else {
return Some(0.0);
};
// bar1 and bar2 are black marubozu.
if !black_marubozu(bar1) || !black_marubozu(bar2) {
return Some(0.0);
}
// bar3 is black, gaps down on the open, throws an upper shadow into bar2.
if bar3.open <= bar3.close {
return Some(0.0);
}
if bar3.open >= bar2.close {
return Some(0.0); // no downside open gap
}
if bar3.high <= bar2.close {
return Some(0.0); // upper shadow does not reach into bar2's body
}
// bar4 is black and engulfs bar3 including its shadows.
if candle.open <= candle.close {
return Some(0.0);
}
if candle.open > bar3.high && candle.close < bar3.low {
return Some(1.0);
}
Some(0.0)
}
fn reset(&mut self) {
self.c1 = None;
self.c2 = None;
self.c3 = None;
self.has_emitted = false;
}
fn warmup_period(&self) -> usize {
4
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"ConcealingBabySwallow"
}
}
#[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 = ConcealingBabySwallow::new();
assert_eq!(t.name(), "ConcealingBabySwallow");
assert_eq!(t.warmup_period(), 4);
assert!(!t.is_ready());
}
#[test]
fn concealing_baby_swallow_is_plus_one() {
let mut t = ConcealingBabySwallow::new();
assert_eq!(t.update(c(20.0, 20.1, 14.9, 15.0, 0)), Some(0.0));
assert_eq!(t.update(c(16.0, 16.1, 11.9, 12.0, 1)), Some(0.0));
assert_eq!(t.update(c(11.0, 13.0, 9.9, 10.0, 2)), Some(0.0));
assert_eq!(t.update(c(14.0, 14.1, 8.9, 9.0, 3)), Some(1.0));
}
#[test]
fn warmup_returns_zero() {
let mut t = ConcealingBabySwallow::new();
assert_eq!(t.update(c(20.0, 20.1, 14.9, 15.0, 0)), Some(0.0));
assert_eq!(t.update(c(16.0, 16.1, 11.9, 12.0, 1)), Some(0.0));
assert_eq!(t.update(c(11.0, 13.0, 9.9, 10.0, 2)), Some(0.0));
}
#[test]
fn first_bar_not_marubozu_yields_zero() {
let mut t = ConcealingBabySwallow::new();
// bar1 white.
t.update(c(15.0, 20.1, 14.9, 20.0, 0));
t.update(c(16.0, 16.1, 11.9, 12.0, 1));
t.update(c(11.0, 13.0, 9.9, 10.0, 2));
assert_eq!(t.update(c(14.0, 14.1, 8.9, 9.0, 3)), Some(0.0));
}
#[test]
fn first_bar_zero_range_yields_zero() {
let mut t = ConcealingBabySwallow::new();
// bar1 zero range -> not a marubozu.
t.update(c(15.0, 15.0, 15.0, 15.0, 0));
t.update(c(16.0, 16.1, 11.9, 12.0, 1));
t.update(c(11.0, 13.0, 9.9, 10.0, 2));
assert_eq!(t.update(c(14.0, 14.1, 8.9, 9.0, 3)), Some(0.0));
}
#[test]
fn second_bar_not_marubozu_yields_zero() {
let mut t = ConcealingBabySwallow::new();
t.update(c(20.0, 20.1, 14.9, 15.0, 0));
// bar2 white.
t.update(c(12.0, 16.1, 11.9, 16.0, 1));
t.update(c(11.0, 13.0, 9.9, 10.0, 2));
assert_eq!(t.update(c(14.0, 14.1, 8.9, 9.0, 3)), Some(0.0));
}
#[test]
fn third_bar_not_black_yields_zero() {
let mut t = ConcealingBabySwallow::new();
t.update(c(20.0, 20.1, 14.9, 15.0, 0));
t.update(c(16.0, 16.1, 11.9, 12.0, 1));
// bar3 white.
t.update(c(11.0, 13.0, 9.9, 12.5, 2));
assert_eq!(t.update(c(14.0, 14.1, 8.9, 9.0, 3)), Some(0.0));
}
#[test]
fn third_bar_no_gap_yields_zero() {
let mut t = ConcealingBabySwallow::new();
t.update(c(20.0, 20.1, 14.9, 15.0, 0));
t.update(c(16.0, 16.1, 11.9, 12.0, 1));
// bar3 black but opens at/above bar2's close -> no downside gap.
t.update(c(12.5, 13.0, 9.9, 10.0, 2));
assert_eq!(t.update(c(14.0, 14.1, 8.9, 9.0, 3)), Some(0.0));
}
#[test]
fn third_bar_no_upper_shadow_yields_zero() {
let mut t = ConcealingBabySwallow::new();
t.update(c(20.0, 20.1, 14.9, 15.0, 0));
t.update(c(16.0, 16.1, 11.9, 12.0, 1));
// bar3 black, gaps down, but its high does not reach into bar2's body.
t.update(c(11.0, 11.5, 9.9, 10.0, 2));
assert_eq!(t.update(c(14.0, 14.1, 8.9, 9.0, 3)), Some(0.0));
}
#[test]
fn fourth_bar_not_black_yields_zero() {
let mut t = ConcealingBabySwallow::new();
t.update(c(20.0, 20.1, 14.9, 15.0, 0));
t.update(c(16.0, 16.1, 11.9, 12.0, 1));
t.update(c(11.0, 13.0, 9.9, 10.0, 2));
// bar4 white.
assert_eq!(t.update(c(14.0, 14.1, 8.9, 14.05, 3)), Some(0.0));
}
#[test]
fn fourth_bar_not_engulfing_yields_zero() {
let mut t = ConcealingBabySwallow::new();
t.update(c(20.0, 20.1, 14.9, 15.0, 0));
t.update(c(16.0, 16.1, 11.9, 12.0, 1));
t.update(c(11.0, 13.0, 9.9, 10.0, 2));
// bar4 black but does not engulf bar3's high.
assert_eq!(t.update(c(12.5, 12.6, 8.9, 9.0, 3)), Some(0.0));
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..40)
.map(|i| {
let base = 200.0 - i as f64;
c(base, base + 0.05, base - 5.0, base - 5.0, i)
})
.collect();
let mut a = ConcealingBabySwallow::new();
let mut b = ConcealingBabySwallow::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut t = ConcealingBabySwallow::new();
t.update(c(20.0, 20.1, 14.9, 15.0, 0));
t.update(c(16.0, 16.1, 11.9, 12.0, 1));
t.update(c(11.0, 13.0, 9.9, 10.0, 2));
t.update(c(14.0, 14.1, 8.9, 9.0, 3));
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));
}
}
@@ -0,0 +1,245 @@
//! Counterattack candlestick pattern.
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Counterattack — a 2-bar reversal where the second bar storms back to close
/// right where the first bar closed. A long candle runs with the trend, then an
/// opposite-coloured long candle opens far in the trend direction and rallies (or
/// sells off) all the way back to the prior close — the two closes meeting forms
/// the "counterattack line".
///
/// ```text
/// long bodies = |close open| >= 0.5 * (high low) (both bars)
/// equal closes = |close2 close1| <= tol * mean(range1, range2)
/// bullish (+1.0): bar1 black (down), bar2 white (up), equal closes
/// bearish (1.0): bar1 white (up), bar2 black (down), equal closes
/// ```
///
/// Output is `+1.0` bullish, `1.0` bearish, and `0.0` when the bodies are short,
/// the colours match, or the closes are not level. The first bar always returns
/// `0.0` because the two-bar window is not yet filled. `equal_tolerance` defaults
/// to `0.05` (TA-Lib's `CDLCOUNTERATTACK` "equal" factor — 5 % of the mean bar
/// range) and must lie in `[0, 1)`. The body-length test uses a fixed half-range
/// fraction rather than TA-Lib's rolling body average, matching the geometric
/// house style of this pattern family. 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::{Candle, Counterattack, Indicator};
///
/// let mut indicator = Counterattack::new();
/// // Bullish: a long black bar, then a long white bar closing at the same level.
/// indicator.update(Candle::new(20.0, 20.1, 14.9, 15.0, 1.0, 0).unwrap());
/// let out = indicator
/// .update(Candle::new(10.0, 15.1, 9.9, 15.0, 1.0, 1).unwrap());
/// assert_eq!(out, Some(1.0));
/// ```
#[derive(Debug, Clone)]
pub struct Counterattack {
equal_tolerance: f64,
prev: Option<Candle>,
has_emitted: bool,
}
impl Default for Counterattack {
fn default() -> Self {
Self::new()
}
}
impl Counterattack {
/// Construct a Counterattack detector with the default 5 % equal-close tolerance.
pub const fn new() -> Self {
Self {
equal_tolerance: 0.05,
prev: None,
has_emitted: false,
}
}
/// Construct a Counterattack detector with a custom equal-close tolerance.
///
/// `equal_tolerance` is the fraction of the mean bar range within which the
/// two closes must agree and must lie in `[0, 1)`.
pub fn with_tolerance(equal_tolerance: f64) -> Result<Self> {
if !(0.0..1.0).contains(&equal_tolerance) {
return Err(Error::InvalidPeriod {
message: "counterattack equal tolerance must lie in [0, 1)",
});
}
Ok(Self {
equal_tolerance,
prev: None,
has_emitted: false,
})
}
/// Configured equal-close tolerance.
pub fn equal_tolerance(&self) -> f64 {
self.equal_tolerance
}
}
impl Indicator for Counterattack {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
self.has_emitted = true;
let prev = self.prev;
self.prev = Some(candle);
let Some(bar1) = prev else {
return Some(0.0);
};
let range1 = bar1.high - bar1.low;
let range2 = candle.high - candle.low;
let body1 = bar1.close - bar1.open;
let body2 = candle.close - candle.open;
let long1 = body1.abs() >= 0.5 * range1;
let long2 = body2.abs() >= 0.5 * range2;
let tol = self.equal_tolerance * 0.5 * (range1 + range2);
let equal_close = (candle.close - bar1.close).abs() <= tol;
if !(long1 && long2 && equal_close) {
return Some(0.0);
}
// Bullish: a long black bar met by a long white bar closing level.
if body1 < 0.0 && body2 > 0.0 {
return Some(1.0);
}
// Bearish: a long white bar met by a long black bar closing level.
if body1 > 0.0 && body2 < 0.0 {
return Some(-1.0);
}
Some(0.0)
}
fn reset(&mut self) {
self.prev = None;
self.has_emitted = false;
}
fn warmup_period(&self) -> usize {
2
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"Counterattack"
}
}
#[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!(Counterattack::with_tolerance(-0.01).is_err());
assert!(Counterattack::with_tolerance(1.0).is_err());
}
#[test]
fn accepts_valid_tolerance() {
let t = Counterattack::with_tolerance(0.0).unwrap();
assert!((t.equal_tolerance() - 0.0).abs() < 1e-12);
}
#[test]
fn accessors_and_metadata() {
let t = Counterattack::default();
assert_eq!(t.name(), "Counterattack");
assert_eq!(t.warmup_period(), 2);
assert!(!t.is_ready());
assert!((t.equal_tolerance() - 0.05).abs() < 1e-12);
}
#[test]
fn bullish_counterattack_is_plus_one() {
let mut t = Counterattack::new();
assert_eq!(t.update(c(20.0, 20.1, 14.9, 15.0, 0)), Some(0.0));
assert_eq!(t.update(c(10.0, 15.1, 9.9, 15.0, 1)), Some(1.0));
}
#[test]
fn bearish_counterattack_is_minus_one() {
let mut t = Counterattack::new();
assert_eq!(t.update(c(15.0, 20.1, 14.9, 20.0, 0)), Some(0.0));
assert_eq!(t.update(c(25.0, 25.1, 19.9, 20.0, 1)), Some(-1.0));
}
#[test]
fn unequal_close_yields_zero() {
let mut t = Counterattack::new();
t.update(c(20.0, 20.1, 14.9, 15.0, 0));
// Second close at 17.0 is far from the first close (15.0) -> not level.
assert_eq!(t.update(c(10.0, 17.1, 9.9, 17.0, 1)), Some(0.0));
}
#[test]
fn same_color_yields_zero() {
let mut t = Counterattack::new();
// Both bars black -> not opposite colours.
t.update(c(20.0, 20.1, 14.9, 15.0, 0));
assert_eq!(t.update(c(20.0, 20.1, 14.9, 15.0, 1)), Some(0.0));
}
#[test]
fn short_body_yields_zero() {
let mut t = Counterattack::new();
// Second bar has a tiny body relative to its range.
t.update(c(20.0, 20.1, 14.9, 15.0, 0));
assert_eq!(t.update(c(14.8, 20.0, 9.9, 15.2, 1)), Some(0.0));
}
#[test]
fn first_bar_returns_zero() {
let mut t = Counterattack::new();
assert_eq!(t.update(c(20.0, 20.1, 14.9, 15.0, 0)), Some(0.0));
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..40)
.map(|i| {
let base = 100.0 + i as f64;
c(base, base + 2.0, base - 2.0, base + 1.5, i)
})
.collect();
let mut a = Counterattack::new();
let mut b = Counterattack::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut t = Counterattack::new();
t.update(c(20.0, 20.1, 14.9, 15.0, 0));
t.update(c(10.0, 15.1, 9.9, 15.0, 1));
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));
}
}
@@ -0,0 +1,163 @@
//! Cumulative Volume Index — running total of volume-normalised net advancing volume.
use crate::cross_section::CrossSection;
use crate::traits::Indicator;
/// Cumulative Volume Index (CVI) — the running total of *volume-normalised* net
/// advancing volume across a universe.
///
/// On each [`CrossSection`] tick the increment is `(advancing volume - declining
/// volume) / total volume`: the share of the tick's total volume that flowed,
/// net, into advancing issues. The index accumulates this share over time. Where
/// the raw [`AdVolumeLine`](crate::AdVolumeLine) sums *absolute* net volume — and
/// so drifts with secular growth in trading activity — the CVI normalises each
/// tick by its own total volume, so a one-share-net day in a thin market counts
/// the same as in a heavy one. This keeps the index comparable across regimes of
/// very different volume.
///
/// When a tick has zero total volume the net is necessarily zero too, so the
/// increment is zero and the index is unchanged (the divisor is floored to the
/// smallest positive `f64` purely to keep the division defined).
///
/// `Input = CrossSection`, `Output = f64`, `warmup_period == 1`.
///
/// # Example
///
/// ```
/// use wickra_core::{CrossSection, CumulativeVolumeIndex, Indicator, Member};
///
/// let mut cvi = CumulativeVolumeIndex::new();
/// // adv vol 150, dec vol 50, total 200 -> (150 - 50) / 200 = 0.5.
/// 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!(cvi.update(tick), Some(0.5));
/// ```
#[derive(Debug, Clone, Default)]
pub struct CumulativeVolumeIndex {
index: f64,
has_emitted: bool,
}
impl CumulativeVolumeIndex {
/// Construct a new Cumulative Volume Index indicator.
#[must_use]
pub const fn new() -> Self {
Self {
index: 0.0,
has_emitted: false,
}
}
}
impl Indicator for CumulativeVolumeIndex {
type Input = CrossSection;
type Output = f64;
fn update(&mut self, section: CrossSection) -> Option<f64> {
let net = section.advancing_volume() - section.declining_volume();
let total = section.total_volume().max(f64::MIN_POSITIVE);
self.index += net / total;
self.has_emitted = true;
Some(self.index)
}
fn reset(&mut self) {
self.index = 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 {
"CumulativeVolumeIndex"
}
}
#[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 cvi = CumulativeVolumeIndex::new();
assert_eq!(cvi.name(), "CumulativeVolumeIndex");
assert_eq!(cvi.warmup_period(), 1);
assert!(!cvi.is_ready());
}
#[test]
fn first_tick_emits_normalised_net() {
let mut cvi = CumulativeVolumeIndex::new();
assert_eq!(cvi.update(tick(&[(1.0, 150.0), (-1.0, 50.0)])), Some(0.5));
assert!(cvi.is_ready());
}
#[test]
fn index_accumulates_normalised_shares() {
let mut cvi = CumulativeVolumeIndex::new();
assert_eq!(cvi.update(tick(&[(1.0, 150.0), (-1.0, 50.0)])), Some(0.5));
// adv 60, dec 60, total 120 -> net 0 -> index unchanged.
assert_eq!(cvi.update(tick(&[(1.0, 60.0), (-1.0, 60.0)])), Some(0.5));
}
#[test]
fn zero_total_volume_leaves_index_unchanged() {
let mut cvi = CumulativeVolumeIndex::new();
cvi.update(tick(&[(1.0, 150.0), (-1.0, 50.0)]));
// A tick with no volume at all: net 0 / floored divisor -> 0 increment.
assert_eq!(cvi.update(tick(&[(0.0, 0.0)])), Some(0.5));
}
#[test]
fn reset_clears_state() {
let mut cvi = CumulativeVolumeIndex::new();
cvi.update(tick(&[(1.0, 150.0), (-1.0, 50.0)]));
assert!(cvi.is_ready());
cvi.reset();
assert!(!cvi.is_ready());
assert_eq!(cvi.update(tick(&[(1.0, 100.0)])), Some(1.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(&[(0.0, 0.0)]),
];
let mut a = CumulativeVolumeIndex::new();
let mut b = CumulativeVolumeIndex::new();
assert_eq!(
a.batch(&sections),
sections
.iter()
.map(|s| b.update(s.clone()))
.collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,258 @@
//! Depth Slope — how fast resting liquidity accumulates away from the mid.
use crate::microstructure::{Level, OrderBook};
use crate::traits::Indicator;
/// Ordinary-least-squares slope of cumulative resting size against distance
/// from the mid, over the levels of one book side.
///
/// `signed_distance` is `+1.0` for the ask side (price above the mid) and
/// `1.0` for the bid side (price below the mid), so the regressor `x` —
/// distance from the mid — is non-negative on both sides. The response `y` is
/// the cumulative size walking outward from the touch. Returns `0.0` for a
/// degenerate fit where every level sits at the same distance (zero variance in
/// `x`).
fn cumulative_slope(levels: &[Level], mid: f64, signed_distance: f64) -> f64 {
let count = levels.len() as f64;
let mut cumulative = 0.0;
let mut sum_x = 0.0;
let mut sum_y = 0.0;
let mut sum_xy = 0.0;
let mut sum_xx = 0.0;
for level in levels {
let x = signed_distance * (level.price - mid);
cumulative += level.size;
sum_x += x;
sum_y += cumulative;
sum_xy += x * cumulative;
sum_xx += x * x;
}
let denom = count * sum_xx - sum_x * sum_x;
if denom == 0.0 {
return 0.0;
}
(count * sum_xy - sum_x * sum_y) / denom
}
/// Depth Slope — the average rate at which cumulative resting size grows with
/// distance from the mid, across the bid and ask sides of the book.
///
/// For each side the indicator runs an ordinary-least-squares regression of
/// cumulative size (walking outward from the touch) on the level's distance
/// from the mid, then reports the mean of the two slopes:
///
/// ```text
/// slope_side = OLS slope of (|priceᵢ mid|, Σ_{j≤i} sizeⱼ)
/// depthSlope = (slope_bid + slope_ask) / 2
/// ```
///
/// Because the response is *cumulative* size it never decreases with distance,
/// so the slope is non-negative: it is a magnitude, not a direction. A large
/// slope means cumulative liquidity builds quickly away from the touch — a deep
/// book that absorbs large orders with little walking; a small slope is a thin,
/// shallow book. A book whose size is concentrated at the touch and thins out
/// behind it (a fragile book) reads a *smaller* slope than one of equal total
/// depth that thickens with distance.
///
/// A side with fewer than two levels carries no slope, so the indicator returns
/// `0.0` whenever either side has fewer than two levels (including an empty
/// book).
///
/// `Input = OrderBook`, `Output = f64`. Stateless; ready after the first
/// snapshot.
///
/// # Example
///
/// ```
/// use wickra_core::{DepthSlope, Indicator, Level, OrderBook};
///
/// // Both sides thicken linearly away from the mid (sizes 1, 2, 3 …).
/// let book = OrderBook::new(
/// vec![Level::new(99.0, 1.0).unwrap(), Level::new(98.0, 2.0).unwrap()],
/// vec![Level::new(101.0, 1.0).unwrap(), Level::new(102.0, 2.0).unwrap()],
/// )
/// .unwrap();
/// let mut ds = DepthSlope::new();
/// assert!(ds.update(book).unwrap() > 0.0);
/// ```
#[derive(Debug, Clone, Default)]
pub struct DepthSlope {
has_emitted: bool,
}
impl DepthSlope {
/// Construct a new depth-slope indicator.
pub const fn new() -> Self {
Self { has_emitted: false }
}
}
impl Indicator for DepthSlope {
type Input = OrderBook;
type Output = f64;
fn update(&mut self, book: OrderBook) -> Option<f64> {
self.has_emitted = true;
let Some(mid) = book.mid() else {
return Some(0.0);
};
if book.bids.len() < 2 || book.asks.len() < 2 {
return Some(0.0);
}
let bid_slope = cumulative_slope(&book.bids, mid, -1.0);
let ask_slope = cumulative_slope(&book.asks, mid, 1.0);
Some(f64::midpoint(bid_slope, ask_slope))
}
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 {
"DepthSlope"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
fn book(bids: &[(f64, f64)], asks: &[(f64, f64)]) -> OrderBook {
let to_levels = |xs: &[(f64, f64)]| {
xs.iter()
.map(|&(p, s)| Level::new(p, s).unwrap())
.collect::<Vec<_>>()
};
OrderBook::new(to_levels(bids), to_levels(asks)).unwrap()
}
#[test]
fn accessors_and_metadata() {
let ds = DepthSlope::new();
assert_eq!(ds.name(), "DepthSlope");
assert_eq!(ds.warmup_period(), 1);
assert!(!ds.is_ready());
}
#[test]
fn thickening_book_has_positive_slope() {
let mut ds = DepthSlope::new();
let out = ds
.update(book(
&[(99.0, 1.0), (98.0, 2.0), (97.0, 3.0)],
&[(101.0, 1.0), (102.0, 2.0), (103.0, 3.0)],
))
.unwrap();
assert!(out > 0.0);
assert!(ds.is_ready());
}
#[test]
fn front_loaded_book_has_smaller_slope_than_back_loaded() {
// Same total depth (6 per side), but one book thickens away from the
// touch and the other thins. Cumulative slope is non-negative for both;
// the back-loaded book accumulates faster, so its slope is larger.
let mut back = DepthSlope::new();
let back_slope = back
.update(book(
&[(99.0, 1.0), (98.0, 2.0), (97.0, 3.0)],
&[(101.0, 1.0), (102.0, 2.0), (103.0, 3.0)],
))
.unwrap();
let mut front = DepthSlope::new();
let front_slope = front
.update(book(
&[(99.0, 3.0), (98.0, 2.0), (97.0, 1.0)],
&[(101.0, 3.0), (102.0, 2.0), (103.0, 1.0)],
))
.unwrap();
assert!(front_slope >= 0.0);
assert!(back_slope > front_slope);
}
#[test]
fn known_slope_value() {
// Symmetric book, each side: distances 1, 2; cumulative sizes 1, 3.
// OLS slope of (1->1, 2->3) = 2. Mean of two equal sides = 2.
let mut ds = DepthSlope::new();
let out = ds
.update(book(
&[(99.0, 1.0), (98.0, 2.0)],
&[(101.0, 1.0), (102.0, 2.0)],
))
.unwrap();
assert!((out - 2.0).abs() < 1e-9);
}
#[test]
fn single_level_side_is_zero() {
let mut ds = DepthSlope::new();
// Bid side has only one level -> no slope -> 0.
assert_eq!(
ds.update(book(&[(100.0, 1.0)], &[(101.0, 1.0), (102.0, 1.0)])),
Some(0.0)
);
}
#[test]
fn empty_book_is_zero() {
let mut ds = DepthSlope::new();
assert_eq!(
ds.update(OrderBook::new_unchecked(vec![], vec![])),
Some(0.0)
);
}
#[test]
fn degenerate_distance_slope_is_zero() {
// Two levels at the same distance from mid carry zero x-variance.
let levels = [
Level::new_unchecked(100.0, 1.0),
Level::new_unchecked(100.0, 2.0),
];
assert_eq!(cumulative_slope(&levels, 100.0, 1.0), 0.0);
}
#[test]
fn batch_equals_streaming() {
let books: Vec<OrderBook> = (0..20)
.map(|i| {
let extra = f64::from(i % 4);
book(
&[(99.0, 1.0 + extra), (98.0, 2.0)],
&[(101.0, 1.0), (102.0, 2.0 + extra)],
)
})
.collect();
let mut a = DepthSlope::new();
let mut b = DepthSlope::new();
assert_eq!(
a.batch(&books),
books
.iter()
.map(|x| b.update(x.clone()))
.collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut ds = DepthSlope::new();
ds.update(book(
&[(99.0, 1.0), (98.0, 2.0)],
&[(101.0, 1.0), (102.0, 2.0)],
));
assert!(ds.is_ready());
ds.reset();
assert!(!ds.is_ready());
}
}
@@ -0,0 +1,235 @@
//! Gatev distance (sum of squared deviations) between two normalised series.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Sum of squared deviations between two price series, normalised to a common
/// start — the classic Gatev et al. pairs-selection distance.
///
/// Each `update` takes one `(a, b)` price pair. Over the trailing window of
/// `period` pairs each series is rebased to `1` at the window's first bar and
/// the squared gap between the two normalised paths is summed:
///
/// ```text
/// ãᵢ = aᵢ / a_first b̃ᵢ = bᵢ / b_first
/// SSD = Σ (ãᵢ b̃ᵢ)²
/// ```
///
/// Rebasing puts the two series on the same scale (both start at `1`), so the
/// distance measures how far their *relative* paths drift apart. A **small**
/// SSD means the two assets track each other tightly — the screen Gatev,
/// Goetzmann and Rouwenhorst use to pick tradeable pairs; a large SSD means
/// they have decoupled. The output is always `≥ 0`. If either series is `0` at
/// the start of the window the normalisation is undefined and the indicator
/// returns `0`.
///
/// Each `update` is `O(period)`, bounded by the fixed window.
///
/// # Example
///
/// ```
/// use wickra_core::{DistanceSsd, Indicator};
///
/// let mut d = DistanceSsd::new(20).unwrap();
/// let mut last = None;
/// for t in 0..40 {
/// let base = 100.0 + f64::from(t);
/// // Two near-identical paths ⇒ tiny distance.
/// last = d.update((base, base * 1.0001));
/// }
/// assert!(last.unwrap() < 1e-3);
/// ```
#[derive(Debug, Clone)]
pub struct DistanceSsd {
period: usize,
window: VecDeque<(f64, f64)>,
}
impl DistanceSsd {
/// Construct a new Gatev distance estimator.
///
/// # Errors
/// Returns [`Error::InvalidPeriod`] if `period < 2` — a distance needs at
/// least two points.
pub fn new(period: usize) -> Result<Self> {
if period < 2 {
return Err(Error::InvalidPeriod {
message: "distance SSD needs period >= 2",
});
}
Ok(Self {
period,
window: VecDeque::with_capacity(period),
})
}
/// Configured look-back window.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for DistanceSsd {
type Input = (f64, f64);
type Output = f64;
fn update(&mut self, input: (f64, f64)) -> Option<f64> {
if self.window.len() == self.period {
self.window.pop_front();
}
self.window.push_back(input);
if self.window.len() < self.period {
return None;
}
let &(a_first, b_first) = self.window.front().expect("window is full");
if a_first == 0.0 || b_first == 0.0 {
// Cannot rebase a series that starts at zero.
return Some(0.0);
}
let ssd = self
.window
.iter()
.map(|&(a, b)| {
let gap = a / a_first - b / b_first;
gap * gap
})
.sum();
Some(ssd)
}
fn reset(&mut self) {
self.window.clear();
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.window.len() == self.period
}
fn name(&self) -> &'static str {
"DistanceSsd"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_period_below_two() {
assert!(DistanceSsd::new(1).is_err());
assert!(DistanceSsd::new(2).is_ok());
}
#[test]
fn accessors_and_metadata() {
let d = DistanceSsd::new(20).unwrap();
assert_eq!(d.period(), 20);
assert_eq!(d.warmup_period(), 20);
assert_eq!(d.name(), "DistanceSsd");
assert!(!d.is_ready());
}
#[test]
fn warmup_returns_none() {
let mut d = DistanceSsd::new(3).unwrap();
assert_eq!(d.update((1.0, 1.0)), None);
assert_eq!(d.update((2.0, 2.0)), None);
assert!(d.update((3.0, 3.0)).is_some());
assert!(d.is_ready());
}
#[test]
fn identical_normalised_paths_have_zero_distance() {
// b = 2·a ⇒ both rebase to the same path ⇒ SSD = 0.
let pairs: Vec<(f64, f64)> = (0..20)
.map(|t| {
let a = 100.0 + f64::from(t);
(a, 2.0 * a)
})
.collect();
let last = DistanceSsd::new(10)
.unwrap()
.batch(&pairs)
.into_iter()
.flatten()
.last()
.unwrap();
assert_relative_eq!(last, 0.0, epsilon = 1e-12);
}
#[test]
fn diverging_paths_have_positive_distance() {
let pairs: Vec<(f64, f64)> = (0..20)
.map(|t| (100.0 + f64::from(t), 100.0 + 3.0 * f64::from(t)))
.collect();
let last = DistanceSsd::new(10)
.unwrap()
.batch(&pairs)
.into_iter()
.flatten()
.last()
.unwrap();
assert!(last > 0.0, "ssd {last}");
}
#[test]
fn hand_computed_value() {
// Window of three pairs, a_first = b_first = 1:
// (1,1) → 0; (2,4) → (24)² = 4; (3,9) → (39)² = 36 ⇒ SSD = 40.
let pairs = [(1.0, 1.0), (2.0, 4.0), (3.0, 9.0)];
let last = DistanceSsd::new(3)
.unwrap()
.batch(&pairs)
.into_iter()
.flatten()
.last()
.unwrap();
assert_relative_eq!(last, 40.0, epsilon = 1e-12);
}
#[test]
fn zero_start_returns_zero() {
// First bar of the window has a = 0 ⇒ rebasing undefined ⇒ 0.
let pairs = [(0.0, 1.0), (2.0, 2.0), (3.0, 3.0)];
let last = DistanceSsd::new(3)
.unwrap()
.batch(&pairs)
.into_iter()
.flatten()
.last()
.unwrap();
assert_eq!(last, 0.0);
}
#[test]
fn reset_clears_state() {
let mut d = DistanceSsd::new(4).unwrap();
d.batch(&[(1.0, 1.0), (2.0, 2.0), (3.0, 4.0), (4.0, 5.0), (5.0, 6.0)]);
assert!(d.is_ready());
d.reset();
assert!(!d.is_ready());
assert_eq!(d.update((1.0, 1.0)), None);
}
#[test]
fn batch_equals_streaming() {
let pairs: Vec<(f64, f64)> = (0..60)
.map(|t| {
let a = 100.0 + f64::from(t);
(a, 100.0 + 1.2 * f64::from(t) + (f64::from(t) * 0.5).sin())
})
.collect();
let batch = DistanceSsd::new(15).unwrap().batch(&pairs);
let mut d = DistanceSsd::new(15).unwrap();
let streamed: Vec<_> = pairs.iter().map(|p| d.update(*p)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,213 @@
//! Doji Star candlestick pattern.
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Doji Star — a 2-bar reversal warning. A long trending body is followed by a
/// doji whose tiny body gaps away in the direction of the trend, the indecision
/// hinting the move is about to turn.
///
/// ```text
/// long body = |close open| >= 0.5 * (high low) (bar1)
/// doji = |close open| <= 0.1 * (high low) (bar2)
/// bullish (+1.0): bar1 black, doji body gaps DOWN below it (max(o2,c2) < close1)
/// bearish (1.0): bar1 white, doji body gaps UP above it (min(o2,c2) > close1)
/// ```
///
/// Output is `+1.0` (bullish star, after a black bar) or `1.0` (bearish star,
/// after a white bar) when the pattern completes, and `0.0` otherwise. The first
/// bar always returns `0.0` because the two-bar window is not yet filled. Doji
/// thresholds follow the geometric house style (fixed half-range body for the
/// long bar, tenth-range body for the doji) rather than TA-Lib's rolling
/// averages. 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::{Candle, DojiStar, Indicator};
///
/// let mut indicator = DojiStar::new();
/// // Long black bar, then a doji gapping down -> bullish star.
/// indicator.update(Candle::new(20.0, 20.2, 14.8, 15.0, 1.0, 0).unwrap());
/// let out = indicator
/// .update(Candle::new(13.0, 13.1, 12.9, 13.0, 1.0, 1).unwrap());
/// assert_eq!(out, Some(1.0));
/// ```
#[derive(Debug, Clone, Default)]
pub struct DojiStar {
prev: Option<Candle>,
has_emitted: bool,
}
impl DojiStar {
/// Construct a new Doji Star detector.
pub const fn new() -> Self {
Self {
prev: None,
has_emitted: false,
}
}
}
impl Indicator for DojiStar {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
self.has_emitted = true;
let prev = self.prev;
self.prev = Some(candle);
let Some(bar1) = prev else {
return Some(0.0);
};
let range1 = bar1.high - bar1.low;
let range2 = candle.high - candle.low;
if range1 <= 0.0 || range2 <= 0.0 {
return Some(0.0);
}
let body1 = bar1.close - bar1.open;
if body1.abs() < 0.5 * range1 {
return Some(0.0);
}
if (candle.close - candle.open).abs() > 0.1 * range2 {
return Some(0.0);
}
let doji_top = candle.open.max(candle.close);
let doji_bottom = candle.open.min(candle.close);
// Bullish: long black bar, doji body gaps down below it.
if body1 < 0.0 && doji_top < bar1.close {
return Some(1.0);
}
// Bearish: long white bar, doji body gaps up above it.
if body1 > 0.0 && doji_bottom > bar1.close {
return Some(-1.0);
}
Some(0.0)
}
fn reset(&mut self) {
self.prev = None;
self.has_emitted = false;
}
fn warmup_period(&self) -> usize {
2
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"DojiStar"
}
}
#[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 = DojiStar::new();
assert_eq!(t.name(), "DojiStar");
assert_eq!(t.warmup_period(), 2);
assert!(!t.is_ready());
}
#[test]
fn bullish_doji_star_is_plus_one() {
let mut t = DojiStar::new();
assert_eq!(t.update(c(20.0, 20.2, 14.8, 15.0, 0)), Some(0.0));
assert_eq!(t.update(c(13.0, 13.1, 12.9, 13.0, 1)), Some(1.0));
}
#[test]
fn bearish_doji_star_is_minus_one() {
let mut t = DojiStar::new();
assert_eq!(t.update(c(15.0, 20.2, 14.8, 20.0, 0)), Some(0.0));
assert_eq!(t.update(c(22.0, 22.1, 21.9, 22.0, 1)), Some(-1.0));
}
#[test]
fn second_bar_not_doji_yields_zero() {
let mut t = DojiStar::new();
t.update(c(20.0, 20.2, 14.8, 15.0, 0));
// Wide body, not a doji.
assert_eq!(t.update(c(13.0, 13.2, 11.0, 11.5, 1)), Some(0.0));
}
#[test]
fn no_gap_yields_zero() {
let mut t = DojiStar::new();
t.update(c(20.0, 20.2, 14.8, 15.0, 0));
// Doji overlaps bar1's body (no gap down).
assert_eq!(t.update(c(16.0, 16.1, 15.9, 16.0, 1)), Some(0.0));
}
#[test]
fn short_first_body_yields_zero() {
let mut t = DojiStar::new();
// First bar body too short to be the "long" leg.
t.update(c(20.0, 24.0, 16.0, 19.5, 0));
assert_eq!(t.update(c(13.0, 13.1, 12.9, 13.0, 1)), Some(0.0));
}
#[test]
fn first_bar_returns_zero() {
let mut t = DojiStar::new();
assert_eq!(t.update(c(20.0, 20.2, 14.8, 15.0, 0)), Some(0.0));
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..40)
.map(|i| {
let base = 100.0 + i as f64;
if i % 2 == 0 {
c(base + 5.0, base + 5.2, base - 0.2, base, i)
} else {
c(base - 3.0, base - 2.9, base - 3.1, base - 3.0, i)
}
})
.collect();
let mut a = DojiStar::new();
let mut b = DojiStar::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut t = DojiStar::new();
t.update(c(20.0, 20.2, 14.8, 15.0, 0));
t.update(c(13.0, 13.1, 12.9, 13.0, 1));
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));
}
#[test]
fn zero_range_yields_zero() {
let mut t = DojiStar::new();
t.update(c(20.0, 20.2, 14.8, 15.0, 0));
// Flat second bar (high == low) -> zero-range guard.
assert_eq!(t.update(c(13.0, 13.0, 13.0, 13.0, 1)), Some(0.0));
}
}
@@ -0,0 +1,210 @@
//! Downside Gap Three Methods candlestick pattern.
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Downside Gap Three Methods — a 3-bar bearish continuation. Two black candles
/// decline with a downside body gap between them, then a white candle opens inside
/// the second body and closes inside the first body, partially filling the gap
/// without erasing the prior decline.
///
/// ```text
/// bar1 black, bar2 black
/// downside body gap: open2 < close1 (bar2's body sits entirely below bar1's)
/// bar3 white, opens within bar2's body and closes within bar1's body
/// ```
///
/// Output is `1.0` when the pattern completes and `0.0` otherwise. Downside Gap
/// Three Methods is a single-direction (bearish-only) continuation, so it never
/// emits `+1.0`; its bullish mirror is [`crate::UpsideGapThreeMethods`]. 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::{Candle, DownsideGapThreeMethods, Indicator};
///
/// let mut indicator = DownsideGapThreeMethods::new();
/// indicator.update(Candle::new(13.0, 13.2, 11.8, 12.0, 1.0, 0).unwrap());
/// indicator.update(Candle::new(11.0, 11.1, 9.8, 10.0, 1.0, 1).unwrap());
/// let out = indicator
/// .update(Candle::new(10.5, 12.6, 10.4, 12.5, 1.0, 2).unwrap());
/// assert_eq!(out, Some(-1.0));
/// ```
#[derive(Debug, Clone, Default)]
pub struct DownsideGapThreeMethods {
c1: Option<Candle>,
c2: Option<Candle>,
has_emitted: bool,
}
impl DownsideGapThreeMethods {
/// Construct a new Downside Gap Three Methods detector.
pub const fn new() -> Self {
Self {
c1: None,
c2: None,
has_emitted: false,
}
}
}
impl Indicator for DownsideGapThreeMethods {
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;
self.c1 = self.c2;
self.c2 = Some(candle);
let (Some(bar1), Some(bar2)) = (bar1, bar2) else {
return Some(0.0);
};
// bar1 and bar2 are both black.
if bar1.close >= bar1.open || bar2.close >= bar2.open {
return Some(0.0);
}
// Downside body gap: bar2's body sits entirely below bar1's.
if bar2.open >= bar1.close {
return Some(0.0);
}
// bar3 is white.
if candle.close <= candle.open {
return Some(0.0);
}
// bar3 opens within bar2's body and closes within bar1's body.
if candle.open > bar2.close
&& candle.open < bar2.open
&& candle.close > bar1.close
&& candle.close < bar1.open
{
return Some(-1.0);
}
Some(0.0)
}
fn reset(&mut self) {
self.c1 = None;
self.c2 = None;
self.has_emitted = false;
}
fn warmup_period(&self) -> usize {
3
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"DownsideGapThreeMethods"
}
}
#[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 = DownsideGapThreeMethods::new();
assert_eq!(t.name(), "DownsideGapThreeMethods");
assert_eq!(t.warmup_period(), 3);
assert!(!t.is_ready());
}
#[test]
fn downside_gap_three_methods_is_minus_one() {
let mut t = DownsideGapThreeMethods::new();
assert_eq!(t.update(c(13.0, 13.2, 11.8, 12.0, 0)), Some(0.0));
assert_eq!(t.update(c(11.0, 11.1, 9.8, 10.0, 1)), Some(0.0));
assert_eq!(t.update(c(10.5, 12.6, 10.4, 12.5, 2)), Some(-1.0));
}
#[test]
fn first_two_bars_return_zero() {
let mut t = DownsideGapThreeMethods::new();
assert_eq!(t.update(c(13.0, 13.2, 11.8, 12.0, 0)), Some(0.0));
assert_eq!(t.update(c(11.0, 11.1, 9.8, 10.0, 1)), Some(0.0));
}
#[test]
fn non_black_first_bars_yield_zero() {
let mut t = DownsideGapThreeMethods::new();
// bar1 is white.
t.update(c(11.0, 13.2, 10.8, 13.0, 0));
t.update(c(11.0, 11.1, 9.8, 10.0, 1));
assert_eq!(t.update(c(10.5, 12.6, 10.4, 12.5, 2)), Some(0.0));
}
#[test]
fn no_gap_yields_zero() {
let mut t = DownsideGapThreeMethods::new();
t.update(c(13.0, 13.2, 11.8, 12.0, 0));
// bar2 opens above bar1's close -> no downside body gap.
t.update(c(12.5, 12.6, 11.4, 11.5, 1));
assert_eq!(t.update(c(11.5, 12.6, 11.4, 12.0, 2)), Some(0.0));
}
#[test]
fn third_bar_not_white_yields_zero() {
let mut t = DownsideGapThreeMethods::new();
t.update(c(13.0, 13.2, 11.8, 12.0, 0));
t.update(c(11.0, 11.1, 9.8, 10.0, 1));
// bar3 black.
assert_eq!(t.update(c(12.5, 12.6, 10.4, 10.5, 2)), Some(0.0));
}
#[test]
fn third_bar_outside_bodies_yields_zero() {
let mut t = DownsideGapThreeMethods::new();
t.update(c(13.0, 13.2, 11.8, 12.0, 0));
t.update(c(11.0, 11.1, 9.8, 10.0, 1));
// bar3 white but closes above bar1's body.
assert_eq!(t.update(c(10.5, 14.0, 10.4, 13.5, 2)), Some(0.0));
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..40)
.map(|i| {
let base = 200.0 - i as f64;
c(base, base + 0.1, base - 5.2, base - 5.0, i)
})
.collect();
let mut a = DownsideGapThreeMethods::new();
let mut b = DownsideGapThreeMethods::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut t = DownsideGapThreeMethods::new();
t.update(c(13.0, 13.2, 11.8, 12.0, 0));
t.update(c(11.0, 11.1, 9.8, 10.0, 1));
t.update(c(10.5, 12.6, 10.4, 12.5, 2));
assert!(t.is_ready());
t.reset();
assert!(!t.is_ready());
assert_eq!(t.update(c(13.0, 13.2, 11.8, 12.0, 0)), Some(0.0));
}
}
@@ -0,0 +1,163 @@
//! Dragonfly Doji candlestick pattern.
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Dragonfly Doji — a single-bar bullish reversal. Open, close, and high sit at
/// the top of the bar while a long lower shadow shows price was driven down hard
/// and then bid all the way back to the open — buyers rejecting the lows.
///
/// ```text
/// range = high low
/// doji = |close open| <= 0.1 * range
/// no upper wick = high max(open, close) <= 0.1 * range
/// long lower = min(open, close) low >= 0.5 * range
/// ```
///
/// Output is `+1.0` when the dragonfly prints and `0.0` otherwise. Dragonfly Doji
/// is a single-direction (bullish-only) shape, so it never emits `1.0`. Body and
/// shadow thresholds follow the geometric house style (fixed fractions of the bar
/// range) rather than TA-Lib's rolling averages. 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, `0.0` no pattern — so it drops straight into
/// a machine-learning feature matrix as a single dimension.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, DragonflyDoji, Indicator};
///
/// let mut indicator = DragonflyDoji::new();
/// // Body at the top, long lower shadow.
/// let candle = Candle::new(10.0, 10.05, 6.0, 10.0, 1.0, 0).unwrap();
/// assert_eq!(indicator.update(candle), Some(1.0));
/// ```
#[derive(Debug, Clone, Default)]
pub struct DragonflyDoji {
has_emitted: bool,
}
impl DragonflyDoji {
/// Construct a new Dragonfly Doji detector.
pub const fn new() -> Self {
Self { has_emitted: false }
}
}
impl Indicator for DragonflyDoji {
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);
}
if (candle.close - candle.open).abs() > 0.1 * range {
return Some(0.0);
}
let upper = candle.high - candle.open.max(candle.close);
let lower = candle.open.min(candle.close) - candle.low;
if upper <= 0.1 * range && lower >= 0.5 * range {
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 {
"DragonflyDoji"
}
}
#[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 = DragonflyDoji::new();
assert_eq!(t.name(), "DragonflyDoji");
assert_eq!(t.warmup_period(), 1);
assert!(!t.is_ready());
}
#[test]
fn dragonfly_is_plus_one() {
let mut t = DragonflyDoji::new();
assert_eq!(t.update(c(10.0, 10.05, 6.0, 10.0, 0)), Some(1.0));
}
#[test]
fn upper_shadow_yields_zero() {
let mut t = DragonflyDoji::new();
// Long upper shadow -> not a dragonfly (this is a gravestone shape).
assert_eq!(t.update(c(10.0, 14.0, 9.95, 10.0, 0)), Some(0.0));
}
#[test]
fn short_lower_shadow_yields_zero() {
let mut t = DragonflyDoji::new();
// Body at the top but the lower shadow is too short.
assert_eq!(t.update(c(10.0, 10.05, 9.6, 10.0, 0)), Some(0.0));
}
#[test]
fn non_doji_yields_zero() {
let mut t = DragonflyDoji::new();
assert_eq!(t.update(c(10.0, 12.0, 6.0, 11.5, 0)), Some(0.0));
}
#[test]
fn zero_range_yields_zero() {
let mut t = DragonflyDoji::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 + 0.05, base - 4.0, base, i)
})
.collect();
let mut a = DragonflyDoji::new();
let mut b = DragonflyDoji::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut t = DragonflyDoji::new();
t.update(c(10.0, 10.05, 6.0, 10.0, 0));
assert!(t.is_ready());
t.reset();
assert!(!t.is_ready());
}
}
+232
View File
@@ -0,0 +1,232 @@
//! Directional Movement Index (DX), Wilder-smoothed.
use crate::error::{Error, Result};
use crate::indicators::adx::directional_movement;
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Wilder's Directional Movement Index (`DX`).
///
/// `DX = 100 · |+DI DI| / (+DI + DI)`, the un-smoothed precursor to
/// [`Adx`](crate::Adx) (which is the Wilder average of `DX`). Both directional
/// indicators are derived from Wilder-smoothed `+DM`, `DM` and true range over
/// `period` bars, so the first value is emitted after `period + 1` candles.
///
/// `DX` ranges over `[0, 100]`: high when one side of the directional system
/// clearly dominates (a strong trend) and near zero when `+DI` and `DI` are
/// balanced (a range). When both directional indicators are zero — a perfectly
/// flat market — the index returns `0`.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, Dx};
///
/// let mut indicator = Dx::new(5).unwrap();
/// let mut last = None;
/// for i in 0..40 {
/// let base = 100.0 + f64::from(i);
/// let candle =
/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
/// last = indicator.update(candle);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct Dx {
period: usize,
prev: Option<Candle>,
plus_dm_seed: f64,
minus_dm_seed: f64,
tr_seed: f64,
seed_count: usize,
plus_dm_smooth: Option<f64>,
minus_dm_smooth: Option<f64>,
tr_smooth: Option<f64>,
}
impl Dx {
/// # 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: None,
plus_dm_seed: 0.0,
minus_dm_seed: 0.0,
tr_seed: 0.0,
seed_count: 0,
plus_dm_smooth: None,
minus_dm_smooth: None,
tr_smooth: None,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for Dx {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
let Some(prev) = self.prev else {
self.prev = Some(candle);
return None;
};
self.prev = Some(candle);
let (plus_dm, minus_dm) = directional_movement(&prev, &candle);
let tr = candle.true_range(Some(prev.close));
let n = self.period as f64;
let (plus_v, minus_v, tr_v) = if let (Some(p), Some(m), Some(t)) =
(self.plus_dm_smooth, self.minus_dm_smooth, self.tr_smooth)
{
let p_new = p - p / n + plus_dm;
let m_new = m - m / n + minus_dm;
let t_new = t - t / n + tr;
self.plus_dm_smooth = Some(p_new);
self.minus_dm_smooth = Some(m_new);
self.tr_smooth = Some(t_new);
(p_new, m_new, t_new)
} else {
self.plus_dm_seed += plus_dm;
self.minus_dm_seed += minus_dm;
self.tr_seed += tr;
self.seed_count += 1;
if self.seed_count < self.period {
return None;
}
self.plus_dm_smooth = Some(self.plus_dm_seed);
self.minus_dm_smooth = Some(self.minus_dm_seed);
self.tr_smooth = Some(self.tr_seed);
(self.plus_dm_seed, self.minus_dm_seed, self.tr_seed)
};
let (plus_di, minus_di) = if tr_v == 0.0 {
(0.0, 0.0)
} else {
(100.0 * plus_v / tr_v, 100.0 * minus_v / tr_v)
};
let di_sum = plus_di + minus_di;
let dx = if di_sum == 0.0 {
0.0
} else {
100.0 * (plus_di - minus_di).abs() / di_sum
};
Some(dx)
}
fn reset(&mut self) {
self.prev = None;
self.plus_dm_seed = 0.0;
self.minus_dm_seed = 0.0;
self.tr_seed = 0.0;
self.seed_count = 0;
self.plus_dm_smooth = None;
self.minus_dm_smooth = None;
self.tr_smooth = None;
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.tr_smooth.is_some()
}
fn name(&self) -> &'static str {
"DX"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn c(h: f64, l: f64, cl: f64) -> Candle {
Candle::new(cl, h, l, cl, 1.0, 0).unwrap()
}
#[test]
fn rejects_zero_period() {
assert!(matches!(Dx::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_report_config() {
let dx = Dx::new(7).unwrap();
assert_eq!(dx.period(), 7);
assert_eq!(dx.name(), "DX");
assert_eq!(dx.warmup_period(), 7);
assert!(!dx.is_ready());
}
#[test]
fn strong_trend_drives_dx_high() {
// A clean uptrend has one-sided directional movement, so DX is large.
let candles: Vec<Candle> = (0..12)
.map(|i| {
let base = 100.0 + f64::from(i) * 2.0;
c(base + 1.0, base - 0.5, base + 0.5)
})
.collect();
let mut dx = Dx::new(3).unwrap();
let out: Vec<Option<f64>> = dx.batch(&candles);
assert_eq!(out[0], None);
assert!(out[3].is_some());
let last = out.into_iter().flatten().last().unwrap();
assert!(last > 50.0 && last <= 100.0);
assert!(dx.is_ready());
}
#[test]
fn flat_market_returns_zero() {
// Both directional indicators collapse to zero -> DX is zero.
let candles: Vec<Candle> = (0..6).map(|_| c(50.0, 50.0, 50.0)).collect();
let mut dx = Dx::new(3).unwrap();
let last = dx.batch(&candles).into_iter().flatten().last().unwrap();
assert_relative_eq!(last, 0.0, epsilon = 1e-12);
}
#[test]
fn balanced_directional_movement_is_low() {
// Alternating up and down bars of equal magnitude keep +DI and -DI close,
// so DX stays well below a trending reading.
let candles: Vec<Candle> = (0..30)
.map(|i| {
let base = if i % 2 == 0 { 100.0 } else { 101.0 };
c(base + 1.0, base - 1.0, base)
})
.collect();
let mut dx = Dx::new(5).unwrap();
let last = dx.batch(&candles).into_iter().flatten().last().unwrap();
assert!((0.0..=100.0).contains(&last));
}
#[test]
fn reset_restores_initial_state() {
let candles: Vec<Candle> = (0..6)
.map(|i| {
let base = 100.0 + f64::from(i) * 2.0;
c(base + 1.0, base - 0.5, base + 0.5)
})
.collect();
let mut dx = Dx::new(3).unwrap();
let _ = dx.batch(&candles);
assert!(dx.is_ready());
dx.reset();
assert!(!dx.is_ready());
assert_eq!(dx.update(candles[0]), None);
}
}
@@ -0,0 +1,157 @@
//! Effective Spread — the realised cost of a single trade in basis points.
use crate::microstructure::TradeQuote;
use crate::traits::Indicator;
/// Effective Spread — twice the signed deviation of an executed trade price
/// from the prevailing mid, expressed in basis points of the mid.
///
/// ```text
/// effectiveSpread = 2 · D · (tradePrice mid) / mid · 10_000 (bps)
/// ```
///
/// where `D` is the aggressor sign (`+1` for a buy, `1` for a sell). The
/// factor of two scales the one-sided deviation up to a full round-trip cost so
/// it is directly comparable to the [quoted spread]: a marketable order that
/// fills exactly at the touch of an otherwise quoted-spread book pays an
/// effective spread equal to the quoted spread. Trades that fill *inside* the
/// spread (price improvement) read below the quoted spread; trades that walk
/// the book read above it.
///
/// A buy printed above the mid (`tradePrice > mid`) and a sell printed below it
/// both yield a positive effective spread — the conventional sign, since the
/// aggressor pays in both cases. A trade printed on the wrong side of the mid
/// for its aggressor flag (a buy below the mid) reads negative, the signature of
/// price improvement or a stale/mislabelled quote.
///
/// `Input = TradeQuote`, `Output = f64`. Stateless; ready after the first
/// trade-quote.
///
/// [quoted spread]: crate::QuotedSpread
///
/// # Example
///
/// ```
/// use wickra_core::{EffectiveSpread, Indicator, Side, Trade, TradeQuote};
///
/// let mut es = EffectiveSpread::new();
/// // Buy filled at 100.05 against a mid of 100.0:
/// // 2 · (+1) · (100.05 100.0) / 100.0 · 10_000 = 10 bps.
/// let trade = Trade::new(100.05, 1.0, Side::Buy, 0).unwrap();
/// let quote = TradeQuote::new(trade, 100.0).unwrap();
/// assert!((es.update(quote).unwrap() - 10.0).abs() < 1e-9);
/// ```
#[derive(Debug, Clone, Default)]
pub struct EffectiveSpread {
has_emitted: bool,
}
impl EffectiveSpread {
/// Construct a new effective-spread indicator.
pub const fn new() -> Self {
Self { has_emitted: false }
}
}
impl Indicator for EffectiveSpread {
type Input = TradeQuote;
type Output = f64;
fn update(&mut self, quote: TradeQuote) -> Option<f64> {
self.has_emitted = true;
let sign = quote.trade.side.sign();
Some(2.0 * sign * (quote.trade.price - quote.mid) / quote.mid * 10_000.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 {
"EffectiveSpread"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::microstructure::{Side, Trade};
use crate::traits::BatchExt;
fn quote(price: f64, side: Side, mid: f64) -> TradeQuote {
TradeQuote::new(Trade::new(price, 1.0, side, 0).unwrap(), mid).unwrap()
}
#[test]
fn accessors_and_metadata() {
let es = EffectiveSpread::new();
assert_eq!(es.name(), "EffectiveSpread");
assert_eq!(es.warmup_period(), 1);
assert!(!es.is_ready());
}
#[test]
fn buy_above_mid_is_positive() {
let mut es = EffectiveSpread::new();
// 2 · (+1) · (100.05 100.0) / 100.0 · 10_000 = 10 bps.
let out = es.update(quote(100.05, Side::Buy, 100.0)).unwrap();
assert!((out - 10.0).abs() < 1e-9);
assert!(es.is_ready());
}
#[test]
fn sell_below_mid_is_positive() {
let mut es = EffectiveSpread::new();
// 2 · (1) · (99.95 100.0) / 100.0 · 10_000 = 10 bps.
let out = es.update(quote(99.95, Side::Sell, 100.0)).unwrap();
assert!((out - 10.0).abs() < 1e-9);
}
#[test]
fn price_improvement_reads_negative() {
let mut es = EffectiveSpread::new();
// A buy filled below the mid: price improvement -> negative.
let out = es.update(quote(99.95, Side::Buy, 100.0)).unwrap();
assert!(out < 0.0);
}
#[test]
fn trade_at_mid_is_zero() {
let mut es = EffectiveSpread::new();
assert_eq!(es.update(quote(100.0, Side::Buy, 100.0)), Some(0.0));
}
#[test]
fn batch_equals_streaming() {
let quotes: Vec<TradeQuote> = (0..20)
.map(|i| {
let side = if i % 2 == 0 { Side::Buy } else { Side::Sell };
let price = 100.0 + f64::from(i % 4) * 0.01;
quote(price, side, 100.0)
})
.collect();
let mut a = EffectiveSpread::new();
let mut b = EffectiveSpread::new();
assert_eq!(
a.batch(&quotes),
quotes.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut es = EffectiveSpread::new();
es.update(quote(100.05, Side::Buy, 100.0));
assert!(es.is_ready());
es.reset();
assert!(!es.is_ready());
}
}
@@ -0,0 +1,259 @@
//! Evening Doji Star candlestick pattern.
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Evening Doji Star — a 3-bar bearish top reversal. A long white bar extends the
/// advance, a doji gaps up above it (the star of indecision), then a black bar
/// gaps back down and closes deep into the first body, confirming the turn.
///
/// ```text
/// long body = |close open| >= 0.5 * (high low)
/// doji = |close open| <= 0.1 * (high low)
/// bar1 white & long
/// bar2 doji, body gaps UP above bar1 body (min(o2,c2) > close1)
/// bar3 black, body gaps DOWN below the doji (max(o3,c3) < min(o2,c2))
/// bar3 closes deep into bar1 body (close3 < close1 penetration·body1)
/// ```
///
/// Output is `1.0` when the pattern completes and `0.0` otherwise. Evening Doji
/// Star is a single-direction (bearish-only) reversal, so it never emits `+1.0`.
/// The first two bars always return `0.0` because the three-bar window is not yet
/// filled. `penetration` is how far into the first body the third bar must close;
/// it defaults to `0.3` (TA-Lib's `CDLEVENINGDOJISTAR` default) and must lie in
/// `[0, 1)`. Body and doji thresholds follow the geometric house style rather than
/// TA-Lib's rolling averages. 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::{Candle, EveningDojiStar, Indicator};
///
/// let mut indicator = EveningDojiStar::new();
/// indicator.update(Candle::new(10.0, 15.1, 9.9, 15.0, 1.0, 0).unwrap());
/// indicator.update(Candle::new(17.0, 17.1, 16.9, 17.0, 1.0, 1).unwrap());
/// let out = indicator
/// .update(Candle::new(16.0, 16.1, 11.9, 12.0, 1.0, 2).unwrap());
/// assert_eq!(out, Some(-1.0));
/// ```
#[derive(Debug, Clone)]
pub struct EveningDojiStar {
penetration: f64,
prev: Option<Candle>,
prev_prev: Option<Candle>,
has_emitted: bool,
}
impl Default for EveningDojiStar {
fn default() -> Self {
Self::new()
}
}
impl EveningDojiStar {
/// Construct an Evening Doji Star detector with the default 0.3 penetration.
pub const fn new() -> Self {
Self {
penetration: 0.3,
prev: None,
prev_prev: None,
has_emitted: false,
}
}
/// Construct an Evening Doji Star detector with a custom penetration fraction.
///
/// `penetration` must lie in `[0, 1)`.
pub fn with_penetration(penetration: f64) -> Result<Self> {
if !(0.0..1.0).contains(&penetration) {
return Err(Error::InvalidPeriod {
message: "evening doji star penetration must lie in [0, 1)",
});
}
Ok(Self {
penetration,
prev: None,
prev_prev: None,
has_emitted: false,
})
}
/// Configured penetration fraction.
pub fn penetration(&self) -> f64 {
self.penetration
}
}
impl Indicator for EveningDojiStar {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
self.has_emitted = true;
let bar1 = self.prev_prev;
let bar2 = self.prev;
self.prev_prev = self.prev;
self.prev = Some(candle);
let (Some(bar1), Some(bar2)) = (bar1, bar2) else {
return Some(0.0);
};
let range1 = bar1.high - bar1.low;
let range2 = bar2.high - bar2.low;
if range1 <= 0.0 || range2 <= 0.0 {
return Some(0.0);
}
let body1 = bar1.close - bar1.open;
if body1 < 0.5 * range1 {
return Some(0.0); // bar1 must be a long white body
}
if (bar2.close - bar2.open).abs() > 0.1 * range2 {
return Some(0.0); // bar2 must be a doji
}
let star_bottom = bar2.open.min(bar2.close);
let bar3_top = candle.open.max(candle.close);
if star_bottom > bar1.close
&& candle.close < candle.open
&& bar3_top < star_bottom
&& candle.close < bar1.close - self.penetration * body1
{
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 {
"EveningDojiStar"
}
}
#[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_penetration() {
assert!(EveningDojiStar::with_penetration(-0.01).is_err());
assert!(EveningDojiStar::with_penetration(1.0).is_err());
}
#[test]
fn accepts_valid_penetration() {
let t = EveningDojiStar::with_penetration(0.5).unwrap();
assert!((t.penetration() - 0.5).abs() < 1e-12);
}
#[test]
fn accessors_and_metadata() {
let t = EveningDojiStar::default();
assert_eq!(t.name(), "EveningDojiStar");
assert_eq!(t.warmup_period(), 3);
assert!(!t.is_ready());
assert!((t.penetration() - 0.3).abs() < 1e-12);
}
#[test]
fn evening_doji_star_is_minus_one() {
let mut t = EveningDojiStar::new();
assert_eq!(t.update(c(10.0, 15.1, 9.9, 15.0, 0)), Some(0.0));
assert_eq!(t.update(c(17.0, 17.1, 16.9, 17.0, 1)), Some(0.0));
assert_eq!(t.update(c(16.0, 16.1, 11.9, 12.0, 2)), Some(-1.0));
}
#[test]
fn middle_not_doji_yields_zero() {
let mut t = EveningDojiStar::new();
t.update(c(10.0, 15.1, 9.9, 15.0, 0));
// Wide-bodied star, not a doji.
t.update(c(16.0, 18.1, 15.9, 18.0, 1));
assert_eq!(t.update(c(16.0, 16.1, 11.9, 12.0, 2)), Some(0.0));
}
#[test]
fn shallow_close_yields_zero() {
let mut t = EveningDojiStar::new();
t.update(c(10.0, 15.1, 9.9, 15.0, 0));
t.update(c(17.0, 17.1, 16.9, 17.0, 1));
// bar3 black but closes at 14.0 -> only 1.0 into the 5.0 body (< 0.3·5).
assert_eq!(t.update(c(16.0, 16.1, 13.9, 14.0, 2)), Some(0.0));
}
#[test]
fn first_two_bars_return_zero() {
let mut t = EveningDojiStar::new();
assert_eq!(t.update(c(10.0, 15.1, 9.9, 15.0, 0)), Some(0.0));
assert_eq!(t.update(c(17.0, 17.1, 16.9, 17.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 + 5.2, base - 0.1, base + 5.0, i)
})
.collect();
let mut a = EveningDojiStar::new();
let mut b = EveningDojiStar::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut t = EveningDojiStar::new();
t.update(c(10.0, 15.1, 9.9, 15.0, 0));
t.update(c(17.0, 17.1, 16.9, 17.0, 1));
t.update(c(16.0, 16.1, 11.9, 12.0, 2));
assert!(t.is_ready());
t.reset();
assert!(!t.is_ready());
assert_eq!(t.update(c(10.0, 15.1, 9.9, 15.0, 0)), Some(0.0));
}
#[test]
fn zero_range_yields_zero() {
let mut t = EveningDojiStar::new();
// Flat first bar (range1 == 0) -> rejected.
t.update(c(10.0, 10.0, 10.0, 10.0, 0));
t.update(c(17.0, 17.1, 16.9, 17.0, 1));
assert_eq!(t.update(c(16.0, 16.1, 11.9, 12.0, 2)), Some(0.0));
}
#[test]
fn short_first_body_yields_zero() {
let mut t = EveningDojiStar::new();
// bar1 has a wide range but a tiny body -> not a long white body.
t.update(c(10.0, 16.0, 9.0, 10.5, 0));
t.update(c(17.0, 17.1, 16.9, 17.0, 1));
assert_eq!(t.update(c(16.0, 16.1, 11.9, 12.0, 2)), Some(0.0));
}
}
@@ -0,0 +1,235 @@
//! Falling Three Methods candlestick pattern.
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Falling Three Methods — a 5-bar bearish continuation. A long black candle is
/// followed by three small bars that drift up but stay inside its range (a brief
/// rest), then a second long black candle closes below the first, resuming the
/// decline.
///
/// ```text
/// long body = |close open| >= 0.5 * (high low)
/// bar1 black & long
/// bar2, bar3, bar4 small bodies, each contained within bar1's high/low range
/// bar5 black, closing below bar1's close
/// ```
///
/// Output is `1.0` when the pattern completes and `0.0` otherwise. Falling Three
/// Methods is a single-direction (bearish-only) continuation, so it never emits
/// `+1.0`. The first four bars always return `0.0` because the five-bar window is
/// not yet filled. Body thresholds follow the geometric house style rather than
/// TA-Lib's rolling averages. 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::{Candle, FallingThreeMethods, Indicator};
///
/// let mut indicator = FallingThreeMethods::new();
/// indicator.update(Candle::new(15.0, 15.1, 9.9, 10.0, 1.0, 0).unwrap());
/// indicator.update(Candle::new(11.0, 12.1, 10.9, 12.0, 1.0, 1).unwrap());
/// indicator.update(Candle::new(11.5, 12.6, 11.4, 12.5, 1.0, 2).unwrap());
/// indicator.update(Candle::new(12.0, 13.1, 11.9, 13.0, 1.0, 3).unwrap());
/// let out = indicator
/// .update(Candle::new(12.5, 12.6, 8.9, 9.0, 1.0, 4).unwrap());
/// assert_eq!(out, Some(-1.0));
/// ```
#[derive(Debug, Clone, Default)]
pub struct FallingThreeMethods {
c1: Option<Candle>,
c2: Option<Candle>,
c3: Option<Candle>,
c4: Option<Candle>,
has_emitted: bool,
}
impl FallingThreeMethods {
/// Construct a new Falling Three Methods detector.
pub const fn new() -> Self {
Self {
c1: None,
c2: None,
c3: None,
c4: None,
has_emitted: false,
}
}
}
impl Indicator for FallingThreeMethods {
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);
};
let range1 = bar1.high - bar1.low;
if range1 <= 0.0 {
return Some(0.0);
}
let body1 = bar1.open - bar1.close;
if body1 < 0.5 * range1 {
return Some(0.0); // bar1 must be a long black body
}
// The three middle bars stay within bar1's range with smaller bodies.
for mid in [bar2, bar3, bar4] {
if (mid.close - mid.open).abs() >= body1 || mid.high > bar1.high || mid.low < bar1.low {
return Some(0.0);
}
}
// bar5 is a black candle closing below bar1's close.
if candle.close < candle.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 {
"FallingThreeMethods"
}
}
#[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 = FallingThreeMethods::new();
assert_eq!(t.name(), "FallingThreeMethods");
assert_eq!(t.warmup_period(), 5);
assert!(!t.is_ready());
}
#[test]
fn falling_three_methods_is_minus_one() {
let mut t = FallingThreeMethods::new();
assert_eq!(t.update(c(15.0, 15.1, 9.9, 10.0, 0)), Some(0.0));
assert_eq!(t.update(c(11.0, 12.1, 10.9, 12.0, 1)), Some(0.0));
assert_eq!(t.update(c(11.5, 12.6, 11.4, 12.5, 2)), Some(0.0));
assert_eq!(t.update(c(12.0, 13.1, 11.9, 13.0, 3)), Some(0.0));
assert_eq!(t.update(c(12.5, 12.6, 8.9, 9.0, 4)), Some(-1.0));
}
#[test]
fn middle_bar_breaks_range_yields_zero() {
let mut t = FallingThreeMethods::new();
t.update(c(15.0, 15.1, 9.9, 10.0, 0));
t.update(c(11.0, 12.1, 10.9, 12.0, 1));
// bar3 pokes below bar1's low.
t.update(c(11.5, 12.6, 9.0, 12.5, 2));
t.update(c(12.0, 13.1, 11.9, 13.0, 3));
assert_eq!(t.update(c(12.5, 12.6, 8.9, 9.0, 4)), Some(0.0));
}
#[test]
fn bar5_not_new_low_yields_zero() {
let mut t = FallingThreeMethods::new();
t.update(c(15.0, 15.1, 9.9, 10.0, 0));
t.update(c(11.0, 12.1, 10.9, 12.0, 1));
t.update(c(11.5, 12.6, 11.4, 12.5, 2));
t.update(c(12.0, 13.1, 11.9, 13.0, 3));
// bar5 black but closes above bar1's close.
assert_eq!(t.update(c(12.5, 12.6, 10.4, 10.5, 4)), Some(0.0));
}
#[test]
fn first_four_bars_return_zero() {
let mut t = FallingThreeMethods::new();
assert_eq!(t.update(c(15.0, 15.1, 9.9, 10.0, 0)), Some(0.0));
assert_eq!(t.update(c(11.0, 12.1, 10.9, 12.0, 1)), Some(0.0));
assert_eq!(t.update(c(11.5, 12.6, 11.4, 12.5, 2)), Some(0.0));
assert_eq!(t.update(c(12.0, 13.1, 11.9, 13.0, 3)), Some(0.0));
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..40)
.map(|i| {
let base = 200.0 - i as f64;
c(base + 5.0, base + 5.1, base - 0.1, base, i)
})
.collect();
let mut a = FallingThreeMethods::new();
let mut b = FallingThreeMethods::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut t = FallingThreeMethods::new();
t.update(c(15.0, 15.1, 9.9, 10.0, 0));
t.update(c(11.0, 12.1, 10.9, 12.0, 1));
t.update(c(11.5, 12.6, 11.4, 12.5, 2));
t.update(c(12.0, 13.1, 11.9, 13.0, 3));
t.update(c(12.5, 12.6, 8.9, 9.0, 4));
assert!(t.is_ready());
t.reset();
assert!(!t.is_ready());
assert_eq!(t.update(c(15.0, 15.1, 9.9, 10.0, 0)), Some(0.0));
}
#[test]
fn zero_range_first_bar_yields_zero() {
let mut t = FallingThreeMethods::new();
// Flat first bar (range1 == 0) -> rejected.
t.update(c(10.0, 10.0, 10.0, 10.0, 0));
t.update(c(11.0, 12.1, 10.9, 12.0, 1));
t.update(c(11.5, 12.6, 11.4, 12.5, 2));
t.update(c(12.0, 13.1, 11.9, 13.0, 3));
assert_eq!(t.update(c(12.5, 12.6, 8.9, 9.0, 4)), Some(0.0));
}
#[test]
fn short_first_body_yields_zero() {
let mut t = FallingThreeMethods::new();
// bar1 has a wide range but a tiny body -> not a long black body.
t.update(c(10.0, 16.0, 9.0, 10.2, 0));
t.update(c(11.0, 12.1, 10.9, 12.0, 1));
t.update(c(11.5, 12.6, 11.4, 12.5, 2));
t.update(c(12.0, 13.1, 11.9, 13.0, 3));
assert_eq!(t.update(c(12.5, 12.6, 8.9, 9.0, 4)), Some(0.0));
}
}
@@ -0,0 +1,259 @@
//! Footprint — buy/sell volume profile per price bucket within a bar.
use std::collections::BTreeMap;
use crate::error::{Error, Result};
use crate::microstructure::Trade;
use crate::traits::Indicator;
/// One price bucket of a [`Footprint`]: the buy- and sell-initiated volume that
/// traded there since the last reset.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct FootprintLevel {
/// Bucket price (the bucket index times the tick size).
pub price: f64,
/// Sell-initiated (bid-hitting) volume traded at this bucket.
pub bid_vol: f64,
/// Buy-initiated (ask-lifting) volume traded at this bucket.
pub ask_vol: f64,
}
/// The full footprint of a bar: one [`FootprintLevel`] per touched price
/// bucket, sorted ascending by price.
#[derive(Debug, Clone, PartialEq, Default)]
pub struct FootprintOutput {
/// Touched price buckets, lowest price first.
pub levels: Vec<FootprintLevel>,
}
/// Footprint — the buy/sell volume profile of a bar, bucketed by price.
///
/// A footprint (a.k.a. bid/ask or volume cluster chart) decomposes the volume
/// traded within a bar across the price levels at which it printed, splitting
/// each level into buy-initiated (ask-lifting) and sell-initiated (bid-hitting)
/// volume. It exposes *where* inside a bar the activity happened and which side
/// was the aggressor there — the basis for absorption, imbalance and
/// point-of-control analysis that a single OHLCV bar hides.
///
/// Each trade is assigned to the price bucket `round(price / tick_size)`; its
/// size is added to that bucket's ask volume for a buy and bid volume for a
/// sell. Every [`update`] returns the complete footprint accumulated since the
/// last [`reset`], as a [`FootprintOutput`] whose `levels` are sorted ascending
/// by price. Call [`reset`] at each bar (or session) boundary to start a fresh
/// footprint.
///
/// `Input = Trade`, `Output = FootprintOutput`. Ready after the first trade.
///
/// [`update`]: crate::Indicator::update
/// [`reset`]: crate::Indicator::reset
///
/// # Example
///
/// ```
/// use wickra_core::{Footprint, Indicator, Side, Trade};
///
/// let mut fp = Footprint::new(1.0).unwrap();
/// fp.update(Trade::new(100.2, 2.0, Side::Buy, 0).unwrap());
/// let out = fp.update(Trade::new(100.7, 3.0, Side::Sell, 1).unwrap()).unwrap();
/// // Two buckets: 100 (ask 2) and 101 (bid 3).
/// assert_eq!(out.levels.len(), 2);
/// assert_eq!(out.levels[0].price, 100.0);
/// assert_eq!(out.levels[0].ask_vol, 2.0);
/// assert_eq!(out.levels[1].price, 101.0);
/// assert_eq!(out.levels[1].bid_vol, 3.0);
/// ```
#[derive(Debug, Clone)]
pub struct Footprint {
tick_size: f64,
// bucket index -> (bid_vol = sell-initiated, ask_vol = buy-initiated).
buckets: BTreeMap<i64, (f64, f64)>,
has_emitted: bool,
}
impl Footprint {
/// Construct a footprint with the given price-bucket `tick_size`.
///
/// # Errors
///
/// Returns [`Error::InvalidTick`] if `tick_size` is not a finite, strictly
/// positive number.
pub fn new(tick_size: f64) -> Result<Self> {
if !tick_size.is_finite() || tick_size <= 0.0 {
return Err(Error::InvalidTick {
message: "footprint tick_size must be finite and positive",
});
}
Ok(Self {
tick_size,
buckets: BTreeMap::new(),
has_emitted: false,
})
}
/// The configured price-bucket size.
pub const fn tick_size(&self) -> f64 {
self.tick_size
}
fn bucket_index(&self, price: f64) -> i64 {
// Float-to-int `as` saturates rather than wrapping, so an extreme
// price/tick ratio clamps to i64::MIN/MAX instead of misbehaving;
// realistic ratios fit comfortably.
#[allow(clippy::cast_possible_truncation)]
{
(price / self.tick_size).round() as i64
}
}
fn snapshot(&self) -> FootprintOutput {
let levels = self
.buckets
.iter()
.map(|(&index, &(bid_vol, ask_vol))| FootprintLevel {
price: index as f64 * self.tick_size,
bid_vol,
ask_vol,
})
.collect();
FootprintOutput { levels }
}
}
impl Indicator for Footprint {
type Input = Trade;
type Output = FootprintOutput;
fn update(&mut self, trade: Trade) -> Option<FootprintOutput> {
self.has_emitted = true;
let index = self.bucket_index(trade.price);
let entry = self.buckets.entry(index).or_insert((0.0, 0.0));
if trade.side.sign() > 0.0 {
entry.1 += trade.size;
} else {
entry.0 += trade.size;
}
Some(self.snapshot())
}
fn reset(&mut self) {
self.buckets.clear();
self.has_emitted = false;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"Footprint"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::microstructure::Side;
use crate::traits::BatchExt;
fn trade(price: f64, size: f64, side: Side) -> Trade {
Trade::new(price, size, side, 0).unwrap()
}
#[test]
fn rejects_bad_tick_size() {
assert!(matches!(
Footprint::new(0.0),
Err(Error::InvalidTick { .. })
));
assert!(matches!(
Footprint::new(-1.0),
Err(Error::InvalidTick { .. })
));
assert!(matches!(
Footprint::new(f64::NAN),
Err(Error::InvalidTick { .. })
));
assert!(Footprint::new(0.5).is_ok());
}
#[test]
fn accessors_and_metadata() {
let fp = Footprint::new(0.25).unwrap();
assert_eq!(fp.name(), "Footprint");
assert_eq!(fp.warmup_period(), 1);
assert_eq!(fp.tick_size(), 0.25);
assert!(!fp.is_ready());
}
#[test]
fn buckets_buy_and_sell_volume() {
let mut fp = Footprint::new(1.0).unwrap();
fp.update(trade(100.2, 2.0, Side::Buy));
fp.update(trade(100.7, 3.0, Side::Sell));
let out = fp.update(trade(100.1, 1.0, Side::Buy)).unwrap();
assert!(fp.is_ready());
// Bucket 100: buy 2 + buy 1 = ask 3, bid 0. Bucket 101: sell 3.
assert_eq!(out.levels.len(), 2);
assert_eq!(out.levels[0].price, 100.0);
assert_eq!(out.levels[0].ask_vol, 3.0);
assert_eq!(out.levels[0].bid_vol, 0.0);
assert_eq!(out.levels[1].price, 101.0);
assert_eq!(out.levels[1].bid_vol, 3.0);
assert_eq!(out.levels[1].ask_vol, 0.0);
}
#[test]
fn levels_sorted_ascending_by_price() {
let mut fp = Footprint::new(1.0).unwrap();
fp.update(trade(103.0, 1.0, Side::Buy));
fp.update(trade(100.0, 1.0, Side::Sell));
let out = fp.update(trade(101.0, 1.0, Side::Buy)).unwrap();
let prices: Vec<f64> = out.levels.iter().map(|l| l.price).collect();
assert_eq!(prices, vec![100.0, 101.0, 103.0]);
}
#[test]
fn sub_tick_prices_share_a_bucket() {
let mut fp = Footprint::new(0.5).unwrap();
// 100.24 and 100.26 both round to bucket 200 (price 100.0)... check:
// 100.24/0.5 = 200.48 -> 200; 100.26/0.5 = 200.52 -> 201. Distinct.
fp.update(trade(100.20, 1.0, Side::Buy)); // 200.4 -> 200 -> price 100.0
let out = fp.update(trade(100.10, 2.0, Side::Buy)).unwrap(); // 200.2 -> 200
assert_eq!(out.levels.len(), 1);
assert_eq!(out.levels[0].price, 100.0);
assert_eq!(out.levels[0].ask_vol, 3.0);
}
#[test]
fn reset_clears_the_footprint() {
let mut fp = Footprint::new(1.0).unwrap();
fp.update(trade(100.0, 5.0, Side::Buy));
assert!(fp.is_ready());
fp.reset();
assert!(!fp.is_ready());
let out = fp.update(trade(200.0, 1.0, Side::Sell)).unwrap();
assert_eq!(out.levels.len(), 1);
assert_eq!(out.levels[0].price, 200.0);
assert_eq!(out.levels[0].bid_vol, 1.0);
}
#[test]
fn batch_equals_streaming() {
let trades: Vec<Trade> = (0..30)
.map(|i| {
let side = if i % 3 == 0 { Side::Sell } else { Side::Buy };
trade(100.0 + f64::from(i % 5), 1.0 + f64::from(i % 4), side)
})
.collect();
let mut a = Footprint::new(1.0).unwrap();
let mut b = Footprint::new(1.0).unwrap();
assert_eq!(
a.batch(&trades),
trades.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,137 @@
//! Funding Basis — the perpetual mark's relative premium to the spot index.
use crate::derivatives::DerivativesTick;
use crate::traits::Indicator;
/// Funding Basis — the relative basis between the perpetual mark price and the
/// spot index it tracks.
///
/// ```text
/// basis = (markPrice indexPrice) / indexPrice
/// ```
///
/// The basis is the spread that the funding mechanism continuously pulls toward
/// zero: a positive basis (perpetual above spot) goes hand in hand with positive
/// funding (longs pay), a negative basis with negative funding. Reading the
/// instantaneous basis alongside the [funding rate] separates a genuine premium
/// from a stale-funding artefact and sizes the carry available to a cash-and-carry
/// or basis-arbitrage trade. The output is a fraction (e.g. `0.001` = 10 bps);
/// multiply by `10_000` for basis points.
///
/// `Input = DerivativesTick`, `Output = f64`. Stateless; ready after the first
/// tick.
///
/// [funding rate]: crate::FundingRate
///
/// # Example
///
/// ```
/// use wickra_core::{DerivativesTick, FundingBasis, Indicator};
///
/// let mut fb = FundingBasis::new();
/// // mark 100.5 vs index 100.0 -> (100.5 - 100.0) / 100.0 = 0.005.
/// let tick = DerivativesTick::new(
/// 0.0, 100.5, 100.0, 100.5, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0,
/// )
/// .unwrap();
/// assert!((fb.update(tick).unwrap() - 0.005).abs() < 1e-12);
/// ```
#[derive(Debug, Clone, Default)]
pub struct FundingBasis {
has_emitted: bool,
}
impl FundingBasis {
/// Construct a new funding-basis indicator.
#[must_use]
pub const fn new() -> Self {
Self { has_emitted: false }
}
}
impl Indicator for FundingBasis {
type Input = DerivativesTick;
type Output = f64;
fn update(&mut self, tick: DerivativesTick) -> Option<f64> {
self.has_emitted = true;
Some((tick.mark_price - tick.index_price) / tick.index_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 {
"FundingBasis"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
fn tick(mark: f64, index: f64) -> DerivativesTick {
DerivativesTick::new_unchecked(0.0, mark, index, mark, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0)
}
#[test]
fn accessors_and_metadata() {
let fb = FundingBasis::new();
assert_eq!(fb.name(), "FundingBasis");
assert_eq!(fb.warmup_period(), 1);
assert!(!fb.is_ready());
}
#[test]
fn premium_is_positive() {
let mut fb = FundingBasis::new();
let out = fb.update(tick(100.5, 100.0)).unwrap();
assert!((out - 0.005).abs() < 1e-12);
assert!(fb.is_ready());
}
#[test]
fn discount_is_negative() {
let mut fb = FundingBasis::new();
let out = fb.update(tick(99.5, 100.0)).unwrap();
assert!((out + 0.005).abs() < 1e-12);
}
#[test]
fn at_par_is_zero() {
let mut fb = FundingBasis::new();
assert_eq!(fb.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) * 0.1, 100.0))
.collect();
let mut a = FundingBasis::new();
let mut b = FundingBasis::new();
assert_eq!(
a.batch(&ticks),
ticks.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut fb = FundingBasis::new();
fb.update(tick(100.5, 100.0));
assert!(fb.is_ready());
fb.reset();
assert!(!fb.is_ready());
}
}
@@ -0,0 +1,131 @@
//! Funding Rate — the current perpetual funding rate.
use crate::derivatives::DerivativesTick;
use crate::traits::Indicator;
/// Funding Rate — the funding rate carried by each derivatives tick.
///
/// The funding rate is the periodic payment exchanged between long and short
/// perpetual-swap holders that tethers the perpetual mark to the spot index. A
/// positive rate means longs pay shorts (the perpetual trades at a premium); a
/// negative rate means shorts pay longs (a discount). This indicator simply
/// surfaces the rate from the [`DerivativesTick`] feed so it can be charted,
/// chained or fed to the rolling funding statistics ([`FundingRateMean`],
/// [`FundingRateZScore`]).
///
/// `Input = DerivativesTick`, `Output = f64`. Stateless; ready after the first
/// tick.
///
/// [`FundingRateMean`]: crate::FundingRateMean
/// [`FundingRateZScore`]: crate::FundingRateZScore
///
/// # Example
///
/// ```
/// use wickra_core::{DerivativesTick, FundingRate, Indicator};
///
/// let mut fr = FundingRate::new();
/// let tick = DerivativesTick::new(
/// 0.0001, 100.0, 100.0, 100.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0,
/// )
/// .unwrap();
/// assert_eq!(fr.update(tick), Some(0.0001));
/// ```
#[derive(Debug, Clone, Default)]
pub struct FundingRate {
has_emitted: bool,
}
impl FundingRate {
/// Construct a new funding-rate indicator.
#[must_use]
pub const fn new() -> Self {
Self { has_emitted: false }
}
}
impl Indicator for FundingRate {
type Input = DerivativesTick;
type Output = f64;
fn update(&mut self, tick: DerivativesTick) -> Option<f64> {
self.has_emitted = true;
Some(tick.funding_rate)
}
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 {
"FundingRate"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
fn tick(funding_rate: f64) -> DerivativesTick {
DerivativesTick::new_unchecked(
funding_rate,
100.0,
100.0,
100.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0,
)
}
#[test]
fn accessors_and_metadata() {
let fr = FundingRate::new();
assert_eq!(fr.name(), "FundingRate");
assert_eq!(fr.warmup_period(), 1);
assert!(!fr.is_ready());
}
#[test]
fn passes_through_funding_rate() {
let mut fr = FundingRate::new();
assert_eq!(fr.update(tick(0.0001)), Some(0.0001));
assert_eq!(fr.update(tick(-0.0003)), Some(-0.0003));
assert!(fr.is_ready());
}
#[test]
fn batch_equals_streaming() {
let ticks: Vec<DerivativesTick> =
(0..20).map(|i| tick(0.0001 * f64::from(i - 10))).collect();
let mut a = FundingRate::new();
let mut b = FundingRate::new();
assert_eq!(
a.batch(&ticks),
ticks.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut fr = FundingRate::new();
fr.update(tick(0.0001));
assert!(fr.is_ready());
fr.reset();
assert!(!fr.is_ready());
}
}
@@ -0,0 +1,181 @@
//! Funding Rate Rolling Mean — average funding rate over a trailing window.
use std::collections::VecDeque;
use crate::derivatives::DerivativesTick;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Funding Rate Rolling Mean — the arithmetic mean of the funding rate over the
/// trailing window of `window` ticks.
///
/// ```text
/// mean = (1 / window) · Σ fundingRate over the last `window` ticks
/// ```
///
/// Smoothing the raw [funding rate] reveals the persistent carry regime — a
/// sustained positive mean marks a crowded-long market paying to hold the
/// perpetual, a sustained negative mean a crowded-short one. The indicator warms
/// up for `window` ticks — `update` returns `None` until the window is full —
/// then emits the rolling mean, maintained in O(1) per tick via a running sum.
///
/// `Input = DerivativesTick`, `Output = f64`.
///
/// [funding rate]: crate::FundingRate
///
/// # Example
///
/// ```
/// use wickra_core::{DerivativesTick, FundingRateMean, Indicator};
///
/// fn tick(rate: f64) -> DerivativesTick {
/// DerivativesTick::new(rate, 100.0, 100.0, 100.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0)
/// .unwrap()
/// }
///
/// let mut frm = FundingRateMean::new(2).unwrap();
/// assert_eq!(frm.update(tick(0.001)), None);
/// // Window full: (0.001 + 0.003) / 2 = 0.002.
/// assert_eq!(frm.update(tick(0.003)), Some(0.002));
/// ```
#[derive(Debug, Clone)]
pub struct FundingRateMean {
window: usize,
history: VecDeque<f64>,
sum: f64,
}
impl FundingRateMean {
/// Construct a funding-rate rolling mean over a window of `window` ticks.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `window` is zero.
pub fn new(window: usize) -> Result<Self> {
if window == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
window,
history: VecDeque::with_capacity(window),
sum: 0.0,
})
}
/// The configured window length, in ticks.
#[must_use]
pub fn window(&self) -> usize {
self.window
}
}
impl Indicator for FundingRateMean {
type Input = DerivativesTick;
type Output = f64;
fn update(&mut self, tick: DerivativesTick) -> Option<f64> {
self.history.push_back(tick.funding_rate);
self.sum += tick.funding_rate;
if self.history.len() > self.window {
let old = self.history.pop_front().expect("window >= 1, len > window");
self.sum -= old;
}
if self.history.len() < self.window {
return None;
}
Some(self.sum / self.window as f64)
}
fn reset(&mut self) {
self.history.clear();
self.sum = 0.0;
}
fn warmup_period(&self) -> usize {
self.window
}
fn is_ready(&self) -> bool {
self.history.len() >= self.window
}
fn name(&self) -> &'static str {
"FundingRateMean"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
fn tick(rate: f64) -> DerivativesTick {
DerivativesTick::new_unchecked(
rate, 100.0, 100.0, 100.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0,
)
}
#[test]
fn rejects_zero_window() {
assert!(matches!(FundingRateMean::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_and_metadata() {
let frm = FundingRateMean::new(5).unwrap();
assert_eq!(frm.name(), "FundingRateMean");
assert_eq!(frm.warmup_period(), 5);
assert_eq!(frm.window(), 5);
assert!(!frm.is_ready());
}
#[test]
fn warms_up_then_emits_mean() {
let mut frm = FundingRateMean::new(2).unwrap();
assert_eq!(frm.update(tick(0.001)), None);
assert!(!frm.is_ready());
assert_eq!(frm.update(tick(0.003)), Some(0.002));
assert!(frm.is_ready());
}
#[test]
fn rolls_off_old_values() {
let mut frm = FundingRateMean::new(2).unwrap();
frm.update(tick(0.001));
frm.update(tick(0.003)); // mean 0.002
let out = frm.update(tick(0.005)).unwrap(); // window [0.003, 0.005] -> 0.004
assert!((out - 0.004).abs() < 1e-12);
}
#[test]
fn handles_negative_rates() {
let mut frm = FundingRateMean::new(2).unwrap();
frm.update(tick(-0.002));
let out = frm.update(tick(0.004)).unwrap();
assert!((out - 0.001).abs() < 1e-12);
}
#[test]
fn batch_equals_streaming() {
let ticks: Vec<DerivativesTick> = (0..30)
.map(|i| tick(0.0001 * f64::from(i % 7) - 0.0003))
.collect();
let mut a = FundingRateMean::new(5).unwrap();
let mut b = FundingRateMean::new(5).unwrap();
assert_eq!(
a.batch(&ticks),
ticks.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut frm = FundingRateMean::new(2).unwrap();
frm.update(tick(0.001));
frm.update(tick(0.003));
assert!(frm.is_ready());
frm.reset();
assert!(!frm.is_ready());
assert_eq!(frm.update(tick(0.002)), None);
}
}
@@ -0,0 +1,201 @@
//! Funding Rate Z-Score — how extreme the latest funding rate is versus its
//! recent history.
use std::collections::VecDeque;
use crate::derivatives::DerivativesTick;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Funding Rate Z-Score — the latest funding rate expressed in standard
/// deviations from its rolling mean over the trailing window of `window` ticks.
///
/// ```text
/// zScore = (fundingRate mean) / population_stddev over the last `window` ticks
/// ```
///
/// A reading of `+2` means funding is two standard deviations richer than its
/// recent norm — an unusually crowded long, a contrarian fade signal; `2` is
/// the mirror. Normalising the [funding rate] this way makes funding extremes
/// comparable across regimes and assets. A window with zero dispersion (a flat
/// funding series) yields `0`. The indicator warms up for `window` ticks, then
/// emits the rolling z-score, maintained in O(1) per tick.
///
/// `Input = DerivativesTick`, `Output = f64`.
///
/// [funding rate]: crate::FundingRate
///
/// # Example
///
/// ```
/// use wickra_core::{DerivativesTick, FundingRateZScore, Indicator};
///
/// fn tick(rate: f64) -> DerivativesTick {
/// DerivativesTick::new(rate, 100.0, 100.0, 100.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0)
/// .unwrap()
/// }
///
/// let mut z = FundingRateZScore::new(2).unwrap();
/// assert_eq!(z.update(tick(0.001)), None);
/// // Window [0.001, 0.003]: mean 0.002, population stddev 0.001 -> (0.003 - 0.002) / 0.001 = 1.
/// assert!((z.update(tick(0.003)).unwrap() - 1.0).abs() < 1e-9);
/// ```
#[derive(Debug, Clone)]
pub struct FundingRateZScore {
window: usize,
history: VecDeque<f64>,
sum: f64,
sum_sq: f64,
}
impl FundingRateZScore {
/// Construct a funding-rate z-score over a window of `window` ticks.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `window` is zero.
pub fn new(window: usize) -> Result<Self> {
if window == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
window,
history: VecDeque::with_capacity(window),
sum: 0.0,
sum_sq: 0.0,
})
}
/// The configured window length, in ticks.
#[must_use]
pub fn window(&self) -> usize {
self.window
}
}
impl Indicator for FundingRateZScore {
type Input = DerivativesTick;
type Output = f64;
fn update(&mut self, tick: DerivativesTick) -> Option<f64> {
let value = tick.funding_rate;
if self.history.len() == self.window {
let old = self.history.pop_front().expect("non-empty");
self.sum -= old;
self.sum_sq -= old * old;
}
self.history.push_back(value);
self.sum += value;
self.sum_sq += value * value;
if self.history.len() < self.window {
return None;
}
let n = self.window as f64;
let mean = self.sum / n;
// Population variance E[x²] E[x]²; clamp away tiny negative drift.
let variance = (self.sum_sq / n - mean * mean).max(0.0);
let std = variance.sqrt();
if std == 0.0 {
// A window with no dispersion: funding is exactly its own mean.
return Some(0.0);
}
Some((value - mean) / std)
}
fn reset(&mut self) {
self.history.clear();
self.sum = 0.0;
self.sum_sq = 0.0;
}
fn warmup_period(&self) -> usize {
self.window
}
fn is_ready(&self) -> bool {
self.history.len() == self.window
}
fn name(&self) -> &'static str {
"FundingRateZScore"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
fn tick(rate: f64) -> DerivativesTick {
DerivativesTick::new_unchecked(
rate, 100.0, 100.0, 100.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0,
)
}
#[test]
fn rejects_zero_window() {
assert!(matches!(FundingRateZScore::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_and_metadata() {
let z = FundingRateZScore::new(5).unwrap();
assert_eq!(z.name(), "FundingRateZScore");
assert_eq!(z.warmup_period(), 5);
assert_eq!(z.window(), 5);
assert!(!z.is_ready());
}
#[test]
fn reference_value() {
let mut z = FundingRateZScore::new(2).unwrap();
assert_eq!(z.update(tick(0.001)), None);
// Window [0.001, 0.003]: mean 0.002, var (1e-6 + 9e-6)/2 - 4e-6 = 1e-6,
// stddev 0.001; latest 0.003 is (0.003 - 0.002) / 0.001 = 1.
let out = z.update(tick(0.003)).unwrap();
assert!((out - 1.0).abs() < 1e-9);
assert!(z.is_ready());
}
#[test]
fn flat_window_is_zero() {
let mut z = FundingRateZScore::new(3).unwrap();
z.update(tick(0.002));
z.update(tick(0.002));
assert_eq!(z.update(tick(0.002)), Some(0.0));
}
#[test]
fn rolls_off_old_values() {
let mut z = FundingRateZScore::new(2).unwrap();
z.update(tick(0.001));
z.update(tick(0.003));
// Window now [0.003, 0.005]: mean 0.004, stddev 0.001 -> +1.
let out = z.update(tick(0.005)).unwrap();
assert!((out - 1.0).abs() < 1e-9);
}
#[test]
fn batch_equals_streaming() {
let ticks: Vec<DerivativesTick> = (0..30)
.map(|i| tick(0.0001 * f64::from(i % 5) - 0.0002))
.collect();
let mut a = FundingRateZScore::new(6).unwrap();
let mut b = FundingRateZScore::new(6).unwrap();
assert_eq!(
a.batch(&ticks),
ticks.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut z = FundingRateZScore::new(2).unwrap();
z.update(tick(0.001));
z.update(tick(0.003));
assert!(z.is_ready());
z.reset();
assert!(!z.is_ready());
assert_eq!(z.update(tick(0.002)), None);
}
}
@@ -0,0 +1,238 @@
//! Gap Side-by-Side White Lines candlestick pattern.
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Gap Side-by-Side White Lines — a 3-bar continuation. After a gap away from the
/// first bar, two white candles of similar size open at roughly the same level
/// (side by side) and hold the gap open, signalling the trend resumes in the gap
/// direction.
///
/// ```text
/// bar2, bar3 both white
/// bar2 body gaps away from bar1 body (up or down)
/// bar3 opens beside bar2 (|open3 open2| <= 0.1 · range2)
/// bar3 body is similar in size to bar2 (neither more than twice the other)
/// gap up -> +1.0 (bullish continuation)
/// gap down -> 1.0 (bearish continuation — "downside" gap side-by-side white)
/// ```
///
/// Output is `+1.0` (gap up) or `1.0` (gap down) when the pattern completes and
/// `0.0` otherwise. The first two bars always return `0.0` because the three-bar
/// window is not yet filled. Open-equality and body-similarity thresholds follow
/// the geometric house style rather than TA-Lib's rolling averages. 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 two gap
/// directions occupy a single dimension.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, GapSideBySideWhite, Indicator};
///
/// let mut indicator = GapSideBySideWhite::new();
/// indicator.update(Candle::new(10.0, 11.1, 9.9, 11.0, 1.0, 0).unwrap());
/// indicator.update(Candle::new(13.0, 14.1, 12.9, 14.0, 1.0, 1).unwrap());
/// let out = indicator
/// .update(Candle::new(13.0, 14.1, 12.9, 14.0, 1.0, 2).unwrap());
/// assert_eq!(out, Some(1.0));
/// ```
#[derive(Debug, Clone, Default)]
pub struct GapSideBySideWhite {
prev: Option<Candle>,
prev_prev: Option<Candle>,
has_emitted: bool,
}
impl GapSideBySideWhite {
/// Construct a new Gap Side-by-Side White Lines detector.
pub const fn new() -> Self {
Self {
prev: None,
prev_prev: None,
has_emitted: false,
}
}
}
impl Indicator for GapSideBySideWhite {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
self.has_emitted = true;
let bar1 = self.prev_prev;
let bar2 = self.prev;
self.prev_prev = self.prev;
self.prev = Some(candle);
let (Some(bar1), Some(bar2)) = (bar1, bar2) else {
return Some(0.0);
};
let range2 = bar2.high - bar2.low;
if range2 <= 0.0 {
return Some(0.0);
}
// Both of the side-by-side bars must be white.
if bar2.close <= bar2.open || candle.close <= candle.open {
return Some(0.0);
}
// Side by side: opens level and bodies of comparable size.
if (candle.open - bar2.open).abs() > 0.1 * range2 {
return Some(0.0);
}
let body2 = bar2.close - bar2.open;
let body3 = candle.close - candle.open;
if body2 > 2.0 * body3 || body3 > 2.0 * body2 {
return Some(0.0);
}
let bar1_top = bar1.open.max(bar1.close);
let bar1_bottom = bar1.open.min(bar1.close);
let bar2_bottom = bar2.open.min(bar2.close);
let bar2_top = bar2.open.max(bar2.close);
if bar2_bottom > bar1_top {
return Some(1.0); // gap up -> bullish continuation
}
if bar2_top < bar1_bottom {
return Some(-1.0); // gap down -> bearish continuation
}
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 {
"GapSideBySideWhite"
}
}
#[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 = GapSideBySideWhite::new();
assert_eq!(t.name(), "GapSideBySideWhite");
assert_eq!(t.warmup_period(), 3);
assert!(!t.is_ready());
}
#[test]
fn gap_up_is_plus_one() {
let mut t = GapSideBySideWhite::new();
assert_eq!(t.update(c(10.0, 11.1, 9.9, 11.0, 0)), Some(0.0));
assert_eq!(t.update(c(13.0, 14.1, 12.9, 14.0, 1)), Some(0.0));
assert_eq!(t.update(c(13.0, 14.1, 12.9, 14.0, 2)), Some(1.0));
}
#[test]
fn gap_down_is_minus_one() {
let mut t = GapSideBySideWhite::new();
assert_eq!(t.update(c(14.0, 14.1, 12.9, 13.0, 0)), Some(0.0));
assert_eq!(t.update(c(10.0, 11.1, 9.9, 11.0, 1)), Some(0.0));
assert_eq!(t.update(c(10.0, 11.1, 9.9, 11.0, 2)), Some(-1.0));
}
#[test]
fn second_bar_black_yields_zero() {
let mut t = GapSideBySideWhite::new();
t.update(c(10.0, 11.1, 9.9, 11.0, 0));
// bar3 is black -> not two white lines.
t.update(c(13.0, 14.1, 12.9, 14.0, 1));
assert_eq!(t.update(c(14.0, 14.1, 12.9, 13.0, 2)), Some(0.0));
}
#[test]
fn not_side_by_side_yields_zero() {
let mut t = GapSideBySideWhite::new();
t.update(c(10.0, 11.1, 9.9, 11.0, 0));
t.update(c(13.0, 14.1, 12.9, 14.0, 1));
// bar3 opens far from bar2's open -> not side by side.
assert_eq!(t.update(c(16.0, 17.1, 15.9, 17.0, 2)), Some(0.0));
}
#[test]
fn no_gap_yields_zero() {
let mut t = GapSideBySideWhite::new();
t.update(c(10.0, 13.1, 9.9, 13.0, 0));
// bar2 overlaps bar1 (no gap).
t.update(c(12.0, 13.1, 11.9, 13.0, 1));
assert_eq!(t.update(c(12.0, 13.1, 11.9, 13.0, 2)), Some(0.0));
}
#[test]
fn first_two_bars_return_zero() {
let mut t = GapSideBySideWhite::new();
assert_eq!(t.update(c(10.0, 11.1, 9.9, 11.0, 0)), Some(0.0));
assert_eq!(t.update(c(13.0, 14.1, 12.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 * 3.0;
c(base, base + 1.1, base - 0.1, base + 1.0, i)
})
.collect();
let mut a = GapSideBySideWhite::new();
let mut b = GapSideBySideWhite::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut t = GapSideBySideWhite::new();
t.update(c(10.0, 11.1, 9.9, 11.0, 0));
t.update(c(13.0, 14.1, 12.9, 14.0, 1));
t.update(c(13.0, 14.1, 12.9, 14.0, 2));
assert!(t.is_ready());
t.reset();
assert!(!t.is_ready());
assert_eq!(t.update(c(10.0, 11.1, 9.9, 11.0, 0)), Some(0.0));
}
#[test]
fn zero_range_yields_zero() {
let mut t = GapSideBySideWhite::new();
t.update(c(10.0, 11.1, 9.9, 11.0, 0));
// Flat second bar (range2 == 0) -> rejected.
t.update(c(13.0, 13.0, 13.0, 13.0, 1));
assert_eq!(t.update(c(13.0, 14.1, 12.9, 14.0, 2)), Some(0.0));
}
#[test]
fn body_size_mismatch_yields_zero() {
let mut t = GapSideBySideWhite::new();
t.update(c(10.0, 11.1, 9.9, 11.0, 0));
t.update(c(13.0, 16.0, 12.9, 15.0, 1)); // white, body 2.0
// Level open, white, but its body is more than 2x smaller -> rejected.
assert_eq!(t.update(c(13.0, 13.7, 12.9, 13.5, 2)), Some(0.0)); // body 0.5
}
}
@@ -0,0 +1,335 @@
//! Granger causality F-statistic: does series `b` help predict series `a`?
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Granger causality of `b` on `a` over a rolling window, as an F-statistic.
///
/// Each `update` takes one `(a, b)` pair. Over the trailing window of `period`
/// observations the indicator fits two autoregressions of `a` and compares them
/// with an F-test:
///
/// ```text
/// restricted: aₜ = c + Σ φᵢ·aₜ₋ᵢ (a's own lags only)
/// unrestricted: aₜ = c + Σ φᵢ·aₜ₋ᵢ + Σ ψᵢ·bₜ₋ᵢ (+ b's lags)
/// F = ((RSSᵣ RSSᵤ) / lag) / (RSSᵤ / (n 2·lag 1))
/// ```
///
/// If adding `b`'s lags significantly reduces the residual sum of squares, `b`
/// **Granger-causes** `a`: past values of `b` carry information about the future
/// of `a` beyond what `a`'s own past holds. A **larger** F means stronger
/// predictive causality (leadlag structure a stat-arb model can trade); a
/// value near `0` means `b` adds nothing. Note Granger causality is purely
/// predictive — it is not structural cause and effect.
///
/// The statistic is `0` when a regression is degenerate — a collinear or flat
/// window makes the normal equations singular. The output is always `≥ 0`.
///
/// Each `update` is `O(period · lag² + lag³)`, bounded by the fixed parameters.
///
/// # Example
///
/// ```
/// use wickra_core::{GrangerCausality, Indicator};
///
/// let mut g = GrangerCausality::new(60, 1).unwrap();
/// let mut last = None;
/// for t in 0..120 {
/// let drive = (f64::from(t) * 0.3).sin();
/// // a echoes b's previous value plus noise ⇒ b Granger-causes a.
/// let b = drive;
/// let a = 0.5 * (f64::from(t.max(1) - 1) * 0.3).sin() + 0.1 * (f64::from(t) * 0.9).cos();
/// last = g.update((a, b));
/// }
/// assert!(last.unwrap() >= 0.0);
/// ```
#[derive(Debug, Clone)]
pub struct GrangerCausality {
period: usize,
lag: usize,
window: VecDeque<(f64, f64)>,
}
impl GrangerCausality {
/// Construct a new Granger causality test.
///
/// `period` is the look-back window; `lag` is the autoregressive order
/// (number of own/cross lags in each model).
///
/// # Errors
/// Returns [`Error::InvalidPeriod`] if `lag < 1` or if `period < 3·lag + 2`
/// (the smallest window that leaves the unrestricted regression at least one
/// residual degree of freedom).
pub fn new(period: usize, lag: usize) -> Result<Self> {
if lag < 1 {
return Err(Error::InvalidPeriod {
message: "granger causality needs lag >= 1",
});
}
if period < 3 * lag + 2 {
return Err(Error::InvalidPeriod {
message: "granger causality needs period >= 3*lag + 2",
});
}
Ok(Self {
period,
lag,
window: VecDeque::with_capacity(period),
})
}
/// Configured look-back window.
pub const fn period(&self) -> usize {
self.period
}
/// Configured autoregressive order.
pub const fn lag(&self) -> usize {
self.lag
}
}
impl Indicator for GrangerCausality {
type Input = (f64, f64);
type Output = f64;
fn update(&mut self, input: (f64, f64)) -> Option<f64> {
if self.window.len() == self.period {
self.window.pop_front();
}
self.window.push_back(input);
if self.window.len() < self.period {
return None;
}
let lag = self.lag;
let a: Vec<f64> = self.window.iter().map(|&(av, _)| av).collect();
let b: Vec<f64> = self.window.iter().map(|&(_, bv)| bv).collect();
let num_obs = self.period - lag;
let mut target = Vec::with_capacity(num_obs);
let mut restricted = Vec::with_capacity(num_obs);
let mut unrestricted = Vec::with_capacity(num_obs);
for k in 0..num_obs {
let now = lag + k;
target.push(a[now]);
let mut row_r = Vec::with_capacity(lag + 1);
row_r.push(1.0);
for back in 1..=lag {
row_r.push(a[now - back]);
}
let mut row_u = row_r.clone();
for back in 1..=lag {
row_u.push(b[now - back]);
}
restricted.push(row_r);
unrestricted.push(row_u);
}
let Some(rss_r) = ols_rss(&restricted, &target, lag + 1) else {
return Some(0.0);
};
let Some(rss_u) = ols_rss(&unrestricted, &target, 2 * lag + 1) else {
return Some(0.0);
};
let dof = (num_obs - (2 * lag + 1)) as f64;
let numerator = (rss_r - rss_u) / lag as f64;
let denominator = rss_u / dof;
Some((numerator / denominator).max(0.0))
}
fn reset(&mut self) {
self.window.clear();
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.window.len() == self.period
}
fn name(&self) -> &'static str {
"GrangerCausality"
}
}
/// Residual sum of squares of the OLS fit of `target` on the design `rows`
/// (each a length-`num_reg` regressor vector). Returns `None` if the normal
/// equations are singular.
fn ols_rss(rows: &[Vec<f64>], target: &[f64], num_reg: usize) -> Option<f64> {
let mut xtx = vec![vec![0.0; num_reg]; num_reg];
let mut xty = vec![0.0; num_reg];
for (row, &observed) in rows.iter().zip(target) {
for (ri, &left) in row.iter().enumerate() {
xty[ri] += left * observed;
for (ci, &right) in row.iter().enumerate() {
xtx[ri][ci] += left * right;
}
}
}
let theta = solve(xtx, xty)?;
let mut rss = 0.0;
for (row, &observed) in rows.iter().zip(target) {
let pred: f64 = row
.iter()
.zip(&theta)
.map(|(coeff, value)| coeff * value)
.sum();
let resid = observed - pred;
rss += resid * resid;
}
Some(rss)
}
/// Solve the linear system `mat·x = rhs` by Gaussian elimination, returning
/// `None` if the matrix is (numerically) singular. `mat` is row-major.
fn solve(mut mat: Vec<Vec<f64>>, mut rhs: Vec<f64>) -> Option<Vec<f64>> {
let dim = rhs.len();
for col in 0..dim {
let pivot = mat[col][col];
if pivot.abs() < 1e-12 {
return None;
}
let pivot_row = mat[col].clone();
for row in (col + 1)..dim {
let factor = mat[row][col] / pivot;
for (cell, &above) in mat[row].iter_mut().zip(&pivot_row).skip(col) {
*cell -= factor * above;
}
rhs[row] -= factor * rhs[col];
}
}
let mut sol = vec![0.0; dim];
for row in (0..dim).rev() {
let known: f64 = mat[row]
.iter()
.zip(&sol)
.skip(row + 1)
.map(|(coeff, value)| coeff * value)
.sum();
sol[row] = (rhs[row] - known) / mat[row][row];
}
Some(sol)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
#[test]
fn rejects_bad_parameters() {
assert!(GrangerCausality::new(10, 0).is_err()); // lag must be >= 1
assert!(GrangerCausality::new(4, 1).is_err()); // period must be >= 3*lag + 2
assert!(GrangerCausality::new(5, 1).is_ok());
}
#[test]
fn accessors_and_metadata() {
let g = GrangerCausality::new(60, 2).unwrap();
assert_eq!(g.period(), 60);
assert_eq!(g.lag(), 2);
assert_eq!(g.warmup_period(), 60);
assert_eq!(g.name(), "GrangerCausality");
assert!(!g.is_ready());
}
#[test]
fn warmup_returns_none() {
let mut g = GrangerCausality::new(5, 1).unwrap();
for t in 0..4 {
assert_eq!(g.update((f64::from(t), f64::from(t) * 0.5)), None);
}
assert!(g.update((4.0, 2.0)).is_some());
assert!(g.is_ready());
}
#[test]
fn b_leading_a_has_positive_statistic() {
// a[t] is driven by b[t-1] plus a little of its own past ⇒ b helps.
let mut prev_drive = 0.0;
let pairs: Vec<(f64, f64)> = (0..120)
.map(|t| {
let drive = (f64::from(t) * 0.3).sin() + 0.4 * (f64::from(t) * 0.11).cos();
let a = 0.8 * prev_drive + 0.05 * (f64::from(t) * 0.7).sin();
prev_drive = drive;
(a, drive)
})
.collect();
let last = GrangerCausality::new(60, 1)
.unwrap()
.batch(&pairs)
.into_iter()
.flatten()
.last()
.unwrap();
assert!(last > 1.0, "F {last}");
}
#[test]
fn constant_b_is_singular_and_returns_zero() {
// b is constant ⇒ its lag columns are collinear with the intercept ⇒
// the unrestricted normal equations are singular ⇒ 0.
let pairs: Vec<(f64, f64)> = (0..40)
.map(|t| (f64::from(t) + (f64::from(t) * 0.6).sin(), 3.0))
.collect();
let last = GrangerCausality::new(20, 1)
.unwrap()
.batch(&pairs)
.into_iter()
.flatten()
.last()
.unwrap();
assert_eq!(last, 0.0);
}
#[test]
fn constant_a_restricted_singular_returns_zero() {
// a is constant ⇒ its own lag columns are collinear with the intercept
// ⇒ the restricted normal equations are singular ⇒ 0.
let pairs: Vec<(f64, f64)> = (0..40).map(|t| (5.0, (f64::from(t) * 0.4).sin())).collect();
let last = GrangerCausality::new(20, 1)
.unwrap()
.batch(&pairs)
.into_iter()
.flatten()
.last()
.unwrap();
assert_eq!(last, 0.0);
}
#[test]
fn reset_clears_state() {
let mut g = GrangerCausality::new(8, 1).unwrap();
for t in 0..12 {
g.update((
f64::from(t) + (f64::from(t) * 0.7).sin(),
(f64::from(t) * 0.3).cos(),
));
}
assert!(g.is_ready());
g.reset();
assert!(!g.is_ready());
assert_eq!(g.update((1.0, 1.0)), None);
}
#[test]
fn batch_equals_streaming() {
let pairs: Vec<(f64, f64)> = (0..80)
.map(|t| {
let b = (f64::from(t) * 0.4).sin();
(
0.6 * (f64::from(t.max(1) - 1) * 0.4).sin() + 0.1 * f64::from(t % 3),
b,
)
})
.collect();
let batch = GrangerCausality::new(30, 2).unwrap().batch(&pairs);
let mut g = GrangerCausality::new(30, 2).unwrap();
let streamed: Vec<_> = pairs.iter().map(|p| g.update(*p)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,163 @@
//! Gravestone Doji candlestick pattern.
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Gravestone Doji — a single-bar bearish reversal. Open, close, and low sit at
/// the bottom of the bar while a long upper shadow shows price was pushed up hard
/// and then sold all the way back to the open — sellers rejecting the highs.
///
/// ```text
/// range = high low
/// doji = |close open| <= 0.1 * range
/// no lower wick = min(open, close) low <= 0.1 * range
/// long upper = high max(open, close) >= 0.5 * range
/// ```
///
/// Output is `1.0` when the gravestone prints and `0.0` otherwise. Gravestone
/// Doji is a single-direction (bearish-only) shape, so it never emits `+1.0`.
/// Body and shadow thresholds follow the geometric house style (fixed fractions
/// of the bar range) rather than TA-Lib's rolling averages. 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::{Candle, GravestoneDoji, Indicator};
///
/// let mut indicator = GravestoneDoji::new();
/// // Body at the bottom, long upper shadow.
/// let candle = Candle::new(10.0, 14.0, 9.95, 10.0, 1.0, 0).unwrap();
/// assert_eq!(indicator.update(candle), Some(-1.0));
/// ```
#[derive(Debug, Clone, Default)]
pub struct GravestoneDoji {
has_emitted: bool,
}
impl GravestoneDoji {
/// Construct a new Gravestone Doji detector.
pub const fn new() -> Self {
Self { has_emitted: false }
}
}
impl Indicator for GravestoneDoji {
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);
}
if (candle.close - candle.open).abs() > 0.1 * range {
return Some(0.0);
}
let upper = candle.high - candle.open.max(candle.close);
let lower = candle.open.min(candle.close) - candle.low;
if lower <= 0.1 * range && upper >= 0.5 * range {
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 {
"GravestoneDoji"
}
}
#[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 = GravestoneDoji::new();
assert_eq!(t.name(), "GravestoneDoji");
assert_eq!(t.warmup_period(), 1);
assert!(!t.is_ready());
}
#[test]
fn gravestone_is_minus_one() {
let mut t = GravestoneDoji::new();
assert_eq!(t.update(c(10.0, 14.0, 9.95, 10.0, 0)), Some(-1.0));
}
#[test]
fn lower_shadow_yields_zero() {
let mut t = GravestoneDoji::new();
// Long lower shadow -> not a gravestone (this is a dragonfly shape).
assert_eq!(t.update(c(10.0, 10.05, 6.0, 10.0, 0)), Some(0.0));
}
#[test]
fn short_upper_shadow_yields_zero() {
let mut t = GravestoneDoji::new();
// Body at the bottom but the upper shadow is too short.
assert_eq!(t.update(c(10.0, 10.4, 9.95, 10.0, 0)), Some(0.0));
}
#[test]
fn non_doji_yields_zero() {
let mut t = GravestoneDoji::new();
assert_eq!(t.update(c(10.0, 14.0, 9.5, 13.5, 0)), Some(0.0));
}
#[test]
fn zero_range_yields_zero() {
let mut t = GravestoneDoji::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 + 4.0, base - 0.05, base, i)
})
.collect();
let mut a = GravestoneDoji::new();
let mut b = GravestoneDoji::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut t = GravestoneDoji::new();
t.update(c(10.0, 14.0, 9.95, 10.0, 0));
assert!(t.is_ready());
t.reset();
assert!(!t.is_ready());
}
}
@@ -0,0 +1,162 @@
//! High-Low Index — a moving average of the record-high percentage.
use crate::cross_section::CrossSection;
use crate::error::Result;
use crate::traits::Indicator;
use crate::Sma;
/// High-Low Index — a simple moving average of the *record high percent*,
/// `100 * new_highs / (new_highs + new_lows)`.
///
/// The record high percent is the share of new-extreme issues that are new
/// *highs* rather than new *lows*; smoothing it over a window (the classic period
/// is 10) gives the High-Low Index. Readings above 50 mean new highs dominate
/// (a healthy, broadening trend), readings below 50 mean new lows dominate. The
/// 30 and 70 lines are watched as oversold / overbought breadth thresholds.
///
/// Each tick floors the new-extreme count to one, so a tick with no new highs or
/// lows 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 percentage in `0..=100`),
/// `warmup_period == period`.
///
/// # Example
///
/// ```
/// use wickra_core::{CrossSection, HighLowIndex, Indicator, Member};
///
/// let mut hli = HighLowIndex::new(2).unwrap();
/// let highs = CrossSection::new(vec![Member::new(1.0, 1.0, true, false)], 0).unwrap();
/// assert_eq!(hli.update(highs.clone()), None); // warming up
/// assert_eq!(hli.update(highs), Some(100.0)); // all new highs
/// ```
#[derive(Debug, Clone)]
pub struct HighLowIndex {
sma: Sma,
}
impl HighLowIndex {
/// Construct a new High-Low Index 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 HighLowIndex {
type Input = CrossSection;
type Output = f64;
fn update(&mut self, section: CrossSection) -> Option<f64> {
let new_highs = section.new_highs();
let new_lows = section.new_lows();
let extremes = (new_highs + new_lows).max(1) as f64;
let record_high_percent = 100.0 * new_highs as f64 / extremes;
self.sma.update(record_high_percent)
}
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 {
"HighLowIndex"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cross_section::Member;
use crate::error::Error;
use crate::traits::BatchExt;
fn flags(highs: usize, lows: usize) -> CrossSection {
let mut members = Vec::new();
for _ in 0..highs {
members.push(Member::new(1.0, 10.0, true, false));
}
for _ in 0..lows {
members.push(Member::new(-1.0, 10.0, false, true));
}
members.push(Member::new(0.0, 10.0, false, false));
CrossSection::new(members, 0).unwrap()
}
#[test]
fn accessors_and_metadata() {
let hli = HighLowIndex::new(10).unwrap();
assert_eq!(hli.name(), "HighLowIndex");
assert_eq!(hli.warmup_period(), 10);
assert_eq!(hli.period(), 10);
assert!(!hli.is_ready());
}
#[test]
fn rejects_zero_period() {
assert!(matches!(HighLowIndex::new(0), Err(Error::PeriodZero)));
}
#[test]
fn averages_the_record_high_percent() {
let mut hli = HighLowIndex::new(2).unwrap();
// 8 highs / 10 extremes -> 80% ; window not full.
assert_eq!(hli.update(flags(8, 2)), None);
// 6 highs / 10 extremes -> 60% ; SMA(2) = (80 + 60) / 2 = 70.
let value = hli.update(flags(6, 4)).unwrap();
assert!((value - 70.0).abs() < 1e-9);
assert!(hli.is_ready());
}
#[test]
fn no_extremes_floors_to_zero_percent() {
let mut hli = HighLowIndex::new(1).unwrap();
// No new highs or lows -> 0 / max(0, 1) -> 0%.
assert_eq!(hli.update(flags(0, 0)), Some(0.0));
}
#[test]
fn reset_clears_state() {
let mut hli = HighLowIndex::new(2).unwrap();
hli.update(flags(8, 2));
hli.update(flags(6, 4));
assert!(hli.is_ready());
hli.reset();
assert!(!hli.is_ready());
assert_eq!(hli.update(flags(8, 2)), None);
}
#[test]
fn batch_equals_streaming() {
let sections = vec![flags(8, 2), flags(6, 4), flags(3, 7), flags(0, 0)];
let mut a = HighLowIndex::new(2).unwrap();
let mut b = HighLowIndex::new(2).unwrap();
assert_eq!(
a.batch(&sections),
sections
.iter()
.map(|s| b.update(s.clone()))
.collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,160 @@
//! High-Wave candlestick pattern.
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// High-Wave — a single-bar extreme-indecision signal. A small body with very
/// long shadows on *both* sides: price swung far up and far down yet finished
/// near the open, a sign that trend conviction has evaporated.
///
/// ```text
/// range = high low
/// long upper = high max(open, close) >= 0.4 * range
/// long lower = min(open, close) low >= 0.4 * range
/// ```
///
/// The two long-shadow conditions force the body below `0.2 * range`, so no
/// separate body test is needed. Output is `+1.0` when the high-wave prints and
/// `0.0` otherwise — a non-directional indecision flag, it never emits `1.0`.
/// Shadow thresholds follow the geometric house style rather than TA-Lib's
/// rolling averages. 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` detected, `0.0` no pattern — so it drops straight into
/// a machine-learning feature matrix as a single dimension.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, HighWave, Indicator};
///
/// let mut indicator = HighWave::new();
/// // Small body, long shadows both sides.
/// let candle = Candle::new(10.0, 12.0, 8.0, 10.3, 1.0, 0).unwrap();
/// assert_eq!(indicator.update(candle), Some(1.0));
/// ```
#[derive(Debug, Clone, Default)]
pub struct HighWave {
has_emitted: bool,
}
impl HighWave {
/// Construct a new High-Wave detector.
pub const fn new() -> Self {
Self { has_emitted: false }
}
}
impl Indicator for HighWave {
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 upper = candle.high - candle.open.max(candle.close);
let lower = candle.open.min(candle.close) - candle.low;
if upper >= 0.4 * range && lower >= 0.4 * range {
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 {
"HighWave"
}
}
#[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 = HighWave::new();
assert_eq!(t.name(), "HighWave");
assert_eq!(t.warmup_period(), 1);
assert!(!t.is_ready());
}
#[test]
fn high_wave_is_plus_one() {
let mut t = HighWave::new();
assert_eq!(t.update(c(10.0, 12.0, 8.0, 10.3, 0)), Some(1.0));
}
#[test]
fn short_upper_shadow_yields_zero() {
let mut t = HighWave::new();
// Long lower shadow but short upper -> not a high-wave.
assert_eq!(t.update(c(11.5, 12.0, 8.0, 11.7, 0)), Some(0.0));
}
#[test]
fn short_lower_shadow_yields_zero() {
let mut t = HighWave::new();
// Long upper shadow but short lower -> not a high-wave.
assert_eq!(t.update(c(8.3, 12.0, 8.0, 8.5, 0)), Some(0.0));
}
#[test]
fn big_body_yields_zero() {
let mut t = HighWave::new();
// A large body cannot leave both shadows long.
assert_eq!(t.update(c(8.5, 12.0, 8.0, 11.5, 0)), Some(0.0));
}
#[test]
fn zero_range_yields_zero() {
let mut t = HighWave::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 + 3.0, base - 3.0, base + 0.2, i)
})
.collect();
let mut a = HighWave::new();
let mut b = HighWave::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut t = HighWave::new();
t.update(c(10.0, 12.0, 8.0, 10.3, 0));
assert!(t.is_ready());
t.reset();
assert!(!t.is_ready());
}
}
@@ -0,0 +1,198 @@
//! Hikkake candlestick pattern.
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Hikkake — a 3-bar trap. An inside bar (bar2 fully contained by bar1) sets up a
/// breakout that immediately fails on bar3, trapping breakout traders and pointing
/// the opposite way.
///
/// ```text
/// inside bar : bar2.high < bar1.high && bar2.low > bar1.low
/// bullish (+1.0): bar3 makes a LOWER high AND LOWER low than bar2
/// (a false downside break -> expect a move up)
/// bearish (1.0): bar3 makes a HIGHER high AND HIGHER low than bar2
/// (a false upside break -> expect a move down)
/// ```
///
/// Output is `+1.0` (bullish setup), `1.0` (bearish setup), or `0.0` otherwise.
/// The detector fires when the three-bar setup completes on bar3; it does not
/// separately flag the optional later confirmation bar. The first two bars always
/// return `0.0` because the 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` bullish, `1.0` bearish, `0.0` no pattern — so it
/// drops straight into a machine-learning feature matrix where the bullish and
/// bearish setups occupy a single dimension.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Hikkake, Indicator};
///
/// let mut indicator = Hikkake::new();
/// indicator.update(Candle::new(10.0, 15.0, 5.0, 12.0, 1.0, 0).unwrap());
/// indicator.update(Candle::new(11.0, 13.0, 8.0, 12.0, 1.0, 1).unwrap());
/// let out = indicator
/// .update(Candle::new(9.0, 12.0, 6.0, 7.0, 1.0, 2).unwrap());
/// assert_eq!(out, Some(1.0));
/// ```
#[derive(Debug, Clone, Default)]
pub struct Hikkake {
prev: Option<Candle>,
prev_prev: Option<Candle>,
has_emitted: bool,
}
impl Hikkake {
/// Construct a new Hikkake detector.
pub const fn new() -> Self {
Self {
prev: None,
prev_prev: None,
has_emitted: false,
}
}
}
impl Indicator for Hikkake {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
self.has_emitted = true;
let bar1 = self.prev_prev;
let bar2 = self.prev;
self.prev_prev = self.prev;
self.prev = Some(candle);
let (Some(bar1), Some(bar2)) = (bar1, bar2) else {
return Some(0.0);
};
// bar2 must be an inside bar of bar1.
if !(bar2.high < bar1.high && bar2.low > bar1.low) {
return Some(0.0);
}
// Bullish: bar3 breaks below the inside bar (lower high and lower low).
if candle.high < bar2.high && candle.low < bar2.low {
return Some(1.0);
}
// Bearish: bar3 breaks above the inside bar (higher high and higher low).
if candle.high > bar2.high && candle.low > 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 {
"Hikkake"
}
}
#[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 = Hikkake::new();
assert_eq!(t.name(), "Hikkake");
assert_eq!(t.warmup_period(), 3);
assert!(!t.is_ready());
}
#[test]
fn bullish_hikkake_is_plus_one() {
let mut t = Hikkake::new();
assert_eq!(t.update(c(10.0, 15.0, 5.0, 12.0, 0)), Some(0.0));
assert_eq!(t.update(c(11.0, 13.0, 8.0, 12.0, 1)), Some(0.0));
assert_eq!(t.update(c(9.0, 12.0, 6.0, 7.0, 2)), Some(1.0));
}
#[test]
fn bearish_hikkake_is_minus_one() {
let mut t = Hikkake::new();
assert_eq!(t.update(c(10.0, 15.0, 5.0, 12.0, 0)), Some(0.0));
assert_eq!(t.update(c(11.0, 13.0, 8.0, 12.0, 1)), Some(0.0));
assert_eq!(t.update(c(12.0, 14.0, 9.0, 13.0, 2)), Some(-1.0));
}
#[test]
fn not_inside_bar_yields_zero() {
let mut t = Hikkake::new();
t.update(c(10.0, 15.0, 5.0, 12.0, 0));
// bar2 is not contained by bar1 (higher high).
t.update(c(11.0, 16.0, 8.0, 12.0, 1));
assert_eq!(t.update(c(9.0, 12.0, 6.0, 7.0, 2)), Some(0.0));
}
#[test]
fn outside_bar3_yields_zero() {
let mut t = Hikkake::new();
t.update(c(10.0, 15.0, 5.0, 12.0, 0));
t.update(c(11.0, 13.0, 8.0, 12.0, 1));
// bar3 engulfs bar2 (higher high and lower low) -> neither direction.
assert_eq!(t.update(c(11.0, 14.0, 7.0, 9.0, 2)), Some(0.0));
}
#[test]
fn first_two_bars_return_zero() {
let mut t = Hikkake::new();
assert_eq!(t.update(c(10.0, 15.0, 5.0, 12.0, 0)), Some(0.0));
assert_eq!(t.update(c(11.0, 13.0, 8.0, 12.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;
match i % 3 {
0 => c(base, base + 6.0, base - 6.0, base, i),
1 => c(base, base + 2.0, base - 2.0, base, i),
_ => c(base, base + 1.0, base - 5.0, base - 4.0, i),
}
})
.collect();
let mut a = Hikkake::new();
let mut b = Hikkake::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut t = Hikkake::new();
t.update(c(10.0, 15.0, 5.0, 12.0, 0));
t.update(c(11.0, 13.0, 8.0, 12.0, 1));
t.update(c(9.0, 12.0, 6.0, 7.0, 2));
assert!(t.is_ready());
t.reset();
assert!(!t.is_ready());
assert_eq!(t.update(c(10.0, 15.0, 5.0, 12.0, 0)), Some(0.0));
}
}
@@ -0,0 +1,196 @@
//! Modified Hikkake candlestick pattern.
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Modified Hikkake — a close-confirmed variant of the [`Hikkake`](crate::Hikkake)
/// trap. An inside bar is followed by a bar that breaks out *and is immediately
/// rejected*: it pierces the inside bar's range intrabar but closes back inside,
/// a stronger signal than the plain breakout setup.
///
/// ```text
/// inside bar : bar2.high < bar1.high && bar2.low > bar1.low
/// bullish (+1.0): bar3 makes a lower high AND lower low than bar2,
/// yet closes back above the inside-bar low (close3 > bar2.low)
/// bearish (1.0): bar3 makes a higher high AND higher low than bar2,
/// yet closes back below the inside-bar high (close3 < bar2.high)
/// ```
///
/// Output is `+1.0` (bullish), `1.0` (bearish), or `0.0` otherwise. The extra
/// close-recovery condition is what distinguishes it from the plain Hikkake, which
/// fires on the high/low break alone. 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` bullish, `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::{Candle, HikkakeModified, Indicator};
///
/// let mut indicator = HikkakeModified::new();
/// indicator.update(Candle::new(10.0, 15.0, 5.0, 12.0, 1.0, 0).unwrap());
/// indicator.update(Candle::new(11.0, 13.0, 8.0, 12.0, 1.0, 1).unwrap());
/// let out = indicator
/// .update(Candle::new(9.0, 12.0, 6.0, 9.0, 1.0, 2).unwrap());
/// assert_eq!(out, Some(1.0));
/// ```
#[derive(Debug, Clone, Default)]
pub struct HikkakeModified {
prev: Option<Candle>,
prev_prev: Option<Candle>,
has_emitted: bool,
}
impl HikkakeModified {
/// Construct a new Modified Hikkake detector.
pub const fn new() -> Self {
Self {
prev: None,
prev_prev: None,
has_emitted: false,
}
}
}
impl Indicator for HikkakeModified {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
self.has_emitted = true;
let bar1 = self.prev_prev;
let bar2 = self.prev;
self.prev_prev = self.prev;
self.prev = Some(candle);
let (Some(bar1), Some(bar2)) = (bar1, bar2) else {
return Some(0.0);
};
if !(bar2.high < bar1.high && bar2.low > bar1.low) {
return Some(0.0);
}
// Bullish: false downside break that closes back above the inside-bar low.
if candle.high < bar2.high && candle.low < bar2.low && candle.close > bar2.low {
return Some(1.0);
}
// Bearish: false upside break that closes back below the inside-bar high.
if candle.high > bar2.high && candle.low > bar2.low && candle.close < bar2.high {
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 {
"HikkakeModified"
}
}
#[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 = HikkakeModified::new();
assert_eq!(t.name(), "HikkakeModified");
assert_eq!(t.warmup_period(), 3);
assert!(!t.is_ready());
}
#[test]
fn bullish_modified_hikkake_is_plus_one() {
let mut t = HikkakeModified::new();
assert_eq!(t.update(c(10.0, 15.0, 5.0, 12.0, 0)), Some(0.0));
assert_eq!(t.update(c(11.0, 13.0, 8.0, 12.0, 1)), Some(0.0));
assert_eq!(t.update(c(9.0, 12.0, 6.0, 9.0, 2)), Some(1.0));
}
#[test]
fn bearish_modified_hikkake_is_minus_one() {
let mut t = HikkakeModified::new();
assert_eq!(t.update(c(10.0, 15.0, 5.0, 12.0, 0)), Some(0.0));
assert_eq!(t.update(c(11.0, 13.0, 8.0, 12.0, 1)), Some(0.0));
assert_eq!(t.update(c(13.0, 14.0, 9.0, 10.0, 2)), Some(-1.0));
}
#[test]
fn break_without_close_recovery_yields_zero() {
let mut t = HikkakeModified::new();
t.update(c(10.0, 15.0, 5.0, 12.0, 0));
t.update(c(11.0, 13.0, 8.0, 12.0, 1));
// Lower high and lower low, but closes below the inside-bar low -> plain
// Hikkake break, not the close-confirmed modified version.
assert_eq!(t.update(c(9.0, 12.0, 6.0, 7.0, 2)), Some(0.0));
}
#[test]
fn not_inside_bar_yields_zero() {
let mut t = HikkakeModified::new();
t.update(c(10.0, 15.0, 5.0, 12.0, 0));
t.update(c(11.0, 16.0, 8.0, 12.0, 1));
assert_eq!(t.update(c(9.0, 12.0, 6.0, 9.0, 2)), Some(0.0));
}
#[test]
fn first_two_bars_return_zero() {
let mut t = HikkakeModified::new();
assert_eq!(t.update(c(10.0, 15.0, 5.0, 12.0, 0)), Some(0.0));
assert_eq!(t.update(c(11.0, 13.0, 8.0, 12.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;
match i % 3 {
0 => c(base, base + 6.0, base - 6.0, base, i),
1 => c(base, base + 2.0, base - 2.0, base, i),
_ => c(base, base + 1.0, base - 5.0, base, i),
}
})
.collect();
let mut a = HikkakeModified::new();
let mut b = HikkakeModified::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut t = HikkakeModified::new();
t.update(c(10.0, 15.0, 5.0, 12.0, 0));
t.update(c(11.0, 13.0, 8.0, 12.0, 1));
t.update(c(9.0, 12.0, 6.0, 9.0, 2));
assert!(t.is_ready());
t.reset();
assert!(!t.is_ready());
assert_eq!(t.update(c(10.0, 15.0, 5.0, 12.0, 0)), Some(0.0));
}
}
@@ -0,0 +1,169 @@
//! Homing Pigeon candlestick pattern.
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Homing Pigeon — a 2-bar bullish reversal. Two black candles in a decline, the
/// second a small body sitting entirely inside the first body (a same-colour
/// harami). The shrinking range signals selling pressure is fading.
///
/// ```text
/// bar1 black (close < open)
/// bar2 black & its body sits inside bar1's body
/// (open2 <= open1 && close2 >= close1)
/// bar2 body is smaller than bar1's
/// ```
///
/// Output is `+1.0` when the pattern completes and `0.0` otherwise. Homing Pigeon
/// is a single-direction (bullish-only) reversal, so it never emits `1.0`. The
/// first bar always returns `0.0` because the two-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` bullish, `0.0` no pattern — so it drops straight into
/// a machine-learning feature matrix as a single dimension.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, HomingPigeon, Indicator};
///
/// let mut indicator = HomingPigeon::new();
/// indicator.update(Candle::new(15.0, 15.1, 9.9, 10.0, 1.0, 0).unwrap());
/// let out = indicator
/// .update(Candle::new(14.0, 14.1, 10.9, 11.0, 1.0, 1).unwrap());
/// assert_eq!(out, Some(1.0));
/// ```
#[derive(Debug, Clone, Default)]
pub struct HomingPigeon {
prev: Option<Candle>,
has_emitted: bool,
}
impl HomingPigeon {
/// Construct a new Homing Pigeon detector.
pub const fn new() -> Self {
Self {
prev: None,
has_emitted: false,
}
}
}
impl Indicator for HomingPigeon {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
self.has_emitted = true;
let prev = self.prev;
self.prev = Some(candle);
let Some(bar1) = prev else {
return Some(0.0);
};
// Both bars black, bar2's body inside bar1's body and smaller.
if bar1.close < bar1.open
&& candle.close < candle.open
&& candle.open <= bar1.open
&& candle.close >= bar1.close
&& (candle.open - candle.close) < (bar1.open - bar1.close)
{
return Some(1.0);
}
Some(0.0)
}
fn reset(&mut self) {
self.prev = None;
self.has_emitted = false;
}
fn warmup_period(&self) -> usize {
2
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"HomingPigeon"
}
}
#[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 = HomingPigeon::new();
assert_eq!(t.name(), "HomingPigeon");
assert_eq!(t.warmup_period(), 2);
assert!(!t.is_ready());
}
#[test]
fn homing_pigeon_is_plus_one() {
let mut t = HomingPigeon::new();
assert_eq!(t.update(c(15.0, 15.1, 9.9, 10.0, 0)), Some(0.0));
assert_eq!(t.update(c(14.0, 14.1, 10.9, 11.0, 1)), Some(1.0));
}
#[test]
fn second_bar_white_yields_zero() {
let mut t = HomingPigeon::new();
t.update(c(15.0, 15.1, 9.9, 10.0, 0));
// bar2 white -> not a homing pigeon.
assert_eq!(t.update(c(11.0, 14.1, 10.9, 14.0, 1)), Some(0.0));
}
#[test]
fn second_body_not_inside_yields_zero() {
let mut t = HomingPigeon::new();
t.update(c(15.0, 15.1, 9.9, 10.0, 0));
// bar2 opens above bar1's open -> body not contained.
assert_eq!(t.update(c(16.0, 16.1, 10.9, 11.0, 1)), Some(0.0));
}
#[test]
fn first_bar_returns_zero() {
let mut t = HomingPigeon::new();
assert_eq!(t.update(c(15.0, 15.1, 9.9, 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 + 5.0, base + 5.1, base - 0.1, base, i)
})
.collect();
let mut a = HomingPigeon::new();
let mut b = HomingPigeon::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut t = HomingPigeon::new();
t.update(c(15.0, 15.1, 9.9, 10.0, 0));
t.update(c(14.0, 14.1, 10.9, 11.0, 1));
assert!(t.is_ready());
t.reset();
assert!(!t.is_ready());
assert_eq!(t.update(c(15.0, 15.1, 9.9, 10.0, 0)), Some(0.0));
}
}
@@ -0,0 +1,303 @@
//! Ehlers Hilbert Transform Dominant Cycle Phase (`HT_DCPHASE`).
#![allow(clippy::manual_clamp)]
use std::f64::consts::PI;
use crate::traits::Indicator;
/// Ehlers' Hilbert Transform Dominant Cycle Phase (`HT_DCPHASE`).
///
/// Runs the same adaptive Hilbert-transform engine as
/// [`HilbertDominantCycle`](crate::HilbertDominantCycle) to recover the dominant
/// cycle period, then measures the **phase angle** of that cycle (in degrees) by
/// correlating the smoothed price over one dominant-cycle window against a unit
/// phasor. The phase advances roughly linearly through a clean cycle and stalls
/// in a trend, which is the basis of Ehlers' trend-versus-cycle detection.
///
/// From *Rocket Science for Traders* (Ehlers 2001), aligned with TA-Lib's
/// `HT_DCPHASE`. The first value is emitted after ~50 inputs, once the engine's
/// moving-average chain has filled.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, HtDcPhase};
///
/// let mut ht = HtDcPhase::new();
/// let mut last = None;
/// for i in 0..120 {
/// last = ht.update(100.0 + (f64::from(i) * 0.4).sin() * 5.0);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone, Default)]
pub struct HtDcPhase {
smooth_buf: Vec<f64>,
detrender_buf: Vec<f64>,
q1_buf: Vec<f64>,
i1_buf: Vec<f64>,
// Longer history of the 4-bar smoothed price, used to integrate the phase
// over one dominant-cycle window (up to 50 bars).
smooth_price: Vec<f64>,
prev_i2: f64,
prev_q2: f64,
prev_re: f64,
prev_im: f64,
prev_period: f64,
prev_smooth_period: f64,
count: usize,
last_value: Option<f64>,
}
impl HtDcPhase {
/// Construct a new Hilbert transform dominant-cycle phase estimator.
pub fn new() -> Self {
Self::default()
}
/// Current dominant-cycle phase (degrees) if available.
pub const fn value(&self) -> Option<f64> {
self.last_value
}
fn push_front(buf: &mut Vec<f64>, v: f64, cap: usize) {
buf.insert(0, v);
if buf.len() > cap {
buf.truncate(cap);
}
}
}
impl Indicator for HtDcPhase {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() {
return self.last_value;
}
self.count += 1;
Self::push_front(&mut self.smooth_buf, input, 7);
if self.smooth_buf.len() < 7 {
return None;
}
let smooth = (4.0 * self.smooth_buf[0]
+ 3.0 * self.smooth_buf[1]
+ 2.0 * self.smooth_buf[2]
+ self.smooth_buf[3])
/ 10.0;
Self::push_front(&mut self.smooth_price, smooth, 50);
let period = self.prev_period.max(6.0).min(50.0);
let adj = 0.075 * period + 0.54;
let s0 = smooth;
let s2 = self.smooth_buf[2];
let s4 = self.smooth_buf[4];
let s6 = self.smooth_buf[6];
let detrender = (0.0962 * s0 + 0.5769 * s2 - 0.5769 * s4 - 0.0962 * s6) * adj;
Self::push_front(&mut self.detrender_buf, detrender, 7);
if self.detrender_buf.len() < 7 {
return None;
}
let q1 = (0.0962 * self.detrender_buf[0] + 0.5769 * self.detrender_buf[2]
- 0.5769 * self.detrender_buf[4]
- 0.0962 * self.detrender_buf[6])
* adj;
let i1 = self.detrender_buf[3];
Self::push_front(&mut self.q1_buf, q1, 7);
Self::push_front(&mut self.i1_buf, i1, 7);
if self.q1_buf.len() < 7 || self.i1_buf.len() < 7 {
return None;
}
let ji = (0.0962 * self.i1_buf[0] + 0.5769 * self.i1_buf[2]
- 0.5769 * self.i1_buf[4]
- 0.0962 * self.i1_buf[6])
* adj;
let jq = (0.0962 * self.q1_buf[0] + 0.5769 * self.q1_buf[2]
- 0.5769 * self.q1_buf[4]
- 0.0962 * self.q1_buf[6])
* adj;
let mut i2 = i1 - jq;
let mut q2 = q1 + ji;
i2 = 0.2 * i2 + 0.8 * self.prev_i2;
q2 = 0.2 * q2 + 0.8 * self.prev_q2;
let mut re = i2 * self.prev_i2 + q2 * self.prev_q2;
let mut im = i2 * self.prev_q2 - q2 * self.prev_i2;
re = 0.2 * re + 0.8 * self.prev_re;
im = 0.2 * im + 0.8 * self.prev_im;
self.prev_i2 = i2;
self.prev_q2 = q2;
self.prev_re = re;
self.prev_im = im;
let mut new_period = if im.abs() > f64::EPSILON && re.abs() > f64::EPSILON {
2.0 * PI / im.atan2(re)
} else {
self.prev_period
};
new_period = new_period.min(1.5 * self.prev_period);
new_period = new_period.max(0.67 * self.prev_period);
new_period = new_period.clamp(6.0, 50.0);
self.prev_period = 0.2 * new_period + 0.8 * self.prev_period;
self.prev_smooth_period = 0.33 * self.prev_period + 0.67 * self.prev_smooth_period;
if self.count < 50 {
return None;
}
// Integrate the smoothed price over one dominant-cycle window against a
// unit phasor to recover the instantaneous dominant-cycle phase.
let smooth_period = self.prev_smooth_period;
let dc_period = (smooth_period + 0.5) as usize;
let dc_period = dc_period.clamp(1, self.smooth_price.len());
let mut real_part = 0.0;
let mut imag_part = 0.0;
for i in 0..dc_period {
let angle = (i as f64) * 2.0 * PI / (dc_period as f64);
let sp = self.smooth_price[i];
real_part += angle.sin() * sp;
imag_part += angle.cos() * sp;
}
let dc_phase = compute_dc_phase(real_part, imag_part, smooth_period);
self.last_value = Some(dc_phase);
Some(dc_phase)
}
fn reset(&mut self) {
self.smooth_buf.clear();
self.detrender_buf.clear();
self.q1_buf.clear();
self.i1_buf.clear();
self.smooth_price.clear();
self.prev_i2 = 0.0;
self.prev_q2 = 0.0;
self.prev_re = 0.0;
self.prev_im = 0.0;
self.prev_period = 0.0;
self.prev_smooth_period = 0.0;
self.count = 0;
self.last_value = None;
}
fn warmup_period(&self) -> usize {
50
}
fn is_ready(&self) -> bool {
self.last_value.is_some()
}
fn name(&self) -> &'static str {
"HT_DCPHASE"
}
}
/// Recovers the dominant-cycle phase (degrees) from the real/imaginary parts of
/// the one-cycle homodyne integration, then unwraps it into TA-Lib's
/// `[-45, 315)` output range with the 4-bar smoother group-delay correction.
///
/// When `imag_part` is within `±0.001` of zero the `atan` is undefined, so the
/// phase collapses to `±90°` by the sign of `real_part`.
fn compute_dc_phase(real_part: f64, imag_part: f64, smooth_period: f64) -> f64 {
let mut dc_phase = if imag_part.abs() > 0.001 {
(real_part / imag_part).atan().to_degrees()
} else if real_part < 0.0 {
-90.0
} else {
90.0
};
dc_phase += 90.0;
// Compensate the group delay of the 4-bar weighted smoother.
dc_phase += 360.0 / smooth_period;
if imag_part < 0.0 {
dc_phase += 180.0;
}
if dc_phase > 315.0 {
dc_phase -= 360.0;
}
dc_phase
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
fn sine_prices(n: usize) -> Vec<f64> {
(0..n)
.map(|i| 100.0 + (i as f64 * 0.4).sin() * 5.0)
.collect()
}
#[test]
fn accessors_and_metadata() {
let ht = HtDcPhase::new();
assert_eq!(ht.warmup_period(), 50);
assert_eq!(ht.name(), "HT_DCPHASE");
assert!(!ht.is_ready());
}
#[test]
fn near_zero_imaginary_collapses_to_signed_ninety() {
// A near-zero imaginary part makes atan(real/imag) undefined, so the phase
// collapses to +90 for non-negative real and -90 for negative real before
// the +90 offset and group-delay correction unwrap it.
let pos = compute_dc_phase(1.0, 0.0, 20.0);
let neg = compute_dc_phase(-1.0, 0.0, 20.0);
assert!((pos - 198.0).abs() < 1e-9);
assert!((neg - 18.0).abs() < 1e-9);
// The normal path still flows through atan.
let mid = compute_dc_phase(1.0, 1.0, 20.0);
assert!((mid - 153.0).abs() < 1e-9);
}
#[test]
fn emits_after_warmup_within_phase_band() {
let mut ht = HtDcPhase::new();
let out: Vec<Option<f64>> = ht.batch(&sine_prices(200));
assert_eq!(out[0], None);
assert!(ht.is_ready());
for v in out.into_iter().flatten() {
assert!(v.is_finite(), "phase must be finite");
assert!((-360.0..=360.0).contains(&v), "phase {v} outside band");
}
}
#[test]
fn ignores_non_finite_input() {
let mut ht = HtDcPhase::new();
let _ = ht.batch(&sine_prices(120));
let before = ht.value();
assert_eq!(ht.update(f64::NAN), before);
}
#[test]
fn batch_equals_streaming() {
let prices = sine_prices(200);
let mut a = HtDcPhase::new();
let mut b = HtDcPhase::new();
let batch = a.batch(&prices);
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
assert_eq!(batch, streamed);
}
#[test]
fn reset_clears_state() {
let mut ht = HtDcPhase::new();
let _ = ht.batch(&sine_prices(120));
assert!(ht.is_ready());
ht.reset();
assert!(!ht.is_ready());
assert_eq!(ht.update(100.0), None);
}
}
@@ -0,0 +1,240 @@
//! Ehlers Hilbert Transform Phasor components (`HT_PHASOR`).
#![allow(clippy::manual_clamp)]
use std::f64::consts::PI;
use crate::traits::Indicator;
/// In-phase and quadrature components of the Hilbert transform phasor.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct HtPhasorOutput {
/// In-phase component (`I1`).
pub inphase: f64,
/// Quadrature component (`Q1`).
pub quadrature: f64,
}
/// Ehlers' Hilbert Transform Phasor (`HT_PHASOR`).
///
/// Runs the same adaptive Hilbert-transform engine as
/// [`HilbertDominantCycle`](crate::HilbertDominantCycle) but reports the raw
/// in-phase (`I1`) and quadrature (`Q1`) components of the analytic signal rather
/// than the recovered cycle period. The two components are 90° out of phase, so
/// their ratio tracks the instantaneous phase of the dominant cycle.
///
/// From *Rocket Science for Traders* (Ehlers 2001), aligned with TA-Lib's
/// `HT_PHASOR`. The first value is emitted once the transform's tap buffers fill.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, HtPhasor};
///
/// let mut ht = HtPhasor::new();
/// let mut last = None;
/// for i in 0..120 {
/// last = ht.update(100.0 + (f64::from(i) * 0.4).sin() * 5.0);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone, Default)]
pub struct HtPhasor {
smooth_buf: Vec<f64>,
detrender_buf: Vec<f64>,
q1_buf: Vec<f64>,
i1_buf: Vec<f64>,
prev_i2: f64,
prev_q2: f64,
prev_re: f64,
prev_im: f64,
prev_period: f64,
ready: bool,
}
impl HtPhasor {
/// Construct a new Hilbert transform phasor.
pub fn new() -> Self {
Self::default()
}
fn push_front(buf: &mut Vec<f64>, v: f64, cap: usize) {
buf.insert(0, v);
if buf.len() > cap {
buf.truncate(cap);
}
}
}
impl Indicator for HtPhasor {
type Input = f64;
type Output = HtPhasorOutput;
fn update(&mut self, input: f64) -> Option<HtPhasorOutput> {
if !input.is_finite() {
return None;
}
Self::push_front(&mut self.smooth_buf, input, 7);
if self.smooth_buf.len() < 7 {
return None;
}
let smooth = (4.0 * self.smooth_buf[0]
+ 3.0 * self.smooth_buf[1]
+ 2.0 * self.smooth_buf[2]
+ self.smooth_buf[3])
/ 10.0;
let period = self.prev_period.max(6.0).min(50.0);
let adj = 0.075 * period + 0.54;
let s0 = smooth;
let s2 = self.smooth_buf[2];
let s4 = self.smooth_buf[4];
let s6 = self.smooth_buf[6];
let detrender = (0.0962 * s0 + 0.5769 * s2 - 0.5769 * s4 - 0.0962 * s6) * adj;
Self::push_front(&mut self.detrender_buf, detrender, 7);
if self.detrender_buf.len() < 7 {
return None;
}
let q1 = (0.0962 * self.detrender_buf[0] + 0.5769 * self.detrender_buf[2]
- 0.5769 * self.detrender_buf[4]
- 0.0962 * self.detrender_buf[6])
* adj;
let i1 = self.detrender_buf[3];
Self::push_front(&mut self.q1_buf, q1, 7);
Self::push_front(&mut self.i1_buf, i1, 7);
if self.q1_buf.len() < 7 || self.i1_buf.len() < 7 {
return None;
}
// Continue the dominant-cycle period adaptation so the next bar's `adj`
// coefficient tracks the cycle, exactly as TA-Lib's HT_PHASOR does.
let ji = (0.0962 * self.i1_buf[0] + 0.5769 * self.i1_buf[2]
- 0.5769 * self.i1_buf[4]
- 0.0962 * self.i1_buf[6])
* adj;
let jq = (0.0962 * self.q1_buf[0] + 0.5769 * self.q1_buf[2]
- 0.5769 * self.q1_buf[4]
- 0.0962 * self.q1_buf[6])
* adj;
let mut i2 = i1 - jq;
let mut q2 = q1 + ji;
i2 = 0.2 * i2 + 0.8 * self.prev_i2;
q2 = 0.2 * q2 + 0.8 * self.prev_q2;
let mut re = i2 * self.prev_i2 + q2 * self.prev_q2;
let mut im = i2 * self.prev_q2 - q2 * self.prev_i2;
re = 0.2 * re + 0.8 * self.prev_re;
im = 0.2 * im + 0.8 * self.prev_im;
self.prev_i2 = i2;
self.prev_q2 = q2;
self.prev_re = re;
self.prev_im = im;
let mut new_period = if im.abs() > f64::EPSILON && re.abs() > f64::EPSILON {
2.0 * PI / im.atan2(re)
} else {
self.prev_period
};
new_period = new_period.min(1.5 * self.prev_period);
new_period = new_period.max(0.67 * self.prev_period);
new_period = new_period.clamp(6.0, 50.0);
self.prev_period = 0.2 * new_period + 0.8 * self.prev_period;
self.ready = true;
Some(HtPhasorOutput {
inphase: i1,
quadrature: q1,
})
}
fn reset(&mut self) {
self.smooth_buf.clear();
self.detrender_buf.clear();
self.q1_buf.clear();
self.i1_buf.clear();
self.prev_i2 = 0.0;
self.prev_q2 = 0.0;
self.prev_re = 0.0;
self.prev_im = 0.0;
self.prev_period = 0.0;
self.ready = false;
}
fn warmup_period(&self) -> usize {
19
}
fn is_ready(&self) -> bool {
self.ready
}
fn name(&self) -> &'static str {
"HT_PHASOR"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
fn sine_prices(n: usize) -> Vec<f64> {
(0..n)
.map(|i| 100.0 + (i as f64 * 0.4).sin() * 5.0)
.collect()
}
#[test]
fn accessors_and_metadata() {
let ht = HtPhasor::new();
assert_eq!(ht.warmup_period(), 19);
assert_eq!(ht.name(), "HT_PHASOR");
assert!(!ht.is_ready());
}
#[test]
fn emits_after_warmup_and_stays_finite() {
let mut ht = HtPhasor::new();
let out: Vec<Option<HtPhasorOutput>> = ht.batch(&sine_prices(120));
assert_eq!(out[0], None);
let first = out.iter().position(Option::is_some).expect("emits");
assert!(first <= 19, "first phasor at index {first}");
for o in out.into_iter().flatten() {
assert!(o.inphase.is_finite() && o.quadrature.is_finite());
}
assert!(ht.is_ready());
}
#[test]
fn ignores_non_finite_input() {
let mut ht = HtPhasor::new();
let _ = ht.batch(&sine_prices(120));
// A non-finite input is skipped and produces no value.
assert_eq!(ht.update(f64::NAN), None);
}
#[test]
fn batch_equals_streaming() {
let prices = sine_prices(150);
let mut a = HtPhasor::new();
let mut b = HtPhasor::new();
let batch = a.batch(&prices);
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
assert_eq!(batch, streamed);
}
#[test]
fn reset_clears_state() {
let mut ht = HtPhasor::new();
let _ = ht.batch(&sine_prices(120));
assert!(ht.is_ready());
ht.reset();
assert!(!ht.is_ready());
assert_eq!(ht.update(100.0), None);
}
}
@@ -0,0 +1,379 @@
//! Ehlers Hilbert Transform Trend vs Cycle Mode (`HT_TRENDMODE`).
#![allow(clippy::manual_clamp)]
use std::f64::consts::PI;
use crate::traits::Indicator;
/// Ehlers' Hilbert Transform Trend Mode (`HT_TRENDMODE`).
///
/// Runs the same adaptive Hilbert-transform engine as
/// [`HilbertDominantCycle`](crate::HilbertDominantCycle), derives the dominant
/// cycle phase, its sine / lead-sine, and an instantaneous trendline, then
/// classifies the market into **trend mode (`1`)** or **cycle mode (`0`)**:
///
/// - it is a *cycle* shortly after the sine and lead-sine cross, while the phase
/// advances at roughly the dominant-cycle rate;
/// - it is a *trend* otherwise, and is forced to trend whenever price separates
/// from the trendline by more than 1.5%.
///
/// From *Rocket Science for Traders* (Ehlers 2001), aligned with TA-Lib's
/// `HT_TRENDMODE`. The output is `1.0` or `0.0`; the first value is emitted after
/// ~50 inputs once the engine's moving-average chain has filled.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, HtTrendMode};
///
/// let mut ht = HtTrendMode::new();
/// let mut last = None;
/// for i in 0..120 {
/// last = ht.update(100.0 + f64::from(i));
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone, Default)]
pub struct HtTrendMode {
smooth_buf: Vec<f64>,
detrender_buf: Vec<f64>,
q1_buf: Vec<f64>,
i1_buf: Vec<f64>,
smooth_price: Vec<f64>,
prev_i2: f64,
prev_q2: f64,
prev_re: f64,
prev_im: f64,
prev_period: f64,
prev_smooth_period: f64,
// Trend-mode state.
prev_dc_phase: f64,
prev_sine: f64,
prev_lead_sine: f64,
days_in_trend: f64,
it1: f64,
it2: f64,
it3: f64,
count: usize,
last_value: Option<f64>,
}
impl HtTrendMode {
/// Construct a new Hilbert transform trend-mode classifier.
pub fn new() -> Self {
Self::default()
}
/// Current trend-mode flag (`1.0` trend, `0.0` cycle) if available.
pub const fn value(&self) -> Option<f64> {
self.last_value
}
fn push_front(buf: &mut Vec<f64>, v: f64, cap: usize) {
buf.insert(0, v);
if buf.len() > cap {
buf.truncate(cap);
}
}
}
impl Indicator for HtTrendMode {
type Input = f64;
type Output = f64;
#[allow(clippy::too_many_lines)]
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() {
return self.last_value;
}
self.count += 1;
Self::push_front(&mut self.smooth_buf, input, 7);
if self.smooth_buf.len() < 7 {
return None;
}
let smooth = (4.0 * self.smooth_buf[0]
+ 3.0 * self.smooth_buf[1]
+ 2.0 * self.smooth_buf[2]
+ self.smooth_buf[3])
/ 10.0;
Self::push_front(&mut self.smooth_price, smooth, 50);
let period = self.prev_period.max(6.0).min(50.0);
let adj = 0.075 * period + 0.54;
let s0 = smooth;
let s2 = self.smooth_buf[2];
let s4 = self.smooth_buf[4];
let s6 = self.smooth_buf[6];
let detrender = (0.0962 * s0 + 0.5769 * s2 - 0.5769 * s4 - 0.0962 * s6) * adj;
Self::push_front(&mut self.detrender_buf, detrender, 7);
if self.detrender_buf.len() < 7 {
return None;
}
let q1 = (0.0962 * self.detrender_buf[0] + 0.5769 * self.detrender_buf[2]
- 0.5769 * self.detrender_buf[4]
- 0.0962 * self.detrender_buf[6])
* adj;
let i1 = self.detrender_buf[3];
Self::push_front(&mut self.q1_buf, q1, 7);
Self::push_front(&mut self.i1_buf, i1, 7);
if self.q1_buf.len() < 7 || self.i1_buf.len() < 7 {
return None;
}
let ji = (0.0962 * self.i1_buf[0] + 0.5769 * self.i1_buf[2]
- 0.5769 * self.i1_buf[4]
- 0.0962 * self.i1_buf[6])
* adj;
let jq = (0.0962 * self.q1_buf[0] + 0.5769 * self.q1_buf[2]
- 0.5769 * self.q1_buf[4]
- 0.0962 * self.q1_buf[6])
* adj;
let mut i2 = i1 - jq;
let mut q2 = q1 + ji;
i2 = 0.2 * i2 + 0.8 * self.prev_i2;
q2 = 0.2 * q2 + 0.8 * self.prev_q2;
let mut re = i2 * self.prev_i2 + q2 * self.prev_q2;
let mut im = i2 * self.prev_q2 - q2 * self.prev_i2;
re = 0.2 * re + 0.8 * self.prev_re;
im = 0.2 * im + 0.8 * self.prev_im;
self.prev_i2 = i2;
self.prev_q2 = q2;
self.prev_re = re;
self.prev_im = im;
let mut new_period = if im.abs() > f64::EPSILON && re.abs() > f64::EPSILON {
2.0 * PI / im.atan2(re)
} else {
self.prev_period
};
new_period = new_period.min(1.5 * self.prev_period);
new_period = new_period.max(0.67 * self.prev_period);
new_period = new_period.clamp(6.0, 50.0);
self.prev_period = 0.2 * new_period + 0.8 * self.prev_period;
self.prev_smooth_period = 0.33 * self.prev_period + 0.67 * self.prev_smooth_period;
let smooth_period = self.prev_smooth_period;
let dc_period = ((smooth_period + 0.5) as usize).clamp(1, self.smooth_price.len());
// Dominant-cycle phase over one cycle window.
let mut real_part = 0.0;
let mut imag_part = 0.0;
for i in 0..dc_period {
let angle = (i as f64) * 2.0 * PI / (dc_period as f64);
let sp = self.smooth_price[i];
real_part += angle.sin() * sp;
imag_part += angle.cos() * sp;
}
let dc_phase = compute_dc_phase(real_part, imag_part, smooth_period);
let sine = (dc_phase * PI / 180.0).sin();
let lead_sine = ((dc_phase + 45.0) * PI / 180.0).sin();
// Instantaneous trendline: average smoothed price over the cycle window,
// then a 4-3-2-1 weighted smoothing of that running average.
let mut trend_sum = 0.0;
for i in 0..dc_period {
trend_sum += self.smooth_price[i];
}
trend_sum /= dc_period as f64;
let trendline = (4.0 * trend_sum + 3.0 * self.it1 + 2.0 * self.it2 + self.it3) / 10.0;
self.it3 = self.it2;
self.it2 = self.it1;
self.it1 = trend_sum;
// Trend / cycle decision (assume trend, override to cycle).
let mut trend = 1.0_f64;
// A crossing of sine and lead-sine restarts the cycle clock.
if (sine > lead_sine && self.prev_sine <= self.prev_lead_sine)
|| (sine < lead_sine && self.prev_sine >= self.prev_lead_sine)
{
self.days_in_trend = 0.0;
trend = 0.0;
}
self.days_in_trend += 1.0;
if self.days_in_trend < 0.5 * smooth_period {
trend = 0.0;
}
// Cycle mode while the phase advances at roughly the dominant-cycle rate.
let delta_phase = dc_phase - self.prev_dc_phase;
if smooth_period != 0.0
&& delta_phase > 0.67 * 360.0 / smooth_period
&& delta_phase < 1.5 * 360.0 / smooth_period
{
trend = 0.0;
}
// Force trend mode when price separates from the trendline.
if trendline != 0.0 && ((smooth - trendline) / trendline).abs() >= 0.015 {
trend = 1.0;
}
self.prev_dc_phase = dc_phase;
self.prev_sine = sine;
self.prev_lead_sine = lead_sine;
if self.count < 50 {
return None;
}
self.last_value = Some(trend);
Some(trend)
}
fn reset(&mut self) {
self.smooth_buf.clear();
self.detrender_buf.clear();
self.q1_buf.clear();
self.i1_buf.clear();
self.smooth_price.clear();
self.prev_i2 = 0.0;
self.prev_q2 = 0.0;
self.prev_re = 0.0;
self.prev_im = 0.0;
self.prev_period = 0.0;
self.prev_smooth_period = 0.0;
self.prev_dc_phase = 0.0;
self.prev_sine = 0.0;
self.prev_lead_sine = 0.0;
self.days_in_trend = 0.0;
self.it1 = 0.0;
self.it2 = 0.0;
self.it3 = 0.0;
self.count = 0;
self.last_value = None;
}
fn warmup_period(&self) -> usize {
50
}
fn is_ready(&self) -> bool {
self.last_value.is_some()
}
fn name(&self) -> &'static str {
"HT_TRENDMODE"
}
}
/// Recovers the dominant-cycle phase (degrees) from the real/imaginary parts of
/// the one-cycle homodyne integration, then unwraps it into TA-Lib's
/// `[-45, 315)` output range with the 4-bar smoother group-delay correction.
///
/// When `imag_part` is within `±0.001` of zero the `atan` is undefined, so the
/// phase collapses to `±90°` by the sign of `real_part`.
fn compute_dc_phase(real_part: f64, imag_part: f64, smooth_period: f64) -> f64 {
let mut dc_phase = if imag_part.abs() > 0.001 {
(real_part / imag_part).atan().to_degrees()
} else if real_part < 0.0 {
-90.0
} else {
90.0
};
dc_phase += 90.0;
dc_phase += 360.0 / smooth_period;
if imag_part < 0.0 {
dc_phase += 180.0;
}
if dc_phase > 315.0 {
dc_phase -= 360.0;
}
dc_phase
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
/// A trending ramp followed by a clean cycle, so both modes are exercised.
fn mixed_prices() -> Vec<f64> {
let mut v = Vec::new();
for i in 0..150 {
v.push(100.0 + f64::from(i) * 0.8);
}
for i in 0..200 {
v.push(220.0 + (f64::from(i) * 0.45).sin() * 12.0);
}
v
}
#[test]
fn accessors_and_metadata() {
let ht = HtTrendMode::new();
assert_eq!(ht.warmup_period(), 50);
assert_eq!(ht.name(), "HT_TRENDMODE");
assert!(!ht.is_ready());
assert!(ht.value().is_none());
}
#[test]
fn near_zero_imaginary_collapses_to_signed_ninety() {
// A near-zero imaginary part makes atan(real/imag) undefined, so the phase
// collapses to +90 for non-negative real and -90 for negative real before
// the +90 offset and group-delay correction unwrap it.
let pos = compute_dc_phase(1.0, 0.0, 20.0);
let neg = compute_dc_phase(-1.0, 0.0, 20.0);
assert!((pos - 198.0).abs() < 1e-9);
assert!((neg - 18.0).abs() < 1e-9);
// The normal path still flows through atan.
let mid = compute_dc_phase(1.0, 1.0, 20.0);
assert!((mid - 153.0).abs() < 1e-9);
}
#[test]
fn emits_binary_flag_and_visits_both_modes() {
let mut ht = HtTrendMode::new();
let out: Vec<Option<f64>> = ht.batch(&mixed_prices());
assert_eq!(out[0], None);
assert!(ht.is_ready());
let mut saw_trend = false;
let mut saw_cycle = false;
for v in out.into_iter().flatten() {
assert!(v == 0.0 || v == 1.0, "trend mode must be binary, got {v}");
if v == 1.0 {
saw_trend = true;
} else {
saw_cycle = true;
}
}
assert!(saw_trend, "ramp segment should report trend mode");
assert!(saw_cycle, "cycle segment should report cycle mode");
}
#[test]
fn ignores_non_finite_input() {
let mut ht = HtTrendMode::new();
let _ = ht.batch(&mixed_prices());
let before = ht.value();
assert_eq!(ht.update(f64::NAN), before);
}
#[test]
fn batch_equals_streaming() {
let prices = mixed_prices();
let mut a = HtTrendMode::new();
let mut b = HtTrendMode::new();
let batch = a.batch(&prices);
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
assert_eq!(batch, streamed);
}
#[test]
fn reset_clears_state() {
let mut ht = HtTrendMode::new();
let _ = ht.batch(&mixed_prices());
assert!(ht.is_ready());
ht.reset();
assert!(!ht.is_ready());
assert_eq!(ht.update(100.0), None);
}
}
@@ -0,0 +1,230 @@
//! Identical Three Crows candlestick pattern.
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Identical Three Crows — a 3-bar bearish reversal: three consecutive red
/// candles with steadily lower closes where each candle opens at (or very near)
/// the prior candle's close, so the bodies stack in an identical staircase.
///
/// ```text
/// tol_n = tolerance * max(|open|, |prev.close|)
/// all three red (close < open)
/// declining closes (bar2.close < bar1.close, bar3.close < bar2.close)
/// bar2 opens at bar1's close (|bar2.open bar1.close| <= tol_2)
/// bar3 opens at bar2's close (|bar3.open bar2.close| <= tol_3)
/// ```
///
/// Output is `1.0` when the pattern completes and `0.0` otherwise. Identical
/// Three Crows is a single-direction (bearish-only) pattern, so it never emits
/// `+1.0`. 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 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` bearish, `0.0` no pattern — so it drops straight into
/// a machine-learning feature matrix as a single dimension.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, IdenticalThreeCrows, Indicator};
///
/// let mut indicator = IdenticalThreeCrows::new();
/// indicator.update(Candle::new(13.0, 13.1, 11.9, 12.0, 1.0, 0).unwrap());
/// indicator.update(Candle::new(12.0, 12.1, 10.9, 11.0, 1.0, 1).unwrap());
/// let out = indicator
/// .update(Candle::new(11.0, 11.1, 9.9, 10.0, 1.0, 2).unwrap());
/// assert_eq!(out, Some(-1.0));
/// ```
#[derive(Debug, Clone)]
pub struct IdenticalThreeCrows {
tolerance: f64,
prev: Option<Candle>,
prev_prev: Option<Candle>,
has_emitted: bool,
}
impl Default for IdenticalThreeCrows {
fn default() -> Self {
Self::new()
}
}
impl IdenticalThreeCrows {
/// Construct a detector with the default relative 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 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: "identical three crows tolerance must lie in [0, 1)",
});
}
Ok(Self {
tolerance,
prev: None,
prev_prev: None,
has_emitted: false,
})
}
/// Configured relative tolerance.
pub fn tolerance(&self) -> f64 {
self.tolerance
}
}
impl Indicator for IdenticalThreeCrows {
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 tol2 = self.tolerance * bar2.open.abs().max(bar1.close.abs());
let tol3 = self.tolerance * candle.open.abs().max(bar2.close.abs());
if bar1.close < bar1.open
&& bar2.close < bar2.open
&& candle.close < candle.open
&& bar2.close < bar1.close
&& candle.close < bar2.close
&& (bar2.open - bar1.close).abs() <= tol2
&& (candle.open - bar2.close).abs() <= tol3
{
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 {
"IdenticalThreeCrows"
}
}
#[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!(IdenticalThreeCrows::with_tolerance(-0.01).is_err());
assert!(IdenticalThreeCrows::with_tolerance(1.0).is_err());
}
#[test]
fn accepts_valid_tolerance() {
let t = IdenticalThreeCrows::with_tolerance(0.0).unwrap();
assert!((t.tolerance() - 0.0).abs() < 1e-12);
}
#[test]
fn accessors_and_metadata() {
let t = IdenticalThreeCrows::default();
assert_eq!(t.name(), "IdenticalThreeCrows");
assert_eq!(t.warmup_period(), 3);
assert!(!t.is_ready());
assert!((t.tolerance() - 0.001).abs() < 1e-12);
}
#[test]
fn identical_three_crows_is_minus_one() {
let mut t = IdenticalThreeCrows::new();
// Three red candles, each opening at the prior close, declining.
assert_eq!(t.update(c(13.0, 13.1, 11.9, 12.0, 0)), Some(0.0));
assert_eq!(t.update(c(12.0, 12.1, 10.9, 11.0, 1)), Some(0.0));
assert_eq!(t.update(c(11.0, 11.1, 9.9, 10.0, 2)), Some(-1.0));
}
#[test]
fn non_identical_opens_yield_zero() {
let mut t = IdenticalThreeCrows::new();
t.update(c(13.0, 13.1, 11.9, 12.0, 0));
t.update(c(12.0, 12.1, 10.9, 11.0, 1));
// bar3 opens at 10.0, far from bar2's close (11.0) -> not identical.
assert_eq!(t.update(c(10.0, 10.1, 8.9, 9.0, 2)), Some(0.0));
}
#[test]
fn rising_close_yields_zero() {
let mut t = IdenticalThreeCrows::new();
t.update(c(13.0, 13.1, 11.9, 12.0, 0));
t.update(c(12.0, 12.1, 10.9, 11.0, 1));
// bar3 is green -> not three crows.
assert_eq!(t.update(c(11.0, 12.2, 10.9, 12.0, 2)), Some(0.0));
}
#[test]
fn first_two_bars_return_zero() {
let mut t = IdenticalThreeCrows::new();
assert_eq!(t.update(c(13.0, 13.1, 11.9, 12.0, 0)), Some(0.0));
assert_eq!(t.update(c(12.0, 12.1, 10.9, 11.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 + 0.1, base - 1.1, base - 1.0, i)
})
.collect();
let mut a = IdenticalThreeCrows::new();
let mut b = IdenticalThreeCrows::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut t = IdenticalThreeCrows::new();
t.update(c(13.0, 13.1, 11.9, 12.0, 0));
t.update(c(12.0, 12.1, 10.9, 11.0, 1));
t.update(c(11.0, 11.1, 9.9, 10.0, 2));
assert!(t.is_ready());
t.reset();
assert!(!t.is_ready());
assert_eq!(t.update(c(13.0, 13.1, 11.9, 12.0, 0)), Some(0.0));
}
}

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