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:
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user