diff --git a/CHANGELOG.md b/CHANGELOG.md index e9c4cbfd..d84fc46a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `(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. +- **Microstructure family — trade flow (part 2).** Indicators over a trade tape + (`Trade` with an aggressor `Side`), exposed in Rust, Python, Node and WASM: + - **Signed Volume** — per-trade size signed by aggressor side (`+size` buy, + `−size` sell). + - **Cumulative Volume Delta** — the running total of signed volume; reset to + re-anchor per session. + - **Trade Imbalance** — the rolling `(buyVol − sellVol)/(buyVol + sellVol)` + over a configurable window of trades. New public value types `Level`, `OrderBook`, `Side`, `Trade` and `TradeQuote` back this and the upcoming trade-flow and price-impact indicators. Python and diff --git a/README.md b/README.md index 8f898e10..0cfe3918 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 224 indicators; start at the + every one of the 227 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 -224 streaming-first indicators across seventeen families. Every one passes the +227 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 | +| Microstructure | Order-Book Imbalance (Top-1 / Top-N / Full), Microprice, Quoted Spread, Signed Volume, Cumulative Volume Delta, Trade Imbalance | | 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 224 indicators +│ ├── wickra-core/ core engine + all 227 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 e5eb2d26..eb335858 100644 --- a/bindings/node/__tests__/indicators.test.js +++ b/bindings/node/__tests__/indicators.test.js @@ -951,3 +951,33 @@ test('order-book TopN rejects zero levels', () => { test('order-book update rejects a crossed book', () => { assert.throws(() => new wickra.QuotedSpread().update([102], [1], [101], [1])); }); + +test('trade-flow indicators reference values', () => { + assert.equal(new wickra.SignedVolume().update(100, 2, true), 2); + assert.equal(new wickra.SignedVolume().update(100, 3, false), -3); + const cvd = new wickra.CumulativeVolumeDelta(); + assert.equal(cvd.update(100, 5, true), 5); + assert.equal(cvd.update(100, 2, false), 3); + const ti = new wickra.TradeImbalance(2); + assert.equal(ti.update(100, 3, true), null); // warming up + assert.equal(ti.update(100, 1, false), 0.5); // (3 - 1) / 4 +}); + +test('trade-flow streaming update matches batch', () => { + const n = 30; + const price = Array.from({ length: n }, () => 100); + const size = Array.from({ length: n }, (_, i) => 1 + (i % 4)); + const isBuy = Array.from({ length: n }, (_, i) => i % 3 !== 0); + const batch = new wickra.CumulativeVolumeDelta().batch(price, size, isBuy); + const streamer = new wickra.CumulativeVolumeDelta(); + assert.equal(batch.length, n); + for (let i = 0; i < n; i++) { + const s = streamer.update(price[i], size[i], isBuy[i]); + assert.ok(Math.abs(s - batch[i]) < 1e-12, `mismatch at ${i}: ${s} vs ${batch[i]}`); + } +}); + +test('trade-flow rejects bad input', () => { + assert.throws(() => new wickra.TradeImbalance(0)); + assert.throws(() => new wickra.SignedVolume().update(100, -1, true)); +}); diff --git a/bindings/node/index.d.ts b/bindings/node/index.d.ts index e4ea282f..efd437fc 100644 --- a/bindings/node/index.d.ts +++ b/bindings/node/index.d.ts @@ -2241,6 +2241,33 @@ export declare class OrderBookImbalanceTopN { isReady(): boolean warmupPeriod(): number } +export type SignedVolumeNode = SignedVolume +export declare class SignedVolume { + constructor() + update(price: number, size: number, isBuy: boolean): number | null + batch(price: Array, size: Array, isBuy: Array): Array + reset(): void + isReady(): boolean + warmupPeriod(): number +} +export type CumulativeVolumeDeltaNode = CumulativeVolumeDelta +export declare class CumulativeVolumeDelta { + constructor() + update(price: number, size: number, isBuy: boolean): number | null + batch(price: Array, size: Array, isBuy: Array): Array + reset(): void + isReady(): boolean + warmupPeriod(): number +} +export type TradeImbalanceNode = TradeImbalance +export declare class TradeImbalance { + constructor(window: number) + update(price: number, size: number, isBuy: boolean): number | null + batch(price: Array, size: Array, isBuy: Array): Array + reset(): void + isReady(): boolean + warmupPeriod(): number +} export type SharpeRatioNode = SharpeRatio export declare class SharpeRatio { constructor(period: number, riskFree: number) diff --git a/bindings/node/index.js b/bindings/node/index.js index b8104dfd..36581daf 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, 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, SignedVolume, CumulativeVolumeDelta, TradeImbalance, 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 @@ -520,6 +520,9 @@ module.exports.OrderBookImbalanceFull = OrderBookImbalanceFull module.exports.Microprice = Microprice module.exports.QuotedSpread = QuotedSpread module.exports.OrderBookImbalanceTopN = OrderBookImbalanceTopN +module.exports.SignedVolume = SignedVolume +module.exports.CumulativeVolumeDelta = CumulativeVolumeDelta +module.exports.TradeImbalance = TradeImbalance 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 7e1b3f95..8fcf414b 100644 --- a/bindings/node/src/lib.rs +++ b/bindings/node/src/lib.rs @@ -8914,6 +8914,144 @@ impl OrderBookImbalanceTopNNode { } } +// ============================== Microstructure: Trade Flow ============================== +// +// Trade-flow indicators consume a trade tape rather than OHLCV. Streaming +// `update(price, size, isBuy)` takes one trade (`isBuy=true` for a +// buyer-initiated trade); `batch` takes three equal-length arrays. + +fn build_trade(price: f64, size: f64, is_buy: bool) -> napi::Result { + let side = if is_buy { + wc::Side::Buy + } else { + wc::Side::Sell + }; + wc::Trade::new(price, size, side, 0).map_err(map_err) +} + +macro_rules! node_trade_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, + ) -> napi::Result> { + Ok(self.inner.update(build_trade(price, size, is_buy)?)) + } + #[napi] + pub fn batch( + &mut self, + price: Vec, + size: Vec, + is_buy: Vec, + ) -> napi::Result> { + if price.len() != size.len() || size.len() != is_buy.len() { + return Err(NapiError::from_reason( + "price, size, is_buy must be equal length".to_string(), + )); + } + let mut out = Vec::with_capacity(price.len()); + for i in 0..price.len() { + let trade = build_trade(price[i], size[i], is_buy[i])?; + out.push(self.inner.update(trade).unwrap_or(f64::NAN)); + } + Ok(out) + } + #[napi] + pub fn reset(&mut self) { + self.inner.reset(); + } + #[napi(js_name = "isReady")] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[napi(js_name = "warmupPeriod")] + pub fn warmup_period(&self) -> u32 { + self.inner.warmup_period() as u32 + } + } + }; +} + +node_trade_indicator!(SignedVolumeNode, wc::SignedVolume, "SignedVolume"); +node_trade_indicator!( + CumulativeVolumeDeltaNode, + wc::CumulativeVolumeDelta, + "CumulativeVolumeDelta" +); + +// Trade imbalance carries a `window` parameter, so it is hand-written. +#[napi(js_name = "TradeImbalance")] +pub struct TradeImbalanceNode { + inner: wc::TradeImbalance, +} + +#[napi] +impl TradeImbalanceNode { + #[napi(constructor)] + pub fn new(window: u32) -> napi::Result { + Ok(Self { + inner: wc::TradeImbalance::new(window as usize).map_err(map_err)?, + }) + } + #[napi] + pub fn update(&mut self, price: f64, size: f64, is_buy: bool) -> napi::Result> { + Ok(self.inner.update(build_trade(price, size, is_buy)?)) + } + #[napi] + pub fn batch( + &mut self, + price: Vec, + size: Vec, + is_buy: Vec, + ) -> napi::Result> { + if price.len() != size.len() || size.len() != is_buy.len() { + return Err(NapiError::from_reason( + "price, size, is_buy must be equal length".to_string(), + )); + } + let mut out = Vec::with_capacity(price.len()); + for i in 0..price.len() { + let trade = build_trade(price[i], size[i], is_buy[i])?; + out.push(self.inner.update(trade).unwrap_or(f64::NAN)); + } + Ok(out) + } + #[napi] + pub fn reset(&mut self) { + self.inner.reset(); + } + #[napi(js_name = "isReady")] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[napi(js_name = "warmupPeriod")] + pub fn warmup_period(&self) -> u32 { + self.inner.warmup_period() as u32 + } +} + // ============================== 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 af5efc7b..a7232207 100644 --- a/bindings/python/python/wickra/__init__.py +++ b/bindings/python/python/wickra/__init__.py @@ -246,6 +246,10 @@ from ._wickra import ( OrderBookImbalanceFull, Microprice, QuotedSpread, + # Microstructure: trade flow + SignedVolume, + CumulativeVolumeDelta, + TradeImbalance, # Risk / Performance SharpeRatio, SortinoRatio, @@ -489,6 +493,10 @@ __all__ = [ "OrderBookImbalanceFull", "Microprice", "QuotedSpread", + # Microstructure: trade flow + "SignedVolume", + "CumulativeVolumeDelta", + "TradeImbalance", # Risk / Performance "SharpeRatio", "SortinoRatio", diff --git a/bindings/python/src/lib.rs b/bindings/python/src/lib.rs index fc4c0c9c..2882d7e4 100644 --- a/bindings/python/src/lib.rs +++ b/bindings/python/src/lib.rs @@ -11771,6 +11771,137 @@ impl PyOrderBookImbalanceTopN { } } +// ============================== Microstructure: Trade Flow ============================== +// +// Trade-flow indicators consume a trade tape rather than OHLCV. Streaming +// `update(price, size, is_buy)` takes one trade (`is_buy=True` for a +// buyer-initiated trade); `batch` takes three equal-length arrays. + +fn build_trade(price: f64, size: f64, is_buy: bool) -> PyResult { + let side = if is_buy { + wc::Side::Buy + } else { + wc::Side::Sell + }; + wc::Trade::new(price, size, side, 0).map_err(map_err) +} + +macro_rules! py_trade_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) -> PyResult> { + Ok(self.inner.update(build_trade(price, size, is_buy)?)) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + price: Vec, + size: Vec, + is_buy: Vec, + ) -> PyResult>> { + if price.len() != size.len() || size.len() != is_buy.len() { + return Err(PyValueError::new_err( + "price, size, is_buy must be equal length", + )); + } + let mut out = Vec::with_capacity(price.len()); + for i in 0..price.len() { + let trade = build_trade(price[i], size[i], is_buy[i])?; + out.push(self.inner.update(trade).unwrap_or(f64::NAN)); + } + Ok(out.into_pyarray(py)) + } + fn reset(&mut self) { + self.inner.reset(); + } + fn is_ready(&self) -> bool { + self.inner.is_ready() + } + fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } + fn __repr__(&self) -> String { + format!("{}()", $repr) + } + } + }; +} + +py_trade_indicator!(PySignedVolume, wc::SignedVolume, "SignedVolume"); +py_trade_indicator!( + PyCumulativeVolumeDelta, + wc::CumulativeVolumeDelta, + "CumulativeVolumeDelta" +); + +// Trade imbalance carries a `window` parameter, so it is hand-written. +#[pyclass( + name = "TradeImbalance", + module = "wickra._wickra", + skip_from_py_object +)] +#[derive(Clone)] +struct PyTradeImbalance { + inner: wc::TradeImbalance, +} + +#[pymethods] +impl PyTradeImbalance { + #[new] + fn new(window: usize) -> PyResult { + Ok(Self { + inner: wc::TradeImbalance::new(window).map_err(map_err)?, + }) + } + fn update(&mut self, price: f64, size: f64, is_buy: bool) -> PyResult> { + Ok(self.inner.update(build_trade(price, size, is_buy)?)) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + price: Vec, + size: Vec, + is_buy: Vec, + ) -> PyResult>> { + if price.len() != size.len() || size.len() != is_buy.len() { + return Err(PyValueError::new_err( + "price, size, is_buy must be equal length", + )); + } + let mut out = Vec::with_capacity(price.len()); + for i in 0..price.len() { + let trade = build_trade(price[i], size[i], is_buy[i])?; + out.push(self.inner.update(trade).unwrap_or(f64::NAN)); + } + Ok(out.into_pyarray(py)) + } + fn reset(&mut self) { + self.inner.reset(); + } + fn is_ready(&self) -> bool { + self.inner.is_ready() + } + fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } + fn __repr__(&self) -> String { + format!("TradeImbalance(window={})", self.inner.window()) + } +} + // ============================== Family 15: Risk / Performance ============================== #[pyclass(name = "SharpeRatio", module = "wickra._wickra", skip_from_py_object)] @@ -12882,6 +13013,10 @@ fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; m.add_class::()?; m.add_class::()?; + // Microstructure: trade flow. + 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 5b207ab9..25845690 100644 --- a/bindings/python/tests/test_input_validation.py +++ b/bindings/python/tests/test_input_validation.py @@ -191,3 +191,23 @@ 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]) + + +def test_trade_imbalance_zero_window_raises(): + with pytest.raises(ValueError): + ta.TradeImbalance(0) + + +def test_trade_negative_size_raises(): + with pytest.raises(ValueError): + ta.SignedVolume().update(100.0, -1.0, True) + + +def test_trade_non_positive_price_raises(): + with pytest.raises(ValueError): + ta.CumulativeVolumeDelta().update(0.0, 1.0, True) + + +def test_trade_batch_unequal_lengths_raise(): + with pytest.raises(ValueError): + ta.SignedVolume().batch([100.0, 100.0], [1.0], [True, False]) diff --git a/bindings/python/tests/test_known_values.py b/bindings/python/tests/test_known_values.py index cb831602..c30387ea 100644 --- a/bindings/python/tests/test_known_values.py +++ b/bindings/python/tests/test_known_values.py @@ -870,3 +870,22 @@ 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) + + +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) + + +def test_cumulative_volume_delta_reference_values(): + cvd = ta.CumulativeVolumeDelta() + assert cvd.update(100.0, 5.0, True) == pytest.approx(5.0) + assert cvd.update(100.0, 2.0, False) == pytest.approx(3.0) + assert cvd.update(100.0, 4.0, False) == pytest.approx(-1.0) + + +def test_trade_imbalance_reference_value(): + ti = ta.TradeImbalance(2) + 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) diff --git a/bindings/python/tests/test_lifecycle.py b/bindings/python/tests/test_lifecycle.py index 7cf4b5f0..06e6a812 100644 --- a/bindings/python/tests/test_lifecycle.py +++ b/bindings/python/tests/test_lifecycle.py @@ -150,3 +150,25 @@ def test_orderbook_lifecycle(): def test_orderbook_topn_repr(): assert repr(ta.OrderBookImbalanceTopN(5)) == "OrderBookImbalanceTopN(levels=5)" + + +def test_tradeflow_lifecycle(): + for ind in [ta.SignedVolume(), ta.CumulativeVolumeDelta()]: + assert ind.warmup_period() == 1 + assert not ind.is_ready() + ind.update(100.0, 1.0, True) + assert ind.is_ready() + ind.reset() + assert not ind.is_ready() + + +def test_trade_imbalance_lifecycle_and_repr(): + ti = ta.TradeImbalance(3) + assert ti.warmup_period() == 3 + assert not ti.is_ready() + for _ in range(3): + ti.update(100.0, 1.0, True) + assert ti.is_ready() + ti.reset() + assert not ti.is_ready() + assert repr(ta.TradeImbalance(4)) == "TradeImbalance(window=4)" diff --git a/bindings/python/tests/test_new_indicators.py b/bindings/python/tests/test_new_indicators.py index 5a83db82..ee23d2fc 100644 --- a/bindings/python/tests/test_new_indicators.py +++ b/bindings/python/tests/test_new_indicators.py @@ -1898,3 +1898,23 @@ def test_orderbook_indicators_streaming_equals_batch(): ) assert batch.shape == (len(snaps),) assert _eq_nan(batch, streamed) + + +def test_tradeflow_indicators_streaming_equals_batch(): + n = 40 + price = np.full(n, 100.0) + size = np.array([1.0 + (i % 5) for i in range(n)], dtype=np.float64) + is_buy = [i % 2 == 0 for i in range(n)] + for make in ( + ta.SignedVolume, + ta.CumulativeVolumeDelta, + lambda: ta.TradeImbalance(5), + ): + batch = make().batch(price, size, is_buy) + streamer = make() + streamed = np.array( + [streamer.update(price[i], size[i], is_buy[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 e391a13b..d8381ad4 100644 --- a/bindings/python/tests/test_smoke.py +++ b/bindings/python/tests/test_smoke.py @@ -119,3 +119,19 @@ def test_orderbook_batch_returns_one_value_per_snapshot(): out = ta.OrderBookImbalanceTop1().batch(snapshots) assert out.shape == (5,) assert out.dtype == np.float64 + + +def test_tradeflow_indicators_construct_and_emit(): + # SignedVolume and CVD emit from the first trade; TradeImbalance(1) too. + assert isinstance(ta.SignedVolume().update(100.0, 2.0, True), float) + assert isinstance(ta.CumulativeVolumeDelta().update(100.0, 2.0, True), float) + assert isinstance(ta.TradeImbalance(1).update(100.0, 2.0, True), float) + + +def test_tradeflow_batch_returns_one_value_per_trade(): + price = np.full(6, 100.0) + size = np.array([1.0, 2.0, 3.0, 1.0, 2.0, 3.0]) + is_buy = [True, False, True, False, True, False] + out = ta.CumulativeVolumeDelta().batch(price, size, is_buy) + assert out.shape == (6,) + 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 1a8ba7aa..c9d5219d 100644 --- a/bindings/python/tests/test_streaming_vs_batch.py +++ b/bindings/python/tests/test_streaming_vs_batch.py @@ -217,3 +217,17 @@ def test_orderbook_streaming_matches_batch(): streamer = ta.Microprice() streamed = np.array([streamer.update(*snap) for snap in snaps], dtype=np.float64) assert _equal_with_nan(batch, streamed) + + +def test_tradeflow_streaming_matches_batch(): + n = 30 + price = np.full(n, 100.0) + size = np.array([1.0 + (i % 4) for i in range(n)], dtype=np.float64) + is_buy = [i % 3 != 0 for i in range(n)] + batch = ta.CumulativeVolumeDelta().batch(price, size, is_buy) + streamer = ta.CumulativeVolumeDelta() + streamed = np.array( + [streamer.update(price[i], size[i], is_buy[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 954227f8..862e4a6a 100644 --- a/bindings/wasm/src/lib.rs +++ b/bindings/wasm/src/lib.rs @@ -6459,6 +6459,102 @@ impl WasmOrderBookImbalanceTopN { } } +// ============================== Microstructure: Trade Flow ============================== +// +// Trade-flow indicators consume a trade tape rather than OHLCV. Each +// `update(price, size, isBuy)` takes one trade (`isBuy=true` for a +// buyer-initiated trade) — the streaming model for a live browser trade feed. + +fn build_trade(price: f64, size: f64, is_buy: bool) -> Result { + let side = if is_buy { + wc::Side::Buy + } else { + wc::Side::Sell + }; + wc::Trade::new(price, size, side, 0).map_err(map_err) +} + +macro_rules! wasm_trade_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, + ) -> Result, JsError> { + Ok(self.inner.update(build_trade(price, size, is_buy)?)) + } + pub fn reset(&mut self) { + self.inner.reset(); + } + #[wasm_bindgen(js_name = isReady)] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[wasm_bindgen(js_name = warmupPeriod)] + pub fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } + } + }; +} + +wasm_trade_indicator!(WasmSignedVolume, wc::SignedVolume, SignedVolume); +wasm_trade_indicator!( + WasmCumulativeVolumeDelta, + wc::CumulativeVolumeDelta, + CumulativeVolumeDelta +); + +// Trade imbalance carries a `window` parameter, so it is hand-written. +#[wasm_bindgen(js_name = TradeImbalance)] +pub struct WasmTradeImbalance { + inner: wc::TradeImbalance, +} + +#[wasm_bindgen(js_class = TradeImbalance)] +impl WasmTradeImbalance { + #[wasm_bindgen(constructor)] + pub fn new(window: usize) -> Result { + Ok(Self { + inner: wc::TradeImbalance::new(window).map_err(map_err)?, + }) + } + pub fn update(&mut self, price: f64, size: f64, is_buy: bool) -> Result, JsError> { + Ok(self.inner.update(build_trade(price, size, is_buy)?)) + } + pub fn reset(&mut self) { + self.inner.reset(); + } + #[wasm_bindgen(js_name = isReady)] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[wasm_bindgen(js_name = warmupPeriod)] + pub fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/wickra-core/src/indicators/cvd.rs b/crates/wickra-core/src/indicators/cvd.rs new file mode 100644 index 00000000..908b284c --- /dev/null +++ b/crates/wickra-core/src/indicators/cvd.rs @@ -0,0 +1,127 @@ +//! Cumulative Volume Delta — running sum of signed trade volume. + +use crate::microstructure::Trade; +use crate::traits::Indicator; + +/// Cumulative Volume Delta (CVD) — the running sum of [signed volume]. +/// +/// ```text +/// CVDₜ = CVDₜ₋₁ + sizeₜ · (+1 if buy, −1 if sell) +/// ``` +/// +/// CVD is an unbounded running total: a rising line signals net buying pressure +/// over the session, a falling line net selling. Divergence between CVD and +/// price is a classic absorption / exhaustion signal. Call [`reset`] at the +/// start of each session to re-anchor the cumulative total at zero. +/// +/// `Input = Trade`, `Output = f64`. Ready after the first trade. +/// +/// [signed volume]: crate::SignedVolume +/// [`reset`]: crate::Indicator::reset +/// +/// # Example +/// +/// ``` +/// use wickra_core::{CumulativeVolumeDelta, Indicator, Side, Trade}; +/// +/// let mut cvd = CumulativeVolumeDelta::new(); +/// assert_eq!(cvd.update(Trade::new(100.0, 5.0, Side::Buy, 0).unwrap()), Some(5.0)); +/// assert_eq!(cvd.update(Trade::new(100.0, 2.0, Side::Sell, 1).unwrap()), Some(3.0)); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct CumulativeVolumeDelta { + cumulative: f64, + has_emitted: bool, +} + +impl CumulativeVolumeDelta { + /// Construct a new CVD indicator with a zero running total. + pub const fn new() -> Self { + Self { + cumulative: 0.0, + has_emitted: false, + } + } +} + +impl Indicator for CumulativeVolumeDelta { + type Input = Trade; + type Output = f64; + + fn update(&mut self, trade: Trade) -> Option { + self.has_emitted = true; + self.cumulative += trade.size * trade.side.sign(); + Some(self.cumulative) + } + + fn reset(&mut self) { + self.cumulative = 0.0; + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "CumulativeVolumeDelta" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::microstructure::Side; + use crate::traits::BatchExt; + + fn trade(size: f64, side: Side, ts: i64) -> Trade { + Trade::new(100.0, size, side, ts).unwrap() + } + + #[test] + fn accessors_and_metadata() { + let cvd = CumulativeVolumeDelta::new(); + assert_eq!(cvd.name(), "CumulativeVolumeDelta"); + assert_eq!(cvd.warmup_period(), 1); + assert!(!cvd.is_ready()); + } + + #[test] + fn accumulates_signed_volume() { + let mut cvd = CumulativeVolumeDelta::new(); + assert_eq!(cvd.update(trade(5.0, Side::Buy, 0)), Some(5.0)); + assert_eq!(cvd.update(trade(2.0, Side::Sell, 1)), Some(3.0)); + assert_eq!(cvd.update(trade(4.0, Side::Sell, 2)), Some(-1.0)); + assert!(cvd.is_ready()); + } + + #[test] + fn batch_equals_streaming() { + let trades: Vec = (0..20) + .map(|i| { + let side = if i % 3 == 0 { Side::Sell } else { Side::Buy }; + trade(1.0 + (i % 4) as f64, side, i) + }) + .collect(); + let mut a = CumulativeVolumeDelta::new(); + let mut b = CumulativeVolumeDelta::new(); + assert_eq!( + a.batch(&trades), + trades.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_re_anchors_at_zero() { + let mut cvd = CumulativeVolumeDelta::new(); + cvd.update(trade(5.0, Side::Buy, 0)); + cvd.reset(); + assert!(!cvd.is_ready()); + // After reset the running total starts again from zero. + assert_eq!(cvd.update(trade(2.0, Side::Buy, 1)), Some(2.0)); + } +} diff --git a/crates/wickra-core/src/indicators/mod.rs b/crates/wickra-core/src/indicators/mod.rs index 2cb61fc6..2e2b67d2 100644 --- a/crates/wickra-core/src/indicators/mod.rs +++ b/crates/wickra-core/src/indicators/mod.rs @@ -47,6 +47,7 @@ mod cointegration; mod conditional_value_at_risk; mod connors_rsi; mod coppock; +mod cvd; mod cybernetic_cycle; mod decycler; mod decycler_oscillator; @@ -155,6 +156,7 @@ mod rvi_volatility; mod rwi; mod sharpe_ratio; mod shooting_star; +mod signed_volume; mod sine_wave; mod skewness; mod sma; @@ -191,6 +193,7 @@ mod three_inside; mod three_outside; mod three_soldiers_or_crows; mod tii; +mod trade_imbalance; mod treynor_ratio; mod trima; mod trix; @@ -271,6 +274,7 @@ pub use cointegration::{Cointegration, CointegrationOutput}; pub use conditional_value_at_risk::ConditionalValueAtRisk; pub use connors_rsi::ConnorsRsi; pub use coppock::Coppock; +pub use cvd::CumulativeVolumeDelta; pub use cybernetic_cycle::CyberneticCycle; pub use decycler::Decycler; pub use decycler_oscillator::DecyclerOscillator; @@ -379,6 +383,7 @@ pub use rvi_volatility::RviVolatility; pub use rwi::{Rwi, RwiOutput}; pub use sharpe_ratio::SharpeRatio; pub use shooting_star::ShootingStar; +pub use signed_volume::SignedVolume; pub use sine_wave::SineWave; pub use skewness::Skewness; pub use sma::Sma; @@ -415,6 +420,7 @@ pub use three_inside::ThreeInside; pub use three_outside::ThreeOutside; pub use three_soldiers_or_crows::ThreeSoldiersOrCrows; pub use tii::Tii; +pub use trade_imbalance::TradeImbalance; pub use treynor_ratio::TreynorRatio; pub use trima::Trima; pub use trix::Trix; @@ -725,6 +731,9 @@ pub const FAMILIES: &[(&str, &[&str])] = &[ "OrderBookImbalanceFull", "Microprice", "QuotedSpread", + "SignedVolume", + "CumulativeVolumeDelta", + "TradeImbalance", ], ), ( @@ -781,6 +790,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, 219, "FAMILIES total drifted from indicator count"); + assert_eq!(total, 222, "FAMILIES total drifted from indicator count"); } } diff --git a/crates/wickra-core/src/indicators/signed_volume.rs b/crates/wickra-core/src/indicators/signed_volume.rs new file mode 100644 index 00000000..e10d8b95 --- /dev/null +++ b/crates/wickra-core/src/indicators/signed_volume.rs @@ -0,0 +1,128 @@ +//! Signed Volume — per-trade volume signed by aggressor side. + +use crate::microstructure::Trade; +use crate::traits::Indicator; + +/// Signed Volume — the size of each trade signed by its aggressor side. +/// +/// ```text +/// signedVolume = size · (+1 if buy, −1 if sell) +/// ``` +/// +/// A positive value is buyer-initiated flow, a negative value seller-initiated. +/// It is the per-trade building block of [`crate::CumulativeVolumeDelta`] and +/// trade-flow imbalance. +/// +/// `Input = Trade`, `Output = f64`. Stateless; ready after the first trade. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, SignedVolume, Side, Trade}; +/// +/// let mut sv = SignedVolume::new(); +/// let buy = Trade::new(100.0, 2.0, Side::Buy, 0).unwrap(); +/// assert_eq!(sv.update(buy), Some(2.0)); +/// let sell = Trade::new(100.0, 3.0, Side::Sell, 1).unwrap(); +/// assert_eq!(sv.update(sell), Some(-3.0)); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct SignedVolume { + has_emitted: bool, +} + +impl SignedVolume { + /// Construct a new signed-volume indicator. + pub const fn new() -> Self { + Self { has_emitted: false } + } +} + +impl Indicator for SignedVolume { + type Input = Trade; + type Output = f64; + + fn update(&mut self, trade: Trade) -> Option { + self.has_emitted = true; + Some(trade.size * trade.side.sign()) + } + + 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 { + "SignedVolume" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::microstructure::Side; + use crate::traits::BatchExt; + + fn trade(size: f64, side: Side, ts: i64) -> Trade { + Trade::new(100.0, size, side, ts).unwrap() + } + + #[test] + fn accessors_and_metadata() { + let sv = SignedVolume::new(); + assert_eq!(sv.name(), "SignedVolume"); + assert_eq!(sv.warmup_period(), 1); + assert!(!sv.is_ready()); + } + + #[test] + fn buy_is_positive() { + let mut sv = SignedVolume::new(); + assert_eq!(sv.update(trade(2.0, Side::Buy, 0)), Some(2.0)); + assert!(sv.is_ready()); + } + + #[test] + fn sell_is_negative() { + let mut sv = SignedVolume::new(); + assert_eq!(sv.update(trade(3.0, Side::Sell, 0)), Some(-3.0)); + } + + #[test] + fn zero_size_is_zero() { + let mut sv = SignedVolume::new(); + assert_eq!(sv.update(trade(0.0, Side::Buy, 0)), Some(0.0)); + } + + #[test] + fn batch_equals_streaming() { + let trades: Vec = (0..20) + .map(|i| { + let side = if i % 2 == 0 { Side::Buy } else { Side::Sell }; + trade(1.0 + (i % 4) as f64, side, i) + }) + .collect(); + let mut a = SignedVolume::new(); + let mut b = SignedVolume::new(); + assert_eq!( + a.batch(&trades), + trades.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut sv = SignedVolume::new(); + sv.update(trade(1.0, Side::Buy, 0)); + assert!(sv.is_ready()); + sv.reset(); + assert!(!sv.is_ready()); + } +} diff --git a/crates/wickra-core/src/indicators/trade_imbalance.rs b/crates/wickra-core/src/indicators/trade_imbalance.rs new file mode 100644 index 00000000..284a915f --- /dev/null +++ b/crates/wickra-core/src/indicators/trade_imbalance.rs @@ -0,0 +1,193 @@ +//! Trade Imbalance — rolling buy/sell volume imbalance over a trade window. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::microstructure::Trade; +use crate::traits::Indicator; + +/// Trade Imbalance — the signed buy/sell volume imbalance over the trailing +/// window of `window` trades. +/// +/// ```text +/// buyVol = Σ size of buyer-initiated trades in the window +/// sellVol = Σ size of seller-initiated trades in the window +/// imbalance = (buyVol − sellVol) / (buyVol + sellVol) +/// ``` +/// +/// The output lies in `[−1, +1]`: `+1` means the window was all aggressive +/// buying, `−1` all aggressive selling, `0` balanced (or no volume). The +/// indicator warms up for `window` trades — `update` returns `None` until the +/// window is full — then emits the rolling imbalance, maintained in O(1) per +/// trade. +/// +/// `Input = Trade`, `Output = f64`. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, Side, Trade, TradeImbalance}; +/// +/// let mut ti = TradeImbalance::new(2).unwrap(); +/// assert_eq!(ti.update(Trade::new(100.0, 3.0, Side::Buy, 0).unwrap()), None); +/// // Window full: buyVol 3, sellVol 1 -> (3 - 1) / 4 = 0.5. +/// let out = ti.update(Trade::new(100.0, 1.0, Side::Sell, 1).unwrap()); +/// assert_eq!(out, Some(0.5)); +/// ``` +#[derive(Debug, Clone)] +pub struct TradeImbalance { + window: usize, + history: VecDeque<(f64, f64)>, + buy_sum: f64, + sell_sum: f64, +} + +impl TradeImbalance { + /// Construct a trade-imbalance indicator over a window of `window` trades. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `window` is zero. + pub fn new(window: usize) -> Result { + if window == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + window, + history: VecDeque::with_capacity(window), + buy_sum: 0.0, + sell_sum: 0.0, + }) + } + + /// The configured window length, in trades. + pub fn window(&self) -> usize { + self.window + } +} + +impl Indicator for TradeImbalance { + type Input = Trade; + type Output = f64; + + fn update(&mut self, trade: Trade) -> Option { + let (buy, sell) = if trade.side.sign() > 0.0 { + (trade.size, 0.0) + } else { + (0.0, trade.size) + }; + self.history.push_back((buy, sell)); + self.buy_sum += buy; + self.sell_sum += sell; + if self.history.len() > self.window { + let (old_buy, old_sell) = self.history.pop_front().expect("window >= 1, len > window"); + self.buy_sum -= old_buy; + self.sell_sum -= old_sell; + } + if self.history.len() < self.window { + return None; + } + let total = self.buy_sum + self.sell_sum; + if total <= 0.0 { + return Some(0.0); + } + Some((self.buy_sum - self.sell_sum) / total) + } + + fn reset(&mut self) { + self.history.clear(); + self.buy_sum = 0.0; + self.sell_sum = 0.0; + } + + fn warmup_period(&self) -> usize { + self.window + } + + fn is_ready(&self) -> bool { + self.history.len() >= self.window + } + + fn name(&self) -> &'static str { + "TradeImbalance" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::microstructure::Side; + use crate::traits::BatchExt; + + fn trade(size: f64, side: Side, ts: i64) -> Trade { + Trade::new(100.0, size, side, ts).unwrap() + } + + #[test] + fn rejects_zero_window() { + assert!(matches!(TradeImbalance::new(0), Err(Error::PeriodZero))); + } + + #[test] + fn accessors_and_metadata() { + let ti = TradeImbalance::new(5).unwrap(); + assert_eq!(ti.name(), "TradeImbalance"); + assert_eq!(ti.warmup_period(), 5); + assert_eq!(ti.window(), 5); + assert!(!ti.is_ready()); + } + + #[test] + fn warms_up_then_emits() { + let mut ti = TradeImbalance::new(2).unwrap(); + assert_eq!(ti.update(trade(3.0, Side::Buy, 0)), None); + assert!(!ti.is_ready()); + // Window full: buyVol 3, sellVol 1 -> 0.5. + assert_eq!(ti.update(trade(1.0, Side::Sell, 1)), Some(0.5)); + assert!(ti.is_ready()); + } + + #[test] + fn rolls_off_old_trades() { + let mut ti = TradeImbalance::new(2).unwrap(); + ti.update(trade(3.0, Side::Buy, 0)); + ti.update(trade(1.0, Side::Sell, 1)); // [buy 3, sell 1] -> 0.5 + // Third trade drops the first: window now [sell 1, buy 5] -> (5-1)/6. + let out = ti.update(trade(5.0, Side::Buy, 2)).unwrap(); + assert!((out - (4.0 / 6.0)).abs() < 1e-12); + } + + #[test] + fn zero_volume_window_is_zero() { + let mut ti = TradeImbalance::new(2).unwrap(); + ti.update(trade(0.0, Side::Buy, 0)); + assert_eq!(ti.update(trade(0.0, Side::Sell, 1)), Some(0.0)); + } + + #[test] + fn batch_equals_streaming() { + let trades: Vec = (0..30) + .map(|i| { + let side = if i % 2 == 0 { Side::Buy } else { Side::Sell }; + trade(1.0 + (i % 5) as f64, side, i) + }) + .collect(); + let mut a = TradeImbalance::new(5).unwrap(); + let mut b = TradeImbalance::new(5).unwrap(); + assert_eq!( + a.batch(&trades), + trades.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut ti = TradeImbalance::new(2).unwrap(); + ti.update(trade(3.0, Side::Buy, 0)); + ti.update(trade(1.0, Side::Sell, 1)); + assert!(ti.is_ready()); + ti.reset(); + assert!(!ti.is_ready()); + assert_eq!(ti.update(trade(2.0, Side::Buy, 2)), None); + } +} diff --git a/crates/wickra-core/src/lib.rs b/crates/wickra-core/src/lib.rs index 2b7b2144..f93732dc 100644 --- a/crates/wickra-core/src/lib.rs +++ b/crates/wickra-core/src/lib.rs @@ -54,35 +54,36 @@ pub use indicators::{ ChaikinVolatility, ChandeKrollStop, ChandeKrollStopOutput, ChandelierExit, ChandelierExitOutput, ChoppinessIndex, ClassicPivots, ClassicPivotsOutput, Cmo, CoefficientOfVariation, Cointegration, CointegrationOutput, ConditionalValueAtRisk, ConnorsRsi, - Coppock, CyberneticCycle, Decycler, DecyclerOscillator, Dema, DemandIndex, DemarkPivots, - DemarkPivotsOutput, DetrendedStdDev, Doji, Donchian, DonchianOutput, DonchianStop, - DonchianStopOutput, DoubleBollinger, DoubleBollingerOutput, Dpo, DrawdownDuration, - EaseOfMovement, 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, 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, + Coppock, CumulativeVolumeDelta, CyberneticCycle, Decycler, DecyclerOscillator, Dema, + DemandIndex, DemarkPivots, DemarkPivotsOutput, DetrendedStdDev, Doji, Donchian, DonchianOutput, + DonchianStop, DonchianStopOutput, DoubleBollinger, DoubleBollingerOutput, Dpo, + DrawdownDuration, EaseOfMovement, 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, + 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, TreynorRatio, Trima, Trix, TrueRange, Tsi, Tsv, TtmSqueeze, - TtmSqueezeOutput, Tweezer, TypicalPrice, UlcerIndex, UltimateOscillator, ValueArea, + 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, diff --git a/crates/wickra/benches/indicators.rs b/crates/wickra/benches/indicators.rs index 239c8ddc..775876f7 100644 --- a/crates/wickra/benches/indicators.rs +++ b/crates/wickra/benches/indicators.rs @@ -37,9 +37,10 @@ use wickra::{ 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, + RollingVwap, Rsi, SharpeRatio, Side, SignedVolume, Sma, Stc, SuperTrend, SuperTrendOutput, + TdSequential, TdSequentialOutput, Trade, TradeImbalance, TtmSqueeze, TtmSqueezeOutput, + ValueArea, ValueAreaOutput, ValueAtRisk, Vwap, VwapStdDevBands, VwapStdDevBandsOutput, + WaveTrend, YangZhangVolatility, T3, }; use wickra_data::csv::CandleReader; @@ -136,6 +137,28 @@ where group.finish(); } +fn bench_trade_input(c: &mut Criterion, name: &str, trades: &[Trade], make: F) +where + F: Fn() -> I, + I: Indicator, +{ + let mut group = c.benchmark_group(name); + for &n in SIZES { + let n = n.min(trades.len()); + let series = &trades[..n]; + group.throughput(Throughput::Elements(n as u64)); + group.bench_with_input(BenchmarkId::new("streaming", n), series, |b, trades| { + b.iter(|| { + let mut ind = make(); + for t in trades { + black_box(ind.update(*t)); + } + }); + }); + } + group.finish(); +} + fn bench_scalar_multi(c: &mut Criterion, name: &str, prices: &[f64], make: F) where F: Fn() -> I, @@ -309,6 +332,25 @@ 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); + + // Synthesise a trade tape from candles: one trade per bar, sided by the + // candle's direction. SignedVolume is the cheapest; TradeImbalance carries + // a rolling window and is the most expensive. + let trades: Vec = candles + .iter() + .map(|candle| { + let side = if candle.close >= candle.open { + Side::Buy + } else { + Side::Sell + }; + Trade::new_unchecked(candle.close, candle.volume, side, candle.timestamp) + }) + .collect(); + bench_trade_input(c, "signed_volume", &trades, SignedVolume::new); + bench_trade_input(c, "trade_imbalance", &trades, || { + TradeImbalance::new(50).unwrap() + }); } criterion_group!(name = wickra_benches; config = Criterion::default(); targets = benches); diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index 8040f9b6..7dbda145 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -59,6 +59,13 @@ test = false doc = false bench = false +[[bin]] +name = "indicator_update_trade" +path = "fuzz_targets/indicator_update_trade.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_trade.rs b/fuzz/fuzz_targets/indicator_update_trade.rs new file mode 100644 index 00000000..a436a745 --- /dev/null +++ b/fuzz/fuzz_targets/indicator_update_trade.rs @@ -0,0 +1,45 @@ +#![no_main] +//! Fuzz trade-flow `Indicator` implementations with arbitrary +//! trade tapes. +//! +//! Each iteration consumes a byte stream, interprets it as a sequence of `f64` +//! values (8 bytes each), and packs consecutive values into `(price, size)` +//! trades whose aggressor side alternates with the sign of the size field. +//! Trades are built with `Trade::new_unchecked` so the fuzzer can explore +//! degenerate values (non-finite, negative) that the validating constructor +//! would reject — the indicators must never panic, streaming or batched. + +use libfuzzer_sys::fuzz_target; +use wickra_core::{ + BatchExt, CumulativeVolumeDelta, Indicator, Side, SignedVolume, Trade, TradeImbalance, +}; + +#[inline(never)] +fn drive(make: impl Fn() -> I, trades: &[Trade]) +where + I: Indicator + BatchExt, +{ + let mut streaming = make(); + for &trade in trades { + let _ = streaming.update(trade); + } + let _ = make().batch(trades); +} + +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 trades: Vec = floats + .chunks_exact(2) + .map(|c| { + let side = if c[1] >= 0.0 { Side::Buy } else { Side::Sell }; + Trade::new_unchecked(c[0], c[1], side, 0) + }) + .collect(); + + drive(SignedVolume::new, &trades); + drive(CumulativeVolumeDelta::new, &trades); + drive(|| TradeImbalance::new(5).unwrap(), &trades); +});