Skip to main content

hypercall_vol_oracle/
blockscholes_types.rs

1//! Block Scholes WebSocket API types.
2//!
3//! This module contains request/response types for the Block Scholes WebSocket API.
4//! Uses JSON-RPC 2.0 format as required by the prod-websocket-api endpoint.
5
6use serde::{Deserialize, Serialize};
7use std::sync::atomic::{AtomicU64, Ordering};
8
9/// Global request ID counter for JSON-RPC requests
10static REQUEST_ID: AtomicU64 = AtomicU64::new(1);
11
12fn next_request_id() -> u64 {
13    REQUEST_ID.fetch_add(1, Ordering::SeqCst)
14}
15
16/// JSON-RPC 2.0 request wrapper
17#[derive(Debug, Clone, Serialize)]
18pub struct JsonRpcRequest<T: Serialize> {
19    pub jsonrpc: &'static str,
20    pub method: String,
21    pub params: T,
22    pub id: u64,
23}
24
25impl<T: Serialize> JsonRpcRequest<T> {
26    pub fn new(method: impl Into<String>, params: T) -> Self {
27        Self {
28            jsonrpc: "2.0",
29            method: method.into(),
30            params,
31            id: next_request_id(),
32        }
33    }
34}
35
36/// Authentication parameters for Block Scholes WebSocket.
37#[derive(Debug, Clone, Serialize)]
38pub struct AuthParams {
39    /// API key for authentication
40    pub api_key: String,
41}
42
43/// Subscription configuration for Block Scholes WebSocket.
44#[derive(Debug, Clone, Serialize)]
45pub struct SubscriptionConfig {
46    /// Max publishing rate (e.g., "1000ms", "20000ms")
47    pub frequency: String,
48    /// Optional identifier for this subscription
49    #[serde(skip_serializing_if = "Option::is_none")]
50    pub client_id: Option<String>,
51    /// Array of subscription batch items
52    pub batch: Vec<BatchItem>,
53    /// Formatting options
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub options: Option<SubscriptionOptions>,
56}
57
58/// A single subscription batch item.
59#[derive(Debug, Clone, Serialize)]
60pub struct BatchItem {
61    /// Unique identifier for this subscription item
62    pub sid: String,
63    /// Feed type: "strike.iv", "delta.iv", "mark.px", "index.px", "settlement.px"
64    pub feed: String,
65    /// Exchange name (e.g., "composite", "deribit")
66    #[serde(skip_serializing_if = "Option::is_none")]
67    pub exchange: Option<String>,
68    /// Base asset (e.g., "BTC", "ETH")
69    pub base_asset: String,
70    /// Quote asset (e.g., "USD")
71    #[serde(skip_serializing_if = "Option::is_none")]
72    pub quote_asset: Option<String>,
73    /// Model type: "SVI" or "Spline"
74    #[serde(skip_serializing_if = "Option::is_none")]
75    pub model: Option<String>,
76    /// Strike price(s) for strike.iv feed
77    #[serde(skip_serializing_if = "Option::is_none")]
78    pub strike: Option<Vec<f64>>,
79    /// Delta level(s) for delta.iv feed
80    #[serde(skip_serializing_if = "Option::is_none")]
81    pub delta: Option<Vec<f64>>,
82    /// Option type: "C" (Call) or "P" (Put) for mark.px
83    #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
84    pub option_type: Option<String>,
85    /// Asset type for mark.px/index.px: "option", "future", "perpetual", "spot"
86    #[serde(skip_serializing_if = "Option::is_none")]
87    pub asset: Option<String>,
88    /// Expiry date/time in ISO 8601 format
89    #[serde(skip_serializing_if = "Option::is_none")]
90    pub expiry: Option<String>,
91}
92
93/// Subscription formatting options.
94#[derive(Debug, Clone, Serialize)]
95pub struct SubscriptionOptions {
96    pub format: FormatOptions,
97}
98
99/// Format options for subscription data.
100#[derive(Debug, Clone, Serialize)]
101pub struct FormatOptions {
102    /// Timestamp format: "ms" for milliseconds
103    pub timestamp: String,
104    /// Whether to hexify numeric values
105    pub hexify: bool,
106    /// Number of decimal places
107    pub decimals: u8,
108}
109
110impl Default for SubscriptionOptions {
111    fn default() -> Self {
112        Self {
113            format: FormatOptions {
114                timestamp: "ms".to_string(),
115                hexify: false,
116                decimals: 5,
117            },
118        }
119    }
120}
121
122/// Subscribe message to Block Scholes WebSocket (JSON-RPC 2.0 format).
123#[derive(Debug, Clone, Serialize)]
124pub struct BlockScholesSubscribe {
125    pub jsonrpc: &'static str,
126    pub method: String,
127    /// Params is an ARRAY of subscription configs
128    pub params: Vec<SubscriptionConfig>,
129    pub id: u64,
130}
131
132impl BlockScholesSubscribe {
133    /// Create a subscription for implied volatility across a delta grid.
134    ///
135    /// Subscribes to IVs at multiple delta levels to build a vol surface
136    /// without needing exchange-listed strikes. The delta grid covers puts
137    /// through calls: 0.05, 0.10, 0.25, 0.50 (ATM), 0.75, 0.90, 0.95.
138    pub fn delta_iv(
139        base_asset: impl Into<String>,
140        expiry: impl Into<String>,
141        client_id: Option<String>,
142    ) -> Self {
143        let base_asset = base_asset.into();
144        let expiry = expiry.into();
145        let sid = format!("delta_iv_{}_{}", base_asset, next_request_id());
146
147        Self {
148            jsonrpc: "2.0",
149            method: "subscribe".to_string(),
150            params: vec![SubscriptionConfig {
151                frequency: "1000ms".to_string(),
152                client_id,
153                batch: vec![BatchItem {
154                    sid,
155                    feed: "delta.iv".to_string(),
156                    exchange: Some("composite".to_string()),
157                    base_asset,
158                    quote_asset: None,
159                    model: Some("SVI".to_string()),
160                    strike: None,
161                    delta: Some(vec![0.05, 0.10, 0.25, 0.50, 0.75, 0.90, 0.95]),
162                    option_type: None,
163                    asset: None,
164                    expiry: Some(expiry),
165                }],
166                options: Some(SubscriptionOptions::default()),
167            }],
168            id: next_request_id(),
169        }
170    }
171
172    /// Create a subscription for index price.
173    pub fn index_price(base_asset: impl Into<String>, client_id: Option<String>) -> Self {
174        let base_asset = base_asset.into();
175        let sid = format!("index_{}_{}", base_asset, next_request_id());
176
177        Self {
178            jsonrpc: "2.0",
179            method: "subscribe".to_string(),
180            params: vec![SubscriptionConfig {
181                frequency: "1000ms".to_string(),
182                client_id,
183                batch: vec![BatchItem {
184                    sid,
185                    feed: "index.px".to_string(),
186                    exchange: None,
187                    base_asset,
188                    quote_asset: Some("USD".to_string()),
189                    model: None,
190                    strike: None,
191                    delta: None,
192                    option_type: None,
193                    asset: Some("spot".to_string()),
194                    expiry: None,
195                }],
196                options: Some(SubscriptionOptions::default()),
197            }],
198            id: next_request_id(),
199        }
200    }
201
202    pub fn atm_iv(
203        base_asset: impl Into<String>,
204        expiry: impl Into<String>,
205        client_id: Option<String>,
206    ) -> Self {
207        let base_asset = base_asset.into();
208        let expiry = expiry.into();
209        let sid = format!("atm_iv_{}_{}", base_asset, next_request_id());
210
211        Self {
212            jsonrpc: "2.0",
213            method: "subscribe".to_string(),
214            params: vec![SubscriptionConfig {
215                frequency: "1000ms".to_string(),
216                client_id,
217                batch: vec![BatchItem {
218                    sid,
219                    feed: "delta.iv".to_string(),
220                    exchange: Some("composite".to_string()),
221                    base_asset,
222                    quote_asset: None,
223                    model: Some("SVI".to_string()),
224                    strike: None,
225                    delta: Some(vec![0.5]),
226                    option_type: None,
227                    asset: None,
228                    expiry: Some(expiry),
229                }],
230                options: Some(SubscriptionOptions::default()),
231            }],
232            id: next_request_id(),
233        }
234    }
235}
236
237/// Authentication message for Block Scholes WebSocket (JSON-RPC 2.0 format).
238#[derive(Debug, Clone, Serialize)]
239pub struct BlockScholesAuth {
240    pub jsonrpc: &'static str,
241    pub method: String,
242    pub params: AuthParams,
243    pub id: u64,
244}
245
246impl BlockScholesAuth {
247    /// Create an authentication request.
248    /// Uses the `authenticate` method with `api_key` param (discovered via API testing).
249    pub fn new(api_key: impl Into<String>, _api_secret: Option<String>) -> Self {
250        Self {
251            jsonrpc: "2.0",
252            method: "authenticate".to_string(),
253            params: AuthParams {
254                api_key: api_key.into(),
255            },
256            id: next_request_id(),
257        }
258    }
259}
260
261/// JSON-RPC 2.0 response from Block Scholes.
262#[derive(Debug, Clone, Deserialize)]
263pub struct JsonRpcResponse {
264    pub jsonrpc: String,
265    #[serde(default)]
266    pub result: Option<sonic_rs::Value>,
267    #[serde(default)]
268    pub error: Option<JsonRpcError>,
269    pub id: Option<u64>,
270    /// For subscription data, the method field indicates the channel
271    #[serde(default)]
272    pub method: Option<String>,
273    /// Params contain the actual data for subscription updates
274    #[serde(default)]
275    pub params: Option<sonic_rs::Value>,
276}
277
278/// JSON-RPC error object
279#[derive(Debug, Clone, Deserialize)]
280pub struct JsonRpcError {
281    pub message: String,
282    pub code: i32,
283}
284
285impl JsonRpcResponse {
286    /// Check if this is a successful response
287    pub fn is_success(&self) -> bool {
288        self.error.is_none() && self.result.is_some()
289    }
290
291    /// Check if this is an error response
292    pub fn is_error(&self) -> bool {
293        self.error.is_some()
294    }
295
296    /// Check if this is a subscription data push (no id, has method/params)
297    pub fn is_subscription_data(&self) -> bool {
298        self.id.is_none() && self.method.is_some() && self.params.is_some()
299    }
300}
301
302/// Incoming WebSocket message types from Block Scholes.
303/// NOTE: The exact subscription data format is unknown without API documentation.
304/// Contact Block Scholes at support@blockscholes.com for API docs.
305#[derive(Debug, Clone, Deserialize)]
306#[serde(tag = "type", rename_all = "snake_case")]
307pub enum BlockScholesMessage {
308    /// Volatility surface update
309    #[serde(alias = "vol_surface_update", alias = "volatility_surface")]
310    VolSurfaceUpdate(VolSurfaceUpdateMessage),
311
312    /// ATM volatility update
313    #[serde(alias = "atm_vol_update", alias = "atm_volatility")]
314    AtmVolUpdate(AtmVolUpdateMessage),
315
316    /// Subscription confirmation
317    Subscribed { channel: String, symbol: String },
318
319    /// Unsubscription confirmation
320    Unsubscribed { channel: String, symbol: String },
321
322    /// Authentication success
323    #[serde(alias = "auth_success")]
324    Authenticated {
325        #[serde(default)]
326        message: Option<String>,
327    },
328
329    /// Error message
330    Error {
331        message: String,
332        #[serde(default)]
333        code: Option<i32>,
334    },
335
336    /// Heartbeat/ping from server
337    Heartbeat { timestamp: i64 },
338
339    /// Connection info
340    #[serde(alias = "welcome", alias = "connected")]
341    Info {
342        #[serde(default)]
343        message: Option<String>,
344        #[serde(default)]
345        version: Option<String>,
346    },
347}
348
349/// Volatility surface update message.
350#[derive(Debug, Clone, Deserialize)]
351pub struct VolSurfaceUpdateMessage {
352    /// Underlying symbol (e.g., "BTC", "ETH")
353    pub symbol: String,
354    /// Update timestamp in milliseconds
355    pub timestamp: i64,
356    /// Volatility surface points
357    pub points: Vec<VolSurfacePoint>,
358    /// Optional ATM volatilities by expiry
359    #[serde(default)]
360    pub atm_vols: Vec<AtmVolPoint>,
361}
362
363/// A single point on the volatility surface.
364#[derive(Debug, Clone, Deserialize, Serialize)]
365pub struct VolSurfacePoint {
366    /// Strike price in USD
367    pub strike: f64,
368    /// Expiry timestamp in Unix seconds
369    pub expiry: i64,
370    /// Implied volatility as decimal (e.g., 0.80 for 80%)
371    pub iv: f64,
372    /// Optional: delta of this point
373    #[serde(default)]
374    pub delta: Option<f64>,
375    /// Optional: forward price used for calculation
376    #[serde(default)]
377    pub forward: Option<f64>,
378    /// Optional: EIP712 signature for verification
379    #[serde(default)]
380    pub signature: Option<String>,
381}
382
383/// ATM volatility for a specific expiry.
384#[derive(Debug, Clone, Deserialize, Serialize)]
385pub struct AtmVolPoint {
386    /// Expiry timestamp in Unix seconds
387    pub expiry: i64,
388    /// ATM implied volatility as decimal
389    pub iv: f64,
390    /// Optional: forward price at this expiry
391    #[serde(default)]
392    pub forward: Option<f64>,
393}
394
395/// ATM volatility update message.
396#[derive(Debug, Clone, Deserialize)]
397pub struct AtmVolUpdateMessage {
398    /// Underlying symbol
399    pub symbol: String,
400    /// Update timestamp in milliseconds
401    pub timestamp: i64,
402    /// ATM volatility points by expiry
403    pub points: Vec<AtmVolPoint>,
404}
405
406#[cfg(test)]
407mod tests {
408    use super::*;
409
410    #[test]
411    fn test_subscribe_serialization() {
412        let sub =
413            BlockScholesSubscribe::atm_iv("BTC", "2026-03-28T08:00:00Z", Some("test".to_string()));
414        let json = sonic_rs::to_string(&sub).unwrap();
415        assert!(json.contains("\"jsonrpc\":\"2.0\""));
416        assert!(json.contains("\"method\":\"subscribe\""));
417        assert!(json.contains("\"feed\":\"delta.iv\""));
418        assert!(json.contains("\"base_asset\":\"BTC\""));
419        assert!(json.contains("\"delta\":[0.5]"));
420    }
421
422    #[test]
423    fn test_vol_surface_update_deserialization() {
424        let json = r#"{
425            "type": "vol_surface_update",
426            "symbol": "BTC",
427            "timestamp": 1735689600000,
428            "points": [
429                {"strike": 100000.0, "expiry": 1735689600, "iv": 0.75},
430                {"strike": 110000.0, "expiry": 1735689600, "iv": 0.72, "delta": 0.25}
431            ],
432            "atm_vols": [
433                {"expiry": 1735689600, "iv": 0.70}
434            ]
435        }"#;
436
437        let msg: BlockScholesMessage = sonic_rs::from_str(json).unwrap();
438        match msg {
439            BlockScholesMessage::VolSurfaceUpdate(update) => {
440                assert_eq!(update.symbol, "BTC");
441                assert_eq!(update.points.len(), 2);
442                assert_eq!(update.points[0].strike, 100000.0);
443                assert_eq!(update.points[0].iv, 0.75);
444                assert_eq!(update.points[1].delta, Some(0.25));
445                assert_eq!(update.atm_vols.len(), 1);
446            }
447            _ => panic!("Expected VolSurfaceUpdate"),
448        }
449    }
450
451    #[test]
452    fn test_error_message_deserialization() {
453        let json = r#"{
454            "type": "error",
455            "message": "Invalid subscription",
456            "code": 400
457        }"#;
458
459        let msg: BlockScholesMessage = sonic_rs::from_str(json).unwrap();
460        match msg {
461            BlockScholesMessage::Error { message, code } => {
462                assert_eq!(message, "Invalid subscription");
463                assert_eq!(code, Some(400));
464            }
465            _ => panic!("Expected Error"),
466        }
467    }
468
469    #[test]
470    fn test_subscribed_message_deserialization() {
471        let json = r#"{
472            "type": "subscribed",
473            "channel": "vol_surface",
474            "symbol": "ETH"
475        }"#;
476
477        let msg: BlockScholesMessage = sonic_rs::from_str(json).unwrap();
478        match msg {
479            BlockScholesMessage::Subscribed { channel, symbol } => {
480                assert_eq!(channel, "vol_surface");
481                assert_eq!(symbol, "ETH");
482            }
483            _ => panic!("Expected Subscribed"),
484        }
485    }
486}