diff --git a/docs/design/INDEX.md b/docs/design/INDEX.md index 112a725..db49c57 100644 --- a/docs/design/INDEX.md +++ b/docs/design/INDEX.md @@ -537,6 +537,17 @@ this cycle; it remains the decoupled, derived, deferred layer described above. lookup — a Rust-authored vetted table in `aura-ingest`, at the ingestion/source edge where the symbol still exists (never `Aura.toml`, never `Ctx`) — supplies the divisor; the engine stays domain-free (no instrument identity reaches the hot path). +**Realization (position-event schema, cycle 0063, #114).** The position-management +half's **schema** now landed (its derivation and the brokers remain deferred): a +closed `PositionAction { Buy, Sell, Close }` enum + the `PositionEvent` row +(`event_ts`, `action`, `position_id`, `instrument_id`, unsigned `volume`; no +`open_ts`; direction *is* the action) live in `aura-engine` beside `RunMetrics` as a +post-run value type (not a per-`eval` node — C8). `action` serde-encodes as a bare +`i64` (Buy=0, Sell=1, Close=2), the C7 scalar column form the ledger's table spec +requires, with an out-of-range code rejected on read. Still deferred: +`derive_position_events` (the first-difference reduction over the exposure history, +#115) and the realistic broker nodes that consume the table (#116). The table stays +broker-independent. The honesty rule is **refuse, don't guess**: a real-data run for a symbol with no vetted spec is a usage error (`exit 2`), so cross-asset pip equity is comparable by construction rather than by researcher discipline. Threaded through the CLI @@ -645,10 +656,15 @@ stream model. stream; reference data feeds source/session nodes from beside the hot path. **Realization (instrument specs, 2026-06, #22).** The "instrument specs are metadata" half of this contract is first realized by `aura-ingest`'s -`InstrumentSpec { pip_size }` + `instrument_spec(symbol)` — non-scalar reference -data held beside the hot path, keyed by symbol, feeding the sim-optimal broker's -pip divisor (C10). Minimal today (pip only); extensible to tick size / digits / -quote currency without a signature break. +`InstrumentSpec` (initially a single `pip_size`) + `instrument_spec(symbol)` — +non-scalar reference data held beside the hot path, keyed by symbol, feeding the +sim-optimal broker's pip divisor (C10). Extended in cycle 0063 (#113) to a six-field +deploy-grade floor (`instrument_id`, `contract_size`, `pip_value_per_lot`, `min_lot`, +`lot_step`, `quote_currency`) for the realistic broker's currency conversion — the +permanent authored floor of the override hierarchy (#124), the refuse-don't-guess +arm preserved. NB: `quote_currency` is `&'static str` (keeps the struct `Copy`); how +the #124 resolver's runtime-sourced tier carries currency (a distinct resolved type, +or widening the field) is that cycle's decision — see #124. ### C16 — Engine / project separation; three-tier node reuse **Guarantee.** aura is the reusable **engine**; each research project is a diff --git a/docs/plans/0063-broker-foundation.md b/docs/plans/0063-broker-foundation.md deleted file mode 100644 index 4d8bac8..0000000 --- a/docs/plans/0063-broker-foundation.md +++ /dev/null @@ -1,446 +0,0 @@ -# 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 = ["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 { - 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 { - 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 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, -} -``` - -- [ ] **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). diff --git a/docs/specs/0063-broker-foundation.md b/docs/specs/0063-broker-foundation.md deleted file mode 100644 index 12f00bd..0000000 --- a/docs/specs/0063-broker-foundation.md +++ /dev/null @@ -1,280 +0,0 @@ -# 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: - -1. **#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-guess - `None` arm stays the absolute floor. -2. **#114** — pin the canonical Rust representation of C10's derived - position-event table: `PositionAction { Buy, Sell, Close }` + `PositionEvent`, - living in `aura-engine` alongside `RunMetrics`/`ColumnarTrace` (a post-run - reduction, the same home as `summarize`). 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: - -- `InstrumentSpec` is 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 stays `Copy`. -- `PositionEvent` is 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: f64` unsigned; **no `open_ts`**; the signed-volume trick is forbidden. - It is a post-run value type (sibling of `RunMetrics`), never a per-`eval` node - output (C8) — which is why it lives as data in `aura-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): - -```rust -// aura-ingest/src/lib.rs — instrument_spec(), after extension -pub fn instrument_spec(symbol: &str) -> Option { - // (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): - -```rust -// aura-engine/src/report.rs -use std::convert::TryFrom; - -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}")), - } - } -} -// 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): - -```rust -// 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`: - -```rust -// 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`: - -```rust -/// 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. `Copy` is preserved because - `quote_currency` is `&'static str`, not `String`. -- **aura-ingest `instrument_spec()` table (populate)** — `:358`. Fill all six - fields for the five vetted symbols; keep the `_ => None` refuse arm. -- **aura-engine `PositionAction` (new)** — `crates/aura-engine/src/report.rs`. - Closed enum + `From for i64` + `TryFrom` + serde - `into`/`try_from` attributes. Re-export from the crate root if `RunMetrics` is - (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)` → `InstrumentSpec` is resolved at the ingestion edge - at bootstrap; the broker (#116) and `derive_position_events` (#115) read the - **resolved injected spec**, never call `instrument_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.) -- `PositionEvent` rows are produced post-run by `derive_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()` returns `None`; the existing - `instrument_spec_or_refuse` (aura-cli `main.rs:766`) keeps refusing. No guessed - metadata enters a run. -- **Out-of-range action i64** → `PositionAction::try_from` returns `Err` (a - message naming the bad value); serde deserialization of a bad `action` fails - 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 in `aura-ingest/src/lib.rs` (greps confirm: no other crate constructs - the struct; `instrument_spec_or_refuse` and 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_size` for every vetted - instrument (pins the cross-field relationship, not the literals' real-world - exactness). - - Invariant: the five `instrument_id`s are distinct (uniqueness). -- **aura-engine** (new tests in `report.rs`): - - `PositionAction` round-trips: each variant → `i64` → `try_from` → same - variant. - - `PositionAction::try_from(3)` (and a negative) → `Err`. - - A `PositionEvent` serde JSON round-trip equals the original, and the - serialized `action` is a **bare integer** (e.g. the JSON contains `"action":1` - for `Sell`), confirming the i64 encoding. - - A reversal pair (Close then Sell) sharing one `event_ts` constructs and is - representable (documents close-before-open ordering; the ordering *logic* is - #115). -- **Workspace:** `cargo test --workspace` green; `cargo clippy --workspace - --all-targets -- -D warnings` clean. - -## Acceptance criteria - -1. `InstrumentSpec` carries the six new deploy-grade fields and stays `Copy`; the - vetted table populates all five instruments; the `_ => None` refuse arm - remains. -2. `PositionAction` is 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. -3. `PositionEvent` matches the C10 column spec exactly — `event_ts`, enum - `action`, `position_id`, `instrument_id`, unsigned `volume`; **no `open_ts`**; - no signed-volume trick. Its serde shape is pinned (action encodes as i64). -4. Both types live where the ledger/issues place them (`InstrumentSpec` in - aura-ingest at the ingestion edge; `PositionEvent` in aura-engine beside - `RunMetrics`), and the engine stays headless (C14) — no broker, no derivation, - no I/O this cycle. -5. `cargo test --workspace` passes and `clippy -D warnings` is clean.