Skip to main content

finance_query/backtesting/portfolio/
config.rs

1//! Portfolio backtest configuration.
2
3use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5
6use crate::backtesting::config::BacktestConfig;
7use crate::backtesting::error::{BacktestError, Result};
8
9/// Controls how capital is divided among symbols when opening new positions.
10#[non_exhaustive]
11#[derive(Debug, Clone, Serialize, Deserialize, Default)]
12pub enum RebalanceMode {
13    /// Use the base config's `position_size_pct` of current available cash for each trade.
14    ///
15    /// Natural "greedy" allocation — new positions are funded from whatever cash is on hand.
16    #[default]
17    AvailableCapital,
18
19    /// Divide the initial capital equally among all available position slots.
20    ///
21    /// Slot count = `max_total_positions` if set, else the number of symbols.
22    /// The per-slot target is `initial_capital / slots`, capped by available cash.
23    ///
24    /// # Important: Anchored to Initial Capital
25    ///
26    /// The allocation target is **anchored to `initial_capital`**, not to current
27    /// portfolio equity. This has two consequences:
28    ///
29    /// * **Profitable portfolios:** profits accumulate as uninvested cash — each new
30    ///   position still receives `initial_capital / slots`. Use [`AvailableCapital`]
31    ///   if you want profits to compound into new positions.
32    ///
33    /// * **Sequential positions in the same symbol:** each entry (enter, exit, re-enter)
34    ///   independently receives the full slot allocation. The `max_total_positions` cap
35    ///   controls only *concurrent* open positions, not lifetime capital per symbol.
36    ///
37    /// [`AvailableCapital`]: RebalanceMode::AvailableCapital
38    EqualWeight,
39
40    /// Custom per-symbol weight as a fraction of initial capital (0.0 – 1.0).
41    ///
42    /// Symbols not present in the map receive no allocation.
43    /// Weights do not need to sum to 1.0 — they can total less (leaving spare cash).
44    CustomWeights(HashMap<String, f64>),
45}
46
47/// Configuration for multi-symbol portfolio backtesting.
48#[non_exhaustive]
49#[derive(Debug, Clone, Serialize, Deserialize, Default)]
50pub struct PortfolioConfig {
51    /// Shared per-trade settings (commission, slippage, stop-loss, etc.)
52    pub base: BacktestConfig,
53
54    /// Maximum fraction of initial capital that can be allocated to a single symbol (0.0 – 1.0).
55    ///
56    /// `None` = no per-symbol cap (default).
57    pub max_allocation_per_symbol: Option<f64>,
58
59    /// Maximum number of concurrent open positions across all symbols.
60    ///
61    /// When the limit is reached, new entry signals are rejected until a position
62    /// closes. Signals are ranked by strength; ties are broken alphabetically.
63    /// `None` = unlimited (default).
64    pub max_total_positions: Option<usize>,
65
66    /// Capital allocation strategy when opening new positions.
67    pub rebalance: RebalanceMode,
68}
69
70impl PortfolioConfig {
71    /// Create a portfolio config wrapping the given single-symbol config.
72    pub fn new(base: BacktestConfig) -> Self {
73        Self {
74            base,
75            ..Self::default()
76        }
77    }
78
79    /// Cap the fraction of initial capital allocated to any single symbol.
80    pub fn max_allocation_per_symbol(mut self, pct: f64) -> Self {
81        self.max_allocation_per_symbol = Some(pct);
82        self
83    }
84
85    /// Limit the number of concurrent open positions across all symbols.
86    pub fn max_total_positions(mut self, max: usize) -> Self {
87        self.max_total_positions = Some(max);
88        self
89    }
90
91    /// Set the capital allocation strategy.
92    pub fn rebalance(mut self, mode: RebalanceMode) -> Self {
93        self.rebalance = mode;
94        self
95    }
96
97    /// Validate configuration constraints.
98    pub fn validate(&self, num_symbols: usize) -> Result<()> {
99        self.base.validate()?;
100
101        if let Some(cap) = self.max_allocation_per_symbol
102            && !(0.0..=1.0).contains(&cap)
103        {
104            return Err(BacktestError::invalid_param(
105                "max_allocation_per_symbol",
106                "must be between 0.0 and 1.0",
107            ));
108        }
109
110        if let RebalanceMode::CustomWeights(ref weights) = self.rebalance {
111            for (sym, &w) in weights {
112                if !(0.0..=1.0).contains(&w) {
113                    return Err(BacktestError::invalid_param(
114                        sym.as_str(),
115                        "custom weight must be between 0.0 and 1.0",
116                    ));
117                }
118            }
119        }
120
121        if num_symbols == 0 {
122            return Err(BacktestError::invalid_param(
123                "symbol_data",
124                "at least one symbol is required",
125            ));
126        }
127
128        Ok(())
129    }
130
131    /// Compute the capital target for a new position in `symbol`.
132    ///
133    /// Returns the amount of capital to commit (before position sizing / commission
134    /// adjustment). The caller must not exceed `available_cash`.
135    pub(crate) fn allocation_target(
136        &self,
137        symbol: &str,
138        available_cash: f64,
139        initial_capital: f64,
140        num_symbols: usize,
141    ) -> f64 {
142        let base = match &self.rebalance {
143            RebalanceMode::AvailableCapital => available_cash * self.base.position_size_pct,
144            RebalanceMode::EqualWeight => {
145                let slots = self
146                    .max_total_positions
147                    .unwrap_or(num_symbols)
148                    .min(num_symbols)
149                    .max(1);
150                initial_capital / slots as f64
151            }
152            RebalanceMode::CustomWeights(weights) => {
153                let weight = weights.get(symbol).copied().unwrap_or(0.0);
154                initial_capital * weight
155            }
156        };
157
158        // Apply per-symbol cap
159        let cap = self
160            .max_allocation_per_symbol
161            .map(|pct| initial_capital * pct)
162            .unwrap_or(f64::MAX);
163
164        base.min(cap).min(available_cash).max(0.0)
165    }
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171
172    #[test]
173    fn test_default_config_validates() {
174        let config = PortfolioConfig::default();
175        assert!(config.validate(1).is_ok());
176    }
177
178    #[test]
179    fn test_custom_weights_allocation() {
180        let mut weights = HashMap::new();
181        weights.insert("AAPL".to_string(), 0.5);
182        weights.insert("MSFT".to_string(), 0.3);
183        let config = PortfolioConfig::default().rebalance(RebalanceMode::CustomWeights(weights));
184
185        let target = config.allocation_target("AAPL", 10_000.0, 10_000.0, 2);
186        assert!((target - 5_000.0).abs() < 0.01);
187
188        // Unknown symbol → 0
189        let target_unknown = config.allocation_target("GOOG", 10_000.0, 10_000.0, 2);
190        assert!((target_unknown - 0.0).abs() < 0.01);
191    }
192
193    #[test]
194    fn test_max_allocation_cap() {
195        let config = PortfolioConfig::default().max_allocation_per_symbol(0.3);
196        // EqualWeight would give 50% for 2 symbols; cap should reduce to 30%
197        let config = config
198            .rebalance(RebalanceMode::EqualWeight)
199            .max_total_positions(2);
200        let target = config.allocation_target("AAPL", 10_000.0, 10_000.0, 2);
201        assert!((target - 3_000.0).abs() < 0.01, "got {target}");
202    }
203
204    #[test]
205    fn test_validation_zero_symbols() {
206        let config = PortfolioConfig::default();
207        assert!(config.validate(0).is_err());
208    }
209
210    #[test]
211    fn test_validation_invalid_cap() {
212        let config = PortfolioConfig::default().max_allocation_per_symbol(1.5);
213        assert!(config.validate(1).is_err());
214    }
215}