Skip to main content

hyper_playbook/
executor.rs

1use serde::{Deserialize, Serialize};
2
3/// Order params for playbook-driven trades.
4#[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/// Result of a filled order.
14#[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/// Errors that can occur during order execution.
22#[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/// Trait abstracting order execution for playbooks.
40///
41/// Implementations include paper trading (instant fill) and live exchange
42/// execution.
43#[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
55/// Paper executor: instant fill at given price, no real exchange interaction.
56pub 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
120// ---------------------------------------------------------------------------
121// Risk-checked executor (decorator)
122// ---------------------------------------------------------------------------
123
124use hyper_risk::risk::{
125    get_risk_config_sync, record_risk_alert, AccountState, OrderRequest, RiskConfig, RiskGuard,
126};
127
128/// An [`OrderExecutor`] decorator that enforces pre-trade risk checks before
129/// delegating to an inner executor.
130///
131/// If the risk check fails the order is rejected with [`ExecutionError::OrderRejected`]
132/// and the violation is recorded to the alert history. Cancel, fill-check, and
133/// close operations are passed through without additional checks.
134pub struct RiskCheckedOrderExecutor {
135    inner: Box<dyn OrderExecutor>,
136    risk_guard: RiskGuard,
137    account_state: std::sync::Mutex<AccountState>,
138}
139
140impl RiskCheckedOrderExecutor {
141    /// Wrap an existing executor with risk enforcement using the on-disk config.
142    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    /// Wrap an existing executor with a specific risk config.
152    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    /// Update the account state snapshot used for risk evaluation.
161    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(&params).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    // -- RiskCheckedOrderExecutor tests --
279
280    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(&params).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        // $60,000 notional > $100 max_order_size
364        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(&params).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        // Set account state with existing position near limit
392        executor.update_account_state(AccountState {
393            total_position_value: 90.0,
394            ..AccountState::default()
395        });
396
397        // $20 notional would push total to $110 > $100 limit
398        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(&params).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        // Cancel should always pass through regardless of risk config
416        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        // Market order with no price (price defaults to 0.0, so notional = 0)
445        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(&params).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        // Huge order should pass when all checks are disabled
492        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(&params).await;
501        assert!(result.is_ok(), "All checks disabled should pass any order");
502    }
503}