diff --git a/CHANGELOG.md b/CHANGELOG.md index f98ccfc0..a1fdea15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Depth Slope** — the mean per-side OLS slope of cumulative resting size against distance from the mid, measuring how fast the book thickens away from the touch. +- **Microstructure family — footprint (part 4).** **Footprint** decomposes the + volume traded in a bar across price buckets (`round(price / tick_size)`), + splitting each bucket into buy-initiated (ask) and sell-initiated (bid) + volume. A multi-output, variable-length indicator: every `update` returns the + full footprint accumulated since the last `reset`, exposed in Rust, Python + (`(k, 3)` arrays), Node (`{ price, bidVol, askVol }` rows) and WASM. ## [0.4.2] - 2026-06-01 diff --git a/README.md b/README.md index 1b73e625..2f61d4f3 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@

- Wickra — streaming-first technical indicators + Wickra — streaming-first technical indicators

[![CI](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml/badge.svg)](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml) @@ -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 231 indicators; start at the + every one of the 232 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 -231 streaming-first indicators across seventeen families. Every one passes the +232 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,7 +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, Depth Slope, Signed Volume, Cumulative Volume Delta, Trade Imbalance, Effective Spread, Realized Spread, Kyle's Lambda | +| Microstructure | Order-Book Imbalance (Top-1 / Top-N / Full), Microprice, Quoted Spread, Depth Slope, Signed Volume, Cumulative Volume Delta, Trade Imbalance, Effective Spread, Realized Spread, Kyle's Lambda, Footprint | | 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) | @@ -237,7 +237,7 @@ A Python live-trading example using the public `websockets` package lives at ``` wickra/ ├── crates/ -│ ├── wickra-core/ core engine + all 231 indicators +│ ├── wickra-core/ core engine + all 232 indicators │ ├── wickra/ top-level facade crate (publishes on crates.io) + benches/ │ └── wickra-data/ CSV reader, tick aggregator, live exchange feeds ├── bindings/ diff --git a/bindings/node/__tests__/indicators.test.js b/bindings/node/__tests__/indicators.test.js index 00159e75..c4e27c3b 100644 --- a/bindings/node/__tests__/indicators.test.js +++ b/bindings/node/__tests__/indicators.test.js @@ -1063,3 +1063,34 @@ test('price-impact rejects bad input', () => { assert.throws(() => new wickra.RealizedSpread(0)); assert.throws(() => new wickra.KylesLambda(1)); }); + +test('footprint buckets buy and sell volume per price level', () => { + const fp = new wickra.Footprint(1.0); + fp.update(100.2, 2, true); // bucket 100 -> ask 2 + fp.update(100.7, 3, false); // bucket 101 -> bid 3 + const out = fp.update(100.1, 1, true); // bucket 100 -> ask 3 + assert.equal(out.length, 2); + assert.deepEqual( + { price: out[0].price, bidVol: out[0].bidVol, askVol: out[0].askVol }, + { price: 100.0, bidVol: 0.0, askVol: 3.0 }, + ); + assert.deepEqual( + { price: out[1].price, bidVol: out[1].bidVol, askVol: out[1].askVol }, + { price: 101.0, bidVol: 3.0, askVol: 0.0 }, + ); +}); + +test('footprint streaming update matches batch and rejects bad tick', () => { + const n = 12; + const price = Array.from({ length: n }, (_, i) => 100 + (i % 5) * 0.3); + const size = Array.from({ length: n }, (_, i) => 1 + (i % 3)); + const isBuy = Array.from({ length: n }, (_, i) => i % 2 === 0); + const batch = new wickra.Footprint(1.0).batch(price, size, isBuy); + const streamer = new wickra.Footprint(1.0); + assert.equal(batch.length, n); + for (let i = 0; i < n; i++) { + const s = streamer.update(price[i], size[i], isBuy[i]); + assert.deepEqual(s, batch[i], `mismatch at ${i}`); + } + assert.throws(() => new wickra.Footprint(0)); +}); diff --git a/bindings/node/index.d.ts b/bindings/node/index.d.ts index 087cc1bb..0be49796 100644 --- a/bindings/node/index.d.ts +++ b/bindings/node/index.d.ts @@ -286,6 +286,12 @@ export interface ObSnapshot { askPx: Array askSz: Array } +/** One price bucket of a footprint. */ +export interface FootprintLevelValue { + price: number + bidVol: number + askVol: number +} export type SmaNode = SMA export declare class SMA { constructor(period: number) @@ -2304,6 +2310,15 @@ export declare class KylesLambda { isReady(): boolean warmupPeriod(): number } +export type FootprintNode = Footprint +export declare class Footprint { + constructor(tickSize: number) + update(price: number, size: number, isBuy: boolean): Array + batch(price: Array, size: Array, isBuy: Array): Array> + reset(): void + isReady(): boolean + warmupPeriod(): number +} export type SharpeRatioNode = SharpeRatio export declare class SharpeRatio { constructor(period: number, riskFree: number) diff --git a/bindings/node/index.js b/bindings/node/index.js index 1136071a..5840116e 100644 --- a/bindings/node/index.js +++ b/bindings/node/index.js @@ -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, OrderBookImbalanceTop1, OrderBookImbalanceFull, Microprice, QuotedSpread, DepthSlope, OrderBookImbalanceTopN, SignedVolume, CumulativeVolumeDelta, TradeImbalance, EffectiveSpread, RealizedSpread, KylesLambda, 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, DepthSlope, OrderBookImbalanceTopN, SignedVolume, CumulativeVolumeDelta, TradeImbalance, EffectiveSpread, RealizedSpread, KylesLambda, Footprint, 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 @@ -527,6 +527,7 @@ module.exports.TradeImbalance = TradeImbalance module.exports.EffectiveSpread = EffectiveSpread module.exports.RealizedSpread = RealizedSpread module.exports.KylesLambda = KylesLambda +module.exports.Footprint = Footprint module.exports.SharpeRatio = SharpeRatio module.exports.SortinoRatio = SortinoRatio module.exports.CalmarRatio = CalmarRatio diff --git a/bindings/node/src/lib.rs b/bindings/node/src/lib.rs index 8c895525..23ccfa61 100644 --- a/bindings/node/src/lib.rs +++ b/bindings/node/src/lib.rs @@ -9264,6 +9264,94 @@ impl KylesLambdaNode { } } +// ============================== Microstructure: Footprint ============================== +// +// Footprint is a multi-output, variable-length indicator. Each `update(price, +// size, isBuy)` returns the full bar footprint accumulated since the last +// `reset()` as an array of `{ price, bidVol, askVol }` rows (sorted ascending +// by price); `batch` returns an array of such arrays, one per trade. + +/// One price bucket of a footprint. +#[napi(object)] +pub struct FootprintLevelValue { + pub price: f64, + pub bid_vol: f64, + pub ask_vol: f64, +} + +fn footprint_levels(out: &wc::FootprintOutput) -> Vec { + out.levels + .iter() + .map(|level| FootprintLevelValue { + price: level.price, + bid_vol: level.bid_vol, + ask_vol: level.ask_vol, + }) + .collect() +} + +#[napi(js_name = "Footprint")] +pub struct FootprintNode { + inner: wc::Footprint, +} + +#[napi] +impl FootprintNode { + #[napi(constructor)] + pub fn new(tick_size: f64) -> napi::Result { + Ok(Self { + inner: wc::Footprint::new(tick_size).map_err(map_err)?, + }) + } + #[napi] + pub fn update( + &mut self, + price: f64, + size: f64, + is_buy: bool, + ) -> napi::Result> { + let out = self + .inner + .update(build_trade(price, size, is_buy)?) + .expect("footprint emits on every trade"); + Ok(footprint_levels(&out)) + } + #[napi] + pub fn batch( + &mut self, + price: Vec, + size: Vec, + is_buy: Vec, + ) -> napi::Result>> { + if price.len() != size.len() || size.len() != is_buy.len() { + return Err(NapiError::from_reason( + "price, size, is_buy must be equal length".to_string(), + )); + } + let mut out = Vec::with_capacity(price.len()); + for i in 0..price.len() { + let snapshot = self + .inner + .update(build_trade(price[i], size[i], is_buy[i])?) + .expect("footprint emits on every trade"); + out.push(footprint_levels(&snapshot)); + } + 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 diff --git a/bindings/python/python/wickra/__init__.py b/bindings/python/python/wickra/__init__.py index b3e97e26..3ed6c433 100644 --- a/bindings/python/python/wickra/__init__.py +++ b/bindings/python/python/wickra/__init__.py @@ -255,6 +255,8 @@ from ._wickra import ( EffectiveSpread, RealizedSpread, KylesLambda, + # Microstructure: footprint + Footprint, # Risk / Performance SharpeRatio, SortinoRatio, @@ -507,6 +509,8 @@ __all__ = [ "EffectiveSpread", "RealizedSpread", "KylesLambda", + # Microstructure: footprint + "Footprint", # Risk / Performance "SharpeRatio", "SortinoRatio", diff --git a/bindings/python/src/lib.rs b/bindings/python/src/lib.rs index 074bdd03..9f763f00 100644 --- a/bindings/python/src/lib.rs +++ b/bindings/python/src/lib.rs @@ -12095,6 +12095,93 @@ impl PyKylesLambda { } } +// ============================== Microstructure: Footprint ============================== +// +// Footprint is a multi-output, variable-length indicator: each `update(price, +// size, is_buy)` returns the full bar footprint accumulated since the last +// `reset()` as a `(k, 3)` array with columns `[price, bid_vol, ask_vol]`, one +// row per touched price bucket (sorted ascending by price). `batch` returns a +// list of such arrays, one per trade. + +fn footprint_to_array<'py>( + py: Python<'py>, + out: &wc::FootprintOutput, +) -> Bound<'py, PyArray2> { + let rows = out.levels.len(); + let mut data = Vec::with_capacity(rows * 3); + for level in &out.levels { + data.push(level.price); + data.push(level.bid_vol); + data.push(level.ask_vol); + } + numpy::ndarray::Array2::from_shape_vec((rows, 3), data) + .expect("shape consistent") + .into_pyarray(py) +} + +#[pyclass(name = "Footprint", module = "wickra._wickra", skip_from_py_object)] +#[derive(Clone)] +struct PyFootprint { + inner: wc::Footprint, +} + +#[pymethods] +impl PyFootprint { + #[new] + fn new(tick_size: f64) -> PyResult { + Ok(Self { + inner: wc::Footprint::new(tick_size).map_err(map_err)?, + }) + } + fn update<'py>( + &mut self, + py: Python<'py>, + price: f64, + size: f64, + is_buy: bool, + ) -> PyResult>> { + let out = self + .inner + .update(build_trade(price, size, is_buy)?) + .expect("footprint emits on every trade"); + Ok(footprint_to_array(py, &out)) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + price: Vec, + size: Vec, + is_buy: Vec, + ) -> PyResult>>> { + if price.len() != size.len() || size.len() != is_buy.len() { + return Err(PyValueError::new_err( + "price, size, is_buy must be equal length", + )); + } + let mut out = Vec::with_capacity(price.len()); + for i in 0..price.len() { + let snapshot = self + .inner + .update(build_trade(price[i], size[i], is_buy[i])?) + .expect("footprint emits on every trade"); + out.push(footprint_to_array(py, &snapshot)); + } + Ok(out) + } + 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!("Footprint(tick_size={})", self.inner.tick_size()) + } +} + // ============================== Family 15: Risk / Performance ============================== #[pyclass(name = "SharpeRatio", module = "wickra._wickra", skip_from_py_object)] @@ -13215,6 +13302,8 @@ fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; m.add_class::()?; m.add_class::()?; + // Microstructure: footprint. + m.add_class::()?; // Family 15: Risk / Performance metrics. m.add_class::()?; m.add_class::()?; diff --git a/bindings/python/tests/test_input_validation.py b/bindings/python/tests/test_input_validation.py index c669b91c..85bddce2 100644 --- a/bindings/python/tests/test_input_validation.py +++ b/bindings/python/tests/test_input_validation.py @@ -231,3 +231,10 @@ def test_realized_spread_zero_horizon_raises(): def test_kyles_lambda_window_below_two_raises(): with pytest.raises(ValueError): ta.KylesLambda(1) + + +def test_footprint_non_positive_tick_raises(): + with pytest.raises(ValueError): + ta.Footprint(0.0) + with pytest.raises(ValueError): + ta.Footprint(-1.0) diff --git a/bindings/python/tests/test_known_values.py b/bindings/python/tests/test_known_values.py index 38b67d06..14aa7ff5 100644 --- a/bindings/python/tests/test_known_values.py +++ b/bindings/python/tests/test_known_values.py @@ -882,6 +882,17 @@ def test_depth_slope_reference_value(): assert ta.DepthSlope().update([100.0], [1.0], [101.0], [1.0]) == pytest.approx(0.0) +def test_footprint_buckets_buy_and_sell_volume(): + fp = ta.Footprint(1.0) + fp.update(100.2, 2.0, True) # bucket 100 -> ask 2 + fp.update(100.7, 3.0, False) # bucket 101 -> bid 3 + out = fp.update(100.1, 1.0, True) # bucket 100 -> ask 3 + # Columns are [price, bid_vol, ask_vol], rows sorted ascending by price. + assert out.shape == (2, 3) + assert list(out[0]) == [100.0, 0.0, 3.0] + assert list(out[1]) == [101.0, 3.0, 0.0] + + def test_signed_volume_reference_values(): assert ta.SignedVolume().update(100.0, 2.0, True) == pytest.approx(2.0) assert ta.SignedVolume().update(100.0, 3.0, False) == pytest.approx(-3.0) diff --git a/bindings/python/tests/test_lifecycle.py b/bindings/python/tests/test_lifecycle.py index abc866c2..e9f125e5 100644 --- a/bindings/python/tests/test_lifecycle.py +++ b/bindings/python/tests/test_lifecycle.py @@ -207,3 +207,14 @@ def test_kyles_lambda_lifecycle_and_repr(): kl.reset() assert not kl.is_ready() assert repr(ta.KylesLambda(7)) == "KylesLambda(window=7)" + + +def test_footprint_lifecycle_and_repr(): + fp = ta.Footprint(0.5) + assert fp.warmup_period() == 1 + assert not fp.is_ready() + fp.update(100.0, 1.0, True) + assert fp.is_ready() + fp.reset() + assert not fp.is_ready() + assert repr(ta.Footprint(0.25)) == "Footprint(tick_size=0.25)" diff --git a/bindings/python/tests/test_new_indicators.py b/bindings/python/tests/test_new_indicators.py index 9bc4d2a8..c2a8754d 100644 --- a/bindings/python/tests/test_new_indicators.py +++ b/bindings/python/tests/test_new_indicators.py @@ -1939,3 +1939,16 @@ def test_price_impact_indicators_streaming_equals_batch(): ) assert batch.shape == (n,) assert _eq_nan(batch, streamed) + + +def test_footprint_streaming_equals_batch(): + n = 20 + price = [100.0 + (i % 5) * 0.3 for i in range(n)] + size = [1.0 + (i % 3) for i in range(n)] + is_buy = [i % 2 == 0 for i in range(n)] + batch = ta.Footprint(1.0).batch(price, size, is_buy) + streamer = ta.Footprint(1.0) + assert len(batch) == n + for i in range(n): + streamed = streamer.update(price[i], size[i], is_buy[i]) + assert np.array_equal(streamed, batch[i]) diff --git a/bindings/python/tests/test_smoke.py b/bindings/python/tests/test_smoke.py index c3528822..9845ba76 100644 --- a/bindings/python/tests/test_smoke.py +++ b/bindings/python/tests/test_smoke.py @@ -154,3 +154,16 @@ def test_price_impact_batch_returns_one_value_per_trade(): out = ind.batch(price, size, is_buy, mid) assert out.shape == (4,) assert out.dtype == np.float64 + + +def test_footprint_constructs_and_emits(): + out = ta.Footprint(1.0).update(100.2, 2.0, True) + assert out.shape == (1, 3) + assert out.dtype == np.float64 + + +def test_footprint_batch_returns_list_of_arrays(): + res = ta.Footprint(1.0).batch([100.2, 100.7], [2.0, 3.0], [True, False]) + assert isinstance(res, list) + assert len(res) == 2 + assert res[-1].shape[1] == 3 diff --git a/bindings/wasm/src/lib.rs b/bindings/wasm/src/lib.rs index 94865596..34ed1ad2 100644 --- a/bindings/wasm/src/lib.rs +++ b/bindings/wasm/src/lib.rs @@ -9,7 +9,7 @@ #![allow(clippy::needless_pass_by_value)] #![allow(missing_debug_implementations)] // wasm_bindgen wrappers expose JS objects, no need for Debug -use js_sys::{Float64Array, Object, Reflect}; +use js_sys::{Array, Float64Array, Object, Reflect}; use wasm_bindgen::prelude::*; use wickra_core as wc; use wickra_core::{BatchExt, Indicator}; @@ -6699,6 +6699,54 @@ impl WasmKylesLambda { } } +// ============================== Microstructure: Footprint ============================== +// +// Footprint is a multi-output, variable-length indicator. Each `update(price, +// size, isBuy)` returns the full bar footprint accumulated since the last +// `reset()` as an array of `{ price, bidVol, askVol }` objects (sorted ascending +// by price) — the streaming model for a live browser trade feed. + +#[wasm_bindgen(js_name = Footprint)] +pub struct WasmFootprint { + inner: wc::Footprint, +} + +#[wasm_bindgen(js_class = Footprint)] +impl WasmFootprint { + #[wasm_bindgen(constructor)] + pub fn new(tick_size: f64) -> Result { + Ok(Self { + inner: wc::Footprint::new(tick_size).map_err(map_err)?, + }) + } + pub fn update(&mut self, price: f64, size: f64, is_buy: bool) -> Result { + let out = self + .inner + .update(build_trade(price, size, is_buy)?) + .expect("footprint emits on every trade"); + let levels = Array::new(); + for level in &out.levels { + let obj = Object::new(); + Reflect::set(&obj, &"price".into(), &level.price.into()).ok(); + Reflect::set(&obj, &"bidVol".into(), &level.bid_vol.into()).ok(); + Reflect::set(&obj, &"askVol".into(), &level.ask_vol.into()).ok(); + levels.push(&obj); + } + Ok(levels.into()) + } + 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::*; diff --git a/crates/wickra-core/src/indicators/footprint.rs b/crates/wickra-core/src/indicators/footprint.rs new file mode 100644 index 00000000..2e5d38e4 --- /dev/null +++ b/crates/wickra-core/src/indicators/footprint.rs @@ -0,0 +1,259 @@ +//! Footprint — buy/sell volume profile per price bucket within a bar. + +use std::collections::BTreeMap; + +use crate::error::{Error, Result}; +use crate::microstructure::Trade; +use crate::traits::Indicator; + +/// One price bucket of a [`Footprint`]: the buy- and sell-initiated volume that +/// traded there since the last reset. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct FootprintLevel { + /// Bucket price (the bucket index times the tick size). + pub price: f64, + /// Sell-initiated (bid-hitting) volume traded at this bucket. + pub bid_vol: f64, + /// Buy-initiated (ask-lifting) volume traded at this bucket. + pub ask_vol: f64, +} + +/// The full footprint of a bar: one [`FootprintLevel`] per touched price +/// bucket, sorted ascending by price. +#[derive(Debug, Clone, PartialEq, Default)] +pub struct FootprintOutput { + /// Touched price buckets, lowest price first. + pub levels: Vec, +} + +/// Footprint — the buy/sell volume profile of a bar, bucketed by price. +/// +/// A footprint (a.k.a. bid/ask or volume cluster chart) decomposes the volume +/// traded within a bar across the price levels at which it printed, splitting +/// each level into buy-initiated (ask-lifting) and sell-initiated (bid-hitting) +/// volume. It exposes *where* inside a bar the activity happened and which side +/// was the aggressor there — the basis for absorption, imbalance and +/// point-of-control analysis that a single OHLCV bar hides. +/// +/// Each trade is assigned to the price bucket `round(price / tick_size)`; its +/// size is added to that bucket's ask volume for a buy and bid volume for a +/// sell. Every [`update`] returns the complete footprint accumulated since the +/// last [`reset`], as a [`FootprintOutput`] whose `levels` are sorted ascending +/// by price. Call [`reset`] at each bar (or session) boundary to start a fresh +/// footprint. +/// +/// `Input = Trade`, `Output = FootprintOutput`. Ready after the first trade. +/// +/// [`update`]: crate::Indicator::update +/// [`reset`]: crate::Indicator::reset +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Footprint, Indicator, Side, Trade}; +/// +/// let mut fp = Footprint::new(1.0).unwrap(); +/// fp.update(Trade::new(100.2, 2.0, Side::Buy, 0).unwrap()); +/// let out = fp.update(Trade::new(100.7, 3.0, Side::Sell, 1).unwrap()).unwrap(); +/// // Two buckets: 100 (ask 2) and 101 (bid 3). +/// assert_eq!(out.levels.len(), 2); +/// assert_eq!(out.levels[0].price, 100.0); +/// assert_eq!(out.levels[0].ask_vol, 2.0); +/// assert_eq!(out.levels[1].price, 101.0); +/// assert_eq!(out.levels[1].bid_vol, 3.0); +/// ``` +#[derive(Debug, Clone)] +pub struct Footprint { + tick_size: f64, + // bucket index -> (bid_vol = sell-initiated, ask_vol = buy-initiated). + buckets: BTreeMap, + has_emitted: bool, +} + +impl Footprint { + /// Construct a footprint with the given price-bucket `tick_size`. + /// + /// # Errors + /// + /// Returns [`Error::InvalidTick`] if `tick_size` is not a finite, strictly + /// positive number. + pub fn new(tick_size: f64) -> Result { + if !tick_size.is_finite() || tick_size <= 0.0 { + return Err(Error::InvalidTick { + message: "footprint tick_size must be finite and positive", + }); + } + Ok(Self { + tick_size, + buckets: BTreeMap::new(), + has_emitted: false, + }) + } + + /// The configured price-bucket size. + pub const fn tick_size(&self) -> f64 { + self.tick_size + } + + fn bucket_index(&self, price: f64) -> i64 { + // Float-to-int `as` saturates rather than wrapping, so an extreme + // price/tick ratio clamps to i64::MIN/MAX instead of misbehaving; + // realistic ratios fit comfortably. + #[allow(clippy::cast_possible_truncation)] + { + (price / self.tick_size).round() as i64 + } + } + + fn snapshot(&self) -> FootprintOutput { + let levels = self + .buckets + .iter() + .map(|(&index, &(bid_vol, ask_vol))| FootprintLevel { + price: index as f64 * self.tick_size, + bid_vol, + ask_vol, + }) + .collect(); + FootprintOutput { levels } + } +} + +impl Indicator for Footprint { + type Input = Trade; + type Output = FootprintOutput; + + fn update(&mut self, trade: Trade) -> Option { + self.has_emitted = true; + let index = self.bucket_index(trade.price); + let entry = self.buckets.entry(index).or_insert((0.0, 0.0)); + if trade.side.sign() > 0.0 { + entry.1 += trade.size; + } else { + entry.0 += trade.size; + } + Some(self.snapshot()) + } + + fn reset(&mut self) { + self.buckets.clear(); + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "Footprint" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::microstructure::Side; + use crate::traits::BatchExt; + + fn trade(price: f64, size: f64, side: Side) -> Trade { + Trade::new(price, size, side, 0).unwrap() + } + + #[test] + fn rejects_bad_tick_size() { + assert!(matches!( + Footprint::new(0.0), + Err(Error::InvalidTick { .. }) + )); + assert!(matches!( + Footprint::new(-1.0), + Err(Error::InvalidTick { .. }) + )); + assert!(matches!( + Footprint::new(f64::NAN), + Err(Error::InvalidTick { .. }) + )); + assert!(Footprint::new(0.5).is_ok()); + } + + #[test] + fn accessors_and_metadata() { + let fp = Footprint::new(0.25).unwrap(); + assert_eq!(fp.name(), "Footprint"); + assert_eq!(fp.warmup_period(), 1); + assert_eq!(fp.tick_size(), 0.25); + assert!(!fp.is_ready()); + } + + #[test] + fn buckets_buy_and_sell_volume() { + let mut fp = Footprint::new(1.0).unwrap(); + fp.update(trade(100.2, 2.0, Side::Buy)); + fp.update(trade(100.7, 3.0, Side::Sell)); + let out = fp.update(trade(100.1, 1.0, Side::Buy)).unwrap(); + assert!(fp.is_ready()); + // Bucket 100: buy 2 + buy 1 = ask 3, bid 0. Bucket 101: sell 3. + assert_eq!(out.levels.len(), 2); + assert_eq!(out.levels[0].price, 100.0); + assert_eq!(out.levels[0].ask_vol, 3.0); + assert_eq!(out.levels[0].bid_vol, 0.0); + assert_eq!(out.levels[1].price, 101.0); + assert_eq!(out.levels[1].bid_vol, 3.0); + assert_eq!(out.levels[1].ask_vol, 0.0); + } + + #[test] + fn levels_sorted_ascending_by_price() { + let mut fp = Footprint::new(1.0).unwrap(); + fp.update(trade(103.0, 1.0, Side::Buy)); + fp.update(trade(100.0, 1.0, Side::Sell)); + let out = fp.update(trade(101.0, 1.0, Side::Buy)).unwrap(); + let prices: Vec = out.levels.iter().map(|l| l.price).collect(); + assert_eq!(prices, vec![100.0, 101.0, 103.0]); + } + + #[test] + fn sub_tick_prices_share_a_bucket() { + let mut fp = Footprint::new(0.5).unwrap(); + // 100.24 and 100.26 both round to bucket 200 (price 100.0)... check: + // 100.24/0.5 = 200.48 -> 200; 100.26/0.5 = 200.52 -> 201. Distinct. + fp.update(trade(100.20, 1.0, Side::Buy)); // 200.4 -> 200 -> price 100.0 + let out = fp.update(trade(100.10, 2.0, Side::Buy)).unwrap(); // 200.2 -> 200 + assert_eq!(out.levels.len(), 1); + assert_eq!(out.levels[0].price, 100.0); + assert_eq!(out.levels[0].ask_vol, 3.0); + } + + #[test] + fn reset_clears_the_footprint() { + let mut fp = Footprint::new(1.0).unwrap(); + fp.update(trade(100.0, 5.0, Side::Buy)); + assert!(fp.is_ready()); + fp.reset(); + assert!(!fp.is_ready()); + let out = fp.update(trade(200.0, 1.0, Side::Sell)).unwrap(); + assert_eq!(out.levels.len(), 1); + assert_eq!(out.levels[0].price, 200.0); + assert_eq!(out.levels[0].bid_vol, 1.0); + } + + #[test] + fn batch_equals_streaming() { + let trades: Vec = (0..30) + .map(|i| { + let side = if i % 3 == 0 { Side::Sell } else { Side::Buy }; + trade(100.0 + f64::from(i % 5), 1.0 + f64::from(i % 4), side) + }) + .collect(); + let mut a = Footprint::new(1.0).unwrap(); + let mut b = Footprint::new(1.0).unwrap(); + assert_eq!( + a.batch(&trades), + trades.iter().map(|x| b.update(*x)).collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/mod.rs b/crates/wickra-core/src/indicators/mod.rs index f87df01c..18c33eab 100644 --- a/crates/wickra-core/src/indicators/mod.rs +++ b/crates/wickra-core/src/indicators/mod.rs @@ -73,6 +73,7 @@ mod evwma; mod fama; mod fibonacci_pivots; mod fisher_transform; +mod footprint; mod force_index; mod fractal_chaos_bands; mod frama; @@ -304,6 +305,7 @@ pub use evwma::Evwma; pub use fama::Fama; pub use fibonacci_pivots::{FibonacciPivots, FibonacciPivotsOutput}; pub use fisher_transform::FisherTransform; +pub use footprint::{Footprint, FootprintLevel, FootprintOutput}; pub use force_index::ForceIndex; pub use fractal_chaos_bands::{FractalChaosBands, FractalChaosBandsOutput}; pub use frama::Frama; @@ -746,6 +748,7 @@ pub const FAMILIES: &[(&str, &[&str])] = &[ "EffectiveSpread", "RealizedSpread", "KylesLambda", + "Footprint", ], ), ( @@ -802,6 +805,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, 226, "FAMILIES total drifted from indicator count"); + assert_eq!(total, 227, "FAMILIES total drifted from indicator count"); } } diff --git a/crates/wickra-core/src/lib.rs b/crates/wickra-core/src/lib.rs index d287df7b..2f33c4ce 100644 --- a/crates/wickra-core/src/lib.rs +++ b/crates/wickra-core/src/lib.rs @@ -59,19 +59,20 @@ pub use indicators::{ DonchianOutput, DonchianStop, DonchianStopOutput, DoubleBollinger, DoubleBollingerOutput, Dpo, DrawdownDuration, EaseOfMovement, EffectiveSpread, EhlersStochastic, ElderImpulse, Ema, EmpiricalModeDecomposition, Engulfing, Evwma, Fama, FibonacciPivots, FibonacciPivotsOutput, - FisherTransform, ForceIndex, FractalChaosBands, FractalChaosBandsOutput, Frama, GainLossRatio, - GarmanKlassVolatility, Hammer, HangingMan, Harami, HeikinAshi, HeikinAshiOutput, HiLoActivator, - HilbertDominantCycle, HistoricalVolatility, Hma, HurstChannel, HurstChannelOutput, - HurstExponent, Ichimoku, IchimokuOutput, Inertia, InformationRatio, InitialBalance, - InitialBalanceOutput, InstantaneousTrendline, InverseFisherTransform, InvertedHammer, Jma, - Kama, KellyCriterion, Keltner, KeltnerOutput, Kst, KstOutput, Kurtosis, Kvo, KylesLambda, - LaguerreRsi, LeadLagCrossCorrelation, LeadLagCrossCorrelationOutput, LinRegAngle, - LinRegChannel, LinRegChannelOutput, LinRegSlope, LinearRegression, MaEnvelope, - MaEnvelopeOutput, MacdIndicator, MacdOutput, Mama, MamaOutput, MarketFacilitationIndex, - Marubozu, MassIndex, MaxDrawdown, McGinleyDynamic, MedianAbsoluteDeviation, MedianPrice, Mfi, - Microprice, Mom, MorningEveningStar, Natr, Nvi, Obv, OmegaRatio, OpeningRange, - OpeningRangeOutput, OrderBookImbalanceFull, OrderBookImbalanceTop1, OrderBookImbalanceTopN, - PainIndex, PairSpreadZScore, PairwiseBeta, ParkinsonVolatility, PearsonCorrelation, PercentB, + FisherTransform, Footprint, FootprintOutput, ForceIndex, FractalChaosBands, + FractalChaosBandsOutput, Frama, GainLossRatio, GarmanKlassVolatility, Hammer, HangingMan, + Harami, HeikinAshi, HeikinAshiOutput, HiLoActivator, HilbertDominantCycle, + HistoricalVolatility, Hma, HurstChannel, HurstChannelOutput, HurstExponent, Ichimoku, + IchimokuOutput, Inertia, InformationRatio, InitialBalance, InitialBalanceOutput, + InstantaneousTrendline, InverseFisherTransform, InvertedHammer, Jma, Kama, KellyCriterion, + Keltner, KeltnerOutput, Kst, KstOutput, Kurtosis, Kvo, KylesLambda, LaguerreRsi, + LeadLagCrossCorrelation, LeadLagCrossCorrelationOutput, LinRegAngle, LinRegChannel, + LinRegChannelOutput, LinRegSlope, LinearRegression, MaEnvelope, MaEnvelopeOutput, + MacdIndicator, MacdOutput, Mama, MamaOutput, MarketFacilitationIndex, Marubozu, MassIndex, + MaxDrawdown, McGinleyDynamic, 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, RealizedSpread, RecoveryFactor, RelativeStrengthAB, RelativeStrengthOutput, RenkoTrailingStop, Roc, RogersSatchellVolatility, RollingVwap, @@ -90,6 +91,10 @@ pub use indicators::{ WilliamsFractalsOutput, WilliamsR, Wma, WoodiePivots, WoodiePivotsOutput, YangZhangVolatility, YoyoExit, ZScore, ZeroLagMacd, ZeroLagMacdOutput, ZigZag, ZigZagOutput, Zlema, FAMILIES, T3, }; +// `FootprintLevel` is a row element of `FootprintOutput`, re-exported on its own +// line so the indicator-count tooling (which scans the braced block above and +// strips only `*Output` companions) does not count it as a separate indicator. +pub use indicators::FootprintLevel; pub use microstructure::{Level, OrderBook, Side, Trade, TradeQuote}; pub use ohlcv::{Candle, Tick}; pub use traits::{BatchExt, Chain, Indicator}; diff --git a/fuzz/fuzz_targets/indicator_update_trade.rs b/fuzz/fuzz_targets/indicator_update_trade.rs index a436a745..898529e9 100644 --- a/fuzz/fuzz_targets/indicator_update_trade.rs +++ b/fuzz/fuzz_targets/indicator_update_trade.rs @@ -11,7 +11,8 @@ use libfuzzer_sys::fuzz_target; use wickra_core::{ - BatchExt, CumulativeVolumeDelta, Indicator, Side, SignedVolume, Trade, TradeImbalance, + BatchExt, CumulativeVolumeDelta, Footprint, Indicator, Side, SignedVolume, Trade, + TradeImbalance, }; #[inline(never)] @@ -42,4 +43,13 @@ fuzz_target!(|data: &[u8]| { drive(SignedVolume::new, &trades); drive(CumulativeVolumeDelta::new, &trades); drive(|| TradeImbalance::new(5).unwrap(), &trades); + + // Footprint emits a variable-length `FootprintOutput` rather than an `f64`, + // so it is driven directly rather than through the scalar-output helper. + let mut footprint = Footprint::new(0.5).unwrap(); + for &trade in &trades { + let _ = footprint.update(trade); + } + footprint.reset(); + let _ = Footprint::new(0.5).unwrap().batch(&trades); });