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#[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(¤cy, ¶ms.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(¤cy, 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: Option<i32>,
166 instrument_name: Option<String>,
168 depth: Option<usize>,
170}
171
172#[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 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 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 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 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}