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