trader_bot/strategy/balancer/
engine.rs1use 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#[derive(Debug, Clone)]
16pub struct BalancerConfig {
17 pub min_trade_percent: Decimal,
19 pub min_trade_value: Decimal,
21 pub tolerance_percent: Decimal,
23 pub order_delay_ms: u64,
25 pub dry_run: bool,
27 pub max_orders_per_cycle: usize,
29 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), min_trade_value: Decimal::new(100, 0), tolerance_percent: Decimal::new(5, 1), order_delay_ms: 1000, dry_run: false,
41 max_orders_per_cycle: 20,
42 min_profit_percent_for_close: None,
43 }
44 }
45}
46
47#[derive(Debug, Clone)]
49pub struct RebalanceResult {
50 pub plan: RebalancePlan,
52 pub executed: Vec<RebalanceAction>,
54 pub failed: Vec<(RebalanceAction, String)>,
56 pub success: bool,
58 pub error: Option<String>,
60}
61
62impl RebalanceResult {
63 #[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 #[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
88pub 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 #[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 #[must_use]
115 pub const fn config(&self) -> &BalancerConfig {
116 &self.config
117 }
118
119 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 pub fn clear_cache(&mut self) {
130 self.instrument_cache.clear();
131 }
132
133 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 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 let wallet = self.exchange.get_wallet(account_id).await?;
155 let currency = wallet.base_currency().to_string();
156
157 if self.calculator.is_balanced(&wallet, desired) {
159 debug!("Portfolio already balanced within tolerance");
160 return Ok(RebalancePlan::empty_with_reason(
161 ¤cy,
162 "Portfolio already balanced",
163 ));
164 }
165
166 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 ¤cy,
174 "No significant rebalancing needed",
175 ));
176 }
177
178 let mut plan = RebalancePlan::new(¤cy);
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 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 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 let market_data = self.exchange.get_market_data(&diff.symbol).await?;
225
226 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 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 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 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, }))
296 }
297
298 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 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 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 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}