summaryrefslogtreecommitdiff
path: root/src/resample.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/resample.rs')
-rw-r--r--src/resample.rs159
1 files changed, 159 insertions, 0 deletions
diff --git a/src/resample.rs b/src/resample.rs
new file mode 100644
index 0000000..74894bb
--- /dev/null
+++ b/src/resample.rs
@@ -0,0 +1,159 @@
+use crate::data::Candle;
+
+#[derive(Clone, Copy, PartialEq, Debug)]
+pub enum Interval {
+ Day,
+ Week,
+ Month,
+}
+
+impl Interval {
+ pub const ALL: [Interval; 3] = [Interval::Day, Interval::Week, Interval::Month];
+
+ pub fn name(self) -> &'static str {
+ match self {
+ Interval::Day => "day",
+ Interval::Week => "week",
+ Interval::Month => "month",
+ }
+ }
+
+ pub fn parse(s: &str) -> Option<Interval> {
+ Interval::ALL.into_iter().find(|i| i.name() == s)
+ }
+}
+
+/// Days since 1970-01-01 for a `YYYY-MM-DD` date (Howard Hinnant's civil-days algorithm).
+fn days_from_date(date: &str) -> i64 {
+ let (y, m, d): (i64, i64, i64) = (
+ date[0..4].parse().unwrap_or(1970),
+ date[5..7].parse().unwrap_or(1),
+ date[8..10].parse().unwrap_or(1),
+ );
+ let y = if m <= 2 { y - 1 } else { y };
+ let era = y.div_euclid(400);
+ let yoe = y - era * 400;
+ let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1;
+ let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
+ era * 146097 + doe - 719468
+}
+
+/// Monday = 0 ... Sunday = 6.
+fn weekday(date: &str) -> i64 {
+ (days_from_date(date) + 3).rem_euclid(7)
+}
+
+/// Bars are grouped by Monday-based week (1970-01-01 was a Thursday) or by calendar month.
+fn group_key(date: &str, interval: Interval) -> i64 {
+ match interval {
+ Interval::Day => days_from_date(date),
+ Interval::Week => (days_from_date(date) + 3).div_euclid(7),
+ Interval::Month => date[0..4].parse::<i64>().unwrap_or(0) * 12 + date[5..7].parse::<i64>().unwrap_or(1),
+ }
+}
+
+/// Aggregate daily candles: first open, highest high, lowest low, last close, summed volume;
+/// `date` is the last trading day of the group.
+pub fn resample(candles: &[Candle], interval: Interval) -> Vec<Candle> {
+ if interval == Interval::Day {
+ return candles.to_vec();
+ }
+ let mut out: Vec<Candle> = Vec::new();
+ let mut last_key = None;
+ for c in candles {
+ let key = group_key(&c.date, interval);
+ match (last_key == Some(key), out.last_mut()) {
+ (true, Some(bar)) => {
+ bar.high = bar.high.max(c.high);
+ bar.low = bar.low.min(c.low);
+ bar.close = c.close;
+ bar.volume += c.volume;
+ bar.date = c.date.clone();
+ }
+ _ => out.push(c.clone()),
+ }
+ last_key = Some(key);
+ }
+ out
+}
+
+/// Whether the last bar may still be incomplete: a week whose last trading day is before
+/// Friday, or a month whose last trading day is not on or after the 28th-ish last weekday.
+/// (Exchange holidays are not known, so this is a hint, not a guarantee.)
+pub fn last_bar_partial(candles: &[Candle], interval: Interval) -> bool {
+ let Some(last) = candles.last() else { return false };
+ match interval {
+ Interval::Day => false,
+ Interval::Week => weekday(&last.date) < 4,
+ Interval::Month => {
+ // last weekday of the month: step back from the month's last day
+ let (y, m): (i64, i64) = (last.date[0..4].parse().unwrap_or(0), last.date[5..7].parse().unwrap_or(1));
+ let (ny, nm) = if m == 12 { (y + 1, 1) } else { (y, m + 1) };
+ let first_next = days_from_date(&format!("{ny:04}-{nm:02}-01"));
+ let mut d = first_next - 1;
+ while (d + 3).rem_euclid(7) >= 5 {
+ d -= 1;
+ }
+ days_from_date(&last.date) < d - 3 // allow a few holiday days before the last weekday
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn c(date: &str, o: f64, h: f64, l: f64, cl: f64, v: f64) -> Candle {
+ Candle { date: date.into(), open: o, high: h, low: l, close: cl, volume: v }
+ }
+
+ #[test]
+ fn weekday_known_dates() {
+ assert_eq!(weekday("1970-01-01"), 3); // Thursday
+ assert_eq!(weekday("2024-01-01"), 0); // Monday
+ assert_eq!(weekday("2026-10-12"), 0); // Monday
+ assert_eq!(weekday("2024-02-29"), 3); // leap day, Thursday
+ }
+
+ #[test]
+ fn weekly_spans_year_boundary_and_holiday() {
+ // 2023-12-28 Thu, 12-29 Fri | 2024-01-01 Mon (holiday, absent), 01-02 Tue, 01-05 Fri | 01-08 Mon
+ let days = [
+ c("2023-12-28", 10.0, 12.0, 9.0, 11.0, 100.0),
+ c("2023-12-29", 11.0, 13.0, 10.0, 12.0, 200.0),
+ c("2024-01-02", 12.0, 14.0, 11.0, 13.0, 300.0),
+ c("2024-01-05", 13.0, 15.0, 8.0, 9.0, 400.0),
+ c("2024-01-08", 9.0, 10.0, 8.5, 9.5, 500.0),
+ ];
+ let w = resample(&days, Interval::Week);
+ assert_eq!(w.len(), 3);
+ assert_eq!((w[0].open, w[0].high, w[0].low, w[0].close, w[0].volume), (10.0, 13.0, 9.0, 12.0, 300.0));
+ assert_eq!(w[0].date, "2023-12-29");
+ assert_eq!((w[1].open, w[1].high, w[1].low, w[1].close, w[1].volume), (12.0, 15.0, 8.0, 9.0, 700.0));
+ assert_eq!(w[1].date, "2024-01-05");
+ assert_eq!(w[2].date, "2024-01-08");
+ }
+
+ #[test]
+ fn monthly_groups_by_calendar_month() {
+ let days = [
+ c("2024-01-30", 1.0, 2.0, 0.5, 1.5, 10.0),
+ c("2024-01-31", 1.5, 3.0, 1.0, 2.0, 10.0),
+ c("2024-02-01", 2.0, 2.5, 1.5, 2.2, 10.0),
+ ];
+ let m = resample(&days, Interval::Month);
+ assert_eq!(m.len(), 2);
+ assert_eq!((m[0].high, m[0].low, m[0].close, m[0].volume), (3.0, 0.5, 2.0, 20.0));
+ assert_eq!(m[0].date, "2024-01-31");
+ }
+
+ #[test]
+ fn partial_flags() {
+ let thu = [c("2026-10-08", 1.0, 1.0, 1.0, 1.0, 1.0)];
+ let fri = [c("2026-10-09", 1.0, 1.0, 1.0, 1.0, 1.0)];
+ assert!(last_bar_partial(&thu, Interval::Week));
+ assert!(!last_bar_partial(&fri, Interval::Week));
+ assert!(last_bar_partial(&thu, Interval::Month)); // 2026-10-30 is the last weekday
+ assert!(!last_bar_partial(&[c("2026-10-30", 1.0, 1.0, 1.0, 1.0, 1.0)], Interval::Month));
+ }
+}