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_symbols::SymbolSpec;
7use thiserror::Error;
8
9/// Policy for computing the position size of one resolved entry signal.
10#[derive(Debug, Clone, Copy, PartialEq)]
11pub enum SizingPolicy {
12    /// Scale a fixed lot quantity by the signal risk multiplier.
13    FixedLot {
14        /// Unscaled lot quantity.
15        lots: f64,
16    },
17    /// Risk a fixed amount in account currency.
18    FixedRiskAmount {
19        /// Unscaled account-currency risk amount.
20        amount: f64,
21    },
22    /// Risk a percentage of the realized balance before the entry.
23    BalanceRiskPercent {
24        /// Unscaled percentage where 1.0 means one percent.
25        percent: f64,
26    },
27}
28
29/// Indicates whether the symbol maximum reduced the computed lot steps.
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum LotCapStatus {
32    /// The computed lot steps did not exceed the symbol maximum.
33    NotCapped,
34    /// The computed lot steps were reduced to the symbol maximum.
35    CappedAtMaximum,
36}
37
38/// Auditable output from [`compute_size`].
39#[derive(Debug, Clone, Copy, PartialEq)]
40pub struct SizingResult {
41    /// Authoritative tradable quantity in symbol lot steps.
42    pub final_lot_steps: u64,
43    /// Final lot quantity derived from `final_lot_steps` and `SymbolSpec`.
44    pub final_lot: f64,
45    /// Raw lot quantity after applying the signal risk multiplier and before lot constraints.
46    pub scaled_raw_lot: f64,
47    /// Requested account-currency risk before lot constraints for monetary policies.
48    pub requested_account_risk: Option<f64>,
49    /// Loss in the symbol's native P&L currency for one lot at the protective stop.
50    pub native_loss_per_lot: Option<f64>,
51    /// Caller-supplied account-currency loss for one lot at the protective stop.
52    pub account_loss_per_lot: Option<f64>,
53    /// Maximum-lot cap status.
54    pub cap_status: LotCapStatus,
55}
56
57/// Stable structured failures from [`compute_size`].
58#[derive(Debug, Clone, PartialEq, Error)]
59pub enum SizingError {
60    /// The signal risk multiplier is not usable.
61    #[error("risk_multiplier must be finite and positive, got {value}")]
62    InvalidRiskMultiplier {
63        /// Invalid multiplier.
64        value: f64,
65    },
66    /// The fixed lot policy value is not usable.
67    #[error("fixed lots must be finite and positive, got {value}")]
68    InvalidFixedLots {
69        /// Invalid lot quantity.
70        value: f64,
71    },
72    /// The fixed account risk policy value is not usable.
73    #[error("fixed risk amount must be finite and positive, got {value}")]
74    InvalidFixedRiskAmount {
75        /// Invalid account-currency amount.
76        value: f64,
77    },
78    /// The balance percentage policy value is not usable.
79    #[error("balance risk percent must be finite and positive, got {value}")]
80    InvalidBalanceRiskPercent {
81        /// Invalid percentage.
82        value: f64,
83    },
84    /// The realized balance required by a balance policy is not usable.
85    #[error("balance_before must be finite and positive, got {value}")]
86    InvalidBalanceBefore {
87        /// Invalid realized balance.
88        value: f64,
89    },
90    /// The authoritative entry price is not usable.
91    #[error("entry price must be finite and positive, got {value}")]
92    InvalidEntryPrice {
93        /// Invalid entry price.
94        value: f64,
95    },
96    /// The supplied protective stop is not usable.
97    #[error("protective stop must be finite and positive, got {value}")]
98    InvalidProtectiveStop {
99        /// Invalid stop price.
100        value: f64,
101    },
102    /// A monetary policy was used without a protective stop.
103    #[error("monetary sizing requires a protective stop")]
104    MissingProtectiveStop,
105    /// A stop is not on the loss side of the authoritative entry.
106    #[error("invalid {side} protective stop geometry: entry {entry_price}, stop {stop_price}")]
107    InvalidStopGeometry {
108        /// Trade side.
109        side: Side,
110        /// Authoritative entry price.
111        entry_price: f64,
112        /// Protective stop price.
113        stop_price: f64,
114    },
115    /// Entry and stop collapse to the same tick at the symbol price precision.
116    #[error("entry {entry_price} and stop {stop_price} have zero distance at {digits} digits")]
117    StopDistanceBelowTick {
118        /// Authoritative entry price.
119        entry_price: f64,
120        /// Protective stop price.
121        stop_price: f64,
122        /// Symbol price digits.
123        digits: u16,
124    },
125    /// A price cannot be represented safely at the symbol precision.
126    #[error("{field} price {value} is out of range at {digits} digits")]
127    PriceOutOfRange {
128        /// Price field name.
129        field: &'static str,
130        /// Out-of-range price.
131        value: f64,
132        /// Symbol price digits.
133        digits: u16,
134    },
135    /// A monetary policy did not receive an account-currency loss per lot.
136    #[error("monetary sizing requires account_loss_per_lot")]
137    MissingAccountLossPerLot,
138    /// The supplied account-currency loss per lot is not usable.
139    #[error("account_loss_per_lot must be finite and positive, got {value}")]
140    InvalidAccountLossPerLot {
141        /// Invalid account-currency loss per lot.
142        value: f64,
143    },
144    /// The symbol price precision is internally inconsistent or unsupported.
145    #[error("invalid symbol price precision: digits={digits}, pip_position={pip_position}")]
146    InvalidPricePrecision {
147        /// Number of symbol price digits.
148        digits: u16,
149        /// Position of one pip.
150        pip_position: u16,
151    },
152    /// The symbol lot base unit count is invalid.
153    #[error("symbol lot_base_units must be positive, got {value}")]
154    InvalidLotBaseUnits {
155        /// Invalid base unit count.
156        value: i64,
157    },
158    /// The symbol lot step unit count is invalid.
159    #[error("symbol lot_step_units must be positive, got {value}")]
160    InvalidLotStepUnits {
161        /// Invalid lot step unit count.
162        value: i64,
163    },
164    /// The symbol minimum lot step count is invalid.
165    #[error("symbol lot_min_steps must be positive, got {value}")]
166    InvalidMinimumLotSteps {
167        /// Invalid minimum lot step count.
168        value: i64,
169    },
170    /// The symbol maximum lot step count is invalid.
171    #[error("symbol lot_max_steps {maximum} must be zero or at least lot_min_steps {minimum}")]
172    InvalidMaximumLotSteps {
173        /// Invalid maximum lot step count.
174        maximum: i64,
175        /// Configured minimum lot step count.
176        minimum: i64,
177    },
178    /// Applying the signal multiplier produced an unusable policy value.
179    #[error(
180        "scaling policy value {base_value} by risk_multiplier {risk_multiplier} did not produce a finite positive value"
181    )]
182    InvalidScaledPolicyValue {
183        /// Unscaled fixed lots or account risk.
184        base_value: f64,
185        /// Signal risk multiplier.
186        risk_multiplier: f64,
187    },
188    /// Monetary division produced an unusable raw lot quantity.
189    #[error("scaled raw lot must be finite and positive, got {value}")]
190    InvalidScaledRawLot {
191        /// Invalid raw lot quantity.
192        value: f64,
193    },
194    /// The computed native loss for one lot overflowed.
195    #[error("native loss per lot is not finite for entry {entry_price} and stop {stop_price}")]
196    InvalidNativeLossPerLot {
197        /// Authoritative entry price.
198        entry_price: f64,
199        /// Protective stop price.
200        stop_price: f64,
201    },
202    /// The floored lot quantity does not meet the symbol minimum.
203    #[error(
204        "scaled raw lot {scaled_raw_lot} floors to {floored_lot_steps} steps below minimum {minimum_lot_steps}"
205    )]
206    BelowMinimumLot {
207        /// Lot quantity before constraints.
208        scaled_raw_lot: f64,
209        /// Lot steps after flooring.
210        floored_lot_steps: u64,
211        /// Required minimum lot steps.
212        minimum_lot_steps: u64,
213    },
214    /// The lot quantity cannot be represented by the authoritative step count.
215    #[error("scaled raw lot {scaled_raw_lot} exceeds the supported lot step count")]
216    LotStepOverflow {
217        /// Lot quantity before constraints.
218        scaled_raw_lot: f64,
219    },
220}
221
222#[derive(Debug, Clone, Copy)]
223struct ValidatedLotSpec {
224    lot_base_units: u64,
225    lot_step_units: u64,
226    lot_min_steps: u64,
227    lot_max_steps: Option<u64>,
228}
229
230#[derive(Debug, Clone, Copy)]
231enum PolicyBasis {
232    FixedLots(f64),
233    AccountRisk(f64),
234}
235
236/// Compute one in-place account position size.
237///
238/// `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.
239#[allow(clippy::too_many_arguments)]
240pub fn compute_size(
241    policy: &SizingPolicy,
242    risk_multiplier: f64,
243    balance_before: f64,
244    side: Side,
245    entry_price: f64,
246    protective_stop: Option<f64>,
247    spec: &SymbolSpec,
248    account_loss_per_lot: Option<f64>,
249) -> Result<SizingResult, SizingError> {
250    validate_risk_multiplier(risk_multiplier)?;
251    validate_entry_price(entry_price)?;
252    let lot_spec = validate_lot_spec(spec)?;
253    let basis = policy_basis(policy, balance_before)?;
254    let native_loss_per_lot = protective_stop
255        .map(|stop_price| compute_native_loss_per_lot(side, entry_price, stop_price, spec))
256        .transpose()?;
257
258    let scaled_policy_value = match basis {
259        PolicyBasis::FixedLots(base_value) | PolicyBasis::AccountRisk(base_value) => {
260            let scaled = base_value * risk_multiplier;
261            if !scaled.is_finite() || scaled <= 0.0 {
262                return Err(SizingError::InvalidScaledPolicyValue {
263                    base_value,
264                    risk_multiplier,
265                });
266            }
267            scaled
268        }
269    };
270
271    let (scaled_raw_lot, requested_account_risk, result_account_loss_per_lot) = match basis {
272        PolicyBasis::FixedLots(_) => (scaled_policy_value, None, None),
273        PolicyBasis::AccountRisk(_) => {
274            native_loss_per_lot.ok_or(SizingError::MissingProtectiveStop)?;
275            let account_loss_per_lot = account_loss_per_lot
276                .ok_or(SizingError::MissingAccountLossPerLot)
277                .and_then(validate_account_loss_per_lot)?;
278            let scaled_raw_lot = scaled_policy_value / account_loss_per_lot;
279            if !scaled_raw_lot.is_finite() || scaled_raw_lot <= 0.0 {
280                return Err(SizingError::InvalidScaledRawLot {
281                    value: scaled_raw_lot,
282                });
283            }
284            (
285                scaled_raw_lot,
286                Some(scaled_policy_value),
287                Some(account_loss_per_lot),
288            )
289        }
290    };
291
292    let (final_lot_steps, final_lot, cap_status) = apply_lot_constraints(scaled_raw_lot, lot_spec)?;
293
294    Ok(SizingResult {
295        final_lot_steps,
296        final_lot,
297        scaled_raw_lot,
298        requested_account_risk,
299        native_loss_per_lot,
300        account_loss_per_lot: result_account_loss_per_lot,
301        cap_status,
302    })
303}
304
305fn validate_risk_multiplier(value: f64) -> Result<(), SizingError> {
306    if value.is_finite() && value > 0.0 {
307        Ok(())
308    } else {
309        Err(SizingError::InvalidRiskMultiplier { value })
310    }
311}
312
313fn validate_entry_price(value: f64) -> Result<(), SizingError> {
314    if value.is_finite() && value > 0.0 {
315        Ok(())
316    } else {
317        Err(SizingError::InvalidEntryPrice { value })
318    }
319}
320
321fn validate_account_loss_per_lot(value: f64) -> Result<f64, SizingError> {
322    if value.is_finite() && value > 0.0 {
323        Ok(value)
324    } else {
325        Err(SizingError::InvalidAccountLossPerLot { value })
326    }
327}
328
329fn policy_basis(policy: &SizingPolicy, balance_before: f64) -> Result<PolicyBasis, SizingError> {
330    match *policy {
331        SizingPolicy::FixedLot { lots } => {
332            if lots.is_finite() && lots > 0.0 {
333                Ok(PolicyBasis::FixedLots(lots))
334            } else {
335                Err(SizingError::InvalidFixedLots { value: lots })
336            }
337        }
338        SizingPolicy::FixedRiskAmount { amount } => {
339            if amount.is_finite() && amount > 0.0 {
340                Ok(PolicyBasis::AccountRisk(amount))
341            } else {
342                Err(SizingError::InvalidFixedRiskAmount { value: amount })
343            }
344        }
345        SizingPolicy::BalanceRiskPercent { percent } => {
346            if !percent.is_finite() || percent <= 0.0 {
347                return Err(SizingError::InvalidBalanceRiskPercent { value: percent });
348            }
349            if !balance_before.is_finite() || balance_before <= 0.0 {
350                return Err(SizingError::InvalidBalanceBefore {
351                    value: balance_before,
352                });
353            }
354            let account_risk = balance_before * (percent / 100.0);
355            if account_risk.is_finite() && account_risk > 0.0 {
356                Ok(PolicyBasis::AccountRisk(account_risk))
357            } else {
358                Err(SizingError::InvalidScaledPolicyValue {
359                    base_value: balance_before,
360                    risk_multiplier: percent / 100.0,
361                })
362            }
363        }
364    }
365}
366
367fn validate_lot_spec(spec: &SymbolSpec) -> Result<ValidatedLotSpec, SizingError> {
368    let lot_base_units = u64::try_from(spec.lot_base_units)
369        .ok()
370        .filter(|value| *value > 0)
371        .ok_or(SizingError::InvalidLotBaseUnits {
372            value: spec.lot_base_units,
373        })?;
374    let lot_step_units = u64::try_from(spec.lot_step_units)
375        .ok()
376        .filter(|value| *value > 0)
377        .ok_or(SizingError::InvalidLotStepUnits {
378            value: spec.lot_step_units,
379        })?;
380    let lot_min_steps = u64::try_from(spec.lot_min_steps)
381        .ok()
382        .filter(|value| *value > 0)
383        .ok_or(SizingError::InvalidMinimumLotSteps {
384            value: spec.lot_min_steps,
385        })?;
386    let lot_max_steps = match spec.lot_max_steps {
387        0 => None,
388        maximum if maximum >= spec.lot_min_steps => Some(maximum as u64),
389        maximum => {
390            return Err(SizingError::InvalidMaximumLotSteps {
391                maximum,
392                minimum: spec.lot_min_steps,
393            });
394        }
395    };
396
397    Ok(ValidatedLotSpec {
398        lot_base_units,
399        lot_step_units,
400        lot_min_steps,
401        lot_max_steps,
402    })
403}
404
405/// Compute the positive native P&L currency loss for one standard lot at a protective stop.
406///
407/// Prices are normalized to `SymbolSpec::digits` before distance is measured. The helper uses the same validation and geometry path as [`compute_size`].
408pub fn compute_native_loss_per_lot(
409    side: Side,
410    entry_price: f64,
411    protective_stop: f64,
412    spec: &SymbolSpec,
413) -> Result<f64, SizingError> {
414    validate_entry_price(entry_price)?;
415    if spec.lot_base_units <= 0 {
416        return Err(SizingError::InvalidLotBaseUnits {
417            value: spec.lot_base_units,
418        });
419    }
420    if !protective_stop.is_finite() || protective_stop <= 0.0 {
421        return Err(SizingError::InvalidProtectiveStop {
422            value: protective_stop,
423        });
424    }
425    let valid_geometry = match side {
426        Side::Buy => protective_stop < entry_price,
427        Side::Sell => protective_stop > entry_price,
428    };
429    if !valid_geometry {
430        return Err(SizingError::InvalidStopGeometry {
431            side,
432            entry_price,
433            stop_price: protective_stop,
434        });
435    }
436    if spec.digits > 18 || spec.pip_position > spec.digits {
437        return Err(SizingError::InvalidPricePrecision {
438            digits: spec.digits,
439            pip_position: spec.pip_position,
440        });
441    }
442
443    let scale = 10_i64.pow(spec.digits as u32) as f64;
444    let entry_ticks = price_to_ticks("entry", entry_price, spec.digits, scale)?;
445    let stop_ticks = price_to_ticks("protective stop", protective_stop, spec.digits, scale)?;
446    let distance_ticks = entry_ticks.abs_diff(stop_ticks);
447    if distance_ticks == 0 {
448        return Err(SizingError::StopDistanceBelowTick {
449            entry_price,
450            stop_price: protective_stop,
451            digits: spec.digits,
452        });
453    }
454
455    let native_loss_per_lot = distance_ticks as f64 * spec.lot_base_units as f64 / scale;
456    if !native_loss_per_lot.is_finite() || native_loss_per_lot <= 0.0 {
457        return Err(SizingError::InvalidNativeLossPerLot {
458            entry_price,
459            stop_price: protective_stop,
460        });
461    }
462
463    Ok(native_loss_per_lot)
464}
465
466fn price_to_ticks(
467    field: &'static str,
468    value: f64,
469    digits: u16,
470    scale: f64,
471) -> Result<i64, SizingError> {
472    let scaled = value * scale;
473    if !scaled.is_finite() || scaled >= i64::MAX as f64 {
474        return Err(SizingError::PriceOutOfRange {
475            field,
476            value,
477            digits,
478        });
479    }
480    Ok(scaled.round() as i64)
481}
482
483fn apply_lot_constraints(
484    scaled_raw_lot: f64,
485    spec: ValidatedLotSpec,
486) -> Result<(u64, f64, LotCapStatus), SizingError> {
487    if !scaled_raw_lot.is_finite() || scaled_raw_lot <= 0.0 {
488        return Err(SizingError::InvalidScaledRawLot {
489            value: scaled_raw_lot,
490        });
491    }
492
493    let raw_steps = scaled_raw_lot * spec.lot_base_units as f64 / spec.lot_step_units as f64;
494    if !raw_steps.is_finite() || raw_steps >= u64::MAX as f64 {
495        return Err(SizingError::LotStepOverflow { scaled_raw_lot });
496    }
497    let floored_lot_steps = raw_steps.floor() as u64;
498    if floored_lot_steps < spec.lot_min_steps {
499        return Err(SizingError::BelowMinimumLot {
500            scaled_raw_lot,
501            floored_lot_steps,
502            minimum_lot_steps: spec.lot_min_steps,
503        });
504    }
505
506    let (final_lot_steps, cap_status) = match spec.lot_max_steps {
507        Some(maximum) if floored_lot_steps > maximum => (maximum, LotCapStatus::CappedAtMaximum),
508        _ => (floored_lot_steps, LotCapStatus::NotCapped),
509    };
510    let final_lot =
511        final_lot_steps as f64 * spec.lot_step_units as f64 / spec.lot_base_units as f64;
512
513    Ok((final_lot_steps, final_lot, cap_status))
514}
515
516#[cfg(test)]
517mod tests {
518    use super::*;
519
520    fn forex_spec() -> SymbolSpec {
521        SymbolSpec {
522            canonical: "eurusd".into(),
523            pip_position: 4,
524            digits: 5,
525            category: "forex".into(),
526            lot_base_units: 100_000,
527            lot_step_units: 1_000,
528            lot_min_steps: 1,
529            lot_max_steps: 0,
530        }
531    }
532
533    fn assert_close(actual: f64, expected: f64) {
534        assert!(
535            (actual - expected).abs() < 1e-12,
536            "expected {expected}, got {actual}"
537        );
538    }
539
540    #[test]
541    fn public_native_loss_helper_matches_compute_size_normalization() {
542        let spec = forex_spec();
543        let native_loss =
544            compute_native_loss_per_lot(Side::Buy, 1.100004, 1.095003, &spec).unwrap();
545        let result = compute_size(
546            &SizingPolicy::FixedRiskAmount { amount: 100.0 },
547            1.0,
548            10_000.0,
549            Side::Buy,
550            1.100004,
551            Some(1.095003),
552            &spec,
553            Some(native_loss),
554        )
555        .unwrap();
556
557        assert_close(native_loss, 500.0);
558        assert_eq!(result.native_loss_per_lot, Some(native_loss));
559
560        let helper_error =
561            compute_native_loss_per_lot(Side::Buy, 1.000004, 1.000003, &spec).unwrap_err();
562        let sizing_error = compute_size(
563            &SizingPolicy::FixedRiskAmount { amount: 100.0 },
564            1.0,
565            10_000.0,
566            Side::Buy,
567            1.000004,
568            Some(1.000003),
569            &spec,
570            Some(100.0),
571        )
572        .unwrap_err();
573        assert_eq!(helper_error, sizing_error);
574    }
575
576    #[test]
577    fn fixed_lot_applies_multiplier_before_lot_step() {
578        let result = compute_size(
579            &SizingPolicy::FixedLot { lots: 0.006 },
580            2.0,
581            10_000.0,
582            Side::Buy,
583            1.10000,
584            None,
585            &forex_spec(),
586            None,
587        )
588        .unwrap();
589
590        assert_close(result.scaled_raw_lot, 0.012);
591        assert_eq!(result.final_lot_steps, 1);
592        assert_close(result.final_lot, 0.01);
593        assert_eq!(result.requested_account_risk, None);
594        assert_eq!(result.native_loss_per_lot, None);
595        assert_eq!(result.account_loss_per_lot, None);
596        assert_eq!(result.cap_status, LotCapStatus::NotCapped);
597    }
598
599    #[test]
600    fn invalid_risk_multipliers_are_rejected() {
601        for value in [0.0, -1.0, f64::NAN, f64::INFINITY] {
602            let error = compute_size(
603                &SizingPolicy::FixedLot { lots: 0.01 },
604                value,
605                10_000.0,
606                Side::Buy,
607                1.10000,
608                None,
609                &forex_spec(),
610                None,
611            )
612            .unwrap_err();
613
614            assert!(matches!(error, SizingError::InvalidRiskMultiplier { .. }));
615        }
616    }
617
618    #[test]
619    fn fixed_lot_may_be_stopless_but_monetary_policies_may_not() {
620        let fixed = compute_size(
621            &SizingPolicy::FixedLot { lots: 0.01 },
622            1.0,
623            10_000.0,
624            Side::Buy,
625            1.10000,
626            None,
627            &forex_spec(),
628            None,
629        )
630        .unwrap();
631        assert_eq!(fixed.final_lot_steps, 1);
632
633        for policy in [
634            SizingPolicy::FixedRiskAmount { amount: 100.0 },
635            SizingPolicy::BalanceRiskPercent { percent: 1.0 },
636        ] {
637            let error = compute_size(
638                &policy,
639                1.0,
640                10_000.0,
641                Side::Buy,
642                1.10000,
643                None,
644                &forex_spec(),
645                Some(500.0),
646            )
647            .unwrap_err();
648            assert_eq!(error, SizingError::MissingProtectiveStop);
649        }
650    }
651
652    #[test]
653    fn monetary_policy_requires_positive_account_loss_per_lot() {
654        let missing = compute_size(
655            &SizingPolicy::FixedRiskAmount { amount: 100.0 },
656            1.0,
657            10_000.0,
658            Side::Buy,
659            1.10000,
660            Some(1.09500),
661            &forex_spec(),
662            None,
663        )
664        .unwrap_err();
665        assert_eq!(missing, SizingError::MissingAccountLossPerLot);
666
667        for value in [0.0, -1.0, f64::NAN, f64::INFINITY] {
668            let error = compute_size(
669                &SizingPolicy::FixedRiskAmount { amount: 100.0 },
670                1.0,
671                10_000.0,
672                Side::Buy,
673                1.10000,
674                Some(1.09500),
675                &forex_spec(),
676                Some(value),
677            )
678            .unwrap_err();
679            assert!(matches!(
680                error,
681                SizingError::InvalidAccountLossPerLot { .. }
682            ));
683        }
684    }
685
686    #[test]
687    fn fixed_risk_returns_requested_and_per_lot_audit_values() {
688        let result = compute_size(
689            &SizingPolicy::FixedRiskAmount { amount: 100.0 },
690            2.0,
691            10_000.0,
692            Side::Buy,
693            1.10000,
694            Some(1.09500),
695            &forex_spec(),
696            Some(500.0),
697        )
698        .unwrap();
699
700        assert_eq!(result.requested_account_risk, Some(200.0));
701        assert_eq!(result.native_loss_per_lot, Some(500.0));
702        assert_eq!(result.account_loss_per_lot, Some(500.0));
703        assert_close(result.scaled_raw_lot, 0.4);
704        assert_eq!(result.final_lot_steps, 40);
705        assert_close(result.final_lot, 0.4);
706    }
707
708    #[test]
709    fn balance_percent_uses_realized_balance_before() {
710        let result = compute_size(
711            &SizingPolicy::BalanceRiskPercent { percent: 1.0 },
712            0.5,
713            20_000.0,
714            Side::Buy,
715            1.10000,
716            Some(1.09500),
717            &forex_spec(),
718            Some(500.0),
719        )
720        .unwrap();
721
722        assert_eq!(result.requested_account_risk, Some(100.0));
723        assert_close(result.scaled_raw_lot, 0.2);
724        assert_eq!(result.final_lot_steps, 20);
725        assert_close(result.final_lot, 0.2);
726    }
727
728    #[test]
729    fn all_policies_reject_lots_below_the_minimum() {
730        let cases = [
731            (SizingPolicy::FixedLot { lots: 0.009 }, None),
732            (SizingPolicy::FixedRiskAmount { amount: 4.5 }, Some(500.0)),
733            (
734                SizingPolicy::BalanceRiskPercent { percent: 0.045 },
735                Some(500.0),
736            ),
737        ];
738
739        for (policy, account_loss_per_lot) in cases {
740            let error = compute_size(
741                &policy,
742                1.0,
743                10_000.0,
744                Side::Buy,
745                1.10000,
746                Some(1.09500),
747                &forex_spec(),
748                account_loss_per_lot,
749            )
750            .unwrap_err();
751
752            assert!(matches!(
753                error,
754                SizingError::BelowMinimumLot {
755                    floored_lot_steps: 0,
756                    minimum_lot_steps: 1,
757                    ..
758                }
759            ));
760        }
761    }
762
763    #[test]
764    fn maximum_cap_preserves_raw_lot_and_reports_audit_status() {
765        let mut spec = forex_spec();
766        spec.lot_max_steps = 5;
767
768        let result = compute_size(
769            &SizingPolicy::FixedRiskAmount { amount: 100.0 },
770            1.0,
771            10_000.0,
772            Side::Buy,
773            1.10000,
774            Some(1.09500),
775            &spec,
776            Some(100.0),
777        )
778        .unwrap();
779
780        assert_close(result.scaled_raw_lot, 1.0);
781        assert_eq!(result.requested_account_risk, Some(100.0));
782        assert_eq!(result.final_lot_steps, 5);
783        assert_close(result.final_lot, 0.05);
784        assert_eq!(result.cap_status, LotCapStatus::CappedAtMaximum);
785    }
786
787    #[test]
788    fn geometry_is_checked_before_sub_tick_distance() {
789        let invalid_geometry = compute_size(
790            &SizingPolicy::FixedRiskAmount { amount: 100.0 },
791            1.0,
792            10_000.0,
793            Side::Buy,
794            1.000003,
795            Some(1.000004),
796            &forex_spec(),
797            Some(100.0),
798        )
799        .unwrap_err();
800        assert!(matches!(
801            invalid_geometry,
802            SizingError::InvalidStopGeometry {
803                side: Side::Buy,
804                ..
805            }
806        ));
807
808        let sub_tick = compute_size(
809            &SizingPolicy::FixedRiskAmount { amount: 100.0 },
810            1.0,
811            10_000.0,
812            Side::Buy,
813            1.000004,
814            Some(1.000003),
815            &forex_spec(),
816            Some(100.0),
817        )
818        .unwrap_err();
819        assert_eq!(
820            sub_tick,
821            SizingError::StopDistanceBelowTick {
822                entry_price: 1.000004,
823                stop_price: 1.000003,
824                digits: 5,
825            }
826        );
827    }
828
829    #[test]
830    fn fixed_lot_validates_a_supplied_stop_but_does_not_require_one() {
831        let error = compute_size(
832            &SizingPolicy::FixedLot { lots: 0.01 },
833            1.0,
834            10_000.0,
835            Side::Sell,
836            1.10000,
837            Some(1.09500),
838            &forex_spec(),
839            None,
840        )
841        .unwrap_err();
842
843        assert!(matches!(
844            error,
845            SizingError::InvalidStopGeometry {
846                side: Side::Sell,
847                ..
848            }
849        ));
850    }
851}