1use nautilus_model::{
18 instruments::{Instrument, InstrumentAny},
19 types::{Money, Price, Quantity},
20};
21use rust_decimal::{Decimal, prelude::FromPrimitive};
22
23#[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
82fn 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; 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), 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), Decimal::ZERO,
157 Decimal::ZERO, 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, equity,
176 Decimal::new(1, 3), 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), 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), Decimal::ZERO,
222 Decimal::from_f64(0.00909).unwrap(), 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), 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), 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), Decimal::ZERO,
312 EXCHANGE_RATE,
313 None,
314 Decimal::from(1000),
315 3, );
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), Decimal::ZERO,
334 EXCHANGE_RATE,
335 None,
336 Decimal::from(25000),
337 4, );
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), Decimal::new(2, 4), Decimal::from_f64(0.009_931).unwrap(), None,
358 Decimal::from(1000),
359 1,
360 );
361
362 assert_eq!(result, Quantity::from("1000000.0"));
363 }
364}