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