Compare commits

...

37 Commits

Author SHA1 Message Date
kingchenc ac8f6acf08 release: bump 0.5.5 -> 0.5.6 (#180)
Release bump `0.5.5 → 0.5.6` for the Momentum Oscillators family deepening
(#179): ten new indicators (DisparityIndex, FisherRsi, Rmi, DerivativeOscillator,
Rsx, DynamicMomentumIndex, IntradayMomentumIndex, StochasticCci, ElderRay, Qqe),
counter now 413.

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

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

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

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

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

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

All are scalar `f64 → f64`:

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

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

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

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

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

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

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

## What's added

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

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

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

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

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

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

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

## Verification

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

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

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

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

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

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

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

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

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

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

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

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

## Detectors

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

## Touchpoints

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

## Verification

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

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

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

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

## Detectors

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

## Touchpoints

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

## Verification

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

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

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

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

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

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

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

## Indicators

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

## Bindings

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

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

## Verification

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

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

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

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

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

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

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

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

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

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

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

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

## Input model

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

## Wiring

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

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

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

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

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

## Indicators

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

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

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

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

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

## Core

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

## Bindings

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

## Tests / Fuzz

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

## Verify

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

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

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

## Change

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

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

## Verify

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Each indicator ships the full chain: core + every-branch unit tests, Python /
Node / WASM bindings, fuzz coverage, README counter + family rows, CHANGELOG.
`cargo test`, doctests, `clippy -D warnings`, `npm test` and pytest all green
locally; mod-count == lib-block == README counter (314), FAMILIES total 309.
2026-06-03 02:26:38 +02:00
kingchenc 9a98e9bf55 release: bump 0.4.4 -> 0.4.5 (#147)
Version bump for 0.4.5: ships Anchored RSI, Volume Profile, TPO Profile and the Alt-Chart Bars family (Renko/Kagi/Point & Figure). Indicator count 289 -> 295.
2026-06-02 22:14:47 +02:00
kingchenc d4b3f9dbd1 feat: add Alt-Chart Bars (Renko, Kagi, Point & Figure) via a BarBuilder trait (#146)
Introduces a BarBuilder trait for price-driven chart constructors that emit a variable number of bars per candle (deliberately not Indicator). Adds Renko (box-size bricks, 2-box reversal), Kagi (reversal-amount segments) and Point & Figure (box-size X/O columns, N-box reversal) in a new Alt-Chart Bars family, with custom Python/Node/WASM bindings, a dedicated fuzz target, tests and docs. Indicator count 292 -> 295.
2026-06-02 21:56:00 +02:00
kingchenc f37eedd44e feat: add Volume Profile and TPO Profile to the market profile family (#145)
Volume Profile exposes the full per-bin volume histogram (price bounds plus raw distribution) that Value Area reduces to POC/VAH/VAL. TPO Profile is the volume-agnostic Time-Price-Opportunity letter count over a rolling window. Both candle-input, Vec-output, Market Profile family, with custom Python/Node/WASM bindings, fuzz, benches, tests and docs. Indicator count 290 -> 292.
2026-06-02 21:16:30 +02:00
kingchenc 93097db482 feat: add Anchored RSI to the momentum oscillators family (#144)
Cumulative Relative Strength Index whose averaging begins at a runtime-chosen anchor bar (set_anchor), the momentum counterpart to Anchored VWAP. Scalar f64 input, 0..=100 output; wired through core, Python, Node and WASM bindings, fuzz, benches, tests and docs. Indicator count 289 -> 290.
2026-06-02 20:50:56 +02:00
200 changed files with 47919 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
+1 -1
View File
@@ -536,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));
"
+7
View File
@@ -33,6 +33,13 @@ jobs:
with:
results_file: results.sarif
results_format: sarif
# The default GITHUB_TOKEN cannot read classic branch-protection
# rules, so the Branch-Protection check fails with an internal error
# and scores -1. A read-only fine-grained PAT (Administration: read,
# Contents: read, Metadata: read) supplied as SCORECARD_TOKEN lets the
# check read the protection settings. See
# https://github.com/ossf/scorecard-action/blob/main/docs/authentication/fine-grained-auth-token.md
repo_token: ${{ secrets.SCORECARD_TOKEN }}
# Publish to the public OpenSSF endpoint that backs the README badge.
publish_results: true
+40 -105
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,69 +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 \
&& grep -qE "\*\*${n} indicators\*\*" docs/README.md; then
echo "matches=true" >> "$GITHUB_OUTPUT"
echo "README + docs/README counter already at ${n}; nothing to do."
else
echo "matches=false" >> "$GITHUB_OUTPUT"
echo "README/docs 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 / docs/README.md say a different indicator count than lib.rs (${n}). This PR is from a fork, so the workflow cannot push the fix; please set README.md to '${n} streaming-first indicators' and docs/README.md to '**${n} indicators**', then 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 }}"
# docs/README.md carries the count in its docs.wickra.org pointer prose
# ("**N indicators**"); keep it in sync with README's prose count.
sed -i -E "s/[0-9]+ (streaming-first )?indicators/${n} \1indicators/g" README.md docs/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. (README only — docs has no banner.)
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 docs/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'
+195 -1
View File
@@ -7,6 +7,190 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [0.5.6] - 2026-06-04
- **QQE** — quantitative qualitative estimation, a smoothed RSI with an ATR-of-RSI trailing line (`QQE`).
- **Intraday Momentum Index** — intraday momentum index (Chande), RSI on the open-to-close body (`IMI`).
- **Elder Ray** — Elder Ray bull power and bear power around an EMA of close (`ElderRay`).
- **Derivative Oscillator** — derivative oscillator (Constance Brown), a double-smoothed RSI histogram (`DerivativeOscillator`).
- **RMI** — relative momentum index (RMI), RSI over a multi-bar momentum lookback (`RMI`).
- **Stochastic CCI** — stochastic CCI, a stochastic oscillator over the CCI (`StochasticCCI`).
- **Dynamic Momentum Index** — dynamic momentum index (Chande), a volatility-adaptive RSI (`DynamicMomentumIndex`).
- **RSX** — RSX, a Jurik-style three-stage smoothed RSI (`RSX`).
- **Fisher RSI** — Fisher RSI, the Fisher transform of a normalised RSI (`FisherRSI`).
- **Disparity Index** — disparity index, the percent gap between price and its moving average (`DisparityIndex`).
## [0.5.5] - 2026-06-04
- **GD** — generalized DEMA (GD), Tillson's volume-factor double EMA and the building block of T3 (`GD`).
- **GMA** — geometric moving average (GMA), the rolling geometric mean of prices (`GMA`).
- **Holt-Winters** — Holt's linear (double exponential) smoothing with level and trend components (`HoltWinters`).
- **Adaptive Laguerre** — Ehlers adaptive Laguerre filter with median-error-adaptive gamma (`AdaptiveLaguerre`).
- **Median MA** — median moving average, the rolling median of prices (`MedianMA`).
- **EHMA** — exponential Hull moving average (EHMA), the Hull construction built from EMAs (`EHMA`).
- **SWMA** — sine-weighted moving average (SWMA), a symmetric half-cycle sine window (`SWMA`).
## [0.5.4] - 2026-06-04
- **Roll Measure** — effective spread implied by the negative serial covariance of trade-price changes (Roll 1984) (`RollMeasure`).
- **Amihud Illiquidity** — average absolute log return per unit of traded value (price-impact liquidity proxy, Amihud 2002) (`AmihudIlliquidity`).
- **VPIN** — volume-synchronised probability of informed trading (volume-bucketed order-flow toxicity) (`Vpin`).
- **Order Flow Imbalance** — rolling sum of best-level order-flow events (Cont-Kukanov-Stoikov OFI) (`OrderFlowImbalance`).
- **Expectancy** — expected return per unit of average loss (R-multiple) over a rolling window of returns (`Expectancy`).
- **Win Rate** — fraction of strictly-positive returns over a rolling window (`WinRate`).
- **Regime Label** — volatility-quantile regime classification: 1 calm / 0 normal / +1 stressed, by where the rolling volatility sits in its own recent distribution (`RegimeLabel`).
- **Jump Indicator** — flags return outliers beyond `threshold ×` trailing return volatility (1 down / 0 / +1 up) (`JumpIndicator`).
- **Trend Label** — discrete trend state from the sign of the rolling least-squares slope (1 / 0 / +1) (`TrendLabel`).
- **High-Low Range** — bar high-low range as a fraction of close (scale-free per-bar volatility) (`HighLowRange`).
- **Wick Ratio** — signed upper-vs-lower shadow imbalance as a fraction of the range (`WickRatio`).
- **Body Size Percent** — absolute candle body as a fraction of the bar range (`BodySizePct`).
- **Close vs Open** — signed body as a fraction of the open price, `(close open) / open` (`CloseVsOpen`).
- **Spread AR(1) Coefficient** — first-order autoregression coefficient of the spread `a b` (direct cointegration / mean-reversion strength) (`SpreadAr1Coefficient`).
- **Rolling Quantile** — interpolated q-th quantile over a trailing window (type-7 / NumPy default) (`RollingQuantile`).
- **Rolling Percentile Rank** — percentile rank of the latest value within its trailing window (`RollingPercentileRank`).
- **Rolling IQR** — interquartile range (Q3 Q1) over a trailing window (robust dispersion) (`RollingIqr`).
- **Realized Volatility** — square root of the summed squared log returns (raw, un-annualised quadratic variation) (`RealizedVolatility`).
- **Log Return** — logarithmic return over a fixed lag, `ln(price_t / price_{tperiod})` (`LogReturn`).
## [0.5.3] - 2026-06-04
- **Fibonacci Time Zones** — vertical markers at Fibonacci bar-distances (1/2/3/5/8/...) from the latest swing pivot (`FIB_TIME_ZONES`).
- **Fibonacci Channel** — a sloped base trendline plus parallel lines at Fibonacci multiples of the channel width (`FIB_CHANNEL`).
- **Fibonacci Arcs** — semicircular retracement levels centred on the swing end, normalised by leg bar-width (`FIB_ARCS`).
- **Fibonacci Fan** — three trendlines fanning from a swing start through its 38.2/50/61.8% retracement levels (`FIB_FAN`).
- **Fibonacci Confluence** — densest cluster of retracement levels across recent swing legs (price + strength) (`FIB_CONFLUENCE`).
- **Golden Pocket** — the 0.618-0.65 optimal-trade-entry band of the most recent swing leg (`GOLDEN_POCKET`).
- **Auto-Fibonacci** — retracement anchored on the dominant (largest-magnitude) leg among recent swings (`AUTO_FIB`).
- **Fibonacci Projection** — measured-move target zone from the last three pivots (A-B-C), projecting A->B from C (`FIB_PROJECTION`).
- **Fibonacci Extension** — projects the latest swing leg to the canonical extension ratios (127.2/141.4/161.8/200/261.8%) (`FIB_EXTENSION`).
- **Fibonacci Retracement** — seven retracement levels (0/23.6/38.2/50/61.8/78.6/100%) of the most recent confirmed swing leg (`FIB_RETRACEMENT`).
## [0.5.2] - 2026-06-03
### Added
- **Three Drives** — three symmetric drives with extension legs; bullish +1, bearish -1 (`THREE_DRIVES`).
- **Cypher** — five-point harmonic whose D retraces XC by 0.786; bullish +1, bearish -1 (`CYPHER`).
- **Shark** — five-point harmonic with an expansion leg and 0.886-1.13 D; bullish +1, bearish -1 (`SHARK`).
- **Crab** — five-point harmonic with the deepest (1.618 XA) D completion; bullish +1, bearish -1 (`CRAB`).
- **Bat** — five-point harmonic with a shallow B and 0.886 D completion; bullish +1, bearish -1 (`BAT`).
- **Butterfly** — five-point harmonic with an extended (1.27-1.618 XA) D; bullish +1, bearish -1 (`BUTTERFLY`).
- **Gartley** — five-point harmonic with a 0.786 D completion; bullish +1, bearish -1 (`GARTLEY`).
- **AB=CD** — four-point AB=CD harmonic: BC retraces AB, CD mirrors AB; bullish +1, bearish -1 (`ABCD`).
- **Cup and Handle** — rounded base with a shallow handle near the rim; bullish +1, inverse -1 (`CUP_AND_HANDLE`).
- **Rectangle / Range** — flat support and resistance; mean-reversion signal off the just-touched boundary; support +1, resistance -1 (`RECTANGLE_RANGE`).
- **Flag / Pennant** — shallow consolidation against a sharp pole; continuation in the pole direction; bull +1, bear -1 (`FLAG_PENNANT`).
- **Wedge (rising/falling)** — both trendlines slope the same way but converge; rising wedge -1, falling wedge +1 (`WEDGE`).
- **Triangle (asc/desc/sym)** — converging trendlines; ascending +1, descending -1, symmetrical follows the last swing (`TRIANGLE`).
- **Head and Shoulders** — central head flanked by two matching shoulders over a flat neckline; top -1, inverse +1 (`HEAD_AND_SHOULDERS`).
- **Triple Top / Bottom** — three matching peaks / troughs; a stronger reversal than the double; bearish -1, bullish +1 (`TRIPLE_TOP_BOTTOM`).
- **Double Top / Bottom** — twin-peak / twin-trough reversal confirmed on the second matching swing extreme; bearish -1, bullish +1 (`DOUBLE_TOP_BOTTOM`).
## [0.5.1] - 2026-06-03
### Added — Seasonality & Session family (12 indicators)
- **Volume-by-Time Profile** — mean traded volume bucketed by intraday time (`VOLUME_BY_TIME_PROFILE`).
- **Intraday Volatility Profile** — return standard deviation bucketed by intraday time (`INTRADAY_VOLATILITY_PROFILE`).
- **Day-of-Week Profile** — mean bar return bucketed by weekday (`DAY_OF_WEEK_PROFILE`).
- **Time-of-Day Return Profile** — mean bar return bucketed by intraday time (`TIME_OF_DAY_RETURN_PROFILE`).
- **Seasonal Z-Score** — z-score of the current return versus the same hour-of-day history (`SEASONAL_Z_SCORE`).
- **Turn-of-Month** — mean daily return inside the turn-of-month window (`TURN_OF_MONTH`).
- **Overnight/Intraday Return** — decomposition of session return into overnight and intraday legs (`OVERNIGHT_INTRADAY_RETURN`).
- **Overnight Gap** — close-to-open return across the session boundary (`OVERNIGHT_GAP`).
- **Average Daily Range** — mean high-low range of the last N completed sessions (`AVERAGE_DAILY_RANGE`).
- **Session Range** — per-session (Asia/EU/US) high-low range (`SESSION_RANGE`).
- **Session High/Low** — running high and low of the current session (`SESSION_HIGH_LOW`).
- **Session VWAP** — session-anchored volume-weighted average price (`SESSION_VWAP`).
## [0.5.0] - 2026-06-03
### Added
- **TICK Index** — instantaneous net advancing-minus-declining issues (`TICK_INDEX`).
- **Absolute Breadth Index** — absolute value of net advancing-minus-declining issues (`ABSOLUTE_BREADTH_INDEX`).
- **Cumulative Volume Index** — running total of volume-normalised net advancing volume (`CUMULATIVE_VOLUME_INDEX`).
- **Bullish Percent Index** — percentage of the universe on a point-and-figure buy signal (`BULLISH_PERCENT_INDEX`).
- **Up/Down Volume Ratio** — advancing volume divided by declining volume (`UP_DOWN_VOLUME_RATIO`).
- **Percent Above Moving Average** — percentage of the universe trading above its reference moving average (`PERCENT_ABOVE_MA`).
- **High-Low Index** — moving average of the record-high percentage (`HIGH_LOW_INDEX`).
- **New Highs - New Lows** — net count of new period highs minus new period lows (`NEW_HIGHS_NEW_LOWS`).
- **Breadth Thrust** — moving average of the advancing-issues share (Zweig) (`BREADTH_THRUST`).
- **TRIN / Arms Index** — advance-decline ratio divided by the up-down volume ratio (`TRIN`).
- **McClellan Summation Index** — running cumulative total of the McClellan Oscillator (`MCCLELLAN_SUMMATION_INDEX`).
- **McClellan Oscillator** — spread between a 19- and 39-period EMA of ratio-adjusted net advances (`MCCLELLAN_OSCILLATOR`).
- **Advance/Decline Volume Line** — cumulative net advancing-minus-declining volume across the universe (`AD_VOLUME_LINE`).
- **Advance/Decline Ratio** — advancing issues divided by declining issues across the universe (`ADVANCE_DECLINE_RATIO`).
### Changed
- **Relicensed** from PolyForm Noncommercial 1.0.0 to dual **MIT OR Apache-2.0**. Wickra is now OSI-approved, permissive open source; commercial use is permitted under either license. See [`LICENSE-MIT`](LICENSE-MIT) and [`LICENSE-APACHE`](LICENSE-APACHE).
## [0.4.7] - 2026-06-03
### Added
- **Spread Bollinger Bands** — Bollinger bands on the spread of two series for pairs mean-reversion (`SPREAD_BOLLINGER_BANDS`).
- **Kalman Hedge Ratio** — Kalman-filter dynamic hedge ratio and spread between two series (`KALMAN_HEDGE_RATIO`).
- **Granger Causality** — Granger causality F-statistic measuring whether one series predicts another (`GRANGER_CAUSALITY`).
- **Variance Ratio** — Lo-MacKinlay variance-ratio test on the spread of two series (`VARIANCE_RATIO`).
- **Beta-Neutral Spread** — beta-neutral spread: the rolling OLS regression residual of two series (`BETA_NEUTRAL_SPREAD`).
- **Distance SSD** — Gatev sum-of-squared-deviations distance between two normalised series (`DISTANCE_SSD`).
- **Spread Hurst** — Hurst exponent of the spread of two series for regime detection (`SPREAD_HURST`).
- **OU Half-Life** — Ornstein-Uhlenbeck half-life of mean reversion for the spread of two series (`OU_HALF_LIFE`).
- **Rolling Covariance** — rolling covariance of the period-over-period returns of two series (`ROLLING_COVARIANCE`).
- **Rolling Correlation** — rolling Pearson correlation of the period-over-period returns of two series (`ROLLING_CORRELATION`).
- **Market Breadth family** — a new indicator family built on a new
`CrossSection` input type that carries the per-symbol state of an entire
universe in one tick (each `Member` holds a signed `change`, a `volume`, and
`new_high` / `new_low` flags). `CrossSection::new` validates the universe
(non-empty, finite changes, finite non-negative volumes); `new_unchecked`
skips validation for hot paths.
- `AdvanceDecline` (`ADVANCE_DECLINE`) — the Advance/Decline Line, the running
cumulative sum of net advancing-minus-declining issues across the universe.
## [0.4.6] - 2026-06-03
### Added
- **TA-Lib parity — Directional Movement components** — the ADX building blocks,
previously available only bundled inside `Adx`, as standalone single-output
indicators:
- `PlusDm` (`PLUS_DM`) — Wilder-smoothed plus directional movement.
- `MinusDm` (`MINUS_DM`) — Wilder-smoothed minus directional movement.
- `PlusDi` (`PLUS_DI`) — plus directional indicator, `100 · smoothed(+DM) / ATR`.
- `MinusDi` (`MINUS_DI`) — minus directional indicator, `100 · smoothed(-DM) / ATR`.
- `Dx` (`DX`) — directional movement index, `100 · |+DI DI| / (+DI + DI)`.
- **TA-Lib parity — price transforms** — window and per-bar price aggregates:
- `MidPrice` (`MIDPRICE`) — `(highest high + lowest low) / 2` over a window.
- `MidPoint` (`MIDPOINT`) — `(max + min) / 2` of a scalar series over a window.
- `AvgPrice` (`AVGPRICE`) — per-bar `(open + high + low + close) / 4`.
- **TA-Lib parity — rate-of-change variants** — the ratio forms of `Roc`:
- `Rocp` (`ROCP`) — `(close close[period]) / close[period]` (fraction).
- `Rocr` (`ROCR`) — `close / close[period]` (ratio).
- `Rocr100` (`ROCR100`) — `close / close[period] · 100`.
- **TA-Lib parity — linear-regression outputs** — the remaining OLS endpoints:
- `LinRegIntercept` (`LINEARREG_INTERCEPT`) — the OLS intercept `a`.
- `Tsf` (`TSF`) — time series forecast, `a + b·period` (one bar ahead).
- **TA-Lib parity — `MacdFix` (`MACDFIX`)** — MACD with fast/slow fixed at 12/26
and only the signal period configurable; output is the usual `{macd, signal,
histogram}` triple.
- **TA-Lib parity — `SarExt` (`SAREXT`)** — Parabolic SAR with a start value,
reversal offset, independent long/short acceleration, and a signed output
(positive in long phases, negative in short phases).
- **TA-Lib parity — `MacdExt` (`MACDEXT`)** — MACD with an independently
selectable moving-average type (new `MaType` enum: SMA/EMA/WMA/DEMA/TEMA/TRIMA)
for each of the fast, slow and signal lines.
- **TA-Lib parity — `HtPhasor` (`HT_PHASOR`)** — the in-phase and quadrature
components of the Hilbert-transform analytic signal, as a `{inphase,
quadrature}` pair.
- **TA-Lib parity — `HtDcPhase` (`HT_DCPHASE`)** — the phase angle (in degrees)
of the Hilbert-transform dominant cycle.
- **TA-Lib parity — `HtTrendMode` (`HT_TRENDMODE`)** — Ehlers' trend (`1`) vs
cycle (`0`) classification from the Hilbert-transform dominant cycle.
## [0.4.5] - 2026-06-02
### Added
- **Anchored RSI** — a cumulative Relative Strength Index whose averaging begins at a runtime-chosen anchor bar (`set_anchor`), the momentum counterpart to Anchored VWAP. Every up- and down-move since the anchor is weighted equally, so it reports the RSI of the entire move since the anchor point. Scalar input, Momentum Oscillators family; available in Rust, Python, Node and WASM.
- **Volume Profile** — the full per-bin volume distribution over a rolling window, exposing the raw histogram (price bounds plus per-bin volume) that Value Area reduces to POC/VAH/VAL. Market Profile family; candle input, available in Rust, Python, Node and WASM.
- **TPO Profile** — the Time-Price-Opportunity (market-profile letter) distribution: a volume-agnostic count of how many periods traded at each price level over a rolling window. Market Profile family; candle input, available in Rust, Python, Node and WASM.
- **Alt-Chart Bars** — a new `BarBuilder` trait and family of price-driven chart constructors that emit a variable number of completed bars per candle (so they are deliberately not `Indicator`s): **Renko** (fixed box-size bricks with the 2-box reversal rule), **Kagi** (reversal-amount line segments), and **Point & Figure** (box-size X/O columns with an N-box reversal). Available in Rust, Python, Node and WASM.
## [0.4.4] - 2026-06-02
### Added
@@ -1075,7 +1259,17 @@ 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.4...HEAD
[Unreleased]: https://github.com/wickra-lib/wickra/compare/v0.5.6...HEAD
[0.5.6]: https://github.com/wickra-lib/wickra/compare/v0.5.5...v0.5.6
[0.5.5]: https://github.com/wickra-lib/wickra/compare/v0.5.4...v0.5.5
[0.5.4]: https://github.com/wickra-lib/wickra/compare/v0.5.3...v0.5.4
[0.5.3]: https://github.com/wickra-lib/wickra/compare/v0.5.2...v0.5.3
[0.5.2]: https://github.com/wickra-lib/wickra/compare/v0.5.1...v0.5.2
[0.5.1]: https://github.com/wickra-lib/wickra/compare/v0.5.0...v0.5.1
[0.5.0]: https://github.com/wickra-lib/wickra/compare/v0.4.7...v0.5.0
[0.4.7]: https://github.com/wickra-lib/wickra/compare/v0.4.6...v0.4.7
[0.4.6]: https://github.com/wickra-lib/wickra/compare/v0.4.5...v0.4.6
[0.4.5]: https://github.com/wickra-lib/wickra/compare/v0.4.4...v0.4.5
[0.4.4]: https://github.com/wickra-lib/wickra/compare/v0.4.3...v0.4.4
[0.4.3]: https://github.com/wickra-lib/wickra/compare/v0.4.2...v0.4.3
[0.4.2]: https://github.com/wickra-lib/wickra/compare/v0.4.1...v0.4.2
+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
+35 -5
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
@@ -122,3 +122,33 @@ installed. Dependabot also keeps the `.github/requirements` pins current.
Use the issue templates under
[`.github/ISSUE_TEMPLATE`](.github/ISSUE_TEMPLATE). For security-sensitive
reports, follow [`SECURITY.md`](SECURITY.md) instead of opening a public issue.
## Developer Certificate of Origin (DCO)
All contributions to Wickra are made under the [Developer Certificate of
Origin (DCO) 1.1](DCO). By signing off on your commits you certify that you
wrote the patch, or otherwise have the right to submit it under the project's
`MIT OR Apache-2.0` license.
Sign off every commit by adding a `Signed-off-by` trailer with your real name
and email — Git adds it automatically with the `-s` flag:
```bash
git commit -s -m "your message"
```
This produces a trailer of the form:
```
Signed-off-by: Your Name <you@example.com>
```
The name and email must match the commit author. Commits without a valid
sign-off line cannot be merged. To sign off a commit you already made, amend it
with `git commit -s --amend`, or sign off a range with an interactive rebase.
## Governance
Wickra's decision-making and maintainership are described in
[`GOVERNANCE.md`](GOVERNANCE.md); the current maintainers are listed in
[`MAINTAINERS.md`](MAINTAINERS.md).
Generated
+6 -6
View File
@@ -1867,7 +1867,7 @@ dependencies = [
[[package]]
name = "wickra"
version = "0.4.4"
version = "0.5.6"
dependencies = [
"approx",
"criterion",
@@ -1878,7 +1878,7 @@ dependencies = [
[[package]]
name = "wickra-core"
version = "0.4.4"
version = "0.5.6"
dependencies = [
"approx",
"proptest",
@@ -1888,7 +1888,7 @@ dependencies = [
[[package]]
name = "wickra-data"
version = "0.4.4"
version = "0.5.6"
dependencies = [
"approx",
"csv",
@@ -1915,7 +1915,7 @@ dependencies = [
[[package]]
name = "wickra-node"
version = "0.4.4"
version = "0.5.6"
dependencies = [
"napi",
"napi-build",
@@ -1925,7 +1925,7 @@ dependencies = [
[[package]]
name = "wickra-python"
version = "0.4.4"
version = "0.5.6"
dependencies = [
"numpy",
"pyo3",
@@ -1934,7 +1934,7 @@ dependencies = [
[[package]]
name = "wickra-wasm"
version = "0.4.4"
version = "0.5.6"
dependencies = [
"console_error_panic_hook",
"js-sys",
+3 -3
View File
@@ -12,11 +12,11 @@ members = [
exclude = ["fuzz"]
[workspace.package]
version = "0.4.4"
version = "0.5.6"
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.4" }
wickra-core = { path = "crates/wickra-core", version = "0.5.6" }
thiserror = "2"
rayon = "1.10"
+34
View File
@@ -0,0 +1,34 @@
Developer Certificate of Origin
Version 1.1
Copyright (C) 2004, 2006 The Linux Foundation and its contributors.
Everyone is permitted to copy and distribute verbatim copies of this
license document, but changing it is not allowed.
Developer's Certificate of Origin 1.1
By making a contribution to this project, I certify that:
(a) The contribution was created in whole or in part by me and I
have the right to submit it under the open source license
indicated in the file; or
(b) The contribution is based upon previous work that, to the best
of my knowledge, is covered under an appropriate open source
license and I have the right under that license to submit that
work with modifications, whether created in whole or in part
by me, under the same open source license (unless I am
permitted to submit under a different license), as indicated
in the file; or
(c) The contribution was provided directly to me by some other
person who certified (a), (b) or (c) and I have not modified
it.
(d) I understand and agree that this project and the contribution
are public and that a record of the contribution (including all
personal information I submit with it, including my sign-off) is
maintained indefinitely and may be redistributed consistent with
this project or the open source license(s) involved.
+71
View File
@@ -0,0 +1,71 @@
# Governance
Wickra is an open-source project maintained under a **single-maintainer
("BDFL") model**. This document describes how decisions are made and how the
project is run, so contributors know what to expect.
## Roles
- **Maintainer.** The maintainer (see [`MAINTAINERS.md`](MAINTAINERS.md)) is
responsible for the project's direction, reviews and merges changes, cuts
releases, and has final say on all technical and project decisions.
- **Contributors.** Anyone who proposes changes via pull requests, files
issues, improves documentation, or otherwise participates. Contributors do
not need any special status to take part.
## Decision-making
- Day-to-day technical decisions (APIs, indicator implementations, refactors)
are made by the maintainer, informed by discussion on issues and pull
requests.
- Proposals are raised as GitHub issues or pull requests. Significant or
breaking changes should be opened as an issue first to agree on the approach
before implementation.
- The maintainer aims to act transparently: rationale for non-trivial decisions
is recorded in the relevant issue, pull request, or commit message.
## Contribution flow
All changes — including the maintainer's own — go through pull requests so that
CI (tests, linting, static analysis) runs against them, and so the change
history is reviewable. Contribution requirements are documented in
[`CONTRIBUTING.md`](CONTRIBUTING.md), including the Developer Certificate of
Origin sign-off that every commit must carry.
## Becoming a maintainer
The project currently has one maintainer. Maintainership may be extended to
contributors who have demonstrated sustained, high-quality involvement, at the
current maintainer's discretion. If the project grows to multiple maintainers,
this document will be updated to describe shared decision-making.
## Continuity and succession
The project is designed to survive the loss of any single individual, so that
issues can be triaged, proposed changes accepted, and releases published within
one week of confirmed loss of the maintainer:
- **Credentials.** All credentials required to operate the project — the
`wickra-lib` GitHub organization, the publishing tokens for crates.io, PyPI
and npm, and the `wickra.org` domain registrar — are stored in a password
manager. A trusted contact (a family member) holds **emergency access** to
that password manager and can obtain these credentials if the maintainer can
no longer continue.
- **Continuity actions.** With that access, the trusted contact (or a delegate
they appoint) can create and close issues, accept pull requests, and publish
releases through the existing CI/CD workflows.
- **Account recovery.** The maintainer's GitHub account has recovery configured,
and ownership of the `wickra-lib` organization can be transferred to a new
maintainer.
- **Legal rights.** Legal rights to the project name and DNS are covered by the
maintainer's estate arrangements.
## Code of conduct
All participants are expected to follow the
[Code of Conduct](CODE_OF_CONDUCT.md).
## Changes to this document
This governance model may evolve as the project grows. Changes are made via
pull request and take effect once merged.
-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.
+201
View File
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or Derivative
Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2026 kingchenc and the Wickra contributors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 kingchenc and the Wickra contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+17
View File
@@ -0,0 +1,17 @@
# Maintainers
This file lists the current maintainers of Wickra. See
[`GOVERNANCE.md`](GOVERNANCE.md) for what the role entails and how the project
is run.
| Maintainer | GitHub | Areas |
| --- | --- | --- |
| kingchenc | [@kingchenc](https://github.com/kingchenc) | All (core, bindings, CI/release, docs) |
## Contacting the maintainers
- General questions and support: see [`SUPPORT.md`](SUPPORT.md).
- Bug reports and feature requests: open an issue using the
[issue templates](.github/ISSUE_TEMPLATE).
- Security reports: follow [`SECURITY.md`](SECURITY.md) — do **not** open a
public issue.
+33 -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=289" 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=413" alt="Wickra — streaming-first technical indicators" width="100%"></a>
</p>
[![CI](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml/badge.svg)](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml)
@@ -9,8 +9,9 @@
[![crates.io](https://img.shields.io/crates/v/wickra.svg?logo=rust&color=orange)](https://crates.io/crates/wickra)
[![PyPI](https://img.shields.io/pypi/v/wickra.svg?logo=pypi&color=blue)](https://pypi.org/project/wickra/)
[![npm](https://img.shields.io/npm/v/wickra.svg?logo=npm&color=red)](https://www.npmjs.com/package/wickra)
[![License: PolyForm-NC](https://img.shields.io/badge/license-PolyForm--NC--1.0.0-purple)](LICENSE)
[![License: MIT OR Apache-2.0](https://img.shields.io/badge/license-MIT_OR_Apache--2.0-blue)](#license)
[![OpenSSF Scorecard](https://api.securityscorecards.dev/projects/github.com/wickra-lib/wickra/badge)](https://scorecard.dev/viewer/?uri=github.com/wickra-lib/wickra)
[![OpenSSF Best Practices](https://www.bestpractices.dev/projects/13094/badge)](https://www.bestpractices.dev/projects/13094)
[![Build provenance](https://img.shields.io/badge/provenance-attested-brightgreen?logo=github)](https://github.com/wickra-lib/wickra/attestations)
[![Docs](https://img.shields.io/badge/docs-docs.wickra.org-0ea5e9?logo=readthedocs&logoColor=white)](https://docs.wickra.org)
@@ -47,7 +48,7 @@ Full documentation lives at **[docs.wickra.org](https://docs.wickra.org)**:
[Node](https://docs.wickra.org/Quickstart-Node),
[WASM](https://docs.wickra.org/Quickstart-WASM).
- **Indicators** — a per-indicator deep dive (formula, parameters, warmup) for
every one of the 289 indicators; start at the
every one of the 413 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,31 +136,37 @@ python -m benchmarks.compare_libraries
## Indicators
289 streaming-first indicators across eighteen families. Every one passes the
413 streaming-first indicators across twenty-four families. Every one passes the
`batch == streaming` equivalence test, reference-value tests, and reset
semantics tests. Each has a per-indicator deep dive (formula, parameters,
warmup) at [docs.wickra.org](https://docs.wickra.org/Indicators-Overview).
| Family | Indicators |
|--------|-----------|
| Moving Averages | SMA, EMA, WMA, DEMA, TEMA, HMA, KAMA, SMMA, TRIMA, ZLEMA, T3, VWMA, ALMA, McGinley Dynamic, FRAMA, VIDYA, JMA, Alligator, EVWMA |
| Momentum Oscillators | RSI (Wilder), Stochastic, CCI, ROC, Williams %R, MFI, Awesome Oscillator, MOM, CMO, TSI, PMO, StochRSI, Ultimate Oscillator, RVI, PGO, KST, SMI, Laguerre RSI, Connors RSI, Inertia |
| Trend & Directional | MACD, ADX (+DI/-DI), ADXR, Aroon, TRIX, Aroon Oscillator, Vortex, Random Walk Index, Trend Intensity Index, Wave Trend Oscillator, Mass Index, Choppiness Index, Vertical Horizontal Filter |
| Moving Averages | SMA, EMA, WMA, DEMA, TEMA, HMA, KAMA, SMMA, TRIMA, ZLEMA, T3, VWMA, ALMA, McGinley Dynamic, FRAMA, VIDYA, JMA, Alligator, EVWMA, SWMA, GMA, EHMA, Median MA, Adaptive Laguerre, GD, Holt-Winters |
| Momentum Oscillators | RSI (Wilder), Anchored RSI, Stochastic, CCI, ROC, Williams %R, MFI, Awesome Oscillator, MOM, CMO, TSI, PMO, StochRSI, Ultimate Oscillator, RVI, PGO, KST, SMI, Laguerre RSI, Connors RSI, Inertia, ROC Percentage (ROCP), ROC Ratio (ROCR), ROC Ratio 100 (ROCR100), Disparity Index, Fisher RSI, RSX, Dynamic Momentum Index, Stochastic CCI, RMI, Derivative Oscillator, Elder Ray, Intraday Momentum Index, QQE |
| Trend & Directional | MACD, MACD Fixed (MACDFIX), MACD Extended (MACDEXT), ADX (+DI/-DI), ADXR, Aroon, TRIX, Aroon Oscillator, Vortex, Random Walk Index, Trend Intensity Index, Wave Trend Oscillator, Mass Index, Choppiness Index, Vertical Horizontal Filter, Plus DM, Minus DM, Plus DI, Minus DI, DX |
| 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, Spread AR(1) Coefficient |
| Ehlers / Cycle (DSP) | MAMA, FAMA, Fisher Transform, Inverse Fisher Transform, SuperSmoother, Hilbert Dominant Cycle, Hilbert Phasor, Hilbert DC Phase, Hilbert Trend Mode, Sine Wave, Decycler, Decycler Oscillator, Roofing Filter, Center of Gravity, Cybernetic Cycle, Adaptive Cycle, Empirical Mode Decomposition, Ehlers Stochastic, Instantaneous Trendline |
| Pivots & S/R | Classic Pivots, Fibonacci Pivots, Camarilla, Woodie Pivots, DeMark Pivots, Williams Fractals, ZigZag |
| DeMark | TD Setup, TD Sequential, TD DeMarker, TD REI, TD Pressure, TD Combo, TD Countdown, TD Lines, TD Range Projection, TD Differential, TD Open, TD Risk Level |
| Ichimoku & Charts | Ichimoku Kinko Hyo (Tenkan, Kijun, Senkou A/B, Chikou), Heikin-Ashi |
| 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 |
| Chart Patterns | Double Top / Bottom, Triple Top / Bottom, Head and Shoulders, Triangle (asc/desc/sym), Wedge (rising/falling), Flag / Pennant, Rectangle / Range, Cup and Handle |
| Harmonic Patterns | AB=CD, Gartley, Butterfly, Bat, Crab, Shark, Cypher, Three Drives |
| Fibonacci | Fibonacci Retracement, Fibonacci Extension, Fibonacci Projection, Auto-Fibonacci, Golden Pocket, Fibonacci Confluence, Fibonacci Fan, Fibonacci Arcs, Fibonacci Channel, Fibonacci Time Zones |
| Microstructure | Order-Book Imbalance (Top-1 / Top-N / Full), Microprice, Quoted Spread, Depth Slope, Signed Volume, Cumulative Volume Delta, Trade Imbalance, Effective Spread, Realized Spread, Kyle's Lambda, Footprint, Order Flow Imbalance, VPIN, Amihud Illiquidity, Roll Measure |
| Derivatives | Funding Rate, Funding Rate Mean, Funding Rate Z-Score, Funding Basis, Open-Interest Delta, OI / Price Divergence, OI-Weighted Price, Long/Short Ratio, Taker Buy/Sell Ratio, Liquidation Features, Term-Structure Basis, Calendar Spread |
| Market Profile | Value Area (POC / VAH / VAL), Initial Balance, Opening Range |
| Market Profile | Value Area (POC / VAH / VAL), Volume Profile (histogram), TPO Profile, Initial Balance, Opening Range |
| Market Breadth | Advance/Decline Line, Advance/Decline Ratio, Advance/Decline Volume Line, McClellan Oscillator, McClellan Summation Index, TRIN / Arms Index, Breadth Thrust, New Highs - New Lows, High-Low Index, Percent Above Moving Average, Up/Down Volume Ratio, Bullish Percent Index, Cumulative Volume Index, Absolute Breadth Index, TICK Index |
| Risk / Performance | Sharpe Ratio, Sortino Ratio, Calmar Ratio, Omega Ratio, Max Drawdown, Average Drawdown, Drawdown Duration, Pain Index, Value at Risk, Conditional Value at Risk (CVaR), Profit Factor, Gain/Loss Ratio, Recovery Factor, Kelly Criterion, Treynor Ratio, Information Ratio, Alpha (Jensen) |
| Seasonality & Session | Session VWAP, Session High/Low, Session Range, Average Daily Range, Overnight Gap, Overnight/Intraday Return, Turn-of-Month, Seasonal Z-Score, Time-of-Day Return Profile, Day-of-Week Profile, Intraday Volatility Profile, Volume-by-Time Profile |
Every candlestick pattern emits a signed per-bar value — `+1.0` bullish,
`1.0` bearish, `0.0` none — so the family drops straight into a feature matrix
@@ -238,7 +245,7 @@ A Python live-trading example using the public `websockets` package lives at
```
wickra/
├── crates/
│ ├── wickra-core/ core engine + all 289 indicators
│ ├── wickra-core/ core engine + all 413 indicators
│ ├── wickra/ top-level facade crate (publishes on crates.io) + benches/
│ └── wickra-data/ CSV reader, tick aggregator, live exchange feeds
├── bindings/
@@ -322,13 +329,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
+36
View File
@@ -0,0 +1,36 @@
# Roadmap
This roadmap describes the project's direction at a high level. It is
intentionally non-binding: priorities shift with feedback and available time,
and the authoritative, up-to-date view of planned work is the
[issue tracker](https://github.com/wickra-lib/wickra/issues). Shipped changes
are recorded in [`CHANGELOG.md`](CHANGELOG.md).
## Status
Wickra is **pre-1.0**. The public API is largely stable but may still change in
minor releases; breaking changes are called out in the changelog.
## Themes
- **Indicator coverage.** Continue broadening the indicator catalogue across
families (trend, momentum, volatility, volume, statistics, market profile,
and more), each with the same streaming/batch parity and test guarantees.
- **API stabilization toward 1.0.** Settle the public `Indicator` and
`BarBuilder` traits and the binding surfaces, then commit to semantic
versioning stability for a 1.0 release.
- **Performance.** Keep per-tick updates O(1) and maintain the benchmark suite;
investigate further allocation and cache improvements.
- **Bindings parity.** Keep the Python, Node.js and WebAssembly bindings in
lockstep with the Rust core, including type stubs and platform coverage.
- **Documentation.** Maintain a deep-dive page per indicator on
<https://docs.wickra.org>, plus quickstarts and cookbook material.
- **Project health.** Maintain test coverage, static and dynamic analysis,
signed releases, and supply-chain monitoring.
## How to influence the roadmap
Open or comment on an issue, or start with the
[feature-request template](.github/ISSUE_TEMPLATE/feature_request.md).
Well-scoped proposals and pull requests are the most effective way to move an
item forward.
+99 -3
View File
@@ -2,13 +2,13 @@
## Supported versions
Wickra is pre-1.0. Security fixes are applied to the latest released `0.1.x`
Wickra is pre-1.0. Security fixes are applied to the latest released `0.5.x`
version only; please upgrade to the newest release before reporting an issue.
| Version | Supported |
| --- | --- |
| 0.1.x (latest) | :white_check_mark: |
| older 0.1.x | :x: |
| 0.5.x (latest) | :white_check_mark: |
| older 0.5.x | :x: |
## Reporting a vulnerability
@@ -41,3 +41,99 @@ PyPI/npm packages, and the build/release workflows in `.github/workflows/`.
Out of scope: vulnerabilities in third-party dependencies (report those
upstream; we track them via Dependabot and `cargo-deny`).
## Security assurance case
This is a short, evidence-backed argument for why Wickra can be used safely.
**Security requirements.** Wickra is a computational library: it ingests
numeric market data and produces indicator values. It stores no user
credentials, authenticates no external users, and implements no cryptography of
its own. The requirements are therefore: (1) memory safety and freedom from
undefined behaviour, (2) robust handling of untrusted/degenerate numeric input
without panics or unbounded resource use, (3) integrity of the published
artifacts, and (4) a healthy dependency supply chain.
**How the requirements are met.**
- *Memory safety* — the core and all bindings are written in Rust. The crates
forbid or minimise `unsafe`, so the compiler guarantees memory and thread
safety for the indicator logic.
- *Input robustness* — every indicator validates its parameters and rejects
non-finite inputs at construction; behaviour on edge cases (flat markets,
warmup, reset) is pinned by unit tests, and the public update paths are
exercised by coverage-guided fuzzing (`cargo-fuzz` / libFuzzer) in CI.
- *Static and dynamic analysis* — every push and pull request runs Clippy
(`clippy::pedantic`, warnings-as-errors), CodeQL, fuzzing, and the full test
suite, with 100% line coverage on the core crate tracked by Codecov.
- *Artifact integrity* — releases are built in CI, commits and tags are signed,
the `main` branch requires signed commits, and release artifacts carry build
provenance attestations.
- *Supply chain* — dependencies are pinned and monitored with Dependabot and
audited with `cargo-deny` (license + advisory checks) on every change.
**Residual risk.** The optional `live-binance` feature opens a TLS WebSocket to
an exchange using the platform TLS library; transport security therefore
depends on that library, not on Wickra. Wickra is not a trading system and is
provided "as is" — see the disclaimers in `README.md` and the licenses.
## Secrets management
The project stores **no** secrets or credentials in the version control system.
Secrets required by automation (publishing tokens, the about-sync PAT) are kept
exclusively as **GitHub Actions encrypted secrets** and referenced via the
`secrets.*` context; they are never written to the repository, logs, or build
artifacts. GitHub **secret scanning with push protection** is enabled to block
accidental commits of credentials. Secrets follow least privilege (the narrowest
scope that works) and are rotated when a holder changes or on suspected
exposure.
## Verifying releases
Released artifacts can be verified for integrity and authenticity:
- **Build provenance.** Release assets carry GitHub build provenance
attestations. Verify a downloaded asset with the GitHub CLI:
`gh attestation verify <file> --repo wickra-lib/wickra`.
- **Signed tags.** Each release corresponds to a signed git tag (`vX.Y.Z`);
the tag signature identifies the maintainer who authorised the release.
- **Registry integrity.** Packages are distributed over HTTPS from crates.io,
PyPI and npm, which serve package checksums that package managers verify on
install.
The release is published only by the maintainer through the tag-triggered
release workflow, so a verified tag signature establishes the expected
publisher identity.
## Support timeline and end of support
Wickra is **pre-1.0**: only the **latest released `0.y.z`** version receives
security fixes. When a newer release is published, the previous version
**immediately reaches end of support** and will not receive further fixes;
users should upgrade to the latest release. The supported-versions table above
is authoritative. After the `1.0.0` release this policy will be revised to
support a defined window of releases.
## Remediation policy (dependencies and code scanning)
- **Severity threshold.** Vulnerabilities of **medium severity or higher** in
the project's own code or its dependencies are remediated promptly and before
the next release; lower-severity findings are addressed on a best-effort
basis.
- **Automated enforcement (SCA).** Every change is evaluated by `cargo-deny`
(RUSTSEC advisories + license policy) and Dependabot; a known-vulnerable
dependency fails CI and **blocks the change** until resolved or explicitly
waived with justification.
- **Automated enforcement (SAST).** Every change is evaluated by CodeQL and
Clippy (`-D warnings`); findings **block the change** in CI until fixed.
- **Pre-release gate.** A release is not cut while an unresolved medium-or-higher
SCA/SAST finding is outstanding.
## Vulnerability exploitability (VEX)
Advisories reported by `cargo-deny`/Dependabot for third-party dependencies that
do **not** affect Wickra (e.g. the vulnerable code path is not reachable, or the
affected feature is not enabled) are triaged and recorded — with the
not-affected justification — in the `cargo-deny` configuration (`deny.toml`) and
the relevant pull request, rather than forcing an unnecessary dependency bump.
This serves as the project's exploitability (VEX) record.
+37
View File
@@ -0,0 +1,37 @@
# Support
Thanks for using Wickra! Here is where to get help, depending on what you need.
## Documentation first
Most questions are answered in the documentation:
- **Docs site:** <https://docs.wickra.org> — quickstarts for Rust, Python,
Node.js and WebAssembly, a per-indicator reference, warmup periods, the data
layer, and an FAQ.
- **README:** <https://github.com/wickra-lib/wickra#readme> — installation and a
quick overview.
- **API docs (Rust):** <https://docs.rs/wickra>.
## Questions and help
- Ask a question with the
[question issue template](.github/ISSUE_TEMPLATE/question.md).
- Browse [existing issues](https://github.com/wickra-lib/wickra/issues) — your
question may already be answered.
## Bugs and feature requests
- **Bugs:** use the bug-report issue template.
- **Feature requests / new indicators:** use the feature-request template.
## Security issues
Please do **not** report security vulnerabilities through public issues. Follow
the process in [`SECURITY.md`](SECURITY.md) (private GitHub advisory or email).
## Support expectations
Wickra is maintained by a single maintainer on a best-effort basis. Issues are
triaged and acknowledged as time allows; there is no commercial support or SLA.
Clear, reproducible reports get help fastest.
+54
View File
@@ -0,0 +1,54 @@
# Threat model
This document describes Wickra's attack surface and the threats considered,
together with their mitigations. It complements the security assurance case in
[`SECURITY.md`](SECURITY.md). Wickra is a computational technical-analysis
library (a Rust core with Python, Node.js and WebAssembly bindings), not a
network service or trading system; the attack surface is correspondingly small.
## Assets
- **Integrity of computed indicator values** — consumers may use them in
automated decisions, so silently wrong output is the primary concern.
- **Availability of the calling process** — a library must not crash or hang
its host on malformed input.
- **Integrity of published artifacts** — the crates, wheels and npm packages
users install.
- **The build and release pipeline** and its secrets (publishing tokens).
## Actors / trust boundaries
- **Library consumer** (trusted) — calls the API with numeric data. Data may
originate from untrusted sources (e.g. a market feed), so *input values* are
treated as untrusted even though the caller is trusted.
- **Optional live feed** — with the `live-binance` feature, data crosses a
network boundary from an exchange over TLS.
- **Contributors** (semi-trusted) — propose changes via pull requests.
- **Supply chain** — upstream dependencies and the CI/CD platform.
## Threats and mitigations
| Threat | Mitigation |
| --- | --- |
| Memory-safety exploit (buffer overflow, UAF) via crafted input | Pure safe Rust; `unsafe` is forbidden/minimised, so the compiler precludes these classes. |
| Denial of service via malformed/degenerate input (NaN, infinities, extreme magnitudes) | Indicators reject non-finite inputs and validate parameters at construction; update paths are exercised by coverage-guided fuzzing and unit tests for edge cases. |
| Silently incorrect results | 100% line coverage on the core crate; reference-value tests against known-good sources; streaming/batch parity tests. |
| Integer overflow / panics | `clippy::pedantic` with `-D warnings`; debug assertions and overflow checks enabled in test/fuzz builds. |
| Adversary-in-the-middle on the optional live feed | Connection uses TLS via the platform library; transport security is delegated to that reviewed implementation. |
| Compromised dependency (supply chain) | Dependencies pinned (`Cargo.lock`, hash-locked CI requirements), monitored by Dependabot, audited by `cargo-deny` (advisories + licenses) on every change. |
| Malicious or accidental change to `main` | Branch protection requires signed commits and blocks force-push and deletion; all changes flow through pull requests with required CI; static analysis (CodeQL, Clippy) and fuzzing run on every change. |
| Compromised CI / leaked secrets | Workflows use least-privilege `permissions:`; secrets live only as encrypted GitHub Actions secrets; secret scanning with push protection is enabled; workflows are linted by `zizmor`. |
| Tampered release artifact | Releases are built in CI, tags are signed, and assets carry build provenance attestations (verifiable with `gh attestation verify`). |
## Out of scope
- Wickra implements no authentication, authorization or cryptography of its own,
stores no user data, and exposes no network listener; those threat classes do
not apply.
- Vulnerabilities in third-party dependencies that do not affect Wickra are
tracked as exploitability (VEX) records (see [`SECURITY.md`](SECURITY.md)).
## Maintenance
This threat model is reviewed when the architecture changes materially (for
example, a new input family, a new network feature, or a new release channel).
+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)
);
});
}
+377
View File
@@ -28,10 +28,40 @@ function num(v) {
// --- Scalar indicators: update(value) vs batch(prices) ---
const scalarFactories = {
DerivativeOscillator: () => new wickra.DerivativeOscillator(14, 5, 3, 9),
RMI: () => new wickra.RMI(14, 5),
DynamicMomentumIndex: () => new wickra.DynamicMomentumIndex(14),
RSX: () => new wickra.RSX(14),
FisherRSI: () => new wickra.FisherRSI(14),
DisparityIndex: () => new wickra.DisparityIndex(14),
HoltWinters: () => new wickra.HoltWinters(0.2, 0.1),
GD: () => new wickra.GD(5, 0.7),
AdaptiveLaguerre: () => new wickra.AdaptiveLaguerre(13),
MedianMA: () => new wickra.MedianMA(14),
EHMA: () => new wickra.EHMA(9),
GMA: () => new wickra.GMA(14),
SWMA: () => new wickra.SWMA(14),
Expectancy: () => new wickra.Expectancy(20),
WinRate: () => new wickra.WinRate(20),
RegimeLabel: () => new wickra.RegimeLabel(5, 20),
JumpIndicator: () => new wickra.JumpIndicator(20, 3.0),
TrendLabel: () => new wickra.TrendLabel(10),
RollingQuantile: () => new wickra.RollingQuantile(20, 0.5),
RollingPercentileRank: () => new wickra.RollingPercentileRank(14),
RollingIqr: () => new wickra.RollingIqr(14),
RealizedVolatility: () => new wickra.RealizedVolatility(20),
LogReturn: () => new wickra.LogReturn(1),
TSF: () => new wickra.TSF(14),
LINEARREG_INTERCEPT: () => new wickra.LINEARREG_INTERCEPT(14),
ROCR100: () => new wickra.ROCR100(10),
ROCR: () => new wickra.ROCR(10),
ROCP: () => new wickra.ROCP(10),
MIDPOINT: () => new wickra.MIDPOINT(14),
SMA: () => new wickra.SMA(14),
EMA: () => new wickra.EMA(14),
WMA: () => new wickra.WMA(14),
RSI: () => new wickra.RSI(14),
AnchoredRSI: () => new wickra.AnchoredRSI(),
DEMA: () => new wickra.DEMA(10),
TEMA: () => new wickra.TEMA(10),
HMA: () => new wickra.HMA(9),
@@ -89,6 +119,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 +190,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 +208,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) },
@@ -280,6 +320,28 @@ const candleScalar = {
TasukiGap: { make: () => new wickra.TasukiGap(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
UniqueThreeRiver: { make: () => new wickra.UniqueThreeRiver(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
ConcealingBabySwallow: { make: () => new wickra.ConcealingBabySwallow(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
DoubleTopBottom: { make: () => new wickra.DoubleTopBottom(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
TripleTopBottom: { make: () => new wickra.TripleTopBottom(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
HeadAndShoulders: { make: () => new wickra.HeadAndShoulders(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
Triangle: { make: () => new wickra.Triangle(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
Wedge: { make: () => new wickra.Wedge(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
FlagPennant: { make: () => new wickra.FlagPennant(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
RectangleRange: { make: () => new wickra.RectangleRange(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
CupAndHandle: { make: () => new wickra.CupAndHandle(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
Abcd: { make: () => new wickra.Abcd(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
Gartley: { make: () => new wickra.Gartley(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
Butterfly: { make: () => new wickra.Butterfly(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
Bat: { make: () => new wickra.Bat(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
Crab: { make: () => new wickra.Crab(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
Shark: { make: () => new wickra.Shark(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
Cypher: { make: () => new wickra.Cypher(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
ThreeDrives: { make: () => new wickra.ThreeDrives(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
CloseVsOpen: { make: () => new wickra.CloseVsOpen(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
BodySizePct: { make: () => new wickra.BodySizePct(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
WickRatio: { make: () => new wickra.WickRatio(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
HighLowRange: { make: () => new wickra.HighLowRange(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
StochasticCCI: { make: () => new wickra.StochasticCCI(14), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
IMI: { make: () => new wickra.IMI(14), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
};
for (const [name, d] of Object.entries(candleScalar)) {
@@ -301,6 +363,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) },
@@ -349,6 +414,18 @@ const multi = {
// Family 13: Ichimoku & alternative charts
Ichimoku: { make: () => new wickra.Ichimoku(9, 26, 52, 26), fields: ['tenkan', 'kijun', 'senkouA', 'senkouB', 'chikou'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
HeikinAshi: { make: () => new wickra.HeikinAshi(), fields: ['open', 'high', 'low', 'close'], step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
FibRetracement: { make: () => new wickra.FibRetracement(), fields: ['level0', 'level236', 'level382', 'level500', 'level618', 'level786', 'level1000'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
FibExtension: { make: () => new wickra.FibExtension(), fields: ['level1272', 'level1414', 'level1618', 'level2000', 'level2618'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
FibProjection: { make: () => new wickra.FibProjection(), fields: ['level618', 'level1000', 'level1618', 'level2618'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
AutoFib: { make: () => new wickra.AutoFib(), fields: ['level0', 'level236', 'level382', 'level500', 'level618', 'level786', 'level1000'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
GoldenPocket: { make: () => new wickra.GoldenPocket(), fields: ['low', 'mid', 'high'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
FibConfluence: { make: () => new wickra.FibConfluence(), fields: ['price', 'strength'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
FibFan: { make: () => new wickra.FibFan(), fields: ['fan382', 'fan500', 'fan618'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
FibArcs: { make: () => new wickra.FibArcs(), fields: ['arc382', 'arc500', 'arc618'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
FibChannel: { make: () => new wickra.FibChannel(), fields: ['base', 'level618', 'level1000', 'level1618'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
FibTimeZones: { make: () => new wickra.FibTimeZones(), fields: ['onZone', 'barsToNext'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
ElderRay: { make: () => new wickra.ElderRay(13), fields: ['bullPower', 'bearPower'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
QQE: { make: () => new wickra.QQE(14, 5, 4.236), fields: ['rsiMa', 'trailingLine'], step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
};
for (const [name, d] of Object.entries(multi)) {
@@ -509,6 +586,15 @@ const pairFactories = {
PairwiseBeta: () => new wickra.PairwiseBeta(14),
PairSpreadZScore: () => new wickra.PairSpreadZScore(14, 14),
SpearmanCorrelation: () => new wickra.SpearmanCorrelation(14),
RollingCorrelation: () => new wickra.RollingCorrelation(20),
RollingCovariance: () => new wickra.RollingCovariance(20),
OuHalfLife: () => new wickra.OuHalfLife(60),
SpreadHurst: () => new wickra.SpreadHurst(60),
DistanceSsd: () => new wickra.DistanceSsd(20),
BetaNeutralSpread: () => new wickra.BetaNeutralSpread(20),
VarianceRatio: () => new wickra.VarianceRatio(60, 2),
GrangerCausality: () => new wickra.GrangerCausality(60, 1),
SpreadAr1Coefficient: () => new wickra.SpreadAr1Coefficient(40),
};
for (const [name, make] of Object.entries(pairFactories)) {
@@ -599,6 +685,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;
@@ -1031,6 +1158,57 @@ test('trade-flow rejects bad input', () => {
assert.throws(() => new wickra.SignedVolume().update(100, -1, true));
});
test('order-flow imbalance reference + streaming matches batch', () => {
// Rising bid (px up, size 6) with an unchanged ask -> +6 flow.
const ofi = new wickra.OrderFlowImbalance(1);
assert.equal(ofi.update([100], [5], [101], [4]), null); // seeds the reference
assert.ok(Math.abs(ofi.update([100.5], [6], [101], [4]) - 6.0) < 1e-12);
const snaps = Array.from({ length: 30 }, (_, i) => ({
bidPx: [100 + Math.sin(i * 0.3)],
bidSz: [5 + Math.abs(Math.cos(i * 0.5))],
askPx: [101 + Math.sin(i * 0.3)],
askSz: [4 + Math.abs(Math.sin(i * 0.4))],
}));
const batch = new wickra.OrderFlowImbalance(10).batch(snaps);
const streamer = new wickra.OrderFlowImbalance(10);
assert.equal(batch.length, snaps.length);
for (let i = 0; i < snaps.length; i++) {
const s = streamer.update(snaps[i].bidPx, snaps[i].bidSz, snaps[i].askPx, snaps[i].askSz);
assert.ok((Number.isNaN(batch[i]) && s === null) || Math.abs(s - batch[i]) < 1e-9, `mismatch at ${i}`);
}
});
test('vpin / amihud / roll reference + streaming matches batch', () => {
// VPIN: two pure-buy buckets of size 10 -> imbalance == size -> 1.
const v = new wickra.Vpin(10, 2);
let last;
for (let i = 0; i < 4; i++) last = v.update(100, 5, true);
assert.equal(last, 1.0);
// Amihud(1): |ln(101/100)| / (101 * 10).
const a = new wickra.AmihudIlliquidity(1);
assert.equal(a.update(100, 10, true), null);
assert.ok(Math.abs(a.update(101, 10, true) - Math.abs(Math.log(101 / 100)) / (101 * 10)) < 1e-15);
// Roll(6): a clean bid-ask bounce of ±1 implies a spread of 2.
const r = new wickra.RollMeasure(6);
let roll = null;
for (let i = 0; i < 20; i++) roll = r.update(i % 2 === 0 ? 100 : 101, 1, true);
assert.ok(Math.abs(roll - 2.0) < 1e-12);
// Streaming-vs-batch for the three trade-input indicators.
const n = 40;
const price = Array.from({ length: n }, (_, i) => 100 + Math.sin(i * 0.25) * 4);
const size = Array.from({ length: n }, (_, i) => 1 + (i % 5));
const isBuy = Array.from({ length: n }, (_, i) => i % 2 === 0);
for (const make of [() => new wickra.Vpin(8, 5), () => new wickra.AmihudIlliquidity(14), () => new wickra.RollMeasure(14)]) {
const batch = make().batch(price, size, isBuy);
const streamer = make();
assert.equal(batch.length, n);
for (let i = 0; i < n; i++) {
const s = streamer.update(price[i], size[i], isBuy[i]);
assert.ok((Number.isNaN(batch[i]) && s === null) || Math.abs(s - batch[i]) < 1e-9, `mismatch at ${i}`);
}
}
});
test('price-impact indicators reference values', () => {
// Buy at 100.05 vs mid 100.0: 2 * (100.05 - 100) / 100 * 10000 = 10 bps.
assert.ok(Math.abs(new wickra.EffectiveSpread().update(100.05, 1, true, 100.0) - 10.0) < 1e-9);
@@ -1182,6 +1360,145 @@ test('derivatives reject bad input', () => {
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);
@@ -1245,3 +1562,63 @@ test('basis rejects bad input', () => {
assert.throws(() => new wickra.TermStructureBasis().update(100, 0));
assert.throws(() => new wickra.CalendarSpread().update(100, 0));
});
test('VolumeProfile exposes the full histogram', () => {
// bar0 single-print at 10 vol 100; bar1 spans 10..14 vol 80 over 4 bins.
const vp = new wickra.VolumeProfile(2, 4);
assert.equal(vp.update(10, 10, 100), null);
const out = vp.update(14, 10, 80);
assert.ok(out !== null);
assert.ok(Math.abs(out.priceLow - 10) < 1e-9);
assert.ok(Math.abs(out.priceHigh - 14) < 1e-9);
assert.deepEqual(out.bins.length, 4);
assert.ok(Math.abs(out.bins[0] - 120) < 1e-9);
for (let i = 1; i < 4; i++) {
assert.ok(Math.abs(out.bins[i] - 20) < 1e-9);
}
});
test('TpoProfile counts time at price, volume-agnostic', () => {
// bar0 spans 10..14 (+1 each bin); bar1 spans 11..12 (+1 bins 1,2).
const tpo = new wickra.TpoProfile(2, 4);
assert.equal(tpo.update(14, 10), null);
const out = tpo.update(12, 11);
assert.ok(out !== null);
assert.ok(Math.abs(out.priceLow - 10) < 1e-9);
assert.ok(Math.abs(out.priceHigh - 14) < 1e-9);
assert.deepEqual(out.counts, [1, 2, 2, 1]);
});
test('RenkoBars prints aligned bricks and reverses on two boxes', () => {
const r = new wickra.RenkoBars(1.0);
assert.deepEqual(r.update(10), []); // seed
const up = r.update(13);
assert.equal(up.length, 3);
assert.ok(Math.abs(up[0].open - 10) < 1e-9 && Math.abs(up[0].close - 11) < 1e-9);
assert.ok(up.every((b) => b.direction === 1));
const down = r.update(10);
assert.equal(down.length, 2);
assert.ok(down.every((b) => b.direction === -1));
});
test('KagiBars closes a segment on a reversal', () => {
const k = new wickra.KagiBars(2.0);
k.update(10);
k.update(11);
k.update(15);
const seg = k.update(12);
assert.equal(seg.length, 1);
assert.equal(seg[0].direction, 1);
assert.ok(Math.abs(seg[0].start - 10) < 1e-9 && Math.abs(seg[0].end - 15) < 1e-9);
});
test('PointAndFigureBars closes a column on a 3-box reversal', () => {
const pnf = new wickra.PointAndFigureBars(1.0, 3);
pnf.update(10);
pnf.update(13);
pnf.update(15);
const col = pnf.update(12);
assert.equal(col.length, 1);
assert.equal(col[0].direction, 1);
assert.ok(Math.abs(col[0].high - 15) < 1e-9 && Math.abs(col[0].low - 10) < 1e-9);
});
@@ -0,0 +1,96 @@
// Streaming-vs-batch equivalence and reference values for the Seasonality &
// Session family. These indicators consume the full candle (open, high, low,
// close, volume, timestamp), so they have a dedicated suite.
const test = require('node:test');
const assert = require('node:assert/strict');
const wickra = require('..');
const HOUR = 3_600_000;
const N = 240;
const close = Array.from({ length: N }, (_, i) => 100 + Math.sin(i * 0.3) * 5 + Math.cos(i * 0.1) * 3);
const open = close.map((c, i) => c + Math.sin(i * 0.5) * 0.5);
const high = close.map((c, i) => Math.max(open[i], c) + 1);
const low = close.map((c, i) => Math.min(open[i], c) - 1);
const volume = Array.from({ length: N }, (_, i) => 1000 + (i % 24) * 50);
const ts = Array.from({ length: N }, (_, i) => i * HOUR);
function eq(a, b) {
if (Number.isNaN(a)) return Number.isNaN(b);
return Math.abs(a - b) < 1e-9;
}
function streamScalar(ind, i) {
const v = ind.update(open[i], high[i], low[i], close[i], volume[i], ts[i]);
return v === null || v === undefined ? NaN : v;
}
function checkScalar(name, make) {
test(`${name} streaming equals batch`, () => {
const a = make();
const b = make();
const batch = b.batch(open, high, low, close, volume, ts);
for (let i = 0; i < N; i += 1) {
assert.ok(eq(streamScalar(a, i), batch[i]), `${name} row ${i}`);
}
});
}
function checkMatrix(name, make, k, pick) {
test(`${name} streaming equals batch`, () => {
const a = make();
const b = make();
const batch = b.batch(open, high, low, close, volume, ts);
for (let i = 0; i < N; i += 1) {
const out = a.update(open[i], high[i], low[i], close[i], volume[i], ts[i]);
for (let j = 0; j < k; j += 1) {
const s = out === null || out === undefined ? NaN : pick(out, j);
assert.ok(eq(s, batch[i * k + j]), `${name} row ${i} col ${j}`);
}
}
});
}
checkScalar('SessionVwap', () => new wickra.SessionVwap(0));
checkScalar('OvernightGap', () => new wickra.OvernightGap(0));
checkScalar('SeasonalZScore', () => new wickra.SeasonalZScore(0));
checkScalar('AverageDailyRange', () => new wickra.AverageDailyRange(3, 0));
checkScalar('TurnOfMonth', () => new wickra.TurnOfMonth(3, 1, 0));
checkMatrix('SessionHighLow', () => new wickra.SessionHighLow(0), 2, (o, j) => (j === 0 ? o.high : o.low));
checkMatrix('SessionRange', () => new wickra.SessionRange(0), 3, (o, j) => [o.asia, o.eu, o.us][j]);
checkMatrix(
'OvernightIntradayReturn',
() => new wickra.OvernightIntradayReturn(0),
2,
(o, j) => (j === 0 ? o.overnight : o.intraday),
);
checkMatrix('TimeOfDayReturnProfile', () => new wickra.TimeOfDayReturnProfile(24, 0), 24, (o, j) => o[j]);
checkMatrix('IntradayVolatilityProfile', () => new wickra.IntradayVolatilityProfile(12, 0), 12, (o, j) => o[j]);
checkMatrix('VolumeByTimeProfile', () => new wickra.VolumeByTimeProfile(24, 0), 24, (o, j) => o[j]);
checkMatrix('DayOfWeekProfile', () => new wickra.DayOfWeekProfile(0), 7, (o, j) => o[j]);
test('SessionVwap reference value', () => {
const vwap = new wickra.SessionVwap(0);
assert.ok(eq(vwap.update(100, 100, 100, 100, 10, 0), 100));
assert.ok(eq(vwap.update(110, 110, 110, 110, 30, HOUR), 107.5));
assert.ok(eq(vwap.update(200, 200, 200, 200, 5, 24 * HOUR), 200));
});
test('OvernightGap reference value', () => {
const gap = new wickra.OvernightGap(0);
assert.equal(gap.update(99, 101, 98, 100, 1, 0), null);
assert.ok(eq(gap.update(105, 106, 104, 105.5, 1, 24 * HOUR), 0.05));
});
test('SessionHighLow reference object', () => {
const shl = new wickra.SessionHighLow(0);
shl.update(100, 105, 99, 101, 1, 0);
const out = shl.update(101, 108, 100, 107, 1, HOUR);
assert.ok(eq(out.high, 108));
assert.ok(eq(out.low, 99));
});
test('AverageDailyRange rejects zero period', () => {
assert.throws(() => new wickra.AverageDailyRange(0, 0));
});
+1325
View File
File diff suppressed because it is too large Load Diff
+125 -1
View File
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "wickra-darwin-arm64",
"version": "0.4.4",
"version": "0.5.6",
"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.4",
"version": "0.5.6",
"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.4",
"version": "0.5.6",
"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.4",
"version": "0.5.6",
"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.4",
"version": "0.5.6",
"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.4",
"version": "0.5.6",
"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.4",
"version": "0.5.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "wickra",
"version": "0.4.4",
"license": "PolyForm-Noncommercial-1.0.0",
"version": "0.5.6",
"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.4",
"wickra-darwin-x64": "0.4.4",
"wickra-linux-arm64-gnu": "0.4.4",
"wickra-linux-x64-gnu": "0.4.4",
"wickra-win32-arm64-msvc": "0.4.4",
"wickra-win32-x64-msvc": "0.4.4"
"wickra-darwin-arm64": "0.5.6",
"wickra-darwin-x64": "0.5.6",
"wickra-linux-arm64-gnu": "0.5.6",
"wickra-linux-x64-gnu": "0.5.6",
"wickra-win32-arm64-msvc": "0.5.6",
"wickra-win32-x64-msvc": "0.5.6"
}
},
"node_modules/@napi-rs/cli": {
@@ -41,13 +41,13 @@
}
},
"node_modules/wickra-darwin-arm64": {
"version": "0.4.4",
"resolved": "https://registry.npmjs.org/wickra-darwin-arm64/-/wickra-darwin-arm64-0.4.4.tgz",
"version": "0.5.6",
"resolved": "https://registry.npmjs.org/wickra-darwin-arm64/-/wickra-darwin-arm64-0.5.6.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.4",
"resolved": "https://registry.npmjs.org/wickra-darwin-x64/-/wickra-darwin-x64-0.4.4.tgz",
"version": "0.5.6",
"resolved": "https://registry.npmjs.org/wickra-darwin-x64/-/wickra-darwin-x64-0.5.6.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.4",
"resolved": "https://registry.npmjs.org/wickra-linux-arm64-gnu/-/wickra-linux-arm64-gnu-0.4.4.tgz",
"version": "0.5.6",
"resolved": "https://registry.npmjs.org/wickra-linux-arm64-gnu/-/wickra-linux-arm64-gnu-0.5.6.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.4",
"resolved": "https://registry.npmjs.org/wickra-linux-x64-gnu/-/wickra-linux-x64-gnu-0.4.4.tgz",
"version": "0.5.6",
"resolved": "https://registry.npmjs.org/wickra-linux-x64-gnu/-/wickra-linux-x64-gnu-0.5.6.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.4",
"resolved": "https://registry.npmjs.org/wickra-win32-arm64-msvc/-/wickra-win32-arm64-msvc-0.4.4.tgz",
"version": "0.5.6",
"resolved": "https://registry.npmjs.org/wickra-win32-arm64-msvc/-/wickra-win32-arm64-msvc-0.5.6.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.4",
"resolved": "https://registry.npmjs.org/wickra-win32-x64-msvc/-/wickra-win32-x64-msvc-0.4.4.tgz",
"version": "0.5.6",
"resolved": "https://registry.npmjs.org/wickra-win32-x64-msvc/-/wickra-win32-x64-msvc-0.5.6.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.4",
"version": "0.5.6",
"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.4",
"wickra-linux-arm64-gnu": "0.4.4",
"wickra-darwin-x64": "0.4.4",
"wickra-darwin-arm64": "0.4.4",
"wickra-win32-x64-msvc": "0.4.4",
"wickra-win32-arm64-msvc": "0.4.4"
"wickra-linux-x64-gnu": "0.5.6",
"wickra-linux-arm64-gnu": "0.5.6",
"wickra-darwin-x64": "0.5.6",
"wickra-darwin-arm64": "0.5.6",
"wickra-win32-x64-msvc": "0.5.6",
"wickra-win32-arm64-msvc": "0.5.6"
},
"scripts": {
"build": "napi build --platform --release",
+4838 -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.4"
version = "0.5.6"
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",
+260
View File
@@ -25,6 +25,48 @@ from __future__ import annotations
from ._wickra import (
__version__,
QQE,
IMI,
ElderRay,
DerivativeOscillator,
RMI,
StochasticCCI,
DynamicMomentumIndex,
RSX,
FisherRSI,
DisparityIndex,
HoltWinters,
GD,
AdaptiveLaguerre,
MedianMA,
EHMA,
GMA,
SWMA,
Expectancy,
WinRate,
RegimeLabel,
JumpIndicator,
TrendLabel,
HighLowRange,
WickRatio,
BodySizePct,
CloseVsOpen,
RollingQuantile,
RollingPercentileRank,
RollingIqr,
RealizedVolatility,
LogReturn,
TSF,
LINEARREG_INTERCEPT,
ROCR100,
ROCR,
ROCP,
AVGPRICE,
MIDPOINT,
MIDPRICE,
DX,
MINUS_DI,
PLUS_DI,
# Trend
SMA,
EMA,
@@ -47,13 +89,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 +144,7 @@ from ._wickra import (
Keltner,
Donchian,
PSAR,
SAREXT,
NATR,
StdDev,
UlcerIndex,
@@ -142,6 +190,16 @@ from ._wickra import (
MarketFacilitationIndex,
EaseOfMovement,
# Statistics
SpreadBollingerBands,
KalmanHedgeRatio,
GrangerCausality,
VarianceRatio,
BetaNeutralSpread,
DistanceSsd,
SpreadHurst,
OuHalfLife,
RollingCovariance,
RollingCorrelation,
TypicalPrice,
MedianPrice,
WeightedClose,
@@ -162,6 +220,7 @@ from ._wickra import (
PearsonCorrelation,
Beta,
PairwiseBeta,
SpreadAr1Coefficient,
PairSpreadZScore,
LeadLagCrossCorrelation,
Cointegration,
@@ -180,6 +239,9 @@ from ._wickra import (
EhlersStochastic,
EmpiricalModeDecomposition,
HilbertDominantCycle,
HT_DCPHASE,
HT_PHASOR,
HT_TRENDMODE,
AdaptiveCycle,
SineWave,
MAMA,
@@ -222,8 +284,14 @@ from ._wickra import (
HeikinAshi,
# Market Profile
ValueArea,
VolumeProfile,
TpoProfile,
InitialBalance,
OpeningRange,
# Alt-Chart Bars
RenkoBars,
KagiBars,
PointAndFigureBars,
# Candlestick patterns
Doji,
Hammer,
@@ -285,7 +353,37 @@ from ._wickra import (
TasukiGap,
UniqueThreeRiver,
ConcealingBabySwallow,
# Chart patterns
CupAndHandle,
RectangleRange,
FlagPennant,
Wedge,
Triangle,
HeadAndShoulders,
TripleTopBottom,
DoubleTopBottom,
# Harmonic patterns
ThreeDrives,
Cypher,
Shark,
Crab,
Bat,
Butterfly,
Gartley,
Abcd,
# Fibonacci
FibTimeZones,
FibChannel,
FibArcs,
FibFan,
FibConfluence,
GoldenPocket,
AutoFib,
FibProjection,
FibExtension,
FibRetracement,
# Microstructure: order book
OrderFlowImbalance,
OrderBookImbalanceTop1,
OrderBookImbalanceTopN,
OrderBookImbalanceFull,
@@ -293,6 +391,9 @@ from ._wickra import (
QuotedSpread,
DepthSlope,
# Microstructure: trade flow
RollMeasure,
AmihudIlliquidity,
Vpin,
SignedVolume,
CumulativeVolumeDelta,
TradeImbalance,
@@ -315,6 +416,22 @@ from ._wickra import (
LiquidationFeatures,
TermStructureBasis,
CalendarSpread,
# Market Breadth
TickIndex,
AbsoluteBreadthIndex,
CumulativeVolumeIndex,
BullishPercentIndex,
UpDownVolumeRatio,
PercentAboveMa,
HighLowIndex,
NewHighsNewLows,
BreadthThrust,
Trin,
McClellanSummationIndex,
McClellanOscillator,
AdVolumeLine,
AdvanceDeclineRatio,
AdvanceDecline,
# Risk / Performance
SharpeRatio,
SortinoRatio,
@@ -333,9 +450,64 @@ from ._wickra import (
TreynorRatio,
InformationRatio,
Alpha,
# Seasonality & Session
SessionVwap,
SessionHighLow,
SessionRange,
AverageDailyRange,
OvernightGap,
OvernightIntradayReturn,
TurnOfMonth,
SeasonalZScore,
TimeOfDayReturnProfile,
DayOfWeekProfile,
IntradayVolatilityProfile,
VolumeByTimeProfile,
)
__all__ = [
"QQE",
"IMI",
"ElderRay",
"DerivativeOscillator",
"RMI",
"StochasticCCI",
"DynamicMomentumIndex",
"RSX",
"FisherRSI",
"DisparityIndex",
"HoltWinters",
"GD",
"AdaptiveLaguerre",
"MedianMA",
"EHMA",
"GMA",
"SWMA",
"Expectancy",
"WinRate",
"RegimeLabel",
"JumpIndicator",
"TrendLabel",
"HighLowRange",
"WickRatio",
"BodySizePct",
"CloseVsOpen",
"RollingQuantile",
"RollingPercentileRank",
"RollingIqr",
"RealizedVolatility",
"LogReturn",
"TSF",
"LINEARREG_INTERCEPT",
"ROCR100",
"ROCR",
"ROCP",
"AVGPRICE",
"MIDPOINT",
"MIDPRICE",
"DX",
"MINUS_DI",
"PLUS_DI",
"__version__",
# Trend
"SMA",
@@ -359,13 +531,18 @@ __all__ = [
"EVWMA",
# Momentum
"RSI",
"AnchoredRSI",
"MACD",
"MACDFIX",
"MACDEXT",
"Stochastic",
"CCI",
"ROC",
"WilliamsR",
"ADX",
"ADXR",
"PLUS_DM",
"MINUS_DM",
"MFI",
"TRIX",
"AwesomeOscillator",
@@ -409,6 +586,7 @@ __all__ = [
"Keltner",
"Donchian",
"PSAR",
"SAREXT",
"NATR",
"StdDev",
"UlcerIndex",
@@ -454,6 +632,16 @@ __all__ = [
"MarketFacilitationIndex",
"EaseOfMovement",
# Statistics
"SpreadBollingerBands",
"KalmanHedgeRatio",
"GrangerCausality",
"VarianceRatio",
"BetaNeutralSpread",
"DistanceSsd",
"SpreadHurst",
"OuHalfLife",
"RollingCovariance",
"RollingCorrelation",
"TypicalPrice",
"MedianPrice",
"WeightedClose",
@@ -474,6 +662,7 @@ __all__ = [
"PearsonCorrelation",
"Beta",
"PairwiseBeta",
"SpreadAr1Coefficient",
"PairSpreadZScore",
"LeadLagCrossCorrelation",
"Cointegration",
@@ -492,6 +681,9 @@ __all__ = [
"EhlersStochastic",
"EmpiricalModeDecomposition",
"HilbertDominantCycle",
"HT_DCPHASE",
"HT_PHASOR",
"HT_TRENDMODE",
"AdaptiveCycle",
"SineWave",
"MAMA",
@@ -534,8 +726,14 @@ __all__ = [
"HeikinAshi",
# Market Profile
"ValueArea",
"VolumeProfile",
"TpoProfile",
"InitialBalance",
"OpeningRange",
# Alt-Chart Bars
"RenkoBars",
"KagiBars",
"PointAndFigureBars",
# Candlestick patterns
"Doji",
"Hammer",
@@ -597,7 +795,37 @@ __all__ = [
"TasukiGap",
"UniqueThreeRiver",
"ConcealingBabySwallow",
# Chart patterns
"CupAndHandle",
"RectangleRange",
"FlagPennant",
"Wedge",
"Triangle",
"HeadAndShoulders",
"TripleTopBottom",
"DoubleTopBottom",
# Harmonic patterns
"ThreeDrives",
"Cypher",
"Shark",
"Crab",
"Bat",
"Butterfly",
"Gartley",
"Abcd",
# Fibonacci
"FibTimeZones",
"FibChannel",
"FibArcs",
"FibFan",
"FibConfluence",
"GoldenPocket",
"AutoFib",
"FibProjection",
"FibExtension",
"FibRetracement",
# Microstructure: order book
"OrderFlowImbalance",
"OrderBookImbalanceTop1",
"OrderBookImbalanceTopN",
"OrderBookImbalanceFull",
@@ -605,6 +833,9 @@ __all__ = [
"QuotedSpread",
"DepthSlope",
# Microstructure: trade flow
"RollMeasure",
"AmihudIlliquidity",
"Vpin",
"SignedVolume",
"CumulativeVolumeDelta",
"TradeImbalance",
@@ -627,6 +858,22 @@ __all__ = [
"LiquidationFeatures",
"TermStructureBasis",
"CalendarSpread",
# Market Breadth
"TickIndex",
"AbsoluteBreadthIndex",
"CumulativeVolumeIndex",
"BullishPercentIndex",
"UpDownVolumeRatio",
"PercentAboveMa",
"HighLowIndex",
"NewHighsNewLows",
"BreadthThrust",
"Trin",
"McClellanSummationIndex",
"McClellanOscillator",
"AdVolumeLine",
"AdvanceDeclineRatio",
"AdvanceDecline",
# Risk / Performance
"SharpeRatio",
"SortinoRatio",
@@ -645,4 +892,17 @@ __all__ = [
"TreynorRatio",
"InformationRatio",
"Alpha",
# Seasonality & Session
"SessionVwap",
"SessionHighLow",
"SessionRange",
"AverageDailyRange",
"OvernightGap",
"OvernightIntradayReturn",
"TurnOfMonth",
"SeasonalZScore",
"TimeOfDayReturnProfile",
"DayOfWeekProfile",
"IntradayVolatilityProfile",
"VolumeByTimeProfile",
]
File diff suppressed because it is too large Load Diff
@@ -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
+2
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),
],
)
File diff suppressed because it is too large Load Diff
+132
View File
@@ -0,0 +1,132 @@
"""Streaming-vs-batch equivalence and reference values for the Seasonality &
Session family.
These indicators read the full candle (including ``timestamp``), so they have a
dedicated test rather than joining the timestamp-less parametrize harness in
``test_new_indicators.py``.
"""
import numpy as np
import pytest
import wickra as ta
HOUR_MS = 3_600_000
@pytest.fixture(scope="module")
def candle_columns():
"""240 hourly candles (10 days) with valid OHLCV and epoch-ms timestamps."""
n = 240
t = np.arange(n, dtype=np.float64)
close = 100.0 + np.sin(t * 0.3) * 5.0 + np.cos(t * 0.1) * 3.0
open_ = close + np.sin(t * 0.5) * 0.5
high = np.maximum(open_, close) + 1.0
low = np.minimum(open_, close) - 1.0
volume = 1000.0 + (t % 24) * 50.0
timestamp = (np.arange(n, dtype=np.int64)) * HOUR_MS
return open_, high, low, close, volume, timestamp
def _candles(cols):
open_, high, low, close, volume, timestamp = cols
return [
(open_[i], high[i], low[i], close[i], volume[i], int(timestamp[i]))
for i in range(len(close))
]
def _check_scalar(make, cols):
candles = _candles(cols)
a, b = make(), make()
stream = np.array(
[np.nan if (v := a.update(c)) is None else v for c in candles],
dtype=np.float64,
)
batch = np.asarray(b.batch(*cols))
np.testing.assert_allclose(stream, batch, equal_nan=True, rtol=1e-9, atol=1e-9)
def _check_matrix(make, k, cols):
candles = _candles(cols)
a, b = make(), make()
rows = []
for c in candles:
out = a.update(c)
rows.append(np.full(k, np.nan) if out is None else np.asarray(out, dtype=float))
stream = np.vstack(rows)
batch = np.asarray(b.batch(*cols))
assert batch.shape == (len(candles), k)
np.testing.assert_allclose(stream, batch, equal_nan=True, rtol=1e-9, atol=1e-9)
SCALAR = [
lambda: ta.SessionVwap(0),
lambda: ta.OvernightGap(0),
lambda: ta.SeasonalZScore(0),
lambda: ta.AverageDailyRange(3, 0),
lambda: ta.TurnOfMonth(3, 1, 0),
]
MATRIX = [
(lambda: ta.SessionHighLow(0), 2),
(lambda: ta.SessionRange(0), 3),
(lambda: ta.OvernightIntradayReturn(0), 2),
(lambda: ta.TimeOfDayReturnProfile(24, 0), 24),
(lambda: ta.IntradayVolatilityProfile(12, 0), 12),
(lambda: ta.VolumeByTimeProfile(24, 0), 24),
(lambda: ta.DayOfWeekProfile(0), 7),
]
@pytest.mark.parametrize("make", SCALAR)
def test_scalar_streaming_equals_batch(make, candle_columns):
_check_scalar(make, candle_columns)
@pytest.mark.parametrize("make,k", MATRIX)
def test_matrix_streaming_equals_batch(make, k, candle_columns):
_check_matrix(make, k, candle_columns)
def test_session_vwap_reference():
vwap = ta.SessionVwap(0)
# typical = close for a flat candle; volume-weighted within the day.
v1 = vwap.update((100.0, 100.0, 100.0, 100.0, 10.0, 0))
assert v1 == pytest.approx(100.0)
v2 = vwap.update((110.0, 110.0, 110.0, 110.0, 30.0, HOUR_MS))
assert v2 == pytest.approx(107.5)
# New day re-anchors.
v3 = vwap.update((200.0, 200.0, 200.0, 200.0, 5.0, 24 * HOUR_MS))
assert v3 == pytest.approx(200.0)
def test_overnight_gap_reference():
gap = ta.OvernightGap(0)
assert gap.update((99.0, 101.0, 98.0, 100.0, 1.0, 0)) is None
g = gap.update((105.0, 106.0, 104.0, 105.5, 1.0, 24 * HOUR_MS))
assert g == pytest.approx(0.05)
def test_session_high_low_reference():
shl = ta.SessionHighLow(0)
shl.update((100.0, 105.0, 99.0, 101.0, 1.0, 0))
out = shl.update((101.0, 108.0, 100.0, 107.0, 1.0, HOUR_MS))
assert out == (108.0, 99.0)
def test_volume_by_time_profile_reference():
prof = ta.VolumeByTimeProfile(24, 0)
out = prof.update((100.0, 100.0, 100.0, 100.0, 500.0, HOUR_MS)) # 01:00 -> bucket 1
assert out[1] == pytest.approx(500.0)
assert out[0] == pytest.approx(0.0)
def test_rejects_zero_buckets():
with pytest.raises(ValueError):
ta.TimeOfDayReturnProfile(0, 0)
def test_average_daily_range_rejects_zero_period():
with pytest.raises(ValueError):
ta.AverageDailyRange(0, 0)
+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.
+3430 -1
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
+203
View File
@@ -0,0 +1,203 @@
//! Pure calendar arithmetic for the timestamp-driven seasonality indicators.
//!
//! Every indicator in the *Seasonality & Session* family keys off the wall-clock
//! fields of [`Candle::timestamp`](crate::Candle) (epoch milliseconds), shifted
//! by a caller-supplied `utc_offset_minutes` so the buckets line up with the
//! relevant exchange session rather than UTC. This module turns an epoch
//! millisecond instant into its civil fields using Howard Hinnant's
//! branch-light `civil_from_days` algorithm (the same one libc++ ships).
//!
//! All arithmetic is floor-based (`div_euclid`/`rem_euclid`) so instants before
//! the Unix epoch decompose correctly without a dedicated negative-input branch.
/// Civil (wall-clock) decomposition of an epoch-millisecond instant.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct CivilTime {
/// Proleptic Gregorian year (can be negative for instants before year 1).
pub(crate) year: i64,
/// Month of year, `1..=12`.
pub(crate) month: u32,
/// Day of month, `1..=31`.
pub(crate) day: u32,
/// Hour of day, `0..=23`.
pub(crate) hour: u32,
/// Minute of hour, `0..=59`.
pub(crate) minute: u32,
/// Day of week with Monday as `0` through Sunday as `6`.
pub(crate) weekday: u32,
}
impl CivilTime {
/// Minute of day, `0..=1439`.
pub(crate) const fn minute_of_day(&self) -> u32 {
self.hour * 60 + self.minute
}
}
/// Decompose an epoch-millisecond instant into local civil fields.
///
/// `utc_offset_minutes` shifts the instant before decomposition: `0` yields
/// UTC, `-300` U.S. Eastern standard time, `60` Central European time, etc.
pub(crate) fn civil_from_timestamp(millis: i64, utc_offset_minutes: i32) -> CivilTime {
let local_secs = millis.div_euclid(1000) + i64::from(utc_offset_minutes) * 60;
let days = local_secs.div_euclid(86_400);
let secs_of_day = local_secs.rem_euclid(86_400);
let hour = (secs_of_day / 3600) as u32;
let minute = ((secs_of_day % 3600) / 60) as u32;
let (year, month, day) = civil_from_days(days);
// 1970-01-01 was a Thursday; Monday-based weekday is `(z + 3) mod 7`.
let weekday = (days + 3).rem_euclid(7) as u32;
CivilTime {
year,
month,
day,
hour,
minute,
weekday,
}
}
/// Gregorian `(year, month, day)` for a day count `z` relative to 1970-01-01.
///
/// Howard Hinnant, "chrono-Compatible Low-Level Date Algorithms".
fn civil_from_days(z: i64) -> (i64, u32, u32) {
let z = z + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = z - era * 146_097; // [0, 146096]
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399]
let year = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
let mp = (5 * doy + 2) / 153; // [0, 11]
let day = (doy - (153 * mp + 2) / 5 + 1) as u32; // [1, 31]
let month = if mp < 10 { mp + 3 } else { mp - 9 } as u32; // [1, 12]
(if month <= 2 { year + 1 } else { year }, month, day)
}
/// Whether `year` is a Gregorian leap year.
pub(crate) const fn is_leap(year: i64) -> bool {
(year % 4 == 0 && year % 100 != 0) || year % 400 == 0
}
/// Number of days in `month` (`1..=12`) of `year`.
pub(crate) const fn days_in_month(year: i64, month: u32) -> u32 {
match month {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
4 | 6 | 9 | 11 => 30,
_ => {
if is_leap(year) {
29
} else {
28
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn epoch_zero_is_thursday_midnight() {
let t = civil_from_timestamp(0, 0);
assert_eq!(
t,
CivilTime {
year: 1970,
month: 1,
day: 1,
hour: 0,
minute: 0,
weekday: 3, // Thursday
}
);
assert_eq!(t.minute_of_day(), 0);
}
#[test]
fn known_utc_instant_mid_year() {
// 2021-06-15 13:45:00 UTC = 1623764700 s.
let t = civil_from_timestamp(1_623_764_700_000, 0);
assert_eq!(t.year, 2021);
assert_eq!(t.month, 6);
assert_eq!(t.day, 15);
assert_eq!(t.hour, 13);
assert_eq!(t.minute, 45);
assert_eq!(t.weekday, 1); // Tuesday
assert_eq!(t.minute_of_day(), 13 * 60 + 45);
}
#[test]
fn new_year_2021_is_friday() {
// 2021-01-01 00:00:00 UTC = 1609459200 s — exercises the m<=2 year bump.
let t = civil_from_timestamp(1_609_459_200_000, 0);
assert_eq!((t.year, t.month, t.day), (2021, 1, 1));
assert_eq!(t.weekday, 4); // Friday
}
#[test]
fn positive_offset_rolls_to_next_day() {
// 2021-01-01 23:30 UTC shifted +60 min -> 2021-01-02 00:30 local.
let base = 1_609_459_200_000 + (23 * 3600 + 30 * 60) * 1000;
let t = civil_from_timestamp(base, 60);
assert_eq!((t.year, t.month, t.day), (2021, 1, 2));
assert_eq!((t.hour, t.minute), (0, 30));
assert_eq!(t.weekday, 5); // Saturday
}
#[test]
fn negative_offset_rolls_to_previous_day() {
// 2021-01-01 00:30 UTC shifted -60 min -> 2020-12-31 23:30 local.
let base = 1_609_459_200_000 + 30 * 60 * 1000;
let t = civil_from_timestamp(base, -60);
assert_eq!((t.year, t.month, t.day), (2020, 12, 31));
assert_eq!((t.hour, t.minute), (23, 30));
assert_eq!(t.weekday, 3); // Thursday
}
#[test]
fn sub_epoch_millis_floor_correctly() {
// -1 ms -> 1969-12-31 23:59:59.999, a Wednesday.
let t = civil_from_timestamp(-1, 0);
assert_eq!((t.year, t.month, t.day), (1969, 12, 31));
assert_eq!((t.hour, t.minute), (23, 59));
assert_eq!(t.weekday, 2); // Wednesday
}
#[test]
fn far_negative_day_count_hits_pre_era_branch() {
// A day count below -719468 drives `z + 719468` negative, exercising the
// `z - 146096` era branch in civil_from_days (year < 1).
let (year, month, day) = civil_from_days(-1_000_000);
// -1_000_000 days before 1970-01-01 is 0768-02-04 BCE (proleptic
// Gregorian, astronomical year numbering where year 0 exists).
assert_eq!((year, month, day), (-768, 2, 4));
}
#[test]
fn leap_year_rules() {
assert!(is_leap(2000));
assert!(!is_leap(1900));
assert!(is_leap(2024));
assert!(!is_leap(2023));
}
#[test]
fn days_in_month_all_cases() {
assert_eq!(days_in_month(2023, 1), 31);
assert_eq!(days_in_month(2023, 4), 30);
assert_eq!(days_in_month(2023, 2), 28);
assert_eq!(days_in_month(2024, 2), 29);
assert_eq!(days_in_month(2023, 12), 31);
assert_eq!(days_in_month(2023, 11), 30);
}
#[test]
fn leap_day_decodes() {
// 2024-02-29 12:00 UTC.
let secs = 1_709_208_000; // 2024-02-29T12:00:00Z
let t = civil_from_timestamp(secs * 1000, 0);
assert_eq!((t.year, t.month, t.day), (2024, 2, 29));
assert_eq!(t.hour, 12);
}
}
+387
View File
@@ -0,0 +1,387 @@
//! Cross-section value type: a market-breadth snapshot across a whole universe.
//!
//! A [`CrossSection`] is a single tick that carries the per-symbol state of
//! *every* symbol in a universe at one point in time. It is the non-OHLCV input
//! consumed by the market-breadth indicator family (advance/decline, `McClellan`,
//! the TRIN / Arms index, the high-low index, ...), each of which aggregates the
//! whole cross-section into a single breadth reading. This is the same
//! one-rich-type-per-family pattern as [`DerivativesTick`] and [`OrderBook`].
//!
//! Each [`Member`] precomputes the per-symbol signals the breadth indicators
//! need — a signed price `change` (whose sign classifies the symbol as
//! advancing, declining or unchanged), the period `volume`, the
//! `new_high` / `new_low` extreme flags, and the `above_ma` / `on_buy_signal`
//! state flags — so the indicators stay stateless per tick and never have to
//! track per-symbol history.
//!
//! [`DerivativesTick`]: crate::DerivativesTick
//! [`OrderBook`]: crate::OrderBook
use crate::error::{Error, Result};
/// One symbol's contribution to a [`CrossSection`] tick.
///
/// Field invariants enforced by [`CrossSection::new`] when the member is placed
/// into a tick:
///
/// - `change` is finite (its sign classifies the symbol — positive is
/// advancing, negative is declining, zero is unchanged).
/// - `volume` is finite and non-negative.
///
/// `new_high` / `new_low` are caller-supplied flags marking whether the symbol
/// printed a new period extreme; `above_ma` / `on_buy_signal` are caller-supplied
/// per-symbol state signals (whether the symbol trades above its reference moving
/// average, and whether it is on a point-and-figure buy signal). None of the four
/// flags carries a numeric invariant.
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq)]
#[allow(
clippy::struct_excessive_bools,
reason = "the four flags are independent per-symbol breadth signals, not a state machine"
)]
pub struct Member {
/// Price change versus the previous close. Sign classifies the symbol:
/// positive is advancing, negative is declining, zero is unchanged.
pub change: f64,
/// Period volume for the symbol (finite, non-negative).
pub volume: f64,
/// Whether the symbol printed a new period high.
pub new_high: bool,
/// Whether the symbol printed a new period low.
pub new_low: bool,
/// Whether the symbol is trading above its reference moving average
/// (consumed by the `% Above Moving Average` breadth indicator).
pub above_ma: bool,
/// Whether the symbol is on a point-and-figure buy signal
/// (consumed by the `Bullish Percent Index` breadth indicator).
pub on_buy_signal: bool,
}
impl Member {
/// Assemble a cross-section member from its core signals, leaving the
/// extended per-symbol state flags (`above_ma`, `on_buy_signal`) cleared.
///
/// The field invariants documented on [`Member`] are validated centrally by
/// [`CrossSection::new`] when the member is placed into a tick; this
/// constructor only assembles the value so the `#[non_exhaustive]` struct can
/// be built from outside the crate.
#[must_use]
pub const fn new(change: f64, volume: f64, new_high: bool, new_low: bool) -> Self {
Self {
change,
volume,
new_high,
new_low,
above_ma: false,
on_buy_signal: false,
}
}
/// Assemble a cross-section member including the extended per-symbol state
/// signals `above_ma` and `on_buy_signal`.
///
/// Use this constructor for the breadth indicators that read per-symbol
/// state (`% Above Moving Average`, `Bullish Percent Index`); [`new`](Member::new)
/// is the shorthand that leaves both flags `false`.
#[must_use]
#[allow(
clippy::fn_params_excessive_bools,
reason = "mirrors the four independent per-symbol flag fields of Member"
)]
pub const fn with_signals(
change: f64,
volume: f64,
new_high: bool,
new_low: bool,
above_ma: bool,
on_buy_signal: bool,
) -> Self {
Self {
change,
volume,
new_high,
new_low,
above_ma,
on_buy_signal,
}
}
}
/// A market-breadth cross-section: the per-symbol state of an entire universe at
/// a single point in time.
///
/// Invariants enforced by [`new`](CrossSection::new):
///
/// - `members` is non-empty (a breadth reading needs at least one symbol).
/// - every member's `change` is finite, and `volume` is finite and non-negative.
///
/// `timestamp` is a caller-defined epoch / resolution and is not validated.
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq)]
pub struct CrossSection {
/// Per-symbol members of the universe for this tick.
pub members: Vec<Member>,
/// Tick timestamp (caller-defined epoch / resolution).
pub timestamp: i64,
}
impl CrossSection {
/// Construct a cross-section, validating every member invariant.
///
/// # Errors
///
/// Returns [`Error::InvalidCrossSection`] if `members` is empty, if any
/// member has a non-finite `change`, or if any member has a `volume` that is
/// not a finite non-negative number.
pub fn new(members: Vec<Member>, timestamp: i64) -> Result<Self> {
if members.is_empty() {
return Err(Error::InvalidCrossSection {
message: "cross-section must contain at least one member",
});
}
for member in &members {
if !member.change.is_finite() {
return Err(Error::InvalidCrossSection {
message: "member change must be finite",
});
}
if !member.volume.is_finite() || member.volume < 0.0 {
return Err(Error::InvalidCrossSection {
message: "member volume must be finite and non-negative",
});
}
}
Ok(Self { members, timestamp })
}
/// Construct a cross-section without validation. The caller asserts that
/// every invariant documented on [`CrossSection`] holds.
#[must_use]
pub const fn new_unchecked(members: Vec<Member>, timestamp: i64) -> Self {
Self { members, timestamp }
}
/// Number of advancing symbols (those with a strictly positive `change`).
#[must_use]
pub fn advancers(&self) -> usize {
self.members.iter().filter(|m| m.change > 0.0).count()
}
/// Number of declining symbols (those with a strictly negative `change`).
#[must_use]
pub fn decliners(&self) -> usize {
self.members.iter().filter(|m| m.change < 0.0).count()
}
/// Total volume traded by advancing symbols (those with positive `change`).
#[must_use]
pub fn advancing_volume(&self) -> f64 {
self.members
.iter()
.filter(|m| m.change > 0.0)
.map(|m| m.volume)
.sum()
}
/// Total volume traded by declining symbols (those with negative `change`).
#[must_use]
pub fn declining_volume(&self) -> f64 {
self.members
.iter()
.filter(|m| m.change < 0.0)
.map(|m| m.volume)
.sum()
}
/// Total volume traded across the whole universe.
#[must_use]
pub fn total_volume(&self) -> f64 {
self.members.iter().map(|m| m.volume).sum()
}
/// Number of symbols that printed a new period high.
#[must_use]
pub fn new_highs(&self) -> usize {
self.members.iter().filter(|m| m.new_high).count()
}
/// Number of symbols that printed a new period low.
#[must_use]
pub fn new_lows(&self) -> usize {
self.members.iter().filter(|m| m.new_low).count()
}
/// Number of symbols trading above their reference moving average.
#[must_use]
pub fn above_ma_count(&self) -> usize {
self.members.iter().filter(|m| m.above_ma).count()
}
/// Number of symbols on a point-and-figure buy signal.
#[must_use]
pub fn on_buy_signal_count(&self) -> usize {
self.members.iter().filter(|m| m.on_buy_signal).count()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn members() -> Vec<Member> {
vec![
Member::new(1.5, 100.0, true, false),
Member::new(-0.5, 50.0, false, true),
Member::new(0.0, 0.0, false, false),
]
}
#[test]
fn new_accepts_valid() {
let cs = CrossSection::new(members(), 42).unwrap();
assert_eq!(cs.members.len(), 3);
assert_eq!(cs.timestamp, 42);
assert_eq!(cs.members[0].change, 1.5);
assert_eq!(cs.members[0].volume, 100.0);
assert!(cs.members[0].new_high);
assert!(cs.members[1].new_low);
}
#[test]
fn member_new_assembles_fields() {
let m = Member::new(2.0, 10.0, true, false);
assert_eq!(m.change, 2.0);
assert_eq!(m.volume, 10.0);
assert!(m.new_high);
assert!(!m.new_low);
}
#[test]
fn new_rejects_empty() {
assert!(matches!(
CrossSection::new(Vec::new(), 0),
Err(Error::InvalidCrossSection { .. })
));
}
#[test]
fn new_rejects_non_finite_change() {
assert!(matches!(
CrossSection::new(vec![Member::new(f64::NAN, 10.0, false, false)], 0),
Err(Error::InvalidCrossSection { .. })
));
assert!(matches!(
CrossSection::new(vec![Member::new(f64::INFINITY, 10.0, false, false)], 0),
Err(Error::InvalidCrossSection { .. })
));
}
#[test]
fn new_rejects_negative_volume() {
assert!(matches!(
CrossSection::new(vec![Member::new(1.0, -1.0, false, false)], 0),
Err(Error::InvalidCrossSection { .. })
));
}
#[test]
fn new_rejects_non_finite_volume() {
assert!(matches!(
CrossSection::new(vec![Member::new(1.0, f64::NAN, false, false)], 0),
Err(Error::InvalidCrossSection { .. })
));
}
#[test]
fn new_unchecked_skips_validation() {
let cs = CrossSection::new_unchecked(vec![Member::new(f64::NAN, -1.0, false, false)], 7);
assert_eq!(cs.members.len(), 1);
assert_eq!(cs.timestamp, 7);
}
#[test]
fn advancers_and_decliners_count_by_sign() {
let cs = CrossSection::new(members(), 0).unwrap();
assert_eq!(cs.advancers(), 1);
assert_eq!(cs.decliners(), 1);
}
#[test]
fn unchanged_members_count_as_neither() {
let cs = CrossSection::new(
vec![
Member::new(0.0, 1.0, false, false),
Member::new(0.0, 1.0, false, false),
],
0,
)
.unwrap();
assert_eq!(cs.advancers(), 0);
assert_eq!(cs.decliners(), 0);
}
#[test]
fn new_leaves_extended_flags_cleared() {
let m = Member::new(1.0, 10.0, true, false);
assert!(!m.above_ma);
assert!(!m.on_buy_signal);
}
#[test]
fn with_signals_assembles_all_fields() {
let m = Member::with_signals(2.0, 10.0, true, false, true, true);
assert_eq!(m.change, 2.0);
assert_eq!(m.volume, 10.0);
assert!(m.new_high);
assert!(!m.new_low);
assert!(m.above_ma);
assert!(m.on_buy_signal);
}
#[test]
fn volume_helpers_bucket_by_change_sign() {
let cs = CrossSection::new(
vec![
Member::new(1.5, 100.0, false, false), // advancing
Member::new(2.0, 40.0, false, false), // advancing
Member::new(-0.5, 50.0, false, false), // declining
Member::new(0.0, 7.0, false, false), // unchanged
],
0,
)
.unwrap();
assert_eq!(cs.advancing_volume(), 140.0);
assert_eq!(cs.declining_volume(), 50.0);
assert_eq!(cs.total_volume(), 197.0);
}
#[test]
fn high_low_helpers_count_flags() {
let cs = CrossSection::new(
vec![
Member::new(1.0, 1.0, true, false),
Member::new(1.0, 1.0, true, false),
Member::new(-1.0, 1.0, false, true),
],
0,
)
.unwrap();
assert_eq!(cs.new_highs(), 2);
assert_eq!(cs.new_lows(), 1);
}
#[test]
fn state_helpers_count_extended_flags() {
let cs = CrossSection::new(
vec![
Member::with_signals(1.0, 1.0, false, false, true, true),
Member::with_signals(1.0, 1.0, false, false, true, false),
Member::with_signals(-1.0, 1.0, false, false, false, true),
],
0,
)
.unwrap();
assert_eq!(cs.above_ma_count(), 2);
assert_eq!(cs.on_buy_signal_count(), 2);
}
}
+15
View File
@@ -52,6 +52,21 @@ pub enum Error {
/// 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>`.
+154
View File
@@ -0,0 +1,154 @@
//! AB=CD harmonic pattern.
use crate::indicators::pattern_swing::{approx_equal, ratios_in, SwingTracker, SWING_THRESHOLD};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// AB=CD — the simplest four-point harmonic pattern: an A→B leg, a B→C
/// retracement, and a C→D leg that mirrors A→B in length:
///
/// ```text
/// BC / AB ∈ [0.382, 0.886] (C retraces AB)
/// CD / BC ∈ [1.13, 2.618] (D extends BC)
/// AB ≈ CD (within 10%) (the two legs are equal — the defining symmetry)
/// ```
///
/// Read from the last four confirmed pivots `A-B-C-D`. Output is `+1.0`
/// (bullish, D a swing low), `-1.0` (bearish, D a swing high), or `0.0`; never
/// `None`. See `crates/wickra-core/src/indicators/abcd.rs`.
#[derive(Debug, Clone)]
pub struct Abcd {
swing: SwingTracker,
has_emitted: bool,
}
impl Abcd {
/// Construct a new AB=CD detector.
pub const fn new() -> Self {
Self {
swing: SwingTracker::new(SWING_THRESHOLD, 4),
has_emitted: false,
}
}
}
impl Default for Abcd {
fn default() -> Self {
Self::new()
}
}
impl Indicator for Abcd {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
self.has_emitted = true;
if !self.swing.update(candle) {
return Some(0.0);
}
let pivots = self.swing.pivots();
if pivots.len() < 4 {
return Some(0.0);
}
let len = pivots.len();
let pa = pivots[len - 4];
let pb = pivots[len - 3];
let pc = pivots[len - 2];
let pd = pivots[len - 1];
let ab = (pb.price - pa.price).abs();
let bc = (pc.price - pb.price).abs();
let cd = (pd.price - pc.price).abs();
let ratios_ok = ratios_in(&[(bc / ab, 0.382, 0.886), (cd / bc, 1.13, 2.618)]);
let legs_equal = approx_equal(ab, cd, 0.10);
if ratios_ok && legs_equal {
return Some(if pd.direction < 0.0 { 1.0 } else { -1.0 });
}
Some(0.0)
}
fn reset(&mut self) {
self.swing.reset();
self.has_emitted = false;
}
fn warmup_period(&self) -> usize {
5
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"Abcd"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::indicators::pattern_swing::candles_for_pivots;
use crate::traits::BatchExt;
fn run(pivots: &[f64]) -> Vec<f64> {
let mut indicator = Abcd::new();
candles_for_pivots(pivots)
.into_iter()
.map(|c| indicator.update(c).unwrap())
.collect()
}
#[test]
fn accessors_and_metadata() {
let indicator = Abcd::new();
assert_eq!(indicator.name(), "Abcd");
assert_eq!(indicator.warmup_period(), 5);
assert!(!indicator.is_ready());
assert!(!Abcd::default().is_ready());
}
#[test]
fn bullish_abcd_is_plus_one() {
// AB = 40 down, BC = 24.7 up (0.618), CD = 40 down → AB = CD.
let out = run(&[140.0, 100.0, 124.7, 84.7]);
assert_eq!(*out.last().unwrap(), 1.0);
assert!(out[..out.len() - 1].iter().all(|&x| x == 0.0));
}
#[test]
fn bearish_abcd_is_minus_one() {
let out = run(&[150.0, 100.0, 140.0, 115.3, 155.3]);
assert_eq!(*out.last().unwrap(), -1.0);
}
#[test]
fn unequal_legs_do_not_trigger() {
// CD (82) far longer than AB (40) → not an AB=CD.
let out = run(&[150.0, 100.0, 140.0, 118.0, 200.0]);
assert_eq!(*out.last().unwrap(), 0.0);
}
#[test]
fn reset_clears_state() {
let mut indicator = Abcd::new();
for c in candles_for_pivots(&[140.0, 100.0, 124.7]) {
let _ = indicator.update(c);
}
indicator.reset();
assert!(!indicator.is_ready());
let c = Candle::new(99.5, 100.0, 99.5, 99.5, 1.0, 0).unwrap();
assert_eq!(indicator.update(c), Some(0.0));
}
#[test]
fn batch_equals_streaming() {
let candles = candles_for_pivots(&[140.0, 100.0, 124.7, 84.7]);
let mut a = Abcd::new();
let mut b = Abcd::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,144 @@
//! Absolute Breadth Index — the magnitude of net advancing-minus-declining issues.
use crate::cross_section::CrossSection;
use crate::traits::Indicator;
/// Absolute Breadth Index (ABI) — the absolute value of net advancing issues,
/// `|advancers - decliners|`.
///
/// The ABI ignores the *direction* of breadth and measures only its *magnitude*:
/// a high reading means the universe moved decisively one way or the other (high
/// internal activity / volatility), while a low reading means advances and
/// declines were nearly balanced (a quiet, directionless market). It is sometimes
/// called a "market thermometer" because elevated readings often cluster around
/// turning points.
///
/// `Input = CrossSection`, `Output = f64`, `warmup_period == 1`.
///
/// # Example
///
/// ```
/// use wickra_core::{AbsoluteBreadthIndex, CrossSection, Indicator, Member};
///
/// let mut abi = AbsoluteBreadthIndex::new();
/// // 2 advancers, 5 decliners -> |2 - 5| = 3.
/// let tick = CrossSection::new(
/// vec![
/// Member::new(1.0, 10.0, false, false),
/// Member::new(1.0, 10.0, false, false),
/// Member::new(-1.0, 10.0, false, false),
/// Member::new(-1.0, 10.0, false, false),
/// Member::new(-1.0, 10.0, false, false),
/// Member::new(-1.0, 10.0, false, false),
/// Member::new(-1.0, 10.0, false, false),
/// ],
/// 0,
/// )
/// .unwrap();
/// assert_eq!(abi.update(tick), Some(3.0));
/// ```
#[derive(Debug, Clone, Default)]
pub struct AbsoluteBreadthIndex {
has_emitted: bool,
}
impl AbsoluteBreadthIndex {
/// Construct a new Absolute Breadth Index indicator.
#[must_use]
pub const fn new() -> Self {
Self { has_emitted: false }
}
}
impl Indicator for AbsoluteBreadthIndex {
type Input = CrossSection;
type Output = f64;
fn update(&mut self, section: CrossSection) -> Option<f64> {
let net = section.advancers() as f64 - section.decliners() as f64;
self.has_emitted = true;
Some(net.abs())
}
fn reset(&mut self) {
self.has_emitted = false;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"AbsoluteBreadthIndex"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cross_section::Member;
use crate::traits::BatchExt;
fn section(up: usize, down: usize) -> CrossSection {
let mut members = Vec::new();
for _ in 0..up {
members.push(Member::new(1.0, 10.0, false, false));
}
for _ in 0..down {
members.push(Member::new(-1.0, 10.0, false, false));
}
members.push(Member::new(0.0, 10.0, false, false));
CrossSection::new(members, 0).unwrap()
}
#[test]
fn accessors_and_metadata() {
let abi = AbsoluteBreadthIndex::new();
assert_eq!(abi.name(), "AbsoluteBreadthIndex");
assert_eq!(abi.warmup_period(), 1);
assert!(!abi.is_ready());
}
#[test]
fn magnitude_ignores_direction() {
let mut abi = AbsoluteBreadthIndex::new();
assert_eq!(abi.update(section(2, 5)), Some(3.0));
// Same magnitude with the direction reversed.
let mut abi2 = AbsoluteBreadthIndex::new();
assert_eq!(abi2.update(section(5, 2)), Some(3.0));
}
#[test]
fn balanced_universe_yields_zero() {
let mut abi = AbsoluteBreadthIndex::new();
assert_eq!(abi.update(section(3, 3)), Some(0.0));
assert!(abi.is_ready());
}
#[test]
fn reset_clears_state() {
let mut abi = AbsoluteBreadthIndex::new();
abi.update(section(2, 5));
assert!(abi.is_ready());
abi.reset();
assert!(!abi.is_ready());
}
#[test]
fn batch_equals_streaming() {
let sections = vec![section(2, 5), section(5, 2), section(3, 3)];
let mut a = AbsoluteBreadthIndex::new();
let mut b = AbsoluteBreadthIndex::new();
assert_eq!(
a.batch(&sections),
sections
.iter()
.map(|s| b.update(s.clone()))
.collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,157 @@
//! Advance/Decline Volume Line — cumulative net advancing-minus-declining volume.
use crate::cross_section::CrossSection;
use crate::traits::Indicator;
/// Advance/Decline Volume Line (AD Volume Line) — the running cumulative sum of
/// net advancing volume across a universe.
///
/// On each [`CrossSection`] tick the net is `advancing volume - declining volume`,
/// where advancing volume is the total volume of symbols with a positive change
/// and declining volume the total volume of symbols with a negative change. The
/// line accumulates this net over time, so a rising line means volume is flowing
/// into advancing issues (healthy participation) while a falling line warns that
/// declining issues are carrying the volume — the volume-weighted analogue of the
/// plain Advance/Decline Line.
///
/// `Input = CrossSection`, `Output = f64`, `warmup_period == 1` (defined from the
/// first tick).
///
/// # Example
///
/// ```
/// use wickra_core::{AdVolumeLine, CrossSection, Indicator, Member};
///
/// let mut adv = AdVolumeLine::new();
/// // advancing volume 150, declining volume 50 -> net +100.
/// let tick = CrossSection::new(
/// vec![
/// Member::new(1.0, 150.0, false, false),
/// Member::new(-1.0, 50.0, false, false),
/// ],
/// 0,
/// )
/// .unwrap();
/// assert_eq!(adv.update(tick), Some(100.0));
/// ```
#[derive(Debug, Clone, Default)]
pub struct AdVolumeLine {
line: f64,
has_emitted: bool,
}
impl AdVolumeLine {
/// Construct a new Advance/Decline Volume Line indicator.
#[must_use]
pub const fn new() -> Self {
Self {
line: 0.0,
has_emitted: false,
}
}
}
impl Indicator for AdVolumeLine {
type Input = CrossSection;
type Output = f64;
fn update(&mut self, section: CrossSection) -> Option<f64> {
let net = section.advancing_volume() - section.declining_volume();
self.line += net;
self.has_emitted = true;
Some(self.line)
}
fn reset(&mut self) {
self.line = 0.0;
self.has_emitted = false;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"AdVolumeLine"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cross_section::Member;
use crate::traits::BatchExt;
fn tick(items: &[(f64, f64)]) -> CrossSection {
CrossSection::new(
items
.iter()
.map(|&(change, volume)| Member::new(change, volume, false, false))
.collect(),
0,
)
.unwrap()
}
#[test]
fn accessors_and_metadata() {
let adv = AdVolumeLine::new();
assert_eq!(adv.name(), "AdVolumeLine");
assert_eq!(adv.warmup_period(), 1);
assert!(!adv.is_ready());
}
#[test]
fn first_tick_emits_net_volume() {
let mut adv = AdVolumeLine::new();
assert_eq!(adv.update(tick(&[(1.0, 150.0), (-1.0, 50.0)])), Some(100.0));
assert!(adv.is_ready());
}
#[test]
fn line_accumulates_across_ticks() {
let mut adv = AdVolumeLine::new();
assert_eq!(adv.update(tick(&[(1.0, 150.0), (-1.0, 50.0)])), Some(100.0));
assert_eq!(adv.update(tick(&[(1.0, 60.0), (-1.0, 60.0)])), Some(100.0));
assert_eq!(adv.update(tick(&[(1.0, 30.0)])), Some(130.0));
}
#[test]
fn unchanged_volume_is_ignored() {
let mut adv = AdVolumeLine::new();
// Unchanged symbols (zero change) contribute to neither bucket.
assert_eq!(adv.update(tick(&[(0.0, 1000.0), (1.0, 10.0)])), Some(10.0));
}
#[test]
fn reset_clears_state() {
let mut adv = AdVolumeLine::new();
adv.update(tick(&[(1.0, 100.0)]));
assert!(adv.is_ready());
adv.reset();
assert!(!adv.is_ready());
assert_eq!(adv.update(tick(&[(1.0, 20.0)])), Some(20.0));
}
#[test]
fn batch_equals_streaming() {
let sections = vec![
tick(&[(1.0, 150.0), (-1.0, 50.0)]),
tick(&[(1.0, 60.0), (-1.0, 60.0)]),
tick(&[(1.0, 30.0)]),
];
let mut a = AdVolumeLine::new();
let mut b = AdVolumeLine::new();
assert_eq!(
a.batch(&sections),
sections
.iter()
.map(|s| b.update(s.clone()))
.collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,344 @@
//! Ehlers' Adaptive Laguerre Filter.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// John Ehlers' Adaptive Laguerre Filter — a four-stage Laguerre polynomial
/// smoother whose damping factor `gamma` is recomputed every bar from how well
/// the filter is currently tracking price.
///
/// The Laguerre cascade is the same one used by [`LaguerreRsi`](crate::LaguerreRsi),
/// but instead of a fixed `gamma` the filter adapts: it measures the recent
/// absolute error `|price filter|`, normalises those errors across a window of
/// `period` bars to `[0, 1]`, and takes their **median** as `gamma`. When price
/// is tracking smoothly the errors are small and uniform (low `gamma`, fast
/// response); when price jumps, the spread of errors widens and `gamma` rises,
/// slowing the filter to reject the noise.
///
/// ```text
/// diff_t = |price_t filter_{t-1}|
/// over the last `period` diffs:
/// HH = max(diff), LL = min(diff)
/// norm_i = (diff_i LL) / (HH LL) (0 if HH == LL)
/// gamma = median(norm)
/// alpha = 1 gamma
/// L0_t = alpha·price_t + gamma·L0_{t-1}
/// L1_t = gamma·L0_t + L0_{t-1} + gamma·L1_{t-1}
/// L2_t = gamma·L1_t + L1_{t-1} + gamma·L2_{t-1}
/// L3_t = gamma·L2_t + L2_{t-1} + gamma·L3_{t-1}
/// filter_t = (L0_t + 2·L1_t + 2·L2_t + L3_t) / 6
/// ```
///
/// The output is a smoothed price on the same scale as the input. The first
/// emission lands once the error window holds `period` values.
///
/// Reference: John F. Ehlers, *"Adaptive Laguerre Filter"*, Technical Analysis
/// of Stocks & Commodities, 2007.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, AdaptiveLaguerreFilter};
///
/// let mut indicator = AdaptiveLaguerreFilter::new(13).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// last = indicator.update(100.0 + f64::from(i));
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct AdaptiveLaguerreFilter {
period: usize,
l0: f64,
l1: f64,
l2: f64,
l3: f64,
/// Previous filter output, or `None` before the first bar.
filter: Option<f64>,
/// The last `period` absolute errors `|price filter|`.
diffs: VecDeque<f64>,
}
impl AdaptiveLaguerreFilter {
/// Construct a new adaptive Laguerre filter with the given error-window
/// length.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
l0: 0.0,
l1: 0.0,
l2: 0.0,
l3: 0.0,
filter: None,
diffs: VecDeque::with_capacity(period),
})
}
/// Configured error-window length.
pub const fn period(&self) -> usize {
self.period
}
/// Current value if the error window is full.
pub fn value(&self) -> Option<f64> {
if self.diffs.len() == self.period {
self.filter
} else {
None
}
}
/// Median of the normalised errors currently in the window. Returns `0.0`
/// when every error is equal (e.g. during a constant warmup), which makes
/// the filter maximally fast.
fn adaptive_gamma(&self) -> f64 {
let mut hh = f64::MIN;
let mut ll = f64::MAX;
for &d in &self.diffs {
if d > hh {
hh = d;
}
if d < ll {
ll = d;
}
}
let range = hh - ll;
if range <= 0.0 {
return 0.0;
}
let mut norm: Vec<f64> = self.diffs.iter().map(|&d| (d - ll) / range).collect();
// `total_cmp` never panics — under pathological (e.g. overflowing) fuzz
// inputs a normalised error can be non-finite; a total order keeps the
// sort sound where `partial_cmp` would return `None`.
norm.sort_by(f64::total_cmp);
let mid = norm.len() / 2;
if norm.len() % 2 == 1 {
norm[mid]
} else {
f64::midpoint(norm[mid - 1], norm[mid])
}
}
}
impl Indicator for AdaptiveLaguerreFilter {
type Input = f64;
type Output = f64;
fn update(&mut self, price: f64) -> Option<f64> {
if !price.is_finite() {
return self.value();
}
// Absolute tracking error against the previous filter (0 on the first
// bar, where there is no prior filter value).
let diff = self.filter.map_or(0.0, |f| (price - f).abs());
if self.diffs.len() == self.period {
self.diffs.pop_front();
}
self.diffs.push_back(diff);
let gamma = self.adaptive_gamma();
let alpha = 1.0 - gamma;
let l0 = alpha * price + gamma * self.l0;
let l1 = -gamma * l0 + self.l0 + gamma * self.l1;
let l2 = -gamma * l1 + self.l1 + gamma * self.l2;
let l3 = -gamma * l2 + self.l2 + gamma * self.l3;
self.l0 = l0;
self.l1 = l1;
self.l2 = l2;
self.l3 = l3;
let filter = (l0 + 2.0 * l1 + 2.0 * l2 + l3) / 6.0;
self.filter = Some(filter);
self.value()
}
fn reset(&mut self) {
self.l0 = 0.0;
self.l1 = 0.0;
self.l2 = 0.0;
self.l3 = 0.0;
self.filter = None;
self.diffs.clear();
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.diffs.len() == self.period
}
fn name(&self) -> &'static str {
"AdaptiveLaguerre"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
/// Independent reference: replays the exact recurrence from scratch.
fn naive(prices: &[f64], period: usize) -> Vec<Option<f64>> {
let (mut l0, mut l1, mut l2, mut l3) = (0.0_f64, 0.0_f64, 0.0_f64, 0.0_f64);
let mut filter: Option<f64> = None;
let mut diffs: Vec<f64> = Vec::new();
let mut out = Vec::with_capacity(prices.len());
for &price in prices {
let diff = filter.map_or(0.0, |f: f64| (price - f).abs());
diffs.push(diff);
if diffs.len() > period {
diffs.remove(0);
}
let hh = diffs.iter().copied().fold(f64::MIN, f64::max);
let ll = diffs.iter().copied().fold(f64::MAX, f64::min);
let range = hh - ll;
let gamma = if range <= 0.0 {
0.0
} else {
let mut norm: Vec<f64> = diffs.iter().map(|&d| (d - ll) / range).collect();
norm.sort_by(|a, b| a.partial_cmp(b).unwrap());
let mid = norm.len() / 2;
if norm.len() % 2 == 1 {
norm[mid]
} else {
f64::midpoint(norm[mid - 1], norm[mid])
}
};
let alpha = 1.0 - gamma;
let n0 = alpha * price + gamma * l0;
let n1 = -gamma * n0 + l0 + gamma * l1;
let n2 = -gamma * n1 + l1 + gamma * l2;
let n3 = -gamma * n2 + l2 + gamma * l3;
l0 = n0;
l1 = n1;
l2 = n2;
l3 = n3;
let f = (n0 + 2.0 * n1 + 2.0 * n2 + n3) / 6.0;
filter = Some(f);
out.push(if diffs.len() == period { Some(f) } else { None });
}
out
}
#[test]
fn new_rejects_zero_period() {
assert!(matches!(
AdaptiveLaguerreFilter::new(0),
Err(Error::PeriodZero)
));
}
/// Cover the const accessor `period` and the Indicator-impl `warmup_period`
/// + `name`.
#[test]
fn accessors_and_metadata() {
let alf = AdaptiveLaguerreFilter::new(13).unwrap();
assert_eq!(alf.period(), 13);
assert_eq!(alf.warmup_period(), 13);
assert_eq!(alf.name(), "AdaptiveLaguerre");
}
#[test]
fn warmup_returns_none_until_window_full() {
let mut alf = AdaptiveLaguerreFilter::new(3).unwrap();
assert_eq!(alf.update(10.0), None);
assert_eq!(alf.update(11.0), None);
assert!(alf.update(12.0).is_some());
}
#[test]
fn constant_series_converges_to_constant() {
// Errors are all zero -> gamma 0 -> the 4-stage delay line fills with
// the constant and the filter settles on it.
let mut alf = AdaptiveLaguerreFilter::new(5).unwrap();
let out = alf.batch(&[42.0_f64; 40]);
let last = out.iter().rev().flatten().next().unwrap();
assert_relative_eq!(*last, 42.0, epsilon = 1e-9);
}
#[test]
fn converged_output_stays_within_price_range() {
// Once the Laguerre cascade has filled (it cold-starts from zero, so the
// first few post-warmup values ramp up toward price), the filter is a
// convex blend of recent prices and must stay inside the data range.
let prices: Vec<f64> = (0..120)
.map(|i| 50.0 + (f64::from(i) * 0.4).sin() * 10.0)
.collect();
let lo = prices.iter().copied().fold(f64::MAX, f64::min);
let hi = prices.iter().copied().fold(f64::MIN, f64::max);
let period = 8;
let mut alf = AdaptiveLaguerreFilter::new(period).unwrap();
for (i, v) in alf.batch(&prices).into_iter().enumerate() {
// Skip the cold-start transient (a few multiples of the window).
if i < 4 * period {
continue;
}
let v = v.expect("filter is ready well past warmup");
assert!(
v >= lo - 1e-6 && v <= hi + 1e-6,
"filter out of range at {i}"
);
}
}
#[test]
fn matches_naive_recurrence() {
let prices: Vec<f64> = (0..80)
.map(|i| 100.0 + (f64::from(i) * 0.5).sin() * 8.0 + f64::from(i) * 0.1)
.collect();
let mut alf = AdaptiveLaguerreFilter::new(10).unwrap();
let got = alf.batch(&prices);
let want = naive(&prices, 10);
for (i, (g, w)) in got.iter().zip(want.iter()).enumerate() {
assert_eq!(g.is_some(), w.is_some(), "readiness mismatch at {i}");
if let (Some(a), Some(b)) = (g, w) {
assert_relative_eq!(*a, *b, epsilon = 1e-9);
}
}
}
#[test]
fn reset_clears_state() {
let mut alf = AdaptiveLaguerreFilter::new(5).unwrap();
alf.batch(&(1..=40).map(f64::from).collect::<Vec<_>>());
assert!(alf.is_ready());
alf.reset();
assert!(!alf.is_ready());
assert_eq!(alf.update(1.0), None);
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (1..=50).map(|i| f64::from(i) * 0.7).collect();
let mut a = AdaptiveLaguerreFilter::new(7).unwrap();
let mut b = AdaptiveLaguerreFilter::new(7).unwrap();
assert_eq!(
a.batch(&prices),
prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
);
}
#[test]
fn ignores_non_finite_input() {
let mut alf = AdaptiveLaguerreFilter::new(3).unwrap();
alf.update(10.0);
alf.update(11.0);
let ready = alf.update(12.0).expect("ready after three inputs");
assert_eq!(alf.update(f64::NAN), Some(ready));
assert_eq!(alf.update(f64::INFINITY), Some(ready));
}
}
@@ -0,0 +1,168 @@
//! Advance/Decline Line — cumulative net advancing-minus-declining issues.
use crate::cross_section::CrossSection;
use crate::traits::Indicator;
/// Advance/Decline Line (A/D Line) — the running cumulative sum of net advancing
/// issues across a universe.
///
/// On each [`CrossSection`] tick the net breadth is `advancers - decliners`:
/// the number of symbols with a positive price change minus the number with a
/// negative change (unchanged symbols are ignored). The line accumulates this
/// net value over time, so a rising line means advancers have persistently
/// outnumbered decliners — broad participation — while a falling line warns that
/// a rally is being carried by fewer and fewer names (a breadth divergence when
/// the index itself is still rising).
///
/// `Input = CrossSection`, `Output = f64`. The line is defined from the very
/// first tick, so `warmup_period == 1` and the indicator is ready after one
/// update.
///
/// # Example
///
/// ```
/// use wickra_core::{AdvanceDecline, CrossSection, Indicator, Member};
///
/// let mut ad = AdvanceDecline::new();
/// // 3 advancers, 1 decliner -> net +2.
/// let tick = CrossSection::new(
/// vec![
/// Member::new(1.0, 10.0, false, false),
/// Member::new(0.5, 10.0, false, false),
/// Member::new(2.0, 10.0, false, false),
/// Member::new(-1.0, 10.0, false, false),
/// ],
/// 0,
/// )
/// .unwrap();
/// assert_eq!(ad.update(tick), Some(2.0));
/// ```
#[derive(Debug, Clone, Default)]
pub struct AdvanceDecline {
line: f64,
has_emitted: bool,
}
impl AdvanceDecline {
/// Construct a new Advance/Decline Line indicator.
#[must_use]
pub const fn new() -> Self {
Self {
line: 0.0,
has_emitted: false,
}
}
}
impl Indicator for AdvanceDecline {
type Input = CrossSection;
type Output = f64;
fn update(&mut self, section: CrossSection) -> Option<f64> {
let net = section.advancers() as f64 - section.decliners() as f64;
self.line += net;
self.has_emitted = true;
Some(self.line)
}
fn reset(&mut self) {
self.line = 0.0;
self.has_emitted = false;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"AdvanceDecline"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cross_section::Member;
use crate::traits::BatchExt;
/// Build a cross-section with `up` advancers, `down` decliners and `flat`
/// unchanged symbols.
fn section(up: usize, down: usize, flat: usize) -> CrossSection {
let mut members = Vec::new();
for _ in 0..up {
members.push(Member::new(1.0, 10.0, false, false));
}
for _ in 0..down {
members.push(Member::new(-1.0, 10.0, false, false));
}
for _ in 0..flat {
members.push(Member::new(0.0, 10.0, false, false));
}
CrossSection::new(members, 0).unwrap()
}
#[test]
fn accessors_and_metadata() {
let ad = AdvanceDecline::new();
assert_eq!(ad.name(), "AdvanceDecline");
assert_eq!(ad.warmup_period(), 1);
assert!(!ad.is_ready());
}
#[test]
fn first_tick_emits_net_breadth() {
let mut ad = AdvanceDecline::new();
assert_eq!(ad.update(section(3, 1, 0)), Some(2.0));
assert!(ad.is_ready());
}
#[test]
fn line_accumulates_across_ticks() {
let mut ad = AdvanceDecline::new();
assert_eq!(ad.update(section(3, 1, 0)), Some(2.0)); // +2 -> 2
assert_eq!(ad.update(section(1, 4, 0)), Some(-1.0)); // -3 -> -1
assert_eq!(ad.update(section(2, 0, 0)), Some(1.0)); // +2 -> 1
}
#[test]
fn unchanged_symbols_are_ignored() {
let mut ad = AdvanceDecline::new();
// 2 up, 2 down, 5 unchanged -> net 0, line stays flat.
assert_eq!(ad.update(section(2, 2, 5)), Some(0.0));
assert_eq!(ad.update(section(2, 2, 5)), Some(0.0));
}
#[test]
fn reset_clears_state() {
let mut ad = AdvanceDecline::new();
ad.update(section(5, 0, 0));
assert!(ad.is_ready());
ad.reset();
assert!(!ad.is_ready());
// Line restarts from zero, not from the pre-reset value.
assert_eq!(ad.update(section(1, 0, 0)), Some(1.0));
}
#[test]
fn batch_equals_streaming() {
let sections = vec![
section(3, 1, 2),
section(1, 4, 0),
section(2, 2, 1),
section(5, 0, 3),
];
let mut a = AdvanceDecline::new();
let mut b = AdvanceDecline::new();
assert_eq!(
a.batch(&sections),
sections
.iter()
.map(|s| b.update(s.clone()))
.collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,151 @@
//! Advance/Decline Ratio — advancing issues divided by declining issues.
use crate::cross_section::CrossSection;
use crate::traits::Indicator;
/// Advance/Decline Ratio (ADR) — the number of advancing symbols divided by the
/// number of declining symbols across a universe.
///
/// On each [`CrossSection`] tick the ratio is `advancers / decliners`: a reading
/// above one means advancing issues outnumber declining ones (broad strength),
/// while a reading below one signals broad weakness. Because it is a ratio rather
/// than a difference, the ADR is comparable across universes of different sizes.
///
/// When a tick has no declining symbols the denominator is floored to one, so the
/// ratio degrades gracefully to the advancer count instead of dividing by zero.
///
/// `Input = CrossSection`, `Output = f64`. The ratio is defined from the first
/// tick, so `warmup_period == 1` and the indicator is ready after one update.
///
/// # Example
///
/// ```
/// use wickra_core::{AdvanceDeclineRatio, CrossSection, Indicator, Member};
///
/// let mut adr = AdvanceDeclineRatio::new();
/// // 3 advancers, 1 decliner -> ratio 3.0.
/// let tick = CrossSection::new(
/// vec![
/// Member::new(1.0, 10.0, false, false),
/// Member::new(0.5, 10.0, false, false),
/// Member::new(2.0, 10.0, false, false),
/// Member::new(-1.0, 10.0, false, false),
/// ],
/// 0,
/// )
/// .unwrap();
/// assert_eq!(adr.update(tick), Some(3.0));
/// ```
#[derive(Debug, Clone, Default)]
pub struct AdvanceDeclineRatio {
has_emitted: bool,
}
impl AdvanceDeclineRatio {
/// Construct a new Advance/Decline Ratio indicator.
#[must_use]
pub const fn new() -> Self {
Self { has_emitted: false }
}
}
impl Indicator for AdvanceDeclineRatio {
type Input = CrossSection;
type Output = f64;
fn update(&mut self, section: CrossSection) -> Option<f64> {
let advancers = section.advancers() as f64;
let decliners = section.decliners().max(1) as f64;
self.has_emitted = true;
Some(advancers / decliners)
}
fn reset(&mut self) {
self.has_emitted = false;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"AdvanceDeclineRatio"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cross_section::Member;
use crate::traits::BatchExt;
fn section(up: usize, down: usize) -> CrossSection {
let mut members = Vec::new();
for _ in 0..up {
members.push(Member::new(1.0, 10.0, false, false));
}
for _ in 0..down {
members.push(Member::new(-1.0, 10.0, false, false));
}
// A non-empty unchanged member guarantees a valid universe when both
// counts are zero.
members.push(Member::new(0.0, 10.0, false, false));
CrossSection::new(members, 0).unwrap()
}
#[test]
fn accessors_and_metadata() {
let adr = AdvanceDeclineRatio::new();
assert_eq!(adr.name(), "AdvanceDeclineRatio");
assert_eq!(adr.warmup_period(), 1);
assert!(!adr.is_ready());
}
#[test]
fn first_tick_emits_ratio() {
let mut adr = AdvanceDeclineRatio::new();
assert_eq!(adr.update(section(3, 1)), Some(3.0));
assert!(adr.is_ready());
}
#[test]
fn zero_decliners_floors_denominator() {
let mut adr = AdvanceDeclineRatio::new();
// 4 advancers, 0 decliners -> 4 / max(0, 1) = 4.0.
assert_eq!(adr.update(section(4, 0)), Some(4.0));
}
#[test]
fn no_advancers_yields_zero() {
let mut adr = AdvanceDeclineRatio::new();
assert_eq!(adr.update(section(0, 5)), Some(0.0));
}
#[test]
fn reset_clears_state() {
let mut adr = AdvanceDeclineRatio::new();
adr.update(section(3, 1));
assert!(adr.is_ready());
adr.reset();
assert!(!adr.is_ready());
assert_eq!(adr.update(section(2, 1)), Some(2.0));
}
#[test]
fn batch_equals_streaming() {
let sections = vec![section(3, 1), section(4, 0), section(0, 5), section(2, 2)];
let mut a = AdvanceDeclineRatio::new();
let mut b = AdvanceDeclineRatio::new();
assert_eq!(
a.batch(&sections),
sections
.iter()
.map(|s| b.update(s.clone()))
.collect::<Vec<_>>()
);
}
}
+1 -1
View File
@@ -91,7 +91,7 @@ impl Adx {
}
}
fn directional_movement(prev: &Candle, current: &Candle) -> (f64, f64) {
pub(crate) fn directional_movement(prev: &Candle, current: &Candle) -> (f64, f64) {
let up = current.high - prev.high;
let down = prev.low - current.low;
let plus_dm = if up > down && up > 0.0 { up } else { 0.0 };
@@ -0,0 +1,239 @@
//! Amihud Illiquidity — average price impact per unit traded value.
use std::collections::VecDeque;
use crate::microstructure::Trade;
use crate::traits::Indicator;
use crate::{Error, Result};
/// Amihud Illiquidity — the average absolute log return per unit of traded
/// value over the last `period` trades (Amihud, 2002).
///
/// ```text
/// rₜ = ln(priceₜ / priceₜ₋₁)
/// ILLIQₜ = |rₜ| / (priceₜ · sizeₜ) (return per dollar of volume)
/// Amihud = mean of ILLIQ over the last `period` trades
/// ```
///
/// Amihud's measure captures how much the price moves for a given amount of
/// traded value: a **high** reading means small volume already shifts the price
/// a lot (an illiquid, easily-moved market), a **low** reading means it takes
/// large volume to move the price (a deep, liquid market). It is the workhorse
/// cross-sectional liquidity proxy in market-microstructure research.
///
/// `Input = Trade`. Trades with zero size carry no traded value and are skipped
/// (the ratio is undefined); the last value is returned and state is untouched.
/// The first valid trade only seeds the reference price.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, Side, Trade, AmihudIlliquidity};
///
/// let mut amihud = AmihudIlliquidity::new(20).unwrap();
/// assert_eq!(amihud.update(Trade::new(100.0, 5.0, Side::Buy, 0).unwrap()), None);
/// ```
#[derive(Debug, Clone)]
pub struct AmihudIlliquidity {
period: usize,
prev_price: Option<f64>,
window: VecDeque<f64>,
sum: f64,
last: Option<f64>,
}
impl AmihudIlliquidity {
/// Construct a new Amihud Illiquidity over the given trade window.
///
/// # Errors
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
prev_price: None,
window: VecDeque::with_capacity(period),
sum: 0.0,
last: None,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for AmihudIlliquidity {
type Input = Trade;
type Output = f64;
fn update(&mut self, trade: Trade) -> Option<f64> {
// A zero-size trade has no traded value: the ratio is undefined, so the
// trade is skipped without touching the reference price.
if trade.size == 0.0 {
return self.last;
}
let Some(prev) = self.prev_price else {
self.prev_price = Some(trade.price);
return None;
};
self.prev_price = Some(trade.price);
// `prev` and `trade.price` are both finite and strictly positive
// (enforced by `Trade::new`), so the log return is well-defined and the
// traded value is strictly positive.
let ret = (trade.price / prev).ln().abs();
let illiq = ret / (trade.price * trade.size);
if self.window.len() == self.period {
let old = self.window.pop_front().expect("window is non-empty");
self.sum -= old;
}
self.window.push_back(illiq);
self.sum += illiq;
if self.window.len() < self.period {
return None;
}
let value = self.sum / self.period as f64;
self.last = Some(value);
Some(value)
}
fn reset(&mut self) {
self.prev_price = None;
self.window.clear();
self.sum = 0.0;
self.last = None;
}
fn warmup_period(&self) -> usize {
self.period + 1
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"AmihudIlliquidity"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::microstructure::Side;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn trade(price: f64, size: f64) -> Trade {
Trade::new(price, size, Side::Buy, 0).unwrap()
}
#[test]
fn rejects_zero_period() {
assert!(matches!(AmihudIlliquidity::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_and_metadata() {
let a = AmihudIlliquidity::new(20).unwrap();
assert_eq!(a.period(), 20);
assert_eq!(a.warmup_period(), 21);
assert_eq!(a.name(), "AmihudIlliquidity");
assert!(!a.is_ready());
}
#[test]
fn known_value() {
// period 1. Seed at 100, then 101 with size 10:
// |ln(101/100)| / (101 * 10).
let mut a = AmihudIlliquidity::new(1).unwrap();
assert_eq!(a.update(trade(100.0, 10.0)), None);
let out = a.update(trade(101.0, 10.0)).unwrap();
let expected = (101.0_f64 / 100.0).ln().abs() / (101.0 * 10.0);
assert_relative_eq!(out, expected, epsilon = 1e-15);
}
#[test]
fn higher_for_thinner_volume() {
// Same price move on smaller volume => larger illiquidity reading.
let thin = {
let mut a = AmihudIlliquidity::new(1).unwrap();
a.update(trade(100.0, 1.0));
a.update(trade(101.0, 1.0)).unwrap()
};
let thick = {
let mut a = AmihudIlliquidity::new(1).unwrap();
a.update(trade(100.0, 1000.0));
a.update(trade(101.0, 1000.0)).unwrap()
};
assert!(thin > thick, "thin {thin} should exceed thick {thick}");
}
#[test]
fn flat_price_is_zero() {
let mut a = AmihudIlliquidity::new(5).unwrap();
for v in a.batch(&[trade(100.0, 3.0); 20]).into_iter().flatten() {
assert_relative_eq!(v, 0.0, epsilon = 1e-15);
}
}
#[test]
fn skips_zero_size_trades() {
let mut a = AmihudIlliquidity::new(1).unwrap();
a.update(trade(100.0, 10.0));
let baseline = a.update(trade(101.0, 10.0)).unwrap();
// A zero-size trade is ignored; the previous reference price is kept.
assert_eq!(a.update(trade(200.0, 0.0)), Some(baseline));
// The next real trade still references price 101, not 200.
let mut control = a.clone();
let after = a.update(trade(102.0, 10.0)).unwrap();
assert_eq!(control.update(trade(102.0, 10.0)).unwrap(), after);
}
#[test]
fn output_is_non_negative() {
let mut a = AmihudIlliquidity::new(10).unwrap();
let trades: Vec<Trade> = (0..100)
.map(|i| {
trade(
100.0 + (f64::from(i) * 0.3).sin() * 5.0,
1.0 + f64::from(i % 7),
)
})
.collect();
for v in a.batch(&trades).into_iter().flatten() {
assert!(v >= 0.0, "illiquidity must be non-negative, got {v}");
}
}
#[test]
fn reset_clears_state() {
let mut a = AmihudIlliquidity::new(5).unwrap();
for i in 0..20 {
a.update(trade(100.0 + f64::from(i), 2.0));
}
assert!(a.is_ready());
a.reset();
assert!(!a.is_ready());
assert_eq!(a.update(trade(100.0, 1.0)), None);
}
#[test]
fn batch_equals_streaming() {
let trades: Vec<Trade> = (0..80)
.map(|i| {
trade(
100.0 + (f64::from(i) * 0.25).sin() * 4.0,
1.0 + f64::from(i % 5),
)
})
.collect();
let batch = AmihudIlliquidity::new(14).unwrap().batch(&trades);
let mut b = AmihudIlliquidity::new(14).unwrap();
let streamed: Vec<_> = trades.iter().map(|t| b.update(*t)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,284 @@
//! Anchored Relative Strength Index.
use crate::traits::Indicator;
/// Anchored RSI — a cumulative Relative Strength Index whose averaging begins at
/// a user-chosen anchor bar rather than over a fixed Wilder period.
///
/// Where [`crate::Rsi`] uses Wilder's `period`-length smoothing, Anchored RSI
/// accumulates *every* up- and down-move since the anchor with equal weight, so
/// it answers "what is the RSI of the entire move since the anchor point?". The
/// running relative strength is `Σ gains / Σ losses` over all bars in the
/// current anchor window (the bar count cancels, so this equals
/// `avg_gain / avg_loss`):
///
/// ```text
/// RSI_t = 100 - 100 / (1 + Σ_{i ≥ anchor} gain_i / Σ_{i ≥ anchor} loss_i)
/// ```
///
/// As with [`crate::AnchoredVwap`], the anchor is chosen at runtime:
/// [`AnchoredRsi::set_anchor`] re-anchors at the **next** bar that arrives,
/// clearing the running sums. Because RSI needs a price *change*, the first bar
/// of a fresh anchor window only seeds the previous close and emits `None`; the
/// first value follows on the second bar (warmup period 2).
///
/// Saturation follows the standard convention: a window with no losses yet (and
/// at least one gain) reads 100, no gains yet reads 0, and a perfectly flat
/// window reads the neutral 50. Non-finite inputs are ignored, leaving the last
/// value unchanged.
///
/// # Example
///
/// ```
/// use wickra_core::{AnchoredRsi, Indicator};
///
/// let mut indicator = AnchoredRsi::new();
/// let mut last = None;
/// for i in 0..80 {
/// let price = 100.0 + (f64::from(i) * 0.5).sin() * 5.0;
/// // Re-anchor at bar 40 (e.g. a major swing low).
/// if i == 40 {
/// indicator.set_anchor();
/// }
/// last = indicator.update(price);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone, Default)]
pub struct AnchoredRsi {
prev_close: Option<f64>,
sum_gain: f64,
sum_loss: f64,
last_value: Option<f64>,
pending_anchor: bool,
}
impl AnchoredRsi {
/// Construct a fresh Anchored RSI. The first bar to arrive is the anchor.
pub const fn new() -> Self {
Self {
prev_close: None,
sum_gain: 0.0,
sum_loss: 0.0,
last_value: None,
pending_anchor: false,
}
}
/// Mark a re-anchor: the **next** [`Indicator::update`] call clears the
/// running sums and previous close before folding in its own bar, starting
/// a fresh anchored window.
pub fn set_anchor(&mut self) {
self.pending_anchor = true;
}
/// Current anchored RSI value if at least one price change has been
/// observed in the current anchor window.
pub const fn value(&self) -> Option<f64> {
self.last_value
}
fn rsi_from_sums(sum_gain: f64, sum_loss: f64) -> f64 {
if sum_loss == 0.0 {
if sum_gain == 0.0 {
// No movement at all -> RSI undefined; standard convention returns 50.
50.0
} else {
100.0
}
} else {
let rs = sum_gain / sum_loss;
100.0 - 100.0 / (1.0 + rs)
}
}
}
impl Indicator for AnchoredRsi {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() {
return self.last_value;
}
if self.pending_anchor {
self.prev_close = None;
self.sum_gain = 0.0;
self.sum_loss = 0.0;
self.last_value = None;
self.pending_anchor = false;
}
let Some(prev) = self.prev_close else {
self.prev_close = Some(input);
return None;
};
self.prev_close = Some(input);
let diff = input - prev;
if diff > 0.0 {
self.sum_gain += diff;
} else if diff < 0.0 {
self.sum_loss -= diff;
}
let value = Self::rsi_from_sums(self.sum_gain, self.sum_loss);
self.last_value = Some(value);
Some(value)
}
fn reset(&mut self) {
self.prev_close = None;
self.sum_gain = 0.0;
self.sum_loss = 0.0;
self.last_value = None;
self.pending_anchor = false;
}
fn warmup_period(&self) -> usize {
2
}
fn is_ready(&self) -> bool {
self.last_value.is_some()
}
fn name(&self) -> &'static str {
"AnchoredRSI"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn accessors_and_metadata() {
let indicator = AnchoredRsi::new();
assert_eq!(indicator.name(), "AnchoredRSI");
assert_eq!(indicator.warmup_period(), 2);
assert_eq!(indicator.value(), None);
assert!(!indicator.is_ready());
}
#[test]
fn first_bar_seeds_and_returns_none() {
let mut indicator = AnchoredRsi::new();
assert_eq!(indicator.update(100.0), None);
assert!(!indicator.is_ready());
// Second bar produces the first value.
assert!(indicator.update(101.0).is_some());
assert!(indicator.is_ready());
}
#[test]
fn pure_uptrend_saturates_at_100() {
let mut indicator = AnchoredRsi::new();
let out = indicator.batch(&[10.0, 11.0, 12.0, 13.0]);
assert_relative_eq!(out[3].unwrap(), 100.0, epsilon = 1e-12);
}
#[test]
fn pure_downtrend_saturates_at_0() {
let mut indicator = AnchoredRsi::new();
let out = indicator.batch(&[13.0, 12.0, 11.0, 10.0]);
assert_relative_eq!(out[3].unwrap(), 0.0, epsilon = 1e-12);
}
#[test]
fn flat_window_reads_50() {
let mut indicator = AnchoredRsi::new();
let out = indicator.batch(&[42.0, 42.0, 42.0]);
assert_relative_eq!(out[2].unwrap(), 50.0, epsilon = 1e-12);
}
#[test]
fn cumulative_reference_values() {
// prices 10 -> 11 (+1) -> 9 (-2) -> 12 (+3)
// after bar2: sum_gain=1, sum_loss=2 -> rs=0.5 -> 100 - 100/1.5 = 33.3333
// after bar3: sum_gain=4, sum_loss=2 -> rs=2.0 -> 100 - 100/3 = 66.6667
let mut indicator = AnchoredRsi::new();
let out = indicator.batch(&[10.0, 11.0, 9.0, 12.0]);
assert_relative_eq!(out[1].unwrap(), 100.0, epsilon = 1e-9);
assert_relative_eq!(out[2].unwrap(), 33.333_333_333, epsilon = 1e-6);
assert_relative_eq!(out[3].unwrap(), 66.666_666_666, epsilon = 1e-6);
}
#[test]
fn set_anchor_clears_old_window() {
// Downtrend, then re-anchor and pump an uptrend: the new window must
// read 100, not the blended value.
let mut indicator = AnchoredRsi::new();
indicator.batch(&[20.0, 19.0, 18.0, 17.0]);
assert_relative_eq!(indicator.value().unwrap(), 0.0, epsilon = 1e-12);
indicator.set_anchor();
// First bar after anchor re-seeds (None), second bar emits.
assert_eq!(indicator.update(50.0), None);
let after = indicator.update(51.0).unwrap();
assert_relative_eq!(after, 100.0, epsilon = 1e-12);
}
#[test]
fn set_anchor_before_first_bar_acts_as_normal_start() {
let mut indicator = AnchoredRsi::new();
indicator.set_anchor();
assert_eq!(indicator.update(10.0), None);
assert_relative_eq!(indicator.update(11.0).unwrap(), 100.0, epsilon = 1e-12);
}
#[test]
fn ignores_non_finite_input() {
let mut indicator = AnchoredRsi::new();
indicator.batch(&[10.0, 11.0, 12.0]);
let before = indicator.value();
assert!(before.is_some());
assert_eq!(indicator.update(f64::NAN), before);
assert_eq!(indicator.update(f64::INFINITY), before);
assert_eq!(indicator.value(), before);
}
#[test]
fn non_finite_before_any_bar_returns_none() {
let mut indicator = AnchoredRsi::new();
assert_eq!(indicator.update(f64::NAN), None);
assert!(!indicator.is_ready());
}
#[test]
fn reset_clears_state() {
let mut indicator = AnchoredRsi::new();
indicator.batch(&[10.0, 11.0, 12.0]);
assert!(indicator.is_ready());
indicator.reset();
assert!(!indicator.is_ready());
assert_eq!(indicator.value(), None);
assert_eq!(indicator.update(50.0), None);
}
#[test]
fn stays_in_0_100_range() {
let prices: Vec<f64> = (0..200)
.map(|i| 100.0 + (f64::from(i) * 0.7).sin() * 10.0)
.collect();
let mut indicator = AnchoredRsi::new();
for value in indicator.batch(&prices).into_iter().flatten() {
assert!((0.0..=100.0).contains(&value), "RSI out of range: {value}");
}
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (1..=40)
.map(|i| (f64::from(i) * 0.3).sin() * 5.0 + f64::from(i))
.collect();
let mut a = AnchoredRsi::new();
let mut b = AnchoredRsi::new();
assert_eq!(
a.batch(&prices),
prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,176 @@
//! Auto-Fibonacci — retracement of the most significant recent swing leg.
use crate::indicators::pattern_swing::{SwingTracker, SWING_THRESHOLD};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// How many recent pivots to consider when picking the dominant leg.
const PIVOT_HISTORY: usize = 6;
/// The seven canonical retracement ratios, in ascending order.
const RATIOS: [f64; 7] = [0.0, 0.236, 0.382, 0.5, 0.618, 0.786, 1.0];
/// Auto-Fibonacci retracement levels for the dominant recent swing leg.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct AutoFibOutput {
/// 0.0% — the dominant leg's end.
pub level_0: f64,
/// 23.6% retracement.
pub level_236: f64,
/// 38.2% retracement.
pub level_382: f64,
/// 50% retracement.
pub level_500: f64,
/// 61.8% retracement.
pub level_618: f64,
/// 78.6% retracement.
pub level_786: f64,
/// 100% — the dominant leg's start.
pub level_1000: f64,
}
/// Auto-Fibonacci (`AutoFib`).
///
/// Like [`crate::indicators::FibRetracement`], but instead of always using the
/// immediate last leg it scans the last six confirmed pivots and anchors the
/// retracement on the single largest-magnitude leg among them — the dominant
/// swing the market is most likely respecting.
///
/// Parameter-free; construction is infallible. Returns `None` until two pivots
/// have confirmed.
///
/// See `crates/wickra-core/src/indicators/auto_fib.rs`.
#[derive(Debug, Clone)]
pub struct AutoFib {
swing: SwingTracker,
}
impl AutoFib {
/// Construct a new Auto-Fibonacci tracker.
#[must_use]
pub const fn new() -> Self {
Self {
swing: SwingTracker::new(SWING_THRESHOLD, PIVOT_HISTORY),
}
}
fn levels(&self) -> Option<AutoFibOutput> {
let dominant = self.swing.pivots().windows(2).max_by(|x, y| {
(x[0].price - x[1].price)
.abs()
.total_cmp(&(y[0].price - y[1].price).abs())
})?;
let (start, end) = (dominant[0].price, dominant[1].price);
let level = |r: f64| end + r * (start - end);
Some(AutoFibOutput {
level_0: level(RATIOS[0]),
level_236: level(RATIOS[1]),
level_382: level(RATIOS[2]),
level_500: level(RATIOS[3]),
level_618: level(RATIOS[4]),
level_786: level(RATIOS[5]),
level_1000: level(RATIOS[6]),
})
}
}
impl Default for AutoFib {
fn default() -> Self {
Self::new()
}
}
impl Indicator for AutoFib {
type Input = Candle;
type Output = AutoFibOutput;
fn update(&mut self, candle: Candle) -> Option<AutoFibOutput> {
self.swing.update(candle);
self.levels()
}
fn reset(&mut self) {
self.swing.reset();
}
fn warmup_period(&self) -> usize {
2
}
fn is_ready(&self) -> bool {
self.swing.pivots().len() >= 2
}
fn name(&self) -> &'static str {
"AutoFib"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::indicators::pattern_swing::candles_for_pivots;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn accessors_and_metadata() {
let indicator = AutoFib::new();
assert_eq!(indicator.name(), "AutoFib");
assert_eq!(indicator.warmup_period(), 2);
assert!(!indicator.is_ready());
assert!(!AutoFib::default().is_ready());
}
#[test]
fn no_output_before_two_pivots() {
let mut indicator = AutoFib::new();
let outputs: Vec<_> = candles_for_pivots(&[120.0])
.into_iter()
.map(|c| indicator.update(c))
.collect();
assert!(outputs.iter().all(Option::is_none));
}
#[test]
fn anchors_on_the_largest_leg() {
// Pivots: 130 -> 120 (small, 10) -> 220 (large, 100) -> 200 (small, 20).
// The dominant leg is 120 -> 220; its retracement spans [120, 220].
let mut indicator = AutoFib::new();
let mut last = None;
for candle in candles_for_pivots(&[130.0, 120.0, 220.0, 200.0]) {
last = indicator.update(candle);
}
let v = last.unwrap();
assert!(indicator.is_ready());
// Largest leg 120 -> 220: 0% on 220 (end), 100% on 120 (start).
assert_relative_eq!(v.level_0, 220.0);
assert_relative_eq!(v.level_1000, 120.0);
assert_relative_eq!(v.level_500, 170.0);
assert_relative_eq!(v.level_618, 220.0 + 0.618 * (120.0 - 220.0));
}
#[test]
fn reset_clears_state() {
let mut indicator = AutoFib::new();
for candle in candles_for_pivots(&[200.0, 100.0]) {
let _ = indicator.update(candle);
}
assert!(indicator.is_ready());
indicator.reset();
assert!(!indicator.is_ready());
let c = Candle::new(99.5, 100.0, 99.5, 99.5, 1.0, 0).unwrap();
assert!(indicator.update(c).is_none());
}
#[test]
fn batch_equals_streaming() {
let candles = candles_for_pivots(&[130.0, 120.0, 220.0, 200.0]);
let mut a = AutoFib::new();
let mut b = AutoFib::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,231 @@
//! Average Daily Range (ADR) — the mean high-minus-low range of the last `period`
//! completed calendar-day sessions.
use std::collections::VecDeque;
use crate::calendar::civil_from_timestamp;
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Average Daily Range over the last `period` completed sessions.
///
/// The indicator tracks the running high / low of the current session (the
/// wall-clock day of [`Candle::timestamp`](crate::Candle) shifted by
/// `utc_offset_minutes`). When a new day begins, the just-finished session's
/// range (`high - low`) joins a rolling window of the last `period` completed
/// days, and the reported value is their mean. The current, still-forming day is
/// excluded until it closes. No value is produced until the first session
/// completes.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, AverageDailyRange};
///
/// let hour = 3_600_000;
/// let mut adr = AverageDailyRange::new(2, 0).unwrap();
/// // Day 1 range 10 (high 110, low 100) — still forming, so None.
/// assert!(adr.update(Candle::new(105.0, 110.0, 100.0, 108.0, 1.0, 0).unwrap()).is_none());
/// // First bar of day 2 closes day 1: ADR = 10.
/// let v = adr.update(Candle::new(108.0, 112.0, 106.0, 109.0, 1.0, 24 * hour).unwrap()).unwrap();
/// assert!((v - 10.0).abs() < 1e-9);
/// ```
#[derive(Debug, Clone)]
pub struct AverageDailyRange {
period: usize,
utc_offset_minutes: i32,
day_key: Option<(i64, u32, u32)>,
cur_high: f64,
cur_low: f64,
completed: VecDeque<f64>,
sum: f64,
}
impl AverageDailyRange {
/// Construct an ADR indicator over `period` completed days.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize, utc_offset_minutes: i32) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
utc_offset_minutes,
day_key: None,
cur_high: f64::NEG_INFINITY,
cur_low: f64::INFINITY,
completed: VecDeque::with_capacity(period),
sum: 0.0,
})
}
/// Configured `(period, utc_offset_minutes)`.
pub const fn params(&self) -> (usize, i32) {
(self.period, self.utc_offset_minutes)
}
/// Most recent ADR if at least one session has completed.
pub fn value(&self) -> Option<f64> {
if self.completed.is_empty() {
None
} else {
Some(self.sum / self.completed.len() as f64)
}
}
}
impl Indicator for AverageDailyRange {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
let civil = civil_from_timestamp(candle.timestamp, self.utc_offset_minutes);
let key = (civil.year, civil.month, civil.day);
match self.day_key {
Some(prev) if prev == key => {
if candle.high > self.cur_high {
self.cur_high = candle.high;
}
if candle.low < self.cur_low {
self.cur_low = candle.low;
}
}
Some(_) => {
let range = self.cur_high - self.cur_low;
self.completed.push_back(range);
self.sum += range;
if self.completed.len() > self.period {
self.sum -= self
.completed
.pop_front()
.expect("len > period implies a front element");
}
self.day_key = Some(key);
self.cur_high = candle.high;
self.cur_low = candle.low;
}
None => {
self.day_key = Some(key);
self.cur_high = candle.high;
self.cur_low = candle.low;
}
}
self.value()
}
fn reset(&mut self) {
self.day_key = None;
self.cur_high = f64::NEG_INFINITY;
self.cur_low = f64::INFINITY;
self.completed.clear();
self.sum = 0.0;
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
!self.completed.is_empty()
}
fn name(&self) -> &'static str {
"AverageDailyRange"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
const HOUR: i64 = 3_600_000;
const DAY: i64 = 24 * HOUR;
fn c(high: f64, low: f64, ts: i64) -> Candle {
let mid = f64::midpoint(high, low);
Candle::new(mid, high, low, mid, 1.0, ts).unwrap()
}
#[test]
fn rejects_zero_period() {
assert!(matches!(
AverageDailyRange::new(0, 0),
Err(Error::PeriodZero)
));
}
#[test]
fn metadata_and_accessors() {
let adr = AverageDailyRange::new(5, -60).unwrap();
assert_eq!(adr.params(), (5, -60));
assert_eq!(adr.name(), "AverageDailyRange");
assert_eq!(adr.warmup_period(), 5);
assert!(!adr.is_ready());
assert!(adr.value().is_none());
}
#[test]
fn averages_completed_day_ranges() {
let mut adr = AverageDailyRange::new(3, 0).unwrap();
// Day 1: range 10.
assert!(adr.update(c(110.0, 100.0, 0)).is_none());
assert!(adr.update(c(108.0, 104.0, HOUR)).is_none());
// Day 2 opens -> day 1 (range 10) completes.
let v = adr.update(c(120.0, 110.0, DAY)).unwrap();
assert_relative_eq!(v, 10.0);
assert!(adr.is_ready());
// Day 3 opens -> day 2 (range 10) completes: mean of [10, 10] = 10.
let v = adr.update(c(130.0, 100.0, 2 * DAY)).unwrap();
assert_relative_eq!(v, 10.0);
}
#[test]
fn rolls_off_oldest_day_beyond_period() {
let mut adr = AverageDailyRange::new(2, 0).unwrap();
adr.update(c(110.0, 100.0, 0)); // day 1 range 10
let v = adr.update(c(125.0, 110.0, DAY)).unwrap(); // close day 1 -> [10]
assert_relative_eq!(v, 10.0);
// Close day 2 (range 125-110=15) -> window [10, 15], mean 12.5.
let v = adr.update(c(130.0, 110.0, 2 * DAY)).unwrap();
assert_relative_eq!(v, 12.5);
// Close day 3 (range 130-110=20) -> window [15, 20], oldest (10) rolled off.
let v = adr.update(c(140.0, 138.0, 3 * DAY)).unwrap();
assert_relative_eq!(v, 17.5);
}
#[test]
fn reset_clears_state() {
let mut adr = AverageDailyRange::new(2, 0).unwrap();
adr.update(c(110.0, 100.0, 0));
adr.update(c(120.0, 110.0, DAY));
adr.reset();
assert!(!adr.is_ready());
assert!(adr.value().is_none());
assert!(adr.update(c(50.0, 40.0, 2 * DAY)).is_none());
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..60)
.map(|i| {
c(
110.0 + f64::from(i % 5),
100.0 - f64::from(i % 3),
i64::from(i) * 6 * HOUR,
)
})
.collect();
let mut a = AverageDailyRange::new(4, 0).unwrap();
let mut b = AverageDailyRange::new(4, 0).unwrap();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,92 @@
//! Average Price (AVGPRICE).
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Average Price (`AVGPRICE`) — the bar's `(open + high + low + close) / 4`.
///
/// A per-bar price aggregate that, unlike [`TypicalPrice`](crate::TypicalPrice)
/// and [`WeightedClose`](crate::WeightedClose), folds in the open as well as the
/// high, low and close. As a stateless transform it emits a value from the very
/// first candle.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, AvgPrice};
///
/// let mut indicator = AvgPrice::new();
/// let mut last = None;
/// for i in 0..80 {
/// let base = 100.0 + f64::from(i);
/// let candle =
/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
/// last = indicator.update(candle);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone, Default)]
pub struct AvgPrice {
has_emitted: bool,
}
impl AvgPrice {
/// Construct a new Average Price transform.
pub const fn new() -> Self {
Self { has_emitted: false }
}
}
impl Indicator for AvgPrice {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
self.has_emitted = true;
Some(candle.avg_price())
}
fn reset(&mut self) {
self.has_emitted = false;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"AVGPRICE"
}
}
#[cfg(test)]
mod tests {
use super::*;
use approx::assert_relative_eq;
#[test]
fn averages_the_four_prices() {
// (open + high + low + close) / 4 = (10 + 14 + 6 + 12) / 4 = 10.5.
let candle = Candle::new(10.0, 14.0, 6.0, 12.0, 1.0, 0).unwrap();
let mut ap = AvgPrice::new();
assert!(!ap.is_ready());
assert_relative_eq!(ap.update(candle).unwrap(), 10.5, epsilon = 1e-12);
assert!(ap.is_ready());
}
#[test]
fn accessors_and_reset() {
let mut ap = AvgPrice::new();
assert_eq!(ap.name(), "AVGPRICE");
assert_eq!(ap.warmup_period(), 1);
let candle = Candle::new(10.0, 14.0, 6.0, 12.0, 1.0, 0).unwrap();
let _ = ap.update(candle);
assert!(ap.is_ready());
ap.reset();
assert!(!ap.is_ready());
}
}
+154
View File
@@ -0,0 +1,154 @@
//! Bat harmonic pattern.
use crate::indicators::pattern_swing::{ratios_in, xabcd, SwingTracker, SWING_THRESHOLD};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Bat — a 5-point (X-A-B-C-D) harmonic pattern with a shallow B and a deep
/// `0.886` D completion:
///
/// ```text
/// AB / XA ∈ [0.382, 0.50]
/// BC / AB ∈ [0.382, 0.886]
/// CD / BC ∈ [1.618, 2.618]
/// AD / XA ∈ [0.84, 0.93] (≈ 0.886 — the defining D completion)
/// ```
///
/// Output is `+1.0` (bullish, D a swing low), `-1.0` (bearish, D a swing high),
/// or `0.0`; never `None`. See `crates/wickra-core/src/indicators/bat.rs`.
#[derive(Debug, Clone)]
pub struct Bat {
swing: SwingTracker,
has_emitted: bool,
}
impl Bat {
/// Construct a new Bat detector.
pub const fn new() -> Self {
Self {
swing: SwingTracker::new(SWING_THRESHOLD, 5),
has_emitted: false,
}
}
}
impl Default for Bat {
fn default() -> Self {
Self::new()
}
}
impl Indicator for Bat {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
self.has_emitted = true;
if !self.swing.update(candle) {
return Some(0.0);
}
let pivots = self.swing.pivots();
if pivots.len() < 5 {
return Some(0.0);
}
let p = xabcd(pivots);
let xa = (p.a - p.x).abs();
let ab = (p.b - p.a).abs();
let bc = (p.c - p.b).abs();
let cd = (p.d - p.c).abs();
let ad = (p.d - p.a).abs();
let matched = ratios_in(&[
(ab / xa, 0.382, 0.50),
(bc / ab, 0.382, 0.886),
(cd / bc, 1.618, 2.618),
(ad / xa, 0.84, 0.93),
]);
if matched {
return Some(if p.bullish { 1.0 } else { -1.0 });
}
Some(0.0)
}
fn reset(&mut self) {
self.swing.reset();
self.has_emitted = false;
}
fn warmup_period(&self) -> usize {
6
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"Bat"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::indicators::pattern_swing::candles_for_pivots;
use crate::traits::BatchExt;
fn run(pivots: &[f64]) -> Vec<f64> {
let mut indicator = Bat::new();
candles_for_pivots(pivots)
.into_iter()
.map(|c| indicator.update(c).unwrap())
.collect()
}
#[test]
fn accessors_and_metadata() {
let indicator = Bat::new();
assert_eq!(indicator.name(), "Bat");
assert_eq!(indicator.warmup_period(), 6);
assert!(!indicator.is_ready());
assert!(!Bat::default().is_ready());
}
#[test]
fn bullish_bat_is_plus_one() {
let out = run(&[150.0, 100.0, 140.0, 122.0, 137.0, 104.56]);
assert_eq!(*out.last().unwrap(), 1.0);
assert!(out[..out.len() - 1].iter().all(|&x| x == 0.0));
}
#[test]
fn bearish_bat_is_minus_one() {
let out = run(&[150.0, 110.0, 128.0, 113.0, 145.44]);
assert_eq!(*out.last().unwrap(), -1.0);
}
#[test]
fn out_of_ratio_does_not_trigger() {
let out = run(&[150.0, 100.0, 140.0, 110.0, 135.0, 105.0]);
assert_eq!(*out.last().unwrap(), 0.0);
}
#[test]
fn reset_clears_state() {
let mut indicator = Bat::new();
for c in candles_for_pivots(&[150.0, 100.0, 140.0]) {
let _ = indicator.update(c);
}
indicator.reset();
assert!(!indicator.is_ready());
let c = Candle::new(99.5, 100.0, 99.5, 99.5, 1.0, 0).unwrap();
assert_eq!(indicator.update(c), Some(0.0));
}
#[test]
fn batch_equals_streaming() {
let candles = candles_for_pivots(&[150.0, 100.0, 140.0, 122.0, 137.0, 104.56]);
let mut a = Bat::new();
let mut b = Bat::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,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,193 @@
//! Body Size Percent — candle body as a fraction of its range.
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Body Size Percent — the absolute body as a fraction of the bar's range.
///
/// ```text
/// BodySizePct = |close open| / (high low)
/// ```
///
/// The result lives in `[0, 1]`: `1` is a full-bodied marubozu (the bar opened
/// at one extreme and closed at the other, no wicks), `0` a doji (open equals
/// close, the bar is all wick). It is the *unsigned* magnitude companion to
/// [`BalanceOfPower`](crate::BalanceOfPower) — where `BoP` keeps the direction,
/// this keeps only the conviction, which is exactly what candlestick body /
/// range filters key on. A zero-range bar carries no information and yields `0`.
///
/// This is a stateless per-bar transform: every candle produces one value.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, BodySizePct};
///
/// let mut indicator = BodySizePct::new();
/// // body |12 - 10| = 2, range 14 - 10 = 4 -> 0.5.
/// let c = Candle::new(10.0, 14.0, 10.0, 12.0, 10.0, 0).unwrap();
/// assert!((indicator.update(c).unwrap() - 0.5).abs() < 1e-12);
/// ```
#[derive(Debug, Clone, Default)]
pub struct BodySizePct {
has_emitted: bool,
}
impl BodySizePct {
/// Construct a new Body Size Percent transform.
pub const fn new() -> Self {
Self { has_emitted: false }
}
}
impl Indicator for BodySizePct {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
self.has_emitted = true;
let range = candle.high - candle.low;
let out = if range == 0.0 {
// A zero-range bar has no body proportion to speak of.
0.0
} else {
(candle.close - candle.open).abs() / range
};
Some(out)
}
fn reset(&mut self) {
self.has_emitted = false;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"BodySizePct"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn candle(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle {
Candle::new(open, high, low, close, 1.0, ts).unwrap()
}
#[test]
fn reference_value() {
// |12 - 10| / (14 - 10) = 0.5.
let mut bsp = BodySizePct::new();
assert_relative_eq!(
bsp.update(candle(10.0, 14.0, 10.0, 12.0, 0)).unwrap(),
0.5,
epsilon = 1e-12
);
}
#[test]
fn marubozu_is_one() {
// open == low, close == high, no wicks -> full body -> 1.
let mut bsp = BodySizePct::new();
assert_relative_eq!(
bsp.update(candle(9.0, 11.0, 9.0, 11.0, 0)).unwrap(),
1.0,
epsilon = 1e-12
);
}
#[test]
fn doji_is_zero() {
// open == close with a real range -> body 0.
let mut bsp = BodySizePct::new();
assert_relative_eq!(
bsp.update(candle(10.0, 12.0, 8.0, 10.0, 0)).unwrap(),
0.0,
epsilon = 1e-12
);
}
#[test]
fn unsigned_regardless_of_direction() {
// A red bar with the same body magnitude reads identically to a green one.
let mut bsp = BodySizePct::new();
let green = bsp.update(candle(10.0, 14.0, 10.0, 12.0, 0)).unwrap();
let mut bsp2 = BodySizePct::new();
let red = bsp2.update(candle(12.0, 14.0, 10.0, 10.0, 0)).unwrap();
assert_relative_eq!(green, red, epsilon = 1e-12);
}
#[test]
fn zero_range_bar_yields_zero() {
let mut bsp = BodySizePct::new();
assert_relative_eq!(
bsp.update(candle(10.0, 10.0, 10.0, 10.0, 0)).unwrap(),
0.0,
epsilon = 1e-12
);
}
#[test]
fn stays_within_unit_range() {
let candles: Vec<Candle> = (0..100)
.map(|i| {
let mid = 100.0 + (f64::from(i) * 0.2).sin() * 8.0;
let close = mid + (f64::from(i) * 0.5).cos() * 2.0;
candle(mid, mid + 3.0, mid - 3.0, close, i64::from(i))
})
.collect();
let mut bsp = BodySizePct::new();
for v in bsp.batch(&candles).into_iter().flatten() {
assert!((0.0..=1.0).contains(&v), "BodySizePct {v} outside [0, 1]");
}
}
#[test]
fn name_metadata() {
let bsp = BodySizePct::new();
assert_eq!(bsp.name(), "BodySizePct");
}
#[test]
fn emits_from_first_candle() {
let mut bsp = BodySizePct::new();
assert_eq!(bsp.warmup_period(), 1);
assert!(!bsp.is_ready());
assert!(bsp.update(candle(10.0, 11.0, 9.0, 10.0, 0)).is_some());
assert!(bsp.is_ready());
}
#[test]
fn reset_clears_state() {
let mut bsp = BodySizePct::new();
bsp.update(candle(10.0, 11.0, 9.0, 10.0, 0));
assert!(bsp.is_ready());
bsp.reset();
assert!(!bsp.is_ready());
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..40)
.map(|i| {
let base = 100.0 + f64::from(i);
candle(base, base + 2.0, base - 2.0, base + 1.0, i64::from(i))
})
.collect();
let mut a = BodySizePct::new();
let mut b = BodySizePct::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
@@ -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,147 @@
//! Bullish Percent Index — share of a universe on a point-and-figure buy signal.
use crate::cross_section::CrossSection;
use crate::traits::Indicator;
/// Bullish Percent Index (BPI) — the percentage of symbols in a universe that are
/// currently on a point-and-figure buy signal.
///
/// On each [`CrossSection`] tick the value is `100 * on_buy_signal_count /
/// universe size`, read from the per-symbol `on_buy_signal` flag (the caller
/// evaluates each symbol's point-and-figure chart when it builds the tick). It is
/// a bounded `0..=100` gauge of how many issues are in a confirmed uptrend.
/// Readings above 70 are considered overbought (broad strength, but a crowded
/// market) and below 30 oversold; reversals from those zones are classic BPI
/// buy/sell triggers.
///
/// `Input = CrossSection`, `Output = f64` (a percentage in `0..=100`),
/// `warmup_period == 1`. The universe is non-empty by construction, so the share
/// is always defined.
///
/// # Example
///
/// ```
/// use wickra_core::{BullishPercentIndex, CrossSection, Indicator, Member};
///
/// let mut bpi = BullishPercentIndex::new();
/// // 2 of 4 symbols on a buy signal -> 50%.
/// let tick = CrossSection::new(
/// vec![
/// Member::with_signals(1.0, 10.0, false, false, false, true),
/// Member::with_signals(1.0, 10.0, false, false, false, true),
/// Member::with_signals(-1.0, 10.0, false, false, false, false),
/// Member::with_signals(-1.0, 10.0, false, false, false, false),
/// ],
/// 0,
/// )
/// .unwrap();
/// assert_eq!(bpi.update(tick), Some(50.0));
/// ```
#[derive(Debug, Clone, Default)]
pub struct BullishPercentIndex {
has_emitted: bool,
}
impl BullishPercentIndex {
/// Construct a new Bullish Percent Index indicator.
#[must_use]
pub const fn new() -> Self {
Self { has_emitted: false }
}
}
impl Indicator for BullishPercentIndex {
type Input = CrossSection;
type Output = f64;
fn update(&mut self, section: CrossSection) -> Option<f64> {
let bullish = section.on_buy_signal_count() as f64;
let total = section.members.len() as f64;
self.has_emitted = true;
Some(100.0 * bullish / total)
}
fn reset(&mut self) {
self.has_emitted = false;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"BullishPercentIndex"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cross_section::Member;
use crate::traits::BatchExt;
fn tick(bullish: usize, bearish: usize) -> CrossSection {
let mut members = Vec::new();
for _ in 0..bullish {
members.push(Member::with_signals(1.0, 10.0, false, false, false, true));
}
for _ in 0..bearish {
members.push(Member::with_signals(-1.0, 10.0, false, false, false, false));
}
CrossSection::new(members, 0).unwrap()
}
#[test]
fn accessors_and_metadata() {
let bpi = BullishPercentIndex::new();
assert_eq!(bpi.name(), "BullishPercentIndex");
assert_eq!(bpi.warmup_period(), 1);
assert!(!bpi.is_ready());
}
#[test]
fn first_tick_emits_percentage() {
let mut bpi = BullishPercentIndex::new();
assert_eq!(bpi.update(tick(2, 2)), Some(50.0));
assert!(bpi.is_ready());
}
#[test]
fn all_bullish_is_one_hundred() {
let mut bpi = BullishPercentIndex::new();
assert_eq!(bpi.update(tick(5, 0)), Some(100.0));
}
#[test]
fn none_bullish_is_zero() {
let mut bpi = BullishPercentIndex::new();
assert_eq!(bpi.update(tick(0, 4)), Some(0.0));
}
#[test]
fn reset_clears_state() {
let mut bpi = BullishPercentIndex::new();
bpi.update(tick(2, 2));
assert!(bpi.is_ready());
bpi.reset();
assert!(!bpi.is_ready());
}
#[test]
fn batch_equals_streaming() {
let sections = vec![tick(2, 2), tick(5, 0), tick(0, 4)];
let mut a = BullishPercentIndex::new();
let mut b = BullishPercentIndex::new();
assert_eq!(
a.batch(&sections),
sections
.iter()
.map(|s| b.update(s.clone()))
.collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,154 @@
//! Butterfly harmonic pattern.
use crate::indicators::pattern_swing::{ratios_in, xabcd, SwingTracker, SWING_THRESHOLD};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Butterfly — a 5-point (X-A-B-C-D) harmonic pattern with a `0.786` B and an
/// **extended** D that overshoots X:
///
/// ```text
/// AB / XA ∈ [0.74, 0.84] (≈ 0.786)
/// BC / AB ∈ [0.382, 0.886]
/// CD / BC ∈ [1.618, 2.618]
/// AD / XA ∈ [1.27, 1.618] (the defining extended D completion)
/// ```
///
/// Output is `+1.0` (bullish, D a swing low), `-1.0` (bearish, D a swing high),
/// or `0.0`; never `None`. See `crates/wickra-core/src/indicators/butterfly.rs`.
#[derive(Debug, Clone)]
pub struct Butterfly {
swing: SwingTracker,
has_emitted: bool,
}
impl Butterfly {
/// Construct a new Butterfly detector.
pub const fn new() -> Self {
Self {
swing: SwingTracker::new(SWING_THRESHOLD, 5),
has_emitted: false,
}
}
}
impl Default for Butterfly {
fn default() -> Self {
Self::new()
}
}
impl Indicator for Butterfly {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
self.has_emitted = true;
if !self.swing.update(candle) {
return Some(0.0);
}
let pivots = self.swing.pivots();
if pivots.len() < 5 {
return Some(0.0);
}
let p = xabcd(pivots);
let xa = (p.a - p.x).abs();
let ab = (p.b - p.a).abs();
let bc = (p.c - p.b).abs();
let cd = (p.d - p.c).abs();
let ad = (p.d - p.a).abs();
let matched = ratios_in(&[
(ab / xa, 0.74, 0.84),
(bc / ab, 0.382, 0.886),
(cd / bc, 1.618, 2.618),
(ad / xa, 1.27, 1.618),
]);
if matched {
return Some(if p.bullish { 1.0 } else { -1.0 });
}
Some(0.0)
}
fn reset(&mut self) {
self.swing.reset();
self.has_emitted = false;
}
fn warmup_period(&self) -> usize {
6
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"Butterfly"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::indicators::pattern_swing::candles_for_pivots;
use crate::traits::BatchExt;
fn run(pivots: &[f64]) -> Vec<f64> {
let mut indicator = Butterfly::new();
candles_for_pivots(pivots)
.into_iter()
.map(|c| indicator.update(c).unwrap())
.collect()
}
#[test]
fn accessors_and_metadata() {
let indicator = Butterfly::new();
assert_eq!(indicator.name(), "Butterfly");
assert_eq!(indicator.warmup_period(), 6);
assert!(!indicator.is_ready());
assert!(!Butterfly::default().is_ready());
}
#[test]
fn bullish_butterfly_is_plus_one() {
let out = run(&[150.0, 100.0, 140.0, 108.6, 128.0, 79.8]);
assert_eq!(*out.last().unwrap(), 1.0);
assert!(out[..out.len() - 1].iter().all(|&x| x == 0.0));
}
#[test]
fn bearish_butterfly_is_minus_one() {
let out = run(&[150.0, 110.0, 141.4, 121.4, 170.2]);
assert_eq!(*out.last().unwrap(), -1.0);
}
#[test]
fn out_of_ratio_does_not_trigger() {
let out = run(&[150.0, 100.0, 140.0, 110.0, 135.0, 105.0]);
assert_eq!(*out.last().unwrap(), 0.0);
}
#[test]
fn reset_clears_state() {
let mut indicator = Butterfly::new();
for c in candles_for_pivots(&[150.0, 100.0, 140.0]) {
let _ = indicator.update(c);
}
indicator.reset();
assert!(!indicator.is_ready());
let c = Candle::new(99.5, 100.0, 99.5, 99.5, 1.0, 0).unwrap();
assert_eq!(indicator.update(c), Some(0.0));
}
#[test]
fn batch_equals_streaming() {
let candles = candles_for_pivots(&[150.0, 100.0, 140.0, 108.6, 128.0, 79.8]);
let mut a = Butterfly::new();
let mut b = Butterfly::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,157 @@
//! Close vs Open — the signed relative body of a bar.
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Close vs Open — the bar's body as a signed fraction of its open price.
///
/// ```text
/// CloseVsOpen = (close open) / open
/// ```
///
/// A scale-free, signed measure of how far price travelled from open to close:
/// `+0.02` is a bar that closed 2% above its open (a green bar), `0.02` the
/// mirror. Unlike [`BalanceOfPower`](crate::BalanceOfPower) — which normalises
/// the body by the bar *range* — this normalises by the *open price*, so it is
/// directly comparable to a return and stays meaningful across instruments of
/// different nominal price. A zero open carries no scale and yields `0`.
///
/// This is a stateless per-bar transform: every candle produces one value.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, CloseVsOpen};
///
/// let mut indicator = CloseVsOpen::new();
/// // open 100, close 102 -> +0.02.
/// let c = Candle::new(100.0, 103.0, 99.0, 102.0, 10.0, 0).unwrap();
/// assert!((indicator.update(c).unwrap() - 0.02).abs() < 1e-12);
/// ```
#[derive(Debug, Clone, Default)]
pub struct CloseVsOpen {
has_emitted: bool,
}
impl CloseVsOpen {
/// Construct a new Close vs Open transform.
pub const fn new() -> Self {
Self { has_emitted: false }
}
}
impl Indicator for CloseVsOpen {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
self.has_emitted = true;
let out = if candle.open == 0.0 {
// A zero open price carries no scale to normalise against.
0.0
} else {
(candle.close - candle.open) / candle.open
};
Some(out)
}
fn reset(&mut self) {
self.has_emitted = false;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"CloseVsOpen"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn candle(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle {
Candle::new(open, high, low, close, 1.0, ts).unwrap()
}
#[test]
fn reference_value() {
// (102 - 100) / 100 = 0.02.
let mut cvo = CloseVsOpen::new();
assert_relative_eq!(
cvo.update(candle(100.0, 103.0, 99.0, 102.0, 0)).unwrap(),
0.02,
epsilon = 1e-12
);
}
#[test]
fn negative_body_is_negative() {
let mut cvo = CloseVsOpen::new();
// close below open -> negative.
assert_relative_eq!(
cvo.update(candle(100.0, 101.0, 97.0, 98.0, 0)).unwrap(),
-0.02,
epsilon = 1e-12
);
}
#[test]
fn zero_open_yields_zero() {
// Candle permits a zero open (only finiteness + OHLC ordering checked).
let mut cvo = CloseVsOpen::new();
assert_relative_eq!(
cvo.update(candle(0.0, 1.0, 0.0, 0.5, 0)).unwrap(),
0.0,
epsilon = 1e-12
);
}
#[test]
fn name_metadata() {
let cvo = CloseVsOpen::new();
assert_eq!(cvo.name(), "CloseVsOpen");
}
#[test]
fn emits_from_first_candle() {
let mut cvo = CloseVsOpen::new();
assert_eq!(cvo.warmup_period(), 1);
assert!(!cvo.is_ready());
assert!(cvo.update(candle(10.0, 11.0, 9.0, 10.0, 0)).is_some());
assert!(cvo.is_ready());
}
#[test]
fn reset_clears_state() {
let mut cvo = CloseVsOpen::new();
cvo.update(candle(10.0, 11.0, 9.0, 10.0, 0));
assert!(cvo.is_ready());
cvo.reset();
assert!(!cvo.is_ready());
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..40)
.map(|i| {
let base = 100.0 + f64::from(i);
candle(base, base + 2.0, base - 2.0, base + 1.0, i64::from(i))
})
.collect();
let mut a = CloseVsOpen::new();
let mut b = CloseVsOpen::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
+154
View File
@@ -0,0 +1,154 @@
//! Crab harmonic pattern.
use crate::indicators::pattern_swing::{ratios_in, xabcd, SwingTracker, SWING_THRESHOLD};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Crab — a 5-point (X-A-B-C-D) harmonic pattern with the deepest D completion
/// of the family, an `1.618` extension of XA:
///
/// ```text
/// AB / XA ∈ [0.382, 0.618]
/// BC / AB ∈ [0.382, 0.886]
/// CD / BC ∈ [2.24, 3.618] (a very long terminal leg)
/// AD / XA ∈ [1.55, 1.65] (≈ 1.618 — the defining D completion)
/// ```
///
/// Output is `+1.0` (bullish, D a swing low), `-1.0` (bearish, D a swing high),
/// or `0.0`; never `None`. See `crates/wickra-core/src/indicators/crab.rs`.
#[derive(Debug, Clone)]
pub struct Crab {
swing: SwingTracker,
has_emitted: bool,
}
impl Crab {
/// Construct a new Crab detector.
pub const fn new() -> Self {
Self {
swing: SwingTracker::new(SWING_THRESHOLD, 5),
has_emitted: false,
}
}
}
impl Default for Crab {
fn default() -> Self {
Self::new()
}
}
impl Indicator for Crab {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
self.has_emitted = true;
if !self.swing.update(candle) {
return Some(0.0);
}
let pivots = self.swing.pivots();
if pivots.len() < 5 {
return Some(0.0);
}
let p = xabcd(pivots);
let xa = (p.a - p.x).abs();
let ab = (p.b - p.a).abs();
let bc = (p.c - p.b).abs();
let cd = (p.d - p.c).abs();
let ad = (p.d - p.a).abs();
let matched = ratios_in(&[
(ab / xa, 0.382, 0.618),
(bc / ab, 0.382, 0.886),
(cd / bc, 2.24, 3.618),
(ad / xa, 1.55, 1.65),
]);
if matched {
return Some(if p.bullish { 1.0 } else { -1.0 });
}
Some(0.0)
}
fn reset(&mut self) {
self.swing.reset();
self.has_emitted = false;
}
fn warmup_period(&self) -> usize {
6
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"Crab"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::indicators::pattern_swing::candles_for_pivots;
use crate::traits::BatchExt;
fn run(pivots: &[f64]) -> Vec<f64> {
let mut indicator = Crab::new();
candles_for_pivots(pivots)
.into_iter()
.map(|c| indicator.update(c).unwrap())
.collect()
}
#[test]
fn accessors_and_metadata() {
let indicator = Crab::new();
assert_eq!(indicator.name(), "Crab");
assert_eq!(indicator.warmup_period(), 6);
assert!(!indicator.is_ready());
assert!(!Crab::default().is_ready());
}
#[test]
fn bullish_crab_is_plus_one() {
let out = run(&[150.0, 100.0, 140.0, 120.0, 137.5, 75.3]);
assert_eq!(*out.last().unwrap(), 1.0);
assert!(out[..out.len() - 1].iter().all(|&x| x == 0.0));
}
#[test]
fn bearish_crab_is_minus_one() {
let out = run(&[150.0, 110.0, 130.0, 112.5, 174.7]);
assert_eq!(*out.last().unwrap(), -1.0);
}
#[test]
fn out_of_ratio_does_not_trigger() {
let out = run(&[150.0, 100.0, 140.0, 110.0, 135.0, 105.0]);
assert_eq!(*out.last().unwrap(), 0.0);
}
#[test]
fn reset_clears_state() {
let mut indicator = Crab::new();
for c in candles_for_pivots(&[150.0, 100.0, 140.0]) {
let _ = indicator.update(c);
}
indicator.reset();
assert!(!indicator.is_ready());
let c = Candle::new(99.5, 100.0, 99.5, 99.5, 1.0, 0).unwrap();
assert_eq!(indicator.update(c), Some(0.0));
}
#[test]
fn batch_equals_streaming() {
let candles = candles_for_pivots(&[150.0, 100.0, 140.0, 120.0, 137.5, 75.3]);
let mut a = Crab::new();
let mut b = Crab::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
@@ -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,176 @@
//! Cup-and-Handle (and Inverse) continuation chart pattern.
use crate::indicators::pattern_swing::{
approx_equal, SwingTracker, LEVEL_TOLERANCE, SWING_THRESHOLD,
};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Cup-and-Handle / Inverse — a rounded base (the cup) followed by a shallow
/// pullback (the handle) near the rim, then a breakout in the cup's direction.
///
/// Built on confirmed swing pivots ([`SWING_THRESHOLD`] = 5%) and read from the
/// last four pivots:
///
/// ```text
/// cup-and-handle (bullish, +1): Rim(high) , Cup(low) , Rim(high) , Handle(low)
/// the two rims match (±3%) ; the handle low sits ABOVE the cup low (a shallow
/// pullback) and below the right rim
///
/// inverse (bearish, -1): Rim(low) , Cap(high) , Rim(low) , Handle(high)
/// the two rims match ; the handle high sits BELOW the cap high and above the
/// right rim
/// ```
///
/// The shallow handle (closer to the rim than the cup extreme) is what
/// distinguishes a cup-and-handle from a plain double bottom/top. Output is
/// `+1.0` / `-1.0` / `0.0`; never `None`.
#[derive(Debug, Clone)]
pub struct CupAndHandle {
swing: SwingTracker,
has_emitted: bool,
}
impl CupAndHandle {
/// Construct a new Cup-and-Handle detector.
pub const fn new() -> Self {
Self {
swing: SwingTracker::new(SWING_THRESHOLD, 4),
has_emitted: false,
}
}
}
impl Default for CupAndHandle {
fn default() -> Self {
Self::new()
}
}
impl Indicator for CupAndHandle {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
self.has_emitted = true;
if !self.swing.update(candle) {
return Some(0.0);
}
let pivots = self.swing.pivots();
if pivots.len() < 4 {
return Some(0.0);
}
let n = pivots.len();
let rim_left = pivots[n - 4];
let extreme = pivots[n - 3];
let rim_right = pivots[n - 2];
let handle = pivots[n - 1];
let rims_match = approx_equal(rim_left.price, rim_right.price, LEVEL_TOLERANCE);
if handle.direction < 0.0 {
// Bullish cup-and-handle: rims are highs, cup is the low between them,
// handle is a shallow low above the cup but below the right rim.
if rims_match && handle.price > extreme.price && handle.price < rim_right.price {
return Some(1.0);
}
} else if rims_match && handle.price < extreme.price && handle.price > rim_right.price {
// Inverse: rims are lows, cap is the high, handle a shallow high.
return Some(-1.0);
}
Some(0.0)
}
fn reset(&mut self) {
self.swing.reset();
self.has_emitted = false;
}
fn warmup_period(&self) -> usize {
// Four confirmed pivots; the earliest confirmation of the fourth is bar 5.
5
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"CupAndHandle"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::indicators::pattern_swing::candles_for_pivots;
use crate::traits::BatchExt;
fn run(pivots: &[f64]) -> Vec<f64> {
let mut indicator = CupAndHandle::new();
candles_for_pivots(pivots)
.into_iter()
.map(|c| indicator.update(c).unwrap())
.collect()
}
#[test]
fn accessors_and_metadata() {
let indicator = CupAndHandle::new();
assert_eq!(indicator.name(), "CupAndHandle");
assert_eq!(indicator.warmup_period(), 5);
assert!(!indicator.is_ready());
assert!(!CupAndHandle::default().is_ready());
}
#[test]
fn cup_and_handle_is_plus_one() {
// Rims 120/121, cup 90 (deep), handle 110 (shallow, above the cup).
let out = run(&[120.0, 90.0, 121.0, 110.0]);
assert_eq!(*out.last().unwrap(), 1.0);
}
#[test]
fn inverse_cup_and_handle_is_minus_one() {
// Lead high then rims 100/101, cap 130, handle 110 (below cap, above rim).
let out = run(&[140.0, 100.0, 130.0, 101.0, 110.0]);
assert_eq!(*out.last().unwrap(), -1.0);
}
#[test]
fn deep_handle_is_not_cup_and_handle() {
// Handle (85) below the cup low (90) → a double bottom, not cup-and-handle.
let out = run(&[120.0, 90.0, 121.0, 85.0]);
assert_eq!(*out.last().unwrap(), 0.0);
}
#[test]
fn inverse_with_mismatched_rims_does_not_trigger() {
// Inverse shape (ends high) but the rims (100 / 90) diverge → enters the
// inverse branch yet reports no pattern.
let out = run(&[140.0, 100.0, 130.0, 90.0, 110.0]);
assert_eq!(*out.last().unwrap(), 0.0);
}
#[test]
fn reset_clears_state() {
let mut indicator = CupAndHandle::new();
for c in candles_for_pivots(&[120.0, 90.0, 121.0]) {
let _ = indicator.update(c);
}
indicator.reset();
assert!(!indicator.is_ready());
let c = Candle::new(99.5, 100.0, 99.5, 99.5, 1.0, 0).unwrap();
assert_eq!(indicator.update(c), Some(0.0));
}
#[test]
fn batch_equals_streaming() {
let candles = candles_for_pivots(&[120.0, 90.0, 121.0, 110.0]);
let mut a = CupAndHandle::new();
let mut b = CupAndHandle::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
+152
View File
@@ -0,0 +1,152 @@
//! Cypher harmonic pattern.
use crate::indicators::pattern_swing::{ratios_in, xabcd, SwingTracker, SWING_THRESHOLD};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Cypher — a 5-point (X-A-B-C-D) harmonic pattern whose C leg is measured
/// against XA (not AB) and whose D retraces the XC leg by `0.786`:
///
/// ```text
/// AB / XA ∈ [0.382, 0.618]
/// BC / XA ∈ [1.13, 1.414] (C extends beyond A, measured on XA)
/// CD / XC ∈ [0.74, 0.83] (≈ 0.786 retracement of XC — the D completion)
/// ```
///
/// Output is `+1.0` (bullish, D a swing low), `-1.0` (bearish, D a swing high),
/// or `0.0`; never `None`. See `crates/wickra-core/src/indicators/cypher.rs`.
#[derive(Debug, Clone)]
pub struct Cypher {
swing: SwingTracker,
has_emitted: bool,
}
impl Cypher {
/// Construct a new Cypher detector.
pub const fn new() -> Self {
Self {
swing: SwingTracker::new(SWING_THRESHOLD, 5),
has_emitted: false,
}
}
}
impl Default for Cypher {
fn default() -> Self {
Self::new()
}
}
impl Indicator for Cypher {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
self.has_emitted = true;
if !self.swing.update(candle) {
return Some(0.0);
}
let pivots = self.swing.pivots();
if pivots.len() < 5 {
return Some(0.0);
}
let p = xabcd(pivots);
let xa = (p.a - p.x).abs();
let ab = (p.b - p.a).abs();
let bc = (p.c - p.b).abs();
let xc = (p.c - p.x).abs();
let cd = (p.d - p.c).abs();
let matched = ratios_in(&[
(ab / xa, 0.382, 0.618),
(bc / xa, 1.13, 1.414),
(cd / xc, 0.74, 0.83),
]);
if matched {
return Some(if p.bullish { 1.0 } else { -1.0 });
}
Some(0.0)
}
fn reset(&mut self) {
self.swing.reset();
self.has_emitted = false;
}
fn warmup_period(&self) -> usize {
6
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"Cypher"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::indicators::pattern_swing::candles_for_pivots;
use crate::traits::BatchExt;
fn run(pivots: &[f64]) -> Vec<f64> {
let mut indicator = Cypher::new();
candles_for_pivots(pivots)
.into_iter()
.map(|c| indicator.update(c).unwrap())
.collect()
}
#[test]
fn accessors_and_metadata() {
let indicator = Cypher::new();
assert_eq!(indicator.name(), "Cypher");
assert_eq!(indicator.warmup_period(), 6);
assert!(!indicator.is_ready());
assert!(!Cypher::default().is_ready());
}
#[test]
fn bullish_cypher_is_plus_one() {
let out = run(&[150.0, 100.0, 140.0, 120.0, 168.0, 114.55]);
assert_eq!(*out.last().unwrap(), 1.0);
assert!(out[..out.len() - 1].iter().all(|&x| x == 0.0));
}
#[test]
fn bearish_cypher_is_minus_one() {
let out = run(&[150.0, 110.0, 130.0, 82.0, 135.45]);
assert_eq!(*out.last().unwrap(), -1.0);
}
#[test]
fn out_of_ratio_does_not_trigger() {
let out = run(&[150.0, 100.0, 140.0, 110.0, 135.0, 105.0]);
assert_eq!(*out.last().unwrap(), 0.0);
}
#[test]
fn reset_clears_state() {
let mut indicator = Cypher::new();
for c in candles_for_pivots(&[150.0, 100.0, 140.0]) {
let _ = indicator.update(c);
}
indicator.reset();
assert!(!indicator.is_ready());
let c = Candle::new(99.5, 100.0, 99.5, 99.5, 1.0, 0).unwrap();
assert_eq!(indicator.update(c), Some(0.0));
}
#[test]
fn batch_equals_streaming() {
let candles = candles_for_pivots(&[150.0, 100.0, 140.0, 120.0, 168.0, 114.55]);
let mut a = Cypher::new();
let mut b = Cypher::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,202 @@
//! Day-of-Week Profile — the mean bar return for each weekday.
use crate::calendar::civil_from_timestamp;
use crate::ohlcv::Candle;
use crate::traits::Indicator;
const DAYS: usize = 7;
/// Day-of-Week Profile output: the per-weekday mean return.
///
/// `bins[i]` is the mean simple return of all bars whose local weekday was `i`,
/// with Monday as `0` through Sunday as `6`. Weekdays with no bars read `0.0`.
#[derive(Debug, Clone, PartialEq)]
pub struct DayOfWeekProfileOutput {
/// Per-weekday mean return, Monday first. Always length 7.
pub bins: Vec<f64>,
}
/// Mean bar return bucketed by local weekday (Monday `0` .. Sunday `6`).
///
/// Each bar's simple return `close / previous_close - 1` is accumulated into the
/// bucket of its local weekday (the wall-clock day of
/// [`Candle::timestamp`](crate::Candle) shifted by `utc_offset_minutes`), and the
/// profile reports the running mean per weekday. The first bar produces no output.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, DayOfWeekProfile};
///
/// let day = 24 * 3_600_000;
/// let mut prof = DayOfWeekProfile::new(0);
/// // 1970-01-01 was a Thursday (weekday 3).
/// assert!(prof.update(Candle::new(100.0, 100.0, 100.0, 100.0, 1.0, 0).unwrap()).is_none());
/// let out = prof.update(Candle::new(101.0, 101.0, 101.0, 101.0, 1.0, day).unwrap()).unwrap();
/// assert_eq!(out.bins.len(), 7);
/// ```
#[derive(Debug, Clone)]
pub struct DayOfWeekProfile {
utc_offset_minutes: i32,
prev_close: Option<f64>,
sum: [f64; DAYS],
count: [u64; DAYS],
last: Option<DayOfWeekProfileOutput>,
}
impl DayOfWeekProfile {
/// Construct a Day-of-Week Profile with the given UTC offset (minutes).
pub const fn new(utc_offset_minutes: i32) -> Self {
Self {
utc_offset_minutes,
prev_close: None,
sum: [0.0; DAYS],
count: [0; DAYS],
last: None,
}
}
/// Configured UTC offset in minutes.
pub const fn utc_offset_minutes(&self) -> i32 {
self.utc_offset_minutes
}
/// Most recent profile if at least one return has been recorded.
pub fn value(&self) -> Option<&DayOfWeekProfileOutput> {
self.last.as_ref()
}
fn snapshot(&self) -> DayOfWeekProfileOutput {
let bins = self
.sum
.iter()
.zip(&self.count)
.map(|(total, n)| if *n > 0 { total / *n as f64 } else { 0.0 })
.collect();
DayOfWeekProfileOutput { bins }
}
}
impl Indicator for DayOfWeekProfile {
type Input = Candle;
type Output = DayOfWeekProfileOutput;
fn update(&mut self, candle: Candle) -> Option<DayOfWeekProfileOutput> {
let civil = civil_from_timestamp(candle.timestamp, self.utc_offset_minutes);
let result = if let Some(prev) = self.prev_close {
let ret = if prev == 0.0 {
0.0
} else {
candle.close / prev - 1.0
};
let day = civil.weekday as usize;
self.sum[day] += ret;
self.count[day] += 1;
let out = self.snapshot();
self.last = Some(out.clone());
Some(out)
} else {
None
};
self.prev_close = Some(candle.close);
result
}
fn reset(&mut self) {
self.prev_close = None;
self.sum = [0.0; DAYS];
self.count = [0; DAYS];
self.last = None;
}
fn warmup_period(&self) -> usize {
2
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"DayOfWeekProfile"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
const DAY: i64 = 24 * 3_600_000;
fn c(close: f64, ts: i64) -> Candle {
Candle::new(close, close, close, close, 1.0, ts).unwrap()
}
#[test]
fn metadata_and_accessors() {
let prof = DayOfWeekProfile::new(60);
assert_eq!(prof.utc_offset_minutes(), 60);
assert_eq!(prof.name(), "DayOfWeekProfile");
assert_eq!(prof.warmup_period(), 2);
assert!(!prof.is_ready());
assert!(prof.value().is_none());
}
#[test]
fn buckets_by_weekday() {
let mut prof = DayOfWeekProfile::new(0);
// 1970-01-01 Thursday (3); 01-02 Friday (4).
assert!(prof.update(c(100.0, 0)).is_none());
let out = prof.update(c(101.0, DAY)).unwrap(); // Friday return +0.01
assert_eq!(out.bins.len(), 7);
assert_relative_eq!(out.bins[4], 0.01); // Friday
assert_relative_eq!(out.bins[3], 0.0); // Thursday had no return
assert!(prof.is_ready());
}
#[test]
fn averages_same_weekday_across_weeks() {
let mut prof = DayOfWeekProfile::new(0);
prof.update(c(100.0, 0)); // Thu
prof.update(c(101.0, DAY)); // Fri +0.01
// Jump to next Friday (7 days later from day 0 -> +7 days, weekday 4).
prof.update(c(100.0, 7 * DAY)); // Thu+? actually day 7 -> weekday (7+3)%7=3 Thu
let out = prof.update(c(103.0, 8 * DAY)).unwrap(); // day 8 -> Fri, return
// Friday now has two samples; both positive.
assert!(out.bins[4] > 0.0);
}
#[test]
fn zero_prev_close_uses_zero_return() {
let mut prof = DayOfWeekProfile::new(0);
prof.update(c(0.0, 0));
let out = prof.update(c(5.0, DAY)).unwrap();
assert_relative_eq!(out.bins[4], 0.0); // Friday, guarded return 0
}
#[test]
fn reset_clears_state() {
let mut prof = DayOfWeekProfile::new(0);
prof.update(c(100.0, 0));
prof.update(c(101.0, DAY));
prof.reset();
assert!(!prof.is_ready());
assert!(prof.value().is_none());
assert!(prof.update(c(100.0, 2 * DAY)).is_none());
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..30)
.map(|i| c(100.0 + f64::from(i % 5), i64::from(i) * DAY))
.collect();
let mut a = DayOfWeekProfile::new(0);
let mut b = DayOfWeekProfile::new(0);
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,218 @@
//! Derivative Oscillator (Constance Brown).
use crate::error::{Error, Result};
use crate::indicators::ema::Ema;
use crate::indicators::rsi::Rsi;
use crate::indicators::sma::Sma;
use crate::traits::Indicator;
/// Derivative Oscillator — Constance Brown's double-smoothed RSI histogram.
///
/// The RSI is smoothed twice with EMAs, then a simple moving average of that
/// double-smoothed line is subtracted as a signal, leaving a zero-centered
/// histogram:
///
/// ```text
/// rsi = RSI(price, rsi_period)
/// s1 = EMA(rsi, smooth1)
/// s2 = EMA(s1, smooth2) // double-smoothed RSI
/// signal = SMA(s2, signal_period)
/// DerivativeOscillator = s2 - signal
/// ```
///
/// The double EMA smoothing strips the RSI's high-frequency noise, and
/// subtracting the SMA signal removes the residual level, so the result
/// oscillates around zero: positive (and rising) bars mark accelerating bullish
/// momentum, negative bars bearish. Brown's defaults are `rsi_period = 14`,
/// `smooth1 = 5`, `smooth2 = 3`, `signal_period = 9`.
///
/// The first value lands after `rsi_period + smooth1 + smooth2 + signal_period 2`
/// inputs, the point at which the whole RSI → EMA → EMA → SMA chain is seeded.
///
/// # Example
///
/// ```
/// use wickra_core::{DerivativeOscillator, Indicator};
///
/// let mut indicator = DerivativeOscillator::new(14, 5, 3, 9).unwrap();
/// let mut last = None;
/// for i in 0..120 {
/// last = indicator.update(100.0 + (f64::from(i) * 0.2).sin() * 5.0);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct DerivativeOscillator {
rsi: Rsi,
ema1: Ema,
ema2: Ema,
signal: Sma,
warmup: usize,
}
impl DerivativeOscillator {
/// Construct a Derivative Oscillator with the RSI, two EMA smoothing, and
/// SMA signal periods.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if any period is `0`.
pub fn new(
rsi_period: usize,
smooth1: usize,
smooth2: usize,
signal_period: usize,
) -> Result<Self> {
if rsi_period == 0 || smooth1 == 0 || smooth2 == 0 || signal_period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
rsi: Rsi::new(rsi_period)?,
ema1: Ema::new(smooth1)?,
ema2: Ema::new(smooth2)?,
signal: Sma::new(signal_period)?,
// RSI seeds at rsi_period + 1, then each stage adds (len - 1).
warmup: rsi_period + smooth1 + smooth2 + signal_period - 2,
})
}
/// Total warmup length (also returned by `warmup_period`).
pub const fn warmup(&self) -> usize {
self.warmup
}
}
impl Indicator for DerivativeOscillator {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
let rsi = self.rsi.update(input)?;
let s1 = self.ema1.update(rsi)?;
let s2 = self.ema2.update(s1)?;
let signal = self.signal.update(s2)?;
Some(s2 - signal)
}
fn reset(&mut self) {
self.rsi.reset();
self.ema1.reset();
self.ema2.reset();
self.signal.reset();
}
fn warmup_period(&self) -> usize {
self.warmup
}
fn is_ready(&self) -> bool {
self.signal.is_ready()
}
fn name(&self) -> &'static str {
"DerivativeOscillator"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_zero_periods() {
assert!(matches!(
DerivativeOscillator::new(0, 5, 3, 9),
Err(Error::PeriodZero)
));
assert!(matches!(
DerivativeOscillator::new(14, 0, 3, 9),
Err(Error::PeriodZero)
));
assert!(matches!(
DerivativeOscillator::new(14, 5, 0, 9),
Err(Error::PeriodZero)
));
assert!(matches!(
DerivativeOscillator::new(14, 5, 3, 0),
Err(Error::PeriodZero)
));
}
/// Cover the const accessor `warmup` and the Indicator-impl `warmup_period`
/// + `name`.
#[test]
fn accessors_and_metadata() {
let d = DerivativeOscillator::new(14, 5, 3, 9).unwrap();
// 14 + 5 + 3 + 9 - 2 = 29.
assert_eq!(d.warmup(), 29);
assert_eq!(d.warmup_period(), 29);
assert_eq!(d.name(), "DerivativeOscillator");
}
#[test]
fn first_emission_matches_warmup_period() {
let prices: Vec<f64> = (0..60)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 6.0)
.collect();
let mut d = DerivativeOscillator::new(14, 5, 3, 9).unwrap();
let out = d.batch(&prices);
let warmup = d.warmup_period();
for (i, v) in out.iter().enumerate().take(warmup - 1) {
assert!(v.is_none(), "index {i} must be None during warmup");
}
assert!(
out[warmup - 1].is_some(),
"first value must land at warmup_period - 1"
);
}
#[test]
fn matches_manual_chain() {
// Equals RSI -> EMA -> EMA, minus the SMA signal of that line.
let prices: Vec<f64> = (0..80)
.map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 8.0)
.collect();
let mut d = DerivativeOscillator::new(14, 5, 3, 9).unwrap();
let mut rsi = Rsi::new(14).unwrap();
let mut e1 = Ema::new(5).unwrap();
let mut e2 = Ema::new(3).unwrap();
let mut sig = Sma::new(9).unwrap();
for (i, &p) in prices.iter().enumerate() {
let got = d.update(p);
let want = rsi
.update(p)
.and_then(|r| e1.update(r))
.and_then(|x| e2.update(x))
.and_then(|s2| sig.update(s2).map(|s| s2 - s));
assert_eq!(got.is_some(), want.is_some(), "readiness mismatch at {i}");
if let (Some(a), Some(b)) = (got, want) {
assert_relative_eq!(a, b, epsilon = 1e-9);
}
}
}
#[test]
fn reset_clears_state() {
let mut d = DerivativeOscillator::new(14, 5, 3, 9).unwrap();
d.batch(&(0..60).map(|i| 100.0 + f64::from(i)).collect::<Vec<_>>());
assert!(d.is_ready());
d.reset();
assert!(!d.is_ready());
assert_eq!(d.update(1.0), None);
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (0..80)
.map(|i| 50.0 + (f64::from(i) * 0.5).sin() * 10.0)
.collect();
let mut a = DerivativeOscillator::new(14, 5, 3, 9).unwrap();
let mut b = DerivativeOscillator::new(14, 5, 3, 9).unwrap();
assert_eq!(
a.batch(&prices),
prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,169 @@
//! Disparity Index.
use crate::error::Result;
use crate::indicators::sma::Sma;
use crate::traits::Indicator;
/// Disparity Index — the percentage gap between price and its moving average.
///
/// ```text
/// Disparity = 100 * (price - SMA(price, period)) / SMA(price, period)
/// ```
///
/// Originating in Japanese technical analysis (*kairi*), the disparity index
/// expresses how far price has stretched from its `period`-bar simple moving
/// average, as a percentage of that average. Positive readings mean price is
/// above the mean (potentially overbought / strong), negative readings mean it
/// is below (potentially oversold / weak); the magnitude measures how
/// over-extended the move is.
///
/// The first output lands once the inner SMA is ready (input `period`). If the
/// moving average is exactly zero the gap percentage is undefined and the index
/// returns `0.0`.
///
/// # Example
///
/// ```
/// use wickra_core::{DisparityIndex, Indicator};
///
/// let mut indicator = DisparityIndex::new(14).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// last = indicator.update(100.0 + f64::from(i));
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct DisparityIndex {
period: usize,
sma: Sma,
}
impl DisparityIndex {
/// Construct a disparity index over `period` inputs.
///
/// # Errors
///
/// Returns [`crate::Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
Ok(Self {
period,
sma: Sma::new(period)?,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for DisparityIndex {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
let mean = self.sma.update(input)?;
if mean == 0.0 {
return Some(0.0);
}
Some(100.0 * (input - mean) / mean)
}
fn reset(&mut self) {
self.sma.reset();
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.sma.is_ready()
}
fn name(&self) -> &'static str {
"DisparityIndex"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_zero_period() {
assert!(DisparityIndex::new(0).is_err());
}
/// Cover the const accessor `period` and the Indicator-impl `warmup_period`
/// + `name`.
#[test]
fn accessors_and_metadata() {
let di = DisparityIndex::new(14).unwrap();
assert_eq!(di.period(), 14);
assert_eq!(di.warmup_period(), 14);
assert_eq!(di.name(), "DisparityIndex");
}
#[test]
fn warmup_then_known_value() {
// SMA(3) of [2, 4, 6] = 4; price 6 -> 100 * (6 - 4) / 4 = 50.
let mut di = DisparityIndex::new(3).unwrap();
assert_eq!(di.update(2.0), None);
assert_eq!(di.update(4.0), None);
assert_relative_eq!(di.update(6.0).unwrap(), 50.0, epsilon = 1e-12);
}
#[test]
fn constant_series_is_zero() {
// Price equals its own mean -> zero disparity.
let mut di = DisparityIndex::new(5).unwrap();
for v in di.batch(&[42.0; 20]).into_iter().flatten() {
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
}
}
#[test]
fn negative_when_below_mean() {
// SMA(3) of [10, 8, 6] = 8; price 6 -> 100 * (6 - 8) / 8 = -25.
let mut di = DisparityIndex::new(3).unwrap();
let v = di.batch(&[10.0, 8.0, 6.0]);
assert_relative_eq!(v[2].unwrap(), -25.0, epsilon = 1e-12);
}
#[test]
fn zero_mean_returns_zero() {
// A window summing to zero (mean 0) makes the percentage undefined; the
// index returns 0.0 rather than a non-finite value.
let mut di = DisparityIndex::new(2).unwrap();
assert_eq!(di.update(-3.0), None);
// SMA(2) of [-3, 3] = 0 -> guarded to 0.0.
assert_relative_eq!(di.update(3.0).unwrap(), 0.0, epsilon = 1e-12);
}
#[test]
fn reset_clears_state() {
let mut di = DisparityIndex::new(5).unwrap();
di.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
assert!(di.is_ready());
di.reset();
assert!(!di.is_ready());
assert_eq!(di.update(1.0), None);
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (1..=30)
.map(|i| 50.0 + (f64::from(i) * 0.3).sin() * 10.0)
.collect();
let mut a = DisparityIndex::new(7).unwrap();
let mut b = DisparityIndex::new(7).unwrap();
assert_eq!(
a.batch(&prices),
prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
);
}
}
@@ -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,188 @@
//! Double Top / Double Bottom reversal chart pattern.
use crate::indicators::pattern_swing::{
approx_equal, SwingTracker, LEVEL_TOLERANCE, SWING_THRESHOLD,
};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Double Top / Double Bottom — a two-peak (or two-trough) reversal pattern.
///
/// The detector tracks confirmed swing pivots (a non-repainting percent-threshold
/// zig-zag, [`SWING_THRESHOLD`] = 5%). A pattern is recognised on the bar that
/// confirms the **second** matching extreme:
///
/// ```text
/// double top : … High₁ , Low , High₂ with High₁ ≈ High₂ → -1 (bearish)
/// double bottom : … Low₁ , High , Low₂ with Low₁ ≈ Low₂ → +1 (bullish)
/// ```
///
/// Two extremes count as the same level when they are within
/// [`LEVEL_TOLERANCE`] (3%) of each other. Because pivots strictly alternate
/// high/low, the trough between the twin tops (or the peak between the twin
/// bottoms) is guaranteed to sit beyond both, so no extra separation check is
/// needed.
///
/// Output is `+1.0` for a double bottom, `-1.0` for a double top, and `0.0` on
/// every other bar (including warmup and bars that confirm a pivot which does
/// not complete the pattern). Like the candlestick family this detector never
/// returns `None`.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, DoubleTopBottom, Indicator};
///
/// let mut indicator = DoubleTopBottom::new();
/// for (i, &(high, low)) in [
/// (100.0, 99.5),
/// (120.0, 119.5),
/// (110.0, 100.0), // confirms the first top at 120
/// (120.0, 119.0), // confirms the trough at 100
/// (115.0, 110.0), // confirms the second top at 120 → double top
/// ]
/// .iter()
/// .enumerate()
/// {
/// let c = Candle::new(low, high, low, low, 1.0, i as i64).unwrap();
/// let signal = indicator.update(c).unwrap();
/// if i == 4 {
/// assert_eq!(signal, -1.0);
/// }
/// }
/// ```
#[derive(Debug, Clone)]
pub struct DoubleTopBottom {
swing: SwingTracker,
has_emitted: bool,
}
impl DoubleTopBottom {
/// Construct a new Double Top / Double Bottom detector.
pub const fn new() -> Self {
Self {
swing: SwingTracker::new(SWING_THRESHOLD, 3),
has_emitted: false,
}
}
}
impl Default for DoubleTopBottom {
fn default() -> Self {
Self::new()
}
}
impl Indicator for DoubleTopBottom {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
self.has_emitted = true;
if !self.swing.update(candle) {
return Some(0.0);
}
let pivots = self.swing.pivots();
if pivots.len() < 3 {
return Some(0.0);
}
let first = pivots[pivots.len() - 3];
let last = pivots[pivots.len() - 1];
if approx_equal(first.price, last.price, LEVEL_TOLERANCE) {
// `last` is the just-confirmed extreme: a high → double top (bearish),
// a low → double bottom (bullish).
return Some(if last.direction > 0.0 { -1.0 } else { 1.0 });
}
Some(0.0)
}
fn reset(&mut self) {
self.swing.reset();
self.has_emitted = false;
}
fn warmup_period(&self) -> usize {
// The first complete pattern needs three confirmed pivots; the earliest
// bar that can confirm a third pivot is the fifth.
5
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"DoubleTopBottom"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::indicators::pattern_swing::candles_for_pivots;
use crate::traits::BatchExt;
fn run(pivots: &[f64]) -> Vec<f64> {
let mut indicator = DoubleTopBottom::new();
candles_for_pivots(pivots)
.into_iter()
.map(|c| indicator.update(c).unwrap())
.collect()
}
#[test]
fn accessors_and_metadata() {
let indicator = DoubleTopBottom::new();
assert_eq!(indicator.name(), "DoubleTopBottom");
assert_eq!(indicator.warmup_period(), 5);
assert!(!indicator.is_ready());
assert!(!DoubleTopBottom::default().is_ready());
}
#[test]
fn double_top_is_minus_one() {
// Twin highs 120 / 120 with a 100 trough → double top on the second.
let out = run(&[120.0, 100.0, 120.0]);
assert_eq!(*out.last().unwrap(), -1.0);
// All earlier bars are warmup / non-completing.
assert!(out[..out.len() - 1].iter().all(|&x| x == 0.0));
}
#[test]
fn double_bottom_is_plus_one() {
// Lead high, then twin lows 100 / 99 around a 120 peak → double bottom.
let out = run(&[130.0, 100.0, 120.0, 99.0]);
assert_eq!(*out.last().unwrap(), 1.0);
}
#[test]
fn unequal_tops_do_not_trigger() {
// Second top 140 diverges from the first (120) → no pattern.
let out = run(&[120.0, 100.0, 140.0]);
assert_eq!(*out.last().unwrap(), 0.0);
assert!(out.iter().all(|&x| x == 0.0));
}
#[test]
fn reset_clears_state() {
let mut indicator = DoubleTopBottom::new();
for c in candles_for_pivots(&[120.0, 100.0, 120.0]) {
let _ = indicator.update(c);
}
indicator.reset();
assert!(!indicator.is_ready());
let c = Candle::new(99.5, 100.0, 99.5, 99.5, 1.0, 0).unwrap();
assert_eq!(indicator.update(c), Some(0.0));
}
#[test]
fn batch_equals_streaming() {
let candles = candles_for_pivots(&[120.0, 100.0, 120.0]);
let mut a = DoubleTopBottom::new();
let mut b = DoubleTopBottom::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
+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,301 @@
//! Dynamic Momentum Index (Chande's volatility-adaptive RSI).
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::indicators::sma::Sma;
use crate::indicators::std_dev::StdDev;
use crate::traits::Indicator;
// Chande's definitional constants.
const STD_PERIOD: usize = 5; // volatility window
const STD_AVG_PERIOD: usize = 10; // smoothing of the volatility
const MIN_PERIOD: usize = 5; // fastest RSI lookback
const MAX_PERIOD: usize = 30; // slowest RSI lookback
/// Dynamic Momentum Index — Tushar Chande's RSI whose lookback shrinks in
/// volatile markets and lengthens in calm ones.
///
/// A standard RSI uses a fixed period; the DMI varies it from the recent
/// volatility so the oscillator stays responsive when the market is fast and
/// smooth when it is quiet:
///
/// ```text
/// vol = StdDev(close, 5)
/// vol_avg = SMA(vol, 10)
/// Vi = vol / vol_avg (volatility index)
/// td = clamp(round(period / Vi), 5, 30) (dynamic lookback)
/// avg_gain, avg_loss = simple means of the last `td` price changes
/// DMI = 100 * avg_gain / (avg_gain + avg_loss)
/// ```
///
/// High volatility (`Vi > 1`) shortens `td` toward `5` (faster); low volatility
/// lengthens it toward `30` (slower). The averages of gains and losses are
/// simple means over the last `td` changes (not Wilder-smoothed), recomputed as
/// the window length flexes. Output is bounded in `[0, 100]`; a flat market
/// returns the neutral `50`.
///
/// The first value lands after `MAX_PERIOD + 1 = 31` inputs, so the change
/// buffer always holds enough history for any dynamic lookback up to `30`.
///
/// # Example
///
/// ```
/// use wickra_core::{DynamicMomentumIndex, Indicator};
///
/// let mut dmi = DynamicMomentumIndex::new(14).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// last = dmi.update(100.0 + (f64::from(i) * 0.2).sin() * 5.0);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct DynamicMomentumIndex {
period: usize,
vol: StdDev,
vol_avg: Sma,
prev_close: Option<f64>,
/// The last `MAX_PERIOD` price changes, oldest at the front.
changes: VecDeque<f64>,
last_vol_avg: Option<f64>,
last_value: Option<f64>,
}
impl DynamicMomentumIndex {
/// Construct a DMI with the given base RSI period (Chande uses 14).
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
vol: StdDev::new(STD_PERIOD)?,
vol_avg: Sma::new(STD_AVG_PERIOD)?,
prev_close: None,
changes: VecDeque::with_capacity(MAX_PERIOD),
last_vol_avg: None,
last_value: None,
})
}
/// Configured base period.
pub const fn period(&self) -> usize {
self.period
}
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.last_value
}
/// Dynamic lookback for the current volatility, clamped to `[5, 30]`.
fn dynamic_period(&self, vol: f64, vol_avg: f64) -> usize {
if vol_avg <= 0.0 || vol <= 0.0 {
// No measurable volatility -> slowest (calmest) lookback.
return MAX_PERIOD;
}
let vi = vol / vol_avg;
let td = (self.period as f64 / vi).round();
// td is finite and positive here; clamp into the valid band.
(td as usize).clamp(MIN_PERIOD, MAX_PERIOD)
}
}
impl Indicator for DynamicMomentumIndex {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() {
return self.last_value;
}
// Track the smoothed volatility on every close.
if let Some(v) = self.vol.update(input) {
self.last_vol_avg = self.vol_avg.update(v);
}
// Record the price change.
if let Some(prev) = self.prev_close {
let change = input - prev;
if self.changes.len() == MAX_PERIOD {
self.changes.pop_front();
}
self.changes.push_back(change);
}
self.prev_close = Some(input);
let vol = self.vol.value()?;
let vol_avg = self.last_vol_avg?;
if self.changes.len() < MAX_PERIOD {
return None;
}
let td = self.dynamic_period(vol, vol_avg);
// Average gains and losses over the last `td` changes.
let mut sum_gain = 0.0;
let mut sum_loss = 0.0;
for &c in self.changes.iter().skip(MAX_PERIOD - td) {
if c > 0.0 {
sum_gain += c;
} else if c < 0.0 {
sum_loss -= c;
}
}
let denom = sum_gain + sum_loss;
let v = if denom == 0.0 {
50.0
} else {
// Ratio first, then scale, so `100 * g / g` cannot round above 100.
100.0 * (sum_gain / denom)
};
self.last_value = Some(v);
Some(v)
}
fn reset(&mut self) {
self.vol.reset();
self.vol_avg.reset();
self.prev_close = None;
self.changes.clear();
self.last_vol_avg = None;
self.last_value = None;
}
fn warmup_period(&self) -> usize {
// The change buffer (MAX_PERIOD changes => MAX_PERIOD + 1 inputs) is the
// binding constraint; the volatility chain (5 + 10 - 1 = 14) is shorter.
MAX_PERIOD + 1
}
fn is_ready(&self) -> bool {
self.last_value.is_some()
}
fn name(&self) -> &'static str {
"DynamicMomentumIndex"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_zero_period() {
assert!(matches!(
DynamicMomentumIndex::new(0),
Err(Error::PeriodZero)
));
}
/// Cover the const accessors `period` + `value` and the Indicator-impl
/// `warmup_period` + `name`.
#[test]
fn accessors_and_metadata() {
let dmi = DynamicMomentumIndex::new(14).unwrap();
assert_eq!(dmi.period(), 14);
assert_eq!(dmi.value(), None);
assert_eq!(dmi.warmup_period(), 31);
assert_eq!(dmi.name(), "DynamicMomentumIndex");
}
#[test]
fn first_emission_matches_warmup_period() {
let prices: Vec<f64> = (0..50)
.map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 6.0)
.collect();
let mut dmi = DynamicMomentumIndex::new(14).unwrap();
let out = dmi.batch(&prices);
for (i, v) in out.iter().enumerate().take(30) {
assert!(v.is_none(), "index {i} must be None during warmup");
}
assert!(out[30].is_some(), "first value at warmup_period - 1 = 30");
}
#[test]
fn pure_uptrend_is_one_hundred() {
// Every change positive -> avg_loss 0 -> 100, regardless of dynamic period.
let prices: Vec<f64> = (1..=60).map(f64::from).collect();
let mut dmi = DynamicMomentumIndex::new(14).unwrap();
let last = dmi.batch(&prices).into_iter().flatten().last().unwrap();
assert_relative_eq!(last, 100.0, epsilon = 1e-9);
}
#[test]
fn flat_market_is_neutral() {
// Constant prices: no volatility (dynamic period -> max) and no changes
// -> neutral 50.
let mut dmi = DynamicMomentumIndex::new(14).unwrap();
let last = dmi.batch(&[42.0; 50]).into_iter().flatten().last().unwrap();
assert_relative_eq!(last, 50.0, epsilon = 1e-12);
}
#[test]
fn output_stays_in_range() {
let prices: Vec<f64> = (0..120)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 10.0 + (f64::from(i) * 0.07).cos() * 4.0)
.collect();
let mut dmi = DynamicMomentumIndex::new(14).unwrap();
for v in dmi.batch(&prices).into_iter().flatten() {
assert!((0.0..=100.0).contains(&v), "DMI {v} left [0, 100]");
}
}
#[test]
fn high_volatility_shortens_period() {
let dmi = DynamicMomentumIndex::new(14).unwrap();
// Vi = 2 (vol twice its average) -> td = round(14 / 2) = 7.
assert_eq!(dmi.dynamic_period(2.0, 1.0), 7);
// Vi = 0.5 (calm) -> td = round(14 / 0.5) = 28.
assert_eq!(dmi.dynamic_period(0.5, 1.0), 28);
// Extreme calm clamps to MAX_PERIOD; extreme volatility clamps to MIN.
assert_eq!(dmi.dynamic_period(0.1, 1.0), MAX_PERIOD);
assert_eq!(dmi.dynamic_period(100.0, 1.0), MIN_PERIOD);
// Zero volatility -> slowest lookback.
assert_eq!(dmi.dynamic_period(0.0, 1.0), MAX_PERIOD);
assert_eq!(dmi.dynamic_period(1.0, 0.0), MAX_PERIOD);
}
#[test]
fn ignores_non_finite_input() {
let mut dmi = DynamicMomentumIndex::new(14).unwrap();
let ready = dmi
.batch(&(0..40).map(|i| 100.0 + f64::from(i)).collect::<Vec<_>>())
.into_iter()
.flatten()
.last()
.unwrap();
assert_eq!(dmi.update(f64::NAN), Some(ready));
assert_eq!(dmi.update(f64::INFINITY), Some(ready));
}
#[test]
fn reset_clears_state() {
let mut dmi = DynamicMomentumIndex::new(14).unwrap();
dmi.batch(&(0..40).map(|i| 100.0 + f64::from(i)).collect::<Vec<_>>());
assert!(dmi.is_ready());
dmi.reset();
assert!(!dmi.is_ready());
assert_eq!(dmi.update(1.0), None);
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (0..80)
.map(|i| 50.0 + (f64::from(i) * 0.5).sin() * 10.0)
.collect();
let mut a = DynamicMomentumIndex::new(14).unwrap();
let mut b = DynamicMomentumIndex::new(14).unwrap();
assert_eq!(
a.batch(&prices),
prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
);
}
}
+202
View File
@@ -0,0 +1,202 @@
//! Exponential Hull Moving Average (EHMA).
use crate::error::{Error, Result};
use crate::indicators::ema::Ema;
use crate::traits::Indicator;
/// Exponential Hull Moving Average: the Hull construction built from EMAs
/// instead of WMAs.
///
/// ```text
/// EHMA = EMA( 2 · EMA(price, period/2) EMA(price, period), round(sqrt(period)) )
/// ```
///
/// Alan Hull's [`Hma`](crate::Hma) uses weighted moving averages; replacing them
/// with exponential moving averages keeps the same lag-reduction trick — a fast
/// half-length average minus a full-length one, smoothed over `sqrt(period)` —
/// while inheriting the EMA's strictly recursive O(1) update and infinite
/// (exponentially decaying) memory. The result is marginally smoother than the
/// WMA-based Hull at the cost of a little more lag.
///
/// The half period is `(period / 2).max(1)` and the smoothing period is
/// `round(sqrt(period)).max(1)`, matching the rounding used by [`Hma`].
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, Ehma};
///
/// let mut indicator = Ehma::new(9).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// last = indicator.update(100.0 + f64::from(i));
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct Ehma {
period: usize,
half_ema: Ema,
full_ema: Ema,
smooth_ema: Ema,
}
impl Ehma {
/// # Errors
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
let half = (period / 2).max(1);
let smooth = (period as f64).sqrt().round() as usize;
let smooth = smooth.max(1);
Ok(Self {
period,
half_ema: Ema::new(half)?,
full_ema: Ema::new(period)?,
smooth_ema: Ema::new(smooth)?,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for Ehma {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
// Feed both component EMAs on every input so they warm up in parallel;
// gating the longer one behind the shorter would delay the first
// emission past `warmup_period()`.
let h = self.half_ema.update(input);
let f = self.full_ema.update(input);
let (h, f) = (h?, f?);
let diff = 2.0 * h - f;
self.smooth_ema.update(diff)
}
fn reset(&mut self) {
self.half_ema.reset();
self.full_ema.reset();
self.smooth_ema.reset();
}
fn warmup_period(&self) -> usize {
// full_ema seeds at `period`, then smooth_ema needs another
// (round(sqrt(period)) - 1) values to seed.
let sm = (self.period as f64).sqrt().round() as usize;
self.period + sm.max(1) - 1
}
fn is_ready(&self) -> bool {
self.smooth_ema.is_ready()
}
fn name(&self) -> &'static str {
"EHMA"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn constant_series_yields_constant_ehma() {
let mut ehma = Ehma::new(9).unwrap();
let out = ehma.batch(&[10.0_f64; 80]);
let last = out.iter().rev().flatten().next().unwrap();
assert_relative_eq!(*last, 10.0, epsilon = 1e-9);
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (1..=100).map(|i| f64::from(i) * 0.7).collect();
let mut a = Ehma::new(9).unwrap();
let mut b = Ehma::new(9).unwrap();
assert_eq!(
a.batch(&prices),
prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut ehma = Ehma::new(9).unwrap();
ehma.batch(&(1..=80).map(f64::from).collect::<Vec<_>>());
assert!(ehma.is_ready());
ehma.reset();
assert!(!ehma.is_ready());
}
#[test]
fn rejects_zero_period() {
assert!(Ehma::new(0).is_err());
}
/// Cover the const accessor `period` and the Indicator-impl `name`.
/// `warmup_period` is covered by `first_emission_matches_warmup_period`.
#[test]
fn accessors_and_metadata() {
let ehma = Ehma::new(9).unwrap();
assert_eq!(ehma.period(), 9);
assert_eq!(ehma.name(), "EHMA");
}
#[test]
fn first_emission_matches_warmup_period() {
let prices: Vec<f64> = (1..=40).map(f64::from).collect();
let mut ehma = Ehma::new(9).unwrap();
let out = ehma.batch(&prices);
let warmup = ehma.warmup_period();
// full EMA seeds at 9, smooth EMA round(sqrt(9))=3 needs 2 more -> 11.
assert_eq!(warmup, 11);
for (i, v) in out.iter().enumerate().take(warmup - 1) {
assert!(v.is_none(), "index {i} must be None during warmup");
}
assert!(
out[warmup - 1].is_some(),
"first EHMA value must land at warmup_period - 1"
);
}
#[test]
fn matches_independent_emas() {
// The two component EMAs run as independent siblings on the price
// stream; EHMA must equal feeding three standalone EMAs and combining.
let prices: Vec<f64> = (1..=50)
.map(|i| (f64::from(i) * 0.3).sin() * 10.0 + 50.0)
.collect();
let mut ehma = Ehma::new(9).unwrap();
let mut half = Ema::new(4).unwrap(); // (9 / 2).max(1)
let mut full = Ema::new(9).unwrap();
let mut smooth = Ema::new(3).unwrap(); // round(sqrt(9))
for (i, &p) in prices.iter().enumerate() {
let got = ehma.update(p);
let want = match (half.update(p), full.update(p)) {
(Some(h), Some(f)) => smooth.update(2.0 * h - f),
_ => None,
};
assert_eq!(got.is_some(), want.is_some(), "readiness mismatch at {i}");
if let (Some(a), Some(b)) = (got, want) {
assert_relative_eq!(a, b, epsilon = 1e-9);
}
}
}
#[test]
fn period_one_collapses_to_pass_through() {
// period 1: half=1, full=1, smooth=round(sqrt(1))=1; every EMA seeds on
// the first input, so EHMA(1) passes the price straight through.
let mut ehma = Ehma::new(1).unwrap();
assert_relative_eq!(ehma.update(5.0).unwrap(), 5.0, epsilon = 1e-12);
assert_relative_eq!(ehma.update(8.0).unwrap(), 8.0, epsilon = 1e-12);
}
}
@@ -0,0 +1,192 @@
//! Elder Ray — Bull Power and Bear Power.
use crate::error::Result;
use crate::indicators::ema::Ema;
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// One Elder Ray reading: the bull and bear power for a bar.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ElderRayOutput {
/// `high EMA(close)`: how far buyers pushed price above the trend mean.
pub bull_power: f64,
/// `low EMA(close)`: how far sellers pushed price below the trend mean
/// (negative in a normal market).
pub bear_power: f64,
}
/// Elder Ray — Alexander Elder's Bull Power / Bear Power oscillator.
///
/// An EMA of the close marks the market's consensus of value; the bar's high and
/// low relative to it measure how far the bulls and bears could push price away
/// from that consensus:
///
/// ```text
/// ema = EMA(close, period)
/// BullPower = high - ema
/// BearPower = low - ema
/// ```
///
/// Bull Power is normally positive (the high prints above the mean) and Bear
/// Power normally negative (the low prints below it). Their behaviour relative
/// to zero and to the EMA's slope drives Elder's signals: e.g. in an uptrend
/// (rising EMA), a bounce in a negative-but-rising Bear Power is a buy setup.
///
/// The first reading lands once the inner EMA is seeded, at bar `period`.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, ElderRay, Indicator};
///
/// let mut er = ElderRay::new(13).unwrap();
/// let mut last = None;
/// for i in 0..40 {
/// let base = 100.0 + f64::from(i);
/// let c = Candle::new(base, base + 2.0, base - 2.0, base + 0.5, 1.0, i64::from(i)).unwrap();
/// last = er.update(c);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct ElderRay {
period: usize,
ema: Ema,
}
impl ElderRay {
/// Construct an Elder Ray with the given EMA period.
///
/// # Errors
///
/// Returns [`crate::Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
Ok(Self {
period,
ema: Ema::new(period)?,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for ElderRay {
type Input = Candle;
type Output = ElderRayOutput;
fn update(&mut self, candle: Candle) -> Option<ElderRayOutput> {
let ema = self.ema.update(candle.close)?;
Some(ElderRayOutput {
bull_power: candle.high - ema,
bear_power: candle.low - ema,
})
}
fn reset(&mut self) {
self.ema.reset();
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.ema.is_ready()
}
fn name(&self) -> &'static str {
"ElderRay"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn candle(high: f64, low: f64, close: f64) -> Candle {
Candle::new(close, high, low, close, 1.0, 0).unwrap()
}
#[test]
fn rejects_zero_period() {
assert!(ElderRay::new(0).is_err());
}
/// Cover the const accessor `period` and the Indicator-impl `warmup_period`
/// + `name`.
#[test]
fn accessors_and_metadata() {
let er = ElderRay::new(13).unwrap();
assert_eq!(er.period(), 13);
assert_eq!(er.warmup_period(), 13);
assert_eq!(er.name(), "ElderRay");
}
#[test]
fn warmup_then_known_value() {
// EMA(3) seeds at bar 3 with SMA([10,12,14]) = 12 (closes).
// bar 3: high 16, low 13 -> bull = 16 - 12 = 4, bear = 13 - 12 = 1.
let mut er = ElderRay::new(3).unwrap();
assert_eq!(er.update(candle(11.0, 9.0, 10.0)), None);
assert_eq!(er.update(candle(13.0, 11.0, 12.0)), None);
let v = er.update(candle(16.0, 13.0, 14.0)).unwrap();
assert_relative_eq!(v.bull_power, 4.0, epsilon = 1e-12);
assert_relative_eq!(v.bear_power, 1.0, epsilon = 1e-12);
}
#[test]
fn matches_manual_ema() {
let bars: Vec<Candle> = (0..40)
.map(|i| {
let base = 100.0 + (f64::from(i) * 0.3).sin() * 5.0;
candle(base + 2.0, base - 2.0, base)
})
.collect();
let mut er = ElderRay::new(13).unwrap();
let mut ema = Ema::new(13).unwrap();
for (i, c) in bars.iter().enumerate() {
let got = er.update(*c);
let want = ema.update(c.close).map(|e| (c.high - e, c.low - e));
assert_eq!(got.is_some(), want.is_some(), "readiness mismatch at {i}");
if let (Some(g), Some((b, be))) = (got, want) {
assert_relative_eq!(g.bull_power, b, epsilon = 1e-9);
assert_relative_eq!(g.bear_power, be, epsilon = 1e-9);
}
}
}
#[test]
fn reset_clears_state() {
let mut er = ElderRay::new(5).unwrap();
er.batch(
&(0..20)
.map(|i| candle(f64::from(i) + 1.0, f64::from(i) - 1.0, f64::from(i)))
.collect::<Vec<_>>(),
);
assert!(er.is_ready());
er.reset();
assert!(!er.is_ready());
assert_eq!(er.update(candle(2.0, 0.0, 1.0)), None);
}
#[test]
fn batch_equals_streaming() {
let bars: Vec<Candle> = (0..30)
.map(|i| {
let base = 50.0 + f64::from(i);
candle(base + 1.5, base - 1.5, base)
})
.collect();
let mut a = ElderRay::new(7).unwrap();
let mut b = ElderRay::new(7).unwrap();
assert_eq!(
a.batch(&bars),
bars.iter().map(|c| b.update(*c)).collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,208 @@
//! Expectancy — expected return per unit of average loss (R-multiple).
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Expectancy — the expected return per trade expressed in units of average
/// loss (the "R-multiple" expectancy) over the last `period` returns.
///
/// ```text
/// mean = average of the `period` returns
/// avgLoss = average of the absolute losing returns (rᵢ < 0)
/// E = mean / avgLoss (0 when there are no losing returns)
/// ```
///
/// Feed a stream of per-trade or per-bar returns. Expectancy answers "how much
/// do I make per trade for every unit I typically risk": `E = 0.3` means the
/// system nets `0.3R` per trade on average, where `R` is the average loss.
/// Dividing the mean return by the average loss makes the figure comparable
/// across systems with different bet sizes — unlike the raw mean return (which
/// is just an SMA of the series). A positive `E` is a profitable edge, a
/// negative `E` a losing one.
///
/// When the window contains **no** losing returns there is no risk reference to
/// normalise against, so the indicator returns `0` (undefined R-multiple)
/// rather than dividing by zero.
///
/// Each `update` is O(1): the running sum and the loss aggregates are
/// maintained incrementally.
///
/// # Example
///
/// ```
/// use wickra_core::{BatchExt, Indicator, Expectancy};
///
/// let mut indicator = Expectancy::new(4).unwrap();
/// // returns +2, -1, +2, -1: mean 0.5, avg loss 1 -> E = 0.5.
/// let out = indicator.batch(&[2.0, -1.0, 2.0, -1.0]);
/// assert_eq!(out[3], Some(0.5));
/// ```
#[derive(Debug, Clone)]
pub struct Expectancy {
period: usize,
window: VecDeque<f64>,
sum: f64,
sum_abs_loss: f64,
loss_count: usize,
}
impl Expectancy {
/// Construct a new Expectancy over the given window.
///
/// # Errors
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
window: VecDeque::with_capacity(period),
sum: 0.0,
sum_abs_loss: 0.0,
loss_count: 0,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for Expectancy {
type Input = f64;
type Output = f64;
fn update(&mut self, ret: f64) -> Option<f64> {
if self.window.len() == self.period {
let old = self.window.pop_front().expect("window is non-empty");
self.sum -= old;
if old < 0.0 {
self.sum_abs_loss -= -old;
self.loss_count -= 1;
}
}
self.window.push_back(ret);
self.sum += ret;
if ret < 0.0 {
self.sum_abs_loss += -ret;
self.loss_count += 1;
}
if self.window.len() < self.period {
return None;
}
if self.loss_count == 0 {
// No losing returns: no risk reference to express the edge in.
return Some(0.0);
}
let mean = self.sum / self.period as f64;
let avg_loss = self.sum_abs_loss / self.loss_count as f64;
Some(mean / avg_loss)
}
fn reset(&mut self) {
self.window.clear();
self.sum = 0.0;
self.sum_abs_loss = 0.0;
self.loss_count = 0;
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.window.len() == self.period
}
fn name(&self) -> &'static str {
"Expectancy"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_zero_period() {
assert!(matches!(Expectancy::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_and_metadata() {
let e = Expectancy::new(20).unwrap();
assert_eq!(e.period(), 20);
assert_eq!(e.warmup_period(), 20);
assert_eq!(e.name(), "Expectancy");
assert!(!e.is_ready());
}
#[test]
fn positive_edge() {
// +2, -1, +2, -1: mean 0.5, avgLoss 1 -> 0.5.
let mut e = Expectancy::new(4).unwrap();
let out = e.batch(&[2.0, -1.0, 2.0, -1.0]);
assert_relative_eq!(out[3].unwrap(), 0.5, epsilon = 1e-12);
}
#[test]
fn negative_edge() {
// +1, -2, +1, -2: mean -0.5, avgLoss 2 -> -0.25.
let mut e = Expectancy::new(4).unwrap();
let out = e.batch(&[1.0, -2.0, 1.0, -2.0]);
assert_relative_eq!(out[3].unwrap(), -0.25, epsilon = 1e-12);
}
#[test]
fn no_losses_returns_zero() {
// All winning returns: no risk reference -> 0.
let mut e = Expectancy::new(5).unwrap();
for v in e.batch(&[1.0, 2.0, 3.0, 1.0, 2.0]).into_iter().flatten() {
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
}
}
#[test]
fn flat_returns_are_not_losses() {
// Zeros are not losses: mean (2+0+2+0)/4 = 1, but no losing returns
// -> 0 (undefined R-multiple).
let mut e = Expectancy::new(4).unwrap();
let out = e.batch(&[2.0, 0.0, 2.0, 0.0]);
assert_relative_eq!(out[3].unwrap(), 0.0, epsilon = 1e-12);
}
#[test]
fn rolling_window_evicts_old_losses() {
// period 4. Window [+2,-1,+2,-1] -> 0.5; then push +3,+3,+3,+3 to evict
// all losses -> no losses -> 0.
let mut e = Expectancy::new(4).unwrap();
let out = e.batch(&[2.0, -1.0, 2.0, -1.0, 3.0, 3.0, 3.0, 3.0]);
assert_relative_eq!(out[3].unwrap(), 0.5, epsilon = 1e-12);
assert_relative_eq!(out[7].unwrap(), 0.0, epsilon = 1e-12);
}
#[test]
fn reset_clears_state() {
let mut e = Expectancy::new(5).unwrap();
e.batch(&[1.0, -1.0, 2.0, -2.0, 1.0]);
assert!(e.is_ready());
e.reset();
assert!(!e.is_ready());
assert_eq!(e.update(1.0), None);
}
#[test]
fn batch_equals_streaming() {
let rets: Vec<f64> = (0..60).map(|i| (f64::from(i) * 0.5).sin() * 2.0).collect();
let batch = Expectancy::new(14).unwrap().batch(&rets);
let mut b = Expectancy::new(14).unwrap();
let streamed: Vec<_> = rets.iter().map(|p| b.update(*p)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,198 @@
//! Fibonacci Arcs — semicircular retracement levels centred on the swing end,
//! decaying back toward it as time elapses.
use crate::indicators::pattern_swing::{SwingTracker, SWING_THRESHOLD};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// The three arc ratios drawn (38.2% / 50% / 61.8%).
const RATIOS: [f64; 3] = [0.382, 0.5, 0.618];
/// Fibonacci Arc prices evaluated at the current bar.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct FibArcsOutput {
/// Price of the 38.2% arc at the current bar.
pub arc_382: f64,
/// Price of the 50% arc at the current bar.
pub arc_500: f64,
/// Price of the 61.8% arc at the current bar.
pub arc_618: f64,
}
/// Fibonacci Arcs (`FibArcs`).
///
/// Three arcs centred on the end of the most recent confirmed swing leg. Time is
/// normalised by the leg's bar-width so the construction is chart-scale-free: at
/// the leg's end bar each arc sits exactly on its retracement level, and as time
/// elapses the arc curves back toward the swing-end price, reaching it one leg
/// width later.
///
/// ```text
/// u = (cur - end_bar) / (end_bar - start_bar)
/// arc(r) = end + (start - end) * r * sqrt(max(0, 1 - u^2))
/// ```
///
/// Parameter-free; construction is infallible. Returns `None` until the first
/// leg is complete.
///
/// See `crates/wickra-core/src/indicators/fib_arcs.rs`.
#[derive(Debug, Clone)]
pub struct FibArcs {
swing: SwingTracker,
}
impl FibArcs {
/// Construct a new Fibonacci Arcs tracker.
#[must_use]
pub const fn new() -> Self {
Self {
swing: SwingTracker::new(SWING_THRESHOLD, 2),
}
}
fn arcs(&self) -> Option<FibArcsOutput> {
let pivots = self.swing.pivots();
let start = pivots.first()?;
let end = pivots.get(1)?;
// Consecutive pivots occur at strictly increasing bars → span >= 1 bar.
let span_bars = (end.bar - start.bar) as f64;
let u = (self.swing.current_bar() - end.bar) as f64 / span_bars;
let curve = (1.0 - u * u).max(0.0).sqrt();
let arc = |r: f64| end.price + (start.price - end.price) * r * curve;
Some(FibArcsOutput {
arc_382: arc(RATIOS[0]),
arc_500: arc(RATIOS[1]),
arc_618: arc(RATIOS[2]),
})
}
}
impl Default for FibArcs {
fn default() -> Self {
Self::new()
}
}
impl Indicator for FibArcs {
type Input = Candle;
type Output = FibArcsOutput;
fn update(&mut self, candle: Candle) -> Option<FibArcsOutput> {
self.swing.update(candle);
self.arcs()
}
fn reset(&mut self) {
self.swing.reset();
}
fn warmup_period(&self) -> usize {
2
}
fn is_ready(&self) -> bool {
self.swing.pivots().len() >= 2
}
fn name(&self) -> &'static str {
"FibArcs"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn c(high: f64, low: f64, ts: i64) -> Candle {
Candle::new(low, high, low, low, 1.0, ts).unwrap()
}
/// Leg start=200 (bar 0) -> end=100 (bar 2), confirmed at bar 3 so the arc is
/// first reported with `u = (3 - 2) / (2 - 0) = 0.5`.
fn down_leg() -> Vec<Candle> {
vec![
c(200.0, 199.0, 0),
c(190.0, 160.0, 1), // confirm high @200
c(150.0, 100.0, 2), // extend low to 100 (bar 2)
c(110.0, 105.0, 3), // confirm low @100 -> two pivots
]
}
#[test]
fn accessors_and_metadata() {
let indicator = FibArcs::new();
assert_eq!(indicator.name(), "FibArcs");
assert_eq!(indicator.warmup_period(), 2);
assert!(!indicator.is_ready());
assert!(!FibArcs::default().is_ready());
}
#[test]
fn no_output_before_two_pivots() {
let mut indicator = FibArcs::new();
let outputs: Vec<_> = [c(200.0, 199.0, 0), c(190.0, 150.0, 1)]
.into_iter()
.map(|x| indicator.update(x))
.collect();
assert!(outputs.iter().all(Option::is_none));
assert!(!indicator.is_ready());
}
#[test]
fn arcs_curve_back_toward_the_swing_end() {
let mut indicator = FibArcs::new();
let mut last = None;
for candle in down_leg() {
last = indicator.update(candle);
}
let v = last.unwrap();
assert!(indicator.is_ready());
// u = 0.5 → curve = sqrt(0.75); arc(r) = 100 + 100 * r * curve.
let curve = 0.75_f64.sqrt();
assert_relative_eq!(v.arc_382, 100.0 + 100.0 * 0.382 * curve);
assert_relative_eq!(v.arc_500, 100.0 + 100.0 * 0.5 * curve);
assert_relative_eq!(v.arc_618, 100.0 + 100.0 * 0.618 * curve);
}
#[test]
fn arc_clamps_to_zero_beyond_one_leg_width() {
// Extend far past the end pivot so u > 1; the curve clamps to 0 and the
// arcs collapse onto the swing-end price.
let mut indicator = FibArcs::new();
for candle in down_leg() {
let _ = indicator.update(candle);
}
// Feed flat bars that neither extend nor confirm a new pivot.
let mut last = None;
for ts in 4..12 {
last = indicator.update(c(108.0, 106.0, ts));
}
let v = last.unwrap();
assert_relative_eq!(v.arc_382, 100.0);
assert_relative_eq!(v.arc_618, 100.0);
}
#[test]
fn reset_clears_state() {
let mut indicator = FibArcs::new();
for candle in down_leg() {
let _ = indicator.update(candle);
}
indicator.reset();
assert!(!indicator.is_ready());
assert!(indicator.update(c(100.0, 99.5, 0)).is_none());
}
#[test]
fn batch_equals_streaming() {
let candles = down_leg();
let mut a = FibArcs::new();
let mut b = FibArcs::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,192 @@
//! Fibonacci Channel — a sloped base trendline plus parallel lines offset by
//! Fibonacci multiples of the channel width.
use crate::indicators::pattern_swing::{SwingTracker, SWING_THRESHOLD};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// The parallel-line ratios above the base (61.8% / 100% / 161.8% of the width).
const RATIOS: [f64; 3] = [0.618, 1.0, 1.618];
/// Fibonacci Channel line prices evaluated at the current bar.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct FibChannelOutput {
/// The base trendline price at the current bar.
pub base: f64,
/// Base + 61.8% of the channel width.
pub level_618: f64,
/// Base + 100% of the width — the opposite channel boundary.
pub level_1000: f64,
/// Base + 161.8% of the width.
pub level_1618: f64,
}
/// Fibonacci Channel (`FibChannel`).
///
/// From the last three confirmed pivots, the two same-direction outer pivots
/// define a sloped base trendline and the opposite middle pivot sets the channel
/// width (its signed distance from the base line). Parallel lines are then offset
/// by Fibonacci multiples of that width and reported at the current bar.
///
/// ```text
/// slope = (p2 - p0) / (bar2 - bar0)
/// base(bar) = p0 + slope * (bar - bar0)
/// width = p1 - base(bar1)
/// level(r) = base(cur) + r * width
/// ```
///
/// Parameter-free; construction is infallible. Returns `None` until three pivots
/// have confirmed.
///
/// See `crates/wickra-core/src/indicators/fib_channel.rs`.
#[derive(Debug, Clone)]
pub struct FibChannel {
swing: SwingTracker,
}
impl FibChannel {
/// Construct a new Fibonacci Channel tracker.
#[must_use]
pub const fn new() -> Self {
Self {
swing: SwingTracker::new(SWING_THRESHOLD, 3),
}
}
fn channel(&self) -> Option<FibChannelOutput> {
let pivots = self.swing.pivots();
let p0 = pivots.first()?;
let p1 = pivots.get(1)?;
let p2 = pivots.get(2)?;
// p0 and p2 are the same-direction outer pivots; their bars differ
// strictly, so the slope denominator is non-zero.
let slope = (p2.price - p0.price) / (p2.bar - p0.bar) as f64;
let base_at = |bar: usize| p0.price + slope * (bar - p0.bar) as f64;
let width = p1.price - base_at(p1.bar);
let base = base_at(self.swing.current_bar());
Some(FibChannelOutput {
base,
level_618: base + RATIOS[0] * width,
level_1000: base + RATIOS[1] * width,
level_1618: base + RATIOS[2] * width,
})
}
}
impl Default for FibChannel {
fn default() -> Self {
Self::new()
}
}
impl Indicator for FibChannel {
type Input = Candle;
type Output = FibChannelOutput;
fn update(&mut self, candle: Candle) -> Option<FibChannelOutput> {
self.swing.update(candle);
self.channel()
}
fn reset(&mut self) {
self.swing.reset();
}
fn warmup_period(&self) -> usize {
3
}
fn is_ready(&self) -> bool {
self.swing.pivots().len() >= 3
}
fn name(&self) -> &'static str {
"FibChannel"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn c(high: f64, low: f64, ts: i64) -> Candle {
Candle::new(low, high, low, low, 1.0, ts).unwrap()
}
/// Pivots: high 200 (bar 0), low 100 (bar 1), high 220 (bar 3); confirmed at
/// bar 4 so the channel is first reported at current bar 4.
fn three_pivots() -> Vec<Candle> {
vec![
c(200.0, 199.0, 0),
c(190.0, 100.0, 1), // confirm high @200, low candidate @100
c(110.0, 108.0, 2), // confirm low @100, high candidate @110
c(220.0, 210.0, 3), // extend high to 220 (bar 3)
c(200.0, 150.0, 4), // confirm high @220 -> three pivots
]
}
#[test]
fn accessors_and_metadata() {
let indicator = FibChannel::new();
assert_eq!(indicator.name(), "FibChannel");
assert_eq!(indicator.warmup_period(), 3);
assert!(!indicator.is_ready());
assert!(!FibChannel::default().is_ready());
}
#[test]
fn no_output_before_three_pivots() {
let mut indicator = FibChannel::new();
let outputs: Vec<_> = [c(200.0, 199.0, 0), c(190.0, 100.0, 1), c(110.0, 108.0, 2)]
.into_iter()
.map(|x| indicator.update(x))
.collect();
// Only two pivots confirm within these three bars.
assert!(outputs.iter().all(Option::is_none));
assert!(!indicator.is_ready());
}
#[test]
fn channel_levels_from_three_pivots() {
let mut indicator = FibChannel::new();
let mut last = None;
for candle in three_pivots() {
last = indicator.update(candle);
}
let v = last.unwrap();
assert!(indicator.is_ready());
// Base through highs (0,200) and (3,220); width from low (1,100); cur = 4.
let slope = (220.0 - 200.0) / 3.0;
let base_cur = 200.0 + slope * 4.0;
let width = 100.0 - (200.0 + slope * 1.0);
assert_relative_eq!(v.base, base_cur);
assert_relative_eq!(v.level_1000, base_cur + width);
assert_relative_eq!(v.level_618, base_cur + 0.618 * width);
assert_relative_eq!(v.level_1618, base_cur + 1.618 * width);
}
#[test]
fn reset_clears_state() {
let mut indicator = FibChannel::new();
for candle in three_pivots() {
let _ = indicator.update(candle);
}
assert!(indicator.is_ready());
indicator.reset();
assert!(!indicator.is_ready());
assert!(indicator.update(c(100.0, 99.5, 0)).is_none());
}
#[test]
fn batch_equals_streaming() {
let candles = three_pivots();
let mut a = FibChannel::new();
let mut b = FibChannel::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,181 @@
//! Fibonacci Confluence — the strongest retracement cluster across recent legs.
use crate::indicators::pattern_swing::{
approx_equal, SwingTracker, LEVEL_TOLERANCE, SWING_THRESHOLD,
};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// How many recent pivots to consider; six pivots yield up to five legs.
const PIVOT_HISTORY: usize = 6;
/// The retracement ratios contributed by each leg to the confluence search.
const RATIOS: [f64; 3] = [0.382, 0.5, 0.618];
/// The strongest Fibonacci confluence zone found across recent swing legs.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct FibConfluenceOutput {
/// Mean price of the densest cluster of retracement levels.
pub price: f64,
/// Number of retracement levels that fall inside the cluster (its strength).
pub strength: f64,
}
/// Fibonacci Confluence (`FibConfluence`).
///
/// Computes the 38.2% / 50% / 61.8% retracement prices of every leg among the
/// last six confirmed pivots, then reports the densest price cluster — where
/// levels from different legs stack up, the zone the market is most likely to
/// react to. `price` is the cluster mean; `strength` is how many levels it
/// gathers.
///
/// Parameter-free; construction is infallible. Returns `None` until at least two
/// legs (three pivots) exist.
///
/// See `crates/wickra-core/src/indicators/fib_confluence.rs`.
#[derive(Debug, Clone)]
pub struct FibConfluence {
swing: SwingTracker,
}
impl FibConfluence {
/// Construct a new Fibonacci Confluence tracker.
#[must_use]
pub const fn new() -> Self {
Self {
swing: SwingTracker::new(SWING_THRESHOLD, PIVOT_HISTORY),
}
}
fn confluence(&self) -> Option<FibConfluenceOutput> {
let pivots = self.swing.pivots();
if pivots.len() < 3 {
return None;
}
let levels: Vec<f64> = pivots
.windows(2)
.flat_map(|leg| {
let (start, end) = (leg[0].price, leg[1].price);
RATIOS.map(|r| end + r * (start - end))
})
.collect();
// The `len < 3` guard guarantees at least two legs, hence a non-empty
// level set, so `max_by` always yields a cluster.
let (count, total) = levels
.iter()
.map(|&center| {
let members: Vec<f64> = levels
.iter()
.copied()
.filter(|&x| approx_equal(x, center, LEVEL_TOLERANCE))
.collect();
(members.len(), members.iter().sum::<f64>())
})
.max_by(|a, b| a.0.cmp(&b.0))
.expect("at least two legs guarantee a non-empty level set");
Some(FibConfluenceOutput {
price: total / count as f64,
strength: count as f64,
})
}
}
impl Default for FibConfluence {
fn default() -> Self {
Self::new()
}
}
impl Indicator for FibConfluence {
type Input = Candle;
type Output = FibConfluenceOutput;
fn update(&mut self, candle: Candle) -> Option<FibConfluenceOutput> {
self.swing.update(candle);
self.confluence()
}
fn reset(&mut self) {
self.swing.reset();
}
fn warmup_period(&self) -> usize {
3
}
fn is_ready(&self) -> bool {
self.swing.pivots().len() >= 3
}
fn name(&self) -> &'static str {
"FibConfluence"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::indicators::pattern_swing::candles_for_pivots;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn accessors_and_metadata() {
let indicator = FibConfluence::new();
assert_eq!(indicator.name(), "FibConfluence");
assert_eq!(indicator.warmup_period(), 3);
assert!(!indicator.is_ready());
assert!(!FibConfluence::default().is_ready());
}
#[test]
fn no_output_before_two_legs() {
let mut indicator = FibConfluence::new();
let outputs: Vec<_> = candles_for_pivots(&[200.0, 100.0])
.into_iter()
.map(|c| indicator.update(c))
.collect();
assert!(outputs.iter().all(Option::is_none));
assert!(!indicator.is_ready());
}
#[test]
fn picks_the_densest_cluster() {
// Legs 200->100 and 100->160. The 38.2% of each (138.2 and ~137.08)
// sit within 3% of each other and form the densest cluster (strength 2).
let mut indicator = FibConfluence::new();
let mut last = None;
for candle in candles_for_pivots(&[200.0, 100.0, 160.0]) {
last = indicator.update(candle);
}
let v = last.unwrap();
assert!(indicator.is_ready());
assert_relative_eq!(v.strength, 2.0);
let want = (138.2 + (160.0 + 0.382 * (100.0 - 160.0))) / 2.0;
assert_relative_eq!(v.price, want, epsilon = 1e-9);
}
#[test]
fn reset_clears_state() {
let mut indicator = FibConfluence::new();
for candle in candles_for_pivots(&[200.0, 100.0, 160.0]) {
let _ = indicator.update(candle);
}
assert!(indicator.is_ready());
indicator.reset();
assert!(!indicator.is_ready());
let c = Candle::new(99.5, 100.0, 99.5, 99.5, 1.0, 0).unwrap();
assert!(indicator.update(c).is_none());
}
#[test]
fn batch_equals_streaming() {
let candles = candles_for_pivots(&[200.0, 100.0, 160.0, 120.0]);
let mut a = FibConfluence::new();
let mut b = FibConfluence::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,171 @@
//! Fibonacci Extension of the most recent confirmed swing leg.
use crate::indicators::pattern_swing::{SwingTracker, SWING_THRESHOLD};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// The five canonical extension ratios, in ascending order. Each is a multiple
/// of the swing leg measured from its origin, so `1.0` sits on the leg's end and
/// every ratio here projects further in the direction of the move.
const RATIOS: [f64; 5] = [1.272, 1.414, 1.618, 2.0, 2.618];
/// Fibonacci Extension levels for the most recent swing leg.
///
/// Each field is the price reached if the move continues to the matching
/// multiple of the leg, measured from the leg's start.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct FibExtensionOutput {
/// 127.2% extension.
pub level_1272: f64,
/// 141.4% extension.
pub level_1414: f64,
/// 161.8% extension — the "golden" extension.
pub level_1618: f64,
/// 200% extension.
pub level_2000: f64,
/// 261.8% extension.
pub level_2618: f64,
}
/// Fibonacci Extension (`FibExtension`).
///
/// Tracks confirmed swing pivots with a baked-in 5% reversal threshold and, once
/// two pivots exist, projects the leg between them to the canonical extension
/// ratios — the price targets a continuation of the move would reach.
///
/// Parameter-free; construction is infallible. Returns `None` until the first
/// leg is complete.
///
/// See `crates/wickra-core/src/indicators/fib_extension.rs`.
#[derive(Debug, Clone)]
pub struct FibExtension {
swing: SwingTracker,
}
impl FibExtension {
/// Construct a new Fibonacci Extension tracker.
#[must_use]
pub const fn new() -> Self {
Self {
swing: SwingTracker::new(SWING_THRESHOLD, 2),
}
}
/// Extension price at ratio `e` for a leg from `start` to `end`: the total
/// move is `e` times the leg, measured from `start`.
fn level(start: f64, end: f64, e: f64) -> f64 {
start + e * (end - start)
}
fn levels(&self) -> Option<FibExtensionOutput> {
let pivots = self.swing.pivots();
let [start, end] = [pivots.first()?.price, pivots.get(1)?.price];
Some(FibExtensionOutput {
level_1272: Self::level(start, end, RATIOS[0]),
level_1414: Self::level(start, end, RATIOS[1]),
level_1618: Self::level(start, end, RATIOS[2]),
level_2000: Self::level(start, end, RATIOS[3]),
level_2618: Self::level(start, end, RATIOS[4]),
})
}
}
impl Default for FibExtension {
fn default() -> Self {
Self::new()
}
}
impl Indicator for FibExtension {
type Input = Candle;
type Output = FibExtensionOutput;
fn update(&mut self, candle: Candle) -> Option<FibExtensionOutput> {
self.swing.update(candle);
self.levels()
}
fn reset(&mut self) {
self.swing.reset();
}
fn warmup_period(&self) -> usize {
2
}
fn is_ready(&self) -> bool {
self.swing.pivots().len() >= 2
}
fn name(&self) -> &'static str {
"FibExtension"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::indicators::pattern_swing::candles_for_pivots;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn accessors_and_metadata() {
let indicator = FibExtension::new();
assert_eq!(indicator.name(), "FibExtension");
assert_eq!(indicator.warmup_period(), 2);
assert!(!indicator.is_ready());
assert!(!FibExtension::default().is_ready());
}
#[test]
fn no_output_before_two_pivots() {
let mut indicator = FibExtension::new();
let outputs: Vec<_> = candles_for_pivots(&[120.0])
.into_iter()
.map(|c| indicator.update(c))
.collect();
assert!(outputs.iter().all(Option::is_none));
}
#[test]
fn extension_levels_of_a_down_leg() {
// Leg start = 200 (high), end = 100 (low): a 100-point drop continued.
let mut indicator = FibExtension::new();
let mut last = None;
for candle in candles_for_pivots(&[200.0, 100.0]) {
last = indicator.update(candle);
}
let v = last.unwrap();
assert!(indicator.is_ready());
// 161.8% extension projects 1.618 * (-100) below the 200 origin.
assert_relative_eq!(v.level_1272, 200.0 - 127.2);
assert_relative_eq!(v.level_1414, 200.0 - 141.4);
assert_relative_eq!(v.level_1618, 200.0 - 161.8);
assert_relative_eq!(v.level_2000, 0.0);
assert_relative_eq!(v.level_2618, 200.0 - 261.8);
}
#[test]
fn reset_clears_state() {
let mut indicator = FibExtension::new();
for candle in candles_for_pivots(&[200.0, 100.0]) {
let _ = indicator.update(candle);
}
indicator.reset();
assert!(!indicator.is_ready());
let c = Candle::new(99.5, 100.0, 99.5, 99.5, 1.0, 0).unwrap();
assert!(indicator.update(c).is_none());
}
#[test]
fn batch_equals_streaming() {
let candles = candles_for_pivots(&[200.0, 100.0, 150.0]);
let mut a = FibExtension::new();
let mut b = FibExtension::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,180 @@
//! Fibonacci Fan — trendlines fanning from a swing start through the
//! retracement levels at the swing end, extended to the current bar.
use crate::indicators::pattern_swing::{SwingTracker, SWING_THRESHOLD};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// The three fan ratios drawn (38.2% / 50% / 61.8%).
const RATIOS: [f64; 3] = [0.382, 0.5, 0.618];
/// Fibonacci Fan line prices evaluated at the current bar.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct FibFanOutput {
/// Price of the 38.2% fan line at the current bar.
pub fan_382: f64,
/// Price of the 50% fan line at the current bar.
pub fan_500: f64,
/// Price of the 61.8% fan line at the current bar.
pub fan_618: f64,
}
/// Fibonacci Fan (`FibFan`).
///
/// Anchored at the start of the most recent confirmed swing leg, three lines fan
/// out through the 38.2% / 50% / 61.8% retracement levels located at the leg's
/// end bar, then extend to the current bar. Each line's price is reported as the
/// fan opens with elapsed time.
///
/// ```text
/// line(r) = start + r * (end - start) * (cur - start_bar) / (end_bar - start_bar)
/// ```
///
/// Parameter-free; construction is infallible. Returns `None` until the first
/// leg is complete.
///
/// See `crates/wickra-core/src/indicators/fib_fan.rs`.
#[derive(Debug, Clone)]
pub struct FibFan {
swing: SwingTracker,
}
impl FibFan {
/// Construct a new Fibonacci Fan tracker.
#[must_use]
pub const fn new() -> Self {
Self {
swing: SwingTracker::new(SWING_THRESHOLD, 2),
}
}
fn fan(&self) -> Option<FibFanOutput> {
let pivots = self.swing.pivots();
let start = pivots.first()?;
let end = pivots.get(1)?;
// Consecutive pivots occur at strictly increasing bars, so the span is
// always at least one bar — no division by zero.
let span_bars = (end.bar - start.bar) as f64;
let elapsed = (self.swing.current_bar() - start.bar) as f64;
let progress = elapsed / span_bars;
let line = |r: f64| start.price + r * (end.price - start.price) * progress;
Some(FibFanOutput {
fan_382: line(RATIOS[0]),
fan_500: line(RATIOS[1]),
fan_618: line(RATIOS[2]),
})
}
}
impl Default for FibFan {
fn default() -> Self {
Self::new()
}
}
impl Indicator for FibFan {
type Input = Candle;
type Output = FibFanOutput;
fn update(&mut self, candle: Candle) -> Option<FibFanOutput> {
self.swing.update(candle);
self.fan()
}
fn reset(&mut self) {
self.swing.reset();
}
fn warmup_period(&self) -> usize {
2
}
fn is_ready(&self) -> bool {
self.swing.pivots().len() >= 2
}
fn name(&self) -> &'static str {
"FibFan"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn c(high: f64, low: f64, ts: i64) -> Candle {
Candle::new(low, high, low, low, 1.0, ts).unwrap()
}
/// Drive a leg start=200 (bar 0) -> end=100 (bar 2), confirmed at bar 3, so
/// the fan is first reported at bar 3 with `progress = 3 / 2 = 1.5`.
fn down_leg() -> Vec<Candle> {
vec![
c(200.0, 199.0, 0), // bootstrap high @200 (bar 0)
c(190.0, 160.0, 1), // confirm high @200, low candidate @160
c(150.0, 100.0, 2), // extend low to 100 (bar 2)
c(110.0, 105.0, 3), // confirm low @100 -> two pivots
]
}
#[test]
fn accessors_and_metadata() {
let indicator = FibFan::new();
assert_eq!(indicator.name(), "FibFan");
assert_eq!(indicator.warmup_period(), 2);
assert!(!indicator.is_ready());
assert!(!FibFan::default().is_ready());
}
#[test]
fn no_output_before_two_pivots() {
let mut indicator = FibFan::new();
// Only the high confirms here; no end pivot yet.
let outputs: Vec<_> = [c(200.0, 199.0, 0), c(190.0, 150.0, 1)]
.into_iter()
.map(|x| indicator.update(x))
.collect();
assert!(outputs.iter().all(Option::is_none));
assert!(!indicator.is_ready());
}
#[test]
fn fan_lines_open_with_elapsed_time() {
let mut indicator = FibFan::new();
let mut last = None;
for candle in down_leg() {
last = indicator.update(candle);
}
let v = last.unwrap();
assert!(indicator.is_ready());
// progress = (3 - 0) / (2 - 0) = 1.5; line(r) = 200 + r*(-100)*1.5.
assert_relative_eq!(v.fan_382, 200.0 - 0.382 * 150.0);
assert_relative_eq!(v.fan_500, 125.0);
assert_relative_eq!(v.fan_618, 200.0 - 0.618 * 150.0);
}
#[test]
fn reset_clears_state() {
let mut indicator = FibFan::new();
for candle in down_leg() {
let _ = indicator.update(candle);
}
assert!(indicator.is_ready());
indicator.reset();
assert!(!indicator.is_ready());
assert!(indicator.update(c(100.0, 99.5, 0)).is_none());
}
#[test]
fn batch_equals_streaming() {
let candles = down_leg();
let mut a = FibFan::new();
let mut b = FibFan::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,165 @@
//! Fibonacci Projection — a measured move from the last three swing pivots.
use crate::indicators::pattern_swing::{SwingTracker, SWING_THRESHOLD};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// The four canonical projection ratios, in ascending order. Each scales the
/// A→B leg and projects it from C; `1.0` is the classic AB=CD measured move.
const RATIOS: [f64; 4] = [0.618, 1.0, 1.618, 2.618];
/// Fibonacci Projection levels (the C→D target zone of a measured move).
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct FibProjectionOutput {
/// 61.8% projection of the A→B leg from C.
pub level_618: f64,
/// 100% projection — the AB=CD measured move.
pub level_1000: f64,
/// 161.8% projection.
pub level_1618: f64,
/// 261.8% projection.
pub level_2618: f64,
}
/// Fibonacci Projection (`FibProjection`).
///
/// Reads the last three confirmed swing pivots as the points A, B and C of a
/// measured move and projects the A→B leg from C at the canonical ratios — the
/// price targets for the C→D leg.
///
/// Parameter-free; construction is infallible. Returns `None` until three
/// pivots have confirmed.
///
/// See `crates/wickra-core/src/indicators/fib_projection.rs`.
#[derive(Debug, Clone)]
pub struct FibProjection {
swing: SwingTracker,
}
impl FibProjection {
/// Construct a new Fibonacci Projection tracker.
#[must_use]
pub const fn new() -> Self {
Self {
swing: SwingTracker::new(SWING_THRESHOLD, 3),
}
}
fn levels(&self) -> Option<FibProjectionOutput> {
let pivots = self.swing.pivots();
let [a, b, c] = [
pivots.first()?.price,
pivots.get(1)?.price,
pivots.get(2)?.price,
];
let project = |p: f64| c + p * (b - a);
Some(FibProjectionOutput {
level_618: project(RATIOS[0]),
level_1000: project(RATIOS[1]),
level_1618: project(RATIOS[2]),
level_2618: project(RATIOS[3]),
})
}
}
impl Default for FibProjection {
fn default() -> Self {
Self::new()
}
}
impl Indicator for FibProjection {
type Input = Candle;
type Output = FibProjectionOutput;
fn update(&mut self, candle: Candle) -> Option<FibProjectionOutput> {
self.swing.update(candle);
self.levels()
}
fn reset(&mut self) {
self.swing.reset();
}
fn warmup_period(&self) -> usize {
3
}
fn is_ready(&self) -> bool {
self.swing.pivots().len() >= 3
}
fn name(&self) -> &'static str {
"FibProjection"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::indicators::pattern_swing::candles_for_pivots;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn accessors_and_metadata() {
let indicator = FibProjection::new();
assert_eq!(indicator.name(), "FibProjection");
assert_eq!(indicator.warmup_period(), 3);
assert!(!indicator.is_ready());
assert!(!FibProjection::default().is_ready());
}
#[test]
fn no_output_before_three_pivots() {
let mut indicator = FibProjection::new();
let outputs: Vec<_> = candles_for_pivots(&[200.0, 100.0])
.into_iter()
.map(|c| indicator.update(c))
.collect();
assert!(outputs.iter().all(Option::is_none));
assert!(!indicator.is_ready());
}
#[test]
fn measured_move_from_three_pivots() {
// A = 200 (high), B = 160 (low), C = 190 (high). A->B = -40, projected
// down from C.
let mut indicator = FibProjection::new();
let mut last = None;
for candle in candles_for_pivots(&[200.0, 160.0, 190.0]) {
last = indicator.update(candle);
}
let v = last.unwrap();
assert!(indicator.is_ready());
let (a, b, c) = (200.0, 160.0, 190.0);
assert_relative_eq!(v.level_618, c + 0.618 * (b - a));
assert_relative_eq!(v.level_1000, c + (b - a));
assert_relative_eq!(v.level_1618, c + 1.618 * (b - a));
assert_relative_eq!(v.level_2618, c + 2.618 * (b - a));
}
#[test]
fn reset_clears_state() {
let mut indicator = FibProjection::new();
for candle in candles_for_pivots(&[200.0, 160.0, 190.0]) {
let _ = indicator.update(candle);
}
assert!(indicator.is_ready());
indicator.reset();
assert!(!indicator.is_ready());
let c = Candle::new(99.5, 100.0, 99.5, 99.5, 1.0, 0).unwrap();
assert!(indicator.update(c).is_none());
}
#[test]
fn batch_equals_streaming() {
let candles = candles_for_pivots(&[200.0, 160.0, 190.0, 150.0]);
let mut a = FibProjection::new();
let mut b = FibProjection::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,201 @@
//! Fibonacci Retracement of the most recent confirmed swing leg.
use crate::indicators::pattern_swing::{SwingTracker, SWING_THRESHOLD};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// The seven canonical retracement ratios, in ascending order. `0.0` marks the
/// most recent swing extreme (the end of the leg) and `1.0` the swing origin
/// (its start); the interior ratios are the classic Fibonacci pullbacks.
const RATIOS: [f64; 7] = [0.0, 0.236, 0.382, 0.5, 0.618, 0.786, 1.0];
/// Fibonacci Retracement levels for the most recent swing leg.
///
/// Each field is the price at the matching retracement ratio, measured from the
/// leg's end (`level_0`, the latest confirmed extreme) back toward its start
/// (`level_1000`, the prior pivot).
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct FibRetracementOutput {
/// 0.0% — the most recent confirmed swing extreme.
pub level_0: f64,
/// 23.6% retracement.
pub level_236: f64,
/// 38.2% retracement.
pub level_382: f64,
/// 50% retracement (not a Fibonacci ratio, but conventionally drawn).
pub level_500: f64,
/// 61.8% retracement — the "golden ratio" pullback.
pub level_618: f64,
/// 78.6% retracement.
pub level_786: f64,
/// 100% — the swing origin.
pub level_1000: f64,
}
/// Fibonacci Retracement (`FibRetracement`).
///
/// Tracks confirmed swing pivots with a baked-in 5% reversal threshold (the
/// same non-repainting logic as [`crate::indicators::ZigZag`]) and, once two
/// pivots exist, reports the seven retracement levels of the leg between them.
///
/// The levels are recomputed each time a new pivot confirms; between
/// confirmations [`Indicator::update`] returns the locked levels of the current
/// leg. Before the first leg is complete it returns `None`.
///
/// Parameter-free: the threshold is a compile-time constant, mirroring the
/// chart- and harmonic-pattern detectors, so construction is infallible.
///
/// See `crates/wickra-core/src/indicators/fib_retracement.rs`.
#[derive(Debug, Clone)]
pub struct FibRetracement {
swing: SwingTracker,
}
impl FibRetracement {
/// Construct a new Fibonacci Retracement tracker.
#[must_use]
pub const fn new() -> Self {
Self {
swing: SwingTracker::new(SWING_THRESHOLD, 2),
}
}
/// Retracement price at ratio `r` for a leg from `start` to `end`: `0.0`
/// sits on `end`, `1.0` on `start`.
fn level(start: f64, end: f64, r: f64) -> f64 {
end + r * (start - end)
}
fn levels(&self) -> Option<FibRetracementOutput> {
let pivots = self.swing.pivots();
let [start, end] = [pivots.first()?.price, pivots.get(1)?.price];
Some(FibRetracementOutput {
level_0: Self::level(start, end, RATIOS[0]),
level_236: Self::level(start, end, RATIOS[1]),
level_382: Self::level(start, end, RATIOS[2]),
level_500: Self::level(start, end, RATIOS[3]),
level_618: Self::level(start, end, RATIOS[4]),
level_786: Self::level(start, end, RATIOS[5]),
level_1000: Self::level(start, end, RATIOS[6]),
})
}
}
impl Default for FibRetracement {
fn default() -> Self {
Self::new()
}
}
impl Indicator for FibRetracement {
type Input = Candle;
type Output = FibRetracementOutput;
fn update(&mut self, candle: Candle) -> Option<FibRetracementOutput> {
self.swing.update(candle);
self.levels()
}
fn reset(&mut self) {
self.swing.reset();
}
fn warmup_period(&self) -> usize {
2
}
fn is_ready(&self) -> bool {
self.swing.pivots().len() >= 2
}
fn name(&self) -> &'static str {
"FibRetracement"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::indicators::pattern_swing::candles_for_pivots;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn accessors_and_metadata() {
let indicator = FibRetracement::new();
assert_eq!(indicator.name(), "FibRetracement");
assert_eq!(indicator.warmup_period(), 2);
assert!(!indicator.is_ready());
assert!(!FibRetracement::default().is_ready());
}
#[test]
fn no_output_before_two_pivots() {
let mut indicator = FibRetracement::new();
// A single confirmed pivot is not enough to define a leg.
let candles = candles_for_pivots(&[120.0]);
let outputs: Vec<_> = candles.into_iter().map(|c| indicator.update(c)).collect();
assert!(outputs.iter().all(Option::is_none));
assert!(!indicator.is_ready());
}
#[test]
fn retracement_levels_of_a_down_leg() {
// Leg start = 200 (high), end = 100 (low): a 100-point drop.
let mut indicator = FibRetracement::new();
let mut last = None;
for candle in candles_for_pivots(&[200.0, 100.0]) {
last = indicator.update(candle);
}
let v = last.unwrap();
assert!(indicator.is_ready());
// 0% on the low (end), 100% on the high (start).
assert_relative_eq!(v.level_0, 100.0);
assert_relative_eq!(v.level_1000, 200.0);
// 61.8% retracement of a 100-point drop, measured up from the low.
assert_relative_eq!(v.level_618, 161.8);
assert_relative_eq!(v.level_500, 150.0);
assert_relative_eq!(v.level_382, 138.2);
assert_relative_eq!(v.level_236, 123.6);
assert_relative_eq!(v.level_786, 178.6);
}
#[test]
fn levels_refresh_on_a_new_leg() {
// Four pivots, cap = 2: once the third and fourth confirm, the reported
// leg shifts to the latest pair (130 high -> 90 low).
let mut indicator = FibRetracement::new();
let mut last = None;
for candle in candles_for_pivots(&[200.0, 100.0, 130.0, 90.0]) {
last = indicator.update(candle);
}
let v = last.unwrap();
assert_relative_eq!(v.level_0, 90.0);
assert_relative_eq!(v.level_1000, 130.0);
assert_relative_eq!(v.level_618, 90.0 + 0.618 * 40.0);
}
#[test]
fn reset_clears_state() {
let mut indicator = FibRetracement::new();
for candle in candles_for_pivots(&[200.0, 100.0]) {
let _ = indicator.update(candle);
}
assert!(indicator.is_ready());
indicator.reset();
assert!(!indicator.is_ready());
let c = Candle::new(99.5, 100.0, 99.5, 99.5, 1.0, 0).unwrap();
assert!(indicator.update(c).is_none());
}
#[test]
fn batch_equals_streaming() {
let candles = candles_for_pivots(&[200.0, 100.0, 150.0]);
let mut a = FibRetracement::new();
let mut b = FibRetracement::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,181 @@
//! Fibonacci Time Zones — vertical markers at Fibonacci bar-distances from the
//! most recent swing pivot.
use crate::indicators::pattern_swing::{SwingTracker, SWING_THRESHOLD};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Where the current bar sits relative to the Fibonacci time-zone grid anchored
/// on the most recent confirmed pivot.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct FibTimeZonesOutput {
/// `1.0` when the current bar lands on a Fibonacci time zone (a bar distance
/// of 1, 2, 3, 5, 8, 13, … from the anchor pivot), otherwise `0.0`.
pub on_zone: f64,
/// Number of bars until the next Fibonacci time zone (`0` is never returned —
/// when on a zone this is the gap to the following one).
pub bars_to_next: f64,
}
/// Fibonacci Time Zones (`FibTimeZones`).
///
/// Anchored on the most recent confirmed swing pivot, the Fibonacci sequence
/// `1, 2, 3, 5, 8, 13, …` marks bars at which trend changes are classically
/// anticipated. Reports whether the current bar is on a zone and how many bars
/// remain until the next one.
///
/// Parameter-free; construction is infallible. Returns `None` until the first
/// pivot has confirmed.
///
/// See `crates/wickra-core/src/indicators/fib_time_zones.rs`.
#[derive(Debug, Clone)]
pub struct FibTimeZones {
swing: SwingTracker,
}
impl FibTimeZones {
/// Construct a new Fibonacci Time Zones tracker.
#[must_use]
pub const fn new() -> Self {
Self {
swing: SwingTracker::new(SWING_THRESHOLD, 2),
}
}
fn zones(&self) -> Option<FibTimeZonesOutput> {
let anchor = self.swing.pivots().last()?;
let distance = self.swing.current_bar() - anchor.bar;
// Walk the time-zone sequence 1, 2, 3, 5, 8, … : `lo` advances through the
// members, `on_zone` records a hit, and the loop exits with `lo` holding
// the smallest member strictly greater than `distance`.
let (mut lo, mut hi) = (1usize, 2usize);
let mut on_zone = false;
while lo <= distance {
if lo == distance {
on_zone = true;
}
let next = lo + hi;
lo = hi;
hi = next;
}
Some(FibTimeZonesOutput {
on_zone: f64::from(u8::from(on_zone)),
bars_to_next: (lo - distance) as f64,
})
}
}
impl Default for FibTimeZones {
fn default() -> Self {
Self::new()
}
}
impl Indicator for FibTimeZones {
type Input = Candle;
type Output = FibTimeZonesOutput;
fn update(&mut self, candle: Candle) -> Option<FibTimeZonesOutput> {
self.swing.update(candle);
self.zones()
}
fn reset(&mut self) {
self.swing.reset();
}
fn warmup_period(&self) -> usize {
2
}
fn is_ready(&self) -> bool {
!self.swing.pivots().is_empty()
}
fn name(&self) -> &'static str {
"FibTimeZones"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn c(high: f64, low: f64, ts: i64) -> Candle {
Candle::new(low, high, low, low, 1.0, ts).unwrap()
}
/// One pivot confirms at bar 0 (high @200, confirmed at bar 1); subsequent
/// flat bars neither extend nor confirm, so the anchor stays at bar 0 and the
/// distance equals the current bar index.
fn anchored_run() -> Vec<Candle> {
let mut bars = vec![c(200.0, 199.0, 0), c(190.0, 150.0, 1)];
for ts in 2..=5 {
bars.push(c(155.0, 151.0, ts));
}
bars
}
#[test]
fn accessors_and_metadata() {
let indicator = FibTimeZones::new();
assert_eq!(indicator.name(), "FibTimeZones");
assert_eq!(indicator.warmup_period(), 2);
assert!(!indicator.is_ready());
assert!(!FibTimeZones::default().is_ready());
}
#[test]
fn no_output_before_first_pivot() {
let mut indicator = FibTimeZones::new();
// The bootstrap bar confirms nothing.
assert!(indicator.update(c(200.0, 199.0, 0)).is_none());
assert!(!indicator.is_ready());
}
#[test]
fn flags_zones_and_counts_to_next() {
let mut indicator = FibTimeZones::new();
let out: Vec<_> = anchored_run()
.into_iter()
.map(|x| indicator.update(x))
.collect();
assert!(out[0].is_none()); // bootstrap, no pivot yet
assert!(indicator.is_ready());
// out[i] is reported at current bar i; anchor at bar 0 → distance = i.
let d1 = out[1].unwrap(); // distance 1 → a zone
assert_relative_eq!(d1.on_zone, 1.0);
assert_relative_eq!(d1.bars_to_next, 1.0); // next zone at 2
let d4 = out[4].unwrap(); // distance 4 → not a zone
assert_relative_eq!(d4.on_zone, 0.0);
assert_relative_eq!(d4.bars_to_next, 1.0); // next zone at 5
let d5 = out[5].unwrap(); // distance 5 → a zone
assert_relative_eq!(d5.on_zone, 1.0);
assert_relative_eq!(d5.bars_to_next, 3.0); // next zone at 8
}
#[test]
fn reset_clears_state() {
let mut indicator = FibTimeZones::new();
for candle in anchored_run() {
let _ = indicator.update(candle);
}
assert!(indicator.is_ready());
indicator.reset();
assert!(!indicator.is_ready());
assert!(indicator.update(c(100.0, 99.5, 0)).is_none());
}
#[test]
fn batch_equals_streaming() {
let candles = anchored_run();
let mut a = FibTimeZones::new();
let mut b = FibTimeZones::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,186 @@
//! Fisher-transformed RSI.
use crate::error::Result;
use crate::indicators::rsi::Rsi;
use crate::traits::Indicator;
/// Fisher RSI — the Fisher transform applied to a normalised [`Rsi`](crate::Rsi).
///
/// The RSI is bounded in `[0, 100]` and its distribution piles up near the
/// middle, which blurs turning points. The Fisher transform reshapes a bounded
/// input toward a Gaussian, sharpening the extremes into clear, near-symmetric
/// peaks:
///
/// ```text
/// rsi = RSI(price, period) in [0, 100]
/// x = clamp((rsi - 50) / 50, ±0.999) normalise to (-1, 1)
/// Fisher = 0.5 * ln((1 + x) / (1 - x))
/// ```
///
/// The clamp keeps the logarithm finite when the RSI pins at `0` or `100`. The
/// output is unbounded but in practice oscillates in roughly `[-3, 3]`, with
/// sharp excursions marking momentum extremes. The first value lands with the
/// inner RSI, after `period + 1` inputs.
///
/// # Example
///
/// ```
/// use wickra_core::{FisherRsi, Indicator};
///
/// let mut indicator = FisherRsi::new(9).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// last = indicator.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct FisherRsi {
period: usize,
rsi: Rsi,
}
impl FisherRsi {
/// Construct a Fisher RSI with the given RSI period.
///
/// # Errors
///
/// Returns [`crate::Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
Ok(Self {
period,
rsi: Rsi::new(period)?,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for FisherRsi {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
let rsi = self.rsi.update(input)?;
let x = ((rsi - 50.0) / 50.0).clamp(-0.999, 0.999);
Some(0.5 * ((1.0 + x) / (1.0 - x)).ln())
}
fn reset(&mut self) {
self.rsi.reset();
}
fn warmup_period(&self) -> usize {
self.rsi.warmup_period()
}
fn is_ready(&self) -> bool {
self.rsi.is_ready()
}
fn name(&self) -> &'static str {
"FisherRSI"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_zero_period() {
assert!(FisherRsi::new(0).is_err());
}
/// Cover the const accessor `period` and the Indicator-impl `warmup_period`
/// + `name`.
#[test]
fn accessors_and_metadata() {
let f = FisherRsi::new(9).unwrap();
assert_eq!(f.period(), 9);
// RSI warmup is period + 1.
assert_eq!(f.warmup_period(), 10);
assert_eq!(f.name(), "FisherRSI");
}
#[test]
fn warmup_matches_rsi() {
let mut f = FisherRsi::new(3).unwrap();
// RSI(3) needs 4 inputs; the first three return None.
assert_eq!(f.update(1.0), None);
assert_eq!(f.update(2.0), None);
assert_eq!(f.update(3.0), None);
assert!(f.update(4.0).is_some());
}
#[test]
fn matches_fisher_of_rsi() {
// Fisher RSI must equal the Fisher transform of the standalone RSI.
let prices: Vec<f64> = (0..60)
.map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 8.0)
.collect();
let mut fr = FisherRsi::new(9).unwrap();
let mut rsi = Rsi::new(9).unwrap();
for (i, &p) in prices.iter().enumerate() {
let got = fr.update(p);
let want = rsi.update(p).map(|r| {
let x = ((r - 50.0) / 50.0).clamp(-0.999, 0.999);
0.5 * ((1.0 + x) / (1.0 - x)).ln()
});
assert_eq!(got.is_some(), want.is_some(), "readiness mismatch at {i}");
if let (Some(a), Some(b)) = (got, want) {
assert_relative_eq!(a, b, epsilon = 1e-12);
}
}
}
#[test]
fn strong_uptrend_is_positive() {
// A pure uptrend pins RSI near 100 -> x near +1 -> large positive Fisher.
let prices: Vec<f64> = (1..=40).map(f64::from).collect();
let mut f = FisherRsi::new(9).unwrap();
let last = f.batch(&prices).into_iter().flatten().last().unwrap();
assert!(
last > 1.0,
"strong uptrend should give a large positive value, got {last}"
);
}
#[test]
fn clamp_keeps_output_finite_at_extremes() {
// Monotonic rise pins RSI at 100; the clamp must keep Fisher finite.
let prices: Vec<f64> = (1..=30).map(f64::from).collect();
let mut f = FisherRsi::new(5).unwrap();
for v in f.batch(&prices).into_iter().flatten() {
assert!(v.is_finite(), "Fisher RSI must stay finite, got {v}");
}
}
#[test]
fn reset_clears_state() {
let mut f = FisherRsi::new(5).unwrap();
f.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
assert!(f.is_ready());
f.reset();
assert!(!f.is_ready());
assert_eq!(f.update(1.0), None);
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (1..=40)
.map(|i| 50.0 + (f64::from(i) * 0.5).sin() * 10.0)
.collect();
let mut a = FisherRsi::new(9).unwrap();
let mut b = FisherRsi::new(9).unwrap();
assert_eq!(
a.batch(&prices),
prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,162 @@
//! Flag / Pennant continuation chart pattern.
use crate::indicators::pattern_swing::{SwingTracker, SWING_THRESHOLD};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Maximum size of the consolidation swing relative to the pole for a
/// flag/pennant to qualify — the pullback must retrace less than half the pole.
const MAX_RETRACE_FRACTION: f64 = 0.5;
/// Flag / Pennant — a brief consolidation against a sharp prior move (the
/// "pole"), resolving in the pole's direction.
///
/// Built on confirmed swing pivots ([`SWING_THRESHOLD`] = 5%); evaluated from the
/// last three pivots `pole_start → pole_end → consolidation`:
///
/// ```text
/// pole = |pole_end pole_start| (the sharp impulse)
/// pullback = |consolidation pole_end| (the shallow counter-move)
/// qualifies when pullback < 0.5 · pole
/// bull flag : pole_end is a swing high → +1 (up-pole, continuation up)
/// bear flag : pole_end is a swing low → -1 (down-pole, continuation down)
/// ```
///
/// The detector fires on the bar that confirms the consolidation pivot (the flag
/// is complete; the breakout is expected to follow). Output is `+1.0` / `-1.0` /
/// `0.0`; never `None`.
#[derive(Debug, Clone)]
pub struct FlagPennant {
swing: SwingTracker,
has_emitted: bool,
}
impl FlagPennant {
/// Construct a new Flag / Pennant detector.
pub const fn new() -> Self {
Self {
swing: SwingTracker::new(SWING_THRESHOLD, 3),
has_emitted: false,
}
}
}
impl Default for FlagPennant {
fn default() -> Self {
Self::new()
}
}
impl Indicator for FlagPennant {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
self.has_emitted = true;
if !self.swing.update(candle) {
return Some(0.0);
}
let pivots = self.swing.pivots();
if pivots.len() < 3 {
return Some(0.0);
}
let n = pivots.len();
let pole_start = pivots[n - 3];
let pole_end = pivots[n - 2];
let consolidation = pivots[n - 1];
let pole = (pole_end.price - pole_start.price).abs();
let pullback = (consolidation.price - pole_end.price).abs();
if pole > 0.0 && pullback < MAX_RETRACE_FRACTION * pole {
// pole_end a high → up-pole → bull flag; a low → bear flag.
return Some(if pole_end.direction > 0.0 { 1.0 } else { -1.0 });
}
Some(0.0)
}
fn reset(&mut self) {
self.swing.reset();
self.has_emitted = false;
}
fn warmup_period(&self) -> usize {
// Three confirmed pivots; the earliest confirmation of the third is bar 4.
4
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"FlagPennant"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::indicators::pattern_swing::candles_for_pivots;
use crate::traits::BatchExt;
fn run(pivots: &[f64]) -> Vec<f64> {
let mut indicator = FlagPennant::new();
candles_for_pivots(pivots)
.into_iter()
.map(|c| indicator.update(c).unwrap())
.collect()
}
#[test]
fn accessors_and_metadata() {
let indicator = FlagPennant::new();
assert_eq!(indicator.name(), "FlagPennant");
assert_eq!(indicator.warmup_period(), 4);
assert!(!indicator.is_ready());
assert!(!FlagPennant::default().is_ready());
}
#[test]
fn bull_flag_is_plus_one() {
// Up-pole 100 → 140 (40), shallow pullback to 130 (10 < 20) → bull flag.
let out = run(&[150.0, 100.0, 140.0, 130.0]);
assert_eq!(*out.last().unwrap(), 1.0);
}
#[test]
fn bear_flag_is_minus_one() {
// Down-pole 140 → 100 (40), shallow pullback to 110 (10 < 20) → bear flag.
let out = run(&[140.0, 100.0, 110.0]);
assert_eq!(*out.last().unwrap(), -1.0);
}
#[test]
fn deep_pullback_is_not_a_flag() {
// Pole 100 → 140 (40) but pullback to 104 (36 > 20) → not a flag.
let out = run(&[150.0, 100.0, 140.0, 104.0]);
assert_eq!(*out.last().unwrap(), 0.0);
}
#[test]
fn reset_clears_state() {
let mut indicator = FlagPennant::new();
for c in candles_for_pivots(&[150.0, 100.0, 140.0]) {
let _ = indicator.update(c);
}
indicator.reset();
assert!(!indicator.is_ready());
let c = Candle::new(99.5, 100.0, 99.5, 99.5, 1.0, 0).unwrap();
assert_eq!(indicator.update(c), Some(0.0));
}
#[test]
fn batch_equals_streaming() {
let candles = candles_for_pivots(&[150.0, 100.0, 140.0, 130.0]);
let mut a = FlagPennant::new();
let mut b = FlagPennant::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,157 @@
//! Gartley harmonic pattern.
use crate::indicators::pattern_swing::{ratios_in, xabcd, SwingTracker, SWING_THRESHOLD};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Gartley — the classic 5-point (X-A-B-C-D) harmonic pattern, recognised from
/// confirmed swing pivots when the legs fall inside the Gartley Fibonacci
/// windows:
///
/// ```text
/// AB / XA ∈ [0.55, 0.70] (≈ 0.618 retracement of XA)
/// BC / AB ∈ [0.382, 0.886]
/// CD / BC ∈ [1.13, 1.618]
/// AD / XA ∈ [0.74, 0.84] (≈ 0.786 — the defining D completion)
/// ```
///
/// Output is `+1.0` when the terminal point D is a swing low (bullish
/// completion), `-1.0` when D is a swing high (bearish), and `0.0` otherwise;
/// never `None`. See `crates/wickra-core/src/indicators/gartley.rs`.
#[derive(Debug, Clone)]
pub struct Gartley {
swing: SwingTracker,
has_emitted: bool,
}
impl Gartley {
/// Construct a new Gartley detector.
pub const fn new() -> Self {
Self {
swing: SwingTracker::new(SWING_THRESHOLD, 5),
has_emitted: false,
}
}
}
impl Default for Gartley {
fn default() -> Self {
Self::new()
}
}
impl Indicator for Gartley {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
self.has_emitted = true;
if !self.swing.update(candle) {
return Some(0.0);
}
let pivots = self.swing.pivots();
if pivots.len() < 5 {
return Some(0.0);
}
let p = xabcd(pivots);
let xa = (p.a - p.x).abs();
let ab = (p.b - p.a).abs();
let bc = (p.c - p.b).abs();
let cd = (p.d - p.c).abs();
let ad = (p.d - p.a).abs();
let matched = ratios_in(&[
(ab / xa, 0.55, 0.70),
(bc / ab, 0.382, 0.886),
(cd / bc, 1.13, 1.618),
(ad / xa, 0.74, 0.84),
]);
if matched {
return Some(if p.bullish { 1.0 } else { -1.0 });
}
Some(0.0)
}
fn reset(&mut self) {
self.swing.reset();
self.has_emitted = false;
}
fn warmup_period(&self) -> usize {
6
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"Gartley"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::indicators::pattern_swing::candles_for_pivots;
use crate::traits::BatchExt;
fn run(pivots: &[f64]) -> Vec<f64> {
let mut indicator = Gartley::new();
candles_for_pivots(pivots)
.into_iter()
.map(|c| indicator.update(c).unwrap())
.collect()
}
#[test]
fn accessors_and_metadata() {
let indicator = Gartley::new();
assert_eq!(indicator.name(), "Gartley");
assert_eq!(indicator.warmup_period(), 6);
assert!(!indicator.is_ready());
assert!(!Gartley::default().is_ready());
}
#[test]
fn bullish_gartley_is_plus_one() {
let out = run(&[150.0, 100.0, 140.0, 115.3, 127.65, 108.56]);
assert_eq!(*out.last().unwrap(), 1.0);
assert!(out[..out.len() - 1].iter().all(|&x| x == 0.0));
}
#[test]
fn bearish_gartley_is_minus_one() {
let out = run(&[150.0, 110.0, 134.7, 122.35, 141.44]);
assert_eq!(*out.last().unwrap(), -1.0);
}
#[test]
fn out_of_ratio_does_not_trigger() {
// Five pivots but the D completion (AD/XA ≈ 0.25) is far from 0.786.
let out = run(&[150.0, 100.0, 140.0, 110.0, 135.0, 105.0]);
assert_eq!(*out.last().unwrap(), 0.0);
}
#[test]
fn reset_clears_state() {
let mut indicator = Gartley::new();
for c in candles_for_pivots(&[150.0, 100.0, 140.0]) {
let _ = indicator.update(c);
}
indicator.reset();
assert!(!indicator.is_ready());
let c = Candle::new(99.5, 100.0, 99.5, 99.5, 1.0, 0).unwrap();
assert_eq!(indicator.update(c), Some(0.0));
}
#[test]
fn batch_equals_streaming() {
let candles = candles_for_pivots(&[150.0, 100.0, 140.0, 115.3, 127.65, 108.56]);
let mut a = Gartley::new();
let mut b = Gartley::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,222 @@
//! Generalized DEMA (GD) — Tim Tillson's volume-factor double EMA.
use crate::error::{Error, Result};
use crate::indicators::ema::Ema;
use crate::traits::Indicator;
/// Generalized DEMA — the building block of Tillson's [`T3`](crate::T3),
/// exposed on its own.
///
/// ```text
/// GD = (1 + v) · EMA(price) v · EMA(EMA(price))
/// ```
///
/// where both EMAs share the same `period` and `v ∈ [0, 1]` is the *volume
/// factor*. `v` controls how much of the second-order lag correction is
/// applied:
///
/// - `v = 0` collapses GD to a plain [`Ema`](crate::Ema) (no correction).
/// - `v = 1` recovers the standard [`Dema`](crate::Dema) `2·EMA EMA(EMA)`.
/// - intermediate values (Tillson uses `0.7`) trade a little lag reduction for
/// less overshoot than DEMA.
///
/// Because the coefficients `(1 + v)` and `v` always sum to `1`, a constant
/// series maps to itself. The first output lands after `2·period 1` inputs —
/// EMA1 seeds at `period`, then EMA2 needs another `period 1` of EMA1's
/// outputs to seed, exactly like DEMA.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, GeneralizedDema};
///
/// let mut indicator = GeneralizedDema::new(5, 0.7).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// last = indicator.update(100.0 + f64::from(i));
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct GeneralizedDema {
ema1: Ema,
ema2: Ema,
period: usize,
v: f64,
}
impl GeneralizedDema {
/// Construct a generalized DEMA with the given `period` and volume factor
/// `v`.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `period == 0`, or
/// [`Error::InvalidPeriod`] if `v` is non-finite or outside `[0.0, 1.0]`.
pub fn new(period: usize, v: f64) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
if !v.is_finite() || !(0.0..=1.0).contains(&v) {
return Err(Error::InvalidPeriod {
message: "GD volume factor must be a finite value in [0.0, 1.0]",
});
}
Ok(Self {
ema1: Ema::new(period)?,
ema2: Ema::new(period)?,
period,
v,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
/// Configured volume factor `v`.
pub const fn volume_factor(&self) -> f64 {
self.v
}
}
impl Indicator for GeneralizedDema {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
let e1 = self.ema1.update(input)?;
let e2 = self.ema2.update(e1)?;
Some((1.0 + self.v) * e1 - self.v * e2)
}
fn reset(&mut self) {
self.ema1.reset();
self.ema2.reset();
}
fn warmup_period(&self) -> usize {
// EMA1 seeds at period, then EMA2 needs another (period - 1) values.
2 * self.period - 1
}
fn is_ready(&self) -> bool {
self.ema2.is_ready()
}
fn name(&self) -> &'static str {
"GD"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::indicators::Dema;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_zero_period() {
assert!(matches!(
GeneralizedDema::new(0, 0.7),
Err(Error::PeriodZero)
));
}
#[test]
fn rejects_invalid_volume_factor() {
assert!(matches!(
GeneralizedDema::new(5, -0.1),
Err(Error::InvalidPeriod { .. })
));
assert!(matches!(
GeneralizedDema::new(5, 1.5),
Err(Error::InvalidPeriod { .. })
));
assert!(matches!(
GeneralizedDema::new(5, f64::NAN),
Err(Error::InvalidPeriod { .. })
));
assert!(GeneralizedDema::new(5, 0.0).is_ok());
assert!(GeneralizedDema::new(5, 1.0).is_ok());
}
/// Cover the const accessors `period` + `volume_factor` and the
/// Indicator-impl `warmup_period` + `name`.
#[test]
fn accessors_and_metadata() {
let gd = GeneralizedDema::new(5, 0.7).unwrap();
assert_eq!(gd.period(), 5);
assert_relative_eq!(gd.volume_factor(), 0.7, epsilon = 1e-12);
// EMA1 seeds at 5, EMA2 needs another 4 -> 2*period - 1 = 9.
assert_eq!(gd.warmup_period(), 9);
assert_eq!(gd.name(), "GD");
}
#[test]
fn constant_series_yields_constant() {
let mut gd = GeneralizedDema::new(5, 0.7).unwrap();
let out = gd.batch(&[100.0_f64; 60]);
let last = out.iter().rev().flatten().next().unwrap();
assert_relative_eq!(*last, 100.0, epsilon = 1e-9);
}
#[test]
fn v_one_equals_dema() {
// GD with v = 1 is exactly the standard DEMA.
let prices: Vec<f64> = (1..=80)
.map(|i| (f64::from(i) * 0.3).sin() * 10.0 + 50.0)
.collect();
let mut gd = GeneralizedDema::new(7, 1.0).unwrap();
let mut dema = Dema::new(7).unwrap();
let gd_out = gd.batch(&prices);
let dema_out = dema.batch(&prices);
for (g, d) in gd_out.iter().zip(dema_out.iter()) {
assert_eq!(g.is_some(), d.is_some());
if let (Some(a), Some(b)) = (g, d) {
assert_relative_eq!(*a, *b, epsilon = 1e-9);
}
}
}
#[test]
fn v_zero_equals_ema() {
// GD with v = 0 is a plain EMA (no second-order correction).
let prices: Vec<f64> = (1..=60).map(|i| f64::from(i) * 0.5).collect();
let mut gd = GeneralizedDema::new(6, 0.0).unwrap();
let mut ema = Ema::new(6).unwrap();
let gd_out = gd.batch(&prices);
for (i, (g, p)) in gd_out.iter().zip(prices.iter()).enumerate() {
// GD(v=0) feeds EMA1 into EMA2 but outputs EMA1 alone (coefficient
// 1 on e1, 0 on e2); it is only ready once EMA2 is, so compare
// against a standalone EMA chained the same way.
let want = ema.update(*p).filter(|_| i + 1 >= gd.warmup_period());
if let (Some(a), Some(b)) = (g, want) {
assert_relative_eq!(*a, b, epsilon = 1e-9);
}
}
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (1..=80).map(|i| f64::from(i) * 0.5).collect();
let mut a = GeneralizedDema::new(7, 0.7).unwrap();
let mut b = GeneralizedDema::new(7, 0.7).unwrap();
assert_eq!(
a.batch(&prices),
prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut gd = GeneralizedDema::new(5, 0.7).unwrap();
gd.batch(&(1..=50).map(f64::from).collect::<Vec<_>>());
assert!(gd.is_ready());
gd.reset();
assert!(!gd.is_ready());
assert_eq!(gd.update(1.0), None);
}
}

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