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:
@@ -31,6 +31,18 @@ pub enum Error {
|
||||
/// A multiplier or factor must be strictly positive.
|
||||
#[error("multiplier must be greater than zero")]
|
||||
NonPositiveMultiplier,
|
||||
|
||||
/// An order-book snapshot whose levels do not satisfy the book invariants
|
||||
/// (e.g. a crossed book, non-finite price, negative size, or mis-sorted
|
||||
/// levels) was provided. Order books are a microstructure input distinct
|
||||
/// from candles and ticks, so they surface as their own variant.
|
||||
#[error("invalid order book: {message}")]
|
||||
InvalidOrderBook { message: &'static str },
|
||||
|
||||
/// A trade whose components do not satisfy the trade invariants (e.g.
|
||||
/// non-finite price or negative size) was provided.
|
||||
#[error("invalid trade: {message}")]
|
||||
InvalidTrade { message: &'static str },
|
||||
}
|
||||
|
||||
/// Convenience alias for `Result<T, wickra_core::Error>`.
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,7 @@
|
||||
#![cfg_attr(docsrs, feature(doc_cfg))]
|
||||
|
||||
mod error;
|
||||
mod microstructure;
|
||||
mod ohlcv;
|
||||
mod traits;
|
||||
|
||||
@@ -67,15 +68,16 @@ pub use indicators::{
|
||||
LeadLagCrossCorrelationOutput, LinRegAngle, LinRegChannel, LinRegChannelOutput, LinRegSlope,
|
||||
LinearRegression, MaEnvelope, MaEnvelopeOutput, MacdIndicator, MacdOutput, Mama, MamaOutput,
|
||||
MarketFacilitationIndex, Marubozu, MassIndex, MaxDrawdown, McGinleyDynamic,
|
||||
MedianAbsoluteDeviation, MedianPrice, Mfi, Mom, MorningEveningStar, Natr, Nvi, Obv, OmegaRatio,
|
||||
OpeningRange, OpeningRangeOutput, PainIndex, PairSpreadZScore, PairwiseBeta,
|
||||
ParkinsonVolatility, PearsonCorrelation, PercentB, PercentageTrailingStop, Pgo,
|
||||
PiercingDarkCloud, Pmo, Ppo, ProfitFactor, Psar, Pvi, RSquared, RecoveryFactor,
|
||||
RelativeStrengthAB, RelativeStrengthOutput, RenkoTrailingStop, Roc, RogersSatchellVolatility,
|
||||
RollingVwap, RoofingFilter, Rsi, Rvi, RviVolatility, Rwi, RwiOutput, SharpeRatio, ShootingStar,
|
||||
SineWave, Skewness, Sma, Smi, Smma, SortinoRatio, SpearmanCorrelation, SpinningTop,
|
||||
StandardError, StandardErrorBands, StandardErrorBandsOutput, StarcBands, StarcBandsOutput, Stc,
|
||||
StdDev, StepTrailingStop, StochRsi, Stochastic, StochasticOutput, SuperSmoother, SuperTrend,
|
||||
MedianAbsoluteDeviation, MedianPrice, Mfi, Microprice, Mom, MorningEveningStar, Natr, Nvi, Obv,
|
||||
OmegaRatio, OpeningRange, OpeningRangeOutput, OrderBookImbalanceFull, OrderBookImbalanceTop1,
|
||||
OrderBookImbalanceTopN, PainIndex, PairSpreadZScore, PairwiseBeta, ParkinsonVolatility,
|
||||
PearsonCorrelation, PercentB, PercentageTrailingStop, Pgo, PiercingDarkCloud, Pmo, Ppo,
|
||||
ProfitFactor, Psar, Pvi, QuotedSpread, RSquared, RecoveryFactor, RelativeStrengthAB,
|
||||
RelativeStrengthOutput, RenkoTrailingStop, Roc, RogersSatchellVolatility, RollingVwap,
|
||||
RoofingFilter, Rsi, Rvi, RviVolatility, Rwi, RwiOutput, SharpeRatio, ShootingStar, SineWave,
|
||||
Skewness, Sma, Smi, Smma, SortinoRatio, SpearmanCorrelation, SpinningTop, StandardError,
|
||||
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,
|
||||
@@ -87,5 +89,6 @@ pub use indicators::{
|
||||
WilliamsFractalsOutput, WilliamsR, Wma, WoodiePivots, WoodiePivotsOutput, YangZhangVolatility,
|
||||
YoyoExit, ZScore, ZeroLagMacd, ZeroLagMacdOutput, ZigZag, ZigZagOutput, Zlema, FAMILIES, T3,
|
||||
};
|
||||
pub use microstructure::{Level, OrderBook, Side, Trade, TradeQuote};
|
||||
pub use ohlcv::{Candle, Tick};
|
||||
pub use traits::{BatchExt, Chain, Indicator};
|
||||
|
||||
@@ -0,0 +1,467 @@
|
||||
//! Microstructure value types: order-book snapshots and trades.
|
||||
//!
|
||||
//! These are the non-OHLCV inputs consumed by the order-book / trade-flow
|
||||
//! indicator family. An [`OrderBook`] is a depth snapshot (sorted bid and ask
|
||||
//! levels); a [`Trade`] is a single executed trade with an aggressor [`Side`];
|
||||
//! a [`TradeQuote`] pairs a trade with the mid-price prevailing at execution,
|
||||
//! the input for spread- and price-impact measures.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
/// A single order-book price level: a resting quantity at a price.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Level {
|
||||
/// Price of the level (strictly positive).
|
||||
pub price: f64,
|
||||
/// Resting size / quantity at this price (non-negative).
|
||||
pub size: f64,
|
||||
}
|
||||
|
||||
impl Level {
|
||||
/// Construct a level, validating that `price` is finite and strictly
|
||||
/// positive and `size` is finite and non-negative.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::InvalidOrderBook`] if the price is not a finite
|
||||
/// positive number, or the size is not a finite non-negative number.
|
||||
pub fn new(price: f64, size: f64) -> Result<Self> {
|
||||
if !price.is_finite() || price <= 0.0 {
|
||||
return Err(Error::InvalidOrderBook {
|
||||
message: "level price must be finite and positive",
|
||||
});
|
||||
}
|
||||
if !size.is_finite() || size < 0.0 {
|
||||
return Err(Error::InvalidOrderBook {
|
||||
message: "level size must be finite and non-negative",
|
||||
});
|
||||
}
|
||||
Ok(Self { price, size })
|
||||
}
|
||||
|
||||
/// Construct a level without validation. The caller asserts that `price`
|
||||
/// is finite and positive and `size` is finite and non-negative.
|
||||
pub const fn new_unchecked(price: f64, size: f64) -> Self {
|
||||
Self { price, size }
|
||||
}
|
||||
}
|
||||
|
||||
/// An order-book depth snapshot.
|
||||
///
|
||||
/// Bids are stored best-first (strictly descending price); asks are stored
|
||||
/// best-first (strictly ascending price). A valid book is non-empty on both
|
||||
/// sides and uncrossed (`best_bid < best_ask`).
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct OrderBook {
|
||||
/// Bid levels, best (highest price) first.
|
||||
pub bids: Vec<Level>,
|
||||
/// Ask levels, best (lowest price) first.
|
||||
pub asks: Vec<Level>,
|
||||
}
|
||||
|
||||
impl OrderBook {
|
||||
/// Construct an order book, validating the level and ordering invariants.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::InvalidOrderBook`] if either side is empty, any level
|
||||
/// has a non-finite/non-positive price or non-finite/negative size, the
|
||||
/// bids are not strictly descending in price, the asks are not strictly
|
||||
/// ascending in price, or the book is crossed/locked (`best_bid >=
|
||||
/// best_ask`).
|
||||
pub fn new(bids: Vec<Level>, asks: Vec<Level>) -> Result<Self> {
|
||||
if bids.is_empty() || asks.is_empty() {
|
||||
return Err(Error::InvalidOrderBook {
|
||||
message: "order book must have at least one bid and one ask",
|
||||
});
|
||||
}
|
||||
for level in bids.iter().chain(asks.iter()) {
|
||||
if !level.price.is_finite() || level.price <= 0.0 {
|
||||
return Err(Error::InvalidOrderBook {
|
||||
message: "level price must be finite and positive",
|
||||
});
|
||||
}
|
||||
if !level.size.is_finite() || level.size < 0.0 {
|
||||
return Err(Error::InvalidOrderBook {
|
||||
message: "level size must be finite and non-negative",
|
||||
});
|
||||
}
|
||||
}
|
||||
for pair in bids.windows(2) {
|
||||
if pair[0].price <= pair[1].price {
|
||||
return Err(Error::InvalidOrderBook {
|
||||
message: "bids must be strictly descending in price",
|
||||
});
|
||||
}
|
||||
}
|
||||
for pair in asks.windows(2) {
|
||||
if pair[0].price >= pair[1].price {
|
||||
return Err(Error::InvalidOrderBook {
|
||||
message: "asks must be strictly ascending in price",
|
||||
});
|
||||
}
|
||||
}
|
||||
if bids[0].price >= asks[0].price {
|
||||
return Err(Error::InvalidOrderBook {
|
||||
message: "order book must be uncrossed (best_bid < best_ask)",
|
||||
});
|
||||
}
|
||||
Ok(Self { bids, asks })
|
||||
}
|
||||
|
||||
/// Construct an order book without validation. The caller asserts that all
|
||||
/// level and ordering invariants hold.
|
||||
pub const fn new_unchecked(bids: Vec<Level>, asks: Vec<Level>) -> Self {
|
||||
Self { bids, asks }
|
||||
}
|
||||
|
||||
/// The best (highest-price) bid level, or `None` if the bid side is empty.
|
||||
pub fn best_bid(&self) -> Option<Level> {
|
||||
self.bids.first().copied()
|
||||
}
|
||||
|
||||
/// The best (lowest-price) ask level, or `None` if the ask side is empty.
|
||||
pub fn best_ask(&self) -> Option<Level> {
|
||||
self.asks.first().copied()
|
||||
}
|
||||
|
||||
/// The mid price `(best_bid + best_ask) / 2`, or `None` if either side is
|
||||
/// empty.
|
||||
pub fn mid(&self) -> Option<f64> {
|
||||
match (self.best_bid(), self.best_ask()) {
|
||||
(Some(bid), Some(ask)) => Some(f64::midpoint(bid.price, ask.price)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The aggressor side of a trade: the side that crossed the spread.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Side {
|
||||
/// A buyer-initiated (aggressive buy) trade.
|
||||
Buy,
|
||||
/// A seller-initiated (aggressive sell) trade.
|
||||
Sell,
|
||||
}
|
||||
|
||||
impl Side {
|
||||
/// The signed multiplier for this side: `+1.0` for a buy, `−1.0` for a
|
||||
/// sell.
|
||||
pub const fn sign(self) -> f64 {
|
||||
match self {
|
||||
Side::Buy => 1.0,
|
||||
Side::Sell => -1.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A single executed trade with an aggressor side.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Trade {
|
||||
/// Execution price (strictly positive).
|
||||
pub price: f64,
|
||||
/// Executed size / quantity (non-negative).
|
||||
pub size: f64,
|
||||
/// Aggressor side.
|
||||
pub side: Side,
|
||||
/// Trade timestamp (caller-defined epoch / resolution).
|
||||
pub timestamp: i64,
|
||||
}
|
||||
|
||||
impl Trade {
|
||||
/// Construct a trade, validating that `price` is finite and strictly
|
||||
/// positive and `size` is finite and non-negative.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::InvalidTrade`] if the price is not a finite positive
|
||||
/// number, or the size is not a finite non-negative number.
|
||||
pub fn new(price: f64, size: f64, side: Side, timestamp: i64) -> Result<Self> {
|
||||
if !price.is_finite() || price <= 0.0 {
|
||||
return Err(Error::InvalidTrade {
|
||||
message: "trade price must be finite and positive",
|
||||
});
|
||||
}
|
||||
if !size.is_finite() || size < 0.0 {
|
||||
return Err(Error::InvalidTrade {
|
||||
message: "trade size must be finite and non-negative",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
price,
|
||||
size,
|
||||
side,
|
||||
timestamp,
|
||||
})
|
||||
}
|
||||
|
||||
/// Construct a trade without validation. The caller asserts that `price`
|
||||
/// is finite and positive and `size` is finite and non-negative.
|
||||
pub const fn new_unchecked(price: f64, size: f64, side: Side, timestamp: i64) -> Self {
|
||||
Self {
|
||||
price,
|
||||
size,
|
||||
side,
|
||||
timestamp,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A trade paired with the mid-price prevailing at execution.
|
||||
///
|
||||
/// This is the input for spread- and price-impact measures (effective spread,
|
||||
/// realized spread, Kyle's lambda), which relate an executed trade to the
|
||||
/// quote it traded against.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct TradeQuote {
|
||||
/// The executed trade.
|
||||
pub trade: Trade,
|
||||
/// The mid-price prevailing at execution (strictly positive).
|
||||
pub mid: f64,
|
||||
}
|
||||
|
||||
impl TradeQuote {
|
||||
/// Construct a trade-quote, validating that `mid` is finite and strictly
|
||||
/// positive. The `trade` is assumed already valid.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::InvalidTrade`] if `mid` is not a finite positive
|
||||
/// number.
|
||||
pub fn new(trade: Trade, mid: f64) -> Result<Self> {
|
||||
if !mid.is_finite() || mid <= 0.0 {
|
||||
return Err(Error::InvalidTrade {
|
||||
message: "trade-quote mid must be finite and positive",
|
||||
});
|
||||
}
|
||||
Ok(Self { trade, mid })
|
||||
}
|
||||
|
||||
/// Construct a trade-quote without validation. The caller asserts that
|
||||
/// `mid` is finite and positive.
|
||||
pub const fn new_unchecked(trade: Trade, mid: f64) -> Self {
|
||||
Self { trade, mid }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn level_new_accepts_valid() {
|
||||
let level = Level::new(100.5, 2.0).unwrap();
|
||||
assert_eq!(level.price, 100.5);
|
||||
assert_eq!(level.size, 2.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn level_new_accepts_zero_size() {
|
||||
assert!(Level::new(100.0, 0.0).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn level_new_rejects_non_finite_price() {
|
||||
assert!(matches!(
|
||||
Level::new(f64::NAN, 1.0),
|
||||
Err(Error::InvalidOrderBook { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
Level::new(f64::INFINITY, 1.0),
|
||||
Err(Error::InvalidOrderBook { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn level_new_rejects_non_positive_price() {
|
||||
assert!(matches!(
|
||||
Level::new(0.0, 1.0),
|
||||
Err(Error::InvalidOrderBook { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
Level::new(-1.0, 1.0),
|
||||
Err(Error::InvalidOrderBook { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn level_new_rejects_bad_size() {
|
||||
assert!(matches!(
|
||||
Level::new(100.0, -1.0),
|
||||
Err(Error::InvalidOrderBook { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
Level::new(100.0, f64::NAN),
|
||||
Err(Error::InvalidOrderBook { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn level_new_unchecked_preserves_fields() {
|
||||
let level = Level::new_unchecked(-5.0, -2.0);
|
||||
assert_eq!(level.price, -5.0);
|
||||
assert_eq!(level.size, -2.0);
|
||||
}
|
||||
|
||||
fn lvl(price: f64, size: f64) -> Level {
|
||||
Level::new(price, size).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn order_book_new_accepts_valid() {
|
||||
let book = OrderBook::new(
|
||||
vec![lvl(100.0, 2.0), lvl(99.0, 3.0)],
|
||||
vec![lvl(101.0, 1.0), lvl(102.0, 4.0)],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(book.best_bid(), Some(lvl(100.0, 2.0)));
|
||||
assert_eq!(book.best_ask(), Some(lvl(101.0, 1.0)));
|
||||
assert_eq!(book.mid(), Some(100.5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn order_book_new_rejects_empty_side() {
|
||||
assert!(matches!(
|
||||
OrderBook::new(vec![], vec![lvl(101.0, 1.0)]),
|
||||
Err(Error::InvalidOrderBook { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
OrderBook::new(vec![lvl(100.0, 1.0)], vec![]),
|
||||
Err(Error::InvalidOrderBook { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn order_book_new_rejects_bad_level() {
|
||||
assert!(matches!(
|
||||
OrderBook::new(
|
||||
vec![Level::new_unchecked(100.0, -1.0)],
|
||||
vec![lvl(101.0, 1.0)]
|
||||
),
|
||||
Err(Error::InvalidOrderBook { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
OrderBook::new(
|
||||
vec![lvl(100.0, 1.0)],
|
||||
vec![Level::new_unchecked(f64::NAN, 1.0)]
|
||||
),
|
||||
Err(Error::InvalidOrderBook { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn order_book_new_rejects_misordered_bids() {
|
||||
assert!(matches!(
|
||||
OrderBook::new(vec![lvl(99.0, 1.0), lvl(100.0, 1.0)], vec![lvl(101.0, 1.0)]),
|
||||
Err(Error::InvalidOrderBook { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn order_book_new_rejects_misordered_asks() {
|
||||
assert!(matches!(
|
||||
OrderBook::new(
|
||||
vec![lvl(100.0, 1.0)],
|
||||
vec![lvl(102.0, 1.0), lvl(101.0, 1.0)]
|
||||
),
|
||||
Err(Error::InvalidOrderBook { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn order_book_new_rejects_crossed() {
|
||||
assert!(matches!(
|
||||
OrderBook::new(vec![lvl(101.0, 1.0)], vec![lvl(101.0, 1.0)]),
|
||||
Err(Error::InvalidOrderBook { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
OrderBook::new(vec![lvl(102.0, 1.0)], vec![lvl(101.0, 1.0)]),
|
||||
Err(Error::InvalidOrderBook { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn order_book_new_unchecked_allows_empty() {
|
||||
let book = OrderBook::new_unchecked(vec![], vec![]);
|
||||
assert_eq!(book.best_bid(), None);
|
||||
assert_eq!(book.best_ask(), None);
|
||||
assert_eq!(book.mid(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn side_sign() {
|
||||
assert_eq!(Side::Buy.sign(), 1.0);
|
||||
assert_eq!(Side::Sell.sign(), -1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trade_new_accepts_valid() {
|
||||
let trade = Trade::new(100.0, 1.5, Side::Buy, 42).unwrap();
|
||||
assert_eq!(trade.price, 100.0);
|
||||
assert_eq!(trade.size, 1.5);
|
||||
assert_eq!(trade.side, Side::Buy);
|
||||
assert_eq!(trade.timestamp, 42);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trade_new_rejects_bad_price() {
|
||||
assert!(matches!(
|
||||
Trade::new(0.0, 1.0, Side::Buy, 0),
|
||||
Err(Error::InvalidTrade { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
Trade::new(f64::NAN, 1.0, Side::Sell, 0),
|
||||
Err(Error::InvalidTrade { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trade_new_rejects_bad_size() {
|
||||
assert!(matches!(
|
||||
Trade::new(100.0, -1.0, Side::Buy, 0),
|
||||
Err(Error::InvalidTrade { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
Trade::new(100.0, f64::INFINITY, Side::Buy, 0),
|
||||
Err(Error::InvalidTrade { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trade_new_unchecked_preserves_fields() {
|
||||
let trade = Trade::new_unchecked(-1.0, -2.0, Side::Sell, 7);
|
||||
assert_eq!(trade.price, -1.0);
|
||||
assert_eq!(trade.size, -2.0);
|
||||
assert_eq!(trade.side, Side::Sell);
|
||||
assert_eq!(trade.timestamp, 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trade_quote_new_accepts_valid() {
|
||||
let trade = Trade::new(100.0, 1.0, Side::Buy, 0).unwrap();
|
||||
let tq = TradeQuote::new(trade, 99.5).unwrap();
|
||||
assert_eq!(tq.trade, trade);
|
||||
assert_eq!(tq.mid, 99.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trade_quote_new_rejects_bad_mid() {
|
||||
let trade = Trade::new(100.0, 1.0, Side::Buy, 0).unwrap();
|
||||
assert!(matches!(
|
||||
TradeQuote::new(trade, 0.0),
|
||||
Err(Error::InvalidTrade { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
TradeQuote::new(trade, f64::NAN),
|
||||
Err(Error::InvalidTrade { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trade_quote_new_unchecked_preserves_fields() {
|
||||
let trade = Trade::new_unchecked(100.0, 1.0, Side::Buy, 0);
|
||||
let tq = TradeQuote::new_unchecked(trade, -1.0);
|
||||
assert_eq!(tq.mid, -1.0);
|
||||
assert_eq!(tq.trade, trade);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user