use bot_core::{
Command, Event, Exchange, ExchangeHealth, ExchangeInstance, InstrumentId, InstrumentMeta,
OrderFilledEvent, OrderSide, Position, Price, Qty, Quote, StopStrategy, Strategy, StrategyId,
};
use rust_decimal::Decimal;
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use crate::context::EngineContext;
use crate::inventory::InventoryLedger;
use crate::order_manager::OrderManager;
pub struct EngineConfig {
pub min_poll_delay_ms: u64,
pub backoff_multiplier: f64,
pub max_backoff_ms: u64,
}
impl Default for EngineConfig {
fn default() -> Self {
Self {
min_poll_delay_ms: 500,
backoff_multiplier: 2.0,
max_backoff_ms: 30_000,
}
}
}
pub struct Engine {
#[allow(dead_code)]
config: EngineConfig,
order_manager: OrderManager,
inventory: InventoryLedger,
quotes: HashMap<InstrumentId, Quote>,
positions: HashMap<InstrumentId, Position>,
instruments: HashMap<InstrumentId, InstrumentMeta>,
exchange_health: HashMap<ExchangeInstance, ExchangeHealth>,
stopped_strategies: HashSet<StrategyId>,
exchanges: HashMap<ExchangeInstance, Arc<dyn Exchange>>,
strategies: Vec<Box<dyn Strategy>>,
fill_history: Vec<OrderFilledEvent>,
}
impl Engine {
pub fn new(config: EngineConfig) -> Self {
Self {
config,
order_manager: OrderManager::new(),
inventory: InventoryLedger::new(),
quotes: HashMap::new(),
positions: HashMap::new(),
instruments: HashMap::new(),
exchange_health: HashMap::new(),
stopped_strategies: HashSet::new(),
exchanges: HashMap::new(),
strategies: Vec::new(),
fill_history: Vec::new(),
}
}
pub fn register_exchange(&mut self, exchange: Arc<dyn Exchange>) {
let instance = exchange.instance();
self.exchange_health
.insert(instance.clone(), ExchangeHealth::Active);
self.exchanges.insert(instance, exchange);
}
pub fn register_instrument(&mut self, meta: InstrumentMeta) {
self.instruments.insert(meta.instrument_id.clone(), meta);
}
pub fn register_strategy(&mut self, strategy: Box<dyn Strategy>) {
self.strategies.push(strategy);
}
pub fn dispatch_event(&mut self, event: &Event) -> Vec<Command> {
let now_ms = bot_core::now_ms();
let num_strategies = self.strategies.len();
let mut commands: Vec<Command> = Vec::new();
let mut strategies_to_stop: Vec<(usize, String)> = Vec::new();
for i in 0..num_strategies {
let strategy_id = self.strategies[i].id().clone();
if self.stopped_strategies.contains(&strategy_id) {
continue;
}
let mut ctx = EngineContext::new(
&self.quotes,
&self.instruments,
&self.order_manager,
&self.inventory,
&self.positions,
&self.exchange_health,
now_ms,
);
self.strategies[i].on_event(&mut ctx, event);
if let Event::OrderRejected(ref rejection) = event {
let is_insufficient_margin = rejection.reason.contains("Insufficient margin");
let is_insufficient_balance =
rejection.reason.contains("Insufficient spot balance");
let is_min_value_error = rejection.reason.contains("minimum value");
if is_insufficient_margin || is_insufficient_balance || is_min_value_error {
let error_type = if is_insufficient_margin {
"MARGIN"
} else if is_insufficient_balance {
"BALANCE"
} else {
"MIN_ORDER_VALUE"
};
tracing::warn!(
"🚨 INSUFFICIENT {} detected for strategy {} - stopping bot",
error_type,
strategy_id
);
let stop_reason = format!(
"Insufficient {} to place order. Last rejected order: {} - {}",
error_type.to_lowercase(),
rejection.client_id,
rejection.reason
);
strategies_to_stop.push((i, stop_reason.clone()));
commands.push(Command::StopStrategy(StopStrategy {
strategy_id: strategy_id.clone(),
reason: stop_reason,
}));
}
let is_api_wallet_expired = rejection.reason.contains("User or API Wallet");
if is_api_wallet_expired {
let stop_reason = format!(
"API wallet expired for strategy {} - stopping bot",
strategy_id
);
tracing::warn!(stop_reason);
strategies_to_stop.push((i, stop_reason.clone()));
commands.push(Command::StopStrategy(StopStrategy {
strategy_id: strategy_id.clone(),
reason: stop_reason,
}));
}
}
let (places, batches, cancels, cancel_alls, stop_requests) = ctx.take_commands();
for cmd in places {
tracing::debug!("Strategy {} emitted PlaceOrder: {:?}", strategy_id, cmd);
commands.push(Command::PlaceOrder(cmd));
}
for batch in batches {
tracing::debug!(
"Strategy {} emitted PlaceOrders batch: {} orders",
strategy_id,
batch.len()
);
commands.push(Command::PlaceOrders(batch));
}
for cmd in cancels {
tracing::debug!("Strategy {} emitted CancelOrder: {:?}", strategy_id, cmd);
commands.push(Command::CancelOrder(cmd));
}
for cmd in cancel_alls {
tracing::debug!("Strategy {} emitted CancelAll: {:?}", strategy_id, cmd);
commands.push(Command::CancelAll(cmd));
}
for stop_req in stop_requests {
if stop_req.strategy_id == strategy_id {
strategies_to_stop.push((i, stop_req.reason.clone()));
commands.push(Command::StopStrategy(stop_req));
}
}
}
for (idx, reason) in strategies_to_stop {
let strategy_id = self.strategies[idx].id().clone();
tracing::info!("Stopping strategy {} due to: {}", strategy_id.0, reason);
let mut ctx = EngineContext::new(
&self.quotes,
&self.instruments,
&self.order_manager,
&self.inventory,
&self.positions,
&self.exchange_health,
now_ms,
);
self.strategies[idx].on_stop(&mut ctx);
let (places, batches, cancels, cancel_alls, _) = ctx.take_commands();
for cmd in places {
commands.push(Command::PlaceOrder(cmd));
}
for batch in batches {
commands.push(Command::PlaceOrders(batch));
}
for cmd in cancels {
commands.push(Command::CancelOrder(cmd));
}
for cmd in cancel_alls {
tracing::info!(
"on_stop emitted CancelAll for {:?}",
cmd.instrument.as_ref().map(|i| i.to_string())
);
commands.push(Command::CancelAll(cmd));
}
self.stopped_strategies.insert(strategy_id.clone());
tracing::info!("Strategy {} marked as stopped", strategy_id.0);
}
commands
}
pub fn update_quote(&mut self, quote: Quote) {
let instrument = quote.instrument.clone();
self.quotes.insert(instrument, quote);
}
pub fn set_exchange_health(&mut self, instance: &ExchangeInstance, health: ExchangeHealth) {
self.exchange_health.insert(instance.clone(), health);
}
pub fn start_strategies(&mut self) -> Vec<Command> {
let now_ms = bot_core::now_ms();
let num_strategies = self.strategies.len();
let mut commands: Vec<Command> = Vec::new();
for i in 0..num_strategies {
let mut ctx = EngineContext::new(
&self.quotes,
&self.instruments,
&self.order_manager,
&self.inventory,
&self.positions,
&self.exchange_health,
now_ms,
);
self.strategies[i].on_start(&mut ctx);
let (places, batches, cancels, cancel_alls, _stop_requests) = ctx.take_commands();
for cmd in places {
commands.push(Command::PlaceOrder(cmd));
}
for batch in batches {
commands.push(Command::PlaceOrders(batch));
}
for cmd in cancels {
commands.push(Command::CancelOrder(cmd));
}
for cmd in cancel_alls {
commands.push(Command::CancelAll(cmd));
}
}
commands
}
pub fn stop_strategies(&mut self) -> Vec<Command> {
let now_ms = bot_core::now_ms();
let num_strategies = self.strategies.len();
let mut commands: Vec<Command> = Vec::new();
for i in 0..num_strategies {
let strategy_id = self.strategies[i].id().clone();
if self.stopped_strategies.contains(&strategy_id) {
continue;
}
let mut ctx = EngineContext::new(
&self.quotes,
&self.instruments,
&self.order_manager,
&self.inventory,
&self.positions,
&self.exchange_health,
now_ms,
);
self.strategies[i].on_stop(&mut ctx);
let (places, batches, cancels, cancel_alls, _stop_requests) = ctx.take_commands();
for cmd in places {
commands.push(Command::PlaceOrder(cmd));
}
for batch in batches {
commands.push(Command::PlaceOrders(batch));
}
for cmd in cancels {
commands.push(Command::CancelOrder(cmd));
}
for cmd in cancel_alls {
commands.push(Command::CancelAll(cmd));
}
self.stopped_strategies.insert(strategy_id);
}
commands
}
pub fn is_strategy_stopped(&self, strategy_id: &StrategyId) -> bool {
self.stopped_strategies.contains(strategy_id)
}
pub fn stopped_strategies(&self) -> &HashSet<StrategyId> {
&self.stopped_strategies
}
pub fn order_manager(&self) -> &OrderManager {
&self.order_manager
}
pub fn order_manager_mut(&mut self) -> &mut OrderManager {
&mut self.order_manager
}
pub fn instrument_meta(&self, instrument: &InstrumentId) -> Option<&InstrumentMeta> {
self.instruments.get(instrument)
}
pub fn inventory(&self) -> &InventoryLedger {
&self.inventory
}
pub fn inventory_mut(&mut self) -> &mut InventoryLedger {
&mut self.inventory
}
pub fn apply_position_fill(
&mut self,
instrument: &InstrumentId,
side: OrderSide,
qty: Qty,
price: Price,
) {
let position = self.positions.entry(instrument.clone()).or_default();
let signed_qty = match side {
OrderSide::Buy => qty.0,
OrderSide::Sell => -qty.0,
};
let old_qty = position.qty;
let new_qty = old_qty + signed_qty;
let is_reversal = (old_qty > Decimal::ZERO && new_qty < Decimal::ZERO)
|| (old_qty < Decimal::ZERO && new_qty > Decimal::ZERO);
if let Some(entry) = position.avg_entry_px {
let is_reducing = (old_qty > Decimal::ZERO && signed_qty < Decimal::ZERO)
|| (old_qty < Decimal::ZERO && signed_qty > Decimal::ZERO);
if is_reducing {
let closed_qty = if is_reversal {
old_qty.abs() } else {
qty.0.min(old_qty.abs())
};
let direction = if old_qty > Decimal::ZERO {
Decimal::ONE
} else {
-Decimal::ONE
};
let pnl = (price.0 - entry.0) * closed_qty * direction;
position.realized_pnl += pnl;
tracing::debug!(
"Realized PnL on close: {} (closed {} @ {} vs entry {})",
pnl,
closed_qty,
price,
entry.0
);
}
}
if is_reversal {
position.avg_entry_px = Some(price);
tracing::debug!(
"Position reversal: {} -> {}, new entry at {}",
old_qty,
new_qty,
price
);
} else if let Some(old_avg) = position.avg_entry_px {
if (old_qty > Decimal::ZERO && signed_qty > Decimal::ZERO)
|| (old_qty < Decimal::ZERO && signed_qty < Decimal::ZERO)
{
let total_notional = old_avg.0 * old_qty.abs() + price.0 * qty.0;
let total_qty = old_qty.abs() + qty.0;
if total_qty > Decimal::ZERO {
position.avg_entry_px = Some(Price(total_notional / total_qty));
}
}
} else if new_qty != Decimal::ZERO {
position.avg_entry_px = Some(price);
}
position.qty = new_qty;
if new_qty == Decimal::ZERO {
position.avg_entry_px = None;
position.unrealized_pnl = None;
}
tracing::debug!(
"Position update: {} {} -> {} (fill: {} {} @ {})",
instrument,
old_qty,
new_qty,
side,
qty,
price
);
}
pub fn apply_fill_fee(&mut self, instrument: &InstrumentId, fee: Decimal) {
if fee != Decimal::ZERO {
let position = self.positions.entry(instrument.clone()).or_default();
position.total_fees += fee;
tracing::debug!("Fee applied: {} for {}", fee, instrument);
}
}
pub fn position(&self, instrument: &InstrumentId) -> Position {
let mut pos = self.positions.get(instrument).cloned().unwrap_or_default();
if let (Some(avg_entry), Some(quote)) = (pos.avg_entry_px, self.quotes.get(instrument)) {
let mid = (quote.bid.0 + quote.ask.0) / Decimal::TWO;
let unrealized = (mid - avg_entry.0) * pos.qty;
pos.unrealized_pnl = Some(unrealized);
}
pos
}
pub fn get_positions(&self) -> HashMap<InstrumentId, Position> {
self.positions
.keys()
.map(|inst| (inst.clone(), self.position(inst)))
.collect()
}
pub fn sync_mechanism(&self) -> bot_core::SyncMechanism {
self.strategies
.first()
.map(|s| s.sync_mechanism())
.unwrap_or_default()
}
pub fn apply_snapshot(
&mut self,
instrument: &InstrumentId,
qty: Decimal,
avg_entry_px: Option<Price>,
unrealized_pnl: Option<Decimal>,
) {
let position = self.positions.entry(instrument.clone()).or_default();
position.qty = qty;
position.avg_entry_px = avg_entry_px;
position.unrealized_pnl = unrealized_pnl;
tracing::debug!(
"Position snapshot applied: {} qty={} entry={:?} pnl={:?}",
instrument,
qty,
avg_entry_px,
unrealized_pnl
);
}
pub fn record_fill(&mut self, fill: OrderFilledEvent) {
self.fill_history.push(fill);
}
pub fn get_fills(&self) -> &[OrderFilledEvent] {
&self.fill_history
}
pub fn clear_fills(&mut self) {
self.fill_history.clear();
}
}