Compare commits

..

6 Commits

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

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

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

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

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

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

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

All are scalar `f64 → f64`:

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

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

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

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

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

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

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

## What's added

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

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

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

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

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

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

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

## Verification

`cargo test -p wickra-core` (lib 3187 + doc 354), `cargo clippy --workspace --all-targets --all-features -D warnings` clean, node `npm run build && npm test` (471), python `pytest` (784). Counter consistent across `mod.rs`, lib block, README, and docs/README at 396.
2026-06-04 12:00:35 +02:00
66 changed files with 13007 additions and 142 deletions
+46 -1
View File
@@ -7,6 +7,48 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [0.5.6] - 2026-06-04
- **QQE** — quantitative qualitative estimation, a smoothed RSI with an ATR-of-RSI trailing line (`QQE`).
- **Intraday Momentum Index** — intraday momentum index (Chande), RSI on the open-to-close body (`IMI`).
- **Elder Ray** — Elder Ray bull power and bear power around an EMA of close (`ElderRay`).
- **Derivative Oscillator** — derivative oscillator (Constance Brown), a double-smoothed RSI histogram (`DerivativeOscillator`).
- **RMI** — relative momentum index (RMI), RSI over a multi-bar momentum lookback (`RMI`).
- **Stochastic CCI** — stochastic CCI, a stochastic oscillator over the CCI (`StochasticCCI`).
- **Dynamic Momentum Index** — dynamic momentum index (Chande), a volatility-adaptive RSI (`DynamicMomentumIndex`).
- **RSX** — RSX, a Jurik-style three-stage smoothed RSI (`RSX`).
- **Fisher RSI** — Fisher RSI, the Fisher transform of a normalised RSI (`FisherRSI`).
- **Disparity Index** — disparity index, the percent gap between price and its moving average (`DisparityIndex`).
## [0.5.5] - 2026-06-04
- **GD** — generalized DEMA (GD), Tillson's volume-factor double EMA and the building block of T3 (`GD`).
- **GMA** — geometric moving average (GMA), the rolling geometric mean of prices (`GMA`).
- **Holt-Winters** — Holt's linear (double exponential) smoothing with level and trend components (`HoltWinters`).
- **Adaptive Laguerre** — Ehlers adaptive Laguerre filter with median-error-adaptive gamma (`AdaptiveLaguerre`).
- **Median MA** — median moving average, the rolling median of prices (`MedianMA`).
- **EHMA** — exponential Hull moving average (EHMA), the Hull construction built from EMAs (`EHMA`).
- **SWMA** — sine-weighted moving average (SWMA), a symmetric half-cycle sine window (`SWMA`).
## [0.5.4] - 2026-06-04
- **Roll Measure** — effective spread implied by the negative serial covariance of trade-price changes (Roll 1984) (`RollMeasure`).
- **Amihud Illiquidity** — average absolute log return per unit of traded value (price-impact liquidity proxy, Amihud 2002) (`AmihudIlliquidity`).
- **VPIN** — volume-synchronised probability of informed trading (volume-bucketed order-flow toxicity) (`Vpin`).
- **Order Flow Imbalance** — rolling sum of best-level order-flow events (Cont-Kukanov-Stoikov OFI) (`OrderFlowImbalance`).
- **Expectancy** — expected return per unit of average loss (R-multiple) over a rolling window of returns (`Expectancy`).
- **Win Rate** — fraction of strictly-positive returns over a rolling window (`WinRate`).
- **Regime Label** — volatility-quantile regime classification: 1 calm / 0 normal / +1 stressed, by where the rolling volatility sits in its own recent distribution (`RegimeLabel`).
- **Jump Indicator** — flags return outliers beyond `threshold ×` trailing return volatility (1 down / 0 / +1 up) (`JumpIndicator`).
- **Trend Label** — discrete trend state from the sign of the rolling least-squares slope (1 / 0 / +1) (`TrendLabel`).
- **High-Low Range** — bar high-low range as a fraction of close (scale-free per-bar volatility) (`HighLowRange`).
- **Wick Ratio** — signed upper-vs-lower shadow imbalance as a fraction of the range (`WickRatio`).
- **Body Size Percent** — absolute candle body as a fraction of the bar range (`BodySizePct`).
- **Close vs Open** — signed body as a fraction of the open price, `(close open) / open` (`CloseVsOpen`).
- **Spread AR(1) Coefficient** — first-order autoregression coefficient of the spread `a b` (direct cointegration / mean-reversion strength) (`SpreadAr1Coefficient`).
- **Rolling Quantile** — interpolated q-th quantile over a trailing window (type-7 / NumPy default) (`RollingQuantile`).
- **Rolling Percentile Rank** — percentile rank of the latest value within its trailing window (`RollingPercentileRank`).
- **Rolling IQR** — interquartile range (Q3 Q1) over a trailing window (robust dispersion) (`RollingIqr`).
- **Realized Volatility** — square root of the summed squared log returns (raw, un-annualised quadratic variation) (`RealizedVolatility`).
- **Log Return** — logarithmic return over a fixed lag, `ln(price_t / price_{tperiod})` (`LogReturn`).
## [0.5.3] - 2026-06-04
- **Fibonacci Time Zones** — vertical markers at Fibonacci bar-distances (1/2/3/5/8/...) from the latest swing pivot (`FIB_TIME_ZONES`).
- **Fibonacci Channel** — a sloped base trendline plus parallel lines at Fibonacci multiples of the channel width (`FIB_CHANNEL`).
@@ -1217,7 +1259,10 @@ 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.3...HEAD
[Unreleased]: https://github.com/wickra-lib/wickra/compare/v0.5.6...HEAD
[0.5.6]: https://github.com/wickra-lib/wickra/compare/v0.5.5...v0.5.6
[0.5.5]: https://github.com/wickra-lib/wickra/compare/v0.5.4...v0.5.5
[0.5.4]: https://github.com/wickra-lib/wickra/compare/v0.5.3...v0.5.4
[0.5.3]: https://github.com/wickra-lib/wickra/compare/v0.5.2...v0.5.3
[0.5.2]: https://github.com/wickra-lib/wickra/compare/v0.5.1...v0.5.2
[0.5.1]: https://github.com/wickra-lib/wickra/compare/v0.5.0...v0.5.1
Generated
+6 -6
View File
@@ -1867,7 +1867,7 @@ dependencies = [
[[package]]
name = "wickra"
version = "0.5.3"
version = "0.5.6"
dependencies = [
"approx",
"criterion",
@@ -1878,7 +1878,7 @@ dependencies = [
[[package]]
name = "wickra-core"
version = "0.5.3"
version = "0.5.6"
dependencies = [
"approx",
"proptest",
@@ -1888,7 +1888,7 @@ dependencies = [
[[package]]
name = "wickra-data"
version = "0.5.3"
version = "0.5.6"
dependencies = [
"approx",
"csv",
@@ -1915,7 +1915,7 @@ dependencies = [
[[package]]
name = "wickra-node"
version = "0.5.3"
version = "0.5.6"
dependencies = [
"napi",
"napi-build",
@@ -1925,7 +1925,7 @@ dependencies = [
[[package]]
name = "wickra-python"
version = "0.5.3"
version = "0.5.6"
dependencies = [
"numpy",
"pyo3",
@@ -1934,7 +1934,7 @@ dependencies = [
[[package]]
name = "wickra-wasm"
version = "0.5.3"
version = "0.5.6"
dependencies = [
"console_error_panic_hook",
"js-sys",
+2 -2
View File
@@ -12,7 +12,7 @@ members = [
exclude = ["fuzz"]
[workspace.package]
version = "0.5.3"
version = "0.5.6"
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.3" }
wickra-core = { path = "crates/wickra-core", version = "0.5.6" }
thiserror = "2"
rayon = "1.10"
+8 -8
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=377" alt="Wickra — streaming-first technical indicators" width="100%"></a>
<a href="https://wickra.org"><img src="https://raw.githubusercontent.com/wickra-lib/.github/main/profile/wickra-banner.webp?v=413" alt="Wickra — streaming-first technical indicators" width="100%"></a>
</p>
[![CI](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml/badge.svg)](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml)
@@ -48,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 377 indicators; start at the
every one of the 413 indicators; start at the
[indicators overview](https://docs.wickra.org/Indicators-Overview).
- **Reference** — [warmup periods](https://docs.wickra.org/Warmup-Periods),
[streaming vs batch](https://docs.wickra.org/Streaming-vs-Batch),
@@ -136,22 +136,22 @@ python -m benchmarks.compare_libraries
## Indicators
377 streaming-first indicators across twenty-four families. Every one passes the
413 streaming-first indicators across twenty-four families. Every one passes the
`batch == streaming` equivalence test, reference-value tests, and reset
semantics tests. Each has a per-indicator deep dive (formula, parameters,
warmup) at [docs.wickra.org](https://docs.wickra.org/Indicators-Overview).
| Family | Indicators |
|--------|-----------|
| Moving Averages | SMA, EMA, WMA, DEMA, TEMA, HMA, KAMA, SMMA, TRIMA, ZLEMA, T3, VWMA, ALMA, McGinley Dynamic, FRAMA, VIDYA, JMA, Alligator, EVWMA |
| Momentum Oscillators | RSI (Wilder), Anchored RSI, Stochastic, CCI, ROC, Williams %R, MFI, Awesome Oscillator, MOM, CMO, TSI, PMO, StochRSI, Ultimate Oscillator, RVI, PGO, KST, SMI, Laguerre RSI, Connors RSI, Inertia, ROC Percentage (ROCP), ROC Ratio (ROCR), ROC Ratio 100 (ROCR100) |
| Moving Averages | SMA, EMA, WMA, DEMA, TEMA, HMA, KAMA, SMMA, TRIMA, ZLEMA, T3, VWMA, ALMA, McGinley Dynamic, FRAMA, VIDYA, JMA, Alligator, EVWMA, SWMA, GMA, EHMA, Median MA, Adaptive Laguerre, GD, Holt-Winters |
| Momentum Oscillators | RSI (Wilder), Anchored RSI, Stochastic, CCI, ROC, Williams %R, MFI, Awesome Oscillator, MOM, CMO, TSI, PMO, StochRSI, Ultimate Oscillator, RVI, PGO, KST, SMI, Laguerre RSI, Connors RSI, Inertia, ROC Percentage (ROCP), ROC Ratio (ROCR), ROC Ratio 100 (ROCR100), Disparity Index, Fisher RSI, RSX, Dynamic Momentum Index, Stochastic CCI, RMI, Derivative Oscillator, Elder Ray, Intraday Momentum Index, QQE |
| Trend & Directional | MACD, MACD Fixed (MACDFIX), MACD Extended (MACDEXT), ADX (+DI/-DI), ADXR, Aroon, TRIX, Aroon Oscillator, Vortex, Random Walk Index, Trend Intensity Index, Wave Trend Oscillator, Mass Index, Choppiness Index, Vertical Horizontal Filter, Plus DM, Minus DM, Plus DI, Minus DI, DX |
| Price Oscillators | PPO, DPO, Coppock, Accelerator Oscillator, Balance of Power, APO, AO Histogram, CFO, Zero-Lag MACD, Elder Impulse, STC |
| Volatility & Bands | ATR, Bollinger Bands, Keltner Channels, Donchian Channels, NATR, StdDev, Ulcer Index, Historical Volatility, Bollinger Bandwidth, %B, True Range, Chaikin Volatility, RVI (Relative Volatility Index), Parkinson Volatility, Garman-Klass Volatility, Rogers-Satchell Volatility, Yang-Zhang Volatility |
| Bands & Channels | MA Envelope, Acceleration Bands, STARC Bands, ATR Bands, Hurst Channel, LinReg Channel, Standard Error Bands, Double Bollinger Bands, TTM Squeeze, Fractal Chaos Bands, VWAP StdDev Bands |
| Trailing Stops | Parabolic SAR, 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 |
@@ -161,7 +161,7 @@ warmup) at [docs.wickra.org](https://docs.wickra.org/Indicators-Overview).
| 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 |
| 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 |
@@ -245,7 +245,7 @@ A Python live-trading example using the public `websockets` package lives at
```
wickra/
├── crates/
│ ├── wickra-core/ core engine + all 377 indicators
│ ├── wickra-core/ core engine + all 413 indicators
│ ├── wickra/ top-level facade crate (publishes on crates.io) + benches/
│ └── wickra-data/ CSV reader, tick aggregator, live exchange feeds
├── bindings/
@@ -28,6 +28,29 @@ function num(v) {
// --- Scalar indicators: update(value) vs batch(prices) ---
const scalarFactories = {
DerivativeOscillator: () => new wickra.DerivativeOscillator(14, 5, 3, 9),
RMI: () => new wickra.RMI(14, 5),
DynamicMomentumIndex: () => new wickra.DynamicMomentumIndex(14),
RSX: () => new wickra.RSX(14),
FisherRSI: () => new wickra.FisherRSI(14),
DisparityIndex: () => new wickra.DisparityIndex(14),
HoltWinters: () => new wickra.HoltWinters(0.2, 0.1),
GD: () => new wickra.GD(5, 0.7),
AdaptiveLaguerre: () => new wickra.AdaptiveLaguerre(13),
MedianMA: () => new wickra.MedianMA(14),
EHMA: () => new wickra.EHMA(9),
GMA: () => new wickra.GMA(14),
SWMA: () => new wickra.SWMA(14),
Expectancy: () => new wickra.Expectancy(20),
WinRate: () => new wickra.WinRate(20),
RegimeLabel: () => new wickra.RegimeLabel(5, 20),
JumpIndicator: () => new wickra.JumpIndicator(20, 3.0),
TrendLabel: () => new wickra.TrendLabel(10),
RollingQuantile: () => new wickra.RollingQuantile(20, 0.5),
RollingPercentileRank: () => new wickra.RollingPercentileRank(14),
RollingIqr: () => new wickra.RollingIqr(14),
RealizedVolatility: () => new wickra.RealizedVolatility(20),
LogReturn: () => new wickra.LogReturn(1),
TSF: () => new wickra.TSF(14),
LINEARREG_INTERCEPT: () => new wickra.LINEARREG_INTERCEPT(14),
ROCR100: () => new wickra.ROCR100(10),
@@ -313,6 +336,12 @@ const candleScalar = {
Shark: { make: () => new wickra.Shark(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
Cypher: { make: () => new wickra.Cypher(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
ThreeDrives: { make: () => new wickra.ThreeDrives(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
CloseVsOpen: { make: () => new wickra.CloseVsOpen(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
BodySizePct: { make: () => new wickra.BodySizePct(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
WickRatio: { make: () => new wickra.WickRatio(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
HighLowRange: { make: () => new wickra.HighLowRange(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
StochasticCCI: { make: () => new wickra.StochasticCCI(14), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
IMI: { make: () => new wickra.IMI(14), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
};
for (const [name, d] of Object.entries(candleScalar)) {
@@ -395,6 +424,8 @@ const multi = {
FibArcs: { make: () => new wickra.FibArcs(), fields: ['arc382', 'arc500', 'arc618'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
FibChannel: { make: () => new wickra.FibChannel(), fields: ['base', 'level618', 'level1000', 'level1618'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
FibTimeZones: { make: () => new wickra.FibTimeZones(), fields: ['onZone', 'barsToNext'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
ElderRay: { make: () => new wickra.ElderRay(13), fields: ['bullPower', 'bearPower'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
QQE: { make: () => new wickra.QQE(14, 5, 4.236), fields: ['rsiMa', 'trailingLine'], step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
};
for (const [name, d] of Object.entries(multi)) {
@@ -563,6 +594,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)) {
@@ -1126,6 +1158,57 @@ test('trade-flow rejects bad input', () => {
assert.throws(() => new wickra.SignedVolume().update(100, -1, true));
});
test('order-flow imbalance reference + streaming matches batch', () => {
// Rising bid (px up, size 6) with an unchanged ask -> +6 flow.
const ofi = new wickra.OrderFlowImbalance(1);
assert.equal(ofi.update([100], [5], [101], [4]), null); // seeds the reference
assert.ok(Math.abs(ofi.update([100.5], [6], [101], [4]) - 6.0) < 1e-12);
const snaps = Array.from({ length: 30 }, (_, i) => ({
bidPx: [100 + Math.sin(i * 0.3)],
bidSz: [5 + Math.abs(Math.cos(i * 0.5))],
askPx: [101 + Math.sin(i * 0.3)],
askSz: [4 + Math.abs(Math.sin(i * 0.4))],
}));
const batch = new wickra.OrderFlowImbalance(10).batch(snaps);
const streamer = new wickra.OrderFlowImbalance(10);
assert.equal(batch.length, snaps.length);
for (let i = 0; i < snaps.length; i++) {
const s = streamer.update(snaps[i].bidPx, snaps[i].bidSz, snaps[i].askPx, snaps[i].askSz);
assert.ok((Number.isNaN(batch[i]) && s === null) || Math.abs(s - batch[i]) < 1e-9, `mismatch at ${i}`);
}
});
test('vpin / amihud / roll reference + streaming matches batch', () => {
// VPIN: two pure-buy buckets of size 10 -> imbalance == size -> 1.
const v = new wickra.Vpin(10, 2);
let last;
for (let i = 0; i < 4; i++) last = v.update(100, 5, true);
assert.equal(last, 1.0);
// Amihud(1): |ln(101/100)| / (101 * 10).
const a = new wickra.AmihudIlliquidity(1);
assert.equal(a.update(100, 10, true), null);
assert.ok(Math.abs(a.update(101, 10, true) - Math.abs(Math.log(101 / 100)) / (101 * 10)) < 1e-15);
// Roll(6): a clean bid-ask bounce of ±1 implies a spread of 2.
const r = new wickra.RollMeasure(6);
let roll = null;
for (let i = 0; i < 20; i++) roll = r.update(i % 2 === 0 ? 100 : 101, 1, true);
assert.ok(Math.abs(roll - 2.0) < 1e-12);
// Streaming-vs-batch for the three trade-input indicators.
const n = 40;
const price = Array.from({ length: n }, (_, i) => 100 + Math.sin(i * 0.25) * 4);
const size = Array.from({ length: n }, (_, i) => 1 + (i % 5));
const isBuy = Array.from({ length: n }, (_, i) => i % 2 === 0);
for (const make of [() => new wickra.Vpin(8, 5), () => new wickra.AmihudIlliquidity(14), () => new wickra.RollMeasure(14)]) {
const batch = make().batch(price, size, isBuy);
const streamer = make();
assert.equal(batch.length, n);
for (let i = 0; i < n; i++) {
const s = streamer.update(price[i], size[i], isBuy[i]);
assert.ok((Number.isNaN(batch[i]) && s === null) || Math.abs(s - batch[i]) < 1e-9, `mismatch at ${i}`);
}
}
});
test('price-impact indicators reference values', () => {
// Buy at 100.05 vs mid 100.0: 2 * (100.05 - 100) / 100 * 10000 = 10 bps.
assert.ok(Math.abs(new wickra.EffectiveSpread().update(100.05, 1, true, 100.0) - 10.0) < 1e-9);
+336
View File
@@ -69,6 +69,14 @@ export interface HtPhasorValue {
inphase: number
quadrature: number
}
export interface QqeValue {
rsiMa: number
trailingLine: number
}
export interface ElderRayValue {
bullPower: number
bearPower: number
}
export interface StochValue {
k: number
d: number
@@ -809,6 +817,177 @@ 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 SineWeightedMaNode = SWMA
export declare class SWMA {
constructor(period: number)
update(value: number): number | null
batch(prices: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type GeometricMaNode = GMA
export declare class GMA {
constructor(period: number)
update(value: number): number | null
batch(prices: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type EhmaNode = EHMA
export declare class EHMA {
constructor(period: number)
update(value: number): number | null
batch(prices: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type MedianMaNode = MedianMA
export declare class MedianMA {
constructor(period: number)
update(value: number): number | null
batch(prices: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type AdaptiveLaguerreFilterNode = AdaptiveLaguerre
export declare class AdaptiveLaguerre {
constructor(period: number)
update(value: number): number | null
batch(prices: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type DisparityIndexNode = DisparityIndex
export declare class DisparityIndex {
constructor(period: number)
update(value: number): number | null
batch(prices: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type FisherRsiNode = FisherRSI
export declare class FisherRSI {
constructor(period: number)
update(value: number): number | null
batch(prices: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type RsxNode = RSX
export declare class RSX {
constructor(period: number)
update(value: number): number | null
batch(prices: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type DynamicMomentumIndexNode = DynamicMomentumIndex
export declare class DynamicMomentumIndex {
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)
@@ -866,6 +1045,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)
@@ -1230,6 +1422,78 @@ 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 StochasticCciNode = StochasticCCI
export declare class StochasticCCI {
constructor(period: number)
update(high: number, low: number, close: number): number | null
batch(high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type ImiNode = IMI
export declare class IMI {
constructor(period: number)
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 QqeNode = QQE
export declare class QQE {
constructor(rsiPeriod: number, smoothing: number, factor: number)
update(value: number): QqeValue | null
batch(prices: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type ElderRayNode = ElderRay
export declare class ElderRay {
constructor(period: number)
update(high: number, low: number, close: number): ElderRayValue | null
batch(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)
@@ -1538,6 +1802,42 @@ export declare class T3 {
isReady(): boolean
warmupPeriod(): number
}
export type GeneralizedDemaNode = GD
export declare class GD {
constructor(period: number, v: number)
update(value: number): number | null
batch(prices: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type HoltWintersNode = HoltWinters
export declare class HoltWinters {
constructor(alpha: number, beta: number)
update(value: number): number | null
batch(prices: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type RmiNode = RMI
export declare class RMI {
constructor(period: number, momentum: number)
update(value: number): number | null
batch(prices: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type DerivativeOscillatorNode = DerivativeOscillator
export declare class DerivativeOscillator {
constructor(rsiPeriod: number, smooth1: number, smooth2: number, signalPeriod: number)
update(value: number): number | null
batch(prices: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type TsiNode = TSI
export declare class TSI {
constructor(long: number, short: number)
@@ -3317,6 +3617,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()
+37 -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.3",
"version": "0.5.6",
"description": "Native binding for wickra (macOS Apple Silicon). Installed automatically as an optional dependency of wickra on matching platforms.",
"main": "wickra.darwin-arm64.node",
"files": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "wickra-darwin-x64",
"version": "0.5.3",
"version": "0.5.6",
"description": "Native binding for wickra (macOS Intel). Installed automatically as an optional dependency of wickra on matching platforms.",
"main": "wickra.darwin-x64.node",
"files": [
@@ -1,6 +1,6 @@
{
"name": "wickra-linux-arm64-gnu",
"version": "0.5.3",
"version": "0.5.6",
"description": "Native binding for wickra (linux arm64 GNU). Installed automatically as an optional dependency of wickra on matching platforms.",
"main": "wickra.linux-arm64-gnu.node",
"files": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "wickra-linux-x64-gnu",
"version": "0.5.3",
"version": "0.5.6",
"description": "Native binding for wickra (linux x64 GNU). Installed automatically as an optional dependency of wickra on matching platforms.",
"main": "wickra.linux-x64-gnu.node",
"files": [
@@ -1,6 +1,6 @@
{
"name": "wickra-win32-arm64-msvc",
"version": "0.5.3",
"version": "0.5.6",
"description": "Native binding for wickra (Windows arm64 MSVC). Installed automatically as an optional dependency of wickra on matching platforms.",
"main": "wickra.win32-arm64-msvc.node",
"files": [
@@ -1,6 +1,6 @@
{
"name": "wickra-win32-x64-msvc",
"version": "0.5.3",
"version": "0.5.6",
"description": "Native binding for wickra (Windows x64 MSVC). Installed automatically as an optional dependency of wickra on matching platforms.",
"main": "wickra.win32-x64-msvc.node",
"files": [
+20 -20
View File
@@ -1,12 +1,12 @@
{
"name": "wickra",
"version": "0.5.3",
"version": "0.5.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "wickra",
"version": "0.5.3",
"version": "0.5.6",
"license": "MIT OR Apache-2.0",
"devDependencies": {
"@napi-rs/cli": "^2.18.0"
@@ -15,12 +15,12 @@
"node": ">= 18"
},
"optionalDependencies": {
"wickra-darwin-arm64": "0.5.3",
"wickra-darwin-x64": "0.5.3",
"wickra-linux-arm64-gnu": "0.5.3",
"wickra-linux-x64-gnu": "0.5.3",
"wickra-win32-arm64-msvc": "0.5.3",
"wickra-win32-x64-msvc": "0.5.3"
"wickra-darwin-arm64": "0.5.6",
"wickra-darwin-x64": "0.5.6",
"wickra-linux-arm64-gnu": "0.5.6",
"wickra-linux-x64-gnu": "0.5.6",
"wickra-win32-arm64-msvc": "0.5.6",
"wickra-win32-x64-msvc": "0.5.6"
}
},
"node_modules/@napi-rs/cli": {
@@ -41,8 +41,8 @@
}
},
"node_modules/wickra-darwin-arm64": {
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/wickra-darwin-arm64/-/wickra-darwin-arm64-0.5.3.tgz",
"version": "0.5.6",
"resolved": "https://registry.npmjs.org/wickra-darwin-arm64/-/wickra-darwin-arm64-0.5.6.tgz",
"integrity": "sha512-4eZiBR/yGUdr4nzhEUFy2i69XgNx64iI2ax/LPamsThgylC0KpHOZKK19QzJ2d9KbK4C8nMjME5FLuR+4GNEwQ==",
"cpu": [
"arm64"
@@ -57,8 +57,8 @@
}
},
"node_modules/wickra-darwin-x64": {
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/wickra-darwin-x64/-/wickra-darwin-x64-0.5.3.tgz",
"version": "0.5.6",
"resolved": "https://registry.npmjs.org/wickra-darwin-x64/-/wickra-darwin-x64-0.5.6.tgz",
"integrity": "sha512-6hf8zI3QPjTFp4zCpmgUwDvNtu6jHqNUHKD5e55POo0CgA52HkpyxSPtVm8TGTIZDI7kPjlbOdBM8CJ76mmXwA==",
"cpu": [
"x64"
@@ -73,8 +73,8 @@
}
},
"node_modules/wickra-linux-arm64-gnu": {
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/wickra-linux-arm64-gnu/-/wickra-linux-arm64-gnu-0.5.3.tgz",
"version": "0.5.6",
"resolved": "https://registry.npmjs.org/wickra-linux-arm64-gnu/-/wickra-linux-arm64-gnu-0.5.6.tgz",
"integrity": "sha512-kSe6y0xBMSiqdPLXNjwop5WZdHtvdBNKSEBCwZ4hFq33p4apW25/wrlzv9/oDuyD4kuPabJEhCCnFOplh58CUg==",
"cpu": [
"arm64"
@@ -89,8 +89,8 @@
}
},
"node_modules/wickra-linux-x64-gnu": {
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/wickra-linux-x64-gnu/-/wickra-linux-x64-gnu-0.5.3.tgz",
"version": "0.5.6",
"resolved": "https://registry.npmjs.org/wickra-linux-x64-gnu/-/wickra-linux-x64-gnu-0.5.6.tgz",
"integrity": "sha512-tWBWS4qz7hxM4xnpFb59bhf6TaLwXq0Z3jEa/2l7r8PiHA94g8r8S53NRMiT+4yiL5hSWe/nUiC/YXdRrhEZ4g==",
"cpu": [
"x64"
@@ -105,8 +105,8 @@
}
},
"node_modules/wickra-win32-arm64-msvc": {
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/wickra-win32-arm64-msvc/-/wickra-win32-arm64-msvc-0.5.3.tgz",
"version": "0.5.6",
"resolved": "https://registry.npmjs.org/wickra-win32-arm64-msvc/-/wickra-win32-arm64-msvc-0.5.6.tgz",
"integrity": "sha512-EXIckHxAtF75PUGDKRzXyqMe9ldP0JjSdu68WFN6iJfp+McYrGu6h40TEJlQ/oUEIoPqiZB/xhVyo/el5Lg7zw==",
"cpu": [
"arm64"
@@ -121,8 +121,8 @@
}
},
"node_modules/wickra-win32-x64-msvc": {
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/wickra-win32-x64-msvc/-/wickra-win32-x64-msvc-0.5.3.tgz",
"version": "0.5.6",
"resolved": "https://registry.npmjs.org/wickra-win32-x64-msvc/-/wickra-win32-x64-msvc-0.5.6.tgz",
"integrity": "sha512-Yfsqq1Xwp6hdxMyLze411vNdo7BDwI6+lPSe7A9XdqyPecNDbtKwYLpsal2r8EHbNzqM+R8XnuRtUaEQS5VlUQ==",
"cpu": [
"x64"
+7 -7
View File
@@ -1,6 +1,6 @@
{
"name": "wickra",
"version": "0.5.3",
"version": "0.5.6",
"description": "Streaming-first technical indicators: incremental, fast, install-free. Node bindings powered by Rust.",
"author": "kingchenc <support@wickra.org>",
"main": "index.js",
@@ -47,12 +47,12 @@
"node": ">= 18"
},
"optionalDependencies": {
"wickra-linux-x64-gnu": "0.5.3",
"wickra-linux-arm64-gnu": "0.5.3",
"wickra-darwin-x64": "0.5.3",
"wickra-darwin-arm64": "0.5.3",
"wickra-win32-x64-msvc": "0.5.3",
"wickra-win32-arm64-msvc": "0.5.3"
"wickra-linux-x64-gnu": "0.5.6",
"wickra-linux-arm64-gnu": "0.5.6",
"wickra-darwin-x64": "0.5.6",
"wickra-darwin-arm64": "0.5.6",
"wickra-win32-x64-msvc": "0.5.6",
"wickra-win32-arm64-msvc": "0.5.6"
},
"scripts": {
"build": "napi build --platform --release",
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.3"
version = "0.5.6"
description = "Streaming-first technical indicators: incremental, fast, install-free."
readme = "README.md"
license = "MIT OR Apache-2.0"
+72
View File
@@ -25,6 +25,37 @@ from __future__ import annotations
from ._wickra import (
__version__,
QQE,
IMI,
ElderRay,
DerivativeOscillator,
RMI,
StochasticCCI,
DynamicMomentumIndex,
RSX,
FisherRSI,
DisparityIndex,
HoltWinters,
GD,
AdaptiveLaguerre,
MedianMA,
EHMA,
GMA,
SWMA,
Expectancy,
WinRate,
RegimeLabel,
JumpIndicator,
TrendLabel,
HighLowRange,
WickRatio,
BodySizePct,
CloseVsOpen,
RollingQuantile,
RollingPercentileRank,
RollingIqr,
RealizedVolatility,
LogReturn,
TSF,
LINEARREG_INTERCEPT,
ROCR100,
@@ -189,6 +220,7 @@ from ._wickra import (
PearsonCorrelation,
Beta,
PairwiseBeta,
SpreadAr1Coefficient,
PairSpreadZScore,
LeadLagCrossCorrelation,
Cointegration,
@@ -351,6 +383,7 @@ from ._wickra import (
FibExtension,
FibRetracement,
# Microstructure: order book
OrderFlowImbalance,
OrderBookImbalanceTop1,
OrderBookImbalanceTopN,
OrderBookImbalanceFull,
@@ -358,6 +391,9 @@ from ._wickra import (
QuotedSpread,
DepthSlope,
# Microstructure: trade flow
RollMeasure,
AmihudIlliquidity,
Vpin,
SignedVolume,
CumulativeVolumeDelta,
TradeImbalance,
@@ -430,6 +466,37 @@ from ._wickra import (
)
__all__ = [
"QQE",
"IMI",
"ElderRay",
"DerivativeOscillator",
"RMI",
"StochasticCCI",
"DynamicMomentumIndex",
"RSX",
"FisherRSI",
"DisparityIndex",
"HoltWinters",
"GD",
"AdaptiveLaguerre",
"MedianMA",
"EHMA",
"GMA",
"SWMA",
"Expectancy",
"WinRate",
"RegimeLabel",
"JumpIndicator",
"TrendLabel",
"HighLowRange",
"WickRatio",
"BodySizePct",
"CloseVsOpen",
"RollingQuantile",
"RollingPercentileRank",
"RollingIqr",
"RealizedVolatility",
"LogReturn",
"TSF",
"LINEARREG_INTERCEPT",
"ROCR100",
@@ -595,6 +662,7 @@ __all__ = [
"PearsonCorrelation",
"Beta",
"PairwiseBeta",
"SpreadAr1Coefficient",
"PairSpreadZScore",
"LeadLagCrossCorrelation",
"Cointegration",
@@ -757,6 +825,7 @@ __all__ = [
"FibExtension",
"FibRetracement",
# Microstructure: order book
"OrderFlowImbalance",
"OrderBookImbalanceTop1",
"OrderBookImbalanceTopN",
"OrderBookImbalanceFull",
@@ -764,6 +833,9 @@ __all__ = [
"QuotedSpread",
"DepthSlope",
# Microstructure: trade flow
"RollMeasure",
"AmihudIlliquidity",
"Vpin",
"SignedVolume",
"CumulativeVolumeDelta",
"TradeImbalance",
File diff suppressed because it is too large Load Diff
@@ -45,6 +45,29 @@ def ohlcv() -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
# --- Scalar (f64 -> f64) indicators ---------------------------------------
SCALAR = [
(ta.DerivativeOscillator, (14, 5, 3, 9)),
(ta.RMI, (14, 5)),
(ta.DynamicMomentumIndex, (14,)),
(ta.RSX, (14,)),
(ta.FisherRSI, (14,)),
(ta.DisparityIndex, (14,)),
(ta.HoltWinters, (0.2, 0.1)),
(ta.GD, (5, 0.7)),
(ta.AdaptiveLaguerre, (13,)),
(ta.MedianMA, (14,)),
(ta.EHMA, (9,)),
(ta.GMA, (14,)),
(ta.SWMA, (14,)),
(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,)),
@@ -140,6 +163,7 @@ SCALAR = [
# Family 05 band/channel indicators with scalar input and multi-output.
# `cols` is the expected number of band columns from `batch`.
SCALAR_MULTI = {
"Qqe": (lambda: ta.QQE(14, 5, 4.236), 2),
"MaEnvelope": (lambda: ta.MaEnvelope(20, 0.025), 3),
"LinRegChannel": (lambda: ta.LinRegChannel(20, 2.0), 3),
"StandardErrorBands": (lambda: ta.StandardErrorBands(21, 2.0), 3),
@@ -167,6 +191,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 +355,13 @@ def test_relative_strength_streaming_matches_batch():
# 6-tuple candle; the batch helper takes only the columns it needs.
CANDLE_SCALAR = {
"StochasticCCI": (lambda: ta.StochasticCCI(14), lambda ind, h, l, c, v: ind.batch(h, l, c)),
# 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),
@@ -860,6 +892,11 @@ def test_candle_scalar_streaming_matches_batch(name, ohlcv):
# --- Candle-input, multi-output indicators --------------------------------
MULTI = {
"ElderRay": (
lambda: ta.ElderRay(13),
lambda ind, h, l, c, v: ind.batch(h, l, c),
2,
),
"FibFan": (
lambda: ta.FibFan(),
lambda ind, h, l, c, v: ind.batch(h, l),
@@ -2707,6 +2744,41 @@ def test_fib_time_zones_reference():
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)
def test_elder_ray_reference():
er = ta.ElderRay(3)
high = np.array([11.0, 13.0, 16.0])
low = np.array([9.0, 11.0, 13.0])
close = np.array([10.0, 12.0, 14.0])
out = er.batch(high, low, close)
# EMA(3) seeds at the third bar with mean close 12; bar high 16 -> bull 4,
# low 13 -> bear 1.
assert out[2][0] == pytest.approx(4.0)
assert out[2][1] == pytest.approx(1.0)
def test_imi_reference():
imi = ta.IMI(3)
open_ = np.array([10.0, 11.0, 10.0])
high = np.array([12.0, 12.0, 13.0])
low = np.array([9.0, 9.0, 9.0])
close = np.array([11.0, 10.0, 12.0])
out = imi.batch(open_, high, low, close)
# bodies +1, -1, +2 -> gain 3, loss 1 -> 100 * 3 / 4 = 75.
assert math.isnan(out[0])
assert math.isnan(out[1])
assert out[2] == pytest.approx(75.0)
# --- Lifecycle ------------------------------------------------------------
@@ -3024,6 +3096,7 @@ def test_orderbook_indicators_streaming_equals_batch():
ta.Microprice,
ta.QuotedSpread,
ta.DepthSlope,
lambda: ta.OrderFlowImbalance(10),
):
batch = make().batch(snaps)
streamer = make()
@@ -3043,6 +3116,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()
+564
View File
@@ -526,6 +526,11 @@ wasm_pair_indicator!(
);
wasm_pair_indicator!(WasmBeta, "Beta", wc::Beta);
wasm_pair_indicator!(WasmPairwiseBeta, "PairwiseBeta", wc::PairwiseBeta);
wasm_pair_indicator!(
WasmSpreadAr1Coefficient,
"SpreadAr1Coefficient",
wc::SpreadAr1Coefficient
);
wasm_pair_indicator!(
WasmSpearmanCorrelation,
"SpearmanCorrelation",
@@ -1841,6 +1846,415 @@ impl WasmHtPhasor {
}
}
#[wasm_bindgen(js_name = CloseVsOpen)]
pub struct WasmCloseVsOpen {
inner: wc::CloseVsOpen,
}
impl Default for WasmCloseVsOpen {
fn default() -> Self {
Self::new()
}
}
#[wasm_bindgen(js_class = CloseVsOpen)]
impl WasmCloseVsOpen {
#[wasm_bindgen(constructor)]
pub fn new() -> WasmCloseVsOpen {
Self {
inner: wc::CloseVsOpen::new(),
}
}
pub fn update(
&mut self,
open: f64,
high: f64,
low: f64,
close: f64,
) -> Result<Option<f64>, JsError> {
let c = make_candle_ohlc(open, high, low, close)?;
Ok(self.inner.update(c))
}
pub fn batch(
&mut self,
open: &[f64],
high: &[f64],
low: &[f64],
close: &[f64],
) -> Result<Float64Array, JsError> {
if open.len() != high.len() || high.len() != low.len() || low.len() != close.len() {
return Err(JsError::new("open, high, low, close must be equal length"));
}
let mut out = Vec::with_capacity(open.len());
for i in 0..open.len() {
let c = make_candle_ohlc(open[i], high[i], low[i], close[i])?;
out.push(self.inner.update(c).unwrap_or(f64::NAN));
}
Ok(Float64Array::from(out.as_slice()))
}
pub fn reset(&mut self) {
self.inner.reset();
}
}
#[wasm_bindgen(js_name = BodySizePct)]
pub struct WasmBodySizePct {
inner: wc::BodySizePct,
}
impl Default for WasmBodySizePct {
fn default() -> Self {
Self::new()
}
}
#[wasm_bindgen(js_class = BodySizePct)]
impl WasmBodySizePct {
#[wasm_bindgen(constructor)]
pub fn new() -> WasmBodySizePct {
Self {
inner: wc::BodySizePct::new(),
}
}
pub fn update(
&mut self,
open: f64,
high: f64,
low: f64,
close: f64,
) -> Result<Option<f64>, JsError> {
let c = make_candle_ohlc(open, high, low, close)?;
Ok(self.inner.update(c))
}
pub fn batch(
&mut self,
open: &[f64],
high: &[f64],
low: &[f64],
close: &[f64],
) -> Result<Float64Array, JsError> {
if open.len() != high.len() || high.len() != low.len() || low.len() != close.len() {
return Err(JsError::new("open, high, low, close must be equal length"));
}
let mut out = Vec::with_capacity(open.len());
for i in 0..open.len() {
let c = make_candle_ohlc(open[i], high[i], low[i], close[i])?;
out.push(self.inner.update(c).unwrap_or(f64::NAN));
}
Ok(Float64Array::from(out.as_slice()))
}
pub fn reset(&mut self) {
self.inner.reset();
}
}
#[wasm_bindgen(js_name = WickRatio)]
pub struct WasmWickRatio {
inner: wc::WickRatio,
}
impl Default for WasmWickRatio {
fn default() -> Self {
Self::new()
}
}
#[wasm_bindgen(js_class = WickRatio)]
impl WasmWickRatio {
#[wasm_bindgen(constructor)]
pub fn new() -> WasmWickRatio {
Self {
inner: wc::WickRatio::new(),
}
}
pub fn update(
&mut self,
open: f64,
high: f64,
low: f64,
close: f64,
) -> Result<Option<f64>, JsError> {
let c = make_candle_ohlc(open, high, low, close)?;
Ok(self.inner.update(c))
}
pub fn batch(
&mut self,
open: &[f64],
high: &[f64],
low: &[f64],
close: &[f64],
) -> Result<Float64Array, JsError> {
if open.len() != high.len() || high.len() != low.len() || low.len() != close.len() {
return Err(JsError::new("open, high, low, close must be equal length"));
}
let mut out = Vec::with_capacity(open.len());
for i in 0..open.len() {
let c = make_candle_ohlc(open[i], high[i], low[i], close[i])?;
out.push(self.inner.update(c).unwrap_or(f64::NAN));
}
Ok(Float64Array::from(out.as_slice()))
}
pub fn reset(&mut self) {
self.inner.reset();
}
}
#[wasm_bindgen(js_name = HighLowRange)]
pub struct WasmHighLowRange {
inner: wc::HighLowRange,
}
impl Default for WasmHighLowRange {
fn default() -> Self {
Self::new()
}
}
#[wasm_bindgen(js_class = HighLowRange)]
impl WasmHighLowRange {
#[wasm_bindgen(constructor)]
pub fn new() -> WasmHighLowRange {
Self {
inner: wc::HighLowRange::new(),
}
}
pub fn update(
&mut self,
open: f64,
high: f64,
low: f64,
close: f64,
) -> Result<Option<f64>, JsError> {
let c = make_candle_ohlc(open, high, low, close)?;
Ok(self.inner.update(c))
}
pub fn batch(
&mut self,
open: &[f64],
high: &[f64],
low: &[f64],
close: &[f64],
) -> Result<Float64Array, JsError> {
if open.len() != high.len() || high.len() != low.len() || low.len() != close.len() {
return Err(JsError::new("open, high, low, close must be equal length"));
}
let mut out = Vec::with_capacity(open.len());
for i in 0..open.len() {
let c = make_candle_ohlc(open[i], high[i], low[i], close[i])?;
out.push(self.inner.update(c).unwrap_or(f64::NAN));
}
Ok(Float64Array::from(out.as_slice()))
}
pub fn reset(&mut self) {
self.inner.reset();
}
}
#[wasm_bindgen(js_name = StochasticCCI)]
pub struct WasmStochasticCci {
inner: wc::StochasticCci,
}
#[wasm_bindgen(js_class = StochasticCCI)]
impl WasmStochasticCci {
#[wasm_bindgen(constructor)]
pub fn new(period: usize) -> Result<WasmStochasticCci, JsError> {
Ok(Self {
inner: wc::StochasticCci::new(period).map_err(map_err)?,
})
}
pub fn update(&mut self, high: f64, low: f64, close: f64) -> Result<Option<f64>, JsError> {
let c = make_candle(high, low, close, 0.0)?;
Ok(self.inner.update(c))
}
pub fn batch(
&mut self,
high: &[f64],
low: &[f64],
close: &[f64],
) -> Result<Float64Array, JsError> {
if high.len() != low.len() || low.len() != close.len() {
return Err(JsError::new("high, low, close must be equal length"));
}
let mut out = Vec::with_capacity(high.len());
for i in 0..high.len() {
let c = make_candle(high[i], low[i], close[i], 0.0)?;
out.push(self.inner.update(c).unwrap_or(f64::NAN));
}
Ok(Float64Array::from(out.as_slice()))
}
pub fn reset(&mut self) {
self.inner.reset();
}
}
#[wasm_bindgen(js_name = IMI)]
pub struct WasmImi {
inner: wc::IntradayMomentumIndex,
}
#[wasm_bindgen(js_class = IMI)]
impl WasmImi {
#[wasm_bindgen(constructor)]
pub fn new(period: usize) -> Result<WasmImi, JsError> {
Ok(Self {
inner: wc::IntradayMomentumIndex::new(period).map_err(map_err)?,
})
}
/// Batch over open/high/low/close arrays; `NaN` during warmup.
pub fn batch(
&mut self,
open: &[f64],
high: &[f64],
low: &[f64],
close: &[f64],
) -> Result<Float64Array, JsError> {
let n = open.len();
if high.len() != n || low.len() != n || close.len() != n {
return Err(JsError::new("open, high, low, close must be equal length"));
}
let mut out = vec![f64::NAN; n];
for i in 0..n {
let c = make_candle_ohlc(open[i], high[i], low[i], close[i])?;
if let Some(v) = self.inner.update(c) {
out[i] = v;
}
}
Ok(Float64Array::from(out.as_slice()))
}
pub fn reset(&mut self) {
self.inner.reset();
}
/// Streaming update over one candle's open/high/low/close.
pub fn update(
&mut self,
open: f64,
high: f64,
low: f64,
close: f64,
) -> Result<Option<f64>, JsError> {
let c = make_candle_ohlc(open, high, low, close)?;
Ok(self.inner.update(c))
}
#[wasm_bindgen(js_name = isReady)]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[wasm_bindgen(js_name = warmupPeriod)]
pub fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
}
#[wasm_bindgen(js_name = QQE)]
pub struct WasmQqe {
inner: wc::Qqe,
}
#[wasm_bindgen(js_class = QQE)]
impl WasmQqe {
#[wasm_bindgen(constructor)]
pub fn new(rsi_period: usize, smoothing: usize, factor: f64) -> Result<WasmQqe, JsError> {
Ok(Self {
inner: wc::Qqe::new(rsi_period, smoothing, factor).map_err(map_err)?,
})
}
/// Returns `[rsiMa0, trailing0, rsiMa1, trailing1, ...]`, length `2 * n`.
pub fn batch(&mut self, prices: &[f64]) -> Float64Array {
let mut out = vec![f64::NAN; prices.len() * 2];
for (i, p) in prices.iter().enumerate() {
if let Some(o) = self.inner.update(*p) {
out[i * 2] = o.rsi_ma;
out[i * 2 + 1] = o.trailing_line;
}
}
Float64Array::from(out.as_slice())
}
pub fn reset(&mut self) {
self.inner.reset();
}
/// Streaming update. Returns `{ rsiMa, trailingLine }` once warm, else `null`.
pub fn update(&mut self, value: f64) -> JsValue {
match self.inner.update(value) {
Some(o) => {
let obj = Object::new();
Reflect::set(&obj, &"rsiMa".into(), &o.rsi_ma.into()).ok();
Reflect::set(&obj, &"trailingLine".into(), &o.trailing_line.into()).ok();
obj.into()
}
None => JsValue::NULL,
}
}
#[wasm_bindgen(js_name = isReady)]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[wasm_bindgen(js_name = warmupPeriod)]
pub fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
}
#[wasm_bindgen(js_name = ElderRay)]
pub struct WasmElderRay {
inner: wc::ElderRay,
}
#[wasm_bindgen(js_class = ElderRay)]
impl WasmElderRay {
#[wasm_bindgen(constructor)]
pub fn new(period: usize) -> Result<WasmElderRay, JsError> {
Ok(Self {
inner: wc::ElderRay::new(period).map_err(map_err)?,
})
}
/// Returns `[bull0, bear0, bull1, bear1, ...]`, length `2 * n`.
pub fn batch(
&mut self,
high: &[f64],
low: &[f64],
close: &[f64],
) -> Result<Float64Array, JsError> {
let n = high.len();
if low.len() != n || close.len() != n {
return Err(JsError::new("high, low, close must be equal length"));
}
let mut out = vec![f64::NAN; n * 2];
for i in 0..n {
let c = make_candle(high[i], low[i], close[i], 0.0)?;
if let Some(o) = self.inner.update(c) {
out[i * 2] = o.bull_power;
out[i * 2 + 1] = o.bear_power;
}
}
Ok(Float64Array::from(out.as_slice()))
}
pub fn reset(&mut self) {
self.inner.reset();
}
/// Streaming update. Returns `{ bullPower, bearPower }` once warm, else `null`.
pub fn update(&mut self, high: f64, low: f64, close: f64) -> Result<JsValue, JsError> {
let c = make_candle(high, low, close, 0.0)?;
Ok(match self.inner.update(c) {
Some(o) => {
let obj = Object::new();
Reflect::set(&obj, &"bullPower".into(), &o.bull_power.into()).ok();
Reflect::set(&obj, &"bearPower".into(), &o.bear_power.into()).ok();
obj.into()
}
None => JsValue::NULL,
})
}
#[wasm_bindgen(js_name = isReady)]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[wasm_bindgen(js_name = warmupPeriod)]
pub fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
}
#[wasm_bindgen(js_name = Stochastic)]
pub struct WasmStoch {
inner: wc::Stochastic,
@@ -7596,6 +8010,133 @@ impl WasmTradeImbalance {
}
}
// Order Flow Imbalance: order-book input with a `period` parameter.
#[wasm_bindgen(js_name = OrderFlowImbalance)]
pub struct WasmOrderFlowImbalance {
inner: wc::OrderFlowImbalance,
}
#[wasm_bindgen(js_class = OrderFlowImbalance)]
impl WasmOrderFlowImbalance {
#[wasm_bindgen(constructor)]
pub fn new(period: usize) -> Result<WasmOrderFlowImbalance, JsError> {
Ok(Self {
inner: wc::OrderFlowImbalance::new(period).map_err(map_err)?,
})
}
pub fn update(
&mut self,
bid_px: &[f64],
bid_sz: &[f64],
ask_px: &[f64],
ask_sz: &[f64],
) -> Result<Option<f64>, JsError> {
let book = build_order_book(bid_px, bid_sz, ask_px, ask_sz)?;
Ok(self.inner.update(book))
}
pub fn reset(&mut self) {
self.inner.reset();
}
#[wasm_bindgen(js_name = isReady)]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[wasm_bindgen(js_name = warmupPeriod)]
pub fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
}
// VPIN: trade input, volume-bucketed `(bucket_volume, num_buckets)`.
#[wasm_bindgen(js_name = Vpin)]
pub struct WasmVpin {
inner: wc::Vpin,
}
#[wasm_bindgen(js_class = Vpin)]
impl WasmVpin {
#[wasm_bindgen(constructor)]
pub fn new(bucket_volume: f64, num_buckets: usize) -> Result<WasmVpin, JsError> {
Ok(Self {
inner: wc::Vpin::new(bucket_volume, num_buckets).map_err(map_err)?,
})
}
pub fn update(&mut self, price: f64, size: f64, is_buy: bool) -> Result<Option<f64>, JsError> {
Ok(self.inner.update(build_trade(price, size, is_buy)?))
}
pub fn reset(&mut self) {
self.inner.reset();
}
#[wasm_bindgen(js_name = isReady)]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[wasm_bindgen(js_name = warmupPeriod)]
pub fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
}
// Amihud Illiquidity: trade input with a `period` parameter.
#[wasm_bindgen(js_name = AmihudIlliquidity)]
pub struct WasmAmihudIlliquidity {
inner: wc::AmihudIlliquidity,
}
#[wasm_bindgen(js_class = AmihudIlliquidity)]
impl WasmAmihudIlliquidity {
#[wasm_bindgen(constructor)]
pub fn new(period: usize) -> Result<WasmAmihudIlliquidity, JsError> {
Ok(Self {
inner: wc::AmihudIlliquidity::new(period).map_err(map_err)?,
})
}
pub fn update(&mut self, price: f64, size: f64, is_buy: bool) -> Result<Option<f64>, JsError> {
Ok(self.inner.update(build_trade(price, size, is_buy)?))
}
pub fn reset(&mut self) {
self.inner.reset();
}
#[wasm_bindgen(js_name = isReady)]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[wasm_bindgen(js_name = warmupPeriod)]
pub fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
}
// Roll Measure: trade input with a `period` parameter.
#[wasm_bindgen(js_name = RollMeasure)]
pub struct WasmRollMeasure {
inner: wc::RollMeasure,
}
#[wasm_bindgen(js_class = RollMeasure)]
impl WasmRollMeasure {
#[wasm_bindgen(constructor)]
pub fn new(period: usize) -> Result<WasmRollMeasure, JsError> {
Ok(Self {
inner: wc::RollMeasure::new(period).map_err(map_err)?,
})
}
pub fn update(&mut self, price: f64, size: f64, is_buy: bool) -> Result<Option<f64>, JsError> {
Ok(self.inner.update(build_trade(price, size, is_buy)?))
}
pub fn reset(&mut self) {
self.inner.reset();
}
#[wasm_bindgen(js_name = isReady)]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[wasm_bindgen(js_name = warmupPeriod)]
pub fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
}
// ============================== Microstructure: Price Impact ==============================
//
// Price-impact indicators consume a trade paired with the mid prevailing at
@@ -9867,6 +10408,29 @@ wasm_scalar_indicator!(WasmRocr, "ROCR", wc::Rocr, period: usize);
wasm_scalar_indicator!(WasmRocr100, "ROCR100", wc::Rocr100, period: usize);
wasm_scalar_indicator!(WasmLinRegIntercept, "LINEARREG_INTERCEPT", wc::LinRegIntercept, period: usize);
wasm_scalar_indicator!(WasmTsf, "TSF", wc::Tsf, period: usize);
wasm_scalar_indicator!(WasmLogReturn, "LogReturn", wc::LogReturn, period: usize);
wasm_scalar_indicator!(WasmRealizedVolatility, "RealizedVolatility", wc::RealizedVolatility, period: usize);
wasm_scalar_indicator!(WasmRollingIqr, "RollingIqr", wc::RollingIqr, period: usize);
wasm_scalar_indicator!(WasmRollingPercentileRank, "RollingPercentileRank", wc::RollingPercentileRank, period: usize);
wasm_scalar_indicator!(WasmRollingQuantile, "RollingQuantile", wc::RollingQuantile, period: usize, quantile: f64);
wasm_scalar_indicator!(WasmTrendLabel, "TrendLabel", wc::TrendLabel, period: usize);
wasm_scalar_indicator!(WasmJumpIndicator, "JumpIndicator", wc::JumpIndicator, period: usize, threshold: f64);
wasm_scalar_indicator!(WasmRegimeLabel, "RegimeLabel", wc::RegimeLabel, vol_period: usize, lookback: usize);
wasm_scalar_indicator!(WasmWinRate, "WinRate", wc::WinRate, period: usize);
wasm_scalar_indicator!(WasmExpectancy, "Expectancy", wc::Expectancy, period: usize);
wasm_scalar_indicator!(WasmSineWeightedMa, "SWMA", wc::SineWeightedMa, period: usize);
wasm_scalar_indicator!(WasmGeometricMa, "GMA", wc::GeometricMa, period: usize);
wasm_scalar_indicator!(WasmEhma, "EHMA", wc::Ehma, period: usize);
wasm_scalar_indicator!(WasmMedianMa, "MedianMA", wc::MedianMa, period: usize);
wasm_scalar_indicator!(WasmAdaptiveLaguerreFilter, "AdaptiveLaguerre", wc::AdaptiveLaguerreFilter, period: usize);
wasm_scalar_indicator!(WasmGeneralizedDema, "GD", wc::GeneralizedDema, period: usize, v: f64);
wasm_scalar_indicator!(WasmHoltWinters, "HoltWinters", wc::HoltWinters, alpha: f64, beta: f64);
wasm_scalar_indicator!(WasmDisparityIndex, "DisparityIndex", wc::DisparityIndex, period: usize);
wasm_scalar_indicator!(WasmFisherRsi, "FisherRSI", wc::FisherRsi, period: usize);
wasm_scalar_indicator!(WasmRsx, "RSX", wc::Rsx, period: usize);
wasm_scalar_indicator!(WasmDynamicMomentumIndex, "DynamicMomentumIndex", wc::DynamicMomentumIndex, period: usize);
wasm_scalar_indicator!(WasmRmi, "RMI", wc::Rmi, period: usize, momentum: usize);
wasm_scalar_indicator!(WasmDerivativeOscillator, "DerivativeOscillator", wc::DerivativeOscillator, rsi_period: usize, smooth1: usize, smooth2: usize, signal_period: usize);
// --- DrawdownDuration: u32 output, no constructor args ---
@@ -0,0 +1,344 @@
//! Ehlers' Adaptive Laguerre Filter.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// John Ehlers' Adaptive Laguerre Filter — a four-stage Laguerre polynomial
/// smoother whose damping factor `gamma` is recomputed every bar from how well
/// the filter is currently tracking price.
///
/// The Laguerre cascade is the same one used by [`LaguerreRsi`](crate::LaguerreRsi),
/// but instead of a fixed `gamma` the filter adapts: it measures the recent
/// absolute error `|price filter|`, normalises those errors across a window of
/// `period` bars to `[0, 1]`, and takes their **median** as `gamma`. When price
/// is tracking smoothly the errors are small and uniform (low `gamma`, fast
/// response); when price jumps, the spread of errors widens and `gamma` rises,
/// slowing the filter to reject the noise.
///
/// ```text
/// diff_t = |price_t filter_{t-1}|
/// over the last `period` diffs:
/// HH = max(diff), LL = min(diff)
/// norm_i = (diff_i LL) / (HH LL) (0 if HH == LL)
/// gamma = median(norm)
/// alpha = 1 gamma
/// L0_t = alpha·price_t + gamma·L0_{t-1}
/// L1_t = gamma·L0_t + L0_{t-1} + gamma·L1_{t-1}
/// L2_t = gamma·L1_t + L1_{t-1} + gamma·L2_{t-1}
/// L3_t = gamma·L2_t + L2_{t-1} + gamma·L3_{t-1}
/// filter_t = (L0_t + 2·L1_t + 2·L2_t + L3_t) / 6
/// ```
///
/// The output is a smoothed price on the same scale as the input. The first
/// emission lands once the error window holds `period` values.
///
/// Reference: John F. Ehlers, *"Adaptive Laguerre Filter"*, Technical Analysis
/// of Stocks & Commodities, 2007.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, AdaptiveLaguerreFilter};
///
/// let mut indicator = AdaptiveLaguerreFilter::new(13).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// last = indicator.update(100.0 + f64::from(i));
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct AdaptiveLaguerreFilter {
period: usize,
l0: f64,
l1: f64,
l2: f64,
l3: f64,
/// Previous filter output, or `None` before the first bar.
filter: Option<f64>,
/// The last `period` absolute errors `|price filter|`.
diffs: VecDeque<f64>,
}
impl AdaptiveLaguerreFilter {
/// Construct a new adaptive Laguerre filter with the given error-window
/// length.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
l0: 0.0,
l1: 0.0,
l2: 0.0,
l3: 0.0,
filter: None,
diffs: VecDeque::with_capacity(period),
})
}
/// Configured error-window length.
pub const fn period(&self) -> usize {
self.period
}
/// Current value if the error window is full.
pub fn value(&self) -> Option<f64> {
if self.diffs.len() == self.period {
self.filter
} else {
None
}
}
/// Median of the normalised errors currently in the window. Returns `0.0`
/// when every error is equal (e.g. during a constant warmup), which makes
/// the filter maximally fast.
fn adaptive_gamma(&self) -> f64 {
let mut hh = f64::MIN;
let mut ll = f64::MAX;
for &d in &self.diffs {
if d > hh {
hh = d;
}
if d < ll {
ll = d;
}
}
let range = hh - ll;
if range <= 0.0 {
return 0.0;
}
let mut norm: Vec<f64> = self.diffs.iter().map(|&d| (d - ll) / range).collect();
// `total_cmp` never panics — under pathological (e.g. overflowing) fuzz
// inputs a normalised error can be non-finite; a total order keeps the
// sort sound where `partial_cmp` would return `None`.
norm.sort_by(f64::total_cmp);
let mid = norm.len() / 2;
if norm.len() % 2 == 1 {
norm[mid]
} else {
f64::midpoint(norm[mid - 1], norm[mid])
}
}
}
impl Indicator for AdaptiveLaguerreFilter {
type Input = f64;
type Output = f64;
fn update(&mut self, price: f64) -> Option<f64> {
if !price.is_finite() {
return self.value();
}
// Absolute tracking error against the previous filter (0 on the first
// bar, where there is no prior filter value).
let diff = self.filter.map_or(0.0, |f| (price - f).abs());
if self.diffs.len() == self.period {
self.diffs.pop_front();
}
self.diffs.push_back(diff);
let gamma = self.adaptive_gamma();
let alpha = 1.0 - gamma;
let l0 = alpha * price + gamma * self.l0;
let l1 = -gamma * l0 + self.l0 + gamma * self.l1;
let l2 = -gamma * l1 + self.l1 + gamma * self.l2;
let l3 = -gamma * l2 + self.l2 + gamma * self.l3;
self.l0 = l0;
self.l1 = l1;
self.l2 = l2;
self.l3 = l3;
let filter = (l0 + 2.0 * l1 + 2.0 * l2 + l3) / 6.0;
self.filter = Some(filter);
self.value()
}
fn reset(&mut self) {
self.l0 = 0.0;
self.l1 = 0.0;
self.l2 = 0.0;
self.l3 = 0.0;
self.filter = None;
self.diffs.clear();
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.diffs.len() == self.period
}
fn name(&self) -> &'static str {
"AdaptiveLaguerre"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
/// Independent reference: replays the exact recurrence from scratch.
fn naive(prices: &[f64], period: usize) -> Vec<Option<f64>> {
let (mut l0, mut l1, mut l2, mut l3) = (0.0_f64, 0.0_f64, 0.0_f64, 0.0_f64);
let mut filter: Option<f64> = None;
let mut diffs: Vec<f64> = Vec::new();
let mut out = Vec::with_capacity(prices.len());
for &price in prices {
let diff = filter.map_or(0.0, |f: f64| (price - f).abs());
diffs.push(diff);
if diffs.len() > period {
diffs.remove(0);
}
let hh = diffs.iter().copied().fold(f64::MIN, f64::max);
let ll = diffs.iter().copied().fold(f64::MAX, f64::min);
let range = hh - ll;
let gamma = if range <= 0.0 {
0.0
} else {
let mut norm: Vec<f64> = diffs.iter().map(|&d| (d - ll) / range).collect();
norm.sort_by(|a, b| a.partial_cmp(b).unwrap());
let mid = norm.len() / 2;
if norm.len() % 2 == 1 {
norm[mid]
} else {
f64::midpoint(norm[mid - 1], norm[mid])
}
};
let alpha = 1.0 - gamma;
let n0 = alpha * price + gamma * l0;
let n1 = -gamma * n0 + l0 + gamma * l1;
let n2 = -gamma * n1 + l1 + gamma * l2;
let n3 = -gamma * n2 + l2 + gamma * l3;
l0 = n0;
l1 = n1;
l2 = n2;
l3 = n3;
let f = (n0 + 2.0 * n1 + 2.0 * n2 + n3) / 6.0;
filter = Some(f);
out.push(if diffs.len() == period { Some(f) } else { None });
}
out
}
#[test]
fn new_rejects_zero_period() {
assert!(matches!(
AdaptiveLaguerreFilter::new(0),
Err(Error::PeriodZero)
));
}
/// Cover the const accessor `period` and the Indicator-impl `warmup_period`
/// + `name`.
#[test]
fn accessors_and_metadata() {
let alf = AdaptiveLaguerreFilter::new(13).unwrap();
assert_eq!(alf.period(), 13);
assert_eq!(alf.warmup_period(), 13);
assert_eq!(alf.name(), "AdaptiveLaguerre");
}
#[test]
fn warmup_returns_none_until_window_full() {
let mut alf = AdaptiveLaguerreFilter::new(3).unwrap();
assert_eq!(alf.update(10.0), None);
assert_eq!(alf.update(11.0), None);
assert!(alf.update(12.0).is_some());
}
#[test]
fn constant_series_converges_to_constant() {
// Errors are all zero -> gamma 0 -> the 4-stage delay line fills with
// the constant and the filter settles on it.
let mut alf = AdaptiveLaguerreFilter::new(5).unwrap();
let out = alf.batch(&[42.0_f64; 40]);
let last = out.iter().rev().flatten().next().unwrap();
assert_relative_eq!(*last, 42.0, epsilon = 1e-9);
}
#[test]
fn converged_output_stays_within_price_range() {
// Once the Laguerre cascade has filled (it cold-starts from zero, so the
// first few post-warmup values ramp up toward price), the filter is a
// convex blend of recent prices and must stay inside the data range.
let prices: Vec<f64> = (0..120)
.map(|i| 50.0 + (f64::from(i) * 0.4).sin() * 10.0)
.collect();
let lo = prices.iter().copied().fold(f64::MAX, f64::min);
let hi = prices.iter().copied().fold(f64::MIN, f64::max);
let period = 8;
let mut alf = AdaptiveLaguerreFilter::new(period).unwrap();
for (i, v) in alf.batch(&prices).into_iter().enumerate() {
// Skip the cold-start transient (a few multiples of the window).
if i < 4 * period {
continue;
}
let v = v.expect("filter is ready well past warmup");
assert!(
v >= lo - 1e-6 && v <= hi + 1e-6,
"filter out of range at {i}"
);
}
}
#[test]
fn matches_naive_recurrence() {
let prices: Vec<f64> = (0..80)
.map(|i| 100.0 + (f64::from(i) * 0.5).sin() * 8.0 + f64::from(i) * 0.1)
.collect();
let mut alf = AdaptiveLaguerreFilter::new(10).unwrap();
let got = alf.batch(&prices);
let want = naive(&prices, 10);
for (i, (g, w)) in got.iter().zip(want.iter()).enumerate() {
assert_eq!(g.is_some(), w.is_some(), "readiness mismatch at {i}");
if let (Some(a), Some(b)) = (g, w) {
assert_relative_eq!(*a, *b, epsilon = 1e-9);
}
}
}
#[test]
fn reset_clears_state() {
let mut alf = AdaptiveLaguerreFilter::new(5).unwrap();
alf.batch(&(1..=40).map(f64::from).collect::<Vec<_>>());
assert!(alf.is_ready());
alf.reset();
assert!(!alf.is_ready());
assert_eq!(alf.update(1.0), None);
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (1..=50).map(|i| f64::from(i) * 0.7).collect();
let mut a = AdaptiveLaguerreFilter::new(7).unwrap();
let mut b = AdaptiveLaguerreFilter::new(7).unwrap();
assert_eq!(
a.batch(&prices),
prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
);
}
#[test]
fn ignores_non_finite_input() {
let mut alf = AdaptiveLaguerreFilter::new(3).unwrap();
alf.update(10.0);
alf.update(11.0);
let ready = alf.update(12.0).expect("ready after three inputs");
assert_eq!(alf.update(f64::NAN), Some(ready));
assert_eq!(alf.update(f64::INFINITY), Some(ready));
}
}
@@ -0,0 +1,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,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,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<_>>()
);
}
}
@@ -0,0 +1,218 @@
//! Derivative Oscillator (Constance Brown).
use crate::error::{Error, Result};
use crate::indicators::ema::Ema;
use crate::indicators::rsi::Rsi;
use crate::indicators::sma::Sma;
use crate::traits::Indicator;
/// Derivative Oscillator — Constance Brown's double-smoothed RSI histogram.
///
/// The RSI is smoothed twice with EMAs, then a simple moving average of that
/// double-smoothed line is subtracted as a signal, leaving a zero-centered
/// histogram:
///
/// ```text
/// rsi = RSI(price, rsi_period)
/// s1 = EMA(rsi, smooth1)
/// s2 = EMA(s1, smooth2) // double-smoothed RSI
/// signal = SMA(s2, signal_period)
/// DerivativeOscillator = s2 - signal
/// ```
///
/// The double EMA smoothing strips the RSI's high-frequency noise, and
/// subtracting the SMA signal removes the residual level, so the result
/// oscillates around zero: positive (and rising) bars mark accelerating bullish
/// momentum, negative bars bearish. Brown's defaults are `rsi_period = 14`,
/// `smooth1 = 5`, `smooth2 = 3`, `signal_period = 9`.
///
/// The first value lands after `rsi_period + smooth1 + smooth2 + signal_period 2`
/// inputs, the point at which the whole RSI → EMA → EMA → SMA chain is seeded.
///
/// # Example
///
/// ```
/// use wickra_core::{DerivativeOscillator, Indicator};
///
/// let mut indicator = DerivativeOscillator::new(14, 5, 3, 9).unwrap();
/// let mut last = None;
/// for i in 0..120 {
/// last = indicator.update(100.0 + (f64::from(i) * 0.2).sin() * 5.0);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct DerivativeOscillator {
rsi: Rsi,
ema1: Ema,
ema2: Ema,
signal: Sma,
warmup: usize,
}
impl DerivativeOscillator {
/// Construct a Derivative Oscillator with the RSI, two EMA smoothing, and
/// SMA signal periods.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if any period is `0`.
pub fn new(
rsi_period: usize,
smooth1: usize,
smooth2: usize,
signal_period: usize,
) -> Result<Self> {
if rsi_period == 0 || smooth1 == 0 || smooth2 == 0 || signal_period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
rsi: Rsi::new(rsi_period)?,
ema1: Ema::new(smooth1)?,
ema2: Ema::new(smooth2)?,
signal: Sma::new(signal_period)?,
// RSI seeds at rsi_period + 1, then each stage adds (len - 1).
warmup: rsi_period + smooth1 + smooth2 + signal_period - 2,
})
}
/// Total warmup length (also returned by `warmup_period`).
pub const fn warmup(&self) -> usize {
self.warmup
}
}
impl Indicator for DerivativeOscillator {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
let rsi = self.rsi.update(input)?;
let s1 = self.ema1.update(rsi)?;
let s2 = self.ema2.update(s1)?;
let signal = self.signal.update(s2)?;
Some(s2 - signal)
}
fn reset(&mut self) {
self.rsi.reset();
self.ema1.reset();
self.ema2.reset();
self.signal.reset();
}
fn warmup_period(&self) -> usize {
self.warmup
}
fn is_ready(&self) -> bool {
self.signal.is_ready()
}
fn name(&self) -> &'static str {
"DerivativeOscillator"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_zero_periods() {
assert!(matches!(
DerivativeOscillator::new(0, 5, 3, 9),
Err(Error::PeriodZero)
));
assert!(matches!(
DerivativeOscillator::new(14, 0, 3, 9),
Err(Error::PeriodZero)
));
assert!(matches!(
DerivativeOscillator::new(14, 5, 0, 9),
Err(Error::PeriodZero)
));
assert!(matches!(
DerivativeOscillator::new(14, 5, 3, 0),
Err(Error::PeriodZero)
));
}
/// Cover the const accessor `warmup` and the Indicator-impl `warmup_period`
/// + `name`.
#[test]
fn accessors_and_metadata() {
let d = DerivativeOscillator::new(14, 5, 3, 9).unwrap();
// 14 + 5 + 3 + 9 - 2 = 29.
assert_eq!(d.warmup(), 29);
assert_eq!(d.warmup_period(), 29);
assert_eq!(d.name(), "DerivativeOscillator");
}
#[test]
fn first_emission_matches_warmup_period() {
let prices: Vec<f64> = (0..60)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 6.0)
.collect();
let mut d = DerivativeOscillator::new(14, 5, 3, 9).unwrap();
let out = d.batch(&prices);
let warmup = d.warmup_period();
for (i, v) in out.iter().enumerate().take(warmup - 1) {
assert!(v.is_none(), "index {i} must be None during warmup");
}
assert!(
out[warmup - 1].is_some(),
"first value must land at warmup_period - 1"
);
}
#[test]
fn matches_manual_chain() {
// Equals RSI -> EMA -> EMA, minus the SMA signal of that line.
let prices: Vec<f64> = (0..80)
.map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 8.0)
.collect();
let mut d = DerivativeOscillator::new(14, 5, 3, 9).unwrap();
let mut rsi = Rsi::new(14).unwrap();
let mut e1 = Ema::new(5).unwrap();
let mut e2 = Ema::new(3).unwrap();
let mut sig = Sma::new(9).unwrap();
for (i, &p) in prices.iter().enumerate() {
let got = d.update(p);
let want = rsi
.update(p)
.and_then(|r| e1.update(r))
.and_then(|x| e2.update(x))
.and_then(|s2| sig.update(s2).map(|s| s2 - s));
assert_eq!(got.is_some(), want.is_some(), "readiness mismatch at {i}");
if let (Some(a), Some(b)) = (got, want) {
assert_relative_eq!(a, b, epsilon = 1e-9);
}
}
}
#[test]
fn reset_clears_state() {
let mut d = DerivativeOscillator::new(14, 5, 3, 9).unwrap();
d.batch(&(0..60).map(|i| 100.0 + f64::from(i)).collect::<Vec<_>>());
assert!(d.is_ready());
d.reset();
assert!(!d.is_ready());
assert_eq!(d.update(1.0), None);
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (0..80)
.map(|i| 50.0 + (f64::from(i) * 0.5).sin() * 10.0)
.collect();
let mut a = DerivativeOscillator::new(14, 5, 3, 9).unwrap();
let mut b = DerivativeOscillator::new(14, 5, 3, 9).unwrap();
assert_eq!(
a.batch(&prices),
prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,169 @@
//! Disparity Index.
use crate::error::Result;
use crate::indicators::sma::Sma;
use crate::traits::Indicator;
/// Disparity Index — the percentage gap between price and its moving average.
///
/// ```text
/// Disparity = 100 * (price - SMA(price, period)) / SMA(price, period)
/// ```
///
/// Originating in Japanese technical analysis (*kairi*), the disparity index
/// expresses how far price has stretched from its `period`-bar simple moving
/// average, as a percentage of that average. Positive readings mean price is
/// above the mean (potentially overbought / strong), negative readings mean it
/// is below (potentially oversold / weak); the magnitude measures how
/// over-extended the move is.
///
/// The first output lands once the inner SMA is ready (input `period`). If the
/// moving average is exactly zero the gap percentage is undefined and the index
/// returns `0.0`.
///
/// # Example
///
/// ```
/// use wickra_core::{DisparityIndex, Indicator};
///
/// let mut indicator = DisparityIndex::new(14).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// last = indicator.update(100.0 + f64::from(i));
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct DisparityIndex {
period: usize,
sma: Sma,
}
impl DisparityIndex {
/// Construct a disparity index over `period` inputs.
///
/// # Errors
///
/// Returns [`crate::Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
Ok(Self {
period,
sma: Sma::new(period)?,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for DisparityIndex {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
let mean = self.sma.update(input)?;
if mean == 0.0 {
return Some(0.0);
}
Some(100.0 * (input - mean) / mean)
}
fn reset(&mut self) {
self.sma.reset();
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.sma.is_ready()
}
fn name(&self) -> &'static str {
"DisparityIndex"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_zero_period() {
assert!(DisparityIndex::new(0).is_err());
}
/// Cover the const accessor `period` and the Indicator-impl `warmup_period`
/// + `name`.
#[test]
fn accessors_and_metadata() {
let di = DisparityIndex::new(14).unwrap();
assert_eq!(di.period(), 14);
assert_eq!(di.warmup_period(), 14);
assert_eq!(di.name(), "DisparityIndex");
}
#[test]
fn warmup_then_known_value() {
// SMA(3) of [2, 4, 6] = 4; price 6 -> 100 * (6 - 4) / 4 = 50.
let mut di = DisparityIndex::new(3).unwrap();
assert_eq!(di.update(2.0), None);
assert_eq!(di.update(4.0), None);
assert_relative_eq!(di.update(6.0).unwrap(), 50.0, epsilon = 1e-12);
}
#[test]
fn constant_series_is_zero() {
// Price equals its own mean -> zero disparity.
let mut di = DisparityIndex::new(5).unwrap();
for v in di.batch(&[42.0; 20]).into_iter().flatten() {
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
}
}
#[test]
fn negative_when_below_mean() {
// SMA(3) of [10, 8, 6] = 8; price 6 -> 100 * (6 - 8) / 8 = -25.
let mut di = DisparityIndex::new(3).unwrap();
let v = di.batch(&[10.0, 8.0, 6.0]);
assert_relative_eq!(v[2].unwrap(), -25.0, epsilon = 1e-12);
}
#[test]
fn zero_mean_returns_zero() {
// A window summing to zero (mean 0) makes the percentage undefined; the
// index returns 0.0 rather than a non-finite value.
let mut di = DisparityIndex::new(2).unwrap();
assert_eq!(di.update(-3.0), None);
// SMA(2) of [-3, 3] = 0 -> guarded to 0.0.
assert_relative_eq!(di.update(3.0).unwrap(), 0.0, epsilon = 1e-12);
}
#[test]
fn reset_clears_state() {
let mut di = DisparityIndex::new(5).unwrap();
di.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
assert!(di.is_ready());
di.reset();
assert!(!di.is_ready());
assert_eq!(di.update(1.0), None);
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (1..=30)
.map(|i| 50.0 + (f64::from(i) * 0.3).sin() * 10.0)
.collect();
let mut a = DisparityIndex::new(7).unwrap();
let mut b = DisparityIndex::new(7).unwrap();
assert_eq!(
a.batch(&prices),
prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,301 @@
//! Dynamic Momentum Index (Chande's volatility-adaptive RSI).
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::indicators::sma::Sma;
use crate::indicators::std_dev::StdDev;
use crate::traits::Indicator;
// Chande's definitional constants.
const STD_PERIOD: usize = 5; // volatility window
const STD_AVG_PERIOD: usize = 10; // smoothing of the volatility
const MIN_PERIOD: usize = 5; // fastest RSI lookback
const MAX_PERIOD: usize = 30; // slowest RSI lookback
/// Dynamic Momentum Index — Tushar Chande's RSI whose lookback shrinks in
/// volatile markets and lengthens in calm ones.
///
/// A standard RSI uses a fixed period; the DMI varies it from the recent
/// volatility so the oscillator stays responsive when the market is fast and
/// smooth when it is quiet:
///
/// ```text
/// vol = StdDev(close, 5)
/// vol_avg = SMA(vol, 10)
/// Vi = vol / vol_avg (volatility index)
/// td = clamp(round(period / Vi), 5, 30) (dynamic lookback)
/// avg_gain, avg_loss = simple means of the last `td` price changes
/// DMI = 100 * avg_gain / (avg_gain + avg_loss)
/// ```
///
/// High volatility (`Vi > 1`) shortens `td` toward `5` (faster); low volatility
/// lengthens it toward `30` (slower). The averages of gains and losses are
/// simple means over the last `td` changes (not Wilder-smoothed), recomputed as
/// the window length flexes. Output is bounded in `[0, 100]`; a flat market
/// returns the neutral `50`.
///
/// The first value lands after `MAX_PERIOD + 1 = 31` inputs, so the change
/// buffer always holds enough history for any dynamic lookback up to `30`.
///
/// # Example
///
/// ```
/// use wickra_core::{DynamicMomentumIndex, Indicator};
///
/// let mut dmi = DynamicMomentumIndex::new(14).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// last = dmi.update(100.0 + (f64::from(i) * 0.2).sin() * 5.0);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct DynamicMomentumIndex {
period: usize,
vol: StdDev,
vol_avg: Sma,
prev_close: Option<f64>,
/// The last `MAX_PERIOD` price changes, oldest at the front.
changes: VecDeque<f64>,
last_vol_avg: Option<f64>,
last_value: Option<f64>,
}
impl DynamicMomentumIndex {
/// Construct a DMI with the given base RSI period (Chande uses 14).
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
vol: StdDev::new(STD_PERIOD)?,
vol_avg: Sma::new(STD_AVG_PERIOD)?,
prev_close: None,
changes: VecDeque::with_capacity(MAX_PERIOD),
last_vol_avg: None,
last_value: None,
})
}
/// Configured base period.
pub const fn period(&self) -> usize {
self.period
}
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.last_value
}
/// Dynamic lookback for the current volatility, clamped to `[5, 30]`.
fn dynamic_period(&self, vol: f64, vol_avg: f64) -> usize {
if vol_avg <= 0.0 || vol <= 0.0 {
// No measurable volatility -> slowest (calmest) lookback.
return MAX_PERIOD;
}
let vi = vol / vol_avg;
let td = (self.period as f64 / vi).round();
// td is finite and positive here; clamp into the valid band.
(td as usize).clamp(MIN_PERIOD, MAX_PERIOD)
}
}
impl Indicator for DynamicMomentumIndex {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() {
return self.last_value;
}
// Track the smoothed volatility on every close.
if let Some(v) = self.vol.update(input) {
self.last_vol_avg = self.vol_avg.update(v);
}
// Record the price change.
if let Some(prev) = self.prev_close {
let change = input - prev;
if self.changes.len() == MAX_PERIOD {
self.changes.pop_front();
}
self.changes.push_back(change);
}
self.prev_close = Some(input);
let vol = self.vol.value()?;
let vol_avg = self.last_vol_avg?;
if self.changes.len() < MAX_PERIOD {
return None;
}
let td = self.dynamic_period(vol, vol_avg);
// Average gains and losses over the last `td` changes.
let mut sum_gain = 0.0;
let mut sum_loss = 0.0;
for &c in self.changes.iter().skip(MAX_PERIOD - td) {
if c > 0.0 {
sum_gain += c;
} else if c < 0.0 {
sum_loss -= c;
}
}
let denom = sum_gain + sum_loss;
let v = if denom == 0.0 {
50.0
} else {
// Ratio first, then scale, so `100 * g / g` cannot round above 100.
100.0 * (sum_gain / denom)
};
self.last_value = Some(v);
Some(v)
}
fn reset(&mut self) {
self.vol.reset();
self.vol_avg.reset();
self.prev_close = None;
self.changes.clear();
self.last_vol_avg = None;
self.last_value = None;
}
fn warmup_period(&self) -> usize {
// The change buffer (MAX_PERIOD changes => MAX_PERIOD + 1 inputs) is the
// binding constraint; the volatility chain (5 + 10 - 1 = 14) is shorter.
MAX_PERIOD + 1
}
fn is_ready(&self) -> bool {
self.last_value.is_some()
}
fn name(&self) -> &'static str {
"DynamicMomentumIndex"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_zero_period() {
assert!(matches!(
DynamicMomentumIndex::new(0),
Err(Error::PeriodZero)
));
}
/// Cover the const accessors `period` + `value` and the Indicator-impl
/// `warmup_period` + `name`.
#[test]
fn accessors_and_metadata() {
let dmi = DynamicMomentumIndex::new(14).unwrap();
assert_eq!(dmi.period(), 14);
assert_eq!(dmi.value(), None);
assert_eq!(dmi.warmup_period(), 31);
assert_eq!(dmi.name(), "DynamicMomentumIndex");
}
#[test]
fn first_emission_matches_warmup_period() {
let prices: Vec<f64> = (0..50)
.map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 6.0)
.collect();
let mut dmi = DynamicMomentumIndex::new(14).unwrap();
let out = dmi.batch(&prices);
for (i, v) in out.iter().enumerate().take(30) {
assert!(v.is_none(), "index {i} must be None during warmup");
}
assert!(out[30].is_some(), "first value at warmup_period - 1 = 30");
}
#[test]
fn pure_uptrend_is_one_hundred() {
// Every change positive -> avg_loss 0 -> 100, regardless of dynamic period.
let prices: Vec<f64> = (1..=60).map(f64::from).collect();
let mut dmi = DynamicMomentumIndex::new(14).unwrap();
let last = dmi.batch(&prices).into_iter().flatten().last().unwrap();
assert_relative_eq!(last, 100.0, epsilon = 1e-9);
}
#[test]
fn flat_market_is_neutral() {
// Constant prices: no volatility (dynamic period -> max) and no changes
// -> neutral 50.
let mut dmi = DynamicMomentumIndex::new(14).unwrap();
let last = dmi.batch(&[42.0; 50]).into_iter().flatten().last().unwrap();
assert_relative_eq!(last, 50.0, epsilon = 1e-12);
}
#[test]
fn output_stays_in_range() {
let prices: Vec<f64> = (0..120)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 10.0 + (f64::from(i) * 0.07).cos() * 4.0)
.collect();
let mut dmi = DynamicMomentumIndex::new(14).unwrap();
for v in dmi.batch(&prices).into_iter().flatten() {
assert!((0.0..=100.0).contains(&v), "DMI {v} left [0, 100]");
}
}
#[test]
fn high_volatility_shortens_period() {
let dmi = DynamicMomentumIndex::new(14).unwrap();
// Vi = 2 (vol twice its average) -> td = round(14 / 2) = 7.
assert_eq!(dmi.dynamic_period(2.0, 1.0), 7);
// Vi = 0.5 (calm) -> td = round(14 / 0.5) = 28.
assert_eq!(dmi.dynamic_period(0.5, 1.0), 28);
// Extreme calm clamps to MAX_PERIOD; extreme volatility clamps to MIN.
assert_eq!(dmi.dynamic_period(0.1, 1.0), MAX_PERIOD);
assert_eq!(dmi.dynamic_period(100.0, 1.0), MIN_PERIOD);
// Zero volatility -> slowest lookback.
assert_eq!(dmi.dynamic_period(0.0, 1.0), MAX_PERIOD);
assert_eq!(dmi.dynamic_period(1.0, 0.0), MAX_PERIOD);
}
#[test]
fn ignores_non_finite_input() {
let mut dmi = DynamicMomentumIndex::new(14).unwrap();
let ready = dmi
.batch(&(0..40).map(|i| 100.0 + f64::from(i)).collect::<Vec<_>>())
.into_iter()
.flatten()
.last()
.unwrap();
assert_eq!(dmi.update(f64::NAN), Some(ready));
assert_eq!(dmi.update(f64::INFINITY), Some(ready));
}
#[test]
fn reset_clears_state() {
let mut dmi = DynamicMomentumIndex::new(14).unwrap();
dmi.batch(&(0..40).map(|i| 100.0 + f64::from(i)).collect::<Vec<_>>());
assert!(dmi.is_ready());
dmi.reset();
assert!(!dmi.is_ready());
assert_eq!(dmi.update(1.0), None);
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (0..80)
.map(|i| 50.0 + (f64::from(i) * 0.5).sin() * 10.0)
.collect();
let mut a = DynamicMomentumIndex::new(14).unwrap();
let mut b = DynamicMomentumIndex::new(14).unwrap();
assert_eq!(
a.batch(&prices),
prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
);
}
}
+202
View File
@@ -0,0 +1,202 @@
//! Exponential Hull Moving Average (EHMA).
use crate::error::{Error, Result};
use crate::indicators::ema::Ema;
use crate::traits::Indicator;
/// Exponential Hull Moving Average: the Hull construction built from EMAs
/// instead of WMAs.
///
/// ```text
/// EHMA = EMA( 2 · EMA(price, period/2) EMA(price, period), round(sqrt(period)) )
/// ```
///
/// Alan Hull's [`Hma`](crate::Hma) uses weighted moving averages; replacing them
/// with exponential moving averages keeps the same lag-reduction trick — a fast
/// half-length average minus a full-length one, smoothed over `sqrt(period)` —
/// while inheriting the EMA's strictly recursive O(1) update and infinite
/// (exponentially decaying) memory. The result is marginally smoother than the
/// WMA-based Hull at the cost of a little more lag.
///
/// The half period is `(period / 2).max(1)` and the smoothing period is
/// `round(sqrt(period)).max(1)`, matching the rounding used by [`Hma`].
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, Ehma};
///
/// let mut indicator = Ehma::new(9).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// last = indicator.update(100.0 + f64::from(i));
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct Ehma {
period: usize,
half_ema: Ema,
full_ema: Ema,
smooth_ema: Ema,
}
impl Ehma {
/// # Errors
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
let half = (period / 2).max(1);
let smooth = (period as f64).sqrt().round() as usize;
let smooth = smooth.max(1);
Ok(Self {
period,
half_ema: Ema::new(half)?,
full_ema: Ema::new(period)?,
smooth_ema: Ema::new(smooth)?,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for Ehma {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
// Feed both component EMAs on every input so they warm up in parallel;
// gating the longer one behind the shorter would delay the first
// emission past `warmup_period()`.
let h = self.half_ema.update(input);
let f = self.full_ema.update(input);
let (h, f) = (h?, f?);
let diff = 2.0 * h - f;
self.smooth_ema.update(diff)
}
fn reset(&mut self) {
self.half_ema.reset();
self.full_ema.reset();
self.smooth_ema.reset();
}
fn warmup_period(&self) -> usize {
// full_ema seeds at `period`, then smooth_ema needs another
// (round(sqrt(period)) - 1) values to seed.
let sm = (self.period as f64).sqrt().round() as usize;
self.period + sm.max(1) - 1
}
fn is_ready(&self) -> bool {
self.smooth_ema.is_ready()
}
fn name(&self) -> &'static str {
"EHMA"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn constant_series_yields_constant_ehma() {
let mut ehma = Ehma::new(9).unwrap();
let out = ehma.batch(&[10.0_f64; 80]);
let last = out.iter().rev().flatten().next().unwrap();
assert_relative_eq!(*last, 10.0, epsilon = 1e-9);
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (1..=100).map(|i| f64::from(i) * 0.7).collect();
let mut a = Ehma::new(9).unwrap();
let mut b = Ehma::new(9).unwrap();
assert_eq!(
a.batch(&prices),
prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut ehma = Ehma::new(9).unwrap();
ehma.batch(&(1..=80).map(f64::from).collect::<Vec<_>>());
assert!(ehma.is_ready());
ehma.reset();
assert!(!ehma.is_ready());
}
#[test]
fn rejects_zero_period() {
assert!(Ehma::new(0).is_err());
}
/// Cover the const accessor `period` and the Indicator-impl `name`.
/// `warmup_period` is covered by `first_emission_matches_warmup_period`.
#[test]
fn accessors_and_metadata() {
let ehma = Ehma::new(9).unwrap();
assert_eq!(ehma.period(), 9);
assert_eq!(ehma.name(), "EHMA");
}
#[test]
fn first_emission_matches_warmup_period() {
let prices: Vec<f64> = (1..=40).map(f64::from).collect();
let mut ehma = Ehma::new(9).unwrap();
let out = ehma.batch(&prices);
let warmup = ehma.warmup_period();
// full EMA seeds at 9, smooth EMA round(sqrt(9))=3 needs 2 more -> 11.
assert_eq!(warmup, 11);
for (i, v) in out.iter().enumerate().take(warmup - 1) {
assert!(v.is_none(), "index {i} must be None during warmup");
}
assert!(
out[warmup - 1].is_some(),
"first EHMA value must land at warmup_period - 1"
);
}
#[test]
fn matches_independent_emas() {
// The two component EMAs run as independent siblings on the price
// stream; EHMA must equal feeding three standalone EMAs and combining.
let prices: Vec<f64> = (1..=50)
.map(|i| (f64::from(i) * 0.3).sin() * 10.0 + 50.0)
.collect();
let mut ehma = Ehma::new(9).unwrap();
let mut half = Ema::new(4).unwrap(); // (9 / 2).max(1)
let mut full = Ema::new(9).unwrap();
let mut smooth = Ema::new(3).unwrap(); // round(sqrt(9))
for (i, &p) in prices.iter().enumerate() {
let got = ehma.update(p);
let want = match (half.update(p), full.update(p)) {
(Some(h), Some(f)) => smooth.update(2.0 * h - f),
_ => None,
};
assert_eq!(got.is_some(), want.is_some(), "readiness mismatch at {i}");
if let (Some(a), Some(b)) = (got, want) {
assert_relative_eq!(a, b, epsilon = 1e-9);
}
}
}
#[test]
fn period_one_collapses_to_pass_through() {
// period 1: half=1, full=1, smooth=round(sqrt(1))=1; every EMA seeds on
// the first input, so EHMA(1) passes the price straight through.
let mut ehma = Ehma::new(1).unwrap();
assert_relative_eq!(ehma.update(5.0).unwrap(), 5.0, epsilon = 1e-12);
assert_relative_eq!(ehma.update(8.0).unwrap(), 8.0, epsilon = 1e-12);
}
}
@@ -0,0 +1,192 @@
//! Elder Ray — Bull Power and Bear Power.
use crate::error::Result;
use crate::indicators::ema::Ema;
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// One Elder Ray reading: the bull and bear power for a bar.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ElderRayOutput {
/// `high EMA(close)`: how far buyers pushed price above the trend mean.
pub bull_power: f64,
/// `low EMA(close)`: how far sellers pushed price below the trend mean
/// (negative in a normal market).
pub bear_power: f64,
}
/// Elder Ray — Alexander Elder's Bull Power / Bear Power oscillator.
///
/// An EMA of the close marks the market's consensus of value; the bar's high and
/// low relative to it measure how far the bulls and bears could push price away
/// from that consensus:
///
/// ```text
/// ema = EMA(close, period)
/// BullPower = high - ema
/// BearPower = low - ema
/// ```
///
/// Bull Power is normally positive (the high prints above the mean) and Bear
/// Power normally negative (the low prints below it). Their behaviour relative
/// to zero and to the EMA's slope drives Elder's signals: e.g. in an uptrend
/// (rising EMA), a bounce in a negative-but-rising Bear Power is a buy setup.
///
/// The first reading lands once the inner EMA is seeded, at bar `period`.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, ElderRay, Indicator};
///
/// let mut er = ElderRay::new(13).unwrap();
/// let mut last = None;
/// for i in 0..40 {
/// let base = 100.0 + f64::from(i);
/// let c = Candle::new(base, base + 2.0, base - 2.0, base + 0.5, 1.0, i64::from(i)).unwrap();
/// last = er.update(c);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct ElderRay {
period: usize,
ema: Ema,
}
impl ElderRay {
/// Construct an Elder Ray with the given EMA period.
///
/// # Errors
///
/// Returns [`crate::Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
Ok(Self {
period,
ema: Ema::new(period)?,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for ElderRay {
type Input = Candle;
type Output = ElderRayOutput;
fn update(&mut self, candle: Candle) -> Option<ElderRayOutput> {
let ema = self.ema.update(candle.close)?;
Some(ElderRayOutput {
bull_power: candle.high - ema,
bear_power: candle.low - ema,
})
}
fn reset(&mut self) {
self.ema.reset();
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.ema.is_ready()
}
fn name(&self) -> &'static str {
"ElderRay"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn candle(high: f64, low: f64, close: f64) -> Candle {
Candle::new(close, high, low, close, 1.0, 0).unwrap()
}
#[test]
fn rejects_zero_period() {
assert!(ElderRay::new(0).is_err());
}
/// Cover the const accessor `period` and the Indicator-impl `warmup_period`
/// + `name`.
#[test]
fn accessors_and_metadata() {
let er = ElderRay::new(13).unwrap();
assert_eq!(er.period(), 13);
assert_eq!(er.warmup_period(), 13);
assert_eq!(er.name(), "ElderRay");
}
#[test]
fn warmup_then_known_value() {
// EMA(3) seeds at bar 3 with SMA([10,12,14]) = 12 (closes).
// bar 3: high 16, low 13 -> bull = 16 - 12 = 4, bear = 13 - 12 = 1.
let mut er = ElderRay::new(3).unwrap();
assert_eq!(er.update(candle(11.0, 9.0, 10.0)), None);
assert_eq!(er.update(candle(13.0, 11.0, 12.0)), None);
let v = er.update(candle(16.0, 13.0, 14.0)).unwrap();
assert_relative_eq!(v.bull_power, 4.0, epsilon = 1e-12);
assert_relative_eq!(v.bear_power, 1.0, epsilon = 1e-12);
}
#[test]
fn matches_manual_ema() {
let bars: Vec<Candle> = (0..40)
.map(|i| {
let base = 100.0 + (f64::from(i) * 0.3).sin() * 5.0;
candle(base + 2.0, base - 2.0, base)
})
.collect();
let mut er = ElderRay::new(13).unwrap();
let mut ema = Ema::new(13).unwrap();
for (i, c) in bars.iter().enumerate() {
let got = er.update(*c);
let want = ema.update(c.close).map(|e| (c.high - e, c.low - e));
assert_eq!(got.is_some(), want.is_some(), "readiness mismatch at {i}");
if let (Some(g), Some((b, be))) = (got, want) {
assert_relative_eq!(g.bull_power, b, epsilon = 1e-9);
assert_relative_eq!(g.bear_power, be, epsilon = 1e-9);
}
}
}
#[test]
fn reset_clears_state() {
let mut er = ElderRay::new(5).unwrap();
er.batch(
&(0..20)
.map(|i| candle(f64::from(i) + 1.0, f64::from(i) - 1.0, f64::from(i)))
.collect::<Vec<_>>(),
);
assert!(er.is_ready());
er.reset();
assert!(!er.is_ready());
assert_eq!(er.update(candle(2.0, 0.0, 1.0)), None);
}
#[test]
fn batch_equals_streaming() {
let bars: Vec<Candle> = (0..30)
.map(|i| {
let base = 50.0 + f64::from(i);
candle(base + 1.5, base - 1.5, base)
})
.collect();
let mut a = ElderRay::new(7).unwrap();
let mut b = ElderRay::new(7).unwrap();
assert_eq!(
a.batch(&bars),
bars.iter().map(|c| b.update(*c)).collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,208 @@
//! Expectancy — expected return per unit of average loss (R-multiple).
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Expectancy — the expected return per trade expressed in units of average
/// loss (the "R-multiple" expectancy) over the last `period` returns.
///
/// ```text
/// mean = average of the `period` returns
/// avgLoss = average of the absolute losing returns (rᵢ < 0)
/// E = mean / avgLoss (0 when there are no losing returns)
/// ```
///
/// Feed a stream of per-trade or per-bar returns. Expectancy answers "how much
/// do I make per trade for every unit I typically risk": `E = 0.3` means the
/// system nets `0.3R` per trade on average, where `R` is the average loss.
/// Dividing the mean return by the average loss makes the figure comparable
/// across systems with different bet sizes — unlike the raw mean return (which
/// is just an SMA of the series). A positive `E` is a profitable edge, a
/// negative `E` a losing one.
///
/// When the window contains **no** losing returns there is no risk reference to
/// normalise against, so the indicator returns `0` (undefined R-multiple)
/// rather than dividing by zero.
///
/// Each `update` is O(1): the running sum and the loss aggregates are
/// maintained incrementally.
///
/// # Example
///
/// ```
/// use wickra_core::{BatchExt, Indicator, Expectancy};
///
/// let mut indicator = Expectancy::new(4).unwrap();
/// // returns +2, -1, +2, -1: mean 0.5, avg loss 1 -> E = 0.5.
/// let out = indicator.batch(&[2.0, -1.0, 2.0, -1.0]);
/// assert_eq!(out[3], Some(0.5));
/// ```
#[derive(Debug, Clone)]
pub struct Expectancy {
period: usize,
window: VecDeque<f64>,
sum: f64,
sum_abs_loss: f64,
loss_count: usize,
}
impl Expectancy {
/// Construct a new Expectancy over the given window.
///
/// # Errors
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
window: VecDeque::with_capacity(period),
sum: 0.0,
sum_abs_loss: 0.0,
loss_count: 0,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for Expectancy {
type Input = f64;
type Output = f64;
fn update(&mut self, ret: f64) -> Option<f64> {
if self.window.len() == self.period {
let old = self.window.pop_front().expect("window is non-empty");
self.sum -= old;
if old < 0.0 {
self.sum_abs_loss -= -old;
self.loss_count -= 1;
}
}
self.window.push_back(ret);
self.sum += ret;
if ret < 0.0 {
self.sum_abs_loss += -ret;
self.loss_count += 1;
}
if self.window.len() < self.period {
return None;
}
if self.loss_count == 0 {
// No losing returns: no risk reference to express the edge in.
return Some(0.0);
}
let mean = self.sum / self.period as f64;
let avg_loss = self.sum_abs_loss / self.loss_count as f64;
Some(mean / avg_loss)
}
fn reset(&mut self) {
self.window.clear();
self.sum = 0.0;
self.sum_abs_loss = 0.0;
self.loss_count = 0;
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.window.len() == self.period
}
fn name(&self) -> &'static str {
"Expectancy"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_zero_period() {
assert!(matches!(Expectancy::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_and_metadata() {
let e = Expectancy::new(20).unwrap();
assert_eq!(e.period(), 20);
assert_eq!(e.warmup_period(), 20);
assert_eq!(e.name(), "Expectancy");
assert!(!e.is_ready());
}
#[test]
fn positive_edge() {
// +2, -1, +2, -1: mean 0.5, avgLoss 1 -> 0.5.
let mut e = Expectancy::new(4).unwrap();
let out = e.batch(&[2.0, -1.0, 2.0, -1.0]);
assert_relative_eq!(out[3].unwrap(), 0.5, epsilon = 1e-12);
}
#[test]
fn negative_edge() {
// +1, -2, +1, -2: mean -0.5, avgLoss 2 -> -0.25.
let mut e = Expectancy::new(4).unwrap();
let out = e.batch(&[1.0, -2.0, 1.0, -2.0]);
assert_relative_eq!(out[3].unwrap(), -0.25, epsilon = 1e-12);
}
#[test]
fn no_losses_returns_zero() {
// All winning returns: no risk reference -> 0.
let mut e = Expectancy::new(5).unwrap();
for v in e.batch(&[1.0, 2.0, 3.0, 1.0, 2.0]).into_iter().flatten() {
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
}
}
#[test]
fn flat_returns_are_not_losses() {
// Zeros are not losses: mean (2+0+2+0)/4 = 1, but no losing returns
// -> 0 (undefined R-multiple).
let mut e = Expectancy::new(4).unwrap();
let out = e.batch(&[2.0, 0.0, 2.0, 0.0]);
assert_relative_eq!(out[3].unwrap(), 0.0, epsilon = 1e-12);
}
#[test]
fn rolling_window_evicts_old_losses() {
// period 4. Window [+2,-1,+2,-1] -> 0.5; then push +3,+3,+3,+3 to evict
// all losses -> no losses -> 0.
let mut e = Expectancy::new(4).unwrap();
let out = e.batch(&[2.0, -1.0, 2.0, -1.0, 3.0, 3.0, 3.0, 3.0]);
assert_relative_eq!(out[3].unwrap(), 0.5, epsilon = 1e-12);
assert_relative_eq!(out[7].unwrap(), 0.0, epsilon = 1e-12);
}
#[test]
fn reset_clears_state() {
let mut e = Expectancy::new(5).unwrap();
e.batch(&[1.0, -1.0, 2.0, -2.0, 1.0]);
assert!(e.is_ready());
e.reset();
assert!(!e.is_ready());
assert_eq!(e.update(1.0), None);
}
#[test]
fn batch_equals_streaming() {
let rets: Vec<f64> = (0..60).map(|i| (f64::from(i) * 0.5).sin() * 2.0).collect();
let batch = Expectancy::new(14).unwrap().batch(&rets);
let mut b = Expectancy::new(14).unwrap();
let streamed: Vec<_> = rets.iter().map(|p| b.update(*p)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,186 @@
//! Fisher-transformed RSI.
use crate::error::Result;
use crate::indicators::rsi::Rsi;
use crate::traits::Indicator;
/// Fisher RSI — the Fisher transform applied to a normalised [`Rsi`](crate::Rsi).
///
/// The RSI is bounded in `[0, 100]` and its distribution piles up near the
/// middle, which blurs turning points. The Fisher transform reshapes a bounded
/// input toward a Gaussian, sharpening the extremes into clear, near-symmetric
/// peaks:
///
/// ```text
/// rsi = RSI(price, period) in [0, 100]
/// x = clamp((rsi - 50) / 50, ±0.999) normalise to (-1, 1)
/// Fisher = 0.5 * ln((1 + x) / (1 - x))
/// ```
///
/// The clamp keeps the logarithm finite when the RSI pins at `0` or `100`. The
/// output is unbounded but in practice oscillates in roughly `[-3, 3]`, with
/// sharp excursions marking momentum extremes. The first value lands with the
/// inner RSI, after `period + 1` inputs.
///
/// # Example
///
/// ```
/// use wickra_core::{FisherRsi, Indicator};
///
/// let mut indicator = FisherRsi::new(9).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// last = indicator.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct FisherRsi {
period: usize,
rsi: Rsi,
}
impl FisherRsi {
/// Construct a Fisher RSI with the given RSI period.
///
/// # Errors
///
/// Returns [`crate::Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
Ok(Self {
period,
rsi: Rsi::new(period)?,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for FisherRsi {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
let rsi = self.rsi.update(input)?;
let x = ((rsi - 50.0) / 50.0).clamp(-0.999, 0.999);
Some(0.5 * ((1.0 + x) / (1.0 - x)).ln())
}
fn reset(&mut self) {
self.rsi.reset();
}
fn warmup_period(&self) -> usize {
self.rsi.warmup_period()
}
fn is_ready(&self) -> bool {
self.rsi.is_ready()
}
fn name(&self) -> &'static str {
"FisherRSI"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_zero_period() {
assert!(FisherRsi::new(0).is_err());
}
/// Cover the const accessor `period` and the Indicator-impl `warmup_period`
/// + `name`.
#[test]
fn accessors_and_metadata() {
let f = FisherRsi::new(9).unwrap();
assert_eq!(f.period(), 9);
// RSI warmup is period + 1.
assert_eq!(f.warmup_period(), 10);
assert_eq!(f.name(), "FisherRSI");
}
#[test]
fn warmup_matches_rsi() {
let mut f = FisherRsi::new(3).unwrap();
// RSI(3) needs 4 inputs; the first three return None.
assert_eq!(f.update(1.0), None);
assert_eq!(f.update(2.0), None);
assert_eq!(f.update(3.0), None);
assert!(f.update(4.0).is_some());
}
#[test]
fn matches_fisher_of_rsi() {
// Fisher RSI must equal the Fisher transform of the standalone RSI.
let prices: Vec<f64> = (0..60)
.map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 8.0)
.collect();
let mut fr = FisherRsi::new(9).unwrap();
let mut rsi = Rsi::new(9).unwrap();
for (i, &p) in prices.iter().enumerate() {
let got = fr.update(p);
let want = rsi.update(p).map(|r| {
let x = ((r - 50.0) / 50.0).clamp(-0.999, 0.999);
0.5 * ((1.0 + x) / (1.0 - x)).ln()
});
assert_eq!(got.is_some(), want.is_some(), "readiness mismatch at {i}");
if let (Some(a), Some(b)) = (got, want) {
assert_relative_eq!(a, b, epsilon = 1e-12);
}
}
}
#[test]
fn strong_uptrend_is_positive() {
// A pure uptrend pins RSI near 100 -> x near +1 -> large positive Fisher.
let prices: Vec<f64> = (1..=40).map(f64::from).collect();
let mut f = FisherRsi::new(9).unwrap();
let last = f.batch(&prices).into_iter().flatten().last().unwrap();
assert!(
last > 1.0,
"strong uptrend should give a large positive value, got {last}"
);
}
#[test]
fn clamp_keeps_output_finite_at_extremes() {
// Monotonic rise pins RSI at 100; the clamp must keep Fisher finite.
let prices: Vec<f64> = (1..=30).map(f64::from).collect();
let mut f = FisherRsi::new(5).unwrap();
for v in f.batch(&prices).into_iter().flatten() {
assert!(v.is_finite(), "Fisher RSI must stay finite, got {v}");
}
}
#[test]
fn reset_clears_state() {
let mut f = FisherRsi::new(5).unwrap();
f.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
assert!(f.is_ready());
f.reset();
assert!(!f.is_ready());
assert_eq!(f.update(1.0), None);
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (1..=40)
.map(|i| 50.0 + (f64::from(i) * 0.5).sin() * 10.0)
.collect();
let mut a = FisherRsi::new(9).unwrap();
let mut b = FisherRsi::new(9).unwrap();
assert_eq!(
a.batch(&prices),
prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,222 @@
//! Generalized DEMA (GD) — Tim Tillson's volume-factor double EMA.
use crate::error::{Error, Result};
use crate::indicators::ema::Ema;
use crate::traits::Indicator;
/// Generalized DEMA — the building block of Tillson's [`T3`](crate::T3),
/// exposed on its own.
///
/// ```text
/// GD = (1 + v) · EMA(price) v · EMA(EMA(price))
/// ```
///
/// where both EMAs share the same `period` and `v ∈ [0, 1]` is the *volume
/// factor*. `v` controls how much of the second-order lag correction is
/// applied:
///
/// - `v = 0` collapses GD to a plain [`Ema`](crate::Ema) (no correction).
/// - `v = 1` recovers the standard [`Dema`](crate::Dema) `2·EMA EMA(EMA)`.
/// - intermediate values (Tillson uses `0.7`) trade a little lag reduction for
/// less overshoot than DEMA.
///
/// Because the coefficients `(1 + v)` and `v` always sum to `1`, a constant
/// series maps to itself. The first output lands after `2·period 1` inputs —
/// EMA1 seeds at `period`, then EMA2 needs another `period 1` of EMA1's
/// outputs to seed, exactly like DEMA.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, GeneralizedDema};
///
/// let mut indicator = GeneralizedDema::new(5, 0.7).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// last = indicator.update(100.0 + f64::from(i));
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct GeneralizedDema {
ema1: Ema,
ema2: Ema,
period: usize,
v: f64,
}
impl GeneralizedDema {
/// Construct a generalized DEMA with the given `period` and volume factor
/// `v`.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `period == 0`, or
/// [`Error::InvalidPeriod`] if `v` is non-finite or outside `[0.0, 1.0]`.
pub fn new(period: usize, v: f64) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
if !v.is_finite() || !(0.0..=1.0).contains(&v) {
return Err(Error::InvalidPeriod {
message: "GD volume factor must be a finite value in [0.0, 1.0]",
});
}
Ok(Self {
ema1: Ema::new(period)?,
ema2: Ema::new(period)?,
period,
v,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
/// Configured volume factor `v`.
pub const fn volume_factor(&self) -> f64 {
self.v
}
}
impl Indicator for GeneralizedDema {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
let e1 = self.ema1.update(input)?;
let e2 = self.ema2.update(e1)?;
Some((1.0 + self.v) * e1 - self.v * e2)
}
fn reset(&mut self) {
self.ema1.reset();
self.ema2.reset();
}
fn warmup_period(&self) -> usize {
// EMA1 seeds at period, then EMA2 needs another (period - 1) values.
2 * self.period - 1
}
fn is_ready(&self) -> bool {
self.ema2.is_ready()
}
fn name(&self) -> &'static str {
"GD"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::indicators::Dema;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_zero_period() {
assert!(matches!(
GeneralizedDema::new(0, 0.7),
Err(Error::PeriodZero)
));
}
#[test]
fn rejects_invalid_volume_factor() {
assert!(matches!(
GeneralizedDema::new(5, -0.1),
Err(Error::InvalidPeriod { .. })
));
assert!(matches!(
GeneralizedDema::new(5, 1.5),
Err(Error::InvalidPeriod { .. })
));
assert!(matches!(
GeneralizedDema::new(5, f64::NAN),
Err(Error::InvalidPeriod { .. })
));
assert!(GeneralizedDema::new(5, 0.0).is_ok());
assert!(GeneralizedDema::new(5, 1.0).is_ok());
}
/// Cover the const accessors `period` + `volume_factor` and the
/// Indicator-impl `warmup_period` + `name`.
#[test]
fn accessors_and_metadata() {
let gd = GeneralizedDema::new(5, 0.7).unwrap();
assert_eq!(gd.period(), 5);
assert_relative_eq!(gd.volume_factor(), 0.7, epsilon = 1e-12);
// EMA1 seeds at 5, EMA2 needs another 4 -> 2*period - 1 = 9.
assert_eq!(gd.warmup_period(), 9);
assert_eq!(gd.name(), "GD");
}
#[test]
fn constant_series_yields_constant() {
let mut gd = GeneralizedDema::new(5, 0.7).unwrap();
let out = gd.batch(&[100.0_f64; 60]);
let last = out.iter().rev().flatten().next().unwrap();
assert_relative_eq!(*last, 100.0, epsilon = 1e-9);
}
#[test]
fn v_one_equals_dema() {
// GD with v = 1 is exactly the standard DEMA.
let prices: Vec<f64> = (1..=80)
.map(|i| (f64::from(i) * 0.3).sin() * 10.0 + 50.0)
.collect();
let mut gd = GeneralizedDema::new(7, 1.0).unwrap();
let mut dema = Dema::new(7).unwrap();
let gd_out = gd.batch(&prices);
let dema_out = dema.batch(&prices);
for (g, d) in gd_out.iter().zip(dema_out.iter()) {
assert_eq!(g.is_some(), d.is_some());
if let (Some(a), Some(b)) = (g, d) {
assert_relative_eq!(*a, *b, epsilon = 1e-9);
}
}
}
#[test]
fn v_zero_equals_ema() {
// GD with v = 0 is a plain EMA (no second-order correction).
let prices: Vec<f64> = (1..=60).map(|i| f64::from(i) * 0.5).collect();
let mut gd = GeneralizedDema::new(6, 0.0).unwrap();
let mut ema = Ema::new(6).unwrap();
let gd_out = gd.batch(&prices);
for (i, (g, p)) in gd_out.iter().zip(prices.iter()).enumerate() {
// GD(v=0) feeds EMA1 into EMA2 but outputs EMA1 alone (coefficient
// 1 on e1, 0 on e2); it is only ready once EMA2 is, so compare
// against a standalone EMA chained the same way.
let want = ema.update(*p).filter(|_| i + 1 >= gd.warmup_period());
if let (Some(a), Some(b)) = (g, want) {
assert_relative_eq!(*a, b, epsilon = 1e-9);
}
}
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (1..=80).map(|i| f64::from(i) * 0.5).collect();
let mut a = GeneralizedDema::new(7, 0.7).unwrap();
let mut b = GeneralizedDema::new(7, 0.7).unwrap();
assert_eq!(
a.batch(&prices),
prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut gd = GeneralizedDema::new(5, 0.7).unwrap();
gd.batch(&(1..=50).map(f64::from).collect::<Vec<_>>());
assert!(gd.is_ready());
gd.reset();
assert!(!gd.is_ready());
assert_eq!(gd.update(1.0), None);
}
}
@@ -0,0 +1,275 @@
//! Geometric Moving Average (GMA).
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Geometric Moving Average — the rolling geometric mean of the last `period`
/// inputs.
///
/// ```text
/// GMA = (Π value_i)^(1/period) = exp( (1/period) · Σ ln(value_i) )
/// ```
///
/// The geometric mean is the natural average for *multiplicative* quantities
/// such as prices and growth factors: averaging in log-space weights relative
/// (percentage) moves symmetrically, so a `+10%` followed by a `10%` move
/// pulls the average below the start, exactly as compounded returns do. It is
/// always less than or equal to the arithmetic mean of the same window.
///
/// Maintained incrementally in O(1): the running sum of natural logs is updated
/// by adding the newcomer's log and subtracting the departing value's log as
/// the window slides.
///
/// The geometric mean is only defined for **strictly positive** inputs. A
/// non-finite or non-positive input is ignored (it leaves the window unchanged
/// and returns the current value), mirroring the non-finite handling of the
/// other moving averages.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, GeometricMa};
///
/// let mut indicator = GeometricMa::new(5).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// last = indicator.update(100.0 + f64::from(i));
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct GeometricMa {
period: usize,
/// Natural logs of the values currently in the window (oldest at front).
logs: VecDeque<f64>,
sum_logs: f64,
}
impl GeometricMa {
/// Construct a new geometric moving average over `period` inputs.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
logs: VecDeque::with_capacity(period),
sum_logs: 0.0,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
/// Current value if the window is full.
pub fn value(&self) -> Option<f64> {
if self.logs.len() == self.period {
Some((self.sum_logs / self.period as f64).exp())
} else {
None
}
}
}
impl Indicator for GeometricMa {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() || input <= 0.0 {
return self.value();
}
if self.logs.len() == self.period {
let oldest = self.logs.pop_front().expect("window non-empty");
self.sum_logs -= oldest;
}
let ln = input.ln();
self.logs.push_back(ln);
self.sum_logs += ln;
self.value()
}
fn reset(&mut self) {
self.logs.clear();
self.sum_logs = 0.0;
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.logs.len() == self.period
}
fn name(&self) -> &'static str {
"GMA"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
/// Reference implementation: explicit geometric mean over a window.
fn gma_naive(prices: &[f64], period: usize) -> Vec<Option<f64>> {
prices
.iter()
.enumerate()
.map(|(i, _)| {
if i + 1 < period {
None
} else {
let window = &prices[i + 1 - period..=i];
let product: f64 = window.iter().product();
Some(product.powf(1.0 / period as f64))
}
})
.collect()
}
#[test]
fn new_rejects_zero_period() {
assert!(matches!(GeometricMa::new(0), Err(Error::PeriodZero)));
}
/// Cover the const accessor `period` and the Indicator-impl `warmup_period`
/// + `name`.
#[test]
fn accessors_and_metadata() {
let gma = GeometricMa::new(7).unwrap();
assert_eq!(gma.period(), 7);
assert_eq!(gma.warmup_period(), 7);
assert_eq!(gma.name(), "GMA");
}
#[test]
fn warmup_returns_none() {
let mut gma = GeometricMa::new(3).unwrap();
assert_eq!(gma.update(1.0), None);
assert_eq!(gma.update(4.0), None);
// GMA(3) of [1, 4, 2] = (1·4·2)^(1/3) = 8^(1/3) = 2.
assert_relative_eq!(gma.update(2.0).unwrap(), 2.0, epsilon = 1e-12);
}
#[test]
fn known_value_period_2() {
// GMA(2) of [4, 9] = sqrt(36) = 6.
let mut gma = GeometricMa::new(2).unwrap();
let v = gma.batch(&[4.0, 9.0]);
assert_relative_eq!(v[1].unwrap(), 6.0, epsilon = 1e-12);
}
#[test]
fn constant_series_returns_the_constant() {
let mut gma = GeometricMa::new(5).unwrap();
for v in gma.batch(&[42.0; 20]).into_iter().flatten() {
assert_relative_eq!(v, 42.0, epsilon = 1e-9);
}
}
#[test]
fn period_one_is_pass_through() {
let mut gma = GeometricMa::new(1).unwrap();
assert_relative_eq!(gma.update(5.5).unwrap(), 5.5, epsilon = 1e-12);
assert_relative_eq!(gma.update(7.5).unwrap(), 7.5, epsilon = 1e-12);
}
#[test]
fn below_or_equal_arithmetic_mean() {
// The geometric mean never exceeds the arithmetic mean of the same set.
let mut gma = GeometricMa::new(4).unwrap();
let prices = [10.0, 20.0, 5.0, 40.0];
let g = gma.batch(&prices)[3].unwrap();
let arithmetic = prices.iter().sum::<f64>() / 4.0;
assert!(
g < arithmetic,
"geometric {g} should be below arithmetic {arithmetic}"
);
}
#[test]
fn matches_naive_over_inputs() {
let prices: Vec<f64> = (1..=30).map(|i| f64::from(i) * 1.7 + 1.0).collect();
let mut gma = GeometricMa::new(7).unwrap();
let got = gma.batch(&prices);
let want = gma_naive(&prices, 7);
for (i, (g, w)) in got.iter().zip(want.iter()).enumerate() {
assert_eq!(g.is_some(), w.is_some(), "warmup mismatch at index {i}");
if let (Some(a), Some(b)) = (g, w) {
assert_relative_eq!(*a, *b, epsilon = 1e-9);
}
}
}
#[test]
fn reset_clears_state() {
let mut gma = GeometricMa::new(4).unwrap();
gma.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
assert!(gma.is_ready());
gma.reset();
assert!(!gma.is_ready());
assert_eq!(gma.update(10.0), None);
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (1..=20).map(|i| f64::from(i) * 0.5 + 1.0).collect();
let mut a = GeometricMa::new(5).unwrap();
let mut b = GeometricMa::new(5).unwrap();
assert_eq!(
a.batch(&prices),
prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
);
}
#[test]
fn ignores_non_finite_and_non_positive_input() {
let mut gma = GeometricMa::new(3).unwrap();
gma.update(1.0);
gma.update(4.0);
let ready = gma.update(2.0).expect("GMA(3) ready after three inputs");
// Non-finite and non-positive inputs are skipped (geometric mean needs
// strictly positive values) and the window is left unchanged.
assert_eq!(gma.update(f64::NAN), Some(ready));
assert_eq!(gma.update(0.0), Some(ready));
assert_eq!(gma.update(-3.0), Some(ready));
// The window still holds 1, 4, 2 -> next real input slides it to 4, 2, 16.
let want = (4.0_f64 * 2.0 * 16.0).powf(1.0 / 3.0);
assert_relative_eq!(gma.update(16.0).unwrap(), want, epsilon = 1e-9);
}
proptest::proptest! {
#![proptest_config(proptest::test_runner::Config::with_cases(48))]
#[test]
fn proptest_matches_naive(
period in 1usize..15,
prices in proptest::collection::vec(0.01_f64..1000.0, 0..120),
) {
let mut gma = GeometricMa::new(period).unwrap();
let got = gma.batch(&prices);
let want = gma_naive(&prices, period);
proptest::prop_assert_eq!(got.len(), want.len());
for (g, w) in got.iter().zip(want.iter()) {
match (g, w) {
(None, None) => {}
(Some(a), Some(b)) => proptest::prop_assert!(
(a - b).abs() <= 1e-6 * b.abs().max(1.0),
"got={a} want={b}"
),
_ => proptest::prop_assert!(false, "warmup mismatch"),
}
}
}
}
}
@@ -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,315 @@
//! Holt's linear (double exponential) smoothing.
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Holt's linear method — double exponential smoothing with a level and a
/// trend component.
///
/// A single [`Ema`](crate::Ema) tracks only a *level* and therefore lags any
/// sustained trend. Holt's method adds a second smoothed state, the trend, and
/// reports the one-step-ahead forecast `level + trend`, which removes that lag
/// on trending data while still smoothing noise.
///
/// ```text
/// level_t = α · price_t + (1 α) · (level_{t-1} + trend_{t-1})
/// trend_t = β · (level_t level_{t-1}) + (1 β) · trend_{t-1}
/// output = level_t + trend_t (one-step-ahead forecast)
/// ```
///
/// `α ∈ (0, 1]` is the level smoothing constant and `β ∈ (0, 1]` the trend
/// smoothing constant. The state is seeded from the first two inputs
/// (`level = price_1`, `trend = price_1 price_0`), so the first output lands
/// on the **second** input.
///
/// On a perfectly linear series the forecast is exact from the second bar
/// onward (for any `α`, `β`): if the level equals the current value and the
/// trend equals the slope, both invariants are preserved and `level + trend`
/// equals the next value.
///
/// # Example
///
/// ```
/// use wickra_core::{HoltWinters, Indicator};
///
/// let mut indicator = HoltWinters::new(0.2, 0.1).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// last = indicator.update(100.0 + f64::from(i));
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct HoltWinters {
alpha: f64,
beta: f64,
/// `(level, trend)` once seeded.
state: Option<(f64, f64)>,
/// First input, held until the second arrives to seed the trend.
prev_price: Option<f64>,
}
impl HoltWinters {
/// Construct Holt's linear smoother with level constant `alpha` and trend
/// constant `beta`.
///
/// # Errors
///
/// Returns [`Error::InvalidPeriod`] if either constant is non-finite or
/// outside `(0.0, 1.0]`.
pub fn new(alpha: f64, beta: f64) -> Result<Self> {
if !alpha.is_finite() || alpha <= 0.0 || alpha > 1.0 {
return Err(Error::InvalidPeriod {
message: "HoltWinters alpha must be in (0.0, 1.0]",
});
}
if !beta.is_finite() || beta <= 0.0 || beta > 1.0 {
return Err(Error::InvalidPeriod {
message: "HoltWinters beta must be in (0.0, 1.0]",
});
}
Ok(Self {
alpha,
beta,
state: None,
prev_price: None,
})
}
/// Level smoothing constant `alpha`.
pub const fn alpha(&self) -> f64 {
self.alpha
}
/// Trend smoothing constant `beta`.
pub const fn beta(&self) -> f64 {
self.beta
}
/// Current smoothed level, if seeded.
pub fn level(&self) -> Option<f64> {
self.state.map(|(level, _)| level)
}
/// Current smoothed trend, if seeded.
pub fn trend(&self) -> Option<f64> {
self.state.map(|(_, trend)| trend)
}
/// Current one-step-ahead forecast `level + trend`, if seeded.
pub fn value(&self) -> Option<f64> {
self.state.map(|(level, trend)| level + trend)
}
}
impl Indicator for HoltWinters {
type Input = f64;
type Output = f64;
fn update(&mut self, price: f64) -> Option<f64> {
if !price.is_finite() {
return self.value();
}
match self.state {
None => {
if let Some(prev) = self.prev_price {
// Second input: seed level and trend.
let level = price;
let trend = price - prev;
self.state = Some((level, trend));
Some(level + trend)
} else {
// First input: hold it to seed the trend next time.
self.prev_price = Some(price);
None
}
}
Some((level, trend)) => {
let level_new = self.alpha * price + (1.0 - self.alpha) * (level + trend);
let trend_new = self.beta * (level_new - level) + (1.0 - self.beta) * trend;
self.state = Some((level_new, trend_new));
Some(level_new + trend_new)
}
}
}
fn reset(&mut self) {
self.state = None;
self.prev_price = None;
}
fn warmup_period(&self) -> usize {
// Two inputs are needed to seed the level and the trend.
2
}
fn is_ready(&self) -> bool {
self.state.is_some()
}
fn name(&self) -> &'static str {
"HoltWinters"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
/// Independent reference for the steady-state recurrence.
fn naive(prices: &[f64], alpha: f64, beta: f64) -> Vec<Option<f64>> {
let mut state: Option<(f64, f64)> = None;
let mut prev: Option<f64> = None;
let mut out = Vec::with_capacity(prices.len());
for &price in prices {
let v = match state {
None => {
if let Some(p0) = prev {
let level = price;
let trend = price - p0;
state = Some((level, trend));
Some(level + trend)
} else {
prev = Some(price);
None
}
}
Some((level, trend)) => {
let ln = alpha * price + (1.0 - alpha) * (level + trend);
let tn = beta * (ln - level) + (1.0 - beta) * trend;
state = Some((ln, tn));
Some(ln + tn)
}
};
out.push(v);
}
out
}
#[test]
fn rejects_invalid_alpha() {
assert!(matches!(
HoltWinters::new(0.0, 0.1),
Err(Error::InvalidPeriod { .. })
));
assert!(matches!(
HoltWinters::new(1.5, 0.1),
Err(Error::InvalidPeriod { .. })
));
assert!(matches!(
HoltWinters::new(f64::NAN, 0.1),
Err(Error::InvalidPeriod { .. })
));
}
#[test]
fn rejects_invalid_beta() {
assert!(matches!(
HoltWinters::new(0.2, 0.0),
Err(Error::InvalidPeriod { .. })
));
assert!(matches!(
HoltWinters::new(0.2, 1.5),
Err(Error::InvalidPeriod { .. })
));
assert!(matches!(
HoltWinters::new(0.2, f64::INFINITY),
Err(Error::InvalidPeriod { .. })
));
}
/// Cover the const accessors `alpha` + `beta` and the Indicator-impl
/// `warmup_period` + `name`.
#[test]
fn accessors_and_metadata() {
let hw = HoltWinters::new(0.2, 0.1).unwrap();
assert_relative_eq!(hw.alpha(), 0.2, epsilon = 1e-12);
assert_relative_eq!(hw.beta(), 0.1, epsilon = 1e-12);
assert_eq!(hw.warmup_period(), 2);
assert_eq!(hw.name(), "HoltWinters");
}
#[test]
fn warmup_then_seed_on_second_input() {
let mut hw = HoltWinters::new(0.2, 0.1).unwrap();
assert_eq!(hw.update(10.0), None);
// Second input seeds level = 12, trend = 12 - 10 = 2 -> forecast 14.
assert_relative_eq!(hw.update(12.0).unwrap(), 14.0, epsilon = 1e-12);
assert_relative_eq!(hw.level().unwrap(), 12.0, epsilon = 1e-12);
assert_relative_eq!(hw.trend().unwrap(), 2.0, epsilon = 1e-12);
}
#[test]
fn linear_series_forecasts_exactly() {
// On a perfect ramp the one-step forecast equals the next value, for
// any alpha/beta, from the second bar onward.
let prices: Vec<f64> = (1..=20).map(f64::from).collect();
let mut hw = HoltWinters::new(0.3, 0.4).unwrap();
let out = hw.batch(&prices);
assert!(out[0].is_none());
for (i, v) in out.iter().enumerate().skip(1) {
// forecast at index i is the price at index i + 1 = (i + 2).
assert_relative_eq!(v.unwrap(), (i + 2) as f64, epsilon = 1e-9);
}
}
#[test]
fn constant_series_yields_constant() {
let mut hw = HoltWinters::new(0.2, 0.1).unwrap();
let out = hw.batch(&[42.0_f64; 30]);
for v in out.into_iter().skip(1).flatten() {
assert_relative_eq!(v, 42.0, epsilon = 1e-9);
}
}
#[test]
fn matches_naive_recurrence() {
let prices: Vec<f64> = (0..60)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 10.0 + f64::from(i) * 0.2)
.collect();
let mut hw = HoltWinters::new(0.25, 0.15).unwrap();
let got = hw.batch(&prices);
let want = naive(&prices, 0.25, 0.15);
for (g, w) in got.iter().zip(want.iter()) {
assert_eq!(g.is_some(), w.is_some());
if let (Some(a), Some(b)) = (g, w) {
assert_relative_eq!(a, b, epsilon = 1e-9);
}
}
}
#[test]
fn reset_clears_state() {
let mut hw = HoltWinters::new(0.2, 0.1).unwrap();
hw.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
assert!(hw.is_ready());
hw.reset();
assert!(!hw.is_ready());
assert_eq!(hw.update(1.0), None);
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (1..=30).map(|i| f64::from(i) * 0.5).collect();
let mut a = HoltWinters::new(0.3, 0.2).unwrap();
let mut b = HoltWinters::new(0.3, 0.2).unwrap();
assert_eq!(
a.batch(&prices),
prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
);
}
#[test]
fn ignores_non_finite_input() {
let mut hw = HoltWinters::new(0.2, 0.1).unwrap();
// Non-finite before any state returns None.
assert_eq!(hw.update(f64::NAN), None);
hw.update(10.0);
let ready = hw.update(12.0).expect("seeded on second finite input");
// Non-finite after seeding returns the current forecast unchanged.
assert_eq!(hw.update(f64::NAN), Some(ready));
assert_eq!(hw.update(f64::INFINITY), Some(ready));
}
}
@@ -0,0 +1,228 @@
//! Intraday Momentum Index (IMI).
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Intraday Momentum Index — Tushar Chande's RSI built from the open-to-close
/// move instead of the close-to-close move.
///
/// For each bar the body is an up-move when `close > open` and a down-move
/// otherwise; the IMI sums those bodies over `period` bars and forms the
/// RSI-style ratio:
///
/// ```text
/// gain = max(close - open, 0), loss = max(open - close, 0)
/// IMI = 100 * Σ gain / (Σ gain + Σ loss) over the last `period` bars
/// ```
///
/// Because it measures *intraday* (body) momentum rather than the gap-inclusive
/// close-to-close change, the IMI is a candle-pattern-flavoured overbought /
/// oversold gauge: persistent white bodies push it up, black bodies down. It is
/// bounded in `[0, 100]`; a window of doji-like bars (no net bodies) returns the
/// neutral `50`.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, IntradayMomentumIndex, Indicator};
///
/// let mut imi = IntradayMomentumIndex::new(14).unwrap();
/// let mut last = None;
/// for i in 0..40 {
/// let base = 100.0 + f64::from(i);
/// let c = Candle::new(base, base + 1.0, base - 1.0, base + 0.5, 1.0, i64::from(i)).unwrap();
/// last = imi.update(c);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct IntradayMomentumIndex {
period: usize,
/// Per-bar `(gain, loss)` bodies, oldest at the front.
window: VecDeque<(f64, f64)>,
sum_gain: f64,
sum_loss: f64,
}
impl IntradayMomentumIndex {
/// Construct an IMI over `period` bars.
///
/// # 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_gain: 0.0,
sum_loss: 0.0,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
/// Current value if the window is full.
pub fn value(&self) -> Option<f64> {
if self.window.len() != self.period {
return None;
}
let denom = self.sum_gain + self.sum_loss;
if denom == 0.0 {
Some(50.0)
} else {
Some(100.0 * self.sum_gain / denom)
}
}
}
impl Indicator for IntradayMomentumIndex {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
let body = candle.close - candle.open;
let gain = if body > 0.0 { body } else { 0.0 };
let loss = if body < 0.0 { -body } else { 0.0 };
if self.window.len() == self.period {
let (old_g, old_l) = self.window.pop_front().expect("window full");
self.sum_gain -= old_g;
self.sum_loss -= old_l;
}
self.window.push_back((gain, loss));
self.sum_gain += gain;
self.sum_loss += loss;
self.value()
}
fn reset(&mut self) {
self.window.clear();
self.sum_gain = 0.0;
self.sum_loss = 0.0;
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.window.len() == self.period
}
fn name(&self) -> &'static str {
"IMI"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn candle(open: f64, close: f64) -> Candle {
let hi = open.max(close) + 1.0;
let lo = open.min(close) - 1.0;
Candle::new(open, hi, lo, close, 1.0, 0).unwrap()
}
#[test]
fn rejects_zero_period() {
assert!(matches!(
IntradayMomentumIndex::new(0),
Err(Error::PeriodZero)
));
}
/// Cover the const accessor `period` and the Indicator-impl `warmup_period`
/// + `name`.
#[test]
fn accessors_and_metadata() {
let imi = IntradayMomentumIndex::new(14).unwrap();
assert_eq!(imi.period(), 14);
assert_eq!(imi.warmup_period(), 14);
assert_eq!(imi.name(), "IMI");
}
#[test]
fn all_up_bodies_is_one_hundred() {
let mut imi = IntradayMomentumIndex::new(3).unwrap();
let bars = [candle(10.0, 11.0), candle(11.0, 13.0), candle(13.0, 14.0)];
let out = imi.batch(&bars);
assert!(out[0].is_none());
assert!(out[1].is_none());
assert_relative_eq!(out[2].unwrap(), 100.0, epsilon = 1e-12);
}
#[test]
fn all_down_bodies_is_zero() {
let mut imi = IntradayMomentumIndex::new(3).unwrap();
let bars = [candle(14.0, 13.0), candle(13.0, 11.0), candle(11.0, 10.0)];
assert_relative_eq!(imi.batch(&bars)[2].unwrap(), 0.0, epsilon = 1e-12);
}
#[test]
fn known_value_mixed_bodies() {
// bodies: +1, -1, +2 -> sum_gain = 3, sum_loss = 1 -> 100*3/4 = 75.
let mut imi = IntradayMomentumIndex::new(3).unwrap();
let bars = [candle(10.0, 11.0), candle(11.0, 10.0), candle(10.0, 12.0)];
assert_relative_eq!(imi.batch(&bars)[2].unwrap(), 75.0, epsilon = 1e-12);
}
#[test]
fn doji_window_is_neutral() {
// close == open every bar -> no bodies -> neutral 50.
let mut imi = IntradayMomentumIndex::new(3).unwrap();
let bars = [candle(10.0, 10.0), candle(11.0, 11.0), candle(12.0, 12.0)];
assert_relative_eq!(imi.batch(&bars)[2].unwrap(), 50.0, epsilon = 1e-12);
}
#[test]
fn slides_window() {
// After [+1,-1,+2] (75) add +0 body window -> [-1,+2,0]: gain 2, loss 1 -> 66.67.
let mut imi = IntradayMomentumIndex::new(3).unwrap();
let bars = [
candle(10.0, 11.0),
candle(11.0, 10.0),
candle(10.0, 12.0),
candle(12.0, 12.0),
];
let out = imi.batch(&bars);
assert_relative_eq!(out[3].unwrap(), 100.0 * 2.0 / 3.0, epsilon = 1e-12);
}
#[test]
fn reset_clears_state() {
let mut imi = IntradayMomentumIndex::new(3).unwrap();
imi.batch(&[candle(10.0, 11.0), candle(11.0, 12.0), candle(12.0, 13.0)]);
assert!(imi.is_ready());
imi.reset();
assert!(!imi.is_ready());
assert_eq!(imi.update(candle(1.0, 2.0)), None);
}
#[test]
fn batch_equals_streaming() {
let bars: Vec<Candle> = (0..30)
.map(|i| {
let base = 100.0 + f64::from(i);
candle(base, base + (f64::from(i) * 0.5).sin())
})
.collect();
let mut a = IntradayMomentumIndex::new(7).unwrap();
let mut b = IntradayMomentumIndex::new(7).unwrap();
assert_eq!(
a.batch(&bars),
bars.iter().map(|c| b.update(*c)).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);
}
}
@@ -0,0 +1,205 @@
//! Median Moving Average.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Median Moving Average — the rolling median of the last `period` inputs.
///
/// For an odd `period` the output is the middle order statistic of the window;
/// for an even `period` it is the average of the two central values. Because it
/// is a rank statistic rather than a sum, the median MA is far more robust to
/// single outliers than the [`Sma`](crate::Sma): a lone spike shifts the rank
/// by at most one position instead of dragging the whole average.
///
/// Each `update` slides the window and computes the median by sorting a copy of
/// the `period` buffered values — O(`period` · log `period`) per step, with the
/// period fixed and bounded.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, MedianMa};
///
/// let mut indicator = MedianMa::new(5).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// last = indicator.update(100.0 + f64::from(i));
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct MedianMa {
period: usize,
window: VecDeque<f64>,
}
impl MedianMa {
/// Construct a new median moving average over `period` inputs.
///
/// # 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
}
/// Current value if the window is full.
pub fn value(&self) -> Option<f64> {
if self.window.len() != self.period {
return None;
}
let mut sorted: Vec<f64> = self.window.iter().copied().collect();
sorted.sort_by(|a, b| a.partial_cmp(b).expect("window holds only finite values"));
let mid = self.period / 2;
if self.period % 2 == 1 {
Some(sorted[mid])
} else {
Some(f64::midpoint(sorted[mid - 1], sorted[mid]))
}
}
}
impl Indicator for MedianMa {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() {
return self.value();
}
if self.window.len() == self.period {
self.window.pop_front();
}
self.window.push_back(input);
self.value()
}
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 {
"MedianMA"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn new_rejects_zero_period() {
assert!(matches!(MedianMa::new(0), Err(Error::PeriodZero)));
}
/// Cover the const accessor `period` and the Indicator-impl `warmup_period`
/// + `name`.
#[test]
fn accessors_and_metadata() {
let mma = MedianMa::new(7).unwrap();
assert_eq!(mma.period(), 7);
assert_eq!(mma.warmup_period(), 7);
assert_eq!(mma.name(), "MedianMA");
}
#[test]
fn warmup_returns_none_then_odd_median() {
let mut mma = MedianMa::new(3).unwrap();
assert_eq!(mma.update(5.0), None);
assert_eq!(mma.update(1.0), None);
// median of [5, 1, 3] = 3 (middle order statistic).
assert_relative_eq!(mma.update(3.0).unwrap(), 3.0, epsilon = 1e-12);
}
#[test]
fn even_period_averages_two_central_values() {
// median of [1, 2, 3, 4] = (2 + 3) / 2 = 2.5.
let mut mma = MedianMa::new(4).unwrap();
let v = mma.batch(&[1.0, 2.0, 3.0, 4.0]);
assert_relative_eq!(v[3].unwrap(), 2.5, epsilon = 1e-12);
}
#[test]
fn robust_to_single_outlier() {
// A lone spike does not move the median of an odd window the way it
// would move an SMA. median of [10, 11, 9999] = 11.
let mut mma = MedianMa::new(3).unwrap();
let v = mma.batch(&[10.0, 11.0, 9999.0]);
assert_relative_eq!(v[2].unwrap(), 11.0, epsilon = 1e-12);
}
#[test]
fn period_one_is_pass_through() {
let mut mma = MedianMa::new(1).unwrap();
assert_relative_eq!(mma.update(5.5).unwrap(), 5.5, epsilon = 1e-12);
assert_relative_eq!(mma.update(7.5).unwrap(), 7.5, epsilon = 1e-12);
}
#[test]
fn slides_window_correctly() {
// After [1,2,3] the window slides to [2,3,4] -> median 3, then [3,4,5] -> 4.
let mut mma = MedianMa::new(3).unwrap();
let v = mma.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
assert_relative_eq!(v[2].unwrap(), 2.0, epsilon = 1e-12);
assert_relative_eq!(v[3].unwrap(), 3.0, epsilon = 1e-12);
assert_relative_eq!(v[4].unwrap(), 4.0, epsilon = 1e-12);
}
#[test]
fn reset_clears_state() {
let mut mma = MedianMa::new(4).unwrap();
mma.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
assert!(mma.is_ready());
mma.reset();
assert!(!mma.is_ready());
assert_eq!(mma.update(10.0), None);
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (1..=20).map(|i| (f64::from(i) * 0.7).sin() * 5.0).collect();
let mut a = MedianMa::new(5).unwrap();
let mut b = MedianMa::new(5).unwrap();
assert_eq!(
a.batch(&prices),
prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
);
}
#[test]
fn ignores_non_finite_input_but_keeps_state() {
let mut mma = MedianMa::new(3).unwrap();
mma.update(5.0);
mma.update(1.0);
let ready = mma
.update(3.0)
.expect("MedianMA(3) ready after three inputs");
assert_eq!(mma.update(f64::NAN), Some(ready));
assert_eq!(mma.update(f64::INFINITY), Some(ready));
// Window still [5, 1, 3] -> next real input slides to [1, 3, 8] -> median 3.
assert_relative_eq!(mma.update(8.0).unwrap(), 3.0, epsilon = 1e-12);
}
}
+109 -1
View File
@@ -17,6 +17,7 @@ mod accelerator_oscillator;
mod ad_oscillator;
mod ad_volume_line;
mod adaptive_cycle;
mod adaptive_laguerre_filter;
mod adl;
mod advance_block;
mod advance_decline;
@@ -26,6 +27,7 @@ mod adxr;
mod alligator;
mod alma;
mod alpha;
mod amihud_illiquidity;
mod anchored_rsi;
mod anchored_vwap;
mod apo;
@@ -46,6 +48,7 @@ mod bat;
mod belt_hold;
mod beta;
mod beta_neutral_spread;
mod body_size_pct;
mod bollinger;
mod bollinger_bandwidth;
mod breadth_thrust;
@@ -64,6 +67,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;
@@ -87,7 +91,9 @@ mod dema;
mod demand_index;
mod demark_pivots;
mod depth_slope;
mod derivative_oscillator;
mod detrended_std_dev;
mod disparity_index;
mod distance_ssd;
mod doji;
mod doji_star;
@@ -100,15 +106,19 @@ mod dpo;
mod dragonfly_doji;
mod drawdown_duration;
mod dx;
mod dynamic_momentum_index;
mod ease_of_movement;
mod effective_spread;
mod ehlers_stochastic;
mod ehma;
mod elder_impulse;
mod elder_ray;
mod ema;
mod empirical_mode_decomposition;
mod engulfing;
mod evening_doji_star;
mod evwma;
mod expectancy;
mod falling_three_methods;
mod fama;
mod fib_arcs;
@@ -120,6 +130,7 @@ mod fib_projection;
mod fib_retracement;
mod fib_time_zones;
mod fibonacci_pivots;
mod fisher_rsi;
mod fisher_transform;
mod flag_pennant;
mod footprint;
@@ -134,6 +145,8 @@ mod gain_loss_ratio;
mod gap_side_by_side_white;
mod garman_klass;
mod gartley;
mod generalized_dema;
mod geometric_ma;
mod golden_pocket;
mod granger_causality;
mod gravestone_doji;
@@ -143,6 +156,7 @@ 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;
@@ -150,6 +164,7 @@ mod hilbert_dominant_cycle;
mod hilo_activator;
mod historical_volatility;
mod hma;
mod holt_winters;
mod homing_pigeon;
mod ht_dcphase;
mod ht_phasor;
@@ -163,10 +178,12 @@ mod inertia;
mod information_ratio;
mod initial_balance;
mod instantaneous_trendline;
mod intraday_momentum_index;
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;
@@ -187,6 +204,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;
@@ -205,6 +223,7 @@ mod mcclellan_oscillator;
mod mcclellan_summation_index;
mod mcginley_dynamic;
mod median_absolute_deviation;
mod median_ma;
mod median_price;
mod mfi;
mod microprice;
@@ -229,6 +248,7 @@ 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;
@@ -250,25 +270,34 @@ mod ppo;
mod profit_factor;
mod psar;
mod pvi;
mod qqe;
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;
mod rickshaw_man;
mod rising_three_methods;
mod rmi;
mod roc;
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 rsx;
mod rvi;
mod rvi_volatility;
mod rwi;
@@ -284,6 +313,7 @@ mod shooting_star;
mod short_line;
mod signed_volume;
mod sine_wave;
mod sine_weighted_ma;
mod skewness;
mod sma;
mod smi;
@@ -291,6 +321,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;
@@ -303,6 +334,7 @@ mod step_trailing_stop;
mod stick_sandwich;
mod stoch_rsi;
mod stochastic;
mod stochastic_cci;
mod super_smoother;
mod super_trend;
mod t3;
@@ -335,6 +367,7 @@ mod tii;
mod time_of_day_return_profile;
mod tpo_profile;
mod trade_imbalance;
mod trend_label;
mod treynor_ratio;
mod triangle;
mod trima;
@@ -367,6 +400,7 @@ mod volume_by_time_profile;
mod volume_oscillator;
mod volume_profile;
mod vortex;
mod vpin;
mod vpt;
mod vwap;
mod vwap_stddev_bands;
@@ -375,8 +409,10 @@ 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;
@@ -394,6 +430,7 @@ pub use accelerator_oscillator::AcceleratorOscillator;
pub use ad_oscillator::AdOscillator;
pub use ad_volume_line::AdVolumeLine;
pub use adaptive_cycle::AdaptiveCycle;
pub use adaptive_laguerre_filter::AdaptiveLaguerreFilter;
pub use adl::Adl;
pub use advance_block::AdvanceBlock;
pub use advance_decline::AdvanceDecline;
@@ -403,6 +440,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;
@@ -423,6 +461,7 @@ 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;
@@ -441,6 +480,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;
@@ -464,7 +504,9 @@ pub use dema::Dema;
pub use demand_index::DemandIndex;
pub use demark_pivots::{DemarkPivots, DemarkPivotsOutput};
pub use depth_slope::DepthSlope;
pub use derivative_oscillator::DerivativeOscillator;
pub use detrended_std_dev::DetrendedStdDev;
pub use disparity_index::DisparityIndex;
pub use distance_ssd::DistanceSsd;
pub use doji::Doji;
pub use doji_star::DojiStar;
@@ -477,15 +519,19 @@ pub use dpo::Dpo;
pub use dragonfly_doji::DragonflyDoji;
pub use drawdown_duration::DrawdownDuration;
pub use dx::Dx;
pub use dynamic_momentum_index::DynamicMomentumIndex;
pub use ease_of_movement::EaseOfMovement;
pub use effective_spread::EffectiveSpread;
pub use ehlers_stochastic::EhlersStochastic;
pub use ehma::Ehma;
pub use elder_impulse::ElderImpulse;
pub use elder_ray::{ElderRay, ElderRayOutput};
pub use ema::Ema;
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};
@@ -497,6 +543,7 @@ 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_rsi::FisherRsi;
pub use fisher_transform::FisherTransform;
pub use flag_pennant::FlagPennant;
pub use footprint::{Footprint, FootprintLevel, FootprintOutput};
@@ -511,6 +558,8 @@ 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 generalized_dema::GeneralizedDema;
pub use geometric_ma::GeometricMa;
pub use golden_pocket::{GoldenPocket, GoldenPocketOutput};
pub use granger_causality::GrangerCausality;
pub use gravestone_doji::GravestoneDoji;
@@ -520,6 +569,7 @@ 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;
@@ -527,6 +577,7 @@ pub use hilbert_dominant_cycle::HilbertDominantCycle;
pub use hilo_activator::HiLoActivator;
pub use historical_volatility::HistoricalVolatility;
pub use hma::Hma;
pub use holt_winters::HoltWinters;
pub use homing_pigeon::HomingPigeon;
pub use ht_dcphase::HtDcPhase;
pub use ht_phasor::{HtPhasor, HtPhasorOutput};
@@ -540,10 +591,12 @@ pub use inertia::Inertia;
pub use information_ratio::InformationRatio;
pub use initial_balance::{InitialBalance, InitialBalanceOutput};
pub use instantaneous_trendline::InstantaneousTrendline;
pub use intraday_momentum_index::IntradayMomentumIndex;
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;
@@ -564,6 +617,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;
@@ -582,6 +636,7 @@ pub use mcclellan_oscillator::McClellanOscillator;
pub use mcclellan_summation_index::McClellanSummationIndex;
pub use mcginley_dynamic::McGinleyDynamic;
pub use median_absolute_deviation::MedianAbsoluteDeviation;
pub use median_ma::MedianMa;
pub use median_price::MedianPrice;
pub use mfi::Mfi;
pub use microprice::Microprice;
@@ -606,6 +661,7 @@ 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};
@@ -627,25 +683,34 @@ pub use ppo::Ppo;
pub use profit_factor::ProfitFactor;
pub use psar::Psar;
pub use pvi::Pvi;
pub use qqe::{Qqe, QqeOutput};
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;
pub use rickshaw_man::RickshawMan;
pub use rising_three_methods::RisingThreeMethods;
pub use rmi::Rmi;
pub use roc::Roc;
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 rsx::Rsx;
pub use rvi::Rvi;
pub use rvi_volatility::RviVolatility;
pub use rwi::{Rwi, RwiOutput};
@@ -661,6 +726,7 @@ pub use shooting_star::ShootingStar;
pub use short_line::ShortLine;
pub use signed_volume::SignedVolume;
pub use sine_wave::SineWave;
pub use sine_weighted_ma::SineWeightedMa;
pub use skewness::Skewness;
pub use sma::Sma;
pub use smi::Smi;
@@ -668,6 +734,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;
@@ -680,6 +747,7 @@ pub use step_trailing_stop::StepTrailingStop;
pub use stick_sandwich::StickSandwich;
pub use stoch_rsi::StochRsi;
pub use stochastic::{Stochastic, StochasticOutput};
pub use stochastic_cci::StochasticCci;
pub use super_smoother::SuperSmoother;
pub use super_trend::{SuperTrend, SuperTrendOutput};
pub use t3::T3;
@@ -712,6 +780,7 @@ 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;
@@ -744,6 +813,7 @@ 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};
@@ -752,8 +822,10 @@ 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;
@@ -792,6 +864,13 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
"Jma",
"Alligator",
"Evwma",
"SineWeightedMa",
"GeometricMa",
"Ehma",
"MedianMa",
"AdaptiveLaguerreFilter",
"GeneralizedDema",
"HoltWinters",
],
),
(
@@ -821,6 +900,16 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
"Rocp",
"Rocr",
"Rocr100",
"DisparityIndex",
"FisherRsi",
"Rsx",
"DynamicMomentumIndex",
"StochasticCci",
"Rmi",
"DerivativeOscillator",
"ElderRay",
"IntradayMomentumIndex",
"Qqe",
],
),
(
@@ -846,6 +935,7 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
"PlusDi",
"MinusDi",
"Dx",
"TrendLabel",
],
),
(
@@ -884,6 +974,8 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
"GarmanKlassVolatility",
"RogersSatchellVolatility",
"YangZhangVolatility",
"JumpIndicator",
"RegimeLabel",
],
),
(
@@ -987,6 +1079,16 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
"GrangerCausality",
"KalmanHedgeRatio",
"SpreadBollingerBands",
"LogReturn",
"RealizedVolatility",
"RollingIqr",
"RollingPercentileRank",
"RollingQuantile",
"SpreadAr1Coefficient",
"CloseVsOpen",
"BodySizePct",
"WickRatio",
"HighLowRange",
],
),
(
@@ -1124,6 +1226,10 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
"RealizedSpread",
"KylesLambda",
"Footprint",
"OrderFlowImbalance",
"Vpin",
"AmihudIlliquidity",
"RollMeasure",
],
),
(
@@ -1173,6 +1279,8 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
"TreynorRatio",
"InformationRatio",
"Alpha",
"WinRate",
"Expectancy",
],
),
(
@@ -1285,6 +1393,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, 377, "FAMILIES total drifted from indicator count");
assert_eq!(total, 413, "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);
}
}
+358
View File
@@ -0,0 +1,358 @@
//! QQE — Quantitative Qualitative Estimation.
use crate::error::{Error, Result};
use crate::indicators::ema::Ema;
use crate::indicators::rsi::Rsi;
use crate::traits::Indicator;
/// One QQE reading: the smoothed RSI and its volatility-trailing line.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct QqeOutput {
/// The EMA-smoothed RSI (the fast QQE line).
pub rsi_ma: f64,
/// The trailing line (the slow QQE line): an ATR-of-RSI trailing stop that
/// the smoothed RSI rides above in an uptrend and below in a downtrend.
pub trailing_line: f64,
}
/// QQE — Quantitative Qualitative Estimation (Igor Livshin).
///
/// QQE smooths the RSI, then builds an "ATR of the RSI" trailing stop around it.
/// Crossovers of the smoothed RSI and that trailing line give cleaner momentum
/// signals than the raw RSI:
///
/// ```text
/// rsi_ma = EMA(RSI(price, rsi_period), smoothing)
/// atr_rsi = |rsi_ma rsi_ma_prev|
/// ma_atr = EMA(atr_rsi, 2·rsi_period 1) // Wilder length
/// dar = EMA(ma_atr, 2·rsi_period 1) · factor // smoothed band width
///
/// long_band = (rsi_ma_prev > long_band_prev && rsi_ma > long_band_prev)
/// ? max(long_band_prev, rsi_ma dar) : rsi_ma dar
/// short_band = (rsi_ma_prev < short_band_prev && rsi_ma < short_band_prev)
/// ? min(short_band_prev, rsi_ma + dar) : rsi_ma + dar
/// trend = cross-up of short_band → +1, cross-down of long_band → 1, else hold
/// trailing = trend == +1 ? long_band : short_band
/// ```
///
/// The trailing line ratchets in the trend direction (only ever tightening until
/// the smoothed RSI crosses it), exactly like a [`SuperTrend`](crate::SuperTrend)
/// on the RSI. Livshin's defaults are `rsi_period = 14`, `smoothing = 5`,
/// `factor = 4.236`.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, Qqe};
///
/// let mut qqe = Qqe::new(14, 5, 4.236).unwrap();
/// let mut last = None;
/// for i in 0..200 {
/// last = qqe.update(100.0 + (f64::from(i) * 0.1).sin() * 8.0);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct Qqe {
rsi: Rsi,
rsi_ma: Ema,
ma_atr: Ema,
dar_ema: Ema,
factor: f64,
prev_rsi_ma: Option<f64>,
bands: Option<(f64, f64, i8)>, // (long_band, short_band, trend)
last_value: Option<QqeOutput>,
}
impl Qqe {
/// Construct a QQE with the RSI period, RSI smoothing, and band `factor`.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `rsi_period` or `smoothing` is `0`, or
/// [`Error::InvalidPeriod`] if `factor` is non-finite or not positive.
pub fn new(rsi_period: usize, smoothing: usize, factor: f64) -> Result<Self> {
if rsi_period == 0 || smoothing == 0 {
return Err(Error::PeriodZero);
}
if !factor.is_finite() || factor <= 0.0 {
return Err(Error::InvalidPeriod {
message: "QQE factor must be a finite positive value",
});
}
let wilders = 2 * rsi_period - 1;
Ok(Self {
rsi: Rsi::new(rsi_period)?,
rsi_ma: Ema::new(smoothing)?,
ma_atr: Ema::new(wilders)?,
dar_ema: Ema::new(wilders)?,
factor,
prev_rsi_ma: None,
bands: None,
last_value: None,
})
}
/// Configured band factor.
pub const fn factor(&self) -> f64 {
self.factor
}
/// Current value if available.
pub const fn value(&self) -> Option<QqeOutput> {
self.last_value
}
}
impl Indicator for Qqe {
type Input = f64;
type Output = QqeOutput;
fn update(&mut self, price: f64) -> Option<QqeOutput> {
let rsi = self.rsi.update(price)?;
let rsi_ma = self.rsi_ma.update(rsi)?;
let Some(prev_ma) = self.prev_rsi_ma else {
self.prev_rsi_ma = Some(rsi_ma);
return None;
};
let atr_rsi = (rsi_ma - prev_ma).abs();
self.prev_rsi_ma = Some(rsi_ma);
let ma_atr = self.ma_atr.update(atr_rsi)?;
let dar = self.dar_ema.update(ma_atr)? * self.factor;
let new_long = rsi_ma - dar;
let new_short = rsi_ma + dar;
let (long_band, short_band, trend) = match self.bands {
Some((lb_prev, sb_prev, tr_prev)) => {
let lb = if prev_ma > lb_prev && rsi_ma > lb_prev {
lb_prev.max(new_long)
} else {
new_long
};
let sb = if prev_ma < sb_prev && rsi_ma < sb_prev {
sb_prev.min(new_short)
} else {
new_short
};
let tr = if prev_ma <= sb_prev && rsi_ma > sb_prev {
1
} else if prev_ma >= lb_prev && rsi_ma < lb_prev {
-1
} else {
tr_prev
};
(lb, sb, tr)
}
None => (new_long, new_short, 1),
};
self.bands = Some((long_band, short_band, trend));
let trailing_line = if trend == 1 { long_band } else { short_band };
let out = QqeOutput {
rsi_ma,
trailing_line,
};
self.last_value = Some(out);
Some(out)
}
fn reset(&mut self) {
self.rsi.reset();
self.rsi_ma.reset();
self.ma_atr.reset();
self.dar_ema.reset();
self.prev_rsi_ma = None;
self.bands = None;
self.last_value = None;
}
fn warmup_period(&self) -> usize {
// RSI (rsi_period + 1) -> rsi_ma EMA -> one bar for the first atr_rsi ->
// ma_atr EMA -> dar EMA. Expressed via the component warmups so it stays
// correct if those change.
self.rsi.warmup_period()
+ self.rsi_ma.warmup_period()
+ self.ma_atr.warmup_period()
+ self.dar_ema.warmup_period()
- 2
}
fn is_ready(&self) -> bool {
self.last_value.is_some()
}
fn name(&self) -> &'static str {
"QQE"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
/// Independent reference replaying the full QQE recurrence.
fn naive(
prices: &[f64],
rsi_period: usize,
smoothing: usize,
factor: f64,
) -> Vec<Option<QqeOutput>> {
let mut rsi = Rsi::new(rsi_period).unwrap();
let mut rsi_ma = Ema::new(smoothing).unwrap();
let wilders = 2 * rsi_period - 1;
let mut ma_atr = Ema::new(wilders).unwrap();
let mut dar_ema = Ema::new(wilders).unwrap();
let mut prev_ma: Option<f64> = None;
let mut bands: Option<(f64, f64, i8)> = None;
let mut out = Vec::with_capacity(prices.len());
for &p in prices {
let v = (|| {
let r = rsi.update(p)?;
let m = rsi_ma.update(r)?;
let Some(pm) = prev_ma else {
prev_ma = Some(m);
return None;
};
let atr = (m - pm).abs();
prev_ma = Some(m);
let ma = ma_atr.update(atr)?;
let dar = dar_ema.update(ma)? * factor;
let nl = m - dar;
let ns = m + dar;
let (lb, sb, tr) = match bands {
Some((lbp, sbp, trp)) => {
let lb = if pm > lbp && m > lbp { lbp.max(nl) } else { nl };
let sb = if pm < sbp && m < sbp { sbp.min(ns) } else { ns };
let tr = if pm <= sbp && m > sbp {
1
} else if pm >= lbp && m < lbp {
-1
} else {
trp
};
(lb, sb, tr)
}
None => (nl, ns, 1),
};
bands = Some((lb, sb, tr));
Some(QqeOutput {
rsi_ma: m,
trailing_line: if tr == 1 { lb } else { sb },
})
})();
out.push(v);
}
out
}
#[test]
fn rejects_bad_params() {
assert!(matches!(Qqe::new(0, 5, 4.236), Err(Error::PeriodZero)));
assert!(matches!(Qqe::new(14, 0, 4.236), Err(Error::PeriodZero)));
assert!(matches!(
Qqe::new(14, 5, 0.0),
Err(Error::InvalidPeriod { .. })
));
assert!(matches!(
Qqe::new(14, 5, f64::NAN),
Err(Error::InvalidPeriod { .. })
));
}
/// Cover the const accessors `factor` + `value` and the Indicator-impl
/// `name`. `warmup_period` is covered by `first_emission_matches_warmup`.
#[test]
fn accessors_and_metadata() {
let qqe = Qqe::new(14, 5, 4.236).unwrap();
assert_relative_eq!(qqe.factor(), 4.236, epsilon = 1e-12);
assert_eq!(qqe.value(), None);
assert_eq!(qqe.name(), "QQE");
}
#[test]
fn first_emission_matches_warmup() {
// A long trend-up-then-down series exercises both trend flips and the
// band tighten/reset branches.
let prices: Vec<f64> = (0..200)
.map(|i| 100.0 + (f64::from(i) * 0.06).sin() * 20.0)
.collect();
let mut qqe = Qqe::new(14, 5, 4.236).unwrap();
let out = qqe.batch(&prices);
let warmup = qqe.warmup_period();
for (i, v) in out.iter().enumerate().take(warmup - 1) {
assert!(v.is_none(), "index {i} must be None during warmup");
}
assert!(
out[warmup - 1].is_some(),
"first value at warmup_period - 1"
);
}
#[test]
fn matches_naive_over_full_cycle() {
// Up, range, and down phases so every band/trend branch is traversed.
let prices: Vec<f64> = (0..220)
.map(|i| {
let t = f64::from(i);
100.0 + (t * 0.05).sin() * 18.0 + (t * 0.2).cos() * 4.0
})
.collect();
let mut qqe = Qqe::new(14, 5, 4.236).unwrap();
let got = qqe.batch(&prices);
let want = naive(&prices, 14, 5, 4.236);
for (i, (g, w)) in got.iter().zip(want.iter()).enumerate() {
assert_eq!(g.is_some(), w.is_some(), "readiness mismatch at {i}");
if let (Some(a), Some(b)) = (g, w) {
assert_relative_eq!(a.rsi_ma, b.rsi_ma, epsilon = 1e-9);
assert_relative_eq!(a.trailing_line, b.trailing_line, epsilon = 1e-9);
}
}
}
#[test]
fn trailing_line_below_rsi_ma_in_uptrend() {
// Sustained rise: trend resolves to +1 and the trailing (long) band sits
// below the smoothed RSI.
let prices: Vec<f64> = (1..=120).map(f64::from).collect();
let mut qqe = Qqe::new(14, 5, 4.236).unwrap();
let last = qqe.batch(&prices).into_iter().flatten().last().unwrap();
assert!(
last.trailing_line <= last.rsi_ma,
"uptrend trailing {} should sit at/below rsi_ma {}",
last.trailing_line,
last.rsi_ma
);
}
#[test]
fn reset_clears_state() {
let mut qqe = Qqe::new(14, 5, 4.236).unwrap();
qqe.batch(
&(0..120)
.map(|i| 100.0 + (f64::from(i) * 0.1).sin() * 8.0)
.collect::<Vec<_>>(),
);
assert!(qqe.is_ready());
qqe.reset();
assert!(!qqe.is_ready());
assert_eq!(qqe.update(1.0), None);
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (0..150)
.map(|i| 50.0 + (f64::from(i) * 0.12).sin() * 12.0)
.collect();
let mut a = Qqe::new(14, 5, 4.236).unwrap();
let mut b = Qqe::new(14, 5, 4.236).unwrap();
assert_eq!(
a.batch(&prices),
prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
);
}
}
@@ -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,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);
}
}
+276
View File
@@ -0,0 +1,276 @@
//! Relative Momentum Index (RMI).
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Relative Momentum Index — RSI generalised to a multi-bar momentum lookback.
///
/// Wilder's [`Rsi`](crate::Rsi) compares each close to the *previous* close.
/// The RMI (Roger Altman, 1993) compares it to the close `momentum` bars ago,
/// then applies the same Wilder-smoothed up/down accumulator over `period`:
///
/// ```text
/// change_t = close_t - close_{t-momentum}
/// gain = max(change, 0), loss = max(-change, 0)
/// avg_gain, avg_loss = Wilder-smoothed over `period`
/// RMI = 100 * avg_gain / (avg_gain + avg_loss)
/// ```
///
/// `momentum = 1` reduces the RMI exactly to the RSI. Larger `momentum` makes
/// the oscillator smoother and slower to flip, holding overbought/oversold
/// readings longer in a trend. Output is bounded in `[0, 100]`; a flat market
/// (no gains and no losses) returns the neutral `50`.
///
/// The first value lands after `momentum + period` inputs: `momentum` to fill
/// the lookback, then `period` changes to seed Wilder's averages.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, Rmi};
///
/// let mut indicator = Rmi::new(14, 5).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// last = indicator.update(100.0 + (f64::from(i) * 0.2).sin() * 5.0);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct Rmi {
period: usize,
momentum: usize,
/// The last `momentum` prices, oldest at the front.
window: VecDeque<f64>,
seed_gains: Vec<f64>,
seed_losses: Vec<f64>,
avg_gain: Option<f64>,
avg_loss: Option<f64>,
last_value: Option<f64>,
}
impl Rmi {
/// Construct an RMI with the given smoothing `period` and `momentum`
/// lookback.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if either `period` or `momentum` is `0`.
pub fn new(period: usize, momentum: usize) -> Result<Self> {
if period == 0 || momentum == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
momentum,
window: VecDeque::with_capacity(momentum),
seed_gains: Vec::with_capacity(period),
seed_losses: Vec::with_capacity(period),
avg_gain: None,
avg_loss: None,
last_value: None,
})
}
/// Configured smoothing period.
pub const fn period(&self) -> usize {
self.period
}
/// Configured momentum lookback.
pub const fn momentum(&self) -> usize {
self.momentum
}
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.last_value
}
fn rmi_from_avgs(avg_gain: f64, avg_loss: f64) -> f64 {
let denom = avg_gain + avg_loss;
if denom == 0.0 {
50.0
} else {
// Ratio first, then scale, so `100 * g / g` cannot round above 100.
100.0 * (avg_gain / denom)
}
}
}
impl Indicator for Rmi {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() {
return self.last_value;
}
if self.window.len() < self.momentum {
// Still filling the momentum lookback; no change to measure yet.
self.window.push_back(input);
return None;
}
let past = self.window.pop_front().expect("window full");
self.window.push_back(input);
let change = input - past;
let gain = if change > 0.0 { change } else { 0.0 };
let loss = if change < 0.0 { -change } else { 0.0 };
if let (Some(ag), Some(al)) = (self.avg_gain, self.avg_loss) {
let n = self.period as f64;
let new_ag = (ag * (n - 1.0) + gain) / n;
let new_al = (al * (n - 1.0) + loss) / n;
self.avg_gain = Some(new_ag);
self.avg_loss = Some(new_al);
let v = Self::rmi_from_avgs(new_ag, new_al);
self.last_value = Some(v);
return Some(v);
}
self.seed_gains.push(gain);
self.seed_losses.push(loss);
if self.seed_gains.len() == self.period {
let ag = self.seed_gains.iter().sum::<f64>() / self.period as f64;
let al = self.seed_losses.iter().sum::<f64>() / self.period as f64;
self.avg_gain = Some(ag);
self.avg_loss = Some(al);
let v = Self::rmi_from_avgs(ag, al);
self.last_value = Some(v);
return Some(v);
}
None
}
fn reset(&mut self) {
self.window.clear();
self.seed_gains.clear();
self.seed_losses.clear();
self.avg_gain = None;
self.avg_loss = None;
self.last_value = None;
}
fn warmup_period(&self) -> usize {
self.momentum + self.period
}
fn is_ready(&self) -> bool {
self.last_value.is_some()
}
fn name(&self) -> &'static str {
"RMI"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::indicators::Rsi;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_zero_params() {
assert!(matches!(Rmi::new(0, 5), Err(Error::PeriodZero)));
assert!(matches!(Rmi::new(14, 0), Err(Error::PeriodZero)));
}
/// Cover the const accessors `period` + `momentum` + `value` and the
/// Indicator-impl `warmup_period` + `name`.
#[test]
fn accessors_and_metadata() {
let rmi = Rmi::new(14, 5).unwrap();
assert_eq!(rmi.period(), 14);
assert_eq!(rmi.momentum(), 5);
assert_eq!(rmi.value(), None);
assert_eq!(rmi.warmup_period(), 19);
assert_eq!(rmi.name(), "RMI");
}
#[test]
fn momentum_one_equals_rsi() {
// With momentum = 1 the RMI is exactly Wilder's RSI.
let prices: Vec<f64> = (0..60)
.map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 8.0)
.collect();
let mut rmi = Rmi::new(14, 1).unwrap();
let mut rsi = Rsi::new(14).unwrap();
for (i, &p) in prices.iter().enumerate() {
let got = rmi.update(p);
let want = rsi.update(p);
assert_eq!(got.is_some(), want.is_some(), "readiness mismatch at {i}");
if let (Some(a), Some(b)) = (got, want) {
assert_relative_eq!(a, b, epsilon = 1e-9);
}
}
}
#[test]
fn warmup_then_emits() {
// momentum + period = 3 + 2 = 5 inputs before the first value.
let mut rmi = Rmi::new(2, 3).unwrap();
let out = rmi.batch(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0]);
for (i, v) in out.iter().enumerate().take(4) {
assert!(v.is_none(), "index {i} must be None during warmup");
}
assert!(out[4].is_some(), "first value at warmup_period - 1");
}
#[test]
fn pure_uptrend_is_one_hundred() {
// Every momentum-spaced change is positive -> avg_loss 0 -> RMI 100.
let prices: Vec<f64> = (1..=40).map(f64::from).collect();
let mut rmi = Rmi::new(5, 3).unwrap();
let last = rmi.batch(&prices).into_iter().flatten().last().unwrap();
assert_relative_eq!(last, 100.0, epsilon = 1e-9);
}
#[test]
fn flat_market_is_neutral() {
// No change -> no gains and no losses -> neutral 50.
let mut rmi = Rmi::new(3, 2).unwrap();
let last = rmi.batch(&[7.0; 20]).into_iter().flatten().last().unwrap();
assert_relative_eq!(last, 50.0, epsilon = 1e-12);
}
#[test]
fn ignores_non_finite_input() {
let mut rmi = Rmi::new(2, 2).unwrap();
let ready = rmi
.batch(&[1.0, 2.0, 3.0, 4.0, 5.0])
.into_iter()
.flatten()
.last()
.unwrap();
assert_eq!(rmi.update(f64::NAN), Some(ready));
assert_eq!(rmi.update(f64::INFINITY), Some(ready));
}
#[test]
fn reset_clears_state() {
let mut rmi = Rmi::new(3, 2).unwrap();
rmi.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
assert!(rmi.is_ready());
rmi.reset();
assert!(!rmi.is_ready());
assert_eq!(rmi.update(1.0), None);
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (1..=40)
.map(|i| 50.0 + (f64::from(i) * 0.5).sin() * 10.0)
.collect();
let mut a = Rmi::new(14, 5).unwrap();
let mut b = Rmi::new(14, 5).unwrap();
assert_eq!(
a.batch(&prices),
prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
);
}
}
@@ -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);
}
}
+291
View File
@@ -0,0 +1,291 @@
//! RSX — Jurik-style smoothed RSI.
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// RSX — a noise-free RSI built from Jurik's three-stage smoothing cascade.
///
/// Where Wilder's [`Rsi`](crate::Rsi) smooths the up/down moves with a single
/// EMA, the RSX runs the signed price change *and* its absolute value through
/// three cascaded "double-EMA with overshoot" stages (each stage is
/// `x = 1.5·a 0.5·b`, the same lag-cancelling trick as a DEMA), then forms the
/// RSI-style ratio from the two smoothed streams:
///
/// ```text
/// f18 = 3 / (length + 2), f20 = 1 - f18
/// each stage: a = f20·a + f18·in; b = f18·a + f20·b; out = 1.5·a 0.5·b
/// v14 = stage3(signed change), v1C = stage3(|change|)
/// RSX = clamp((v14 / v1C + 1) · 50, 0, 100) (50 when v1C == 0)
/// ```
///
/// The result is an oscillator in `[0, 100]` that tracks the RSI but is far
/// smoother for the same responsiveness — it has very little of the RSI's
/// bar-to-bar jitter, so threshold crosses and divergences are cleaner. A flat
/// market returns the neutral `50`.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, Rsx};
///
/// let mut indicator = Rsx::new(14).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// last = indicator.update(100.0 + (f64::from(i) * 0.2).sin() * 5.0);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct Rsx {
length: usize,
f18: f64,
f20: f64,
prev: Option<f64>,
count: usize,
// Signed-change cascade (three stages: a/b pairs).
s_a0: f64,
s_b0: f64,
s_a1: f64,
s_b1: f64,
s_a2: f64,
s_b2: f64,
// Absolute-change cascade.
a_a0: f64,
a_b0: f64,
a_a1: f64,
a_b1: f64,
a_a2: f64,
a_b2: f64,
last_value: Option<f64>,
}
impl Rsx {
/// Construct an RSX with the given smoothing length.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `length == 0`.
pub fn new(length: usize) -> Result<Self> {
if length == 0 {
return Err(Error::PeriodZero);
}
let f18 = 3.0 / (length as f64 + 2.0);
Ok(Self {
length,
f18,
f20: 1.0 - f18,
prev: None,
count: 0,
s_a0: 0.0,
s_b0: 0.0,
s_a1: 0.0,
s_b1: 0.0,
s_a2: 0.0,
s_b2: 0.0,
a_a0: 0.0,
a_b0: 0.0,
a_a1: 0.0,
a_b1: 0.0,
a_a2: 0.0,
a_b2: 0.0,
last_value: None,
})
}
/// Configured length.
pub const fn length(&self) -> usize {
self.length
}
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.last_value
}
/// One double-EMA-with-overshoot stage: updates the `(a, b)` pair in place
/// and returns `1.5·a 0.5·b`.
fn stage(&self, a: &mut f64, b: &mut f64, input: f64) -> f64 {
*a = self.f20 * *a + self.f18 * input;
*b = self.f18 * *a + self.f20 * *b;
1.5 * *a - 0.5 * *b
}
}
impl Indicator for Rsx {
type Input = f64;
type Output = f64;
fn update(&mut self, price: f64) -> Option<f64> {
if !price.is_finite() {
return self.last_value;
}
let Some(prev) = self.prev else {
self.prev = Some(price);
return None;
};
self.prev = Some(price);
let change = price - prev;
// Signed-change cascade.
let (mut sa0, mut sb0) = (self.s_a0, self.s_b0);
let v_c = self.stage(&mut sa0, &mut sb0, change);
self.s_a0 = sa0;
self.s_b0 = sb0;
let (mut sa1, mut sb1) = (self.s_a1, self.s_b1);
let v_10 = self.stage(&mut sa1, &mut sb1, v_c);
self.s_a1 = sa1;
self.s_b1 = sb1;
let (mut sa2, mut sb2) = (self.s_a2, self.s_b2);
let v_14 = self.stage(&mut sa2, &mut sb2, v_10);
self.s_a2 = sa2;
self.s_b2 = sb2;
// Absolute-change cascade.
let abs = change.abs();
let (mut aa0, mut ab0) = (self.a_a0, self.a_b0);
let v_c1 = self.stage(&mut aa0, &mut ab0, abs);
self.a_a0 = aa0;
self.a_b0 = ab0;
let (mut aa1, mut ab1) = (self.a_a1, self.a_b1);
let v_18 = self.stage(&mut aa1, &mut ab1, v_c1);
self.a_a1 = aa1;
self.a_b1 = ab1;
let (mut aa2, mut ab2) = (self.a_a2, self.a_b2);
let v_1c = self.stage(&mut aa2, &mut ab2, v_18);
self.a_a2 = aa2;
self.a_b2 = ab2;
let v4 = if v_1c > 0.0 {
(v_14 / v_1c + 1.0) * 50.0
} else {
50.0
};
let rsx = v4.clamp(0.0, 100.0);
self.count += 1;
self.last_value = Some(rsx);
if self.count >= self.length {
Some(rsx)
} else {
None
}
}
fn reset(&mut self) {
*self = Self::new(self.length).expect("length already validated");
}
fn warmup_period(&self) -> usize {
// One input to seed `prev`, then `length` changes to settle the cascade.
self.length + 1
}
fn is_ready(&self) -> bool {
self.count >= self.length
}
fn name(&self) -> &'static str {
"RSX"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_zero_length() {
assert!(matches!(Rsx::new(0), Err(Error::PeriodZero)));
}
/// Cover the const accessors `length` + `value` and the Indicator-impl
/// `warmup_period` + `name`.
#[test]
fn accessors_and_metadata() {
let rsx = Rsx::new(14).unwrap();
assert_eq!(rsx.length(), 14);
assert_eq!(rsx.value(), None);
assert_eq!(rsx.warmup_period(), 15);
assert_eq!(rsx.name(), "RSX");
}
#[test]
fn warmup_then_emits() {
let mut rsx = Rsx::new(3).unwrap();
// 1 input seeds prev; then 3 changes settle -> first Some on input 4.
assert_eq!(rsx.update(10.0), None);
assert_eq!(rsx.update(11.0), None);
assert_eq!(rsx.update(12.0), None);
assert!(rsx.update(13.0).is_some());
}
#[test]
fn flat_market_is_neutral() {
// No movement -> absolute cascade is zero -> neutral 50.
let mut rsx = Rsx::new(5).unwrap();
let last = rsx.batch(&[7.0; 40]).into_iter().flatten().last().unwrap();
assert_relative_eq!(last, 50.0, epsilon = 1e-12);
}
#[test]
fn output_stays_in_range() {
let prices: Vec<f64> = (0..120)
.map(|i| 100.0 + (f64::from(i) * 0.35).sin() * 12.0)
.collect();
let mut rsx = Rsx::new(14).unwrap();
for v in rsx.batch(&prices).into_iter().flatten() {
assert!((0.0..=100.0).contains(&v), "RSX {v} left [0, 100]");
}
}
#[test]
fn strong_uptrend_is_high() {
// A sustained rise drives RSX well above the neutral 50.
let prices: Vec<f64> = (1..=60).map(f64::from).collect();
let mut rsx = Rsx::new(14).unwrap();
let last = rsx.batch(&prices).into_iter().flatten().last().unwrap();
assert!(
last > 80.0,
"strong uptrend should push RSX high, got {last}"
);
}
#[test]
fn ignores_non_finite_input() {
let mut rsx = Rsx::new(3).unwrap();
let ready = rsx
.batch(&[1.0, 2.0, 3.0, 4.0, 5.0])
.into_iter()
.flatten()
.last()
.unwrap();
assert_eq!(rsx.update(f64::NAN), Some(ready));
assert_eq!(rsx.update(f64::INFINITY), Some(ready));
}
#[test]
fn reset_clears_state() {
let mut rsx = Rsx::new(5).unwrap();
rsx.batch(&(1..=40).map(f64::from).collect::<Vec<_>>());
assert!(rsx.is_ready());
rsx.reset();
assert!(!rsx.is_ready());
assert_eq!(rsx.update(1.0), None);
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (1..=60)
.map(|i| 50.0 + (f64::from(i) * 0.5).sin() * 10.0)
.collect();
let mut a = Rsx::new(14).unwrap();
let mut b = Rsx::new(14).unwrap();
assert_eq!(
a.batch(&prices),
prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,273 @@
//! Sine-Weighted Moving Average (SWMA).
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Sine-Weighted Moving Average — a windowed average whose weights follow one
/// half-cycle of a sine wave.
///
/// Over the last `period` inputs the weight of the value at position
/// `i = 0, 1, …, period 1` (oldest to newest) is
///
/// ```text
/// w_i = sin(π · (i + 1) / (period + 1))
/// SWMA = Σ (w_i · value_i) / Σ w_i
/// ```
///
/// The window is symmetric: weights rise to a peak in the middle of the window
/// and fall off at both ends, so the central observations dominate while the
/// extremes are de-emphasised. Every weight is strictly positive because the
/// argument `(i + 1) / (period + 1)` lies in the open interval `(0, 1)`, so the
/// normaliser is always non-zero.
///
/// Each `update` is O(`period`): the fixed weight vector is dotted with the
/// trailing window, mirroring the way [`Alma`](crate::Alma) recomputes its
/// Gaussian weights. `period == 1` collapses to a pass-through
/// (`w_0 = sin(π/2) = 1`).
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, SineWeightedMa};
///
/// let mut indicator = SineWeightedMa::new(5).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// last = indicator.update(100.0 + f64::from(i));
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct SineWeightedMa {
period: usize,
window: VecDeque<f64>,
/// Sine weights for positions `0..period` (oldest to newest), constant in
/// `period`.
weights: Vec<f64>,
weights_total: f64,
}
impl SineWeightedMa {
/// Construct a new sine-weighted moving average over `period` inputs.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
let denom = period as f64 + 1.0;
let weights: Vec<f64> = (0..period)
.map(|i| (std::f64::consts::PI * (i as f64 + 1.0) / denom).sin())
.collect();
let weights_total = weights.iter().sum();
Ok(Self {
period,
window: VecDeque::with_capacity(period),
weights,
weights_total,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
/// Current value if the window is full.
pub fn value(&self) -> Option<f64> {
if self.window.len() == self.period {
let dot: f64 = self
.window
.iter()
.zip(&self.weights)
.map(|(v, w)| v * w)
.sum();
Some(dot / self.weights_total)
} else {
None
}
}
}
impl Indicator for SineWeightedMa {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() {
return self.value();
}
if self.window.len() == self.period {
self.window.pop_front();
}
self.window.push_back(input);
self.value()
}
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 {
"SWMA"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
/// Reference implementation: explicit sine-weighted average over a window.
fn swma_naive(prices: &[f64], period: usize) -> Vec<Option<f64>> {
let denom = period as f64 + 1.0;
let weights: Vec<f64> = (0..period)
.map(|i| (std::f64::consts::PI * (i as f64 + 1.0) / denom).sin())
.collect();
let total: f64 = weights.iter().sum();
prices
.iter()
.enumerate()
.map(|(i, _)| {
if i + 1 < period {
None
} else {
let window = &prices[i + 1 - period..=i];
let dot: f64 = window.iter().zip(&weights).map(|(v, w)| v * w).sum();
Some(dot / total)
}
})
.collect()
}
#[test]
fn new_rejects_zero_period() {
assert!(matches!(SineWeightedMa::new(0), Err(Error::PeriodZero)));
}
/// Cover the const accessor `period` and the Indicator-impl `warmup_period`
/// + `name`.
#[test]
fn accessors_and_metadata() {
let swma = SineWeightedMa::new(7).unwrap();
assert_eq!(swma.period(), 7);
assert_eq!(swma.warmup_period(), 7);
assert_eq!(swma.name(), "SWMA");
}
#[test]
fn warmup_returns_none() {
let mut swma = SineWeightedMa::new(3).unwrap();
assert_eq!(swma.update(1.0), None);
assert_eq!(swma.update(2.0), None);
// SWMA(3): weights sin(pi/4), sin(pi/2), sin(3pi/4) = [√½, 1, √½].
// Over [1,2,3]: (√½·1 + 1·2 + √½·3) / (√½ + 1 + √½).
let s = std::f64::consts::FRAC_1_SQRT_2;
let total = s + 1.0 + s;
let want = (s * 1.0 + 1.0 * 2.0 + s * 3.0) / total;
assert_relative_eq!(swma.update(3.0).unwrap(), want, epsilon = 1e-12);
}
#[test]
fn symmetric_weights_give_midpoint_on_linear_window() {
// For a perfectly linear window the symmetric weighting reproduces the
// arithmetic centre of the window.
let mut swma = SineWeightedMa::new(5).unwrap();
let v = swma.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
assert_relative_eq!(v[4].unwrap(), 3.0, epsilon = 1e-12);
}
#[test]
fn period_one_is_pass_through() {
let mut swma = SineWeightedMa::new(1).unwrap();
assert_relative_eq!(swma.update(5.5).unwrap(), 5.5, epsilon = 1e-12);
assert_relative_eq!(swma.update(7.5).unwrap(), 7.5, epsilon = 1e-12);
}
#[test]
fn matches_naive_over_inputs() {
let prices: Vec<f64> = (1..=30).map(|i| f64::from(i) * 1.7 - 5.0).collect();
let mut swma = SineWeightedMa::new(7).unwrap();
let got = swma.batch(&prices);
let want = swma_naive(&prices, 7);
for (i, (g, w)) in got.iter().zip(want.iter()).enumerate() {
assert_eq!(g.is_some(), w.is_some(), "warmup mismatch at index {i}");
if let (Some(a), Some(b)) = (g, w) {
assert_relative_eq!(*a, *b, epsilon = 1e-9);
}
}
}
#[test]
fn reset_clears_state() {
let mut swma = SineWeightedMa::new(4).unwrap();
swma.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
assert!(swma.is_ready());
swma.reset();
assert!(!swma.is_ready());
assert_eq!(swma.update(10.0), None);
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (1..=20).map(|i| f64::from(i) * 0.5).collect();
let mut a = SineWeightedMa::new(5).unwrap();
let mut b = SineWeightedMa::new(5).unwrap();
assert_eq!(
a.batch(&prices),
prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
);
}
#[test]
fn ignores_non_finite_input_but_keeps_state() {
let mut swma = SineWeightedMa::new(3).unwrap();
swma.update(1.0);
swma.update(2.0);
let ready = swma.update(3.0).expect("SWMA(3) ready after three inputs");
assert_eq!(swma.update(f64::NAN), Some(ready));
assert_eq!(swma.update(f64::INFINITY), Some(ready));
// The window still holds 1, 2, 3 -> next real input slides it to 2, 3, 4.
let s = std::f64::consts::FRAC_1_SQRT_2;
let total = s + 1.0 + s;
let want = (s * 2.0 + 1.0 * 3.0 + s * 4.0) / total;
assert_relative_eq!(swma.update(4.0).unwrap(), want, epsilon = 1e-12);
}
proptest::proptest! {
#![proptest_config(proptest::test_runner::Config::with_cases(48))]
#[test]
fn proptest_matches_naive(
period in 1usize..15,
prices in proptest::collection::vec(-500.0_f64..500.0, 0..120),
) {
let mut swma = SineWeightedMa::new(period).unwrap();
let got = swma.batch(&prices);
let want = swma_naive(&prices, period);
proptest::prop_assert_eq!(got.len(), want.len());
for (g, w) in got.iter().zip(want.iter()) {
match (g, w) {
(None, None) => {}
(Some(a), Some(b)) => proptest::prop_assert!(
(a - b).abs() < 1e-7,
"got={a} want={b}"
),
_ => proptest::prop_assert!(false, "warmup mismatch"),
}
}
}
}
}
@@ -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,231 @@
//! Stochastic CCI — a stochastic oscillator applied to the CCI.
use std::collections::VecDeque;
use crate::error::Result;
use crate::indicators::cci::Cci;
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Stochastic CCI — the stochastic oscillator computed over the
/// [`Cci`](crate::Cci) instead of price.
///
/// The CCI is unbounded and spends most of its time inside `±100`, which makes
/// fixed overbought/oversold lines awkward. Running a stochastic over the CCI
/// re-scales it to `[0, 100]` relative to its own recent range, turning it into
/// a bounded, self-normalising momentum oscillator:
///
/// ```text
/// cci = CCI(typical price, period)
/// %K = 100 * (cci - lowest(cci, period)) / (highest(cci, period) - lowest(cci, period))
/// ```
///
/// The same `period` is used for the CCI and the stochastic lookback. When the
/// CCI range over the window is zero (a flat market, where the CCI is pinned at
/// `0`) the oscillator returns the neutral `50`. The first value lands after
/// `2·period 1` bars: `period` to seed the CCI, then `period` CCI values to
/// fill the stochastic window.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, StochasticCci, Indicator};
///
/// let mut sc = StochasticCci::new(14).unwrap();
/// let mut last = None;
/// for i in 0..60 {
/// let base = 100.0 + (f64::from(i) * 0.3).sin() * 10.0;
/// let c = Candle::new(base, base + 1.0, base - 1.0, base, 1.0, i64::from(i)).unwrap();
/// last = sc.update(c);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct StochasticCci {
period: usize,
cci: Cci,
/// The last `period` CCI values.
window: VecDeque<f64>,
}
impl StochasticCci {
/// Construct a Stochastic CCI with the given period (shared by the CCI and
/// the stochastic lookback).
///
/// # Errors
///
/// Returns [`crate::Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
Ok(Self {
period,
cci: Cci::new(period)?,
window: VecDeque::with_capacity(period),
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for StochasticCci {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
let cci = self.cci.update(candle)?;
if self.window.len() == self.period {
self.window.pop_front();
}
self.window.push_back(cci);
if self.window.len() < self.period {
return None;
}
let mut lo = f64::MAX;
let mut hi = f64::MIN;
for &v in &self.window {
if v < lo {
lo = v;
}
if v > hi {
hi = v;
}
}
let range = hi - lo;
if range == 0.0 {
return Some(50.0);
}
// Ratio first, then scale: `100 * x / x` can round to 100.0000…1.
Some(100.0 * ((cci - lo) / range))
}
fn reset(&mut self) {
self.cci.reset();
self.window.clear();
}
fn warmup_period(&self) -> usize {
// CCI seeds at `period`, then `period` CCI values fill the stochastic window.
2 * self.period - 1
}
fn is_ready(&self) -> bool {
self.window.len() == self.period
}
fn name(&self) -> &'static str {
"StochasticCCI"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn candle(high: f64, low: f64, close: f64) -> Candle {
Candle::new(close, high, low, close, 1.0, 0).unwrap()
}
#[test]
fn rejects_zero_period() {
assert!(StochasticCci::new(0).is_err());
}
/// Cover the const accessor `period` and the Indicator-impl `warmup_period`
/// + `name`.
#[test]
fn accessors_and_metadata() {
let sc = StochasticCci::new(14).unwrap();
assert_eq!(sc.period(), 14);
assert_eq!(sc.warmup_period(), 27);
assert_eq!(sc.name(), "StochasticCCI");
}
#[test]
fn first_emission_matches_warmup_period() {
let bars: Vec<Candle> = (0..40)
.map(|i| {
let base = 100.0 + (f64::from(i) * 0.4).sin() * 8.0;
candle(base + 1.0, base - 1.0, base)
})
.collect();
let mut sc = StochasticCci::new(5).unwrap();
let out = sc.batch(&bars);
let warmup = sc.warmup_period();
assert_eq!(warmup, 9);
for (i, v) in out.iter().enumerate().take(warmup - 1) {
assert!(v.is_none(), "index {i} must be None during warmup");
}
assert!(out[warmup - 1].is_some());
}
#[test]
fn bounded_zero_to_hundred() {
let bars: Vec<Candle> = (0..80)
.map(|i| {
let base = 100.0 + (f64::from(i) * 0.35).sin() * 12.0;
candle(base + 2.0, base - 2.0, base)
})
.collect();
let mut sc = StochasticCci::new(9).unwrap();
for v in sc.batch(&bars).into_iter().flatten() {
assert!((0.0..=100.0).contains(&v), "%K {v} left [0, 100]");
}
}
#[test]
fn flat_market_is_neutral() {
// Constant candles -> CCI pinned at 0 -> zero range -> neutral 50.
let mut sc = StochasticCci::new(4).unwrap();
let bars = vec![candle(10.0, 10.0, 10.0); 20];
let last = sc.batch(&bars).into_iter().flatten().last().unwrap();
assert_relative_eq!(last, 50.0, epsilon = 1e-12);
}
#[test]
fn highest_cci_in_window_is_hundred() {
// When the latest CCI is the window maximum, %K must be 100.
// A long rise then makes the final CCI the highest in its window.
let mut bars: Vec<Candle> = (0..20)
.map(|i| candle(f64::from(i) + 1.0, f64::from(i) - 1.0, f64::from(i)))
.collect();
// Strong final push so the last CCI tops its window.
bars.push(candle(100.0, 98.0, 100.0));
let mut sc = StochasticCci::new(5).unwrap();
let last = sc.batch(&bars).into_iter().flatten().last().unwrap();
assert_relative_eq!(last, 100.0, epsilon = 1e-9);
}
#[test]
fn reset_clears_state() {
let mut sc = StochasticCci::new(5).unwrap();
sc.batch(
&(0..30)
.map(|i| candle(f64::from(i) + 1.0, f64::from(i) - 1.0, f64::from(i)))
.collect::<Vec<_>>(),
);
assert!(sc.is_ready());
sc.reset();
assert!(!sc.is_ready());
assert_eq!(sc.update(candle(2.0, 0.0, 1.0)), None);
}
#[test]
fn batch_equals_streaming() {
let bars: Vec<Candle> = (0..60)
.map(|i| {
let base = 50.0 + (f64::from(i) * 0.5).sin() * 10.0;
candle(base + 1.5, base - 1.5, base)
})
.collect();
let mut a = StochasticCci::new(9).unwrap();
let mut b = StochasticCci::new(9).unwrap();
assert_eq!(
a.batch(&bars),
bars.iter().map(|c| b.update(*c)).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);
}
}
+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);
}
}
@@ -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);
}
}
+74 -68
View File
@@ -57,80 +57,86 @@ pub use derivatives::DerivativesTick;
pub use error::{Error, Result};
pub use indicators::{
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, AutoFib, AutoFibOutput, Autocorrelation, AverageDailyRange,
AverageDrawdown, AvgPrice, AwesomeOscillator, AwesomeOscillatorHistogram, BalanceOfPower, Bat,
BeltHold, Beta, BetaNeutralSpread, BollingerBands, BollingerBandwidth, BollingerOutput,
AcceleratorOscillator, AdOscillator, AdVolumeLine, AdaptiveCycle, AdaptiveLaguerreFilter, Adl,
AdvanceBlock, AdvanceDecline, AdvanceDeclineRatio, Adx, AdxOutput, Adxr, Alligator,
AlligatorOutput, Alma, 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, 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, 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, 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, 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,
OvernightGap, OvernightIntradayReturn, OvernightIntradayReturnOutput, PainIndex,
PairSpreadZScore, PairwiseBeta, ParkinsonVolatility, PearsonCorrelation, PercentAboveMa,
PercentB, PercentageTrailingStop, Pgo, PiercingDarkCloud, PlusDi, PlusDm, Pmo,
PointAndFigureBars, Ppo, ProfitFactor, Psar, Pvi, QuotedSpread, RSquared, RealizedSpread,
RecoveryFactor, RectangleRange, RelativeStrengthAB, RelativeStrengthOutput, RenkoBars,
RenkoTrailingStop, RickshawMan, RisingThreeMethods, Roc, Rocp, Rocr, Rocr100,
RogersSatchellVolatility, RollingCorrelation, RollingCovariance, 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, 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, ThreeDrives, ThreeInside,
ThreeLineStrike, ThreeOutside, ThreeSoldiersOrCrows, ThreeStarsInSouth, Thrusting, TickIndex,
Tii, TimeOfDayReturnProfile, TimeOfDayReturnProfileOutput, TpoProfile, TpoProfileOutput,
TradeImbalance, TreynorRatio, Triangle, Trima, Trin, TripleTopBottom, Trix, TrueRange, Tsf,
Tsi, Tsv, TtmSqueeze, TtmSqueezeOutput, TurnOfMonth, Tweezer, TwoCrows, TypicalPrice,
UlcerIndex, UltimateOscillator, UniqueThreeRiver, UpDownVolumeRatio, UpsideGapThreeMethods,
ChandelierExitOutput, ChoppinessIndex, ClassicPivots, ClassicPivotsOutput, 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, DerivativeOscillator, DetrendedStdDev,
DisparityIndex, DistanceSsd, Doji, DojiStar, Donchian, DonchianOutput, DonchianStop,
DonchianStopOutput, DoubleBollinger, DoubleBollingerOutput, DoubleTopBottom,
DownsideGapThreeMethods, Dpo, DragonflyDoji, DrawdownDuration, Dx, DynamicMomentumIndex,
EaseOfMovement, EffectiveSpread, EhlersStochastic, Ehma, ElderImpulse, ElderRay,
ElderRayOutput, 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, FisherRsi, FisherTransform, FlagPennant, Footprint,
FootprintOutput, ForceIndex, FractalChaosBands, FractalChaosBandsOutput, Frama, FundingBasis,
FundingRate, FundingRateMean, FundingRateZScore, GainLossRatio, GapSideBySideWhite,
GarmanKlassVolatility, Gartley, GeneralizedDema, GeometricMa, GoldenPocket, GoldenPocketOutput,
GrangerCausality, GravestoneDoji, Hammer, HangingMan, Harami, HeadAndShoulders, HeikinAshi,
HeikinAshiOutput, HiLoActivator, HighLowIndex, HighLowRange, HighWave, Hikkake,
HikkakeModified, HilbertDominantCycle, HistoricalVolatility, Hma, HoltWinters, HomingPigeon,
HtDcPhase, HtPhasor, HtPhasorOutput, HtTrendMode, HurstChannel, HurstChannelOutput,
HurstExponent, Ichimoku, IchimokuOutput, IdenticalThreeCrows, InNeck, Inertia,
InformationRatio, InitialBalance, InitialBalanceOutput, InstantaneousTrendline,
IntradayMomentumIndex, 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, MedianMa, 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, Qqe, QqeOutput, QuotedSpread, RSquared, RealizedSpread,
RealizedVolatility, RecoveryFactor, RectangleRange, RegimeLabel, RelativeStrengthAB,
RelativeStrengthOutput, RenkoBars, RenkoTrailingStop, RickshawMan, RisingThreeMethods, Rmi,
Roc, Rocp, Rocr, Rocr100, RogersSatchellVolatility, RollMeasure, RollingCorrelation,
RollingCovariance, RollingIqr, RollingPercentileRank, RollingQuantile, RollingVwap,
RoofingFilter, Rsi, Rsx, Rvi, RviVolatility, Rwi, RwiOutput, SarExt, SeasonalZScore,
SeparatingLines, SessionHighLow, SessionHighLowOutput, SessionRange, SessionRangeOutput,
SessionVwap, Shark, SharpeRatio, ShootingStar, ShortLine, SignedVolume, SineWave,
SineWeightedMa, Skewness, Sma, Smi, Smma, SortinoRatio, SpearmanCorrelation, SpinningTop,
SpreadAr1Coefficient, SpreadBollingerBands, SpreadBollingerBandsOutput, SpreadHurst,
StalledPattern, StandardError, StandardErrorBands, StandardErrorBandsOutput, StarcBands,
StarcBandsOutput, Stc, StdDev, StepTrailingStop, StickSandwich, StochRsi, Stochastic,
StochasticCci, 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, 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,
Vwap, VwapStdDevBands, VwapStdDevBandsOutput, Vwma, Vzo, WaveTrend, WaveTrendOutput, Wedge,
WeightedClose, WilliamsFractals, WilliamsFractalsOutput, WilliamsR, Wma, WoodiePivots,
WoodiePivotsOutput, YangZhangVolatility, YoyoExit, ZScore, ZeroLagMacd, ZeroLagMacdOutput,
ZigZag, ZigZagOutput, Zlema, FAMILIES, T3,
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 **377 indicators** across
- A per-indicator deep dive for every one of the **413 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.3",
"version": "0.5.6",
"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.3",
"wickra-darwin-x64": "0.5.3",
"wickra-linux-arm64-gnu": "0.5.3",
"wickra-linux-x64-gnu": "0.5.3",
"wickra-win32-arm64-msvc": "0.5.3",
"wickra-win32-x64-msvc": "0.5.3"
"wickra-darwin-arm64": "0.5.6",
"wickra-darwin-x64": "0.5.6",
"wickra-linux-arm64-gnu": "0.5.6",
"wickra-linux-x64-gnu": "0.5.6",
"wickra-win32-arm64-msvc": "0.5.6",
"wickra-win32-x64-msvc": "0.5.6"
}
},
"node_modules/wickra": {
+34 -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, AdaptiveLaguerreFilter, Alma, AnchoredRsi, Apo, Autocorrelation, AverageDrawdown, BatchExt, Beta, BollingerBands, CalmarRatio, CenterOfGravity, Cfo, Cmo, CoefficientOfVariation, ConditionalValueAtRisk, ConnorsRsi, Coppock, CyberneticCycle, Decycler, DecyclerOscillator, Dema, DerivativeOscillator, DetrendedStdDev, DisparityIndex, DoubleBollinger, Dpo, DrawdownDuration, DynamicMomentumIndex, EhlersStochastic, Ehma, ElderImpulse, Ema, EmpiricalModeDecomposition, Expectancy, Fama, FisherRsi, FisherTransform, Frama, GainLossRatio, GeneralizedDema, GeometricMa, HilbertDominantCycle, HistoricalVolatility, Hma, HoltWinters, 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, MedianMa, MidPoint, Mom, OmegaRatio, PainIndex, PearsonCorrelation, PercentageTrailingStop, Pmo, Ppo, ProfitFactor, Qqe, RSquared, RealizedVolatility, RecoveryFactor, RegimeLabel, RenkoTrailingStop, Rmi, Roc, Rocp, Rocr, Rocr100, RollingIqr, RollingPercentileRank, RollingQuantile, RoofingFilter, Rsi, Rsx, RviVolatility, SharpeRatio, SineWave, SineWeightedMa, 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.
@@ -45,6 +43,13 @@ fuzz_target!(|data: Vec<f64>| {
drive(|| Dema::new(14).unwrap(), &data);
drive(|| Tema::new(14).unwrap(), &data);
drive(|| Hma::new(14).unwrap(), &data);
drive(|| SineWeightedMa::new(14).unwrap(), &data);
drive(|| GeometricMa::new(14).unwrap(), &data);
drive(|| Ehma::new(9).unwrap(), &data);
drive(|| MedianMa::new(14).unwrap(), &data);
drive(|| AdaptiveLaguerreFilter::new(13).unwrap(), &data);
drive(|| GeneralizedDema::new(5, 0.7).unwrap(), &data);
drive(|| HoltWinters::new(0.2, 0.1).unwrap(), &data);
drive(|| Roc::new(14).unwrap(), &data);
drive(|| Rocp::new(14).unwrap(), &data);
drive(|| Rocr::new(14).unwrap(), &data);
@@ -62,6 +67,12 @@ fuzz_target!(|data: Vec<f64>| {
drive(|| T3::new(14, 0.7).unwrap(), &data);
drive(|| Mom::new(14).unwrap(), &data);
drive(|| Cmo::new(14).unwrap(), &data);
drive(|| DisparityIndex::new(14).unwrap(), &data);
drive(|| FisherRsi::new(14).unwrap(), &data);
drive(|| Rsx::new(14).unwrap(), &data);
drive(|| DynamicMomentumIndex::new(14).unwrap(), &data);
drive(|| Rmi::new(14, 5).unwrap(), &data);
drive(|| DerivativeOscillator::new(14, 5, 3, 9).unwrap(), &data);
drive(|| Tsi::new(25, 13).unwrap(), &data);
drive(|| Pmo::new(35, 20).unwrap(), &data);
drive(|| Tii::new(60, 30).unwrap(), &data);
@@ -96,6 +107,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);
@@ -110,6 +129,16 @@ fuzz_target!(|data: Vec<f64>| {
let _ = Kst::classic().batch(&data);
}
// QQE is scalar-input but emits `QqeOutput`, so it bypasses the generic
// `drive` helper. Streaming + batch are still both exercised.
{
let mut qqe = Qqe::new(14, 5, 4.236).unwrap();
for &x in &data {
let _ = qqe.update(x);
}
let _ = Qqe::new(14, 5, 4.236).unwrap().batch(&data);
}
// Zero-Lag MACD shares MACD's multi-output topology, so it gets the
// same hand-rolled streaming + batch drive as classic MACD below.
{
@@ -157,6 +186,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.
+8 -1
View File
@@ -22,7 +22,7 @@
//! WeightedClose.
use libfuzzer_sys::fuzz_target;
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, Breakaway, Butterfly, Camarilla, Candle, Cci, ChaikinMoneyFlow, ChaikinOscillator, ChaikinVolatility, ChandeKrollStop, ChandelierExit, ChoppinessIndex, ClassicPivots, 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, 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, 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, ElderRay, 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, IntradayMomentumIndex, 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, StochasticCci, 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
@@ -105,6 +105,9 @@ fuzz_target!(|data: Vec<f64>| {
// --- Momentum & Oscillators ---
drive(|| Cci::new(20).unwrap(), &candles);
drive(|| StochasticCci::new(14).unwrap(), &candles);
drive(|| ElderRay::new(13).unwrap(), &candles);
drive(|| IntradayMomentumIndex::new(14).unwrap(), &candles);
drive(|| Rvi::new(10).unwrap(), &candles);
drive(|| Inertia::new(14, 20).unwrap(), &candles);
drive(|| Pgo::new(14).unwrap(), &candles);
@@ -118,6 +121,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);
@@ -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);
});
+2 -1
View File
@@ -8,7 +8,7 @@
//! panic.
use libfuzzer_sys::fuzz_target;
use wickra_core::{Alpha, BatchExt, BetaNeutralSpread, Cointegration, DistanceSsd, GrangerCausality, Indicator, InformationRatio, KalmanHedgeRatio, LeadLagCrossCorrelation, OuHalfLife, PairSpreadZScore, PairwiseBeta, RelativeStrengthAB, RollingCorrelation, RollingCovariance, SpreadBollingerBands, SpreadHurst, TreynorRatio, VarianceRatio};
use wickra_core::{Alpha, BatchExt, BetaNeutralSpread, Cointegration, DistanceSsd, GrangerCausality, Indicator, InformationRatio, KalmanHedgeRatio, LeadLagCrossCorrelation, OuHalfLife, PairSpreadZScore, PairwiseBeta, RelativeStrengthAB, RollingCorrelation, RollingCovariance, SpreadAr1Coefficient, SpreadBollingerBands, SpreadHurst, TreynorRatio, VarianceRatio};
#[inline(never)]
fn drive<I>(make: impl Fn() -> I, data: &[(f64, f64)])
@@ -46,6 +46,7 @@ fuzz_target!(|data: &[u8]| {
drive(|| BetaNeutralSpread::new(20).unwrap(), &pairs);
drive(|| VarianceRatio::new(60, 2).unwrap(), &pairs);
drive(|| GrangerCausality::new(60, 1).unwrap(), &pairs);
drive(|| SpreadAr1Coefficient::new(40).unwrap(), &pairs);
// Struct-output pair indicator: drive update + batch directly (the generic
// `drive` above only covers `Output = f64`).
+4 -4
View File
@@ -10,10 +10,7 @@
//! would reject — the indicators must never panic, streaming or batched.
use libfuzzer_sys::fuzz_target;
use wickra_core::{
BatchExt, CumulativeVolumeDelta, Footprint, Indicator, Side, SignedVolume, Trade,
TradeImbalance,
};
use wickra_core::{AmihudIlliquidity, BatchExt, CumulativeVolumeDelta, Footprint, Indicator, RollMeasure, Side, SignedVolume, Trade, TradeImbalance, Vpin};
#[inline(never)]
fn drive<I>(make: impl Fn() -> I, trades: &[Trade])
@@ -43,6 +40,9 @@ fuzz_target!(|data: &[u8]| {
drive(SignedVolume::new, &trades);
drive(CumulativeVolumeDelta::new, &trades);
drive(|| TradeImbalance::new(5).unwrap(), &trades);
drive(|| Vpin::new(8.0, 5).unwrap(), &trades);
drive(|| AmihudIlliquidity::new(20).unwrap(), &trades);
drive(|| RollMeasure::new(20).unwrap(), &trades);
// Footprint emits a variable-length `FootprintOutput` rather than an `f64`,
// so it is driven directly rather than through the scalar-output helper.