Skip to main content

trader_bot/strategy/balancer/
calculator.rs

1//! Rebalance calculation logic.
2//!
3//! This module contains the pure calculation logic for determining what
4//! trades need to be made to rebalance a portfolio.
5
6use rust_decimal::Decimal;
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9
10use crate::domain::{DesiredAllocation, Money, Wallet};
11
12/// Represents the difference between current and desired allocation for a symbol.
13#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
14pub struct AllocationDiff {
15    /// The trading symbol.
16    pub symbol: String,
17    /// Current percentage allocation (0-100).
18    pub current_percent: Decimal,
19    /// Desired percentage allocation (0-100).
20    pub desired_percent: Decimal,
21    /// Difference (desired - current).
22    pub diff_percent: Decimal,
23    /// Current value in base currency.
24    pub current_value: Decimal,
25    /// Desired value in base currency.
26    pub desired_value: Decimal,
27    /// Value difference (desired - current).
28    pub diff_value: Decimal,
29}
30
31impl AllocationDiff {
32    /// Returns true if we need to buy this asset.
33    #[must_use]
34    pub fn needs_buy(&self) -> bool {
35        self.diff_value > Decimal::ZERO
36    }
37
38    /// Returns true if we need to sell this asset.
39    #[must_use]
40    pub fn needs_sell(&self) -> bool {
41        self.diff_value < Decimal::ZERO
42    }
43
44    /// Returns the absolute value difference.
45    #[must_use]
46    pub fn abs_diff_value(&self) -> Decimal {
47        self.diff_value.abs()
48    }
49
50    /// Returns the absolute percentage difference.
51    #[must_use]
52    pub fn abs_diff_percent(&self) -> Decimal {
53        self.diff_percent.abs()
54    }
55}
56
57/// Calculator for portfolio rebalancing.
58#[derive(Debug, Clone)]
59pub struct RebalanceCalculator {
60    /// Minimum trade size as a percentage of portfolio value.
61    min_trade_percent: Decimal,
62    /// Minimum trade size in absolute value.
63    min_trade_value: Decimal,
64    /// Tolerance for considering allocation "close enough" (percentage).
65    tolerance_percent: Decimal,
66}
67
68impl Default for RebalanceCalculator {
69    fn default() -> Self {
70        Self {
71            min_trade_percent: Decimal::new(1, 1), // 0.1%
72            min_trade_value: Decimal::new(1, 0),   // 1 unit of currency
73            tolerance_percent: Decimal::new(5, 1), // 0.5%
74        }
75    }
76}
77
78impl RebalanceCalculator {
79    /// Creates a new calculator with default settings.
80    #[must_use]
81    pub fn new() -> Self {
82        Self::default()
83    }
84
85    /// Sets the minimum trade percentage.
86    #[must_use]
87    pub const fn with_min_trade_percent(mut self, percent: Decimal) -> Self {
88        self.min_trade_percent = percent;
89        self
90    }
91
92    /// Sets the minimum trade value.
93    #[must_use]
94    pub const fn with_min_trade_value(mut self, value: Decimal) -> Self {
95        self.min_trade_value = value;
96        self
97    }
98
99    /// Sets the tolerance percentage.
100    #[must_use]
101    pub const fn with_tolerance_percent(mut self, percent: Decimal) -> Self {
102        self.tolerance_percent = percent;
103        self
104    }
105
106    /// Calculates the allocation differences between current and desired allocations.
107    #[must_use]
108    pub fn calculate_diffs(
109        &self,
110        wallet: &Wallet,
111        desired: &DesiredAllocation,
112    ) -> Vec<AllocationDiff> {
113        let total_value = wallet.total_value().amount();
114        if total_value.is_zero() {
115            return Vec::new();
116        }
117
118        let current_alloc = wallet.current_allocation();
119        let mut all_symbols: Vec<String> = current_alloc
120            .symbols()
121            .chain(desired.symbols())
122            .cloned()
123            .collect();
124        all_symbols.sort();
125        all_symbols.dedup();
126
127        all_symbols
128            .into_iter()
129            .map(|symbol| {
130                let current_pct = current_alloc.get(&symbol).unwrap_or(Decimal::ZERO);
131                let desired_pct = desired.get(&symbol).unwrap_or(Decimal::ZERO);
132                let diff_pct = desired_pct - current_pct;
133
134                let current_val = total_value * current_pct / Decimal::from(100);
135                let desired_val = total_value * desired_pct / Decimal::from(100);
136                let diff_val = desired_val - current_val;
137
138                AllocationDiff {
139                    symbol,
140                    current_percent: current_pct,
141                    desired_percent: desired_pct,
142                    diff_percent: diff_pct,
143                    current_value: current_val,
144                    desired_value: desired_val,
145                    diff_value: diff_val,
146                }
147            })
148            .collect()
149    }
150
151    /// Filters diffs to only include those that need rebalancing.
152    #[must_use]
153    pub fn filter_significant_diffs(&self, diffs: Vec<AllocationDiff>) -> Vec<AllocationDiff> {
154        diffs
155            .into_iter()
156            .filter(|d| {
157                d.abs_diff_percent() > self.tolerance_percent
158                    && d.abs_diff_value() >= self.min_trade_value
159            })
160            .collect()
161    }
162
163    /// Sorts diffs by priority (sells first, then buys, ordered by magnitude).
164    #[must_use]
165    pub fn sort_by_priority(&self, mut diffs: Vec<AllocationDiff>) -> Vec<AllocationDiff> {
166        // Sells come first (to free up cash), then buys
167        // Within each category, larger absolute differences first
168        diffs.sort_by(|a, b| {
169            let a_is_sell = a.needs_sell();
170            let b_is_sell = b.needs_sell();
171
172            match (a_is_sell, b_is_sell) {
173                (true, false) => std::cmp::Ordering::Less,
174                (false, true) => std::cmp::Ordering::Greater,
175                _ => b.abs_diff_value().cmp(&a.abs_diff_value()),
176            }
177        });
178        diffs
179    }
180
181    /// Calculates the number of lots to trade for a given value difference.
182    #[must_use]
183    pub fn calculate_lots(
184        &self,
185        diff_value: Decimal,
186        price_per_lot: Decimal,
187        _lot_size: u32,
188    ) -> u32 {
189        if price_per_lot.is_zero() {
190            return 0;
191        }
192
193        let lots = (diff_value.abs() / price_per_lot).floor();
194        lots.to_string().parse().unwrap_or(0)
195    }
196
197    /// Estimates the total cost of executing all trades.
198    #[must_use]
199    pub fn estimate_total_trade_value(&self, diffs: &[AllocationDiff]) -> Money {
200        let total: Decimal = diffs.iter().map(|d| d.abs_diff_value()).sum();
201        // We use an empty string for currency since this is abstract
202        Money::new(total, "")
203    }
204
205    /// Checks if the portfolio is balanced within tolerance.
206    #[must_use]
207    pub fn is_balanced(&self, wallet: &Wallet, desired: &DesiredAllocation) -> bool {
208        let diffs = self.calculate_diffs(wallet, desired);
209        let significant = self.filter_significant_diffs(diffs);
210        significant.is_empty()
211    }
212
213    /// Calculates the maximum deviation from desired allocation.
214    #[must_use]
215    pub fn max_deviation(&self, wallet: &Wallet, desired: &DesiredAllocation) -> Decimal {
216        let diffs = self.calculate_diffs(wallet, desired);
217        diffs
218            .iter()
219            .map(|d| d.abs_diff_percent())
220            .max()
221            .unwrap_or(Decimal::ZERO)
222    }
223}
224
225/// Builds desired allocation based on different modes.
226///
227/// This enum is prepared for future use to support multiple allocation strategies.
228#[derive(Debug, Clone, Copy, PartialEq, Eq)]
229#[allow(dead_code)]
230pub enum AllocationMode {
231    /// Manual allocation with fixed percentages.
232    Manual,
233    /// Allocation based on market capitalization.
234    MarketCap,
235    /// Allocation based on assets under management.
236    Aum,
237    /// Decorrelation strategy (diff between market cap and AUM).
238    Decorrelation,
239}
240
241/// Metric data for automatic allocation calculation.
242///
243/// This struct is prepared for future use to support automatic allocation strategies.
244#[derive(Debug, Clone, Default)]
245#[allow(dead_code)]
246pub struct AssetMetrics {
247    /// Market data by symbol.
248    pub data: HashMap<String, MetricValue>,
249}
250
251/// Metrics for a single asset.
252///
253/// This struct is prepared for future use with automatic allocation strategies.
254#[derive(Debug, Clone)]
255#[allow(dead_code)]
256pub struct MetricValue {
257    /// Market capitalization.
258    pub market_cap: Option<Decimal>,
259    /// Assets under management.
260    pub aum: Option<Decimal>,
261}
262
263#[allow(dead_code)]
264impl AssetMetrics {
265    /// Creates a new empty metrics collection.
266    #[must_use]
267    pub fn new() -> Self {
268        Self::default()
269    }
270
271    /// Adds metrics for a symbol.
272    pub fn add(
273        &mut self,
274        symbol: impl Into<String>,
275        market_cap: Option<Decimal>,
276        aum: Option<Decimal>,
277    ) {
278        self.data
279            .insert(symbol.into(), MetricValue { market_cap, aum });
280    }
281
282    /// Calculates allocation based on market cap.
283    #[must_use]
284    pub fn calculate_market_cap_allocation(&self) -> DesiredAllocation {
285        let total: Decimal = self.data.values().filter_map(|m| m.market_cap).sum();
286
287        if total.is_zero() {
288            return DesiredAllocation::new();
289        }
290
291        let mut alloc = DesiredAllocation::new();
292        for (symbol, metrics) in &self.data {
293            if let Some(cap) = metrics.market_cap {
294                let pct = cap / total * Decimal::from(100);
295                alloc.set(symbol, pct);
296            }
297        }
298        alloc
299    }
300
301    /// Calculates allocation based on AUM.
302    #[must_use]
303    pub fn calculate_aum_allocation(&self) -> DesiredAllocation {
304        let total: Decimal = self.data.values().filter_map(|m| m.aum).sum();
305
306        if total.is_zero() {
307            return DesiredAllocation::new();
308        }
309
310        let mut alloc = DesiredAllocation::new();
311        for (symbol, metrics) in &self.data {
312            if let Some(aum) = metrics.aum {
313                let pct = aum / total * Decimal::from(100);
314                alloc.set(symbol, pct);
315            }
316        }
317        alloc
318    }
319
320    /// Calculates decorrelation-based allocation.
321    /// This favors assets where market cap > AUM (potentially undervalued).
322    #[must_use]
323    pub fn calculate_decorrelation_allocation(&self) -> DesiredAllocation {
324        // Calculate decorrelation scores (market_cap / aum ratio)
325        let mut scores: HashMap<String, Decimal> = HashMap::new();
326
327        for (symbol, metrics) in &self.data {
328            if let (Some(cap), Some(aum)) = (metrics.market_cap, metrics.aum) {
329                if !aum.is_zero() {
330                    // Higher score = market cap is larger relative to AUM
331                    let score = cap / aum;
332                    scores.insert(symbol.clone(), score);
333                }
334            }
335        }
336
337        if scores.is_empty() {
338            return DesiredAllocation::new();
339        }
340
341        let total: Decimal = scores.values().copied().sum();
342        let mut alloc = DesiredAllocation::new();
343
344        for (symbol, score) in scores {
345            let pct = score / total * Decimal::from(100);
346            alloc.set(&symbol, pct);
347        }
348
349        alloc
350    }
351}
352
353#[cfg(test)]
354mod tests {
355    use super::*;
356    use crate::domain::Position;
357    use rust_decimal_macros::dec;
358
359    fn make_usd(amount: Decimal) -> Money {
360        Money::new(amount, "USD")
361    }
362
363    fn create_test_wallet() -> Wallet {
364        let mut wallet = Wallet::with_cash("USD", make_usd(dec!(1000)));
365        wallet.add_position(Position::new(
366            "AAPL",
367            "NASDAQ",
368            dec!(10),
369            make_usd(dec!(100)),
370        ));
371        wallet.add_position(Position::new(
372            "GOOGL",
373            "NASDAQ",
374            dec!(10),
375            make_usd(dec!(200)),
376        ));
377        wallet.add_position(Position::new(
378            "MSFT",
379            "NASDAQ",
380            dec!(10),
381            make_usd(dec!(100)),
382        ));
383        wallet
384    }
385
386    #[test]
387    fn test_calculate_diffs() {
388        let wallet = create_test_wallet();
389        // Total = 1000 cash + 1000 AAPL + 2000 GOOGL + 1000 MSFT = 5000
390
391        let mut desired = DesiredAllocation::new();
392        desired.set("AAPL", dec!(30)); // Want 30%, have 20%
393        desired.set("GOOGL", dec!(30)); // Want 30%, have 40%
394        desired.set("MSFT", dec!(20)); // Want 20%, have 20%
395                                       // Cash stays at 20%
396
397        let calc = RebalanceCalculator::new();
398        let diffs = calc.calculate_diffs(&wallet, &desired);
399
400        let aapl_diff = diffs.iter().find(|d| d.symbol == "AAPL").unwrap();
401        assert_eq!(aapl_diff.current_percent, dec!(20)); // 1000/5000 = 20%
402        assert_eq!(aapl_diff.desired_percent, dec!(30));
403        assert_eq!(aapl_diff.diff_percent, dec!(10)); // Need to increase by 10%
404        assert!(aapl_diff.needs_buy());
405
406        let googl_diff = diffs.iter().find(|d| d.symbol == "GOOGL").unwrap();
407        assert_eq!(googl_diff.current_percent, dec!(40)); // 2000/5000 = 40%
408        assert_eq!(googl_diff.desired_percent, dec!(30));
409        assert!(googl_diff.needs_sell());
410    }
411
412    #[test]
413    fn test_filter_significant_diffs() {
414        let diffs = vec![
415            AllocationDiff {
416                symbol: "A".into(),
417                current_percent: dec!(10),
418                desired_percent: dec!(10.3),
419                diff_percent: dec!(0.3),
420                current_value: dec!(1000),
421                desired_value: dec!(1030),
422                diff_value: dec!(30),
423            },
424            AllocationDiff {
425                symbol: "B".into(),
426                current_percent: dec!(20),
427                desired_percent: dec!(25),
428                diff_percent: dec!(5),
429                current_value: dec!(2000),
430                desired_value: dec!(2500),
431                diff_value: dec!(500),
432            },
433        ];
434
435        let calc = RebalanceCalculator::new();
436        let significant = calc.filter_significant_diffs(diffs);
437
438        assert_eq!(significant.len(), 1);
439        assert_eq!(significant[0].symbol, "B");
440    }
441
442    #[test]
443    fn test_sort_by_priority() {
444        let diffs = vec![
445            AllocationDiff {
446                symbol: "BUY_SMALL".into(),
447                current_percent: dec!(10),
448                desired_percent: dec!(15),
449                diff_percent: dec!(5),
450                current_value: dec!(1000),
451                desired_value: dec!(1500),
452                diff_value: dec!(500), // Buy
453            },
454            AllocationDiff {
455                symbol: "SELL_BIG".into(),
456                current_percent: dec!(30),
457                desired_percent: dec!(20),
458                diff_percent: dec!(-10),
459                current_value: dec!(3000),
460                desired_value: dec!(2000),
461                diff_value: dec!(-1000), // Sell
462            },
463            AllocationDiff {
464                symbol: "BUY_BIG".into(),
465                current_percent: dec!(5),
466                desired_percent: dec!(20),
467                diff_percent: dec!(15),
468                current_value: dec!(500),
469                desired_value: dec!(2000),
470                diff_value: dec!(1500), // Buy
471            },
472        ];
473
474        let calc = RebalanceCalculator::new();
475        let sorted = calc.sort_by_priority(diffs);
476
477        // Sells first, then buys ordered by size
478        assert_eq!(sorted[0].symbol, "SELL_BIG"); // Only sell
479        assert_eq!(sorted[1].symbol, "BUY_BIG"); // Larger buy
480        assert_eq!(sorted[2].symbol, "BUY_SMALL"); // Smaller buy
481    }
482
483    #[test]
484    fn test_is_balanced() {
485        let mut wallet = Wallet::with_cash("USD", make_usd(dec!(0)));
486        wallet.add_position(Position::new("A", "X", dec!(50), make_usd(dec!(100))));
487        wallet.add_position(Position::new("B", "X", dec!(50), make_usd(dec!(100))));
488
489        let mut desired = DesiredAllocation::new();
490        desired.set("A", dec!(50));
491        desired.set("B", dec!(50));
492
493        let calc = RebalanceCalculator::new();
494        assert!(calc.is_balanced(&wallet, &desired));
495    }
496
497    #[test]
498    fn test_calculate_lots() {
499        let calc = RebalanceCalculator::new();
500
501        // If we need to buy 1000 USD worth, and price per lot is 100, we need 10 lots
502        assert_eq!(calc.calculate_lots(dec!(1000), dec!(100), 1), 10);
503
504        // Partial lots are floored
505        assert_eq!(calc.calculate_lots(dec!(550), dec!(100), 1), 5);
506    }
507
508    #[test]
509    fn test_asset_metrics_market_cap_allocation() {
510        let mut metrics = AssetMetrics::new();
511        metrics.add("A", Some(dec!(1000)), None);
512        metrics.add("B", Some(dec!(3000)), None);
513        metrics.add("C", Some(dec!(6000)), None);
514        // Total = 10000
515
516        let alloc = metrics.calculate_market_cap_allocation();
517
518        assert_eq!(alloc.get("A"), Some(dec!(10))); // 1000/10000 = 10%
519        assert_eq!(alloc.get("B"), Some(dec!(30))); // 3000/10000 = 30%
520        assert_eq!(alloc.get("C"), Some(dec!(60))); // 6000/10000 = 60%
521    }
522
523    #[test]
524    fn test_decorrelation_allocation() {
525        let mut metrics = AssetMetrics::new();
526        // A: market cap / AUM = 2.0 (undervalued by AUM)
527        metrics.add("A", Some(dec!(2000)), Some(dec!(1000)));
528        // B: market cap / AUM = 0.5 (overvalued by AUM)
529        metrics.add("B", Some(dec!(1000)), Some(dec!(2000)));
530        // Total ratio = 2.5
531
532        let alloc = metrics.calculate_decorrelation_allocation();
533
534        // A should get 2.0/2.5 = 80%
535        assert_eq!(alloc.get("A"), Some(dec!(80)));
536        // B should get 0.5/2.5 = 20%
537        assert_eq!(alloc.get("B"), Some(dec!(20)));
538    }
539}