Files
Aura/docs/specs/0068-position-event-derive.md
T
Brummel ef8537c44f spec: 0068 position-event-derive — book first-difference table (#115) (boss-signed)
Derive the broker-independent position-event table (#114's PositionEvent rows)
from a completed PositionManagement run, as the first difference of the executed
book (deal = target - book - in_flight; in_flight = 0 for instant backtest
fills). A pure post-run reduction `derive_position_events(record, instrument_id)`
in aura-engine, sibling of summarize_r, reading the 14-column PM record
positionally; 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.

Supersedes the rolled-back 0064 exposure-integral derive (abandoned per #117):
this derives from the executed book (the PM record), never from exposure deltas.
Stage-2 fixed-fractional / currency / equity-feedback sizing is out of scope
(#116 owns the equity->Sizer z^-1 register per C10).

Boss-signed: specify Step-5 grounding-check PASS (8/8 load-bearing assumptions
ratified by named green tests). Fork decisions recorded on #115.

refs #115
2026-06-24 19:45:31 +02:00

257 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Position-Event Derive (book first-difference) — Design Spec
**Date:** 2026-06-24
**Status:** Draft — awaiting user spec review
**Authors:** orchestrator + Claude
> Reference issue: #115 (milestone "Realistic broker & position-event table
> (C10 A-side)"). Fork decisions recorded as a #115 comment (cycle 0068).
> Supersedes the rolled-back 0064 exposure-integral derive (abandoned per #117).
## Goal
Derive the broker-independent **position-event table** — the `PositionEvent`
rows pinned by #114 — from a completed `PositionManagement` run, as the **first
difference of the executed book** (`deal = target book in_flight`;
`in_flight = 0` for the instant-fill backtest). This is the standalone,
deterministic, post-run reduction that #116's realistic brokers will consume and
that #122 will persist. It is the decoupled Stage-2 *audit* layer of C10,
buildable and testable now against the existing flat-1R executor — independent of
the realistic broker, currency equity, and the equity→Sizer feedback (all #116).
Out of scope (deferred to #116): fixed-fractional sizing that reads account
equity (the only feedback, cut by the z⁻¹ register on the fill edge — C10 assigns
it to #116); the realistic broker; currency equity; persistence (#122);
currency metrics (#121).
## Architecture
A pure post-run fold
```
derive_position_events(record, instrument_id) -> Vec<PositionEvent>
```
in `aura-engine` (`src/report.rs`), the **sibling of `summarize_r`** — same home
(beside `RunMetrics` / `PositionEvent`, both already there since cycle 0063), same
shape (a total pure function over the `PositionManagement` dense record), same
cross-crate discipline: it reads the 14-column record **positionally** as
type-erased `Scalar`s (C7 SoA), never importing the producer's `aura-std` types.
It is **not** an in-graph node — the run loop never calls it, so the hot path
stays domain-free (the same standard that placed `PositionEvent`/`summarize_r` in
`aura-engine`). `aura-engine` depends only on `aura-core`, so it **cannot** import
`InstrumentSpec` (that lives in `aura-ingest`, which depends on `aura-engine`; the
reverse is a dependency cycle). The `instrument_id` is therefore a caller-supplied
**scalar** argument — exactly as `summarize_r` takes `round_trip_cost` — extracted
by the caller from the run's `InstrumentSpec.instrument_id` at the source edge.
The derive maintains a single-position **book** as it walks the record in cycle
order and emits the book's first difference: a `Buy`/`Sell` at each open, a `Close`
at each close. A **reversal** (close + re-open in one cycle) emits `Close` then the
opposite-direction open at the **same `event_ts`**, close before open (the #114
contract). This is `deal = target book in_flight` specialised to the current
single-position-at-a-time executor (`book` ∈ {flat, ±one position}; `in_flight`
always 0).
## Concrete code shapes
### Worked usage (what a consumer / the future #123 CLI writes)
```rust
use aura_engine::{derive_position_events, PositionEvent, PositionAction};
// After a stage1-r run, `r_record: Vec<(Timestamp, Vec<Scalar>)>` is the
// PositionManagement dense record (the same value summarize_r already folds).
// `instrument_id` comes from the run's InstrumentSpec (a scalar; the engine
// never imports aura-ingest).
let events: Vec<PositionEvent> = derive_position_events(&r_record, instrument_id);
// Each entry is a Buy/Sell; each exit a Close referencing the position it closes.
// A reversal is a Close then the opposite open at the SAME event_ts (close first).
// A position still open at window end has its open event but no synthetic Close —
// the table records actual executed events (summarize_r's force-close is for the
// R metric only, not for the event table).
```
### Must-pass test (the #114 reversal contract, now *produced by the derive*)
```rust
#[test]
fn reversal_derives_close_then_open_at_same_ts() {
// Hand-built PM record (only the columns the derive reads are set; others 0):
// t=10: open LONG size 2.0 (closed=false, direction=+1, size=2.0, open=true)
// t=20: REVERSAL to SHORT (closed=true, direction=-1, size=3.0, open=true)
// t=30: stop close (closed=true, direction=-1, size=3.0, open=false)
let record = pm_rows(&[
(10, /*closed*/ false, /*dir*/ 1, /*size*/ 2.0, /*open*/ true),
(20, /*closed*/ true, /*dir*/ -1, /*size*/ 3.0, /*open*/ true),
(30, /*closed*/ true, /*dir*/ -1, /*size*/ 3.0, /*open*/ false),
]);
let ev = derive_position_events(&record, 42);
// open long; reversal = close-then-open at t=20; final close at t=30
assert_eq!(ev.len(), 4);
assert_eq!(ev[0].action, PositionAction::Buy);
assert_eq!(ev[0].event_ts, Timestamp(10));
assert_eq!(ev[0].volume, 2.0); // unsigned lots = the size column
assert_eq!(ev[0].position_id, 0);
assert_eq!(ev[1].action, PositionAction::Close); // close the long
assert_eq!(ev[1].position_id, 0);
assert_eq!(ev[1].event_ts, Timestamp(20));
assert_eq!(ev[1].volume, 2.0); // full close = the book's volume
assert_eq!(ev[2].action, PositionAction::Sell); // open the short
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));
assert_eq!(ev[0].instrument_id, 42);
}
```
### Before → after implementation shape (secondary)
`PositionEvent` / `PositionAction` already exist (cycle 0063). No struct change.
The change is one new public function plus two column-index constants:
```rust
// crates/aura-engine/src/report.rs — extend the existing `mod r_col`
mod r_col {
pub const CLOSED: usize = 0;
pub const REALIZED_R: usize = 1;
pub const DIRECTION: usize = 4; // NEW — i64: +1 long, -1 short (0 = flat)
pub const ENTRY_PRICE: usize = 6;
pub const STOP_PRICE: usize = 7;
pub const CONVICTION_AT_ENTRY: usize = 9;
pub const SIZE: usize = 10; // NEW — f64 lots from the Sizer
pub const OPEN: usize = 11;
pub const UNREALIZED_R: usize = 12;
}
/// Derive the broker-independent position-event table from a `PositionManagement`
/// dense record (read positionally, C7 SoA), as the first difference of the
/// executed book. 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 window-end open position emits its open with no
/// synthetic Close.
pub fn derive_position_events(
record: &[(Timestamp, Vec<Scalar>)],
instrument_id: i64,
) -> Vec<PositionEvent> {
struct Book { position_id: i64, dir: 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
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 isn't 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, dir, volume });
next_id += 1;
}
}
out
}
```
(Exact bytes / `Scalar` accessor names — `.as_bool()` / `.as_i64()` / `.as_f64()`
mirror `summarize_r`'s reads — and the test helper `pm_rows`/`pm_record` are the
planner's job.)
## Components
- **`derive_position_events`** — new `pub fn` in `crates/aura-engine/src/report.rs`,
beside `summarize_r`. Re-export from the crate root mirrors `PositionEvent`
(already exported; the fn joins the same `pub use` line in `lib.rs`).
- **`r_col::DIRECTION` (=4), `r_col::SIZE` (=10)** — two added column indices in the
existing private `r_col` module (the lockstep contract with `aura-std`'s
`FIELD_NAMES`/`RECORD_KINDS`, guarded by `stage1_r_e2e.rs`).
- **Internal `Book` state** — the derive's single-position book (`position_id`,
`dir`, `volume`); `in_flight` is structurally 0 (instant fills) so it is not
represented.
## Data flow
`PositionManagement` dense record (per-cycle `(Timestamp, Vec<Scalar>)` rows, in
cycle order) → `derive_position_events` walks rows, maintaining the book → emits
`Vec<PositionEvent>`. The caller (a test, or the future #123 CLI) supplies
`instrument_id` from the run's `InstrumentSpec` at the source edge. No node, no
graph wiring, no hot-path call — the run loop is untouched (C14 domain-free hot
path holds, as for `summarize_r`).
## Error handling
The function is **total and pure**: an empty record → empty vec; a window-end open
position → its open event with no synthetic Close. The record's column layout is
the producer-guaranteed `PositionManagement` lockstep contract (`stage1_r_e2e.rs`
guards it), so malformed input is not a runtime concern; no panics, no `Result`.
`direction == 0` cannot occur on an `open` row (the executor only opens on a
nonzero bias), and the `dir >= 0 => Buy` branch is a total mapping regardless.
## Testing strategy
Unit tests in `report.rs` (sibling of the `summarize_r` tests):
- **reversal** → Close then opposite open at the same `event_ts`, close first
(the must-pass test above).
- **normal lifecycle** — open long, hold one cycle, stop-close → `Buy` at the open
cycle, `Close` at the exit cycle; `event_ts` from each row's own cycle.
- **short** — a short entry → `Sell`.
- **window-end open** — last row `open = true`, never closed → the open event only,
no `Close`.
- **empty record** → empty vec.
- **position_id monotonic** — successive positions get 0, 1, 2…; each `Close`
carries the `position_id` of the open it closes; `volume` equals the book volume,
unsigned.
E2E (extend `crates/aura-engine/tests/stage1_r_e2e.rs`): run the existing stage1-r
seam, fold both `summarize_r` and `derive_position_events`, and assert the table is
consistent with the metrics — the number of `Close` events equals
`summarize_r`'s closed-trade count (`n_trades` minus any window-end open), and
every `Close`'s `position_id` was opened by an earlier `Buy`/`Sell`.
## Acceptance criteria
- `derive_position_events(record, instrument_id)` produces a faithful
`PositionEvent` table from a `PositionManagement` record.
- A reversal emits `Close` then the opposite open at one `event_ts`, close before
open (the #114 contract, now *derived*, not just hand-built in a test).
- A window-end open position emits its open with no synthetic `Close`.
- Deterministic (C1); no look-ahead — each event's `event_ts` is its own cycle (C2);
broker-independent (C10); pure scalar columns, no new streamed type (C7); the
hot path is untouched (no node, C14).
- Fixed-fractional / currency / equity-feedback sizing is **not** in scope (#116).
- Additive only: `cargo build --workspace`, `cargo test --workspace`, and
`cargo clippy --workspace --all-targets -- -D warnings` are clean, and no existing
golden / serde test changes (the derive adds a function; it touches no existing
output).