use std::collections::BTreeSet;
use qs_instruments::{
AssetId, Decimal, DecimalGrid, EconomicsModelId, EffectiveInterval, InstrumentAlias,
InstrumentAssets, InstrumentEconomics, InstrumentId, InstrumentSpec, ListingStatus,
PositiveDecimal, PriceRules, QuantityRules, QuantityUnit, SpecRevision,
};
use qs_symbols::{SymbolCurrencyMetadata, SymbolSpec};
pub const LEGACY_ECONOMIC_GUARD_ID: &str = "legacy-economic-guard-v1";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LegacyEconomicModel {
FxLinearV1,
CfdLinearV1,
}
impl LegacyEconomicModel {
pub const fn as_str(self) -> &'static str {
match self {
Self::FxLinearV1 => "legacy_fx_linear_v1",
Self::CfdLinearV1 => "legacy_cfd_linear_v1",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SupportedLegacyEconomics {
pub model: LegacyEconomicModel,
pub contract_multiplier: f64,
}
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
pub enum EconomicSupportError {
#[error(
"unsupported_economic_model: instrument {instrument} has category '{category}' and cannot use the legacy contract-multiplier P&L model"
)]
UnsupportedCategory {
instrument: String,
category: String,
},
#[error(
"invalid_economic_multiplier: instrument {instrument} has invalid lot_base_units {lot_base_units}"
)]
InvalidContractMultiplier {
instrument: String,
lot_base_units: i64,
},
#[error("invalid_compatibility_instrument: {0}")]
InvalidCompatibilityInstrument(String),
}
pub fn resolve_legacy_economics(
spec: &SymbolSpec,
) -> Result<SupportedLegacyEconomics, EconomicSupportError> {
let model = match spec.category.as_str() {
"forex" => LegacyEconomicModel::FxLinearV1,
"metal" | "commodity" | "index" => LegacyEconomicModel::CfdLinearV1,
_ => {
return Err(EconomicSupportError::UnsupportedCategory {
instrument: spec.canonical.clone(),
category: spec.category.clone(),
});
}
};
if spec.lot_base_units <= 0 {
return Err(EconomicSupportError::InvalidContractMultiplier {
instrument: spec.canonical.clone(),
lot_base_units: spec.lot_base_units,
});
}
let contract_multiplier = spec.lot_base_units as f64;
if !contract_multiplier.is_finite() || contract_multiplier <= 0.0 {
return Err(EconomicSupportError::InvalidContractMultiplier {
instrument: spec.canonical.clone(),
lot_base_units: spec.lot_base_units,
});
}
Ok(SupportedLegacyEconomics {
model,
contract_multiplier,
})
}
pub fn guarded_instrument_spec(
symbol: &SymbolSpec,
currencies: &SymbolCurrencyMetadata,
economics: SupportedLegacyEconomics,
instrument: InstrumentId,
effective: EffectiveInterval,
) -> Result<InstrumentSpec, EconomicSupportError> {
let error = |message: String| EconomicSupportError::InvalidCompatibilityInstrument(message);
if symbol.canonical.is_empty() || symbol.digits > 18 || symbol.pip_position > symbol.digits {
return Err(error(format!(
"invalid symbol precision for {}",
symbol.canonical
)));
}
if symbol.lot_base_units <= 0
|| symbol.lot_step_units <= 0
|| symbol.lot_min_steps <= 0
|| symbol.lot_max_steps < 0
|| (symbol.lot_max_steps > 0 && symbol.lot_max_steps < symbol.lot_min_steps)
{
return Err(error(format!(
"invalid lot metadata for {}",
symbol.canonical
)));
}
if !economics.contract_multiplier.is_finite() || economics.contract_multiplier <= 0.0 {
return Err(error(format!(
"invalid guarded contract multiplier for {}",
symbol.canonical
)));
}
let settlement_code = if currencies.pnl_currency.is_empty() {
currencies.quote_currency.as_deref()
} else {
Some(currencies.pnl_currency.as_str())
}
.ok_or_else(|| error(format!("missing settlement asset for {}", symbol.canonical)))?;
let settlement = AssetId::new(settlement_code).map_err(|source| error(source.to_string()))?;
let base = currencies
.base_currency
.as_deref()
.map(AssetId::new)
.transpose()
.map_err(|source| error(source.to_string()))?;
let quote = currencies
.quote_currency
.as_deref()
.map(AssetId::new)
.transpose()
.map_err(|source| error(source.to_string()))?;
let price_step = decimal_power_of_ten(symbol.digits)?;
let quantity_step = decimal_ratio(symbol.lot_step_units, symbol.lot_base_units)?;
let quantity_minimum = decimal_ratio(
symbol
.lot_step_units
.checked_mul(symbol.lot_min_steps)
.ok_or_else(|| error("minimum quantity overflow".into()))?,
symbol.lot_base_units,
)?;
let quantity_maximum = if symbol.lot_max_steps == 0 {
None
} else {
Some(
PositiveDecimal::new(decimal_ratio(
symbol
.lot_step_units
.checked_mul(symbol.lot_max_steps)
.ok_or_else(|| error("maximum quantity overflow".into()))?,
symbol.lot_base_units,
)?)
.map_err(|source| error(source.to_string()))?,
)
};
let quantity_storage_scale = quantity_maximum
.map(|value| value.get().scale())
.unwrap_or(0)
.max(quantity_step.scale())
.max(quantity_minimum.scale());
let model = match economics.model {
LegacyEconomicModel::FxLinearV1 => EconomicsModelId::FX_QUOTE_LINEAR_V1,
LegacyEconomicModel::CfdLinearV1 => EconomicsModelId::CFD_QUOTE_LINEAR_V1,
};
let contract_multiplier = economics
.contract_multiplier
.to_string()
.parse::<PositiveDecimal>()
.map_err(|source| error(source.to_string()))?;
let aliases = BTreeSet::from([
InstrumentAlias::new(&symbol.canonical).map_err(|source| error(source.to_string()))?
]);
let spec = InstrumentSpec {
revision: SpecRevision::new("1.0.0").map_err(|source| error(source.to_string()))?,
instrument,
effective,
status: ListingStatus::Trading,
assets: InstrumentAssets {
base,
quote,
settlement: settlement.clone(),
fee_assets: BTreeSet::new(),
},
price: PriceRules {
grid: DecimalGrid::new(
Decimal::ZERO,
PositiveDecimal::new(price_step).map_err(|source| error(source.to_string()))?,
),
display_scale: symbol.digits as u8,
},
quantity: QuantityRules {
grid: DecimalGrid::new(
Decimal::ZERO,
PositiveDecimal::new(quantity_step).map_err(|source| error(source.to_string()))?,
),
minimum: PositiveDecimal::new(quantity_minimum)
.map_err(|source| error(source.to_string()))?,
maximum: quantity_maximum,
storage_scale: quantity_storage_scale,
},
notional: None,
economics: InstrumentEconomics {
pnl_model: EconomicsModelId::new(model).map_err(|source| error(source.to_string()))?,
quantity_unit: QuantityUnit::StandardLot,
contract_multiplier,
settlement_asset: settlement,
fee_model: None,
funding_model: None,
margin_model: None,
},
aliases,
};
spec.validate()
.map_err(|source| error(source.to_string()))?;
Ok(spec)
}
fn decimal_power_of_ten(scale: u16) -> Result<Decimal, EconomicSupportError> {
let scale = u8::try_from(scale).map_err(|_| {
EconomicSupportError::InvalidCompatibilityInstrument("price scale is too large".into())
})?;
Decimal::new(1, scale)
.map_err(|source| EconomicSupportError::InvalidCompatibilityInstrument(source.to_string()))
}
fn decimal_ratio(numerator: i64, denominator: i64) -> Result<Decimal, EconomicSupportError> {
if numerator <= 0 || denominator <= 0 {
return Err(EconomicSupportError::InvalidCompatibilityInstrument(
"quantity ratio must be positive".into(),
));
}
let numerator = i128::from(numerator);
let denominator = i128::from(denominator);
let mut scaled = numerator;
for scale in 0..=qs_instruments::MAX_DECIMAL_SCALE {
if scaled % denominator == 0 {
return Decimal::new(scaled / denominator, scale).map_err(|source| {
EconomicSupportError::InvalidCompatibilityInstrument(source.to_string())
});
}
scaled = scaled.checked_mul(10).ok_or_else(|| {
EconomicSupportError::InvalidCompatibilityInstrument(
"quantity ratio exceeds exact decimal range".into(),
)
})?;
}
Err(EconomicSupportError::InvalidCompatibilityInstrument(
"quantity ratio cannot be represented exactly".into(),
))
}
#[cfg(test)]
mod tests {
use std::path::Path;
use qs_symbols::{SymbolRegistry, SymbolSpec};
use super::*;
fn spec(symbol: &str, category: &str, lot_base_units: i64) -> SymbolSpec {
SymbolSpec {
canonical: symbol.into(),
pip_position: 2,
digits: 5,
category: category.into(),
lot_base_units,
lot_step_units: 1,
lot_min_steps: 1,
lot_max_steps: 0,
}
}
#[test]
fn current_fx_and_cfd_categories_resolve_to_explicit_legacy_models() {
let cases = [
(
spec("eurusd", "forex", 100_000),
LegacyEconomicModel::FxLinearV1,
100_000.0,
),
(
spec("xauusd", "metal", 100),
LegacyEconomicModel::CfdLinearV1,
100.0,
),
(
spec("xtiusd", "commodity", 100),
LegacyEconomicModel::CfdLinearV1,
100.0,
),
(
spec("us100", "index", 1),
LegacyEconomicModel::CfdLinearV1,
1.0,
),
];
for (spec, expected_model, expected_multiplier) in cases {
let resolved = resolve_legacy_economics(&spec).unwrap();
assert_eq!(resolved.model, expected_model);
assert_eq!(resolved.contract_multiplier, expected_multiplier);
}
}
#[test]
fn every_shipped_crypto_symbol_fails_closed() {
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("../symbols/symbols.toml");
let registry = SymbolRegistry::load(path).unwrap();
let crypto = registry.symbols_in_category("crypto");
assert_eq!(
crypto.len(),
4,
"update the crypto economic inventory when the catalog changes"
);
let mut rejected = crypto
.into_iter()
.map(|spec| {
let symbol = spec.canonical.clone();
let error = resolve_legacy_economics(spec).unwrap_err();
assert!(matches!(
error,
EconomicSupportError::UnsupportedCategory { .. }
));
symbol
})
.collect::<Vec<_>>();
rejected.sort();
assert_eq!(rejected, ["btcusd", "dotusd", "ethusd", "solusd"]);
}
#[test]
fn unknown_category_fails_closed() {
let error = resolve_legacy_economics(&spec("mystery", "synthetic", 1)).unwrap_err();
assert_eq!(
error.to_string(),
"unsupported_economic_model: instrument mystery has category 'synthetic' and cannot use the legacy contract-multiplier P&L model"
);
}
#[test]
fn invalid_supported_multiplier_is_rejected() {
let error = resolve_legacy_economics(&spec("eurusd", "forex", 0)).unwrap_err();
assert!(matches!(
error,
EconomicSupportError::InvalidContractMultiplier {
lot_base_units: 0,
..
}
));
}
}