diff --git a/crates/aura-cli/tests/cli_run.rs b/crates/aura-cli/tests/cli_run.rs index 1b70856..27d1056 100644 --- a/crates/aura-cli/tests/cli_run.rs +++ b/crates/aura-cli/tests/cli_run.rs @@ -3039,6 +3039,114 @@ fn generalize_without_explicit_window_falls_back_to_the_first_symbols_full_windo assert!(stdout.contains("\"family_id\":\"generalize-0\""), "family handle: {stdout}"); } +/// Property (#213, no-window semantics): invoked WITHOUT `--from`/`--to`, a +/// multi-symbol `generalize` resolves the ONE shared campaign window as the +/// INTERSECTION of the listed symbols' full archive windows — the latest start +/// and earliest end, i.e. the only span over which EVERY listed instrument +/// actually has data. `generalize` is the cross-instrument comparison (the +/// worst-case R floor, #146); a floor pooled over a different period per +/// instrument conflates the instrument axis with the period axis, so the common +/// window is the only one that isolates what the verb measures. Today's fallback +/// picks `symbols[0]`'s full window alone (the superseded shape test above): +/// listing the WIDER symbol first (`AAPL.US`, archive from 2006) keeps AAPL's +/// early start, whereas the intersection with the later-starting `GER40` (archive +/// from 2014) must start at GER40's first bar and end at whichever archive ends +/// first — so on the current tree the resolved window differs from the +/// intersection on both bounds. Each symbol's full window is resolved +/// independently here: a single-symbol `sweep` with no window records it into its +/// generated campaign document, in the same Unix-ms currency and via the same +/// `campaign_window_ms(full_window)` probe generalize itself resolves against — +/// so the expected intersection is derived from the live archive, never a +/// hard-coded span that shifts as the archive grows. Archive-gated; skips +/// cleanly on a data-less host. +#[test] +fn generalize_without_explicit_window_resolves_the_intersection_of_all_symbols() { + if !local_data_present() { + eprintln!("skip: no local data at {}", data_server::DEFAULT_DATA_PATH); + return; + } + let (cwd, _g) = fresh_project(); + let fixture = format!("{}/examples/r_sma_open.json", env!("CARGO_MANIFEST_DIR")); + + // The resolved shared window persists verbatim in the generated campaign + // document; read the (from_ms, to_ms) of the one whose `name` field matches. + let window_of = |name: &str| -> (i64, i64) { + let dir = cwd.join("runs").join("campaigns"); + for entry in std::fs::read_dir(&dir).expect("campaigns dir exists after a run") { + let path = entry.expect("readable dir entry").path(); + let doc: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&path).expect("read campaign doc")) + .expect("campaign doc parses as JSON"); + if doc["name"].as_str() == Some(name) { + let w = &doc["data"]["windows"][0]; + return ( + w["from_ms"].as_i64().expect("from_ms is an integer"), + w["to_ms"].as_i64().expect("to_ms is an integer"), + ); + } + } + panic!("no campaign document named {name:?} in {}", dir.display()); + }; + + // Resolve one symbol's full archive window by running a single-symbol, + // single-cell sweep with NO --from/--to; the window it records is exactly the + // `campaign_window_ms(full_window)` value generalize resolves per symbol. + let probe = |symbol: &str, name: &str| -> (i64, i64) { + let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) + .args([ + "sweep", &fixture, "--real", symbol, + "--axis", "sma_signal.fast.length=3", "--axis", "sma_signal.slow.length=12", + "--name", name, + ]) + .current_dir(cwd) + .output() + .expect("spawn aura sweep"); + assert_eq!( + out.status.code(), Some(0), + "single-symbol window-probe sweep for {symbol} must run to exit 0: {}", + String::from_utf8_lossy(&out.stderr) + ); + window_of(name) + }; + + let aapl = probe("AAPL.US", "probe-aapl"); + let ger40 = probe("GER40", "probe-ger40"); + // Precondition: the archives genuinely differ, so "the intersection" is a + // distinct claim from "symbols[0]'s full window" (else the test is vacuous). + assert_ne!( + aapl, ger40, + "AAPL.US {aapl:?} and GER40 {ger40:?} must span different windows for the test to bite" + ); + + // The contract: the shared window is the intersection — latest start, earliest end. + let expected = (aapl.0.max(ger40.0), aapl.1.min(ger40.1)); + + // AAPL.US listed FIRST: today's `symbols[0]` fallback keeps AAPL's (wider) + // window, which differs from the intersection on both bounds — so a pass here + // can only mean the intersection semantics landed. + let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) + .args([ + "generalize", &fixture, "--real", "AAPL.US,GER40", + "--axis", "sma_signal.fast.length=3", "--axis", "sma_signal.slow.length=12", + "--name", "gen-both", + ]) + .current_dir(cwd) + .output() + .expect("spawn aura generalize"); + assert_eq!( + out.status.code(), Some(0), + "two-symbol no-window generalize must run to exit 0: {}", + String::from_utf8_lossy(&out.stderr) + ); + let resolved = window_of("gen-both"); + + assert_eq!( + resolved, expected, + "no-window generalize must resolve the INTERSECTION {expected:?} of AAPL.US {aapl:?} \ + and GER40 {ger40:?} — not symbols[0]'s ({aapl:?}) full window (resolved {resolved:?})" + ); +} + /// Property: a single-instrument generalize is refused at parse time (exit 2), /// before any data access — so it asserts the arity refusal on any machine. #[test]