Skip to main content

qs_core/
sizing.rs

1//! In-place account position sizing for backtests.
2//!
3//! Sizing consumes the authoritative entry and protective stop after signal resolution. Currency conversion and target allocation are performed by callers.
4
5use 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/// Policy for computing the position size of one resolved entry signal.
14#[derive(Debug, Clone, Copy, PartialEq)]
15pub enum SizingPolicy {
16    /// Scale a fixed lot quantity by the signal risk multiplier.
17    FixedLot {
18        /// Unscaled lot quantity.
19        lots: f64,
20    },
21    /// Risk a fixed amount in account currency.
22    FixedRiskAmount {
23        /// Unscaled account-currency risk amount.
24        amount: f64,
25    },
26    /// Risk a percentage of the realized balance before the entry.
27    BalanceRiskPercent {
28        /// Unscaled percentage where 1.0 means one percent.
29        percent: f64,
30    },
31}
32
33/// Indicates whether the symbol maximum reduced the computed lot steps.
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum LotCapStatus {
36    /// The computed lot steps did not exceed the symbol maximum.
37    NotCapped,
38    /// The computed lot steps were reduced to the symbol maximum.
39    CappedAtMaximum,
40}
41
42/// Auditable output from legacy or catalog-backed sizing.
43#[derive(Debug, Clone, PartialEq)]
44pub struct SizingResult {
45    /// Authoritative tradable quantity in legacy lot steps or catalog quantity-grid steps.
46    pub final_lot_steps: u64,
47    /// Final standard-lot quantity derived from `final_lot_steps` and the active quantity rules.
48    pub final_lot: f64,
49    /// Raw lot quantity after applying the signal risk multiplier and before lot constraints.
50    pub scaled_raw_lot: f64,
51    /// Requested account-currency risk before lot constraints for monetary policies.
52    pub requested_account_risk: Option<f64>,
53    /// Loss in the symbol's native P&L currency for one lot at the protective stop.
54    pub native_loss_per_lot: Option<f64>,
55    /// Account-currency loss for one lot at the protective stop.
56    pub account_loss_per_lot: Option<f64>,
57    /// Maximum-lot cap status.
58    pub cap_status: LotCapStatus,
59    /// Exact requested and final quantity for catalog-backed sizing.
60    pub quantity_adjustment: Option<GridAdjustment<Decimal>>,
61    /// Exact notional after quantity adjustment for catalog-backed sizing.
62    pub final_notional: Option<Money>,
63}
64
65/// Stable structured failures from [`compute_size`].
66#[derive(Debug, Clone, PartialEq, Error)]
67pub enum SizingError {
68    /// The signal risk multiplier is not usable.
69    #[error("risk_multiplier must be finite and positive, got {value}")]
70    InvalidRiskMultiplier {
71        /// Invalid multiplier.
72        value: f64,
73    },
74    /// The fixed lot policy value is not usable.
75    #[error("fixed lots must be finite and positive, got {value}")]
76    InvalidFixedLots {
77        /// Invalid lot quantity.
78        value: f64,
79    },
80    /// The fixed account risk policy value is not usable.
81    #[error("fixed risk amount must be finite and positive, got {value}")]
82    InvalidFixedRiskAmount {
83        /// Invalid account-currency amount.
84        value: f64,
85    },
86    /// The balance percentage policy value is not usable.
87    #[error("balance risk percent must be finite and positive, got {value}")]
88    InvalidBalanceRiskPercent {
89        /// Invalid percentage.
90        value: f64,
91    },
92    /// The realized balance required by a balance policy is not usable.
93    #[error("balance_before must be finite and positive, got {value}")]
94    InvalidBalanceBefore {
95        /// Invalid realized balance.
96        value: f64,
97    },
98    /// The authoritative entry price is not usable.
99    #[error("entry price must be finite and positive, got {value}")]
100    InvalidEntryPrice {
101        /// Invalid entry price.
102        value: f64,
103    },
104    /// The supplied protective stop is not usable.
105    #[error("protective stop must be finite and positive, got {value}")]
106    InvalidProtectiveStop {
107        /// Invalid stop price.
108        value: f64,
109    },
110    /// A monetary policy was used without a protective stop.
111    #[error("monetary sizing requires a protective stop")]
112    MissingProtectiveStop,
113    /// A stop is not on the loss side of the authoritative entry.
114    #[error("invalid {side} protective stop geometry: entry {entry_price}, stop {stop_price}")]
115    InvalidStopGeometry {
116        /// Trade side.
117        side: Side,
118        /// Authoritative entry price.
119        entry_price: f64,
120        /// Protective stop price.
121        stop_price: f64,
122    },
123    /// Entry and stop collapse to the same tick at the symbol price precision.
124    #[error("entry {entry_price} and stop {stop_price} have zero distance at {digits} digits")]
125    StopDistanceBelowTick {
126        /// Authoritative entry price.
127        entry_price: f64,
128        /// Protective stop price.
129        stop_price: f64,
130        /// Symbol price digits.
131        digits: u16,
132    },
133    /// A price cannot be represented safely at the symbol precision.
134    #[error("{field} price {value} is out of range at {digits} digits")]
135    PriceOutOfRange {
136        /// Price field name.
137        field: &'static str,
138        /// Out-of-range price.
139        value: f64,
140        /// Symbol price digits.
141        digits: u16,
142    },
143    /// A monetary policy did not receive an account-currency loss per lot.
144    #[error("monetary sizing requires account_loss_per_lot")]
145    MissingAccountLossPerLot,
146    /// The supplied account-currency loss per lot is not usable.
147    #[error("account_loss_per_lot must be finite and positive, got {value}")]
148    InvalidAccountLossPerLot {
149        /// Invalid account-currency loss per lot.
150        value: f64,
151    },
152    /// The symbol price precision is internally inconsistent or unsupported.
153    #[error("invalid symbol price precision: digits={digits}, pip_position={pip_position}")]
154    InvalidPricePrecision {
155        /// Number of symbol price digits.
156        digits: u16,
157        /// Position of one pip.
158        pip_position: u16,
159    },
160    /// The symbol lot base unit count is invalid.
161    #[error("symbol lot_base_units must be positive, got {value}")]
162    InvalidLotBaseUnits {
163        /// Invalid base unit count.
164        value: i64,
165    },
166    /// The symbol lot step unit count is invalid.
167    #[error("symbol lot_step_units must be positive, got {value}")]
168    InvalidLotStepUnits {
169        /// Invalid lot step unit count.
170        value: i64,
171    },
172    /// The symbol minimum lot step count is invalid.
173    #[error("symbol lot_min_steps must be positive, got {value}")]
174    InvalidMinimumLotSteps {
175        /// Invalid minimum lot step count.
176        value: i64,
177    },
178    /// The symbol maximum lot step count is invalid.
179    #[error("symbol lot_max_steps {maximum} must be zero or at least lot_min_steps {minimum}")]
180    InvalidMaximumLotSteps {
181        /// Invalid maximum lot step count.
182        maximum: i64,
183        /// Configured minimum lot step count.
184        minimum: i64,
185    },
186    /// Applying the signal multiplier produced an unusable policy value.
187    #[error(
188        "scaling policy value {base_value} by risk_multiplier {risk_multiplier} did not produce a finite positive value"
189    )]
190    InvalidScaledPolicyValue {
191        /// Unscaled fixed lots or account risk.
192        base_value: f64,
193        /// Signal risk multiplier.
194        risk_multiplier: f64,
195    },
196    /// Monetary division produced an unusable raw lot quantity.
197    #[error("scaled raw lot must be finite and positive, got {value}")]
198    InvalidScaledRawLot {
199        /// Invalid raw lot quantity.
200        value: f64,
201    },
202    /// The computed native loss for one lot overflowed.
203    #[error("native loss per lot is not finite for entry {entry_price} and stop {stop_price}")]
204    InvalidNativeLossPerLot {
205        /// Authoritative entry price.
206        entry_price: f64,
207        /// Protective stop price.
208        stop_price: f64,
209    },
210    /// The floored lot quantity does not meet the symbol minimum.
211    #[error(
212        "scaled raw lot {scaled_raw_lot} floors to {floored_lot_steps} steps below minimum {minimum_lot_steps}"
213    )]
214    BelowMinimumLot {
215        /// Lot quantity before constraints.
216        scaled_raw_lot: f64,
217        /// Lot steps after flooring.
218        floored_lot_steps: u64,
219        /// Required minimum lot steps.
220        minimum_lot_steps: u64,
221    },
222    /// The lot quantity cannot be represented by the authoritative step count.
223    #[error("scaled raw lot {scaled_raw_lot} exceeds the supported lot step count")]
224    LotStepOverflow {
225        /// Lot quantity before constraints.
226        scaled_raw_lot: f64,
227    },
228}
229
230/// Catalog-backed sizing failures.
231#[derive(Debug, Clone, PartialEq, Error)]
232pub enum InstrumentSizingError {
233    /// Existing policy, price, or floating-point sizing validation failed.
234    #[error(transparent)]
235    Sizing(#[from] SizingError),
236    /// The instrument quantity and P&L model combination is not supported by this sizing path.
237    #[error(
238        "unsupported instrument sizing combination: quantity_unit={quantity_unit:?}, pnl_model={pnl_model}"
239    )]
240    UnsupportedInstrumentSizing {
241        /// Instrument quantity unit.
242        quantity_unit: QuantityUnit,
243        /// Instrument P&L model.
244        pnl_model: EconomicsModelId,
245    },
246    /// Catalog-backed monetary sizing did not receive a native-to-account conversion rate.
247    #[error("catalog-backed monetary sizing requires native_to_account_rate")]
248    MissingNativeToAccountRate,
249    /// The supplied native-to-account conversion rate is not usable.
250    #[error("native_to_account_rate must be finite and positive, got {value}")]
251    InvalidNativeToAccountRate {
252        /// Invalid conversion rate.
253        value: f64,
254    },
255    /// The exact instrument decimal cannot be represented as a finite `f64`.
256    #[error("{field} decimal {value} cannot be represented as a finite f64")]
257    ExactDecimalOutOfRange {
258        /// Instrument field being converted.
259        field: &'static str,
260        /// Exact value that could not be converted.
261        value: Decimal,
262    },
263    /// The quantity grid origin is incompatible with the retained zero-based lot-step result.
264    #[error("quantity grid origin must be zero for standard-lot sizing, got {value}")]
265    UnsupportedQuantityGridOrigin {
266        /// Unsupported grid origin.
267        value: Decimal,
268    },
269    /// An exact quantity bound is not on the declared quantity grid.
270    #[error("{field} quantity {value} is not on the declared quantity grid")]
271    QuantityBoundOffGrid {
272        /// Quantity bound field.
273        field: &'static str,
274        /// Off-grid exact quantity.
275        value: Decimal,
276    },
277    /// The exact quantity bounds are inconsistent.
278    #[error("maximum quantity {maximum} is below minimum quantity {minimum}")]
279    InvalidQuantityBounds {
280        /// Minimum standard-lot quantity.
281        minimum: Decimal,
282        /// Maximum standard-lot quantity.
283        maximum: Decimal,
284    },
285    /// An exact quantity bound cannot be represented as a supported lot-step count.
286    #[error("{field} quantity {value} exceeds the supported lot step count")]
287    QuantityStepOverflow {
288        /// Quantity bound field.
289        field: &'static str,
290        /// Exact quantity that overflowed.
291        value: Decimal,
292    },
293    /// Exact quantity-grid arithmetic failed.
294    #[error("invalid quantity grid: {0}")]
295    InvalidQuantityGrid(#[from] qs_instruments::GridError),
296    /// A replay price is outside the declared price grid.
297    #[error("{field} price {value} is outside the declared price grid")]
298    PriceOffGrid {
299        /// Price field being validated.
300        field: &'static str,
301        /// Exact off-grid value.
302        value: Decimal,
303    },
304    /// The notional asset is unsupported by the current quote-linear sizing model.
305    #[error("notional asset {notional_asset} must match settlement asset {settlement_asset}")]
306    UnsupportedNotionalAsset {
307        /// Declared notional asset.
308        notional_asset: qs_instruments::AssetId,
309        /// Instrument settlement asset.
310        settlement_asset: qs_instruments::AssetId,
311    },
312    /// Exact final notional is below the declared minimum.
313    #[error("final notional {notional} is below minimum {minimum}")]
314    BelowMinimumNotional {
315        /// Calculated final notional.
316        notional: Decimal,
317        /// Required minimum.
318        minimum: Decimal,
319    },
320    /// Exact final notional exceeds the declared maximum.
321    #[error("final notional {notional} exceeds maximum {maximum}")]
322    AboveMaximumNotional {
323        /// Calculated final notional.
324        notional: Decimal,
325        /// Allowed maximum.
326        maximum: Decimal,
327    },
328    /// Exact catalog arithmetic failed.
329    #[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/// Compute one in-place account position size.
355///
356/// `balance_before` is the realized account balance immediately before the entry. `entry_price` and `protective_stop` are authoritative resolved prices. Monetary policies require `account_loss_per_lot` to be converted to account currency by the caller. Fixed-lot sizing ignores `balance_before` and `account_loss_per_lot`, and it may omit the protective stop.
357#[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/// Compute one account position size from catalog-backed quantity and economics contracts.
388///
389/// This compatibility seam validates exact quantity rules but has no full specification for price-grid or notional validation. New replay consumers should use [`compute_instrument_size_for_spec`].
390#[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/// Compute catalog-backed sizing with complete price, quantity, economics, and notional validation.
439#[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
865/// Compute the positive native P&L currency loss for one standard lot at a protective stop.
866///
867/// Prices are normalized to `SymbolSpec::digits` before distance is measured. The helper uses the same validation and geometry path as [`compute_size`].
868pub 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
890/// Compute native stop loss for one catalog-backed standard lot.
891///
892/// The contract multiplier is the economic authority. Quantity storage scale does not participate in this calculation.
893pub 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}