From 099aac87e1f996705165f99e8ec0480151e3675d Mon Sep 17 00:00:00 2001 From: Brummel Date: Mon, 22 Jun 2026 19:22:42 +0200 Subject: [PATCH] =?UTF-8?q?plan:=200063=20broker-foundation=20=E2=80=94=20?= =?UTF-8?q?InstrumentSpec=20deploy=20metadata=20+=20PositionEvent=20schema?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three tasks. Task 1 (#113, compile-atomic): RED-rewrite the lookup test to the six-field shape + invariant/uniqueness tests, then extend InstrumentSpec and populate the vetted table for the five instruments (refuse arm stays). Task 2 (#114, greenfield): RED the round-trip / try_from-Err / serde-bare-int / reversal tests, then add PositionAction (serde-as-i64) + PositionEvent in report.rs and re-export from the crate root. Task 3: workspace test + clippy gate. Recon-grounded line anchors; the redundant std::convert::TryFrom import dropped (2021 prelude). INDEX.md realization-note drift (C10/C15 "minimal today") deferred to the cycle-close audit, not folded here (code-only scope). refs #113 #114 --- docs/plans/0063-broker-foundation.md | 446 +++++++++++++++++++++++++++ 1 file changed, 446 insertions(+) create mode 100644 docs/plans/0063-broker-foundation.md diff --git a/docs/plans/0063-broker-foundation.md b/docs/plans/0063-broker-foundation.md new file mode 100644 index 0000000..4d8bac8 --- /dev/null +++ b/docs/plans/0063-broker-foundation.md @@ -0,0 +1,446 @@ +# 0063 broker-foundation — InstrumentSpec deploy metadata + PositionEvent schema — Implementation Plan + +> **Parent spec:** `docs/specs/0063-broker-foundation.md` +> +> **For agentic workers:** REQUIRED SUB-SKILL: use the `implement` skill to run +> this plan. Steps use `- [ ]` checkboxes for tracking. + +**Goal:** Land the broker milestone's two foundation value types — the extended +`InstrumentSpec` (deploy-grade metadata) and the `PositionAction`/`PositionEvent` +schema — with no broker and no derivation, fully tested. + +**Architecture:** Two independent, additive changes in two crates. `InstrumentSpec` +(aura-ingest) gains six fields and its vetted table is populated for five symbols; +the struct stays `Copy` (so `quote_currency` is `&'static str`). `PositionAction` +(closed enum, serde-encoded as i64) + `PositionEvent` (row struct) are new in +aura-engine's `report.rs` beside `RunMetrics`, re-exported from the crate root. The +engine stays headless (C14) — no run-loop behaviour changes. + +**Tech Stack:** Rust; serde (`into`/`try_from` enum encoding); the existing +`#[cfg(test)] mod tests` in each touched file. + +--- + +**Files this plan creates or modifies:** + +- Modify: `crates/aura-ingest/src/lib.rs:342-349` — `InstrumentSpec` struct: add six fields, keep `Copy`. +- Modify: `crates/aura-ingest/src/lib.rs:358-365` — `instrument_spec()` table: per-symbol literals for the five vetted instruments; keep the `_ => None` refuse arm. +- Test: `crates/aura-ingest/src/lib.rs:371-380` — rewrite `instrument_spec_lookup_by_symbol` (full-field asserts for all 5 + `NONEXISTENT == None`); add `instrument_spec_pip_value_matches_contract_times_pip` and `instrument_spec_ids_are_distinct`. +- Modify: `crates/aura-engine/src/report.rs:31` — insert `PositionAction` (+ `From`/`TryFrom`) + `PositionEvent` (after `RunMetrics`, before `RunManifest`). +- Modify: `crates/aura-engine/src/lib.rs:64-66` — add `PositionAction, PositionEvent` to the `pub use report::{ … }` crate-root re-export. +- Test: `crates/aura-engine/src/report.rs:278` — extend the existing `mod tests` with four new `#[test]` fns. + +--- + +## Task 1: InstrumentSpec deploy metadata (#113) + +**COMPILE-ATOMIC TASK.** Adding fields to `InstrumentSpec` breaks the table +constructor and the test literals simultaneously; the crate does not compile until +all are threaded. Run `cargo` **only** at Step 2 (RED) and Step 5 (GREEN) — do NOT +run it between Steps 3 and 4. + +**Files:** +- Modify: `crates/aura-ingest/src/lib.rs:342-349` (struct), `:358-365` (table) +- Test: `crates/aura-ingest/src/lib.rs:371-380` (rewrite + add) + +- [ ] **Step 1: Rewrite the test to specify the new shape (RED side)** + +In `crates/aura-ingest/src/lib.rs`, replace the existing test fn (lines 371-380): + +```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); + } +``` + +with: + +```rust + #[test] + fn instrument_spec_lookup_by_symbol() { + // index CFDs: quote EUR, 1 contract = €1/point, pip = 1.0 point + assert_eq!( + instrument_spec("GER40"), + Some(InstrumentSpec { + instrument_id: 1, + pip_size: 1.0, + contract_size: 1.0, + pip_value_per_lot: 1.0, + min_lot: 0.1, + lot_step: 0.01, + quote_currency: "EUR", + }) + ); + assert_eq!( + instrument_spec("FRA40"), + Some(InstrumentSpec { + instrument_id: 2, + pip_size: 1.0, + contract_size: 1.0, + pip_value_per_lot: 1.0, + min_lot: 0.1, + lot_step: 0.01, + quote_currency: "EUR", + }) + ); + // FX majors: standard lot = 100_000 units, pip = 0.0001, pip value = 10 in quote ccy + assert_eq!( + instrument_spec("EURUSD"), + Some(InstrumentSpec { + instrument_id: 3, + pip_size: 0.0001, + contract_size: 100_000.0, + pip_value_per_lot: 10.0, + min_lot: 0.01, + lot_step: 0.01, + quote_currency: "USD", + }) + ); + assert_eq!( + instrument_spec("GBPUSD"), + Some(InstrumentSpec { + instrument_id: 4, + pip_size: 0.0001, + contract_size: 100_000.0, + pip_value_per_lot: 10.0, + min_lot: 0.01, + lot_step: 0.01, + quote_currency: "USD", + }) + ); + assert_eq!( + instrument_spec("USDCAD"), + Some(InstrumentSpec { + instrument_id: 5, + pip_size: 0.0001, + contract_size: 100_000.0, + pip_value_per_lot: 10.0, + min_lot: 0.01, + lot_step: 0.01, + quote_currency: "CAD", + }) + ); + // un-specced → None (the honesty lever) + assert_eq!(instrument_spec("NONEXISTENT"), None); + } + + #[test] + fn instrument_spec_pip_value_matches_contract_times_pip() { + // pip_value_per_lot == contract_size * pip_size for every vetted instrument + // (cross-field invariant, not the literals' real-world exactness). + for sym in ["GER40", "FRA40", "EURUSD", "GBPUSD", "USDCAD"] { + let s = instrument_spec(sym).expect("vetted symbol"); + assert!( + (s.pip_value_per_lot - s.contract_size * s.pip_size).abs() < 1e-9, + "{sym}: pip_value_per_lot {} != contract_size {} * pip_size {}", + s.pip_value_per_lot, + s.contract_size, + s.pip_size + ); + } + } + + #[test] + fn instrument_spec_ids_are_distinct() { + let ids: Vec = ["GER40", "FRA40", "EURUSD", "GBPUSD", "USDCAD"] + .iter() + .map(|s| instrument_spec(s).expect("vetted symbol").instrument_id) + .collect(); + let mut sorted = ids.clone(); + sorted.sort_unstable(); + sorted.dedup(); + assert_eq!(sorted.len(), ids.len(), "instrument_ids must be unique: {ids:?}"); + } +``` + +- [ ] **Step 2: Run the crate suite to confirm RED** + +Run: `cargo test -p aura-ingest` +Expected: FAIL — a compile error in the test module, `no field `instrument_id` on +type `InstrumentSpec`` (the struct does not yet have the new fields). This is the +RED: the new assertions cannot compile against the old shape. + +- [ ] **Step 3: Add the six fields to the struct** + +In `crates/aura-ingest/src/lib.rs`, replace the doc comment + struct (lines +342-349): + +```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, +} +``` + +with: + +```rust +/// Per-instrument reference metadata held beside the hot path (C7/C15): non-scalar, +/// never streamed. The permanent vetted floor of the milestone's +/// authored-override-wins resolution hierarchy (the recorded-metadata tier is +/// forward work, #124). Stays `Copy` — `quote_currency` is `&'static str`. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct InstrumentSpec { + /// Stable per-instrument id — the position-event table's `instrument_id` + /// column. Assigned once, never renumbered. + pub instrument_id: i64, + /// Price units per pip / point (> 0). The sim-optimal broker's divisor. + pub pip_size: f64, + /// Units of the base instrument per 1.0 lot (FX standard lot = 100_000; + /// an index CFD = 1). + pub contract_size: f64, + /// Value of one pip per 1.0 lot, in the quote currency. Equals + /// `contract_size * pip_size` for every vetted instrument here; carried + /// explicitly because a real broker may not make it exactly so. + pub pip_value_per_lot: f64, + /// Smallest tradable volume and the volume granularity, both in lots. + pub min_lot: f64, + pub lot_step: f64, + /// Quote currency (ISO label only this cycle — no FX conversion yet). + pub quote_currency: &'static str, +} +``` + +- [ ] **Step 4: Rewrite the vetted table to per-symbol literals** + +In `crates/aura-ingest/src/lib.rs`, replace the `instrument_spec()` body (lines +358-365): + +```rust +pub fn instrument_spec(symbol: &str) -> Option { + let pip_size = match symbol { + "GER40" | "FRA40" => 1.0, + "EURUSD" | "GBPUSD" | "USDCAD" => 0.0001, + _ => return None, + }; + Some(InstrumentSpec { pip_size }) +} +``` + +with: + +```rust +pub fn instrument_spec(symbol: &str) -> Option { + let s = match symbol { + "GER40" => InstrumentSpec { + instrument_id: 1, pip_size: 1.0, contract_size: 1.0, + pip_value_per_lot: 1.0, min_lot: 0.1, lot_step: 0.01, quote_currency: "EUR", + }, + "FRA40" => InstrumentSpec { + instrument_id: 2, pip_size: 1.0, contract_size: 1.0, + pip_value_per_lot: 1.0, min_lot: 0.1, lot_step: 0.01, quote_currency: "EUR", + }, + "EURUSD" => InstrumentSpec { + instrument_id: 3, pip_size: 0.0001, contract_size: 100_000.0, + pip_value_per_lot: 10.0, min_lot: 0.01, lot_step: 0.01, quote_currency: "USD", + }, + "GBPUSD" => InstrumentSpec { + instrument_id: 4, pip_size: 0.0001, contract_size: 100_000.0, + pip_value_per_lot: 10.0, min_lot: 0.01, lot_step: 0.01, quote_currency: "USD", + }, + "USDCAD" => InstrumentSpec { + instrument_id: 5, pip_size: 0.0001, contract_size: 100_000.0, + pip_value_per_lot: 10.0, min_lot: 0.01, lot_step: 0.01, quote_currency: "CAD", + }, + _ => return None, + }; + Some(s) +} +``` + +- [ ] **Step 5: Run the crate suite to confirm GREEN** + +Run: `cargo test -p aura-ingest` +Expected: PASS — `test result: ok.`, including +`instrument_spec_lookup_by_symbol`, `instrument_spec_pip_value_matches_contract_times_pip`, +and `instrument_spec_ids_are_distinct`. 0 failed. + +--- + +## Task 2: PositionAction + PositionEvent schema (#114) + +Greenfield, additive — no existing code breaks. RED-first is clean (the tests +reference types that do not exist yet). + +**Files:** +- Modify: `crates/aura-engine/src/report.rs:31` (insert types), `:278` (extend tests) +- Modify: `crates/aura-engine/src/lib.rs:64-66` (re-export) + +- [ ] **Step 1: Add the tests (RED side)** + +In `crates/aura-engine/src/report.rs`, inside the existing `mod tests` (it opens +at line 278), insert these four `#[test]` fns immediately after the +`use std::sync::mpsc;` line (line 284): + +```rust + #[test] + fn position_action_round_trips_through_i64() { + for a in [PositionAction::Buy, PositionAction::Sell, PositionAction::Close] { + let n: i64 = a.into(); + assert_eq!(PositionAction::try_from(n), Ok(a)); + } + assert_eq!(i64::from(PositionAction::Buy), 0); + assert_eq!(i64::from(PositionAction::Sell), 1); + assert_eq!(i64::from(PositionAction::Close), 2); + } + + #[test] + fn position_action_rejects_out_of_range_i64() { + assert!(PositionAction::try_from(3).is_err()); + assert!(PositionAction::try_from(-1).is_err()); + } + + #[test] + fn position_event_serde_round_trips_with_bare_int_action() { + let ev = PositionEvent { + event_ts: Timestamp(42), + action: PositionAction::Sell, + position_id: 7, + instrument_id: 3, + volume: 0.5, + }; + let json = serde_json::to_string(&ev).expect("serialize"); + // action encodes as a bare integer (C7 scalar shape), not a tagged enum + assert!(json.contains("\"action\":1"), "action not bare-int encoded: {json}"); + let back: PositionEvent = serde_json::from_str(&json).expect("deserialize"); + assert_eq!(back, ev); + } + + #[test] + fn position_events_may_share_one_event_ts_on_reversal() { + // a stop-and-reverse: Close then open at the SAME event_ts (close-before-open). + let ts = Timestamp(100); + let close = PositionEvent { + event_ts: ts, action: PositionAction::Close, + position_id: 1, instrument_id: 3, volume: 0.5, + }; + let open = PositionEvent { + event_ts: ts, action: PositionAction::Sell, + position_id: 2, instrument_id: 3, volume: 0.5, + }; + let table = vec![close, open]; + assert_eq!(table[0].event_ts, table[1].event_ts); + assert_eq!(table[0].action, PositionAction::Close); + assert_eq!(table[1].action, PositionAction::Sell); + } +``` + +- [ ] **Step 2: Run the crate suite to confirm RED** + +Run: `cargo test -p aura-engine` +Expected: FAIL — compile error `cannot find type `PositionAction` in this scope` +(and `PositionEvent`). The types do not exist yet. This is the RED. + +- [ ] **Step 3: Add the types + impls** + +In `crates/aura-engine/src/report.rs`, insert the following at line 31 (after the +closing `}` of `RunMetrics` at line 30, before the `RunManifest` doc comment at +line 32). Note: do NOT add `use std::convert::TryFrom;` — it is in the 2021/2024 +prelude and an explicit import warns. + +```rust + +/// The three position-event actions (C10). Direction IS the action; volume is +/// unsigned. Serde-encoded as its i64 mapping (`Buy=0, Sell=1, Close=2`) so the +/// persisted/columnar form stays C7-scalar and ledger-faithful (`action: i64`). +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(into = "i64", try_from = "i64")] +pub enum PositionAction { + Buy, + Sell, + Close, +} + +impl From for i64 { + fn from(a: PositionAction) -> i64 { + match a { + PositionAction::Buy => 0, + PositionAction::Sell => 1, + PositionAction::Close => 2, + } + } +} + +impl TryFrom for PositionAction { + type Error = String; + fn try_from(v: i64) -> Result { + match v { + 0 => Ok(PositionAction::Buy), + 1 => Ok(PositionAction::Sell), + 2 => Ok(PositionAction::Close), + other => Err(format!("invalid PositionAction i64: {other}")), + } + } +} + +/// One row of C10's derived position-event table — the broker-independent audit +/// view (the first difference of the exposure state). A post-run value type +/// (sibling of [`RunMetrics`]), NOT a per-`eval` node output (C8). Multiple events +/// may share one `event_ts` (a reversal: Close then open, close-before-open). No +/// `open_ts` — a position's open time is its opening event's `event_ts`. +#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct PositionEvent { + pub event_ts: Timestamp, + pub action: PositionAction, + /// Monotonic, assigned at open. A Close references an existing `position_id`. + pub position_id: i64, + pub instrument_id: i64, + /// Lots, unsigned. A partial close carries its own (smaller) volume. + pub volume: f64, +} +``` + +- [ ] **Step 4: Re-export the new types from the crate root** + +In `crates/aura-engine/src/lib.rs`, replace the re-export block (lines 64-66): + +```rust +pub use report::{ + f64_field, join_on_ts, summarize, ColumnarTrace, JoinedRow, RunManifest, RunMetrics, RunReport, +}; +``` + +with: + +```rust +pub use report::{ + f64_field, join_on_ts, summarize, ColumnarTrace, JoinedRow, PositionAction, PositionEvent, + RunManifest, RunMetrics, RunReport, +}; +``` + +- [ ] **Step 5: Run the crate suite to confirm GREEN** + +Run: `cargo test -p aura-engine` +Expected: PASS — `test result: ok.`, including the four new +`position_action_*` / `position_event_*` / `position_events_*` tests. 0 failed. + +--- + +## Task 3: Workspace gate + +**Files:** none (verification only). + +- [ ] **Step 1: Full workspace test** + +Run: `cargo test --workspace` +Expected: PASS — `0 failed` across all crates. The passing count rises by 6 over +the pre-cycle baseline (447 → 453): +2 in aura-ingest, +4 in aura-engine. + +- [ ] **Step 2: Lint gate** + +Run: `cargo clippy --workspace --all-targets -- -D warnings` +Expected: clean exit (no warnings). In particular: no `redundant import` from a +stray `TryFrom` use, and no `dead_code` (the new types are re-exported, so they are +used).