4de6d5cbad
The stage1 ordinal has been dead vocabulary since the C10 reframe dropped the two-stage research model: a 1 structurally implies a 2 that no longer exists. The family is renamed by its live discriminator - the R yardstick - with members named by their signal, uniform with their signal-named siblings (sma, macd, momentum): - selectors: stage1-r -> r-sma, stage1-breakout -> r-breakout, stage1-meanrev -> r-meanrev (old tokens are usage errors, exit 2 - no silent alias) - identifiers: Strategy::RSma/RBreakout/RMeanRev, HarnessKind::RSma, r_sma_*/r_breakout_*/r_meanrev_*, wrap_r, run_signal_r, RGrid, R_SMA_* - persisted identity: the sma_signal composite (param prefix sma_signal.fast.length / .slow.length; fixtures regenerated; content-ids shift - no test pins a literal hash, the registry parses no record names) - e2e test files git-mv'd to r_sma_e2e / r_breakout_e2e / r_meanrev_e2e - dead Stage-1/Stage-2 prose reworded to post-C10 vocabulary across rustdoc, Cargo.tomls, ledger live lines, and the glossary (historical entries stay; fieldtests corpus untouched) - CLAUDE.md invariant 7 rewritten to record the ratified C10 reframe faithfully (the token-swap alone would have laundered the retired gated-currency/realistic-broker design into unmarked live prose); the unbacked account-mode clause dropped Verification: cargo build/test --workspace green (51 targets), clippy -D warnings clean, doc build clean, acceptance grep gate leaves exactly the one resampling-stage false positive (harness.rs), smoke: --harness r-sma runs, --harness stage1-r exits 2 with the new usage line. Decision log: forks and rationale recorded on the issue (reconciliation + implementation-phase comments). closes #174
411 lines
18 KiB
Rust
411 lines
18 KiB
Rust
//! `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<Open>,
|
|
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![] },
|
|
|_| 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<usize> {
|
|
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<Cell> {
|
|
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<Cell> {
|
|
step_sized(n, cols, bias, price, dist, 1.0)
|
|
}
|
|
fn cols() -> Vec<AnyColumn> { (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
|
|
}
|
|
}
|