Skip to main content

fin_primitives/crypto/
mod.rs

1//! Crypto-specific financial metrics: funding rates, perpetual basis,
2//! open-interest ratio, liquidation heatmap, and Fear & Greed index.
3//!
4//! ## Responsibility
5//! Crypto-specific financial metrics: funding rates, perpetual futures basis,
6//! open-interest ratios, liquidation heatmaps, and a composite Fear & Greed index.
7//!
8//! ## Guarantees
9//! - Zero panics; all arithmetic is checked or guarded
10//! - Rolling history uses `VecDeque` with configurable max size (no unbounded growth)
11//! - All public items are documented
12
13pub mod defi;
14
15use std::collections::VecDeque;
16
17// ─────────────────────────────────────────
18//  FundingRate
19// ─────────────────────────────────────────
20
21/// A single perpetual-futures funding rate observation.
22///
23/// Funding rates are typically settled every 8 hours (3× per day).
24/// The `annualized` field converts the period rate to an annual rate.
25#[derive(Debug, Clone)]
26pub struct FundingRate {
27    /// Trading pair symbol (e.g. `"BTCUSDT"`).
28    pub symbol: String,
29    /// Raw funding rate for one 8-hour period (e.g. 0.0001 = 0.01%).
30    pub rate: f64,
31    /// Unix timestamp in milliseconds of this funding observation.
32    pub timestamp_ms: u64,
33    /// Annualized funding rate = `rate × 3 × 365`.
34    pub annualized: f64,
35}
36
37impl FundingRate {
38    /// Construct a new `FundingRate`, computing `annualized` automatically.
39    ///
40    /// # Arguments
41    /// * `symbol`       - Trading pair identifier.
42    /// * `rate`         - Raw 8-hour period rate.
43    /// * `timestamp_ms` - Observation time in milliseconds since Unix epoch.
44    #[must_use]
45    pub fn new(symbol: impl Into<String>, rate: f64, timestamp_ms: u64) -> Self {
46        let annualized = rate * 3.0 * 365.0;
47        Self {
48            symbol: symbol.into(),
49            rate,
50            timestamp_ms,
51            annualized,
52        }
53    }
54}
55
56// ─────────────────────────────────────────
57//  PerpBasis
58// ─────────────────────────────────────────
59
60/// Perpetual-futures basis: the percentage spread between perp and spot prices.
61///
62/// Positive basis → perp trades at a premium to spot (contango).
63/// Negative basis → perp trades at a discount (backwardation).
64#[derive(Debug, Clone)]
65pub struct PerpBasis {
66    /// Trading pair symbol.
67    pub symbol: String,
68    /// Perpetual futures price.
69    pub perp_price: f64,
70    /// Spot price.
71    pub spot_price: f64,
72    /// Basis as a percentage: `(perp - spot) / spot × 100`.
73    pub basis_pct: f64,
74    /// Unix timestamp in milliseconds.
75    pub timestamp_ms: u64,
76}
77
78impl PerpBasis {
79    /// Construct a new `PerpBasis`, computing `basis_pct` automatically.
80    ///
81    /// Returns `basis_pct = 0.0` if `spot_price` is zero.
82    #[must_use]
83    pub fn new(
84        symbol: impl Into<String>,
85        perp_price: f64,
86        spot_price: f64,
87        timestamp_ms: u64,
88    ) -> Self {
89        let basis_pct = if spot_price != 0.0 {
90            (perp_price - spot_price) / spot_price * 100.0
91        } else {
92            0.0
93        };
94        Self {
95            symbol: symbol.into(),
96            perp_price,
97            spot_price,
98            basis_pct,
99            timestamp_ms,
100        }
101    }
102
103    /// Returns `true` if the perpetual trades at a premium to spot (contango).
104    #[must_use]
105    pub fn is_contango(&self) -> bool {
106        self.perp_price > self.spot_price
107    }
108
109    /// Annualised carry yield for a fixed-expiry contract.
110    ///
111    /// `annualized_carry = (basis_pct / 100) / (days_to_expiry / 365)`
112    ///
113    /// Returns `0.0` if `days_to_expiry` ≤ 0.
114    #[must_use]
115    pub fn annualized_carry(&self, days_to_expiry: f64) -> f64 {
116        if days_to_expiry <= 0.0 {
117            return 0.0;
118        }
119        (self.basis_pct / 100.0) / (days_to_expiry / 365.0)
120    }
121}
122
123// ─────────────────────────────────────────
124//  FundingHistory
125// ─────────────────────────────────────────
126
127/// Rolling history of [`FundingRate`] observations with a configurable maximum size.
128#[derive(Debug, Clone)]
129pub struct FundingHistory {
130    data: VecDeque<FundingRate>,
131    max_size: usize,
132}
133
134impl FundingHistory {
135    /// Create a new `FundingHistory` with the given maximum capacity.
136    ///
137    /// # Panics
138    /// Panics if `max_size` is zero.
139    #[must_use]
140    pub fn new(max_size: usize) -> Self {
141        assert!(max_size > 0, "FundingHistory max_size must be > 0");
142        Self {
143            data: VecDeque::with_capacity(max_size),
144            max_size,
145        }
146    }
147
148    /// Push a new funding rate, evicting the oldest if at capacity.
149    pub fn push(&mut self, rate: FundingRate) {
150        if self.data.len() >= self.max_size {
151            self.data.pop_front();
152        }
153        self.data.push_back(rate);
154    }
155
156    /// Compute the simple average of all stored rates.
157    ///
158    /// Returns `0.0` if the history is empty.
159    #[must_use]
160    pub fn average_rate(&self) -> f64 {
161        if self.data.is_empty() {
162            return 0.0;
163        }
164        let sum: f64 = self.data.iter().map(|r| r.rate).sum();
165        sum / self.data.len() as f64
166    }
167
168    /// Compute the cumulative sum of all stored rates.
169    #[must_use]
170    pub fn cumulative_funding(&self) -> f64 {
171        self.data.iter().map(|r| r.rate).sum()
172    }
173
174    /// Compute the OLS slope of `rate` over time (index as x-axis).
175    ///
176    /// Positive slope → funding rates are trending upward.
177    /// Returns `0.0` if fewer than 2 observations are present.
178    #[must_use]
179    pub fn rate_trend(&self) -> f64 {
180        ols_slope(self.data.iter().map(|r| r.rate).collect::<Vec<_>>().as_slice())
181    }
182
183    /// Number of stored observations.
184    #[must_use]
185    pub fn len(&self) -> usize {
186        self.data.len()
187    }
188
189    /// Returns `true` if there are no stored observations.
190    #[must_use]
191    pub fn is_empty(&self) -> bool {
192        self.data.is_empty()
193    }
194}
195
196// ─────────────────────────────────────────
197//  CryptoMarketMetrics
198// ─────────────────────────────────────────
199
200/// Collection of crypto-specific market metrics (stateless, all methods are static).
201pub struct CryptoMarketMetrics;
202
203impl CryptoMarketMetrics {
204    /// Open interest to spot volume ratio — a proxy for market leverage.
205    ///
206    /// Returns `0.0` if `spot_volume` is zero.
207    #[must_use]
208    pub fn open_interest_ratio(oi: f64, spot_volume: f64) -> f64 {
209        if spot_volume == 0.0 {
210            return 0.0;
211        }
212        oi / spot_volume
213    }
214
215    /// Estimate liquidation volumes across a set of price levels and leverage multiples.
216    ///
217    /// For each price in `prices`, the estimated liquidation USD is:
218    /// `Σ_{lev in leverage_levels} round(1 / lev)`
219    ///
220    /// This is a simplified heuristic — in practice, open-interest data per leverage
221    /// level would be required for an accurate estimate.
222    ///
223    /// Returns a `Vec<(price, estimated_liq_usd)>`.
224    #[must_use]
225    pub fn liquidation_heatmap(prices: &[f64], leverage_levels: &[f64]) -> Vec<(f64, f64)> {
226        prices
227            .iter()
228            .map(|&price| {
229                let liq: f64 = leverage_levels
230                    .iter()
231                    .filter(|&&lev| lev > 0.0)
232                    .map(|&lev| (1.0 / lev).round())
233                    .sum();
234                (price, liq)
235            })
236            .collect()
237    }
238
239    /// Composite Fear & Greed index in the range [0, 100].
240    ///
241    /// Inputs are combined with fixed weights:
242    /// - `daily_return`:  weight 0.30 (positive return → greed)
243    /// - `volatility`:    weight 0.30 (high vol → fear)
244    /// - `funding_rate`:  weight 0.20 (positive funding → greed)
245    /// - `volume_ratio`:  weight 0.20 (above-average volume → greed)
246    ///
247    /// Each component is clamped to [0, 1] before weighting so the output is always
248    /// in [0, 100].
249    ///
250    /// # Arguments
251    /// * `daily_return` - Today's return (e.g. 0.05 = +5%).
252    /// * `volume_ratio` - Ratio of current volume to historical average (1.0 = neutral).
253    /// * `funding_rate` - Current 8-hour funding rate.
254    /// * `volatility`   - Current realized volatility (annualised).
255    #[must_use]
256    pub fn fear_greed_index(
257        daily_return: f64,
258        volume_ratio: f64,
259        funding_rate: f64,
260        volatility: f64,
261    ) -> u8 {
262        // Normalize each component to [0, 1] — higher value = more greed
263        // Return component: map [-0.10, +0.10] → [0, 1]
264        let return_score = ((daily_return + 0.10) / 0.20).clamp(0.0, 1.0);
265
266        // Volume component: map [0, 3] → [0, 1] (ratio above average = greed)
267        let volume_score = (volume_ratio / 3.0).clamp(0.0, 1.0);
268
269        // Funding component: map [-0.001, +0.001] → [0, 1]
270        let funding_score = ((funding_rate + 0.001) / 0.002).clamp(0.0, 1.0);
271
272        // Volatility component: high vol = fear; map [0, 1.0 annualized] → [1, 0]
273        let vol_score = (1.0 - (volatility / 1.0).clamp(0.0, 1.0)).clamp(0.0, 1.0);
274
275        let composite =
276            return_score * 0.30 + vol_score * 0.30 + funding_score * 0.20 + volume_score * 0.20;
277
278        (composite * 100.0).round().clamp(0.0, 100.0) as u8
279    }
280}
281
282// ─────────────────────────────────────────
283//  OLS helper
284// ─────────────────────────────────────────
285
286/// Compute the OLS slope of `y` against integer indices `0..n`.
287///
288/// Returns `0.0` if fewer than 2 data points are provided.
289fn ols_slope(y: &[f64]) -> f64 {
290    let n = y.len();
291    if n < 2 {
292        return 0.0;
293    }
294    let n_f = n as f64;
295    let mean_x = (n_f - 1.0) / 2.0;
296    let mean_y = y.iter().sum::<f64>() / n_f;
297
298    let mut num = 0.0_f64;
299    let mut den = 0.0_f64;
300    for (i, &yi) in y.iter().enumerate() {
301        let dx = i as f64 - mean_x;
302        num += dx * (yi - mean_y);
303        den += dx * dx;
304    }
305    if den == 0.0 { 0.0 } else { num / den }
306}
307
308// ─────────────────────────────────────────
309//  Tests
310// ─────────────────────────────────────────
311
312#[cfg(test)]
313mod tests {
314    use super::*;
315
316    #[test]
317    fn funding_rate_annualized() {
318        let fr = FundingRate::new("BTCUSDT", 0.0001, 1_700_000_000_000);
319        // 0.0001 * 3 * 365 = 0.1095
320        assert!((fr.annualized - 0.1095).abs() < 1e-10);
321    }
322
323    #[test]
324    fn perp_basis_contango() {
325        let basis = PerpBasis::new("BTCUSDT", 30_100.0, 30_000.0, 0);
326        assert!(basis.is_contango());
327        assert!(basis.basis_pct > 0.0);
328    }
329
330    #[test]
331    fn perp_basis_backwardation() {
332        let basis = PerpBasis::new("BTCUSDT", 29_900.0, 30_000.0, 0);
333        assert!(!basis.is_contango());
334        assert!(basis.basis_pct < 0.0);
335    }
336
337    #[test]
338    fn perp_basis_annualized_carry() {
339        let basis = PerpBasis::new("BTCUSDT", 30_300.0, 30_000.0, 0);
340        // basis_pct = 1.0; carry for 30 days = 1/100 / (30/365) ≈ 0.1217
341        let carry = basis.annualized_carry(30.0);
342        assert!(carry > 0.0);
343        assert!((carry - (0.01 / (30.0 / 365.0))).abs() < 1e-10);
344    }
345
346    #[test]
347    fn perp_basis_zero_days_to_expiry_returns_zero() {
348        let basis = PerpBasis::new("BTCUSDT", 30_300.0, 30_000.0, 0);
349        assert_eq!(basis.annualized_carry(0.0), 0.0);
350    }
351
352    #[test]
353    fn funding_history_average() {
354        let mut hist = FundingHistory::new(10);
355        hist.push(FundingRate::new("X", 0.0002, 0));
356        hist.push(FundingRate::new("X", 0.0004, 1));
357        assert!((hist.average_rate() - 0.0003).abs() < 1e-10);
358    }
359
360    #[test]
361    fn funding_history_cumulative() {
362        let mut hist = FundingHistory::new(10);
363        for i in 1..=5_u64 {
364            hist.push(FundingRate::new("X", 0.0001, i));
365        }
366        assert!((hist.cumulative_funding() - 0.0005).abs() < 1e-12);
367    }
368
369    #[test]
370    fn funding_history_eviction() {
371        let mut hist = FundingHistory::new(3);
372        for i in 0..5_u64 {
373            hist.push(FundingRate::new("X", i as f64 * 0.001, i));
374        }
375        assert_eq!(hist.len(), 3);
376    }
377
378    #[test]
379    fn funding_history_trend_increasing() {
380        let mut hist = FundingHistory::new(20);
381        for i in 0..10_u64 {
382            hist.push(FundingRate::new("X", i as f64 * 0.0001, i));
383        }
384        assert!(hist.rate_trend() > 0.0);
385    }
386
387    #[test]
388    fn fear_greed_in_range() {
389        let score = CryptoMarketMetrics::fear_greed_index(0.03, 1.5, 0.0001, 0.5);
390        assert!(score <= 100);
391    }
392
393    #[test]
394    fn fear_greed_extreme_greed() {
395        // Very positive return, high volume, positive funding, low vol
396        let score = CryptoMarketMetrics::fear_greed_index(0.10, 3.0, 0.001, 0.0);
397        assert!(score > 50);
398    }
399
400    #[test]
401    fn fear_greed_extreme_fear() {
402        // Very negative return, low volume, negative funding, very high vol
403        let score = CryptoMarketMetrics::fear_greed_index(-0.10, 0.0, -0.001, 1.0);
404        assert!(score < 50);
405    }
406
407    #[test]
408    fn liquidation_heatmap_length() {
409        let prices = vec![29_000.0, 30_000.0, 31_000.0];
410        let levs = vec![2.0, 5.0, 10.0];
411        let heatmap = CryptoMarketMetrics::liquidation_heatmap(&prices, &levs);
412        assert_eq!(heatmap.len(), prices.len());
413        for (_, liq) in &heatmap {
414            assert!(*liq >= 0.0);
415        }
416    }
417
418    #[test]
419    fn open_interest_ratio_zero_volume() {
420        assert_eq!(CryptoMarketMetrics::open_interest_ratio(1_000_000.0, 0.0), 0.0);
421    }
422
423    #[test]
424    fn open_interest_ratio_normal() {
425        let ratio = CryptoMarketMetrics::open_interest_ratio(500.0, 1000.0);
426        assert!((ratio - 0.5).abs() < 1e-10);
427    }
428}