diff --git a/docs/design/INDEX.md b/docs/design/INDEX.md index ef23474..643cf1e 100644 --- a/docs/design/INDEX.md +++ b/docs/design/INDEX.md @@ -674,6 +674,25 @@ member's equity/exposure/r_equity under `runs/traces///` via the refusal guard is gone, so per-member `--trace` is symmetric across all three sweep strategies and a swept member charts (`chart / --tap r_equity`). +**Realization (cycle 0068, #115 — position-event derive).** The Stage-2 audit layer's +*derivation* now landed (the schema was 0063, #114; the realistic brokers consuming it +remain #116). `derive_position_events(record, instrument_id) -> Vec` +(`aura-engine`, beside `summarize_r`) is the **first difference of the executed book**: +a pure post-run reduction over the `PositionManagement` dense record (read positionally +as type-erased `Scalar`s, C7 SoA — no in-graph node, so the hot path stays domain-free, +C14), emitting a `Buy`/`Sell` at each open and a `Close` at each exit, a reversal (or a +stop-then-same-cycle reopen) emitting **Close then the opposite open at one `event_ts`** +(close first — the C8 ">1 event per instant" case that forces this to be a *derived* +table, not a per-`eval` output). The close sizes the **actual book** (the closed +position's stored volume), never an exposure delta — the post-reframe replacement for the +rolled-back 0064 exposure-integral derive (#117). `instrument_id` is a caller-supplied +scalar (`aura-engine` depends only on `aura-core`, so it never imports `InstrumentSpec`). +A position open at window end emits its open with **no synthetic `Close`** (the table +records actual executed events; `summarize_r`'s force-close is for the R metric only). +The `r_col` ⟷ PM-record lockstep is now guard-pinned for `direction` too. **Still #116:** +fixed-fractional / currency / equity-feedback sizing (the equity→Sizer z⁻¹ fill-edge +register) and the realistic brokers that consume the table. + ### C11 — Generalized sources; record-then-replay determinism boundary **Guarantee.** A source is anything that produces timestamped scalar streams — market data (`data-server`) and non-financial sources (e.g. a news-agent node diff --git a/docs/plans/0068-position-event-derive.md b/docs/plans/0068-position-event-derive.md deleted file mode 100644 index 0055518..0000000 --- a/docs/plans/0068-position-event-derive.md +++ /dev/null @@ -1,366 +0,0 @@ -# Position-Event Derive — Implementation Plan - -> **Parent spec:** `docs/specs/0068-position-event-derive.md` -> -> **For agentic workers:** REQUIRED SUB-SKILL: use the `implement` skill to run -> this plan. Steps use `- [ ]` checkboxes for tracking. - -**Goal:** Add the pure post-run reduction `derive_position_events(record, -instrument_id) -> Vec` to `aura-engine`, deriving the -broker-independent position-event table as the first difference of the executed -book from a `PositionManagement` dense record. - -**Architecture:** One new `pub fn` in `crates/aura-engine/src/report.rs` beside -`summarize_r` (same positional, type-erased `Scalar` read of the 14-column PM -record), two added private `r_col` constants (`DIRECTION=4`, `SIZE=10`), a -crate-root re-export, unit tests in `report.rs`, and a lockstep-guard extension + -an agreement E2E test in `crates/aura-engine/tests/stage1_r_e2e.rs`. Purely -additive: no signature change, no struct change, no existing output touched. - -**Tech Stack:** Rust, `aura-engine` (depends only on `aura-core`), the existing -`PositionEvent` / `PositionAction` value types (cycle 0063), the -`PositionManagement` dense-record contract (`aura-std`). - ---- - -**Files this plan creates or modifies:** - -- Modify: `crates/aura-engine/src/report.rs:69-77` — add `r_col::DIRECTION=4` and - `r_col::SIZE=10`; add `pub fn derive_position_events` immediately after - `summarize_r` (ends L218); add unit tests in the `#[cfg(test)] mod tests` - block (opens L515). -- Modify: `crates/aura-engine/src/lib.rs:64-67` — add `derive_position_events` - to the `pub use report::{…}` re-export. -- Test: `crates/aura-engine/tests/stage1_r_e2e.rs` — add `const DIRECTION = 4` - (beside L35-42), extend `r_col_indices_match_producer_field_layout` (L291-324) - to name-pin `direction`, and add one agreement E2E test. - ---- - -### Task 1: `derive_position_events` + `r_col` consts + re-export + unit tests - -**Files:** -- Modify: `crates/aura-engine/src/report.rs` (`mod r_col` L69-77; new fn after - L218; tests in `mod tests` from L515) -- Modify: `crates/aura-engine/src/lib.rs:64-67` - -- [ ] **Step 1: Add the two column-index constants to `mod r_col`** - -In `crates/aura-engine/src/report.rs`, inside the existing `mod r_col { … }` -(L69-77), add these two lines (keep the others unchanged; `OPEN=11` is already -present): - -```rust - pub const DIRECTION: usize = 4; - pub const SIZE: usize = 10; -``` - -(Place `DIRECTION` after `REALIZED_R` and `SIZE` after `CONVICTION_AT_ENTRY` so -the consts stay in ascending index order; exact position is cosmetic.) - -- [ ] **Step 2: Write the failing unit tests + the row helper** - -In `crates/aura-engine/src/report.rs`, inside `#[cfg(test)] mod tests` (after the -existing `summarize_r_*` tests; `use super::*;` is already at the top of the -module, bringing `Scalar`, `Timestamp`, `r_col`, `PositionEvent`, -`PositionAction`, and the new `derive_position_events` into scope), add: - -```rust -// One PositionManagement dense record row for the derive tests: only the columns -// derive_position_events reads (closed, direction, size, open) are set; the rest -// default to 0. Distinct per-row timestamps (the derive keys events on the row's -// own cycle, not the entry_ts column). -fn pm_row(ts: i64, closed: bool, dir: i64, size: f64, open: bool) -> (Timestamp, Vec) { - let mut v = vec![Scalar::f64(0.0); r_col::UNREALIZED_R + 1]; - v[r_col::CLOSED] = Scalar::bool(closed); - v[r_col::DIRECTION] = Scalar::i64(dir); - v[r_col::SIZE] = Scalar::f64(size); - v[r_col::OPEN] = Scalar::bool(open); - (Timestamp(ts), v) -} - -#[test] -fn derive_reversal_emits_close_then_opposite_open_at_one_ts() { - let rec = vec![ - pm_row(10, false, 1, 2.0, true), // open long - pm_row(20, true, -1, 3.0, true), // reversal: close long, reopen short - pm_row(30, true, -1, 3.0, false), // close short, flat - ]; - let ev = derive_position_events(&rec, 42); - assert_eq!(ev.len(), 4); - assert_eq!(ev[0].action, PositionAction::Buy); - assert_eq!(ev[0].event_ts, Timestamp(10)); - assert_eq!(ev[0].position_id, 0); - assert_eq!(ev[0].volume, 2.0); - assert_eq!(ev[0].instrument_id, 42); - assert_eq!(ev[1].action, PositionAction::Close); - assert_eq!(ev[1].position_id, 0); - assert_eq!(ev[1].event_ts, Timestamp(20)); - assert_eq!(ev[1].volume, 2.0); // close sizes the actual (old) book, not the new leg - assert_eq!(ev[2].action, PositionAction::Sell); - 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)); -} - -#[test] -fn derive_normal_open_hold_close_lifecycle() { - let rec = vec![ - pm_row(1, false, 1, 1.0, true), // open long - pm_row(2, false, 1, 1.0, true), // hold (no event) - pm_row(3, true, 1, 1.0, false), // close to flat - ]; - let ev = derive_position_events(&rec, 9); - assert_eq!(ev.len(), 2); - assert_eq!(ev[0].action, PositionAction::Buy); - assert_eq!(ev[0].event_ts, Timestamp(1)); - assert_eq!(ev[1].action, PositionAction::Close); - assert_eq!(ev[1].event_ts, Timestamp(3)); - assert_eq!(ev[1].position_id, 0); -} - -#[test] -fn derive_short_entry_emits_sell() { - let ev = derive_position_events(&[pm_row(5, false, -1, 1.0, true)], 0); - assert_eq!(ev.len(), 1); - assert_eq!(ev[0].action, PositionAction::Sell); - assert_eq!(ev[0].position_id, 0); -} - -#[test] -fn derive_window_end_open_has_no_synthetic_close() { - // opened and never closed in-window -> open event only, no Close. - let rec = vec![ - pm_row(1, false, 1, 1.0, true), - pm_row(2, false, 1, 1.0, true), // still open on the last row - ]; - let ev = derive_position_events(&rec, 0); - assert_eq!(ev.len(), 1); - assert_eq!(ev[0].action, PositionAction::Buy); -} - -#[test] -fn derive_empty_record_is_empty_table() { - let rec: Vec<(Timestamp, Vec)> = vec![]; - assert!(derive_position_events(&rec, 0).is_empty()); -} - -#[test] -fn derive_position_ids_are_monotonic_across_trades() { - let rec = vec![ - pm_row(1, false, 1, 1.0, true), // open id0 - pm_row(2, true, 1, 1.0, false), // close id0 - pm_row(3, false, -1, 1.0, true), // open id1 (short) - pm_row(4, true, -1, 1.0, false), // close id1 - ]; - let ev = derive_position_events(&rec, 0); - assert_eq!(ev.len(), 4); - assert_eq!(ev[0].position_id, 0); - assert_eq!(ev[1].position_id, 0); - assert_eq!(ev[2].position_id, 1); - assert_eq!(ev[3].position_id, 1); -} -``` - -- [ ] **Step 3: Run the unit tests to verify they fail (RED)** - -Run: `cargo test --workspace derive_` -Expected: FAIL — compile error `cannot find function 'derive_position_events' in -this scope` (the function does not exist yet). This is the RED state for a new -pure function (its first reference cannot compile until Step 4). - -- [ ] **Step 4: Implement `derive_position_events`** - -In `crates/aura-engine/src/report.rs`, immediately after `summarize_r` (which -ends at L218, before the `PositionAction` enum), add: - -```rust -/// Derive the broker-independent position-event table from a `PositionManagement` -/// dense record (read positionally, C7 SoA), as the first difference of the -/// executed book (`deal = target - book - in_flight`; `in_flight = 0` for the -/// instant-fill backtest). 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 position still open on the last row emits its -/// open with no synthetic `Close` (the table records actual executed events — -/// unlike `summarize_r`, which force-closes for the R metric). -pub fn derive_position_events( - record: &[(Timestamp, Vec)], - instrument_id: i64, -) -> Vec { - // The derive's single-position book (in_flight is structurally 0). - struct Book { - position_id: 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 this cycle. - 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 is not 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, volume }); - next_id += 1; - } - } - out -} -``` - -- [ ] **Step 5: Re-export the function from the crate root** - -In `crates/aura-engine/src/lib.rs`, the `pub use report::{…}` block (L64-67) adds -`derive_position_events` to the list (alphabetical-ish, beside `summarize_r`): - -```rust -pub use report::{ - derive_position_events, f64_field, join_on_ts, summarize, summarize_r, ColumnarTrace, - JoinedRow, PositionAction, PositionEvent, RMetrics, RunManifest, RunMetrics, RunReport, -}; -``` - -- [ ] **Step 6: Run the unit tests to verify they pass (GREEN)** - -Run: `cargo test --workspace derive_` -Expected: PASS — the 6 `derive_*` tests pass (0 failed). - ---- - -### Task 2: lockstep-guard `direction` pin + agreement E2E test - -**Files:** -- Test: `crates/aura-engine/tests/stage1_r_e2e.rs` (consts L35-42; guard test - L291-324; new E2E test) - -- [ ] **Step 1: Add the `DIRECTION` index const + extend the imports** - -In `crates/aura-engine/tests/stage1_r_e2e.rs`, beside the re-declared index -consts (L35-42, which already hold `SIZE = 10`), add: - -```rust -const DIRECTION: usize = 4; -``` - -And extend the engine import (L29) from -`use aura_engine::{RMetrics, summarize_r};` to: - -```rust -use aura_engine::{PositionAction, RMetrics, derive_position_events, summarize_r}; -``` - -- [ ] **Step 2: Name-pin `direction` in the lockstep guard test** - -In `r_col_indices_match_producer_field_layout` (L291-324), beside the existing -`SIZE` assertions (`PM_FIELD_NAMES[SIZE] == "size"`, `PM_RECORD_KINDS[SIZE] == -ScalarKind::F64`), add the matching `DIRECTION` pin: - -```rust - assert_eq!(PM_FIELD_NAMES[DIRECTION], "direction"); - assert_eq!(PM_RECORD_KINDS[DIRECTION], ScalarKind::I64); -``` - -- [ ] **Step 3: Add the agreement E2E test** - -In `crates/aura-engine/tests/stage1_r_e2e.rs`, add a new test that folds both -`summarize_r` and `derive_position_events` over one recorded ledger (reusing the -existing `run_chain_ledger` / `long_path` helpers and the known -`synthetic_long_then_stop` scenario): - -```rust -/// Property: the derived position-event table agrees with the R-metrics fold over -/// the SAME recorded ledger — one Close per closed round-trip, every open either -/// closed in-window or still open at window end, every Close referencing an earlier -/// open, and the caller's instrument_id threaded onto every event. -#[test] -fn derived_event_table_agrees_with_r_metrics_over_one_ledger() { - let mut stop = FixedStop::new(5.0); - let ledger = - run_chain_ledger(&mut stop, &long_path(&[100.0, 104.0, 108.0, 110.0, 102.0, 96.0, 94.0])); - let m: RMetrics = summarize_r(&ledger, 0.0); - let events = derive_position_events(&ledger, 7); - - let closes = events.iter().filter(|e| e.action == PositionAction::Close).count() as u64; - let opens = events - .iter() - .filter(|e| matches!(e.action, PositionAction::Buy | PositionAction::Sell)) - .count() as u64; - // one Close per closed round-trip; a window-end open position has no Close. - assert_eq!(closes, m.n_trades - m.n_open_at_end); - // every open is either closed in-window or still open at window end. - assert_eq!(opens, closes + m.n_open_at_end); - assert!(opens >= 1, "scenario must open at least one position"); - - // referential integrity: every Close references a position_id opened earlier. - let mut opened: std::collections::HashSet = std::collections::HashSet::new(); - for e in &events { - match e.action { - PositionAction::Buy | PositionAction::Sell => { - opened.insert(e.position_id); - } - PositionAction::Close => assert!(opened.contains(&e.position_id)), - } - } - // the caller-supplied instrument_id is threaded onto every event. - assert!(events.iter().all(|e| e.instrument_id == 7)); -} -``` - -- [ ] **Step 4: Run the E2E + guard tests to verify they pass** - -Run: `cargo test --workspace stage1_r_e2e` -Expected: PASS — all tests in the file pass (0 failed), including the new -`derived_event_table_agrees_with_r_metrics_over_one_ledger` and the extended -`r_col_indices_match_producer_field_layout`. - ---- - -### Task 3: full workspace verification - -**Files:** none (verification only) - -- [ ] **Step 1: Build the workspace** - -Run: `cargo build --workspace` -Expected: clean build, 0 errors. - -- [ ] **Step 2: Run the full test suite** - -Run: `cargo test --workspace` -Expected: 0 failed across every test binary. The suite gains 7 new tests vs the -pre-cycle baseline (6 `derive_*` unit tests in `report.rs` + 1 -`derived_event_table_agrees_with_r_metrics_over_one_ledger` E2E); the guard test -count is unchanged (it gained assertions, not a new `#[test]`). Confirm the new -`derive_*` and `derived_event_table_*` tests are present and passing in the -summary, and that no previously-green test regressed. - -- [ ] **Step 3: Lint** - -Run: `cargo clippy --workspace --all-targets -- -D warnings` -Expected: clean, 0 warnings. (The `Book` struct stores only the fields the close -path reads — `position_id`, `volume` — so no dead-field lint; `dir` is a local -used only to choose `Buy`/`Sell`.) diff --git a/docs/specs/0068-position-event-derive.md b/docs/specs/0068-position-event-derive.md deleted file mode 100644 index 6f7f264..0000000 --- a/docs/specs/0068-position-event-derive.md +++ /dev/null @@ -1,256 +0,0 @@ -# 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).