use std::collections::{HashMap, HashSet};
use jiff::Timestamp;
use jiff::civil::Date;
use jiff::tz::TimeZone;
use crate::error::{PondError, Result};
use crate::series::{Column, TimeSeries};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SessionBreak {
pub start: i64,
pub end: i64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Session {
pub date: String,
pub open: i64,
pub close: i64,
pub breaks: Vec<SessionBreak>,
}
impl Session {
pub fn new(date: &str, open: i64, close: i64) -> Self {
Self {
date: date.to_string(),
open,
close,
breaks: Vec::new(),
}
}
pub fn with_break(mut self, start: i64, end: i64) -> Self {
self.breaks.push(SessionBreak { start, end });
self
}
}
pub fn normalize_sessions(sessions: impl IntoIterator<Item = Session>) -> Result<Vec<Session>> {
const CALL: &str = "normalize_sessions";
let err = |message: String| PondError::InvalidData {
call: CALL,
message,
};
let mut sessions: Vec<Session> = sessions.into_iter().collect();
for s in &sessions {
if !is_iso_date(&s.date) {
return Err(err(format!(
"session date must be an ISO YYYY-MM-DD string; got {}",
json_string(&s.date)
)));
}
if s.close <= s.open {
return Err(err(format!(
"session {}: close ({}) must be > open ({})",
s.date, s.close, s.open
)));
}
let mut prev_end = s.open;
for b in &s.breaks {
if b.end <= b.start {
return Err(err(format!(
"session {}: break end must be > start",
s.date
)));
}
if b.start < prev_end || b.end > s.close {
return Err(err(format!(
"session {}: breaks must be sorted, non-overlapping, and within [open, close]",
s.date
)));
}
prev_end = b.end;
}
}
sessions.sort_by_key(|s| s.open);
for pair in sessions.windows(2) {
let (prev, cur) = (&pair[0], &pair[1]);
if cur.date == prev.date {
return Err(err(format!("duplicate session date: {}", cur.date)));
}
if cur.open < prev.close {
return Err(err(format!(
"sessions overlap: {} [{}, {}) and {} [{}, {})",
prev.date, prev.open, prev.close, cur.date, cur.open, cur.close
)));
}
if cur.date < prev.date {
return Err(err(format!(
"session date order disagrees with open times: {} opens before {}",
prev.date, cur.date
)));
}
}
Ok(sessions)
}
fn is_iso_date(date: &str) -> bool {
let b = date.as_bytes();
b.len() == 10
&& b.iter().enumerate().all(|(i, c)| match i {
4 | 7 => *c == b'-',
_ => c.is_ascii_digit(),
})
}
fn json_string(text: &str) -> String {
serde_json::to_string(text).unwrap_or_default()
}
#[derive(Debug, Clone, PartialEq)]
pub struct SessionRules {
time_zone: String,
open: String,
close: String,
weekmask: Option<Vec<u8>>,
breaks: Vec<(String, String)>,
holidays: Vec<String>,
early_closes: Vec<(String, String)>,
}
impl SessionRules {
pub fn new(time_zone: &str, open: &str, close: &str) -> Self {
Self {
time_zone: time_zone.to_string(),
open: open.to_string(),
close: close.to_string(),
weekmask: None,
breaks: Vec::new(),
holidays: Vec::new(),
early_closes: Vec::new(),
}
}
pub fn weekmask(mut self, days: &[u8]) -> Self {
self.weekmask = Some(days.to_vec());
self
}
pub fn with_break(mut self, start: &str, end: &str) -> Self {
self.breaks.push((start.to_string(), end.to_string()));
self
}
pub fn holiday(mut self, date: &str) -> Self {
self.holidays.push(date.to_string());
self
}
pub fn holidays<I, D>(mut self, dates: I) -> Self
where
I: IntoIterator<Item = D>,
D: AsRef<str>,
{
self.holidays
.extend(dates.into_iter().map(|d| d.as_ref().to_string()));
self
}
pub fn early_close(mut self, date: &str, close: &str) -> Self {
self.early_closes
.push((date.to_string(), close.to_string()));
self
}
pub fn time_zone(&self) -> &str {
&self.time_zone
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DateBound {
Date(String),
At(i64),
}
impl From<&str> for DateBound {
fn from(date: &str) -> Self {
DateBound::Date(date.to_string())
}
}
impl From<String> for DateBound {
fn from(date: String) -> Self {
DateBound::Date(date)
}
}
impl From<i64> for DateBound {
fn from(at: i64) -> Self {
DateBound::At(at)
}
}
pub fn generate_sessions(
rules: &SessionRules,
from: impl Into<DateBound>,
to: impl Into<DateBound>,
) -> Result<Vec<Session>> {
generate(rules, &from.into(), &to.into())
}
const GENERATE: &str = "generate_sessions";
fn generate_error(message: impl Into<String>) -> PondError {
PondError::InvalidData {
call: GENERATE,
message: message.into(),
}
}
fn generate(rules: &SessionRules, from: &DateBound, to: &DateBound) -> Result<Vec<Session>> {
let mut cached: Option<TimeZone> = None;
let mut zone = || -> Result<TimeZone> {
if let Some(z) = &cached {
return Ok(z.clone());
}
let z = time_zone(GENERATE, &rules.time_zone)?;
cached = Some(z.clone());
Ok(z)
};
let weekmask: &[u8] = rules.weekmask.as_deref().unwrap_or(&[1, 2, 3, 4, 5]);
let holidays: HashSet<&str> = rules.holidays.iter().map(String::as_str).collect();
let early_closes: HashMap<&str, &String> = rules
.early_closes
.iter()
.map(|(d, c)| (d.as_str(), c))
.collect();
let open_min = parse_minutes(&rules.open)?;
let regular_close_min = parse_minutes(&rules.close)?;
if regular_close_min <= open_min {
return Err(generate_error(format!(
"SessionRules close ({}) must be after open ({}); overnight sessions are not supported by rules — use an explicit session list.",
rules.close, rules.open
)));
}
let mut break_mins = Vec::with_capacity(rules.breaks.len());
for (start, end) in &rules.breaks {
break_mins.push((parse_minutes(start)?, parse_minutes(end)?));
}
break_mins.sort_by_key(|b| b.0);
let mut prev_end = open_min;
for &(start, end) in &break_mins {
if end <= start {
return Err(generate_error("break end must be after start"));
}
if start < prev_end || end > regular_close_min {
return Err(generate_error(
"breaks must be within [open, close], sorted, and non-overlapping",
));
}
prev_end = end;
}
let start = plain_date(from, &mut zone)?;
let end = plain_date(to, &mut zone)?;
if end < start {
return Err(generate_error("range.to must be on or after range.from"));
}
let mut sessions = Vec::new();
let mut date = start;
loop {
let weekday = date.weekday().to_monday_one_offset();
if weekmask.iter().any(|&d| i16::from(d) == i16::from(weekday)) {
let iso = date.to_string();
if !holidays.contains(iso.as_str()) {
let early = early_closes.get(iso.as_str()).copied();
let close_min = match early {
Some(close) => parse_minutes(close)?,
None => regular_close_min,
};
if close_min <= open_min {
return Err(generate_error(format!(
"early close {} on {iso} is not after open {}",
early.map_or("", String::as_str),
rules.open
)));
}
let tz = zone()?;
let open = instant_at(date, open_min, &tz)?;
let close = instant_at(date, close_min, &tz)?;
let mut breaks = Vec::new();
for &(start, end) in break_mins.iter().filter(|b| b.1 <= close_min) {
breaks.push(SessionBreak {
start: instant_at(date, start, &tz)?,
end: instant_at(date, end, &tz)?,
});
}
sessions.push(Session {
date: iso,
open,
close,
breaks,
});
}
}
if date >= end {
break;
}
date = date
.tomorrow()
.map_err(|e| generate_error(format!("date out of range: {e}")))?;
}
Ok(sessions)
}
fn parse_minutes(time: &str) -> Result<u32> {
let b = time.as_bytes();
let well_formed =
b.len() == 5 && b[2] == b':' && [0, 1, 3, 4].iter().all(|&i| b[i].is_ascii_digit());
if !well_formed {
return Err(generate_error(format!(
"time must be \"HH:MM\"; got {}",
json_string(time)
)));
}
let digit = |i: usize| u32::from(b[i] - b'0');
let (hours, minutes) = (digit(0) * 10 + digit(1), digit(3) * 10 + digit(4));
if minutes > 59 {
return Err(generate_error(format!(
"minutes out of range in {}",
json_string(time)
)));
}
let total = hours * 60 + minutes;
if total > 1440 {
return Err(generate_error(format!(
"time past 24:00 in {}",
json_string(time)
)));
}
Ok(total)
}
fn instant_at(date: Date, minutes: u32, zone: &TimeZone) -> Result<i64> {
let bad = |e: jiff::Error| generate_error(format!("cannot place {date} in the time zone: {e}"));
let zoned = if minutes == 1440 {
date.tomorrow()
.and_then(|d| d.to_zoned(zone.clone()))
.and_then(|z| z.start_of_day())
.map_err(bad)?
} else {
let (h, m) = ((minutes / 60) as i8, (minutes % 60) as i8);
date.at(h, m, 0, 0).to_zoned(zone.clone()).map_err(bad)?
};
Ok(zoned.timestamp().as_millisecond())
}
fn plain_date(bound: &DateBound, zone: &mut impl FnMut() -> Result<TimeZone>) -> Result<Date> {
match bound {
DateBound::Date(text) => {
if let Some(at) = text.find("u-ca=") {
let value = text[at + 5..].split(']').next().unwrap_or_default();
if !value.eq_ignore_ascii_case("iso8601") {
return Err(generate_error(format!(
"cannot read {} as a date: only the ISO calendar is supported",
json_string(text)
)));
}
}
text.parse::<Date>().map_err(|e| {
generate_error(format!("cannot read {} as a date: {e}", json_string(text)))
})
}
DateBound::At(ms) => {
let at = Timestamp::from_millisecond(*ms)
.map_err(|e| generate_error(format!("instant {ms} out of range: {e}")))?;
Ok(at.to_zoned(zone()?).date())
}
}
}
fn time_zone(call: &'static str, name: &str) -> Result<TimeZone> {
match crate::sequence::fixed_offset(name) {
Some(zone) => Ok(zone),
None => TimeZone::get(name).map_err(|e| PondError::InvalidData {
call,
message: format!("unknown time zone '{name}': {e}"),
}),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Stamped {
#[default]
Open,
Close,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SessionRef<'a> {
At(i64),
Date(&'a str),
}
impl From<i64> for SessionRef<'_> {
fn from(at: i64) -> Self {
SessionRef::At(at)
}
}
impl<'a> From<&'a str> for SessionRef<'a> {
fn from(date: &'a str) -> Self {
SessionRef::Date(date)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct TagSessions {
column: Option<String>,
stamped: Stamped,
}
impl TagSessions {
pub fn column(mut self, name: &str) -> Self {
self.column = Some(name.to_string());
self
}
pub fn stamped(mut self, stamped: Stamped) -> Self {
self.stamped = stamped;
self
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct TradingCalendar {
sessions: Vec<Session>,
opens: Vec<i64>,
time_zone: Option<String>,
}
impl TradingCalendar {
pub fn from_sessions(sessions: impl IntoIterator<Item = Session>) -> Result<Self> {
let sessions = normalize_sessions(sessions)
.map_err(|e| e.renamed("TradingCalendar::from_sessions"))?;
Ok(Self::build(sessions, None))
}
pub fn from_rules(
rules: &SessionRules,
from: impl Into<DateBound>,
to: impl Into<DateBound>,
) -> Result<Self> {
const CALL: &str = "TradingCalendar::from_rules";
let sessions = generate(rules, &from.into(), &to.into()).map_err(|e| e.renamed(CALL))?;
let sessions = normalize_sessions(sessions).map_err(|e| e.renamed(CALL))?;
Ok(Self::build(sessions, Some(rules.time_zone.clone())))
}
fn build(sessions: Vec<Session>, time_zone: Option<String>) -> Self {
let opens = sessions.iter().map(|s| s.open).collect();
Self {
sessions,
opens,
time_zone,
}
}
pub fn with_time_zone(mut self, time_zone: &str) -> Self {
self.time_zone = Some(time_zone.to_string());
self
}
pub fn time_zone(&self) -> Option<&str> {
self.time_zone.as_deref()
}
pub fn sessions(&self) -> &[Session] {
&self.sessions
}
pub fn len(&self) -> usize {
self.sessions.len()
}
pub fn is_empty(&self) -> bool {
self.sessions.is_empty()
}
pub fn session_on(&self, date: &str) -> Option<&Session> {
self.sessions
.binary_search_by(|s| s.date.as_str().cmp(date))
.ok()
.map(|i| &self.sessions[i])
}
pub fn is_trading_day(&self, date: &str) -> bool {
self.session_on(date).is_some()
}
pub fn session_containing(&self, at: i64) -> Option<&Session> {
let i = self.opens.partition_point(|&o| o <= at);
let s = self.sessions.get(i.checked_sub(1)?)?;
(at < s.close).then_some(s)
}
pub fn is_open(&self, at: i64) -> bool {
self.session_containing(at)
.is_some_and(|s| !s.breaks.iter().any(|b| at >= b.start && at < b.end))
}
pub fn sessions_in_range(&self, start: i64, end: i64) -> &[Session] {
if end <= start {
return &[];
}
let first = self
.opens
.partition_point(|&o| o <= start)
.saturating_sub(1);
let first = if self.sessions.get(first).is_some_and(|s| s.close <= start) {
first + 1
} else {
first
};
let last = self.opens.partition_point(|&o| o < end);
&self.sessions[first.min(last)..last]
}
pub fn next_session<'a>(&self, from: impl Into<SessionRef<'a>>) -> Option<&Session> {
let i = match from.into() {
SessionRef::At(at) => self.opens.partition_point(|&o| o <= at),
SessionRef::Date(date) => self.sessions.partition_point(|s| s.date.as_str() <= date),
};
self.sessions.get(i)
}
pub fn previous_session<'a>(&self, from: impl Into<SessionRef<'a>>) -> Option<&Session> {
let i = match from.into() {
SessionRef::At(at) => self.opens.partition_point(|&o| o < at),
SessionRef::Date(date) => self.sessions.partition_point(|s| s.date.as_str() < date),
};
self.sessions.get(i.checked_sub(1)?)
}
pub fn tag_sessions(&self, series: &TimeSeries, options: TagSessions) -> Result<TimeSeries> {
let column = options.column.as_deref().unwrap_or("session");
let ids = session_id_values(series.key().begin(), &self.sessions, options.stamped);
series
.with_column(Column::optional(column, ids))
.map_err(|e| e.renamed("TradingCalendar::tag_sessions"))
}
}
pub(super) fn session_id_values(times: &[i64], sessions: &[Session], stamped: Stamped) -> Vec<f64> {
let close_stamped = stamped == Stamped::Close;
let mut c = 0;
times
.iter()
.map(|&t| {
while c < sessions.len()
&& (if close_stamped {
sessions[c].close < t
} else {
sessions[c].close <= t
})
{
c += 1;
}
match sessions.get(c) {
Some(s)
if (if close_stamped {
t > s.open
} else {
t >= s.open
}) =>
{
s.open as f64
}
_ => f64::NAN,
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn minutes_parse_as_pond_ts() {
assert_eq!(parse_minutes("00:00").unwrap(), 0);
assert_eq!(parse_minutes("24:00").unwrap(), 1440);
assert_eq!(parse_minutes("09:30").unwrap(), 570);
for (bad, why) in [
("9:30", "must be \"HH:MM\""),
("09:3", "must be \"HH:MM\""),
("09-30", "must be \"HH:MM\""),
("09:30", "must be \"HH:MM\""),
("23:60", "minutes out of range"),
("99:99", "minutes out of range"),
("24:01", "past 24:00"),
("25:00", "past 24:00"),
] {
let e = parse_minutes(bad).unwrap_err().to_string();
assert!(e.contains(why), "{bad}: {e}");
}
}
#[test]
fn errors_come_in_pond_ts_order() {
let base = || SessionRules::new("Nowhere/Zone", "09:30", "16:00");
let msg = |r: &SessionRules, from: &str, to: &str| {
generate_sessions(r, from, to).unwrap_err().to_string()
};
assert_eq!(
generate_sessions(&base(), "2024-01-06", "2024-01-07").unwrap(),
vec![]
);
assert!(msg(&base(), "2024-01-08", "2024-01-08").contains("unknown time zone"));
let late_open = SessionRules::new("Nowhere/Zone", "16:00", "16:00").with_break("x", "y");
assert!(msg(&late_open, "x", "y").contains("must be after open"));
let bad_break = base().with_break("12:00", "x");
assert!(msg(&bad_break, "x", "y").contains("\"x\""));
let backwards = base().with_break("12:00", "11:00");
assert!(msg(&backwards, "x", "y").contains("break end must be after start"));
assert!(msg(&base(), "x", "2024-01-08").contains("as a date"));
assert!(msg(&base(), "2024-01-09", "2024-01-08").contains("range.to"));
let early = base()
.holiday("2024-01-08")
.early_close("2024-01-08", "bad");
assert!(generate_sessions(&early, "2024-01-06", "2024-01-08").is_ok());
let early = base().early_close("2024-01-08", "09:00");
assert!(msg(&early, "2024-01-08", "2024-01-08").contains("early close 09:00"));
}
#[test]
fn range_keys_tag_by_their_start() {
use crate::TimeRangeKeys;
let cal = TradingCalendar::from_sessions([Session::new("2024-01-08", 100, 200)]).unwrap();
let bars = TimeSeries::from_time_ranges(
"bars",
TimeRangeKeys {
begin: vec![50, 100, 199],
end: vec![150, 250, 400],
},
vec![Column::required("close", vec![1.0, 2.0, 3.0])],
)
.unwrap();
let tagged = cal.tag_sessions(&bars, TagSessions::default()).unwrap();
let ids: Vec<_> = tagged
.column("session")
.unwrap()
.numbers()
.unwrap()
.iter()
.collect();
assert_eq!(ids, [None, Some(100.0), Some(100.0)]);
}
#[test]
fn date_bounds_outside_the_fixture() {
let rules = SessionRules::new("America/New_York", "09:30", "16:00");
let one = |from: &str| generate_sessions(&rules, from, "2024-01-08");
for other in ["2024-01-08[u-ca=gregory]", "2024-01-08[u-ca=japanese]"] {
let e = one(other).unwrap_err().to_string();
assert!(e.contains("only the ISO calendar"), "{e}");
}
assert_eq!(one("2024-01-08[u-ca=iso8601]").unwrap().len(), 1);
assert_eq!(one("2024-01-08[u-ca=ISO8601]").unwrap().len(), 1);
}
}