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)]
440pub fn compute_instrument_size_for_spec(
441 policy: &SizingPolicy,
442 risk_multiplier: f64,
443 balance_before: f64,
444 side: Side,
445 entry_price: f64,
446 protective_stop: Option<f64>,
447 spec: &InstrumentSpec,
448 native_to_account_rate: Option<f64>,
449) -> Result<SizingResult, InstrumentSizingError> {
450 validate_risk_multiplier(risk_multiplier)?;
451 validate_entry_price(entry_price)?;
452 validate_instrument_economics(&spec.economics)?;
453 let quantity_rules = validate_quantity_rules(&spec.quantity)?;
454 let entry_decimal = validate_price_grid("entry", entry_price, spec)?;
455 if let Some(stop) = protective_stop {
456 validate_price_grid("protective stop", stop, spec)?;
457 }
458 let basis = policy_basis(policy, balance_before)?;
459 let native_loss_per_lot = protective_stop
460 .map(|stop_price| {
461 compute_instrument_native_loss_per_lot(
462 side,
463 entry_price,
464 stop_price,
465 u16::from(spec.price.display_scale),
466 &spec.economics,
467 )
468 })
469 .transpose()?;
470 let account_loss_per_lot =
471 instrument_account_loss(basis, native_loss_per_lot, native_to_account_rate)?;
472 let sizing = compute_raw_size(
473 basis,
474 risk_multiplier,
475 native_loss_per_lot,
476 account_loss_per_lot,
477 )?;
478 let (final_lot_steps, final_lot, cap_status, quantity_adjustment) =
479 apply_quantity_constraints(sizing.scaled_raw_lot, quantity_rules)?;
480 let final_notional =
481 validate_final_notional(entry_decimal, quantity_adjustment.adjusted, spec)?;
482
483 Ok(sizing.into_result(
484 final_lot_steps,
485 final_lot,
486 cap_status,
487 Some(quantity_adjustment),
488 final_notional,
489 ))
490}
491
492#[derive(Debug, Clone, Copy)]
493struct RawSizingResult {
494 scaled_raw_lot: f64,
495 requested_account_risk: Option<f64>,
496 native_loss_per_lot: Option<f64>,
497 account_loss_per_lot: Option<f64>,
498}
499
500impl RawSizingResult {
501 fn into_result(
502 self,
503 final_lot_steps: u64,
504 final_lot: f64,
505 cap_status: LotCapStatus,
506 quantity_adjustment: Option<GridAdjustment<Decimal>>,
507 final_notional: Option<Money>,
508 ) -> SizingResult {
509 SizingResult {
510 final_lot_steps,
511 final_lot,
512 scaled_raw_lot: self.scaled_raw_lot,
513 requested_account_risk: self.requested_account_risk,
514 native_loss_per_lot: self.native_loss_per_lot,
515 account_loss_per_lot: self.account_loss_per_lot,
516 cap_status,
517 quantity_adjustment,
518 final_notional,
519 }
520 }
521}
522
523fn compute_raw_size(
524 basis: PolicyBasis,
525 risk_multiplier: f64,
526 native_loss_per_lot: Option<f64>,
527 account_loss_per_lot: Option<f64>,
528) -> Result<RawSizingResult, SizingError> {
529 let scaled_policy_value = match basis {
530 PolicyBasis::FixedLots(base_value) | PolicyBasis::AccountRisk(base_value) => {
531 let scaled = base_value * risk_multiplier;
532 if !scaled.is_finite() || scaled <= 0.0 {
533 return Err(SizingError::InvalidScaledPolicyValue {
534 base_value,
535 risk_multiplier,
536 });
537 }
538 scaled
539 }
540 };
541
542 let (scaled_raw_lot, requested_account_risk, result_account_loss_per_lot) = match basis {
543 PolicyBasis::FixedLots(_) => (scaled_policy_value, None, None),
544 PolicyBasis::AccountRisk(_) => {
545 native_loss_per_lot.ok_or(SizingError::MissingProtectiveStop)?;
546 let account_loss_per_lot = account_loss_per_lot
547 .ok_or(SizingError::MissingAccountLossPerLot)
548 .and_then(validate_account_loss_per_lot)?;
549 let scaled_raw_lot = scaled_policy_value / account_loss_per_lot;
550 if !scaled_raw_lot.is_finite() || scaled_raw_lot <= 0.0 {
551 return Err(SizingError::InvalidScaledRawLot {
552 value: scaled_raw_lot,
553 });
554 }
555 (
556 scaled_raw_lot,
557 Some(scaled_policy_value),
558 Some(account_loss_per_lot),
559 )
560 }
561 };
562
563 Ok(RawSizingResult {
564 scaled_raw_lot,
565 requested_account_risk,
566 native_loss_per_lot,
567 account_loss_per_lot: result_account_loss_per_lot,
568 })
569}
570
571fn instrument_account_loss(
572 basis: PolicyBasis,
573 native_loss_per_lot: Option<f64>,
574 native_to_account_rate: Option<f64>,
575) -> Result<Option<f64>, InstrumentSizingError> {
576 match basis {
577 PolicyBasis::FixedLots(_) => Ok(None),
578 PolicyBasis::AccountRisk(_) => {
579 let native_loss_per_lot =
580 native_loss_per_lot.ok_or(SizingError::MissingProtectiveStop)?;
581 let conversion_rate = native_to_account_rate
582 .ok_or(InstrumentSizingError::MissingNativeToAccountRate)
583 .and_then(validate_native_to_account_rate)?;
584 let account_loss_per_lot = native_loss_per_lot * conversion_rate;
585 Ok(Some(validate_account_loss_per_lot(account_loss_per_lot)?))
586 }
587 }
588}
589
590fn validate_risk_multiplier(value: f64) -> Result<(), SizingError> {
591 if value.is_finite() && value > 0.0 {
592 Ok(())
593 } else {
594 Err(SizingError::InvalidRiskMultiplier { value })
595 }
596}
597
598fn validate_entry_price(value: f64) -> Result<(), SizingError> {
599 if value.is_finite() && value > 0.0 {
600 Ok(())
601 } else {
602 Err(SizingError::InvalidEntryPrice { value })
603 }
604}
605
606fn validate_account_loss_per_lot(value: f64) -> Result<f64, SizingError> {
607 if value.is_finite() && value > 0.0 {
608 Ok(value)
609 } else {
610 Err(SizingError::InvalidAccountLossPerLot { value })
611 }
612}
613
614fn validate_native_to_account_rate(value: f64) -> Result<f64, InstrumentSizingError> {
615 if value.is_finite() && value > 0.0 {
616 Ok(value)
617 } else {
618 Err(InstrumentSizingError::InvalidNativeToAccountRate { value })
619 }
620}
621
622fn policy_basis(policy: &SizingPolicy, balance_before: f64) -> Result<PolicyBasis, SizingError> {
623 match *policy {
624 SizingPolicy::FixedLot { lots } => {
625 if lots.is_finite() && lots > 0.0 {
626 Ok(PolicyBasis::FixedLots(lots))
627 } else {
628 Err(SizingError::InvalidFixedLots { value: lots })
629 }
630 }
631 SizingPolicy::FixedRiskAmount { amount } => {
632 if amount.is_finite() && amount > 0.0 {
633 Ok(PolicyBasis::AccountRisk(amount))
634 } else {
635 Err(SizingError::InvalidFixedRiskAmount { value: amount })
636 }
637 }
638 SizingPolicy::BalanceRiskPercent { percent } => {
639 if !percent.is_finite() || percent <= 0.0 {
640 return Err(SizingError::InvalidBalanceRiskPercent { value: percent });
641 }
642 if !balance_before.is_finite() || balance_before <= 0.0 {
643 return Err(SizingError::InvalidBalanceBefore {
644 value: balance_before,
645 });
646 }
647 let account_risk = balance_before * (percent / 100.0);
648 if account_risk.is_finite() && account_risk > 0.0 {
649 Ok(PolicyBasis::AccountRisk(account_risk))
650 } else {
651 Err(SizingError::InvalidScaledPolicyValue {
652 base_value: balance_before,
653 risk_multiplier: percent / 100.0,
654 })
655 }
656 }
657 }
658}
659
660fn validate_lot_spec(spec: &SymbolSpec) -> Result<ValidatedLotSpec, SizingError> {
661 let lot_base_units = u64::try_from(spec.lot_base_units)
662 .ok()
663 .filter(|value| *value > 0)
664 .ok_or(SizingError::InvalidLotBaseUnits {
665 value: spec.lot_base_units,
666 })?;
667 let lot_step_units = u64::try_from(spec.lot_step_units)
668 .ok()
669 .filter(|value| *value > 0)
670 .ok_or(SizingError::InvalidLotStepUnits {
671 value: spec.lot_step_units,
672 })?;
673 let lot_min_steps = u64::try_from(spec.lot_min_steps)
674 .ok()
675 .filter(|value| *value > 0)
676 .ok_or(SizingError::InvalidMinimumLotSteps {
677 value: spec.lot_min_steps,
678 })?;
679 let lot_max_steps = match spec.lot_max_steps {
680 0 => None,
681 maximum if maximum >= spec.lot_min_steps => Some(maximum as u64),
682 maximum => {
683 return Err(SizingError::InvalidMaximumLotSteps {
684 maximum,
685 minimum: spec.lot_min_steps,
686 });
687 }
688 };
689
690 Ok(ValidatedLotSpec {
691 lot_base_units,
692 lot_step_units,
693 lot_min_steps,
694 lot_max_steps,
695 })
696}
697
698fn validate_instrument_economics(
699 economics: &InstrumentEconomics,
700) -> Result<(), InstrumentSizingError> {
701 let supported_model = matches!(
702 economics.pnl_model.as_str(),
703 EconomicsModelId::FX_QUOTE_LINEAR_V1 | EconomicsModelId::CFD_QUOTE_LINEAR_V1
704 );
705 if economics.quantity_unit == QuantityUnit::StandardLot && supported_model {
706 Ok(())
707 } else {
708 Err(InstrumentSizingError::UnsupportedInstrumentSizing {
709 quantity_unit: economics.quantity_unit,
710 pnl_model: economics.pnl_model.clone(),
711 })
712 }
713}
714
715fn validate_quantity_rules(
716 rules: &QuantityRules,
717) -> Result<ValidatedQuantityRules, InstrumentSizingError> {
718 if !rules.grid.origin.is_zero() {
719 return Err(InstrumentSizingError::UnsupportedQuantityGridOrigin {
720 value: rules.grid.origin,
721 });
722 }
723
724 let step = rules.grid.step.get();
725 let minimum = rules.minimum.get();
726 if !rules.grid.contains(minimum)? {
727 return Err(InstrumentSizingError::QuantityBoundOffGrid {
728 field: "minimum",
729 value: minimum,
730 });
731 }
732 let minimum_steps = exact_quantity_steps("minimum", minimum, rules.grid.step.get())?;
733
734 let maximum_steps = match rules.maximum {
735 Some(maximum) => {
736 let maximum = maximum.get();
737 if maximum < minimum {
738 return Err(InstrumentSizingError::InvalidQuantityBounds { minimum, maximum });
739 }
740 if !rules.grid.contains(maximum)? {
741 return Err(InstrumentSizingError::QuantityBoundOffGrid {
742 field: "maximum",
743 value: maximum,
744 });
745 }
746 Some(exact_quantity_steps(
747 "maximum",
748 maximum,
749 rules.grid.step.get(),
750 )?)
751 }
752 None => None,
753 };
754
755 Ok(ValidatedQuantityRules {
756 step,
757 minimum_steps,
758 maximum_steps,
759 })
760}
761
762fn exact_quantity_steps(
763 field: &'static str,
764 quantity: Decimal,
765 step: Decimal,
766) -> Result<u64, InstrumentSizingError> {
767 let scale = quantity.scale().max(step.scale());
768 let quantity_factor = 10_i128
769 .checked_pow(u32::from(scale - quantity.scale()))
770 .ok_or(InstrumentSizingError::QuantityStepOverflow {
771 field,
772 value: quantity,
773 })?;
774 let step_factor = 10_i128.checked_pow(u32::from(scale - step.scale())).ok_or(
775 InstrumentSizingError::QuantityStepOverflow {
776 field,
777 value: quantity,
778 },
779 )?;
780 let quantity_coefficient = quantity.coefficient().checked_mul(quantity_factor).ok_or(
781 InstrumentSizingError::QuantityStepOverflow {
782 field,
783 value: quantity,
784 },
785 )?;
786 let step_coefficient = step.coefficient().checked_mul(step_factor).ok_or(
787 InstrumentSizingError::QuantityStepOverflow {
788 field,
789 value: quantity,
790 },
791 )?;
792 let steps = quantity_coefficient / step_coefficient;
793 u64::try_from(steps).map_err(|_| InstrumentSizingError::QuantityStepOverflow {
794 field,
795 value: quantity,
796 })
797}
798
799fn exact_decimal_to_f64(field: &'static str, value: Decimal) -> Result<f64, InstrumentSizingError> {
800 let converted = value.coefficient() as f64 / 10_f64.powi(i32::from(value.scale()));
801 if converted.is_finite() {
802 Ok(converted)
803 } else {
804 Err(InstrumentSizingError::ExactDecimalOutOfRange { field, value })
805 }
806}
807
808fn validate_price_grid(
809 field: &'static str,
810 price: f64,
811 spec: &InstrumentSpec,
812) -> Result<Decimal, InstrumentSizingError> {
813 let scale = 10_f64.powi(i32::from(spec.price.display_scale));
814 let normalized = (price * scale).round() / scale;
815 let price = Decimal::checked_from_f64(normalized)?;
816 if spec.price.grid.contains(price)? {
817 Ok(price)
818 } else {
819 Err(InstrumentSizingError::PriceOffGrid {
820 field,
821 value: price,
822 })
823 }
824}
825
826fn validate_final_notional(
827 entry_price: Decimal,
828 quantity: Decimal,
829 spec: &InstrumentSpec,
830) -> Result<Option<Money>, InstrumentSizingError> {
831 let Some(rules) = &spec.notional else {
832 return Ok(None);
833 };
834 if rules.asset != spec.economics.settlement_asset {
835 return Err(InstrumentSizingError::UnsupportedNotionalAsset {
836 notional_asset: rules.asset.clone(),
837 settlement_asset: spec.economics.settlement_asset.clone(),
838 });
839 }
840 let amount = entry_price
841 .checked_mul(quantity)?
842 .checked_mul(spec.economics.contract_multiplier.get())?;
843 if let Some(minimum) = rules.minimum
844 && amount < minimum.get()
845 {
846 return Err(InstrumentSizingError::BelowMinimumNotional {
847 notional: amount,
848 minimum: minimum.get(),
849 });
850 }
851 if let Some(maximum) = rules.maximum
852 && amount > maximum.get()
853 {
854 return Err(InstrumentSizingError::AboveMaximumNotional {
855 notional: amount,
856 maximum: maximum.get(),
857 });
858 }
859 Ok(Some(Money {
860 asset: rules.asset.clone(),
861 amount,
862 }))
863}
864
865pub fn compute_native_loss_per_lot(
869 side: Side,
870 entry_price: f64,
871 protective_stop: f64,
872 spec: &SymbolSpec,
873) -> Result<f64, SizingError> {
874 validate_entry_price(entry_price)?;
875 if spec.lot_base_units <= 0 {
876 return Err(SizingError::InvalidLotBaseUnits {
877 value: spec.lot_base_units,
878 });
879 }
880 compute_native_loss_with_multiplier(
881 side,
882 entry_price,
883 protective_stop,
884 spec.digits,
885 spec.pip_position,
886 spec.lot_base_units as f64,
887 )
888}
889
890pub fn compute_instrument_native_loss_per_lot(
894 side: Side,
895 entry_price: f64,
896 protective_stop: f64,
897 price_digits: u16,
898 economics: &InstrumentEconomics,
899) -> Result<f64, InstrumentSizingError> {
900 validate_instrument_economics(economics)?;
901 let contract_multiplier = exact_decimal_to_f64(
902 "instrument contract multiplier",
903 economics.contract_multiplier.get(),
904 )?;
905 Ok(compute_native_loss_with_multiplier(
906 side,
907 entry_price,
908 protective_stop,
909 price_digits,
910 price_digits,
911 contract_multiplier,
912 )?)
913}
914
915fn compute_native_loss_with_multiplier(
916 side: Side,
917 entry_price: f64,
918 protective_stop: f64,
919 digits: u16,
920 pip_position: u16,
921 contract_multiplier: f64,
922) -> Result<f64, SizingError> {
923 validate_entry_price(entry_price)?;
924 if !protective_stop.is_finite() || protective_stop <= 0.0 {
925 return Err(SizingError::InvalidProtectiveStop {
926 value: protective_stop,
927 });
928 }
929 let valid_geometry = match side {
930 Side::Buy => protective_stop < entry_price,
931 Side::Sell => protective_stop > entry_price,
932 };
933 if !valid_geometry {
934 return Err(SizingError::InvalidStopGeometry {
935 side,
936 entry_price,
937 stop_price: protective_stop,
938 });
939 }
940 if digits > 18 || pip_position > digits {
941 return Err(SizingError::InvalidPricePrecision {
942 digits,
943 pip_position,
944 });
945 }
946
947 let scale = 10_i64.pow(digits as u32) as f64;
948 let entry_ticks = price_to_ticks("entry", entry_price, digits, scale)?;
949 let stop_ticks = price_to_ticks("protective stop", protective_stop, digits, scale)?;
950 let distance_ticks = entry_ticks.abs_diff(stop_ticks);
951 if distance_ticks == 0 {
952 return Err(SizingError::StopDistanceBelowTick {
953 entry_price,
954 stop_price: protective_stop,
955 digits,
956 });
957 }
958
959 let native_loss_per_lot = distance_ticks as f64 * contract_multiplier / scale;
960 if !native_loss_per_lot.is_finite() || native_loss_per_lot <= 0.0 {
961 return Err(SizingError::InvalidNativeLossPerLot {
962 entry_price,
963 stop_price: protective_stop,
964 });
965 }
966
967 Ok(native_loss_per_lot)
968}
969
970fn price_to_ticks(
971 field: &'static str,
972 value: f64,
973 digits: u16,
974 scale: f64,
975) -> Result<i64, SizingError> {
976 let scaled = value * scale;
977 if !scaled.is_finite() || scaled >= i64::MAX as f64 {
978 return Err(SizingError::PriceOutOfRange {
979 field,
980 value,
981 digits,
982 });
983 }
984 Ok(scaled.round() as i64)
985}
986
987fn apply_lot_constraints(
988 scaled_raw_lot: f64,
989 spec: ValidatedLotSpec,
990) -> Result<(u64, f64, LotCapStatus), SizingError> {
991 if !scaled_raw_lot.is_finite() || scaled_raw_lot <= 0.0 {
992 return Err(SizingError::InvalidScaledRawLot {
993 value: scaled_raw_lot,
994 });
995 }
996
997 let raw_steps = scaled_raw_lot * spec.lot_base_units as f64 / spec.lot_step_units as f64;
998 if !raw_steps.is_finite() || raw_steps >= u64::MAX as f64 {
999 return Err(SizingError::LotStepOverflow { scaled_raw_lot });
1000 }
1001 let floored_lot_steps = raw_steps.floor() as u64;
1002 if floored_lot_steps < spec.lot_min_steps {
1003 return Err(SizingError::BelowMinimumLot {
1004 scaled_raw_lot,
1005 floored_lot_steps,
1006 minimum_lot_steps: spec.lot_min_steps,
1007 });
1008 }
1009
1010 let (final_lot_steps, cap_status) = match spec.lot_max_steps {
1011 Some(maximum) if floored_lot_steps > maximum => (maximum, LotCapStatus::CappedAtMaximum),
1012 _ => (floored_lot_steps, LotCapStatus::NotCapped),
1013 };
1014 let final_lot =
1015 final_lot_steps as f64 * spec.lot_step_units as f64 / spec.lot_base_units as f64;
1016
1017 Ok((final_lot_steps, final_lot, cap_status))
1018}
1019
1020fn apply_quantity_constraints(
1021 scaled_raw_lot: f64,
1022 rules: ValidatedQuantityRules,
1023) -> Result<(u64, f64, LotCapStatus, GridAdjustment<Decimal>), InstrumentSizingError> {
1024 if !scaled_raw_lot.is_finite() || scaled_raw_lot <= 0.0 {
1025 return Err(SizingError::InvalidScaledRawLot {
1026 value: scaled_raw_lot,
1027 }
1028 .into());
1029 }
1030
1031 let requested = Decimal::checked_from_f64(scaled_raw_lot)?;
1032 let floored = qs_instruments::DecimalGrid::new(Decimal::ZERO, rules.step.try_into()?)
1033 .adjust(requested, GridRounding::Floor)?;
1034 let floored_lot_steps = exact_quantity_steps("adjusted", floored.adjusted, rules.step)?;
1035 if floored_lot_steps < rules.minimum_steps {
1036 return Err(SizingError::BelowMinimumLot {
1037 scaled_raw_lot,
1038 floored_lot_steps,
1039 minimum_lot_steps: rules.minimum_steps,
1040 }
1041 .into());
1042 }
1043
1044 let (final_lot_steps, cap_status) = match rules.maximum_steps {
1045 Some(maximum) if floored_lot_steps > maximum => (maximum, LotCapStatus::CappedAtMaximum),
1046 _ => (floored_lot_steps, LotCapStatus::NotCapped),
1047 };
1048 let final_quantity = rules
1049 .step
1050 .checked_mul(Decimal::new(i128::from(final_lot_steps), 0)?)?;
1051 let final_lot = exact_decimal_to_f64("final quantity", final_quantity)?;
1052 let direction = match final_quantity.cmp(&requested) {
1053 std::cmp::Ordering::Less => AdjustmentDirection::Down,
1054 std::cmp::Ordering::Equal => AdjustmentDirection::Unchanged,
1055 std::cmp::Ordering::Greater => AdjustmentDirection::Up,
1056 };
1057
1058 Ok((
1059 final_lot_steps,
1060 final_lot,
1061 cap_status,
1062 GridAdjustment {
1063 requested,
1064 adjusted: final_quantity,
1065 direction,
1066 },
1067 ))
1068}
1069
1070#[cfg(test)]
1071mod tests {
1072 use super::*;
1073 use std::collections::BTreeSet;
1074
1075 use qs_instruments::{
1076 AssetId, DecimalGrid, EconomicsModelId, EffectiveInterval, InstrumentAssets, InstrumentId,
1077 InstrumentSpec, ListingStatus, NotionalRules, PositiveDecimal, PriceRules, QuantityUnit,
1078 };
1079
1080 fn decimal(value: &str) -> Decimal {
1081 value.parse().unwrap()
1082 }
1083
1084 fn positive(value: &str) -> PositiveDecimal {
1085 value.parse().unwrap()
1086 }
1087
1088 fn quantity_rules(storage_scale: u8) -> QuantityRules {
1089 QuantityRules {
1090 grid: DecimalGrid::new(Decimal::ZERO, positive("0.01")),
1091 minimum: positive("0.01"),
1092 maximum: Some(positive("100")),
1093 storage_scale,
1094 }
1095 }
1096
1097 fn economics(
1098 quantity_unit: QuantityUnit,
1099 model: &str,
1100 multiplier: &str,
1101 ) -> InstrumentEconomics {
1102 InstrumentEconomics {
1103 pnl_model: EconomicsModelId::new(model).unwrap(),
1104 quantity_unit,
1105 contract_multiplier: positive(multiplier),
1106 settlement_asset: AssetId::new("USD").unwrap(),
1107 fee_model: None,
1108 funding_model: None,
1109 margin_model: None,
1110 }
1111 }
1112
1113 fn standard_lot_economics(multiplier: &str) -> InstrumentEconomics {
1114 economics(
1115 QuantityUnit::StandardLot,
1116 EconomicsModelId::FX_QUOTE_LINEAR_V1,
1117 multiplier,
1118 )
1119 }
1120
1121 fn instrument_spec(step: &str, notional: Option<NotionalRules>) -> InstrumentSpec {
1122 let usd = AssetId::new("USD").unwrap();
1123 InstrumentSpec {
1124 revision: "1.0.0".parse().unwrap(),
1125 instrument: InstrumentId::new(
1126 "broker-a".parse().unwrap(),
1127 qs_instruments::MarketKind::new(qs_instruments::MarketKind::FX_CFD).unwrap(),
1128 "EURUSD".parse().unwrap(),
1129 ),
1130 effective: EffectiveInterval::new("2026-01-01T00:00:00Z".parse().unwrap(), None)
1131 .unwrap(),
1132 status: ListingStatus::Trading,
1133 assets: InstrumentAssets {
1134 base: Some("EUR".parse().unwrap()),
1135 quote: Some(usd.clone()),
1136 settlement: usd,
1137 fee_assets: BTreeSet::new(),
1138 },
1139 price: PriceRules {
1140 grid: DecimalGrid::new(Decimal::ZERO, positive("0.00001")),
1141 display_scale: 5,
1142 },
1143 quantity: QuantityRules {
1144 grid: DecimalGrid::new(Decimal::ZERO, positive(step)),
1145 minimum: positive(step),
1146 maximum: Some(positive("100")),
1147 storage_scale: 2,
1148 },
1149 notional,
1150 economics: standard_lot_economics("100000"),
1151 aliases: BTreeSet::from(["EURUSD".parse().unwrap()]),
1152 }
1153 }
1154
1155 fn forex_spec() -> SymbolSpec {
1156 SymbolSpec {
1157 canonical: "eurusd".into(),
1158 pip_position: 4,
1159 digits: 5,
1160 category: "forex".into(),
1161 lot_base_units: 100_000,
1162 lot_step_units: 1_000,
1163 lot_min_steps: 1,
1164 lot_max_steps: 0,
1165 }
1166 }
1167
1168 fn assert_close(actual: f64, expected: f64) {
1169 assert!(
1170 (actual - expected).abs() < 1e-12,
1171 "expected {expected}, got {actual}"
1172 );
1173 }
1174
1175 #[test]
1176 fn catalog_sizing_uses_quantity_rules_and_contract_multiplier() {
1177 let result = compute_instrument_size(
1178 &SizingPolicy::FixedRiskAmount { amount: 100.0 },
1179 1.0,
1180 10_000.0,
1181 Side::Buy,
1182 1.10000,
1183 Some(1.09500),
1184 5,
1185 &quantity_rules(2),
1186 &standard_lot_economics("100000"),
1187 Some(1.0),
1188 )
1189 .unwrap();
1190
1191 assert_eq!(result.native_loss_per_lot, Some(500.0));
1192 assert_eq!(result.account_loss_per_lot, Some(500.0));
1193 assert_close(result.scaled_raw_lot, 0.2);
1194 assert_eq!(result.final_lot_steps, 20);
1195 assert_close(result.final_lot, 0.2);
1196 }
1197
1198 #[test]
1199 fn storage_scale_does_not_change_native_loss_or_monetary_size() {
1200 let economics = standard_lot_economics("100000");
1201 let low_scale = compute_instrument_size(
1202 &SizingPolicy::FixedRiskAmount { amount: 100.0 },
1203 1.0,
1204 10_000.0,
1205 Side::Buy,
1206 1.10000,
1207 Some(1.09500),
1208 5,
1209 &quantity_rules(2),
1210 &economics,
1211 Some(1.0),
1212 )
1213 .unwrap();
1214 let high_scale = 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(8),
1223 &economics,
1224 Some(1.0),
1225 )
1226 .unwrap();
1227
1228 assert_eq!(low_scale, high_scale);
1229 }
1230
1231 #[test]
1232 fn contract_multiplier_changes_native_loss_and_monetary_size() {
1233 let standard = compute_instrument_size(
1234 &SizingPolicy::FixedRiskAmount { amount: 100.0 },
1235 1.0,
1236 10_000.0,
1237 Side::Buy,
1238 1.10000,
1239 Some(1.09500),
1240 5,
1241 &quantity_rules(2),
1242 &standard_lot_economics("100000"),
1243 Some(1.0),
1244 )
1245 .unwrap();
1246 let doubled = compute_instrument_size(
1247 &SizingPolicy::FixedRiskAmount { amount: 100.0 },
1248 1.0,
1249 10_000.0,
1250 Side::Buy,
1251 1.10000,
1252 Some(1.09500),
1253 5,
1254 &quantity_rules(2),
1255 &standard_lot_economics("200000"),
1256 Some(1.0),
1257 )
1258 .unwrap();
1259
1260 assert_eq!(standard.native_loss_per_lot, Some(500.0));
1261 assert_eq!(doubled.native_loss_per_lot, Some(1_000.0));
1262 assert_close(standard.scaled_raw_lot, 0.2);
1263 assert_close(doubled.scaled_raw_lot, 0.1);
1264 assert_eq!(standard.final_lot_steps, 20);
1265 assert_eq!(doubled.final_lot_steps, 10);
1266 }
1267
1268 #[test]
1269 fn catalog_fixed_lot_uses_exact_quantity_grid_and_cap() {
1270 let mut rules = quantity_rules(4);
1271 rules.maximum = Some(positive("0.05"));
1272 let result = compute_instrument_size(
1273 &SizingPolicy::FixedLot { lots: 0.066 },
1274 1.0,
1275 10_000.0,
1276 Side::Buy,
1277 1.10000,
1278 None,
1279 5,
1280 &rules,
1281 &standard_lot_economics("100000"),
1282 None,
1283 )
1284 .unwrap();
1285
1286 assert_eq!(result.final_lot_steps, 5);
1287 assert_close(result.final_lot, 0.05);
1288 assert_eq!(result.cap_status, LotCapStatus::CappedAtMaximum);
1289 assert_eq!(result.native_loss_per_lot, None);
1290 }
1291
1292 #[test]
1293 fn full_spec_sizing_uses_exact_grid_and_records_adjustment() {
1294 let spec = instrument_spec("0.1", None);
1295 let result = compute_instrument_size_for_spec(
1296 &SizingPolicy::FixedLot { lots: 0.3 },
1297 1.0,
1298 10_000.0,
1299 Side::Buy,
1300 1.1,
1301 None,
1302 &spec,
1303 None,
1304 )
1305 .unwrap();
1306
1307 assert_eq!(result.final_lot_steps, 3);
1308 assert_eq!(result.final_lot, 0.3);
1309 assert_eq!(
1310 result.quantity_adjustment,
1311 Some(GridAdjustment {
1312 requested: decimal("0.3"),
1313 adjusted: decimal("0.3"),
1314 direction: AdjustmentDirection::Unchanged,
1315 })
1316 );
1317 }
1318
1319 #[test]
1320 fn full_spec_sizing_validates_price_grid_and_post_rounding_notional() {
1321 let usd = AssetId::new("USD").unwrap();
1322 let minimum_spec = instrument_spec(
1323 "0.01",
1324 Some(NotionalRules {
1325 asset: usd.clone(),
1326 minimum: Some(positive("2200")),
1327 maximum: None,
1328 }),
1329 );
1330 let minimum_error = compute_instrument_size_for_spec(
1331 &SizingPolicy::FixedLot { lots: 0.019 },
1332 1.0,
1333 10_000.0,
1334 Side::Buy,
1335 1.1,
1336 None,
1337 &minimum_spec,
1338 None,
1339 )
1340 .unwrap_err();
1341 assert!(matches!(
1342 minimum_error,
1343 InstrumentSizingError::BelowMinimumNotional { .. }
1344 ));
1345
1346 let maximum_spec = instrument_spec(
1347 "0.01",
1348 Some(NotionalRules {
1349 asset: usd,
1350 minimum: None,
1351 maximum: Some(positive("1000")),
1352 }),
1353 );
1354 let maximum_error = compute_instrument_size_for_spec(
1355 &SizingPolicy::FixedLot { lots: 0.02 },
1356 1.0,
1357 10_000.0,
1358 Side::Buy,
1359 1.1,
1360 None,
1361 &maximum_spec,
1362 None,
1363 )
1364 .unwrap_err();
1365 assert!(matches!(
1366 maximum_error,
1367 InstrumentSizingError::AboveMaximumNotional { .. }
1368 ));
1369
1370 let mut price_spec = instrument_spec("0.01", None);
1371 price_spec.price.grid = DecimalGrid::new(Decimal::ZERO, positive("0.00005"));
1372 let price_error = compute_instrument_size_for_spec(
1373 &SizingPolicy::FixedLot { lots: 0.02 },
1374 1.0,
1375 10_000.0,
1376 Side::Buy,
1377 1.10003,
1378 None,
1379 &price_spec,
1380 None,
1381 )
1382 .unwrap_err();
1383 assert!(matches!(
1384 price_error,
1385 InstrumentSizingError::PriceOffGrid { .. }
1386 ));
1387 }
1388
1389 #[test]
1390 fn catalog_sizing_rejects_unsupported_quantity_and_model_combinations() {
1391 for economics in [
1392 economics(
1393 QuantityUnit::Contract,
1394 EconomicsModelId::FX_QUOTE_LINEAR_V1,
1395 "100000",
1396 ),
1397 economics(
1398 QuantityUnit::StandardLot,
1399 EconomicsModelId::LINEAR_CONTRACT_V1,
1400 "100000",
1401 ),
1402 ] {
1403 let error = compute_instrument_size(
1404 &SizingPolicy::FixedLot { lots: 0.01 },
1405 1.0,
1406 10_000.0,
1407 Side::Buy,
1408 1.10000,
1409 None,
1410 5,
1411 &quantity_rules(2),
1412 &economics,
1413 None,
1414 )
1415 .unwrap_err();
1416
1417 assert!(matches!(
1418 error,
1419 InstrumentSizingError::UnsupportedInstrumentSizing { .. }
1420 ));
1421 }
1422 }
1423
1424 #[test]
1425 fn catalog_sizing_rejects_nonzero_quantity_grid_origin() {
1426 let mut rules = quantity_rules(2);
1427 rules.grid = DecimalGrid::new(decimal("0.01"), positive("0.01"));
1428 let error = compute_instrument_size(
1429 &SizingPolicy::FixedLot { lots: 0.01 },
1430 1.0,
1431 10_000.0,
1432 Side::Buy,
1433 1.10000,
1434 None,
1435 5,
1436 &rules,
1437 &standard_lot_economics("100000"),
1438 None,
1439 )
1440 .unwrap_err();
1441
1442 assert!(matches!(
1443 error,
1444 InstrumentSizingError::UnsupportedQuantityGridOrigin { .. }
1445 ));
1446 }
1447
1448 #[test]
1449 fn public_native_loss_helper_matches_compute_size_normalization() {
1450 let spec = forex_spec();
1451 let native_loss =
1452 compute_native_loss_per_lot(Side::Buy, 1.100004, 1.095003, &spec).unwrap();
1453 let result = compute_size(
1454 &SizingPolicy::FixedRiskAmount { amount: 100.0 },
1455 1.0,
1456 10_000.0,
1457 Side::Buy,
1458 1.100004,
1459 Some(1.095003),
1460 &spec,
1461 Some(native_loss),
1462 )
1463 .unwrap();
1464
1465 assert_close(native_loss, 500.0);
1466 assert_eq!(result.native_loss_per_lot, Some(native_loss));
1467
1468 let helper_error =
1469 compute_native_loss_per_lot(Side::Buy, 1.000004, 1.000003, &spec).unwrap_err();
1470 let sizing_error = compute_size(
1471 &SizingPolicy::FixedRiskAmount { amount: 100.0 },
1472 1.0,
1473 10_000.0,
1474 Side::Buy,
1475 1.000004,
1476 Some(1.000003),
1477 &spec,
1478 Some(100.0),
1479 )
1480 .unwrap_err();
1481 assert_eq!(helper_error, sizing_error);
1482 }
1483
1484 #[test]
1485 fn fixed_lot_applies_multiplier_before_lot_step() {
1486 let result = compute_size(
1487 &SizingPolicy::FixedLot { lots: 0.006 },
1488 2.0,
1489 10_000.0,
1490 Side::Buy,
1491 1.10000,
1492 None,
1493 &forex_spec(),
1494 None,
1495 )
1496 .unwrap();
1497
1498 assert_close(result.scaled_raw_lot, 0.012);
1499 assert_eq!(result.final_lot_steps, 1);
1500 assert_close(result.final_lot, 0.01);
1501 assert_eq!(result.requested_account_risk, None);
1502 assert_eq!(result.native_loss_per_lot, None);
1503 assert_eq!(result.account_loss_per_lot, None);
1504 assert_eq!(result.cap_status, LotCapStatus::NotCapped);
1505 }
1506
1507 #[test]
1508 fn invalid_risk_multipliers_are_rejected() {
1509 for value in [0.0, -1.0, f64::NAN, f64::INFINITY] {
1510 let error = compute_size(
1511 &SizingPolicy::FixedLot { lots: 0.01 },
1512 value,
1513 10_000.0,
1514 Side::Buy,
1515 1.10000,
1516 None,
1517 &forex_spec(),
1518 None,
1519 )
1520 .unwrap_err();
1521
1522 assert!(matches!(error, SizingError::InvalidRiskMultiplier { .. }));
1523 }
1524 }
1525
1526 #[test]
1527 fn fixed_lot_may_be_stopless_but_monetary_policies_may_not() {
1528 let fixed = compute_size(
1529 &SizingPolicy::FixedLot { lots: 0.01 },
1530 1.0,
1531 10_000.0,
1532 Side::Buy,
1533 1.10000,
1534 None,
1535 &forex_spec(),
1536 None,
1537 )
1538 .unwrap();
1539 assert_eq!(fixed.final_lot_steps, 1);
1540
1541 for policy in [
1542 SizingPolicy::FixedRiskAmount { amount: 100.0 },
1543 SizingPolicy::BalanceRiskPercent { percent: 1.0 },
1544 ] {
1545 let error = compute_size(
1546 &policy,
1547 1.0,
1548 10_000.0,
1549 Side::Buy,
1550 1.10000,
1551 None,
1552 &forex_spec(),
1553 Some(500.0),
1554 )
1555 .unwrap_err();
1556 assert_eq!(error, SizingError::MissingProtectiveStop);
1557 }
1558 }
1559
1560 #[test]
1561 fn monetary_policy_requires_positive_account_loss_per_lot() {
1562 let missing = compute_size(
1563 &SizingPolicy::FixedRiskAmount { amount: 100.0 },
1564 1.0,
1565 10_000.0,
1566 Side::Buy,
1567 1.10000,
1568 Some(1.09500),
1569 &forex_spec(),
1570 None,
1571 )
1572 .unwrap_err();
1573 assert_eq!(missing, SizingError::MissingAccountLossPerLot);
1574
1575 for value in [0.0, -1.0, f64::NAN, f64::INFINITY] {
1576 let error = compute_size(
1577 &SizingPolicy::FixedRiskAmount { amount: 100.0 },
1578 1.0,
1579 10_000.0,
1580 Side::Buy,
1581 1.10000,
1582 Some(1.09500),
1583 &forex_spec(),
1584 Some(value),
1585 )
1586 .unwrap_err();
1587 assert!(matches!(
1588 error,
1589 SizingError::InvalidAccountLossPerLot { .. }
1590 ));
1591 }
1592 }
1593
1594 #[test]
1595 fn fixed_risk_returns_requested_and_per_lot_audit_values() {
1596 let result = compute_size(
1597 &SizingPolicy::FixedRiskAmount { amount: 100.0 },
1598 2.0,
1599 10_000.0,
1600 Side::Buy,
1601 1.10000,
1602 Some(1.09500),
1603 &forex_spec(),
1604 Some(500.0),
1605 )
1606 .unwrap();
1607
1608 assert_eq!(result.requested_account_risk, Some(200.0));
1609 assert_eq!(result.native_loss_per_lot, Some(500.0));
1610 assert_eq!(result.account_loss_per_lot, Some(500.0));
1611 assert_close(result.scaled_raw_lot, 0.4);
1612 assert_eq!(result.final_lot_steps, 40);
1613 assert_close(result.final_lot, 0.4);
1614 }
1615
1616 #[test]
1617 fn balance_percent_uses_realized_balance_before() {
1618 let result = compute_size(
1619 &SizingPolicy::BalanceRiskPercent { percent: 1.0 },
1620 0.5,
1621 20_000.0,
1622 Side::Buy,
1623 1.10000,
1624 Some(1.09500),
1625 &forex_spec(),
1626 Some(500.0),
1627 )
1628 .unwrap();
1629
1630 assert_eq!(result.requested_account_risk, Some(100.0));
1631 assert_close(result.scaled_raw_lot, 0.2);
1632 assert_eq!(result.final_lot_steps, 20);
1633 assert_close(result.final_lot, 0.2);
1634 }
1635
1636 #[test]
1637 fn all_policies_reject_lots_below_the_minimum() {
1638 let cases = [
1639 (SizingPolicy::FixedLot { lots: 0.009 }, None),
1640 (SizingPolicy::FixedRiskAmount { amount: 4.5 }, Some(500.0)),
1641 (
1642 SizingPolicy::BalanceRiskPercent { percent: 0.045 },
1643 Some(500.0),
1644 ),
1645 ];
1646
1647 for (policy, account_loss_per_lot) in cases {
1648 let error = compute_size(
1649 &policy,
1650 1.0,
1651 10_000.0,
1652 Side::Buy,
1653 1.10000,
1654 Some(1.09500),
1655 &forex_spec(),
1656 account_loss_per_lot,
1657 )
1658 .unwrap_err();
1659
1660 assert!(matches!(
1661 error,
1662 SizingError::BelowMinimumLot {
1663 floored_lot_steps: 0,
1664 minimum_lot_steps: 1,
1665 ..
1666 }
1667 ));
1668 }
1669 }
1670
1671 #[test]
1672 fn maximum_cap_preserves_raw_lot_and_reports_audit_status() {
1673 let mut spec = forex_spec();
1674 spec.lot_max_steps = 5;
1675
1676 let result = compute_size(
1677 &SizingPolicy::FixedRiskAmount { amount: 100.0 },
1678 1.0,
1679 10_000.0,
1680 Side::Buy,
1681 1.10000,
1682 Some(1.09500),
1683 &spec,
1684 Some(100.0),
1685 )
1686 .unwrap();
1687
1688 assert_close(result.scaled_raw_lot, 1.0);
1689 assert_eq!(result.requested_account_risk, Some(100.0));
1690 assert_eq!(result.final_lot_steps, 5);
1691 assert_close(result.final_lot, 0.05);
1692 assert_eq!(result.cap_status, LotCapStatus::CappedAtMaximum);
1693 }
1694
1695 #[test]
1696 fn geometry_is_checked_before_sub_tick_distance() {
1697 let invalid_geometry = compute_size(
1698 &SizingPolicy::FixedRiskAmount { amount: 100.0 },
1699 1.0,
1700 10_000.0,
1701 Side::Buy,
1702 1.000003,
1703 Some(1.000004),
1704 &forex_spec(),
1705 Some(100.0),
1706 )
1707 .unwrap_err();
1708 assert!(matches!(
1709 invalid_geometry,
1710 SizingError::InvalidStopGeometry {
1711 side: Side::Buy,
1712 ..
1713 }
1714 ));
1715
1716 let sub_tick = compute_size(
1717 &SizingPolicy::FixedRiskAmount { amount: 100.0 },
1718 1.0,
1719 10_000.0,
1720 Side::Buy,
1721 1.000004,
1722 Some(1.000003),
1723 &forex_spec(),
1724 Some(100.0),
1725 )
1726 .unwrap_err();
1727 assert_eq!(
1728 sub_tick,
1729 SizingError::StopDistanceBelowTick {
1730 entry_price: 1.000004,
1731 stop_price: 1.000003,
1732 digits: 5,
1733 }
1734 );
1735 }
1736
1737 #[test]
1738 fn fixed_lot_validates_a_supplied_stop_but_does_not_require_one() {
1739 let error = compute_size(
1740 &SizingPolicy::FixedLot { lots: 0.01 },
1741 1.0,
1742 10_000.0,
1743 Side::Sell,
1744 1.10000,
1745 Some(1.09500),
1746 &forex_spec(),
1747 None,
1748 )
1749 .unwrap_err();
1750
1751 assert!(matches!(
1752 error,
1753 SizingError::InvalidStopGeometry {
1754 side: Side::Sell,
1755 ..
1756 }
1757 ));
1758 }
1759}