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 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
16/// Stable identity recorded in replay metadata for the transitional economic guard.
17pub const LEGACY_ECONOMIC_GUARD_ID: &str = "legacy-economic-guard-v1";
18
19/// Economic models explicitly supported by the current contract-multiplier replay path.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum LegacyEconomicModel {
22    /// Quote-linear FX P&L using the configured standard-lot base-unit multiplier.
23    FxLinearV1,
24    /// Quote-linear CFD P&L using the configured per-lot contract multiplier.
25    CfdLinearV1,
26}
27
28impl LegacyEconomicModel {
29    /// Stable identifier used in execution metadata.
30    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/// Resolved economic capability for one registry-backed symbol.
39#[derive(Debug, Clone, Copy, PartialEq)]
40pub struct SupportedLegacyEconomics {
41    /// Explicit legacy P&L model selected for the symbol.
42    pub model: LegacyEconomicModel,
43    /// Monetary point-value multiplier used for one lot.
44    pub contract_multiplier: f64,
45}
46
47/// A registry-backed symbol cannot use the current replay economic model.
48#[derive(Debug, Clone, PartialEq, thiserror::Error)]
49pub enum EconomicSupportError {
50    /// The category has no explicitly supported legacy P&L convention.
51    #[error(
52        "unsupported_economic_model: instrument {instrument} has category '{category}' and cannot use the legacy contract-multiplier P&L model"
53    )]
54    UnsupportedCategory {
55        /// Canonical instrument name.
56        instrument: String,
57        /// Registry category that failed closed.
58        category: String,
59    },
60
61    /// A supported category has unusable legacy multiplier metadata.
62    #[error(
63        "invalid_economic_multiplier: instrument {instrument} has invalid lot_base_units {lot_base_units}"
64    )]
65    InvalidContractMultiplier {
66        /// Canonical instrument name.
67        instrument: String,
68        /// Configured value that would otherwise become the contract multiplier.
69        lot_base_units: i64,
70    },
71
72    /// Compatibility symbol metadata cannot be represented by the neutral domain.
73    #[error("invalid_compatibility_instrument: {0}")]
74    InvalidCompatibilityInstrument(String),
75}
76
77/// Resolve the explicitly supported legacy economics for one symbol specification.
78///
79/// This is a transitional compatibility function. It does not infer crypto, spot, derivative,
80/// fee, funding, margin, or liquidation behavior. Any category outside the current FX/CFD
81/// allowlist fails closed.
82pub 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
116/// Translate an already guarded compatibility symbol into an explicit neutral instrument spec.
117///
118/// Economic support and the contract multiplier come only from `economics`. Raw `lot_base_units`
119/// is used only to preserve the existing lot grid after support has already been authorized.
120pub 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}