1use std::collections::BTreeSet;
8
9use qs_instruments::{
10 AssetId, Decimal, DecimalGrid, EconomicsModelId, EffectiveInterval, InstrumentAlias,
11 InstrumentAssets, InstrumentEconomics, InstrumentId, InstrumentSpec, ListingStatus,
12 PositiveDecimal, PriceRules, QuantityRules, QuantityUnit, SpecRevision,
13};
14use qs_symbols::{SymbolCurrencyMetadata, SymbolSpec};
15
16pub const LEGACY_ECONOMIC_GUARD_ID: &str = "legacy-economic-guard-v1";
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum LegacyEconomicModel {
22 FxLinearV1,
24 CfdLinearV1,
26}
27
28impl LegacyEconomicModel {
29 pub const fn as_str(self) -> &'static str {
31 match self {
32 Self::FxLinearV1 => "legacy_fx_linear_v1",
33 Self::CfdLinearV1 => "legacy_cfd_linear_v1",
34 }
35 }
36}
37
38#[derive(Debug, Clone, Copy, PartialEq)]
40pub struct SupportedLegacyEconomics {
41 pub model: LegacyEconomicModel,
43 pub contract_multiplier: f64,
45}
46
47#[derive(Debug, Clone, PartialEq, thiserror::Error)]
49pub enum EconomicSupportError {
50 #[error(
52 "unsupported_economic_model: instrument {instrument} has category '{category}' and cannot use the legacy contract-multiplier P&L model"
53 )]
54 UnsupportedCategory {
55 instrument: String,
57 category: String,
59 },
60
61 #[error(
63 "invalid_economic_multiplier: instrument {instrument} has invalid lot_base_units {lot_base_units}"
64 )]
65 InvalidContractMultiplier {
66 instrument: String,
68 lot_base_units: i64,
70 },
71
72 #[error("invalid_compatibility_instrument: {0}")]
74 InvalidCompatibilityInstrument(String),
75}
76
77pub fn resolve_legacy_economics(
83 spec: &SymbolSpec,
84) -> Result<SupportedLegacyEconomics, EconomicSupportError> {
85 let model = match spec.category.as_str() {
86 "forex" => LegacyEconomicModel::FxLinearV1,
87 "metal" | "commodity" | "index" => LegacyEconomicModel::CfdLinearV1,
88 _ => {
89 return Err(EconomicSupportError::UnsupportedCategory {
90 instrument: spec.canonical.clone(),
91 category: spec.category.clone(),
92 });
93 }
94 };
95
96 if spec.lot_base_units <= 0 {
97 return Err(EconomicSupportError::InvalidContractMultiplier {
98 instrument: spec.canonical.clone(),
99 lot_base_units: spec.lot_base_units,
100 });
101 }
102 let contract_multiplier = spec.lot_base_units as f64;
103 if !contract_multiplier.is_finite() || contract_multiplier <= 0.0 {
104 return Err(EconomicSupportError::InvalidContractMultiplier {
105 instrument: spec.canonical.clone(),
106 lot_base_units: spec.lot_base_units,
107 });
108 }
109
110 Ok(SupportedLegacyEconomics {
111 model,
112 contract_multiplier,
113 })
114}
115
116pub fn guarded_instrument_spec(
121 symbol: &SymbolSpec,
122 currencies: &SymbolCurrencyMetadata,
123 economics: SupportedLegacyEconomics,
124 instrument: InstrumentId,
125 effective: EffectiveInterval,
126) -> Result<InstrumentSpec, EconomicSupportError> {
127 let error = |message: String| EconomicSupportError::InvalidCompatibilityInstrument(message);
128 if symbol.canonical.is_empty() || symbol.digits > 18 || symbol.pip_position > symbol.digits {
129 return Err(error(format!(
130 "invalid symbol precision for {}",
131 symbol.canonical
132 )));
133 }
134 if symbol.lot_base_units <= 0
135 || symbol.lot_step_units <= 0
136 || symbol.lot_min_steps <= 0
137 || symbol.lot_max_steps < 0
138 || (symbol.lot_max_steps > 0 && symbol.lot_max_steps < symbol.lot_min_steps)
139 {
140 return Err(error(format!(
141 "invalid lot metadata for {}",
142 symbol.canonical
143 )));
144 }
145 if !economics.contract_multiplier.is_finite() || economics.contract_multiplier <= 0.0 {
146 return Err(error(format!(
147 "invalid guarded contract multiplier for {}",
148 symbol.canonical
149 )));
150 }
151
152 let settlement_code = if currencies.pnl_currency.is_empty() {
153 currencies.quote_currency.as_deref()
154 } else {
155 Some(currencies.pnl_currency.as_str())
156 }
157 .ok_or_else(|| error(format!("missing settlement asset for {}", symbol.canonical)))?;
158 let settlement = AssetId::new(settlement_code).map_err(|source| error(source.to_string()))?;
159 let base = currencies
160 .base_currency
161 .as_deref()
162 .map(AssetId::new)
163 .transpose()
164 .map_err(|source| error(source.to_string()))?;
165 let quote = currencies
166 .quote_currency
167 .as_deref()
168 .map(AssetId::new)
169 .transpose()
170 .map_err(|source| error(source.to_string()))?;
171 let price_step = decimal_power_of_ten(symbol.digits)?;
172 let quantity_step = decimal_ratio(symbol.lot_step_units, symbol.lot_base_units)?;
173 let quantity_minimum = decimal_ratio(
174 symbol
175 .lot_step_units
176 .checked_mul(symbol.lot_min_steps)
177 .ok_or_else(|| error("minimum quantity overflow".into()))?,
178 symbol.lot_base_units,
179 )?;
180 let quantity_maximum = if symbol.lot_max_steps == 0 {
181 None
182 } else {
183 Some(
184 PositiveDecimal::new(decimal_ratio(
185 symbol
186 .lot_step_units
187 .checked_mul(symbol.lot_max_steps)
188 .ok_or_else(|| error("maximum quantity overflow".into()))?,
189 symbol.lot_base_units,
190 )?)
191 .map_err(|source| error(source.to_string()))?,
192 )
193 };
194 let quantity_storage_scale = quantity_maximum
195 .map(|value| value.get().scale())
196 .unwrap_or(0)
197 .max(quantity_step.scale())
198 .max(quantity_minimum.scale());
199 let model = match economics.model {
200 LegacyEconomicModel::FxLinearV1 => EconomicsModelId::FX_QUOTE_LINEAR_V1,
201 LegacyEconomicModel::CfdLinearV1 => EconomicsModelId::CFD_QUOTE_LINEAR_V1,
202 };
203 let contract_multiplier = economics
204 .contract_multiplier
205 .to_string()
206 .parse::<PositiveDecimal>()
207 .map_err(|source| error(source.to_string()))?;
208 let aliases = BTreeSet::from([
209 InstrumentAlias::new(&symbol.canonical).map_err(|source| error(source.to_string()))?
210 ]);
211
212 let spec = InstrumentSpec {
213 revision: SpecRevision::new("1.0.0").map_err(|source| error(source.to_string()))?,
214 instrument,
215 effective,
216 status: ListingStatus::Trading,
217 assets: InstrumentAssets {
218 base,
219 quote,
220 settlement: settlement.clone(),
221 fee_assets: BTreeSet::new(),
222 },
223 price: PriceRules {
224 grid: DecimalGrid::new(
225 Decimal::ZERO,
226 PositiveDecimal::new(price_step).map_err(|source| error(source.to_string()))?,
227 ),
228 display_scale: symbol.digits as u8,
229 },
230 quantity: QuantityRules {
231 grid: DecimalGrid::new(
232 Decimal::ZERO,
233 PositiveDecimal::new(quantity_step).map_err(|source| error(source.to_string()))?,
234 ),
235 minimum: PositiveDecimal::new(quantity_minimum)
236 .map_err(|source| error(source.to_string()))?,
237 maximum: quantity_maximum,
238 storage_scale: quantity_storage_scale,
239 },
240 notional: None,
241 economics: InstrumentEconomics {
242 pnl_model: EconomicsModelId::new(model).map_err(|source| error(source.to_string()))?,
243 quantity_unit: QuantityUnit::StandardLot,
244 contract_multiplier,
245 settlement_asset: settlement,
246 fee_model: None,
247 funding_model: None,
248 margin_model: None,
249 },
250 aliases,
251 };
252 spec.validate()
253 .map_err(|source| error(source.to_string()))?;
254 Ok(spec)
255}
256
257fn decimal_power_of_ten(scale: u16) -> Result<Decimal, EconomicSupportError> {
258 let scale = u8::try_from(scale).map_err(|_| {
259 EconomicSupportError::InvalidCompatibilityInstrument("price scale is too large".into())
260 })?;
261 Decimal::new(1, scale)
262 .map_err(|source| EconomicSupportError::InvalidCompatibilityInstrument(source.to_string()))
263}
264
265fn decimal_ratio(numerator: i64, denominator: i64) -> Result<Decimal, EconomicSupportError> {
266 if numerator <= 0 || denominator <= 0 {
267 return Err(EconomicSupportError::InvalidCompatibilityInstrument(
268 "quantity ratio must be positive".into(),
269 ));
270 }
271 let numerator = i128::from(numerator);
272 let denominator = i128::from(denominator);
273 let mut scaled = numerator;
274 for scale in 0..=qs_instruments::MAX_DECIMAL_SCALE {
275 if scaled % denominator == 0 {
276 return Decimal::new(scaled / denominator, scale).map_err(|source| {
277 EconomicSupportError::InvalidCompatibilityInstrument(source.to_string())
278 });
279 }
280 scaled = scaled.checked_mul(10).ok_or_else(|| {
281 EconomicSupportError::InvalidCompatibilityInstrument(
282 "quantity ratio exceeds exact decimal range".into(),
283 )
284 })?;
285 }
286 Err(EconomicSupportError::InvalidCompatibilityInstrument(
287 "quantity ratio cannot be represented exactly".into(),
288 ))
289}
290
291#[cfg(test)]
292mod tests {
293 use std::path::Path;
294
295 use qs_symbols::{SymbolRegistry, SymbolSpec};
296
297 use super::*;
298
299 fn spec(symbol: &str, category: &str, lot_base_units: i64) -> SymbolSpec {
300 SymbolSpec {
301 canonical: symbol.into(),
302 pip_position: 2,
303 digits: 5,
304 category: category.into(),
305 lot_base_units,
306 lot_step_units: 1,
307 lot_min_steps: 1,
308 lot_max_steps: 0,
309 }
310 }
311
312 #[test]
313 fn current_fx_and_cfd_categories_resolve_to_explicit_legacy_models() {
314 let cases = [
315 (
316 spec("eurusd", "forex", 100_000),
317 LegacyEconomicModel::FxLinearV1,
318 100_000.0,
319 ),
320 (
321 spec("xauusd", "metal", 100),
322 LegacyEconomicModel::CfdLinearV1,
323 100.0,
324 ),
325 (
326 spec("xtiusd", "commodity", 100),
327 LegacyEconomicModel::CfdLinearV1,
328 100.0,
329 ),
330 (
331 spec("us100", "index", 1),
332 LegacyEconomicModel::CfdLinearV1,
333 1.0,
334 ),
335 ];
336
337 for (spec, expected_model, expected_multiplier) in cases {
338 let resolved = resolve_legacy_economics(&spec).unwrap();
339 assert_eq!(resolved.model, expected_model);
340 assert_eq!(resolved.contract_multiplier, expected_multiplier);
341 }
342 }
343
344 #[test]
345 fn every_shipped_crypto_symbol_fails_closed() {
346 let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("../symbols/symbols.toml");
347 let registry = SymbolRegistry::load(path).unwrap();
348 let crypto = registry.symbols_in_category("crypto");
349 assert_eq!(
350 crypto.len(),
351 4,
352 "update the crypto economic inventory when the catalog changes"
353 );
354
355 let mut rejected = crypto
356 .into_iter()
357 .map(|spec| {
358 let symbol = spec.canonical.clone();
359 let error = resolve_legacy_economics(spec).unwrap_err();
360 assert!(matches!(
361 error,
362 EconomicSupportError::UnsupportedCategory { .. }
363 ));
364 symbol
365 })
366 .collect::<Vec<_>>();
367 rejected.sort();
368 assert_eq!(rejected, ["btcusd", "dotusd", "ethusd", "solusd"]);
369 }
370
371 #[test]
372 fn unknown_category_fails_closed() {
373 let error = resolve_legacy_economics(&spec("mystery", "synthetic", 1)).unwrap_err();
374 assert_eq!(
375 error.to_string(),
376 "unsupported_economic_model: instrument mystery has category 'synthetic' and cannot use the legacy contract-multiplier P&L model"
377 );
378 }
379
380 #[test]
381 fn invalid_supported_multiplier_is_rejected() {
382 let error = resolve_legacy_economics(&spec("eurusd", "forex", 0)).unwrap_err();
383 assert!(matches!(
384 error,
385 EconomicSupportError::InvalidContractMultiplier {
386 lot_base_units: 0,
387 ..
388 }
389 ));
390 }
391}