1use crate::types::Side;
6use qs_instruments::{
7 AdjustmentDirection, Decimal, EconomicsModelId, GridAdjustment, GridRounding,
8 InstrumentEconomics, InstrumentSpec, Money, QuantityRules, QuantityUnit,
9};
10use qs_symbols::SymbolSpec;
11use serde::Serialize;
12use thiserror::Error;
13
14#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
16pub enum SizingPolicy {
17 FixedLot {
19 lots: f64,
21 },
22 FixedRiskAmount {
24 amount: f64,
26 },
27 BalanceRiskPercent {
29 percent: f64,
31 },
32}
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum LotCapStatus {
37 NotCapped,
39 CappedAtMaximum,
41}
42
43#[derive(Debug, Clone, PartialEq)]
45pub struct SizingResult {
46 pub final_lot_steps: u64,
48 pub final_lot: f64,
50 pub scaled_raw_lot: f64,
52 pub requested_account_risk: Option<f64>,
54 pub native_loss_per_lot: Option<f64>,
56 pub account_loss_per_lot: Option<f64>,
58 pub cap_status: LotCapStatus,
60 pub quantity_adjustment: Option<GridAdjustment<Decimal>>,
62 pub final_notional: Option<Money>,
64}
65
66#[derive(Debug, Clone, PartialEq, Error)]
68pub enum SizingError {
69 #[error("risk_multiplier must be finite and positive, got {value}")]
71 InvalidRiskMultiplier {
72 value: f64,
74 },
75 #[error("fixed lots must be finite and positive, got {value}")]
77 InvalidFixedLots {
78 value: f64,
80 },
81 #[error("fixed risk amount must be finite and positive, got {value}")]
83 InvalidFixedRiskAmount {
84 value: f64,
86 },
87 #[error("balance risk percent must be finite and positive, got {value}")]
89 InvalidBalanceRiskPercent {
90 value: f64,
92 },
93 #[error("balance_before must be finite and positive, got {value}")]
95 InvalidBalanceBefore {
96 value: f64,
98 },
99 #[error("entry price must be finite and positive, got {value}")]
101 InvalidEntryPrice {
102 value: f64,
104 },
105 #[error("protective stop must be finite and positive, got {value}")]
107 InvalidProtectiveStop {
108 value: f64,
110 },
111 #[error("monetary sizing requires a protective stop")]
113 MissingProtectiveStop,
114 #[error("invalid {side} protective stop geometry: entry {entry_price}, stop {stop_price}")]
116 InvalidStopGeometry {
117 side: Side,
119 entry_price: f64,
121 stop_price: f64,
123 },
124 #[error("entry {entry_price} and stop {stop_price} have zero distance at {digits} digits")]
126 StopDistanceBelowTick {
127 entry_price: f64,
129 stop_price: f64,
131 digits: u16,
133 },
134 #[error("{field} price {value} is out of range at {digits} digits")]
136 PriceOutOfRange {
137 field: &'static str,
139 value: f64,
141 digits: u16,
143 },
144 #[error("monetary sizing requires account_loss_per_lot")]
146 MissingAccountLossPerLot,
147 #[error("account_loss_per_lot must be finite and positive, got {value}")]
149 InvalidAccountLossPerLot {
150 value: f64,
152 },
153 #[error("invalid symbol price precision: digits={digits}, pip_position={pip_position}")]
155 InvalidPricePrecision {
156 digits: u16,
158 pip_position: u16,
160 },
161 #[error("symbol lot_base_units must be positive, got {value}")]
163 InvalidLotBaseUnits {
164 value: i64,
166 },
167 #[error("symbol lot_step_units must be positive, got {value}")]
169 InvalidLotStepUnits {
170 value: i64,
172 },
173 #[error("symbol lot_min_steps must be positive, got {value}")]
175 InvalidMinimumLotSteps {
176 value: i64,
178 },
179 #[error("symbol lot_max_steps {maximum} must be zero or at least lot_min_steps {minimum}")]
181 InvalidMaximumLotSteps {
182 maximum: i64,
184 minimum: i64,
186 },
187 #[error(
189 "scaling policy value {base_value} by risk_multiplier {risk_multiplier} did not produce a finite positive value"
190 )]
191 InvalidScaledPolicyValue {
192 base_value: f64,
194 risk_multiplier: f64,
196 },
197 #[error("scaled raw lot must be finite and positive, got {value}")]
199 InvalidScaledRawLot {
200 value: f64,
202 },
203 #[error("native loss per lot is not finite for entry {entry_price} and stop {stop_price}")]
205 InvalidNativeLossPerLot {
206 entry_price: f64,
208 stop_price: f64,
210 },
211 #[error(
213 "scaled raw lot {scaled_raw_lot} floors to {floored_lot_steps} steps below minimum {minimum_lot_steps}"
214 )]
215 BelowMinimumLot {
216 scaled_raw_lot: f64,
218 floored_lot_steps: u64,
220 minimum_lot_steps: u64,
222 },
223 #[error("scaled raw lot {scaled_raw_lot} exceeds the supported lot step count")]
225 LotStepOverflow {
226 scaled_raw_lot: f64,
228 },
229}
230
231#[derive(Debug, Clone, PartialEq, Error)]
233pub enum InstrumentSizingError {
234 #[error(transparent)]
236 Sizing(#[from] SizingError),
237 #[error(
239 "unsupported instrument sizing combination: quantity_unit={quantity_unit:?}, pnl_model={pnl_model}"
240 )]
241 UnsupportedInstrumentSizing {
242 quantity_unit: QuantityUnit,
244 pnl_model: EconomicsModelId,
246 },
247 #[error("catalog-backed monetary sizing requires native_to_account_rate")]
249 MissingNativeToAccountRate,
250 #[error("native_to_account_rate must be finite and positive, got {value}")]
252 InvalidNativeToAccountRate {
253 value: f64,
255 },
256 #[error("{field} decimal {value} cannot be represented as a finite f64")]
258 ExactDecimalOutOfRange {
259 field: &'static str,
261 value: Decimal,
263 },
264 #[error("quantity grid origin must be zero for standard-lot sizing, got {value}")]
266 UnsupportedQuantityGridOrigin {
267 value: Decimal,
269 },
270 #[error("{field} quantity {value} is not on the declared quantity grid")]
272 QuantityBoundOffGrid {
273 field: &'static str,
275 value: Decimal,
277 },
278 #[error("maximum quantity {maximum} is below minimum quantity {minimum}")]
280 InvalidQuantityBounds {
281 minimum: Decimal,
283 maximum: Decimal,
285 },
286 #[error("{field} quantity {value} exceeds the supported lot step count")]
288 QuantityStepOverflow {
289 field: &'static str,
291 value: Decimal,
293 },
294 #[error("invalid quantity grid: {0}")]
296 InvalidQuantityGrid(#[from] qs_instruments::GridError),
297 #[error("{field} price {value} is outside the declared price grid")]
299 PriceOffGrid {
300 field: &'static str,
302 value: Decimal,
304 },
305 #[error("notional asset {notional_asset} must match settlement asset {settlement_asset}")]
307 UnsupportedNotionalAsset {
308 notional_asset: qs_instruments::AssetId,
310 settlement_asset: qs_instruments::AssetId,
312 },
313 #[error("final notional {notional} is below minimum {minimum}")]
315 BelowMinimumNotional {
316 notional: Decimal,
318 minimum: Decimal,
320 },
321 #[error("final notional {notional} exceeds maximum {maximum}")]
323 AboveMaximumNotional {
324 notional: Decimal,
326 maximum: Decimal,
328 },
329 #[error("invalid exact instrument arithmetic: {0}")]
331 ExactArithmetic(#[from] qs_instruments::DecimalError),
332}
333
334#[derive(Debug, Clone, Copy)]
335struct ValidatedLotSpec {
336 lot_base_units: u64,
337 lot_step_units: u64,
338 lot_min_steps: u64,
339 lot_max_steps: Option<u64>,
340}
341
342#[derive(Debug, Clone, Copy)]
343struct ValidatedQuantityRules {
344 step: Decimal,
345 minimum_steps: u64,
346 maximum_steps: Option<u64>,
347}
348
349#[derive(Debug, Clone, Copy)]
350enum PolicyBasis {
351 FixedLots(f64),
352 AccountRisk(f64),
353}
354
355#[allow(clippy::too_many_arguments)]
359pub fn compute_size(
360 policy: &SizingPolicy,
361 risk_multiplier: f64,
362 balance_before: f64,
363 side: Side,
364 entry_price: f64,
365 protective_stop: Option<f64>,
366 spec: &SymbolSpec,
367 account_loss_per_lot: Option<f64>,
368) -> Result<SizingResult, SizingError> {
369 validate_risk_multiplier(risk_multiplier)?;
370 validate_entry_price(entry_price)?;
371 let lot_spec = validate_lot_spec(spec)?;
372 let basis = policy_basis(policy, balance_before)?;
373 let native_loss_per_lot = protective_stop
374 .map(|stop_price| compute_native_loss_per_lot(side, entry_price, stop_price, spec))
375 .transpose()?;
376 let sizing = compute_raw_size(
377 basis,
378 risk_multiplier,
379 native_loss_per_lot,
380 account_loss_per_lot,
381 )?;
382 let (final_lot_steps, final_lot, cap_status) =
383 apply_lot_constraints(sizing.scaled_raw_lot, lot_spec)?;
384
385 Ok(sizing.into_result(final_lot_steps, final_lot, cap_status, None, None))
386}
387
388#[allow(clippy::too_many_arguments)]
392pub fn compute_instrument_size(
393 policy: &SizingPolicy,
394 risk_multiplier: f64,
395 balance_before: f64,
396 side: Side,
397 entry_price: f64,
398 protective_stop: Option<f64>,
399 price_digits: u16,
400 quantity_rules: &QuantityRules,
401 economics: &InstrumentEconomics,
402 native_to_account_rate: Option<f64>,
403) -> Result<SizingResult, InstrumentSizingError> {
404 validate_risk_multiplier(risk_multiplier)?;
405 validate_entry_price(entry_price)?;
406 validate_instrument_economics(economics)?;
407 let quantity_rules = validate_quantity_rules(quantity_rules)?;
408 let basis = policy_basis(policy, balance_before)?;
409 let native_loss_per_lot = protective_stop
410 .map(|stop_price| {
411 compute_instrument_native_loss_per_lot(
412 side,
413 entry_price,
414 stop_price,
415 price_digits,
416 economics,
417 )
418 })
419 .transpose()?;
420 let account_loss_per_lot =
421 instrument_account_loss(basis, native_loss_per_lot, native_to_account_rate)?;
422 let sizing = compute_raw_size(
423 basis,
424 risk_multiplier,
425 native_loss_per_lot,
426 account_loss_per_lot,
427 )?;
428 let (final_lot_steps, final_lot, cap_status, quantity_adjustment) =
429 apply_quantity_constraints(sizing.scaled_raw_lot, quantity_rules)?;
430 Ok(sizing.into_result(
431 final_lot_steps,
432 final_lot,
433 cap_status,
434 Some(quantity_adjustment),
435 None,
436 ))
437}
438
439#[allow(clippy::too_many_arguments)]
443pub fn compute_instrument_size_for_spec(
444 policy: &SizingPolicy,
445 risk_multiplier: f64,
446 balance_before: f64,
447 side: Side,
448 entry_price: f64,
449 protective_stop: Option<f64>,
450 spec: &InstrumentSpec,
451 native_to_account_rate: Option<f64>,
452) -> Result<SizingResult, InstrumentSizingError> {
453 compute_instrument_size_for_spec_with_prices(
454 policy,
455 risk_multiplier,
456 balance_before,
457 side,
458 entry_price,
459 entry_price,
460 protective_stop,
461 spec,
462 native_to_account_rate,
463 )
464}
465
466#[allow(clippy::too_many_arguments)]
470pub fn compute_instrument_size_for_spec_with_prices(
471 policy: &SizingPolicy,
472 risk_multiplier: f64,
473 balance_before: f64,
474 side: Side,
475 sizing_reference_price: f64,
476 execution_notional_price: f64,
477 protective_stop: Option<f64>,
478 spec: &InstrumentSpec,
479 native_to_account_rate: Option<f64>,
480) -> Result<SizingResult, InstrumentSizingError> {
481 validate_risk_multiplier(risk_multiplier)?;
482 validate_entry_price(sizing_reference_price)?;
483 validate_entry_price(execution_notional_price)?;
484 validate_instrument_economics(&spec.economics)?;
485 let quantity_rules = validate_quantity_rules(&spec.quantity)?;
486 validate_price_grid("sizing reference", sizing_reference_price, spec)?;
487 let execution_notional_decimal =
488 validate_price_grid("execution notional", execution_notional_price, spec)?;
489 if let Some(stop) = protective_stop {
490 validate_price_grid("protective stop", stop, spec)?;
491 }
492 let basis = policy_basis(policy, balance_before)?;
493 let native_loss_per_lot = protective_stop
494 .map(|stop_price| {
495 compute_instrument_native_loss_per_lot(
496 side,
497 sizing_reference_price,
498 stop_price,
499 u16::from(spec.price.display_scale),
500 &spec.economics,
501 )
502 })
503 .transpose()?;
504 let account_loss_per_lot =
505 instrument_account_loss(basis, native_loss_per_lot, native_to_account_rate)?;
506 let sizing = compute_raw_size(
507 basis,
508 risk_multiplier,
509 native_loss_per_lot,
510 account_loss_per_lot,
511 )?;
512 let (final_lot_steps, final_lot, cap_status, quantity_adjustment) =
513 apply_quantity_constraints(sizing.scaled_raw_lot, quantity_rules)?;
514 let final_notional = validate_final_notional(
515 execution_notional_decimal,
516 quantity_adjustment.adjusted,
517 spec,
518 )?;
519
520 Ok(sizing.into_result(
521 final_lot_steps,
522 final_lot,
523 cap_status,
524 Some(quantity_adjustment),
525 final_notional,
526 ))
527}
528
529#[derive(Debug, Clone, Copy)]
530struct RawSizingResult {
531 scaled_raw_lot: f64,
532 requested_account_risk: Option<f64>,
533 native_loss_per_lot: Option<f64>,
534 account_loss_per_lot: Option<f64>,
535}
536
537impl RawSizingResult {
538 fn into_result(
539 self,
540 final_lot_steps: u64,
541 final_lot: f64,
542 cap_status: LotCapStatus,
543 quantity_adjustment: Option<GridAdjustment<Decimal>>,
544 final_notional: Option<Money>,
545 ) -> SizingResult {
546 SizingResult {
547 final_lot_steps,
548 final_lot,
549 scaled_raw_lot: self.scaled_raw_lot,
550 requested_account_risk: self.requested_account_risk,
551 native_loss_per_lot: self.native_loss_per_lot,
552 account_loss_per_lot: self.account_loss_per_lot,
553 cap_status,
554 quantity_adjustment,
555 final_notional,
556 }
557 }
558}
559
560fn compute_raw_size(
561 basis: PolicyBasis,
562 risk_multiplier: f64,
563 native_loss_per_lot: Option<f64>,
564 account_loss_per_lot: Option<f64>,
565) -> Result<RawSizingResult, SizingError> {
566 let scaled_policy_value = match basis {
567 PolicyBasis::FixedLots(base_value) | PolicyBasis::AccountRisk(base_value) => {
568 let scaled = base_value * risk_multiplier;
569 if !scaled.is_finite() || scaled <= 0.0 {
570 return Err(SizingError::InvalidScaledPolicyValue {
571 base_value,
572 risk_multiplier,
573 });
574 }
575 scaled
576 }
577 };
578
579 let (scaled_raw_lot, requested_account_risk, result_account_loss_per_lot) = match basis {
580 PolicyBasis::FixedLots(_) => (scaled_policy_value, None, None),
581 PolicyBasis::AccountRisk(_) => {
582 native_loss_per_lot.ok_or(SizingError::MissingProtectiveStop)?;
583 let account_loss_per_lot = account_loss_per_lot
584 .ok_or(SizingError::MissingAccountLossPerLot)
585 .and_then(validate_account_loss_per_lot)?;
586 let scaled_raw_lot = scaled_policy_value / account_loss_per_lot;
587 if !scaled_raw_lot.is_finite() || scaled_raw_lot <= 0.0 {
588 return Err(SizingError::InvalidScaledRawLot {
589 value: scaled_raw_lot,
590 });
591 }
592 (
593 scaled_raw_lot,
594 Some(scaled_policy_value),
595 Some(account_loss_per_lot),
596 )
597 }
598 };
599
600 Ok(RawSizingResult {
601 scaled_raw_lot,
602 requested_account_risk,
603 native_loss_per_lot,
604 account_loss_per_lot: result_account_loss_per_lot,
605 })
606}
607
608fn instrument_account_loss(
609 basis: PolicyBasis,
610 native_loss_per_lot: Option<f64>,
611 native_to_account_rate: Option<f64>,
612) -> Result<Option<f64>, InstrumentSizingError> {
613 match basis {
614 PolicyBasis::FixedLots(_) => Ok(None),
615 PolicyBasis::AccountRisk(_) => {
616 let native_loss_per_lot =
617 native_loss_per_lot.ok_or(SizingError::MissingProtectiveStop)?;
618 let conversion_rate = native_to_account_rate
619 .ok_or(InstrumentSizingError::MissingNativeToAccountRate)
620 .and_then(validate_native_to_account_rate)?;
621 let account_loss_per_lot = native_loss_per_lot * conversion_rate;
622 Ok(Some(validate_account_loss_per_lot(account_loss_per_lot)?))
623 }
624 }
625}
626
627fn validate_risk_multiplier(value: f64) -> Result<(), SizingError> {
628 if value.is_finite() && value > 0.0 {
629 Ok(())
630 } else {
631 Err(SizingError::InvalidRiskMultiplier { value })
632 }
633}
634
635fn validate_entry_price(value: f64) -> Result<(), SizingError> {
636 if value.is_finite() && value > 0.0 {
637 Ok(())
638 } else {
639 Err(SizingError::InvalidEntryPrice { value })
640 }
641}
642
643fn validate_account_loss_per_lot(value: f64) -> Result<f64, SizingError> {
644 if value.is_finite() && value > 0.0 {
645 Ok(value)
646 } else {
647 Err(SizingError::InvalidAccountLossPerLot { value })
648 }
649}
650
651fn validate_native_to_account_rate(value: f64) -> Result<f64, InstrumentSizingError> {
652 if value.is_finite() && value > 0.0 {
653 Ok(value)
654 } else {
655 Err(InstrumentSizingError::InvalidNativeToAccountRate { value })
656 }
657}
658
659fn policy_basis(policy: &SizingPolicy, balance_before: f64) -> Result<PolicyBasis, SizingError> {
660 match *policy {
661 SizingPolicy::FixedLot { lots } => {
662 if lots.is_finite() && lots > 0.0 {
663 Ok(PolicyBasis::FixedLots(lots))
664 } else {
665 Err(SizingError::InvalidFixedLots { value: lots })
666 }
667 }
668 SizingPolicy::FixedRiskAmount { amount } => {
669 if amount.is_finite() && amount > 0.0 {
670 Ok(PolicyBasis::AccountRisk(amount))
671 } else {
672 Err(SizingError::InvalidFixedRiskAmount { value: amount })
673 }
674 }
675 SizingPolicy::BalanceRiskPercent { percent } => {
676 if !percent.is_finite() || percent <= 0.0 {
677 return Err(SizingError::InvalidBalanceRiskPercent { value: percent });
678 }
679 if !balance_before.is_finite() || balance_before <= 0.0 {
680 return Err(SizingError::InvalidBalanceBefore {
681 value: balance_before,
682 });
683 }
684 let account_risk = balance_before * (percent / 100.0);
685 if account_risk.is_finite() && account_risk > 0.0 {
686 Ok(PolicyBasis::AccountRisk(account_risk))
687 } else {
688 Err(SizingError::InvalidScaledPolicyValue {
689 base_value: balance_before,
690 risk_multiplier: percent / 100.0,
691 })
692 }
693 }
694 }
695}
696
697fn validate_lot_spec(spec: &SymbolSpec) -> Result<ValidatedLotSpec, SizingError> {
698 let lot_base_units = u64::try_from(spec.lot_base_units)
699 .ok()
700 .filter(|value| *value > 0)
701 .ok_or(SizingError::InvalidLotBaseUnits {
702 value: spec.lot_base_units,
703 })?;
704 let lot_step_units = u64::try_from(spec.lot_step_units)
705 .ok()
706 .filter(|value| *value > 0)
707 .ok_or(SizingError::InvalidLotStepUnits {
708 value: spec.lot_step_units,
709 })?;
710 let lot_min_steps = u64::try_from(spec.lot_min_steps)
711 .ok()
712 .filter(|value| *value > 0)
713 .ok_or(SizingError::InvalidMinimumLotSteps {
714 value: spec.lot_min_steps,
715 })?;
716 let lot_max_steps = match spec.lot_max_steps {
717 0 => None,
718 maximum if maximum >= spec.lot_min_steps => Some(maximum as u64),
719 maximum => {
720 return Err(SizingError::InvalidMaximumLotSteps {
721 maximum,
722 minimum: spec.lot_min_steps,
723 });
724 }
725 };
726
727 Ok(ValidatedLotSpec {
728 lot_base_units,
729 lot_step_units,
730 lot_min_steps,
731 lot_max_steps,
732 })
733}
734
735fn validate_instrument_economics(
736 economics: &InstrumentEconomics,
737) -> Result<(), InstrumentSizingError> {
738 let supported_model = matches!(
739 economics.pnl_model.as_str(),
740 EconomicsModelId::FX_QUOTE_LINEAR_V1 | EconomicsModelId::CFD_QUOTE_LINEAR_V1
741 );
742 if economics.quantity_unit == QuantityUnit::StandardLot && supported_model {
743 Ok(())
744 } else {
745 Err(InstrumentSizingError::UnsupportedInstrumentSizing {
746 quantity_unit: economics.quantity_unit,
747 pnl_model: economics.pnl_model.clone(),
748 })
749 }
750}
751
752fn validate_quantity_rules(
753 rules: &QuantityRules,
754) -> Result<ValidatedQuantityRules, InstrumentSizingError> {
755 if !rules.grid.origin.is_zero() {
756 return Err(InstrumentSizingError::UnsupportedQuantityGridOrigin {
757 value: rules.grid.origin,
758 });
759 }
760
761 let step = rules.grid.step.get();
762 let minimum = rules.minimum.get();
763 if !rules.grid.contains(minimum)? {
764 return Err(InstrumentSizingError::QuantityBoundOffGrid {
765 field: "minimum",
766 value: minimum,
767 });
768 }
769 let minimum_steps = exact_quantity_steps("minimum", minimum, rules.grid.step.get())?;
770
771 let maximum_steps = match rules.maximum {
772 Some(maximum) => {
773 let maximum = maximum.get();
774 if maximum < minimum {
775 return Err(InstrumentSizingError::InvalidQuantityBounds { minimum, maximum });
776 }
777 if !rules.grid.contains(maximum)? {
778 return Err(InstrumentSizingError::QuantityBoundOffGrid {
779 field: "maximum",
780 value: maximum,
781 });
782 }
783 Some(exact_quantity_steps(
784 "maximum",
785 maximum,
786 rules.grid.step.get(),
787 )?)
788 }
789 None => None,
790 };
791
792 Ok(ValidatedQuantityRules {
793 step,
794 minimum_steps,
795 maximum_steps,
796 })
797}
798
799fn exact_quantity_steps(
800 field: &'static str,
801 quantity: Decimal,
802 step: Decimal,
803) -> Result<u64, InstrumentSizingError> {
804 let scale = quantity.scale().max(step.scale());
805 let quantity_factor = 10_i128
806 .checked_pow(u32::from(scale - quantity.scale()))
807 .ok_or(InstrumentSizingError::QuantityStepOverflow {
808 field,
809 value: quantity,
810 })?;
811 let step_factor = 10_i128.checked_pow(u32::from(scale - step.scale())).ok_or(
812 InstrumentSizingError::QuantityStepOverflow {
813 field,
814 value: quantity,
815 },
816 )?;
817 let quantity_coefficient = quantity.coefficient().checked_mul(quantity_factor).ok_or(
818 InstrumentSizingError::QuantityStepOverflow {
819 field,
820 value: quantity,
821 },
822 )?;
823 let step_coefficient = step.coefficient().checked_mul(step_factor).ok_or(
824 InstrumentSizingError::QuantityStepOverflow {
825 field,
826 value: quantity,
827 },
828 )?;
829 let steps = quantity_coefficient / step_coefficient;
830 u64::try_from(steps).map_err(|_| InstrumentSizingError::QuantityStepOverflow {
831 field,
832 value: quantity,
833 })
834}
835
836fn exact_decimal_to_f64(field: &'static str, value: Decimal) -> Result<f64, InstrumentSizingError> {
837 let converted = value.coefficient() as f64 / 10_f64.powi(i32::from(value.scale()));
838 if converted.is_finite() {
839 Ok(converted)
840 } else {
841 Err(InstrumentSizingError::ExactDecimalOutOfRange { field, value })
842 }
843}
844
845fn validate_price_grid(
846 field: &'static str,
847 price: f64,
848 spec: &InstrumentSpec,
849) -> Result<Decimal, InstrumentSizingError> {
850 let scale = 10_f64.powi(i32::from(spec.price.display_scale));
851 let normalized = (price * scale).round() / scale;
852 let price = Decimal::checked_from_f64(normalized)?;
853 if spec.price.grid.contains(price)? {
854 Ok(price)
855 } else {
856 Err(InstrumentSizingError::PriceOffGrid {
857 field,
858 value: price,
859 })
860 }
861}
862
863fn validate_final_notional(
864 entry_price: Decimal,
865 quantity: Decimal,
866 spec: &InstrumentSpec,
867) -> Result<Option<Money>, InstrumentSizingError> {
868 let Some(rules) = &spec.notional else {
869 return Ok(None);
870 };
871 if rules.asset != spec.economics.settlement_asset {
872 return Err(InstrumentSizingError::UnsupportedNotionalAsset {
873 notional_asset: rules.asset.clone(),
874 settlement_asset: spec.economics.settlement_asset.clone(),
875 });
876 }
877 let amount = entry_price
878 .checked_mul(quantity)?
879 .checked_mul(spec.economics.contract_multiplier.get())?;
880 if let Some(minimum) = rules.minimum
881 && amount < minimum.get()
882 {
883 return Err(InstrumentSizingError::BelowMinimumNotional {
884 notional: amount,
885 minimum: minimum.get(),
886 });
887 }
888 if let Some(maximum) = rules.maximum
889 && amount > maximum.get()
890 {
891 return Err(InstrumentSizingError::AboveMaximumNotional {
892 notional: amount,
893 maximum: maximum.get(),
894 });
895 }
896 Ok(Some(Money {
897 asset: rules.asset.clone(),
898 amount,
899 }))
900}
901
902pub fn compute_native_loss_per_lot(
906 side: Side,
907 entry_price: f64,
908 protective_stop: f64,
909 spec: &SymbolSpec,
910) -> Result<f64, SizingError> {
911 validate_entry_price(entry_price)?;
912 if spec.lot_base_units <= 0 {
913 return Err(SizingError::InvalidLotBaseUnits {
914 value: spec.lot_base_units,
915 });
916 }
917 compute_native_loss_with_multiplier(
918 side,
919 entry_price,
920 protective_stop,
921 spec.digits,
922 spec.pip_position,
923 spec.lot_base_units as f64,
924 )
925}
926
927pub fn compute_instrument_native_loss_per_lot(
931 side: Side,
932 entry_price: f64,
933 protective_stop: f64,
934 price_digits: u16,
935 economics: &InstrumentEconomics,
936) -> Result<f64, InstrumentSizingError> {
937 validate_instrument_economics(economics)?;
938 let contract_multiplier = exact_decimal_to_f64(
939 "instrument contract multiplier",
940 economics.contract_multiplier.get(),
941 )?;
942 Ok(compute_native_loss_with_multiplier(
943 side,
944 entry_price,
945 protective_stop,
946 price_digits,
947 price_digits,
948 contract_multiplier,
949 )?)
950}
951
952fn compute_native_loss_with_multiplier(
953 side: Side,
954 entry_price: f64,
955 protective_stop: f64,
956 digits: u16,
957 pip_position: u16,
958 contract_multiplier: f64,
959) -> Result<f64, SizingError> {
960 validate_entry_price(entry_price)?;
961 if !protective_stop.is_finite() || protective_stop <= 0.0 {
962 return Err(SizingError::InvalidProtectiveStop {
963 value: protective_stop,
964 });
965 }
966 let valid_geometry = match side {
967 Side::Buy => protective_stop < entry_price,
968 Side::Sell => protective_stop > entry_price,
969 };
970 if !valid_geometry {
971 return Err(SizingError::InvalidStopGeometry {
972 side,
973 entry_price,
974 stop_price: protective_stop,
975 });
976 }
977 if digits > 18 || pip_position > digits {
978 return Err(SizingError::InvalidPricePrecision {
979 digits,
980 pip_position,
981 });
982 }
983
984 let scale = 10_i64.pow(digits as u32) as f64;
985 let entry_ticks = price_to_ticks("entry", entry_price, digits, scale)?;
986 let stop_ticks = price_to_ticks("protective stop", protective_stop, digits, scale)?;
987 let distance_ticks = entry_ticks.abs_diff(stop_ticks);
988 if distance_ticks == 0 {
989 return Err(SizingError::StopDistanceBelowTick {
990 entry_price,
991 stop_price: protective_stop,
992 digits,
993 });
994 }
995
996 let native_loss_per_lot = distance_ticks as f64 * contract_multiplier / scale;
997 if !native_loss_per_lot.is_finite() || native_loss_per_lot <= 0.0 {
998 return Err(SizingError::InvalidNativeLossPerLot {
999 entry_price,
1000 stop_price: protective_stop,
1001 });
1002 }
1003
1004 Ok(native_loss_per_lot)
1005}
1006
1007fn price_to_ticks(
1008 field: &'static str,
1009 value: f64,
1010 digits: u16,
1011 scale: f64,
1012) -> Result<i64, SizingError> {
1013 let scaled = value * scale;
1014 if !scaled.is_finite() || scaled >= i64::MAX as f64 {
1015 return Err(SizingError::PriceOutOfRange {
1016 field,
1017 value,
1018 digits,
1019 });
1020 }
1021 Ok(scaled.round() as i64)
1022}
1023
1024fn apply_lot_constraints(
1025 scaled_raw_lot: f64,
1026 spec: ValidatedLotSpec,
1027) -> Result<(u64, f64, LotCapStatus), SizingError> {
1028 if !scaled_raw_lot.is_finite() || scaled_raw_lot <= 0.0 {
1029 return Err(SizingError::InvalidScaledRawLot {
1030 value: scaled_raw_lot,
1031 });
1032 }
1033
1034 let raw_steps = scaled_raw_lot * spec.lot_base_units as f64 / spec.lot_step_units as f64;
1035 if !raw_steps.is_finite() || raw_steps >= u64::MAX as f64 {
1036 return Err(SizingError::LotStepOverflow { scaled_raw_lot });
1037 }
1038 let floored_lot_steps = raw_steps.floor() as u64;
1039 if floored_lot_steps < spec.lot_min_steps {
1040 return Err(SizingError::BelowMinimumLot {
1041 scaled_raw_lot,
1042 floored_lot_steps,
1043 minimum_lot_steps: spec.lot_min_steps,
1044 });
1045 }
1046
1047 let (final_lot_steps, cap_status) = match spec.lot_max_steps {
1048 Some(maximum) if floored_lot_steps > maximum => (maximum, LotCapStatus::CappedAtMaximum),
1049 _ => (floored_lot_steps, LotCapStatus::NotCapped),
1050 };
1051 let final_lot =
1052 final_lot_steps as f64 * spec.lot_step_units as f64 / spec.lot_base_units as f64;
1053
1054 Ok((final_lot_steps, final_lot, cap_status))
1055}
1056
1057fn apply_quantity_constraints(
1058 scaled_raw_lot: f64,
1059 rules: ValidatedQuantityRules,
1060) -> Result<(u64, f64, LotCapStatus, GridAdjustment<Decimal>), InstrumentSizingError> {
1061 if !scaled_raw_lot.is_finite() || scaled_raw_lot <= 0.0 {
1062 return Err(SizingError::InvalidScaledRawLot {
1063 value: scaled_raw_lot,
1064 }
1065 .into());
1066 }
1067
1068 let requested = Decimal::checked_from_f64(scaled_raw_lot)?;
1069 let floored = qs_instruments::DecimalGrid::new(Decimal::ZERO, rules.step.try_into()?)
1070 .adjust(requested, GridRounding::Floor)?;
1071 let floored_lot_steps = exact_quantity_steps("adjusted", floored.adjusted, rules.step)?;
1072 if floored_lot_steps < rules.minimum_steps {
1073 return Err(SizingError::BelowMinimumLot {
1074 scaled_raw_lot,
1075 floored_lot_steps,
1076 minimum_lot_steps: rules.minimum_steps,
1077 }
1078 .into());
1079 }
1080
1081 let (final_lot_steps, cap_status) = match rules.maximum_steps {
1082 Some(maximum) if floored_lot_steps > maximum => (maximum, LotCapStatus::CappedAtMaximum),
1083 _ => (floored_lot_steps, LotCapStatus::NotCapped),
1084 };
1085 let final_quantity = rules
1086 .step
1087 .checked_mul(Decimal::new(i128::from(final_lot_steps), 0)?)?;
1088 let final_lot = exact_decimal_to_f64("final quantity", final_quantity)?;
1089 let direction = match final_quantity.cmp(&requested) {
1090 std::cmp::Ordering::Less => AdjustmentDirection::Down,
1091 std::cmp::Ordering::Equal => AdjustmentDirection::Unchanged,
1092 std::cmp::Ordering::Greater => AdjustmentDirection::Up,
1093 };
1094
1095 Ok((
1096 final_lot_steps,
1097 final_lot,
1098 cap_status,
1099 GridAdjustment {
1100 requested,
1101 adjusted: final_quantity,
1102 direction,
1103 },
1104 ))
1105}
1106
1107#[cfg(test)]
1108mod tests {
1109 use super::*;
1110 use std::collections::BTreeSet;
1111
1112 use qs_instruments::{
1113 AssetId, DecimalGrid, EconomicsModelId, EffectiveInterval, InstrumentAssets, InstrumentId,
1114 InstrumentSpec, ListingStatus, NotionalRules, PositiveDecimal, PriceRules, QuantityUnit,
1115 };
1116
1117 fn decimal(value: &str) -> Decimal {
1118 value.parse().unwrap()
1119 }
1120
1121 fn positive(value: &str) -> PositiveDecimal {
1122 value.parse().unwrap()
1123 }
1124
1125 fn quantity_rules(storage_scale: u8) -> QuantityRules {
1126 QuantityRules {
1127 grid: DecimalGrid::new(Decimal::ZERO, positive("0.01")),
1128 minimum: positive("0.01"),
1129 maximum: Some(positive("100")),
1130 storage_scale,
1131 }
1132 }
1133
1134 fn economics(
1135 quantity_unit: QuantityUnit,
1136 model: &str,
1137 multiplier: &str,
1138 ) -> InstrumentEconomics {
1139 InstrumentEconomics {
1140 pnl_model: EconomicsModelId::new(model).unwrap(),
1141 quantity_unit,
1142 contract_multiplier: positive(multiplier),
1143 settlement_asset: AssetId::new("USD").unwrap(),
1144 fee_model: None,
1145 funding_model: None,
1146 margin_model: None,
1147 }
1148 }
1149
1150 fn standard_lot_economics(multiplier: &str) -> InstrumentEconomics {
1151 economics(
1152 QuantityUnit::StandardLot,
1153 EconomicsModelId::FX_QUOTE_LINEAR_V1,
1154 multiplier,
1155 )
1156 }
1157
1158 fn instrument_spec(step: &str, notional: Option<NotionalRules>) -> InstrumentSpec {
1159 let usd = AssetId::new("USD").unwrap();
1160 InstrumentSpec {
1161 revision: "1.0.0".parse().unwrap(),
1162 instrument: InstrumentId::new(
1163 "broker-a".parse().unwrap(),
1164 qs_instruments::MarketKind::new(qs_instruments::MarketKind::FX_CFD).unwrap(),
1165 "EURUSD".parse().unwrap(),
1166 ),
1167 effective: EffectiveInterval::new("2026-01-01T00:00:00Z".parse().unwrap(), None)
1168 .unwrap(),
1169 status: ListingStatus::Trading,
1170 assets: InstrumentAssets {
1171 base: Some("EUR".parse().unwrap()),
1172 quote: Some(usd.clone()),
1173 settlement: usd,
1174 fee_assets: BTreeSet::new(),
1175 },
1176 price: PriceRules {
1177 grid: DecimalGrid::new(Decimal::ZERO, positive("0.00001")),
1178 display_scale: 5,
1179 },
1180 quantity: QuantityRules {
1181 grid: DecimalGrid::new(Decimal::ZERO, positive(step)),
1182 minimum: positive(step),
1183 maximum: Some(positive("100")),
1184 storage_scale: 2,
1185 },
1186 notional,
1187 economics: standard_lot_economics("100000"),
1188 aliases: BTreeSet::from(["EURUSD".parse().unwrap()]),
1189 }
1190 }
1191
1192 fn forex_spec() -> SymbolSpec {
1193 SymbolSpec {
1194 canonical: "eurusd".into(),
1195 pip_position: 4,
1196 digits: 5,
1197 category: "forex".into(),
1198 lot_base_units: 100_000,
1199 lot_step_units: 1_000,
1200 lot_min_steps: 1,
1201 lot_max_steps: 0,
1202 }
1203 }
1204
1205 fn assert_close(actual: f64, expected: f64) {
1206 assert!(
1207 (actual - expected).abs() < 1e-12,
1208 "expected {expected}, got {actual}"
1209 );
1210 }
1211
1212 #[test]
1213 fn catalog_sizing_uses_quantity_rules_and_contract_multiplier() {
1214 let result = compute_instrument_size(
1215 &SizingPolicy::FixedRiskAmount { amount: 100.0 },
1216 1.0,
1217 10_000.0,
1218 Side::Buy,
1219 1.10000,
1220 Some(1.09500),
1221 5,
1222 &quantity_rules(2),
1223 &standard_lot_economics("100000"),
1224 Some(1.0),
1225 )
1226 .unwrap();
1227
1228 assert_eq!(result.native_loss_per_lot, Some(500.0));
1229 assert_eq!(result.account_loss_per_lot, Some(500.0));
1230 assert_close(result.scaled_raw_lot, 0.2);
1231 assert_eq!(result.final_lot_steps, 20);
1232 assert_close(result.final_lot, 0.2);
1233 }
1234
1235 #[test]
1236 fn storage_scale_does_not_change_native_loss_or_monetary_size() {
1237 let economics = standard_lot_economics("100000");
1238 let low_scale = compute_instrument_size(
1239 &SizingPolicy::FixedRiskAmount { amount: 100.0 },
1240 1.0,
1241 10_000.0,
1242 Side::Buy,
1243 1.10000,
1244 Some(1.09500),
1245 5,
1246 &quantity_rules(2),
1247 &economics,
1248 Some(1.0),
1249 )
1250 .unwrap();
1251 let high_scale = compute_instrument_size(
1252 &SizingPolicy::FixedRiskAmount { amount: 100.0 },
1253 1.0,
1254 10_000.0,
1255 Side::Buy,
1256 1.10000,
1257 Some(1.09500),
1258 5,
1259 &quantity_rules(8),
1260 &economics,
1261 Some(1.0),
1262 )
1263 .unwrap();
1264
1265 assert_eq!(low_scale, high_scale);
1266 }
1267
1268 #[test]
1269 fn contract_multiplier_changes_native_loss_and_monetary_size() {
1270 let standard = compute_instrument_size(
1271 &SizingPolicy::FixedRiskAmount { amount: 100.0 },
1272 1.0,
1273 10_000.0,
1274 Side::Buy,
1275 1.10000,
1276 Some(1.09500),
1277 5,
1278 &quantity_rules(2),
1279 &standard_lot_economics("100000"),
1280 Some(1.0),
1281 )
1282 .unwrap();
1283 let doubled = compute_instrument_size(
1284 &SizingPolicy::FixedRiskAmount { amount: 100.0 },
1285 1.0,
1286 10_000.0,
1287 Side::Buy,
1288 1.10000,
1289 Some(1.09500),
1290 5,
1291 &quantity_rules(2),
1292 &standard_lot_economics("200000"),
1293 Some(1.0),
1294 )
1295 .unwrap();
1296
1297 assert_eq!(standard.native_loss_per_lot, Some(500.0));
1298 assert_eq!(doubled.native_loss_per_lot, Some(1_000.0));
1299 assert_close(standard.scaled_raw_lot, 0.2);
1300 assert_close(doubled.scaled_raw_lot, 0.1);
1301 assert_eq!(standard.final_lot_steps, 20);
1302 assert_eq!(doubled.final_lot_steps, 10);
1303 }
1304
1305 #[test]
1306 fn catalog_fixed_lot_uses_exact_quantity_grid_and_cap() {
1307 let mut rules = quantity_rules(4);
1308 rules.maximum = Some(positive("0.05"));
1309 let result = compute_instrument_size(
1310 &SizingPolicy::FixedLot { lots: 0.066 },
1311 1.0,
1312 10_000.0,
1313 Side::Buy,
1314 1.10000,
1315 None,
1316 5,
1317 &rules,
1318 &standard_lot_economics("100000"),
1319 None,
1320 )
1321 .unwrap();
1322
1323 assert_eq!(result.final_lot_steps, 5);
1324 assert_close(result.final_lot, 0.05);
1325 assert_eq!(result.cap_status, LotCapStatus::CappedAtMaximum);
1326 assert_eq!(result.native_loss_per_lot, None);
1327 }
1328
1329 #[test]
1330 fn full_spec_sizing_uses_exact_grid_and_records_adjustment() {
1331 let spec = instrument_spec("0.1", None);
1332 let result = compute_instrument_size_for_spec(
1333 &SizingPolicy::FixedLot { lots: 0.3 },
1334 1.0,
1335 10_000.0,
1336 Side::Buy,
1337 1.1,
1338 None,
1339 &spec,
1340 None,
1341 )
1342 .unwrap();
1343
1344 assert_eq!(result.final_lot_steps, 3);
1345 assert_eq!(result.final_lot, 0.3);
1346 assert_eq!(
1347 result.quantity_adjustment,
1348 Some(GridAdjustment {
1349 requested: decimal("0.3"),
1350 adjusted: decimal("0.3"),
1351 direction: AdjustmentDirection::Unchanged,
1352 })
1353 );
1354 }
1355
1356 #[test]
1357 fn full_spec_sizing_wrapper_matches_separate_price_api() {
1358 let usd = AssetId::new("USD").unwrap();
1359 let spec = instrument_spec(
1360 "0.01",
1361 Some(NotionalRules {
1362 asset: usd,
1363 minimum: Some(positive("1000")),
1364 maximum: Some(positive("50000")),
1365 }),
1366 );
1367 let wrapper = compute_instrument_size_for_spec(
1368 &SizingPolicy::FixedRiskAmount { amount: 100.0 },
1369 1.0,
1370 10_000.0,
1371 Side::Buy,
1372 1.1,
1373 Some(1.095),
1374 &spec,
1375 Some(1.0),
1376 )
1377 .unwrap();
1378 let separate_prices = compute_instrument_size_for_spec_with_prices(
1379 &SizingPolicy::FixedRiskAmount { amount: 100.0 },
1380 1.0,
1381 10_000.0,
1382 Side::Buy,
1383 1.1,
1384 1.1,
1385 Some(1.095),
1386 &spec,
1387 Some(1.0),
1388 )
1389 .unwrap();
1390
1391 assert_eq!(wrapper, separate_prices);
1392 }
1393
1394 #[test]
1395 fn sizing_reference_price_changes_risk_lot_independently_of_execution_price() {
1396 let spec = instrument_spec("0.01", None);
1397 let nearer_reference = compute_instrument_size_for_spec_with_prices(
1398 &SizingPolicy::FixedRiskAmount { amount: 100.0 },
1399 1.0,
1400 10_000.0,
1401 Side::Buy,
1402 1.1,
1403 1.2,
1404 Some(1.095),
1405 &spec,
1406 Some(1.0),
1407 )
1408 .unwrap();
1409 let farther_reference = compute_instrument_size_for_spec_with_prices(
1410 &SizingPolicy::FixedRiskAmount { amount: 100.0 },
1411 1.0,
1412 10_000.0,
1413 Side::Buy,
1414 1.105,
1415 1.2,
1416 Some(1.095),
1417 &spec,
1418 Some(1.0),
1419 )
1420 .unwrap();
1421
1422 assert_eq!(nearer_reference.native_loss_per_lot, Some(500.0));
1423 assert_eq!(nearer_reference.account_loss_per_lot, Some(500.0));
1424 assert_close(nearer_reference.scaled_raw_lot, 0.2);
1425 assert_eq!(nearer_reference.final_lot_steps, 20);
1426 assert_eq!(farther_reference.native_loss_per_lot, Some(1_000.0));
1427 assert_eq!(farther_reference.account_loss_per_lot, Some(1_000.0));
1428 assert_close(farther_reference.scaled_raw_lot, 0.1);
1429 assert_eq!(farther_reference.final_lot_steps, 10);
1430 }
1431
1432 #[test]
1433 fn execution_notional_price_enforces_notional_bounds() {
1434 let usd = AssetId::new("USD").unwrap();
1435 let minimum_spec = instrument_spec(
1436 "0.01",
1437 Some(NotionalRules {
1438 asset: usd.clone(),
1439 minimum: Some(positive("2200")),
1440 maximum: None,
1441 }),
1442 );
1443 let minimum_error = compute_instrument_size_for_spec_with_prices(
1444 &SizingPolicy::FixedLot { lots: 0.02 },
1445 1.0,
1446 10_000.0,
1447 Side::Buy,
1448 1.2,
1449 1.05,
1450 None,
1451 &minimum_spec,
1452 None,
1453 )
1454 .unwrap_err();
1455 assert_eq!(
1456 minimum_error,
1457 InstrumentSizingError::BelowMinimumNotional {
1458 notional: decimal("2100"),
1459 minimum: decimal("2200"),
1460 }
1461 );
1462
1463 let maximum_spec = instrument_spec(
1464 "0.01",
1465 Some(NotionalRules {
1466 asset: usd,
1467 minimum: None,
1468 maximum: Some(positive("2200")),
1469 }),
1470 );
1471 let maximum_error = compute_instrument_size_for_spec_with_prices(
1472 &SizingPolicy::FixedLot { lots: 0.02 },
1473 1.0,
1474 10_000.0,
1475 Side::Buy,
1476 1.05,
1477 1.2,
1478 None,
1479 &maximum_spec,
1480 None,
1481 )
1482 .unwrap_err();
1483 assert_eq!(
1484 maximum_error,
1485 InstrumentSizingError::AboveMaximumNotional {
1486 notional: decimal("2400"),
1487 maximum: decimal("2200"),
1488 }
1489 );
1490 }
1491
1492 #[test]
1493 fn separate_sizing_prices_require_instrument_grid_alignment() {
1494 let mut spec = instrument_spec("0.01", None);
1495 spec.price.grid = DecimalGrid::new(Decimal::ZERO, positive("0.00005"));
1496
1497 let reference_error = compute_instrument_size_for_spec_with_prices(
1498 &SizingPolicy::FixedLot { lots: 0.02 },
1499 1.0,
1500 10_000.0,
1501 Side::Buy,
1502 1.10003,
1503 1.1,
1504 None,
1505 &spec,
1506 None,
1507 )
1508 .unwrap_err();
1509 assert!(matches!(
1510 reference_error,
1511 InstrumentSizingError::PriceOffGrid {
1512 field: "sizing reference",
1513 ..
1514 }
1515 ));
1516
1517 let execution_error = compute_instrument_size_for_spec_with_prices(
1518 &SizingPolicy::FixedLot { lots: 0.02 },
1519 1.0,
1520 10_000.0,
1521 Side::Buy,
1522 1.1,
1523 1.10003,
1524 None,
1525 &spec,
1526 None,
1527 )
1528 .unwrap_err();
1529 assert!(matches!(
1530 execution_error,
1531 InstrumentSizingError::PriceOffGrid {
1532 field: "execution notional",
1533 ..
1534 }
1535 ));
1536 }
1537
1538 #[test]
1539 fn full_spec_sizing_validates_price_grid_and_post_rounding_notional() {
1540 let usd = AssetId::new("USD").unwrap();
1541 let minimum_spec = instrument_spec(
1542 "0.01",
1543 Some(NotionalRules {
1544 asset: usd.clone(),
1545 minimum: Some(positive("2200")),
1546 maximum: None,
1547 }),
1548 );
1549 let minimum_error = compute_instrument_size_for_spec(
1550 &SizingPolicy::FixedLot { lots: 0.019 },
1551 1.0,
1552 10_000.0,
1553 Side::Buy,
1554 1.1,
1555 None,
1556 &minimum_spec,
1557 None,
1558 )
1559 .unwrap_err();
1560 assert!(matches!(
1561 minimum_error,
1562 InstrumentSizingError::BelowMinimumNotional { .. }
1563 ));
1564
1565 let maximum_spec = instrument_spec(
1566 "0.01",
1567 Some(NotionalRules {
1568 asset: usd,
1569 minimum: None,
1570 maximum: Some(positive("1000")),
1571 }),
1572 );
1573 let maximum_error = compute_instrument_size_for_spec(
1574 &SizingPolicy::FixedLot { lots: 0.02 },
1575 1.0,
1576 10_000.0,
1577 Side::Buy,
1578 1.1,
1579 None,
1580 &maximum_spec,
1581 None,
1582 )
1583 .unwrap_err();
1584 assert!(matches!(
1585 maximum_error,
1586 InstrumentSizingError::AboveMaximumNotional { .. }
1587 ));
1588
1589 let mut price_spec = instrument_spec("0.01", None);
1590 price_spec.price.grid = DecimalGrid::new(Decimal::ZERO, positive("0.00005"));
1591 let price_error = compute_instrument_size_for_spec(
1592 &SizingPolicy::FixedLot { lots: 0.02 },
1593 1.0,
1594 10_000.0,
1595 Side::Buy,
1596 1.10003,
1597 None,
1598 &price_spec,
1599 None,
1600 )
1601 .unwrap_err();
1602 assert!(matches!(
1603 price_error,
1604 InstrumentSizingError::PriceOffGrid { .. }
1605 ));
1606 }
1607
1608 #[test]
1609 fn catalog_sizing_rejects_unsupported_quantity_and_model_combinations() {
1610 for economics in [
1611 economics(
1612 QuantityUnit::Contract,
1613 EconomicsModelId::FX_QUOTE_LINEAR_V1,
1614 "100000",
1615 ),
1616 economics(
1617 QuantityUnit::StandardLot,
1618 EconomicsModelId::LINEAR_CONTRACT_V1,
1619 "100000",
1620 ),
1621 ] {
1622 let error = compute_instrument_size(
1623 &SizingPolicy::FixedLot { lots: 0.01 },
1624 1.0,
1625 10_000.0,
1626 Side::Buy,
1627 1.10000,
1628 None,
1629 5,
1630 &quantity_rules(2),
1631 &economics,
1632 None,
1633 )
1634 .unwrap_err();
1635
1636 assert!(matches!(
1637 error,
1638 InstrumentSizingError::UnsupportedInstrumentSizing { .. }
1639 ));
1640 }
1641 }
1642
1643 #[test]
1644 fn catalog_sizing_rejects_nonzero_quantity_grid_origin() {
1645 let mut rules = quantity_rules(2);
1646 rules.grid = DecimalGrid::new(decimal("0.01"), positive("0.01"));
1647 let error = compute_instrument_size(
1648 &SizingPolicy::FixedLot { lots: 0.01 },
1649 1.0,
1650 10_000.0,
1651 Side::Buy,
1652 1.10000,
1653 None,
1654 5,
1655 &rules,
1656 &standard_lot_economics("100000"),
1657 None,
1658 )
1659 .unwrap_err();
1660
1661 assert!(matches!(
1662 error,
1663 InstrumentSizingError::UnsupportedQuantityGridOrigin { .. }
1664 ));
1665 }
1666
1667 #[test]
1668 fn public_native_loss_helper_matches_compute_size_normalization() {
1669 let spec = forex_spec();
1670 let native_loss =
1671 compute_native_loss_per_lot(Side::Buy, 1.100004, 1.095003, &spec).unwrap();
1672 let result = compute_size(
1673 &SizingPolicy::FixedRiskAmount { amount: 100.0 },
1674 1.0,
1675 10_000.0,
1676 Side::Buy,
1677 1.100004,
1678 Some(1.095003),
1679 &spec,
1680 Some(native_loss),
1681 )
1682 .unwrap();
1683
1684 assert_close(native_loss, 500.0);
1685 assert_eq!(result.native_loss_per_lot, Some(native_loss));
1686
1687 let helper_error =
1688 compute_native_loss_per_lot(Side::Buy, 1.000004, 1.000003, &spec).unwrap_err();
1689 let sizing_error = compute_size(
1690 &SizingPolicy::FixedRiskAmount { amount: 100.0 },
1691 1.0,
1692 10_000.0,
1693 Side::Buy,
1694 1.000004,
1695 Some(1.000003),
1696 &spec,
1697 Some(100.0),
1698 )
1699 .unwrap_err();
1700 assert_eq!(helper_error, sizing_error);
1701 }
1702
1703 #[test]
1704 fn fixed_lot_applies_multiplier_before_lot_step() {
1705 let result = compute_size(
1706 &SizingPolicy::FixedLot { lots: 0.006 },
1707 2.0,
1708 10_000.0,
1709 Side::Buy,
1710 1.10000,
1711 None,
1712 &forex_spec(),
1713 None,
1714 )
1715 .unwrap();
1716
1717 assert_close(result.scaled_raw_lot, 0.012);
1718 assert_eq!(result.final_lot_steps, 1);
1719 assert_close(result.final_lot, 0.01);
1720 assert_eq!(result.requested_account_risk, None);
1721 assert_eq!(result.native_loss_per_lot, None);
1722 assert_eq!(result.account_loss_per_lot, None);
1723 assert_eq!(result.cap_status, LotCapStatus::NotCapped);
1724 }
1725
1726 #[test]
1727 fn invalid_risk_multipliers_are_rejected() {
1728 for value in [0.0, -1.0, f64::NAN, f64::INFINITY] {
1729 let error = compute_size(
1730 &SizingPolicy::FixedLot { lots: 0.01 },
1731 value,
1732 10_000.0,
1733 Side::Buy,
1734 1.10000,
1735 None,
1736 &forex_spec(),
1737 None,
1738 )
1739 .unwrap_err();
1740
1741 assert!(matches!(error, SizingError::InvalidRiskMultiplier { .. }));
1742 }
1743 }
1744
1745 #[test]
1746 fn fixed_lot_may_be_stopless_but_monetary_policies_may_not() {
1747 let fixed = compute_size(
1748 &SizingPolicy::FixedLot { lots: 0.01 },
1749 1.0,
1750 10_000.0,
1751 Side::Buy,
1752 1.10000,
1753 None,
1754 &forex_spec(),
1755 None,
1756 )
1757 .unwrap();
1758 assert_eq!(fixed.final_lot_steps, 1);
1759
1760 for policy in [
1761 SizingPolicy::FixedRiskAmount { amount: 100.0 },
1762 SizingPolicy::BalanceRiskPercent { percent: 1.0 },
1763 ] {
1764 let error = compute_size(
1765 &policy,
1766 1.0,
1767 10_000.0,
1768 Side::Buy,
1769 1.10000,
1770 None,
1771 &forex_spec(),
1772 Some(500.0),
1773 )
1774 .unwrap_err();
1775 assert_eq!(error, SizingError::MissingProtectiveStop);
1776 }
1777 }
1778
1779 #[test]
1780 fn monetary_policy_requires_positive_account_loss_per_lot() {
1781 let missing = compute_size(
1782 &SizingPolicy::FixedRiskAmount { amount: 100.0 },
1783 1.0,
1784 10_000.0,
1785 Side::Buy,
1786 1.10000,
1787 Some(1.09500),
1788 &forex_spec(),
1789 None,
1790 )
1791 .unwrap_err();
1792 assert_eq!(missing, SizingError::MissingAccountLossPerLot);
1793
1794 for value in [0.0, -1.0, f64::NAN, f64::INFINITY] {
1795 let error = compute_size(
1796 &SizingPolicy::FixedRiskAmount { amount: 100.0 },
1797 1.0,
1798 10_000.0,
1799 Side::Buy,
1800 1.10000,
1801 Some(1.09500),
1802 &forex_spec(),
1803 Some(value),
1804 )
1805 .unwrap_err();
1806 assert!(matches!(
1807 error,
1808 SizingError::InvalidAccountLossPerLot { .. }
1809 ));
1810 }
1811 }
1812
1813 #[test]
1814 fn fixed_risk_returns_requested_and_per_lot_audit_values() {
1815 let result = compute_size(
1816 &SizingPolicy::FixedRiskAmount { amount: 100.0 },
1817 2.0,
1818 10_000.0,
1819 Side::Buy,
1820 1.10000,
1821 Some(1.09500),
1822 &forex_spec(),
1823 Some(500.0),
1824 )
1825 .unwrap();
1826
1827 assert_eq!(result.requested_account_risk, Some(200.0));
1828 assert_eq!(result.native_loss_per_lot, Some(500.0));
1829 assert_eq!(result.account_loss_per_lot, Some(500.0));
1830 assert_close(result.scaled_raw_lot, 0.4);
1831 assert_eq!(result.final_lot_steps, 40);
1832 assert_close(result.final_lot, 0.4);
1833 }
1834
1835 #[test]
1836 fn balance_percent_uses_realized_balance_before() {
1837 let result = compute_size(
1838 &SizingPolicy::BalanceRiskPercent { percent: 1.0 },
1839 0.5,
1840 20_000.0,
1841 Side::Buy,
1842 1.10000,
1843 Some(1.09500),
1844 &forex_spec(),
1845 Some(500.0),
1846 )
1847 .unwrap();
1848
1849 assert_eq!(result.requested_account_risk, Some(100.0));
1850 assert_close(result.scaled_raw_lot, 0.2);
1851 assert_eq!(result.final_lot_steps, 20);
1852 assert_close(result.final_lot, 0.2);
1853 }
1854
1855 #[test]
1856 fn all_policies_reject_lots_below_the_minimum() {
1857 let cases = [
1858 (SizingPolicy::FixedLot { lots: 0.009 }, None),
1859 (SizingPolicy::FixedRiskAmount { amount: 4.5 }, Some(500.0)),
1860 (
1861 SizingPolicy::BalanceRiskPercent { percent: 0.045 },
1862 Some(500.0),
1863 ),
1864 ];
1865
1866 for (policy, account_loss_per_lot) in cases {
1867 let error = compute_size(
1868 &policy,
1869 1.0,
1870 10_000.0,
1871 Side::Buy,
1872 1.10000,
1873 Some(1.09500),
1874 &forex_spec(),
1875 account_loss_per_lot,
1876 )
1877 .unwrap_err();
1878
1879 assert!(matches!(
1880 error,
1881 SizingError::BelowMinimumLot {
1882 floored_lot_steps: 0,
1883 minimum_lot_steps: 1,
1884 ..
1885 }
1886 ));
1887 }
1888 }
1889
1890 #[test]
1891 fn maximum_cap_preserves_raw_lot_and_reports_audit_status() {
1892 let mut spec = forex_spec();
1893 spec.lot_max_steps = 5;
1894
1895 let result = compute_size(
1896 &SizingPolicy::FixedRiskAmount { amount: 100.0 },
1897 1.0,
1898 10_000.0,
1899 Side::Buy,
1900 1.10000,
1901 Some(1.09500),
1902 &spec,
1903 Some(100.0),
1904 )
1905 .unwrap();
1906
1907 assert_close(result.scaled_raw_lot, 1.0);
1908 assert_eq!(result.requested_account_risk, Some(100.0));
1909 assert_eq!(result.final_lot_steps, 5);
1910 assert_close(result.final_lot, 0.05);
1911 assert_eq!(result.cap_status, LotCapStatus::CappedAtMaximum);
1912 }
1913
1914 #[test]
1915 fn geometry_is_checked_before_sub_tick_distance() {
1916 let invalid_geometry = compute_size(
1917 &SizingPolicy::FixedRiskAmount { amount: 100.0 },
1918 1.0,
1919 10_000.0,
1920 Side::Buy,
1921 1.000003,
1922 Some(1.000004),
1923 &forex_spec(),
1924 Some(100.0),
1925 )
1926 .unwrap_err();
1927 assert!(matches!(
1928 invalid_geometry,
1929 SizingError::InvalidStopGeometry {
1930 side: Side::Buy,
1931 ..
1932 }
1933 ));
1934
1935 let sub_tick = compute_size(
1936 &SizingPolicy::FixedRiskAmount { amount: 100.0 },
1937 1.0,
1938 10_000.0,
1939 Side::Buy,
1940 1.000004,
1941 Some(1.000003),
1942 &forex_spec(),
1943 Some(100.0),
1944 )
1945 .unwrap_err();
1946 assert_eq!(
1947 sub_tick,
1948 SizingError::StopDistanceBelowTick {
1949 entry_price: 1.000004,
1950 stop_price: 1.000003,
1951 digits: 5,
1952 }
1953 );
1954 }
1955
1956 #[test]
1957 fn fixed_lot_validates_a_supplied_stop_but_does_not_require_one() {
1958 let error = compute_size(
1959 &SizingPolicy::FixedLot { lots: 0.01 },
1960 1.0,
1961 10_000.0,
1962 Side::Sell,
1963 1.10000,
1964 Some(1.09500),
1965 &forex_spec(),
1966 None,
1967 )
1968 .unwrap_err();
1969
1970 assert!(matches!(
1971 error,
1972 SizingError::InvalidStopGeometry {
1973 side: Side::Sell,
1974 ..
1975 }
1976 ));
1977 }
1978}