1use std::collections::{BTreeSet, HashMap};
7use std::fs;
8use std::path::Path;
9
10use chrono::{Datelike, NaiveDate, Weekday};
11use serde::Deserialize;
12
13#[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
93fn 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
115fn 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#[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
274pub 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}