545 lines
18 KiB
Rust
545 lines
18 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 = match (self.risk_config, self.defer_selection_risk) {
|
|
(Some(risk_config), false) => self
|
|
.data
|
|
.eligible_universe_on_with_risk_config(self.decision_date, risk_config),
|
|
_ => 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> {
|
|
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 mut risk_config = FidcRiskControlConfig::default();
|
|
risk_config.static_rules.reject_st_selection = true;
|
|
risk_config.static_rules.reject_kcb_selection = true;
|
|
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(&risk_config),
|
|
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_applies_configured_selection_risk_on_decision_date() {
|
|
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 mut risk_config = FidcRiskControlConfig::default();
|
|
risk_config.static_rules.reject_st_selection = true;
|
|
risk_config.static_rules.reject_kcb_selection = true;
|
|
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(&risk_config),
|
|
defer_selection_risk: false,
|
|
});
|
|
|
|
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, 2);
|
|
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);
|
|
}
|
|
|
|
#[test]
|
|
fn selector_can_defer_configured_selection_risk_without_losing_diagnostics() {
|
|
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 mut risk_config = FidcRiskControlConfig::default();
|
|
risk_config.static_rules.reject_st_selection = true;
|
|
risk_config.static_rules.reject_kcb_selection = true;
|
|
|
|
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(&risk_config),
|
|
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"));
|
|
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);
|
|
}
|
|
}
|