From 4f11df0e334e553d4fc90ec4fb4b46feca4d7228 Mon Sep 17 00:00:00 2001 From: kingchenc Date: Mon, 1 Jun 2026 19:45:38 +0200 Subject: [PATCH] feat: microstructure price-impact & depth indicators (part 3 of 4) (#122) * feat: effective spread microstructure indicator (part 3 of 4) * feat: realized spread microstructure indicator (part 3 of 4) * feat: kyle's lambda microstructure indicator (part 3 of 4) * feat: depth slope microstructure indicator (part 3 of 4) --- CHANGELOG.md | 15 + README.md | 10 +- bindings/node/__tests__/indicators.test.js | 82 +++++ bindings/node/index.d.ts | 36 +++ bindings/node/index.js | 6 +- bindings/node/src/lib.rs | 212 +++++++++++++ bindings/python/python/wickra/__init__.py | 10 + bindings/python/src/lib.rs | 198 ++++++++++++ .../python/tests/test_input_validation.py | 20 ++ bindings/python/tests/test_known_values.py | 46 +++ bindings/python/tests/test_lifecycle.py | 35 +++ bindings/python/tests/test_new_indicators.py | 21 ++ bindings/python/tests/test_smoke.py | 19 ++ .../python/tests/test_streaming_vs_batch.py | 17 ++ bindings/wasm/src/lib.rs | 144 +++++++++ .../wickra-core/src/indicators/depth_slope.rs | 258 ++++++++++++++++ .../src/indicators/effective_spread.rs | 157 ++++++++++ .../src/indicators/kyles_lambda.rs | 281 ++++++++++++++++++ crates/wickra-core/src/indicators/mod.rs | 14 +- .../src/indicators/realized_spread.rs | 204 +++++++++++++ crates/wickra-core/src/lib.rs | 46 +-- crates/wickra/benches/indicators.rs | 49 ++- fuzz/Cargo.toml | 7 + .../indicator_update_orderbook.rs | 3 +- .../indicator_update_tradequote.rs | 47 +++ 25 files changed, 1898 insertions(+), 39 deletions(-) create mode 100644 crates/wickra-core/src/indicators/depth_slope.rs create mode 100644 crates/wickra-core/src/indicators/effective_spread.rs create mode 100644 crates/wickra-core/src/indicators/kyles_lambda.rs create mode 100644 crates/wickra-core/src/indicators/realized_spread.rs create mode 100644 fuzz/fuzz_targets/indicator_update_tradequote.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a47dc43..f98ccfc0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- **Microstructure family — price impact & depth (part 3).** Indicators over a + trade paired with the prevailing mid (`TradeQuote`) and over the order-book + depth profile, exposed in Rust, Python, Node and WASM: + - **Effective Spread** — `2 · D · (tradePrice − mid) / mid · 10_000` bps, the + realised round-trip cost of a single trade against the mid. + - **Realized Spread** — `2 · D · (tradePrice − mid_{t+horizon}) / mid_t · + 10_000` bps, the share of the effective spread a liquidity provider keeps + once the mid has moved over a configurable horizon. + - **Kyle's Lambda** — the rolling OLS slope of mid changes on signed volume + (`cov(Δmid, q) / var(q)`), the canonical price-impact / market-depth proxy. + - **Depth Slope** — the mean per-side OLS slope of cumulative resting size + against distance from the mid, measuring how fast the book thickens away + from the touch. + ## [0.4.2] - 2026-06-01 ### Added diff --git a/README.md b/README.md index 2e0a5afe..1b73e625 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) @@ -47,7 +47,7 @@ Full documentation lives at **[docs.wickra.org](https://docs.wickra.org)**: [Node](https://docs.wickra.org/Quickstart-Node), [WASM](https://docs.wickra.org/Quickstart-WASM). - **Indicators** — a per-indicator deep dive (formula, parameters, warmup) for - every one of the 227 indicators; start at the + every one of the 231 indicators; start at the [indicators overview](https://docs.wickra.org/Indicators-Overview). - **Reference** — [warmup periods](https://docs.wickra.org/Warmup-Periods), [streaming vs batch](https://docs.wickra.org/Streaming-vs-Batch), @@ -135,7 +135,7 @@ python -m benchmarks.compare_libraries ## Indicators -227 streaming-first indicators across seventeen families. Every one passes the +231 streaming-first indicators across seventeen 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). @@ -156,7 +156,7 @@ warmup) at [docs.wickra.org](https://docs.wickra.org/Indicators-Overview). | DeMark | TD Setup, TD Sequential, TD DeMarker, TD REI, TD Pressure, TD Combo, TD Countdown, TD Lines, TD Range Projection, TD Differential, TD Open, TD Risk Level | | Ichimoku & Charts | Ichimoku Kinko Hyo (Tenkan, Kijun, Senkou A/B, Chikou), Heikin-Ashi | | Candlestick Patterns | Doji, Hammer, Inverted Hammer, Hanging Man, Shooting Star, Engulfing, Harami, Morning/Evening Star, Three White Soldiers/Black Crows, Piercing Line/Dark Cloud Cover, Marubozu, Tweezer, Spinning Top, Three Inside Up/Down, Three Outside Up/Down | -| Microstructure | Order-Book Imbalance (Top-1 / Top-N / Full), Microprice, Quoted Spread, Signed Volume, Cumulative Volume Delta, Trade Imbalance | +| 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 | | Market Profile | Value Area (POC / VAH / VAL), Initial Balance, Opening Range | | Risk / Performance | Sharpe Ratio, Sortino Ratio, Calmar Ratio, Omega Ratio, Max Drawdown, Average Drawdown, Drawdown Duration, Pain Index, Value at Risk, Conditional Value at Risk (CVaR), Profit Factor, Gain/Loss Ratio, Recovery Factor, Kelly Criterion, Treynor Ratio, Information Ratio, Alpha (Jensen) | @@ -237,7 +237,7 @@ A Python live-trading example using the public `websockets` package lives at ``` wickra/ ├── crates/ -│ ├── wickra-core/ core engine + all 227 indicators +│ ├── wickra-core/ core engine + all 231 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 eb335858..00159e75 100644 --- a/bindings/node/__tests__/indicators.test.js +++ b/bindings/node/__tests__/indicators.test.js @@ -926,6 +926,10 @@ test('order-book indicators reference values', () => { assert.equal(new wickra.Microprice().update([100], [1], [101], [3]), 100.25); // Quoted spread: 1 / 100.5 * 10000 ≈ 99.5025 bps. assert.ok(Math.abs(new wickra.QuotedSpread().update([100], [1], [101], [1]) - 99.50248756) < 1e-6); + // Depth slope: each side distances 1,2 -> cumulative 1,3 -> OLS slope 2. + assert.ok(Math.abs(new wickra.DepthSlope().update([99, 98], [1, 2], [101, 102], [1, 2]) - 2.0) < 1e-9); + // Single level per side -> no slope -> 0. + assert.equal(new wickra.DepthSlope().update([100], [1], [101], [1]), 0.0); }); test('order-book streaming update matches batch', () => { @@ -981,3 +985,81 @@ test('trade-flow rejects bad input', () => { assert.throws(() => new wickra.TradeImbalance(0)); assert.throws(() => new wickra.SignedVolume().update(100, -1, true)); }); + +test('price-impact indicators reference values', () => { + // Buy at 100.05 vs mid 100.0: 2 * (100.05 - 100) / 100 * 10000 = 10 bps. + assert.ok(Math.abs(new wickra.EffectiveSpread().update(100.05, 1, true, 100.0) - 10.0) < 1e-9); + // Sell at 99.95 vs mid 100.0: 2 * -1 * (99.95 - 100) / 100 * 10000 = 10 bps. + assert.ok(Math.abs(new wickra.EffectiveSpread().update(99.95, 1, false, 100.0) - 10.0) < 1e-9); + // A buy filled below the mid is price improvement -> negative. + assert.ok(new wickra.EffectiveSpread().update(99.95, 1, true, 100.0) < 0.0); +}); + +test('price-impact streaming update matches batch', () => { + const n = 30; + const mid = Array.from({ length: n }, (_, i) => 100 + 0.25 * Math.sin(i * 0.5)); + const isBuy = Array.from({ length: n }, (_, i) => i % 3 !== 0); + const price = Array.from({ length: n }, (_, i) => mid[i] + (isBuy[i] ? 0.03 : -0.03)); + const size = Array.from({ length: n }, (_, i) => 1 + (i % 4)); + const batch = new wickra.EffectiveSpread().batch(price, size, isBuy, mid); + const streamer = new wickra.EffectiveSpread(); + assert.equal(batch.length, n); + for (let i = 0; i < n; i++) { + const s = streamer.update(price[i], size[i], isBuy[i], mid[i]); + assert.ok(Math.abs(s - batch[i]) < 1e-9, `mismatch at ${i}: ${s} vs ${batch[i]}`); + } +}); + +test('realized spread resolves against the future mid', () => { + const rs = new wickra.RealizedSpread(1); + assert.equal(rs.update(100.10, 1, true, 100.0), null); // buffered + // 2 * (+1) * (100.10 - 100.20) / 100.0 * 10000 = -20 bps. + assert.ok(Math.abs(rs.update(99.90, 1, false, 100.20) - -20.0) < 1e-9); +}); + +test('realized spread streaming update matches batch', () => { + const n = 30; + const mid = Array.from({ length: n }, (_, i) => 100 + 0.25 * Math.sin(i * 0.5)); + const isBuy = Array.from({ length: n }, (_, i) => i % 3 !== 0); + const price = Array.from({ length: n }, (_, i) => mid[i] + (isBuy[i] ? 0.03 : -0.03)); + const size = Array.from({ length: n }, (_, i) => 1 + (i % 4)); + const batch = new wickra.RealizedSpread(4).batch(price, size, isBuy, mid); + const streamer = new wickra.RealizedSpread(4); + assert.equal(batch.length, n); + for (let i = 0; i < n; i++) { + const s = streamer.update(price[i], size[i], isBuy[i], mid[i]); + const got = s === null ? NaN : s; + assert.ok( + (Number.isNaN(got) && Number.isNaN(batch[i])) || Math.abs(got - batch[i]) < 1e-9, + `mismatch at ${i}: ${got} vs ${batch[i]}`, + ); + } +}); + +test("kyle's lambda recovers a constant price-impact slope", () => { + // Each trade moves the mid by exactly 0.5 per unit of signed volume. + const impact = 0.5; + let mid = 100; + const price = []; + const size = []; + const isBuy = []; + const mids = []; + for (let i = 0; i < 20; i++) { + const buy = i % 2 === 0; + const sz = 1 + (i % 3); + const signed = buy ? sz : -sz; + mid += impact * signed; + price.push(mid); + size.push(sz); + isBuy.push(buy); + mids.push(mid); + } + const out = new wickra.KylesLambda(6).batch(price, size, isBuy, mids); + assert.ok(Math.abs(out[out.length - 1] - 0.5) < 1e-9); +}); + +test('price-impact rejects bad input', () => { + assert.throws(() => new wickra.EffectiveSpread().update(100, 1, true, 0)); + assert.throws(() => new wickra.RealizedSpread(0)); + assert.throws(() => new wickra.KylesLambda(1)); +}); diff --git a/bindings/node/index.d.ts b/bindings/node/index.d.ts index efd437fc..087cc1bb 100644 --- a/bindings/node/index.d.ts +++ b/bindings/node/index.d.ts @@ -2232,6 +2232,15 @@ export declare class QuotedSpread { isReady(): boolean warmupPeriod(): number } +export type DepthSlopeNode = DepthSlope +export declare class DepthSlope { + constructor() + update(bidPx: Array, bidSz: Array, askPx: Array, askSz: Array): number | null + batch(snapshots: Array): Array + reset(): void + isReady(): boolean + warmupPeriod(): number +} export type OrderBookImbalanceTopNNode = OrderBookImbalanceTopN export declare class OrderBookImbalanceTopN { constructor(levels: number) @@ -2268,6 +2277,33 @@ export declare class TradeImbalance { isReady(): boolean warmupPeriod(): number } +export type EffectiveSpreadNode = EffectiveSpread +export declare class EffectiveSpread { + constructor() + update(price: number, size: number, isBuy: boolean, mid: number): number | null + batch(price: Array, size: Array, isBuy: Array, mid: Array): Array + reset(): void + isReady(): boolean + warmupPeriod(): number +} +export type RealizedSpreadNode = RealizedSpread +export declare class RealizedSpread { + constructor(horizon: number) + update(price: number, size: number, isBuy: boolean, mid: number): number | null + batch(price: Array, size: Array, isBuy: Array, mid: Array): Array + reset(): void + isReady(): boolean + warmupPeriod(): number +} +export type KylesLambdaNode = KylesLambda +export declare class KylesLambda { + constructor(window: number) + update(price: number, size: number, isBuy: boolean, mid: number): number | null + batch(price: Array, size: Array, isBuy: Array, mid: Array): Array + reset(): void + isReady(): boolean + warmupPeriod(): number +} export type SharpeRatioNode = SharpeRatio export declare class SharpeRatio { constructor(period: number, riskFree: number) diff --git a/bindings/node/index.js b/bindings/node/index.js index 36581daf..1136071a 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, Autocorrelation, HurstExponent, PearsonCorrelation, Beta, PairwiseBeta, SpearmanCorrelation, PairSpreadZScore, LeadLagCrossCorrelation, Cointegration, RelativeStrengthAB, MACD, BollingerBands, ATR, Stochastic, OBV, ADX, ADXR, CCI, WilliamsR, MFI, PSAR, Keltner, Donchian, VWAP, RollingVWAP, AwesomeOscillator, Aroon, Inertia, ConnorsRSI, LaguerreRSI, SMI, KST, PGO, RVI, AwesomeOscillatorHistogram, STC, ElderImpulse, ZeroLagMACD, CFO, APO, KAMA, EVWMA, Alligator, JMA, VIDYA, ALMA, T3, TSI, PMO, TII, ADL, VolumePriceTrend, ChaikinMoneyFlow, ChaikinOscillator, ForceIndex, NVI, PVI, VolumeOscillator, KVO, WilliamsAD, AnchoredVWAP, DemandIndex, TSV, VZO, MarketFacilitationIndex, EaseOfMovement, SuperTrend, ChandelierExit, ChandeKrollStop, AtrTrailingStop, HiLoActivator, VoltyStop, YoyoExit, DonchianStop, PercentageTrailingStop, StepTrailingStop, RenkoTrailingStop, TypicalPrice, MedianPrice, WeightedClose, LinearRegression, LinRegSlope, AcceleratorOscillator, BalanceOfPower, ChoppinessIndex, TrueRange, ChaikinVolatility, YangZhangVolatility, RogersSatchellVolatility, GarmanKlassVolatility, ParkinsonVolatility, LinRegAngle, BollingerBandwidth, PercentB, NATR, HistoricalVolatility, AroonOscillator, WaveTrend, RWI, Vortex, MassIndex, StochRSI, UltimateOscillator, PPO, Coppock, VWMA, MaEnvelope, AccelerationBands, StarcBands, AtrBands, HurstChannel, LinRegChannel, StandardErrorBands, DoubleBollinger, TtmSqueeze, FractalChaosBands, VwapStdDevBands, ClassicPivots, FibonacciPivots, Camarilla, WoodiePivots, DemarkPivots, WilliamsFractals, ZigZag, TDSetup, TDSequential, TDDeMarker, TDREI, TDPressure, TDCombo, TDCountdown, TDLines, TDRangeProjection, TDDifferential, TDOpen, TDRiskLevel, InverseFisherTransform, DecyclerOscillator, RoofingFilter, EmpiricalModeDecomposition, HilbertDominantCycle, AdaptiveCycle, SineWave, MAMA, FAMA, Ichimoku, HeikinAshi, ValueArea, InitialBalance, OpeningRange, Doji, Hammer, InvertedHammer, HangingMan, ShootingStar, Engulfing, Harami, MorningEveningStar, ThreeSoldiersOrCrows, PiercingDarkCloud, Marubozu, Tweezer, SpinningTop, ThreeInside, ThreeOutside, OrderBookImbalanceTop1, OrderBookImbalanceFull, Microprice, QuotedSpread, OrderBookImbalanceTopN, SignedVolume, CumulativeVolumeDelta, TradeImbalance, SharpeRatio, SortinoRatio, CalmarRatio, OmegaRatio, MaxDrawdown, AverageDrawdown, DrawdownDuration, PainIndex, ValueAtRisk, ConditionalValueAtRisk, ProfitFactor, GainLossRatio, RecoveryFactor, KellyCriterion, TreynorRatio, InformationRatio, Alpha } = nativeBinding +const { version, SMA, EMA, WMA, RSI, DEMA, TEMA, HMA, ROC, TRIX, SMMA, TRIMA, ZLEMA, MOM, CMO, DPO, StdDev, UlcerIndex, VerticalHorizontalFilter, ZScore, McGinleyDynamic, FRAMA, SuperSmoother, FisherTransform, Decycler, CenterOfGravity, CyberneticCycle, InstantaneousTrendline, EhlersStochastic, RVIVolatility, Variance, CoefficientOfVariation, Skewness, Kurtosis, StandardError, DetrendedStdDev, RSquared, MedianAbsoluteDeviation, Autocorrelation, HurstExponent, PearsonCorrelation, Beta, PairwiseBeta, SpearmanCorrelation, PairSpreadZScore, LeadLagCrossCorrelation, Cointegration, RelativeStrengthAB, MACD, BollingerBands, ATR, Stochastic, OBV, ADX, ADXR, CCI, WilliamsR, MFI, PSAR, Keltner, Donchian, VWAP, RollingVWAP, AwesomeOscillator, Aroon, Inertia, ConnorsRSI, LaguerreRSI, SMI, KST, PGO, RVI, AwesomeOscillatorHistogram, STC, ElderImpulse, ZeroLagMACD, CFO, APO, KAMA, EVWMA, Alligator, JMA, VIDYA, ALMA, T3, TSI, PMO, TII, ADL, VolumePriceTrend, ChaikinMoneyFlow, ChaikinOscillator, ForceIndex, NVI, PVI, VolumeOscillator, KVO, WilliamsAD, AnchoredVWAP, DemandIndex, TSV, VZO, MarketFacilitationIndex, EaseOfMovement, SuperTrend, ChandelierExit, ChandeKrollStop, AtrTrailingStop, HiLoActivator, VoltyStop, YoyoExit, DonchianStop, PercentageTrailingStop, StepTrailingStop, RenkoTrailingStop, TypicalPrice, MedianPrice, WeightedClose, LinearRegression, LinRegSlope, AcceleratorOscillator, BalanceOfPower, ChoppinessIndex, TrueRange, ChaikinVolatility, YangZhangVolatility, RogersSatchellVolatility, GarmanKlassVolatility, ParkinsonVolatility, LinRegAngle, BollingerBandwidth, PercentB, NATR, HistoricalVolatility, AroonOscillator, WaveTrend, RWI, Vortex, MassIndex, StochRSI, UltimateOscillator, PPO, Coppock, VWMA, MaEnvelope, AccelerationBands, StarcBands, AtrBands, HurstChannel, LinRegChannel, StandardErrorBands, DoubleBollinger, TtmSqueeze, FractalChaosBands, VwapStdDevBands, ClassicPivots, FibonacciPivots, Camarilla, WoodiePivots, DemarkPivots, WilliamsFractals, ZigZag, TDSetup, TDSequential, TDDeMarker, TDREI, TDPressure, TDCombo, TDCountdown, TDLines, TDRangeProjection, TDDifferential, TDOpen, TDRiskLevel, InverseFisherTransform, DecyclerOscillator, RoofingFilter, EmpiricalModeDecomposition, HilbertDominantCycle, AdaptiveCycle, SineWave, MAMA, FAMA, Ichimoku, HeikinAshi, ValueArea, InitialBalance, OpeningRange, Doji, Hammer, InvertedHammer, HangingMan, ShootingStar, Engulfing, Harami, MorningEveningStar, ThreeSoldiersOrCrows, PiercingDarkCloud, Marubozu, Tweezer, SpinningTop, ThreeInside, ThreeOutside, OrderBookImbalanceTop1, OrderBookImbalanceFull, Microprice, QuotedSpread, DepthSlope, OrderBookImbalanceTopN, SignedVolume, CumulativeVolumeDelta, TradeImbalance, EffectiveSpread, RealizedSpread, KylesLambda, SharpeRatio, SortinoRatio, CalmarRatio, OmegaRatio, MaxDrawdown, AverageDrawdown, DrawdownDuration, PainIndex, ValueAtRisk, ConditionalValueAtRisk, ProfitFactor, GainLossRatio, RecoveryFactor, KellyCriterion, TreynorRatio, InformationRatio, Alpha } = nativeBinding module.exports.version = version module.exports.SMA = SMA @@ -519,10 +519,14 @@ module.exports.OrderBookImbalanceTop1 = OrderBookImbalanceTop1 module.exports.OrderBookImbalanceFull = OrderBookImbalanceFull module.exports.Microprice = Microprice module.exports.QuotedSpread = QuotedSpread +module.exports.DepthSlope = DepthSlope module.exports.OrderBookImbalanceTopN = OrderBookImbalanceTopN module.exports.SignedVolume = SignedVolume module.exports.CumulativeVolumeDelta = CumulativeVolumeDelta module.exports.TradeImbalance = TradeImbalance +module.exports.EffectiveSpread = EffectiveSpread +module.exports.RealizedSpread = RealizedSpread +module.exports.KylesLambda = KylesLambda module.exports.SharpeRatio = SharpeRatio module.exports.SortinoRatio = SortinoRatio module.exports.CalmarRatio = CalmarRatio diff --git a/bindings/node/src/lib.rs b/bindings/node/src/lib.rs index 8fcf414b..8c895525 100644 --- a/bindings/node/src/lib.rs +++ b/bindings/node/src/lib.rs @@ -8865,6 +8865,7 @@ node_ob_indicator!( ); node_ob_indicator!(MicropriceNode, wc::Microprice, "Microprice"); node_ob_indicator!(QuotedSpreadNode, wc::QuotedSpread, "QuotedSpread"); +node_ob_indicator!(DepthSlopeNode, wc::DepthSlope, "DepthSlope"); // Top-N imbalance carries a `levels` parameter, so it is hand-written. #[napi(js_name = "OrderBookImbalanceTopN")] @@ -9052,6 +9053,217 @@ impl TradeImbalanceNode { } } +// ============================== Microstructure: Price Impact ============================== +// +// Price-impact indicators consume a trade paired with the mid prevailing at +// execution. Streaming `update(price, size, isBuy, mid)` takes one such +// trade-quote (`isBuy=true` for a buyer-initiated trade); `batch` takes four +// equal-length arrays. + +fn build_trade_quote( + price: f64, + size: f64, + is_buy: bool, + mid: f64, +) -> napi::Result { + let trade = build_trade(price, size, is_buy)?; + wc::TradeQuote::new(trade, mid).map_err(map_err) +} + +macro_rules! node_trade_quote_indicator { + ($node:ident, $inner:ty, $js:literal) => { + #[napi(js_name = $js)] + pub struct $node { + inner: $inner, + } + + impl Default for $node { + fn default() -> Self { + Self::new() + } + } + + #[napi] + impl $node { + #[napi(constructor)] + pub fn new() -> Self { + Self { + inner: <$inner>::new(), + } + } + #[napi] + pub fn update( + &mut self, + price: f64, + size: f64, + is_buy: bool, + mid: f64, + ) -> napi::Result> { + Ok(self + .inner + .update(build_trade_quote(price, size, is_buy, mid)?)) + } + #[napi] + pub fn batch( + &mut self, + price: Vec, + size: Vec, + is_buy: Vec, + mid: Vec, + ) -> napi::Result> { + if price.len() != size.len() + || size.len() != is_buy.len() + || is_buy.len() != mid.len() + { + return Err(NapiError::from_reason( + "price, size, is_buy, mid must be equal length".to_string(), + )); + } + let mut out = Vec::with_capacity(price.len()); + for i in 0..price.len() { + let quote = build_trade_quote(price[i], size[i], is_buy[i], mid[i])?; + out.push(self.inner.update(quote).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 + } + } + }; +} + +node_trade_quote_indicator!(EffectiveSpreadNode, wc::EffectiveSpread, "EffectiveSpread"); + +// Realized spread carries a `horizon` parameter, so it is hand-written. +#[napi(js_name = "RealizedSpread")] +pub struct RealizedSpreadNode { + inner: wc::RealizedSpread, +} + +#[napi] +impl RealizedSpreadNode { + #[napi(constructor)] + pub fn new(horizon: u32) -> napi::Result { + Ok(Self { + inner: wc::RealizedSpread::new(horizon as usize).map_err(map_err)?, + }) + } + #[napi] + pub fn update( + &mut self, + price: f64, + size: f64, + is_buy: bool, + mid: f64, + ) -> napi::Result> { + Ok(self + .inner + .update(build_trade_quote(price, size, is_buy, mid)?)) + } + #[napi] + pub fn batch( + &mut self, + price: Vec, + size: Vec, + is_buy: Vec, + mid: Vec, + ) -> napi::Result> { + if price.len() != size.len() || size.len() != is_buy.len() || is_buy.len() != mid.len() { + return Err(NapiError::from_reason( + "price, size, is_buy, mid must be equal length".to_string(), + )); + } + let mut out = Vec::with_capacity(price.len()); + for i in 0..price.len() { + let quote = build_trade_quote(price[i], size[i], is_buy[i], mid[i])?; + out.push(self.inner.update(quote).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 + } +} + +// Kyle's lambda carries a `window` parameter, so it is hand-written. +#[napi(js_name = "KylesLambda")] +pub struct KylesLambdaNode { + inner: wc::KylesLambda, +} + +#[napi] +impl KylesLambdaNode { + #[napi(constructor)] + pub fn new(window: u32) -> napi::Result { + Ok(Self { + inner: wc::KylesLambda::new(window as usize).map_err(map_err)?, + }) + } + #[napi] + pub fn update( + &mut self, + price: f64, + size: f64, + is_buy: bool, + mid: f64, + ) -> napi::Result> { + Ok(self + .inner + .update(build_trade_quote(price, size, is_buy, mid)?)) + } + #[napi] + pub fn batch( + &mut self, + price: Vec, + size: Vec, + is_buy: Vec, + mid: Vec, + ) -> napi::Result> { + if price.len() != size.len() || size.len() != is_buy.len() || is_buy.len() != mid.len() { + return Err(NapiError::from_reason( + "price, size, is_buy, mid must be equal length".to_string(), + )); + } + let mut out = Vec::with_capacity(price.len()); + for i in 0..price.len() { + let quote = build_trade_quote(price[i], size[i], is_buy[i], mid[i])?; + out.push(self.inner.update(quote).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 + } +} + // ============================== Family 15: Risk / Performance ============================== // Risk metrics with fallible `new` (most need `period >= 2`), so each wrapper diff --git a/bindings/python/python/wickra/__init__.py b/bindings/python/python/wickra/__init__.py index a7232207..b3e97e26 100644 --- a/bindings/python/python/wickra/__init__.py +++ b/bindings/python/python/wickra/__init__.py @@ -246,10 +246,15 @@ from ._wickra import ( OrderBookImbalanceFull, Microprice, QuotedSpread, + DepthSlope, # Microstructure: trade flow SignedVolume, CumulativeVolumeDelta, TradeImbalance, + # Microstructure: price impact + EffectiveSpread, + RealizedSpread, + KylesLambda, # Risk / Performance SharpeRatio, SortinoRatio, @@ -493,10 +498,15 @@ __all__ = [ "OrderBookImbalanceFull", "Microprice", "QuotedSpread", + "DepthSlope", # Microstructure: trade flow "SignedVolume", "CumulativeVolumeDelta", "TradeImbalance", + # Microstructure: price impact + "EffectiveSpread", + "RealizedSpread", + "KylesLambda", # Risk / Performance "SharpeRatio", "SortinoRatio", diff --git a/bindings/python/src/lib.rs b/bindings/python/src/lib.rs index 2882d7e4..074bdd03 100644 --- a/bindings/python/src/lib.rs +++ b/bindings/python/src/lib.rs @@ -11714,6 +11714,7 @@ py_ob_indicator!( ); py_ob_indicator!(PyMicroprice, wc::Microprice, "Microprice"); py_ob_indicator!(PyQuotedSpread, wc::QuotedSpread, "QuotedSpread"); +py_ob_indicator!(PyDepthSlope, wc::DepthSlope, "DepthSlope"); // Top-N imbalance carries a `levels` parameter, so it is hand-written. #[pyclass( @@ -11902,6 +11903,198 @@ impl PyTradeImbalance { } } +// ============================== Microstructure: Price Impact ============================== +// +// Price-impact indicators consume a trade paired with the mid prevailing at +// execution. Streaming `update(price, size, is_buy, mid)` takes one such +// trade-quote (`is_buy=True` for a buyer-initiated trade); `batch` takes four +// equal-length arrays. + +fn build_trade_quote(price: f64, size: f64, is_buy: bool, mid: f64) -> PyResult { + let trade = build_trade(price, size, is_buy)?; + wc::TradeQuote::new(trade, mid).map_err(map_err) +} + +macro_rules! py_trade_quote_indicator { + ($name:ident, $inner:ty, $repr:expr) => { + #[pyclass(name = $repr, module = "wickra._wickra", skip_from_py_object)] + #[derive(Clone)] + struct $name { + inner: $inner, + } + + #[pymethods] + impl $name { + #[new] + fn new() -> Self { + Self { + inner: <$inner>::new(), + } + } + fn update( + &mut self, + price: f64, + size: f64, + is_buy: bool, + mid: f64, + ) -> PyResult> { + Ok(self + .inner + .update(build_trade_quote(price, size, is_buy, mid)?)) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + price: Vec, + size: Vec, + is_buy: Vec, + mid: Vec, + ) -> PyResult>> { + if price.len() != size.len() + || size.len() != is_buy.len() + || is_buy.len() != mid.len() + { + return Err(PyValueError::new_err( + "price, size, is_buy, mid must be equal length", + )); + } + let mut out = Vec::with_capacity(price.len()); + for i in 0..price.len() { + let quote = build_trade_quote(price[i], size[i], is_buy[i], mid[i])?; + out.push(self.inner.update(quote).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!("{}()", $repr) + } + } + }; +} + +py_trade_quote_indicator!(PyEffectiveSpread, wc::EffectiveSpread, "EffectiveSpread"); + +// Realized spread carries a `horizon` parameter, so it is hand-written. +#[pyclass( + name = "RealizedSpread", + module = "wickra._wickra", + skip_from_py_object +)] +#[derive(Clone)] +struct PyRealizedSpread { + inner: wc::RealizedSpread, +} + +#[pymethods] +impl PyRealizedSpread { + #[new] + fn new(horizon: usize) -> PyResult { + Ok(Self { + inner: wc::RealizedSpread::new(horizon).map_err(map_err)?, + }) + } + fn update(&mut self, price: f64, size: f64, is_buy: bool, mid: f64) -> PyResult> { + Ok(self + .inner + .update(build_trade_quote(price, size, is_buy, mid)?)) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + price: Vec, + size: Vec, + is_buy: Vec, + mid: Vec, + ) -> PyResult>> { + if price.len() != size.len() || size.len() != is_buy.len() || is_buy.len() != mid.len() { + return Err(PyValueError::new_err( + "price, size, is_buy, mid must be equal length", + )); + } + let mut out = Vec::with_capacity(price.len()); + for i in 0..price.len() { + let quote = build_trade_quote(price[i], size[i], is_buy[i], mid[i])?; + out.push(self.inner.update(quote).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!("RealizedSpread(horizon={})", self.inner.horizon()) + } +} + +// Kyle's lambda carries a `window` parameter, so it is hand-written. +#[pyclass(name = "KylesLambda", module = "wickra._wickra", skip_from_py_object)] +#[derive(Clone)] +struct PyKylesLambda { + inner: wc::KylesLambda, +} + +#[pymethods] +impl PyKylesLambda { + #[new] + fn new(window: usize) -> PyResult { + Ok(Self { + inner: wc::KylesLambda::new(window).map_err(map_err)?, + }) + } + fn update(&mut self, price: f64, size: f64, is_buy: bool, mid: f64) -> PyResult> { + Ok(self + .inner + .update(build_trade_quote(price, size, is_buy, mid)?)) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + price: Vec, + size: Vec, + is_buy: Vec, + mid: Vec, + ) -> PyResult>> { + if price.len() != size.len() || size.len() != is_buy.len() || is_buy.len() != mid.len() { + return Err(PyValueError::new_err( + "price, size, is_buy, mid must be equal length", + )); + } + let mut out = Vec::with_capacity(price.len()); + for i in 0..price.len() { + let quote = build_trade_quote(price[i], size[i], is_buy[i], mid[i])?; + out.push(self.inner.update(quote).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!("KylesLambda(window={})", self.inner.window()) + } +} + // ============================== Family 15: Risk / Performance ============================== #[pyclass(name = "SharpeRatio", module = "wickra._wickra", skip_from_py_object)] @@ -13013,10 +13206,15 @@ fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; m.add_class::()?; m.add_class::()?; + m.add_class::()?; // Microstructure: trade flow. m.add_class::()?; m.add_class::()?; m.add_class::()?; + // Microstructure: price impact. + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; // Family 15: Risk / Performance metrics. m.add_class::()?; m.add_class::()?; diff --git a/bindings/python/tests/test_input_validation.py b/bindings/python/tests/test_input_validation.py index 25845690..c669b91c 100644 --- a/bindings/python/tests/test_input_validation.py +++ b/bindings/python/tests/test_input_validation.py @@ -211,3 +211,23 @@ def test_trade_non_positive_price_raises(): def test_trade_batch_unequal_lengths_raise(): with pytest.raises(ValueError): ta.SignedVolume().batch([100.0, 100.0], [1.0], [True, False]) + + +def test_effective_spread_non_positive_mid_raises(): + with pytest.raises(ValueError): + ta.EffectiveSpread().update(100.0, 1.0, True, 0.0) + + +def test_effective_spread_batch_unequal_lengths_raise(): + with pytest.raises(ValueError): + ta.EffectiveSpread().batch([100.0, 100.0], [1.0, 1.0], [True, False], [100.0]) + + +def test_realized_spread_zero_horizon_raises(): + with pytest.raises(ValueError): + ta.RealizedSpread(0) + + +def test_kyles_lambda_window_below_two_raises(): + with pytest.raises(ValueError): + ta.KylesLambda(1) diff --git a/bindings/python/tests/test_known_values.py b/bindings/python/tests/test_known_values.py index c30387ea..38b67d06 100644 --- a/bindings/python/tests/test_known_values.py +++ b/bindings/python/tests/test_known_values.py @@ -872,6 +872,16 @@ def test_quoted_spread_reference_value(): assert qs.update([100.0], [1.0], [101.0], [1.0]) == pytest.approx(99.50248756, abs=1e-6) +def test_depth_slope_reference_value(): + # Symmetric book, each side distances 1, 2 with cumulative sizes 1, 3. + # OLS slope of (1->1, 2->3) = 2; mean of two equal sides = 2. + ds = ta.DepthSlope() + out = ds.update([99.0, 98.0], [1.0, 2.0], [101.0, 102.0], [1.0, 2.0]) + assert out == pytest.approx(2.0, abs=1e-9) + # A book with a single level per side has no slope -> 0. + assert ta.DepthSlope().update([100.0], [1.0], [101.0], [1.0]) == pytest.approx(0.0) + + def test_signed_volume_reference_values(): assert ta.SignedVolume().update(100.0, 2.0, True) == pytest.approx(2.0) assert ta.SignedVolume().update(100.0, 3.0, False) == pytest.approx(-3.0) @@ -889,3 +899,39 @@ def test_trade_imbalance_reference_value(): assert ti.update(100.0, 3.0, True) is None # warming up # Window full: buyVol 3, sellVol 1 -> (3 - 1) / 4 = 0.5. assert ti.update(100.0, 1.0, False) == pytest.approx(0.5) + + +def test_effective_spread_reference_values(): + # Buy at 100.05 vs mid 100.0: 2 * (100.05 - 100) / 100 * 10000 = 10 bps. + assert ta.EffectiveSpread().update(100.05, 1.0, True, 100.0) == pytest.approx(10.0) + # Sell at 99.95 vs mid 100.0: 2 * -1 * (99.95 - 100) / 100 * 10000 = 10 bps. + assert ta.EffectiveSpread().update(99.95, 1.0, False, 100.0) == pytest.approx(10.0) + # A buy filled below the mid is price improvement -> negative. + assert ta.EffectiveSpread().update(99.95, 1.0, True, 100.0) < 0.0 + + +def test_realized_spread_reference_value(): + rs = ta.RealizedSpread(1) + assert rs.update(100.10, 1.0, True, 100.0) is None # buffered + # Resolved against mid 100.20 one trade later: + # 2 * (+1) * (100.10 - 100.20) / 100.0 * 10000 = -20 bps (adverse selection). + assert rs.update(99.90, 1.0, False, 100.20) == pytest.approx(-20.0) + + +def test_kyles_lambda_recovers_constant_impact(): + # Build a tape where each trade moves the mid by exactly 0.5 per unit of + # signed volume -> the rolling OLS slope is 0.5. + impact = 0.5 + mid = 100.0 + price, size, is_buy, mids = [], [], [], [] + for i in range(20): + buy = i % 2 == 0 + sz = 1.0 + (i % 3) + signed = sz if buy else -sz + mid += impact * signed + price.append(mid) + size.append(sz) + is_buy.append(buy) + mids.append(mid) + out = ta.KylesLambda(6).batch(price, size, is_buy, mids) + assert out[-1] == pytest.approx(0.5, abs=1e-9) diff --git a/bindings/python/tests/test_lifecycle.py b/bindings/python/tests/test_lifecycle.py index 06e6a812..abc866c2 100644 --- a/bindings/python/tests/test_lifecycle.py +++ b/bindings/python/tests/test_lifecycle.py @@ -139,6 +139,7 @@ def test_orderbook_lifecycle(): ta.OrderBookImbalanceFull(), ta.Microprice(), ta.QuotedSpread(), + ta.DepthSlope(), ]: assert ind.warmup_period() == 1 assert not ind.is_ready() @@ -172,3 +173,37 @@ def test_trade_imbalance_lifecycle_and_repr(): ti.reset() assert not ti.is_ready() assert repr(ta.TradeImbalance(4)) == "TradeImbalance(window=4)" + + +def test_effective_spread_lifecycle(): + es = ta.EffectiveSpread() + assert es.warmup_period() == 1 + assert not es.is_ready() + es.update(100.05, 1.0, True, 100.0) + assert es.is_ready() + es.reset() + assert not es.is_ready() + + +def test_realized_spread_lifecycle_and_repr(): + rs = ta.RealizedSpread(3) + assert rs.warmup_period() == 4 + assert not rs.is_ready() + for _ in range(4): + rs.update(100.0, 1.0, True, 100.0) + assert rs.is_ready() + rs.reset() + assert not rs.is_ready() + assert repr(ta.RealizedSpread(5)) == "RealizedSpread(horizon=5)" + + +def test_kyles_lambda_lifecycle_and_repr(): + kl = ta.KylesLambda(3) + assert kl.warmup_period() == 4 + assert not kl.is_ready() + for i in range(4): + kl.update(100.0 + i, 1.0 + (i % 2), i % 2 == 0, 100.0 + i) + assert kl.is_ready() + kl.reset() + assert not kl.is_ready() + assert repr(ta.KylesLambda(7)) == "KylesLambda(window=7)" diff --git a/bindings/python/tests/test_new_indicators.py b/bindings/python/tests/test_new_indicators.py index ee23d2fc..9bc4d2a8 100644 --- a/bindings/python/tests/test_new_indicators.py +++ b/bindings/python/tests/test_new_indicators.py @@ -1890,6 +1890,7 @@ def test_orderbook_indicators_streaming_equals_batch(): ta.OrderBookImbalanceFull, ta.Microprice, ta.QuotedSpread, + ta.DepthSlope, ): batch = make().batch(snaps) streamer = make() @@ -1918,3 +1919,23 @@ def test_tradeflow_indicators_streaming_equals_batch(): ) assert batch.shape == (n,) assert _eq_nan(batch, streamed) + + +def test_price_impact_indicators_streaming_equals_batch(): + n = 40 + mid = np.array([100.0 + 0.5 * math.sin(i * 0.4) for i in range(n)], dtype=np.float64) + is_buy = [i % 2 == 0 for i in range(n)] + # Aggressive trades print across the mid in the aggressor's direction. + price = np.array( + [mid[i] + (0.02 if is_buy[i] else -0.02) for i in range(n)], dtype=np.float64 + ) + size = np.array([1.0 + (i % 5) for i in range(n)], dtype=np.float64) + for make in (ta.EffectiveSpread, lambda: ta.RealizedSpread(4), lambda: ta.KylesLambda(5)): + batch = make().batch(price, size, is_buy, mid) + streamer = make() + streamed = np.array( + [streamer.update(price[i], size[i], is_buy[i], mid[i]) for i in range(n)], + dtype=np.float64, + ) + assert batch.shape == (n,) + assert _eq_nan(batch, streamed) diff --git a/bindings/python/tests/test_smoke.py b/bindings/python/tests/test_smoke.py index d8381ad4..c3528822 100644 --- a/bindings/python/tests/test_smoke.py +++ b/bindings/python/tests/test_smoke.py @@ -108,6 +108,7 @@ def test_orderbook_indicators_construct_and_emit(): ta.OrderBookImbalanceFull(), ta.Microprice(), ta.QuotedSpread(), + ta.DepthSlope(), ] for ind in indicators: out = ind.update(*snapshot) @@ -135,3 +136,21 @@ def test_tradeflow_batch_returns_one_value_per_trade(): out = ta.CumulativeVolumeDelta().batch(price, size, is_buy) assert out.shape == (6,) assert out.dtype == np.float64 + + +def test_price_impact_indicators_construct_and_emit(): + # Price-impact indicators take a trade paired with the prevailing mid. + assert isinstance(ta.EffectiveSpread().update(100.05, 1.0, True, 100.0), float) + # RealizedSpread buffers until its horizon elapses. + assert ta.RealizedSpread(1).update(100.05, 1.0, True, 100.0) is None + + +def test_price_impact_batch_returns_one_value_per_trade(): + price = np.array([100.05, 99.95, 100.10, 99.90]) + size = np.array([1.0, 2.0, 1.0, 2.0]) + is_buy = [True, False, True, False] + mid = np.full(4, 100.0) + for ind in (ta.EffectiveSpread(), ta.RealizedSpread(2), ta.KylesLambda(2)): + out = ind.batch(price, size, is_buy, mid) + assert out.shape == (4,) + assert out.dtype == np.float64 diff --git a/bindings/python/tests/test_streaming_vs_batch.py b/bindings/python/tests/test_streaming_vs_batch.py index c9d5219d..cb1944fa 100644 --- a/bindings/python/tests/test_streaming_vs_batch.py +++ b/bindings/python/tests/test_streaming_vs_batch.py @@ -231,3 +231,20 @@ def test_tradeflow_streaming_matches_batch(): dtype=np.float64, ) assert _equal_with_nan(batch, streamed) + + +def test_price_impact_streaming_matches_batch(): + n = 30 + mid = np.array([100.0 + 0.25 * math.sin(i * 0.5) for i in range(n)], dtype=np.float64) + is_buy = [i % 3 != 0 for i in range(n)] + price = np.array( + [mid[i] + (0.03 if is_buy[i] else -0.03) for i in range(n)], dtype=np.float64 + ) + size = np.array([1.0 + (i % 4) for i in range(n)], dtype=np.float64) + batch = ta.EffectiveSpread().batch(price, size, is_buy, mid) + streamer = ta.EffectiveSpread() + streamed = np.array( + [streamer.update(price[i], size[i], is_buy[i], mid[i]) for i in range(n)], + dtype=np.float64, + ) + assert _equal_with_nan(batch, streamed) diff --git a/bindings/wasm/src/lib.rs b/bindings/wasm/src/lib.rs index 862e4a6a..94865596 100644 --- a/bindings/wasm/src/lib.rs +++ b/bindings/wasm/src/lib.rs @@ -6421,6 +6421,7 @@ wasm_ob_indicator!( ); wasm_ob_indicator!(WasmMicroprice, wc::Microprice, Microprice); wasm_ob_indicator!(WasmQuotedSpread, wc::QuotedSpread, QuotedSpread); +wasm_ob_indicator!(WasmDepthSlope, wc::DepthSlope, DepthSlope); // Top-N imbalance carries a `levels` parameter, so it is hand-written. #[wasm_bindgen(js_name = OrderBookImbalanceTopN)] @@ -6555,6 +6556,149 @@ impl WasmTradeImbalance { } } +// ============================== Microstructure: Price Impact ============================== +// +// Price-impact indicators consume a trade paired with the mid prevailing at +// execution. Each `update(price, size, isBuy, mid)` takes one such trade-quote +// (`isBuy=true` for a buyer-initiated trade) — the streaming model for a live +// browser trade feed. Batch over a tape is provided by the Python and Node +// bindings. + +fn build_trade_quote( + price: f64, + size: f64, + is_buy: bool, + mid: f64, +) -> Result { + let trade = build_trade(price, size, is_buy)?; + wc::TradeQuote::new(trade, mid).map_err(map_err) +} + +macro_rules! wasm_trade_quote_indicator { + ($wasm:ident, $inner:ty, $js:ident) => { + #[wasm_bindgen(js_name = $js)] + pub struct $wasm { + inner: $inner, + } + + impl Default for $wasm { + fn default() -> Self { + Self::new() + } + } + + #[wasm_bindgen(js_class = $js)] + impl $wasm { + #[wasm_bindgen(constructor)] + pub fn new() -> $wasm { + Self { + inner: <$inner>::new(), + } + } + pub fn update( + &mut self, + price: f64, + size: f64, + is_buy: bool, + mid: f64, + ) -> Result, JsError> { + Ok(self + .inner + .update(build_trade_quote(price, size, is_buy, mid)?)) + } + 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() + } + } + }; +} + +wasm_trade_quote_indicator!(WasmEffectiveSpread, wc::EffectiveSpread, EffectiveSpread); + +// Realized spread carries a `horizon` parameter, so it is hand-written. +#[wasm_bindgen(js_name = RealizedSpread)] +pub struct WasmRealizedSpread { + inner: wc::RealizedSpread, +} + +#[wasm_bindgen(js_class = RealizedSpread)] +impl WasmRealizedSpread { + #[wasm_bindgen(constructor)] + pub fn new(horizon: usize) -> Result { + Ok(Self { + inner: wc::RealizedSpread::new(horizon).map_err(map_err)?, + }) + } + pub fn update( + &mut self, + price: f64, + size: f64, + is_buy: bool, + mid: f64, + ) -> Result, JsError> { + Ok(self + .inner + .update(build_trade_quote(price, size, is_buy, mid)?)) + } + 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() + } +} + +// Kyle's lambda carries a `window` parameter, so it is hand-written. +#[wasm_bindgen(js_name = KylesLambda)] +pub struct WasmKylesLambda { + inner: wc::KylesLambda, +} + +#[wasm_bindgen(js_class = KylesLambda)] +impl WasmKylesLambda { + #[wasm_bindgen(constructor)] + pub fn new(window: usize) -> Result { + Ok(Self { + inner: wc::KylesLambda::new(window).map_err(map_err)?, + }) + } + pub fn update( + &mut self, + price: f64, + size: f64, + is_buy: bool, + mid: f64, + ) -> Result, JsError> { + Ok(self + .inner + .update(build_trade_quote(price, size, is_buy, mid)?)) + } + 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() + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/wickra-core/src/indicators/depth_slope.rs b/crates/wickra-core/src/indicators/depth_slope.rs new file mode 100644 index 00000000..7c1c875d --- /dev/null +++ b/crates/wickra-core/src/indicators/depth_slope.rs @@ -0,0 +1,258 @@ +//! Depth Slope — how fast resting liquidity accumulates away from the mid. + +use crate::microstructure::{Level, OrderBook}; +use crate::traits::Indicator; + +/// Ordinary-least-squares slope of cumulative resting size against distance +/// from the mid, over the levels of one book side. +/// +/// `signed_distance` is `+1.0` for the ask side (price above the mid) and +/// `−1.0` for the bid side (price below the mid), so the regressor `x` — +/// distance from the mid — is non-negative on both sides. The response `y` is +/// the cumulative size walking outward from the touch. Returns `0.0` for a +/// degenerate fit where every level sits at the same distance (zero variance in +/// `x`). +fn cumulative_slope(levels: &[Level], mid: f64, signed_distance: f64) -> f64 { + let count = levels.len() as f64; + let mut cumulative = 0.0; + let mut sum_x = 0.0; + let mut sum_y = 0.0; + let mut sum_xy = 0.0; + let mut sum_xx = 0.0; + for level in levels { + let x = signed_distance * (level.price - mid); + cumulative += level.size; + sum_x += x; + sum_y += cumulative; + sum_xy += x * cumulative; + sum_xx += x * x; + } + let denom = count * sum_xx - sum_x * sum_x; + if denom == 0.0 { + return 0.0; + } + (count * sum_xy - sum_x * sum_y) / denom +} + +/// Depth Slope — the average rate at which cumulative resting size grows with +/// distance from the mid, across the bid and ask sides of the book. +/// +/// For each side the indicator runs an ordinary-least-squares regression of +/// cumulative size (walking outward from the touch) on the level's distance +/// from the mid, then reports the mean of the two slopes: +/// +/// ```text +/// slope_side = OLS slope of (|priceᵢ − mid|, Σ_{j≤i} sizeⱼ) +/// depthSlope = (slope_bid + slope_ask) / 2 +/// ``` +/// +/// Because the response is *cumulative* size it never decreases with distance, +/// so the slope is non-negative: it is a magnitude, not a direction. A large +/// slope means cumulative liquidity builds quickly away from the touch — a deep +/// book that absorbs large orders with little walking; a small slope is a thin, +/// shallow book. A book whose size is concentrated at the touch and thins out +/// behind it (a fragile book) reads a *smaller* slope than one of equal total +/// depth that thickens with distance. +/// +/// A side with fewer than two levels carries no slope, so the indicator returns +/// `0.0` whenever either side has fewer than two levels (including an empty +/// book). +/// +/// `Input = OrderBook`, `Output = f64`. Stateless; ready after the first +/// snapshot. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{DepthSlope, Indicator, Level, OrderBook}; +/// +/// // Both sides thicken linearly away from the mid (sizes 1, 2, 3 …). +/// let book = OrderBook::new( +/// vec![Level::new(99.0, 1.0).unwrap(), Level::new(98.0, 2.0).unwrap()], +/// vec![Level::new(101.0, 1.0).unwrap(), Level::new(102.0, 2.0).unwrap()], +/// ) +/// .unwrap(); +/// let mut ds = DepthSlope::new(); +/// assert!(ds.update(book).unwrap() > 0.0); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct DepthSlope { + has_emitted: bool, +} + +impl DepthSlope { + /// Construct a new depth-slope indicator. + pub const fn new() -> Self { + Self { has_emitted: false } + } +} + +impl Indicator for DepthSlope { + type Input = OrderBook; + type Output = f64; + + fn update(&mut self, book: OrderBook) -> Option { + self.has_emitted = true; + let Some(mid) = book.mid() else { + return Some(0.0); + }; + if book.bids.len() < 2 || book.asks.len() < 2 { + return Some(0.0); + } + let bid_slope = cumulative_slope(&book.bids, mid, -1.0); + let ask_slope = cumulative_slope(&book.asks, mid, 1.0); + Some(f64::midpoint(bid_slope, ask_slope)) + } + + fn reset(&mut self) { + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "DepthSlope" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn book(bids: &[(f64, f64)], asks: &[(f64, f64)]) -> OrderBook { + let to_levels = |xs: &[(f64, f64)]| { + xs.iter() + .map(|&(p, s)| Level::new(p, s).unwrap()) + .collect::>() + }; + OrderBook::new(to_levels(bids), to_levels(asks)).unwrap() + } + + #[test] + fn accessors_and_metadata() { + let ds = DepthSlope::new(); + assert_eq!(ds.name(), "DepthSlope"); + assert_eq!(ds.warmup_period(), 1); + assert!(!ds.is_ready()); + } + + #[test] + fn thickening_book_has_positive_slope() { + let mut ds = DepthSlope::new(); + let out = ds + .update(book( + &[(99.0, 1.0), (98.0, 2.0), (97.0, 3.0)], + &[(101.0, 1.0), (102.0, 2.0), (103.0, 3.0)], + )) + .unwrap(); + assert!(out > 0.0); + assert!(ds.is_ready()); + } + + #[test] + fn front_loaded_book_has_smaller_slope_than_back_loaded() { + // Same total depth (6 per side), but one book thickens away from the + // touch and the other thins. Cumulative slope is non-negative for both; + // the back-loaded book accumulates faster, so its slope is larger. + let mut back = DepthSlope::new(); + let back_slope = back + .update(book( + &[(99.0, 1.0), (98.0, 2.0), (97.0, 3.0)], + &[(101.0, 1.0), (102.0, 2.0), (103.0, 3.0)], + )) + .unwrap(); + let mut front = DepthSlope::new(); + let front_slope = front + .update(book( + &[(99.0, 3.0), (98.0, 2.0), (97.0, 1.0)], + &[(101.0, 3.0), (102.0, 2.0), (103.0, 1.0)], + )) + .unwrap(); + assert!(front_slope >= 0.0); + assert!(back_slope > front_slope); + } + + #[test] + fn known_slope_value() { + // Symmetric book, each side: distances 1, 2; cumulative sizes 1, 3. + // OLS slope of (1->1, 2->3) = 2. Mean of two equal sides = 2. + let mut ds = DepthSlope::new(); + let out = ds + .update(book( + &[(99.0, 1.0), (98.0, 2.0)], + &[(101.0, 1.0), (102.0, 2.0)], + )) + .unwrap(); + assert!((out - 2.0).abs() < 1e-9); + } + + #[test] + fn single_level_side_is_zero() { + let mut ds = DepthSlope::new(); + // Bid side has only one level -> no slope -> 0. + assert_eq!( + ds.update(book(&[(100.0, 1.0)], &[(101.0, 1.0), (102.0, 1.0)])), + Some(0.0) + ); + } + + #[test] + fn empty_book_is_zero() { + let mut ds = DepthSlope::new(); + assert_eq!( + ds.update(OrderBook::new_unchecked(vec![], vec![])), + Some(0.0) + ); + } + + #[test] + fn degenerate_distance_slope_is_zero() { + // Two levels at the same distance from mid carry zero x-variance. + let levels = [ + Level::new_unchecked(100.0, 1.0), + Level::new_unchecked(100.0, 2.0), + ]; + assert_eq!(cumulative_slope(&levels, 100.0, 1.0), 0.0); + } + + #[test] + fn batch_equals_streaming() { + let books: Vec = (0..20) + .map(|i| { + let extra = f64::from(i % 4); + book( + &[(99.0, 1.0 + extra), (98.0, 2.0)], + &[(101.0, 1.0), (102.0, 2.0 + extra)], + ) + }) + .collect(); + let mut a = DepthSlope::new(); + let mut b = DepthSlope::new(); + assert_eq!( + a.batch(&books), + books + .iter() + .map(|x| b.update(x.clone())) + .collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut ds = DepthSlope::new(); + ds.update(book( + &[(99.0, 1.0), (98.0, 2.0)], + &[(101.0, 1.0), (102.0, 2.0)], + )); + assert!(ds.is_ready()); + ds.reset(); + assert!(!ds.is_ready()); + } +} diff --git a/crates/wickra-core/src/indicators/effective_spread.rs b/crates/wickra-core/src/indicators/effective_spread.rs new file mode 100644 index 00000000..5c61cb00 --- /dev/null +++ b/crates/wickra-core/src/indicators/effective_spread.rs @@ -0,0 +1,157 @@ +//! Effective Spread — the realised cost of a single trade in basis points. + +use crate::microstructure::TradeQuote; +use crate::traits::Indicator; + +/// Effective Spread — twice the signed deviation of an executed trade price +/// from the prevailing mid, expressed in basis points of the mid. +/// +/// ```text +/// effectiveSpread = 2 · D · (tradePrice − mid) / mid · 10_000 (bps) +/// ``` +/// +/// where `D` is the aggressor sign (`+1` for a buy, `−1` for a sell). The +/// factor of two scales the one-sided deviation up to a full round-trip cost so +/// it is directly comparable to the [quoted spread]: a marketable order that +/// fills exactly at the touch of an otherwise quoted-spread book pays an +/// effective spread equal to the quoted spread. Trades that fill *inside* the +/// spread (price improvement) read below the quoted spread; trades that walk +/// the book read above it. +/// +/// A buy printed above the mid (`tradePrice > mid`) and a sell printed below it +/// both yield a positive effective spread — the conventional sign, since the +/// aggressor pays in both cases. A trade printed on the wrong side of the mid +/// for its aggressor flag (a buy below the mid) reads negative, the signature of +/// price improvement or a stale/mislabelled quote. +/// +/// `Input = TradeQuote`, `Output = f64`. Stateless; ready after the first +/// trade-quote. +/// +/// [quoted spread]: crate::QuotedSpread +/// +/// # Example +/// +/// ``` +/// use wickra_core::{EffectiveSpread, Indicator, Side, Trade, TradeQuote}; +/// +/// let mut es = EffectiveSpread::new(); +/// // Buy filled at 100.05 against a mid of 100.0: +/// // 2 · (+1) · (100.05 − 100.0) / 100.0 · 10_000 = 10 bps. +/// let trade = Trade::new(100.05, 1.0, Side::Buy, 0).unwrap(); +/// let quote = TradeQuote::new(trade, 100.0).unwrap(); +/// assert!((es.update(quote).unwrap() - 10.0).abs() < 1e-9); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct EffectiveSpread { + has_emitted: bool, +} + +impl EffectiveSpread { + /// Construct a new effective-spread indicator. + pub const fn new() -> Self { + Self { has_emitted: false } + } +} + +impl Indicator for EffectiveSpread { + type Input = TradeQuote; + type Output = f64; + + fn update(&mut self, quote: TradeQuote) -> Option { + self.has_emitted = true; + let sign = quote.trade.side.sign(); + Some(2.0 * sign * (quote.trade.price - quote.mid) / quote.mid * 10_000.0) + } + + fn reset(&mut self) { + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "EffectiveSpread" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::microstructure::{Side, Trade}; + use crate::traits::BatchExt; + + fn quote(price: f64, side: Side, mid: f64) -> TradeQuote { + TradeQuote::new(Trade::new(price, 1.0, side, 0).unwrap(), mid).unwrap() + } + + #[test] + fn accessors_and_metadata() { + let es = EffectiveSpread::new(); + assert_eq!(es.name(), "EffectiveSpread"); + assert_eq!(es.warmup_period(), 1); + assert!(!es.is_ready()); + } + + #[test] + fn buy_above_mid_is_positive() { + let mut es = EffectiveSpread::new(); + // 2 · (+1) · (100.05 − 100.0) / 100.0 · 10_000 = 10 bps. + let out = es.update(quote(100.05, Side::Buy, 100.0)).unwrap(); + assert!((out - 10.0).abs() < 1e-9); + assert!(es.is_ready()); + } + + #[test] + fn sell_below_mid_is_positive() { + let mut es = EffectiveSpread::new(); + // 2 · (−1) · (99.95 − 100.0) / 100.0 · 10_000 = 10 bps. + let out = es.update(quote(99.95, Side::Sell, 100.0)).unwrap(); + assert!((out - 10.0).abs() < 1e-9); + } + + #[test] + fn price_improvement_reads_negative() { + let mut es = EffectiveSpread::new(); + // A buy filled below the mid: price improvement -> negative. + let out = es.update(quote(99.95, Side::Buy, 100.0)).unwrap(); + assert!(out < 0.0); + } + + #[test] + fn trade_at_mid_is_zero() { + let mut es = EffectiveSpread::new(); + assert_eq!(es.update(quote(100.0, Side::Buy, 100.0)), Some(0.0)); + } + + #[test] + fn batch_equals_streaming() { + let quotes: Vec = (0..20) + .map(|i| { + let side = if i % 2 == 0 { Side::Buy } else { Side::Sell }; + let price = 100.0 + f64::from(i % 4) * 0.01; + quote(price, side, 100.0) + }) + .collect(); + let mut a = EffectiveSpread::new(); + let mut b = EffectiveSpread::new(); + assert_eq!( + a.batch("es), + quotes.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut es = EffectiveSpread::new(); + es.update(quote(100.05, Side::Buy, 100.0)); + assert!(es.is_ready()); + es.reset(); + assert!(!es.is_ready()); + } +} diff --git a/crates/wickra-core/src/indicators/kyles_lambda.rs b/crates/wickra-core/src/indicators/kyles_lambda.rs new file mode 100644 index 00000000..b6c0f994 --- /dev/null +++ b/crates/wickra-core/src/indicators/kyles_lambda.rs @@ -0,0 +1,281 @@ +//! Kyle's Lambda — rolling price impact per unit of signed order flow. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::microstructure::TradeQuote; +use crate::traits::Indicator; + +/// Kyle's Lambda — the rolling ordinary-least-squares slope of mid-price changes +/// on signed trade volume, the canonical measure of market depth / price +/// impact. +/// +/// Each `update` receives a [`TradeQuote`] — a trade plus the mid prevailing at +/// execution. Internally the indicator forms, per trade, the mid change since +/// the previous trade (`Δmid = midₜ − midₜ₋₁`) and the signed volume +/// (`q = size · D`, with `D` the aggressor sign), then runs a rolling OLS +/// regression of `Δmid` on `q` over the trailing window of `window` trades: +/// +/// ```text +/// cov = (1/n) · Σ q·Δmid − q̄·Δ̄mid +/// var = (1/n) · Σ q² − q̄² +/// λ = cov / var +/// ``` +/// +/// `λ` is the estimated price move per unit of signed volume: a deep, liquid +/// book absorbs flow with little movement and reads a small `λ`; a thin book +/// moves sharply per unit traded and reads a large `λ`. It is a direct, +/// model-light proxy for the slope of the demand curve in Kyle's microstructure +/// model. +/// +/// Each `update` is O(1): four running sums (`Σq`, `ΣΔmid`, `Σq²`, `Σq·Δmid`) +/// are maintained as the window slides. A window of constant signed volume has +/// zero variance and `λ` is undefined; the indicator returns `0` in that case +/// rather than producing `NaN`. +/// +/// `Input = TradeQuote`, `Output = f64`. It warms up for `window + 1` +/// trade-quotes: one to seed the previous mid, then `window` paired +/// observations. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, KylesLambda, Side, Trade, TradeQuote}; +/// +/// // A book where each trade moves the mid by exactly 0.5 per unit of signed +/// // volume gives λ = 0.5. +/// let mut lambda = KylesLambda::new(8).unwrap(); +/// let mut mid = 100.0; +/// let mut last = None; +/// for i in 0..20 { +/// let side = if i % 2 == 0 { Side::Buy } else { Side::Sell }; +/// let size = 1.0 + f64::from(i % 3); +/// let signed = size * side.sign(); +/// mid += 0.5 * signed; +/// let trade = Trade::new(mid, size, side, 0).unwrap(); +/// last = lambda.update(TradeQuote::new(trade, mid).unwrap()); +/// } +/// assert!((last.unwrap() - 0.5).abs() < 1e-9); +/// ``` +#[derive(Debug, Clone)] +pub struct KylesLambda { + window: usize, + prev_mid: Option, + pairs: VecDeque<(f64, f64)>, + sum_q: f64, + sum_dm: f64, + sum_qq: f64, + sum_qdm: f64, +} + +impl KylesLambda { + /// Construct a rolling Kyle's lambda over `window` paired observations. + /// + /// # Errors + /// + /// Returns [`Error::InvalidPeriod`] if `window < 2` (the regression + /// variance needs at least two observations). + pub fn new(window: usize) -> Result { + if window < 2 { + return Err(Error::InvalidPeriod { + message: "kyle's lambda needs window >= 2", + }); + } + Ok(Self { + window, + prev_mid: None, + pairs: VecDeque::with_capacity(window), + sum_q: 0.0, + sum_dm: 0.0, + sum_qq: 0.0, + sum_qdm: 0.0, + }) + } + + /// The configured window length, in paired observations. + pub const fn window(&self) -> usize { + self.window + } + + fn push_pair(&mut self, signed_vol: f64, delta_mid: f64) -> Option { + if self.pairs.len() == self.window { + let (old_q, old_dm) = self.pairs.pop_front().expect("non-empty"); + self.sum_q -= old_q; + self.sum_dm -= old_dm; + self.sum_qq -= old_q * old_q; + self.sum_qdm -= old_q * old_dm; + } + self.pairs.push_back((signed_vol, delta_mid)); + self.sum_q += signed_vol; + self.sum_dm += delta_mid; + self.sum_qq += signed_vol * signed_vol; + self.sum_qdm += signed_vol * delta_mid; + if self.pairs.len() < self.window { + return None; + } + let n = self.window as f64; + let mean_q = self.sum_q / n; + let mean_dm = self.sum_dm / n; + let var_q = (self.sum_qq / n - mean_q * mean_q).max(0.0); + let cov = self.sum_qdm / n - mean_q * mean_dm; + if var_q == 0.0 { + // Constant signed-volume window has no defined slope. + return Some(0.0); + } + Some(cov / var_q) + } +} + +impl Indicator for KylesLambda { + type Input = TradeQuote; + type Output = f64; + + fn update(&mut self, quote: TradeQuote) -> Option { + let mid = quote.mid; + let signed_vol = quote.trade.size * quote.trade.side.sign(); + let Some(prev) = self.prev_mid else { + self.prev_mid = Some(mid); + return None; + }; + self.prev_mid = Some(mid); + self.push_pair(signed_vol, mid - prev) + } + + fn reset(&mut self) { + self.prev_mid = None; + self.pairs.clear(); + self.sum_q = 0.0; + self.sum_dm = 0.0; + self.sum_qq = 0.0; + self.sum_qdm = 0.0; + } + + fn warmup_period(&self) -> usize { + self.window + 1 + } + + fn is_ready(&self) -> bool { + self.pairs.len() == self.window + } + + fn name(&self) -> &'static str { + "KylesLambda" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::microstructure::{Side, Trade}; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn quotes_with_impact(n: usize, impact: f64) -> Vec { + let mut mid = 100.0; + (0..n) + .map(|i| { + let side = if i % 2 == 0 { Side::Buy } else { Side::Sell }; + let size = 1.0 + (i % 3) as f64; + let signed = size * side.sign(); + mid += impact * signed; + let trade = Trade::new(mid, size, side, 0).unwrap(); + TradeQuote::new(trade, mid).unwrap() + }) + .collect() + } + + #[test] + fn rejects_window_below_two() { + assert!(KylesLambda::new(0).is_err()); + assert!(KylesLambda::new(1).is_err()); + assert!(KylesLambda::new(2).is_ok()); + } + + #[test] + fn accessors_and_metadata() { + let kl = KylesLambda::new(14).unwrap(); + assert_eq!(kl.name(), "KylesLambda"); + assert_eq!(kl.window(), 14); + assert_eq!(kl.warmup_period(), 15); + assert!(!kl.is_ready()); + } + + #[test] + fn recovers_constant_impact_slope() { + // mid moves exactly 0.5 per unit signed volume -> lambda = 0.5. + let last = KylesLambda::new(6) + .unwrap() + .batch("es_with_impact(20, 0.5)) + .into_iter() + .flatten() + .last() + .unwrap(); + assert_relative_eq!(last, 0.5, epsilon = 1e-9); + } + + #[test] + fn negative_impact_reads_negative() { + let last = KylesLambda::new(6) + .unwrap() + .batch("es_with_impact(20, -0.3)) + .into_iter() + .flatten() + .last() + .unwrap(); + assert_relative_eq!(last, -0.3, epsilon = 1e-9); + } + + #[test] + fn constant_signed_volume_is_zero() { + // Every trade is a buy of size 1: signed volume is constant -> var 0 -> 0. + let mut mid = 100.0; + let quotes: Vec = (0..10) + .map(|_| { + mid += 0.01; + let trade = Trade::new(mid, 1.0, Side::Buy, 0).unwrap(); + TradeQuote::new(trade, mid).unwrap() + }) + .collect(); + let last = KylesLambda::new(5) + .unwrap() + .batch("es) + .into_iter() + .flatten() + .last() + .unwrap(); + assert_relative_eq!(last, 0.0, epsilon = 1e-12); + } + + #[test] + fn warms_up_after_window_plus_one() { + let mut kl = KylesLambda::new(3).unwrap(); + let quotes = quotes_with_impact(4, 0.2); + assert_eq!(kl.update(quotes[0]), None); // seeds prev mid + assert_eq!(kl.update(quotes[1]), None); + assert_eq!(kl.update(quotes[2]), None); + assert!(!kl.is_ready()); + assert!(kl.update(quotes[3]).is_some()); + assert!(kl.is_ready()); + } + + #[test] + fn batch_equals_streaming() { + let quotes = quotes_with_impact(40, 0.15); + let batch = KylesLambda::new(10).unwrap().batch("es); + let mut kl = KylesLambda::new(10).unwrap(); + let streamed: Vec<_> = quotes.iter().map(|q| kl.update(*q)).collect(); + assert_eq!(batch, streamed); + } + + #[test] + fn reset_clears_state() { + let mut kl = KylesLambda::new(3).unwrap(); + for q in quotes_with_impact(6, 0.2) { + kl.update(q); + } + assert!(kl.is_ready()); + kl.reset(); + assert!(!kl.is_ready()); + assert_eq!(kl.update(quotes_with_impact(1, 0.2)[0]), None); + } +} diff --git a/crates/wickra-core/src/indicators/mod.rs b/crates/wickra-core/src/indicators/mod.rs index 2e2b67d2..f87df01c 100644 --- a/crates/wickra-core/src/indicators/mod.rs +++ b/crates/wickra-core/src/indicators/mod.rs @@ -54,6 +54,7 @@ mod decycler_oscillator; mod dema; mod demand_index; mod demark_pivots; +mod depth_slope; mod detrended_std_dev; mod doji; mod donchian; @@ -62,6 +63,7 @@ mod double_bollinger; mod dpo; mod drawdown_duration; mod ease_of_movement; +mod effective_spread; mod ehlers_stochastic; mod elder_impulse; mod ema; @@ -100,6 +102,7 @@ mod keltner; mod kst; mod kurtosis; mod kvo; +mod kyles_lambda; mod laguerre_rsi; mod lead_lag_cross_correlation; mod linreg; @@ -144,6 +147,7 @@ mod psar; mod pvi; mod quoted_spread; mod r_squared; +mod realized_spread; mod recovery_factor; mod relative_strength_ab; mod renko_trailing_stop; @@ -281,6 +285,7 @@ pub use decycler_oscillator::DecyclerOscillator; pub use dema::Dema; pub use demand_index::DemandIndex; pub use demark_pivots::{DemarkPivots, DemarkPivotsOutput}; +pub use depth_slope::DepthSlope; pub use detrended_std_dev::DetrendedStdDev; pub use doji::Doji; pub use donchian::{Donchian, DonchianOutput}; @@ -289,6 +294,7 @@ pub use double_bollinger::{DoubleBollinger, DoubleBollingerOutput}; pub use dpo::Dpo; pub use drawdown_duration::DrawdownDuration; pub use ease_of_movement::EaseOfMovement; +pub use effective_spread::EffectiveSpread; pub use ehlers_stochastic::EhlersStochastic; pub use elder_impulse::ElderImpulse; pub use ema::Ema; @@ -327,6 +333,7 @@ pub use keltner::{Keltner, KeltnerOutput}; pub use kst::{Kst, KstOutput}; pub use kurtosis::Kurtosis; pub use kvo::Kvo; +pub use kyles_lambda::KylesLambda; pub use laguerre_rsi::LaguerreRsi; pub use lead_lag_cross_correlation::{LeadLagCrossCorrelation, LeadLagCrossCorrelationOutput}; pub use linreg::LinearRegression; @@ -371,6 +378,7 @@ pub use psar::Psar; pub use pvi::Pvi; pub use quoted_spread::QuotedSpread; pub use r_squared::RSquared; +pub use realized_spread::RealizedSpread; pub use recovery_factor::RecoveryFactor; pub use relative_strength_ab::{RelativeStrengthAB, RelativeStrengthOutput}; pub use renko_trailing_stop::RenkoTrailingStop; @@ -731,9 +739,13 @@ pub const FAMILIES: &[(&str, &[&str])] = &[ "OrderBookImbalanceFull", "Microprice", "QuotedSpread", + "DepthSlope", "SignedVolume", "CumulativeVolumeDelta", "TradeImbalance", + "EffectiveSpread", + "RealizedSpread", + "KylesLambda", ], ), ( @@ -790,6 +802,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, 222, "FAMILIES total drifted from indicator count"); + assert_eq!(total, 226, "FAMILIES total drifted from indicator count"); } } diff --git a/crates/wickra-core/src/indicators/realized_spread.rs b/crates/wickra-core/src/indicators/realized_spread.rs new file mode 100644 index 00000000..c3c500e3 --- /dev/null +++ b/crates/wickra-core/src/indicators/realized_spread.rs @@ -0,0 +1,204 @@ +//! Realized Spread — the post-trade liquidity revenue of a trade in basis +//! points. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::microstructure::TradeQuote; +use crate::traits::Indicator; + +/// Realized Spread — twice the signed deviation of a trade price from the mid +/// that prevails `horizon` trades *later*, expressed in basis points of the +/// trade's contemporaneous mid. +/// +/// ```text +/// realizedSpread = 2 · D · (tradePrice − mid_{t+horizon}) / mid_t · 10_000 (bps) +/// ``` +/// +/// where `D` is the aggressor sign (`+1` for a buy, `−1` for a sell), `mid_t` +/// is the mid at the time of the trade, and `mid_{t+horizon}` is the mid +/// `horizon` trade-quotes later. Where the [effective spread] measures the full +/// cost paid by the aggressor against the contemporaneous mid, the realized +/// spread measures the share of that cost a liquidity provider *keeps* after +/// the mid has moved: it is the effective spread net of the price impact +/// (`effective = realized + 2 · priceImpact`). A high realized spread means +/// the quote was not picked off; a low or negative one is the signature of +/// adverse selection, the trade preceding a move in its own direction. +/// +/// The indicator buffers each incoming trade-quote and emits the realized +/// spread for the trade made `horizon` updates ago, once that future mid is +/// known. It warms up for `horizon + 1` trade-quotes — `update` returns `None` +/// until the first trade can be resolved — and then emits one value per update +/// in O(1). +/// +/// `Input = TradeQuote`, `Output = f64`. +/// +/// [effective spread]: crate::EffectiveSpread +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, RealizedSpread, Side, Trade, TradeQuote}; +/// +/// let mut rs = RealizedSpread::new(1).unwrap(); +/// let tq = |price: f64, side, mid| TradeQuote::new(Trade::new(price, 1.0, side, 0).unwrap(), mid).unwrap(); +/// // First trade buffered; nothing to resolve yet. +/// assert_eq!(rs.update(tq(100.10, Side::Buy, 100.0)), None); +/// // One trade later the mid is 100.20, resolving the first buy: +/// // 2 · (+1) · (100.10 − 100.20) / 100.0 · 10_000 = −20 bps (adverse selection). +/// let out = rs.update(tq(99.90, Side::Sell, 100.20)).unwrap(); +/// assert!((out - (-20.0)).abs() < 1e-9); +/// ``` +#[derive(Debug, Clone)] +pub struct RealizedSpread { + horizon: usize, + // Each pending entry is (aggressor sign, trade price, contemporaneous mid). + pending: VecDeque<(f64, f64, f64)>, + has_emitted: bool, +} + +impl RealizedSpread { + /// Construct a realized-spread indicator that resolves each trade against + /// the mid `horizon` trade-quotes later. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `horizon` is zero (the realized spread + /// is defined against a strictly future mid). + pub fn new(horizon: usize) -> Result { + if horizon == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + horizon, + pending: VecDeque::with_capacity(horizon + 1), + has_emitted: false, + }) + } + + /// The configured horizon, in trade-quotes. + pub const fn horizon(&self) -> usize { + self.horizon + } +} + +impl Indicator for RealizedSpread { + type Input = TradeQuote; + type Output = f64; + + fn update(&mut self, quote: TradeQuote) -> Option { + let sign = quote.trade.side.sign(); + self.pending.push_back((sign, quote.trade.price, quote.mid)); + if self.pending.len() <= self.horizon { + return None; + } + let (old_sign, old_price, old_mid) = self.pending.pop_front().expect("len > horizon >= 1"); + self.has_emitted = true; + // `quote.mid` is the mid prevailing `horizon` trades after the resolved + // trade; normalise by that trade's own contemporaneous mid. + Some(2.0 * old_sign * (old_price - quote.mid) / old_mid * 10_000.0) + } + + fn reset(&mut self) { + self.pending.clear(); + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + self.horizon + 1 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "RealizedSpread" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::microstructure::{Side, Trade}; + use crate::traits::BatchExt; + + fn tq(price: f64, side: Side, mid: f64) -> TradeQuote { + TradeQuote::new(Trade::new(price, 1.0, side, 0).unwrap(), mid).unwrap() + } + + #[test] + fn rejects_zero_horizon() { + assert!(matches!(RealizedSpread::new(0), Err(Error::PeriodZero))); + assert!(RealizedSpread::new(1).is_ok()); + } + + #[test] + fn accessors_and_metadata() { + let rs = RealizedSpread::new(3).unwrap(); + assert_eq!(rs.name(), "RealizedSpread"); + assert_eq!(rs.horizon(), 3); + assert_eq!(rs.warmup_period(), 4); + assert!(!rs.is_ready()); + } + + #[test] + fn resolves_against_future_mid() { + let mut rs = RealizedSpread::new(1).unwrap(); + assert_eq!(rs.update(tq(100.10, Side::Buy, 100.0)), None); + assert!(!rs.is_ready()); + // 2 · (+1) · (100.10 − 100.20) / 100.0 · 10_000 = −20 bps. + let out = rs.update(tq(99.90, Side::Sell, 100.20)).unwrap(); + assert!((out - (-20.0)).abs() < 1e-9); + assert!(rs.is_ready()); + } + + #[test] + fn no_adverse_move_equals_effective_spread() { + // If the mid does not move over the horizon, realized == effective. + let mut rs = RealizedSpread::new(1).unwrap(); + rs.update(tq(100.05, Side::Buy, 100.0)); + // mid stays at 100.0 -> 2 · (100.05 − 100.0) / 100.0 · 10_000 = 10 bps. + let out = rs.update(tq(100.0, Side::Buy, 100.0)).unwrap(); + assert!((out - 10.0).abs() < 1e-9); + } + + #[test] + fn longer_horizon_warms_up() { + let mut rs = RealizedSpread::new(3).unwrap(); + for _ in 0..3 { + assert_eq!(rs.update(tq(100.0, Side::Buy, 100.0)), None); + } + assert!(!rs.is_ready()); + assert!(rs.update(tq(100.0, Side::Buy, 100.0)).is_some()); + assert!(rs.is_ready()); + } + + #[test] + fn batch_equals_streaming() { + let quotes: Vec = (0..30) + .map(|i| { + let side = if i % 2 == 0 { Side::Buy } else { Side::Sell }; + let mid = 100.0 + f64::from(i % 5) * 0.05; + tq(mid + 0.02, side, mid) + }) + .collect(); + let mut a = RealizedSpread::new(4).unwrap(); + let mut b = RealizedSpread::new(4).unwrap(); + assert_eq!( + a.batch("es), + quotes.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut rs = RealizedSpread::new(1).unwrap(); + rs.update(tq(100.05, Side::Buy, 100.0)); + rs.update(tq(100.0, Side::Buy, 100.0)); + assert!(rs.is_ready()); + rs.reset(); + assert!(!rs.is_ready()); + assert_eq!(rs.update(tq(100.05, Side::Buy, 100.0)), None); + } +} diff --git a/crates/wickra-core/src/lib.rs b/crates/wickra-core/src/lib.rs index f93732dc..d287df7b 100644 --- a/crates/wickra-core/src/lib.rs +++ b/crates/wickra-core/src/lib.rs @@ -55,36 +55,36 @@ pub use indicators::{ ChandelierExitOutput, ChoppinessIndex, ClassicPivots, ClassicPivotsOutput, Cmo, CoefficientOfVariation, Cointegration, CointegrationOutput, ConditionalValueAtRisk, ConnorsRsi, Coppock, CumulativeVolumeDelta, CyberneticCycle, Decycler, DecyclerOscillator, Dema, - DemandIndex, DemarkPivots, DemarkPivotsOutput, DetrendedStdDev, Doji, Donchian, DonchianOutput, - DonchianStop, DonchianStopOutput, DoubleBollinger, DoubleBollingerOutput, Dpo, - DrawdownDuration, EaseOfMovement, EhlersStochastic, ElderImpulse, Ema, + DemandIndex, DemarkPivots, DemarkPivotsOutput, DepthSlope, DetrendedStdDev, Doji, Donchian, + DonchianOutput, DonchianStop, DonchianStopOutput, DoubleBollinger, DoubleBollingerOutput, Dpo, + DrawdownDuration, EaseOfMovement, EffectiveSpread, EhlersStochastic, ElderImpulse, Ema, EmpiricalModeDecomposition, Engulfing, Evwma, Fama, FibonacciPivots, FibonacciPivotsOutput, FisherTransform, ForceIndex, FractalChaosBands, FractalChaosBandsOutput, Frama, GainLossRatio, GarmanKlassVolatility, Hammer, HangingMan, Harami, HeikinAshi, HeikinAshiOutput, HiLoActivator, HilbertDominantCycle, HistoricalVolatility, Hma, HurstChannel, HurstChannelOutput, HurstExponent, Ichimoku, IchimokuOutput, Inertia, InformationRatio, InitialBalance, InitialBalanceOutput, InstantaneousTrendline, InverseFisherTransform, InvertedHammer, Jma, - Kama, KellyCriterion, Keltner, KeltnerOutput, Kst, KstOutput, Kurtosis, Kvo, LaguerreRsi, - LeadLagCrossCorrelation, LeadLagCrossCorrelationOutput, LinRegAngle, LinRegChannel, - LinRegChannelOutput, LinRegSlope, LinearRegression, MaEnvelope, MaEnvelopeOutput, - MacdIndicator, MacdOutput, Mama, MamaOutput, MarketFacilitationIndex, Marubozu, MassIndex, - MaxDrawdown, McGinleyDynamic, MedianAbsoluteDeviation, MedianPrice, Mfi, Microprice, Mom, - MorningEveningStar, Natr, Nvi, Obv, OmegaRatio, OpeningRange, OpeningRangeOutput, - OrderBookImbalanceFull, OrderBookImbalanceTop1, OrderBookImbalanceTopN, PainIndex, - PairSpreadZScore, PairwiseBeta, ParkinsonVolatility, PearsonCorrelation, PercentB, + Kama, KellyCriterion, Keltner, KeltnerOutput, Kst, KstOutput, Kurtosis, Kvo, KylesLambda, + LaguerreRsi, LeadLagCrossCorrelation, LeadLagCrossCorrelationOutput, LinRegAngle, + LinRegChannel, LinRegChannelOutput, LinRegSlope, LinearRegression, MaEnvelope, + MaEnvelopeOutput, MacdIndicator, MacdOutput, Mama, MamaOutput, MarketFacilitationIndex, + Marubozu, MassIndex, MaxDrawdown, McGinleyDynamic, MedianAbsoluteDeviation, MedianPrice, Mfi, + Microprice, Mom, MorningEveningStar, Natr, Nvi, Obv, OmegaRatio, OpeningRange, + OpeningRangeOutput, OrderBookImbalanceFull, OrderBookImbalanceTop1, OrderBookImbalanceTopN, + PainIndex, PairSpreadZScore, PairwiseBeta, ParkinsonVolatility, PearsonCorrelation, PercentB, PercentageTrailingStop, Pgo, PiercingDarkCloud, Pmo, Ppo, ProfitFactor, Psar, Pvi, - QuotedSpread, RSquared, RecoveryFactor, RelativeStrengthAB, RelativeStrengthOutput, - RenkoTrailingStop, Roc, RogersSatchellVolatility, RollingVwap, RoofingFilter, Rsi, Rvi, - RviVolatility, Rwi, RwiOutput, SharpeRatio, ShootingStar, SignedVolume, SineWave, Skewness, - Sma, Smi, Smma, SortinoRatio, SpearmanCorrelation, SpinningTop, StandardError, - StandardErrorBands, StandardErrorBandsOutput, StarcBands, StarcBandsOutput, Stc, StdDev, - StepTrailingStop, StochRsi, Stochastic, StochasticOutput, SuperSmoother, SuperTrend, - SuperTrendOutput, TdCombo, TdCountdown, TdDeMarker, TdDifferential, TdLines, TdLinesOutput, - TdOpen, TdPressure, TdRangeProjection, TdRangeProjectionOutput, TdRei, TdRiskLevel, - TdRiskLevelOutput, TdSequential, TdSequentialOutput, TdSetup, Tema, ThreeInside, ThreeOutside, - ThreeSoldiersOrCrows, Tii, TradeImbalance, TreynorRatio, Trima, Trix, TrueRange, Tsi, Tsv, - TtmSqueeze, TtmSqueezeOutput, Tweezer, TypicalPrice, UlcerIndex, UltimateOscillator, ValueArea, - ValueAreaOutput, ValueAtRisk, Variance, VerticalHorizontalFilter, Vidya, VoltyStop, + QuotedSpread, RSquared, RealizedSpread, RecoveryFactor, RelativeStrengthAB, + RelativeStrengthOutput, RenkoTrailingStop, Roc, RogersSatchellVolatility, RollingVwap, + RoofingFilter, Rsi, Rvi, RviVolatility, Rwi, RwiOutput, SharpeRatio, ShootingStar, + SignedVolume, SineWave, Skewness, Sma, Smi, Smma, SortinoRatio, SpearmanCorrelation, + SpinningTop, StandardError, StandardErrorBands, StandardErrorBandsOutput, StarcBands, + StarcBandsOutput, Stc, StdDev, StepTrailingStop, StochRsi, Stochastic, StochasticOutput, + SuperSmoother, SuperTrend, SuperTrendOutput, TdCombo, TdCountdown, TdDeMarker, TdDifferential, + TdLines, TdLinesOutput, TdOpen, TdPressure, TdRangeProjection, TdRangeProjectionOutput, TdRei, + TdRiskLevel, TdRiskLevelOutput, TdSequential, TdSequentialOutput, TdSetup, Tema, ThreeInside, + ThreeOutside, ThreeSoldiersOrCrows, Tii, TradeImbalance, TreynorRatio, Trima, Trix, TrueRange, + Tsi, Tsv, TtmSqueeze, TtmSqueezeOutput, Tweezer, TypicalPrice, UlcerIndex, UltimateOscillator, + ValueArea, ValueAreaOutput, ValueAtRisk, Variance, VerticalHorizontalFilter, Vidya, VoltyStop, VolumeOscillator, VolumePriceTrend, Vortex, VortexOutput, Vwap, VwapStdDevBands, VwapStdDevBandsOutput, Vwma, Vzo, WaveTrend, WaveTrendOutput, WeightedClose, WilliamsFractals, WilliamsFractalsOutput, WilliamsR, Wma, WoodiePivots, WoodiePivotsOutput, YangZhangVolatility, diff --git a/crates/wickra/benches/indicators.rs b/crates/wickra/benches/indicators.rs index 775876f7..30e48883 100644 --- a/crates/wickra/benches/indicators.rs +++ b/crates/wickra/benches/indicators.rs @@ -33,14 +33,14 @@ use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion, Through use std::hint::black_box; use wickra::{ Adx, Atr, Autocorrelation, BatchExt, BollingerBands, BollingerOutput, CalmarRatio, Candle, Cci, - ClassicPivots, ConnorsRsi, Ema, EmpiricalModeDecomposition, Engulfing, Frama, - HilbertDominantCycle, HurstExponent, Ichimoku, IchimokuOutput, Indicator, Jma, Level, - LinearRegression, MacdIndicator, MacdOutput, Mama, MamaOutput, MaxDrawdown, Microprice, Obv, - OrderBook, OrderBookImbalanceFull, OrderBookImbalanceTop1, ParkinsonVolatility, Ppo, Psar, - RollingVwap, Rsi, SharpeRatio, Side, SignedVolume, Sma, Stc, SuperTrend, SuperTrendOutput, - TdSequential, TdSequentialOutput, Trade, TradeImbalance, TtmSqueeze, TtmSqueezeOutput, - ValueArea, ValueAreaOutput, ValueAtRisk, Vwap, VwapStdDevBands, VwapStdDevBandsOutput, - WaveTrend, YangZhangVolatility, T3, + ClassicPivots, ConnorsRsi, DepthSlope, EffectiveSpread, Ema, EmpiricalModeDecomposition, + Engulfing, Frama, HilbertDominantCycle, HurstExponent, Ichimoku, IchimokuOutput, Indicator, + Jma, KylesLambda, Level, LinearRegression, MacdIndicator, MacdOutput, Mama, MamaOutput, + MaxDrawdown, Microprice, Obv, OrderBook, OrderBookImbalanceFull, OrderBookImbalanceTop1, + ParkinsonVolatility, Ppo, Psar, RollingVwap, Rsi, SharpeRatio, Side, SignedVolume, Sma, Stc, + SuperTrend, SuperTrendOutput, TdSequential, TdSequentialOutput, Trade, TradeImbalance, + TradeQuote, TtmSqueeze, TtmSqueezeOutput, ValueArea, ValueAreaOutput, ValueAtRisk, Vwap, + VwapStdDevBands, VwapStdDevBandsOutput, WaveTrend, YangZhangVolatility, T3, }; use wickra_data::csv::CandleReader; @@ -159,6 +159,28 @@ where group.finish(); } +fn bench_tradequote_input(c: &mut Criterion, name: &str, quotes: &[TradeQuote], make: F) +where + F: Fn() -> I, + I: Indicator, +{ + let mut group = c.benchmark_group(name); + for &n in SIZES { + let n = n.min(quotes.len()); + let series = "es[..n]; + group.throughput(Throughput::Elements(n as u64)); + group.bench_with_input(BenchmarkId::new("streaming", n), series, |b, quotes| { + b.iter(|| { + let mut ind = make(); + for q in quotes { + black_box(ind.update(*q)); + } + }); + }); + } + group.finish(); +} + fn bench_scalar_multi(c: &mut Criterion, name: &str, prices: &[f64], make: F) where F: Fn() -> I, @@ -332,6 +354,7 @@ fn benches(c: &mut Criterion) { bench_orderbook_input(c, "ob_imbalance_top1", &books, OrderBookImbalanceTop1::new); bench_orderbook_input(c, "ob_imbalance_full", &books, OrderBookImbalanceFull::new); bench_orderbook_input(c, "microprice", &books, Microprice::new); + bench_orderbook_input(c, "depth_slope", &books, DepthSlope::new); // Synthesise a trade tape from candles: one trade per bar, sided by the // candle's direction. SignedVolume is the cheapest; TradeImbalance carries @@ -351,6 +374,16 @@ fn benches(c: &mut Criterion) { bench_trade_input(c, "trade_imbalance", &trades, || { TradeImbalance::new(50).unwrap() }); + + // Pair each synthetic trade with the candle close as the prevailing mid to + // exercise the price-impact family. EffectiveSpread is the stateless + // representative. + let quotes: Vec = trades + .iter() + .map(|trade| TradeQuote::new_unchecked(*trade, trade.price)) + .collect(); + bench_tradequote_input(c, "effective_spread", "es, EffectiveSpread::new); + bench_tradequote_input(c, "kyles_lambda", "es, || KylesLambda::new(50).unwrap()); } criterion_group!(name = wickra_benches; config = Criterion::default(); targets = benches); diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index 7dbda145..9ecac434 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -66,6 +66,13 @@ test = false doc = false bench = false +[[bin]] +name = "indicator_update_tradequote" +path = "fuzz_targets/indicator_update_tradequote.rs" +test = false +doc = false +bench = false + [[bin]] name = "tick_aggregator" path = "fuzz_targets/tick_aggregator.rs" diff --git a/fuzz/fuzz_targets/indicator_update_orderbook.rs b/fuzz/fuzz_targets/indicator_update_orderbook.rs index 03d27c50..21c1a15f 100644 --- a/fuzz/fuzz_targets/indicator_update_orderbook.rs +++ b/fuzz/fuzz_targets/indicator_update_orderbook.rs @@ -12,7 +12,7 @@ use libfuzzer_sys::fuzz_target; use wickra_core::{ - BatchExt, Indicator, Level, Microprice, OrderBook, OrderBookImbalanceFull, + BatchExt, DepthSlope, Indicator, Level, Microprice, OrderBook, OrderBookImbalanceFull, OrderBookImbalanceTop1, OrderBookImbalanceTopN, QuotedSpread, }; @@ -51,4 +51,5 @@ fuzz_target!(|data: &[u8]| { drive(OrderBookImbalanceFull::new, &books); drive(Microprice::new, &books); drive(QuotedSpread::new, &books); + drive(DepthSlope::new, &books); }); diff --git a/fuzz/fuzz_targets/indicator_update_tradequote.rs b/fuzz/fuzz_targets/indicator_update_tradequote.rs new file mode 100644 index 00000000..76cf53eb --- /dev/null +++ b/fuzz/fuzz_targets/indicator_update_tradequote.rs @@ -0,0 +1,47 @@ +#![no_main] +//! Fuzz price-impact `Indicator` implementations with +//! arbitrary trade-quote tapes. +//! +//! Each iteration consumes a byte stream, interprets it as a sequence of `f64` +//! values (8 bytes each), and packs consecutive triples into `(price, size, +//! mid)` trade-quotes whose aggressor side alternates with the sign of the size +//! field. Trade-quotes are built with the `new_unchecked` constructors so the +//! fuzzer can explore degenerate values (non-finite, negative, zero mid) that +//! the validating constructors would reject — the indicators must never panic, +//! streaming or batched. + +use libfuzzer_sys::fuzz_target; +use wickra_core::{ + BatchExt, EffectiveSpread, Indicator, KylesLambda, RealizedSpread, Side, Trade, TradeQuote, +}; + +#[inline(never)] +fn drive(make: impl Fn() -> I, quotes: &[TradeQuote]) +where + I: Indicator + BatchExt, +{ + let mut streaming = make(); + for "e in quotes { + let _ = streaming.update(quote); + } + let _ = make().batch(quotes); +} + +fuzz_target!(|data: &[u8]| { + let floats: Vec = data + .chunks_exact(8) + .map(|c| f64::from_le_bytes(c.try_into().expect("8 bytes"))) + .collect(); + let quotes: Vec = floats + .chunks_exact(3) + .map(|c| { + let side = if c[1] >= 0.0 { Side::Buy } else { Side::Sell }; + let trade = Trade::new_unchecked(c[0], c[1], side, 0); + TradeQuote::new_unchecked(trade, c[2]) + }) + .collect(); + + drive(EffectiveSpread::new, "es); + drive(|| RealizedSpread::new(5).unwrap(), "es); + drive(|| KylesLambda::new(5).unwrap(), "es); +});