From 75497df9f0fc43d3291995a827061d713da65fc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stanis=C5=82aw=20Drozd?= Date: Thu, 23 Nov 2023 14:07:33 +0100 Subject: [PATCH] src/agent/market_hours.rs: Add market hours format implementation (#93) * src/agent/market_hours.rs: Add market hours format implementation * market_hours.rs: Support and test 24:00 as an open hours end time. * Please the formatter * Add missing O in midnight test --- Cargo.lock | 193 +++++++++++++---- Cargo.toml | 3 +- src/agent.rs | 1 + src/agent/market_hours.rs | 423 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 583 insertions(+), 37 deletions(-) create mode 100644 src/agent/market_hours.rs diff --git a/Cargo.lock b/Cargo.lock index 50e201f..bc94a75 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -89,6 +89,12 @@ dependencies = [ "alloc-no-stdlib", ] +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + [[package]] name = "android_system_properties" version = "0.1.5" @@ -159,7 +165,7 @@ dependencies = [ "num-traits", "rusticata-macros", "thiserror", - "time 0.3.14", + "time", ] [[package]] @@ -542,18 +548,39 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" [[package]] name = "chrono" -version = "0.4.22" +version = "0.4.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfd4d1b31faaa3a89d7934dbded3111da0d2ef28e3ebccdb4f0179f5929d1ef1" +checksum = "7f2c685bad3eb3d45a01354cedb7d5faa66194d1d58ba6e267a8de788f79db38" dependencies = [ + "android-tzdata", "iana-time-zone", "js-sys", - "num-integer", "num-traits", "serde", - "time 0.1.44", "wasm-bindgen", - "winapi", + "windows-targets", +] + +[[package]] +name = "chrono-tz" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e23185c0e21df6ed832a12e2bda87c7d1def6842881fb634a8511ced741b0d76" +dependencies = [ + "chrono", + "chrono-tz-build", + "phf", +] + +[[package]] +name = "chrono-tz-build" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "433e39f13c9a060046954e0592a8d0a4bcb1040125cbf91cb8ee58964cfb350f" +dependencies = [ + "parse-zoneinfo", + "phf", + "phf_codegen", ] [[package]] @@ -2459,6 +2486,15 @@ dependencies = [ "windows-sys 0.36.1", ] +[[package]] +name = "parse-zoneinfo" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c705f256449c60da65e11ff6626e0c16a0a0b96aaa348de61376b249bc340f41" +dependencies = [ + "regex", +] + [[package]] name = "pathdiff" version = "0.2.1" @@ -2561,23 +2597,61 @@ dependencies = [ "ordermap", ] +[[package]] +name = "phf" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ade2d8b8f33c7333b51bcf0428d37e217e9f32192ae4772156f65063b8ce03dc" +dependencies = [ + "phf_shared 0.11.2", +] + +[[package]] +name = "phf_codegen" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8d39688d359e6b34654d328e262234662d16cc0f60ec8dcbe5e718709342a5a" +dependencies = [ + "phf_generator 0.11.2", + "phf_shared 0.11.2", +] + [[package]] name = "phf_generator" version = "0.7.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09364cc93c159b8b06b1f4dd8a4398984503483891b0c26b867cf431fb132662" dependencies = [ - "phf_shared", + "phf_shared 0.7.24", "rand 0.6.5", ] +[[package]] +name = "phf_generator" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e4cc64c2ad9ebe670cb8fd69dd50ae301650392e81c05f9bfcb2d5bdbc24b0" +dependencies = [ + "phf_shared 0.11.2", + "rand 0.8.5", +] + [[package]] name = "phf_shared" version = "0.7.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "234f71a15de2288bcb7e3b6515828d22af7ec8598ee6d24c3b526fa0a80b67a0" dependencies = [ - "siphasher", + "siphasher 0.2.3", +] + +[[package]] +name = "phf_shared" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90fcb95eef784c2ac79119d1dd819e162b5da872ce6f3c3abe1e8ca1c082f72b" +dependencies = [ + "siphasher 0.3.11", ] [[package]] @@ -2767,6 +2841,7 @@ dependencies = [ "async-trait", "bincode", "chrono", + "chrono-tz", "clap 4.0.32", "config", "futures-util", @@ -3139,7 +3214,7 @@ checksum = "6413f3de1edee53342e6138e75b56d32e7bc6e332b3bd62d497b1929d4cfbcdd" dependencies = [ "pem", "ring", - "time 0.3.14", + "time", "yasna", ] @@ -3697,6 +3772,12 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b8de496cf83d4ed58b6be86c3a275b8602f6ffe98d3024a869e124147a9a3ac" +[[package]] +name = "siphasher" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" + [[package]] name = "sized-chunks" version = "0.6.5" @@ -3746,7 +3827,7 @@ dependencies = [ "hostname", "slog", "slog-json", - "time 0.3.14", + "time", ] [[package]] @@ -3787,7 +3868,7 @@ dependencies = [ "serde", "serde_json", "slog", - "time 0.3.14", + "time", ] [[package]] @@ -3822,7 +3903,7 @@ dependencies = [ "slog", "term 0.7.0", "thread_local", - "time 0.3.14", + "time", ] [[package]] @@ -4565,7 +4646,7 @@ checksum = "89c058a82f9fd69b1becf8c274f412281038877c553182f1d02eb027045a2d67" dependencies = [ "lazy_static", "new_debug_unreachable", - "phf_shared", + "phf_shared 0.7.24", "precomputed-hash", "serde", "string_cache_codegen", @@ -4578,8 +4659,8 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0f45ed1b65bf9a4bf2f7b7dc59212d1926e9eaf00fa998988e420fd124467c6" dependencies = [ - "phf_generator", - "phf_shared", + "phf_generator 0.7.24", + "phf_shared 0.7.24", "proc-macro2 1.0.43", "quote 1.0.21", "string_cache_shared", @@ -4766,17 +4847,6 @@ dependencies = [ "once_cell", ] -[[package]] -name = "time" -version = "0.1.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db9e6914ab8b1ae1c260a4ae7a49b6c5611b40328a735b21862567685e73255" -dependencies = [ - "libc", - "wasi 0.10.0+wasi-snapshot-preview1", - "winapi", -] - [[package]] name = "time" version = "0.3.14" @@ -5289,12 +5359,6 @@ version = "0.9.0+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519" -[[package]] -name = "wasi" -version = "0.10.0+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a143597ca7c7793eff794def352d41792a93c481eb1042423ff7ff72ba2c31f" - [[package]] name = "wasi" version = "0.11.0+wasi-snapshot-preview1" @@ -5446,21 +5510,42 @@ version = "0.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a3e1820f08b8513f676f7ab6c1f99ff312fb97b553d30ff4dd86f9f15728aa7" dependencies = [ - "windows_aarch64_gnullvm", + "windows_aarch64_gnullvm 0.42.0", "windows_aarch64_msvc 0.42.0", "windows_i686_gnu 0.42.0", "windows_i686_msvc 0.42.0", "windows_x86_64_gnu 0.42.0", - "windows_x86_64_gnullvm", + "windows_x86_64_gnullvm 0.42.0", "windows_x86_64_msvc 0.42.0", ] +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "41d2aa71f6f0cbe00ae5167d90ef3cfe66527d6f613ca78ac8024c3ccab9a19e" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + [[package]] name = "windows_aarch64_msvc" version = "0.36.1" @@ -5473,6 +5558,12 @@ version = "0.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd0f252f5a35cac83d6311b2e795981f5ee6e67eb1f9a7f64eb4500fbc4dcdb4" +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + [[package]] name = "windows_i686_gnu" version = "0.36.1" @@ -5485,6 +5576,12 @@ version = "0.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fbeae19f6716841636c28d695375df17562ca208b2b7d0dc47635a50ae6c5de7" +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + [[package]] name = "windows_i686_msvc" version = "0.36.1" @@ -5497,6 +5594,12 @@ version = "0.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "84c12f65daa39dd2babe6e442988fc329d6243fdce47d7d2d155b8d874862246" +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + [[package]] name = "windows_x86_64_gnu" version = "0.36.1" @@ -5509,12 +5612,24 @@ version = "0.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bf7b1b21b5362cbc318f686150e5bcea75ecedc74dd157d874d754a2ca44b0ed" +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + [[package]] name = "windows_x86_64_gnullvm" version = "0.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09d525d2ba30eeb3297665bd434a54297e4170c7f1a44cad4ef58095b4cd2028" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + [[package]] name = "windows_x86_64_msvc" version = "0.36.1" @@ -5527,6 +5642,12 @@ version = "0.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f40009d85759725a34da6d89a94e63d7bdc50a862acf0dbc7c8e488f1edcb6f5" +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + [[package]] name = "winnow" version = "0.5.0" @@ -5560,7 +5681,7 @@ dependencies = [ "oid-registry", "rusticata-macros", "thiserror", - "time 0.3.14", + "time", ] [[package]] @@ -5578,7 +5699,7 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "346d34a236c9d3e5f3b9b74563f238f955bbd05fa0b8b4efa53c130c43982f4c" dependencies = [ - "time 0.3.14", + "time", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 00c3fc2..1cfa8ec 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,7 +24,8 @@ futures-util = { version = "0.3", default-features = false, features = [ jrpc = "0.4.1" serde_json = "1.0.79" tracing = "0.1.31" -chrono = "0.4.19" +chrono = "0.4.31" +chrono-tz = "0.8.4" parking_lot = "0.12.1" pyth-sdk = "0.7.0" pyth-sdk-solana = "0.7.1" diff --git a/src/agent.rs b/src/agent.rs index e24c82a..b23db86 100644 --- a/src/agent.rs +++ b/src/agent.rs @@ -63,6 +63,7 @@ Note that there is an Oracle and Exporter for each network, but only one Local S ################################################################################################################################## */ pub mod dashboard; +pub mod market_hours; pub mod metrics; pub mod pythd; pub mod remote_keypair_loader; diff --git a/src/agent/market_hours.rs b/src/agent/market_hours.rs new file mode 100644 index 0000000..1a8cc84 --- /dev/null +++ b/src/agent/market_hours.rs @@ -0,0 +1,423 @@ +//! Market hours metadata parsing and evaluation logic + +use { + anyhow::{ + anyhow, + Context, + Result, + }, + chrono::{ + naive::NaiveTime, + DateTime, + Duration, + TimeZone, + Weekday, + }, + chrono_tz::Tz, + lazy_static::lazy_static, + std::str::FromStr, +}; + +lazy_static! { + /// Helper time value representing 24:00:00 as 00:00:00 minus 1 + /// nanosecond (underflowing to 23:59:59.999(...) ). While chrono + /// has this value internally exposed as NaiveTime::MAX, it is not + /// exposed outside the crate. + static ref MAX_TIME_INSTANT: NaiveTime = NaiveTime::MIN.overflowing_sub_signed(Duration::nanoseconds(1)).0; +} + +/// Weekly market hours schedule +#[derive(Default, Debug, Eq, PartialEq)] +pub struct MarketHours { + pub timezone: Tz, + pub mon: MHKind, + pub tue: MHKind, + pub wed: MHKind, + pub thu: MHKind, + pub fri: MHKind, + pub sat: MHKind, + pub sun: MHKind, +} + +impl MarketHours { + pub fn all_closed() -> Self { + Self { + timezone: Default::default(), + mon: MHKind::Closed, + tue: MHKind::Closed, + wed: MHKind::Closed, + thu: MHKind::Closed, + fri: MHKind::Closed, + sat: MHKind::Closed, + sun: MHKind::Closed, + } + } + + pub fn can_publish_at(&self, when: &DateTime) -> Result { + // Convert to time local to the market + let when_market_local = when.with_timezone(&self.timezone); + + // NOTE(2023-11-21): Strangely enough, I couldn't find a + // method that gets the programmatic Weekday from a DateTime. + let market_weekday: Weekday = when_market_local.format("%A").to_string().parse()?; + + let market_time = when_market_local.time(); + + let ret = match market_weekday { + Weekday::Mon => self.mon.can_publish_at(market_time), + Weekday::Tue => self.tue.can_publish_at(market_time), + Weekday::Wed => self.wed.can_publish_at(market_time), + Weekday::Thu => self.thu.can_publish_at(market_time), + Weekday::Fri => self.fri.can_publish_at(market_time), + Weekday::Sat => self.sat.can_publish_at(market_time), + Weekday::Sun => self.sun.can_publish_at(market_time), + }; + + Ok(ret) + } +} + +impl FromStr for MarketHours { + type Err = anyhow::Error; + fn from_str(s: &str) -> Result { + let mut split_by_commas = s.split(","); + + // Timezone id, e.g. Europe/Paris + let tz_str = split_by_commas.next().ok_or(anyhow!( + "Market hours schedule ends before mandatory timezone field" + ))?; + let tz: Tz = tz_str + .trim() + .parse() + .map_err(|e: String| anyhow!(e)) + .context(format!("Could parse timezone from {:?}", tz_str))?; + + let mut weekday_schedules = Vec::with_capacity(7); + + for weekday in &[ + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", + "Sunday", + ] { + let mhkind_str = split_by_commas.next().ok_or(anyhow!( + "Market hours schedule ends before mandatory {} field", + weekday + ))?; + + let mhkind: MHKind = mhkind_str.trim().parse().context(format!( + "Could not parse {} field from {:?}", + weekday, mhkind_str + ))?; + + weekday_schedules.push(mhkind); + } + + // We expect specifying wrong (incl. too large) amount of days + // to be an easy mistake. We should catch it to avoid acting + // on ambiguous schedule when there's too many day schedules + // specified. + if let Some(one_too_many) = split_by_commas.next() { + return Err(anyhow!("Found unexpected 8th day spec {:?}", one_too_many)); + } + + // The compiler was not too happy with moving values via plain [] access + let mut weekday_sched_iter = weekday_schedules.into_iter(); + + let result = Self { + timezone: tz, + // These unwraps failing would be an internal error, but + // panicking here does not seem wise. + mon: weekday_sched_iter + .next() + .ok_or(anyhow!("INTERNAL: weekday_sched_iter too short"))?, + tue: weekday_sched_iter + .next() + .ok_or(anyhow!("INTERNAL: weekday_sched_iter too short"))?, + wed: weekday_sched_iter + .next() + .ok_or(anyhow!("INTERNAL: weekday_sched_iter too short"))?, + thu: weekday_sched_iter + .next() + .ok_or(anyhow!("INTERNAL: weekday_sched_iter too short"))?, + fri: weekday_sched_iter + .next() + .ok_or(anyhow!("INTERNAL: weekday_sched_iter too short"))?, + sat: weekday_sched_iter + .next() + .ok_or(anyhow!("INTERNAL: weekday_sched_iter too short"))?, + sun: weekday_sched_iter + .next() + .ok_or(anyhow!("INTERNAL: weekday_sched_iter too short"))?, + }; + + if let Some(_i_wish_lol) = weekday_sched_iter.next() { + Err(anyhow!("INTERNAL: weekday_sched_iter too long")) + } else { + Ok(result) + } + } +} + +/// Helper enum for denoting per-day schedules: time range, all-day open and all-day closed. +#[derive(Debug, Eq, PartialEq)] +pub enum MHKind { + Open, + Closed, + TimeRange(NaiveTime, NaiveTime), +} + +impl MHKind { + pub fn can_publish_at(&self, when_market_local: NaiveTime) -> bool { + match self { + Self::Open => true, + Self::Closed => false, + Self::TimeRange(start, end) => start <= &when_market_local && &when_market_local <= end, + } + } +} + +impl Default for MHKind { + fn default() -> Self { + Self::Open + } +} + +impl FromStr for MHKind { + type Err = anyhow::Error; + fn from_str(s: &str) -> Result { + match s { + "O" => Ok(MHKind::Open), + "C" => Ok(MHKind::Closed), + other => { + let (start_str, end_str) = other.split_once("-").ok_or(anyhow!( + "Missing '-' delimiter between start and end of range" + ))?; + + let start = NaiveTime::parse_from_str(start_str, "%H:%M") + .context("start time does not match HH:MM format")?; + + // The chrono crate is unable to parse 24:00 as + // previous day's perspective of midnight, so we use + // the next best thing - see MAX_TIME_INSTANT for + // details. + let end = if end_str.contains("24:00") { + MAX_TIME_INSTANT.clone() + } else { + NaiveTime::parse_from_str(end_str, "%H:%M") + .context("end time does not match HH:MM format")? + }; + + if start < end { + Ok(MHKind::TimeRange(start, end)) + } else { + Err(anyhow!("Incorrect time range: start must come before end")) + } + } + } + } +} + +#[cfg(test)] +mod tests { + use { + super::*, + chrono::{ + NaiveDate, + NaiveDateTime, + }, + }; + + #[test] + fn test_parsing_happy_path() -> Result<()> { + // Mon-Fri 9-5, inconsistent leading space on Tuesday, leading 0 on Friday (expected to be fine) + let s = "Europe/Warsaw,9:00-17:00, 9:00-17:00,9:00-17:00,9:00-17:00,09:00-17:00,C,C"; + + let parsed: MarketHours = s.parse()?; + + let expected = MarketHours { + timezone: Tz::Europe__Warsaw, + mon: MHKind::TimeRange( + NaiveTime::from_hms_opt(9, 0, 0).unwrap(), + NaiveTime::from_hms_opt(17, 0, 0).unwrap(), + ), + tue: MHKind::TimeRange( + NaiveTime::from_hms_opt(9, 0, 0).unwrap(), + NaiveTime::from_hms_opt(17, 0, 0).unwrap(), + ), + wed: MHKind::TimeRange( + NaiveTime::from_hms_opt(9, 0, 0).unwrap(), + NaiveTime::from_hms_opt(17, 0, 0).unwrap(), + ), + thu: MHKind::TimeRange( + NaiveTime::from_hms_opt(9, 0, 0).unwrap(), + NaiveTime::from_hms_opt(17, 0, 0).unwrap(), + ), + fri: MHKind::TimeRange( + NaiveTime::from_hms_opt(9, 0, 0).unwrap(), + NaiveTime::from_hms_opt(17, 0, 0).unwrap(), + ), + sat: MHKind::Closed, + sun: MHKind::Closed, + }; + + assert_eq!(parsed, expected); + + Ok(()) + } + + #[test] + fn test_parsing_no_timezone_is_error() { + // Valid but missing a timezone + let s = "O,C,O,C,O,C,O"; + + let parsing_result: Result = s.parse(); + + dbg!(&parsing_result); + assert!(parsing_result.is_err()); + } + + #[test] + fn test_parsing_missing_sunday_is_error() { + // One day short + let s = "Asia/Hong_Kong,C,O,C,O,C,O"; + + let parsing_result: Result = s.parse(); + + dbg!(&parsing_result); + assert!(parsing_result.is_err()); + } + + #[test] + fn test_parsing_gibberish_timezone_is_error() { + // Pretty sure that one's extinct + let s = "Pangea/New_Dino_City,O,O,O,O,O,O,O"; + let parsing_result: Result = s.parse(); + + dbg!(&parsing_result); + assert!(parsing_result.is_err()); + } + + #[test] + fn test_parsing_gibberish_day_schedule_is_error() { + let s = "Europe/Amsterdam,mondays are alright I guess,O,O,O,O,O,O"; + let parsing_result: Result = s.parse(); + + dbg!(&parsing_result); + assert!(parsing_result.is_err()); + } + + #[test] + fn test_parsing_too_many_days_is_error() { + // One day too many + let s = "Europe/Lisbon,O,O,O,O,O,O,O,O,C"; + let parsing_result: Result = s.parse(); + + dbg!(&parsing_result); + assert!(parsing_result.is_err()); + } + + #[test] + fn test_market_hours_happy_path() -> Result<()> { + // Prepare a schedule of narrow ranges + let mh: MarketHours = "America/New_York,00:00-1:00,1:00-2:00,2:00-3:00,3:00-4:00,4:00-5:00,5:00-6:00,6:00-7:00".parse()?; + + // Prepare UTC datetimes that fall before, within and after market hours + let format = "%Y-%m-%d %H:%M"; + let bad_datetimes_before = vec![ + NaiveDateTime::parse_from_str("2023-11-20 04:30", format)?.and_utc(), + NaiveDateTime::parse_from_str("2023-11-21 05:30", format)?.and_utc(), + NaiveDateTime::parse_from_str("2023-11-22 06:30", format)?.and_utc(), + NaiveDateTime::parse_from_str("2023-11-23 07:30", format)?.and_utc(), + NaiveDateTime::parse_from_str("2023-11-24 08:30", format)?.and_utc(), + NaiveDateTime::parse_from_str("2023-11-25 09:30", format)?.and_utc(), + NaiveDateTime::parse_from_str("2023-11-26 10:30", format)?.and_utc(), + ]; + + let ok_datetimes = vec![ + NaiveDateTime::parse_from_str("2023-11-20 05:30", format)?.and_utc(), + NaiveDateTime::parse_from_str("2023-11-21 06:30", format)?.and_utc(), + NaiveDateTime::parse_from_str("2023-11-22 07:30", format)?.and_utc(), + NaiveDateTime::parse_from_str("2023-11-23 08:30", format)?.and_utc(), + NaiveDateTime::parse_from_str("2023-11-24 09:30", format)?.and_utc(), + NaiveDateTime::parse_from_str("2023-11-25 10:30", format)?.and_utc(), + NaiveDateTime::parse_from_str("2023-11-26 11:30", format)?.and_utc(), + ]; + + let bad_datetimes_after = vec![ + NaiveDateTime::parse_from_str("2023-11-20 06:30", format)?.and_utc(), + NaiveDateTime::parse_from_str("2023-11-21 07:30", format)?.and_utc(), + NaiveDateTime::parse_from_str("2023-11-22 08:30", format)?.and_utc(), + NaiveDateTime::parse_from_str("2023-11-23 09:30", format)?.and_utc(), + NaiveDateTime::parse_from_str("2023-11-24 10:30", format)?.and_utc(), + NaiveDateTime::parse_from_str("2023-11-25 11:30", format)?.and_utc(), + NaiveDateTime::parse_from_str("2023-11-26 12:30", format)?.and_utc(), + ]; + + dbg!(&mh); + + for ((before_dt, ok_dt), after_dt) in bad_datetimes_before + .iter() + .zip(ok_datetimes.iter()) + .zip(bad_datetimes_after.iter()) + { + dbg!(&before_dt); + dbg!(&ok_dt); + dbg!(&after_dt); + + assert!(!mh.can_publish_at(before_dt)?); + assert!(mh.can_publish_at(ok_dt)?); + assert!(!mh.can_publish_at(after_dt)?); + } + + Ok(()) + } + + /// Verify desired 24:00 behavior. + #[test] + fn test_market_hours_midnight_00_24() -> Result<()> { + // Prepare a schedule of midnight-neighboring ranges + let mh: MarketHours = "Europe/Amsterdam,23:00-24:00,00:00-01:00,O,C,C,C,C".parse()?; + + let format = "%Y-%m-%d %H:%M"; + let ok_datetimes = vec![ + NaiveDate::from_ymd_opt(2023, 11, 20) + .unwrap() + .and_time(MAX_TIME_INSTANT.clone()) + .and_local_timezone(Tz::Europe__Amsterdam) + .unwrap(), + NaiveDateTime::parse_from_str("2023-11-21 00:00", format)? + .and_local_timezone(Tz::Europe__Amsterdam) + .unwrap(), + ]; + + let bad_datetimes = vec![ + // Start of Monday Nov 20th, must not be confused for MAX_TIME_INSTANT on that day + NaiveDateTime::parse_from_str("2023-11-20 00:00", format)? + .and_local_timezone(Tz::Europe__Amsterdam) + .unwrap(), + // End of Tuesday Nov 21st, borders Wednesday, must not be + // confused for Wednesday 00:00 which is open. + NaiveDate::from_ymd_opt(2023, 11, 21) + .unwrap() + .and_time(MAX_TIME_INSTANT.clone()) + .and_local_timezone(Tz::Europe__Amsterdam) + .unwrap(), + ]; + + dbg!(&mh); + + for (ok_dt, bad_dt) in ok_datetimes.iter().zip(bad_datetimes.iter()) { + dbg!(&ok_dt); + dbg!(&bad_dt); + + assert!(mh.can_publish_at(ok_dt)?); + assert!(!mh.can_publish_at(bad_dt)?); + } + + Ok(()) + } +}