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
+128
View File
@@ -6331,6 +6331,134 @@ wasm_candle_pattern!(WasmSpinningTop, wc::SpinningTop, SpinningTop);
wasm_candle_pattern!(WasmThreeInside, wc::ThreeInside, ThreeInside);
wasm_candle_pattern!(WasmThreeOutside, wc::ThreeOutside, ThreeOutside);
// ============================== Microstructure: Order Book ==============================
//
// Order-book indicators consume a depth snapshot rather than OHLCV. Each
// `update(bidPx, bidSz, askPx, askSz)` takes four equal-length typed arrays for
// one snapshot (bids best-first = descending price, asks best-first = ascending
// price) — the streaming model that fits a live browser book feed. Batch over a
// ragged depth history is provided by the Python and Node bindings.
fn build_order_book(
bid_px: &[f64],
bid_sz: &[f64],
ask_px: &[f64],
ask_sz: &[f64],
) -> Result<wc::OrderBook, JsError> {
if bid_px.len() != bid_sz.len() || ask_px.len() != ask_sz.len() {
return Err(JsError::new(
"bid/ask price and size arrays must be equal length",
));
}
let bids = bid_px
.iter()
.zip(bid_sz)
.map(|(&p, &s)| wc::Level::new_unchecked(p, s))
.collect();
let asks = ask_px
.iter()
.zip(ask_sz)
.map(|(&p, &s)| wc::Level::new_unchecked(p, s))
.collect();
wc::OrderBook::new(bids, asks).map_err(map_err)
}
macro_rules! wasm_ob_indicator {
($wasm:ident, $inner:ty, $js:ident) => {
#[wasm_bindgen(js_name = $js)]
pub struct $wasm {
inner: $inner,
}
impl Default for $wasm {
fn default() -> Self {
Self::new()
}
}
#[wasm_bindgen(js_class = $js)]
impl $wasm {
#[wasm_bindgen(constructor)]
pub fn new() -> $wasm {
Self {
inner: <$inner>::new(),
}
}
pub fn update(
&mut self,
bid_px: &[f64],
bid_sz: &[f64],
ask_px: &[f64],
ask_sz: &[f64],
) -> Result<Option<f64>, JsError> {
let book = build_order_book(bid_px, bid_sz, ask_px, ask_sz)?;
Ok(self.inner.update(book))
}
pub fn reset(&mut self) {
self.inner.reset();
}
#[wasm_bindgen(js_name = isReady)]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[wasm_bindgen(js_name = warmupPeriod)]
pub fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
}
};
}
wasm_ob_indicator!(
WasmOrderBookImbalanceTop1,
wc::OrderBookImbalanceTop1,
OrderBookImbalanceTop1
);
wasm_ob_indicator!(
WasmOrderBookImbalanceFull,
wc::OrderBookImbalanceFull,
OrderBookImbalanceFull
);
wasm_ob_indicator!(WasmMicroprice, wc::Microprice, Microprice);
wasm_ob_indicator!(WasmQuotedSpread, wc::QuotedSpread, QuotedSpread);
// Top-N imbalance carries a `levels` parameter, so it is hand-written.
#[wasm_bindgen(js_name = OrderBookImbalanceTopN)]
pub struct WasmOrderBookImbalanceTopN {
inner: wc::OrderBookImbalanceTopN,
}
#[wasm_bindgen(js_class = OrderBookImbalanceTopN)]
impl WasmOrderBookImbalanceTopN {
#[wasm_bindgen(constructor)]
pub fn new(levels: usize) -> Result<WasmOrderBookImbalanceTopN, JsError> {
Ok(Self {
inner: wc::OrderBookImbalanceTopN::new(levels).map_err(map_err)?,
})
}
pub fn update(
&mut self,
bid_px: &[f64],
bid_sz: &[f64],
ask_px: &[f64],
ask_sz: &[f64],
) -> Result<Option<f64>, JsError> {
let book = build_order_book(bid_px, bid_sz, ask_px, ask_sz)?;
Ok(self.inner.update(book))
}
pub fn reset(&mut self) {
self.inner.reset();
}
#[wasm_bindgen(js_name = isReady)]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[wasm_bindgen(js_name = warmupPeriod)]
pub fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
}
#[cfg(test)]
mod tests {
use super::*;