189 lines
5.5 KiB
Rust
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,
|
|
}
|
|
}
|