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 #[param(example = "BTC")]
26 currency: Option<String>,
27 _kind: Option<String>,
29 #[param(example = "ACTIVE")]
33 status: Option<String>,
34}
35
36#[derive(Debug, Deserialize, IntoParams)]
37pub struct OptionsSummaryQuery {
38 #[param(example = "BTC")]
41 currency: Option<String>,
42 kind: Option<String>,
44}
45
46#[derive(Debug, Deserialize, IntoParams)]
47pub struct ExpirySummaryQuery {
48 #[param(example = "BTC")]
50 currency: String,
51 #[param(example = "2025-01-31")]
53 expiry: String,
54 depth: Option<usize>,
56}
57
58#[derive(Debug, Deserialize, IntoParams)]
59pub struct OptionsChainQuery {
60 #[param(example = "BTC")]
62 pub currency: String,
63 #[param(example = "2026-03-31")]
65 pub expiry: String,
66 #[param(example = "both")]
68 pub option_type: Option<String>,
69 #[param(example = "both")]
71 pub side: Option<String>,
72}
73
74#[derive(Debug, Clone, Serialize, ToSchema)]
75pub struct OptionSummary {
76 pub instrument_id: i32,
78 pub instrument_name: String,
80 #[schema(value_type = Option<String>)]
82 pub option_token_address: Option<WalletAddress>,
83 pub expiration_timestamp: i64,
85 pub bid_price: f64,
87 pub ask_price: f64,
89 #[serde(skip_serializing_if = "Option::is_none")]
91 pub best_bid_size: Option<f64>,
92 #[serde(skip_serializing_if = "Option::is_none")]
94 pub best_ask_size: Option<f64>,
95 #[serde(skip_serializing_if = "Option::is_none")]
97 pub indicative_bid_price: Option<f64>,
98 #[serde(skip_serializing_if = "Option::is_none")]
100 pub indicative_ask_price: Option<f64>,
101 #[serde(skip_serializing_if = "Option::is_none")]
103 pub indicative_bid_size: Option<f64>,
104 #[serde(skip_serializing_if = "Option::is_none")]
106 pub indicative_ask_size: Option<f64>,
107 pub mark_price: f64,
109 #[serde(skip_serializing_if = "Option::is_none")]
111 pub theoretical_price: Option<f64>,
112 pub mark_iv: Option<f64>,
114 #[serde(skip_serializing_if = "Option::is_none")]
116 pub ask_iv: Option<f64>,
117 #[serde(skip_serializing_if = "Option::is_none")]
119 pub bid_iv: Option<f64>,
120 pub underlying_price: f64,
122 pub underlying_index: String,
124 pub open_interest: f64,
126 pub volume: f64,
128 pub volume_usd: f64,
130 pub high: Option<f64>,
132 pub low: Option<f64>,
134 pub last: Option<f64>,
136 pub price_change: Option<f64>,
139 pub interest_rate: f64,
141 pub estimated_delivery_price: f64,
143 pub creation_timestamp: i64,
145 pub base_currency: String,
147 pub quote_currency: String,
149 pub mid_price: f64,
151 #[serde(skip_serializing_if = "Option::is_none")]
153 pub greeks: Option<OrderBookGreeks>,
154}
155
156#[derive(Debug, Serialize, ToSchema)]
157pub struct InstrumentWithOrderbook {
158 pub instrument_id: i32,
160 pub instrument_name: String,
162 #[schema(value_type = Option<String>)]
164 pub option_token_address: Option<WalletAddress>,
165 pub strike: f64,
167 pub option_type: String,
169 pub expiration_timestamp: i64,
171 pub bid_price: f64,
173 pub ask_price: f64,
175 pub mark_price: f64,
177 #[serde(skip_serializing_if = "Option::is_none")]
179 pub theoretical_price: Option<f64>,
180 pub mark_iv: Option<f64>,
182 #[serde(skip_serializing_if = "Option::is_none")]
184 pub ask_iv: Option<f64>,
185 #[serde(skip_serializing_if = "Option::is_none")]
187 pub bid_iv: Option<f64>,
188 pub underlying_price: f64,
190 pub underlying_index: String,
192 pub open_interest: f64,
194 pub volume: f64,
196 pub volume_usd: f64,
198 pub high: Option<f64>,
200 pub low: Option<f64>,
202 pub last: Option<f64>,
204 pub price_change: Option<f64>,
206 pub interest_rate: f64,
208 pub estimated_delivery_price: f64,
210 pub creation_timestamp: i64,
212 pub base_currency: String,
214 pub quote_currency: String,
216 pub mid_price: f64,
218 pub bids: Vec<[f64; 2]>,
220 pub asks: Vec<[f64; 2]>,
222}
223
224#[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(¤cy, &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#[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(¤cy, &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#[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#[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 let expiry_int = parse_expiry_date_to_unix(¤cy, ¶ms.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 let instruments: Vec<_> = app_state
367 .instruments_cache
368 .get_by_underlying_and_expiry(¤cy, 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, ¤cy).await?;
378
379 for inst in instruments {
380 let expiry_timestamp = (inst.expiry * 1000) as i64;
382 let theoretical_price = resolve_theoretical_price(&app_state, &inst.id).await;
383
384 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 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 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 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}