feat: microstructure price-impact & depth indicators (part 3 of 4) (#122)

* feat: effective spread microstructure indicator (part 3 of 4)

* feat: realized spread microstructure indicator (part 3 of 4)

* feat: kyle's lambda microstructure indicator (part 3 of 4)

* feat: depth slope microstructure indicator (part 3 of 4)
This commit is contained in:
kingchenc
2026-06-01 19:45:38 +02:00
committed by GitHub
parent b5d9e47a2e
commit 4f11df0e33
25 changed files with 1898 additions and 39 deletions
@@ -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<f64> {
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::<Vec<_>>()
};
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<OrderBook> = (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::<Vec<_>>()
);
}
#[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());
}
}
@@ -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<f64> {
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<TradeQuote> = (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(&quotes),
quotes.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
#[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());
}
}
@@ -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<f64>,
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<Self> {
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<f64> {
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<f64> {
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<TradeQuote> {
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(&quotes_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(&quotes_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<TradeQuote> = (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(&quotes)
.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(&quotes);
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);
}
}
+13 -1
View File
@@ -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");
}
}
@@ -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<Self> {
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<f64> {
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<TradeQuote> = (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(&quotes),
quotes.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
#[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);
}
}
+23 -23
View File
@@ -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,
+41 -8
View File
@@ -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<I, F, O>(c: &mut Criterion, name: &str, quotes: &[TradeQuote], make: F)
where
F: Fn() -> I,
I: Indicator<Input = TradeQuote, Output = O>,
{
let mut group = c.benchmark_group(name);
for &n in SIZES {
let n = n.min(quotes.len());
let series = &quotes[..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<I, F, O>(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<TradeQuote> = trades
.iter()
.map(|trade| TradeQuote::new_unchecked(*trade, trade.price))
.collect();
bench_tradequote_input(c, "effective_spread", &quotes, EffectiveSpread::new);
bench_tradequote_input(c, "kyles_lambda", &quotes, || KylesLambda::new(50).unwrap());
}
criterion_group!(name = wickra_benches; config = Criterion::default(); targets = benches);