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