1use qs_symbols::SymbolSpec;
10
11pub const LEGACY_ECONOMIC_GUARD_ID: &str = "legacy-economic-guard-v1";
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum LegacyEconomicModel {
17 FxLinearV1,
19 CfdLinearV1,
21}
22
23impl LegacyEconomicModel {
24 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#[derive(Debug, Clone, Copy, PartialEq)]
35pub struct SupportedLegacyEconomics {
36 pub model: LegacyEconomicModel,
38 pub contract_multiplier: f64,
40}
41
42#[derive(Debug, Clone, PartialEq, thiserror::Error)]
44pub enum EconomicSupportError {
45 #[error(
47 "unsupported_economic_model: instrument {instrument} has category '{category}' and cannot use the legacy contract-multiplier P&L model"
48 )]
49 UnsupportedCategory {
50 instrument: String,
52 category: String,
54 },
55
56 #[error(
58 "invalid_economic_multiplier: instrument {instrument} has invalid lot_base_units {lot_base_units}"
59 )]
60 InvalidContractMultiplier {
61 instrument: String,
63 lot_base_units: i64,
65 },
66}
67
68pub 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}