1use rust_decimal::Decimal;
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9
10use crate::domain::{DesiredAllocation, Money, Wallet};
11
12#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
14pub struct AllocationDiff {
15 pub symbol: String,
17 pub current_percent: Decimal,
19 pub desired_percent: Decimal,
21 pub diff_percent: Decimal,
23 pub current_value: Decimal,
25 pub desired_value: Decimal,
27 pub diff_value: Decimal,
29}
30
31impl AllocationDiff {
32 #[must_use]
34 pub fn needs_buy(&self) -> bool {
35 self.diff_value > Decimal::ZERO
36 }
37
38 #[must_use]
40 pub fn needs_sell(&self) -> bool {
41 self.diff_value < Decimal::ZERO
42 }
43
44 #[must_use]
46 pub fn abs_diff_value(&self) -> Decimal {
47 self.diff_value.abs()
48 }
49
50 #[must_use]
52 pub fn abs_diff_percent(&self) -> Decimal {
53 self.diff_percent.abs()
54 }
55}
56
57#[derive(Debug, Clone)]
59pub struct RebalanceCalculator {
60 min_trade_percent: Decimal,
62 min_trade_value: Decimal,
64 tolerance_percent: Decimal,
66}
67
68impl Default for RebalanceCalculator {
69 fn default() -> Self {
70 Self {
71 min_trade_percent: Decimal::new(1, 1), min_trade_value: Decimal::new(1, 0), tolerance_percent: Decimal::new(5, 1), }
75 }
76}
77
78impl RebalanceCalculator {
79 #[must_use]
81 pub fn new() -> Self {
82 Self::default()
83 }
84
85 #[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 #[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 #[must_use]
101 pub const fn with_tolerance_percent(mut self, percent: Decimal) -> Self {
102 self.tolerance_percent = percent;
103 self
104 }
105
106 #[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 #[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 #[must_use]
165 pub fn sort_by_priority(&self, mut diffs: Vec<AllocationDiff>) -> Vec<AllocationDiff> {
166 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 #[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 #[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 Money::new(total, "")
203 }
204
205 #[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 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
229#[allow(dead_code)]
230pub enum AllocationMode {
231 Manual,
233 MarketCap,
235 Aum,
237 Decorrelation,
239}
240
241#[derive(Debug, Clone, Default)]
245#[allow(dead_code)]
246pub struct AssetMetrics {
247 pub data: HashMap<String, MetricValue>,
249}
250
251#[derive(Debug, Clone)]
255#[allow(dead_code)]
256pub struct MetricValue {
257 pub market_cap: Option<Decimal>,
259 pub aum: Option<Decimal>,
261}
262
263#[allow(dead_code)]
264impl AssetMetrics {
265 #[must_use]
267 pub fn new() -> Self {
268 Self::default()
269 }
270
271 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 #[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 #[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 #[must_use]
323 pub fn calculate_decorrelation_allocation(&self) -> DesiredAllocation {
324 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 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 let mut desired = DesiredAllocation::new();
392 desired.set("AAPL", dec!(30)); desired.set("GOOGL", dec!(30)); desired.set("MSFT", dec!(20)); 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)); assert_eq!(aapl_diff.desired_percent, dec!(30));
403 assert_eq!(aapl_diff.diff_percent, dec!(10)); 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)); 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), },
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), },
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), },
472 ];
473
474 let calc = RebalanceCalculator::new();
475 let sorted = calc.sort_by_priority(diffs);
476
477 assert_eq!(sorted[0].symbol, "SELL_BIG"); assert_eq!(sorted[1].symbol, "BUY_BIG"); assert_eq!(sorted[2].symbol, "BUY_SMALL"); }
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 assert_eq!(calc.calculate_lots(dec!(1000), dec!(100), 1), 10);
503
504 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 let alloc = metrics.calculate_market_cap_allocation();
517
518 assert_eq!(alloc.get("A"), Some(dec!(10))); assert_eq!(alloc.get("B"), Some(dec!(30))); assert_eq!(alloc.get("C"), Some(dec!(60))); }
522
523 #[test]
524 fn test_decorrelation_allocation() {
525 let mut metrics = AssetMetrics::new();
526 metrics.add("A", Some(dec!(2000)), Some(dec!(1000)));
528 metrics.add("B", Some(dec!(1000)), Some(dec!(2000)));
530 let alloc = metrics.calculate_decorrelation_allocation();
533
534 assert_eq!(alloc.get("A"), Some(dec!(80)));
536 assert_eq!(alloc.get("B"), Some(dec!(20)));
538 }
539}