Skip to main content

trader_bot/strategy/balancer/
engine.rs

1//! Balancer engine that orchestrates the rebalancing process.
2
3use std::collections::HashMap;
4use std::sync::Arc;
5use std::time::Duration;
6
7use rust_decimal::Decimal;
8use tracing::{debug, info, warn};
9
10use super::{AllocationDiff, RebalanceAction, RebalanceCalculator, RebalancePlan};
11use crate::domain::{DesiredAllocation, OrderDirection, OrderStatus, Wallet};
12use crate::exchange::{ExchangeError, ExchangeProvider, ExchangeResult, Instrument};
13
14/// Configuration for the balancer engine.
15#[derive(Debug, Clone)]
16pub struct BalancerConfig {
17    /// Minimum trade size as percentage of portfolio.
18    pub min_trade_percent: Decimal,
19    /// Minimum trade value in base currency.
20    pub min_trade_value: Decimal,
21    /// Tolerance for considering allocation "close enough".
22    pub tolerance_percent: Decimal,
23    /// Delay between consecutive orders (milliseconds).
24    pub order_delay_ms: u64,
25    /// Whether to actually execute trades or just simulate.
26    pub dry_run: bool,
27    /// Maximum number of orders per rebalancing cycle.
28    pub max_orders_per_cycle: usize,
29    /// Minimum profit percentage required for selling a position.
30    pub min_profit_percent_for_close: Option<Decimal>,
31}
32
33impl Default for BalancerConfig {
34    fn default() -> Self {
35        Self {
36            min_trade_percent: Decimal::new(1, 1), // 0.1%
37            min_trade_value: Decimal::new(100, 0), // 100 units
38            tolerance_percent: Decimal::new(5, 1), // 0.5%
39            order_delay_ms: 1000,                  // 1 second
40            dry_run: false,
41            max_orders_per_cycle: 20,
42            min_profit_percent_for_close: None,
43        }
44    }
45}
46
47/// Result of a single rebalancing cycle.
48#[derive(Debug, Clone)]
49pub struct RebalanceResult {
50    /// The plan that was created.
51    pub plan: RebalancePlan,
52    /// Actions that were successfully executed.
53    pub executed: Vec<RebalanceAction>,
54    /// Actions that failed.
55    pub failed: Vec<(RebalanceAction, String)>,
56    /// Whether the cycle completed successfully.
57    pub success: bool,
58    /// Error message if the cycle failed.
59    pub error: Option<String>,
60}
61
62impl RebalanceResult {
63    /// Creates a successful result.
64    #[must_use]
65    pub fn success(plan: RebalancePlan, executed: Vec<RebalanceAction>) -> Self {
66        Self {
67            plan,
68            executed,
69            failed: Vec::new(),
70            success: true,
71            error: None,
72        }
73    }
74
75    /// Creates a failed result.
76    #[must_use]
77    pub fn failure(plan: RebalancePlan, error: impl Into<String>) -> Self {
78        Self {
79            plan,
80            executed: Vec::new(),
81            failed: Vec::new(),
82            success: false,
83            error: Some(error.into()),
84        }
85    }
86}
87
88/// The main balancer engine.
89pub struct BalancerEngine<E: ExchangeProvider> {
90    exchange: Arc<E>,
91    config: BalancerConfig,
92    calculator: RebalanceCalculator,
93    instrument_cache: HashMap<String, Instrument>,
94}
95
96impl<E: ExchangeProvider> BalancerEngine<E> {
97    /// Creates a new balancer engine.
98    #[must_use]
99    pub fn new(exchange: Arc<E>, config: BalancerConfig) -> Self {
100        let calculator = RebalanceCalculator::new()
101            .with_min_trade_percent(config.min_trade_percent)
102            .with_min_trade_value(config.min_trade_value)
103            .with_tolerance_percent(config.tolerance_percent);
104
105        Self {
106            exchange,
107            config,
108            calculator,
109            instrument_cache: HashMap::new(),
110        }
111    }
112
113    /// Returns the current configuration.
114    #[must_use]
115    pub const fn config(&self) -> &BalancerConfig {
116        &self.config
117    }
118
119    /// Updates the configuration.
120    pub fn set_config(&mut self, config: BalancerConfig) {
121        self.calculator = RebalanceCalculator::new()
122            .with_min_trade_percent(config.min_trade_percent)
123            .with_min_trade_value(config.min_trade_value)
124            .with_tolerance_percent(config.tolerance_percent);
125        self.config = config;
126    }
127
128    /// Clears the instrument cache.
129    pub fn clear_cache(&mut self) {
130        self.instrument_cache.clear();
131    }
132
133    /// Gets an instrument, using cache if available.
134    async fn get_instrument(&mut self, symbol: &str) -> ExchangeResult<Instrument> {
135        if let Some(instrument) = self.instrument_cache.get(symbol) {
136            return Ok(instrument.clone());
137        }
138
139        let instrument = self.exchange.get_instrument(symbol).await?;
140        self.instrument_cache
141            .insert(symbol.to_string(), instrument.clone());
142        Ok(instrument)
143    }
144
145    /// Creates a rebalancing plan without executing it.
146    pub async fn create_plan(
147        &mut self,
148        account_id: &str,
149        desired: &DesiredAllocation,
150    ) -> ExchangeResult<RebalancePlan> {
151        info!("Creating rebalance plan for account {}", account_id);
152
153        // Get current wallet state
154        let wallet = self.exchange.get_wallet(account_id).await?;
155        let currency = wallet.base_currency().to_string();
156
157        // Check if already balanced
158        if self.calculator.is_balanced(&wallet, desired) {
159            debug!("Portfolio already balanced within tolerance");
160            return Ok(RebalancePlan::empty_with_reason(
161                &currency,
162                "Portfolio already balanced",
163            ));
164        }
165
166        // Calculate differences
167        let diffs = self.calculator.calculate_diffs(&wallet, desired);
168        let significant = self.calculator.filter_significant_diffs(diffs);
169        let sorted = self.calculator.sort_by_priority(significant);
170
171        if sorted.is_empty() {
172            return Ok(RebalancePlan::empty_with_reason(
173                &currency,
174                "No significant rebalancing needed",
175            ));
176        }
177
178        // Build the plan
179        let mut plan = RebalancePlan::new(&currency);
180        plan.set_dry_run(self.config.dry_run);
181
182        for (i, diff) in sorted.iter().enumerate() {
183            if i >= self.config.max_orders_per_cycle {
184                warn!(
185                    "Reached max orders per cycle ({}), truncating plan",
186                    self.config.max_orders_per_cycle
187                );
188                break;
189            }
190
191            if let Some(action) = self.diff_to_action(&wallet, diff).await? {
192                plan.add_action(action);
193            }
194        }
195
196        info!(
197            "Created plan with {} actions: {}",
198            plan.trade_count,
199            plan.summary()
200        );
201
202        Ok(plan)
203    }
204
205    /// Converts an allocation diff to a rebalance action.
206    async fn diff_to_action(
207        &mut self,
208        wallet: &Wallet,
209        diff: &AllocationDiff,
210    ) -> ExchangeResult<Option<RebalanceAction>> {
211        let position = wallet.get_position(&diff.symbol);
212
213        // Get instrument info
214        let instrument = match self.get_instrument(&diff.symbol).await {
215            Ok(i) => i,
216            Err(ExchangeError::InstrumentNotFound(_)) => {
217                warn!("Instrument {} not found, skipping", diff.symbol);
218                return Ok(None);
219            }
220            Err(e) => return Err(e),
221        };
222
223        // Get current market data
224        let market_data = self.exchange.get_market_data(&diff.symbol).await?;
225
226        // Check if market is open
227        if !market_data.market_status.is_tradeable() {
228            debug!("Market closed for {}, skipping", diff.symbol);
229            return Ok(None);
230        }
231
232        let price_per_unit = market_data.last_price.clone();
233        let lot_size = instrument.lot_size;
234        let price_per_lot = price_per_unit.multiply(Decimal::from(lot_size));
235
236        let direction = if diff.needs_buy() {
237            OrderDirection::Buy
238        } else {
239            OrderDirection::Sell
240        };
241
242        // Calculate lots
243        let lots =
244            self.calculator
245                .calculate_lots(diff.diff_value, price_per_lot.amount(), lot_size);
246
247        if lots == 0 {
248            debug!("Trade for {} would be 0 lots, skipping", diff.symbol);
249            return Ok(None);
250        }
251
252        // Check min profit requirement for sells
253        if direction == OrderDirection::Sell {
254            if let Some(min_profit) = self.config.min_profit_percent_for_close {
255                if let Some(pos) = position {
256                    if let Some(pnl_pct) = pos.unrealized_pnl_percent() {
257                        if pnl_pct < min_profit {
258                            debug!(
259                                "Skipping sell of {} due to min profit requirement ({:.2}% < {:.2}%)",
260                                diff.symbol, pnl_pct, min_profit
261                            );
262                            return Ok(None);
263                        }
264                    }
265                }
266            }
267
268            // Check if we have enough tradeable lots
269            if let Some(pos) = position {
270                let available = pos.tradeable_lots();
271                if lots > available {
272                    debug!(
273                        "Reducing sell lots for {} from {} to {} (available)",
274                        diff.symbol, lots, available
275                    );
276                    if available == 0 {
277                        return Ok(None);
278                    }
279                }
280            }
281        }
282
283        let estimated_value = price_per_lot.multiply(Decimal::from(lots));
284
285        Ok(Some(RebalanceAction {
286            symbol: diff.symbol.clone(),
287            exchange: instrument.exchange.clone(),
288            direction,
289            lots,
290            estimated_price: price_per_lot,
291            estimated_value,
292            current_percent: diff.current_percent,
293            target_percent: diff.desired_percent,
294            priority: 0, // Will be set by plan builder
295        }))
296    }
297
298    /// Executes a rebalancing plan.
299    pub async fn execute_plan(
300        &self,
301        account_id: &str,
302        plan: &RebalancePlan,
303    ) -> ExchangeResult<RebalanceResult> {
304        if plan.is_empty() {
305            return Ok(RebalanceResult::success(plan.clone(), Vec::new()));
306        }
307
308        if plan.dry_run {
309            info!("Dry run mode - not executing {} trades", plan.trade_count);
310            return Ok(RebalanceResult::success(plan.clone(), Vec::new()));
311        }
312
313        info!("Executing {} trades", plan.trade_count);
314
315        let mut executed = Vec::new();
316        let mut failed = Vec::new();
317
318        for (i, action) in plan.actions.iter().enumerate() {
319            if i > 0 && self.config.order_delay_ms > 0 {
320                tokio::time::sleep(Duration::from_millis(self.config.order_delay_ms)).await;
321            }
322
323            match self.execute_action(account_id, action).await {
324                Ok(_) => {
325                    info!(
326                        "Executed {} {} lots of {}",
327                        if action.is_buy() { "buy" } else { "sell" },
328                        action.lots,
329                        action.symbol
330                    );
331                    executed.push(action.clone());
332                }
333                Err(e) => {
334                    warn!(
335                        "Failed to execute {} for {}: {}",
336                        if action.is_buy() { "buy" } else { "sell" },
337                        action.symbol,
338                        e
339                    );
340                    failed.push((action.clone(), e.to_string()));
341                }
342            }
343        }
344
345        let mut result = RebalanceResult::success(plan.clone(), executed);
346        result.failed = failed;
347
348        if !result.failed.is_empty() {
349            result.success = false;
350            result.error = Some(format!(
351                "{} of {} trades failed",
352                result.failed.len(),
353                plan.trade_count
354            ));
355        }
356
357        Ok(result)
358    }
359
360    /// Executes a single rebalance action.
361    async fn execute_action(
362        &self,
363        account_id: &str,
364        action: &RebalanceAction,
365    ) -> ExchangeResult<()> {
366        let order = match action.direction {
367            OrderDirection::Buy => {
368                self.exchange
369                    .market_buy(account_id, &action.symbol, action.lots)
370                    .await?
371            }
372            OrderDirection::Sell => {
373                self.exchange
374                    .market_sell(account_id, &action.symbol, action.lots)
375                    .await?
376            }
377        };
378
379        // Check order status
380        match order.status() {
381            OrderStatus::Filled | OrderStatus::PartiallyFilled | OrderStatus::Submitted => Ok(()),
382            OrderStatus::Rejected => Err(ExchangeError::OrderRejected(
383                order.message().unwrap_or("Unknown reason").to_string(),
384            )),
385            status => Err(ExchangeError::UnexpectedResponse(format!(
386                "Unexpected order status: {status:?}"
387            ))),
388        }
389    }
390
391    /// Runs a complete rebalancing cycle.
392    pub async fn rebalance(
393        &mut self,
394        account_id: &str,
395        desired: &DesiredAllocation,
396    ) -> ExchangeResult<RebalanceResult> {
397        let plan = self.create_plan(account_id, desired).await?;
398        self.execute_plan(account_id, &plan).await
399    }
400}
401
402#[cfg(test)]
403mod tests {
404    use super::*;
405
406    #[test]
407    fn test_balancer_config_default() {
408        let config = BalancerConfig::default();
409        assert!(!config.dry_run);
410        assert_eq!(config.order_delay_ms, 1000);
411        assert_eq!(config.max_orders_per_cycle, 20);
412    }
413
414    #[test]
415    fn test_rebalance_result_success() {
416        let plan = RebalancePlan::new("USD");
417        let result = RebalanceResult::success(plan, Vec::new());
418        assert!(result.success);
419        assert!(result.error.is_none());
420    }
421
422    #[test]
423    fn test_rebalance_result_failure() {
424        let plan = RebalancePlan::new("USD");
425        let result = RebalanceResult::failure(plan, "Test error");
426        assert!(!result.success);
427        assert_eq!(result.error, Some("Test error".into()));
428    }
429}