Skip to main content

hyper_agent_core/
executor.rs

1use std::sync::Arc;
2
3use async_trait::async_trait;
4use serde::{Deserialize, Serialize};
5
6use crate::position_manager::{Position, PositionManager};
7
8/// Error type for order execution.
9#[derive(Debug, thiserror::Error)]
10pub enum ExecutorError {
11    #[error("Position error: {0}")]
12    Position(#[from] crate::position_manager::PositionError),
13    #[error("Execution error: {0}")]
14    Execution(String),
15}
16
17/// Parameters for submitting an order.
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct OrderParams {
20    /// Market identifier, e.g. "BTC-PERP".
21    pub market: String,
22    /// Order side: "buy" or "sell".
23    pub side: String,
24    /// Order size in base units.
25    pub size: f64,
26    /// Limit price. `None` means market order.
27    pub price: Option<f64>,
28}
29
30/// Result of a submitted order.
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct OrderResult {
33    pub order_id: String,
34    pub filled_price: f64,
35    pub filled_size: f64,
36    /// Original requested size — compare with `filled_size` to detect partial fills.
37    pub requested_size: f64,
38    /// Order status: "filled", "partial_fill", "resting", "simulated", "ok", "trigger_sl", "trigger_tp".
39    pub status: String,
40}
41
42/// Trait for submitting and cancelling orders.
43///
44/// Implementations include the paper executor (simulated fills persisted
45/// to SQLite) and the dry-run executor (log-only, no state).
46#[async_trait]
47pub trait OrderSubmitter: Send + Sync {
48    async fn place_order(&self, params: &OrderParams) -> Result<OrderResult, ExecutorError>;
49    async fn cancel_order(&self, order_id: &str) -> Result<(), ExecutorError>;
50}
51
52// ---------------------------------------------------------------------------
53// Paper executor
54// ---------------------------------------------------------------------------
55
56/// Simulates order fills and records positions in the SQLite database.
57///
58/// Uses the supplied price (or a default mock mid-price) as the fill price,
59/// creates a position record, and returns a synthetic order result.
60pub struct PaperExecutor {
61    position_manager: Arc<PositionManager>,
62}
63
64impl PaperExecutor {
65    pub fn new(position_manager: Arc<PositionManager>) -> Self {
66        Self { position_manager }
67    }
68}
69
70#[async_trait]
71impl OrderSubmitter for PaperExecutor {
72    async fn place_order(&self, params: &OrderParams) -> Result<OrderResult, ExecutorError> {
73        let fill_price = params.price.unwrap_or(0.0);
74        let order_id = uuid::Uuid::new_v4().to_string();
75
76        let side = match params.side.as_str() {
77            "buy" => "long",
78            "sell" => "short",
79            other => other,
80        };
81
82        let pos = Position {
83            id: order_id.clone(),
84            market: params.market.clone(),
85            side: side.to_string(),
86            size: params.size,
87            entry_price: fill_price,
88            current_price: Some(fill_price),
89            status: "open".to_string(),
90            pnl: Some(0.0),
91            mode: "paper".to_string(),
92            strategy: None,
93            opened_at: chrono::Utc::now().to_rfc3339(),
94            closed_at: None,
95            close_reason: None,
96        };
97
98        self.position_manager.open_position(&pos).await?;
99
100        Ok(OrderResult {
101            order_id,
102            filled_price: fill_price,
103            filled_size: params.size,
104            requested_size: params.size,
105            status: "filled".to_string(),
106        })
107    }
108
109    async fn cancel_order(&self, _order_id: &str) -> Result<(), ExecutorError> {
110        // Paper executor: cancel is a no-op since fills are instant.
111        Ok(())
112    }
113}
114
115// ---------------------------------------------------------------------------
116// Dry-run executor
117// ---------------------------------------------------------------------------
118
119/// Logs order actions without persisting any state.
120///
121/// Useful for testing agent decision logic without side effects.
122pub struct DryRunExecutor;
123
124#[async_trait]
125impl OrderSubmitter for DryRunExecutor {
126    async fn place_order(&self, params: &OrderParams) -> Result<OrderResult, ExecutorError> {
127        let order_id = uuid::Uuid::new_v4().to_string();
128        let fill_price = params.price.unwrap_or(0.0);
129
130        tracing::info!(
131            order_id = %order_id,
132            market = %params.market,
133            side = %params.side,
134            size = %params.size,
135            price = %fill_price,
136            "[dry-run] simulated order"
137        );
138
139        Ok(OrderResult {
140            order_id,
141            filled_price: fill_price,
142            filled_size: params.size,
143            requested_size: params.size,
144            status: "simulated".to_string(),
145        })
146    }
147
148    async fn cancel_order(&self, order_id: &str) -> Result<(), ExecutorError> {
149        tracing::info!(order_id = %order_id, "[dry-run] simulated cancel");
150        Ok(())
151    }
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157
158    fn make_paper_executor() -> (Arc<PositionManager>, PaperExecutor) {
159        let pm = Arc::new(PositionManager::in_memory().unwrap());
160        let exec = PaperExecutor::new(Arc::clone(&pm));
161        (pm, exec)
162    }
163
164    #[tokio::test]
165    async fn test_paper_executor_place_buy() {
166        let (pm, exec) = make_paper_executor();
167
168        let result = exec
169            .place_order(&OrderParams {
170                market: "BTC-PERP".to_string(),
171                side: "buy".to_string(),
172                size: 0.5,
173                price: Some(60000.0),
174            })
175            .await
176            .unwrap();
177
178        assert_eq!(result.filled_price, 60000.0);
179        assert_eq!(result.filled_size, 0.5);
180        assert_eq!(result.requested_size, 0.5);
181        assert_eq!(result.status, "filled");
182
183        // Position should exist in DB
184        let pos = pm.get_position(&result.order_id).await.unwrap().unwrap();
185        assert_eq!(pos.market, "BTC-PERP");
186        assert_eq!(pos.side, "long");
187        assert_eq!(pos.size, 0.5);
188        assert_eq!(pos.entry_price, 60000.0);
189        assert_eq!(pos.mode, "paper");
190        assert_eq!(pos.status, "open");
191    }
192
193    #[tokio::test]
194    async fn test_paper_executor_place_sell() {
195        let (pm, exec) = make_paper_executor();
196
197        let result = exec
198            .place_order(&OrderParams {
199                market: "ETH-PERP".to_string(),
200                side: "sell".to_string(),
201                size: 10.0,
202                price: Some(3000.0),
203            })
204            .await
205            .unwrap();
206
207        let pos = pm.get_position(&result.order_id).await.unwrap().unwrap();
208        assert_eq!(pos.side, "short");
209        assert_eq!(pos.entry_price, 3000.0);
210    }
211
212    #[tokio::test]
213    async fn test_paper_executor_market_order_zero_price() {
214        let (_pm, exec) = make_paper_executor();
215
216        let result = exec
217            .place_order(&OrderParams {
218                market: "SOL-PERP".to_string(),
219                side: "buy".to_string(),
220                size: 100.0,
221                price: None,
222            })
223            .await
224            .unwrap();
225
226        assert_eq!(result.filled_price, 0.0);
227    }
228
229    #[tokio::test]
230    async fn test_paper_executor_cancel_is_noop() {
231        let (_pm, exec) = make_paper_executor();
232        exec.cancel_order("any-id").await.unwrap();
233    }
234
235    #[tokio::test]
236    async fn test_paper_executor_positions_accumulate() {
237        let (pm, exec) = make_paper_executor();
238
239        for _ in 0..3 {
240            exec.place_order(&OrderParams {
241                market: "BTC-PERP".to_string(),
242                side: "buy".to_string(),
243                size: 1.0,
244                price: Some(60000.0),
245            })
246            .await
247            .unwrap();
248        }
249
250        assert_eq!(pm.list_open().await.unwrap().len(), 3);
251    }
252
253    #[tokio::test]
254    async fn test_dry_run_executor_place_order() {
255        let exec = DryRunExecutor;
256
257        let result = exec
258            .place_order(&OrderParams {
259                market: "BTC-PERP".to_string(),
260                side: "buy".to_string(),
261                size: 1.0,
262                price: Some(60000.0),
263            })
264            .await
265            .unwrap();
266
267        assert_eq!(result.filled_price, 60000.0);
268        assert_eq!(result.filled_size, 1.0);
269        assert_eq!(result.requested_size, 1.0);
270        assert_eq!(result.status, "simulated");
271        assert!(!result.order_id.is_empty());
272    }
273
274    #[tokio::test]
275    async fn test_dry_run_executor_cancel() {
276        let exec = DryRunExecutor;
277        exec.cancel_order("anything").await.unwrap();
278    }
279
280    #[tokio::test]
281    async fn test_dry_run_executor_market_order() {
282        let exec = DryRunExecutor;
283
284        let result = exec
285            .place_order(&OrderParams {
286                market: "ETH-PERP".to_string(),
287                side: "sell".to_string(),
288                size: 5.0,
289                price: None,
290            })
291            .await
292            .unwrap();
293
294        assert_eq!(result.filled_price, 0.0);
295        assert_eq!(result.status, "simulated");
296    }
297
298    #[test]
299    fn order_result_requested_size_tracks_original() {
300        let result = OrderResult {
301            order_id: "test".into(),
302            filled_price: 65000.0,
303            filled_size: 0.005,
304            requested_size: 0.01,
305            status: "partial_fill".into(),
306        };
307        assert_eq!(result.requested_size, 0.01);
308        assert_eq!(result.filled_size, 0.005);
309        assert_eq!(result.status, "partial_fill");
310        // Fill percentage
311        let pct = (result.filled_size / result.requested_size) * 100.0;
312        assert!((pct - 50.0).abs() < 0.01);
313    }
314
315    #[tokio::test]
316    async fn paper_executor_always_full_fill() {
317        let (_pm, exec) = make_paper_executor();
318        let result = exec
319            .place_order(&OrderParams {
320                market: "BTC-PERP".to_string(),
321                side: "buy".to_string(),
322                size: 0.01,
323                price: Some(65000.0),
324            })
325            .await
326            .unwrap();
327        // Paper executor always fills the full requested size
328        assert_eq!(result.filled_size, result.requested_size);
329        assert_eq!(result.status, "filled");
330    }
331}