From 5eb820a9c7f1c968cf456e60fd0e0ced8d18f47a Mon Sep 17 00:00:00 2001 From: kingchenc Date: Mon, 1 Jun 2026 21:26:37 +0200 Subject: [PATCH] feat: derivatives funding & open-interest indicators (part 1 of 3) (#126) * feat(derivatives): DerivativesTick input type + InvalidDerivatives error * feat(derivatives): FundingRate indicator (core) * feat(derivatives): FundingRateMean indicator (core) * feat(derivatives): FundingRateZScore indicator (core) * feat(derivatives): FundingBasis indicator (core) * feat(derivatives): OpenInterestDelta indicator (core) * feat(derivatives): Python, Node and WASM bindings for funding & OI-delta indicators * test(derivatives): Python and Node tests for funding & OI-delta indicators * bench(derivatives): synthetic-tick bench + derivatives fuzz target * docs(derivatives): README family row + counter 232->237, CHANGELOG entry --- CHANGELOG.md | 14 + README.md | 9 +- bindings/node/__tests__/indicators.test.js | 42 +++ bindings/node/index.d.ts | 45 +++ bindings/node/index.js | 7 +- bindings/node/src/lib.rs | 283 +++++++++++++++ bindings/python/python/wickra/__init__.py | 12 + bindings/python/src/lib.rs | 308 ++++++++++++++++- .../python/tests/test_input_validation.py | 20 ++ bindings/python/tests/test_known_values.py | 33 ++ bindings/python/tests/test_new_indicators.py | 42 +++ bindings/wasm/src/lib.rs | 225 ++++++++++++ crates/wickra-core/src/derivatives.rs | 321 ++++++++++++++++++ crates/wickra-core/src/error.rs | 9 + .../src/indicators/funding_basis.rs | 137 ++++++++ .../src/indicators/funding_rate.rs | 131 +++++++ .../src/indicators/funding_rate_mean.rs | 181 ++++++++++ .../src/indicators/funding_rate_zscore.rs | 201 +++++++++++ crates/wickra-core/src/indicators/mod.rs | 22 +- crates/wickra-core/src/indicators/oi_delta.rs | 142 ++++++++ crates/wickra-core/src/lib.rs | 36 +- crates/wickra/benches/indicators.rs | 73 +++- fuzz/Cargo.toml | 7 + .../indicator_update_derivatives.rs | 49 +++ 24 files changed, 2317 insertions(+), 32 deletions(-) create mode 100644 crates/wickra-core/src/derivatives.rs create mode 100644 crates/wickra-core/src/indicators/funding_basis.rs create mode 100644 crates/wickra-core/src/indicators/funding_rate.rs create mode 100644 crates/wickra-core/src/indicators/funding_rate_mean.rs create mode 100644 crates/wickra-core/src/indicators/funding_rate_zscore.rs create mode 100644 crates/wickra-core/src/indicators/oi_delta.rs create mode 100644 fuzz/fuzz_targets/indicator_update_derivatives.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index f0626948..855a9313 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- **Derivatives family — funding & open interest (part 1).** A new family of + indicators that consume a perpetual / futures tick (`DerivativesTick`, + bundling funding rate, mark / index / futures price, open interest, + positioning, taker flow and liquidations) rather than OHLCV, exposed in Rust, + Python, Node and WASM: + - **Funding Rate** — the current perpetual funding rate. + - **Funding Rate Mean** — the rolling mean funding rate over a window. + - **Funding Rate Z-Score** — the latest funding rate in standard deviations + from its rolling mean. + - **Funding Basis** — the perpetual's relative premium to spot, + `(markPrice − indexPrice) / indexPrice`. + - **Open-Interest Delta** — the tick-over-tick change in open interest. + ## [0.4.3] - 2026-06-01 ### Added diff --git a/README.md b/README.md index 2f61d4f3..aa34f5a3 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@

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

[![CI](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml/badge.svg)](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml) @@ -47,7 +47,7 @@ Full documentation lives at **[docs.wickra.org](https://docs.wickra.org)**: [Node](https://docs.wickra.org/Quickstart-Node), [WASM](https://docs.wickra.org/Quickstart-WASM). - **Indicators** — a per-indicator deep dive (formula, parameters, warmup) for - every one of the 232 indicators; start at the + every one of the 237 indicators; start at the [indicators overview](https://docs.wickra.org/Indicators-Overview). - **Reference** — [warmup periods](https://docs.wickra.org/Warmup-Periods), [streaming vs batch](https://docs.wickra.org/Streaming-vs-Batch), @@ -135,7 +135,7 @@ python -m benchmarks.compare_libraries ## Indicators -232 streaming-first indicators across seventeen families. Every one passes the +237 streaming-first indicators across eighteen 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). @@ -157,6 +157,7 @@ warmup) at [docs.wickra.org](https://docs.wickra.org/Indicators-Overview). | Ichimoku & Charts | Ichimoku Kinko Hyo (Tenkan, Kijun, Senkou A/B, Chikou), Heikin-Ashi | | Candlestick Patterns | Doji, Hammer, Inverted Hammer, Hanging Man, Shooting Star, Engulfing, Harami, Morning/Evening Star, Three White Soldiers/Black Crows, Piercing Line/Dark Cloud Cover, Marubozu, Tweezer, Spinning Top, Three Inside Up/Down, Three Outside Up/Down | | Microstructure | Order-Book Imbalance (Top-1 / Top-N / Full), Microprice, Quoted Spread, Depth Slope, Signed Volume, Cumulative Volume Delta, Trade Imbalance, Effective Spread, Realized Spread, Kyle's Lambda, Footprint | +| Derivatives | Funding Rate, Funding Rate Mean, Funding Rate Z-Score, Funding Basis, Open-Interest Delta | | Market Profile | Value Area (POC / VAH / VAL), Initial Balance, Opening Range | | Risk / Performance | Sharpe Ratio, Sortino Ratio, Calmar Ratio, Omega Ratio, Max Drawdown, Average Drawdown, Drawdown Duration, Pain Index, Value at Risk, Conditional Value at Risk (CVaR), Profit Factor, Gain/Loss Ratio, Recovery Factor, Kelly Criterion, Treynor Ratio, Information Ratio, Alpha (Jensen) | @@ -237,7 +238,7 @@ A Python live-trading example using the public `websockets` package lives at ``` wickra/ ├── crates/ -│ ├── wickra-core/ core engine + all 232 indicators +│ ├── wickra-core/ core engine + all 237 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 c4e27c3b..4cd0012b 100644 --- a/bindings/node/__tests__/indicators.test.js +++ b/bindings/node/__tests__/indicators.test.js @@ -1094,3 +1094,45 @@ test('footprint streaming update matches batch and rejects bad tick', () => { } assert.throws(() => new wickra.Footprint(0)); }); + +test('derivatives indicators reference values', () => { + // Funding rate passes through (and may be negative). + assert.equal(new wickra.FundingRate().update(0.0001), 0.0001); + assert.equal(new wickra.FundingRate().update(-0.0003), -0.0003); + // Rolling mean: window [0.001, 0.003] -> 0.002. + const frm = new wickra.FundingRateMean(2); + assert.equal(frm.update(0.001), null); // warming up + assert.ok(Math.abs(frm.update(0.003) - 0.002) < 1e-12); + // Z-score: window [0.001, 0.003] -> +1. + const z = new wickra.FundingRateZScore(2); + assert.equal(z.update(0.001), null); // warming up + assert.ok(Math.abs(z.update(0.003) - 1.0) < 1e-9); + // Basis: mark 100.5 vs index 100.0 -> 0.005. + assert.ok(Math.abs(new wickra.FundingBasis().update(100.5, 100.0) - 0.005) < 1e-12); + // OI delta: seeds then emits the change. + const oid = new wickra.OpenInterestDelta(); + assert.equal(oid.update(1000), null); + assert.equal(oid.update(1250), 250); + assert.equal(oid.update(1100), -150); +}); + +test('derivatives streaming update matches batch', () => { + const n = 30; + const rate = Array.from({ length: n }, (_, i) => 0.0001 * Math.sin(i * 0.3)); + const batch = new wickra.FundingRateMean(5).batch(rate); + const streamer = new wickra.FundingRateMean(5); + assert.equal(batch.length, n); + for (let i = 0; i < n; i++) { + const s = streamer.update(rate[i]); + assert.ok( + (s === null && Number.isNaN(batch[i])) || Math.abs(s - batch[i]) < 1e-12, + `mismatch at ${i}: ${s} vs ${batch[i]}`, + ); + } +}); + +test('derivatives reject bad input', () => { + assert.throws(() => new wickra.FundingRateMean(0)); + assert.throws(() => new wickra.FundingRateZScore(0)); + assert.throws(() => new wickra.FundingBasis().update(100, 0)); +}); diff --git a/bindings/node/index.d.ts b/bindings/node/index.d.ts index 0be49796..0a6869c8 100644 --- a/bindings/node/index.d.ts +++ b/bindings/node/index.d.ts @@ -2319,6 +2319,51 @@ export declare class Footprint { isReady(): boolean warmupPeriod(): number } +export type FundingRateNode = FundingRate +export declare class FundingRate { + constructor() + update(fundingRate: number): number | null + batch(fundingRate: Array): Array + reset(): void + isReady(): boolean + warmupPeriod(): number +} +export type FundingRateMeanNode = FundingRateMean +export declare class FundingRateMean { + constructor(window: number) + update(fundingRate: number): number | null + batch(fundingRate: Array): Array + reset(): void + isReady(): boolean + warmupPeriod(): number +} +export type FundingRateZScoreNode = FundingRateZScore +export declare class FundingRateZScore { + constructor(window: number) + update(fundingRate: number): number | null + batch(fundingRate: Array): Array + reset(): void + isReady(): boolean + warmupPeriod(): number +} +export type FundingBasisNode = FundingBasis +export declare class FundingBasis { + constructor() + update(markPrice: number, indexPrice: number): number | null + batch(markPrice: Array, indexPrice: Array): Array + reset(): void + isReady(): boolean + warmupPeriod(): number +} +export type OpenInterestDeltaNode = OpenInterestDelta +export declare class OpenInterestDelta { + constructor() + update(openInterest: number): number | null + batch(openInterest: Array): Array + reset(): void + isReady(): boolean + warmupPeriod(): number +} export type SharpeRatioNode = SharpeRatio export declare class SharpeRatio { constructor(period: number, riskFree: number) diff --git a/bindings/node/index.js b/bindings/node/index.js index 5840116e..f79e5578 100644 --- a/bindings/node/index.js +++ b/bindings/node/index.js @@ -310,7 +310,7 @@ if (!nativeBinding) { throw new Error(`Failed to load native binding`) } -const { version, SMA, EMA, WMA, RSI, DEMA, TEMA, HMA, ROC, TRIX, SMMA, TRIMA, ZLEMA, MOM, CMO, DPO, StdDev, UlcerIndex, VerticalHorizontalFilter, ZScore, McGinleyDynamic, FRAMA, SuperSmoother, FisherTransform, Decycler, CenterOfGravity, CyberneticCycle, InstantaneousTrendline, EhlersStochastic, RVIVolatility, Variance, CoefficientOfVariation, Skewness, Kurtosis, StandardError, DetrendedStdDev, RSquared, MedianAbsoluteDeviation, Autocorrelation, HurstExponent, PearsonCorrelation, Beta, PairwiseBeta, SpearmanCorrelation, PairSpreadZScore, LeadLagCrossCorrelation, Cointegration, RelativeStrengthAB, MACD, BollingerBands, ATR, Stochastic, OBV, ADX, ADXR, CCI, WilliamsR, MFI, PSAR, Keltner, Donchian, VWAP, RollingVWAP, AwesomeOscillator, Aroon, Inertia, ConnorsRSI, LaguerreRSI, SMI, KST, PGO, RVI, AwesomeOscillatorHistogram, STC, ElderImpulse, ZeroLagMACD, CFO, APO, KAMA, EVWMA, Alligator, JMA, VIDYA, ALMA, T3, TSI, PMO, TII, ADL, VolumePriceTrend, ChaikinMoneyFlow, ChaikinOscillator, ForceIndex, NVI, PVI, VolumeOscillator, KVO, WilliamsAD, AnchoredVWAP, DemandIndex, TSV, VZO, MarketFacilitationIndex, EaseOfMovement, SuperTrend, ChandelierExit, ChandeKrollStop, AtrTrailingStop, HiLoActivator, VoltyStop, YoyoExit, DonchianStop, PercentageTrailingStop, StepTrailingStop, RenkoTrailingStop, TypicalPrice, MedianPrice, WeightedClose, LinearRegression, LinRegSlope, AcceleratorOscillator, BalanceOfPower, ChoppinessIndex, TrueRange, ChaikinVolatility, YangZhangVolatility, RogersSatchellVolatility, GarmanKlassVolatility, ParkinsonVolatility, LinRegAngle, BollingerBandwidth, PercentB, NATR, HistoricalVolatility, AroonOscillator, WaveTrend, RWI, Vortex, MassIndex, StochRSI, UltimateOscillator, PPO, Coppock, VWMA, MaEnvelope, AccelerationBands, StarcBands, AtrBands, HurstChannel, LinRegChannel, StandardErrorBands, DoubleBollinger, TtmSqueeze, FractalChaosBands, VwapStdDevBands, ClassicPivots, FibonacciPivots, Camarilla, WoodiePivots, DemarkPivots, WilliamsFractals, ZigZag, TDSetup, TDSequential, TDDeMarker, TDREI, TDPressure, TDCombo, TDCountdown, TDLines, TDRangeProjection, TDDifferential, TDOpen, TDRiskLevel, InverseFisherTransform, DecyclerOscillator, RoofingFilter, EmpiricalModeDecomposition, HilbertDominantCycle, AdaptiveCycle, SineWave, MAMA, FAMA, Ichimoku, HeikinAshi, ValueArea, InitialBalance, OpeningRange, Doji, Hammer, InvertedHammer, HangingMan, ShootingStar, Engulfing, Harami, MorningEveningStar, ThreeSoldiersOrCrows, PiercingDarkCloud, Marubozu, Tweezer, SpinningTop, ThreeInside, ThreeOutside, OrderBookImbalanceTop1, OrderBookImbalanceFull, Microprice, QuotedSpread, DepthSlope, OrderBookImbalanceTopN, SignedVolume, CumulativeVolumeDelta, TradeImbalance, EffectiveSpread, RealizedSpread, KylesLambda, Footprint, SharpeRatio, SortinoRatio, CalmarRatio, OmegaRatio, MaxDrawdown, AverageDrawdown, DrawdownDuration, PainIndex, ValueAtRisk, ConditionalValueAtRisk, ProfitFactor, GainLossRatio, RecoveryFactor, KellyCriterion, TreynorRatio, InformationRatio, Alpha } = nativeBinding +const { version, SMA, EMA, WMA, RSI, DEMA, TEMA, HMA, ROC, TRIX, SMMA, TRIMA, ZLEMA, MOM, CMO, DPO, StdDev, UlcerIndex, VerticalHorizontalFilter, ZScore, McGinleyDynamic, FRAMA, SuperSmoother, FisherTransform, Decycler, CenterOfGravity, CyberneticCycle, InstantaneousTrendline, EhlersStochastic, RVIVolatility, Variance, CoefficientOfVariation, Skewness, Kurtosis, StandardError, DetrendedStdDev, RSquared, MedianAbsoluteDeviation, Autocorrelation, HurstExponent, PearsonCorrelation, Beta, PairwiseBeta, SpearmanCorrelation, PairSpreadZScore, LeadLagCrossCorrelation, Cointegration, RelativeStrengthAB, MACD, BollingerBands, ATR, Stochastic, OBV, ADX, ADXR, CCI, WilliamsR, MFI, PSAR, Keltner, Donchian, VWAP, RollingVWAP, AwesomeOscillator, Aroon, Inertia, ConnorsRSI, LaguerreRSI, SMI, KST, PGO, RVI, AwesomeOscillatorHistogram, STC, ElderImpulse, ZeroLagMACD, CFO, APO, KAMA, EVWMA, Alligator, JMA, VIDYA, ALMA, T3, TSI, PMO, TII, ADL, VolumePriceTrend, ChaikinMoneyFlow, ChaikinOscillator, ForceIndex, NVI, PVI, VolumeOscillator, KVO, WilliamsAD, AnchoredVWAP, DemandIndex, TSV, VZO, MarketFacilitationIndex, EaseOfMovement, SuperTrend, ChandelierExit, ChandeKrollStop, AtrTrailingStop, HiLoActivator, VoltyStop, YoyoExit, DonchianStop, PercentageTrailingStop, StepTrailingStop, RenkoTrailingStop, TypicalPrice, MedianPrice, WeightedClose, LinearRegression, LinRegSlope, AcceleratorOscillator, BalanceOfPower, ChoppinessIndex, TrueRange, ChaikinVolatility, YangZhangVolatility, RogersSatchellVolatility, GarmanKlassVolatility, ParkinsonVolatility, LinRegAngle, BollingerBandwidth, PercentB, NATR, HistoricalVolatility, AroonOscillator, WaveTrend, RWI, Vortex, MassIndex, StochRSI, UltimateOscillator, PPO, Coppock, VWMA, MaEnvelope, AccelerationBands, StarcBands, AtrBands, HurstChannel, LinRegChannel, StandardErrorBands, DoubleBollinger, TtmSqueeze, FractalChaosBands, VwapStdDevBands, ClassicPivots, FibonacciPivots, Camarilla, WoodiePivots, DemarkPivots, WilliamsFractals, ZigZag, TDSetup, TDSequential, TDDeMarker, TDREI, TDPressure, TDCombo, TDCountdown, TDLines, TDRangeProjection, TDDifferential, TDOpen, TDRiskLevel, InverseFisherTransform, DecyclerOscillator, RoofingFilter, EmpiricalModeDecomposition, HilbertDominantCycle, AdaptiveCycle, SineWave, MAMA, FAMA, Ichimoku, HeikinAshi, ValueArea, InitialBalance, OpeningRange, Doji, Hammer, InvertedHammer, HangingMan, ShootingStar, Engulfing, Harami, MorningEveningStar, ThreeSoldiersOrCrows, PiercingDarkCloud, Marubozu, Tweezer, SpinningTop, ThreeInside, ThreeOutside, OrderBookImbalanceTop1, OrderBookImbalanceFull, Microprice, QuotedSpread, DepthSlope, OrderBookImbalanceTopN, SignedVolume, CumulativeVolumeDelta, TradeImbalance, EffectiveSpread, RealizedSpread, KylesLambda, Footprint, FundingRate, FundingRateMean, FundingRateZScore, FundingBasis, OpenInterestDelta, SharpeRatio, SortinoRatio, CalmarRatio, OmegaRatio, MaxDrawdown, AverageDrawdown, DrawdownDuration, PainIndex, ValueAtRisk, ConditionalValueAtRisk, ProfitFactor, GainLossRatio, RecoveryFactor, KellyCriterion, TreynorRatio, InformationRatio, Alpha } = nativeBinding module.exports.version = version module.exports.SMA = SMA @@ -528,6 +528,11 @@ module.exports.EffectiveSpread = EffectiveSpread module.exports.RealizedSpread = RealizedSpread module.exports.KylesLambda = KylesLambda module.exports.Footprint = Footprint +module.exports.FundingRate = FundingRate +module.exports.FundingRateMean = FundingRateMean +module.exports.FundingRateZScore = FundingRateZScore +module.exports.FundingBasis = FundingBasis +module.exports.OpenInterestDelta = OpenInterestDelta module.exports.SharpeRatio = SharpeRatio module.exports.SortinoRatio = SortinoRatio module.exports.CalmarRatio = CalmarRatio diff --git a/bindings/node/src/lib.rs b/bindings/node/src/lib.rs index 23ccfa61..1c5eb4b6 100644 --- a/bindings/node/src/lib.rs +++ b/bindings/node/src/lib.rs @@ -9352,6 +9352,289 @@ impl FootprintNode { } } +// ============================== Derivatives ============================== +// +// Derivatives indicators consume a perpetual / futures tick rather than OHLCV. +// Each wrapper exposes only the tick fields its indicator reads; the helpers +// below build a fully-valid `DerivativesTick`, filling the unused fields with +// neutral defaults (prices `1.0`, sizes / rates `0.0`). + +fn deriv_funding(funding_rate: f64) -> napi::Result { + wc::DerivativesTick::new( + funding_rate, + 1.0, + 1.0, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0, + ) + .map_err(map_err) +} + +fn deriv_basis(mark_price: f64, index_price: f64) -> napi::Result { + wc::DerivativesTick::new( + 0.0, + mark_price, + index_price, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0, + ) + .map_err(map_err) +} + +fn deriv_oi(open_interest: f64) -> napi::Result { + wc::DerivativesTick::new( + 0.0, + 1.0, + 1.0, + 1.0, + open_interest, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0, + ) + .map_err(map_err) +} + +#[napi(js_name = "FundingRate")] +pub struct FundingRateNode { + inner: wc::FundingRate, +} + +impl Default for FundingRateNode { + fn default() -> Self { + Self::new() + } +} + +#[napi] +impl FundingRateNode { + #[napi(constructor)] + pub fn new() -> Self { + Self { + inner: wc::FundingRate::new(), + } + } + #[napi] + pub fn update(&mut self, funding_rate: f64) -> napi::Result> { + Ok(self.inner.update(deriv_funding(funding_rate)?)) + } + #[napi] + pub fn batch(&mut self, funding_rate: Vec) -> napi::Result> { + let mut out = Vec::with_capacity(funding_rate.len()); + for rate in funding_rate { + out.push(self.inner.update(deriv_funding(rate)?).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 = "FundingRateMean")] +pub struct FundingRateMeanNode { + inner: wc::FundingRateMean, +} + +#[napi] +impl FundingRateMeanNode { + #[napi(constructor)] + pub fn new(window: u32) -> napi::Result { + Ok(Self { + inner: wc::FundingRateMean::new(window as usize).map_err(map_err)?, + }) + } + #[napi] + pub fn update(&mut self, funding_rate: f64) -> napi::Result> { + Ok(self.inner.update(deriv_funding(funding_rate)?)) + } + #[napi] + pub fn batch(&mut self, funding_rate: Vec) -> napi::Result> { + let mut out = Vec::with_capacity(funding_rate.len()); + for rate in funding_rate { + out.push(self.inner.update(deriv_funding(rate)?).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 = "FundingRateZScore")] +pub struct FundingRateZScoreNode { + inner: wc::FundingRateZScore, +} + +#[napi] +impl FundingRateZScoreNode { + #[napi(constructor)] + pub fn new(window: u32) -> napi::Result { + Ok(Self { + inner: wc::FundingRateZScore::new(window as usize).map_err(map_err)?, + }) + } + #[napi] + pub fn update(&mut self, funding_rate: f64) -> napi::Result> { + Ok(self.inner.update(deriv_funding(funding_rate)?)) + } + #[napi] + pub fn batch(&mut self, funding_rate: Vec) -> napi::Result> { + let mut out = Vec::with_capacity(funding_rate.len()); + for rate in funding_rate { + out.push(self.inner.update(deriv_funding(rate)?).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 = "FundingBasis")] +pub struct FundingBasisNode { + inner: wc::FundingBasis, +} + +impl Default for FundingBasisNode { + fn default() -> Self { + Self::new() + } +} + +#[napi] +impl FundingBasisNode { + #[napi(constructor)] + pub fn new() -> Self { + Self { + inner: wc::FundingBasis::new(), + } + } + #[napi] + pub fn update(&mut self, mark_price: f64, index_price: f64) -> napi::Result> { + Ok(self.inner.update(deriv_basis(mark_price, index_price)?)) + } + #[napi] + pub fn batch(&mut self, mark_price: Vec, index_price: Vec) -> napi::Result> { + if mark_price.len() != index_price.len() { + return Err(NapiError::from_reason( + "mark_price and index_price must be equal length".to_string(), + )); + } + let mut out = Vec::with_capacity(mark_price.len()); + for i in 0..mark_price.len() { + out.push( + self.inner + .update(deriv_basis(mark_price[i], index_price[i])?) + .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 = "OpenInterestDelta")] +pub struct OpenInterestDeltaNode { + inner: wc::OpenInterestDelta, +} + +impl Default for OpenInterestDeltaNode { + fn default() -> Self { + Self::new() + } +} + +#[napi] +impl OpenInterestDeltaNode { + #[napi(constructor)] + pub fn new() -> Self { + Self { + inner: wc::OpenInterestDelta::new(), + } + } + #[napi] + pub fn update(&mut self, open_interest: f64) -> napi::Result> { + Ok(self.inner.update(deriv_oi(open_interest)?)) + } + #[napi] + pub fn batch(&mut self, open_interest: Vec) -> napi::Result> { + let mut out = Vec::with_capacity(open_interest.len()); + for oi in open_interest { + out.push(self.inner.update(deriv_oi(oi)?).unwrap_or(f64::NAN)); + } + Ok(out) + } + #[napi] + pub fn reset(&mut self) { + self.inner.reset(); + } + #[napi(js_name = "isReady")] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[napi(js_name = "warmupPeriod")] + pub fn warmup_period(&self) -> u32 { + self.inner.warmup_period() as u32 + } +} + // ============================== Family 15: Risk / Performance ============================== // Risk metrics with fallible `new` (most need `period >= 2`), so each wrapper diff --git a/bindings/python/python/wickra/__init__.py b/bindings/python/python/wickra/__init__.py index 3ed6c433..ad022311 100644 --- a/bindings/python/python/wickra/__init__.py +++ b/bindings/python/python/wickra/__init__.py @@ -257,6 +257,12 @@ from ._wickra import ( KylesLambda, # Microstructure: footprint Footprint, + # Derivatives + FundingRate, + FundingRateMean, + FundingRateZScore, + FundingBasis, + OpenInterestDelta, # Risk / Performance SharpeRatio, SortinoRatio, @@ -511,6 +517,12 @@ __all__ = [ "KylesLambda", # Microstructure: footprint "Footprint", + # Derivatives + "FundingRate", + "FundingRateMean", + "FundingRateZScore", + "FundingBasis", + "OpenInterestDelta", # Risk / Performance "SharpeRatio", "SortinoRatio", diff --git a/bindings/python/src/lib.rs b/bindings/python/src/lib.rs index 9f763f00..f738b489 100644 --- a/bindings/python/src/lib.rs +++ b/bindings/python/src/lib.rs @@ -28,7 +28,8 @@ fn map_err(e: wc::Error) -> PyErr { | wc::Error::InvalidCandle { .. } | wc::Error::InvalidTick { .. } | wc::Error::InvalidOrderBook { .. } - | wc::Error::InvalidTrade { .. } => PyValueError::new_err(e.to_string()), + | wc::Error::InvalidTrade { .. } + | wc::Error::InvalidDerivatives { .. } => PyValueError::new_err(e.to_string()), } } @@ -12182,6 +12183,305 @@ impl PyFootprint { } } +// ============================== Derivatives ============================== +// +// Derivatives indicators consume a perpetual / futures tick rather than OHLCV. +// Each wrapper exposes only the tick fields its indicator reads; the helpers +// below build a fully-valid `DerivativesTick`, filling the unused fields with +// neutral defaults (prices `1.0`, sizes / rates `0.0`). + +fn deriv_funding(funding_rate: f64) -> PyResult { + wc::DerivativesTick::new( + funding_rate, + 1.0, + 1.0, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0, + ) + .map_err(map_err) +} + +fn deriv_basis(mark_price: f64, index_price: f64) -> PyResult { + wc::DerivativesTick::new( + 0.0, + mark_price, + index_price, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0, + ) + .map_err(map_err) +} + +fn deriv_oi(open_interest: f64) -> PyResult { + wc::DerivativesTick::new( + 0.0, + 1.0, + 1.0, + 1.0, + open_interest, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0, + ) + .map_err(map_err) +} + +// FundingRate takes no parameters; streaming `update(funding_rate)`, `batch` +// over one funding-rate array. +#[pyclass(name = "FundingRate", module = "wickra._wickra", skip_from_py_object)] +#[derive(Clone)] +struct PyFundingRate { + inner: wc::FundingRate, +} + +#[pymethods] +impl PyFundingRate { + #[new] + fn new() -> Self { + Self { + inner: wc::FundingRate::new(), + } + } + fn update(&mut self, funding_rate: f64) -> PyResult> { + Ok(self.inner.update(deriv_funding(funding_rate)?)) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + funding_rate: Vec, + ) -> PyResult>> { + let mut out = Vec::with_capacity(funding_rate.len()); + for rate in funding_rate { + out.push(self.inner.update(deriv_funding(rate)?).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 { + "FundingRate()".to_string() + } +} + +// FundingRateMean carries a `window` parameter. +#[pyclass( + name = "FundingRateMean", + module = "wickra._wickra", + skip_from_py_object +)] +#[derive(Clone)] +struct PyFundingRateMean { + inner: wc::FundingRateMean, +} + +#[pymethods] +impl PyFundingRateMean { + #[new] + fn new(window: usize) -> PyResult { + Ok(Self { + inner: wc::FundingRateMean::new(window).map_err(map_err)?, + }) + } + fn update(&mut self, funding_rate: f64) -> PyResult> { + Ok(self.inner.update(deriv_funding(funding_rate)?)) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + funding_rate: Vec, + ) -> PyResult>> { + let mut out = Vec::with_capacity(funding_rate.len()); + for rate in funding_rate { + out.push(self.inner.update(deriv_funding(rate)?).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!("FundingRateMean(window={})", self.inner.window()) + } +} + +// FundingRateZScore carries a `window` parameter. +#[pyclass( + name = "FundingRateZScore", + module = "wickra._wickra", + skip_from_py_object +)] +#[derive(Clone)] +struct PyFundingRateZScore { + inner: wc::FundingRateZScore, +} + +#[pymethods] +impl PyFundingRateZScore { + #[new] + fn new(window: usize) -> PyResult { + Ok(Self { + inner: wc::FundingRateZScore::new(window).map_err(map_err)?, + }) + } + fn update(&mut self, funding_rate: f64) -> PyResult> { + Ok(self.inner.update(deriv_funding(funding_rate)?)) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + funding_rate: Vec, + ) -> PyResult>> { + let mut out = Vec::with_capacity(funding_rate.len()); + for rate in funding_rate { + out.push(self.inner.update(deriv_funding(rate)?).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!("FundingRateZScore(window={})", self.inner.window()) + } +} + +// FundingBasis takes no parameters; streaming `update(mark_price, index_price)`. +#[pyclass(name = "FundingBasis", module = "wickra._wickra", skip_from_py_object)] +#[derive(Clone)] +struct PyFundingBasis { + inner: wc::FundingBasis, +} + +#[pymethods] +impl PyFundingBasis { + #[new] + fn new() -> Self { + Self { + inner: wc::FundingBasis::new(), + } + } + fn update(&mut self, mark_price: f64, index_price: f64) -> PyResult> { + Ok(self.inner.update(deriv_basis(mark_price, index_price)?)) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + mark_price: Vec, + index_price: Vec, + ) -> PyResult>> { + if mark_price.len() != index_price.len() { + return Err(PyValueError::new_err( + "mark_price and index_price must be equal length", + )); + } + let mut out = Vec::with_capacity(mark_price.len()); + for i in 0..mark_price.len() { + out.push( + self.inner + .update(deriv_basis(mark_price[i], index_price[i])?) + .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 { + "FundingBasis()".to_string() + } +} + +// OpenInterestDelta takes no parameters; streaming `update(open_interest)`. +#[pyclass( + name = "OpenInterestDelta", + module = "wickra._wickra", + skip_from_py_object +)] +#[derive(Clone)] +struct PyOpenInterestDelta { + inner: wc::OpenInterestDelta, +} + +#[pymethods] +impl PyOpenInterestDelta { + #[new] + fn new() -> Self { + Self { + inner: wc::OpenInterestDelta::new(), + } + } + fn update(&mut self, open_interest: f64) -> PyResult> { + Ok(self.inner.update(deriv_oi(open_interest)?)) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + open_interest: Vec, + ) -> PyResult>> { + let mut out = Vec::with_capacity(open_interest.len()); + for oi in open_interest { + out.push(self.inner.update(deriv_oi(oi)?).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 { + "OpenInterestDelta()".to_string() + } +} + // ============================== Family 15: Risk / Performance ============================== #[pyclass(name = "SharpeRatio", module = "wickra._wickra", skip_from_py_object)] @@ -13304,6 +13604,12 @@ fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; // Microstructure: footprint. m.add_class::()?; + // Derivatives. + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; // Family 15: Risk / Performance metrics. m.add_class::()?; m.add_class::()?; diff --git a/bindings/python/tests/test_input_validation.py b/bindings/python/tests/test_input_validation.py index 85bddce2..7e372e58 100644 --- a/bindings/python/tests/test_input_validation.py +++ b/bindings/python/tests/test_input_validation.py @@ -238,3 +238,23 @@ def test_footprint_non_positive_tick_raises(): ta.Footprint(0.0) with pytest.raises(ValueError): ta.Footprint(-1.0) + + +def test_funding_rate_mean_zero_window_raises(): + with pytest.raises(ValueError): + ta.FundingRateMean(0) + + +def test_funding_rate_zscore_zero_window_raises(): + with pytest.raises(ValueError): + ta.FundingRateZScore(0) + + +def test_funding_basis_non_positive_index_raises(): + with pytest.raises(ValueError): + ta.FundingBasis().update(100.0, 0.0) + + +def test_funding_rate_non_finite_raises(): + with pytest.raises(ValueError): + ta.FundingRate().update(float("nan")) diff --git a/bindings/python/tests/test_known_values.py b/bindings/python/tests/test_known_values.py index 14aa7ff5..098e81a5 100644 --- a/bindings/python/tests/test_known_values.py +++ b/bindings/python/tests/test_known_values.py @@ -946,3 +946,36 @@ def test_kyles_lambda_recovers_constant_impact(): mids.append(mid) out = ta.KylesLambda(6).batch(price, size, is_buy, mids) assert out[-1] == pytest.approx(0.5, abs=1e-9) + + +def test_funding_rate_reference_values(): + assert ta.FundingRate().update(0.0001) == pytest.approx(0.0001) + assert ta.FundingRate().update(-0.0003) == pytest.approx(-0.0003) + + +def test_funding_rate_mean_reference_value(): + frm = ta.FundingRateMean(2) + assert frm.update(0.001) is None # warming up + # Window [0.001, 0.003] -> mean 0.002. + assert frm.update(0.003) == pytest.approx(0.002) + + +def test_funding_rate_zscore_reference_value(): + z = ta.FundingRateZScore(2) + assert z.update(0.001) is None # warming up + # Window [0.001, 0.003]: mean 0.002, population stddev 0.001 -> +1. + assert z.update(0.003) == pytest.approx(1.0, abs=1e-9) + + +def test_funding_basis_reference_value(): + # mark 100.5 vs index 100.0 -> (100.5 - 100.0) / 100.0 = 0.005. + assert ta.FundingBasis().update(100.5, 100.0) == pytest.approx(0.005) + # A discount reads negative. + assert ta.FundingBasis().update(99.5, 100.0) == pytest.approx(-0.005) + + +def test_open_interest_delta_reference_value(): + oid = ta.OpenInterestDelta() + assert oid.update(1000.0) is None # seeds the previous OI + assert oid.update(1250.0) == pytest.approx(250.0) + assert oid.update(1100.0) == pytest.approx(-150.0) diff --git a/bindings/python/tests/test_new_indicators.py b/bindings/python/tests/test_new_indicators.py index c2a8754d..1d02290c 100644 --- a/bindings/python/tests/test_new_indicators.py +++ b/bindings/python/tests/test_new_indicators.py @@ -1952,3 +1952,45 @@ def test_footprint_streaming_equals_batch(): for i in range(n): streamed = streamer.update(price[i], size[i], is_buy[i]) assert np.array_equal(streamed, batch[i]) + + +def test_funding_indicators_streaming_equals_batch(): + n = 40 + rate = np.array([0.0001 * math.sin(i * 0.3) for i in range(n)], dtype=np.float64) + for make in ( + ta.FundingRate, + lambda: ta.FundingRateMean(5), + lambda: ta.FundingRateZScore(5), + ): + batch = make().batch(rate) + streamer = make() + streamed = np.array( + [streamer.update(rate[i]) for i in range(n)], dtype=np.float64 + ) + assert batch.shape == (n,) + assert _eq_nan(batch, streamed) + + +def test_funding_basis_streaming_equals_batch(): + n = 40 + index = np.array([100.0 + 0.5 * math.sin(i * 0.2) for i in range(n)], dtype=np.float64) + mark = np.array( + [index[i] + 0.1 * math.cos(i * 0.3) for i in range(n)], dtype=np.float64 + ) + batch = ta.FundingBasis().batch(mark, index) + streamer = ta.FundingBasis() + streamed = np.array( + [streamer.update(mark[i], index[i]) for i in range(n)], dtype=np.float64 + ) + assert batch.shape == (n,) + assert _eq_nan(batch, streamed) + + +def test_open_interest_delta_streaming_equals_batch(): + n = 40 + oi = np.array([1000.0 + 50.0 * math.sin(i * 0.25) for i in range(n)], dtype=np.float64) + batch = ta.OpenInterestDelta().batch(oi) + streamer = ta.OpenInterestDelta() + streamed = np.array([streamer.update(oi[i]) for i in range(n)], dtype=np.float64) + assert batch.shape == (n,) + assert _eq_nan(batch, streamed) diff --git a/bindings/wasm/src/lib.rs b/bindings/wasm/src/lib.rs index 34ed1ad2..ed2d4c00 100644 --- a/bindings/wasm/src/lib.rs +++ b/bindings/wasm/src/lib.rs @@ -6747,6 +6747,231 @@ impl WasmFootprint { } } +// ============================== Derivatives ============================== +// +// Derivatives indicators consume a perpetual / futures tick rather than OHLCV. +// Each `update(...)` takes only the tick fields its indicator reads — the +// streaming model for a live browser derivatives feed. Batch over a tape is +// provided by the Python and Node bindings. The helpers build a fully-valid +// `DerivativesTick`, filling unused fields with neutral defaults. + +fn deriv_funding(funding_rate: f64) -> Result { + wc::DerivativesTick::new( + funding_rate, + 1.0, + 1.0, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0, + ) + .map_err(map_err) +} + +fn deriv_basis(mark_price: f64, index_price: f64) -> Result { + wc::DerivativesTick::new( + 0.0, + mark_price, + index_price, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0, + ) + .map_err(map_err) +} + +fn deriv_oi(open_interest: f64) -> Result { + wc::DerivativesTick::new( + 0.0, + 1.0, + 1.0, + 1.0, + open_interest, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0, + ) + .map_err(map_err) +} + +#[wasm_bindgen(js_name = FundingRate)] +pub struct WasmFundingRate { + inner: wc::FundingRate, +} + +impl Default for WasmFundingRate { + fn default() -> Self { + Self::new() + } +} + +#[wasm_bindgen(js_class = FundingRate)] +impl WasmFundingRate { + #[wasm_bindgen(constructor)] + pub fn new() -> WasmFundingRate { + Self { + inner: wc::FundingRate::new(), + } + } + pub fn update(&mut self, funding_rate: f64) -> Result, JsError> { + Ok(self.inner.update(deriv_funding(funding_rate)?)) + } + pub fn reset(&mut self) { + self.inner.reset(); + } + #[wasm_bindgen(js_name = isReady)] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[wasm_bindgen(js_name = warmupPeriod)] + pub fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } +} + +#[wasm_bindgen(js_name = FundingRateMean)] +pub struct WasmFundingRateMean { + inner: wc::FundingRateMean, +} + +#[wasm_bindgen(js_class = FundingRateMean)] +impl WasmFundingRateMean { + #[wasm_bindgen(constructor)] + pub fn new(window: usize) -> Result { + Ok(Self { + inner: wc::FundingRateMean::new(window).map_err(map_err)?, + }) + } + pub fn update(&mut self, funding_rate: f64) -> Result, JsError> { + Ok(self.inner.update(deriv_funding(funding_rate)?)) + } + pub fn reset(&mut self) { + self.inner.reset(); + } + #[wasm_bindgen(js_name = isReady)] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[wasm_bindgen(js_name = warmupPeriod)] + pub fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } +} + +#[wasm_bindgen(js_name = FundingRateZScore)] +pub struct WasmFundingRateZScore { + inner: wc::FundingRateZScore, +} + +#[wasm_bindgen(js_class = FundingRateZScore)] +impl WasmFundingRateZScore { + #[wasm_bindgen(constructor)] + pub fn new(window: usize) -> Result { + Ok(Self { + inner: wc::FundingRateZScore::new(window).map_err(map_err)?, + }) + } + pub fn update(&mut self, funding_rate: f64) -> Result, JsError> { + Ok(self.inner.update(deriv_funding(funding_rate)?)) + } + pub fn reset(&mut self) { + self.inner.reset(); + } + #[wasm_bindgen(js_name = isReady)] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[wasm_bindgen(js_name = warmupPeriod)] + pub fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } +} + +#[wasm_bindgen(js_name = FundingBasis)] +pub struct WasmFundingBasis { + inner: wc::FundingBasis, +} + +impl Default for WasmFundingBasis { + fn default() -> Self { + Self::new() + } +} + +#[wasm_bindgen(js_class = FundingBasis)] +impl WasmFundingBasis { + #[wasm_bindgen(constructor)] + pub fn new() -> WasmFundingBasis { + Self { + inner: wc::FundingBasis::new(), + } + } + pub fn update(&mut self, mark_price: f64, index_price: f64) -> Result, JsError> { + Ok(self.inner.update(deriv_basis(mark_price, index_price)?)) + } + pub fn reset(&mut self) { + self.inner.reset(); + } + #[wasm_bindgen(js_name = isReady)] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[wasm_bindgen(js_name = warmupPeriod)] + pub fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } +} + +#[wasm_bindgen(js_name = OpenInterestDelta)] +pub struct WasmOpenInterestDelta { + inner: wc::OpenInterestDelta, +} + +impl Default for WasmOpenInterestDelta { + fn default() -> Self { + Self::new() + } +} + +#[wasm_bindgen(js_class = OpenInterestDelta)] +impl WasmOpenInterestDelta { + #[wasm_bindgen(constructor)] + pub fn new() -> WasmOpenInterestDelta { + Self { + inner: wc::OpenInterestDelta::new(), + } + } + pub fn update(&mut self, open_interest: f64) -> Result, JsError> { + Ok(self.inner.update(deriv_oi(open_interest)?)) + } + pub fn reset(&mut self) { + self.inner.reset(); + } + #[wasm_bindgen(js_name = isReady)] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[wasm_bindgen(js_name = warmupPeriod)] + pub fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/wickra-core/src/derivatives.rs b/crates/wickra-core/src/derivatives.rs new file mode 100644 index 00000000..ef290aba --- /dev/null +++ b/crates/wickra-core/src/derivatives.rs @@ -0,0 +1,321 @@ +//! Derivatives value type: the perpetual / futures tick. +//! +//! [`DerivativesTick`] is the non-OHLCV input consumed by the derivatives / +//! perpetual-futures indicator family. A single tick bundles the funding, +//! price, open-interest, positioning, taker-flow and liquidation fields a +//! perp/futures venue publishes per update; each indicator reads only the +//! subset it needs (the same one-rich-type-per-family pattern as [`Trade`] / +//! [`OrderBook`] in [`crate::microstructure`]). +//! +//! [`Trade`]: crate::microstructure::Trade +//! [`OrderBook`]: crate::microstructure::OrderBook + +use crate::error::{Error, Result}; + +/// A single derivatives / perpetual-futures market tick. +/// +/// Field invariants enforced by [`new`](DerivativesTick::new): +/// +/// - `funding_rate` is finite and **may be negative** (a negative funding rate +/// means shorts pay longs). +/// - `mark_price`, `index_price` and `futures_price` are finite and strictly +/// positive. +/// - `open_interest`, `long_size`, `short_size`, `taker_buy_volume`, +/// `taker_sell_volume`, `long_liquidation` and `short_liquidation` are finite +/// and non-negative. +/// +/// `timestamp` is a caller-defined epoch / resolution and is not validated. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct DerivativesTick { + /// Current funding rate for the interval (finite; may be negative). + pub funding_rate: f64, + /// Perpetual mark price (finite, strictly positive). + pub mark_price: f64, + /// Spot / index price the perpetual tracks (finite, strictly positive). + pub index_price: f64, + /// Dated (e.g. quarterly) futures mark price (finite, strictly positive). + pub futures_price: f64, + /// Open interest — outstanding contracts / notional (finite, non-negative). + pub open_interest: f64, + /// Aggregate long size / long account count (finite, non-negative). + pub long_size: f64, + /// Aggregate short size / short account count (finite, non-negative). + pub short_size: f64, + /// Taker buy (ask-lifting) volume (finite, non-negative). + pub taker_buy_volume: f64, + /// Taker sell (bid-hitting) volume (finite, non-negative). + pub taker_sell_volume: f64, + /// Long-side liquidation notional (finite, non-negative). + pub long_liquidation: f64, + /// Short-side liquidation notional (finite, non-negative). + pub short_liquidation: f64, + /// Tick timestamp (caller-defined epoch / resolution). + pub timestamp: i64, +} + +impl DerivativesTick { + /// Construct a derivatives tick, validating every field invariant. + /// + /// # Errors + /// + /// Returns [`Error::InvalidDerivatives`] if `funding_rate` is not finite; + /// any of `mark_price`, `index_price`, `futures_price` is not a finite + /// positive number; or any of the six size / volume / liquidation fields is + /// not a finite non-negative number. + #[allow(clippy::too_many_arguments)] + pub fn new( + funding_rate: f64, + mark_price: f64, + index_price: f64, + futures_price: f64, + open_interest: f64, + long_size: f64, + short_size: f64, + taker_buy_volume: f64, + taker_sell_volume: f64, + long_liquidation: f64, + short_liquidation: f64, + timestamp: i64, + ) -> Result { + if !funding_rate.is_finite() { + return Err(Error::InvalidDerivatives { + message: "funding_rate must be finite", + }); + } + for price in [mark_price, index_price, futures_price] { + if !price.is_finite() || price <= 0.0 { + return Err(Error::InvalidDerivatives { + message: + "mark_price, index_price and futures_price must be finite and positive", + }); + } + } + for amount in [ + open_interest, + long_size, + short_size, + taker_buy_volume, + taker_sell_volume, + long_liquidation, + short_liquidation, + ] { + if !amount.is_finite() || amount < 0.0 { + return Err(Error::InvalidDerivatives { + message: "open interest, sizes, volumes and liquidations must be finite and non-negative", + }); + } + } + Ok(Self { + funding_rate, + mark_price, + index_price, + futures_price, + open_interest, + long_size, + short_size, + taker_buy_volume, + taker_sell_volume, + long_liquidation, + short_liquidation, + timestamp, + }) + } + + /// Construct a derivatives tick without validation. The caller asserts that + /// every field invariant documented on [`DerivativesTick`] holds. + #[allow(clippy::too_many_arguments)] + #[must_use] + pub const fn new_unchecked( + funding_rate: f64, + mark_price: f64, + index_price: f64, + futures_price: f64, + open_interest: f64, + long_size: f64, + short_size: f64, + taker_buy_volume: f64, + taker_sell_volume: f64, + long_liquidation: f64, + short_liquidation: f64, + timestamp: i64, + ) -> Self { + Self { + funding_rate, + mark_price, + index_price, + futures_price, + open_interest, + long_size, + short_size, + taker_buy_volume, + taker_sell_volume, + long_liquidation, + short_liquidation, + timestamp, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A fully valid tick used as a baseline; individual tests override one + /// field to exercise a single reject branch. + fn valid() -> DerivativesTick { + DerivativesTick::new( + 0.0001, 100.0, 99.5, 100.5, 1_000.0, 600.0, 400.0, 50.0, 40.0, 5.0, 3.0, 42, + ) + .unwrap() + } + + #[test] + fn new_accepts_valid() { + let tick = valid(); + assert_eq!(tick.funding_rate, 0.0001); + assert_eq!(tick.mark_price, 100.0); + assert_eq!(tick.index_price, 99.5); + assert_eq!(tick.futures_price, 100.5); + assert_eq!(tick.open_interest, 1_000.0); + assert_eq!(tick.long_size, 600.0); + assert_eq!(tick.short_size, 400.0); + assert_eq!(tick.taker_buy_volume, 50.0); + assert_eq!(tick.taker_sell_volume, 40.0); + assert_eq!(tick.long_liquidation, 5.0); + assert_eq!(tick.short_liquidation, 3.0); + assert_eq!(tick.timestamp, 42); + } + + #[test] + fn new_accepts_negative_funding_and_zero_amounts() { + let tick = DerivativesTick::new( + -0.0005, 100.0, 100.0, 100.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0, + ) + .unwrap(); + assert_eq!(tick.funding_rate, -0.0005); + assert_eq!(tick.open_interest, 0.0); + } + + #[test] + fn new_rejects_non_finite_funding() { + assert!(matches!( + DerivativesTick::new( + f64::NAN, + 100.0, + 100.0, + 100.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0 + ), + Err(Error::InvalidDerivatives { .. }) + )); + assert!(matches!( + DerivativesTick::new( + f64::INFINITY, + 100.0, + 100.0, + 100.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0 + ), + Err(Error::InvalidDerivatives { .. }) + )); + } + + #[test] + fn new_rejects_non_positive_mark() { + assert!(matches!( + DerivativesTick::new(0.0, 0.0, 100.0, 100.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0), + Err(Error::InvalidDerivatives { .. }) + )); + } + + #[test] + fn new_rejects_non_positive_index() { + assert!(matches!( + DerivativesTick::new(0.0, 100.0, -1.0, 100.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0), + Err(Error::InvalidDerivatives { .. }) + )); + } + + #[test] + fn new_rejects_non_finite_futures() { + assert!(matches!( + DerivativesTick::new( + 0.0, + 100.0, + 100.0, + f64::NAN, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0 + ), + Err(Error::InvalidDerivatives { .. }) + )); + } + + #[test] + fn new_rejects_negative_open_interest() { + assert!(matches!( + DerivativesTick::new(0.0, 100.0, 100.0, 100.0, -1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0), + Err(Error::InvalidDerivatives { .. }) + )); + } + + #[test] + fn new_rejects_non_finite_size() { + assert!(matches!( + DerivativesTick::new( + 0.0, + 100.0, + 100.0, + 100.0, + 0.0, + f64::INFINITY, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0 + ), + Err(Error::InvalidDerivatives { .. }) + )); + } + + #[test] + fn new_rejects_negative_liquidation() { + assert!(matches!( + DerivativesTick::new(0.0, 100.0, 100.0, 100.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, -2.0, 0), + Err(Error::InvalidDerivatives { .. }) + )); + } + + #[test] + fn new_unchecked_preserves_fields() { + let tick = DerivativesTick::new_unchecked( + -1.0, -2.0, -3.0, -4.0, -5.0, -6.0, -7.0, -8.0, -9.0, -10.0, -11.0, 7, + ); + assert_eq!(tick.funding_rate, -1.0); + assert_eq!(tick.mark_price, -2.0); + assert_eq!(tick.short_liquidation, -11.0); + assert_eq!(tick.timestamp, 7); + } +} diff --git a/crates/wickra-core/src/error.rs b/crates/wickra-core/src/error.rs index d00f0d94..b91f2da3 100644 --- a/crates/wickra-core/src/error.rs +++ b/crates/wickra-core/src/error.rs @@ -43,6 +43,15 @@ pub enum Error { /// non-finite price or negative size) was provided. #[error("invalid trade: {message}")] InvalidTrade { message: &'static str }, + + /// A derivatives tick whose components do not satisfy the tick invariants + /// (e.g. a non-positive price, a non-finite funding rate, or a negative + /// size/volume/liquidation) was provided. Derivatives ticks (funding / + /// open-interest / liquidation feeds) are a perpetual-futures input + /// distinct from candles, order books and trades, so they surface as their + /// own variant. + #[error("invalid derivatives tick: {message}")] + InvalidDerivatives { message: &'static str }, } /// Convenience alias for `Result`. diff --git a/crates/wickra-core/src/indicators/funding_basis.rs b/crates/wickra-core/src/indicators/funding_basis.rs new file mode 100644 index 00000000..bb804778 --- /dev/null +++ b/crates/wickra-core/src/indicators/funding_basis.rs @@ -0,0 +1,137 @@ +//! Funding Basis — the perpetual mark's relative premium to the spot index. + +use crate::derivatives::DerivativesTick; +use crate::traits::Indicator; + +/// Funding Basis — the relative basis between the perpetual mark price and the +/// spot index it tracks. +/// +/// ```text +/// basis = (markPrice − indexPrice) / indexPrice +/// ``` +/// +/// The basis is the spread that the funding mechanism continuously pulls toward +/// zero: a positive basis (perpetual above spot) goes hand in hand with positive +/// funding (longs pay), a negative basis with negative funding. Reading the +/// instantaneous basis alongside the [funding rate] separates a genuine premium +/// from a stale-funding artefact and sizes the carry available to a cash-and-carry +/// or basis-arbitrage trade. The output is a fraction (e.g. `0.001` = 10 bps); +/// multiply by `10_000` for basis points. +/// +/// `Input = DerivativesTick`, `Output = f64`. Stateless; ready after the first +/// tick. +/// +/// [funding rate]: crate::FundingRate +/// +/// # Example +/// +/// ``` +/// use wickra_core::{DerivativesTick, FundingBasis, Indicator}; +/// +/// let mut fb = FundingBasis::new(); +/// // mark 100.5 vs index 100.0 -> (100.5 - 100.0) / 100.0 = 0.005. +/// let tick = DerivativesTick::new( +/// 0.0, 100.5, 100.0, 100.5, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0, +/// ) +/// .unwrap(); +/// assert!((fb.update(tick).unwrap() - 0.005).abs() < 1e-12); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct FundingBasis { + has_emitted: bool, +} + +impl FundingBasis { + /// Construct a new funding-basis indicator. + #[must_use] + pub const fn new() -> Self { + Self { has_emitted: false } + } +} + +impl Indicator for FundingBasis { + type Input = DerivativesTick; + type Output = f64; + + fn update(&mut self, tick: DerivativesTick) -> Option { + self.has_emitted = true; + Some((tick.mark_price - tick.index_price) / tick.index_price) + } + + 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 { + "FundingBasis" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn tick(mark: f64, index: f64) -> DerivativesTick { + DerivativesTick::new_unchecked(0.0, mark, index, mark, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0) + } + + #[test] + fn accessors_and_metadata() { + let fb = FundingBasis::new(); + assert_eq!(fb.name(), "FundingBasis"); + assert_eq!(fb.warmup_period(), 1); + assert!(!fb.is_ready()); + } + + #[test] + fn premium_is_positive() { + let mut fb = FundingBasis::new(); + let out = fb.update(tick(100.5, 100.0)).unwrap(); + assert!((out - 0.005).abs() < 1e-12); + assert!(fb.is_ready()); + } + + #[test] + fn discount_is_negative() { + let mut fb = FundingBasis::new(); + let out = fb.update(tick(99.5, 100.0)).unwrap(); + assert!((out + 0.005).abs() < 1e-12); + } + + #[test] + fn at_par_is_zero() { + let mut fb = FundingBasis::new(); + assert_eq!(fb.update(tick(100.0, 100.0)), Some(0.0)); + } + + #[test] + fn batch_equals_streaming() { + let ticks: Vec = (0..20) + .map(|i| tick(100.0 + f64::from(i % 5) * 0.1, 100.0)) + .collect(); + let mut a = FundingBasis::new(); + let mut b = FundingBasis::new(); + assert_eq!( + a.batch(&ticks), + ticks.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut fb = FundingBasis::new(); + fb.update(tick(100.5, 100.0)); + assert!(fb.is_ready()); + fb.reset(); + assert!(!fb.is_ready()); + } +} diff --git a/crates/wickra-core/src/indicators/funding_rate.rs b/crates/wickra-core/src/indicators/funding_rate.rs new file mode 100644 index 00000000..37c6d23b --- /dev/null +++ b/crates/wickra-core/src/indicators/funding_rate.rs @@ -0,0 +1,131 @@ +//! Funding Rate — the current perpetual funding rate. + +use crate::derivatives::DerivativesTick; +use crate::traits::Indicator; + +/// Funding Rate — the funding rate carried by each derivatives tick. +/// +/// The funding rate is the periodic payment exchanged between long and short +/// perpetual-swap holders that tethers the perpetual mark to the spot index. A +/// positive rate means longs pay shorts (the perpetual trades at a premium); a +/// negative rate means shorts pay longs (a discount). This indicator simply +/// surfaces the rate from the [`DerivativesTick`] feed so it can be charted, +/// chained or fed to the rolling funding statistics ([`FundingRateMean`], +/// [`FundingRateZScore`]). +/// +/// `Input = DerivativesTick`, `Output = f64`. Stateless; ready after the first +/// tick. +/// +/// [`FundingRateMean`]: crate::FundingRateMean +/// [`FundingRateZScore`]: crate::FundingRateZScore +/// +/// # Example +/// +/// ``` +/// use wickra_core::{DerivativesTick, FundingRate, Indicator}; +/// +/// let mut fr = FundingRate::new(); +/// let tick = DerivativesTick::new( +/// 0.0001, 100.0, 100.0, 100.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0, +/// ) +/// .unwrap(); +/// assert_eq!(fr.update(tick), Some(0.0001)); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct FundingRate { + has_emitted: bool, +} + +impl FundingRate { + /// Construct a new funding-rate indicator. + #[must_use] + pub const fn new() -> Self { + Self { has_emitted: false } + } +} + +impl Indicator for FundingRate { + type Input = DerivativesTick; + type Output = f64; + + fn update(&mut self, tick: DerivativesTick) -> Option { + self.has_emitted = true; + Some(tick.funding_rate) + } + + 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 { + "FundingRate" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn tick(funding_rate: f64) -> DerivativesTick { + DerivativesTick::new_unchecked( + funding_rate, + 100.0, + 100.0, + 100.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0, + ) + } + + #[test] + fn accessors_and_metadata() { + let fr = FundingRate::new(); + assert_eq!(fr.name(), "FundingRate"); + assert_eq!(fr.warmup_period(), 1); + assert!(!fr.is_ready()); + } + + #[test] + fn passes_through_funding_rate() { + let mut fr = FundingRate::new(); + assert_eq!(fr.update(tick(0.0001)), Some(0.0001)); + assert_eq!(fr.update(tick(-0.0003)), Some(-0.0003)); + assert!(fr.is_ready()); + } + + #[test] + fn batch_equals_streaming() { + let ticks: Vec = + (0..20).map(|i| tick(0.0001 * f64::from(i - 10))).collect(); + let mut a = FundingRate::new(); + let mut b = FundingRate::new(); + assert_eq!( + a.batch(&ticks), + ticks.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut fr = FundingRate::new(); + fr.update(tick(0.0001)); + assert!(fr.is_ready()); + fr.reset(); + assert!(!fr.is_ready()); + } +} diff --git a/crates/wickra-core/src/indicators/funding_rate_mean.rs b/crates/wickra-core/src/indicators/funding_rate_mean.rs new file mode 100644 index 00000000..7f342337 --- /dev/null +++ b/crates/wickra-core/src/indicators/funding_rate_mean.rs @@ -0,0 +1,181 @@ +//! Funding Rate Rolling Mean — average funding rate over a trailing window. + +use std::collections::VecDeque; + +use crate::derivatives::DerivativesTick; +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Funding Rate Rolling Mean — the arithmetic mean of the funding rate over the +/// trailing window of `window` ticks. +/// +/// ```text +/// mean = (1 / window) · Σ fundingRate over the last `window` ticks +/// ``` +/// +/// Smoothing the raw [funding rate] reveals the persistent carry regime — a +/// sustained positive mean marks a crowded-long market paying to hold the +/// perpetual, a sustained negative mean a crowded-short one. The indicator warms +/// up for `window` ticks — `update` returns `None` until the window is full — +/// then emits the rolling mean, maintained in O(1) per tick via a running sum. +/// +/// `Input = DerivativesTick`, `Output = f64`. +/// +/// [funding rate]: crate::FundingRate +/// +/// # Example +/// +/// ``` +/// use wickra_core::{DerivativesTick, FundingRateMean, Indicator}; +/// +/// fn tick(rate: f64) -> DerivativesTick { +/// DerivativesTick::new(rate, 100.0, 100.0, 100.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0) +/// .unwrap() +/// } +/// +/// let mut frm = FundingRateMean::new(2).unwrap(); +/// assert_eq!(frm.update(tick(0.001)), None); +/// // Window full: (0.001 + 0.003) / 2 = 0.002. +/// assert_eq!(frm.update(tick(0.003)), Some(0.002)); +/// ``` +#[derive(Debug, Clone)] +pub struct FundingRateMean { + window: usize, + history: VecDeque, + sum: f64, +} + +impl FundingRateMean { + /// Construct a funding-rate rolling mean over a window of `window` ticks. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `window` is zero. + pub fn new(window: usize) -> Result { + if window == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + window, + history: VecDeque::with_capacity(window), + sum: 0.0, + }) + } + + /// The configured window length, in ticks. + #[must_use] + pub fn window(&self) -> usize { + self.window + } +} + +impl Indicator for FundingRateMean { + type Input = DerivativesTick; + type Output = f64; + + fn update(&mut self, tick: DerivativesTick) -> Option { + self.history.push_back(tick.funding_rate); + self.sum += tick.funding_rate; + if self.history.len() > self.window { + let old = self.history.pop_front().expect("window >= 1, len > window"); + self.sum -= old; + } + if self.history.len() < self.window { + return None; + } + Some(self.sum / self.window as f64) + } + + fn reset(&mut self) { + self.history.clear(); + self.sum = 0.0; + } + + fn warmup_period(&self) -> usize { + self.window + } + + fn is_ready(&self) -> bool { + self.history.len() >= self.window + } + + fn name(&self) -> &'static str { + "FundingRateMean" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn tick(rate: f64) -> DerivativesTick { + DerivativesTick::new_unchecked( + rate, 100.0, 100.0, 100.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0, + ) + } + + #[test] + fn rejects_zero_window() { + assert!(matches!(FundingRateMean::new(0), Err(Error::PeriodZero))); + } + + #[test] + fn accessors_and_metadata() { + let frm = FundingRateMean::new(5).unwrap(); + assert_eq!(frm.name(), "FundingRateMean"); + assert_eq!(frm.warmup_period(), 5); + assert_eq!(frm.window(), 5); + assert!(!frm.is_ready()); + } + + #[test] + fn warms_up_then_emits_mean() { + let mut frm = FundingRateMean::new(2).unwrap(); + assert_eq!(frm.update(tick(0.001)), None); + assert!(!frm.is_ready()); + assert_eq!(frm.update(tick(0.003)), Some(0.002)); + assert!(frm.is_ready()); + } + + #[test] + fn rolls_off_old_values() { + let mut frm = FundingRateMean::new(2).unwrap(); + frm.update(tick(0.001)); + frm.update(tick(0.003)); // mean 0.002 + let out = frm.update(tick(0.005)).unwrap(); // window [0.003, 0.005] -> 0.004 + assert!((out - 0.004).abs() < 1e-12); + } + + #[test] + fn handles_negative_rates() { + let mut frm = FundingRateMean::new(2).unwrap(); + frm.update(tick(-0.002)); + let out = frm.update(tick(0.004)).unwrap(); + assert!((out - 0.001).abs() < 1e-12); + } + + #[test] + fn batch_equals_streaming() { + let ticks: Vec = (0..30) + .map(|i| tick(0.0001 * f64::from(i % 7) - 0.0003)) + .collect(); + let mut a = FundingRateMean::new(5).unwrap(); + let mut b = FundingRateMean::new(5).unwrap(); + assert_eq!( + a.batch(&ticks), + ticks.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut frm = FundingRateMean::new(2).unwrap(); + frm.update(tick(0.001)); + frm.update(tick(0.003)); + assert!(frm.is_ready()); + frm.reset(); + assert!(!frm.is_ready()); + assert_eq!(frm.update(tick(0.002)), None); + } +} diff --git a/crates/wickra-core/src/indicators/funding_rate_zscore.rs b/crates/wickra-core/src/indicators/funding_rate_zscore.rs new file mode 100644 index 00000000..de687123 --- /dev/null +++ b/crates/wickra-core/src/indicators/funding_rate_zscore.rs @@ -0,0 +1,201 @@ +//! Funding Rate Z-Score — how extreme the latest funding rate is versus its +//! recent history. + +use std::collections::VecDeque; + +use crate::derivatives::DerivativesTick; +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Funding Rate Z-Score — the latest funding rate expressed in standard +/// deviations from its rolling mean over the trailing window of `window` ticks. +/// +/// ```text +/// zScore = (fundingRate − mean) / population_stddev over the last `window` ticks +/// ``` +/// +/// A reading of `+2` means funding is two standard deviations richer than its +/// recent norm — an unusually crowded long, a contrarian fade signal; `−2` is +/// the mirror. Normalising the [funding rate] this way makes funding extremes +/// comparable across regimes and assets. A window with zero dispersion (a flat +/// funding series) yields `0`. The indicator warms up for `window` ticks, then +/// emits the rolling z-score, maintained in O(1) per tick. +/// +/// `Input = DerivativesTick`, `Output = f64`. +/// +/// [funding rate]: crate::FundingRate +/// +/// # Example +/// +/// ``` +/// use wickra_core::{DerivativesTick, FundingRateZScore, Indicator}; +/// +/// fn tick(rate: f64) -> DerivativesTick { +/// DerivativesTick::new(rate, 100.0, 100.0, 100.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0) +/// .unwrap() +/// } +/// +/// let mut z = FundingRateZScore::new(2).unwrap(); +/// assert_eq!(z.update(tick(0.001)), None); +/// // Window [0.001, 0.003]: mean 0.002, population stddev 0.001 -> (0.003 - 0.002) / 0.001 = 1. +/// assert!((z.update(tick(0.003)).unwrap() - 1.0).abs() < 1e-9); +/// ``` +#[derive(Debug, Clone)] +pub struct FundingRateZScore { + window: usize, + history: VecDeque, + sum: f64, + sum_sq: f64, +} + +impl FundingRateZScore { + /// Construct a funding-rate z-score over a window of `window` ticks. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `window` is zero. + pub fn new(window: usize) -> Result { + if window == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + window, + history: VecDeque::with_capacity(window), + sum: 0.0, + sum_sq: 0.0, + }) + } + + /// The configured window length, in ticks. + #[must_use] + pub fn window(&self) -> usize { + self.window + } +} + +impl Indicator for FundingRateZScore { + type Input = DerivativesTick; + type Output = f64; + + fn update(&mut self, tick: DerivativesTick) -> Option { + let value = tick.funding_rate; + if self.history.len() == self.window { + let old = self.history.pop_front().expect("non-empty"); + self.sum -= old; + self.sum_sq -= old * old; + } + self.history.push_back(value); + self.sum += value; + self.sum_sq += value * value; + if self.history.len() < self.window { + return None; + } + let n = self.window as f64; + let mean = self.sum / n; + // Population variance E[x²] − E[x]²; clamp away tiny negative drift. + let variance = (self.sum_sq / n - mean * mean).max(0.0); + let std = variance.sqrt(); + if std == 0.0 { + // A window with no dispersion: funding is exactly its own mean. + return Some(0.0); + } + Some((value - mean) / std) + } + + fn reset(&mut self) { + self.history.clear(); + self.sum = 0.0; + self.sum_sq = 0.0; + } + + fn warmup_period(&self) -> usize { + self.window + } + + fn is_ready(&self) -> bool { + self.history.len() == self.window + } + + fn name(&self) -> &'static str { + "FundingRateZScore" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn tick(rate: f64) -> DerivativesTick { + DerivativesTick::new_unchecked( + rate, 100.0, 100.0, 100.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0, + ) + } + + #[test] + fn rejects_zero_window() { + assert!(matches!(FundingRateZScore::new(0), Err(Error::PeriodZero))); + } + + #[test] + fn accessors_and_metadata() { + let z = FundingRateZScore::new(5).unwrap(); + assert_eq!(z.name(), "FundingRateZScore"); + assert_eq!(z.warmup_period(), 5); + assert_eq!(z.window(), 5); + assert!(!z.is_ready()); + } + + #[test] + fn reference_value() { + let mut z = FundingRateZScore::new(2).unwrap(); + assert_eq!(z.update(tick(0.001)), None); + // Window [0.001, 0.003]: mean 0.002, var (1e-6 + 9e-6)/2 - 4e-6 = 1e-6, + // stddev 0.001; latest 0.003 is (0.003 - 0.002) / 0.001 = 1. + let out = z.update(tick(0.003)).unwrap(); + assert!((out - 1.0).abs() < 1e-9); + assert!(z.is_ready()); + } + + #[test] + fn flat_window_is_zero() { + let mut z = FundingRateZScore::new(3).unwrap(); + z.update(tick(0.002)); + z.update(tick(0.002)); + assert_eq!(z.update(tick(0.002)), Some(0.0)); + } + + #[test] + fn rolls_off_old_values() { + let mut z = FundingRateZScore::new(2).unwrap(); + z.update(tick(0.001)); + z.update(tick(0.003)); + // Window now [0.003, 0.005]: mean 0.004, stddev 0.001 -> +1. + let out = z.update(tick(0.005)).unwrap(); + assert!((out - 1.0).abs() < 1e-9); + } + + #[test] + fn batch_equals_streaming() { + let ticks: Vec = (0..30) + .map(|i| tick(0.0001 * f64::from(i % 5) - 0.0002)) + .collect(); + let mut a = FundingRateZScore::new(6).unwrap(); + let mut b = FundingRateZScore::new(6).unwrap(); + assert_eq!( + a.batch(&ticks), + ticks.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut z = FundingRateZScore::new(2).unwrap(); + z.update(tick(0.001)); + z.update(tick(0.003)); + assert!(z.is_ready()); + z.reset(); + assert!(!z.is_ready()); + assert_eq!(z.update(tick(0.002)), None); + } +} diff --git a/crates/wickra-core/src/indicators/mod.rs b/crates/wickra-core/src/indicators/mod.rs index 18c33eab..820a414b 100644 --- a/crates/wickra-core/src/indicators/mod.rs +++ b/crates/wickra-core/src/indicators/mod.rs @@ -77,6 +77,10 @@ mod footprint; mod force_index; mod fractal_chaos_bands; mod frama; +mod funding_basis; +mod funding_rate; +mod funding_rate_mean; +mod funding_rate_zscore; mod gain_loss_ratio; mod garman_klass; mod hammer; @@ -130,6 +134,7 @@ mod ob_imbalance_full; mod ob_imbalance_top1; mod ob_imbalance_topn; mod obv; +mod oi_delta; mod omega_ratio; mod opening_range; mod pain_index; @@ -309,6 +314,10 @@ pub use footprint::{Footprint, FootprintLevel, FootprintOutput}; pub use force_index::ForceIndex; pub use fractal_chaos_bands::{FractalChaosBands, FractalChaosBandsOutput}; pub use frama::Frama; +pub use funding_basis::FundingBasis; +pub use funding_rate::FundingRate; +pub use funding_rate_mean::FundingRateMean; +pub use funding_rate_zscore::FundingRateZScore; pub use gain_loss_ratio::GainLossRatio; pub use garman_klass::GarmanKlassVolatility; pub use hammer::Hammer; @@ -362,6 +371,7 @@ pub use ob_imbalance_full::OrderBookImbalanceFull; pub use ob_imbalance_top1::OrderBookImbalanceTop1; pub use ob_imbalance_topn::OrderBookImbalanceTopN; pub use obv::Obv; +pub use oi_delta::OpenInterestDelta; pub use omega_ratio::OmegaRatio; pub use opening_range::{OpeningRange, OpeningRangeOutput}; pub use pain_index::PainIndex; @@ -751,6 +761,16 @@ pub const FAMILIES: &[(&str, &[&str])] = &[ "Footprint", ], ), + ( + "Derivatives", + &[ + "FundingRate", + "FundingRateMean", + "FundingRateZScore", + "FundingBasis", + "OpenInterestDelta", + ], + ), ( "Market Profile", &["ValueArea", "InitialBalance", "OpeningRange"], @@ -805,6 +825,6 @@ mod family_tests { // the actual indicator count is the early-warning signal that an // indicator was added without being assigned a family. let total: usize = FAMILIES.iter().map(|(_, ns)| ns.len()).sum(); - assert_eq!(total, 227, "FAMILIES total drifted from indicator count"); + assert_eq!(total, 232, "FAMILIES total drifted from indicator count"); } } diff --git a/crates/wickra-core/src/indicators/oi_delta.rs b/crates/wickra-core/src/indicators/oi_delta.rs new file mode 100644 index 00000000..99a12540 --- /dev/null +++ b/crates/wickra-core/src/indicators/oi_delta.rs @@ -0,0 +1,142 @@ +//! Open-Interest Delta — the tick-over-tick change in open interest. + +use crate::derivatives::DerivativesTick; +use crate::traits::Indicator; + +/// Open-Interest Delta — the change in open interest from the previous tick. +/// +/// ```text +/// delta = openInterestₜ − openInterestₜ₋₁ +/// ``` +/// +/// Open interest is the count of outstanding contracts; its change separates new +/// positioning from mere turnover. Read together with price, rising OI confirms +/// a trend (fresh money entering) while falling OI flags an unwind (positions +/// closing) — the raw input to the [OI / price divergence] signal. A positive +/// delta is net position-building, a negative delta net liquidation/closing. +/// +/// The first tick only seeds the previous value and returns `None`; from the +/// second tick on the indicator emits the delta. +/// +/// `Input = DerivativesTick`, `Output = f64`. +/// +/// [OI / price divergence]: crate::OIPriceDivergence +/// +/// # Example +/// +/// ``` +/// use wickra_core::{DerivativesTick, Indicator, OpenInterestDelta}; +/// +/// fn tick(oi: f64) -> DerivativesTick { +/// DerivativesTick::new(0.0, 100.0, 100.0, 100.0, oi, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0) +/// .unwrap() +/// } +/// +/// let mut oid = OpenInterestDelta::new(); +/// assert_eq!(oid.update(tick(1_000.0)), None); // seeds the previous OI +/// assert_eq!(oid.update(tick(1_250.0)), Some(250.0)); +/// assert_eq!(oid.update(tick(1_100.0)), Some(-150.0)); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct OpenInterestDelta { + prev: Option, + has_emitted: bool, +} + +impl OpenInterestDelta { + /// Construct a new open-interest delta indicator. + #[must_use] + pub const fn new() -> Self { + Self { + prev: None, + has_emitted: false, + } + } +} + +impl Indicator for OpenInterestDelta { + type Input = DerivativesTick; + type Output = f64; + + fn update(&mut self, tick: DerivativesTick) -> Option { + let oi = tick.open_interest; + let delta = self.prev.map(|prev| oi - prev); + self.prev = Some(oi); + if delta.is_some() { + self.has_emitted = true; + } + delta + } + + fn reset(&mut self) { + self.prev = None; + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 2 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "OpenInterestDelta" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn tick(oi: f64) -> DerivativesTick { + DerivativesTick::new_unchecked( + 0.0, 100.0, 100.0, 100.0, oi, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0, + ) + } + + #[test] + fn accessors_and_metadata() { + let oid = OpenInterestDelta::new(); + assert_eq!(oid.name(), "OpenInterestDelta"); + assert_eq!(oid.warmup_period(), 2); + assert!(!oid.is_ready()); + } + + #[test] + fn seeds_then_emits_delta() { + let mut oid = OpenInterestDelta::new(); + assert_eq!(oid.update(tick(1_000.0)), None); + assert!(!oid.is_ready()); + assert_eq!(oid.update(tick(1_250.0)), Some(250.0)); + assert!(oid.is_ready()); + assert_eq!(oid.update(tick(1_100.0)), Some(-150.0)); + } + + #[test] + fn batch_equals_streaming() { + let ticks: Vec = (0..20) + .map(|i| tick(1_000.0 + f64::from(i * i % 13) * 10.0)) + .collect(); + let mut a = OpenInterestDelta::new(); + let mut b = OpenInterestDelta::new(); + assert_eq!( + a.batch(&ticks), + ticks.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut oid = OpenInterestDelta::new(); + oid.update(tick(1_000.0)); + oid.update(tick(1_250.0)); + assert!(oid.is_ready()); + oid.reset(); + assert!(!oid.is_ready()); + // After reset the next tick only re-seeds, returning None. + assert_eq!(oid.update(tick(2_000.0)), None); + } +} diff --git a/crates/wickra-core/src/lib.rs b/crates/wickra-core/src/lib.rs index 2f33c4ce..65d62e1c 100644 --- a/crates/wickra-core/src/lib.rs +++ b/crates/wickra-core/src/lib.rs @@ -36,6 +36,7 @@ #![cfg_attr(docsrs, feature(doc_cfg))] +mod derivatives; mod error; mod microstructure; mod ohlcv; @@ -43,6 +44,7 @@ mod traits; pub mod indicators; +pub use derivatives::DerivativesTick; pub use error::{Error, Result}; pub use indicators::{ AccelerationBands, AccelerationBandsOutput, AcceleratorOscillator, AdOscillator, AdaptiveCycle, @@ -60,23 +62,23 @@ pub use indicators::{ DrawdownDuration, EaseOfMovement, EffectiveSpread, EhlersStochastic, ElderImpulse, Ema, EmpiricalModeDecomposition, Engulfing, Evwma, Fama, FibonacciPivots, FibonacciPivotsOutput, FisherTransform, Footprint, FootprintOutput, ForceIndex, FractalChaosBands, - FractalChaosBandsOutput, Frama, GainLossRatio, GarmanKlassVolatility, Hammer, HangingMan, - Harami, HeikinAshi, HeikinAshiOutput, HiLoActivator, HilbertDominantCycle, - HistoricalVolatility, Hma, HurstChannel, HurstChannelOutput, HurstExponent, Ichimoku, - IchimokuOutput, Inertia, InformationRatio, InitialBalance, InitialBalanceOutput, - InstantaneousTrendline, InverseFisherTransform, InvertedHammer, Jma, Kama, KellyCriterion, - Keltner, KeltnerOutput, Kst, KstOutput, Kurtosis, Kvo, KylesLambda, LaguerreRsi, - LeadLagCrossCorrelation, LeadLagCrossCorrelationOutput, LinRegAngle, LinRegChannel, - LinRegChannelOutput, LinRegSlope, LinearRegression, MaEnvelope, MaEnvelopeOutput, - MacdIndicator, MacdOutput, Mama, MamaOutput, MarketFacilitationIndex, Marubozu, MassIndex, - MaxDrawdown, McGinleyDynamic, MedianAbsoluteDeviation, MedianPrice, Mfi, Microprice, Mom, - MorningEveningStar, Natr, Nvi, Obv, OmegaRatio, OpeningRange, OpeningRangeOutput, - OrderBookImbalanceFull, OrderBookImbalanceTop1, OrderBookImbalanceTopN, PainIndex, - PairSpreadZScore, PairwiseBeta, ParkinsonVolatility, PearsonCorrelation, PercentB, - PercentageTrailingStop, Pgo, PiercingDarkCloud, Pmo, Ppo, ProfitFactor, Psar, Pvi, - QuotedSpread, RSquared, RealizedSpread, RecoveryFactor, RelativeStrengthAB, - RelativeStrengthOutput, RenkoTrailingStop, Roc, RogersSatchellVolatility, RollingVwap, - RoofingFilter, Rsi, Rvi, RviVolatility, Rwi, RwiOutput, SharpeRatio, ShootingStar, + FractalChaosBandsOutput, Frama, FundingBasis, FundingRate, FundingRateMean, FundingRateZScore, + GainLossRatio, GarmanKlassVolatility, Hammer, HangingMan, Harami, HeikinAshi, HeikinAshiOutput, + HiLoActivator, HilbertDominantCycle, HistoricalVolatility, Hma, HurstChannel, + HurstChannelOutput, HurstExponent, Ichimoku, IchimokuOutput, Inertia, InformationRatio, + InitialBalance, InitialBalanceOutput, InstantaneousTrendline, InverseFisherTransform, + InvertedHammer, Jma, Kama, KellyCriterion, Keltner, KeltnerOutput, Kst, KstOutput, Kurtosis, + Kvo, KylesLambda, LaguerreRsi, LeadLagCrossCorrelation, LeadLagCrossCorrelationOutput, + LinRegAngle, LinRegChannel, LinRegChannelOutput, LinRegSlope, LinearRegression, MaEnvelope, + MaEnvelopeOutput, MacdIndicator, MacdOutput, Mama, MamaOutput, MarketFacilitationIndex, + Marubozu, MassIndex, MaxDrawdown, McGinleyDynamic, MedianAbsoluteDeviation, MedianPrice, Mfi, + Microprice, Mom, MorningEveningStar, Natr, Nvi, Obv, OmegaRatio, OpenInterestDelta, + OpeningRange, OpeningRangeOutput, OrderBookImbalanceFull, OrderBookImbalanceTop1, + OrderBookImbalanceTopN, PainIndex, PairSpreadZScore, PairwiseBeta, ParkinsonVolatility, + PearsonCorrelation, PercentB, PercentageTrailingStop, Pgo, PiercingDarkCloud, Pmo, Ppo, + ProfitFactor, Psar, Pvi, QuotedSpread, RSquared, RealizedSpread, RecoveryFactor, + RelativeStrengthAB, RelativeStrengthOutput, RenkoTrailingStop, Roc, RogersSatchellVolatility, + RollingVwap, RoofingFilter, Rsi, Rvi, RviVolatility, Rwi, RwiOutput, SharpeRatio, ShootingStar, SignedVolume, SineWave, Skewness, Sma, Smi, Smma, SortinoRatio, SpearmanCorrelation, SpinningTop, StandardError, StandardErrorBands, StandardErrorBandsOutput, StarcBands, StarcBandsOutput, Stc, StdDev, StepTrailingStop, StochRsi, Stochastic, StochasticOutput, diff --git a/crates/wickra/benches/indicators.rs b/crates/wickra/benches/indicators.rs index 30e48883..a9d22c07 100644 --- a/crates/wickra/benches/indicators.rs +++ b/crates/wickra/benches/indicators.rs @@ -33,14 +33,15 @@ use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion, Through use std::hint::black_box; use wickra::{ Adx, Atr, Autocorrelation, BatchExt, BollingerBands, BollingerOutput, CalmarRatio, Candle, Cci, - ClassicPivots, ConnorsRsi, DepthSlope, EffectiveSpread, Ema, EmpiricalModeDecomposition, - Engulfing, Frama, HilbertDominantCycle, HurstExponent, Ichimoku, IchimokuOutput, Indicator, - Jma, KylesLambda, Level, LinearRegression, MacdIndicator, MacdOutput, Mama, MamaOutput, - MaxDrawdown, Microprice, Obv, OrderBook, OrderBookImbalanceFull, OrderBookImbalanceTop1, - ParkinsonVolatility, Ppo, Psar, RollingVwap, Rsi, SharpeRatio, Side, SignedVolume, Sma, Stc, - SuperTrend, SuperTrendOutput, TdSequential, TdSequentialOutput, Trade, TradeImbalance, - TradeQuote, TtmSqueeze, TtmSqueezeOutput, ValueArea, ValueAreaOutput, ValueAtRisk, Vwap, - VwapStdDevBands, VwapStdDevBandsOutput, WaveTrend, YangZhangVolatility, T3, + ClassicPivots, ConnorsRsi, DepthSlope, DerivativesTick, EffectiveSpread, Ema, + EmpiricalModeDecomposition, Engulfing, Frama, FundingRate, FundingRateZScore, + HilbertDominantCycle, HurstExponent, Ichimoku, IchimokuOutput, Indicator, Jma, KylesLambda, + Level, LinearRegression, MacdIndicator, MacdOutput, Mama, MamaOutput, MaxDrawdown, Microprice, + Obv, OrderBook, OrderBookImbalanceFull, OrderBookImbalanceTop1, ParkinsonVolatility, Ppo, Psar, + RollingVwap, Rsi, SharpeRatio, Side, SignedVolume, Sma, Stc, SuperTrend, SuperTrendOutput, + TdSequential, TdSequentialOutput, Trade, TradeImbalance, TradeQuote, TtmSqueeze, + TtmSqueezeOutput, ValueArea, ValueAreaOutput, ValueAtRisk, Vwap, VwapStdDevBands, + VwapStdDevBandsOutput, WaveTrend, YangZhangVolatility, T3, }; use wickra_data::csv::CandleReader; @@ -181,6 +182,32 @@ where group.finish(); } +fn bench_derivatives_input( + c: &mut Criterion, + name: &str, + ticks: &[DerivativesTick], + make: F, +) where + F: Fn() -> I, + I: Indicator, +{ + let mut group = c.benchmark_group(name); + for &n in SIZES { + let n = n.min(ticks.len()); + let series = &ticks[..n]; + group.throughput(Throughput::Elements(n as u64)); + group.bench_with_input(BenchmarkId::new("streaming", n), series, |b, ticks| { + b.iter(|| { + let mut ind = make(); + for tick in ticks { + black_box(ind.update(*tick)); + } + }); + }); + } + group.finish(); +} + fn bench_scalar_multi(c: &mut Criterion, name: &str, prices: &[f64], make: F) where F: Fn() -> I, @@ -384,6 +411,36 @@ fn benches(c: &mut Criterion) { .collect(); bench_tradequote_input(c, "effective_spread", "es, EffectiveSpread::new); bench_tradequote_input(c, "kyles_lambda", "es, || KylesLambda::new(50).unwrap()); + + // === Family — Derivatives === + // No derivatives feed ships with the repo, so synthesise a tick per candle: + // the close drives the mark price, funding tracks the candle's body, and + // open interest follows volume. FundingRate is the cheapest (passthrough); + // FundingRateZScore carries a rolling window and is the most expensive. + let ticks: Vec = candles + .iter() + .map(|candle| { + let funding = (candle.close - candle.open) / candle.open * 0.01; + DerivativesTick::new_unchecked( + funding, + candle.close, + candle.close * 0.999, + candle.close * 1.001, + candle.volume * 100.0, + candle.volume * 0.6, + candle.volume * 0.4, + candle.volume * 0.5, + candle.volume * 0.5, + 0.0, + 0.0, + candle.timestamp, + ) + }) + .collect(); + bench_derivatives_input(c, "funding_rate", &ticks, FundingRate::new); + bench_derivatives_input(c, "funding_rate_zscore", &ticks, || { + FundingRateZScore::new(50).unwrap() + }); } criterion_group!(name = wickra_benches; config = Criterion::default(); targets = benches); diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index 9ecac434..e25a2107 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -73,6 +73,13 @@ test = false doc = false bench = false +[[bin]] +name = "indicator_update_derivatives" +path = "fuzz_targets/indicator_update_derivatives.rs" +test = false +doc = false +bench = false + [[bin]] name = "tick_aggregator" path = "fuzz_targets/tick_aggregator.rs" diff --git a/fuzz/fuzz_targets/indicator_update_derivatives.rs b/fuzz/fuzz_targets/indicator_update_derivatives.rs new file mode 100644 index 00000000..5271feb4 --- /dev/null +++ b/fuzz/fuzz_targets/indicator_update_derivatives.rs @@ -0,0 +1,49 @@ +#![no_main] +//! Fuzz derivatives `Indicator` implementations with +//! arbitrary perpetual / futures tick streams. +//! +//! Each iteration consumes a byte stream, interprets it as a sequence of `f64` +//! values (8 bytes each), and packs consecutive groups of eleven into a +//! [`DerivativesTick`]'s numeric fields. Ticks are built with `new_unchecked` +//! so the fuzzer can explore degenerate values (non-finite, negative, zero +//! prices) that the validating constructor would reject — the indicators must +//! never panic, streaming or batched. + +use libfuzzer_sys::fuzz_target; +use wickra_core::{ + BatchExt, DerivativesTick, FundingBasis, FundingRate, FundingRateMean, FundingRateZScore, + Indicator, OpenInterestDelta, +}; + +#[inline(never)] +fn drive(make: impl Fn() -> I, ticks: &[DerivativesTick]) +where + I: Indicator + BatchExt, +{ + let mut streaming = make(); + for &tick in ticks { + let _ = streaming.update(tick); + } + let _ = make().batch(ticks); +} + +fuzz_target!(|data: &[u8]| { + let floats: Vec = data + .chunks_exact(8) + .map(|c| f64::from_le_bytes(c.try_into().expect("8 bytes"))) + .collect(); + let ticks: Vec = floats + .chunks_exact(11) + .map(|c| { + DerivativesTick::new_unchecked( + c[0], c[1], c[2], c[3], c[4], c[5], c[6], c[7], c[8], c[9], c[10], 0, + ) + }) + .collect(); + + drive(FundingRate::new, &ticks); + drive(|| FundingRateMean::new(5).unwrap(), &ticks); + drive(|| FundingRateZScore::new(5).unwrap(), &ticks); + drive(FundingBasis::new, &ticks); + drive(OpenInterestDelta::new, &ticks); +});