use std::collections::BTreeSet; use chrono::NaiveDate; use serde::Serialize; use crate::data::{BenchmarkSnapshot, DataSet, EligibleUniverseSnapshot}; use crate::risk_control::{ChinaAShareRiskControl, FidcRiskControlConfig, FidcRiskDecisionAudit}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum BandRegime { Bullish, Neutral, Defensive, } #[derive(Debug, Clone, Serialize)] pub struct UniverseCandidate { pub symbol: String, pub market_cap_bn: f64, pub free_float_cap_bn: f64, pub band_low: f64, pub band_high: f64, } #[derive(Debug, Clone, Serialize)] pub struct SelectionDiagnostics { pub decision_date: NaiveDate, pub reference_level: f64, pub band_low: f64, pub band_high: f64, pub factor_total: usize, pub market_cap_missing_count: usize, pub candidate_missing_count: usize, pub market_missing_count: usize, pub not_eligible_count: usize, pub paused_count: usize, pub out_of_band_count: usize, pub selected_before_limit: usize, pub selected_after_limit: usize, pub missing_market_cap_symbols: Vec, pub selected_symbols: Vec, pub rejection_examples: Vec, pub risk_decisions: Vec, } pub struct SelectionContext<'a> { pub decision_date: NaiveDate, pub benchmark: &'a BenchmarkSnapshot, pub reference_level: f64, pub data: &'a DataSet, pub dynamic_universe: Option<&'a BTreeSet>, pub risk_config: Option<&'a FidcRiskControlConfig>, pub defer_selection_risk: bool, } impl SelectionContext<'_> { fn eligible_universe(&self) -> Vec { let eligible = if self.defer_selection_risk { self.data.fundamental_universe_on(self.decision_date) } else { match self.risk_config { Some(risk_config) => self .data .eligible_universe_on_with_risk_config(self.decision_date, risk_config), None => self.data.eligible_universe_on(self.decision_date).to_vec(), } }; match self.dynamic_universe { Some(symbols) if !symbols.is_empty() => eligible .into_iter() .filter(|row| symbols.contains(&row.symbol)) .collect(), _ => eligible, } } fn selection_risk_decisions(&self) -> Vec { if self.defer_selection_risk { return Vec::new(); } let default_risk_config; let risk_config = match self.risk_config { Some(value) => value, None => { default_risk_config = FidcRiskControlConfig::default(); &default_risk_config } }; let mut decisions = Vec::new(); for factor in self.data.factor_snapshots_on(self.decision_date) { if self .dynamic_universe .is_some_and(|symbols| !symbols.is_empty() && !symbols.contains(&factor.symbol)) { continue; } let Some(candidate) = self.data.candidate(self.decision_date, &factor.symbol) else { continue; }; let Some(market) = self.data.market(self.decision_date, &factor.symbol) else { continue; }; if let Some(decision) = ChinaAShareRiskControl::selection_rejection_decision_with_config( self.decision_date, candidate, market, self.data.instrument(&factor.symbol), risk_config, ) { decisions.push(decision); } } decisions } } pub trait UniverseSelector { fn select(&self, ctx: &SelectionContext<'_>) -> Vec; fn select_with_diagnostics( &self, ctx: &SelectionContext<'_>, ) -> (Vec, SelectionDiagnostics); } #[derive(Debug, Clone)] pub struct DynamicMarketCapBandSelector { pub base_index_level: f64, pub base_cap_floor: f64, pub cap_span: f64, pub xs: f64, pub top_n: usize, pub padding_ratio: f64, pub min_padding: f64, pub max_padding: f64, } impl DynamicMarketCapBandSelector { pub fn new( base_index_level: f64, base_cap_floor: f64, cap_span: f64, xs: f64, top_n: usize, padding_ratio: f64, min_padding: f64, max_padding: f64, ) -> Self { Self { base_index_level, base_cap_floor, cap_span, xs, top_n, padding_ratio, min_padding, max_padding, } } pub fn demo(top_n: usize) -> Self { Self::new(2000.0, 7.0, 10.0, 4.0 / 500.0, top_n, 0.5, 8.0, 20.0) } pub fn regime(&self, benchmark_level: f64) -> BandRegime { if benchmark_level >= self.base_index_level + 400.0 { BandRegime::Bullish } else if benchmark_level >= self.base_index_level { BandRegime::Neutral } else { BandRegime::Defensive } } pub fn band_for_level(&self, benchmark_level: f64) -> (f64, f64) { let start = ((benchmark_level - self.base_index_level) * self.xs) + self.base_cap_floor; let low = start.round(); let high = low + self.cap_span; // Apply padding to expand the range let span = high - low; let padding = (span * self.padding_ratio) .max(self.min_padding) .min(self.max_padding); let lower_bound = (low - padding).max(0.0); let upper_bound = high + padding; (lower_bound, upper_bound) } } impl UniverseSelector for DynamicMarketCapBandSelector { fn select(&self, ctx: &SelectionContext<'_>) -> Vec { self.select_with_diagnostics(ctx).0 } fn select_with_diagnostics( &self, ctx: &SelectionContext<'_>, ) -> (Vec, SelectionDiagnostics) { let _regime = self.regime(ctx.reference_level); let (min_cap, max_cap) = self.band_for_level(ctx.reference_level); let mut diagnostics = SelectionDiagnostics { decision_date: ctx.decision_date, reference_level: ctx.reference_level, band_low: min_cap, band_high: max_cap, factor_total: 0, market_cap_missing_count: 0, candidate_missing_count: 0, market_missing_count: 0, not_eligible_count: 0, paused_count: 0, out_of_band_count: 0, selected_before_limit: 0, selected_after_limit: 0, missing_market_cap_symbols: Vec::new(), selected_symbols: Vec::new(), rejection_examples: Vec::new(), risk_decisions: Vec::new(), }; diagnostics.factor_total = ctx.data.factor_snapshots_on(ctx.decision_date).len(); diagnostics.risk_decisions = ctx.selection_risk_decisions(); diagnostics.not_eligible_count = diagnostics.risk_decisions.len(); diagnostics.paused_count = diagnostics .risk_decisions .iter() .filter(|decision| decision.rule_code == "paused") .count(); diagnostics.rejection_examples = diagnostics .risk_decisions .iter() .take(8) .map(|decision| format!("{} rejected by {}", decision.symbol, decision.rule_code)) .collect(); let eligible = ctx.eligible_universe(); diagnostics.market_cap_missing_count = diagnostics.factor_total.saturating_sub(eligible.len()); let start_idx = lower_bound_by_market_cap(&eligible, min_cap); let mut selected = Vec::new(); for factor in eligible.iter().skip(start_idx) { if factor.market_cap_bn > max_cap { break; } selected.push(to_universe_candidate(factor, min_cap, max_cap)); } diagnostics.out_of_band_count = eligible.len().saturating_sub(selected.len()); diagnostics.selected_before_limit = selected.len(); if selected.len() > self.top_n { selected.truncate(self.top_n); } diagnostics.selected_symbols = selected.iter().map(|item| item.symbol.clone()).collect(); diagnostics.selected_after_limit = diagnostics.selected_symbols.len(); (selected, diagnostics) } } fn lower_bound_by_market_cap(rows: &[EligibleUniverseSnapshot], target: f64) -> usize { let mut left = 0usize; let mut right = rows.len(); while left < right { let mid = left + (right - left) / 2; if rows[mid].market_cap_bn < target { left = mid + 1; } else { right = mid; } } left } fn to_universe_candidate( factor: &EligibleUniverseSnapshot, band_low: f64, band_high: f64, ) -> UniverseCandidate { UniverseCandidate { symbol: factor.symbol.clone(), market_cap_bn: factor.market_cap_bn, free_float_cap_bn: factor.free_float_cap_bn, band_low, band_high, } } #[cfg(test)] mod tests { use super::*; use crate::data::{ BenchmarkSnapshot, CandidateEligibility, DailyFactorSnapshot, DailyMarketSnapshot, DataSet, }; use crate::instrument::Instrument; fn d() -> NaiveDate { NaiveDate::from_ymd_opt(2025, 1, 2).unwrap() } fn instrument(symbol: &str) -> Instrument { Instrument { symbol: symbol.to_string(), name: symbol.to_string(), board: symbol.rsplit('.').next().unwrap_or("").to_string(), round_lot: 100, listed_at: Some(NaiveDate::from_ymd_opt(2020, 1, 1).unwrap()), delisted_at: None, status: "active".to_string(), } } fn market(symbol: &str, price: f64) -> DailyMarketSnapshot { DailyMarketSnapshot { date: d(), symbol: symbol.to_string(), timestamp: Some("2025-01-02 10:00:00".to_string()), day_open: price, open: price, high: price, low: price, close: price, last_price: price, bid1: price, ask1: price, prev_close: price, volume: 1_000_000, minute_volume: 10_000, bid1_volume: 10_000, ask1_volume: 10_000, trading_phase: Some("continuous".to_string()), paused: false, upper_limit: price * 1.1, lower_limit: price * 0.9, price_tick: 0.01, } } fn factor(symbol: &str, market_cap_bn: f64) -> DailyFactorSnapshot { DailyFactorSnapshot { date: d(), symbol: symbol.to_string(), market_cap_bn, free_float_cap_bn: market_cap_bn, pe_ttm: 10.0, turnover_ratio: Some(0.01), effective_turnover_ratio: Some(0.01), extra_factors: Default::default(), } } fn candidate(symbol: &str, is_st: bool, is_kcb: bool) -> CandidateEligibility { CandidateEligibility { date: d(), symbol: symbol.to_string(), is_st, is_star_st: false, is_new_listing: false, is_paused: false, allow_buy: true, allow_sell: true, is_kcb, is_one_yuan: false, risk_level_code: None, } } fn benchmark() -> BenchmarkSnapshot { BenchmarkSnapshot { date: d(), benchmark: "000852.SH".to_string(), open: 2000.0, close: 2000.0, prev_close: 1990.0, volume: 1_000_000, } } #[test] fn selector_records_structured_selection_risk_decisions() { let data = DataSet::from_components( vec![ instrument("000001.SZ"), instrument("688001.SH"), instrument("000002.SZ"), ], vec![ market("000001.SZ", 10.0), market("688001.SH", 10.0), market("000002.SZ", 10.0), ], vec![ factor("000001.SZ", 8.0), factor("688001.SH", 9.0), factor("000002.SZ", 10.0), ], vec![ candidate("000001.SZ", true, false), candidate("688001.SH", false, true), candidate("000002.SZ", false, false), ], vec![benchmark()], ) .unwrap(); let selector = DynamicMarketCapBandSelector::new(2000.0, 7.0, 10.0, 0.0, 10, 0.0, 0.0, 0.0); let (_selected, diagnostics) = selector.select_with_diagnostics(&SelectionContext { decision_date: d(), benchmark: &benchmark(), reference_level: 2000.0, data: &data, dynamic_universe: None, risk_config: Some(&FidcRiskControlConfig::default()), defer_selection_risk: false, }); let rules = diagnostics .risk_decisions .iter() .map(|decision| decision.rule_code.as_str()) .collect::>(); assert!(rules.contains("st"), "{:?}", diagnostics.risk_decisions); assert!(rules.contains("kcb"), "{:?}", diagnostics.risk_decisions); assert_eq!( diagnostics.not_eligible_count, diagnostics.risk_decisions.len() ); assert!( diagnostics.risk_decisions[0] .diagnostic_line() .starts_with("risk_decision=") ); } #[test] fn selector_defers_static_execution_risk_for_lagged_execution() { let data = DataSet::from_components( vec![ instrument("000001.SZ"), instrument("688001.SH"), instrument("000002.SZ"), ], vec![ market("000001.SZ", 10.0), market("688001.SH", 10.0), market("000002.SZ", 10.0), ], vec![ factor("000001.SZ", 8.0), factor("688001.SH", 9.0), factor("000002.SZ", 10.0), ], vec![ candidate("000001.SZ", true, false), candidate("688001.SH", false, true), candidate("000002.SZ", false, false), ], vec![benchmark()], ) .unwrap(); let selector = DynamicMarketCapBandSelector::new(2000.0, 7.0, 10.0, 0.0, 10, 0.0, 0.0, 0.0); let (selected, diagnostics) = selector.select_with_diagnostics(&SelectionContext { decision_date: d(), benchmark: &benchmark(), reference_level: 2000.0, data: &data, dynamic_universe: None, risk_config: Some(&FidcRiskControlConfig::default()), defer_selection_risk: true, }); let selected_symbols = selected .iter() .map(|candidate| candidate.symbol.as_str()) .collect::>(); assert!(selected_symbols.contains("000001.SZ")); assert!(selected_symbols.contains("688001.SH")); assert!(selected_symbols.contains("000002.SZ")); assert_eq!(diagnostics.not_eligible_count, 0); assert!(diagnostics.risk_decisions.is_empty()); } }