From 7aaa3e5d5ce76e54965775bb2858c6360930af92 Mon Sep 17 00:00:00 2001 From: Brummel Date: Wed, 24 Jun 2026 20:19:31 +0200 Subject: [PATCH] =?UTF-8?q?feat(0068):=20position-event=20derive=20?= =?UTF-8?q?=E2=80=94=20book=20first-difference=20table=20(#115)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `derive_position_events(record, instrument_id) -> Vec` 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 --- crates/aura-engine/src/lib.rs | 4 +- crates/aura-engine/src/report.rs | 153 +++++++++++++++++++++++ crates/aura-engine/tests/stage1_r_e2e.rs | 107 +++++++++++++++- 3 files changed, 261 insertions(+), 3 deletions(-) diff --git a/crates/aura-engine/src/lib.rs b/crates/aura-engine/src/lib.rs index 77f45bb..bd0742e 100644 --- a/crates/aura-engine/src/lib.rs +++ b/crates/aura-engine/src/lib.rs @@ -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, diff --git a/crates/aura-engine/src/report.rs b/crates/aura-engine/src/report.rs index 0cb166c..0cf761e 100644 --- a/crates/aura-engine/src/report.rs +++ b/crates/aura-engine/src/report.rs @@ -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)], 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)], + 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 +} + /// 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) { + 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); + } + fn samples(values: &[f64]) -> Vec<(Timestamp, f64)> { values .iter() diff --git a/crates/aura-engine/tests/stage1_r_e2e.rs b/crates/aura-engine/tests/stage1_r_e2e.rs index d8c015e..6323fd2 100644 --- a/crates/aura-engine/tests/stage1_r_e2e.rs +++ b/crates/aura-engine/tests/stage1_r_e2e.rs @@ -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 = 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)); }