docs(specs): 0050 — session-breakout node vocabulary (milestone spec)

Milestone 'Strategy node vocabulary I — temporal, logic, resample & session':
the 7 aura-std nodes (EqConst, Gt, And, Delay, Latch, Resample, Session) plus a
synthetic e2e fixture, driven by the GER40 15m session-breakout. Records the
design pass's cross-cutting decisions: close-instant timestamp convention,
Latch emits f64 (Exposure dropped from the DAG), the EqConst i64->bool gate,
the Resample-only Barrier(0) firing regime, and the build order. refs #84-#91.
This commit is contained in:
2026-06-17 16:41:31 +02:00
parent 58bff32e6a
commit 16adb2b949
@@ -0,0 +1,144 @@
# 0050 — Session-breakout node vocabulary (aura-std)
Status: draft (milestone spec)
Driver: the GER40 / DAX 15-minute session-breakout strategy (below) as a
forcing function for the stateful / temporal / logic / resampling vocabulary
aura-std is still missing.
## 1. Goal & non-goal
aura today ships eight nodes (`Sma, Ema, Add, Sub, LinComb, Exposure,
SimBroker, Recorder`). That set composes cross / linear-combination signals but
has **no** previous-bar lag (C5 register), **no** comparison producing `bool`,
**no** boolean logic, **no** resampler (C2 complete-bar), and **no** session /
calendar awareness. The GER40 session breakout needs all of them, so it is the
ideal driver to grow that vocabulary.
This milestone is **engine-side, Phase 1**: the reusable nodes in `aura-std`
plus one synthetic end-to-end fixture that pins the whole strategy on
hand-built bars. The **real** GER40 strategy — wired against real GER40 M1 data
with the correct pip value — is **Phase 2**, a *separate consumer crate* in its
own repo (C9), out of this milestone's scope (see §7).
## 2. The strategy (the driver)
- Resample raw M1 OHLC → 15-minute OHLC bars. Frankfurt session opens 09:00
Europe/Berlin (DST-aware).
- Bars indexed from the open: bar1 = 09:0009:15, bar2 = 09:1509:30,
bar3 = 09:3009:45, bar4, bar5 …
- **Entry (long):** when bar3 *closes* above the *high of bar2* (the previous
15m bar): `close(bar3) > high(bar2)`.
- **Exit:** flat when bar5 *closes*.
- Exposure is therefore `+1` held from bar3-close through bar5-close, then `0`.
Long-only (`0` or `+1`).
## 3. Dataflow (the DAG)
```
M1 OHLC: open/high/low/close as 4 separate f64 sources [aura-ingest M1FieldSource]
└─ Resample[15m] ⇒ record{open15,high15,low15,close15} [NEW, C2 emit-on-rollover]
close15 ─────────────────────────────────────────┐ ┐ ┐
high15 ─→ Delay[1] ─→ prevHigh15 ─┐ │ │ │
▼ │ │ │
Gt(close15 > prevHigh15) ⇒ breakout:bool [NEW]
close15 ─→ Session[FRA 09:00,15m] ⇒ bars_since_open:i64 [NEW]
├─ EqConst(·,3) ⇒ isBar3:bool [NEW]
└─ EqConst(·,5) ⇒ isBar5Close:bool [NEW]
And(breakout, isBar3) ⇒ entry:bool [NEW]
Latch(set=entry, reset=isBar5Close) ⇒ held:f64 (0.0/1.0) [NEW]
held ─→ SimBroker(exposure=held, price=close15) ⇒ equity(pips) ─→ Recorder [exists]
```
Note vs. the first sketch: **`Exposure` is dropped** — `Latch` emits `f64`
directly, so an on/off latch needs no clamp/scale. **`EqConst` is added** — it is
the `i64 → bool` gate that turns `Session`'s `bars_since_open` into the per-bar
booleans; without it the `i64 → bool` seam is unbridged.
## 4. Cross-cutting decisions (settled by the design pass)
1. **Timestamp = close-instant, everywhere.** A resampled bar carries only its
OHLC `f64` *values* (no timestamp on the hot path, C4); its actionable cycle
timestamp is the close-instant the engine stamps on the forwarded edge
(`harness.rs:427`). `Resample` emits bucket *B* exactly when `ctx.now()`
first lands in bucket *B+1*, so the emission cycle's ts **is** the close
instant of *B* — C2-correct (a bar is actionable only once complete).
`SimBroker` marks the held exposure against `close15` at that instant;
`Session` indexes off the *same* close-instant. **Off-by-one:** the prose
"bar3" is 1-indexed by ordinal; under close-count indexing,
`bars_since_open == 3` is the close of the 09:3009:45 bar. Pin this once in
`Session`'s RED test and reuse the constant in the `EqConst` bindings.
2. **`bool → f64` via `Latch` output, no cast node.** `SimBroker` slot 0
(`exposure`) is `f64` and reads a cold leg as `0.0`; a held position *is*
exposure `+1`, flat *is* `0.0`. Every other `bool` in the DAG (`breakout`,
`isBar3`, `isBar5Close`, `entry`) stays `bool` on the hot path (a first-class
streamed scalar). The only `bool → f64` transition lives inside `Latch.eval`.
3. **Firing.** The *only* `Barrier(0)` group is `Resample`'s 4 OHLC inputs (four
separate M1 sources at the same M1 timestamp — the `Ohlcv` fixture
precedent). Everything downstream is `Firing::Any` and co-fresh because it all
hangs off the single `Resample` emission cycle. `Latch.set`/`reset` are `Any`
(disjoint pulses on different cycles). This holds **provided** `Session` is
triggered off a `Resample` field (see 4).
4. **`Session` trigger is `f64` Any, wired from `close15`** (the trigger *value*
is unused — `Session` reads only `ctx.now()` + config). This makes `Session`
fire on exactly the bar-close cycle (the once-per-bar contract) without a new
`Resample` field. Pin in `Session`'s RED test that the trigger value is
ignored.
5. **Dependency:** `Session` needs DST-correct wall-clock math →
`chrono` / `chrono-tz` enters `aura-std` (a vetted standard crate; do not
hand-roll timezone/DST). `chrono` is already transitive via aura-ingest's
data-server.
## 5. Node contracts
| Node | in | out | params | state / notes |
|---|---|---|---|---|
| `EqConst` | `value:i64 Any` | `bool` | `target:i64` | stateless; `out = (value == target)`. Operator is topology (separate type), only `target` is a knob (C11/C12). The `i64→bool` analogue of `Gt`. |
| `Gt` | `a:f64 Any, b:f64 Any` | `bool` | — | stateless; `out = (a > b)`. First bool-emitting node. Relational family (`Lt/Ge/…`) are separate types, not a swept op param. |
| `And` | `a:bool Any, b:bool Any` | `bool` | — | stateless; `out = a && b`. Logic family (`Or/Not`) are separate types. |
| `Delay` | `series:f64 Any` | `value:f64` | `lag:i64` | C5 register; node-owned ring of length `lag`; warm-up **skip-emit** (`None` until `lag+1` samples). Output depends only on pre-cycle state (the half that lets a future engine close a feedback loop). |
| `Latch` | `set:bool Any, reset:bool Any` | `held:f64` | — | C5 SR register; `held``1.0` on `set`, → `0.0` on `reset`; initial `0.0`; define set&reset-same-cycle priority (cannot coincide here, but specify). Feeds `SimBroker.slot0` directly. |
| `Resample` | `open,high,low,close: 4×f64 Barrier(0)` | record `open15,high15,low15,close15: f64` | `period_minutes:i64` | accumulator (`open=first, high=max, low=min, close=last`); emit-on-rollover via `ctx.now()` bucket (C2); **drops** the partial last bar. |
| `Session` | `trigger:f64 Any` (value ignored) | `bars_since_open:i64` | `open_time, tz, period_minutes` (+ indices via `EqConst` downstream) | derives Frankfurt local time from `ctx.now()` (chrono-tz, DST); close-count indexing; emits `0`/none outside the session. |
## 6. Build order & deliverable
Dependency-ordered (tiny independent primitives first), each a RED-first TDD
unit per the skills pipeline:
1. `EqConst` — unblocks the `Session` gate seam (biggest gap).
2. `Gt``Gt.rhs` depends on `Delay`; build the comparator early.
3. `And`.
4. `Delay`.
5. `Latch`.
6. `Resample` (heaviest internal accumulator).
7. `Session` (heaviest external surface — admits `chrono-tz`).
8. **Synthetic end-to-end fixture** — the Phase-1 C9 deliverable.
### E2E fixture (deliverable 8)
`crates/aura-engine` test `ger40_session_breakout_holds_exposure_bar3_to_bar5`,
a hand-wired `FlatGraph` (no composite/strategy-builder API is in scope — that
is a later milestone). One Frankfurt session, 4 synthetic OHLC `VecSource`
streams declared in fixed source order (k-way merge tie-breaks by source index).
Asserts:
- equity flat `0.0` through bar1..bar3-close, then integrates `+1·Δprice` per
price cycle bar3-close → bar5-close, then flat after exit;
- a second `Recorder` on `Latch.held`: `0.0` before bar3-close, `1.0` through
the hold, `0.0` after;
- a no-entry control session (`bar3 close == bar2 high`, strict-`>` false) stays
flat throughout — pins the strict-greater semantic;
- run twice → byte-identical (C1 determinism).
**Two traps the fixture must avoid:** (a) include an M1 tick in *bar6*'s window
so bar5 actually closes (the partial last bar is dropped) — else exposure leaks
to EOF; (b) supply ≥2 bars before the entry so `Delay` is warm.
## 7. Phase 2 (out of scope here)
The real GER40 strategy lives in a *separate consumer crate* (its own repo),
depending on `aura-engine` + `aura-std` cargo-natively (C9). It is not built via
`aura new` / cdylib hosting (a later milestone) — just a hand-wired Rust
bin/test running `bootstrap → run` on real GER40 M1 bars. Related existing
issues it will need: #22 (SimBroker pip value is per-asset — GER40 is an index,
`pip_size = 1.0`, not FX `0.0001`), #80 / #81 (real-data ingestion seams).