diff --git a/crates/aura-ingest/tests/ger40_archive_utc_dst.rs b/crates/aura-ingest/tests/ger40_archive_utc_dst.rs new file mode 100644 index 0000000..f91b384 --- /dev/null +++ b/crates/aura-ingest/tests/ger40_archive_utc_dst.rs @@ -0,0 +1,82 @@ +//! Gated integration test: the GER40 archive timestamps are **genuine UTC**, so +//! the Frankfurt session aligns **DST-correctly end-to-end** (raw archive → +//! `unix_ms_to_epoch_ns` → the `Session` node's Berlin conversion). +//! +//! Why this needs pinning: `data-server` applies **no timezone math** — its +//! `delphi_to_unix_ms` is pure arithmetic (Delphi `TDateTime` → Unix-ms, no +//! offset, no DST), so the archive carries whatever the raw Pepperstone files +//! contain, *labelled* as Unix-ms-UTC. The `Session` node's UTC→Berlin DST math +//! is unit-tested in isolation, but the assumption that the raw timestamps are +//! UTC was otherwise unverified — a broker-local (e.g. EET) archive would shift +//! the whole session and the error would jump across the DST boundary. +//! +//! Decisive market signal (the DAX cash open at 09:00 Frankfurt is the morning's +//! sharp **volume** spike): in genuine-UTC data that spike renders at **Berlin +//! 09:00 in BOTH seasons**, and the same instant is **07:00 UTC** (summer, CEST, +//! UTC+2) / **08:00 UTC** (winter, CET, UTC+1) — a clean +1h DST shift. A +//! fixed-offset / broker-local archive would put the spike at the wrong Berlin +//! hour or fail to shift across DST. +//! +//! Mirrors `real_bars.rs`: skips with a note where the local Pepperstone archive +//! is absent, so `cargo test --workspace` stays green anywhere. + +use std::sync::Arc; + +use aura_ingest::load_m1_window; +use chrono::{TimeZone, Timelike}; +use chrono_tz::Europe::Berlin; +use data_server::{DataServer, DEFAULT_DATA_PATH}; + +/// For one UTC calendar day of GER40, return `(morning-peak Berlin hour over +/// 06..=11, the UTC hour that the Berlin-09:00 hour maps to)`. The morning volume +/// peak is the DAX cash open; in genuine-UTC data it sits at Berlin 09:00 and the +/// same instant is 07:00 UTC (CEST) / 08:00 UTC (CET). +fn morning_open(server: &Arc, y: i32, m: u32, d: u32) -> (u32, u32) { + let from = chrono::Utc.with_ymd_and_hms(y, m, d, 0, 0, 0).unwrap().timestamp_millis(); + let to = from + 24 * 3600 * 1000 - 1; + let cols = load_m1_window(server, "GER40", Some(from), Some(to)) + .expect("GER40 has data on this day"); + + let mut vol_by_berlin_hour = [0i64; 24]; + let mut utc_hour_of_berlin_9: Option = None; + for i in 0..cols.ts.len() { + let ns = cols.ts[i].0; + let berlin_hour = Berlin.timestamp_nanos(ns).hour(); + vol_by_berlin_hour[berlin_hour as usize] += cols.volume[i]; + if berlin_hour == 9 && utc_hour_of_berlin_9.is_none() { + utc_hour_of_berlin_9 = Some(chrono::Utc.timestamp_nanos(ns).hour()); + } + } + let morning_peak = (6u32..=11) + .max_by_key(|&h| vol_by_berlin_hour[h as usize]) + .unwrap(); + (morning_peak, utc_hour_of_berlin_9.expect("bars exist in the Berlin 09:00 hour")) +} + +#[test] +fn ger40_archive_is_utc_and_session_is_dst_correct() { + let server = Arc::new(DataServer::new(DEFAULT_DATA_PATH)); + if !server.has_symbol("GER40") { + eprintln!("skip: no local data at {DEFAULT_DATA_PATH} (symbol GER40 absent)"); + return; // hermetic elsewhere; exercises the real archive where files exist + } + + // Two normal trading Mondays, one each side of the EU DST boundary. + let (summer_peak, summer_utc) = morning_open(&server, 2024, 7, 15); + let (winter_peak, winter_utc) = morning_open(&server, 2024, 1, 15); + + // The morning volume peak is the DAX cash open, at Berlin 09:00 in BOTH + // seasons — the data, rendered local, places the open at the right wall-clock. + assert_eq!(summer_peak, 9, "summer: morning volume must peak at the Berlin 09:00 cash open"); + assert_eq!(winter_peak, 9, "winter: morning volume must peak at the Berlin 09:00 cash open"); + + // ...and Berlin 09:00 is the DST-correct UTC instant: 07:00 (CEST, UTC+2) in + // summer, 08:00 (CET, UTC+1) in winter. The shift is the genuine-UTC + // signature (a fixed-offset / broker-local archive would not shift). + assert_eq!(summer_utc, 7, "summer: Berlin 09:00 must be 07:00 UTC (CEST, UTC+2)"); + assert_eq!(winter_utc, 8, "winter: Berlin 09:00 must be 08:00 UTC (CET, UTC+1)"); + assert_ne!( + summer_utc, winter_utc, + "the open's UTC instant must shift across DST — proof the archive is genuine UTC", + ); +}