1pub mod account;
2pub mod admin;
3pub mod agents;
4pub mod bulk_orders;
5pub mod candles;
6pub mod competition;
7pub mod directives;
8pub mod gas_provider;
9pub mod greeks;
10pub mod health;
11pub mod historical_pnl;
12pub mod historical_theos;
13pub mod liquidation;
14pub mod market_data;
15pub mod notifications;
16pub mod options_chain;
17pub mod orders;
18pub mod profile_image;
19pub mod push;
20pub mod replace_orders;
21pub mod rfq;
22#[cfg(feature = "rsm-state")]
23pub mod rsm_rpc;
24pub mod settlement;
25#[cfg(any(feature = "faucet", feature = "test-endpoints"))]
26pub mod testnet;
27pub mod username;
28
29use rust_decimal::Decimal;
30use serde::{Serialize, Serializer};
31
32use super::sonic_json::SonicJson;
33use super::{
34 error::ApiError,
35 models::{
36 ApiResponse, ExtendedRiskMatrixResponse, InstrumentRiskRowResponse, InstrumentStatus,
37 RiskGridScenario, ScenarioDefinition,
38 },
39};
40pub(crate) use crate::state::{submit_tier_update_command, ENGINE_RESPONSE_TIMEOUT};
41pub use crate::state::{AppRuntimeConfig, AppState};
42use axum::{
43 http::StatusCode,
44 response::{IntoResponse, Response},
45};
46use hypercall_types::{Greeks, OrderBookGreeks, CONTRACT_UNIT_MULTIPLIER};
47
48#[cfg(feature = "faucet")]
49pub(crate) const FAUCET_LIFETIME_CAP_USDC: i64 = 100_000;
50#[cfg(feature = "faucet")]
51pub(crate) const MARKET_MAKER_TIER: &str = "market_maker";
52
53pub use account::{
56 get_fills, get_markets, get_orders, get_portfolio, get_risk_grid, get_trades, set_margin_mode,
57};
58#[cfg(feature = "test-endpoints")]
59pub use admin::cancel_all_orders;
60pub use admin::{
61 delete_mmp_config, delete_user_tier, get_mmp_config, get_user_tier, reset_mmp, set_mmp_config,
62 set_user_tier,
63};
64pub use agents::{approve_agent, get_authorized_agents, revoke_agent};
65pub use bulk_orders::{
66 bulk_cancel_order, bulk_cancel_order_by_cloid, bulk_place_order, BulkOrderResult,
67};
68pub use health::{health, ready, standby_ready, version};
69pub use market_data::{
70 get_expiry_summary, get_instrument_specs, get_instruments, get_options_summary,
71};
72pub use options_chain::{get_options_chain, get_orderbook};
73pub use orders::{cancel_order, cancel_order_by_cloid, place_order};
74pub use replace_orders::{bulk_replace_order, replace_order};
75#[cfg(feature = "test-endpoints")]
76pub use testnet::expire_instrument;
77#[cfg(feature = "faucet")]
78pub use testnet::faucet;
79#[cfg(feature = "test-endpoints")]
80pub use testnet::{set_option_iv, set_spot_price};
81
82fn trading_halt_message(reason: &str) -> String {
85 format!(
86 "{}. Order placement is disabled while trading is halted. Existing orders can still be canceled.",
87 reason
88 )
89}
90
91fn trading_halt_api_error(reason: String) -> ApiError {
92 ApiError::new(
93 StatusCode::SERVICE_UNAVAILABLE,
94 "trading_halted",
95 trading_halt_message(&reason),
96 )
97}
98
99pub(crate) async fn ensure_order_creation_allowed(
100 state: &AppState,
101 symbol: &str,
102) -> Result<(), ApiError> {
103 let halt_state = state.trading_halt.read().await;
104 if let Some(reason) = halt_state.blocked_reason(symbol) {
105 return Err(trading_halt_api_error(reason));
106 }
107 Ok(())
108}
109
110pub fn serialize_f64_as_string<S>(value: &f64, serializer: S) -> Result<S::Ok, S::Error>
111where
112 S: Serializer,
113{
114 serializer.serialize_str(&value.to_string())
115}
116
117pub fn serialize_option_f64_as_string<S>(
118 value: &Option<f64>,
119 serializer: S,
120) -> Result<S::Ok, S::Error>
121where
122 S: Serializer,
123{
124 match value {
125 Some(v) => serializer.serialize_str(&v.to_string()),
126 None => serializer.serialize_none(),
127 }
128}
129
130pub(crate) fn try_decimal_from_f64(value: f64, context: &str) -> Result<Decimal, String> {
131 Decimal::from_f64_retain(value).ok_or_else(|| {
132 format!(
133 "{} value {} is not representable as Decimal",
134 context, value
135 )
136 })
137}
138
139fn api_json_response<T: Serialize>(status: StatusCode, payload: ApiResponse<T>) -> Response {
140 (status, SonicJson(payload)).into_response()
141}
142
143fn api_error_response(status: StatusCode, message: impl Into<String>) -> Response {
144 api_json_response(status, ApiResponse::<()>::error(message.into()))
145}
146
147fn pm_unavailable_response(message: impl Into<String>) -> Response {
148 api_error_response(StatusCode::SERVICE_UNAVAILABLE, message)
149}
150
151fn convert_risk_grid_scenarios(
152 scenario_pnls: Vec<hypercall_margin::ScenarioPnl>,
153) -> Result<Vec<RiskGridScenario>, String> {
154 scenario_pnls
155 .into_iter()
156 .map(|scenario| {
157 Ok(RiskGridScenario {
158 id: scenario.scenario_id,
159 spot_shock_pct: try_decimal_from_f64(
160 scenario.scenario.spot_shock_pct,
161 "risk_grid.scenario.spot_shock_pct",
162 )?,
163 vol_shock_pct: try_decimal_from_f64(
164 scenario.scenario.vol_shock_pct,
165 "risk_grid.scenario.vol_shock_pct",
166 )?,
167 pnl_weight: try_decimal_from_f64(
168 scenario.scenario.pnl_weight,
169 "risk_grid.scenario.pnl_weight",
170 )?,
171 is_tail: scenario.scenario.is_tail,
172 total_pnl: try_decimal_from_f64(
173 scenario.total_pnl,
174 "risk_grid.scenario.total_pnl",
175 )?,
176 })
177 })
178 .collect()
179}
180
181fn convert_extended_risk_matrix(
182 extended_grid: hypercall_margin::ExtendedRiskGrid,
183) -> Result<Option<ExtendedRiskMatrixResponse>, String> {
184 if extended_grid.instruments.is_empty() {
185 return Ok(None);
186 }
187
188 let scenarios = extended_grid
189 .scenarios
190 .iter()
191 .map(|scenario| {
192 Ok(ScenarioDefinition {
193 id: scenario.id.clone(),
194 spot_shock_pct: try_decimal_from_f64(
195 scenario.spot_shock_pct,
196 "risk_grid.extended.scenario.spot_shock_pct",
197 )?,
198 vol_shock_pct: try_decimal_from_f64(
199 scenario.vol_shock_pct,
200 "risk_grid.extended.scenario.vol_shock_pct",
201 )?,
202 pnl_weight: try_decimal_from_f64(
203 scenario.pnl_weight,
204 "risk_grid.extended.scenario.pnl_weight",
205 )?,
206 is_tail: scenario.is_tail,
207 })
208 })
209 .collect::<Result<Vec<_>, String>>()?;
210 let instruments = extended_grid
211 .instruments
212 .into_iter()
213 .map(|instrument| {
214 Ok(InstrumentRiskRowResponse {
215 symbol: instrument.symbol,
216 underlying: instrument.underlying,
217 amount: try_decimal_from_f64(
218 instrument.amount,
219 "risk_grid.extended.instrument.amount",
220 )?,
221 base_amount: try_decimal_from_f64(
222 instrument.base_amount,
223 "risk_grid.extended.instrument.base_amount",
224 )?,
225 current_value: try_decimal_from_f64(
226 instrument.current_value,
227 "risk_grid.extended.instrument.current_value",
228 )?,
229 scenario_pnls: instrument
230 .scenario_pnls
231 .into_iter()
232 .map(|pnl| {
233 try_decimal_from_f64(pnl, "risk_grid.extended.instrument.scenario_pnl")
234 })
235 .collect::<Result<Vec<_>, String>>()?,
236 })
237 })
238 .collect::<Result<Vec<_>, String>>()?;
239 let total_pnls = extended_grid
240 .total_pnls
241 .into_iter()
242 .map(|pnl| try_decimal_from_f64(pnl, "risk_grid.extended.total_pnl"))
243 .collect::<Result<Vec<_>, String>>()?;
244
245 Ok(Some(ExtendedRiskMatrixResponse {
246 scenarios,
247 instruments,
248 total_pnls,
249 worst_scenario_index: extended_grid.worst_scenario_index,
250 worst_scenario_pnl: try_decimal_from_f64(
251 extended_grid.worst_scenario_pnl,
252 "risk_grid.extended.worst_scenario_pnl",
253 )?,
254 }))
255}
256
257pub(crate) fn order_book_greeks_from(source: &Greeks) -> OrderBookGreeks {
258 OrderBookGreeks {
259 delta: source.delta,
260 gamma: source.gamma,
261 vega: source.vega,
262 theta: source.theta,
263 rho: source.rho,
264 }
265}
266
267pub(crate) fn raw_contract_units_to_human_contracts(size_contract_units_raw: f64) -> f64 {
268 size_contract_units_raw / CONTRACT_UNIT_MULTIPLIER
269}
270
271pub(crate) fn quote_levels_to_human_contracts(
272 levels_contract_units_raw: &[(f64, f64)],
273) -> Vec<[f64; 2]> {
274 levels_contract_units_raw
275 .iter()
276 .map(|(price, size_contract_units_raw)| {
277 [
278 *price,
279 raw_contract_units_to_human_contracts(*size_contract_units_raw),
280 ]
281 })
282 .collect()
283}
284
285fn normalize_orders_status_filter_input(status: &str) -> String {
286 status.trim().to_ascii_lowercase().replace('-', "_")
287}
288
289pub(crate) fn parse_status_filter(raw: Option<&str>) -> Option<Vec<InstrumentStatus>> {
290 match raw {
291 Some(s) if s.eq_ignore_ascii_case("all") => None,
292 Some(statuses) => {
293 let parsed: Vec<InstrumentStatus> = statuses
294 .split(',')
295 .filter_map(|s| InstrumentStatus::from_db_str(s.trim()))
296 .collect();
297 if parsed.is_empty() {
298 Some(vec![InstrumentStatus::Active])
299 } else {
300 Some(parsed)
301 }
302 }
303 None => Some(vec![InstrumentStatus::Active]),
304 }
305}
306
307pub(crate) fn parse_requested_underlyings(
308 raw: Option<&str>,
309 available_underlyings: Vec<String>,
310) -> Vec<String> {
311 match raw {
312 Some(value) => {
313 let mut seen = std::collections::HashSet::new();
314 let parsed: Vec<String> = value
315 .split(',')
316 .map(|item| item.trim().to_uppercase())
317 .filter(|item| !item.is_empty())
318 .filter(|item| seen.insert(item.clone()))
319 .collect();
320 if parsed.is_empty() || parsed.iter().any(|item| item == "ALL") {
321 available_underlyings
322 } else {
323 parsed
324 }
325 }
326 None => available_underlyings,
327 }
328}
329
330#[cfg(test)]
333fn matches_status_filter(
334 status: &InstrumentStatus,
335 filter: &Option<Vec<InstrumentStatus>>,
336) -> bool {
337 match filter {
338 Some(allowed) => allowed.contains(status),
339 None => true,
340 }
341}
342
343#[cfg(test)]
344mod status_filter_tests {
345 use super::*;
346
347 #[test]
348 fn test_parse_default_returns_active_only() {
349 let filter = parse_status_filter(None);
350 assert_eq!(filter, Some(vec![InstrumentStatus::Active]));
351 }
352
353 #[test]
354 fn test_parse_all_returns_none() {
355 assert_eq!(parse_status_filter(Some("all")), None);
356 assert_eq!(parse_status_filter(Some("ALL")), None);
357 assert_eq!(parse_status_filter(Some("All")), None);
358 }
359
360 #[test]
361 fn test_parse_single_status() {
362 assert_eq!(
363 parse_status_filter(Some("SETTLED")),
364 Some(vec![InstrumentStatus::Settled])
365 );
366 assert_eq!(
367 parse_status_filter(Some("EXPIRED_PENDING_PRICE")),
368 Some(vec![InstrumentStatus::ExpiredPendingPrice])
369 );
370 }
371
372 #[test]
373 fn test_parse_comma_separated() {
374 let filter = parse_status_filter(Some("ACTIVE,EXPIRED_PENDING_PRICE"));
375 assert_eq!(
376 filter,
377 Some(vec![
378 InstrumentStatus::Active,
379 InstrumentStatus::ExpiredPendingPrice,
380 ])
381 );
382 }
383
384 #[test]
385 fn test_parse_with_whitespace() {
386 let filter = parse_status_filter(Some(" ACTIVE , SETTLED "));
387 assert_eq!(
388 filter,
389 Some(vec![InstrumentStatus::Active, InstrumentStatus::Settled])
390 );
391 }
392
393 #[test]
394 fn test_parse_invalid_falls_back_to_active() {
395 let filter = parse_status_filter(Some("GARBAGE"));
396 assert_eq!(filter, Some(vec![InstrumentStatus::Active]));
397 }
398
399 #[test]
400 fn test_parse_mixed_valid_invalid_keeps_valid() {
401 let filter = parse_status_filter(Some("ACTIVE,GARBAGE,SETTLED"));
402 assert_eq!(
403 filter,
404 Some(vec![InstrumentStatus::Active, InstrumentStatus::Settled])
405 );
406 }
407
408 #[test]
409 fn test_parse_case_insensitive() {
410 let filter = parse_status_filter(Some("active,settled"));
411 assert_eq!(
412 filter,
413 Some(vec![InstrumentStatus::Active, InstrumentStatus::Settled])
414 );
415 }
416
417 #[test]
418 fn test_matches_active_default_filter() {
419 let filter = parse_status_filter(None);
420 assert!(matches_status_filter(&InstrumentStatus::Active, &filter));
421 assert!(!matches_status_filter(&InstrumentStatus::Settled, &filter));
422 assert!(!matches_status_filter(
423 &InstrumentStatus::ExpiredPendingPrice,
424 &filter
425 ));
426 }
427
428 #[test]
429 fn test_matches_all_filter() {
430 let filter = parse_status_filter(Some("all"));
431 assert!(matches_status_filter(&InstrumentStatus::Active, &filter));
432 assert!(matches_status_filter(&InstrumentStatus::Settled, &filter));
433 assert!(matches_status_filter(
434 &InstrumentStatus::ExpiredPendingPrice,
435 &filter
436 ));
437 }
438
439 #[test]
440 fn test_matches_multi_status_filter() {
441 let filter = parse_status_filter(Some("ACTIVE,EXPIRED_PENDING_PRICE"));
442 assert!(matches_status_filter(&InstrumentStatus::Active, &filter));
443 assert!(matches_status_filter(
444 &InstrumentStatus::ExpiredPendingPrice,
445 &filter
446 ));
447 assert!(!matches_status_filter(&InstrumentStatus::Settled, &filter));
448 }
449}
450
451#[cfg(test)]
452mod instrument_status_tests {
453 use super::*;
454
455 #[test]
456 fn test_from_db_str() {
457 assert_eq!(
458 InstrumentStatus::from_db_str("ACTIVE"),
459 Some(InstrumentStatus::Active)
460 );
461 assert_eq!(
462 InstrumentStatus::from_db_str("EXPIRED_PENDING_PRICE"),
463 Some(InstrumentStatus::ExpiredPendingPrice)
464 );
465 assert_eq!(
466 InstrumentStatus::from_db_str("SETTLED"),
467 Some(InstrumentStatus::Settled)
468 );
469 assert_eq!(
470 InstrumentStatus::from_db_str("active"),
471 Some(InstrumentStatus::Active)
472 );
473 assert_eq!(InstrumentStatus::from_db_str("UNKNOWN"), None);
474 assert_eq!(InstrumentStatus::from_db_str(""), None);
475 }
476
477 #[test]
478 fn test_is_active() {
479 assert!(InstrumentStatus::Active.is_active());
480 assert!(!InstrumentStatus::ExpiredPendingPrice.is_active());
481 assert!(!InstrumentStatus::Settled.is_active());
482 }
483
484 #[test]
485 fn test_display_roundtrip() {
486 for status in [
487 InstrumentStatus::Active,
488 InstrumentStatus::ExpiredPendingPrice,
489 InstrumentStatus::Settled,
490 ] {
491 let s = status.to_string();
492 assert_eq!(InstrumentStatus::from_db_str(&s), Some(status));
493 }
494 }
495
496 #[test]
497 fn test_serde_roundtrip() {
498 let status = InstrumentStatus::ExpiredPendingPrice;
499 let json = sonic_rs::to_string(&status).unwrap();
500 assert_eq!(json, "\"EXPIRED_PENDING_PRICE\"");
501 let parsed: InstrumentStatus = sonic_rs::from_str(&json).unwrap();
502 assert_eq!(parsed, status);
503 }
504}
505
506#[cfg(test)]
507mod parse_requested_underlyings_tests {
508 use super::parse_requested_underlyings;
509
510 #[test]
511 fn deduplicates_requested_underlyings_case_insensitively() {
512 let parsed = parse_requested_underlyings(
513 Some("btc,BTC, eth ,ETH"),
514 vec!["BTC".to_string(), "ETH".to_string(), "SOL".to_string()],
515 );
516 assert_eq!(parsed, vec!["BTC".to_string(), "ETH".to_string()]);
517 }
518
519 #[test]
520 fn returns_all_available_when_all_requested_after_deduplication() {
521 let parsed = parse_requested_underlyings(
522 Some("btc,all,BTC"),
523 vec!["BTC".to_string(), "ETH".to_string()],
524 );
525 assert_eq!(parsed, vec!["BTC".to_string(), "ETH".to_string()]);
526 }
527}
528
529