Skip to main content

hypercall_admin/monitoring/
halts.rs

1//! Trading halt kill-switch admin endpoints.
2
3use axum::{extract::State, http::StatusCode, response::IntoResponse};
4use serde::Serialize;
5use sonic_rs::json;
6use std::collections::HashMap;
7use tracing::warn;
8
9use crate::state::AdminState;
10use hypercall_runtime_api::sonic_json::SonicJson;
11use hypercall_runtime_api::trading_halt::{
12    normalize_symbol, TradingHaltActivation, TradingHaltState,
13};
14
15#[derive(Debug, Serialize, utoipa::ToSchema)]
16pub struct TradingHaltsResponse {
17    pub global_halt: Option<TradingHaltActivation>,
18    pub halted_markets: HashMap<String, TradingHaltActivation>,
19    pub audit_log: Vec<TradingHaltActivation>,
20}
21
22#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
23pub struct SetGlobalTradingHaltRequest {
24    pub halted: bool,
25    pub reason: String,
26}
27
28#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
29pub struct SetMarketTradingHaltRequest {
30    pub symbol: String,
31    pub halted: bool,
32    pub reason: String,
33}
34
35fn trading_halts_response(state: &TradingHaltState) -> TradingHaltsResponse {
36    TradingHaltsResponse {
37        global_halt: state.global_halt.clone(),
38        halted_markets: state.halted_markets.clone(),
39        audit_log: state.audit_log.clone(),
40    }
41}
42
43/// GET /monitoring/trading-halts - Current global and per-market trading halt state.
44#[utoipa::path(
45    get,
46    path = "/monitoring/trading-halts",
47    responses(
48        (status = 200, description = "Trading halt state", body = TradingHaltsResponse),
49        (status = 401, description = "Invalid or missing X-Admin-Key header")
50    ),
51    tag = "Monitoring",
52    security(("admin_key" = []))
53)]
54pub async fn get_trading_halts(State(app_state): State<AdminState>) -> impl IntoResponse {
55    let state = app_state.trading_halt.read().await;
56    SonicJson(trading_halts_response(&state))
57}
58
59/// POST /monitoring/trading-halts/global - Enable or disable the global trading halt.
60#[utoipa::path(
61    post,
62    path = "/monitoring/trading-halts/global",
63    request_body = SetGlobalTradingHaltRequest,
64    responses(
65        (status = 200, description = "Updated trading halt state", body = TradingHaltsResponse),
66        (status = 400, description = "Invalid request"),
67        (status = 401, description = "Invalid or missing X-Admin-Key header")
68    ),
69    tag = "Monitoring",
70    security(("admin_key" = []))
71)]
72pub async fn set_global_trading_halt(
73    State(app_state): State<AdminState>,
74    SonicJson(request): SonicJson<SetGlobalTradingHaltRequest>,
75) -> impl IntoResponse {
76    if request.reason.trim().is_empty() {
77        return (
78            StatusCode::BAD_REQUEST,
79            SonicJson(json!({
80                "error": "Reason is required",
81            })),
82        )
83            .into_response();
84    }
85
86    let actor = "admin_api".to_string();
87    {
88        let mut state = app_state.trading_halt.write().await;
89        state.set_global_halt(request.halted, request.reason, actor.clone());
90    }
91
92    warn!(
93        halted = request.halted,
94        actor = %actor,
95        "Global trading halt updated"
96    );
97
98    let state = app_state.trading_halt.read().await;
99    (StatusCode::OK, SonicJson(trading_halts_response(&state))).into_response()
100}
101
102/// POST /monitoring/trading-halts/market - Enable or disable a per-market trading halt.
103#[utoipa::path(
104    post,
105    path = "/monitoring/trading-halts/market",
106    request_body = SetMarketTradingHaltRequest,
107    responses(
108        (status = 200, description = "Updated trading halt state", body = TradingHaltsResponse),
109        (status = 400, description = "Invalid request"),
110        (status = 401, description = "Invalid or missing X-Admin-Key header")
111    ),
112    tag = "Monitoring",
113    security(("admin_key" = []))
114)]
115pub async fn set_market_trading_halt(
116    State(app_state): State<AdminState>,
117    SonicJson(request): SonicJson<SetMarketTradingHaltRequest>,
118) -> impl IntoResponse {
119    if request.reason.trim().is_empty() {
120        return (
121            StatusCode::BAD_REQUEST,
122            SonicJson(json!({
123                "error": "Reason is required",
124            })),
125        )
126            .into_response();
127    }
128    if request.symbol.trim().is_empty() {
129        return (
130            StatusCode::BAD_REQUEST,
131            SonicJson(json!({
132                "error": "Symbol is required",
133            })),
134        )
135            .into_response();
136    }
137
138    let actor = "admin_api".to_string();
139    let normalized_symbol = normalize_symbol(&request.symbol);
140    {
141        let mut state = app_state.trading_halt.write().await;
142        state.set_market_halt(
143            &normalized_symbol,
144            request.halted,
145            request.reason,
146            actor.clone(),
147        );
148    }
149
150    warn!(
151        halted = request.halted,
152        symbol = %normalized_symbol,
153        actor = %actor,
154        "Market trading halt updated"
155    );
156
157    let state = app_state.trading_halt.read().await;
158    (StatusCode::OK, SonicJson(trading_halts_response(&state))).into_response()
159}