1use bot_core::{
4 Command, Event, Exchange, ExchangeHealth, ExchangeInstance, InstrumentId, InstrumentMeta,
5 OrderFilledEvent, OrderSide, Position, Price, Qty, Quote, StopStrategy, Strategy, StrategyId,
6};
7use rust_decimal::Decimal;
8use std::collections::{HashMap, HashSet};
9use std::sync::Arc;
10
11use crate::context::EngineContext;
12use crate::inventory::InventoryLedger;
13use crate::order_manager::OrderManager;
14
15pub struct EngineConfig {
17 pub min_poll_delay_ms: u64,
19 pub backoff_multiplier: f64,
21 pub max_backoff_ms: u64,
23}
24
25impl Default for EngineConfig {
26 fn default() -> Self {
27 Self {
28 min_poll_delay_ms: 500,
29 backoff_multiplier: 2.0,
30 max_backoff_ms: 30_000,
31 }
32 }
33}
34
35pub struct Engine {
44 #[allow(dead_code)]
45 config: EngineConfig,
46
47 order_manager: OrderManager,
49 inventory: InventoryLedger,
50 quotes: HashMap<InstrumentId, Quote>,
51 positions: HashMap<InstrumentId, Position>,
52 instruments: HashMap<InstrumentId, InstrumentMeta>,
53 exchange_health: HashMap<ExchangeInstance, ExchangeHealth>,
54
55 stopped_strategies: HashSet<StrategyId>,
57
58 exchanges: HashMap<ExchangeInstance, Arc<dyn Exchange>>,
60 strategies: Vec<Box<dyn Strategy>>,
61
62 fill_history: Vec<OrderFilledEvent>,
64}
65
66impl Engine {
67 pub fn new(config: EngineConfig) -> Self {
69 Self {
70 config,
71 order_manager: OrderManager::new(),
72 inventory: InventoryLedger::new(),
73 quotes: HashMap::new(),
74 positions: HashMap::new(),
75 instruments: HashMap::new(),
76 exchange_health: HashMap::new(),
77 stopped_strategies: HashSet::new(),
78 exchanges: HashMap::new(),
79 strategies: Vec::new(),
80 fill_history: Vec::new(),
81 }
82 }
83
84 pub fn register_exchange(&mut self, exchange: Arc<dyn Exchange>) {
86 let instance = exchange.instance();
87 self.exchange_health
88 .insert(instance.clone(), ExchangeHealth::Active);
89 self.exchanges.insert(instance, exchange);
90 }
91
92 pub fn register_instrument(&mut self, meta: InstrumentMeta) {
94 self.instruments.insert(meta.instrument_id.clone(), meta);
95 }
96
97 pub fn register_strategy(&mut self, strategy: Box<dyn Strategy>) {
99 self.strategies.push(strategy);
100 }
101
102 pub fn dispatch_event(&mut self, event: &Event) -> Vec<Command> {
106 let now_ms = bot_core::now_ms();
107 let num_strategies = self.strategies.len();
108 let mut commands: Vec<Command> = Vec::new();
109 let mut strategies_to_stop: Vec<(usize, String)> = Vec::new();
110
111 for i in 0..num_strategies {
112 let strategy_id = self.strategies[i].id().clone();
113
114 if self.stopped_strategies.contains(&strategy_id) {
116 continue;
117 }
118
119 let mut ctx = EngineContext::new(
121 &self.quotes,
122 &self.instruments,
123 &self.order_manager,
124 &self.inventory,
125 &self.positions,
126 &self.exchange_health,
127 now_ms,
128 );
129
130 self.strategies[i].on_event(&mut ctx, event);
132
133 if let Event::OrderRejected(ref rejection) = event {
137 let is_insufficient_margin = rejection.reason.contains("Insufficient margin");
138 let is_insufficient_balance =
139 rejection.reason.contains("Insufficient spot balance");
140 let is_min_value_error = rejection.reason.contains("minimum value");
141
142 if is_insufficient_margin || is_insufficient_balance || is_min_value_error {
143 let error_type = if is_insufficient_margin {
144 "MARGIN"
145 } else if is_insufficient_balance {
146 "BALANCE"
147 } else {
148 "MIN_ORDER_VALUE"
149 };
150 tracing::warn!(
151 "🚨 INSUFFICIENT {} detected for strategy {} - stopping bot",
152 error_type,
153 strategy_id
154 );
155 let stop_reason = format!(
156 "Insufficient {} to place order. Last rejected order: {} - {}",
157 error_type.to_lowercase(),
158 rejection.client_id,
159 rejection.reason
160 );
161 strategies_to_stop.push((i, stop_reason.clone()));
162 commands.push(Command::StopStrategy(StopStrategy {
163 strategy_id: strategy_id.clone(),
164 reason: stop_reason,
165 }));
166 }
167 let is_api_wallet_expired = rejection.reason.contains("User or API Wallet");
168
169 if is_api_wallet_expired {
170 let stop_reason = format!(
171 "API wallet expired for strategy {} - stopping bot",
172 strategy_id
173 );
174 tracing::warn!(stop_reason);
175 strategies_to_stop.push((i, stop_reason.clone()));
176 commands.push(Command::StopStrategy(StopStrategy {
177 strategy_id: strategy_id.clone(),
178 reason: stop_reason,
179 }));
180 }
181 }
182
183 let (places, batches, cancels, cancel_alls, stop_requests) = ctx.take_commands();
185
186 for cmd in places {
187 tracing::debug!("Strategy {} emitted PlaceOrder: {:?}", strategy_id, cmd);
188 commands.push(Command::PlaceOrder(cmd));
189 }
190 for batch in batches {
191 tracing::debug!(
192 "Strategy {} emitted PlaceOrders batch: {} orders",
193 strategy_id,
194 batch.len()
195 );
196 commands.push(Command::PlaceOrders(batch));
197 }
198 for cmd in cancels {
199 tracing::debug!("Strategy {} emitted CancelOrder: {:?}", strategy_id, cmd);
200 commands.push(Command::CancelOrder(cmd));
201 }
202 for cmd in cancel_alls {
203 tracing::debug!("Strategy {} emitted CancelAll: {:?}", strategy_id, cmd);
204 commands.push(Command::CancelAll(cmd));
205 }
206
207 for stop_req in stop_requests {
209 if stop_req.strategy_id == strategy_id {
210 strategies_to_stop.push((i, stop_req.reason.clone()));
211 commands.push(Command::StopStrategy(stop_req));
212 }
213 }
214 }
215
216 for (idx, reason) in strategies_to_stop {
218 let strategy_id = self.strategies[idx].id().clone();
219 tracing::info!("Stopping strategy {} due to: {}", strategy_id.0, reason);
220
221 let mut ctx = EngineContext::new(
222 &self.quotes,
223 &self.instruments,
224 &self.order_manager,
225 &self.inventory,
226 &self.positions,
227 &self.exchange_health,
228 now_ms,
229 );
230
231 self.strategies[idx].on_stop(&mut ctx);
233
234 let (places, batches, cancels, cancel_alls, _) = ctx.take_commands();
236 for cmd in places {
237 commands.push(Command::PlaceOrder(cmd));
238 }
239 for batch in batches {
240 commands.push(Command::PlaceOrders(batch));
241 }
242 for cmd in cancels {
243 commands.push(Command::CancelOrder(cmd));
244 }
245 for cmd in cancel_alls {
246 tracing::info!(
247 "on_stop emitted CancelAll for {:?}",
248 cmd.instrument.as_ref().map(|i| i.to_string())
249 );
250 commands.push(Command::CancelAll(cmd));
251 }
252
253 self.stopped_strategies.insert(strategy_id.clone());
255 tracing::info!("Strategy {} marked as stopped", strategy_id.0);
256 }
257
258 commands
259 }
260
261 pub fn update_quote(&mut self, quote: Quote) {
263 let instrument = quote.instrument.clone();
264 self.quotes.insert(instrument, quote);
265 }
266
267 pub fn set_exchange_health(&mut self, instance: &ExchangeInstance, health: ExchangeHealth) {
269 self.exchange_health.insert(instance.clone(), health);
270 }
271
272 pub fn start_strategies(&mut self) -> Vec<Command> {
274 let now_ms = bot_core::now_ms();
275 let num_strategies = self.strategies.len();
276 let mut commands: Vec<Command> = Vec::new();
277
278 for i in 0..num_strategies {
279 let mut ctx = EngineContext::new(
280 &self.quotes,
281 &self.instruments,
282 &self.order_manager,
283 &self.inventory,
284 &self.positions,
285 &self.exchange_health,
286 now_ms,
287 );
288 self.strategies[i].on_start(&mut ctx);
289
290 let (places, batches, cancels, cancel_alls, _stop_requests) = ctx.take_commands();
291 for cmd in places {
292 commands.push(Command::PlaceOrder(cmd));
293 }
294 for batch in batches {
295 commands.push(Command::PlaceOrders(batch));
296 }
297 for cmd in cancels {
298 commands.push(Command::CancelOrder(cmd));
299 }
300 for cmd in cancel_alls {
301 commands.push(Command::CancelAll(cmd));
302 }
303 }
304
305 commands
306 }
307
308 pub fn stop_strategies(&mut self) -> Vec<Command> {
310 let now_ms = bot_core::now_ms();
311 let num_strategies = self.strategies.len();
312 let mut commands: Vec<Command> = Vec::new();
313
314 for i in 0..num_strategies {
315 let strategy_id = self.strategies[i].id().clone();
316
317 if self.stopped_strategies.contains(&strategy_id) {
319 continue;
320 }
321
322 let mut ctx = EngineContext::new(
323 &self.quotes,
324 &self.instruments,
325 &self.order_manager,
326 &self.inventory,
327 &self.positions,
328 &self.exchange_health,
329 now_ms,
330 );
331 self.strategies[i].on_stop(&mut ctx);
332
333 let (places, batches, cancels, cancel_alls, _stop_requests) = ctx.take_commands();
334 for cmd in places {
335 commands.push(Command::PlaceOrder(cmd));
336 }
337 for batch in batches {
338 commands.push(Command::PlaceOrders(batch));
339 }
340 for cmd in cancels {
341 commands.push(Command::CancelOrder(cmd));
342 }
343 for cmd in cancel_alls {
344 commands.push(Command::CancelAll(cmd));
345 }
346
347 self.stopped_strategies.insert(strategy_id);
348 }
349
350 commands
351 }
352
353 pub fn is_strategy_stopped(&self, strategy_id: &StrategyId) -> bool {
355 self.stopped_strategies.contains(strategy_id)
356 }
357
358 pub fn stopped_strategies(&self) -> &HashSet<StrategyId> {
360 &self.stopped_strategies
361 }
362
363 pub fn order_manager(&self) -> &OrderManager {
365 &self.order_manager
366 }
367
368 pub fn order_manager_mut(&mut self) -> &mut OrderManager {
370 &mut self.order_manager
371 }
372
373 pub fn instrument_meta(&self, instrument: &InstrumentId) -> Option<&InstrumentMeta> {
375 self.instruments.get(instrument)
376 }
377
378 pub fn inventory(&self) -> &InventoryLedger {
380 &self.inventory
381 }
382
383 pub fn inventory_mut(&mut self) -> &mut InventoryLedger {
385 &mut self.inventory
386 }
387
388 pub fn apply_position_fill(
394 &mut self,
395 instrument: &InstrumentId,
396 side: OrderSide,
397 qty: Qty,
398 price: Price,
399 ) {
400 let position = self.positions.entry(instrument.clone()).or_default();
401
402 let signed_qty = match side {
404 OrderSide::Buy => qty.0,
405 OrderSide::Sell => -qty.0,
406 };
407
408 let old_qty = position.qty;
409 let new_qty = old_qty + signed_qty;
410
411 let is_reversal = (old_qty > Decimal::ZERO && new_qty < Decimal::ZERO)
413 || (old_qty < Decimal::ZERO && new_qty > Decimal::ZERO);
414
415 if let Some(entry) = position.avg_entry_px {
417 let is_reducing = (old_qty > Decimal::ZERO && signed_qty < Decimal::ZERO)
419 || (old_qty < Decimal::ZERO && signed_qty > Decimal::ZERO);
420
421 if is_reducing {
422 let closed_qty = if is_reversal {
425 old_qty.abs() } else {
427 qty.0.min(old_qty.abs())
428 };
429
430 let direction = if old_qty > Decimal::ZERO {
433 Decimal::ONE
434 } else {
435 -Decimal::ONE
436 };
437 let pnl = (price.0 - entry.0) * closed_qty * direction;
438 position.realized_pnl += pnl;
439 tracing::debug!(
440 "Realized PnL on close: {} (closed {} @ {} vs entry {})",
441 pnl,
442 closed_qty,
443 price,
444 entry.0
445 );
446 }
447 }
448
449 if is_reversal {
451 position.avg_entry_px = Some(price);
453 tracing::debug!(
454 "Position reversal: {} -> {}, new entry at {}",
455 old_qty,
456 new_qty,
457 price
458 );
459 } else if let Some(old_avg) = position.avg_entry_px {
460 if (old_qty > Decimal::ZERO && signed_qty > Decimal::ZERO)
462 || (old_qty < Decimal::ZERO && signed_qty < Decimal::ZERO)
463 {
464 let total_notional = old_avg.0 * old_qty.abs() + price.0 * qty.0;
466 let total_qty = old_qty.abs() + qty.0;
467 if total_qty > Decimal::ZERO {
468 position.avg_entry_px = Some(Price(total_notional / total_qty));
469 }
470 }
471 } else if new_qty != Decimal::ZERO {
473 position.avg_entry_px = Some(price);
475 }
476
477 position.qty = new_qty;
478
479 if new_qty == Decimal::ZERO {
481 position.avg_entry_px = None;
482 position.unrealized_pnl = None;
483 }
484
485 tracing::debug!(
486 "Position update: {} {} -> {} (fill: {} {} @ {})",
487 instrument,
488 old_qty,
489 new_qty,
490 side,
491 qty,
492 price
493 );
494 }
495
496 pub fn apply_fill_fee(&mut self, instrument: &InstrumentId, fee: Decimal) {
498 if fee != Decimal::ZERO {
499 let position = self.positions.entry(instrument.clone()).or_default();
500 position.total_fees += fee;
501 tracing::debug!("Fee applied: {} for {}", fee, instrument);
502 }
503 }
504
505 pub fn position(&self, instrument: &InstrumentId) -> Position {
508 let mut pos = self.positions.get(instrument).cloned().unwrap_or_default();
509
510 if let (Some(avg_entry), Some(quote)) = (pos.avg_entry_px, self.quotes.get(instrument)) {
512 let mid = (quote.bid.0 + quote.ask.0) / Decimal::TWO;
513 let unrealized = (mid - avg_entry.0) * pos.qty;
514 pos.unrealized_pnl = Some(unrealized);
515 }
516
517 pos
518 }
519
520 pub fn get_positions(&self) -> HashMap<InstrumentId, Position> {
523 self.positions
524 .keys()
525 .map(|inst| (inst.clone(), self.position(inst)))
526 .collect()
527 }
528
529 pub fn sync_mechanism(&self) -> bot_core::SyncMechanism {
531 self.strategies
532 .first()
533 .map(|s| s.sync_mechanism())
534 .unwrap_or_default()
535 }
536
537 pub fn apply_snapshot(
541 &mut self,
542 instrument: &InstrumentId,
543 qty: Decimal,
544 avg_entry_px: Option<Price>,
545 unrealized_pnl: Option<Decimal>,
546 ) {
547 let position = self.positions.entry(instrument.clone()).or_default();
548 position.qty = qty;
549 position.avg_entry_px = avg_entry_px;
550 position.unrealized_pnl = unrealized_pnl;
551
552 tracing::debug!(
553 "Position snapshot applied: {} qty={} entry={:?} pnl={:?}",
554 instrument,
555 qty,
556 avg_entry_px,
557 unrealized_pnl
558 );
559 }
560
561 pub fn record_fill(&mut self, fill: OrderFilledEvent) {
564 self.fill_history.push(fill);
565 }
566
567 pub fn get_fills(&self) -> &[OrderFilledEvent] {
569 &self.fill_history
570 }
571
572 pub fn clear_fills(&mut self) {
574 self.fill_history.clear();
575 }
576}