diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7d101da8..bd08a3aa 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,25 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
+- **Roll Measure** — effective spread implied by the negative serial covariance of trade-price changes (Roll 1984) (`RollMeasure`).
+- **Amihud Illiquidity** — average absolute log return per unit of traded value (price-impact liquidity proxy, Amihud 2002) (`AmihudIlliquidity`).
+- **VPIN** — volume-synchronised probability of informed trading (volume-bucketed order-flow toxicity) (`Vpin`).
+- **Order Flow Imbalance** — rolling sum of best-level order-flow events (Cont-Kukanov-Stoikov OFI) (`OrderFlowImbalance`).
+- **Expectancy** — expected return per unit of average loss (R-multiple) over a rolling window of returns (`Expectancy`).
+- **Win Rate** — fraction of strictly-positive returns over a rolling window (`WinRate`).
+- **Regime Label** — volatility-quantile regime classification: −1 calm / 0 normal / +1 stressed, by where the rolling volatility sits in its own recent distribution (`RegimeLabel`).
+- **Jump Indicator** — flags return outliers beyond `threshold ×` trailing return volatility (−1 down / 0 / +1 up) (`JumpIndicator`).
+- **Trend Label** — discrete trend state from the sign of the rolling least-squares slope (−1 / 0 / +1) (`TrendLabel`).
+- **High-Low Range** — bar high-low range as a fraction of close (scale-free per-bar volatility) (`HighLowRange`).
+- **Wick Ratio** — signed upper-vs-lower shadow imbalance as a fraction of the range (`WickRatio`).
+- **Body Size Percent** — absolute candle body as a fraction of the bar range (`BodySizePct`).
+- **Close vs Open** — signed body as a fraction of the open price, `(close − open) / open` (`CloseVsOpen`).
+- **Spread AR(1) Coefficient** — first-order autoregression coefficient of the spread `a − b` (direct cointegration / mean-reversion strength) (`SpreadAr1Coefficient`).
+- **Rolling Quantile** — interpolated q-th quantile over a trailing window (type-7 / NumPy default) (`RollingQuantile`).
+- **Rolling Percentile Rank** — percentile rank of the latest value within its trailing window (`RollingPercentileRank`).
+- **Rolling IQR** — interquartile range (Q3 − Q1) over a trailing window (robust dispersion) (`RollingIqr`).
+- **Realized Volatility** — square root of the summed squared log returns (raw, un-annualised quadratic variation) (`RealizedVolatility`).
+- **Log Return** — logarithmic return over a fixed lag, `ln(price_t / price_{t−period})` (`LogReturn`).
## [0.5.3] - 2026-06-04
- **Fibonacci Time Zones** — vertical markers at Fibonacci bar-distances (1/2/3/5/8/...) from the latest swing pivot (`FIB_TIME_ZONES`).
diff --git a/README.md b/README.md
index e5c0c218..47ee4cdd 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,5 @@
-
+
[](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml)
@@ -48,7 +48,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 377 indicators; start at the
+ every one of the 396 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),
@@ -136,7 +136,7 @@ python -m benchmarks.compare_libraries
## Indicators
-377 streaming-first indicators across twenty-four families. Every one passes the
+396 streaming-first indicators across twenty-four 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).
@@ -151,7 +151,7 @@ warmup) at [docs.wickra.org](https://docs.wickra.org/Indicators-Overview).
| Bands & Channels | MA Envelope, Acceleration Bands, STARC Bands, ATR Bands, Hurst Channel, LinReg Channel, Standard Error Bands, Double Bollinger Bands, TTM Squeeze, Fractal Chaos Bands, VWAP StdDev Bands |
| Trailing Stops | Parabolic SAR, Parabolic SAR Extended (SAREXT), SuperTrend, Chandelier Exit, Chande Kroll Stop, ATR Trailing Stop, HiLo Activator, Volty Stop, Yo-Yo Exit, Donchian Channel Stop, Percentage Trailing Stop, Step Trailing Stop, Renko Trailing Stop |
| Volume | OBV, VWAP (cumulative + rolling), ADL, Volume-Price Trend, Chaikin Money Flow, Chaikin Oscillator, Force Index, Ease of Movement, Klinger Volume Oscillator, Volume Oscillator, NVI, PVI, Williams A/D, Anchored VWAP, Demand Index, TSV, VZO, Market Facilitation Index |
-| Price Statistics | Typical Price, Median Price, Weighted Close, Linear Regression, Linear Regression Slope, Z-Score, Linear Regression Angle, Variance, Coefficient of Variation, Skewness, Kurtosis, Standard Error, Detrended StdDev, R², Median Absolute Deviation, Autocorrelation, Hurst Exponent, Pearson Correlation, Beta, Pairwise Beta, Pair Spread Z-Score, Lead-Lag Cross-Correlation, Cointegration, Relative Strength A-vs-B, Spearman Correlation, Mid Price, Mid Point, Average Price, Linear Regression Intercept, Time Series Forecast, Rolling Correlation, Rolling Covariance, OU Half-Life, Spread Hurst, Distance SSD, Beta-Neutral Spread, Variance Ratio, Granger Causality, Kalman Hedge Ratio, Spread Bollinger Bands |
+| Price Statistics | Typical Price, Median Price, Weighted Close, Linear Regression, Linear Regression Slope, Z-Score, Linear Regression Angle, Variance, Coefficient of Variation, Skewness, Kurtosis, Standard Error, Detrended StdDev, R², Median Absolute Deviation, Autocorrelation, Hurst Exponent, Pearson Correlation, Beta, Pairwise Beta, Pair Spread Z-Score, Lead-Lag Cross-Correlation, Cointegration, Relative Strength A-vs-B, Spearman Correlation, Mid Price, Mid Point, Average Price, Linear Regression Intercept, Time Series Forecast, Rolling Correlation, Rolling Covariance, OU Half-Life, Spread Hurst, Distance SSD, Beta-Neutral Spread, Variance Ratio, Granger Causality, Kalman Hedge Ratio, Spread Bollinger Bands, Spread AR(1) Coefficient |
| Ehlers / Cycle (DSP) | MAMA, FAMA, Fisher Transform, Inverse Fisher Transform, SuperSmoother, Hilbert Dominant Cycle, Hilbert Phasor, Hilbert DC Phase, Hilbert Trend Mode, Sine Wave, Decycler, Decycler Oscillator, Roofing Filter, Center of Gravity, Cybernetic Cycle, Adaptive Cycle, Empirical Mode Decomposition, Ehlers Stochastic, Instantaneous Trendline |
| Pivots & S/R | Classic Pivots, Fibonacci Pivots, Camarilla, Woodie Pivots, DeMark Pivots, Williams Fractals, ZigZag |
| 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 |
@@ -161,7 +161,7 @@ warmup) at [docs.wickra.org](https://docs.wickra.org/Indicators-Overview).
| Chart Patterns | Double Top / Bottom, Triple Top / Bottom, Head and Shoulders, Triangle (asc/desc/sym), Wedge (rising/falling), Flag / Pennant, Rectangle / Range, Cup and Handle |
| Harmonic Patterns | AB=CD, Gartley, Butterfly, Bat, Crab, Shark, Cypher, Three Drives |
| Fibonacci | Fibonacci Retracement, Fibonacci Extension, Fibonacci Projection, Auto-Fibonacci, Golden Pocket, Fibonacci Confluence, Fibonacci Fan, Fibonacci Arcs, Fibonacci Channel, Fibonacci Time Zones |
-| 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 |
+| 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, Order Flow Imbalance, VPIN, Amihud Illiquidity, Roll Measure |
| Derivatives | Funding Rate, Funding Rate Mean, Funding Rate Z-Score, Funding Basis, Open-Interest Delta, OI / Price Divergence, OI-Weighted Price, Long/Short Ratio, Taker Buy/Sell Ratio, Liquidation Features, Term-Structure Basis, Calendar Spread |
| Market Profile | Value Area (POC / VAH / VAL), Volume Profile (histogram), TPO Profile, Initial Balance, Opening Range |
| Market Breadth | Advance/Decline Line, Advance/Decline Ratio, Advance/Decline Volume Line, McClellan Oscillator, McClellan Summation Index, TRIN / Arms Index, Breadth Thrust, New Highs - New Lows, High-Low Index, Percent Above Moving Average, Up/Down Volume Ratio, Bullish Percent Index, Cumulative Volume Index, Absolute Breadth Index, TICK Index |
@@ -245,7 +245,7 @@ A Python live-trading example using the public `websockets` package lives at
```
wickra/
├── crates/
-│ ├── wickra-core/ core engine + all 377 indicators
+│ ├── wickra-core/ core engine + all 396 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 03112995..cbc564f8 100644
--- a/bindings/node/__tests__/indicators.test.js
+++ b/bindings/node/__tests__/indicators.test.js
@@ -28,6 +28,16 @@ function num(v) {
// --- Scalar indicators: update(value) vs batch(prices) ---
const scalarFactories = {
+ Expectancy: () => new wickra.Expectancy(20),
+ WinRate: () => new wickra.WinRate(20),
+ RegimeLabel: () => new wickra.RegimeLabel(5, 20),
+ JumpIndicator: () => new wickra.JumpIndicator(20, 3.0),
+ TrendLabel: () => new wickra.TrendLabel(10),
+ RollingQuantile: () => new wickra.RollingQuantile(20, 0.5),
+ RollingPercentileRank: () => new wickra.RollingPercentileRank(14),
+ RollingIqr: () => new wickra.RollingIqr(14),
+ RealizedVolatility: () => new wickra.RealizedVolatility(20),
+ LogReturn: () => new wickra.LogReturn(1),
TSF: () => new wickra.TSF(14),
LINEARREG_INTERCEPT: () => new wickra.LINEARREG_INTERCEPT(14),
ROCR100: () => new wickra.ROCR100(10),
@@ -313,6 +323,10 @@ const candleScalar = {
Shark: { make: () => new wickra.Shark(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
Cypher: { make: () => new wickra.Cypher(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
ThreeDrives: { make: () => new wickra.ThreeDrives(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
+ CloseVsOpen: { make: () => new wickra.CloseVsOpen(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
+ BodySizePct: { make: () => new wickra.BodySizePct(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
+ WickRatio: { make: () => new wickra.WickRatio(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
+ HighLowRange: { make: () => new wickra.HighLowRange(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
};
for (const [name, d] of Object.entries(candleScalar)) {
@@ -563,6 +577,7 @@ const pairFactories = {
BetaNeutralSpread: () => new wickra.BetaNeutralSpread(20),
VarianceRatio: () => new wickra.VarianceRatio(60, 2),
GrangerCausality: () => new wickra.GrangerCausality(60, 1),
+ SpreadAr1Coefficient: () => new wickra.SpreadAr1Coefficient(40),
};
for (const [name, make] of Object.entries(pairFactories)) {
@@ -1126,6 +1141,57 @@ test('trade-flow rejects bad input', () => {
assert.throws(() => new wickra.SignedVolume().update(100, -1, true));
});
+test('order-flow imbalance reference + streaming matches batch', () => {
+ // Rising bid (px up, size 6) with an unchanged ask -> +6 flow.
+ const ofi = new wickra.OrderFlowImbalance(1);
+ assert.equal(ofi.update([100], [5], [101], [4]), null); // seeds the reference
+ assert.ok(Math.abs(ofi.update([100.5], [6], [101], [4]) - 6.0) < 1e-12);
+ const snaps = Array.from({ length: 30 }, (_, i) => ({
+ bidPx: [100 + Math.sin(i * 0.3)],
+ bidSz: [5 + Math.abs(Math.cos(i * 0.5))],
+ askPx: [101 + Math.sin(i * 0.3)],
+ askSz: [4 + Math.abs(Math.sin(i * 0.4))],
+ }));
+ const batch = new wickra.OrderFlowImbalance(10).batch(snaps);
+ const streamer = new wickra.OrderFlowImbalance(10);
+ 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((Number.isNaN(batch[i]) && s === null) || Math.abs(s - batch[i]) < 1e-9, `mismatch at ${i}`);
+ }
+});
+
+test('vpin / amihud / roll reference + streaming matches batch', () => {
+ // VPIN: two pure-buy buckets of size 10 -> imbalance == size -> 1.
+ const v = new wickra.Vpin(10, 2);
+ let last;
+ for (let i = 0; i < 4; i++) last = v.update(100, 5, true);
+ assert.equal(last, 1.0);
+ // Amihud(1): |ln(101/100)| / (101 * 10).
+ const a = new wickra.AmihudIlliquidity(1);
+ assert.equal(a.update(100, 10, true), null);
+ assert.ok(Math.abs(a.update(101, 10, true) - Math.abs(Math.log(101 / 100)) / (101 * 10)) < 1e-15);
+ // Roll(6): a clean bid-ask bounce of ±1 implies a spread of 2.
+ const r = new wickra.RollMeasure(6);
+ let roll = null;
+ for (let i = 0; i < 20; i++) roll = r.update(i % 2 === 0 ? 100 : 101, 1, true);
+ assert.ok(Math.abs(roll - 2.0) < 1e-12);
+ // Streaming-vs-batch for the three trade-input indicators.
+ const n = 40;
+ const price = Array.from({ length: n }, (_, i) => 100 + Math.sin(i * 0.25) * 4);
+ const size = Array.from({ length: n }, (_, i) => 1 + (i % 5));
+ const isBuy = Array.from({ length: n }, (_, i) => i % 2 === 0);
+ for (const make of [() => new wickra.Vpin(8, 5), () => new wickra.AmihudIlliquidity(14), () => new wickra.RollMeasure(14)]) {
+ const batch = make().batch(price, size, isBuy);
+ const streamer = make();
+ assert.equal(batch.length, n);
+ for (let i = 0; i < n; i++) {
+ const s = streamer.update(price[i], size[i], isBuy[i]);
+ assert.ok((Number.isNaN(batch[i]) && s === null) || Math.abs(s - batch[i]) < 1e-9, `mismatch at ${i}`);
+ }
+ }
+});
+
test('price-impact indicators reference values', () => {
// Buy at 100.05 vs mid 100.0: 2 * (100.05 - 100) / 100 * 10000 = 10 bps.
assert.ok(Math.abs(new wickra.EffectiveSpread().update(100.05, 1, true, 100.0) - 10.0) < 1e-9);
diff --git a/bindings/node/index.d.ts b/bindings/node/index.d.ts
index 044e4538..81bed00e 100644
--- a/bindings/node/index.d.ts
+++ b/bindings/node/index.d.ts
@@ -809,6 +809,96 @@ export declare class TSF {
isReady(): boolean
warmupPeriod(): number
}
+export type LogReturnNode = LogReturn
+export declare class LogReturn {
+ constructor(period: number)
+ update(value: number): number | null
+ batch(prices: Array): Array
+ reset(): void
+ isReady(): boolean
+ warmupPeriod(): number
+}
+export type RealizedVolatilityNode = RealizedVolatility
+export declare class RealizedVolatility {
+ constructor(period: number)
+ update(value: number): number | null
+ batch(prices: Array): Array
+ reset(): void
+ isReady(): boolean
+ warmupPeriod(): number
+}
+export type RollingIqrNode = RollingIqr
+export declare class RollingIqr {
+ constructor(period: number)
+ update(value: number): number | null
+ batch(prices: Array): Array
+ reset(): void
+ isReady(): boolean
+ warmupPeriod(): number
+}
+export type RollingPercentileRankNode = RollingPercentileRank
+export declare class RollingPercentileRank {
+ constructor(period: number)
+ update(value: number): number | null
+ batch(prices: Array): Array
+ reset(): void
+ isReady(): boolean
+ warmupPeriod(): number
+}
+export type TrendLabelNode = TrendLabel
+export declare class TrendLabel {
+ constructor(period: number)
+ update(value: number): number | null
+ batch(prices: Array): Array
+ reset(): void
+ isReady(): boolean
+ warmupPeriod(): number
+}
+export type WinRateNode = WinRate
+export declare class WinRate {
+ constructor(period: number)
+ update(value: number): number | null
+ batch(prices: Array): Array
+ reset(): void
+ isReady(): boolean
+ warmupPeriod(): number
+}
+export type ExpectancyNode = Expectancy
+export declare class Expectancy {
+ constructor(period: number)
+ update(value: number): number | null
+ batch(prices: Array): Array
+ reset(): void
+ isReady(): boolean
+ warmupPeriod(): number
+}
+export type JumpIndicatorNode = JumpIndicator
+export declare class JumpIndicator {
+ constructor(period: number, threshold: number)
+ update(value: number): number | null
+ batch(prices: Array): Array
+ reset(): void
+ isReady(): boolean
+ warmupPeriod(): number
+}
+export type RegimeLabelNode = RegimeLabel
+export declare class RegimeLabel {
+ constructor(volPeriod: number, lookback: number)
+ update(value: number): number | null
+ batch(prices: Array): Array
+ reset(): void
+ isReady(): boolean
+ warmupPeriod(): number
+}
+export type RollingQuantileNode = RollingQuantile
+export declare class RollingQuantile {
+ constructor(period: number, quantile: number)
+ update(value: number): number | null
+ batch(prices: Array): Array
+ reset(): void
+ isReady(): boolean
+ warmupPeriod(): number
+}
export type AutocorrelationNode = Autocorrelation
export declare class Autocorrelation {
constructor(period: number, lag: number)
@@ -866,6 +956,19 @@ export declare class PairwiseBeta {
isReady(): boolean
warmupPeriod(): number
}
+export type SpreadAr1CoefficientNode = SpreadAr1Coefficient
+export declare class SpreadAr1Coefficient {
+ constructor(period: number)
+ update(x: number, y: number): number | null
+ /**
+ * Batch over two equally-sized arrays. Returns a length-`n` array
+ * with `NaN` for warmup positions.
+ */
+ batch(x: Array, y: Array): Array
+ reset(): void
+ isReady(): boolean
+ warmupPeriod(): number
+}
export type SpearmanCorrelationNode = SpearmanCorrelation
export declare class SpearmanCorrelation {
constructor(period: number)
@@ -1230,6 +1333,42 @@ export declare class HT_PHASOR {
isReady(): boolean
warmupPeriod(): number
}
+export type CloseVsOpenNode = CloseVsOpen
+export declare class CloseVsOpen {
+ constructor()
+ update(open: number, high: number, low: number, close: number): number | null
+ batch(open: Array, high: Array, low: Array, close: Array): Array
+ reset(): void
+ isReady(): boolean
+ warmupPeriod(): number
+}
+export type BodySizePctNode = BodySizePct
+export declare class BodySizePct {
+ constructor()
+ update(open: number, high: number, low: number, close: number): number | null
+ batch(open: Array, high: Array, low: Array, close: Array): Array
+ reset(): void
+ isReady(): boolean
+ warmupPeriod(): number
+}
+export type WickRatioNode = WickRatio
+export declare class WickRatio {
+ constructor()
+ update(open: number, high: number, low: number, close: number): number | null
+ batch(open: Array, high: Array, low: Array, close: Array): Array
+ reset(): void
+ isReady(): boolean
+ warmupPeriod(): number
+}
+export type HighLowRangeNode = HighLowRange
+export declare class HighLowRange {
+ constructor()
+ update(open: number, high: number, low: number, close: number): number | null
+ batch(open: Array, high: Array, low: Array, close: Array): Array
+ reset(): void
+ isReady(): boolean
+ warmupPeriod(): number
+}
export type StochNode = Stochastic
export declare class Stochastic {
constructor(kPeriod: number, dPeriod: number)
@@ -3317,6 +3456,42 @@ export declare class TradeImbalance {
isReady(): boolean
warmupPeriod(): number
}
+export type OrderFlowImbalanceNode = OrderFlowImbalance
+export declare class OrderFlowImbalance {
+ constructor(period: number)
+ update(bidPx: Array, bidSz: Array, askPx: Array, askSz: Array): number | null
+ batch(snapshots: Array): Array
+ reset(): void
+ isReady(): boolean
+ warmupPeriod(): number
+}
+export type VpinNode = Vpin
+export declare class Vpin {
+ constructor(bucketVolume: number, numBuckets: number)
+ update(price: number, size: number, isBuy: boolean): number | null
+ batch(price: Array, size: Array, isBuy: Array): Array
+ reset(): void
+ isReady(): boolean
+ warmupPeriod(): number
+}
+export type AmihudIlliquidityNode = AmihudIlliquidity
+export declare class AmihudIlliquidity {
+ constructor(period: number)
+ update(price: number, size: number, isBuy: boolean): number | null
+ batch(price: Array, size: Array, isBuy: Array): Array
+ reset(): void
+ isReady(): boolean
+ warmupPeriod(): number
+}
+export type RollMeasureNode = RollMeasure
+export declare class RollMeasure {
+ constructor(period: number)
+ update(price: number, size: number, isBuy: boolean): number | null
+ batch(price: Array, size: Array, isBuy: Array): Array
+ reset(): void
+ isReady(): boolean
+ warmupPeriod(): number
+}
export type EffectiveSpreadNode = EffectiveSpread
export declare class EffectiveSpread {
constructor()
diff --git a/bindings/node/index.js b/bindings/node/index.js
index fcef2d46..e359a2c8 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, MIDPOINT, ROCP, ROCR, ROCR100, LINEARREG_INTERCEPT, TSF, Autocorrelation, HurstExponent, PearsonCorrelation, Beta, PairwiseBeta, SpearmanCorrelation, RollingCorrelation, RollingCovariance, OuHalfLife, SpreadHurst, DistanceSsd, BetaNeutralSpread, PairSpreadZScore, LeadLagCrossCorrelation, Cointegration, RelativeStrengthAB, VarianceRatio, GrangerCausality, KalmanHedgeRatio, SpreadBollingerBands, MACD, MACDFIX, MACDEXT, BollingerBands, ATR, PLUS_DM, MINUS_DM, PLUS_DI, MINUS_DI, DX, MIDPRICE, AVGPRICE, SAREXT, HT_PHASOR, 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, AnchoredRSI, 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, HT_DCPHASE, HT_TRENDMODE, HilbertDominantCycle, AdaptiveCycle, SineWave, MAMA, FAMA, Ichimoku, HeikinAshi, ValueArea, VolumeProfile, TpoProfile, InitialBalance, OpeningRange, Doji, Hammer, InvertedHammer, HangingMan, ShootingStar, Engulfing, Harami, MorningEveningStar, ThreeSoldiersOrCrows, PiercingDarkCloud, Marubozu, Tweezer, SpinningTop, ThreeInside, ThreeOutside, TwoCrows, UpsideGapTwoCrows, IdenticalThreeCrows, ThreeLineStrike, ThreeStarsInSouth, AbandonedBaby, AdvanceBlock, BeltHold, Breakaway, Counterattack, DojiStar, DragonflyDoji, GravestoneDoji, LongLeggedDoji, RickshawMan, EveningDojiStar, MorningDojiStar, GapSideBySideWhite, HighWave, Hikkake, HikkakeModified, HomingPigeon, OnNeck, InNeck, Thrusting, SeparatingLines, Kicking, KickingByLength, LadderBottom, MatHold, MatchingLow, LongLine, ShortLine, RisingThreeMethods, FallingThreeMethods, UpsideGapThreeMethods, DownsideGapThreeMethods, StalledPattern, StickSandwich, Takuri, ClosingMarubozu, OpeningMarubozu, TasukiGap, UniqueThreeRiver, ConcealingBabySwallow, DoubleTopBottom, TripleTopBottom, HeadAndShoulders, Triangle, Wedge, FlagPennant, RectangleRange, CupAndHandle, Abcd, Gartley, Butterfly, Bat, Crab, Shark, Cypher, ThreeDrives, OrderBookImbalanceTop1, OrderBookImbalanceFull, Microprice, QuotedSpread, DepthSlope, OrderBookImbalanceTopN, SignedVolume, CumulativeVolumeDelta, TradeImbalance, EffectiveSpread, RealizedSpread, KylesLambda, Footprint, FundingRate, FundingRateMean, FundingRateZScore, FundingBasis, OpenInterestDelta, OIPriceDivergence, OIWeighted, LongShortRatio, TakerBuySellRatio, LiquidationFeatures, TermStructureBasis, CalendarSpread, AdvanceDecline, AdvanceDeclineRatio, AdVolumeLine, McClellanOscillator, McClellanSummationIndex, Trin, BreadthThrust, NewHighsNewLows, HighLowIndex, PercentAboveMa, UpDownVolumeRatio, BullishPercentIndex, CumulativeVolumeIndex, AbsoluteBreadthIndex, TickIndex, SharpeRatio, SortinoRatio, CalmarRatio, OmegaRatio, MaxDrawdown, AverageDrawdown, DrawdownDuration, PainIndex, ValueAtRisk, ConditionalValueAtRisk, ProfitFactor, GainLossRatio, RecoveryFactor, KellyCriterion, TreynorRatio, InformationRatio, RenkoBars, KagiBars, PointAndFigureBars, Alpha, SessionVwap, OvernightGap, SeasonalZScore, TimeOfDayReturnProfile, IntradayVolatilityProfile, VolumeByTimeProfile, DayOfWeekProfile, AverageDailyRange, TurnOfMonth, SessionHighLow, SessionRange, OvernightIntradayReturn, FibRetracement, FibExtension, FibProjection, AutoFib, GoldenPocket, FibConfluence, FibFan, FibArcs, FibChannel, FibTimeZones } = 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, MIDPOINT, ROCP, ROCR, ROCR100, LINEARREG_INTERCEPT, TSF, LogReturn, RealizedVolatility, RollingIqr, RollingPercentileRank, TrendLabel, WinRate, Expectancy, JumpIndicator, RegimeLabel, RollingQuantile, Autocorrelation, HurstExponent, PearsonCorrelation, Beta, PairwiseBeta, SpreadAr1Coefficient, SpearmanCorrelation, RollingCorrelation, RollingCovariance, OuHalfLife, SpreadHurst, DistanceSsd, BetaNeutralSpread, PairSpreadZScore, LeadLagCrossCorrelation, Cointegration, RelativeStrengthAB, VarianceRatio, GrangerCausality, KalmanHedgeRatio, SpreadBollingerBands, MACD, MACDFIX, MACDEXT, BollingerBands, ATR, PLUS_DM, MINUS_DM, PLUS_DI, MINUS_DI, DX, MIDPRICE, AVGPRICE, SAREXT, HT_PHASOR, CloseVsOpen, BodySizePct, WickRatio, HighLowRange, 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, AnchoredRSI, 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, HT_DCPHASE, HT_TRENDMODE, HilbertDominantCycle, AdaptiveCycle, SineWave, MAMA, FAMA, Ichimoku, HeikinAshi, ValueArea, VolumeProfile, TpoProfile, InitialBalance, OpeningRange, Doji, Hammer, InvertedHammer, HangingMan, ShootingStar, Engulfing, Harami, MorningEveningStar, ThreeSoldiersOrCrows, PiercingDarkCloud, Marubozu, Tweezer, SpinningTop, ThreeInside, ThreeOutside, TwoCrows, UpsideGapTwoCrows, IdenticalThreeCrows, ThreeLineStrike, ThreeStarsInSouth, AbandonedBaby, AdvanceBlock, BeltHold, Breakaway, Counterattack, DojiStar, DragonflyDoji, GravestoneDoji, LongLeggedDoji, RickshawMan, EveningDojiStar, MorningDojiStar, GapSideBySideWhite, HighWave, Hikkake, HikkakeModified, HomingPigeon, OnNeck, InNeck, Thrusting, SeparatingLines, Kicking, KickingByLength, LadderBottom, MatHold, MatchingLow, LongLine, ShortLine, RisingThreeMethods, FallingThreeMethods, UpsideGapThreeMethods, DownsideGapThreeMethods, StalledPattern, StickSandwich, Takuri, ClosingMarubozu, OpeningMarubozu, TasukiGap, UniqueThreeRiver, ConcealingBabySwallow, DoubleTopBottom, TripleTopBottom, HeadAndShoulders, Triangle, Wedge, FlagPennant, RectangleRange, CupAndHandle, Abcd, Gartley, Butterfly, Bat, Crab, Shark, Cypher, ThreeDrives, OrderBookImbalanceTop1, OrderBookImbalanceFull, Microprice, QuotedSpread, DepthSlope, OrderBookImbalanceTopN, SignedVolume, CumulativeVolumeDelta, TradeImbalance, OrderFlowImbalance, Vpin, AmihudIlliquidity, RollMeasure, EffectiveSpread, RealizedSpread, KylesLambda, Footprint, FundingRate, FundingRateMean, FundingRateZScore, FundingBasis, OpenInterestDelta, OIPriceDivergence, OIWeighted, LongShortRatio, TakerBuySellRatio, LiquidationFeatures, TermStructureBasis, CalendarSpread, AdvanceDecline, AdvanceDeclineRatio, AdVolumeLine, McClellanOscillator, McClellanSummationIndex, Trin, BreadthThrust, NewHighsNewLows, HighLowIndex, PercentAboveMa, UpDownVolumeRatio, BullishPercentIndex, CumulativeVolumeIndex, AbsoluteBreadthIndex, TickIndex, SharpeRatio, SortinoRatio, CalmarRatio, OmegaRatio, MaxDrawdown, AverageDrawdown, DrawdownDuration, PainIndex, ValueAtRisk, ConditionalValueAtRisk, ProfitFactor, GainLossRatio, RecoveryFactor, KellyCriterion, TreynorRatio, InformationRatio, RenkoBars, KagiBars, PointAndFigureBars, Alpha, SessionVwap, OvernightGap, SeasonalZScore, TimeOfDayReturnProfile, IntradayVolatilityProfile, VolumeByTimeProfile, DayOfWeekProfile, AverageDailyRange, TurnOfMonth, SessionHighLow, SessionRange, OvernightIntradayReturn, FibRetracement, FibExtension, FibProjection, AutoFib, GoldenPocket, FibConfluence, FibFan, FibArcs, FibChannel, FibTimeZones } = nativeBinding
module.exports.version = version
module.exports.SMA = SMA
@@ -356,11 +356,22 @@ module.exports.ROCR = ROCR
module.exports.ROCR100 = ROCR100
module.exports.LINEARREG_INTERCEPT = LINEARREG_INTERCEPT
module.exports.TSF = TSF
+module.exports.LogReturn = LogReturn
+module.exports.RealizedVolatility = RealizedVolatility
+module.exports.RollingIqr = RollingIqr
+module.exports.RollingPercentileRank = RollingPercentileRank
+module.exports.TrendLabel = TrendLabel
+module.exports.WinRate = WinRate
+module.exports.Expectancy = Expectancy
+module.exports.JumpIndicator = JumpIndicator
+module.exports.RegimeLabel = RegimeLabel
+module.exports.RollingQuantile = RollingQuantile
module.exports.Autocorrelation = Autocorrelation
module.exports.HurstExponent = HurstExponent
module.exports.PearsonCorrelation = PearsonCorrelation
module.exports.Beta = Beta
module.exports.PairwiseBeta = PairwiseBeta
+module.exports.SpreadAr1Coefficient = SpreadAr1Coefficient
module.exports.SpearmanCorrelation = SpearmanCorrelation
module.exports.RollingCorrelation = RollingCorrelation
module.exports.RollingCovariance = RollingCovariance
@@ -390,6 +401,10 @@ module.exports.MIDPRICE = MIDPRICE
module.exports.AVGPRICE = AVGPRICE
module.exports.SAREXT = SAREXT
module.exports.HT_PHASOR = HT_PHASOR
+module.exports.CloseVsOpen = CloseVsOpen
+module.exports.BodySizePct = BodySizePct
+module.exports.WickRatio = WickRatio
+module.exports.HighLowRange = HighLowRange
module.exports.Stochastic = Stochastic
module.exports.OBV = OBV
module.exports.ADX = ADX
@@ -617,6 +632,10 @@ module.exports.OrderBookImbalanceTopN = OrderBookImbalanceTopN
module.exports.SignedVolume = SignedVolume
module.exports.CumulativeVolumeDelta = CumulativeVolumeDelta
module.exports.TradeImbalance = TradeImbalance
+module.exports.OrderFlowImbalance = OrderFlowImbalance
+module.exports.Vpin = Vpin
+module.exports.AmihudIlliquidity = AmihudIlliquidity
+module.exports.RollMeasure = RollMeasure
module.exports.EffectiveSpread = EffectiveSpread
module.exports.RealizedSpread = RealizedSpread
module.exports.KylesLambda = KylesLambda
diff --git a/bindings/node/src/lib.rs b/bindings/node/src/lib.rs
index 8436e171..f7874f37 100644
--- a/bindings/node/src/lib.rs
+++ b/bindings/node/src/lib.rs
@@ -182,6 +182,125 @@ node_scalar_indicator!(
wc::LinRegIntercept
);
node_scalar_indicator!(TsfNode, "TSF", wc::Tsf);
+node_scalar_indicator!(LogReturnNode, "LogReturn", wc::LogReturn);
+node_scalar_indicator!(
+ RealizedVolatilityNode,
+ "RealizedVolatility",
+ wc::RealizedVolatility
+);
+node_scalar_indicator!(RollingIqrNode, "RollingIqr", wc::RollingIqr);
+node_scalar_indicator!(
+ RollingPercentileRankNode,
+ "RollingPercentileRank",
+ wc::RollingPercentileRank
+);
+node_scalar_indicator!(TrendLabelNode, "TrendLabel", wc::TrendLabel);
+node_scalar_indicator!(WinRateNode, "WinRate", wc::WinRate);
+node_scalar_indicator!(ExpectancyNode, "Expectancy", wc::Expectancy);
+#[napi(js_name = "JumpIndicator")]
+pub struct JumpIndicatorNode {
+ inner: wc::JumpIndicator,
+}
+
+#[napi]
+impl JumpIndicatorNode {
+ #[napi(constructor)]
+ pub fn new(period: u32, threshold: f64) -> napi::Result {
+ Ok(Self {
+ inner: wc::JumpIndicator::new(period as usize, threshold).map_err(map_err)?,
+ })
+ }
+ #[napi]
+ pub fn update(&mut self, value: f64) -> Option {
+ self.inner.update(value)
+ }
+ #[napi]
+ pub fn batch(&mut self, prices: Vec) -> Vec {
+ flatten(self.inner.batch(&prices))
+ }
+ #[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
+ }
+}
+
+#[napi(js_name = "RegimeLabel")]
+pub struct RegimeLabelNode {
+ inner: wc::RegimeLabel,
+}
+
+#[napi]
+impl RegimeLabelNode {
+ #[napi(constructor)]
+ pub fn new(vol_period: u32, lookback: u32) -> napi::Result {
+ Ok(Self {
+ inner: wc::RegimeLabel::new(vol_period as usize, lookback as usize).map_err(map_err)?,
+ })
+ }
+ #[napi]
+ pub fn update(&mut self, value: f64) -> Option {
+ self.inner.update(value)
+ }
+ #[napi]
+ pub fn batch(&mut self, prices: Vec) -> Vec {
+ flatten(self.inner.batch(&prices))
+ }
+ #[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
+ }
+}
+
+#[napi(js_name = "RollingQuantile")]
+pub struct RollingQuantileNode {
+ inner: wc::RollingQuantile,
+}
+
+#[napi]
+impl RollingQuantileNode {
+ #[napi(constructor)]
+ pub fn new(period: u32, quantile: f64) -> napi::Result {
+ Ok(Self {
+ inner: wc::RollingQuantile::new(period as usize, quantile).map_err(map_err)?,
+ })
+ }
+ #[napi]
+ pub fn update(&mut self, value: f64) -> Option {
+ self.inner.update(value)
+ }
+ #[napi]
+ pub fn batch(&mut self, prices: Vec) -> Vec {
+ flatten(self.inner.batch(&prices))
+ }
+ #[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
+ }
+}
// ============================== Autocorrelation (period + lag) ==============================
@@ -317,6 +436,11 @@ node_pair_indicator!(
);
node_pair_indicator!(BetaNode, "Beta", wc::Beta);
node_pair_indicator!(PairwiseBetaNode, "PairwiseBeta", wc::PairwiseBeta);
+node_pair_indicator!(
+ SpreadAr1CoefficientNode,
+ "SpreadAr1Coefficient",
+ wc::SpreadAr1Coefficient
+);
node_pair_indicator!(
SpearmanCorrelationNode,
"SpearmanCorrelation",
@@ -1658,6 +1782,266 @@ impl HtPhasorNode {
}
}
+#[napi(js_name = "CloseVsOpen")]
+pub struct CloseVsOpenNode {
+ inner: wc::CloseVsOpen,
+}
+
+impl Default for CloseVsOpenNode {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+#[napi]
+impl CloseVsOpenNode {
+ #[napi(constructor)]
+ pub fn new() -> Self {
+ Self {
+ inner: wc::CloseVsOpen::new(),
+ }
+ }
+ #[napi]
+ pub fn update(
+ &mut self,
+ open: f64,
+ high: f64,
+ low: f64,
+ close: f64,
+ ) -> napi::Result> {
+ let candle = wc::Candle::new(open, high, low, close, 0.0, 0).map_err(map_err)?;
+ Ok(self.inner.update(candle))
+ }
+ #[napi]
+ pub fn batch(
+ &mut self,
+ open: Vec,
+ high: Vec,
+ low: Vec,
+ close: Vec,
+ ) -> napi::Result> {
+ if open.len() != high.len() || high.len() != low.len() || low.len() != close.len() {
+ return Err(NapiError::from_reason(
+ "open, high, low, close must be equal length".to_string(),
+ ));
+ }
+ let mut out = Vec::with_capacity(open.len());
+ for i in 0..open.len() {
+ let candle =
+ wc::Candle::new(open[i], high[i], low[i], close[i], 0.0, 0).map_err(map_err)?;
+ out.push(self.inner.update(candle).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
+ }
+}
+
+#[napi(js_name = "BodySizePct")]
+pub struct BodySizePctNode {
+ inner: wc::BodySizePct,
+}
+
+impl Default for BodySizePctNode {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+#[napi]
+impl BodySizePctNode {
+ #[napi(constructor)]
+ pub fn new() -> Self {
+ Self {
+ inner: wc::BodySizePct::new(),
+ }
+ }
+ #[napi]
+ pub fn update(
+ &mut self,
+ open: f64,
+ high: f64,
+ low: f64,
+ close: f64,
+ ) -> napi::Result> {
+ let candle = wc::Candle::new(open, high, low, close, 0.0, 0).map_err(map_err)?;
+ Ok(self.inner.update(candle))
+ }
+ #[napi]
+ pub fn batch(
+ &mut self,
+ open: Vec,
+ high: Vec,
+ low: Vec,
+ close: Vec,
+ ) -> napi::Result> {
+ if open.len() != high.len() || high.len() != low.len() || low.len() != close.len() {
+ return Err(NapiError::from_reason(
+ "open, high, low, close must be equal length".to_string(),
+ ));
+ }
+ let mut out = Vec::with_capacity(open.len());
+ for i in 0..open.len() {
+ let candle =
+ wc::Candle::new(open[i], high[i], low[i], close[i], 0.0, 0).map_err(map_err)?;
+ out.push(self.inner.update(candle).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
+ }
+}
+
+#[napi(js_name = "WickRatio")]
+pub struct WickRatioNode {
+ inner: wc::WickRatio,
+}
+
+impl Default for WickRatioNode {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+#[napi]
+impl WickRatioNode {
+ #[napi(constructor)]
+ pub fn new() -> Self {
+ Self {
+ inner: wc::WickRatio::new(),
+ }
+ }
+ #[napi]
+ pub fn update(
+ &mut self,
+ open: f64,
+ high: f64,
+ low: f64,
+ close: f64,
+ ) -> napi::Result> {
+ let candle = wc::Candle::new(open, high, low, close, 0.0, 0).map_err(map_err)?;
+ Ok(self.inner.update(candle))
+ }
+ #[napi]
+ pub fn batch(
+ &mut self,
+ open: Vec,
+ high: Vec,
+ low: Vec,
+ close: Vec,
+ ) -> napi::Result> {
+ if open.len() != high.len() || high.len() != low.len() || low.len() != close.len() {
+ return Err(NapiError::from_reason(
+ "open, high, low, close must be equal length".to_string(),
+ ));
+ }
+ let mut out = Vec::with_capacity(open.len());
+ for i in 0..open.len() {
+ let candle =
+ wc::Candle::new(open[i], high[i], low[i], close[i], 0.0, 0).map_err(map_err)?;
+ out.push(self.inner.update(candle).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
+ }
+}
+
+#[napi(js_name = "HighLowRange")]
+pub struct HighLowRangeNode {
+ inner: wc::HighLowRange,
+}
+
+impl Default for HighLowRangeNode {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+#[napi]
+impl HighLowRangeNode {
+ #[napi(constructor)]
+ pub fn new() -> Self {
+ Self {
+ inner: wc::HighLowRange::new(),
+ }
+ }
+ #[napi]
+ pub fn update(
+ &mut self,
+ open: f64,
+ high: f64,
+ low: f64,
+ close: f64,
+ ) -> napi::Result> {
+ let candle = wc::Candle::new(open, high, low, close, 0.0, 0).map_err(map_err)?;
+ Ok(self.inner.update(candle))
+ }
+ #[napi]
+ pub fn batch(
+ &mut self,
+ open: Vec,
+ high: Vec,
+ low: Vec,
+ close: Vec,
+ ) -> napi::Result> {
+ if open.len() != high.len() || high.len() != low.len() || low.len() != close.len() {
+ return Err(NapiError::from_reason(
+ "open, high, low, close must be equal length".to_string(),
+ ));
+ }
+ let mut out = Vec::with_capacity(open.len());
+ for i in 0..open.len() {
+ let candle =
+ wc::Candle::new(open[i], high[i], low[i], close[i], 0.0, 0).map_err(map_err)?;
+ out.push(self.inner.update(candle).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
+ }
+}
+
#[napi(object)]
pub struct StochValue {
pub k: f64,
@@ -10334,6 +10718,207 @@ impl TradeImbalanceNode {
}
}
+// Order Flow Imbalance: order-book input with a `period` parameter.
+#[napi(js_name = "OrderFlowImbalance")]
+pub struct OrderFlowImbalanceNode {
+ inner: wc::OrderFlowImbalance,
+}
+
+#[napi]
+impl OrderFlowImbalanceNode {
+ #[napi(constructor)]
+ pub fn new(period: u32) -> napi::Result {
+ Ok(Self {
+ inner: wc::OrderFlowImbalance::new(period as usize).map_err(map_err)?,
+ })
+ }
+ #[napi]
+ pub fn update(
+ &mut self,
+ bid_px: Vec,
+ bid_sz: Vec,
+ ask_px: Vec,
+ ask_sz: Vec,
+ ) -> napi::Result> {
+ 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) -> napi::Result> {
+ 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
+ }
+}
+
+// VPIN: trade input, volume-bucketed `(bucket_volume, num_buckets)`.
+#[napi(js_name = "Vpin")]
+pub struct VpinNode {
+ inner: wc::Vpin,
+}
+
+#[napi]
+impl VpinNode {
+ #[napi(constructor)]
+ pub fn new(bucket_volume: f64, num_buckets: u32) -> napi::Result {
+ Ok(Self {
+ inner: wc::Vpin::new(bucket_volume, num_buckets as usize).map_err(map_err)?,
+ })
+ }
+ #[napi]
+ pub fn update(&mut self, price: f64, size: f64, is_buy: bool) -> napi::Result> {
+ Ok(self.inner.update(build_trade(price, size, is_buy)?))
+ }
+ #[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 trade = build_trade(price[i], size[i], is_buy[i])?;
+ out.push(self.inner.update(trade).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
+ }
+}
+
+// Amihud Illiquidity: trade input with a `period` parameter.
+#[napi(js_name = "AmihudIlliquidity")]
+pub struct AmihudIlliquidityNode {
+ inner: wc::AmihudIlliquidity,
+}
+
+#[napi]
+impl AmihudIlliquidityNode {
+ #[napi(constructor)]
+ pub fn new(period: u32) -> napi::Result {
+ Ok(Self {
+ inner: wc::AmihudIlliquidity::new(period as usize).map_err(map_err)?,
+ })
+ }
+ #[napi]
+ pub fn update(&mut self, price: f64, size: f64, is_buy: bool) -> napi::Result> {
+ Ok(self.inner.update(build_trade(price, size, is_buy)?))
+ }
+ #[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 trade = build_trade(price[i], size[i], is_buy[i])?;
+ out.push(self.inner.update(trade).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
+ }
+}
+
+// Roll Measure: trade input with a `period` parameter.
+#[napi(js_name = "RollMeasure")]
+pub struct RollMeasureNode {
+ inner: wc::RollMeasure,
+}
+
+#[napi]
+impl RollMeasureNode {
+ #[napi(constructor)]
+ pub fn new(period: u32) -> napi::Result {
+ Ok(Self {
+ inner: wc::RollMeasure::new(period as usize).map_err(map_err)?,
+ })
+ }
+ #[napi]
+ pub fn update(&mut self, price: f64, size: f64, is_buy: bool) -> napi::Result> {
+ Ok(self.inner.update(build_trade(price, size, is_buy)?))
+ }
+ #[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 trade = build_trade(price[i], size[i], is_buy[i])?;
+ out.push(self.inner.update(trade).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
+ }
+}
+
// ============================== Microstructure: Price Impact ==============================
//
// Price-impact indicators consume a trade paired with the mid prevailing at
diff --git a/bindings/python/python/wickra/__init__.py b/bindings/python/python/wickra/__init__.py
index 8be26790..5f678fa3 100644
--- a/bindings/python/python/wickra/__init__.py
+++ b/bindings/python/python/wickra/__init__.py
@@ -25,6 +25,20 @@ from __future__ import annotations
from ._wickra import (
__version__,
+ Expectancy,
+ WinRate,
+ RegimeLabel,
+ JumpIndicator,
+ TrendLabel,
+ HighLowRange,
+ WickRatio,
+ BodySizePct,
+ CloseVsOpen,
+ RollingQuantile,
+ RollingPercentileRank,
+ RollingIqr,
+ RealizedVolatility,
+ LogReturn,
TSF,
LINEARREG_INTERCEPT,
ROCR100,
@@ -189,6 +203,7 @@ from ._wickra import (
PearsonCorrelation,
Beta,
PairwiseBeta,
+ SpreadAr1Coefficient,
PairSpreadZScore,
LeadLagCrossCorrelation,
Cointegration,
@@ -351,6 +366,7 @@ from ._wickra import (
FibExtension,
FibRetracement,
# Microstructure: order book
+ OrderFlowImbalance,
OrderBookImbalanceTop1,
OrderBookImbalanceTopN,
OrderBookImbalanceFull,
@@ -358,6 +374,9 @@ from ._wickra import (
QuotedSpread,
DepthSlope,
# Microstructure: trade flow
+ RollMeasure,
+ AmihudIlliquidity,
+ Vpin,
SignedVolume,
CumulativeVolumeDelta,
TradeImbalance,
@@ -430,6 +449,20 @@ from ._wickra import (
)
__all__ = [
+ "Expectancy",
+ "WinRate",
+ "RegimeLabel",
+ "JumpIndicator",
+ "TrendLabel",
+ "HighLowRange",
+ "WickRatio",
+ "BodySizePct",
+ "CloseVsOpen",
+ "RollingQuantile",
+ "RollingPercentileRank",
+ "RollingIqr",
+ "RealizedVolatility",
+ "LogReturn",
"TSF",
"LINEARREG_INTERCEPT",
"ROCR100",
@@ -595,6 +628,7 @@ __all__ = [
"PearsonCorrelation",
"Beta",
"PairwiseBeta",
+ "SpreadAr1Coefficient",
"PairSpreadZScore",
"LeadLagCrossCorrelation",
"Cointegration",
@@ -757,6 +791,7 @@ __all__ = [
"FibExtension",
"FibRetracement",
# Microstructure: order book
+ "OrderFlowImbalance",
"OrderBookImbalanceTop1",
"OrderBookImbalanceTopN",
"OrderBookImbalanceFull",
@@ -764,6 +799,9 @@ __all__ = [
"QuotedSpread",
"DepthSlope",
# Microstructure: trade flow
+ "RollMeasure",
+ "AmihudIlliquidity",
+ "Vpin",
"SignedVolume",
"CumulativeVolumeDelta",
"TradeImbalance",
diff --git a/bindings/python/src/lib.rs b/bindings/python/src/lib.rs
index 7b50aaa0..72559b7f 100644
--- a/bindings/python/src/lib.rs
+++ b/bindings/python/src/lib.rs
@@ -1576,6 +1576,784 @@ impl PyHtPhasor {
}
}
+// ============================== LogReturn ==============================
+
+#[pyclass(name = "LogReturn", module = "wickra._wickra", skip_from_py_object)]
+#[derive(Clone)]
+struct PyLogReturn {
+ inner: wc::LogReturn,
+}
+
+#[pymethods]
+impl PyLogReturn {
+ #[new]
+ #[pyo3(signature = (period=1))]
+ fn new(period: usize) -> PyResult {
+ Ok(Self {
+ inner: wc::LogReturn::new(period).map_err(map_err)?,
+ })
+ }
+ fn update(&mut self, value: f64) -> Option {
+ self.inner.update(value)
+ }
+ fn batch<'py>(
+ &mut self,
+ py: Python<'py>,
+ prices: PyReadonlyArray1<'py, f64>,
+ ) -> PyResult>> {
+ let s = prices
+ .as_slice()
+ .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
+ Ok(flatten(self.inner.batch(s)).into_pyarray(py))
+ }
+ #[getter]
+ fn period(&self) -> usize {
+ self.inner.period()
+ }
+ 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!("LogReturn(period={})", self.inner.period())
+ }
+}
+
+// ============================== RealizedVolatility ==============================
+
+#[pyclass(
+ name = "RealizedVolatility",
+ module = "wickra._wickra",
+ skip_from_py_object
+)]
+#[derive(Clone)]
+struct PyRealizedVolatility {
+ inner: wc::RealizedVolatility,
+}
+
+#[pymethods]
+impl PyRealizedVolatility {
+ #[new]
+ #[pyo3(signature = (period=20))]
+ fn new(period: usize) -> PyResult {
+ Ok(Self {
+ inner: wc::RealizedVolatility::new(period).map_err(map_err)?,
+ })
+ }
+ fn update(&mut self, value: f64) -> Option {
+ self.inner.update(value)
+ }
+ fn batch<'py>(
+ &mut self,
+ py: Python<'py>,
+ prices: PyReadonlyArray1<'py, f64>,
+ ) -> PyResult>> {
+ let s = prices
+ .as_slice()
+ .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
+ Ok(flatten(self.inner.batch(s)).into_pyarray(py))
+ }
+ #[getter]
+ fn period(&self) -> usize {
+ self.inner.period()
+ }
+ 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!("RealizedVolatility(period={})", self.inner.period())
+ }
+}
+
+// ============================== RollingIqr ==============================
+
+#[pyclass(name = "RollingIqr", module = "wickra._wickra", skip_from_py_object)]
+#[derive(Clone)]
+struct PyRollingIqr {
+ inner: wc::RollingIqr,
+}
+
+#[pymethods]
+impl PyRollingIqr {
+ #[new]
+ #[pyo3(signature = (period=14))]
+ fn new(period: usize) -> PyResult {
+ Ok(Self {
+ inner: wc::RollingIqr::new(period).map_err(map_err)?,
+ })
+ }
+ fn update(&mut self, value: f64) -> Option {
+ self.inner.update(value)
+ }
+ fn batch<'py>(
+ &mut self,
+ py: Python<'py>,
+ prices: PyReadonlyArray1<'py, f64>,
+ ) -> PyResult>> {
+ let s = prices
+ .as_slice()
+ .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
+ Ok(flatten(self.inner.batch(s)).into_pyarray(py))
+ }
+ #[getter]
+ fn period(&self) -> usize {
+ self.inner.period()
+ }
+ 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!("RollingIqr(period={})", self.inner.period())
+ }
+}
+
+// ============================== RollingPercentileRank ==============================
+
+#[pyclass(
+ name = "RollingPercentileRank",
+ module = "wickra._wickra",
+ skip_from_py_object
+)]
+#[derive(Clone)]
+struct PyRollingPercentileRank {
+ inner: wc::RollingPercentileRank,
+}
+
+#[pymethods]
+impl PyRollingPercentileRank {
+ #[new]
+ #[pyo3(signature = (period=14))]
+ fn new(period: usize) -> PyResult {
+ Ok(Self {
+ inner: wc::RollingPercentileRank::new(period).map_err(map_err)?,
+ })
+ }
+ fn update(&mut self, value: f64) -> Option {
+ self.inner.update(value)
+ }
+ fn batch<'py>(
+ &mut self,
+ py: Python<'py>,
+ prices: PyReadonlyArray1<'py, f64>,
+ ) -> PyResult>> {
+ let s = prices
+ .as_slice()
+ .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
+ Ok(flatten(self.inner.batch(s)).into_pyarray(py))
+ }
+ #[getter]
+ fn period(&self) -> usize {
+ self.inner.period()
+ }
+ 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!("RollingPercentileRank(period={})", self.inner.period())
+ }
+}
+
+// ============================== RollingQuantile ==============================
+
+#[pyclass(
+ name = "RollingQuantile",
+ module = "wickra._wickra",
+ skip_from_py_object
+)]
+#[derive(Clone)]
+struct PyRollingQuantile {
+ inner: wc::RollingQuantile,
+}
+
+#[pymethods]
+impl PyRollingQuantile {
+ #[new]
+ #[pyo3(signature = (period=20, quantile=0.5))]
+ fn new(period: usize, quantile: f64) -> PyResult {
+ Ok(Self {
+ inner: wc::RollingQuantile::new(period, quantile).map_err(map_err)?,
+ })
+ }
+ fn update(&mut self, value: f64) -> Option {
+ self.inner.update(value)
+ }
+ fn batch<'py>(
+ &mut self,
+ py: Python<'py>,
+ prices: PyReadonlyArray1<'py, f64>,
+ ) -> PyResult>> {
+ let s = prices
+ .as_slice()
+ .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
+ Ok(flatten(self.inner.batch(s)).into_pyarray(py))
+ }
+ #[getter]
+ fn period(&self) -> usize {
+ self.inner.period()
+ }
+ #[getter]
+ fn quantile(&self) -> f64 {
+ self.inner.quantile()
+ }
+ 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!(
+ "RollingQuantile(period={}, quantile={})",
+ self.inner.period(),
+ self.inner.quantile()
+ )
+ }
+}
+
+// ============================== CloseVsOpen ==============================
+
+#[pyclass(name = "CloseVsOpen", module = "wickra._wickra", skip_from_py_object)]
+#[derive(Clone)]
+struct PyCloseVsOpen {
+ inner: wc::CloseVsOpen,
+}
+
+#[pymethods]
+impl PyCloseVsOpen {
+ #[new]
+ fn new() -> Self {
+ Self {
+ inner: wc::CloseVsOpen::new(),
+ }
+ }
+ fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult> {
+ let c = extract_candle(candle)?;
+ Ok(self.inner.update(c))
+ }
+ /// Batch over numpy columns: open, high, low, close (all 1-D, equal length).
+ fn batch<'py>(
+ &mut self,
+ py: Python<'py>,
+ open: PyReadonlyArray1<'py, f64>,
+ high: PyReadonlyArray1<'py, f64>,
+ low: PyReadonlyArray1<'py, f64>,
+ close: PyReadonlyArray1<'py, f64>,
+ ) -> PyResult>> {
+ let o = open
+ .as_slice()
+ .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
+ let h = high
+ .as_slice()
+ .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
+ let l = low
+ .as_slice()
+ .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
+ let c = close
+ .as_slice()
+ .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
+ if o.len() != h.len() || h.len() != l.len() || l.len() != c.len() {
+ return Err(PyValueError::new_err(
+ "open, high, low, close must be equal length",
+ ));
+ }
+ let mut out = Vec::with_capacity(o.len());
+ for i in 0..o.len() {
+ let candle = wc::Candle::new(o[i], h[i], l[i], c[i], 0.0, 0).map_err(map_err)?;
+ out.push(self.inner.update(candle).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 {
+ "CloseVsOpen()".to_string()
+ }
+}
+
+// ============================== BodySizePct ==============================
+
+#[pyclass(name = "BodySizePct", module = "wickra._wickra", skip_from_py_object)]
+#[derive(Clone)]
+struct PyBodySizePct {
+ inner: wc::BodySizePct,
+}
+
+#[pymethods]
+impl PyBodySizePct {
+ #[new]
+ fn new() -> Self {
+ Self {
+ inner: wc::BodySizePct::new(),
+ }
+ }
+ fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult> {
+ let c = extract_candle(candle)?;
+ Ok(self.inner.update(c))
+ }
+ /// Batch over numpy columns: open, high, low, close (all 1-D, equal length).
+ fn batch<'py>(
+ &mut self,
+ py: Python<'py>,
+ open: PyReadonlyArray1<'py, f64>,
+ high: PyReadonlyArray1<'py, f64>,
+ low: PyReadonlyArray1<'py, f64>,
+ close: PyReadonlyArray1<'py, f64>,
+ ) -> PyResult>> {
+ let o = open
+ .as_slice()
+ .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
+ let h = high
+ .as_slice()
+ .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
+ let l = low
+ .as_slice()
+ .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
+ let c = close
+ .as_slice()
+ .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
+ if o.len() != h.len() || h.len() != l.len() || l.len() != c.len() {
+ return Err(PyValueError::new_err(
+ "open, high, low, close must be equal length",
+ ));
+ }
+ let mut out = Vec::with_capacity(o.len());
+ for i in 0..o.len() {
+ let candle = wc::Candle::new(o[i], h[i], l[i], c[i], 0.0, 0).map_err(map_err)?;
+ out.push(self.inner.update(candle).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 {
+ "BodySizePct()".to_string()
+ }
+}
+
+// ============================== WickRatio ==============================
+
+#[pyclass(name = "WickRatio", module = "wickra._wickra", skip_from_py_object)]
+#[derive(Clone)]
+struct PyWickRatio {
+ inner: wc::WickRatio,
+}
+
+#[pymethods]
+impl PyWickRatio {
+ #[new]
+ fn new() -> Self {
+ Self {
+ inner: wc::WickRatio::new(),
+ }
+ }
+ fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult> {
+ let c = extract_candle(candle)?;
+ Ok(self.inner.update(c))
+ }
+ /// Batch over numpy columns: open, high, low, close (all 1-D, equal length).
+ fn batch<'py>(
+ &mut self,
+ py: Python<'py>,
+ open: PyReadonlyArray1<'py, f64>,
+ high: PyReadonlyArray1<'py, f64>,
+ low: PyReadonlyArray1<'py, f64>,
+ close: PyReadonlyArray1<'py, f64>,
+ ) -> PyResult>> {
+ let o = open
+ .as_slice()
+ .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
+ let h = high
+ .as_slice()
+ .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
+ let l = low
+ .as_slice()
+ .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
+ let c = close
+ .as_slice()
+ .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
+ if o.len() != h.len() || h.len() != l.len() || l.len() != c.len() {
+ return Err(PyValueError::new_err(
+ "open, high, low, close must be equal length",
+ ));
+ }
+ let mut out = Vec::with_capacity(o.len());
+ for i in 0..o.len() {
+ let candle = wc::Candle::new(o[i], h[i], l[i], c[i], 0.0, 0).map_err(map_err)?;
+ out.push(self.inner.update(candle).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 {
+ "WickRatio()".to_string()
+ }
+}
+
+// ============================== HighLowRange ==============================
+
+#[pyclass(name = "HighLowRange", module = "wickra._wickra", skip_from_py_object)]
+#[derive(Clone)]
+struct PyHighLowRange {
+ inner: wc::HighLowRange,
+}
+
+#[pymethods]
+impl PyHighLowRange {
+ #[new]
+ fn new() -> Self {
+ Self {
+ inner: wc::HighLowRange::new(),
+ }
+ }
+ fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult> {
+ let c = extract_candle(candle)?;
+ Ok(self.inner.update(c))
+ }
+ /// Batch over numpy columns: open, high, low, close (all 1-D, equal length).
+ fn batch<'py>(
+ &mut self,
+ py: Python<'py>,
+ open: PyReadonlyArray1<'py, f64>,
+ high: PyReadonlyArray1<'py, f64>,
+ low: PyReadonlyArray1<'py, f64>,
+ close: PyReadonlyArray1<'py, f64>,
+ ) -> PyResult>> {
+ let o = open
+ .as_slice()
+ .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
+ let h = high
+ .as_slice()
+ .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
+ let l = low
+ .as_slice()
+ .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
+ let c = close
+ .as_slice()
+ .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
+ if o.len() != h.len() || h.len() != l.len() || l.len() != c.len() {
+ return Err(PyValueError::new_err(
+ "open, high, low, close must be equal length",
+ ));
+ }
+ let mut out = Vec::with_capacity(o.len());
+ for i in 0..o.len() {
+ let candle = wc::Candle::new(o[i], h[i], l[i], c[i], 0.0, 0).map_err(map_err)?;
+ out.push(self.inner.update(candle).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 {
+ "HighLowRange()".to_string()
+ }
+}
+
+// ============================== TrendLabel ==============================
+
+#[pyclass(name = "TrendLabel", module = "wickra._wickra", skip_from_py_object)]
+#[derive(Clone)]
+struct PyTrendLabel {
+ inner: wc::TrendLabel,
+}
+
+#[pymethods]
+impl PyTrendLabel {
+ #[new]
+ #[pyo3(signature = (period=10))]
+ fn new(period: usize) -> PyResult {
+ Ok(Self {
+ inner: wc::TrendLabel::new(period).map_err(map_err)?,
+ })
+ }
+ fn update(&mut self, value: f64) -> Option {
+ self.inner.update(value)
+ }
+ fn batch<'py>(
+ &mut self,
+ py: Python<'py>,
+ prices: PyReadonlyArray1<'py, f64>,
+ ) -> PyResult>> {
+ let s = prices
+ .as_slice()
+ .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
+ Ok(flatten(self.inner.batch(s)).into_pyarray(py))
+ }
+ #[getter]
+ fn period(&self) -> usize {
+ self.inner.period()
+ }
+ 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!("TrendLabel(period={})", self.inner.period())
+ }
+}
+
+// ============================== JumpIndicator ==============================
+
+#[pyclass(name = "JumpIndicator", module = "wickra._wickra", skip_from_py_object)]
+#[derive(Clone)]
+struct PyJumpIndicator {
+ inner: wc::JumpIndicator,
+}
+
+#[pymethods]
+impl PyJumpIndicator {
+ #[new]
+ #[pyo3(signature = (period=20, threshold=3.0))]
+ fn new(period: usize, threshold: f64) -> PyResult {
+ Ok(Self {
+ inner: wc::JumpIndicator::new(period, threshold).map_err(map_err)?,
+ })
+ }
+ fn update(&mut self, value: f64) -> Option {
+ self.inner.update(value)
+ }
+ fn batch<'py>(
+ &mut self,
+ py: Python<'py>,
+ prices: PyReadonlyArray1<'py, f64>,
+ ) -> PyResult>> {
+ let s = prices
+ .as_slice()
+ .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
+ Ok(flatten(self.inner.batch(s)).into_pyarray(py))
+ }
+ #[getter]
+ fn period(&self) -> usize {
+ self.inner.params().0
+ }
+ #[getter]
+ fn threshold(&self) -> f64 {
+ self.inner.params().1
+ }
+ 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 {
+ let (period, threshold) = self.inner.params();
+ format!("JumpIndicator(period={period}, threshold={threshold})")
+ }
+}
+
+// ============================== RegimeLabel ==============================
+
+#[pyclass(name = "RegimeLabel", module = "wickra._wickra", skip_from_py_object)]
+#[derive(Clone)]
+struct PyRegimeLabel {
+ inner: wc::RegimeLabel,
+}
+
+#[pymethods]
+impl PyRegimeLabel {
+ #[new]
+ #[pyo3(signature = (vol_period=5, lookback=20))]
+ fn new(vol_period: usize, lookback: usize) -> PyResult {
+ Ok(Self {
+ inner: wc::RegimeLabel::new(vol_period, lookback).map_err(map_err)?,
+ })
+ }
+ fn update(&mut self, value: f64) -> Option {
+ self.inner.update(value)
+ }
+ fn batch<'py>(
+ &mut self,
+ py: Python<'py>,
+ prices: PyReadonlyArray1<'py, f64>,
+ ) -> PyResult>> {
+ let s = prices
+ .as_slice()
+ .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
+ Ok(flatten(self.inner.batch(s)).into_pyarray(py))
+ }
+ #[getter]
+ fn vol_period(&self) -> usize {
+ self.inner.params().0
+ }
+ #[getter]
+ fn lookback(&self) -> usize {
+ self.inner.params().1
+ }
+ 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 {
+ let (vol_period, lookback) = self.inner.params();
+ format!("RegimeLabel(vol_period={vol_period}, lookback={lookback})")
+ }
+}
+
+// ============================== WinRate ==============================
+
+#[pyclass(name = "WinRate", module = "wickra._wickra", skip_from_py_object)]
+#[derive(Clone)]
+struct PyWinRate {
+ inner: wc::WinRate,
+}
+
+#[pymethods]
+impl PyWinRate {
+ #[new]
+ #[pyo3(signature = (period=20))]
+ fn new(period: usize) -> PyResult {
+ Ok(Self {
+ inner: wc::WinRate::new(period).map_err(map_err)?,
+ })
+ }
+ fn update(&mut self, value: f64) -> Option {
+ self.inner.update(value)
+ }
+ fn batch<'py>(
+ &mut self,
+ py: Python<'py>,
+ prices: PyReadonlyArray1<'py, f64>,
+ ) -> PyResult>> {
+ let s = prices
+ .as_slice()
+ .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
+ Ok(flatten(self.inner.batch(s)).into_pyarray(py))
+ }
+ #[getter]
+ fn period(&self) -> usize {
+ self.inner.period()
+ }
+ 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!("WinRate(period={})", self.inner.period())
+ }
+}
+
+// ============================== Expectancy ==============================
+
+#[pyclass(name = "Expectancy", module = "wickra._wickra", skip_from_py_object)]
+#[derive(Clone)]
+struct PyExpectancy {
+ inner: wc::Expectancy,
+}
+
+#[pymethods]
+impl PyExpectancy {
+ #[new]
+ #[pyo3(signature = (period=20))]
+ fn new(period: usize) -> PyResult {
+ Ok(Self {
+ inner: wc::Expectancy::new(period).map_err(map_err)?,
+ })
+ }
+ fn update(&mut self, value: f64) -> Option {
+ self.inner.update(value)
+ }
+ fn batch<'py>(
+ &mut self,
+ py: Python<'py>,
+ prices: PyReadonlyArray1<'py, f64>,
+ ) -> PyResult>> {
+ let s = prices
+ .as_slice()
+ .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
+ Ok(flatten(self.inner.batch(s)).into_pyarray(py))
+ }
+ #[getter]
+ fn period(&self) -> usize {
+ self.inner.period()
+ }
+ 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!("Expectancy(period={})", self.inner.period())
+ }
+}
+
// ============================== Stochastic ==============================
#[pyclass(name = "Stochastic", module = "wickra._wickra", skip_from_py_object)]
@@ -11936,6 +12714,70 @@ impl PyPairwiseBeta {
}
}
+// ============================== SpreadAr1Coefficient ==============================
+
+#[pyclass(
+ name = "SpreadAr1Coefficient",
+ module = "wickra._wickra",
+ skip_from_py_object
+)]
+#[derive(Clone)]
+struct PySpreadAr1Coefficient {
+ inner: wc::SpreadAr1Coefficient,
+}
+
+#[pymethods]
+impl PySpreadAr1Coefficient {
+ #[new]
+ #[pyo3(signature = (period=40))]
+ fn new(period: usize) -> PyResult {
+ Ok(Self {
+ inner: wc::SpreadAr1Coefficient::new(period).map_err(map_err)?,
+ })
+ }
+ fn update(&mut self, a: f64, b: f64) -> Option {
+ self.inner.update((a, b))
+ }
+ /// Batch over two equally-sized numpy arrays of prices: `a` and `b`.
+ fn batch<'py>(
+ &mut self,
+ py: Python<'py>,
+ a: PyReadonlyArray1<'py, f64>,
+ b: PyReadonlyArray1<'py, f64>,
+ ) -> PyResult>> {
+ let xs = a
+ .as_slice()
+ .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
+ let ys = b
+ .as_slice()
+ .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
+ if xs.len() != ys.len() {
+ return Err(PyValueError::new_err("a and b must be equal length"));
+ }
+ let mut out = Vec::with_capacity(xs.len());
+ for i in 0..xs.len() {
+ out.push(self.inner.update((xs[i], ys[i])).unwrap_or(f64::NAN));
+ }
+ Ok(out.into_pyarray(py))
+ }
+ #[getter]
+ fn period(&self) -> usize {
+ self.inner.period()
+ }
+ 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!("SpreadAr1Coefficient(period={})", self.inner.period())
+ }
+}
+
// ============================== PairSpreadZScore ==============================
#[pyclass(
@@ -13981,6 +14823,221 @@ impl PyTradeImbalance {
}
}
+// Order Flow Imbalance carries a `period` parameter and an order-book input,
+// so it is hand-written.
+#[pyclass(
+ name = "OrderFlowImbalance",
+ module = "wickra._wickra",
+ skip_from_py_object
+)]
+#[derive(Clone)]
+struct PyOrderFlowImbalance {
+ inner: wc::OrderFlowImbalance,
+}
+
+#[pymethods]
+impl PyOrderFlowImbalance {
+ #[new]
+ fn new(period: usize) -> PyResult {
+ Ok(Self {
+ inner: wc::OrderFlowImbalance::new(period).map_err(map_err)?,
+ })
+ }
+ fn update(
+ &mut self,
+ bid_px: Vec,
+ bid_sz: Vec,
+ ask_px: Vec,
+ ask_sz: Vec,
+ ) -> PyResult> {
+ 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, Vec, Vec, Vec)>,
+ ) -> PyResult>> {
+ 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!("OrderFlowImbalance(period={})", self.inner.period())
+ }
+}
+
+// VPIN buckets trades by volume; it carries `(bucket_volume, num_buckets)`.
+#[pyclass(name = "Vpin", module = "wickra._wickra", skip_from_py_object)]
+#[derive(Clone)]
+struct PyVpin {
+ inner: wc::Vpin,
+}
+
+#[pymethods]
+impl PyVpin {
+ #[new]
+ fn new(bucket_volume: f64, num_buckets: usize) -> PyResult {
+ Ok(Self {
+ inner: wc::Vpin::new(bucket_volume, num_buckets).map_err(map_err)?,
+ })
+ }
+ fn update(&mut self, price: f64, size: f64, is_buy: bool) -> PyResult> {
+ Ok(self.inner.update(build_trade(price, size, is_buy)?))
+ }
+ 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 trade = build_trade(price[i], size[i], is_buy[i])?;
+ out.push(self.inner.update(trade).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 {
+ let (bucket_volume, num_buckets) = self.inner.params();
+ format!("Vpin(bucket_volume={bucket_volume}, num_buckets={num_buckets})")
+ }
+}
+
+// Amihud illiquidity carries a `period` parameter and a trade input.
+#[pyclass(
+ name = "AmihudIlliquidity",
+ module = "wickra._wickra",
+ skip_from_py_object
+)]
+#[derive(Clone)]
+struct PyAmihudIlliquidity {
+ inner: wc::AmihudIlliquidity,
+}
+
+#[pymethods]
+impl PyAmihudIlliquidity {
+ #[new]
+ fn new(period: usize) -> PyResult {
+ Ok(Self {
+ inner: wc::AmihudIlliquidity::new(period).map_err(map_err)?,
+ })
+ }
+ fn update(&mut self, price: f64, size: f64, is_buy: bool) -> PyResult> {
+ Ok(self.inner.update(build_trade(price, size, is_buy)?))
+ }
+ 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 trade = build_trade(price[i], size[i], is_buy[i])?;
+ out.push(self.inner.update(trade).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!("AmihudIlliquidity(period={})", self.inner.period())
+ }
+}
+
+// Roll measure carries a `period` parameter and a trade input.
+#[pyclass(name = "RollMeasure", module = "wickra._wickra", skip_from_py_object)]
+#[derive(Clone)]
+struct PyRollMeasure {
+ inner: wc::RollMeasure,
+}
+
+#[pymethods]
+impl PyRollMeasure {
+ #[new]
+ fn new(period: usize) -> PyResult {
+ Ok(Self {
+ inner: wc::RollMeasure::new(period).map_err(map_err)?,
+ })
+ }
+ fn update(&mut self, price: f64, size: f64, is_buy: bool) -> PyResult> {
+ Ok(self.inner.update(build_trade(price, size, is_buy)?))
+ }
+ 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 trade = build_trade(price[i], size[i], is_buy[i])?;
+ out.push(self.inner.update(trade).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!("RollMeasure(period={})", self.inner.period())
+ }
+}
+
// ============================== Microstructure: Price Impact ==============================
//
// Price-impact indicators consume a trade paired with the mid prevailing at
@@ -18757,6 +19814,7 @@ fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_class::()?;
m.add_class::()?;
m.add_class::()?;
+ m.add_class::()?;
m.add_class::()?;
m.add_class::()?;
m.add_class::()?;
@@ -18852,6 +19910,10 @@ fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_class::()?;
m.add_class::()?;
m.add_class::()?;
+ m.add_class::()?;
+ m.add_class::()?;
+ m.add_class::()?;
+ m.add_class::()?;
// Microstructure: price impact.
m.add_class::()?;
m.add_class::()?;
@@ -18955,5 +20017,19 @@ fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_class::()?;
m.add_class::()?;
m.add_class::()?;
+ m.add_class::()?;
+ m.add_class::()?;
+ m.add_class::()?;
+ m.add_class::()?;
+ m.add_class::()?;
+ m.add_class::()?;
+ m.add_class::()?;
+ m.add_class::()?;
+ m.add_class::()?;
+ m.add_class::()?;
+ m.add_class::()?;
+ m.add_class::()?;
+ m.add_class::()?;
+ m.add_class::()?;
Ok(())
}
diff --git a/bindings/python/tests/test_new_indicators.py b/bindings/python/tests/test_new_indicators.py
index eaf60b45..3ec35522 100644
--- a/bindings/python/tests/test_new_indicators.py
+++ b/bindings/python/tests/test_new_indicators.py
@@ -45,6 +45,16 @@ def ohlcv() -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
# --- Scalar (f64 -> f64) indicators ---------------------------------------
SCALAR = [
+ (ta.Expectancy, (20,)),
+ (ta.WinRate, (20,)),
+ (ta.RegimeLabel, (5, 20)),
+ (ta.JumpIndicator, (20, 3.0)),
+ (ta.TrendLabel, (10,)),
+ (ta.RollingQuantile, (20, 0.5)),
+ (ta.RollingPercentileRank, (14,)),
+ (ta.RollingIqr, (14,)),
+ (ta.RealizedVolatility, (20,)),
+ (ta.LogReturn, (1,)),
(ta.TSF, (14,)),
(ta.LINEARREG_INTERCEPT, (14,)),
(ta.ROCR100, (10,)),
@@ -167,6 +177,7 @@ def test_scalar_streaming_matches_batch(cls, args, sine_prices):
# --- Two-series (asset, benchmark) indicators -----------------------------
PAIR = [
+ (ta.SpreadAr1Coefficient, (40,)),
(ta.GrangerCausality, (60, 1)),
(ta.VarianceRatio, (60, 2)),
(ta.BetaNeutralSpread, (20,)),
@@ -330,6 +341,12 @@ def test_relative_strength_streaming_matches_batch():
# 6-tuple candle; the batch helper takes only the columns it needs.
CANDLE_SCALAR = {
+ # Per-bar OHLC transforms (open matters). The streaming harness feeds
+ # open == close, so batch passes the close column in for open to match.
+ "HighLowRange": (lambda: ta.HighLowRange(), lambda ind, h, l, c, v: ind.batch(c, h, l, c)),
+ "WickRatio": (lambda: ta.WickRatio(), lambda ind, h, l, c, v: ind.batch(c, h, l, c)),
+ "BodySizePct": (lambda: ta.BodySizePct(), lambda ind, h, l, c, v: ind.batch(c, h, l, c)),
+ "CloseVsOpen": (lambda: ta.CloseVsOpen(), lambda ind, h, l, c, v: ind.batch(c, h, l, c)),
"ThreeDrives": (
lambda: ta.ThreeDrives(),
lambda ind, h, l, c, v: ind.batch(c, h, l, c),
@@ -2707,6 +2724,16 @@ def test_fib_time_zones_reference():
assert t.update((151.0, 155.0, 151.0, 151.0, 1.0, 4)) == pytest.approx((0.0, 1.0))
assert t.update((151.0, 155.0, 151.0, 151.0, 1.0, 5)) == pytest.approx((1.0, 3.0))
+
+def test_spread_ar1_coefficient_reference():
+ t = ta.SpreadAr1Coefficient(20)
+ assert t.update(1.0, 1.0) is None
+ # Spread a - b grows by exactly 1 each bar (unit root) => rho == 1.
+ a = np.array([2.0 * i for i in range(40)])
+ b = np.array([float(i) for i in range(40)])
+ out = ta.SpreadAr1Coefficient(20).batch(a, b)
+ assert math.isclose(out[-1], 1.0, abs_tol=1e-9)
+
# --- Lifecycle ------------------------------------------------------------
@@ -3024,6 +3051,7 @@ def test_orderbook_indicators_streaming_equals_batch():
ta.Microprice,
ta.QuotedSpread,
ta.DepthSlope,
+ lambda: ta.OrderFlowImbalance(10),
):
batch = make().batch(snaps)
streamer = make()
@@ -3043,6 +3071,9 @@ def test_tradeflow_indicators_streaming_equals_batch():
ta.SignedVolume,
ta.CumulativeVolumeDelta,
lambda: ta.TradeImbalance(5),
+ lambda: ta.Vpin(8.0, 5),
+ lambda: ta.AmihudIlliquidity(14),
+ lambda: ta.RollMeasure(14),
):
batch = make().batch(price, size, is_buy)
streamer = make()
diff --git a/bindings/wasm/src/lib.rs b/bindings/wasm/src/lib.rs
index 2e72fcfe..5e362ae4 100644
--- a/bindings/wasm/src/lib.rs
+++ b/bindings/wasm/src/lib.rs
@@ -526,6 +526,11 @@ wasm_pair_indicator!(
);
wasm_pair_indicator!(WasmBeta, "Beta", wc::Beta);
wasm_pair_indicator!(WasmPairwiseBeta, "PairwiseBeta", wc::PairwiseBeta);
+wasm_pair_indicator!(
+ WasmSpreadAr1Coefficient,
+ "SpreadAr1Coefficient",
+ wc::SpreadAr1Coefficient
+);
wasm_pair_indicator!(
WasmSpearmanCorrelation,
"SpearmanCorrelation",
@@ -1841,6 +1846,210 @@ impl WasmHtPhasor {
}
}
+#[wasm_bindgen(js_name = CloseVsOpen)]
+pub struct WasmCloseVsOpen {
+ inner: wc::CloseVsOpen,
+}
+
+impl Default for WasmCloseVsOpen {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+#[wasm_bindgen(js_class = CloseVsOpen)]
+impl WasmCloseVsOpen {
+ #[wasm_bindgen(constructor)]
+ pub fn new() -> WasmCloseVsOpen {
+ Self {
+ inner: wc::CloseVsOpen::new(),
+ }
+ }
+ pub fn update(
+ &mut self,
+ open: f64,
+ high: f64,
+ low: f64,
+ close: f64,
+ ) -> Result, JsError> {
+ let c = make_candle_ohlc(open, high, low, close)?;
+ Ok(self.inner.update(c))
+ }
+ pub fn batch(
+ &mut self,
+ open: &[f64],
+ high: &[f64],
+ low: &[f64],
+ close: &[f64],
+ ) -> Result {
+ if open.len() != high.len() || high.len() != low.len() || low.len() != close.len() {
+ return Err(JsError::new("open, high, low, close must be equal length"));
+ }
+ let mut out = Vec::with_capacity(open.len());
+ for i in 0..open.len() {
+ let c = make_candle_ohlc(open[i], high[i], low[i], close[i])?;
+ out.push(self.inner.update(c).unwrap_or(f64::NAN));
+ }
+ Ok(Float64Array::from(out.as_slice()))
+ }
+ pub fn reset(&mut self) {
+ self.inner.reset();
+ }
+}
+
+#[wasm_bindgen(js_name = BodySizePct)]
+pub struct WasmBodySizePct {
+ inner: wc::BodySizePct,
+}
+
+impl Default for WasmBodySizePct {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+#[wasm_bindgen(js_class = BodySizePct)]
+impl WasmBodySizePct {
+ #[wasm_bindgen(constructor)]
+ pub fn new() -> WasmBodySizePct {
+ Self {
+ inner: wc::BodySizePct::new(),
+ }
+ }
+ pub fn update(
+ &mut self,
+ open: f64,
+ high: f64,
+ low: f64,
+ close: f64,
+ ) -> Result, JsError> {
+ let c = make_candle_ohlc(open, high, low, close)?;
+ Ok(self.inner.update(c))
+ }
+ pub fn batch(
+ &mut self,
+ open: &[f64],
+ high: &[f64],
+ low: &[f64],
+ close: &[f64],
+ ) -> Result {
+ if open.len() != high.len() || high.len() != low.len() || low.len() != close.len() {
+ return Err(JsError::new("open, high, low, close must be equal length"));
+ }
+ let mut out = Vec::with_capacity(open.len());
+ for i in 0..open.len() {
+ let c = make_candle_ohlc(open[i], high[i], low[i], close[i])?;
+ out.push(self.inner.update(c).unwrap_or(f64::NAN));
+ }
+ Ok(Float64Array::from(out.as_slice()))
+ }
+ pub fn reset(&mut self) {
+ self.inner.reset();
+ }
+}
+
+#[wasm_bindgen(js_name = WickRatio)]
+pub struct WasmWickRatio {
+ inner: wc::WickRatio,
+}
+
+impl Default for WasmWickRatio {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+#[wasm_bindgen(js_class = WickRatio)]
+impl WasmWickRatio {
+ #[wasm_bindgen(constructor)]
+ pub fn new() -> WasmWickRatio {
+ Self {
+ inner: wc::WickRatio::new(),
+ }
+ }
+ pub fn update(
+ &mut self,
+ open: f64,
+ high: f64,
+ low: f64,
+ close: f64,
+ ) -> Result, JsError> {
+ let c = make_candle_ohlc(open, high, low, close)?;
+ Ok(self.inner.update(c))
+ }
+ pub fn batch(
+ &mut self,
+ open: &[f64],
+ high: &[f64],
+ low: &[f64],
+ close: &[f64],
+ ) -> Result {
+ if open.len() != high.len() || high.len() != low.len() || low.len() != close.len() {
+ return Err(JsError::new("open, high, low, close must be equal length"));
+ }
+ let mut out = Vec::with_capacity(open.len());
+ for i in 0..open.len() {
+ let c = make_candle_ohlc(open[i], high[i], low[i], close[i])?;
+ out.push(self.inner.update(c).unwrap_or(f64::NAN));
+ }
+ Ok(Float64Array::from(out.as_slice()))
+ }
+ pub fn reset(&mut self) {
+ self.inner.reset();
+ }
+}
+
+#[wasm_bindgen(js_name = HighLowRange)]
+pub struct WasmHighLowRange {
+ inner: wc::HighLowRange,
+}
+
+impl Default for WasmHighLowRange {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+#[wasm_bindgen(js_class = HighLowRange)]
+impl WasmHighLowRange {
+ #[wasm_bindgen(constructor)]
+ pub fn new() -> WasmHighLowRange {
+ Self {
+ inner: wc::HighLowRange::new(),
+ }
+ }
+ pub fn update(
+ &mut self,
+ open: f64,
+ high: f64,
+ low: f64,
+ close: f64,
+ ) -> Result, JsError> {
+ let c = make_candle_ohlc(open, high, low, close)?;
+ Ok(self.inner.update(c))
+ }
+ pub fn batch(
+ &mut self,
+ open: &[f64],
+ high: &[f64],
+ low: &[f64],
+ close: &[f64],
+ ) -> Result {
+ if open.len() != high.len() || high.len() != low.len() || low.len() != close.len() {
+ return Err(JsError::new("open, high, low, close must be equal length"));
+ }
+ let mut out = Vec::with_capacity(open.len());
+ for i in 0..open.len() {
+ let c = make_candle_ohlc(open[i], high[i], low[i], close[i])?;
+ out.push(self.inner.update(c).unwrap_or(f64::NAN));
+ }
+ Ok(Float64Array::from(out.as_slice()))
+ }
+ pub fn reset(&mut self) {
+ self.inner.reset();
+ }
+}
+
#[wasm_bindgen(js_name = Stochastic)]
pub struct WasmStoch {
inner: wc::Stochastic,
@@ -7596,6 +7805,133 @@ impl WasmTradeImbalance {
}
}
+// Order Flow Imbalance: order-book input with a `period` parameter.
+#[wasm_bindgen(js_name = OrderFlowImbalance)]
+pub struct WasmOrderFlowImbalance {
+ inner: wc::OrderFlowImbalance,
+}
+
+#[wasm_bindgen(js_class = OrderFlowImbalance)]
+impl WasmOrderFlowImbalance {
+ #[wasm_bindgen(constructor)]
+ pub fn new(period: usize) -> Result {
+ Ok(Self {
+ inner: wc::OrderFlowImbalance::new(period).map_err(map_err)?,
+ })
+ }
+ pub fn update(
+ &mut self,
+ bid_px: &[f64],
+ bid_sz: &[f64],
+ ask_px: &[f64],
+ ask_sz: &[f64],
+ ) -> Result, 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()
+ }
+}
+
+// VPIN: trade input, volume-bucketed `(bucket_volume, num_buckets)`.
+#[wasm_bindgen(js_name = Vpin)]
+pub struct WasmVpin {
+ inner: wc::Vpin,
+}
+
+#[wasm_bindgen(js_class = Vpin)]
+impl WasmVpin {
+ #[wasm_bindgen(constructor)]
+ pub fn new(bucket_volume: f64, num_buckets: usize) -> Result {
+ Ok(Self {
+ inner: wc::Vpin::new(bucket_volume, num_buckets).map_err(map_err)?,
+ })
+ }
+ pub fn update(&mut self, price: f64, size: f64, is_buy: bool) -> Result, JsError> {
+ Ok(self.inner.update(build_trade(price, size, is_buy)?))
+ }
+ 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()
+ }
+}
+
+// Amihud Illiquidity: trade input with a `period` parameter.
+#[wasm_bindgen(js_name = AmihudIlliquidity)]
+pub struct WasmAmihudIlliquidity {
+ inner: wc::AmihudIlliquidity,
+}
+
+#[wasm_bindgen(js_class = AmihudIlliquidity)]
+impl WasmAmihudIlliquidity {
+ #[wasm_bindgen(constructor)]
+ pub fn new(period: usize) -> Result {
+ Ok(Self {
+ inner: wc::AmihudIlliquidity::new(period).map_err(map_err)?,
+ })
+ }
+ pub fn update(&mut self, price: f64, size: f64, is_buy: bool) -> Result, JsError> {
+ Ok(self.inner.update(build_trade(price, size, is_buy)?))
+ }
+ 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()
+ }
+}
+
+// Roll Measure: trade input with a `period` parameter.
+#[wasm_bindgen(js_name = RollMeasure)]
+pub struct WasmRollMeasure {
+ inner: wc::RollMeasure,
+}
+
+#[wasm_bindgen(js_class = RollMeasure)]
+impl WasmRollMeasure {
+ #[wasm_bindgen(constructor)]
+ pub fn new(period: usize) -> Result {
+ Ok(Self {
+ inner: wc::RollMeasure::new(period).map_err(map_err)?,
+ })
+ }
+ pub fn update(&mut self, price: f64, size: f64, is_buy: bool) -> Result, JsError> {
+ Ok(self.inner.update(build_trade(price, size, is_buy)?))
+ }
+ 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()
+ }
+}
+
// ============================== Microstructure: Price Impact ==============================
//
// Price-impact indicators consume a trade paired with the mid prevailing at
@@ -9867,6 +10203,16 @@ wasm_scalar_indicator!(WasmRocr, "ROCR", wc::Rocr, period: usize);
wasm_scalar_indicator!(WasmRocr100, "ROCR100", wc::Rocr100, period: usize);
wasm_scalar_indicator!(WasmLinRegIntercept, "LINEARREG_INTERCEPT", wc::LinRegIntercept, period: usize);
wasm_scalar_indicator!(WasmTsf, "TSF", wc::Tsf, period: usize);
+wasm_scalar_indicator!(WasmLogReturn, "LogReturn", wc::LogReturn, period: usize);
+wasm_scalar_indicator!(WasmRealizedVolatility, "RealizedVolatility", wc::RealizedVolatility, period: usize);
+wasm_scalar_indicator!(WasmRollingIqr, "RollingIqr", wc::RollingIqr, period: usize);
+wasm_scalar_indicator!(WasmRollingPercentileRank, "RollingPercentileRank", wc::RollingPercentileRank, period: usize);
+wasm_scalar_indicator!(WasmRollingQuantile, "RollingQuantile", wc::RollingQuantile, period: usize, quantile: f64);
+wasm_scalar_indicator!(WasmTrendLabel, "TrendLabel", wc::TrendLabel, period: usize);
+wasm_scalar_indicator!(WasmJumpIndicator, "JumpIndicator", wc::JumpIndicator, period: usize, threshold: f64);
+wasm_scalar_indicator!(WasmRegimeLabel, "RegimeLabel", wc::RegimeLabel, vol_period: usize, lookback: usize);
+wasm_scalar_indicator!(WasmWinRate, "WinRate", wc::WinRate, period: usize);
+wasm_scalar_indicator!(WasmExpectancy, "Expectancy", wc::Expectancy, period: usize);
// --- DrawdownDuration: u32 output, no constructor args ---
diff --git a/crates/wickra-core/src/indicators/amihud_illiquidity.rs b/crates/wickra-core/src/indicators/amihud_illiquidity.rs
new file mode 100644
index 00000000..cef52a14
--- /dev/null
+++ b/crates/wickra-core/src/indicators/amihud_illiquidity.rs
@@ -0,0 +1,239 @@
+//! Amihud Illiquidity — average price impact per unit traded value.
+
+use std::collections::VecDeque;
+
+use crate::microstructure::Trade;
+use crate::traits::Indicator;
+use crate::{Error, Result};
+
+/// Amihud Illiquidity — the average absolute log return per unit of traded
+/// value over the last `period` trades (Amihud, 2002).
+///
+/// ```text
+/// rₜ = ln(priceₜ / priceₜ₋₁)
+/// ILLIQₜ = |rₜ| / (priceₜ · sizeₜ) (return per dollar of volume)
+/// Amihud = mean of ILLIQ over the last `period` trades
+/// ```
+///
+/// Amihud's measure captures how much the price moves for a given amount of
+/// traded value: a **high** reading means small volume already shifts the price
+/// a lot (an illiquid, easily-moved market), a **low** reading means it takes
+/// large volume to move the price (a deep, liquid market). It is the workhorse
+/// cross-sectional liquidity proxy in market-microstructure research.
+///
+/// `Input = Trade`. Trades with zero size carry no traded value and are skipped
+/// (the ratio is undefined); the last value is returned and state is untouched.
+/// The first valid trade only seeds the reference price.
+///
+/// # Example
+///
+/// ```
+/// use wickra_core::{Indicator, Side, Trade, AmihudIlliquidity};
+///
+/// let mut amihud = AmihudIlliquidity::new(20).unwrap();
+/// assert_eq!(amihud.update(Trade::new(100.0, 5.0, Side::Buy, 0).unwrap()), None);
+/// ```
+#[derive(Debug, Clone)]
+pub struct AmihudIlliquidity {
+ period: usize,
+ prev_price: Option,
+ window: VecDeque,
+ sum: f64,
+ last: Option,
+}
+
+impl AmihudIlliquidity {
+ /// Construct a new Amihud Illiquidity over the given trade window.
+ ///
+ /// # Errors
+ /// Returns [`Error::PeriodZero`] if `period == 0`.
+ pub fn new(period: usize) -> Result {
+ if period == 0 {
+ return Err(Error::PeriodZero);
+ }
+ Ok(Self {
+ period,
+ prev_price: None,
+ window: VecDeque::with_capacity(period),
+ sum: 0.0,
+ last: None,
+ })
+ }
+
+ /// Configured period.
+ pub const fn period(&self) -> usize {
+ self.period
+ }
+}
+
+impl Indicator for AmihudIlliquidity {
+ type Input = Trade;
+ type Output = f64;
+
+ fn update(&mut self, trade: Trade) -> Option {
+ // A zero-size trade has no traded value: the ratio is undefined, so the
+ // trade is skipped without touching the reference price.
+ if trade.size == 0.0 {
+ return self.last;
+ }
+ let Some(prev) = self.prev_price else {
+ self.prev_price = Some(trade.price);
+ return None;
+ };
+ self.prev_price = Some(trade.price);
+ // `prev` and `trade.price` are both finite and strictly positive
+ // (enforced by `Trade::new`), so the log return is well-defined and the
+ // traded value is strictly positive.
+ let ret = (trade.price / prev).ln().abs();
+ let illiq = ret / (trade.price * trade.size);
+ if self.window.len() == self.period {
+ let old = self.window.pop_front().expect("window is non-empty");
+ self.sum -= old;
+ }
+ self.window.push_back(illiq);
+ self.sum += illiq;
+ if self.window.len() < self.period {
+ return None;
+ }
+ let value = self.sum / self.period as f64;
+ self.last = Some(value);
+ Some(value)
+ }
+
+ fn reset(&mut self) {
+ self.prev_price = None;
+ self.window.clear();
+ self.sum = 0.0;
+ self.last = None;
+ }
+
+ fn warmup_period(&self) -> usize {
+ self.period + 1
+ }
+
+ fn is_ready(&self) -> bool {
+ self.last.is_some()
+ }
+
+ fn name(&self) -> &'static str {
+ "AmihudIlliquidity"
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::microstructure::Side;
+ use crate::traits::BatchExt;
+ use approx::assert_relative_eq;
+
+ fn trade(price: f64, size: f64) -> Trade {
+ Trade::new(price, size, Side::Buy, 0).unwrap()
+ }
+
+ #[test]
+ fn rejects_zero_period() {
+ assert!(matches!(AmihudIlliquidity::new(0), Err(Error::PeriodZero)));
+ }
+
+ #[test]
+ fn accessors_and_metadata() {
+ let a = AmihudIlliquidity::new(20).unwrap();
+ assert_eq!(a.period(), 20);
+ assert_eq!(a.warmup_period(), 21);
+ assert_eq!(a.name(), "AmihudIlliquidity");
+ assert!(!a.is_ready());
+ }
+
+ #[test]
+ fn known_value() {
+ // period 1. Seed at 100, then 101 with size 10:
+ // |ln(101/100)| / (101 * 10).
+ let mut a = AmihudIlliquidity::new(1).unwrap();
+ assert_eq!(a.update(trade(100.0, 10.0)), None);
+ let out = a.update(trade(101.0, 10.0)).unwrap();
+ let expected = (101.0_f64 / 100.0).ln().abs() / (101.0 * 10.0);
+ assert_relative_eq!(out, expected, epsilon = 1e-15);
+ }
+
+ #[test]
+ fn higher_for_thinner_volume() {
+ // Same price move on smaller volume => larger illiquidity reading.
+ let thin = {
+ let mut a = AmihudIlliquidity::new(1).unwrap();
+ a.update(trade(100.0, 1.0));
+ a.update(trade(101.0, 1.0)).unwrap()
+ };
+ let thick = {
+ let mut a = AmihudIlliquidity::new(1).unwrap();
+ a.update(trade(100.0, 1000.0));
+ a.update(trade(101.0, 1000.0)).unwrap()
+ };
+ assert!(thin > thick, "thin {thin} should exceed thick {thick}");
+ }
+
+ #[test]
+ fn flat_price_is_zero() {
+ let mut a = AmihudIlliquidity::new(5).unwrap();
+ for v in a.batch(&[trade(100.0, 3.0); 20]).into_iter().flatten() {
+ assert_relative_eq!(v, 0.0, epsilon = 1e-15);
+ }
+ }
+
+ #[test]
+ fn skips_zero_size_trades() {
+ let mut a = AmihudIlliquidity::new(1).unwrap();
+ a.update(trade(100.0, 10.0));
+ let baseline = a.update(trade(101.0, 10.0)).unwrap();
+ // A zero-size trade is ignored; the previous reference price is kept.
+ assert_eq!(a.update(trade(200.0, 0.0)), Some(baseline));
+ // The next real trade still references price 101, not 200.
+ let mut control = a.clone();
+ let after = a.update(trade(102.0, 10.0)).unwrap();
+ assert_eq!(control.update(trade(102.0, 10.0)).unwrap(), after);
+ }
+
+ #[test]
+ fn output_is_non_negative() {
+ let mut a = AmihudIlliquidity::new(10).unwrap();
+ let trades: Vec = (0..100)
+ .map(|i| {
+ trade(
+ 100.0 + (f64::from(i) * 0.3).sin() * 5.0,
+ 1.0 + f64::from(i % 7),
+ )
+ })
+ .collect();
+ for v in a.batch(&trades).into_iter().flatten() {
+ assert!(v >= 0.0, "illiquidity must be non-negative, got {v}");
+ }
+ }
+
+ #[test]
+ fn reset_clears_state() {
+ let mut a = AmihudIlliquidity::new(5).unwrap();
+ for i in 0..20 {
+ a.update(trade(100.0 + f64::from(i), 2.0));
+ }
+ assert!(a.is_ready());
+ a.reset();
+ assert!(!a.is_ready());
+ assert_eq!(a.update(trade(100.0, 1.0)), None);
+ }
+
+ #[test]
+ fn batch_equals_streaming() {
+ let trades: Vec = (0..80)
+ .map(|i| {
+ trade(
+ 100.0 + (f64::from(i) * 0.25).sin() * 4.0,
+ 1.0 + f64::from(i % 5),
+ )
+ })
+ .collect();
+ let batch = AmihudIlliquidity::new(14).unwrap().batch(&trades);
+ let mut b = AmihudIlliquidity::new(14).unwrap();
+ let streamed: Vec<_> = trades.iter().map(|t| b.update(*t)).collect();
+ assert_eq!(batch, streamed);
+ }
+}
diff --git a/crates/wickra-core/src/indicators/body_size_pct.rs b/crates/wickra-core/src/indicators/body_size_pct.rs
new file mode 100644
index 00000000..478bd76a
--- /dev/null
+++ b/crates/wickra-core/src/indicators/body_size_pct.rs
@@ -0,0 +1,193 @@
+//! Body Size Percent — candle body as a fraction of its range.
+
+use crate::ohlcv::Candle;
+use crate::traits::Indicator;
+
+/// Body Size Percent — the absolute body as a fraction of the bar's range.
+///
+/// ```text
+/// BodySizePct = |close − open| / (high − low)
+/// ```
+///
+/// The result lives in `[0, 1]`: `1` is a full-bodied marubozu (the bar opened
+/// at one extreme and closed at the other, no wicks), `0` a doji (open equals
+/// close, the bar is all wick). It is the *unsigned* magnitude companion to
+/// [`BalanceOfPower`](crate::BalanceOfPower) — where `BoP` keeps the direction,
+/// this keeps only the conviction, which is exactly what candlestick body /
+/// range filters key on. A zero-range bar carries no information and yields `0`.
+///
+/// This is a stateless per-bar transform: every candle produces one value.
+///
+/// # Example
+///
+/// ```
+/// use wickra_core::{Candle, Indicator, BodySizePct};
+///
+/// let mut indicator = BodySizePct::new();
+/// // body |12 - 10| = 2, range 14 - 10 = 4 -> 0.5.
+/// let c = Candle::new(10.0, 14.0, 10.0, 12.0, 10.0, 0).unwrap();
+/// assert!((indicator.update(c).unwrap() - 0.5).abs() < 1e-12);
+/// ```
+#[derive(Debug, Clone, Default)]
+pub struct BodySizePct {
+ has_emitted: bool,
+}
+
+impl BodySizePct {
+ /// Construct a new Body Size Percent transform.
+ pub const fn new() -> Self {
+ Self { has_emitted: false }
+ }
+}
+
+impl Indicator for BodySizePct {
+ type Input = Candle;
+ type Output = f64;
+
+ fn update(&mut self, candle: Candle) -> Option {
+ self.has_emitted = true;
+ let range = candle.high - candle.low;
+ let out = if range == 0.0 {
+ // A zero-range bar has no body proportion to speak of.
+ 0.0
+ } else {
+ (candle.close - candle.open).abs() / range
+ };
+ Some(out)
+ }
+
+ 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 {
+ "BodySizePct"
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::traits::BatchExt;
+ use approx::assert_relative_eq;
+
+ fn candle(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle {
+ Candle::new(open, high, low, close, 1.0, ts).unwrap()
+ }
+
+ #[test]
+ fn reference_value() {
+ // |12 - 10| / (14 - 10) = 0.5.
+ let mut bsp = BodySizePct::new();
+ assert_relative_eq!(
+ bsp.update(candle(10.0, 14.0, 10.0, 12.0, 0)).unwrap(),
+ 0.5,
+ epsilon = 1e-12
+ );
+ }
+
+ #[test]
+ fn marubozu_is_one() {
+ // open == low, close == high, no wicks -> full body -> 1.
+ let mut bsp = BodySizePct::new();
+ assert_relative_eq!(
+ bsp.update(candle(9.0, 11.0, 9.0, 11.0, 0)).unwrap(),
+ 1.0,
+ epsilon = 1e-12
+ );
+ }
+
+ #[test]
+ fn doji_is_zero() {
+ // open == close with a real range -> body 0.
+ let mut bsp = BodySizePct::new();
+ assert_relative_eq!(
+ bsp.update(candle(10.0, 12.0, 8.0, 10.0, 0)).unwrap(),
+ 0.0,
+ epsilon = 1e-12
+ );
+ }
+
+ #[test]
+ fn unsigned_regardless_of_direction() {
+ // A red bar with the same body magnitude reads identically to a green one.
+ let mut bsp = BodySizePct::new();
+ let green = bsp.update(candle(10.0, 14.0, 10.0, 12.0, 0)).unwrap();
+ let mut bsp2 = BodySizePct::new();
+ let red = bsp2.update(candle(12.0, 14.0, 10.0, 10.0, 0)).unwrap();
+ assert_relative_eq!(green, red, epsilon = 1e-12);
+ }
+
+ #[test]
+ fn zero_range_bar_yields_zero() {
+ let mut bsp = BodySizePct::new();
+ assert_relative_eq!(
+ bsp.update(candle(10.0, 10.0, 10.0, 10.0, 0)).unwrap(),
+ 0.0,
+ epsilon = 1e-12
+ );
+ }
+
+ #[test]
+ fn stays_within_unit_range() {
+ let candles: Vec = (0..100)
+ .map(|i| {
+ let mid = 100.0 + (f64::from(i) * 0.2).sin() * 8.0;
+ let close = mid + (f64::from(i) * 0.5).cos() * 2.0;
+ candle(mid, mid + 3.0, mid - 3.0, close, i64::from(i))
+ })
+ .collect();
+ let mut bsp = BodySizePct::new();
+ for v in bsp.batch(&candles).into_iter().flatten() {
+ assert!((0.0..=1.0).contains(&v), "BodySizePct {v} outside [0, 1]");
+ }
+ }
+
+ #[test]
+ fn name_metadata() {
+ let bsp = BodySizePct::new();
+ assert_eq!(bsp.name(), "BodySizePct");
+ }
+
+ #[test]
+ fn emits_from_first_candle() {
+ let mut bsp = BodySizePct::new();
+ assert_eq!(bsp.warmup_period(), 1);
+ assert!(!bsp.is_ready());
+ assert!(bsp.update(candle(10.0, 11.0, 9.0, 10.0, 0)).is_some());
+ assert!(bsp.is_ready());
+ }
+
+ #[test]
+ fn reset_clears_state() {
+ let mut bsp = BodySizePct::new();
+ bsp.update(candle(10.0, 11.0, 9.0, 10.0, 0));
+ assert!(bsp.is_ready());
+ bsp.reset();
+ assert!(!bsp.is_ready());
+ }
+
+ #[test]
+ fn batch_equals_streaming() {
+ let candles: Vec = (0..40)
+ .map(|i| {
+ let base = 100.0 + f64::from(i);
+ candle(base, base + 2.0, base - 2.0, base + 1.0, i64::from(i))
+ })
+ .collect();
+ let mut a = BodySizePct::new();
+ let mut b = BodySizePct::new();
+ assert_eq!(
+ a.batch(&candles),
+ candles.iter().map(|x| b.update(*x)).collect::>()
+ );
+ }
+}
diff --git a/crates/wickra-core/src/indicators/close_vs_open.rs b/crates/wickra-core/src/indicators/close_vs_open.rs
new file mode 100644
index 00000000..eee8f74f
--- /dev/null
+++ b/crates/wickra-core/src/indicators/close_vs_open.rs
@@ -0,0 +1,157 @@
+//! Close vs Open — the signed relative body of a bar.
+
+use crate::ohlcv::Candle;
+use crate::traits::Indicator;
+
+/// Close vs Open — the bar's body as a signed fraction of its open price.
+///
+/// ```text
+/// CloseVsOpen = (close − open) / open
+/// ```
+///
+/// A scale-free, signed measure of how far price travelled from open to close:
+/// `+0.02` is a bar that closed 2% above its open (a green bar), `−0.02` the
+/// mirror. Unlike [`BalanceOfPower`](crate::BalanceOfPower) — which normalises
+/// the body by the bar *range* — this normalises by the *open price*, so it is
+/// directly comparable to a return and stays meaningful across instruments of
+/// different nominal price. A zero open carries no scale and yields `0`.
+///
+/// This is a stateless per-bar transform: every candle produces one value.
+///
+/// # Example
+///
+/// ```
+/// use wickra_core::{Candle, Indicator, CloseVsOpen};
+///
+/// let mut indicator = CloseVsOpen::new();
+/// // open 100, close 102 -> +0.02.
+/// let c = Candle::new(100.0, 103.0, 99.0, 102.0, 10.0, 0).unwrap();
+/// assert!((indicator.update(c).unwrap() - 0.02).abs() < 1e-12);
+/// ```
+#[derive(Debug, Clone, Default)]
+pub struct CloseVsOpen {
+ has_emitted: bool,
+}
+
+impl CloseVsOpen {
+ /// Construct a new Close vs Open transform.
+ pub const fn new() -> Self {
+ Self { has_emitted: false }
+ }
+}
+
+impl Indicator for CloseVsOpen {
+ type Input = Candle;
+ type Output = f64;
+
+ fn update(&mut self, candle: Candle) -> Option