refactor: extract the member-run recipe into library crates (#295 part 1)
The shell no longer defines what an aura backtest is. Tasks 1-9 of the shell-boundary cycle — structural extraction, behaviour byte-identical: - aura-runner (new; the C28 assembly position): input binding (the C26 module, moved whole), the C1-load-bearing param<->config translators, harness assembly (wrap_r / run_signal_r / run_blueprint_member and the probe/reopen cluster), axis + risk-regime conventions (bind_axes et al.), the campaign family builders + MC guards, reproduce (process::exit -> RunnerError, shell remaps to identical bytes), the measurement-run orchestration, project loading (Env / cdylib load / charter / staleness, moved whole), the coverage gap-walk (deduplicated onto interior_gap_months), and DefaultMemberRunner — the public implementation of aura_campaign::MemberRunner (ex CliMemberRunner). The MemberRunner trait stays in aura-campaign; the column still imports no harness/data-binding machinery. - aura-measurement (new; seeds C28 rung 3): the IcMetrics/IcKey vocabulary + information_coefficient, verbatim incl. serde derives (#294 duplicate-timestamp semantics move as-is, unresolved). - aura-backtest: the pure per-run scaffold (point_from_params, wf_ms_sizes / fit_wf_ms_sizes, intersect_shared_window). - shell residue: main.rs / campaign_run.rs keep argv/dispatch, argv->document translation, and presentation only; walkforward_summary_json_from_reports now calls the public aura_engine::param_stability instead of its inline twin. - worked example (crates/aura-runner/examples/world_member_run.rs): a World program runs a member backtest through the library alone. Held quality findings, adjudicated: the plan's literal pub-visibility list for binding.rs kept (cosmetic); ~20 refusal sites inside aura-runner (family/member/measure/translate) still process::exit — behaviour-identical today, conversion to RunnerError is filed forward (refs #297); rustfmt line-width drift on re-pathed call sites left for a repo-wide fmt decision. Verification: cargo test --workspace green (1471 passed, 0 failed); cargo clippy --workspace --all-targets -D warnings clean. Byte-identity is pinned by the untouched shell E2E suites (cli_run, measure_ic, run_measurement, research_docs, run_refuses_unrunnable_blueprint, tap_recording — zero assertion edits). Remaining in this cycle: full-workspace c28_layering + shell-content check, ledger amendments (C28 assembly position, C25/C14 control-surface consequence, C26 realization note). refs #295
This commit is contained in:
@@ -0,0 +1,224 @@
|
||||
//! The Information Coefficient — the measurement rung's first deflatable
|
||||
//! metric (#295).
|
||||
|
||||
use aura_analysis::{one_sided_p_laplace, pearson_corr, permute, MetricVocabulary, SplitMix64};
|
||||
use aura_core::Timestamp;
|
||||
|
||||
/// The IC measurement payload: the scalar plus its in-memory null inputs
|
||||
/// (the aligned pairs ride #[serde(skip)], mirroring RMetrics.net_trade_rs).
|
||||
/// The second production implementor of `MetricVocabulary` (#147) — the
|
||||
/// registry's deflation machinery dispatches to ITS permutation null.
|
||||
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||
pub struct IcMetrics {
|
||||
pub information_coefficient: f64,
|
||||
#[serde(skip)]
|
||||
pub sigs: Vec<f64>,
|
||||
#[serde(skip)]
|
||||
pub frs: Vec<f64>,
|
||||
}
|
||||
|
||||
/// The IC vocabulary's single resolved key.
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct IcKey;
|
||||
|
||||
impl MetricVocabulary for IcMetrics {
|
||||
type Key = IcKey;
|
||||
|
||||
fn resolve(name: &str) -> Option<IcKey> {
|
||||
(name == "information_coefficient").then_some(IcKey)
|
||||
}
|
||||
fn known() -> &'static [&'static str] {
|
||||
&["information_coefficient"]
|
||||
}
|
||||
fn higher_is_better(_: IcKey) -> bool {
|
||||
true
|
||||
}
|
||||
fn value(&self, _: IcKey) -> f64 {
|
||||
self.information_coefficient
|
||||
}
|
||||
fn has_resampling_null(_: IcKey) -> bool {
|
||||
true
|
||||
}
|
||||
fn null_draw(&self, _: IcKey, _block_len: usize, rng: &mut SplitMix64) -> Option<f64> {
|
||||
if self.sigs.len() < 2 {
|
||||
return None; // consumes no rng
|
||||
}
|
||||
let mut perm = self.sigs.clone(); // independent draw, not a chained shuffle
|
||||
permute(&mut perm, rng);
|
||||
Some(pearson_corr(&perm, &self.frs))
|
||||
}
|
||||
}
|
||||
|
||||
/// The pure IC reduction result (no CLI context) — unit-testable in isolation.
|
||||
/// Exercised by the in-crate `tests` module and consumed by the run/command-path
|
||||
/// wiring in `dispatch_measure_ic`, which constructs `IcReport` from a live
|
||||
/// measurement run's recorded taps.
|
||||
pub struct IcOutcome {
|
||||
pub information_coefficient: f64,
|
||||
pub overfit_probability: f64,
|
||||
pub n_pairs: usize,
|
||||
}
|
||||
|
||||
/// `corr(signal_t, forward_return_{t+h})` over two recorded tap series, with a
|
||||
/// permutation null. `forward_return[i] = (price[i+h] - price[i]) / price[i]` on the
|
||||
/// price ts-spine (a post-run array shift over recorded data — C2 governs in-graph
|
||||
/// nodes, not a completed run's trace); signal is aligned to it by EXACT timestamp
|
||||
/// match (unmatched dropped). `< 2` aligned pairs or zero variance → the degenerate
|
||||
/// floor `(0.0, 1.0)`. One-sided permutation null via the shared
|
||||
/// `MetricVocabulary::null_draw` + `one_sided_p_laplace` building blocks (#147);
|
||||
/// draws are independent permutations, deterministic given `seed`.
|
||||
pub fn information_coefficient(
|
||||
signal: &[(Timestamp, f64)],
|
||||
price: &[(Timestamp, f64)],
|
||||
horizon: usize,
|
||||
permutations: usize,
|
||||
seed: u64,
|
||||
) -> IcOutcome {
|
||||
use std::collections::HashMap;
|
||||
// signal value by timestamp-i64 (`Timestamp` is a tuple struct over epoch-ns; a tap
|
||||
// fires at most once per ts). `t.0` is the epoch-ns i64.
|
||||
let sig_at: HashMap<i64, f64> = signal.iter().map(|(t, v)| (t.0, *v)).collect();
|
||||
// forward returns on the price spine, paired with the signal at the SAME ts
|
||||
let (mut sigs, mut frs) = (Vec::new(), Vec::new());
|
||||
if horizon >= 1 && price.len() > horizon {
|
||||
for i in 0..price.len() - horizon {
|
||||
let (t, p0) = (price[i].0, price[i].1);
|
||||
if p0 == 0.0 {
|
||||
continue; // undefined return
|
||||
}
|
||||
let fr = (price[i + horizon].1 - p0) / p0;
|
||||
if let Some(&s) = sig_at.get(&t.0) {
|
||||
sigs.push(s);
|
||||
frs.push(fr);
|
||||
}
|
||||
}
|
||||
}
|
||||
let n_pairs = sigs.len();
|
||||
if n_pairs < 2 {
|
||||
return IcOutcome { information_coefficient: 0.0, overfit_probability: 1.0, n_pairs };
|
||||
}
|
||||
let raw = pearson_corr(&sigs, &frs);
|
||||
let member = IcMetrics { information_coefficient: raw, sigs, frs };
|
||||
let mut rng = SplitMix64::new(seed);
|
||||
let mut ge = 0usize;
|
||||
for _ in 0..permutations {
|
||||
let draw = member
|
||||
.null_draw(IcKey, 0, &mut rng)
|
||||
.expect("n_pairs >= 2 checked above");
|
||||
if draw >= raw {
|
||||
ge += 1;
|
||||
}
|
||||
}
|
||||
let overfit_probability = one_sided_p_laplace(ge, permutations);
|
||||
IcOutcome { information_coefficient: raw, overfit_probability, n_pairs }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn ts(i: i64) -> Timestamp {
|
||||
Timestamp(i) // Timestamp is a tuple struct over epoch-ns i64 (pub field, per report.rs)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ic_engineered_signal_is_significant() {
|
||||
// price path; forward return fr[i] = (p[i+1]-p[i])/p[i]. Engineer signal_t = fr[i]
|
||||
// exactly (a hand-built OFFLINE series — a look-ahead signal is impossible in a
|
||||
// causal run, C2, so this property lives at the unit level, not E2E).
|
||||
let price: Vec<(Timestamp, f64)> = [100.0, 101.0, 100.5, 102.0, 101.0, 103.0, 102.5, 104.0]
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, &p)| (ts(i as i64), p))
|
||||
.collect();
|
||||
let signal: Vec<(Timestamp, f64)> = (0..price.len() - 1)
|
||||
.map(|i| (ts(i as i64), (price[i + 1].1 - price[i].1) / price[i].1))
|
||||
.collect();
|
||||
let out = information_coefficient(&signal, &price, 1, 1000, 0);
|
||||
assert!(out.information_coefficient > 0.99, "ic = {}", out.information_coefficient);
|
||||
assert!(out.overfit_probability < 0.05, "p = {}", out.overfit_probability);
|
||||
assert!(out.n_pairs >= 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ic_constant_signal_is_not_significant() {
|
||||
let price: Vec<(Timestamp, f64)> = [100.0, 101.0, 100.5, 102.0, 101.0, 103.0]
|
||||
.iter().enumerate().map(|(i, &p)| (ts(i as i64), p)).collect();
|
||||
let signal: Vec<(Timestamp, f64)> =
|
||||
(0..price.len()).map(|i| (ts(i as i64), 1.0)).collect(); // constant → zero variance
|
||||
let out = information_coefficient(&signal, &price, 1, 1000, 0);
|
||||
assert_eq!(out.information_coefficient, 0.0);
|
||||
assert_eq!(out.overfit_probability, 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ic_varying_uncorrelated_signal_is_not_significant() {
|
||||
// The false-positive control on a NON-degenerate null: a VARYING signal (unlike the
|
||||
// constant case above, whose null is degenerate) that is exactly uncorrelated with the
|
||||
// forward returns. price is chosen so forward returns are exactly [0.01, 0.02, 0.03,
|
||||
// 0.04]; signal is a permutation of those values arranged orthogonal to them
|
||||
// (Σ centred products = 0 → IC = 0), so the permutation null genuinely varies yet the
|
||||
// raw IC sits in its bulk (~half the permutations exceed it), not its tail → not
|
||||
// significant. This is the core guarantee of a significance test.
|
||||
let price: Vec<(Timestamp, f64)> = [100.0, 101.0, 103.02, 106.1106, 110.355024]
|
||||
.iter().enumerate().map(|(i, &p)| (ts(i as i64), p)).collect();
|
||||
let signal: Vec<(Timestamp, f64)> = [0.03, 0.01, 0.04, 0.02]
|
||||
.iter().enumerate().map(|(i, &s)| (ts(i as i64), s)).collect();
|
||||
let out = information_coefficient(&signal, &price, 1, 1000, 0);
|
||||
assert_eq!(out.n_pairs, 4);
|
||||
assert!(out.information_coefficient.abs() < 1e-6, "ic ~ 0 expected, got {}", out.information_coefficient);
|
||||
assert!(
|
||||
out.overfit_probability > 0.1,
|
||||
"an uncorrelated signal must not be significant: p = {}",
|
||||
out.overfit_probability
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ic_is_deterministic_given_seed() {
|
||||
let price: Vec<(Timestamp, f64)> = [100.0, 101.0, 100.5, 102.0, 101.0, 103.0, 102.5]
|
||||
.iter().enumerate().map(|(i, &p)| (ts(i as i64), p)).collect();
|
||||
let signal: Vec<(Timestamp, f64)> =
|
||||
[0.3, -0.1, 0.4, -0.2, 0.1, 0.5, -0.3].iter().enumerate()
|
||||
.map(|(i, &s)| (ts(i as i64), s)).collect();
|
||||
let a = information_coefficient(&signal, &price, 1, 500, 7);
|
||||
let b = information_coefficient(&signal, &price, 1, 500, 7);
|
||||
assert_eq!(a.information_coefficient, b.information_coefficient);
|
||||
assert_eq!(a.overfit_probability, b.overfit_probability);
|
||||
assert_eq!(a.n_pairs, b.n_pairs);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ic_degenerate_floor_on_too_few_pairs() {
|
||||
let price = vec![(ts(0), 100.0)]; // one row → no forward return → 0 pairs
|
||||
let signal = vec![(ts(0), 1.0)];
|
||||
let out = information_coefficient(&signal, &price, 1, 1000, 0);
|
||||
assert_eq!(out.information_coefficient, 0.0);
|
||||
assert_eq!(out.overfit_probability, 1.0);
|
||||
assert_eq!(out.n_pairs, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ic_pairs_only_on_exact_timestamp_overlap() {
|
||||
// Property: alignment is by EXACT ts match, unmatched dropped (the cross-cadence
|
||||
// case). A price ts carrying no signal is dropped, and a signal ts absent from the
|
||||
// price spine is ignored — so n_pairs is the OVERLAP count, never the price length,
|
||||
// and the dropped rows never enter the correlation.
|
||||
//
|
||||
// price spine ts 0..8 → horizon-1 forward returns exist at ts 0..7. Signal is placed
|
||||
// ONLY at ts {1,2,4,5} (leaving price ts {0,3,6} without a signal → dropped) plus two
|
||||
// decoy ts {50,60} absent from the price spine (→ never looked up). Where present, the
|
||||
// signal equals the forward return exactly, so the 4 matched pairs correlate perfectly.
|
||||
let price: Vec<(Timestamp, f64)> =
|
||||
[100.0, 101.0, 100.5, 102.0, 101.0, 103.0, 102.5, 104.0]
|
||||
.iter().enumerate().map(|(i, &p)| (ts(i as i64), p)).collect();
|
||||
let fr = |i: usize| (price[i + 1].1 - price[i].1) / price[i].1;
|
||||
let mut signal: Vec<(Timestamp, f64)> =
|
||||
[1usize, 2, 4, 5].iter().map(|&i| (ts(i as i64), fr(i))).collect();
|
||||
signal.push((ts(50), 999.0)); // decoy ts not on the price spine → unmatched, dropped
|
||||
signal.push((ts(60), -999.0));
|
||||
let out = information_coefficient(&signal, &price, 1, 1000, 0);
|
||||
assert_eq!(out.n_pairs, 4, "only the 4 overlapping ts pair; price ts 0/3/6 and the decoys drop");
|
||||
assert!(out.information_coefficient > 0.99, "ic = {}", out.information_coefficient);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user