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}