chore: cycle 0054 close — per-instrument pip; C10/C15 realization note, retire spec+plan
Audit (architect drift review + cargo test --workspace) clean: code matches the spec, the engine stayed domain-free (no instrument identity in Ctx / the hot path), InstrumentSpec sits beside the hot path per C7/C15. No regression scripts configured (no-op gate); the full suite is green. Ratify: the architect flagged one ledger-realization gap — C10/C15 described per-instrument pip as standing design intent but carried no realization note for the now-shipped channel. Added dated realization notes to C10 (the per-instrument pip channel + the refuse-don't-guess honesty rule) and C15 (instrument specs as metadata, first realized by InstrumentSpec/instrument_spec). Retire the ephemeral cycle artefacts (spec 0054, plan 0054) per the active-cycle lifecycle. Deferred follow-up: #98 (route the runnable GER40 examples through the lookup). refs #22
This commit is contained in:
@@ -531,6 +531,18 @@ across runs (C1). The **position-management half** — deriving the position-eve
|
|||||||
table (buy/sell/close, `position_id`, partial closes) from the exposure history,
|
table (buy/sell/close, `position_id`, partial closes) from the exposure history,
|
||||||
and the realistic broker nodes that consume it — is deliberately **not** built
|
and the realistic broker nodes that consume it — is deliberately **not** built
|
||||||
this cycle; it remains the decoupled, derived, deferred layer described above.
|
this cycle; it remains the decoupled, derived, deferred layer described above.
|
||||||
|
**Realization (per-instrument pip channel, 2026-06, #22).** `SimBroker`'s
|
||||||
|
`pip_size` is now sourced **per instrument**, not from one global literal. A typed
|
||||||
|
`InstrumentSpec { pip_size }` + `instrument_spec(symbol) -> Option<InstrumentSpec>`
|
||||||
|
lookup — a Rust-authored vetted table in `aura-ingest`, at the ingestion/source
|
||||||
|
edge where the symbol still exists (never `Aura.toml`, never `Ctx`) — supplies the
|
||||||
|
divisor; the engine stays domain-free (no instrument identity reaches the hot path).
|
||||||
|
The honesty rule is **refuse, don't guess**: a real-data run for a symbol with no
|
||||||
|
vetted spec is a usage error (`exit 2`), so cross-asset pip equity is comparable by
|
||||||
|
construction rather than by researcher discipline. Threaded through the CLI
|
||||||
|
`aura run --real` path; the manifest broker label records the looked-up pip. The
|
||||||
|
runnable GER40 examples (already at the correct `1.0`) are routed through the same
|
||||||
|
lookup separately (#98).
|
||||||
|
|
||||||
### C11 — Generalized sources; record-then-replay determinism boundary
|
### C11 — Generalized sources; record-then-replay determinism boundary
|
||||||
**Guarantee.** A source is anything that produces timestamped scalar streams —
|
**Guarantee.** A source is anything that produces timestamped scalar streams —
|
||||||
@@ -626,6 +638,12 @@ session open" is then a plain node checking `bars_since_open == 3`.
|
|||||||
stream model.
|
stream model.
|
||||||
**Why.** Keeps the line consistent — everything a signal needs arrives as a
|
**Why.** Keeps the line consistent — everything a signal needs arrives as a
|
||||||
stream; reference data feeds source/session nodes from beside the hot path.
|
stream; reference data feeds source/session nodes from beside the hot path.
|
||||||
|
**Realization (instrument specs, 2026-06, #22).** The "instrument specs are
|
||||||
|
metadata" half of this contract is first realized by `aura-ingest`'s
|
||||||
|
`InstrumentSpec { pip_size }` + `instrument_spec(symbol)` — non-scalar reference
|
||||||
|
data held beside the hot path, keyed by symbol, feeding the sim-optimal broker's
|
||||||
|
pip divisor (C10). Minimal today (pip only); extensible to tick size / digits /
|
||||||
|
quote currency without a signature break.
|
||||||
|
|
||||||
### C16 — Engine / project separation; three-tier node reuse
|
### C16 — Engine / project separation; three-tier node reuse
|
||||||
**Guarantee.** aura is the reusable **engine**; each research project is a
|
**Guarantee.** aura is the reusable **engine**; each research project is a
|
||||||
|
|||||||
@@ -1,321 +0,0 @@
|
|||||||
# Per-Instrument Pip Metadata — Implementation Plan
|
|
||||||
|
|
||||||
> **Parent spec:** `docs/specs/0054-per-instrument-pip.md`
|
|
||||||
>
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: use the `implement` skill to run
|
|
||||||
> this plan. Steps use `- [ ]` checkboxes for tracking.
|
|
||||||
|
|
||||||
**Goal:** Add a typed per-instrument pip metadata channel (`InstrumentSpec` +
|
|
||||||
`instrument_spec(symbol)`) and thread the looked-up pip through the CLI
|
|
||||||
`aura run --real` path, refusing an un-specced instrument.
|
|
||||||
|
|
||||||
**Architecture:** A new `InstrumentSpec { pip_size }` + `instrument_spec(symbol)`
|
|
||||||
static vetted lookup in `aura-ingest`. The CLI's `sample_harness` and
|
|
||||||
`sim_optimal_manifest` gain a `pip_size: f64` parameter; `run_sample_real` looks
|
|
||||||
up the pip by symbol *before any data access* (refusing on `None`) and threads it;
|
|
||||||
every other (synthetic) caller passes the unchanged `0.0001`. `SimBroker` is
|
|
||||||
untouched.
|
|
||||||
|
|
||||||
**Tech Stack:** `aura-ingest/src/lib.rs` (lookup), `aura-cli/src/main.rs` (CLI
|
|
||||||
threading + refusal), `aura-cli/tests/cli_run.rs` (integration test via
|
|
||||||
`std::process::Command` + `env!("CARGO_BIN_EXE_aura")`).
|
|
||||||
|
|
||||||
**Files this plan creates or modifies:**
|
|
||||||
|
|
||||||
- Modify: `crates/aura-ingest/src/lib.rs` — add `InstrumentSpec` + `instrument_spec`
|
|
||||||
(insert at ~`:341`, after `default_data_server`); unit test in the existing
|
|
||||||
`#[cfg(test)] mod tests` (`:342`).
|
|
||||||
- Modify: `crates/aura-cli/src/main.rs` — `sample_harness` (`:73`) +
|
|
||||||
`sim_optimal_manifest` (`:130`) signatures; `run_sample` (`:149`),
|
|
||||||
`run_sample_real` (`:183`); the 10 synthetic callers; an in-file `#[cfg(test)]`
|
|
||||||
manifest-pip unit test + gated GER40 honesty test.
|
|
||||||
- Test: `crates/aura-cli/tests/cli_run.rs` — NON-gated refusal integration test
|
|
||||||
(synthetic pin at `:35` untouched).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 1: `InstrumentSpec` + `instrument_spec` lookup (aura-ingest)
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `crates/aura-ingest/src/lib.rs` (add struct + fn ~`:341`; test in `mod tests` `:342`)
|
|
||||||
|
|
||||||
- [ ] **Step 1: Write the failing unit test**
|
|
||||||
|
|
||||||
In `crates/aura-ingest/src/lib.rs`, inside `#[cfg(test)] mod tests` (after the
|
|
||||||
`use super::*;` at `:344`), add:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
#[test]
|
|
||||||
fn instrument_spec_lookup_by_symbol() {
|
|
||||||
// index points → pip 1.0
|
|
||||||
assert_eq!(instrument_spec("GER40"), Some(InstrumentSpec { pip_size: 1.0 }));
|
|
||||||
assert_eq!(instrument_spec("FRA40"), Some(InstrumentSpec { pip_size: 1.0 }));
|
|
||||||
// 5-decimal FX major → pip 0.0001
|
|
||||||
assert_eq!(instrument_spec("EURUSD"), Some(InstrumentSpec { pip_size: 0.0001 }));
|
|
||||||
// un-specced → None (the honesty lever)
|
|
||||||
assert_eq!(instrument_spec("NONEXISTENT"), None);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run test to verify it fails**
|
|
||||||
|
|
||||||
Run: `cargo test -p aura-ingest instrument_spec_lookup_by_symbol`
|
|
||||||
Expected: FAIL — compile error `cannot find function 'instrument_spec' in this scope`
|
|
||||||
(and `cannot find type 'InstrumentSpec'`).
|
|
||||||
|
|
||||||
- [ ] **Step 3: Write minimal implementation**
|
|
||||||
|
|
||||||
In `crates/aura-ingest/src/lib.rs`, immediately after the `default_data_server`
|
|
||||||
function (ends `:340`), insert:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
/// Per-instrument reference metadata held beside the hot path (C7/C15): non-scalar,
|
|
||||||
/// never streamed. Minimal today — extend with tick size / digits / quote currency
|
|
||||||
/// as later cycles need, without breaking this signature.
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
|
||||||
pub struct InstrumentSpec {
|
|
||||||
/// Price units per pip / point (> 0). The sim-optimal broker's divisor.
|
|
||||||
pub pip_size: f64,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Look up per-instrument reference metadata by `symbol` at the ingestion edge.
|
|
||||||
/// Rust-authored vetted table (NOT `Aura.toml`); `None` for an instrument with no
|
|
||||||
/// vetted spec — the caller must refuse rather than guess a pip.
|
|
||||||
///
|
|
||||||
/// Seeded with the instruments the engine's own paths exercise. Indices quote in
|
|
||||||
/// points (pip = 1.0); 5-decimal FX majors = 0.0001. NB: JPY pairs are 0.01, NOT
|
|
||||||
/// 0.0001 — only vetted values are seeded.
|
|
||||||
pub fn instrument_spec(symbol: &str) -> Option<InstrumentSpec> {
|
|
||||||
let pip_size = match symbol {
|
|
||||||
"GER40" | "FRA40" => 1.0,
|
|
||||||
"EURUSD" | "GBPUSD" | "USDCAD" => 0.0001,
|
|
||||||
_ => return None,
|
|
||||||
};
|
|
||||||
Some(InstrumentSpec { pip_size })
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Run test to verify it passes**
|
|
||||||
|
|
||||||
Run: `cargo test -p aura-ingest instrument_spec_lookup_by_symbol`
|
|
||||||
Expected: PASS (1 test).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 2: Thread pip + refusal through the CLI (aura-cli)
|
|
||||||
|
|
||||||
This task changes two function signatures in `main.rs`; the crate does not compile
|
|
||||||
until **all** call sites are threaded, so every caller is updated within this task
|
|
||||||
and the compile gate (Step 9) closes it. RED-first via the refusal integration test
|
|
||||||
(it compiles against today's binary and asserts the new message).
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `crates/aura-cli/src/main.rs`
|
|
||||||
- Test: `crates/aura-cli/tests/cli_run.rs`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Write the failing refusal integration test**
|
|
||||||
|
|
||||||
In `crates/aura-cli/tests/cli_run.rs`, add (mirroring the existing
|
|
||||||
`std::process::Command` + `env!("CARGO_BIN_EXE_aura")` pattern):
|
|
||||||
|
|
||||||
```rust
|
|
||||||
#[test]
|
|
||||||
fn run_real_unspecced_symbol_refuses_with_exit_2() {
|
|
||||||
// The instrument_spec lookup precedes any data access, so an un-specced symbol
|
|
||||||
// refuses with NO local data required (CI-safe). "NONEXISTENT" is not in the
|
|
||||||
// vetted table.
|
|
||||||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
|
||||||
.args(["run", "--real", "NONEXISTENT"])
|
|
||||||
.output()
|
|
||||||
.expect("spawn aura");
|
|
||||||
assert_eq!(out.status.code(), Some(2), "expected exit 2");
|
|
||||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
|
||||||
assert!(
|
|
||||||
stderr.contains("no vetted pip/instrument spec for symbol 'NONEXISTENT'"),
|
|
||||||
"expected the per-instrument-pip refusal message, got: {stderr}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run test to verify it fails**
|
|
||||||
|
|
||||||
Run: `cargo test -p aura-cli --test cli_run run_real_unspecced_symbol_refuses_with_exit_2`
|
|
||||||
Expected: FAIL — exit code is `2` but the stderr is today's
|
|
||||||
`no local data for symbol 'NONEXISTENT'`, not the new
|
|
||||||
`no vetted pip/instrument spec ...` refusal message (assertion fails).
|
|
||||||
|
|
||||||
- [ ] **Step 3: Change `sample_harness` signature**
|
|
||||||
|
|
||||||
In `crates/aura-cli/src/main.rs`, change the `sample_harness` definition (`:73`):
|
|
||||||
|
|
||||||
```rust
|
|
||||||
fn sample_harness(pip_size: f64) -> (
|
|
||||||
```
|
|
||||||
|
|
||||||
and inside its body change the broker construction (`:91`, `:100`):
|
|
||||||
|
|
||||||
```rust
|
|
||||||
Box::new(SimBroker::new(pip_size)), // 4 sim-optimal broker
|
|
||||||
```
|
|
||||||
```rust
|
|
||||||
SimBroker::builder(pip_size).schema().clone(),
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Change `sim_optimal_manifest` signature + body**
|
|
||||||
|
|
||||||
Change the `sim_optimal_manifest` definition (`:130`) to take `pip_size`:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
fn sim_optimal_manifest(
|
|
||||||
params: Vec<(String, Scalar)>,
|
|
||||||
window: (Timestamp, Timestamp),
|
|
||||||
seed: u64,
|
|
||||||
pip_size: f64,
|
|
||||||
) -> RunManifest {
|
|
||||||
```
|
|
||||||
|
|
||||||
and change the hand-built broker literal (`:142`) to a `format!`:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
broker: format!("sim-optimal(pip_size={pip_size})"),
|
|
||||||
```
|
|
||||||
|
|
||||||
(`format!("{}", 0.0001_f64)` renders `0.0001`; `format!("{}", 1.0_f64)` renders `1`.)
|
|
||||||
|
|
||||||
- [ ] **Step 5: Thread `run_sample` (synthetic — pass `0.0001`)**
|
|
||||||
|
|
||||||
In `run_sample` (`:149`): the `sample_harness()` call (`:150`) becomes
|
|
||||||
`sample_harness(0.0001)`, and the `sim_optimal_manifest(...)` call (`:163`) gains a
|
|
||||||
trailing `0.0001` argument after its `0,` seed line:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
let (mut h, rx_eq, rx_ex) = sample_harness(0.0001);
|
|
||||||
```
|
|
||||||
```rust
|
|
||||||
manifest: sim_optimal_manifest(
|
|
||||||
vec![
|
|
||||||
("sma_fast".to_string(), Scalar::i64(2)),
|
|
||||||
("sma_slow".to_string(), Scalar::i64(4)),
|
|
||||||
("exposure_scale".to_string(), Scalar::f64(0.5)),
|
|
||||||
],
|
|
||||||
window,
|
|
||||||
0,
|
|
||||||
0.0001,
|
|
||||||
),
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 6: Thread `run_sample_real` — lookup FIRST, refuse, pass the pip**
|
|
||||||
|
|
||||||
In `run_sample_real` (`:183`), insert the lookup as the very first statement
|
|
||||||
(before the `sample_harness()` call), refuse on `None`, then pass `spec.pip_size`
|
|
||||||
to both `sample_harness` (`:184`) and `sim_optimal_manifest` (`:226`):
|
|
||||||
|
|
||||||
```rust
|
|
||||||
fn run_sample_real(symbol: &str, from_ms: Option<i64>, to_ms: Option<i64>) -> RunReport {
|
|
||||||
// Per-instrument pip: look up BEFORE any data access, so an un-specced symbol
|
|
||||||
// refuses without touching local data. Honest by construction (#22).
|
|
||||||
let spec = match aura_ingest::instrument_spec(symbol) {
|
|
||||||
Some(s) => s,
|
|
||||||
None => {
|
|
||||||
eprintln!("aura: no vetted pip/instrument spec for symbol '{symbol}' — refusing to run a real instrument with a guessed pip (add it to the instrument table)");
|
|
||||||
std::process::exit(2);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let (mut h, rx_eq, rx_ex) = sample_harness(spec.pip_size);
|
|
||||||
```
|
|
||||||
|
|
||||||
and the `sim_optimal_manifest(...)` call (`:226`) gains `spec.pip_size` after its
|
|
||||||
`0,` seed line:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
manifest: sim_optimal_manifest(
|
|
||||||
vec![
|
|
||||||
("sma_fast".to_string(), Scalar::i64(2)),
|
|
||||||
("sma_slow".to_string(), Scalar::i64(4)),
|
|
||||||
("exposure_scale".to_string(), Scalar::f64(0.5)),
|
|
||||||
],
|
|
||||||
window,
|
|
||||||
0,
|
|
||||||
spec.pip_size,
|
|
||||||
),
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 7: Thread the remaining synthetic callers (all pass `0.0001`)**
|
|
||||||
|
|
||||||
These are the call sites `rustc` flags after Steps 3-4; every one is a synthetic
|
|
||||||
path → pass `0.0001` (behaviour-preserving, no emitted-pip change). Apply:
|
|
||||||
|
|
||||||
`sample_harness` callers — `sample_harness()` → `sample_harness(0.0001)` at:
|
|
||||||
- `:589` (`mc_family` per-draw closure)
|
|
||||||
- `:946` (in-file `#[cfg(test)]` caller)
|
|
||||||
|
|
||||||
`sim_optimal_manifest` callers — add a trailing `0.0001` argument at:
|
|
||||||
- `:379` one-liner: `sim_optimal_manifest(zip_params(&space, point), window, 0)` →
|
|
||||||
`sim_optimal_manifest(zip_params(&space, point), window, 0, 0.0001)`
|
|
||||||
- `:498` one-liner: `sim_optimal_manifest(zip_params(&space, point), window, 0)` →
|
|
||||||
`sim_optimal_manifest(zip_params(&space, point), window, 0, 0.0001)`
|
|
||||||
- `:520` one-liner: `sim_optimal_manifest(zip_params(&space, params), window, 0)` →
|
|
||||||
`sim_optimal_manifest(zip_params(&space, params), window, 0, 0.0001)`
|
|
||||||
- `:597` multi-line — after its `seed,` line, before the closing `)`, add `0.0001,`
|
|
||||||
- `:853` multi-line — after its `0,` line, before the closing `)`, add `0.0001,`
|
|
||||||
- `:954` multi-line — after its `seed,` line, before the closing `)`, add `0.0001,`
|
|
||||||
|
|
||||||
- [ ] **Step 8: Add the manifest-pip unit test + gated GER40 honesty test**
|
|
||||||
|
|
||||||
In `crates/aura-cli/src/main.rs`, inside the in-file `#[cfg(test)] mod` (alongside
|
|
||||||
the existing `run_sample_real_streams_real_close_bars_deterministically`), add:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
#[test]
|
|
||||||
fn sim_optimal_manifest_renders_per_instrument_pip() {
|
|
||||||
let m = sim_optimal_manifest(vec![], (Timestamp(1), Timestamp(2)), 0, 1.0);
|
|
||||||
assert_eq!(m.broker, "sim-optimal(pip_size=1)");
|
|
||||||
let m2 = sim_optimal_manifest(vec![], (Timestamp(1), Timestamp(2)), 0, 0.0001);
|
|
||||||
assert_eq!(m2.broker, "sim-optimal(pip_size=0.0001)");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn run_real_ger40_uses_index_pip() {
|
|
||||||
// Gated: needs local GER40 data. Mirrors the existing real-path test's skip.
|
|
||||||
let server = data_server::DataServer::new(data_server::DEFAULT_DATA_PATH);
|
|
||||||
if !server.has_symbol("GER40") {
|
|
||||||
eprintln!("skip: no local GER40 data at {}", data_server::DEFAULT_DATA_PATH);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let report = run_sample_real("GER40", None, None);
|
|
||||||
// The looked-up index pip (1.0) reaches the manifest — not the FX 0.0001.
|
|
||||||
assert_eq!(report.manifest.broker, "sim-optimal(pip_size=1)");
|
|
||||||
// Deterministic (C1): a second run yields the same report.
|
|
||||||
let again = run_sample_real("GER40", None, None);
|
|
||||||
assert_eq!(report.manifest.broker, again.manifest.broker);
|
|
||||||
assert_eq!(report.metrics.total_pips, again.metrics.total_pips);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 9: Compile gate — workspace builds with 0 errors**
|
|
||||||
|
|
||||||
Run: `cargo build --workspace`
|
|
||||||
Expected: finishes with 0 errors (the two signature changes + all 12 call sites
|
|
||||||
thread cleanly).
|
|
||||||
|
|
||||||
- [ ] **Step 10: Run the new + regression tests**
|
|
||||||
|
|
||||||
Run: `cargo test -p aura-cli --test cli_run run_real_unspecced_symbol_refuses_with_exit_2`
|
|
||||||
Expected: PASS (the refusal message now matches).
|
|
||||||
|
|
||||||
Run: `cargo test -p aura-cli sim_optimal_manifest_renders_per_instrument_pip`
|
|
||||||
Expected: PASS.
|
|
||||||
|
|
||||||
Run: `cargo test -p aura-cli --test cli_run run_prints_json_and_exits_zero`
|
|
||||||
Expected: PASS — the synthetic run still emits `sim-optimal(pip_size=0.0001)`
|
|
||||||
(`cli_run.rs:35` unchanged).
|
|
||||||
|
|
||||||
Run: `cargo test -p aura-cli run_real_ger40_uses_index_pip`
|
|
||||||
Expected: PASS (or a clean skip line if no local GER40 data).
|
|
||||||
|
|
||||||
- [ ] **Step 11: Full workspace test sweep (no regressions)**
|
|
||||||
|
|
||||||
Run: `cargo test --workspace`
|
|
||||||
Expected: PASS — including the aura-ingest seam tests `real_bars.rs` /
|
|
||||||
`streaming_seam.rs` (AAPL.US at `0.0001`, built literally — unaffected by the CLI
|
|
||||||
signature change).
|
|
||||||
@@ -1,235 +0,0 @@
|
|||||||
# Per-Instrument Pip Metadata — Design Spec
|
|
||||||
|
|
||||||
**Date:** 2026-06-18
|
|
||||||
**Status:** Draft — awaiting user spec review
|
|
||||||
**Authors:** orchestrator + Claude
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
|
|
||||||
The data-server symbol stream carries only price bars — it brings **no instrument
|
|
||||||
metadata**, in particular no pip/point size. That absence is the bug behind #22:
|
|
||||||
the sim-optimal broker is therefore constructed with a single global `pip_size`
|
|
||||||
literal (`0.0001`, correct only for 5-decimal FX), so on the CLI real path
|
|
||||||
`aura run --real <symbol>` produces wrong-unit pip equity for any non-FX instrument
|
|
||||||
(issue-#22 table: `AAPL.US` → `-8,693,884`, `BTCUSD` → `-3.2e9`).
|
|
||||||
|
|
||||||
This cycle adds the missing **channel to specify per-instrument pip metadata**, and
|
|
||||||
threads it through the one observably-broken path — the CLI `aura run --real`.
|
|
||||||
|
|
||||||
**Scope (narrowed, user-directed 2026-06-18, recorded on #22):** the metadata
|
|
||||||
channel + the CLI real path + the unknown-symbol refusal. The runnable real-data
|
|
||||||
examples (`ger40_breakout_*`) already bake the **correct** pip (`1.0` for the GER40/
|
|
||||||
FRA40 indices) — they are *not* buggy; centralizing them through the lookup is a
|
|
||||||
consistency cleanup **deferred to a follow-up `idea` issue**. `SimBroker` is
|
|
||||||
unchanged. Portfolio/multi-instrument machinery is out of scope (`SimBroker` is and
|
|
||||||
stays single-instrument); **not** via `Aura.toml`.
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
Two new pieces + one threaded path, placed by the crate dependency graph
|
|
||||||
(`aura-cli → aura-ingest → {aura-std, aura-engine, aura-core}`):
|
|
||||||
|
|
||||||
1. **`InstrumentSpec` — a typed per-instrument reference-metadata carrier**, home
|
|
||||||
**`aura-ingest`** (the ingestion/source edge, where `symbol` exists). Minimal
|
|
||||||
today (`pip_size` only); extensible (tick size, digits, quote currency) without a
|
|
||||||
signature break. It is metadata beside the hot path (C4/C7/C15): never streamed,
|
|
||||||
never on `Ctx`; the engine stays domain-free.
|
|
||||||
|
|
||||||
2. **`instrument_spec(symbol) -> Option<InstrumentSpec>` — the lookup**, home
|
|
||||||
**`aura-ingest`**. A static, Rust-authored vetted table (NOT `Aura.toml` — the
|
|
||||||
later `Aura.toml` schema can subsume it). `None` for an unknown symbol: the
|
|
||||||
honesty lever — a real run for an instrument with no vetted pip cannot proceed
|
|
||||||
with a guessed value.
|
|
||||||
|
|
||||||
3. **CLI real path threads the looked-up pip.** `SimBroker` keeps its `pip_size: f64`
|
|
||||||
divisor constructor (synthetic/seam/test fixtures legitimately build it with an
|
|
||||||
explicit value). The honesty is realized at the real-data call site, which obtains
|
|
||||||
pip **only** through `instrument_spec(symbol)` and never a literal.
|
|
||||||
|
|
||||||
## Concrete code shapes
|
|
||||||
|
|
||||||
### Worked user-facing behaviour (the acceptance evidence)
|
|
||||||
|
|
||||||
The same command over differently-quoted instruments yields honestly-scaled pip
|
|
||||||
equity, with no pip literal in the invocation; an un-specced instrument refuses:
|
|
||||||
|
|
||||||
```text
|
|
||||||
$ aura run --real GER40 --from … --to …
|
|
||||||
{"manifest":{…,"broker":"sim-optimal(pip_size=1)"}, "metrics":{…}} # index → pip 1.0
|
|
||||||
$ aura run --real EURUSD --from … --to …
|
|
||||||
{"manifest":{…,"broker":"sim-optimal(pip_size=0.0001)"}, "metrics":{…}} # 5-dp FX → pip 0.0001
|
|
||||||
$ aura run --real BTCUSD
|
|
||||||
aura: no vetted pip/instrument spec for symbol 'BTCUSD' — refusing to run a real
|
|
||||||
instrument with a guessed pip (add it to the instrument table). [exit 2]
|
|
||||||
```
|
|
||||||
|
|
||||||
GER40 equity is now scaled by `1.0` (was `0.0001`), so it is comparable to a real FX
|
|
||||||
run — this is the cross-asset mismatch the feature exists to fix, shown end-to-end.
|
|
||||||
|
|
||||||
### New: `InstrumentSpec` + `instrument_spec` (aura-ingest)
|
|
||||||
|
|
||||||
```rust
|
|
||||||
// crates/aura-ingest/src/lib.rs (new)
|
|
||||||
|
|
||||||
/// Per-instrument reference metadata held beside the hot path (C7/C15): non-scalar,
|
|
||||||
/// never streamed. Minimal today — extend with tick size / digits / quote currency
|
|
||||||
/// as later cycles need, without breaking this signature.
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
|
||||||
pub struct InstrumentSpec {
|
|
||||||
/// Price units per pip / point (> 0). The sim-optimal broker's divisor.
|
|
||||||
pub pip_size: f64,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Look up per-instrument reference metadata by `symbol` at the ingestion edge.
|
|
||||||
/// Rust-authored vetted table (NOT `Aura.toml`); `None` for an instrument with no
|
|
||||||
/// vetted spec — the caller must refuse rather than guess a pip.
|
|
||||||
pub fn instrument_spec(symbol: &str) -> Option<InstrumentSpec> {
|
|
||||||
// SHAPE: explicit per-symbol entries, None for unknown. Indices quote in points
|
|
||||||
// (pip = 1.0); 5-decimal FX = 0.0001. NB: JPY pairs are 0.01, NOT 0.0001 — seed
|
|
||||||
// only vetted values. Planner pins the full set against the data-server symbols.
|
|
||||||
let pip = match symbol {
|
|
||||||
"GER40" | "FRA40" => 1.0, // index points
|
|
||||||
"EURUSD" | "GBPUSD" | "USDCAD" => 0.0001, // 5-dp FX majors (vetted subset)
|
|
||||||
_ => return None,
|
|
||||||
};
|
|
||||||
Some(InstrumentSpec { pip_size: pip })
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Changed: CLI real path (aura-cli)
|
|
||||||
|
|
||||||
```rust
|
|
||||||
// crates/aura-cli/src/main.rs
|
|
||||||
|
|
||||||
// BEFORE: sample_harness hard-codes the broker pip; the manifest label is a literal
|
|
||||||
fn sample_harness() -> (Harness, …) { … SimBroker::new(0.0001) … } // :91
|
|
||||||
fn sim_optimal_manifest(params, window, seed) -> RunManifest {
|
|
||||||
RunManifest { …, broker: "sim-optimal(pip_size=0.0001)".to_string() } // :142
|
|
||||||
}
|
|
||||||
|
|
||||||
// AFTER: pip is a parameter, threaded from the per-instrument lookup
|
|
||||||
fn sample_harness(pip_size: f64) -> (Harness, …) { … SimBroker::new(pip_size) … }
|
|
||||||
fn sim_optimal_manifest(params, window, seed, pip_size: f64) -> RunManifest {
|
|
||||||
RunManifest { …, broker: format!("sim-optimal(pip_size={pip_size})") } // 1.0→"1", 0.0001→"0.0001"
|
|
||||||
}
|
|
||||||
|
|
||||||
fn run_sample() -> RunReport { // synthetic — UNCHANGED behaviour
|
|
||||||
let (mut h, …) = sample_harness(0.0001); // sample instrument; cli_run.rs:35 pin holds
|
|
||||||
… sim_optimal_manifest(…, 0.0001) …
|
|
||||||
}
|
|
||||||
|
|
||||||
fn run_sample_real(symbol: &str, from_ms, to_ms) -> RunReport {
|
|
||||||
// Look up FIRST — before any data access — so an un-specced symbol refuses
|
|
||||||
// without touching local data (CI-testable, no fixture).
|
|
||||||
let spec = match aura_ingest::instrument_spec(symbol) {
|
|
||||||
Some(s) => s,
|
|
||||||
None => { eprintln!("aura: no vetted pip/instrument spec for symbol '{symbol}' …"); std::process::exit(2); }
|
|
||||||
};
|
|
||||||
let (mut h, …) = sample_harness(spec.pip_size);
|
|
||||||
… // unchanged streaming over M1FieldSource::open(symbol, …); has_symbol/window guards follow
|
|
||||||
… sim_optimal_manifest(…, spec.pip_size) …
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`SimBroker::label()` already renders `SimBroker({pip_size})` (sim_broker.rs:99), so
|
|
||||||
the node label tracks the looked-up pip; only the manifest *string* (a hand-built
|
|
||||||
literal today) needs parameterizing.
|
|
||||||
|
|
||||||
### Call-site propagation (workspace-wide, mechanical)
|
|
||||||
|
|
||||||
Two signatures gain a `pip_size` parameter; the type checker enumerates every
|
|
||||||
caller, and every caller that is NOT the real path passes its existing `0.0001`
|
|
||||||
unchanged — behaviour-preserving, no emitted-pip change, so existing drift pins stay
|
|
||||||
green:
|
|
||||||
|
|
||||||
- `sample_harness(pip_size)` — **4** sites (main.rs:150,184,589,946). `run_sample_real`
|
|
||||||
passes the lookup; the synthetic `run_sample` + the two enriched/seeded sample
|
|
||||||
callers pass `0.0001`.
|
|
||||||
- `sim_optimal_manifest(…, pip_size)` — **8** sites (main.rs:163,226,379,498,520,597,853,954).
|
|
||||||
`run_sample_real` passes the lookup; the synthetic run / sweep / walkforward
|
|
||||||
(IS+OOS) / MC / macd / seeded callers pass `0.0001`.
|
|
||||||
|
|
||||||
The exact enumeration is `rustc`'s: the signature change is the complete forcing
|
|
||||||
function; the rule is "the real path passes the lookup, every other caller passes its
|
|
||||||
existing `0.0001`."
|
|
||||||
|
|
||||||
## Components
|
|
||||||
|
|
||||||
| Component | Crate | Change |
|
|
||||||
|---|---|---|
|
|
||||||
| `InstrumentSpec` struct | aura-ingest | **new** — `{ pip_size: f64 }`, derives Clone/Copy/Debug/PartialEq |
|
|
||||||
| `instrument_spec(symbol)` | aura-ingest | **new** — static vetted table, `Option<InstrumentSpec>` |
|
|
||||||
| `sample_harness` | aura-cli | takes `pip_size: f64` (4 call sites propagate) |
|
|
||||||
| `sim_optimal_manifest` | aura-cli | takes `pip_size: f64`, formats it into the broker string (8 call sites propagate) |
|
|
||||||
| `run_sample_real` | aura-cli | look up by symbol FIRST; refuse (exit 2) on `None`; thread pip |
|
|
||||||
| `run_sample` + synthetic/sweep/mc/wf/macd callers | aura-cli | pass the unchanged `0.0001` |
|
|
||||||
| `SimBroker` | aura-std | **unchanged** (keeps `new(f64)` / `builder(f64)`) |
|
|
||||||
|
|
||||||
## Data flow
|
|
||||||
|
|
||||||
Real path (changed): `symbol` → `instrument_spec(symbol)` (before any data access) →
|
|
||||||
`InstrumentSpec { pip_size }` → `sample_harness(pip_size)` builds `SimBroker::new(pip_size)`
|
|
||||||
→ equity integrates `exposure·(Δprice)/pip_size` (sim_broker.rs:90) →
|
|
||||||
`sim_optimal_manifest(…, pip_size)` records the real pip in the manifest broker
|
|
||||||
string. No pip literal on the path.
|
|
||||||
|
|
||||||
Synthetic / seam / test paths (unchanged): construct `SimBroker::new(<literal>)`
|
|
||||||
directly; no lookup.
|
|
||||||
|
|
||||||
## Error handling
|
|
||||||
|
|
||||||
- **Unknown / un-specced instrument on a real run (new guard, checked FIRST):**
|
|
||||||
`run_sample_real` calls `instrument_spec(symbol)` before any data access. `None` →
|
|
||||||
a clear stderr refusal + `exit(2)`, without touching local data (so it is
|
|
||||||
observable and CI-testable with no fixture). Deliberate, observable **behaviour
|
|
||||||
change**: a real-data run for a non-seeded symbol previously produced silent
|
|
||||||
wrong-unit pips and now refuses (user-directed, recorded on #22).
|
|
||||||
- **Downstream guards unchanged and not depended on.** A specced symbol flows through
|
|
||||||
the existing `run_sample_real` body untouched — the `has_symbol`/empty-window usage
|
|
||||||
error and the streaming run are not modified; the spec adds a guard *ahead* of them
|
|
||||||
and rests on no current-behaviour claim about that pre-existing path.
|
|
||||||
- `pip_size > 0` is already asserted in `SimBroker::new` (sim_broker.rs:48); the table
|
|
||||||
holds only positive values.
|
|
||||||
|
|
||||||
## Testing strategy
|
|
||||||
|
|
||||||
- **Lookup unit (aura-ingest):** `instrument_spec("GER40") == Some(pip_size:1.0)`;
|
|
||||||
`instrument_spec("EURUSD") == Some(0.0001)`; `instrument_spec("NONEXISTENT") == None`.
|
|
||||||
- **Manifest pip (aura-cli):** `sim_optimal_manifest(…, 1.0)` → `broker ==
|
|
||||||
"sim-optimal(pip_size=1)"`; with `0.0001` → the existing string. Pins the exact
|
|
||||||
`format!("{pip_size}")` rendering (`1.0_f64` → `1`).
|
|
||||||
- **Refusal (NON-gated, CI-covered):** the lookup precedes data access, so
|
|
||||||
`aura run --real <un-specced-symbol>` exits `2` with the refusal message and **no
|
|
||||||
local data** — a plain `cli_run.rs` integration test, closing the current
|
|
||||||
zero-coverage of the `--real` path's refusal branch.
|
|
||||||
- **Real-path honesty (gated, skip if no local data — same gating as `real_bars.rs`):**
|
|
||||||
`aura run --real GER40 <window>` emits `sim-optimal(pip_size=1)` and equity scaled
|
|
||||||
by `1.0`; deterministic across two runs (C1).
|
|
||||||
- **Regression (unchanged behaviour):** the synthetic `aura run` still emits
|
|
||||||
`sim-optimal(pip_size=0.0001)` — `cli_run.rs:35` untouched; the seam tests
|
|
||||||
`real_bars.rs` / `streaming_seam.rs` (AAPL.US at 0.0001, a mechanism fixture) stay
|
|
||||||
green.
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
|
|
||||||
aura's feature-acceptance criterion (CLAUDE.md): the audience reaches for it, it
|
|
||||||
measurably improves correctness, and it reintroduces no failure class a core
|
|
||||||
invariant exists to prevent.
|
|
||||||
|
|
||||||
1. **Reached for naturally.** A researcher runs `aura run --real <symbol>` with no
|
|
||||||
pip literal; the engine supplies the right pip from the metadata channel.
|
|
||||||
2. **Measurably more correct.** `--real GER40` equity is scaled by `1.0`, not
|
|
||||||
`0.0001`; an un-specced instrument refuses instead of emitting nonsense — the
|
|
||||||
issue-#22 wrong-unit class is removed on the CLI real path (the worked example
|
|
||||||
spans GER40=1.0 vs EURUSD=0.0001 vs BTCUSD→refuse).
|
|
||||||
3. **No invariant regressed.** Pip stays non-scalar reference metadata beside the hot
|
|
||||||
path (C4/C7/C15) — never streamed, never on `Ctx`; the engine stays domain-free.
|
|
||||||
Determinism (C1) preserved (pip fixed per symbol). `SimBroker` single-instrument
|
|
||||||
unchanged (C7). Not via `Aura.toml` (C16/C17).
|
|
||||||
|
|
||||||
## Deferred (follow-up)
|
|
||||||
|
|
||||||
The runnable-example pip centralization (give `ger40_breakout_blueprint` a `pip_size`
|
|
||||||
param; source `build_harness`/compare pip from `instrument_spec`) is **out of this
|
|
||||||
cycle's scope** — the examples already bake the correct `1.0`, so it is a consistency
|
|
||||||
cleanup, not a bug fix. Filed as a follow-up `idea` issue.
|
|
||||||
Reference in New Issue
Block a user