The broker milestone's (C10 A-side) foundation: two pure value-type deliverables, no broker and no derivation yet. - #113: extend InstrumentSpec with deploy-grade fields (instrument_id, contract_size, pip_value_per_lot, min_lot, lot_step, quote_currency) + populate the vetted table for the five instruments; refuse-don't-guess arm stays. Permanent authored floor of the override hierarchy (#124). - #114: PositionAction { Buy, Sell, Close } (closed enum, serde-encoded as i64) + PositionEvent row in aura-engine, faithful to the C10 column spec (no open_ts, unsigned volume, direction is the action). Signed under /boss on a grounding-check PASS (all current-state assumptions ratified by green tests; the two new types are greenfield, expected). Derived fork decisions (quote_currency &'static str; action enum encoded as i64 0/1/2; stable instrument_id + overridable vetted values) recorded on #113. refs #113 #114
14 KiB
0063 broker-foundation — InstrumentSpec deploy metadata + PositionEvent schema — Design Spec
Date: 2026-06-22 Status: Draft — awaiting user spec review Authors: orchestrator + Claude
Goal
Lay the two data foundations the realistic-broker milestone (C10 A-side) stands on, with no broker and no derivation yet — pure, fully-testable value types:
- #113 — extend
InstrumentSpec(aura-ingest) with the deploy-grade fields a realistic broker needs to turn pips/volume into account currency (instrument_id,contract_size,pip_value_per_lot,min_lot,lot_step,quote_currency), and populate the vetted table for the existing GER40/FRA40/EURUSD/GBPUSD/USDCAD set. The authored table is the permanent vetted floor of the milestone's authored-override-wins resolution hierarchy (the recorded-metadata tier is forward work, #124); the refuse-don't-guessNonearm stays the absolute floor. - #114 — pin the canonical Rust representation of C10's derived
position-event table:
PositionAction { Buy, Sell, Close }+PositionEvent, living inaura-enginealongsideRunMetrics/ColumnarTrace(a post-run reduction, the same home assummarize). Pure schema — no friction, no derivation logic.
This is foundation only. derive_position_events (#115), the RealisticBroker
node (#116), and frictions/metrics/persist (#120–122) consume these shapes in
later cycles.
Architecture
Both deliverables are value types beside the hot path, faithful to existing contracts — they add no behaviour to the engine's run loop:
InstrumentSpecis non-scalar reference metadata held beside the hot path (C7/C15) at the ingestion edge, keyed by symbol. C15's realization note already promises this exact extension ("extensible to tick size / digits / quote currency without a signature break"). The struct staysCopy.PositionEventis the broker-independent audit row C10 already specifies down to its columns (INDEX.md:471–501):event_ts,action: i64(buy/sell/close — direction IS the action),position_id: i64,instrument_id: i64,volume: f64unsigned; noopen_ts; the signed-volume trick is forbidden. It is a post-run value type (sibling ofRunMetrics), never a per-evalnode output (C8) — which is why it lives as data inaura-engine, not as a node. The engine stays headless and domain-free (C14): these are plain serializable structs, no I/O, no instrument identity on the hot path.
The one in-memory refinement over the ledger's columnar action: i64: the
canonical Rust type is a closed enum PositionAction, serde-encoded as
i64 (ordinal Buy=0, Sell=1, Close=2). The enum makes an invalid action
unrepresentable in memory; the i64 encoding keeps the persisted/columnar form
ledger- and C7-faithful and makes #122's columnar projection trivial. An
out-of-range i64 fails TryFrom (the honesty lever, mirroring
instrument_spec's None).
Concrete code shapes
Delivered code — behaviour this cycle ships (the empirical evidence)
The extended vetted table (must-pass) and its refuse arm (must-fail-by-design):
// aura-ingest/src/lib.rs — instrument_spec(), after extension
pub fn instrument_spec(symbol: &str) -> Option<InstrumentSpec> {
// (instrument_id, pip_size, contract_size, pip_value_per_lot, min_lot, lot_step, quote)
let s = match symbol {
// index CFDs: quote EUR, 1 contract = €1 per point, pip = 1.0 point
"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" },
// FX majors: standard lot = 100_000 units, pip = 0.0001,
// pip value = contract_size * pip_size in the quote currency (= 10)
"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, // refuse-don't-guess — the absolute floor stays
};
Some(s)
}
SUSPECT BYTES — vetted reference data, not validated by an existing test. The contract numbers (contract sizes, pip values, min/lot steps) are industry-standard conventions, not broker-exact, and are overridable by design per the milestone's authored-override-wins contract. They are this spec's most fragile content; a later cycle (or an Aura.toml override) corrects any that prove wrong. The invariants over them are what the tests pin (below), not the literals' real-world exactness.
The closed action mapping (must-pass round-trip + must-fail rejection):
// aura-engine/src/report.rs
use std::convert::TryFrom;
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, String> {
match v {
0 => Ok(PositionAction::Buy),
1 => Ok(PositionAction::Sell),
2 => Ok(PositionAction::Close),
other => Err(format!("invalid PositionAction i64: {other}")),
}
}
}
// try_from(99) -> Err — an out-of-range action is rejected, never silently mapped.
North star (cycle #115 — NOT built this cycle)
How the foundation will be consumed, shown minimally for honesty (no code lands here):
// derive_position_events folds the recorded exposure first-difference into rows.
// A long open, then a stop-and-reverse (Close + Sell at the SAME event_ts):
PositionEvent { event_ts: t0, action: PositionAction::Buy, position_id: 1, instrument_id: 3, volume: lots };
PositionEvent { event_ts: t9, action: PositionAction::Close, position_id: 1, instrument_id: 3, volume: lots };
PositionEvent { event_ts: t9, action: PositionAction::Sell, position_id: 2, instrument_id: 3, volume: lots };
// sizing lots = round_to(|exposure| * account / contract_value, lot_step); sub-min_lot drops (#115).
Before → after struct shapes (secondary)
InstrumentSpec — add six fields, keep Copy:
// BEFORE
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct InstrumentSpec {
pub pip_size: f64,
}
// AFTER
#[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,
}
PositionAction + PositionEvent — new in aura-engine/src/report.rs:
/// The three position-event actions (C10). Direction IS the action; volume is
/// unsigned. Serde-encoded as its i64 mapping 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 }
/// 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,
}
Components
- aura-ingest
InstrumentSpec(extend) —crates/aura-ingest/src/lib.rs:346. Add the six fields; keep#[derive(Clone, Copy, Debug, PartialEq)]. Update the doc comment to name the new fields.Copyis preserved becausequote_currencyis&'static str, notString. - aura-ingest
instrument_spec()table (populate) —:358. Fill all six fields for the five vetted symbols; keep the_ => Nonerefuse arm. - aura-engine
PositionAction(new) —crates/aura-engine/src/report.rs. Closed enum +From<PositionAction> for i64+TryFrom<i64>+ serdeinto/try_fromattributes. Re-export from the crate root ifRunMetricsis (match the existing re-export pattern). - aura-engine
PositionEvent(new) — same file. The row struct above.
Data flow
No runtime/streaming flow changes this cycle — these are value types. The relationships they encode (consumed later):
instrument_spec(symbol)→InstrumentSpecis resolved at the ingestion edge at bootstrap; the broker (#116) andderive_position_events(#115) read the resolved injected spec, never callinstrument_spec()directly, so a resolver (the override hierarchy, #124) can sit in front later without touching them. (Wiring the injection is #115/#116, not this cycle.)PositionEventrows are produced post-run byderive_position_events(#115) from the recorded exposure stream; persisted dup-ts-safe (#122). This cycle only fixes the shape every downstream piece wires to.
Error handling
- Un-specced symbol →
instrument_spec()returnsNone; the existinginstrument_spec_or_refuse(aura-climain.rs:766) keeps refusing. No guessed metadata enters a run. - Out-of-range action i64 →
PositionAction::try_fromreturnsErr(a message naming the bad value); serde deserialization of a badactionfails rather than silently coercing. - Compile fan-out (contained): adding fields breaks every
InstrumentSpec { pip_size }literal construction — the table constructor and the three test literals inaura-ingest/src/lib.rs(greps confirm: no other crate constructs the struct;instrument_spec_or_refuseand the examples only access.pip_size, which is unaffected). The plan threads all construction sites in one task so the crate compiles before its build gate. - No new panics; the engine stays headless (C14).
Testing strategy
- aura-ingest (extend
instrument_spec_lookup_by_symbol, add cases):- Each of the five symbols returns the full expected
InstrumentSpec(all six fields), asserted by value. instrument_spec("NONEXISTENT") == None(refuse arm intact).- Invariant:
pip_value_per_lot == contract_size * pip_sizefor every vetted instrument (pins the cross-field relationship, not the literals' real-world exactness). - Invariant: the five
instrument_ids are distinct (uniqueness).
- Each of the five symbols returns the full expected
- aura-engine (new tests in
report.rs):PositionActionround-trips: each variant →i64→try_from→ same variant.PositionAction::try_from(3)(and a negative) →Err.- A
PositionEventserde JSON round-trip equals the original, and the serializedactionis a bare integer (e.g. the JSON contains"action":1forSell), confirming the i64 encoding. - A reversal pair (Close then Sell) sharing one
event_tsconstructs and is representable (documents close-before-open ordering; the ordering logic is #115).
- Workspace:
cargo test --workspacegreen;cargo clippy --workspace --all-targets -- -D warningsclean.
Acceptance criteria
InstrumentSpeccarries the six new deploy-grade fields and staysCopy; the vetted table populates all five instruments; the_ => Nonerefuse arm remains.PositionActionis a closed three-value enum with a total, round-tripping i64 mapping (Buy=0, Sell=1, Close=2); an out-of-range i64 is rejected.PositionEventmatches the C10 column spec exactly —event_ts, enumaction,position_id,instrument_id, unsignedvolume; noopen_ts; no signed-volume trick. Its serde shape is pinned (action encodes as i64).- Both types live where the ledger/issues place them (
InstrumentSpecin aura-ingest at the ingestion edge;PositionEventin aura-engine besideRunMetrics), and the engine stays headless (C14) — no broker, no derivation, no I/O this cycle. cargo test --workspacepasses andclippy -D warningsis clean.