feat(aura-std): Session — Frankfurt bars_since_open (DST-aware)

Maps ctx.now() (epoch-ns UTC) to a Frankfurt session bar index: emits
bars_since_open:i64 = (local wall-clock minutes past the 09:00 open) /
period_minutes, in tz-aware DST-correct local time. So local 09:45 reads 3 in
both CEST (UTC+2) and CET (UTC+1) — the close-instant convention (spec 0050
§4.1). Trigger is an f64 Any input (value ignored, wired from close15) so the
node fires once per completed bar. Open/tz/period are baked structural config,
not scalar params (a timezone is not a scalar, C11). Admits chrono/chrono-tz to
aura-std — vetted DST math, never hand-rolled (per-case dep policy). Last node;
build-step 7 of milestone 'Strategy node vocabulary I'.

closes #90
This commit is contained in:
2026-06-17 17:14:32 +02:00
parent 0e1eee61bd
commit 17197fed91
4 changed files with 231 additions and 0 deletions
Generated
+36
View File
@@ -102,6 +102,8 @@ name = "aura-std"
version = "0.1.0"
dependencies = [
"aura-core",
"chrono",
"chrono-tz",
]
[[package]]
@@ -181,6 +183,16 @@ dependencies = [
"windows-link",
]
[[package]]
name = "chrono-tz"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3"
dependencies = [
"chrono",
"phf",
]
[[package]]
name = "cipher"
version = "0.4.4"
@@ -546,6 +558,24 @@ dependencies = [
"hmac",
]
[[package]]
name = "phf"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7"
dependencies = [
"phf_shared",
]
[[package]]
name = "phf_shared"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981"
dependencies = [
"siphasher",
]
[[package]]
name = "pin-project-lite"
version = "0.2.17"
@@ -689,6 +719,12 @@ version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
[[package]]
name = "siphasher"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"
[[package]]
name = "slab"
version = "0.4.12"
+7
View File
@@ -7,3 +7,10 @@ publish.workspace = true
[dependencies]
aura-core = { path = "../aura-core" }
# DST-correct wall-clock math for the `Session` node (spec 0050 §4.5). A vetted
# standard crate for timezone/DST — never hand-rolled (C16 per-case policy).
# `chrono` is already a transitive workspace dep (0.4 via aura-ingest's
# data-server); aligned to that major. `chrono-tz` brings the IANA `Europe/Berlin`
# zone + DST transitions.
chrono = { version = "0.4", default-features = false, features = ["clock"] }
chrono-tz = { version = "0.10", default-features = false }
+2
View File
@@ -26,6 +26,7 @@ mod latch;
mod lincomb;
mod recorder;
mod resample;
mod session;
mod sim_broker;
mod sma;
mod sub;
@@ -40,6 +41,7 @@ pub use latch::Latch;
pub use lincomb::LinComb;
pub use recorder::Recorder;
pub use resample::Resample;
pub use session::Session;
pub use sim_broker::SimBroker;
pub use sma::Sma;
pub use sub::Sub;
+186
View File
@@ -0,0 +1,186 @@
//! `Session` — maps the current cycle's UTC instant to a **Frankfurt session bar
//! index**, emitting `bars_since_open: i64` = the count of completed bar-periods
//! since the session open, in **LOCAL (tz-aware, DST-correct) time**.
//!
//! This is the one node in the session-breakout vocabulary that carries domain
//! knowledge (the Frankfurt open + timezone + DST). It admits `chrono` /
//! `chrono-tz` to `aura-std` — a vetted standard crate for wall-clock/DST math,
//! never hand-rolled (C16 per-case policy).
//!
//! **Schema.** ONE input `trigger: f64, Firing::Any` whose **value is ignored**:
//! it is wired from the resampler's `close15` only so the node fires exactly once
//! per completed 15m bar. `Session` reads only `ctx.now()` + its baked config.
//! Output ONE `i64` field `bars_since_open`. **No scalar params** — the open
//! time, timezone, and period are *structural* construction config, baked at
//! build (like `SimBroker::builder(fee)` bakes its fee); a timezone is not a
//! scalar, so it is captured by the closure, not declared as a param.
//!
//! **Semantics (spec 0050 §4).** Let `local = <ctx.now() epoch-ns UTC> converted
//! to tz` (chrono-tz, DST-correct). Let
//! `mins = (local.hour()*60 + local.minute()) - (open_hour*60 + open_minute)`.
//! Emit `bars_since_open = mins / period_minutes` (i64 division). The resampler
//! buckets land on exact :00/:15/:30/:45 boundaries, so in-session bar closes are
//! exact positive multiples: the 09:0009:15 bar closes at **09:15 → 1**,
//! 09:3009:45 closes at **09:45 → 3**, 10:0010:15 closes at **10:15 → 5**.
//! Pre-open instants give `<= 0` (non-actionable — the downstream `EqConst(==3)`
//! / `EqConst(==5)` gates simply never match; there is no separate in-session
//! bool gate, `bars_since_open` alone is the contract).
//!
//! **Off-by-one (close-instant indexing, spec 0050 §4.1).** `ctx.now()` at a
//! resampler emission is the **just-closed** bar's *close instant*, so "the bar
//! that closed at 09:45" reads `bars_since_open == 3`.
//!
//! **Cold guard.** If the trigger column is empty (not yet fired) → `None`.
use aura_core::{Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, PortSpec, PrimitiveBuilder, ScalarKind};
use chrono::{TimeZone, Timelike};
/// Frankfurt-session bar indexer. Holds the open offset (minutes past local
/// midnight), the IANA timezone, and the bar period — baked at construction,
/// never streamed and never a swept param (a timezone is not a scalar).
pub struct Session {
open_minutes: i64, // open_hour*60 + open_minute, minutes past local midnight
tz: chrono_tz::Tz,
period_minutes: i64,
out: [Cell; 1],
}
impl Session {
/// Build a `Session` for an open at `open_hour:open_minute` local `tz` time,
/// indexing bars of `period_minutes` width (must be >= 1).
pub fn new(open_hour: u32, open_minute: u32, tz: chrono_tz::Tz, period_minutes: i64) -> Self {
assert!(period_minutes >= 1, "Session period_minutes must be >= 1");
Self {
open_minutes: open_hour as i64 * 60 + open_minute as i64,
tz,
period_minutes,
out: [Cell::from_i64(0)],
}
}
/// The param-generic recipe for a blueprint primitive. The open time,
/// timezone, and period are **structural** config baked into the closure
/// (like `SimBroker::builder` bakes `pip_size`), NOT scalar params — the
/// declared param list is empty and the `build_fn` ignores its slice (like
/// `Gt` / `Latch`).
pub fn builder(open_hour: u32, open_minute: u32, tz: chrono_tz::Tz, period_minutes: i64) -> PrimitiveBuilder {
PrimitiveBuilder::new(
"Session",
NodeSchema {
inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "trigger".into() }],
output: vec![FieldSpec { name: "bars_since_open".into(), kind: ScalarKind::I64 }],
params: vec![],
},
move |_| Box::new(Session::new(open_hour, open_minute, tz, period_minutes)),
)
}
}
impl Node for Session {
fn lookbacks(&self) -> Vec<usize> {
vec![1]
}
fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Cell]> {
if ctx.f64_in(0).is_empty() {
return None; // cold: trigger has not fired yet
}
// Convert the engine's epoch-ns UTC instant (close-instant, spec 0050
// §4.1) into tz-aware LOCAL wall-clock — chrono-tz applies the correct
// DST offset for this date, so the same local minute reads the same bar
// index in summer (CEST) and winter (CET).
let local = self.tz.timestamp_nanos(ctx.now().0);
let mins = local.hour() as i64 * 60 + local.minute() as i64 - self.open_minutes;
// i64 division: in-session closes land on exact period boundaries, so the
// value is the bar index; pre-open is <= 0 (non-actionable).
self.out[0] = Cell::from_i64(mins / self.period_minutes);
Some(&self.out)
}
fn label(&self) -> String {
format!("Session({}m@{})", self.period_minutes, self.tz)
}
}
#[cfg(test)]
mod tests {
use super::*;
use aura_core::{AnyColumn, Scalar, Timestamp};
use chrono::TimeZone;
use chrono_tz::Europe::Berlin;
/// One depth-1 f64 trigger column, as bootstrap sizes it from `lookbacks`.
fn trigger_input() -> Vec<AnyColumn> {
vec![AnyColumn::with_capacity(ScalarKind::F64, 1)]
}
/// Drive one fired eval: push an (ignored-value) trigger into slot 0 and step
/// the node at the cycle instant built from Berlin **local wall-clock**
/// `(year, month, day, hour, minute)` —
/// `Berlin.with_ymd_and_hms(...).timestamp_nanos_opt()` converts the local
/// instant to the epoch-ns UTC the engine streams. Returns `bars_since_open`.
fn step_local(
node: &mut Session,
inputs: &mut [AnyColumn],
trigger: f64,
local: (i32, u32, u32, u32, u32),
) -> Option<i64> {
let (y, mo, d, h, mi) = local;
let ns = Berlin.with_ymd_and_hms(y, mo, d, h, mi, 0).unwrap().timestamp_nanos_opt().unwrap();
inputs[0].push(Scalar::f64(trigger)).unwrap();
node.eval(Ctx::new(inputs, Timestamp(ns))).map(|r| r[0].i64())
}
#[test]
fn session_indexes_bars_since_frankfurt_open_in_local_dst_aware_time() {
// THE HEADLINE PROPERTY: bars_since_open = (local wall-clock minutes past
// the 09:00 Frankfurt open) / period, in tz-aware LOCAL time. Because the
// close instant is the bucket boundary (:00/:15/:30/:45, spec 0050 §4.1),
// in-session bar closes are exact positive multiples of the bar index.
//
// Built from Berlin local wall-clock via chrono-tz so the assertion is
// self-checking across DST — the SAME local 09:45 must read 3 in both the
// CEST summer (UTC+2) and the CET winter (UTC+1), even though the raw
// epoch-ns differs by an hour. That equality is what proves the node uses
// tz-aware local time, not a fixed UTC offset.
let mut node = Session::new(9, 0, Berlin, 15);
let mut inputs = trigger_input();
// --- (1) Summer (CEST, UTC+2), 2024-07-01 — the in-session sequence.
// 09:15→1, 09:30→2, 09:45→3, 10:00→4, 10:15→5. The trigger VALUE is
// arbitrary (0.0) — proving it is ignored. ---
assert_eq!(step_local(&mut node, &mut inputs, 0.0, (2024, 7, 1, 9, 15)), Some(1));
assert_eq!(step_local(&mut node, &mut inputs, 0.0, (2024, 7, 1, 9, 30)), Some(2));
assert_eq!(step_local(&mut node, &mut inputs, 0.0, (2024, 7, 1, 9, 45)), Some(3));
assert_eq!(step_local(&mut node, &mut inputs, 0.0, (2024, 7, 1, 10, 0)), Some(4));
assert_eq!(step_local(&mut node, &mut inputs, 0.0, (2024, 7, 1, 10, 15)), Some(5));
// --- (2) DST-CORRECTNESS (load-bearing): Winter (CET, UTC+1),
// 2024-01-02, local 09:45 → 3. The UTC offset is one hour LESS than
// summer, so the raw epoch-ns for the same wall-clock differs — yet
// local 09:45 still yields 3. A fixed-offset implementation would be
// off by one bar here. ---
assert_eq!(step_local(&mut node, &mut inputs, 0.0, (2024, 1, 2, 9, 45)), Some(3));
// --- (3) Trigger value is ignored: a WILD trigger (999.0) at summer
// local 09:45 still reads 3 — the output depends only on ctx.now(). ---
assert_eq!(step_local(&mut node, &mut inputs, 999.0, (2024, 7, 1, 9, 45)), Some(3));
// --- (4) Pre-open: summer local 08:45 is BEFORE the 09:00 open, so
// bars_since_open <= 0 — non-actionable; it must be neither 3 nor 5,
// so the downstream EqConst gates never match out of session. ---
let pre = step_local(&mut node, &mut inputs, 0.0, (2024, 7, 1, 8, 45)).unwrap();
assert!(pre <= 0, "pre-open bars_since_open must be <= 0, got {pre}");
assert_ne!(pre, 3);
assert_ne!(pre, 5);
}
#[test]
fn session_is_none_until_trigger_fires() {
// Cold guard (C8): an empty trigger column means the resampler has not yet
// emitted a completed bar — the node has nothing to index, so it filters.
let mut node = Session::new(9, 0, Berlin, 15);
let inputs = trigger_input();
assert_eq!(node.eval(Ctx::new(&inputs, Timestamp(0))), None);
}
}