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