From ef8537c44fef2e87ee0e1ae09490b369c5cfc1b9 Mon Sep 17 00:00:00 2001 From: Brummel Date: Wed, 24 Jun 2026 19:45:31 +0200 Subject: [PATCH] =?UTF-8?q?spec:=200068=20position-event-derive=20?= =?UTF-8?q?=E2=80=94=20book=20first-difference=20table=20(#115)=20(boss-si?= =?UTF-8?q?gned)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Derive the broker-independent position-event table (#114's PositionEvent rows) from a completed PositionManagement run, as the first difference of the executed book (deal = target - book - in_flight; in_flight = 0 for instant backtest fills). A pure post-run reduction `derive_position_events(record, instrument_id)` in aura-engine, sibling of summarize_r, reading the 14-column PM record positionally; a reversal emits Close then the opposite open at one event_ts (close first); a window-end open position emits its open with no synthetic Close. Supersedes the rolled-back 0064 exposure-integral derive (abandoned per #117): this derives from the executed book (the PM record), never from exposure deltas. Stage-2 fixed-fractional / currency / equity-feedback sizing is out of scope (#116 owns the equity->Sizer z^-1 register per C10). Boss-signed: specify Step-5 grounding-check PASS (8/8 load-bearing assumptions ratified by named green tests). Fork decisions recorded on #115. refs #115 --- docs/specs/0068-position-event-derive.md | 256 +++++++++++++++++++++++ 1 file changed, 256 insertions(+) create mode 100644 docs/specs/0068-position-event-derive.md diff --git a/docs/specs/0068-position-event-derive.md b/docs/specs/0068-position-event-derive.md new file mode 100644 index 0000000..6f7f264 --- /dev/null +++ b/docs/specs/0068-position-event-derive.md @@ -0,0 +1,256 @@ +# Position-Event Derive (book first-difference) — Design Spec + +**Date:** 2026-06-24 +**Status:** Draft — awaiting user spec review +**Authors:** orchestrator + Claude + +> Reference issue: #115 (milestone "Realistic broker & position-event table +> (C10 A-side)"). Fork decisions recorded as a #115 comment (cycle 0068). +> Supersedes the rolled-back 0064 exposure-integral derive (abandoned per #117). + +## Goal + +Derive the broker-independent **position-event table** — the `PositionEvent` +rows pinned by #114 — from a completed `PositionManagement` run, as the **first +difference of the executed book** (`deal = target − book − in_flight`; +`in_flight = 0` for the instant-fill backtest). This is the standalone, +deterministic, post-run reduction that #116's realistic brokers will consume and +that #122 will persist. It is the decoupled Stage-2 *audit* layer of C10, +buildable and testable now against the existing flat-1R executor — independent of +the realistic broker, currency equity, and the equity→Sizer feedback (all #116). + +Out of scope (deferred to #116): fixed-fractional sizing that reads account +equity (the only feedback, cut by the z⁻¹ register on the fill edge — C10 assigns +it to #116); the realistic broker; currency equity; persistence (#122); +currency metrics (#121). + +## Architecture + +A pure post-run fold + +``` +derive_position_events(record, instrument_id) -> Vec +``` + +in `aura-engine` (`src/report.rs`), the **sibling of `summarize_r`** — same home +(beside `RunMetrics` / `PositionEvent`, both already there since cycle 0063), same +shape (a total pure function over the `PositionManagement` dense record), same +cross-crate discipline: it reads the 14-column record **positionally** as +type-erased `Scalar`s (C7 SoA), never importing the producer's `aura-std` types. + +It is **not** an in-graph node — the run loop never calls it, so the hot path +stays domain-free (the same standard that placed `PositionEvent`/`summarize_r` in +`aura-engine`). `aura-engine` depends only on `aura-core`, so it **cannot** import +`InstrumentSpec` (that lives in `aura-ingest`, which depends on `aura-engine`; the +reverse is a dependency cycle). The `instrument_id` is therefore a caller-supplied +**scalar** argument — exactly as `summarize_r` takes `round_trip_cost` — extracted +by the caller from the run's `InstrumentSpec.instrument_id` at the source edge. + +The derive maintains a single-position **book** as it walks the record in cycle +order and emits the book's first difference: a `Buy`/`Sell` at each open, a `Close` +at each close. A **reversal** (close + re-open in one cycle) emits `Close` then the +opposite-direction open at the **same `event_ts`**, close before open (the #114 +contract). This is `deal = target − book − in_flight` specialised to the current +single-position-at-a-time executor (`book` ∈ {flat, ±one position}; `in_flight` +always 0). + +## Concrete code shapes + +### Worked usage (what a consumer / the future #123 CLI writes) + +```rust +use aura_engine::{derive_position_events, PositionEvent, PositionAction}; + +// After a stage1-r run, `r_record: Vec<(Timestamp, Vec)>` is the +// PositionManagement dense record (the same value summarize_r already folds). +// `instrument_id` comes from the run's InstrumentSpec (a scalar; the engine +// never imports aura-ingest). +let events: Vec = derive_position_events(&r_record, instrument_id); + +// Each entry is a Buy/Sell; each exit a Close referencing the position it closes. +// A reversal is a Close then the opposite open at the SAME event_ts (close first). +// A position still open at window end has its open event but no synthetic Close — +// the table records actual executed events (summarize_r's force-close is for the +// R metric only, not for the event table). +``` + +### Must-pass test (the #114 reversal contract, now *produced by the derive*) + +```rust +#[test] +fn reversal_derives_close_then_open_at_same_ts() { + // Hand-built PM record (only the columns the derive reads are set; others 0): + // t=10: open LONG size 2.0 (closed=false, direction=+1, size=2.0, open=true) + // t=20: REVERSAL to SHORT (closed=true, direction=-1, size=3.0, open=true) + // t=30: stop close (closed=true, direction=-1, size=3.0, open=false) + let record = pm_rows(&[ + (10, /*closed*/ false, /*dir*/ 1, /*size*/ 2.0, /*open*/ true), + (20, /*closed*/ true, /*dir*/ -1, /*size*/ 3.0, /*open*/ true), + (30, /*closed*/ true, /*dir*/ -1, /*size*/ 3.0, /*open*/ false), + ]); + + let ev = derive_position_events(&record, 42); + + // open long; reversal = close-then-open at t=20; final close at t=30 + assert_eq!(ev.len(), 4); + assert_eq!(ev[0].action, PositionAction::Buy); + assert_eq!(ev[0].event_ts, Timestamp(10)); + assert_eq!(ev[0].volume, 2.0); // unsigned lots = the size column + assert_eq!(ev[0].position_id, 0); + + assert_eq!(ev[1].action, PositionAction::Close); // close the long + assert_eq!(ev[1].position_id, 0); + assert_eq!(ev[1].event_ts, Timestamp(20)); + assert_eq!(ev[1].volume, 2.0); // full close = the book's volume + + assert_eq!(ev[2].action, PositionAction::Sell); // open the short + assert_eq!(ev[2].position_id, 1); + assert_eq!(ev[2].event_ts, Timestamp(20)); // SAME instant as the close + assert_eq!(ev[2].volume, 3.0); + + assert_eq!(ev[3].action, PositionAction::Close); + assert_eq!(ev[3].position_id, 1); + assert_eq!(ev[3].event_ts, Timestamp(30)); + + assert_eq!(ev[0].instrument_id, 42); +} +``` + +### Before → after implementation shape (secondary) + +`PositionEvent` / `PositionAction` already exist (cycle 0063). No struct change. +The change is one new public function plus two column-index constants: + +```rust +// crates/aura-engine/src/report.rs — extend the existing `mod r_col` +mod r_col { + pub const CLOSED: usize = 0; + pub const REALIZED_R: usize = 1; + pub const DIRECTION: usize = 4; // NEW — i64: +1 long, -1 short (0 = flat) + pub const ENTRY_PRICE: usize = 6; + pub const STOP_PRICE: usize = 7; + pub const CONVICTION_AT_ENTRY: usize = 9; + pub const SIZE: usize = 10; // NEW — f64 lots from the Sizer + pub const OPEN: usize = 11; + pub const UNREALIZED_R: usize = 12; +} + +/// Derive the broker-independent position-event table from a `PositionManagement` +/// dense record (read positionally, C7 SoA), as the first difference of the +/// executed book. Pure (C1); no look-ahead (each event's `event_ts` is its own +/// cycle, C2). `instrument_id` is supplied by the caller (the engine never imports +/// `InstrumentSpec`). A reversal emits Close then the opposite open at one +/// `event_ts` (close first); a window-end open position emits its open with no +/// synthetic Close. +pub fn derive_position_events( + record: &[(Timestamp, Vec)], + instrument_id: i64, +) -> Vec { + struct Book { position_id: i64, dir: i64, volume: f64 } + let mut out: Vec = Vec::new(); + let mut book: Option = None; + let mut next_id: i64 = 0; + for (ts, row) in record { + // 1) close first: the book held into this cycle exited + if row[r_col::CLOSED].as_bool() + && let Some(b) = book.take() + { + out.push(PositionEvent { + event_ts: *ts, + action: PositionAction::Close, + position_id: b.position_id, + instrument_id, + volume: b.volume, + }); + } + // 2) then open: a position is open at cycle end the book isn't tracking + if row[r_col::OPEN].as_bool() && book.is_none() { + let dir = row[r_col::DIRECTION].as_i64(); + let volume = row[r_col::SIZE].as_f64(); + let action = if dir >= 0 { PositionAction::Buy } else { PositionAction::Sell }; + out.push(PositionEvent { + event_ts: *ts, + action, + position_id: next_id, + instrument_id, + volume, + }); + book = Some(Book { position_id: next_id, dir, volume }); + next_id += 1; + } + } + out +} +``` + +(Exact bytes / `Scalar` accessor names — `.as_bool()` / `.as_i64()` / `.as_f64()` +mirror `summarize_r`'s reads — and the test helper `pm_rows`/`pm_record` are the +planner's job.) + +## Components + +- **`derive_position_events`** — new `pub fn` in `crates/aura-engine/src/report.rs`, + beside `summarize_r`. Re-export from the crate root mirrors `PositionEvent` + (already exported; the fn joins the same `pub use` line in `lib.rs`). +- **`r_col::DIRECTION` (=4), `r_col::SIZE` (=10)** — two added column indices in the + existing private `r_col` module (the lockstep contract with `aura-std`'s + `FIELD_NAMES`/`RECORD_KINDS`, guarded by `stage1_r_e2e.rs`). +- **Internal `Book` state** — the derive's single-position book (`position_id`, + `dir`, `volume`); `in_flight` is structurally 0 (instant fills) so it is not + represented. + +## Data flow + +`PositionManagement` dense record (per-cycle `(Timestamp, Vec)` rows, in +cycle order) → `derive_position_events` walks rows, maintaining the book → emits +`Vec`. The caller (a test, or the future #123 CLI) supplies +`instrument_id` from the run's `InstrumentSpec` at the source edge. No node, no +graph wiring, no hot-path call — the run loop is untouched (C14 domain-free hot +path holds, as for `summarize_r`). + +## Error handling + +The function is **total and pure**: an empty record → empty vec; a window-end open +position → its open event with no synthetic Close. The record's column layout is +the producer-guaranteed `PositionManagement` lockstep contract (`stage1_r_e2e.rs` +guards it), so malformed input is not a runtime concern; no panics, no `Result`. +`direction == 0` cannot occur on an `open` row (the executor only opens on a +nonzero bias), and the `dir >= 0 => Buy` branch is a total mapping regardless. + +## Testing strategy + +Unit tests in `report.rs` (sibling of the `summarize_r` tests): + +- **reversal** → Close then opposite open at the same `event_ts`, close first + (the must-pass test above). +- **normal lifecycle** — open long, hold one cycle, stop-close → `Buy` at the open + cycle, `Close` at the exit cycle; `event_ts` from each row's own cycle. +- **short** — a short entry → `Sell`. +- **window-end open** — last row `open = true`, never closed → the open event only, + no `Close`. +- **empty record** → empty vec. +- **position_id monotonic** — successive positions get 0, 1, 2…; each `Close` + carries the `position_id` of the open it closes; `volume` equals the book volume, + unsigned. + +E2E (extend `crates/aura-engine/tests/stage1_r_e2e.rs`): run the existing stage1-r +seam, fold both `summarize_r` and `derive_position_events`, and assert the table is +consistent with the metrics — the number of `Close` events equals +`summarize_r`'s closed-trade count (`n_trades` minus any window-end open), and +every `Close`'s `position_id` was opened by an earlier `Buy`/`Sell`. + +## Acceptance criteria + +- `derive_position_events(record, instrument_id)` produces a faithful + `PositionEvent` table from a `PositionManagement` record. +- A reversal emits `Close` then the opposite open at one `event_ts`, close before + open (the #114 contract, now *derived*, not just hand-built in a test). +- A window-end open position emits its open with no synthetic `Close`. +- Deterministic (C1); no look-ahead — each event's `event_ts` is its own cycle (C2); + broker-independent (C10); pure scalar columns, no new streamed type (C7); the + hot path is untouched (no node, C14). +- Fixed-fractional / currency / equity-feedback sizing is **not** in scope (#116). +- Additive only: `cargo build --workspace`, `cargo test --workspace`, and + `cargo clippy --workspace --all-targets -- -D warnings` are clean, and no existing + golden / serde test changes (the derive adds a function; it touches no existing + output).