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