Skip to main content

hypercall_api/handlers/
market_data.rs

1use rust_decimal::prelude::ToPrimitive;
2use utoipa::{IntoParams, ToSchema};
3
4use crate::sonic_json::SonicJson;
5use crate::{error::ApiError, options_chain::parse_expiry_date_to_unix};
6use axum::{
7    extract::{Query, State},
8    http::StatusCode,
9};
10use hypercall_types::{
11    InstrumentResponse, InstrumentSpecResponse, JsonRpcResponse, OrderBookGreeks, WalletAddress,
12};
13use serde::{Deserialize, Serialize};
14use tracing::error;
15
16use super::{
17    parse_requested_underlyings, parse_status_filter, quote_levels_to_human_contracts, AppState,
18};
19
20use super::options_chain::{resolve_theoretical_price, resolve_underlying_price};
21
22#[derive(Debug, Deserialize, IntoParams)]
23pub struct InstrumentsQuery {
24    /// Filter by currency (e.g., "BTC", "ETH"). Defaults to "BTC" when omitted.
25    #[param(example = "BTC")]
26    currency: Option<String>,
27    /// Option kind (unused)
28    _kind: Option<String>,
29    /// Filter by instrument status. Defaults to "ACTIVE" to hide expired/settled instruments.
30    /// Use "all" to return instruments in any status, or specify a comma-separated list
31    /// (e.g., "ACTIVE,EXPIRED_PENDING_PRICE").
32    #[param(example = "ACTIVE")]
33    status: Option<String>,
34}
35
36#[derive(Debug, Deserialize, IntoParams)]
37pub struct OptionsSummaryQuery {
38    /// Filter by currency (e.g., "BTC", "ETH"). Supports comma-separated values and "ALL".
39    /// When omitted, returns options for all active underlyings.
40    #[param(example = "BTC")]
41    currency: Option<String>,
42    /// Option kind
43    kind: Option<String>,
44}
45
46#[derive(Debug, Deserialize, IntoParams)]
47pub struct ExpirySummaryQuery {
48    /// Currency (e.g., "BTC", "ETH")
49    #[param(example = "BTC")]
50    currency: String,
51    /// Expiry date in ISO format
52    #[param(example = "2025-01-31")]
53    expiry: String,
54    /// Orderbook depth
55    depth: Option<usize>,
56}
57
58#[derive(Debug, Deserialize, IntoParams)]
59pub struct OptionsChainQuery {
60    /// Currency (e.g., "BTC", "ETH")
61    #[param(example = "BTC")]
62    pub currency: String,
63    /// Expiry date in ISO format
64    #[param(example = "2026-03-31")]
65    pub expiry: String,
66    /// Filter by option type (call, put, both). Defaults to both.
67    #[param(example = "both")]
68    pub option_type: Option<String>,
69    /// Filter by side (buy, sell, both). Defaults to both.
70    #[param(example = "both")]
71    pub side: Option<String>,
72}
73
74#[derive(Debug, Clone, Serialize, ToSchema)]
75pub struct OptionSummary {
76    /// Instrument ID
77    pub instrument_id: i32,
78    /// Instrument name/symbol
79    pub instrument_name: String,
80    /// Option token contract address
81    #[schema(value_type = Option<String>)]
82    pub option_token_address: Option<WalletAddress>,
83    /// Expiration timestamp
84    pub expiration_timestamp: i64,
85    /// Best bid price
86    pub bid_price: f64,
87    /// Best ask price
88    pub ask_price: f64,
89    /// Best bid size on the orderbook, in human-readable contracts.
90    #[serde(skip_serializing_if = "Option::is_none")]
91    pub best_bid_size: Option<f64>,
92    /// Best ask size on the orderbook, in human-readable contracts.
93    #[serde(skip_serializing_if = "Option::is_none")]
94    pub best_ask_size: Option<f64>,
95    /// Best indicative RFQ bid price from quote providers.
96    #[serde(skip_serializing_if = "Option::is_none")]
97    pub indicative_bid_price: Option<f64>,
98    /// Best indicative RFQ ask price from quote providers.
99    #[serde(skip_serializing_if = "Option::is_none")]
100    pub indicative_ask_price: Option<f64>,
101    /// Best indicative RFQ bid size, in human-readable contracts.
102    #[serde(skip_serializing_if = "Option::is_none")]
103    pub indicative_bid_size: Option<f64>,
104    /// Best indicative RFQ ask size, in human-readable contracts.
105    #[serde(skip_serializing_if = "Option::is_none")]
106    pub indicative_ask_size: Option<f64>,
107    /// Mark price
108    pub mark_price: f64,
109    /// Theoretical option price derived from the vol oracle, when available.
110    #[serde(skip_serializing_if = "Option::is_none")]
111    pub theoretical_price: Option<f64>,
112    /// Theoretical implied volatility used for mark price and greeks
113    pub mark_iv: Option<f64>,
114    /// Quote-derived ask-side implied volatility
115    #[serde(skip_serializing_if = "Option::is_none")]
116    pub ask_iv: Option<f64>,
117    /// Quote-derived bid-side implied volatility
118    #[serde(skip_serializing_if = "Option::is_none")]
119    pub bid_iv: Option<f64>,
120    /// Underlying price
121    pub underlying_price: f64,
122    /// Underlying index name
123    pub underlying_index: String,
124    /// Open interest
125    pub open_interest: f64,
126    /// 24h volume
127    pub volume: f64,
128    /// 24h volume in USD
129    pub volume_usd: f64,
130    /// 24h high
131    pub high: Option<f64>,
132    /// 24h low
133    pub low: Option<f64>,
134    /// Last trade price
135    pub last: Option<f64>,
136    /// 24h price change in percentage points, computed as:
137    /// `((current_best_bid - reference_best_ask_near_24h_ago) / reference_best_ask_near_24h_ago) * 100`.
138    pub price_change: Option<f64>,
139    /// Interest rate
140    pub interest_rate: f64,
141    /// Estimated delivery price
142    pub estimated_delivery_price: f64,
143    /// Creation timestamp
144    pub creation_timestamp: i64,
145    /// Base currency
146    pub base_currency: String,
147    /// Quote currency
148    pub quote_currency: String,
149    /// Mid price
150    pub mid_price: f64,
151    /// Option Greeks (if available)
152    #[serde(skip_serializing_if = "Option::is_none")]
153    pub greeks: Option<OrderBookGreeks>,
154}
155
156#[derive(Debug, Serialize, ToSchema)]
157pub struct InstrumentWithOrderbook {
158    /// Instrument ID
159    pub instrument_id: i32,
160    /// Instrument name/symbol
161    pub instrument_name: String,
162    /// Option token contract address
163    #[schema(value_type = Option<String>)]
164    pub option_token_address: Option<WalletAddress>,
165    /// Strike price
166    pub strike: f64,
167    /// Option type (call/put)
168    pub option_type: String,
169    /// Expiration timestamp
170    pub expiration_timestamp: i64,
171    /// Best bid price
172    pub bid_price: f64,
173    /// Best ask price
174    pub ask_price: f64,
175    /// Mark price
176    pub mark_price: f64,
177    /// Theoretical option price derived from the vol oracle, when available.
178    #[serde(skip_serializing_if = "Option::is_none")]
179    pub theoretical_price: Option<f64>,
180    /// Theoretical implied volatility used for mark price and greeks
181    pub mark_iv: Option<f64>,
182    /// Quote-derived ask-side implied volatility
183    #[serde(skip_serializing_if = "Option::is_none")]
184    pub ask_iv: Option<f64>,
185    /// Quote-derived bid-side implied volatility
186    #[serde(skip_serializing_if = "Option::is_none")]
187    pub bid_iv: Option<f64>,
188    /// Underlying price
189    pub underlying_price: f64,
190    /// Underlying index name
191    pub underlying_index: String,
192    /// Open interest
193    pub open_interest: f64,
194    /// 24h volume
195    pub volume: f64,
196    /// 24h volume in USD
197    pub volume_usd: f64,
198    /// 24h high
199    pub high: Option<f64>,
200    /// 24h low
201    pub low: Option<f64>,
202    /// Last trade price
203    pub last: Option<f64>,
204    /// 24h price change
205    pub price_change: Option<f64>,
206    /// Interest rate
207    pub interest_rate: f64,
208    /// Estimated delivery price
209    pub estimated_delivery_price: f64,
210    /// Creation timestamp
211    pub creation_timestamp: i64,
212    /// Base currency
213    pub base_currency: String,
214    /// Quote currency
215    pub quote_currency: String,
216    /// Mid price
217    pub mid_price: f64,
218    /// Bid orders [price, size], where size is in human-readable contracts
219    pub bids: Vec<[f64; 2]>,
220    /// Ask orders [price, size], where size is in human-readable contracts
221    pub asks: Vec<[f64; 2]>,
222}
223
224/// Get list of instruments (Deribit-compatible format)
225#[utoipa::path(
226    get,
227    path = "/instruments",
228    params(InstrumentsQuery),
229    responses(
230        (status = 200, description = "List of instruments", body = Vec<InstrumentResponse>),
231        (status = 500, description = "Internal server error")
232    ),
233    tag = "Markets"
234)]
235pub async fn get_instruments(
236    State(app_state): State<AppState>,
237    Query(params): Query<InstrumentsQuery>,
238) -> Result<SonicJson<JsonRpcResponse<Vec<InstrumentResponse>>>, StatusCode> {
239    let currency = params.currency.unwrap_or_else(|| "BTC".to_string());
240    let status_filter = parse_status_filter(params.status.as_deref());
241    let (result, _) = app_state
242        .instruments_snapshot_cache
243        .get_filtered(&currency, &status_filter);
244
245    let response = JsonRpcResponse {
246        jsonrpc: "2.0".to_string(),
247        result: Some(result),
248        error: None,
249        testnet: false,
250        us_diff: 1,
251        us_in: chrono::Utc::now().timestamp_micros(),
252        us_out: chrono::Utc::now().timestamp_micros(),
253    };
254
255    Ok(SonicJson(response))
256}
257
258/// Get canonical instrument specifications for downstream services.
259#[utoipa::path(
260    get,
261    path = "/instrument-specs",
262    params(InstrumentsQuery),
263    responses(
264        (status = 200, description = "List of canonical instrument specifications", body = Vec<InstrumentSpecResponse>),
265        (status = 500, description = "Internal server error")
266    ),
267    tag = "Markets"
268)]
269pub async fn get_instrument_specs(
270    State(app_state): State<AppState>,
271    Query(params): Query<InstrumentsQuery>,
272) -> Result<SonicJson<JsonRpcResponse<Vec<InstrumentSpecResponse>>>, StatusCode> {
273    let currency = params.currency.unwrap_or_else(|| "BTC".to_string());
274    let status_filter = parse_status_filter(params.status.as_deref());
275    let (result, _) = app_state
276        .instruments_snapshot_cache
277        .get_specs_filtered(&currency, &status_filter);
278
279    let response = JsonRpcResponse {
280        jsonrpc: "2.0".to_string(),
281        result: Some(result),
282        error: None,
283        testnet: false,
284        us_diff: 1,
285        us_in: chrono::Utc::now().timestamp_micros(),
286        us_out: chrono::Utc::now().timestamp_micros(),
287    };
288
289    Ok(SonicJson(response))
290}
291
292/// Get options summary (Deribit-compatible format)
293#[utoipa::path(
294    get,
295    path = "/options-summary",
296    params(OptionsSummaryQuery),
297    responses(
298        (status = 200, description = "Options summary", body = Vec<OptionSummary>),
299        (status = 500, description = "Internal server error")
300    ),
301    tag = "Markets"
302)]
303pub async fn get_options_summary(
304    State(app_state): State<AppState>,
305    Query(params): Query<OptionsSummaryQuery>,
306) -> Result<SonicJson<JsonRpcResponse<Vec<OptionSummary>>>, ApiError> {
307    let _kind = params.kind.unwrap_or_else(|| "option".to_string());
308    let request_started = chrono::Utc::now().timestamp_micros();
309    let requested_underlyings = parse_requested_underlyings(
310        params.currency.as_deref(),
311        app_state
312            .options_summary_snapshot_cache
313            .available_underlyings(),
314    );
315    let (summaries, _) = app_state
316        .options_summary_snapshot_cache
317        .get_for_underlyings(&requested_underlyings);
318
319    let response = JsonRpcResponse {
320        jsonrpc: "2.0".to_string(),
321        result: Some(summaries),
322        error: None,
323        testnet: false,
324        us_diff: 1,
325        us_in: request_started,
326        us_out: chrono::Utc::now().timestamp_micros(),
327    };
328
329    Ok(SonicJson(response))
330}
331
332/// Get instruments by expiry date with orderbook data
333#[utoipa::path(
334    get,
335    path = "/expiry-summary",
336    params(ExpirySummaryQuery),
337    responses(
338        (status = 200, description = "Instruments with orderbook data", body = Vec<InstrumentWithOrderbook>),
339        (status = 400, description = "Invalid expiry date format"),
340        (status = 500, description = "Internal server error")
341    ),
342    tag = "Markets"
343)]
344pub async fn get_expiry_summary(
345    State(app_state): State<AppState>,
346    Query(params): Query<ExpirySummaryQuery>,
347) -> Result<SonicJson<JsonRpcResponse<Vec<InstrumentWithOrderbook>>>, ApiError> {
348    let currency = params.currency.to_uppercase();
349    let depth = params.depth.unwrap_or(10);
350    let request_started = chrono::Utc::now().timestamp_micros();
351
352    // Parse ISO date string to Unix timestamp (instruments store Unix timestamps)
353    let expiry_int = parse_expiry_date_to_unix(&currency, &params.expiry).map_err(|err| {
354        error!("{}", err);
355        ApiError::bad_request(err)
356    })?;
357
358    tracing::info!(
359        "Getting expiry summary for currency: {}, expiry: {} ({})",
360        currency,
361        params.expiry,
362        expiry_int
363    );
364
365    // Get instruments from cache instead of DB (only active instruments)
366    let instruments: Vec<_> = app_state
367        .instruments_cache
368        .get_by_underlying_and_expiry(&currency, expiry_int)
369        .await
370        .into_iter()
371        .filter(|inst| inst.status.is_active())
372        .collect();
373
374    if !instruments.is_empty() {
375        {
376            let mut results = Vec::with_capacity(instruments.len());
377            let underlying_price = resolve_underlying_price(&app_state, &currency).await?;
378
379            for inst in instruments {
380                // inst.expiry is Unix timestamp in seconds, convert to milliseconds
381                let expiry_timestamp = (inst.expiry * 1000) as i64;
382                let theoretical_price = resolve_theoretical_price(&app_state, &inst.id).await;
383
384                // Get quote data from quote provider
385                let sq = app_state.quote_provider.get_quote(&inst.id);
386                let (bid_quote_price, ask_quote_price, bid_price, ask_price, mid_candidate) =
387                    if let Some(q) = &sq {
388                        (
389                            q.best_bid,
390                            q.best_ask,
391                            q.best_bid.unwrap_or(0.0),
392                            q.best_ask.unwrap_or(0.0),
393                            q.mid,
394                        )
395                    } else {
396                        (None, None, 0.0, 0.0, None)
397                    };
398
399                let mark_price = mid_candidate.unwrap_or_else(|| {
400                    if bid_price > 0.0 && ask_price > 0.0 {
401                        (bid_price + ask_price) / 2.0
402                    } else if bid_price > 0.0 {
403                        bid_price
404                    } else if ask_price > 0.0 {
405                        ask_price
406                    } else {
407                        0.0
408                    }
409                });
410
411                let mid_price = mid_candidate.unwrap_or(mark_price);
412                let last_price = if mid_price > 0.0 {
413                    Some(mid_price)
414                } else {
415                    None
416                };
417                let volume_24h = inst.volume_24h.to_f64().unwrap_or(0.0);
418                let volume_usd = volume_24h
419                    * if mark_price > 0.0 {
420                        mark_price
421                    } else {
422                        underlying_price
423                    };
424
425                // Get orderbook levels from snapshot
426                let (bids_contract_units_raw, asks_contract_units_raw) = sq
427                    .map(|q| {
428                        let b: Vec<(f64, f64)> = q.bids.iter().take(depth).copied().collect();
429                        let a: Vec<(f64, f64)> = q.asks.iter().take(depth).copied().collect();
430                        (b, a)
431                    })
432                    .unwrap_or_else(|| (Vec::new(), Vec::new()));
433
434                // Convert raw contract-unit sizes to human-readable contracts for API consumers.
435                let bids_human_contracts =
436                    quote_levels_to_human_contracts(&bids_contract_units_raw);
437                let asks_human_contracts =
438                    quote_levels_to_human_contracts(&asks_contract_units_raw);
439
440                let dynamic_mark_iv = app_state.greeks_cache.get_iv(&inst.id).await.ok();
441                let (bid_iv, ask_iv) = app_state
442                    .greeks_cache
443                    .get_quote_side_ivs_from_prices(&inst.id, bid_quote_price, ask_quote_price)
444                    .await
445                    .unwrap_or((None, None));
446
447                results.push(InstrumentWithOrderbook {
448                    instrument_id: inst.instrument_id,
449                    instrument_name: inst.id.clone(),
450                    option_token_address: inst.option_token_address,
451                    strike: inst.strike.to_f64().unwrap_or(0.0),
452                    option_type: inst.option_type.clone(),
453                    expiration_timestamp: expiry_timestamp,
454                    bid_price,
455                    ask_price,
456                    mark_price,
457                    theoretical_price,
458                    mark_iv: dynamic_mark_iv,
459                    ask_iv,
460                    bid_iv,
461                    underlying_price,
462                    underlying_index: format!("{}_USD", currency),
463                    open_interest: inst.open_interest.to_f64().unwrap_or(0.0),
464                    volume: volume_24h,
465                    volume_usd,
466                    high: None,
467                    low: None,
468                    last: last_price,
469                    price_change: None,
470                    interest_rate: 0.0,
471                    estimated_delivery_price: if mark_price > 0.0 {
472                        mark_price
473                    } else {
474                        underlying_price
475                    },
476                    creation_timestamp: inst.updated_at.timestamp_millis(),
477                    base_currency: currency.clone(),
478                    quote_currency: "USD".to_string(),
479                    mid_price,
480                    bids: bids_human_contracts,
481                    asks: asks_human_contracts,
482                });
483            }
484
485            let response = JsonRpcResponse {
486                jsonrpc: "2.0".to_string(),
487                result: Some(results),
488                error: None,
489                testnet: false,
490                us_diff: 1,
491                us_in: request_started,
492                us_out: chrono::Utc::now().timestamp_micros(),
493            };
494
495            Ok(SonicJson(response))
496        }
497    } else {
498        // No instruments found for this currency and expiry
499        let response = JsonRpcResponse {
500            jsonrpc: "2.0".to_string(),
501            result: Some(Vec::new()),
502            error: None,
503            testnet: false,
504            us_diff: 1,
505            us_in: request_started,
506            us_out: chrono::Utc::now().timestamp_micros(),
507        };
508
509        Ok(SonicJson(response))
510    }
511}