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
12 KiB
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— newpub fnincrates/aura-engine/src/report.rs, besidesummarize_r. Re-export from the crate root mirrorsPositionEvent(already exported; the fn joins the samepub useline inlib.rs).r_col::DIRECTION(=4),r_col::SIZE(=10) — two added column indices in the existing privater_colmodule (the lockstep contract withaura-std'sFIELD_NAMES/RECORD_KINDS, guarded bystage1_r_e2e.rs).- Internal
Bookstate — the derive's single-position book (position_id,dir,volume);in_flightis 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 →
Buyat the open cycle,Closeat the exit cycle;event_tsfrom each row's own cycle. - short — a short entry →
Sell. - window-end open — last row
open = true, never closed → the open event only, noClose. - empty record → empty vec.
- position_id monotonic — successive positions get 0, 1, 2…; each
Closecarries theposition_idof the open it closes;volumeequals 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 faithfulPositionEventtable from aPositionManagementrecord.- A reversal emits
Closethen the opposite open at oneevent_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_tsis 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, andcargo clippy --workspace --all-targets -- -D warningsare clean, and no existing golden / serde test changes (the derive adds a function; it touches no existing output).