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    #[inline]
73    fn update(&mut self, tick: DerivativesTick) -> Option<f64> {
74        self.ready = true;
75        Some(tick.funding_rate * self.intervals_per_year)
76    }
77
78    fn reset(&mut self) {
79        self.ready = false;
80    }
81
82    #[inline]
83    fn warmup_period(&self) -> usize {
84        1
85    }
86
87    #[inline]
88    fn is_ready(&self) -> bool {
89        self.ready
90    }
91
92    #[inline]
93    fn name(&self) -> &'static str {
94        "FundingImpliedApr"
95    }
96}
97
98#[cfg(test)]
99mod tests {
100    use super::*;
101    use crate::traits::BatchExt;
102    use approx::assert_relative_eq;
103
104    fn tick(funding: f64) -> DerivativesTick {
105        DerivativesTick::new_unchecked(
106            funding, 100.0, 100.0, 100.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0,
107        )
108    }
109
110    #[test]
111    fn rejects_invalid_intervals() {
112        assert!(matches!(
113            FundingImpliedApr::new(0.0),
114            Err(Error::InvalidParameter { .. })
115        ));
116        assert!(matches!(
117            FundingImpliedApr::new(-1.0),
118            Err(Error::InvalidParameter { .. })
119        ));
120    }
121
122    #[test]
123    fn accessors_and_metadata() {
124        let f = FundingImpliedApr::new(1095.0).unwrap();
125        assert_relative_eq!(f.intervals_per_year(), 1095.0, epsilon = 1e-12);
126        assert_eq!(f.warmup_period(), 1);
127        assert_eq!(f.name(), "FundingImpliedApr");
128        assert!(!f.is_ready());
129    }
130
131    #[test]
132    fn apr_reference_value() {
133        let mut f = FundingImpliedApr::new(1095.0).unwrap();
134        assert_relative_eq!(f.update(tick(0.0001)).unwrap(), 0.1095, epsilon = 1e-9);
135    }
136
137    #[test]
138    fn negative_funding_is_negative_apr() {
139        let mut f = FundingImpliedApr::new(1095.0).unwrap();
140        assert!(f.update(tick(-0.0001)).unwrap() < 0.0);
141    }
142
143    #[test]
144    fn zero_funding_is_zero() {
145        let mut f = FundingImpliedApr::new(365.0).unwrap();
146        assert_relative_eq!(f.update(tick(0.0)).unwrap(), 0.0, epsilon = 1e-12);
147    }
148
149    #[test]
150    fn reset_clears_state() {
151        let mut f = FundingImpliedApr::new(1095.0).unwrap();
152        f.update(tick(0.0001));
153        assert!(f.is_ready());
154        f.reset();
155        assert!(!f.is_ready());
156    }
157
158    #[test]
159    fn batch_equals_streaming() {
160        let ticks: Vec<DerivativesTick> = (0..40)
161            .map(|i| tick(0.0001 * (f64::from(i) * 0.3).sin()))
162            .collect();
163        let batch = FundingImpliedApr::new(1095.0).unwrap().batch(&ticks);
164        let mut b = FundingImpliedApr::new(1095.0).unwrap();
165        let streamed: Vec<_> = ticks.iter().map(|x| b.update(*x)).collect();
166        assert_eq!(batch, streamed);
167    }
168}