plan: 0063 broker-foundation — InstrumentSpec deploy metadata + PositionEvent schema

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
This commit is contained in:
2026-06-22 19:22:42 +02:00
parent cc0dce19a3
commit 099aac87e1
+446
View File
@@ -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<i64> = ["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<InstrumentSpec> {
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<InstrumentSpec> {
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<PositionAction> for i64 {
fn from(a: PositionAction) -> i64 {
match a {
PositionAction::Buy => 0,
PositionAction::Sell => 1,
PositionAction::Close => 2,
}
}
}
impl TryFrom<i64> for PositionAction {
type Error = String;
fn try_from(v: i64) -> Result<Self, Self::Error> {
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).