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