diff --git a/CHANGELOG.md b/CHANGELOG.md index 49d5266b..d7963197 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Taker Buy/Sell Ratio** — taker buy volume over taker sell volume. - **Liquidation Features** — a multi-output breakdown of long/short liquidation notional into net, total and a bounded imbalance. +- **Derivatives family — basis & term structure (part 3).** The final + perpetual-vs-futures basis indicators over the `DerivativesTick` feed: + - **Term-Structure Basis** — the dated future's relative premium to spot, + `(futuresPrice − indexPrice) / indexPrice`. + - **Calendar Spread** — the dated future's relative premium to the perpetual, + `(futuresPrice − markPrice) / markPrice`. ## [0.4.3] - 2026-06-01 diff --git a/README.md b/README.md index d3299afb..296fce8b 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 242 indicators; start at the + every one of the 244 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 -242 streaming-first indicators across eighteen families. Every one passes the +244 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,7 +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, OI / Price Divergence, OI-Weighted Price, Long/Short Ratio, Taker Buy/Sell Ratio, Liquidation Features | +| 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), 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) | @@ -238,7 +238,7 @@ A Python live-trading example using the public `websockets` package lives at ``` wickra/ ├── crates/ -│ ├── wickra-core/ core engine + all 242 indicators +│ ├── wickra-core/ core engine + all 244 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 e16d9de9..39f29fcd 100644 --- a/bindings/node/__tests__/indicators.test.js +++ b/bindings/node/__tests__/indicators.test.js @@ -1175,3 +1175,28 @@ test('OI flow rejects bad input', () => { assert.throws(() => new wickra.OIPriceDivergence(0)); assert.throws(() => new wickra.OIWeighted().update(0, 100)); }); + +test('basis & calendar-spread reference values', () => { + // futures 102 vs index 100 -> 0.02 contango. + assert.ok(Math.abs(new wickra.TermStructureBasis().update(102, 100) - 0.02) < 1e-12); + assert.ok(Math.abs(new wickra.TermStructureBasis().update(98, 100) + 0.02) < 1e-12); + // futures 101 vs perpetual mark 100 -> 0.01. + assert.ok(Math.abs(new wickra.CalendarSpread().update(101, 100) - 0.01) < 1e-12); +}); + +test('basis streaming update matches batch', () => { + const n = 20; + const index = Array.from({ length: n }, (_, i) => 100 + Math.sin(i * 0.2)); + const futures = Array.from({ length: n }, (_, i) => index[i] + 0.5); + const batch = new wickra.TermStructureBasis().batch(futures, index); + const streamer = new wickra.TermStructureBasis(); + assert.equal(batch.length, n); + for (let i = 0; i < n; i++) { + assert.ok(Math.abs(streamer.update(futures[i], index[i]) - batch[i]) < 1e-12); + } +}); + +test('basis rejects bad input', () => { + assert.throws(() => new wickra.TermStructureBasis().update(100, 0)); + assert.throws(() => new wickra.CalendarSpread().update(100, 0)); +}); diff --git a/bindings/node/index.d.ts b/bindings/node/index.d.ts index 5ca64cad..9077e13d 100644 --- a/bindings/node/index.d.ts +++ b/bindings/node/index.d.ts @@ -2417,6 +2417,24 @@ export declare class LiquidationFeatures { isReady(): boolean warmupPeriod(): number } +export type TermStructureBasisNode = TermStructureBasis +export declare class TermStructureBasis { + constructor() + update(futuresPrice: number, indexPrice: number): number | null + batch(futuresPrice: Array, indexPrice: Array): Array + reset(): void + isReady(): boolean + warmupPeriod(): number +} +export type CalendarSpreadNode = CalendarSpread +export declare class CalendarSpread { + constructor() + update(futuresPrice: number, markPrice: number): number | null + batch(futuresPrice: Array, markPrice: 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 05a97f88..e1c892e1 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, FundingRate, FundingRateMean, FundingRateZScore, FundingBasis, OpenInterestDelta, OIPriceDivergence, OIWeighted, LongShortRatio, TakerBuySellRatio, LiquidationFeatures, 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, OIPriceDivergence, OIWeighted, LongShortRatio, TakerBuySellRatio, LiquidationFeatures, TermStructureBasis, CalendarSpread, 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 @@ -538,6 +538,8 @@ module.exports.OIWeighted = OIWeighted module.exports.LongShortRatio = LongShortRatio module.exports.TakerBuySellRatio = TakerBuySellRatio module.exports.LiquidationFeatures = LiquidationFeatures +module.exports.TermStructureBasis = TermStructureBasis +module.exports.CalendarSpread = CalendarSpread 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 98dc070f..4d65e58e 100644 --- a/bindings/node/src/lib.rs +++ b/bindings/node/src/lib.rs @@ -9477,6 +9477,42 @@ fn deriv_liquidation( .map_err(map_err) } +fn deriv_futures_index(futures_price: f64, index_price: f64) -> napi::Result { + wc::DerivativesTick::new( + 0.0, + 1.0, + index_price, + futures_price, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0, + ) + .map_err(map_err) +} + +fn deriv_futures_mark(futures_price: f64, mark_price: f64) -> napi::Result { + wc::DerivativesTick::new( + 0.0, + mark_price, + 1.0, + futures_price, + 0.0, + 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, @@ -10015,6 +10051,126 @@ impl LiquidationFeaturesNode { } } +#[napi(js_name = "TermStructureBasis")] +pub struct TermStructureBasisNode { + inner: wc::TermStructureBasis, +} + +impl Default for TermStructureBasisNode { + fn default() -> Self { + Self::new() + } +} + +#[napi] +impl TermStructureBasisNode { + #[napi(constructor)] + pub fn new() -> Self { + Self { + inner: wc::TermStructureBasis::new(), + } + } + #[napi] + pub fn update(&mut self, futures_price: f64, index_price: f64) -> napi::Result> { + Ok(self + .inner + .update(deriv_futures_index(futures_price, index_price)?)) + } + #[napi] + pub fn batch( + &mut self, + futures_price: Vec, + index_price: Vec, + ) -> napi::Result> { + if futures_price.len() != index_price.len() { + return Err(NapiError::from_reason( + "futures_price and index_price must be equal length".to_string(), + )); + } + let mut out = Vec::with_capacity(futures_price.len()); + for i in 0..futures_price.len() { + out.push( + self.inner + .update(deriv_futures_index(futures_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 = "CalendarSpread")] +pub struct CalendarSpreadNode { + inner: wc::CalendarSpread, +} + +impl Default for CalendarSpreadNode { + fn default() -> Self { + Self::new() + } +} + +#[napi] +impl CalendarSpreadNode { + #[napi(constructor)] + pub fn new() -> Self { + Self { + inner: wc::CalendarSpread::new(), + } + } + #[napi] + pub fn update(&mut self, futures_price: f64, mark_price: f64) -> napi::Result> { + Ok(self + .inner + .update(deriv_futures_mark(futures_price, mark_price)?)) + } + #[napi] + pub fn batch( + &mut self, + futures_price: Vec, + mark_price: Vec, + ) -> napi::Result> { + if futures_price.len() != mark_price.len() { + return Err(NapiError::from_reason( + "futures_price and mark_price must be equal length".to_string(), + )); + } + let mut out = Vec::with_capacity(futures_price.len()); + for i in 0..futures_price.len() { + out.push( + self.inner + .update(deriv_futures_mark(futures_price[i], mark_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 + } +} + // ============================== 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 68d6e0fd..207be12d 100644 --- a/bindings/python/python/wickra/__init__.py +++ b/bindings/python/python/wickra/__init__.py @@ -268,6 +268,8 @@ from ._wickra import ( LongShortRatio, TakerBuySellRatio, LiquidationFeatures, + TermStructureBasis, + CalendarSpread, # Risk / Performance SharpeRatio, SortinoRatio, @@ -533,6 +535,8 @@ __all__ = [ "LongShortRatio", "TakerBuySellRatio", "LiquidationFeatures", + "TermStructureBasis", + "CalendarSpread", # Risk / Performance "SharpeRatio", "SortinoRatio", diff --git a/bindings/python/src/lib.rs b/bindings/python/src/lib.rs index 11f634e4..0aebb4ff 100644 --- a/bindings/python/src/lib.rs +++ b/bindings/python/src/lib.rs @@ -12308,6 +12308,42 @@ fn deriv_liquidation( .map_err(map_err) } +fn deriv_futures_index(futures_price: f64, index_price: f64) -> PyResult { + wc::DerivativesTick::new( + 0.0, + 1.0, + index_price, + futures_price, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0, + ) + .map_err(map_err) +} + +fn deriv_futures_mark(futures_price: f64, mark_price: f64) -> PyResult { + wc::DerivativesTick::new( + 0.0, + mark_price, + 1.0, + futures_price, + 0.0, + 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)] @@ -12852,6 +12888,125 @@ impl PyLiquidationFeatures { } } +// TermStructureBasis takes no parameters; streaming +// `update(futures_price, index_price)`. +#[pyclass( + name = "TermStructureBasis", + module = "wickra._wickra", + skip_from_py_object +)] +#[derive(Clone)] +struct PyTermStructureBasis { + inner: wc::TermStructureBasis, +} + +#[pymethods] +impl PyTermStructureBasis { + #[new] + fn new() -> Self { + Self { + inner: wc::TermStructureBasis::new(), + } + } + fn update(&mut self, futures_price: f64, index_price: f64) -> PyResult> { + Ok(self + .inner + .update(deriv_futures_index(futures_price, index_price)?)) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + futures_price: Vec, + index_price: Vec, + ) -> PyResult>> { + if futures_price.len() != index_price.len() { + return Err(PyValueError::new_err( + "futures_price and index_price must be equal length", + )); + } + let mut out = Vec::with_capacity(futures_price.len()); + for i in 0..futures_price.len() { + out.push( + self.inner + .update(deriv_futures_index(futures_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 { + "TermStructureBasis()".to_string() + } +} + +// CalendarSpread takes no parameters; streaming `update(futures_price, mark_price)`. +#[pyclass( + name = "CalendarSpread", + module = "wickra._wickra", + skip_from_py_object +)] +#[derive(Clone)] +struct PyCalendarSpread { + inner: wc::CalendarSpread, +} + +#[pymethods] +impl PyCalendarSpread { + #[new] + fn new() -> Self { + Self { + inner: wc::CalendarSpread::new(), + } + } + fn update(&mut self, futures_price: f64, mark_price: f64) -> PyResult> { + Ok(self + .inner + .update(deriv_futures_mark(futures_price, mark_price)?)) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + futures_price: Vec, + mark_price: Vec, + ) -> PyResult>> { + if futures_price.len() != mark_price.len() { + return Err(PyValueError::new_err( + "futures_price and mark_price must be equal length", + )); + } + let mut out = Vec::with_capacity(futures_price.len()); + for i in 0..futures_price.len() { + out.push( + self.inner + .update(deriv_futures_mark(futures_price[i], mark_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 { + "CalendarSpread()".to_string() + } +} + // ============================== Family 15: Risk / Performance ============================== #[pyclass(name = "SharpeRatio", module = "wickra._wickra", skip_from_py_object)] @@ -13985,6 +14140,8 @@ fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> { 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 3ed99b96..fc1bb4db 100644 --- a/bindings/python/tests/test_input_validation.py +++ b/bindings/python/tests/test_input_validation.py @@ -268,3 +268,13 @@ def test_oi_price_divergence_zero_window_raises(): def test_oi_weighted_non_positive_mark_raises(): with pytest.raises(ValueError): ta.OIWeighted().update(0.0, 100.0) + + +def test_term_structure_basis_non_positive_index_raises(): + with pytest.raises(ValueError): + ta.TermStructureBasis().update(100.0, 0.0) + + +def test_calendar_spread_non_positive_mark_raises(): + with pytest.raises(ValueError): + ta.CalendarSpread().update(100.0, 0.0) diff --git a/bindings/python/tests/test_known_values.py b/bindings/python/tests/test_known_values.py index d350f17d..6446aace 100644 --- a/bindings/python/tests/test_known_values.py +++ b/bindings/python/tests/test_known_values.py @@ -1013,3 +1013,16 @@ def test_liquidation_features_reference_value(): # 30 long vs 10 short: (long, short, net, total, imbalance). out = ta.LiquidationFeatures().update(30.0, 10.0) assert out == pytest.approx((30.0, 10.0, 20.0, 40.0, 0.5)) + + +def test_term_structure_basis_reference_value(): + # futures 102 vs index 100 -> 0.02 (contango). + assert ta.TermStructureBasis().update(102.0, 100.0) == pytest.approx(0.02) + # Backwardation reads negative. + assert ta.TermStructureBasis().update(98.0, 100.0) == pytest.approx(-0.02) + + +def test_calendar_spread_reference_value(): + # futures 101 vs perpetual mark 100 -> 0.01. + assert ta.CalendarSpread().update(101.0, 100.0) == pytest.approx(0.01) + assert ta.CalendarSpread().update(99.0, 100.0) == pytest.approx(-0.01) diff --git a/bindings/python/tests/test_new_indicators.py b/bindings/python/tests/test_new_indicators.py index 07146951..754f267d 100644 --- a/bindings/python/tests/test_new_indicators.py +++ b/bindings/python/tests/test_new_indicators.py @@ -2047,3 +2047,29 @@ def test_liquidation_features_streaming_equals_batch(): for i in range(n): row = streamer.update(long_liq[i], short_liq[i]) assert tuple(batch[i]) == pytest.approx(row) + + +def test_basis_indicators_streaming_equals_batch(): + n = 40 + index = np.array([100.0 + math.sin(i * 0.2) for i in range(n)], dtype=np.float64) + mark = np.array([index[i] + 0.05 * math.cos(i * 0.3) for i in range(n)], dtype=np.float64) + futures = np.array( + [index[i] + 0.5 + 0.1 * math.sin(i * 0.25) for i in range(n)], dtype=np.float64 + ) + + # TermStructureBasis; update(futures_price, index_price). + batch = ta.TermStructureBasis().batch(futures, index) + streamer = ta.TermStructureBasis() + streamed = np.array( + [streamer.update(futures[i], index[i]) for i in range(n)], dtype=np.float64 + ) + assert batch.shape == (n,) + assert _eq_nan(batch, streamed) + + # CalendarSpread; update(futures_price, mark_price). + batch = ta.CalendarSpread().batch(futures, mark) + streamer = ta.CalendarSpread() + streamed = np.array( + [streamer.update(futures[i], mark[i]) for i in range(n)], dtype=np.float64 + ) + assert _eq_nan(batch, streamed) diff --git a/bindings/wasm/src/lib.rs b/bindings/wasm/src/lib.rs index eb3f69fa..3f4d2305 100644 --- a/bindings/wasm/src/lib.rs +++ b/bindings/wasm/src/lib.rs @@ -7228,6 +7228,119 @@ impl WasmLiquidationFeatures { } } +fn deriv_futures_index( + futures_price: f64, + index_price: f64, +) -> Result { + wc::DerivativesTick::new( + 0.0, + 1.0, + index_price, + futures_price, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0, + ) + .map_err(map_err) +} + +fn deriv_futures_mark(futures_price: f64, mark_price: f64) -> Result { + wc::DerivativesTick::new( + 0.0, + mark_price, + 1.0, + futures_price, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0, + ) + .map_err(map_err) +} + +#[wasm_bindgen(js_name = TermStructureBasis)] +pub struct WasmTermStructureBasis { + inner: wc::TermStructureBasis, +} + +impl Default for WasmTermStructureBasis { + fn default() -> Self { + Self::new() + } +} + +#[wasm_bindgen(js_class = TermStructureBasis)] +impl WasmTermStructureBasis { + #[wasm_bindgen(constructor)] + pub fn new() -> WasmTermStructureBasis { + Self { + inner: wc::TermStructureBasis::new(), + } + } + pub fn update(&mut self, futures_price: f64, index_price: f64) -> Result, JsError> { + Ok(self + .inner + .update(deriv_futures_index(futures_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 = CalendarSpread)] +pub struct WasmCalendarSpread { + inner: wc::CalendarSpread, +} + +impl Default for WasmCalendarSpread { + fn default() -> Self { + Self::new() + } +} + +#[wasm_bindgen(js_class = CalendarSpread)] +impl WasmCalendarSpread { + #[wasm_bindgen(constructor)] + pub fn new() -> WasmCalendarSpread { + Self { + inner: wc::CalendarSpread::new(), + } + } + pub fn update(&mut self, futures_price: f64, mark_price: f64) -> Result, JsError> { + Ok(self + .inner + .update(deriv_futures_mark(futures_price, mark_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() + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/wickra-core/src/indicators/calendar_spread.rs b/crates/wickra-core/src/indicators/calendar_spread.rs new file mode 100644 index 00000000..4da8b570 --- /dev/null +++ b/crates/wickra-core/src/indicators/calendar_spread.rs @@ -0,0 +1,141 @@ +//! Calendar Spread — the dated future's relative premium to the perpetual. + +use crate::derivatives::DerivativesTick; +use crate::traits::Indicator; + +/// Calendar Spread — the relative spread between a dated (e.g. quarterly) +/// futures price and the perpetual mark price. +/// +/// ```text +/// spread = (futuresPrice − markPrice) / markPrice +/// ``` +/// +/// A calendar (or inter-delivery) spread trades the *near* leg against the +/// *far* leg — here the perpetual against a dated future. The relative spread is +/// the roll yield available between the two contracts: positive when the future +/// trades over the perpetual (contango roll), negative when under +/// (backwardation). Where [`TermStructureBasis`] measures the future against +/// spot, this measures it against the perpetual — the leg a perp-vs-future +/// basis trade actually holds. The output is a fraction; multiply by `10_000` +/// for basis points. +/// +/// `Input = DerivativesTick`, `Output = f64`. Stateless; ready after the first +/// tick. +/// +/// [`TermStructureBasis`]: crate::TermStructureBasis +/// +/// # Example +/// +/// ``` +/// use wickra_core::{CalendarSpread, DerivativesTick, Indicator}; +/// +/// fn tick(futures: f64, mark: f64) -> DerivativesTick { +/// DerivativesTick::new(0.0, mark, mark, futures, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0) +/// .unwrap() +/// } +/// +/// let mut cs = CalendarSpread::new(); +/// // futures 101 vs perpetual mark 100 -> 0.01. +/// assert!((cs.update(tick(101.0, 100.0)).unwrap() - 0.01).abs() < 1e-12); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct CalendarSpread { + has_emitted: bool, +} + +impl CalendarSpread { + /// Construct a new calendar-spread indicator. + #[must_use] + pub const fn new() -> Self { + Self { has_emitted: false } + } +} + +impl Indicator for CalendarSpread { + type Input = DerivativesTick; + type Output = f64; + + fn update(&mut self, tick: DerivativesTick) -> Option { + self.has_emitted = true; + Some((tick.futures_price - tick.mark_price) / tick.mark_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 { + "CalendarSpread" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn tick(futures: f64, mark: f64) -> DerivativesTick { + DerivativesTick::new_unchecked( + 0.0, mark, mark, futures, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0, + ) + } + + #[test] + fn accessors_and_metadata() { + let cs = CalendarSpread::new(); + assert_eq!(cs.name(), "CalendarSpread"); + assert_eq!(cs.warmup_period(), 1); + assert!(!cs.is_ready()); + } + + #[test] + fn future_over_perp_is_positive() { + let mut cs = CalendarSpread::new(); + let out = cs.update(tick(101.0, 100.0)).unwrap(); + assert!((out - 0.01).abs() < 1e-12); + assert!(cs.is_ready()); + } + + #[test] + fn future_under_perp_is_negative() { + let mut cs = CalendarSpread::new(); + let out = cs.update(tick(99.0, 100.0)).unwrap(); + assert!((out + 0.01).abs() < 1e-12); + } + + #[test] + fn flat_is_zero() { + let mut cs = CalendarSpread::new(); + assert_eq!(cs.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), 100.0)) + .collect(); + let mut a = CalendarSpread::new(); + let mut b = CalendarSpread::new(); + assert_eq!( + a.batch(&ticks), + ticks.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut cs = CalendarSpread::new(); + cs.update(tick(101.0, 100.0)); + assert!(cs.is_ready()); + cs.reset(); + assert!(!cs.is_ready()); + } +} diff --git a/crates/wickra-core/src/indicators/mod.rs b/crates/wickra-core/src/indicators/mod.rs index b975b9d7..58fe26d7 100644 --- a/crates/wickra-core/src/indicators/mod.rs +++ b/crates/wickra-core/src/indicators/mod.rs @@ -29,6 +29,7 @@ mod balance_of_power; mod beta; mod bollinger; mod bollinger_bandwidth; +mod calendar_spread; mod calmar_ratio; mod camarilla_pivots; mod cci; @@ -204,6 +205,7 @@ mod td_risk_level; mod td_sequential; mod td_setup; mod tema; +mod term_structure_basis; mod three_inside; mod three_outside; mod three_soldiers_or_crows; @@ -271,6 +273,7 @@ pub use balance_of_power::BalanceOfPower; pub use beta::Beta; pub use bollinger::{BollingerBands, BollingerOutput}; pub use bollinger_bandwidth::BollingerBandwidth; +pub use calendar_spread::CalendarSpread; pub use calmar_ratio::CalmarRatio; pub use camarilla_pivots::{Camarilla, CamarillaPivotsOutput}; pub use cci::Cci; @@ -446,6 +449,7 @@ pub use td_risk_level::{TdRiskLevel, TdRiskLevelOutput}; pub use td_sequential::{TdSequential, TdSequentialOutput}; pub use td_setup::TdSetup; pub use tema::Tema; +pub use term_structure_basis::TermStructureBasis; pub use three_inside::ThreeInside; pub use three_outside::ThreeOutside; pub use three_soldiers_or_crows::ThreeSoldiersOrCrows; @@ -784,6 +788,8 @@ pub const FAMILIES: &[(&str, &[&str])] = &[ "LongShortRatio", "TakerBuySellRatio", "LiquidationFeatures", + "TermStructureBasis", + "CalendarSpread", ], ), ( @@ -840,6 +846,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, 237, "FAMILIES total drifted from indicator count"); + assert_eq!(total, 239, "FAMILIES total drifted from indicator count"); } } diff --git a/crates/wickra-core/src/indicators/term_structure_basis.rs b/crates/wickra-core/src/indicators/term_structure_basis.rs new file mode 100644 index 00000000..5927339c --- /dev/null +++ b/crates/wickra-core/src/indicators/term_structure_basis.rs @@ -0,0 +1,139 @@ +//! Term-Structure Basis — the dated future's relative premium to spot. + +use crate::derivatives::DerivativesTick; +use crate::traits::Indicator; + +/// Term-Structure Basis — the relative basis between a dated (e.g. quarterly) +/// futures price and the spot index. +/// +/// ```text +/// basis = (futuresPrice − indexPrice) / indexPrice +/// ``` +/// +/// Where [`FundingBasis`] measures the *perpetual*'s premium to spot, this +/// measures a *dated future*'s — the term-structure carry that a calendar or +/// cash-and-carry trade harvests as the contract converges to spot at expiry. A +/// positive basis is contango (futures above spot), a negative one backwardation. +/// The output is a fraction (e.g. `0.02` = 2%); multiply by `10_000` for basis +/// points. +/// +/// `Input = DerivativesTick`, `Output = f64`. Stateless; ready after the first +/// tick. +/// +/// [`FundingBasis`]: crate::FundingBasis +/// +/// # Example +/// +/// ``` +/// use wickra_core::{DerivativesTick, Indicator, TermStructureBasis}; +/// +/// fn tick(futures: f64, index: f64) -> DerivativesTick { +/// DerivativesTick::new(0.0, index, index, futures, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0) +/// .unwrap() +/// } +/// +/// let mut ts = TermStructureBasis::new(); +/// // futures 102 vs index 100 -> 0.02 (2% contango). +/// assert!((ts.update(tick(102.0, 100.0)).unwrap() - 0.02).abs() < 1e-12); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct TermStructureBasis { + has_emitted: bool, +} + +impl TermStructureBasis { + /// Construct a new term-structure basis indicator. + #[must_use] + pub const fn new() -> Self { + Self { has_emitted: false } + } +} + +impl Indicator for TermStructureBasis { + type Input = DerivativesTick; + type Output = f64; + + fn update(&mut self, tick: DerivativesTick) -> Option { + self.has_emitted = true; + Some((tick.futures_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 { + "TermStructureBasis" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn tick(futures: f64, index: f64) -> DerivativesTick { + DerivativesTick::new_unchecked( + 0.0, index, index, futures, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0, + ) + } + + #[test] + fn accessors_and_metadata() { + let ts = TermStructureBasis::new(); + assert_eq!(ts.name(), "TermStructureBasis"); + assert_eq!(ts.warmup_period(), 1); + assert!(!ts.is_ready()); + } + + #[test] + fn contango_is_positive() { + let mut ts = TermStructureBasis::new(); + let out = ts.update(tick(102.0, 100.0)).unwrap(); + assert!((out - 0.02).abs() < 1e-12); + assert!(ts.is_ready()); + } + + #[test] + fn backwardation_is_negative() { + let mut ts = TermStructureBasis::new(); + let out = ts.update(tick(98.0, 100.0)).unwrap(); + assert!((out + 0.02).abs() < 1e-12); + } + + #[test] + fn at_par_is_zero() { + let mut ts = TermStructureBasis::new(); + assert_eq!(ts.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), 100.0)) + .collect(); + let mut a = TermStructureBasis::new(); + let mut b = TermStructureBasis::new(); + assert_eq!( + a.batch(&ticks), + ticks.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut ts = TermStructureBasis::new(); + ts.update(tick(102.0, 100.0)); + assert!(ts.is_ready()); + ts.reset(); + assert!(!ts.is_ready()); + } +} diff --git a/crates/wickra-core/src/lib.rs b/crates/wickra-core/src/lib.rs index c861e49f..c067abc6 100644 --- a/crates/wickra-core/src/lib.rs +++ b/crates/wickra-core/src/lib.rs @@ -51,7 +51,7 @@ pub use indicators::{ Adl, Adx, AdxOutput, Adxr, Alligator, AlligatorOutput, Alma, Alpha, AnchoredVwap, Apo, Aroon, AroonOscillator, AroonOutput, Atr, AtrBands, AtrBandsOutput, AtrTrailingStop, Autocorrelation, AverageDrawdown, AwesomeOscillator, AwesomeOscillatorHistogram, BalanceOfPower, Beta, - BollingerBands, BollingerBandwidth, BollingerOutput, CalmarRatio, Camarilla, + BollingerBands, BollingerBandwidth, BollingerOutput, CalendarSpread, CalmarRatio, Camarilla, CamarillaPivotsOutput, Cci, CenterOfGravity, Cfo, ChaikinMoneyFlow, ChaikinOscillator, ChaikinVolatility, ChandeKrollStop, ChandeKrollStopOutput, ChandelierExit, ChandelierExitOutput, ChoppinessIndex, ClassicPivots, ClassicPivotsOutput, Cmo, @@ -86,7 +86,7 @@ pub use indicators::{ StochasticOutput, SuperSmoother, SuperTrend, SuperTrendOutput, TakerBuySellRatio, TdCombo, TdCountdown, TdDeMarker, TdDifferential, TdLines, TdLinesOutput, TdOpen, TdPressure, TdRangeProjection, TdRangeProjectionOutput, TdRei, TdRiskLevel, TdRiskLevelOutput, - TdSequential, TdSequentialOutput, TdSetup, Tema, ThreeInside, ThreeOutside, + TdSequential, TdSequentialOutput, TdSetup, Tema, TermStructureBasis, ThreeInside, ThreeOutside, ThreeSoldiersOrCrows, Tii, TradeImbalance, TreynorRatio, Trima, Trix, TrueRange, Tsi, Tsv, TtmSqueeze, TtmSqueezeOutput, Tweezer, TypicalPrice, UlcerIndex, UltimateOscillator, ValueArea, ValueAreaOutput, ValueAtRisk, Variance, VerticalHorizontalFilter, Vidya, VoltyStop, diff --git a/fuzz/fuzz_targets/indicator_update_derivatives.rs b/fuzz/fuzz_targets/indicator_update_derivatives.rs index ed4a86dc..a7b6ccbf 100644 --- a/fuzz/fuzz_targets/indicator_update_derivatives.rs +++ b/fuzz/fuzz_targets/indicator_update_derivatives.rs @@ -11,9 +11,9 @@ use libfuzzer_sys::fuzz_target; use wickra_core::{ - BatchExt, DerivativesTick, FundingBasis, FundingRate, FundingRateMean, FundingRateZScore, - Indicator, LiquidationFeatures, LongShortRatio, OIPriceDivergence, OIWeighted, - OpenInterestDelta, TakerBuySellRatio, + BatchExt, CalendarSpread, DerivativesTick, FundingBasis, FundingRate, FundingRateMean, + FundingRateZScore, Indicator, LiquidationFeatures, LongShortRatio, OIPriceDivergence, + OIWeighted, OpenInterestDelta, TakerBuySellRatio, TermStructureBasis, }; #[inline(never)] @@ -51,6 +51,8 @@ fuzz_target!(|data: &[u8]| { drive(OIWeighted::new, &ticks); drive(LongShortRatio::new, &ticks); drive(TakerBuySellRatio::new, &ticks); + drive(TermStructureBasis::new, &ticks); + drive(CalendarSpread::new, &ticks); // LiquidationFeatures emits a struct, not an f64, so drive it directly. let mut liq = LiquidationFeatures::new();