From 289448d196e3f3d53fb9376b1ce7756016bb20da Mon Sep 17 00:00:00 2001 From: boris Date: Sun, 5 Jul 2026 17:13:51 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8DFIDC=E9=BB=98=E8=AE=A4?= =?UTF-8?q?=E9=80=89=E8=82=A1=E6=B1=A0=E4=BE=9D=E8=B5=96=E9=A3=8E=E6=8E=A7?= =?UTF-8?q?=E4=BA=8B=E5=AE=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/fidc-core/src/data.rs | 136 +++++++++++++++++++++++++++++------ 1 file changed, 113 insertions(+), 23 deletions(-) diff --git a/crates/fidc-core/src/data.rs b/crates/fidc-core/src/data.rs index 0c1b2ee..3c38f0c 100644 --- a/crates/fidc-core/src/data.rs +++ b/crates/fidc-core/src/data.rs @@ -2493,14 +2493,7 @@ impl DataSet { pub fn eligible_universe_on(&self, date: NaiveDate) -> &[EligibleUniverseSnapshot] { self.eligible_universe_by_date - .get_or_init(|| { - build_eligible_universe( - &self.factor_by_date, - &self.candidate_by_date, - &self.market_by_date, - &self.instruments, - ) - }) + .get_or_init(|| build_eligible_universe(&self.factor_by_date, &self.market_by_date)) .get(&date) .map(Vec::as_slice) .unwrap_or(&[]) @@ -3033,22 +3026,12 @@ fn build_order_book_depth_index( fn build_eligible_universe( factor_by_date: &BTreeMap>>, - candidate_by_date: &BTreeMap>>, market_by_date: &BTreeMap>>, - instruments: &HashMap, ) -> BTreeMap> { let mut per_date = BTreeMap::>::new(); - let risk_config = FidcRiskControlConfig::default(); - for (date, factors) in factor_by_date { - let rows = build_eligible_universe_for_date_from_factors( - *date, - factors, - candidate_by_date, - market_by_date, - instruments, - &risk_config, - ); + for date in factor_by_date.keys() { + let rows = build_fundamental_universe_for_date(*date, factor_by_date, market_by_date); per_date.insert(*date, rows); } @@ -3126,11 +3109,15 @@ fn build_eligible_universe_for_date_from_factors( if factor.market_cap_bn <= 0.0 || !factor.market_cap_bn.is_finite() { continue; } - let Some(candidate) = candidate_by_date + let synthetic_candidate; + let candidate = if let Some(candidate) = candidate_by_date .get(&date) .and_then(|rows| find_arc_by_symbol(rows, &factor.symbol, |row| row.symbol.as_str())) - else { - continue; + { + candidate + } else { + synthetic_candidate = missing_candidate_risk_state(date, &factor.symbol); + &synthetic_candidate }; let Some(market) = market_by_date .get(&date) @@ -3169,6 +3156,25 @@ fn build_eligible_universe_for_date_from_factors( rows } +fn missing_candidate_risk_state(date: NaiveDate, symbol: &str) -> CandidateEligibility { + CandidateEligibility { + date, + symbol: symbol.to_string(), + is_st: false, + is_star_st: false, + is_new_listing: false, + is_paused: false, + allow_buy: true, + allow_sell: true, + is_kcb: false, + is_one_yuan: false, + risk_level_code: Some( + "missing_risk_state:is_st,is_star_st,is_paused,listed_days,is_kcb,is_one_yuan" + .to_string(), + ), + } +} + #[cfg(test)] fn instrument_passes_baseline_selection(instrument: Option<&Instrument>, date: NaiveDate) -> bool { ChinaAShareRiskControl::instrument_rejection_reason(instrument, date).is_none() @@ -3445,6 +3451,90 @@ mod tests { assert!((rows[1].market_cap_bn - 10.0).abs() < 1e-9); } + #[test] + fn eligible_universe_does_not_require_candidate_risk_state_when_selection_risk_is_disabled() { + let date = NaiveDate::parse_from_str("2025-01-06", "%Y-%m-%d").unwrap(); + let symbol = "000001.SZ"; + let data = DataSet::from_components( + vec![Instrument { + symbol: symbol.to_string(), + name: symbol.to_string(), + board: "SZ".to_string(), + round_lot: 100, + listed_at: Some(NaiveDate::parse_from_str("2020-01-01", "%Y-%m-%d").unwrap()), + delisted_at: None, + status: "active".to_string(), + }], + vec![DailyMarketSnapshot { + date, + symbol: symbol.to_string(), + timestamp: Some("2025-01-06 10:18:00".to_string()), + day_open: 10.0, + open: 10.0, + high: 10.2, + low: 9.8, + close: 10.1, + last_price: 10.1, + bid1: 10.0, + ask1: 10.1, + prev_close: 10.0, + volume: 100_000, + minute_volume: 1_000, + bid1_volume: 1_000, + ask1_volume: 1_000, + trading_phase: Some("continuous".to_string()), + paused: false, + upper_limit: 11.0, + lower_limit: 9.0, + price_tick: 0.01, + }], + vec![DailyFactorSnapshot { + date, + symbol: symbol.to_string(), + market_cap_bn: 10.0, + free_float_cap_bn: 9.0, + pe_ttm: 10.0, + turnover_ratio: Some(1.0), + effective_turnover_ratio: Some(1.0), + extra_factors: BTreeMap::new(), + }], + Vec::new(), + vec![BenchmarkSnapshot { + date, + benchmark: "000852.SH".to_string(), + open: 100.0, + close: 101.0, + prev_close: 99.0, + volume: 1_000_000, + }], + ) + .expect("dataset"); + + assert_eq!( + data.eligible_universe_on(date) + .iter() + .map(|row| row.symbol.as_str()) + .collect::>(), + vec![symbol] + ); + assert_eq!( + data.eligible_universe_on_with_risk_config(date, &FidcRiskControlConfig::default()) + .iter() + .map(|row| row.symbol.as_str()) + .collect::>(), + vec![symbol], + "execution-risk defaults must not make selection depend on candidate risk facts" + ); + + let mut selection_risk_config = FidcRiskControlConfig::default(); + selection_risk_config.static_rules.reject_st_selection = true; + assert!( + data.eligible_universe_on_with_risk_config(date, &selection_risk_config) + .is_empty(), + "explicit selection risk must reject when required candidate facts are missing" + ); + } + #[test] fn eligible_universe_can_use_configured_risk_policy() { let date = NaiveDate::parse_from_str("2025-01-06", "%Y-%m-%d").unwrap();