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
+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));
}