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:
@@ -240,6 +240,12 @@ from ._wickra import (
|
||||
SpinningTop,
|
||||
ThreeInside,
|
||||
ThreeOutside,
|
||||
# Microstructure: order book
|
||||
OrderBookImbalanceTop1,
|
||||
OrderBookImbalanceTopN,
|
||||
OrderBookImbalanceFull,
|
||||
Microprice,
|
||||
QuotedSpread,
|
||||
# Risk / Performance
|
||||
SharpeRatio,
|
||||
SortinoRatio,
|
||||
@@ -477,6 +483,12 @@ __all__ = [
|
||||
"SpinningTop",
|
||||
"ThreeInside",
|
||||
"ThreeOutside",
|
||||
# Microstructure: order book
|
||||
"OrderBookImbalanceTop1",
|
||||
"OrderBookImbalanceTopN",
|
||||
"OrderBookImbalanceFull",
|
||||
"Microprice",
|
||||
"QuotedSpread",
|
||||
# Risk / Performance
|
||||
"SharpeRatio",
|
||||
"SortinoRatio",
|
||||
|
||||
+165
-1
@@ -26,7 +26,9 @@ fn map_err(e: wc::Error) -> PyErr {
|
||||
| wc::Error::NonPositiveMultiplier
|
||||
| wc::Error::NonFiniteInput
|
||||
| wc::Error::InvalidCandle { .. }
|
||||
| wc::Error::InvalidTick { .. } => PyValueError::new_err(e.to_string()),
|
||||
| wc::Error::InvalidTick { .. }
|
||||
| wc::Error::InvalidOrderBook { .. }
|
||||
| wc::Error::InvalidTrade { .. } => PyValueError::new_err(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11613,6 +11615,162 @@ candle_pattern_no_param!(PySpinningTop, wc::SpinningTop, "SpinningTop");
|
||||
candle_pattern_no_param!(PyThreeInside, wc::ThreeInside, "ThreeInside");
|
||||
candle_pattern_no_param!(PyThreeOutside, wc::ThreeOutside, "ThreeOutside");
|
||||
|
||||
// ============================== Microstructure: Order Book ==============================
|
||||
//
|
||||
// Order-book indicators consume a depth snapshot rather than OHLCV. Streaming
|
||||
// `update(bid_px, bid_sz, ask_px, ask_sz)` takes four equal-length sequences
|
||||
// describing one snapshot (bids best-first = descending price, asks best-first
|
||||
// = ascending price); `batch` takes a list of such `(bid_px, bid_sz, ask_px,
|
||||
// ask_sz)` tuples and returns one value per snapshot.
|
||||
|
||||
fn build_order_book(
|
||||
bid_px: &[f64],
|
||||
bid_sz: &[f64],
|
||||
ask_px: &[f64],
|
||||
ask_sz: &[f64],
|
||||
) -> PyResult<wc::OrderBook> {
|
||||
if bid_px.len() != bid_sz.len() || ask_px.len() != ask_sz.len() {
|
||||
return Err(PyValueError::new_err(
|
||||
"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! py_ob_indicator {
|
||||
($name:ident, $inner:ty, $repr:expr) => {
|
||||
#[pyclass(name = $repr, module = "wickra._wickra", skip_from_py_object)]
|
||||
#[derive(Clone)]
|
||||
struct $name {
|
||||
inner: $inner,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl $name {
|
||||
#[new]
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
inner: <$inner>::new(),
|
||||
}
|
||||
}
|
||||
fn update(
|
||||
&mut self,
|
||||
bid_px: Vec<f64>,
|
||||
bid_sz: Vec<f64>,
|
||||
ask_px: Vec<f64>,
|
||||
ask_sz: Vec<f64>,
|
||||
) -> PyResult<Option<f64>> {
|
||||
let book = build_order_book(&bid_px, &bid_sz, &ask_px, &ask_sz)?;
|
||||
Ok(self.inner.update(book))
|
||||
}
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn batch<'py>(
|
||||
&mut self,
|
||||
py: Python<'py>,
|
||||
snapshots: Vec<(Vec<f64>, Vec<f64>, Vec<f64>, Vec<f64>)>,
|
||||
) -> PyResult<Bound<'py, PyArray1<f64>>> {
|
||||
let mut out = Vec::with_capacity(snapshots.len());
|
||||
for (bid_px, bid_sz, ask_px, ask_sz) in &snapshots {
|
||||
let book = build_order_book(bid_px, bid_sz, ask_px, ask_sz)?;
|
||||
out.push(self.inner.update(book).unwrap_or(f64::NAN));
|
||||
}
|
||||
Ok(out.into_pyarray(py))
|
||||
}
|
||||
fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.inner.warmup_period()
|
||||
}
|
||||
fn __repr__(&self) -> String {
|
||||
format!("{}()", $repr)
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
py_ob_indicator!(
|
||||
PyOrderBookImbalanceTop1,
|
||||
wc::OrderBookImbalanceTop1,
|
||||
"OrderBookImbalanceTop1"
|
||||
);
|
||||
py_ob_indicator!(
|
||||
PyOrderBookImbalanceFull,
|
||||
wc::OrderBookImbalanceFull,
|
||||
"OrderBookImbalanceFull"
|
||||
);
|
||||
py_ob_indicator!(PyMicroprice, wc::Microprice, "Microprice");
|
||||
py_ob_indicator!(PyQuotedSpread, wc::QuotedSpread, "QuotedSpread");
|
||||
|
||||
// Top-N imbalance carries a `levels` parameter, so it is hand-written.
|
||||
#[pyclass(
|
||||
name = "OrderBookImbalanceTopN",
|
||||
module = "wickra._wickra",
|
||||
skip_from_py_object
|
||||
)]
|
||||
#[derive(Clone)]
|
||||
struct PyOrderBookImbalanceTopN {
|
||||
inner: wc::OrderBookImbalanceTopN,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyOrderBookImbalanceTopN {
|
||||
#[new]
|
||||
fn new(levels: usize) -> PyResult<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::OrderBookImbalanceTopN::new(levels).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
fn update(
|
||||
&mut self,
|
||||
bid_px: Vec<f64>,
|
||||
bid_sz: Vec<f64>,
|
||||
ask_px: Vec<f64>,
|
||||
ask_sz: Vec<f64>,
|
||||
) -> PyResult<Option<f64>> {
|
||||
let book = build_order_book(&bid_px, &bid_sz, &ask_px, &ask_sz)?;
|
||||
Ok(self.inner.update(book))
|
||||
}
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn batch<'py>(
|
||||
&mut self,
|
||||
py: Python<'py>,
|
||||
snapshots: Vec<(Vec<f64>, Vec<f64>, Vec<f64>, Vec<f64>)>,
|
||||
) -> PyResult<Bound<'py, PyArray1<f64>>> {
|
||||
let mut out = Vec::with_capacity(snapshots.len());
|
||||
for (bid_px, bid_sz, ask_px, ask_sz) in &snapshots {
|
||||
let book = build_order_book(bid_px, bid_sz, ask_px, ask_sz)?;
|
||||
out.push(self.inner.update(book).unwrap_or(f64::NAN));
|
||||
}
|
||||
Ok(out.into_pyarray(py))
|
||||
}
|
||||
fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.inner.warmup_period()
|
||||
}
|
||||
fn __repr__(&self) -> String {
|
||||
format!("OrderBookImbalanceTopN(levels={})", self.inner.levels())
|
||||
}
|
||||
}
|
||||
|
||||
// ============================== Family 15: Risk / Performance ==============================
|
||||
|
||||
#[pyclass(name = "SharpeRatio", module = "wickra._wickra", skip_from_py_object)]
|
||||
@@ -12718,6 +12876,12 @@ fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
|
||||
m.add_class::<PySpinningTop>()?;
|
||||
m.add_class::<PyThreeInside>()?;
|
||||
m.add_class::<PyThreeOutside>()?;
|
||||
// Microstructure: order book.
|
||||
m.add_class::<PyOrderBookImbalanceTop1>()?;
|
||||
m.add_class::<PyOrderBookImbalanceTopN>()?;
|
||||
m.add_class::<PyOrderBookImbalanceFull>()?;
|
||||
m.add_class::<PyMicroprice>()?;
|
||||
m.add_class::<PyQuotedSpread>()?;
|
||||
// Family 15: Risk / Performance metrics.
|
||||
m.add_class::<PySharpeRatio>()?;
|
||||
m.add_class::<PySortinoRatio>()?;
|
||||
|
||||
@@ -166,3 +166,28 @@ def test_family_10_ehlers_rejects_invalid_parameters():
|
||||
ta.MAMA(0.05, 0.5)
|
||||
with pytest.raises(ValueError):
|
||||
ta.EmpiricalModeDecomposition(20, 0.0)
|
||||
|
||||
|
||||
def test_orderbook_topn_zero_levels_raises():
|
||||
with pytest.raises(ValueError):
|
||||
ta.OrderBookImbalanceTopN(0)
|
||||
|
||||
|
||||
def test_orderbook_unequal_price_size_lengths_raise():
|
||||
# bid_px has 2 entries but bid_sz has 1 -> mismatched -> ValueError.
|
||||
with pytest.raises(ValueError):
|
||||
ta.OrderBookImbalanceTop1().update([100.0, 99.0], [1.0], [101.0], [1.0])
|
||||
with pytest.raises(ValueError):
|
||||
ta.Microprice().update([100.0], [1.0], [101.0, 102.0], [1.0])
|
||||
|
||||
|
||||
def test_orderbook_crossed_book_raises():
|
||||
# best_bid (102) >= best_ask (101) is a crossed book -> rejected.
|
||||
with pytest.raises(ValueError):
|
||||
ta.QuotedSpread().update([102.0], [1.0], [101.0], [1.0])
|
||||
|
||||
|
||||
def test_orderbook_misordered_levels_raise():
|
||||
# Bids must be strictly descending in price.
|
||||
with pytest.raises(ValueError):
|
||||
ta.OrderBookImbalanceFull().update([99.0, 100.0], [1.0, 1.0], [101.0], [1.0])
|
||||
|
||||
@@ -847,3 +847,26 @@ def test_doji_signed_dragonfly_gravestone_neutral():
|
||||
assert d.update((10.0, 12.0, 8.0, 10.0, 1.0, 2)) == pytest.approx(0.0)
|
||||
# A large body is not a doji at all -> 0 regardless of position.
|
||||
assert d.update((10.0, 12.0, 10.0, 12.0, 1.0, 3)) == pytest.approx(0.0)
|
||||
|
||||
|
||||
def test_orderbook_imbalance_reference_values():
|
||||
# Top-1: (3 - 1) / (3 + 1) = 0.5.
|
||||
assert ta.OrderBookImbalanceTop1().update([100.0], [3.0], [101.0], [1.0]) == pytest.approx(0.5)
|
||||
# Top-2: bidDepth 3, askDepth 2 -> (3 - 2) / 5 = 0.2.
|
||||
topn = ta.OrderBookImbalanceTopN(2)
|
||||
assert topn.update([100.0, 99.0], [2.0, 1.0], [101.0, 102.0], [1.0, 1.0]) == pytest.approx(0.2)
|
||||
# Full: bidDepth 1, askDepth 3 -> (1 - 3) / 4 = -0.5.
|
||||
full = ta.OrderBookImbalanceFull()
|
||||
assert full.update([100.0], [1.0], [101.0, 102.0], [2.0, 1.0]) == pytest.approx(-0.5)
|
||||
|
||||
|
||||
def test_microprice_reference_value():
|
||||
# (100*3 + 101*1) / (1 + 3) = 401 / 4 = 100.25 — heavy ask pulls toward bid.
|
||||
mp = ta.Microprice()
|
||||
assert mp.update([100.0], [1.0], [101.0], [3.0]) == pytest.approx(100.25)
|
||||
|
||||
|
||||
def test_quoted_spread_reference_value():
|
||||
# spread 1.0, mid 100.5 -> 1 / 100.5 * 10_000 ≈ 99.5025 bps.
|
||||
qs = ta.QuotedSpread()
|
||||
assert qs.update([100.0], [1.0], [101.0], [1.0]) == pytest.approx(99.50248756, abs=1e-6)
|
||||
|
||||
@@ -129,3 +129,24 @@ def test_ehlers_indicators_lifecycle():
|
||||
assert ind.is_ready()
|
||||
ind.reset()
|
||||
assert not ind.is_ready()
|
||||
|
||||
|
||||
def test_orderbook_lifecycle():
|
||||
snapshot = ([100.0], [1.0], [101.0], [1.0])
|
||||
for ind in [
|
||||
ta.OrderBookImbalanceTop1(),
|
||||
ta.OrderBookImbalanceTopN(3),
|
||||
ta.OrderBookImbalanceFull(),
|
||||
ta.Microprice(),
|
||||
ta.QuotedSpread(),
|
||||
]:
|
||||
assert ind.warmup_period() == 1
|
||||
assert not ind.is_ready()
|
||||
ind.update(*snapshot)
|
||||
assert ind.is_ready()
|
||||
ind.reset()
|
||||
assert not ind.is_ready()
|
||||
|
||||
|
||||
def test_orderbook_topn_repr():
|
||||
assert repr(ta.OrderBookImbalanceTopN(5)) == "OrderBookImbalanceTopN(levels=5)"
|
||||
|
||||
@@ -1863,3 +1863,38 @@ def test_new_indicators_expose_lifecycle():
|
||||
assert ind.warmup_period() >= 1
|
||||
ind.reset()
|
||||
assert ind.is_ready() is False
|
||||
|
||||
|
||||
def _orderbook_snapshots(n: int) -> list:
|
||||
"""A deterministic varying sequence of order-book snapshots."""
|
||||
snaps = []
|
||||
for i in range(n):
|
||||
bid_sz = 1.0 + (i % 5)
|
||||
ask_sz = 1.0 + ((i + 2) % 4)
|
||||
snaps.append(
|
||||
(
|
||||
[100.0, 99.0],
|
||||
[bid_sz, 1.0],
|
||||
[101.0, 102.0],
|
||||
[ask_sz, 1.0],
|
||||
)
|
||||
)
|
||||
return snaps
|
||||
|
||||
|
||||
def test_orderbook_indicators_streaming_equals_batch():
|
||||
snaps = _orderbook_snapshots(40)
|
||||
for make in (
|
||||
ta.OrderBookImbalanceTop1,
|
||||
lambda: ta.OrderBookImbalanceTopN(2),
|
||||
ta.OrderBookImbalanceFull,
|
||||
ta.Microprice,
|
||||
ta.QuotedSpread,
|
||||
):
|
||||
batch = make().batch(snaps)
|
||||
streamer = make()
|
||||
streamed = np.array(
|
||||
[streamer.update(*snap) for snap in snaps], dtype=np.float64
|
||||
)
|
||||
assert batch.shape == (len(snaps),)
|
||||
assert _eq_nan(batch, streamed)
|
||||
|
||||
@@ -97,3 +97,25 @@ def test_ehlers_super_smoother_batch_shape(sine_prices):
|
||||
def test_mama_batch_shape(sine_prices):
|
||||
out = ta.MAMA().batch(sine_prices)
|
||||
assert out.shape == (sine_prices.size, 2)
|
||||
|
||||
|
||||
def test_orderbook_indicators_construct_and_emit():
|
||||
# All five order-book indicators accept a four-array snapshot and emit a float.
|
||||
snapshot = ([100.0, 99.0], [2.0, 1.0], [101.0, 102.0], [1.0, 1.0])
|
||||
indicators = [
|
||||
ta.OrderBookImbalanceTop1(),
|
||||
ta.OrderBookImbalanceTopN(2),
|
||||
ta.OrderBookImbalanceFull(),
|
||||
ta.Microprice(),
|
||||
ta.QuotedSpread(),
|
||||
]
|
||||
for ind in indicators:
|
||||
out = ind.update(*snapshot)
|
||||
assert isinstance(out, float)
|
||||
|
||||
|
||||
def test_orderbook_batch_returns_one_value_per_snapshot():
|
||||
snapshots = [([100.0], [3.0], [101.0], [1.0])] * 5
|
||||
out = ta.OrderBookImbalanceTop1().batch(snapshots)
|
||||
assert out.shape == (5,)
|
||||
assert out.dtype == np.float64
|
||||
|
||||
@@ -201,3 +201,19 @@ def test_opening_range_streaming_matches_batch(ohlc_series):
|
||||
rows.append([math.nan, math.nan, math.nan] if out is None else list(out))
|
||||
streamed = np.array(rows, dtype=np.float64)
|
||||
assert _equal_with_nan(batch, streamed)
|
||||
|
||||
|
||||
def test_orderbook_streaming_matches_batch():
|
||||
snaps = [
|
||||
(
|
||||
[100.0, 99.0],
|
||||
[1.0 + (i % 5), 1.0],
|
||||
[101.0, 102.0],
|
||||
[1.0 + ((i + 1) % 3), 1.0],
|
||||
)
|
||||
for i in range(30)
|
||||
]
|
||||
batch = ta.Microprice().batch(snaps)
|
||||
streamer = ta.Microprice()
|
||||
streamed = np.array([streamer.update(*snap) for snap in snaps], dtype=np.float64)
|
||||
assert _equal_with_nan(batch, streamed)
|
||||
|
||||
Reference in New Issue
Block a user