Skip to main content

hypercall_api/handlers/
mod.rs

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
53// Re-export handler functions so callers can use `handlers::place_order` style.
54// These stay until all call sites migrate to direct `handlers::orders::place_order` paths.
55pub 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
82// ---- Shared helper functions used by submodules ----
83
84fn 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// ---- Tests for shared helpers ----
331
332#[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// ---- utoipa path re-exports ----