Skip to main content

tickerforge/
schedule.rs

1//! Rule-based exchange schedule engine.
2//!
3//! Loads holiday rules from `spec/schedules/<exchange>.yaml` and evaluates them
4//! for any year using the Computus algorithm for Easter-relative holidays.
5
6use std::collections::{BTreeSet, HashMap};
7use std::fs;
8use std::path::Path;
9
10use chrono::{Datelike, NaiveDate, Weekday};
11use serde::Deserialize;
12
13// ---------------------------------------------------------------------------
14// YAML model
15// ---------------------------------------------------------------------------
16
17#[derive(Debug, Clone, Deserialize)]
18pub struct ScheduleYaml {
19    pub exchange: String,
20    pub timezone: String,
21    #[serde(default)]
22    pub holidays: HolidayRules,
23    #[serde(default)]
24    pub early_closes: Option<EarlyCloseRules>,
25}
26
27#[derive(Debug, Clone, Default, Deserialize)]
28pub struct HolidayRules {
29    #[serde(default)]
30    pub fixed: Vec<FixedRule>,
31    #[serde(default)]
32    pub easter_offset: Vec<EasterOffsetRule>,
33    #[serde(default)]
34    pub nth_weekday: Vec<NthWeekdayRule>,
35    #[serde(default)]
36    pub last_weekday: Vec<LastWeekdayRule>,
37    #[serde(default)]
38    pub overrides: Vec<OverrideRule>,
39}
40
41#[derive(Debug, Clone, Deserialize)]
42pub struct FixedRule {
43    pub month: u32,
44    pub day: u32,
45    pub name: String,
46    pub from_year: Option<i32>,
47    pub to_year: Option<i32>,
48}
49
50#[derive(Debug, Clone, Deserialize)]
51pub struct EasterOffsetRule {
52    pub offset: i32,
53    pub name: String,
54    pub from_year: Option<i32>,
55    pub to_year: Option<i32>,
56}
57
58#[derive(Debug, Clone, Deserialize)]
59pub struct NthWeekdayRule {
60    pub month: u32,
61    pub weekday: String,
62    pub nth: u32,
63    pub name: String,
64    pub from_year: Option<i32>,
65    pub to_year: Option<i32>,
66}
67
68#[derive(Debug, Clone, Deserialize)]
69pub struct LastWeekdayRule {
70    pub month: u32,
71    pub weekday: String,
72    pub name: String,
73    pub from_year: Option<i32>,
74    pub to_year: Option<i32>,
75}
76
77#[derive(Debug, Clone, Deserialize)]
78pub struct OverrideRule {
79    pub date: String,
80    pub action: String,
81    #[serde(default)]
82    pub name: Option<String>,
83}
84
85#[derive(Debug, Clone, Default, Deserialize)]
86pub struct EarlyCloseRules {
87    #[serde(default)]
88    pub fixed: Vec<serde_yaml::Value>,
89    #[serde(default)]
90    pub easter_offset: Vec<serde_yaml::Value>,
91}
92
93// ---------------------------------------------------------------------------
94// Computus (Anonymous Gregorian algorithm)
95// ---------------------------------------------------------------------------
96
97fn easter_sunday(year: i32) -> NaiveDate {
98    let a = year % 19;
99    let b = year / 100;
100    let c = year % 100;
101    let d = b / 4;
102    let e = b % 4;
103    let f = (b + 8) / 25;
104    let g = (b - f + 1) / 3;
105    let h = (19 * a + b - d - g + 15) % 30;
106    let i = c / 4;
107    let k = c % 4;
108    let l = (32 + 2 * e + 2 * i - h - k) % 7;
109    let m = (a + 11 * h + 22 * l) / 451;
110    let month = (h + l - 7 * m + 114) / 31;
111    let day = ((h + l - 7 * m + 114) % 31) + 1;
112    NaiveDate::from_ymd_opt(year, month as u32, day as u32).unwrap()
113}
114
115// ---------------------------------------------------------------------------
116// Weekday helpers
117// ---------------------------------------------------------------------------
118
119fn weekday_from_name(name: &str) -> Option<Weekday> {
120    match name.to_lowercase().as_str() {
121        "monday" => Some(Weekday::Mon),
122        "tuesday" => Some(Weekday::Tue),
123        "wednesday" => Some(Weekday::Wed),
124        "thursday" => Some(Weekday::Thu),
125        "friday" => Some(Weekday::Fri),
126        _ => None,
127    }
128}
129
130fn nth_weekday_of_month(year: i32, month: u32, weekday: Weekday, nth: u32) -> NaiveDate {
131    let first = NaiveDate::from_ymd_opt(year, month, 1).unwrap();
132    let diff = (weekday.num_days_from_monday() as i32
133        - first.weekday().num_days_from_monday() as i32)
134        .rem_euclid(7);
135    let first_occ = first + chrono::Duration::days(diff as i64);
136    first_occ + chrono::Duration::weeks((nth - 1) as i64)
137}
138
139fn last_weekday_of_month(year: i32, month: u32, weekday: Weekday) -> NaiveDate {
140    let last_day = if month == 12 {
141        NaiveDate::from_ymd_opt(year + 1, 1, 1).unwrap() - chrono::Duration::days(1)
142    } else {
143        NaiveDate::from_ymd_opt(year, month + 1, 1).unwrap() - chrono::Duration::days(1)
144    };
145    let diff = (last_day.weekday().num_days_from_monday() as i32
146        - weekday.num_days_from_monday() as i32)
147        .rem_euclid(7);
148    last_day - chrono::Duration::days(diff as i64)
149}
150
151fn rule_applies(from_year: Option<i32>, to_year: Option<i32>, year: i32) -> bool {
152    if let Some(fy) = from_year {
153        if year < fy {
154            return false;
155        }
156    }
157    if let Some(ty) = to_year {
158        if year > ty {
159            return false;
160        }
161    }
162    true
163}
164
165// ---------------------------------------------------------------------------
166// ExchangeSchedule
167// ---------------------------------------------------------------------------
168
169#[derive(Debug, Clone)]
170pub struct ExchangeSchedule {
171    pub exchange: String,
172    pub timezone: String,
173    rules: HolidayRules,
174    holiday_cache: HashMap<i32, BTreeSet<NaiveDate>>,
175}
176
177impl ExchangeSchedule {
178    pub fn from_yaml(data: ScheduleYaml) -> Self {
179        ExchangeSchedule {
180            exchange: data.exchange,
181            timezone: data.timezone,
182            rules: data.holidays,
183            holiday_cache: HashMap::new(),
184        }
185    }
186
187    pub fn holidays_for_year(&mut self, year: i32) -> &BTreeSet<NaiveDate> {
188        if self.holiday_cache.contains_key(&year) {
189            return &self.holiday_cache[&year];
190        }
191
192        let mut holidays = BTreeSet::new();
193        let easter = easter_sunday(year);
194
195        for rule in &self.rules.fixed {
196            if !rule_applies(rule.from_year, rule.to_year, year) {
197                continue;
198            }
199            if let Some(d) = NaiveDate::from_ymd_opt(year, rule.month, rule.day) {
200                holidays.insert(d);
201            }
202        }
203
204        for rule in &self.rules.easter_offset {
205            if !rule_applies(rule.from_year, rule.to_year, year) {
206                continue;
207            }
208            holidays.insert(easter + chrono::Duration::days(rule.offset as i64));
209        }
210
211        for rule in &self.rules.nth_weekday {
212            if !rule_applies(rule.from_year, rule.to_year, year) {
213                continue;
214            }
215            if let Some(wd) = weekday_from_name(&rule.weekday) {
216                holidays.insert(nth_weekday_of_month(year, rule.month, wd, rule.nth));
217            }
218        }
219
220        for rule in &self.rules.last_weekday {
221            if !rule_applies(rule.from_year, rule.to_year, year) {
222                continue;
223            }
224            if let Some(wd) = weekday_from_name(&rule.weekday) {
225                holidays.insert(last_weekday_of_month(year, rule.month, wd));
226            }
227        }
228
229        for rule in &self.rules.overrides {
230            if let Ok(d) = NaiveDate::parse_from_str(&rule.date, "%Y-%m-%d") {
231                if d.year() != year {
232                    continue;
233                }
234                match rule.action.as_str() {
235                    "add" => {
236                        holidays.insert(d);
237                    }
238                    "remove" => {
239                        holidays.remove(&d);
240                    }
241                    _ => {}
242                }
243            }
244        }
245
246        holidays.retain(|d| d.weekday() != Weekday::Sat && d.weekday() != Weekday::Sun);
247        self.holiday_cache.insert(year, holidays);
248        &self.holiday_cache[&year]
249    }
250
251    pub fn is_session(&mut self, d: NaiveDate) -> bool {
252        if d.weekday() == Weekday::Sat || d.weekday() == Weekday::Sun {
253            return false;
254        }
255        !self.holidays_for_year(d.year()).contains(&d)
256    }
257
258    pub fn sessions_in_range(&mut self, start: NaiveDate, end: NaiveDate) -> Vec<NaiveDate> {
259        let mut result = Vec::new();
260        let mut d = start;
261        while d <= end {
262            if self.is_session(d) {
263                result.push(d);
264            }
265            d = match d.succ_opt() {
266                Some(next) => next,
267                None => break,
268            };
269        }
270        result
271    }
272}
273
274// ---------------------------------------------------------------------------
275// Loading
276// ---------------------------------------------------------------------------
277
278pub fn load_schedule(path: &Path) -> Result<ExchangeSchedule, String> {
279    let raw = fs::read_to_string(path).map_err(|e| format!("read {}: {e}", path.display()))?;
280    let data: ScheduleYaml =
281        serde_yaml::from_str(&raw).map_err(|e| format!("YAML {}: {e}", path.display()))?;
282    Ok(ExchangeSchedule::from_yaml(data))
283}
284
285pub fn load_schedules(spec_root: &Path) -> Result<HashMap<String, ExchangeSchedule>, String> {
286    let schedules_dir = spec_root.join("schedules");
287    let mut result = HashMap::new();
288    if !schedules_dir.is_dir() {
289        return Ok(result);
290    }
291    let mut paths: Vec<_> = fs::read_dir(&schedules_dir)
292        .map_err(|e| format!("read schedules dir: {e}"))?
293        .filter_map(|e| e.ok())
294        .map(|e| e.path())
295        .filter(|p| p.extension().map(|x| x == "yaml").unwrap_or(false))
296        .collect();
297    paths.sort();
298
299    for yaml_path in paths {
300        let schedule = load_schedule(&yaml_path)?;
301        result.insert(schedule.exchange.to_uppercase(), schedule);
302    }
303    Ok(result)
304}
305
306#[cfg(test)]
307mod tests {
308    use super::*;
309
310    #[test]
311    fn easter_2026() {
312        assert_eq!(
313            easter_sunday(2026),
314            NaiveDate::from_ymd_opt(2026, 4, 5).unwrap()
315        );
316    }
317
318    #[test]
319    fn easter_2024() {
320        assert_eq!(
321            easter_sunday(2024),
322            NaiveDate::from_ymd_opt(2024, 3, 31).unwrap()
323        );
324    }
325
326    #[test]
327    fn easter_2023() {
328        assert_eq!(
329            easter_sunday(2023),
330            NaiveDate::from_ymd_opt(2023, 4, 9).unwrap()
331        );
332    }
333}