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