修复延迟执行选股风控语义

This commit is contained in:
boris
2026-07-04 03:02:02 +08:00
parent 8e4b3d15a4
commit 5f2697540a
3 changed files with 128 additions and 30 deletions
+39
View File
@@ -2506,6 +2506,10 @@ impl DataSet {
.unwrap_or(&[])
}
pub fn fundamental_universe_on(&self, date: NaiveDate) -> Vec<EligibleUniverseSnapshot> {
build_fundamental_universe_for_date(date, &self.factor_by_date, &self.market_by_date)
}
pub fn eligible_universe_on_with_risk_config(
&self,
date: NaiveDate,
@@ -3051,6 +3055,41 @@ fn build_eligible_universe(
per_date
}
fn build_fundamental_universe_for_date(
date: NaiveDate,
factor_by_date: &BTreeMap<NaiveDate, Vec<Arc<DailyFactorSnapshot>>>,
market_by_date: &BTreeMap<NaiveDate, Vec<Arc<DailyMarketSnapshot>>>,
) -> Vec<EligibleUniverseSnapshot> {
let mut rows = Vec::new();
let Some(factors) = factor_by_date.get(&date) else {
return rows;
};
for factor in factors {
let Some(market) = market_by_date
.get(&date)
.and_then(|rows| find_arc_by_symbol(rows, &factor.symbol, |row| row.symbol.as_str()))
else {
continue;
};
let market_cap_bn = decision_market_cap_bn(factor, market);
if market_cap_bn <= 0.0 || !market_cap_bn.is_finite() {
continue;
}
rows.push(EligibleUniverseSnapshot {
symbol: factor.symbol.clone(),
market_cap_bn,
free_float_cap_bn: decision_free_float_cap_bn(factor, market),
});
}
rows.sort_by(|left, right| {
left.market_cap_bn
.partial_cmp(&right.market_cap_bn)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| left.symbol.cmp(&right.symbol))
});
rows
}
fn build_eligible_universe_for_date(
date: NaiveDate,
factor_by_date: &BTreeMap<NaiveDate, Vec<Arc<DailyFactorSnapshot>>>,
+75 -25
View File
@@ -156,6 +156,10 @@ pub struct StrategyContext<'a> {
}
impl StrategyContext<'_> {
pub fn is_lagged_execution(&self) -> bool {
self.execution_date != self.decision_date
}
pub fn current_datetime(&self) -> Option<NaiveDateTime> {
self.active_datetime
}
@@ -525,6 +529,20 @@ impl StrategyContext<'_> {
}
}
pub fn fundamental_universe_on(
&self,
date: NaiveDate,
) -> Vec<crate::data::EligibleUniverseSnapshot> {
let eligible = self.data.fundamental_universe_on(date);
match self.dynamic_universe {
Some(symbols) if !symbols.is_empty() => eligible
.into_iter()
.filter(|row| symbols.contains(&row.symbol))
.collect(),
_ => eligible,
}
}
pub fn current_snapshot(&self, symbol: &str) -> Option<&DailyMarketSnapshot> {
self.data.market(self.execution_date, symbol)
}
@@ -1373,14 +1391,14 @@ impl Strategy for CnSmallCapRotationStrategy {
date: ctx.decision_date,
})?;
if self.config.in_skip_window(ctx.execution_date) {
if self.config.in_skip_window(ctx.decision_date) {
self.last_gross_exposure = Some(0.0);
return Ok(StrategyDecision {
rebalance: true,
target_weights: BTreeMap::new(),
exit_symbols: ctx.portfolio.positions().keys().cloned().collect(),
order_intents: Vec::new(),
notes: vec![format!("skip-window active on {}", ctx.execution_date)],
notes: vec![format!("skip-window active on {}", ctx.decision_date)],
diagnostics: vec![
"seasonal stop window approximated at daily granularity".to_string(),
"run_daily(10:17/10:18) mapped to T-1 decision and T open execution"
@@ -1454,6 +1472,7 @@ impl Strategy for CnSmallCapRotationStrategy {
data: ctx.data,
dynamic_universe: ctx.dynamic_universe,
risk_config: Some(&self.config.risk_config),
defer_selection_risk: ctx.is_lagged_execution(),
});
let before_ma_count = selected_before_ma.len();
let mut ma_rejects = Vec::new();
@@ -2421,7 +2440,11 @@ impl OmniMicroCapStrategy {
&self,
ctx: &StrategyContext<'_>,
date: NaiveDate,
defer_selection_risk: bool,
) -> Vec<FidcRiskDecisionAudit> {
if defer_selection_risk {
return Vec::new();
}
let mut decisions = Vec::new();
for factor in ctx.data.factor_snapshots_on(date) {
if ctx.has_dynamic_universe() && !ctx.dynamic_universe_contains(&factor.symbol) {
@@ -2481,8 +2504,13 @@ impl OmniMicroCapStrategy {
date: NaiveDate,
band_low: f64,
band_high: f64,
defer_selection_risk: bool,
) -> Result<(Vec<String>, Vec<String>), BacktestError> {
let universe = ctx.eligible_universe_on_with_risk_config(date, &self.config.risk_config);
let universe = if defer_selection_risk {
ctx.fundamental_universe_on(date)
} else {
ctx.eligible_universe_on_with_risk_config(date, &self.config.risk_config)
};
let mut diagnostics = Vec::new();
let mut selected = Vec::new();
let start = lower_bound_eligible(&universe, band_low);
@@ -2491,7 +2519,13 @@ impl OmniMicroCapStrategy {
if candidate.market_cap_bn > band_high {
break;
}
if let Some(reason) = self.buy_rejection_reason(ctx, date, &candidate.symbol)? {
let rejection = if defer_selection_risk {
(!self.stock_passes_ma_filter(ctx, date, &candidate.symbol))
.then_some("ma_filter".to_string())
} else {
self.buy_rejection_reason(ctx, date, &candidate.symbol)?
};
if let Some(reason) = rejection {
if diagnostics.len() < 12 {
diagnostics.push(format!("{} rejected by {}", candidate.symbol, reason));
}
@@ -2514,8 +2548,10 @@ impl Strategy for OmniMicroCapStrategy {
}
fn on_day(&mut self, ctx: &StrategyContext<'_>) -> Result<StrategyDecision, BacktestError> {
let date = ctx.execution_date;
if self.config.in_skip_window(date) {
let signal_date = ctx.decision_date;
let execution_date = ctx.execution_date;
let defer_selection_risk = ctx.is_lagged_execution();
if self.config.in_skip_window(signal_date) {
return Ok(StrategyDecision {
rebalance: false,
target_weights: BTreeMap::new(),
@@ -2531,14 +2567,14 @@ impl Strategy for OmniMicroCapStrategy {
reason: "seasonal_stop_window".to_string(),
})
.collect(),
notes: vec![format!("seasonal stop window on {}", date)],
notes: vec![format!("seasonal stop window on {}", signal_date)],
diagnostics: vec!["platform-native skip window forced all cash".to_string()],
risk_decisions: Vec::new(),
});
}
let (index_level, prev_index_level, ma_short, ma_long, trading_ratio) =
match self.trading_ratio(ctx, date) {
match self.trading_ratio(ctx, signal_date) {
Ok(value) => value,
Err(BacktestError::Execution(message))
if message.contains("insufficient benchmark") =>
@@ -2559,9 +2595,11 @@ impl Strategy for OmniMicroCapStrategy {
};
// 使用前一交易日的指数价格计算市值区间(模拟实盘场景)
let (band_low, band_high) = self.market_cap_band(prev_index_level);
let (stock_list, selection_notes) = self.select_symbols(ctx, date, band_low, band_high)?;
let risk_decisions = self.selection_risk_decisions(ctx, date);
let (stock_list, selection_notes) =
self.select_symbols(ctx, signal_date, band_low, band_high, defer_selection_risk)?;
let risk_decisions = self.selection_risk_decisions(ctx, signal_date, defer_selection_risk);
let periodic_rebalance = ctx.decision_index % self.config.refresh_rate == 0;
let projection_date = signal_date;
let mut projected = ctx.portfolio.clone();
let mut projected_execution_state = ProjectedExecutionState::default();
let mut order_intents = Vec::new();
@@ -2571,18 +2609,21 @@ impl Strategy for OmniMicroCapStrategy {
if position.quantity == 0 || position.average_cost <= 0.0 {
continue;
}
let Some(current_price) = ctx.data.price(date, &position.symbol, PriceField::Last)
let Some(current_price) =
ctx.data
.price(signal_date, &position.symbol, PriceField::Last)
else {
continue;
};
let Some(market) = ctx.data.market(date, &position.symbol) else {
let Some(market) = ctx.data.market(signal_date, &position.symbol) else {
continue;
};
let stop_hit = current_price
<= position.average_cost * self.config.stop_loss_ratio
+ self.stop_loss_tolerance(market);
let profit_hit = current_price / position.average_cost > self.config.take_profit_ratio;
let can_sell = self.can_sell_position(ctx, date, &position.symbol);
let can_sell = defer_selection_risk
|| self.can_sell_position(ctx, execution_date, &position.symbol);
let at_upper_limit = market.is_at_upper_limit_price(current_price);
if stop_hit || (profit_hit && !at_upper_limit) {
let sell_reason = if stop_hit {
@@ -2600,7 +2641,7 @@ impl Strategy for OmniMicroCapStrategy {
self.project_target_zero(
ctx,
&mut projected,
date,
projection_date,
&position.symbol,
sell_reason,
&mut projected_execution_state,
@@ -2618,7 +2659,11 @@ impl Strategy for OmniMicroCapStrategy {
{
continue;
}
if self.buy_rejection_reason(ctx, date, symbol)?.is_some() {
if !defer_selection_risk
&& self
.buy_rejection_reason(ctx, execution_date, symbol)?
.is_some()
{
continue;
}
order_intents.push(OrderIntent::Value {
@@ -2629,7 +2674,7 @@ impl Strategy for OmniMicroCapStrategy {
self.project_order_value(
ctx,
&mut projected,
date,
projection_date,
symbol,
replacement_cash,
&format!("replacement_after_{}", sell_reason),
@@ -2652,7 +2697,7 @@ impl Strategy for OmniMicroCapStrategy {
if stock_list.iter().any(|candidate| candidate == symbol) {
continue;
}
if !self.can_sell_position(ctx, date, symbol) {
if !defer_selection_risk && !self.can_sell_position(ctx, execution_date, symbol) {
continue;
}
order_intents.push(OrderIntent::TargetValue {
@@ -2663,7 +2708,7 @@ impl Strategy for OmniMicroCapStrategy {
self.project_target_zero(
ctx,
&mut projected,
date,
projection_date,
symbol,
"periodic_rebalance_sell",
&mut projected_execution_state,
@@ -2680,7 +2725,11 @@ impl Strategy for OmniMicroCapStrategy {
{
continue;
}
if self.buy_rejection_reason(ctx, date, symbol)?.is_some() {
if !defer_selection_risk
&& self
.buy_rejection_reason(ctx, execution_date, symbol)?
.is_some()
{
continue;
}
order_intents.push(OrderIntent::Value {
@@ -2691,7 +2740,7 @@ impl Strategy for OmniMicroCapStrategy {
self.project_order_value(
ctx,
&mut projected,
date,
projection_date,
symbol,
fixed_buy_cash,
"periodic_rebalance_buy",
@@ -2713,7 +2762,7 @@ impl Strategy for OmniMicroCapStrategy {
projected.positions().len(),
order_intents.len()
),
"platform schedule 10:17/10:18 approximated as same-day decision with snapshot last_price signals and bid1/ask1 side-aware execution".to_string(),
"platform schedule signal uses decision_date data; broker applies execution_date price and risk checks".to_string(),
];
if std::env::var("FIDC_BT_DEBUG_POSITION_ORDER")
.map(|value| value == "1")
@@ -2907,10 +2956,11 @@ mod tests {
default_cfg.stock_long_ma_days = 3;
let default_strategy = OmniMicroCapStrategy::new(default_cfg.clone());
let (default_selected, _) = default_strategy
.select_symbols(&ctx, dates[2], 0.0, 100.0)
.select_symbols(&ctx, dates[2], 0.0, 100.0, false)
.expect("default selection");
assert!(default_selected.is_empty());
let default_risk_decisions = default_strategy.selection_risk_decisions(&ctx, dates[2]);
let default_risk_decisions =
default_strategy.selection_risk_decisions(&ctx, dates[2], false);
assert_eq!(default_risk_decisions.len(), 1);
assert_eq!(default_risk_decisions[0].symbol, symbol);
assert_eq!(default_risk_decisions[0].rule_code, "kcb");
@@ -2925,11 +2975,11 @@ mod tests {
cfg.risk_config.static_rules.reject_kcb_buy = false;
let configured_strategy = OmniMicroCapStrategy::new(cfg);
let (selected, _) = configured_strategy
.select_symbols(&ctx, dates[2], 0.0, 100.0)
.select_symbols(&ctx, dates[2], 0.0, 100.0, false)
.expect("configured selection");
assert!(
configured_strategy
.selection_risk_decisions(&ctx, dates[2])
.selection_risk_decisions(&ctx, dates[2], false)
.is_empty()
);
+14 -5
View File
@@ -50,15 +50,20 @@ pub struct SelectionContext<'a> {
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 {
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(),
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
@@ -70,6 +75,9 @@ impl SelectionContext<'_> {
}
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,
@@ -403,6 +411,7 @@ mod tests {
data: &data,
dynamic_universe: None,
risk_config: Some(&FidcRiskControlConfig::default()),
defer_selection_risk: false,
});
let rules = diagnostics