Skip to main content

hypercall_api/handlers/
historical_theos.rs

1use std::collections::HashMap;
2
3use axum::extract::{Query, State};
4use hypercall_types::{HistoricalTheoInterval, HistoricalTheoPoint, HistoricalTheoResponse};
5use rust_decimal::prelude::ToPrimitive;
6use serde::{Deserialize, Serialize};
7use utoipa::{IntoParams, ToSchema};
8
9use super::AppState;
10use crate::error::ApiError;
11use crate::models::ApiResponse;
12use crate::sonic_json::SonicJson;
13
14#[derive(Debug, Deserialize, IntoParams)]
15pub struct HistoricalTheosQuery {
16    /// Option instrument symbol.
17    #[param(example = "BTC-20260331-100000-C")]
18    pub instrument_name: String,
19    /// Interval bucket size (`5m`, `1h`, `1d`)
20    #[param(example = "1h")]
21    pub interval: HistoricalTheoInterval,
22    /// Maximum number of periods to return (default: 100, max: 100)
23    #[param(maximum = 100, example = 100)]
24    pub limit: Option<usize>,
25}
26
27#[derive(Debug, Serialize, ToSchema)]
28pub struct HistoricalTheoApiResponse {
29    pub success: bool,
30    pub data: Option<HistoricalTheoResponse>,
31    pub error: Option<String>,
32}
33
34/// Get historical theoretical-price snapshots for an option instrument.
35#[utoipa::path(
36    get,
37    path = "/historical-theos",
38    params(HistoricalTheosQuery),
39    responses(
40        (status = 200, description = "Historical theoretical-price snapshots", body = HistoricalTheoApiResponse),
41        (status = 400, description = "Bad Request - invalid or blank instrument_name"),
42        (status = 404, description = "Instrument not found"),
43        (status = 500, description = "Internal server error")
44    ),
45    tag = "Markets"
46)]
47pub async fn get_historical_theos(
48    State(app_state): State<AppState>,
49    Query(params): Query<HistoricalTheosQuery>,
50) -> Result<SonicJson<ApiResponse<HistoricalTheoResponse>>, ApiError> {
51    let instrument_name = params.instrument_name.trim();
52    if instrument_name.is_empty() {
53        return Err(ApiError::bad_request("instrument_name is required"));
54    }
55
56    let instrument_known = app_state
57        .instruments_cache
58        .get_by_symbol(instrument_name)
59        .await
60        .is_some()
61        || app_state
62            .db
63            .instrument_exists(instrument_name)
64            .await
65            .map_err(|error| {
66                tracing::error!(
67                    instrument_name,
68                    error = %error,
69                    "Failed to verify instrument existence for historical theos"
70                );
71                ApiError::internal_error(format!(
72                    "failed to verify instrument existence for historical theos: {error}"
73                ))
74            })?;
75
76    let history_exists = app_state
77        .db
78        .historical_theo_history_exists_for_symbol(instrument_name)
79        .await
80        .map_err(|error| {
81            tracing::error!(
82                instrument_name,
83                error = %error,
84                "Failed to verify historical theo availability"
85            );
86            ApiError::internal_error(format!(
87                "failed to verify historical theo availability: {error}"
88            ))
89        })?;
90
91    if !instrument_known && !history_exists {
92        return Err(ApiError::not_found(format!(
93            "instrument not found: {instrument_name}"
94        )));
95    }
96
97    let limit = params.limit.unwrap_or(100).min(100);
98    let points = app_state
99        .db
100        .get_historical_theos(instrument_name, params.interval.as_ms(), limit)
101        .await
102        .map_err(|error| {
103            tracing::error!(
104                instrument_name,
105                interval = params.interval.as_str(),
106                error = %error,
107                "Failed to fetch historical theos"
108            );
109            ApiError::internal_error(format!("failed to fetch historical theos: {error}"))
110        })?;
111
112    let points = points
113        .into_iter()
114        .map(|point| {
115            point
116                .theoretical_price
117                .to_f64()
118                .ok_or_else(|| {
119                    tracing::error!(
120                        instrument_name,
121                        timestamp_ms = point.timestamp_ms,
122                        theoretical_price = %point.theoretical_price,
123                        "Failed to convert historical theo price to f64"
124                    );
125                    ApiError::internal_error(format!(
126                        "failed to convert historical theo price at {} to f64",
127                        point.timestamp_ms
128                    ))
129                })
130                .map(|theoretical_price| HistoricalTheoPoint {
131                    timestamp: point.timestamp_ms,
132                    theoretical_price,
133                })
134        })
135        .collect::<Result<Vec<_>, _>>()?;
136
137    Ok(SonicJson(ApiResponse::success(HistoricalTheoResponse {
138        instrument_name: instrument_name.to_string(),
139        interval: params.interval,
140        points,
141    })))
142}
143
144#[derive(Debug, Deserialize, IntoParams)]
145pub struct BatchHistoricalTheosQuery {
146    /// Comma-separated list of instrument names
147    #[param(example = "ETH-20260413-2000-C,ETH-20260413-2150-C")]
148    pub instrument_names: String,
149    /// Interval bucket size (`5m`, `1h`, `1d`)
150    #[param(example = "1h")]
151    pub interval: HistoricalTheoInterval,
152    /// Maximum number of periods per instrument (default: 100, max: 100)
153    #[param(maximum = 100, example = 100)]
154    pub limit: Option<usize>,
155}
156
157#[derive(Debug, Serialize, ToSchema)]
158pub struct BatchHistoricalTheoApiResponse {
159    pub success: bool,
160    pub data: HashMap<String, HistoricalTheoResponse>,
161    #[serde(skip_serializing_if = "Option::is_none")]
162    pub error: Option<String>,
163}
164
165/// Get historical theoretical-price snapshots for multiple instruments in one request.
166#[utoipa::path(
167    get,
168    path = "/historical-theos/batch",
169    params(BatchHistoricalTheosQuery),
170    responses(
171        (status = 200, description = "Batch historical theoretical-price snapshots", body = BatchHistoricalTheoApiResponse),
172        (status = 400, description = "Bad Request"),
173        (status = 500, description = "Internal server error")
174    ),
175    tag = "Markets"
176)]
177pub async fn get_historical_theos_batch(
178    State(app_state): State<AppState>,
179    Query(params): Query<BatchHistoricalTheosQuery>,
180) -> Result<SonicJson<BatchHistoricalTheoApiResponse>, ApiError> {
181    let symbols: Vec<String> = params
182        .instrument_names
183        .split(',')
184        .map(|s| s.trim().to_string())
185        .filter(|s| !s.is_empty())
186        .collect();
187
188    if symbols.is_empty() {
189        return Err(ApiError::bad_request(
190            "instrument_names is required (comma-separated)",
191        ));
192    }
193
194    if symbols.len() > 50 {
195        return Err(ApiError::bad_request(
196            "maximum 50 instruments per batch request",
197        ));
198    }
199
200    let limit = params.limit.unwrap_or(100).min(100);
201    let interval_ms = params.interval.as_ms();
202
203    let raw_points = app_state
204        .db
205        .get_historical_theos_batch(&symbols, interval_ms, limit)
206        .await
207        .map_err(|error| {
208            tracing::error!(
209                instrument_count = symbols.len(),
210                interval = params.interval.as_str(),
211                error = %error,
212                "Failed to fetch batch historical theos"
213            );
214            ApiError::internal_error(format!("failed to fetch batch historical theos: {error}"))
215        })?;
216
217    let mut data = HashMap::with_capacity(symbols.len());
218
219    // Initialize all requested symbols so the response includes instruments
220    // with no history (empty points array), matching single-endpoint behavior.
221    for symbol in &symbols {
222        data.insert(
223            symbol.clone(),
224            HistoricalTheoResponse {
225                instrument_name: symbol.clone(),
226                interval: params.interval,
227                points: Vec::new(),
228            },
229        );
230    }
231
232    for (symbol, points) in raw_points {
233        let converted: Result<Vec<HistoricalTheoPoint>, ApiError> = points
234            .into_iter()
235            .map(|point| {
236                point
237                    .theoretical_price
238                    .to_f64()
239                    .ok_or_else(|| {
240                        ApiError::internal_error(format!(
241                            "failed to convert historical theo price at {} to f64",
242                            point.timestamp_ms
243                        ))
244                    })
245                    .map(|theoretical_price| HistoricalTheoPoint {
246                        timestamp: point.timestamp_ms,
247                        theoretical_price,
248                    })
249            })
250            .collect();
251
252        data.insert(
253            symbol.clone(),
254            HistoricalTheoResponse {
255                instrument_name: symbol,
256                interval: params.interval,
257                points: converted?,
258            },
259        );
260    }
261
262    Ok(SonicJson(BatchHistoricalTheoApiResponse {
263        success: true,
264        data,
265        error: None,
266    }))
267}
268
269#[cfg(test)]
270mod tests {
271    use super::*;
272    use hypercall_types::{
273        HISTORICAL_THEO_INTERVAL_1D_MS, HISTORICAL_THEO_INTERVAL_1H_MS,
274        HISTORICAL_THEO_INTERVAL_5M_MS,
275    };
276
277    #[test]
278    fn test_historical_theo_interval_mapping() {
279        assert_eq!(
280            HistoricalTheoInterval::FiveMinutes.as_ms(),
281            HISTORICAL_THEO_INTERVAL_5M_MS
282        );
283        assert_eq!(
284            HistoricalTheoInterval::OneHour.as_ms(),
285            HISTORICAL_THEO_INTERVAL_1H_MS
286        );
287        assert_eq!(
288            HistoricalTheoInterval::OneDay.as_ms(),
289            HISTORICAL_THEO_INTERVAL_1D_MS
290        );
291
292        assert_eq!(HistoricalTheoInterval::FiveMinutes.as_str(), "5m");
293        assert_eq!(HistoricalTheoInterval::OneHour.as_str(), "1h");
294        assert_eq!(HistoricalTheoInterval::OneDay.as_str(), "1d");
295    }
296
297    #[test]
298    fn test_historical_theo_interval_deserialization() {
299        let i5m: HistoricalTheoInterval = sonic_rs::from_str("\"5m\"").expect("parse 5m");
300        let i1h: HistoricalTheoInterval = sonic_rs::from_str("\"1h\"").expect("parse 1h");
301        let i1d: HistoricalTheoInterval = sonic_rs::from_str("\"1d\"").expect("parse 1d");
302
303        assert_eq!(i5m, HistoricalTheoInterval::FiveMinutes);
304        assert_eq!(i1h, HistoricalTheoInterval::OneHour);
305        assert_eq!(i1d, HistoricalTheoInterval::OneDay);
306
307        let invalid = sonic_rs::from_str::<HistoricalTheoInterval>("\"10m\"");
308        assert!(
309            invalid.is_err(),
310            "invalid interval should fail to deserialize"
311        );
312    }
313}