//! `PositionManagement` — the stateful heart of feed-forward risk-based execution. Turns a //! bias + a protective-stop distance into a stream of realised per-trade R-outcomes. //! Emits ONE dense record per cycle (C8, like `SimBroker`; multi-field output on the //! `Resample` precedent): the trade ledger is the rows where `closed_this_cycle`, the //! R-equity is `cum_realized_r + unrealized_r`, and a position still open at window end //! is the last row with `open = true`. R is computed SIZE-INVARIANTLY: //! `realized_r = direction * (exit - entry) / latched_distance`. use aura_core::{ Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, PortSpec, PrimitiveBuilder, ScalarKind, Timestamp, }; /// Why a trade closed. i64-encoded into the dense record (column 2). `summarize_r` /// matches the raw i64 across the crate boundary (the record streams as type-erased /// `Scalar`s, C4 SoA, not as this enum), so the encoding is the load-bearing contract. #[derive(Clone, Copy, Debug, PartialEq, Eq)] #[repr(i64)] pub enum ExitReason { Stop = 0, BiasFlip = 1, ReversalLeg = 2, WindowEnd = 3, } /// Dense record column layout — the lockstep contract shared (by convention) with the /// `Recorder` tap kinds and `summarize_r`'s column reads. pub const WIDTH: usize = 14; pub const FIELD_NAMES: [&str; WIDTH] = [ "closed_this_cycle", "realized_r", "exit_reason", "was_stopped", "direction", "entry_ts", "entry_price", "stop_price", "exit_price", "conviction_at_entry", "size", "open", "unrealized_r", "cum_realized_r", ]; pub const RECORD_KINDS: [ScalarKind; WIDTH] = { use ScalarKind::*; [ Bool, F64, I64, Bool, I64, Timestamp, F64, F64, F64, F64, F64, Bool, F64, F64, ] }; // The frozen-R latch: an open position records its entry, its latched (frozen at // entry) R-distance, the protective stop level, and entry metadata. R is computed // against `latched_dist`, never a re-read distance — the R-denominator is frozen. struct Open { dir: i64, entry: f64, latched_dist: f64, stop_level: f64, entry_ts: Timestamp, bias_abs: f64, } pub struct PositionManagement { pos: Option, cum_realized_r: f64, out: [Cell; WIDTH], } impl PositionManagement { pub fn new() -> Self { Self { pos: None, cum_realized_r: 0.0, out: [Cell::from_f64(0.0); WIDTH], } } pub fn builder() -> PrimitiveBuilder { let inputs = vec![ PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "bias".into() }, PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "price".into() }, PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "stop_distance".into() }, PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "size".into() }, ]; let output = FIELD_NAMES .iter() .zip(RECORD_KINDS) .map(|(n, k)| FieldSpec { name: (*n).into(), kind: k }) .collect(); PrimitiveBuilder::new( "PositionManagement", NodeSchema { inputs, output, params: vec![], doc: "turns bias plus protective stop into a managed position in R", }, |_| Box::new(PositionManagement::new()), ) } } impl Default for PositionManagement { fn default() -> Self { Self::new() } } fn sign0(v: f64) -> f64 { if v > 0.0 { 1.0 } else if v < 0.0 { -1.0 } else { 0.0 } } impl Node for PositionManagement { fn lookbacks(&self) -> Vec { vec![1, 1, 1, 1] } fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Cell]> { let pw = ctx.f64_in(1); if pw.is_empty() { return None; // no price yet — nothing to do } let price = pw[0]; let bias = ctx.f64_in(0).get(0).unwrap_or(0.0); let dist = ctx.f64_in(2).get(0).unwrap_or(0.0); let size = ctx.f64_in(3).get(0).unwrap_or(1.0); // the Sizer's size; defaults to flat-1R 1.0 if unwired let now = ctx.now(); let mut closed = false; let mut realized = 0.0; let mut reason = ExitReason::Stop as i64; let mut was_stopped = false; let mut ex_dir = 0i64; let mut ex_entry_ts = Timestamp(0); let mut ex_entry = 0.0; let mut ex_stop = 0.0; let mut ex_exit = 0.0; let mut ex_bias_abs = 0.0; // (A) Maintain/exit the position held into this cycle, marked to `price`. if let Some(p) = &self.pos { let stop_hit = (p.dir > 0 && price <= p.stop_level) || (p.dir < 0 && price >= p.stop_level); let bias_exit = sign0(bias) != p.dir as f64; // flip or ->0 if stop_hit { let fill = if p.dir > 0 { price.min(p.stop_level) } else { price.max(p.stop_level) }; // size-invariant: R is a pure stop-distance ratio (feed-forward). realized = p.dir as f64 * (fill - p.entry) / p.latched_dist; was_stopped = true; reason = ExitReason::Stop as i64; ex_exit = fill; (ex_dir, ex_entry_ts, ex_entry, ex_stop, ex_bias_abs) = (p.dir, p.entry_ts, p.entry, p.stop_level, p.bias_abs); self.cum_realized_r += realized; closed = true; self.pos = None; } else if bias_exit { // size-invariant: R is a pure stop-distance ratio (feed-forward). realized = p.dir as f64 * (price - p.entry) / p.latched_dist; reason = if sign0(bias) != 0.0 { ExitReason::ReversalLeg as i64 } else { ExitReason::BiasFlip as i64 }; ex_exit = price; (ex_dir, ex_entry_ts, ex_entry, ex_stop, ex_bias_abs) = (p.dir, p.entry_ts, p.entry, p.stop_level, p.bias_abs); self.cum_realized_r += realized; closed = true; self.pos = None; } } // (B) Open if flat, bias nonzero, and a valid (>0) frozen R-distance is available. if self.pos.is_none() && sign0(bias) != 0.0 && dist > 0.0 { let dir = sign0(bias) as i64; let stop_level = if dir > 0 { price - dist } else { price + dist }; self.pos = Some(Open { dir, entry: price, latched_dist: dist, stop_level, entry_ts: now, bias_abs: bias.abs(), }); } // (C) Open-position fields (carry for window-end) + unrealized mark. let (open, unrealized, o_dir, o_ets, o_entry, o_stop, o_babs) = match &self.pos { Some(p) => ( true, p.dir as f64 * (price - p.entry) / p.latched_dist, p.dir, p.entry_ts, p.entry, p.stop_level, p.bias_abs, ), None => (false, 0.0, 0, Timestamp(0), 0.0, 0.0, 0.0), }; // Trade-detail columns describe the CLOSED trade if closed, else the OPEN one. let (d_dir, d_ets, d_entry, d_stop, d_exit, d_babs) = if closed { (ex_dir, ex_entry_ts, ex_entry, ex_stop, ex_exit, ex_bias_abs) } else { (o_dir, o_ets, o_entry, o_stop, 0.0, o_babs) }; // `direction` (col 4) tracks the position OPEN at cycle end — it is carried for // window-end synthesis and names the reopened leg on a reversal. It falls back // to the closed trade's direction only when flat at cycle end. The other // trade-detail columns (and the debug_assert below) stay keyed to the CLOSED // trade's geometry via `d_*`. let out_dir = if open { o_dir } else { d_dir }; self.out = [ Cell::from_bool(closed), Cell::from_f64(realized), Cell::from_i64(reason), Cell::from_bool(was_stopped), Cell::from_i64(out_dir), Cell::from_ts(d_ets), Cell::from_f64(d_entry), Cell::from_f64(d_stop), Cell::from_f64(d_exit), Cell::from_f64(d_babs), Cell::from_f64(size), // size: from the Sizer (slot 3); R stays size-invariant Cell::from_bool(open), Cell::from_f64(unrealized), Cell::from_f64(self.cum_realized_r), ]; // Scale-robust tolerance: the column re-derivation reconstructs the R-denominator // via `d_entry - d_stop`, a catastrophic-cancellation subtraction at tiny-pip price // scale (entry ~1.1, dist ~1e-6) that drifts from the cancellation-free frozen // `latched_dist` `realized` was computed with. A relative tolerance (floored at the // historical absolute 1e-9 for |R| <= 1) keeps the guard meaningful while letting the // two build profiles agree on tiny-pip instruments — `realized` itself is exact. debug_assert!( !closed || (realized - d_dir as f64 * (d_exit - d_entry) / (d_entry - d_stop).abs()).abs() < 1e-9 * realized.abs().max(1.0) ); Some(&self.out) } fn label(&self) -> String { "PositionManagement".into() } } #[cfg(test)] mod tests { use super::*; use aura_core::{AnyColumn, Scalar}; // Drive one cycle: push bias/price/stop/size into the four slots, eval, return the row. fn step_sized(n: &mut PositionManagement, cols: &mut [AnyColumn], bias: f64, price: f64, dist: f64, size: f64) -> Vec { cols[0].push(Scalar::f64(bias)).unwrap(); cols[1].push(Scalar::f64(price)).unwrap(); cols[2].push(Scalar::f64(dist)).unwrap(); cols[3].push(Scalar::f64(size)).unwrap(); n.eval(Ctx::new(cols, Timestamp(1))).expect("dense record every cycle once price present").to_vec() } // size defaults to 1.0 (the iter-1 placeholder value), so every existing case is // behaviour-identical under the new 4-input shape. fn step(n: &mut PositionManagement, cols: &mut [AnyColumn], bias: f64, price: f64, dist: f64) -> Vec { step_sized(n, cols, bias, price, dist, 1.0) } fn cols() -> Vec { (0..4).map(|_| AnyColumn::with_capacity(ScalarKind::F64, 1)).collect() } #[test] fn emits_one_dense_record_per_cycle() { let mut n = PositionManagement::new(); let mut c = cols(); let row = step(&mut n, &mut c, 0.0, 100.0, 10.0); // flat assert_eq!(row.len(), WIDTH); assert!(!row[0].bool()); // closed_this_cycle = false assert!(!row[11].bool()); // open = false } // (3) R-invariance under size: the Sizer's `size` flows into col 10 but never into R. // Scaling size leaves every realized_r identical (the property that keeps the research loop // feed-forward); col 10 reflects the size so we know it actually flowed end-to-end. #[test] fn realized_r_is_invariant_under_size() { let run = |size: f64| { let mut n = PositionManagement::new(); let mut c = cols(); let _ = step_sized(&mut n, &mut c, 1.0, 100.0, 10.0, size); // open long @100 step_sized(&mut n, &mut c, 0.0, 110.0, 10.0, size) // bias->0 exit @110 }; let small = run(1.0); let big = run(7.0); assert_eq!(small[1].f64(), big[1].f64(), "realized_r must be size-invariant"); assert_eq!(small[1].f64(), 1.0); // (110-100)/10 assert_eq!(small[10].f64(), 1.0); // size column reflects the input assert_eq!(big[10].f64(), 7.0); } // (1) No look-ahead, the SimBroker mirror: a long entered at 100 with stop_distance // 10, then bias->0 at price 110, realises R = (110-100)/10 = +1.0 (it earned the // move to 110; the flip closes it THERE, never retroactively flattening it). #[test] fn no_lookahead_bias_exit_realises_the_held_move() { let mut n = PositionManagement::new(); let mut c = cols(); let _ = step(&mut n, &mut c, 1.0, 100.0, 10.0); // open long @100, stop 90 let row = step(&mut n, &mut c, 0.0, 110.0, 10.0); // bias->0 @110: exit assert!(row[0].bool()); // closed_this_cycle assert_eq!(row[1].f64(), 1.0); // realized_r = +1.0 assert_eq!(row[2].i64(), ExitReason::BiasFlip as i64); assert!(!row[3].bool()); // not stopped } // A position opened this cycle never earns the pre-entry move: entered @110, its // own-cycle unrealized R is 0. #[test] fn entry_does_not_earn_pre_entry_move() { let mut n = PositionManagement::new(); let mut c = cols(); let _ = step(&mut n, &mut c, 0.0, 100.0, 10.0); // flat let row = step(&mut n, &mut c, 1.0, 110.0, 10.0); // open long @110 assert!(row[11].bool()); // open assert_eq!(row[12].f64(), 0.0); // unrealized_r = 0 at entry cycle } // (2) Stop tail is NOT capped at -1R: a gap THROUGH the stop realises R < -1. #[test] fn stop_out_is_not_capped_at_minus_one_r() { let mut n = PositionManagement::new(); let mut c = cols(); let _ = step(&mut n, &mut c, 1.0, 100.0, 10.0); // open long @100, stop 90 let row = step(&mut n, &mut c, 0.0, 80.0, 10.0); // gaps to 80 (< stop 90) assert!(row[0].bool()); assert!(row[3].bool()); // was_stopped assert_eq!(row[2].i64(), ExitReason::Stop as i64); assert_eq!(row[1].f64(), -2.0); // (80-100)/10 = -2R, the honest tail } // A no-gap stop fills exactly at the stop -> exactly -1R. #[test] fn no_gap_stop_is_exactly_minus_one_r() { let mut n = PositionManagement::new(); let mut c = cols(); let _ = step(&mut n, &mut c, 1.0, 100.0, 10.0); // open long @100, stop 90 let row = step(&mut n, &mut c, 1.0, 90.0, 10.0); // touches stop exactly assert_eq!(row[1].f64(), -1.0); } // (5) One close per cycle on a reversal: long -> bias flips short closes ONE leg // (one record, ReversalLeg) and reopens short as state (open=true, dir=-1). #[test] fn reversal_closes_one_leg_and_reopens() { let mut n = PositionManagement::new(); let mut c = cols(); let _ = step(&mut n, &mut c, 1.0, 100.0, 10.0); // open long @100 let row = step(&mut n, &mut c, -1.0, 110.0, 10.0); // flip short @110 assert!(row[0].bool()); // one close assert_eq!(row[1].f64(), 1.0); // closed long: (110-100)/10 assert_eq!(row[2].i64(), ExitReason::ReversalLeg as i64); assert!(row[11].bool()); // reopened: open assert_eq!(row[4].i64(), -1); // new direction short } // Property: the realized_r <-> dense-record-columns redundancy guard holds for a // tiny-pip instrument (EURUSD-scale entry, a sub-pip stop distance). `eval` stores // `realized` from the cancellation-free frozen `latched_dist`; the in-`eval` // redundancy guard RE-derives R from the record columns as // `direction * (exit - entry) / |entry - stop|`, reconstructing the R-denominator // by the subtraction `entry - stop`. At a price scale ~1.1 with a stop distance // ~1e-6, that subtraction loses ~6 significant digits relative to the magnitude of // `entry` (~2e-16 ULP / 1e-6 dist), so the recomputed denominator drifts from the // frozen `latched_dist`; with a deep gap-through-stop realising a large |R|, the // recomputed R diverges from the (correct) stored `realized` by more than the // guard's ABSOLUTE 1e-9 tolerance, panicking a debug build while a release build // (assert compiled out) returns the correct R. The stored `realized` is correct; // the guard must use a scale-robust (relative) tolerance so the two build profiles // agree. A no-op for large-pip instruments (GER40), where the cancellation is // negligible. #[test] fn tiny_pip_gap_stop_does_not_trip_the_redundancy_guard() { let entry = 1.10000_f64; // EURUSD-scale price let dist = 0.000001_f64; // sub-pip vol-stop distance (a quiet M1 bar) let mut n = PositionManagement::new(); let mut c = cols(); let _ = step(&mut n, &mut c, 1.0, entry, dist); // open long @1.10000, stop @1.099999 // A fast gap straight through the stop: price 1.09990, ~100e-6 below entry -> // a ~-100R loss (stop-outs are NOT capped at -1R; the honest tail). In a debug // build the current absolute-tolerance guard PANICS here. let row = step(&mut n, &mut c, 1.0, 1.09990_f64, dist); assert!(row[0].bool(), "closed_this_cycle"); assert!(row[3].bool(), "was_stopped"); // The stored realized_r and the record-column re-derivation must agree within a // tolerance robust to the tiny price scale (relative, not absolute 1e-9). let realized = row[1].f64(); let (dir, d_entry, d_stop, d_exit) = (row[4].i64() as f64, row[6].f64(), row[7].f64(), row[8].f64()); let recomputed = dir * (d_exit - d_entry) / (d_entry - d_stop).abs(); assert!( (realized - recomputed).abs() <= 1e-9 * realized.abs().max(1.0), "realized_r ({realized}) must match the column re-derivation ({recomputed}) \ within a scale-robust tolerance", ); } // (4) Open at window end is carried on the last row: open=true, the unrealized R the // post-run fold will force-close, and the direction for window-end synthesis. #[test] fn open_at_window_end_is_carried_on_the_last_row() { let mut n = PositionManagement::new(); let mut c = cols(); let _ = step(&mut n, &mut c, 1.0, 100.0, 10.0); // open long @100 let row = step(&mut n, &mut c, 1.0, 105.0, 10.0); // still long @105, no close assert!(!row[0].bool()); // not closed assert!(row[11].bool()); // open assert_eq!(row[12].f64(), 0.5); // unrealized (105-100)/10 assert_eq!(row[4].i64(), 1); // direction carried for window-end synthesis } }