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 #[param(example = "BTC-20260331-100000-C")]
18 pub instrument_name: String,
19 #[param(example = "1h")]
21 pub interval: HistoricalTheoInterval,
22 #[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#[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 #[param(example = "ETH-20260413-2000-C,ETH-20260413-2150-C")]
148 pub instrument_names: String,
149 #[param(example = "1h")]
151 pub interval: HistoricalTheoInterval,
152 #[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#[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 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}