Cycle-close tidy for broker-foundation (InstrumentSpec deploy metadata #113 + PositionEvent schema #114). Architect drift review (3638d48..HEAD): code is drift-clean. PositionEvent matches the C10 column spec field-for-field (no open_ts, unsigned volume, direction-is-action, bare-i64 action); the engine run-loop is untouched (C14); InstrumentSpec stays Copy metadata at the ingestion edge (C7/C15); no instrument identity reaches the hot path. The only drift was stale ledger prose, fixed here: - C10 realization note: added a cycle-0063 entry recording that the position-event *schema* landed (PositionAction/PositionEvent), while derivation (#115) and the realistic broker nodes (#116) remain the deferred decoupled layer. - C15 realization note: replaced "minimal today (pip only)" with the six-field deploy-grade floor and the #124 currency-type forward note (quote_currency is &'static str; the #124 resolver chooses how its runtime tier carries currency). Regression (verbatim): cargo test --workspace = 458 passed / 0 failed (exit 0); cargo clippy --workspace --all-targets -- -D warnings = clean (exit 0). Carry-on (not actioned, justified): the inline reversal test is a shape-pin only — the behaviour test arrives with #115's derive, and the E2E twin already exercises the reversal wire shape. The quote_currency &'static str / #124 resolver concern is logged on #124 as a deliberate deferred design choice. This is drift-clean, NOT a milestone close — the realistic-broker milestone needs its end-to-end fieldtest green, which depends on #115/#116/#120-123 still to land. Removed the ephemeral cycle artifacts (spec + plan) per the lifecycle convention.
This commit is contained in:
+20
-4
@@ -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
|
||||
|
||||
@@ -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<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_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<InstrumentSpec> {
|
||||
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<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):
|
||||
|
||||
```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<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):
|
||||
|
||||
```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).
|
||||
@@ -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<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):
|
||||
|
||||
```rust
|
||||
// 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):
|
||||
|
||||
```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<PositionAction> for i64` + `TryFrom<i64>` + 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.
|
||||
Reference in New Issue
Block a user