nautilus_risk/
sizing.rs

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