Skip to main content

hypercall_api/handlers/
options_chain.rs

1use std::collections::BTreeMap;
2
3use rust_decimal::prelude::ToPrimitive;
4use utoipa::IntoParams;
5
6use crate::sonic_json::SonicJson;
7use crate::{
8    error::ApiError,
9    models::{OptionsChainLeg, OptionsChainSnapshotResponse, OptionsChainStrikeRow},
10    options_chain::{
11        build_options_chain_leg, parse_expiry_date_to_unix, strike_to_f64,
12        OptionsChainOptionTypeFilter, OptionsChainSideFilter,
13    },
14};
15use axum::extract::{Query, State};
16use hypercall_runtime_api::valuation::get_symbol_theoretical_price;
17use hypercall_types::{JsonRpcError, JsonRpcResponse, OrderBookResponse, OrderBookStats};
18use serde::Deserialize;
19
20use super::{quote_levels_to_human_contracts, raw_contract_units_to_human_contracts, AppState};
21
22use super::market_data::OptionsChainQuery;
23
24#[derive(Default)]
25pub(crate) struct StrikeRowBuilder {
26    pub(crate) call: Option<OptionsChainLeg>,
27    pub(crate) put: Option<OptionsChainLeg>,
28}
29
30pub(crate) fn insert_options_chain_leg_for_strike(
31    grouped_rows: &mut BTreeMap<rust_decimal::Decimal, StrikeRowBuilder>,
32    strike: rust_decimal::Decimal,
33    option_type: &str,
34    leg: OptionsChainLeg,
35) -> Result<(), ApiError> {
36    let entry = grouped_rows.entry(strike).or_default();
37    if option_type.eq_ignore_ascii_case("call") {
38        entry.call = Some(leg);
39        return Ok(());
40    }
41    if option_type.eq_ignore_ascii_case("put") {
42        entry.put = Some(leg);
43        return Ok(());
44    }
45
46    Err(ApiError::internal_error(format!(
47        "Invalid option type '{}' for strike {}",
48        option_type, strike
49    )))
50}
51
52/// Get a full options chain snapshot by underlying and expiry.
53#[utoipa::path(
54    get,
55    path = "/options-chain",
56    params(OptionsChainQuery),
57    responses(
58        (status = 200, description = "Options chain snapshot", body = OptionsChainSnapshotResponse),
59        (status = 400, description = "Invalid query parameters"),
60        (status = 500, description = "Internal server error")
61    ),
62    tag = "Markets"
63)]
64pub async fn get_options_chain(
65    State(app_state): State<AppState>,
66    Query(params): Query<OptionsChainQuery>,
67) -> Result<SonicJson<JsonRpcResponse<OptionsChainSnapshotResponse>>, ApiError> {
68    let request_started = chrono::Utc::now().timestamp_micros();
69    let currency = params.currency.to_uppercase();
70    let expiry_ts =
71        parse_expiry_date_to_unix(&currency, &params.expiry).map_err(ApiError::bad_request)?;
72    let option_type_filter = OptionsChainOptionTypeFilter::parse(params.option_type.as_deref())
73        .map_err(ApiError::bad_request)?;
74    let side_filter =
75        OptionsChainSideFilter::parse(params.side.as_deref()).map_err(ApiError::bad_request)?;
76
77    let instruments = app_state
78        .instruments_cache
79        .get_by_underlying_and_expiry(&currency, expiry_ts)
80        .await;
81
82    let active_instruments: Vec<_> = instruments
83        .into_iter()
84        .filter(|instrument| instrument.status.is_active())
85        .filter(|instrument| option_type_filter.allows_option_type(&instrument.option_type))
86        .collect();
87
88    let mut grouped_rows: BTreeMap<rust_decimal::Decimal, StrikeRowBuilder> = BTreeMap::new();
89    for instrument in active_instruments {
90        let leg = build_options_chain_leg(
91            &instrument,
92            &app_state.quote_provider,
93            &app_state.greeks_cache,
94            side_filter,
95        )
96        .await;
97        insert_options_chain_leg_for_strike(
98            &mut grouped_rows,
99            instrument.strike,
100            &instrument.option_type,
101            leg,
102        )?;
103    }
104
105    let rows = grouped_rows
106        .into_iter()
107        .map(|(strike, row)| {
108            strike_to_f64(strike)
109                .map_err(ApiError::internal_error)
110                .map(|strike_f64| OptionsChainStrikeRow {
111                    strike: strike_f64,
112                    call: row.call,
113                    put: row.put,
114                })
115        })
116        .collect::<Result<Vec<_>, _>>()?;
117
118    let response_payload = OptionsChainSnapshotResponse {
119        currency,
120        expiry: expiry_ts,
121        rows,
122    };
123
124    let response = JsonRpcResponse {
125        jsonrpc: "2.0".to_string(),
126        result: Some(response_payload),
127        error: None,
128        testnet: false,
129        us_diff: 1,
130        us_in: request_started,
131        us_out: chrono::Utc::now().timestamp_micros(),
132    };
133
134    Ok(SonicJson(response))
135}
136
137pub(crate) async fn resolve_underlying_price(
138    app_state: &AppState,
139    underlying: &str,
140) -> Result<f64, ApiError> {
141    if let Some(price) = app_state.greeks_cache.get_spot_price(underlying).await {
142        return Ok(price);
143    }
144
145    let perp_symbol = format!("{}-PERP", underlying);
146    if let Some(price) = app_state.greeks_cache.get_spot_price(&perp_symbol).await {
147        return Ok(price);
148    }
149
150    Err(ApiError::internal_error(format!(
151        "No spot price available for underlying {}",
152        underlying
153    )))
154}
155
156pub(crate) async fn resolve_theoretical_price(app_state: &AppState, symbol: &str) -> Option<f64> {
157    get_symbol_theoretical_price(app_state.greeks_cache.as_ref(), symbol)
158        .await
159        .ok()
160}
161
162#[derive(Debug, Deserialize, IntoParams)]
163pub struct OrderBookQuery {
164    /// Instrument ID to get orderbook for
165    instrument_id: Option<i32>,
166    /// Instrument name to get orderbook for (e.g., BTC-20250228-50000-C)
167    instrument_name: Option<String>,
168    /// Orderbook depth (default: 20)
169    depth: Option<usize>,
170}
171
172/// Get orderbook for an instrument
173#[utoipa::path(
174    get,
175    path = "/orderbook",
176    params(OrderBookQuery),
177    responses(
178        (status = 200, description = "Orderbook data", body = OrderBookResponse),
179        (status = 400, description = "Missing query parameter: instrument_id or instrument_name"),
180        (status = 404, description = "Instrument not found")
181    ),
182    tag = "Markets"
183)]
184pub async fn get_orderbook(
185    State(app_state): State<AppState>,
186    Query(params): Query<OrderBookQuery>,
187) -> Result<SonicJson<JsonRpcResponse<OrderBookResponse>>, ApiError> {
188    if params.instrument_id.is_none() && params.instrument_name.is_none() {
189        return Err(ApiError::bad_request(
190            "instrument_id or instrument_name is required",
191        ));
192    }
193
194    let depth = params.depth.unwrap_or(20).max(1);
195    let instrument = match (params.instrument_id, params.instrument_name.as_ref()) {
196        (Some(id), _) => app_state.instruments_cache.get_by_instrument_id(id).await,
197        (None, Some(symbol)) => app_state.instruments_cache.get_by_symbol(symbol).await,
198        (None, None) => None,
199    };
200
201    let instrument = match instrument {
202        Some(inst) => inst,
203        None => {
204            let missing_key = match (params.instrument_id, params.instrument_name) {
205                (Some(id), _) => serde_json::json!({
206                    "object": "instrument_id",
207                    "value": id
208                }),
209                (None, Some(symbol)) => serde_json::json!({
210                    "object": "instrument_name",
211                    "value": symbol
212                }),
213                (None, None) => serde_json::json!({
214                    "object": "instrument_query",
215                    "value": "missing instrument_id and instrument_name"
216                }),
217            };
218            let error_response = JsonRpcResponse {
219                jsonrpc: "2.0".to_string(),
220                result: None,
221                error: Some(JsonRpcError {
222                    code: 13020,
223                    message: "not_found".to_string(),
224                    data: Some(missing_key),
225                }),
226                testnet: false,
227                us_diff: 1,
228                us_in: chrono::Utc::now().timestamp_micros(),
229                us_out: chrono::Utc::now().timestamp_micros(),
230            };
231            return Ok(SonicJson(error_response));
232        }
233    };
234
235    let underlying = instrument.underlying.to_uppercase();
236    let underlying_price = resolve_underlying_price(&app_state, &underlying).await?;
237    let theoretical_price = resolve_theoretical_price(&app_state, &instrument.id).await;
238
239    // Get orderbook levels from quote provider
240    let (sq, change_id) = app_state.quote_provider.get_quote_with_seq(&instrument.id);
241    let (bids_contract_units_raw, asks_contract_units_raw) = sq
242        .as_ref()
243        .map(|q| {
244            let b: Vec<(f64, f64)> = q.bids.iter().take(depth).copied().collect();
245            let a: Vec<(f64, f64)> = q.asks.iter().take(depth).copied().collect();
246            (b, a)
247        })
248        .unwrap_or_else(|| (Vec::new(), Vec::new()));
249
250    // Convert raw contract-unit sizes to human-readable contracts for API response.
251    let bids_human_contracts = quote_levels_to_human_contracts(&bids_contract_units_raw);
252    let asks_human_contracts = quote_levels_to_human_contracts(&asks_contract_units_raw);
253
254    // Prefer top-of-book values from snapshot quote, then fall back to depth arrays.
255    let best_bid = sq
256        .as_ref()
257        .and_then(|quote| quote.best_bid)
258        .filter(|price| *price > 0.0)
259        .or_else(|| bids_contract_units_raw.first().map(|(price, _)| *price))
260        .unwrap_or(0.0);
261    let best_ask = sq
262        .as_ref()
263        .and_then(|quote| quote.best_ask)
264        .filter(|price| *price > 0.0)
265        .or_else(|| asks_contract_units_raw.first().map(|(price, _)| *price))
266        .unwrap_or(0.0);
267    let best_bid_amount_contract_units_raw = sq
268        .as_ref()
269        .and_then(|quote| quote.best_bid_size)
270        .filter(|size| *size > 0.0)
271        .or_else(|| bids_contract_units_raw.first().map(|(_, size)| *size))
272        .unwrap_or(0.0);
273    let best_ask_amount_contract_units_raw = sq
274        .as_ref()
275        .and_then(|quote| quote.best_ask_size)
276        .filter(|size| *size > 0.0)
277        .or_else(|| asks_contract_units_raw.first().map(|(_, size)| *size))
278        .unwrap_or(0.0);
279    let best_bid_amount_human_contracts =
280        raw_contract_units_to_human_contracts(best_bid_amount_contract_units_raw);
281    let best_ask_amount_human_contracts =
282        raw_contract_units_to_human_contracts(best_ask_amount_contract_units_raw);
283
284    // Calculate mid price
285    let mid_price = match (best_bid, best_ask) {
286        (b, a) if b > 0.0 && a > 0.0 => (b + a) / 2.0,
287        (b, _) if b > 0.0 => b,
288        (_, a) if a > 0.0 => a,
289        _ => 0.0,
290    };
291
292    let (dynamic_mark_iv, dynamic_greeks) =
293        match app_state.greeks_cache.get_greeks(&instrument.id).await {
294            Ok(greeks) => (
295                Some(greeks.implied_vol),
296                Some(super::order_book_greeks_from(&greeks)),
297            ),
298            Err(_) => (
299                app_state.greeks_cache.get_iv(&instrument.id).await.ok(),
300                None,
301            ),
302        };
303    let (bid_iv, ask_iv) = app_state
304        .greeks_cache
305        .get_quote_side_ivs_from_prices(
306            &instrument.id,
307            (best_bid > 0.0).then_some(best_bid),
308            (best_ask > 0.0).then_some(best_ask),
309        )
310        .await
311        .unwrap_or((None, None));
312
313    let response_payload = OrderBookResponse {
314        timestamp: chrono::Utc::now().timestamp_millis(),
315        state: "open".to_string(),
316        stats: OrderBookStats {
317            high: None,
318            low: None,
319            price_change: None,
320            volume: 0.0,
321            volume_usd: 0.0,
322        },
323        greeks: dynamic_greeks,
324        change_id,
325        index_price: underlying_price,
326        instrument_name: instrument.id.clone(),
327        option_token_address: instrument.option_token_address,
328        bids: bids_human_contracts,
329        asks: asks_human_contracts,
330        last_price: if mid_price > 0.0 {
331            Some(mid_price)
332        } else {
333            None
334        },
335        settlement_price: if mid_price > 0.0 {
336            mid_price
337        } else {
338            underlying_price
339        },
340        min_price: if best_bid > 0.0 { best_bid } else { 0.0 },
341        max_price: if best_ask > 0.0 { best_ask } else { 0.0 },
342        open_interest: instrument.open_interest.to_f64().unwrap_or(0.0),
343        mark_price: mid_price,
344        theoretical_price,
345        best_bid_price: best_bid,
346        best_ask_price: best_ask,
347        mark_iv: dynamic_mark_iv,
348        ask_iv,
349        bid_iv,
350        underlying_price,
351        underlying_index: format!("{}_USD", underlying),
352        interest_rate: 0.0,
353        estimated_delivery_price: underlying_price,
354        best_ask_amount: best_ask_amount_human_contracts,
355        best_bid_amount: best_bid_amount_human_contracts,
356    };
357
358    let response = JsonRpcResponse {
359        jsonrpc: "2.0".to_string(),
360        result: Some(response_payload),
361        error: None,
362        testnet: false,
363        us_diff: 1,
364        us_in: chrono::Utc::now().timestamp_micros(),
365        us_out: chrono::Utc::now().timestamp_micros(),
366    };
367
368    Ok(SonicJson(response))
369}