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}