From 2c43296c2c32fb9b0fa998c281e4640a3a1bc0b7 Mon Sep 17 00:00:00 2001 From: Brummel Date: Tue, 23 Jun 2026 19:48:58 +0200 Subject: [PATCH] feat(stage1-r): bias rename + stop-rule + position-management + summarize_r (iter 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- crates/aura-cli/src/main.rs | 39 +- crates/aura-cli/src/render.rs | 2 +- .../aura-cli/tests/fixtures/sample-model.json | 2 +- crates/aura-engine/src/blueprint.rs | 20 +- crates/aura-engine/src/builder.rs | 20 +- crates/aura-engine/src/graph_model.rs | 16 +- crates/aura-engine/src/harness.rs | 12 +- crates/aura-engine/src/lib.rs | 4 +- crates/aura-engine/src/report.rs | 137 ++++++- crates/aura-engine/src/test_fixtures.rs | 6 +- crates/aura-engine/tests/random_sweep_e2e.rs | 6 +- crates/aura-engine/tests/stage1_r_e2e.rs | 204 +++++++++++ crates/aura-ingest/tests/real_bars.rs | 8 +- crates/aura-ingest/tests/streaming_seam.rs | 6 +- crates/aura-std/src/{exposure.rs => bias.rs} | 44 +-- crates/aura-std/src/latch.rs | 2 +- crates/aura-std/src/lib.rs | 11 +- crates/aura-std/src/position_management.rs | 336 ++++++++++++++++++ crates/aura-std/src/sim_broker.rs | 2 +- crates/aura-std/src/sma.rs | 8 +- crates/aura-std/src/stop_rule.rs | 138 +++++++ 21 files changed, 919 insertions(+), 104 deletions(-) create mode 100644 crates/aura-engine/tests/stage1_r_e2e.rs rename crates/aura-std/src/{exposure.rs => bias.rs} (63%) create mode 100644 crates/aura-std/src/position_management.rs create mode 100644 crates/aura-std/src/stop_rule.rs diff --git a/crates/aura-cli/src/main.rs b/crates/aura-cli/src/main.rs index aac560b..989b0d6 100644 --- a/crates/aura-cli/src/main.rs +++ b/crates/aura-cli/src/main.rs @@ -2,7 +2,7 @@ //! automation drive: author a node, run a sim/sweep, emit structured metrics). //! //! The walking skeleton's closing seam: `aura run` bootstraps a built-in sample -//! signal-quality harness (synthetic source → SMA-cross → Exposure → SimBroker → +//! signal-quality harness (synthetic source → SMA-cross → Bias → SimBroker → //! recording sinks), runs it deterministically (C1), and prints the run's //! metrics + manifest (#6) as canonical JSON to stdout (the headline C14 move). //! @@ -26,7 +26,7 @@ use aura_registry::{ walkforward_member_reports, FamilyKind, FamilyMember, NameKind, Registry, RunTraces, TraceStore, WriteKind, }; -use aura_std::{Ema, Exposure, LinComb, LongOnly, Recorder, SimBroker, Sma, Sub}; +use aura_std::{Bias, Ema, LinComb, LongOnly, Recorder, SimBroker, Sma, Sub}; use std::sync::mpsc::{self, Receiver}; use std::collections::HashSet; @@ -82,7 +82,7 @@ fn showcase_prices() -> Vec<(Timestamp, Scalar)> { } /// Bootstrap the sample signal-quality harness with two recording sinks (equity -/// tapped on the SimBroker, exposure tapped on the Exposure node). Rust-authored +/// tapped on the SimBroker, exposure tapped on the Bias node). Rust-authored /// wiring (C17/C20) over the raw bootstrap API — no builder DSL this cycle. The /// price taps both SMAs and the broker's price slot (slot 1); exposure feeds the /// broker's slot 0 (slot order is load-bearing — both are f64). @@ -106,7 +106,7 @@ fn sample_harness(pip_size: f64) -> ( Box::new(Sma::new(2)), // 0 fast SMA Box::new(Sma::new(4)), // 1 slow SMA Box::new(Sub::new()), // 2 spread - Box::new(Exposure::new(0.5)), // 3 exposure + Box::new(Bias::new(0.5)), // 3 bias Box::new(SimBroker::new(pip_size)), // 4 sim-optimal broker 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 @@ -115,7 +115,7 @@ fn sample_harness(pip_size: f64) -> ( Sma::builder().schema().clone(), Sma::builder().schema().clone(), Sub::builder().schema().clone(), - Exposure::builder().schema().clone(), + Bias::builder().schema().clone(), SimBroker::builder(pip_size).schema().clone(), f64_recorder_sig(), f64_recorder_sig(), @@ -960,16 +960,16 @@ fn sample_blueprint_with_sinks(pip_size: f64) -> ( let (tx_ex, rx_ex) = mpsc::channel(); let mut g = GraphBuilder::new("sample"); let sig = g.add(signals("signals")); - let exposure = g.add(Exposure::builder()); + let exposure = g.add(Bias::builder().named("exposure")); let broker = g.add(SimBroker::builder(pip_size)); 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 price = g.source_role("price", ScalarKind::F64); g.feed(price, [sig.input("price"), broker.input("price")]); - g.connect(sig.output("signal"), exposure.input("signal")); // blended signal -> Exposure - g.connect(exposure.output("exposure"), broker.input("exposure")); // exposure -> broker slot 0 + g.connect(sig.output("signal"), exposure.input("signal")); // blended signal -> Bias + g.connect(exposure.output("bias"), broker.input("exposure")); // bias -> broker slot 0 g.connect(broker.output("equity"), eq.input("col[0]")); // equity -> sink - g.connect(exposure.output("exposure"), ex.input("col[0]")); // exposure -> sink + g.connect(exposure.output("bias"), ex.input("col[0]")); // bias -> sink let bp = g.build().expect("sample blueprint wiring resolves"); (bp, rx_eq, rx_ex) } @@ -1031,8 +1031,9 @@ fn sweep_family(trace: Option<&str>, data: &DataSource) -> SweepFamily { } /// The EMA-distance momentum demo strategy with its two recording sinks reachable. -/// The signal is how far price sits above/below its own EMA (`price - ema`), sized -/// by `Exposure`, then passed through the long-only `LongOnly` gate. Three swept +/// The signal is how far price sits above/below its own EMA (`price - ema`), bounded +/// into a directional (unsized) bias by `Bias`, then passed through the long-only +/// `LongOnly` gate. Three swept /// knobs of three kinds — `ema.length` (i64), `exposure.scale` (f64), /// `longonly.enabled` (bool) — with nothing in common with the SMA-cross demo: the /// generic-sweep proof. The exposure sink taps the FINAL (gated) exposure so the @@ -1051,7 +1052,7 @@ fn momentum_blueprint_with_sinks(pip_size: f64) -> ( // derivation (the member-key examples + the param-space test depend on these). let ema = g.add(Ema::builder().named("ema")); // ema.length let dist = g.add(Sub::builder()); // momentum = price - ema - let expo = g.add(Exposure::builder().named("exposure")); // exposure.scale + let expo = g.add(Bias::builder().named("exposure")); // exposure.scale let gate = g.add(LongOnly::builder().named("longonly")); // longonly.enabled (the bool param) let broker = g.add(SimBroker::builder(pip_size)); let eq = g.add(Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_eq)); @@ -1060,7 +1061,7 @@ fn momentum_blueprint_with_sinks(pip_size: f64) -> ( g.feed(price, [ema.input("series"), dist.input("lhs"), broker.input("price")]); g.connect(ema.output("value"), dist.input("rhs")); // momentum = price - ema g.connect(dist.output("value"), expo.input("signal")); - g.connect(expo.output("exposure"), gate.input("exposure")); + g.connect(expo.output("bias"), gate.input("exposure")); g.connect(gate.output("exposure"), broker.input("exposure")); // gated exposure -> broker g.connect(broker.output("equity"), eq.input("col[0]")); // equity -> sink g.connect(gate.output("exposure"), ex.input("col[0]")); // gated exposure -> sink @@ -1601,7 +1602,7 @@ fn macd(name: &str) -> Composite { g.build().expect("sample macd wiring resolves") } -/// The MACD strategy blueprint (value-empty): the `macd` histogram → `Exposure` → +/// The MACD strategy blueprint (value-empty): the `macd` histogram → `Bias` → /// `SimBroker` → recording sinks. Channels are threaded so a run can drain the /// sinks; `macd_blueprint` drops the receivers for the structural render. fn macd_strategy_blueprint( @@ -1610,16 +1611,16 @@ fn macd_strategy_blueprint( ) -> Composite { let mut g = GraphBuilder::new("macd_strategy"); let macd_node = g.add(macd("macd")); - let exposure = g.add(Exposure::builder()); + let exposure = g.add(Bias::builder().named("exposure")); let broker = g.add(SimBroker::builder(SYNTHETIC_PIP_SIZE)); 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 price = g.source_role("price", ScalarKind::F64); g.feed(price, [macd_node.input("price"), broker.input("price")]); - g.connect(macd_node.output("histogram"), exposure.input("signal")); // histogram → Exposure - g.connect(exposure.output("exposure"), broker.input("exposure")); // exposure → broker slot 0 + g.connect(macd_node.output("histogram"), exposure.input("signal")); // histogram → Bias + g.connect(exposure.output("bias"), broker.input("exposure")); // bias → broker slot 0 g.connect(broker.output("equity"), eq.input("col[0]")); // equity → sink - g.connect(exposure.output("exposure"), ex.input("col[0]")); // exposure → sink + g.connect(exposure.output("bias"), ex.input("col[0]")); // bias → sink g.build().expect("macd_strategy wiring resolves") } @@ -2303,7 +2304,7 @@ mod tests { let names: Vec = macd_blueprint().param_space().into_iter().map(|p| p.name).collect(); // three named composite-interior slots, in declared (fast, slow, signal) - // order, then the strategy-level Exposure `scale` (a root-level leaf). + // order, then the strategy-level Bias `scale` (a root-level leaf). assert_eq!( names, vec![ diff --git a/crates/aura-cli/src/render.rs b/crates/aura-cli/src/render.rs index 9d1b10d..c922713 100644 --- a/crates/aura-cli/src/render.rs +++ b/crates/aura-cli/src/render.rs @@ -259,7 +259,7 @@ mod tests { // the deterministic model is injected as the viewer's data source assert!(html.contains("window.AURA_MODEL = {\"root\":"), "model JSON not injected"); // a known node from the sample harness round-trips via the model - assert!(html.contains("\"type\":\"Exposure\""), "sample model content missing"); + assert!(html.contains("\"type\":\"Bias\""), "sample model content missing"); // the vendored Graphviz-WASM is present (its bootstrap global) assert!(html.contains("Viz.instance"), "viewer bootstrap (Viz.instance) missing"); // C4: the four-scalar palette, no fifth colour diff --git a/crates/aura-cli/tests/fixtures/sample-model.json b/crates/aura-cli/tests/fixtures/sample-model.json index f2bde42..e16452c 100644 --- a/crates/aura-cli/tests/fixtures/sample-model.json +++ b/crates/aura-cli/tests/fixtures/sample-model.json @@ -1 +1 @@ -{"root":{"nodes":{"0":{"comp":"signals"},"1":{"prim":{"type":"Exposure","role":"node","params":[["scale","f64"]],"ins":[["f64","any","signal"]],"outs":[["exposure","f64"]]}},"2":{"prim":{"type":"SimBroker","role":"node","params":[],"ins":[["f64","any","exposure"],["f64","any","price"]],"outs":[["equity","f64"]]}},"3":{"prim":{"type":"Recorder","role":"sink","params":[],"ins":[["f64","any","col[0]"]],"outs":[]}},"4":{"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"],["2.o0","3.i0"],["1.o0","4.i0"],["src_price.o0","0.i0"],["src_price.o0","2.i1"]]},"composites":{"signals":{"inputs":[["f64","any","price"]],"outputs":[["signal","f64"]],"nodes":{"0":{"comp":"trend"},"1":{"comp":"momentum"},"2":{"prim":{"name":"blend","type":"LinComb","role":"node","params":[["weights[0]","f64"],["weights[1]","f64"]],"bound":[[2,"weights[2]","f64","0.5"]],"ins":[["f64","any","term[0]"],["f64","any","term[1]"],["f64","any","term[2]"]],"outs":[["value","f64"]]}}},"edges":[["0.o0","2.i0"],["1.o2","2.i1"],["1.o1","2.i2"],["@price","0.i0"],["@price","1.i0"],["2.o0","#0"]]},"trend":{"inputs":[["f64","any","price"]],"outputs":[["cross","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"]]},"momentum":{"inputs":[["f64","any","price"]],"outputs":[["macd","f64"],["signal","f64"],["histogram","f64"]],"nodes":{"0":{"prim":{"name":"fast","type":"EMA","role":"node","params":[["length","i64"]],"ins":[["f64","any","series"]],"outs":[["value","f64"]]}},"1":{"prim":{"name":"slow","type":"EMA","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"]]}},"3":{"prim":{"name":"signal","type":"EMA","role":"node","params":[["length","i64"]],"ins":[["f64","any","series"]],"outs":[["value","f64"]]}},"4":{"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"],["2.o0","3.i0"],["2.o0","4.i0"],["3.o0","4.i1"],["@price","0.i0"],["@price","1.i0"],["2.o0","#0"],["3.o0","#1"],["4.o0","#2"]]}}} +{"root":{"nodes":{"0":{"comp":"signals"},"1":{"prim":{"type":"Bias","role":"node","params":[["scale","f64"]],"ins":[["f64","any","signal"]],"outs":[["bias","f64"]]}},"2":{"prim":{"type":"SimBroker","role":"node","params":[],"ins":[["f64","any","exposure"],["f64","any","price"]],"outs":[["equity","f64"]]}},"3":{"prim":{"type":"Recorder","role":"sink","params":[],"ins":[["f64","any","col[0]"]],"outs":[]}},"4":{"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"],["2.o0","3.i0"],["1.o0","4.i0"],["src_price.o0","0.i0"],["src_price.o0","2.i1"]]},"composites":{"signals":{"inputs":[["f64","any","price"]],"outputs":[["signal","f64"]],"nodes":{"0":{"comp":"trend"},"1":{"comp":"momentum"},"2":{"prim":{"name":"blend","type":"LinComb","role":"node","params":[["weights[0]","f64"],["weights[1]","f64"]],"bound":[[2,"weights[2]","f64","0.5"]],"ins":[["f64","any","term[0]"],["f64","any","term[1]"],["f64","any","term[2]"]],"outs":[["value","f64"]]}}},"edges":[["0.o0","2.i0"],["1.o2","2.i1"],["1.o1","2.i2"],["@price","0.i0"],["@price","1.i0"],["2.o0","#0"]]},"trend":{"inputs":[["f64","any","price"]],"outputs":[["cross","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"]]},"momentum":{"inputs":[["f64","any","price"]],"outputs":[["macd","f64"],["signal","f64"],["histogram","f64"]],"nodes":{"0":{"prim":{"name":"fast","type":"EMA","role":"node","params":[["length","i64"]],"ins":[["f64","any","series"]],"outs":[["value","f64"]]}},"1":{"prim":{"name":"slow","type":"EMA","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"]]}},"3":{"prim":{"name":"signal","type":"EMA","role":"node","params":[["length","i64"]],"ins":[["f64","any","series"]],"outs":[["value","f64"]]}},"4":{"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"],["2.o0","3.i0"],["2.o0","4.i0"],["3.o0","4.i1"],["@price","0.i0"],["@price","1.i0"],["2.o0","#0"],["3.o0","#1"],["4.o0","#2"]]}}} diff --git a/crates/aura-engine/src/blueprint.rs b/crates/aura-engine/src/blueprint.rs index a786ebb..790c028 100644 --- a/crates/aura-engine/src/blueprint.rs +++ b/crates/aura-engine/src/blueprint.rs @@ -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::>(), "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::>(), @@ -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 { diff --git a/crates/aura-engine/src/builder.rs b/crates/aura-engine/src/builder.rs index b75f242..2427a1d 100644 --- a/crates/aura-engine/src/builder.rs +++ b/crates/aura-engine/src/builder.rs @@ -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() }) diff --git a/crates/aura-engine/src/graph_model.rs b/crates/aura-engine/src/graph_model.rs index 1d061b6..9cb1890 100644 --- a/crates/aura-engine/src/graph_model.rs +++ b/crates/aura-engine/src/graph_model.rs @@ -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); } diff --git a/crates/aura-engine/src/harness.rs b/crates/aura-engine/src/harness.rs index acbfd0b..5b44651 100644 --- a/crates/aura-engine/src/harness.rs +++ b/crates/aura-engine/src/harness.rs @@ -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), ], diff --git a/crates/aura-engine/src/lib.rs b/crates/aura-engine/src/lib.rs index 0801e21..77f45bb 100644 --- a/crates/aura-engine/src/lib.rs +++ b/crates/aura-engine/src/lib.rs @@ -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, diff --git a/crates/aura-engine/src/report.rs b/crates/aura-engine/src/report.rs index fb964e7..9e75113 100644 --- a/crates/aura-engine/src/report.rs +++ b/crates/aura-engine/src/report.rs @@ -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)]) -> 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 = 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 = rs.iter().copied().filter(|&r| r > 0.0).collect(); + let losses: Vec = 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::() / 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) { + 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() diff --git a/crates/aura-engine/src/test_fixtures.rs b/crates/aura-engine/src/test_fixtures.rs index 6c65011..8150330 100644 --- a/crates/aura-engine/src/test_fixtures.rs +++ b/crates/aura-engine/src/test_fixtures.rs @@ -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 diff --git a/crates/aura-engine/tests/random_sweep_e2e.rs b/crates/aura-engine/tests/random_sweep_e2e.rs index 2b4c288..6799520 100644 --- a/crates/aura-engine/tests/random_sweep_e2e.rs +++ b/crates/aura-engine/tests/random_sweep_e2e.rs @@ -18,7 +18,7 @@ use aura_engine::{ f64_field, summarize, sweep, BlueprintNode, Composite, Edge, OutField, ParamRange, ParamSpec, RandomSpace, Role, RunManifest, RunReport, Scalar, Space, SweepError, Target, VecSource, }; -use aura_std::{Exposure, Recorder, SimBroker, Sma, Sub}; +use aura_std::{Bias, Recorder, SimBroker, Sma, Sub}; /// Seven synthetic F64 ticks (mirrors the crate-private `synthetic_prices`): /// short enough that small SMA windows warm up, so every run yields finite, @@ -72,13 +72,13 @@ fn sma_cross_harness() -> ( "root", vec![ BlueprintNode::Composite(sma_cross), - Exposure::builder().into(), + Bias::builder().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 diff --git a/crates/aura-engine/tests/stage1_r_e2e.rs b/crates/aura-engine/tests/stage1_r_e2e.rs new file mode 100644 index 0000000..175c0b0 --- /dev/null +++ b/crates/aura-engine/tests/stage1_r_e2e.rs @@ -0,0 +1,204 @@ +//! Stage-1 R E2E: a synthetic bias+price chain through stop-rule + position-management, +//! recorded and folded by `summarize_r`. Also guards the dense-record layout contract +//! (aura-std's `PM_FIELD_NAMES`/`PM_RECORD_KINDS` vs the column indices `summarize_r` +//! reads). +//! +//! Two distinct jobs live here: +//! +//! 1. **The producer -> consumer seam (E2E).** The whole point of the dense +//! `PositionManagement` record is that one node emits it and a *different* +//! crate (`summarize_r` in `aura-engine`) folds it. The `summarize_r` unit +//! tests in `report.rs` feed hand-built rows whose indices are hardcoded +//! independently of the producer, so they never exercise that seam. Here the +//! real chain runs node-by-node: a synthetic price drives `FixedStop` and +//! `PositionManagement` directly (their `eval` is the contract), the dense +//! records are collected, and `summarize_r` folds them. If the producer's +//! column layout and the consumer's reads disagreed, the folded R-metrics +//! would be wrong. +//! +//! 2. **The column-index contract guard.** `report.rs::summarize_r` reads the +//! dense record by raw index (`CLOSED=0`, `REALIZED_R=1`, `OPEN=11`, +//! `UNREALIZED_R=12`). `aura-std` declares the authoritative layout in +//! `PM_FIELD_NAMES` / `PM_RECORD_KINDS` with a fixed `PM_WIDTH`. `aura-std` is +//! only a dev-dependency of `aura-engine`, so the two sit across a crate +//! boundary with no compiler link — a reorder of `PM_FIELD_NAMES` (or a +//! `PM_WIDTH` change) would silently misalign `summarize_r`. The +//! `r_col_indices_match_producer_field_layout` test pins that contract. + +use aura_core::{AnyColumn, Ctx, Node, Scalar, ScalarKind, Timestamp}; +use aura_engine::{RMetrics, summarize_r}; +use aura_std::{FixedStop, PM_FIELD_NAMES, PM_RECORD_KINDS, PM_WIDTH, PositionManagement, VolStop}; + +// The indices `report.rs::summarize_r` reads the dense record by. Re-declared here +// (the consumer's `r_col` module is private to `aura-engine`) so the guard test can +// assert they line up with the producer's `PM_FIELD_NAMES` / `PM_RECORD_KINDS`. +const CLOSED: usize = 0; +const REALIZED_R: usize = 1; +const OPEN: usize = 11; +const UNREALIZED_R: usize = 12; + +/// Property: the real producer->consumer seam composes. Driving `FixedStop` -> +/// `PositionManagement` directly (node-by-node, not a bootstrapped graph) over a +/// synthetic price that rises then falls through the stop, with a constant long +/// bias, must produce at least one trade and a finite `RMetrics` — proving +/// `stop -> position-management -> summarize_r` folds the *recorded* dense records +/// into well-defined R-outcomes. The entry sits near 100 and the price gaps down +/// through the 95 stop, so the closed trade is a loss; a fresh long reopens on the +/// last cycle and is force-closed at window end. +#[test] +fn synthetic_long_then_stop_produces_a_sane_rmetric() { + let mut stop = FixedStop::new(5.0); + let mut pm = PositionManagement::new(); + let mut sc = vec![AnyColumn::with_capacity(ScalarKind::F64, 1)]; // stop input: price + let mut pc: Vec = + (0..3).map(|_| AnyColumn::with_capacity(ScalarKind::F64, 1)).collect(); + let mut ledger: Vec<(Timestamp, Vec)> = vec![]; + // price path: up to 110 then down through the 95 stop. + let prices = [100.0, 104.0, 108.0, 110.0, 102.0, 96.0, 94.0]; + for (i, &p) in prices.iter().enumerate() { + sc[0].push(Scalar::f64(p)).unwrap(); + let dist = stop.eval(Ctx::new(&sc, Timestamp(i as i64))).map(|c| c[0].f64()).unwrap_or(0.0); + pc[0].push(Scalar::f64(1.0)).unwrap(); // constant long bias + pc[1].push(Scalar::f64(p)).unwrap(); + pc[2].push(Scalar::f64(dist)).unwrap(); + if let Some(row) = pm.eval(Ctx::new(&pc, Timestamp(i as i64))) { + ledger.push(( + Timestamp(i as i64), + row.iter() + .enumerate() + .map(|(j, c)| Scalar::from_cell(PM_RECORD_KINDS[j], *c)) + .collect(), + )); + } + } + let m = summarize_r(&ledger); + assert!(m.n_trades >= 1, "expected at least one trade (entry then stop), got {m:?}"); + assert!(m.expectancy_r.is_finite()); + // entered ~100, stopped at the 95 level (price gapped to 94 through it) -> a loss. + assert!(m.expectancy_r < 0.0 || m.n_open_at_end == 1); +} + +/// Drive the real cross-crate chain `stop -> PositionManagement -> summarize_r` +/// over a `(bias, price)` path, collecting the producer's dense records (decoded by +/// the producer's own `PM_RECORD_KINDS`, the wire contract) and folding them. This +/// is the actual producer->consumer seam, node-by-node; the resulting `RMetrics` is +/// what a recorded Stage-1 run would report. Per-cycle bias (not constant) so a +/// fixture can drop bias to 0 to forbid the immediate re-entry after a stop. +fn run_chain(stop: &mut dyn Node, path: &[(f64, f64)]) -> RMetrics { + let mut sc = vec![AnyColumn::with_capacity(ScalarKind::F64, 1)]; // stop input: price + let mut pc: Vec = + (0..3).map(|_| AnyColumn::with_capacity(ScalarKind::F64, 1)).collect(); + let mut pm = PositionManagement::new(); + let mut ledger: Vec<(Timestamp, Vec)> = vec![]; + for (i, &(bias, p)) in path.iter().enumerate() { + let ts = Timestamp(i as i64); + sc[0].push(Scalar::f64(p)).unwrap(); + let dist = stop.eval(Ctx::new(&sc, ts)).map(|c| c[0].f64()).unwrap_or(0.0); + pc[0].push(Scalar::f64(bias)).unwrap(); + pc[1].push(Scalar::f64(p)).unwrap(); + pc[2].push(Scalar::f64(dist)).unwrap(); + if let Some(row) = pm.eval(Ctx::new(&pc, ts)) { + ledger.push(( + ts, + row.iter().enumerate().map(|(j, c)| Scalar::from_cell(PM_RECORD_KINDS[j], *c)).collect(), + )); + } + } + summarize_r(&ledger) +} + +/// A constant-long-bias path (`bias = +1` every cycle) over `prices`. +fn long_path(prices: &[f64]) -> Vec<(f64, f64)> { + prices.iter().map(|&p| (1.0, p)).collect() +} + +/// Property: **R is stop-defined, and the stop choice flows through the whole +/// producer->consumer seam.** The same constant-long-bias price path, folded +/// through two different stop rules, must yield different expectancy_r — because R +/// is `(exit - entry) / latched_distance` and the two rules latch different +/// distances. With a no-gap stop hit at exactly the stop level the fold returns +/// exactly -1R *whatever the distance*, so to observe the stop-defines-R property +/// end-to-end the price must overshoot the stop: then a wider FixedStop (distance +/// 10) realises a shallower R-loss than a tight VolStop. A run that ignored the +/// stop distance (e.g. measured R in raw pips) would collapse these to one number. +#[test] +fn r_is_stop_defined_two_stops_fold_to_different_expectancy() { + // Open long ~100, then a sharp drop that overshoots both stops. + // FixedStop(10): stop @90, fill @85 -> R = (85-100)/10 = -1.5. + let mut fixed = FixedStop::new(10.0); + let wide = run_chain(&mut fixed, &long_path(&[100.0, 100.0, 85.0])); + // VolStop(1, 1.0): length-1 VolStop warms after one return; entry on the cycle + // after warm-up. returns: 100->101 (=1), warm; entry @101 with dist=1, stop @100; + // drop to 96. A unit (1.0) far tighter than the wide FixedStop unit (10.0). + let mut vol = VolStop::new(1, 1.0); + let vol_m = run_chain(&mut vol, &long_path(&[100.0, 101.0, 96.0])); + assert!(wide.n_trades >= 1 && vol_m.n_trades >= 1); + // R is the (exit-entry)/distance ratio: the tight VolStop unit (1.0) yields a far + // deeper R-loss than the wide FixedStop unit (10.0) for a comparable price drop. + assert!( + vol_m.expectancy_r < wide.expectancy_r, + "stop choice must change folded R: vol={:?} wide={:?}", + vol_m.expectancy_r, + wide.expectancy_r, + ); +} + +/// Property: **a position open at window end is force-closed into expectancy at its +/// unrealized R, not silently dropped — through the real fold.** A constant long +/// bias with a monotonically rising price never triggers a stop or bias-exit, so the +/// position is still open on the last record. `summarize_r` must count it +/// (`n_open_at_end == 1`) and fold its unrealized R into expectancy. Entry latches at +/// 100 with FixedStop distance 10; the last mark is 105, so the forced window-end +/// trade is exactly +0.5R and, being the only trade, that is the expectancy. +#[test] +fn open_at_window_end_is_folded_into_expectancy_not_dropped() { + let mut stop = FixedStop::new(10.0); + let m = run_chain(&mut stop, &long_path(&[100.0, 102.0, 105.0])); // long @100, never exits, last @105 + assert_eq!(m.n_open_at_end, 1, "the open position must be counted, not hidden"); + assert_eq!(m.n_trades, 1, "exactly the one window-end trade"); + assert!((m.expectancy_r - 0.5).abs() < 1e-9, "window-end R = (105-100)/10; got {}", m.expectancy_r); +} + +/// Property: **a clean no-gap stop folds to exactly -1R through the chain (the +/// lagged-fill keystone), and that exact value survives the cross-crate fold.** A +/// long opened at 100 with FixedStop distance 10 (stop @90), price falling to touch +/// 90 exactly, realises -1.0R at the producer and `summarize_r` reports expectancy +/// exactly -1.0 — the R-unit's defining identity (1R = the loss if stopped), +/// asserted end-to-end rather than only on hand-built rows. Bias drops to 0 on the +/// stop cycle so the producer does not immediately re-enter a fresh long (which +/// would add a window-end trade) — isolating the single stopped trade. +#[test] +fn clean_stop_folds_to_exactly_minus_one_r() { + let mut stop = FixedStop::new(10.0); + // (bias, price): open long @100, hold, then bias->0 as price touches the 90 stop. + let m = run_chain(&mut stop, &[(1.0, 100.0), (1.0, 100.0), (0.0, 90.0)]); + assert_eq!(m.n_trades, 1); + assert_eq!(m.n_open_at_end, 0, "the stop closed it before window end"); + assert!((m.expectancy_r + 1.0).abs() < 1e-9, "1R = the loss if stopped; got {}", m.expectancy_r); +} + +/// Contract guard (the lockstep cross-crate column-index agreement `report.rs` +/// names): `PM_WIDTH` is the fixed dense-record arity, the indices `summarize_r` +/// reads the dense record by must equal the indices the producer's authoritative +/// `PM_FIELD_NAMES` assigns those columns, and the producer's `PM_RECORD_KINDS` at +/// those indices must be the kinds `summarize_r` assumes when it reads them as +/// bool / f64. `aura-std` is only a dev-dependency, so nothing but this test stops +/// a `PM_WIDTH` change or a `PM_FIELD_NAMES` reorder from silently breaking the fold. +#[test] +fn r_col_indices_match_producer_field_layout() { + assert_eq!(PM_WIDTH, 14); + assert_eq!(PM_FIELD_NAMES.len(), PM_WIDTH); + assert_eq!(PM_RECORD_KINDS.len(), PM_WIDTH); + + // names: the consumer's read indices land on the columns it intends. + assert_eq!(PM_FIELD_NAMES[CLOSED], "closed_this_cycle"); + assert_eq!(PM_FIELD_NAMES[REALIZED_R], "realized_r"); + assert_eq!(PM_FIELD_NAMES[OPEN], "open"); + assert_eq!(PM_FIELD_NAMES[UNREALIZED_R], "unrealized_r"); + + // kinds: summarize_r reads CLOSED/OPEN as bool and REALIZED_R/UNREALIZED_R as f64. + assert_eq!(PM_RECORD_KINDS[CLOSED], ScalarKind::Bool); + assert_eq!(PM_RECORD_KINDS[OPEN], ScalarKind::Bool); + assert_eq!(PM_RECORD_KINDS[REALIZED_R], ScalarKind::F64); + assert_eq!(PM_RECORD_KINDS[UNREALIZED_R], ScalarKind::F64); +} diff --git a/crates/aura-ingest/tests/real_bars.rs b/crates/aura-ingest/tests/real_bars.rs index 603e909..7b88dff 100644 --- a/crates/aura-ingest/tests/real_bars.rs +++ b/crates/aura-ingest/tests/real_bars.rs @@ -1,5 +1,5 @@ //! Gated integration test: a real data-server M1 close stream driven through -//! the cycle-0007 signal-quality sample harness (SMA-cross → Exposure → +//! the cycle-0007 signal-quality sample harness (SMA-cross → Bias → //! SimBroker), folded into a `RunReport`. Skips with a note where the local //! Pepperstone data directory is absent, so `cargo test --workspace` stays green //! anywhere; it exercises the real ingestion path where data exists. @@ -13,7 +13,7 @@ use aura_engine::{ VecSource, }; use aura_ingest::load_m1_window; -use aura_std::{Exposure, Recorder, SimBroker, Sma, Sub}; +use aura_std::{Bias, Recorder, SimBroker, Sma, Sub}; use data_server::{DataServer, DEFAULT_DATA_PATH}; /// Bootstrap the cycle-0007 two-sink signal-quality harness (mirrors @@ -33,7 +33,7 @@ fn run_sample_over(prices: Vec<(Timestamp, Scalar)>) -> RunReport { 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 @@ -42,7 +42,7 @@ fn run_sample_over(prices: Vec<(Timestamp, Scalar)>) -> RunReport { 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(), diff --git a/crates/aura-ingest/tests/streaming_seam.rs b/crates/aura-ingest/tests/streaming_seam.rs index 3891ae9..ae8b2fc 100644 --- a/crates/aura-ingest/tests/streaming_seam.rs +++ b/crates/aura-ingest/tests/streaming_seam.rs @@ -13,7 +13,7 @@ use aura_engine::{ Target, }; use aura_ingest::{M1Field, M1FieldSource}; -use aura_std::{Exposure, Recorder, SimBroker, Sma, Sub}; +use aura_std::{Bias, Recorder, SimBroker, Sma, Sub}; use data_server::loader::CHUNK_SIZE; use data_server::{DataServer, DEFAULT_DATA_PATH}; @@ -39,7 +39,7 @@ fn run_sample(window: (Timestamp, Timestamp), source: Box) -> RunRep 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)), @@ -48,7 +48,7 @@ fn run_sample(window: (Timestamp, Timestamp), source: Box) -> RunRep 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(), diff --git a/crates/aura-std/src/exposure.rs b/crates/aura-std/src/bias.rs similarity index 63% rename from crates/aura-std/src/exposure.rs rename to crates/aura-std/src/bias.rs index 5f7662e..b3c9d44 100644 --- a/crates/aura-std/src/exposure.rs +++ b/crates/aura-std/src/bias.rs @@ -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 { 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"); } } diff --git a/crates/aura-std/src/latch.rs b/crates/aura-std/src/latch.rs index df9ae22..ad4747e 100644 --- a/crates/aura-std/src/latch.rs +++ b/crates/aura-std/src/latch.rs @@ -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) diff --git a/crates/aura-std/src/lib.rs b/crates/aura-std/src/lib.rs index c275fb5..a3c2af1 100644 --- a/crates/aura-std/src/lib.rs +++ b/crates/aura-std/src/lib.rs @@ -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; diff --git a/crates/aura-std/src/position_management.rs b/crates/aura-std/src/position_management.rs new file mode 100644 index 0000000..c349546 --- /dev/null +++ b/crates/aura-std/src/position_management.rs @@ -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, + 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 { + 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 { + 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 { (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 + } +} diff --git a/crates/aura-std/src/sim_broker.rs b/crates/aura-std/src/sim_broker.rs index 6deb8de..e9fac88 100644 --- a/crates/aura-std/src/sim_broker.rs +++ b/crates/aura-std/src/sim_broker.rs @@ -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 diff --git a/crates/aura-std/src/sma.rs b/crates/aura-std/src/sma.rs index 65cab52..2acfb20 100644 --- a/crates/aura-std/src/sma.rs +++ b/crates/aura-std/src/sma.rs @@ -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 diff --git a/crates/aura-std/src/stop_rule.rs b/crates/aura-std/src/stop_rule.rs new file mode 100644 index 0000000..cac5d28 --- /dev/null +++ b/crates/aura-std/src/stop_rule.rs @@ -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 { 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, + 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 { 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> { + 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); + } +}