use chrono::{DateTime, NaiveDate, Utc};
use serde::{Deserialize, Serialize};
use std::fmt;
use std::str::FromStr;
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum Currency {
#[serde(alias = "usd")]
USD,
#[serde(alias = "eur")]
EUR,
#[serde(alias = "gbp")]
GBP,
#[serde(alias = "jpy")]
JPY,
#[serde(alias = "aud")]
AUD,
#[serde(alias = "cad")]
CAD,
#[serde(alias = "chf")]
CHF,
#[serde(alias = "nzd")]
NZD,
#[serde(alias = "cny")]
CNY,
#[serde(alias = "all", alias = "ALL", alias = "Global", alias = "global")]
All,
#[serde(untagged)]
Custom(String),
}
impl Currency {
#[must_use]
pub fn as_str(&self) -> &str {
match self {
Currency::USD => "USD",
Currency::EUR => "EUR",
Currency::GBP => "GBP",
Currency::JPY => "JPY",
Currency::AUD => "AUD",
Currency::CAD => "CAD",
Currency::CHF => "CHF",
Currency::NZD => "NZD",
Currency::CNY => "CNY",
Currency::All => "All",
Currency::Custom(s) => s.as_str(),
}
}
#[must_use]
pub fn matches_str(&self, text: &str) -> bool {
match self {
Currency::All => true,
Currency::Custom(s) => {
s.eq_ignore_ascii_case(text)
|| text.eq_ignore_ascii_case("all")
|| text.eq_ignore_ascii_case("global")
}
standard => {
standard.as_str().eq_ignore_ascii_case(text)
|| text.eq_ignore_ascii_case("all")
|| text.eq_ignore_ascii_case("global")
}
}
}
}
impl fmt::Display for Currency {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.as_str())
}
}
impl FromStr for Currency {
type Err = std::convert::Infallible;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let trimmed = s.trim();
Ok(match trimmed.to_uppercase().as_str() {
"USD" => Currency::USD,
"EUR" => Currency::EUR,
"GBP" => Currency::GBP,
"JPY" => Currency::JPY,
"AUD" => Currency::AUD,
"CAD" => Currency::CAD,
"CHF" => Currency::CHF,
"NZD" => Currency::NZD,
"CNY" => Currency::CNY,
"ALL" | "GLOBAL" => Currency::All,
_ => Currency::Custom(trimmed.to_string()),
})
}
}
impl From<&str> for Currency {
fn from(s: &str) -> Self {
s.parse().unwrap()
}
}
impl From<String> for Currency {
fn from(s: String) -> Self {
s.as_str().into()
}
}
impl From<Currency> for String {
fn from(c: Currency) -> Self {
c.to_string()
}
}
impl AsRef<str> for Currency {
fn as_ref(&self) -> &str {
self.as_str()
}
}
impl PartialEq<&str> for Currency {
fn eq(&self, other: &&str) -> bool {
self.matches_str(other)
}
}
impl PartialEq<Currency> for &str {
fn eq(&self, other: &Currency) -> bool {
other.matches_str(self)
}
}
impl PartialEq<String> for Currency {
fn eq(&self, other: &String) -> bool {
self.matches_str(other)
}
}
impl PartialEq<Currency> for String {
fn eq(&self, other: &Currency) -> bool {
other.matches_str(self)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
pub enum Impact {
#[serde(rename = "Non-Economic", alias = "None", alias = "Holiday")]
NonEconomic,
#[serde(rename = "Low", alias = "low")]
Low,
#[serde(rename = "Medium", alias = "medium", alias = "Med")]
Medium,
#[serde(rename = "High", alias = "high", alias = "Red")]
High,
#[serde(untagged)]
Custom(String),
}
impl Impact {
#[must_use]
pub fn is_high(&self) -> bool {
matches!(self, Impact::High)
}
#[must_use]
pub fn is_red_folder(&self) -> bool {
self.is_high()
}
#[must_use]
pub fn matches_str(&self, text: &str) -> bool {
match self {
Impact::High => text.eq_ignore_ascii_case("High") || text.eq_ignore_ascii_case("Red"),
Impact::Medium => {
text.eq_ignore_ascii_case("Medium") || text.eq_ignore_ascii_case("Med")
}
Impact::Low => text.eq_ignore_ascii_case("Low"),
Impact::NonEconomic => {
text.eq_ignore_ascii_case("Non-Economic")
|| text.eq_ignore_ascii_case("None")
|| text.eq_ignore_ascii_case("Holiday")
}
Impact::Custom(s) => s.eq_ignore_ascii_case(text),
}
}
}
impl From<Impact> for String {
fn from(i: Impact) -> Self {
i.to_string()
}
}
impl From<&str> for Impact {
fn from(s: &str) -> Self {
s.parse().unwrap()
}
}
impl From<String> for Impact {
fn from(s: String) -> Self {
s.as_str().into()
}
}
impl fmt::Display for Impact {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Impact::High => write!(f, "High"),
Impact::Medium => write!(f, "Medium"),
Impact::Low => write!(f, "Low"),
Impact::NonEconomic => write!(f, "Non-Economic"),
Impact::Custom(s) => write!(f, "{}", s),
}
}
}
impl AsRef<str> for Impact {
fn as_ref(&self) -> &str {
match self {
Impact::High => "High",
Impact::Medium => "Medium",
Impact::Low => "Low",
Impact::NonEconomic => "Non-Economic",
Impact::Custom(s) => s.as_str(),
}
}
}
impl PartialEq<&str> for Impact {
fn eq(&self, other: &&str) -> bool {
self.matches_str(other)
}
}
impl PartialEq<Impact> for &str {
fn eq(&self, other: &Impact) -> bool {
other.matches_str(self)
}
}
impl PartialEq<String> for Impact {
fn eq(&self, other: &String) -> bool {
self.matches_str(other)
}
}
impl PartialEq<Impact> for String {
fn eq(&self, other: &Impact) -> bool {
other.matches_str(self)
}
}
impl FromStr for Impact {
type Err = std::convert::Infallible;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(match s.trim().to_lowercase().as_str() {
"high" | "red" => Impact::High,
"medium" | "med" => Impact::Medium,
"low" => Impact::Low,
"non-economic" | "none" | "holiday" => Impact::NonEconomic,
other => Impact::Custom(other.to_string()),
})
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum EventTiming {
Exact(DateTime<Utc>),
TentativeDate(NaiveDate),
AllDay(NaiveDate),
}
impl EventTiming {
#[must_use]
pub fn exact_time(&self) -> Option<DateTime<Utc>> {
match self {
EventTiming::Exact(dt) => Some(*dt),
_ => None,
}
}
#[must_use]
pub fn is_exact(&self) -> bool {
matches!(self, EventTiming::Exact(_))
}
#[must_use]
pub fn is_tentative(&self) -> bool {
matches!(self, EventTiming::TentativeDate(_))
}
#[must_use]
pub fn is_all_day(&self) -> bool {
matches!(self, EventTiming::AllDay(_))
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct EconomicEvent {
pub title: String,
pub country: String,
pub impact: String,
pub datetime: DateTime<Utc>,
#[serde(default = "default_event_timing")]
pub timing: EventTiming,
}
fn default_event_timing() -> EventTiming {
EventTiming::Exact(Utc::now())
}
impl EconomicEvent {
pub fn new_exact(
title: impl Into<String>,
country: impl Into<String>,
impact: impl Into<String>,
datetime: DateTime<Utc>,
) -> Self {
Self {
title: title.into(),
country: country.into(),
impact: impact.into(),
datetime,
timing: EventTiming::Exact(datetime),
}
}
#[must_use]
pub fn exact_time(&self) -> Option<DateTime<Utc>> {
self.timing.exact_time()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum CustomEventKind {
WeekendCurfew,
FailClosedSafety,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct WindowEvent {
pub is_custom: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub custom_kind: Option<CustomEventKind>,
pub event_time: DateTime<Utc>,
pub country: String,
pub impact: String,
pub title: String,
}
impl WindowEvent {
#[must_use]
pub fn is_weekend_curfew(&self) -> bool {
self.custom_kind == Some(CustomEventKind::WeekendCurfew)
|| (self.is_custom && self.custom_kind.is_none() && !self.title.contains("Fail-Closed"))
}
#[must_use]
pub fn is_fail_closed_safety(&self) -> bool {
self.custom_kind == Some(CustomEventKind::FailClosedSafety)
|| (self.is_custom && self.custom_kind.is_none() && self.title.contains("Fail-Closed"))
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct BlackoutWindow {
pub start: DateTime<Utc>,
pub end: DateTime<Utc>,
pub events: Vec<WindowEvent>,
}
impl BlackoutWindow {
#[must_use]
pub fn remaining_minutes(&self) -> i64 {
(self.end - Utc::now()).num_seconds().max(0) / 60
}
#[must_use]
pub fn duration_minutes(&self) -> i64 {
(self.end - self.start).num_seconds() / 60
}
#[must_use]
pub fn is_active_at(&self, time: DateTime<Utc>) -> bool {
self.start <= time && time < self.end
}
#[must_use]
pub fn is_active(&self) -> bool {
self.is_active_at(Utc::now())
}
#[must_use]
pub fn summary_title(&self) -> String {
if self.events.is_empty() {
"Blackout Window".to_string()
} else if self.events.len() == 1 {
self.events[0].title.clone()
} else {
format!(
"{} (+{} events)",
self.events[0].title,
self.events.len() - 1
)
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct BlackoutNotification {
pub active: bool,
pub window: Option<BlackoutWindow>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum FailSafeMode {
#[default]
#[serde(rename = "fail_open", alias = "open", alias = "permissive")]
FailOpen,
#[serde(rename = "fail_closed", alias = "closed", alias = "strict")]
FailClosed,
}
impl FailSafeMode {
#[must_use]
pub fn is_fail_closed(&self) -> bool {
matches!(self, FailSafeMode::FailClosed)
}
#[must_use]
pub fn is_fail_open(&self) -> bool {
matches!(self, FailSafeMode::FailOpen)
}
}
impl fmt::Display for FailSafeMode {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
FailSafeMode::FailOpen => write!(f, "fail_open"),
FailSafeMode::FailClosed => write!(f, "fail_closed"),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Duration;
#[test]
fn test_impact_parsing_and_matching() {
assert_eq!("High".parse::<Impact>().unwrap(), Impact::High);
assert_eq!("red".parse::<Impact>().unwrap(), Impact::High);
assert_eq!("Medium".parse::<Impact>().unwrap(), Impact::Medium);
assert_eq!("low".parse::<Impact>().unwrap(), Impact::Low);
let high = Impact::High;
assert!(high.is_high());
assert!(high.matches_str("high"));
assert!(high.matches_str("red"));
assert!(!high.matches_str("medium"));
}
#[test]
fn test_blackout_window_methods() {
let now = Utc::now();
let window = BlackoutWindow {
start: now - Duration::minutes(10),
end: now + Duration::minutes(20),
events: vec![WindowEvent {
is_custom: false,
custom_kind: None,
event_time: now,
country: "USD".to_string(),
impact: "High".to_string(),
title: "US CPI Release".to_string(),
}],
};
assert!(window.is_active());
assert_eq!(window.duration_minutes(), 30);
assert!(window.remaining_minutes() >= 19 && window.remaining_minutes() <= 20);
assert_eq!(window.summary_title(), "US CPI Release");
}
#[test]
fn test_currency_parsing_and_matching() {
assert_eq!("USD".parse::<Currency>().unwrap(), Currency::USD);
assert_eq!("usd".parse::<Currency>().unwrap(), Currency::USD);
assert_eq!("eur".parse::<Currency>().unwrap(), Currency::EUR);
assert_eq!("ALL".parse::<Currency>().unwrap(), Currency::All);
assert_eq!(
"XAU".parse::<Currency>().unwrap(),
Currency::Custom("XAU".into())
);
let usd = Currency::USD;
assert!(usd.matches_str("USD"));
assert!(usd.matches_str("usd"));
assert!(usd.matches_str("all"));
assert!(!usd.matches_str("EUR"));
let all = Currency::All;
assert!(all.matches_str("USD"));
assert!(all.matches_str("JPY"));
assert_eq!(String::from(Currency::EUR), "EUR");
assert_eq!(Currency::from("GBP"), Currency::GBP);
}
#[test]
fn test_fail_safe_mode() {
assert_eq!(FailSafeMode::default(), FailSafeMode::FailOpen);
assert!(FailSafeMode::FailClosed.is_fail_closed());
assert!(!FailSafeMode::FailClosed.is_fail_open());
assert!(FailSafeMode::FailOpen.is_fail_open());
assert!(!FailSafeMode::FailOpen.is_fail_closed());
assert_eq!(FailSafeMode::FailOpen.to_string(), "fail_open");
assert_eq!(FailSafeMode::FailClosed.to_string(), "fail_closed");
}
#[test]
fn test_custom_event_kind_and_window_event_methods() {
let now = Utc::now();
let curfew_ev = WindowEvent {
is_custom: true,
custom_kind: Some(CustomEventKind::WeekendCurfew),
event_time: now,
country: "Global".into(),
impact: "High".into(),
title: "Weekend Market Close Curfew (Friday 20:30 UTC -> Monday 00:00 UTC)".into(),
};
assert!(curfew_ev.is_weekend_curfew());
assert!(!curfew_ev.is_fail_closed_safety());
let safety_ev = WindowEvent {
is_custom: true,
custom_kind: Some(CustomEventKind::FailClosedSafety),
event_time: now,
country: "Global".into(),
impact: "High".into(),
title: "Calendar Data Unavailable (Fail-Closed Safety Blackout)".into(),
};
assert!(safety_ev.is_fail_closed_safety());
assert!(!safety_ev.is_weekend_curfew());
let legacy_safety = WindowEvent {
is_custom: true,
custom_kind: None,
event_time: now,
country: "Global".into(),
impact: "High".into(),
title: "Calendar Data Stale (Fail-Closed Safety Blackout)".into(),
};
assert!(legacy_safety.is_fail_closed_safety());
assert!(!legacy_safety.is_weekend_curfew());
}
}