Files
Aura/crates/aura-market/src/session.rs
T
claude b39fd63396 refactor: split the aura-std roster into C28 layer crates
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
2026-07-19 20:28:20 +02:00

246 lines
12 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! `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: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).** `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);
}
}