From cc17039122211949485a30a3e4fdfcb037d6ac3a Mon Sep 17 00:00:00 2001 From: Sean Koval Date: Fri, 13 Feb 2026 17:32:30 -0500 Subject: [PATCH] feat(openquant): add AFML ch15 strategy risk diagnostics --- crates/openquant/src/lib.rs | 1 + crates/openquant/src/strategy_risk.rs | 326 ++++++++++++++++++++++++ crates/openquant/tests/strategy_risk.rs | 110 ++++++++ docs-site/src/data/afmlDocsState.ts | 14 + docs-site/src/data/moduleDocs.ts | 45 ++++ docs-site/src/pages/api-reference.astro | 1 + 6 files changed, 497 insertions(+) create mode 100644 crates/openquant/src/strategy_risk.rs create mode 100644 crates/openquant/tests/strategy_risk.rs diff --git a/crates/openquant/src/lib.rs b/crates/openquant/src/lib.rs index 41693b6..a0cae40 100644 --- a/crates/openquant/src/lib.rs +++ b/crates/openquant/src/lib.rs @@ -24,6 +24,7 @@ pub mod risk_metrics; pub mod sample_weights; pub mod sampling; pub mod sb_bagging; +pub mod strategy_risk; pub mod structural_breaks; pub mod synthetic_backtesting; pub mod util; diff --git a/crates/openquant/src/strategy_risk.rs b/crates/openquant/src/strategy_risk.rs new file mode 100644 index 0000000..70e0eb5 --- /dev/null +++ b/crates/openquant/src/strategy_risk.rs @@ -0,0 +1,326 @@ +//! Strategy-risk diagnostics aligned to AFML Chapter 15. +//! +//! This module models trade outcomes as a binary process to quantify: +//! - Sharpe-vs-precision/frequency relations under symmetric and asymmetric payouts, +//! - implied precision/frequency needed to hit a Sharpe target, and +//! - probability that a strategy fails to achieve a Sharpe target. +//! +//! The focus is strategy viability risk, not holdings/portfolio variance risk. + +use rand::rngs::StdRng; +use rand::{Rng, SeedableRng}; +use statrs::distribution::{ContinuousCDF, Normal}; + +#[derive(Debug, Clone, PartialEq)] +pub enum StrategyRiskError { + EmptyInput(&'static str), + InvalidInput(&'static str), + NoValidRoot(&'static str), +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct AsymmetricPayout { + pub pi_plus: f64, + pub pi_minus: f64, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct StrategyRiskConfig { + pub years_elapsed: f64, + pub target_sharpe: f64, + pub investor_horizon_years: f64, + pub bootstrap_iterations: usize, + pub seed: u64, + pub kde_bandwidth: Option, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct StrategyRiskReport { + pub payout: AsymmetricPayout, + pub annual_bet_frequency: f64, + pub implied_precision_threshold: f64, + pub bootstrap_precision_mean: f64, + pub bootstrap_precision_std: f64, + pub empirical_failure_probability: f64, + pub kde_failure_probability: f64, + pub bootstrap_precision_samples: Vec, +} + +pub fn sharpe_symmetric( + precision: f64, + annual_bet_frequency: f64, +) -> Result { + validate_precision(precision)?; + validate_positive("annual_bet_frequency", annual_bet_frequency)?; + + let denom = 2.0 * (precision * (1.0 - precision)).sqrt(); + if denom <= 0.0 { + return Err(StrategyRiskError::InvalidInput("precision must be strictly between 0 and 1")); + } + Ok((2.0 * precision - 1.0) / denom * annual_bet_frequency.sqrt()) +} + +pub fn implied_precision_symmetric( + target_sharpe: f64, + annual_bet_frequency: f64, +) -> Result { + validate_positive("annual_bet_frequency", annual_bet_frequency)?; + if target_sharpe <= 0.0 || !target_sharpe.is_finite() { + return Err(StrategyRiskError::InvalidInput("target_sharpe must be finite and > 0")); + } + + let root = target_sharpe / (target_sharpe * target_sharpe + annual_bet_frequency).sqrt(); + let p = 0.5 * (1.0 + root); + if !(0.0..=1.0).contains(&p) { + return Err(StrategyRiskError::NoValidRoot( + "implied symmetric precision is outside [0, 1]", + )); + } + Ok(p) +} + +pub fn implied_frequency_symmetric( + precision: f64, + target_sharpe: f64, +) -> Result { + validate_precision(precision)?; + if target_sharpe <= 0.0 || !target_sharpe.is_finite() { + return Err(StrategyRiskError::InvalidInput("target_sharpe must be finite and > 0")); + } + let edge = 2.0 * precision - 1.0; + if edge.abs() < 1e-12 { + return Err(StrategyRiskError::InvalidInput( + "precision too close to 0.5 to imply finite frequency for positive target Sharpe", + )); + } + let n = target_sharpe * target_sharpe * 4.0 * precision * (1.0 - precision) / (edge * edge); + validate_positive("implied_frequency", n)?; + Ok(n) +} + +pub fn sharpe_asymmetric( + precision: f64, + annual_bet_frequency: f64, + payout: AsymmetricPayout, +) -> Result { + validate_precision(precision)?; + validate_positive("annual_bet_frequency", annual_bet_frequency)?; + validate_payout(payout)?; + + let d = payout.pi_plus - payout.pi_minus; + let mu = d * precision + payout.pi_minus; + let sigma = d.abs() * (precision * (1.0 - precision)).sqrt(); + if sigma <= 0.0 || !sigma.is_finite() { + return Err(StrategyRiskError::InvalidInput("asymmetric payout variance must be positive")); + } + Ok(mu / sigma * annual_bet_frequency.sqrt()) +} + +pub fn implied_precision_asymmetric( + target_sharpe: f64, + annual_bet_frequency: f64, + payout: AsymmetricPayout, +) -> Result { + validate_positive("annual_bet_frequency", annual_bet_frequency)?; + if target_sharpe <= 0.0 || !target_sharpe.is_finite() { + return Err(StrategyRiskError::InvalidInput("target_sharpe must be finite and > 0")); + } + validate_payout(payout)?; + + let d = payout.pi_plus - payout.pi_minus; + let n = annual_bet_frequency; + let theta2 = target_sharpe * target_sharpe; + + let a = (n + theta2) * d * d; + let b = (2.0 * n * payout.pi_minus - theta2 * d) * d; + let c = n * payout.pi_minus * payout.pi_minus; + let disc = b * b - 4.0 * a * c; + if disc < 0.0 || !disc.is_finite() { + return Err(StrategyRiskError::NoValidRoot( + "no real implied precision for these parameters", + )); + } + + let sqrt_disc = disc.sqrt(); + let r1 = (-b + sqrt_disc) / (2.0 * a); + let r2 = (-b - sqrt_disc) / (2.0 * a); + + let mut candidates = Vec::new(); + for p in [r1, r2] { + if (0.0..=1.0).contains(&p) { + let model_sr = sharpe_asymmetric(p, annual_bet_frequency, payout)?; + if (model_sr - target_sharpe).abs() < 1e-6 || model_sr >= target_sharpe - 1e-6 { + candidates.push(p); + } + } + } + candidates.sort_by(|a, b| a.total_cmp(b)); + candidates.dedup_by(|a, b| (*a - *b).abs() < 1e-9); + + candidates + .first() + .copied() + .ok_or(StrategyRiskError::NoValidRoot("no admissible implied precision root in [0, 1]")) +} + +pub fn implied_frequency_asymmetric( + precision: f64, + target_sharpe: f64, + payout: AsymmetricPayout, +) -> Result { + validate_precision(precision)?; + if target_sharpe <= 0.0 || !target_sharpe.is_finite() { + return Err(StrategyRiskError::InvalidInput("target_sharpe must be finite and > 0")); + } + validate_payout(payout)?; + + let d = payout.pi_plus - payout.pi_minus; + let mu = d * precision + payout.pi_minus; + if mu.abs() < 1e-12 { + return Err(StrategyRiskError::NoValidRoot( + "mean payoff is near zero; implied frequency is not finite", + )); + } + let n = target_sharpe * target_sharpe * d * d * precision * (1.0 - precision) / (mu * mu); + validate_positive("implied_frequency", n)?; + Ok(n) +} + +pub fn estimate_strategy_failure_probability( + bet_outcomes: &[f64], + cfg: StrategyRiskConfig, +) -> Result { + if bet_outcomes.is_empty() { + return Err(StrategyRiskError::EmptyInput("bet_outcomes")); + } + if bet_outcomes.iter().any(|v| !v.is_finite()) { + return Err(StrategyRiskError::InvalidInput( + "bet_outcomes must contain only finite values", + )); + } + validate_positive("years_elapsed", cfg.years_elapsed)?; + validate_positive("target_sharpe", cfg.target_sharpe)?; + validate_positive("investor_horizon_years", cfg.investor_horizon_years)?; + if cfg.bootstrap_iterations == 0 { + return Err(StrategyRiskError::InvalidInput("bootstrap_iterations must be > 0")); + } + if let Some(h) = cfg.kde_bandwidth { + validate_positive("kde_bandwidth", h)?; + } + + let neg: Vec = bet_outcomes.iter().copied().filter(|v| *v <= 0.0).collect(); + let pos: Vec = bet_outcomes.iter().copied().filter(|v| *v > 0.0).collect(); + if neg.is_empty() || pos.is_empty() { + return Err(StrategyRiskError::InvalidInput( + "bet_outcomes must include at least one winning and one losing bet", + )); + } + + let payout = AsymmetricPayout { pi_plus: mean(&pos), pi_minus: mean(&neg) }; + validate_payout(payout)?; + + let n = bet_outcomes.len() as f64 / cfg.years_elapsed; + validate_positive("annual_bet_frequency", n)?; + let p_star = implied_precision_asymmetric(cfg.target_sharpe, n, payout)?; + + let bootstrap_draw_size = ((n * cfg.investor_horizon_years).floor() as usize).max(1); + let mut rng = StdRng::seed_from_u64(cfg.seed); + let mut p_samples = Vec::with_capacity(cfg.bootstrap_iterations); + + for _ in 0..cfg.bootstrap_iterations { + let mut wins = 0usize; + for _ in 0..bootstrap_draw_size { + let idx = rng.gen_range(0..bet_outcomes.len()); + if bet_outcomes[idx] > 0.0 { + wins += 1; + } + } + p_samples.push(wins as f64 / bootstrap_draw_size as f64); + } + + let sample_mean = mean(&p_samples); + let sample_std = std_dev(&p_samples); + let empirical_failure_probability = + p_samples.iter().filter(|p| **p <= p_star).count() as f64 / p_samples.len() as f64; + + let bandwidth = cfg.kde_bandwidth.unwrap_or_else(|| silverman_bandwidth(&p_samples)); + let kde_failure_probability = kde_cdf(p_star, &p_samples, bandwidth)?; + + Ok(StrategyRiskReport { + payout, + annual_bet_frequency: n, + implied_precision_threshold: p_star, + bootstrap_precision_mean: sample_mean, + bootstrap_precision_std: sample_std, + empirical_failure_probability, + kde_failure_probability, + bootstrap_precision_samples: p_samples, + }) +} + +fn validate_payout(payout: AsymmetricPayout) -> Result<(), StrategyRiskError> { + if !payout.pi_plus.is_finite() || !payout.pi_minus.is_finite() { + return Err(StrategyRiskError::InvalidInput("payout values must be finite")); + } + if payout.pi_plus <= payout.pi_minus { + return Err(StrategyRiskError::InvalidInput("pi_plus must be greater than pi_minus")); + } + Ok(()) +} + +fn validate_positive(name: &'static str, value: f64) -> Result<(), StrategyRiskError> { + if !value.is_finite() || value <= 0.0 { + return Err(StrategyRiskError::InvalidInput(name)); + } + Ok(()) +} + +fn validate_precision(precision: f64) -> Result<(), StrategyRiskError> { + if !precision.is_finite() || !(0.0..=1.0).contains(&precision) { + return Err(StrategyRiskError::InvalidInput("precision must be finite and in [0, 1]")); + } + Ok(()) +} + +fn mean(values: &[f64]) -> f64 { + values.iter().sum::() / values.len() as f64 +} + +fn std_dev(values: &[f64]) -> f64 { + if values.len() < 2 { + return 0.0; + } + let mu = mean(values); + let var = values + .iter() + .map(|v| { + let d = *v - mu; + d * d + }) + .sum::() + / (values.len() as f64 - 1.0); + var.sqrt() +} + +fn silverman_bandwidth(samples: &[f64]) -> f64 { + let sigma = std_dev(samples); + let n = samples.len().max(2) as f64; + let raw = 1.06 * sigma * n.powf(-0.2); + if raw.is_finite() && raw > 1e-6 { + raw + } else { + 1e-3 + } +} + +fn kde_cdf(x: f64, samples: &[f64], bandwidth: f64) -> Result { + if samples.is_empty() { + return Err(StrategyRiskError::EmptyInput("samples")); + } + validate_positive("bandwidth", bandwidth)?; + let normal = Normal::new(0.0, 1.0) + .map_err(|_| StrategyRiskError::InvalidInput("failed to construct standard normal"))?; + let cdf = samples.iter().map(|s| normal.cdf((x - *s) / bandwidth)).sum::() + / samples.len() as f64; + Ok(cdf.clamp(0.0, 1.0)) +} diff --git a/crates/openquant/tests/strategy_risk.rs b/crates/openquant/tests/strategy_risk.rs new file mode 100644 index 0000000..8241054 --- /dev/null +++ b/crates/openquant/tests/strategy_risk.rs @@ -0,0 +1,110 @@ +use openquant::strategy_risk::{ + estimate_strategy_failure_probability, implied_frequency_asymmetric, + implied_frequency_symmetric, implied_precision_asymmetric, implied_precision_symmetric, + sharpe_asymmetric, sharpe_symmetric, AsymmetricPayout, StrategyRiskConfig, +}; + +#[test] +fn test_symmetric_inverse_consistency() { + let target = 2.0; + let n = 260.0; + let p = implied_precision_symmetric(target, n).unwrap(); + let sr = sharpe_symmetric(p, n).unwrap(); + let implied_n = implied_frequency_symmetric(p, target).unwrap(); + + assert!((sr - target).abs() < 1e-8); + assert!((implied_n - n).abs() < 1e-8); + assert!(p > 0.5); +} + +#[test] +fn test_asymmetric_inverse_consistency() { + let payout = AsymmetricPayout { pi_plus: 0.005, pi_minus: -0.01 }; + let target = 1.5; + let n = 260.0; + + let p = implied_precision_asymmetric(target, n, payout).unwrap(); + let sr = sharpe_asymmetric(p, n, payout).unwrap(); + let implied_n = implied_frequency_asymmetric(p, target, payout).unwrap(); + + assert!((sr - target).abs() < 1e-7); + assert!((implied_n - n).abs() < 1e-6); + assert!((0.5..1.0).contains(&p)); +} + +#[test] +fn test_sensitivity_to_small_parameter_changes() { + let payout = AsymmetricPayout { pi_plus: 0.005, pi_minus: -0.01 }; + let base_sr = sharpe_asymmetric(0.70, 260.0, payout).unwrap(); + let higher_p_sr = sharpe_asymmetric(0.71, 260.0, payout).unwrap(); + let lower_n_sr = sharpe_asymmetric(0.70, 240.0, payout).unwrap(); + let worse_loss_sr = + sharpe_asymmetric(0.70, 260.0, AsymmetricPayout { pi_plus: 0.005, pi_minus: -0.011 }) + .unwrap(); + + assert!(higher_p_sr > base_sr); + assert!(lower_n_sr < base_sr); + assert!(worse_loss_sr < base_sr); +} + +#[test] +fn test_strategy_failure_probability_workflow() { + let mut outcomes = Vec::new(); + for i in 0..1200 { + if i % 10 < 7 { + outcomes.push(0.005); + } else { + outcomes.push(-0.01); + } + } + + let report = estimate_strategy_failure_probability( + &outcomes, + StrategyRiskConfig { + years_elapsed: 5.0, + target_sharpe: 2.0, + investor_horizon_years: 2.0, + bootstrap_iterations: 2_000, + seed: 17, + kde_bandwidth: None, + }, + ) + .unwrap(); + + assert!(report.annual_bet_frequency > 200.0); + assert!((0.0..=1.0).contains(&report.implied_precision_threshold)); + assert!((0.0..=1.0).contains(&report.empirical_failure_probability)); + assert!((0.0..=1.0).contains(&report.kde_failure_probability)); + assert!(report.bootstrap_precision_std > 0.0); + assert_eq!(report.bootstrap_precision_samples.len(), 2_000); +} + +#[test] +fn test_failure_probability_rises_with_higher_target_sharpe() { + let mut outcomes = Vec::new(); + for i in 0..1400 { + if i % 10 < 7 { + outcomes.push(0.006); + } else { + outcomes.push(-0.009); + } + } + + let cfg = StrategyRiskConfig { + years_elapsed: 5.0, + target_sharpe: 1.0, + investor_horizon_years: 2.0, + bootstrap_iterations: 1_200, + seed: 33, + kde_bandwidth: None, + }; + let low_target = estimate_strategy_failure_probability(&outcomes, cfg).unwrap(); + let high_target = estimate_strategy_failure_probability( + &outcomes, + StrategyRiskConfig { target_sharpe: 2.0, ..cfg }, + ) + .unwrap(); + + assert!(high_target.implied_precision_threshold > low_target.implied_precision_threshold); + assert!(high_target.kde_failure_probability >= low_target.kde_failure_probability); +} diff --git a/docs-site/src/data/afmlDocsState.ts b/docs-site/src/data/afmlDocsState.ts index c6b26ab..bac0324 100644 --- a/docs-site/src/data/afmlDocsState.ts +++ b/docs-site/src/data/afmlDocsState.ts @@ -232,6 +232,20 @@ export const afmlDocsState = { } ] }, + { + "chapter": "CHAPTER 15", + "theme": "Strategy risk", + "status": "done", + "chunkCount": 16, + "sections": [ + { + "id": "chapter-15-strategy_risk", + "module": "strategy_risk", + "slug": "strategy-risk", + "status": "done" + } + ] + }, { "chapter": "CHAPTER 16", "theme": "Portfolio construction", diff --git a/docs-site/src/data/moduleDocs.ts b/docs-site/src/data/moduleDocs.ts index e4d87bf..5475039 100644 --- a/docs-site/src/data/moduleDocs.ts +++ b/docs-site/src/data/moduleDocs.ts @@ -646,6 +646,51 @@ export const moduleDocs: ModuleDoc[] = [ ], notes: ["Non-parametric estimates need enough tail observations.", "Use matrix variants for multi-asset return panels."], }, + { + slug: "strategy-risk", + module: "strategy_risk", + subject: "Portfolio Construction and Risk", + summary: "AFML Chapter 15 strategy-viability diagnostics based on precision, payout asymmetry, and bet frequency.", + whyItExists: + "Strategy risk is the probability that a process fails to achieve a Sharpe objective over time; it is distinct from holdings/portfolio variance risk and should be monitored separately.", + keyApis: [ + "sharpe_symmetric", + "implied_precision_symmetric", + "implied_frequency_symmetric", + "sharpe_asymmetric", + "implied_precision_asymmetric", + "implied_frequency_asymmetric", + "estimate_strategy_failure_probability", + "StrategyRiskConfig", + "StrategyRiskReport", + ], + formulas: [ + { + label: "Symmetric Sharpe", + latex: "\\theta=\\frac{2p-1}{2\\sqrt{p(1-p)}}\\sqrt{n}", + }, + { + label: "Asymmetric Sharpe", + latex: + "\\theta=\\frac{(\\pi_+-\\pi_-)p+\\pi_-}{(\\pi_+-\\pi_-)\\sqrt{p(1-p)}}\\sqrt{n}", + }, + { + label: "Strategy Failure Probability", + latex: "P_{fail}=\\Pr[p\\le p^*],\\quad p^*=\\text{impliedPrecision}(\\theta^*,\\pi_+,\\pi_-,n)", + }, + ], + examples: [ + { + title: "Estimate strategy-failure probability from realized bets", + language: "rust", + code: `use openquant::strategy_risk::{estimate_strategy_failure_probability, StrategyRiskConfig};\n\nlet outcomes = vec![0.005, -0.01, 0.005, 0.005, -0.01, 0.005, 0.005, -0.01];\nlet report = estimate_strategy_failure_probability(\n &outcomes,\n StrategyRiskConfig {\n years_elapsed: 2.0,\n target_sharpe: 2.0,\n investor_horizon_years: 2.0,\n bootstrap_iterations: 10_000,\n seed: 7,\n kde_bandwidth: None,\n },\n)?;\n\nprintln!(\"p*: {:.4}\", report.implied_precision_threshold);\nprintln!(\"failure (KDE): {:.2}%\", 100.0 * report.kde_failure_probability);`, + }, + ], + notes: [ + "Inputs under manager control ({pi_minus, pi_plus, n}) should be analyzed separately from uncertain market precision p.", + "Use this module for strategy-level viability and probability-of-failure diagnostics; use `risk_metrics` for portfolio-tail and drawdown risk.", + ], + }, { slug: "sample-weights", module: "sample_weights", diff --git a/docs-site/src/pages/api-reference.astro b/docs-site/src/pages/api-reference.astro index 835b2d0..fdc6ae6 100644 --- a/docs-site/src/pages/api-reference.astro +++ b/docs-site/src/pages/api-reference.astro @@ -41,6 +41,7 @@ import Layout from "../layouts/Layout.astro";
  • cla::CLA, hrp::HierarchicalRiskParity, hcaa::HierarchicalClusteringAssetAllocation, onc::get_onc_clusters
  • RiskMetrics::calculate_value_at_risk, RiskMetrics::calculate_expected_shortfall, RiskMetrics::calculate_conditional_drawdown_risk
  • backtest_statistics::sharpe_ratio, backtest_statistics::deflated_sharpe_ratio, backtest_statistics::drawdown_and_time_under_water
  • +
  • strategy_risk::sharpe_symmetric, strategy_risk::implied_precision_asymmetric, strategy_risk::estimate_strategy_failure_probability
  • 5. Market Microstructure, Dependence and Regime Detection