Skip to main content

trader_bot/domain/
allocation.rs

1//! Recursive allocation system for portfolio management.
2//!
3//! This module provides a flexible allocation system that supports:
4//! - Simple ticker-based allocations (e.g., SBER 30%, LKOH 30%, GAZP 40%)
5//! - Strategy-based allocations (e.g., 50% balancer, 30% scalper, 20% hold)
6//! - Nested/recursive allocations (balancers containing other balancers)
7//!
8//! # Links Notation Configuration
9//!
10//! ```text
11//! allocation
12//!   strategy balancer
13//!     portion 30
14//!       allocation
15//!         strategy hold
16//!         ticker SBER
17//!     portion 30
18//!       allocation
19//!         strategy hold
20//!         ticker LKOH
21//!     portion 40
22//!       allocation
23//!         strategy hold
24//!         ticker GAZP
25//! ```
26//!
27//! This is equivalent to the short notation:
28//!
29//! ```text
30//! allocation
31//!   SBER 30
32//!   LKOH 30
33//!   GAZP 40
34//! ```
35
36use rust_decimal::Decimal;
37use serde::{Deserialize, Serialize};
38use std::collections::HashMap;
39
40/// The type of allocation strategy.
41#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
42#[serde(rename_all = "snake_case")]
43pub enum StrategyType {
44    /// Balancer strategy - maintains target allocations across sub-allocations.
45    #[default]
46    Balancer,
47    /// Hold strategy - maintains a position in a single ticker.
48    Hold,
49    /// Scalper strategy - buy-low/sell-high with FIFO tracking.
50    Scalper,
51    /// DCA (Dollar Cost Average) strategy - regular purchases.
52    Dca,
53    /// Custom strategy with a name.
54    Custom(String),
55}
56
57impl StrategyType {
58    /// Creates a strategy type from a string name.
59    #[must_use]
60    pub fn from_name(name: &str) -> Self {
61        match name.to_lowercase().as_str() {
62            "balancer" | "balance" | "rebalance" => Self::Balancer,
63            "hold" | "holding" | "hodl" => Self::Hold,
64            "scalp" | "scalper" | "scalping" => Self::Scalper,
65            "dca" | "dollar_cost_average" => Self::Dca,
66            _ => Self::Custom(name.to_string()),
67        }
68    }
69
70    /// Returns the strategy name as a string.
71    #[must_use]
72    pub fn as_str(&self) -> &str {
73        match self {
74            Self::Balancer => "balancer",
75            Self::Hold => "hold",
76            Self::Scalper => "scalper",
77            Self::Dca => "dca",
78            Self::Custom(name) => name,
79        }
80    }
81}
82
83/// A portion of an allocation with its own strategy.
84#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
85pub struct AllocationPortion {
86    /// The percentage of the parent allocation (0-100).
87    pub percentage: Decimal,
88    /// The allocation details for this portion.
89    pub allocation: Allocation,
90}
91
92impl AllocationPortion {
93    /// Creates a new allocation portion.
94    #[must_use]
95    pub fn new(percentage: Decimal, allocation: Allocation) -> Self {
96        Self {
97            percentage,
98            allocation,
99        }
100    }
101
102    /// Creates a simple hold portion for a ticker.
103    #[must_use]
104    pub fn hold(ticker: impl Into<String>, percentage: Decimal) -> Self {
105        Self {
106            percentage,
107            allocation: Allocation::hold(ticker),
108        }
109    }
110}
111
112/// A recursive allocation structure supporting nested strategies.
113///
114/// An allocation can be:
115/// 1. A simple ticker hold (ticker + optional strategy)
116/// 2. A strategy with portions (balancer managing sub-allocations)
117/// 3. A mix of both
118#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
119pub struct Allocation {
120    /// The strategy to use for this allocation.
121    #[serde(default)]
122    pub strategy: StrategyType,
123
124    /// The ticker symbol (for hold/scalper strategies).
125    #[serde(skip_serializing_if = "Option::is_none")]
126    pub ticker: Option<String>,
127
128    /// Child portions (for balancer strategy).
129    #[serde(default, skip_serializing_if = "Vec::is_empty")]
130    pub portions: Vec<AllocationPortion>,
131
132    /// Strategy-specific parameters.
133    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
134    pub params: HashMap<String, String>,
135}
136
137impl Allocation {
138    /// Creates a new empty allocation with default balancer strategy.
139    #[must_use]
140    pub fn new() -> Self {
141        Self::default()
142    }
143
144    /// Creates a new balancer allocation with portions.
145    #[must_use]
146    pub fn balancer(portions: Vec<AllocationPortion>) -> Self {
147        Self {
148            strategy: StrategyType::Balancer,
149            ticker: None,
150            portions,
151            params: HashMap::new(),
152        }
153    }
154
155    /// Creates a simple hold allocation for a ticker.
156    #[must_use]
157    pub fn hold(ticker: impl Into<String>) -> Self {
158        Self {
159            strategy: StrategyType::Hold,
160            ticker: Some(ticker.into()),
161            portions: Vec::new(),
162            params: HashMap::new(),
163        }
164    }
165
166    /// Creates a scalper allocation for a ticker.
167    #[must_use]
168    pub fn scalper(ticker: impl Into<String>) -> Self {
169        Self {
170            strategy: StrategyType::Scalper,
171            ticker: Some(ticker.into()),
172            portions: Vec::new(),
173            params: HashMap::new(),
174        }
175    }
176
177    /// Creates a simple allocation from ticker-percentage pairs.
178    ///
179    /// This is the "short notation" equivalent where:
180    /// ```text
181    /// allocation
182    ///   SBER 30
183    ///   LKOH 30
184    ///   GAZP 40
185    /// ```
186    ///
187    /// Becomes a balancer with hold portions.
188    #[must_use]
189    pub fn from_simple(allocations: &[(String, Decimal)]) -> Self {
190        let portions = allocations
191            .iter()
192            .map(|(ticker, pct)| AllocationPortion::hold(ticker, *pct))
193            .collect();
194
195        Self::balancer(portions)
196    }
197
198    /// Creates an allocation from a HashMap of ticker -> percentage.
199    #[must_use]
200    pub fn from_map(allocations: HashMap<String, Decimal>) -> Self {
201        let portions = allocations
202            .into_iter()
203            .map(|(ticker, pct)| AllocationPortion::hold(ticker, pct))
204            .collect();
205
206        Self::balancer(portions)
207    }
208
209    /// Sets a strategy parameter.
210    pub fn set_param(&mut self, key: impl Into<String>, value: impl Into<String>) {
211        self.params.insert(key.into(), value.into());
212    }
213
214    /// Gets a strategy parameter.
215    #[must_use]
216    pub fn get_param(&self, key: &str) -> Option<&str> {
217        self.params.get(key).map(|s| s.as_str())
218    }
219
220    /// Adds a portion to the allocation.
221    pub fn add_portion(&mut self, portion: AllocationPortion) {
222        self.portions.push(portion);
223    }
224
225    /// Returns true if this is a simple hold strategy.
226    #[must_use]
227    pub fn is_hold(&self) -> bool {
228        matches!(self.strategy, StrategyType::Hold) && self.ticker.is_some()
229    }
230
231    /// Returns true if this is a balancer strategy.
232    #[must_use]
233    pub fn is_balancer(&self) -> bool {
234        matches!(self.strategy, StrategyType::Balancer) && !self.portions.is_empty()
235    }
236
237    /// Returns all tickers involved in this allocation (recursively).
238    #[must_use]
239    pub fn all_tickers(&self) -> Vec<String> {
240        let mut tickers = Vec::new();
241        self.collect_tickers(&mut tickers);
242        tickers
243    }
244
245    fn collect_tickers(&self, tickers: &mut Vec<String>) {
246        if let Some(ref ticker) = self.ticker {
247            if !tickers.contains(ticker) {
248                tickers.push(ticker.clone());
249            }
250        }
251        for portion in &self.portions {
252            portion.allocation.collect_tickers(tickers);
253        }
254    }
255
256    /// Calculates the total percentage of all portions.
257    #[must_use]
258    pub fn total_percentage(&self) -> Decimal {
259        self.portions.iter().map(|p| p.percentage).sum()
260    }
261
262    /// Validates that portions sum to approximately 100%.
263    #[must_use]
264    pub fn is_valid(&self) -> bool {
265        if self.portions.is_empty() {
266            // Simple hold/scalper allocation is always valid if it has a ticker
267            return self.ticker.is_some();
268        }
269
270        let total = self.total_percentage();
271        let diff = (total - Decimal::from(100)).abs();
272        diff < Decimal::ONE // Allow 1% tolerance
273    }
274
275    /// Normalizes portions to sum to exactly 100%.
276    pub fn normalize(&mut self) {
277        let total = self.total_percentage();
278        if total.is_zero() {
279            return;
280        }
281
282        let factor = Decimal::from(100) / total;
283        for portion in &mut self.portions {
284            portion.percentage *= factor;
285        }
286    }
287
288    /// Flattens the allocation to a simple ticker -> percentage map.
289    ///
290    /// This recursively expands all nested allocations and calculates
291    /// the effective percentage for each ticker.
292    #[must_use]
293    pub fn flatten(&self) -> HashMap<String, Decimal> {
294        let mut result = HashMap::new();
295        self.flatten_recursive(Decimal::from(100), &mut result);
296        result
297    }
298
299    fn flatten_recursive(&self, parent_pct: Decimal, result: &mut HashMap<String, Decimal>) {
300        // If this is a simple hold/scalper, add the ticker
301        if let Some(ref ticker) = self.ticker {
302            let entry = result.entry(ticker.clone()).or_insert(Decimal::ZERO);
303            *entry += parent_pct;
304            return;
305        }
306
307        // Otherwise, process portions
308        for portion in &self.portions {
309            let effective_pct = parent_pct * portion.percentage / Decimal::from(100);
310            portion.allocation.flatten_recursive(effective_pct, result);
311        }
312    }
313}
314
315/// Converts a `DesiredAllocation` to the new `Allocation` format.
316impl From<super::DesiredAllocation> for Allocation {
317    fn from(desired: super::DesiredAllocation) -> Self {
318        let map: HashMap<String, Decimal> = desired.iter().map(|(k, v)| (k.clone(), *v)).collect();
319        Self::from_map(map)
320    }
321}
322
323#[cfg(test)]
324mod tests {
325    use super::*;
326    use rust_decimal_macros::dec;
327
328    #[test]
329    fn test_simple_allocation() {
330        let alloc = Allocation::from_simple(&[
331            ("SBER".to_string(), dec!(30)),
332            ("LKOH".to_string(), dec!(30)),
333            ("GAZP".to_string(), dec!(40)),
334        ]);
335
336        assert!(alloc.is_balancer());
337        assert!(alloc.is_valid());
338        assert_eq!(alloc.portions.len(), 3);
339
340        let flat = alloc.flatten();
341        assert_eq!(flat.get("SBER"), Some(&dec!(30)));
342        assert_eq!(flat.get("LKOH"), Some(&dec!(30)));
343        assert_eq!(flat.get("GAZP"), Some(&dec!(40)));
344    }
345
346    #[test]
347    fn test_hold_allocation() {
348        let alloc = Allocation::hold("BTC");
349        assert!(alloc.is_hold());
350        assert_eq!(alloc.ticker, Some("BTC".to_string()));
351        assert_eq!(alloc.all_tickers(), vec!["BTC".to_string()]);
352    }
353
354    #[test]
355    fn test_nested_allocation() {
356        // Create nested allocation:
357        // 60% to stocks (SBER 50%, LKOH 50%)
358        // 40% to crypto (BTC 70%, ETH 30%)
359        let stocks = Allocation::balancer(vec![
360            AllocationPortion::hold("SBER", dec!(50)),
361            AllocationPortion::hold("LKOH", dec!(50)),
362        ]);
363
364        let crypto = Allocation::balancer(vec![
365            AllocationPortion::hold("BTC", dec!(70)),
366            AllocationPortion::hold("ETH", dec!(30)),
367        ]);
368
369        let root = Allocation::balancer(vec![
370            AllocationPortion::new(dec!(60), stocks),
371            AllocationPortion::new(dec!(40), crypto),
372        ]);
373
374        assert!(root.is_valid());
375
376        let flat = root.flatten();
377        // SBER: 60% * 50% = 30%
378        // LKOH: 60% * 50% = 30%
379        // BTC: 40% * 70% = 28%
380        // ETH: 40% * 30% = 12%
381        assert_eq!(flat.get("SBER"), Some(&dec!(30)));
382        assert_eq!(flat.get("LKOH"), Some(&dec!(30)));
383        assert_eq!(flat.get("BTC"), Some(&dec!(28)));
384        assert_eq!(flat.get("ETH"), Some(&dec!(12)));
385
386        let all_tickers = root.all_tickers();
387        assert_eq!(all_tickers.len(), 4);
388    }
389
390    #[test]
391    fn test_strategy_types() {
392        assert_eq!(StrategyType::from_name("balancer"), StrategyType::Balancer);
393        assert_eq!(StrategyType::from_name("HOLD"), StrategyType::Hold);
394        assert_eq!(StrategyType::from_name("scalper"), StrategyType::Scalper);
395        assert_eq!(
396            StrategyType::from_name("custom_strat"),
397            StrategyType::Custom("custom_strat".to_string())
398        );
399    }
400
401    #[test]
402    fn test_normalize_allocation() {
403        let mut alloc = Allocation::balancer(vec![
404            AllocationPortion::hold("A", dec!(30)),
405            AllocationPortion::hold("B", dec!(20)),
406        ]);
407
408        // Total is 50%, normalize to 100%
409        alloc.normalize();
410
411        assert_eq!(alloc.portions[0].percentage, dec!(60)); // 30/50 * 100
412        assert_eq!(alloc.portions[1].percentage, dec!(40)); // 20/50 * 100
413    }
414
415    #[test]
416    fn test_deeply_nested_allocation() {
417        // Test 3-level nesting
418        let level3 = Allocation::balancer(vec![
419            AllocationPortion::hold("X", dec!(50)),
420            AllocationPortion::hold("Y", dec!(50)),
421        ]);
422
423        let level2 = Allocation::balancer(vec![AllocationPortion::new(dec!(100), level3)]);
424
425        let root = Allocation::balancer(vec![
426            AllocationPortion::new(dec!(50), level2),
427            AllocationPortion::hold("Z", dec!(50)),
428        ]);
429
430        let flat = root.flatten();
431        // X: 50% * 100% * 50% = 25%
432        // Y: 50% * 100% * 50% = 25%
433        // Z: 50%
434        assert_eq!(flat.get("X"), Some(&dec!(25)));
435        assert_eq!(flat.get("Y"), Some(&dec!(25)));
436        assert_eq!(flat.get("Z"), Some(&dec!(50)));
437    }
438}