Skip to main content

wickra_core/indicators/
calendar_spread.rs

1//! Calendar Spread — the dated future's relative premium to the perpetual.
2
3use crate::derivatives::DerivativesTick;
4use crate::traits::Indicator;
5
6/// Calendar Spread — the relative spread between a dated (e.g. quarterly)
7/// futures price and the perpetual mark price.
8///
9/// ```text
10/// spread = (futuresPrice − markPrice) / markPrice
11/// ```
12///
13/// A calendar (or inter-delivery) spread trades the *near* leg against the
14/// *far* leg — here the perpetual against a dated future. The relative spread is
15/// the roll yield available between the two contracts: positive when the future
16/// trades over the perpetual (contango roll), negative when under
17/// (backwardation). Where [`TermStructureBasis`] measures the future against
18/// spot, this measures it against the perpetual — the leg a perp-vs-future
19/// basis trade actually holds. The output is a fraction; multiply by `10_000`
20/// for basis points.
21///
22/// `Input = DerivativesTick`, `Output = f64`. Stateless; ready after the first
23/// tick.
24///
25/// [`TermStructureBasis`]: crate::TermStructureBasis
26///
27/// # Example
28///
29/// ```
30/// use wickra_core::{CalendarSpread, DerivativesTick, Indicator};
31///
32/// fn tick(futures: f64, mark: f64) -> DerivativesTick {
33///     DerivativesTick::new(0.0, mark, mark, futures, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0)
34///         .unwrap()
35/// }
36///
37/// let mut cs = CalendarSpread::new();
38/// // futures 101 vs perpetual mark 100 -> 0.01.
39/// assert!((cs.update(tick(101.0, 100.0)).unwrap() - 0.01).abs() < 1e-12);
40/// ```
41#[derive(Debug, Clone, Default)]
42pub struct CalendarSpread {
43    has_emitted: bool,
44}
45
46impl CalendarSpread {
47    /// Construct a new calendar-spread indicator.
48    #[must_use]
49    pub const fn new() -> Self {
50        Self { has_emitted: false }
51    }
52}
53
54impl Indicator for CalendarSpread {
55    type Input = DerivativesTick;
56    type Output = f64;
57
58    #[inline]
59    fn update(&mut self, tick: DerivativesTick) -> Option<f64> {
60        self.has_emitted = true;
61        Some((tick.futures_price - tick.mark_price) / tick.mark_price)
62    }
63
64    fn reset(&mut self) {
65        self.has_emitted = false;
66    }
67
68    #[inline]
69    fn warmup_period(&self) -> usize {
70        1
71    }
72
73    #[inline]
74    fn is_ready(&self) -> bool {
75        self.has_emitted
76    }
77
78    #[inline]
79    fn name(&self) -> &'static str {
80        "CalendarSpread"
81    }
82}
83
84#[cfg(test)]
85mod tests {
86    use super::*;
87    use crate::traits::BatchExt;
88
89    fn tick(futures: f64, mark: f64) -> DerivativesTick {
90        DerivativesTick::new_unchecked(
91            0.0, mark, mark, futures, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0,
92        )
93    }
94
95    #[test]
96    fn accessors_and_metadata() {
97        let cs = CalendarSpread::new();
98        assert_eq!(cs.name(), "CalendarSpread");
99        assert_eq!(cs.warmup_period(), 1);
100        assert!(!cs.is_ready());
101    }
102
103    #[test]
104    fn future_over_perp_is_positive() {
105        let mut cs = CalendarSpread::new();
106        let out = cs.update(tick(101.0, 100.0)).unwrap();
107        assert!((out - 0.01).abs() < 1e-12);
108        assert!(cs.is_ready());
109    }
110
111    #[test]
112    fn future_under_perp_is_negative() {
113        let mut cs = CalendarSpread::new();
114        let out = cs.update(tick(99.0, 100.0)).unwrap();
115        assert!((out + 0.01).abs() < 1e-12);
116    }
117
118    #[test]
119    fn flat_is_zero() {
120        let mut cs = CalendarSpread::new();
121        assert_eq!(cs.update(tick(100.0, 100.0)), Some(0.0));
122    }
123
124    #[test]
125    fn batch_equals_streaming() {
126        let ticks: Vec<DerivativesTick> = (0..20)
127            .map(|i| tick(100.0 + f64::from(i % 5), 100.0))
128            .collect();
129        let mut a = CalendarSpread::new();
130        let mut b = CalendarSpread::new();
131        assert_eq!(
132            a.batch(&ticks),
133            ticks.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
134        );
135    }
136
137    #[test]
138    fn reset_clears_state() {
139        let mut cs = CalendarSpread::new();
140        cs.update(tick(101.0, 100.0));
141        assert!(cs.is_ready());
142        cs.reset();
143        assert!(!cs.is_ready());
144    }
145}