use serde::{Deserialize, Deserializer, Serialize};
use std::collections::HashMap;
use std::sync::OnceLock;
use crate::options_models::OptionRule;
use crate::pattern_index::{build_pattern_index, PatternIndex};
use crate::schedule::ExchangeSchedule;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SessionSegment {
pub name: String,
pub start: String,
pub end: String,
}
fn mapping_to_segments(m: serde_yaml::Mapping) -> Result<Vec<SessionSegment>, String> {
let mut segments = Vec::new();
for (k, val) in m {
let name = k
.as_str()
.ok_or_else(|| "session key must be a string".to_string())?
.to_string();
let inner = match val {
serde_yaml::Value::Mapping(m) => m,
_ => return Err(format!("session '{name}' must be a mapping with start/end")),
};
let start = inner
.get(serde_yaml::Value::String("start".into()))
.and_then(|v| v.as_str())
.ok_or_else(|| format!("session '{name}' missing start"))?
.to_string();
let end = inner
.get(serde_yaml::Value::String("end".into()))
.and_then(|v| v.as_str())
.ok_or_else(|| format!("session '{name}' missing end"))?
.to_string();
segments.push(SessionSegment { name, start, end });
}
Ok(segments)
}
fn validate_sessions(segments: &[SessionSegment]) -> Result<(), String> {
if segments.is_empty() {
return Ok(());
}
if !segments[0].name.eq_ignore_ascii_case("regular") {
return Err("first session segment must be 'regular' (case-insensitive)".to_string());
}
Ok(())
}
fn deserialize_asset_sessions<'de, D>(deserializer: D) -> Result<Vec<SessionSegment>, D::Error>
where
D: Deserializer<'de>,
{
use serde::de::Error;
let v = serde_yaml::Value::deserialize(deserializer)?;
match v {
serde_yaml::Value::Mapping(m) => {
if m.is_empty() {
return Err(Error::custom("sessions must not be empty"));
}
let segments = mapping_to_segments(m).map_err(Error::custom)?;
validate_sessions(&segments).map_err(Error::custom)?;
Ok(segments)
}
_ => Err(Error::custom("sessions must be a YAML mapping")),
}
}
fn deserialize_contract_sessions<'de, D>(deserializer: D) -> Result<Vec<SessionSegment>, D::Error>
where
D: Deserializer<'de>,
{
use serde::de::Error;
let v = serde_yaml::Value::deserialize(deserializer)?;
match v {
serde_yaml::Value::Null => Ok(Vec::new()),
serde_yaml::Value::Mapping(m) => {
if m.is_empty() {
return Ok(Vec::new());
}
let segments = mapping_to_segments(m).map_err(Error::custom)?;
validate_sessions(&segments).map_err(Error::custom)?;
Ok(segments)
}
_ => Err(Error::custom("sessions must be a YAML mapping")),
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct Asset {
pub symbol: String,
#[serde(default)]
pub r#type: Option<String>,
#[serde(default)]
pub category: Option<String>,
#[serde(default)]
pub description: Option<String>,
#[serde(deserialize_with = "deserialize_asset_sessions")]
pub sessions: Vec<SessionSegment>,
}
impl Asset {
pub fn is_unique_session(&self) -> bool {
self.sessions.len() == 1
}
pub fn default_session(&self) -> Option<&SessionSegment> {
if self.sessions.len() == 1 {
self.sessions.first()
} else {
None
}
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct EquitySpec {
pub symbol: String,
pub exchange: String,
#[serde(default)]
pub r#type: Option<String>,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub currency: Option<String>,
#[serde(default)]
pub tick_size: Option<f64>,
#[serde(default, rename = "contract_standard")]
pub ctr_std: Option<u32>,
#[serde(default, rename = "contract_size")]
pub ctr_size: Option<f64>,
#[serde(default)]
pub aliases: Vec<String>,
#[serde(default, deserialize_with = "deserialize_asset_sessions")]
pub sessions: Vec<SessionSegment>,
#[serde(default)]
pub exchange_timezone: Option<String>,
}
impl EquitySpec {
pub fn regular_session(&self) -> Option<&SessionSegment> {
self.sessions.first()
}
pub fn is_unique_session(&self) -> bool {
self.sessions.len() == 1
}
pub fn default_session(&self) -> Option<&SessionSegment> {
if self.sessions.len() == 1 {
self.sessions.first()
} else {
None
}
}
pub fn regular_session_start_end(&self) -> Option<(&str, &str)> {
let seg = self.regular_session()?;
Some((seg.start.as_str(), seg.end.as_str()))
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct Exchange {
pub code: String,
#[serde(default)]
pub mic: Option<String>,
#[serde(default)]
pub full_name: Option<String>,
#[serde(default)]
pub country: Option<String>,
#[serde(default)]
pub timezone: Option<String>,
#[serde(default)]
pub assets: HashMap<String, Asset>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct ContractCycle {
#[serde(default)]
pub name: String,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub months: Vec<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct ExpirationRule {
#[serde(default)]
pub name: String,
pub r#type: String,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub weekday: Option<String>,
#[serde(default)]
pub day: Option<i32>,
#[serde(default)]
pub n: Option<i32>,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default)]
pub last_trading_day: Option<String>,
#[serde(default)]
pub last_trading_day_offset: Option<i32>,
#[serde(default)]
pub roll_on_last_trading_day: Option<bool>,
}
impl ExpirationRule {
pub fn effective_last_trading_day_offset(&self) -> i32 {
if let Some(offset) = self.last_trading_day_offset {
return offset;
}
match self.last_trading_day.as_deref() {
Some("prior_business_day") | Some("previous_business_day") => -1,
Some("same_day") => 0,
_ => {
if self.r#type == "first_business_day" {
-1
} else {
0
}
}
}
}
pub fn should_roll_on_last_trading_day(&self) -> bool {
if let Some(roll) = self.roll_on_last_trading_day {
return roll;
}
self.effective_last_trading_day_offset() < 0
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct ContractSpec {
pub symbol: String,
pub exchange: String,
#[serde(default)]
pub description: Option<String>,
#[serde(default = "default_ticker_format")]
pub ticker_format: String,
pub contract_cycle: String,
pub expiration_rule: String,
#[serde(default, rename = "contract_standard")]
pub ctr_std: Option<u32>,
#[serde(default, rename = "contract_size")]
pub ctr_size: Option<f64>,
#[serde(default)]
pub tick_size: Option<f64>,
#[serde(default)]
pub currency: Option<String>,
#[serde(default)]
pub aliases: Vec<String>,
#[serde(default, deserialize_with = "deserialize_contract_sessions")]
pub sessions: Vec<SessionSegment>,
#[serde(default)]
pub exchange_timezone: Option<String>,
}
impl ContractSpec {
pub fn regular_session(&self) -> Option<&SessionSegment> {
self.sessions.first()
}
pub fn is_unique_session(&self) -> bool {
self.sessions.len() == 1
}
pub fn default_session(&self) -> Option<&SessionSegment> {
if self.sessions.len() == 1 {
self.sessions.first()
} else {
None
}
}
pub fn regular_session_start_end(&self) -> Option<(&str, &str)> {
let seg = self.regular_session()?;
Some((seg.start.as_str(), seg.end.as_str()))
}
}
fn default_ticker_format() -> String {
"{symbol}{month_code}{yy}".to_string()
}
#[derive(Debug, Clone)]
pub struct SpecRepository {
pub exchanges: HashMap<String, Exchange>,
pub contracts: HashMap<String, ContractSpec>,
pub options: Vec<OptionRule>,
pub equities: HashMap<String, EquitySpec>,
pub contract_cycles: HashMap<String, ContractCycle>,
pub expiration_rules: HashMap<String, ExpirationRule>,
pub schedules: HashMap<String, ExchangeSchedule>,
#[doc(hidden)]
pub pattern_index: OnceLock<PatternIndex>,
}
impl SpecRepository {
pub fn get_exchange(&self, code: &str) -> Result<&Exchange, String> {
let key = code.to_uppercase();
self.exchanges
.get(&key)
.ok_or_else(|| format!("Unknown exchange: {code}"))
}
pub fn get_contract(&self, symbol: &str) -> Result<&ContractSpec, String> {
let key = symbol.to_uppercase();
self.contracts
.get(&key)
.ok_or_else(|| format!("Unknown contract: {symbol}"))
}
pub fn get_equity(&self, symbol: &str) -> Result<&EquitySpec, String> {
let key = symbol.to_uppercase();
self.equities
.get(&key)
.ok_or_else(|| format!("Unknown equity: {symbol}"))
}
pub fn pattern_index(&self) -> &PatternIndex {
self.pattern_index.get_or_init(|| build_pattern_index(self))
}
}
#[derive(Debug, Clone)]
pub struct ParsedFuturesTicker {
pub symbol: String,
pub year: i32,
pub month: u32,
pub tick_size: Option<f64>,
pub ctr_std: Option<u32>,
pub ctr_size: Option<f64>,
pub contract: ContractSpec,
pub reference_date: Option<chrono::NaiveDate>,
pub is_trading_session: Option<bool>,
pub contract_offset: Option<isize>,
pub is_valid: Option<bool>,
}
impl ParsedFuturesTicker {
pub fn format_ticker(&self) -> Result<String, String> {
crate::ticker_generator::format_contract_ticker(&self.contract, self.year, self.month)
}
pub fn ticker(&self) -> Result<String, String> {
self.format_ticker()
}
}
#[derive(Debug, Clone)]
pub struct ParsedEquityTicker {
pub symbol: String,
pub equity: EquitySpec,
}
impl ParsedEquityTicker {
pub fn format_ticker(&self) -> String {
self.symbol.clone()
}
pub fn ticker(&self) -> String {
self.format_ticker()
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct ParsedOptionTicker {
pub kind: String,
pub underlying_or_symbol: String,
pub year: Option<i32>,
pub month: u32,
pub is_call: bool,
pub strike: String,
pub exchange: String,
pub tick_size: Option<f64>,
pub ctr_std: Option<u32>,
pub ctr_size: Option<f64>,
}
impl ParsedOptionTicker {
pub fn format_ticker(&self, spec: &SpecRepository) -> Result<String, String> {
crate::options_ticker::format_parsed_option_ticker(self, spec)
}
pub fn ticker(&self, spec: &SpecRepository) -> Result<String, String> {
self.format_ticker(spec)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_contract_spec(sessions: Vec<SessionSegment>) -> ContractSpec {
ContractSpec {
symbol: "Y".into(),
exchange: "B3".into(),
description: None,
ticker_format: default_ticker_format(),
contract_cycle: "m".into(),
expiration_rule: "r".into(),
ctr_std: None,
ctr_size: None,
tick_size: None,
currency: None,
aliases: vec![],
sessions,
exchange_timezone: None,
}
}
#[test]
fn default_session_is_some_only_for_single_segment() {
let one = sample_contract_spec(vec![SessionSegment {
name: "regular".into(),
start: "09:00".into(),
end: "18:00".into(),
}]);
assert!(one.is_unique_session());
assert_eq!(
one.default_session().map(|s| s.name.as_str()),
Some("regular")
);
let multi = sample_contract_spec(vec![
SessionSegment {
name: "regular".into(),
start: "09:00".into(),
end: "12:00".into(),
},
SessionSegment {
name: "afternoon".into(),
start: "13:00".into(),
end: "18:00".into(),
},
]);
assert!(!multi.is_unique_session());
assert!(multi.default_session().is_none());
}
#[test]
fn empty_contract_spec_sessions_no_default() {
let empty = sample_contract_spec(vec![]);
assert!(!empty.is_unique_session());
assert!(empty.default_session().is_none());
}
}