1use qs_symbols::SymbolSpec;
8
9pub const LEGACY_ECONOMIC_GUARD_ID: &str = "legacy-economic-guard-v1";
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum LegacyEconomicModel {
15 FxLinearV1,
17 CfdLinearV1,
19}
20
21impl LegacyEconomicModel {
22 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#[derive(Debug, Clone, Copy, PartialEq)]
33pub struct SupportedLegacyEconomics {
34 pub model: LegacyEconomicModel,
36 pub contract_multiplier: f64,
38}
39
40#[derive(Debug, Clone, PartialEq, thiserror::Error)]
42pub enum EconomicSupportError {
43 #[error(
45 "unsupported_economic_model: instrument {instrument} has category '{category}' and cannot use the legacy contract-multiplier P&L model"
46 )]
47 UnsupportedCategory {
48 instrument: String,
50 category: String,
52 },
53
54 #[error(
56 "invalid_economic_multiplier: instrument {instrument} has invalid lot_base_units {lot_base_units}"
57 )]
58 InvalidContractMultiplier {
59 instrument: String,
61 lot_base_units: i64,
63 },
64}
65
66pub 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}