1use serde::{Deserialize, Serialize};
4
5use super::BacktestConfig;
6
7#[non_exhaustive]
17#[derive(Debug, Clone, Copy, PartialEq, Default, Serialize, Deserialize)]
18pub enum PositionSizing {
19 #[default]
21 FixedFraction,
22
23 Atr {
28 risk_pct: f64,
31 atr_period: usize,
33 atr_multiple: f64,
35 },
36
37 VolatilityTarget {
39 target_vol_pct: f64,
41 lookback: usize,
43 },
44
45 FractionalKelly {
51 kelly_fraction: f64,
53 lookback_trades: usize,
57 },
58}
59
60#[non_exhaustive]
65#[derive(Debug, Clone, Copy, Default, PartialEq)]
66pub struct SizingContext {
67 pub atr: Option<f64>,
69 pub recent_volatility: Option<f64>,
71 pub win_rate: Option<f64>,
73 pub payoff_ratio: Option<f64>,
75}
76
77impl BacktestConfig {
78 pub fn calculate_position_size(&self, available_capital: f64, price: f64) -> f64 {
91 self.size_from_fraction(
92 available_capital,
93 price,
94 self.position_size_pct * self.max_leverage,
95 )
96 }
97
98 pub fn calculate_position_size_with_context(
110 &self,
111 available_capital: f64,
112 price: f64,
113 ctx: &SizingContext,
114 ) -> f64 {
115 let fraction = self.sizing_fraction(price, ctx);
116 self.size_from_fraction(available_capital, price, fraction)
117 }
118
119 pub(crate) fn sizing_fraction(&self, price: f64, ctx: &SizingContext) -> f64 {
120 let budget = self.position_size_pct * self.max_leverage;
121 let base = match self.position_sizing {
122 PositionSizing::FixedFraction => budget,
123 PositionSizing::Atr {
124 risk_pct,
125 atr_multiple,
126 ..
127 } => match ctx.atr {
128 Some(atr) if atr > 0.0 && atr_multiple > 0.0 && price > 0.0 => {
129 (risk_pct * price) / (atr_multiple * atr)
130 }
131 _ => budget,
132 },
133 PositionSizing::VolatilityTarget { target_vol_pct, .. } => {
134 match ctx.recent_volatility {
135 Some(vol) if vol > 0.0 => target_vol_pct / vol,
136 _ => budget,
137 }
138 }
139 PositionSizing::FractionalKelly { kelly_fraction, .. } => {
140 match (ctx.win_rate, ctx.payoff_ratio) {
141 (Some(win_rate), Some(payoff)) if payoff > 0.0 => {
142 let kelly = win_rate - (1.0 - win_rate) / payoff;
143 kelly_fraction * kelly
144 }
145 _ => budget,
146 }
147 }
148 };
149
150 base.clamp(0.0, budget)
151 }
152
153 pub fn sizing_warmup(&self) -> usize {
163 match self.position_sizing {
164 PositionSizing::Atr { atr_period, .. } => atr_period + 1,
165 PositionSizing::VolatilityTarget { lookback, .. } => lookback + 1,
166 PositionSizing::FixedFraction | PositionSizing::FractionalKelly { .. } => 0,
167 }
168 }
169
170 fn size_from_fraction(&self, available_capital: f64, price: f64, fraction: f64) -> f64 {
171 let capital_to_use = available_capital * fraction;
172
173 let adjusted_capital = if self.commission_fn.is_some() {
174 capital_to_use / (1.0 + self.spread_pct + self.transaction_tax_pct)
177 } else {
178 let friction =
181 1.0 + 2.0 * self.commission_pct + self.spread_pct + self.transaction_tax_pct;
182 capital_to_use / friction - 2.0 * self.commission
183 };
184
185 (adjusted_capital / price).max(0.0)
186 }
187}
188
189#[cfg(test)]
190mod tests {
191 use super::*;
192
193 fn scheme(sizing: PositionSizing, position_size_pct: f64) -> BacktestConfig {
194 BacktestConfig::builder()
195 .commission_pct(0.0)
196 .position_size_pct(position_size_pct)
197 .position_sizing(sizing)
198 .build()
199 .unwrap()
200 }
201
202 #[test]
203 fn test_fixed_fraction_context_matches_plain_sizing() {
204 let config = scheme(PositionSizing::FixedFraction, 0.5);
205 let with_ctx =
206 config.calculate_position_size_with_context(10_000.0, 100.0, &SizingContext::default());
207 let plain = config.calculate_position_size(10_000.0, 100.0);
208 assert!((with_ctx - plain).abs() < 1e-12);
209 }
210
211 #[test]
212 fn test_atr_sizing_uses_risk_over_stop_distance() {
213 let config = scheme(
214 PositionSizing::Atr {
215 risk_pct: 0.02,
216 atr_period: 14,
217 atr_multiple: 2.0,
218 },
219 1.0,
220 );
221 let ctx = SizingContext {
222 atr: Some(2.0),
223 ..SizingContext::default()
224 };
225 let size = config.calculate_position_size_with_context(10_000.0, 100.0, &ctx);
227 assert!((size - 50.0).abs() < 1e-9);
228 }
229
230 #[test]
231 fn test_atr_sizing_falls_back_without_atr() {
232 let config = scheme(
233 PositionSizing::Atr {
234 risk_pct: 0.02,
235 atr_period: 14,
236 atr_multiple: 2.0,
237 },
238 0.4,
239 );
240 let size =
241 config.calculate_position_size_with_context(10_000.0, 100.0, &SizingContext::default());
242 assert!((size - config.calculate_position_size(10_000.0, 100.0)).abs() < 1e-12);
243 }
244
245 #[test]
246 fn test_volatility_target_scales_inversely_to_volatility() {
247 let config = scheme(
248 PositionSizing::VolatilityTarget {
249 target_vol_pct: 0.01,
250 lookback: 20,
251 },
252 1.0,
253 );
254 let calm = SizingContext {
255 recent_volatility: Some(0.02),
256 ..SizingContext::default()
257 };
258 let wild = SizingContext {
259 recent_volatility: Some(0.04),
260 ..SizingContext::default()
261 };
262 let calm_size = config.calculate_position_size_with_context(10_000.0, 100.0, &calm);
263 let wild_size = config.calculate_position_size_with_context(10_000.0, 100.0, &wild);
264 assert!((calm_size - 50.0).abs() < 1e-9);
265 assert!((wild_size - 25.0).abs() < 1e-9);
266 }
267
268 #[test]
269 fn test_volatility_target_falls_back_without_data() {
270 let config = scheme(
271 PositionSizing::VolatilityTarget {
272 target_vol_pct: 0.01,
273 lookback: 20,
274 },
275 0.3,
276 );
277 let size =
278 config.calculate_position_size_with_context(10_000.0, 100.0, &SizingContext::default());
279 assert!((size - config.calculate_position_size(10_000.0, 100.0)).abs() < 1e-12);
280 }
281
282 #[test]
283 fn test_fractional_kelly_matches_formula() {
284 let config = scheme(
285 PositionSizing::FractionalKelly {
286 kelly_fraction: 0.5,
287 lookback_trades: 20,
288 },
289 1.0,
290 );
291 let ctx = SizingContext {
292 win_rate: Some(0.6),
293 payoff_ratio: Some(2.0),
294 ..SizingContext::default()
295 };
296 let size = config.calculate_position_size_with_context(10_000.0, 100.0, &ctx);
298 assert!((size - 20.0).abs() < 1e-9);
299 }
300
301 #[test]
302 fn test_fractional_kelly_negative_edge_sizes_to_zero() {
303 let config = scheme(
304 PositionSizing::FractionalKelly {
305 kelly_fraction: 0.5,
306 lookback_trades: 20,
307 },
308 1.0,
309 );
310 let ctx = SizingContext {
311 win_rate: Some(0.3),
312 payoff_ratio: Some(1.0),
313 ..SizingContext::default()
314 };
315 let size = config.calculate_position_size_with_context(10_000.0, 100.0, &ctx);
316 assert_eq!(size, 0.0);
317 }
318
319 #[test]
320 fn test_leverage_raises_the_budget_without_scaling_the_scheme() {
321 let config = BacktestConfig::builder()
322 .commission_pct(0.0)
323 .position_size_pct(1.0)
324 .max_leverage(3.0)
325 .position_sizing(PositionSizing::Atr {
326 risk_pct: 0.02,
327 atr_period: 14,
328 atr_multiple: 2.0,
329 })
330 .build()
331 .unwrap();
332
333 let ctx = SizingContext {
334 atr: Some(2.0),
335 ..SizingContext::default()
336 };
337 let size = config.calculate_position_size_with_context(10_000.0, 100.0, &ctx);
339 assert!((size - 50.0).abs() < 1e-9);
340
341 let tight_stop = SizingContext {
342 atr: Some(0.1),
343 ..SizingContext::default()
344 };
345 let capped = config.calculate_position_size_with_context(10_000.0, 100.0, &tight_stop);
347 assert!((capped - 300.0).abs() < 1e-9);
348 }
349
350 #[test]
351 fn test_leverage_falls_back_to_the_full_budget() {
352 let config = BacktestConfig::builder()
353 .commission_pct(0.0)
354 .position_size_pct(0.5)
355 .max_leverage(2.0)
356 .position_sizing(PositionSizing::VolatilityTarget {
357 target_vol_pct: 0.01,
358 lookback: 20,
359 })
360 .build()
361 .unwrap();
362
363 let size =
364 config.calculate_position_size_with_context(10_000.0, 100.0, &SizingContext::default());
365 assert!((size - config.calculate_position_size(10_000.0, 100.0)).abs() < 1e-12);
366 assert!((size - 100.0).abs() < 1e-9);
367 }
368
369 #[test]
370 fn test_fractional_kelly_falls_back_without_history() {
371 let config = scheme(
372 PositionSizing::FractionalKelly {
373 kelly_fraction: 0.5,
374 lookback_trades: 20,
375 },
376 0.25,
377 );
378 let size =
379 config.calculate_position_size_with_context(10_000.0, 100.0, &SizingContext::default());
380 assert!((size - config.calculate_position_size(10_000.0, 100.0)).abs() < 1e-12);
381 }
382
383 #[test]
384 fn test_scheme_cannot_exceed_the_risk_budget() {
385 let config = scheme(
386 PositionSizing::Atr {
387 risk_pct: 0.02,
388 atr_period: 14,
389 atr_multiple: 2.0,
390 },
391 0.1,
392 );
393 let ctx = SizingContext {
395 atr: Some(0.005),
396 ..SizingContext::default()
397 };
398 let size = config.calculate_position_size_with_context(10_000.0, 100.0, &ctx);
399 assert!((size - config.calculate_position_size(10_000.0, 100.0)).abs() < 1e-12);
400 assert!((size - 10.0).abs() < 1e-9);
401 }
402
403 #[test]
404 fn test_sizing_warmup_per_scheme() {
405 assert_eq!(BacktestConfig::default().sizing_warmup(), 0);
406 assert_eq!(
407 scheme(
408 PositionSizing::Atr {
409 risk_pct: 0.02,
410 atr_period: 14,
411 atr_multiple: 2.0,
412 },
413 1.0,
414 )
415 .sizing_warmup(),
416 15
417 );
418 assert_eq!(
419 scheme(
420 PositionSizing::VolatilityTarget {
421 target_vol_pct: 0.01,
422 lookback: 20,
423 },
424 1.0,
425 )
426 .sizing_warmup(),
427 21
428 );
429 assert_eq!(
430 scheme(
431 PositionSizing::FractionalKelly {
432 kelly_fraction: 0.5,
433 lookback_trades: 20,
434 },
435 1.0,
436 )
437 .sizing_warmup(),
438 0
439 );
440 }
441
442 #[test]
443 fn test_position_sizing() {
444 let config = BacktestConfig::builder()
445 .position_size_pct(0.5) .commission_pct(0.0) .build()
448 .unwrap();
449
450 let size = config.calculate_position_size(10_000.0, 100.0);
452 assert!((size - 50.0).abs() < 0.01);
453 }
454
455 #[test]
456 fn test_position_sizing_with_commission() {
457 let config = BacktestConfig::builder()
458 .position_size_pct(0.5) .commission_pct(0.001) .build()
461 .unwrap();
462
463 let size = config.calculate_position_size(10_000.0, 100.0);
467 let expected = 5000.0 / 1.002 / 100.0;
468 assert!((size - expected).abs() < 0.01);
469 }
470
471 #[test]
472 fn test_position_sizing_accounts_for_exit_commission() {
473 let comm = 0.01; let config = BacktestConfig::builder()
476 .commission_pct(comm)
477 .position_size_pct(1.0)
478 .build()
479 .unwrap();
480 let size = config.calculate_position_size(10_000.0, 100.0);
481 let expected = 10_000.0 / (1.0 + 2.0 * comm) / 100.0;
482 assert!((size - expected).abs() < 0.001);
483 }
484
485 #[test]
486 fn test_position_sizing_flat_commission_reduces_size() {
487 let config = BacktestConfig::builder()
489 .commission(10.0)
490 .commission_pct(0.0)
491 .position_size_pct(1.0)
492 .build()
493 .unwrap();
494 let size_with_flat = config.calculate_position_size(10_000.0, 100.0);
495
496 let config_no_flat = BacktestConfig::builder()
497 .commission_pct(0.0)
498 .position_size_pct(1.0)
499 .build()
500 .unwrap();
501 let size_no_flat = config_no_flat.calculate_position_size(10_000.0, 100.0);
502
503 assert!(size_with_flat < size_no_flat);
505 let expected = (10_000.0 - 20.0) / 100.0;
507 assert!((size_with_flat - expected).abs() < 0.001);
508 }
509
510 #[test]
511 fn test_position_sizing_flat_commission_exceeds_capital_returns_zero() {
512 let config = BacktestConfig::builder()
514 .commission(6_000.0) .position_size_pct(1.0)
516 .build()
517 .unwrap();
518 let size = config.calculate_position_size(10_000.0, 100.0);
519 assert_eq!(size, 0.0);
520 }
521
522 #[test]
523 fn test_position_sizing_includes_spread_and_tax() {
524 let spread = 0.0004; let tax = 0.005; let config = BacktestConfig::builder()
527 .commission_pct(0.0)
528 .spread_pct(spread)
529 .transaction_tax_pct(tax)
530 .position_size_pct(1.0)
531 .build()
532 .unwrap();
533
534 let size = config.calculate_position_size(10_000.0, 100.0);
535 let expected = 10_000.0 / (1.0 + spread + tax) / 100.0;
536 assert!((size - expected).abs() < 0.01);
537 }
538}