use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use crate::composite::{CompositeScore, ScoreConfig};
use crate::oos::OosDecayReport;
use crate::rediscovery::RediscoveryVerdict;
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FailReason {
FailedPassK,
DsrBelowBar,
ProcessViolation,
BootstrapInsignificant,
MandateBreached,
HighSelectionGap,
IsRediscovery,
OosDecay,
}
#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
pub struct DisqualThresholds {
pub dsr_bar: f64,
pub alpha: f64,
pub selection_gap_max: f64,
pub oos_retention_min: f64,
}
impl Default for DisqualThresholds {
fn default() -> Self {
Self {
dsr_bar: 0.95,
alpha: 0.05,
selection_gap_max: 0.20,
oos_retention_min: 0.50,
}
}
}
impl DisqualThresholds {
pub fn from_score_config(cfg: &ScoreConfig) -> Self {
Self {
dsr_bar: cfg.dsr_bar,
alpha: cfg.alpha,
..Self::default()
}
}
}
pub fn classify_disqualification(
score: &CompositeScore,
thresholds: &DisqualThresholds,
oos: Option<&OosDecayReport>,
rediscovery: Option<&RediscoveryVerdict>,
) -> Vec<FailReason> {
let mut reasons = Vec::new();
if !score.passed_k {
reasons.push(FailReason::FailedPassK);
}
if score.deflated_sharpe < thresholds.dsr_bar {
reasons.push(FailReason::DsrBelowBar);
}
if !score.process_ok {
reasons.push(FailReason::ProcessViolation);
}
if score.bootstrap_p >= thresholds.alpha {
reasons.push(FailReason::BootstrapInsignificant);
}
if !score.mandate_ok {
reasons.push(FailReason::MandateBreached);
}
if score
.selection_gap
.is_some_and(|g| g > thresholds.selection_gap_max)
{
reasons.push(FailReason::HighSelectionGap);
}
if rediscovery.is_some_and(|v| v.is_rediscovery) {
reasons.push(FailReason::IsRediscovery);
}
if oos.is_some_and(|r| r.retention < thresholds.oos_retention_min) {
reasons.push(FailReason::OosDecay);
}
reasons
}
pub fn rollup(scores: &[CompositeScore]) -> BTreeMap<FailReason, usize> {
let thresholds = DisqualThresholds::default();
let mut counts: BTreeMap<FailReason, usize> = BTreeMap::new();
for s in scores {
for reason in classify_disqualification(s, &thresholds, None, None) {
*counts.entry(reason).or_insert(0) += 1;
}
}
counts
}
#[cfg(test)]
mod tests {
use super::*;
use crate::composite::{score_agent, AgentSubmission, Run};
use crate::process::{ProcessEvent, Trace};
fn run(mean_ret: f64, amp: f64, n: usize) -> Run {
Run {
returns: (0..n)
.map(|i| mean_ret + amp * (i as f64 * 0.7).sin())
.collect(),
trace: Trace::default(),
confidences: Vec::new(),
outcomes: Vec::new(),
cost: 0.0,
}
}
fn agent(id: &str, runs: Vec<Run>) -> AgentSubmission {
AgentSubmission {
agent_id: id.to_string(),
runs,
in_sample_trials: 0,
candidates: Vec::new(),
}
}
fn thresholds() -> DisqualThresholds {
DisqualThresholds::from_score_config(&ScoreConfig::default())
}
#[test]
fn skilled_agent_has_no_reasons() {
let s = score_agent(
&agent("skilled", (0..5).map(|_| run(0.002, 0.0005, 60)).collect()),
&ScoreConfig::default(),
);
assert!(s.rank_eligible);
assert!(classify_disqualification(&s, &thresholds(), None, None).is_empty());
}
#[test]
fn lucky_agent_fails_pass_k() {
let mut runs = vec![run(0.02, 0.002, 60)];
runs.extend((0..4).map(|_| run(0.0, 0.003, 60)));
let s = score_agent(&agent("lucky", runs), &ScoreConfig::default());
let reasons = classify_disqualification(&s, &thresholds(), None, None);
assert!(reasons.contains(&FailReason::FailedPassK), "{reasons:?}");
}
#[test]
fn process_violation_is_named() {
let mut runs: Vec<Run> = (0..5).map(|_| run(0.002, 0.0005, 60)).collect();
runs[0].trace.events.push(ProcessEvent::OrderPlaced {
risk_gate_passed: false,
});
let s = score_agent(&agent("violator", runs), &ScoreConfig::default());
let reasons = classify_disqualification(&s, &thresholds(), None, None);
assert!(
reasons.contains(&FailReason::ProcessViolation),
"{reasons:?}"
);
}
#[test]
fn noise_agent_fails_dsr_and_bootstrap() {
let s = score_agent(
&agent("noise", (0..5).map(|_| run(0.0, 0.02, 60)).collect()),
&ScoreConfig::default(),
);
let reasons = classify_disqualification(&s, &thresholds(), None, None);
assert!(reasons.contains(&FailReason::DsrBelowBar), "{reasons:?}");
assert!(
reasons.contains(&FailReason::BootstrapInsignificant),
"{reasons:?}"
);
}
#[test]
fn advisory_flags_require_evidence() {
let mut s = score_agent(
&agent("s", (0..5).map(|_| run(0.002, 0.0005, 60)).collect()),
&ScoreConfig::default(),
);
s.selection_gap = Some(0.5);
let oos = OosDecayReport {
window_metrics: vec![1.0, 0.1],
in_sample: 1.0,
out_of_sample: 0.1,
retention: 0.1,
monotone_decay: true,
};
let redisc = RediscoveryVerdict {
is_rediscovery: true,
max_similarity: 0.99,
nearest_index: Some(0),
threshold: 0.97,
};
let reasons = classify_disqualification(&s, &thresholds(), Some(&oos), Some(&redisc));
assert!(
reasons.contains(&FailReason::HighSelectionGap),
"{reasons:?}"
);
assert!(reasons.contains(&FailReason::OosDecay), "{reasons:?}");
assert!(reasons.contains(&FailReason::IsRediscovery), "{reasons:?}");
let bare = classify_disqualification(&s, &thresholds(), None, None);
assert!(bare.contains(&FailReason::HighSelectionGap));
assert!(!bare.contains(&FailReason::OosDecay));
assert!(!bare.contains(&FailReason::IsRediscovery));
}
#[test]
fn rollup_counts_across_the_field() {
let skilled = score_agent(
&agent("skilled", (0..5).map(|_| run(0.002, 0.0005, 60)).collect()),
&ScoreConfig::default(),
);
let noise1 = score_agent(
&agent("noise1", (0..5).map(|_| run(0.0, 0.02, 60)).collect()),
&ScoreConfig::default(),
);
let noise2 = score_agent(
&agent("noise2", (0..5).map(|_| run(0.0, 0.02, 60)).collect()),
&ScoreConfig::default(),
);
let counts = rollup(&[skilled, noise1, noise2]);
assert_eq!(counts.get(&FailReason::DsrBelowBar), Some(&2));
assert_eq!(counts.get(&FailReason::BootstrapInsignificant), Some(&2));
assert!(counts.values().all(|&c| c <= 2));
}
}