feat: order-book microstructure indicators (part 1 of 4) (#112)

* feat(core): add microstructure input types (OrderBook, Trade, TradeQuote)

New non-OHLCV value types for the order-book / trade-flow indicator family:
Level, OrderBook (sorted, uncrossed depth snapshot), Side, Trade (with
aggressor side), and TradeQuote (trade paired with prevailing mid). Each has a
validating constructor plus a new_unchecked hot-path constructor, with full
unit coverage. Adds InvalidOrderBook / InvalidTrade error variants.

* feat(core): add 5 order-book microstructure indicators

OrderBookImbalanceTop1/TopN/Full (signed depth imbalance), Microprice
(size-weighted fair value), and QuotedSpread (top-of-book spread in bps). All
consume the OrderBook snapshot type, emit f64, are stateless and ready after
the first snapshot, with full unit coverage. Registers a new Microstructure
family in the taxonomy.

* feat(bindings): expose order-book microstructure indicators

Python, Node, and WASM bindings for OrderBookImbalanceTop1/TopN/Full,
Microprice and QuotedSpread. Each takes a depth snapshot via four equal-length
(bid_px, bid_sz, ask_px, ask_sz) arrays. Python and Node expose a batch over a
list of snapshots; WASM exposes per-snapshot update (the streaming model that
fits a browser book feed). Regenerates node index.d.ts/.js and registers the
new InvalidOrderBook/InvalidTrade arms in the Python error mapping.

* test(bindings,fuzz): cover order-book microstructure indicators

Python: smoke, reference values, streaming-vs-batch, lifecycle/repr and input
validation (mismatched lengths, crossed book, misordered levels, zero levels)
for all five order-book indicators. Node: reference values, streaming-vs-batch,
and rejection cases. Adds an indicator_update_orderbook fuzz target driving
every order-book indicator over arbitrary (incl. degenerate) snapshots.

* bench(microstructure): synthetic order-book benchmarks

Add a bench_orderbook_input harness and synthesise a five-level book around
each candle close (no order-book dataset ships with the repo). Benches the
cheapest (top-of-book imbalance) and most-expensive (full-depth imbalance) plus
microprice, matching the curated cheapest/expensive-per-family approach.

* docs: add Microstructure family + bump indicator counter to 224

README gains the Microstructure family row (order-book imbalance, microprice,
quoted spread) and the indicator counter goes 219 -> 224 across seventeen
families; CHANGELOG records the new order-book indicators and value types.
This commit is contained in:
kingchenc
2026-06-01 16:06:22 +02:00
committed by GitHub
parent 498b74a5ae
commit 2be21df803
27 changed files with 2189 additions and 21 deletions
@@ -0,0 +1,170 @@
//! Microprice — size-weighted fair value of the top of book.
use crate::microstructure::OrderBook;
use crate::traits::Indicator;
/// Microprice — the size-weighted mid of the top of book.
///
/// The microprice tilts the mid toward the side that is *more likely to be
/// hit*: it weights each touch price by the size resting on the **opposite**
/// side, so a heavy ask (sell pressure) pulls the fair value down toward the
/// bid, and vice versa:
///
/// ```text
/// microprice = (bidPrice₁·askSize₁ + askPrice₁·bidSize₁) / (bidSize₁ + askSize₁)
/// ```
///
/// When both top sizes are zero the weighting is undefined and the plain mid
/// `(bidPrice₁ + askPrice₁) / 2` is returned. An empty book yields `0`.
///
/// `Input = OrderBook`, `Output = f64`. Stateless; ready after the first
/// snapshot.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, Level, Microprice, OrderBook};
///
/// let book = OrderBook::new(
/// vec![Level::new(100.0, 1.0).unwrap()],
/// vec![Level::new(101.0, 3.0).unwrap()],
/// )
/// .unwrap();
/// let mut mp = Microprice::new();
/// // (100·3 + 101·1) / (1 + 3) = 401 / 4 = 100.25 — pulled toward the bid.
/// assert_eq!(mp.update(book), Some(100.25));
/// ```
#[derive(Debug, Clone, Default)]
pub struct Microprice {
has_emitted: bool,
}
impl Microprice {
/// Construct a new microprice indicator.
pub const fn new() -> Self {
Self { has_emitted: false }
}
}
impl Indicator for Microprice {
type Input = OrderBook;
type Output = f64;
fn update(&mut self, book: OrderBook) -> Option<f64> {
self.has_emitted = true;
let (Some(bid), Some(ask)) = (book.best_bid(), book.best_ask()) else {
return Some(0.0);
};
let total = bid.size + ask.size;
if total <= 0.0 {
return Some(f64::midpoint(bid.price, ask.price));
}
Some((bid.price * ask.size + ask.price * bid.size) / total)
}
fn reset(&mut self) {
self.has_emitted = false;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"Microprice"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::microstructure::Level;
use crate::traits::BatchExt;
fn book(bids: &[(f64, f64)], asks: &[(f64, f64)]) -> OrderBook {
let to_levels = |xs: &[(f64, f64)]| {
xs.iter()
.map(|&(p, s)| Level::new(p, s).unwrap())
.collect::<Vec<_>>()
};
OrderBook::new(to_levels(bids), to_levels(asks)).unwrap()
}
#[test]
fn accessors_and_metadata() {
let mp = Microprice::new();
assert_eq!(mp.name(), "Microprice");
assert_eq!(mp.warmup_period(), 1);
assert!(!mp.is_ready());
}
#[test]
fn weights_toward_thin_side() {
let mut mp = Microprice::new();
// Heavy ask -> microprice pulled toward bid.
assert_eq!(
mp.update(book(&[(100.0, 1.0)], &[(101.0, 3.0)])),
Some(100.25)
);
assert!(mp.is_ready());
}
#[test]
fn balanced_top_equals_mid() {
let mut mp = Microprice::new();
assert_eq!(
mp.update(book(&[(100.0, 2.0)], &[(101.0, 2.0)])),
Some(100.5)
);
}
#[test]
fn zero_size_falls_back_to_mid() {
let mut mp = Microprice::new();
assert_eq!(
mp.update(book(&[(100.0, 0.0)], &[(102.0, 0.0)])),
Some(101.0)
);
}
#[test]
fn empty_book_is_zero() {
let mut mp = Microprice::new();
assert_eq!(
mp.update(OrderBook::new_unchecked(vec![], vec![])),
Some(0.0)
);
}
#[test]
fn batch_equals_streaming() {
let books: Vec<OrderBook> = (0..20)
.map(|i| {
let ask = 1.0 + f64::from(i % 4);
book(&[(100.0, 2.0)], &[(101.0, ask)])
})
.collect();
let mut a = Microprice::new();
let mut b = Microprice::new();
assert_eq!(
a.batch(&books),
books
.iter()
.map(|x| b.update(x.clone()))
.collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut mp = Microprice::new();
mp.update(book(&[(100.0, 1.0)], &[(101.0, 1.0)]));
assert!(mp.is_ready());
mp.reset();
assert!(!mp.is_ready());
}
}
+21 -1
View File
@@ -116,10 +116,14 @@ mod mcginley_dynamic;
mod median_absolute_deviation;
mod median_price;
mod mfi;
mod microprice;
mod mom;
mod morning_evening_star;
mod natr;
mod nvi;
mod ob_imbalance_full;
mod ob_imbalance_top1;
mod ob_imbalance_topn;
mod obv;
mod omega_ratio;
mod opening_range;
@@ -137,6 +141,7 @@ mod ppo;
mod profit_factor;
mod psar;
mod pvi;
mod quoted_spread;
mod r_squared;
mod recovery_factor;
mod relative_strength_ab;
@@ -335,10 +340,14 @@ pub use mcginley_dynamic::McGinleyDynamic;
pub use median_absolute_deviation::MedianAbsoluteDeviation;
pub use median_price::MedianPrice;
pub use mfi::Mfi;
pub use microprice::Microprice;
pub use mom::Mom;
pub use morning_evening_star::MorningEveningStar;
pub use natr::Natr;
pub use nvi::Nvi;
pub use ob_imbalance_full::OrderBookImbalanceFull;
pub use ob_imbalance_top1::OrderBookImbalanceTop1;
pub use ob_imbalance_topn::OrderBookImbalanceTopN;
pub use obv::Obv;
pub use omega_ratio::OmegaRatio;
pub use opening_range::{OpeningRange, OpeningRangeOutput};
@@ -356,6 +365,7 @@ pub use ppo::Ppo;
pub use profit_factor::ProfitFactor;
pub use psar::Psar;
pub use pvi::Pvi;
pub use quoted_spread::QuotedSpread;
pub use r_squared::RSquared;
pub use recovery_factor::RecoveryFactor;
pub use relative_strength_ab::{RelativeStrengthAB, RelativeStrengthOutput};
@@ -707,6 +717,16 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
"ThreeOutside",
],
),
(
"Microstructure",
&[
"OrderBookImbalanceTop1",
"OrderBookImbalanceTopN",
"OrderBookImbalanceFull",
"Microprice",
"QuotedSpread",
],
),
(
"Market Profile",
&["ValueArea", "InitialBalance", "OpeningRange"],
@@ -761,6 +781,6 @@ mod family_tests {
// the actual indicator count is the early-warning signal that an
// indicator was added without being assigned a family.
let total: usize = FAMILIES.iter().map(|(_, ns)| ns.len()).sum();
assert_eq!(total, 214, "FAMILIES total drifted from indicator count");
assert_eq!(total, 219, "FAMILIES total drifted from indicator count");
}
}
@@ -0,0 +1,157 @@
//! Order-Book Imbalance over the full visible depth.
use crate::microstructure::OrderBook;
use crate::traits::Indicator;
/// Order-Book Imbalance aggregated over the full visible depth of each side.
///
/// Sums the resting size of every bid level and every ask level in the
/// snapshot and compares them:
///
/// ```text
/// bidDepth = Σ size of all bids
/// askDepth = Σ size of all asks
/// imbalance = (bidDepth askDepth) / (bidDepth + askDepth)
/// ```
///
/// The output lies in `[1, +1]`. A book with zero total size yields `0`. Use
/// [`crate::OrderBookImbalanceTopN`] to bound the depth to the most relevant
/// near-touch levels instead of the full visible book.
///
/// `Input = OrderBook`, `Output = f64`. Stateless; ready after the first
/// snapshot.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, Level, OrderBook, OrderBookImbalanceFull};
///
/// let book = OrderBook::new(
/// vec![Level::new(100.0, 2.0).unwrap(), Level::new(99.0, 1.0).unwrap()],
/// vec![Level::new(101.0, 0.5).unwrap(), Level::new(102.0, 0.5).unwrap()],
/// )
/// .unwrap();
/// let mut obi = OrderBookImbalanceFull::new();
/// assert_eq!(obi.update(book), Some(0.5)); // (3 1) / (3 + 1)
/// ```
#[derive(Debug, Clone, Default)]
pub struct OrderBookImbalanceFull {
has_emitted: bool,
}
impl OrderBookImbalanceFull {
/// Construct a new full-depth imbalance indicator.
pub const fn new() -> Self {
Self { has_emitted: false }
}
}
impl Indicator for OrderBookImbalanceFull {
type Input = OrderBook;
type Output = f64;
fn update(&mut self, book: OrderBook) -> Option<f64> {
self.has_emitted = true;
let bid_depth: f64 = book.bids.iter().map(|l| l.size).sum();
let ask_depth: f64 = book.asks.iter().map(|l| l.size).sum();
let total = bid_depth + ask_depth;
if total <= 0.0 {
return Some(0.0);
}
Some((bid_depth - ask_depth) / total)
}
fn reset(&mut self) {
self.has_emitted = false;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"OrderBookImbalanceFull"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::microstructure::Level;
use crate::traits::BatchExt;
fn book(bids: &[(f64, f64)], asks: &[(f64, f64)]) -> OrderBook {
let to_levels = |xs: &[(f64, f64)]| {
xs.iter()
.map(|&(p, s)| Level::new(p, s).unwrap())
.collect::<Vec<_>>()
};
OrderBook::new(to_levels(bids), to_levels(asks)).unwrap()
}
#[test]
fn accessors_and_metadata() {
let obi = OrderBookImbalanceFull::new();
assert_eq!(obi.name(), "OrderBookImbalanceFull");
assert_eq!(obi.warmup_period(), 1);
assert!(!obi.is_ready());
}
#[test]
fn sums_full_depth() {
let mut obi = OrderBookImbalanceFull::new();
let b = book(&[(100.0, 2.0), (99.0, 2.0)], &[(101.0, 1.0), (102.0, 1.0)]);
// bidDepth 4, askDepth 2 -> (4 - 2) / 6 = 1/3.
assert_eq!(obi.update(b), Some(1.0 / 3.0));
assert!(obi.is_ready());
}
#[test]
fn ask_heavy_full_depth_is_negative() {
let mut obi = OrderBookImbalanceFull::new();
let b = book(&[(100.0, 1.0)], &[(101.0, 2.0), (102.0, 1.0)]);
// (1 - 3) / 4 = -0.5.
assert_eq!(obi.update(b), Some(-0.5));
}
#[test]
fn zero_size_is_zero() {
let mut obi = OrderBookImbalanceFull::new();
assert_eq!(
obi.update(book(&[(100.0, 0.0)], &[(101.0, 0.0)])),
Some(0.0)
);
}
#[test]
fn batch_equals_streaming() {
let books: Vec<OrderBook> = (0..20)
.map(|i| {
let bid = 1.0 + f64::from(i % 3);
book(&[(100.0, bid), (99.0, 1.0)], &[(101.0, 2.0), (102.0, 1.0)])
})
.collect();
let mut a = OrderBookImbalanceFull::new();
let mut b = OrderBookImbalanceFull::new();
assert_eq!(
a.batch(&books),
books
.iter()
.map(|x| b.update(x.clone()))
.collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut obi = OrderBookImbalanceFull::new();
obi.update(book(&[(100.0, 1.0)], &[(101.0, 1.0)]));
assert!(obi.is_ready());
obi.reset();
assert!(!obi.is_ready());
}
}
@@ -0,0 +1,176 @@
//! Order-Book Imbalance at the top of book.
use crate::microstructure::OrderBook;
use crate::traits::Indicator;
/// Order-Book Imbalance (top-of-book).
///
/// Measures the pressure between the best bid and best ask by comparing their
/// resting sizes:
///
/// ```text
/// imbalance = (bidSize₁ askSize₁) / (bidSize₁ + askSize₁)
/// ```
///
/// The output lies in `[1, +1]`: `+1` means all size sits on the bid (buy
/// pressure), `1` means all size sits on the ask (sell pressure), `0` means a
/// balanced top of book. A book with zero size on both top levels yields `0`.
///
/// `Input = OrderBook`, `Output = f64`. The indicator is stateless and ready
/// after the first snapshot.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, Level, OrderBook, OrderBookImbalanceTop1};
///
/// let book = OrderBook::new(
/// vec![Level::new(100.0, 3.0).unwrap()],
/// vec![Level::new(101.0, 1.0).unwrap()],
/// )
/// .unwrap();
/// let mut obi = OrderBookImbalanceTop1::new();
/// assert_eq!(obi.update(book), Some(0.5)); // (3 1) / (3 + 1)
/// ```
#[derive(Debug, Clone, Default)]
pub struct OrderBookImbalanceTop1 {
has_emitted: bool,
}
impl OrderBookImbalanceTop1 {
/// Construct a new top-of-book imbalance indicator.
pub const fn new() -> Self {
Self { has_emitted: false }
}
}
impl Indicator for OrderBookImbalanceTop1 {
type Input = OrderBook;
type Output = f64;
fn update(&mut self, book: OrderBook) -> Option<f64> {
self.has_emitted = true;
let (Some(bid), Some(ask)) = (book.best_bid(), book.best_ask()) else {
return Some(0.0);
};
let total = bid.size + ask.size;
if total <= 0.0 {
return Some(0.0);
}
Some((bid.size - ask.size) / total)
}
fn reset(&mut self) {
self.has_emitted = false;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"OrderBookImbalanceTop1"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::microstructure::Level;
use crate::traits::BatchExt;
fn book(bids: &[(f64, f64)], asks: &[(f64, f64)]) -> OrderBook {
let to_levels = |xs: &[(f64, f64)]| {
xs.iter()
.map(|&(p, s)| Level::new(p, s).unwrap())
.collect::<Vec<_>>()
};
OrderBook::new(to_levels(bids), to_levels(asks)).unwrap()
}
#[test]
fn accessors_and_metadata() {
let obi = OrderBookImbalanceTop1::new();
assert_eq!(obi.name(), "OrderBookImbalanceTop1");
assert_eq!(obi.warmup_period(), 1);
assert!(!obi.is_ready());
}
#[test]
fn balanced_top_is_zero() {
let mut obi = OrderBookImbalanceTop1::new();
assert_eq!(
obi.update(book(&[(100.0, 2.0)], &[(101.0, 2.0)])),
Some(0.0)
);
assert!(obi.is_ready());
}
#[test]
fn bid_heavy_is_positive() {
let mut obi = OrderBookImbalanceTop1::new();
assert_eq!(
obi.update(book(&[(100.0, 3.0)], &[(101.0, 1.0)])),
Some(0.5)
);
}
#[test]
fn ask_heavy_is_negative() {
let mut obi = OrderBookImbalanceTop1::new();
assert_eq!(
obi.update(book(&[(100.0, 1.0)], &[(101.0, 3.0)])),
Some(-0.5)
);
}
#[test]
fn zero_size_top_is_zero() {
let mut obi = OrderBookImbalanceTop1::new();
assert_eq!(
obi.update(book(&[(100.0, 0.0)], &[(101.0, 0.0)])),
Some(0.0)
);
}
#[test]
fn empty_book_is_zero() {
let mut obi = OrderBookImbalanceTop1::new();
assert_eq!(
obi.update(OrderBook::new_unchecked(vec![], vec![])),
Some(0.0)
);
}
#[test]
fn batch_equals_streaming() {
let books: Vec<OrderBook> = (0..20)
.map(|i| {
let bid = 1.0 + f64::from(i % 5);
book(&[(100.0, bid)], &[(101.0, 2.0)])
})
.collect();
let mut a = OrderBookImbalanceTop1::new();
let mut b = OrderBookImbalanceTop1::new();
assert_eq!(
a.batch(&books),
books
.iter()
.map(|x| b.update(x.clone()))
.collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut obi = OrderBookImbalanceTop1::new();
obi.update(book(&[(100.0, 1.0)], &[(101.0, 1.0)]));
assert!(obi.is_ready());
obi.reset();
assert!(!obi.is_ready());
}
}
@@ -0,0 +1,186 @@
//! Order-Book Imbalance over the top-N levels.
use crate::error::{Error, Result};
use crate::microstructure::OrderBook;
use crate::traits::Indicator;
/// Order-Book Imbalance aggregated over the top-N levels of each side.
///
/// Generalises [`crate::OrderBookImbalanceTop1`] to a configurable depth: it
/// sums the resting size of the best `levels` bids and the best `levels` asks
/// and compares them:
///
/// ```text
/// bidDepth = Σ size of the best `levels` bids
/// askDepth = Σ size of the best `levels` asks
/// imbalance = (bidDepth askDepth) / (bidDepth + askDepth)
/// ```
///
/// If a side has fewer than `levels` levels, all available levels are summed.
/// The output lies in `[1, +1]`; a book with zero size across the summed
/// levels yields `0`.
///
/// `Input = OrderBook`, `Output = f64`. Stateless; ready after the first
/// snapshot.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, Level, OrderBook, OrderBookImbalanceTopN};
///
/// let book = OrderBook::new(
/// vec![Level::new(100.0, 2.0).unwrap(), Level::new(99.0, 1.0).unwrap()],
/// vec![Level::new(101.0, 1.0).unwrap(), Level::new(102.0, 1.0).unwrap()],
/// )
/// .unwrap();
/// let mut obi = OrderBookImbalanceTopN::new(2).unwrap();
/// assert_eq!(obi.update(book), Some(0.2)); // (3 2) / (3 + 2)
/// ```
#[derive(Debug, Clone)]
pub struct OrderBookImbalanceTopN {
levels: usize,
has_emitted: bool,
}
impl OrderBookImbalanceTopN {
/// Construct a top-N imbalance indicator.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `levels` is zero.
pub fn new(levels: usize) -> Result<Self> {
if levels == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
levels,
has_emitted: false,
})
}
/// The configured number of levels summed per side.
pub fn levels(&self) -> usize {
self.levels
}
}
impl Indicator for OrderBookImbalanceTopN {
type Input = OrderBook;
type Output = f64;
fn update(&mut self, book: OrderBook) -> Option<f64> {
self.has_emitted = true;
let bid_depth: f64 = book.bids.iter().take(self.levels).map(|l| l.size).sum();
let ask_depth: f64 = book.asks.iter().take(self.levels).map(|l| l.size).sum();
let total = bid_depth + ask_depth;
if total <= 0.0 {
return Some(0.0);
}
Some((bid_depth - ask_depth) / total)
}
fn reset(&mut self) {
self.has_emitted = false;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"OrderBookImbalanceTopN"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::microstructure::Level;
use crate::traits::BatchExt;
fn book(bids: &[(f64, f64)], asks: &[(f64, f64)]) -> OrderBook {
let to_levels = |xs: &[(f64, f64)]| {
xs.iter()
.map(|&(p, s)| Level::new(p, s).unwrap())
.collect::<Vec<_>>()
};
OrderBook::new(to_levels(bids), to_levels(asks)).unwrap()
}
#[test]
fn rejects_zero_levels() {
assert!(matches!(
OrderBookImbalanceTopN::new(0),
Err(Error::PeriodZero)
));
}
#[test]
fn accessors_and_metadata() {
let obi = OrderBookImbalanceTopN::new(3).unwrap();
assert_eq!(obi.name(), "OrderBookImbalanceTopN");
assert_eq!(obi.warmup_period(), 1);
assert_eq!(obi.levels(), 3);
assert!(!obi.is_ready());
}
#[test]
fn sums_top_two_levels() {
let mut obi = OrderBookImbalanceTopN::new(2).unwrap();
let b = book(&[(100.0, 2.0), (99.0, 1.0)], &[(101.0, 1.0), (102.0, 1.0)]);
// bidDepth 3, askDepth 2 -> (3 - 2) / 5 = 0.2.
assert_eq!(obi.update(b), Some(0.2));
assert!(obi.is_ready());
}
#[test]
fn caps_at_available_depth() {
// Only one level per side, N = 5 -> uses what exists.
let mut obi = OrderBookImbalanceTopN::new(5).unwrap();
assert_eq!(
obi.update(book(&[(100.0, 3.0)], &[(101.0, 1.0)])),
Some(0.5)
);
}
#[test]
fn zero_size_is_zero() {
let mut obi = OrderBookImbalanceTopN::new(2).unwrap();
assert_eq!(
obi.update(book(&[(100.0, 0.0)], &[(101.0, 0.0)])),
Some(0.0)
);
}
#[test]
fn batch_equals_streaming() {
let books: Vec<OrderBook> = (0..20)
.map(|i| {
let ask = 1.0 + f64::from(i % 4);
book(&[(100.0, 2.0), (99.0, 1.0)], &[(101.0, ask), (102.0, 1.0)])
})
.collect();
let mut a = OrderBookImbalanceTopN::new(2).unwrap();
let mut b = OrderBookImbalanceTopN::new(2).unwrap();
assert_eq!(
a.batch(&books),
books
.iter()
.map(|x| b.update(x.clone()))
.collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut obi = OrderBookImbalanceTopN::new(2).unwrap();
obi.update(book(&[(100.0, 1.0)], &[(101.0, 1.0)]));
assert!(obi.is_ready());
obi.reset();
assert!(!obi.is_ready());
}
}
@@ -0,0 +1,153 @@
//! Quoted Spread — top-of-book spread in basis points.
use crate::microstructure::OrderBook;
use crate::traits::Indicator;
/// Quoted Spread — the top-of-book bid-ask spread expressed in basis points of
/// the mid price.
///
/// ```text
/// mid = (bidPrice₁ + askPrice₁) / 2
/// quotedSpread = (askPrice₁ bidPrice₁) / mid · 10_000 (bps)
/// ```
///
/// This is the round-trip cost of crossing the spread at the touch, normalised
/// by price so it is comparable across instruments. For a valid (uncrossed)
/// book the result is non-negative. An empty book yields `0`.
///
/// `Input = OrderBook`, `Output = f64`. Stateless; ready after the first
/// snapshot.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, Level, OrderBook, QuotedSpread};
///
/// let book = OrderBook::new(
/// vec![Level::new(100.0, 1.0).unwrap()],
/// vec![Level::new(100.5, 1.0).unwrap()],
/// )
/// .unwrap();
/// let mut qs = QuotedSpread::new();
/// // spread 0.5, mid 100.25 -> 0.5 / 100.25 * 10_000 ≈ 49.875 bps.
/// let bps = qs.update(book).unwrap();
/// assert!((bps - 49.875_311_72).abs() < 1e-6);
/// ```
#[derive(Debug, Clone, Default)]
pub struct QuotedSpread {
has_emitted: bool,
}
impl QuotedSpread {
/// Construct a new quoted-spread indicator.
pub const fn new() -> Self {
Self { has_emitted: false }
}
}
impl Indicator for QuotedSpread {
type Input = OrderBook;
type Output = f64;
fn update(&mut self, book: OrderBook) -> Option<f64> {
self.has_emitted = true;
let (Some(bid), Some(ask)) = (book.best_bid(), book.best_ask()) else {
return Some(0.0);
};
let mid = f64::midpoint(bid.price, ask.price);
Some((ask.price - bid.price) / mid * 10_000.0)
}
fn reset(&mut self) {
self.has_emitted = false;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"QuotedSpread"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::microstructure::Level;
use crate::traits::BatchExt;
fn book(bids: &[(f64, f64)], asks: &[(f64, f64)]) -> OrderBook {
let to_levels = |xs: &[(f64, f64)]| {
xs.iter()
.map(|&(p, s)| Level::new(p, s).unwrap())
.collect::<Vec<_>>()
};
OrderBook::new(to_levels(bids), to_levels(asks)).unwrap()
}
#[test]
fn accessors_and_metadata() {
let qs = QuotedSpread::new();
assert_eq!(qs.name(), "QuotedSpread");
assert_eq!(qs.warmup_period(), 1);
assert!(!qs.is_ready());
}
#[test]
fn known_value_in_bps() {
let mut qs = QuotedSpread::new();
// spread 1.0, mid 100.5 -> 1 / 100.5 * 10_000 ≈ 99.5025 bps.
let bps = qs.update(book(&[(100.0, 1.0)], &[(101.0, 1.0)])).unwrap();
assert!((bps - 99.502_487_56).abs() < 1e-6);
assert!(qs.is_ready());
}
#[test]
fn tight_book_is_small() {
let mut qs = QuotedSpread::new();
let bps = qs.update(book(&[(100.0, 1.0)], &[(100.01, 1.0)])).unwrap();
assert!(bps > 0.0 && bps < 2.0);
}
#[test]
fn empty_book_is_zero() {
let mut qs = QuotedSpread::new();
assert_eq!(
qs.update(OrderBook::new_unchecked(vec![], vec![])),
Some(0.0)
);
}
#[test]
fn batch_equals_streaming() {
let books: Vec<OrderBook> = (0..20)
.map(|i| {
let ask = 100.5 + f64::from(i % 4) * 0.1;
book(&[(100.0, 1.0)], &[(ask, 1.0)])
})
.collect();
let mut a = QuotedSpread::new();
let mut b = QuotedSpread::new();
assert_eq!(
a.batch(&books),
books
.iter()
.map(|x| b.update(x.clone()))
.collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut qs = QuotedSpread::new();
qs.update(book(&[(100.0, 1.0)], &[(101.0, 1.0)]));
assert!(qs.is_ready());
qs.reset();
assert!(!qs.is_ready());
}
}