Skip to main content

hypercall/iv_oracle/
iv_oracle.rs

1use chrono::{DateTime, Utc};
2use reqwest;
3use serde::Deserialize;
4use sonic_rs::JsonValueTrait;
5use std::time::Duration;
6use tokio::time;
7
8#[derive(Debug, Clone)]
9pub struct IVOracle {
10    client: reqwest::Client,
11    base_url: String,
12    poll_interval: Duration,
13}
14
15// Trait for testability
16#[async_trait::async_trait]
17pub trait IVDataFetcher: Send + Sync {
18    async fn fetch_iv_data(
19        &self,
20        currency: &str,
21        kind: &str,
22    ) -> Result<Vec<ImpliedVolatilityData>, Box<dyn std::error::Error>>;
23    async fn fetch_iv_data_filtered(
24        &self,
25        currency: &str,
26        expiry: Option<&str>,
27        strike: Option<f64>,
28    ) -> Result<Vec<ImpliedVolatilityData>, Box<dyn std::error::Error>>;
29}
30
31#[derive(Debug, Deserialize)]
32struct DeribitResponse<T> {
33    #[allow(dead_code)]
34    jsonrpc: String,
35    result: T,
36    #[allow(dead_code)]
37    id: Option<i64>,
38}
39
40#[derive(Debug, Deserialize)]
41#[allow(dead_code)]
42struct BookSummary {
43    ask_price: Option<f64>,
44    bid_price: Option<f64>,
45    mark_price: f64,
46    mark_iv: Option<f64>,
47    underlying_price: Option<f64>,
48    underlying_index: String,
49    last_price: Option<f64>,
50    open_interest: f64,
51    instrument_name: String,
52    creation_timestamp: i64,
53    current_funding: Option<f64>,
54    funding_8h: Option<f64>,
55    high: Option<f64>,
56    low: Option<f64>,
57    mid_price: Option<f64>,
58    price_change: Option<f64>,
59    volume: f64,
60    volume_notional: Option<f64>,
61    volume_usd: Option<f64>,
62    // Additional fields from actual API
63    interest_rate: Option<f64>,
64    estimated_delivery_price: Option<f64>,
65    base_currency: Option<String>,
66    quote_currency: Option<String>,
67}
68
69#[derive(Debug, Clone)]
70pub struct ImpliedVolatilityData {
71    pub instrument_name: String,
72    pub mark_iv: f64,
73    pub mark_price: f64,
74    pub mid_price: f64,
75    pub underlying_price: f64,
76    pub mark_price_usd: f64,
77    pub mid_price_usd: f64,
78    pub timestamp: DateTime<Utc>,
79}
80
81impl IVOracle {
82    pub fn new(poll_interval_seconds: u64) -> Self {
83        Self {
84            client: reqwest::Client::new(),
85            base_url: "https://www.deribit.com/api/v2".to_string(),
86            poll_interval: Duration::from_secs(poll_interval_seconds),
87        }
88    }
89
90    pub async fn start(&self) {
91        let mut interval = time::interval(self.poll_interval);
92
93        loop {
94            interval.tick().await;
95
96            match self.fetch_and_post_iv().await {
97                Ok(_) => println!("Successfully fetched and posted IV data"),
98                Err(e) => eprintln!("Error fetching IV data: {}", e),
99            }
100        }
101    }
102
103    async fn fetch_and_post_iv(&self) -> Result<(), Box<dyn std::error::Error>> {
104        // Fetch data for all supported currencies
105        let currencies = vec!["BTC", "ETH"];
106
107        for currency in currencies {
108            // Fetch the underlying index price in USD
109            let index_price = self.fetch_index_price(currency).await?;
110
111            // Fetch book summary for options
112            let mut iv_data =
113                <Self as IVDataFetcher>::fetch_iv_data(self, currency, "option").await?;
114
115            // Convert prices to USD
116            for data in &mut iv_data {
117                data.mark_price_usd = data.mark_price * index_price;
118                data.mid_price_usd = data.mid_price * index_price;
119            }
120
121            // Post the IV data
122            for data in iv_data {
123                self.post_iv(data).await?;
124            }
125        }
126
127        Ok(())
128    }
129
130    // Dummy postIV function
131    async fn post_iv(&self, data: ImpliedVolatilityData) -> Result<(), Box<dyn std::error::Error>> {
132        // TODO: Implement actual posting logic
133        println!(
134            "postIV called - Instrument: {}, Mark IV: {:.2}%, Mid Price: {:.6} (${:.2} USD), Mark Price: {:.6} (${:.2} USD), Underlying: ${:.2}",
135            data.instrument_name,
136            data.mark_iv,
137            data.mid_price,
138            data.mid_price_usd,
139            data.mark_price,
140            data.mark_price_usd,
141            data.underlying_price
142        );
143
144        Ok(())
145    }
146
147    async fn fetch_index_price(&self, currency: &str) -> Result<f64, Box<dyn std::error::Error>> {
148        let url = format!("{}/public/get_index_price", self.base_url);
149        let index_name = format!("{}_usd", currency.to_lowercase());
150
151        let params = [("index_name", index_name.as_str())];
152
153        let response = self.client.get(&url).query(&params).send().await?;
154
155        let text = response.text().await?;
156
157        // Check for error response
158        if let Ok(error_response) = sonic_rs::from_str::<sonic_rs::Value>(&text) {
159            if let Some(error) = error_response.get("error") {
160                return Err(format!("API error fetching index price: {}", error).into());
161            }
162        }
163
164        #[derive(Deserialize)]
165        struct IndexPriceResult {
166            index_price: f64,
167        }
168
169        let deribit_response: DeribitResponse<IndexPriceResult> = sonic_rs::from_str(&text)?;
170        Ok(deribit_response.result.index_price)
171    }
172}
173
174impl IVOracle {
175    pub fn with_base_url(poll_interval_seconds: u64, base_url: String) -> Self {
176        Self {
177            client: reqwest::Client::new(),
178            base_url,
179            poll_interval: Duration::from_secs(poll_interval_seconds),
180        }
181    }
182}
183
184#[async_trait::async_trait]
185impl IVDataFetcher for IVOracle {
186    async fn fetch_iv_data(
187        &self,
188        currency: &str,
189        kind: &str,
190    ) -> Result<Vec<ImpliedVolatilityData>, Box<dyn std::error::Error>> {
191        let url = format!("{}/public/get_book_summary_by_currency", self.base_url);
192
193        let params = [("currency", currency), ("kind", kind)];
194
195        let response = self.client.get(&url).query(&params).send().await?;
196
197        // Check if the response is successful
198        if !response.status().is_success() {
199            let error_text = response.text().await?;
200            return Err(format!("API request failed with status: {}", error_text).into());
201        }
202
203        let text = response.text().await?;
204
205        // Try to parse as error response first
206        if let Ok(error_response) = sonic_rs::from_str::<sonic_rs::Value>(&text) {
207            if let Some(error) = error_response.get("error") {
208                return Err(format!("API error: {}", error).into());
209            }
210        }
211
212        let deribit_response: DeribitResponse<Vec<BookSummary>> = sonic_rs::from_str(&text)
213            .map_err(|e| {
214                format!(
215                    "Failed to parse response: {}. Response: {}",
216                    e,
217                    &text[..text.len().min(200)]
218                )
219            })?;
220
221        let iv_data: Vec<ImpliedVolatilityData> = deribit_response
222            .result
223            .into_iter()
224            .filter_map(|summary| {
225                if let Some(mark_iv) = summary.mark_iv {
226                    // Calculate mid_price if not provided
227                    let mid_price = summary.mid_price.unwrap_or_else(|| {
228                        if let (Some(bid), Some(ask)) = (summary.bid_price, summary.ask_price) {
229                            (bid + ask) / 2.0
230                        } else {
231                            summary.mark_price
232                        }
233                    });
234
235                    Some(ImpliedVolatilityData {
236                        instrument_name: summary.instrument_name,
237                        mark_iv,
238                        mark_price: summary.mark_price,
239                        mid_price,
240                        underlying_price: summary.underlying_price.unwrap_or(0.0),
241                        mark_price_usd: 0.0, // Will be filled later
242                        mid_price_usd: 0.0,  // Will be filled later
243                        timestamp: Utc::now(),
244                    })
245                } else {
246                    None
247                }
248            })
249            .collect();
250
251        Ok(iv_data)
252    }
253
254    async fn fetch_iv_data_filtered(
255        &self,
256        currency: &str,
257        expiry: Option<&str>,
258        strike: Option<f64>,
259    ) -> Result<Vec<ImpliedVolatilityData>, Box<dyn std::error::Error>> {
260        // First get all options for the currency
261        let all_options = self.fetch_iv_data(currency, "option").await?;
262
263        // Filter by expiry and/or strike
264        let filtered: Vec<ImpliedVolatilityData> = all_options
265            .into_iter()
266            .filter(|option| {
267                let mut pass = true;
268
269                // Parse instrument name to extract expiry and strike
270                // Format: BTC-28JUN24-45000-C or BTC-28JUN24-45000-P
271                let parts: Vec<&str> = option.instrument_name.split('-').collect();
272                if parts.len() >= 4 {
273                    // Check expiry filter
274                    if let Some(exp) = expiry {
275                        pass = pass && parts[1] == exp;
276                    }
277
278                    // Check strike filter
279                    if let Some(str) = strike {
280                        if let Ok(instrument_strike) = parts[2].parse::<f64>() {
281                            pass = pass && (instrument_strike - str).abs() < 0.01;
282                        } else {
283                            pass = false;
284                        }
285                    }
286                }
287
288                pass
289            })
290            .collect();
291
292        Ok(filtered)
293    }
294}
295
296#[cfg(test)]
297mod tests {
298    use super::*;
299    use mockito::Server;
300
301    #[tokio::test]
302    async fn test_iv_oracle_creation() {
303        let oracle = IVOracle::new(60);
304        assert_eq!(oracle.poll_interval, Duration::from_secs(60));
305        assert_eq!(oracle.base_url, "https://www.deribit.com/api/v2");
306    }
307
308    #[tokio::test]
309    async fn test_iv_oracle_with_custom_url() {
310        let oracle = IVOracle::with_base_url(30, "http://localhost:8080".to_string());
311        assert_eq!(oracle.poll_interval, Duration::from_secs(30));
312        assert_eq!(oracle.base_url, "http://localhost:8080");
313    }
314
315    #[tokio::test]
316    async fn test_fetch_iv_data_success() {
317        let mut server = Server::new_async().await;
318        let mock_response = r#"{
319            "jsonrpc": "2.0",
320            "id": 1,
321            "result": [
322                {
323                    "ask_price": 0.05,
324                    "bid_price": 0.04,
325                    "mark_price": 0.045,
326                    "mark_iv": 75.5,
327                    "underlying_price": 45000.0,
328                    "underlying_index": "BTC-USD",
329                    "last_price": 0.044,
330                    "open_interest": 100.0,
331                    "instrument_name": "BTC-28JUN24-45000-C",
332                    "creation_timestamp": 1700000000000,
333                    "current_funding": null,
334                    "funding_8h": null,
335                    "high": 0.05,
336                    "low": 0.04,
337                    "mid_price": 0.045,
338                    "price_change": 0.1,
339                    "volume": 50.0,
340                    "volume_notional": 2250.0,
341                    "volume_usd": 2250.0
342                },
343                {
344                    "ask_price": 0.03,
345                    "bid_price": 0.02,
346                    "mark_price": 0.025,
347                    "mark_iv": null,
348                    "underlying_price": 45000.0,
349                    "underlying_index": "BTC-USD",
350                    "last_price": 0.024,
351                    "open_interest": 50.0,
352                    "instrument_name": "BTC-28JUN24-50000-C",
353                    "creation_timestamp": 1700000000000,
354                    "current_funding": null,
355                    "funding_8h": null,
356                    "high": 0.03,
357                    "low": 0.02,
358                    "mid_price": 0.025,
359                    "price_change": 0.05,
360                    "volume": 25.0,
361                    "volume_notional": 625.0,
362                    "volume_usd": 625.0
363                }
364            ]
365        }"#;
366
367        let mock = server
368            .mock("GET", "/public/get_book_summary_by_currency")
369            .match_query(mockito::Matcher::UrlEncoded(
370                "currency".into(),
371                "BTC".into(),
372            ))
373            .match_query(mockito::Matcher::UrlEncoded("kind".into(), "option".into()))
374            .with_status(200)
375            .with_header("content-type", "application/json")
376            .with_body(mock_response)
377            .create();
378
379        let oracle = IVOracle::with_base_url(60, server.url());
380        let result = oracle.fetch_iv_data("BTC", "option").await;
381
382        mock.assert();
383        assert!(result.is_ok());
384        let iv_data = result.unwrap();
385        assert_eq!(iv_data.len(), 1); // Only one item has mark_iv
386        assert_eq!(iv_data[0].instrument_name, "BTC-28JUN24-45000-C");
387        assert_eq!(iv_data[0].mark_iv, 75.5);
388        assert_eq!(iv_data[0].mark_price, 0.045);
389        assert_eq!(iv_data[0].mid_price, 0.045);
390        assert_eq!(iv_data[0].underlying_price, 45000.0);
391    }
392
393    #[tokio::test]
394    async fn test_fetch_iv_data_empty_response() {
395        let mut server = Server::new_async().await;
396        let mock_response = r#"{
397            "jsonrpc": "2.0",
398            "id": 1,
399            "result": []
400        }"#;
401
402        let mock = server
403            .mock("GET", "/public/get_book_summary_by_currency")
404            .match_query(mockito::Matcher::UrlEncoded(
405                "currency".into(),
406                "BTC".into(),
407            ))
408            .match_query(mockito::Matcher::UrlEncoded("kind".into(), "option".into()))
409            .with_status(200)
410            .with_header("content-type", "application/json")
411            .with_body(mock_response)
412            .create();
413
414        let oracle = IVOracle::with_base_url(60, server.url());
415        let result = oracle.fetch_iv_data("BTC", "option").await;
416
417        mock.assert();
418        assert!(result.is_ok());
419        let iv_data = result.unwrap();
420        assert_eq!(iv_data.len(), 0);
421    }
422
423    #[tokio::test]
424    async fn test_fetch_iv_data_network_error() {
425        let mut server = Server::new_async().await;
426        let mock = server
427            .mock("GET", "/public/get_book_summary_by_currency")
428            .match_query(mockito::Matcher::UrlEncoded(
429                "currency".into(),
430                "BTC".into(),
431            ))
432            .match_query(mockito::Matcher::UrlEncoded("kind".into(), "option".into()))
433            .with_status(500)
434            .with_body("Internal Server Error")
435            .create();
436
437        let oracle = IVOracle::with_base_url(60, server.url());
438        let result = oracle.fetch_iv_data("BTC", "option").await;
439
440        mock.assert();
441        assert!(result.is_err());
442    }
443
444    #[tokio::test]
445    async fn test_implied_volatility_data_creation() {
446        let iv_data = ImpliedVolatilityData {
447            instrument_name: "BTC-28JUN24-45000-C".to_string(),
448            mark_iv: 75.5,
449            mark_price: 0.045,
450            mid_price: 0.045,
451            underlying_price: 45000.0,
452            mark_price_usd: 2025.0,
453            mid_price_usd: 2025.0,
454            timestamp: Utc::now(),
455        };
456
457        assert_eq!(iv_data.instrument_name, "BTC-28JUN24-45000-C");
458        assert_eq!(iv_data.mark_iv, 75.5);
459        assert_eq!(iv_data.mark_price, 0.045);
460        assert_eq!(iv_data.mid_price, 0.045);
461        assert_eq!(iv_data.underlying_price, 45000.0);
462        assert_eq!(iv_data.mark_price_usd, 2025.0);
463        assert_eq!(iv_data.mid_price_usd, 2025.0);
464    }
465
466    #[tokio::test]
467    async fn test_post_iv_dummy_function() {
468        let oracle = IVOracle::new(60);
469        let iv_data = ImpliedVolatilityData {
470            instrument_name: "BTC-28JUN24-45000-C".to_string(),
471            mark_iv: 75.5,
472            mark_price: 0.045,
473            mid_price: 0.045,
474            underlying_price: 45000.0,
475            mark_price_usd: 2025.0,
476            mid_price_usd: 2025.0,
477            timestamp: Utc::now(),
478        };
479
480        // This should not panic and should return Ok
481        let result = oracle.post_iv(iv_data).await;
482        assert!(result.is_ok());
483    }
484
485    #[tokio::test]
486    async fn test_fetch_index_price() {
487        let mut server = Server::new_async().await;
488        let mock_response = r#"{
489            "jsonrpc": "2.0",
490            "id": 1,
491            "result": {
492                "index_price": 45000.0
493            }
494        }"#;
495
496        let mock = server
497            .mock("GET", "/public/get_index_price")
498            .match_query(mockito::Matcher::UrlEncoded(
499                "index_name".into(),
500                "btc_usd".into(),
501            ))
502            .with_status(200)
503            .with_header("content-type", "application/json")
504            .with_body(mock_response)
505            .create();
506
507        let oracle = IVOracle::with_base_url(60, server.url());
508        let result = oracle.fetch_index_price("BTC").await;
509
510        mock.assert();
511        assert!(result.is_ok());
512        assert_eq!(result.unwrap(), 45000.0);
513    }
514
515    #[tokio::test]
516    async fn test_fetch_iv_data_filtered_by_expiry() {
517        let mut server = Server::new_async().await;
518        let mock_response = r#"{
519            "jsonrpc": "2.0",
520            "id": 1,
521            "result": [
522                {
523                    "ask_price": 0.05,
524                    "bid_price": 0.04,
525                    "mark_price": 0.045,
526                    "mark_iv": 75.5,
527                    "underlying_price": 45000.0,
528                    "underlying_index": "BTC-USD",
529                    "last_price": 0.044,
530                    "open_interest": 100.0,
531                    "instrument_name": "BTC-28JUN24-45000-C",
532                    "creation_timestamp": 1700000000000,
533                    "current_funding": null,
534                    "funding_8h": null,
535                    "high": 0.05,
536                    "low": 0.04,
537                    "mid_price": 0.045,
538                    "price_change": 0.1,
539                    "volume": 50.0,
540                    "volume_notional": 2250.0,
541                    "volume_usd": 2250.0
542                },
543                {
544                    "ask_price": 0.03,
545                    "bid_price": 0.02,
546                    "mark_price": 0.025,
547                    "mark_iv": 80.0,
548                    "underlying_price": 45000.0,
549                    "underlying_index": "BTC-USD",
550                    "last_price": 0.024,
551                    "open_interest": 50.0,
552                    "instrument_name": "BTC-29JUN24-50000-C",
553                    "creation_timestamp": 1700000000000,
554                    "current_funding": null,
555                    "funding_8h": null,
556                    "high": 0.03,
557                    "low": 0.02,
558                    "mid_price": 0.025,
559                    "price_change": 0.05,
560                    "volume": 25.0,
561                    "volume_notional": 625.0,
562                    "volume_usd": 625.0
563                }
564            ]
565        }"#;
566
567        let mock = server
568            .mock("GET", "/public/get_book_summary_by_currency")
569            .match_query(mockito::Matcher::UrlEncoded(
570                "currency".into(),
571                "BTC".into(),
572            ))
573            .match_query(mockito::Matcher::UrlEncoded("kind".into(), "option".into()))
574            .with_status(200)
575            .with_header("content-type", "application/json")
576            .with_body(mock_response)
577            .create();
578
579        let oracle = IVOracle::with_base_url(60, server.url());
580        let result = oracle
581            .fetch_iv_data_filtered("BTC", Some("28JUN24"), None)
582            .await;
583
584        mock.assert();
585        assert!(result.is_ok());
586        let iv_data = result.unwrap();
587        assert_eq!(iv_data.len(), 1);
588        assert_eq!(iv_data[0].instrument_name, "BTC-28JUN24-45000-C");
589    }
590
591    #[tokio::test]
592    async fn test_fetch_iv_data_filtered_by_strike() {
593        let mut server = Server::new_async().await;
594        let mock_response = r#"{
595            "jsonrpc": "2.0",
596            "id": 1,
597            "result": [
598                {
599                    "ask_price": 0.05,
600                    "bid_price": 0.04,
601                    "mark_price": 0.045,
602                    "mark_iv": 75.5,
603                    "underlying_price": 45000.0,
604                    "underlying_index": "BTC-USD",
605                    "last_price": 0.044,
606                    "open_interest": 100.0,
607                    "instrument_name": "BTC-28JUN24-45000-C",
608                    "creation_timestamp": 1700000000000,
609                    "current_funding": null,
610                    "funding_8h": null,
611                    "high": 0.05,
612                    "low": 0.04,
613                    "mid_price": 0.045,
614                    "price_change": 0.1,
615                    "volume": 50.0,
616                    "volume_notional": 2250.0,
617                    "volume_usd": 2250.0
618                },
619                {
620                    "ask_price": 0.03,
621                    "bid_price": 0.02,
622                    "mark_price": 0.025,
623                    "mark_iv": 80.0,
624                    "underlying_price": 45000.0,
625                    "underlying_index": "BTC-USD",
626                    "last_price": 0.024,
627                    "open_interest": 50.0,
628                    "instrument_name": "BTC-28JUN24-50000-C",
629                    "creation_timestamp": 1700000000000,
630                    "current_funding": null,
631                    "funding_8h": null,
632                    "high": 0.03,
633                    "low": 0.02,
634                    "mid_price": 0.025,
635                    "price_change": 0.05,
636                    "volume": 25.0,
637                    "volume_notional": 625.0,
638                    "volume_usd": 625.0
639                }
640            ]
641        }"#;
642
643        let mock = server
644            .mock("GET", "/public/get_book_summary_by_currency")
645            .match_query(mockito::Matcher::UrlEncoded(
646                "currency".into(),
647                "BTC".into(),
648            ))
649            .match_query(mockito::Matcher::UrlEncoded("kind".into(), "option".into()))
650            .with_status(200)
651            .with_header("content-type", "application/json")
652            .with_body(mock_response)
653            .create();
654
655        let oracle = IVOracle::with_base_url(60, server.url());
656        let result = oracle
657            .fetch_iv_data_filtered("BTC", None, Some(50000.0))
658            .await;
659
660        mock.assert();
661        assert!(result.is_ok());
662        let iv_data = result.unwrap();
663        assert_eq!(iv_data.len(), 1);
664        assert_eq!(iv_data[0].instrument_name, "BTC-28JUN24-50000-C");
665    }
666
667    #[tokio::test]
668    async fn test_mid_price_calculation() {
669        let mut server = Server::new_async().await;
670        let mock_response = r#"{
671            "jsonrpc": "2.0",
672            "id": 1,
673            "result": [
674                {
675                    "ask_price": 0.06,
676                    "bid_price": 0.04,
677                    "mark_price": 0.045,
678                    "mark_iv": 75.5,
679                    "underlying_price": 45000.0,
680                    "underlying_index": "BTC-USD",
681                    "last_price": 0.044,
682                    "open_interest": 100.0,
683                    "instrument_name": "BTC-28JUN24-45000-C",
684                    "creation_timestamp": 1700000000000,
685                    "current_funding": null,
686                    "funding_8h": null,
687                    "high": 0.05,
688                    "low": 0.04,
689                    "mid_price": null,
690                    "price_change": 0.1,
691                    "volume": 50.0,
692                    "volume_notional": 2250.0,
693                    "volume_usd": 2250.0
694                }
695            ]
696        }"#;
697
698        let mock = server
699            .mock("GET", "/public/get_book_summary_by_currency")
700            .match_query(mockito::Matcher::UrlEncoded(
701                "currency".into(),
702                "BTC".into(),
703            ))
704            .match_query(mockito::Matcher::UrlEncoded("kind".into(), "option".into()))
705            .with_status(200)
706            .with_header("content-type", "application/json")
707            .with_body(mock_response)
708            .create();
709
710        let oracle = IVOracle::with_base_url(60, server.url());
711        let result = oracle.fetch_iv_data("BTC", "option").await;
712
713        mock.assert();
714        assert!(result.is_ok());
715        let iv_data = result.unwrap();
716        assert_eq!(iv_data.len(), 1);
717        // mid_price should be calculated as (bid + ask) / 2 = (0.04 + 0.06) / 2 = 0.05
718        assert_eq!(iv_data[0].mid_price, 0.05);
719    }
720}