//! 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, #[serde(skip)] pub frs: Vec, } /// 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 { (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 { 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 = 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); } }