diff --git a/docs/plans/0068-position-event-derive.md b/docs/plans/0068-position-event-derive.md new file mode 100644 index 0000000..0055518 --- /dev/null +++ b/docs/plans/0068-position-event-derive.md @@ -0,0 +1,366 @@ +# 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`.)