use std::collections::HashMap;
use chrono::{DateTime, Utc};
use optionstratlib::chains::chain::OptionChain;
use super::events::GreeksRow;
use super::greeks::PremiumNumeraire;
use super::identity::{
ContractSpecFingerprint, ExerciseStyle, Instrument, InstrumentKey, ProviderId, SettlementStyle,
};
use crate::error::OverlayError;
#[derive(Debug, Clone)]
pub struct ChainFetch {
pub chain: OptionChain,
pub expiry_source: ExpirySource,
pub aliases: AliasCatalog,
pub greeks_seed: Vec<GreeksRow>,
pub premium_numeraire: PremiumNumeraire,
}
impl ChainFetch {
#[must_use]
pub fn new(chain: OptionChain, expiry_source: ExpirySource, aliases: AliasCatalog) -> Self {
Self {
chain,
expiry_source,
aliases,
greeks_seed: Vec::new(),
premium_numeraire: PremiumNumeraire::QuoteCurrency,
}
}
#[must_use]
pub fn with_greeks_seed(mut self, greeks_seed: Vec<GreeksRow>) -> Self {
self.greeks_seed = greeks_seed;
self
}
#[must_use]
pub fn with_premium_numeraire(mut self, premium_numeraire: PremiumNumeraire) -> Self {
self.premium_numeraire = premium_numeraire;
self
}
}
#[derive(Debug, Clone)]
pub struct ExpirySource {
pub underlying: String,
pub expiration_utc: DateTime<Utc>,
pub provider: ProviderId,
}
impl ExpirySource {
#[must_use]
pub fn new(
underlying: impl Into<String>,
expiration_utc: DateTime<Utc>,
provider: ProviderId,
) -> Self {
Self {
underlying: underlying.into(),
expiration_utc,
provider,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct AliasCatalog {
by_key: HashMap<InstrumentKey, Vec<Instrument>>,
}
impl AliasCatalog {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn insert(&mut self, instrument: Instrument) {
let entries = self.by_key.entry(instrument.key.clone()).or_default();
match entries
.iter_mut()
.find(|existing| existing.provider == instrument.provider)
{
Some(existing) => *existing = instrument,
None => entries.push(instrument),
}
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.by_key.is_empty()
}
#[must_use]
pub fn len(&self) -> usize {
self.by_key.len()
}
pub fn instruments(&self) -> impl Iterator<Item = &Instrument> {
self.by_key.values().flatten()
}
#[must_use]
pub fn instrument(&self, key: &InstrumentKey, provider: &ProviderId) -> Option<&Instrument> {
self.by_key
.get(key)?
.iter()
.find(|instrument| &instrument.provider == provider)
}
#[must_use]
pub fn resolve_symbol(&self, symbol: &str) -> Option<&InstrumentKey> {
self.by_key.iter().find_map(|(key, instruments)| {
let matches = instruments.iter().any(|instrument| {
instrument.native_symbol == symbol
|| instrument.stream_symbol.as_deref() == Some(symbol)
});
matches.then_some(key)
})
}
pub fn overlay_compatible(
&self,
key: &InstrumentKey,
source: &ProviderId,
overlay: &ProviderId,
) -> Result<(), OverlayError> {
let Some(source_leg) = self.instrument(key, source) else {
return Err(OverlayError::MissingAlias {
contract: contract_label(key),
provider: source.clone(),
});
};
let Some(overlay_leg) = self.instrument(key, overlay) else {
return Err(OverlayError::MissingAlias {
contract: contract_label(key),
provider: overlay.clone(),
});
};
compare_fingerprints(&contract_label(key), &source_leg.spec, &overlay_leg.spec)
}
}
#[must_use]
fn contract_label(key: &InstrumentKey) -> String {
format!(
"{} {} {} {}",
key.underlying,
key.expiration_utc.to_rfc3339(),
key.strike,
key.style.as_str()
)
}
#[must_use]
const fn settlement_label(settlement: SettlementStyle) -> &'static str {
match settlement {
SettlementStyle::Cash => "cash",
SettlementStyle::Physical => "physical",
}
}
#[must_use]
const fn exercise_label(exercise: ExerciseStyle) -> &'static str {
match exercise {
ExerciseStyle::European => "european",
ExerciseStyle::American => "american",
}
}
fn compare_fingerprints(
contract: &str,
source: &ContractSpecFingerprint,
overlay: &ContractSpecFingerprint,
) -> Result<(), OverlayError> {
if source.contract_multiplier != overlay.contract_multiplier {
return Err(OverlayError::SpecMismatch {
contract: contract.to_owned(),
field: "contract_multiplier",
source: source.contract_multiplier.to_string(),
overlay: overlay.contract_multiplier.to_string(),
});
}
if source.settlement != overlay.settlement {
return Err(OverlayError::SpecMismatch {
contract: contract.to_owned(),
field: "settlement",
source: settlement_label(source.settlement).to_owned(),
overlay: settlement_label(overlay.settlement).to_owned(),
});
}
if source.exercise != overlay.exercise {
return Err(OverlayError::SpecMismatch {
contract: contract.to_owned(),
field: "exercise",
source: exercise_label(source.exercise).to_owned(),
overlay: exercise_label(overlay.exercise).to_owned(),
});
}
if source.quote_currency != overlay.quote_currency {
return Err(OverlayError::SpecMismatch {
contract: contract.to_owned(),
field: "quote_currency",
source: source.quote_currency.clone(),
overlay: overlay.quote_currency.clone(),
});
}
if source.venue_product_code != overlay.venue_product_code {
return Err(OverlayError::SpecMismatch {
contract: contract.to_owned(),
field: "venue_product_code",
source: source.venue_product_code.clone(),
overlay: overlay.venue_product_code.clone(),
});
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use optionstratlib::OptionStyle;
use optionstratlib::prelude::Positive;
#[track_caller]
fn pid(id: &str) -> ProviderId {
match ProviderId::new(id) {
Ok(p) => p,
Err(e) => panic!("expected a valid provider id `{id}`, got: {e}"),
}
}
#[track_caller]
fn utc(secs: i64) -> DateTime<Utc> {
match DateTime::<Utc>::from_timestamp(secs, 0) {
Some(t) => t,
None => panic!("invalid test timestamp: {secs}"),
}
}
#[track_caller]
fn pos(value: f64) -> Positive {
match Positive::new(value) {
Ok(p) => p,
Err(e) => panic!("invalid test positive `{value}`: {e}"),
}
}
fn sample_key() -> InstrumentKey {
InstrumentKey {
underlying: "BTC".to_owned(),
expiration_utc: utc(1_700_000_000),
strike: pos(60_000.0),
style: OptionStyle::Call,
}
}
fn spec(multiplier: u32) -> ContractSpecFingerprint {
ContractSpecFingerprint {
contract_multiplier: multiplier,
settlement: SettlementStyle::Cash,
exercise: ExerciseStyle::European,
quote_currency: "USD".to_owned(),
venue_product_code: "BTC".to_owned(),
}
}
fn instrument(provider: &str, native: &str, stream: Option<&str>) -> Instrument {
Instrument {
key: sample_key(),
provider: pid(provider),
native_symbol: native.to_owned(),
stream_symbol: stream.map(str::to_owned),
spec: spec(1),
}
}
#[test]
fn test_alias_catalog_new_is_empty() {
let catalog = AliasCatalog::new();
assert!(catalog.is_empty());
assert_eq!(catalog.len(), 0);
assert!(catalog.resolve_symbol("anything").is_none());
}
#[test]
fn test_alias_catalog_resolves_native_symbol_to_shared_key() {
let mut catalog = AliasCatalog::new();
catalog.insert(instrument("deribit", "BTC-27JUN25-60000-C", None));
assert_eq!(
catalog.resolve_symbol("BTC-27JUN25-60000-C"),
Some(&sample_key())
);
}
#[test]
fn test_alias_catalog_resolves_stream_symbol_to_shared_key() {
let mut catalog = AliasCatalog::new();
catalog.insert(instrument(
"tastytrade",
".SPY250627C600",
Some(".SPY250627C600:dxfeed"),
));
assert_eq!(
catalog.resolve_symbol(".SPY250627C600:dxfeed"),
Some(&sample_key())
);
assert_eq!(
catalog.resolve_symbol(".SPY250627C600"),
Some(&sample_key())
);
}
#[test]
fn test_alias_catalog_resolve_unknown_symbol_is_none() {
let mut catalog = AliasCatalog::new();
catalog.insert(instrument("deribit", "BTC-27JUN25-60000-C", None));
assert!(catalog.resolve_symbol("UNKNOWN").is_none());
}
#[test]
fn test_alias_catalog_instruments_enumerates_every_feed_alias() {
let mut catalog = AliasCatalog::new();
catalog.insert(instrument("deribit", "BTC-27JUN25-60000-C", None));
catalog.insert(instrument("dxlink", ".BTC250627C60000", Some("dxfeed-sym")));
let mut natives: Vec<String> = catalog
.instruments()
.map(|instrument| instrument.native_symbol.clone())
.collect();
natives.sort();
assert_eq!(natives, vec![".BTC250627C60000", "BTC-27JUN25-60000-C"]);
let deribit_only: Vec<&Instrument> = catalog
.instruments()
.filter(|instrument| instrument.provider == pid("deribit"))
.collect();
assert_eq!(deribit_only.len(), 1);
match deribit_only.first() {
Some(found) => assert_eq!(found.native_symbol, "BTC-27JUN25-60000-C"),
None => panic!("expected the deribit leg"),
}
}
#[test]
fn test_alias_catalog_instrument_lookup_by_feed() {
let mut catalog = AliasCatalog::new();
catalog.insert(instrument("deribit", "BTC-27JUN25-60000-C", None));
catalog.insert(instrument("dxlink", ".BTC250627C60000", Some("dxfeed-sym")));
let key = sample_key();
match catalog.instrument(&key, &pid("deribit")) {
Some(found) => assert_eq!(found.native_symbol, "BTC-27JUN25-60000-C"),
None => panic!("expected the deribit alias for the leg"),
}
match catalog.instrument(&key, &pid("dxlink")) {
Some(found) => assert_eq!(found.stream_symbol.as_deref(), Some("dxfeed-sym")),
None => panic!("expected the dxlink alias for the leg"),
}
assert!(catalog.instrument(&key, &pid("alpaca")).is_none());
}
#[test]
fn test_alias_catalog_reinsert_same_key_provider_replaces_not_duplicates() {
let mut catalog = AliasCatalog::new();
catalog.insert(instrument("deribit", "OLD-SYMBOL", None));
catalog.insert(instrument("deribit", "NEW-SYMBOL", Some("new-stream")));
assert_eq!(catalog.len(), 1);
match catalog.instrument(&sample_key(), &pid("deribit")) {
Some(found) => {
assert_eq!(found.native_symbol, "NEW-SYMBOL");
assert_eq!(found.stream_symbol.as_deref(), Some("new-stream"));
}
None => panic!("expected the deribit alias after re-insert"),
}
assert!(catalog.resolve_symbol("OLD-SYMBOL").is_none());
assert_eq!(catalog.resolve_symbol("NEW-SYMBOL"), Some(&sample_key()));
catalog.insert(instrument("dxlink", "DX-SYMBOL", Some("dx-stream")));
assert_eq!(catalog.len(), 1);
assert!(catalog.instrument(&sample_key(), &pid("dxlink")).is_some());
assert!(catalog.instrument(&sample_key(), &pid("deribit")).is_some());
}
#[test]
fn test_chain_fetch_carries_alias_catalog_forward_unchanged() {
let mut catalog = AliasCatalog::new();
catalog.insert(instrument("deribit", "BTC-27JUN25-60000-C", None));
catalog.insert(instrument("dxlink", ".BTC250627C60000", Some("dxfeed-sym")));
let fetch = ChainFetch::new(
OptionChain::new("BTC", pos(60_000.0), "2025-06-27".to_owned(), None, None),
ExpirySource::new("BTC", utc(1_700_000_000), pid("deribit")),
catalog,
);
assert_eq!(fetch.aliases.len(), 1); assert_eq!(
fetch.aliases.resolve_symbol("dxfeed-sym"),
Some(&sample_key())
);
assert!(
fetch
.aliases
.instrument(&sample_key(), &pid("deribit"))
.is_some()
);
assert_eq!(fetch.expiry_source.underlying, "BTC");
assert_eq!(fetch.chain.symbol, "BTC");
}
#[test]
fn test_overlay_compatible_same_feed_is_ok() {
let mut catalog = AliasCatalog::new();
catalog.insert(instrument("deribit", "BTC-27JUN25-60000-C", None));
assert!(
catalog
.overlay_compatible(&sample_key(), &pid("deribit"), &pid("deribit"))
.is_ok()
);
}
#[test]
fn test_overlay_compatible_matching_specs_is_ok() {
let mut catalog = AliasCatalog::new();
catalog.insert(instrument("deribit", "native", None));
catalog.insert(instrument("dxlink", "native2", Some("stream")));
assert!(
catalog
.overlay_compatible(&sample_key(), &pid("deribit"), &pid("dxlink"))
.is_ok()
);
}
#[test]
fn test_overlay_compatible_multiplier_mismatch_is_spec_mismatch() {
let mut catalog = AliasCatalog::new();
catalog.insert(instrument("deribit", "native", None));
let mut overlay = instrument("dxlink", "native2", Some("stream"));
overlay.spec = spec(100); catalog.insert(overlay);
match catalog.overlay_compatible(&sample_key(), &pid("deribit"), &pid("dxlink")) {
Err(OverlayError::SpecMismatch {
field,
source,
overlay,
..
}) => {
assert_eq!(field, "contract_multiplier");
assert_eq!(source, "1");
assert_eq!(overlay, "100");
}
other => panic!("expected a multiplier SpecMismatch, got {other:?}"),
}
}
#[test]
fn test_overlay_compatible_settlement_mismatch_is_spec_mismatch() {
let mut catalog = AliasCatalog::new();
catalog.insert(instrument("deribit", "native", None));
let mut overlay = instrument("dxlink", "native2", Some("stream"));
overlay.spec = ContractSpecFingerprint {
settlement: SettlementStyle::Physical,
..spec(1)
};
catalog.insert(overlay);
match catalog.overlay_compatible(&sample_key(), &pid("deribit"), &pid("dxlink")) {
Err(OverlayError::SpecMismatch {
field,
source,
overlay,
..
}) => {
assert_eq!(field, "settlement");
assert_eq!(source, "cash");
assert_eq!(overlay, "physical");
}
other => panic!("expected a settlement SpecMismatch, got {other:?}"),
}
}
#[test]
fn test_overlay_compatible_missing_overlay_leg_is_refused() {
let mut catalog = AliasCatalog::new();
catalog.insert(instrument("deribit", "native", None));
match catalog.overlay_compatible(&sample_key(), &pid("deribit"), &pid("dxlink")) {
Err(OverlayError::MissingAlias { provider, .. }) => {
assert_eq!(provider.as_str(), "dxlink");
}
other => panic!("expected a MissingAlias refusal, got {other:?}"),
}
}
#[test]
fn test_overlay_compatible_missing_source_leg_is_refused() {
let mut catalog = AliasCatalog::new();
catalog.insert(instrument("dxlink", "native", Some("stream")));
match catalog.overlay_compatible(&sample_key(), &pid("deribit"), &pid("dxlink")) {
Err(OverlayError::MissingAlias { provider, .. }) => {
assert_eq!(provider.as_str(), "deribit");
}
other => panic!("expected a MissingAlias refusal, got {other:?}"),
}
}
#[test]
fn test_expiry_source_new_sets_fields() {
let source = ExpirySource::new("BTC", utc(1_700_000_000), pid("deribit"));
assert_eq!(source.underlying, "BTC");
assert_eq!(source.expiration_utc, utc(1_700_000_000));
assert_eq!(source.provider.as_str(), "deribit");
}
}