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:
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user