Skip to main content

nautilus_risk/
sizing.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Position sizing calculation functions.
17use nautilus_core::correctness::{
18    CorrectnessError, CorrectnessResult, check_positive_decimal, check_positive_usize,
19    check_predicate_true,
20};
21use nautilus_model::{
22    instruments::{Instrument, InstrumentAny},
23    types::{Money, Price, Quantity},
24};
25use rust_decimal::{Decimal, prelude::FromPrimitive};
26
27const OVERFLOW_MESSAGE: &str = "arithmetic overflow calculating fixed-risk position size";
28
29/// Calculates the position size based on fixed risk parameters.
30///
31/// # Errors
32///
33/// Returns an error if an input is invalid, decimal arithmetic overflows, or
34/// the final size cannot be represented as a [`Quantity`].
35#[expect(
36    clippy::too_many_arguments,
37    reason = "position sizing API mirrors fixed-risk inputs used by callers"
38)]
39pub fn calculate_fixed_risk_position_size(
40    instrument: &InstrumentAny,
41    entry: Price,
42    stop_loss: Price,
43    equity: Money,
44    risk: Decimal,
45    commission_rate: Decimal,
46    exchange_rate: Decimal,
47    hard_limit: Option<Decimal>,
48    unit_batch_size: Decimal,
49    units: usize,
50) -> CorrectnessResult<Quantity> {
51    check_positive_decimal(risk, "risk")?;
52    check_predicate_true(
53        exchange_rate >= Decimal::ZERO,
54        "exchange_rate must be non-negative",
55    )?;
56    check_predicate_true(
57        commission_rate >= Decimal::ZERO,
58        "commission_rate must be non-negative",
59    )?;
60
61    if let Some(hard_limit) = hard_limit {
62        check_positive_decimal(hard_limit, "hard_limit")?;
63    }
64    check_predicate_true(
65        unit_batch_size >= Decimal::ZERO,
66        "unit_batch_size must be non-negative",
67    )?;
68    check_positive_usize(units, "units")?;
69
70    if exchange_rate.is_zero() {
71        return Ok(Quantity::zero(instrument.size_precision()));
72    }
73
74    let risk_points = calculate_risk_ticks(entry, stop_loss, instrument)?;
75    let risk_money = calculate_riskable_money(equity.as_decimal(), risk, commission_rate)?;
76
77    if risk_points <= Decimal::ZERO {
78        return Ok(Quantity::zero(instrument.size_precision()));
79    }
80
81    let mut position_size = risk_money
82        .checked_div(exchange_rate)
83        .and_then(|value| value.checked_div(risk_points))
84        .and_then(|value| value.checked_div(instrument.price_increment().as_decimal()))
85        .and_then(|value| value.checked_div(instrument.multiplier().as_decimal()))
86        .ok_or_else(position_size_overflow)?;
87
88    if let Some(hard_limit) = hard_limit {
89        position_size = position_size.min(hard_limit);
90    }
91
92    let units_decimal = Decimal::from_usize(units).ok_or_else(position_size_overflow)?;
93    let mut position_size_batched = position_size
94        .checked_div(units_decimal)
95        .map(|value| value.max(Decimal::ZERO))
96        .ok_or_else(position_size_overflow)?;
97
98    if unit_batch_size > Decimal::ZERO {
99        position_size_batched = position_size_batched
100            .checked_div(unit_batch_size)
101            .map(|value| value.floor())
102            .and_then(|value| value.checked_mul(unit_batch_size))
103            .ok_or_else(position_size_overflow)?;
104    }
105
106    let final_size = instrument
107        .max_quantity()
108        .map_or(position_size_batched, |max_quantity| {
109            position_size_batched.min(max_quantity.as_decimal())
110        });
111
112    instrument
113        .try_make_qty_from_decimal(final_size, None)
114        .map_err(|e| CorrectnessError::PredicateViolation {
115            message: e.to_string(),
116        })
117}
118
119fn calculate_risk_ticks(
120    entry: Price,
121    stop_loss: Price,
122    instrument: &InstrumentAny,
123) -> CorrectnessResult<Decimal> {
124    entry
125        .as_decimal()
126        .checked_sub(stop_loss.as_decimal())
127        .map(|value| value.abs())
128        .and_then(|value| value.checked_div(instrument.price_increment().as_decimal()))
129        .ok_or_else(position_size_overflow)
130}
131
132fn calculate_riskable_money(
133    equity: Decimal,
134    risk: Decimal,
135    commission_rate: Decimal,
136) -> CorrectnessResult<Decimal> {
137    if equity <= Decimal::ZERO {
138        return Ok(Decimal::ZERO);
139    }
140
141    let risk_money = equity
142        .checked_mul(risk)
143        .ok_or_else(position_size_overflow)?;
144    let commission = risk_money
145        .checked_mul(commission_rate)
146        .and_then(|value| value.checked_mul(Decimal::TWO))
147        .ok_or_else(position_size_overflow)?;
148
149    risk_money
150        .checked_sub(commission)
151        .ok_or_else(position_size_overflow)
152}
153
154fn position_size_overflow() -> CorrectnessError {
155    CorrectnessError::PredicateViolation {
156        message: OVERFLOW_MESSAGE.to_string(),
157    }
158}
159
160#[cfg(test)]
161mod tests {
162    use nautilus_model::{
163        identifiers::Symbol,
164        instruments::stubs::{default_fx_ccy, futures_contract_es},
165        types::Currency,
166    };
167    use rstest::*;
168    use rust_decimal_macros::dec;
169
170    use super::*;
171
172    const EXCHANGE_RATE: Decimal = Decimal::ONE;
173
174    #[fixture]
175    fn instrument_gbpusd() -> InstrumentAny {
176        InstrumentAny::CurrencyPair(default_fx_ccy(Symbol::from_str_unchecked("GBP/USD"), None))
177    }
178
179    #[fixture]
180    fn instrument_futures_with_multiplier() -> InstrumentAny {
181        // A futures contract whose multiplier scales the per-unit dollar risk,
182        // as it does for position P&L (e.g. ES, CL, NG).
183        let mut instrument = futures_contract_es(None, None);
184        instrument.multiplier = Quantity::from(1000);
185        InstrumentAny::FuturesContract(instrument)
186    }
187
188    #[fixture]
189    fn instrument_gbpusd_without_max_quantity() -> InstrumentAny {
190        let mut instrument = default_fx_ccy(Symbol::from_str_unchecked("GBP/USD"), None);
191        instrument.max_quantity = None;
192        InstrumentAny::CurrencyPair(instrument)
193    }
194
195    #[rstest]
196    fn test_calculate_with_zero_equity_returns_quantity_zero(instrument_gbpusd: InstrumentAny) {
197        let equity = Money::zero(instrument_gbpusd.quote_currency());
198        let entry = Price::new(1.00100, instrument_gbpusd.price_precision());
199        let stop_loss = Price::new(1.00000, instrument_gbpusd.price_precision());
200
201        let result = calculate_fixed_risk_position_size(
202            &instrument_gbpusd,
203            entry,
204            stop_loss,
205            equity,
206            Decimal::new(1, 3), // 0.001%
207            Decimal::ZERO,
208            EXCHANGE_RATE,
209            None,
210            Decimal::from(1000),
211            1,
212        )
213        .unwrap();
214
215        assert_eq!(result, Quantity::from("0.0"));
216    }
217
218    #[rstest]
219    fn test_calculate_with_zero_exchange_rate(instrument_gbpusd: InstrumentAny) {
220        let equity = Money::new(100_000.0, instrument_gbpusd.quote_currency());
221        let entry = Price::new(1.00100, instrument_gbpusd.price_precision());
222        let stop_loss = Price::new(1.00000, instrument_gbpusd.price_precision());
223
224        let result = calculate_fixed_risk_position_size(
225            &instrument_gbpusd,
226            entry,
227            stop_loss,
228            equity,
229            Decimal::new(1, 3), // 0.001%
230            Decimal::ZERO,
231            Decimal::ZERO, // Zero exchange rate
232            None,
233            Decimal::from(1000),
234            1,
235        )
236        .unwrap();
237
238        assert_eq!(result, Quantity::from("0.0"));
239    }
240
241    #[rstest]
242    fn test_calculate_with_zero_risk(instrument_gbpusd: InstrumentAny) {
243        let equity = Money::new(100_000.0, instrument_gbpusd.quote_currency());
244        let price = Price::new(1.00100, instrument_gbpusd.price_precision());
245
246        let result = calculate_fixed_risk_position_size(
247            &instrument_gbpusd,
248            price,
249            price, // Same price = no risk
250            equity,
251            Decimal::new(1, 3), // 0.001%
252            Decimal::ZERO,
253            EXCHANGE_RATE,
254            None,
255            Decimal::from(1000),
256            1,
257        )
258        .unwrap();
259
260        assert_eq!(result, Quantity::from("0.0"));
261    }
262
263    #[rstest]
264    fn test_calculate_single_unit_size(instrument_gbpusd: InstrumentAny) {
265        let equity = Money::new(1_000_000.0, instrument_gbpusd.quote_currency());
266        let entry = Price::new(1.00100, instrument_gbpusd.price_precision());
267        let stop_loss = Price::new(1.00000, instrument_gbpusd.price_precision());
268
269        let result = calculate_fixed_risk_position_size(
270            &instrument_gbpusd,
271            entry,
272            stop_loss,
273            equity,
274            Decimal::new(1, 3), // 0.001%
275            Decimal::ZERO,
276            EXCHANGE_RATE,
277            None,
278            Decimal::from(1000),
279            1,
280        )
281        .unwrap();
282
283        assert_eq!(result, Quantity::from("1000000.0"));
284    }
285
286    #[rstest]
287    fn test_calculate_accounts_for_instrument_multiplier(
288        instrument_futures_with_multiplier: InstrumentAny,
289    ) {
290        // Multiplier = 1000: a $1.00 stop risks $1,000/contract, so $10,000 of
291        // risk targets 10 contracts.
292        let equity = Money::new(1_000_000.0, Currency::USD());
293        let entry = Price::new(100.00, instrument_futures_with_multiplier.price_precision());
294        let stop_loss = Price::new(99.00, instrument_futures_with_multiplier.price_precision());
295
296        let result = calculate_fixed_risk_position_size(
297            &instrument_futures_with_multiplier,
298            entry,
299            stop_loss,
300            equity,
301            Decimal::new(1, 2), // 1%
302            Decimal::ZERO,
303            EXCHANGE_RATE,
304            None,
305            Decimal::from(1),
306            1,
307        )
308        .unwrap();
309
310        assert_eq!(result.as_decimal(), dec!(10));
311    }
312
313    #[rstest]
314    fn test_calculate_single_unit_with_exchange_rate(instrument_gbpusd: InstrumentAny) {
315        let equity = Money::new(1_000_000.0, Currency::USD());
316        let entry = Price::new(110.010, instrument_gbpusd.price_precision());
317        let stop_loss = Price::new(110.000, instrument_gbpusd.price_precision());
318
319        let result = calculate_fixed_risk_position_size(
320            &instrument_gbpusd,
321            entry,
322            stop_loss,
323            equity,
324            Decimal::new(1, 3), // 0.1%
325            Decimal::ZERO,
326            Decimal::from_f64(0.00909).unwrap(), // 1/110
327            None,
328            Decimal::from(1),
329            1,
330        )
331        .unwrap();
332
333        assert_eq!(result, Quantity::from("1000000.0"));
334    }
335
336    #[rstest]
337    fn test_calculate_single_unit_size_when_risk_too_high(instrument_gbpusd: InstrumentAny) {
338        let equity = Money::new(100_000.0, Currency::USD());
339        let entry = Price::new(3.00000, instrument_gbpusd.price_precision());
340        let stop_loss = Price::new(1.00000, instrument_gbpusd.price_precision());
341
342        let result = calculate_fixed_risk_position_size(
343            &instrument_gbpusd,
344            entry,
345            stop_loss,
346            equity,
347            Decimal::new(1, 2), // 1%
348            Decimal::ZERO,
349            EXCHANGE_RATE,
350            None,
351            Decimal::from(1000),
352            1,
353        )
354        .unwrap();
355
356        assert_eq!(result, Quantity::from("0.0"));
357    }
358
359    #[rstest]
360    fn test_impose_hard_limit(instrument_gbpusd: InstrumentAny) {
361        let equity = Money::new(1_000_000.0, instrument_gbpusd.quote_currency());
362        let entry = Price::new(1.00010, instrument_gbpusd.price_precision());
363        let stop_loss = Price::new(1.00000, instrument_gbpusd.price_precision());
364
365        let result = calculate_fixed_risk_position_size(
366            &instrument_gbpusd,
367            entry,
368            stop_loss,
369            equity,
370            Decimal::new(1, 2), // 1%
371            Decimal::ZERO,
372            EXCHANGE_RATE,
373            Some(Decimal::from(500_000)),
374            Decimal::from(1000),
375            1,
376        )
377        .unwrap();
378
379        assert_eq!(result, Quantity::from("500000.0"));
380    }
381
382    #[rstest]
383    fn test_calculate_without_max_quantity_leaves_size_uncapped(
384        instrument_gbpusd_without_max_quantity: InstrumentAny,
385    ) {
386        let equity = Money::from("1000000 USD");
387        let entry = Price::from("1.00010");
388        let stop_loss = Price::from("1.00000");
389
390        let result = calculate_fixed_risk_position_size(
391            &instrument_gbpusd_without_max_quantity,
392            entry,
393            stop_loss,
394            equity,
395            dec!(0.01),
396            Decimal::ZERO,
397            EXCHANGE_RATE,
398            None,
399            Decimal::from(1000),
400            1,
401        )
402        .unwrap();
403
404        assert_eq!(result.as_decimal(), dec!(100000000));
405    }
406
407    #[rstest]
408    fn test_calculate_multiple_unit_size(instrument_gbpusd: InstrumentAny) {
409        let equity = Money::new(1_000_000.0, instrument_gbpusd.quote_currency());
410        let entry = Price::new(1.00010, instrument_gbpusd.price_precision());
411        let stop_loss = Price::new(1.00000, instrument_gbpusd.price_precision());
412
413        let result = calculate_fixed_risk_position_size(
414            &instrument_gbpusd,
415            entry,
416            stop_loss,
417            equity,
418            Decimal::new(1, 3), // 0.1%
419            Decimal::ZERO,
420            EXCHANGE_RATE,
421            None,
422            Decimal::from(1000),
423            3, // 3 units
424        )
425        .unwrap();
426
427        assert_eq!(result, Quantity::from("1000000.0"));
428    }
429
430    #[rstest]
431    fn test_calculate_multiple_unit_size_larger_batches(instrument_gbpusd: InstrumentAny) {
432        let equity = Money::new(1_000_000.0, instrument_gbpusd.quote_currency());
433        let entry = Price::new(1.00087, instrument_gbpusd.price_precision());
434        let stop_loss = Price::new(1.00000, instrument_gbpusd.price_precision());
435
436        let result = calculate_fixed_risk_position_size(
437            &instrument_gbpusd,
438            entry,
439            stop_loss,
440            equity,
441            Decimal::new(1, 3), // 0.1%
442            Decimal::ZERO,
443            EXCHANGE_RATE,
444            None,
445            Decimal::from(25000),
446            4, // 4 units
447        )
448        .unwrap();
449
450        assert_eq!(result, Quantity::from("275000.0"));
451    }
452
453    #[rstest]
454    fn test_calculate_for_gbpusd_with_commission(instrument_gbpusd: InstrumentAny) {
455        let equity = Money::new(1_000_000.0, instrument_gbpusd.quote_currency());
456        let entry = Price::new(107.703, instrument_gbpusd.price_precision());
457        let stop_loss = Price::new(107.403, instrument_gbpusd.price_precision());
458
459        let result = calculate_fixed_risk_position_size(
460            &instrument_gbpusd,
461            entry,
462            stop_loss,
463            equity,
464            Decimal::new(1, 2),                    // 1%
465            Decimal::new(2, 4),                    // 0.0002
466            Decimal::from_f64(0.009_931).unwrap(), // 1/107.403
467            None,
468            Decimal::from(1000),
469            1,
470        )
471        .unwrap();
472
473        assert_eq!(result, Quantity::from("1000000.0"));
474    }
475
476    #[rstest]
477    #[case(
478        (
479            Decimal::ZERO,
480            Decimal::ZERO,
481            EXCHANGE_RATE,
482            None,
483            Decimal::ONE,
484            1,
485        ),
486        "invalid Decimal for 'risk' not positive, was 0"
487    )]
488    #[case(
489        (
490            dec!(0.001),
491            Decimal::ZERO,
492            dec!(-1),
493            None,
494            Decimal::ONE,
495            1,
496        ),
497        "exchange_rate must be non-negative"
498    )]
499    #[case(
500        (
501            dec!(0.001),
502            dec!(-0.001),
503            EXCHANGE_RATE,
504            None,
505            Decimal::ONE,
506            1,
507        ),
508        "commission_rate must be non-negative"
509    )]
510    #[case(
511        (
512            dec!(0.001),
513            Decimal::ZERO,
514            EXCHANGE_RATE,
515            Some(Decimal::ZERO),
516            Decimal::ONE,
517            1,
518        ),
519        "invalid Decimal for 'hard_limit' not positive, was 0"
520    )]
521    #[case(
522        (
523            dec!(0.001),
524            Decimal::ZERO,
525            EXCHANGE_RATE,
526            None,
527            dec!(-1),
528            1,
529        ),
530        "unit_batch_size must be non-negative"
531    )]
532    #[case(
533        (
534            dec!(0.001),
535            Decimal::ZERO,
536            EXCHANGE_RATE,
537            None,
538            Decimal::ONE,
539            0,
540        ),
541        "invalid usize for 'units' not positive, was 0"
542    )]
543    fn test_calculate_rejects_invalid_inputs(
544        #[case] inputs: (Decimal, Decimal, Decimal, Option<Decimal>, Decimal, usize),
545        #[case] expected: &str,
546        instrument_gbpusd: InstrumentAny,
547    ) {
548        let (risk, commission_rate, exchange_rate, hard_limit, unit_batch_size, units) = inputs;
549        let equity = Money::new(1_000_000.0, instrument_gbpusd.quote_currency());
550        let entry = Price::new(1.00100, instrument_gbpusd.price_precision());
551        let stop_loss = Price::new(1.00000, instrument_gbpusd.price_precision());
552
553        let error = calculate_fixed_risk_position_size(
554            &instrument_gbpusd,
555            entry,
556            stop_loss,
557            equity,
558            risk,
559            commission_rate,
560            exchange_rate,
561            hard_limit,
562            unit_batch_size,
563            units,
564        )
565        .unwrap_err();
566
567        assert_eq!(error.to_string(), expected);
568    }
569
570    #[rstest]
571    #[case::large_risk(dec!(1e23), Decimal::ZERO, EXCHANGE_RATE)]
572    #[case::large_commission_rate(dec!(0.001), dec!(1e26), EXCHANGE_RATE)]
573    #[case::tiny_exchange_rate(dec!(0.001), Decimal::ZERO, dec!(1e-28))]
574    fn test_calculate_returns_error_on_arithmetic_overflow(
575        #[case] risk: Decimal,
576        #[case] commission_rate: Decimal,
577        #[case] exchange_rate: Decimal,
578        instrument_gbpusd: InstrumentAny,
579    ) {
580        let equity = Money::new(1_000_000.0, instrument_gbpusd.quote_currency());
581        let entry = Price::new(1.00100, instrument_gbpusd.price_precision());
582        let stop_loss = Price::new(1.00000, instrument_gbpusd.price_precision());
583
584        let error = calculate_fixed_risk_position_size(
585            &instrument_gbpusd,
586            entry,
587            stop_loss,
588            equity,
589            risk,
590            commission_rate,
591            exchange_rate,
592            None,
593            Decimal::from(1000),
594            1,
595        )
596        .unwrap_err();
597
598        assert_eq!(
599            error,
600            CorrectnessError::PredicateViolation {
601                message: OVERFLOW_MESSAGE.to_string(),
602            }
603        );
604    }
605}