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:
@@ -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:00–09:15, bar2 = 09:15–09:30,
|
||||
bar3 = 09:30–09: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:30–09: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).
|
||||
Reference in New Issue
Block a user