feat(chart): aura chart <family> overlays a family's members; --tap + write-guard
`aura chart <name>` now branches on the name's on-disk kind: a single run charts
its taps as before; a FAMILY overlays one tap (default equity) across all its
members in one self-contained uPlot page, consistent across sweep / MC /
walk-forward. Members share ONE y-scale (they measure one quantity — that is what
makes the overlay comparable, unlike the single-run overlay of different taps),
and align on the union-ts spine via the existing join_on_ts: sweep/MC members
share the window (true overlay), walk-forward members are disjoint OOS windows
(null-complementary -> the stitched curve). One mechanism, three correct readings.
- build_comparison_chart_data (one labelled Series per member, shared y_scale_id);
filter_to_tap (single-run --tap restricts to one tap; no --tap = all taps,
unchanged); parse_chart_args (`<name> [--tap <t>] [--panels]`, any order);
emit_chart name-kind branch; USAGE updated.
- Write-guard: ensure_name_free is now called once per tracing command (run /
run --macd / run --real -> Run; sweep / mc / walkforward -> Family) so a name
cannot be reused across kinds — name resolution stays a total function.
Refuse-don't-guess throughout (exit 2, never panic). Engine untouched (C9/C14).
Tasks 2-3 of plan 0061; Task 1 (registry) landed in 9b2adcb.
Verified: cargo test --workspace green (no failures; new unit + integration tests
incl. family-overlay, single-run --tap filter, cross-kind collision RED-then-green),
cargo clippy --workspace --all-targets -D warnings clean, cargo build --workspace
clean. Benign clippy-forced deltas vs the literal plan (no behaviour change): a
local MemberRows type alias (type_complexity), let-chains in the six guards
(collapsible_if), and dropping the now-dead TraceStoreError import.
closes #107
This commit is contained in:
+277
-18
@@ -23,7 +23,8 @@ use aura_engine::{
|
|||||||
};
|
};
|
||||||
use aura_registry::{
|
use aura_registry::{
|
||||||
group_families, mc_member_reports, optimize, rank_by, sweep_member_reports,
|
group_families, mc_member_reports, optimize, rank_by, sweep_member_reports,
|
||||||
walkforward_member_reports, FamilyKind, Registry, RunTraces, TraceStore, TraceStoreError,
|
walkforward_member_reports, FamilyKind, FamilyMember, NameKind, Registry, RunTraces, TraceStore,
|
||||||
|
WriteKind,
|
||||||
};
|
};
|
||||||
use aura_std::{Ema, Exposure, LinComb, LongOnly, Recorder, SimBroker, Sma, Sub};
|
use aura_std::{Ema, Exposure, LinComb, LongOnly, Recorder, SimBroker, Sma, Sub};
|
||||||
use std::sync::mpsc::{self, Receiver};
|
use std::sync::mpsc::{self, Receiver};
|
||||||
@@ -271,28 +272,130 @@ fn build_chart_data(traces: RunTraces) -> ChartData {
|
|||||||
ChartData { xs, series }
|
ChartData { xs, series }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `aura chart <name> [--panels]`: read the persisted run's traces, align them, and
|
/// One member's contribution to the comparison build: its key (the future series
|
||||||
/// print the self-contained uPlot page. A missing run is a usage error (stderr +
|
/// name) paired with the chosen tap's drained `(ts, row)` pairs.
|
||||||
/// exit 2), per the CLI's convention — never a panic.
|
type MemberRows = (String, Vec<(Timestamp, Vec<Scalar>)>);
|
||||||
fn emit_chart(name: &str, mode: ChartMode) {
|
|
||||||
let traces = match TraceStore::open("runs").read(name) {
|
/// Build the comparison `ChartData` for a family: one `Series` per member (the
|
||||||
Ok(t) => t,
|
/// chosen `tap`'s column), labelled by `member.key`, ALL sharing ONE `y_scale_id`
|
||||||
Err(TraceStoreError::NotFound(n)) => {
|
/// (the members measure one identical quantity, so a shared scale is what makes
|
||||||
eprintln!("aura: no recorded run '{n}' under runs/traces (run `aura run --trace {n}` first)");
|
/// them comparable — unlike the single-run overlay, whose series are different
|
||||||
|
/// taps). Aligned on the union-ts spine via the same `join_on_ts` build_chart_data
|
||||||
|
/// uses. `Err` if NO member carries `tap` (refuse-don't-guess).
|
||||||
|
fn build_comparison_chart_data(
|
||||||
|
members: &[FamilyMember],
|
||||||
|
tap: &str,
|
||||||
|
) -> Result<ChartData, String> {
|
||||||
|
let mut member_rows: Vec<MemberRows> = Vec::new();
|
||||||
|
for m in members {
|
||||||
|
if let Some(t) = m.traces.taps.iter().find(|t| t.tap == tap) {
|
||||||
|
member_rows.push((m.key.clone(), t.to_rows()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if member_rows.is_empty() {
|
||||||
|
return Err(format!("no family member has a tap named '{tap}'"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut xs: Vec<i64> =
|
||||||
|
member_rows.iter().flat_map(|(_, r)| r.iter().map(|(t, _)| t.0)).collect();
|
||||||
|
xs.sort_unstable();
|
||||||
|
xs.dedup();
|
||||||
|
|
||||||
|
let spine: Vec<(Timestamp, Vec<Scalar>)> =
|
||||||
|
xs.iter().map(|&t| (Timestamp(t), Vec::new())).collect();
|
||||||
|
let sides: Vec<&[(Timestamp, Vec<Scalar>)]> =
|
||||||
|
member_rows.iter().map(|(_, r)| r.as_slice()).collect();
|
||||||
|
let joined: Vec<JoinedRow> = join_on_ts(&spine, &sides);
|
||||||
|
|
||||||
|
// One shared y-scale across all member series (same quantity).
|
||||||
|
let y_scale_id = format!("y_cmp_{tap}");
|
||||||
|
let mut series: Vec<Series> = Vec::new();
|
||||||
|
for (i, (key, _)) in member_rows.iter().enumerate() {
|
||||||
|
// Project column 0 — the doc's "chosen tap's column" (singular). The
|
||||||
|
// comparison taps in scope (equity / exposure) are single-column `f64`.
|
||||||
|
// A future multi-column tap selection would need a column index here.
|
||||||
|
let points: Vec<Option<f64>> =
|
||||||
|
joined.iter().map(|r| r.sides[i].as_ref().map(|row| row[0].as_f64())).collect();
|
||||||
|
series.push(Series { name: key.clone(), y_scale_id: y_scale_id.clone(), points });
|
||||||
|
}
|
||||||
|
Ok(ChartData { xs, series })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restrict a single-run `ChartData` to the one series named `tap`. `Err` if the
|
||||||
|
/// run has no such tap (refuse-don't-guess). Used by the `--tap` flag on the
|
||||||
|
/// single-run chart path; without `--tap` the single-run page is unchanged.
|
||||||
|
fn filter_to_tap(data: ChartData, tap: &str) -> Result<ChartData, String> {
|
||||||
|
let series: Vec<Series> = data.series.into_iter().filter(|s| s.name == tap).collect();
|
||||||
|
if series.is_empty() {
|
||||||
|
return Err(format!("run has no tap named '{tap}'"));
|
||||||
|
}
|
||||||
|
Ok(ChartData { xs: data.xs, series })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `aura chart <name> [--tap <t>] [--panels]`: classify the name and render. A
|
||||||
|
/// single run charts all its taps (or the one `--tap` selects); a family overlays
|
||||||
|
/// one tap (default `equity`) across its members; an unknown name is a usage error
|
||||||
|
/// (stderr + exit 2), never a panic.
|
||||||
|
fn emit_chart(name: &str, tap: Option<&str>, mode: ChartMode) {
|
||||||
|
let store = TraceStore::open("runs");
|
||||||
|
match store.name_kind(name) {
|
||||||
|
NameKind::Run => {
|
||||||
|
let traces = match store.read(name) {
|
||||||
|
Ok(t) => t,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("aura: {e}");
|
||||||
|
std::process::exit(2);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let mut data = build_chart_data(traces);
|
||||||
|
if let Some(t) = tap {
|
||||||
|
data = match filter_to_tap(data, t) {
|
||||||
|
Ok(d) => d,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("aura: {e}");
|
||||||
|
std::process::exit(2);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
print!("{}", render::render_chart_html(&data, mode));
|
||||||
|
}
|
||||||
|
NameKind::Family => {
|
||||||
|
let members = match store.read_family(name) {
|
||||||
|
Ok(m) => m,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("aura: {e}");
|
||||||
|
std::process::exit(2);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let data = match build_comparison_chart_data(&members, tap.unwrap_or("equity")) {
|
||||||
|
Ok(d) => d,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("aura: {e}");
|
||||||
|
std::process::exit(2);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
print!("{}", render::render_chart_html(&data, mode));
|
||||||
|
}
|
||||||
|
NameKind::NotFound => {
|
||||||
|
eprintln!(
|
||||||
|
"aura: no recorded run or family '{name}' under runs/traces \
|
||||||
|
(run `aura run --trace {name}` or `aura sweep --trace {name}` first)"
|
||||||
|
);
|
||||||
std::process::exit(2);
|
std::process::exit(2);
|
||||||
}
|
}
|
||||||
Err(e) => {
|
}
|
||||||
eprintln!("aura: {e}");
|
|
||||||
std::process::exit(2);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
print!("{}", render::render_chart_html(&build_chart_data(traces), mode));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Run the sample harness and fold it into a `RunReport` (drain both sinks →
|
/// Run the sample harness and fold it into a `RunReport` (drain both sinks →
|
||||||
/// `f64_field` → `summarize` → pair with a `RunManifest`). Pure and deterministic
|
/// `f64_field` → `summarize` → pair with a `RunManifest`). Pure and deterministic
|
||||||
/// (C1): the same build yields the same report.
|
/// (C1): the same build yields the same report.
|
||||||
fn run_sample(trace: Option<&str>) -> RunReport {
|
fn run_sample(trace: Option<&str>) -> RunReport {
|
||||||
|
if let Some(n) = trace
|
||||||
|
&& let Err(e) = TraceStore::open("runs").ensure_name_free(n, WriteKind::Run)
|
||||||
|
{
|
||||||
|
eprintln!("aura: {e}");
|
||||||
|
std::process::exit(2);
|
||||||
|
}
|
||||||
let (mut h, rx_eq, rx_ex) = sample_harness(SYNTHETIC_PIP_SIZE);
|
let (mut h, rx_eq, rx_ex) = sample_harness(SYNTHETIC_PIP_SIZE);
|
||||||
let sources: Vec<Box<dyn aura_engine::Source>> =
|
let sources: Vec<Box<dyn aura_engine::Source>> =
|
||||||
vec![Box::new(VecSource::new(synthetic_prices()))];
|
vec![Box::new(VecSource::new(synthetic_prices()))];
|
||||||
@@ -326,6 +429,12 @@ fn run_sample(trace: Option<&str>) -> RunReport {
|
|||||||
/// A no-local-data condition (unknown symbol, or a window overlapping no file / no
|
/// A no-local-data condition (unknown symbol, or a window overlapping no file / no
|
||||||
/// bars) is a usage error: stderr + exit(2), not a panic.
|
/// bars) is a usage error: stderr + exit(2), not a panic.
|
||||||
fn run_sample_real(symbol: &str, from_ms: Option<i64>, to_ms: Option<i64>, trace: Option<&str>) -> RunReport {
|
fn run_sample_real(symbol: &str, from_ms: Option<i64>, to_ms: Option<i64>, trace: Option<&str>) -> RunReport {
|
||||||
|
if let Some(n) = trace
|
||||||
|
&& let Err(e) = TraceStore::open("runs").ensure_name_free(n, WriteKind::Run)
|
||||||
|
{
|
||||||
|
eprintln!("aura: {e}");
|
||||||
|
std::process::exit(2);
|
||||||
|
}
|
||||||
// Per-instrument pip: look up BEFORE any data access, so an un-specced symbol
|
// Per-instrument pip: look up BEFORE any data access, so an un-specced symbol
|
||||||
// refuses without touching local data. Honest by construction.
|
// refuses without touching local data. Honest by construction.
|
||||||
let spec = instrument_spec_or_refuse(symbol);
|
let spec = instrument_spec_or_refuse(symbol);
|
||||||
@@ -394,6 +503,34 @@ fn parse_real_args(
|
|||||||
Ok((symbol.to_string(), from, to, trace))
|
Ok((symbol.to_string(), from, to, trace))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Parse `aura chart` args: `<name> [--tap <t>] [--panels]` in any order.
|
||||||
|
fn parse_chart_args(rest: &[&str]) -> Result<(String, Option<String>, ChartMode), String> {
|
||||||
|
let mut name: Option<String> = None;
|
||||||
|
let mut tap: Option<String> = None;
|
||||||
|
let mut mode = ChartMode::Overlay;
|
||||||
|
let mut i = 0;
|
||||||
|
while i < rest.len() {
|
||||||
|
match rest[i] {
|
||||||
|
"--panels" => {
|
||||||
|
mode = ChartMode::Panels;
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
"--tap" => {
|
||||||
|
let t = rest.get(i + 1).ok_or("--tap needs a value")?;
|
||||||
|
tap = Some((*t).to_string());
|
||||||
|
i += 2;
|
||||||
|
}
|
||||||
|
other if !other.starts_with("--") && name.is_none() => {
|
||||||
|
name = Some(other.to_string());
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
other => return Err(format!("unexpected chart argument '{other}'")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let name = name.ok_or("chart needs a <name>")?;
|
||||||
|
Ok((name, tap, mode))
|
||||||
|
}
|
||||||
|
|
||||||
/// The shared `--real <SYMBOL> [--from <ms>] [--to <ms>]` accumulator for the
|
/// The shared `--real <SYMBOL> [--from <ms>] [--to <ms>]` accumulator for the
|
||||||
/// family parsers (`parse_sweep_args` / `parse_walkforward_args`). It holds the
|
/// family parsers (`parse_sweep_args` / `parse_walkforward_args`). It holds the
|
||||||
/// one home of the real/window grammar — flag-repeat strictness, the empty-symbol
|
/// one home of the real/window grammar — flag-repeat strictness, the empty-symbol
|
||||||
@@ -947,6 +1084,12 @@ fn mc_member_line(id: &str, seed: u64, report: &RunReport) -> String {
|
|||||||
/// carrying the assigned id. With `--trace`, also persist each member's streams
|
/// carrying the assigned id. With `--trace`, also persist each member's streams
|
||||||
/// under `runs/traces/<n>/<member_key>/` (opt-in).
|
/// under `runs/traces/<n>/<member_key>/` (opt-in).
|
||||||
fn run_sweep(strategy: Strategy, name: &str, persist: bool, data: DataSource) {
|
fn run_sweep(strategy: Strategy, name: &str, persist: bool, data: DataSource) {
|
||||||
|
if persist
|
||||||
|
&& let Err(e) = TraceStore::open("runs").ensure_name_free(name, WriteKind::Family)
|
||||||
|
{
|
||||||
|
eprintln!("aura: {e}");
|
||||||
|
std::process::exit(2);
|
||||||
|
}
|
||||||
let reg = default_registry();
|
let reg = default_registry();
|
||||||
let family = match strategy {
|
let family = match strategy {
|
||||||
Strategy::SmaCross => sweep_family(persist.then_some(name), &data),
|
Strategy::SmaCross => sweep_family(persist.then_some(name), &data),
|
||||||
@@ -973,6 +1116,12 @@ fn run_sweep(strategy: Strategy, name: &str, persist: bool, data: DataSource) {
|
|||||||
/// `--trace`, also persist each OOS member's streams under
|
/// `--trace`, also persist each OOS member's streams under
|
||||||
/// `runs/traces/<n>/oos<ns>/` (opt-in). Deterministic (C1).
|
/// `runs/traces/<n>/oos<ns>/` (opt-in). Deterministic (C1).
|
||||||
fn run_walkforward(name: &str, persist: bool, data: DataSource) {
|
fn run_walkforward(name: &str, persist: bool, data: DataSource) {
|
||||||
|
if persist
|
||||||
|
&& let Err(e) = TraceStore::open("runs").ensure_name_free(name, WriteKind::Family)
|
||||||
|
{
|
||||||
|
eprintln!("aura: {e}");
|
||||||
|
std::process::exit(2);
|
||||||
|
}
|
||||||
let reg = default_registry();
|
let reg = default_registry();
|
||||||
let result = walkforward_family(persist.then_some(name), &data);
|
let result = walkforward_family(persist.then_some(name), &data);
|
||||||
let id =
|
let id =
|
||||||
@@ -1190,6 +1339,12 @@ fn mc_aggregate_json(agg: &McAggregate) -> String {
|
|||||||
/// line (carrying the assigned `family_id`) plus the aggregate line. With `--trace`,
|
/// line (carrying the assigned `family_id`) plus the aggregate line. With `--trace`,
|
||||||
/// also persist each draw's streams under `runs/traces/<n>/seed<seed>/` (opt-in).
|
/// also persist each draw's streams under `runs/traces/<n>/seed<seed>/` (opt-in).
|
||||||
fn run_mc(name: &str, persist: bool) {
|
fn run_mc(name: &str, persist: bool) {
|
||||||
|
if persist
|
||||||
|
&& let Err(e) = TraceStore::open("runs").ensure_name_free(name, WriteKind::Family)
|
||||||
|
{
|
||||||
|
eprintln!("aura: {e}");
|
||||||
|
std::process::exit(2);
|
||||||
|
}
|
||||||
let reg = default_registry();
|
let reg = default_registry();
|
||||||
let family = mc_family(persist.then_some(name));
|
let family = mc_family(persist.then_some(name));
|
||||||
let id = match reg.append_family(name, FamilyKind::MonteCarlo, &mc_member_reports(&family)) {
|
let id = match reg.append_family(name, FamilyKind::MonteCarlo, &mc_member_reports(&family)) {
|
||||||
@@ -1361,6 +1516,12 @@ fn macd_prices() -> Vec<(Timestamp, Scalar)> {
|
|||||||
/// synthetic stream, and fold both sinks into a `RunReport`. Pure and
|
/// synthetic stream, and fold both sinks into a `RunReport`. Pure and
|
||||||
/// deterministic (C1).
|
/// deterministic (C1).
|
||||||
fn run_macd(trace: Option<&str>) -> RunReport {
|
fn run_macd(trace: Option<&str>) -> RunReport {
|
||||||
|
if let Some(n) = trace
|
||||||
|
&& let Err(e) = TraceStore::open("runs").ensure_name_free(n, WriteKind::Run)
|
||||||
|
{
|
||||||
|
eprintln!("aura: {e}");
|
||||||
|
std::process::exit(2);
|
||||||
|
}
|
||||||
let (tx_eq, rx_eq) = mpsc::channel();
|
let (tx_eq, rx_eq) = mpsc::channel();
|
||||||
let (tx_ex, rx_ex) = mpsc::channel();
|
let (tx_ex, rx_ex) = mpsc::channel();
|
||||||
let flat = macd_strategy_blueprint(tx_eq, tx_ex)
|
let flat = macd_strategy_blueprint(tx_eq, tx_ex)
|
||||||
@@ -1394,7 +1555,7 @@ fn run_macd(trace: Option<&str>) -> RunReport {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const USAGE: &str =
|
const USAGE: &str =
|
||||||
"usage: aura run [--macd] [--trace <name>] | aura run --real <SYMBOL> [--from <ms>] [--to <ms>] [--trace <name>] | aura chart <name> [--panels] | aura graph | aura sweep [--strategy <sma|momentum>] [--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 [--macd] [--trace <name>] | aura run --real <SYMBOL> [--from <ms>] [--to <ms>] [--trace <name>] | aura chart <name> [--tap <t>] [--panels] | aura graph | aura sweep [--strategy <sma|momentum>] [--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() {
|
fn main() {
|
||||||
// Collect argv and match the whole vector: every accepted form is exhaustive,
|
// Collect argv and match the whole vector: every accepted form is exhaustive,
|
||||||
@@ -1415,8 +1576,13 @@ fn main() {
|
|||||||
std::process::exit(2);
|
std::process::exit(2);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
["chart", name] => emit_chart(name, ChartMode::Overlay),
|
["chart", rest @ ..] => match parse_chart_args(rest) {
|
||||||
["chart", name, "--panels"] => emit_chart(name, ChartMode::Panels),
|
Ok((name, tap, mode)) => emit_chart(&name, tap.as_deref(), mode),
|
||||||
|
Err(msg) => {
|
||||||
|
eprintln!("aura: {msg}");
|
||||||
|
std::process::exit(2);
|
||||||
|
}
|
||||||
|
},
|
||||||
["graph"] => print!("{}", render::render_html(&sample_blueprint())),
|
["graph"] => print!("{}", render::render_html(&sample_blueprint())),
|
||||||
["sweep", rest @ ..] => match parse_sweep_args(rest) {
|
["sweep", rest @ ..] => match parse_sweep_args(rest) {
|
||||||
Ok((strategy, name, persist, choice)) => {
|
Ok((strategy, name, persist, choice)) => {
|
||||||
@@ -1454,6 +1620,54 @@ fn main() {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
fn cmp_member(key: &str, ts: &[i64], vals: &[f64]) -> FamilyMember {
|
||||||
|
let rows: Vec<(Timestamp, Vec<Scalar>)> =
|
||||||
|
ts.iter().zip(vals).map(|(&t, &v)| (Timestamp(t), vec![Scalar::f64(v)])).collect();
|
||||||
|
let tap = ColumnarTrace::from_rows("equity", &[ScalarKind::F64], &rows);
|
||||||
|
FamilyMember {
|
||||||
|
key: key.to_string(),
|
||||||
|
traces: RunTraces {
|
||||||
|
manifest: sim_optimal_manifest(vec![], (Timestamp(0), Timestamp(0)), 0, 1.0),
|
||||||
|
taps: vec![tap],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn comparison_overlays_one_shared_scale_series_per_member() {
|
||||||
|
let members = vec![
|
||||||
|
cmp_member("a", &[1, 2, 3], &[10.0, 11.0, 12.0]),
|
||||||
|
cmp_member("b", &[1, 2, 3], &[20.0, 21.0, 22.0]),
|
||||||
|
];
|
||||||
|
let data = build_comparison_chart_data(&members, "equity").expect("builds");
|
||||||
|
assert_eq!(data.xs, vec![1, 2, 3]);
|
||||||
|
assert_eq!(data.series.len(), 2);
|
||||||
|
assert_eq!(data.series[0].name, "a");
|
||||||
|
assert_eq!(data.series[1].name, "b");
|
||||||
|
// ONE shared y-scale across members (same quantity).
|
||||||
|
assert_eq!(data.series[0].y_scale_id, data.series[1].y_scale_id);
|
||||||
|
// shared ts -> dense, no nulls.
|
||||||
|
assert!(data.series[0].points.iter().all(Option::is_some));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn comparison_disjoint_members_are_null_complementary() {
|
||||||
|
let members = vec![
|
||||||
|
cmp_member("oos1", &[1, 2], &[10.0, 11.0]),
|
||||||
|
cmp_member("oos2", &[3, 4], &[20.0, 21.0]),
|
||||||
|
];
|
||||||
|
let data = build_comparison_chart_data(&members, "equity").expect("builds");
|
||||||
|
assert_eq!(data.xs, vec![1, 2, 3, 4]);
|
||||||
|
assert_eq!(data.series[0].points, vec![Some(10.0), Some(11.0), None, None]);
|
||||||
|
assert_eq!(data.series[1].points, vec![None, None, Some(20.0), Some(21.0)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn comparison_errors_when_no_member_has_the_tap() {
|
||||||
|
let members = vec![cmp_member("a", &[1], &[1.0])];
|
||||||
|
assert!(build_comparison_chart_data(&members, "nosuch").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
/// #99: a sweep/walk-forward family-member stdout line embeds the `RunReport` in
|
/// #99: a sweep/walk-forward family-member stdout line embeds the `RunReport` in
|
||||||
/// its own declaration key order (manifest leads with `commit`), byte-matching the
|
/// its own declaration key order (manifest leads with `commit`), byte-matching the
|
||||||
/// stored `families.jsonl` — never `serde_json::Value`'s alphabetical order (which
|
/// stored `families.jsonl` — never `serde_json::Value`'s alphabetical order (which
|
||||||
@@ -1896,6 +2110,51 @@ mod tests {
|
|||||||
assert!(parse_real_args(&["EURUSD", "--from", "notanumber"]).is_err());
|
assert!(parse_real_args(&["EURUSD", "--from", "notanumber"]).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `parse_chart_args` parses `<name>`, `--tap <t>`, and `--panels` in any order
|
||||||
|
/// (the loop grammar's reason for being — the name need not lead), and surfaces
|
||||||
|
/// each of its three distinct errors: `--tap` missing its value, an unexpected
|
||||||
|
/// extra argument, and a missing `<name>`. The any-order contract is the only
|
||||||
|
/// reason `main` runs a loop here instead of an exhaustive argv match, so it is
|
||||||
|
/// asserted as a property, not just exercised.
|
||||||
|
#[test]
|
||||||
|
fn parse_chart_args_accepts_any_order_and_rejects_three_ways() {
|
||||||
|
// bare name -> default tap (None) + Overlay mode
|
||||||
|
let (name, tap, mode) = parse_chart_args(&["fam"]).expect("bare name parses");
|
||||||
|
assert_eq!(name, "fam");
|
||||||
|
assert_eq!(tap, None);
|
||||||
|
assert!(matches!(mode, ChartMode::Overlay));
|
||||||
|
|
||||||
|
// name + --tap + --panels in canonical order
|
||||||
|
let (name, tap, mode) =
|
||||||
|
parse_chart_args(&["fam", "--tap", "equity", "--panels"]).expect("full parses");
|
||||||
|
assert_eq!(name, "fam");
|
||||||
|
assert_eq!(tap.as_deref(), Some("equity"));
|
||||||
|
assert!(matches!(mode, ChartMode::Panels));
|
||||||
|
|
||||||
|
// any order: flags before the name, and --panels between name and --tap
|
||||||
|
let (name, tap, mode) =
|
||||||
|
parse_chart_args(&["--tap", "equity", "fam"]).expect("flag-first parses");
|
||||||
|
assert_eq!(name, "fam");
|
||||||
|
assert_eq!(tap.as_deref(), Some("equity"));
|
||||||
|
assert!(matches!(mode, ChartMode::Overlay));
|
||||||
|
|
||||||
|
let (name, tap, mode) =
|
||||||
|
parse_chart_args(&["--panels", "fam", "--tap", "exposure"]).expect("interleaved parses");
|
||||||
|
assert_eq!(name, "fam");
|
||||||
|
assert_eq!(tap.as_deref(), Some("exposure"));
|
||||||
|
assert!(matches!(mode, ChartMode::Panels));
|
||||||
|
|
||||||
|
// three distinct error branches, by message (the Ok tuple holds a
|
||||||
|
// non-Debug ChartMode, so match the Err arm instead of unwrapping).
|
||||||
|
let err = |args: &[&str]| match parse_chart_args(args) {
|
||||||
|
Err(e) => e,
|
||||||
|
Ok(_) => panic!("expected an error for {args:?}"),
|
||||||
|
};
|
||||||
|
assert_eq!(err(&["fam", "--tap"]), "--tap needs a value");
|
||||||
|
assert_eq!(err(&["fam", "extra"]), "unexpected chart argument 'extra'");
|
||||||
|
assert_eq!(err(&["--panels"]), "chart needs a <name>");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn run_sample_is_deterministic_and_non_trivial() {
|
fn run_sample_is_deterministic_and_non_trivial() {
|
||||||
let r1 = run_sample(None);
|
let r1 = run_sample(None);
|
||||||
|
|||||||
@@ -467,6 +467,55 @@ fn chart_panels_flag_selects_panels_mode() {
|
|||||||
let _ = std::fs::remove_dir_all(&cwd);
|
let _ = std::fs::remove_dir_all(&cwd);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Property: `--tap <nonexistent>` on a single-run chart is a CLEAN refusal at the
|
||||||
|
/// CLI seam — refuse-don't-guess (C10). The builder's no-tap `Err` is unit-tested,
|
||||||
|
/// but this pins the user-facing wiring: `filter_to_tap` returning `Err` must reach
|
||||||
|
/// `eprintln!` + `exit(2)`, never a panic and never a chart of zero series. A
|
||||||
|
/// regression that swallowed the `Err` (e.g. charting an empty `ChartData`) would
|
||||||
|
/// exit 0 with an empty page; this fails it. Archive-local (persists its own run).
|
||||||
|
#[test]
|
||||||
|
fn chart_tap_nonexistent_on_run_refuses_with_exit_2() {
|
||||||
|
let cwd = temp_cwd("chart-tap-missing");
|
||||||
|
|
||||||
|
// persist a single run; its taps are `equity` / `exposure`, never `nope`.
|
||||||
|
let traced = Command::new(BIN).args(["run", "--trace", "demo"]).current_dir(&cwd).output().expect("spawn run --trace");
|
||||||
|
assert!(traced.status.success(), "run --trace exit: {:?}", traced.status);
|
||||||
|
|
||||||
|
let out = Command::new(BIN).args(["chart", "demo", "--tap", "nope"]).current_dir(&cwd).output().expect("spawn chart --tap");
|
||||||
|
assert_eq!(out.status.code(), Some(2), "nonexistent tap must exit 2: {:?}", out.status);
|
||||||
|
assert!(out.stdout.is_empty(), "the refusal must not leak a chart page to stdout, got: {:?}", String::from_utf8_lossy(&out.stdout));
|
||||||
|
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||||
|
assert!(
|
||||||
|
stderr.contains("run has no tap named 'nope'"),
|
||||||
|
"expected the no-such-tap refusal message, got: {stderr}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir_all(&cwd);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Property: an unknown `chart <name>` is a usage error pinned at BOTH the exit code
|
||||||
|
/// and the user-facing message — exit 2 (never a panic) AND the NotFound branch's
|
||||||
|
/// "no recorded run or family '<name>'" wording, which now covers families too. A
|
||||||
|
/// regression that drifted the message (e.g. back to the run-only phrasing) or
|
||||||
|
/// downgraded the exit would slip past the bundled exit-only check; this pins the
|
||||||
|
/// message/exit contract. Archive-local: charts into an empty cwd so the name is
|
||||||
|
/// genuinely absent.
|
||||||
|
#[test]
|
||||||
|
fn chart_unknown_name_refuses_with_exit_2_and_message() {
|
||||||
|
let cwd = temp_cwd("chart-unknown");
|
||||||
|
|
||||||
|
let out = Command::new(BIN).args(["chart", "ghost"]).current_dir(&cwd).output().expect("spawn chart ghost");
|
||||||
|
assert_eq!(out.status.code(), Some(2), "unknown name must exit 2: {:?}", out.status);
|
||||||
|
assert!(out.stdout.is_empty(), "the refusal must not leak a chart page to stdout, got: {:?}", String::from_utf8_lossy(&out.stdout));
|
||||||
|
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||||
|
assert!(
|
||||||
|
stderr.contains("no recorded run or family 'ghost'"),
|
||||||
|
"expected the unknown-name (run-or-family) refusal message, got: {stderr}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir_all(&cwd);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn no_args_prints_usage_and_exits_two() {
|
fn no_args_prints_usage_and_exits_two() {
|
||||||
let out = Command::new(BIN).output().expect("spawn aura");
|
let out = Command::new(BIN).output().expect("spawn aura");
|
||||||
@@ -1199,3 +1248,132 @@ fn family_window_flag_without_real_refuses_with_usage_exit_2() {
|
|||||||
let _ = std::fs::remove_dir_all(&dir);
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Property: `aura chart <family>` overlays one series per family member, labelled
|
||||||
|
/// by member key. The built-in sweep grid is fast∈{2,3} × slow∈{4,5} = 4 members;
|
||||||
|
/// each member key appears as a series name in the injected AURA_TRACES.
|
||||||
|
#[test]
|
||||||
|
fn chart_of_a_family_overlays_one_series_per_member() {
|
||||||
|
let cwd = temp_cwd("chart-family");
|
||||||
|
let swept = Command::new(BIN)
|
||||||
|
.args(["sweep", "--trace", "fam"])
|
||||||
|
.current_dir(&cwd)
|
||||||
|
.output()
|
||||||
|
.expect("spawn sweep --trace");
|
||||||
|
assert!(swept.status.success(), "sweep --trace exit: {:?}", swept.status);
|
||||||
|
|
||||||
|
let chart = Command::new(BIN).args(["chart", "fam"]).current_dir(&cwd).output().expect("spawn chart fam");
|
||||||
|
assert!(chart.status.success(), "chart family exit: {:?}", chart.status);
|
||||||
|
let html = String::from_utf8(chart.stdout).expect("utf-8 html");
|
||||||
|
assert!(html.contains("window.AURA_TRACES = {"), "AURA_TRACES not injected");
|
||||||
|
for key in [
|
||||||
|
"signals.trend.fast.length-2_signals.trend.slow.length-4",
|
||||||
|
"signals.trend.fast.length-2_signals.trend.slow.length-5",
|
||||||
|
"signals.trend.fast.length-3_signals.trend.slow.length-4",
|
||||||
|
"signals.trend.fast.length-3_signals.trend.slow.length-5",
|
||||||
|
] {
|
||||||
|
assert!(html.contains(key), "member series '{key}' missing from the family chart");
|
||||||
|
}
|
||||||
|
let _ = std::fs::remove_dir_all(&cwd);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Property: the single-run chart path is unchanged — no `--tap` shows ALL taps
|
||||||
|
/// (equity + exposure); `--tap equity` filters to one. A regression net for the
|
||||||
|
/// "single-run byte-unchanged when no --tap" acceptance bullet.
|
||||||
|
#[test]
|
||||||
|
fn chart_single_run_tap_filter_keeps_only_the_named_tap() {
|
||||||
|
let cwd = temp_cwd("chart-tap");
|
||||||
|
let traced = Command::new(BIN).args(["run", "--trace", "solo"]).current_dir(&cwd).output().expect("spawn run --trace");
|
||||||
|
assert!(traced.status.success(), "run --trace exit: {:?}", traced.status);
|
||||||
|
|
||||||
|
// no --tap: both taps present (unchanged behaviour).
|
||||||
|
let all = Command::new(BIN).args(["chart", "solo"]).current_dir(&cwd).output().expect("spawn chart");
|
||||||
|
assert!(all.status.success());
|
||||||
|
let all_html = String::from_utf8(all.stdout).expect("utf-8");
|
||||||
|
assert!(all_html.contains("\"equity\""), "equity series missing");
|
||||||
|
assert!(all_html.contains("\"exposure\""), "exposure series missing");
|
||||||
|
|
||||||
|
// --tap equity: only equity remains.
|
||||||
|
let one = Command::new(BIN).args(["chart", "solo", "--tap", "equity"]).current_dir(&cwd).output().expect("spawn chart --tap");
|
||||||
|
assert!(one.status.success(), "chart --tap exit: {:?}", one.status);
|
||||||
|
let one_html = String::from_utf8(one.stdout).expect("utf-8");
|
||||||
|
assert!(one_html.contains("\"equity\""), "equity series missing under --tap");
|
||||||
|
assert!(!one_html.contains("\"exposure\""), "--tap equity must drop exposure");
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir_all(&cwd);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Property: a trace name cannot be reused across kinds — the write-guard refuses
|
||||||
|
/// it (exit 2), keeping `aura chart <name>` resolution a total function.
|
||||||
|
#[test]
|
||||||
|
fn trace_name_collision_across_kinds_is_refused() {
|
||||||
|
let cwd = temp_cwd("collision");
|
||||||
|
|
||||||
|
// run then sweep on the same name -> the sweep is refused.
|
||||||
|
let run = Command::new(BIN).args(["run", "--trace", "t"]).current_dir(&cwd).output().expect("spawn run --trace");
|
||||||
|
assert!(run.status.success(), "run --trace exit: {:?}", run.status);
|
||||||
|
let sweep = Command::new(BIN).args(["sweep", "--trace", "t"]).current_dir(&cwd).output().expect("spawn sweep --trace");
|
||||||
|
assert_eq!(sweep.status.code(), Some(2), "sweep onto a run name must exit 2");
|
||||||
|
|
||||||
|
// reverse: sweep then run on a fresh name -> the run is refused.
|
||||||
|
let sweep2 = Command::new(BIN).args(["sweep", "--trace", "u"]).current_dir(&cwd).output().expect("spawn sweep --trace u");
|
||||||
|
assert!(sweep2.status.success(), "sweep --trace u exit: {:?}", sweep2.status);
|
||||||
|
let run2 = Command::new(BIN).args(["run", "--trace", "u"]).current_dir(&cwd).output().expect("spawn run --trace u");
|
||||||
|
assert_eq!(run2.status.code(), Some(2), "run onto a family name must exit 2");
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir_all(&cwd);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Property: the write-guard refuses ONLY cross-kind name reuse — a SAME-kind
|
||||||
|
/// overwrite stays Ok. Re-running `aura sweep --trace <name>` onto a name already
|
||||||
|
/// held by a family succeeds (exit 0), so the guard does not over-broadly forbid
|
||||||
|
/// all reuse. Without this the collision test alone would pass even if the guard
|
||||||
|
/// degenerated into "refuse every existing name", silently breaking re-tracing.
|
||||||
|
#[test]
|
||||||
|
fn same_kind_overwrite_is_allowed_by_the_write_guard() {
|
||||||
|
let cwd = temp_cwd("same-kind-overwrite");
|
||||||
|
|
||||||
|
let first = Command::new(BIN).args(["sweep", "--trace", "fam"]).current_dir(&cwd).output().expect("spawn sweep --trace fam");
|
||||||
|
assert!(first.status.success(), "first sweep --trace exit: {:?}", first.status);
|
||||||
|
|
||||||
|
// Same name, same kind (family -> family): the guard must NOT refuse it.
|
||||||
|
let again = Command::new(BIN).args(["sweep", "--trace", "fam"]).current_dir(&cwd).output().expect("spawn sweep --trace fam again");
|
||||||
|
assert_eq!(again.status.code(), Some(0), "same-kind family overwrite must stay Ok, got: {:?}; stderr: {}", again.status, String::from_utf8_lossy(&again.stderr));
|
||||||
|
|
||||||
|
// And the re-traced family is still chartable (name resolution unbroken).
|
||||||
|
let chart = Command::new(BIN).args(["chart", "fam"]).current_dir(&cwd).output().expect("spawn chart fam");
|
||||||
|
assert!(chart.status.success(), "chart after overwrite exit: {:?}", chart.status);
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir_all(&cwd);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Property: the family overlay honours a non-default `--tap` — `chart <family>
|
||||||
|
/// --tap exposure` overlays the exposure tap of every member, not the default
|
||||||
|
/// equity. Pins the family branch's `tap.unwrap_or("equity")` selection: a
|
||||||
|
/// regression that ignored `--tap` on families (always equity) would still pass
|
||||||
|
/// the default-equity family test, so the explicit non-default tap is its own net.
|
||||||
|
#[test]
|
||||||
|
fn chart_family_with_tap_overlays_the_named_tap_per_member() {
|
||||||
|
let cwd = temp_cwd("chart-family-tap");
|
||||||
|
|
||||||
|
let swept = Command::new(BIN).args(["sweep", "--trace", "fam"]).current_dir(&cwd).output().expect("spawn sweep --trace");
|
||||||
|
assert!(swept.status.success(), "sweep --trace exit: {:?}", swept.status);
|
||||||
|
|
||||||
|
let chart = Command::new(BIN).args(["chart", "fam", "--tap", "exposure"]).current_dir(&cwd).output().expect("spawn chart fam --tap exposure");
|
||||||
|
assert!(chart.status.success(), "chart family --tap exposure exit: {:?}", chart.status);
|
||||||
|
let html = String::from_utf8(chart.stdout).expect("utf-8 html");
|
||||||
|
assert!(html.contains("window.AURA_TRACES = {"), "AURA_TRACES not injected");
|
||||||
|
// One series per member, keyed by member key — the exposure overlay still
|
||||||
|
// spans all four grid points (the tap selection changes the column, not the
|
||||||
|
// member set).
|
||||||
|
for key in [
|
||||||
|
"signals.trend.fast.length-2_signals.trend.slow.length-4",
|
||||||
|
"signals.trend.fast.length-2_signals.trend.slow.length-5",
|
||||||
|
"signals.trend.fast.length-3_signals.trend.slow.length-4",
|
||||||
|
"signals.trend.fast.length-3_signals.trend.slow.length-5",
|
||||||
|
] {
|
||||||
|
assert!(html.contains(key), "member series '{key}' missing from the --tap exposure family chart");
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir_all(&cwd);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user