diff --git a/Cargo.lock b/Cargo.lock index e785920..fc8d658 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -236,6 +236,16 @@ dependencies = [ "num-traits", ] +[[package]] +name = "chrono-tz" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3" +dependencies = [ + "chrono", + "phf 0.12.1", +] + [[package]] name = "ciborium" version = "0.2.2" @@ -1523,6 +1533,15 @@ dependencies = [ "phf_shared 0.11.3", ] +[[package]] +name = "phf" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7" +dependencies = [ + "phf_shared 0.12.1", +] + [[package]] name = "phf" version = "0.13.1" @@ -1628,6 +1647,15 @@ dependencies = [ "siphasher 1.0.2", ] +[[package]] +name = "phf_shared" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981" +dependencies = [ + "siphasher 1.0.2", +] + [[package]] name = "phf_shared" version = "0.13.1" @@ -1671,6 +1699,7 @@ name = "pine-builtins" version = "0.2.5" dependencies = [ "chrono", + "chrono-tz", "nalgebra", "pine-ast", "pine-broker", diff --git a/Cargo.toml b/Cargo.toml index 2bafbe4..e148ff8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -53,5 +53,6 @@ clap = { version = "4.5", features = ["derive"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" chrono = { version = "0.4", default-features = false, features = ["std"] } +chrono-tz = "0.10" csv = "1.3" ureq = { version = "2.10", features = ["json"] } diff --git a/crates/pine-builtins/Cargo.toml b/crates/pine-builtins/Cargo.toml index 1294a50..a3eac99 100644 --- a/crates/pine-builtins/Cargo.toml +++ b/crates/pine-builtins/Cargo.toml @@ -14,5 +14,6 @@ pine-core = { workspace = true } pine-broker = { workspace = true } pine-interpreter = { workspace = true } chrono = { workspace = true } +chrono-tz = { workspace = true } regex = "1" nalgebra = "0.33" diff --git a/crates/pine-builtins/src/time/mod.rs b/crates/pine-builtins/src/time/mod.rs index 37158a7..c25d3f9 100644 --- a/crates/pine-builtins/src/time/mod.rs +++ b/crates/pine-builtins/src/time/mod.rs @@ -1,4 +1,5 @@ -use chrono::{DateTime, Datelike, NaiveDate, NaiveDateTime, Timelike, Utc}; +use chrono::{DateTime, Datelike, FixedOffset, NaiveDate, NaiveDateTime, Timelike}; +use chrono_tz::Tz; use pine_builtin_macro::BuiltinFunction; use pine_core::PineOutput; use pine_interpreter::{ @@ -92,45 +93,83 @@ fn timestamp_fn() -> BuiltinFn { }) } -/// The UTC datetime for a UNIX-ms timestamp, or `None` if out of range. -fn datetime_of(millis: f64) -> Option> { - DateTime::from_timestamp_millis(millis as i64) +/// A `"UTC"`/`"GMT"` timezone with an optional numeric offset (`"GMT-5"`, +/// `"UTC+05:30"`), as a fixed offset. `None` for anything else (an IANA name). +fn resolve_offset(tz: &str) -> Option { + let rest = tz + .strip_prefix("UTC") + .or_else(|| tz.strip_prefix("GMT")) + .unwrap_or(tz) + .trim(); + if rest.is_empty() { + return FixedOffset::east_opt(0); + } + let (sign, digits) = match rest.strip_prefix('+') { + Some(digits) => (1, digits), + None => (-1, rest.strip_prefix('-')?), + }; + let (hours, minutes) = match digits.split_once(':') { + Some((h, m)) => (h.parse::().ok()?, m.parse::().ok()?), + None => (digits.parse::().ok()?, 0), + }; + FixedOffset::east_opt(sign * (hours * 3600 + minutes * 60)) +} + +/// The instant `millis` (UNIX ms, UTC) as a local datetime in `tz`. `tz` accepts +/// a UTC offset (`""`, `"UTC"`, `"GMT-5"`, `"UTC+05:30"`) or an IANA name +/// (`"America/New_York"`); an unrecognised zone falls back to UTC. +fn datetime_at(millis: f64, tz: &str) -> Option> { + let utc = DateTime::from_timestamp_millis(millis as i64)?; + let tz = tz.trim(); + if tz.is_empty() { + return Some(utc.fixed_offset()); + } + if let Some(offset) = resolve_offset(tz) { + return Some(utc.with_timezone(&offset)); + } + match tz.parse::() { + Ok(zone) => Some(utc.with_timezone(&zone).fixed_offset()), + Err(_) => Some(utc.fixed_offset()), + } } -// The date-part extractors, shared by the `x(time)` functions and the bare-value -// forms so the two can never disagree. `sunday = 1 … saturday = 7`, matching Pine. -fn year_of(ms: f64) -> Option { - datetime_of(ms).map(|d| d.year() as i64) +// The date-part extractors, shared by the `x(time, timezone)` functions and the +// bare-value forms so the two can never disagree. `sunday = 1 … saturday = 7`. +fn year_of(ms: f64, tz: &str) -> Option { + datetime_at(ms, tz).map(|d| d.year() as i64) } -fn month_of(ms: f64) -> Option { - datetime_of(ms).map(|d| d.month() as i64) +fn month_of(ms: f64, tz: &str) -> Option { + datetime_at(ms, tz).map(|d| d.month() as i64) } -fn dayofmonth_of(ms: f64) -> Option { - datetime_of(ms).map(|d| d.day() as i64) +fn dayofmonth_of(ms: f64, tz: &str) -> Option { + datetime_at(ms, tz).map(|d| d.day() as i64) } -fn dayofweek_of(ms: f64) -> Option { - datetime_of(ms).map(|d| d.weekday().num_days_from_sunday() as i64 + 1) +fn dayofweek_of(ms: f64, tz: &str) -> Option { + datetime_at(ms, tz).map(|d| d.weekday().num_days_from_sunday() as i64 + 1) } -fn hour_of(ms: f64) -> Option { - datetime_of(ms).map(|d| d.hour() as i64) +fn hour_of(ms: f64, tz: &str) -> Option { + datetime_at(ms, tz).map(|d| d.hour() as i64) } -fn minute_of(ms: f64) -> Option { - datetime_of(ms).map(|d| d.minute() as i64) +fn minute_of(ms: f64, tz: &str) -> Option { + datetime_at(ms, tz).map(|d| d.minute() as i64) } -fn second_of(ms: f64) -> Option { - datetime_of(ms).map(|d| d.second() as i64) +fn second_of(ms: f64, tz: &str) -> Option { + datetime_at(ms, tz).map(|d| d.second() as i64) } -fn weekofyear_of(ms: f64) -> Option { - datetime_of(ms).map(|d| d.iso_week().week() as i64) +fn weekofyear_of(ms: f64, tz: &str) -> Option { + datetime_at(ms, tz).map(|d| d.iso_week().week() as i64) } -/// Defines a `name(time)` date function returning an integer part (or `na`). +/// Defines a `name(time, timezone)` date function returning an integer part (or +/// `na`). The timezone is optional and defaults to UTC. macro_rules! date_fn { ($ident:ident, $name:literal, $extract:ident) => { #[derive(BuiltinFunction)] #[builtin(name = $name)] struct $ident { time: f64, + #[arg(default = "")] + timezone: String, } impl $ident { @@ -138,7 +177,9 @@ macro_rules! date_fn { &self, _ctx: &mut Interpreter, ) -> Result, RuntimeError> { - Ok($extract(self.time).map(Value::Int).unwrap_or(Value::Na)) + Ok($extract(self.time, &self.timezone) + .map(Value::Int) + .unwrap_or(Value::Na)) } } }; @@ -159,7 +200,7 @@ fn date_dual( name: &str, call: BuiltinFn, signature: &'static BuiltinSignature, - extract: fn(f64) -> Option, + extract: fn(f64, &str) -> Option, ) -> Value { Value::Object { type_name: name.to_string(), @@ -168,7 +209,7 @@ fn date_dual( value: Some(Rc::new(move |ctx: &mut Interpreter| { Ok(ctx .current_time - .and_then(|ms| extract(ms as f64)) + .and_then(|ms| extract(ms as f64, "")) .map(Value::Int) .unwrap_or(Value::Na)) })), @@ -296,9 +337,51 @@ pub fn register_dayofweek() -> Value { value: Some(Rc::new(|ctx: &mut Interpreter| { Ok(ctx .current_time - .and_then(|ms| dayofweek_of(ms as f64)) + .and_then(|ms| dayofweek_of(ms as f64, "")) .map(Value::Int) .unwrap_or(Value::Na)) })), } } + +#[cfg(test)] +mod tests { + use super::*; + + // 2021-01-01 00:00:00 UTC — a Friday. + const NEW_YEAR_2021_UTC: f64 = 1_609_459_200_000.0; + + #[test] + fn resolve_offset_parses_utc_and_gmt_forms() { + assert_eq!(resolve_offset(""), FixedOffset::east_opt(0)); + assert_eq!(resolve_offset("UTC"), FixedOffset::east_opt(0)); + assert_eq!(resolve_offset("GMT"), FixedOffset::east_opt(0)); + assert_eq!(resolve_offset("GMT-5"), FixedOffset::east_opt(-5 * 3600)); + assert_eq!(resolve_offset("UTC+3"), FixedOffset::east_opt(3 * 3600)); + assert_eq!( + resolve_offset("UTC+5:30"), + FixedOffset::east_opt(5 * 3600 + 30 * 60) + ); + // An IANA name is not an offset form. + assert_eq!(resolve_offset("America/New_York"), None); + } + + #[test] + fn datetime_at_applies_offsets_and_iana_zones() { + // UTC / empty: midnight. + assert_eq!(hour_of(NEW_YEAR_2021_UTC, ""), Some(0)); + assert_eq!(minute_of(NEW_YEAR_2021_UTC, ""), Some(0)); + // A half-hour offset shifts hour and minute. + assert_eq!(hour_of(NEW_YEAR_2021_UTC, "UTC+05:30"), Some(5)); + assert_eq!(minute_of(NEW_YEAR_2021_UTC, "UTC+05:30"), Some(30)); + // GMT-5 rolls back to the previous evening. + assert_eq!(hour_of(NEW_YEAR_2021_UTC, "GMT-5"), Some(19)); + // An IANA zone (EST is -5 in January) matches the offset form. + assert_eq!(hour_of(NEW_YEAR_2021_UTC, "America/New_York"), Some(19)); + // Timezone changes the weekday: Friday (6) in UTC, Thursday (5) at GMT-5. + assert_eq!(dayofweek_of(NEW_YEAR_2021_UTC, ""), Some(6)); + assert_eq!(dayofweek_of(NEW_YEAR_2021_UTC, "GMT-5"), Some(5)); + // An unrecognised zone falls back to UTC rather than erroring. + assert_eq!(hour_of(NEW_YEAR_2021_UTC, "Not/AZone"), Some(0)); + } +} diff --git a/tests/testdata/time/minute_tz.pine b/tests/testdata/time/minute_tz.pine new file mode 100644 index 0000000..72255a8 --- /dev/null +++ b/tests/testdata/time/minute_tz.pine @@ -0,0 +1,14 @@ +//@version=6 +indicator("time/minute_tz") +// minute(time, timezone) and hour(time, timezone): the timezone shifts the +// extracted parts. Offsets ("UTC+05:30") and IANA zones ("America/New_York", +// EDT = UTC-4 in June) are both supported. +t = timestamp(2021, 6, 15, 13, 45, 0) +log.info(str.tostring(hour(t)) + "|" + str.tostring(minute(t))) +log.info(str.tostring(hour(t, "UTC+05:30")) + "|" + str.tostring(minute(t, "UTC+05:30"))) +log.info(str.tostring(hour(t, "America/New_York")) + "|" + str.tostring(minute(t, "America/New_York"))) + +// Expected output: +// 13|45 +// 19|15 +// 9|45