From b91c89f964bfb093516aab9feab7a05cd7ca570a Mon Sep 17 00:00:00 2001 From: Brummel Date: Mon, 22 Jun 2026 19:51:17 +0200 Subject: [PATCH] feat(broker-foundation): InstrumentSpec deploy metadata + PositionEvent schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- crates/aura-engine/src/lib.rs | 3 +- crates/aura-engine/src/report.rs | 100 +++++++++++++ .../aura-engine/tests/position_event_table.rs | 86 +++++++++++ crates/aura-ingest/src/lib.rs | 141 ++++++++++++++++-- .../tests/instrument_spec_deploy_metadata.rs | 72 +++++++++ 5 files changed, 390 insertions(+), 12 deletions(-) create mode 100644 crates/aura-engine/tests/position_event_table.rs create mode 100644 crates/aura-ingest/tests/instrument_spec_deploy_metadata.rs diff --git a/crates/aura-engine/src/lib.rs b/crates/aura-engine/src/lib.rs index 479189c..0801e21 100644 --- a/crates/aura-engine/src/lib.rs +++ b/crates/aura-engine/src/lib.rs @@ -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, diff --git a/crates/aura-engine/src/report.rs b/crates/aura-engine/src/report.rs index 22df460..fb964e7 100644 --- a/crates/aura-engine/src/report.rs +++ b/crates/aura-engine/src/report.rs @@ -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 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, +} + /// 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 { diff --git a/crates/aura-engine/tests/position_event_table.rs b/crates/aura-engine/tests/position_event_table.rs new file mode 100644 index 0000000..48efb16 --- /dev/null +++ b/crates/aura-engine/tests/position_event_table.rs @@ -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 { + 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 = 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::("0").unwrap(), PositionAction::Buy); + assert_eq!(serde_json::from_str::("1").unwrap(), PositionAction::Sell); + assert_eq!(serde_json::from_str::("2").unwrap(), PositionAction::Close); + + // an out-of-range code is refused on read, not coerced (the validation arm). + assert!(serde_json::from_str::("3").is_err()); + assert!(serde_json::from_str::("-1").is_err()); +} diff --git a/crates/aura-ingest/src/lib.rs b/crates/aura-ingest/src/lib.rs index de651e9..c96430c 100644 --- a/crates/aura-ingest/src/lib.rs +++ b/crates/aura-ingest/src/lib.rs @@ -340,12 +340,28 @@ pub fn default_data_server() -> Arc { } /// 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 { - 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 = ["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. diff --git a/crates/aura-ingest/tests/instrument_spec_deploy_metadata.rs b/crates/aura-ingest/tests/instrument_spec_deploy_metadata.rs new file mode 100644 index 0000000..b0014d4 --- /dev/null +++ b/crates/aura-ingest/tests/instrument_spec_deploy_metadata.rs @@ -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); +}