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()
|
||||
|
||||
Reference in New Issue
Block a user