1use 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#[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 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), 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), Decimal::ZERO,
231 Decimal::ZERO, 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, equity,
251 Decimal::new(1, 3), 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), 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 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), 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), Decimal::ZERO,
326 Decimal::from_f64(0.00909).unwrap(), 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), 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), 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), Decimal::ZERO,
420 EXCHANGE_RATE,
421 None,
422 Decimal::from(1000),
423 3, )
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), Decimal::ZERO,
443 EXCHANGE_RATE,
444 None,
445 Decimal::from(25000),
446 4, )
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), Decimal::new(2, 4), Decimal::from_f64(0.009_931).unwrap(), 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}