diff --git a/CHANGELOG.md b/CHANGELOG.md index a95b5116..e9c4cbfd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **Microstructure family — order book (part 1).** A new family of indicators + that consume an order-book depth snapshot (`OrderBook` of sorted, uncrossed + bid/ask `Level`s) rather than OHLCV, exposed in Rust, Python, Node and WASM: + - **Order-Book Imbalance** — `OrderBookImbalanceTop1`, `OrderBookImbalanceTopN` + (configurable depth) and `OrderBookImbalanceFull` measure signed depth + pressure `(bidDepth − askDepth) / (bidDepth + askDepth)` over the top level, + the top-N levels, or the full book. + - **Microprice** — the size-weighted fair value + `(bidPx·askSz + askPx·bidSz) / (bidSz + askSz)`, tilting the mid toward the + side more likely to be hit. + - **Quoted Spread** — the top-of-book spread in basis points of the mid. + + New public value types `Level`, `OrderBook`, `Side`, `Trade` and `TradeQuote` + back this and the upcoming trade-flow and price-impact indicators. Python and + Node accept a batch over a list of snapshots; WASM exposes per-snapshot + `update`. - **Signed Doji encoding.** `Doji` gains an opt-in `.signed()` mode (`Doji(signed=True)` in Python, `new Doji(true)` in Node and WASM) that classifies a detected Doji by the position of its body within the bar range — diff --git a/README.md b/README.md index d11b42db..8f898e10 100644 --- a/README.md +++ b/README.md @@ -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 219 indicators; start at the + every one of the 224 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 -219 streaming-first indicators across sixteen families. Every one passes the +224 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,6 +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 | | 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) | @@ -236,7 +237,7 @@ A Python live-trading example using the public `websockets` package lives at ``` wickra/ ├── crates/ -│ ├── wickra-core/ core engine + all 219 indicators +│ ├── wickra-core/ core engine + all 224 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 f9914d4b..e5eb2d26 100644 --- a/bindings/node/__tests__/indicators.test.js +++ b/bindings/node/__tests__/indicators.test.js @@ -912,3 +912,42 @@ test('Doji signed mode encodes dragonfly/gravestone/neutral direction', () => { assert.equal(d.update(10, 12, 8, 10), 0); // long-legged -> neutral 0 assert.equal(d.update(10, 12, 10, 12), 0); // not a doji -> 0 }); + +test('order-book indicators reference values', () => { + // Top-1: (3 - 1) / (3 + 1) = 0.5. + assert.equal(new wickra.OrderBookImbalanceTop1().update([100], [3], [101], [1]), 0.5); + // Top-2: bidDepth 3, askDepth 2 -> (3 - 2) / 5 = 0.2. + assert.ok( + Math.abs(new wickra.OrderBookImbalanceTopN(2).update([100, 99], [2, 1], [101, 102], [1, 1]) - 0.2) < 1e-12, + ); + // Full: bidDepth 1, askDepth 3 -> -0.5. + assert.equal(new wickra.OrderBookImbalanceFull().update([100], [1], [101, 102], [2, 1]), -0.5); + // Microprice: (100*3 + 101*1) / 4 = 100.25. + 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); +}); + +test('order-book streaming update matches batch', () => { + const snaps = Array.from({ length: 30 }, (_, i) => ({ + bidPx: [100, 99], + bidSz: [1 + (i % 5), 1], + askPx: [101, 102], + askSz: [1 + ((i + 1) % 3), 1], + })); + const batch = new wickra.Microprice().batch(snaps); + const streamer = new wickra.Microprice(); + 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(Math.abs(s - batch[i]) < 1e-12, `mismatch at ${i}: ${s} vs ${batch[i]}`); + } +}); + +test('order-book TopN rejects zero levels', () => { + assert.throws(() => new wickra.OrderBookImbalanceTopN(0)); +}); + +test('order-book update rejects a crossed book', () => { + assert.throws(() => new wickra.QuotedSpread().update([102], [1], [101], [1])); +}); diff --git a/bindings/node/index.d.ts b/bindings/node/index.d.ts index 57818e71..e4ea282f 100644 --- a/bindings/node/index.d.ts +++ b/bindings/node/index.d.ts @@ -279,6 +279,13 @@ export interface OpeningRangeValue { low: number breakoutDistance: number } +/** One order-book depth snapshot for batch evaluation. */ +export interface ObSnapshot { + bidPx: Array + bidSz: Array + askPx: Array + askSz: Array +} export type SmaNode = SMA export declare class SMA { constructor(period: number) @@ -2189,6 +2196,51 @@ export declare class ThreeOutside { isReady(): boolean warmupPeriod(): number } +export type OrderBookImbalanceTop1Node = OrderBookImbalanceTop1 +export declare class OrderBookImbalanceTop1 { + constructor() + update(bidPx: Array, bidSz: Array, askPx: Array, askSz: Array): number | null + batch(snapshots: Array): Array + reset(): void + isReady(): boolean + warmupPeriod(): number +} +export type OrderBookImbalanceFullNode = OrderBookImbalanceFull +export declare class OrderBookImbalanceFull { + constructor() + update(bidPx: Array, bidSz: Array, askPx: Array, askSz: Array): number | null + batch(snapshots: Array): Array + reset(): void + isReady(): boolean + warmupPeriod(): number +} +export type MicropriceNode = Microprice +export declare class Microprice { + constructor() + update(bidPx: Array, bidSz: Array, askPx: Array, askSz: Array): number | null + batch(snapshots: Array): Array + reset(): void + isReady(): boolean + warmupPeriod(): number +} +export type QuotedSpreadNode = QuotedSpread +export declare class QuotedSpread { + 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) + update(bidPx: Array, bidSz: Array, askPx: Array, askSz: Array): number | null + batch(snapshots: 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 39d8e357..b8104dfd 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, 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, OrderBookImbalanceTopN, 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 @@ -515,6 +515,11 @@ module.exports.Tweezer = Tweezer module.exports.SpinningTop = SpinningTop module.exports.ThreeInside = ThreeInside module.exports.ThreeOutside = ThreeOutside +module.exports.OrderBookImbalanceTop1 = OrderBookImbalanceTop1 +module.exports.OrderBookImbalanceFull = OrderBookImbalanceFull +module.exports.Microprice = Microprice +module.exports.QuotedSpread = QuotedSpread +module.exports.OrderBookImbalanceTopN = OrderBookImbalanceTopN 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 69848931..7e1b3f95 100644 --- a/bindings/node/src/lib.rs +++ b/bindings/node/src/lib.rs @@ -8754,6 +8754,166 @@ node_candle_pattern!(SpinningTopNode, wc::SpinningTop, "SpinningTop"); node_candle_pattern!(ThreeInsideNode, wc::ThreeInside, "ThreeInside"); node_candle_pattern!(ThreeOutsideNode, wc::ThreeOutside, "ThreeOutside"); +// ============================== Microstructure: Order Book ============================== +// +// Order-book indicators consume a depth snapshot rather than OHLCV. Streaming +// `update(bidPx, bidSz, askPx, askSz)` takes four equal-length arrays for one +// snapshot (bids best-first = descending price, asks best-first = ascending +// price); `batch` takes an array of `{ bidPx, bidSz, askPx, askSz }` snapshots +// and returns one value per snapshot. + +/// One order-book depth snapshot for batch evaluation. +#[napi(object)] +pub struct ObSnapshot { + pub bid_px: Vec, + pub bid_sz: Vec, + pub ask_px: Vec, + pub ask_sz: Vec, +} + +fn build_order_book( + bid_px: &[f64], + bid_sz: &[f64], + ask_px: &[f64], + ask_sz: &[f64], +) -> napi::Result { + if bid_px.len() != bid_sz.len() || ask_px.len() != ask_sz.len() { + return Err(NapiError::from_reason( + "bid/ask price and size arrays must be equal length".to_string(), + )); + } + let bids = bid_px + .iter() + .zip(bid_sz) + .map(|(&p, &s)| wc::Level::new_unchecked(p, s)) + .collect(); + let asks = ask_px + .iter() + .zip(ask_sz) + .map(|(&p, &s)| wc::Level::new_unchecked(p, s)) + .collect(); + wc::OrderBook::new(bids, asks).map_err(map_err) +} + +macro_rules! node_ob_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, + 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 + } + } + }; +} + +node_ob_indicator!( + OrderBookImbalanceTop1Node, + wc::OrderBookImbalanceTop1, + "OrderBookImbalanceTop1" +); +node_ob_indicator!( + OrderBookImbalanceFullNode, + wc::OrderBookImbalanceFull, + "OrderBookImbalanceFull" +); +node_ob_indicator!(MicropriceNode, wc::Microprice, "Microprice"); +node_ob_indicator!(QuotedSpreadNode, wc::QuotedSpread, "QuotedSpread"); + +// Top-N imbalance carries a `levels` parameter, so it is hand-written. +#[napi(js_name = "OrderBookImbalanceTopN")] +pub struct OrderBookImbalanceTopNNode { + inner: wc::OrderBookImbalanceTopN, +} + +#[napi] +impl OrderBookImbalanceTopNNode { + #[napi(constructor)] + pub fn new(levels: u32) -> napi::Result { + Ok(Self { + inner: wc::OrderBookImbalanceTopN::new(levels 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 + } +} + // ============================== 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 15f88bdb..af5efc7b 100644 --- a/bindings/python/python/wickra/__init__.py +++ b/bindings/python/python/wickra/__init__.py @@ -240,6 +240,12 @@ from ._wickra import ( SpinningTop, ThreeInside, ThreeOutside, + # Microstructure: order book + OrderBookImbalanceTop1, + OrderBookImbalanceTopN, + OrderBookImbalanceFull, + Microprice, + QuotedSpread, # Risk / Performance SharpeRatio, SortinoRatio, @@ -477,6 +483,12 @@ __all__ = [ "SpinningTop", "ThreeInside", "ThreeOutside", + # Microstructure: order book + "OrderBookImbalanceTop1", + "OrderBookImbalanceTopN", + "OrderBookImbalanceFull", + "Microprice", + "QuotedSpread", # Risk / Performance "SharpeRatio", "SortinoRatio", diff --git a/bindings/python/src/lib.rs b/bindings/python/src/lib.rs index 5e1613b0..fc4c0c9c 100644 --- a/bindings/python/src/lib.rs +++ b/bindings/python/src/lib.rs @@ -26,7 +26,9 @@ fn map_err(e: wc::Error) -> PyErr { | wc::Error::NonPositiveMultiplier | wc::Error::NonFiniteInput | wc::Error::InvalidCandle { .. } - | wc::Error::InvalidTick { .. } => PyValueError::new_err(e.to_string()), + | wc::Error::InvalidTick { .. } + | wc::Error::InvalidOrderBook { .. } + | wc::Error::InvalidTrade { .. } => PyValueError::new_err(e.to_string()), } } @@ -11613,6 +11615,162 @@ candle_pattern_no_param!(PySpinningTop, wc::SpinningTop, "SpinningTop"); candle_pattern_no_param!(PyThreeInside, wc::ThreeInside, "ThreeInside"); candle_pattern_no_param!(PyThreeOutside, wc::ThreeOutside, "ThreeOutside"); +// ============================== Microstructure: Order Book ============================== +// +// Order-book indicators consume a depth snapshot rather than OHLCV. Streaming +// `update(bid_px, bid_sz, ask_px, ask_sz)` takes four equal-length sequences +// describing one snapshot (bids best-first = descending price, asks best-first +// = ascending price); `batch` takes a list of such `(bid_px, bid_sz, ask_px, +// ask_sz)` tuples and returns one value per snapshot. + +fn build_order_book( + bid_px: &[f64], + bid_sz: &[f64], + ask_px: &[f64], + ask_sz: &[f64], +) -> PyResult { + if bid_px.len() != bid_sz.len() || ask_px.len() != ask_sz.len() { + return Err(PyValueError::new_err( + "bid/ask price and size arrays must be equal length", + )); + } + let bids = bid_px + .iter() + .zip(bid_sz) + .map(|(&p, &s)| wc::Level::new_unchecked(p, s)) + .collect(); + let asks = ask_px + .iter() + .zip(ask_sz) + .map(|(&p, &s)| wc::Level::new_unchecked(p, s)) + .collect(); + wc::OrderBook::new(bids, asks).map_err(map_err) +} + +macro_rules! py_ob_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, + 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!("{}()", $repr) + } + } + }; +} + +py_ob_indicator!( + PyOrderBookImbalanceTop1, + wc::OrderBookImbalanceTop1, + "OrderBookImbalanceTop1" +); +py_ob_indicator!( + PyOrderBookImbalanceFull, + wc::OrderBookImbalanceFull, + "OrderBookImbalanceFull" +); +py_ob_indicator!(PyMicroprice, wc::Microprice, "Microprice"); +py_ob_indicator!(PyQuotedSpread, wc::QuotedSpread, "QuotedSpread"); + +// Top-N imbalance carries a `levels` parameter, so it is hand-written. +#[pyclass( + name = "OrderBookImbalanceTopN", + module = "wickra._wickra", + skip_from_py_object +)] +#[derive(Clone)] +struct PyOrderBookImbalanceTopN { + inner: wc::OrderBookImbalanceTopN, +} + +#[pymethods] +impl PyOrderBookImbalanceTopN { + #[new] + fn new(levels: usize) -> PyResult { + Ok(Self { + inner: wc::OrderBookImbalanceTopN::new(levels).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!("OrderBookImbalanceTopN(levels={})", self.inner.levels()) + } +} + // ============================== Family 15: Risk / Performance ============================== #[pyclass(name = "SharpeRatio", module = "wickra._wickra", skip_from_py_object)] @@ -12718,6 +12876,12 @@ fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; m.add_class::()?; m.add_class::()?; + // Microstructure: order book. + m.add_class::()?; + m.add_class::()?; + 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 e1298fbd..5b207ab9 100644 --- a/bindings/python/tests/test_input_validation.py +++ b/bindings/python/tests/test_input_validation.py @@ -166,3 +166,28 @@ def test_family_10_ehlers_rejects_invalid_parameters(): ta.MAMA(0.05, 0.5) with pytest.raises(ValueError): ta.EmpiricalModeDecomposition(20, 0.0) + + +def test_orderbook_topn_zero_levels_raises(): + with pytest.raises(ValueError): + ta.OrderBookImbalanceTopN(0) + + +def test_orderbook_unequal_price_size_lengths_raise(): + # bid_px has 2 entries but bid_sz has 1 -> mismatched -> ValueError. + with pytest.raises(ValueError): + ta.OrderBookImbalanceTop1().update([100.0, 99.0], [1.0], [101.0], [1.0]) + with pytest.raises(ValueError): + ta.Microprice().update([100.0], [1.0], [101.0, 102.0], [1.0]) + + +def test_orderbook_crossed_book_raises(): + # best_bid (102) >= best_ask (101) is a crossed book -> rejected. + with pytest.raises(ValueError): + ta.QuotedSpread().update([102.0], [1.0], [101.0], [1.0]) + + +def test_orderbook_misordered_levels_raise(): + # Bids must be strictly descending in price. + with pytest.raises(ValueError): + ta.OrderBookImbalanceFull().update([99.0, 100.0], [1.0, 1.0], [101.0], [1.0]) diff --git a/bindings/python/tests/test_known_values.py b/bindings/python/tests/test_known_values.py index 3f9242a8..cb831602 100644 --- a/bindings/python/tests/test_known_values.py +++ b/bindings/python/tests/test_known_values.py @@ -847,3 +847,26 @@ def test_doji_signed_dragonfly_gravestone_neutral(): assert d.update((10.0, 12.0, 8.0, 10.0, 1.0, 2)) == pytest.approx(0.0) # A large body is not a doji at all -> 0 regardless of position. assert d.update((10.0, 12.0, 10.0, 12.0, 1.0, 3)) == pytest.approx(0.0) + + +def test_orderbook_imbalance_reference_values(): + # Top-1: (3 - 1) / (3 + 1) = 0.5. + assert ta.OrderBookImbalanceTop1().update([100.0], [3.0], [101.0], [1.0]) == pytest.approx(0.5) + # Top-2: bidDepth 3, askDepth 2 -> (3 - 2) / 5 = 0.2. + topn = ta.OrderBookImbalanceTopN(2) + assert topn.update([100.0, 99.0], [2.0, 1.0], [101.0, 102.0], [1.0, 1.0]) == pytest.approx(0.2) + # Full: bidDepth 1, askDepth 3 -> (1 - 3) / 4 = -0.5. + full = ta.OrderBookImbalanceFull() + assert full.update([100.0], [1.0], [101.0, 102.0], [2.0, 1.0]) == pytest.approx(-0.5) + + +def test_microprice_reference_value(): + # (100*3 + 101*1) / (1 + 3) = 401 / 4 = 100.25 — heavy ask pulls toward bid. + mp = ta.Microprice() + assert mp.update([100.0], [1.0], [101.0], [3.0]) == pytest.approx(100.25) + + +def test_quoted_spread_reference_value(): + # spread 1.0, mid 100.5 -> 1 / 100.5 * 10_000 ≈ 99.5025 bps. + qs = ta.QuotedSpread() + assert qs.update([100.0], [1.0], [101.0], [1.0]) == pytest.approx(99.50248756, abs=1e-6) diff --git a/bindings/python/tests/test_lifecycle.py b/bindings/python/tests/test_lifecycle.py index cab8616f..7cf4b5f0 100644 --- a/bindings/python/tests/test_lifecycle.py +++ b/bindings/python/tests/test_lifecycle.py @@ -129,3 +129,24 @@ def test_ehlers_indicators_lifecycle(): assert ind.is_ready() ind.reset() assert not ind.is_ready() + + +def test_orderbook_lifecycle(): + snapshot = ([100.0], [1.0], [101.0], [1.0]) + for ind in [ + ta.OrderBookImbalanceTop1(), + ta.OrderBookImbalanceTopN(3), + ta.OrderBookImbalanceFull(), + ta.Microprice(), + ta.QuotedSpread(), + ]: + assert ind.warmup_period() == 1 + assert not ind.is_ready() + ind.update(*snapshot) + assert ind.is_ready() + ind.reset() + assert not ind.is_ready() + + +def test_orderbook_topn_repr(): + assert repr(ta.OrderBookImbalanceTopN(5)) == "OrderBookImbalanceTopN(levels=5)" diff --git a/bindings/python/tests/test_new_indicators.py b/bindings/python/tests/test_new_indicators.py index df3bbfc0..5a83db82 100644 --- a/bindings/python/tests/test_new_indicators.py +++ b/bindings/python/tests/test_new_indicators.py @@ -1863,3 +1863,38 @@ def test_new_indicators_expose_lifecycle(): assert ind.warmup_period() >= 1 ind.reset() assert ind.is_ready() is False + + +def _orderbook_snapshots(n: int) -> list: + """A deterministic varying sequence of order-book snapshots.""" + snaps = [] + for i in range(n): + bid_sz = 1.0 + (i % 5) + ask_sz = 1.0 + ((i + 2) % 4) + snaps.append( + ( + [100.0, 99.0], + [bid_sz, 1.0], + [101.0, 102.0], + [ask_sz, 1.0], + ) + ) + return snaps + + +def test_orderbook_indicators_streaming_equals_batch(): + snaps = _orderbook_snapshots(40) + for make in ( + ta.OrderBookImbalanceTop1, + lambda: ta.OrderBookImbalanceTopN(2), + ta.OrderBookImbalanceFull, + ta.Microprice, + ta.QuotedSpread, + ): + batch = make().batch(snaps) + streamer = make() + streamed = np.array( + [streamer.update(*snap) for snap in snaps], dtype=np.float64 + ) + assert batch.shape == (len(snaps),) + assert _eq_nan(batch, streamed) diff --git a/bindings/python/tests/test_smoke.py b/bindings/python/tests/test_smoke.py index 389bfde4..e391a13b 100644 --- a/bindings/python/tests/test_smoke.py +++ b/bindings/python/tests/test_smoke.py @@ -97,3 +97,25 @@ def test_ehlers_super_smoother_batch_shape(sine_prices): def test_mama_batch_shape(sine_prices): out = ta.MAMA().batch(sine_prices) assert out.shape == (sine_prices.size, 2) + + +def test_orderbook_indicators_construct_and_emit(): + # All five order-book indicators accept a four-array snapshot and emit a float. + snapshot = ([100.0, 99.0], [2.0, 1.0], [101.0, 102.0], [1.0, 1.0]) + indicators = [ + ta.OrderBookImbalanceTop1(), + ta.OrderBookImbalanceTopN(2), + ta.OrderBookImbalanceFull(), + ta.Microprice(), + ta.QuotedSpread(), + ] + for ind in indicators: + out = ind.update(*snapshot) + assert isinstance(out, float) + + +def test_orderbook_batch_returns_one_value_per_snapshot(): + snapshots = [([100.0], [3.0], [101.0], [1.0])] * 5 + out = ta.OrderBookImbalanceTop1().batch(snapshots) + assert out.shape == (5,) + 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 7993647f..1a8ba7aa 100644 --- a/bindings/python/tests/test_streaming_vs_batch.py +++ b/bindings/python/tests/test_streaming_vs_batch.py @@ -201,3 +201,19 @@ def test_opening_range_streaming_matches_batch(ohlc_series): rows.append([math.nan, math.nan, math.nan] if out is None else list(out)) streamed = np.array(rows, dtype=np.float64) assert _equal_with_nan(batch, streamed) + + +def test_orderbook_streaming_matches_batch(): + snaps = [ + ( + [100.0, 99.0], + [1.0 + (i % 5), 1.0], + [101.0, 102.0], + [1.0 + ((i + 1) % 3), 1.0], + ) + for i in range(30) + ] + batch = ta.Microprice().batch(snaps) + streamer = ta.Microprice() + streamed = np.array([streamer.update(*snap) for snap in snaps], dtype=np.float64) + assert _equal_with_nan(batch, streamed) diff --git a/bindings/wasm/src/lib.rs b/bindings/wasm/src/lib.rs index daf70c27..954227f8 100644 --- a/bindings/wasm/src/lib.rs +++ b/bindings/wasm/src/lib.rs @@ -6331,6 +6331,134 @@ wasm_candle_pattern!(WasmSpinningTop, wc::SpinningTop, SpinningTop); wasm_candle_pattern!(WasmThreeInside, wc::ThreeInside, ThreeInside); wasm_candle_pattern!(WasmThreeOutside, wc::ThreeOutside, ThreeOutside); +// ============================== Microstructure: Order Book ============================== +// +// Order-book indicators consume a depth snapshot rather than OHLCV. Each +// `update(bidPx, bidSz, askPx, askSz)` takes four equal-length typed arrays for +// one snapshot (bids best-first = descending price, asks best-first = ascending +// price) — the streaming model that fits a live browser book feed. Batch over a +// ragged depth history is provided by the Python and Node bindings. + +fn build_order_book( + bid_px: &[f64], + bid_sz: &[f64], + ask_px: &[f64], + ask_sz: &[f64], +) -> Result { + if bid_px.len() != bid_sz.len() || ask_px.len() != ask_sz.len() { + return Err(JsError::new( + "bid/ask price and size arrays must be equal length", + )); + } + let bids = bid_px + .iter() + .zip(bid_sz) + .map(|(&p, &s)| wc::Level::new_unchecked(p, s)) + .collect(); + let asks = ask_px + .iter() + .zip(ask_sz) + .map(|(&p, &s)| wc::Level::new_unchecked(p, s)) + .collect(); + wc::OrderBook::new(bids, asks).map_err(map_err) +} + +macro_rules! wasm_ob_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, + 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() + } + } + }; +} + +wasm_ob_indicator!( + WasmOrderBookImbalanceTop1, + wc::OrderBookImbalanceTop1, + OrderBookImbalanceTop1 +); +wasm_ob_indicator!( + WasmOrderBookImbalanceFull, + wc::OrderBookImbalanceFull, + OrderBookImbalanceFull +); +wasm_ob_indicator!(WasmMicroprice, wc::Microprice, Microprice); +wasm_ob_indicator!(WasmQuotedSpread, wc::QuotedSpread, QuotedSpread); + +// Top-N imbalance carries a `levels` parameter, so it is hand-written. +#[wasm_bindgen(js_name = OrderBookImbalanceTopN)] +pub struct WasmOrderBookImbalanceTopN { + inner: wc::OrderBookImbalanceTopN, +} + +#[wasm_bindgen(js_class = OrderBookImbalanceTopN)] +impl WasmOrderBookImbalanceTopN { + #[wasm_bindgen(constructor)] + pub fn new(levels: usize) -> Result { + Ok(Self { + inner: wc::OrderBookImbalanceTopN::new(levels).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() + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/wickra-core/src/error.rs b/crates/wickra-core/src/error.rs index c91aa99e..d00f0d94 100644 --- a/crates/wickra-core/src/error.rs +++ b/crates/wickra-core/src/error.rs @@ -31,6 +31,18 @@ pub enum Error { /// A multiplier or factor must be strictly positive. #[error("multiplier must be greater than zero")] NonPositiveMultiplier, + + /// An order-book snapshot whose levels do not satisfy the book invariants + /// (e.g. a crossed book, non-finite price, negative size, or mis-sorted + /// levels) was provided. Order books are a microstructure input distinct + /// from candles and ticks, so they surface as their own variant. + #[error("invalid order book: {message}")] + InvalidOrderBook { message: &'static str }, + + /// A trade whose components do not satisfy the trade invariants (e.g. + /// non-finite price or negative size) was provided. + #[error("invalid trade: {message}")] + InvalidTrade { message: &'static str }, } /// Convenience alias for `Result`. diff --git a/crates/wickra-core/src/indicators/microprice.rs b/crates/wickra-core/src/indicators/microprice.rs new file mode 100644 index 00000000..e8173265 --- /dev/null +++ b/crates/wickra-core/src/indicators/microprice.rs @@ -0,0 +1,170 @@ +//! Microprice — size-weighted fair value of the top of book. + +use crate::microstructure::OrderBook; +use crate::traits::Indicator; + +/// Microprice — the size-weighted mid of the top of book. +/// +/// The microprice tilts the mid toward the side that is *more likely to be +/// hit*: it weights each touch price by the size resting on the **opposite** +/// side, so a heavy ask (sell pressure) pulls the fair value down toward the +/// bid, and vice versa: +/// +/// ```text +/// microprice = (bidPrice₁·askSize₁ + askPrice₁·bidSize₁) / (bidSize₁ + askSize₁) +/// ``` +/// +/// When both top sizes are zero the weighting is undefined and the plain mid +/// `(bidPrice₁ + askPrice₁) / 2` is returned. An empty book yields `0`. +/// +/// `Input = OrderBook`, `Output = f64`. Stateless; ready after the first +/// snapshot. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, Level, Microprice, OrderBook}; +/// +/// let book = OrderBook::new( +/// vec![Level::new(100.0, 1.0).unwrap()], +/// vec![Level::new(101.0, 3.0).unwrap()], +/// ) +/// .unwrap(); +/// let mut mp = Microprice::new(); +/// // (100·3 + 101·1) / (1 + 3) = 401 / 4 = 100.25 — pulled toward the bid. +/// assert_eq!(mp.update(book), Some(100.25)); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct Microprice { + has_emitted: bool, +} + +impl Microprice { + /// Construct a new microprice indicator. + pub const fn new() -> Self { + Self { has_emitted: false } + } +} + +impl Indicator for Microprice { + type Input = OrderBook; + type Output = f64; + + fn update(&mut self, book: OrderBook) -> Option { + self.has_emitted = true; + let (Some(bid), Some(ask)) = (book.best_bid(), book.best_ask()) else { + return Some(0.0); + }; + let total = bid.size + ask.size; + if total <= 0.0 { + return Some(f64::midpoint(bid.price, ask.price)); + } + Some((bid.price * ask.size + ask.price * bid.size) / total) + } + + fn reset(&mut self) { + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "Microprice" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::microstructure::Level; + 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 mp = Microprice::new(); + assert_eq!(mp.name(), "Microprice"); + assert_eq!(mp.warmup_period(), 1); + assert!(!mp.is_ready()); + } + + #[test] + fn weights_toward_thin_side() { + let mut mp = Microprice::new(); + // Heavy ask -> microprice pulled toward bid. + assert_eq!( + mp.update(book(&[(100.0, 1.0)], &[(101.0, 3.0)])), + Some(100.25) + ); + assert!(mp.is_ready()); + } + + #[test] + fn balanced_top_equals_mid() { + let mut mp = Microprice::new(); + assert_eq!( + mp.update(book(&[(100.0, 2.0)], &[(101.0, 2.0)])), + Some(100.5) + ); + } + + #[test] + fn zero_size_falls_back_to_mid() { + let mut mp = Microprice::new(); + assert_eq!( + mp.update(book(&[(100.0, 0.0)], &[(102.0, 0.0)])), + Some(101.0) + ); + } + + #[test] + fn empty_book_is_zero() { + let mut mp = Microprice::new(); + assert_eq!( + mp.update(OrderBook::new_unchecked(vec![], vec![])), + Some(0.0) + ); + } + + #[test] + fn batch_equals_streaming() { + let books: Vec = (0..20) + .map(|i| { + let ask = 1.0 + f64::from(i % 4); + book(&[(100.0, 2.0)], &[(101.0, ask)]) + }) + .collect(); + let mut a = Microprice::new(); + let mut b = Microprice::new(); + assert_eq!( + a.batch(&books), + books + .iter() + .map(|x| b.update(x.clone())) + .collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut mp = Microprice::new(); + mp.update(book(&[(100.0, 1.0)], &[(101.0, 1.0)])); + assert!(mp.is_ready()); + mp.reset(); + assert!(!mp.is_ready()); + } +} diff --git a/crates/wickra-core/src/indicators/mod.rs b/crates/wickra-core/src/indicators/mod.rs index 7a58ccda..2cb61fc6 100644 --- a/crates/wickra-core/src/indicators/mod.rs +++ b/crates/wickra-core/src/indicators/mod.rs @@ -116,10 +116,14 @@ mod mcginley_dynamic; mod median_absolute_deviation; mod median_price; mod mfi; +mod microprice; mod mom; mod morning_evening_star; mod natr; mod nvi; +mod ob_imbalance_full; +mod ob_imbalance_top1; +mod ob_imbalance_topn; mod obv; mod omega_ratio; mod opening_range; @@ -137,6 +141,7 @@ mod ppo; mod profit_factor; mod psar; mod pvi; +mod quoted_spread; mod r_squared; mod recovery_factor; mod relative_strength_ab; @@ -335,10 +340,14 @@ pub use mcginley_dynamic::McGinleyDynamic; pub use median_absolute_deviation::MedianAbsoluteDeviation; pub use median_price::MedianPrice; pub use mfi::Mfi; +pub use microprice::Microprice; pub use mom::Mom; pub use morning_evening_star::MorningEveningStar; pub use natr::Natr; pub use nvi::Nvi; +pub use ob_imbalance_full::OrderBookImbalanceFull; +pub use ob_imbalance_top1::OrderBookImbalanceTop1; +pub use ob_imbalance_topn::OrderBookImbalanceTopN; pub use obv::Obv; pub use omega_ratio::OmegaRatio; pub use opening_range::{OpeningRange, OpeningRangeOutput}; @@ -356,6 +365,7 @@ pub use ppo::Ppo; pub use profit_factor::ProfitFactor; pub use psar::Psar; pub use pvi::Pvi; +pub use quoted_spread::QuotedSpread; pub use r_squared::RSquared; pub use recovery_factor::RecoveryFactor; pub use relative_strength_ab::{RelativeStrengthAB, RelativeStrengthOutput}; @@ -707,6 +717,16 @@ pub const FAMILIES: &[(&str, &[&str])] = &[ "ThreeOutside", ], ), + ( + "Microstructure", + &[ + "OrderBookImbalanceTop1", + "OrderBookImbalanceTopN", + "OrderBookImbalanceFull", + "Microprice", + "QuotedSpread", + ], + ), ( "Market Profile", &["ValueArea", "InitialBalance", "OpeningRange"], @@ -761,6 +781,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, 214, "FAMILIES total drifted from indicator count"); + assert_eq!(total, 219, "FAMILIES total drifted from indicator count"); } } diff --git a/crates/wickra-core/src/indicators/ob_imbalance_full.rs b/crates/wickra-core/src/indicators/ob_imbalance_full.rs new file mode 100644 index 00000000..38d62efa --- /dev/null +++ b/crates/wickra-core/src/indicators/ob_imbalance_full.rs @@ -0,0 +1,157 @@ +//! Order-Book Imbalance over the full visible depth. + +use crate::microstructure::OrderBook; +use crate::traits::Indicator; + +/// Order-Book Imbalance aggregated over the full visible depth of each side. +/// +/// Sums the resting size of every bid level and every ask level in the +/// snapshot and compares them: +/// +/// ```text +/// bidDepth = Σ size of all bids +/// askDepth = Σ size of all asks +/// imbalance = (bidDepth − askDepth) / (bidDepth + askDepth) +/// ``` +/// +/// The output lies in `[−1, +1]`. A book with zero total size yields `0`. Use +/// [`crate::OrderBookImbalanceTopN`] to bound the depth to the most relevant +/// near-touch levels instead of the full visible book. +/// +/// `Input = OrderBook`, `Output = f64`. Stateless; ready after the first +/// snapshot. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, Level, OrderBook, OrderBookImbalanceFull}; +/// +/// let book = OrderBook::new( +/// vec![Level::new(100.0, 2.0).unwrap(), Level::new(99.0, 1.0).unwrap()], +/// vec![Level::new(101.0, 0.5).unwrap(), Level::new(102.0, 0.5).unwrap()], +/// ) +/// .unwrap(); +/// let mut obi = OrderBookImbalanceFull::new(); +/// assert_eq!(obi.update(book), Some(0.5)); // (3 − 1) / (3 + 1) +/// ``` +#[derive(Debug, Clone, Default)] +pub struct OrderBookImbalanceFull { + has_emitted: bool, +} + +impl OrderBookImbalanceFull { + /// Construct a new full-depth imbalance indicator. + pub const fn new() -> Self { + Self { has_emitted: false } + } +} + +impl Indicator for OrderBookImbalanceFull { + type Input = OrderBook; + type Output = f64; + + fn update(&mut self, book: OrderBook) -> Option { + self.has_emitted = true; + let bid_depth: f64 = book.bids.iter().map(|l| l.size).sum(); + let ask_depth: f64 = book.asks.iter().map(|l| l.size).sum(); + let total = bid_depth + ask_depth; + if total <= 0.0 { + return Some(0.0); + } + Some((bid_depth - ask_depth) / total) + } + + fn reset(&mut self) { + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "OrderBookImbalanceFull" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::microstructure::Level; + 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 obi = OrderBookImbalanceFull::new(); + assert_eq!(obi.name(), "OrderBookImbalanceFull"); + assert_eq!(obi.warmup_period(), 1); + assert!(!obi.is_ready()); + } + + #[test] + fn sums_full_depth() { + let mut obi = OrderBookImbalanceFull::new(); + let b = book(&[(100.0, 2.0), (99.0, 2.0)], &[(101.0, 1.0), (102.0, 1.0)]); + // bidDepth 4, askDepth 2 -> (4 - 2) / 6 = 1/3. + assert_eq!(obi.update(b), Some(1.0 / 3.0)); + assert!(obi.is_ready()); + } + + #[test] + fn ask_heavy_full_depth_is_negative() { + let mut obi = OrderBookImbalanceFull::new(); + let b = book(&[(100.0, 1.0)], &[(101.0, 2.0), (102.0, 1.0)]); + // (1 - 3) / 4 = -0.5. + assert_eq!(obi.update(b), Some(-0.5)); + } + + #[test] + fn zero_size_is_zero() { + let mut obi = OrderBookImbalanceFull::new(); + assert_eq!( + obi.update(book(&[(100.0, 0.0)], &[(101.0, 0.0)])), + Some(0.0) + ); + } + + #[test] + fn batch_equals_streaming() { + let books: Vec = (0..20) + .map(|i| { + let bid = 1.0 + f64::from(i % 3); + book(&[(100.0, bid), (99.0, 1.0)], &[(101.0, 2.0), (102.0, 1.0)]) + }) + .collect(); + let mut a = OrderBookImbalanceFull::new(); + let mut b = OrderBookImbalanceFull::new(); + assert_eq!( + a.batch(&books), + books + .iter() + .map(|x| b.update(x.clone())) + .collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut obi = OrderBookImbalanceFull::new(); + obi.update(book(&[(100.0, 1.0)], &[(101.0, 1.0)])); + assert!(obi.is_ready()); + obi.reset(); + assert!(!obi.is_ready()); + } +} diff --git a/crates/wickra-core/src/indicators/ob_imbalance_top1.rs b/crates/wickra-core/src/indicators/ob_imbalance_top1.rs new file mode 100644 index 00000000..7224aba7 --- /dev/null +++ b/crates/wickra-core/src/indicators/ob_imbalance_top1.rs @@ -0,0 +1,176 @@ +//! Order-Book Imbalance at the top of book. + +use crate::microstructure::OrderBook; +use crate::traits::Indicator; + +/// Order-Book Imbalance (top-of-book). +/// +/// Measures the pressure between the best bid and best ask by comparing their +/// resting sizes: +/// +/// ```text +/// imbalance = (bidSize₁ − askSize₁) / (bidSize₁ + askSize₁) +/// ``` +/// +/// The output lies in `[−1, +1]`: `+1` means all size sits on the bid (buy +/// pressure), `−1` means all size sits on the ask (sell pressure), `0` means a +/// balanced top of book. A book with zero size on both top levels yields `0`. +/// +/// `Input = OrderBook`, `Output = f64`. The indicator is stateless and ready +/// after the first snapshot. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, Level, OrderBook, OrderBookImbalanceTop1}; +/// +/// let book = OrderBook::new( +/// vec![Level::new(100.0, 3.0).unwrap()], +/// vec![Level::new(101.0, 1.0).unwrap()], +/// ) +/// .unwrap(); +/// let mut obi = OrderBookImbalanceTop1::new(); +/// assert_eq!(obi.update(book), Some(0.5)); // (3 − 1) / (3 + 1) +/// ``` +#[derive(Debug, Clone, Default)] +pub struct OrderBookImbalanceTop1 { + has_emitted: bool, +} + +impl OrderBookImbalanceTop1 { + /// Construct a new top-of-book imbalance indicator. + pub const fn new() -> Self { + Self { has_emitted: false } + } +} + +impl Indicator for OrderBookImbalanceTop1 { + type Input = OrderBook; + type Output = f64; + + fn update(&mut self, book: OrderBook) -> Option { + self.has_emitted = true; + let (Some(bid), Some(ask)) = (book.best_bid(), book.best_ask()) else { + return Some(0.0); + }; + let total = bid.size + ask.size; + if total <= 0.0 { + return Some(0.0); + } + Some((bid.size - ask.size) / total) + } + + fn reset(&mut self) { + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "OrderBookImbalanceTop1" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::microstructure::Level; + 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 obi = OrderBookImbalanceTop1::new(); + assert_eq!(obi.name(), "OrderBookImbalanceTop1"); + assert_eq!(obi.warmup_period(), 1); + assert!(!obi.is_ready()); + } + + #[test] + fn balanced_top_is_zero() { + let mut obi = OrderBookImbalanceTop1::new(); + assert_eq!( + obi.update(book(&[(100.0, 2.0)], &[(101.0, 2.0)])), + Some(0.0) + ); + assert!(obi.is_ready()); + } + + #[test] + fn bid_heavy_is_positive() { + let mut obi = OrderBookImbalanceTop1::new(); + assert_eq!( + obi.update(book(&[(100.0, 3.0)], &[(101.0, 1.0)])), + Some(0.5) + ); + } + + #[test] + fn ask_heavy_is_negative() { + let mut obi = OrderBookImbalanceTop1::new(); + assert_eq!( + obi.update(book(&[(100.0, 1.0)], &[(101.0, 3.0)])), + Some(-0.5) + ); + } + + #[test] + fn zero_size_top_is_zero() { + let mut obi = OrderBookImbalanceTop1::new(); + assert_eq!( + obi.update(book(&[(100.0, 0.0)], &[(101.0, 0.0)])), + Some(0.0) + ); + } + + #[test] + fn empty_book_is_zero() { + let mut obi = OrderBookImbalanceTop1::new(); + assert_eq!( + obi.update(OrderBook::new_unchecked(vec![], vec![])), + Some(0.0) + ); + } + + #[test] + fn batch_equals_streaming() { + let books: Vec = (0..20) + .map(|i| { + let bid = 1.0 + f64::from(i % 5); + book(&[(100.0, bid)], &[(101.0, 2.0)]) + }) + .collect(); + let mut a = OrderBookImbalanceTop1::new(); + let mut b = OrderBookImbalanceTop1::new(); + assert_eq!( + a.batch(&books), + books + .iter() + .map(|x| b.update(x.clone())) + .collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut obi = OrderBookImbalanceTop1::new(); + obi.update(book(&[(100.0, 1.0)], &[(101.0, 1.0)])); + assert!(obi.is_ready()); + obi.reset(); + assert!(!obi.is_ready()); + } +} diff --git a/crates/wickra-core/src/indicators/ob_imbalance_topn.rs b/crates/wickra-core/src/indicators/ob_imbalance_topn.rs new file mode 100644 index 00000000..de0c2d51 --- /dev/null +++ b/crates/wickra-core/src/indicators/ob_imbalance_topn.rs @@ -0,0 +1,186 @@ +//! Order-Book Imbalance over the top-N levels. + +use crate::error::{Error, Result}; +use crate::microstructure::OrderBook; +use crate::traits::Indicator; + +/// Order-Book Imbalance aggregated over the top-N levels of each side. +/// +/// Generalises [`crate::OrderBookImbalanceTop1`] to a configurable depth: it +/// sums the resting size of the best `levels` bids and the best `levels` asks +/// and compares them: +/// +/// ```text +/// bidDepth = Σ size of the best `levels` bids +/// askDepth = Σ size of the best `levels` asks +/// imbalance = (bidDepth − askDepth) / (bidDepth + askDepth) +/// ``` +/// +/// If a side has fewer than `levels` levels, all available levels are summed. +/// The output lies in `[−1, +1]`; a book with zero size across the summed +/// levels yields `0`. +/// +/// `Input = OrderBook`, `Output = f64`. Stateless; ready after the first +/// snapshot. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, Level, OrderBook, OrderBookImbalanceTopN}; +/// +/// let book = OrderBook::new( +/// vec![Level::new(100.0, 2.0).unwrap(), Level::new(99.0, 1.0).unwrap()], +/// vec![Level::new(101.0, 1.0).unwrap(), Level::new(102.0, 1.0).unwrap()], +/// ) +/// .unwrap(); +/// let mut obi = OrderBookImbalanceTopN::new(2).unwrap(); +/// assert_eq!(obi.update(book), Some(0.2)); // (3 − 2) / (3 + 2) +/// ``` +#[derive(Debug, Clone)] +pub struct OrderBookImbalanceTopN { + levels: usize, + has_emitted: bool, +} + +impl OrderBookImbalanceTopN { + /// Construct a top-N imbalance indicator. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `levels` is zero. + pub fn new(levels: usize) -> Result { + if levels == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + levels, + has_emitted: false, + }) + } + + /// The configured number of levels summed per side. + pub fn levels(&self) -> usize { + self.levels + } +} + +impl Indicator for OrderBookImbalanceTopN { + type Input = OrderBook; + type Output = f64; + + fn update(&mut self, book: OrderBook) -> Option { + self.has_emitted = true; + let bid_depth: f64 = book.bids.iter().take(self.levels).map(|l| l.size).sum(); + let ask_depth: f64 = book.asks.iter().take(self.levels).map(|l| l.size).sum(); + let total = bid_depth + ask_depth; + if total <= 0.0 { + return Some(0.0); + } + Some((bid_depth - ask_depth) / total) + } + + fn reset(&mut self) { + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "OrderBookImbalanceTopN" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::microstructure::Level; + 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 rejects_zero_levels() { + assert!(matches!( + OrderBookImbalanceTopN::new(0), + Err(Error::PeriodZero) + )); + } + + #[test] + fn accessors_and_metadata() { + let obi = OrderBookImbalanceTopN::new(3).unwrap(); + assert_eq!(obi.name(), "OrderBookImbalanceTopN"); + assert_eq!(obi.warmup_period(), 1); + assert_eq!(obi.levels(), 3); + assert!(!obi.is_ready()); + } + + #[test] + fn sums_top_two_levels() { + let mut obi = OrderBookImbalanceTopN::new(2).unwrap(); + let b = book(&[(100.0, 2.0), (99.0, 1.0)], &[(101.0, 1.0), (102.0, 1.0)]); + // bidDepth 3, askDepth 2 -> (3 - 2) / 5 = 0.2. + assert_eq!(obi.update(b), Some(0.2)); + assert!(obi.is_ready()); + } + + #[test] + fn caps_at_available_depth() { + // Only one level per side, N = 5 -> uses what exists. + let mut obi = OrderBookImbalanceTopN::new(5).unwrap(); + assert_eq!( + obi.update(book(&[(100.0, 3.0)], &[(101.0, 1.0)])), + Some(0.5) + ); + } + + #[test] + fn zero_size_is_zero() { + let mut obi = OrderBookImbalanceTopN::new(2).unwrap(); + assert_eq!( + obi.update(book(&[(100.0, 0.0)], &[(101.0, 0.0)])), + Some(0.0) + ); + } + + #[test] + fn batch_equals_streaming() { + let books: Vec = (0..20) + .map(|i| { + let ask = 1.0 + f64::from(i % 4); + book(&[(100.0, 2.0), (99.0, 1.0)], &[(101.0, ask), (102.0, 1.0)]) + }) + .collect(); + let mut a = OrderBookImbalanceTopN::new(2).unwrap(); + let mut b = OrderBookImbalanceTopN::new(2).unwrap(); + assert_eq!( + a.batch(&books), + books + .iter() + .map(|x| b.update(x.clone())) + .collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut obi = OrderBookImbalanceTopN::new(2).unwrap(); + obi.update(book(&[(100.0, 1.0)], &[(101.0, 1.0)])); + assert!(obi.is_ready()); + obi.reset(); + assert!(!obi.is_ready()); + } +} diff --git a/crates/wickra-core/src/indicators/quoted_spread.rs b/crates/wickra-core/src/indicators/quoted_spread.rs new file mode 100644 index 00000000..cbb54eec --- /dev/null +++ b/crates/wickra-core/src/indicators/quoted_spread.rs @@ -0,0 +1,153 @@ +//! Quoted Spread — top-of-book spread in basis points. + +use crate::microstructure::OrderBook; +use crate::traits::Indicator; + +/// Quoted Spread — the top-of-book bid-ask spread expressed in basis points of +/// the mid price. +/// +/// ```text +/// mid = (bidPrice₁ + askPrice₁) / 2 +/// quotedSpread = (askPrice₁ − bidPrice₁) / mid · 10_000 (bps) +/// ``` +/// +/// This is the round-trip cost of crossing the spread at the touch, normalised +/// by price so it is comparable across instruments. For a valid (uncrossed) +/// book the result is non-negative. An empty book yields `0`. +/// +/// `Input = OrderBook`, `Output = f64`. Stateless; ready after the first +/// snapshot. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, Level, OrderBook, QuotedSpread}; +/// +/// let book = OrderBook::new( +/// vec![Level::new(100.0, 1.0).unwrap()], +/// vec![Level::new(100.5, 1.0).unwrap()], +/// ) +/// .unwrap(); +/// let mut qs = QuotedSpread::new(); +/// // spread 0.5, mid 100.25 -> 0.5 / 100.25 * 10_000 ≈ 49.875 bps. +/// let bps = qs.update(book).unwrap(); +/// assert!((bps - 49.875_311_72).abs() < 1e-6); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct QuotedSpread { + has_emitted: bool, +} + +impl QuotedSpread { + /// Construct a new quoted-spread indicator. + pub const fn new() -> Self { + Self { has_emitted: false } + } +} + +impl Indicator for QuotedSpread { + type Input = OrderBook; + type Output = f64; + + fn update(&mut self, book: OrderBook) -> Option { + self.has_emitted = true; + let (Some(bid), Some(ask)) = (book.best_bid(), book.best_ask()) else { + return Some(0.0); + }; + let mid = f64::midpoint(bid.price, ask.price); + Some((ask.price - bid.price) / 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 { + "QuotedSpread" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::microstructure::Level; + 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 qs = QuotedSpread::new(); + assert_eq!(qs.name(), "QuotedSpread"); + assert_eq!(qs.warmup_period(), 1); + assert!(!qs.is_ready()); + } + + #[test] + fn known_value_in_bps() { + let mut qs = QuotedSpread::new(); + // spread 1.0, mid 100.5 -> 1 / 100.5 * 10_000 ≈ 99.5025 bps. + let bps = qs.update(book(&[(100.0, 1.0)], &[(101.0, 1.0)])).unwrap(); + assert!((bps - 99.502_487_56).abs() < 1e-6); + assert!(qs.is_ready()); + } + + #[test] + fn tight_book_is_small() { + let mut qs = QuotedSpread::new(); + let bps = qs.update(book(&[(100.0, 1.0)], &[(100.01, 1.0)])).unwrap(); + assert!(bps > 0.0 && bps < 2.0); + } + + #[test] + fn empty_book_is_zero() { + let mut qs = QuotedSpread::new(); + assert_eq!( + qs.update(OrderBook::new_unchecked(vec![], vec![])), + Some(0.0) + ); + } + + #[test] + fn batch_equals_streaming() { + let books: Vec = (0..20) + .map(|i| { + let ask = 100.5 + f64::from(i % 4) * 0.1; + book(&[(100.0, 1.0)], &[(ask, 1.0)]) + }) + .collect(); + let mut a = QuotedSpread::new(); + let mut b = QuotedSpread::new(); + assert_eq!( + a.batch(&books), + books + .iter() + .map(|x| b.update(x.clone())) + .collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut qs = QuotedSpread::new(); + qs.update(book(&[(100.0, 1.0)], &[(101.0, 1.0)])); + assert!(qs.is_ready()); + qs.reset(); + assert!(!qs.is_ready()); + } +} diff --git a/crates/wickra-core/src/lib.rs b/crates/wickra-core/src/lib.rs index 23bd72d8..2b7b2144 100644 --- a/crates/wickra-core/src/lib.rs +++ b/crates/wickra-core/src/lib.rs @@ -37,6 +37,7 @@ #![cfg_attr(docsrs, feature(doc_cfg))] mod error; +mod microstructure; mod ohlcv; mod traits; @@ -67,15 +68,16 @@ pub use indicators::{ LeadLagCrossCorrelationOutput, LinRegAngle, LinRegChannel, LinRegChannelOutput, LinRegSlope, LinearRegression, MaEnvelope, MaEnvelopeOutput, MacdIndicator, MacdOutput, Mama, MamaOutput, MarketFacilitationIndex, Marubozu, MassIndex, MaxDrawdown, McGinleyDynamic, - MedianAbsoluteDeviation, MedianPrice, Mfi, Mom, MorningEveningStar, Natr, Nvi, Obv, OmegaRatio, - OpeningRange, OpeningRangeOutput, PainIndex, PairSpreadZScore, PairwiseBeta, - ParkinsonVolatility, PearsonCorrelation, PercentB, PercentageTrailingStop, Pgo, - PiercingDarkCloud, Pmo, Ppo, ProfitFactor, Psar, Pvi, RSquared, RecoveryFactor, - RelativeStrengthAB, RelativeStrengthOutput, RenkoTrailingStop, Roc, RogersSatchellVolatility, - RollingVwap, RoofingFilter, Rsi, Rvi, RviVolatility, Rwi, RwiOutput, SharpeRatio, ShootingStar, - SineWave, Skewness, Sma, Smi, Smma, SortinoRatio, SpearmanCorrelation, SpinningTop, - StandardError, StandardErrorBands, StandardErrorBandsOutput, StarcBands, StarcBandsOutput, Stc, - StdDev, StepTrailingStop, StochRsi, Stochastic, StochasticOutput, SuperSmoother, SuperTrend, + 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, 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, @@ -87,5 +89,6 @@ pub use indicators::{ WilliamsFractalsOutput, WilliamsR, Wma, WoodiePivots, WoodiePivotsOutput, YangZhangVolatility, YoyoExit, ZScore, ZeroLagMacd, ZeroLagMacdOutput, ZigZag, ZigZagOutput, Zlema, FAMILIES, T3, }; +pub use microstructure::{Level, OrderBook, Side, Trade, TradeQuote}; pub use ohlcv::{Candle, Tick}; pub use traits::{BatchExt, Chain, Indicator}; diff --git a/crates/wickra-core/src/microstructure.rs b/crates/wickra-core/src/microstructure.rs new file mode 100644 index 00000000..5f4e8cb8 --- /dev/null +++ b/crates/wickra-core/src/microstructure.rs @@ -0,0 +1,467 @@ +//! Microstructure value types: order-book snapshots and trades. +//! +//! These are the non-OHLCV inputs consumed by the order-book / trade-flow +//! indicator family. An [`OrderBook`] is a depth snapshot (sorted bid and ask +//! levels); a [`Trade`] is a single executed trade with an aggressor [`Side`]; +//! a [`TradeQuote`] pairs a trade with the mid-price prevailing at execution, +//! the input for spread- and price-impact measures. + +use crate::error::{Error, Result}; + +/// A single order-book price level: a resting quantity at a price. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Level { + /// Price of the level (strictly positive). + pub price: f64, + /// Resting size / quantity at this price (non-negative). + pub size: f64, +} + +impl Level { + /// Construct a level, validating that `price` is finite and strictly + /// positive and `size` is finite and non-negative. + /// + /// # Errors + /// + /// Returns [`Error::InvalidOrderBook`] if the price is not a finite + /// positive number, or the size is not a finite non-negative number. + pub fn new(price: f64, size: f64) -> Result { + if !price.is_finite() || price <= 0.0 { + return Err(Error::InvalidOrderBook { + message: "level price must be finite and positive", + }); + } + if !size.is_finite() || size < 0.0 { + return Err(Error::InvalidOrderBook { + message: "level size must be finite and non-negative", + }); + } + Ok(Self { price, size }) + } + + /// Construct a level without validation. The caller asserts that `price` + /// is finite and positive and `size` is finite and non-negative. + pub const fn new_unchecked(price: f64, size: f64) -> Self { + Self { price, size } + } +} + +/// An order-book depth snapshot. +/// +/// Bids are stored best-first (strictly descending price); asks are stored +/// best-first (strictly ascending price). A valid book is non-empty on both +/// sides and uncrossed (`best_bid < best_ask`). +#[derive(Debug, Clone, PartialEq)] +pub struct OrderBook { + /// Bid levels, best (highest price) first. + pub bids: Vec, + /// Ask levels, best (lowest price) first. + pub asks: Vec, +} + +impl OrderBook { + /// Construct an order book, validating the level and ordering invariants. + /// + /// # Errors + /// + /// Returns [`Error::InvalidOrderBook`] if either side is empty, any level + /// has a non-finite/non-positive price or non-finite/negative size, the + /// bids are not strictly descending in price, the asks are not strictly + /// ascending in price, or the book is crossed/locked (`best_bid >= + /// best_ask`). + pub fn new(bids: Vec, asks: Vec) -> Result { + if bids.is_empty() || asks.is_empty() { + return Err(Error::InvalidOrderBook { + message: "order book must have at least one bid and one ask", + }); + } + for level in bids.iter().chain(asks.iter()) { + if !level.price.is_finite() || level.price <= 0.0 { + return Err(Error::InvalidOrderBook { + message: "level price must be finite and positive", + }); + } + if !level.size.is_finite() || level.size < 0.0 { + return Err(Error::InvalidOrderBook { + message: "level size must be finite and non-negative", + }); + } + } + for pair in bids.windows(2) { + if pair[0].price <= pair[1].price { + return Err(Error::InvalidOrderBook { + message: "bids must be strictly descending in price", + }); + } + } + for pair in asks.windows(2) { + if pair[0].price >= pair[1].price { + return Err(Error::InvalidOrderBook { + message: "asks must be strictly ascending in price", + }); + } + } + if bids[0].price >= asks[0].price { + return Err(Error::InvalidOrderBook { + message: "order book must be uncrossed (best_bid < best_ask)", + }); + } + Ok(Self { bids, asks }) + } + + /// Construct an order book without validation. The caller asserts that all + /// level and ordering invariants hold. + pub const fn new_unchecked(bids: Vec, asks: Vec) -> Self { + Self { bids, asks } + } + + /// The best (highest-price) bid level, or `None` if the bid side is empty. + pub fn best_bid(&self) -> Option { + self.bids.first().copied() + } + + /// The best (lowest-price) ask level, or `None` if the ask side is empty. + pub fn best_ask(&self) -> Option { + self.asks.first().copied() + } + + /// The mid price `(best_bid + best_ask) / 2`, or `None` if either side is + /// empty. + pub fn mid(&self) -> Option { + match (self.best_bid(), self.best_ask()) { + (Some(bid), Some(ask)) => Some(f64::midpoint(bid.price, ask.price)), + _ => None, + } + } +} + +/// The aggressor side of a trade: the side that crossed the spread. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Side { + /// A buyer-initiated (aggressive buy) trade. + Buy, + /// A seller-initiated (aggressive sell) trade. + Sell, +} + +impl Side { + /// The signed multiplier for this side: `+1.0` for a buy, `−1.0` for a + /// sell. + pub const fn sign(self) -> f64 { + match self { + Side::Buy => 1.0, + Side::Sell => -1.0, + } + } +} + +/// A single executed trade with an aggressor side. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Trade { + /// Execution price (strictly positive). + pub price: f64, + /// Executed size / quantity (non-negative). + pub size: f64, + /// Aggressor side. + pub side: Side, + /// Trade timestamp (caller-defined epoch / resolution). + pub timestamp: i64, +} + +impl Trade { + /// Construct a trade, validating that `price` is finite and strictly + /// positive and `size` is finite and non-negative. + /// + /// # Errors + /// + /// Returns [`Error::InvalidTrade`] if the price is not a finite positive + /// number, or the size is not a finite non-negative number. + pub fn new(price: f64, size: f64, side: Side, timestamp: i64) -> Result { + if !price.is_finite() || price <= 0.0 { + return Err(Error::InvalidTrade { + message: "trade price must be finite and positive", + }); + } + if !size.is_finite() || size < 0.0 { + return Err(Error::InvalidTrade { + message: "trade size must be finite and non-negative", + }); + } + Ok(Self { + price, + size, + side, + timestamp, + }) + } + + /// Construct a trade without validation. The caller asserts that `price` + /// is finite and positive and `size` is finite and non-negative. + pub const fn new_unchecked(price: f64, size: f64, side: Side, timestamp: i64) -> Self { + Self { + price, + size, + side, + timestamp, + } + } +} + +/// A trade paired with the mid-price prevailing at execution. +/// +/// This is the input for spread- and price-impact measures (effective spread, +/// realized spread, Kyle's lambda), which relate an executed trade to the +/// quote it traded against. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct TradeQuote { + /// The executed trade. + pub trade: Trade, + /// The mid-price prevailing at execution (strictly positive). + pub mid: f64, +} + +impl TradeQuote { + /// Construct a trade-quote, validating that `mid` is finite and strictly + /// positive. The `trade` is assumed already valid. + /// + /// # Errors + /// + /// Returns [`Error::InvalidTrade`] if `mid` is not a finite positive + /// number. + pub fn new(trade: Trade, mid: f64) -> Result { + if !mid.is_finite() || mid <= 0.0 { + return Err(Error::InvalidTrade { + message: "trade-quote mid must be finite and positive", + }); + } + Ok(Self { trade, mid }) + } + + /// Construct a trade-quote without validation. The caller asserts that + /// `mid` is finite and positive. + pub const fn new_unchecked(trade: Trade, mid: f64) -> Self { + Self { trade, mid } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn level_new_accepts_valid() { + let level = Level::new(100.5, 2.0).unwrap(); + assert_eq!(level.price, 100.5); + assert_eq!(level.size, 2.0); + } + + #[test] + fn level_new_accepts_zero_size() { + assert!(Level::new(100.0, 0.0).is_ok()); + } + + #[test] + fn level_new_rejects_non_finite_price() { + assert!(matches!( + Level::new(f64::NAN, 1.0), + Err(Error::InvalidOrderBook { .. }) + )); + assert!(matches!( + Level::new(f64::INFINITY, 1.0), + Err(Error::InvalidOrderBook { .. }) + )); + } + + #[test] + fn level_new_rejects_non_positive_price() { + assert!(matches!( + Level::new(0.0, 1.0), + Err(Error::InvalidOrderBook { .. }) + )); + assert!(matches!( + Level::new(-1.0, 1.0), + Err(Error::InvalidOrderBook { .. }) + )); + } + + #[test] + fn level_new_rejects_bad_size() { + assert!(matches!( + Level::new(100.0, -1.0), + Err(Error::InvalidOrderBook { .. }) + )); + assert!(matches!( + Level::new(100.0, f64::NAN), + Err(Error::InvalidOrderBook { .. }) + )); + } + + #[test] + fn level_new_unchecked_preserves_fields() { + let level = Level::new_unchecked(-5.0, -2.0); + assert_eq!(level.price, -5.0); + assert_eq!(level.size, -2.0); + } + + fn lvl(price: f64, size: f64) -> Level { + Level::new(price, size).unwrap() + } + + #[test] + fn order_book_new_accepts_valid() { + let book = OrderBook::new( + vec![lvl(100.0, 2.0), lvl(99.0, 3.0)], + vec![lvl(101.0, 1.0), lvl(102.0, 4.0)], + ) + .unwrap(); + assert_eq!(book.best_bid(), Some(lvl(100.0, 2.0))); + assert_eq!(book.best_ask(), Some(lvl(101.0, 1.0))); + assert_eq!(book.mid(), Some(100.5)); + } + + #[test] + fn order_book_new_rejects_empty_side() { + assert!(matches!( + OrderBook::new(vec![], vec![lvl(101.0, 1.0)]), + Err(Error::InvalidOrderBook { .. }) + )); + assert!(matches!( + OrderBook::new(vec![lvl(100.0, 1.0)], vec![]), + Err(Error::InvalidOrderBook { .. }) + )); + } + + #[test] + fn order_book_new_rejects_bad_level() { + assert!(matches!( + OrderBook::new( + vec![Level::new_unchecked(100.0, -1.0)], + vec![lvl(101.0, 1.0)] + ), + Err(Error::InvalidOrderBook { .. }) + )); + assert!(matches!( + OrderBook::new( + vec![lvl(100.0, 1.0)], + vec![Level::new_unchecked(f64::NAN, 1.0)] + ), + Err(Error::InvalidOrderBook { .. }) + )); + } + + #[test] + fn order_book_new_rejects_misordered_bids() { + assert!(matches!( + OrderBook::new(vec![lvl(99.0, 1.0), lvl(100.0, 1.0)], vec![lvl(101.0, 1.0)]), + Err(Error::InvalidOrderBook { .. }) + )); + } + + #[test] + fn order_book_new_rejects_misordered_asks() { + assert!(matches!( + OrderBook::new( + vec![lvl(100.0, 1.0)], + vec![lvl(102.0, 1.0), lvl(101.0, 1.0)] + ), + Err(Error::InvalidOrderBook { .. }) + )); + } + + #[test] + fn order_book_new_rejects_crossed() { + assert!(matches!( + OrderBook::new(vec![lvl(101.0, 1.0)], vec![lvl(101.0, 1.0)]), + Err(Error::InvalidOrderBook { .. }) + )); + assert!(matches!( + OrderBook::new(vec![lvl(102.0, 1.0)], vec![lvl(101.0, 1.0)]), + Err(Error::InvalidOrderBook { .. }) + )); + } + + #[test] + fn order_book_new_unchecked_allows_empty() { + let book = OrderBook::new_unchecked(vec![], vec![]); + assert_eq!(book.best_bid(), None); + assert_eq!(book.best_ask(), None); + assert_eq!(book.mid(), None); + } + + #[test] + fn side_sign() { + assert_eq!(Side::Buy.sign(), 1.0); + assert_eq!(Side::Sell.sign(), -1.0); + } + + #[test] + fn trade_new_accepts_valid() { + let trade = Trade::new(100.0, 1.5, Side::Buy, 42).unwrap(); + assert_eq!(trade.price, 100.0); + assert_eq!(trade.size, 1.5); + assert_eq!(trade.side, Side::Buy); + assert_eq!(trade.timestamp, 42); + } + + #[test] + fn trade_new_rejects_bad_price() { + assert!(matches!( + Trade::new(0.0, 1.0, Side::Buy, 0), + Err(Error::InvalidTrade { .. }) + )); + assert!(matches!( + Trade::new(f64::NAN, 1.0, Side::Sell, 0), + Err(Error::InvalidTrade { .. }) + )); + } + + #[test] + fn trade_new_rejects_bad_size() { + assert!(matches!( + Trade::new(100.0, -1.0, Side::Buy, 0), + Err(Error::InvalidTrade { .. }) + )); + assert!(matches!( + Trade::new(100.0, f64::INFINITY, Side::Buy, 0), + Err(Error::InvalidTrade { .. }) + )); + } + + #[test] + fn trade_new_unchecked_preserves_fields() { + let trade = Trade::new_unchecked(-1.0, -2.0, Side::Sell, 7); + assert_eq!(trade.price, -1.0); + assert_eq!(trade.size, -2.0); + assert_eq!(trade.side, Side::Sell); + assert_eq!(trade.timestamp, 7); + } + + #[test] + fn trade_quote_new_accepts_valid() { + let trade = Trade::new(100.0, 1.0, Side::Buy, 0).unwrap(); + let tq = TradeQuote::new(trade, 99.5).unwrap(); + assert_eq!(tq.trade, trade); + assert_eq!(tq.mid, 99.5); + } + + #[test] + fn trade_quote_new_rejects_bad_mid() { + let trade = Trade::new(100.0, 1.0, Side::Buy, 0).unwrap(); + assert!(matches!( + TradeQuote::new(trade, 0.0), + Err(Error::InvalidTrade { .. }) + )); + assert!(matches!( + TradeQuote::new(trade, f64::NAN), + Err(Error::InvalidTrade { .. }) + )); + } + + #[test] + fn trade_quote_new_unchecked_preserves_fields() { + let trade = Trade::new_unchecked(100.0, 1.0, Side::Buy, 0); + let tq = TradeQuote::new_unchecked(trade, -1.0); + assert_eq!(tq.mid, -1.0); + assert_eq!(tq.trade, trade); + } +} diff --git a/crates/wickra/benches/indicators.rs b/crates/wickra/benches/indicators.rs index 839e8952..239c8ddc 100644 --- a/crates/wickra/benches/indicators.rs +++ b/crates/wickra/benches/indicators.rs @@ -34,12 +34,12 @@ 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, - LinearRegression, MacdIndicator, MacdOutput, Mama, MamaOutput, MaxDrawdown, Obv, - ParkinsonVolatility, Ppo, Psar, RollingVwap, Rsi, SharpeRatio, Sma, Stc, SuperTrend, - SuperTrendOutput, TdSequential, TdSequentialOutput, TtmSqueeze, TtmSqueezeOutput, ValueArea, - ValueAreaOutput, ValueAtRisk, Vwap, VwapStdDevBands, VwapStdDevBandsOutput, WaveTrend, - YangZhangVolatility, T3, + HilbertDominantCycle, HurstExponent, Ichimoku, IchimokuOutput, Indicator, Jma, Level, + LinearRegression, MacdIndicator, MacdOutput, Mama, MamaOutput, MaxDrawdown, Microprice, Obv, + OrderBook, OrderBookImbalanceFull, OrderBookImbalanceTop1, ParkinsonVolatility, Ppo, Psar, + RollingVwap, Rsi, SharpeRatio, Sma, Stc, SuperTrend, SuperTrendOutput, TdSequential, + TdSequentialOutput, TtmSqueeze, TtmSqueezeOutput, ValueArea, ValueAreaOutput, ValueAtRisk, + Vwap, VwapStdDevBands, VwapStdDevBandsOutput, WaveTrend, YangZhangVolatility, T3, }; use wickra_data::csv::CandleReader; @@ -114,6 +114,28 @@ where group.finish(); } +fn bench_orderbook_input(c: &mut Criterion, name: &str, books: &[OrderBook], make: F) +where + F: Fn() -> I, + I: Indicator, +{ + let mut group = c.benchmark_group(name); + for &n in SIZES { + let n = n.min(books.len()); + let series = &books[..n]; + group.throughput(Throughput::Elements(n as u64)); + group.bench_with_input(BenchmarkId::new("streaming", n), series, |b, books| { + b.iter(|| { + let mut ind = make(); + for book in books { + black_box(ind.update(book.clone())); + } + }); + }); + } + group.finish(); +} + fn bench_scalar_multi(c: &mut Criterion, name: &str, prices: &[f64], make: F) where F: Fn() -> I, @@ -265,6 +287,28 @@ fn benches(c: &mut Criterion) { bench_scalar(c, "value_at_risk", &closes, || { ValueAtRisk::new(50, 0.95).unwrap() }); + + // === Family — Microstructure === + // No order-book dataset ships with the repo, so synthesise a five-level + // book around each candle close. Benches the cheapest (top-of-book) and the + // most-expensive (full-depth sum) representatives of the family. + let books: Vec = candles + .iter() + .map(|candle| { + let mid = candle.close; + let tick = (mid * 0.0001).max(0.01); + let bids = (0..5u32) + .map(|i| Level::new_unchecked(mid - tick * f64::from(i + 1), 1.0 + f64::from(i))) + .collect(); + let asks = (0..5u32) + .map(|i| Level::new_unchecked(mid + tick * f64::from(i + 1), 1.0 + f64::from(i))) + .collect(); + OrderBook::new_unchecked(bids, asks) + }) + .collect(); + 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); } criterion_group!(name = wickra_benches; config = Criterion::default(); targets = benches); diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index e1123511..8040f9b6 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -52,6 +52,13 @@ test = false doc = false bench = false +[[bin]] +name = "indicator_update_orderbook" +path = "fuzz_targets/indicator_update_orderbook.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 new file mode 100644 index 00000000..03d27c50 --- /dev/null +++ b/fuzz/fuzz_targets/indicator_update_orderbook.rs @@ -0,0 +1,54 @@ +#![no_main] +//! Fuzz order-book `Indicator` implementations with +//! arbitrary depth snapshots. +//! +//! Each iteration consumes a byte stream, interprets it as a sequence of +//! `f64` values (8 bytes each), packs consecutive values into `(price, size)` +//! levels, and groups levels into order-book snapshots. Books are built with +//! `OrderBook::new_unchecked` so the fuzzer can explore degenerate shapes +//! (empty sides, crossed books, non-finite prices, negative sizes) that the +//! validating constructor would reject — the indicators must never panic on +//! any of them, streaming or batched. + +use libfuzzer_sys::fuzz_target; +use wickra_core::{ + BatchExt, Indicator, Level, Microprice, OrderBook, OrderBookImbalanceFull, + OrderBookImbalanceTop1, OrderBookImbalanceTopN, QuotedSpread, +}; + +#[inline(never)] +fn drive(make: impl Fn() -> I, books: &[OrderBook]) +where + I: Indicator + BatchExt, +{ + let mut streaming = make(); + for book in books { + let _ = streaming.update(book.clone()); + } + let _ = make().batch(books); +} + +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 levels: Vec = floats + .chunks_exact(2) + .map(|c| Level::new_unchecked(c[0], c[1])) + .collect(); + // Group levels into snapshots of up to four levels (split into bids / asks). + let books: Vec = levels + .chunks(4) + .map(|chunk| { + let half = chunk.len() / 2; + OrderBook::new_unchecked(chunk[..half].to_vec(), chunk[half..].to_vec()) + }) + .collect(); + + drive(OrderBookImbalanceTop1::new, &books); + drive(|| OrderBookImbalanceTopN::new(3).unwrap(), &books); + drive(OrderBookImbalanceFull::new, &books); + drive(Microprice::new, &books); + drive(QuotedSpread::new, &books); +});