Files
Aura/crates/aura-engine/tests/position_event_table.rs
claude a56ab7859d refactor: cut the engine's backtest-metrics edge via RunReport<M>
C28 phase 2 (Stratification); realizes item 1 of the deferred #147. The
engine's production surface no longer names a backtest-metric type:

- RunReport becomes generic over its metric payload M; sweep/mc/walkforward/
  blueprint thread the parameter (SweepPoint<M>, SweepFamily<M>, WindowRun<M>,
  WalkForwardResult<M>). RunManifest stays concrete and engine-owned (its
  selection: Option<FamilySelection> embeds the foundation-grade analysis type).
- summarize and the MC assembly (McDraw/McFamily/McAggregate/RBootstrap/
  r_bootstrap/monte_carlo) move to aura-backtest - McAggregate::from_draws reads
  RunMetrics fields by name, so generifying it is the phase-6 metric-vocabulary
  abstraction (#147 item 2), still deferred; wholesale relocation is the honest
  cut. The concrete instantiation lives in aura-backtest as
  `type RunReport = aura_engine::RunReport<RunMetrics>` + sibling aliases.
- the statistics kernel (MetricStats/quantile/resample_block/SplitMix64) moves
  to the aura-analysis foundation; the engine re-imports it (inner->foundation,
  legal) and re-exports it so existing consumers stay source-compatible.

Dependency inversion in one commit: aura-engine drops aura-backtest from
[dependencies] (back to dev-deps for its SimBroker/RunMetrics test fixtures);
aura-backtest gains aura-engine. Cycle-free for lib targets - the cycle closes
only through the engine's dev-dep edge, the pattern aura-vocabulary already
uses. aura-backtest reaches the kernel transitively through the engine
re-export, so no aura-backtest -> aura-analysis edge exists (the C28 ladder
permits backtest -> {core, engine} only). run_indexed / SplitMix64::next_f64
widened pub(crate) -> pub for cross-crate use.

Consumers (registry/campaign/cli/composites/ingest/bench) rewired by import
path only, no call-site logic changed. The c28_layering structural test extends
to the full ladder: aura-analysis (no aura-* deps), aura-engine ⊆ {core,
analysis}, aura-backtest ⊆ {core, engine}.

Behaviour-preserving: 1448/0 tests, clippy -D warnings clean, serde shapes
byte-identical (C18 - RunReport<M> keeps field order manifest,metrics; the
CLI pre-serialized-splice contract unchanged), moved code traceable via git
rename detection. Cycle-introduced broken intra-doc links fixed.

closes #292
2026-07-20 13:25:07 +02:00

89 lines
4.3 KiB
Rust

//! 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_backtest` re-exports (moved from `aura_engine`, #291) 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 `aura-backtest::metrics`.
use aura_backtest::{PositionAction, PositionEvent};
use aura_engine::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());
}