Files
Aura/docs/specs/0068-position-event-derive.md
T
Brummel ef8537c44f spec: 0068 position-event-derive — book first-difference table (#115) (boss-signed)
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
2026-06-24 19:45:31 +02:00

12 KiB
Raw Blame History

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

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 Scalars (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)

use aura_engine::{derive_position_events, PositionEvent, PositionAction};

// After a stage1-r run, `r_record: Vec<(Timestamp, Vec<Scalar>)>` 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<PositionEvent> = 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)

#[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:

// 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<Scalar>)],
    instrument_id: i64,
) -> Vec<PositionEvent> {
    struct Book { position_id: i64, dir: 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
        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<Scalar>) rows, in cycle order) → derive_position_events walks rows, maintaining the book → emits Vec<PositionEvent>. 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).