nautilus_risk/
sizing.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2025 Posei Systems Pty Ltd. All rights reserved.
3//  https://poseitrader.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::{
22    Decimal,
23    prelude::{FromPrimitive, ToPrimitive},
24};
25
26/// Calculates the position size based on fixed risk parameters.
27///
28/// # Panics
29///
30/// Panics if converting `units` to a decimal fails,
31/// or if converting the final size to `f64` fails.
32#[must_use]
33#[allow(clippy::too_many_arguments)]
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 instrument.make_qty(0.0, None);
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 instrument.make_qty(0.0, None);
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: Decimal = position_size_batched.min(
73        instrument
74            .max_quantity()
75            .unwrap_or_else(|| instrument.make_qty(0.0, None))
76            .as_decimal(),
77    );
78
79    Quantity::new(
80        final_size
81            .to_f64()
82            .expect("Error: Decimal to f64 conversion failed"),
83        instrument.size_precision(),
84    )
85}
86
87// Helper functions
88fn calculate_risk_ticks(entry: Price, stop_loss: Price, instrument: &InstrumentAny) -> Decimal {
89    (entry - stop_loss).as_decimal().abs() / instrument.price_increment().as_decimal()
90}
91
92fn calculate_riskable_money(equity: Decimal, risk: Decimal, commission_rate: Decimal) -> Decimal {
93    if equity <= Decimal::ZERO {
94        return Decimal::ZERO;
95    }
96
97    let risk_money = equity * risk;
98    let commission = risk_money * commission_rate * Decimal::TWO; // (round turn)
99
100    risk_money - commission
101}
102
103#[cfg(test)]
104mod tests {
105    use nautilus_model::{
106        identifiers::Symbol, instruments::stubs::default_fx_ccy, types::Currency,
107    };
108    use rstest::*;
109
110    use super::*;
111
112    const EXCHANGE_RATE: Decimal = Decimal::ONE;
113
114    #[fixture]
115    fn instrument_gbpusd() -> InstrumentAny {
116        InstrumentAny::CurrencyPair(default_fx_ccy(Symbol::from_str_unchecked("GBP/USD"), None))
117    }
118
119    #[rstest]
120    fn test_calculate_with_zero_equity_returns_quantity_zero(instrument_gbpusd: InstrumentAny) {
121        let equity = Money::new(0.0, instrument_gbpusd.quote_currency());
122        let entry = Price::new(1.00100, instrument_gbpusd.price_precision());
123        let stop_loss = Price::new(1.00000, instrument_gbpusd.price_precision());
124
125        let result = calculate_fixed_risk_position_size(
126            instrument_gbpusd,
127            entry,
128            stop_loss,
129            equity,
130            Decimal::new(1, 3), // 0.001%
131            Decimal::ZERO,
132            EXCHANGE_RATE,
133            None,
134            Decimal::from(1000),
135            1,
136        );
137
138        assert_eq!(result.as_f64(), 0.0);
139    }
140
141    #[rstest]
142    fn test_calculate_with_zero_exchange_rate(instrument_gbpusd: InstrumentAny) {
143        let equity = Money::new(100000.0, instrument_gbpusd.quote_currency());
144        let entry = Price::new(1.00100, instrument_gbpusd.price_precision());
145        let stop_loss = Price::new(1.00000, instrument_gbpusd.price_precision());
146
147        let result = calculate_fixed_risk_position_size(
148            instrument_gbpusd,
149            entry,
150            stop_loss,
151            equity,
152            Decimal::new(1, 3), // 0.001%
153            Decimal::ZERO,
154            Decimal::ZERO, // Zero exchange rate
155            None,
156            Decimal::from(1000),
157            1,
158        );
159
160        assert_eq!(result.as_f64(), 0.0);
161    }
162
163    #[rstest]
164    fn test_calculate_with_zero_risk(instrument_gbpusd: InstrumentAny) {
165        let equity = Money::new(100000.0, instrument_gbpusd.quote_currency());
166        let price = Price::new(1.00100, instrument_gbpusd.price_precision());
167
168        let result = calculate_fixed_risk_position_size(
169            instrument_gbpusd,
170            price,
171            price, // Same price = no risk
172            equity,
173            Decimal::new(1, 3), // 0.001%
174            Decimal::ZERO,
175            EXCHANGE_RATE,
176            None,
177            Decimal::from(1000),
178            1,
179        );
180
181        assert_eq!(result.as_f64(), 0.0);
182    }
183
184    #[rstest]
185    fn test_calculate_single_unit_size(instrument_gbpusd: InstrumentAny) {
186        let equity = Money::new(1_000_000.0, instrument_gbpusd.quote_currency());
187        let entry = Price::new(1.00100, instrument_gbpusd.price_precision());
188        let stop_loss = Price::new(1.00000, instrument_gbpusd.price_precision());
189
190        let result = calculate_fixed_risk_position_size(
191            instrument_gbpusd,
192            entry,
193            stop_loss,
194            equity,
195            Decimal::new(1, 3), // 0.001%
196            Decimal::ZERO,
197            EXCHANGE_RATE,
198            None,
199            Decimal::from(1000),
200            1,
201        );
202
203        assert_eq!(result.as_f64(), 1_000_000.0);
204    }
205
206    #[rstest]
207    fn test_calculate_single_unit_with_exchange_rate(instrument_gbpusd: InstrumentAny) {
208        let equity = Money::new(1_000_000.0, Currency::USD());
209        let entry = Price::new(110.010, instrument_gbpusd.price_precision());
210        let stop_loss = Price::new(110.000, instrument_gbpusd.price_precision());
211
212        let result = calculate_fixed_risk_position_size(
213            instrument_gbpusd,
214            entry,
215            stop_loss,
216            equity,
217            Decimal::new(1, 3), // 0.1%
218            Decimal::ZERO,
219            Decimal::from_f64(0.00909).unwrap(), // 1/110
220            None,
221            Decimal::from(1),
222            1,
223        );
224
225        assert_eq!(result.as_f64(), 1_000_000.0);
226    }
227
228    #[rstest]
229    fn test_calculate_single_unit_size_when_risk_too_high(instrument_gbpusd: InstrumentAny) {
230        let equity = Money::new(100000.0, Currency::USD());
231        let entry = Price::new(3.00000, instrument_gbpusd.price_precision());
232        let stop_loss = Price::new(1.00000, instrument_gbpusd.price_precision());
233
234        let result = calculate_fixed_risk_position_size(
235            instrument_gbpusd,
236            entry,
237            stop_loss,
238            equity,
239            Decimal::new(1, 2), // 1%
240            Decimal::ZERO,
241            EXCHANGE_RATE,
242            None,
243            Decimal::from(1000),
244            1,
245        );
246
247        assert_eq!(result.as_f64(), 0.0);
248    }
249
250    #[rstest]
251    fn test_impose_hard_limit(instrument_gbpusd: InstrumentAny) {
252        let equity = Money::new(1_000_000.0, instrument_gbpusd.quote_currency());
253        let entry = Price::new(1.00010, instrument_gbpusd.price_precision());
254        let stop_loss = Price::new(1.00000, instrument_gbpusd.price_precision());
255
256        let result = calculate_fixed_risk_position_size(
257            instrument_gbpusd,
258            entry,
259            stop_loss,
260            equity,
261            Decimal::new(1, 2), // 1%
262            Decimal::ZERO,
263            EXCHANGE_RATE,
264            Some(Decimal::from(500000)),
265            Decimal::from(1000),
266            1,
267        );
268
269        assert_eq!(result.as_f64(), 500_000.0);
270    }
271
272    #[rstest]
273    fn test_calculate_multiple_unit_size(instrument_gbpusd: InstrumentAny) {
274        let equity = Money::new(1_000_000.0, instrument_gbpusd.quote_currency());
275        let entry = Price::new(1.00010, instrument_gbpusd.price_precision());
276        let stop_loss = Price::new(1.00000, instrument_gbpusd.price_precision());
277
278        let result = calculate_fixed_risk_position_size(
279            instrument_gbpusd,
280            entry,
281            stop_loss,
282            equity,
283            Decimal::new(1, 3), // 0.1%
284            Decimal::ZERO,
285            EXCHANGE_RATE,
286            None,
287            Decimal::from(1000),
288            3, // 3 units
289        );
290
291        assert_eq!(result.as_f64(), 1000000.0);
292    }
293
294    #[rstest]
295    fn test_calculate_multiple_unit_size_larger_batches(instrument_gbpusd: InstrumentAny) {
296        let equity = Money::new(1_000_000.0, instrument_gbpusd.quote_currency());
297        let entry = Price::new(1.00087, instrument_gbpusd.price_precision());
298        let stop_loss = Price::new(1.00000, instrument_gbpusd.price_precision());
299
300        let result = calculate_fixed_risk_position_size(
301            instrument_gbpusd,
302            entry,
303            stop_loss,
304            equity,
305            Decimal::new(1, 3), // 0.1%
306            Decimal::ZERO,
307            EXCHANGE_RATE,
308            None,
309            Decimal::from(25000),
310            4, // 4 units
311        );
312
313        assert_eq!(result.as_f64(), 275000.0);
314    }
315
316    #[rstest]
317    fn test_calculate_for_gbpusd_with_commission(instrument_gbpusd: InstrumentAny) {
318        let equity = Money::new(1_000_000.0, instrument_gbpusd.quote_currency());
319        let entry = Price::new(107.703, instrument_gbpusd.price_precision());
320        let stop_loss = Price::new(107.403, instrument_gbpusd.price_precision());
321
322        let result = calculate_fixed_risk_position_size(
323            instrument_gbpusd,
324            entry,
325            stop_loss,
326            equity,
327            Decimal::new(1, 2),                   // 1%
328            Decimal::new(2, 4),                   // 0.0002
329            Decimal::from_f64(0.009931).unwrap(), // 1/107.403
330            None,
331            Decimal::from(1000),
332            1,
333        );
334
335        assert_eq!(result.as_f64(), 1000000.0);
336    }
337}