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:
@@ -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("es),
|
||||
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("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<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("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);
|
||||
}
|
||||
}
|
||||
@@ -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("es),
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user