Skip to main content

hypercall_runtime_api/
trading_halt.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4
5#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema, PartialEq, Eq)]
6#[serde(rename_all = "snake_case")]
7pub enum TradingHaltScope {
8    Global,
9    Market,
10}
11
12#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema, PartialEq, Eq)]
13pub struct TradingHaltActivation {
14    pub scope: TradingHaltScope,
15    pub symbol: Option<String>,
16    pub halted: bool,
17    pub reason: String,
18    pub activated_by: String,
19    pub activated_at: DateTime<Utc>,
20}
21
22#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
23pub struct TradingHaltState {
24    pub global_halt: Option<TradingHaltActivation>,
25    pub halted_markets: HashMap<String, TradingHaltActivation>,
26    pub audit_log: Vec<TradingHaltActivation>,
27}
28
29impl Default for TradingHaltState {
30    fn default() -> Self {
31        Self::new()
32    }
33}
34
35impl TradingHaltState {
36    pub fn new() -> Self {
37        Self {
38            global_halt: None,
39            halted_markets: HashMap::new(),
40            audit_log: Vec::new(),
41        }
42    }
43
44    pub fn from_config(global_halted: bool, halted_markets: &[String]) -> Self {
45        let mut state = Self::new();
46        if global_halted {
47            state.set_global_halt(
48                true,
49                "Startup config: TRADING_HALTED".to_string(),
50                "config".to_string(),
51            );
52        }
53        for market in halted_markets {
54            state.set_market_halt(
55                market,
56                true,
57                "Startup config: HALTED_MARKETS".to_string(),
58                "config".to_string(),
59            );
60        }
61        state
62    }
63
64    pub fn set_global_halt(&mut self, halted: bool, reason: String, activated_by: String) {
65        let activation = TradingHaltActivation {
66            scope: TradingHaltScope::Global,
67            symbol: None,
68            halted,
69            reason,
70            activated_by,
71            activated_at: Utc::now(),
72        };
73
74        if halted {
75            self.global_halt = Some(activation.clone());
76        } else {
77            self.global_halt = None;
78        }
79
80        self.audit_log.push(activation);
81    }
82
83    pub fn set_market_halt(
84        &mut self,
85        symbol: &str,
86        halted: bool,
87        reason: String,
88        activated_by: String,
89    ) {
90        let normalized_symbol = normalize_symbol(symbol);
91        let activation = TradingHaltActivation {
92            scope: TradingHaltScope::Market,
93            symbol: Some(normalized_symbol.clone()),
94            halted,
95            reason,
96            activated_by,
97            activated_at: Utc::now(),
98        };
99
100        if halted {
101            self.halted_markets
102                .insert(normalized_symbol, activation.clone());
103        } else {
104            self.halted_markets.remove(&normalized_symbol);
105        }
106
107        self.audit_log.push(activation);
108    }
109
110    pub fn blocked_reason(&self, symbol: &str) -> Option<String> {
111        if let Some(global) = &self.global_halt {
112            if global.halted {
113                return Some(format!("Trading is halted globally: {}", global.reason));
114            }
115        }
116
117        let normalized_symbol = normalize_symbol(symbol);
118        if let Some(market_halt) = self.halted_markets.get(&normalized_symbol) {
119            if market_halt.halted {
120                return Some(format!(
121                    "Trading is halted for market {}: {}",
122                    normalized_symbol, market_halt.reason
123                ));
124            }
125        }
126
127        None
128    }
129}
130
131pub fn normalize_symbol(symbol: &str) -> String {
132    symbol.trim().to_ascii_uppercase()
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138
139    #[test]
140    fn test_global_halt_blocks_all_markets() {
141        let mut state = TradingHaltState::new();
142        state.set_global_halt(true, "Emergency".to_string(), "ops".to_string());
143
144        assert!(state.blocked_reason("BTC-20260213-70000-C").is_some());
145        assert!(state.blocked_reason("ETH-PERP").is_some());
146    }
147
148    #[test]
149    fn test_market_halt_blocks_only_target_market() {
150        let mut state = TradingHaltState::new();
151        state.set_market_halt(
152            "btc-20260213-70000-c",
153            true,
154            "Orderbook anomaly".to_string(),
155            "ops".to_string(),
156        );
157
158        let blocked = state.blocked_reason("BTC-20260213-70000-C");
159        assert!(blocked.is_some());
160        assert!(state.blocked_reason("BTC-20260213-80000-C").is_none());
161    }
162
163    #[test]
164    fn test_market_unhalt_removes_block() {
165        let mut state = TradingHaltState::new();
166        state.set_market_halt(
167            "BTC-20260213-70000-C",
168            true,
169            "Emergency".to_string(),
170            "ops".to_string(),
171        );
172        state.set_market_halt(
173            "BTC-20260213-70000-C",
174            false,
175            "Recovered".to_string(),
176            "ops".to_string(),
177        );
178
179        assert!(state.blocked_reason("BTC-20260213-70000-C").is_none());
180    }
181
182    #[test]
183    fn test_audit_log_records_transitions() {
184        let mut state = TradingHaltState::new();
185        state.set_global_halt(true, "Emergency".to_string(), "ops".to_string());
186        state.set_global_halt(false, "Recovered".to_string(), "ops".to_string());
187
188        assert_eq!(state.audit_log.len(), 2);
189        assert!(state.audit_log[0].halted);
190        assert!(!state.audit_log[1].halted);
191    }
192
193    #[test]
194    fn test_from_config_loads_configured_halts() {
195        let state =
196            TradingHaltState::from_config(true, &["BTC-PERP".to_string(), "ETH-PERP".to_string()]);
197
198        assert!(state.global_halt.is_some());
199        assert!(state.blocked_reason("BTC-PERP").is_some());
200        assert!(state.blocked_reason("ETH-PERP").is_some());
201    }
202}