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 notional target for a new position in `symbol`.
132    ///
133    /// `available` is the portfolio's remaining buying power (equity times
134    /// leverage minus gross exposure) and caps the result. `fraction` is the
135    /// active sizing scheme's fraction for this entry, at most the risk budget
136    /// (`position_size_pct * max_leverage`); [`AvailableCapital`] applies it to
137    /// the unlevered share of buying power, the anchored modes scale their slot
138    /// by the fraction's share of the budget (a no-op for `FixedFraction`).
139    ///
140    /// [`AvailableCapital`]: RebalanceMode::AvailableCapital
141    pub(crate) fn allocation_target(
142        &self,
143        symbol: &str,
144        available: f64,
145        initial_capital: f64,
146        num_symbols: usize,
147        fraction: f64,
148    ) -> f64 {
149        let leverage = self.base.max_leverage;
150        let budget = self.base.position_size_pct * leverage;
151        let utilization = if budget > 0.0 { fraction / budget } else { 0.0 };
152        let base = match &self.rebalance {
153            RebalanceMode::AvailableCapital => available * fraction / leverage,
154            RebalanceMode::EqualWeight => {
155                let slots = self
156                    .max_total_positions
157                    .unwrap_or(num_symbols)
158                    .min(num_symbols)
159                    .max(1);
160                initial_capital / slots as f64 * utilization
161            }
162            RebalanceMode::CustomWeights(weights) => {
163                let weight = weights.get(symbol).copied().unwrap_or(0.0);
164                initial_capital * weight * utilization
165            }
166        };
167
168        // Apply per-symbol cap
169        let cap = self
170            .max_allocation_per_symbol
171            .map(|pct| initial_capital * pct)
172            .unwrap_or(f64::MAX);
173
174        base.min(cap).min(available).max(0.0)
175    }
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181    use crate::backtesting::config::PositionSizing;
182
183    #[test]
184    fn test_default_config_validates() {
185        let config = PortfolioConfig::default();
186        assert!(config.validate(1).is_ok());
187    }
188
189    #[test]
190    fn test_levered_financed_and_sized_bases_validate() {
191        let with_base = |base: BacktestConfig| PortfolioConfig::new(base).validate(1);
192
193        let levered = BacktestConfig::builder().max_leverage(2.0).build().unwrap();
194        assert!(with_base(levered).is_ok());
195
196        let financed = BacktestConfig::builder()
197            .short_borrow_rate(0.05)
198            .build()
199            .unwrap();
200        assert!(with_base(financed).is_ok());
201
202        let sized = BacktestConfig::builder()
203            .position_sizing(PositionSizing::VolatilityTarget {
204                target_vol_pct: 0.01,
205                lookback: 20,
206            })
207            .build()
208            .unwrap();
209        assert!(with_base(sized).is_ok());
210
211        assert!(with_base(BacktestConfig::default()).is_ok());
212    }
213
214    #[test]
215    fn test_custom_weights_allocation() {
216        let mut weights = HashMap::new();
217        weights.insert("AAPL".to_string(), 0.5);
218        weights.insert("MSFT".to_string(), 0.3);
219        let config = PortfolioConfig::default().rebalance(RebalanceMode::CustomWeights(weights));
220
221        let target = config.allocation_target("AAPL", 10_000.0, 10_000.0, 2, 1.0);
222        assert!((target - 5_000.0).abs() < 0.01);
223
224        // Unknown symbol → 0
225        let target_unknown = config.allocation_target("GOOG", 10_000.0, 10_000.0, 2, 1.0);
226        assert!((target_unknown - 0.0).abs() < 0.01);
227    }
228
229    #[test]
230    fn test_max_allocation_cap() {
231        let config = PortfolioConfig::default().max_allocation_per_symbol(0.3);
232        // EqualWeight would give 50% for 2 symbols; cap should reduce to 30%
233        let config = config
234            .rebalance(RebalanceMode::EqualWeight)
235            .max_total_positions(2);
236        let target = config.allocation_target("AAPL", 10_000.0, 10_000.0, 2, 1.0);
237        assert!((target - 3_000.0).abs() < 0.01, "got {target}");
238    }
239
240    #[test]
241    fn test_levered_available_capital_target() {
242        let base = BacktestConfig::builder().max_leverage(2.0).build().unwrap();
243        let config = PortfolioConfig::new(base);
244        // Buying power 20k at 2x on 10k equity; full budget fraction 2.0
245        // commits the whole levered notional.
246        let target = config.allocation_target("AAPL", 20_000.0, 10_000.0, 1, 2.0);
247        assert!((target - 20_000.0).abs() < 0.01, "got {target}");
248        // A scheme asking half the budget commits half.
249        let half = config.allocation_target("AAPL", 20_000.0, 10_000.0, 1, 1.0);
250        assert!((half - 10_000.0).abs() < 0.01, "got {half}");
251    }
252
253    #[test]
254    fn test_validation_zero_symbols() {
255        let config = PortfolioConfig::default();
256        assert!(config.validate(0).is_err());
257    }
258
259    #[test]
260    fn test_validation_invalid_cap() {
261        let config = PortfolioConfig::default().max_allocation_per_symbol(1.5);
262        assert!(config.validate(1).is_err());
263    }
264}