b39fd63396
Phase 4 of the Stratification milestone. aura-std held four C28 ladder layers in one roster; this cuts them into layer-aligned, aura-core-only node crates so the import direction is enforced by the crate graph: - aura-std — engine nodes only (arithmetic/logic/rolling + sinks) - aura-market — session, resample - aura-strategy — bias, stops, sizer, cost-model machinery - aura-backtest — sim_broker, position_management - aura-vocabulary — the relocated closed std_vocabulary roster Node modules move verbatim (byte-identical renames); consumers are rewired by import path only. A new structural test (aura-vocabulary/tests/c28_layering.rs) asserts each node crate's [dependencies] stay within its C28-permitted inner set, catching the acyclic-but-outward violation the compiler misses. Behaviour byte-identical: full workspace suite green (1448 tests), no golden edited, clippy -D warnings clean. C28 Status block updated. closes #288
246 lines
12 KiB
Rust
246 lines
12 KiB
Rust
//! `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.** 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:00–09:15 bar closes at **09:15 → 1**,
|
||
//! 09:30–09:45 closes at **09:45 → 3**, 10:00–10: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).** `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, ParamSpec, 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)),
|
||
)
|
||
}
|
||
}
|
||
|
||
/// The zero-arg `SessionFrankfurt` roster preset (#261): the Frankfurt open
|
||
/// (09:00 `Europe/Berlin`) baked as a literal, so the closed `std_vocabulary`
|
||
/// roster can reach a `Session` without a structural construction argument
|
||
/// (`Session::builder` itself stays absent from the roster — #155 scope).
|
||
/// The one remaining knob, `period_minutes` (i64, conventionally 15 — the
|
||
/// resampler's bar width), is declared as the node's sole `ParamSpec` and
|
||
/// bound through it via the single-knob `CarryCost`/`ConstantCost`/
|
||
/// `VolSlippageCost` pattern. This is additive: `Session::builder` is
|
||
/// untouched and stays the vehicle for other timezones/opens.
|
||
pub struct SessionFrankfurt;
|
||
|
||
impl SessionFrankfurt {
|
||
/// The param-generic recipe: one `period_minutes` I64 knob, the Frankfurt
|
||
/// open/timezone baked in the closure.
|
||
pub fn builder() -> PrimitiveBuilder {
|
||
PrimitiveBuilder::new(
|
||
"SessionFrankfurt",
|
||
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![ParamSpec { name: "period_minutes".into(), kind: ScalarKind::I64 }],
|
||
},
|
||
|p| Box::new(Session::new(9, 0, chrono_tz::Europe::Berlin, p[0].i64())),
|
||
)
|
||
}
|
||
}
|
||
|
||
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) 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().snap_to_nearest_minute().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),
|
||
// 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_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
|
||
// 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);
|
||
}
|
||
}
|