feat(0068): position-event derive — book first-difference table (#115)

Add `derive_position_events(record, instrument_id) -> Vec<PositionEvent>` to
aura-engine (report.rs), the broker-independent Stage-2 audit layer of C10: the
first difference of the executed book derived from a PositionManagement dense
record. A pure post-run reduction, sibling of summarize_r — same positional,
type-erased Scalar read of the 14-column record, no in-graph node (the hot path
stays domain-free, C14). 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 (the table records actual executed events — unlike summarize_r,
which force-closes for the R metric). instrument_id is a caller-supplied scalar
(aura-engine depends only on aura-core; it cannot import InstrumentSpec).

Two r_col indices added (DIRECTION=4, SIZE=10), the lockstep guard in
stage1_r_e2e.rs extended to name-pin `direction`, and an agreement E2E folds both
summarize_r and derive_position_events over one recorded ledger (one Close per
closed round-trip; every open closed-or-open-at-end; referential integrity).

Scope: the derive only. Fixed-fractional / currency / equity-feedback sizing is
#116 (it owns the equity->Sizer z^-1 register per C10) — deferring it here avoids
a circular dependency. Supersedes the rolled-back 0064 exposure-integral derive
(abandoned per #117): this derives from the executed book, never exposure deltas.

Verified by the orchestrator: cargo build + clippy --all-targets -D warnings
clean; full workspace suite 0 failed; aura-engine lib 214 pass (incl. the 6
derive_* unit tests); stage1_r_e2e 11 pass (incl. the new E2E + extended guard).
Fork decisions recorded on #115.

closes #115
This commit is contained in:
2026-06-24 20:19:31 +02:00
parent 1647737baa
commit 7aaa3e5d5c
3 changed files with 261 additions and 3 deletions
+2 -2
View File
@@ -62,8 +62,8 @@ pub use harness::{
VecSource,
};
pub use report::{
f64_field, join_on_ts, summarize, summarize_r, ColumnarTrace, JoinedRow, PositionAction,
PositionEvent, RMetrics, RunManifest, RunMetrics, RunReport,
derive_position_events, f64_field, join_on_ts, summarize, summarize_r, ColumnarTrace,
JoinedRow, PositionAction, PositionEvent, RMetrics, RunManifest, RunMetrics, RunReport,
};
pub use sweep::{
sweep, GridSpace, ParamRange, RandomSpace, Space, SweepError, SweepFamily, SweepPoint,
+153
View File
@@ -69,9 +69,11 @@ pub struct RMetrics {
mod r_col {
pub const CLOSED: usize = 0;
pub const REALIZED_R: usize = 1;
pub const DIRECTION: usize = 4;
pub const ENTRY_PRICE: usize = 6;
pub const STOP_PRICE: usize = 7;
pub const CONVICTION_AT_ENTRY: usize = 9;
pub const SIZE: usize = 10;
pub const OPEN: usize = 11;
pub const UNREALIZED_R: usize = 12;
}
@@ -217,6 +219,59 @@ pub fn summarize_r(record: &[(Timestamp, Vec<Scalar>)], round_trip_cost: f64) ->
}
}
/// 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
}
/// The three position-event actions (C10). Direction IS the action; volume is
/// unsigned. Serde-encoded as its i64 mapping (`Buy=0, Sell=1, Close=2`) so the
/// persisted/columnar form stays C7-scalar and ledger-faithful (`action: i64`).
@@ -855,6 +910,104 @@ mod tests {
assert!((net.net_expectancy_r - 1.9).abs() < 1e-9, "net = 2 - 1/10; got {}", net.net_expectancy_r);
}
// 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);
}
fn samples(values: &[f64]) -> Vec<(Timestamp, f64)> {
values
.iter()
+106 -1
View File
@@ -26,7 +26,7 @@
//! `r_col_indices_match_producer_field_layout` test pins that contract.
use aura_core::{AnyColumn, Ctx, Node, Scalar, ScalarKind, Timestamp};
use aura_engine::{RMetrics, summarize_r};
use aura_engine::{PositionAction, RMetrics, derive_position_events, summarize_r};
use aura_std::{FixedStop, PM_FIELD_NAMES, PM_RECORD_KINDS, PM_WIDTH, PositionManagement};
// The indices `report.rs::summarize_r` reads the dense record by. Re-declared here
@@ -40,6 +40,7 @@ const ENTRY_PRICE: usize = 6;
const STOP_PRICE: usize = 7;
const CONVICTION_AT_ENTRY: usize = 9;
const SIZE: usize = 10;
const DIRECTION: usize = 4;
/// Property: the real producer->consumer seam composes. Driving `FixedStop` ->
/// `PositionManagement` directly (node-by-node, not a bootstrapped graph) over a
@@ -321,4 +322,108 @@ fn r_col_indices_match_producer_field_layout() {
// `summarize_r` makes (the lockstep claim in that fixture's header relies on this).
assert_eq!(PM_FIELD_NAMES[SIZE], "size");
assert_eq!(PM_RECORD_KINDS[SIZE], ScalarKind::F64);
// iter-2 direction column (4): the signed bias sign `derive_position_events` reads to
// decide Buy vs Sell. Pinned against the producer layout so a reorder can't silently
// flip the derived event-table's action — the lockstep claim now covers the derive too.
assert_eq!(PM_FIELD_NAMES[DIRECTION], "direction");
assert_eq!(PM_RECORD_KINDS[DIRECTION], ScalarKind::I64);
}
/// Property (#114, derived not hand-built): **a bias reversal in the REAL producer
/// derives Close-then-opposite-open at one `event_ts`, close first.** The reversal
/// contract is the central acceptance criterion of the derive, but the `report.rs`
/// unit sets the `closed`/`direction`/`open`/`size` columns by hand, independently
/// of `PositionManagement` — so it can stay green while the producer's actual
/// reversal row (one record carrying `closed_this_cycle=true` AND `open=true` AND a
/// flipped `direction`) disagrees with the column indices the derive reads. Here the
/// real `FixedStop -> PositionManagement` chain emits that one reversal record (long
/// opened @100, bias flips to short @110 with no stop hit -> ReversalLeg + reopen),
/// and the derive must turn it into exactly Buy@t0, then at the SAME flip instant
/// Close(the long) before Sell(the short). A producer-layout drift that misplaced
/// `direction`, or a derive that opened before it closed, breaks this even though the
/// hand-built `report.rs` reversal unit stays green — the cross-crate seam this guards.
#[test]
fn reversal_in_real_producer_derives_close_then_opposite_open_at_one_ts() {
let mut stop = FixedStop::new(10.0);
// (bias, price): open long @100, hold @105, then bias flips short @110 (above the
// 90 stop -> no stop hit, a clean reversal leg).
let ledger = run_chain_ledger(&mut stop, &[(1.0, 100.0), (1.0, 105.0), (-1.0, 110.0)]);
let events = derive_position_events(&ledger, 3);
// open long; reversal at the flip cycle = Close(long) then Sell(short), same ts;
// the short is open at window end, so no synthetic Close after it.
assert_eq!(events.len(), 3, "Buy, Close, Sell; got {events:?}");
assert_eq!(events[0].action, PositionAction::Buy);
assert_eq!(events[0].position_id, 0);
// close before open, at one and the same instant (the #114 ordering contract).
assert_eq!(events[1].action, PositionAction::Close);
assert_eq!(events[1].position_id, 0, "the Close references the long it closes");
assert_eq!(events[2].action, PositionAction::Sell, "opposite-direction reopen");
assert_eq!(events[2].position_id, 1, "a fresh position id for the reopened leg");
assert_eq!(
events[1].event_ts, events[2].event_ts,
"Close and the opposite open share the reversal instant",
);
// close strictly before open in emission order (the table is ordered, close first).
let (close_idx, sell_idx) = (1usize, 2usize);
assert!(close_idx < sell_idx, "Close must be emitted before the opposite open");
assert!(events.iter().all(|e| e.instrument_id == 3));
}
/// Property: **a short entry in the REAL producer derives a `Sell`, reading the
/// producer's signed `direction` column across the crate seam.** Every other E2E
/// path opens longs only, so the derive's `dir < 0 => Sell` branch — and its read of
/// the real `PositionManagement` `direction` column (i64, index 4) — is exercised
/// nowhere else end-to-end. A constant `bias = -1` path opens a short directly; the
/// derive must emit `Sell` (not `Buy`). A producer that wrote `direction` to a
/// different index, or a derive that defaulted to `Buy`, breaks this.
#[test]
fn short_entry_in_real_producer_derives_sell() {
let mut stop = FixedStop::new(10.0);
// constant short bias: open short @100, hold while price falls (a winning short).
let ledger = run_chain_ledger(&mut stop, &[(-1.0, 100.0), (-1.0, 98.0), (-1.0, 96.0)]);
let events = derive_position_events(&ledger, 1);
assert!(!events.is_empty(), "a short must open");
assert_eq!(events[0].action, PositionAction::Sell, "negative direction derives Sell");
assert_eq!(events[0].position_id, 0);
}
/// 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));
}