feat: derivatives basis & calendar-spread indicators (part 3 of 3) (#128)

* feat(derivatives): TermStructureBasis indicator (core)

* feat(derivatives): CalendarSpread indicator (core)

* feat(derivatives): Python, Node and WASM bindings for basis & calendar-spread indicators

* test(derivatives): Python and Node tests for basis & calendar-spread indicators

* docs(derivatives): README row + counter 242->244, CHANGELOG part 3; fuzz basis indicators
This commit is contained in:
kingchenc
2026-06-01 22:07:35 +02:00
committed by GitHub
parent 8e5bfd07ce
commit 2d140419bb
17 changed files with 830 additions and 12 deletions
@@ -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<f64> {
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<DerivativesTick> = (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::<Vec<_>>()
);
}
#[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());
}
}
+7 -1
View File
@@ -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");
}
}
@@ -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<f64> {
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<DerivativesTick> = (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::<Vec<_>>()
);
}
#[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());
}
}
+2 -2
View File
@@ -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,