feat(0072): stage1 mean-reversion candidate — EWMA Bollinger fade (#137)
Third price-only Stage-1 R strategy: aura sweep --strategy stage1-meanrev fades deviation from a rolling mean (price above mean+k*sigma -> short, below -> long, latched +-1), feeding the unchanged bias -> RiskExecutor (vol-stop = R) -> flat-1R seam. sigma = Sqrt(Ema((price-Ema(price))^2)) reuses the vol_stop deviation- squared-then-smoothed shape, so the band is built from existing aura-std nodes (Ema/Sub/Mul/Sqrt/LinComb/Add/Gt/Latch) -> ZERO new nodes. No Delay: the current bar legitimately belongs to its own band (causal, C2). Swept knobs: --window (one Ema length ganged across mean+variance) and --band-k; downstream vol-stop R knobs unchanged. An in-file test reads the SHIPPED graph's exposure tap to pin the fade polarity (short above the band, long below) — a latch-set/reset copy-paste from breakout would invert the signal yet leave the fold-vs-raw and window-grid CLI tests green. All 588 workspace tests pass (existing R/sma/momentum/breakout goldens byte-identical); clippy clean. refs #137
This commit is contained in:
+264
-6
@@ -29,8 +29,8 @@ use aura_registry::{
|
||||
WriteKind,
|
||||
};
|
||||
use aura_std::{
|
||||
Bias, Delay, Ema, GatedRecorder, Gt, Latch, LinComb, LongOnly, Recorder, RollingMax,
|
||||
RollingMin, SeriesReducer, SimBroker, Sma, Sub, PM_FIELD_NAMES, PM_RECORD_KINDS,
|
||||
Add, Bias, Delay, Ema, GatedRecorder, Gt, Latch, LinComb, LongOnly, Mul, Recorder, RollingMax,
|
||||
RollingMin, SeriesReducer, SimBroker, Sma, Sqrt, Sub, PM_FIELD_NAMES, PM_RECORD_KINDS,
|
||||
};
|
||||
use std::sync::mpsc::{self, Receiver};
|
||||
use std::sync::LazyLock;
|
||||
@@ -1128,6 +1128,8 @@ struct Stage1RGrid {
|
||||
stop_length: Vec<i64>,
|
||||
stop_k: Vec<f64>,
|
||||
channel: Vec<i64>,
|
||||
window: Vec<i64>,
|
||||
band_k: Vec<f64>,
|
||||
}
|
||||
|
||||
impl Default for Stage1RGrid {
|
||||
@@ -1138,6 +1140,8 @@ impl Default for Stage1RGrid {
|
||||
stop_length: vec![STAGE1_R_STOP_LENGTH],
|
||||
stop_k: vec![STAGE1_R_STOP_K],
|
||||
channel: vec![1920],
|
||||
window: vec![1920],
|
||||
band_k: vec![2.0],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1337,6 +1341,79 @@ fn stage1_breakout_sweep_family(trace: Option<&str>, data: &DataSource, grid: &S
|
||||
SweepFamily { space: vec![], points }
|
||||
}
|
||||
|
||||
fn stage1_meanrev_sweep_family(trace: Option<&str>, data: &DataSource, grid: &Stage1RGrid) -> SweepFamily {
|
||||
let pip = data.pip_size();
|
||||
let window = data.full_window();
|
||||
let mut varying: HashSet<String> = HashSet::new();
|
||||
if grid.window.len() > 1 {
|
||||
varying.insert("window".to_string());
|
||||
}
|
||||
if grid.band_k.len() > 1 {
|
||||
varying.insert("band_k".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 &n in &grid.window {
|
||||
for &bk in &grid.band_k {
|
||||
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 =
|
||||
stage1_meanrev_graph(tx_eq, tx_ex, tx_r, tx_req, Some(n), bk, sl, sk, reduce)
|
||||
.compile_with_params(&[])
|
||||
.expect("valid stage1-meanrev blueprint");
|
||||
let mut h = Harness::bootstrap(flat).expect("valid stage1-meanrev harness");
|
||||
h.run(data.run_sources());
|
||||
let named: Vec<(String, Scalar)> = vec![
|
||||
("window".to_string(), Scalar::i64(n)),
|
||||
("band_k".to_string(), Scalar::f64(bk)),
|
||||
("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 = stage1_r_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, 0.0));
|
||||
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);
|
||||
}
|
||||
let mut m = summarize(&f64_field(&eq_rows, 0), &f64_field(&ex_rows, 0));
|
||||
m.r = Some(summarize_r(&r_rows, 0.0));
|
||||
m
|
||||
};
|
||||
points.push(SweepPoint { params: vec![], report: RunReport { manifest, metrics } });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
SweepFamily { space: vec![], points }
|
||||
}
|
||||
|
||||
/// Render a sweep family as one `RunReport` JSON line per point. Test helper:
|
||||
/// production (`run_sweep`) renders *and* persists per point.
|
||||
#[cfg(test)]
|
||||
@@ -1364,6 +1441,7 @@ enum Strategy {
|
||||
Momentum,
|
||||
Stage1R,
|
||||
Stage1Breakout,
|
||||
Stage1MeanRev,
|
||||
}
|
||||
|
||||
/// Parse a comma-separated list of `T` (each item parsed via `FromStr`), rejecting
|
||||
@@ -1383,7 +1461,7 @@ fn parse_csv_list<T: std::str::FromStr>(s: &str) -> Result<Vec<T>, ()> {
|
||||
}
|
||||
|
||||
/// Parse the `sweep` tail:
|
||||
/// `[--strategy <sma|momentum|stage1-r|stage1-breakout>] [--real <SYMBOL> [--from <ms>] [--to <ms>]] [--name <n> | --trace <n>] [--fast <csv>] [--slow <csv>] [--stop-length <csv>] [--stop-k <csv>] [--channel <csv>]`.
|
||||
/// `[--strategy <sma|momentum|stage1-r|stage1-breakout|stage1-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>]`.
|
||||
/// Defaults: SMA-cross, synthetic, name "sweep", no persist (today's bare `aura
|
||||
/// sweep`). `--name` and `--trace` are mutually exclusive; `--from`/`--to` (ms,
|
||||
/// `i64`) require `--real` (there is no synthetic window knob). The four optional grid
|
||||
@@ -1396,7 +1474,7 @@ fn parse_csv_list<T: std::str::FromStr>(s: &str) -> Result<Vec<T>, ()> {
|
||||
fn parse_sweep_args(
|
||||
rest: &[&str],
|
||||
) -> Result<(Strategy, String, bool, DataChoice, Stage1RGrid), String> {
|
||||
let usage = || "sweep [--strategy <sma|momentum|stage1-r|stage1-breakout>] [--real <SYMBOL> [--from <ms>] [--to <ms>]] [--name <n> | --trace <n>] [--fast <csv>] [--slow <csv>] [--stop-length <csv>] [--stop-k <csv>] [--channel <csv>]".to_string();
|
||||
let usage = || "sweep [--strategy <sma|momentum|stage1-r|stage1-breakout|stage1-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 mut strategy = Strategy::SmaCross;
|
||||
let mut name: Option<(String, bool)> = None; // (name, persist)
|
||||
let mut real = RealWindowGrammar::default();
|
||||
@@ -1415,6 +1493,7 @@ fn parse_sweep_args(
|
||||
"momentum" => Strategy::Momentum,
|
||||
"stage1-r" => Strategy::Stage1R,
|
||||
"stage1-breakout" => Strategy::Stage1Breakout,
|
||||
"stage1-meanrev" => Strategy::Stage1MeanRev,
|
||||
_ => return Err(usage()),
|
||||
};
|
||||
}
|
||||
@@ -1425,6 +1504,8 @@ fn parse_sweep_args(
|
||||
"--stop-length" => grid.stop_length = parse_csv_list(value).map_err(|()| usage())?,
|
||||
"--stop-k" => grid.stop_k = parse_csv_list(value).map_err(|()| usage())?,
|
||||
"--channel" => grid.channel = parse_csv_list(value).map_err(|()| usage())?,
|
||||
"--window" => grid.window = parse_csv_list(value).map_err(|()| usage())?,
|
||||
"--band-k" => grid.band_k = parse_csv_list(value).map_err(|()| usage())?,
|
||||
_ => return Err(usage()),
|
||||
}
|
||||
tail = t;
|
||||
@@ -1483,7 +1564,7 @@ fn mc_member_line(id: &str, seed: u64, report: &RunReport) -> String {
|
||||
)
|
||||
}
|
||||
|
||||
/// `aura sweep [--strategy <sma|momentum|stage1-r|stage1-breakout>] [--name <n>|--trace <n>]`: run the
|
||||
/// `aura sweep [--strategy <sma|momentum|stage1-r|stage1-breakout|stage1-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
|
||||
@@ -1503,6 +1584,7 @@ fn run_sweep(strategy: Strategy, name: &str, persist: bool, data: DataSource, gr
|
||||
Strategy::Momentum => momentum_sweep_family(persist.then_some(name), &data),
|
||||
Strategy::Stage1R => stage1_r_sweep_family(persist.then_some(name), &data, grid),
|
||||
Strategy::Stage1Breakout => stage1_breakout_sweep_family(persist.then_some(name), &data, grid),
|
||||
Strategy::Stage1MeanRev => stage1_meanrev_sweep_family(persist.then_some(name), &data, grid),
|
||||
};
|
||||
let id = match reg.append_family(name, FamilyKind::Sweep, &sweep_member_reports(&family)) {
|
||||
Ok(id) => id,
|
||||
@@ -2204,6 +2286,121 @@ fn stage1_breakout_graph(
|
||||
g.build().expect("stage1_breakout wiring resolves")
|
||||
}
|
||||
|
||||
/// The Stage-1 EWMA Bollinger-band mean-reversion candidate, mirroring
|
||||
/// `stage1_breakout_graph` but 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 `stage1_breakout_graph`.
|
||||
#[allow(clippy::type_complexity, clippy::too_many_arguments)]
|
||||
fn stage1_meanrev_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>)>,
|
||||
window: Option<i64>,
|
||||
band_k: f64,
|
||||
stop_length: i64,
|
||||
stop_k: f64,
|
||||
reduce: bool,
|
||||
) -> Composite {
|
||||
let mut g = GraphBuilder::new("stage1_meanrev");
|
||||
// EWMA Bollinger-band mean-reversion signal leg (the ONLY change vs breakout).
|
||||
let (mut mean_b, mut var_b) =
|
||||
(Ema::builder().named("mean_window"), Ema::builder().named("var_window"));
|
||||
if let Some(n) = window {
|
||||
mean_b = mean_b.bind("length", Scalar::i64(n));
|
||||
var_b = var_b.bind("length", Scalar::i64(n));
|
||||
}
|
||||
let mean = g.add(mean_b);
|
||||
let dev = g.add(Sub::builder()); // price - mean
|
||||
let sq = g.add(Mul::builder()); // dev * dev
|
||||
let var = g.add(var_b); // EWMA variance
|
||||
let sigma = g.add(Sqrt::builder()); // sigma (price units)
|
||||
let band = g.add(LinComb::builder(1).bind("weights[0]", Scalar::f64(band_k))); // k*sigma
|
||||
let upper = g.add(Add::builder()); // mean + k*sigma
|
||||
let lower = g.add(Sub::builder()); // mean - k*sigma
|
||||
let gt_hi = g.add(Gt::builder()); // price > upper -> overextended up -> fade short
|
||||
let gt_lo = g.add(Gt::builder()); // lower > price -> overextended down -> fade long
|
||||
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 stage1_breakout_graph).
|
||||
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,
|
||||
[
|
||||
mean.input("series"),
|
||||
dev.input("lhs"),
|
||||
gt_hi.input("a"),
|
||||
gt_lo.input("b"),
|
||||
broker.input("price"),
|
||||
exec.input("price"),
|
||||
],
|
||||
);
|
||||
g.connect(mean.output("value"), dev.input("rhs"));
|
||||
g.connect(dev.output("value"), sq.input("lhs"));
|
||||
g.connect(dev.output("value"), sq.input("rhs")); // square: feed dev to both legs
|
||||
g.connect(sq.output("value"), var.input("series"));
|
||||
g.connect(var.output("value"), sigma.input("value"));
|
||||
g.connect(sigma.output("value"), band.input("term[0]"));
|
||||
g.connect(mean.output("value"), upper.input("lhs"));
|
||||
g.connect(band.output("value"), upper.input("rhs")); // upper = mean + k*sigma
|
||||
g.connect(mean.output("value"), lower.input("lhs"));
|
||||
g.connect(band.output("value"), lower.input("rhs")); // lower = mean - k*sigma
|
||||
g.connect(upper.output("value"), gt_hi.input("b"));
|
||||
g.connect(lower.output("value"), gt_lo.input("a"));
|
||||
g.connect(gt_hi.output("value"), short_latch.input("set"));
|
||||
g.connect(gt_lo.output("value"), short_latch.input("reset"));
|
||||
g.connect(gt_lo.output("value"), long_latch.input("set"));
|
||||
g.connect(gt_hi.output("value"), long_latch.input("reset"));
|
||||
g.connect(long_latch.output("value"), exposure.input("lhs"));
|
||||
g.connect(short_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("stage1_meanrev wiring resolves")
|
||||
}
|
||||
|
||||
/// `aura run --harness stage1-r [--real <SYM> [--from][--to]] [--trace <n>]`: build the
|
||||
/// dual-tap stage1-r harness, run it on synthetic or real M1 data, fold the pip taps via
|
||||
/// `summarize` and the dense R-record via `summarize_r`, and attach the R block as
|
||||
@@ -2386,7 +2583,7 @@ fn run_dispatch(args: RunArgs) -> Result<RunReport, String> {
|
||||
}
|
||||
|
||||
const USAGE: &str =
|
||||
"usage: aura run [--harness <sma|macd|stage1-r>] [--real <SYMBOL> [--from <ms>] [--to <ms>]] [--trace <name>] | aura chart <name> [--tap <t>] [--panels] | aura graph | aura sweep [--strategy <sma|momentum|stage1-r|stage1-breakout>] [--real <SYMBOL> [--from <ms>] [--to <ms>]] [--name <n>|--trace <n>] | aura mc [--name <n>|--trace <n>] | aura walkforward [--real <SYMBOL> [--from <ms>] [--to <ms>]] [--name <n>|--trace <n>] | aura runs families | aura runs family <id> [rank <metric>]";
|
||||
"usage: aura run [--harness <sma|macd|stage1-r>] [--real <SYMBOL> [--from <ms>] [--to <ms>]] [--trace <name>] | aura chart <name> [--tap <t>] [--panels] | aura graph | aura sweep [--strategy <sma|momentum|stage1-r|stage1-breakout|stage1-meanrev>] [--real <SYMBOL> [--from <ms>] [--to <ms>]] [--name <n>|--trace <n>] | aura mc [--name <n>|--trace <n>] | aura walkforward [--real <SYMBOL> [--from <ms>] [--to <ms>]] [--name <n>|--trace <n>] | aura runs families | aura runs family <id> [rank <metric>]";
|
||||
|
||||
fn main() {
|
||||
// Collect argv and match the whole vector: every accepted form is exhaustive,
|
||||
@@ -3354,6 +3551,67 @@ mod tests {
|
||||
assert!(parse_sweep_args(&["--channel", ""]).is_err()); // empty item
|
||||
}
|
||||
|
||||
/// Property: the meanrev `--window` / `--band-k` flags parse comma-separated
|
||||
/// lists onto `Stage1RGrid.{window,band_k}` (the meanrev family's signal axes),
|
||||
/// with the same strictness as the other grid flags — absent flags keep the
|
||||
/// historical defaults, a malformed list is the usage error.
|
||||
#[test]
|
||||
fn parse_sweep_args_parses_the_meanrev_grid_flags() {
|
||||
let parsed = parse_sweep_args(&[
|
||||
"--strategy", "stage1-meanrev", "--window", "120,240,480", "--band-k", "1.5,2.5",
|
||||
])
|
||||
.expect("the meanrev grid flags parse");
|
||||
assert_eq!(parsed.0, Strategy::Stage1MeanRev);
|
||||
assert_eq!(parsed.4.window, vec![120, 240, 480]);
|
||||
assert_eq!(parsed.4.band_k, vec![1.5, 2.5]);
|
||||
// absent flags keep the historical defaults.
|
||||
assert_eq!(parse_sweep_args(&[]).unwrap().4.window, vec![1920]);
|
||||
assert_eq!(parse_sweep_args(&[]).unwrap().4.band_k, vec![2.0]);
|
||||
// a malformed list is the strict usage error.
|
||||
assert!(parse_sweep_args(&["--window", "120,x"]).is_err());
|
||||
assert!(parse_sweep_args(&["--band-k", ""]).is_err());
|
||||
}
|
||||
|
||||
/// Property: the *shipped* `stage1_meanrev_graph` (the CLI compile unit, not
|
||||
/// the hand-rebuilt subgraph in `stage1_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 `stage1_breakout_graph` (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
|
||||
/// mean, isolating direction + latch from the sigma threshold; window 3
|
||||
/// (alpha = 0.5) lags the level clearly. The exposure Recorder (`tx_ex`,
|
||||
/// `reduce = false`) carries the bias in col[0].
|
||||
#[test]
|
||||
fn stage1_meanrev_graph_fades_short_above_the_band_and_long_below() {
|
||||
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 flat = stage1_meanrev_graph(tx_eq, tx_ex, tx_r, tx_req, Some(3), 0.0, 3, 2.0, false)
|
||||
.compile_with_params(&[])
|
||||
.expect("stage1-meanrev blueprint compiles");
|
||||
let mut h = Harness::bootstrap(flat).expect("stage1-meanrev harness bootstraps");
|
||||
// calm (price == lagging mean -> no fade) | sustained UP (price > mean ->
|
||||
// fade SHORT) | sustained DOWN (price < mean -> fade LONG).
|
||||
let closes = [
|
||||
100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 130.0, 130.0, 130.0, 70.0, 70.0, 70.0, 70.0,
|
||||
];
|
||||
let prices: Vec<(Timestamp, Scalar)> =
|
||||
closes.iter().enumerate().map(|(i, &c)| (Timestamp(i as i64), Scalar::f64(c))).collect();
|
||||
let src: Vec<Box<dyn aura_engine::Source>> = vec![Box::new(VecSource::new(prices))];
|
||||
h.run(src);
|
||||
let bias: Vec<f64> =
|
||||
rx_ex.try_iter().map(|(_, row): (Timestamp, Vec<Scalar>)| row[0].as_f64()).collect();
|
||||
assert!(!bias.is_empty(), "the meanrev exposure tap must emit once warmed up");
|
||||
assert_eq!(*bias.first().unwrap(), 0.0, "calm bars (price == mean) must not fade: {bias:?}");
|
||||
let first_short = bias.iter().position(|&b| b == -1.0).expect("an up-move must fade SHORT (-1)");
|
||||
let first_long = bias.iter().position(|&b| b == 1.0).expect("a down-move must fade LONG (+1)");
|
||||
assert!(first_short < first_long, "short (up-fade) must precede long (down-fade): {bias:?}");
|
||||
assert_eq!(*bias.last().unwrap(), 1.0, "the down-fade long must hold to the end: {bias:?}");
|
||||
}
|
||||
|
||||
/// `parse_sweep_args` admits a real symbol with an optional `--from`/`--to`
|
||||
/// window (yielding `DataChoice::Real`), still honouring `--strategy`/`--trace`;
|
||||
/// a `--real` without its symbol, or a window flag without `--real`, is rejected.
|
||||
|
||||
@@ -2148,3 +2148,96 @@ fn sweep_strategy_stage1_breakout_grids_one_member_per_channel() {
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&cwd);
|
||||
}
|
||||
|
||||
/// The mean-reversion strategy reaches the CLI seam: `aura sweep --strategy
|
||||
/// stage1-meanrev` emits an R-bearing member, and the folded no-trace path equals
|
||||
/// the raw --trace path byte-for-byte (parity with the stage1-r / breakout
|
||||
/// fold-vs-raw guard). window 3 warms up on the synthetic stream; one window ×
|
||||
/// one band_k × the single default stop = one member.
|
||||
#[test]
|
||||
fn sweep_strategy_stage1_meanrev_folded_no_trace_metrics_equal_raw_trace_metrics() {
|
||||
let cwd = temp_cwd("sweep-stage1-meanrev-fold-vs-raw");
|
||||
|
||||
let folded = Command::new(BIN)
|
||||
.args(["sweep", "--strategy", "stage1-meanrev", "--window", "3"])
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("spawn folded (no-trace) stage1-meanrev sweep");
|
||||
assert!(
|
||||
folded.status.success(),
|
||||
"folded no-trace meanrev 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", "stage1-meanrev", "--window", "3", "--trace", "m1"])
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("spawn raw (--trace) stage1-meanrev sweep");
|
||||
assert!(
|
||||
raw.status.success(),
|
||||
"raw --trace meanrev 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 meanrev 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 meanrev member {i} must carry an r block: {fm}");
|
||||
assert_eq!(
|
||||
fm, rm,
|
||||
"folded vs raw meanrev metrics diverge at member {i}\n folded: {fm}\n raw: {rm}"
|
||||
);
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&cwd);
|
||||
}
|
||||
|
||||
/// Property: the meanrev family is the cartesian product of its grid axes — a
|
||||
/// multi-value `--window` list yields exactly one member per window value, each
|
||||
/// carrying its own bound window length in the manifest params. Guards the manual
|
||||
/// cartesian loop in `stage1_meanrev_sweep_family`. windows 3 and 4 both warm up
|
||||
/// on the 18-bar synthetic stream, so two members must appear, in grid order.
|
||||
#[test]
|
||||
fn sweep_strategy_stage1_meanrev_grids_one_member_per_window() {
|
||||
let cwd = temp_cwd("sweep-stage1-meanrev-window-grid");
|
||||
let out = Command::new(BIN)
|
||||
.args(["sweep", "--strategy", "stage1-meanrev", "--window", "3,4"])
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("spawn 2-window stage1-meanrev sweep");
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"window-grid meanrev 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-window grid must print 2 members: {stdout:?}");
|
||||
assert!(
|
||||
lines[0].contains("[\"window\",{\"I64\":3}]"),
|
||||
"first member must bind window=3: {}",
|
||||
lines[0]
|
||||
);
|
||||
assert!(
|
||||
lines[1].contains("[\"window\",{\"I64\":4}]"),
|
||||
"second member must bind window=4: {}",
|
||||
lines[1]
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&cwd);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user