feat(aura-cli, aura-runner): JSON data namespace — info verb, NDJSON list, coverage retired
Reshape the aura data namespace around one principle: every verb emits JSON, no format flag (the headless-agent posture #264 named). - aura data info <symbol> (new): one flat JSON object — symbol plus the six neutral geometry fields (digits, pipSize, tickSize, lotSize, baseAsset, quoteAsset) from DataServer::symbol_meta when the <SYMBOL>.meta.json sidecar yields geometry; a symbol is known via bar files OR sidecar (a sidecar-only symbol still reports its geometry); an unknown symbol refuses with prose, exit 1. description stays out until the neutral reader exposes it (data-server#4). - aura data list: NDJSON — one JSON string per line; an empty archive emits zero stdout lines, with the human affordance moved to a stderr note in the #278 class vocabulary. - aura data coverage: retired — #272's per-cell fault isolation subsumed the prophylactic pre-run check; aura-runner's data_coverage_report and its presentation helpers go with it, while interior_gap_months stays as the campaign path's single gap walk (#295). The three derived design decisions (description omitted, broader refusal prose, empty-archive stderr notice) are minuted on the issue. closes #273
This commit is contained in:
+143
-52
@@ -1258,15 +1258,15 @@ struct DataCmd {
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum DataCommand {
|
||||
/// Report a symbol's monthly file-index coverage (span + interior gaps).
|
||||
Coverage(DataCoverageCmd),
|
||||
/// List the archive's known symbols, sorted, one per line.
|
||||
/// Print a symbol's recorded geometry as one flat JSON object.
|
||||
Info(DataInfoCmd),
|
||||
/// List the archive's known symbols as NDJSON, sorted, one per line.
|
||||
List,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
struct DataCoverageCmd {
|
||||
/// The symbol to inventory (e.g. `GER40`).
|
||||
struct DataInfoCmd {
|
||||
/// The symbol to inspect (e.g. `GER40`).
|
||||
symbol: String,
|
||||
}
|
||||
|
||||
@@ -2280,7 +2280,7 @@ fn dispatch_nodes_new(a: NodesNewCmd) {
|
||||
|
||||
fn dispatch_data(cmd: DataCmd, env: &aura_runner::project::Env) {
|
||||
match cmd.command {
|
||||
DataCommand::Coverage(a) => dispatch_data_coverage(a, env),
|
||||
DataCommand::Info(a) => dispatch_data_info(a, env),
|
||||
DataCommand::List => dispatch_data_list(env),
|
||||
}
|
||||
}
|
||||
@@ -2325,57 +2325,116 @@ fn dispatch_measure_ic(a: MeasureIcCmd, env: &aura_runner::project::Env) {
|
||||
println!("{}", report.to_json());
|
||||
}
|
||||
|
||||
/// `aura data list` (#264 cut 2): print the archive's known symbols, sorted
|
||||
/// ascending, one per line — the discovery step before `aura data coverage`
|
||||
/// or scoping a campaign's instrument matrix. Resolves the archive root
|
||||
/// through the normal project path ([`dispatch_data_coverage`]'s
|
||||
/// `Env::data_path`), then reuses `DataServer`'s own symbol index
|
||||
/// (`DataServer::symbols()`, already sorted) rather than re-deriving a
|
||||
/// directory scan. An empty or absent archive is informational absence, not a
|
||||
/// fault: a `no symbols` prose line, exit 0 (the verb corpus's established
|
||||
/// empty-result register — cf. `runs family`'s unknown-id empty exit 0).
|
||||
/// `aura data list` (#273: the namespace-wide NDJSON reshape): print the
|
||||
/// archive's known symbols as NDJSON, sorted ascending, one JSON string per
|
||||
/// line — the discovery step before `aura data info <symbol>` or scoping a
|
||||
/// campaign's instrument matrix. Resolves the archive root through the normal
|
||||
/// project path (`Env::data_path`), then reuses `DataServer`'s own symbol
|
||||
/// index (`DataServer::symbols()`, already sorted) rather than re-deriving a
|
||||
/// directory scan. An empty or absent archive prints zero stdout lines — the
|
||||
/// honest NDJSON of an empty set, since a placeholder prose line would break
|
||||
/// `| jq`-style consumption; the human affordance moves to a
|
||||
/// `crate::diag::note!` stderr notice instead (#273 design minutes,
|
||||
/// 2026-07-25).
|
||||
fn dispatch_data_list(env: &aura_runner::project::Env) {
|
||||
let data_path = std::path::PathBuf::from(env.data_path());
|
||||
let server = aura_ingest::DataServer::new(&data_path);
|
||||
for line in data_list_report(&server.symbols()) {
|
||||
let report = data_list_report(&server.symbols());
|
||||
if report.is_empty() {
|
||||
crate::diag::note!("no symbols in the archive");
|
||||
return;
|
||||
}
|
||||
for line in report {
|
||||
println!("{line}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Pure: render `symbols` (already sorted by `DataServer::symbols()`) as one
|
||||
/// line per symbol, or a single `no symbols` line when the archive is empty
|
||||
/// or absent — informational absence, never a fault (#264).
|
||||
/// JSON-string line per symbol (`serde_json::to_string`, so `SYMA` becomes the
|
||||
/// quoted `"SYMA"` NDJSON line) — an empty archive yields an empty `Vec`,
|
||||
/// never a placeholder line, so stdout stays pure NDJSON either way (#273).
|
||||
fn data_list_report(symbols: &[std::sync::Arc<str>]) -> Vec<String> {
|
||||
if symbols.is_empty() {
|
||||
return vec!["no symbols".to_string()];
|
||||
}
|
||||
symbols.iter().map(|s| s.to_string()).collect()
|
||||
symbols
|
||||
.iter()
|
||||
.map(|s| serde_json::to_string(s.as_ref()).expect("a symbol string always serializes"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `aura data coverage <SYMBOL>` (#264 cut 1): print the archive's month
|
||||
/// coverage for `SYMBOL` — the Copper failure mode (files present at both
|
||||
/// ends of a window while an interior month is missing, so a first/last-bounds
|
||||
/// check passes but a campaign run aborts mid-window) made visible up front.
|
||||
/// Resolves the archive root through the normal project path (`Env::data_path`
|
||||
/// — a project-local `[paths] data` override when set, the data-server default
|
||||
/// otherwise), so a project-scoped archive is inventoried, not the host one.
|
||||
fn dispatch_data_coverage(a: DataCoverageCmd, env: &aura_runner::project::Env) {
|
||||
/// `aura data info <SYMBOL>` (#273): print a symbol's recorded geometry as one
|
||||
/// flat JSON object — the machine-readable follow-up to `aura data list`'s
|
||||
/// discovery pass, surfacing `DataServer::symbol_meta`'s neutral,
|
||||
/// broker-agnostic geometry (digits / pip / tick / lot sizing, base / quote
|
||||
/// asset) without a human ever opening the `<SYMBOL>.meta.json` sidecar by
|
||||
/// hand. Resolves the archive root through the normal project path
|
||||
/// (`Env::data_path`, same as every other `data` verb). A symbol is KNOWN
|
||||
/// when the archive holds bar files for it (`DataServer::has_symbol`) OR it
|
||||
/// carries a usable geometry sidecar (`DataServer::symbol_meta`) — a
|
||||
/// sidecar-only symbol (bars not yet ingested) still reports its geometry. An
|
||||
/// unknown symbol (neither) refuses on stderr, exit 1. Nothing else about the
|
||||
/// archive (span, gaps, provider metadata) surfaces here — that was `aura
|
||||
/// data coverage`'s job; #272's per-cell fault isolation made it redundant,
|
||||
/// so this issue retires it rather than folding it in (#273 design minutes).
|
||||
fn dispatch_data_info(a: DataInfoCmd, env: &aura_runner::project::Env) {
|
||||
let data_path = std::path::PathBuf::from(env.data_path());
|
||||
let months = aura_ingest::list_m1_months(&data_path, &a.symbol);
|
||||
// The report body is single-sourced with a campaign cell's own coverage
|
||||
// annotation (#295 Task 7: `aura_runner::coverage::interior_gap_months`
|
||||
// is the one gap walk both consumers call).
|
||||
match aura_runner::coverage::data_coverage_report(&a.symbol, &months) {
|
||||
Ok(lines) => {
|
||||
for line in lines {
|
||||
println!("{line}");
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("aura: {e} at {}", env.data_path());
|
||||
let server = aura_ingest::DataServer::new(&data_path);
|
||||
let geo = server.symbol_meta(&a.symbol);
|
||||
if geo.is_none() && !server.has_symbol(&a.symbol) {
|
||||
eprintln!(
|
||||
"aura: no recorded data for symbol \"{}\" (no archive files, no geometry sidecar)",
|
||||
a.symbol
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
println!("{}", data_info_report(&a.symbol, geo.as_ref()));
|
||||
}
|
||||
|
||||
/// One `aura data info` object's shape: `symbol` plus, when a geometry
|
||||
/// sidecar was found, its six neutral price-geometry fields — never a
|
||||
/// provider-specific field (`generator`, `schemaVersion`, `generatedAtUtc`,
|
||||
/// #273 acceptance), since `InstrumentGeometry` never carries one to begin
|
||||
/// with. `InstrumentGeometry` itself has no `Serialize` impl, so
|
||||
/// [`data_info_report`] copies its fields in here. `rename_all = "camelCase"`
|
||||
/// turns `pip_size`/`tick_size`/`lot_size`/`base_asset`/`quote_asset` into the
|
||||
/// sidecar's own `pipSize`/`tickSize`/`lotSize`/`baseAsset`/`quoteAsset`
|
||||
/// spelling; `#[serde(skip_serializing_if)]` drops the whole geometry tail
|
||||
/// when absent rather than emitting `null`s, so a sidecar-less known symbol
|
||||
/// reports as bare `{"symbol":"…"}` (#273 design minutes, 2026-07-25:
|
||||
/// `description` stays out of this report until the reader exposes it).
|
||||
#[derive(serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct DataInfoReport {
|
||||
symbol: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
digits: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pip_size: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
tick_size: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
lot_size: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
base_asset: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
quote_asset: Option<String>,
|
||||
}
|
||||
|
||||
/// Pure: `aura data info <SYMBOL>`'s report body (#273) — one flat JSON
|
||||
/// object, field order `symbol`, `digits`, `pipSize`, `tickSize`, `lotSize`,
|
||||
/// `baseAsset`, `quoteAsset` (serde's field-declaration order is stable,
|
||||
/// unlike `serde_json::Map`'s alphabetical iteration). `geo` is `None` for a
|
||||
/// known-but-sidecar-less symbol, in which case every geometry field is
|
||||
/// omitted (see [`DataInfoReport`]).
|
||||
fn data_info_report(symbol: &str, geo: Option<&aura_ingest::InstrumentGeometry>) -> String {
|
||||
let report = DataInfoReport {
|
||||
symbol: symbol.to_string(),
|
||||
digits: geo.map(|g| g.digits),
|
||||
pip_size: geo.map(|g| g.pip_size),
|
||||
tick_size: geo.map(|g| g.tick_size),
|
||||
lot_size: geo.map(|g| g.lot_size),
|
||||
base_asset: geo.map(|g| g.base.clone()),
|
||||
quote_asset: geo.map(|g| g.quote.clone()),
|
||||
};
|
||||
serde_json::to_string(&report).expect("a finite DataInfoReport always serializes")
|
||||
}
|
||||
|
||||
/// `aura sweep`: loaded-blueprint by-name axis sweep (or `--list-axes` probe) when the
|
||||
@@ -2819,23 +2878,55 @@ mod tests {
|
||||
}
|
||||
|
||||
/// An empty (or absent) archive is informational absence, not a fault
|
||||
/// (#264): `data_list_report` returns the single `no symbols` prose line
|
||||
/// rather than an empty `Vec`, so the caller's exit-0 println always has
|
||||
/// something to say.
|
||||
/// (#264), but under the all-JSON namespace reshape (#273) stdout must
|
||||
/// stay pure NDJSON: `data_list_report` returns an empty `Vec` rather
|
||||
/// than a placeholder prose line, so the caller has nothing to print and
|
||||
/// moves the human affordance to stderr instead.
|
||||
#[test]
|
||||
fn data_list_report_of_an_empty_archive_is_a_no_symbols_line() {
|
||||
fn data_list_report_of_an_empty_archive_is_empty() {
|
||||
let symbols: Vec<std::sync::Arc<str>> = vec![];
|
||||
assert_eq!(data_list_report(&symbols), vec!["no symbols".to_string()]);
|
||||
assert_eq!(data_list_report(&symbols), Vec::<String>::new());
|
||||
}
|
||||
|
||||
/// A non-empty archive renders one line per symbol, in the order handed
|
||||
/// in (`DataServer::symbols()` already sorts) — no `no symbols` line
|
||||
/// mixed in (#264).
|
||||
/// A non-empty archive renders one NDJSON line per symbol, in the order
|
||||
/// handed in (`DataServer::symbols()` already sorts) — each symbol as a
|
||||
/// quoted JSON string, not a bare name (#273: the NDJSON reshape).
|
||||
#[test]
|
||||
fn data_list_report_of_a_populated_archive_is_one_line_per_symbol() {
|
||||
fn data_list_report_of_a_populated_archive_is_one_json_string_per_line() {
|
||||
let symbols: Vec<std::sync::Arc<str>> =
|
||||
vec![std::sync::Arc::from("SYMA"), std::sync::Arc::from("SYMB")];
|
||||
assert_eq!(data_list_report(&symbols), vec!["SYMA".to_string(), "SYMB".to_string()]);
|
||||
assert_eq!(
|
||||
data_list_report(&symbols),
|
||||
vec!["\"SYMA\"".to_string(), "\"SYMB\"".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
/// `aura data info`'s report body with a full geometry sidecar present:
|
||||
/// the exact output string, pinning both the field order (`symbol` then
|
||||
/// the six geometry fields, camelCased) and serde_json's own float
|
||||
/// rendering (#273).
|
||||
#[test]
|
||||
fn data_info_report_with_geometry_is_symbol_plus_six_camelcase_fields() {
|
||||
let geo = aura_ingest::InstrumentGeometry {
|
||||
digits: 5,
|
||||
pip_size: 0.0001,
|
||||
tick_size: 1e-05,
|
||||
lot_size: 100_000.0,
|
||||
base: "EUR".to_string(),
|
||||
quote: "USD".to_string(),
|
||||
};
|
||||
assert_eq!(
|
||||
data_info_report("EURUSD", Some(&geo)),
|
||||
r#"{"symbol":"EURUSD","digits":5,"pipSize":0.0001,"tickSize":0.00001,"lotSize":100000.0,"baseAsset":"EUR","quoteAsset":"USD"}"#
|
||||
);
|
||||
}
|
||||
|
||||
/// A known symbol with no geometry sidecar reports bare identity — no
|
||||
/// `null` geometry fields, no provider metadata, just `symbol` (#273:
|
||||
/// "a sidecar-less known symbol yields just `{"symbol":"…"}`").
|
||||
#[test]
|
||||
fn data_info_report_without_geometry_is_bare_symbol() {
|
||||
assert_eq!(data_info_report("SYMX", None), r#"{"symbol":"SYMX"}"#);
|
||||
}
|
||||
|
||||
/// #234: `campaign_run::persist_campaign_traces`'s C1 drift alarm re-runs a
|
||||
|
||||
@@ -5,7 +5,7 @@ use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
mod common;
|
||||
use common::{fresh_project, fresh_project_with_data, fresh_project_with_gapped_data};
|
||||
use common::{fresh_project, fresh_project_with_data};
|
||||
|
||||
/// Path to the freshly-built `aura` binary (Cargo sets this env var for the test
|
||||
/// crate; the binary is named `aura` in `Cargo.toml`).
|
||||
@@ -6738,61 +6738,18 @@ fn shipped_r_meanrev_example_runs_end_to_end_via_aura_run() {
|
||||
assert!(stdout.contains("\"total_pips\":0.0"), "{stdout}");
|
||||
}
|
||||
|
||||
/// Property (#264, coverage cut 1): `aura data coverage <SYMBOL>` surfaces the
|
||||
/// archive's month COVERAGE from the monthly file index — the first and last
|
||||
/// present month AND every interior missing-month range (a gap) — so the Copper
|
||||
/// failure mode is visible up front. Copper had files from 2016-08 but was
|
||||
/// missing 2018-01..2018-10, so a first/last-bounds check passed while a
|
||||
/// window opening in the hole had no data, aborting a multi-hour campaign
|
||||
/// mid-run; a per-symbol gap report would have caught it before the run. Over a
|
||||
/// hermetic synthetic archive whose `GAPSYM` has 2024-01..02 and 2024-05..06
|
||||
/// present and 2024-03..04 absent, the verb exits 0, frames the span by its
|
||||
/// first (2024-01) and last (2024-06) present month, and reports the interior
|
||||
/// hole as a single collapsed month-range line `missing: 2024-03..2024-04`
|
||||
/// (one line for the whole contiguous gap, as Copper's ten-month hole would be
|
||||
/// one line — not one line per missing month).
|
||||
/// Property (#273, list reshape): `aura data list` enumerates the archive's
|
||||
/// symbols as NDJSON, sorted — the discovery step a campaign author (or an
|
||||
/// agent operating through the CLI alone, the deployment posture) needs
|
||||
/// before `aura data info <symbol>`. The archive root resolves through the
|
||||
/// same project path as `info` (`Env::data_path` — a project-local `[paths]
|
||||
/// data` override when set). Over a hermetic synthetic archive holding two
|
||||
/// symbols (`SYMA` 2024-01..08 and `SYMB` 2024-03..06), the verb exits 0 and
|
||||
/// prints EXACTLY two lines, each a JSON string parseable via
|
||||
/// `serde_json::from_str::<String>`, in ascending order — `"SYMA"` before
|
||||
/// `"SYMB"`, nothing else.
|
||||
#[test]
|
||||
fn data_coverage_reports_month_span_and_interior_gap_range() {
|
||||
let (cwd, _g) = fresh_project_with_gapped_data();
|
||||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||||
.args(["data", "coverage", "GAPSYM"])
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("spawn aura data coverage");
|
||||
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 stdout");
|
||||
// The interior gap is the headline — the Copper failure the verb exists to
|
||||
// surface — reported as one collapsed range for the whole contiguous hole.
|
||||
assert!(
|
||||
stdout.contains("missing: 2024-03..2024-04"),
|
||||
"interior gap month-range reported as one line: {stdout}"
|
||||
);
|
||||
// The span bounds frame the gap: first and last present month.
|
||||
assert!(
|
||||
stdout.contains("2024-01") && stdout.contains("2024-06"),
|
||||
"first (2024-01) and last (2024-06) present month reported: {stdout}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Property (#264, list cut 2): `aura data list` enumerates the symbols the
|
||||
/// archive contains, one per line, sorted — the discovery step a campaign
|
||||
/// author (or an agent operating through the CLI alone, the deployment
|
||||
/// posture) needs before `aura data coverage <symbol>`, since without it the
|
||||
/// inventory is obtainable only by listing the archive directory outside
|
||||
/// aura. The archive root resolves through the same project path as `coverage`
|
||||
/// (`Env::data_path` — a project-local `[paths] data` override when set). Over
|
||||
/// a hermetic synthetic archive holding two symbols (`SYMA` 2024-01..08 and
|
||||
/// `SYMB` 2024-03..06), the verb exits 0 and prints EXACTLY the two symbol
|
||||
/// names in ascending order, one per line — `SYMA` before `SYMB`, nothing
|
||||
/// else.
|
||||
#[test]
|
||||
fn data_list_enumerates_archive_symbols_one_per_line_sorted() {
|
||||
fn data_list_on_a_populated_archive_is_ndjson_one_symbol_per_line() {
|
||||
let (cwd, _g) = fresh_project_with_data();
|
||||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||||
.args(["data", "list"])
|
||||
@@ -6807,10 +6764,161 @@ fn data_list_enumerates_archive_symbols_one_per_line_sorted() {
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout");
|
||||
// The whole contract: the two archive symbols, sorted, one per line, and
|
||||
// nothing else — this is the inventory a campaign author reads before
|
||||
// scoping an instrument matrix.
|
||||
assert_eq!(stdout, "SYMA\nSYMB\n", "sorted symbol enumeration: {stdout:?}");
|
||||
let lines: Vec<&str> = stdout.lines().collect();
|
||||
assert_eq!(lines.len(), 2, "exactly the two archive symbols: {stdout:?}");
|
||||
let parsed: Vec<String> = lines
|
||||
.iter()
|
||||
.map(|l| serde_json::from_str::<String>(l).unwrap_or_else(|e| panic!("line {l:?} is a JSON string: {e}")))
|
||||
.collect();
|
||||
assert_eq!(parsed, vec!["SYMA".to_string(), "SYMB".to_string()], "sorted NDJSON enumeration: {stdout:?}");
|
||||
}
|
||||
|
||||
/// Property (#273 design minutes, 2026-07-25): an empty archive under the
|
||||
/// all-JSON namespace prints ZERO stdout lines — the honest NDJSON of an
|
||||
/// empty set, since a placeholder prose line would break `| jq`-style
|
||||
/// consumption — with the human affordance carried on stderr instead (the
|
||||
/// `aura: note:` class, #278).
|
||||
#[test]
|
||||
fn data_list_on_an_empty_archive_is_empty_stdout_with_a_stderr_notice() {
|
||||
let (cwd, _g) = fresh_project_with_data();
|
||||
let data_dir = cwd.join("data");
|
||||
std::fs::remove_dir_all(&data_dir).expect("clear the synthetic archive");
|
||||
std::fs::create_dir_all(&data_dir).expect("recreate an empty synthetic archive dir");
|
||||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||||
.args(["data", "list"])
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("spawn aura data list");
|
||||
assert_eq!(
|
||||
out.status.code(),
|
||||
Some(0),
|
||||
"exit: {:?}, stderr: {}",
|
||||
out.status,
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
assert_eq!(out.stdout, b"", "empty archive: zero stdout lines");
|
||||
let stderr = String::from_utf8(out.stderr).expect("utf-8 stderr");
|
||||
assert!(stderr.contains("aura: note:") && stderr.contains("no symbols"), "stderr carries the notice: {stderr}");
|
||||
}
|
||||
|
||||
/// Property (#273): `aura data info <SYMBOL>` on a symbol the archive holds
|
||||
/// bar files for but no `<SYMBOL>.meta.json` sidecar reports bare identity —
|
||||
/// exit 0, stdout is exactly `{"symbol":"…"}`, no `null` geometry fields.
|
||||
#[test]
|
||||
fn data_info_on_a_symbol_without_sidecar_is_bare_symbol_json() {
|
||||
let (cwd, _g) = fresh_project_with_data();
|
||||
std::fs::remove_file(cwd.join("data/SYMB.meta.json")).expect("drop SYMB's sidecar");
|
||||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||||
.args(["data", "info", "SYMB"])
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("spawn aura data info");
|
||||
assert_eq!(
|
||||
out.status.code(),
|
||||
Some(0),
|
||||
"exit: {:?}, stderr: {}",
|
||||
out.status,
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
assert_eq!(String::from_utf8(out.stdout).expect("utf-8 stdout"), "{\"symbol\":\"SYMB\"}\n");
|
||||
}
|
||||
|
||||
/// Property (#273): `aura data info <SYMBOL>` on a symbol carrying a usable
|
||||
/// geometry sidecar surfaces exactly the six neutral geometry fields
|
||||
/// (`digits`, `pipSize`, `tickSize`, `lotSize`, `baseAsset`, `quoteAsset`)
|
||||
/// alongside `symbol` — and NOTHING provider-specific (`description`,
|
||||
/// `generator`, `schemaVersion`) leaks through, even though the sidecar
|
||||
/// itself carries those fields: `InstrumentGeometry`'s reader never parses
|
||||
/// them out of the sidecar JSON in the first place (#273 acceptance).
|
||||
#[test]
|
||||
fn data_info_on_a_symbol_with_a_sidecar_carries_the_six_geometry_fields() {
|
||||
let (cwd, _g) = fresh_project_with_data();
|
||||
std::fs::write(
|
||||
cwd.join("data/SYMA.meta.json"),
|
||||
r#"{"schemaVersion":2,"generator":"MS_SimpleExport (cTrader.Automate)","description":"Euro vs US Dollar","digits":5,"pipSize":0.0001,"tickSize":1e-05,"lotSize":100000,"baseAsset":"EUR","quoteAsset":"USD"}"#,
|
||||
)
|
||||
.expect("overwrite SYMA's sidecar with the EURUSD-shaped fixture");
|
||||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||||
.args(["data", "info", "SYMA"])
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("spawn aura data info");
|
||||
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 stdout");
|
||||
let obj: serde_json::Value = serde_json::from_str(stdout.trim()).expect("one JSON object");
|
||||
let map = obj.as_object().expect("a flat object");
|
||||
let mut keys: Vec<&str> = map.keys().map(String::as_str).collect();
|
||||
keys.sort_unstable();
|
||||
assert_eq!(
|
||||
keys,
|
||||
vec!["baseAsset", "digits", "lotSize", "pipSize", "quoteAsset", "symbol", "tickSize"],
|
||||
"exactly symbol + the six geometry fields, no provider metadata: {stdout}"
|
||||
);
|
||||
assert_eq!(map["symbol"], "SYMA");
|
||||
assert_eq!(map["digits"], 5);
|
||||
assert_eq!(map["baseAsset"], "EUR");
|
||||
assert_eq!(map["quoteAsset"], "USD");
|
||||
}
|
||||
|
||||
/// Property (#273): a symbol carrying a usable geometry sidecar but NO
|
||||
/// archive bar files is still KNOWN to `aura data info` — the `symbol_meta`
|
||||
/// arm of the known-check's OR, independent of `has_symbol` — and its
|
||||
/// geometry reports normally, exit 0. This is the real-world case of a
|
||||
/// sidecar exported ahead of the bars themselves being ingested.
|
||||
#[test]
|
||||
fn data_info_on_a_sidecar_only_symbol_reports_geometry() {
|
||||
let (cwd, _g) = fresh_project_with_data();
|
||||
std::fs::write(
|
||||
cwd.join("data/METAONLY.meta.json"),
|
||||
r#"{"schemaVersion":2,"generator":"MS_SimpleExport (cTrader.Automate)","description":"Euro vs US Dollar","digits":5,"pipSize":0.0001,"tickSize":1e-05,"lotSize":100000,"baseAsset":"EUR","quoteAsset":"USD"}"#,
|
||||
)
|
||||
.expect("write METAONLY's sidecar-only fixture (no archive bar files)");
|
||||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||||
.args(["data", "info", "METAONLY"])
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("spawn aura data info");
|
||||
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 stdout");
|
||||
let obj: serde_json::Value = serde_json::from_str(stdout.trim()).expect("one JSON object");
|
||||
let map = obj.as_object().expect("a flat object");
|
||||
let mut keys: Vec<&str> = map.keys().map(String::as_str).collect();
|
||||
keys.sort_unstable();
|
||||
assert_eq!(
|
||||
keys,
|
||||
vec!["baseAsset", "digits", "lotSize", "pipSize", "quoteAsset", "symbol", "tickSize"],
|
||||
"exactly symbol + the six geometry fields, no provider metadata: {stdout}"
|
||||
);
|
||||
assert_eq!(map["symbol"], "METAONLY");
|
||||
}
|
||||
|
||||
/// Property (#273): a symbol with neither archive bar files nor a geometry
|
||||
/// sidecar is unknown to `aura data info` — refuses on stderr, exit 1, no
|
||||
/// panic, no bare Debug struct.
|
||||
#[test]
|
||||
fn data_info_on_an_unknown_symbol_refuses() {
|
||||
let (cwd, _g) = fresh_project_with_data();
|
||||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||||
.args(["data", "info", "GHOST"])
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("spawn aura data info");
|
||||
assert_eq!(out.status.code(), Some(1), "stdout: {}", String::from_utf8_lossy(&out.stdout));
|
||||
let stderr = String::from_utf8(out.stderr).expect("utf-8 stderr");
|
||||
assert!(stderr.contains("no recorded data for symbol"), "{stderr}");
|
||||
assert!(stderr.contains("GHOST"), "names the unknown symbol: {stderr}");
|
||||
}
|
||||
|
||||
/// Property (#300): `aura campaign show <CID>` prints the registered
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
//! Coverage reporting: the single interior-gap-month walk, shared by a
|
||||
//! campaign cell's coverage annotation
|
||||
//! (`DefaultMemberRunner::window_coverage`) and `aura data coverage`'s
|
||||
//! archive-wide report — two independent walk implementations before this
|
||||
//! module existed (#295 dedup).
|
||||
//! The interior-gap-month walk behind a campaign cell's coverage annotation
|
||||
//! (`DefaultMemberRunner::window_coverage`) — originally shared with `aura
|
||||
//! data coverage`'s archive-wide report (#295 dedup: two independent walk
|
||||
//! implementations before this module existed) before that verb retired in
|
||||
//! favor of per-cell fault isolation (#272, #273).
|
||||
|
||||
/// `"YYYY-MM"` rendering of a `(year, month)` pair.
|
||||
pub fn fmt_year_month((y, m): (u16, u8)) -> String {
|
||||
@@ -18,11 +18,11 @@ pub fn next_year_month((y, m): (u16, u8)) -> (u16, u8) {
|
||||
/// fall inside `window_ms` (Unix-ms) — one `"YYYY-MM"` entry per missing
|
||||
/// month, never a collapsed range: the registry's `CellCoverage::gap_months`
|
||||
/// is a flat list an aggregate counts directly. `months` is assumed sorted
|
||||
/// ([`aura_ingest::list_m1_months`]'s own contract). The single gap-walk
|
||||
/// implementation (#295): both `DefaultMemberRunner::window_coverage`
|
||||
/// (a swept cell's own window) and [`data_coverage_report`] (the full
|
||||
/// archive span, via [`FULL_ARCHIVE_WINDOW_MS`]) call this one walk instead
|
||||
/// of maintaining independent copies.
|
||||
/// ([`aura_ingest::list_m1_months`]'s own contract). `DefaultMemberRunner::
|
||||
/// window_coverage` (a swept cell's own window) is the sole caller since
|
||||
/// `aura data coverage`'s archive-wide report retired (#273); the walk stays
|
||||
/// its own function because it was single-sourced with that verb before then
|
||||
/// (#295).
|
||||
pub fn interior_gap_months(months: &[(u16, u8)], window_ms: (i64, i64)) -> Vec<String> {
|
||||
let from_ym = data_server::records::unix_ms_to_year_month(window_ms.0);
|
||||
let to_ym = data_server::records::unix_ms_to_year_month(window_ms.1);
|
||||
@@ -40,68 +40,6 @@ pub fn interior_gap_months(months: &[(u16, u8)], window_ms: (i64, i64)) -> Vec<S
|
||||
out
|
||||
}
|
||||
|
||||
/// A Unix-ms window that decodes (via `unix_ms_to_year_month`) to year/month
|
||||
/// bounds — 0001-01 and 9999-01, exact epoch-ms for those UTC instants, not
|
||||
/// an approximation — comfortably outside any realistic archive month, so an
|
||||
/// unbounded, archive-wide [`interior_gap_months`] walk never ms-filters out
|
||||
/// a real gap. Safely inside `chrono`'s valid calendar range, so the
|
||||
/// conversion inside `interior_gap_months` never panics.
|
||||
pub const FULL_ARCHIVE_WINDOW_MS: (i64, i64) = (-62_135_596_800_000, 253_370_764_800_000);
|
||||
|
||||
/// `aura data coverage <SYMBOL>`'s pure report body (#264): render `symbol`'s
|
||||
/// coverage report from its sorted `(year, month)` file list — one `span:`
|
||||
/// line framing the first/last present month, then either a `no gaps` line
|
||||
/// or one `missing: YYYY-MM..YYYY-MM` line per interior contiguous gap (a
|
||||
/// ten-month hole is one line, not ten). `Err` exactly when `months` is
|
||||
/// empty — no archive file exists for `symbol` at all (the caller's "unknown
|
||||
/// symbol" refusal, stderr + exit 1). The gap detection itself is
|
||||
/// [`interior_gap_months`] over the full archive span
|
||||
/// ([`FULL_ARCHIVE_WINDOW_MS`]); collapsing its flat per-month list into
|
||||
/// contiguous ranges is presentation-only regrouping, done here since it
|
||||
/// stays byte-identical to the pre-dedup report.
|
||||
pub fn data_coverage_report(symbol: &str, months: &[(u16, u8)]) -> Result<Vec<String>, String> {
|
||||
let Some(&first) = months.first() else {
|
||||
return Err(format!("no archive files found for symbol \"{symbol}\""));
|
||||
};
|
||||
let last = *months.last().expect("non-empty checked above");
|
||||
let mut lines =
|
||||
vec![format!("{symbol} span: {}..{}", fmt_year_month(first), fmt_year_month(last))];
|
||||
let gap_months = interior_gap_months(months, FULL_ARCHIVE_WINDOW_MS);
|
||||
if gap_months.is_empty() {
|
||||
lines.push(format!("{symbol} no gaps"));
|
||||
} else {
|
||||
for (from, to) in collapse_contiguous_year_months(&gap_months) {
|
||||
lines.push(format!("{symbol} missing: {from}..{to}"));
|
||||
}
|
||||
}
|
||||
Ok(lines)
|
||||
}
|
||||
|
||||
/// Collapse [`interior_gap_months`]' flat, ascending `"YYYY-MM"` list into
|
||||
/// contiguous inclusive `(from, to)` ranges — a ten-month hole collapses to
|
||||
/// one pair, not ten. Presentation-only regrouping: the gap WALK itself
|
||||
/// already ran in `interior_gap_months`.
|
||||
fn collapse_contiguous_year_months(months: &[String]) -> Vec<(String, String)> {
|
||||
let mut out: Vec<(String, String)> = Vec::new();
|
||||
for m in months {
|
||||
let ym = parse_year_month(m);
|
||||
match out.last_mut() {
|
||||
Some((_, to)) if next_year_month(parse_year_month(to)) == ym => {
|
||||
*to = m.clone();
|
||||
}
|
||||
_ => out.push((m.clone(), m.clone())),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Inverse of [`fmt_year_month`] — parses back the `"YYYY-MM"` shape
|
||||
/// [`interior_gap_months`] always emits.
|
||||
fn parse_year_month(s: &str) -> (u16, u8) {
|
||||
let (y, m) = s.split_once('-').expect("interior_gap_months emits YYYY-MM");
|
||||
(y.parse().expect("YYYY digits"), m.parse().expect("MM digits"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -125,37 +63,4 @@ mod tests {
|
||||
"both interior gap months must be named, in order"
|
||||
);
|
||||
}
|
||||
|
||||
/// An unknown symbol (no archive files at all — an empty month list)
|
||||
/// refuses rather than reporting a bogus empty-span coverage (#264): the
|
||||
/// caller eprintln's the message and exits 1.
|
||||
#[test]
|
||||
fn data_coverage_report_refuses_an_unknown_symbol() {
|
||||
let err = data_coverage_report("GHOST", &[]).unwrap_err();
|
||||
assert!(err.contains("GHOST"), "names the unknown symbol: {err}");
|
||||
}
|
||||
|
||||
/// A symbol with a fully contiguous file index reports its span plus an
|
||||
/// explicit `no gaps` line — never a bare span with no gap-status line at
|
||||
/// all, which would leave "no gaps" indistinguishable from "gaps not yet
|
||||
/// checked" (#264).
|
||||
#[test]
|
||||
fn data_coverage_report_of_a_gapless_symbol_is_span_plus_no_gaps() {
|
||||
let months = [(2024, 1), (2024, 2), (2024, 3)];
|
||||
let lines = data_coverage_report("SYMA", &months).expect("known symbol");
|
||||
assert_eq!(lines, vec!["SYMA span: 2024-01..2024-03", "SYMA no gaps"]);
|
||||
}
|
||||
|
||||
/// The Copper failure shape itself: one interior gap collapses to a single
|
||||
/// `missing: YYYY-MM..YYYY-MM` line naming the whole contiguous hole, not
|
||||
/// one line per missing month (#264).
|
||||
#[test]
|
||||
fn data_coverage_report_collapses_an_interior_gap_to_one_range_line() {
|
||||
let months = [(2024, 1), (2024, 2), (2024, 5), (2024, 6)];
|
||||
let lines = data_coverage_report("GAPSYM", &months).expect("known symbol");
|
||||
assert_eq!(
|
||||
lines,
|
||||
vec!["GAPSYM span: 2024-01..2024-06", "GAPSYM missing: 2024-03..2024-04"]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user