refactor(cli): retire the r-breakout demo — topology now lives only as data (#159 cut 2)
Cut 2 of the hard-wired demo retirement, applying the r-sma template one level
down. The r-breakout signal leg was fused into r_breakout_graph (which also built
the whole pip/R harness inline), so this cut first carves the signal leg out as a
pure price->bias Composite, ships it as a proven blueprint example, then deletes
the fused builder and the built-in --strategy r-breakout surface. The r-breakout
topology now exists only as data (crates/aura-cli/examples/r_breakout{,_open}.json).
Carved: r_breakout_signal(channel) -> Composite, a verbatim lift of the fused
builder's signal leg (Delay/RollingMax/RollingMin/Gt/Latch/Sub) exposing the raw
exposure as "bias"; the generic wrap_r/run_signal_r path (unchanged) supplies the
pip/R harness. It is #[cfg(test)] — its only role is regenerating the examples and
pinning them to a faithful serialisation; production loads the shipped JSON, never
this builder (so no dead-code residue, no production topology constructor — #159's
invariant).
Proof: before the fused builder was deleted, an equivalence test proved the carved
signal, wrapped and run, reproduces the retiring r_breakout_graph grade byte-for-byte
on the synthetic stream at channel=3 with the shared default R-stop (metric
computation is identical on both paths). That gate is deleted with the builder it
references; the durable anchors that survive are the byte-identity of the shipped
examples to the carve, the loaded-example-grades-identically-to-the-carve proof, and
the closed-ness / open-axis introspect anchors (channel_hi.length, channel_lo.length
per-node — ganging is the separate #61).
Deleted: r_breakout_graph, r_breakout_sweep_family (aura sweep --strategy r-breakout),
Strategy::RBreakout + all four match arms, the r-breakout token from the --strategy
usage strings and docs, and the now-dead --channel flag + RGrid.channel field (its
sole reader was r_breakout_sweep_family). The data successors: aura run
examples/r_breakout.json, and aura sweep examples/r_breakout_open.json --axis
channel_hi.length --axis channel_lo.length.
Test surface: the three built-in-surface sweep tests drop; the walkforward
valid-but-unsupported-token test is re-pointed to momentum (the property is generic
and stays live until Cut 4); and a new negative pins that --strategy r-breakout on
both sweep and walkforward now falls into the generic usage error, not a
special-cased message — catching a half-finished retirement that left the token
parseable.
Verification (own, not the workflow's report): full `cargo test --workspace` green
(0 failed across every binary); `cargo clippy --workspace --all-targets -- -D warnings`
clean, no dead-code residue; the r-sma introspect + grade anchors and the
dissolved-verb real goldens stay green. The implement-loop ran both plan tasks in one
pass (task_range was not honoured); the tree was verified by hand — grep-confirmed the
retired surface is gone and the carve is #[cfg(test)]-scoped.
refs #159
This commit is contained in:
@@ -0,0 +1 @@
|
||||
{"format_version":1,"blueprint":{"name":"r_breakout_signal","nodes":[{"primitive":{"type":"Delay","bound":[{"pos":0,"name":"lag","kind":"I64","value":{"I64":1}}]}},{"primitive":{"type":"RollingMax","name":"channel_hi","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":3}}]}},{"primitive":{"type":"RollingMin","name":"channel_lo","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":3}}]}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Sub"}}],"edges":[{"from":0,"to":1,"slot":0,"from_field":0},{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":3,"slot":1,"from_field":0},{"from":2,"to":4,"slot":0,"from_field":0},{"from":3,"to":5,"slot":0,"from_field":0},{"from":4,"to":5,"slot":1,"from_field":0},{"from":4,"to":6,"slot":0,"from_field":0},{"from":3,"to":6,"slot":1,"from_field":0},{"from":5,"to":7,"slot":0,"from_field":0},{"from":6,"to":7,"slot":1,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":3,"slot":0},{"node":4,"slot":1}],"source":"F64"}],"output":[{"node":7,"field":0,"name":"bias"}]}}
|
||||
@@ -0,0 +1 @@
|
||||
{"format_version":1,"blueprint":{"name":"r_breakout_signal","nodes":[{"primitive":{"type":"Delay","bound":[{"pos":0,"name":"lag","kind":"I64","value":{"I64":1}}]}},{"primitive":{"type":"RollingMax","name":"channel_hi"}},{"primitive":{"type":"RollingMin","name":"channel_lo"}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Sub"}}],"edges":[{"from":0,"to":1,"slot":0,"from_field":0},{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":3,"slot":1,"from_field":0},{"from":2,"to":4,"slot":0,"from_field":0},{"from":3,"to":5,"slot":0,"from_field":0},{"from":4,"to":5,"slot":1,"from_field":0},{"from":4,"to":6,"slot":0,"from_field":0},{"from":3,"to":6,"slot":1,"from_field":0},{"from":5,"to":7,"slot":0,"from_field":0},{"from":6,"to":7,"slot":1,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":3,"slot":0},{"node":4,"slot":1}],"source":"F64"}],"output":[{"node":7,"field":0,"name":"bias"}]}}
|
||||
+86
-165
@@ -37,7 +37,7 @@ use aura_registry::{
|
||||
DEFLATION_BLOCK_LEN, DEFLATION_N_RESAMPLES,
|
||||
};
|
||||
use aura_std::{
|
||||
Add, Bias, CarryCost, ConstantCost, Delay, Ema, GatedRecorder, Gt, Latch, LinComb, LongOnly, Mul,
|
||||
Add, Bias, CarryCost, ConstantCost, Ema, GatedRecorder, Gt, Latch, LinComb, LongOnly, Mul,
|
||||
Recorder, RollingMax, RollingMin, SeriesReducer, SimBroker, Sma, Sqrt, Sub, VolSlippageCost,
|
||||
PM_FIELD_NAMES, PM_RECORD_KINDS,
|
||||
};
|
||||
@@ -46,6 +46,10 @@ use aura_std::{
|
||||
// the import is test-only.
|
||||
#[cfg(test)]
|
||||
use aura_std::std_vocabulary;
|
||||
// `Delay` is now only used by the test-only `r_breakout_signal` carve (#159 cut 2's
|
||||
// fused-builder retirement dropped the production caller); the import is test-only.
|
||||
#[cfg(test)]
|
||||
use aura_std::Delay;
|
||||
use std::sync::mpsc::{self, Receiver};
|
||||
use std::sync::LazyLock;
|
||||
use std::collections::HashSet;
|
||||
@@ -1097,7 +1101,6 @@ struct RGrid {
|
||||
slow: Vec<i64>,
|
||||
stop_length: Vec<i64>,
|
||||
stop_k: Vec<f64>,
|
||||
channel: Vec<i64>,
|
||||
window: Vec<i64>,
|
||||
band_k: Vec<f64>,
|
||||
}
|
||||
@@ -1109,86 +1112,12 @@ impl Default for RGrid {
|
||||
slow: vec![6, 12],
|
||||
stop_length: vec![R_SMA_STOP_LENGTH],
|
||||
stop_k: vec![R_SMA_STOP_K],
|
||||
channel: vec![1920],
|
||||
window: vec![1920],
|
||||
band_k: vec![2.0],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `aura sweep --strategy r-breakout`: sweep the breakout harness over a channel ×
|
||||
/// stop grid. One `channel` length drives BOTH rolling nodes (parameter-ganging, #61),
|
||||
/// so the family iterates the cartesian product MANUALLY with a fully-bound graph per
|
||||
/// point (compile_with_params(&[]) + Harness::bootstrap) rather than
|
||||
/// the open .axis/bootstrap_with_cells path. Each member folds the dense R-record via
|
||||
/// summarize_r, so the family is rankable by sqn / expectancy_r / ... (parity with
|
||||
/// r-sma). With --trace, raw recorders persist the per-cycle streams.
|
||||
fn r_breakout_sweep_family(trace: Option<&str>, data: &DataSource, grid: &RGrid, env: &project::Env) -> SweepFamily {
|
||||
let pip = data.pip_size();
|
||||
let window = data.full_window(env);
|
||||
let mut varying: HashSet<String> = HashSet::new();
|
||||
if grid.channel.len() > 1 {
|
||||
varying.insert("channel".to_string());
|
||||
}
|
||||
if grid.stop_length.len() > 1 {
|
||||
varying.insert("stop_length".to_string());
|
||||
}
|
||||
if grid.stop_k.len() > 1 {
|
||||
varying.insert("stop_k".to_string());
|
||||
}
|
||||
let mut points = Vec::new();
|
||||
for &c in &grid.channel {
|
||||
for &sl in &grid.stop_length {
|
||||
for &sk in &grid.stop_k {
|
||||
let (tx_eq, rx_eq) = mpsc::channel();
|
||||
let (tx_ex, rx_ex) = mpsc::channel();
|
||||
let (tx_r, rx_r) = mpsc::channel();
|
||||
let (tx_req, rx_req) = mpsc::channel();
|
||||
let reduce = trace.is_none();
|
||||
let flat = r_breakout_graph(tx_eq, tx_ex, tx_r, tx_req, Some(c), sl, sk, reduce)
|
||||
.compile_with_params(&[])
|
||||
.expect("valid r-breakout blueprint");
|
||||
let mut h = Harness::bootstrap(flat).expect("valid r-breakout harness");
|
||||
h.run(data.run_sources(env));
|
||||
let named: Vec<(String, Scalar)> = vec![
|
||||
("channel".to_string(), Scalar::i64(c)),
|
||||
("stop_length".to_string(), Scalar::i64(sl)),
|
||||
("stop_k".to_string(), Scalar::f64(sk)),
|
||||
];
|
||||
let key = member_key(&named, &varying);
|
||||
let mut manifest = sim_optimal_manifest(named, window, 0, pip);
|
||||
manifest.broker = r_sma_broker_label(pip);
|
||||
let metrics = if reduce {
|
||||
let r_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_r.try_iter().collect();
|
||||
let (total_pips, max_drawdown) = rx_eq
|
||||
.try_iter()
|
||||
.next()
|
||||
.map(|(_, row)| (row[0].as_f64(), row[1].as_f64()))
|
||||
.unwrap_or((0.0, 0.0));
|
||||
let bias_sign_flips =
|
||||
rx_ex.try_iter().next().map(|(_, row)| row[2].as_i64() as u64).unwrap_or(0);
|
||||
let mut m = RunMetrics { total_pips, max_drawdown, bias_sign_flips, r: None };
|
||||
m.r = Some(summarize_r(&r_rows, &[]));
|
||||
m
|
||||
} else {
|
||||
let eq_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_eq.try_iter().collect();
|
||||
let ex_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_ex.try_iter().collect();
|
||||
let r_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_r.try_iter().collect();
|
||||
let req_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_req.try_iter().collect();
|
||||
if let Some(name) = trace {
|
||||
persist_traces_r(&format!("{name}/{key}"), &manifest, &eq_rows, &ex_rows, &req_rows, &[], env);
|
||||
}
|
||||
let mut m = summarize(&f64_field(&eq_rows, 0), &f64_field(&ex_rows, 0));
|
||||
m.r = Some(summarize_r(&r_rows, &[]));
|
||||
m
|
||||
};
|
||||
points.push(SweepPoint { params: vec![], report: RunReport { manifest, metrics } });
|
||||
}
|
||||
}
|
||||
}
|
||||
SweepFamily { space: vec![], points }
|
||||
}
|
||||
|
||||
fn r_meanrev_sweep_family(trace: Option<&str>, data: &DataSource, grid: &RGrid, env: &project::Env) -> SweepFamily {
|
||||
let pip = data.pip_size();
|
||||
let window = data.full_window(env);
|
||||
@@ -1280,7 +1209,6 @@ fn sweep_report() -> String {
|
||||
enum Strategy {
|
||||
SmaCross,
|
||||
Momentum,
|
||||
RBreakout,
|
||||
RMeanRev,
|
||||
}
|
||||
|
||||
@@ -1312,7 +1240,6 @@ impl Strategy {
|
||||
match self {
|
||||
Strategy::SmaCross => "sma",
|
||||
Strategy::Momentum => "momentum",
|
||||
Strategy::RBreakout => "r-breakout",
|
||||
Strategy::RMeanRev => "r-meanrev",
|
||||
}
|
||||
}
|
||||
@@ -1359,7 +1286,7 @@ fn mc_member_line(id: &str, seed: u64, report: &RunReport) -> String {
|
||||
)
|
||||
}
|
||||
|
||||
/// `aura sweep [--strategy <sma|momentum|r-sma|r-breakout|r-meanrev>] [--name <n>|--trace <n>]`: run the
|
||||
/// `aura sweep [--strategy <sma|momentum|r-sma|r-meanrev>] [--name <n>|--trace <n>]`: run the
|
||||
/// selected built-in sweep, persist it as a *family* (related records sharing one
|
||||
/// `family_id`, C18/C21) via `append_family`, and print each point's record line
|
||||
/// carrying the assigned id. With `--trace`, every strategy
|
||||
@@ -1380,7 +1307,6 @@ fn run_sweep(
|
||||
let family = match strategy {
|
||||
Strategy::SmaCross => sweep_family(persist.then_some(name), &data, env),
|
||||
Strategy::Momentum => momentum_sweep_family(persist.then_some(name), &data, env),
|
||||
Strategy::RBreakout => r_breakout_sweep_family(persist.then_some(name), &data, grid, env),
|
||||
Strategy::RMeanRev => r_meanrev_sweep_family(persist.then_some(name), &data, grid, env),
|
||||
};
|
||||
let id = match reg.append_family(name, FamilyKind::Sweep, &sweep_member_reports(&family)) {
|
||||
@@ -2942,25 +2868,24 @@ fn run_blueprint_mc(doc: &str, n_seeds: u64, name: &str, data: DataSource, env:
|
||||
println!("{}", mc_aggregate_json(&family.aggregate));
|
||||
}
|
||||
|
||||
/// The r-sma harness with its signal leg swapped for a Donchian channel breakout:
|
||||
/// `close -> Delay(1) -> {RollingMax,RollingMin}(channel) -> {Gt,Gt} -> {Latch,Latch}
|
||||
/// -> Sub = bias in {-1,0,+1}`. The one Delay(1) on close feeds both rolling nodes, so
|
||||
/// each channel covers `close[t-N..t-1]` (the C2 guard: the current bar is excluded).
|
||||
/// `channel = None` leaves the lengths open (unused today; the family binds them); the
|
||||
/// stop is bound (defines R), identical downstream to `wrap_r`'s scaffolding.
|
||||
#[allow(clippy::type_complexity, clippy::too_many_arguments)]
|
||||
fn r_breakout_graph(
|
||||
tx_eq: mpsc::Sender<(Timestamp, Vec<Scalar>)>,
|
||||
tx_ex: mpsc::Sender<(Timestamp, Vec<Scalar>)>,
|
||||
tx_r: mpsc::Sender<(Timestamp, Vec<Scalar>)>,
|
||||
tx_req: mpsc::Sender<(Timestamp, Vec<Scalar>)>,
|
||||
channel: Option<i64>,
|
||||
stop_length: i64,
|
||||
stop_k: f64,
|
||||
reduce: bool,
|
||||
) -> Composite {
|
||||
let mut g = GraphBuilder::new("r_breakout");
|
||||
// Donchian breakout signal leg.
|
||||
/// The Donchian channel length for the canonical closed r_breakout example. Single
|
||||
/// source for the emitter and the three proof tests that must all agree with the
|
||||
/// baked `examples/r_breakout.json` — kept honest by one constant instead of a
|
||||
/// hand-synced `Some(3)` literal recurring across those call sites.
|
||||
#[cfg(test)]
|
||||
const R_BREAKOUT_CHANNEL: i64 = 3;
|
||||
|
||||
/// The Donchian breakout signal leg, a pure `price→bias` Composite so it serialises
|
||||
/// as blueprint data (#159 cut 2). The signal computation matches the retired fused
|
||||
/// builder's leg (same nodes, same wiring); the pip/R harness is the generic
|
||||
/// `wrap_r` wrapper, not part of the signal. `channel = Some(n)` binds both rolling
|
||||
/// nodes (closed); `None` leaves them open (two sweep axes — per-node exposure;
|
||||
/// ganging one knob into both is the separate #61). This carve has no production
|
||||
/// (non-test) caller — it is exercised only by the regeneration + proof tests below;
|
||||
/// production code loads the shipped JSON, not this builder — hence `#[cfg(test)]`.
|
||||
#[cfg(test)]
|
||||
fn r_breakout_signal(channel: Option<i64>) -> Composite {
|
||||
let mut g = GraphBuilder::new("r_breakout_signal");
|
||||
let delay = g.add(Delay::builder().bind("lag", Scalar::i64(1)));
|
||||
let mut mx_b = RollingMax::builder().named("channel_hi");
|
||||
let mut mn_b = RollingMin::builder().named("channel_lo");
|
||||
@@ -2975,39 +2900,8 @@ fn r_breakout_graph(
|
||||
let up_latch = g.add(Latch::builder());
|
||||
let down_latch = g.add(Latch::builder());
|
||||
let exposure = g.add(Sub::builder()); // up_latch - down_latch -> bias in {-1,0,+1}
|
||||
// pip branch (verbatim from wrap_r's scaffolding).
|
||||
let broker = g.add(SimBroker::builder(SYNTHETIC_PIP_SIZE));
|
||||
let gate_col = PM_FIELD_NAMES
|
||||
.iter()
|
||||
.position(|&n| n == "closed_this_cycle")
|
||||
.expect("PM record has a closed_this_cycle column");
|
||||
let eq = if reduce {
|
||||
g.add(SeriesReducer::builder(Firing::Any, tx_eq))
|
||||
} else {
|
||||
g.add(Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_eq))
|
||||
};
|
||||
let ex = if reduce {
|
||||
g.add(SeriesReducer::builder(Firing::Any, tx_ex))
|
||||
} else {
|
||||
g.add(Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_ex))
|
||||
};
|
||||
let exec = g.add(risk_executor(StopRule::Vol { length: stop_length, k: stop_k }, 1.0));
|
||||
let rrec = if reduce {
|
||||
g.add(GatedRecorder::builder(PM_RECORD_KINDS.to_vec(), gate_col, Firing::Any, tx_r))
|
||||
} else {
|
||||
g.add(Recorder::builder(PM_RECORD_KINDS.to_vec(), Firing::Any, tx_r))
|
||||
};
|
||||
let price = g.source_role("price", ScalarKind::F64);
|
||||
g.feed(
|
||||
price,
|
||||
[
|
||||
delay.input("series"),
|
||||
gt_up.input("a"),
|
||||
gt_down.input("b"),
|
||||
broker.input("price"),
|
||||
exec.input("price"),
|
||||
],
|
||||
);
|
||||
g.feed(price, [delay.input("series"), gt_up.input("a"), gt_down.input("b")]);
|
||||
g.connect(delay.output("value"), mx.input("series"));
|
||||
g.connect(delay.output("value"), mn.input("series"));
|
||||
g.connect(mx.output("value"), gt_up.input("b"));
|
||||
@@ -3018,36 +2912,19 @@ fn r_breakout_graph(
|
||||
g.connect(gt_up.output("value"), down_latch.input("reset"));
|
||||
g.connect(up_latch.output("value"), exposure.input("lhs"));
|
||||
g.connect(down_latch.output("value"), exposure.input("rhs"));
|
||||
g.connect(exposure.output("value"), broker.input("exposure"));
|
||||
g.connect(exposure.output("value"), ex.input("col[0]"));
|
||||
g.connect(exposure.output("value"), exec.input("bias"));
|
||||
g.connect(broker.output("equity"), eq.input("col[0]"));
|
||||
for (i, field) in PM_FIELD_NAMES.iter().enumerate() {
|
||||
g.connect(exec.output(field), rrec.input(COL_PORTS[i].as_str()));
|
||||
}
|
||||
if !reduce {
|
||||
let r_equity = g.add(
|
||||
LinComb::builder(2)
|
||||
.bind("weights[0]", Scalar::f64(1.0))
|
||||
.bind("weights[1]", Scalar::f64(1.0)),
|
||||
);
|
||||
let req = g.add(Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_req));
|
||||
g.connect(exec.output("cum_realized_r"), r_equity.input("term[0]"));
|
||||
g.connect(exec.output("unrealized_r"), r_equity.input("term[1]"));
|
||||
g.connect(r_equity.output("value"), req.input("col[0]"));
|
||||
}
|
||||
g.build().expect("r_breakout wiring resolves")
|
||||
g.expose(exposure.output("value"), "bias");
|
||||
g.build().expect("r_breakout signal wiring resolves")
|
||||
}
|
||||
|
||||
/// The EWMA Bollinger-band mean-reversion candidate, mirroring
|
||||
/// `r_breakout_graph` but swapping the signal leg: fade deviation from a
|
||||
/// The EWMA Bollinger-band mean-reversion candidate, mirroring the breakout
|
||||
/// candidate's structure, swapping the signal leg: fade deviation from a
|
||||
/// rolling mean (price above `mean + k*sigma` -> short; below `mean - k*sigma` ->
|
||||
/// long), latched +-1. sigma = `Sqrt(Ema((price-mean)^2))` (deviation squared
|
||||
/// then smoothed, the vol_stop shape — no catastrophic cancellation, no NaN).
|
||||
/// No Delay: the current bar legitimately belongs to its own band (causal, C2).
|
||||
/// `window` gangs the mean Ema and the variance Ema (one Bollinger window);
|
||||
/// `band_k` is the band half-width in sigma. Everything below the signal leg is
|
||||
/// byte-identical to `r_breakout_graph`.
|
||||
/// the standard pip/R branch.
|
||||
#[allow(clippy::type_complexity, clippy::too_many_arguments)]
|
||||
fn r_meanrev_graph(
|
||||
tx_eq: mpsc::Sender<(Timestamp, Vec<Scalar>)>,
|
||||
@@ -3081,7 +2958,7 @@ fn r_meanrev_graph(
|
||||
let short_latch = g.add(Latch::builder());
|
||||
let long_latch = g.add(Latch::builder());
|
||||
let exposure = g.add(Sub::builder()); // long_latch - short_latch -> bias in {-1,0,+1}
|
||||
// pip branch (VERBATIM from r_breakout_graph).
|
||||
// pip branch (the standard SimBroker + RiskExecutor + recorders branch).
|
||||
let broker = g.add(SimBroker::builder(SYNTHETIC_PIP_SIZE));
|
||||
let gate_col = PM_FIELD_NAMES
|
||||
.iter()
|
||||
@@ -3451,7 +3328,7 @@ struct RunCmd {
|
||||
struct SweepCmd {
|
||||
/// A loaded blueprint (.json); omit for the built-in --strategy grammar.
|
||||
blueprint: Option<String>,
|
||||
/// Built-in strategy: sma | momentum | r-sma | r-breakout | r-meanrev (default sma).
|
||||
/// Built-in strategy: sma | momentum | r-sma | r-meanrev (default sma).
|
||||
#[arg(long)]
|
||||
strategy: Option<String>,
|
||||
/// Real instrument symbol to sweep over (recorded data); omit for the synthetic stream.
|
||||
@@ -3481,9 +3358,6 @@ struct SweepCmd {
|
||||
/// Stop-k grid axis (comma-separated values).
|
||||
#[arg(long)]
|
||||
stop_k: Option<String>,
|
||||
/// Breakout-channel grid axis (comma-separated values; r-breakout).
|
||||
#[arg(long)]
|
||||
channel: Option<String>,
|
||||
/// Mean-reversion window grid axis (comma-separated values; r-meanrev).
|
||||
#[arg(long)]
|
||||
window: Option<String>,
|
||||
@@ -3502,7 +3376,7 @@ struct SweepCmd {
|
||||
struct WalkforwardCmd {
|
||||
/// A loaded blueprint (.json); omit for the built-in --strategy grammar.
|
||||
blueprint: Option<String>,
|
||||
/// Built-in strategy: sma | momentum | r-sma | r-breakout | r-meanrev (default sma).
|
||||
/// Built-in strategy: sma | momentum | r-sma | r-meanrev (default sma).
|
||||
#[arg(long)]
|
||||
strategy: Option<String>,
|
||||
/// Real instrument symbol to validate over (recorded data); omit for the synthetic stream.
|
||||
@@ -3805,7 +3679,6 @@ fn strategy_from(s: Option<&str>, usage: &impl Fn() -> String) -> Result<Strateg
|
||||
Ok(match s {
|
||||
None | Some("sma") => Strategy::SmaCross,
|
||||
Some("momentum") => Strategy::Momentum,
|
||||
Some("r-breakout") => Strategy::RBreakout,
|
||||
Some("r-meanrev") => Strategy::RMeanRev,
|
||||
Some(_) => return Err(usage()),
|
||||
})
|
||||
@@ -4063,7 +3936,6 @@ fn dispatch_sweep(a: SweepCmd, env: &project::Env) {
|
||||
|| a.slow.is_some()
|
||||
|| a.stop_length.is_some()
|
||||
|| a.stop_k.is_some()
|
||||
|| a.channel.is_some()
|
||||
|| a.window.is_some()
|
||||
|| a.band_k.is_some()
|
||||
{
|
||||
@@ -4194,7 +4066,7 @@ fn dispatch_sweep(a: SweepCmd, env: &project::Env) {
|
||||
}
|
||||
}
|
||||
None => {
|
||||
let usage = || "Usage: aura sweep [--strategy <sma|momentum|r-sma|r-breakout|r-meanrev>] [--real <SYMBOL> [--from <ms>] [--to <ms>]] [--name <n> | --trace <n>] [--fast <csv>] [--slow <csv>] [--stop-length <csv>] [--stop-k <csv>] [--channel <csv>] [--window <csv>] [--band-k <csv>]".to_string();
|
||||
let usage = || "Usage: aura sweep [--strategy <sma|momentum|r-sma|r-meanrev>] [--real <SYMBOL> [--from <ms>] [--to <ms>]] [--name <n> | --trace <n>] [--fast <csv>] [--slow <csv>] [--stop-length <csv>] [--stop-k <csv>] [--window <csv>] [--band-k <csv>]".to_string();
|
||||
if a.blueprint.is_some() || !a.axis.is_empty() || a.list_axes {
|
||||
eprintln!("aura: {}", usage());
|
||||
std::process::exit(2);
|
||||
@@ -4212,7 +4084,6 @@ fn dispatch_sweep(a: SweepCmd, env: &project::Env) {
|
||||
if let Some(v) = a.slow.as_deref() { grid.slow = parse_csv_list(v).unwrap_or_else(|()| bad(usage())); }
|
||||
if let Some(v) = a.stop_length.as_deref() { grid.stop_length = parse_csv_list(v).unwrap_or_else(|()| bad(usage())); }
|
||||
if let Some(v) = a.stop_k.as_deref() { grid.stop_k = parse_csv_list(v).unwrap_or_else(|()| bad(usage())); }
|
||||
if let Some(v) = a.channel.as_deref() { grid.channel = parse_csv_list(v).unwrap_or_else(|()| bad(usage())); }
|
||||
if let Some(v) = a.window.as_deref() { grid.window = parse_csv_list(v).unwrap_or_else(|()| bad(usage())); }
|
||||
if let Some(v) = a.band_k.as_deref() { grid.band_k = parse_csv_list(v).unwrap_or_else(|()| bad(usage())); }
|
||||
let (name, persist) = name_persist(a.name.as_deref(), a.trace.as_deref(), "sweep", &usage)
|
||||
@@ -4272,7 +4143,7 @@ fn dispatch_walkforward(a: WalkforwardCmd, env: &project::Env) {
|
||||
run_blueprint_walkforward(&doc, &axes, &name, DataSource::Synthetic, select, env);
|
||||
}
|
||||
None => {
|
||||
let usage = || "Usage: aura walkforward [--strategy <sma|momentum|r-sma|r-breakout|r-meanrev>] [--real <SYMBOL> [--from <ms>] [--to <ms>]] [--name <n> | --trace <n>] [--fast <csv>] [--slow <csv>] [--stop-length <csv>] [--stop-k <csv>] [--select <argmax|plateau:mean|plateau:worst>]".to_string();
|
||||
let usage = || "Usage: aura walkforward [--strategy <sma|momentum|r-sma|r-meanrev>] [--real <SYMBOL> [--from <ms>] [--to <ms>]] [--name <n> | --trace <n>] [--fast <csv>] [--slow <csv>] [--stop-length <csv>] [--stop-k <csv>] [--select <argmax|plateau:mean|plateau:worst>]".to_string();
|
||||
if a.blueprint.is_some() || !a.axis.is_empty() {
|
||||
eprintln!("aura: {}", usage());
|
||||
std::process::exit(2);
|
||||
@@ -4528,6 +4399,56 @@ fn main() {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Regenerates the shipped r_breakout examples from the carved signal. Run by hand:
|
||||
/// `cargo test --bin aura emit_r_breakout_examples -- --ignored`. The examples are
|
||||
/// green-by-construction (serialised from the builder, never hand-authored).
|
||||
#[test]
|
||||
#[ignore = "regenerates crates/aura-cli/examples/r_breakout{,_open}.json; run by hand"]
|
||||
fn emit_r_breakout_examples() {
|
||||
std::fs::create_dir_all("examples").expect("examples dir");
|
||||
std::fs::write(
|
||||
"examples/r_breakout.json",
|
||||
blueprint_to_json(&r_breakout_signal(Some(R_BREAKOUT_CHANNEL))).expect("serialize closed r_breakout"),
|
||||
)
|
||||
.expect("write examples/r_breakout.json");
|
||||
std::fs::write(
|
||||
"examples/r_breakout_open.json",
|
||||
blueprint_to_json(&r_breakout_signal(None)).expect("serialize open r_breakout"),
|
||||
)
|
||||
.expect("write examples/r_breakout_open.json");
|
||||
}
|
||||
|
||||
/// The shipped examples are a faithful serialisation of the carved signal (#159 cut 2):
|
||||
/// re-serialising the carve equals the checked-in bytes. Green-by-construction with the
|
||||
/// emitter; a drift between builder and file breaks it. Survives the fused builder's
|
||||
/// retirement (it references `r_breakout_signal`, the carve, not the retired builder).
|
||||
#[test]
|
||||
fn shipped_r_breakout_examples_serialize_the_carved_signal() {
|
||||
assert_eq!(
|
||||
blueprint_to_json(&r_breakout_signal(Some(R_BREAKOUT_CHANNEL))).expect("serialize closed"),
|
||||
include_str!("../examples/r_breakout.json"),
|
||||
);
|
||||
assert_eq!(
|
||||
blueprint_to_json(&r_breakout_signal(None)).expect("serialize open"),
|
||||
include_str!("../examples/r_breakout_open.json"),
|
||||
);
|
||||
}
|
||||
|
||||
/// The shipped closed example, reloaded through the data plane and run, grades
|
||||
/// bit-identically to running the carved signal directly (#159 cut 2). This is
|
||||
/// r_breakout's durable equivalence anchor after the fused builder retires: it pins
|
||||
/// that `examples/r_breakout.json` still produces the carve's grade, with no
|
||||
/// hardcoded golden. `Composite` is `!Clone`, so the example is loaded twice.
|
||||
#[test]
|
||||
fn r_breakout_example_loaded_runs_identically_to_the_carved_signal() {
|
||||
let env = project::Env::std();
|
||||
let loaded = blueprint_from_json(include_str!("../examples/r_breakout.json"), &|t| std_vocabulary(t))
|
||||
.expect("shipped r_breakout example loads");
|
||||
let via_file = run_signal_r(loaded, &[], RunData::Synthetic, 0, &env);
|
||||
let via_carve = run_signal_r(r_breakout_signal(Some(R_BREAKOUT_CHANNEL)), &[], RunData::Synthetic, 0, &env);
|
||||
assert_eq!(via_file.metrics, via_carve.metrics, "loaded example grades identically to the carve");
|
||||
}
|
||||
|
||||
/// Loads the shipped closed r-sma example (fast=2, slow=4 bound) through the
|
||||
/// public `blueprint_from_json` path — the single call site so a fixture
|
||||
/// rename or vocabulary change is one edit, not fourteen.
|
||||
@@ -5414,7 +5335,7 @@ mod tests {
|
||||
/// the hand-rebuilt subgraph in `r_meanrev_e2e.rs`) FADES against the
|
||||
/// move — its exposure tap reads short (-1) above the band and long (+1)
|
||||
/// below, the sign-inverted-vs-breakout polarity that defines mean-reversion.
|
||||
/// A latch-polarity copy-paste from `r_breakout_graph` (swapping the
|
||||
/// A latch-polarity copy-paste from the breakout candidate (swapping the
|
||||
/// `set`/`reset` legs) would leave the fold-vs-raw and window-grid CLI tests
|
||||
/// green yet silently invert the signal; only an observable read of this
|
||||
/// function's bias catches it. `k = 0` collapses the band to the lagging EWMA
|
||||
|
||||
@@ -2107,101 +2107,6 @@ fn metrics_object(line: &str) -> &str {
|
||||
panic!("unbalanced metrics object: {line}")
|
||||
}
|
||||
|
||||
/// The breakout strategy reaches the CLI seam: `aura sweep --strategy r-breakout`
|
||||
/// emits an R-bearing member, and the folded no-trace path equals the raw --trace path
|
||||
/// byte-for-byte (parity with the r-sma fold-vs-raw guard). channel 3 warms up on
|
||||
/// the synthetic stream; one channel value × the single default stop = one member.
|
||||
#[test]
|
||||
fn sweep_strategy_r_breakout_folded_no_trace_metrics_equal_raw_trace_metrics() {
|
||||
let cwd = temp_cwd("sweep-r-breakout-fold-vs-raw");
|
||||
|
||||
let folded = Command::new(BIN)
|
||||
.args(["sweep", "--strategy", "r-breakout", "--channel", "3"])
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("spawn folded (no-trace) r-breakout sweep");
|
||||
assert!(
|
||||
folded.status.success(),
|
||||
"folded no-trace breakout sweep exit: {:?}; stderr: {}",
|
||||
folded.status,
|
||||
String::from_utf8_lossy(&folded.stderr)
|
||||
);
|
||||
let folded_out = String::from_utf8(folded.stdout).expect("utf-8");
|
||||
|
||||
let raw = Command::new(BIN)
|
||||
.args(["sweep", "--strategy", "r-breakout", "--channel", "3", "--trace", "b1"])
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("spawn raw (--trace) r-breakout sweep");
|
||||
assert!(
|
||||
raw.status.success(),
|
||||
"raw --trace breakout sweep exit: {:?}; stderr: {}",
|
||||
raw.status,
|
||||
String::from_utf8_lossy(&raw.stderr)
|
||||
);
|
||||
let raw_out = String::from_utf8(raw.stdout).expect("utf-8");
|
||||
|
||||
let folded_lines: Vec<&str> = folded_out.lines().collect();
|
||||
let raw_lines: Vec<&str> = raw_out.lines().collect();
|
||||
assert_eq!(folded_lines.len(), 1, "folded breakout sweep must print 1 member: {folded_out:?}");
|
||||
assert_eq!(
|
||||
raw_lines.len(),
|
||||
folded_lines.len(),
|
||||
"member count must match: folded {} vs raw {}",
|
||||
folded_lines.len(),
|
||||
raw_lines.len()
|
||||
);
|
||||
|
||||
for (i, (f, r)) in folded_lines.iter().zip(raw_lines.iter()).enumerate() {
|
||||
let fm = metrics_object(f);
|
||||
let rm = metrics_object(r);
|
||||
assert!(fm.contains("\"r\":{"), "folded breakout member {i} must carry an r block: {fm}");
|
||||
assert_eq!(
|
||||
fm, rm,
|
||||
"folded vs raw breakout metrics diverge at member {i}\n folded: {fm}\n raw: {rm}"
|
||||
);
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&cwd);
|
||||
}
|
||||
|
||||
/// Property: the breakout family is the cartesian product of its grid axes — a
|
||||
/// multi-value `--channel` list yields exactly one member per channel value, each
|
||||
/// member carrying its own bound channel length in the manifest params. Guards the
|
||||
/// manual cartesian-product loop in `r_breakout_sweep_family`: a regression that
|
||||
/// collapsed the loop (sweeping only the last value, or ganging the two channels)
|
||||
/// would silently drop members here. Channels 2 and 3 both warm up on the 18-bar
|
||||
/// synthetic stream, so two members must appear, in grid order, distinctly.
|
||||
#[test]
|
||||
fn sweep_strategy_r_breakout_grids_one_member_per_channel() {
|
||||
let cwd = temp_cwd("sweep-r-breakout-channel-grid");
|
||||
let out = Command::new(BIN)
|
||||
.args(["sweep", "--strategy", "r-breakout", "--channel", "2,3"])
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("spawn 2-channel r-breakout sweep");
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"channel-grid breakout sweep exit: {:?}; stderr: {}",
|
||||
out.status,
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
let stdout = String::from_utf8(out.stdout).expect("utf-8");
|
||||
let lines: Vec<&str> = stdout.lines().collect();
|
||||
assert_eq!(lines.len(), 2, "2-channel grid must print 2 members: {stdout:?}");
|
||||
// each member carries its own bound channel length (grid order: 2 then 3).
|
||||
assert!(
|
||||
lines[0].contains("[\"channel\",{\"I64\":2}]"),
|
||||
"first member must bind channel=2: {}",
|
||||
lines[0]
|
||||
);
|
||||
assert!(
|
||||
lines[1].contains("[\"channel\",{\"I64\":3}]"),
|
||||
"second member must bind channel=3: {}",
|
||||
lines[1]
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&cwd);
|
||||
}
|
||||
|
||||
/// The mean-reversion strategy reaches the CLI seam: `aura sweep --strategy
|
||||
/// r-meanrev` emits an R-bearing member, and the folded no-trace path equals
|
||||
/// the raw --trace path byte-for-byte (parity with the r-sma / breakout
|
||||
@@ -2328,31 +2233,87 @@ fn walkforward_bare_sma_summary_has_no_oos_r() {
|
||||
let _ = std::fs::remove_dir_all(&cwd);
|
||||
}
|
||||
|
||||
/// Property: a strategy that parses but has no walk-forward form yet (e.g.
|
||||
/// `r-breakout`) is rejected with exit 2 and a stderr message that names the
|
||||
/// Property: a strategy that parses but has no walk-forward form (e.g.
|
||||
/// `momentum`) is rejected with exit 2 and a stderr message that names the
|
||||
/// OFFENDING strategy by its CLI token — not silently run as SMA, not crashed.
|
||||
/// The dispatch's catch-all arm routes through `Strategy::cli_token()`, so the
|
||||
/// diagnostic must echo the token the user typed (`r-breakout`), proving the
|
||||
/// diagnostic must echo the token the user typed (`momentum`), proving the
|
||||
/// inverse map is wired. Distinct from a bad `--strategy bogus` (a usage parse
|
||||
/// error): here the token is a valid strategy with no walk-forward arm.
|
||||
#[test]
|
||||
fn walkforward_unsupported_strategy_exits_2_naming_the_token() {
|
||||
let cwd = temp_cwd("wf-unsupported");
|
||||
let out = Command::new(BIN)
|
||||
.args(["walkforward", "--strategy", "r-breakout"])
|
||||
.args(["walkforward", "--strategy", "momentum"])
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("spawn aura walkforward --strategy r-breakout");
|
||||
.expect("spawn aura walkforward --strategy momentum");
|
||||
assert_eq!(out.status.code(), Some(2), "unsupported walkforward strategy must exit 2");
|
||||
let stderr = String::from_utf8(out.stderr).expect("utf-8 stderr");
|
||||
assert!(
|
||||
stderr.contains("r-breakout"),
|
||||
stderr.contains("momentum"),
|
||||
"stderr must name the offending strategy token: {stderr:?}"
|
||||
);
|
||||
assert!(out.stdout.is_empty(), "no family summary on the rejected path: {:?}", out.stdout);
|
||||
let _ = std::fs::remove_dir_all(&cwd);
|
||||
}
|
||||
|
||||
/// Property (#159 cut 2): `--strategy r-breakout` is a fully retired CLI token, on
|
||||
/// BOTH `sweep` and `walkforward` — indistinguishable from an unrecognized token
|
||||
/// (`bogus`), i.e. it falls through `strategy_from`'s catch-all into the plain
|
||||
/// usage error, not into a "valid-but-unsupported" branch (contrast
|
||||
/// `walkforward_unsupported_strategy_exits_2_naming_the_token`, which pins that
|
||||
/// shape for `momentum`). A regression that left `Strategy::RBreakout` parseable
|
||||
/// while only deleting its family builder would make this test's stderr start
|
||||
/// naming "r-breakout" specially instead of reading the generic `Usage: aura
|
||||
/// <verb> …` line — this is the negative that catches a half-finished retirement.
|
||||
#[test]
|
||||
fn sweep_and_walkforward_reject_retired_r_breakout_token_as_unrecognized() {
|
||||
for (args, verb) in [
|
||||
(&["sweep", "--strategy", "r-breakout"][..], "sweep"),
|
||||
(&["walkforward", "--strategy", "r-breakout"][..], "walkforward"),
|
||||
] {
|
||||
let cwd = temp_cwd("retired-r-breakout-token");
|
||||
let out = Command::new(BIN)
|
||||
.args(args)
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.unwrap_or_else(|e| panic!("spawn aura {args:?}: {e}"));
|
||||
assert_eq!(out.status.code(), Some(2), "`aura {args:?}` must exit 2; status: {:?}", out.status);
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
let want = format!("aura: Usage: aura {verb} ");
|
||||
assert!(
|
||||
stderr.starts_with(&want),
|
||||
"`aura {args:?}` must fall into the generic usage error, not a special-cased \
|
||||
message: {stderr:?}"
|
||||
);
|
||||
assert!(out.stdout.is_empty(), "no family summary on the rejected path: {:?}", out.stdout);
|
||||
let _ = std::fs::remove_dir_all(&cwd);
|
||||
}
|
||||
}
|
||||
|
||||
/// Property (#159 cut 2): `--channel` is genuinely removed from the sweep grammar,
|
||||
/// not merely unused — clap rejects it as a structurally unknown argument (exit 2)
|
||||
/// rather than silently accepting and dropping it. A regression that left the flag
|
||||
/// on `SweepCmd` while deleting only its plumbing would parse successfully here and
|
||||
/// this test would fail on the exit code.
|
||||
#[test]
|
||||
fn sweep_channel_flag_is_removed_from_the_grammar() {
|
||||
let cwd = temp_cwd("sweep-channel-flag-retired");
|
||||
let out = Command::new(BIN)
|
||||
.args(["sweep", "--strategy", "r-meanrev", "--channel", "3"])
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("spawn aura sweep --channel");
|
||||
assert_eq!(out.status.code(), Some(2), "--channel must be an unrecognized clap argument: {:?}", out.status);
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(
|
||||
stderr.contains("--channel") || stderr.to_lowercase().contains("unexpected argument"),
|
||||
"clap must name the unknown flag: {stderr:?}"
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&cwd);
|
||||
}
|
||||
|
||||
/// Property: `aura generalize` grades a single r-sma candidate across two
|
||||
/// instruments — its stdout carries the per-instrument breakdown + the worst-case
|
||||
/// floor + the sign-agreement, then the persisted CrossInstrument family handle.
|
||||
|
||||
@@ -731,3 +731,28 @@ fn shipped_r_sma_open_example_lists_its_axis_namespace() {
|
||||
"the raw open params, in order, from the public gallery copy"
|
||||
);
|
||||
}
|
||||
|
||||
/// Property (#159 cut 2): the shipped closed example (`examples/r_breakout.json`) is
|
||||
/// genuinely closed — `graph introspect --params` reports zero unbound params.
|
||||
#[test]
|
||||
fn shipped_r_breakout_example_is_genuinely_closed() {
|
||||
let dir = temp_cwd("r-breakout-example-closed-params");
|
||||
let (stdout, stderr, code) =
|
||||
run_in(&dir, &["graph", "introspect", "--params", &example("r_breakout.json")]);
|
||||
assert_eq!(code, Some(0), "stdout: {stdout} stderr: {stderr}");
|
||||
assert_eq!(stdout, "", "the closed example must leave zero params unbound");
|
||||
}
|
||||
|
||||
/// Property (#159 cut 2): the shipped open example (`examples/r_breakout_open.json`)
|
||||
/// lists its two channel axes, in lowering order (per-node exposure; ganging is #61).
|
||||
#[test]
|
||||
fn shipped_r_breakout_open_example_lists_its_axis_namespace() {
|
||||
let dir = temp_cwd("r-breakout-example-open-params");
|
||||
let (stdout, stderr, code) =
|
||||
run_in(&dir, &["graph", "introspect", "--params", &example("r_breakout_open.json")]);
|
||||
assert_eq!(code, Some(0), "stdout: {stdout} stderr: {stderr}");
|
||||
assert_eq!(
|
||||
stdout, "channel_hi.length:I64\nchannel_lo.length:I64\n",
|
||||
"the raw open params, in order, from the public gallery copy"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user