1use serde::{Deserialize, Serialize};
2
3#[derive(Debug, Clone, Serialize, Deserialize)]
5pub struct PlaybookOrderParams {
6 pub symbol: String,
7 pub side: String,
8 pub size: f64,
9 pub price: Option<f64>,
10 pub reduce_only: bool,
11}
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct OrderFill {
16 pub order_id: String,
17 pub fill_price: f64,
18 pub filled: bool,
19}
20
21#[derive(Debug, Clone)]
23pub enum ExecutionError {
24 OrderRejected(String),
25 NetworkError(String),
26 NotFound(String),
27}
28
29impl std::fmt::Display for ExecutionError {
30 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
31 match self {
32 Self::OrderRejected(msg) => write!(f, "Order rejected: {}", msg),
33 Self::NetworkError(msg) => write!(f, "Network error: {}", msg),
34 Self::NotFound(msg) => write!(f, "Not found: {}", msg),
35 }
36 }
37}
38
39#[async_trait::async_trait]
44pub trait OrderExecutor: Send + Sync {
45 async fn place_order(&self, params: &PlaybookOrderParams) -> Result<String, ExecutionError>;
46 async fn cancel_order(&self, order_id: &str) -> Result<(), ExecutionError>;
47 async fn is_filled(&self, order_id: &str) -> Result<bool, ExecutionError>;
48 async fn close_position(
49 &self,
50 position_id: &str,
51 reason: &str,
52 ) -> Result<String, ExecutionError>;
53}
54
55pub struct PaperOrderExecutor {
57 counter: std::sync::atomic::AtomicU64,
58}
59
60impl PaperOrderExecutor {
61 pub fn new() -> Self {
62 Self {
63 counter: std::sync::atomic::AtomicU64::new(1),
64 }
65 }
66
67 fn next_id(&self, prefix: &str) -> String {
68 let n = self
69 .counter
70 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
71 format!("{}-{}", prefix, n)
72 }
73}
74
75impl Default for PaperOrderExecutor {
76 fn default() -> Self {
77 Self::new()
78 }
79}
80
81#[async_trait::async_trait]
82impl OrderExecutor for PaperOrderExecutor {
83 async fn place_order(&self, params: &PlaybookOrderParams) -> Result<String, ExecutionError> {
84 let id = self.next_id("paper-order");
85 tracing::info!(
86 order_id = %id,
87 symbol = %params.symbol,
88 side = %params.side,
89 size = params.size,
90 "Paper order placed (instant fill)"
91 );
92 Ok(id)
93 }
94
95 async fn cancel_order(&self, order_id: &str) -> Result<(), ExecutionError> {
96 tracing::info!(order_id = %order_id, "Paper order cancelled");
97 Ok(())
98 }
99
100 async fn is_filled(&self, _order_id: &str) -> Result<bool, ExecutionError> {
101 Ok(true)
102 }
103
104 async fn close_position(
105 &self,
106 position_id: &str,
107 reason: &str,
108 ) -> Result<String, ExecutionError> {
109 let id = self.next_id("paper-close");
110 tracing::info!(
111 position_id = %position_id,
112 reason = %reason,
113 close_id = %id,
114 "Paper position closed"
115 );
116 Ok(id)
117 }
118}
119
120use hyper_risk::risk::{
125 get_risk_config_sync, record_risk_alert, AccountState, OrderRequest, RiskConfig, RiskGuard,
126};
127
128pub struct RiskCheckedOrderExecutor {
135 inner: Box<dyn OrderExecutor>,
136 risk_guard: RiskGuard,
137 account_state: std::sync::Mutex<AccountState>,
138}
139
140impl RiskCheckedOrderExecutor {
141 pub fn new(inner: Box<dyn OrderExecutor>) -> Self {
143 let config = get_risk_config_sync();
144 Self {
145 inner,
146 risk_guard: RiskGuard::new(config),
147 account_state: std::sync::Mutex::new(AccountState::default()),
148 }
149 }
150
151 pub fn with_config(inner: Box<dyn OrderExecutor>, config: RiskConfig) -> Self {
153 Self {
154 inner,
155 risk_guard: RiskGuard::new(config),
156 account_state: std::sync::Mutex::new(AccountState::default()),
157 }
158 }
159
160 pub fn update_account_state(&self, state: AccountState) {
162 let mut current = self.account_state.lock().unwrap();
163 *current = state;
164 }
165}
166
167#[async_trait::async_trait]
168impl OrderExecutor for RiskCheckedOrderExecutor {
169 async fn place_order(&self, params: &PlaybookOrderParams) -> Result<String, ExecutionError> {
170 let order_req = OrderRequest {
171 symbol: params.symbol.clone(),
172 side: params.side.clone(),
173 size: params.size,
174 price: params.price.unwrap_or(0.0),
175 };
176
177 let account_state = self.account_state.lock().unwrap().clone();
178 if let Err(violation) = self.risk_guard.check_order(&order_req, &account_state) {
179 record_risk_alert(&violation);
180 return Err(ExecutionError::OrderRejected(format!(
181 "Risk violation: {}",
182 violation
183 )));
184 }
185
186 self.inner.place_order(params).await
187 }
188
189 async fn cancel_order(&self, order_id: &str) -> Result<(), ExecutionError> {
190 self.inner.cancel_order(order_id).await
191 }
192
193 async fn is_filled(&self, order_id: &str) -> Result<bool, ExecutionError> {
194 self.inner.is_filled(order_id).await
195 }
196
197 async fn close_position(
198 &self,
199 position_id: &str,
200 reason: &str,
201 ) -> Result<String, ExecutionError> {
202 self.inner.close_position(position_id, reason).await
203 }
204}
205
206#[cfg(test)]
207mod tests {
208 use super::*;
209
210 fn make_params() -> PlaybookOrderParams {
211 PlaybookOrderParams {
212 symbol: "BTC-USDT".to_string(),
213 side: "buy".to_string(),
214 size: 0.1,
215 price: Some(60000.0),
216 reduce_only: false,
217 }
218 }
219
220 #[tokio::test]
221 async fn place_order_returns_unique_ids() {
222 let executor = PaperOrderExecutor::new();
223 let id1 = executor.place_order(&make_params()).await.unwrap();
224 let id2 = executor.place_order(&make_params()).await.unwrap();
225 assert_ne!(id1, id2);
226 assert!(id1.starts_with("paper-order-"));
227 assert!(id2.starts_with("paper-order-"));
228 }
229
230 #[tokio::test]
231 async fn is_filled_always_true() {
232 let executor = PaperOrderExecutor::new();
233 assert!(executor.is_filled("any-id").await.unwrap());
234 }
235
236 #[tokio::test]
237 async fn cancel_order_succeeds() {
238 let executor = PaperOrderExecutor::new();
239 assert!(executor.cancel_order("order-1").await.is_ok());
240 }
241
242 #[tokio::test]
243 async fn close_position_returns_unique_id() {
244 let executor = PaperOrderExecutor::new();
245 let id1 = executor
246 .close_position("pos-1", "take-profit")
247 .await
248 .unwrap();
249 let id2 = executor.close_position("pos-2", "stop-loss").await.unwrap();
250 assert_ne!(id1, id2);
251 assert!(id1.starts_with("paper-close-"));
252 }
253
254 #[test]
255 fn order_params_serde_roundtrip() {
256 let params = make_params();
257 let json = serde_json::to_string(¶ms).unwrap();
258 let back: PlaybookOrderParams = serde_json::from_str(&json).unwrap();
259 assert_eq!(back.symbol, params.symbol);
260 assert_eq!(back.side, params.side);
261 assert_eq!(back.size, params.size);
262 assert_eq!(back.price, params.price);
263 assert_eq!(back.reduce_only, params.reduce_only);
264 }
265
266 #[test]
267 fn execution_error_display() {
268 let e1 = ExecutionError::OrderRejected("insufficient funds".to_string());
269 assert_eq!(e1.to_string(), "Order rejected: insufficient funds");
270
271 let e2 = ExecutionError::NetworkError("timeout".to_string());
272 assert_eq!(e2.to_string(), "Network error: timeout");
273
274 let e3 = ExecutionError::NotFound("order-999".to_string());
275 assert_eq!(e3.to_string(), "Not found: order-999");
276 }
277
278 use hyper_risk::risk::{
281 AnomalyDetection, CircuitBreaker, DailyLossLimits, PositionLimits, RiskConfig,
282 };
283
284 fn permissive_risk_config() -> RiskConfig {
285 RiskConfig {
286 position_limits: PositionLimits {
287 enabled: true,
288 max_total_position: 1_000_000.0,
289 max_per_symbol: 500_000.0,
290 },
291 daily_loss_limits: DailyLossLimits {
292 enabled: false,
293 max_daily_loss: 100_000.0,
294 max_daily_loss_percent: 50.0,
295 },
296 anomaly_detection: AnomalyDetection {
297 enabled: true,
298 max_order_size: 1_000_000.0,
299 max_orders_per_minute: 100,
300 block_duplicate_orders: false,
301 },
302 circuit_breaker: CircuitBreaker {
303 enabled: false,
304 trigger_loss: 100_000.0,
305 trigger_window_minutes: 60,
306 action: "pause_all".to_string(),
307 cooldown_minutes: 30,
308 },
309 }
310 }
311
312 fn restrictive_risk_config() -> RiskConfig {
313 RiskConfig {
314 position_limits: PositionLimits {
315 enabled: true,
316 max_total_position: 100.0,
317 max_per_symbol: 50.0,
318 },
319 daily_loss_limits: DailyLossLimits {
320 enabled: false,
321 max_daily_loss: 100_000.0,
322 max_daily_loss_percent: 50.0,
323 },
324 anomaly_detection: AnomalyDetection {
325 enabled: true,
326 max_order_size: 100.0,
327 max_orders_per_minute: 100,
328 block_duplicate_orders: false,
329 },
330 circuit_breaker: CircuitBreaker {
331 enabled: false,
332 trigger_loss: 100_000.0,
333 trigger_window_minutes: 60,
334 action: "pause_all".to_string(),
335 cooldown_minutes: 30,
336 },
337 }
338 }
339
340 #[tokio::test]
341 async fn risk_checked_executor_passes_small_order() {
342 let inner = Box::new(PaperOrderExecutor::new());
343 let executor = RiskCheckedOrderExecutor::with_config(inner, permissive_risk_config());
344
345 let params = PlaybookOrderParams {
346 symbol: "BTC-PERP".to_string(),
347 side: "buy".to_string(),
348 size: 0.01,
349 price: Some(60000.0),
350 reduce_only: false,
351 };
352
353 let result = executor.place_order(¶ms).await;
354 assert!(result.is_ok(), "Small order should pass risk checks");
355 assert!(result.unwrap().starts_with("paper-order-"));
356 }
357
358 #[tokio::test]
359 async fn risk_checked_executor_blocks_oversized_order() {
360 let inner = Box::new(PaperOrderExecutor::new());
361 let executor = RiskCheckedOrderExecutor::with_config(inner, restrictive_risk_config());
362
363 let params = PlaybookOrderParams {
365 symbol: "BTC-PERP".to_string(),
366 side: "buy".to_string(),
367 size: 1.0,
368 price: Some(60000.0),
369 reduce_only: false,
370 };
371
372 let result = executor.place_order(¶ms).await;
373 assert!(result.is_err(), "Oversized order should be blocked");
374 match result.unwrap_err() {
375 ExecutionError::OrderRejected(msg) => {
376 assert!(
377 msg.contains("Risk violation"),
378 "Error should mention risk: {}",
379 msg
380 );
381 }
382 other => panic!("Expected OrderRejected, got: {:?}", other),
383 }
384 }
385
386 #[tokio::test]
387 async fn risk_checked_executor_blocks_position_limit_exceeded() {
388 let inner = Box::new(PaperOrderExecutor::new());
389 let executor = RiskCheckedOrderExecutor::with_config(inner, restrictive_risk_config());
390
391 executor.update_account_state(AccountState {
393 total_position_value: 90.0,
394 ..AccountState::default()
395 });
396
397 let params = PlaybookOrderParams {
399 symbol: "ETH-PERP".to_string(),
400 side: "buy".to_string(),
401 size: 1.0,
402 price: Some(20.0),
403 reduce_only: false,
404 };
405
406 let result = executor.place_order(¶ms).await;
407 assert!(result.is_err(), "Should block when position limit exceeded");
408 }
409
410 #[tokio::test]
411 async fn risk_checked_executor_cancel_passes_through() {
412 let inner = Box::new(PaperOrderExecutor::new());
413 let executor = RiskCheckedOrderExecutor::with_config(inner, restrictive_risk_config());
414
415 let result = executor.cancel_order("order-123").await;
417 assert!(result.is_ok());
418 }
419
420 #[tokio::test]
421 async fn risk_checked_executor_close_position_passes_through() {
422 let inner = Box::new(PaperOrderExecutor::new());
423 let executor = RiskCheckedOrderExecutor::with_config(inner, restrictive_risk_config());
424
425 let result = executor.close_position("pos-1", "stop-loss").await;
426 assert!(result.is_ok());
427 }
428
429 #[tokio::test]
430 async fn risk_checked_executor_is_filled_passes_through() {
431 let inner = Box::new(PaperOrderExecutor::new());
432 let executor = RiskCheckedOrderExecutor::with_config(inner, permissive_risk_config());
433
434 let result = executor.is_filled("any-order").await;
435 assert!(result.is_ok());
436 assert!(result.unwrap());
437 }
438
439 #[tokio::test]
440 async fn risk_checked_executor_market_order_no_price() {
441 let inner = Box::new(PaperOrderExecutor::new());
442 let executor = RiskCheckedOrderExecutor::with_config(inner, permissive_risk_config());
443
444 let params = PlaybookOrderParams {
446 symbol: "SOL-PERP".to_string(),
447 side: "buy".to_string(),
448 size: 100.0,
449 price: None,
450 reduce_only: false,
451 };
452
453 let result = executor.place_order(¶ms).await;
454 assert!(
455 result.is_ok(),
456 "Market order with no price should pass (notional=0)"
457 );
458 }
459
460 #[tokio::test]
461 async fn risk_checked_executor_all_disabled_passes_everything() {
462 let config = RiskConfig {
463 position_limits: PositionLimits {
464 enabled: false,
465 max_total_position: 1.0,
466 max_per_symbol: 1.0,
467 },
468 daily_loss_limits: DailyLossLimits {
469 enabled: false,
470 max_daily_loss: 1.0,
471 max_daily_loss_percent: 0.01,
472 },
473 anomaly_detection: AnomalyDetection {
474 enabled: false,
475 max_order_size: 1.0,
476 max_orders_per_minute: 1,
477 block_duplicate_orders: true,
478 },
479 circuit_breaker: CircuitBreaker {
480 enabled: false,
481 trigger_loss: 1.0,
482 trigger_window_minutes: 1,
483 action: "pause_all".to_string(),
484 cooldown_minutes: 1,
485 },
486 };
487
488 let inner = Box::new(PaperOrderExecutor::new());
489 let executor = RiskCheckedOrderExecutor::with_config(inner, config);
490
491 let params = PlaybookOrderParams {
493 symbol: "BTC-PERP".to_string(),
494 side: "buy".to_string(),
495 size: 999_999.0,
496 price: Some(999_999.0),
497 reduce_only: false,
498 };
499
500 let result = executor.place_order(¶ms).await;
501 assert!(result.is_ok(), "All checks disabled should pass any order");
502 }
503}