From fcb221ec03ffeabf70acbe32ff73b579ae4da5a1 Mon Sep 17 00:00:00 2001 From: kingchenc Date: Thu, 4 Jun 2026 12:00:35 +0200 Subject: [PATCH] feat: add 19 indicators for external feature-extractor coverage (377 -> 396) (#175) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CHANGELOG.md | 19 + README.md | 12 +- bindings/node/__tests__/indicators.test.js | 66 + bindings/node/index.d.ts | 175 +++ bindings/node/index.js | 21 +- bindings/node/src/lib.rs | 585 +++++++++ bindings/python/python/wickra/__init__.py | 38 + bindings/python/src/lib.rs | 1076 +++++++++++++++++ bindings/python/tests/test_new_indicators.py | 31 + bindings/wasm/src/lib.rs | 346 ++++++ .../src/indicators/amihud_illiquidity.rs | 239 ++++ .../src/indicators/body_size_pct.rs | 193 +++ .../src/indicators/close_vs_open.rs | 157 +++ .../wickra-core/src/indicators/expectancy.rs | 208 ++++ .../src/indicators/high_low_range.rs | 174 +++ .../src/indicators/jump_indicator.rs | 291 +++++ .../wickra-core/src/indicators/log_return.rs | 218 ++++ crates/wickra-core/src/indicators/mod.rs | 59 +- .../src/indicators/order_flow_imbalance.rs | 242 ++++ .../src/indicators/realized_volatility.rs | 240 ++++ .../src/indicators/regime_label.rs | 307 +++++ .../src/indicators/roll_measure.rs | 210 ++++ .../wickra-core/src/indicators/rolling_iqr.rs | 186 +++ .../src/indicators/rolling_percentile_rank.rs | 191 +++ .../src/indicators/rolling_quantile.rs | 230 ++++ .../src/indicators/spread_ar1_coefficient.rs | 251 ++++ .../wickra-core/src/indicators/trend_label.rs | 206 ++++ crates/wickra-core/src/indicators/vpin.rs | 262 ++++ .../wickra-core/src/indicators/wick_ratio.rs | 192 +++ crates/wickra-core/src/indicators/win_rate.rs | 191 +++ crates/wickra-core/src/lib.rs | 126 +- docs/README.md | 2 +- fuzz/fuzz_targets/indicator_update.rs | 14 +- fuzz/fuzz_targets/indicator_update_candle.rs | 6 +- .../indicator_update_orderbook.rs | 6 +- fuzz/fuzz_targets/indicator_update_pair.rs | 3 +- fuzz/fuzz_targets/indicator_update_trade.rs | 8 +- 37 files changed, 6697 insertions(+), 84 deletions(-) create mode 100644 crates/wickra-core/src/indicators/amihud_illiquidity.rs create mode 100644 crates/wickra-core/src/indicators/body_size_pct.rs create mode 100644 crates/wickra-core/src/indicators/close_vs_open.rs create mode 100644 crates/wickra-core/src/indicators/expectancy.rs create mode 100644 crates/wickra-core/src/indicators/high_low_range.rs create mode 100644 crates/wickra-core/src/indicators/jump_indicator.rs create mode 100644 crates/wickra-core/src/indicators/log_return.rs create mode 100644 crates/wickra-core/src/indicators/order_flow_imbalance.rs create mode 100644 crates/wickra-core/src/indicators/realized_volatility.rs create mode 100644 crates/wickra-core/src/indicators/regime_label.rs create mode 100644 crates/wickra-core/src/indicators/roll_measure.rs create mode 100644 crates/wickra-core/src/indicators/rolling_iqr.rs create mode 100644 crates/wickra-core/src/indicators/rolling_percentile_rank.rs create mode 100644 crates/wickra-core/src/indicators/rolling_quantile.rs create mode 100644 crates/wickra-core/src/indicators/spread_ar1_coefficient.rs create mode 100644 crates/wickra-core/src/indicators/trend_label.rs create mode 100644 crates/wickra-core/src/indicators/vpin.rs create mode 100644 crates/wickra-core/src/indicators/wick_ratio.rs create mode 100644 crates/wickra-core/src/indicators/win_rate.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d101da8..bd08a3aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,25 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +- **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_{t−period})` (`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`). diff --git a/README.md b/README.md index e5c0c218..47ee4cdd 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@

- Wickra — streaming-first technical indicators + Wickra — streaming-first technical indicators

[![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 396 indicators; start at the [indicators overview](https://docs.wickra.org/Indicators-Overview). - **Reference** — [warmup periods](https://docs.wickra.org/Warmup-Periods), [streaming vs batch](https://docs.wickra.org/Streaming-vs-Batch), @@ -136,7 +136,7 @@ python -m benchmarks.compare_libraries ## Indicators -377 streaming-first indicators across twenty-four families. Every one passes the +396 streaming-first indicators across twenty-four families. Every one passes the `batch == streaming` equivalence test, reference-value tests, and reset semantics tests. Each has a per-indicator deep dive (formula, parameters, warmup) at [docs.wickra.org](https://docs.wickra.org/Indicators-Overview). @@ -151,7 +151,7 @@ warmup) at [docs.wickra.org](https://docs.wickra.org/Indicators-Overview). | Bands & Channels | MA Envelope, Acceleration Bands, STARC Bands, ATR Bands, Hurst Channel, LinReg Channel, Standard Error Bands, Double Bollinger Bands, TTM Squeeze, Fractal Chaos Bands, VWAP StdDev Bands | | Trailing Stops | Parabolic SAR, Parabolic SAR Extended (SAREXT), SuperTrend, Chandelier Exit, Chande Kroll Stop, ATR Trailing Stop, HiLo Activator, Volty Stop, Yo-Yo Exit, Donchian Channel Stop, Percentage Trailing Stop, Step Trailing Stop, Renko Trailing Stop | | Volume | OBV, VWAP (cumulative + rolling), ADL, Volume-Price Trend, Chaikin Money Flow, Chaikin Oscillator, Force Index, Ease of Movement, Klinger Volume Oscillator, Volume Oscillator, NVI, PVI, Williams A/D, Anchored VWAP, Demand Index, TSV, VZO, Market Facilitation Index | -| Price Statistics | Typical Price, Median Price, Weighted Close, Linear Regression, Linear Regression Slope, Z-Score, Linear Regression Angle, Variance, Coefficient of Variation, Skewness, Kurtosis, Standard Error, Detrended StdDev, R², Median Absolute Deviation, Autocorrelation, Hurst Exponent, Pearson Correlation, Beta, Pairwise Beta, Pair Spread Z-Score, Lead-Lag Cross-Correlation, Cointegration, Relative Strength A-vs-B, Spearman Correlation, Mid Price, Mid Point, Average Price, Linear Regression Intercept, Time Series Forecast, Rolling Correlation, Rolling Covariance, OU Half-Life, Spread Hurst, Distance SSD, Beta-Neutral Spread, Variance Ratio, Granger Causality, Kalman Hedge Ratio, Spread Bollinger Bands | +| Price Statistics | Typical Price, Median Price, Weighted Close, Linear Regression, Linear Regression Slope, Z-Score, Linear Regression Angle, Variance, Coefficient of Variation, Skewness, Kurtosis, Standard Error, Detrended StdDev, R², Median Absolute Deviation, Autocorrelation, Hurst Exponent, Pearson Correlation, Beta, Pairwise Beta, Pair Spread Z-Score, Lead-Lag Cross-Correlation, Cointegration, Relative Strength A-vs-B, Spearman Correlation, Mid Price, Mid Point, Average Price, Linear Regression Intercept, Time Series Forecast, Rolling Correlation, Rolling Covariance, OU Half-Life, Spread Hurst, Distance SSD, Beta-Neutral Spread, Variance Ratio, Granger Causality, Kalman Hedge Ratio, Spread Bollinger Bands, Spread AR(1) Coefficient | | Ehlers / Cycle (DSP) | MAMA, FAMA, Fisher Transform, Inverse Fisher Transform, SuperSmoother, Hilbert Dominant Cycle, Hilbert Phasor, Hilbert DC Phase, Hilbert Trend Mode, Sine Wave, Decycler, Decycler Oscillator, Roofing Filter, Center of Gravity, Cybernetic Cycle, Adaptive Cycle, Empirical Mode Decomposition, Ehlers Stochastic, Instantaneous Trendline | | Pivots & S/R | Classic Pivots, Fibonacci Pivots, Camarilla, Woodie Pivots, DeMark Pivots, Williams Fractals, ZigZag | | DeMark | TD Setup, TD Sequential, TD DeMarker, TD REI, TD Pressure, TD Combo, TD Countdown, TD Lines, TD Range Projection, TD Differential, TD Open, TD Risk Level | @@ -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 396 indicators │ ├── wickra/ top-level facade crate (publishes on crates.io) + benches/ │ └── wickra-data/ CSV reader, tick aggregator, live exchange feeds ├── bindings/ diff --git a/bindings/node/__tests__/indicators.test.js b/bindings/node/__tests__/indicators.test.js index 03112995..cbc564f8 100644 --- a/bindings/node/__tests__/indicators.test.js +++ b/bindings/node/__tests__/indicators.test.js @@ -28,6 +28,16 @@ function num(v) { // --- Scalar indicators: update(value) vs batch(prices) --- const scalarFactories = { + Expectancy: () => new wickra.Expectancy(20), + WinRate: () => new wickra.WinRate(20), + RegimeLabel: () => new wickra.RegimeLabel(5, 20), + JumpIndicator: () => new wickra.JumpIndicator(20, 3.0), + TrendLabel: () => new wickra.TrendLabel(10), + RollingQuantile: () => new wickra.RollingQuantile(20, 0.5), + RollingPercentileRank: () => new wickra.RollingPercentileRank(14), + RollingIqr: () => new wickra.RollingIqr(14), + RealizedVolatility: () => new wickra.RealizedVolatility(20), + LogReturn: () => new wickra.LogReturn(1), TSF: () => new wickra.TSF(14), LINEARREG_INTERCEPT: () => new wickra.LINEARREG_INTERCEPT(14), ROCR100: () => new wickra.ROCR100(10), @@ -313,6 +323,10 @@ 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) }, }; for (const [name, d] of Object.entries(candleScalar)) { @@ -563,6 +577,7 @@ const pairFactories = { BetaNeutralSpread: () => new wickra.BetaNeutralSpread(20), VarianceRatio: () => new wickra.VarianceRatio(60, 2), GrangerCausality: () => new wickra.GrangerCausality(60, 1), + SpreadAr1Coefficient: () => new wickra.SpreadAr1Coefficient(40), }; for (const [name, make] of Object.entries(pairFactories)) { @@ -1126,6 +1141,57 @@ test('trade-flow rejects bad input', () => { assert.throws(() => new wickra.SignedVolume().update(100, -1, true)); }); +test('order-flow imbalance reference + streaming matches batch', () => { + // Rising bid (px up, size 6) with an unchanged ask -> +6 flow. + const ofi = new wickra.OrderFlowImbalance(1); + assert.equal(ofi.update([100], [5], [101], [4]), null); // seeds the reference + assert.ok(Math.abs(ofi.update([100.5], [6], [101], [4]) - 6.0) < 1e-12); + const snaps = Array.from({ length: 30 }, (_, i) => ({ + bidPx: [100 + Math.sin(i * 0.3)], + bidSz: [5 + Math.abs(Math.cos(i * 0.5))], + askPx: [101 + Math.sin(i * 0.3)], + askSz: [4 + Math.abs(Math.sin(i * 0.4))], + })); + const batch = new wickra.OrderFlowImbalance(10).batch(snaps); + const streamer = new wickra.OrderFlowImbalance(10); + assert.equal(batch.length, snaps.length); + for (let i = 0; i < snaps.length; i++) { + const s = streamer.update(snaps[i].bidPx, snaps[i].bidSz, snaps[i].askPx, snaps[i].askSz); + assert.ok((Number.isNaN(batch[i]) && s === null) || Math.abs(s - batch[i]) < 1e-9, `mismatch at ${i}`); + } +}); + +test('vpin / amihud / roll reference + streaming matches batch', () => { + // VPIN: two pure-buy buckets of size 10 -> imbalance == size -> 1. + const v = new wickra.Vpin(10, 2); + let last; + for (let i = 0; i < 4; i++) last = v.update(100, 5, true); + assert.equal(last, 1.0); + // Amihud(1): |ln(101/100)| / (101 * 10). + const a = new wickra.AmihudIlliquidity(1); + assert.equal(a.update(100, 10, true), null); + assert.ok(Math.abs(a.update(101, 10, true) - Math.abs(Math.log(101 / 100)) / (101 * 10)) < 1e-15); + // Roll(6): a clean bid-ask bounce of ±1 implies a spread of 2. + const r = new wickra.RollMeasure(6); + let roll = null; + for (let i = 0; i < 20; i++) roll = r.update(i % 2 === 0 ? 100 : 101, 1, true); + assert.ok(Math.abs(roll - 2.0) < 1e-12); + // Streaming-vs-batch for the three trade-input indicators. + const n = 40; + const price = Array.from({ length: n }, (_, i) => 100 + Math.sin(i * 0.25) * 4); + const size = Array.from({ length: n }, (_, i) => 1 + (i % 5)); + const isBuy = Array.from({ length: n }, (_, i) => i % 2 === 0); + for (const make of [() => new wickra.Vpin(8, 5), () => new wickra.AmihudIlliquidity(14), () => new wickra.RollMeasure(14)]) { + const batch = make().batch(price, size, isBuy); + const streamer = make(); + assert.equal(batch.length, n); + for (let i = 0; i < n; i++) { + const s = streamer.update(price[i], size[i], isBuy[i]); + assert.ok((Number.isNaN(batch[i]) && s === null) || Math.abs(s - batch[i]) < 1e-9, `mismatch at ${i}`); + } + } +}); + test('price-impact indicators reference values', () => { // Buy at 100.05 vs mid 100.0: 2 * (100.05 - 100) / 100 * 10000 = 10 bps. assert.ok(Math.abs(new wickra.EffectiveSpread().update(100.05, 1, true, 100.0) - 10.0) < 1e-9); diff --git a/bindings/node/index.d.ts b/bindings/node/index.d.ts index 044e4538..81bed00e 100644 --- a/bindings/node/index.d.ts +++ b/bindings/node/index.d.ts @@ -809,6 +809,96 @@ export declare class TSF { isReady(): boolean warmupPeriod(): number } +export type LogReturnNode = LogReturn +export declare class LogReturn { + constructor(period: number) + update(value: number): number | null + batch(prices: Array): Array + 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): Array + 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): Array + 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): Array + 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): Array + 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): Array + 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): Array + 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): Array + 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): Array + 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): Array + reset(): void + isReady(): boolean + warmupPeriod(): number +} export type AutocorrelationNode = Autocorrelation export declare class Autocorrelation { constructor(period: number, lag: number) @@ -866,6 +956,19 @@ export declare class PairwiseBeta { isReady(): boolean warmupPeriod(): number } +export type SpreadAr1CoefficientNode = SpreadAr1Coefficient +export declare class SpreadAr1Coefficient { + constructor(period: number) + update(x: number, y: number): number | null + /** + * Batch over two equally-sized arrays. Returns a length-`n` array + * with `NaN` for warmup positions. + */ + batch(x: Array, y: Array): Array + reset(): void + isReady(): boolean + warmupPeriod(): number +} export type SpearmanCorrelationNode = SpearmanCorrelation export declare class SpearmanCorrelation { constructor(period: number) @@ -1230,6 +1333,42 @@ export declare class HT_PHASOR { isReady(): boolean warmupPeriod(): number } +export type CloseVsOpenNode = CloseVsOpen +export declare class CloseVsOpen { + constructor() + update(open: number, high: number, low: number, close: number): number | null + batch(open: Array, high: Array, low: Array, close: Array): Array + 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, high: Array, low: Array, close: Array): Array + 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, high: Array, low: Array, close: Array): Array + 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, high: Array, low: Array, close: Array): Array + reset(): void + isReady(): boolean + warmupPeriod(): number +} export type StochNode = Stochastic export declare class Stochastic { constructor(kPeriod: number, dPeriod: number) @@ -3317,6 +3456,42 @@ export declare class TradeImbalance { isReady(): boolean warmupPeriod(): number } +export type OrderFlowImbalanceNode = OrderFlowImbalance +export declare class OrderFlowImbalance { + constructor(period: number) + update(bidPx: Array, bidSz: Array, askPx: Array, askSz: Array): number | null + batch(snapshots: Array): Array + 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, size: Array, isBuy: Array): Array + 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, size: Array, isBuy: Array): Array + 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, size: Array, isBuy: Array): Array + reset(): void + isReady(): boolean + warmupPeriod(): number +} export type EffectiveSpreadNode = EffectiveSpread export declare class EffectiveSpread { constructor() diff --git a/bindings/node/index.js b/bindings/node/index.js index fcef2d46..e359a2c8 100644 --- a/bindings/node/index.js +++ b/bindings/node/index.js @@ -310,7 +310,7 @@ if (!nativeBinding) { throw new Error(`Failed to load native binding`) } -const { version, SMA, EMA, WMA, RSI, DEMA, TEMA, HMA, ROC, TRIX, SMMA, TRIMA, ZLEMA, MOM, CMO, DPO, StdDev, UlcerIndex, VerticalHorizontalFilter, ZScore, McGinleyDynamic, FRAMA, SuperSmoother, FisherTransform, Decycler, CenterOfGravity, CyberneticCycle, InstantaneousTrendline, EhlersStochastic, RVIVolatility, Variance, CoefficientOfVariation, Skewness, Kurtosis, StandardError, DetrendedStdDev, RSquared, MedianAbsoluteDeviation, MIDPOINT, ROCP, ROCR, ROCR100, LINEARREG_INTERCEPT, TSF, Autocorrelation, HurstExponent, PearsonCorrelation, Beta, PairwiseBeta, SpearmanCorrelation, RollingCorrelation, RollingCovariance, OuHalfLife, SpreadHurst, DistanceSsd, BetaNeutralSpread, PairSpreadZScore, LeadLagCrossCorrelation, Cointegration, RelativeStrengthAB, VarianceRatio, GrangerCausality, KalmanHedgeRatio, SpreadBollingerBands, MACD, MACDFIX, MACDEXT, BollingerBands, ATR, PLUS_DM, MINUS_DM, PLUS_DI, MINUS_DI, DX, MIDPRICE, AVGPRICE, SAREXT, HT_PHASOR, Stochastic, OBV, ADX, ADXR, CCI, WilliamsR, MFI, PSAR, Keltner, Donchian, VWAP, RollingVWAP, AwesomeOscillator, Aroon, Inertia, ConnorsRSI, LaguerreRSI, SMI, KST, PGO, RVI, AwesomeOscillatorHistogram, STC, ElderImpulse, ZeroLagMACD, CFO, APO, KAMA, EVWMA, Alligator, JMA, VIDYA, ALMA, T3, TSI, PMO, TII, ADL, VolumePriceTrend, ChaikinMoneyFlow, ChaikinOscillator, ForceIndex, NVI, PVI, VolumeOscillator, KVO, WilliamsAD, AnchoredRSI, AnchoredVWAP, DemandIndex, TSV, VZO, MarketFacilitationIndex, EaseOfMovement, SuperTrend, ChandelierExit, ChandeKrollStop, AtrTrailingStop, HiLoActivator, VoltyStop, YoyoExit, DonchianStop, PercentageTrailingStop, StepTrailingStop, RenkoTrailingStop, TypicalPrice, MedianPrice, WeightedClose, LinearRegression, LinRegSlope, AcceleratorOscillator, BalanceOfPower, ChoppinessIndex, TrueRange, ChaikinVolatility, YangZhangVolatility, RogersSatchellVolatility, GarmanKlassVolatility, ParkinsonVolatility, LinRegAngle, BollingerBandwidth, PercentB, NATR, HistoricalVolatility, AroonOscillator, WaveTrend, RWI, Vortex, MassIndex, StochRSI, UltimateOscillator, PPO, Coppock, VWMA, MaEnvelope, AccelerationBands, StarcBands, AtrBands, HurstChannel, LinRegChannel, StandardErrorBands, DoubleBollinger, TtmSqueeze, FractalChaosBands, VwapStdDevBands, ClassicPivots, FibonacciPivots, Camarilla, WoodiePivots, DemarkPivots, WilliamsFractals, ZigZag, TDSetup, TDSequential, TDDeMarker, TDREI, TDPressure, TDCombo, TDCountdown, TDLines, TDRangeProjection, TDDifferential, TDOpen, TDRiskLevel, InverseFisherTransform, DecyclerOscillator, RoofingFilter, EmpiricalModeDecomposition, HT_DCPHASE, HT_TRENDMODE, HilbertDominantCycle, AdaptiveCycle, SineWave, MAMA, FAMA, Ichimoku, HeikinAshi, ValueArea, VolumeProfile, TpoProfile, InitialBalance, OpeningRange, Doji, Hammer, InvertedHammer, HangingMan, ShootingStar, Engulfing, Harami, MorningEveningStar, ThreeSoldiersOrCrows, PiercingDarkCloud, Marubozu, Tweezer, SpinningTop, ThreeInside, ThreeOutside, TwoCrows, UpsideGapTwoCrows, IdenticalThreeCrows, ThreeLineStrike, ThreeStarsInSouth, AbandonedBaby, AdvanceBlock, BeltHold, Breakaway, Counterattack, DojiStar, DragonflyDoji, GravestoneDoji, LongLeggedDoji, RickshawMan, EveningDojiStar, MorningDojiStar, GapSideBySideWhite, HighWave, Hikkake, HikkakeModified, HomingPigeon, OnNeck, InNeck, Thrusting, SeparatingLines, Kicking, KickingByLength, LadderBottom, MatHold, MatchingLow, LongLine, ShortLine, RisingThreeMethods, FallingThreeMethods, UpsideGapThreeMethods, DownsideGapThreeMethods, StalledPattern, StickSandwich, Takuri, ClosingMarubozu, OpeningMarubozu, TasukiGap, UniqueThreeRiver, ConcealingBabySwallow, DoubleTopBottom, TripleTopBottom, HeadAndShoulders, Triangle, Wedge, FlagPennant, RectangleRange, CupAndHandle, Abcd, Gartley, Butterfly, Bat, Crab, Shark, Cypher, ThreeDrives, OrderBookImbalanceTop1, OrderBookImbalanceFull, Microprice, QuotedSpread, DepthSlope, OrderBookImbalanceTopN, SignedVolume, CumulativeVolumeDelta, TradeImbalance, EffectiveSpread, RealizedSpread, KylesLambda, Footprint, FundingRate, FundingRateMean, FundingRateZScore, FundingBasis, OpenInterestDelta, OIPriceDivergence, OIWeighted, LongShortRatio, TakerBuySellRatio, LiquidationFeatures, TermStructureBasis, CalendarSpread, AdvanceDecline, AdvanceDeclineRatio, AdVolumeLine, McClellanOscillator, McClellanSummationIndex, Trin, BreadthThrust, NewHighsNewLows, HighLowIndex, PercentAboveMa, UpDownVolumeRatio, BullishPercentIndex, CumulativeVolumeIndex, AbsoluteBreadthIndex, TickIndex, SharpeRatio, SortinoRatio, CalmarRatio, OmegaRatio, MaxDrawdown, AverageDrawdown, DrawdownDuration, PainIndex, ValueAtRisk, ConditionalValueAtRisk, ProfitFactor, GainLossRatio, RecoveryFactor, KellyCriterion, TreynorRatio, InformationRatio, RenkoBars, KagiBars, PointAndFigureBars, Alpha, SessionVwap, OvernightGap, SeasonalZScore, TimeOfDayReturnProfile, IntradayVolatilityProfile, VolumeByTimeProfile, DayOfWeekProfile, AverageDailyRange, TurnOfMonth, SessionHighLow, SessionRange, OvernightIntradayReturn, FibRetracement, FibExtension, FibProjection, AutoFib, GoldenPocket, FibConfluence, FibFan, FibArcs, FibChannel, FibTimeZones } = nativeBinding +const { version, SMA, EMA, WMA, RSI, DEMA, TEMA, HMA, ROC, TRIX, SMMA, TRIMA, ZLEMA, MOM, CMO, DPO, StdDev, UlcerIndex, VerticalHorizontalFilter, ZScore, McGinleyDynamic, FRAMA, SuperSmoother, FisherTransform, Decycler, CenterOfGravity, CyberneticCycle, InstantaneousTrendline, EhlersStochastic, RVIVolatility, Variance, CoefficientOfVariation, Skewness, Kurtosis, StandardError, DetrendedStdDev, RSquared, MedianAbsoluteDeviation, MIDPOINT, ROCP, ROCR, ROCR100, LINEARREG_INTERCEPT, TSF, LogReturn, RealizedVolatility, RollingIqr, RollingPercentileRank, TrendLabel, WinRate, Expectancy, JumpIndicator, RegimeLabel, RollingQuantile, Autocorrelation, HurstExponent, PearsonCorrelation, Beta, PairwiseBeta, SpreadAr1Coefficient, SpearmanCorrelation, RollingCorrelation, RollingCovariance, OuHalfLife, SpreadHurst, DistanceSsd, BetaNeutralSpread, PairSpreadZScore, LeadLagCrossCorrelation, Cointegration, RelativeStrengthAB, VarianceRatio, GrangerCausality, KalmanHedgeRatio, SpreadBollingerBands, MACD, MACDFIX, MACDEXT, BollingerBands, ATR, PLUS_DM, MINUS_DM, PLUS_DI, MINUS_DI, DX, MIDPRICE, AVGPRICE, SAREXT, HT_PHASOR, CloseVsOpen, BodySizePct, WickRatio, HighLowRange, Stochastic, OBV, ADX, ADXR, CCI, WilliamsR, MFI, PSAR, Keltner, Donchian, VWAP, RollingVWAP, AwesomeOscillator, Aroon, Inertia, ConnorsRSI, LaguerreRSI, SMI, KST, PGO, RVI, AwesomeOscillatorHistogram, STC, ElderImpulse, ZeroLagMACD, CFO, APO, KAMA, EVWMA, Alligator, JMA, VIDYA, ALMA, T3, TSI, PMO, TII, ADL, VolumePriceTrend, ChaikinMoneyFlow, ChaikinOscillator, ForceIndex, NVI, PVI, VolumeOscillator, KVO, WilliamsAD, AnchoredRSI, AnchoredVWAP, DemandIndex, TSV, VZO, MarketFacilitationIndex, EaseOfMovement, SuperTrend, ChandelierExit, ChandeKrollStop, AtrTrailingStop, HiLoActivator, VoltyStop, YoyoExit, DonchianStop, PercentageTrailingStop, StepTrailingStop, RenkoTrailingStop, TypicalPrice, MedianPrice, WeightedClose, LinearRegression, LinRegSlope, AcceleratorOscillator, BalanceOfPower, ChoppinessIndex, TrueRange, ChaikinVolatility, YangZhangVolatility, RogersSatchellVolatility, GarmanKlassVolatility, ParkinsonVolatility, LinRegAngle, BollingerBandwidth, PercentB, NATR, HistoricalVolatility, AroonOscillator, WaveTrend, RWI, Vortex, MassIndex, StochRSI, UltimateOscillator, PPO, Coppock, VWMA, MaEnvelope, AccelerationBands, StarcBands, AtrBands, HurstChannel, LinRegChannel, StandardErrorBands, DoubleBollinger, TtmSqueeze, FractalChaosBands, VwapStdDevBands, ClassicPivots, FibonacciPivots, Camarilla, WoodiePivots, DemarkPivots, WilliamsFractals, ZigZag, TDSetup, TDSequential, TDDeMarker, TDREI, TDPressure, TDCombo, TDCountdown, TDLines, TDRangeProjection, TDDifferential, TDOpen, TDRiskLevel, InverseFisherTransform, DecyclerOscillator, RoofingFilter, EmpiricalModeDecomposition, HT_DCPHASE, HT_TRENDMODE, HilbertDominantCycle, AdaptiveCycle, SineWave, MAMA, FAMA, Ichimoku, HeikinAshi, ValueArea, VolumeProfile, TpoProfile, InitialBalance, OpeningRange, Doji, Hammer, InvertedHammer, HangingMan, ShootingStar, Engulfing, Harami, MorningEveningStar, ThreeSoldiersOrCrows, PiercingDarkCloud, Marubozu, Tweezer, SpinningTop, ThreeInside, ThreeOutside, TwoCrows, UpsideGapTwoCrows, IdenticalThreeCrows, ThreeLineStrike, ThreeStarsInSouth, AbandonedBaby, AdvanceBlock, BeltHold, Breakaway, Counterattack, DojiStar, DragonflyDoji, GravestoneDoji, LongLeggedDoji, RickshawMan, EveningDojiStar, MorningDojiStar, GapSideBySideWhite, HighWave, Hikkake, HikkakeModified, HomingPigeon, OnNeck, InNeck, Thrusting, SeparatingLines, Kicking, KickingByLength, LadderBottom, MatHold, MatchingLow, LongLine, ShortLine, RisingThreeMethods, FallingThreeMethods, UpsideGapThreeMethods, DownsideGapThreeMethods, StalledPattern, StickSandwich, Takuri, ClosingMarubozu, OpeningMarubozu, TasukiGap, UniqueThreeRiver, ConcealingBabySwallow, DoubleTopBottom, TripleTopBottom, HeadAndShoulders, Triangle, Wedge, FlagPennant, RectangleRange, CupAndHandle, Abcd, Gartley, Butterfly, Bat, Crab, Shark, Cypher, ThreeDrives, OrderBookImbalanceTop1, OrderBookImbalanceFull, Microprice, QuotedSpread, DepthSlope, OrderBookImbalanceTopN, SignedVolume, CumulativeVolumeDelta, TradeImbalance, OrderFlowImbalance, Vpin, AmihudIlliquidity, RollMeasure, EffectiveSpread, RealizedSpread, KylesLambda, Footprint, FundingRate, FundingRateMean, FundingRateZScore, FundingBasis, OpenInterestDelta, OIPriceDivergence, OIWeighted, LongShortRatio, TakerBuySellRatio, LiquidationFeatures, TermStructureBasis, CalendarSpread, AdvanceDecline, AdvanceDeclineRatio, AdVolumeLine, McClellanOscillator, McClellanSummationIndex, Trin, BreadthThrust, NewHighsNewLows, HighLowIndex, PercentAboveMa, UpDownVolumeRatio, BullishPercentIndex, CumulativeVolumeIndex, AbsoluteBreadthIndex, TickIndex, SharpeRatio, SortinoRatio, CalmarRatio, OmegaRatio, MaxDrawdown, AverageDrawdown, DrawdownDuration, PainIndex, ValueAtRisk, ConditionalValueAtRisk, ProfitFactor, GainLossRatio, RecoveryFactor, KellyCriterion, TreynorRatio, InformationRatio, RenkoBars, KagiBars, PointAndFigureBars, Alpha, SessionVwap, OvernightGap, SeasonalZScore, TimeOfDayReturnProfile, IntradayVolatilityProfile, VolumeByTimeProfile, DayOfWeekProfile, AverageDailyRange, TurnOfMonth, SessionHighLow, SessionRange, OvernightIntradayReturn, FibRetracement, FibExtension, FibProjection, AutoFib, GoldenPocket, FibConfluence, FibFan, FibArcs, FibChannel, FibTimeZones } = nativeBinding module.exports.version = version module.exports.SMA = SMA @@ -356,11 +356,22 @@ module.exports.ROCR = ROCR module.exports.ROCR100 = ROCR100 module.exports.LINEARREG_INTERCEPT = LINEARREG_INTERCEPT module.exports.TSF = TSF +module.exports.LogReturn = LogReturn +module.exports.RealizedVolatility = RealizedVolatility +module.exports.RollingIqr = RollingIqr +module.exports.RollingPercentileRank = RollingPercentileRank +module.exports.TrendLabel = TrendLabel +module.exports.WinRate = WinRate +module.exports.Expectancy = Expectancy +module.exports.JumpIndicator = JumpIndicator +module.exports.RegimeLabel = RegimeLabel +module.exports.RollingQuantile = RollingQuantile module.exports.Autocorrelation = Autocorrelation module.exports.HurstExponent = HurstExponent module.exports.PearsonCorrelation = PearsonCorrelation module.exports.Beta = Beta module.exports.PairwiseBeta = PairwiseBeta +module.exports.SpreadAr1Coefficient = SpreadAr1Coefficient module.exports.SpearmanCorrelation = SpearmanCorrelation module.exports.RollingCorrelation = RollingCorrelation module.exports.RollingCovariance = RollingCovariance @@ -390,6 +401,10 @@ module.exports.MIDPRICE = MIDPRICE module.exports.AVGPRICE = AVGPRICE module.exports.SAREXT = SAREXT module.exports.HT_PHASOR = HT_PHASOR +module.exports.CloseVsOpen = CloseVsOpen +module.exports.BodySizePct = BodySizePct +module.exports.WickRatio = WickRatio +module.exports.HighLowRange = HighLowRange module.exports.Stochastic = Stochastic module.exports.OBV = OBV module.exports.ADX = ADX @@ -617,6 +632,10 @@ module.exports.OrderBookImbalanceTopN = OrderBookImbalanceTopN module.exports.SignedVolume = SignedVolume module.exports.CumulativeVolumeDelta = CumulativeVolumeDelta module.exports.TradeImbalance = TradeImbalance +module.exports.OrderFlowImbalance = OrderFlowImbalance +module.exports.Vpin = Vpin +module.exports.AmihudIlliquidity = AmihudIlliquidity +module.exports.RollMeasure = RollMeasure module.exports.EffectiveSpread = EffectiveSpread module.exports.RealizedSpread = RealizedSpread module.exports.KylesLambda = KylesLambda diff --git a/bindings/node/src/lib.rs b/bindings/node/src/lib.rs index 8436e171..f7874f37 100644 --- a/bindings/node/src/lib.rs +++ b/bindings/node/src/lib.rs @@ -182,6 +182,125 @@ node_scalar_indicator!( wc::LinRegIntercept ); node_scalar_indicator!(TsfNode, "TSF", wc::Tsf); +node_scalar_indicator!(LogReturnNode, "LogReturn", wc::LogReturn); +node_scalar_indicator!( + RealizedVolatilityNode, + "RealizedVolatility", + wc::RealizedVolatility +); +node_scalar_indicator!(RollingIqrNode, "RollingIqr", wc::RollingIqr); +node_scalar_indicator!( + RollingPercentileRankNode, + "RollingPercentileRank", + wc::RollingPercentileRank +); +node_scalar_indicator!(TrendLabelNode, "TrendLabel", wc::TrendLabel); +node_scalar_indicator!(WinRateNode, "WinRate", wc::WinRate); +node_scalar_indicator!(ExpectancyNode, "Expectancy", wc::Expectancy); +#[napi(js_name = "JumpIndicator")] +pub struct JumpIndicatorNode { + inner: wc::JumpIndicator, +} + +#[napi] +impl JumpIndicatorNode { + #[napi(constructor)] + pub fn new(period: u32, threshold: f64) -> napi::Result { + Ok(Self { + inner: wc::JumpIndicator::new(period as usize, threshold).map_err(map_err)?, + }) + } + #[napi] + pub fn update(&mut self, value: f64) -> Option { + self.inner.update(value) + } + #[napi] + pub fn batch(&mut self, prices: Vec) -> Vec { + flatten(self.inner.batch(&prices)) + } + #[napi] + pub fn reset(&mut self) { + self.inner.reset(); + } + #[napi(js_name = "isReady")] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[napi(js_name = "warmupPeriod")] + pub fn warmup_period(&self) -> u32 { + self.inner.warmup_period() as u32 + } +} + +#[napi(js_name = "RegimeLabel")] +pub struct RegimeLabelNode { + inner: wc::RegimeLabel, +} + +#[napi] +impl RegimeLabelNode { + #[napi(constructor)] + pub fn new(vol_period: u32, lookback: u32) -> napi::Result { + Ok(Self { + inner: wc::RegimeLabel::new(vol_period as usize, lookback as usize).map_err(map_err)?, + }) + } + #[napi] + pub fn update(&mut self, value: f64) -> Option { + self.inner.update(value) + } + #[napi] + pub fn batch(&mut self, prices: Vec) -> Vec { + flatten(self.inner.batch(&prices)) + } + #[napi] + pub fn reset(&mut self) { + self.inner.reset(); + } + #[napi(js_name = "isReady")] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[napi(js_name = "warmupPeriod")] + pub fn warmup_period(&self) -> u32 { + self.inner.warmup_period() as u32 + } +} + +#[napi(js_name = "RollingQuantile")] +pub struct RollingQuantileNode { + inner: wc::RollingQuantile, +} + +#[napi] +impl RollingQuantileNode { + #[napi(constructor)] + pub fn new(period: u32, quantile: f64) -> napi::Result { + Ok(Self { + inner: wc::RollingQuantile::new(period as usize, quantile).map_err(map_err)?, + }) + } + #[napi] + pub fn update(&mut self, value: f64) -> Option { + self.inner.update(value) + } + #[napi] + pub fn batch(&mut self, prices: Vec) -> Vec { + flatten(self.inner.batch(&prices)) + } + #[napi] + pub fn reset(&mut self) { + self.inner.reset(); + } + #[napi(js_name = "isReady")] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[napi(js_name = "warmupPeriod")] + pub fn warmup_period(&self) -> u32 { + self.inner.warmup_period() as u32 + } +} // ============================== Autocorrelation (period + lag) ============================== @@ -317,6 +436,11 @@ node_pair_indicator!( ); node_pair_indicator!(BetaNode, "Beta", wc::Beta); node_pair_indicator!(PairwiseBetaNode, "PairwiseBeta", wc::PairwiseBeta); +node_pair_indicator!( + SpreadAr1CoefficientNode, + "SpreadAr1Coefficient", + wc::SpreadAr1Coefficient +); node_pair_indicator!( SpearmanCorrelationNode, "SpearmanCorrelation", @@ -1658,6 +1782,266 @@ impl HtPhasorNode { } } +#[napi(js_name = "CloseVsOpen")] +pub struct CloseVsOpenNode { + inner: wc::CloseVsOpen, +} + +impl Default for CloseVsOpenNode { + fn default() -> Self { + Self::new() + } +} + +#[napi] +impl CloseVsOpenNode { + #[napi(constructor)] + pub fn new() -> Self { + Self { + inner: wc::CloseVsOpen::new(), + } + } + #[napi] + pub fn update( + &mut self, + open: f64, + high: f64, + low: f64, + close: f64, + ) -> napi::Result> { + let candle = wc::Candle::new(open, high, low, close, 0.0, 0).map_err(map_err)?; + Ok(self.inner.update(candle)) + } + #[napi] + pub fn batch( + &mut self, + open: Vec, + high: Vec, + low: Vec, + close: Vec, + ) -> napi::Result> { + if open.len() != high.len() || high.len() != low.len() || low.len() != close.len() { + return Err(NapiError::from_reason( + "open, high, low, close must be equal length".to_string(), + )); + } + let mut out = Vec::with_capacity(open.len()); + for i in 0..open.len() { + let candle = + wc::Candle::new(open[i], high[i], low[i], close[i], 0.0, 0).map_err(map_err)?; + out.push(self.inner.update(candle).unwrap_or(f64::NAN)); + } + Ok(out) + } + #[napi] + pub fn reset(&mut self) { + self.inner.reset(); + } + #[napi(js_name = "isReady")] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[napi(js_name = "warmupPeriod")] + pub fn warmup_period(&self) -> u32 { + self.inner.warmup_period() as u32 + } +} + +#[napi(js_name = "BodySizePct")] +pub struct BodySizePctNode { + inner: wc::BodySizePct, +} + +impl Default for BodySizePctNode { + fn default() -> Self { + Self::new() + } +} + +#[napi] +impl BodySizePctNode { + #[napi(constructor)] + pub fn new() -> Self { + Self { + inner: wc::BodySizePct::new(), + } + } + #[napi] + pub fn update( + &mut self, + open: f64, + high: f64, + low: f64, + close: f64, + ) -> napi::Result> { + let candle = wc::Candle::new(open, high, low, close, 0.0, 0).map_err(map_err)?; + Ok(self.inner.update(candle)) + } + #[napi] + pub fn batch( + &mut self, + open: Vec, + high: Vec, + low: Vec, + close: Vec, + ) -> napi::Result> { + if open.len() != high.len() || high.len() != low.len() || low.len() != close.len() { + return Err(NapiError::from_reason( + "open, high, low, close must be equal length".to_string(), + )); + } + let mut out = Vec::with_capacity(open.len()); + for i in 0..open.len() { + let candle = + wc::Candle::new(open[i], high[i], low[i], close[i], 0.0, 0).map_err(map_err)?; + out.push(self.inner.update(candle).unwrap_or(f64::NAN)); + } + Ok(out) + } + #[napi] + pub fn reset(&mut self) { + self.inner.reset(); + } + #[napi(js_name = "isReady")] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[napi(js_name = "warmupPeriod")] + pub fn warmup_period(&self) -> u32 { + self.inner.warmup_period() as u32 + } +} + +#[napi(js_name = "WickRatio")] +pub struct WickRatioNode { + inner: wc::WickRatio, +} + +impl Default for WickRatioNode { + fn default() -> Self { + Self::new() + } +} + +#[napi] +impl WickRatioNode { + #[napi(constructor)] + pub fn new() -> Self { + Self { + inner: wc::WickRatio::new(), + } + } + #[napi] + pub fn update( + &mut self, + open: f64, + high: f64, + low: f64, + close: f64, + ) -> napi::Result> { + let candle = wc::Candle::new(open, high, low, close, 0.0, 0).map_err(map_err)?; + Ok(self.inner.update(candle)) + } + #[napi] + pub fn batch( + &mut self, + open: Vec, + high: Vec, + low: Vec, + close: Vec, + ) -> napi::Result> { + if open.len() != high.len() || high.len() != low.len() || low.len() != close.len() { + return Err(NapiError::from_reason( + "open, high, low, close must be equal length".to_string(), + )); + } + let mut out = Vec::with_capacity(open.len()); + for i in 0..open.len() { + let candle = + wc::Candle::new(open[i], high[i], low[i], close[i], 0.0, 0).map_err(map_err)?; + out.push(self.inner.update(candle).unwrap_or(f64::NAN)); + } + Ok(out) + } + #[napi] + pub fn reset(&mut self) { + self.inner.reset(); + } + #[napi(js_name = "isReady")] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[napi(js_name = "warmupPeriod")] + pub fn warmup_period(&self) -> u32 { + self.inner.warmup_period() as u32 + } +} + +#[napi(js_name = "HighLowRange")] +pub struct HighLowRangeNode { + inner: wc::HighLowRange, +} + +impl Default for HighLowRangeNode { + fn default() -> Self { + Self::new() + } +} + +#[napi] +impl HighLowRangeNode { + #[napi(constructor)] + pub fn new() -> Self { + Self { + inner: wc::HighLowRange::new(), + } + } + #[napi] + pub fn update( + &mut self, + open: f64, + high: f64, + low: f64, + close: f64, + ) -> napi::Result> { + let candle = wc::Candle::new(open, high, low, close, 0.0, 0).map_err(map_err)?; + Ok(self.inner.update(candle)) + } + #[napi] + pub fn batch( + &mut self, + open: Vec, + high: Vec, + low: Vec, + close: Vec, + ) -> napi::Result> { + if open.len() != high.len() || high.len() != low.len() || low.len() != close.len() { + return Err(NapiError::from_reason( + "open, high, low, close must be equal length".to_string(), + )); + } + let mut out = Vec::with_capacity(open.len()); + for i in 0..open.len() { + let candle = + wc::Candle::new(open[i], high[i], low[i], close[i], 0.0, 0).map_err(map_err)?; + out.push(self.inner.update(candle).unwrap_or(f64::NAN)); + } + Ok(out) + } + #[napi] + pub fn reset(&mut self) { + self.inner.reset(); + } + #[napi(js_name = "isReady")] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[napi(js_name = "warmupPeriod")] + pub fn warmup_period(&self) -> u32 { + self.inner.warmup_period() as u32 + } +} + #[napi(object)] pub struct StochValue { pub k: f64, @@ -10334,6 +10718,207 @@ impl TradeImbalanceNode { } } +// Order Flow Imbalance: order-book input with a `period` parameter. +#[napi(js_name = "OrderFlowImbalance")] +pub struct OrderFlowImbalanceNode { + inner: wc::OrderFlowImbalance, +} + +#[napi] +impl OrderFlowImbalanceNode { + #[napi(constructor)] + pub fn new(period: u32) -> napi::Result { + Ok(Self { + inner: wc::OrderFlowImbalance::new(period as usize).map_err(map_err)?, + }) + } + #[napi] + pub fn update( + &mut self, + bid_px: Vec, + bid_sz: Vec, + ask_px: Vec, + ask_sz: Vec, + ) -> napi::Result> { + let book = build_order_book(&bid_px, &bid_sz, &ask_px, &ask_sz)?; + Ok(self.inner.update(book)) + } + #[napi] + pub fn batch(&mut self, snapshots: Vec) -> napi::Result> { + let mut out = Vec::with_capacity(snapshots.len()); + for snap in &snapshots { + let book = build_order_book(&snap.bid_px, &snap.bid_sz, &snap.ask_px, &snap.ask_sz)?; + out.push(self.inner.update(book).unwrap_or(f64::NAN)); + } + Ok(out) + } + #[napi] + pub fn reset(&mut self) { + self.inner.reset(); + } + #[napi(js_name = "isReady")] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[napi(js_name = "warmupPeriod")] + pub fn warmup_period(&self) -> u32 { + self.inner.warmup_period() as u32 + } +} + +// VPIN: trade input, volume-bucketed `(bucket_volume, num_buckets)`. +#[napi(js_name = "Vpin")] +pub struct VpinNode { + inner: wc::Vpin, +} + +#[napi] +impl VpinNode { + #[napi(constructor)] + pub fn new(bucket_volume: f64, num_buckets: u32) -> napi::Result { + Ok(Self { + inner: wc::Vpin::new(bucket_volume, num_buckets as usize).map_err(map_err)?, + }) + } + #[napi] + pub fn update(&mut self, price: f64, size: f64, is_buy: bool) -> napi::Result> { + Ok(self.inner.update(build_trade(price, size, is_buy)?)) + } + #[napi] + pub fn batch( + &mut self, + price: Vec, + size: Vec, + is_buy: Vec, + ) -> napi::Result> { + if price.len() != size.len() || size.len() != is_buy.len() { + return Err(NapiError::from_reason( + "price, size, is_buy must be equal length".to_string(), + )); + } + let mut out = Vec::with_capacity(price.len()); + for i in 0..price.len() { + let trade = build_trade(price[i], size[i], is_buy[i])?; + out.push(self.inner.update(trade).unwrap_or(f64::NAN)); + } + Ok(out) + } + #[napi] + pub fn reset(&mut self) { + self.inner.reset(); + } + #[napi(js_name = "isReady")] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[napi(js_name = "warmupPeriod")] + pub fn warmup_period(&self) -> u32 { + self.inner.warmup_period() as u32 + } +} + +// Amihud Illiquidity: trade input with a `period` parameter. +#[napi(js_name = "AmihudIlliquidity")] +pub struct AmihudIlliquidityNode { + inner: wc::AmihudIlliquidity, +} + +#[napi] +impl AmihudIlliquidityNode { + #[napi(constructor)] + pub fn new(period: u32) -> napi::Result { + Ok(Self { + inner: wc::AmihudIlliquidity::new(period as usize).map_err(map_err)?, + }) + } + #[napi] + pub fn update(&mut self, price: f64, size: f64, is_buy: bool) -> napi::Result> { + Ok(self.inner.update(build_trade(price, size, is_buy)?)) + } + #[napi] + pub fn batch( + &mut self, + price: Vec, + size: Vec, + is_buy: Vec, + ) -> napi::Result> { + if price.len() != size.len() || size.len() != is_buy.len() { + return Err(NapiError::from_reason( + "price, size, is_buy must be equal length".to_string(), + )); + } + let mut out = Vec::with_capacity(price.len()); + for i in 0..price.len() { + let trade = build_trade(price[i], size[i], is_buy[i])?; + out.push(self.inner.update(trade).unwrap_or(f64::NAN)); + } + Ok(out) + } + #[napi] + pub fn reset(&mut self) { + self.inner.reset(); + } + #[napi(js_name = "isReady")] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[napi(js_name = "warmupPeriod")] + pub fn warmup_period(&self) -> u32 { + self.inner.warmup_period() as u32 + } +} + +// Roll Measure: trade input with a `period` parameter. +#[napi(js_name = "RollMeasure")] +pub struct RollMeasureNode { + inner: wc::RollMeasure, +} + +#[napi] +impl RollMeasureNode { + #[napi(constructor)] + pub fn new(period: u32) -> napi::Result { + Ok(Self { + inner: wc::RollMeasure::new(period as usize).map_err(map_err)?, + }) + } + #[napi] + pub fn update(&mut self, price: f64, size: f64, is_buy: bool) -> napi::Result> { + Ok(self.inner.update(build_trade(price, size, is_buy)?)) + } + #[napi] + pub fn batch( + &mut self, + price: Vec, + size: Vec, + is_buy: Vec, + ) -> napi::Result> { + if price.len() != size.len() || size.len() != is_buy.len() { + return Err(NapiError::from_reason( + "price, size, is_buy must be equal length".to_string(), + )); + } + let mut out = Vec::with_capacity(price.len()); + for i in 0..price.len() { + let trade = build_trade(price[i], size[i], is_buy[i])?; + out.push(self.inner.update(trade).unwrap_or(f64::NAN)); + } + Ok(out) + } + #[napi] + pub fn reset(&mut self) { + self.inner.reset(); + } + #[napi(js_name = "isReady")] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[napi(js_name = "warmupPeriod")] + pub fn warmup_period(&self) -> u32 { + self.inner.warmup_period() as u32 + } +} + // ============================== Microstructure: Price Impact ============================== // // Price-impact indicators consume a trade paired with the mid prevailing at diff --git a/bindings/python/python/wickra/__init__.py b/bindings/python/python/wickra/__init__.py index 8be26790..5f678fa3 100644 --- a/bindings/python/python/wickra/__init__.py +++ b/bindings/python/python/wickra/__init__.py @@ -25,6 +25,20 @@ from __future__ import annotations from ._wickra import ( __version__, + Expectancy, + WinRate, + RegimeLabel, + JumpIndicator, + TrendLabel, + HighLowRange, + WickRatio, + BodySizePct, + CloseVsOpen, + RollingQuantile, + RollingPercentileRank, + RollingIqr, + RealizedVolatility, + LogReturn, TSF, LINEARREG_INTERCEPT, ROCR100, @@ -189,6 +203,7 @@ from ._wickra import ( PearsonCorrelation, Beta, PairwiseBeta, + SpreadAr1Coefficient, PairSpreadZScore, LeadLagCrossCorrelation, Cointegration, @@ -351,6 +366,7 @@ from ._wickra import ( FibExtension, FibRetracement, # Microstructure: order book + OrderFlowImbalance, OrderBookImbalanceTop1, OrderBookImbalanceTopN, OrderBookImbalanceFull, @@ -358,6 +374,9 @@ from ._wickra import ( QuotedSpread, DepthSlope, # Microstructure: trade flow + RollMeasure, + AmihudIlliquidity, + Vpin, SignedVolume, CumulativeVolumeDelta, TradeImbalance, @@ -430,6 +449,20 @@ from ._wickra import ( ) __all__ = [ + "Expectancy", + "WinRate", + "RegimeLabel", + "JumpIndicator", + "TrendLabel", + "HighLowRange", + "WickRatio", + "BodySizePct", + "CloseVsOpen", + "RollingQuantile", + "RollingPercentileRank", + "RollingIqr", + "RealizedVolatility", + "LogReturn", "TSF", "LINEARREG_INTERCEPT", "ROCR100", @@ -595,6 +628,7 @@ __all__ = [ "PearsonCorrelation", "Beta", "PairwiseBeta", + "SpreadAr1Coefficient", "PairSpreadZScore", "LeadLagCrossCorrelation", "Cointegration", @@ -757,6 +791,7 @@ __all__ = [ "FibExtension", "FibRetracement", # Microstructure: order book + "OrderFlowImbalance", "OrderBookImbalanceTop1", "OrderBookImbalanceTopN", "OrderBookImbalanceFull", @@ -764,6 +799,9 @@ __all__ = [ "QuotedSpread", "DepthSlope", # Microstructure: trade flow + "RollMeasure", + "AmihudIlliquidity", + "Vpin", "SignedVolume", "CumulativeVolumeDelta", "TradeImbalance", diff --git a/bindings/python/src/lib.rs b/bindings/python/src/lib.rs index 7b50aaa0..72559b7f 100644 --- a/bindings/python/src/lib.rs +++ b/bindings/python/src/lib.rs @@ -1576,6 +1576,784 @@ impl PyHtPhasor { } } +// ============================== LogReturn ============================== + +#[pyclass(name = "LogReturn", module = "wickra._wickra", skip_from_py_object)] +#[derive(Clone)] +struct PyLogReturn { + inner: wc::LogReturn, +} + +#[pymethods] +impl PyLogReturn { + #[new] + #[pyo3(signature = (period=1))] + fn new(period: usize) -> PyResult { + Ok(Self { + inner: wc::LogReturn::new(period).map_err(map_err)?, + }) + } + fn update(&mut self, value: f64) -> Option { + self.inner.update(value) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + prices: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let s = prices + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + Ok(flatten(self.inner.batch(s)).into_pyarray(py)) + } + #[getter] + fn period(&self) -> usize { + self.inner.period() + } + fn reset(&mut self) { + self.inner.reset(); + } + fn is_ready(&self) -> bool { + self.inner.is_ready() + } + fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } + fn __repr__(&self) -> String { + format!("LogReturn(period={})", self.inner.period()) + } +} + +// ============================== RealizedVolatility ============================== + +#[pyclass( + name = "RealizedVolatility", + module = "wickra._wickra", + skip_from_py_object +)] +#[derive(Clone)] +struct PyRealizedVolatility { + inner: wc::RealizedVolatility, +} + +#[pymethods] +impl PyRealizedVolatility { + #[new] + #[pyo3(signature = (period=20))] + fn new(period: usize) -> PyResult { + Ok(Self { + inner: wc::RealizedVolatility::new(period).map_err(map_err)?, + }) + } + fn update(&mut self, value: f64) -> Option { + self.inner.update(value) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + prices: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let s = prices + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + Ok(flatten(self.inner.batch(s)).into_pyarray(py)) + } + #[getter] + fn period(&self) -> usize { + self.inner.period() + } + fn reset(&mut self) { + self.inner.reset(); + } + fn is_ready(&self) -> bool { + self.inner.is_ready() + } + fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } + fn __repr__(&self) -> String { + format!("RealizedVolatility(period={})", self.inner.period()) + } +} + +// ============================== RollingIqr ============================== + +#[pyclass(name = "RollingIqr", module = "wickra._wickra", skip_from_py_object)] +#[derive(Clone)] +struct PyRollingIqr { + inner: wc::RollingIqr, +} + +#[pymethods] +impl PyRollingIqr { + #[new] + #[pyo3(signature = (period=14))] + fn new(period: usize) -> PyResult { + Ok(Self { + inner: wc::RollingIqr::new(period).map_err(map_err)?, + }) + } + fn update(&mut self, value: f64) -> Option { + self.inner.update(value) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + prices: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let s = prices + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + Ok(flatten(self.inner.batch(s)).into_pyarray(py)) + } + #[getter] + fn period(&self) -> usize { + self.inner.period() + } + fn reset(&mut self) { + self.inner.reset(); + } + fn is_ready(&self) -> bool { + self.inner.is_ready() + } + fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } + fn __repr__(&self) -> String { + format!("RollingIqr(period={})", self.inner.period()) + } +} + +// ============================== RollingPercentileRank ============================== + +#[pyclass( + name = "RollingPercentileRank", + module = "wickra._wickra", + skip_from_py_object +)] +#[derive(Clone)] +struct PyRollingPercentileRank { + inner: wc::RollingPercentileRank, +} + +#[pymethods] +impl PyRollingPercentileRank { + #[new] + #[pyo3(signature = (period=14))] + fn new(period: usize) -> PyResult { + Ok(Self { + inner: wc::RollingPercentileRank::new(period).map_err(map_err)?, + }) + } + fn update(&mut self, value: f64) -> Option { + self.inner.update(value) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + prices: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let s = prices + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + Ok(flatten(self.inner.batch(s)).into_pyarray(py)) + } + #[getter] + fn period(&self) -> usize { + self.inner.period() + } + fn reset(&mut self) { + self.inner.reset(); + } + fn is_ready(&self) -> bool { + self.inner.is_ready() + } + fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } + fn __repr__(&self) -> String { + format!("RollingPercentileRank(period={})", self.inner.period()) + } +} + +// ============================== RollingQuantile ============================== + +#[pyclass( + name = "RollingQuantile", + module = "wickra._wickra", + skip_from_py_object +)] +#[derive(Clone)] +struct PyRollingQuantile { + inner: wc::RollingQuantile, +} + +#[pymethods] +impl PyRollingQuantile { + #[new] + #[pyo3(signature = (period=20, quantile=0.5))] + fn new(period: usize, quantile: f64) -> PyResult { + Ok(Self { + inner: wc::RollingQuantile::new(period, quantile).map_err(map_err)?, + }) + } + fn update(&mut self, value: f64) -> Option { + self.inner.update(value) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + prices: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let s = prices + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + Ok(flatten(self.inner.batch(s)).into_pyarray(py)) + } + #[getter] + fn period(&self) -> usize { + self.inner.period() + } + #[getter] + fn quantile(&self) -> f64 { + self.inner.quantile() + } + fn reset(&mut self) { + self.inner.reset(); + } + fn is_ready(&self) -> bool { + self.inner.is_ready() + } + fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } + fn __repr__(&self) -> String { + format!( + "RollingQuantile(period={}, quantile={})", + self.inner.period(), + self.inner.quantile() + ) + } +} + +// ============================== CloseVsOpen ============================== + +#[pyclass(name = "CloseVsOpen", module = "wickra._wickra", skip_from_py_object)] +#[derive(Clone)] +struct PyCloseVsOpen { + inner: wc::CloseVsOpen, +} + +#[pymethods] +impl PyCloseVsOpen { + #[new] + fn new() -> Self { + Self { + inner: wc::CloseVsOpen::new(), + } + } + fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult> { + let c = extract_candle(candle)?; + Ok(self.inner.update(c)) + } + /// Batch over numpy columns: open, high, low, close (all 1-D, equal length). + fn batch<'py>( + &mut self, + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let o = open + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let h = high + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let l = low + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let c = close + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + if o.len() != h.len() || h.len() != l.len() || l.len() != c.len() { + return Err(PyValueError::new_err( + "open, high, low, close must be equal length", + )); + } + let mut out = Vec::with_capacity(o.len()); + for i in 0..o.len() { + let candle = wc::Candle::new(o[i], h[i], l[i], c[i], 0.0, 0).map_err(map_err)?; + out.push(self.inner.update(candle).unwrap_or(f64::NAN)); + } + Ok(out.into_pyarray(py)) + } + fn reset(&mut self) { + self.inner.reset(); + } + fn is_ready(&self) -> bool { + self.inner.is_ready() + } + fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } + fn __repr__(&self) -> String { + "CloseVsOpen()".to_string() + } +} + +// ============================== BodySizePct ============================== + +#[pyclass(name = "BodySizePct", module = "wickra._wickra", skip_from_py_object)] +#[derive(Clone)] +struct PyBodySizePct { + inner: wc::BodySizePct, +} + +#[pymethods] +impl PyBodySizePct { + #[new] + fn new() -> Self { + Self { + inner: wc::BodySizePct::new(), + } + } + fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult> { + let c = extract_candle(candle)?; + Ok(self.inner.update(c)) + } + /// Batch over numpy columns: open, high, low, close (all 1-D, equal length). + fn batch<'py>( + &mut self, + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let o = open + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let h = high + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let l = low + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let c = close + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + if o.len() != h.len() || h.len() != l.len() || l.len() != c.len() { + return Err(PyValueError::new_err( + "open, high, low, close must be equal length", + )); + } + let mut out = Vec::with_capacity(o.len()); + for i in 0..o.len() { + let candle = wc::Candle::new(o[i], h[i], l[i], c[i], 0.0, 0).map_err(map_err)?; + out.push(self.inner.update(candle).unwrap_or(f64::NAN)); + } + Ok(out.into_pyarray(py)) + } + fn reset(&mut self) { + self.inner.reset(); + } + fn is_ready(&self) -> bool { + self.inner.is_ready() + } + fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } + fn __repr__(&self) -> String { + "BodySizePct()".to_string() + } +} + +// ============================== WickRatio ============================== + +#[pyclass(name = "WickRatio", module = "wickra._wickra", skip_from_py_object)] +#[derive(Clone)] +struct PyWickRatio { + inner: wc::WickRatio, +} + +#[pymethods] +impl PyWickRatio { + #[new] + fn new() -> Self { + Self { + inner: wc::WickRatio::new(), + } + } + fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult> { + let c = extract_candle(candle)?; + Ok(self.inner.update(c)) + } + /// Batch over numpy columns: open, high, low, close (all 1-D, equal length). + fn batch<'py>( + &mut self, + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let o = open + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let h = high + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let l = low + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let c = close + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + if o.len() != h.len() || h.len() != l.len() || l.len() != c.len() { + return Err(PyValueError::new_err( + "open, high, low, close must be equal length", + )); + } + let mut out = Vec::with_capacity(o.len()); + for i in 0..o.len() { + let candle = wc::Candle::new(o[i], h[i], l[i], c[i], 0.0, 0).map_err(map_err)?; + out.push(self.inner.update(candle).unwrap_or(f64::NAN)); + } + Ok(out.into_pyarray(py)) + } + fn reset(&mut self) { + self.inner.reset(); + } + fn is_ready(&self) -> bool { + self.inner.is_ready() + } + fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } + fn __repr__(&self) -> String { + "WickRatio()".to_string() + } +} + +// ============================== HighLowRange ============================== + +#[pyclass(name = "HighLowRange", module = "wickra._wickra", skip_from_py_object)] +#[derive(Clone)] +struct PyHighLowRange { + inner: wc::HighLowRange, +} + +#[pymethods] +impl PyHighLowRange { + #[new] + fn new() -> Self { + Self { + inner: wc::HighLowRange::new(), + } + } + fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult> { + let c = extract_candle(candle)?; + Ok(self.inner.update(c)) + } + /// Batch over numpy columns: open, high, low, close (all 1-D, equal length). + fn batch<'py>( + &mut self, + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let o = open + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let h = high + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let l = low + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let c = close + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + if o.len() != h.len() || h.len() != l.len() || l.len() != c.len() { + return Err(PyValueError::new_err( + "open, high, low, close must be equal length", + )); + } + let mut out = Vec::with_capacity(o.len()); + for i in 0..o.len() { + let candle = wc::Candle::new(o[i], h[i], l[i], c[i], 0.0, 0).map_err(map_err)?; + out.push(self.inner.update(candle).unwrap_or(f64::NAN)); + } + Ok(out.into_pyarray(py)) + } + fn reset(&mut self) { + self.inner.reset(); + } + fn is_ready(&self) -> bool { + self.inner.is_ready() + } + fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } + fn __repr__(&self) -> String { + "HighLowRange()".to_string() + } +} + +// ============================== TrendLabel ============================== + +#[pyclass(name = "TrendLabel", module = "wickra._wickra", skip_from_py_object)] +#[derive(Clone)] +struct PyTrendLabel { + inner: wc::TrendLabel, +} + +#[pymethods] +impl PyTrendLabel { + #[new] + #[pyo3(signature = (period=10))] + fn new(period: usize) -> PyResult { + Ok(Self { + inner: wc::TrendLabel::new(period).map_err(map_err)?, + }) + } + fn update(&mut self, value: f64) -> Option { + self.inner.update(value) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + prices: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let s = prices + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + Ok(flatten(self.inner.batch(s)).into_pyarray(py)) + } + #[getter] + fn period(&self) -> usize { + self.inner.period() + } + fn reset(&mut self) { + self.inner.reset(); + } + fn is_ready(&self) -> bool { + self.inner.is_ready() + } + fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } + fn __repr__(&self) -> String { + format!("TrendLabel(period={})", self.inner.period()) + } +} + +// ============================== JumpIndicator ============================== + +#[pyclass(name = "JumpIndicator", module = "wickra._wickra", skip_from_py_object)] +#[derive(Clone)] +struct PyJumpIndicator { + inner: wc::JumpIndicator, +} + +#[pymethods] +impl PyJumpIndicator { + #[new] + #[pyo3(signature = (period=20, threshold=3.0))] + fn new(period: usize, threshold: f64) -> PyResult { + Ok(Self { + inner: wc::JumpIndicator::new(period, threshold).map_err(map_err)?, + }) + } + fn update(&mut self, value: f64) -> Option { + self.inner.update(value) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + prices: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let s = prices + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + Ok(flatten(self.inner.batch(s)).into_pyarray(py)) + } + #[getter] + fn period(&self) -> usize { + self.inner.params().0 + } + #[getter] + fn threshold(&self) -> f64 { + self.inner.params().1 + } + fn reset(&mut self) { + self.inner.reset(); + } + fn is_ready(&self) -> bool { + self.inner.is_ready() + } + fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } + fn __repr__(&self) -> String { + let (period, threshold) = self.inner.params(); + format!("JumpIndicator(period={period}, threshold={threshold})") + } +} + +// ============================== RegimeLabel ============================== + +#[pyclass(name = "RegimeLabel", module = "wickra._wickra", skip_from_py_object)] +#[derive(Clone)] +struct PyRegimeLabel { + inner: wc::RegimeLabel, +} + +#[pymethods] +impl PyRegimeLabel { + #[new] + #[pyo3(signature = (vol_period=5, lookback=20))] + fn new(vol_period: usize, lookback: usize) -> PyResult { + Ok(Self { + inner: wc::RegimeLabel::new(vol_period, lookback).map_err(map_err)?, + }) + } + fn update(&mut self, value: f64) -> Option { + self.inner.update(value) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + prices: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let s = prices + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + Ok(flatten(self.inner.batch(s)).into_pyarray(py)) + } + #[getter] + fn vol_period(&self) -> usize { + self.inner.params().0 + } + #[getter] + fn lookback(&self) -> usize { + self.inner.params().1 + } + fn reset(&mut self) { + self.inner.reset(); + } + fn is_ready(&self) -> bool { + self.inner.is_ready() + } + fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } + fn __repr__(&self) -> String { + let (vol_period, lookback) = self.inner.params(); + format!("RegimeLabel(vol_period={vol_period}, lookback={lookback})") + } +} + +// ============================== WinRate ============================== + +#[pyclass(name = "WinRate", module = "wickra._wickra", skip_from_py_object)] +#[derive(Clone)] +struct PyWinRate { + inner: wc::WinRate, +} + +#[pymethods] +impl PyWinRate { + #[new] + #[pyo3(signature = (period=20))] + fn new(period: usize) -> PyResult { + Ok(Self { + inner: wc::WinRate::new(period).map_err(map_err)?, + }) + } + fn update(&mut self, value: f64) -> Option { + self.inner.update(value) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + prices: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let s = prices + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + Ok(flatten(self.inner.batch(s)).into_pyarray(py)) + } + #[getter] + fn period(&self) -> usize { + self.inner.period() + } + fn reset(&mut self) { + self.inner.reset(); + } + fn is_ready(&self) -> bool { + self.inner.is_ready() + } + fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } + fn __repr__(&self) -> String { + format!("WinRate(period={})", self.inner.period()) + } +} + +// ============================== Expectancy ============================== + +#[pyclass(name = "Expectancy", module = "wickra._wickra", skip_from_py_object)] +#[derive(Clone)] +struct PyExpectancy { + inner: wc::Expectancy, +} + +#[pymethods] +impl PyExpectancy { + #[new] + #[pyo3(signature = (period=20))] + fn new(period: usize) -> PyResult { + Ok(Self { + inner: wc::Expectancy::new(period).map_err(map_err)?, + }) + } + fn update(&mut self, value: f64) -> Option { + self.inner.update(value) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + prices: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let s = prices + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + Ok(flatten(self.inner.batch(s)).into_pyarray(py)) + } + #[getter] + fn period(&self) -> usize { + self.inner.period() + } + fn reset(&mut self) { + self.inner.reset(); + } + fn is_ready(&self) -> bool { + self.inner.is_ready() + } + fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } + fn __repr__(&self) -> String { + format!("Expectancy(period={})", self.inner.period()) + } +} + // ============================== Stochastic ============================== #[pyclass(name = "Stochastic", module = "wickra._wickra", skip_from_py_object)] @@ -11936,6 +12714,70 @@ impl PyPairwiseBeta { } } +// ============================== SpreadAr1Coefficient ============================== + +#[pyclass( + name = "SpreadAr1Coefficient", + module = "wickra._wickra", + skip_from_py_object +)] +#[derive(Clone)] +struct PySpreadAr1Coefficient { + inner: wc::SpreadAr1Coefficient, +} + +#[pymethods] +impl PySpreadAr1Coefficient { + #[new] + #[pyo3(signature = (period=40))] + fn new(period: usize) -> PyResult { + Ok(Self { + inner: wc::SpreadAr1Coefficient::new(period).map_err(map_err)?, + }) + } + fn update(&mut self, a: f64, b: f64) -> Option { + self.inner.update((a, b)) + } + /// Batch over two equally-sized numpy arrays of prices: `a` and `b`. + fn batch<'py>( + &mut self, + py: Python<'py>, + a: PyReadonlyArray1<'py, f64>, + b: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let xs = a + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let ys = b + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + if xs.len() != ys.len() { + return Err(PyValueError::new_err("a and b must be equal length")); + } + let mut out = Vec::with_capacity(xs.len()); + for i in 0..xs.len() { + out.push(self.inner.update((xs[i], ys[i])).unwrap_or(f64::NAN)); + } + Ok(out.into_pyarray(py)) + } + #[getter] + fn period(&self) -> usize { + self.inner.period() + } + fn reset(&mut self) { + self.inner.reset(); + } + fn is_ready(&self) -> bool { + self.inner.is_ready() + } + fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } + fn __repr__(&self) -> String { + format!("SpreadAr1Coefficient(period={})", self.inner.period()) + } +} + // ============================== PairSpreadZScore ============================== #[pyclass( @@ -13981,6 +14823,221 @@ impl PyTradeImbalance { } } +// Order Flow Imbalance carries a `period` parameter and an order-book input, +// so it is hand-written. +#[pyclass( + name = "OrderFlowImbalance", + module = "wickra._wickra", + skip_from_py_object +)] +#[derive(Clone)] +struct PyOrderFlowImbalance { + inner: wc::OrderFlowImbalance, +} + +#[pymethods] +impl PyOrderFlowImbalance { + #[new] + fn new(period: usize) -> PyResult { + Ok(Self { + inner: wc::OrderFlowImbalance::new(period).map_err(map_err)?, + }) + } + fn update( + &mut self, + bid_px: Vec, + bid_sz: Vec, + ask_px: Vec, + ask_sz: Vec, + ) -> PyResult> { + let book = build_order_book(&bid_px, &bid_sz, &ask_px, &ask_sz)?; + Ok(self.inner.update(book)) + } + #[allow(clippy::type_complexity)] + fn batch<'py>( + &mut self, + py: Python<'py>, + snapshots: Vec<(Vec, Vec, Vec, Vec)>, + ) -> PyResult>> { + let mut out = Vec::with_capacity(snapshots.len()); + for (bid_px, bid_sz, ask_px, ask_sz) in &snapshots { + let book = build_order_book(bid_px, bid_sz, ask_px, ask_sz)?; + out.push(self.inner.update(book).unwrap_or(f64::NAN)); + } + Ok(out.into_pyarray(py)) + } + fn reset(&mut self) { + self.inner.reset(); + } + fn is_ready(&self) -> bool { + self.inner.is_ready() + } + fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } + fn __repr__(&self) -> String { + format!("OrderFlowImbalance(period={})", self.inner.period()) + } +} + +// VPIN buckets trades by volume; it carries `(bucket_volume, num_buckets)`. +#[pyclass(name = "Vpin", module = "wickra._wickra", skip_from_py_object)] +#[derive(Clone)] +struct PyVpin { + inner: wc::Vpin, +} + +#[pymethods] +impl PyVpin { + #[new] + fn new(bucket_volume: f64, num_buckets: usize) -> PyResult { + Ok(Self { + inner: wc::Vpin::new(bucket_volume, num_buckets).map_err(map_err)?, + }) + } + fn update(&mut self, price: f64, size: f64, is_buy: bool) -> PyResult> { + Ok(self.inner.update(build_trade(price, size, is_buy)?)) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + price: Vec, + size: Vec, + is_buy: Vec, + ) -> PyResult>> { + if price.len() != size.len() || size.len() != is_buy.len() { + return Err(PyValueError::new_err( + "price, size, is_buy must be equal length", + )); + } + let mut out = Vec::with_capacity(price.len()); + for i in 0..price.len() { + let trade = build_trade(price[i], size[i], is_buy[i])?; + out.push(self.inner.update(trade).unwrap_or(f64::NAN)); + } + Ok(out.into_pyarray(py)) + } + fn reset(&mut self) { + self.inner.reset(); + } + fn is_ready(&self) -> bool { + self.inner.is_ready() + } + fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } + fn __repr__(&self) -> String { + let (bucket_volume, num_buckets) = self.inner.params(); + format!("Vpin(bucket_volume={bucket_volume}, num_buckets={num_buckets})") + } +} + +// Amihud illiquidity carries a `period` parameter and a trade input. +#[pyclass( + name = "AmihudIlliquidity", + module = "wickra._wickra", + skip_from_py_object +)] +#[derive(Clone)] +struct PyAmihudIlliquidity { + inner: wc::AmihudIlliquidity, +} + +#[pymethods] +impl PyAmihudIlliquidity { + #[new] + fn new(period: usize) -> PyResult { + Ok(Self { + inner: wc::AmihudIlliquidity::new(period).map_err(map_err)?, + }) + } + fn update(&mut self, price: f64, size: f64, is_buy: bool) -> PyResult> { + Ok(self.inner.update(build_trade(price, size, is_buy)?)) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + price: Vec, + size: Vec, + is_buy: Vec, + ) -> PyResult>> { + if price.len() != size.len() || size.len() != is_buy.len() { + return Err(PyValueError::new_err( + "price, size, is_buy must be equal length", + )); + } + let mut out = Vec::with_capacity(price.len()); + for i in 0..price.len() { + let trade = build_trade(price[i], size[i], is_buy[i])?; + out.push(self.inner.update(trade).unwrap_or(f64::NAN)); + } + Ok(out.into_pyarray(py)) + } + fn reset(&mut self) { + self.inner.reset(); + } + fn is_ready(&self) -> bool { + self.inner.is_ready() + } + fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } + fn __repr__(&self) -> String { + format!("AmihudIlliquidity(period={})", self.inner.period()) + } +} + +// Roll measure carries a `period` parameter and a trade input. +#[pyclass(name = "RollMeasure", module = "wickra._wickra", skip_from_py_object)] +#[derive(Clone)] +struct PyRollMeasure { + inner: wc::RollMeasure, +} + +#[pymethods] +impl PyRollMeasure { + #[new] + fn new(period: usize) -> PyResult { + Ok(Self { + inner: wc::RollMeasure::new(period).map_err(map_err)?, + }) + } + fn update(&mut self, price: f64, size: f64, is_buy: bool) -> PyResult> { + Ok(self.inner.update(build_trade(price, size, is_buy)?)) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + price: Vec, + size: Vec, + is_buy: Vec, + ) -> PyResult>> { + if price.len() != size.len() || size.len() != is_buy.len() { + return Err(PyValueError::new_err( + "price, size, is_buy must be equal length", + )); + } + let mut out = Vec::with_capacity(price.len()); + for i in 0..price.len() { + let trade = build_trade(price[i], size[i], is_buy[i])?; + out.push(self.inner.update(trade).unwrap_or(f64::NAN)); + } + Ok(out.into_pyarray(py)) + } + fn reset(&mut self) { + self.inner.reset(); + } + fn is_ready(&self) -> bool { + self.inner.is_ready() + } + fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } + fn __repr__(&self) -> String { + format!("RollMeasure(period={})", self.inner.period()) + } +} + // ============================== Microstructure: Price Impact ============================== // // Price-impact indicators consume a trade paired with the mid prevailing at @@ -18757,6 +19814,7 @@ fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; m.add_class::()?; m.add_class::()?; + m.add_class::()?; m.add_class::()?; m.add_class::()?; m.add_class::()?; @@ -18852,6 +19910,10 @@ fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; m.add_class::()?; m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; // Microstructure: price impact. m.add_class::()?; m.add_class::()?; @@ -18955,5 +20017,19 @@ fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; m.add_class::()?; m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; Ok(()) } diff --git a/bindings/python/tests/test_new_indicators.py b/bindings/python/tests/test_new_indicators.py index eaf60b45..3ec35522 100644 --- a/bindings/python/tests/test_new_indicators.py +++ b/bindings/python/tests/test_new_indicators.py @@ -45,6 +45,16 @@ def ohlcv() -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: # --- Scalar (f64 -> f64) indicators --------------------------------------- SCALAR = [ + (ta.Expectancy, (20,)), + (ta.WinRate, (20,)), + (ta.RegimeLabel, (5, 20)), + (ta.JumpIndicator, (20, 3.0)), + (ta.TrendLabel, (10,)), + (ta.RollingQuantile, (20, 0.5)), + (ta.RollingPercentileRank, (14,)), + (ta.RollingIqr, (14,)), + (ta.RealizedVolatility, (20,)), + (ta.LogReturn, (1,)), (ta.TSF, (14,)), (ta.LINEARREG_INTERCEPT, (14,)), (ta.ROCR100, (10,)), @@ -167,6 +177,7 @@ def test_scalar_streaming_matches_batch(cls, args, sine_prices): # --- Two-series (asset, benchmark) indicators ----------------------------- PAIR = [ + (ta.SpreadAr1Coefficient, (40,)), (ta.GrangerCausality, (60, 1)), (ta.VarianceRatio, (60, 2)), (ta.BetaNeutralSpread, (20,)), @@ -330,6 +341,12 @@ def test_relative_strength_streaming_matches_batch(): # 6-tuple candle; the batch helper takes only the columns it needs. CANDLE_SCALAR = { + # Per-bar OHLC transforms (open matters). The streaming harness feeds + # open == close, so batch passes the close column in for open to match. + "HighLowRange": (lambda: ta.HighLowRange(), lambda ind, h, l, c, v: ind.batch(c, h, l, c)), + "WickRatio": (lambda: ta.WickRatio(), lambda ind, h, l, c, v: ind.batch(c, h, l, c)), + "BodySizePct": (lambda: ta.BodySizePct(), lambda ind, h, l, c, v: ind.batch(c, h, l, c)), + "CloseVsOpen": (lambda: ta.CloseVsOpen(), lambda ind, h, l, c, v: ind.batch(c, h, l, c)), "ThreeDrives": ( lambda: ta.ThreeDrives(), lambda ind, h, l, c, v: ind.batch(c, h, l, c), @@ -2707,6 +2724,16 @@ 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) + # --- Lifecycle ------------------------------------------------------------ @@ -3024,6 +3051,7 @@ def test_orderbook_indicators_streaming_equals_batch(): ta.Microprice, ta.QuotedSpread, ta.DepthSlope, + lambda: ta.OrderFlowImbalance(10), ): batch = make().batch(snaps) streamer = make() @@ -3043,6 +3071,9 @@ def test_tradeflow_indicators_streaming_equals_batch(): ta.SignedVolume, ta.CumulativeVolumeDelta, lambda: ta.TradeImbalance(5), + lambda: ta.Vpin(8.0, 5), + lambda: ta.AmihudIlliquidity(14), + lambda: ta.RollMeasure(14), ): batch = make().batch(price, size, is_buy) streamer = make() diff --git a/bindings/wasm/src/lib.rs b/bindings/wasm/src/lib.rs index 2e72fcfe..5e362ae4 100644 --- a/bindings/wasm/src/lib.rs +++ b/bindings/wasm/src/lib.rs @@ -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,210 @@ 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, 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 { + 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, 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 { + 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, 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 { + 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, 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 { + 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 = Stochastic)] pub struct WasmStoch { inner: wc::Stochastic, @@ -7596,6 +7805,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 { + 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, 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 { + 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, 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 { + Ok(Self { + inner: wc::AmihudIlliquidity::new(period).map_err(map_err)?, + }) + } + pub fn update(&mut self, price: f64, size: f64, is_buy: bool) -> Result, 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 { + Ok(Self { + inner: wc::RollMeasure::new(period).map_err(map_err)?, + }) + } + pub fn update(&mut self, price: f64, size: f64, is_buy: bool) -> Result, 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 +10203,16 @@ 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); // --- DrawdownDuration: u32 output, no constructor args --- diff --git a/crates/wickra-core/src/indicators/amihud_illiquidity.rs b/crates/wickra-core/src/indicators/amihud_illiquidity.rs new file mode 100644 index 00000000..cef52a14 --- /dev/null +++ b/crates/wickra-core/src/indicators/amihud_illiquidity.rs @@ -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, + window: VecDeque, + sum: f64, + last: Option, +} + +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 { + 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 { + // 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 = (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 = (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); + } +} diff --git a/crates/wickra-core/src/indicators/body_size_pct.rs b/crates/wickra-core/src/indicators/body_size_pct.rs new file mode 100644 index 00000000..478bd76a --- /dev/null +++ b/crates/wickra-core/src/indicators/body_size_pct.rs @@ -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 { + 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 = (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 = (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::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/close_vs_open.rs b/crates/wickra-core/src/indicators/close_vs_open.rs new file mode 100644 index 00000000..eee8f74f --- /dev/null +++ b/crates/wickra-core/src/indicators/close_vs_open.rs @@ -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 { + 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 = (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::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/expectancy.rs b/crates/wickra-core/src/indicators/expectancy.rs new file mode 100644 index 00000000..f15ff2fd --- /dev/null +++ b/crates/wickra-core/src/indicators/expectancy.rs @@ -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, + 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 { + 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 { + 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 = (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); + } +} diff --git a/crates/wickra-core/src/indicators/high_low_range.rs b/crates/wickra-core/src/indicators/high_low_range.rs new file mode 100644 index 00000000..2f3fc53a --- /dev/null +++ b/crates/wickra-core/src/indicators/high_low_range.rs @@ -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 { + 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 = (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 = (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::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/jump_indicator.rs b/crates/wickra-core/src/indicators/jump_indicator.rs new file mode 100644 index 00000000..3521b35d --- /dev/null +++ b/crates/wickra-core/src/indicators/jump_indicator.rs @@ -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, + /// Trailing window of the `period` returns preceding the current one. + window: VecDeque, + sum: f64, + sum_sq: f64, + last: Option, +} + +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 { + 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 { + 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 = (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 = (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 = (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 = (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 = (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::>()); + 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 = (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); + } +} diff --git a/crates/wickra-core/src/indicators/log_return.rs b/crates/wickra-core/src/indicators/log_return.rs new file mode 100644 index 00000000..9758b6db --- /dev/null +++ b/crates/wickra-core/src/indicators/log_return.rs @@ -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_{t−period})`. +/// +/// 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_{t−period}) +/// ``` +/// +/// 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, + last: Option, +} + +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 { + 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 { + // 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 = (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); + } +} diff --git a/crates/wickra-core/src/indicators/mod.rs b/crates/wickra-core/src/indicators/mod.rs index a896e424..528f34d0 100644 --- a/crates/wickra-core/src/indicators/mod.rs +++ b/crates/wickra-core/src/indicators/mod.rs @@ -26,6 +26,7 @@ mod adxr; mod alligator; mod alma; mod alpha; +mod amihud_illiquidity; mod anchored_rsi; mod anchored_vwap; mod apo; @@ -46,6 +47,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 +66,7 @@ mod chande_kroll_stop; mod chandelier_exit; mod choppiness_index; mod classic_pivots; +mod close_vs_open; mod closing_marubozu; mod cmf; mod cmo; @@ -109,6 +112,7 @@ mod empirical_mode_decomposition; mod engulfing; mod evening_doji_star; mod evwma; +mod expectancy; mod falling_three_methods; mod fama; mod fib_arcs; @@ -143,6 +147,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; @@ -167,6 +172,7 @@ 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 +193,7 @@ mod linreg_channel; mod linreg_intercept; mod linreg_slope; mod liquidation_features; +mod log_return; mod long_legged_doji; mod long_line; mod long_short_ratio; @@ -229,6 +236,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; @@ -253,8 +261,10 @@ mod pvi; mod quoted_spread; mod r_squared; mod realized_spread; +mod realized_volatility; mod recovery_factor; mod rectangle_range; +mod regime_label; mod relative_strength_ab; mod renko_bars; mod renko_trailing_stop; @@ -265,8 +275,12 @@ mod rocp; mod rocr; mod rocr100; mod rogers_satchell; +mod roll_measure; mod rolling_correlation; mod rolling_covariance; +mod rolling_iqr; +mod rolling_percentile_rank; +mod rolling_quantile; mod roofing_filter; mod rsi; mod rvi; @@ -291,6 +305,7 @@ mod smma; mod sortino_ratio; mod spearman_correlation; mod spinning_top; +mod spread_ar1_coefficient; mod spread_bollinger_bands; mod spread_hurst; mod stalled_pattern; @@ -335,6 +350,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 +383,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 +392,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; @@ -403,6 +422,7 @@ pub use adxr::Adxr; pub use alligator::{Alligator, AlligatorOutput}; pub use alma::Alma; pub use alpha::Alpha; +pub use amihud_illiquidity::AmihudIlliquidity; pub use anchored_rsi::AnchoredRsi; pub use anchored_vwap::AnchoredVwap; pub use apo::Apo; @@ -423,6 +443,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 +462,7 @@ pub use chande_kroll_stop::{ChandeKrollStop, ChandeKrollStopOutput}; pub use chandelier_exit::{ChandelierExit, ChandelierExitOutput}; pub use choppiness_index::ChoppinessIndex; pub use classic_pivots::{ClassicPivots, ClassicPivotsOutput}; +pub use close_vs_open::CloseVsOpen; pub use closing_marubozu::ClosingMarubozu; pub use cmf::ChaikinMoneyFlow; pub use cmo::Cmo; @@ -486,6 +508,7 @@ 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}; @@ -520,6 +543,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; @@ -544,6 +568,7 @@ pub use intraday_volatility_profile::{IntradayVolatilityProfile, IntradayVolatil 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 +589,7 @@ pub use linreg_channel::{LinRegChannel, LinRegChannelOutput}; pub use linreg_intercept::LinRegIntercept; pub use linreg_slope::LinRegSlope; pub use liquidation_features::{LiquidationFeatures, LiquidationFeaturesOutput}; +pub use log_return::LogReturn; pub use long_legged_doji::LongLeggedDoji; pub use long_line::LongLine; pub use long_short_ratio::LongShortRatio; @@ -606,6 +632,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}; @@ -630,8 +657,10 @@ pub use pvi::Pvi; pub use quoted_spread::QuotedSpread; pub use r_squared::RSquared; pub use realized_spread::RealizedSpread; +pub use realized_volatility::RealizedVolatility; pub use recovery_factor::RecoveryFactor; pub use rectangle_range::RectangleRange; +pub use regime_label::RegimeLabel; pub use relative_strength_ab::{RelativeStrengthAB, RelativeStrengthOutput}; pub use renko_bars::{RenkoBars, RenkoBrick}; pub use renko_trailing_stop::RenkoTrailingStop; @@ -642,8 +671,12 @@ pub use rocp::Rocp; pub use rocr::Rocr; pub use rocr100::Rocr100; pub use rogers_satchell::RogersSatchellVolatility; +pub use roll_measure::RollMeasure; pub use rolling_correlation::RollingCorrelation; pub use rolling_covariance::RollingCovariance; +pub use rolling_iqr::RollingIqr; +pub use rolling_percentile_rank::RollingPercentileRank; +pub use rolling_quantile::RollingQuantile; pub use roofing_filter::RoofingFilter; pub use rsi::Rsi; pub use rvi::Rvi; @@ -668,6 +701,7 @@ pub use smma::Smma; pub use sortino_ratio::SortinoRatio; pub use spearman_correlation::SpearmanCorrelation; pub use spinning_top::SpinningTop; +pub use spread_ar1_coefficient::SpreadAr1Coefficient; pub use spread_bollinger_bands::{SpreadBollingerBands, SpreadBollingerBandsOutput}; pub use spread_hurst::SpreadHurst; pub use stalled_pattern::StalledPattern; @@ -712,6 +746,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 +779,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 +788,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; @@ -846,6 +884,7 @@ pub const FAMILIES: &[(&str, &[&str])] = &[ "PlusDi", "MinusDi", "Dx", + "TrendLabel", ], ), ( @@ -884,6 +923,8 @@ pub const FAMILIES: &[(&str, &[&str])] = &[ "GarmanKlassVolatility", "RogersSatchellVolatility", "YangZhangVolatility", + "JumpIndicator", + "RegimeLabel", ], ), ( @@ -987,6 +1028,16 @@ pub const FAMILIES: &[(&str, &[&str])] = &[ "GrangerCausality", "KalmanHedgeRatio", "SpreadBollingerBands", + "LogReturn", + "RealizedVolatility", + "RollingIqr", + "RollingPercentileRank", + "RollingQuantile", + "SpreadAr1Coefficient", + "CloseVsOpen", + "BodySizePct", + "WickRatio", + "HighLowRange", ], ), ( @@ -1124,6 +1175,10 @@ pub const FAMILIES: &[(&str, &[&str])] = &[ "RealizedSpread", "KylesLambda", "Footprint", + "OrderFlowImbalance", + "Vpin", + "AmihudIlliquidity", + "RollMeasure", ], ), ( @@ -1173,6 +1228,8 @@ pub const FAMILIES: &[(&str, &[&str])] = &[ "TreynorRatio", "InformationRatio", "Alpha", + "WinRate", + "Expectancy", ], ), ( @@ -1285,6 +1342,6 @@ mod family_tests { // the actual indicator count is the early-warning signal that an // indicator was added without being assigned a family. let total: usize = FAMILIES.iter().map(|(_, ns)| ns.len()).sum(); - assert_eq!(total, 377, "FAMILIES total drifted from indicator count"); + assert_eq!(total, 396, "FAMILIES total drifted from indicator count"); } } diff --git a/crates/wickra-core/src/indicators/order_flow_imbalance.rs b/crates/wickra-core/src/indicators/order_flow_imbalance.rs new file mode 100644 index 00000000..ae6158a5 --- /dev/null +++ b/crates/wickra-core/src/indicators/order_flow_imbalance.rs @@ -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, + 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 { + 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 { + // 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 = (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); + } +} diff --git a/crates/wickra-core/src/indicators/realized_volatility.rs b/crates/wickra-core/src/indicators/realized_volatility.rs new file mode 100644 index 00000000..a1b64e7c --- /dev/null +++ b/crates/wickra-core/src/indicators/realized_volatility.rs @@ -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_{t−1}) +/// 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, + /// Rolling window of the last `period` log returns. + window: VecDeque, + sum_sq: f64, + last: Option, +} + +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 { + 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 { + // 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::>()); + 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 = (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::>()); + 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::>()); + 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::>()); + 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 = (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); + } +} diff --git a/crates/wickra-core/src/indicators/regime_label.rs b/crates/wickra-core/src/indicators/regime_label.rs new file mode 100644 index 00000000..27d5e96f --- /dev/null +++ b/crates/wickra-core/src/indicators/regime_label.rs @@ -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, + /// Trailing window of the last `vol_period` log returns. + ret_window: VecDeque, + ret_sum: f64, + ret_sum_sq: f64, + /// Trailing window of the last `lookback` volatility readings. + vol_window: VecDeque, + /// Reusable scratch buffer for the quantile sort. + scratch: Vec, + last: Option, +} + +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 { + 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 { + 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 = (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 = 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 = (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 = (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::>()); + 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 = (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); + } +} diff --git a/crates/wickra-core/src/indicators/roll_measure.rs b/crates/wickra-core/src/indicators/roll_measure.rs new file mode 100644 index 00000000..55a32eb6 --- /dev/null +++ b/crates/wickra-core/src/indicators/roll_measure.rs @@ -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, + window: VecDeque, +} + +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 { + 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 { + 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 = self.window.iter().copied().collect(); + let count = changes.len() as f64; + let mean = changes.iter().sum::() / 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 = (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 = (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 = (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 = (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); + } +} diff --git a/crates/wickra-core/src/indicators/rolling_iqr.rs b/crates/wickra-core/src/indicators/rolling_iqr.rs new file mode 100644 index 00000000..b2293fda --- /dev/null +++ b/crates/wickra-core/src/indicators/rolling_iqr.rs @@ -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, + /// Reusable scratch buffer to avoid allocating per `update`. + scratch: Vec, +} + +impl RollingIqr { + /// Construct a new rolling IQR with the given period. + /// + /// # Errors + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + 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 { + 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 = (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 = (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); + } +} diff --git a/crates/wickra-core/src/indicators/rolling_percentile_rank.rs b/crates/wickra-core/src/indicators/rolling_percentile_rank.rs new file mode 100644 index 00000000..f599398d --- /dev/null +++ b/crates/wickra-core/src/indicators/rolling_percentile_rank.rs @@ -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, +} + +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 { + 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 { + 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 = (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 = (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); + } +} diff --git a/crates/wickra-core/src/indicators/rolling_quantile.rs b/crates/wickra-core/src/indicators/rolling_quantile.rs new file mode 100644 index 00000000..47cfc223 --- /dev/null +++ b/crates/wickra-core/src/indicators/rolling_quantile.rs @@ -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, + /// Reusable scratch buffer to avoid allocating per `update`. + scratch: Vec, +} + +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 { + 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 { + 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 = (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); + } +} diff --git a/crates/wickra-core/src/indicators/spread_ar1_coefficient.rs b/crates/wickra-core/src/indicators/spread_ar1_coefficient.rs new file mode 100644 index 00000000..3f2d89ea --- /dev/null +++ b/crates/wickra-core/src/indicators/spread_ar1_coefficient.rs @@ -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, +} + +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 { + 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 { + 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 = 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); + } +} diff --git a/crates/wickra-core/src/indicators/trend_label.rs b/crates/wickra-core/src/indicators/trend_label.rs new file mode 100644 index 00000000..8ee224c4 --- /dev/null +++ b/crates/wickra-core/src/indicators/trend_label.rs @@ -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, +} + +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 { + 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 { + 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::() / 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 = (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 = (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 = (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 = 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 = (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 = (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); + } +} diff --git a/crates/wickra-core/src/indicators/vpin.rs b/crates/wickra-core/src/indicators/vpin.rs new file mode 100644 index 00000000..c2eee76f --- /dev/null +++ b/crates/wickra-core/src/indicators/vpin.rs @@ -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, + 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 { + 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 { + 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 = (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); + } +} diff --git a/crates/wickra-core/src/indicators/wick_ratio.rs b/crates/wickra-core/src/indicators/wick_ratio.rs new file mode 100644 index 00000000..b76877a6 --- /dev/null +++ b/crates/wickra-core/src/indicators/wick_ratio.rs @@ -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 { + 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 = (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 = (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::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/win_rate.rs b/crates/wickra-core/src/indicators/win_rate.rs new file mode 100644 index 00000000..7873775c --- /dev/null +++ b/crates/wickra-core/src/indicators/win_rate.rs @@ -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, + 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 { + 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 { + 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 = (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 = (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); + } +} diff --git a/crates/wickra-core/src/lib.rs b/crates/wickra-core/src/lib.rs index f7c02df4..8deb5ff7 100644 --- a/crates/wickra-core/src/lib.rs +++ b/crates/wickra-core/src/lib.rs @@ -59,78 +59,80 @@ 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, - 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, + 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, + CloseVsOpen, ClosingMarubozu, Cmo, CoefficientOfVariation, Cointegration, CointegrationOutput, + ConcealingBabySwallow, ConditionalValueAtRisk, ConnorsRsi, Coppock, Counterattack, Crab, + CumulativeVolumeDelta, CumulativeVolumeIndex, CupAndHandle, CyberneticCycle, Cypher, + DayOfWeekProfile, DayOfWeekProfileOutput, Decycler, DecyclerOscillator, Dema, DemandIndex, + DemarkPivots, DemarkPivotsOutput, DepthSlope, DetrendedStdDev, DistanceSsd, Doji, DojiStar, + Donchian, DonchianOutput, DonchianStop, DonchianStopOutput, DoubleBollinger, + DoubleBollingerOutput, DoubleTopBottom, DownsideGapThreeMethods, Dpo, DragonflyDoji, + DrawdownDuration, Dx, EaseOfMovement, EffectiveSpread, EhlersStochastic, ElderImpulse, Ema, + EmpiricalModeDecomposition, Engulfing, EveningDojiStar, Evwma, Expectancy, FallingThreeMethods, + Fama, FibArcs, FibArcsOutput, FibChannel, FibChannelOutput, FibConfluence, FibConfluenceOutput, FibExtension, FibExtensionOutput, FibFan, FibFanOutput, FibProjection, FibProjectionOutput, FibRetracement, FibRetracementOutput, FibTimeZones, FibTimeZonesOutput, FibonacciPivots, FibonacciPivotsOutput, FisherTransform, FlagPennant, Footprint, FootprintOutput, ForceIndex, FractalChaosBands, FractalChaosBandsOutput, Frama, FundingBasis, FundingRate, FundingRateMean, FundingRateZScore, GainLossRatio, GapSideBySideWhite, GarmanKlassVolatility, Gartley, GoldenPocket, GoldenPocketOutput, GrangerCausality, GravestoneDoji, Hammer, HangingMan, Harami, - HeadAndShoulders, HeikinAshi, HeikinAshiOutput, HiLoActivator, HighLowIndex, 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, + HeadAndShoulders, HeikinAshi, HeikinAshiOutput, HiLoActivator, HighLowIndex, HighLowRange, + HighWave, Hikkake, HikkakeModified, HilbertDominantCycle, HistoricalVolatility, Hma, + HomingPigeon, HtDcPhase, HtPhasor, HtPhasorOutput, HtTrendMode, HurstChannel, + HurstChannelOutput, HurstExponent, Ichimoku, IchimokuOutput, IdenticalThreeCrows, InNeck, + Inertia, InformationRatio, InitialBalance, InitialBalanceOutput, InstantaneousTrendline, + IntradayVolatilityProfile, IntradayVolatilityProfileOutput, InverseFisherTransform, + InvertedHammer, Jma, JumpIndicator, KagiBars, KalmanHedgeRatio, KalmanHedgeRatioOutput, Kama, + KellyCriterion, Keltner, KeltnerOutput, Kicking, KickingByLength, Kst, KstOutput, Kurtosis, + Kvo, KylesLambda, LadderBottom, LaguerreRsi, LeadLagCrossCorrelation, + LeadLagCrossCorrelationOutput, LinRegAngle, LinRegChannel, LinRegChannelOutput, + LinRegIntercept, LinRegSlope, LinearRegression, LiquidationFeatures, LiquidationFeaturesOutput, + LogReturn, LongLeggedDoji, LongLine, LongShortRatio, MaEnvelope, MaEnvelopeOutput, MacdExt, + MacdFix, MacdIndicator, MacdOutput, Mama, MamaOutput, MarketFacilitationIndex, Marubozu, + MassIndex, MatHold, MatchingLow, MaxDrawdown, McClellanOscillator, McClellanSummationIndex, + McGinleyDynamic, MedianAbsoluteDeviation, MedianPrice, Mfi, Microprice, MidPoint, MidPrice, + MinusDi, MinusDm, Mom, MorningDojiStar, MorningEveningStar, Natr, NewHighsNewLows, Nvi, + OIPriceDivergence, OIWeighted, Obv, OmegaRatio, OnNeck, OpenInterestDelta, OpeningMarubozu, + OpeningRange, OpeningRangeOutput, OrderBookImbalanceFull, OrderBookImbalanceTop1, + OrderBookImbalanceTopN, OrderFlowImbalance, OuHalfLife, OvernightGap, OvernightIntradayReturn, + OvernightIntradayReturnOutput, PainIndex, PairSpreadZScore, PairwiseBeta, ParkinsonVolatility, + PearsonCorrelation, PercentAboveMa, PercentB, PercentageTrailingStop, Pgo, PiercingDarkCloud, + PlusDi, PlusDm, Pmo, PointAndFigureBars, Ppo, ProfitFactor, Psar, Pvi, QuotedSpread, RSquared, + RealizedSpread, RealizedVolatility, RecoveryFactor, RectangleRange, RegimeLabel, + RelativeStrengthAB, RelativeStrengthOutput, RenkoBars, RenkoTrailingStop, RickshawMan, + RisingThreeMethods, Roc, Rocp, Rocr, Rocr100, RogersSatchellVolatility, RollMeasure, + RollingCorrelation, RollingCovariance, RollingIqr, RollingPercentileRank, RollingQuantile, + RollingVwap, RoofingFilter, Rsi, Rvi, RviVolatility, Rwi, RwiOutput, SarExt, SeasonalZScore, + SeparatingLines, SessionHighLow, SessionHighLowOutput, SessionRange, SessionRangeOutput, + SessionVwap, Shark, SharpeRatio, ShootingStar, ShortLine, SignedVolume, SineWave, Skewness, + Sma, Smi, Smma, SortinoRatio, SpearmanCorrelation, SpinningTop, SpreadAr1Coefficient, + SpreadBollingerBands, SpreadBollingerBandsOutput, SpreadHurst, StalledPattern, StandardError, + StandardErrorBands, StandardErrorBandsOutput, StarcBands, StarcBandsOutput, Stc, StdDev, + StepTrailingStop, StickSandwich, StochRsi, Stochastic, StochasticOutput, SuperSmoother, + SuperTrend, SuperTrendOutput, TakerBuySellRatio, Takuri, TasukiGap, TdCombo, TdCountdown, + TdDeMarker, TdDifferential, TdLines, TdLinesOutput, TdOpen, TdPressure, TdRangeProjection, TdRangeProjectionOutput, TdRei, TdRiskLevel, TdRiskLevelOutput, TdSequential, TdSequentialOutput, TdSetup, Tema, TermStructureBasis, 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, - 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, + TradeImbalance, TrendLabel, TreynorRatio, Triangle, Trima, Trin, TripleTopBottom, Trix, + TrueRange, Tsf, Tsi, Tsv, TtmSqueeze, TtmSqueezeOutput, TurnOfMonth, Tweezer, TwoCrows, + TypicalPrice, UlcerIndex, UltimateOscillator, UniqueThreeRiver, UpDownVolumeRatio, + UpsideGapThreeMethods, UpsideGapTwoCrows, ValueArea, ValueAreaOutput, ValueAtRisk, Variance, + VarianceRatio, VerticalHorizontalFilter, Vidya, VoltyStop, VolumeByTimeProfile, + VolumeByTimeProfileOutput, VolumeOscillator, VolumePriceTrend, VolumeProfile, + VolumeProfileOutput, Vortex, VortexOutput, Vpin, Vwap, VwapStdDevBands, VwapStdDevBandsOutput, + Vwma, Vzo, WaveTrend, WaveTrendOutput, Wedge, WeightedClose, WickRatio, WilliamsFractals, + WilliamsFractalsOutput, WilliamsR, WinRate, Wma, WoodiePivots, WoodiePivotsOutput, + YangZhangVolatility, YoyoExit, ZScore, ZeroLagMacd, ZeroLagMacdOutput, ZigZag, ZigZagOutput, + Zlema, FAMILIES, T3, }; // `FootprintLevel` is a row element of `FootprintOutput`, re-exported on its own // line so the indicator-count tooling (which scans the braced block above and diff --git a/docs/README.md b/docs/README.md index de2e3e61..47912449 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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 **396 indicators** across the sixteen families (Moving Averages, Momentum Oscillators, Trend & Directional, Price Oscillators, Volatility & Bands, Bands & Channels, Trailing Stops, Volume, Price Statistics, Ehlers / Cycle DSP, Pivots & diff --git a/fuzz/fuzz_targets/indicator_update.rs b/fuzz/fuzz_targets/indicator_update.rs index 3447e162..de5e926c 100644 --- a/fuzz/fuzz_targets/indicator_update.rs +++ b/fuzz/fuzz_targets/indicator_update.rs @@ -14,9 +14,7 @@ //! `Ema(20)`. This target now covers every scalar indicator in the catalogue. use libfuzzer_sys::fuzz_target; -use wickra_core::{ -AdaptiveCycle, Alma, AnchoredRsi, Apo, Autocorrelation, AverageDrawdown, BatchExt, Beta, BollingerBands, CalmarRatio, CenterOfGravity, Cfo, Cmo, CoefficientOfVariation, ConditionalValueAtRisk, ConnorsRsi, Coppock, CyberneticCycle, Decycler, DecyclerOscillator, Dema, DetrendedStdDev, DoubleBollinger, Dpo, DrawdownDuration, EhlersStochastic, ElderImpulse, Ema, EmpiricalModeDecomposition, Fama, FisherTransform, Frama, GainLossRatio, HilbertDominantCycle, HistoricalVolatility, Hma, HtDcPhase, HtPhasor, HtTrendMode, HurstExponent, Indicator, InstantaneousTrendline, InverseFisherTransform, Jma, Kama, KellyCriterion, Kst, Kurtosis, LaguerreRsi, LinRegAngle, LinRegChannel, LinRegIntercept, LinRegSlope, LinearRegression, MaEnvelope, MaType, MacdExt, MacdFix, MacdIndicator, Mama, MaxDrawdown, McGinleyDynamic, MedianAbsoluteDeviation, MidPoint, Mom, OmegaRatio, PainIndex, PearsonCorrelation, PercentageTrailingStop, Pmo, Ppo, ProfitFactor, RSquared, RecoveryFactor, RenkoTrailingStop, Roc, Rocp, Rocr, Rocr100, RoofingFilter, Rsi, RviVolatility, SharpeRatio, SineWave, Skewness, Sma, Smma, SortinoRatio, SpearmanCorrelation, StandardError, StandardErrorBands, Stc, StdDev, StepTrailingStop, StochRsi, SuperSmoother, Tema, Tii, Trima, Trix, Tsf, Tsi, UlcerIndex, ValueAtRisk, Variance, VerticalHorizontalFilter, Vidya, Wma, ZScore, ZeroLagMacd, Zlema, T3 -}; +use wickra_core::{AdaptiveCycle, Alma, AnchoredRsi, Apo, Autocorrelation, AverageDrawdown, BatchExt, Beta, BollingerBands, CalmarRatio, CenterOfGravity, Cfo, Cmo, CoefficientOfVariation, ConditionalValueAtRisk, ConnorsRsi, Coppock, CyberneticCycle, Decycler, DecyclerOscillator, Dema, DetrendedStdDev, DoubleBollinger, Dpo, DrawdownDuration, EhlersStochastic, ElderImpulse, Ema, EmpiricalModeDecomposition, Expectancy, Fama, FisherTransform, Frama, GainLossRatio, HilbertDominantCycle, HistoricalVolatility, Hma, HtDcPhase, HtPhasor, HtTrendMode, HurstExponent, Indicator, InstantaneousTrendline, InverseFisherTransform, Jma, JumpIndicator, Kama, KellyCriterion, Kst, Kurtosis, LaguerreRsi, LinRegAngle, LinRegChannel, LinRegIntercept, LinRegSlope, LinearRegression, LogReturn, MaEnvelope, MaType, MacdExt, MacdFix, MacdIndicator, Mama, MaxDrawdown, McGinleyDynamic, MedianAbsoluteDeviation, MidPoint, Mom, OmegaRatio, PainIndex, PearsonCorrelation, PercentageTrailingStop, Pmo, Ppo, ProfitFactor, RSquared, RealizedVolatility, RecoveryFactor, RegimeLabel, RenkoTrailingStop, Roc, Rocp, Rocr, Rocr100, RollingIqr, RollingPercentileRank, RollingQuantile, RoofingFilter, Rsi, RviVolatility, SharpeRatio, SineWave, Skewness, Sma, Smma, SortinoRatio, SpearmanCorrelation, StandardError, StandardErrorBands, Stc, StdDev, StepTrailingStop, StochRsi, SuperSmoother, Tema, Tii, TrendLabel, Trima, Trix, Tsf, Tsi, UlcerIndex, ValueAtRisk, Variance, VerticalHorizontalFilter, Vidya, WinRate, Wma, ZScore, ZeroLagMacd, Zlema, T3}; /// Drive a single streaming + batch run through one scalar indicator. Marked /// `#[inline(never)]` so a panic backtrace pin-points the specific indicator. @@ -96,6 +94,14 @@ fuzz_target!(|data: Vec| { // HurstExponent needs `period >= 2 * chunks`; 16/4 is the cheapest fit // that still exercises every code path. drive(|| HurstExponent::new(16, 4).unwrap(), &data); + drive(|| LogReturn::new(1).unwrap(), &data); + drive(|| RealizedVolatility::new(20).unwrap(), &data); + drive(|| RollingQuantile::new(20, 0.5).unwrap(), &data); + drive(|| RollingIqr::new(14).unwrap(), &data); + drive(|| RollingPercentileRank::new(14).unwrap(), &data); + drive(|| TrendLabel::new(14).unwrap(), &data); + drive(|| JumpIndicator::new(20, 3.0).unwrap(), &data); + drive(|| RegimeLabel::new(5, 20).unwrap(), &data); drive(|| RviVolatility::new(10).unwrap(), &data); drive(|| LaguerreRsi::new(0.5).unwrap(), &data); drive(|| ConnorsRsi::classic(), &data); @@ -157,6 +163,8 @@ fuzz_target!(|data: Vec| { 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. diff --git a/fuzz/fuzz_targets/indicator_update_candle.rs b/fuzz/fuzz_targets/indicator_update_candle.rs index 57e91b4e..8e17e3cb 100644 --- a/fuzz/fuzz_targets/indicator_update_candle.rs +++ b/fuzz/fuzz_targets/indicator_update_candle.rs @@ -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, Engulfing, EveningDojiStar, Evwma, FallingThreeMethods, FibArcs, FibChannel, FibConfluence, FibExtension, FibFan, FibProjection, FibRetracement, FibTimeZones, FibonacciPivots, FlagPennant, ForceIndex, FractalChaosBands, GapSideBySideWhite, GarmanKlassVolatility, Gartley, GoldenPocket, GravestoneDoji, Hammer, HangingMan, Harami, HeadAndShoulders, HeikinAshi, HiLoActivator, HighLowRange, HighWave, Hikkake, HikkakeModified, HomingPigeon, HurstChannel, Ichimoku, IdenticalThreeCrows, InNeck, Indicator, Inertia, InitialBalance, IntradayVolatilityProfile, InvertedHammer, Keltner, Kicking, KickingByLength, Kvo, LadderBottom, LongLeggedDoji, LongLine, MarketFacilitationIndex, Marubozu, MassIndex, MatHold, MatchingLow, AvgPrice, MedianPrice, Mfi, MidPrice, MinusDi, MinusDm, MorningDojiStar, MorningEveningStar, Natr, Nvi, Obv, OnNeck, OpeningMarubozu, OpeningRange, OvernightGap, OvernightIntradayReturn, ParkinsonVolatility, Pgo, PiercingDarkCloud, PlusDi, PlusDm, Psar, Pvi, RectangleRange, RickshawMan, RisingThreeMethods, RogersSatchellVolatility, RollingVwap, Rvi, Rwi, SarExt, SeasonalZScore, SeparatingLines, SessionHighLow, SessionRange, SessionVwap, Shark, ShootingStar, ShortLine, Smi, SpinningTop, StalledPattern, StarcBands, StickSandwich, Stochastic, SuperTrend, Takuri, TasukiGap, TdCombo, TdCountdown, TdDeMarker, TdDifferential, TdLines, TdOpen, TdPressure, TdRangeProjection, TdRei, TdRiskLevel, TdSequential, TdSetup, ThreeDrives, ThreeInside, ThreeLineStrike, ThreeOutside, ThreeSoldiersOrCrows, ThreeStarsInSouth, Thrusting, TimeOfDayReturnProfile, TpoProfile, Triangle, TripleTopBottom, TrueRange, Tsv, TtmSqueeze, TurnOfMonth, Tweezer, TwoCrows, TypicalPrice, UltimateOscillator, UniqueThreeRiver, UpsideGapThreeMethods, UpsideGapTwoCrows, ValueArea, VoltyStop, VolumeByTimeProfile, VolumeOscillator, VolumePriceTrend, VolumeProfile, Vortex, Vwap, VwapStdDevBands, Vwma, Vzo, WaveTrend, Wedge, WeightedClose, WickRatio, WilliamsFractals, WilliamsR, WoodiePivots, YangZhangVolatility, YoyoExit, ZigZag}; /// Convert a flat `f64` stream into a `Vec` by chunking it into /// `[open, high, low, close, volume]` groups. Tuples that fail OHLCV @@ -118,6 +118,10 @@ fuzz_target!(|data: Vec| { 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); diff --git a/fuzz/fuzz_targets/indicator_update_orderbook.rs b/fuzz/fuzz_targets/indicator_update_orderbook.rs index 21c1a15f..0aa756d6 100644 --- a/fuzz/fuzz_targets/indicator_update_orderbook.rs +++ b/fuzz/fuzz_targets/indicator_update_orderbook.rs @@ -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(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); }); diff --git a/fuzz/fuzz_targets/indicator_update_pair.rs b/fuzz/fuzz_targets/indicator_update_pair.rs index dc10008f..be0286fa 100644 --- a/fuzz/fuzz_targets/indicator_update_pair.rs +++ b/fuzz/fuzz_targets/indicator_update_pair.rs @@ -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(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`). diff --git a/fuzz/fuzz_targets/indicator_update_trade.rs b/fuzz/fuzz_targets/indicator_update_trade.rs index 898529e9..7428c02d 100644 --- a/fuzz/fuzz_targets/indicator_update_trade.rs +++ b/fuzz/fuzz_targets/indicator_update_trade.rs @@ -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(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.