Skip to main content

bot_engine/
order_manager.rs

1//! Order manager: tracks order state and transitions.
2
3use bot_core::{
4    now_ms, ClientOrderId, ExchangeOrderId, InstrumentId, LiveOrder, OrderSide, OrderStatus, Price,
5    Qty, TradeId,
6};
7use std::collections::{HashMap, HashSet};
8
9/// Manages order state and fill deduplication.
10pub struct OrderManager {
11    /// All tracked orders by client_id
12    orders: HashMap<ClientOrderId, LiveOrder>,
13
14    /// Map exchange_order_id -> client_id (for fills that don't include cloid)
15    exchange_id_map: HashMap<ExchangeOrderId, ClientOrderId>,
16
17    /// Seen trade IDs for deduplication (bounded LRU-style)
18    seen_trades: HashSet<String>,
19    seen_trades_order: Vec<String>,
20    max_seen_trades: usize,
21}
22
23impl OrderManager {
24    /// Create an empty order manager.
25    pub fn new() -> Self {
26        Self {
27            orders: HashMap::new(),
28            exchange_id_map: HashMap::new(),
29            seen_trades: HashSet::new(),
30            seen_trades_order: Vec::new(),
31            max_seen_trades: 10000,
32        }
33    }
34
35    /// Get an order by client_id
36    pub fn get(&self, client_id: &ClientOrderId) -> Option<&LiveOrder> {
37        self.orders.get(client_id)
38    }
39
40    /// Get a mutable order by client_id
41    pub fn get_mut(&mut self, client_id: &ClientOrderId) -> Option<&mut LiveOrder> {
42        self.orders.get_mut(client_id)
43    }
44
45    /// Check if we're tracking an order
46    pub fn contains(&self, client_id: &ClientOrderId) -> bool {
47        self.orders.contains_key(client_id)
48    }
49
50    /// Create a new order (when PlaceOrder command is emitted)
51    pub fn create_order(
52        &mut self,
53        client_id: ClientOrderId,
54        instrument: InstrumentId,
55        side: OrderSide,
56        price: Price,
57        qty: Qty,
58    ) -> &LiveOrder {
59        let order = LiveOrder {
60            client_id: client_id.clone(),
61            exchange_order_id: None,
62            instrument,
63            side,
64            price,
65            requested_qty: qty,
66            filled_qty: Qty::new(rust_decimal::Decimal::ZERO),
67            avg_fill_px: None,
68            status: OrderStatus::New,
69            ts_created: now_ms(),
70            ts_last_update: now_ms(),
71        };
72        self.orders.insert(client_id.clone(), order);
73        self.orders.get(&client_id).unwrap()
74    }
75
76    /// Mark order as accepted
77    pub fn accept_order(
78        &mut self,
79        client_id: &ClientOrderId,
80        exchange_order_id: Option<ExchangeOrderId>,
81    ) -> bool {
82        if let Some(order) = self.orders.get_mut(client_id) {
83            order.status = OrderStatus::Accepted;
84            order.ts_last_update = now_ms();
85            if let Some(eid) = exchange_order_id {
86                order.exchange_order_id = Some(eid.clone());
87                self.exchange_id_map.insert(eid, client_id.clone());
88            }
89            true
90        } else {
91            false
92        }
93    }
94
95    /// Mark order as rejected and remove it
96    pub fn reject_order(&mut self, client_id: &ClientOrderId) -> Option<LiveOrder> {
97        if let Some(mut order) = self.orders.remove(client_id) {
98            order.status = OrderStatus::Rejected;
99            order.ts_last_update = now_ms();
100            if let Some(ref eid) = order.exchange_order_id {
101                self.exchange_id_map.remove(eid);
102            }
103            Some(order)
104        } else {
105            None
106        }
107    }
108
109    /// Apply a fill to an order
110    /// Returns true if this is a new fill (not a duplicate)
111    pub fn apply_fill(
112        &mut self,
113        client_id: &ClientOrderId,
114        trade_id: &TradeId,
115        fill_qty: Qty,
116        fill_px: Price,
117    ) -> bool {
118        // Dedupe check
119        if self.seen_trades.contains(&trade_id.0) {
120            return false;
121        }
122
123        // Add to seen trades (with LRU eviction)
124        self.seen_trades.insert(trade_id.0.clone());
125        self.seen_trades_order.push(trade_id.0.clone());
126        if self.seen_trades_order.len() > self.max_seen_trades {
127            if let Some(old) = self.seen_trades_order.first().cloned() {
128                self.seen_trades.remove(&old);
129                self.seen_trades_order.remove(0);
130            }
131        }
132
133        // Apply to order
134        if let Some(order) = self.orders.get_mut(client_id) {
135            // Update filled qty
136            order.filled_qty += fill_qty;
137
138            // Update average fill price (weighted)
139            let old_notional = order.avg_fill_px.map(|p| p.0).unwrap_or_default()
140                * (order.filled_qty.0 - fill_qty.0);
141            let new_notional = fill_px.0 * fill_qty.0;
142            let total_qty = order.filled_qty.0;
143            if total_qty > rust_decimal::Decimal::ZERO {
144                order.avg_fill_px = Some(Price((old_notional + new_notional) / total_qty));
145            }
146
147            // Update status
148            if order.filled_qty >= order.requested_qty {
149                order.status = OrderStatus::Filled;
150            } else {
151                order.status = OrderStatus::PartiallyFilled;
152            }
153
154            order.ts_last_update = now_ms();
155            true
156        } else {
157            // Unknown order - might be from before our tracking started
158            false
159        }
160    }
161
162    /// Mark order as canceled and remove it
163    pub fn cancel_order(&mut self, client_id: &ClientOrderId) -> Option<LiveOrder> {
164        if let Some(mut order) = self.orders.remove(client_id) {
165            order.status = OrderStatus::Canceled;
166            order.ts_last_update = now_ms();
167            if let Some(ref eid) = order.exchange_order_id {
168                self.exchange_id_map.remove(eid);
169            }
170            Some(order)
171        } else {
172            None
173        }
174    }
175
176    /// Check if an order is complete (fully filled)
177    pub fn is_complete(&self, client_id: &ClientOrderId) -> bool {
178        self.orders
179            .get(client_id)
180            .map(|o| o.is_complete())
181            .unwrap_or(false)
182    }
183
184    /// Remove completed/terminal orders
185    pub fn remove_terminal(&mut self, client_id: &ClientOrderId) -> Option<LiveOrder> {
186        if let Some(order) = self.orders.get(client_id) {
187            if order.status.is_terminal() {
188                let order = self.orders.remove(client_id)?;
189                if let Some(ref eid) = order.exchange_order_id {
190                    self.exchange_id_map.remove(eid);
191                }
192                return Some(order);
193            }
194        }
195        None
196    }
197
198    /// Look up client_id from exchange_order_id
199    pub fn client_id_from_exchange_id(
200        &self,
201        exchange_id: &ExchangeOrderId,
202    ) -> Option<&ClientOrderId> {
203        self.exchange_id_map.get(exchange_id)
204    }
205
206    /// Check if a trade_id has been seen (for external dedupe)
207    pub fn is_trade_seen(&self, trade_id: &TradeId) -> bool {
208        self.seen_trades.contains(&trade_id.0)
209    }
210
211    /// Get the number of orders currently tracked
212    pub fn order_count(&self) -> usize {
213        self.orders.len()
214    }
215}
216
217impl Default for OrderManager {
218    fn default() -> Self {
219        Self::new()
220    }
221}