Skip to main content

qs_backtest/
economic_support.rs

1//! Fail-closed capability checks for the legacy backtest economic model.
2//!
3//! `SymbolSpec` currently carries both lot-grid metadata and the value historically used as the per-lot P&L multiplier.
4//! Registry-backed replay allows only categories whose existing contract-multiplier convention is intentionally supported until explicit instrument economics replace this compatibility model.
5//! Registered crypto symbols remain useful for normalization and quantity metadata, but they are not economically executable through this model.
6
7use qs_symbols::SymbolSpec;
8
9/// Stable identity recorded in replay metadata for the transitional economic guard.
10pub const LEGACY_ECONOMIC_GUARD_ID: &str = "legacy-economic-guard-v1";
11
12/// Economic models explicitly supported by the current contract-multiplier replay path.
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum LegacyEconomicModel {
15    /// Quote-linear FX P&L using the configured standard-lot base-unit multiplier.
16    FxLinearV1,
17    /// Quote-linear CFD P&L using the configured per-lot contract multiplier.
18    CfdLinearV1,
19}
20
21impl LegacyEconomicModel {
22    /// Stable identifier used in execution metadata.
23    pub const fn as_str(self) -> &'static str {
24        match self {
25            Self::FxLinearV1 => "legacy_fx_linear_v1",
26            Self::CfdLinearV1 => "legacy_cfd_linear_v1",
27        }
28    }
29}
30
31/// Resolved economic capability for one registry-backed symbol.
32#[derive(Debug, Clone, Copy, PartialEq)]
33pub struct SupportedLegacyEconomics {
34    /// Explicit legacy P&L model selected for the symbol.
35    pub model: LegacyEconomicModel,
36    /// Monetary point-value multiplier used for one lot.
37    pub contract_multiplier: f64,
38}
39
40/// A registry-backed symbol cannot use the current replay economic model.
41#[derive(Debug, Clone, PartialEq, thiserror::Error)]
42pub enum EconomicSupportError {
43    /// The category has no explicitly supported legacy P&L convention.
44    #[error(
45        "unsupported_economic_model: instrument {instrument} has category '{category}' and cannot use the legacy contract-multiplier P&L model"
46    )]
47    UnsupportedCategory {
48        /// Canonical instrument name.
49        instrument: String,
50        /// Registry category that failed closed.
51        category: String,
52    },
53
54    /// A supported category has unusable legacy multiplier metadata.
55    #[error(
56        "invalid_economic_multiplier: instrument {instrument} has invalid lot_base_units {lot_base_units}"
57    )]
58    InvalidContractMultiplier {
59        /// Canonical instrument name.
60        instrument: String,
61        /// Configured value that would otherwise become the contract multiplier.
62        lot_base_units: i64,
63    },
64}
65
66/// Resolve the explicitly supported legacy economics for one symbol specification.
67///
68/// This is a transitional compatibility function. It does not infer crypto, spot, derivative,
69/// fee, funding, margin, or liquidation behavior. Any category outside the current FX/CFD
70/// allowlist fails closed.
71pub fn resolve_legacy_economics(
72    spec: &SymbolSpec,
73) -> Result<SupportedLegacyEconomics, EconomicSupportError> {
74    let model = match spec.category.as_str() {
75        "forex" => LegacyEconomicModel::FxLinearV1,
76        "metal" | "commodity" | "index" => LegacyEconomicModel::CfdLinearV1,
77        _ => {
78            return Err(EconomicSupportError::UnsupportedCategory {
79                instrument: spec.canonical.clone(),
80                category: spec.category.clone(),
81            });
82        }
83    };
84
85    if spec.lot_base_units <= 0 {
86        return Err(EconomicSupportError::InvalidContractMultiplier {
87            instrument: spec.canonical.clone(),
88            lot_base_units: spec.lot_base_units,
89        });
90    }
91    let contract_multiplier = spec.lot_base_units as f64;
92    if !contract_multiplier.is_finite() || contract_multiplier <= 0.0 {
93        return Err(EconomicSupportError::InvalidContractMultiplier {
94            instrument: spec.canonical.clone(),
95            lot_base_units: spec.lot_base_units,
96        });
97    }
98
99    Ok(SupportedLegacyEconomics {
100        model,
101        contract_multiplier,
102    })
103}
104
105#[cfg(test)]
106mod tests {
107    use std::path::Path;
108
109    use qs_symbols::{SymbolRegistry, SymbolSpec};
110
111    use super::*;
112
113    fn spec(symbol: &str, category: &str, lot_base_units: i64) -> SymbolSpec {
114        SymbolSpec {
115            canonical: symbol.into(),
116            pip_position: 2,
117            digits: 5,
118            category: category.into(),
119            lot_base_units,
120            lot_step_units: 1,
121            lot_min_steps: 1,
122            lot_max_steps: 0,
123        }
124    }
125
126    #[test]
127    fn current_fx_and_cfd_categories_resolve_to_explicit_legacy_models() {
128        let cases = [
129            (
130                spec("eurusd", "forex", 100_000),
131                LegacyEconomicModel::FxLinearV1,
132                100_000.0,
133            ),
134            (
135                spec("xauusd", "metal", 100),
136                LegacyEconomicModel::CfdLinearV1,
137                100.0,
138            ),
139            (
140                spec("xtiusd", "commodity", 100),
141                LegacyEconomicModel::CfdLinearV1,
142                100.0,
143            ),
144            (
145                spec("us100", "index", 1),
146                LegacyEconomicModel::CfdLinearV1,
147                1.0,
148            ),
149        ];
150
151        for (spec, expected_model, expected_multiplier) in cases {
152            let resolved = resolve_legacy_economics(&spec).unwrap();
153            assert_eq!(resolved.model, expected_model);
154            assert_eq!(resolved.contract_multiplier, expected_multiplier);
155        }
156    }
157
158    #[test]
159    fn every_shipped_crypto_symbol_fails_closed() {
160        let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("../symbols/symbols.toml");
161        let registry = SymbolRegistry::load(path).unwrap();
162        let crypto = registry.symbols_in_category("crypto");
163        assert_eq!(
164            crypto.len(),
165            4,
166            "update the crypto economic inventory when the catalog changes"
167        );
168
169        let mut rejected = crypto
170            .into_iter()
171            .map(|spec| {
172                let symbol = spec.canonical.clone();
173                let error = resolve_legacy_economics(spec).unwrap_err();
174                assert!(matches!(
175                    error,
176                    EconomicSupportError::UnsupportedCategory { .. }
177                ));
178                symbol
179            })
180            .collect::<Vec<_>>();
181        rejected.sort();
182        assert_eq!(rejected, ["btcusd", "dotusd", "ethusd", "solusd"]);
183    }
184
185    #[test]
186    fn unknown_category_fails_closed() {
187        let error = resolve_legacy_economics(&spec("mystery", "synthetic", 1)).unwrap_err();
188        assert_eq!(
189            error.to_string(),
190            "unsupported_economic_model: instrument mystery has category 'synthetic' and cannot use the legacy contract-multiplier P&L model"
191        );
192    }
193
194    #[test]
195    fn invalid_supported_multiplier_is_rejected() {
196        let error = resolve_legacy_economics(&spec("eurusd", "forex", 0)).unwrap_err();
197        assert!(matches!(
198            error,
199            EconomicSupportError::InvalidContractMultiplier {
200                lot_base_units: 0,
201                ..
202            }
203        ));
204    }
205}