28984ba73c
aura data coverage <SYMBOL> must print the archive's first/last present month and each interior missing-month range collapsed to one 'missing: YYYY-MM..YYYY-MM' line — the Copper failure mode (first/last check passes while the window start has no data) made visible before a campaign runs. Hostless RED over a new gapped synthetic-archive fixture (fresh_project_with_gapped_data); fails on the absent 'data' subcommand (clap exit 2), not on scaffolding. refs #264
316 lines
14 KiB
Rust
316 lines
14 KiB
Rust
//! Shared fixture helpers for the `aura-cli` end-to-end test binaries
|
|
//! (`research_docs.rs`, `cli_run.rs`, `project_load.rs`), all of which build
|
|
//! and drive the `tests/fixtures/demo-project` fixture through the real
|
|
//! `aura` binary (#223).
|
|
//!
|
|
//! **Per-test project directories, no shared store.** [`fresh_project`] mints
|
|
//! a unique tempdir per test, wired to the shared, once-built fixture crate
|
|
//! via an absolute `[nodes]` pointer (`project.rs`'s pointer-arm routing), so
|
|
//! no two tests ever race on the same `runs/` store — libtest's default
|
|
//! thread parallelism applies without a serializing lock (#250).
|
|
//!
|
|
//! Each of the three test binaries compiles this file as its own `mod
|
|
//! common`, so an item unused by one binary trips that binary's `-D
|
|
//! warnings` `dead_code` lint; see the `#[allow(dead_code)]` markers below
|
|
//! rather than deleting a helper just because one binary doesn't need it.
|
|
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::OnceLock;
|
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
|
|
|
/// The demo-project fixture (`Aura.toml` present), built once per test-binary
|
|
/// process. Each test binary has its own `OnceLock` (this module is compiled
|
|
/// separately per binary), but `cargo build` is idempotent, so a second
|
|
/// binary building the same fixture is a no-op.
|
|
pub fn built_project() -> &'static PathBuf {
|
|
static BUILT: OnceLock<PathBuf> = OnceLock::new();
|
|
BUILT.get_or_init(|| {
|
|
let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/demo-project");
|
|
let out = std::process::Command::new("cargo")
|
|
.arg("build")
|
|
.current_dir(&dir)
|
|
.output()
|
|
.expect("spawn cargo build for the fixture project");
|
|
assert!(
|
|
out.status.success(),
|
|
"fixture build failed:\n{}",
|
|
String::from_utf8_lossy(&out.stderr)
|
|
);
|
|
dir
|
|
})
|
|
}
|
|
|
|
/// A per-test project directory, removed on drop (including mid-test panic).
|
|
#[allow(dead_code)]
|
|
pub struct FreshProject {
|
|
root: PathBuf,
|
|
}
|
|
|
|
impl Drop for FreshProject {
|
|
fn drop(&mut self) {
|
|
let _ = std::fs::remove_dir_all(&self.root);
|
|
}
|
|
}
|
|
|
|
/// Mints a unique, empty tempdir under `std::env::temp_dir()`, named
|
|
/// `aura-e2e-<pid>-<counter>` so concurrent test threads (same process) and
|
|
/// concurrent `cargo test` processes never collide.
|
|
///
|
|
/// #258 sweep note: this site keeps `std::process::id()` deliberately, unlike
|
|
/// the fixed, pid-free sandbox names the rest of #258's fix uses — it has no
|
|
/// pre-create wipe to defeat in the first place. `FreshProject::drop` (above)
|
|
/// unconditionally removes the returned root when the owning test ends (pass,
|
|
/// fail, or panic-unwind), so nothing strands across runs; the pid instead
|
|
/// disambiguates concurrent `cargo test` *processes* racing this one function
|
|
/// from dozens of call sites, which a fixed tag-keyed name cannot do without
|
|
/// plumbing a unique tag through every one of them.
|
|
fn mint_tempdir() -> PathBuf {
|
|
static COUNTER: AtomicUsize = AtomicUsize::new(0);
|
|
let root = std::env::temp_dir().join(format!(
|
|
"aura-e2e-{}-{}",
|
|
std::process::id(),
|
|
COUNTER.fetch_add(1, Ordering::Relaxed)
|
|
));
|
|
std::fs::create_dir_all(&root).expect("create fresh project tempdir");
|
|
root
|
|
}
|
|
|
|
/// Writes the `Aura.toml` + `demo_signal.json` common to every fresh project:
|
|
/// an absolute `[nodes]` pointer at the shared, once-built fixture crate, and
|
|
/// — when `data_dir` is `Some` — a `[paths] data` override pointing at it
|
|
/// (relative, resolved against the project root — `root` itself — since
|
|
/// `Env::data_path` joins a relative `paths.data` onto the project root,
|
|
/// same as `runs_root()` and the `[nodes]` pointers, #254).
|
|
fn write_project_files(root: &Path, data_dir: Option<&str>) {
|
|
let fixture = built_project();
|
|
let data_line = data_dir.map(|d| format!("data = \"{d}\"\n")).unwrap_or_default();
|
|
std::fs::write(
|
|
root.join("Aura.toml"),
|
|
format!(
|
|
"[paths]\nruns = \"runs\"\n{data_line}\n[nodes]\ncrates = [\"{}\"]\n",
|
|
fixture.display()
|
|
),
|
|
)
|
|
.expect("write fresh project Aura.toml");
|
|
std::fs::copy(fixture.join("demo_signal.json"), root.join("demo_signal.json"))
|
|
.expect("copy demo_signal.json into the fresh project");
|
|
}
|
|
|
|
/// Mint a unique tempdir holding a 2-line data-level project (`Aura.toml`
|
|
/// with an absolute `[nodes]` pointer at the shared, once-built fixture
|
|
/// crate) plus a copy of `demo_signal.json`. Every test gets its own `runs/`
|
|
/// store under this root, so no two tests ever contend for the same store —
|
|
/// this is what lets `project_lock` disappear (#250). Bind the guard for the
|
|
/// whole test body: `let (dir, _g) = fresh_project();`.
|
|
#[allow(dead_code)]
|
|
pub fn fresh_project() -> (PathBuf, FreshProject) {
|
|
let root = mint_tempdir();
|
|
write_project_files(&root, None);
|
|
(root.clone(), FreshProject { root })
|
|
}
|
|
|
|
/// Like [`fresh_project`], but additionally generates a tiny synthetic M1
|
|
/// archive under `<root>/data/` (two symbols, `SYMA` spanning 2024-01..08 and
|
|
/// `SYMB` spanning 2024-03..06 — `SYMB` lies strictly inside `SYMA`) and
|
|
/// points `[paths] data` at it. For tests whose property is archive-window
|
|
/// (span) semantics, not archive size — no host tick-data mount needed, and
|
|
/// no `--real`-symbol data refusal is ever reachable, so gates on data
|
|
/// presence are dead code once a test switches to this helper (#250).
|
|
#[allow(dead_code)]
|
|
pub fn fresh_project_with_data() -> (PathBuf, FreshProject) {
|
|
let root = mint_tempdir();
|
|
write_project_files(&root, Some("data"));
|
|
let data_dir = root.join("data");
|
|
std::fs::create_dir_all(&data_dir).expect("create synthetic archive dir");
|
|
synthetic_data::write_symbol_archive(&data_dir, "SYMA", (2024, 1), (2024, 8));
|
|
synthetic_data::write_symbol_archive(&data_dir, "SYMB", (2024, 3), (2024, 6));
|
|
(root.clone(), FreshProject { root })
|
|
}
|
|
|
|
/// Like [`fresh_project_with_data`], but the single symbol `GAPSYM` carries a
|
|
/// DELIBERATE interior month gap: monthly files exist for 2024-01..2024-02 and
|
|
/// 2024-05..2024-06, while 2024-03..2024-04 are absent. This is the Copper
|
|
/// failure shape (#264 — files present at both ends while an interior window
|
|
/// has no data, so a first/last-bounds check passes but a campaign aborts
|
|
/// mid-run). For tests whose property is per-symbol month-gap reporting: the
|
|
/// two `write_symbol_archive` calls write disjoint contiguous segments into the
|
|
/// same `data/` dir (the second re-writes GAPSYM's identical geometry sidecar),
|
|
/// leaving March and April as a real hole in the monthly file index.
|
|
#[allow(dead_code)]
|
|
pub fn fresh_project_with_gapped_data() -> (PathBuf, FreshProject) {
|
|
let root = mint_tempdir();
|
|
write_project_files(&root, Some("data"));
|
|
let data_dir = root.join("data");
|
|
std::fs::create_dir_all(&data_dir).expect("create synthetic gapped archive dir");
|
|
synthetic_data::write_symbol_archive(&data_dir, "GAPSYM", (2024, 1), (2024, 2));
|
|
synthetic_data::write_symbol_archive(&data_dir, "GAPSYM", (2024, 5), (2024, 6));
|
|
(root.clone(), FreshProject { root })
|
|
}
|
|
|
|
/// A scratch filesystem entry a test writes under the git-tracked
|
|
/// demo-project fixture root (only `runs/` there is fixture-gitignored),
|
|
/// removed on drop — including during a mid-test panic — so a failed
|
|
/// assertion never leaks scratch docs into tracked working-tree state.
|
|
#[allow(dead_code)]
|
|
pub enum ScratchPath {
|
|
File(PathBuf),
|
|
Dir(PathBuf),
|
|
}
|
|
|
|
#[allow(dead_code)]
|
|
pub struct ScratchGuard(pub Vec<ScratchPath>);
|
|
|
|
impl Drop for ScratchGuard {
|
|
fn drop(&mut self) {
|
|
for p in &self.0 {
|
|
match p {
|
|
ScratchPath::File(p) => {
|
|
let _ = std::fs::remove_file(p);
|
|
}
|
|
ScratchPath::Dir(p) => {
|
|
let _ = std::fs::remove_dir_all(p);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A tiny, deterministic synthetic M1 archive generator: hand-packs the exact
|
|
/// on-disk format `data-server` reads (its `loader.rs` and `records.rs`) —
|
|
/// one zip per `SYMBOL_YYYY_MM.m1` holding a single `.bin` entry of packed
|
|
/// 48-byte `RawM1Record`s, plus a `SYMBOL.meta.json` geometry sidecar — so
|
|
/// the two no-window `generalize` E2E tests get a real, tiny, hostless
|
|
/// archive instead of streaming the 6.6 GB host archive (#250).
|
|
#[allow(dead_code)]
|
|
mod synthetic_data {
|
|
use std::io::Write;
|
|
use std::path::Path;
|
|
|
|
/// Days since the Unix epoch for a proleptic-Gregorian civil date.
|
|
/// Howard Hinnant's `days_from_civil` — dependency-free calendar math so
|
|
/// this module needs no `chrono` (data-server's own dependency, not
|
|
/// aura-cli's).
|
|
fn days_from_civil(y: i32, m: u32, d: u32) -> i64 {
|
|
let y = i64::from(if m <= 2 { y - 1 } else { y });
|
|
let era = if y >= 0 { y } else { y - 399 } / 400;
|
|
let yoe = y - era * 400; // [0, 399]
|
|
let mp = (i64::from(m) + 9) % 12; // [0, 11], Mar=0 .. Feb=11
|
|
let doy = (153 * mp + 2) / 5 + i64::from(d) - 1; // [0, 365]
|
|
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; // [0, 146096]
|
|
era * 146_097 + doe - 719_468
|
|
}
|
|
|
|
/// Unix milliseconds at `y-m-d hh:mm:00` UTC.
|
|
fn unix_ms(y: i32, m: u32, d: u32, hh: u32, mm: u32) -> i64 {
|
|
days_from_civil(y, m, d) * 86_400_000 + (i64::from(hh) * 3600 + i64::from(mm) * 60) * 1000
|
|
}
|
|
|
|
/// 0 = Sunday .. 6 = Saturday (1970-01-01 was a Thursday, day 0).
|
|
fn weekday(y: i32, m: u32, d: u32) -> i64 {
|
|
(days_from_civil(y, m, d) % 7 + 4 + 7) % 7
|
|
}
|
|
|
|
fn days_in_month(y: i32, m: u32) -> u32 {
|
|
match m {
|
|
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
|
|
4 | 6 | 9 | 11 => 30,
|
|
2 if (y % 4 == 0 && y % 100 != 0) || y % 400 == 0 => 29,
|
|
2 => 28,
|
|
_ => unreachable!("month out of range: {m}"),
|
|
}
|
|
}
|
|
|
|
/// A deterministic price at continuous minute offset `t` (minutes since
|
|
/// 2024-01-01T00:00 UTC, shared across both symbols so their overlapping
|
|
/// span carries the same underlying curve): a gentle upward trend plus a
|
|
/// 180-minute sine — short enough against a day's 480 tradeable minutes to
|
|
/// produce several SMA(3)/SMA(12) crossovers per day.
|
|
fn price_at(t_minutes: f64) -> f64 {
|
|
let hours = t_minutes / 60.0;
|
|
100.0 + 0.01 * hours + 5.0 * (2.0 * std::f64::consts::PI * t_minutes / 180.0).sin()
|
|
}
|
|
|
|
/// Packs one `RawM1Record`-shaped 48-byte little-endian record: `time`
|
|
/// (Delphi `TDateTime`, days since 1899-12-30 — the inverse of
|
|
/// `data_server::records::delphi_to_unix_ms`), `open/high/low/close`,
|
|
/// `spread` (f32), `volume` (i32).
|
|
fn pack_record(unix_ms_time: i64, open: f64, high: f64, low: f64, close: f64) -> [u8; 48] {
|
|
const DELPHI_EPOCH_OFFSET_DAYS: f64 = 25569.0;
|
|
const MS_PER_DAY: f64 = 86_400_000.0;
|
|
let dt = unix_ms_time as f64 / MS_PER_DAY + DELPHI_EPOCH_OFFSET_DAYS;
|
|
let mut rec = [0u8; 48];
|
|
rec[0..8].copy_from_slice(&dt.to_le_bytes());
|
|
rec[8..16].copy_from_slice(&open.to_le_bytes());
|
|
rec[16..24].copy_from_slice(&high.to_le_bytes());
|
|
rec[24..32].copy_from_slice(&low.to_le_bytes());
|
|
rec[32..40].copy_from_slice(&close.to_le_bytes());
|
|
rec[40..44].copy_from_slice(&0.5_f32.to_le_bytes());
|
|
rec[44..48].copy_from_slice(&100_i32.to_le_bytes());
|
|
rec
|
|
}
|
|
|
|
/// One month's worth of weekday, 08:00-16:00 UTC, 1-minute bars (~10k).
|
|
fn month_records(year: i32, month: u32) -> Vec<[u8; 48]> {
|
|
let epoch_ms = unix_ms(2024, 1, 1, 0, 0);
|
|
let mut records = Vec::new();
|
|
for day in 1..=days_in_month(year, month) {
|
|
if !(1..=5).contains(&weekday(year, month, day)) {
|
|
continue; // weekend
|
|
}
|
|
for hh in 8..16 {
|
|
for mm in 0..60 {
|
|
let ms = unix_ms(year, month, day, hh, mm);
|
|
let t = (ms - epoch_ms) as f64 / 60_000.0;
|
|
let (open, close) = (price_at(t), price_at(t + 1.0));
|
|
let high = open.max(close) + 0.2;
|
|
let low = open.min(close) - 0.2;
|
|
records.push(pack_record(ms, open, high, low, close));
|
|
}
|
|
}
|
|
}
|
|
records
|
|
}
|
|
|
|
/// Writes one `SYMBOL_YYYY_MM.m1` zip (a single `<SYMBOL>.bin` entry of
|
|
/// packed records — mirrors `data-server`'s own `create_test_m1_zip` test
|
|
/// helper) under `data_dir`.
|
|
fn write_month_zip(data_dir: &Path, symbol: &str, year: i32, month: u32) {
|
|
let path = data_dir.join(format!("{symbol}_{year:04}_{month:02}.m1"));
|
|
let file = std::fs::File::create(&path).expect("create synthetic m1 zip");
|
|
let mut zip = zip::ZipWriter::new(file);
|
|
zip.start_file::<String, ()>(format!("{symbol}.bin"), Default::default())
|
|
.expect("start synthetic m1 zip entry");
|
|
for rec in month_records(year, month) {
|
|
zip.write_all(&rec).expect("write synthetic m1 record");
|
|
}
|
|
zip.finish().expect("finish synthetic m1 zip");
|
|
}
|
|
|
|
/// Writes every month in `[from, to]` (inclusive, `(year, month)` pairs)
|
|
/// for `symbol`, plus its `<SYMBOL>.meta.json` geometry sidecar —
|
|
/// GER40-shaped (digits 1, pip/tick/lot sized like an index CFD) so the
|
|
/// R/stop machinery never hits "no recorded geometry".
|
|
pub fn write_symbol_archive(data_dir: &Path, symbol: &str, from: (i32, u32), to: (i32, u32)) {
|
|
let (mut y, mut m) = from;
|
|
loop {
|
|
write_month_zip(data_dir, symbol, y, m);
|
|
if (y, m) == to {
|
|
break;
|
|
}
|
|
m += 1;
|
|
if m > 12 {
|
|
m = 1;
|
|
y += 1;
|
|
}
|
|
}
|
|
std::fs::write(
|
|
data_dir.join(format!("{symbol}.meta.json")),
|
|
format!(
|
|
"{{\"schemaVersion\":2,\"symbol\":\"{symbol}\",\"digits\":1,\"pipSize\":1,\
|
|
\"tickSize\":0.1,\"lotSize\":1,\"baseAsset\":\"{symbol}\",\"quoteAsset\":\"EUR\"}}"
|
|
),
|
|
)
|
|
.expect("write synthetic geometry sidecar");
|
|
}
|
|
}
|