Files
fidc-backtest-engine/crates/fidc-core/src/universe.rs
2026-04-18 18:02:50 +08:00

189 lines
5.5 KiB
Rust

use chrono::NaiveDate;
use serde::Serialize;
use crate::data::{BenchmarkSnapshot, DataSet, EligibleUniverseSnapshot};
#[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 struct SelectionContext<'a> {
pub decision_date: NaiveDate,
pub benchmark: &'a BenchmarkSnapshot,
pub reference_level: f64,
pub data: &'a DataSet,
}
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,
}
impl DynamicMarketCapBandSelector {
pub fn new(
base_index_level: f64,
base_cap_floor: f64,
cap_span: f64,
xs: f64,
top_n: usize,
) -> Self {
Self {
base_index_level,
base_cap_floor,
cap_span,
xs,
top_n,
}
}
pub fn demo(top_n: usize) -> Self {
Self::new(2000.0, 7.0, 10.0, 4.0 / 500.0, top_n)
}
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();
(low, low + self.cap_span)
}
}
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(),
};
diagnostics.factor_total = ctx.data.factor_snapshots_on(ctx.decision_date).len();
diagnostics.market_cap_missing_count = diagnostics
.factor_total
.saturating_sub(ctx.data.eligible_universe_on(ctx.decision_date).len());
let eligible = ctx.data.eligible_universe_on(ctx.decision_date);
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,
}
}