Files
fidc-backtest-engine/crates/fidc-core/src/universe.rs
T
2026-07-04 05:51:35 +08:00

483 lines
15 KiB
Rust

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<String>,
pub selected_symbols: Vec<String>,
pub rejection_examples: Vec<String>,
pub risk_decisions: Vec<FidcRiskDecisionAudit>,
}
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<String>>,
pub risk_config: Option<&'a FidcRiskControlConfig>,
pub defer_selection_risk: bool,
}
impl SelectionContext<'_> {
fn eligible_universe(&self) -> Vec<EligibleUniverseSnapshot> {
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<FidcRiskDecisionAudit> {
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<UniverseCandidate>;
fn select_with_diagnostics(
&self,
ctx: &SelectionContext<'_>,
) -> (Vec<UniverseCandidate>, 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<UniverseCandidate> {
self.select_with_diagnostics(ctx).0
}
fn select_with_diagnostics(
&self,
ctx: &SelectionContext<'_>,
) -> (Vec<UniverseCandidate>, 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::<BTreeSet<_>>();
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::<BTreeSet<_>>();
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());
}
}