use chrono::{Datelike, NaiveDate};
use crate::calendars::get_calendar;
use crate::contract_cycle::resolve_contract_months;
use crate::dates::is_month_in_calendar_range;
use crate::expiration_rules::{month_sessions, resolve_expiration};
use crate::models::{ContractSpec, SpecRepository};
use crate::month_codes::month_to_code;
use crate::options_models::{OptionRule, OptionTypeCodes};
use crate::options_spec::load_option_rules;
fn parse_date(s: &str) -> Result<NaiveDate, String> {
NaiveDate::parse_from_str(s.trim(), "%Y-%m-%d").map_err(|e| e.to_string())
}
pub fn equity_root(underlying: &str) -> String {
let mut cs: Vec<char> = underlying.chars().collect();
if let Some(last) = cs.last() {
if last.is_ascii_digit() {
cs.pop();
}
}
cs.into_iter().collect()
}
fn still_tradeable(as_of: NaiveDate, expiration: NaiveDate, contract: &ContractSpec) -> bool {
if contract.symbol == "DOL" || contract.symbol == "WDO" {
as_of < expiration
} else {
as_of <= expiration
}
}
fn synthetic_contract(symbol: &str, exchange: &str, cycle: &str, exp_rule: &str) -> ContractSpec {
ContractSpec {
symbol: symbol.to_string(),
exchange: exchange.to_string(),
description: None,
ticker_format: "{symbol}{month_code}{yy}".to_string(),
contract_cycle: cycle.to_string(),
expiration_rule: exp_rule.to_string(),
contract_multiplier: None,
tick_size: None,
currency: None,
aliases: vec![],
sessions: vec![],
exchange_timezone: None,
}
}
fn collect_eligible_months(
spec: &SpecRepository,
contract: &ContractSpec,
as_of: NaiveDate,
) -> Result<Vec<(i32, u32)>, String> {
let cycle = spec
.contract_cycles
.get(&contract.contract_cycle)
.ok_or_else(|| format!("unknown cycle {}", contract.contract_cycle))?;
let rule = spec
.expiration_rules
.get(&contract.expiration_rule)
.ok_or_else(|| format!("unknown rule {}", contract.expiration_rule))?;
let cal = get_calendar(&contract.exchange);
let mut eligible: Vec<(i32, u32)> = Vec::new();
for year in as_of.year()..as_of.year() + 4 {
let months = resolve_contract_months(cycle, year)?;
for month in months {
if !is_month_in_calendar_range(&cal, year, month) {
continue;
}
if month_sessions(&cal, year, month).is_empty() {
continue;
}
let expiration_date = match resolve_expiration(contract, year, month, rule, &cal) {
Ok(d) => d,
Err(_) => continue,
};
if still_tradeable(as_of, expiration_date, contract) {
eligible.push((year, month));
}
}
}
Ok(eligible)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParsedOptionTicker {
pub kind: String,
pub underlying_or_symbol: String,
pub year: i32,
pub month: u32,
pub is_call: bool,
pub strike: i64,
}
pub struct OptionGenerator {
pub spec: SpecRepository,
pub rules: Vec<OptionRule>,
}
impl OptionGenerator {
pub fn new(spec: SpecRepository, rules: Vec<OptionRule>) -> Self {
Self { spec, rules }
}
pub fn bundled() -> Result<Self, String> {
Self::from_spec_and_options_paths(None, None)
}
pub fn with_spec_root(spec_root: &std::path::Path) -> Result<Self, String> {
let options = spec_root.join("contracts").join("b3").join("options.yaml");
Self::from_spec_and_options_paths(Some(spec_root), Some(&options))
}
pub fn from_spec_and_options_paths(
spec_path: Option<&std::path::Path>,
options_path: Option<&std::path::Path>,
) -> Result<Self, String> {
let spec = match spec_path {
Some(p) => crate::spec_loader::load_spec_from_path(p)?,
None => crate::spec_loader::load_spec()?,
};
let rules = load_option_rules(options_path)?;
Ok(Self::new(spec, rules))
}
fn index_ibov_month_letter(month: u32) -> char {
"ABCDEFGHIJKL"
.chars()
.nth((month - 1) as usize)
.unwrap_or('A')
}
#[allow(clippy::too_many_arguments)]
fn gen_symbol_option(
&self,
rule_symbol: &str,
date: &str,
is_call: bool,
strike: i64,
offset: usize,
expiration_rule: &str,
contract_cycle: &str,
exchange: &str,
opt_codes: &OptionTypeCodes,
month_char: impl Fn(u32) -> Result<char, String>,
) -> Result<String, String> {
let as_of = parse_date(date)?;
let contract = synthetic_contract(rule_symbol, exchange, contract_cycle, expiration_rule);
let eligible = collect_eligible_months(&self.spec, &contract, as_of)?;
if offset >= eligible.len() {
return Err("offset out of range".to_string());
}
let (year, month) = eligible[offset];
let mc = month_char(month)?;
let yy = format!("{:02}", year.rem_euclid(100));
let ot = if is_call {
opt_codes.call.chars().next().unwrap()
} else {
opt_codes.put.chars().next().unwrap()
};
let strike_s = format!("{:06}", strike);
Ok(format!("{rule_symbol}{mc}{yy}{ot}{strike_s}"))
}
pub fn generate_equity(
&self,
underlying: &str,
date: &str,
is_call: bool,
strike: i64,
offset: usize,
) -> Result<String, String> {
let rule = self
.rules
.iter()
.find_map(|r| match r {
OptionRule::Equity(e) => Some(e),
_ => None,
})
.ok_or_else(|| "no equity option rule".to_string())?;
let u = underlying.to_uppercase();
if !rule.underlyings.iter().any(|x| x.eq_ignore_ascii_case(&u)) {
return Err(format!("underlying not listed: {underlying}"));
}
let as_of = parse_date(date)?;
let contract = synthetic_contract(
&u,
&rule.exchange,
&rule.contract_cycle,
&rule.expiration_rule,
);
let eligible = collect_eligible_months(&self.spec, &contract, as_of)?;
if offset >= eligible.len() {
return Err("offset out of range".to_string());
}
let (_year, month) = eligible[offset];
let mc = if is_call {
rule.call_month_codes
.get((month - 1) as usize)
.ok_or_else(|| "call month code".to_string())?
} else {
rule.put_month_codes
.get((month - 1) as usize)
.ok_or_else(|| "put month code".to_string())?
};
let ch = mc
.chars()
.next()
.ok_or_else(|| "empty month code".to_string())?;
let root = equity_root(&u);
Ok(format!("{root}{ch}{strike}"))
}
pub fn generate_index(
&self,
symbol: &str,
date: &str,
is_call: bool,
strike: i64,
offset: usize,
) -> Result<String, String> {
let rule = self
.rules
.iter()
.find_map(|r| match r {
OptionRule::Index(i) if i.symbol.eq_ignore_ascii_case(symbol) => Some(i),
_ => None,
})
.ok_or_else(|| format!("no index rule for {symbol}"))?;
self.gen_symbol_option(
&rule.symbol,
date,
is_call,
strike,
offset,
&rule.expiration_rule,
&rule.contract_cycle,
&rule.exchange,
&rule.option_type_codes,
|m| Ok(Self::index_ibov_month_letter(m)),
)
}
pub fn generate_dollar(
&self,
date: &str,
is_call: bool,
strike: i64,
offset: usize,
) -> Result<String, String> {
let rule = self
.rules
.iter()
.find_map(|r| match r {
OptionRule::Dollar(d) => Some(d),
_ => None,
})
.ok_or_else(|| "no dollar option rule".to_string())?;
self.gen_symbol_option(
&rule.symbol,
date,
is_call,
strike,
offset,
&rule.expiration_rule,
&rule.contract_cycle,
&rule.exchange,
&rule.option_type_codes,
|m| month_to_code(m).map_err(|e| e.to_string()),
)
}
pub fn generate_interest_rate(
&self,
date: &str,
is_call: bool,
strike: i64,
offset: usize,
) -> Result<String, String> {
let rule = self
.rules
.iter()
.find_map(|r| match r {
OptionRule::InterestRate(i) => Some(i),
_ => None,
})
.ok_or_else(|| "no interest rate option rule".to_string())?;
self.gen_symbol_option(
&rule.symbol,
date,
is_call,
strike,
offset,
&rule.expiration_rule,
&rule.contract_cycle,
&rule.exchange,
&rule.option_type_codes,
|m| month_to_code(m).map_err(|e| e.to_string()),
)
}
pub fn generate_from_row(
&self,
kind: &str,
underlying: &str,
date: &str,
opt_type: &str,
strike: i64,
offset: usize,
) -> Result<String, String> {
let is_call = opt_type.eq_ignore_ascii_case("call");
match kind {
"equity" => self.generate_equity(underlying, date, is_call, strike, offset),
"index" => self.generate_index(underlying, date, is_call, strike, offset),
"dollar" => self.generate_dollar(date, is_call, strike, offset),
"interest_rate" => self.generate_interest_rate(date, is_call, strike, offset),
_ => Err(format!("unknown option kind: {kind}")),
}
}
}
pub struct OptionParser;