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

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

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

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

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

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

refs #117 #119 #126 #127 #129
This commit is contained in:
2026-06-23 19:48:58 +02:00
parent c5f5e17297
commit 2c43296c2c
21 changed files with 919 additions and 104 deletions
+20 -19
View File
@@ -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<String> =
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![
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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"]]}}}
+10 -10
View File
@@ -909,7 +909,7 @@ mod tests {
use crate::test_fixtures::{composite_sma_cross_harness, synthetic_prices};
use crate::{f64_field, summarize, ParamRange, RandomSpace, RunManifest, VecSource};
use aura_core::{Cell, Ctx, FieldSpec, Firing, NodeSchema, Timestamp};
use aura_std::{Ema, Exposure, Recorder, SimBroker, Sma, Sub};
use aura_std::{Bias, Ema, Recorder, SimBroker, Sma, Sub};
use std::sync::mpsc;
/// Build + bootstrap + run + drain + summarize one swept point into a
@@ -1890,7 +1890,7 @@ mod tests {
Box::new(Sma::new(2)),
Box::new(Sma::new(4)),
Box::new(Sub::new()),
Box::new(Exposure::new(0.5)),
Box::new(Bias::new(0.5)),
Box::new(SimBroker::new(0.0001)),
Box::new(Recorder::new(&[ScalarKind::F64], Firing::Any, tx_eq)),
Box::new(Recorder::new(&[ScalarKind::F64], Firing::Any, tx_ex)),
@@ -1899,7 +1899,7 @@ mod tests {
Sma::builder().schema().clone(),
Sma::builder().schema().clone(),
Sub::builder().schema().clone(),
Exposure::builder().schema().clone(),
Bias::builder().schema().clone(),
SimBroker::builder(0.0001).schema().clone(),
f64_recorder_sig(),
f64_recorder_sig(),
@@ -2100,7 +2100,7 @@ mod tests {
from_flat.iter().map(|p| p.kind).collect::<Vec<_>>(),
"per-slot param kinds must line up with the compiled flat-node order",
);
// the realistic harness's concrete space: two SMA lengths (I64) + Exposure
// the realistic harness's concrete space: two SMA lengths (I64) + Bias
// scale (F64); Sub/SimBroker/Recorder declare none
assert_eq!(
space.iter().map(|p| p.name.as_str()).collect::<Vec<_>>(),
@@ -2165,15 +2165,15 @@ mod tests {
#[test]
fn param_space_reflects_only_open_knobs() {
use aura_std::{Exposure, Sma};
use aura_std::{Bias, Sma};
// "sma2_entry": Sma "bias" with length bound to 2 (a structural constant)
// plus Exposure "exp" whose `scale` stays open. The bound knob must be
// plus Bias "exp" whose `scale` stays open. The bound knob must be
// absent from param_space; only the open one remains.
let strat = Composite::new(
"sma2_entry",
vec![
Sma::builder().named("bias").bind("length", Scalar::i64(2)).into(),
Exposure::builder().named("exp").into(),
Bias::builder().named("exp").into(),
],
vec![], // edges — irrelevant to param_space()
vec![], // input_roles
@@ -2535,7 +2535,7 @@ mod tests {
/// bad `compile()` Err.
#[test]
fn named_cross_resolves_by_name_and_runs_to_a_trace() {
// root { sma_cross[ fast/slow SMA -> Sub ] -> Exposure -> SimBroker -> rec },
// root { sma_cross[ fast/slow SMA -> Sub ] -> Bias -> SimBroker -> rec },
// plus an exposure tap, mirroring composite_sma_cross_harness but driven
// through the by-name binder so the injective cured space is exercised
// end-to-end (resolve + bootstrap + run), not just at compile().
@@ -2563,11 +2563,11 @@ mod tests {
"root",
vec![
BlueprintNode::Composite(cross),
Exposure::builder().into(),
Bias::builder().named("exposure").into(),
Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_ex).into(),
],
vec![
Edge { from: 0, to: 1, slot: 0, from_field: 0 }, // cross out -> Exposure
Edge { from: 0, to: 1, slot: 0, from_field: 0 }, // cross out -> Bias
Edge { from: 1, to: 2, slot: 0, from_field: 0 }, // exposure -> recorder
],
vec![Role {
+10 -10
View File
@@ -204,7 +204,7 @@ mod tests {
use crate::test_fixtures::sma_cross as hand_sma_cross;
use crate::{Composite, OutField, Role, Target};
use aura_core::{Firing, ScalarKind};
use aura_std::{Exposure, Recorder, SimBroker, Sma, Sub};
use aura_std::{Bias, Recorder, SimBroker, Sma, Sub};
use std::sync::mpsc;
/// The `sma_cross` sub-composite authored through the builder.
@@ -251,16 +251,16 @@ mod tests {
let (tx_ex, _r2) = mpsc::channel();
let mut g = GraphBuilder::new("root");
let xross = g.add(built_sma_cross());
let expo = g.add(Exposure::builder());
let expo = g.add(Bias::builder());
let broker = g.add(SimBroker::builder(0.0001));
let eq = g.add(Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_eq));
let ex = g.add(Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_ex));
let src = g.source_role("src", ScalarKind::F64);
g.feed(src, [xross.input("price"), broker.input("price")]);
g.connect(xross.output("out"), expo.input("signal"));
g.connect(expo.output("exposure"), broker.input("exposure"));
g.connect(expo.output("bias"), broker.input("exposure"));
g.connect(broker.output("equity"), eq.input("col[0]"));
g.connect(expo.output("exposure"), ex.input("col[0]"));
g.connect(expo.output("bias"), ex.input("col[0]"));
let built_flat = g
.build()
.expect("root resolves")
@@ -280,12 +280,12 @@ mod tests {
// wiring-totality check rejects it at compile (broker is node 1, slot 0).
let (tx_eq, _r1) = mpsc::channel();
let mut g = GraphBuilder::new("root");
let expo = g.add(Exposure::builder());
let expo = g.add(Bias::builder());
let broker = g.add(SimBroker::builder(0.0001));
let eq = g.add(Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_eq));
let price = g.source_role("price", ScalarKind::F64);
g.feed(price, [expo.input("signal"), broker.input("price")]);
// BUG: g.connect(expo.output("exposure"), broker.input("exposure")) missing.
// BUG: g.connect(expo.output("bias"), broker.input("exposure")) missing.
g.connect(broker.output("equity"), eq.input("col[0]"));
let compiled = g
.build()
@@ -386,19 +386,19 @@ mod tests {
fn simbroker_legs_resolve_by_name_and_a_typo_is_caught() {
// exposure/price (both f64, slot 0/1) addressed by NAME — the #21 legibility win
let mut ok = GraphBuilder::new("ok");
let expo = ok.add(Exposure::builder());
let expo = ok.add(Bias::builder());
let broker = ok.add(SimBroker::builder(0.0001));
let src = ok.source_role("p", ScalarKind::F64);
ok.feed(src, [expo.input("signal"), broker.input("price")]);
ok.connect(expo.output("exposure"), broker.input("exposure"));
ok.connect(expo.output("bias"), broker.input("exposure"));
ok.expose(broker.output("equity"), "eq");
assert!(ok.build().is_ok());
// a transposed/typo'd port name is caught, where a bare slot index is not
let mut typo = GraphBuilder::new("typo");
let expo2 = typo.add(Exposure::builder());
let expo2 = typo.add(Bias::builder());
let broker2 = typo.add(SimBroker::builder(0.0001));
typo.connect(expo2.output("exposure"), broker2.input("pirce"));
typo.connect(expo2.output("bias"), broker2.input("pirce"));
assert_eq!(
typo.build().err(),
Some(BuildError::UnknownInPort { node: 1, name: "pirce".into() })
+8 -8
View File
@@ -274,7 +274,7 @@ mod tests {
use aura_core::{
Cell, FieldSpec, Node, NodeSchema, PortSpec, PrimitiveBuilder, Scalar,
};
use aura_std::{Exposure, Recorder, Sma, Sub};
use aura_std::{Bias, Recorder, Sma, Sub};
use std::sync::mpsc;
/// A bare node whose only purpose is to back a `PrimitiveBuilder` in a test.
@@ -331,7 +331,7 @@ mod tests {
/// A small, stable root harness exercising the four model elements: a bound
/// source role (`price`, → a synthetic source node), a composite reference
/// (`sma_cross`, → a `composites` entry), a plain node (`Exposure`), and a
/// (`sma_cross`, → a `composites` entry), a plain node (`Bias`), and a
/// sink (`Recorder`, `output: vec![]`). A deliberately minimal serializer
/// fixture, independent of the CLI's built-in sample (`build_sample` in
/// aura-cli — a richer nested `signals` graph since cycle 0036), kept small so
@@ -343,11 +343,11 @@ mod tests {
"sample",
vec![
BlueprintNode::Composite(sma_cross()),
Exposure::builder().into(),
Bias::builder().into(),
Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx).into(),
],
vec![
Edge { from: 0, to: 1, slot: 0, from_field: 0 }, // spread -> Exposure
Edge { from: 0, to: 1, slot: 0, from_field: 0 }, // spread -> Bias
Edge { from: 1, to: 2, slot: 0, from_field: 0 }, // exposure -> sink
],
vec![Role {
@@ -375,7 +375,7 @@ mod tests {
}
/// `sample_root` with one interior edge re-targeted to a different valid node:
/// the `exposure -> sink` edge instead feeds the `Exposure` node's own input
/// the `exposure -> sink` edge instead feeds the `Bias` node's own input
/// slot (`to: 1`). Same node set, distinct wiring — the model must differ.
fn miswired_root() -> Composite {
let (tx, _rx) = mpsc::channel();
@@ -383,7 +383,7 @@ mod tests {
"sample",
vec![
BlueprintNode::Composite(sma_cross()),
Exposure::builder().into(),
Bias::builder().into(),
Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx).into(),
],
vec![
@@ -432,7 +432,7 @@ mod tests {
assert!(bound.contains(r#""params":[]"#), "{bound}");
// a bound f64 value renders canonically (shortest round-trip), e.g. 0.5
let f = prim_record(&Exposure::builder().bind("scale", Scalar::f64(0.5)));
let f = prim_record(&Bias::builder().bind("scale", Scalar::f64(0.5)));
assert!(f.contains(r#""bound":[[0,"scale","f64","0.5"]]"#), "{f}");
// an unbound builder → no "bound" field at all
@@ -494,7 +494,7 @@ mod tests {
// Re-capture if an intended model-shape change lands: temporarily add a
// `println!("{}", model_to_json(&sample_root()))` test, run with
// `-- --nocapture`, and paste the fresh bytes below.
let expected = r##"{"root":{"nodes":{"0":{"comp":"sma_cross"},"1":{"prim":{"type":"Exposure","role":"node","params":[["scale","f64"]],"ins":[["f64","any","signal"]],"outs":[["exposure","f64"]]}},"2":{"prim":{"type":"Recorder","role":"sink","params":[],"ins":[["f64","any","col[0]"]],"outs":[]}},"src_price":{"prim":{"type":"price","role":"source","params":[],"ins":[],"outs":[["price","f64"]]}}},"edges":[["0.o0","1.i0"],["1.o0","2.i0"],["src_price.o0","0.i0"]]},"composites":{"sma_cross":{"inputs":[["f64","any","price"]],"outputs":[["out","f64"]],"nodes":{"0":{"prim":{"name":"fast","type":"SMA","role":"node","params":[["length","i64"]],"ins":[["f64","any","series"]],"outs":[["value","f64"]]}},"1":{"prim":{"name":"slow","type":"SMA","role":"node","params":[["length","i64"]],"ins":[["f64","any","series"]],"outs":[["value","f64"]]}},"2":{"prim":{"type":"Sub","role":"node","params":[],"ins":[["f64","any","lhs"],["f64","any","rhs"]],"outs":[["value","f64"]]}}},"edges":[["0.o0","2.i0"],["1.o0","2.i1"],["@price","0.i0"],["@price","1.i0"],["2.o0","#0"]]}}}"##;
let expected = r##"{"root":{"nodes":{"0":{"comp":"sma_cross"},"1":{"prim":{"type":"Bias","role":"node","params":[["scale","f64"]],"ins":[["f64","any","signal"]],"outs":[["bias","f64"]]}},"2":{"prim":{"type":"Recorder","role":"sink","params":[],"ins":[["f64","any","col[0]"]],"outs":[]}},"src_price":{"prim":{"type":"price","role":"source","params":[],"ins":[],"outs":[["price","f64"]]}}},"edges":[["0.o0","1.i0"],["1.o0","2.i0"],["src_price.o0","0.i0"]]},"composites":{"sma_cross":{"inputs":[["f64","any","price"]],"outputs":[["out","f64"]],"nodes":{"0":{"prim":{"name":"fast","type":"SMA","role":"node","params":[["length","i64"]],"ins":[["f64","any","series"]],"outs":[["value","f64"]]}},"1":{"prim":{"name":"slow","type":"SMA","role":"node","params":[["length","i64"]],"ins":[["f64","any","series"]],"outs":[["value","f64"]]}},"2":{"prim":{"type":"Sub","role":"node","params":[],"ins":[["f64","any","lhs"],["f64","any","rhs"]],"outs":[["value","f64"]]}}},"edges":[["0.o0","2.i0"],["1.o0","2.i1"],["@price","0.i0"],["@price","1.i0"],["2.o0","#0"]]}}}"##;
assert_eq!(model_to_json(&sample_root()), expected);
}
+6 -6
View File
@@ -497,7 +497,7 @@ mod tests {
// PortSpec / NodeSchema name the fixtures' declared signatures, brought in here
// (production code reads them via the carried signatures, never naming the types).
use aura_core::{FieldSpec, NodeSchema, PortSpec};
use aura_std::{Exposure, Recorder, Sma, SimBroker, Sub};
use aura_std::{Bias, Recorder, Sma, SimBroker, Sub};
use std::sync::mpsc;
/// Build an f64 source stream from (timestamp, value) points.
@@ -2160,7 +2160,7 @@ mod tests {
Box::new(Sma::new(2)), // 0 fast
Box::new(Sma::new(4)), // 1 slow
Box::new(Sub::new()), // 2 raw signal
Box::new(Exposure::new(0.5)), // 3 exposure
Box::new(Bias::new(0.5)), // 3 bias
Box::new(SimBroker::new(0.0001)), // 4 pip equity
Box::new(Recorder::new(&[ScalarKind::F64], Firing::Any, tx_eq)), // 5 sink
],
@@ -2168,7 +2168,7 @@ mod tests {
Sma::builder().schema().clone(),
Sma::builder().schema().clone(),
Sub::builder().schema().clone(),
Exposure::builder().schema().clone(),
Bias::builder().schema().clone(),
SimBroker::builder(0.0001).schema().clone(),
recorder_sig(&[ScalarKind::F64], Firing::Any),
],
@@ -2183,7 +2183,7 @@ mod tests {
vec![
Edge { from: 0, to: 2, slot: 0, from_field: 0 }, // fast -> Sub.0
Edge { from: 1, to: 2, slot: 1, from_field: 0 }, // slow -> Sub.1
Edge { from: 2, to: 3, slot: 0, from_field: 0 }, // signal -> Exposure
Edge { from: 2, to: 3, slot: 0, from_field: 0 }, // signal -> Bias
Edge { from: 3, to: 4, slot: 0, from_field: 0 }, // exposure -> broker.0
Edge { from: 4, to: 5, slot: 0, from_field: 0 }, // equity -> recorder
],
@@ -2225,7 +2225,7 @@ mod tests {
Box::new(Sma::new(2)),
Box::new(Sma::new(4)),
Box::new(Sub::new()),
Box::new(Exposure::new(0.5)),
Box::new(Bias::new(0.5)),
Box::new(SimBroker::new(0.0001)),
Box::new(Recorder::new(&[ScalarKind::F64], Firing::Any, tx)),
],
@@ -2233,7 +2233,7 @@ mod tests {
Sma::builder().schema().clone(),
Sma::builder().schema().clone(),
Sub::builder().schema().clone(),
Exposure::builder().schema().clone(),
Bias::builder().schema().clone(),
SimBroker::builder(0.0001).schema().clone(),
recorder_sig(&[ScalarKind::F64], Firing::Any),
],
+2 -2
View File
@@ -62,8 +62,8 @@ pub use harness::{
VecSource,
};
pub use report::{
f64_field, join_on_ts, summarize, ColumnarTrace, JoinedRow, PositionAction, PositionEvent,
RunManifest, RunMetrics, RunReport,
f64_field, join_on_ts, summarize, summarize_r, ColumnarTrace, JoinedRow, PositionAction,
PositionEvent, RMetrics, RunManifest, RunMetrics, RunReport,
};
pub use sweep::{
sweep, GridSpace, ParamRange, RandomSpace, Space, SweepError, SweepFamily, SweepPoint,
+133 -4
View File
@@ -29,6 +29,85 @@ pub struct RunMetrics {
pub exposure_sign_flips: u64,
}
/// R-based signal-quality metrics (Stage-1), reduced from a `PositionManagement` dense
/// record stream by [`summarize_r`]. Account- and instrument-agnostic (pure R). The
/// iteration-2 fields (SQN, conviction terciles, net-of-cost) are added later.
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct RMetrics {
pub expectancy_r: f64, // mean realised R over all trades (equal-weighted; headline)
pub n_trades: u64,
pub win_rate: f64, // fraction with R > 0
pub avg_win_r: f64,
pub avg_loss_r: f64,
pub profit_factor: f64, // sum(win R) / |sum(loss R)|; 0.0 if no losses or no trades
pub max_r_drawdown: f64, // worst peak-to-trough on the by-trade cumulative-R curve, >= 0
pub n_open_at_end: u64, // positions force-closed at window end (counted, not hidden)
}
// Dense `PositionManagement` record column indices — the lockstep contract with
// aura-std's FIELD_NAMES/RECORD_KINDS (aura-std is only a dev-dependency here, so the
// layout is shared by convention; `stage1_r_e2e.rs` guards that it matches).
mod r_col {
pub const CLOSED: usize = 0;
pub const REALIZED_R: usize = 1;
pub const OPEN: usize = 11;
pub const UNREALIZED_R: usize = 12;
}
/// Reduce a `PositionManagement` dense record stream into R-metrics. Pure (C1).
/// The trade ledger is the rows where `closed_this_cycle`; a position still open on the
/// last row is force-closed at its `unrealized_r` (a window-end trade — never silently
/// folded as unrealised MtM). Empty input -> a well-defined all-zero `RMetrics`.
pub fn summarize_r(record: &[(Timestamp, Vec<Scalar>)]) -> RMetrics {
// Rows are the producer's dense `PositionManagement` records, always `PM_WIDTH`
// (14) columns wide — pinned by `stage1_r_e2e.rs`. Trust that layout and index
// every column directly: a guard on one column while the next is a bare `[]`
// index would buy nothing (a short row panics either way).
let mut rs: Vec<f64> = Vec::new();
let mut n_open_at_end = 0u64;
for (_, row) in record {
if row[r_col::CLOSED].as_bool() {
rs.push(row[r_col::REALIZED_R].as_f64());
}
}
if let Some((_, last)) = record.last()
&& last[r_col::OPEN].as_bool()
{
rs.push(last[r_col::UNREALIZED_R].as_f64());
n_open_at_end = 1;
}
let n = rs.len() as u64;
if n == 0 {
return RMetrics { expectancy_r: 0.0, n_trades: 0, win_rate: 0.0, avg_win_r: 0.0, avg_loss_r: 0.0, profit_factor: 0.0, max_r_drawdown: 0.0, n_open_at_end: 0 };
}
let sum: f64 = rs.iter().sum();
let wins: Vec<f64> = rs.iter().copied().filter(|&r| r > 0.0).collect();
let losses: Vec<f64> = rs.iter().copied().filter(|&r| r <= 0.0).collect();
let sum_win: f64 = wins.iter().sum();
let sum_loss: f64 = losses.iter().sum(); // <= 0
let avg = |v: &[f64]| if v.is_empty() { 0.0 } else { v.iter().sum::<f64>() / v.len() as f64 };
// by-trade cumulative-R drawdown
let mut peak = f64::NEG_INFINITY;
let mut cum = 0.0;
let mut max_dd = 0.0_f64;
for &r in &rs {
cum += r;
if cum > peak { peak = cum; }
let dd = peak - cum;
if dd > max_dd { max_dd = dd; }
}
RMetrics {
expectancy_r: sum / n as f64,
n_trades: n,
win_rate: wins.len() as f64 / n as f64,
avg_win_r: avg(&wins),
avg_loss_r: avg(&losses),
profit_factor: if sum_loss < 0.0 { sum_win / (-sum_loss) } else { 0.0 },
max_r_drawdown: max_dd,
n_open_at_end,
}
}
/// The three position-event actions (C10). Direction IS the action; volume is
/// unsigned. Serde-encoded as its i64 mapping (`Buy=0, Sell=1, Close=2`) so the
/// persisted/columnar form stays C7-scalar and ledger-faithful (`action: i64`).
@@ -329,7 +408,7 @@ mod tests {
use super::*;
use crate::{Edge, FlatGraph, Harness, SourceSpec, Target, VecSource};
use aura_core::{Firing, NodeSchema, PortSpec, ScalarKind};
use aura_std::{Exposure, Recorder, SimBroker, Sma, Sub};
use aura_std::{Bias, Recorder, SimBroker, Sma, Sub};
use std::sync::mpsc;
#[test]
@@ -401,7 +480,7 @@ mod tests {
}
/// Bootstrap the cycle-0007 signal-quality harness with TWO sinks: one on
/// the SimBroker equity output (node 4 -> node 5) and one on the Exposure
/// the SimBroker equity output (node 4 -> node 5) and one on the Bias
/// output (node 3 -> node 6). Returns the harness plus the two receivers.
#[allow(clippy::type_complexity)]
fn build_two_sink_harness() -> (
@@ -416,7 +495,7 @@ mod tests {
Box::new(Sma::new(2)), // 0
Box::new(Sma::new(4)), // 1
Box::new(Sub::new()), // 2
Box::new(Exposure::new(0.5)), // 3
Box::new(Bias::new(0.5)), // 3
Box::new(SimBroker::new(0.0001)), // 4
Box::new(Recorder::new(&[ScalarKind::F64], Firing::Any, tx_eq)), // 5 equity sink
Box::new(Recorder::new(&[ScalarKind::F64], Firing::Any, tx_ex)), // 6 exposure sink
@@ -425,7 +504,7 @@ mod tests {
Sma::builder().schema().clone(),
Sma::builder().schema().clone(),
Sub::builder().schema().clone(),
Exposure::builder().schema().clone(),
Bias::builder().schema().clone(),
SimBroker::builder(0.0001).schema().clone(),
f64_recorder_sig(),
f64_recorder_sig(),
@@ -492,6 +571,56 @@ mod tests {
assert_eq!(r1.to_json(), r2.to_json());
}
// Build a minimal dense record row: only the columns summarize_r reads matter.
// The four written slots reference `r_col::*` (not bare literals) so the helper
// tracks the same constants it exists to test — a literal would mask drift in them.
fn r_row(closed: bool, realized: f64, open: bool, unreal: f64) -> (Timestamp, Vec<Scalar>) {
let mut v = vec![Scalar::f64(0.0); r_col::UNREALIZED_R + 1];
v[r_col::CLOSED] = Scalar::bool(closed);
v[r_col::REALIZED_R] = Scalar::f64(realized);
v[r_col::OPEN] = Scalar::bool(open);
v[r_col::UNREALIZED_R] = Scalar::f64(unreal);
(Timestamp(0), v)
}
#[test]
fn summarize_r_is_zero_on_empty() {
let m = summarize_r(&[]);
assert_eq!(m.n_trades, 0);
assert_eq!(m.expectancy_r, 0.0);
assert_eq!(m.max_r_drawdown, 0.0);
}
#[test]
fn summarize_r_expectancy_winrate_profit_factor() {
// three closed trades: +2, -1, +1 (no open at end). E[R] = 2/3.
let rec = vec![
r_row(true, 2.0, true, 0.0),
r_row(false, 0.0, true, 0.0),
r_row(true, -1.0, true, 0.0),
r_row(true, 1.0, false, 0.0), // last row not open
];
let m = summarize_r(&rec);
assert_eq!(m.n_trades, 3);
assert!((m.expectancy_r - (2.0 / 3.0)).abs() < 1e-9);
assert!((m.win_rate - (2.0 / 3.0)).abs() < 1e-9);
assert!((m.profit_factor - 3.0).abs() < 1e-9); // (2+1)/1
assert_eq!(m.n_open_at_end, 0);
}
#[test]
fn summarize_r_force_closes_open_position_at_window_end() {
// one closed +1, then last row open with unrealized -0.5 -> a window-end trade.
let rec = vec![r_row(true, 1.0, true, 0.0), r_row(false, 0.0, true, -0.5)];
let m = summarize_r(&rec);
assert_eq!(m.n_trades, 2);
assert_eq!(m.n_open_at_end, 1);
assert!((m.expectancy_r - 0.25).abs() < 1e-9); // (1 + -0.5)/2
}
#[test]
fn summarize_r_max_drawdown_on_by_trade_curve() {
// cum: +3, +1 (dd 2), +4 -> max dd = 2.
let rec = vec![r_row(true, 3.0, false, 0.0), r_row(true, -2.0, false, 0.0), r_row(true, 3.0, false, 0.0)];
assert!((summarize_r(&rec).max_r_drawdown - 2.0).abs() < 1e-9);
}
fn samples(values: &[f64]) -> Vec<(Timestamp, f64)> {
values
.iter()
+3 -3
View File
@@ -6,7 +6,7 @@
use crate::{BlueprintNode, Composite, Edge, OutField, Role, Target};
use aura_core::{Firing, Scalar, ScalarKind, Timestamp};
use aura_std::{Exposure, Recorder, SimBroker, Sma, Sub};
use aura_std::{Bias, Recorder, SimBroker, Sma, Sub};
use std::sync::mpsc;
/// Seven synthetic F64 ticks driving the harness; deterministic input fixture.
@@ -62,13 +62,13 @@ pub(crate) fn composite_sma_cross_harness() -> (
"root",
vec![
BlueprintNode::Composite(sma_cross()),
Exposure::builder().into(),
Bias::builder().named("exposure").into(),
SimBroker::builder(0.0001).into(),
Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_eq).into(),
Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_ex).into(),
],
vec![
Edge { from: 0, to: 1, slot: 0, from_field: 0 }, // composite out -> Exposure
Edge { from: 0, to: 1, slot: 0, from_field: 0 }, // composite out -> Bias
Edge { from: 1, to: 2, slot: 0, from_field: 0 }, // exposure -> broker slot 0
Edge { from: 2, to: 3, slot: 0, from_field: 0 }, // equity -> sink
Edge { from: 1, to: 4, slot: 0, from_field: 0 }, // exposure -> sink
+3 -3
View File
@@ -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
+204
View File
@@ -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<AnyColumn> =
(0..3).map(|_| AnyColumn::with_capacity(ScalarKind::F64, 1)).collect();
let mut ledger: Vec<(Timestamp, Vec<Scalar>)> = 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<AnyColumn> =
(0..3).map(|_| AnyColumn::with_capacity(ScalarKind::F64, 1)).collect();
let mut pm = PositionManagement::new();
let mut ledger: Vec<(Timestamp, Vec<Scalar>)> = 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);
}
+4 -4
View File
@@ -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(),
+3 -3
View File
@@ -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<dyn Source>) -> 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<dyn Source>) -> 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(),
@@ -1,44 +1,44 @@
//! `Exposure` — shapes a raw signal score into a bounded exposure (intent).
//! The decision/sizing node of C10's chain `signals -> decision/sizing node ->
//! exposure stream`: one f64 input, one f64 output `clamp(signal / scale, -1, +1)`.
//! `scale` sets which signal magnitude maps to full exposure (sizing lives here).
//! `Bias` — shapes a raw signal score into a bounded, UNSIGNED-magnitude directional
//! bias (C10). The strategy's primary output: one f64 input, one f64 output
//! `clamp(signal / scale, -1, +1)`. Sign is direction, magnitude is (optional)
//! conviction; the output is UNSIZED — sizing leaves the strategy (downstream Sizer).
//! `scale` sets which signal magnitude maps to full conviction.
use aura_core::{
Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec, PrimitiveBuilder,
ScalarKind,
};
/// Bounded exposure from a raw signal score: `clamp(signal / scale, -1.0, +1.0)`.
/// Bounded bias from a raw signal score: `clamp(signal / scale, -1.0, +1.0)`.
/// Emits `None` until its input is present (warm-up filter, C8).
pub struct Exposure {
pub struct Bias {
scale: f64,
out: [Cell; 1],
}
impl Exposure {
/// Build an exposure node with saturation magnitude `scale` (must be > 0).
impl Bias {
/// Build a bias node with saturation magnitude `scale` (must be > 0).
pub fn new(scale: f64) -> Self {
assert!(scale > 0.0, "Exposure scale must be > 0");
assert!(scale > 0.0, "Bias scale must be > 0");
Self { scale, out: [Cell::from_f64(0.0)] }
}
/// The param-generic recipe for a blueprint primitive: declares `scale` and builds
/// through `Exposure::new` (the single sizing/validation gate; the slice is
/// through `Bias::new` (the single sizing/validation gate; the slice is
/// kind-checked before `build` runs, so the typed read is total).
pub fn builder() -> PrimitiveBuilder {
PrimitiveBuilder::new(
"Exposure",
"Bias",
NodeSchema {
inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "signal".into() }],
output: vec![FieldSpec { name: "exposure".into(), kind: ScalarKind::F64 }],
output: vec![FieldSpec { name: "bias".into(), kind: ScalarKind::F64 }],
params: vec![ParamSpec { name: "scale".into(), kind: ScalarKind::F64 }],
},
|p| Box::new(Exposure::new(p[0].f64())),
|p| Box::new(Bias::new(p[0].f64())),
)
}
}
impl Node for Exposure {
impl Node for Bias {
fn lookbacks(&self) -> Vec<usize> {
vec![1]
}
@@ -53,7 +53,7 @@ impl Node for Exposure {
}
fn label(&self) -> String {
format!("Exposure({})", self.scale)
format!("Bias({})", self.scale)
}
}
@@ -63,10 +63,10 @@ mod tests {
use aura_core::{AnyColumn, Scalar, Timestamp};
#[test]
fn exposure_clamps_to_unit_band() {
let mut e = Exposure::new(0.5);
fn bias_clamps_to_unit_band() {
let mut e = Bias::new(0.5);
let mut inputs = vec![AnyColumn::with_capacity(ScalarKind::F64, 1)];
// (raw signal, expected clamped exposure) for scale 0.5
// (raw signal, expected clamped bias) for scale 0.5
let cases = [
(0.1_f64, 0.2_f64), // within band
(0.5, 1.0), // at the high edge
@@ -84,14 +84,14 @@ mod tests {
}
#[test]
fn exposure_is_none_until_input_present() {
let mut e = Exposure::new(0.5);
fn bias_is_none_until_input_present() {
let mut e = Bias::new(0.5);
let inputs = vec![AnyColumn::with_capacity(ScalarKind::F64, 1)];
assert_eq!(e.eval(Ctx::new(&inputs, Timestamp(0))), None);
}
#[test]
fn input_slot_is_named_signal() {
assert_eq!(Exposure::builder().schema().inputs[0].name, "signal");
assert_eq!(Bias::builder().schema().inputs[0].name, "signal");
}
}
+1 -1
View File
@@ -6,7 +6,7 @@
//! no params. **Emits `f64` directly**, not `bool`: a held position *is*
//! exposure `+1`, flat *is* `0.0`, so the output feeds [`SimBroker`](crate::SimBroker)'s
//! `f64` `exposure` slot with no cast — this is the resolution of the `bool → f64`
//! seam (no separate `Exposure`/cast node).
//! seam (no separate `Bias`/cast node).
//!
//! **State.** A persisted `held: f64`, initial `0.0`, mutated in `eval` and
//! carried across cycles in the struct — exactly how [`SimBroker`](crate::SimBroker)
+9 -2
View File
@@ -17,33 +17,40 @@
mod add;
mod and;
mod bias;
mod delay;
mod ema;
mod eqconst;
mod exposure;
mod gt;
mod latch;
mod lincomb;
mod longonly;
mod position_management;
mod recorder;
mod resample;
mod session;
mod sim_broker;
mod sma;
mod stop_rule;
mod sub;
pub use add::Add;
pub use and::And;
pub use bias::Bias;
pub use delay::Delay;
pub use ema::Ema;
pub use eqconst::EqConst;
pub use exposure::Exposure;
pub use gt::Gt;
pub use latch::Latch;
pub use lincomb::LinComb;
pub use longonly::LongOnly;
pub use position_management::{
ExitReason, FIELD_NAMES as PM_FIELD_NAMES, PositionManagement, RECORD_KINDS as PM_RECORD_KINDS,
WIDTH as PM_WIDTH,
};
pub use recorder::Recorder;
pub use resample::Resample;
pub use session::Session;
pub use sim_broker::SimBroker;
pub use sma::Sma;
pub use stop_rule::{FixedStop, VolStop};
pub use sub::Sub;
+336
View File
@@ -0,0 +1,336 @@
//! `PositionManagement` — the stateful heart of Stage-1 risk-based execution. Turns a
//! bias + a protective-stop distance into a stream of realised per-trade R-outcomes.
//! Emits ONE dense record per cycle (C8, like `SimBroker`; multi-field output on the
//! `Resample` precedent): the trade ledger is the rows where `closed_this_cycle`, the
//! R-equity is `cum_realized_r + unrealized_r`, and a position still open at window end
//! is the last row with `open = true`. R is computed SIZE-INVARIANTLY:
//! `realized_r = direction * (exit - entry) / latched_distance`.
use aura_core::{
Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, PortSpec, PrimitiveBuilder, ScalarKind,
Timestamp,
};
/// Why a trade closed. i64-encoded into the dense record (column 2). `summarize_r`
/// matches the raw i64 (it lives across a crate boundary — aura-std is only a
/// dev-dependency of aura-engine), so the encoding is the load-bearing contract.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[repr(i64)]
pub enum ExitReason {
Stop = 0,
BiasFlip = 1,
ReversalLeg = 2,
WindowEnd = 3,
}
/// Dense record column layout — the lockstep contract shared (by convention) with the
/// `Recorder` tap kinds and `summarize_r`'s column reads.
pub const WIDTH: usize = 14;
pub const FIELD_NAMES: [&str; WIDTH] = [
"closed_this_cycle",
"realized_r",
"exit_reason",
"was_stopped",
"direction",
"entry_ts",
"entry_price",
"stop_price",
"exit_price",
"bias_at_entry_abs",
"size",
"open",
"unrealized_r",
"cum_realized_r",
];
pub const RECORD_KINDS: [ScalarKind; WIDTH] = {
use ScalarKind::*;
[
Bool, F64, I64, Bool, I64, Timestamp, F64, F64, F64, F64, F64, Bool, F64, F64,
]
};
// The frozen-R latch: an open position records its entry, its latched (frozen at
// entry) R-distance, the protective stop level, and entry metadata. R is computed
// against `latched_dist`, never a re-read distance — the R-denominator is frozen.
struct Open {
dir: i64,
entry: f64,
latched_dist: f64,
stop_level: f64,
entry_ts: Timestamp,
bias_abs: f64,
}
pub struct PositionManagement {
pos: Option<Open>,
cum_realized_r: f64,
out: [Cell; WIDTH],
}
impl PositionManagement {
pub fn new() -> Self {
Self {
pos: None,
cum_realized_r: 0.0,
out: [Cell::from_f64(0.0); WIDTH],
}
}
pub fn builder() -> PrimitiveBuilder {
let inputs = vec![
PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "bias".into() },
PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "price".into() },
PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "stop_distance".into() },
];
let output = FIELD_NAMES
.iter()
.zip(RECORD_KINDS)
.map(|(n, k)| FieldSpec { name: (*n).into(), kind: k })
.collect();
PrimitiveBuilder::new(
"PositionManagement",
NodeSchema { inputs, output, params: vec![] },
|_| Box::new(PositionManagement::new()),
)
}
}
impl Default for PositionManagement {
fn default() -> Self {
Self::new()
}
}
fn sign0(v: f64) -> f64 {
if v > 0.0 {
1.0
} else if v < 0.0 {
-1.0
} else {
0.0
}
}
impl Node for PositionManagement {
fn lookbacks(&self) -> Vec<usize> {
vec![1, 1, 1]
}
fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Cell]> {
let pw = ctx.f64_in(1);
if pw.is_empty() {
return None; // no price yet — nothing to do
}
let price = pw[0];
let bias = ctx.f64_in(0).get(0).unwrap_or(0.0);
let dist = ctx.f64_in(2).get(0).unwrap_or(0.0);
let now = ctx.now();
let mut closed = false;
let mut realized = 0.0;
let mut reason = ExitReason::Stop as i64;
let mut was_stopped = false;
let mut ex_dir = 0i64;
let mut ex_entry_ts = Timestamp(0);
let mut ex_entry = 0.0;
let mut ex_stop = 0.0;
let mut ex_exit = 0.0;
let mut ex_bias_abs = 0.0;
// (A) Maintain/exit the position held into this cycle, marked to `price`.
if let Some(p) = &self.pos {
let stop_hit = (p.dir > 0 && price <= p.stop_level)
|| (p.dir < 0 && price >= p.stop_level);
let bias_exit = sign0(bias) != p.dir as f64; // flip or ->0
if stop_hit {
let fill = if p.dir > 0 {
price.min(p.stop_level)
} else {
price.max(p.stop_level)
};
// size-invariant: R is a pure stop-distance ratio (Stage-1 feed-forward).
realized = p.dir as f64 * (fill - p.entry) / p.latched_dist;
was_stopped = true;
reason = ExitReason::Stop as i64;
ex_exit = fill;
(ex_dir, ex_entry_ts, ex_entry, ex_stop, ex_bias_abs) =
(p.dir, p.entry_ts, p.entry, p.stop_level, p.bias_abs);
self.cum_realized_r += realized;
closed = true;
self.pos = None;
} else if bias_exit {
// size-invariant: R is a pure stop-distance ratio (Stage-1 feed-forward).
realized = p.dir as f64 * (price - p.entry) / p.latched_dist;
reason = if sign0(bias) != 0.0 {
ExitReason::ReversalLeg as i64
} else {
ExitReason::BiasFlip as i64
};
ex_exit = price;
(ex_dir, ex_entry_ts, ex_entry, ex_stop, ex_bias_abs) =
(p.dir, p.entry_ts, p.entry, p.stop_level, p.bias_abs);
self.cum_realized_r += realized;
closed = true;
self.pos = None;
}
}
// (B) Open if flat, bias nonzero, and a valid (>0) frozen R-distance is available.
if self.pos.is_none() && sign0(bias) != 0.0 && dist > 0.0 {
let dir = sign0(bias) as i64;
let stop_level = if dir > 0 { price - dist } else { price + dist };
self.pos = Some(Open {
dir,
entry: price,
latched_dist: dist,
stop_level,
entry_ts: now,
bias_abs: bias.abs(),
});
}
// (C) Open-position fields (carry for window-end) + unrealized mark.
let (open, unrealized, o_dir, o_ets, o_entry, o_stop, o_babs) = match &self.pos {
Some(p) => (
true,
p.dir as f64 * (price - p.entry) / p.latched_dist,
p.dir,
p.entry_ts,
p.entry,
p.stop_level,
p.bias_abs,
),
None => (false, 0.0, 0, Timestamp(0), 0.0, 0.0, 0.0),
};
// Trade-detail columns describe the CLOSED trade if closed, else the OPEN one.
let (d_dir, d_ets, d_entry, d_stop, d_exit, d_babs) = if closed {
(ex_dir, ex_entry_ts, ex_entry, ex_stop, ex_exit, ex_bias_abs)
} else {
(o_dir, o_ets, o_entry, o_stop, 0.0, o_babs)
};
// `direction` (col 4) tracks the position OPEN at cycle end — it is carried for
// window-end synthesis and names the reopened leg on a reversal. It falls back
// to the closed trade's direction only when flat at cycle end. The other
// trade-detail columns (and the debug_assert below) stay keyed to the CLOSED
// trade's geometry via `d_*`.
let out_dir = if open { o_dir } else { d_dir };
self.out = [
Cell::from_bool(closed),
Cell::from_f64(realized),
Cell::from_i64(reason),
Cell::from_bool(was_stopped),
Cell::from_i64(out_dir),
Cell::from_ts(d_ets),
Cell::from_f64(d_entry),
Cell::from_f64(d_stop),
Cell::from_f64(d_exit),
Cell::from_f64(d_babs),
Cell::from_f64(1.0), // size: flat-1R placeholder (iter-2 Sizer)
Cell::from_bool(open),
Cell::from_f64(unrealized),
Cell::from_f64(self.cum_realized_r),
];
debug_assert!(
!closed
|| (realized - d_dir as f64 * (d_exit - d_entry) / (d_entry - d_stop).abs()).abs()
< 1e-9
);
Some(&self.out)
}
fn label(&self) -> String {
"PositionManagement".into()
}
}
#[cfg(test)]
mod tests {
use super::*;
use aura_core::{AnyColumn, Scalar};
// Drive one cycle: push bias/price/stop into the three slots, eval, return the row.
fn step(n: &mut PositionManagement, cols: &mut [AnyColumn], bias: f64, price: f64, dist: f64) -> Vec<Cell> {
cols[0].push(Scalar::f64(bias)).unwrap();
cols[1].push(Scalar::f64(price)).unwrap();
cols[2].push(Scalar::f64(dist)).unwrap();
n.eval(Ctx::new(cols, Timestamp(1))).expect("dense record every cycle once price present").to_vec()
}
fn cols() -> Vec<AnyColumn> { (0..3).map(|_| AnyColumn::with_capacity(ScalarKind::F64, 1)).collect() }
#[test]
fn emits_one_dense_record_per_cycle() {
let mut n = PositionManagement::new();
let mut c = cols();
let row = step(&mut n, &mut c, 0.0, 100.0, 10.0); // flat
assert_eq!(row.len(), WIDTH);
assert!(!row[0].bool()); // closed_this_cycle = false
assert!(!row[11].bool()); // open = false
}
// (1) No look-ahead, the SimBroker mirror: a long entered at 100 with stop_distance
// 10, then bias->0 at price 110, realises R = (110-100)/10 = +1.0 (it earned the
// move to 110; the flip closes it THERE, never retroactively flattening it).
#[test]
fn no_lookahead_bias_exit_realises_the_held_move() {
let mut n = PositionManagement::new();
let mut c = cols();
let _ = step(&mut n, &mut c, 1.0, 100.0, 10.0); // open long @100, stop 90
let row = step(&mut n, &mut c, 0.0, 110.0, 10.0); // bias->0 @110: exit
assert!(row[0].bool()); // closed_this_cycle
assert_eq!(row[1].f64(), 1.0); // realized_r = +1.0
assert_eq!(row[2].i64(), ExitReason::BiasFlip as i64);
assert!(!row[3].bool()); // not stopped
}
// A position opened this cycle never earns the pre-entry move: entered @110, its
// own-cycle unrealized R is 0.
#[test]
fn entry_does_not_earn_pre_entry_move() {
let mut n = PositionManagement::new();
let mut c = cols();
let _ = step(&mut n, &mut c, 0.0, 100.0, 10.0); // flat
let row = step(&mut n, &mut c, 1.0, 110.0, 10.0); // open long @110
assert!(row[11].bool()); // open
assert_eq!(row[12].f64(), 0.0); // unrealized_r = 0 at entry cycle
}
// (2) Stop tail is NOT capped at -1R: a gap THROUGH the stop realises R < -1.
#[test]
fn stop_out_is_not_capped_at_minus_one_r() {
let mut n = PositionManagement::new();
let mut c = cols();
let _ = step(&mut n, &mut c, 1.0, 100.0, 10.0); // open long @100, stop 90
let row = step(&mut n, &mut c, 0.0, 80.0, 10.0); // gaps to 80 (< stop 90)
assert!(row[0].bool());
assert!(row[3].bool()); // was_stopped
assert_eq!(row[2].i64(), ExitReason::Stop as i64);
assert_eq!(row[1].f64(), -2.0); // (80-100)/10 = -2R, the honest tail
}
// A no-gap stop fills exactly at the stop -> exactly -1R.
#[test]
fn no_gap_stop_is_exactly_minus_one_r() {
let mut n = PositionManagement::new();
let mut c = cols();
let _ = step(&mut n, &mut c, 1.0, 100.0, 10.0); // open long @100, stop 90
let row = step(&mut n, &mut c, 1.0, 90.0, 10.0); // touches stop exactly
assert_eq!(row[1].f64(), -1.0);
}
// (5) One close per cycle on a reversal: long -> bias flips short closes ONE leg
// (one record, ReversalLeg) and reopens short as state (open=true, dir=-1).
#[test]
fn reversal_closes_one_leg_and_reopens() {
let mut n = PositionManagement::new();
let mut c = cols();
let _ = step(&mut n, &mut c, 1.0, 100.0, 10.0); // open long @100
let row = step(&mut n, &mut c, -1.0, 110.0, 10.0); // flip short @110
assert!(row[0].bool()); // one close
assert_eq!(row[1].f64(), 1.0); // closed long: (110-100)/10
assert_eq!(row[2].i64(), ExitReason::ReversalLeg as i64);
assert!(row[11].bool()); // reopened: open
assert_eq!(row[4].i64(), -1); // new direction short
}
// (4) Open at window end is carried on the last row: open=true, the unrealized R the
// post-run fold will force-close, and the direction for window-end synthesis.
#[test]
fn open_at_window_end_is_carried_on_the_last_row() {
let mut n = PositionManagement::new();
let mut c = cols();
let _ = step(&mut n, &mut c, 1.0, 100.0, 10.0); // open long @100
let row = step(&mut n, &mut c, 1.0, 105.0, 10.0); // still long @105, no close
assert!(!row[0].bool()); // not closed
assert!(row[11].bool()); // open
assert_eq!(row[12].f64(), 0.5); // unrealized (105-100)/10
assert_eq!(row[4].i64(), 1); // direction carried for window-end synthesis
}
}
+1 -1
View File
@@ -17,7 +17,7 @@ use aura_core::{Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, PortSpec, Primit
/// equity curve):
///
/// - **slot 0 — exposure** ∈ [-1, +1] (the strategy's intent, e.g. from
/// [`Exposure`](crate::Exposure)).
/// [`Bias`](crate::Bias)).
/// - **slot 1 — price** (the instrument price the exposure is marked against).
///
/// # Firing and warm-up
+4 -4
View File
@@ -202,14 +202,14 @@ mod tests {
#[test]
fn labels_carry_identifying_params() {
use crate::{Add, Exposure, LinComb, Recorder, SimBroker, Sub};
use crate::{Add, Bias, LinComb, Recorder, SimBroker, Sub};
use aura_core::{Firing, ScalarKind};
// the load-bearing payoff: two SMAs disambiguate by window
assert_eq!(Sma::new(2).label(), "SMA(2)");
assert_eq!(Sma::new(4).label(), "SMA(4)");
// param-carrying single nodes
assert_eq!(Exposure::new(0.5).label(), "Exposure(0.5)");
assert_eq!(Bias::new(0.5).label(), "Bias(0.5)");
assert_eq!(SimBroker::new(0.0001).label(), "SimBroker(0.0001)");
// bare-kind nodes (identity is not a mis-wiring axis here)
assert_eq!(Sub::new().label(), "Sub");
@@ -221,7 +221,7 @@ mod tests {
#[test]
fn nodes_declare_expected_params() {
use crate::{Add, Exposure, LinComb, Recorder, SimBroker, Sub};
use crate::{Add, Bias, LinComb, Recorder, SimBroker, Sub};
use aura_core::{Firing, ParamSpec, ScalarKind};
// single scalar knobs (declared on the param-generic builder, pre-build)
assert_eq!(
@@ -229,7 +229,7 @@ mod tests {
vec![ParamSpec { name: "length".into(), kind: ScalarKind::I64 }],
);
assert_eq!(
Exposure::builder().schema().params,
Bias::builder().schema().params,
vec![ParamSpec { name: "scale".into(), kind: ScalarKind::F64 }],
);
// vector knob expands flat to N indexed F64 entries
+138
View File
@@ -0,0 +1,138 @@
//! Stop-rule nodes — emit a protective-stop *distance* (price units, ≥ 0,
//! direction-agnostic). The stop DEFINES the risk unit R (1R = the loss if stopped);
//! position-management latches the entry-cycle distance as the frozen R-denominator.
//! `FixedStop` is a constant distance (test fixture / structural-axis sibling);
//! `VolStop` is a close-to-close volatility stop `k * EMA_length(|price - prev_price|)`
//! (true-range ATR is deferred — it needs OHLC). One fused node each (no Abs/Mul
//! primitive exists, so abs+delta+EMA fuse inside VolStop).
use aura_core::{
Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec, PrimitiveBuilder,
ScalarKind,
};
/// Constant stop distance. `distance` must be > 0.
pub struct FixedStop { distance: f64, out: [Cell; 1] }
impl FixedStop {
pub fn new(distance: f64) -> Self {
assert!(distance > 0.0, "FixedStop distance must be > 0");
Self { distance, out: [Cell::from_f64(distance)] }
}
pub fn builder() -> PrimitiveBuilder {
PrimitiveBuilder::new(
"FixedStop",
NodeSchema {
inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "price".into() }],
output: vec![FieldSpec { name: "stop_distance".into(), kind: ScalarKind::F64 }],
params: vec![ParamSpec { name: "distance".into(), kind: ScalarKind::F64 }],
},
|p| Box::new(FixedStop::new(p[0].f64())),
)
}
}
impl Node for FixedStop {
fn lookbacks(&self) -> Vec<usize> { vec![1] }
fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Cell]> {
if ctx.f64_in(0).is_empty() { return None; } // fire with price (warm-up filter)
self.out[0] = Cell::from_f64(self.distance);
Some(&self.out)
}
fn label(&self) -> String { format!("FixedStop({})", self.distance) }
}
/// Volatility stop distance: `k * EMA_length(|price - prev_price|)`. EMA is the
/// standard `alpha = 2/(length+1)` recursion, but its WARM-UP DIFFERS from the
/// crate's [`crate::Ema`]: that node seeds with the SMA of its first `length` samples
/// (ta-lib convention), whereas `VolStop` seeds with the FIRST abs-return and runs the
/// recurrence from there (a first-value seed). The divergence is deliberate — the
/// abs-return stream needs a prior price before any sample exists, so a uniform
/// `length`-sample SMA seed would push warm-up an extra cycle out and complicate the
/// frozen-R latch; the first-value seed keeps the stop available as early as the data
/// allows. Output is `None` until `length` abs-returns have been seen (warm-up).
/// `prev_price` updated AFTER use (intra-node z⁻¹, C2-clean). `length >= 1`, `k > 0`.
pub struct VolStop {
length: usize,
k: f64,
prev_price: Option<f64>,
ema: f64,
count: usize,
out: [Cell; 1],
}
impl VolStop {
pub fn new(length: usize, k: f64) -> Self {
assert!(length >= 1, "VolStop length must be >= 1");
assert!(k > 0.0, "VolStop k must be > 0");
Self { length, k, prev_price: None, ema: 0.0, count: 0, out: [Cell::from_f64(0.0)] }
}
pub fn builder() -> PrimitiveBuilder {
PrimitiveBuilder::new(
"VolStop",
NodeSchema {
inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "price".into() }],
output: vec![FieldSpec { name: "stop_distance".into(), kind: ScalarKind::F64 }],
params: vec![
ParamSpec { name: "length".into(), kind: ScalarKind::I64 },
ParamSpec { name: "k".into(), kind: ScalarKind::F64 },
],
},
|p| Box::new(VolStop::new(p[0].i64() as usize, p[1].f64())),
)
}
}
impl Node for VolStop {
fn lookbacks(&self) -> Vec<usize> { vec![1] }
fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Cell]> {
let w = ctx.f64_in(0);
if w.is_empty() { return None; }
let price = w[0];
let Some(pp) = self.prev_price else {
self.prev_price = Some(price);
return None; // need a prior price to form the first abs-return
};
let d = (price - pp).abs();
let alpha = 2.0 / (self.length as f64 + 1.0);
if self.count == 0 { self.ema = d; } else { self.ema += alpha * (d - self.ema); }
self.count += 1;
self.prev_price = Some(price); // update AFTER use (C2)
if self.count < self.length { return None; } // warm-up
self.out[0] = Cell::from_f64(self.k * self.ema);
Some(&self.out)
}
fn label(&self) -> String { format!("VolStop({},{})", self.length, self.k) }
}
#[cfg(test)]
mod tests {
use super::*;
use aura_core::{AnyColumn, Scalar, Timestamp};
fn feed(node: &mut dyn Node, prices: &[f64]) -> Vec<Option<f64>> {
let mut col = vec![AnyColumn::with_capacity(ScalarKind::F64, 1)];
let mut out = vec![];
for &p in prices {
col[0].push(Scalar::f64(p)).unwrap();
out.push(node.eval(Ctx::new(&col, Timestamp(0))).map(|c| c[0].f64()));
}
out
}
#[test]
fn fixed_stop_is_constant_after_first_price() {
let mut s = FixedStop::new(2.5);
assert_eq!(feed(&mut s, &[100.0, 110.0, 90.0]), vec![Some(2.5), Some(2.5), Some(2.5)]);
}
#[test]
fn vol_stop_is_none_until_warm() {
// length 3: needs a prior price (cycle 1 -> None) then 3 abs-returns.
let mut s = VolStop::new(3, 2.0);
let got = feed(&mut s, &[100.0, 101.0, 102.0, 103.0]);
assert_eq!(got[0], None); // no prior price
assert_eq!(got[1], None); // 1 return
assert_eq!(got[2], None); // 2 returns
assert!(got[3].is_some()); // 3 returns -> warm
}
#[test]
fn vol_stop_tracks_k_times_ema_abs_return() {
// constant abs-return of 1.0 -> EMA = 1.0 -> distance = k*1.0 = 2.0.
let mut s = VolStop::new(2, 2.0);
let got = feed(&mut s, &[100.0, 101.0, 102.0, 103.0]);
assert_eq!(got.last().unwrap().unwrap(), 2.0);
}
}