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
@@ -912,3 +912,42 @@ test('Doji signed mode encodes dragonfly/gravestone/neutral direction', () => {
assert.equal(d.update(10, 12, 8, 10), 0); // long-legged -> neutral 0
assert.equal(d.update(10, 12, 10, 12), 0); // not a doji -> 0
});
test('order-book indicators reference values', () => {
// Top-1: (3 - 1) / (3 + 1) = 0.5.
assert.equal(new wickra.OrderBookImbalanceTop1().update([100], [3], [101], [1]), 0.5);
// Top-2: bidDepth 3, askDepth 2 -> (3 - 2) / 5 = 0.2.
assert.ok(
Math.abs(new wickra.OrderBookImbalanceTopN(2).update([100, 99], [2, 1], [101, 102], [1, 1]) - 0.2) < 1e-12,
);
// Full: bidDepth 1, askDepth 3 -> -0.5.
assert.equal(new wickra.OrderBookImbalanceFull().update([100], [1], [101, 102], [2, 1]), -0.5);
// Microprice: (100*3 + 101*1) / 4 = 100.25.
assert.equal(new wickra.Microprice().update([100], [1], [101], [3]), 100.25);
// Quoted spread: 1 / 100.5 * 10000 ≈ 99.5025 bps.
assert.ok(Math.abs(new wickra.QuotedSpread().update([100], [1], [101], [1]) - 99.50248756) < 1e-6);
});
test('order-book streaming update matches batch', () => {
const snaps = Array.from({ length: 30 }, (_, i) => ({
bidPx: [100, 99],
bidSz: [1 + (i % 5), 1],
askPx: [101, 102],
askSz: [1 + ((i + 1) % 3), 1],
}));
const batch = new wickra.Microprice().batch(snaps);
const streamer = new wickra.Microprice();
assert.equal(batch.length, snaps.length);
for (let i = 0; i < snaps.length; i++) {
const s = streamer.update(snaps[i].bidPx, snaps[i].bidSz, snaps[i].askPx, snaps[i].askSz);
assert.ok(Math.abs(s - batch[i]) < 1e-12, `mismatch at ${i}: ${s} vs ${batch[i]}`);
}
});
test('order-book TopN rejects zero levels', () => {
assert.throws(() => new wickra.OrderBookImbalanceTopN(0));
});
test('order-book update rejects a crossed book', () => {
assert.throws(() => new wickra.QuotedSpread().update([102], [1], [101], [1]));
});
+52
View File
@@ -279,6 +279,13 @@ export interface OpeningRangeValue {
low: number
breakoutDistance: number
}
/** One order-book depth snapshot for batch evaluation. */
export interface ObSnapshot {
bidPx: Array<number>
bidSz: Array<number>
askPx: Array<number>
askSz: Array<number>
}
export type SmaNode = SMA
export declare class SMA {
constructor(period: number)
@@ -2189,6 +2196,51 @@ export declare class ThreeOutside {
isReady(): boolean
warmupPeriod(): number
}
export type OrderBookImbalanceTop1Node = OrderBookImbalanceTop1
export declare class OrderBookImbalanceTop1 {
constructor()
update(bidPx: Array<number>, bidSz: Array<number>, askPx: Array<number>, askSz: Array<number>): number | null
batch(snapshots: Array<ObSnapshot>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type OrderBookImbalanceFullNode = OrderBookImbalanceFull
export declare class OrderBookImbalanceFull {
constructor()
update(bidPx: Array<number>, bidSz: Array<number>, askPx: Array<number>, askSz: Array<number>): number | null
batch(snapshots: Array<ObSnapshot>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type MicropriceNode = Microprice
export declare class Microprice {
constructor()
update(bidPx: Array<number>, bidSz: Array<number>, askPx: Array<number>, askSz: Array<number>): number | null
batch(snapshots: Array<ObSnapshot>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type QuotedSpreadNode = QuotedSpread
export declare class QuotedSpread {
constructor()
update(bidPx: Array<number>, bidSz: Array<number>, askPx: Array<number>, askSz: Array<number>): number | null
batch(snapshots: Array<ObSnapshot>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type OrderBookImbalanceTopNNode = OrderBookImbalanceTopN
export declare class OrderBookImbalanceTopN {
constructor(levels: number)
update(bidPx: Array<number>, bidSz: Array<number>, askPx: Array<number>, askSz: Array<number>): number | null
batch(snapshots: Array<ObSnapshot>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type SharpeRatioNode = SharpeRatio
export declare class SharpeRatio {
constructor(period: number, riskFree: number)
+6 -1
View File
@@ -310,7 +310,7 @@ if (!nativeBinding) {
throw new Error(`Failed to load native binding`)
}
const { version, SMA, EMA, WMA, RSI, DEMA, TEMA, HMA, ROC, TRIX, SMMA, TRIMA, ZLEMA, MOM, CMO, DPO, StdDev, UlcerIndex, VerticalHorizontalFilter, ZScore, McGinleyDynamic, FRAMA, SuperSmoother, FisherTransform, Decycler, CenterOfGravity, CyberneticCycle, InstantaneousTrendline, EhlersStochastic, RVIVolatility, Variance, CoefficientOfVariation, Skewness, Kurtosis, StandardError, DetrendedStdDev, RSquared, MedianAbsoluteDeviation, Autocorrelation, HurstExponent, PearsonCorrelation, Beta, PairwiseBeta, SpearmanCorrelation, PairSpreadZScore, LeadLagCrossCorrelation, Cointegration, RelativeStrengthAB, MACD, BollingerBands, ATR, Stochastic, OBV, ADX, ADXR, CCI, WilliamsR, MFI, PSAR, Keltner, Donchian, VWAP, RollingVWAP, AwesomeOscillator, Aroon, Inertia, ConnorsRSI, LaguerreRSI, SMI, KST, PGO, RVI, AwesomeOscillatorHistogram, STC, ElderImpulse, ZeroLagMACD, CFO, APO, KAMA, EVWMA, Alligator, JMA, VIDYA, ALMA, T3, TSI, PMO, TII, ADL, VolumePriceTrend, ChaikinMoneyFlow, ChaikinOscillator, ForceIndex, NVI, PVI, VolumeOscillator, KVO, WilliamsAD, AnchoredVWAP, DemandIndex, TSV, VZO, MarketFacilitationIndex, EaseOfMovement, SuperTrend, ChandelierExit, ChandeKrollStop, AtrTrailingStop, HiLoActivator, VoltyStop, YoyoExit, DonchianStop, PercentageTrailingStop, StepTrailingStop, RenkoTrailingStop, TypicalPrice, MedianPrice, WeightedClose, LinearRegression, LinRegSlope, AcceleratorOscillator, BalanceOfPower, ChoppinessIndex, TrueRange, ChaikinVolatility, YangZhangVolatility, RogersSatchellVolatility, GarmanKlassVolatility, ParkinsonVolatility, LinRegAngle, BollingerBandwidth, PercentB, NATR, HistoricalVolatility, AroonOscillator, WaveTrend, RWI, Vortex, MassIndex, StochRSI, UltimateOscillator, PPO, Coppock, VWMA, MaEnvelope, AccelerationBands, StarcBands, AtrBands, HurstChannel, LinRegChannel, StandardErrorBands, DoubleBollinger, TtmSqueeze, FractalChaosBands, VwapStdDevBands, ClassicPivots, FibonacciPivots, Camarilla, WoodiePivots, DemarkPivots, WilliamsFractals, ZigZag, TDSetup, TDSequential, TDDeMarker, TDREI, TDPressure, TDCombo, TDCountdown, TDLines, TDRangeProjection, TDDifferential, TDOpen, TDRiskLevel, InverseFisherTransform, DecyclerOscillator, RoofingFilter, EmpiricalModeDecomposition, HilbertDominantCycle, AdaptiveCycle, SineWave, MAMA, FAMA, Ichimoku, HeikinAshi, ValueArea, InitialBalance, OpeningRange, Doji, Hammer, InvertedHammer, HangingMan, ShootingStar, Engulfing, Harami, MorningEveningStar, ThreeSoldiersOrCrows, PiercingDarkCloud, Marubozu, Tweezer, SpinningTop, ThreeInside, ThreeOutside, SharpeRatio, SortinoRatio, CalmarRatio, OmegaRatio, MaxDrawdown, AverageDrawdown, DrawdownDuration, PainIndex, ValueAtRisk, ConditionalValueAtRisk, ProfitFactor, GainLossRatio, RecoveryFactor, KellyCriterion, TreynorRatio, InformationRatio, Alpha } = nativeBinding
const { version, SMA, EMA, WMA, RSI, DEMA, TEMA, HMA, ROC, TRIX, SMMA, TRIMA, ZLEMA, MOM, CMO, DPO, StdDev, UlcerIndex, VerticalHorizontalFilter, ZScore, McGinleyDynamic, FRAMA, SuperSmoother, FisherTransform, Decycler, CenterOfGravity, CyberneticCycle, InstantaneousTrendline, EhlersStochastic, RVIVolatility, Variance, CoefficientOfVariation, Skewness, Kurtosis, StandardError, DetrendedStdDev, RSquared, MedianAbsoluteDeviation, Autocorrelation, HurstExponent, PearsonCorrelation, Beta, PairwiseBeta, SpearmanCorrelation, PairSpreadZScore, LeadLagCrossCorrelation, Cointegration, RelativeStrengthAB, MACD, BollingerBands, ATR, Stochastic, OBV, ADX, ADXR, CCI, WilliamsR, MFI, PSAR, Keltner, Donchian, VWAP, RollingVWAP, AwesomeOscillator, Aroon, Inertia, ConnorsRSI, LaguerreRSI, SMI, KST, PGO, RVI, AwesomeOscillatorHistogram, STC, ElderImpulse, ZeroLagMACD, CFO, APO, KAMA, EVWMA, Alligator, JMA, VIDYA, ALMA, T3, TSI, PMO, TII, ADL, VolumePriceTrend, ChaikinMoneyFlow, ChaikinOscillator, ForceIndex, NVI, PVI, VolumeOscillator, KVO, WilliamsAD, AnchoredVWAP, DemandIndex, TSV, VZO, MarketFacilitationIndex, EaseOfMovement, SuperTrend, ChandelierExit, ChandeKrollStop, AtrTrailingStop, HiLoActivator, VoltyStop, YoyoExit, DonchianStop, PercentageTrailingStop, StepTrailingStop, RenkoTrailingStop, TypicalPrice, MedianPrice, WeightedClose, LinearRegression, LinRegSlope, AcceleratorOscillator, BalanceOfPower, ChoppinessIndex, TrueRange, ChaikinVolatility, YangZhangVolatility, RogersSatchellVolatility, GarmanKlassVolatility, ParkinsonVolatility, LinRegAngle, BollingerBandwidth, PercentB, NATR, HistoricalVolatility, AroonOscillator, WaveTrend, RWI, Vortex, MassIndex, StochRSI, UltimateOscillator, PPO, Coppock, VWMA, MaEnvelope, AccelerationBands, StarcBands, AtrBands, HurstChannel, LinRegChannel, StandardErrorBands, DoubleBollinger, TtmSqueeze, FractalChaosBands, VwapStdDevBands, ClassicPivots, FibonacciPivots, Camarilla, WoodiePivots, DemarkPivots, WilliamsFractals, ZigZag, TDSetup, TDSequential, TDDeMarker, TDREI, TDPressure, TDCombo, TDCountdown, TDLines, TDRangeProjection, TDDifferential, TDOpen, TDRiskLevel, InverseFisherTransform, DecyclerOscillator, RoofingFilter, EmpiricalModeDecomposition, HilbertDominantCycle, AdaptiveCycle, SineWave, MAMA, FAMA, Ichimoku, HeikinAshi, ValueArea, InitialBalance, OpeningRange, Doji, Hammer, InvertedHammer, HangingMan, ShootingStar, Engulfing, Harami, MorningEveningStar, ThreeSoldiersOrCrows, PiercingDarkCloud, Marubozu, Tweezer, SpinningTop, ThreeInside, ThreeOutside, OrderBookImbalanceTop1, OrderBookImbalanceFull, Microprice, QuotedSpread, OrderBookImbalanceTopN, SharpeRatio, SortinoRatio, CalmarRatio, OmegaRatio, MaxDrawdown, AverageDrawdown, DrawdownDuration, PainIndex, ValueAtRisk, ConditionalValueAtRisk, ProfitFactor, GainLossRatio, RecoveryFactor, KellyCriterion, TreynorRatio, InformationRatio, Alpha } = nativeBinding
module.exports.version = version
module.exports.SMA = SMA
@@ -515,6 +515,11 @@ module.exports.Tweezer = Tweezer
module.exports.SpinningTop = SpinningTop
module.exports.ThreeInside = ThreeInside
module.exports.ThreeOutside = ThreeOutside
module.exports.OrderBookImbalanceTop1 = OrderBookImbalanceTop1
module.exports.OrderBookImbalanceFull = OrderBookImbalanceFull
module.exports.Microprice = Microprice
module.exports.QuotedSpread = QuotedSpread
module.exports.OrderBookImbalanceTopN = OrderBookImbalanceTopN
module.exports.SharpeRatio = SharpeRatio
module.exports.SortinoRatio = SortinoRatio
module.exports.CalmarRatio = CalmarRatio
+160
View File
@@ -8754,6 +8754,166 @@ node_candle_pattern!(SpinningTopNode, wc::SpinningTop, "SpinningTop");
node_candle_pattern!(ThreeInsideNode, wc::ThreeInside, "ThreeInside");
node_candle_pattern!(ThreeOutsideNode, wc::ThreeOutside, "ThreeOutside");
// ============================== Microstructure: Order Book ==============================
//
// Order-book indicators consume a depth snapshot rather than OHLCV. Streaming
// `update(bidPx, bidSz, askPx, askSz)` takes four equal-length arrays for one
// snapshot (bids best-first = descending price, asks best-first = ascending
// price); `batch` takes an array of `{ bidPx, bidSz, askPx, askSz }` snapshots
// and returns one value per snapshot.
/// One order-book depth snapshot for batch evaluation.
#[napi(object)]
pub struct ObSnapshot {
pub bid_px: Vec<f64>,
pub bid_sz: Vec<f64>,
pub ask_px: Vec<f64>,
pub ask_sz: Vec<f64>,
}
fn build_order_book(
bid_px: &[f64],
bid_sz: &[f64],
ask_px: &[f64],
ask_sz: &[f64],
) -> napi::Result<wc::OrderBook> {
if bid_px.len() != bid_sz.len() || ask_px.len() != ask_sz.len() {
return Err(NapiError::from_reason(
"bid/ask price and size arrays must be equal length".to_string(),
));
}
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! node_ob_indicator {
($node:ident, $inner:ty, $js:literal) => {
#[napi(js_name = $js)]
pub struct $node {
inner: $inner,
}
impl Default for $node {
fn default() -> Self {
Self::new()
}
}
#[napi]
impl $node {
#[napi(constructor)]
pub fn new() -> Self {
Self {
inner: <$inner>::new(),
}
}
#[napi]
pub fn update(
&mut self,
bid_px: Vec<f64>,
bid_sz: Vec<f64>,
ask_px: Vec<f64>,
ask_sz: Vec<f64>,
) -> napi::Result<Option<f64>> {
let book = build_order_book(&bid_px, &bid_sz, &ask_px, &ask_sz)?;
Ok(self.inner.update(book))
}
#[napi]
pub fn batch(&mut self, snapshots: Vec<ObSnapshot>) -> napi::Result<Vec<f64>> {
let mut out = Vec::with_capacity(snapshots.len());
for snap in &snapshots {
let book =
build_order_book(&snap.bid_px, &snap.bid_sz, &snap.ask_px, &snap.ask_sz)?;
out.push(self.inner.update(book).unwrap_or(f64::NAN));
}
Ok(out)
}
#[napi]
pub fn reset(&mut self) {
self.inner.reset();
}
#[napi(js_name = "isReady")]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[napi(js_name = "warmupPeriod")]
pub fn warmup_period(&self) -> u32 {
self.inner.warmup_period() as u32
}
}
};
}
node_ob_indicator!(
OrderBookImbalanceTop1Node,
wc::OrderBookImbalanceTop1,
"OrderBookImbalanceTop1"
);
node_ob_indicator!(
OrderBookImbalanceFullNode,
wc::OrderBookImbalanceFull,
"OrderBookImbalanceFull"
);
node_ob_indicator!(MicropriceNode, wc::Microprice, "Microprice");
node_ob_indicator!(QuotedSpreadNode, wc::QuotedSpread, "QuotedSpread");
// Top-N imbalance carries a `levels` parameter, so it is hand-written.
#[napi(js_name = "OrderBookImbalanceTopN")]
pub struct OrderBookImbalanceTopNNode {
inner: wc::OrderBookImbalanceTopN,
}
#[napi]
impl OrderBookImbalanceTopNNode {
#[napi(constructor)]
pub fn new(levels: u32) -> napi::Result<Self> {
Ok(Self {
inner: wc::OrderBookImbalanceTopN::new(levels as usize).map_err(map_err)?,
})
}
#[napi]
pub fn update(
&mut self,
bid_px: Vec<f64>,
bid_sz: Vec<f64>,
ask_px: Vec<f64>,
ask_sz: Vec<f64>,
) -> napi::Result<Option<f64>> {
let book = build_order_book(&bid_px, &bid_sz, &ask_px, &ask_sz)?;
Ok(self.inner.update(book))
}
#[napi]
pub fn batch(&mut self, snapshots: Vec<ObSnapshot>) -> napi::Result<Vec<f64>> {
let mut out = Vec::with_capacity(snapshots.len());
for snap in &snapshots {
let book = build_order_book(&snap.bid_px, &snap.bid_sz, &snap.ask_px, &snap.ask_sz)?;
out.push(self.inner.update(book).unwrap_or(f64::NAN));
}
Ok(out)
}
#[napi]
pub fn reset(&mut self) {
self.inner.reset();
}
#[napi(js_name = "isReady")]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[napi(js_name = "warmupPeriod")]
pub fn warmup_period(&self) -> u32 {
self.inner.warmup_period() as u32
}
}
// ============================== Family 15: Risk / Performance ==============================
// Risk metrics with fallible `new` (most need `period >= 2`), so each wrapper
+12
View File
@@ -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
View File
@@ -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)
+21
View File
@@ -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)
+22
View File
@@ -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)
+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::*;