Compare commits

...

17 Commits

Author SHA1 Message Date
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
102 changed files with 20373 additions and 127 deletions
+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
+75 -1
View File
@@ -7,6 +7,76 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [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
@@ -1168,7 +1238,11 @@ 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.5.0...HEAD
[Unreleased]: https://github.com/wickra-lib/wickra/compare/v0.5.4...HEAD
[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
+30
View File
@@ -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.5.0"
version = "0.5.4"
dependencies = [
"approx",
"criterion",
@@ -1878,7 +1878,7 @@ dependencies = [
[[package]]
name = "wickra-core"
version = "0.5.0"
version = "0.5.4"
dependencies = [
"approx",
"proptest",
@@ -1888,7 +1888,7 @@ dependencies = [
[[package]]
name = "wickra-data"
version = "0.5.0"
version = "0.5.4"
dependencies = [
"approx",
"csv",
@@ -1915,7 +1915,7 @@ dependencies = [
[[package]]
name = "wickra-node"
version = "0.5.0"
version = "0.5.4"
dependencies = [
"napi",
"napi-build",
@@ -1925,7 +1925,7 @@ dependencies = [
[[package]]
name = "wickra-python"
version = "0.5.0"
version = "0.5.4"
dependencies = [
"numpy",
"pyo3",
@@ -1934,7 +1934,7 @@ dependencies = [
[[package]]
name = "wickra-wasm"
version = "0.5.0"
version = "0.5.4"
dependencies = [
"console_error_panic_hook",
"js-sys",
+2 -2
View File
@@ -12,7 +12,7 @@ members = [
exclude = ["fuzz"]
[workspace.package]
version = "0.5.0"
version = "0.5.4"
authors = ["kingchenc <support@wickra.org>"]
edition = "2021"
rust-version = "1.86"
@@ -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.5.0" }
wickra-core = { path = "crates/wickra-core", version = "0.5.4" }
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.
+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.
+11 -6
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=339" 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=396" 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)
@@ -11,6 +11,7 @@
[![npm](https://img.shields.io/npm/v/wickra.svg?logo=npm&color=red)](https://www.npmjs.com/package/wickra)
[![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 339 indicators; start at the
every one of the 396 indicators; start at the
[indicators overview](https://docs.wickra.org/Indicators-Overview).
- **Reference** — [warmup periods](https://docs.wickra.org/Warmup-Periods),
[streaming vs batch](https://docs.wickra.org/Streaming-vs-Batch),
@@ -135,7 +136,7 @@ python -m benchmarks.compare_libraries
## Indicators
339 streaming-first indicators across twenty families. Every one passes the
396 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).
@@ -150,18 +151,22 @@ warmup) at [docs.wickra.org](https://docs.wickra.org/Indicators-Overview).
| 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, 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, 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 |
| 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), 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
@@ -240,7 +245,7 @@ A Python live-trading example using the public `websockets` package lives at
```
wickra/
├── crates/
│ ├── wickra-core/ core engine + all 339 indicators
│ ├── wickra-core/ core engine + all 396 indicators
│ ├── wickra/ top-level facade crate (publishes on crates.io) + benches/
│ └── wickra-data/ CSV reader, tick aggregator, live exchange feeds
├── bindings/
+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.
+96
View File
@@ -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).
@@ -28,6 +28,16 @@ function num(v) {
// --- Scalar indicators: update(value) vs batch(prices) ---
const scalarFactories = {
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),
@@ -297,6 +307,26 @@ 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) },
};
for (const [name, d] of Object.entries(candleScalar)) {
@@ -369,6 +399,16 @@ 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) },
};
for (const [name, d] of Object.entries(multi)) {
@@ -537,6 +577,7 @@ const pairFactories = {
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)) {
@@ -1100,6 +1141,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);
@@ -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));
});
+600
View File
@@ -349,6 +349,79 @@ export interface PnfColumnValue {
high: number
low: number
}
export interface SessionHighLowValue {
high: number
low: number
}
export interface SessionRangeValue {
asia: number
eu: number
us: number
}
export interface OvernightIntradayReturnValue {
overnight: number
intraday: number
}
export interface FibRetracementValue {
level0: number
level236: number
level382: number
level500: number
level618: number
level786: number
level1000: number
}
export interface FibExtensionValue {
level1272: number
level1414: number
level1618: number
level2000: number
level2618: number
}
export interface FibProjectionValue {
level618: number
level1000: number
level1618: number
level2618: number
}
export interface AutoFibValue {
level0: number
level236: number
level382: number
level500: number
level618: number
level786: number
level1000: number
}
export interface GoldenPocketValue {
low: number
mid: number
high: number
}
export interface FibConfluenceValue {
price: number
strength: number
}
export interface FibFanValue {
fan382: number
fan500: number
fan618: number
}
export interface FibArcsValue {
arc382: number
arc500: number
arc618: number
}
export interface FibChannelValue {
base: number
level618: number
level1000: number
level1618: number
}
export interface FibTimeZonesValue {
onZone: number
barsToNext: number
}
export type SmaNode = SMA
export declare class SMA {
constructor(period: number)
@@ -736,6 +809,96 @@ export declare class TSF {
isReady(): boolean
warmupPeriod(): number
}
export type LogReturnNode = LogReturn
export declare class LogReturn {
constructor(period: number)
update(value: number): number | null
batch(prices: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type RealizedVolatilityNode = RealizedVolatility
export declare class RealizedVolatility {
constructor(period: number)
update(value: number): number | null
batch(prices: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type RollingIqrNode = RollingIqr
export declare class RollingIqr {
constructor(period: number)
update(value: number): number | null
batch(prices: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type RollingPercentileRankNode = RollingPercentileRank
export declare class RollingPercentileRank {
constructor(period: number)
update(value: number): number | null
batch(prices: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type TrendLabelNode = TrendLabel
export declare class TrendLabel {
constructor(period: number)
update(value: number): number | null
batch(prices: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type WinRateNode = WinRate
export declare class WinRate {
constructor(period: number)
update(value: number): number | null
batch(prices: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type ExpectancyNode = Expectancy
export declare class Expectancy {
constructor(period: number)
update(value: number): number | null
batch(prices: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type JumpIndicatorNode = JumpIndicator
export declare class JumpIndicator {
constructor(period: number, threshold: number)
update(value: number): number | null
batch(prices: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type RegimeLabelNode = RegimeLabel
export declare class RegimeLabel {
constructor(volPeriod: number, lookback: number)
update(value: number): number | null
batch(prices: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type RollingQuantileNode = RollingQuantile
export declare class RollingQuantile {
constructor(period: number, quantile: number)
update(value: number): number | null
batch(prices: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type AutocorrelationNode = Autocorrelation
export declare class Autocorrelation {
constructor(period: number, lag: number)
@@ -793,6 +956,19 @@ export declare class PairwiseBeta {
isReady(): boolean
warmupPeriod(): number
}
export type SpreadAr1CoefficientNode = SpreadAr1Coefficient
export declare class SpreadAr1Coefficient {
constructor(period: number)
update(x: number, y: number): number | null
/**
* Batch over two equally-sized arrays. Returns a length-`n` array
* with `NaN` for warmup positions.
*/
batch(x: Array<number>, y: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type SpearmanCorrelationNode = SpearmanCorrelation
export declare class SpearmanCorrelation {
constructor(period: number)
@@ -1157,6 +1333,42 @@ export declare class HT_PHASOR {
isReady(): boolean
warmupPeriod(): number
}
export type CloseVsOpenNode = CloseVsOpen
export declare class CloseVsOpen {
constructor()
update(open: number, high: number, low: number, close: number): number | null
batch(open: Array<number>, high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type BodySizePctNode = BodySizePct
export declare class BodySizePct {
constructor()
update(open: number, high: number, low: number, close: number): number | null
batch(open: Array<number>, high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type WickRatioNode = WickRatio
export declare class WickRatio {
constructor()
update(open: number, high: number, low: number, close: number): number | null
batch(open: Array<number>, high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type HighLowRangeNode = HighLowRange
export declare class HighLowRange {
constructor()
update(open: number, high: number, low: number, close: number): number | null
batch(open: Array<number>, high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type StochNode = Stochastic
export declare class Stochastic {
constructor(kPeriod: number, dPeriod: number)
@@ -3019,6 +3231,150 @@ export declare class ConcealingBabySwallow {
isReady(): boolean
warmupPeriod(): number
}
export type DoubleTopBottomNode = DoubleTopBottom
export declare class DoubleTopBottom {
constructor()
update(open: number, high: number, low: number, close: number): number | null
batch(open: Array<number>, high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type TripleTopBottomNode = TripleTopBottom
export declare class TripleTopBottom {
constructor()
update(open: number, high: number, low: number, close: number): number | null
batch(open: Array<number>, high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type HeadAndShouldersNode = HeadAndShoulders
export declare class HeadAndShoulders {
constructor()
update(open: number, high: number, low: number, close: number): number | null
batch(open: Array<number>, high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type TriangleNode = Triangle
export declare class Triangle {
constructor()
update(open: number, high: number, low: number, close: number): number | null
batch(open: Array<number>, high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type WedgeNode = Wedge
export declare class Wedge {
constructor()
update(open: number, high: number, low: number, close: number): number | null
batch(open: Array<number>, high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type FlagPennantNode = FlagPennant
export declare class FlagPennant {
constructor()
update(open: number, high: number, low: number, close: number): number | null
batch(open: Array<number>, high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type RectangleRangeNode = RectangleRange
export declare class RectangleRange {
constructor()
update(open: number, high: number, low: number, close: number): number | null
batch(open: Array<number>, high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type CupAndHandleNode = CupAndHandle
export declare class CupAndHandle {
constructor()
update(open: number, high: number, low: number, close: number): number | null
batch(open: Array<number>, high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type AbcdNode = Abcd
export declare class Abcd {
constructor()
update(open: number, high: number, low: number, close: number): number | null
batch(open: Array<number>, high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type GartleyNode = Gartley
export declare class Gartley {
constructor()
update(open: number, high: number, low: number, close: number): number | null
batch(open: Array<number>, high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type ButterflyNode = Butterfly
export declare class Butterfly {
constructor()
update(open: number, high: number, low: number, close: number): number | null
batch(open: Array<number>, high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type BatNode = Bat
export declare class Bat {
constructor()
update(open: number, high: number, low: number, close: number): number | null
batch(open: Array<number>, high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type CrabNode = Crab
export declare class Crab {
constructor()
update(open: number, high: number, low: number, close: number): number | null
batch(open: Array<number>, high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type SharkNode = Shark
export declare class Shark {
constructor()
update(open: number, high: number, low: number, close: number): number | null
batch(open: Array<number>, high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type CypherNode = Cypher
export declare class Cypher {
constructor()
update(open: number, high: number, low: number, close: number): number | null
batch(open: Array<number>, high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type ThreeDrivesNode = ThreeDrives
export declare class ThreeDrives {
constructor()
update(open: number, high: number, low: number, close: number): number | null
batch(open: Array<number>, high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type OrderBookImbalanceTop1Node = OrderBookImbalanceTop1
export declare class OrderBookImbalanceTop1 {
constructor()
@@ -3100,6 +3456,42 @@ export declare class TradeImbalance {
isReady(): boolean
warmupPeriod(): number
}
export type OrderFlowImbalanceNode = OrderFlowImbalance
export declare class OrderFlowImbalance {
constructor(period: number)
update(bidPx: Array<number>, bidSz: Array<number>, askPx: Array<number>, askSz: Array<number>): number | null
batch(snapshots: Array<ObSnapshot>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type VpinNode = Vpin
export declare class Vpin {
constructor(bucketVolume: number, numBuckets: number)
update(price: number, size: number, isBuy: boolean): number | null
batch(price: Array<number>, size: Array<number>, isBuy: Array<boolean>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type AmihudIlliquidityNode = AmihudIlliquidity
export declare class AmihudIlliquidity {
constructor(period: number)
update(price: number, size: number, isBuy: boolean): number | null
batch(price: Array<number>, size: Array<number>, isBuy: Array<boolean>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type RollMeasureNode = RollMeasure
export declare class RollMeasure {
constructor(period: number)
update(price: number, size: number, isBuy: boolean): number | null
batch(price: Array<number>, size: Array<number>, isBuy: Array<boolean>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type EffectiveSpreadNode = EffectiveSpread
export declare class EffectiveSpread {
constructor()
@@ -3557,3 +3949,211 @@ export declare class Alpha {
isReady(): boolean
warmupPeriod(): number
}
export type SessionVwapNode = SessionVwap
export declare class SessionVwap {
constructor(utcOffsetMinutes: number)
update(open: number, high: number, low: number, close: number, volume: number, timestamp: number): number | null
batch(open: Array<number>, high: Array<number>, low: Array<number>, close: Array<number>, volume: Array<number>, timestamp: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
utcOffsetMinutes(): number
}
export type OvernightGapNode = OvernightGap
export declare class OvernightGap {
constructor(utcOffsetMinutes: number)
update(open: number, high: number, low: number, close: number, volume: number, timestamp: number): number | null
batch(open: Array<number>, high: Array<number>, low: Array<number>, close: Array<number>, volume: Array<number>, timestamp: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
utcOffsetMinutes(): number
}
export type SeasonalZScoreNode = SeasonalZScore
export declare class SeasonalZScore {
constructor(utcOffsetMinutes: number)
update(open: number, high: number, low: number, close: number, volume: number, timestamp: number): number | null
batch(open: Array<number>, high: Array<number>, low: Array<number>, close: Array<number>, volume: Array<number>, timestamp: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
utcOffsetMinutes(): number
}
export type TimeOfDayReturnProfileNode = TimeOfDayReturnProfile
export declare class TimeOfDayReturnProfile {
constructor(buckets: number, utcOffsetMinutes: number)
update(open: number, high: number, low: number, close: number, volume: number, timestamp: number): Array<number> | null
batch(open: Array<number>, high: Array<number>, low: Array<number>, close: Array<number>, volume: Array<number>, timestamp: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
buckets(): number
utcOffsetMinutes(): number
}
export type IntradayVolatilityProfileNode = IntradayVolatilityProfile
export declare class IntradayVolatilityProfile {
constructor(buckets: number, utcOffsetMinutes: number)
update(open: number, high: number, low: number, close: number, volume: number, timestamp: number): Array<number> | null
batch(open: Array<number>, high: Array<number>, low: Array<number>, close: Array<number>, volume: Array<number>, timestamp: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
buckets(): number
utcOffsetMinutes(): number
}
export type VolumeByTimeProfileNode = VolumeByTimeProfile
export declare class VolumeByTimeProfile {
constructor(buckets: number, utcOffsetMinutes: number)
update(open: number, high: number, low: number, close: number, volume: number, timestamp: number): Array<number> | null
batch(open: Array<number>, high: Array<number>, low: Array<number>, close: Array<number>, volume: Array<number>, timestamp: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
buckets(): number
utcOffsetMinutes(): number
}
export type DayOfWeekProfileNode = DayOfWeekProfile
export declare class DayOfWeekProfile {
constructor(utcOffsetMinutes: number)
update(open: number, high: number, low: number, close: number, volume: number, timestamp: number): Array<number> | null
batch(open: Array<number>, high: Array<number>, low: Array<number>, close: Array<number>, volume: Array<number>, timestamp: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
utcOffsetMinutes(): number
}
export type AverageDailyRangeNode = AverageDailyRange
export declare class AverageDailyRange {
constructor(period: number, utcOffsetMinutes: number)
update(open: number, high: number, low: number, close: number, volume: number, timestamp: number): number | null
batch(open: Array<number>, high: Array<number>, low: Array<number>, close: Array<number>, volume: Array<number>, timestamp: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type TurnOfMonthNode = TurnOfMonth
export declare class TurnOfMonth {
constructor(nFirst: number, nLast: number, utcOffsetMinutes: number)
update(open: number, high: number, low: number, close: number, volume: number, timestamp: number): number | null
batch(open: Array<number>, high: Array<number>, low: Array<number>, close: Array<number>, volume: Array<number>, timestamp: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type SessionHighLowNode = SessionHighLow
export declare class SessionHighLow {
constructor(utcOffsetMinutes: number)
update(open: number, high: number, low: number, close: number, volume: number, timestamp: number): SessionHighLowValue | null
batch(open: Array<number>, high: Array<number>, low: Array<number>, close: Array<number>, volume: Array<number>, timestamp: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type SessionRangeNode = SessionRange
export declare class SessionRange {
constructor(utcOffsetMinutes: number)
update(open: number, high: number, low: number, close: number, volume: number, timestamp: number): SessionRangeValue | null
batch(open: Array<number>, high: Array<number>, low: Array<number>, close: Array<number>, volume: Array<number>, timestamp: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type OvernightIntradayReturnNode = OvernightIntradayReturn
export declare class OvernightIntradayReturn {
constructor(utcOffsetMinutes: number)
update(open: number, high: number, low: number, close: number, volume: number, timestamp: number): OvernightIntradayReturnValue | null
batch(open: Array<number>, high: Array<number>, low: Array<number>, close: Array<number>, volume: Array<number>, timestamp: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type FibRetracementNode = FibRetracement
export declare class FibRetracement {
constructor()
update(high: number, low: number): FibRetracementValue | null
batch(high: Array<number>, low: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type FibExtensionNode = FibExtension
export declare class FibExtension {
constructor()
update(high: number, low: number): FibExtensionValue | null
batch(high: Array<number>, low: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type FibProjectionNode = FibProjection
export declare class FibProjection {
constructor()
update(high: number, low: number): FibProjectionValue | null
batch(high: Array<number>, low: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type AutoFibNode = AutoFib
export declare class AutoFib {
constructor()
update(high: number, low: number): AutoFibValue | null
batch(high: Array<number>, low: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type GoldenPocketNode = GoldenPocket
export declare class GoldenPocket {
constructor()
update(high: number, low: number): GoldenPocketValue | null
batch(high: Array<number>, low: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type FibConfluenceNode = FibConfluence
export declare class FibConfluence {
constructor()
update(high: number, low: number): FibConfluenceValue | null
batch(high: Array<number>, low: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type FibFanNode = FibFan
export declare class FibFan {
constructor()
update(high: number, low: number): FibFanValue | null
batch(high: Array<number>, low: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type FibArcsNode = FibArcs
export declare class FibArcs {
constructor()
update(high: number, low: number): FibArcsValue | null
batch(high: Array<number>, low: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type FibChannelNode = FibChannel
export declare class FibChannel {
constructor()
update(high: number, low: number): FibChannelValue | null
batch(high: Array<number>, low: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type FibTimeZonesNode = FibTimeZones
export declare class FibTimeZones {
constructor()
update(high: number, low: number): FibTimeZonesValue | null
batch(high: Array<number>, low: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
+58 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "wickra-darwin-arm64",
"version": "0.5.0",
"version": "0.5.4",
"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": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "wickra-darwin-x64",
"version": "0.5.0",
"version": "0.5.4",
"description": "Native binding for wickra (macOS Intel). Installed automatically as an optional dependency of wickra on matching platforms.",
"main": "wickra.darwin-x64.node",
"files": [
@@ -1,6 +1,6 @@
{
"name": "wickra-linux-arm64-gnu",
"version": "0.5.0",
"version": "0.5.4",
"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": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "wickra-linux-x64-gnu",
"version": "0.5.0",
"version": "0.5.4",
"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": [
@@ -1,6 +1,6 @@
{
"name": "wickra-win32-arm64-msvc",
"version": "0.5.0",
"version": "0.5.4",
"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": [
@@ -1,6 +1,6 @@
{
"name": "wickra-win32-x64-msvc",
"version": "0.5.0",
"version": "0.5.4",
"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": [
+20 -20
View File
@@ -1,12 +1,12 @@
{
"name": "wickra",
"version": "0.5.0",
"version": "0.5.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "wickra",
"version": "0.5.0",
"version": "0.5.4",
"license": "MIT OR Apache-2.0",
"devDependencies": {
"@napi-rs/cli": "^2.18.0"
@@ -15,12 +15,12 @@
"node": ">= 18"
},
"optionalDependencies": {
"wickra-darwin-arm64": "0.5.0",
"wickra-darwin-x64": "0.5.0",
"wickra-linux-arm64-gnu": "0.5.0",
"wickra-linux-x64-gnu": "0.5.0",
"wickra-win32-arm64-msvc": "0.5.0",
"wickra-win32-x64-msvc": "0.5.0"
"wickra-darwin-arm64": "0.5.4",
"wickra-darwin-x64": "0.5.4",
"wickra-linux-arm64-gnu": "0.5.4",
"wickra-linux-x64-gnu": "0.5.4",
"wickra-win32-arm64-msvc": "0.5.4",
"wickra-win32-x64-msvc": "0.5.4"
}
},
"node_modules/@napi-rs/cli": {
@@ -41,8 +41,8 @@
}
},
"node_modules/wickra-darwin-arm64": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/wickra-darwin-arm64/-/wickra-darwin-arm64-0.5.0.tgz",
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/wickra-darwin-arm64/-/wickra-darwin-arm64-0.5.4.tgz",
"integrity": "sha512-4eZiBR/yGUdr4nzhEUFy2i69XgNx64iI2ax/LPamsThgylC0KpHOZKK19QzJ2d9KbK4C8nMjME5FLuR+4GNEwQ==",
"cpu": [
"arm64"
@@ -57,8 +57,8 @@
}
},
"node_modules/wickra-darwin-x64": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/wickra-darwin-x64/-/wickra-darwin-x64-0.5.0.tgz",
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/wickra-darwin-x64/-/wickra-darwin-x64-0.5.4.tgz",
"integrity": "sha512-6hf8zI3QPjTFp4zCpmgUwDvNtu6jHqNUHKD5e55POo0CgA52HkpyxSPtVm8TGTIZDI7kPjlbOdBM8CJ76mmXwA==",
"cpu": [
"x64"
@@ -73,8 +73,8 @@
}
},
"node_modules/wickra-linux-arm64-gnu": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/wickra-linux-arm64-gnu/-/wickra-linux-arm64-gnu-0.5.0.tgz",
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/wickra-linux-arm64-gnu/-/wickra-linux-arm64-gnu-0.5.4.tgz",
"integrity": "sha512-kSe6y0xBMSiqdPLXNjwop5WZdHtvdBNKSEBCwZ4hFq33p4apW25/wrlzv9/oDuyD4kuPabJEhCCnFOplh58CUg==",
"cpu": [
"arm64"
@@ -89,8 +89,8 @@
}
},
"node_modules/wickra-linux-x64-gnu": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/wickra-linux-x64-gnu/-/wickra-linux-x64-gnu-0.5.0.tgz",
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/wickra-linux-x64-gnu/-/wickra-linux-x64-gnu-0.5.4.tgz",
"integrity": "sha512-tWBWS4qz7hxM4xnpFb59bhf6TaLwXq0Z3jEa/2l7r8PiHA94g8r8S53NRMiT+4yiL5hSWe/nUiC/YXdRrhEZ4g==",
"cpu": [
"x64"
@@ -105,8 +105,8 @@
}
},
"node_modules/wickra-win32-arm64-msvc": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/wickra-win32-arm64-msvc/-/wickra-win32-arm64-msvc-0.5.0.tgz",
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/wickra-win32-arm64-msvc/-/wickra-win32-arm64-msvc-0.5.4.tgz",
"integrity": "sha512-EXIckHxAtF75PUGDKRzXyqMe9ldP0JjSdu68WFN6iJfp+McYrGu6h40TEJlQ/oUEIoPqiZB/xhVyo/el5Lg7zw==",
"cpu": [
"arm64"
@@ -121,8 +121,8 @@
}
},
"node_modules/wickra-win32-x64-msvc": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/wickra-win32-x64-msvc/-/wickra-win32-x64-msvc-0.5.0.tgz",
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/wickra-win32-x64-msvc/-/wickra-win32-x64-msvc-0.5.4.tgz",
"integrity": "sha512-Yfsqq1Xwp6hdxMyLze411vNdo7BDwI6+lPSe7A9XdqyPecNDbtKwYLpsal2r8EHbNzqM+R8XnuRtUaEQS5VlUQ==",
"cpu": [
"x64"
+7 -7
View File
@@ -1,6 +1,6 @@
{
"name": "wickra",
"version": "0.5.0",
"version": "0.5.4",
"description": "Streaming-first technical indicators: incremental, fast, install-free. Node bindings powered by Rust.",
"author": "kingchenc <support@wickra.org>",
"main": "index.js",
@@ -47,12 +47,12 @@
"node": ">= 18"
},
"optionalDependencies": {
"wickra-linux-x64-gnu": "0.5.0",
"wickra-linux-arm64-gnu": "0.5.0",
"wickra-darwin-x64": "0.5.0",
"wickra-darwin-arm64": "0.5.0",
"wickra-win32-x64-msvc": "0.5.0",
"wickra-win32-arm64-msvc": "0.5.0"
"wickra-linux-x64-gnu": "0.5.4",
"wickra-linux-arm64-gnu": "0.5.4",
"wickra-darwin-x64": "0.5.4",
"wickra-darwin-arm64": "0.5.4",
"wickra-win32-x64-msvc": "0.5.4",
"wickra-win32-arm64-msvc": "0.5.4"
},
"scripts": {
"build": "napi build --platform --release",
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "maturin"
[project]
name = "wickra"
version = "0.5.0"
version = "0.5.4"
description = "Streaming-first technical indicators: incremental, fast, install-free."
readme = "README.md"
license = "MIT OR Apache-2.0"
+122
View File
@@ -25,6 +25,20 @@ from __future__ import annotations
from ._wickra import (
__version__,
Expectancy,
WinRate,
RegimeLabel,
JumpIndicator,
TrendLabel,
HighLowRange,
WickRatio,
BodySizePct,
CloseVsOpen,
RollingQuantile,
RollingPercentileRank,
RollingIqr,
RealizedVolatility,
LogReturn,
TSF,
LINEARREG_INTERCEPT,
ROCR100,
@@ -189,6 +203,7 @@ from ._wickra import (
PearsonCorrelation,
Beta,
PairwiseBeta,
SpreadAr1Coefficient,
PairSpreadZScore,
LeadLagCrossCorrelation,
Cointegration,
@@ -321,7 +336,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,
@@ -329,6 +374,9 @@ from ._wickra import (
QuotedSpread,
DepthSlope,
# Microstructure: trade flow
RollMeasure,
AmihudIlliquidity,
Vpin,
SignedVolume,
CumulativeVolumeDelta,
TradeImbalance,
@@ -385,9 +433,36 @@ from ._wickra import (
TreynorRatio,
InformationRatio,
Alpha,
# Seasonality & Session
SessionVwap,
SessionHighLow,
SessionRange,
AverageDailyRange,
OvernightGap,
OvernightIntradayReturn,
TurnOfMonth,
SeasonalZScore,
TimeOfDayReturnProfile,
DayOfWeekProfile,
IntradayVolatilityProfile,
VolumeByTimeProfile,
)
__all__ = [
"Expectancy",
"WinRate",
"RegimeLabel",
"JumpIndicator",
"TrendLabel",
"HighLowRange",
"WickRatio",
"BodySizePct",
"CloseVsOpen",
"RollingQuantile",
"RollingPercentileRank",
"RollingIqr",
"RealizedVolatility",
"LogReturn",
"TSF",
"LINEARREG_INTERCEPT",
"ROCR100",
@@ -553,6 +628,7 @@ __all__ = [
"PearsonCorrelation",
"Beta",
"PairwiseBeta",
"SpreadAr1Coefficient",
"PairSpreadZScore",
"LeadLagCrossCorrelation",
"Cointegration",
@@ -685,7 +761,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",
@@ -693,6 +799,9 @@ __all__ = [
"QuotedSpread",
"DepthSlope",
# Microstructure: trade flow
"RollMeasure",
"AmihudIlliquidity",
"Vpin",
"SignedVolume",
"CumulativeVolumeDelta",
"TradeImbalance",
@@ -749,4 +858,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
@@ -45,6 +45,16 @@ def ohlcv() -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
# --- Scalar (f64 -> f64) indicators ---------------------------------------
SCALAR = [
(ta.Expectancy, (20,)),
(ta.WinRate, (20,)),
(ta.RegimeLabel, (5, 20)),
(ta.JumpIndicator, (20, 3.0)),
(ta.TrendLabel, (10,)),
(ta.RollingQuantile, (20, 0.5)),
(ta.RollingPercentileRank, (14,)),
(ta.RollingIqr, (14,)),
(ta.RealizedVolatility, (20,)),
(ta.LogReturn, (1,)),
(ta.TSF, (14,)),
(ta.LINEARREG_INTERCEPT, (14,)),
(ta.ROCR100, (10,)),
@@ -167,6 +177,7 @@ def test_scalar_streaming_matches_batch(cls, args, sine_prices):
# --- Two-series (asset, benchmark) indicators -----------------------------
PAIR = [
(ta.SpreadAr1Coefficient, (40,)),
(ta.GrangerCausality, (60, 1)),
(ta.VarianceRatio, (60, 2)),
(ta.BetaNeutralSpread, (20,)),
@@ -330,6 +341,76 @@ def test_relative_strength_streaming_matches_batch():
# 6-tuple candle; the batch helper takes only the columns it needs.
CANDLE_SCALAR = {
# Per-bar OHLC transforms (open matters). The streaming harness feeds
# open == close, so batch passes the close column in for open to match.
"HighLowRange": (lambda: ta.HighLowRange(), lambda ind, h, l, c, v: ind.batch(c, h, l, c)),
"WickRatio": (lambda: ta.WickRatio(), lambda ind, h, l, c, v: ind.batch(c, h, l, c)),
"BodySizePct": (lambda: ta.BodySizePct(), lambda ind, h, l, c, v: ind.batch(c, h, l, c)),
"CloseVsOpen": (lambda: ta.CloseVsOpen(), lambda ind, h, l, c, v: ind.batch(c, h, l, c)),
"ThreeDrives": (
lambda: ta.ThreeDrives(),
lambda ind, h, l, c, v: ind.batch(c, h, l, c),
),
"Cypher": (
lambda: ta.Cypher(),
lambda ind, h, l, c, v: ind.batch(c, h, l, c),
),
"Shark": (
lambda: ta.Shark(),
lambda ind, h, l, c, v: ind.batch(c, h, l, c),
),
"Crab": (
lambda: ta.Crab(),
lambda ind, h, l, c, v: ind.batch(c, h, l, c),
),
"Bat": (
lambda: ta.Bat(),
lambda ind, h, l, c, v: ind.batch(c, h, l, c),
),
"Butterfly": (
lambda: ta.Butterfly(),
lambda ind, h, l, c, v: ind.batch(c, h, l, c),
),
"Gartley": (
lambda: ta.Gartley(),
lambda ind, h, l, c, v: ind.batch(c, h, l, c),
),
"Abcd": (
lambda: ta.Abcd(),
lambda ind, h, l, c, v: ind.batch(c, h, l, c),
),
"CupAndHandle": (
lambda: ta.CupAndHandle(),
lambda ind, h, l, c, v: ind.batch(c, h, l, c),
),
"RectangleRange": (
lambda: ta.RectangleRange(),
lambda ind, h, l, c, v: ind.batch(c, h, l, c),
),
"FlagPennant": (
lambda: ta.FlagPennant(),
lambda ind, h, l, c, v: ind.batch(c, h, l, c),
),
"Wedge": (
lambda: ta.Wedge(),
lambda ind, h, l, c, v: ind.batch(c, h, l, c),
),
"Triangle": (
lambda: ta.Triangle(),
lambda ind, h, l, c, v: ind.batch(c, h, l, c),
),
"HeadAndShoulders": (
lambda: ta.HeadAndShoulders(),
lambda ind, h, l, c, v: ind.batch(c, h, l, c),
),
"TripleTopBottom": (
lambda: ta.TripleTopBottom(),
lambda ind, h, l, c, v: ind.batch(c, h, l, c),
),
"DoubleTopBottom": (
lambda: ta.DoubleTopBottom(),
lambda ind, h, l, c, v: ind.batch(c, h, l, c),
),
"MIDPRICE": (lambda: ta.MIDPRICE(14), lambda ind, h, l, c, v: ind.batch(h, l, c)),
"AVGPRICE": (lambda: ta.AVGPRICE(), lambda ind, h, l, c, v: ind.batch(c, h, l, c)),
"DX": (lambda: ta.DX(14), lambda ind, h, l, c, v: ind.batch(h, l, c)),
@@ -796,6 +877,56 @@ def test_candle_scalar_streaming_matches_batch(name, ohlcv):
# --- Candle-input, multi-output indicators --------------------------------
MULTI = {
"FibFan": (
lambda: ta.FibFan(),
lambda ind, h, l, c, v: ind.batch(h, l),
3,
),
"FibArcs": (
lambda: ta.FibArcs(),
lambda ind, h, l, c, v: ind.batch(h, l),
3,
),
"FibChannel": (
lambda: ta.FibChannel(),
lambda ind, h, l, c, v: ind.batch(h, l),
4,
),
"FibTimeZones": (
lambda: ta.FibTimeZones(),
lambda ind, h, l, c, v: ind.batch(h, l),
2,
),
"FibRetracement": (
lambda: ta.FibRetracement(),
lambda ind, h, l, c, v: ind.batch(h, l),
7,
),
"FibExtension": (
lambda: ta.FibExtension(),
lambda ind, h, l, c, v: ind.batch(h, l),
5,
),
"FibProjection": (
lambda: ta.FibProjection(),
lambda ind, h, l, c, v: ind.batch(h, l),
4,
),
"AutoFib": (
lambda: ta.AutoFib(),
lambda ind, h, l, c, v: ind.batch(h, l),
7,
),
"GoldenPocket": (
lambda: ta.GoldenPocket(),
lambda ind, h, l, c, v: ind.batch(h, l),
3,
),
"FibConfluence": (
lambda: ta.FibConfluence(),
lambda ind, h, l, c, v: ind.batch(h, l),
2,
),
"Vortex": (
lambda: ta.Vortex(14),
lambda ind, h, l, c, v: ind.batch(h, l, c),
@@ -2354,6 +2485,255 @@ def test_granger_causality_reference():
assert t.update(1.0, 1.0) is None
assert t.update(2.0, 1.5) is None
def test_double_top_bottom_reference():
t = ta.DoubleTopBottom()
assert t.update((119.88, 120.0, 119.88, 119.88, 1.0, 0)) == pytest.approx(0.0)
assert t.update((100.0, 118.8, 100.0, 100.0, 1.0, 1)) == pytest.approx(0.0)
assert t.update((101.0, 120.0, 101.0, 101.0, 1.0, 2)) == pytest.approx(0.0)
assert t.update((108.0, 118.8, 108.0, 108.0, 1.0, 3)) == pytest.approx(-1.0)
def test_triple_top_bottom_reference():
t = ta.TripleTopBottom()
assert t.update((119.88, 120.0, 119.88, 119.88, 1.0, 0)) == pytest.approx(0.0)
assert t.update((100.0, 118.8, 100.0, 100.0, 1.0, 1)) == pytest.approx(0.0)
assert t.update((101.0, 121.0, 101.0, 101.0, 1.0, 2)) == pytest.approx(0.0)
assert t.update((99.0, 119.79, 99.0, 99.0, 1.0, 3)) == pytest.approx(0.0)
assert t.update((99.99, 119.0, 99.99, 99.99, 1.0, 4)) == pytest.approx(0.0)
assert t.update((107.1, 117.81, 107.1, 107.1, 1.0, 5)) == pytest.approx(-1.0)
def test_head_and_shoulders_reference():
t = ta.HeadAndShoulders()
assert t.update((99.9, 100.0, 99.9, 99.9, 1.0, 0)) == pytest.approx(0.0)
assert t.update((90.0, 99.0, 90.0, 90.0, 1.0, 1)) == pytest.approx(0.0)
assert t.update((90.9, 120.0, 90.9, 90.9, 1.0, 2)) == pytest.approx(0.0)
assert t.update((92.0, 118.8, 92.0, 92.0, 1.0, 3)) == pytest.approx(0.0)
assert t.update((92.92, 101.0, 92.92, 92.92, 1.0, 4)) == pytest.approx(0.0)
assert t.update((90.9, 99.99, 90.9, 90.9, 1.0, 5)) == pytest.approx(-1.0)
def test_triangle_reference():
t = ta.Triangle()
assert t.update((129.87, 130.0, 129.87, 129.87, 1.0, 0)) == pytest.approx(0.0)
assert t.update((100.0, 128.7, 100.0, 100.0, 1.0, 1)) == pytest.approx(0.0)
assert t.update((101.0, 120.0, 101.0, 101.0, 1.0, 2)) == pytest.approx(0.0)
assert t.update((110.0, 118.8, 110.0, 110.0, 1.0, 3)) == pytest.approx(0.0)
assert t.update((111.1, 120.0, 111.1, 111.1, 1.0, 4)) == pytest.approx(1.0)
assert t.update((108.0, 118.8, 108.0, 108.0, 1.0, 5)) == pytest.approx(1.0)
def test_wedge_reference():
t = ta.Wedge()
assert t.update((109.89, 110.0, 109.89, 109.89, 1.0, 0)) == pytest.approx(0.0)
assert t.update((90.0, 108.9, 90.0, 90.0, 1.0, 1)) == pytest.approx(0.0)
assert t.update((90.9, 100.0, 90.9, 90.9, 1.0, 2)) == pytest.approx(0.0)
assert t.update((94.0, 99.0, 94.0, 94.0, 1.0, 3)) == pytest.approx(0.0)
assert t.update((94.94, 103.0, 94.94, 94.94, 1.0, 4)) == pytest.approx(0.0)
assert t.update((92.7, 101.97, 92.7, 92.7, 1.0, 5)) == pytest.approx(-1.0)
def test_flag_pennant_reference():
t = ta.FlagPennant()
assert t.update((149.85, 150.0, 149.85, 149.85, 1.0, 0)) == pytest.approx(0.0)
assert t.update((100.0, 148.5, 100.0, 100.0, 1.0, 1)) == pytest.approx(0.0)
assert t.update((101.0, 140.0, 101.0, 101.0, 1.0, 2)) == pytest.approx(0.0)
assert t.update((130.0, 138.6, 130.0, 130.0, 1.0, 3)) == pytest.approx(0.0)
assert t.update((131.3, 143.0, 131.3, 131.3, 1.0, 4)) == pytest.approx(1.0)
def test_rectangle_range_reference():
t = ta.RectangleRange()
assert t.update((119.88, 120.0, 119.88, 119.88, 1.0, 0)) == pytest.approx(0.0)
assert t.update((100.0, 118.8, 100.0, 100.0, 1.0, 1)) == pytest.approx(0.0)
assert t.update((101.0, 121.0, 101.0, 101.0, 1.0, 2)) == pytest.approx(0.0)
assert t.update((99.0, 119.79, 99.0, 99.0, 1.0, 3)) == pytest.approx(0.0)
assert t.update((99.99, 108.9, 99.99, 99.99, 1.0, 4)) == pytest.approx(1.0)
def test_cup_and_handle_reference():
t = ta.CupAndHandle()
assert t.update((119.88, 120.0, 119.88, 119.88, 1.0, 0)) == pytest.approx(0.0)
assert t.update((90.0, 118.8, 90.0, 90.0, 1.0, 1)) == pytest.approx(0.0)
assert t.update((90.9, 121.0, 90.9, 90.9, 1.0, 2)) == pytest.approx(0.0)
assert t.update((110.0, 119.79, 110.0, 110.0, 1.0, 3)) == pytest.approx(0.0)
assert t.update((111.1, 121.0, 111.1, 111.1, 1.0, 4)) == pytest.approx(1.0)
def test_abcd_reference():
t = ta.Abcd()
assert t.update((139.86, 140.0, 139.86, 139.86, 1.0, 0)) == pytest.approx(0.0)
assert t.update((100.0, 138.6, 100.0, 100.0, 1.0, 1)) == pytest.approx(0.0)
assert t.update((101.0, 124.7, 101.0, 101.0, 1.0, 2)) == pytest.approx(0.0)
assert t.update((84.7, 123.453, 84.7, 84.7, 1.0, 3)) == pytest.approx(0.0)
assert t.update((85.547, 93.17, 85.547, 85.547, 1.0, 4)) == pytest.approx(1.0)
def test_gartley_reference():
t = ta.Gartley()
assert t.update((149.85, 150.0, 149.85, 149.85, 1.0, 0)) == pytest.approx(0.0)
assert t.update((100.0, 148.5, 100.0, 100.0, 1.0, 1)) == pytest.approx(0.0)
assert t.update((101.0, 140.0, 101.0, 101.0, 1.0, 2)) == pytest.approx(0.0)
assert t.update((115.3, 138.6, 115.3, 115.3, 1.0, 3)) == pytest.approx(0.0)
assert t.update((116.453, 127.65, 116.453, 116.453, 1.0, 4)) == pytest.approx(0.0)
assert t.update((108.56, 126.3735, 108.56, 108.56, 1.0, 5)) == pytest.approx(0.0)
assert t.update((109.6456, 119.416, 109.6456, 109.6456, 1.0, 6)) == pytest.approx(1.0)
def test_butterfly_reference():
t = ta.Butterfly()
assert t.update((149.85, 150.0, 149.85, 149.85, 1.0, 0)) == pytest.approx(0.0)
assert t.update((100.0, 148.5, 100.0, 100.0, 1.0, 1)) == pytest.approx(0.0)
assert t.update((101.0, 140.0, 101.0, 101.0, 1.0, 2)) == pytest.approx(0.0)
assert t.update((108.6, 138.6, 108.6, 108.6, 1.0, 3)) == pytest.approx(0.0)
assert t.update((109.686, 128.0, 109.686, 109.686, 1.0, 4)) == pytest.approx(0.0)
assert t.update((79.8, 126.72, 79.8, 79.8, 1.0, 5)) == pytest.approx(0.0)
assert t.update((80.598, 87.78, 80.598, 80.598, 1.0, 6)) == pytest.approx(1.0)
def test_bat_reference():
t = ta.Bat()
assert t.update((149.85, 150.0, 149.85, 149.85, 1.0, 0)) == pytest.approx(0.0)
assert t.update((100.0, 148.5, 100.0, 100.0, 1.0, 1)) == pytest.approx(0.0)
assert t.update((101.0, 140.0, 101.0, 101.0, 1.0, 2)) == pytest.approx(0.0)
assert t.update((122.0, 138.6, 122.0, 122.0, 1.0, 3)) == pytest.approx(0.0)
assert t.update((123.22, 137.0, 123.22, 123.22, 1.0, 4)) == pytest.approx(0.0)
assert t.update((104.56, 135.63, 104.56, 104.56, 1.0, 5)) == pytest.approx(0.0)
assert t.update((105.6056, 115.016, 105.6056, 105.6056, 1.0, 6)) == pytest.approx(1.0)
def test_crab_reference():
t = ta.Crab()
assert t.update((149.85, 150.0, 149.85, 149.85, 1.0, 0)) == pytest.approx(0.0)
assert t.update((100.0, 148.5, 100.0, 100.0, 1.0, 1)) == pytest.approx(0.0)
assert t.update((101.0, 140.0, 101.0, 101.0, 1.0, 2)) == pytest.approx(0.0)
assert t.update((120.0, 138.6, 120.0, 120.0, 1.0, 3)) == pytest.approx(0.0)
assert t.update((121.2, 137.5, 121.2, 121.2, 1.0, 4)) == pytest.approx(0.0)
assert t.update((75.3, 136.125, 75.3, 75.3, 1.0, 5)) == pytest.approx(0.0)
assert t.update((76.053, 82.83, 76.053, 76.053, 1.0, 6)) == pytest.approx(1.0)
def test_shark_reference():
t = ta.Shark()
assert t.update((149.85, 150.0, 149.85, 149.85, 1.0, 0)) == pytest.approx(0.0)
assert t.update((100.0, 148.5, 100.0, 100.0, 1.0, 1)) == pytest.approx(0.0)
assert t.update((101.0, 140.0, 101.0, 101.0, 1.0, 2)) == pytest.approx(0.0)
assert t.update((88.0, 138.6, 88.0, 88.0, 1.0, 3)) == pytest.approx(0.0)
assert t.update((88.88, 186.8, 88.88, 88.88, 1.0, 4)) == pytest.approx(0.0)
assert t.update((100.0, 184.932, 100.0, 100.0, 1.0, 5)) == pytest.approx(0.0)
assert t.update((101.0, 110.0, 101.0, 101.0, 1.0, 6)) == pytest.approx(1.0)
def test_cypher_reference():
t = ta.Cypher()
assert t.update((149.85, 150.0, 149.85, 149.85, 1.0, 0)) == pytest.approx(0.0)
assert t.update((100.0, 148.5, 100.0, 100.0, 1.0, 1)) == pytest.approx(0.0)
assert t.update((101.0, 140.0, 101.0, 101.0, 1.0, 2)) == pytest.approx(0.0)
assert t.update((120.0, 138.6, 120.0, 120.0, 1.0, 3)) == pytest.approx(0.0)
assert t.update((121.2, 168.0, 121.2, 121.2, 1.0, 4)) == pytest.approx(0.0)
assert t.update((114.55, 166.32, 114.55, 114.55, 1.0, 5)) == pytest.approx(0.0)
assert t.update((115.6955, 126.005, 115.6955, 115.6955, 1.0, 6)) == pytest.approx(1.0)
def test_three_drives_reference():
t = ta.ThreeDrives()
assert t.update((119.88, 120.0, 119.88, 119.88, 1.0, 0)) == pytest.approx(0.0)
assert t.update((100.0, 118.8, 100.0, 100.0, 1.0, 1)) == pytest.approx(0.0)
assert t.update((101.0, 128.0, 101.0, 101.0, 1.0, 2)) == pytest.approx(0.0)
assert t.update((108.0, 126.72, 108.0, 108.0, 1.0, 3)) == pytest.approx(0.0)
assert t.update((109.08, 136.0, 109.08, 109.08, 1.0, 4)) == pytest.approx(0.0)
assert t.update((122.4, 134.64, 122.4, 122.4, 1.0, 5)) == pytest.approx(-1.0)
def test_fib_retracement_reference():
t = ta.FibRetracement()
assert t.update((199.8, 200.0, 199.8, 199.8, 1.0, 0)) is None
assert t.update((100.0, 198.0, 100.0, 100.0, 1.0, 1)) is None
assert t.update((101.0, 110.0, 101.0, 101.0, 1.0, 2)) == pytest.approx((100.0, 123.6, 138.2, 150.0, 161.8, 178.6, 200.0))
def test_fib_extension_reference():
t = ta.FibExtension()
assert t.update((199.8, 200.0, 199.8, 199.8, 1.0, 0)) is None
assert t.update((100.0, 198.0, 100.0, 100.0, 1.0, 1)) is None
assert t.update((101.0, 110.0, 101.0, 101.0, 1.0, 2)) == pytest.approx((72.8, 58.6, 38.2, 0.0, -61.8))
def test_fib_projection_reference():
t = ta.FibProjection()
assert t.update((199.8, 200.0, 199.8, 199.8, 1.0, 0)) is None
assert t.update((160.0, 198.0, 160.0, 160.0, 1.0, 1)) is None
assert t.update((161.6, 190.0, 161.6, 161.6, 1.0, 2)) is None
assert t.update((171.0, 188.1, 171.0, 171.0, 1.0, 3)) == pytest.approx((165.28, 150.0, 125.28, 85.28))
def test_auto_fib_reference():
t = ta.AutoFib()
assert t.update((199.8, 200.0, 199.8, 199.8, 1.0, 0)) is None
assert t.update((100.0, 198.0, 100.0, 100.0, 1.0, 1)) is None
assert t.update((101.0, 110.0, 101.0, 101.0, 1.0, 2)) == pytest.approx((100.0, 123.6, 138.2, 150.0, 161.8, 178.6, 200.0))
def test_golden_pocket_reference():
t = ta.GoldenPocket()
assert t.update((199.8, 200.0, 199.8, 199.8, 1.0, 0)) is None
assert t.update((100.0, 198.0, 100.0, 100.0, 1.0, 1)) is None
assert t.update((101.0, 110.0, 101.0, 101.0, 1.0, 2)) == pytest.approx((161.8, 163.4, 165.0))
def test_fib_confluence_reference():
t = ta.FibConfluence()
assert t.update((199.8, 200.0, 199.8, 199.8, 1.0, 0)) is None
assert t.update((100.0, 198.0, 100.0, 100.0, 1.0, 1)) is None
assert t.update((101.0, 160.0, 101.0, 101.0, 1.0, 2)) is None
assert t.update((144.0, 158.4, 144.0, 144.0, 1.0, 3)) == pytest.approx((137.64, 2.0))
def test_fib_fan_reference():
t = ta.FibFan()
assert t.update((199.0, 200.0, 199.0, 199.0, 1.0, 0)) is None
assert t.update((160.0, 190.0, 160.0, 160.0, 1.0, 1)) is None
assert t.update((100.0, 150.0, 100.0, 100.0, 1.0, 2)) is None
assert t.update((105.0, 110.0, 105.0, 105.0, 1.0, 3)) == pytest.approx((142.7, 125.0, 107.3))
def test_fib_arcs_reference():
t = ta.FibArcs()
assert t.update((199.0, 200.0, 199.0, 199.0, 1.0, 0)) is None
assert t.update((160.0, 190.0, 160.0, 160.0, 1.0, 1)) is None
assert t.update((100.0, 150.0, 100.0, 100.0, 1.0, 2)) is None
assert t.update((105.0, 110.0, 105.0, 105.0, 1.0, 3)) == pytest.approx((133.082181, 143.30127, 153.52037))
def test_fib_channel_reference():
t = ta.FibChannel()
assert t.update((199.0, 200.0, 199.0, 199.0, 1.0, 0)) is None
assert t.update((100.0, 190.0, 100.0, 100.0, 1.0, 1)) is None
assert t.update((108.0, 110.0, 108.0, 108.0, 1.0, 2)) is None
assert t.update((210.0, 220.0, 210.0, 210.0, 1.0, 3)) is None
assert t.update((150.0, 200.0, 150.0, 150.0, 1.0, 4)) == pytest.approx((226.666667, 160.746667, 120.0, 54.08))
def test_fib_time_zones_reference():
t = ta.FibTimeZones()
assert t.update((199.0, 200.0, 199.0, 199.0, 1.0, 0)) is None
assert t.update((150.0, 190.0, 150.0, 150.0, 1.0, 1)) == pytest.approx((1.0, 1.0))
assert t.update((151.0, 155.0, 151.0, 151.0, 1.0, 2)) == pytest.approx((1.0, 1.0))
assert t.update((151.0, 155.0, 151.0, 151.0, 1.0, 3)) == pytest.approx((1.0, 2.0))
assert t.update((151.0, 155.0, 151.0, 151.0, 1.0, 4)) == pytest.approx((0.0, 1.0))
assert t.update((151.0, 155.0, 151.0, 151.0, 1.0, 5)) == pytest.approx((1.0, 3.0))
def test_spread_ar1_coefficient_reference():
t = ta.SpreadAr1Coefficient(20)
assert t.update(1.0, 1.0) is None
# Spread a - b grows by exactly 1 each bar (unit root) => rho == 1.
a = np.array([2.0 * i for i in range(40)])
b = np.array([float(i) for i in range(40)])
out = ta.SpreadAr1Coefficient(20).batch(a, b)
assert math.isclose(out[-1], 1.0, abs_tol=1e-9)
# --- Lifecycle ------------------------------------------------------------
@@ -2671,6 +3051,7 @@ def test_orderbook_indicators_streaming_equals_batch():
ta.Microprice,
ta.QuotedSpread,
ta.DepthSlope,
lambda: ta.OrderFlowImbalance(10),
):
batch = make().batch(snaps)
streamer = make()
@@ -2690,6 +3071,9 @@ def test_tradeflow_indicators_streaming_equals_batch():
ta.SignedVolume,
ta.CumulativeVolumeDelta,
lambda: ta.TradeImbalance(5),
lambda: ta.Vpin(8.0, 5),
lambda: ta.AmihudIlliquidity(14),
lambda: ta.RollMeasure(14),
):
batch = make().batch(price, size, is_buy)
streamer = make()
+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)
File diff suppressed because it is too large Load Diff
+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);
}
}
+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,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,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<_>>()
);
}
}
+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,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,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,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,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<_>>()
);
}
}
@@ -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,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,175 @@
//! Golden Pocket — the 0.618-0.65 optimal-trade-entry zone of the last swing.
use crate::indicators::pattern_swing::{SwingTracker, SWING_THRESHOLD};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Lower bound of the golden pocket (the 61.8% retracement).
const RATIO_LOW: f64 = 0.618;
/// Upper bound of the golden pocket (the 65% retracement).
const RATIO_HIGH: f64 = 0.65;
/// The golden-pocket zone of the most recent swing leg.
///
/// `low`/`high` bracket the 0.618-0.65 retracement band (sorted, so `low <=
/// high` regardless of swing direction); `mid` is their midpoint.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct GoldenPocketOutput {
/// Lower price of the golden-pocket band.
pub low: f64,
/// Midpoint of the band.
pub mid: f64,
/// Upper price of the golden-pocket band.
pub high: f64,
}
/// Golden Pocket (`GoldenPocket`).
///
/// The 0.618-0.65 retracement band of the most recent confirmed swing leg — the
/// "optimal trade entry" zone many swing traders watch for continuation.
///
/// Parameter-free; construction is infallible. Returns `None` until the first
/// leg is complete.
///
/// See `crates/wickra-core/src/indicators/golden_pocket.rs`.
#[derive(Debug, Clone)]
pub struct GoldenPocket {
swing: SwingTracker,
}
impl GoldenPocket {
/// Construct a new Golden Pocket tracker.
#[must_use]
pub const fn new() -> Self {
Self {
swing: SwingTracker::new(SWING_THRESHOLD, 2),
}
}
fn zone(&self) -> Option<GoldenPocketOutput> {
let pivots = self.swing.pivots();
let [start, end] = [pivots.first()?.price, pivots.get(1)?.price];
let span = start - end;
let edge_low = end + RATIO_LOW * span;
let edge_high = end + RATIO_HIGH * span;
let low = edge_low.min(edge_high);
let high = edge_low.max(edge_high);
Some(GoldenPocketOutput {
low,
mid: f64::midpoint(low, high),
high,
})
}
}
impl Default for GoldenPocket {
fn default() -> Self {
Self::new()
}
}
impl Indicator for GoldenPocket {
type Input = Candle;
type Output = GoldenPocketOutput;
fn update(&mut self, candle: Candle) -> Option<GoldenPocketOutput> {
self.swing.update(candle);
self.zone()
}
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 {
"GoldenPocket"
}
}
#[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 = GoldenPocket::new();
assert_eq!(indicator.name(), "GoldenPocket");
assert_eq!(indicator.warmup_period(), 2);
assert!(!indicator.is_ready());
assert!(!GoldenPocket::default().is_ready());
}
#[test]
fn no_output_before_two_pivots() {
let mut indicator = GoldenPocket::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 zone_of_a_down_leg() {
// Leg 200 (high) -> 100 (low), span = 100.
let mut indicator = GoldenPocket::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());
// 61.8% = 161.8, 65% = 165 → sorted band [161.8, 165], mid 163.4.
assert_relative_eq!(v.low, 161.8);
assert_relative_eq!(v.high, 165.0);
assert_relative_eq!(v.mid, 163.4);
}
#[test]
fn band_is_sorted_for_an_up_leg() {
// Latest leg 100 (low) -> 250 (high): span negative, edges flip, but
// low <= high must still hold.
let mut indicator = GoldenPocket::new();
let mut last = None;
for candle in candles_for_pivots(&[200.0, 100.0, 250.0]) {
last = indicator.update(candle);
}
let v = last.unwrap();
assert!(v.low <= v.high);
assert_relative_eq!(v.mid, f64::midpoint(v.low, v.high));
}
#[test]
fn reset_clears_state() {
let mut indicator = GoldenPocket::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 = GoldenPocket::new();
let mut b = GoldenPocket::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,191 @@
//! Head-and-Shoulders (and Inverse) reversal chart pattern.
use crate::indicators::pattern_swing::{
approx_equal, SwingTracker, LEVEL_TOLERANCE, SWING_THRESHOLD,
};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Head-and-Shoulders / Inverse Head-and-Shoulders — a five-pivot reversal
/// pattern with a central extreme (the head) flanked by two lower/higher
/// shoulders at a similar level, joined by a roughly horizontal neckline.
///
/// Built on confirmed swing pivots ([`SWING_THRESHOLD`] = 5%); recognised on the
/// bar that confirms the right shoulder:
///
/// ```text
/// head-and-shoulders top (bearish, -1):
/// LeftShoulder(high) , Trough , Head(high) , Trough , RightShoulder(high)
/// Head > both shoulders ; LeftShoulder ≈ RightShoulder ; Trough₁ ≈ Trough₂
///
/// inverse head-and-shoulders (bullish, +1):
/// LeftShoulder(low) , Peak , Head(low) , Peak , RightShoulder(low)
/// Head < both shoulders ; LeftShoulder ≈ RightShoulder ; Peak₁ ≈ Peak₂
/// ```
///
/// The shoulders must match within [`LEVEL_TOLERANCE`] (3%) and the two neckline
/// points within the same tolerance. Output is `-1.0` for a top, `+1.0` for an
/// inverse, `0.0` otherwise; never `None`.
#[derive(Debug, Clone)]
pub struct HeadAndShoulders {
swing: SwingTracker,
has_emitted: bool,
}
impl HeadAndShoulders {
/// Construct a new Head-and-Shoulders detector.
pub const fn new() -> Self {
Self {
swing: SwingTracker::new(SWING_THRESHOLD, 5),
has_emitted: false,
}
}
}
impl Default for HeadAndShoulders {
fn default() -> Self {
Self::new()
}
}
impl Indicator for HeadAndShoulders {
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 n = pivots.len();
let left_shoulder = pivots[n - 5];
let neck_1 = pivots[n - 4];
let head = pivots[n - 3];
let neck_2 = pivots[n - 2];
let right_shoulder = pivots[n - 1];
let shoulders_match =
approx_equal(left_shoulder.price, right_shoulder.price, LEVEL_TOLERANCE);
let neckline_flat = approx_equal(neck_1.price, neck_2.price, LEVEL_TOLERANCE);
let head_is_peak = head.price > left_shoulder.price && head.price > right_shoulder.price;
let head_is_trough = head.price < left_shoulder.price && head.price < right_shoulder.price;
let frame_matches = shoulders_match && neckline_flat;
if right_shoulder.direction > 0.0 {
// Head-and-shoulders top: head is the highest of the three highs.
if head_is_peak && frame_matches {
return Some(-1.0);
}
} else if head_is_trough && frame_matches {
// Inverse: head is the lowest of the three lows.
return Some(1.0);
}
Some(0.0)
}
fn reset(&mut self) {
self.swing.reset();
self.has_emitted = false;
}
fn warmup_period(&self) -> usize {
// Five confirmed pivots; the earliest confirmation of the fifth is bar 6.
6
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"HeadAndShoulders"
}
}
#[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 = HeadAndShoulders::new();
candles_for_pivots(pivots)
.into_iter()
.map(|c| indicator.update(c).unwrap())
.collect()
}
#[test]
fn accessors_and_metadata() {
let indicator = HeadAndShoulders::new();
assert_eq!(indicator.name(), "HeadAndShoulders");
assert_eq!(indicator.warmup_period(), 6);
assert!(!indicator.is_ready());
assert!(!HeadAndShoulders::default().is_ready());
}
#[test]
fn head_and_shoulders_top_is_minus_one() {
// LS 100, trough 90, head 120, trough 92, RS 101.
let out = run(&[100.0, 90.0, 120.0, 92.0, 101.0]);
assert_eq!(*out.last().unwrap(), -1.0);
assert!(out[..out.len() - 1].iter().all(|&x| x == 0.0));
}
#[test]
fn inverse_head_and_shoulders_is_plus_one() {
// Lead high then LS 100, peak 110, head 80, peak 108, RS 101.
let out = run(&[130.0, 100.0, 110.0, 80.0, 108.0, 101.0]);
assert_eq!(*out.last().unwrap(), 1.0);
}
#[test]
fn mismatched_shoulders_do_not_trigger() {
// Right shoulder (115) far from left (100) → no pattern.
let out = run(&[100.0, 90.0, 130.0, 92.0, 115.0]);
assert_eq!(*out.last().unwrap(), 0.0);
}
#[test]
fn inverse_mismatched_shoulders_do_not_trigger() {
// Inverse shape (ends on a low) but the right shoulder (90) diverges from
// the left (100) → enters the inverse branch yet reports no pattern.
let out = run(&[130.0, 100.0, 110.0, 80.0, 108.0, 90.0]);
assert_eq!(*out.last().unwrap(), 0.0);
}
#[test]
fn equal_highs_without_taller_head_do_not_trigger() {
// Three equal highs (no dominant head) → not H&S (that is a triple top).
let out = run(&[120.0, 90.0, 120.0, 92.0, 120.0]);
assert_eq!(*out.last().unwrap(), 0.0);
}
#[test]
fn reset_clears_state() {
let mut indicator = HeadAndShoulders::new();
for c in candles_for_pivots(&[100.0, 90.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(&[100.0, 90.0, 120.0, 92.0, 101.0]);
let mut a = HeadAndShoulders::new();
let mut b = HeadAndShoulders::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,174 @@
//! High-Low Range — the bar range as a fraction of close.
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// High-Low Range — the bar's high-low range expressed as a fraction of its
/// close price.
///
/// ```text
/// HighLowRange = (high low) / close
/// ```
///
/// A scale-free, single-bar volatility proxy: the absolute range `high low`
/// grows with the nominal price level, so dividing by the close makes a `2$`
/// range on a `100$` instrument (`0.02`) directly comparable to a `200$` range
/// on a `10000$` one (`0.02`). It is the per-bar cousin of average-true-range
/// style measures without the smoothing — useful as an instant intrabar
/// volatility read or a normaliser for other features. The output is `≥ 0`
/// for positive prices. A zero close carries no scale and yields `0`.
///
/// This is a stateless per-bar transform: every candle produces one value.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, HighLowRange};
///
/// let mut indicator = HighLowRange::new();
/// // range 104 - 98 = 6, close 100 -> 0.06.
/// let c = Candle::new(99.0, 104.0, 98.0, 100.0, 10.0, 0).unwrap();
/// assert!((indicator.update(c).unwrap() - 0.06).abs() < 1e-12);
/// ```
#[derive(Debug, Clone, Default)]
pub struct HighLowRange {
has_emitted: bool,
}
impl HighLowRange {
/// Construct a new High-Low Range transform.
pub const fn new() -> Self {
Self { has_emitted: false }
}
}
impl Indicator for HighLowRange {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
self.has_emitted = true;
let out = if candle.close == 0.0 {
// A zero close carries no scale to normalise the range against.
0.0
} else {
(candle.high - candle.low) / candle.close
};
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 {
"HighLowRange"
}
}
#[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() {
// (104 - 98) / 100 = 0.06.
let mut hlr = HighLowRange::new();
assert_relative_eq!(
hlr.update(candle(99.0, 104.0, 98.0, 100.0, 0)).unwrap(),
0.06,
epsilon = 1e-12
);
}
#[test]
fn zero_range_bar_yields_zero() {
// high == low -> range 0 -> 0 regardless of close.
let mut hlr = HighLowRange::new();
assert_relative_eq!(
hlr.update(candle(10.0, 10.0, 10.0, 10.0, 0)).unwrap(),
0.0,
epsilon = 1e-12
);
}
#[test]
fn zero_close_yields_zero() {
// Candle permits a zero close (only finiteness + OHLC ordering checked):
// open 0, high 1, low 0, close 0 satisfies high >= all, low <= all.
let mut hlr = HighLowRange::new();
assert_relative_eq!(
hlr.update(candle(0.0, 1.0, 0.0, 0.0, 0)).unwrap(),
0.0,
epsilon = 1e-12
);
}
#[test]
fn output_is_non_negative() {
let candles: Vec<Candle> = (0..100)
.map(|i| {
let mid = 100.0 + (f64::from(i) * 0.2).sin() * 8.0;
candle(mid, mid + 3.0, mid - 3.0, mid, i64::from(i))
})
.collect();
let mut hlr = HighLowRange::new();
for v in hlr.batch(&candles).into_iter().flatten() {
assert!(v >= 0.0, "HighLowRange {v} must be non-negative");
}
}
#[test]
fn name_metadata() {
let hlr = HighLowRange::new();
assert_eq!(hlr.name(), "HighLowRange");
}
#[test]
fn emits_from_first_candle() {
let mut hlr = HighLowRange::new();
assert_eq!(hlr.warmup_period(), 1);
assert!(!hlr.is_ready());
assert!(hlr.update(candle(10.0, 11.0, 9.0, 10.0, 0)).is_some());
assert!(hlr.is_ready());
}
#[test]
fn reset_clears_state() {
let mut hlr = HighLowRange::new();
hlr.update(candle(10.0, 11.0, 9.0, 10.0, 0));
assert!(hlr.is_ready());
hlr.reset();
assert!(!hlr.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 = HighLowRange::new();
let mut b = HighLowRange::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,236 @@
//! Intraday Volatility Profile — the return volatility in each intraday bucket.
use crate::calendar::civil_from_timestamp;
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Intraday Volatility Profile output: the per-bucket return standard deviation.
///
/// `bins[i]` is the sample standard deviation of the simple returns of all bars
/// whose local time-of-day fell in bucket `i`. Buckets with fewer than two
/// samples read `0.0`.
#[derive(Debug, Clone, PartialEq)]
pub struct IntradayVolatilityProfileOutput {
/// Per-bucket return standard deviation, earliest bucket first.
pub bins: Vec<f64>,
}
/// Return volatility bucketed by local time of day.
///
/// The local day (the wall-clock day of [`Candle::timestamp`](crate::Candle)
/// shifted by `utc_offset_minutes`) is split into `buckets` equal slices. Each
/// bar's simple return `close / previous_close - 1` updates the per-bucket
/// running variance (Welford), and the profile reports the per-bucket sample
/// standard deviation. The first bar produces no output.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, IntradayVolatilityProfile};
///
/// let hour = 3_600_000;
/// let mut prof = IntradayVolatilityProfile::new(24, 0).unwrap();
/// 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, hour).unwrap()).unwrap();
/// assert_eq!(out.bins.len(), 24);
/// ```
#[derive(Debug, Clone)]
pub struct IntradayVolatilityProfile {
buckets: usize,
utc_offset_minutes: i32,
prev_close: Option<f64>,
count: Vec<u64>,
mean: Vec<f64>,
m2: Vec<f64>,
last: Option<IntradayVolatilityProfileOutput>,
}
impl IntradayVolatilityProfile {
/// Construct an Intraday Volatility Profile with `buckets` intraday slices.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `buckets == 0`.
pub fn new(buckets: usize, utc_offset_minutes: i32) -> Result<Self> {
if buckets == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
buckets,
utc_offset_minutes,
prev_close: None,
count: vec![0; buckets],
mean: vec![0.0; buckets],
m2: vec![0.0; buckets],
last: None,
})
}
/// Configured `(buckets, utc_offset_minutes)`.
pub const fn params(&self) -> (usize, i32) {
(self.buckets, self.utc_offset_minutes)
}
/// Most recent profile if at least one return has been recorded.
pub fn value(&self) -> Option<&IntradayVolatilityProfileOutput> {
self.last.as_ref()
}
fn bucket_of(&self, minute_of_day: u32) -> usize {
let raw = (minute_of_day as usize * self.buckets) / 1440;
raw.min(self.buckets - 1)
}
fn snapshot(&self) -> IntradayVolatilityProfileOutput {
let bins = self
.count
.iter()
.zip(&self.m2)
.map(|(n, m2)| {
if *n >= 2 {
(m2 / (*n - 1) as f64).sqrt()
} else {
0.0
}
})
.collect();
IntradayVolatilityProfileOutput { bins }
}
}
impl Indicator for IntradayVolatilityProfile {
type Input = Candle;
type Output = IntradayVolatilityProfileOutput;
fn update(&mut self, candle: Candle) -> Option<IntradayVolatilityProfileOutput> {
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 bucket = self.bucket_of(civil.minute_of_day());
self.count[bucket] += 1;
let delta = ret - self.mean[bucket];
self.mean[bucket] += delta / self.count[bucket] as f64;
let delta2 = ret - self.mean[bucket];
self.m2[bucket] += delta * delta2;
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.count.iter_mut().for_each(|x| *x = 0);
self.mean.iter_mut().for_each(|x| *x = 0.0);
self.m2.iter_mut().for_each(|x| *x = 0.0);
self.last = None;
}
fn warmup_period(&self) -> usize {
2
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"IntradayVolatilityProfile"
}
}
#[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(close: f64, ts: i64) -> Candle {
Candle::new(close, close, close, close, 1.0, ts).unwrap()
}
#[test]
fn rejects_zero_buckets() {
assert!(matches!(
IntradayVolatilityProfile::new(0, 0),
Err(Error::PeriodZero)
));
}
#[test]
fn metadata_and_accessors() {
let prof = IntradayVolatilityProfile::new(24, 90).unwrap();
assert_eq!(prof.params(), (24, 90));
assert_eq!(prof.name(), "IntradayVolatilityProfile");
assert_eq!(prof.warmup_period(), 2);
assert!(!prof.is_ready());
assert!(prof.value().is_none());
}
#[test]
fn single_sample_bucket_has_zero_vol() {
let mut prof = IntradayVolatilityProfile::new(24, 0).unwrap();
assert!(prof.update(c(100.0, 0)).is_none());
let out = prof.update(c(101.0, HOUR)).unwrap();
assert_eq!(out.bins.len(), 24);
assert_relative_eq!(out.bins[1], 0.0); // only one sample in bucket 1
assert!(prof.is_ready());
}
#[test]
fn std_matches_manual_two_samples() {
let mut prof = IntradayVolatilityProfile::new(24, 0).unwrap();
prof.update(c(100.0, 0)); // 00:00
prof.update(c(101.0, HOUR)); // 01:00 r=0.01 into bucket 1
// Next day 01:00, r2 = 0.03 into bucket 1.
let out = prof.update(c(101.0 * 1.03, 25 * HOUR)).unwrap();
// sample std of {0.01, 0.03} = sqrt(((.01-.02)^2+(.03-.02)^2)/1) = 0.01414..
let mean = 0.02;
let expected = (((0.01_f64 - mean).powi(2) + (0.03 - mean).powi(2)) / 1.0).sqrt();
assert_relative_eq!(out.bins[1], expected, epsilon = 1e-9);
}
#[test]
fn zero_prev_close_uses_zero_return() {
let mut prof = IntradayVolatilityProfile::new(4, 0).unwrap();
prof.update(c(0.0, 0));
let out = prof.update(c(5.0, HOUR)).unwrap();
assert_relative_eq!(out.bins[0], 0.0);
}
#[test]
fn reset_clears_state() {
let mut prof = IntradayVolatilityProfile::new(24, 0).unwrap();
prof.update(c(100.0, 0));
prof.update(c(101.0, HOUR));
prof.reset();
assert!(!prof.is_ready());
assert!(prof.value().is_none());
assert!(prof.update(c(100.0, DAY)).is_none());
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..50)
.map(|i| c(100.0 + f64::from(i % 6), i64::from(i) * HOUR))
.collect();
let mut a = IntradayVolatilityProfile::new(12, 0).unwrap();
let mut b = IntradayVolatilityProfile::new(12, 0).unwrap();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,291 @@
//! Jump Indicator — detects return outliers relative to trailing volatility.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Jump Indicator — a discrete `{1, 0, +1}` flag for whether the current log
/// return is an outlier relative to the trailing volatility of returns.
///
/// ```text
/// rₜ = ln(priceₜ / priceₜ₋₁)
/// μ, σ = sample mean and stddev of the `period` returns *before* rₜ (trailing)
/// flag = +1 if rₜ μ > threshold · σ
/// 1 if rₜ μ < threshold · σ
/// 0 otherwise
/// ```
///
/// The baseline is the trailing return distribution and **excludes** the current
/// return, so a genuine jump cannot inflate the band it is tested against.
/// Measuring the deviation from the trailing mean `μ` (not the raw return) means
/// a steady drift is *not* flagged — only moves that are large relative to the
/// recent return distribution count. `+1` marks an up jump, `1` a down jump,
/// and `0` an ordinary move. When the trailing window has zero dispersion
/// (`σ = 0`, e.g. a perfectly constant drift) there is no defined baseline and
/// the indicator returns `0` rather than flagging every move.
///
/// This is the generic, threshold-tunable detector; downstream models keep any
/// regime-specific sensitivity by choosing `threshold`. Non-finite and
/// non-positive prices are ignored (the log return is undefined): the tick is
/// dropped and the last value returned.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, JumpIndicator};
///
/// let mut indicator = JumpIndicator::new(20, 3.0).unwrap();
/// let mut last = None;
/// for i in 0..40 {
/// last = indicator.update(100.0 + (f64::from(i) * 0.5).sin());
/// }
/// // A calm sinusoid produces no jumps.
/// assert_eq!(last, Some(0.0));
/// ```
#[derive(Debug, Clone)]
pub struct JumpIndicator {
period: usize,
threshold: f64,
prev_price: Option<f64>,
/// Trailing window of the `period` returns preceding the current one.
window: VecDeque<f64>,
sum: f64,
sum_sq: f64,
last: Option<f64>,
}
impl JumpIndicator {
/// Construct a new Jump Indicator.
///
/// `threshold` is the number of trailing standard deviations a return must
/// exceed to be flagged.
///
/// # Errors
/// Returns [`Error::InvalidPeriod`] if `period < 2` (the sample standard
/// deviation needs at least two returns), or [`Error::InvalidParameter`] if
/// `threshold` is not finite and positive.
pub fn new(period: usize, threshold: f64) -> Result<Self> {
if period < 2 {
return Err(Error::InvalidPeriod {
message: "jump indicator needs period >= 2",
});
}
if !threshold.is_finite() || threshold <= 0.0 {
return Err(Error::InvalidParameter {
message: "jump indicator threshold must be finite and positive",
});
}
Ok(Self {
period,
threshold,
prev_price: None,
window: VecDeque::with_capacity(period),
sum: 0.0,
sum_sq: 0.0,
last: None,
})
}
/// Configured `(period, threshold)`.
pub const fn params(&self) -> (usize, f64) {
(self.period, self.threshold)
}
}
impl Indicator for JumpIndicator {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() || input <= 0.0 {
return self.last;
}
let Some(prev) = self.prev_price else {
self.prev_price = Some(input);
return None;
};
self.prev_price = Some(input);
let r = (input / prev).ln();
if self.window.len() < self.period {
// Still filling the trailing window; no baseline yet.
self.window.push_back(r);
self.sum += r;
self.sum_sq += r * r;
return None;
}
// Trailing window is full: classify `r` against the volatility of the
// `period` returns that precede it.
let n = self.period as f64;
let mean = self.sum / n;
let var = ((self.sum_sq - n * mean * mean) / (n - 1.0)).max(0.0);
let sd = var.sqrt();
let deviation = r - mean;
let label = if sd == 0.0 {
0.0
} else if deviation > self.threshold * sd {
1.0
} else if deviation < -self.threshold * sd {
-1.0
} else {
0.0
};
// Slide the trailing window forward to include `r`.
let old = self.window.pop_front().expect("window is non-empty");
self.sum -= old;
self.sum_sq -= old * old;
self.window.push_back(r);
self.sum += r;
self.sum_sq += r * r;
self.last = Some(label);
Some(label)
}
fn reset(&mut self) {
self.prev_price = None;
self.window.clear();
self.sum = 0.0;
self.sum_sq = 0.0;
self.last = None;
}
fn warmup_period(&self) -> usize {
// One price seeds `prev`, `period` returns fill the trailing window,
// then the next return is the first one classified.
self.period + 2
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"JumpIndicator"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
#[test]
fn rejects_bad_params() {
assert!(matches!(
JumpIndicator::new(1, 3.0),
Err(Error::InvalidPeriod { .. })
));
assert!(matches!(
JumpIndicator::new(20, 0.0),
Err(Error::InvalidParameter { .. })
));
assert!(matches!(
JumpIndicator::new(20, f64::NAN),
Err(Error::InvalidParameter { .. })
));
}
#[test]
fn accessors_and_metadata() {
let ji = JumpIndicator::new(20, 3.0).unwrap();
assert_eq!(ji.params(), (20, 3.0));
assert_eq!(ji.warmup_period(), 22);
assert_eq!(ji.name(), "JumpIndicator");
assert!(!ji.is_ready());
}
#[test]
fn detects_upward_jump() {
let mut ji = JumpIndicator::new(10, 3.0).unwrap();
// Calm oscillating warmup (small, varied returns), then a +20% spike.
let mut prices: Vec<f64> = (0..20)
.map(|i| 100.0 + (f64::from(i) * 0.7).sin() * 0.2)
.collect();
let last_calm = *prices.last().unwrap();
prices.push(last_calm * 1.2);
let out = ji.batch(&prices);
assert_eq!(out.last().copied().flatten(), Some(1.0));
}
#[test]
fn detects_downward_jump() {
let mut ji = JumpIndicator::new(10, 3.0).unwrap();
let mut prices: Vec<f64> = (0..20)
.map(|i| 100.0 + (f64::from(i) * 0.7).sin() * 0.2)
.collect();
let last_calm = *prices.last().unwrap();
prices.push(last_calm * 0.8);
let out = ji.batch(&prices);
assert_eq!(out.last().copied().flatten(), Some(-1.0));
}
#[test]
fn calm_series_has_no_jumps() {
let mut ji = JumpIndicator::new(20, 3.0).unwrap();
let prices: Vec<f64> = (0..80)
.map(|i| 100.0 + (f64::from(i) * 0.5).sin())
.collect();
for v in ji.batch(&prices).into_iter().flatten() {
assert_eq!(v, 0.0);
}
}
#[test]
fn zero_trailing_volatility_returns_zero() {
// A constant price has exactly-zero returns => zero trailing dispersion
// => no defined baseline => label 0. (Pins the `sd == 0` branch with an
// exact-zero series; a geometric drift is conceptually zero-vol too but
// floating-point rounding of the log returns leaves ~1e-16 noise.)
let mut ji = JumpIndicator::new(10, 3.0).unwrap();
for v in ji.batch(&[100.0; 30]).into_iter().flatten() {
assert_eq!(v, 0.0);
}
}
#[test]
fn steady_drift_is_not_flagged() {
// A near-constant positive drift (small, equal-ish returns) must not be
// flagged: the deviation from the trailing mean stays well inside the
// band even though the raw return is non-zero every bar.
let mut ji = JumpIndicator::new(10, 3.0).unwrap();
let prices: Vec<f64> = (0..40).map(|i| 100.0 + f64::from(i) * 0.5).collect();
for v in ji.batch(&prices).into_iter().flatten() {
assert_eq!(v, 0.0);
}
}
#[test]
fn ignores_non_finite_and_non_positive() {
let mut ji = JumpIndicator::new(5, 3.0).unwrap();
let prices: Vec<f64> = (0..20)
.map(|i| 100.0 + (f64::from(i) * 0.6).sin())
.collect();
let out = ji.batch(&prices);
let last = *out.last().unwrap();
assert!(last.is_some());
assert_eq!(ji.update(f64::NAN), last);
assert_eq!(ji.update(-1.0), last);
assert_eq!(ji.update(0.0), last);
}
#[test]
fn reset_clears_state() {
let mut ji = JumpIndicator::new(5, 3.0).unwrap();
ji.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
assert!(ji.is_ready());
ji.reset();
assert!(!ji.is_ready());
assert_eq!(ji.update(1.0), None);
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (1..=120)
.map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 3.0)
.collect();
let batch = JumpIndicator::new(20, 3.0).unwrap().batch(&prices);
let mut b = JumpIndicator::new(20, 3.0).unwrap();
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,218 @@
//! Logarithmic Return over a fixed lag.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Logarithmic return over a `period`-bar lag: `ln(price_t / price_{tperiod})`.
///
/// The natural-log analogue of [`Roc`](crate::Roc) (which reports the simple
/// percentage change). Log returns are the canonical input for volatility and
/// statistical models because they are additive across time — the log return
/// over `k` bars equals the sum of the `k` one-bar log returns — and symmetric
/// around zero (a `+x` move and the reverse `x` move cancel exactly).
///
/// ```text
/// r_t = ln(price_t / price_{tperiod})
/// ```
///
/// Non-finite and non-positive prices are ignored: the input is dropped, state
/// is left untouched, and the last computed value is returned instead. The log
/// of a non-positive price is undefined, so such ticks must not enter the
/// window — mirroring [`HistoricalVolatility`](crate::HistoricalVolatility).
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, LogReturn};
///
/// let mut indicator = LogReturn::new(1).unwrap();
/// indicator.update(100.0);
/// // ln(110 / 100) ≈ 0.09531
/// let r = indicator.update(110.0).unwrap();
/// assert!((r - (110.0_f64 / 100.0).ln()).abs() < 1e-12);
/// ```
#[derive(Debug, Clone)]
pub struct LogReturn {
period: usize,
window: VecDeque<f64>,
last: Option<f64>,
}
impl LogReturn {
/// Construct a new log-return indicator with the given lag.
///
/// # 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 + 1),
last: None,
})
}
/// Configured lag.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for LogReturn {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
// Non-finite or non-positive prices are ignored: `ln` of a non-positive
// price is undefined, so the tick must not enter the window. Return the
// last value and leave state untouched (SMA / EMA / HV convention).
if !input.is_finite() || input <= 0.0 {
return self.last;
}
if self.window.len() == self.period + 1 {
self.window.pop_front();
}
self.window.push_back(input);
if self.window.len() < self.period + 1 {
return None;
}
// `prev` was pushed through the same guard, so it is finite and > 0 and
// `(input / prev).ln()` is always well-defined.
let prev = *self.window.front().expect("non-empty");
let r = (input / prev).ln();
self.last = Some(r);
Some(r)
}
fn reset(&mut self) {
self.window.clear();
self.last = None;
}
fn warmup_period(&self) -> usize {
self.period + 1
}
fn is_ready(&self) -> bool {
self.window.len() == self.period + 1
}
fn name(&self) -> &'static str {
"LogReturn"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_zero_period() {
assert!(matches!(LogReturn::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_and_metadata() {
let lr = LogReturn::new(5).unwrap();
assert_eq!(lr.period(), 5);
assert_eq!(lr.warmup_period(), 6);
assert_eq!(lr.name(), "LogReturn");
assert!(!lr.is_ready());
}
#[test]
fn known_value() {
// LogReturn(1): ln(110 / 100).
let mut lr = LogReturn::new(1).unwrap();
let out = lr.batch(&[100.0, 110.0]);
assert!(out[0].is_none());
assert_relative_eq!(out[1].unwrap(), (110.0_f64 / 100.0).ln(), epsilon = 1e-12);
}
#[test]
fn multi_bar_lag() {
// LogReturn(3): at index 3, ln(price_3 / price_0).
let mut lr = LogReturn::new(3).unwrap();
let out = lr.batch(&[100.0, 105.0, 108.0, 121.0]);
for v in out.iter().take(3) {
assert!(v.is_none());
}
assert_relative_eq!(out[3].unwrap(), (121.0_f64 / 100.0).ln(), epsilon = 1e-12);
}
#[test]
fn additive_across_time() {
// The 2-bar log return equals the sum of the two 1-bar log returns.
let prices = [50.0, 55.0, 60.5];
let mut lag2 = LogReturn::new(2).unwrap();
let two_bar = lag2.batch(&prices)[2].unwrap();
let mut lag1 = LogReturn::new(1).unwrap();
let ones = lag1.batch(&prices);
let sum = ones[1].unwrap() + ones[2].unwrap();
assert_relative_eq!(two_bar, sum, epsilon = 1e-12);
}
#[test]
fn constant_series_yields_zero() {
let mut lr = LogReturn::new(4).unwrap();
for v in lr.batch(&[42.0; 20]).into_iter().flatten() {
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
}
}
#[test]
fn ignores_non_finite_input() {
let mut lr = LogReturn::new(1).unwrap();
let out = lr.batch(&[100.0, 110.0]);
let ready = out[1].expect("ready after two inputs");
assert_eq!(lr.update(f64::NAN), Some(ready));
assert_eq!(lr.update(f64::INFINITY), Some(ready));
// Window untouched: the next finite price still references prev = 110.
assert_relative_eq!(
lr.update(121.0).unwrap(),
(121.0_f64 / 110.0).ln(),
epsilon = 1e-12
);
}
#[test]
fn skips_non_positive_prices() {
let mut lr = LogReturn::new(1).unwrap();
let out = lr.batch(&[100.0, 110.0]);
let baseline = out[1].expect("ready");
// A non-positive tick is ignored and the previous valid price is kept.
assert_eq!(lr.update(-5.0), Some(baseline));
assert_eq!(lr.update(0.0), Some(baseline));
let mut control = lr.clone();
let after = lr.update(121.0).expect("ready");
assert_eq!(control.update(121.0).expect("ready"), after);
assert_relative_eq!(after, (121.0_f64 / 110.0).ln(), epsilon = 1e-12);
}
#[test]
fn reset_clears_state() {
let mut lr = LogReturn::new(3).unwrap();
lr.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
assert!(lr.is_ready());
lr.reset();
assert!(!lr.is_ready());
assert_eq!(lr.update(1.0), None);
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (1..=60)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0)
.collect();
let batch = LogReturn::new(5).unwrap().batch(&prices);
let mut b = LogReturn::new(5).unwrap();
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
assert_eq!(batch, streamed);
}
}
+197 -1
View File
@@ -4,7 +4,13 @@
//! [`FAMILIES`]. Every public name is re-exported flat from this module and
//! from the crate root for convenience.
// Internal shared building block for the chart- and harmonic-pattern detectors.
// Declared `pub(crate)` (not `mod`) so it is excluded from the public-catalogue
// counter (`grep -c '^mod '`) and re-exported nowhere.
pub(crate) mod pattern_swing;
mod abandoned_baby;
mod abcd;
mod absolute_breadth_index;
mod acceleration_bands;
mod accelerator_oscillator;
@@ -20,6 +26,7 @@ mod adxr;
mod alligator;
mod alma;
mod alpha;
mod amihud_illiquidity;
mod anchored_rsi;
mod anchored_vwap;
mod apo;
@@ -28,20 +35,25 @@ mod aroon_oscillator;
mod atr;
mod atr_bands;
mod atr_trailing_stop;
mod auto_fib;
mod autocorrelation;
mod average_daily_range;
mod average_drawdown;
mod avg_price;
mod awesome_oscillator;
mod awesome_oscillator_histogram;
mod balance_of_power;
mod bat;
mod belt_hold;
mod beta;
mod beta_neutral_spread;
mod body_size_pct;
mod bollinger;
mod bollinger_bandwidth;
mod breadth_thrust;
mod breakaway;
mod bullish_percent_index;
mod butterfly;
mod calendar_spread;
mod calmar_ratio;
mod camarilla_pivots;
@@ -54,6 +66,7 @@ mod chande_kroll_stop;
mod chandelier_exit;
mod choppiness_index;
mod classic_pivots;
mod close_vs_open;
mod closing_marubozu;
mod cmf;
mod cmo;
@@ -64,9 +77,13 @@ mod conditional_value_at_risk;
mod connors_rsi;
mod coppock;
mod counterattack;
mod crab;
mod cumulative_volume_index;
mod cup_and_handle;
mod cvd;
mod cybernetic_cycle;
mod cypher;
mod day_of_week_profile;
mod decycler;
mod decycler_oscillator;
mod dema;
@@ -80,6 +97,7 @@ mod doji_star;
mod donchian;
mod donchian_stop;
mod double_bollinger;
mod double_top_bottom;
mod downside_gap_three_methods;
mod dpo;
mod dragonfly_doji;
@@ -94,10 +112,20 @@ mod empirical_mode_decomposition;
mod engulfing;
mod evening_doji_star;
mod evwma;
mod expectancy;
mod falling_three_methods;
mod fama;
mod fib_arcs;
mod fib_channel;
mod fib_confluence;
mod fib_extension;
mod fib_fan;
mod fib_projection;
mod fib_retracement;
mod fib_time_zones;
mod fibonacci_pivots;
mod fisher_transform;
mod flag_pennant;
mod footprint;
mod force_index;
mod fractal_chaos_bands;
@@ -109,13 +137,17 @@ mod funding_rate_zscore;
mod gain_loss_ratio;
mod gap_side_by_side_white;
mod garman_klass;
mod gartley;
mod golden_pocket;
mod granger_causality;
mod gravestone_doji;
mod hammer;
mod hanging_man;
mod harami;
mod head_and_shoulders;
mod heikin_ashi;
mod high_low_index;
mod high_low_range;
mod high_wave;
mod hikkake;
mod hikkake_modified;
@@ -136,9 +168,11 @@ mod inertia;
mod information_ratio;
mod initial_balance;
mod instantaneous_trendline;
mod intraday_volatility_profile;
mod inverse_fisher_transform;
mod inverted_hammer;
mod jma;
mod jump_indicator;
mod kagi_bars;
mod kalman_hedge_ratio;
mod kama;
@@ -159,6 +193,7 @@ mod linreg_channel;
mod linreg_intercept;
mod linreg_slope;
mod liquidation_features;
mod log_return;
mod long_legged_doji;
mod long_line;
mod long_short_ratio;
@@ -201,7 +236,10 @@ mod omega_ratio;
mod on_neck;
mod opening_marubozu;
mod opening_range;
mod order_flow_imbalance;
mod ou_half_life;
mod overnight_gap;
mod overnight_intraday_return;
mod pain_index;
mod pair_spread_zscore;
mod pairwise_beta;
@@ -223,7 +261,10 @@ mod pvi;
mod quoted_spread;
mod r_squared;
mod realized_spread;
mod realized_volatility;
mod recovery_factor;
mod rectangle_range;
mod regime_label;
mod relative_strength_ab;
mod renko_bars;
mod renko_trailing_stop;
@@ -234,15 +275,24 @@ mod rocp;
mod rocr;
mod rocr100;
mod rogers_satchell;
mod roll_measure;
mod rolling_correlation;
mod rolling_covariance;
mod rolling_iqr;
mod rolling_percentile_rank;
mod rolling_quantile;
mod roofing_filter;
mod rsi;
mod rvi;
mod rvi_volatility;
mod rwi;
mod sar_ext;
mod seasonal_z_score;
mod separating_lines;
mod session_high_low;
mod session_range;
mod session_vwap;
mod shark;
mod sharpe_ratio;
mod shooting_star;
mod short_line;
@@ -255,6 +305,7 @@ mod smma;
mod sortino_ratio;
mod spearman_correlation;
mod spinning_top;
mod spread_ar1_coefficient;
mod spread_bollinger_bands;
mod spread_hurst;
mod stalled_pattern;
@@ -287,6 +338,7 @@ mod td_sequential;
mod td_setup;
mod tema;
mod term_structure_basis;
mod three_drives;
mod three_inside;
mod three_line_strike;
mod three_outside;
@@ -295,17 +347,22 @@ mod three_stars_in_south;
mod thrusting;
mod tick_index;
mod tii;
mod time_of_day_return_profile;
mod tpo_profile;
mod trade_imbalance;
mod trend_label;
mod treynor_ratio;
mod triangle;
mod trima;
mod trin;
mod triple_top_bottom;
mod trix;
mod true_range;
mod tsf;
mod tsi;
mod tsv;
mod ttm_squeeze;
mod turn_of_month;
mod tweezer;
mod two_crows;
mod typical_price;
@@ -322,18 +379,23 @@ mod variance_ratio;
mod vertical_horizontal_filter;
mod vidya;
mod volty_stop;
mod volume_by_time_profile;
mod volume_oscillator;
mod volume_profile;
mod vortex;
mod vpin;
mod vpt;
mod vwap;
mod vwap_stddev_bands;
mod vwma;
mod vzo;
mod wave_trend;
mod wedge;
mod weighted_close;
mod wick_ratio;
mod williams_fractals;
mod williams_r;
mod win_rate;
mod wma;
mod woodie_pivots;
mod yang_zhang;
@@ -344,6 +406,7 @@ mod zig_zag;
mod zlema;
pub use abandoned_baby::AbandonedBaby;
pub use abcd::Abcd;
pub use absolute_breadth_index::AbsoluteBreadthIndex;
pub use acceleration_bands::{AccelerationBands, AccelerationBandsOutput};
pub use accelerator_oscillator::AcceleratorOscillator;
@@ -359,6 +422,7 @@ pub use adxr::Adxr;
pub use alligator::{Alligator, AlligatorOutput};
pub use alma::Alma;
pub use alpha::Alpha;
pub use amihud_illiquidity::AmihudIlliquidity;
pub use anchored_rsi::AnchoredRsi;
pub use anchored_vwap::AnchoredVwap;
pub use apo::Apo;
@@ -367,20 +431,25 @@ pub use aroon_oscillator::AroonOscillator;
pub use atr::Atr;
pub use atr_bands::{AtrBands, AtrBandsOutput};
pub use atr_trailing_stop::AtrTrailingStop;
pub use auto_fib::{AutoFib, AutoFibOutput};
pub use autocorrelation::Autocorrelation;
pub use average_daily_range::AverageDailyRange;
pub use average_drawdown::AverageDrawdown;
pub use avg_price::AvgPrice;
pub use awesome_oscillator::AwesomeOscillator;
pub use awesome_oscillator_histogram::AwesomeOscillatorHistogram;
pub use balance_of_power::BalanceOfPower;
pub use bat::Bat;
pub use belt_hold::BeltHold;
pub use beta::Beta;
pub use beta_neutral_spread::BetaNeutralSpread;
pub use body_size_pct::BodySizePct;
pub use bollinger::{BollingerBands, BollingerOutput};
pub use bollinger_bandwidth::BollingerBandwidth;
pub use breadth_thrust::BreadthThrust;
pub use breakaway::Breakaway;
pub use bullish_percent_index::BullishPercentIndex;
pub use butterfly::Butterfly;
pub use calendar_spread::CalendarSpread;
pub use calmar_ratio::CalmarRatio;
pub use camarilla_pivots::{Camarilla, CamarillaPivotsOutput};
@@ -393,6 +462,7 @@ pub use chande_kroll_stop::{ChandeKrollStop, ChandeKrollStopOutput};
pub use chandelier_exit::{ChandelierExit, ChandelierExitOutput};
pub use choppiness_index::ChoppinessIndex;
pub use classic_pivots::{ClassicPivots, ClassicPivotsOutput};
pub use close_vs_open::CloseVsOpen;
pub use closing_marubozu::ClosingMarubozu;
pub use cmf::ChaikinMoneyFlow;
pub use cmo::Cmo;
@@ -403,9 +473,13 @@ pub use conditional_value_at_risk::ConditionalValueAtRisk;
pub use connors_rsi::ConnorsRsi;
pub use coppock::Coppock;
pub use counterattack::Counterattack;
pub use crab::Crab;
pub use cumulative_volume_index::CumulativeVolumeIndex;
pub use cup_and_handle::CupAndHandle;
pub use cvd::CumulativeVolumeDelta;
pub use cybernetic_cycle::CyberneticCycle;
pub use cypher::Cypher;
pub use day_of_week_profile::{DayOfWeekProfile, DayOfWeekProfileOutput};
pub use decycler::Decycler;
pub use decycler_oscillator::DecyclerOscillator;
pub use dema::Dema;
@@ -419,6 +493,7 @@ pub use doji_star::DojiStar;
pub use donchian::{Donchian, DonchianOutput};
pub use donchian_stop::{DonchianStop, DonchianStopOutput};
pub use double_bollinger::{DoubleBollinger, DoubleBollingerOutput};
pub use double_top_bottom::DoubleTopBottom;
pub use downside_gap_three_methods::DownsideGapThreeMethods;
pub use dpo::Dpo;
pub use dragonfly_doji::DragonflyDoji;
@@ -433,10 +508,20 @@ pub use empirical_mode_decomposition::EmpiricalModeDecomposition;
pub use engulfing::Engulfing;
pub use evening_doji_star::EveningDojiStar;
pub use evwma::Evwma;
pub use expectancy::Expectancy;
pub use falling_three_methods::FallingThreeMethods;
pub use fama::Fama;
pub use fib_arcs::{FibArcs, FibArcsOutput};
pub use fib_channel::{FibChannel, FibChannelOutput};
pub use fib_confluence::{FibConfluence, FibConfluenceOutput};
pub use fib_extension::{FibExtension, FibExtensionOutput};
pub use fib_fan::{FibFan, FibFanOutput};
pub use fib_projection::{FibProjection, FibProjectionOutput};
pub use fib_retracement::{FibRetracement, FibRetracementOutput};
pub use fib_time_zones::{FibTimeZones, FibTimeZonesOutput};
pub use fibonacci_pivots::{FibonacciPivots, FibonacciPivotsOutput};
pub use fisher_transform::FisherTransform;
pub use flag_pennant::FlagPennant;
pub use footprint::{Footprint, FootprintLevel, FootprintOutput};
pub use force_index::ForceIndex;
pub use fractal_chaos_bands::{FractalChaosBands, FractalChaosBandsOutput};
@@ -448,13 +533,17 @@ pub use funding_rate_zscore::FundingRateZScore;
pub use gain_loss_ratio::GainLossRatio;
pub use gap_side_by_side_white::GapSideBySideWhite;
pub use garman_klass::GarmanKlassVolatility;
pub use gartley::Gartley;
pub use golden_pocket::{GoldenPocket, GoldenPocketOutput};
pub use granger_causality::GrangerCausality;
pub use gravestone_doji::GravestoneDoji;
pub use hammer::Hammer;
pub use hanging_man::HangingMan;
pub use harami::Harami;
pub use head_and_shoulders::HeadAndShoulders;
pub use heikin_ashi::{HeikinAshi, HeikinAshiOutput};
pub use high_low_index::HighLowIndex;
pub use high_low_range::HighLowRange;
pub use high_wave::HighWave;
pub use hikkake::Hikkake;
pub use hikkake_modified::HikkakeModified;
@@ -475,9 +564,11 @@ pub use inertia::Inertia;
pub use information_ratio::InformationRatio;
pub use initial_balance::{InitialBalance, InitialBalanceOutput};
pub use instantaneous_trendline::InstantaneousTrendline;
pub use intraday_volatility_profile::{IntradayVolatilityProfile, IntradayVolatilityProfileOutput};
pub use inverse_fisher_transform::InverseFisherTransform;
pub use inverted_hammer::InvertedHammer;
pub use jma::Jma;
pub use jump_indicator::JumpIndicator;
pub use kagi_bars::{KagiBar, KagiBars};
pub use kalman_hedge_ratio::{KalmanHedgeRatio, KalmanHedgeRatioOutput};
pub use kama::Kama;
@@ -498,6 +589,7 @@ pub use linreg_channel::{LinRegChannel, LinRegChannelOutput};
pub use linreg_intercept::LinRegIntercept;
pub use linreg_slope::LinRegSlope;
pub use liquidation_features::{LiquidationFeatures, LiquidationFeaturesOutput};
pub use log_return::LogReturn;
pub use long_legged_doji::LongLeggedDoji;
pub use long_line::LongLine;
pub use long_short_ratio::LongShortRatio;
@@ -540,7 +632,10 @@ pub use omega_ratio::OmegaRatio;
pub use on_neck::OnNeck;
pub use opening_marubozu::OpeningMarubozu;
pub use opening_range::{OpeningRange, OpeningRangeOutput};
pub use order_flow_imbalance::OrderFlowImbalance;
pub use ou_half_life::OuHalfLife;
pub use overnight_gap::OvernightGap;
pub use overnight_intraday_return::{OvernightIntradayReturn, OvernightIntradayReturnOutput};
pub use pain_index::PainIndex;
pub use pair_spread_zscore::PairSpreadZScore;
pub use pairwise_beta::PairwiseBeta;
@@ -562,7 +657,10 @@ pub use pvi::Pvi;
pub use quoted_spread::QuotedSpread;
pub use r_squared::RSquared;
pub use realized_spread::RealizedSpread;
pub use realized_volatility::RealizedVolatility;
pub use recovery_factor::RecoveryFactor;
pub use rectangle_range::RectangleRange;
pub use regime_label::RegimeLabel;
pub use relative_strength_ab::{RelativeStrengthAB, RelativeStrengthOutput};
pub use renko_bars::{RenkoBars, RenkoBrick};
pub use renko_trailing_stop::RenkoTrailingStop;
@@ -573,15 +671,24 @@ pub use rocp::Rocp;
pub use rocr::Rocr;
pub use rocr100::Rocr100;
pub use rogers_satchell::RogersSatchellVolatility;
pub use roll_measure::RollMeasure;
pub use rolling_correlation::RollingCorrelation;
pub use rolling_covariance::RollingCovariance;
pub use rolling_iqr::RollingIqr;
pub use rolling_percentile_rank::RollingPercentileRank;
pub use rolling_quantile::RollingQuantile;
pub use roofing_filter::RoofingFilter;
pub use rsi::Rsi;
pub use rvi::Rvi;
pub use rvi_volatility::RviVolatility;
pub use rwi::{Rwi, RwiOutput};
pub use sar_ext::SarExt;
pub use seasonal_z_score::SeasonalZScore;
pub use separating_lines::SeparatingLines;
pub use session_high_low::{SessionHighLow, SessionHighLowOutput};
pub use session_range::{SessionRange, SessionRangeOutput};
pub use session_vwap::SessionVwap;
pub use shark::Shark;
pub use sharpe_ratio::SharpeRatio;
pub use shooting_star::ShootingStar;
pub use short_line::ShortLine;
@@ -594,6 +701,7 @@ pub use smma::Smma;
pub use sortino_ratio::SortinoRatio;
pub use spearman_correlation::SpearmanCorrelation;
pub use spinning_top::SpinningTop;
pub use spread_ar1_coefficient::SpreadAr1Coefficient;
pub use spread_bollinger_bands::{SpreadBollingerBands, SpreadBollingerBandsOutput};
pub use spread_hurst::SpreadHurst;
pub use stalled_pattern::StalledPattern;
@@ -626,6 +734,7 @@ pub use td_sequential::{TdSequential, TdSequentialOutput};
pub use td_setup::TdSetup;
pub use tema::Tema;
pub use term_structure_basis::TermStructureBasis;
pub use three_drives::ThreeDrives;
pub use three_inside::ThreeInside;
pub use three_line_strike::ThreeLineStrike;
pub use three_outside::ThreeOutside;
@@ -634,17 +743,22 @@ pub use three_stars_in_south::ThreeStarsInSouth;
pub use thrusting::Thrusting;
pub use tick_index::TickIndex;
pub use tii::Tii;
pub use time_of_day_return_profile::{TimeOfDayReturnProfile, TimeOfDayReturnProfileOutput};
pub use tpo_profile::{TpoProfile, TpoProfileOutput};
pub use trade_imbalance::TradeImbalance;
pub use trend_label::TrendLabel;
pub use treynor_ratio::TreynorRatio;
pub use triangle::Triangle;
pub use trima::Trima;
pub use trin::Trin;
pub use triple_top_bottom::TripleTopBottom;
pub use trix::Trix;
pub use true_range::TrueRange;
pub use tsf::Tsf;
pub use tsi::Tsi;
pub use tsv::Tsv;
pub use ttm_squeeze::{TtmSqueeze, TtmSqueezeOutput};
pub use turn_of_month::TurnOfMonth;
pub use tweezer::Tweezer;
pub use two_crows::TwoCrows;
pub use typical_price::TypicalPrice;
@@ -661,18 +775,23 @@ pub use variance_ratio::VarianceRatio;
pub use vertical_horizontal_filter::VerticalHorizontalFilter;
pub use vidya::Vidya;
pub use volty_stop::VoltyStop;
pub use volume_by_time_profile::{VolumeByTimeProfile, VolumeByTimeProfileOutput};
pub use volume_oscillator::VolumeOscillator;
pub use volume_profile::{VolumeProfile, VolumeProfileOutput};
pub use vortex::{Vortex, VortexOutput};
pub use vpin::Vpin;
pub use vpt::VolumePriceTrend;
pub use vwap::{RollingVwap, Vwap};
pub use vwap_stddev_bands::{VwapStdDevBands, VwapStdDevBandsOutput};
pub use vwma::Vwma;
pub use vzo::Vzo;
pub use wave_trend::{WaveTrend, WaveTrendOutput};
pub use wedge::Wedge;
pub use weighted_close::WeightedClose;
pub use wick_ratio::WickRatio;
pub use williams_fractals::{WilliamsFractals, WilliamsFractalsOutput};
pub use williams_r::WilliamsR;
pub use win_rate::WinRate;
pub use wma::Wma;
pub use woodie_pivots::{WoodiePivots, WoodiePivotsOutput};
pub use yang_zhang::YangZhangVolatility;
@@ -765,6 +884,7 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
"PlusDi",
"MinusDi",
"Dx",
"TrendLabel",
],
),
(
@@ -803,6 +923,8 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
"GarmanKlassVolatility",
"RogersSatchellVolatility",
"YangZhangVolatility",
"JumpIndicator",
"RegimeLabel",
],
),
(
@@ -906,6 +1028,16 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
"GrangerCausality",
"KalmanHedgeRatio",
"SpreadBollingerBands",
"LogReturn",
"RealizedVolatility",
"RollingIqr",
"RollingPercentileRank",
"RollingQuantile",
"SpreadAr1Coefficient",
"CloseVsOpen",
"BodySizePct",
"WickRatio",
"HighLowRange",
],
),
(
@@ -1043,6 +1175,10 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
"RealizedSpread",
"KylesLambda",
"Footprint",
"OrderFlowImbalance",
"Vpin",
"AmihudIlliquidity",
"RollMeasure",
],
),
(
@@ -1092,6 +1228,8 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
"TreynorRatio",
"InformationRatio",
"Alpha",
"WinRate",
"Expectancy",
],
),
(
@@ -1118,6 +1256,64 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
"TickIndex",
],
),
(
"Seasonality & Session",
&[
"SessionVwap",
"SessionHighLow",
"SessionRange",
"AverageDailyRange",
"OvernightGap",
"OvernightIntradayReturn",
"TurnOfMonth",
"SeasonalZScore",
"TimeOfDayReturnProfile",
"DayOfWeekProfile",
"IntradayVolatilityProfile",
"VolumeByTimeProfile",
],
),
(
"Chart Patterns",
&[
"DoubleTopBottom",
"TripleTopBottom",
"HeadAndShoulders",
"Triangle",
"Wedge",
"FlagPennant",
"RectangleRange",
"CupAndHandle",
],
),
(
"Harmonic Patterns",
&[
"Abcd",
"Gartley",
"Butterfly",
"Bat",
"Crab",
"Shark",
"Cypher",
"ThreeDrives",
],
),
(
"Fibonacci",
&[
"FibRetracement",
"FibExtension",
"FibProjection",
"AutoFib",
"GoldenPocket",
"FibConfluence",
"FibFan",
"FibArcs",
"FibChannel",
"FibTimeZones",
],
),
];
#[cfg(test)]
@@ -1146,6 +1342,6 @@ mod family_tests {
// the actual indicator count is the early-warning signal that an
// indicator was added without being assigned a family.
let total: usize = FAMILIES.iter().map(|(_, ns)| ns.len()).sum();
assert_eq!(total, 339, "FAMILIES total drifted from indicator count");
assert_eq!(total, 396, "FAMILIES total drifted from indicator count");
}
}
@@ -0,0 +1,242 @@
//! Order Flow Imbalance (OFI) from best-level order-book changes.
use std::collections::VecDeque;
use crate::microstructure::OrderBook;
use crate::traits::Indicator;
use crate::{Error, Result};
/// Order Flow Imbalance — the rolling sum of best-level order-flow events over
/// the last `period` order-book snapshots.
///
/// Following Cont, Kukanov & Stoikov (2014), each new snapshot contributes a
/// signed event from how the best bid and ask moved versus the previous one:
///
/// ```text
/// Δᵇ = qᵇₙ·1{Pᵇₙ ≥ Pᵇₙ₋₁} − qᵇₙ₋₁·1{Pᵇₙ ≤ Pᵇₙ₋₁} (bid pressure)
/// Δᵃ = qᵃₙ·1{Pᵃₙ ≤ Pᵃₙ₋₁} − qᵃₙ₋₁·1{Pᵃₙ ≥ Pᵃₙ₋₁} (ask pressure)
/// eₙ = Δᵇ Δᵃ
/// OFI = Σ eₙ over the last `period` snapshots
/// ```
///
/// A rising bid (or replenished bid size) and a falling/depleting ask both add
/// positive flow; the mirror subtracts. The rolling sum is a strong
/// short-horizon predictor of price moves: a large positive `OFI` reflects net
/// buying pressure at the top of book, a large negative `OFI` net selling.
///
/// `Input = OrderBook`. Each `update` is O(1) (only the best levels are read).
/// The first snapshot only seeds the reference quotes and emits `None`.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, Level, OrderBook, OrderFlowImbalance};
///
/// let mut ofi = OrderFlowImbalance::new(20).unwrap();
/// let book = OrderBook::new(
/// vec![Level::new(100.0, 5.0).unwrap()],
/// vec![Level::new(101.0, 4.0).unwrap()],
/// )
/// .unwrap();
/// assert_eq!(ofi.update(book), None); // first snapshot seeds the reference
/// ```
#[derive(Debug, Clone)]
pub struct OrderFlowImbalance {
period: usize,
prev: Option<(f64, f64, f64, f64)>, // (bid_px, bid_sz, ask_px, ask_sz)
window: VecDeque<f64>,
sum: f64,
}
impl OrderFlowImbalance {
/// Construct a new Order Flow Imbalance over the given snapshot 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: None,
window: VecDeque::with_capacity(period),
sum: 0.0,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for OrderFlowImbalance {
type Input = OrderBook;
type Output = f64;
fn update(&mut self, book: OrderBook) -> Option<f64> {
// A book with no levels on a side carries no best-level information.
let (Some(bid), Some(ask)) = (book.best_bid(), book.best_ask()) else {
return None;
};
let curr = (bid.price, bid.size, ask.price, ask.size);
let Some((pb_px, pb_sz, pa_px, pa_sz)) = self.prev else {
self.prev = Some(curr);
return None;
};
self.prev = Some(curr);
let (bid_px, bid_sz, ask_px, ask_sz) = curr;
// Bid pressure: size added when the bid does not retreat, minus size
// removed when the bid does not advance.
let delta_b = f64::from(u8::from(bid_px >= pb_px)) * bid_sz
- f64::from(u8::from(bid_px <= pb_px)) * pb_sz;
// Ask pressure: size added when the ask does not advance, minus size
// removed when the ask does not retreat.
let delta_a = f64::from(u8::from(ask_px <= pa_px)) * ask_sz
- f64::from(u8::from(ask_px >= pa_px)) * pa_sz;
let event = delta_b - delta_a;
if self.window.len() == self.period {
let old = self.window.pop_front().expect("window is non-empty");
self.sum -= old;
}
self.window.push_back(event);
self.sum += event;
if self.window.len() < self.period {
return None;
}
Some(self.sum)
}
fn reset(&mut self) {
self.prev = None;
self.window.clear();
self.sum = 0.0;
}
fn warmup_period(&self) -> usize {
// One snapshot seeds the reference quotes, then `period` events fill the
// window.
self.period + 1
}
fn is_ready(&self) -> bool {
self.window.len() == self.period
}
fn name(&self) -> &'static str {
"OrderFlowImbalance"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::microstructure::Level;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn book(bid_px: f64, bid_sz: f64, ask_px: f64, ask_sz: f64) -> OrderBook {
OrderBook::new(
vec![Level::new(bid_px, bid_sz).unwrap()],
vec![Level::new(ask_px, ask_sz).unwrap()],
)
.unwrap()
}
#[test]
fn rejects_zero_period() {
assert!(matches!(OrderFlowImbalance::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_and_metadata() {
let ofi = OrderFlowImbalance::new(20).unwrap();
assert_eq!(ofi.period(), 20);
assert_eq!(ofi.warmup_period(), 21);
assert_eq!(ofi.name(), "OrderFlowImbalance");
assert!(!ofi.is_ready());
}
#[test]
fn first_snapshot_is_none() {
let mut ofi = OrderFlowImbalance::new(2).unwrap();
assert_eq!(ofi.update(book(100.0, 5.0, 101.0, 4.0)), None);
}
#[test]
fn empty_book_side_is_none() {
// A book with no levels on a side (only constructible via
// `new_unchecked`, since `OrderBook::new` rejects empty sides) carries
// no best-level information and emits `None` without advancing state.
let mut ofi = OrderFlowImbalance::new(2).unwrap();
let empty = OrderBook::new_unchecked(vec![], vec![]);
assert_eq!(ofi.update(empty), None);
// A real book afterwards still seeds the reference (state untouched).
assert_eq!(ofi.update(book(100.0, 5.0, 101.0, 4.0)), None);
}
#[test]
fn rising_bid_adds_positive_flow() {
// period 1. Reference book, then the bid lifts (price up) with size 6:
// Δᵇ = 6 (bid_px > prev), Δᵃ = (ask unchanged px=) ask_sz - ask_sz = 0
// when ask is identical => e = 6.
let mut ofi = OrderFlowImbalance::new(1).unwrap();
ofi.update(book(100.0, 5.0, 101.0, 4.0));
let out = ofi.update(book(100.5, 6.0, 101.0, 4.0)).unwrap();
assert_relative_eq!(out, 6.0, epsilon = 1e-12);
}
#[test]
fn falling_bid_adds_negative_flow() {
// The bid drops in price: Δᵇ = prev_bid_sz (bid_px < prev) = 5,
// ask identical => Δᵃ = 0 => e = 5.
let mut ofi = OrderFlowImbalance::new(1).unwrap();
ofi.update(book(100.0, 5.0, 101.0, 4.0));
let out = ofi.update(book(99.5, 3.0, 101.0, 4.0)).unwrap();
assert_relative_eq!(out, -5.0, epsilon = 1e-12);
}
#[test]
fn rolling_sum_accumulates() {
let mut ofi = OrderFlowImbalance::new(2).unwrap();
ofi.update(book(100.0, 5.0, 101.0, 4.0));
let a = ofi.update(book(100.5, 6.0, 101.0, 4.0)); // warming (1 event)
assert!(a.is_none());
let b = ofi.update(book(101.0, 2.0, 101.5, 4.0)).unwrap(); // 2 events
// Second event: bid_px 101 > 100.5 => Δᵇ = 2; ask_px 101.5 > 101 =>
// Δᵃ = prev_ask_sz = 4 => e2 = 2 (4) = 6. Sum = 6 + 6 = 12.
assert_relative_eq!(b, 12.0, epsilon = 1e-12);
}
#[test]
fn reset_clears_state() {
let mut ofi = OrderFlowImbalance::new(2).unwrap();
ofi.update(book(100.0, 5.0, 101.0, 4.0));
ofi.update(book(100.5, 6.0, 101.0, 4.0));
ofi.update(book(101.0, 2.0, 101.5, 4.0));
assert!(ofi.is_ready());
ofi.reset();
assert!(!ofi.is_ready());
assert_eq!(ofi.update(book(100.0, 5.0, 101.0, 4.0)), None);
}
#[test]
fn batch_equals_streaming() {
let books: Vec<OrderBook> = (0..30)
.map(|i| {
let f = f64::from(i);
book(
100.0 + (f * 0.3).sin(),
5.0 + (f * 0.5).cos().abs(),
101.0 + (f * 0.3).sin(),
4.0 + (f * 0.4).sin().abs(),
)
})
.collect();
let batch = OrderFlowImbalance::new(10).unwrap().batch(&books);
let mut b = OrderFlowImbalance::new(10).unwrap();
let streamed: Vec<_> = books.iter().map(|x| b.update(x.clone())).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,191 @@
//! Overnight Gap — the return from the previous session's close to the current
//! session's open, detected automatically at each day boundary.
use crate::calendar::civil_from_timestamp;
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Close-to-open overnight gap as a simple return.
///
/// At every local day boundary the indicator computes
/// `open / previous_close - 1`, where `previous_close` is the close of the last
/// bar of the prior session and `open` is the open of the first bar of the new
/// session. The value holds for the rest of the session until the next boundary.
/// The boundary is the wall-clock day of [`Candle::timestamp`](crate::Candle)
/// shifted by `utc_offset_minutes`. The first session yields no gap (there is no
/// prior close to compare against).
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, OvernightGap};
///
/// let hour = 3_600_000;
/// let mut gap = OvernightGap::new(0);
/// // Day 1 closes at 100.
/// assert!(gap.update(Candle::new(99.0, 101.0, 98.0, 100.0, 1.0, 0).unwrap()).is_none());
/// // Day 2 opens at 105 -> gap = 105 / 100 - 1 = 0.05.
/// let g = gap.update(Candle::new(105.0, 106.0, 104.0, 105.5, 1.0, 24 * hour).unwrap()).unwrap();
/// assert!((g - 0.05).abs() < 1e-9);
/// ```
#[derive(Debug, Clone)]
pub struct OvernightGap {
utc_offset_minutes: i32,
day_key: Option<(i64, u32, u32)>,
last_close: Option<f64>,
gap: Option<f64>,
}
impl OvernightGap {
/// Construct an Overnight Gap indicator with the given UTC offset (minutes).
pub const fn new(utc_offset_minutes: i32) -> Self {
Self {
utc_offset_minutes,
day_key: None,
last_close: None,
gap: None,
}
}
/// Configured UTC offset in minutes.
pub const fn utc_offset_minutes(&self) -> i32 {
self.utc_offset_minutes
}
/// Most recent overnight gap if at least one day boundary has been crossed.
pub const fn value(&self) -> Option<f64> {
self.gap
}
}
impl Indicator for OvernightGap {
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);
if self.day_key != Some(key) {
if let Some(prev_close) = self.last_close {
self.gap = Some(if prev_close == 0.0 {
0.0
} else {
candle.open / prev_close - 1.0
});
}
self.day_key = Some(key);
}
self.last_close = Some(candle.close);
self.gap
}
fn reset(&mut self) {
self.day_key = None;
self.last_close = None;
self.gap = None;
}
fn warmup_period(&self) -> usize {
2
}
fn is_ready(&self) -> bool {
self.gap.is_some()
}
fn name(&self) -> &'static str {
"OvernightGap"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
const HOUR: i64 = 3_600_000;
fn c(open: f64, close: f64, ts: i64) -> Candle {
let high = open.max(close);
let low = open.min(close);
Candle::new(open, high, low, close, 1.0, ts).unwrap()
}
#[test]
fn metadata_and_accessors() {
let gap = OvernightGap::new(330);
assert_eq!(gap.utc_offset_minutes(), 330);
assert_eq!(gap.name(), "OvernightGap");
assert_eq!(gap.warmup_period(), 2);
assert!(!gap.is_ready());
assert!(gap.value().is_none());
}
#[test]
fn first_session_has_no_gap() {
let mut gap = OvernightGap::new(0);
assert!(gap.update(c(99.0, 100.0, 0)).is_none());
// Same day, still no gap.
assert!(gap.update(c(100.0, 101.0, HOUR)).is_none());
assert!(!gap.is_ready());
}
#[test]
fn computes_gap_at_day_boundary() {
let mut gap = OvernightGap::new(0);
gap.update(c(99.0, 100.0, 0)); // day 1 closes 100
let g = gap.update(c(105.0, 105.5, 24 * HOUR)).unwrap();
assert_relative_eq!(g, 0.05);
assert!(gap.is_ready());
// Holds for the rest of the session.
let same = gap.update(c(106.0, 107.0, 25 * HOUR)).unwrap();
assert_relative_eq!(same, 0.05);
}
#[test]
fn negative_gap_down() {
let mut gap = OvernightGap::new(0);
gap.update(c(99.0, 100.0, 0));
let g = gap.update(c(90.0, 91.0, 24 * HOUR)).unwrap();
assert_relative_eq!(g, -0.1);
}
#[test]
fn zero_prev_close_yields_zero_gap() {
let mut gap = OvernightGap::new(0);
gap.update(c(0.0, 0.0, 0)); // degenerate day 1 closing at 0
let g = gap.update(c(5.0, 6.0, 24 * HOUR)).unwrap();
assert_relative_eq!(g, 0.0);
}
#[test]
fn reset_clears_state() {
let mut gap = OvernightGap::new(0);
gap.update(c(99.0, 100.0, 0));
gap.update(c(105.0, 105.5, 24 * HOUR));
gap.reset();
assert!(!gap.is_ready());
assert!(gap.value().is_none());
assert!(gap.update(c(10.0, 11.0, 48 * HOUR)).is_none());
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..50)
.map(|i| {
c(
100.0 + f64::from(i % 7),
100.0 + f64::from(i % 5),
i64::from(i) * 6 * HOUR,
)
})
.collect();
let mut a = OvernightGap::new(0);
let mut b = OvernightGap::new(0);
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,225 @@
//! Overnight vs. Intraday Return — decomposes a session's total return into its
//! overnight (close-to-open) and intraday (open-to-close) components.
use crate::calendar::civil_from_timestamp;
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// The two return components of the current session.
///
/// `overnight` is fixed at the session open (`open / previous_close - 1`);
/// `intraday` updates with every bar (`close / open - 1`). Compounding the two —
/// `(1 + overnight)(1 + intraday) - 1` — reconstructs the full previous-close to
/// latest-close return.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct OvernightIntradayReturnOutput {
/// Close-to-open return carried into the session.
pub overnight: f64,
/// Open-to-latest-close return accumulated within the session.
pub intraday: f64,
}
/// Overnight / intraday return decomposition, re-anchored at each local day
/// boundary of [`Candle::timestamp`](crate::Candle) shifted by
/// `utc_offset_minutes`.
///
/// The first session yields no output (there is no prior close to anchor the
/// overnight leg); from the second session onward every bar reports both
/// components.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, OvernightIntradayReturn};
///
/// let hour = 3_600_000;
/// let mut oi = OvernightIntradayReturn::new(0);
/// // Day 1 closes at 100.
/// assert!(oi.update(Candle::new(99.0, 101.0, 98.0, 100.0, 1.0, 0).unwrap()).is_none());
/// // Day 2 opens 110 (overnight +10%), closes 121 (intraday +10%).
/// let v = oi.update(Candle::new(110.0, 122.0, 109.0, 121.0, 1.0, 24 * hour).unwrap()).unwrap();
/// assert!((v.overnight - 0.10).abs() < 1e-9);
/// assert!((v.intraday - 0.10).abs() < 1e-9);
/// ```
#[derive(Debug, Clone)]
pub struct OvernightIntradayReturn {
utc_offset_minutes: i32,
day_key: Option<(i64, u32, u32)>,
last_close: Option<f64>,
today_open: f64,
overnight: Option<f64>,
last: Option<OvernightIntradayReturnOutput>,
}
impl OvernightIntradayReturn {
/// Construct the indicator with the given UTC offset (minutes).
pub const fn new(utc_offset_minutes: i32) -> Self {
Self {
utc_offset_minutes,
day_key: None,
last_close: None,
today_open: 0.0,
overnight: None,
last: None,
}
}
/// Configured UTC offset in minutes.
pub const fn utc_offset_minutes(&self) -> i32 {
self.utc_offset_minutes
}
/// Most recent decomposition if at least one day boundary has been crossed.
pub const fn value(&self) -> Option<OvernightIntradayReturnOutput> {
self.last
}
}
impl Indicator for OvernightIntradayReturn {
type Input = Candle;
type Output = OvernightIntradayReturnOutput;
fn update(&mut self, candle: Candle) -> Option<OvernightIntradayReturnOutput> {
let civil = civil_from_timestamp(candle.timestamp, self.utc_offset_minutes);
let key = (civil.year, civil.month, civil.day);
if self.day_key != Some(key) {
if let Some(prev_close) = self.last_close {
self.overnight = Some(if prev_close == 0.0 {
0.0
} else {
candle.open / prev_close - 1.0
});
}
self.today_open = candle.open;
self.day_key = Some(key);
}
self.last_close = Some(candle.close);
let overnight = self.overnight?;
let intraday = if self.today_open == 0.0 {
0.0
} else {
candle.close / self.today_open - 1.0
};
let out = OvernightIntradayReturnOutput {
overnight,
intraday,
};
self.last = Some(out);
Some(out)
}
fn reset(&mut self) {
self.day_key = None;
self.last_close = None;
self.today_open = 0.0;
self.overnight = None;
self.last = None;
}
fn warmup_period(&self) -> usize {
2
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"OvernightIntradayReturn"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
const HOUR: i64 = 3_600_000;
fn c(open: f64, close: f64, ts: i64) -> Candle {
let high = open.max(close) + 1.0;
let low = open.min(close) - 1.0;
Candle::new(open, high, low.max(0.0), close, 1.0, ts).unwrap()
}
#[test]
fn metadata_and_accessors() {
let oi = OvernightIntradayReturn::new(-300);
assert_eq!(oi.utc_offset_minutes(), -300);
assert_eq!(oi.name(), "OvernightIntradayReturn");
assert_eq!(oi.warmup_period(), 2);
assert!(!oi.is_ready());
assert!(oi.value().is_none());
}
#[test]
fn first_session_yields_none() {
let mut oi = OvernightIntradayReturn::new(0);
assert!(oi.update(c(99.0, 100.0, 0)).is_none());
assert!(oi.update(c(100.0, 102.0, HOUR)).is_none());
assert!(!oi.is_ready());
}
#[test]
fn decomposes_overnight_and_intraday() {
let mut oi = OvernightIntradayReturn::new(0);
oi.update(c(99.0, 100.0, 0)); // day 1 close 100
let v = oi.update(c(110.0, 121.0, 24 * HOUR)).unwrap();
assert_relative_eq!(v.overnight, 0.10);
assert_relative_eq!(v.intraday, 0.10);
assert!(oi.is_ready());
}
#[test]
fn intraday_updates_through_the_session() {
let mut oi = OvernightIntradayReturn::new(0);
oi.update(c(99.0, 100.0, 0));
oi.update(c(110.0, 110.0, 24 * HOUR)); // open 110, close 110 -> intraday 0
let later = oi.update(c(111.0, 132.0, 25 * HOUR)).unwrap();
assert_relative_eq!(later.overnight, 0.10); // fixed at open
assert_relative_eq!(later.intraday, 0.20); // 132 / 110 - 1
}
#[test]
fn zero_anchors_yield_zero_components() {
let mut oi = OvernightIntradayReturn::new(0);
oi.update(c(1.0, 0.0, 0)); // day 1 closes at 0
// Day 2 opens at 0: overnight uses zero prev_close -> 0; intraday uses
// zero today_open -> 0.
let candle = Candle::new(0.0, 5.0, 0.0, 4.0, 1.0, 24 * HOUR).unwrap();
let v = oi.update(candle).unwrap();
assert_relative_eq!(v.overnight, 0.0);
assert_relative_eq!(v.intraday, 0.0);
}
#[test]
fn reset_clears_state() {
let mut oi = OvernightIntradayReturn::new(0);
oi.update(c(99.0, 100.0, 0));
oi.update(c(110.0, 121.0, 24 * HOUR));
oi.reset();
assert!(!oi.is_ready());
assert!(oi.value().is_none());
assert!(oi.update(c(50.0, 55.0, 48 * HOUR)).is_none());
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..48)
.map(|i| {
c(
100.0 + f64::from(i % 6),
100.0 + f64::from(i % 4),
i64::from(i) * 8 * HOUR,
)
})
.collect();
let mut a = OvernightIntradayReturn::new(0);
let mut b = OvernightIntradayReturn::new(0);
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,542 @@
//! Internal swing-pivot tracker shared by the chart-pattern and harmonic-pattern
//! detectors. Not a public indicator — it carries no `Indicator` impl and is
//! re-exported nowhere, so it is excluded from the public catalogue counter.
//!
//! The tracker mirrors [`crate::indicators::ZigZag`]'s non-repainting
//! percent-threshold confirmation logic, but differs in two ways that make it a
//! reusable building block rather than a standalone indicator:
//!
//! * It is **parameter-free at the call site** — the reversal threshold is baked
//! in by each detector as a compile-time constant, so construction is
//! infallible (`const fn new`) and there is no user-facing validation branch.
//! * It **accumulates a bounded history** of the most recently confirmed pivots
//! (capped at `cap`), so a detector can inspect the last few swings to match a
//! geometric template (double top, head-and-shoulders, the XABCD legs of a
//! harmonic pattern, …).
use crate::ohlcv::Candle;
/// Default fractional reversal threshold for pattern swing detection (5%). A
/// pivot is confirmed once price reverses by this fraction away from the running
/// extreme. Baked in so the pattern detectors stay parameter-free, mirroring the
/// candlestick-pattern family's fixed geometric thresholds.
pub(crate) const SWING_THRESHOLD: f64 = 0.05;
/// Default relative tolerance for two swing levels to count as "equal" (3%) —
/// the twin tops of a double top, the shoulders of a head-and-shoulders, the
/// flat boundary of a rectangle.
pub(crate) const LEVEL_TOLERANCE: f64 = 0.03;
/// A confirmed swing pivot: the extreme price the swing turned from, its
/// direction (`+1.0` for a swing high, `-1.0` for a swing low) and the bar index
/// at which that extreme occurred.
#[derive(Debug, Clone, Copy, PartialEq)]
pub(crate) struct Pivot {
/// Price of the confirmed swing extreme.
pub price: f64,
/// `+1.0` if the pivot is a swing high, `-1.0` if it is a swing low.
pub direction: f64,
/// Zero-based index of the candle at which the swing extreme occurred (not
/// the later bar that confirmed it). Used by the geometric Fibonacci tools
/// (fan, arcs, channel, time zones) to place trendlines and time offsets.
pub bar: usize,
}
/// Non-repainting percent-threshold swing tracker with a bounded pivot history.
///
/// Feeding a candle returns `true` exactly on the bar where a new pivot is
/// confirmed (price has reversed by the configured fraction away from the
/// running extreme); the newly confirmed pivot is appended to [`pivots`] and the
/// oldest is dropped once the cap is exceeded. Bars that merely extend the
/// running extreme, or that move less than the threshold, return `false`.
///
/// [`pivots`]: SwingTracker::pivots
#[derive(Debug, Clone)]
pub(crate) struct SwingTracker {
threshold: f64,
cap: usize,
/// Number of candles fed so far; the current bar index is `bars_seen - 1`.
bars_seen: usize,
state: Option<State>,
pivots: Vec<Pivot>,
}
#[derive(Debug, Clone, Copy)]
struct State {
/// `+1.0` while tracking a candidate high (uptrend), `-1.0` while tracking a
/// candidate low (downtrend).
direction: f64,
/// The running candidate extreme price.
extreme: f64,
/// Bar index at which the running extreme was last set.
extreme_bar: usize,
}
impl SwingTracker {
/// Construct a tracker with a fractional reversal `threshold` (e.g. `0.05`
/// for 5%) and a pivot history capped at `cap` entries.
///
/// The threshold is supplied by the detectors as a compile-time constant in
/// `(0, 1)`, so no runtime validation is performed — an out-of-range
/// constant would be a library bug caught by the unit tests, not invalid
/// caller input.
pub(crate) const fn new(threshold: f64, cap: usize) -> Self {
Self {
threshold,
cap,
bars_seen: 0,
state: None,
pivots: Vec::new(),
}
}
/// Feed one candle. Returns `true` when a new pivot was confirmed this bar.
pub(crate) fn update(&mut self, candle: Candle) -> bool {
let bar = self.bars_seen;
self.bars_seen += 1;
let Some(s) = self.state else {
// Bootstrap: seed an uptrend tracking the first candle's high.
self.state = Some(State {
direction: 1.0,
extreme: candle.high,
extreme_bar: bar,
});
return false;
};
if s.direction > 0.0 {
if candle.high > s.extreme {
// Extend the candidate high.
self.state = Some(State {
direction: 1.0,
extreme: candle.high,
extreme_bar: bar,
});
return false;
}
if candle.low <= s.extreme * (1.0 - self.threshold) {
// Confirm the swing high; flip to tracking this bar's low.
self.push(Pivot {
price: s.extreme,
direction: 1.0,
bar: s.extreme_bar,
});
self.state = Some(State {
direction: -1.0,
extreme: candle.low,
extreme_bar: bar,
});
return true;
}
false
} else {
if candle.low < s.extreme {
// Extend the candidate low.
self.state = Some(State {
direction: -1.0,
extreme: candle.low,
extreme_bar: bar,
});
return false;
}
if candle.high >= s.extreme * (1.0 + self.threshold) {
// Confirm the swing low; flip to tracking this bar's high.
self.push(Pivot {
price: s.extreme,
direction: -1.0,
bar: s.extreme_bar,
});
self.state = Some(State {
direction: 1.0,
extreme: candle.high,
extreme_bar: bar,
});
return true;
}
false
}
}
/// Zero-based index of the most recently fed candle. Saturates at `0` before
/// any candle has been seen.
pub(crate) fn current_bar(&self) -> usize {
self.bars_seen.saturating_sub(1)
}
fn push(&mut self, pivot: Pivot) {
self.pivots.push(pivot);
if self.pivots.len() > self.cap {
self.pivots.remove(0);
}
}
/// The confirmed pivots in chronological order (oldest first, newest last).
pub(crate) fn pivots(&self) -> &[Pivot] {
&self.pivots
}
/// Clear all state, returning the tracker to its just-constructed condition.
pub(crate) fn reset(&mut self) {
self.bars_seen = 0;
self.state = None;
self.pivots.clear();
}
}
/// The two most recent swing highs and lows from the last four (strictly
/// alternating) pivots, returned as `(high_old, high_new, low_old, low_new)`.
/// Used by the converging/diverging trendline patterns (triangle, wedge,
/// rectangle). The slice must hold at least four pivots.
pub(crate) fn recent_legs(pivots: &[Pivot]) -> (f64, f64, f64, f64) {
let n = pivots.len();
if pivots[n - 1].direction > 0.0 {
// … low_old, high_old, low_new, high_new (newest is a high)
(
pivots[n - 3].price,
pivots[n - 1].price,
pivots[n - 4].price,
pivots[n - 2].price,
)
} else {
// … high_old, low_old, high_new, low_new (newest is a low)
(
pivots[n - 4].price,
pivots[n - 2].price,
pivots[n - 3].price,
pivots[n - 1].price,
)
}
}
/// Relative-tolerance equality: `true` when `a` and `b` are within `tol`
/// (a fraction) of the larger magnitude. Used to decide whether two swing
/// levels (the twin highs of a double top, the shoulders of a head-and-shoulders,
/// a harmonic Fibonacci ratio) count as "the same".
pub(crate) fn approx_equal(a: f64, b: f64, tol: f64) -> bool {
let scale = a.abs().max(b.abs()).max(f64::MIN_POSITIVE);
(a - b).abs() <= tol * scale
}
/// The five most recent pivots interpreted as the X-A-B-C-D points of a harmonic
/// pattern, with the terminal direction. The slice must hold at least five
/// pivots. Each detector derives the leg lengths and Fibonacci ratios it needs
/// from these five prices.
#[derive(Debug, Clone, Copy)]
pub(crate) struct Xabcd {
pub x: f64,
pub a: f64,
pub b: f64,
pub c: f64,
pub d: f64,
/// `true` when the terminal point D is a swing low (a bullish, buy-side
/// completion); `false` when D is a swing high (bearish).
pub bullish: bool,
}
/// Read the last five pivots as an [`Xabcd`]. Pivots are guaranteed nonzero-leg
/// (the swing tracker only confirms moves of at least the threshold), so the
/// leg-ratio divisions in the detectors never divide by zero.
pub(crate) fn xabcd(pivots: &[Pivot]) -> Xabcd {
let n = pivots.len();
Xabcd {
x: pivots[n - 5].price,
a: pivots[n - 4].price,
b: pivots[n - 3].price,
c: pivots[n - 2].price,
d: pivots[n - 1].price,
bullish: pivots[n - 1].direction < 0.0,
}
}
/// `true` when every `(value, low, high)` triple satisfies `low <= value <= high`.
/// Harmonic detectors express their Fibonacci windows as a list of these triples;
/// evaluating them in one expression keeps the per-triple comparison on a single
/// line (no multi-line `&&` coverage gaps).
pub(crate) fn ratios_in(checks: &[(f64, f64, f64)]) -> bool {
checks
.iter()
.all(|&(value, low, high)| value >= low && value <= high)
}
/// Build a candle sequence that drives a `SwingTracker` (or any detector built
/// on one) to confirm exactly the given alternating pivot prices, in order.
///
/// `pivots` must start with a **high** and strictly alternate high/low, with
/// each consecutive pair differing by at least the swing threshold (5%) in the
/// correct direction (`high > adjacent low * 1.05`). The returned vector has one
/// seed candle plus one confirming candle per pivot; pivot `k` is confirmed by
/// candle `k + 1`. Only the high/low of each candle is meaningful — the pattern
/// detectors read swings, not bodies.
#[cfg(test)]
pub(crate) fn candles_for_pivots(pivots: &[f64]) -> Vec<Candle> {
fn bar(high: f64, low: f64, ts: i64) -> Candle {
Candle::new(low, high, low, low, 1.0, ts).unwrap()
}
let mut out = vec![bar(pivots[0], pivots[0] * 0.999, 0)];
let mut ts: i64 = 0;
for (k, &price) in pivots.iter().enumerate() {
ts += 1;
let is_high = k % 2 == 0;
let next = if k + 1 < pivots.len() {
pivots[k + 1]
} else if is_high {
price * 0.90
} else {
price * 1.10
};
let candle = if is_high {
// Reverse down from the candidate high `price` to confirm it.
bar(price * 0.99, next, ts)
} else {
// Reverse up from the candidate low `price` to confirm it.
bar(next, price * 1.01, ts)
};
out.push(candle);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn c_hl(high: f64, low: f64, ts: i64) -> Candle {
Candle::new(low, high, low, low, 1.0, ts).unwrap()
}
#[test]
fn first_bar_only_bootstraps_no_pivot() {
let mut t = SwingTracker::new(0.05, 6);
assert!(!t.update(c_hl(100.0, 99.5, 0)));
assert!(t.pivots().is_empty());
}
#[test]
fn extends_candidate_high_without_confirming() {
let mut t = SwingTracker::new(0.10, 6);
assert!(!t.update(c_hl(100.0, 99.5, 0)));
// A higher high merely raises the candidate — no pivot yet.
assert!(!t.update(c_hl(110.0, 109.0, 1)));
assert!(t.pivots().is_empty());
}
#[test]
fn uptrend_small_move_does_not_confirm() {
let mut t = SwingTracker::new(0.10, 6);
let _ = t.update(c_hl(100.0, 99.5, 0));
// A 1% dip is below the 10% threshold — neither extends nor confirms.
assert!(!t.update(c_hl(99.8, 99.0, 1)));
assert!(t.pivots().is_empty());
}
#[test]
fn confirms_high_then_low_alternating() {
let mut t = SwingTracker::new(0.10, 6);
let _ = t.update(c_hl(100.0, 99.5, 0)); // seed uptrend
let _ = t.update(c_hl(120.0, 119.5, 1)); // raise candidate high to 120
// Drop ≥10% below 120 → confirm the high at 120, flip to downtrend.
assert!(t.update(c_hl(101.0, 100.0, 2)));
assert_eq!(
t.pivots().last().copied(),
// The high extreme was set at bar 1, confirmed at bar 2.
Some(Pivot {
price: 120.0,
direction: 1.0,
bar: 1,
})
);
// Now in a downtrend: a lower low extends the candidate low.
assert!(!t.update(c_hl(100.5, 90.0, 3)));
// Rise ≥10% above 90 → confirm the low at 90.
assert!(t.update(c_hl(100.0, 99.0, 4)));
assert_eq!(
t.pivots().last().copied(),
// The low extreme was set at bar 3, confirmed at bar 4.
Some(Pivot {
price: 90.0,
direction: -1.0,
bar: 3,
})
);
}
#[test]
fn downtrend_tiny_rise_does_not_confirm() {
let mut t = SwingTracker::new(0.10, 6);
let _ = t.update(c_hl(100.0, 99.5, 0));
let _ = t.update(c_hl(120.0, 119.5, 1));
let _ = t.update(c_hl(101.0, 90.0, 2)); // confirm high, now downtrend at 90
// A 1% bounce is below threshold — no confirmation, no new candidate low.
assert!(!t.update(c_hl(91.0, 90.5, 3)));
assert_eq!(t.pivots().len(), 1);
}
#[test]
fn history_is_capped() {
let mut t = SwingTracker::new(0.10, 2);
// Drive an oscillation that confirms several pivots; only the last 2 stay.
let path = [
(100.0, 99.5),
(120.0, 119.5),
(101.0, 90.0), // confirm 120 (high)
(91.0, 90.5),
(110.0, 109.0), // confirm 90 (low)
(109.0, 95.0), // confirm 110 (high)
];
for (i, (h, l)) in path.iter().enumerate() {
let _ = t.update(c_hl(*h, *l, i64::try_from(i).unwrap()));
}
assert_eq!(t.pivots().len(), 2);
// The two most recent confirmations: low 90 then high 110.
assert_eq!(t.pivots()[0].price, 90.0);
assert_eq!(t.pivots()[1].price, 110.0);
}
#[test]
fn reset_clears_state_and_history() {
let mut t = SwingTracker::new(0.10, 6);
let _ = t.update(c_hl(100.0, 99.5, 0));
let _ = t.update(c_hl(120.0, 119.5, 1));
let _ = t.update(c_hl(101.0, 90.0, 2));
assert_eq!(t.pivots().len(), 1);
t.reset();
assert!(t.pivots().is_empty());
// After reset the next bar bootstraps again (returns false).
assert!(!t.update(c_hl(100.0, 99.5, 0)));
}
#[test]
fn recent_legs_extracts_highs_and_lows_either_ending() {
// Newest pivot a high: [low_old, high_old, low_new, high_new].
let ending_high = [
Pivot {
price: 100.0,
direction: -1.0,
bar: 0,
},
Pivot {
price: 120.0,
direction: 1.0,
bar: 0,
},
Pivot {
price: 110.0,
direction: -1.0,
bar: 0,
},
Pivot {
price: 121.0,
direction: 1.0,
bar: 0,
},
];
assert_eq!(recent_legs(&ending_high), (120.0, 121.0, 100.0, 110.0));
// Newest pivot a low: [high_old, low_old, high_new, low_new].
let ending_low = [
Pivot {
price: 120.0,
direction: 1.0,
bar: 0,
},
Pivot {
price: 100.0,
direction: -1.0,
bar: 0,
},
Pivot {
price: 110.0,
direction: 1.0,
bar: 0,
},
Pivot {
price: 99.0,
direction: -1.0,
bar: 0,
},
];
assert_eq!(recent_legs(&ending_low), (120.0, 110.0, 100.0, 99.0));
}
#[test]
fn xabcd_reads_last_five_pivots_and_direction() {
let pivots = [
Pivot {
price: 50.0,
direction: 1.0,
bar: 0,
},
Pivot {
price: 100.0,
direction: -1.0,
bar: 0,
}, // X
Pivot {
price: 140.0,
direction: 1.0,
bar: 0,
}, // A
Pivot {
price: 115.0,
direction: -1.0,
bar: 0,
}, // B
Pivot {
price: 128.0,
direction: 1.0,
bar: 0,
}, // C
Pivot {
price: 108.0,
direction: -1.0,
bar: 0,
}, // D (low → bullish)
];
let p = xabcd(&pivots);
assert_eq!(
(p.x, p.a, p.b, p.c, p.d),
(100.0, 140.0, 115.0, 128.0, 108.0)
);
assert!(p.bullish);
}
#[test]
fn ratios_in_checks_every_window() {
assert!(ratios_in(&[(0.6, 0.5, 0.7), (1.5, 1.0, 2.0)]));
assert!(!ratios_in(&[(0.6, 0.5, 0.7), (3.0, 1.0, 2.0)])); // second out of range
assert!(!ratios_in(&[(0.4, 0.5, 0.7)])); // below the window
}
#[test]
fn candles_for_pivots_realizes_the_requested_swings() {
let want = [120.0, 100.0, 125.0, 95.0];
let mut t = SwingTracker::new(0.05, 6);
for candle in candles_for_pivots(&want) {
let _ = t.update(candle);
}
let got: Vec<f64> = t.pivots().iter().map(|p| p.price).collect();
assert_eq!(got, want);
// Directions alternate starting from a high.
assert_eq!(t.pivots()[0].direction, 1.0);
assert_eq!(t.pivots()[1].direction, -1.0);
}
#[test]
fn approx_equal_relative_tolerance() {
assert!(approx_equal(100.0, 102.0, 0.03)); // 2% apart, within 3%
assert!(!approx_equal(100.0, 110.0, 0.03)); // 10% apart, outside 3%
assert!(approx_equal(0.0, 0.0, 0.01)); // both zero
assert!(approx_equal(-50.0, -49.0, 0.05)); // negative magnitudes
}
#[test]
fn tracks_extreme_bar_and_current_bar() {
let mut t = SwingTracker::new(0.10, 6);
// Before any candle, the current bar saturates at 0.
assert_eq!(t.current_bar(), 0);
let _ = t.update(c_hl(100.0, 99.5, 0));
let _ = t.update(c_hl(120.0, 119.5, 1)); // candidate high at bar 1
let _ = t.update(c_hl(101.0, 90.0, 2)); // confirm high @120 (extreme bar 1)
assert_eq!(t.current_bar(), 2);
assert_eq!(t.pivots().last().unwrap().bar, 1);
}
}
@@ -0,0 +1,240 @@
//! Realized Volatility from the sum of squared log returns.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Realized Volatility — the square root of the sum of squared log returns over
/// the trailing `period` bars.
///
/// ```text
/// r_t = ln(price_t / price_{t1})
/// RV = √( Σ r_t² over the last `period` returns )
/// ```
///
/// Unlike [`HistoricalVolatility`](crate::HistoricalVolatility) — which reports
/// the *annualised sample standard deviation* of log returns (mean-centred,
/// divided by `n 1`, scaled by `√trading_periods` and ×100) — realized
/// volatility is the **raw, un-centred, un-annualised** quadratic variation
/// estimator used in high-frequency econometrics. It makes no Gaussian
/// assumption and no mean subtraction: it simply accumulates squared returns,
/// which converges to the integrated variance of the price path as the
/// sampling frequency rises. Multiply by `√trading_periods` yourself if an
/// annual figure is wanted.
///
/// Non-finite and non-positive prices are ignored (the log return would be
/// undefined): the tick is dropped, state is left untouched, and the last
/// value is returned.
///
/// Each `update` is O(1): a running sum of squared returns is maintained over
/// the rolling window.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, RealizedVolatility};
///
/// let mut indicator = RealizedVolatility::new(20).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// last = indicator.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct RealizedVolatility {
period: usize,
prev_price: Option<f64>,
/// Rolling window of the last `period` log returns.
window: VecDeque<f64>,
sum_sq: f64,
last: Option<f64>,
}
impl RealizedVolatility {
/// Construct a new realized-volatility indicator.
///
/// `period` is the number of squared log returns accumulated in the 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_sq: 0.0,
last: None,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for RealizedVolatility {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
// Non-finite / non-positive prices are skipped: `ln(input / prev)` is
// undefined, so the tick must not enter the return window.
if !input.is_finite() || input <= 0.0 {
return self.last;
}
let Some(prev) = self.prev_price else {
self.prev_price = Some(input);
return None;
};
self.prev_price = Some(input);
// `prev` came from `self.prev_price`, gated by the guard above, so it is
// finite and positive — the log return is always well-defined.
let r = (input / prev).ln();
if self.window.len() == self.period {
let old = self.window.pop_front().expect("window is non-empty");
self.sum_sq -= old * old;
}
self.window.push_back(r);
self.sum_sq += r * r;
if self.window.len() < self.period {
return None;
}
// Floating-point subtraction in the rolling sum can leave a tiny
// negative residual when every return is ~0; clamp before the sqrt.
let rv = self.sum_sq.max(0.0).sqrt();
self.last = Some(rv);
Some(rv)
}
fn reset(&mut self) {
self.prev_price = None;
self.window.clear();
self.sum_sq = 0.0;
self.last = None;
}
fn warmup_period(&self) -> usize {
// The first log return needs a previous price, then the window fills.
self.period + 1
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"RealizedVolatility"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_zero_period() {
assert!(matches!(RealizedVolatility::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_and_metadata() {
let rv = RealizedVolatility::new(20).unwrap();
assert_eq!(rv.period(), 20);
assert_eq!(rv.warmup_period(), 21);
assert_eq!(rv.name(), "RealizedVolatility");
assert!(!rv.is_ready());
}
#[test]
fn first_emission_at_warmup_period() {
let mut rv = RealizedVolatility::new(5).unwrap();
let out = rv.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
for v in out.iter().take(5) {
assert!(v.is_none());
}
assert!(out[5].is_some());
}
#[test]
fn known_value() {
// Two equal +10% steps: r = ln(1.1) each. RV = √(2·ln(1.1)²).
let mut rv = RealizedVolatility::new(2).unwrap();
let out = rv.batch(&[100.0, 110.0, 121.0]);
let expected = (2.0 * (1.1_f64).ln().powi(2)).sqrt();
assert_relative_eq!(out[2].unwrap(), expected, epsilon = 1e-12);
}
#[test]
fn constant_series_yields_zero() {
let mut rv = RealizedVolatility::new(10).unwrap();
for v in rv.batch(&[100.0; 40]).into_iter().flatten() {
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
}
}
#[test]
fn output_is_non_negative() {
let mut rv = RealizedVolatility::new(20).unwrap();
let prices: Vec<f64> = (1..=200)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 12.0)
.collect();
for v in rv.batch(&prices).into_iter().flatten() {
assert!(
v >= 0.0,
"realized volatility must be non-negative, got {v}"
);
}
}
#[test]
fn ignores_non_finite_input() {
let mut rv = RealizedVolatility::new(5).unwrap();
let out = rv.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
let last = *out.last().unwrap();
assert!(last.is_some());
assert_eq!(rv.update(f64::NAN), last);
assert_eq!(rv.update(f64::INFINITY), last);
}
#[test]
fn skips_non_positive_prices() {
let mut rv = RealizedVolatility::new(5).unwrap();
let warmup = rv.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
let baseline = warmup.last().copied().flatten().expect("warmed up");
assert_eq!(rv.update(-5.0), Some(baseline));
assert_eq!(rv.update(0.0), Some(baseline));
// State untouched: a clone advanced by the same real tick agrees.
let mut control = rv.clone();
let after = rv.update(21.0).expect("ready");
assert_eq!(control.update(21.0).expect("ready"), after);
}
#[test]
fn reset_clears_state() {
let mut rv = RealizedVolatility::new(5).unwrap();
rv.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
assert!(rv.is_ready());
rv.reset();
assert!(!rv.is_ready());
assert_eq!(rv.update(1.0), None);
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (1..=120)
.map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 9.0)
.collect();
let batch = RealizedVolatility::new(20).unwrap().batch(&prices);
let mut b = RealizedVolatility::new(20).unwrap();
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,155 @@
//! Rectangle / Range chart pattern.
use crate::indicators::pattern_swing::{
approx_equal, recent_legs, SwingTracker, LEVEL_TOLERANCE, SWING_THRESHOLD,
};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Rectangle / Range — price oscillating between a roughly horizontal support
/// and resistance, a mean-reversion (range-trading) structure.
///
/// Built on confirmed swing pivots ([`SWING_THRESHOLD`] = 5%); recognised when the
/// last two highs and the last two lows are each flat within [`LEVEL_TOLERANCE`]
/// (3%):
///
/// ```text
/// flat highs (resistance) AND flat lows (support):
/// last pivot a low → +1 (a bounce off support — buy the range)
/// last pivot a high → -1 (a rejection at resistance — sell the range)
/// ```
///
/// Unlike the breakout patterns the rectangle is range-bound, so the sign
/// encodes the actionable mean-reversion direction of the just-confirmed touch.
/// Output is `+1.0` / `-1.0` / `0.0`; never `None`.
#[derive(Debug, Clone)]
pub struct RectangleRange {
swing: SwingTracker,
has_emitted: bool,
}
impl RectangleRange {
/// Construct a new Rectangle / Range detector.
pub const fn new() -> Self {
Self {
swing: SwingTracker::new(SWING_THRESHOLD, 4),
has_emitted: false,
}
}
}
impl Default for RectangleRange {
fn default() -> Self {
Self::new()
}
}
impl Indicator for RectangleRange {
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 (high_old, high_new, low_old, low_new) = recent_legs(pivots);
let flat_highs = approx_equal(high_old, high_new, LEVEL_TOLERANCE);
let flat_lows = approx_equal(low_old, low_new, LEVEL_TOLERANCE);
if flat_highs && flat_lows {
let last_is_high = pivots[pivots.len() - 1].direction > 0.0;
return Some(if last_is_high { -1.0 } else { 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 {
"RectangleRange"
}
}
#[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 = RectangleRange::new();
candles_for_pivots(pivots)
.into_iter()
.map(|c| indicator.update(c).unwrap())
.collect()
}
#[test]
fn accessors_and_metadata() {
let indicator = RectangleRange::new();
assert_eq!(indicator.name(), "RectangleRange");
assert_eq!(indicator.warmup_period(), 5);
assert!(!indicator.is_ready());
assert!(!RectangleRange::default().is_ready());
}
#[test]
fn range_bounce_off_support_is_plus_one() {
// Flat highs (120, 121), flat lows (100, 99); last pivot a low → +1.
let out = run(&[120.0, 100.0, 121.0, 99.0]);
assert_eq!(*out.last().unwrap(), 1.0);
}
#[test]
fn range_rejection_at_resistance_is_minus_one() {
// Same range but ending on a high pivot → -1.
let out = run(&[130.0, 100.0, 120.0, 99.0, 121.0]);
assert_eq!(*out.last().unwrap(), -1.0);
}
#[test]
fn trending_highs_are_not_a_rectangle() {
// Rising highs break the flat-resistance requirement → no rectangle.
let out = run(&[120.0, 100.0, 140.0, 99.0]);
assert_eq!(*out.last().unwrap(), 0.0);
}
#[test]
fn reset_clears_state() {
let mut indicator = RectangleRange::new();
for c in candles_for_pivots(&[120.0, 100.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, 100.0, 121.0, 99.0]);
let mut a = RectangleRange::new();
let mut b = RectangleRange::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,307 @@
//! Regime Label — volatility-quantile classification of the current bar.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::indicators::rolling_quantile::quantile_sorted;
use crate::traits::Indicator;
/// Regime Label — a discrete `{1, 0, +1}` classification of the current
/// volatility regime by where the latest rolling volatility falls within its
/// own recent distribution.
///
/// ```text
/// σₜ = sample stddev of the last `vol_period` log returns
/// q1,q3 = 25th / 75th percentile of the last `lookback` σ readings
/// label = 1 if σₜ < q1 (calm regime)
/// +1 if σₜ > q3 (stressed regime)
/// 0 otherwise (normal regime)
/// ```
///
/// This is the canonical rolling-volatility-quantile regime split: rather than
/// thresholding absolute volatility (which is not comparable across instruments
/// or epochs), it asks whether *today's* volatility is unusually low or high
/// **relative to its own recent history**. `1` is a calm regime, `+1` a
/// stressed / high-volatility regime, `0` the normal middle. Because the latest
/// reading is included in its own reference window, a freshly elevated
/// volatility prints `+1` until the window catches up to the new level — it
/// flags the *transition*, not just the absolute level. When the recent
/// volatilities are all equal (`q1 == q3`, e.g. a constant drift) there is no
/// spread to classify against and the label is `0`.
///
/// Each `update` is `O(vol_period + lookback log lookback)`. Non-finite and
/// non-positive prices are ignored.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, RegimeLabel};
///
/// let mut indicator = RegimeLabel::new(5, 20).unwrap();
/// let mut last = None;
/// for i in 0..60 {
/// last = indicator.update(100.0 + (f64::from(i) * 0.5).sin());
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct RegimeLabel {
vol_period: usize,
lookback: usize,
prev_price: Option<f64>,
/// Trailing window of the last `vol_period` log returns.
ret_window: VecDeque<f64>,
ret_sum: f64,
ret_sum_sq: f64,
/// Trailing window of the last `lookback` volatility readings.
vol_window: VecDeque<f64>,
/// Reusable scratch buffer for the quantile sort.
scratch: Vec<f64>,
last: Option<f64>,
}
impl RegimeLabel {
/// Construct a new Regime Label classifier.
///
/// `vol_period` is the window for the rolling volatility; `lookback` is the
/// window of volatility readings whose quartiles set the regime bands.
///
/// # Errors
/// Returns [`Error::InvalidPeriod`] if `vol_period < 2` (the sample standard
/// deviation needs at least two returns) or if `lookback < 2` (the quartile
/// split needs at least two readings).
pub fn new(vol_period: usize, lookback: usize) -> Result<Self> {
if vol_period < 2 {
return Err(Error::InvalidPeriod {
message: "regime label needs vol_period >= 2",
});
}
if lookback < 2 {
return Err(Error::InvalidPeriod {
message: "regime label needs lookback >= 2",
});
}
Ok(Self {
vol_period,
lookback,
prev_price: None,
ret_window: VecDeque::with_capacity(vol_period),
ret_sum: 0.0,
ret_sum_sq: 0.0,
vol_window: VecDeque::with_capacity(lookback),
scratch: Vec::with_capacity(lookback),
last: None,
})
}
/// Configured `(vol_period, lookback)`.
pub const fn params(&self) -> (usize, usize) {
(self.vol_period, self.lookback)
}
}
impl Indicator for RegimeLabel {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() || input <= 0.0 {
return self.last;
}
let Some(prev) = self.prev_price else {
self.prev_price = Some(input);
return None;
};
self.prev_price = Some(input);
let r = (input / prev).ln();
// Roll the return window and its running moments.
if self.ret_window.len() == self.vol_period {
let old = self.ret_window.pop_front().expect("non-empty");
self.ret_sum -= old;
self.ret_sum_sq -= old * old;
}
self.ret_window.push_back(r);
self.ret_sum += r;
self.ret_sum_sq += r * r;
if self.ret_window.len() < self.vol_period {
return None;
}
let n = self.vol_period as f64;
let mean = self.ret_sum / n;
let var = ((self.ret_sum_sq - n * mean * mean) / (n - 1.0)).max(0.0);
let vol = var.sqrt();
// Roll the volatility window.
if self.vol_window.len() == self.lookback {
self.vol_window.pop_front();
}
self.vol_window.push_back(vol);
if self.vol_window.len() < self.lookback {
return None;
}
// Classify the latest volatility against the quartiles of the window.
self.scratch.clear();
self.scratch.extend(self.vol_window.iter().copied());
self.scratch.sort_by(f64::total_cmp);
let q1 = quantile_sorted(&self.scratch, 0.25);
let q3 = quantile_sorted(&self.scratch, 0.75);
let label = if vol < q1 {
-1.0
} else if vol > q3 {
1.0
} else {
0.0
};
self.last = Some(label);
Some(label)
}
fn reset(&mut self) {
self.prev_price = None;
self.ret_window.clear();
self.ret_sum = 0.0;
self.ret_sum_sq = 0.0;
self.vol_window.clear();
self.scratch.clear();
self.last = None;
}
fn warmup_period(&self) -> usize {
// One price seeds `prev`, `vol_period` returns yield the first vol, then
// `lookback` vols fill the regime window.
self.vol_period + self.lookback
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"RegimeLabel"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
#[test]
fn rejects_bad_periods() {
assert!(matches!(
RegimeLabel::new(1, 20),
Err(Error::InvalidPeriod { .. })
));
assert!(matches!(
RegimeLabel::new(5, 1),
Err(Error::InvalidPeriod { .. })
));
}
#[test]
fn accessors_and_metadata() {
let rl = RegimeLabel::new(5, 20).unwrap();
assert_eq!(rl.params(), (5, 20));
assert_eq!(rl.warmup_period(), 25);
assert_eq!(rl.name(), "RegimeLabel");
assert!(!rl.is_ready());
}
#[test]
fn detects_stressed_regime_on_volatility_spike() {
// Calm warmup, then a burst of large moves: the elevated volatility
// prints +1 while the lookback window still holds the calm readings.
let mut rl = RegimeLabel::new(4, 8).unwrap();
let mut prices: Vec<f64> = (0..24)
.map(|i| 100.0 + (f64::from(i) * 0.7).sin() * 0.2)
.collect();
let mut base = *prices.last().unwrap();
for i in 0..8 {
base *= if i % 2 == 0 { 1.08 } else { 0.93 };
prices.push(base);
}
let out = rl.batch(&prices);
assert!(
out.iter().flatten().any(|&v| v == 1.0),
"expected a stressed (+1) regime label"
);
}
#[test]
fn detects_calm_regime_after_volatility_drop() {
// Volatile warmup, then a calm tail: the depressed volatility prints -1.
let mut rl = RegimeLabel::new(4, 8).unwrap();
let mut prices: Vec<f64> = Vec::new();
let mut base = 100.0;
for i in 0..24 {
base *= if i % 2 == 0 { 1.05 } else { 0.96 };
prices.push(base);
}
for i in 0..12 {
prices.push(base + (f64::from(i) * 0.7).sin() * 0.05);
}
let out = rl.batch(&prices);
assert!(
out.iter().flatten().any(|&v| v == -1.0),
"expected a calm (-1) regime label"
);
}
#[test]
fn zero_volatility_is_neutral() {
// A constant price has exactly-zero returns => zero volatility on every
// window => q1 == q3 == 0 => neutral 0 throughout. (A geometric drift is
// *conceptually* constant-vol too, but floating-point rounding of the
// log returns leaves ~1e-16 dispersion, so the exactly-flat series is
// the clean way to pin the q1 == q3 branch.)
let mut rl = RegimeLabel::new(4, 8).unwrap();
for v in rl.batch(&[100.0; 40]).into_iter().flatten() {
assert_eq!(v, 0.0);
}
}
#[test]
fn output_is_ternary() {
let mut rl = RegimeLabel::new(5, 20).unwrap();
let prices: Vec<f64> = (0..300)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * (1.0 + (f64::from(i) * 0.05).sin() * 5.0))
.collect();
for v in rl.batch(&prices).into_iter().flatten() {
assert!(v == -1.0 || v == 0.0 || v == 1.0, "non-ternary label {v}");
}
}
#[test]
fn ignores_non_finite_and_non_positive() {
let mut rl = RegimeLabel::new(4, 6).unwrap();
let prices: Vec<f64> = (0..40)
.map(|i| 100.0 + (f64::from(i) * 0.5).sin() * 2.0)
.collect();
let out = rl.batch(&prices);
let last = *out.last().unwrap();
assert!(last.is_some());
assert_eq!(rl.update(f64::NAN), last);
assert_eq!(rl.update(-1.0), last);
assert_eq!(rl.update(0.0), last);
}
#[test]
fn reset_clears_state() {
let mut rl = RegimeLabel::new(4, 6).unwrap();
rl.batch(&(1..=40).map(f64::from).collect::<Vec<_>>());
assert!(rl.is_ready());
rl.reset();
assert!(!rl.is_ready());
assert_eq!(rl.update(1.0), None);
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (1..=160)
.map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 4.0)
.collect();
let batch = RegimeLabel::new(5, 20).unwrap().batch(&prices);
let mut b = RegimeLabel::new(5, 20).unwrap();
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,210 @@
//! Roll Measure — effective spread implied by serial covariance of price changes.
use std::collections::VecDeque;
use crate::microstructure::Trade;
use crate::traits::Indicator;
use crate::{Error, Result};
/// Roll Measure — the effective bid-ask spread implied by the negative
/// first-order serial covariance of trade-price changes (Roll, 1984).
///
/// ```text
/// Δpₜ = priceₜ priceₜ₋₁
/// γ = sample lag-1 autocovariance of Δp over the last `period` changes
/// spread = 2 · √(−γ) if γ < 0, else 0
/// ```
///
/// Roll's insight: in a frictionless market price changes are serially
/// uncorrelated, but the *bid-ask bounce* — trades alternating between buying at
/// the ask and selling at the bid — induces a **negative** autocovariance whose
/// magnitude pins the spread. The measure recovers an effective spread from
/// trade prices alone, with no quote data. When the serial covariance is
/// non-negative (a trending or frictionless tape) the model implies no spread
/// and the indicator returns `0`.
///
/// `Input = Trade` (only the price is used). Each `update` is `O(period)`: the
/// autocovariance is recomputed from the window of price changes.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, Side, Trade, RollMeasure};
///
/// let mut roll = RollMeasure::new(20).unwrap();
/// let mut last = None;
/// // A clean bid-ask bounce of ±0.5 around 100 implies a spread near 1.0.
/// for i in 0..40 {
/// let price = if i % 2 == 0 { 100.0 } else { 101.0 };
/// last = roll.update(Trade::new(price, 1.0, Side::Buy, 0).unwrap());
/// }
/// assert!(last.unwrap() > 0.0);
/// ```
#[derive(Debug, Clone)]
pub struct RollMeasure {
period: usize,
prev_price: Option<f64>,
window: VecDeque<f64>,
}
impl RollMeasure {
/// Construct a new Roll Measure over the given window of price changes.
///
/// # Errors
/// Returns [`Error::InvalidPeriod`] if `period < 3` — the lag-1
/// autocovariance needs at least two consecutive change pairs.
pub fn new(period: usize) -> Result<Self> {
if period < 3 {
return Err(Error::InvalidPeriod {
message: "Roll measure needs period >= 3",
});
}
Ok(Self {
period,
prev_price: None,
window: VecDeque::with_capacity(period),
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for RollMeasure {
type Input = Trade;
type Output = f64;
fn update(&mut self, trade: Trade) -> Option<f64> {
let Some(prev) = self.prev_price else {
self.prev_price = Some(trade.price);
return None;
};
let change = trade.price - prev;
self.prev_price = Some(trade.price);
if self.window.len() == self.period {
self.window.pop_front();
}
self.window.push_back(change);
if self.window.len() < self.period {
return None;
}
// Sample lag-1 autocovariance of the price changes over the window.
let changes: Vec<f64> = self.window.iter().copied().collect();
let count = changes.len() as f64;
let mean = changes.iter().sum::<f64>() / count;
let pairs = (changes.len() - 1) as f64;
let mut cov = 0.0;
for pair in changes.windows(2) {
cov += (pair[0] - mean) * (pair[1] - mean);
}
cov /= pairs;
let spread = if cov < 0.0 { 2.0 * (-cov).sqrt() } else { 0.0 };
Some(spread)
}
fn reset(&mut self) {
self.prev_price = None;
self.window.clear();
}
fn warmup_period(&self) -> usize {
self.period + 1
}
fn is_ready(&self) -> bool {
self.window.len() == self.period
}
fn name(&self) -> &'static str {
"RollMeasure"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::microstructure::Side;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn trade(price: f64) -> Trade {
Trade::new(price, 1.0, Side::Buy, 0).unwrap()
}
#[test]
fn rejects_period_below_three() {
assert!(matches!(
RollMeasure::new(2),
Err(Error::InvalidPeriod { .. })
));
assert!(RollMeasure::new(3).is_ok());
}
#[test]
fn accessors_and_metadata() {
let roll = RollMeasure::new(20).unwrap();
assert_eq!(roll.period(), 20);
assert_eq!(roll.warmup_period(), 21);
assert_eq!(roll.name(), "RollMeasure");
assert!(!roll.is_ready());
}
#[test]
fn bid_ask_bounce_implies_spread() {
// Prices bounce 100/101 => Δp alternates +1/-1 => mean 0, lag-1
// autocov = -5/(6-1) = -1 over a 6-change window => spread = 2.
let mut roll = RollMeasure::new(6).unwrap();
let prices: Vec<Trade> = (0..20)
.map(|i| trade(if i % 2 == 0 { 100.0 } else { 101.0 }))
.collect();
let last = roll.batch(&prices).into_iter().flatten().last().unwrap();
assert_relative_eq!(last, 2.0, epsilon = 1e-12);
}
#[test]
fn trending_prices_imply_no_spread() {
// Monotone prices => constant Δp => zero-centred deviations => cov 0
// => spread 0.
let mut roll = RollMeasure::new(6).unwrap();
let prices: Vec<Trade> = (0..20).map(|i| trade(100.0 + f64::from(i))).collect();
for v in roll.batch(&prices).into_iter().flatten() {
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
}
}
#[test]
fn output_is_non_negative() {
let mut roll = RollMeasure::new(20).unwrap();
let prices: Vec<Trade> = (0..200)
.map(|i| trade(100.0 + (f64::from(i) * 0.7).sin() * 2.0))
.collect();
for v in roll.batch(&prices).into_iter().flatten() {
assert!(v >= 0.0, "spread must be non-negative, got {v}");
}
}
#[test]
fn reset_clears_state() {
let mut roll = RollMeasure::new(5).unwrap();
for i in 0..20 {
roll.update(trade(100.0 + f64::from(i % 2)));
}
assert!(roll.is_ready());
roll.reset();
assert!(!roll.is_ready());
assert_eq!(roll.update(trade(100.0)), None);
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<Trade> = (0..80)
.map(|i| trade(100.0 + (f64::from(i) * 0.6).sin() * 3.0))
.collect();
let batch = RollMeasure::new(14).unwrap().batch(&prices);
let mut b = RollMeasure::new(14).unwrap();
let streamed: Vec<_> = prices.iter().map(|t| b.update(*t)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,186 @@
//! Rolling Interquartile Range (IQR) over a trailing window.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::indicators::rolling_quantile::quantile_sorted;
use crate::traits::Indicator;
/// Interquartile Range of the last `period` values: `Q3 Q1`.
///
/// ```text
/// IQR = quantile(0.75) quantile(0.25)
/// ```
///
/// The IQR is the width of the central 50% of the window — the spread between
/// the third and first quartiles. It is a robust dispersion measure: unlike the
/// standard deviation it ignores the extreme tails entirely, so a single spike
/// barely moves it. That makes it the natural scale for outlier rules (the
/// classic *Tukey fence* flags points more than `1.5 · IQR` beyond a quartile)
/// and for volatility-regime splits that must not be dominated by one shock.
///
/// Both quartiles use the type-7 / NumPy-default linearly-interpolated
/// definition, identical to [`RollingQuantile`](crate::RollingQuantile). Each
/// `update` is O(period log period): the window is copied into a scratch buffer
/// and sorted once.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, RollingIqr};
///
/// let mut indicator = RollingIqr::new(20).unwrap();
/// let mut last = None;
/// for i in 0..40 {
/// last = indicator.update(100.0 + f64::from(i));
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct RollingIqr {
period: usize,
window: VecDeque<f64>,
/// Reusable scratch buffer to avoid allocating per `update`.
scratch: Vec<f64>,
}
impl RollingIqr {
/// Construct a new rolling IQR with the given period.
///
/// # Errors
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
window: VecDeque::with_capacity(period),
scratch: Vec::with_capacity(period),
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for RollingIqr {
type Input = f64;
type Output = f64;
fn update(&mut self, value: f64) -> Option<f64> {
if self.window.len() == self.period {
self.window.pop_front();
}
self.window.push_back(value);
if self.window.len() < self.period {
return None;
}
self.scratch.clear();
self.scratch.extend(self.window.iter().copied());
self.scratch.sort_by(f64::total_cmp);
let q1 = quantile_sorted(&self.scratch, 0.25);
let q3 = quantile_sorted(&self.scratch, 0.75);
Some(q3 - q1)
}
fn reset(&mut self) {
self.window.clear();
self.scratch.clear();
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.window.len() == self.period
}
fn name(&self) -> &'static str {
"RollingIqr"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_zero_period() {
assert!(matches!(RollingIqr::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_and_metadata() {
let iqr = RollingIqr::new(14).unwrap();
assert_eq!(iqr.period(), 14);
assert_eq!(iqr.warmup_period(), 14);
assert_eq!(iqr.name(), "RollingIqr");
assert!(!iqr.is_ready());
}
#[test]
fn reference_value() {
// sorted [10,20,30,40,50]: Q1 = q(0.25)= 10 + (4*0.25)*(...)= h=1.0 →20,
// Q3 = q(0.75): h = 4*0.75 = 3.0 → 40. IQR = 40 - 20 = 20.
let mut iqr = RollingIqr::new(5).unwrap();
let out = iqr.batch(&[50.0, 40.0, 30.0, 20.0, 10.0]);
assert_relative_eq!(out[4].unwrap(), 20.0, epsilon = 1e-12);
}
#[test]
fn constant_series_yields_zero() {
let mut iqr = RollingIqr::new(8).unwrap();
for v in iqr.batch(&[42.0; 20]).into_iter().flatten() {
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
}
}
#[test]
fn output_is_non_negative() {
let mut iqr = RollingIqr::new(20).unwrap();
let prices: Vec<f64> = (1..=200)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 12.0)
.collect();
for v in iqr.batch(&prices).into_iter().flatten() {
assert!(v >= 0.0, "IQR must be non-negative, got {v}");
}
}
#[test]
fn ignores_single_extreme_outlier() {
// 19 tightly-clustered values plus one huge spike: the central 50%
// is unaffected, so the IQR stays small (well below the spike scale).
let mut iqr = RollingIqr::new(20).unwrap();
let mut prices = vec![5.0; 19];
prices.push(10_000.0);
let last = iqr.batch(&prices).into_iter().flatten().last().unwrap();
assert!(last < 1.0, "spike leaked into IQR: {last}");
}
#[test]
fn reset_clears_state() {
let mut iqr = RollingIqr::new(5).unwrap();
iqr.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
assert!(iqr.is_ready());
iqr.reset();
assert!(!iqr.is_ready());
assert_eq!(iqr.update(1.0), None);
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (0..60)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0)
.collect();
let batch = RollingIqr::new(14).unwrap().batch(&prices);
let mut b = RollingIqr::new(14).unwrap();
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,191 @@
//! Rolling Percentile Rank of the latest value within its trailing window.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Percentile rank of the most-recent value within the last `period` values,
/// in `[0, 100]`.
///
/// ```text
/// rank = 100 · (#below + 0.5 · #equal) / period
/// ```
///
/// where `#below` counts window values strictly less than the current value and
/// `#equal` counts those equal to it (including the current value itself). This
/// is the "mean" method of `percentileofscore`: ties are split symmetrically,
/// so a flat window scores exactly `50`, the strict window maximum scores just
/// under `100`, and the strict minimum just over `0`.
///
/// Percentile rank turns any series into a bounded, self-normalising oscillator:
/// "where does today sit relative to its own recent history" — high readings
/// mark stretched extremes, mid readings mark the typical range. It is the
/// scale-free cousin of the z-score that makes no distributional assumption.
///
/// Each `update` is O(period): one linear pass tallies the comparisons.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, RollingPercentileRank};
///
/// let mut indicator = RollingPercentileRank::new(20).unwrap();
/// let mut last = None;
/// for i in 0..40 {
/// last = indicator.update(100.0 + f64::from(i));
/// }
/// // A strictly rising series puts the newest value near the top.
/// assert!(last.unwrap() > 90.0);
/// ```
#[derive(Debug, Clone)]
pub struct RollingPercentileRank {
period: usize,
window: VecDeque<f64>,
}
impl RollingPercentileRank {
/// Construct a new rolling percentile rank with the given period.
///
/// # Errors
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
window: VecDeque::with_capacity(period),
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for RollingPercentileRank {
type Input = f64;
type Output = f64;
fn update(&mut self, value: f64) -> Option<f64> {
if self.window.len() == self.period {
self.window.pop_front();
}
self.window.push_back(value);
if self.window.len() < self.period {
return None;
}
let mut below = 0_usize;
let mut equal = 0_usize;
for &x in &self.window {
if x < value {
below += 1;
} else if x == value {
equal += 1;
}
}
let score = (below as f64 + 0.5 * equal as f64) / self.period as f64 * 100.0;
Some(score)
}
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 {
"RollingPercentileRank"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_zero_period() {
assert!(matches!(
RollingPercentileRank::new(0),
Err(Error::PeriodZero)
));
}
#[test]
fn accessors_and_metadata() {
let pr = RollingPercentileRank::new(14).unwrap();
assert_eq!(pr.period(), 14);
assert_eq!(pr.warmup_period(), 14);
assert_eq!(pr.name(), "RollingPercentileRank");
assert!(!pr.is_ready());
}
#[test]
fn flat_window_scores_fifty() {
// All values equal: #below = 0, #equal = period → 0.5 → 50.
let mut pr = RollingPercentileRank::new(10).unwrap();
for v in pr.batch(&[7.0; 20]).into_iter().flatten() {
assert_relative_eq!(v, 50.0, epsilon = 1e-12);
}
}
#[test]
fn current_is_strict_maximum() {
// Window [1,2,3,4,5], current = 5: #below = 4, #equal = 1.
// (4 + 0.5) / 5 * 100 = 90.
let mut pr = RollingPercentileRank::new(5).unwrap();
let out = pr.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
assert_relative_eq!(out[4].unwrap(), 90.0, epsilon = 1e-12);
}
#[test]
fn current_is_strict_minimum() {
// Window [5,4,3,2,1], current = 1: #below = 0, #equal = 1.
// (0 + 0.5) / 5 * 100 = 10.
let mut pr = RollingPercentileRank::new(5).unwrap();
let out = pr.batch(&[5.0, 4.0, 3.0, 2.0, 1.0]);
assert_relative_eq!(out[4].unwrap(), 10.0, epsilon = 1e-12);
}
#[test]
fn output_within_bounds() {
let mut pr = RollingPercentileRank::new(20).unwrap();
let prices: Vec<f64> = (1..=200)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 12.0)
.collect();
for v in pr.batch(&prices).into_iter().flatten() {
assert!((0.0..=100.0).contains(&v), "out of bounds: {v}");
}
}
#[test]
fn reset_clears_state() {
let mut pr = RollingPercentileRank::new(5).unwrap();
pr.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
assert!(pr.is_ready());
pr.reset();
assert!(!pr.is_ready());
assert_eq!(pr.update(1.0), None);
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (0..60)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0)
.collect();
let batch = RollingPercentileRank::new(14).unwrap().batch(&prices);
let mut b = RollingPercentileRank::new(14).unwrap();
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,230 @@
//! Rolling Quantile over a trailing window.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// The `quantile`-th quantile of the last `period` values, with linear
/// interpolation between order statistics.
///
/// ```text
/// h = (period 1) · quantile
/// lower = ⌊h⌋
/// result = sorted[lower] + (h lower) · (sorted[lower + 1] sorted[lower])
/// ```
///
/// This is the type-7 / NumPy-default `quantile` definition: `quantile = 0.0`
/// returns the window minimum, `0.5` the median, `1.0` the maximum, and
/// fractional values interpolate linearly between the bracketing order
/// statistics. Rolling quantiles are the building block for distribution-aware
/// thresholds — a price sitting above its rolling 90th-percentile, a volatility
/// regime split at the 25th/75th percentiles, robust band edges that ignore the
/// tails.
///
/// Each `update` is O(period log period): the window is copied into a scratch
/// buffer and sorted with total ordering (NaN-safe).
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, RollingQuantile};
///
/// // Rolling median of the last 5 values.
/// let mut indicator = RollingQuantile::new(5, 0.5).unwrap();
/// let out = indicator.update(1.0);
/// assert!(out.is_none()); // warming up
/// ```
#[derive(Debug, Clone)]
pub struct RollingQuantile {
period: usize,
quantile: f64,
window: VecDeque<f64>,
/// Reusable scratch buffer to avoid allocating per `update`.
scratch: Vec<f64>,
}
impl RollingQuantile {
/// Construct a new rolling quantile.
///
/// `quantile` selects the order statistic in `[0.0, 1.0]`.
///
/// # Errors
/// Returns [`Error::PeriodZero`] if `period == 0`, or
/// [`Error::InvalidParameter`] if `quantile` is not a finite value in
/// `[0.0, 1.0]`.
pub fn new(period: usize, quantile: f64) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
if !quantile.is_finite() || !(0.0..=1.0).contains(&quantile) {
return Err(Error::InvalidParameter {
message: "rolling quantile must be a finite value in [0.0, 1.0]",
});
}
Ok(Self {
period,
quantile,
window: VecDeque::with_capacity(period),
scratch: Vec::with_capacity(period),
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
/// Configured quantile in `[0.0, 1.0]`.
pub const fn quantile(&self) -> f64 {
self.quantile
}
}
/// Linearly-interpolated quantile of a sorted, non-empty slice (type-7).
pub(crate) fn quantile_sorted(sorted: &[f64], quantile: f64) -> f64 {
let n = sorted.len();
if n == 1 {
return sorted[0];
}
let h = (n - 1) as f64 * quantile;
let lower = h.floor();
let idx = lower as usize;
// `idx <= n - 1`: when `quantile == 1.0`, `h == n - 1` and `idx == n - 1`,
// so the interpolation neighbour would be out of bounds — return the top.
if idx >= n - 1 {
return sorted[n - 1];
}
let frac = h - lower;
sorted[idx] + frac * (sorted[idx + 1] - sorted[idx])
}
impl Indicator for RollingQuantile {
type Input = f64;
type Output = f64;
fn update(&mut self, value: f64) -> Option<f64> {
if self.window.len() == self.period {
self.window.pop_front();
}
self.window.push_back(value);
if self.window.len() < self.period {
return None;
}
self.scratch.clear();
self.scratch.extend(self.window.iter().copied());
self.scratch.sort_by(f64::total_cmp);
Some(quantile_sorted(&self.scratch, self.quantile))
}
fn reset(&mut self) {
self.window.clear();
self.scratch.clear();
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.window.len() == self.period
}
fn name(&self) -> &'static str {
"RollingQuantile"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_zero_period() {
assert!(matches!(
RollingQuantile::new(0, 0.5),
Err(Error::PeriodZero)
));
}
#[test]
fn rejects_out_of_range_quantile() {
assert!(matches!(
RollingQuantile::new(5, -0.1),
Err(Error::InvalidParameter { .. })
));
assert!(matches!(
RollingQuantile::new(5, 1.1),
Err(Error::InvalidParameter { .. })
));
assert!(matches!(
RollingQuantile::new(5, f64::NAN),
Err(Error::InvalidParameter { .. })
));
}
#[test]
fn accessors_and_metadata() {
let q = RollingQuantile::new(14, 0.25).unwrap();
assert_eq!(q.period(), 14);
assert_relative_eq!(q.quantile(), 0.25, epsilon = 1e-12);
assert_eq!(q.warmup_period(), 14);
assert_eq!(q.name(), "RollingQuantile");
assert!(!q.is_ready());
}
#[test]
fn median_of_window() {
// Window [5, 1, 3, 2, 4] sorted [1,2,3,4,5] → median 3.
let mut q = RollingQuantile::new(5, 0.5).unwrap();
let out = q.batch(&[5.0, 1.0, 3.0, 2.0, 4.0]);
assert_relative_eq!(out[4].unwrap(), 3.0, epsilon = 1e-12);
}
#[test]
fn min_and_max_quantiles() {
let prices = [5.0, 1.0, 3.0, 2.0, 4.0];
let lo = RollingQuantile::new(5, 0.0).unwrap().batch(&prices)[4].unwrap();
let hi = RollingQuantile::new(5, 1.0).unwrap().batch(&prices)[4].unwrap();
assert_relative_eq!(lo, 1.0, epsilon = 1e-12);
assert_relative_eq!(hi, 5.0, epsilon = 1e-12);
}
#[test]
fn interpolated_quantile() {
// sorted [10,20,30,40]: q=0.25 → h=(4-1)*0.25=0.75 → 10 + 0.75*(20-10)=17.5.
let mut q = RollingQuantile::new(4, 0.25).unwrap();
let out = q.batch(&[40.0, 30.0, 20.0, 10.0]);
assert_relative_eq!(out[3].unwrap(), 17.5, epsilon = 1e-12);
}
#[test]
fn single_period_returns_value() {
// period 1: window holds one value; quantile of a singleton is itself.
let mut q = RollingQuantile::new(1, 0.3).unwrap();
assert_relative_eq!(q.update(7.0).unwrap(), 7.0, epsilon = 1e-12);
}
#[test]
fn reset_clears_state() {
let mut q = RollingQuantile::new(5, 0.5).unwrap();
q.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
assert!(q.is_ready());
q.reset();
assert!(!q.is_ready());
assert_eq!(q.update(1.0), None);
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (0..60)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0)
.collect();
let batch = RollingQuantile::new(14, 0.75).unwrap().batch(&prices);
let mut b = RollingQuantile::new(14, 0.75).unwrap();
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,232 @@
//! Seasonal Z-Score — how far the current bar's return sits from the historical
//! mean return of bars in the *same hour of day*, in standard deviations.
use crate::calendar::civil_from_timestamp;
use crate::ohlcv::Candle;
use crate::traits::Indicator;
const HOURS: usize = 24;
/// Seasonal Z-Score keyed on hour of day.
///
/// For every bar the indicator forms the simple return `close / previous_close - 1`
/// and compares it to the running mean and standard deviation of all prior
/// returns that fell in the *same* local hour (the wall-clock hour of
/// [`Candle::timestamp`](crate::Candle) shifted by `utc_offset_minutes`). The
/// output is `(return - hour_mean) / hour_std`. A bucket needs at least two prior
/// samples before it can emit; a bucket with zero historical variance reports
/// `0.0`. The per-hour statistics use Welford's online algorithm.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, SeasonalZScore};
///
/// let day = 24 * 3_600_000;
/// let mut z = SeasonalZScore::new(0);
/// // Same hour each day so they share a bucket; close grows then jumps.
/// for (i, close) in [100.0, 101.0, 103.0].iter().enumerate() {
/// z.update(Candle::new(*close, *close, *close, *close, 1.0, i as i64 * day).unwrap());
/// }
/// // Fourth same-hour sample has two priors in the bucket -> emits a z-score.
/// let out = z.update(Candle::new(110.0, 110.0, 110.0, 110.0, 1.0, 3 * day).unwrap());
/// assert!(out.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct SeasonalZScore {
utc_offset_minutes: i32,
prev_close: Option<f64>,
count: [u64; HOURS],
mean: [f64; HOURS],
m2: [f64; HOURS],
last: Option<f64>,
}
impl SeasonalZScore {
/// Construct a Seasonal Z-Score indicator with the given UTC offset (minutes).
pub const fn new(utc_offset_minutes: i32) -> Self {
Self {
utc_offset_minutes,
prev_close: None,
count: [0; HOURS],
mean: [0.0; HOURS],
m2: [0.0; HOURS],
last: None,
}
}
/// Configured UTC offset in minutes.
pub const fn utc_offset_minutes(&self) -> i32 {
self.utc_offset_minutes
}
/// Most recent z-score if a populated bucket has produced one.
pub const fn value(&self) -> Option<f64> {
self.last
}
fn z_for(&self, hour: usize, ret: f64) -> Option<f64> {
if self.count[hour] < 2 {
return None;
}
let variance = self.m2[hour] / (self.count[hour] - 1) as f64;
if variance > 0.0 {
Some((ret - self.mean[hour]) / variance.sqrt())
} else {
Some(0.0)
}
}
fn accumulate(&mut self, hour: usize, ret: f64) {
self.count[hour] += 1;
let delta = ret - self.mean[hour];
self.mean[hour] += delta / self.count[hour] as f64;
let delta2 = ret - self.mean[hour];
self.m2[hour] += delta * delta2;
}
}
impl Indicator for SeasonalZScore {
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 hour = civil.hour as usize;
let result = if let Some(prev) = self.prev_close {
let ret = if prev == 0.0 {
0.0
} else {
candle.close / prev - 1.0
};
let z = self.z_for(hour, ret);
self.accumulate(hour, ret);
z
} else {
None
};
self.prev_close = Some(candle.close);
if result.is_some() {
self.last = result;
}
result
}
fn reset(&mut self) {
self.prev_close = None;
self.count = [0; HOURS];
self.mean = [0.0; HOURS];
self.m2 = [0.0; HOURS];
self.last = None;
}
fn warmup_period(&self) -> usize {
2
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"SeasonalZScore"
}
}
#[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 z = SeasonalZScore::new(120);
assert_eq!(z.utc_offset_minutes(), 120);
assert_eq!(z.name(), "SeasonalZScore");
assert_eq!(z.warmup_period(), 2);
assert!(!z.is_ready());
assert!(z.value().is_none());
}
#[test]
fn no_output_until_bucket_has_two_priors() {
let mut z = SeasonalZScore::new(0);
// Each bar shares the same hour bucket (same time-of-day, daily spacing).
assert!(z.update(c(100.0, 0)).is_none()); // first: no return
assert!(z.update(c(101.0, DAY)).is_none()); // return #1 -> bucket has 0 priors
assert!(z.update(c(102.0, 2 * DAY)).is_none()); // return #2 -> bucket has 1 prior
// return #3 -> bucket has 2 priors -> emits.
assert!(z.update(c(104.0, 3 * DAY)).is_some());
assert!(z.is_ready());
}
#[test]
fn z_score_matches_manual_welford() {
let mut z = SeasonalZScore::new(0);
// Returns into one hourly bucket: r1 = 0.01, r2 = 0.02, r3 = 0.03.
z.update(c(100.0, 0));
z.update(c(101.0, DAY)); // r1 = 0.01
z.update(c(103.02, 2 * DAY)); // r2 = 0.02
// Priors {0.01, 0.02}: mean 0.015, sample std = sqrt(((.005)^2*2)/1).
let mean = 0.015;
let std = (((0.01_f64 - mean).powi(2) + (0.02 - mean).powi(2)) / 1.0).sqrt();
let r3 = 0.03;
let expected = (r3 - mean) / std;
let close = 103.02 * (1.0 + r3);
let out = z.update(c(close, 3 * DAY)).unwrap();
assert_relative_eq!(out, expected, epsilon = 1e-9);
}
#[test]
fn zero_variance_bucket_reports_zero() {
let mut z = SeasonalZScore::new(0);
// Constant return into the bucket -> variance 0 -> z = 0.
z.update(c(100.0, 0));
z.update(c(110.0, DAY)); // r1 = 0.10
z.update(c(121.0, 2 * DAY)); // r2 = 0.10
let out = z.update(c(133.1, 3 * DAY)).unwrap(); // r3 = 0.10
assert_relative_eq!(out, 0.0);
}
#[test]
fn zero_prev_close_uses_zero_return() {
let mut z = SeasonalZScore::new(0);
z.update(c(0.0, 0)); // prev close 0
z.update(c(0.0, DAY)); // ret = 0 (guarded), bucket sample
z.update(c(0.0, 2 * DAY)); // ret = 0, bucket now 2 priors
let out = z.update(c(0.0, 3 * DAY)).unwrap();
assert_relative_eq!(out, 0.0);
}
#[test]
fn reset_clears_state() {
let mut z = SeasonalZScore::new(0);
for i in 0..4 {
z.update(c(100.0 + f64::from(i), i64::from(i) * DAY));
}
z.reset();
assert!(!z.is_ready());
assert!(z.value().is_none());
assert!(z.update(c(100.0, 4 * DAY)).is_none());
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..50)
.map(|i| c(100.0 + f64::from(i % 9), i64::from(i) * 3 * 3_600_000))
.collect();
let mut a = SeasonalZScore::new(0);
let mut b = SeasonalZScore::new(0);
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,226 @@
//! Session High/Low — the running high and low of the current calendar-day
//! session, re-anchored automatically at each day boundary.
use crate::calendar::civil_from_timestamp;
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Session High/Low output: the high and low established so far in the current
/// session.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SessionHighLowOutput {
/// Highest high seen since the current session opened.
pub high: f64,
/// Lowest low seen since the current session opened.
pub low: f64,
}
/// Running high / low of the current session, keyed off the wall-clock day of
/// [`Candle::timestamp`](crate::Candle).
///
/// Unlike [`crate::OpeningRange`] or [`crate::InitialBalance`], which require the
/// caller to invoke `reset()` at every session boundary, this indicator detects
/// the boundary itself: whenever a candle falls on a different local calendar
/// day (after shifting by `utc_offset_minutes`) the high / low are re-anchored to
/// that candle. `utc_offset_minutes` lets callers align the day boundary to an
/// exchange session — `0` for UTC, `-300` for U.S. Eastern standard time.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, SessionHighLow};
///
/// // One bar per hour; the day rolls over after 24 bars at UTC.
/// let mut shl = SessionHighLow::new(0);
/// let hour = 3_600_000;
/// shl.update(Candle::new(100.0, 105.0, 99.0, 101.0, 1.0, 0).unwrap());
/// let v = shl.update(Candle::new(101.0, 108.0, 100.0, 107.0, 1.0, hour).unwrap()).unwrap();
/// assert_eq!(v.high, 108.0);
/// assert_eq!(v.low, 99.0);
/// // A bar on the next day re-anchors to that bar alone.
/// let v = shl.update(Candle::new(50.0, 51.0, 49.0, 50.0, 1.0, 24 * hour).unwrap()).unwrap();
/// assert_eq!(v.high, 51.0);
/// assert_eq!(v.low, 49.0);
/// ```
#[derive(Debug, Clone)]
pub struct SessionHighLow {
utc_offset_minutes: i32,
day_key: Option<(i64, u32, u32)>,
high: f64,
low: f64,
last: Option<SessionHighLowOutput>,
}
impl SessionHighLow {
/// Construct a Session High/Low indicator with the given UTC offset (minutes).
pub const fn new(utc_offset_minutes: i32) -> Self {
Self {
utc_offset_minutes,
day_key: None,
high: f64::NEG_INFINITY,
low: f64::INFINITY,
last: None,
}
}
/// Configured UTC offset in minutes.
pub const fn utc_offset_minutes(&self) -> i32 {
self.utc_offset_minutes
}
/// Most recent output if at least one bar has been seen.
pub const fn value(&self) -> Option<SessionHighLowOutput> {
self.last
}
}
impl Indicator for SessionHighLow {
type Input = Candle;
type Output = SessionHighLowOutput;
fn update(&mut self, candle: Candle) -> Option<SessionHighLowOutput> {
let civil = civil_from_timestamp(candle.timestamp, self.utc_offset_minutes);
let key = (civil.year, civil.month, civil.day);
if self.day_key == Some(key) {
if candle.high > self.high {
self.high = candle.high;
}
if candle.low < self.low {
self.low = candle.low;
}
} else {
self.day_key = Some(key);
self.high = candle.high;
self.low = candle.low;
}
let out = SessionHighLowOutput {
high: self.high,
low: self.low,
};
self.last = Some(out);
Some(out)
}
fn reset(&mut self) {
self.day_key = None;
self.high = f64::NEG_INFINITY;
self.low = f64::INFINITY;
self.last = None;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"SessionHighLow"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
const HOUR: i64 = 3_600_000;
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 metadata_and_accessors() {
let shl = SessionHighLow::new(-300);
assert_eq!(shl.utc_offset_minutes(), -300);
assert_eq!(shl.name(), "SessionHighLow");
assert_eq!(shl.warmup_period(), 1);
assert!(!shl.is_ready());
assert!(shl.value().is_none());
}
#[test]
fn tracks_high_low_within_day() {
let mut shl = SessionHighLow::new(0);
let first = shl.update(c(105.0, 99.0, 0)).unwrap();
assert_relative_eq!(first.high, 105.0);
assert_relative_eq!(first.low, 99.0);
assert!(shl.is_ready());
let second = shl.update(c(108.0, 100.0, HOUR)).unwrap();
assert_relative_eq!(second.high, 108.0);
assert_relative_eq!(second.low, 99.0);
// A narrower bar does not shrink the range.
let third = shl.update(c(106.0, 101.0, 2 * HOUR)).unwrap();
assert_relative_eq!(third.high, 108.0);
assert_relative_eq!(third.low, 99.0);
// A bar with a lower low extends the range downward (same day).
let fourth = shl.update(c(107.0, 95.0, 3 * HOUR)).unwrap();
assert_relative_eq!(fourth.high, 108.0);
assert_relative_eq!(fourth.low, 95.0);
}
#[test]
fn re_anchors_on_new_day() {
let mut shl = SessionHighLow::new(0);
shl.update(c(105.0, 99.0, 0));
shl.update(c(108.0, 100.0, HOUR));
let next = shl.update(c(51.0, 49.0, 24 * HOUR)).unwrap();
assert_relative_eq!(next.high, 51.0);
assert_relative_eq!(next.low, 49.0);
}
#[test]
fn utc_offset_shifts_day_boundary() {
// Two bars 1h apart straddling UTC midnight. At UTC they are different
// days; at +120 min they fall on the same local day.
let pre = 23 * HOUR; // 1970-01-01 23:00 UTC
let post = 24 * HOUR; // 1970-01-02 00:00 UTC
let mut utc = SessionHighLow::new(0);
utc.update(c(105.0, 99.0, pre));
let rolled = utc.update(c(108.0, 100.0, post)).unwrap();
assert_relative_eq!(rolled.high, 108.0);
assert_relative_eq!(rolled.low, 100.0); // re-anchored
let mut shifted = SessionHighLow::new(120);
shifted.update(c(105.0, 99.0, pre));
let same = shifted.update(c(108.0, 100.0, post)).unwrap();
assert_relative_eq!(same.high, 108.0);
assert_relative_eq!(same.low, 99.0); // same local day, range kept
}
#[test]
fn reset_clears_state() {
let mut shl = SessionHighLow::new(0);
shl.update(c(105.0, 99.0, 0));
shl.reset();
assert!(!shl.is_ready());
assert!(shl.value().is_none());
let after = shl.update(c(60.0, 50.0, HOUR)).unwrap();
assert_relative_eq!(after.high, 60.0);
assert_relative_eq!(after.low, 50.0);
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..30)
.map(|i| {
c(
100.0 + f64::from(i),
90.0 + f64::from(i) * 0.5,
i64::from(i) * HOUR,
)
})
.collect();
let mut a = SessionHighLow::new(0);
let mut b = SessionHighLow::new(0);
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,248 @@
//! Session Range — the high-minus-low range accumulated within each of the
//! three canonical trading sessions (Asia / EU / US) of the current day.
use crate::calendar::civil_from_timestamp;
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Session Range output: the current day's range within each session.
///
/// A session with no bars yet reports `0.0`. All three reset at the local day
/// boundary.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SessionRangeOutput {
/// High low within the Asia session (local hours `00:00..08:00`).
pub asia: f64,
/// High low within the EU session (local hours `08:00..16:00`).
pub eu: f64,
/// High low within the US session (local hours `16:00..24:00`).
pub us: f64,
}
#[derive(Debug, Clone, Copy)]
struct Extent {
high: f64,
low: f64,
}
impl Extent {
const EMPTY: Self = Self {
high: f64::NEG_INFINITY,
low: f64::INFINITY,
};
fn add(&mut self, candle: Candle) {
if candle.high > self.high {
self.high = candle.high;
}
if candle.low < self.low {
self.low = candle.low;
}
}
fn range(self) -> f64 {
if self.high >= self.low {
self.high - self.low
} else {
0.0
}
}
}
/// Per-session high-low range, keyed off the wall-clock hour of
/// [`Candle::timestamp`](crate::Candle).
///
/// The local day (after shifting by `utc_offset_minutes`) is split into three
/// eight-hour sessions: **Asia** `00:00..08:00`, **EU** `08:00..16:00`, **US**
/// `16:00..24:00`. Each session accumulates its own high / low; the reported
/// range is `high - low`, or `0.0` before that session has seen a bar. All three
/// re-anchor automatically at the day boundary.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, SessionRange};
///
/// let hour = 3_600_000;
/// let mut sr = SessionRange::new(0);
/// // 02:00 UTC — Asia session.
/// sr.update(Candle::new(100.0, 104.0, 98.0, 101.0, 1.0, 2 * hour).unwrap());
/// // 10:00 UTC — EU session.
/// let v = sr.update(Candle::new(101.0, 110.0, 100.0, 109.0, 1.0, 10 * hour).unwrap()).unwrap();
/// assert_eq!(v.asia, 6.0);
/// assert_eq!(v.eu, 10.0);
/// assert_eq!(v.us, 0.0);
/// ```
#[derive(Debug, Clone)]
pub struct SessionRange {
utc_offset_minutes: i32,
day_key: Option<(i64, u32, u32)>,
sessions: [Extent; 3],
last: Option<SessionRangeOutput>,
}
impl SessionRange {
/// Construct a Session Range indicator with the given UTC offset (minutes).
pub const fn new(utc_offset_minutes: i32) -> Self {
Self {
utc_offset_minutes,
day_key: None,
sessions: [Extent::EMPTY; 3],
last: None,
}
}
/// Configured UTC offset in minutes.
pub const fn utc_offset_minutes(&self) -> i32 {
self.utc_offset_minutes
}
/// Most recent output if at least one bar has been seen.
pub const fn value(&self) -> Option<SessionRangeOutput> {
self.last
}
fn snapshot(&self) -> SessionRangeOutput {
SessionRangeOutput {
asia: self.sessions[0].range(),
eu: self.sessions[1].range(),
us: self.sessions[2].range(),
}
}
}
impl Indicator for SessionRange {
type Input = Candle;
type Output = SessionRangeOutput;
fn update(&mut self, candle: Candle) -> Option<SessionRangeOutput> {
let civil = civil_from_timestamp(candle.timestamp, self.utc_offset_minutes);
let key = (civil.year, civil.month, civil.day);
if self.day_key != Some(key) {
self.day_key = Some(key);
self.sessions = [Extent::EMPTY; 3];
}
let session = (civil.hour / 8) as usize; // 0 Asia, 1 EU, 2 US
self.sessions[session].add(candle);
let out = self.snapshot();
self.last = Some(out);
Some(out)
}
fn reset(&mut self) {
self.day_key = None;
self.sessions = [Extent::EMPTY; 3];
self.last = None;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"SessionRange"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
const HOUR: i64 = 3_600_000;
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 metadata_and_accessors() {
let sr = SessionRange::new(60);
assert_eq!(sr.utc_offset_minutes(), 60);
assert_eq!(sr.name(), "SessionRange");
assert_eq!(sr.warmup_period(), 1);
assert!(!sr.is_ready());
assert!(sr.value().is_none());
}
#[test]
fn assigns_bars_to_sessions() {
let mut sr = SessionRange::new(0);
let asia = sr.update(c(104.0, 98.0, 2 * HOUR)).unwrap();
assert_relative_eq!(asia.asia, 6.0);
assert_relative_eq!(asia.eu, 0.0);
assert_relative_eq!(asia.us, 0.0);
assert!(sr.is_ready());
let eu = sr.update(c(110.0, 100.0, 10 * HOUR)).unwrap();
assert_relative_eq!(eu.eu, 10.0);
let us = sr.update(c(120.0, 118.0, 20 * HOUR)).unwrap();
assert_relative_eq!(us.us, 2.0);
assert_relative_eq!(us.asia, 6.0);
}
#[test]
fn widens_within_one_session() {
let mut sr = SessionRange::new(0);
sr.update(c(104.0, 98.0, HOUR));
let wider = sr.update(c(106.0, 95.0, 3 * HOUR)).unwrap();
assert_relative_eq!(wider.asia, 11.0);
}
#[test]
fn resets_sessions_on_new_day() {
let mut sr = SessionRange::new(0);
sr.update(c(104.0, 98.0, 2 * HOUR));
sr.update(c(110.0, 100.0, 10 * HOUR));
let next = sr.update(c(101.0, 99.0, (24 + 2) * HOUR)).unwrap();
assert_relative_eq!(next.asia, 2.0);
assert_relative_eq!(next.eu, 0.0);
}
#[test]
fn utc_offset_moves_bar_between_sessions() {
// 07:00 UTC is Asia; shifted +120 min it becomes 09:00 -> EU.
let mut utc = SessionRange::new(0);
let a = utc.update(c(104.0, 98.0, 7 * HOUR)).unwrap();
assert_relative_eq!(a.asia, 6.0);
assert_relative_eq!(a.eu, 0.0);
let mut shifted = SessionRange::new(120);
let e = shifted.update(c(104.0, 98.0, 7 * HOUR)).unwrap();
assert_relative_eq!(e.asia, 0.0);
assert_relative_eq!(e.eu, 6.0);
}
#[test]
fn reset_clears_state() {
let mut sr = SessionRange::new(0);
sr.update(c(104.0, 98.0, 2 * HOUR));
sr.reset();
assert!(!sr.is_ready());
assert!(sr.value().is_none());
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..40)
.map(|i| {
c(
100.0 + f64::from(i % 5),
95.0 - f64::from(i % 3),
i64::from(i) * HOUR,
)
})
.collect();
let mut a = SessionRange::new(0);
let mut b = SessionRange::new(0);
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,199 @@
//! Session VWAP — the volume-weighted average price accumulated since the start
//! of the current calendar-day session, re-anchored automatically each day.
use crate::calendar::civil_from_timestamp;
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Volume-weighted average price reset at each local day boundary.
///
/// Each bar contributes its typical price `(high + low + close) / 3` weighted by
/// volume. The running VWAP is `Σ(typical · volume) / Σ volume` over the current
/// session; if the session's volume is still zero the indicator falls back to the
/// latest typical price so the output is always finite. The session boundary is
/// the wall-clock day of [`Candle::timestamp`](crate::Candle) shifted by
/// `utc_offset_minutes`.
///
/// Where [`crate::RollingVwap`] averages over a fixed bar window and
/// [`crate::AnchoredVwap`] anchors at a caller-chosen bar, Session VWAP anchors
/// at the automatically detected day open.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, SessionVwap};
///
/// let hour = 3_600_000;
/// let mut vwap = SessionVwap::new(0);
/// // typical = 100, volume 10.
/// vwap.update(Candle::new(100.0, 100.0, 100.0, 100.0, 10.0, 0).unwrap());
/// // typical = 110, volume 30 -> VWAP = (100*10 + 110*30) / 40 = 107.5.
/// let v = vwap.update(Candle::new(110.0, 110.0, 110.0, 110.0, 30.0, hour).unwrap()).unwrap();
/// assert!((v - 107.5).abs() < 1e-9);
/// ```
#[derive(Debug, Clone)]
pub struct SessionVwap {
utc_offset_minutes: i32,
day_key: Option<(i64, u32, u32)>,
cum_pv: f64,
cum_volume: f64,
last: Option<f64>,
}
impl SessionVwap {
/// Construct a Session VWAP indicator with the given UTC offset (minutes).
pub const fn new(utc_offset_minutes: i32) -> Self {
Self {
utc_offset_minutes,
day_key: None,
cum_pv: 0.0,
cum_volume: 0.0,
last: None,
}
}
/// Configured UTC offset in minutes.
pub const fn utc_offset_minutes(&self) -> i32 {
self.utc_offset_minutes
}
/// Most recent VWAP if at least one bar has been seen.
pub const fn value(&self) -> Option<f64> {
self.last
}
}
impl Indicator for SessionVwap {
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);
if self.day_key != Some(key) {
self.day_key = Some(key);
self.cum_pv = 0.0;
self.cum_volume = 0.0;
}
let typical = (candle.high + candle.low + candle.close) / 3.0;
self.cum_pv += typical * candle.volume;
self.cum_volume += candle.volume;
let vwap = if self.cum_volume > 0.0 {
self.cum_pv / self.cum_volume
} else {
typical
};
self.last = Some(vwap);
Some(vwap)
}
fn reset(&mut self) {
self.day_key = None;
self.cum_pv = 0.0;
self.cum_volume = 0.0;
self.last = None;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"SessionVwap"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
const HOUR: i64 = 3_600_000;
fn c(price: f64, volume: f64, ts: i64) -> Candle {
Candle::new(price, price, price, price, volume, ts).unwrap()
}
#[test]
fn metadata_and_accessors() {
let vwap = SessionVwap::new(-480);
assert_eq!(vwap.utc_offset_minutes(), -480);
assert_eq!(vwap.name(), "SessionVwap");
assert_eq!(vwap.warmup_period(), 1);
assert!(!vwap.is_ready());
assert!(vwap.value().is_none());
}
#[test]
fn volume_weights_the_average() {
let mut vwap = SessionVwap::new(0);
let first = vwap.update(c(100.0, 10.0, 0)).unwrap();
assert_relative_eq!(first, 100.0);
assert!(vwap.is_ready());
let second = vwap.update(c(110.0, 30.0, HOUR)).unwrap();
assert_relative_eq!(second, 107.5);
}
#[test]
fn zero_volume_session_falls_back_to_typical() {
let mut vwap = SessionVwap::new(0);
let v = vwap.update(c(100.0, 0.0, 0)).unwrap();
assert_relative_eq!(v, 100.0);
let v2 = vwap.update(c(120.0, 0.0, HOUR)).unwrap();
assert_relative_eq!(v2, 120.0);
}
#[test]
fn re_anchors_on_new_day() {
let mut vwap = SessionVwap::new(0);
vwap.update(c(100.0, 10.0, 0));
vwap.update(c(110.0, 30.0, HOUR));
// New day: VWAP restarts from the first bar of day 2.
let next = vwap.update(c(200.0, 5.0, 24 * HOUR)).unwrap();
assert_relative_eq!(next, 200.0);
}
#[test]
fn typical_price_uses_high_low_close() {
let mut vwap = SessionVwap::new(0);
// typical = (120 + 90 + 102) / 3 = 104.
let candle = Candle::new(100.0, 120.0, 90.0, 102.0, 10.0, 0).unwrap();
let v = vwap.update(candle).unwrap();
assert_relative_eq!(v, 104.0);
}
#[test]
fn reset_clears_state() {
let mut vwap = SessionVwap::new(0);
vwap.update(c(100.0, 10.0, 0));
vwap.reset();
assert!(!vwap.is_ready());
assert!(vwap.value().is_none());
let after = vwap.update(c(50.0, 1.0, HOUR)).unwrap();
assert_relative_eq!(after, 50.0);
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..30)
.map(|i| {
c(
100.0 + f64::from(i),
1.0 + f64::from(i % 4),
i64::from(i) * HOUR,
)
})
.collect();
let mut a = SessionVwap::new(0);
let mut b = SessionVwap::new(0);
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
+155
View File
@@ -0,0 +1,155 @@
//! Shark harmonic pattern.
use crate::indicators::pattern_swing::{ratios_in, xabcd, SwingTracker, SWING_THRESHOLD};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Shark — a 5-point (X-A-B-C-D) harmonic pattern characterised by an
/// **expansion** leg (AB longer than XA) and a `0.886``1.13` D completion:
///
/// ```text
/// AB / XA ∈ [1.13, 1.618] (expansion — B overshoots X)
/// BC / AB ∈ [1.618, 2.24]
/// CD / BC ∈ [0.382, 0.886]
/// AD / XA ∈ [0.886, 1.13] (the defining D completion near A)
/// ```
///
/// This is the 5-point reading of the Shark; 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/shark.rs`.
#[derive(Debug, Clone)]
pub struct Shark {
swing: SwingTracker,
has_emitted: bool,
}
impl Shark {
/// Construct a new Shark detector.
pub const fn new() -> Self {
Self {
swing: SwingTracker::new(SWING_THRESHOLD, 5),
has_emitted: false,
}
}
}
impl Default for Shark {
fn default() -> Self {
Self::new()
}
}
impl Indicator for Shark {
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, 1.13, 1.618),
(bc / ab, 1.618, 2.24),
(cd / bc, 0.382, 0.886),
(ad / xa, 0.886, 1.13),
]);
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 {
"Shark"
}
}
#[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 = Shark::new();
candles_for_pivots(pivots)
.into_iter()
.map(|c| indicator.update(c).unwrap())
.collect()
}
#[test]
fn accessors_and_metadata() {
let indicator = Shark::new();
assert_eq!(indicator.name(), "Shark");
assert_eq!(indicator.warmup_period(), 6);
assert!(!indicator.is_ready());
assert!(!Shark::default().is_ready());
}
#[test]
fn bullish_shark_is_plus_one() {
let out = run(&[150.0, 100.0, 140.0, 88.0, 186.8, 100.0]);
assert_eq!(*out.last().unwrap(), 1.0);
assert!(out[..out.len() - 1].iter().all(|&x| x == 0.0));
}
#[test]
fn bearish_shark_is_minus_one() {
let out = run(&[150.0, 110.0, 162.0, 60.2, 150.0]);
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 = Shark::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, 88.0, 186.8, 100.0]);
let mut a = Shark::new();
let mut b = Shark::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,251 @@
//! AR(1) autoregression coefficient of the spread of two series.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// First-order autoregression coefficient `ρ` of the spread `a b`.
///
/// Each `update` takes one `(a, b)` price pair and forms the spread
/// `sₜ = aₜ bₜ`. Over the trailing window of `period` spreads the indicator
/// fits the discrete AR(1) model by ordinary least squares of the level on its
/// own lag:
///
/// ```text
/// sₜ = ρ · sₜ₋₁ + c + εₜ
/// ρ = cov(sₜ₋₁, sₜ) / var(sₜ₋₁)
/// ```
///
/// `ρ` is the direct measure of cointegration / mean-reversion strength of the
/// pair:
///
/// - `ρ` near `0` — the spread snaps back to its mean almost instantly (very
/// strong mean reversion).
/// - `ρ` near `1` — the spread behaves like a random walk (a unit root: no
/// reliable reversion, the pair is *not* cointegrated).
/// - `ρ > 1` — the spread is explosive (diverging).
///
/// This is the complement of [`OuHalfLife`](crate::OuHalfLife): the OU half-life
/// is `ln(2) / ln(ρ)` for `0 < ρ < 1`, but `ρ` itself is the raw, unbounded
/// stationarity statistic many pairs-trading screens threshold on directly
/// (e.g. "trade only pairs with `ρ < 0.9`"). When the spread is flat over the
/// window (`var(sₜ₋₁) = 0`) the regression slope is undefined and the indicator
/// returns `0`.
///
/// Each `update` is `O(period)`: the OLS slope is recomputed from the window's
/// running geometry.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, SpreadAr1Coefficient};
///
/// let mut ar1 = SpreadAr1Coefficient::new(40).unwrap();
/// let mut last = None;
/// for t in 0..120 {
/// let b = 100.0 + f64::from(t);
/// // `a` hugs `b` with a fast mean-reverting wobble ⇒ ρ well below 1.
/// let a = b + 2.0 * (f64::from(t) * 0.9).sin();
/// last = ar1.update((a, b));
/// }
/// let rho = last.unwrap();
/// assert!(rho > 0.0 && rho < 1.0);
/// ```
#[derive(Debug, Clone)]
pub struct SpreadAr1Coefficient {
period: usize,
window: VecDeque<f64>,
}
impl SpreadAr1Coefficient {
/// Construct a new AR(1) spread-coefficient estimator.
///
/// # Errors
/// Returns [`Error::InvalidPeriod`] if `period < 3` — the AR(1) regression
/// needs at least two `(level, next)` observations (a slope and an
/// intercept).
pub fn new(period: usize) -> Result<Self> {
if period < 3 {
return Err(Error::InvalidPeriod {
message: "AR(1) spread coefficient needs period >= 3",
});
}
Ok(Self {
period,
window: VecDeque::with_capacity(period),
})
}
/// Configured look-back window of spreads.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for SpreadAr1Coefficient {
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 {
self.window.pop_front();
}
self.window.push_back(a - b);
if self.window.len() < self.period {
return None;
}
// OLS slope ρ of the level on its own lag over the window.
let spreads: Vec<f64> = self.window.iter().copied().collect();
let count = (spreads.len() - 1) as f64;
let mut sum_level = 0.0;
let mut sum_next = 0.0;
let mut sum_ll = 0.0;
let mut sum_ln = 0.0;
for pair in spreads.windows(2) {
let level = pair[0];
let next = pair[1];
sum_level += level;
sum_next += next;
sum_ll += level * level;
sum_ln += level * next;
}
let mean_level = sum_level / count;
let mean_next = sum_next / count;
let var_level = sum_ll / count - mean_level * mean_level;
if var_level <= 0.0 {
// Flat spread: the regression has no defined slope.
return Some(0.0);
}
let cov = sum_ln / count - mean_level * mean_next;
Some(cov / var_level)
}
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 {
"SpreadAr1Coefficient"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_period_below_three() {
assert!(SpreadAr1Coefficient::new(2).is_err());
assert!(SpreadAr1Coefficient::new(3).is_ok());
}
#[test]
fn accessors_and_metadata() {
let ar1 = SpreadAr1Coefficient::new(30).unwrap();
assert_eq!(ar1.period(), 30);
assert_eq!(ar1.warmup_period(), 30);
assert_eq!(ar1.name(), "SpreadAr1Coefficient");
assert!(!ar1.is_ready());
}
#[test]
fn warmup_returns_none() {
let mut ar1 = SpreadAr1Coefficient::new(4).unwrap();
assert_eq!(ar1.update((1.0, 0.0)), None);
assert_eq!(ar1.update((2.0, 0.0)), None);
assert_eq!(ar1.update((3.0, 0.0)), None);
assert!(ar1.update((4.0, 0.0)).is_some());
assert!(ar1.is_ready());
}
#[test]
fn mean_reverting_spread_has_rho_below_one() {
// Fast sinusoidal spread around zero ⇒ stationary ⇒ 0 < ρ < 1.
let pairs: Vec<(f64, f64)> = (0..120)
.map(|t| {
let b = 100.0 + f64::from(t);
let a = b + 2.0 * (f64::from(t) * 0.9).sin();
(a, b)
})
.collect();
let last = SpreadAr1Coefficient::new(40)
.unwrap()
.batch(&pairs)
.into_iter()
.flatten()
.last()
.unwrap();
assert!(last > 0.0 && last < 1.0, "rho {last}");
}
#[test]
fn random_walk_spread_has_rho_near_one() {
// Spread = a b grows by exactly 1 each bar ⇒ next = level + 1 ⇒
// the OLS slope is exactly 1 (unit root).
let pairs: Vec<(f64, f64)> = (0..40)
.map(|t| (2.0 * f64::from(t), f64::from(t)))
.collect();
let last = SpreadAr1Coefficient::new(20)
.unwrap()
.batch(&pairs)
.into_iter()
.flatten()
.last()
.unwrap();
assert_relative_eq!(last, 1.0, epsilon = 1e-9);
}
#[test]
fn flat_spread_returns_zero() {
// a b is constant ⇒ var(level) = 0 ⇒ undefined ⇒ 0.
let pairs: Vec<(f64, f64)> = (0..30)
.map(|t| (5.0 + f64::from(t), f64::from(t)))
.collect();
let last = SpreadAr1Coefficient::new(10)
.unwrap()
.batch(&pairs)
.into_iter()
.flatten()
.last()
.unwrap();
assert_eq!(last, 0.0);
}
#[test]
fn reset_clears_state() {
let mut ar1 = SpreadAr1Coefficient::new(5).unwrap();
for t in 0..10 {
ar1.update((f64::from(t) + (f64::from(t) * 0.7).sin(), f64::from(t)));
}
assert!(ar1.is_ready());
ar1.reset();
assert!(!ar1.is_ready());
assert_eq!(ar1.update((1.0, 0.0)), None);
}
#[test]
fn batch_equals_streaming() {
let pairs: Vec<(f64, f64)> = (0..80)
.map(|t| {
let b = 50.0 + 0.5 * f64::from(t);
(b + (f64::from(t) * 0.6).sin(), b)
})
.collect();
let batch = SpreadAr1Coefficient::new(25).unwrap().batch(&pairs);
let mut ar1 = SpreadAr1Coefficient::new(25).unwrap();
let streamed: Vec<_> = pairs.iter().map(|p| ar1.update(*p)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,155 @@
//! Three Drives harmonic pattern.
use crate::indicators::pattern_swing::{
approx_equal, ratios_in, xabcd, SwingTracker, SWING_THRESHOLD,
};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Three Drives — a symmetric harmonic pattern of two visible drives separated
/// by two retracements, read from the last five pivots `X-A-B-C-D` (the two
/// drive legs are `A→B` and `C→D`):
///
/// ```text
/// AB / XA ∈ [1.13, 1.75] (drive 1 extends the prior retracement)
/// CD / BC ∈ [1.13, 1.75] (drive 2 extends symmetrically)
/// AB ≈ CD (within 20%) (the two drives are similar in size)
/// XA ≈ BC (within 30%) (the two retracements are similar)
/// ```
///
/// Output is `+1.0` (bullish, terminal D a swing low — drives down), `-1.0`
/// (bearish, drives up), or `0.0`; never `None`. See
/// `crates/wickra-core/src/indicators/three_drives.rs`.
#[derive(Debug, Clone)]
pub struct ThreeDrives {
swing: SwingTracker,
has_emitted: bool,
}
impl ThreeDrives {
/// Construct a new Three Drives detector.
pub const fn new() -> Self {
Self {
swing: SwingTracker::new(SWING_THRESHOLD, 5),
has_emitted: false,
}
}
}
impl Default for ThreeDrives {
fn default() -> Self {
Self::new()
}
}
impl Indicator for ThreeDrives {
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 extensions = ratios_in(&[(ab / xa, 1.13, 1.75), (cd / bc, 1.13, 1.75)]);
let symmetric = approx_equal(ab, cd, 0.20) && approx_equal(xa, bc, 0.30);
if extensions && symmetric {
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 {
"ThreeDrives"
}
}
#[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 = ThreeDrives::new();
candles_for_pivots(pivots)
.into_iter()
.map(|c| indicator.update(c).unwrap())
.collect()
}
#[test]
fn accessors_and_metadata() {
let indicator = ThreeDrives::new();
assert_eq!(indicator.name(), "ThreeDrives");
assert_eq!(indicator.warmup_period(), 6);
assert!(!indicator.is_ready());
assert!(!ThreeDrives::default().is_ready());
}
#[test]
fn bearish_three_drives_is_minus_one() {
// Three rising drives (120, 128, 136) → bearish exhaustion.
let out = run(&[120.0, 100.0, 128.0, 108.0, 136.0]);
assert_eq!(*out.last().unwrap(), -1.0);
assert!(out[..out.len() - 1].iter().all(|&x| x == 0.0));
}
#[test]
fn bullish_three_drives_is_plus_one() {
// Three falling drives → bullish exhaustion.
let out = run(&[150.0, 120.0, 140.0, 112.0, 132.0, 104.0]);
assert_eq!(*out.last().unwrap(), 1.0);
}
#[test]
fn asymmetric_drives_do_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 = ThreeDrives::new();
for c in candles_for_pivots(&[120.0, 100.0, 128.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, 128.0, 108.0, 136.0]);
let mut a = ThreeDrives::new();
let mut b = ThreeDrives::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,226 @@
//! Time-of-Day Return Profile — the mean bar return in each intraday time bucket.
use crate::calendar::civil_from_timestamp;
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Time-of-Day Return Profile output: the per-bucket mean return.
///
/// `bins[i]` is the mean simple return of all bars whose local time-of-day fell
/// in bucket `i`, where bucket `i` spans the minutes
/// `[i * 1440 / bins.len(), (i + 1) * 1440 / bins.len())`. Empty buckets read
/// `0.0`.
#[derive(Debug, Clone, PartialEq)]
pub struct TimeOfDayReturnProfileOutput {
/// Per-bucket mean return, earliest bucket first. Length equals `buckets`.
pub bins: Vec<f64>,
}
/// Mean bar return bucketed by local time of day.
///
/// The local day (the wall-clock day of [`Candle::timestamp`](crate::Candle)
/// shifted by `utc_offset_minutes`) is divided into `buckets` equal slices. Each
/// bar's simple return `close / previous_close - 1` is accumulated into the bucket
/// of its time-of-day, and the profile reports the running mean per bucket. The
/// first bar produces no output (no return yet).
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, TimeOfDayReturnProfile};
///
/// let hour = 3_600_000;
/// let mut prof = TimeOfDayReturnProfile::new(24, 0).unwrap();
/// 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, hour).unwrap()).unwrap();
/// assert_eq!(out.bins.len(), 24);
/// ```
#[derive(Debug, Clone)]
pub struct TimeOfDayReturnProfile {
buckets: usize,
utc_offset_minutes: i32,
prev_close: Option<f64>,
sum: Vec<f64>,
count: Vec<u64>,
last: Option<TimeOfDayReturnProfileOutput>,
}
impl TimeOfDayReturnProfile {
/// Construct a Time-of-Day Return Profile with `buckets` intraday slices.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `buckets == 0`.
pub fn new(buckets: usize, utc_offset_minutes: i32) -> Result<Self> {
if buckets == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
buckets,
utc_offset_minutes,
prev_close: None,
sum: vec![0.0; buckets],
count: vec![0; buckets],
last: None,
})
}
/// Configured `(buckets, utc_offset_minutes)`.
pub const fn params(&self) -> (usize, i32) {
(self.buckets, self.utc_offset_minutes)
}
/// Most recent profile if at least one return has been recorded.
pub fn value(&self) -> Option<&TimeOfDayReturnProfileOutput> {
self.last.as_ref()
}
fn bucket_of(&self, minute_of_day: u32) -> usize {
let raw = (minute_of_day as usize * self.buckets) / 1440;
raw.min(self.buckets - 1)
}
fn snapshot(&self) -> TimeOfDayReturnProfileOutput {
let bins = self
.sum
.iter()
.zip(&self.count)
.map(|(total, n)| if *n > 0 { total / *n as f64 } else { 0.0 })
.collect();
TimeOfDayReturnProfileOutput { bins }
}
}
impl Indicator for TimeOfDayReturnProfile {
type Input = Candle;
type Output = TimeOfDayReturnProfileOutput;
fn update(&mut self, candle: Candle) -> Option<TimeOfDayReturnProfileOutput> {
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 bucket = self.bucket_of(civil.minute_of_day());
self.sum[bucket] += ret;
self.count[bucket] += 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.iter_mut().for_each(|x| *x = 0.0);
self.count.iter_mut().for_each(|x| *x = 0);
self.last = None;
}
fn warmup_period(&self) -> usize {
2
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"TimeOfDayReturnProfile"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
const HOUR: i64 = 3_600_000;
fn c(close: f64, ts: i64) -> Candle {
Candle::new(close, close, close, close, 1.0, ts).unwrap()
}
#[test]
fn rejects_zero_buckets() {
assert!(matches!(
TimeOfDayReturnProfile::new(0, 0),
Err(Error::PeriodZero)
));
}
#[test]
fn metadata_and_accessors() {
let prof = TimeOfDayReturnProfile::new(24, -300).unwrap();
assert_eq!(prof.params(), (24, -300));
assert_eq!(prof.name(), "TimeOfDayReturnProfile");
assert_eq!(prof.warmup_period(), 2);
assert!(!prof.is_ready());
assert!(prof.value().is_none());
}
#[test]
fn buckets_by_hour_and_means_returns() {
let mut prof = TimeOfDayReturnProfile::new(24, 0).unwrap();
assert!(prof.update(c(100.0, 0)).is_none()); // 00:00, no return
// 01:00 return +0.01 -> bucket 1.
let out = prof.update(c(101.0, HOUR)).unwrap();
assert_eq!(out.bins.len(), 24);
assert_relative_eq!(out.bins[1], 0.01);
assert_relative_eq!(out.bins[0], 0.0);
assert!(prof.is_ready());
// 01:00 next day, return -> averages into bucket 1.
let out = prof.update(c(102.01, 25 * HOUR)).unwrap();
// two returns in bucket 1: 0.01 and 0.01 -> mean 0.01.
assert_relative_eq!(out.bins[1], 0.01);
}
#[test]
fn last_bucket_clamped_for_end_of_day() {
let mut prof = TimeOfDayReturnProfile::new(24, 0).unwrap();
prof.update(c(100.0, 23 * HOUR));
// 23:59 -> minute 1439 -> bucket min(23, 23) = 23.
let out = prof.update(c(110.0, 23 * HOUR + 59 * 60_000)).unwrap();
assert_relative_eq!(out.bins[23], 0.10);
}
#[test]
fn zero_prev_close_uses_zero_return() {
let mut prof = TimeOfDayReturnProfile::new(4, 0).unwrap();
prof.update(c(0.0, 0));
let out = prof.update(c(5.0, HOUR)).unwrap();
assert_relative_eq!(out.bins[0], 0.0);
}
#[test]
fn reset_clears_state() {
let mut prof = TimeOfDayReturnProfile::new(24, 0).unwrap();
prof.update(c(100.0, 0));
prof.update(c(101.0, HOUR));
prof.reset();
assert!(!prof.is_ready());
assert!(prof.value().is_none());
assert!(prof.update(c(100.0, 2 * HOUR)).is_none());
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..50)
.map(|i| c(100.0 + f64::from(i % 7), i64::from(i) * HOUR))
.collect();
let mut a = TimeOfDayReturnProfile::new(12, 0).unwrap();
let mut b = TimeOfDayReturnProfile::new(12, 0).unwrap();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,206 @@
//! Trend Label — the sign of the rolling least-squares slope.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Trend Label — a discrete `{1, 0, +1}` classification of the local trend from
/// the sign of the ordinary-least-squares slope over the last `period` values.
///
/// ```text
/// slope = Σ (tᵢ t̄)(xᵢ x̄) / Σ (tᵢ t̄)² (regress price on bar index)
/// label = +1 if slope > 0, 1 if slope < 0, 0 if slope == 0
/// ```
///
/// The sign of the regression slope is *scale-invariant* — it does not depend on
/// the nominal price level — which makes it a clean, comparable trend state
/// across instruments. `+1` marks a rising regression line, `1` a falling one,
/// and `0` a perfectly flat window. It is the discrete companion to
/// [`LinRegSlope`](crate::LinRegSlope) (which returns the continuous slope): use
/// the label when a feature pipeline wants a categorical trend direction and
/// keys any magnitude / dead-band tuning on the raw slope itself.
///
/// Each `update` is `O(period)`: the slope numerator is recomputed from the
/// window. The denominator `Σ(tᵢ t̄)²` is strictly positive for `period ≥ 2`,
/// so the sign is always well-defined.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, TrendLabel};
///
/// let mut indicator = TrendLabel::new(10).unwrap();
/// let mut last = None;
/// for i in 0..20 {
/// last = indicator.update(100.0 + f64::from(i)); // strictly rising
/// }
/// assert_eq!(last, Some(1.0));
/// ```
#[derive(Debug, Clone)]
pub struct TrendLabel {
period: usize,
window: VecDeque<f64>,
}
impl TrendLabel {
/// Construct a new Trend Label classifier.
///
/// # Errors
/// Returns [`Error::InvalidPeriod`] if `period < 2` — a slope needs at least
/// two points.
pub fn new(period: usize) -> Result<Self> {
if period < 2 {
return Err(Error::InvalidPeriod {
message: "trend label needs period >= 2",
});
}
Ok(Self {
period,
window: VecDeque::with_capacity(period),
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for TrendLabel {
type Input = f64;
type Output = f64;
fn update(&mut self, value: f64) -> Option<f64> {
if self.window.len() == self.period {
self.window.pop_front();
}
self.window.push_back(value);
if self.window.len() < self.period {
return None;
}
let count = self.period as f64;
let mean_t = (count - 1.0) / 2.0;
let mean_x = self.window.iter().sum::<f64>() / count;
// Slope numerator: Σ (t t̄)(x x̄). The denominator Σ(t t̄)² > 0 for
// period >= 2, so the slope sign equals the numerator sign.
let mut numerator = 0.0;
for (t, &x) in self.window.iter().enumerate() {
numerator += (t as f64 - mean_t) * (x - mean_x);
}
let label = if numerator > 0.0 {
1.0
} else if numerator < 0.0 {
-1.0
} else {
0.0
};
Some(label)
}
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 {
"TrendLabel"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
#[test]
fn rejects_period_below_two() {
assert!(matches!(
TrendLabel::new(1),
Err(Error::InvalidPeriod { .. })
));
assert!(TrendLabel::new(2).is_ok());
}
#[test]
fn accessors_and_metadata() {
let tl = TrendLabel::new(10).unwrap();
assert_eq!(tl.period(), 10);
assert_eq!(tl.warmup_period(), 10);
assert_eq!(tl.name(), "TrendLabel");
assert!(!tl.is_ready());
}
#[test]
fn rising_series_is_plus_one() {
let mut tl = TrendLabel::new(10).unwrap();
let prices: Vec<f64> = (0..20).map(f64::from).collect();
assert_eq!(tl.batch(&prices).into_iter().flatten().last(), Some(1.0));
}
#[test]
fn falling_series_is_minus_one() {
let mut tl = TrendLabel::new(10).unwrap();
let prices: Vec<f64> = (0..20).map(|i| 100.0 - f64::from(i)).collect();
assert_eq!(tl.batch(&prices).into_iter().flatten().last(), Some(-1.0));
}
#[test]
fn flat_series_is_zero() {
let mut tl = TrendLabel::new(8).unwrap();
for v in tl.batch(&[42.0; 16]).into_iter().flatten() {
assert_eq!(v, 0.0);
}
}
#[test]
fn scale_invariant_sign() {
// Multiplying the whole series by a constant cannot change the trend sign.
let prices: Vec<f64> = (0..30)
.map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 5.0)
.collect();
let small = TrendLabel::new(12).unwrap().batch(&prices);
let scaled: Vec<f64> = prices.iter().map(|p| p * 1000.0).collect();
let large = TrendLabel::new(12).unwrap().batch(&scaled);
assert_eq!(small, large);
}
#[test]
fn output_is_ternary() {
let mut tl = TrendLabel::new(14).unwrap();
let prices: Vec<f64> = (0..200)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 10.0)
.collect();
for v in tl.batch(&prices).into_iter().flatten() {
assert!(v == -1.0 || v == 0.0 || v == 1.0, "non-ternary label {v}");
}
}
#[test]
fn reset_clears_state() {
let mut tl = TrendLabel::new(5).unwrap();
tl.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
assert!(tl.is_ready());
tl.reset();
assert!(!tl.is_ready());
assert_eq!(tl.update(1.0), None);
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (0..60)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0)
.collect();
let batch = TrendLabel::new(14).unwrap().batch(&prices);
let mut b = TrendLabel::new(14).unwrap();
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,181 @@
//! Triangle (ascending / descending / symmetrical) chart pattern.
use crate::indicators::pattern_swing::{
approx_equal, recent_legs, SwingTracker, LEVEL_TOLERANCE, SWING_THRESHOLD,
};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Triangle — a consolidation pattern bounded by two converging trendlines,
/// detected from the two most recent swing highs and lows.
///
/// Built on confirmed swing pivots ([`SWING_THRESHOLD`] = 5%); evaluated on every
/// bar that confirms a new pivot once four pivots exist:
///
/// ```text
/// ascending : flat highs + rising lows → +1 (bullish bias)
/// descending : falling highs + flat lows → -1 (bearish bias)
/// symmetrical : falling highs + rising lows → +1 if the last pivot is a low
/// (an up-bounce), else -1
/// ```
///
/// "Flat" means the two highs (or lows) are within [`LEVEL_TOLERANCE`] (3%) of
/// each other; "rising"/"falling" means they differ by more than that tolerance.
/// The symmetrical case is directionally neutral, so its sign follows the
/// momentum of the most recently confirmed swing. Output is `+1.0` / `-1.0` /
/// `0.0`; never `None`.
#[derive(Debug, Clone)]
pub struct Triangle {
swing: SwingTracker,
has_emitted: bool,
}
impl Triangle {
/// Construct a new Triangle detector.
pub const fn new() -> Self {
Self {
swing: SwingTracker::new(SWING_THRESHOLD, 4),
has_emitted: false,
}
}
}
impl Default for Triangle {
fn default() -> Self {
Self::new()
}
}
impl Indicator for Triangle {
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 (high_old, high_new, low_old, low_new) = recent_legs(pivots);
let flat_highs = approx_equal(high_old, high_new, LEVEL_TOLERANCE);
let flat_lows = approx_equal(low_old, low_new, LEVEL_TOLERANCE);
let rising_lows = low_new > low_old * (1.0 + LEVEL_TOLERANCE);
let falling_highs = high_new < high_old * (1.0 - LEVEL_TOLERANCE);
let last_is_high = pivots[pivots.len() - 1].direction > 0.0;
if flat_highs && rising_lows {
return Some(1.0); // ascending
}
if falling_highs && flat_lows {
return Some(-1.0); // descending
}
if falling_highs && rising_lows {
// symmetrical: lean with the latest swing's momentum.
return Some(if last_is_high { -1.0 } else { 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 {
"Triangle"
}
}
#[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 = Triangle::new();
candles_for_pivots(pivots)
.into_iter()
.map(|c| indicator.update(c).unwrap())
.collect()
}
#[test]
fn accessors_and_metadata() {
let indicator = Triangle::new();
assert_eq!(indicator.name(), "Triangle");
assert_eq!(indicator.warmup_period(), 5);
assert!(!indicator.is_ready());
assert!(!Triangle::default().is_ready());
}
#[test]
fn ascending_triangle_is_plus_one() {
// Flat highs (120, 120), rising lows (100 → 110).
let out = run(&[130.0, 100.0, 120.0, 110.0, 120.0]);
assert_eq!(*out.last().unwrap(), 1.0);
}
#[test]
fn descending_triangle_is_minus_one() {
// Falling highs (120 → 110), flat lows (100, 99).
let out = run(&[120.0, 100.0, 110.0, 99.0]);
assert_eq!(*out.last().unwrap(), -1.0);
}
#[test]
fn symmetrical_triangle_ending_low_is_plus_one() {
// Falling highs (120 → 113), rising lows (100 → 106); last pivot a low.
let out = run(&[120.0, 100.0, 113.0, 106.0]);
assert_eq!(*out.last().unwrap(), 1.0);
}
#[test]
fn symmetrical_triangle_ending_high_is_minus_one() {
// Same convergence but ending on a high pivot.
let out = run(&[130.0, 100.0, 120.0, 106.0, 113.0]);
assert_eq!(*out.last().unwrap(), -1.0);
}
#[test]
fn expanding_swings_are_not_a_triangle() {
// Rising highs and falling lows (broadening) → no converging triangle.
let out = run(&[110.0, 100.0, 130.0, 80.0]);
assert_eq!(*out.last().unwrap(), 0.0);
}
#[test]
fn reset_clears_state() {
let mut indicator = Triangle::new();
for c in candles_for_pivots(&[130.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(&[130.0, 100.0, 120.0, 110.0, 120.0]);
let mut a = Triangle::new();
let mut b = Triangle::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,160 @@
//! Triple Top / Triple Bottom reversal chart pattern.
use crate::indicators::pattern_swing::{
approx_equal, SwingTracker, LEVEL_TOLERANCE, SWING_THRESHOLD,
};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Triple Top / Triple Bottom — a three-peak (or three-trough) reversal pattern,
/// a stronger variant of the double top/bottom.
///
/// Built on confirmed swing pivots ([`SWING_THRESHOLD`] = 5%). A pattern is
/// recognised on the bar that confirms the **third** matching extreme:
///
/// ```text
/// triple top : High₁ , Low , High₂ , Low , High₃ High₁ ≈ High₂ ≈ High₃ → -1
/// triple bottom : Low₁ , High, Low₂ , High, Low₃ Low₁ ≈ Low₂ ≈ Low₃ → +1
/// ```
///
/// The three same-direction extremes (positions `n-5`, `n-3`, `n-1` in the pivot
/// history) must all lie within [`LEVEL_TOLERANCE`] (3%) of one another.
///
/// Output is `+1.0` for a triple bottom, `-1.0` for a triple top, and `0.0`
/// otherwise; never `None`.
#[derive(Debug, Clone)]
pub struct TripleTopBottom {
swing: SwingTracker,
has_emitted: bool,
}
impl TripleTopBottom {
/// Construct a new Triple Top / Triple Bottom detector.
pub const fn new() -> Self {
Self {
swing: SwingTracker::new(SWING_THRESHOLD, 5),
has_emitted: false,
}
}
}
impl Default for TripleTopBottom {
fn default() -> Self {
Self::new()
}
}
impl Indicator for TripleTopBottom {
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 n = pivots.len();
let first = pivots[n - 5];
let middle = pivots[n - 3];
let last = pivots[n - 1];
let outer_match = approx_equal(first.price, middle.price, LEVEL_TOLERANCE);
let inner_match = approx_equal(middle.price, last.price, LEVEL_TOLERANCE);
if outer_match && inner_match {
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 {
// Five confirmed pivots are needed; the earliest bar that can confirm a
// fifth pivot is the sixth.
6
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"TripleTopBottom"
}
}
#[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 = TripleTopBottom::new();
candles_for_pivots(pivots)
.into_iter()
.map(|c| indicator.update(c).unwrap())
.collect()
}
#[test]
fn accessors_and_metadata() {
let indicator = TripleTopBottom::new();
assert_eq!(indicator.name(), "TripleTopBottom");
assert_eq!(indicator.warmup_period(), 6);
assert!(!indicator.is_ready());
assert!(!TripleTopBottom::default().is_ready());
}
#[test]
fn triple_top_is_minus_one() {
// Three ~equal highs (120, 121, 119) → triple top on the third.
let out = run(&[120.0, 100.0, 121.0, 99.0, 119.0]);
assert_eq!(*out.last().unwrap(), -1.0);
assert!(out[..out.len() - 1].iter().all(|&x| x == 0.0));
}
#[test]
fn triple_bottom_is_plus_one() {
// Lead high then three ~equal lows (100, 99, 101) → triple bottom.
let out = run(&[130.0, 100.0, 120.0, 99.0, 122.0, 101.0]);
assert_eq!(*out.last().unwrap(), 1.0);
}
#[test]
fn unequal_third_peak_does_not_trigger() {
// Third high (140) diverges from the first two (120, 121) → no pattern.
let out = run(&[120.0, 100.0, 121.0, 99.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 = TripleTopBottom::new();
for c in candles_for_pivots(&[120.0, 100.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, 100.0, 121.0, 99.0, 119.0]);
let mut a = TripleTopBottom::new();
let mut b = TripleTopBottom::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,275 @@
//! Turn-of-Month Effect — the mean daily return of sessions that fall inside the
//! turn-of-month window (the last `n_last` and first `n_first` days of a month).
use crate::calendar::{civil_from_timestamp, days_in_month};
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Whether a day-of-month lies in the turn-of-month window.
///
/// The window is the first `n_first` calendar days plus the last `n_last` days of
/// the month (`days_in_month - n_last < dom`).
fn in_turn_window(dom: u32, dim: u32, n_first: u32, n_last: u32) -> bool {
dom <= n_first || dom > dim.saturating_sub(n_last)
}
/// Turn-of-Month effect: the running mean of daily close-to-close returns for the
/// sessions that fall in the turn-of-month window.
///
/// Each completed session (the wall-clock day of
/// [`Candle::timestamp`](crate::Candle) shifted by `utc_offset_minutes`)
/// contributes its return `close / previous_close - 1`. Only sessions whose
/// day-of-month is within the first `n_first` or last `n_last` days of their month
/// are averaged; the rest are ignored. The classic effect uses `n_first = 3`,
/// `n_last = 1`.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, TurnOfMonth};
///
/// let day = 24 * 3_600_000;
/// // 2021-01-29 .. 02-02 — all turn-of-month days with n_first=3, n_last=1.
/// let mut tom = TurnOfMonth::new(3, 1, 0).unwrap();
/// let start = 1_611_878_400_000; // 2021-01-29 00:00 UTC
/// let mut last = None;
/// for (i, close) in [100.0, 101.0, 102.0, 103.0].iter().enumerate() {
/// let ts = start + i as i64 * day;
/// last = tom.update(Candle::new(*close, *close, *close, *close, 1.0, ts).unwrap());
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct TurnOfMonth {
n_first: u32,
n_last: u32,
utc_offset_minutes: i32,
day: Option<(i64, u32, u32)>,
cur_close: f64,
prev_day_close: Option<f64>,
sum: f64,
count: u64,
}
impl TurnOfMonth {
/// Construct a Turn-of-Month indicator.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if both `n_first` and `n_last` are zero (the
/// window would never include a day).
pub fn new(n_first: u32, n_last: u32, utc_offset_minutes: i32) -> Result<Self> {
if n_first == 0 && n_last == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
n_first,
n_last,
utc_offset_minutes,
day: None,
cur_close: 0.0,
prev_day_close: None,
sum: 0.0,
count: 0,
})
}
/// Classic turn-of-month window: first 3 and last 1 day of the month.
pub fn classic() -> Self {
Self::new(3, 1, 0).expect("classic turn-of-month window is valid")
}
/// Configured `(n_first, n_last, utc_offset_minutes)`.
pub const fn params(&self) -> (u32, u32, i32) {
(self.n_first, self.n_last, self.utc_offset_minutes)
}
/// Most recent mean turn-of-month return if any in-window day has completed.
pub fn value(&self) -> Option<f64> {
if self.count == 0 {
None
} else {
Some(self.sum / self.count as f64)
}
}
/// Settle the just-finished day `(year, month, dom)` whose last close is
/// `self.cur_close`, then start `next_key`.
fn roll_into(
&mut self,
year: i64,
month: u32,
dom: u32,
next_key: (i64, u32, u32),
close: f64,
) {
if let Some(prev) = self.prev_day_close {
let ret = if prev == 0.0 {
0.0
} else {
self.cur_close / prev - 1.0
};
if in_turn_window(dom, days_in_month(year, month), self.n_first, self.n_last) {
self.sum += ret;
self.count += 1;
}
}
self.prev_day_close = Some(self.cur_close);
self.day = Some(next_key);
self.cur_close = close;
}
}
impl Indicator for TurnOfMonth {
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 {
Some(prev) if prev == key => {
self.cur_close = candle.close;
}
Some((year, month, dom)) => {
self.roll_into(year, month, dom, key, candle.close);
}
None => {
self.day = Some(key);
self.cur_close = candle.close;
}
}
self.value()
}
fn reset(&mut self) {
self.day = None;
self.cur_close = 0.0;
self.prev_day_close = None;
self.sum = 0.0;
self.count = 0;
}
fn warmup_period(&self) -> usize {
2
}
fn is_ready(&self) -> bool {
self.count > 0
}
fn name(&self) -> &'static str {
"TurnOfMonth"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
const DAY: i64 = 24 * 3_600_000;
// 2021-01-28 00:00 UTC.
const JAN28_2021: i64 = 1_611_792_000_000;
fn c(close: f64, ts: i64) -> Candle {
Candle::new(close, close, close, close, 1.0, ts).unwrap()
}
#[test]
fn window_predicate_branches() {
// First-days branch.
assert!(in_turn_window(1, 31, 3, 1));
assert!(in_turn_window(3, 31, 3, 1));
assert!(!in_turn_window(4, 31, 3, 1));
// Last-days branch.
assert!(in_turn_window(31, 31, 3, 1));
assert!(!in_turn_window(30, 31, 3, 1));
// Saturating subtraction when n_last exceeds the month length.
assert!(in_turn_window(1, 28, 0, 40));
}
#[test]
fn rejects_empty_window() {
assert!(matches!(TurnOfMonth::new(0, 0, 0), Err(Error::PeriodZero)));
}
#[test]
fn metadata_and_accessors() {
let tom = TurnOfMonth::classic();
assert_eq!(tom.params(), (3, 1, 0));
assert_eq!(tom.name(), "TurnOfMonth");
assert_eq!(tom.warmup_period(), 2);
assert!(!tom.is_ready());
assert!(tom.value().is_none());
}
#[test]
fn averages_in_window_returns_only() {
let mut tom = TurnOfMonth::new(3, 1, 0).unwrap();
// 2021-01-28 (out of window, no prior close): close 100.
assert!(tom.update(c(100.0, JAN28_2021)).is_none());
// 2021-01-29 (out of window: dom 29, dim 31 -> 29 <= 30): return ignored.
assert!(tom.update(c(110.0, JAN28_2021 + DAY)).is_none());
// 2021-01-30 (out of window): completes 01-29; still none.
assert!(tom.update(c(120.0, JAN28_2021 + 2 * DAY)).is_none());
// 2021-01-31 (last day, in window): completes 01-30 (out). Still none.
assert!(tom.update(c(121.0, JAN28_2021 + 3 * DAY)).is_none());
// 2021-02-01 (first day, in window): completes 01-31 (in window).
// return = 121 / 120 - 1.
let v = tom.update(c(130.0, JAN28_2021 + 4 * DAY)).unwrap();
assert_relative_eq!(v, 121.0 / 120.0 - 1.0);
assert!(tom.is_ready());
}
#[test]
fn zero_prev_close_contributes_zero() {
let mut tom = TurnOfMonth::new(3, 1, 0).unwrap();
// 2021-01-30 closes at 0 — becomes the prior close for 01-31.
tom.update(c(0.0, JAN28_2021 + 2 * DAY));
// 2021-01-31 (last day, in window): finalizes 01-30 with no prior -> no
// contribution, but records prev_day_close = 0.
tom.update(c(5.0, JAN28_2021 + 3 * DAY));
// 2021-02-01 (in window): finalizes 01-31 with prev_close 0 -> ret 0.
let v = tom.update(c(50.0, JAN28_2021 + 4 * DAY)).unwrap();
assert_relative_eq!(v, 0.0);
}
#[test]
fn same_day_bars_use_latest_close() {
let mut tom = TurnOfMonth::new(3, 1, 0).unwrap();
// 2021-01-30 closes at 100 (prior day, sets prev_day_close).
tom.update(c(100.0, JAN28_2021 + 2 * DAY));
// 2021-01-31 two bars on the same day; the later close (120) wins.
tom.update(c(110.0, JAN28_2021 + 3 * DAY));
tom.update(c(120.0, JAN28_2021 + 3 * DAY + 3_600_000));
// 2021-02-01 (in window) finalizes 01-31: return = 120 / 100 - 1 = 0.20.
let v = tom.update(c(130.0, JAN28_2021 + 4 * DAY)).unwrap();
assert_relative_eq!(v, 0.20);
}
#[test]
fn reset_clears_state() {
let mut tom = TurnOfMonth::new(3, 1, 0).unwrap();
tom.update(c(121.0, JAN28_2021 + 3 * DAY));
tom.update(c(130.0, JAN28_2021 + 4 * DAY));
tom.reset();
assert!(!tom.is_ready());
assert!(tom.value().is_none());
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..40)
.map(|i| c(100.0 + f64::from(i), JAN28_2021 + i64::from(i) * DAY))
.collect();
let mut a = TurnOfMonth::new(3, 2, 0).unwrap();
let mut b = TurnOfMonth::new(3, 2, 0).unwrap();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,198 @@
//! Volume-by-Time Profile — the mean traded volume in each intraday bucket.
use crate::calendar::civil_from_timestamp;
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Volume-by-Time Profile output: the per-bucket mean volume.
///
/// `bins[i]` is the mean volume of all bars whose local time-of-day fell in
/// bucket `i`. Empty buckets read `0.0`.
#[derive(Debug, Clone, PartialEq)]
pub struct VolumeByTimeProfileOutput {
/// Per-bucket mean volume, earliest bucket first. Length equals `buckets`.
pub bins: Vec<f64>,
}
/// Mean traded volume bucketed by local time of day.
///
/// The local day (the wall-clock day of [`Candle::timestamp`](crate::Candle)
/// shifted by `utc_offset_minutes`) is split into `buckets` equal slices. Each
/// bar's volume is accumulated into the bucket of its time-of-day, and the
/// profile reports the running mean volume per bucket. Unlike the return
/// profiles, the first bar already produces output (volume needs no prior bar).
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, VolumeByTimeProfile};
///
/// let hour = 3_600_000;
/// let mut prof = VolumeByTimeProfile::new(24, 0).unwrap();
/// let out = prof.update(Candle::new(100.0, 100.0, 100.0, 100.0, 500.0, hour).unwrap()).unwrap();
/// assert_eq!(out.bins.len(), 24);
/// assert_eq!(out.bins[1], 500.0);
/// ```
#[derive(Debug, Clone)]
pub struct VolumeByTimeProfile {
buckets: usize,
utc_offset_minutes: i32,
sum: Vec<f64>,
count: Vec<u64>,
last: Option<VolumeByTimeProfileOutput>,
}
impl VolumeByTimeProfile {
/// Construct a Volume-by-Time Profile with `buckets` intraday slices.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `buckets == 0`.
pub fn new(buckets: usize, utc_offset_minutes: i32) -> Result<Self> {
if buckets == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
buckets,
utc_offset_minutes,
sum: vec![0.0; buckets],
count: vec![0; buckets],
last: None,
})
}
/// Configured `(buckets, utc_offset_minutes)`.
pub const fn params(&self) -> (usize, i32) {
(self.buckets, self.utc_offset_minutes)
}
/// Most recent profile if at least one bar has been seen.
pub fn value(&self) -> Option<&VolumeByTimeProfileOutput> {
self.last.as_ref()
}
fn bucket_of(&self, minute_of_day: u32) -> usize {
let raw = (minute_of_day as usize * self.buckets) / 1440;
raw.min(self.buckets - 1)
}
fn snapshot(&self) -> VolumeByTimeProfileOutput {
let bins = self
.sum
.iter()
.zip(&self.count)
.map(|(total, n)| if *n > 0 { total / *n as f64 } else { 0.0 })
.collect();
VolumeByTimeProfileOutput { bins }
}
}
impl Indicator for VolumeByTimeProfile {
type Input = Candle;
type Output = VolumeByTimeProfileOutput;
fn update(&mut self, candle: Candle) -> Option<VolumeByTimeProfileOutput> {
let civil = civil_from_timestamp(candle.timestamp, self.utc_offset_minutes);
let bucket = self.bucket_of(civil.minute_of_day());
self.sum[bucket] += candle.volume;
self.count[bucket] += 1;
let out = self.snapshot();
self.last = Some(out.clone());
Some(out)
}
fn reset(&mut self) {
self.sum.iter_mut().for_each(|x| *x = 0.0);
self.count.iter_mut().for_each(|x| *x = 0);
self.last = None;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"VolumeByTimeProfile"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
const HOUR: i64 = 3_600_000;
fn c(volume: f64, ts: i64) -> Candle {
Candle::new(100.0, 100.0, 100.0, 100.0, volume, ts).unwrap()
}
#[test]
fn rejects_zero_buckets() {
assert!(matches!(
VolumeByTimeProfile::new(0, 0),
Err(Error::PeriodZero)
));
}
#[test]
fn metadata_and_accessors() {
let prof = VolumeByTimeProfile::new(24, -60).unwrap();
assert_eq!(prof.params(), (24, -60));
assert_eq!(prof.name(), "VolumeByTimeProfile");
assert_eq!(prof.warmup_period(), 1);
assert!(!prof.is_ready());
assert!(prof.value().is_none());
}
#[test]
fn emits_from_first_bar_and_means_volume() {
let mut prof = VolumeByTimeProfile::new(24, 0).unwrap();
let out = prof.update(c(500.0, HOUR)).unwrap(); // 01:00 -> bucket 1
assert_eq!(out.bins.len(), 24);
assert_relative_eq!(out.bins[1], 500.0);
assert_relative_eq!(out.bins[0], 0.0);
assert!(prof.is_ready());
// Next day 01:00, volume 700 -> mean (500 + 700) / 2 = 600.
let out = prof.update(c(700.0, 25 * HOUR)).unwrap();
assert_relative_eq!(out.bins[1], 600.0);
}
#[test]
fn last_bucket_clamped() {
let mut prof = VolumeByTimeProfile::new(24, 0).unwrap();
// 23:59 -> minute 1439 -> bucket 23.
let out = prof.update(c(300.0, 23 * HOUR + 59 * 60_000)).unwrap();
assert_relative_eq!(out.bins[23], 300.0);
}
#[test]
fn reset_clears_state() {
let mut prof = VolumeByTimeProfile::new(24, 0).unwrap();
prof.update(c(500.0, HOUR));
prof.reset();
assert!(!prof.is_ready());
assert!(prof.value().is_none());
let out = prof.update(c(100.0, 2 * HOUR)).unwrap();
assert_relative_eq!(out.bins[2], 100.0);
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..50)
.map(|i| c(100.0 + f64::from(i % 8), i64::from(i) * HOUR))
.collect();
let mut a = VolumeByTimeProfile::new(12, 0).unwrap();
let mut b = VolumeByTimeProfile::new(12, 0).unwrap();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
+262
View File
@@ -0,0 +1,262 @@
//! VPIN — Volume-Synchronised Probability of Informed Trading.
use std::collections::VecDeque;
use crate::microstructure::{Side, Trade};
use crate::traits::Indicator;
use crate::{Error, Result};
/// VPIN — the Volume-Synchronised Probability of Informed Trading
/// (Easley, López de Prado & O'Hara, 2012).
///
/// Trades are bucketed into equal-volume buckets of size `bucket_volume`. For
/// each completed bucket the order-flow imbalance is the absolute difference
/// between buy and sell volume; VPIN is that imbalance averaged over the last
/// `num_buckets` buckets and normalised by the bucket size:
///
/// ```text
/// VPIN = ( Σ |Vᴮ_τ Vˢ_τ| ) / (num_buckets · bucket_volume)
/// ```
///
/// The aggressor [`Side`] of each [`Trade`] classifies its volume directly (no
/// bulk-volume classification needed). A single trade may span several buckets;
/// its volume is split across bucket boundaries. The result lies in `[0, 1]`:
/// values near `1` signal a strongly one-sided, likely-informed flow (a toxic
/// regime), values near `0` a balanced two-sided flow.
///
/// `Input = Trade`. Because bucket completion is driven by cumulative volume,
/// readiness is data-dependent; [`warmup_period`](Indicator::warmup_period)
/// reports `num_buckets` as the minimum number of trades (one per bucket) and
/// [`is_ready`](Indicator::is_ready) reflects the true bucket count.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, Side, Trade, Vpin};
///
/// let mut vpin = Vpin::new(10.0, 2).unwrap();
/// // Two buckets of pure buying => imbalance == bucket size => VPIN 1.
/// let mut last = None;
/// for _ in 0..4 {
/// last = vpin.update(Trade::new(100.0, 5.0, Side::Buy, 0).unwrap());
/// }
/// assert_eq!(last, Some(1.0));
/// ```
#[derive(Debug, Clone)]
pub struct Vpin {
bucket_volume: f64,
num_buckets: usize,
cur_buy: f64,
cur_sell: f64,
cur_total: f64,
window: VecDeque<f64>,
sum_imbalance: f64,
}
impl Vpin {
/// Construct a new VPIN estimator.
///
/// # Errors
/// Returns [`Error::PeriodZero`] if `num_buckets == 0`, or
/// [`Error::InvalidParameter`] if `bucket_volume` is not finite and
/// positive.
pub fn new(bucket_volume: f64, num_buckets: usize) -> Result<Self> {
if num_buckets == 0 {
return Err(Error::PeriodZero);
}
if !bucket_volume.is_finite() || bucket_volume <= 0.0 {
return Err(Error::InvalidParameter {
message: "VPIN bucket_volume must be finite and positive",
});
}
Ok(Self {
bucket_volume,
num_buckets,
cur_buy: 0.0,
cur_sell: 0.0,
cur_total: 0.0,
window: VecDeque::with_capacity(num_buckets),
sum_imbalance: 0.0,
})
}
/// Configured `(bucket_volume, num_buckets)`.
pub const fn params(&self) -> (f64, usize) {
(self.bucket_volume, self.num_buckets)
}
fn close_bucket(&mut self) {
let imbalance = (self.cur_buy - self.cur_sell).abs();
if self.window.len() == self.num_buckets {
let old = self.window.pop_front().expect("window is non-empty");
self.sum_imbalance -= old;
}
self.window.push_back(imbalance);
self.sum_imbalance += imbalance;
self.cur_buy = 0.0;
self.cur_sell = 0.0;
self.cur_total = 0.0;
}
}
impl Indicator for Vpin {
type Input = Trade;
type Output = f64;
fn update(&mut self, trade: Trade) -> Option<f64> {
let mut remaining = trade.size;
let buy = trade.side == Side::Buy;
// Distribute the trade's volume across one or more buckets.
while remaining > 0.0 {
let capacity = self.bucket_volume - self.cur_total;
let take = remaining.min(capacity);
if buy {
self.cur_buy += take;
} else {
self.cur_sell += take;
}
self.cur_total += take;
remaining -= take;
if self.cur_total >= self.bucket_volume {
self.close_bucket();
}
}
if self.window.len() < self.num_buckets {
return None;
}
Some(self.sum_imbalance / (self.num_buckets as f64 * self.bucket_volume))
}
fn reset(&mut self) {
self.cur_buy = 0.0;
self.cur_sell = 0.0;
self.cur_total = 0.0;
self.window.clear();
self.sum_imbalance = 0.0;
}
fn warmup_period(&self) -> usize {
self.num_buckets
}
fn is_ready(&self) -> bool {
self.window.len() == self.num_buckets
}
fn name(&self) -> &'static str {
"Vpin"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn trade(size: f64, side: Side) -> Trade {
Trade::new(100.0, size, side, 0).unwrap()
}
#[test]
fn rejects_bad_params() {
assert!(matches!(Vpin::new(10.0, 0), Err(Error::PeriodZero)));
assert!(matches!(
Vpin::new(0.0, 5),
Err(Error::InvalidParameter { .. })
));
assert!(matches!(
Vpin::new(f64::NAN, 5),
Err(Error::InvalidParameter { .. })
));
}
#[test]
fn accessors_and_metadata() {
let vpin = Vpin::new(10.0, 50).unwrap();
assert_eq!(vpin.params(), (10.0, 50));
assert_eq!(vpin.warmup_period(), 50);
assert_eq!(vpin.name(), "Vpin");
assert!(!vpin.is_ready());
}
#[test]
fn one_sided_flow_is_one() {
// Every bucket is pure buying => |buy - sell| == bucket size => VPIN 1.
let mut vpin = Vpin::new(10.0, 2).unwrap();
let mut last = None;
for _ in 0..4 {
last = vpin.update(trade(5.0, Side::Buy));
}
assert_relative_eq!(last.unwrap(), 1.0, epsilon = 1e-12);
assert!(vpin.is_ready());
}
#[test]
fn balanced_flow_is_zero() {
// Each bucket holds equal buy and sell volume => imbalance 0 => VPIN 0.
let mut vpin = Vpin::new(10.0, 2).unwrap();
let mut last = None;
for _ in 0..4 {
vpin.update(trade(5.0, Side::Buy));
last = vpin.update(trade(5.0, Side::Sell));
}
assert_relative_eq!(last.unwrap(), 0.0, epsilon = 1e-12);
}
#[test]
fn large_trade_spans_multiple_buckets() {
// A single 25-unit buy fills 2 full buckets (size 10) plus 5 into a
// third. Two buckets close => both pure buy => imbalance 10 each.
let mut vpin = Vpin::new(10.0, 2).unwrap();
let out = vpin.update(trade(25.0, Side::Buy));
// After 2 closed buckets the window is full: VPIN = (10+10)/(2*10) = 1.
assert_relative_eq!(out.unwrap(), 1.0, epsilon = 1e-12);
}
#[test]
fn output_within_bounds() {
let mut vpin = Vpin::new(7.0, 4).unwrap();
for i in 0..200 {
let side = if i % 3 == 0 { Side::Sell } else { Side::Buy };
if let Some(v) = vpin.update(trade(1.0 + f64::from(i % 5), side)) {
assert!((0.0..=1.0).contains(&v), "out of bounds: {v}");
}
}
}
#[test]
fn zero_size_trade_is_noop() {
let mut vpin = Vpin::new(10.0, 1).unwrap();
assert_eq!(vpin.update(trade(0.0, Side::Buy)), None);
// A full bucket of buying then closes it: VPIN 1.
let out = vpin.update(trade(10.0, Side::Buy));
assert_relative_eq!(out.unwrap(), 1.0, epsilon = 1e-12);
}
#[test]
fn reset_clears_state() {
let mut vpin = Vpin::new(10.0, 2).unwrap();
for _ in 0..4 {
vpin.update(trade(5.0, Side::Buy));
}
assert!(vpin.is_ready());
vpin.reset();
assert!(!vpin.is_ready());
assert_eq!(vpin.update(trade(5.0, Side::Buy)), None);
}
#[test]
fn batch_equals_streaming() {
let trades: Vec<Trade> = (0..120)
.map(|i| {
let side = if i % 2 == 0 { Side::Buy } else { Side::Sell };
trade(1.0 + f64::from(i % 4), side)
})
.collect();
let batch = Vpin::new(8.0, 5).unwrap().batch(&trades);
let mut b = Vpin::new(8.0, 5).unwrap();
let streamed: Vec<_> = trades.iter().map(|t| b.update(*t)).collect();
assert_eq!(batch, streamed);
}
}
+157
View File
@@ -0,0 +1,157 @@
//! Wedge (rising / falling) reversal chart pattern.
use crate::indicators::pattern_swing::{recent_legs, SwingTracker, SWING_THRESHOLD};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Wedge — a pattern where both trendlines slope the same way but converge,
/// signalling exhaustion of the prevailing move.
///
/// Built on confirmed swing pivots ([`SWING_THRESHOLD`] = 5%); evaluated from the
/// last two swing highs and lows:
///
/// ```text
/// rising wedge : highs rising AND lows rising, lows rising faster → -1 (bearish)
/// falling wedge : highs falling AND lows falling, highs falling faster → +1 (bullish)
/// ```
///
/// Convergence is the key: in a rising wedge the lower trendline climbs faster
/// than the upper (the range narrows from below); in a falling wedge the upper
/// trendline drops faster than the lower. Output is `+1.0` / `-1.0` / `0.0`;
/// never `None`.
#[derive(Debug, Clone)]
pub struct Wedge {
swing: SwingTracker,
has_emitted: bool,
}
impl Wedge {
/// Construct a new Wedge detector.
pub const fn new() -> Self {
Self {
swing: SwingTracker::new(SWING_THRESHOLD, 4),
has_emitted: false,
}
}
}
impl Default for Wedge {
fn default() -> Self {
Self::new()
}
}
impl Indicator for Wedge {
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 (high_old, high_new, low_old, low_new) = recent_legs(pivots);
let high_slope = high_new - high_old;
let low_slope = low_new - low_old;
// Rising wedge: both lines slope up, lower line steeper (converging) → bearish.
if high_slope > 0.0 && low_slope > 0.0 && low_slope > high_slope {
return Some(-1.0);
}
// Falling wedge: both lines slope down, upper line steeper → bullish.
if high_slope < 0.0 && low_slope < 0.0 && high_slope < low_slope {
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 {
"Wedge"
}
}
#[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 = Wedge::new();
candles_for_pivots(pivots)
.into_iter()
.map(|c| indicator.update(c).unwrap())
.collect()
}
#[test]
fn accessors_and_metadata() {
let indicator = Wedge::new();
assert_eq!(indicator.name(), "Wedge");
assert_eq!(indicator.warmup_period(), 5);
assert!(!indicator.is_ready());
assert!(!Wedge::default().is_ready());
}
#[test]
fn rising_wedge_is_minus_one() {
// Highs 100 → 103 (+3), lows 90 → 94 (+4, steeper) → rising wedge.
let out = run(&[110.0, 90.0, 100.0, 94.0, 103.0]);
assert_eq!(*out.last().unwrap(), -1.0);
}
#[test]
fn falling_wedge_is_plus_one() {
// Highs 120 → 106 (-14, steeper), lows 100 → 99 (-1) → falling wedge.
let out = run(&[120.0, 100.0, 106.0, 99.0]);
assert_eq!(*out.last().unwrap(), 1.0);
}
#[test]
fn diverging_swings_are_not_a_wedge() {
// Rising highs but falling lows (broadening) → no wedge.
let out = run(&[110.0, 100.0, 130.0, 80.0]);
assert_eq!(*out.last().unwrap(), 0.0);
}
#[test]
fn reset_clears_state() {
let mut indicator = Wedge::new();
for c in candles_for_pivots(&[110.0, 90.0, 100.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(&[110.0, 90.0, 100.0, 94.0, 103.0]);
let mut a = Wedge::new();
let mut b = Wedge::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,192 @@
//! Wick Ratio — the shadow imbalance of a bar.
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Wick Ratio — the signed imbalance between the upper and lower shadows as a
/// fraction of the bar's range.
///
/// ```text
/// upper_wick = high max(open, close)
/// lower_wick = min(open, close) low
/// WickRatio = (upper_wick lower_wick) / (high low)
/// ```
///
/// The result lives in `[1, +1]`: `+1` is a bar that is all upper shadow (a
/// long rejection of higher prices, classic shooting-star geometry), `1` all
/// lower shadow (a long rejection of lower prices, hammer geometry), and `0`
/// either a symmetric bar or a wickless one. Where
/// [`BodySizePct`](crate::BodySizePct) measures how much of the range is body,
/// this measures *which side* the wicks fall on — the rejection asymmetry many
/// reversal setups depend on. A zero-range bar yields `0`.
///
/// This is a stateless per-bar transform: every candle produces one value.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, WickRatio};
///
/// let mut indicator = WickRatio::new();
/// // upper 13 - 10.5 = 2.5, lower 10 - 10 = 0, range 3 -> +0.8333.
/// let c = Candle::new(10.0, 13.0, 10.0, 10.5, 10.0, 0).unwrap();
/// assert!((indicator.update(c).unwrap() - 2.5 / 3.0).abs() < 1e-12);
/// ```
#[derive(Debug, Clone, Default)]
pub struct WickRatio {
has_emitted: bool,
}
impl WickRatio {
/// Construct a new Wick Ratio transform.
pub const fn new() -> Self {
Self { has_emitted: false }
}
}
impl Indicator for WickRatio {
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 shadows to compare.
0.0
} else {
let body_top = candle.open.max(candle.close);
let body_bottom = candle.open.min(candle.close);
let upper_wick = candle.high - body_top;
let lower_wick = body_bottom - candle.low;
(upper_wick - lower_wick) / 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 {
"WickRatio"
}
}
#[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 upper_shadow_dominates_is_positive() {
// upper 13 - 10.5 = 2.5, lower 10 - 10 = 0, range 3 -> +2.5/3.
let mut wr = WickRatio::new();
assert_relative_eq!(
wr.update(candle(10.0, 13.0, 10.0, 10.5, 0)).unwrap(),
2.5 / 3.0,
epsilon = 1e-12
);
}
#[test]
fn lower_shadow_dominates_is_negative() {
// Hammer: long lower shadow -> negative.
// open 12, close 12.5, high 13, low 9: upper 0.5, lower 3, range 4.
let mut wr = WickRatio::new();
assert_relative_eq!(
wr.update(candle(12.0, 13.0, 9.0, 12.5, 0)).unwrap(),
(0.5 - 3.0) / 4.0,
epsilon = 1e-12
);
}
#[test]
fn symmetric_wicks_are_zero() {
// Equal upper and lower shadows -> 0.
let mut wr = WickRatio::new();
assert_relative_eq!(
wr.update(candle(10.0, 12.0, 8.0, 10.0, 0)).unwrap(),
0.0,
epsilon = 1e-12
);
}
#[test]
fn zero_range_bar_yields_zero() {
let mut wr = WickRatio::new();
assert_relative_eq!(
wr.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 wr = WickRatio::new();
for v in wr.batch(&candles).into_iter().flatten() {
assert!((-1.0..=1.0).contains(&v), "WickRatio {v} outside [-1, 1]");
}
}
#[test]
fn name_metadata() {
let wr = WickRatio::new();
assert_eq!(wr.name(), "WickRatio");
}
#[test]
fn emits_from_first_candle() {
let mut wr = WickRatio::new();
assert_eq!(wr.warmup_period(), 1);
assert!(!wr.is_ready());
assert!(wr.update(candle(10.0, 11.0, 9.0, 10.0, 0)).is_some());
assert!(wr.is_ready());
}
#[test]
fn reset_clears_state() {
let mut wr = WickRatio::new();
wr.update(candle(10.0, 11.0, 9.0, 10.0, 0));
assert!(wr.is_ready());
wr.reset();
assert!(!wr.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 = WickRatio::new();
let mut b = WickRatio::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,191 @@
//! Win Rate — the fraction of winning returns over a rolling window.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Win Rate — the fraction of strictly-positive returns among the last `period`
/// returns, in `[0, 1]`.
///
/// ```text
/// WinRate = #(rᵢ > 0) / period
/// ```
///
/// Feed a stream of per-trade or per-bar returns (or `PnL`); the indicator reports
/// the rolling hit rate. A return of exactly `0` is treated as a non-win (a
/// flat / scratch), so `WinRate` is the share of the window that strictly made
/// money — the most basic performance statistic and a building block for
/// [`Expectancy`](crate::Expectancy), Kelly sizing, and confidence filters.
///
/// Each `update` is O(1): the count of wins in the window is maintained
/// incrementally.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, WinRate};
///
/// let mut indicator = WinRate::new(4).unwrap();
/// // returns: +, -, +, + -> 3 of 4 win -> 0.75.
/// let out = indicator.batch(&[1.0, -1.0, 2.0, 1.0]);
/// # use wickra_core::BatchExt;
/// assert_eq!(out[3], Some(0.75));
/// ```
#[derive(Debug, Clone)]
pub struct WinRate {
period: usize,
window: VecDeque<f64>,
wins: usize,
}
impl WinRate {
/// Construct a new Win Rate 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),
wins: 0,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for WinRate {
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");
if old > 0.0 {
self.wins -= 1;
}
}
self.window.push_back(ret);
if ret > 0.0 {
self.wins += 1;
}
if self.window.len() < self.period {
return None;
}
Some(self.wins as f64 / self.period as f64)
}
fn reset(&mut self) {
self.window.clear();
self.wins = 0;
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.window.len() == self.period
}
fn name(&self) -> &'static str {
"WinRate"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_zero_period() {
assert!(matches!(WinRate::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_and_metadata() {
let wr = WinRate::new(20).unwrap();
assert_eq!(wr.period(), 20);
assert_eq!(wr.warmup_period(), 20);
assert_eq!(wr.name(), "WinRate");
assert!(!wr.is_ready());
}
#[test]
fn reference_value() {
// +, -, +, + -> 3 wins of 4 -> 0.75.
let mut wr = WinRate::new(4).unwrap();
let out = wr.batch(&[1.0, -1.0, 2.0, 1.0]);
assert_relative_eq!(out[3].unwrap(), 0.75, epsilon = 1e-12);
}
#[test]
fn all_wins_is_one() {
let mut wr = WinRate::new(5).unwrap();
for v in wr.batch(&[1.0; 10]).into_iter().flatten() {
assert_relative_eq!(v, 1.0, epsilon = 1e-12);
}
}
#[test]
fn all_losses_is_zero() {
let mut wr = WinRate::new(5).unwrap();
for v in wr.batch(&[-1.0; 10]).into_iter().flatten() {
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
}
}
#[test]
fn flat_returns_are_not_wins() {
// Zeros count as non-wins: 2 wins, 2 flats -> 0.5.
let mut wr = WinRate::new(4).unwrap();
let out = wr.batch(&[1.0, 0.0, 2.0, 0.0]);
assert_relative_eq!(out[3].unwrap(), 0.5, epsilon = 1e-12);
}
#[test]
fn rolling_window_drops_old_wins() {
// period 3: after [+,+,+] -> 1.0, then three losses slide the wins out.
let mut wr = WinRate::new(3).unwrap();
let out = wr.batch(&[1.0, 1.0, 1.0, -1.0, -1.0, -1.0]);
assert_relative_eq!(out[2].unwrap(), 1.0, epsilon = 1e-12);
assert_relative_eq!(out[5].unwrap(), 0.0, epsilon = 1e-12);
}
#[test]
fn output_within_bounds() {
let mut wr = WinRate::new(20).unwrap();
let rets: Vec<f64> = (0..200).map(|i| (f64::from(i) * 0.7).sin()).collect();
for v in wr.batch(&rets).into_iter().flatten() {
assert!((0.0..=1.0).contains(&v), "out of bounds: {v}");
}
}
#[test]
fn reset_clears_state() {
let mut wr = WinRate::new(5).unwrap();
wr.batch(&[1.0, -1.0, 1.0, -1.0, 1.0]);
assert!(wr.is_ready());
wr.reset();
assert!(!wr.is_ready());
assert_eq!(wr.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 = WinRate::new(14).unwrap().batch(&rets);
let mut b = WinRate::new(14).unwrap();
let streamed: Vec<_> = rets.iter().map(|p| b.update(*p)).collect();
assert_eq!(batch, streamed);
}
}
+67 -53
View File
@@ -42,6 +42,7 @@
// builds — library code is still linted for genuinely large stack arrays.
#![cfg_attr(test, allow(clippy::large_stack_arrays))]
mod calendar;
mod cross_section;
mod derivatives;
mod error;
@@ -55,70 +56,83 @@ pub use cross_section::{CrossSection, Member};
pub use derivatives::DerivativesTick;
pub use error::{Error, Result};
pub use indicators::{
AbandonedBaby, AbsoluteBreadthIndex, AccelerationBands, AccelerationBandsOutput,
AbandonedBaby, Abcd, AbsoluteBreadthIndex, AccelerationBands, AccelerationBandsOutput,
AcceleratorOscillator, AdOscillator, AdVolumeLine, AdaptiveCycle, Adl, AdvanceBlock,
AdvanceDecline, AdvanceDeclineRatio, Adx, AdxOutput, Adxr, Alligator, AlligatorOutput, Alma,
Alpha, AnchoredRsi, AnchoredVwap, Apo, Aroon, AroonOscillator, AroonOutput, Atr, AtrBands,
AtrBandsOutput, AtrTrailingStop, Autocorrelation, AverageDrawdown, AvgPrice, AwesomeOscillator,
AwesomeOscillatorHistogram, BalanceOfPower, BeltHold, Beta, BetaNeutralSpread, BollingerBands,
BollingerBandwidth, BollingerOutput, BreadthThrust, Breakaway, BullishPercentIndex,
Alpha, AmihudIlliquidity, AnchoredRsi, AnchoredVwap, Apo, Aroon, AroonOscillator, AroonOutput,
Atr, AtrBands, AtrBandsOutput, AtrTrailingStop, AutoFib, AutoFibOutput, Autocorrelation,
AverageDailyRange, AverageDrawdown, AvgPrice, AwesomeOscillator, AwesomeOscillatorHistogram,
BalanceOfPower, Bat, BeltHold, Beta, BetaNeutralSpread, BodySizePct, BollingerBands,
BollingerBandwidth, BollingerOutput, BreadthThrust, Breakaway, BullishPercentIndex, Butterfly,
CalendarSpread, CalmarRatio, Camarilla, CamarillaPivotsOutput, Cci, CenterOfGravity, Cfo,
ChaikinMoneyFlow, ChaikinOscillator, ChaikinVolatility, ChandeKrollStop, ChandeKrollStopOutput,
ChandelierExit, ChandelierExitOutput, ChoppinessIndex, ClassicPivots, ClassicPivotsOutput,
ClosingMarubozu, Cmo, CoefficientOfVariation, Cointegration, CointegrationOutput,
ConcealingBabySwallow, ConditionalValueAtRisk, ConnorsRsi, Coppock, Counterattack,
CumulativeVolumeDelta, CumulativeVolumeIndex, CyberneticCycle, Decycler, DecyclerOscillator,
Dema, DemandIndex, DemarkPivots, DemarkPivotsOutput, DepthSlope, DetrendedStdDev, DistanceSsd,
Doji, DojiStar, Donchian, DonchianOutput, DonchianStop, DonchianStopOutput, DoubleBollinger,
DoubleBollingerOutput, DownsideGapThreeMethods, Dpo, DragonflyDoji, DrawdownDuration, Dx,
EaseOfMovement, EffectiveSpread, EhlersStochastic, ElderImpulse, Ema,
EmpiricalModeDecomposition, Engulfing, EveningDojiStar, Evwma, FallingThreeMethods, Fama,
FibonacciPivots, FibonacciPivotsOutput, FisherTransform, Footprint, FootprintOutput,
ForceIndex, FractalChaosBands, FractalChaosBandsOutput, Frama, FundingBasis, FundingRate,
FundingRateMean, FundingRateZScore, GainLossRatio, GapSideBySideWhite, GarmanKlassVolatility,
GrangerCausality, GravestoneDoji, Hammer, HangingMan, Harami, HeikinAshi, HeikinAshiOutput,
HiLoActivator, HighLowIndex, HighWave, Hikkake, HikkakeModified, HilbertDominantCycle,
HistoricalVolatility, Hma, HomingPigeon, HtDcPhase, HtPhasor, HtPhasorOutput, HtTrendMode,
HurstChannel, HurstChannelOutput, HurstExponent, Ichimoku, IchimokuOutput, IdenticalThreeCrows,
InNeck, Inertia, InformationRatio, InitialBalance, InitialBalanceOutput,
InstantaneousTrendline, InverseFisherTransform, InvertedHammer, Jma, KagiBars,
KalmanHedgeRatio, KalmanHedgeRatioOutput, Kama, KellyCriterion, Keltner, KeltnerOutput,
Kicking, KickingByLength, Kst, KstOutput, Kurtosis, Kvo, KylesLambda, LadderBottom,
LaguerreRsi, LeadLagCrossCorrelation, LeadLagCrossCorrelationOutput, LinRegAngle,
LinRegChannel, LinRegChannelOutput, LinRegIntercept, LinRegSlope, LinearRegression,
LiquidationFeatures, LiquidationFeaturesOutput, LongLeggedDoji, LongLine, LongShortRatio,
MaEnvelope, MaEnvelopeOutput, MacdExt, MacdFix, MacdIndicator, MacdOutput, Mama, MamaOutput,
MarketFacilitationIndex, Marubozu, MassIndex, MatHold, MatchingLow, MaxDrawdown,
McClellanOscillator, McClellanSummationIndex, McGinleyDynamic, MedianAbsoluteDeviation,
MedianPrice, Mfi, Microprice, MidPoint, MidPrice, MinusDi, MinusDm, Mom, MorningDojiStar,
MorningEveningStar, Natr, NewHighsNewLows, Nvi, OIPriceDivergence, OIWeighted, Obv, OmegaRatio,
OnNeck, OpenInterestDelta, OpeningMarubozu, OpeningRange, OpeningRangeOutput,
OrderBookImbalanceFull, OrderBookImbalanceTop1, OrderBookImbalanceTopN, OuHalfLife, PainIndex,
PairSpreadZScore, PairwiseBeta, ParkinsonVolatility, PearsonCorrelation, PercentAboveMa,
PercentB, PercentageTrailingStop, Pgo, PiercingDarkCloud, PlusDi, PlusDm, Pmo,
PointAndFigureBars, Ppo, ProfitFactor, Psar, Pvi, QuotedSpread, RSquared, RealizedSpread,
RecoveryFactor, RelativeStrengthAB, RelativeStrengthOutput, RenkoBars, RenkoTrailingStop,
RickshawMan, RisingThreeMethods, Roc, Rocp, Rocr, Rocr100, RogersSatchellVolatility,
RollingCorrelation, RollingCovariance, RollingVwap, RoofingFilter, Rsi, Rvi, RviVolatility,
Rwi, RwiOutput, SarExt, SeparatingLines, SharpeRatio, ShootingStar, ShortLine, SignedVolume,
SineWave, Skewness, Sma, Smi, Smma, SortinoRatio, SpearmanCorrelation, SpinningTop,
CloseVsOpen, ClosingMarubozu, Cmo, CoefficientOfVariation, Cointegration, CointegrationOutput,
ConcealingBabySwallow, ConditionalValueAtRisk, ConnorsRsi, Coppock, Counterattack, Crab,
CumulativeVolumeDelta, CumulativeVolumeIndex, CupAndHandle, CyberneticCycle, Cypher,
DayOfWeekProfile, DayOfWeekProfileOutput, Decycler, DecyclerOscillator, Dema, DemandIndex,
DemarkPivots, DemarkPivotsOutput, DepthSlope, DetrendedStdDev, DistanceSsd, Doji, DojiStar,
Donchian, DonchianOutput, DonchianStop, DonchianStopOutput, DoubleBollinger,
DoubleBollingerOutput, DoubleTopBottom, DownsideGapThreeMethods, Dpo, DragonflyDoji,
DrawdownDuration, Dx, EaseOfMovement, EffectiveSpread, EhlersStochastic, ElderImpulse, Ema,
EmpiricalModeDecomposition, Engulfing, EveningDojiStar, Evwma, Expectancy, FallingThreeMethods,
Fama, FibArcs, FibArcsOutput, FibChannel, FibChannelOutput, FibConfluence, FibConfluenceOutput,
FibExtension, FibExtensionOutput, FibFan, FibFanOutput, FibProjection, FibProjectionOutput,
FibRetracement, FibRetracementOutput, FibTimeZones, FibTimeZonesOutput, FibonacciPivots,
FibonacciPivotsOutput, FisherTransform, FlagPennant, Footprint, FootprintOutput, ForceIndex,
FractalChaosBands, FractalChaosBandsOutput, Frama, FundingBasis, FundingRate, FundingRateMean,
FundingRateZScore, GainLossRatio, GapSideBySideWhite, GarmanKlassVolatility, Gartley,
GoldenPocket, GoldenPocketOutput, GrangerCausality, GravestoneDoji, Hammer, HangingMan, Harami,
HeadAndShoulders, HeikinAshi, HeikinAshiOutput, HiLoActivator, HighLowIndex, HighLowRange,
HighWave, Hikkake, HikkakeModified, HilbertDominantCycle, HistoricalVolatility, Hma,
HomingPigeon, HtDcPhase, HtPhasor, HtPhasorOutput, HtTrendMode, HurstChannel,
HurstChannelOutput, HurstExponent, Ichimoku, IchimokuOutput, IdenticalThreeCrows, InNeck,
Inertia, InformationRatio, InitialBalance, InitialBalanceOutput, InstantaneousTrendline,
IntradayVolatilityProfile, IntradayVolatilityProfileOutput, InverseFisherTransform,
InvertedHammer, Jma, JumpIndicator, KagiBars, KalmanHedgeRatio, KalmanHedgeRatioOutput, Kama,
KellyCriterion, Keltner, KeltnerOutput, Kicking, KickingByLength, Kst, KstOutput, Kurtosis,
Kvo, KylesLambda, LadderBottom, LaguerreRsi, LeadLagCrossCorrelation,
LeadLagCrossCorrelationOutput, LinRegAngle, LinRegChannel, LinRegChannelOutput,
LinRegIntercept, LinRegSlope, LinearRegression, LiquidationFeatures, LiquidationFeaturesOutput,
LogReturn, LongLeggedDoji, LongLine, LongShortRatio, MaEnvelope, MaEnvelopeOutput, MacdExt,
MacdFix, MacdIndicator, MacdOutput, Mama, MamaOutput, MarketFacilitationIndex, Marubozu,
MassIndex, MatHold, MatchingLow, MaxDrawdown, McClellanOscillator, McClellanSummationIndex,
McGinleyDynamic, MedianAbsoluteDeviation, MedianPrice, Mfi, Microprice, MidPoint, MidPrice,
MinusDi, MinusDm, Mom, MorningDojiStar, MorningEveningStar, Natr, NewHighsNewLows, Nvi,
OIPriceDivergence, OIWeighted, Obv, OmegaRatio, OnNeck, OpenInterestDelta, OpeningMarubozu,
OpeningRange, OpeningRangeOutput, OrderBookImbalanceFull, OrderBookImbalanceTop1,
OrderBookImbalanceTopN, OrderFlowImbalance, OuHalfLife, OvernightGap, OvernightIntradayReturn,
OvernightIntradayReturnOutput, PainIndex, PairSpreadZScore, PairwiseBeta, ParkinsonVolatility,
PearsonCorrelation, PercentAboveMa, PercentB, PercentageTrailingStop, Pgo, PiercingDarkCloud,
PlusDi, PlusDm, Pmo, PointAndFigureBars, Ppo, ProfitFactor, Psar, Pvi, QuotedSpread, RSquared,
RealizedSpread, RealizedVolatility, RecoveryFactor, RectangleRange, RegimeLabel,
RelativeStrengthAB, RelativeStrengthOutput, RenkoBars, RenkoTrailingStop, RickshawMan,
RisingThreeMethods, Roc, Rocp, Rocr, Rocr100, RogersSatchellVolatility, RollMeasure,
RollingCorrelation, RollingCovariance, RollingIqr, RollingPercentileRank, RollingQuantile,
RollingVwap, RoofingFilter, Rsi, Rvi, RviVolatility, Rwi, RwiOutput, SarExt, SeasonalZScore,
SeparatingLines, SessionHighLow, SessionHighLowOutput, SessionRange, SessionRangeOutput,
SessionVwap, Shark, SharpeRatio, ShootingStar, ShortLine, SignedVolume, SineWave, Skewness,
Sma, Smi, Smma, SortinoRatio, SpearmanCorrelation, SpinningTop, SpreadAr1Coefficient,
SpreadBollingerBands, SpreadBollingerBandsOutput, SpreadHurst, StalledPattern, StandardError,
StandardErrorBands, StandardErrorBandsOutput, StarcBands, StarcBandsOutput, Stc, StdDev,
StepTrailingStop, StickSandwich, StochRsi, Stochastic, StochasticOutput, SuperSmoother,
SuperTrend, SuperTrendOutput, TakerBuySellRatio, Takuri, TasukiGap, TdCombo, TdCountdown,
TdDeMarker, TdDifferential, TdLines, TdLinesOutput, TdOpen, TdPressure, TdRangeProjection,
TdRangeProjectionOutput, TdRei, TdRiskLevel, TdRiskLevelOutput, TdSequential,
TdSequentialOutput, TdSetup, Tema, TermStructureBasis, ThreeInside, ThreeLineStrike,
ThreeOutside, ThreeSoldiersOrCrows, ThreeStarsInSouth, Thrusting, TickIndex, Tii, TpoProfile,
TpoProfileOutput, TradeImbalance, TreynorRatio, Trima, Trin, Trix, TrueRange, Tsf, Tsi, Tsv,
TtmSqueeze, TtmSqueezeOutput, Tweezer, TwoCrows, TypicalPrice, UlcerIndex, UltimateOscillator,
UniqueThreeRiver, UpDownVolumeRatio, UpsideGapThreeMethods, UpsideGapTwoCrows, ValueArea,
ValueAreaOutput, ValueAtRisk, Variance, VarianceRatio, VerticalHorizontalFilter, Vidya,
VoltyStop, VolumeOscillator, VolumePriceTrend, VolumeProfile, VolumeProfileOutput, Vortex,
VortexOutput, Vwap, VwapStdDevBands, VwapStdDevBandsOutput, Vwma, Vzo, WaveTrend,
WaveTrendOutput, WeightedClose, WilliamsFractals, WilliamsFractalsOutput, WilliamsR, Wma,
WoodiePivots, WoodiePivotsOutput, YangZhangVolatility, YoyoExit, ZScore, ZeroLagMacd,
ZeroLagMacdOutput, ZigZag, ZigZagOutput, Zlema, FAMILIES, T3,
TdSequentialOutput, TdSetup, Tema, TermStructureBasis, ThreeDrives, ThreeInside,
ThreeLineStrike, ThreeOutside, ThreeSoldiersOrCrows, ThreeStarsInSouth, Thrusting, TickIndex,
Tii, TimeOfDayReturnProfile, TimeOfDayReturnProfileOutput, TpoProfile, TpoProfileOutput,
TradeImbalance, TrendLabel, TreynorRatio, Triangle, Trima, Trin, TripleTopBottom, Trix,
TrueRange, Tsf, Tsi, Tsv, TtmSqueeze, TtmSqueezeOutput, TurnOfMonth, Tweezer, TwoCrows,
TypicalPrice, UlcerIndex, UltimateOscillator, UniqueThreeRiver, UpDownVolumeRatio,
UpsideGapThreeMethods, UpsideGapTwoCrows, ValueArea, ValueAreaOutput, ValueAtRisk, Variance,
VarianceRatio, VerticalHorizontalFilter, Vidya, VoltyStop, VolumeByTimeProfile,
VolumeByTimeProfileOutput, VolumeOscillator, VolumePriceTrend, VolumeProfile,
VolumeProfileOutput, Vortex, VortexOutput, Vpin, Vwap, VwapStdDevBands, VwapStdDevBandsOutput,
Vwma, Vzo, WaveTrend, WaveTrendOutput, Wedge, WeightedClose, WickRatio, WilliamsFractals,
WilliamsFractalsOutput, WilliamsR, WinRate, Wma, WoodiePivots, WoodiePivotsOutput,
YangZhangVolatility, YoyoExit, ZScore, ZeroLagMacd, ZeroLagMacdOutput, ZigZag, ZigZagOutput,
Zlema, FAMILIES, T3,
};
// `FootprintLevel` is a row element of `FootprintOutput`, re-exported on its own
// line so the indicator-count tooling (which scans the braced block above and
+1 -1
View File
@@ -8,7 +8,7 @@ That includes:
[Python](https://docs.wickra.org/Quickstart-Python),
[Node](https://docs.wickra.org/Quickstart-Node), and
[WASM](https://docs.wickra.org/Quickstart-WASM).
- A per-indicator deep dive for every one of the **339 indicators** across
- A per-indicator deep dive for every one of the **396 indicators** across
the sixteen families (Moving Averages, Momentum Oscillators, Trend &
Directional, Price Oscillators, Volatility & Bands, Bands & Channels,
Trailing Stops, Volume, Price Statistics, Ehlers / Cycle DSP, Pivots &
+7 -7
View File
@@ -17,7 +17,7 @@
},
"../../bindings/node": {
"name": "wickra",
"version": "0.5.0",
"version": "0.5.4",
"license": "MIT OR Apache-2.0",
"devDependencies": {
"@napi-rs/cli": "^2.18.0"
@@ -26,12 +26,12 @@
"node": ">= 18"
},
"optionalDependencies": {
"wickra-darwin-arm64": "0.5.0",
"wickra-darwin-x64": "0.5.0",
"wickra-linux-arm64-gnu": "0.5.0",
"wickra-linux-x64-gnu": "0.5.0",
"wickra-win32-arm64-msvc": "0.5.0",
"wickra-win32-x64-msvc": "0.5.0"
"wickra-darwin-arm64": "0.5.4",
"wickra-darwin-x64": "0.5.4",
"wickra-linux-arm64-gnu": "0.5.4",
"wickra-linux-x64-gnu": "0.5.4",
"wickra-win32-arm64-msvc": "0.5.4",
"wickra-win32-x64-msvc": "0.5.4"
}
},
"node_modules/wickra": {
+11 -3
View File
@@ -14,9 +14,7 @@
//! `Ema(20)`. This target now covers every scalar indicator in the catalogue.
use libfuzzer_sys::fuzz_target;
use wickra_core::{
AdaptiveCycle, Alma, AnchoredRsi, Apo, Autocorrelation, AverageDrawdown, BatchExt, Beta, BollingerBands, CalmarRatio, CenterOfGravity, Cfo, Cmo, CoefficientOfVariation, ConditionalValueAtRisk, ConnorsRsi, Coppock, CyberneticCycle, Decycler, DecyclerOscillator, Dema, DetrendedStdDev, DoubleBollinger, Dpo, DrawdownDuration, EhlersStochastic, ElderImpulse, Ema, EmpiricalModeDecomposition, Fama, FisherTransform, Frama, GainLossRatio, HilbertDominantCycle, HistoricalVolatility, Hma, HtDcPhase, HtPhasor, HtTrendMode, HurstExponent, Indicator, InstantaneousTrendline, InverseFisherTransform, Jma, Kama, KellyCriterion, Kst, Kurtosis, LaguerreRsi, LinRegAngle, LinRegChannel, LinRegIntercept, LinRegSlope, LinearRegression, MaEnvelope, MaType, MacdExt, MacdFix, MacdIndicator, Mama, MaxDrawdown, McGinleyDynamic, MedianAbsoluteDeviation, MidPoint, Mom, OmegaRatio, PainIndex, PearsonCorrelation, PercentageTrailingStop, Pmo, Ppo, ProfitFactor, RSquared, RecoveryFactor, RenkoTrailingStop, Roc, Rocp, Rocr, Rocr100, RoofingFilter, Rsi, RviVolatility, SharpeRatio, SineWave, Skewness, Sma, Smma, SortinoRatio, SpearmanCorrelation, StandardError, StandardErrorBands, Stc, StdDev, StepTrailingStop, StochRsi, SuperSmoother, Tema, Tii, Trima, Trix, Tsf, Tsi, UlcerIndex, ValueAtRisk, Variance, VerticalHorizontalFilter, Vidya, Wma, ZScore, ZeroLagMacd, Zlema, T3
};
use wickra_core::{AdaptiveCycle, Alma, AnchoredRsi, Apo, Autocorrelation, AverageDrawdown, BatchExt, Beta, BollingerBands, CalmarRatio, CenterOfGravity, Cfo, Cmo, CoefficientOfVariation, ConditionalValueAtRisk, ConnorsRsi, Coppock, CyberneticCycle, Decycler, DecyclerOscillator, Dema, DetrendedStdDev, DoubleBollinger, Dpo, DrawdownDuration, EhlersStochastic, ElderImpulse, Ema, EmpiricalModeDecomposition, Expectancy, Fama, FisherTransform, Frama, GainLossRatio, HilbertDominantCycle, HistoricalVolatility, Hma, HtDcPhase, HtPhasor, HtTrendMode, HurstExponent, Indicator, InstantaneousTrendline, InverseFisherTransform, Jma, JumpIndicator, Kama, KellyCriterion, Kst, Kurtosis, LaguerreRsi, LinRegAngle, LinRegChannel, LinRegIntercept, LinRegSlope, LinearRegression, LogReturn, MaEnvelope, MaType, MacdExt, MacdFix, MacdIndicator, Mama, MaxDrawdown, McGinleyDynamic, MedianAbsoluteDeviation, MidPoint, Mom, OmegaRatio, PainIndex, PearsonCorrelation, PercentageTrailingStop, Pmo, Ppo, ProfitFactor, RSquared, RealizedVolatility, RecoveryFactor, RegimeLabel, RenkoTrailingStop, Roc, Rocp, Rocr, Rocr100, RollingIqr, RollingPercentileRank, RollingQuantile, RoofingFilter, Rsi, RviVolatility, SharpeRatio, SineWave, Skewness, Sma, Smma, SortinoRatio, SpearmanCorrelation, StandardError, StandardErrorBands, Stc, StdDev, StepTrailingStop, StochRsi, SuperSmoother, Tema, Tii, TrendLabel, Trima, Trix, Tsf, Tsi, UlcerIndex, ValueAtRisk, Variance, VerticalHorizontalFilter, Vidya, WinRate, Wma, ZScore, ZeroLagMacd, Zlema, T3};
/// Drive a single streaming + batch run through one scalar indicator. Marked
/// `#[inline(never)]` so a panic backtrace pin-points the specific indicator.
@@ -96,6 +94,14 @@ fuzz_target!(|data: Vec<f64>| {
// HurstExponent needs `period >= 2 * chunks`; 16/4 is the cheapest fit
// that still exercises every code path.
drive(|| HurstExponent::new(16, 4).unwrap(), &data);
drive(|| LogReturn::new(1).unwrap(), &data);
drive(|| RealizedVolatility::new(20).unwrap(), &data);
drive(|| RollingQuantile::new(20, 0.5).unwrap(), &data);
drive(|| RollingIqr::new(14).unwrap(), &data);
drive(|| RollingPercentileRank::new(14).unwrap(), &data);
drive(|| TrendLabel::new(14).unwrap(), &data);
drive(|| JumpIndicator::new(20, 3.0).unwrap(), &data);
drive(|| RegimeLabel::new(5, 20).unwrap(), &data);
drive(|| RviVolatility::new(10).unwrap(), &data);
drive(|| LaguerreRsi::new(0.5).unwrap(), &data);
drive(|| ConnorsRsi::classic(), &data);
@@ -157,6 +163,8 @@ fuzz_target!(|data: Vec<f64>| {
drive(|| ProfitFactor::new(20).unwrap(), &data);
drive(|| GainLossRatio::new(20).unwrap(), &data);
drive(|| KellyCriterion::new(20).unwrap(), &data);
drive(|| WinRate::new(20).unwrap(), &data);
drive(|| Expectancy::new(20).unwrap(), &data);
// RecoveryFactor and DrawdownDuration produce non-`f64` outputs / have
// no `period` knob, so they cannot use the `drive` helper directly.
+62 -3
View File
@@ -22,9 +22,7 @@
//! WeightedClose.
use libfuzzer_sys::fuzz_target;
use wickra_core::{
AbandonedBaby, AccelerationBands, AcceleratorOscillator, AdOscillator, Adl, AdvanceBlock, Adx, Adxr, Alligator, AnchoredVwap, Aroon, AroonOscillator, Atr, AtrBands, AtrTrailingStop, AwesomeOscillator, AwesomeOscillatorHistogram, BalanceOfPower, BatchExt, BeltHold, Breakaway, Camarilla, Candle, Cci, ChaikinMoneyFlow, ChaikinOscillator, ChaikinVolatility, ChandeKrollStop, ChandelierExit, ChoppinessIndex, ClassicPivots, ClosingMarubozu, ConcealingBabySwallow, Counterattack, DemandIndex, DemarkPivots, Doji, DojiStar, Donchian, DonchianStop, DownsideGapThreeMethods, DragonflyDoji, Dx, EaseOfMovement, Engulfing, EveningDojiStar, Evwma, FallingThreeMethods, FibonacciPivots, ForceIndex, FractalChaosBands, GapSideBySideWhite, GarmanKlassVolatility, GravestoneDoji, Hammer, HangingMan, Harami, HeikinAshi, HiLoActivator, HighWave, Hikkake, HikkakeModified, HomingPigeon, HurstChannel, Ichimoku, IdenticalThreeCrows, InNeck, Indicator, Inertia, InitialBalance, InvertedHammer, Keltner, Kicking, KickingByLength, Kvo, LadderBottom, LongLeggedDoji, LongLine, MarketFacilitationIndex, Marubozu, MassIndex, MatHold, MatchingLow, AvgPrice, MedianPrice, Mfi, MidPrice, MinusDi, MinusDm, MorningDojiStar, MorningEveningStar, Natr, Nvi, Obv, OnNeck, OpeningMarubozu, OpeningRange, ParkinsonVolatility, Pgo, PiercingDarkCloud, PlusDi, PlusDm, Psar, Pvi, RickshawMan, RisingThreeMethods, RogersSatchellVolatility, RollingVwap, Rvi, Rwi, SarExt, SeparatingLines, ShootingStar, ShortLine, Smi, SpinningTop, StalledPattern, StarcBands, StickSandwich, Stochastic, SuperTrend, Takuri, TasukiGap, TdCombo, TdCountdown, TdDeMarker, TdDifferential, TdLines, TdOpen, TdPressure, TdRangeProjection, TdRei, TdRiskLevel, TdSequential, TdSetup, ThreeInside, ThreeLineStrike, ThreeOutside, ThreeSoldiersOrCrows, ThreeStarsInSouth, Thrusting, TpoProfile, TrueRange, Tsv, TtmSqueeze, Tweezer, TwoCrows, TypicalPrice, UltimateOscillator, UniqueThreeRiver, UpsideGapThreeMethods, UpsideGapTwoCrows, ValueArea, VoltyStop, VolumeOscillator, VolumePriceTrend, VolumeProfile, Vortex, Vwap, VwapStdDevBands, Vwma, Vzo, WaveTrend, WeightedClose, WilliamsFractals, WilliamsR, WoodiePivots, YangZhangVolatility, YoyoExit, ZigZag
};
use wickra_core::{AbandonedBaby, Abcd, AccelerationBands, AcceleratorOscillator, AdOscillator, Adl, AdvanceBlock, Adx, Adxr, Alligator, AnchoredVwap, Aroon, AroonOscillator, Atr, AtrBands, AtrTrailingStop, AutoFib, AverageDailyRange, AwesomeOscillator, AwesomeOscillatorHistogram, BalanceOfPower, Bat, BatchExt, BeltHold, BodySizePct, Breakaway, Butterfly, Camarilla, Candle, Cci, ChaikinMoneyFlow, ChaikinOscillator, ChaikinVolatility, ChandeKrollStop, ChandelierExit, ChoppinessIndex, ClassicPivots, CloseVsOpen, ClosingMarubozu, ConcealingBabySwallow, Counterattack, Crab, CupAndHandle, Cypher, DayOfWeekProfile, DemandIndex, DemarkPivots, Doji, DojiStar, Donchian, DonchianStop, DoubleTopBottom, DownsideGapThreeMethods, DragonflyDoji, Dx, EaseOfMovement, Engulfing, EveningDojiStar, Evwma, FallingThreeMethods, FibArcs, FibChannel, FibConfluence, FibExtension, FibFan, FibProjection, FibRetracement, FibTimeZones, FibonacciPivots, FlagPennant, ForceIndex, FractalChaosBands, GapSideBySideWhite, GarmanKlassVolatility, Gartley, GoldenPocket, GravestoneDoji, Hammer, HangingMan, Harami, HeadAndShoulders, HeikinAshi, HiLoActivator, HighLowRange, HighWave, Hikkake, HikkakeModified, HomingPigeon, HurstChannel, Ichimoku, IdenticalThreeCrows, InNeck, Indicator, Inertia, InitialBalance, IntradayVolatilityProfile, InvertedHammer, Keltner, Kicking, KickingByLength, Kvo, LadderBottom, LongLeggedDoji, LongLine, MarketFacilitationIndex, Marubozu, MassIndex, MatHold, MatchingLow, AvgPrice, MedianPrice, Mfi, MidPrice, MinusDi, MinusDm, MorningDojiStar, MorningEveningStar, Natr, Nvi, Obv, OnNeck, OpeningMarubozu, OpeningRange, OvernightGap, OvernightIntradayReturn, ParkinsonVolatility, Pgo, PiercingDarkCloud, PlusDi, PlusDm, Psar, Pvi, RectangleRange, RickshawMan, RisingThreeMethods, RogersSatchellVolatility, RollingVwap, Rvi, Rwi, SarExt, SeasonalZScore, SeparatingLines, SessionHighLow, SessionRange, SessionVwap, Shark, ShootingStar, ShortLine, Smi, SpinningTop, StalledPattern, StarcBands, StickSandwich, Stochastic, SuperTrend, Takuri, TasukiGap, TdCombo, TdCountdown, TdDeMarker, TdDifferential, TdLines, TdOpen, TdPressure, TdRangeProjection, TdRei, TdRiskLevel, TdSequential, TdSetup, ThreeDrives, ThreeInside, ThreeLineStrike, ThreeOutside, ThreeSoldiersOrCrows, ThreeStarsInSouth, Thrusting, TimeOfDayReturnProfile, TpoProfile, Triangle, TripleTopBottom, TrueRange, Tsv, TtmSqueeze, TurnOfMonth, Tweezer, TwoCrows, TypicalPrice, UltimateOscillator, UniqueThreeRiver, UpsideGapThreeMethods, UpsideGapTwoCrows, ValueArea, VoltyStop, VolumeByTimeProfile, VolumeOscillator, VolumePriceTrend, VolumeProfile, Vortex, Vwap, VwapStdDevBands, Vwma, Vzo, WaveTrend, Wedge, WeightedClose, WickRatio, WilliamsFractals, WilliamsR, WoodiePivots, YangZhangVolatility, YoyoExit, ZigZag};
/// Convert a flat `f64` stream into a `Vec<Candle>` by chunking it into
/// `[open, high, low, close, volume]` groups. Tuples that fail OHLCV
@@ -120,6 +118,10 @@ fuzz_target!(|data: Vec<f64>| {
drive(|| AcceleratorOscillator::new(5, 34, 5).unwrap(), &candles);
drive(|| UltimateOscillator::new(7, 14, 28).unwrap(), &candles);
drive(BalanceOfPower::new, &candles);
drive(CloseVsOpen::new, &candles);
drive(BodySizePct::new, &candles);
drive(WickRatio::new, &candles);
drive(HighLowRange::new, &candles);
// --- Volume ---
drive(Obv::new, &candles);
@@ -349,4 +351,61 @@ fuzz_target!(|data: Vec<f64>| {
drive(TwoCrows::new, &candles);
drive(UpsideGapTwoCrows::new, &candles);
drive(IdenticalThreeCrows::new, &candles);
// --- Seasonality & Session ---
drive(|| VolumeByTimeProfile::new(24, 0).unwrap(), &candles);
drive(|| IntradayVolatilityProfile::new(24, 0).unwrap(), &candles);
drive(|| DayOfWeekProfile::new(0), &candles);
drive(|| TimeOfDayReturnProfile::new(24, 0).unwrap(), &candles);
drive(|| SeasonalZScore::new(0), &candles);
drive(|| TurnOfMonth::new(3, 1, 0).unwrap(), &candles);
drive(|| OvernightIntradayReturn::new(0), &candles);
drive(|| OvernightGap::new(0), &candles);
drive(|| AverageDailyRange::new(14, 0).unwrap(), &candles);
drive(|| SessionRange::new(0), &candles);
drive(|| SessionHighLow::new(0), &candles);
drive(|| SessionVwap::new(0), &candles);
// --- Chart Patterns ---
drive(CupAndHandle::new, &candles);
drive(RectangleRange::new, &candles);
drive(FlagPennant::new, &candles);
drive(Wedge::new, &candles);
drive(Triangle::new, &candles);
drive(HeadAndShoulders::new, &candles);
drive(TripleTopBottom::new, &candles);
drive(DoubleTopBottom::new, &candles);
// --- Harmonic Patterns ---
drive(ThreeDrives::new, &candles);
drive(Cypher::new, &candles);
drive(Shark::new, &candles);
drive(Crab::new, &candles);
drive(Bat::new, &candles);
drive(Butterfly::new, &candles);
drive(Gartley::new, &candles);
drive(Abcd::new, &candles);
// --- Fibonacci (multi-output) ---
drive(FibTimeZones::new, &candles);
drive(FibChannel::new, &candles);
drive(FibArcs::new, &candles);
drive(FibFan::new, &candles);
drive(FibConfluence::new, &candles);
drive(GoldenPocket::new, &candles);
drive(AutoFib::new, &candles);
drive(FibProjection::new, &candles);
drive(FibExtension::new, &candles);
drive(FibRetracement::new, &candles);
});
@@ -11,10 +11,7 @@
//! any of them, streaming or batched.
use libfuzzer_sys::fuzz_target;
use wickra_core::{
BatchExt, DepthSlope, Indicator, Level, Microprice, OrderBook, OrderBookImbalanceFull,
OrderBookImbalanceTop1, OrderBookImbalanceTopN, QuotedSpread,
};
use wickra_core::{BatchExt, DepthSlope, Indicator, Level, Microprice, OrderBook, OrderBookImbalanceFull, OrderBookImbalanceTop1, OrderBookImbalanceTopN, OrderFlowImbalance, QuotedSpread};
#[inline(never)]
fn drive<I>(make: impl Fn() -> I, books: &[OrderBook])
@@ -52,4 +49,5 @@ fuzz_target!(|data: &[u8]| {
drive(Microprice::new, &books);
drive(QuotedSpread::new, &books);
drive(DepthSlope::new, &books);
drive(|| OrderFlowImbalance::new(20).unwrap(), &books);
});

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