Files
Aura/docs/plans/0068-position-event-derive.md
T
Brummel 1647737baa plan: 0068 position-event-derive
Three tasks for spec 0068: (1) derive_position_events + r_col DIRECTION/SIZE
consts + crate-root re-export + report.rs unit tests (reversal, normal lifecycle,
short, window-end open, empty, monotonic position_id); (2) stage1_r_e2e.rs
lockstep-guard direction pin + an agreement E2E folding both summarize_r and
derive_position_events over one recorded ledger; (3) full workspace
build/test/clippy verification. Purely additive — no existing output touched.

refs #115
2026-06-24 19:52:27 +02:00

14 KiB

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<PositionEvent> 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):

    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:

// 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<Scalar>) {
    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<Scalar>)> = 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:

/// 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<Scalar>)],
    instrument_id: i64,
) -> Vec<PositionEvent> {
    // The derive's single-position book (in_flight is structurally 0).
    struct Book {
        position_id: i64,
        volume: f64,
    }
    let mut out: Vec<PositionEvent> = Vec::new();
    let mut book: Option<Book> = 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):

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:

const DIRECTION: usize = 4;

And extend the engine import (L29) from use aura_engine::{RMetrics, summarize_r}; to:

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:

    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):

/// 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<i64> = 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.)