Skip to main content

finance_query/backtesting/config/
sizing.rs

1//! Position sizing: how much capital an entry commits.
2
3use serde::{Deserialize, Serialize};
4
5use super::BacktestConfig;
6
7/// How an entry's size is derived from available equity.
8///
9/// Every scheme targets a fraction of equity clamped to the risk budget
10/// ([`BacktestConfig::position_size_pct`], raised by
11/// [`BacktestConfig::max_leverage`] when levered), and falls back to that budget
12/// when its inputs are unavailable.
13///
14/// Scale-in signals carry an explicit fraction of their own and are not sized
15/// by the active scheme.
16#[non_exhaustive]
17#[derive(Debug, Clone, Copy, PartialEq, Default, Serialize, Deserialize)]
18pub enum PositionSizing {
19    /// Commit [`BacktestConfig::position_size_pct`] of equity on every entry.
20    #[default]
21    FixedFraction,
22
23    /// Risk a fixed fraction of equity across an ATR-derived stop distance.
24    ///
25    /// Wider ranges produce smaller positions, holding currency risk per trade
26    /// roughly constant.
27    Atr {
28        /// Fraction of equity to risk if price moves `atr_multiple` ATRs against
29        /// the entry (0.0 - 1.0).
30        risk_pct: f64,
31        /// Lookback period for the ATR computation.
32        atr_period: usize,
33        /// Stop distance as a multiple of ATR.
34        atr_multiple: f64,
35    },
36
37    /// Scale exposure inversely to realized volatility.
38    VolatilityTarget {
39        /// Target per-bar volatility contribution as a fraction (`0.01` = 1%).
40        target_vol_pct: f64,
41        /// Trailing bars used to estimate realized volatility.
42        lookback: usize,
43    },
44
45    /// Size by a fraction of the Kelly-optimal bet implied by recent trades.
46    ///
47    /// A trade count is not a bar count, so unlike the other schemes this one
48    /// cannot extend the warmup period. Entries before the window holds both a
49    /// win and a loss fall back to the risk budget.
50    FractionalKelly {
51        /// Multiplier on the full Kelly fraction (`0.5` = half-Kelly).
52        kelly_fraction: f64,
53        /// Trailing fully-closed trades used to estimate win rate and payoff
54        /// ratio. Partial closes from `Signal::scale_out` are excluded, so one
55        /// entry contributes one observation.
56        lookback_trades: usize,
57    },
58}
59
60/// Market and trade-history inputs a [`PositionSizing`] scheme reads at entry.
61///
62/// A `None` field means the engine had no value to supply, and the scheme falls
63/// back to [`BacktestConfig::position_size_pct`].
64#[non_exhaustive]
65#[derive(Debug, Clone, Copy, Default, PartialEq)]
66pub struct SizingContext {
67    /// ATR at the entry bar.
68    pub atr: Option<f64>,
69    /// Realized per-bar return volatility over the scheme's lookback.
70    pub recent_volatility: Option<f64>,
71    /// Win rate over the trailing closed-trade window (0.0 - 1.0).
72    pub win_rate: Option<f64>,
73    /// Mean win divided by mean loss, both as absolute return fractions.
74    pub payoff_ratio: Option<f64>,
75}
76
77impl BacktestConfig {
78    /// Calculate position size based on available capital.
79    ///
80    /// `price` **must** be the fully-adjusted entry price (after slippage and
81    /// spread) so that subsequent fill guards (`entry_value + costs > cash`)
82    /// do not over-allocate capital.
83    ///
84    /// When [`commission_fn`] is set the commission component cannot be
85    /// analytically solved for, so only spread and transaction-tax fractions
86    /// are deducted from the denominator; the fill-rejection guard catches any
87    /// remaining over-allocation.
88    ///
89    /// [`commission_fn`]: Self::commission_fn
90    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    /// Calculate position size under the active [`PositionSizing`] scheme.
99    ///
100    /// The scheme's own fraction is clamped to the risk budget
101    /// ([`position_size_pct`] times [`max_leverage`]), so leverage raises the
102    /// ceiling a scheme may reach rather than multiplying what it asked for.
103    /// `price` carries the same fully-adjusted requirement as
104    /// [`calculate_position_size`].
105    ///
106    /// [`position_size_pct`]: Self::position_size_pct
107    /// [`max_leverage`]: Self::max_leverage
108    /// [`calculate_position_size`]: Self::calculate_position_size
109    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    /// Bars of history the active [`PositionSizing`] scheme needs before it can
154    /// size an entry from real data.
155    ///
156    /// The engine folds this into the strategy's own warmup so an early entry
157    /// cannot silently fall back to [`position_size_pct`].
158    /// [`PositionSizing::FractionalKelly`] returns `0` because its window counts
159    /// closed trades, which no number of bars guarantees.
160    ///
161    /// [`position_size_pct`]: Self::position_size_pct
162    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            // Can't analytically invert commission_fn; use spread + tax only.
175            // The fill-rejection guard will catch any over-allocation.
176            capital_to_use / (1.0 + self.spread_pct + self.transaction_tax_pct)
177        } else {
178            // Round-trip friction as a fraction of trade value: entry + exit
179            // commission, full spread (half each way), tax on the buy only.
180            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        // (0.02 * 100) / (2.0 * 2.0) = 0.5 of equity
226        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        // kelly = 0.6 - 0.4 / 2.0 = 0.4; half-Kelly = 0.2
297        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        // (0.02 * 100) / (2.0 * 2.0) = 0.5 of equity, leverage or not.
338        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        // Asks for 10x equity, capped at the 3x budget.
346        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        // A tiny ATR asks for 20x equity; the budget caps it at 10%.
394        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) // Use 50% of capital
446            .commission_pct(0.0) // No commission for simpler test
447            .build()
448            .unwrap();
449
450        // With $10,000 and price $100, use $5,000 -> 50 shares
451        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) // Use 50% of capital
459            .commission_pct(0.001) // 0.1% commission
460            .build()
461            .unwrap();
462
463        // With $10,000 and price $100, use $5,000
464        // But adjusted for entry + exit commission: 5000 / 1.002 = 4990.019960...
465        // So shares = 4990.019960 / 100 = 49.90...
466        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        // Verify the denominator is 1 + 2*comm (entry + exit)
474        let comm = 0.01; // 1%
475        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        // With $10 flat commission per side, $20 total must be reserved
488        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        // Flat commission should reduce position size
504        assert!(size_with_flat < size_no_flat);
505        // Expected: (10_000 - 20) / 100 = 99.8
506        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        // If flat commission alone exceeds available capital, quantity should be 0
513        let config = BacktestConfig::builder()
514            .commission(6_000.0) // $6k/side → $12k total > $10k capital
515            .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; // 4 bps round-trip
525        let tax = 0.005; // 0.5% stamp duty
526        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}