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
+10 -10
View File
@@ -909,7 +909,7 @@ mod tests {
use crate::test_fixtures::{composite_sma_cross_harness, synthetic_prices};
use crate::{f64_field, summarize, ParamRange, RandomSpace, RunManifest, VecSource};
use aura_core::{Cell, Ctx, FieldSpec, Firing, NodeSchema, Timestamp};
use aura_std::{Ema, Exposure, Recorder, SimBroker, Sma, Sub};
use aura_std::{Bias, Ema, Recorder, SimBroker, Sma, Sub};
use std::sync::mpsc;
/// Build + bootstrap + run + drain + summarize one swept point into a
@@ -1890,7 +1890,7 @@ mod tests {
Box::new(Sma::new(2)),
Box::new(Sma::new(4)),
Box::new(Sub::new()),
Box::new(Exposure::new(0.5)),
Box::new(Bias::new(0.5)),
Box::new(SimBroker::new(0.0001)),
Box::new(Recorder::new(&[ScalarKind::F64], Firing::Any, tx_eq)),
Box::new(Recorder::new(&[ScalarKind::F64], Firing::Any, tx_ex)),
@@ -1899,7 +1899,7 @@ mod tests {
Sma::builder().schema().clone(),
Sma::builder().schema().clone(),
Sub::builder().schema().clone(),
Exposure::builder().schema().clone(),
Bias::builder().schema().clone(),
SimBroker::builder(0.0001).schema().clone(),
f64_recorder_sig(),
f64_recorder_sig(),
@@ -2100,7 +2100,7 @@ mod tests {
from_flat.iter().map(|p| p.kind).collect::<Vec<_>>(),
"per-slot param kinds must line up with the compiled flat-node order",
);
// the realistic harness's concrete space: two SMA lengths (I64) + Exposure
// the realistic harness's concrete space: two SMA lengths (I64) + Bias
// scale (F64); Sub/SimBroker/Recorder declare none
assert_eq!(
space.iter().map(|p| p.name.as_str()).collect::<Vec<_>>(),
@@ -2165,15 +2165,15 @@ mod tests {
#[test]
fn param_space_reflects_only_open_knobs() {
use aura_std::{Exposure, Sma};
use aura_std::{Bias, Sma};
// "sma2_entry": Sma "bias" with length bound to 2 (a structural constant)
// plus Exposure "exp" whose `scale` stays open. The bound knob must be
// plus Bias "exp" whose `scale` stays open. The bound knob must be
// absent from param_space; only the open one remains.
let strat = Composite::new(
"sma2_entry",
vec![
Sma::builder().named("bias").bind("length", Scalar::i64(2)).into(),
Exposure::builder().named("exp").into(),
Bias::builder().named("exp").into(),
],
vec![], // edges — irrelevant to param_space()
vec![], // input_roles
@@ -2535,7 +2535,7 @@ mod tests {
/// bad `compile()` Err.
#[test]
fn named_cross_resolves_by_name_and_runs_to_a_trace() {
// root { sma_cross[ fast/slow SMA -> Sub ] -> Exposure -> SimBroker -> rec },
// root { sma_cross[ fast/slow SMA -> Sub ] -> Bias -> SimBroker -> rec },
// plus an exposure tap, mirroring composite_sma_cross_harness but driven
// through the by-name binder so the injective cured space is exercised
// end-to-end (resolve + bootstrap + run), not just at compile().
@@ -2563,11 +2563,11 @@ mod tests {
"root",
vec![
BlueprintNode::Composite(cross),
Exposure::builder().into(),
Bias::builder().named("exposure").into(),
Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_ex).into(),
],
vec![
Edge { from: 0, to: 1, slot: 0, from_field: 0 }, // cross out -> Exposure
Edge { from: 0, to: 1, slot: 0, from_field: 0 }, // cross out -> Bias
Edge { from: 1, to: 2, slot: 0, from_field: 0 }, // exposure -> recorder
],
vec![Role {
+10 -10
View File
@@ -204,7 +204,7 @@ mod tests {
use crate::test_fixtures::sma_cross as hand_sma_cross;
use crate::{Composite, OutField, Role, Target};
use aura_core::{Firing, ScalarKind};
use aura_std::{Exposure, Recorder, SimBroker, Sma, Sub};
use aura_std::{Bias, Recorder, SimBroker, Sma, Sub};
use std::sync::mpsc;
/// The `sma_cross` sub-composite authored through the builder.
@@ -251,16 +251,16 @@ mod tests {
let (tx_ex, _r2) = mpsc::channel();
let mut g = GraphBuilder::new("root");
let xross = g.add(built_sma_cross());
let expo = g.add(Exposure::builder());
let expo = g.add(Bias::builder());
let broker = g.add(SimBroker::builder(0.0001));
let eq = g.add(Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_eq));
let ex = g.add(Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_ex));
let src = g.source_role("src", ScalarKind::F64);
g.feed(src, [xross.input("price"), broker.input("price")]);
g.connect(xross.output("out"), expo.input("signal"));
g.connect(expo.output("exposure"), broker.input("exposure"));
g.connect(expo.output("bias"), broker.input("exposure"));
g.connect(broker.output("equity"), eq.input("col[0]"));
g.connect(expo.output("exposure"), ex.input("col[0]"));
g.connect(expo.output("bias"), ex.input("col[0]"));
let built_flat = g
.build()
.expect("root resolves")
@@ -280,12 +280,12 @@ mod tests {
// wiring-totality check rejects it at compile (broker is node 1, slot 0).
let (tx_eq, _r1) = mpsc::channel();
let mut g = GraphBuilder::new("root");
let expo = g.add(Exposure::builder());
let expo = g.add(Bias::builder());
let broker = g.add(SimBroker::builder(0.0001));
let eq = g.add(Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_eq));
let price = g.source_role("price", ScalarKind::F64);
g.feed(price, [expo.input("signal"), broker.input("price")]);
// BUG: g.connect(expo.output("exposure"), broker.input("exposure")) missing.
// BUG: g.connect(expo.output("bias"), broker.input("exposure")) missing.
g.connect(broker.output("equity"), eq.input("col[0]"));
let compiled = g
.build()
@@ -386,19 +386,19 @@ mod tests {
fn simbroker_legs_resolve_by_name_and_a_typo_is_caught() {
// exposure/price (both f64, slot 0/1) addressed by NAME — the #21 legibility win
let mut ok = GraphBuilder::new("ok");
let expo = ok.add(Exposure::builder());
let expo = ok.add(Bias::builder());
let broker = ok.add(SimBroker::builder(0.0001));
let src = ok.source_role("p", ScalarKind::F64);
ok.feed(src, [expo.input("signal"), broker.input("price")]);
ok.connect(expo.output("exposure"), broker.input("exposure"));
ok.connect(expo.output("bias"), broker.input("exposure"));
ok.expose(broker.output("equity"), "eq");
assert!(ok.build().is_ok());
// a transposed/typo'd port name is caught, where a bare slot index is not
let mut typo = GraphBuilder::new("typo");
let expo2 = typo.add(Exposure::builder());
let expo2 = typo.add(Bias::builder());
let broker2 = typo.add(SimBroker::builder(0.0001));
typo.connect(expo2.output("exposure"), broker2.input("pirce"));
typo.connect(expo2.output("bias"), broker2.input("pirce"));
assert_eq!(
typo.build().err(),
Some(BuildError::UnknownInPort { node: 1, name: "pirce".into() })
+8 -8
View File
@@ -274,7 +274,7 @@ mod tests {
use aura_core::{
Cell, FieldSpec, Node, NodeSchema, PortSpec, PrimitiveBuilder, Scalar,
};
use aura_std::{Exposure, Recorder, Sma, Sub};
use aura_std::{Bias, Recorder, Sma, Sub};
use std::sync::mpsc;
/// A bare node whose only purpose is to back a `PrimitiveBuilder` in a test.
@@ -331,7 +331,7 @@ mod tests {
/// A small, stable root harness exercising the four model elements: a bound
/// source role (`price`, → a synthetic source node), a composite reference
/// (`sma_cross`, → a `composites` entry), a plain node (`Exposure`), and a
/// (`sma_cross`, → a `composites` entry), a plain node (`Bias`), and a
/// sink (`Recorder`, `output: vec![]`). A deliberately minimal serializer
/// fixture, independent of the CLI's built-in sample (`build_sample` in
/// aura-cli — a richer nested `signals` graph since cycle 0036), kept small so
@@ -343,11 +343,11 @@ mod tests {
"sample",
vec![
BlueprintNode::Composite(sma_cross()),
Exposure::builder().into(),
Bias::builder().into(),
Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx).into(),
],
vec![
Edge { from: 0, to: 1, slot: 0, from_field: 0 }, // spread -> Exposure
Edge { from: 0, to: 1, slot: 0, from_field: 0 }, // spread -> Bias
Edge { from: 1, to: 2, slot: 0, from_field: 0 }, // exposure -> sink
],
vec![Role {
@@ -375,7 +375,7 @@ mod tests {
}
/// `sample_root` with one interior edge re-targeted to a different valid node:
/// the `exposure -> sink` edge instead feeds the `Exposure` node's own input
/// the `exposure -> sink` edge instead feeds the `Bias` node's own input
/// slot (`to: 1`). Same node set, distinct wiring — the model must differ.
fn miswired_root() -> Composite {
let (tx, _rx) = mpsc::channel();
@@ -383,7 +383,7 @@ mod tests {
"sample",
vec![
BlueprintNode::Composite(sma_cross()),
Exposure::builder().into(),
Bias::builder().into(),
Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx).into(),
],
vec![
@@ -432,7 +432,7 @@ mod tests {
assert!(bound.contains(r#""params":[]"#), "{bound}");
// a bound f64 value renders canonically (shortest round-trip), e.g. 0.5
let f = prim_record(&Exposure::builder().bind("scale", Scalar::f64(0.5)));
let f = prim_record(&Bias::builder().bind("scale", Scalar::f64(0.5)));
assert!(f.contains(r#""bound":[[0,"scale","f64","0.5"]]"#), "{f}");
// an unbound builder → no "bound" field at all
@@ -494,7 +494,7 @@ mod tests {
// Re-capture if an intended model-shape change lands: temporarily add a
// `println!("{}", model_to_json(&sample_root()))` test, run with
// `-- --nocapture`, and paste the fresh bytes below.
let expected = r##"{"root":{"nodes":{"0":{"comp":"sma_cross"},"1":{"prim":{"type":"Exposure","role":"node","params":[["scale","f64"]],"ins":[["f64","any","signal"]],"outs":[["exposure","f64"]]}},"2":{"prim":{"type":"Recorder","role":"sink","params":[],"ins":[["f64","any","col[0]"]],"outs":[]}},"src_price":{"prim":{"type":"price","role":"source","params":[],"ins":[],"outs":[["price","f64"]]}}},"edges":[["0.o0","1.i0"],["1.o0","2.i0"],["src_price.o0","0.i0"]]},"composites":{"sma_cross":{"inputs":[["f64","any","price"]],"outputs":[["out","f64"]],"nodes":{"0":{"prim":{"name":"fast","type":"SMA","role":"node","params":[["length","i64"]],"ins":[["f64","any","series"]],"outs":[["value","f64"]]}},"1":{"prim":{"name":"slow","type":"SMA","role":"node","params":[["length","i64"]],"ins":[["f64","any","series"]],"outs":[["value","f64"]]}},"2":{"prim":{"type":"Sub","role":"node","params":[],"ins":[["f64","any","lhs"],["f64","any","rhs"]],"outs":[["value","f64"]]}}},"edges":[["0.o0","2.i0"],["1.o0","2.i1"],["@price","0.i0"],["@price","1.i0"],["2.o0","#0"]]}}}"##;
let expected = r##"{"root":{"nodes":{"0":{"comp":"sma_cross"},"1":{"prim":{"type":"Bias","role":"node","params":[["scale","f64"]],"ins":[["f64","any","signal"]],"outs":[["bias","f64"]]}},"2":{"prim":{"type":"Recorder","role":"sink","params":[],"ins":[["f64","any","col[0]"]],"outs":[]}},"src_price":{"prim":{"type":"price","role":"source","params":[],"ins":[],"outs":[["price","f64"]]}}},"edges":[["0.o0","1.i0"],["1.o0","2.i0"],["src_price.o0","0.i0"]]},"composites":{"sma_cross":{"inputs":[["f64","any","price"]],"outputs":[["out","f64"]],"nodes":{"0":{"prim":{"name":"fast","type":"SMA","role":"node","params":[["length","i64"]],"ins":[["f64","any","series"]],"outs":[["value","f64"]]}},"1":{"prim":{"name":"slow","type":"SMA","role":"node","params":[["length","i64"]],"ins":[["f64","any","series"]],"outs":[["value","f64"]]}},"2":{"prim":{"type":"Sub","role":"node","params":[],"ins":[["f64","any","lhs"],["f64","any","rhs"]],"outs":[["value","f64"]]}}},"edges":[["0.o0","2.i0"],["1.o0","2.i1"],["@price","0.i0"],["@price","1.i0"],["2.o0","#0"]]}}}"##;
assert_eq!(model_to_json(&sample_root()), expected);
}
+6 -6
View File
@@ -497,7 +497,7 @@ mod tests {
// PortSpec / NodeSchema name the fixtures' declared signatures, brought in here
// (production code reads them via the carried signatures, never naming the types).
use aura_core::{FieldSpec, NodeSchema, PortSpec};
use aura_std::{Exposure, Recorder, Sma, SimBroker, Sub};
use aura_std::{Bias, Recorder, Sma, SimBroker, Sub};
use std::sync::mpsc;
/// Build an f64 source stream from (timestamp, value) points.
@@ -2160,7 +2160,7 @@ mod tests {
Box::new(Sma::new(2)), // 0 fast
Box::new(Sma::new(4)), // 1 slow
Box::new(Sub::new()), // 2 raw signal
Box::new(Exposure::new(0.5)), // 3 exposure
Box::new(Bias::new(0.5)), // 3 bias
Box::new(SimBroker::new(0.0001)), // 4 pip equity
Box::new(Recorder::new(&[ScalarKind::F64], Firing::Any, tx_eq)), // 5 sink
],
@@ -2168,7 +2168,7 @@ mod tests {
Sma::builder().schema().clone(),
Sma::builder().schema().clone(),
Sub::builder().schema().clone(),
Exposure::builder().schema().clone(),
Bias::builder().schema().clone(),
SimBroker::builder(0.0001).schema().clone(),
recorder_sig(&[ScalarKind::F64], Firing::Any),
],
@@ -2183,7 +2183,7 @@ mod tests {
vec![
Edge { from: 0, to: 2, slot: 0, from_field: 0 }, // fast -> Sub.0
Edge { from: 1, to: 2, slot: 1, from_field: 0 }, // slow -> Sub.1
Edge { from: 2, to: 3, slot: 0, from_field: 0 }, // signal -> Exposure
Edge { from: 2, to: 3, slot: 0, from_field: 0 }, // signal -> Bias
Edge { from: 3, to: 4, slot: 0, from_field: 0 }, // exposure -> broker.0
Edge { from: 4, to: 5, slot: 0, from_field: 0 }, // equity -> recorder
],
@@ -2225,7 +2225,7 @@ mod tests {
Box::new(Sma::new(2)),
Box::new(Sma::new(4)),
Box::new(Sub::new()),
Box::new(Exposure::new(0.5)),
Box::new(Bias::new(0.5)),
Box::new(SimBroker::new(0.0001)),
Box::new(Recorder::new(&[ScalarKind::F64], Firing::Any, tx)),
],
@@ -2233,7 +2233,7 @@ mod tests {
Sma::builder().schema().clone(),
Sma::builder().schema().clone(),
Sub::builder().schema().clone(),
Exposure::builder().schema().clone(),
Bias::builder().schema().clone(),
SimBroker::builder(0.0001).schema().clone(),
recorder_sig(&[ScalarKind::F64], Firing::Any),
],
+2 -2
View File
@@ -62,8 +62,8 @@ pub use harness::{
VecSource,
};
pub use report::{
f64_field, join_on_ts, summarize, ColumnarTrace, JoinedRow, PositionAction, PositionEvent,
RunManifest, RunMetrics, RunReport,
f64_field, join_on_ts, summarize, summarize_r, ColumnarTrace, JoinedRow, PositionAction,
PositionEvent, RMetrics, RunManifest, RunMetrics, RunReport,
};
pub use sweep::{
sweep, GridSpace, ParamRange, RandomSpace, Space, SweepError, SweepFamily, SweepPoint,
+133 -4
View File
@@ -29,6 +29,85 @@ pub struct RunMetrics {
pub exposure_sign_flips: u64,
}
/// R-based signal-quality metrics (Stage-1), reduced from a `PositionManagement` dense
/// record stream by [`summarize_r`]. Account- and instrument-agnostic (pure R). The
/// iteration-2 fields (SQN, conviction terciles, net-of-cost) are added later.
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct RMetrics {
pub expectancy_r: f64, // mean realised R over all trades (equal-weighted; headline)
pub n_trades: u64,
pub win_rate: f64, // fraction with R > 0
pub avg_win_r: f64,
pub avg_loss_r: f64,
pub profit_factor: f64, // sum(win R) / |sum(loss R)|; 0.0 if no losses or no trades
pub max_r_drawdown: f64, // worst peak-to-trough on the by-trade cumulative-R curve, >= 0
pub n_open_at_end: u64, // positions force-closed at window end (counted, not hidden)
}
// Dense `PositionManagement` record column indices — the lockstep contract with
// aura-std's FIELD_NAMES/RECORD_KINDS (aura-std is only a dev-dependency here, so the
// layout is shared by convention; `stage1_r_e2e.rs` guards that it matches).
mod r_col {
pub const CLOSED: usize = 0;
pub const REALIZED_R: usize = 1;
pub const OPEN: usize = 11;
pub const UNREALIZED_R: usize = 12;
}
/// Reduce a `PositionManagement` dense record stream into R-metrics. Pure (C1).
/// The trade ledger is the rows where `closed_this_cycle`; a position still open on the
/// last row is force-closed at its `unrealized_r` (a window-end trade — never silently
/// folded as unrealised MtM). Empty input -> a well-defined all-zero `RMetrics`.
pub fn summarize_r(record: &[(Timestamp, Vec<Scalar>)]) -> RMetrics {
// Rows are the producer's dense `PositionManagement` records, always `PM_WIDTH`
// (14) columns wide — pinned by `stage1_r_e2e.rs`. Trust that layout and index
// every column directly: a guard on one column while the next is a bare `[]`
// index would buy nothing (a short row panics either way).
let mut rs: Vec<f64> = Vec::new();
let mut n_open_at_end = 0u64;
for (_, row) in record {
if row[r_col::CLOSED].as_bool() {
rs.push(row[r_col::REALIZED_R].as_f64());
}
}
if let Some((_, last)) = record.last()
&& last[r_col::OPEN].as_bool()
{
rs.push(last[r_col::UNREALIZED_R].as_f64());
n_open_at_end = 1;
}
let n = rs.len() as u64;
if n == 0 {
return RMetrics { expectancy_r: 0.0, n_trades: 0, win_rate: 0.0, avg_win_r: 0.0, avg_loss_r: 0.0, profit_factor: 0.0, max_r_drawdown: 0.0, n_open_at_end: 0 };
}
let sum: f64 = rs.iter().sum();
let wins: Vec<f64> = rs.iter().copied().filter(|&r| r > 0.0).collect();
let losses: Vec<f64> = rs.iter().copied().filter(|&r| r <= 0.0).collect();
let sum_win: f64 = wins.iter().sum();
let sum_loss: f64 = losses.iter().sum(); // <= 0
let avg = |v: &[f64]| if v.is_empty() { 0.0 } else { v.iter().sum::<f64>() / v.len() as f64 };
// by-trade cumulative-R drawdown
let mut peak = f64::NEG_INFINITY;
let mut cum = 0.0;
let mut max_dd = 0.0_f64;
for &r in &rs {
cum += r;
if cum > peak { peak = cum; }
let dd = peak - cum;
if dd > max_dd { max_dd = dd; }
}
RMetrics {
expectancy_r: sum / n as f64,
n_trades: n,
win_rate: wins.len() as f64 / n as f64,
avg_win_r: avg(&wins),
avg_loss_r: avg(&losses),
profit_factor: if sum_loss < 0.0 { sum_win / (-sum_loss) } else { 0.0 },
max_r_drawdown: max_dd,
n_open_at_end,
}
}
/// The three position-event actions (C10). Direction IS the action; volume is
/// unsigned. Serde-encoded as its i64 mapping (`Buy=0, Sell=1, Close=2`) so the
/// persisted/columnar form stays C7-scalar and ledger-faithful (`action: i64`).
@@ -329,7 +408,7 @@ mod tests {
use super::*;
use crate::{Edge, FlatGraph, Harness, SourceSpec, Target, VecSource};
use aura_core::{Firing, NodeSchema, PortSpec, ScalarKind};
use aura_std::{Exposure, Recorder, SimBroker, Sma, Sub};
use aura_std::{Bias, Recorder, SimBroker, Sma, Sub};
use std::sync::mpsc;
#[test]
@@ -401,7 +480,7 @@ mod tests {
}
/// Bootstrap the cycle-0007 signal-quality harness with TWO sinks: one on
/// the SimBroker equity output (node 4 -> node 5) and one on the Exposure
/// the SimBroker equity output (node 4 -> node 5) and one on the Bias
/// output (node 3 -> node 6). Returns the harness plus the two receivers.
#[allow(clippy::type_complexity)]
fn build_two_sink_harness() -> (
@@ -416,7 +495,7 @@ mod tests {
Box::new(Sma::new(2)), // 0
Box::new(Sma::new(4)), // 1
Box::new(Sub::new()), // 2
Box::new(Exposure::new(0.5)), // 3
Box::new(Bias::new(0.5)), // 3
Box::new(SimBroker::new(0.0001)), // 4
Box::new(Recorder::new(&[ScalarKind::F64], Firing::Any, tx_eq)), // 5 equity sink
Box::new(Recorder::new(&[ScalarKind::F64], Firing::Any, tx_ex)), // 6 exposure sink
@@ -425,7 +504,7 @@ mod tests {
Sma::builder().schema().clone(),
Sma::builder().schema().clone(),
Sub::builder().schema().clone(),
Exposure::builder().schema().clone(),
Bias::builder().schema().clone(),
SimBroker::builder(0.0001).schema().clone(),
f64_recorder_sig(),
f64_recorder_sig(),
@@ -492,6 +571,56 @@ mod tests {
assert_eq!(r1.to_json(), r2.to_json());
}
// Build a minimal dense record row: only the columns summarize_r reads matter.
// The four written slots reference `r_col::*` (not bare literals) so the helper
// tracks the same constants it exists to test — a literal would mask drift in them.
fn r_row(closed: bool, realized: f64, open: bool, unreal: f64) -> (Timestamp, Vec<Scalar>) {
let mut v = vec![Scalar::f64(0.0); r_col::UNREALIZED_R + 1];
v[r_col::CLOSED] = Scalar::bool(closed);
v[r_col::REALIZED_R] = Scalar::f64(realized);
v[r_col::OPEN] = Scalar::bool(open);
v[r_col::UNREALIZED_R] = Scalar::f64(unreal);
(Timestamp(0), v)
}
#[test]
fn summarize_r_is_zero_on_empty() {
let m = summarize_r(&[]);
assert_eq!(m.n_trades, 0);
assert_eq!(m.expectancy_r, 0.0);
assert_eq!(m.max_r_drawdown, 0.0);
}
#[test]
fn summarize_r_expectancy_winrate_profit_factor() {
// three closed trades: +2, -1, +1 (no open at end). E[R] = 2/3.
let rec = vec![
r_row(true, 2.0, true, 0.0),
r_row(false, 0.0, true, 0.0),
r_row(true, -1.0, true, 0.0),
r_row(true, 1.0, false, 0.0), // last row not open
];
let m = summarize_r(&rec);
assert_eq!(m.n_trades, 3);
assert!((m.expectancy_r - (2.0 / 3.0)).abs() < 1e-9);
assert!((m.win_rate - (2.0 / 3.0)).abs() < 1e-9);
assert!((m.profit_factor - 3.0).abs() < 1e-9); // (2+1)/1
assert_eq!(m.n_open_at_end, 0);
}
#[test]
fn summarize_r_force_closes_open_position_at_window_end() {
// one closed +1, then last row open with unrealized -0.5 -> a window-end trade.
let rec = vec![r_row(true, 1.0, true, 0.0), r_row(false, 0.0, true, -0.5)];
let m = summarize_r(&rec);
assert_eq!(m.n_trades, 2);
assert_eq!(m.n_open_at_end, 1);
assert!((m.expectancy_r - 0.25).abs() < 1e-9); // (1 + -0.5)/2
}
#[test]
fn summarize_r_max_drawdown_on_by_trade_curve() {
// cum: +3, +1 (dd 2), +4 -> max dd = 2.
let rec = vec![r_row(true, 3.0, false, 0.0), r_row(true, -2.0, false, 0.0), r_row(true, 3.0, false, 0.0)];
assert!((summarize_r(&rec).max_r_drawdown - 2.0).abs() < 1e-9);
}
fn samples(values: &[f64]) -> Vec<(Timestamp, f64)> {
values
.iter()
+3 -3
View File
@@ -6,7 +6,7 @@
use crate::{BlueprintNode, Composite, Edge, OutField, Role, Target};
use aura_core::{Firing, Scalar, ScalarKind, Timestamp};
use aura_std::{Exposure, Recorder, SimBroker, Sma, Sub};
use aura_std::{Bias, Recorder, SimBroker, Sma, Sub};
use std::sync::mpsc;
/// Seven synthetic F64 ticks driving the harness; deterministic input fixture.
@@ -62,13 +62,13 @@ pub(crate) fn composite_sma_cross_harness() -> (
"root",
vec![
BlueprintNode::Composite(sma_cross()),
Exposure::builder().into(),
Bias::builder().named("exposure").into(),
SimBroker::builder(0.0001).into(),
Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_eq).into(),
Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_ex).into(),
],
vec![
Edge { from: 0, to: 1, slot: 0, from_field: 0 }, // composite out -> Exposure
Edge { from: 0, to: 1, slot: 0, from_field: 0 }, // composite out -> Bias
Edge { from: 1, to: 2, slot: 0, from_field: 0 }, // exposure -> broker slot 0
Edge { from: 2, to: 3, slot: 0, from_field: 0 }, // equity -> sink
Edge { from: 1, to: 4, slot: 0, from_field: 0 }, // exposure -> sink