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:
@@ -8,6 +8,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- **Microstructure family — order book (part 1).** A new family of indicators
|
||||
that consume an order-book depth snapshot (`OrderBook` of sorted, uncrossed
|
||||
bid/ask `Level`s) rather than OHLCV, exposed in Rust, Python, Node and WASM:
|
||||
- **Order-Book Imbalance** — `OrderBookImbalanceTop1`, `OrderBookImbalanceTopN`
|
||||
(configurable depth) and `OrderBookImbalanceFull` measure signed depth
|
||||
pressure `(bidDepth − askDepth) / (bidDepth + askDepth)` over the top level,
|
||||
the top-N levels, or the full book.
|
||||
- **Microprice** — the size-weighted fair value
|
||||
`(bidPx·askSz + askPx·bidSz) / (bidSz + askSz)`, tilting the mid toward the
|
||||
side more likely to be hit.
|
||||
- **Quoted Spread** — the top-of-book spread in basis points of the mid.
|
||||
|
||||
New public value types `Level`, `OrderBook`, `Side`, `Trade` and `TradeQuote`
|
||||
back this and the upcoming trade-flow and price-impact indicators. Python and
|
||||
Node accept a batch over a list of snapshots; WASM exposes per-snapshot
|
||||
`update`.
|
||||
- **Signed Doji encoding.** `Doji` gains an opt-in `.signed()` mode
|
||||
(`Doji(signed=True)` in Python, `new Doji(true)` in Node and WASM) that
|
||||
classifies a detected Doji by the position of its body within the bar range —
|
||||
|
||||
@@ -47,7 +47,7 @@ Full documentation lives at **[docs.wickra.org](https://docs.wickra.org)**:
|
||||
[Node](https://docs.wickra.org/Quickstart-Node),
|
||||
[WASM](https://docs.wickra.org/Quickstart-WASM).
|
||||
- **Indicators** — a per-indicator deep dive (formula, parameters, warmup) for
|
||||
every one of the 219 indicators; start at the
|
||||
every one of the 224 indicators; start at the
|
||||
[indicators overview](https://docs.wickra.org/Indicators-Overview).
|
||||
- **Reference** — [warmup periods](https://docs.wickra.org/Warmup-Periods),
|
||||
[streaming vs batch](https://docs.wickra.org/Streaming-vs-Batch),
|
||||
@@ -135,7 +135,7 @@ python -m benchmarks.compare_libraries
|
||||
|
||||
## Indicators
|
||||
|
||||
219 streaming-first indicators across sixteen families. Every one passes the
|
||||
224 streaming-first indicators across seventeen families. Every one passes the
|
||||
`batch == streaming` equivalence test, reference-value tests, and reset
|
||||
semantics tests. Each has a per-indicator deep dive (formula, parameters,
|
||||
warmup) at [docs.wickra.org](https://docs.wickra.org/Indicators-Overview).
|
||||
@@ -156,6 +156,7 @@ warmup) at [docs.wickra.org](https://docs.wickra.org/Indicators-Overview).
|
||||
| DeMark | TD Setup, TD Sequential, TD DeMarker, TD REI, TD Pressure, TD Combo, TD Countdown, TD Lines, TD Range Projection, TD Differential, TD Open, TD Risk Level |
|
||||
| Ichimoku & Charts | Ichimoku Kinko Hyo (Tenkan, Kijun, Senkou A/B, Chikou), Heikin-Ashi |
|
||||
| Candlestick Patterns | Doji, Hammer, Inverted Hammer, Hanging Man, Shooting Star, Engulfing, Harami, Morning/Evening Star, Three White Soldiers/Black Crows, Piercing Line/Dark Cloud Cover, Marubozu, Tweezer, Spinning Top, Three Inside Up/Down, Three Outside Up/Down |
|
||||
| Microstructure | Order-Book Imbalance (Top-1 / Top-N / Full), Microprice, Quoted Spread |
|
||||
| Market Profile | Value Area (POC / VAH / VAL), Initial Balance, Opening Range |
|
||||
| Risk / Performance | Sharpe Ratio, Sortino Ratio, Calmar Ratio, Omega Ratio, Max Drawdown, Average Drawdown, Drawdown Duration, Pain Index, Value at Risk, Conditional Value at Risk (CVaR), Profit Factor, Gain/Loss Ratio, Recovery Factor, Kelly Criterion, Treynor Ratio, Information Ratio, Alpha (Jensen) |
|
||||
|
||||
@@ -236,7 +237,7 @@ A Python live-trading example using the public `websockets` package lives at
|
||||
```
|
||||
wickra/
|
||||
├── crates/
|
||||
│ ├── wickra-core/ core engine + all 219 indicators
|
||||
│ ├── wickra-core/ core engine + all 224 indicators
|
||||
│ ├── wickra/ top-level facade crate (publishes on crates.io) + benches/
|
||||
│ └── wickra-data/ CSV reader, tick aggregator, live exchange feeds
|
||||
├── bindings/
|
||||
|
||||
@@ -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]));
|
||||
});
|
||||
|
||||
Vendored
+52
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -34,12 +34,12 @@ use std::hint::black_box;
|
||||
use wickra::{
|
||||
Adx, Atr, Autocorrelation, BatchExt, BollingerBands, BollingerOutput, CalmarRatio, Candle, Cci,
|
||||
ClassicPivots, ConnorsRsi, Ema, EmpiricalModeDecomposition, Engulfing, Frama,
|
||||
HilbertDominantCycle, HurstExponent, Ichimoku, IchimokuOutput, Indicator, Jma,
|
||||
LinearRegression, MacdIndicator, MacdOutput, Mama, MamaOutput, MaxDrawdown, Obv,
|
||||
ParkinsonVolatility, Ppo, Psar, RollingVwap, Rsi, SharpeRatio, Sma, Stc, SuperTrend,
|
||||
SuperTrendOutput, TdSequential, TdSequentialOutput, TtmSqueeze, TtmSqueezeOutput, ValueArea,
|
||||
ValueAreaOutput, ValueAtRisk, Vwap, VwapStdDevBands, VwapStdDevBandsOutput, WaveTrend,
|
||||
YangZhangVolatility, T3,
|
||||
HilbertDominantCycle, HurstExponent, Ichimoku, IchimokuOutput, Indicator, Jma, Level,
|
||||
LinearRegression, MacdIndicator, MacdOutput, Mama, MamaOutput, MaxDrawdown, Microprice, Obv,
|
||||
OrderBook, OrderBookImbalanceFull, OrderBookImbalanceTop1, ParkinsonVolatility, Ppo, Psar,
|
||||
RollingVwap, Rsi, SharpeRatio, Sma, Stc, SuperTrend, SuperTrendOutput, TdSequential,
|
||||
TdSequentialOutput, TtmSqueeze, TtmSqueezeOutput, ValueArea, ValueAreaOutput, ValueAtRisk,
|
||||
Vwap, VwapStdDevBands, VwapStdDevBandsOutput, WaveTrend, YangZhangVolatility, T3,
|
||||
};
|
||||
use wickra_data::csv::CandleReader;
|
||||
|
||||
@@ -114,6 +114,28 @@ where
|
||||
group.finish();
|
||||
}
|
||||
|
||||
fn bench_orderbook_input<I, F, O>(c: &mut Criterion, name: &str, books: &[OrderBook], make: F)
|
||||
where
|
||||
F: Fn() -> I,
|
||||
I: Indicator<Input = OrderBook, Output = O>,
|
||||
{
|
||||
let mut group = c.benchmark_group(name);
|
||||
for &n in SIZES {
|
||||
let n = n.min(books.len());
|
||||
let series = &books[..n];
|
||||
group.throughput(Throughput::Elements(n as u64));
|
||||
group.bench_with_input(BenchmarkId::new("streaming", n), series, |b, books| {
|
||||
b.iter(|| {
|
||||
let mut ind = make();
|
||||
for book in books {
|
||||
black_box(ind.update(book.clone()));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
group.finish();
|
||||
}
|
||||
|
||||
fn bench_scalar_multi<I, F, O>(c: &mut Criterion, name: &str, prices: &[f64], make: F)
|
||||
where
|
||||
F: Fn() -> I,
|
||||
@@ -265,6 +287,28 @@ fn benches(c: &mut Criterion) {
|
||||
bench_scalar(c, "value_at_risk", &closes, || {
|
||||
ValueAtRisk::new(50, 0.95).unwrap()
|
||||
});
|
||||
|
||||
// === Family — Microstructure ===
|
||||
// No order-book dataset ships with the repo, so synthesise a five-level
|
||||
// book around each candle close. Benches the cheapest (top-of-book) and the
|
||||
// most-expensive (full-depth sum) representatives of the family.
|
||||
let books: Vec<OrderBook> = candles
|
||||
.iter()
|
||||
.map(|candle| {
|
||||
let mid = candle.close;
|
||||
let tick = (mid * 0.0001).max(0.01);
|
||||
let bids = (0..5u32)
|
||||
.map(|i| Level::new_unchecked(mid - tick * f64::from(i + 1), 1.0 + f64::from(i)))
|
||||
.collect();
|
||||
let asks = (0..5u32)
|
||||
.map(|i| Level::new_unchecked(mid + tick * f64::from(i + 1), 1.0 + f64::from(i)))
|
||||
.collect();
|
||||
OrderBook::new_unchecked(bids, asks)
|
||||
})
|
||||
.collect();
|
||||
bench_orderbook_input(c, "ob_imbalance_top1", &books, OrderBookImbalanceTop1::new);
|
||||
bench_orderbook_input(c, "ob_imbalance_full", &books, OrderBookImbalanceFull::new);
|
||||
bench_orderbook_input(c, "microprice", &books, Microprice::new);
|
||||
}
|
||||
|
||||
criterion_group!(name = wickra_benches; config = Criterion::default(); targets = benches);
|
||||
|
||||
@@ -52,6 +52,13 @@ test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
||||
[[bin]]
|
||||
name = "indicator_update_orderbook"
|
||||
path = "fuzz_targets/indicator_update_orderbook.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
||||
[[bin]]
|
||||
name = "tick_aggregator"
|
||||
path = "fuzz_targets/tick_aggregator.rs"
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
#![no_main]
|
||||
//! Fuzz order-book `Indicator<Input = OrderBook>` implementations with
|
||||
//! arbitrary depth snapshots.
|
||||
//!
|
||||
//! Each iteration consumes a byte stream, interprets it as a sequence of
|
||||
//! `f64` values (8 bytes each), packs consecutive values into `(price, size)`
|
||||
//! levels, and groups levels into order-book snapshots. Books are built with
|
||||
//! `OrderBook::new_unchecked` so the fuzzer can explore degenerate shapes
|
||||
//! (empty sides, crossed books, non-finite prices, negative sizes) that the
|
||||
//! validating constructor would reject — the indicators must never panic on
|
||||
//! any of them, streaming or batched.
|
||||
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
use wickra_core::{
|
||||
BatchExt, Indicator, Level, Microprice, OrderBook, OrderBookImbalanceFull,
|
||||
OrderBookImbalanceTop1, OrderBookImbalanceTopN, QuotedSpread,
|
||||
};
|
||||
|
||||
#[inline(never)]
|
||||
fn drive<I>(make: impl Fn() -> I, books: &[OrderBook])
|
||||
where
|
||||
I: Indicator<Input = OrderBook, Output = f64> + BatchExt,
|
||||
{
|
||||
let mut streaming = make();
|
||||
for book in books {
|
||||
let _ = streaming.update(book.clone());
|
||||
}
|
||||
let _ = make().batch(books);
|
||||
}
|
||||
|
||||
fuzz_target!(|data: &[u8]| {
|
||||
let floats: Vec<f64> = data
|
||||
.chunks_exact(8)
|
||||
.map(|c| f64::from_le_bytes(c.try_into().expect("8 bytes")))
|
||||
.collect();
|
||||
let levels: Vec<Level> = floats
|
||||
.chunks_exact(2)
|
||||
.map(|c| Level::new_unchecked(c[0], c[1]))
|
||||
.collect();
|
||||
// Group levels into snapshots of up to four levels (split into bids / asks).
|
||||
let books: Vec<OrderBook> = levels
|
||||
.chunks(4)
|
||||
.map(|chunk| {
|
||||
let half = chunk.len() / 2;
|
||||
OrderBook::new_unchecked(chunk[..half].to_vec(), chunk[half..].to_vec())
|
||||
})
|
||||
.collect();
|
||||
|
||||
drive(OrderBookImbalanceTop1::new, &books);
|
||||
drive(|| OrderBookImbalanceTopN::new(3).unwrap(), &books);
|
||||
drive(OrderBookImbalanceFull::new, &books);
|
||||
drive(Microprice::new, &books);
|
||||
drive(QuotedSpread::new, &books);
|
||||
});
|
||||
Reference in New Issue
Block a user