feat(stage1-r): bias rename + stop-rule + position-management + summarize_r (iter 1)

Iteration 1 of the Stage-1 "R-based signal quality" cycle (spec 0065): a
strategy's signal quality, measured in R on its unsized bias stream, feed-forward.
New discrete-trade machinery, NOT a SimBroker extension.

What lands:
- exposure -> bias (refs #126): the Exposure node/type/file/output-field renamed to
  Bias (computation unchanged: clamp(signal/scale,-1,+1); the SEMANTICS change — the
  output is the unsized strategy bias, sizing leaves the strategy). Scoped to the
  semantic core; behaviour-preserving cosmetics are deferred (see below).
- stop-rule nodes (refs #119): VolStop = k*EMA(|price - prev_price|) (one fused node;
  the volatility that defines 1R, close-to-close — true-range ATR deferred, needs
  OHLC) + FixedStop (constant, the test fixture / fixed-vs-vol structural-axis sibling).
- PositionManagement (refs #127): the stateful heart. Latches the entry-cycle stop
  distance as the FROZEN R-denominator (never re-read), marks/exits no-look-ahead like
  SimBroker (a position earns from the next cycle; an exit realises against the cycle's
  close), and emits ONE dense per-cycle R-record (C8). Stop-outs are NOT capped at -1R
  (a gap through the stop realises R < -1 — the honest loss tail); R is computed
  size-invariantly (size = (exit-entry)*dir / latched_dist cancels). The trade ledger
  is the rows where closed_this_cycle; the R-equity is cum_realized + unrealized; a
  position open at window end is the last row (open=true) — explicit, never silent MtM.
- summarize_r + RMetrics (refs #129): the post-run fold (NOT an in-graph node —
  recorders drain post-run). E[R], win-rate, profit-factor, avg win/loss R, by-trade
  max R-drawdown, n_open_at_end (window-end force-close). SQN / conviction terciles /
  net-of-cost / RunMetrics.r are iteration 2.

Design adversarially hardened before implementation (12-juror refute panel; decisions
on #117). Ratified implementer deviations from the plan snippet, verified by hand:
- col-4 `direction` tracks the OPEN position at cycle end (window-end synthesis; names
  the reopened leg on a reversal), falling back to the closed trade's dir only when
  flat. summarize_r does not read col 4; the R-metrics are unaffected.
- .named("exposure") kept on the 4 previously-unnamed Bias nodes so the auto-derived
  param-path stays exposure.scale (no manifest/dir-name drift) — the unnamed nodes
  would otherwise flip to bias.scale.
- intra-doc links [`Exposure`] -> [`Bias`] in sim_broker/latch + the sample-model test
  pin (cosmetic, behaviour-neutral; broken intra-doc links fail cargo doc).
- the E2E drives a full bootstrapped Harness (stronger than the plan's direct-node
  drive — exercises the real cross-crate producer->consumer seam).

Deferred (behaviour-preserving, separate cosmetic pass — NOT this iteration):
RunMetrics.exposure_sign_flips / Metric::ExposureSignFlips, SimBroker/LongOnly
"exposure" input ports, the "exposure" tap/trace names, and the .named("exposure")
instance labels. Iteration 2: the Sizer seam, the RiskExecutor composite (+ the Veto
documented-seam), summarize_r enrichment, RunMetrics.r, and the CLI/recording surface.

Verification (orchestrator-run, not agent-claimed):
- cargo build --workspace: clean.
- cargo test --workspace: all green, 0 failed (incl. the new bias/stop_rule/
  position_management unit tests, the summarize_r arithmetic tests, and the
  stage1_r_e2e capstone + layout guard).
- cargo clippy --workspace --all-targets -- -D warnings: clean (exit 0).
- Keystone RED tests pass: no_lookahead_bias_exit_realises_the_held_move,
  stop_out_is_not_capped_at_minus_one_r, no_gap_stop_is_exactly_minus_one_r,
  reversal_closes_one_leg_and_reopens, open_at_window_end_is_carried_on_the_last_row.

refs #117 #119 #126 #127 #129
This commit is contained in:
2026-06-23 19:48:58 +02:00
parent c5f5e17297
commit 2c43296c2c
21 changed files with 919 additions and 104 deletions
@@ -1,44 +1,44 @@
//! `Exposure` — shapes a raw signal score into a bounded exposure (intent).
//! The decision/sizing node of C10's chain `signals -> decision/sizing node ->
//! exposure stream`: one f64 input, one f64 output `clamp(signal / scale, -1, +1)`.
//! `scale` sets which signal magnitude maps to full exposure (sizing lives here).
//! `Bias` — shapes a raw signal score into a bounded, UNSIGNED-magnitude directional
//! bias (C10). The strategy's primary output: one f64 input, one f64 output
//! `clamp(signal / scale, -1, +1)`. Sign is direction, magnitude is (optional)
//! conviction; the output is UNSIZED — sizing leaves the strategy (downstream Sizer).
//! `scale` sets which signal magnitude maps to full conviction.
use aura_core::{
Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec, PrimitiveBuilder,
ScalarKind,
};
/// Bounded exposure from a raw signal score: `clamp(signal / scale, -1.0, +1.0)`.
/// Bounded bias from a raw signal score: `clamp(signal / scale, -1.0, +1.0)`.
/// Emits `None` until its input is present (warm-up filter, C8).
pub struct Exposure {
pub struct Bias {
scale: f64,
out: [Cell; 1],
}
impl Exposure {
/// Build an exposure node with saturation magnitude `scale` (must be > 0).
impl Bias {
/// Build a bias node with saturation magnitude `scale` (must be > 0).
pub fn new(scale: f64) -> Self {
assert!(scale > 0.0, "Exposure scale must be > 0");
assert!(scale > 0.0, "Bias scale must be > 0");
Self { scale, out: [Cell::from_f64(0.0)] }
}
/// The param-generic recipe for a blueprint primitive: declares `scale` and builds
/// through `Exposure::new` (the single sizing/validation gate; the slice is
/// through `Bias::new` (the single sizing/validation gate; the slice is
/// kind-checked before `build` runs, so the typed read is total).
pub fn builder() -> PrimitiveBuilder {
PrimitiveBuilder::new(
"Exposure",
"Bias",
NodeSchema {
inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "signal".into() }],
output: vec![FieldSpec { name: "exposure".into(), kind: ScalarKind::F64 }],
output: vec![FieldSpec { name: "bias".into(), kind: ScalarKind::F64 }],
params: vec![ParamSpec { name: "scale".into(), kind: ScalarKind::F64 }],
},
|p| Box::new(Exposure::new(p[0].f64())),
|p| Box::new(Bias::new(p[0].f64())),
)
}
}
impl Node for Exposure {
impl Node for Bias {
fn lookbacks(&self) -> Vec<usize> {
vec![1]
}
@@ -53,7 +53,7 @@ impl Node for Exposure {
}
fn label(&self) -> String {
format!("Exposure({})", self.scale)
format!("Bias({})", self.scale)
}
}
@@ -63,10 +63,10 @@ mod tests {
use aura_core::{AnyColumn, Scalar, Timestamp};
#[test]
fn exposure_clamps_to_unit_band() {
let mut e = Exposure::new(0.5);
fn bias_clamps_to_unit_band() {
let mut e = Bias::new(0.5);
let mut inputs = vec![AnyColumn::with_capacity(ScalarKind::F64, 1)];
// (raw signal, expected clamped exposure) for scale 0.5
// (raw signal, expected clamped bias) for scale 0.5
let cases = [
(0.1_f64, 0.2_f64), // within band
(0.5, 1.0), // at the high edge
@@ -84,14 +84,14 @@ mod tests {
}
#[test]
fn exposure_is_none_until_input_present() {
let mut e = Exposure::new(0.5);
fn bias_is_none_until_input_present() {
let mut e = Bias::new(0.5);
let inputs = vec![AnyColumn::with_capacity(ScalarKind::F64, 1)];
assert_eq!(e.eval(Ctx::new(&inputs, Timestamp(0))), None);
}
#[test]
fn input_slot_is_named_signal() {
assert_eq!(Exposure::builder().schema().inputs[0].name, "signal");
assert_eq!(Bias::builder().schema().inputs[0].name, "signal");
}
}
+1 -1
View File
@@ -6,7 +6,7 @@
//! no params. **Emits `f64` directly**, not `bool`: a held position *is*
//! exposure `+1`, flat *is* `0.0`, so the output feeds [`SimBroker`](crate::SimBroker)'s
//! `f64` `exposure` slot with no cast — this is the resolution of the `bool → f64`
//! seam (no separate `Exposure`/cast node).
//! seam (no separate `Bias`/cast node).
//!
//! **State.** A persisted `held: f64`, initial `0.0`, mutated in `eval` and
//! carried across cycles in the struct — exactly how [`SimBroker`](crate::SimBroker)
+9 -2
View File
@@ -17,33 +17,40 @@
mod add;
mod and;
mod bias;
mod delay;
mod ema;
mod eqconst;
mod exposure;
mod gt;
mod latch;
mod lincomb;
mod longonly;
mod position_management;
mod recorder;
mod resample;
mod session;
mod sim_broker;
mod sma;
mod stop_rule;
mod sub;
pub use add::Add;
pub use and::And;
pub use bias::Bias;
pub use delay::Delay;
pub use ema::Ema;
pub use eqconst::EqConst;
pub use exposure::Exposure;
pub use gt::Gt;
pub use latch::Latch;
pub use lincomb::LinComb;
pub use longonly::LongOnly;
pub use position_management::{
ExitReason, FIELD_NAMES as PM_FIELD_NAMES, PositionManagement, RECORD_KINDS as PM_RECORD_KINDS,
WIDTH as PM_WIDTH,
};
pub use recorder::Recorder;
pub use resample::Resample;
pub use session::Session;
pub use sim_broker::SimBroker;
pub use sma::Sma;
pub use stop_rule::{FixedStop, VolStop};
pub use sub::Sub;
+336
View File
@@ -0,0 +1,336 @@
//! `PositionManagement` — the stateful heart of Stage-1 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 (it lives across a crate boundary — aura-std is only a
/// dev-dependency of aura-engine), 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",
"bias_at_entry_abs",
"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() },
];
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]
}
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 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 (Stage-1 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 (Stage-1 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(1.0), // size: flat-1R placeholder (iter-2 Sizer)
Cell::from_bool(open),
Cell::from_f64(unrealized),
Cell::from_f64(self.cum_realized_r),
];
debug_assert!(
!closed
|| (realized - d_dir as f64 * (d_exit - d_entry) / (d_entry - d_stop).abs()).abs()
< 1e-9
);
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 into the three slots, eval, return the row.
fn step(n: &mut PositionManagement, cols: &mut [AnyColumn], bias: f64, price: f64, dist: f64) -> Vec<Cell> {
cols[0].push(Scalar::f64(bias)).unwrap();
cols[1].push(Scalar::f64(price)).unwrap();
cols[2].push(Scalar::f64(dist)).unwrap();
n.eval(Ctx::new(cols, Timestamp(1))).expect("dense record every cycle once price present").to_vec()
}
fn cols() -> Vec<AnyColumn> { (0..3).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
}
// (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
}
// (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
}
}
+1 -1
View File
@@ -17,7 +17,7 @@ use aura_core::{Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, PortSpec, Primit
/// equity curve):
///
/// - **slot 0 — exposure** ∈ [-1, +1] (the strategy's intent, e.g. from
/// [`Exposure`](crate::Exposure)).
/// [`Bias`](crate::Bias)).
/// - **slot 1 — price** (the instrument price the exposure is marked against).
///
/// # Firing and warm-up
+4 -4
View File
@@ -202,14 +202,14 @@ mod tests {
#[test]
fn labels_carry_identifying_params() {
use crate::{Add, Exposure, LinComb, Recorder, SimBroker, Sub};
use crate::{Add, Bias, LinComb, Recorder, SimBroker, Sub};
use aura_core::{Firing, ScalarKind};
// the load-bearing payoff: two SMAs disambiguate by window
assert_eq!(Sma::new(2).label(), "SMA(2)");
assert_eq!(Sma::new(4).label(), "SMA(4)");
// param-carrying single nodes
assert_eq!(Exposure::new(0.5).label(), "Exposure(0.5)");
assert_eq!(Bias::new(0.5).label(), "Bias(0.5)");
assert_eq!(SimBroker::new(0.0001).label(), "SimBroker(0.0001)");
// bare-kind nodes (identity is not a mis-wiring axis here)
assert_eq!(Sub::new().label(), "Sub");
@@ -221,7 +221,7 @@ mod tests {
#[test]
fn nodes_declare_expected_params() {
use crate::{Add, Exposure, LinComb, Recorder, SimBroker, Sub};
use crate::{Add, Bias, LinComb, Recorder, SimBroker, Sub};
use aura_core::{Firing, ParamSpec, ScalarKind};
// single scalar knobs (declared on the param-generic builder, pre-build)
assert_eq!(
@@ -229,7 +229,7 @@ mod tests {
vec![ParamSpec { name: "length".into(), kind: ScalarKind::I64 }],
);
assert_eq!(
Exposure::builder().schema().params,
Bias::builder().schema().params,
vec![ParamSpec { name: "scale".into(), kind: ScalarKind::F64 }],
);
// vector knob expands flat to N indexed F64 entries
+138
View File
@@ -0,0 +1,138 @@
//! Stop-rule nodes — emit a protective-stop *distance* (price units, ≥ 0,
//! direction-agnostic). The stop DEFINES the risk unit R (1R = the loss if stopped);
//! position-management latches the entry-cycle distance as the frozen R-denominator.
//! `FixedStop` is a constant distance (test fixture / structural-axis sibling);
//! `VolStop` is a close-to-close volatility stop `k * EMA_length(|price - prev_price|)`
//! (true-range ATR is deferred — it needs OHLC). One fused node each (no Abs/Mul
//! primitive exists, so abs+delta+EMA fuse inside VolStop).
use aura_core::{
Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec, PrimitiveBuilder,
ScalarKind,
};
/// Constant stop distance. `distance` must be > 0.
pub struct FixedStop { distance: f64, out: [Cell; 1] }
impl FixedStop {
pub fn new(distance: f64) -> Self {
assert!(distance > 0.0, "FixedStop distance must be > 0");
Self { distance, out: [Cell::from_f64(distance)] }
}
pub fn builder() -> PrimitiveBuilder {
PrimitiveBuilder::new(
"FixedStop",
NodeSchema {
inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "price".into() }],
output: vec![FieldSpec { name: "stop_distance".into(), kind: ScalarKind::F64 }],
params: vec![ParamSpec { name: "distance".into(), kind: ScalarKind::F64 }],
},
|p| Box::new(FixedStop::new(p[0].f64())),
)
}
}
impl Node for FixedStop {
fn lookbacks(&self) -> Vec<usize> { vec![1] }
fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Cell]> {
if ctx.f64_in(0).is_empty() { return None; } // fire with price (warm-up filter)
self.out[0] = Cell::from_f64(self.distance);
Some(&self.out)
}
fn label(&self) -> String { format!("FixedStop({})", self.distance) }
}
/// Volatility stop distance: `k * EMA_length(|price - prev_price|)`. EMA is the
/// standard `alpha = 2/(length+1)` recursion, but its WARM-UP DIFFERS from the
/// crate's [`crate::Ema`]: that node seeds with the SMA of its first `length` samples
/// (ta-lib convention), whereas `VolStop` seeds with the FIRST abs-return and runs the
/// recurrence from there (a first-value seed). The divergence is deliberate — the
/// abs-return stream needs a prior price before any sample exists, so a uniform
/// `length`-sample SMA seed would push warm-up an extra cycle out and complicate the
/// frozen-R latch; the first-value seed keeps the stop available as early as the data
/// allows. Output is `None` until `length` abs-returns have been seen (warm-up).
/// `prev_price` updated AFTER use (intra-node z⁻¹, C2-clean). `length >= 1`, `k > 0`.
pub struct VolStop {
length: usize,
k: f64,
prev_price: Option<f64>,
ema: f64,
count: usize,
out: [Cell; 1],
}
impl VolStop {
pub fn new(length: usize, k: f64) -> Self {
assert!(length >= 1, "VolStop length must be >= 1");
assert!(k > 0.0, "VolStop k must be > 0");
Self { length, k, prev_price: None, ema: 0.0, count: 0, out: [Cell::from_f64(0.0)] }
}
pub fn builder() -> PrimitiveBuilder {
PrimitiveBuilder::new(
"VolStop",
NodeSchema {
inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "price".into() }],
output: vec![FieldSpec { name: "stop_distance".into(), kind: ScalarKind::F64 }],
params: vec![
ParamSpec { name: "length".into(), kind: ScalarKind::I64 },
ParamSpec { name: "k".into(), kind: ScalarKind::F64 },
],
},
|p| Box::new(VolStop::new(p[0].i64() as usize, p[1].f64())),
)
}
}
impl Node for VolStop {
fn lookbacks(&self) -> Vec<usize> { vec![1] }
fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Cell]> {
let w = ctx.f64_in(0);
if w.is_empty() { return None; }
let price = w[0];
let Some(pp) = self.prev_price else {
self.prev_price = Some(price);
return None; // need a prior price to form the first abs-return
};
let d = (price - pp).abs();
let alpha = 2.0 / (self.length as f64 + 1.0);
if self.count == 0 { self.ema = d; } else { self.ema += alpha * (d - self.ema); }
self.count += 1;
self.prev_price = Some(price); // update AFTER use (C2)
if self.count < self.length { return None; } // warm-up
self.out[0] = Cell::from_f64(self.k * self.ema);
Some(&self.out)
}
fn label(&self) -> String { format!("VolStop({},{})", self.length, self.k) }
}
#[cfg(test)]
mod tests {
use super::*;
use aura_core::{AnyColumn, Scalar, Timestamp};
fn feed(node: &mut dyn Node, prices: &[f64]) -> Vec<Option<f64>> {
let mut col = vec![AnyColumn::with_capacity(ScalarKind::F64, 1)];
let mut out = vec![];
for &p in prices {
col[0].push(Scalar::f64(p)).unwrap();
out.push(node.eval(Ctx::new(&col, Timestamp(0))).map(|c| c[0].f64()));
}
out
}
#[test]
fn fixed_stop_is_constant_after_first_price() {
let mut s = FixedStop::new(2.5);
assert_eq!(feed(&mut s, &[100.0, 110.0, 90.0]), vec![Some(2.5), Some(2.5), Some(2.5)]);
}
#[test]
fn vol_stop_is_none_until_warm() {
// length 3: needs a prior price (cycle 1 -> None) then 3 abs-returns.
let mut s = VolStop::new(3, 2.0);
let got = feed(&mut s, &[100.0, 101.0, 102.0, 103.0]);
assert_eq!(got[0], None); // no prior price
assert_eq!(got[1], None); // 1 return
assert_eq!(got[2], None); // 2 returns
assert!(got[3].is_some()); // 3 returns -> warm
}
#[test]
fn vol_stop_tracks_k_times_ema_abs_return() {
// constant abs-return of 1.0 -> EMA = 1.0 -> distance = k*1.0 = 2.0.
let mut s = VolStop::new(2, 2.0);
let got = feed(&mut s, &[100.0, 101.0, 102.0, 103.0]);
assert_eq!(got.last().unwrap().unwrap(), 2.0);
}
}