feat: footprint microstructure indicator (part 4 of 4) (#123)

This commit is contained in:
kingchenc
2026-06-01 20:00:58 +02:00
committed by GitHub
parent 4f11df0e33
commit 3dd7010129
18 changed files with 636 additions and 22 deletions
+6
View File
@@ -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
+5 -5
View File
@@ -1,5 +1,5 @@
<p align="center">
<a href="https://wickra.org"><img src="https://raw.githubusercontent.com/wickra-lib/.github/main/profile/wickra-banner.webp?v=231" alt="Wickra — streaming-first technical indicators" width="100%"></a>
<a href="https://wickra.org"><img src="https://raw.githubusercontent.com/wickra-lib/.github/main/profile/wickra-banner.webp?v=232" alt="Wickra — streaming-first technical indicators" width="100%"></a>
</p>
[![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/
@@ -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));
});
+15
View File
@@ -286,6 +286,12 @@ export interface ObSnapshot {
askPx: Array<number>
askSz: Array<number>
}
/** 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<FootprintLevelValue>
batch(price: Array<number>, size: Array<number>, isBuy: Array<boolean>): Array<Array<FootprintLevelValue>>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type SharpeRatioNode = SharpeRatio
export declare class SharpeRatio {
constructor(period: number, riskFree: number)
+2 -1
View File
@@ -310,7 +310,7 @@ if (!nativeBinding) {
throw new Error(`Failed to load native binding`)
}
const { version, SMA, EMA, WMA, RSI, DEMA, TEMA, HMA, ROC, TRIX, SMMA, TRIMA, ZLEMA, MOM, CMO, DPO, StdDev, UlcerIndex, VerticalHorizontalFilter, ZScore, McGinleyDynamic, FRAMA, SuperSmoother, FisherTransform, Decycler, CenterOfGravity, CyberneticCycle, InstantaneousTrendline, EhlersStochastic, RVIVolatility, Variance, CoefficientOfVariation, Skewness, Kurtosis, StandardError, DetrendedStdDev, RSquared, MedianAbsoluteDeviation, Autocorrelation, HurstExponent, PearsonCorrelation, Beta, PairwiseBeta, SpearmanCorrelation, PairSpreadZScore, LeadLagCrossCorrelation, Cointegration, RelativeStrengthAB, MACD, BollingerBands, ATR, Stochastic, OBV, ADX, ADXR, CCI, WilliamsR, MFI, PSAR, Keltner, Donchian, VWAP, RollingVWAP, AwesomeOscillator, Aroon, Inertia, ConnorsRSI, LaguerreRSI, SMI, KST, PGO, RVI, AwesomeOscillatorHistogram, STC, ElderImpulse, ZeroLagMACD, CFO, APO, KAMA, EVWMA, Alligator, JMA, VIDYA, ALMA, T3, TSI, PMO, TII, ADL, VolumePriceTrend, ChaikinMoneyFlow, ChaikinOscillator, ForceIndex, NVI, PVI, VolumeOscillator, KVO, WilliamsAD, AnchoredVWAP, DemandIndex, TSV, VZO, MarketFacilitationIndex, EaseOfMovement, SuperTrend, ChandelierExit, ChandeKrollStop, AtrTrailingStop, HiLoActivator, VoltyStop, YoyoExit, DonchianStop, PercentageTrailingStop, StepTrailingStop, RenkoTrailingStop, TypicalPrice, MedianPrice, WeightedClose, LinearRegression, LinRegSlope, AcceleratorOscillator, BalanceOfPower, ChoppinessIndex, TrueRange, ChaikinVolatility, YangZhangVolatility, RogersSatchellVolatility, GarmanKlassVolatility, ParkinsonVolatility, LinRegAngle, BollingerBandwidth, PercentB, NATR, HistoricalVolatility, AroonOscillator, WaveTrend, RWI, Vortex, MassIndex, StochRSI, UltimateOscillator, PPO, Coppock, VWMA, MaEnvelope, AccelerationBands, StarcBands, AtrBands, HurstChannel, LinRegChannel, StandardErrorBands, DoubleBollinger, TtmSqueeze, FractalChaosBands, VwapStdDevBands, ClassicPivots, FibonacciPivots, Camarilla, WoodiePivots, DemarkPivots, WilliamsFractals, ZigZag, TDSetup, TDSequential, TDDeMarker, TDREI, TDPressure, TDCombo, TDCountdown, TDLines, TDRangeProjection, TDDifferential, TDOpen, TDRiskLevel, InverseFisherTransform, DecyclerOscillator, RoofingFilter, EmpiricalModeDecomposition, HilbertDominantCycle, AdaptiveCycle, SineWave, MAMA, FAMA, Ichimoku, HeikinAshi, ValueArea, InitialBalance, OpeningRange, Doji, Hammer, InvertedHammer, HangingMan, ShootingStar, Engulfing, Harami, MorningEveningStar, ThreeSoldiersOrCrows, PiercingDarkCloud, Marubozu, Tweezer, SpinningTop, ThreeInside, ThreeOutside, 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
+88
View File
@@ -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<FootprintLevelValue> {
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<Self> {
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<Vec<FootprintLevelValue>> {
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<f64>,
size: Vec<f64>,
is_buy: Vec<bool>,
) -> napi::Result<Vec<Vec<FootprintLevelValue>>> {
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
@@ -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",
+89
View File
@@ -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<f64>> {
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<Self> {
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<Bound<'py, PyArray2<f64>>> {
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<f64>,
size: Vec<f64>,
is_buy: Vec<bool>,
) -> PyResult<Vec<Bound<'py, PyArray2<f64>>>> {
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::<PyEffectiveSpread>()?;
m.add_class::<PyRealizedSpread>()?;
m.add_class::<PyKylesLambda>()?;
// Microstructure: footprint.
m.add_class::<PyFootprint>()?;
// Family 15: Risk / Performance metrics.
m.add_class::<PySharpeRatio>()?;
m.add_class::<PySortinoRatio>()?;
@@ -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)
@@ -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)
+11
View File
@@ -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)"
@@ -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])
+13
View File
@@ -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
+49 -1
View File
@@ -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<WasmFootprint, JsError> {
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<JsValue, JsError> {
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::*;
@@ -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<FootprintLevel>,
}
/// 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<i64, (f64, f64)>,
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<Self> {
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<FootprintOutput> {
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<f64> = 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<Trade> = (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::<Vec<_>>()
);
}
}
+4 -1
View File
@@ -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");
}
}
+18 -13
View File
@@ -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};
+11 -1
View File
@@ -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);
});