diff --git a/crates/aura-std/src/resample.rs b/crates/aura-std/src/resample.rs index 49afcf5..5752ff1 100644 --- a/crates/aura-std/src/resample.rs +++ b/crates/aura-std/src/resample.rs @@ -228,6 +228,39 @@ mod tests { // ...and bucket 2 (the new partial) is NOT flushed: no further emission. } + #[test] + fn resample_buckets_a_boundary_bar_by_its_nominal_minute_despite_subsecond_jitter() { + // PROPERTY: bucket membership follows an M1 bar's NOMINAL minute, not the + // raw provider stamp. Provider M1 stamps carry sub-second jitter around + // minute boundaries (#280: on ~55% of days the 09:00 bar is stamped + // 08:59:59.xxx). A bar whose nominal minute *is* a bucket boundary must + // open the new bucket — and thereby complete the previous one — even when + // its stamp lands a fraction of a second BEFORE that boundary. It must not + // be folded back into the previous bucket. + // + // 5m buckets: boundaries at 0, 5min, 10min, ... (multiples of period_ns). + let period_ns = 5 * 60 * 1_000_000_000_i64; // 300_000_000_000 + let mut node = Resample::new(5); + let mut inputs = ohlc_inputs(); + + // Bucket 0 opens with one M1 bar at a clean minute stamp (t=0). + assert_eq!(step(&mut node, &mut inputs, 0, 100.0, 101.0, 99.0, 100.5), None); + + // The next bar's NOMINAL minute is 05:00 — exactly the bucket-0/bucket-1 + // boundary — but the provider stamped it 600ms early at 04:59:59.400 + // (= 299_400_000_000 ns, 0.6s before the 300_000_000_000 boundary). By its + // nominal minute it is bucket 1's FIRST bar, so feeding it must ROLL OVER + // bucket 0 and emit the completed bucket-0 bar [100, 101, 99, 100.5]. + let jittered_boundary_stamp = period_ns - 600_000_000; // 299_400_000_000 + let emitted = step(&mut node, &mut inputs, jittered_boundary_stamp, 105.0, 106.0, 104.0, 105.5); + assert_eq!( + emitted, + Some(vec![100.0, 101.0, 99.0, 100.5]), + "the jitter-early boundary bar must open the new bucket and complete bucket 0, \ + not fold back into it" + ); + } + #[test] fn output_is_a_four_field_ohlc_record_barrier_gated() { // schema shape: four Barrier(0) f64 inputs named o/h/l/c, four f64 output diff --git a/crates/aura-std/src/session.rs b/crates/aura-std/src/session.rs index c1c1a2e..7c5614b 100644 --- a/crates/aura-std/src/session.rs +++ b/crates/aura-std/src/session.rs @@ -204,6 +204,36 @@ mod tests { assert_ne!(pre, 5); } + #[test] + fn session_indexes_a_boundary_bar_by_its_nominal_minute_despite_subsecond_jitter() { + // PROPERTY (#280): the bar index follows an M1 bar's NOMINAL minute, not + // the raw provider stamp. Provider stamps carry sub-second jitter around + // minute boundaries, and the node derives wall-clock from the raw stamp + // (dropping the seconds entirely: hour*60 + minute). The first in-session + // bar (nominal 09:15 → bars_since_open = 1) must not be demoted to 0 + // (at-open, non-actionable — "pre-open") just because its stamp landed a + // fraction of a second early at 09:14:59.4. + let mut node = Session::new(9, 0, Berlin, 15); + let mut inputs = trigger_input(); + // Nominal 09:15 Berlin (summer/CEST), stamped 600ms early at 09:14:59.400. + // Raw wall-clock reads minute 14 (mins=14, 14/15=0 → at-open); the nominal + // minute is 09:15 → 1. + let ns = Berlin + .with_ymd_and_hms(2024, 7, 1, 9, 14, 59) + .unwrap() + .timestamp_nanos_opt() + .unwrap() + + 400_000_000; + inputs[0].push(Scalar::f64(0.0)).unwrap(); + let got = node.eval(Ctx::new(&inputs, Timestamp(ns))).map(|r| r[0].i64()); + assert_eq!( + got, + Some(1), + "the jitter-early first in-session bar (nominal 09:15) must read \ + bars_since_open = 1, not be demoted to 0 by its raw sub-second stamp" + ); + } + #[test] fn session_is_none_until_trigger_fires() { // Cold guard (C8): an empty trigger column means the resampler has not yet diff --git a/crates/aura-std/src/vol_tf_stop.rs b/crates/aura-std/src/vol_tf_stop.rs index 31a5134..65190d7 100644 --- a/crates/aura-std/src/vol_tf_stop.rs +++ b/crates/aura-std/src/vol_tf_stop.rs @@ -200,6 +200,40 @@ mod tests { assert_eq!(out[6], Some(10.0), "2·(109−104)"); } + /// Property (#280): bucket membership follows a price sample's NOMINAL minute, + /// not the raw provider stamp. Provider M1 stamps carry sub-second jitter + /// around minute boundaries; a sample whose nominal minute is a bucket boundary + /// but stamped a fraction of a second early must open the new bucket — + /// completing the previous one — rather than folding back into it. Mis-bucketing + /// the boundary sample suppresses the rollover, so the stop emission over the + /// true bucket closes never fires. + #[test] + fn buckets_a_boundary_sample_by_its_nominal_minute_despite_subsecond_jitter() { + // 5m buckets: boundaries at 0, 5min, 10min (multiples of period_ns). + let period_ns = 5 * NS_PER_MINUTE; // 300_000_000_000 + let mut n = VolTfStop::new(5, 1, 2.0); + let mut col = vec![AnyColumn::with_capacity(ScalarKind::F64, 1)]; + let mut feed = |ns: i64, price: f64| { + col[0].push(Scalar::f64(price)).unwrap(); + n.eval(Ctx::new(&col, Timestamp(ns))).map(|c| c[0].f64()) + }; + + // Bucket 0 opens with a clean stamp; its running close is 100.0. + assert_eq!(feed(0, 100.0), None); + // The next sample's NOMINAL minute is 05:00 (the bucket-0/bucket-1 + // boundary) but the provider stamped it 600ms early at 04:59:59.400. By its + // nominal minute it opens bucket 1 (close 105.0), rolling over bucket 0 and + // seeding prev_close = 100.0 (first completed bucket: no delta yet). + assert_eq!(feed(period_ns - 600_000_000, 105.0), None); + // Nominal 10:00 rolls over bucket 1: Δ = 105 − 100, stop = k·|Δ| = 2·5 = 10. + assert_eq!( + feed(2 * period_ns, 108.0), + Some(10.0), + "the jitter-early boundary sample must roll over bucket 0 so this \ + rollover emits k·|Δ| over the TRUE bucket closes (100→105)" + ); + } + /// Correspondence pin (#262 acceptance): on strictly 1-minute-spaced /// input with CONSTANT |Δ| per minute, vol_tf{1, length, k} converges to /// exactly what the composite vol stop computes — k·|Δ| — the constant