Skip to main content

hyper_agent_core/
position_manager.rs

1use tokio::sync::Mutex;
2
3use rusqlite::{params, Connection, Result as SqlResult};
4use serde::{Deserialize, Serialize};
5
6/// A trading position tracked in the local SQLite database.
7#[derive(Debug, Clone, Serialize, Deserialize)]
8pub struct Position {
9    pub id: String,
10    pub market: String,
11    pub side: String,
12    pub size: f64,
13    pub entry_price: f64,
14    pub current_price: Option<f64>,
15    pub status: String,
16    pub pnl: Option<f64>,
17    pub mode: String,
18    pub strategy: Option<String>,
19    pub opened_at: String,
20    pub closed_at: Option<String>,
21    pub close_reason: Option<String>,
22}
23
24/// A single execution quality data point recorded after an order is submitted.
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct ExecutionMetric {
27    pub order_id: String,
28    pub market: String,
29    pub side: String,
30    pub requested_size: f64,
31    pub filled_size: f64,
32    pub status: String,
33    pub mode: String,
34}
35
36/// Aggregated execution quality summary.
37#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct ExecutionSummary {
39    pub total_orders: u64,
40    pub full_fills: u64,
41    pub partial_fills: u64,
42    pub zero_fills: u64,
43    pub avg_fill_rate_pct: f64,
44}
45
46/// Aggregated profit-and-loss summary over a time window.
47#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct PnlSummary {
49    pub total_pnl: f64,
50    pub win_count: u32,
51    pub loss_count: u32,
52    pub win_rate: f64,
53    pub best_trade: Option<(String, f64)>,
54    pub worst_trade: Option<(String, f64)>,
55}
56
57/// Error type for position manager operations.
58#[derive(Debug, thiserror::Error)]
59pub enum PositionError {
60    #[error("Database error: {0}")]
61    Db(#[from] rusqlite::Error),
62    #[error("Position not found: {0}")]
63    NotFound(String),
64}
65
66/// Manages position state in a local SQLite database.
67///
68/// Provides CRUD operations for positions, P&L tracking, and
69/// query helpers for open/closed position lists.
70///
71/// The inner `Connection` is wrapped in a `tokio::sync::Mutex` so that
72/// `PositionManager` is `Send + Sync` and can be shared via `Arc`
73/// without risk of deadlocks when the lock is held across `.await` points.
74pub struct PositionManager {
75    db: Mutex<Connection>,
76}
77
78impl PositionManager {
79    /// Open (or create) the SQLite database at `db_path` and ensure the
80    /// positions table exists.
81    pub fn new(db_path: &str) -> Result<Self, PositionError> {
82        let db = Connection::open(db_path)?;
83        db.execute_batch("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;")?;
84        db.execute(
85            "CREATE TABLE IF NOT EXISTS positions (
86                id            TEXT PRIMARY KEY,
87                market        TEXT NOT NULL,
88                side          TEXT NOT NULL CHECK(side IN ('long','short')),
89                size          REAL NOT NULL,
90                entry_price   REAL NOT NULL,
91                current_price REAL,
92                status        TEXT NOT NULL DEFAULT 'open' CHECK(status IN ('open','closed')),
93                pnl           REAL,
94                mode          TEXT NOT NULL CHECK(mode IN ('live','paper')),
95                strategy      TEXT,
96                opened_at     TEXT NOT NULL,
97                closed_at     TEXT,
98                close_reason  TEXT
99            )",
100            [],
101        )?;
102        db.execute(
103            "CREATE TABLE IF NOT EXISTS execution_metrics (
104                id             INTEGER PRIMARY KEY AUTOINCREMENT,
105                timestamp      TEXT NOT NULL,
106                order_id       TEXT NOT NULL,
107                market         TEXT NOT NULL,
108                side           TEXT NOT NULL,
109                requested_size REAL NOT NULL,
110                filled_size    REAL NOT NULL,
111                fill_rate      REAL NOT NULL,
112                status         TEXT NOT NULL,
113                mode           TEXT NOT NULL
114            )",
115            [],
116        )?;
117        Ok(Self { db: Mutex::new(db) })
118    }
119
120    /// Create an in-memory database (useful for tests).
121    #[cfg(test)]
122    pub fn in_memory() -> Result<Self, PositionError> {
123        Self::new(":memory:")
124    }
125
126    /// Expose the database connection for test-only direct SQL manipulation.
127    #[cfg(test)]
128    pub async fn lock_db_for_test(&self) -> tokio::sync::MutexGuard<'_, Connection> {
129        self.db.lock().await
130    }
131
132    /// Insert a new position into the database.
133    pub async fn open_position(&self, pos: &Position) -> Result<(), PositionError> {
134        let db = self.db.lock().await;
135        db.execute(
136            "INSERT INTO positions
137                (id, market, side, size, entry_price, current_price,
138                 status, pnl, mode, strategy, opened_at, closed_at, close_reason)
139             VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13)",
140            params![
141                pos.id,
142                pos.market,
143                pos.side,
144                pos.size,
145                pos.entry_price,
146                pos.current_price,
147                pos.status,
148                pos.pnl,
149                pos.mode,
150                pos.strategy,
151                pos.opened_at,
152                pos.closed_at,
153                pos.close_reason,
154            ],
155        )?;
156        Ok(())
157    }
158
159    /// Close a position: set status to "closed", record exit price,
160    /// compute realised P&L, and store the close reason.
161    pub async fn close_position(
162        &self,
163        id: &str,
164        price: f64,
165        reason: &str,
166    ) -> Result<(), PositionError> {
167        let db = self.db.lock().await;
168        let pos = Self::get_position_inner(&db, id)?
169            .ok_or_else(|| PositionError::NotFound(id.to_string()))?;
170
171        let pnl = compute_pnl(&pos.side, pos.entry_price, price, pos.size);
172        let now = chrono::Utc::now().to_rfc3339();
173
174        let changed = db.execute(
175            "UPDATE positions
176                SET status = 'closed',
177                    current_price = ?1,
178                    pnl = ?2,
179                    closed_at = ?3,
180                    close_reason = ?4
181              WHERE id = ?5 AND status = 'open'",
182            params![price, pnl, now, reason, id],
183        )?;
184
185        if changed == 0 {
186            return Err(PositionError::NotFound(id.to_string()));
187        }
188        Ok(())
189    }
190
191    /// Update the mark price of an open position and recompute unrealised P&L.
192    pub async fn update_price(&self, id: &str, price: f64) -> Result<(), PositionError> {
193        let db = self.db.lock().await;
194        let pos = Self::get_position_inner(&db, id)?
195            .ok_or_else(|| PositionError::NotFound(id.to_string()))?;
196
197        let pnl = compute_pnl(&pos.side, pos.entry_price, price, pos.size);
198
199        let changed = db.execute(
200            "UPDATE positions SET current_price = ?1, pnl = ?2 WHERE id = ?3 AND status = 'open'",
201            params![price, pnl, id],
202        )?;
203
204        if changed == 0 {
205            return Err(PositionError::NotFound(id.to_string()));
206        }
207        Ok(())
208    }
209
210    /// Return all positions with status = "open".
211    pub async fn list_open(&self) -> Result<Vec<Position>, PositionError> {
212        let db = self.db.lock().await;
213        let mut stmt =
214            db.prepare("SELECT * FROM positions WHERE status = 'open' ORDER BY opened_at DESC")?;
215        let rows = stmt.query_map([], row_to_position)?;
216        rows.collect::<SqlResult<Vec<_>>>()
217            .map_err(PositionError::from)
218    }
219
220    /// Return the most recent `limit` closed positions.
221    pub async fn list_closed(&self, limit: usize) -> Result<Vec<Position>, PositionError> {
222        let db = self.db.lock().await;
223        let mut stmt = db.prepare(
224            "SELECT * FROM positions WHERE status = 'closed' ORDER BY closed_at DESC LIMIT ?1",
225        )?;
226        let rows = stmt.query_map(params![limit as i64], row_to_position)?;
227        rows.collect::<SqlResult<Vec<_>>>()
228            .map_err(PositionError::from)
229    }
230
231    /// Compute an aggregate P&L summary for closed positions in the last
232    /// `days` calendar days.
233    pub async fn get_pnl_summary(&self, days: u32) -> Result<PnlSummary, PositionError> {
234        let cutoff = chrono::Utc::now() - chrono::Duration::days(i64::from(days));
235        let cutoff_str = cutoff.to_rfc3339();
236
237        let db = self.db.lock().await;
238        let mut stmt = db.prepare(
239            "SELECT id, pnl FROM positions
240              WHERE status = 'closed' AND closed_at >= ?1
241              ORDER BY pnl DESC",
242        )?;
243
244        let trades: Vec<(String, f64)> = stmt
245            .query_map(params![cutoff_str], |row| {
246                Ok((row.get::<_, String>(0)?, row.get::<_, f64>(1)?))
247            })?
248            .collect::<SqlResult<Vec<_>>>()?;
249
250        let mut total_pnl = 0.0;
251        let mut win_count: u32 = 0;
252        let mut loss_count: u32 = 0;
253        let mut best: Option<(String, f64)> = None;
254        let mut worst: Option<(String, f64)> = None;
255
256        for (id, pnl) in &trades {
257            total_pnl += pnl;
258            if *pnl >= 0.0 {
259                win_count += 1;
260            } else {
261                loss_count += 1;
262            }
263            if best.as_ref().map_or(true, |(_, b)| pnl > b) {
264                best = Some((id.clone(), *pnl));
265            }
266            if worst.as_ref().map_or(true, |(_, w)| pnl < w) {
267                worst = Some((id.clone(), *pnl));
268            }
269        }
270
271        let total = win_count + loss_count;
272        let win_rate = if total > 0 {
273            f64::from(win_count) / f64::from(total)
274        } else {
275            0.0
276        };
277
278        Ok(PnlSummary {
279            total_pnl,
280            win_count,
281            loss_count,
282            win_rate,
283            best_trade: best,
284            worst_trade: worst,
285        })
286    }
287
288    /// Record an execution quality metric after an order is submitted.
289    pub async fn record_execution(&self, metric: ExecutionMetric) -> Result<(), PositionError> {
290        let db = self.db.lock().await;
291        let fill_rate = if metric.requested_size > 0.0 {
292            (metric.filled_size / metric.requested_size) * 100.0
293        } else {
294            0.0
295        };
296        let now = chrono::Utc::now().to_rfc3339();
297        db.execute(
298            "INSERT INTO execution_metrics
299                (timestamp, order_id, market, side, requested_size, filled_size,
300                 fill_rate, status, mode)
301             VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9)",
302            params![
303                now,
304                metric.order_id,
305                metric.market,
306                metric.side,
307                metric.requested_size,
308                metric.filled_size,
309                fill_rate,
310                metric.status,
311                metric.mode,
312            ],
313        )?;
314        Ok(())
315    }
316
317    /// Return an aggregated execution quality summary across all recorded metrics.
318    pub async fn get_execution_summary(&self) -> Result<ExecutionSummary, PositionError> {
319        let db = self.db.lock().await;
320        let mut stmt = db.prepare("SELECT fill_rate, status FROM execution_metrics")?;
321        let rows: Vec<(f64, String)> = stmt
322            .query_map([], |row| {
323                Ok((row.get::<_, f64>(0)?, row.get::<_, String>(1)?))
324            })?
325            .collect::<SqlResult<Vec<_>>>()?;
326
327        let total_orders = rows.len() as u64;
328        let mut full_fills: u64 = 0;
329        let mut partial_fills: u64 = 0;
330        let mut zero_fills: u64 = 0;
331        let mut fill_rate_sum: f64 = 0.0;
332
333        for (fill_rate, status) in &rows {
334            fill_rate_sum += fill_rate;
335            match status.as_str() {
336                "partial_fill" => partial_fills += 1,
337                "resting" => zero_fills += 1,
338                _ => {
339                    // "filled", "simulated", "ok", etc. are full fills
340                    if *fill_rate >= 100.0 - f64::EPSILON {
341                        full_fills += 1;
342                    } else if *fill_rate > 0.0 {
343                        partial_fills += 1;
344                    } else {
345                        zero_fills += 1;
346                    }
347                }
348            }
349        }
350
351        let avg_fill_rate_pct = if total_orders > 0 {
352            fill_rate_sum / total_orders as f64
353        } else {
354            0.0
355        };
356
357        Ok(ExecutionSummary {
358            total_orders,
359            full_fills,
360            partial_fills,
361            zero_fills,
362            avg_fill_rate_pct,
363        })
364    }
365
366    /// Fetch a single position by ID, or `None` if it does not exist.
367    pub async fn get_position(&self, id: &str) -> Result<Option<Position>, PositionError> {
368        let db = self.db.lock().await;
369        Self::get_position_inner(&db, id)
370    }
371
372    /// Inner helper that operates on a borrowed `Connection` (no locking).
373    fn get_position_inner(db: &Connection, id: &str) -> Result<Option<Position>, PositionError> {
374        let mut stmt = db.prepare("SELECT * FROM positions WHERE id = ?1")?;
375        let mut rows = stmt.query_map(params![id], row_to_position)?;
376        match rows.next() {
377            Some(row) => Ok(Some(row?)),
378            None => Ok(None),
379        }
380    }
381}
382
383/// Compute P&L given direction, entry, exit, and size.
384fn compute_pnl(side: &str, entry: f64, exit: f64, size: f64) -> f64 {
385    match side {
386        "long" => (exit - entry) * size,
387        "short" => (entry - exit) * size,
388        _ => 0.0,
389    }
390}
391
392/// Map a rusqlite row to a `Position`.
393fn row_to_position(row: &rusqlite::Row) -> SqlResult<Position> {
394    Ok(Position {
395        id: row.get("id")?,
396        market: row.get("market")?,
397        side: row.get("side")?,
398        size: row.get("size")?,
399        entry_price: row.get("entry_price")?,
400        current_price: row.get("current_price")?,
401        status: row.get("status")?,
402        pnl: row.get("pnl")?,
403        mode: row.get("mode")?,
404        strategy: row.get("strategy")?,
405        opened_at: row.get("opened_at")?,
406        closed_at: row.get("closed_at")?,
407        close_reason: row.get("close_reason")?,
408    })
409}
410
411#[cfg(test)]
412mod tests {
413    use super::*;
414
415    fn make_position(id: &str, market: &str, side: &str, size: f64, entry: f64) -> Position {
416        Position {
417            id: id.to_string(),
418            market: market.to_string(),
419            side: side.to_string(),
420            size,
421            entry_price: entry,
422            current_price: None,
423            status: "open".to_string(),
424            pnl: None,
425            mode: "paper".to_string(),
426            strategy: Some("test-strat".to_string()),
427            opened_at: chrono::Utc::now().to_rfc3339(),
428            closed_at: None,
429            close_reason: None,
430        }
431    }
432
433    #[tokio::test]
434    async fn test_open_and_get_position() {
435        let pm = PositionManager::in_memory().unwrap();
436        let pos = make_position("pos-1", "BTC-PERP", "long", 0.5, 60000.0);
437        pm.open_position(&pos).await.unwrap();
438
439        let fetched = pm.get_position("pos-1").await.unwrap().unwrap();
440        assert_eq!(fetched.market, "BTC-PERP");
441        assert_eq!(fetched.side, "long");
442        assert_eq!(fetched.size, 0.5);
443        assert_eq!(fetched.entry_price, 60000.0);
444        assert_eq!(fetched.status, "open");
445    }
446
447    #[tokio::test]
448    async fn test_get_nonexistent_returns_none() {
449        let pm = PositionManager::in_memory().unwrap();
450        assert!(pm.get_position("nonexistent").await.unwrap().is_none());
451    }
452
453    #[tokio::test]
454    async fn test_close_position_long_profit() {
455        let pm = PositionManager::in_memory().unwrap();
456        let pos = make_position("pos-long", "ETH-PERP", "long", 2.0, 3000.0);
457        pm.open_position(&pos).await.unwrap();
458
459        pm.close_position("pos-long", 3500.0, "take-profit")
460            .await
461            .unwrap();
462
463        let closed = pm.get_position("pos-long").await.unwrap().unwrap();
464        assert_eq!(closed.status, "closed");
465        assert!((closed.pnl.unwrap() - 1000.0).abs() < 1e-6);
466        assert_eq!(closed.close_reason.as_deref(), Some("take-profit"));
467        assert!(closed.closed_at.is_some());
468    }
469
470    #[tokio::test]
471    async fn test_close_position_short_profit() {
472        let pm = PositionManager::in_memory().unwrap();
473        let pos = make_position("pos-short", "SOL-PERP", "short", 10.0, 100.0);
474        pm.open_position(&pos).await.unwrap();
475
476        pm.close_position("pos-short", 90.0, "take-profit")
477            .await
478            .unwrap();
479
480        let closed = pm.get_position("pos-short").await.unwrap().unwrap();
481        assert!((closed.pnl.unwrap() - 100.0).abs() < 1e-6);
482    }
483
484    #[tokio::test]
485    async fn test_close_position_long_loss() {
486        let pm = PositionManager::in_memory().unwrap();
487        let pos = make_position("pos-loss", "BTC-PERP", "long", 1.0, 60000.0);
488        pm.open_position(&pos).await.unwrap();
489
490        pm.close_position("pos-loss", 59000.0, "stop-loss")
491            .await
492            .unwrap();
493
494        let closed = pm.get_position("pos-loss").await.unwrap().unwrap();
495        assert!((closed.pnl.unwrap() - (-1000.0)).abs() < 1e-6);
496    }
497
498    #[tokio::test]
499    async fn test_close_nonexistent_position() {
500        let pm = PositionManager::in_memory().unwrap();
501        let err = pm
502            .close_position("ghost", 100.0, "reason")
503            .await
504            .unwrap_err();
505        assert!(matches!(err, PositionError::NotFound(_)));
506    }
507
508    #[tokio::test]
509    async fn test_update_price() {
510        let pm = PositionManager::in_memory().unwrap();
511        let pos = make_position("pos-upd", "BTC-PERP", "long", 1.0, 60000.0);
512        pm.open_position(&pos).await.unwrap();
513
514        pm.update_price("pos-upd", 61000.0).await.unwrap();
515
516        let fetched = pm.get_position("pos-upd").await.unwrap().unwrap();
517        assert_eq!(fetched.current_price, Some(61000.0));
518        assert!((fetched.pnl.unwrap() - 1000.0).abs() < 1e-6);
519    }
520
521    #[tokio::test]
522    async fn test_list_open_and_closed() {
523        let pm = PositionManager::in_memory().unwrap();
524        pm.open_position(&make_position("a", "BTC-PERP", "long", 1.0, 60000.0))
525            .await
526            .unwrap();
527        pm.open_position(&make_position("b", "ETH-PERP", "short", 5.0, 3000.0))
528            .await
529            .unwrap();
530        pm.open_position(&make_position("c", "SOL-PERP", "long", 10.0, 100.0))
531            .await
532            .unwrap();
533
534        assert_eq!(pm.list_open().await.unwrap().len(), 3);
535        assert_eq!(pm.list_closed(10).await.unwrap().len(), 0);
536
537        pm.close_position("a", 61000.0, "tp").await.unwrap();
538
539        assert_eq!(pm.list_open().await.unwrap().len(), 2);
540        assert_eq!(pm.list_closed(10).await.unwrap().len(), 1);
541    }
542
543    #[tokio::test]
544    async fn test_pnl_summary() {
545        let pm = PositionManager::in_memory().unwrap();
546
547        // 3 trades: 2 wins, 1 loss
548        pm.open_position(&make_position("w1", "BTC-PERP", "long", 1.0, 60000.0))
549            .await
550            .unwrap();
551        pm.open_position(&make_position("w2", "ETH-PERP", "short", 10.0, 3000.0))
552            .await
553            .unwrap();
554        pm.open_position(&make_position("l1", "SOL-PERP", "long", 100.0, 100.0))
555            .await
556            .unwrap();
557
558        pm.close_position("w1", 62000.0, "tp").await.unwrap(); // +2000
559        pm.close_position("w2", 2800.0, "tp").await.unwrap(); // +2000
560        pm.close_position("l1", 95.0, "sl").await.unwrap(); // -500
561
562        let summary = pm.get_pnl_summary(30).await.unwrap();
563        assert!((summary.total_pnl - 3500.0).abs() < 1e-6);
564        assert_eq!(summary.win_count, 2);
565        assert_eq!(summary.loss_count, 1);
566        assert!((summary.win_rate - 2.0 / 3.0).abs() < 1e-6);
567
568        let (best_id, best_pnl) = summary.best_trade.unwrap();
569        assert!((best_pnl - 2000.0).abs() < 1e-6);
570        // best could be w1 or w2 (both +2000), just check it's one of them
571        assert!(best_id == "w1" || best_id == "w2");
572
573        let (_, worst_pnl) = summary.worst_trade.unwrap();
574        assert!((worst_pnl - (-500.0)).abs() < 1e-6);
575    }
576
577    #[tokio::test]
578    async fn test_pnl_summary_empty() {
579        let pm = PositionManager::in_memory().unwrap();
580        let summary = pm.get_pnl_summary(30).await.unwrap();
581        assert_eq!(summary.total_pnl, 0.0);
582        assert_eq!(summary.win_count, 0);
583        assert_eq!(summary.loss_count, 0);
584        assert_eq!(summary.win_rate, 0.0);
585        assert!(summary.best_trade.is_none());
586        assert!(summary.worst_trade.is_none());
587    }
588
589    #[tokio::test]
590    async fn test_duplicate_id_fails() {
591        let pm = PositionManager::in_memory().unwrap();
592        let pos = make_position("dup", "BTC-PERP", "long", 1.0, 60000.0);
593        pm.open_position(&pos).await.unwrap();
594        assert!(pm.open_position(&pos).await.is_err());
595    }
596
597    #[tokio::test]
598    async fn test_invalid_side_rejected() {
599        let pm = PositionManager::in_memory().unwrap();
600        let mut pos = make_position("bad", "BTC-PERP", "long", 1.0, 60000.0);
601        pos.side = "sideways".to_string();
602        assert!(pm.open_position(&pos).await.is_err());
603    }
604
605    // -- Execution metrics tests --
606
607    fn make_metric(order_id: &str, status: &str, requested: f64, filled: f64) -> ExecutionMetric {
608        ExecutionMetric {
609            order_id: order_id.to_string(),
610            market: "BTC-PERP".to_string(),
611            side: "buy".to_string(),
612            requested_size: requested,
613            filled_size: filled,
614            status: status.to_string(),
615            mode: "paper".to_string(),
616        }
617    }
618
619    #[tokio::test]
620    async fn test_execution_summary_empty() {
621        let pm = PositionManager::in_memory().unwrap();
622        let summary = pm.get_execution_summary().await.unwrap();
623        assert_eq!(summary.total_orders, 0);
624        assert_eq!(summary.full_fills, 0);
625        assert_eq!(summary.partial_fills, 0);
626        assert_eq!(summary.zero_fills, 0);
627        assert_eq!(summary.avg_fill_rate_pct, 0.0);
628    }
629
630    #[tokio::test]
631    async fn test_record_and_summarize_executions() {
632        let pm = PositionManager::in_memory().unwrap();
633
634        // Full fill
635        pm.record_execution(make_metric("o1", "filled", 1.0, 1.0))
636            .await
637            .unwrap();
638        // Partial fill
639        pm.record_execution(make_metric("o2", "partial_fill", 1.0, 0.5))
640            .await
641            .unwrap();
642        // Zero fill (resting)
643        pm.record_execution(make_metric("o3", "resting", 1.0, 0.0))
644            .await
645            .unwrap();
646
647        let summary = pm.get_execution_summary().await.unwrap();
648        assert_eq!(summary.total_orders, 3);
649        assert_eq!(summary.full_fills, 1);
650        assert_eq!(summary.partial_fills, 1);
651        assert_eq!(summary.zero_fills, 1);
652        // avg: (100 + 50 + 0) / 3 = 50.0
653        assert!((summary.avg_fill_rate_pct - 50.0).abs() < 1e-6);
654    }
655
656    #[tokio::test]
657    async fn test_all_full_fills() {
658        let pm = PositionManager::in_memory().unwrap();
659        pm.record_execution(make_metric("a", "filled", 2.0, 2.0))
660            .await
661            .unwrap();
662        pm.record_execution(make_metric("b", "ok", 0.5, 0.5))
663            .await
664            .unwrap();
665
666        let summary = pm.get_execution_summary().await.unwrap();
667        assert_eq!(summary.total_orders, 2);
668        assert_eq!(summary.full_fills, 2);
669        assert_eq!(summary.partial_fills, 0);
670        assert_eq!(summary.zero_fills, 0);
671        assert!((summary.avg_fill_rate_pct - 100.0).abs() < 1e-6);
672    }
673}