Skip to main content

wickra_core/indicators/
funding_implied_apr.rs

1//! Funding-Implied APR — the per-interval funding rate annualised.
2
3use crate::derivatives::DerivativesTick;
4use crate::error::{Error, Result};
5use crate::traits::Indicator;
6
7/// Funding-Implied APR — the perpetual's per-interval funding rate scaled to an
8/// annualised rate.
9///
10/// ```text
11/// APR = funding_rate · intervals_per_year
12/// ```
13///
14/// Funding is paid in small per-interval amounts (commonly every 8 hours, i.e.
15/// `1095` intervals per year). Annualising it converts the headline funding number
16/// into the carry cost (or yield) of holding the position for a year, which is far
17/// easier to reason about and to compare against spot lending rates, basis trades,
18/// and other yields. A large positive APR means longs pay a steep carry to shorts
19/// (and vice versa) — the economic incentive behind cash-and-carry and
20/// funding-arbitrage strategies.
21///
22/// The output is a fraction (multiply by `100` for percent) and may be negative.
23/// It is stateless — each tick yields one value (no warmup). Each `update` is O(1).
24///
25/// # Example
26///
27/// ```
28/// use wickra_core::{DerivativesTick, Indicator, FundingImpliedApr};
29///
30/// // 0.01% per 8h funding -> 0.0001 * 1095 ≈ 10.95% APR.
31/// let mut indicator = FundingImpliedApr::new(1095.0).unwrap();
32/// 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();
33/// let apr = indicator.update(tick).unwrap();
34/// assert!((apr - 0.1095).abs() < 1e-9);
35/// ```
36#[derive(Debug, Clone)]
37pub struct FundingImpliedApr {
38    intervals_per_year: f64,
39    ready: bool,
40}
41
42impl FundingImpliedApr {
43    /// Construct a Funding-Implied APR with the number of funding intervals per
44    /// year (e.g. `1095` for 8-hour funding, `365` for daily).
45    ///
46    /// # Errors
47    ///
48    /// Returns [`Error::InvalidParameter`] if `intervals_per_year` is not finite
49    /// and positive.
50    pub fn new(intervals_per_year: f64) -> Result<Self> {
51        if !intervals_per_year.is_finite() || intervals_per_year <= 0.0 {
52            return Err(Error::InvalidParameter {
53                message: "intervals_per_year must be finite and positive",
54            });
55        }
56        Ok(Self {
57            intervals_per_year,
58            ready: false,
59        })
60    }
61
62    /// Configured intervals per year.
63    pub const fn intervals_per_year(&self) -> f64 {
64        self.intervals_per_year
65    }
66}
67
68impl Indicator for FundingImpliedApr {
69    type Input = DerivativesTick;
70    type Output = f64;
71
72    fn update(&mut self, tick: DerivativesTick) -> Option<f64> {
73        self.ready = true;
74        Some(tick.funding_rate * self.intervals_per_year)
75    }
76
77    fn reset(&mut self) {
78        self.ready = false;
79    }
80
81    fn warmup_period(&self) -> usize {
82        1
83    }
84
85    fn is_ready(&self) -> bool {
86        self.ready
87    }
88
89    fn name(&self) -> &'static str {
90        "FundingImpliedApr"
91    }
92}
93
94#[cfg(test)]
95mod tests {
96    use super::*;
97    use crate::traits::BatchExt;
98    use approx::assert_relative_eq;
99
100    fn tick(funding: f64) -> DerivativesTick {
101        DerivativesTick::new_unchecked(
102            funding, 100.0, 100.0, 100.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0,
103        )
104    }
105
106    #[test]
107    fn rejects_invalid_intervals() {
108        assert!(matches!(
109            FundingImpliedApr::new(0.0),
110            Err(Error::InvalidParameter { .. })
111        ));
112        assert!(matches!(
113            FundingImpliedApr::new(-1.0),
114            Err(Error::InvalidParameter { .. })
115        ));
116    }
117
118    #[test]
119    fn accessors_and_metadata() {
120        let f = FundingImpliedApr::new(1095.0).unwrap();
121        assert_relative_eq!(f.intervals_per_year(), 1095.0, epsilon = 1e-12);
122        assert_eq!(f.warmup_period(), 1);
123        assert_eq!(f.name(), "FundingImpliedApr");
124        assert!(!f.is_ready());
125    }
126
127    #[test]
128    fn apr_reference_value() {
129        let mut f = FundingImpliedApr::new(1095.0).unwrap();
130        assert_relative_eq!(f.update(tick(0.0001)).unwrap(), 0.1095, epsilon = 1e-9);
131    }
132
133    #[test]
134    fn negative_funding_is_negative_apr() {
135        let mut f = FundingImpliedApr::new(1095.0).unwrap();
136        assert!(f.update(tick(-0.0001)).unwrap() < 0.0);
137    }
138
139    #[test]
140    fn zero_funding_is_zero() {
141        let mut f = FundingImpliedApr::new(365.0).unwrap();
142        assert_relative_eq!(f.update(tick(0.0)).unwrap(), 0.0, epsilon = 1e-12);
143    }
144
145    #[test]
146    fn reset_clears_state() {
147        let mut f = FundingImpliedApr::new(1095.0).unwrap();
148        f.update(tick(0.0001));
149        assert!(f.is_ready());
150        f.reset();
151        assert!(!f.is_ready());
152    }
153
154    #[test]
155    fn batch_equals_streaming() {
156        let ticks: Vec<DerivativesTick> = (0..40)
157            .map(|i| tick(0.0001 * (f64::from(i) * 0.3).sin()))
158            .collect();
159        let batch = FundingImpliedApr::new(1095.0).unwrap().batch(&ticks);
160        let mut b = FundingImpliedApr::new(1095.0).unwrap();
161        let streamed: Vec<_> = ticks.iter().map(|x| b.update(*x)).collect();
162        assert_eq!(batch, streamed);
163    }
164}