From 9e5b5bc9f2ed33d9baa439d56d0da449b0fb90b9 Mon Sep 17 00:00:00 2001
From: Stan Drozd <stan@pyth.network>
Date: Tue, 21 Nov 2023 19:10:35 +0100
Subject: [PATCH] src/agent/market_hours.rs: Add market hours format
 implementation

---
 Cargo.lock                | 193 +++++++++++++++++----
 Cargo.toml                |   3 +-
 src/agent.rs              |   1 +
 src/agent/market_hours.rs | 353 ++++++++++++++++++++++++++++++++++++++
 src/agent/metrics.rs      |   4 +-
 5 files changed, 514 insertions(+), 40 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..0c798f9
--- /dev/null
+++ b/src/agent/market_hours.rs
@@ -0,0 +1,353 @@
+//! Market hours metadata parsing and evaluation logic
+
+use {
+    anyhow::{
+        anyhow,
+        Context,
+        Result,
+    },
+    chrono::{
+        naive::NaiveTime,
+        DateTime,
+        NaiveDateTime,
+        TimeZone,
+        Utc,
+        Weekday,
+    },
+    chrono_tz::Tz,
+    std::str::FromStr,
+};
+
+/// 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<Tz: TimeZone>(&self, when: &DateTime<Tz>) -> Result<bool> {
+        // 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<Self> {
+        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 making clear time range, all-day open and all-day closed distinction
+#[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 {
+        dbg!(&when_market_local);
+        dbg!(self);
+        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<Self> {
+        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")?;
+                let end = NaiveTime::parse_from_str(end_str, "%H:%M")
+                    .context("end time does not match HH:MM format")?;
+
+                Ok(MHKind::TimeRange(start, end))
+            }
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[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<MarketHours> = 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<MarketHours> = 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<MarketHours> = 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<MarketHours> = 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<MarketHours> = 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(())
+    }
+}
diff --git a/src/agent/metrics.rs b/src/agent/metrics.rs
index 63c4098..e6bfe76 100644
--- a/src/agent/metrics.rs
+++ b/src/agent/metrics.rs
@@ -40,9 +40,7 @@ use {
     },
     warp::{
         hyper::StatusCode,
-        reply::{
-            self,
-        },
+        reply::{self,},
         Filter,
         Rejection,
         Reply,