use std::{collections::HashMap, str::FromStr, sync::LazyLock};
use ibapi::contracts::{Contract, Currency, Exchange, OptionRight, SecurityType, Symbol};
use nautilus_core::UnixNanos;
use nautilus_model::identifiers::{InstrumentId, Symbol as NautilusSymbol, TradeId, Venue};
use crate::common::enums::{IbOptionRight, IbSecurityType};
fn ib_option_right_to_option_right(right: IbOptionRight) -> OptionRight {
match right {
IbOptionRight::Call => OptionRight::Call,
IbOptionRight::Put => OptionRight::Put,
}
}
pub fn generate_ib_trade_id(ts_event: UnixNanos, price: f64, size: f64) -> TradeId {
let ts_secs = ts_event.as_i64() / 1_000_000_000;
TradeId::new(format!("{ts_secs}-{price}-{size}"))
}
pub fn ib_contract_to_instrument_id_simplified(
contract: &Contract,
venue: Option<Venue>,
) -> anyhow::Result<InstrumentId> {
let venue = venue.unwrap_or_else(|| {
match contract.security_type {
SecurityType::Index => {
if !contract.exchange.as_str().is_empty() && contract.exchange.as_str() != "SMART" {
Venue::from(contract.exchange.as_str())
} else {
Venue::from("SMART")
}
}
SecurityType::Future => {
if !contract.exchange.as_str().is_empty() && contract.exchange.as_str() != "SMART" {
Venue::from(contract.exchange.as_str())
} else {
Venue::from("GLOBEX")
}
}
SecurityType::ForexPair => Venue::from("IDEALPRO"),
SecurityType::Crypto => derive_crypto_venue(contract),
SecurityType::Stock => Venue::from("SMART"),
SecurityType::Option | SecurityType::FuturesOption => {
if !contract.exchange.as_str().is_empty() && contract.exchange.as_str() != "SMART" {
Venue::from(contract.exchange.as_str())
} else {
Venue::from("SMART")
}
}
SecurityType::CFD => Venue::from("SMART"),
SecurityType::Commodity => Venue::from("SMART"),
SecurityType::Bond => Venue::from("SMART"),
_ => Venue::from("SMART"),
}
});
let symbol = match contract.security_type {
SecurityType::Stock => {
let symbol_str = if contract.local_symbol.is_empty() {
contract.symbol.as_str().to_string()
} else {
contract.local_symbol.as_str().replace(' ', "-")
};
NautilusSymbol::from(symbol_str.as_str())
}
SecurityType::Index => {
let base = if contract.local_symbol.is_empty() {
contract.symbol.as_str()
} else {
contract.local_symbol.as_str()
};
NautilusSymbol::from(format!("^{base}").as_str())
}
SecurityType::Option => {
let symbol_str = if contract.local_symbol.is_empty() {
format!(
"{} {} {} {}",
contract.right.map_or("", |right| right.as_str()),
contract.trading_class.as_str(),
contract.last_trade_date_or_contract_month.as_str(),
format_option_strike(contract.strike),
)
} else {
normalize_option_symbol(contract.local_symbol.as_str())
};
NautilusSymbol::from(symbol_str.as_str())
}
SecurityType::ForexPair | SecurityType::Crypto => {
let symbol_str = if contract.local_symbol.is_empty() {
format!(
"{}/{}",
contract.symbol.as_str(),
contract.currency.as_str()
)
} else {
contract.local_symbol.as_str().replace('.', "/")
};
NautilusSymbol::from(symbol_str.as_str())
}
SecurityType::Future => {
if contract.local_symbol.is_empty() {
if !contract.trading_class.is_empty()
&& !contract.last_trade_date_or_contract_month.is_empty()
{
let symbol_str = format!(
"{} {} {}",
contract.symbol.as_str(),
contract.trading_class.as_str(),
contract.last_trade_date_or_contract_month.as_str()
);
NautilusSymbol::from(symbol_str.as_str())
} else if !contract.last_trade_date_or_contract_month.is_empty() {
let expiry = contract.last_trade_date_or_contract_month.as_str();
let symbol_str = format!("{}{}", contract.symbol.as_str(), expiry);
NautilusSymbol::from(symbol_str.as_str())
} else {
NautilusSymbol::from(contract.symbol.as_str())
}
} else {
NautilusSymbol::from(contract.local_symbol.as_str())
}
}
SecurityType::FuturesOption => {
if contract.local_symbol.is_empty() {
let expiry = contract.last_trade_date_or_contract_month.as_str();
let right = contract.right.map_or("P", |right| right.as_str());
let strike_str = format!("{}", contract.strike as i64);
let symbol_str = format!(
"{}{} {}{}",
contract.symbol.as_str(),
expiry,
right,
strike_str
);
NautilusSymbol::from(symbol_str.as_str())
} else {
NautilusSymbol::from(contract.local_symbol.as_str())
}
}
SecurityType::CFD => {
if !contract.local_symbol.is_empty() && contract.local_symbol.contains('.') {
let cash_like = contract.local_symbol.as_str().replace('.', "/");
NautilusSymbol::from(cash_like.as_str())
} else {
let symbol_str = contract.symbol.as_str().replace(' ', "-");
NautilusSymbol::from(symbol_str.as_str())
}
}
SecurityType::Commodity => {
let symbol_str = contract.symbol.as_str().replace(' ', "-");
NautilusSymbol::from(symbol_str.as_str())
}
SecurityType::Bond => {
let symbol_str = if contract.local_symbol.is_empty() {
contract.symbol.as_str()
} else {
contract.local_symbol.as_str()
};
NautilusSymbol::from(symbol_str)
}
_ => {
let symbol_str = if contract.local_symbol.is_empty() {
contract.symbol.as_str()
} else {
contract.local_symbol.as_str()
};
NautilusSymbol::from(symbol_str)
}
};
Ok(InstrumentId::new(symbol, venue))
}
pub fn ib_contract_to_instrument_id_raw(
contract: &Contract,
venue: Option<Venue>,
) -> anyhow::Result<InstrumentId> {
let venue = venue.unwrap_or_else(|| match contract.security_type {
SecurityType::ForexPair => Venue::from("IDEALPRO"),
SecurityType::Crypto => derive_crypto_venue(contract),
SecurityType::Stock => Venue::from("SMART"),
SecurityType::Option => Venue::from("SMART"),
SecurityType::FuturesOption => Venue::from("SMART"),
SecurityType::Future => Venue::from("GLOBEX"),
SecurityType::Index => Venue::from("SMART"),
SecurityType::CFD => Venue::from("SMART"),
SecurityType::Commodity => Venue::from("SMART"),
SecurityType::Bond => Venue::from("SMART"),
_ => Venue::from("SMART"),
});
let local_symbol = if contract.local_symbol.is_empty() {
contract.symbol.as_str()
} else {
contract.local_symbol.as_str()
};
let sec_type_str = IbSecurityType::try_from(&contract.security_type).map_or_else(
|_| "OTHER".to_string(),
|security_type| security_type.to_string(),
);
let symbol_str = format!("{local_symbol}={sec_type_str}");
let symbol = NautilusSymbol::from(symbol_str.as_str());
Ok(InstrumentId::new(symbol, venue))
}
pub fn ib_contract_to_instrument_id_simple(contract: &Contract) -> anyhow::Result<InstrumentId> {
ib_contract_to_instrument_id_simplified(contract, None)
}
pub static VENUE_MEMBERS: LazyLock<HashMap<&'static str, Vec<&'static str>>> =
LazyLock::new(|| {
let mut map = HashMap::new();
map.insert("NDEX", vec!["ENDEX"]);
map.insert("XCME", vec!["CME"]);
map.insert("XCEC", vec!["CME"]);
map.insert("XFXS", vec!["CME"]);
map.insert("XCBT", vec!["CBOT"]);
map.insert("CBCM", vec!["CBOT"]);
map.insert("XNYM", vec!["NYMEX"]);
map.insert("NYUM", vec!["NYMEX"]);
map.insert("IFUS", vec!["NYBOT"]);
map.insert("GLBX", vec!["CBOT", "CME", "NYBOT", "NYMEX"]);
map.insert("XNAS", vec!["NASDAQ"]);
map.insert("XNYS", vec!["NYSE"]);
map.insert("ARCX", vec!["ARCA"]);
map.insert("BATS", vec!["BATS"]);
map.insert("IEXG", vec!["IEX"]);
map.insert("XCBO", vec!["CBOE"]);
map.insert("XCBF", vec!["CFE"]);
map.insert("XTSE", vec!["TSX"]);
map.insert("IFEU", vec!["ICEEU", "ICEEUSOFT", "IPE"]);
map.insert("XLON", vec!["LSE"]);
map.insert("XPAR", vec!["SBF"]);
map.insert("XETR", vec!["IBIS"]);
map.insert("XEUR", vec!["DTB", "EUREX", "SOFFEX"]);
map.insert("XAMS", vec!["AEB"]);
map.insert("XBRU", vec!["EBS"]);
map.insert("XBRD", vec!["BELFOX"]);
map.insert("XLIS", vec!["BVLP"]);
map.insert("XDUB", vec!["IRE"]);
map.insert("XOSL", vec!["OSL"]);
map.insert("XSWX", vec!["EBS", "SIX", "SWX"]);
map.insert("XSVX", vec!["VRTX"]);
map.insert("XMIL", vec!["BIT", "BVME", "IDEM"]);
map.insert("XMAD", vec!["MDRD", "BME"]);
map.insert("DXEX", vec!["BATEEN"]);
map.insert("XWBO", vec!["WBAG"]);
map.insert("XBUD", vec!["BUX"]);
map.insert("XPRA", vec!["PRA"]);
map.insert("XWAR", vec!["WSE"]);
map.insert("XIST", vec!["ISE"]);
map.insert("XSTO", vec!["SFB"]);
map.insert("XCSE", vec!["KFB"]);
map.insert("XHEL", vec!["HMB"]);
map.insert("XICE", vec!["ISB"]);
map.insert("XASX", vec!["ASX"]);
map.insert("XHKG", vec!["SEHK"]);
map.insert("XHKF", vec!["HKFE"]);
map.insert("XSES", vec!["SGX"]);
map.insert("XOSE", vec!["OSE.JPN"]);
map.insert("XTKS", vec!["TSEJ", "TSE.JPN"]);
map.insert("XKRX", vec!["KSE", "KRX"]);
map.insert("XTAI", vec!["TASE", "TWSE"]);
map.insert("XSHG", vec!["SEHKNTL", "SSE"]);
map.insert("XSHE", vec!["SEHKSZSE"]);
map.insert("XNSE", vec!["NSE"]);
map.insert("XBOM", vec!["BSE"]);
map.insert("XSFE", vec!["SNFE"]);
map.insert("XMEX", vec!["MEXDER"]);
map.insert("XJSE", vec!["JSE"]);
map.insert("XBOG", vec!["BVC"]);
map.insert("XTAE", vec!["TASE"]);
map.insert("BVMF", vec!["BVMF"]);
map
});
#[must_use]
pub fn is_crypto_contract(contract: &Contract) -> bool {
matches!(contract.security_type, SecurityType::Crypto)
}
#[must_use]
pub fn derive_crypto_venue(contract: &Contract) -> Venue {
if !contract.exchange.as_str().is_empty() && contract.exchange.as_str() != "SMART" {
Venue::from(contract.exchange.as_str())
} else {
Venue::from("PAXOS")
}
}
#[must_use]
pub fn possible_exchanges_for_venue(venue: &str) -> Vec<String> {
if venue == "OPRA" {
return vec!["SMART".to_string()];
}
if let Some(exchanges) = VENUE_MEMBERS.get(venue) {
return exchanges
.iter()
.map(|exchange| (*exchange).to_string())
.collect();
}
vec![venue.to_string()]
}
const VENUES_CASH: &[&str] = &["IDEALPRO"];
const VENUES_CRYPTO: &[&str] = &["PAXOS", "ZEROHASH"];
const VENUES_OPT: &[&str] = &["SMART", "EUREX"];
const VENUES_FUT: &[&str] = &[
"BELFOX",
"GLOBEX",
"CBOT",
"CFE",
"CME",
"COMEX",
"CBOE",
"DTB",
"EUREX",
"HKFE",
"ICE",
"ICEEU",
"ICEEUSOFT",
"IDEM",
"IPE",
"KCBT",
"MEXDER",
"MGE",
"NYBOT",
"NYMEX",
"OSE.JPN",
"SNFE",
"SOFFEX",
"VRTX",
"CMECRYPTO",
"NYMEXMETALS",
"NYMEXNG",
"NYMEXENERGY",
"CMEPRECIOUS",
"CMECURRENCY",
"CMEINDEX",
"CMEWEATHER",
"CMEINTEREST",
"CMEFLOOR",
"CBOTFLOOR",
"NYMEXFLOOR",
"NYBOTFLOOR",
"CFEFLOOR",
"CMEOPTIONS",
"CBOTOPTIONS",
"NYMEXOPTIONS",
"NYBOTOPTIONS",
"ECBOT",
];
const VENUES_CFD: &[&str] = &["IBCFD", "SMART"];
const VENUES_CMDTY: &[&str] = &["IBCMDTY"];
fn venue_matches(venue_str: &str, venues: &[&str]) -> bool {
venues.contains(&venue_str)
|| VENUE_MEMBERS
.get(venue_str)
.is_some_and(|exchanges| exchanges.iter().any(|exchange| venues.contains(exchange)))
}
fn is_option_venue(venue: &str) -> bool {
venue == "OPRA" || venue_matches(venue, VENUES_OPT)
}
fn is_canonical_occ_option_symbol(symbol: &str) -> bool {
let bytes = symbol.as_bytes();
if bytes.len() != 21 || !symbol.is_ascii() {
return false;
}
let root = &bytes[..6];
let root_len = root.iter().position(|byte| *byte == b' ').unwrap_or(6);
root_len > 0
&& root[..root_len].iter().all(u8::is_ascii_graphic)
&& root[root_len..].iter().all(|byte| *byte == b' ')
&& bytes[6..12].iter().all(u8::is_ascii_digit)
&& matches!(bytes[12], b'C' | b'P')
&& bytes[13..].iter().all(u8::is_ascii_digit)
}
#[allow(dead_code)]
const FUTURES_MONTH_CODES: &[(char, &str)] = &[
('F', "01"),
('G', "02"),
('H', "03"),
('J', "04"),
('K', "05"),
('M', "06"),
('N', "07"),
('Q', "08"),
('U', "09"),
('V', "10"),
('X', "11"),
('Z', "12"),
];
pub fn determine_venue_from_contract(
contract: &Contract,
symbol_to_mic_venue: &std::collections::HashMap<String, String>,
convert_exchange_to_mic_venue: bool,
valid_exchanges: Option<&str>,
) -> String {
if matches!(contract.security_type, SecurityType::CFD) {
return "IBCFD".to_string();
}
if matches!(contract.security_type, SecurityType::Commodity) {
return "IBCMDTY".to_string();
}
if !symbol_to_mic_venue.is_empty() {
let symbol = contract.symbol.as_str();
for (symbol_prefix, symbol_venue) in symbol_to_mic_venue {
if symbol.starts_with(symbol_prefix) {
return symbol_venue.clone();
}
}
}
let mut exchange = if contract.exchange.as_str() == "SMART"
&& !contract.primary_exchange.as_str().is_empty()
&& contract.primary_exchange.as_str() != "SMART"
{
contract.primary_exchange.as_str().to_string()
} else {
contract.exchange.as_str().to_string()
};
if exchange == "SMART"
&& let Some(valid_exchanges) = valid_exchanges
{
let parts: Vec<&str> = valid_exchanges
.split(',')
.map(str::trim)
.filter(|part| !part.is_empty())
.collect();
if let Some(chosen) = parts.iter().find(|part| **part != "SMART") {
exchange = (*chosen).to_string();
} else if let Some(first) = parts.first() {
exchange = (*first).to_string();
}
}
if convert_exchange_to_mic_venue {
if let Some(venue) = exchange_to_mic_venue(&exchange) {
return venue;
}
}
exchange
}
#[must_use]
pub fn exchange_to_mic_venue(exchange: &str) -> Option<String> {
VENUE_MEMBERS.iter().find_map(|(venue_member, exchanges)| {
exchanges
.contains(&exchange)
.then(|| (*venue_member).to_string())
})
}
pub fn instrument_id_to_ib_contract(
instrument_id: InstrumentId,
exchange: Option<&str>,
) -> anyhow::Result<Contract> {
let venue_str = instrument_id.venue.to_string();
let derived_exchange = if venue_str == "OPRA" {
"SMART"
} else {
VENUE_MEMBERS
.get(venue_str.as_str())
.and_then(|exchanges| exchanges.first().copied())
.or_else(|| {
if venue_matches(venue_str.as_str(), VENUES_CASH)
|| venue_matches(venue_str.as_str(), VENUES_CRYPTO)
|| venue_matches(venue_str.as_str(), VENUES_OPT)
|| venue_matches(venue_str.as_str(), VENUES_FUT)
{
Some(venue_str.as_str())
} else {
None
}
})
.unwrap_or("SMART")
};
let exchange_str = exchange.unwrap_or(derived_exchange);
let symbol_str = instrument_id.symbol.as_str();
if let Some(contract) = instrument_id_to_ib_contract_raw(&instrument_id, exchange) {
return Ok(contract);
}
if venue_matches(venue_str.as_str(), VENUES_CASH)
&& let Some(captures) = parse_cash_symbol(symbol_str)
{
return Ok(Contract {
contract_id: 0,
symbol: Symbol::from(&captures.base),
security_type: SecurityType::ForexPair,
exchange: Exchange::from(exchange_str),
currency: Currency::from(&captures.quote),
local_symbol: format!("{}.{}", captures.base, captures.quote),
..Default::default()
});
}
if venue_matches(venue_str.as_str(), VENUES_CRYPTO)
&& let Some(captures) = parse_crypto_symbol(symbol_str)
{
return Ok(Contract {
contract_id: 0,
symbol: Symbol::from(&captures.base),
security_type: SecurityType::Crypto,
exchange: Exchange::from(exchange_str),
currency: Currency::from(&captures.quote),
local_symbol: format!("{}.{}", captures.base, captures.quote),
..Default::default()
});
}
if is_option_venue(venue_str.as_str()) {
if venue_str == "OPRA" {
if !is_canonical_occ_option_symbol(symbol_str) {
anyhow::bail!("Invalid OPRA option symbol: {symbol_str}");
}
return Ok(Contract {
contract_id: 0,
security_type: SecurityType::Option,
exchange: Exchange::from(exchange_str),
currency: Currency::from("USD"),
local_symbol: symbol_str.to_string(),
..Default::default()
});
}
if let Some(opt) = parse_option_symbol(symbol_str) {
return Ok(Contract {
contract_id: 0,
symbol: Symbol::from(&opt.symbol),
security_type: SecurityType::Option,
exchange: Exchange::from(exchange_str),
currency: Currency::from("USD"), local_symbol: opt.local_symbol,
last_trade_date_or_contract_month: opt.expiry,
strike: opt.strike_value,
right: Some(opt.right),
..Default::default()
});
}
if let Some(opt) = parse_named_option_symbol(symbol_str) {
return Ok(Contract {
contract_id: 0,
symbol: Symbol::from(&opt.trading_class),
security_type: SecurityType::Option,
exchange: Exchange::from(exchange_str),
currency: Currency::from("USD"),
trading_class: opt.trading_class,
last_trade_date_or_contract_month: opt.expiry,
strike: opt.strike_value,
right: Some(opt.right),
..Default::default()
});
}
}
if venue_matches(venue_str.as_str(), VENUES_FUT) {
if let Some(fut) = parse_named_futures_symbol(symbol_str) {
return Ok(Contract {
contract_id: 0,
symbol: Symbol::from(&fut.underlying),
security_type: SecurityType::Future,
exchange: Exchange::from(exchange_str),
currency: Currency::from("USD"),
trading_class: fut.trading_class,
last_trade_date_or_contract_month: fut.expiry,
..Default::default()
});
}
if let Some(underlying) = parse_futures_underlying(symbol_str) {
return Ok(Contract {
contract_id: 0,
symbol: Symbol::from(&underlying),
security_type: SecurityType::ContinuousFuture,
exchange: Exchange::from(exchange_str),
currency: Currency::from("USD"), ..Default::default()
});
}
if let Some(local_symbol) = parse_futures_option_symbol(symbol_str) {
return Ok(Contract {
contract_id: 0,
security_type: SecurityType::FuturesOption,
exchange: Exchange::from(exchange_str),
currency: Currency::from("USD"),
local_symbol,
..Default::default()
});
}
if let Some(fut) = parse_futures_symbol(symbol_str) {
return Ok(Contract {
contract_id: 0,
security_type: SecurityType::Future,
exchange: Exchange::from(exchange_str),
currency: Currency::from("USD"),
local_symbol: fut.local_symbol,
..Default::default()
});
}
}
if venue_matches(venue_str.as_str(), VENUES_CFD) {
if let Some(captures) =
parse_cash_symbol(symbol_str).or_else(|| parse_cfd_cash_symbol(symbol_str))
{
return Ok(Contract {
contract_id: 0,
symbol: Symbol::from(&captures.base),
security_type: SecurityType::CFD,
exchange: Exchange::from("SMART"),
currency: Currency::from(&captures.quote),
local_symbol: format!("{}.{}", captures.base, captures.quote),
..Default::default()
});
} else {
let symbol_clean = symbol_str.replace('-', " ");
return Ok(Contract {
contract_id: 0,
symbol: Symbol::from(&symbol_clean),
security_type: SecurityType::CFD,
exchange: Exchange::from("SMART"),
currency: Currency::from("USD"),
..Default::default()
});
}
}
if VENUES_CMDTY.contains(&venue_str.as_str()) {
let symbol_clean = symbol_str.replace('-', " ");
return Ok(Contract {
contract_id: 0,
symbol: Symbol::from(&symbol_clean),
security_type: SecurityType::Commodity,
exchange: Exchange::from("SMART"),
currency: Currency::from("USD"),
..Default::default()
});
}
if let Some(local_symbol) = symbol_str.strip_prefix('^') {
return Ok(Contract {
contract_id: 0,
symbol: Symbol::from(local_symbol),
security_type: SecurityType::Index,
exchange: Exchange::from(exchange_str),
currency: Currency::from("USD"),
local_symbol: local_symbol.into(),
..Default::default()
});
}
let symbol_clean = symbol_str.replace('-', " ");
Ok(Contract {
contract_id: 0,
symbol: Symbol::from(&symbol_clean),
security_type: SecurityType::Stock,
exchange: Exchange::from("SMART"),
currency: Currency::from(""), primary_exchange: Exchange::from(exchange_str),
..Default::default()
})
}
fn instrument_id_to_ib_contract_raw(
instrument_id: &InstrumentId,
exchange: Option<&str>,
) -> Option<Contract> {
let (local_symbol, sec_type_code) = instrument_id.symbol.as_str().rsplit_once('=')?;
let venue_exchange = instrument_id.venue.as_str().replace('/', ".");
let security_type = IbSecurityType::from_str(sec_type_code)
.ok()
.map(IbSecurityType::ibapi_security_type)?;
let default_exchange =
if security_type == SecurityType::Option && instrument_id.venue.as_str() == "OPRA" {
"SMART"
} else {
venue_exchange.as_str()
};
let exchange_str = exchange.unwrap_or(default_exchange);
let contract = match security_type {
SecurityType::Stock => Contract {
contract_id: 0,
security_type,
exchange: Exchange::from("SMART"),
primary_exchange: Exchange::from(exchange_str),
local_symbol: local_symbol.to_string(),
..Default::default()
},
SecurityType::CFD | SecurityType::Commodity => Contract {
contract_id: 0,
security_type,
exchange: Exchange::from("SMART"),
local_symbol: local_symbol.to_string(),
..Default::default()
},
SecurityType::Index => Contract {
contract_id: 0,
security_type,
exchange: Exchange::from(exchange_str),
local_symbol: local_symbol.to_string(),
..Default::default()
},
_ => Contract {
contract_id: 0,
security_type,
exchange: Exchange::from(exchange_str),
local_symbol: local_symbol.to_string(),
..Default::default()
},
};
Some(contract)
}
struct CurrencyPair {
base: String,
quote: String,
}
fn parse_cash_symbol(symbol: &str) -> Option<CurrencyPair> {
if let Some((base, quote)) = symbol.split_once('/')
&& base.len() == 3
&& quote.len() == 3
{
return Some(CurrencyPair {
base: base.to_string(),
quote: quote.to_string(),
});
}
None
}
fn parse_crypto_symbol(symbol: &str) -> Option<CurrencyPair> {
if let Some((base, quote)) = symbol.split_once('/')
&& !base.is_empty()
&& base.chars().all(|ch| ch.is_ascii_uppercase())
&& quote.len() == 3
&& quote.chars().all(|ch| ch.is_ascii_uppercase())
{
return Some(CurrencyPair {
base: base.to_string(),
quote: quote.to_string(),
});
}
None
}
fn parse_cfd_cash_symbol(symbol: &str) -> Option<CurrencyPair> {
if let Some((base, quote)) = symbol.split_once('.')
&& base.len() == 3
&& quote.len() == 3
{
return Some(CurrencyPair {
base: base.to_string(),
quote: quote.to_string(),
});
}
None
}
struct OptionSymbol {
symbol: String,
expiry: String,
right: OptionRight,
local_symbol: String,
strike_value: f64,
}
fn parse_option_symbol(symbol: &str) -> Option<OptionSymbol> {
if symbol.len() < 21 {
return None;
}
let symbol_part = symbol[..6.min(symbol.len())].trim();
let remaining = &symbol[6.min(symbol.len())..];
if remaining.len() < 15 {
return None;
}
let expiry = &remaining[..6];
let right_char = remaining.chars().nth(6)?;
let right = IbOptionRight::from_str(&right_char.to_string()).ok()?;
let strike_str = &remaining[7..];
if strike_str.len() < 8 {
return None;
}
let strike_value = if strike_str.contains('.') {
strike_str.parse().ok()?
} else {
let strike_int: i32 = strike_str.parse().ok()?;
strike_int as f64 / 1000.0
};
Some(OptionSymbol {
symbol: symbol_part.to_string(),
expiry: expiry.to_string(),
right: ib_option_right_to_option_right(right),
local_symbol: symbol.to_string(),
strike_value,
})
}
struct NamedOptionSymbol {
trading_class: String,
expiry: String,
right: OptionRight,
strike_value: f64,
}
struct NamedFuturesSymbol {
underlying: String,
trading_class: String,
expiry: String,
}
fn parse_named_futures_symbol(symbol: &str) -> Option<NamedFuturesSymbol> {
let parts: Vec<&str> = symbol.split_whitespace().collect();
if parts.len() != 3 {
return None;
}
let expiry = parts[2];
if expiry.len() != 8 || !expiry.chars().all(|c| c.is_ascii_digit()) {
return None;
}
Some(NamedFuturesSymbol {
underlying: parts[0].to_string(),
trading_class: parts[1].to_string(),
expiry: expiry.to_string(),
})
}
fn parse_named_option_symbol(symbol: &str) -> Option<NamedOptionSymbol> {
let parts: Vec<&str> = symbol.split_whitespace().collect();
if !(parts.len() == 4 || parts.len() == 5) {
return None;
}
let right = IbOptionRight::from_str(parts[0]).ok()?;
let expiry = parts[2];
if expiry.len() != 8 || !expiry.chars().all(|c| c.is_ascii_digit()) {
return None;
}
Some(NamedOptionSymbol {
trading_class: parts[1].to_string(),
expiry: expiry.to_string(),
right: ib_option_right_to_option_right(right),
strike_value: parts[3].parse::<f64>().ok()?,
})
}
fn normalize_option_symbol(local_symbol: &str) -> String {
if local_symbol.len() >= 15 {
let (root, suffix) = local_symbol.split_at(local_symbol.len() - 15);
let is_occ_suffix = suffix[..6].chars().all(|c| c.is_ascii_digit())
&& matches!(suffix.chars().nth(6), Some('C' | 'P'))
&& suffix[7..].chars().all(|c| c.is_ascii_digit());
if !root.is_empty() && root.len() <= 6 && is_occ_suffix {
return format!("{:<6}{}", root.trim_end(), suffix);
}
}
local_symbol.to_string()
}
fn format_option_strike(strike: f64) -> String {
if strike.fract() == 0.0 {
format!("{strike:.0}")
} else {
format!("{strike}")
}
}
struct FuturesSymbol {
local_symbol: String,
}
fn parse_futures_underlying(symbol: &str) -> Option<String> {
if symbol.len() <= 3 && symbol.chars().all(|c| c.is_alphabetic()) {
Some(symbol.to_string())
} else {
None
}
}
fn is_futures_month_code(ch: char) -> bool {
matches!(
ch,
'F' | 'G' | 'H' | 'J' | 'K' | 'M' | 'N' | 'Q' | 'U' | 'V' | 'X' | 'Z'
)
}
fn parse_futures_month_and_year(symbol: &str) -> Option<(usize, char, String)> {
for (month_pos, month_char) in symbol.char_indices().rev() {
if !is_futures_month_code(month_char) {
continue;
}
let remaining = &symbol[month_pos + month_char.len_utf8()..];
if remaining.is_empty() || !remaining.chars().all(|ch| ch.is_ascii_digit()) {
continue;
}
let year = match remaining.len() {
1 | 2 => remaining.to_string(),
4 => remaining[remaining.len() - 2..].to_string(),
_ => continue,
};
if month_pos == 0 {
continue;
}
return Some((month_pos, month_char, year));
}
None
}
fn parse_futures_symbol(symbol: &str) -> Option<FuturesSymbol> {
parse_futures_month_and_year(symbol).map(|_| FuturesSymbol {
local_symbol: symbol.to_string(),
})
}
fn parse_futures_option_symbol(symbol: &str) -> Option<String> {
let (futures_symbol, rest) = symbol.split_once(' ')?;
let (month_pos, _, _) = parse_futures_month_and_year(futures_symbol)?;
let _fut_symbol = &futures_symbol[..month_pos];
let right_char = rest.chars().next()?;
IbOptionRight::from_str(&right_char.to_string()).ok()?;
let strike_str = &rest[1..];
strike_str.parse::<f64>().ok()?;
Some(symbol.to_string())
}
#[must_use]
pub fn is_spread_instrument_id(instrument_id: &InstrumentId) -> bool {
let symbol_str = instrument_id.symbol.as_str();
symbol_str.contains('(') && symbol_str.contains('_')
}
pub fn create_spread_instrument_id(
leg_tuples: &[(InstrumentId, i32)],
) -> anyhow::Result<InstrumentId> {
if leg_tuples.len() < 2 {
anyhow::bail!("instrument_ratios list needs to have at least 2 legs");
}
let first_venue = leg_tuples[0].0.venue;
for (instrument_id, ratio) in leg_tuples {
if *ratio == 0 {
anyhow::bail!("ratio cannot be zero");
}
if instrument_id.venue != first_venue {
anyhow::bail!(
"All venues must match. Expected {}, was {}",
first_venue,
instrument_id.venue
);
}
}
let mut sorted_ratios = leg_tuples.to_vec();
sorted_ratios.sort_by(|a, b| a.0.symbol.as_str().cmp(b.0.symbol.as_str()));
let symbol_parts = sorted_ratios
.iter()
.map(|(instrument_id, ratio)| {
if *ratio > 0 {
format!("({}){}", ratio, instrument_id.symbol.as_str())
} else {
format!("(({})){}", ratio.abs(), instrument_id.symbol.as_str())
}
})
.collect::<Vec<_>>();
let composite_symbol = symbol_parts.join("_");
let symbol = NautilusSymbol::from(composite_symbol.as_str());
Ok(InstrumentId::new(symbol, first_venue))
}
pub fn parse_spread_instrument_id_to_legs(
instrument_id: &InstrumentId,
) -> anyhow::Result<Vec<(InstrumentId, i32)>> {
let symbol_str = instrument_id.symbol.as_str();
let venue = instrument_id.venue;
let components: Vec<&str> = symbol_str.split('_').collect();
let mut result = Vec::new();
for component in components {
if component.is_empty() {
continue;
}
if let Some(rest) = component.strip_prefix("((")
&& let Some(pos) = rest.find("))")
{
let ratio_str = &rest[..pos];
let symbol_value = &rest[pos + 2..];
if let Ok(ratio) = ratio_str.parse::<i32>() {
let leg_instrument_id =
InstrumentId::new(NautilusSymbol::from(symbol_value), venue);
result.push((leg_instrument_id, -ratio));
continue;
}
}
if let Some(rest) = component.strip_prefix('(')
&& let Some(pos) = rest.find(')')
{
let ratio_str = &rest[..pos];
let symbol_value = &rest[pos + 1..];
if let Ok(ratio) = ratio_str.parse::<i32>() {
let leg_instrument_id =
InstrumentId::new(NautilusSymbol::from(symbol_value), venue);
result.push((leg_instrument_id, ratio));
continue;
}
}
anyhow::bail!("Invalid spread symbol format for component: {component}");
}
result.sort_by(|a, b| a.0.symbol.as_str().cmp(b.0.symbol.as_str()));
Ok(result)
}
#[cfg(test)]
mod tests {
use ibapi::contracts::{Contract, Currency, Exchange, OptionRight, SecurityType, Symbol};
use nautilus_model::identifiers::InstrumentId;
use rstest::rstest;
use super::{
exchange_to_mic_venue, ib_contract_to_instrument_id_simplified,
instrument_id_to_ib_contract, possible_exchanges_for_venue,
};
#[rstest]
fn test_ib_contract_to_instrument_id_simplified_normalizes_occ_option_root() {
let contract = Contract {
symbol: Symbol::from("SPXW"),
security_type: SecurityType::Option,
exchange: Exchange::from("SMART"),
currency: Currency::from("USD"),
local_symbol: "SPXW260313P06630000".to_string(),
last_trade_date_or_contract_month: "260313".to_string(),
right: Some(OptionRight::Put),
strike: 6630.0,
..Default::default()
};
let instrument_id = ib_contract_to_instrument_id_simplified(&contract, None).unwrap();
assert_eq!(
instrument_id,
InstrumentId::from("SPXW 260313P06630000.SMART")
);
}
#[rstest]
fn test_ib_contract_to_instrument_id_simplified_formats_named_option_without_local_symbol() {
let contract = Contract {
symbol: Symbol::from("OESX"),
security_type: SecurityType::Option,
exchange: Exchange::from("EUREX"),
currency: Currency::from("EUR"),
trading_class: "OESX".to_string(),
local_symbol: String::new(),
last_trade_date_or_contract_month: "20260213".to_string(),
right: Some(OptionRight::Call),
strike: 4775.0,
..Default::default()
};
let instrument_id = ib_contract_to_instrument_id_simplified(&contract, None).unwrap();
assert_eq!(
instrument_id,
InstrumentId::from("C OESX 20260213 4775.EUREX")
);
}
#[rstest]
fn test_ib_contract_to_instrument_id_simplified_preserves_fop_spacing() {
let contract = Contract {
symbol: Symbol::from("EX2"),
security_type: SecurityType::FuturesOption,
exchange: Exchange::from("NYBOT"),
currency: Currency::from("USD"),
local_symbol: "EX2G3 P4080".to_string(),
..Default::default()
};
let instrument_id = ib_contract_to_instrument_id_simplified(&contract, None).unwrap();
assert_eq!(instrument_id, InstrumentId::from("EX2G3 P4080.NYBOT"));
}
#[rstest]
fn test_instrument_id_to_ib_contract_parses_named_option_symbol() {
let instrument_id = InstrumentId::from("C OESX 20260213 4775.EUREX");
let contract = instrument_id_to_ib_contract(instrument_id, None).unwrap();
assert_eq!(contract.security_type, SecurityType::Option);
assert_eq!(contract.exchange.as_str(), "EUREX");
assert_eq!(contract.symbol.as_str(), "OESX");
assert_eq!(contract.trading_class.as_str(), "OESX");
assert_eq!(
contract.last_trade_date_or_contract_month.as_str(),
"20260213"
);
assert_eq!(contract.right.map(|right| right.as_str()), Some("C"));
assert_eq!(contract.strike, 4775.0);
}
#[rstest]
fn test_instrument_id_to_ib_contract_preserves_occ_local_symbol() {
let instrument_id = InstrumentId::from("AAPL 230217P00155000.SMART");
let contract = instrument_id_to_ib_contract(instrument_id, None).unwrap();
assert_eq!(contract.security_type, SecurityType::Option);
assert_eq!(contract.exchange.as_str(), "SMART");
assert_eq!(contract.symbol.as_str(), "AAPL");
assert_eq!(contract.local_symbol.as_str(), "AAPL 230217P00155000");
assert_eq!(
contract.last_trade_date_or_contract_month.as_str(),
"230217"
);
assert_eq!(contract.right.map(|right| right.as_str()), Some("P"));
assert_eq!(contract.strike, 155.0);
}
#[rstest]
fn test_possible_exchanges_for_opra_routes_to_smart() {
assert_eq!(
possible_exchanges_for_venue("OPRA"),
vec!["SMART".to_string()]
);
}
#[rstest]
fn test_opra_occ_option_default_contract_routes_to_smart() {
let instrument_id = InstrumentId::from("SPY 240319P00511000.OPRA");
let contract = instrument_id_to_ib_contract(instrument_id, None).unwrap();
assert_eq!(contract.security_type, SecurityType::Option);
assert_eq!(contract.exchange.as_str(), "SMART");
assert!(contract.symbol.as_str().is_empty());
assert_eq!(contract.currency.as_str(), "USD");
assert_eq!(contract.local_symbol.as_str(), "SPY 240319P00511000");
assert!(contract.last_trade_date_or_contract_month.is_empty());
assert!(contract.right.is_none());
assert_eq!(contract.strike, 0.0);
}
#[rstest]
fn test_opra_occ_option_qualification_contract_uses_opt_smart() {
let instrument_id = InstrumentId::from("SPY 240319P00511000.OPRA");
let exchanges = possible_exchanges_for_venue(instrument_id.venue.as_str());
assert_eq!(exchanges, vec!["SMART".to_string()]);
let contract =
instrument_id_to_ib_contract(instrument_id, exchanges.first().map(String::as_str))
.unwrap();
assert_eq!(contract.security_type, SecurityType::Option);
assert_eq!(contract.exchange.as_str(), "SMART");
assert!(contract.symbol.as_str().is_empty());
assert_eq!(contract.currency.as_str(), "USD");
assert_eq!(contract.local_symbol.as_str(), "SPY 240319P00511000");
assert!(contract.last_trade_date_or_contract_month.is_empty());
assert!(contract.right.is_none());
assert_eq!(contract.strike, 0.0);
}
#[rstest]
fn test_opra_raw_option_default_contract_routes_to_smart() {
let instrument_id = InstrumentId::from("SPY 240319P00511000=OPT.OPRA");
let contract = instrument_id_to_ib_contract(instrument_id, None).unwrap();
assert_eq!(contract.security_type, SecurityType::Option);
assert_eq!(contract.exchange.as_str(), "SMART");
assert_eq!(contract.local_symbol.as_str(), "SPY 240319P00511000");
}
#[rstest]
#[case("SPY 240319P00511000.OPRA")]
#[case("SPY 240319P00511000=OPT.OPRA")]
fn test_opra_option_respects_exchange_override(#[case] value: &str) {
let instrument_id = InstrumentId::from(value);
let contract = instrument_id_to_ib_contract(instrument_id, Some("CBOE")).unwrap();
assert_eq!(contract.security_type, SecurityType::Option);
assert_eq!(contract.exchange.as_str(), "CBOE");
}
#[rstest]
#[case("AAPL.OPRA")]
#[case("SPY abcdefP00511000.OPRA")]
#[case("P SPY 20240319 511.OPRA")]
fn test_invalid_opra_occ_option_returns_error(#[case] value: &str) {
let instrument_id = InstrumentId::from(value);
let result = instrument_id_to_ib_contract(instrument_id, None);
assert!(result.is_err());
}
#[rstest]
fn test_opra_route_does_not_create_reverse_smart_mapping() {
assert_eq!(exchange_to_mic_venue("SMART"), None);
}
#[rstest]
fn test_instrument_id_to_ib_contract_parses_named_futures_symbol() {
let instrument_id = InstrumentId::from("ESTX50 FESX 20240315.EUREX");
let contract = instrument_id_to_ib_contract(instrument_id, None).unwrap();
assert_eq!(contract.security_type, SecurityType::Future);
assert_eq!(contract.exchange.as_str(), "EUREX");
assert_eq!(contract.symbol.as_str(), "ESTX50");
assert_eq!(contract.trading_class.as_str(), "FESX");
assert_eq!(
contract.last_trade_date_or_contract_month.as_str(),
"20240315"
);
}
#[rstest]
fn test_instrument_id_to_ib_contract_maps_xcbt_to_cbot_exchange() {
let instrument_id = InstrumentId::from("YMM6.XCBT");
let contract = instrument_id_to_ib_contract(instrument_id, None).unwrap();
assert_eq!(contract.security_type, SecurityType::Future);
assert_eq!(contract.exchange.as_str(), "CBOT");
assert_eq!(contract.local_symbol.as_str(), "YMM6");
assert!(contract.symbol.as_str().is_empty());
assert!(contract.last_trade_date_or_contract_month.is_empty());
}
#[rstest]
fn test_instrument_id_to_ib_contract_maps_mic_future_to_member_exchange() {
let instrument_id = InstrumentId::from("OESXH6.XEUR");
let contract = instrument_id_to_ib_contract(instrument_id, None).unwrap();
assert_eq!(contract.security_type, SecurityType::Future);
assert_eq!(contract.exchange.as_str(), "DTB");
assert_eq!(contract.local_symbol.as_str(), "OESXH6");
}
#[rstest]
fn test_instrument_id_to_ib_contract_parses_futures_option_with_month_code_in_symbol() {
let instrument_id = InstrumentId::from("YMM6 C45000.XCBT");
let contract = instrument_id_to_ib_contract(instrument_id, None).unwrap();
assert_eq!(contract.security_type, SecurityType::FuturesOption);
assert_eq!(contract.exchange.as_str(), "CBOT");
assert_eq!(contract.local_symbol.as_str(), "YMM6 C45000");
}
#[rstest]
fn test_instrument_id_to_ib_contract_parses_ibcfd_cash_symbol() {
let instrument_id = InstrumentId::from("EUR/USD.IBCFD");
let contract = instrument_id_to_ib_contract(instrument_id, None).unwrap();
assert_eq!(contract.security_type, SecurityType::CFD);
assert_eq!(contract.exchange.as_str(), "SMART");
assert_eq!(contract.symbol.as_str(), "EUR");
assert_eq!(contract.currency.as_str(), "USD");
assert_eq!(contract.local_symbol.as_str(), "EUR.USD");
}
#[rstest]
fn test_instrument_id_to_ib_contract_parses_long_crypto_symbol() {
let instrument_id = InstrumentId::from("DOGE/USD.PAXOS");
let contract = instrument_id_to_ib_contract(instrument_id, None).unwrap();
assert_eq!(contract.security_type, SecurityType::Crypto);
assert_eq!(contract.exchange.as_str(), "PAXOS");
assert_eq!(contract.symbol.as_str(), "DOGE");
assert_eq!(contract.currency.as_str(), "USD");
assert_eq!(contract.local_symbol.as_str(), "DOGE.USD");
}
#[rstest]
fn test_instrument_id_to_ib_contract_parses_zerohash_crypto_symbol() {
let instrument_id = InstrumentId::from("BTC/USD.ZEROHASH");
let contract = instrument_id_to_ib_contract(instrument_id, None).unwrap();
assert_eq!(contract.security_type, SecurityType::Crypto);
assert_eq!(contract.exchange.as_str(), "ZEROHASH");
assert_eq!(contract.symbol.as_str(), "BTC");
assert_eq!(contract.currency.as_str(), "USD");
assert_eq!(contract.local_symbol.as_str(), "BTC.USD");
}
#[rstest]
fn test_ib_contract_to_instrument_id_simplified_derives_zerohash_crypto_venue() {
let contract = Contract {
symbol: Symbol::from("BTC"),
security_type: SecurityType::Crypto,
exchange: Exchange::from("ZEROHASH"),
currency: Currency::from("USD"),
local_symbol: "BTC.USD".to_string(),
..Default::default()
};
let instrument_id = ib_contract_to_instrument_id_simplified(&contract, None).unwrap();
assert_eq!(instrument_id, InstrumentId::from("BTC/USD.ZEROHASH"));
}
#[rstest]
fn test_ib_contract_to_instrument_id_simplified_falls_back_to_paxos_crypto_venue() {
let contract = Contract {
symbol: Symbol::from("DOGE"),
security_type: SecurityType::Crypto,
currency: Currency::from("USD"),
local_symbol: "DOGE.USD".to_string(),
..Default::default()
};
let instrument_id = ib_contract_to_instrument_id_simplified(&contract, None).unwrap();
assert_eq!(instrument_id, InstrumentId::from("DOGE/USD.PAXOS"));
}
#[rstest]
fn test_instrument_id_to_ib_contract_parses_paxos_crypto_symbol() {
let instrument_id = InstrumentId::from("BTC/USD.PAXOS");
let contract = instrument_id_to_ib_contract(instrument_id, None).unwrap();
assert_eq!(contract.security_type, SecurityType::Crypto);
assert_eq!(contract.exchange.as_str(), "PAXOS");
assert_eq!(contract.symbol.as_str(), "BTC");
assert_eq!(contract.currency.as_str(), "USD");
assert_eq!(contract.local_symbol.as_str(), "BTC.USD");
}
#[rstest]
fn test_ib_contract_to_instrument_id_simplified_derives_paxos_crypto_venue() {
let contract = Contract {
symbol: Symbol::from("BTC"),
security_type: SecurityType::Crypto,
exchange: Exchange::from("PAXOS"),
currency: Currency::from("USD"),
local_symbol: "BTC.USD".to_string(),
..Default::default()
};
let instrument_id = ib_contract_to_instrument_id_simplified(&contract, None).unwrap();
assert_eq!(instrument_id, InstrumentId::from("BTC/USD.PAXOS"));
}
#[rstest]
fn test_instrument_id_to_ib_contract_uses_contfut_for_underlying() {
let instrument_id = InstrumentId::from("ES.XCME");
let contract = instrument_id_to_ib_contract(instrument_id, None).unwrap();
assert_eq!(contract.security_type, SecurityType::ContinuousFuture);
assert_eq!(contract.exchange.as_str(), "CME");
assert_eq!(contract.symbol.as_str(), "ES");
}
#[rstest]
fn test_instrument_id_to_ib_contract_parses_raw_stock_symbol() {
let instrument_id = InstrumentId::from("AAPL=STK.NASDAQ");
let contract = instrument_id_to_ib_contract(instrument_id, None).unwrap();
assert_eq!(contract.security_type, SecurityType::Stock);
assert_eq!(contract.exchange.as_str(), "SMART");
assert_eq!(contract.primary_exchange.as_str(), "NASDAQ");
assert_eq!(contract.local_symbol.as_str(), "AAPL");
assert!(contract.symbol.as_str().is_empty());
}
#[rstest]
fn test_instrument_id_to_ib_contract_parses_raw_forex_symbol() {
let instrument_id = InstrumentId::from("EUR.USD=CASH.IDEALPRO");
let contract = instrument_id_to_ib_contract(instrument_id, None).unwrap();
assert_eq!(contract.security_type, SecurityType::ForexPair);
assert_eq!(contract.exchange.as_str(), "IDEALPRO");
assert_eq!(contract.local_symbol.as_str(), "EUR.USD");
assert!(contract.symbol.as_str().is_empty());
}
#[rstest]
fn test_instrument_id_to_ib_contract_raw_respects_exchange_override() {
let instrument_id = InstrumentId::from("YMM6=FUT.XCBT");
let contract = instrument_id_to_ib_contract(instrument_id, Some("CBOT")).unwrap();
assert_eq!(contract.security_type, SecurityType::Future);
assert_eq!(contract.exchange.as_str(), "CBOT");
assert_eq!(contract.local_symbol.as_str(), "YMM6");
}
#[rstest]
fn test_instrument_id_to_ib_contract_default_stock_omits_local_symbol_and_currency() {
let instrument_id = InstrumentId::from("IUSA.IBIS");
let contract = instrument_id_to_ib_contract(instrument_id, Some("IBIS")).unwrap();
assert_eq!(contract.security_type, SecurityType::Stock);
assert_eq!(contract.symbol.as_str(), "IUSA");
assert_eq!(contract.exchange.as_str(), "SMART");
assert_eq!(contract.primary_exchange.as_str(), "IBIS");
assert!(contract.currency.as_str().is_empty());
assert!(contract.local_symbol.is_empty());
}
}