feat(broker-foundation): InstrumentSpec deploy metadata + PositionEvent schema

The realistic-broker milestone's (C10 A-side) two foundation value types — no
broker, no derivation yet, fully tested.

#113 — InstrumentSpec gains six deploy-grade fields (instrument_id,
contract_size, pip_value_per_lot, min_lot, lot_step, quote_currency) and the
vetted table is populated for GER40/FRA40/EURUSD/GBPUSD/USDCAD. The struct stays
Copy (quote_currency is &'static str); the _ => None refuse-don't-guess arm is the
permanent floor. This is the milestone's permanent authored floor (recorded-metadata
tier is forward work, #124).

#114 — PositionAction { Buy, Sell, Close } (closed enum, serde-encoded as a bare
i64 0/1/2 via From/TryFrom) + PositionEvent in aura-engine beside RunMetrics,
faithful to the C10 column spec: event_ts, action, position_id, instrument_id,
unsigned volume; no open_ts; direction is the action (no signed-volume trick).
Re-exported from the crate root.

Derived fork decisions (recorded on #113): quote_currency &'static str; action as
ordinal i64; stable instrument_id + overridable vetted values.

Verified by the orchestrator: cargo test --workspace = 458 passed / 0 failed;
clippy --all-targets -D warnings clean. An adversarial 4-lens review (ledger
faithfulness, downstream soundness, serde/wire robustness, test honesty) returned
3 SOUND + 1 CONCERN: quote_currency &'static str cannot hold a runtime-sourced
currency at the #124 resolver — not a blocker (#115/#116 read only the authored
floor), logged on #124 as a deliberate deferred design choice. The implementer's
deviation from the plan snippet (vec![..] -> [..]) avoids clippy::useless_vec;
semantics identical.

Two tester-authored consumer-boundary E2E tests added beyond the inline units:
instrument_spec_deploy_metadata.rs (public resolution seam) and
position_event_table.rs (persisted bare-int wire shape + reversal table).

closes #113 #114
This commit is contained in:
2026-06-22 19:51:17 +02:00
parent 099aac87e1
commit b91c89f964
5 changed files with 390 additions and 12 deletions
+2 -1
View File
@@ -62,7 +62,8 @@ pub use harness::{
VecSource,
};
pub use report::{
f64_field, join_on_ts, summarize, ColumnarTrace, JoinedRow, RunManifest, RunMetrics, RunReport,
f64_field, join_on_ts, summarize, ColumnarTrace, JoinedRow, PositionAction, PositionEvent,
RunManifest, RunMetrics, RunReport,
};
pub use sweep::{
sweep, GridSpace, ParamRange, RandomSpace, Space, SweepError, SweepFamily, SweepPoint,
+100
View File
@@ -29,6 +29,55 @@ pub struct RunMetrics {
pub exposure_sign_flips: u64,
}
/// 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,
}
/// The reproducible run descriptor (C18). **Caller-supplied**: the engine
/// cannot introspect a git commit, an RNG seed, or a broker label — the World
/// that bootstraps and runs the harness fills these in.
@@ -283,6 +332,57 @@ mod tests {
use aura_std::{Exposure, Recorder, SimBroker, Sma, Sub};
use std::sync::mpsc;
#[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 = [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);
}
/// The declared signature of a `Recorder` over one f64 column (the sink shape
/// the two-sink harness uses).
fn f64_recorder_sig() -> NodeSchema {
@@ -0,0 +1,86 @@
//! End-to-end verification of the C10 derived position-event table (#114) at the
//! public crate boundary: the World consumes `PositionAction` / `PositionEvent`
//! via the `aura_engine` re-exports and persists the audit table the same way
//! the run registry persists a `RunReport` — through serde_json. These tests pin
//! the *consumer-visible wire shape* (the persisted column form), distinct from
//! the crate-internal type-level unit tests in `report.rs`.
use aura_engine::{PositionAction, PositionEvent, Timestamp};
/// A minimal stop-and-reverse audit table as a broker would derive it: open
/// long, then at one later instant Close-before-open into a short (a reversal,
/// two events sharing `event_ts`). The smallest table that exercises every
/// action variant and the same-ts reversal shape.
fn reversal_table() -> Vec<PositionEvent> {
let open = Timestamp(1_000);
let flip = Timestamp(2_000);
vec![
PositionEvent {
event_ts: open,
action: PositionAction::Buy,
position_id: 1,
instrument_id: 3,
volume: 0.10,
},
// reversal at one instant: close the long first, then open the short.
PositionEvent {
event_ts: flip,
action: PositionAction::Close,
position_id: 1,
instrument_id: 3,
volume: 0.10,
},
PositionEvent {
event_ts: flip,
action: PositionAction::Sell,
position_id: 2,
instrument_id: 3,
volume: 0.10,
},
]
}
/// Property: a derived position-event table survives a full serde_json
/// round-trip through the public re-exports byte-for-byte equal, AND the
/// persisted `action` column is a **bare integer** (C7-scalar / ledger column
/// shape), never a tagged-enum object. The World persists this audit table with
/// the same encoder it uses for `RunReport`; if `action` serialised as
/// `{"Sell":null}` it would break the flat columnar contract and any tool
/// reading the table as `action: i64`. Pins the consumer-visible on-disk shape.
#[test]
fn position_event_table_round_trips_with_bare_int_action_column() {
let table = reversal_table();
let json = serde_json::to_string(&table).expect("serialize audit table");
// action persists as a bare i64 (Buy=0, Sell=1, Close=2), not a tagged enum.
assert!(json.contains("\"action\":0"), "Buy must encode as action:0 — {json}");
assert!(json.contains("\"action\":1"), "Sell must encode as action:1 — {json}");
assert!(json.contains("\"action\":2"), "Close must encode as action:2 — {json}");
assert!(!json.contains("\"Buy\""), "action must not be a tagged enum — {json}");
let back: Vec<PositionEvent> = serde_json::from_str(&json).expect("deserialize audit table");
assert_eq!(back, table, "the audit table must round-trip byte-for-byte (C18 reproducibility)");
}
/// Property: the persisted `PositionAction` integer mapping is the **stable
/// wire encoding** `Buy=0, Sell=1, Close=2`, and any out-of-range integer is
/// **rejected** on read rather than silently coerced. This is the contract every
/// persisted audit table depends on: renumbering the variants (or accepting a
/// stray 3 as some action) would silently mis-read every historical table.
/// Exercised through the public re-export as a downstream reader would.
#[test]
fn position_action_wire_encoding_is_stable_and_validated() {
// forward: the literal i64 each variant persists as.
assert_eq!(serde_json::to_string(&PositionAction::Buy).unwrap(), "0");
assert_eq!(serde_json::to_string(&PositionAction::Sell).unwrap(), "1");
assert_eq!(serde_json::to_string(&PositionAction::Close).unwrap(), "2");
// backward: each valid code reads back to its variant.
assert_eq!(serde_json::from_str::<PositionAction>("0").unwrap(), PositionAction::Buy);
assert_eq!(serde_json::from_str::<PositionAction>("1").unwrap(), PositionAction::Sell);
assert_eq!(serde_json::from_str::<PositionAction>("2").unwrap(), PositionAction::Close);
// an out-of-range code is refused on read, not coerced (the validation arm).
assert!(serde_json::from_str::<PositionAction>("3").is_err());
assert!(serde_json::from_str::<PositionAction>("-1").is_err());
}
+130 -11
View File
@@ -340,12 +340,28 @@ pub fn default_data_server() -> Arc<DataServer> {
}
/// 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.
/// 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,
}
/// Look up per-instrument reference metadata by `symbol` at the ingestion edge.
@@ -356,12 +372,30 @@ pub struct InstrumentSpec {
/// 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,
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(InstrumentSpec { pip_size })
Some(s)
}
#[cfg(test)]
@@ -370,15 +404,100 @@ mod tests {
#[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 }));
// 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:?}");
}
#[test]
fn unix_ms_to_epoch_ns_scales_ms_to_ns() {
// data-server's own epoch fixture: 2017-03-01 00:00 UTC.
@@ -0,0 +1,72 @@
//! End-to-end verification of the public instrument-spec resolution seam
//! (#113): a downstream broker resolves deploy-grade metadata by symbol through
//! `aura_ingest::instrument_spec` — the permanent authored floor of the
//! milestone's override hierarchy (#124). These tests pin the *consumer-visible*
//! contract (the public return shape + the refuse-don't-guess arm), distinct
//! from the crate-internal literal-table unit tests.
use aura_ingest::{instrument_spec, InstrumentSpec};
/// The vetted symbols a deploy step may ask for. Kept here (not imported) so the
/// test fails loudly if the public lookup ever stops resolving one of them.
const VETTED: [&str; 5] = ["GER40", "FRA40", "EURUSD", "GBPUSD", "USDCAD"];
/// Property: every vetted symbol resolves through the *public* lookup to a fully
/// populated `InstrumentSpec` whose six deploy fields are all present and sane
/// (positive sizes, a non-empty quote currency). This is the floor a broker
/// reads before sizing a lot; a regression that left any field at a placeholder
/// (0.0 / "") or dropped a symbol from the table would size positions wrong with
/// no error. Asserts only the consumer-visible guarantees, not the exact vetted
/// literals (those are the crate's own unit test's remit).
#[test]
fn every_vetted_symbol_resolves_to_a_complete_spec() {
for sym in VETTED {
let s: InstrumentSpec = instrument_spec(sym).unwrap_or_else(|| {
panic!("vetted symbol {sym} must resolve through the public lookup")
});
assert!(s.instrument_id > 0, "{sym}: instrument_id must be a real (>0) id");
assert!(s.pip_size > 0.0, "{sym}: pip_size must be positive");
assert!(s.contract_size > 0.0, "{sym}: contract_size must be positive");
assert!(s.pip_value_per_lot > 0.0, "{sym}: pip_value_per_lot must be positive");
assert!(s.min_lot > 0.0, "{sym}: min_lot must be positive");
assert!(s.lot_step > 0.0, "{sym}: lot_step must be positive");
assert!(!s.quote_currency.is_empty(), "{sym}: quote_currency must be labelled");
}
}
/// Property: the cross-field deploy invariant `pip_value_per_lot ==
/// contract_size * pip_size` holds for every vetted instrument as a downstream
/// broker observes it through the public lookup. A broker computing pip P&L may
/// use either `pip_value_per_lot` directly or derive it from contract_size ×
/// pip_size; this pins that the two paths agree, so the choice is free. A future
/// table edit that broke the identity (e.g. a typo'd pip_value) would silently
/// double or halve every realistic-broker P&L.
#[test]
fn pip_value_is_consistent_with_contract_and_pip_at_the_public_boundary() {
for sym in VETTED {
let s = instrument_spec(sym).expect("vetted symbol");
let derived = s.contract_size * s.pip_size;
assert!(
(s.pip_value_per_lot - derived).abs() < 1e-9,
"{sym}: pip_value_per_lot {} must equal contract_size {} * pip_size {} = {}",
s.pip_value_per_lot,
s.contract_size,
s.pip_size,
derived,
);
}
}
/// Property: an un-vetted symbol resolves to `None` — the refuse-don't-guess
/// honesty lever (#113). A broker that asked for a symbol with no authored floor
/// must get nothing back, never a fabricated/defaulted spec, so deploy fails
/// loudly rather than sizing on a guessed pip value. A regression that returned
/// a `Some(default)` for unknown symbols is exactly the silent-guess failure
/// this guards.
#[test]
fn unvetted_symbol_refuses_with_none() {
assert_eq!(instrument_spec("NOT_A_REAL_SYMBOL"), None);
assert_eq!(instrument_spec(""), None);
// case sensitivity is part of the refuse arm: the table is exact-match only.
assert_eq!(instrument_spec("ger40"), None);
}