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_model::{
18    instruments::{Instrument, InstrumentAny},
19    types::{Money, Price, Quantity},
20};
21use rust_decimal::{Decimal, prelude::FromPrimitive};
22
23/// Calculates the position size based on fixed risk parameters.
24///
25/// # Panics
26///
27/// Panics if converting `units` to a decimal fails,
28/// or if converting the final size to a [`Quantity`] fails.
29#[must_use]
30#[expect(
31    clippy::too_many_arguments,
32    reason = "position sizing API mirrors fixed-risk inputs used by callers"
33)]
34pub fn calculate_fixed_risk_position_size(
35    instrument: &InstrumentAny,
36    entry: Price,
37    stop_loss: Price,
38    equity: Money,
39    risk: Decimal,
40    commission_rate: Decimal,
41    exchange_rate: Decimal,
42    hard_limit: Option<Decimal>,
43    unit_batch_size: Decimal,
44    units: usize,
45) -> Quantity {
46    if exchange_rate.is_zero() {
47        return Quantity::zero(instrument.size_precision());
48    }
49
50    let risk_points = calculate_risk_ticks(entry, stop_loss, instrument);
51    let risk_money = calculate_riskable_money(equity.as_decimal(), risk, commission_rate);
52
53    if risk_points <= Decimal::ZERO {
54        return Quantity::zero(instrument.size_precision());
55    }
56
57    let mut position_size =
58        ((risk_money / exchange_rate) / risk_points) / instrument.price_increment().as_decimal();
59
60    if let Some(hard_limit) = hard_limit {
61        position_size = position_size.min(hard_limit);
62    }
63
64    let mut position_size_batched = (position_size
65        / Decimal::from_usize(units).expect("Error: Failed to convert units to decimal"))
66    .max(Decimal::ZERO);
67
68    if unit_batch_size > Decimal::ZERO {
69        position_size_batched = (position_size_batched / unit_batch_size).floor() * unit_batch_size;
70    }
71
72    let final_size = instrument
73        .max_quantity()
74        .map_or(position_size_batched, |max_quantity| {
75            position_size_batched.min(max_quantity.as_decimal())
76        });
77
78    Quantity::from_decimal_dp(final_size, instrument.size_precision())
79        .expect("Error: Failed to convert final size to Quantity")
80}
81
82// Helper functions
83fn calculate_risk_ticks(entry: Price, stop_loss: Price, instrument: &InstrumentAny) -> Decimal {
84    (entry - stop_loss).as_decimal().abs() / instrument.price_increment().as_decimal()
85}
86
87fn calculate_riskable_money(equity: Decimal, risk: Decimal, commission_rate: Decimal) -> Decimal {
88    if equity <= Decimal::ZERO {
89        return Decimal::ZERO;
90    }
91
92    let risk_money = equity * risk;
93    let commission = risk_money * commission_rate * Decimal::TWO; // (round turn)
94
95    risk_money - commission
96}
97
98#[cfg(test)]
99mod tests {
100    use nautilus_model::{
101        identifiers::Symbol, instruments::stubs::default_fx_ccy, types::Currency,
102    };
103    use rstest::*;
104    use rust_decimal_macros::dec;
105
106    use super::*;
107
108    const EXCHANGE_RATE: Decimal = Decimal::ONE;
109
110    #[fixture]
111    fn instrument_gbpusd() -> InstrumentAny {
112        InstrumentAny::CurrencyPair(default_fx_ccy(Symbol::from_str_unchecked("GBP/USD"), None))
113    }
114
115    #[fixture]
116    fn instrument_gbpusd_without_max_quantity() -> InstrumentAny {
117        let mut instrument = default_fx_ccy(Symbol::from_str_unchecked("GBP/USD"), None);
118        instrument.max_quantity = None;
119        InstrumentAny::CurrencyPair(instrument)
120    }
121
122    #[rstest]
123    fn test_calculate_with_zero_equity_returns_quantity_zero(instrument_gbpusd: InstrumentAny) {
124        let equity = Money::zero(instrument_gbpusd.quote_currency());
125        let entry = Price::new(1.00100, instrument_gbpusd.price_precision());
126        let stop_loss = Price::new(1.00000, instrument_gbpusd.price_precision());
127
128        let result = calculate_fixed_risk_position_size(
129            &instrument_gbpusd,
130            entry,
131            stop_loss,
132            equity,
133            Decimal::new(1, 3), // 0.001%
134            Decimal::ZERO,
135            EXCHANGE_RATE,
136            None,
137            Decimal::from(1000),
138            1,
139        );
140
141        assert_eq!(result, Quantity::from("0.0"));
142    }
143
144    #[rstest]
145    fn test_calculate_with_zero_exchange_rate(instrument_gbpusd: InstrumentAny) {
146        let equity = Money::new(100_000.0, instrument_gbpusd.quote_currency());
147        let entry = Price::new(1.00100, instrument_gbpusd.price_precision());
148        let stop_loss = Price::new(1.00000, instrument_gbpusd.price_precision());
149
150        let result = calculate_fixed_risk_position_size(
151            &instrument_gbpusd,
152            entry,
153            stop_loss,
154            equity,
155            Decimal::new(1, 3), // 0.001%
156            Decimal::ZERO,
157            Decimal::ZERO, // Zero exchange rate
158            None,
159            Decimal::from(1000),
160            1,
161        );
162
163        assert_eq!(result, Quantity::from("0.0"));
164    }
165
166    #[rstest]
167    fn test_calculate_with_zero_risk(instrument_gbpusd: InstrumentAny) {
168        let equity = Money::new(100_000.0, instrument_gbpusd.quote_currency());
169        let price = Price::new(1.00100, instrument_gbpusd.price_precision());
170
171        let result = calculate_fixed_risk_position_size(
172            &instrument_gbpusd,
173            price,
174            price, // Same price = no risk
175            equity,
176            Decimal::new(1, 3), // 0.001%
177            Decimal::ZERO,
178            EXCHANGE_RATE,
179            None,
180            Decimal::from(1000),
181            1,
182        );
183
184        assert_eq!(result, Quantity::from("0.0"));
185    }
186
187    #[rstest]
188    fn test_calculate_single_unit_size(instrument_gbpusd: InstrumentAny) {
189        let equity = Money::new(1_000_000.0, instrument_gbpusd.quote_currency());
190        let entry = Price::new(1.00100, instrument_gbpusd.price_precision());
191        let stop_loss = Price::new(1.00000, instrument_gbpusd.price_precision());
192
193        let result = calculate_fixed_risk_position_size(
194            &instrument_gbpusd,
195            entry,
196            stop_loss,
197            equity,
198            Decimal::new(1, 3), // 0.001%
199            Decimal::ZERO,
200            EXCHANGE_RATE,
201            None,
202            Decimal::from(1000),
203            1,
204        );
205
206        assert_eq!(result, Quantity::from("1000000.0"));
207    }
208
209    #[rstest]
210    fn test_calculate_single_unit_with_exchange_rate(instrument_gbpusd: InstrumentAny) {
211        let equity = Money::new(1_000_000.0, Currency::USD());
212        let entry = Price::new(110.010, instrument_gbpusd.price_precision());
213        let stop_loss = Price::new(110.000, instrument_gbpusd.price_precision());
214
215        let result = calculate_fixed_risk_position_size(
216            &instrument_gbpusd,
217            entry,
218            stop_loss,
219            equity,
220            Decimal::new(1, 3), // 0.1%
221            Decimal::ZERO,
222            Decimal::from_f64(0.00909).unwrap(), // 1/110
223            None,
224            Decimal::from(1),
225            1,
226        );
227
228        assert_eq!(result, Quantity::from("1000000.0"));
229    }
230
231    #[rstest]
232    fn test_calculate_single_unit_size_when_risk_too_high(instrument_gbpusd: InstrumentAny) {
233        let equity = Money::new(100_000.0, Currency::USD());
234        let entry = Price::new(3.00000, instrument_gbpusd.price_precision());
235        let stop_loss = Price::new(1.00000, instrument_gbpusd.price_precision());
236
237        let result = calculate_fixed_risk_position_size(
238            &instrument_gbpusd,
239            entry,
240            stop_loss,
241            equity,
242            Decimal::new(1, 2), // 1%
243            Decimal::ZERO,
244            EXCHANGE_RATE,
245            None,
246            Decimal::from(1000),
247            1,
248        );
249
250        assert_eq!(result, Quantity::from("0.0"));
251    }
252
253    #[rstest]
254    fn test_impose_hard_limit(instrument_gbpusd: InstrumentAny) {
255        let equity = Money::new(1_000_000.0, instrument_gbpusd.quote_currency());
256        let entry = Price::new(1.00010, instrument_gbpusd.price_precision());
257        let stop_loss = Price::new(1.00000, instrument_gbpusd.price_precision());
258
259        let result = calculate_fixed_risk_position_size(
260            &instrument_gbpusd,
261            entry,
262            stop_loss,
263            equity,
264            Decimal::new(1, 2), // 1%
265            Decimal::ZERO,
266            EXCHANGE_RATE,
267            Some(Decimal::from(500_000)),
268            Decimal::from(1000),
269            1,
270        );
271
272        assert_eq!(result, Quantity::from("500000.0"));
273    }
274
275    #[rstest]
276    fn test_calculate_without_max_quantity_leaves_size_uncapped(
277        instrument_gbpusd_without_max_quantity: InstrumentAny,
278    ) {
279        let equity = Money::from("1000000 USD");
280        let entry = Price::from("1.00010");
281        let stop_loss = Price::from("1.00000");
282
283        let result = calculate_fixed_risk_position_size(
284            &instrument_gbpusd_without_max_quantity,
285            entry,
286            stop_loss,
287            equity,
288            dec!(0.01),
289            Decimal::ZERO,
290            EXCHANGE_RATE,
291            None,
292            Decimal::from(1000),
293            1,
294        );
295
296        assert_eq!(result.as_decimal(), dec!(100000000));
297    }
298
299    #[rstest]
300    fn test_calculate_multiple_unit_size(instrument_gbpusd: InstrumentAny) {
301        let equity = Money::new(1_000_000.0, instrument_gbpusd.quote_currency());
302        let entry = Price::new(1.00010, instrument_gbpusd.price_precision());
303        let stop_loss = Price::new(1.00000, instrument_gbpusd.price_precision());
304
305        let result = calculate_fixed_risk_position_size(
306            &instrument_gbpusd,
307            entry,
308            stop_loss,
309            equity,
310            Decimal::new(1, 3), // 0.1%
311            Decimal::ZERO,
312            EXCHANGE_RATE,
313            None,
314            Decimal::from(1000),
315            3, // 3 units
316        );
317
318        assert_eq!(result, Quantity::from("1000000.0"));
319    }
320
321    #[rstest]
322    fn test_calculate_multiple_unit_size_larger_batches(instrument_gbpusd: InstrumentAny) {
323        let equity = Money::new(1_000_000.0, instrument_gbpusd.quote_currency());
324        let entry = Price::new(1.00087, instrument_gbpusd.price_precision());
325        let stop_loss = Price::new(1.00000, instrument_gbpusd.price_precision());
326
327        let result = calculate_fixed_risk_position_size(
328            &instrument_gbpusd,
329            entry,
330            stop_loss,
331            equity,
332            Decimal::new(1, 3), // 0.1%
333            Decimal::ZERO,
334            EXCHANGE_RATE,
335            None,
336            Decimal::from(25000),
337            4, // 4 units
338        );
339
340        assert_eq!(result, Quantity::from("275000.0"));
341    }
342
343    #[rstest]
344    fn test_calculate_for_gbpusd_with_commission(instrument_gbpusd: InstrumentAny) {
345        let equity = Money::new(1_000_000.0, instrument_gbpusd.quote_currency());
346        let entry = Price::new(107.703, instrument_gbpusd.price_precision());
347        let stop_loss = Price::new(107.403, instrument_gbpusd.price_precision());
348
349        let result = calculate_fixed_risk_position_size(
350            &instrument_gbpusd,
351            entry,
352            stop_loss,
353            equity,
354            Decimal::new(1, 2),                    // 1%
355            Decimal::new(2, 4),                    // 0.0002
356            Decimal::from_f64(0.009_931).unwrap(), // 1/107.403
357            None,
358            Decimal::from(1000),
359            1,
360        );
361
362        assert_eq!(result, Quantity::from("1000000.0"));
363    }
364}