#[derive(Debug, Clone)]
pub struct AlphaInvestingConfig {
pub initial_wealth: f64,
pub reward_fraction: f64,
pub investment_fraction: f64,
pub min_wealth: f64,
}
impl Default for AlphaInvestingConfig {
fn default() -> Self {
Self {
initial_wealth: 0.5,
reward_fraction: 0.5,
investment_fraction: 0.1,
min_wealth: 1e-10,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TestOutcome {
Rejected,
NotRejected,
Skipped,
}
#[derive(Debug, Clone)]
pub struct TestRecord {
pub index: usize,
pub p_value: f64,
pub alpha_invested: f64,
pub outcome: TestOutcome,
pub wealth_after: f64,
}
#[derive(Debug, Clone)]
pub struct AlphaInvestor {
config: AlphaInvestingConfig,
wealth: f64,
tests_run: usize,
discoveries: usize,
history: Vec<TestRecord>,
}
impl AlphaInvestor {
pub fn new(config: AlphaInvestingConfig) -> Self {
let wealth = config.initial_wealth;
Self {
config,
wealth,
tests_run: 0,
discoveries: 0,
history: Vec::new(),
}
}
pub fn with_defaults() -> Self {
Self::new(AlphaInvestingConfig::default())
}
pub fn wealth(&self) -> f64 {
self.wealth
}
pub fn tests_run(&self) -> usize {
self.tests_run
}
pub fn discoveries(&self) -> usize {
self.discoveries
}
pub fn discovery_rate(&self) -> f64 {
if self.tests_run == 0 {
0.0
} else {
self.discoveries as f64 / self.tests_run as f64
}
}
pub fn test(&mut self, p_value: f64) -> TestOutcome {
self.test_with_investment(p_value, None)
}
pub fn test_with_investment(&mut self, p_value: f64, custom_alpha: Option<f64>) -> TestOutcome {
let p = p_value.clamp(0.0, 1.0);
if self.wealth < self.config.min_wealth {
let record = TestRecord {
index: self.tests_run,
p_value: p,
alpha_invested: 0.0,
outcome: TestOutcome::Skipped,
wealth_after: self.wealth,
};
self.history.push(record);
self.tests_run += 1;
return TestOutcome::Skipped;
}
let alpha = match custom_alpha {
Some(a) => a.clamp(0.0, self.wealth),
None => (self.config.investment_fraction * self.wealth).min(self.wealth),
};
if alpha <= 0.0 {
let record = TestRecord {
index: self.tests_run,
p_value: p,
alpha_invested: 0.0,
outcome: TestOutcome::Skipped,
wealth_after: self.wealth,
};
self.history.push(record);
self.tests_run += 1;
return TestOutcome::Skipped;
}
self.wealth -= alpha;
let outcome = if p <= alpha {
let reward = self.config.reward_fraction * alpha;
self.wealth += reward;
self.discoveries += 1;
TestOutcome::Rejected
} else {
TestOutcome::NotRejected
};
let record = TestRecord {
index: self.tests_run,
p_value: p,
alpha_invested: alpha,
outcome,
wealth_after: self.wealth,
};
self.history.push(record);
self.tests_run += 1;
outcome
}
pub fn test_batch(&mut self, p_values: &[f64]) -> Vec<TestOutcome> {
p_values.iter().map(|&p| self.test(p)).collect()
}
pub fn reset(&mut self) {
self.wealth = self.config.initial_wealth;
self.tests_run = 0;
self.discoveries = 0;
self.history.clear();
}
pub fn history(&self) -> &[TestRecord] {
&self.history
}
pub fn drain_history(&mut self) -> Vec<TestRecord> {
std::mem::take(&mut self.history)
}
}
pub fn bonferroni_test(p_values: &[f64], alpha: f64) -> Vec<bool> {
if p_values.is_empty() {
return Vec::new();
}
let threshold = alpha / p_values.len() as f64;
p_values.iter().map(|&p| p <= threshold).collect()
}
pub fn benjamini_hochberg(p_values: &[f64], alpha: f64) -> Vec<usize> {
if p_values.is_empty() {
return Vec::new();
}
let m = p_values.len();
let mut indexed: Vec<(usize, f64)> = p_values
.iter()
.enumerate()
.map(|(i, &p)| (i, p.clamp(0.0, 1.0)))
.collect();
indexed.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
let mut max_k = 0;
for (rank, &(_, p)) in indexed.iter().enumerate() {
let threshold = (rank + 1) as f64 / m as f64 * alpha;
if p <= threshold {
max_k = rank + 1;
}
}
indexed[..max_k].iter().map(|&(i, _)| i).collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_config() {
let cfg = AlphaInvestingConfig::default();
assert_eq!(cfg.initial_wealth, 0.5);
assert_eq!(cfg.reward_fraction, 0.5);
assert_eq!(cfg.investment_fraction, 0.1);
}
#[test]
fn investor_initial_state() {
let inv = AlphaInvestor::with_defaults();
assert_eq!(inv.wealth(), 0.5);
assert_eq!(inv.tests_run(), 0);
assert_eq!(inv.discoveries(), 0);
assert_eq!(inv.discovery_rate(), 0.0);
}
#[test]
fn single_rejection() {
let mut inv = AlphaInvestor::with_defaults();
let outcome = inv.test(0.01);
assert_eq!(outcome, TestOutcome::Rejected);
assert_eq!(inv.discoveries(), 1);
assert!((inv.wealth() - 0.475).abs() < 1e-10);
}
#[test]
fn single_non_rejection() {
let mut inv = AlphaInvestor::with_defaults();
let outcome = inv.test(0.9);
assert_eq!(outcome, TestOutcome::NotRejected);
assert_eq!(inv.discoveries(), 0);
assert!((inv.wealth() - 0.45).abs() < 1e-10);
}
#[test]
fn wealth_exhaustion() {
let cfg = AlphaInvestingConfig {
initial_wealth: 0.01,
investment_fraction: 1.0, min_wealth: 0.005,
..Default::default()
};
let mut inv = AlphaInvestor::new(cfg);
let o1 = inv.test(0.5);
assert_eq!(o1, TestOutcome::NotRejected);
let o2 = inv.test(0.001);
assert_eq!(o2, TestOutcome::Skipped);
}
#[test]
fn batch_test() {
let mut inv = AlphaInvestor::with_defaults();
let outcomes = inv.test_batch(&[0.001, 0.001, 0.9, 0.9, 0.001]);
assert_eq!(outcomes.len(), 5);
assert_eq!(outcomes[0], TestOutcome::Rejected);
assert_eq!(outcomes[1], TestOutcome::Rejected);
assert_eq!(outcomes[2], TestOutcome::NotRejected);
assert_eq!(outcomes[3], TestOutcome::NotRejected);
}
#[test]
fn custom_investment() {
let mut inv = AlphaInvestor::with_defaults();
let outcome = inv.test_with_investment(0.1, Some(0.2));
assert_eq!(outcome, TestOutcome::Rejected);
assert!((inv.wealth() - 0.4).abs() < 1e-10);
}
#[test]
fn custom_investment_clamped_to_wealth() {
let cfg = AlphaInvestingConfig {
initial_wealth: 0.1,
..Default::default()
};
let mut inv = AlphaInvestor::new(cfg);
let outcome = inv.test_with_investment(0.01, Some(1.0));
assert_eq!(outcome, TestOutcome::Rejected);
assert!((inv.wealth() - 0.05).abs() < 1e-10);
}
#[test]
fn p_value_clamping() {
let mut inv = AlphaInvestor::with_defaults();
let o1 = inv.test(-0.5);
assert_eq!(o1, TestOutcome::Rejected);
let o2 = inv.test(2.0);
assert_eq!(o2, TestOutcome::NotRejected);
}
#[test]
fn reset() {
let mut inv = AlphaInvestor::with_defaults();
inv.test(0.001);
inv.test(0.9);
assert!(inv.tests_run() > 0);
inv.reset();
assert_eq!(inv.tests_run(), 0);
assert_eq!(inv.discoveries(), 0);
assert_eq!(inv.wealth(), 0.5);
assert!(inv.history().is_empty());
}
#[test]
fn history_tracking() {
let mut inv = AlphaInvestor::with_defaults();
inv.test(0.01);
inv.test(0.9);
assert_eq!(inv.history().len(), 2);
let h = inv.drain_history();
assert_eq!(h.len(), 2);
assert!(inv.history().is_empty());
}
#[test]
fn bonferroni_basic() {
let p_values = [0.01, 0.03, 0.04, 0.05, 0.10];
let results = bonferroni_test(&p_values, 0.05);
assert_eq!(results, vec![true, false, false, false, false]);
}
#[test]
fn bonferroni_empty() {
assert!(bonferroni_test(&[], 0.05).is_empty());
}
#[test]
fn benjamini_hochberg_basic() {
let p_values = [0.001, 0.008, 0.039, 0.041, 0.23, 0.35, 0.78, 0.90];
let rejected = benjamini_hochberg(&p_values, 0.05);
assert_eq!(rejected.len(), 2);
assert!(rejected.contains(&0));
assert!(rejected.contains(&1));
}
#[test]
fn benjamini_hochberg_all_significant() {
let p_values = [0.001, 0.002, 0.003];
let rejected = benjamini_hochberg(&p_values, 0.05);
assert_eq!(rejected.len(), 3);
}
#[test]
fn benjamini_hochberg_none_significant() {
let p_values = [0.5, 0.6, 0.7];
let rejected = benjamini_hochberg(&p_values, 0.05);
assert!(rejected.is_empty());
}
#[test]
fn benjamini_hochberg_empty() {
assert!(benjamini_hochberg(&[], 0.05).is_empty());
}
#[test]
fn fdr_control_simulation() {
let mut inv = AlphaInvestor::new(AlphaInvestingConfig {
initial_wealth: 0.5,
reward_fraction: 0.5,
investment_fraction: 0.1,
min_wealth: 1e-12,
});
let mut p_values = vec![0.001; 10];
for i in 0..90 {
p_values.push(0.1 + (i as f64 * 0.01));
}
let outcomes = inv.test_batch(&p_values);
let rejections: usize = outcomes
.iter()
.filter(|&&o| o == TestOutcome::Rejected)
.count();
assert!(rejections >= 1, "Should reject at least 1 real signal");
assert!(
rejections <= 20,
"Should not reject too many (got {})",
rejections
);
}
#[test]
fn wealth_monotone_on_null() {
let mut inv = AlphaInvestor::with_defaults();
let mut prev_wealth = inv.wealth();
for _ in 0..20 {
inv.test(0.9);
assert!(
inv.wealth() <= prev_wealth,
"Wealth should not increase on non-rejection"
);
prev_wealth = inv.wealth();
}
}
}