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#[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 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 let currencies = vec!["BTC", "ETH"];
106
107 for currency in currencies {
108 let index_price = self.fetch_index_price(currency).await?;
110
111 let mut iv_data =
113 <Self as IVDataFetcher>::fetch_iv_data(self, currency, "option").await?;
114
115 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 for data in iv_data {
123 self.post_iv(data).await?;
124 }
125 }
126
127 Ok(())
128 }
129
130 async fn post_iv(&self, data: ImpliedVolatilityData) -> Result<(), Box<dyn std::error::Error>> {
132 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(¶ms).send().await?;
154
155 let text = response.text().await?;
156
157 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(¶ms).send().await?;
196
197 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 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 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, mid_price_usd: 0.0, 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 let all_options = self.fetch_iv_data(currency, "option").await?;
262
263 let filtered: Vec<ImpliedVolatilityData> = all_options
265 .into_iter()
266 .filter(|option| {
267 let mut pass = true;
268
269 let parts: Vec<&str> = option.instrument_name.split('-').collect();
272 if parts.len() >= 4 {
273 if let Some(exp) = expiry {
275 pass = pass && parts[1] == exp;
276 }
277
278 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); 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 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 assert_eq!(iv_data[0].mid_price, 0.05);
719 }
720}