Files
Aura/docs/plans/0063-broker-foundation.md
T
Brummel 099aac87e1 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
2026-06-22 19:22:42 +02:00

16 KiB

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-349InstrumentSpec struct: add six fields, keep Copy.
  • Modify: crates/aura-ingest/src/lib.rs:358-365instrument_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):

    #[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:

    #[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_idon typeInstrumentSpec`` (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):

/// 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:

/// 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):

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:

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):

    #[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.


/// 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):

pub use report::{
    f64_field, join_on_ts, summarize, ColumnarTrace, JoinedRow, RunManifest, RunMetrics, RunReport,
};

with:

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).