diff --git a/crates/aura-ingest/src/lib.rs b/crates/aura-ingest/src/lib.rs index c96430c..02e96f3 100644 --- a/crates/aura-ingest/src/lib.rs +++ b/crates/aura-ingest/src/lib.rs @@ -28,6 +28,9 @@ use data_server::SymbolChunkIter; /// builds from `aura-ingest` alone — a consumer never names the external /// `data_server` crate directly. pub use data_server::{DataServer, DEFAULT_DATA_PATH}; +/// Re-export of data-server's neutral instrument geometry (#143): a consumer reads +/// provider geometry through `aura-ingest` alone, never naming `data_server`. +pub use data_server::meta::InstrumentGeometry; use std::sync::Arc; /// Normalize data-server's Unix-millisecond time to aura's canonical epoch-ns @@ -339,6 +342,22 @@ pub fn default_data_server() -> Arc { Arc::new(DataServer::new(DEFAULT_DATA_PATH)) } +/// Load the provider-recorded neutral geometry for `symbol` — the recorded +/// dataset-metadata tier (tier 2) of #124's resolution hierarchy — or `None` when +/// the dataset carries no usable sidecar (missing / unparseable / unsupported +/// version / a non-finite field; the reader absorbs every provider quirk). +/// +/// Surfaces the tier-2 *source*; the tier-composing, authored-override-wins +/// *resolver* is #124. The geometry is an **untrusted input** the authored override +/// supersedes — never a silent runtime fallback for an unvetted symbol +/// (refuse-don't-guess stands: [`instrument_spec`] still returns `None`). It is +/// non-scalar reference metadata held beside the hot path (C15); it is **not** a +/// [`Source`](aura_engine::Source) and never enters the engine's +/// `(Timestamp, Scalar)` seam. +pub fn instrument_geometry(server: &Arc, symbol: &str) -> Option { + server.symbol_meta(symbol) +} + /// 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 @@ -362,8 +381,21 @@ pub struct InstrumentSpec { pub lot_step: f64, /// Quote currency (ISO label only this cycle — no FX conversion yet). pub quote_currency: &'static str, + /// Smallest representable price increment (provider geometry; deploy-grade + /// price rounding). 1e-5 for a 5-decimal FX major, 0.1 for the indices. + pub tick_size: f64, + /// Decimal places in a quoted price (provider geometry; deploy-grade price + /// rounding). 5 for FX majors, 1 for the indices. + pub digits: u32, } +/// The vetted symbols [`instrument_spec`] resolves — the single named truth of the +/// authored floor's membership (#113), so consumers iterate one list rather than +/// re-spelling the set. Extending the table (#140) extends this constant and only +/// this constant; every test that sweeps the floor reads it, so the copies cannot +/// drift. Order matches the `instrument_id` assignment in [`instrument_spec`]. +pub const VETTED_SYMBOLS: [&str; 5] = ["GER40", "FRA40", "EURUSD", "GBPUSD", "USDCAD"]; + /// Look up per-instrument reference metadata by `symbol` at the ingestion edge. /// Rust-authored vetted table (NOT `Aura.toml`); `None` for an instrument with no /// vetted spec — the caller must refuse rather than guess a pip. @@ -376,22 +408,27 @@ pub fn instrument_spec(symbol: &str) -> Option { "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", + tick_size: 0.1, digits: 1, }, "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", + tick_size: 0.1, digits: 1, }, "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", + tick_size: 1e-5, digits: 5, }, "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", + tick_size: 1e-5, digits: 5, }, "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", + tick_size: 1e-5, digits: 5, }, _ => return None, }; @@ -402,6 +439,17 @@ pub fn instrument_spec(symbol: &str) -> Option { mod tests { use super::*; + #[test] + fn instrument_geometry_none_for_unknown_symbol() { + // A symbol with no sidecar yields None — the accessor surfaces the reader's + // absent/untrusted contract at the aura-ingest edge. Hermetic: a bogus symbol + // has no .meta.json whether or not the local archive is present, and + // `default_data_server()` constructs safely even when the path is absent + // (see tests/open_ohlc_absent_archive.rs). + let server = default_data_server(); + assert!(instrument_geometry(&server, "NOT_A_REAL_SYMBOL").is_none()); + } + #[test] fn instrument_spec_lookup_by_symbol() { // index CFDs: quote EUR, 1 contract = €1/point, pip = 1.0 point @@ -415,6 +463,8 @@ mod tests { min_lot: 0.1, lot_step: 0.01, quote_currency: "EUR", + tick_size: 0.1, + digits: 1, }) ); assert_eq!( @@ -427,6 +477,8 @@ mod tests { min_lot: 0.1, lot_step: 0.01, quote_currency: "EUR", + tick_size: 0.1, + digits: 1, }) ); // FX majors: standard lot = 100_000 units, pip = 0.0001, pip value = 10 in quote ccy @@ -440,6 +492,8 @@ mod tests { min_lot: 0.01, lot_step: 0.01, quote_currency: "USD", + tick_size: 1e-5, + digits: 5, }) ); assert_eq!( @@ -452,6 +506,8 @@ mod tests { min_lot: 0.01, lot_step: 0.01, quote_currency: "USD", + tick_size: 1e-5, + digits: 5, }) ); assert_eq!( @@ -464,6 +520,8 @@ mod tests { min_lot: 0.01, lot_step: 0.01, quote_currency: "CAD", + tick_size: 1e-5, + digits: 5, }) ); // un-specced → None (the honesty lever) diff --git a/crates/aura-ingest/tests/instrument_geometry_crosscheck.rs b/crates/aura-ingest/tests/instrument_geometry_crosscheck.rs new file mode 100644 index 0000000..518c442 --- /dev/null +++ b/crates/aura-ingest/tests/instrument_geometry_crosscheck.rs @@ -0,0 +1,82 @@ +//! Cross-check the hand-authored vetted InstrumentSpec floor (#113) against the +//! provider-recorded geometry sidecars (#143 — tier 2 of #124's resolution +//! hierarchy). Skips with a note where the local Pepperstone archive is absent, +//! so it is hermetic elsewhere and exercises broker truth where the files exist. + +use aura_ingest::{ + default_data_server, instrument_geometry, instrument_spec, InstrumentGeometry, + DEFAULT_DATA_PATH, VETTED_SYMBOLS, +}; + +/// Property: the provider geometry type is **nameable through `aura-ingest` +/// alone** — the load-bearing seam contract of #143's re-export. A consumer that +/// wants to store or type-annotate the provider geometry (not just call the +/// accessor and immediately destructure it) writes `aura_ingest::InstrumentGeometry` +/// and never reaches into `data_server`. This pins that re-export as an +/// observable, consumer-facing fact: the accessor's `Option<_>` binds to the +/// crate-re-exported type, and a real sidecar yields a value carrying sane +/// provider fields. Dropping the `pub use data_server::meta::InstrumentGeometry` +/// would break this compile while leaving the bare-accessor tests green (they +/// never name the type), so it guards exactly the seam a refactor could silently +/// sever. Skip-if-absent, same discipline as the cross-check below. +#[test] +fn provider_geometry_type_is_nameable_through_the_ingest_seam() { + let server = default_data_server(); + let maybe: Option = instrument_geometry(&server, "EURUSD"); + let Some(geo) = maybe else { + eprintln!("skip: no local geometry sidecars at {DEFAULT_DATA_PATH} (EURUSD.meta.json absent)"); + return; + }; + // Bind into the re-exported type by name (not just `geo.field` off an inferred + // type) so the test fails to compile if the re-export is removed. + let g: InstrumentGeometry = geo; + assert!(g.pip_size > 0.0, "EURUSD provider pip_size must be positive"); + assert!(g.tick_size > 0.0, "EURUSD provider tick_size must be positive"); + assert!(g.digits > 0, "EURUSD provider digits must be positive"); + assert!(g.lot_size > 0.0, "EURUSD provider lot_size must be positive"); + assert!(!g.quote.is_empty(), "EURUSD provider quote currency must be labelled"); +} + +#[test] +fn authored_floor_agrees_with_provider_geometry() { + let server = default_data_server(); + // Gate on a sidecar's presence (symbol_meta reads the file directly, not the + // bar index) — the same skip-if-absent discipline as the other real-data tests. + if instrument_geometry(&server, "EURUSD").is_none() { + eprintln!("skip: no local geometry sidecars at {DEFAULT_DATA_PATH} (EURUSD.meta.json absent)"); + return; + } + for sym in VETTED_SYMBOLS { + let spec = instrument_spec(sym).expect("vetted symbol"); + let geo = instrument_geometry(&server, sym) + .unwrap_or_else(|| panic!("vetted symbol {sym} must carry a geometry sidecar")); + + assert!( + (spec.pip_size - geo.pip_size).abs() < 1e-12, + "{sym}: authored pip_size {} != provider {}", spec.pip_size, geo.pip_size + ); + assert!( + (spec.contract_size - geo.lot_size).abs() < 1e-6, + "{sym}: authored contract_size {} != provider lot_size {}", spec.contract_size, geo.lot_size + ); + // the C10 pip-value invariant, now against broker truth + let provider_pip_value = geo.lot_size * geo.pip_size; + assert!( + (spec.pip_value_per_lot - provider_pip_value).abs() < 1e-9, + "{sym}: authored pip_value_per_lot {} != provider lot_size*pip_size {}", + spec.pip_value_per_lot, provider_pip_value + ); + assert!( + (spec.tick_size - geo.tick_size).abs() < 1e-12, + "{sym}: authored tick_size {} != provider {}", spec.tick_size, geo.tick_size + ); + assert_eq!( + spec.digits, geo.digits, + "{sym}: authored digits {} != provider {}", spec.digits, geo.digits + ); + assert_eq!( + spec.quote_currency, geo.quote.as_str(), + "{sym}: authored quote_currency {} != provider {}", spec.quote_currency, geo.quote + ); + } +} diff --git a/crates/aura-ingest/tests/instrument_spec_deploy_metadata.rs b/crates/aura-ingest/tests/instrument_spec_deploy_metadata.rs index b0014d4..2d00310 100644 --- a/crates/aura-ingest/tests/instrument_spec_deploy_metadata.rs +++ b/crates/aura-ingest/tests/instrument_spec_deploy_metadata.rs @@ -5,22 +5,19 @@ //! 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"]; +use aura_ingest::{instrument_spec, InstrumentSpec, VETTED_SYMBOLS}; /// 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 +/// populated `InstrumentSpec` whose deploy fields are all present and sane +/// (positive sizes/ids, positive tick geometry, 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 { + for sym in VETTED_SYMBOLS { let s: InstrumentSpec = instrument_spec(sym).unwrap_or_else(|| { panic!("vetted symbol {sym} must resolve through the public lookup") }); @@ -31,6 +28,30 @@ fn every_vetted_symbol_resolves_to_a_complete_spec() { 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"); + assert!(s.tick_size > 0.0, "{sym}: tick_size must be positive"); + assert!(s.digits > 0, "{sym}: digits must be positive"); + } +} + +/// Property: the cross-field deploy invariant `tick_size == 10^(-digits)` holds +/// for every vetted instrument as a downstream broker observes it through the +/// public lookup. `tick_size` and `digits` are two views of the same provider +/// price geometry; a broker may round a price by either. This pins that the two +/// agree, so a consistent typo'd *pair* in one table entry (which the positivity +/// asserts and an equally-typo'd literal unit test would both wave through) is +/// caught — exactly the silent-wrong-rounding regression this file guards. +#[test] +fn tick_size_is_consistent_with_digits_at_the_public_boundary() { + for sym in VETTED_SYMBOLS { + let s = instrument_spec(sym).expect("vetted symbol"); + let derived = 10f64.powi(-(s.digits as i32)); + assert!( + (s.tick_size - derived).abs() < 1e-12, + "{sym}: tick_size {} must equal 10^(-digits) = 10^(-{}) = {}", + s.tick_size, + s.digits, + derived, + ); } } @@ -43,7 +64,7 @@ fn every_vetted_symbol_resolves_to_a_complete_spec() { /// 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 { + for sym in VETTED_SYMBOLS { let s = instrument_spec(sym).expect("vetted symbol"); let derived = s.contract_size * s.pip_size; assert!(