Skip to main content

bot_engine/
engine.rs

1//! Main engine implementation.
2
3use 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
15/// Engine configuration
16pub struct EngineConfig {
17    /// Minimum delay between userFills polls (ms)
18    pub min_poll_delay_ms: u64,
19    /// Backoff multiplier for errors
20    pub backoff_multiplier: f64,
21    /// Max backoff delay (ms)
22    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
35/// The main trading engine.
36///
37/// Owns:
38/// - Order state (OrderManager)
39/// - Inventory/balance state (InventoryLedger)
40/// - Exchange adapters
41/// - Strategies
42/// - Exchange health state
43pub struct Engine {
44    #[allow(dead_code)]
45    config: EngineConfig,
46
47    // State
48    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    // Strategies that have been stopped (by their own request)
56    stopped_strategies: HashSet<StrategyId>,
57
58    // Components (to be wired up)
59    exchanges: HashMap<ExchangeInstance, Arc<dyn Exchange>>,
60    strategies: Vec<Box<dyn Strategy>>,
61
62    // Fill history (for backtesting/reporting)
63    fill_history: Vec<OrderFilledEvent>,
64}
65
66impl Engine {
67    /// Create an empty engine with the supplied polling/backoff config.
68    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    /// Register an exchange adapter
85    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    /// Register an instrument
93    pub fn register_instrument(&mut self, meta: InstrumentMeta) {
94        self.instruments.insert(meta.instrument_id.clone(), meta);
95    }
96
97    /// Register a strategy
98    pub fn register_strategy(&mut self, strategy: Box<dyn Strategy>) {
99        self.strategies.push(strategy);
100    }
101
102    /// Dispatch an event to all strategies.
103    ///
104    /// Returns all commands emitted by strategies while handling this event.
105    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            // Skip stopped strategies
115            if self.stopped_strategies.contains(&strategy_id) {
116                continue;
117            }
118
119            // Create context from non-strategy state
120            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            // Borrow strategy mutably and dispatch
131            self.strategies[i].on_event(&mut ctx, event);
132
133            // ENGINE SAFETY CHECK: Detect funding errors (margin for perps, balance for spot)
134            // If an order is rejected due to insufficient funds, stop the bot immediately
135            // to prevent further failed orders and notify the user
136            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            // Process any commands emitted by the strategy
184            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            // Check for stop requests
208            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        // Process stop requests - call on_stop for strategies that requested it
217        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            // Call on_stop
232            self.strategies[idx].on_stop(&mut ctx);
233
234            // Collect any commands from on_stop
235            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            // Mark as stopped
254            self.stopped_strategies.insert(strategy_id.clone());
255            tracing::info!("Strategy {} marked as stopped", strategy_id.0);
256        }
257
258        commands
259    }
260
261    /// Update quote for an instrument
262    pub fn update_quote(&mut self, quote: Quote) {
263        let instrument = quote.instrument.clone();
264        self.quotes.insert(instrument, quote);
265    }
266
267    /// Update exchange health
268    pub fn set_exchange_health(&mut self, instance: &ExchangeInstance, health: ExchangeHealth) {
269        self.exchange_health.insert(instance.clone(), health);
270    }
271
272    /// Start all strategies
273    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    /// Stop all strategies
309    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            // Skip already stopped strategies
318            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    /// Check if a strategy has been stopped
354    pub fn is_strategy_stopped(&self, strategy_id: &StrategyId) -> bool {
355        self.stopped_strategies.contains(strategy_id)
356    }
357
358    /// Get all stopped strategies
359    pub fn stopped_strategies(&self) -> &HashSet<StrategyId> {
360        &self.stopped_strategies
361    }
362
363    /// Get reference to order manager
364    pub fn order_manager(&self) -> &OrderManager {
365        &self.order_manager
366    }
367
368    /// Get mutable reference to order manager
369    pub fn order_manager_mut(&mut self) -> &mut OrderManager {
370        &mut self.order_manager
371    }
372
373    /// Get instrument metadata (needed for command execution).
374    pub fn instrument_meta(&self, instrument: &InstrumentId) -> Option<&InstrumentMeta> {
375        self.instruments.get(instrument)
376    }
377
378    /// Get reference to inventory ledger
379    pub fn inventory(&self) -> &InventoryLedger {
380        &self.inventory
381    }
382
383    /// Get mutable reference to inventory ledger
384    pub fn inventory_mut(&mut self) -> &mut InventoryLedger {
385        &mut self.inventory
386    }
387
388    /// Apply a fill to position tracking (for perpetuals).
389    ///
390    /// - Buy fill: increases position (adds qty)
391    /// - Sell fill: decreases position (subtracts qty)
392    /// - Tracks realized PnL when reducing/closing position
393    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        // Calculate signed delta
403        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        // Detect position reversal (flip) - trade goes beyond current position
412        let is_reversal = (old_qty > Decimal::ZERO && new_qty < Decimal::ZERO)
413            || (old_qty < Decimal::ZERO && new_qty > Decimal::ZERO);
414
415        // Track realized PnL when reducing position
416        if let Some(entry) = position.avg_entry_px {
417            // Check if this fill reduces the position (opposite direction)
418            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                // For reversal: close the FULL old position
423                // For partial/full close: close min of trade_qty and old_qty
424                let closed_qty = if is_reversal {
425                    old_qty.abs() // Close entire old position
426                } else {
427                    qty.0.min(old_qty.abs())
428                };
429
430                // PnL = (exit_price - entry_price) * closed_qty * direction
431                // direction: +1 for long, -1 for short
432                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        // Update average entry price
450        if is_reversal {
451            // REVERSAL: New position starts fresh at trade price
452            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 adding to position (same direction), compute weighted avg
461            if (old_qty > Decimal::ZERO && signed_qty > Decimal::ZERO)
462                || (old_qty < Decimal::ZERO && signed_qty < Decimal::ZERO)
463            {
464                // Same direction: weighted average
465                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            // If reducing (not reversal), keep old avg until flat
472        } else if new_qty != Decimal::ZERO {
473            // First fill, set entry price
474            position.avg_entry_px = Some(price);
475        }
476
477        position.qty = new_qty;
478
479        // If position is now flat, clear avg entry but KEEP realized_pnl and total_fees
480        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    /// Apply fee from a fill to position tracking
497    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    /// Get current position for an instrument.
506    /// Automatically computes unrealized_pnl from current quotes if available.
507    pub fn position(&self, instrument: &InstrumentId) -> Position {
508        let mut pos = self.positions.get(instrument).cloned().unwrap_or_default();
509
510        // Compute unrealized PnL if we have position and quote
511        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    /// Get all positions (for testing/WASM/UI snapshots).
521    /// Computes unrealized_pnl for each position from current quotes.
522    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    /// Get the sync mechanism from the first strategy (assumes all strategies use the same mechanism)
530    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    /// Apply a position snapshot (absolute update)
538    ///
539    /// Used for snapshot-based synchronization (e.g., clearinghouseState)
540    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    /// Record a fill in the engine's fill history.
562    /// This is called when processing OrderFilled events (e.g., from exchange or paper_exchange).
563    pub fn record_fill(&mut self, fill: OrderFilledEvent) {
564        self.fill_history.push(fill);
565    }
566
567    /// Get all recorded fills (for backtesting/reporting).
568    pub fn get_fills(&self) -> &[OrderFilledEvent] {
569        &self.fill_history
570    }
571
572    /// Clear fill history (e.g., on reset).
573    pub fn clear_fills(&mut self) {
574        self.fill_history.clear();
575    }
576}