finance_query/backtesting/portfolio/
config.rs1use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5
6use crate::backtesting::config::BacktestConfig;
7use crate::backtesting::error::{BacktestError, Result};
8
9#[non_exhaustive]
11#[derive(Debug, Clone, Serialize, Deserialize, Default)]
12pub enum RebalanceMode {
13 #[default]
17 AvailableCapital,
18
19 EqualWeight,
39
40 CustomWeights(HashMap<String, f64>),
45}
46
47#[non_exhaustive]
49#[derive(Debug, Clone, Serialize, Deserialize, Default)]
50pub struct PortfolioConfig {
51 pub base: BacktestConfig,
53
54 pub max_allocation_per_symbol: Option<f64>,
58
59 pub max_total_positions: Option<usize>,
65
66 pub rebalance: RebalanceMode,
68}
69
70impl PortfolioConfig {
71 pub fn new(base: BacktestConfig) -> Self {
73 Self {
74 base,
75 ..Self::default()
76 }
77 }
78
79 pub fn max_allocation_per_symbol(mut self, pct: f64) -> Self {
81 self.max_allocation_per_symbol = Some(pct);
82 self
83 }
84
85 pub fn max_total_positions(mut self, max: usize) -> Self {
87 self.max_total_positions = Some(max);
88 self
89 }
90
91 pub fn rebalance(mut self, mode: RebalanceMode) -> Self {
93 self.rebalance = mode;
94 self
95 }
96
97 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 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 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 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 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 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 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}