feat(std,cli): add the Scale node, retire the r-meanrev demo to data (#159 cut 3)
Cut 3 of the hard-wired demo retirement. Unlike r-sma/r-breakout, r-meanrev could
not round-trip as data: its band computes band_k*sigma (a constant times a stream)
and the closed 22-node std_vocabulary had no way to express it (EqConst is i64->bool,
Bias is clamp(signal/scale,±1), Mul is binary, LinComb is excluded as a
construction-arg node; no Div/Const/constant-emitter). Standard operators are missing
by chance, not by design, so this adds the one r-meanrev needs.
Scale node (aura-std): out = input * factor — one f64 input, one f64 `factor` param,
stateless, warm-up-filtered (the EqConst/Bias shape). A zero-input `Const` emitter was
rejected: the per-cycle eval loop gates every node through an input-firing test
(harness.rs `fires()`), so a node with no input never fires without an engine-core
change; `Scale` fits the existing model and, by IEEE-754 commutativity, reproduces the
retiring LinComb's band byte-for-byte. Rostered (`"Scale" => Scale`); the two
vocabulary count-pins bump 22 -> 23.
r-meanrev migration: r_meanrev_signal(window, band_k) is carved out of the fused
r_meanrev_graph as a #[cfg(test)] price->bias Composite whose band is `Scale` in place
of `LinComb(1)`; production loads the shipped JSON (examples/r_meanrev{,_open}.json),
never a builder. Before the fused builder was deleted, an equivalence test proved the
carved Scale-band signal reproduces its grade byte-for-byte on the synthetic stream
(window=3, band_k=2). Durable survivors: the byte-identity of the examples to the
carve, the loaded-grades-identically-to-carve proof, the closed/open introspect
anchors (mean_window.length, var_window.length, band.factor — band_k is now a
first-class sweepable param), and — re-pointed onto the carve rather than dropped —
the fades-short-above/long-below behavioural test, which becomes the durable
carve-correctness gate the deleted equivalence test used to be.
Deletions + the dead-code cascade the retirement opened: r_meanrev_graph,
r_meanrev_sweep_family, Strategy::RMeanRev (+ all arms). r-meanrev was the last reader
of run_sweep's grid, so the whole vestigial built-in-sweep grid apparatus retires with
it: run_sweep's `grid` param, the sweep call-site grid build, and SweepCmd's
--fast/--slow/--stop-length/--stop-k/--window/--band-k (sma/momentum sweeps build their
own blueprints and ignored these; the fast/slow/stop-* had been vestigial since r-sma's
cut 1b). RGrid the struct SURVIVES — the dissolved `generalize` verb resolves its
candidate from its fast/slow/stop_length/stop_k via generalize_args_from — so only its
window/band_k fields go; its stale doc-comment (r-sma-sweep / persist_traces_r / CLI
flags, all now gone) is rewritten to its true generalize-only role. Also retired as
transitively dead: persist_traces_r and the metrics_object test helper (their only
callers were the deleted families/tests); Add/Gt/Latch/Mul/Sqrt imports are now
#[cfg(test)] (their last production caller was r_meanrev_graph).
Test surface: the two built-in --strategy r-meanrev sweep tests drop; a new negative
pins that --strategy r-meanrev on both sweep and walkforward now falls into the generic
usage error; the grid-flag stray-positional negative is re-pointed to a surviving flag.
Verification (own, not the workflow's report): full `cargo test --workspace` green
(0 failed across 62 result lines); `cargo clippy --workspace --all-targets -- -D
warnings` clean, no dead-code residue; the r-sma + r-breakout anchors and the
dissolved-verb real goldens (generalize still reads RGrid) stay green. Bundled as one
commit (the Scale node's count-pin and the r-meanrev anchors share graph_construct.rs,
so a clean two-commit split was not path-separable); the implement-loop ran all three
plan tasks in one pass and the tree was verified by hand.
refs #159
This commit is contained in:
@@ -0,0 +1 @@
|
||||
{"format_version":1,"blueprint":{"name":"r_meanrev_signal","nodes":[{"primitive":{"type":"EMA","name":"mean_window","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":3}}]}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Mul"}},{"primitive":{"type":"EMA","name":"var_window","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":3}}]}},{"primitive":{"type":"Sqrt"}},{"primitive":{"type":"Scale","name":"band","bound":[{"pos":0,"name":"factor","kind":"F64","value":{"F64":2.0}}]}},{"primitive":{"type":"Add"}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Sub"}}],"edges":[{"from":0,"to":1,"slot":1,"from_field":0},{"from":1,"to":2,"slot":0,"from_field":0},{"from":1,"to":2,"slot":1,"from_field":0},{"from":2,"to":3,"slot":0,"from_field":0},{"from":3,"to":4,"slot":0,"from_field":0},{"from":4,"to":5,"slot":0,"from_field":0},{"from":0,"to":6,"slot":0,"from_field":0},{"from":5,"to":6,"slot":1,"from_field":0},{"from":0,"to":7,"slot":0,"from_field":0},{"from":5,"to":7,"slot":1,"from_field":0},{"from":6,"to":8,"slot":1,"from_field":0},{"from":7,"to":9,"slot":0,"from_field":0},{"from":8,"to":10,"slot":0,"from_field":0},{"from":9,"to":10,"slot":1,"from_field":0},{"from":9,"to":11,"slot":0,"from_field":0},{"from":8,"to":11,"slot":1,"from_field":0},{"from":11,"to":12,"slot":0,"from_field":0},{"from":10,"to":12,"slot":1,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":1,"slot":0},{"node":8,"slot":0},{"node":9,"slot":1}],"source":"F64"}],"output":[{"node":12,"field":0,"name":"bias"}]}}
|
||||
@@ -0,0 +1 @@
|
||||
{"format_version":1,"blueprint":{"name":"r_meanrev_signal","nodes":[{"primitive":{"type":"EMA","name":"mean_window"}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Mul"}},{"primitive":{"type":"EMA","name":"var_window"}},{"primitive":{"type":"Sqrt"}},{"primitive":{"type":"Scale","name":"band"}},{"primitive":{"type":"Add"}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Sub"}}],"edges":[{"from":0,"to":1,"slot":1,"from_field":0},{"from":1,"to":2,"slot":0,"from_field":0},{"from":1,"to":2,"slot":1,"from_field":0},{"from":2,"to":3,"slot":0,"from_field":0},{"from":3,"to":4,"slot":0,"from_field":0},{"from":4,"to":5,"slot":0,"from_field":0},{"from":0,"to":6,"slot":0,"from_field":0},{"from":5,"to":6,"slot":1,"from_field":0},{"from":0,"to":7,"slot":0,"from_field":0},{"from":5,"to":7,"slot":1,"from_field":0},{"from":6,"to":8,"slot":1,"from_field":0},{"from":7,"to":9,"slot":0,"from_field":0},{"from":8,"to":10,"slot":0,"from_field":0},{"from":9,"to":10,"slot":1,"from_field":0},{"from":9,"to":11,"slot":0,"from_field":0},{"from":8,"to":11,"slot":1,"from_field":0},{"from":11,"to":12,"slot":0,"from_field":0},{"from":10,"to":12,"slot":1,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":1,"slot":0},{"node":8,"slot":0},{"node":9,"slot":1}],"source":"F64"}],"output":[{"node":12,"field":0,"name":"bias"}]}}
|
||||
+177
-306
@@ -37,9 +37,9 @@ use aura_registry::{
|
||||
DEFLATION_BLOCK_LEN, DEFLATION_N_RESAMPLES,
|
||||
};
|
||||
use aura_std::{
|
||||
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,
|
||||
Bias, CarryCost, ConstantCost, Ema, GatedRecorder, LinComb, LongOnly, Recorder, RollingMax,
|
||||
RollingMin, SeriesReducer, SimBroker, Sma, Sub, VolSlippageCost, PM_FIELD_NAMES,
|
||||
PM_RECORD_KINDS,
|
||||
};
|
||||
// `std_vocabulary` is now only reached through `project::Env::resolve` in production
|
||||
// code; the test module still builds reference blueprints against it directly, so
|
||||
@@ -50,6 +50,17 @@ use aura_std::std_vocabulary;
|
||||
// fused-builder retirement dropped the production caller); the import is test-only.
|
||||
#[cfg(test)]
|
||||
use aura_std::Delay;
|
||||
// `Scale` is only used by the test-only `r_meanrev_signal` carve (#159 cut 3; the
|
||||
// band half-width uses `Scale` in place of `LinComb(1)`) — no production caller, so
|
||||
// the import is test-only, mirroring `Delay` above.
|
||||
#[cfg(test)]
|
||||
use aura_std::Scale;
|
||||
// `Add`/`Gt`/`Latch`/`Mul`/`Sqrt` are now only reached from the test-only
|
||||
// `r_breakout_signal`/`r_meanrev_signal` carves — #159 cut 3's retirement of the
|
||||
// last fused mean-reversion builder dropped the last production caller, mirroring
|
||||
// `Delay`/`Scale` above.
|
||||
#[cfg(test)]
|
||||
use aura_std::{Add, Gt, Latch, Mul, Sqrt};
|
||||
use std::sync::mpsc::{self, Receiver};
|
||||
use std::sync::LazyLock;
|
||||
use std::collections::HashSet;
|
||||
@@ -1080,29 +1091,18 @@ fn r_sma_broker_label(pip_size: f64) -> String {
|
||||
format!("sim-optimal+risk-executor(pip_size={pip_size})")
|
||||
}
|
||||
|
||||
/// `aura sweep --strategy r-sma`: sweep the r-sma harness over a fast×slow
|
||||
/// SIGNAL grid (the stop + sizing stay fixed — see the cycle-0066 / #133 decision
|
||||
/// log: risk_budget is R-invariant and bias.scale is sign-only under flat-1R, both
|
||||
/// degenerate axes; the stop defines the R unit, so varying it would break
|
||||
/// cross-member SQN comparability). Each member folds the dense R-record via
|
||||
/// summarize_r, so its RunReport carries `r: Some(..)` and the family is rankable
|
||||
/// by sqn / sqn_normalized / expectancy_r / net_expectancy_r. With `--trace`, each
|
||||
/// member's equity / exposure / r_equity streams are persisted under
|
||||
/// `runs/traces/<name>/<member_key>/` via `persist_traces_r` (mirroring
|
||||
/// `momentum_sweep_family`), so a swept member is chartable.
|
||||
/// The four griddable knobs of the r-sma sweep as value lists (#137): the SMA fast
|
||||
/// / slow lengths and the vol-stop length / `k`-multiplier. The family is the cartesian
|
||||
/// product of the four lists; absent flags fall back to the historical defaults (fast
|
||||
/// `{2,3}`, slow `{6,12}`, stop_length `{3}`, stop_k `{2.0}`) so a no-flags sweep is
|
||||
/// byte-identical to the pre-#137 family.
|
||||
/// The four-knob r-sma candidate grid the dissolved `generalize` verb resolves its
|
||||
/// candidate from (`generalize_args_from`): the SMA fast / slow lengths and the
|
||||
/// vol-stop length / `k`-multiplier, each a value list. Absent knobs fall back to the
|
||||
/// historical defaults (fast `{2,3}`, slow `{6,12}`, stop_length `{3}`, stop_k
|
||||
/// `{2.0}`). The built-in `--strategy` sweep and its CLI grid flags retired with the
|
||||
/// demo harnesses (#159 cuts 1b/2/3); this struct now serves `generalize` alone.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
struct RGrid {
|
||||
fast: Vec<i64>,
|
||||
slow: Vec<i64>,
|
||||
stop_length: Vec<i64>,
|
||||
stop_k: Vec<f64>,
|
||||
window: Vec<i64>,
|
||||
band_k: Vec<f64>,
|
||||
}
|
||||
|
||||
impl Default for RGrid {
|
||||
@@ -1112,85 +1112,10 @@ impl Default for RGrid {
|
||||
slow: vec![6, 12],
|
||||
stop_length: vec![R_SMA_STOP_LENGTH],
|
||||
stop_k: vec![R_SMA_STOP_K],
|
||||
window: vec![1920],
|
||||
band_k: vec![2.0],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
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 =
|
||||
r_meanrev_graph(tx_eq, tx_ex, tx_r, tx_req, Some(n), bk, sl, sk, reduce)
|
||||
.compile_with_params(&[])
|
||||
.expect("valid r-meanrev blueprint");
|
||||
let mut h = Harness::bootstrap(flat).expect("valid r-meanrev harness");
|
||||
h.run(data.run_sources(env));
|
||||
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 = 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 }
|
||||
}
|
||||
|
||||
/// Render a sweep family as one `RunReport` JSON line per point. Test helper:
|
||||
/// production (`run_sweep`) renders *and* persists per point.
|
||||
#[cfg(test)]
|
||||
@@ -1209,7 +1134,6 @@ fn sweep_report() -> String {
|
||||
enum Strategy {
|
||||
SmaCross,
|
||||
Momentum,
|
||||
RMeanRev,
|
||||
}
|
||||
|
||||
/// In-sample winner-selection objective for walk-forward (cycle 0077). `Argmax` is
|
||||
@@ -1240,7 +1164,6 @@ impl Strategy {
|
||||
match self {
|
||||
Strategy::SmaCross => "sma",
|
||||
Strategy::Momentum => "momentum",
|
||||
Strategy::RMeanRev => "r-meanrev",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1286,17 +1209,13 @@ fn mc_member_line(id: &str, seed: u64, report: &RunReport) -> String {
|
||||
)
|
||||
}
|
||||
|
||||
/// `aura sweep [--strategy <sma|momentum|r-sma|r-meanrev>] [--name <n>|--trace <n>]`: run the
|
||||
/// `aura sweep [--strategy <sma|momentum|r-sma>] [--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
|
||||
/// (`sma`/`momentum`/`r-sma`) persists each member's streams under
|
||||
/// `runs/traces/<n>/<member_key>/` (opt-in); the `r-sma` member also carries
|
||||
/// the `r_equity` tap (via `persist_traces_r`).
|
||||
fn run_sweep(
|
||||
strategy: Strategy, name: &str, persist: bool, data: DataSource, grid: &RGrid,
|
||||
env: &project::Env,
|
||||
) {
|
||||
/// (`sma`/`momentum`) persists each member's streams under
|
||||
/// `runs/traces/<n>/<member_key>/` (opt-in).
|
||||
fn run_sweep(strategy: Strategy, name: &str, persist: bool, data: DataSource, env: &project::Env) {
|
||||
if persist
|
||||
&& let Err(e) = env.trace_store().ensure_name_free(name, WriteKind::Family)
|
||||
{
|
||||
@@ -1307,7 +1226,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::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)) {
|
||||
Ok(id) => id,
|
||||
@@ -2916,29 +2834,26 @@ fn r_breakout_signal(channel: Option<i64>) -> Composite {
|
||||
g.build().expect("r_breakout signal wiring resolves")
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// the standard pip/R branch.
|
||||
#[allow(clippy::type_complexity, clippy::too_many_arguments)]
|
||||
fn r_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("r_meanrev");
|
||||
// EWMA Bollinger-band mean-reversion signal leg (the ONLY change vs breakout).
|
||||
/// The EWMA window for the canonical closed r_meanrev example (ganged mean/var Ema
|
||||
/// length). Single source for the emitter and the proof tests that must all agree
|
||||
/// with the baked `examples/r_meanrev.json`.
|
||||
#[cfg(test)]
|
||||
const R_MEANREV_WINDOW: i64 = 3;
|
||||
|
||||
/// The Bollinger band half-width (in sigma) for the canonical closed r_meanrev
|
||||
/// example. Single source alongside [`R_MEANREV_WINDOW`].
|
||||
#[cfg(test)]
|
||||
const R_MEANREV_BAND_K: f64 = 2.0;
|
||||
|
||||
/// The EWMA Bollinger-band mean-reversion signal leg, carved out of the retired fused
|
||||
/// builder as a pure `price→bias` Composite so it serialises as blueprint data (#159
|
||||
/// cut 3). Verbatim signal computation vs that retired fused builder, except the band
|
||||
/// half-width `band_k*sigma` uses `Scale` (a rosterable multiply) in place of
|
||||
/// `LinComb(1)`. `#[cfg(test)]`: its only role is regenerating + pinning the examples;
|
||||
/// production loads the shipped JSON.
|
||||
#[cfg(test)]
|
||||
fn r_meanrev_signal(window: Option<i64>, band_k: Option<f64>) -> Composite {
|
||||
let mut g = GraphBuilder::new("r_meanrev_signal");
|
||||
let (mut mean_b, mut var_b) =
|
||||
(Ema::builder().named("mean_window"), Ema::builder().named("var_window"));
|
||||
if let Some(n) = window {
|
||||
@@ -2949,59 +2864,31 @@ fn r_meanrev_graph(
|
||||
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 sigma = g.add(Sqrt::builder());
|
||||
let mut band_b = Scale::builder().named("band");
|
||||
if let Some(k) = band_k {
|
||||
band_b = band_b.bind("factor", Scalar::f64(k));
|
||||
}
|
||||
let band = g.add(band_b);
|
||||
let upper = g.add(Add::builder());
|
||||
let lower = g.add(Sub::builder());
|
||||
let gt_hi = g.add(Gt::builder());
|
||||
let gt_lo = g.add(Gt::builder());
|
||||
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 (the standard SimBroker + RiskExecutor + recorders branch).
|
||||
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 exposure = g.add(Sub::builder()); // long_latch - short_latch -> bias
|
||||
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.feed(price, [mean.input("series"), dev.input("lhs"), gt_hi.input("a"), gt_lo.input("b")]);
|
||||
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(dev.output("value"), sq.input("rhs"));
|
||||
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(sigma.output("value"), band.input("signal"));
|
||||
g.connect(mean.output("value"), upper.input("lhs"));
|
||||
g.connect(band.output("value"), upper.input("rhs")); // upper = mean + k*sigma
|
||||
g.connect(band.output("value"), upper.input("rhs"));
|
||||
g.connect(mean.output("value"), lower.input("lhs"));
|
||||
g.connect(band.output("value"), lower.input("rhs")); // lower = mean - k*sigma
|
||||
g.connect(band.output("value"), lower.input("rhs"));
|
||||
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"));
|
||||
@@ -3010,54 +2897,8 @@ fn r_meanrev_graph(
|
||||
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("r_meanrev wiring resolves")
|
||||
}
|
||||
|
||||
/// Persist a r-sma run's taps: equity (off the SimBroker), exposure (off the Bias),
|
||||
/// r_equity = cum_realized_r + unrealized_r (off the RiskExecutor), and — only on a cost
|
||||
/// run — net_r_equity (gross r_equity minus the cost-in-R taps). Separate from the two-tap
|
||||
/// `persist_traces` so the pip handlers stay byte-unchanged on disk; the `net_r_equity` tap
|
||||
/// is emitted only when `net_rows` is non-empty, so a no-cost run's on-disk trace set is
|
||||
/// byte-unchanged too.
|
||||
fn persist_traces_r(
|
||||
name: &str,
|
||||
manifest: &RunManifest,
|
||||
eq_rows: &[(Timestamp, Vec<Scalar>)],
|
||||
ex_rows: &[(Timestamp, Vec<Scalar>)],
|
||||
req_rows: &[(Timestamp, Vec<Scalar>)],
|
||||
net_rows: &[(Timestamp, Vec<Scalar>)],
|
||||
env: &project::Env,
|
||||
) {
|
||||
let mut taps = vec![
|
||||
ColumnarTrace::from_rows("equity", &[ScalarKind::F64], eq_rows),
|
||||
ColumnarTrace::from_rows("exposure", &[ScalarKind::F64], ex_rows),
|
||||
ColumnarTrace::from_rows("r_equity", &[ScalarKind::F64], req_rows),
|
||||
];
|
||||
if !net_rows.is_empty() {
|
||||
taps.push(ColumnarTrace::from_rows("net_r_equity", &[ScalarKind::F64], net_rows));
|
||||
}
|
||||
if let Err(e) = env.trace_store().write(name, manifest, &taps) {
|
||||
eprintln!("aura: trace persist failed: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
g.expose(exposure.output("value"), "bias");
|
||||
g.build().expect("r_meanrev signal wiring resolves")
|
||||
}
|
||||
|
||||
/// Which built-in harness `aura run` drives. A fixed compile-time enumeration over
|
||||
@@ -3328,7 +3169,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-meanrev (default sma).
|
||||
/// Built-in strategy: sma | momentum | r-sma (default sma).
|
||||
#[arg(long)]
|
||||
strategy: Option<String>,
|
||||
/// Real instrument symbol to sweep over (recorded data); omit for the synthetic stream.
|
||||
@@ -3346,24 +3187,6 @@ struct SweepCmd {
|
||||
/// Family name that also persists each member's taps (mutually exclusive with --name).
|
||||
#[arg(long)]
|
||||
trace: Option<String>,
|
||||
/// Fast-MA grid axis (comma-separated values).
|
||||
#[arg(long)]
|
||||
fast: Option<String>,
|
||||
/// Slow-MA grid axis (comma-separated values).
|
||||
#[arg(long)]
|
||||
slow: Option<String>,
|
||||
/// Stop-length grid axis (comma-separated values).
|
||||
#[arg(long)]
|
||||
stop_length: Option<String>,
|
||||
/// Stop-k grid axis (comma-separated values).
|
||||
#[arg(long)]
|
||||
stop_k: Option<String>,
|
||||
/// Mean-reversion window grid axis (comma-separated values; r-meanrev).
|
||||
#[arg(long)]
|
||||
window: Option<String>,
|
||||
/// Mean-reversion band-k grid axis (comma-separated values; r-meanrev).
|
||||
#[arg(long)]
|
||||
band_k: Option<String>,
|
||||
/// Blueprint sweep axis `<name>=<csv>` (repeatable; .json mode).
|
||||
#[arg(long)]
|
||||
axis: Vec<String>,
|
||||
@@ -3376,7 +3199,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-meanrev (default sma).
|
||||
/// Built-in strategy: sma | momentum | r-sma (default sma).
|
||||
#[arg(long)]
|
||||
strategy: Option<String>,
|
||||
/// Real instrument symbol to validate over (recorded data); omit for the synthetic stream.
|
||||
@@ -3648,7 +3471,6 @@ fn generalize_args_from(
|
||||
slow: vec![a.slow.ok_or_else(knobs)?],
|
||||
stop_length: vec![a.stop_length.ok_or_else(knobs)?],
|
||||
stop_k: vec![a.stop_k.ok_or_else(knobs)?],
|
||||
..RGrid::default()
|
||||
};
|
||||
let metric = a.metric.clone().unwrap_or_else(|| "expectancy_r".to_string());
|
||||
let name = a.name.clone().unwrap_or_else(|| "generalize".to_string());
|
||||
@@ -3674,12 +3496,11 @@ fn data_choice_from(
|
||||
}
|
||||
|
||||
/// Map a built-in `--strategy` token to a `Strategy` (default sma), reusing the old
|
||||
/// four-way parse arms; an unknown token is a usage error.
|
||||
/// parse arms; an unknown token is a usage error.
|
||||
fn strategy_from(s: Option<&str>, usage: &impl Fn() -> String) -> Result<Strategy, String> {
|
||||
Ok(match s {
|
||||
None | Some("sma") => Strategy::SmaCross,
|
||||
Some("momentum") => Strategy::Momentum,
|
||||
Some("r-meanrev") => Strategy::RMeanRev,
|
||||
Some(_) => return Err(usage()),
|
||||
})
|
||||
}
|
||||
@@ -3931,14 +3752,7 @@ fn dispatch_sweep(a: SweepCmd, env: &project::Env) {
|
||||
std::process::exit(2);
|
||||
}
|
||||
// A built-in-only flag with a blueprint file is not in this grammar.
|
||||
if a.strategy.is_some()
|
||||
|| a.fast.is_some()
|
||||
|| a.slow.is_some()
|
||||
|| a.stop_length.is_some()
|
||||
|| a.stop_k.is_some()
|
||||
|| a.window.is_some()
|
||||
|| a.band_k.is_some()
|
||||
{
|
||||
if a.strategy.is_some() {
|
||||
eprintln!("aura: {}", usage());
|
||||
std::process::exit(2);
|
||||
}
|
||||
@@ -4066,7 +3880,7 @@ fn dispatch_sweep(a: SweepCmd, env: &project::Env) {
|
||||
}
|
||||
}
|
||||
None => {
|
||||
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();
|
||||
let usage = || "Usage: aura sweep [--strategy <sma|momentum|r-sma>] [--real <SYMBOL> [--from <ms>] [--to <ms>]] [--name <n> | --trace <n>]".to_string();
|
||||
if a.blueprint.is_some() || !a.axis.is_empty() || a.list_axes {
|
||||
eprintln!("aura: {}", usage());
|
||||
std::process::exit(2);
|
||||
@@ -4075,17 +3889,6 @@ fn dispatch_sweep(a: SweepCmd, env: &project::Env) {
|
||||
eprintln!("aura: {m}");
|
||||
std::process::exit(2);
|
||||
});
|
||||
let mut grid = RGrid::default();
|
||||
let bad = |m: String| -> ! {
|
||||
eprintln!("aura: {m}");
|
||||
std::process::exit(2)
|
||||
};
|
||||
if let Some(v) = a.fast.as_deref() { grid.fast = parse_csv_list(v).unwrap_or_else(|()| bad(usage())); }
|
||||
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.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)
|
||||
.unwrap_or_else(|m| {
|
||||
eprintln!("aura: {m}");
|
||||
@@ -4095,7 +3898,7 @@ fn dispatch_sweep(a: SweepCmd, env: &project::Env) {
|
||||
eprintln!("aura: {m}");
|
||||
std::process::exit(2);
|
||||
});
|
||||
run_sweep(strategy, &name, persist, DataSource::from_choice(data, env), &grid, env);
|
||||
run_sweep(strategy, &name, persist, DataSource::from_choice(data, env), env);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4143,7 +3946,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-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>] [--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);
|
||||
@@ -4449,6 +4252,114 @@ mod tests {
|
||||
assert_eq!(via_file.metrics, via_carve.metrics, "loaded example grades identically to the carve");
|
||||
}
|
||||
|
||||
/// Regenerates the shipped r_meanrev examples from the carved signal. Run by hand:
|
||||
/// `cargo test --bin aura emit_r_meanrev_examples -- --ignored`. The examples are
|
||||
/// green-by-construction (serialised from the builder, never hand-authored).
|
||||
#[test]
|
||||
#[ignore = "regenerates crates/aura-cli/examples/r_meanrev{,_open}.json; run by hand"]
|
||||
fn emit_r_meanrev_examples() {
|
||||
std::fs::create_dir_all("examples").expect("examples dir");
|
||||
std::fs::write(
|
||||
"examples/r_meanrev.json",
|
||||
blueprint_to_json(&r_meanrev_signal(Some(R_MEANREV_WINDOW), Some(R_MEANREV_BAND_K)))
|
||||
.expect("serialize closed r_meanrev"),
|
||||
)
|
||||
.expect("write examples/r_meanrev.json");
|
||||
std::fs::write(
|
||||
"examples/r_meanrev_open.json",
|
||||
blueprint_to_json(&r_meanrev_signal(None, None)).expect("serialize open r_meanrev"),
|
||||
)
|
||||
.expect("write examples/r_meanrev_open.json");
|
||||
}
|
||||
|
||||
/// The shipped examples are a faithful serialisation of the carved signal (#159 cut 3):
|
||||
/// re-serialising the carve equals the checked-in bytes. Green-by-construction with the
|
||||
/// emitter; a drift between builder and file breaks it.
|
||||
#[test]
|
||||
fn shipped_r_meanrev_examples_serialize_the_carved_signal() {
|
||||
assert_eq!(
|
||||
blueprint_to_json(&r_meanrev_signal(Some(R_MEANREV_WINDOW), Some(R_MEANREV_BAND_K))).expect("closed"),
|
||||
include_str!("../examples/r_meanrev.json"),
|
||||
);
|
||||
assert_eq!(
|
||||
blueprint_to_json(&r_meanrev_signal(None, None)).expect("open"),
|
||||
include_str!("../examples/r_meanrev_open.json"),
|
||||
);
|
||||
}
|
||||
|
||||
/// The shipped closed example, reloaded through the data plane and run, grades
|
||||
/// bit-identically to running the carved signal directly (#159 cut 3). This is
|
||||
/// r_meanrev's durable equivalence anchor after the fused builder retires: it pins
|
||||
/// that `examples/r_meanrev.json` still produces the carve's grade, with no
|
||||
/// hardcoded golden. `Composite` is `!Clone`, so the example is loaded twice.
|
||||
#[test]
|
||||
fn r_meanrev_example_loaded_runs_identically_to_the_carved_signal() {
|
||||
let env = project::Env::std();
|
||||
let loaded = blueprint_from_json(include_str!("../examples/r_meanrev.json"), &|t| std_vocabulary(t))
|
||||
.expect("shipped r_meanrev example loads");
|
||||
let via_file = run_signal_r(loaded, &[], RunData::Synthetic, 0, &env);
|
||||
let via_carve = run_signal_r(
|
||||
r_meanrev_signal(Some(R_MEANREV_WINDOW), Some(R_MEANREV_BAND_K)),
|
||||
&[],
|
||||
RunData::Synthetic,
|
||||
0,
|
||||
&env,
|
||||
);
|
||||
assert_eq!(via_file.metrics, via_carve.metrics, "loaded example grades identically to the carve");
|
||||
}
|
||||
|
||||
/// Independently pins the shipped `r_meanrev_signal` carve's FADE direction —
|
||||
/// short (-1) above the band, long (+1) below — using the Scale-based band it
|
||||
/// actually ships with. The equivalence anchor above only proves the loaded
|
||||
/// example agrees with the carve; both sides run the same `r_meanrev_signal`, so
|
||||
/// it is tautological on carve correctness (a mis-wired or sign-inverted carve
|
||||
/// would fail identically on both sides and still pass). The retired
|
||||
/// `r_meanrev_graph` builder (LinComb-based) that once graded against this exact
|
||||
/// polarity is deleted (#159 cut 3); `aura-engine`'s `r_meanrev_e2e` survives but
|
||||
/// still hand-builds the band with `LinComb(1)`, not `Scale`, so it does not
|
||||
/// exercise the node this carve actually ships. `k = 0` collapses the band to the
|
||||
/// lagging EWMA mean, isolating direction + latch from the sigma threshold (same
|
||||
/// idiom as `r_meanrev_e2e`'s own k=0 case); window 3 (alpha = 0.5) lags the level
|
||||
/// clearly. `wrap_r`'s `ex` tap reads `sig.output("bias")` directly (before any
|
||||
/// broker/exec/cost machinery), so `rx_ex` carries the raw signal bias.
|
||||
#[test]
|
||||
fn r_meanrev_signal_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 = wrap_r(
|
||||
r_meanrev_signal(Some(3), Some(0.0)),
|
||||
tx_eq,
|
||||
tx_ex,
|
||||
tx_r,
|
||||
tx_req,
|
||||
StopRule::Vol { length: 3, k: 2.0 },
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.compile_with_params(&[])
|
||||
.expect("r-meanrev signal wraps to a valid harness");
|
||||
let mut h = Harness::bootstrap(flat).expect("r-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:?}");
|
||||
}
|
||||
|
||||
/// 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.
|
||||
@@ -5331,46 +5242,6 @@ mod tests {
|
||||
assert_eq!(a, b, "C1: the momentum family is a pure function of the build");
|
||||
}
|
||||
|
||||
/// Property: the *shipped* `r_meanrev_graph` (the CLI compile unit, not
|
||||
/// 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 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
|
||||
/// 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 r_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 = r_meanrev_graph(tx_eq, tx_ex, tx_r, tx_req, Some(3), 0.0, 3, 2.0, false)
|
||||
.compile_with_params(&[])
|
||||
.expect("r-meanrev blueprint compiles");
|
||||
let mut h = Harness::bootstrap(flat).expect("r-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:?}");
|
||||
}
|
||||
|
||||
/// Property: a `blueprint_sweep_family` member built from a serialized signal is
|
||||
/// the SAME trading result as the cycle-1 single run of that signal at the same
|
||||
/// params — the loaded-blueprint sweep reuses the identical `wrap_r` run path
|
||||
|
||||
@@ -2080,126 +2080,6 @@ fn run_unknown_harness_exits_two() {
|
||||
assert_eq!(out.status.code(), Some(2));
|
||||
}
|
||||
|
||||
/// Extract the balanced `"metrics":{...}` object substring from one stdout member
|
||||
/// line. The `family_id` prefix legitimately differs between a no-trace sweep
|
||||
/// (`sweep-0`) and a `--trace n` sweep (`n-0`), so an equivalence check must
|
||||
/// compare only the `metrics` block (the reduced/raw output under test), not the
|
||||
/// whole line. Brace-balanced scan, not a JSON parser (no serde dep in this test
|
||||
/// crate); the member lines are flat enough for this to be exact.
|
||||
fn metrics_object(line: &str) -> &str {
|
||||
let key = "\"metrics\":";
|
||||
let start = line.find(key).expect("member line has a metrics block") + key.len();
|
||||
let bytes = line.as_bytes();
|
||||
assert_eq!(bytes[start], b'{', "metrics value must be an object: {line}");
|
||||
let mut depth = 0usize;
|
||||
for (i, &b) in bytes[start..].iter().enumerate() {
|
||||
match b {
|
||||
b'{' => depth += 1,
|
||||
b'}' => {
|
||||
depth -= 1;
|
||||
if depth == 0 {
|
||||
return &line[start..start + i + 1];
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
panic!("unbalanced metrics object: {line}")
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// 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_r_meanrev_folded_no_trace_metrics_equal_raw_trace_metrics() {
|
||||
let cwd = temp_cwd("sweep-r-meanrev-fold-vs-raw");
|
||||
|
||||
let folded = Command::new(BIN)
|
||||
.args(["sweep", "--strategy", "r-meanrev", "--window", "3"])
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("spawn folded (no-trace) r-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", "r-meanrev", "--window", "3", "--trace", "m1"])
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("spawn raw (--trace) r-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 `r_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_r_meanrev_grids_one_member_per_window() {
|
||||
let cwd = temp_cwd("sweep-r-meanrev-window-grid");
|
||||
let out = Command::new(BIN)
|
||||
.args(["sweep", "--strategy", "r-meanrev", "--window", "3,4"])
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("spawn 2-window r-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);
|
||||
}
|
||||
|
||||
/// Property: the bare `aura walkforward` (default SMA-cross) summary is preserved
|
||||
/// byte-for-byte by the new `--strategy`/grid plumbing — it still carries exactly
|
||||
/// `windows`, `stitched_total_pips`, `param_stability` and NOT the r-sma-only
|
||||
@@ -2292,6 +2172,26 @@ fn sweep_and_walkforward_reject_retired_r_breakout_token_as_unrecognized() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Property (#159 cut 3): `--strategy r-meanrev` is a fully retired token on both
|
||||
/// sweep and walkforward — the generic usage error, no special-casing.
|
||||
#[test]
|
||||
fn sweep_and_walkforward_reject_retired_r_meanrev_token_as_unrecognized() {
|
||||
for (args, verb) in [
|
||||
(&["sweep", "--strategy", "r-meanrev"][..], "sweep"),
|
||||
(&["walkforward", "--strategy", "r-meanrev"][..], "walkforward"),
|
||||
] {
|
||||
let cwd = temp_cwd("retired-r-meanrev-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), "generic usage, not special-cased: {stderr:?}");
|
||||
assert!(out.stdout.is_empty(), "no 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
|
||||
@@ -2301,7 +2201,7 @@ fn sweep_and_walkforward_reject_retired_r_breakout_token_as_unrecognized() {
|
||||
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"])
|
||||
.args(["sweep", "--strategy", "sma", "--channel", "3"])
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("spawn aura sweep --channel");
|
||||
@@ -3925,7 +3825,7 @@ fn dual_grammar_stray_positional_is_a_usage_error_not_swallowed() {
|
||||
// is_file() discriminator sends every case down the built-in branch.
|
||||
for argv in [
|
||||
&["run", "bogus"][..],
|
||||
&["sweep", "bogus", "--fast", "3"][..],
|
||||
&["sweep", "bogus", "--name", "x"][..],
|
||||
&["walkforward", "bogus", "--strategy", "sma"][..],
|
||||
&["mc", "bogus"][..],
|
||||
] {
|
||||
@@ -4254,3 +4154,37 @@ fn shipped_r_sma_example_reproduces_the_builtin_grade() {
|
||||
assert!(stdout.contains("\"total_pips\":0.34185000000002036"), "{stdout}");
|
||||
assert!(stdout.contains("\"n_trades\":3"), "{stdout}");
|
||||
}
|
||||
|
||||
/// `aura run` on the shipped closed r-meanrev example (#159 cut 3) actually loads
|
||||
/// and grades through the real, separately-linked `aura` binary — not merely
|
||||
/// in-process, as `r_meanrev_example_loaded_runs_identically_to_the_carved_signal`
|
||||
/// (main.rs) and this test's own grade-equivalence check both do by calling
|
||||
/// `run_signal_r`/`blueprint_from_json` inside the *test* binary. This is the
|
||||
/// CLI-seam companion `shipped_r_sma_example_reproduces_the_builtin_grade` has for
|
||||
/// r-sma: it pins that the `Scale`-based band, freshly added to the closed
|
||||
/// vocabulary this iteration, resolves and runs cleanly through the production
|
||||
/// vocabulary lookup + arg-parsing path a real invocation takes, not just through
|
||||
/// the test harness's direct function calls. The synthetic stream never crosses
|
||||
/// the band at this window/k, so the grade is genuinely all-zero (no trade) —
|
||||
/// still a deterministic, meaningful pin: a regression that broke `Scale`'s CLI
|
||||
/// wiring (e.g. a roster/registration mismatch) would surface as a non-zero exit
|
||||
/// or a load error here, not as a metrics drift.
|
||||
#[test]
|
||||
fn shipped_r_meanrev_example_runs_end_to_end_via_aura_run() {
|
||||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||||
.args(["run", "examples/r_meanrev.json"])
|
||||
.output()
|
||||
.expect("spawn aura run example");
|
||||
assert_eq!(
|
||||
out.status.code(),
|
||||
Some(0),
|
||||
"exit: {:?} stderr={}",
|
||||
out.status,
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
let stdout = String::from_utf8(out.stdout).expect("utf-8");
|
||||
assert!(stdout.contains("\"topology_hash\":\""), "manifest carries the topology hash: {stdout}");
|
||||
assert!(stdout.contains("\"expectancy_r\":0.0"), "{stdout}");
|
||||
assert!(stdout.contains("\"n_trades\":0"), "{stdout}");
|
||||
assert!(stdout.contains("\"total_pips\":0.0"), "{stdout}");
|
||||
}
|
||||
|
||||
@@ -121,8 +121,8 @@ fn graph_introspect_vocabulary_lists_exactly_the_closed_roster_count() {
|
||||
let lines: Vec<&str> = stdout.lines().filter(|l| !l.is_empty()).collect();
|
||||
assert_eq!(
|
||||
lines.len(),
|
||||
22,
|
||||
"the std-only (no project) vocabulary has exactly the roster's 22 entries: {stdout}"
|
||||
23,
|
||||
"the std-only (no project) vocabulary has exactly the roster's 23 entries: {stdout}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -756,3 +756,28 @@ fn shipped_r_breakout_open_example_lists_its_axis_namespace() {
|
||||
"the raw open params, in order, from the public gallery copy"
|
||||
);
|
||||
}
|
||||
|
||||
/// Property (#159 cut 3): the shipped closed example (`examples/r_meanrev.json`) is
|
||||
/// genuinely closed — `graph introspect --params` reports zero unbound params.
|
||||
#[test]
|
||||
fn shipped_r_meanrev_example_is_genuinely_closed() {
|
||||
let dir = temp_cwd("r-meanrev-example-closed-params");
|
||||
let (stdout, stderr, code) =
|
||||
run_in(&dir, &["graph", "introspect", "--params", &example("r_meanrev.json")]);
|
||||
assert_eq!(code, Some(0), "stdout: {stdout} stderr: {stderr}");
|
||||
assert_eq!(stdout, "", "the closed example must leave zero params unbound");
|
||||
}
|
||||
|
||||
/// Property (#159 cut 3): the shipped open example (`examples/r_meanrev_open.json`)
|
||||
/// lists its raw axis namespace, in lowering (node-add) order.
|
||||
#[test]
|
||||
fn shipped_r_meanrev_open_example_lists_its_axis_namespace() {
|
||||
let dir = temp_cwd("r-meanrev-example-open-params");
|
||||
let (stdout, stderr, code) =
|
||||
run_in(&dir, &["graph", "introspect", "--params", &example("r_meanrev_open.json")]);
|
||||
assert_eq!(code, Some(0), "stdout: {stdout} stderr: {stderr}");
|
||||
assert_eq!(
|
||||
stdout, "mean_window.length:I64\nvar_window.length:I64\nband.factor:F64\n",
|
||||
"the raw open params, in lowering order"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ mod recorder;
|
||||
mod resample;
|
||||
mod rolling_max;
|
||||
mod rolling_min;
|
||||
mod scale;
|
||||
mod series_reducer;
|
||||
mod session;
|
||||
mod sim_broker;
|
||||
@@ -75,6 +76,7 @@ pub use recorder::Recorder;
|
||||
pub use resample::Resample;
|
||||
pub use rolling_max::RollingMax;
|
||||
pub use rolling_min::RollingMin;
|
||||
pub use scale::Scale;
|
||||
pub use series_reducer::SeriesReducer;
|
||||
pub use session::Session;
|
||||
pub use sim_broker::SimBroker;
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
//! `Scale` — a stateless `f64 -> f64` multiply-by-constant: `out = input * factor`.
|
||||
//! The standard scalar-gain operator (the `*` is fixed by the type; `factor` is the
|
||||
//! only knob). One f64 input, one f64 output, allocation-free on the hot path.
|
||||
use aura_core::{
|
||||
Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec, PrimitiveBuilder,
|
||||
ScalarKind,
|
||||
};
|
||||
|
||||
/// Stateless scalar gain: emits `input * factor` each cycle. Emits `None` until its
|
||||
/// input is present (warm-up filter, C8).
|
||||
pub struct Scale {
|
||||
factor: f64,
|
||||
out: [Cell; 1],
|
||||
}
|
||||
|
||||
impl Scale {
|
||||
/// Build a gain node that multiplies its input by `factor`.
|
||||
pub fn new(factor: f64) -> Self {
|
||||
Self { factor, out: [Cell::from_f64(0.0)] }
|
||||
}
|
||||
|
||||
/// The param-generic recipe for a blueprint primitive: declares `factor` and
|
||||
/// builds through `Scale::new` (the slice is kind-checked before `build` runs).
|
||||
pub fn builder() -> PrimitiveBuilder {
|
||||
PrimitiveBuilder::new(
|
||||
"Scale",
|
||||
NodeSchema {
|
||||
inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "signal".into() }],
|
||||
output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }],
|
||||
params: vec![ParamSpec { name: "factor".into(), kind: ScalarKind::F64 }],
|
||||
},
|
||||
|p| Box::new(Scale::new(p[0].f64())),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Node for Scale {
|
||||
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; // not yet warmed up (C8 filter)
|
||||
}
|
||||
self.out[0] = Cell::from_f64(w[0] * self.factor);
|
||||
Some(&self.out)
|
||||
}
|
||||
|
||||
fn label(&self) -> String {
|
||||
format!("Scale({})", self.factor)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use aura_core::{AnyColumn, Scalar, Timestamp};
|
||||
|
||||
#[test]
|
||||
fn scale_multiplies_input_by_factor() {
|
||||
let mut node = Scale::new(2.0);
|
||||
let mut inputs = vec![AnyColumn::with_capacity(ScalarKind::F64, node.lookbacks()[0])];
|
||||
inputs[0].push(Scalar::f64(3.0)).unwrap();
|
||||
assert_eq!(node.eval(Ctx::new(&inputs, Timestamp(0))), Some([Cell::from_f64(6.0)].as_slice()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scale_is_none_until_input_present() {
|
||||
let mut node = Scale::new(2.0);
|
||||
let inputs = vec![AnyColumn::with_capacity(ScalarKind::F64, 1)];
|
||||
assert_eq!(node.eval(Ctx::new(&inputs, Timestamp(0))), None);
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
use crate::{
|
||||
Add, And, Bias, CarryCost, ConstantCost, Delay, Ema, EqConst, FixedStop, Gt, Latch, LongOnly,
|
||||
Mul, PositionManagement, Resample, RollingMax, RollingMin, Sizer, Sma, Sqrt, Sub,
|
||||
Mul, PositionManagement, Resample, RollingMax, RollingMin, Scale, Sizer, Sma, Sqrt, Sub,
|
||||
VolSlippageCost,
|
||||
};
|
||||
use aura_core::PrimitiveBuilder;
|
||||
@@ -75,6 +75,7 @@ std_vocabulary_roster! {
|
||||
"Resample" => Resample,
|
||||
"RollingMax" => RollingMax,
|
||||
"RollingMin" => RollingMin,
|
||||
"Scale" => Scale,
|
||||
"Sizer" => Sizer,
|
||||
"SMA" => Sma,
|
||||
"Sqrt" => Sqrt,
|
||||
@@ -123,7 +124,7 @@ mod tests {
|
||||
assert!(!std_vocabulary_types().contains(&"LinComb")); // construction-arg node
|
||||
assert!(!std_vocabulary_types().contains(&"Recorder")); // sink
|
||||
assert!(!std_vocabulary_types().contains(&"nope"));
|
||||
// count guard: pins the roster at exactly 22 entries
|
||||
assert_eq!(std_vocabulary_types().len(), 22);
|
||||
// count guard: pins the roster at exactly 23 entries
|
||||
assert_eq!(std_vocabulary_types().len(), 23);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user