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

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(&[]) .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( pub fn eligible_universe_on_with_risk_config(
&self, &self,
date: NaiveDate, date: NaiveDate,
@@ -3051,6 +3055,41 @@ fn build_eligible_universe(
per_date 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( fn build_eligible_universe_for_date(
date: NaiveDate, date: NaiveDate,
factor_by_date: &BTreeMap<NaiveDate, Vec<Arc<DailyFactorSnapshot>>>, factor_by_date: &BTreeMap<NaiveDate, Vec<Arc<DailyFactorSnapshot>>>,
+75 -25
View File
@@ -156,6 +156,10 @@ pub struct StrategyContext<'a> {
} }
impl StrategyContext<'_> { impl StrategyContext<'_> {
pub fn is_lagged_execution(&self) -> bool {
self.execution_date != self.decision_date
}
pub fn current_datetime(&self) -> Option<NaiveDateTime> { pub fn current_datetime(&self) -> Option<NaiveDateTime> {
self.active_datetime 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> { pub fn current_snapshot(&self, symbol: &str) -> Option<&DailyMarketSnapshot> {
self.data.market(self.execution_date, symbol) self.data.market(self.execution_date, symbol)
} }
@@ -1373,14 +1391,14 @@ impl Strategy for CnSmallCapRotationStrategy {
date: ctx.decision_date, 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); self.last_gross_exposure = Some(0.0);
return Ok(StrategyDecision { return Ok(StrategyDecision {
rebalance: true, rebalance: true,
target_weights: BTreeMap::new(), target_weights: BTreeMap::new(),
exit_symbols: ctx.portfolio.positions().keys().cloned().collect(), exit_symbols: ctx.portfolio.positions().keys().cloned().collect(),
order_intents: Vec::new(), 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![ diagnostics: vec![
"seasonal stop window approximated at daily granularity".to_string(), "seasonal stop window approximated at daily granularity".to_string(),
"run_daily(10:17/10:18) mapped to T-1 decision and T open execution" "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, data: ctx.data,
dynamic_universe: ctx.dynamic_universe, dynamic_universe: ctx.dynamic_universe,
risk_config: Some(&self.config.risk_config), risk_config: Some(&self.config.risk_config),
defer_selection_risk: ctx.is_lagged_execution(),
}); });
let before_ma_count = selected_before_ma.len(); let before_ma_count = selected_before_ma.len();
let mut ma_rejects = Vec::new(); let mut ma_rejects = Vec::new();
@@ -2421,7 +2440,11 @@ impl OmniMicroCapStrategy {
&self, &self,
ctx: &StrategyContext<'_>, ctx: &StrategyContext<'_>,
date: NaiveDate, date: NaiveDate,
defer_selection_risk: bool,
) -> Vec<FidcRiskDecisionAudit> { ) -> Vec<FidcRiskDecisionAudit> {
if defer_selection_risk {
return Vec::new();
}
let mut decisions = Vec::new(); let mut decisions = Vec::new();
for factor in ctx.data.factor_snapshots_on(date) { for factor in ctx.data.factor_snapshots_on(date) {
if ctx.has_dynamic_universe() && !ctx.dynamic_universe_contains(&factor.symbol) { if ctx.has_dynamic_universe() && !ctx.dynamic_universe_contains(&factor.symbol) {
@@ -2481,8 +2504,13 @@ impl OmniMicroCapStrategy {
date: NaiveDate, date: NaiveDate,
band_low: f64, band_low: f64,
band_high: f64, band_high: f64,
defer_selection_risk: bool,
) -> Result<(Vec<String>, Vec<String>), BacktestError> { ) -> 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 diagnostics = Vec::new();
let mut selected = Vec::new(); let mut selected = Vec::new();
let start = lower_bound_eligible(&universe, band_low); let start = lower_bound_eligible(&universe, band_low);
@@ -2491,7 +2519,13 @@ impl OmniMicroCapStrategy {
if candidate.market_cap_bn > band_high { if candidate.market_cap_bn > band_high {
break; 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 { if diagnostics.len() < 12 {
diagnostics.push(format!("{} rejected by {}", candidate.symbol, reason)); 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> { fn on_day(&mut self, ctx: &StrategyContext<'_>) -> Result<StrategyDecision, BacktestError> {
let date = ctx.execution_date; let signal_date = ctx.decision_date;
if self.config.in_skip_window(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 { return Ok(StrategyDecision {
rebalance: false, rebalance: false,
target_weights: BTreeMap::new(), target_weights: BTreeMap::new(),
@@ -2531,14 +2567,14 @@ impl Strategy for OmniMicroCapStrategy {
reason: "seasonal_stop_window".to_string(), reason: "seasonal_stop_window".to_string(),
}) })
.collect(), .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()], diagnostics: vec!["platform-native skip window forced all cash".to_string()],
risk_decisions: Vec::new(), risk_decisions: Vec::new(),
}); });
} }
let (index_level, prev_index_level, ma_short, ma_long, trading_ratio) = 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, Ok(value) => value,
Err(BacktestError::Execution(message)) Err(BacktestError::Execution(message))
if message.contains("insufficient benchmark") => 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 (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 (stock_list, selection_notes) =
let risk_decisions = self.selection_risk_decisions(ctx, date); 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 periodic_rebalance = ctx.decision_index % self.config.refresh_rate == 0;
let projection_date = signal_date;
let mut projected = ctx.portfolio.clone(); let mut projected = ctx.portfolio.clone();
let mut projected_execution_state = ProjectedExecutionState::default(); let mut projected_execution_state = ProjectedExecutionState::default();
let mut order_intents = Vec::new(); let mut order_intents = Vec::new();
@@ -2571,18 +2609,21 @@ impl Strategy for OmniMicroCapStrategy {
if position.quantity == 0 || position.average_cost <= 0.0 { if position.quantity == 0 || position.average_cost <= 0.0 {
continue; 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 { else {
continue; continue;
}; };
let Some(market) = ctx.data.market(date, &position.symbol) else { let Some(market) = ctx.data.market(signal_date, &position.symbol) else {
continue; continue;
}; };
let stop_hit = current_price let stop_hit = current_price
<= position.average_cost * self.config.stop_loss_ratio <= position.average_cost * self.config.stop_loss_ratio
+ self.stop_loss_tolerance(market); + self.stop_loss_tolerance(market);
let profit_hit = current_price / position.average_cost > self.config.take_profit_ratio; 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); let at_upper_limit = market.is_at_upper_limit_price(current_price);
if stop_hit || (profit_hit && !at_upper_limit) { if stop_hit || (profit_hit && !at_upper_limit) {
let sell_reason = if stop_hit { let sell_reason = if stop_hit {
@@ -2600,7 +2641,7 @@ impl Strategy for OmniMicroCapStrategy {
self.project_target_zero( self.project_target_zero(
ctx, ctx,
&mut projected, &mut projected,
date, projection_date,
&position.symbol, &position.symbol,
sell_reason, sell_reason,
&mut projected_execution_state, &mut projected_execution_state,
@@ -2618,7 +2659,11 @@ impl Strategy for OmniMicroCapStrategy {
{ {
continue; 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; continue;
} }
order_intents.push(OrderIntent::Value { order_intents.push(OrderIntent::Value {
@@ -2629,7 +2674,7 @@ impl Strategy for OmniMicroCapStrategy {
self.project_order_value( self.project_order_value(
ctx, ctx,
&mut projected, &mut projected,
date, projection_date,
symbol, symbol,
replacement_cash, replacement_cash,
&format!("replacement_after_{}", sell_reason), &format!("replacement_after_{}", sell_reason),
@@ -2652,7 +2697,7 @@ impl Strategy for OmniMicroCapStrategy {
if stock_list.iter().any(|candidate| candidate == symbol) { if stock_list.iter().any(|candidate| candidate == symbol) {
continue; continue;
} }
if !self.can_sell_position(ctx, date, symbol) { if !defer_selection_risk && !self.can_sell_position(ctx, execution_date, symbol) {
continue; continue;
} }
order_intents.push(OrderIntent::TargetValue { order_intents.push(OrderIntent::TargetValue {
@@ -2663,7 +2708,7 @@ impl Strategy for OmniMicroCapStrategy {
self.project_target_zero( self.project_target_zero(
ctx, ctx,
&mut projected, &mut projected,
date, projection_date,
symbol, symbol,
"periodic_rebalance_sell", "periodic_rebalance_sell",
&mut projected_execution_state, &mut projected_execution_state,
@@ -2680,7 +2725,11 @@ impl Strategy for OmniMicroCapStrategy {
{ {
continue; 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; continue;
} }
order_intents.push(OrderIntent::Value { order_intents.push(OrderIntent::Value {
@@ -2691,7 +2740,7 @@ impl Strategy for OmniMicroCapStrategy {
self.project_order_value( self.project_order_value(
ctx, ctx,
&mut projected, &mut projected,
date, projection_date,
symbol, symbol,
fixed_buy_cash, fixed_buy_cash,
"periodic_rebalance_buy", "periodic_rebalance_buy",
@@ -2713,7 +2762,7 @@ impl Strategy for OmniMicroCapStrategy {
projected.positions().len(), projected.positions().len(),
order_intents.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") if std::env::var("FIDC_BT_DEBUG_POSITION_ORDER")
.map(|value| value == "1") .map(|value| value == "1")
@@ -2907,10 +2956,11 @@ mod tests {
default_cfg.stock_long_ma_days = 3; default_cfg.stock_long_ma_days = 3;
let default_strategy = OmniMicroCapStrategy::new(default_cfg.clone()); let default_strategy = OmniMicroCapStrategy::new(default_cfg.clone());
let (default_selected, _) = default_strategy 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"); .expect("default selection");
assert!(default_selected.is_empty()); 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.len(), 1);
assert_eq!(default_risk_decisions[0].symbol, symbol); assert_eq!(default_risk_decisions[0].symbol, symbol);
assert_eq!(default_risk_decisions[0].rule_code, "kcb"); assert_eq!(default_risk_decisions[0].rule_code, "kcb");
@@ -2925,11 +2975,11 @@ mod tests {
cfg.risk_config.static_rules.reject_kcb_buy = false; cfg.risk_config.static_rules.reject_kcb_buy = false;
let configured_strategy = OmniMicroCapStrategy::new(cfg); let configured_strategy = OmniMicroCapStrategy::new(cfg);
let (selected, _) = configured_strategy 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"); .expect("configured selection");
assert!( assert!(
configured_strategy configured_strategy
.selection_risk_decisions(&ctx, dates[2]) .selection_risk_decisions(&ctx, dates[2], false)
.is_empty() .is_empty()
); );
+10 -1
View File
@@ -50,15 +50,20 @@ pub struct SelectionContext<'a> {
pub data: &'a DataSet, pub data: &'a DataSet,
pub dynamic_universe: Option<&'a BTreeSet<String>>, pub dynamic_universe: Option<&'a BTreeSet<String>>,
pub risk_config: Option<&'a FidcRiskControlConfig>, pub risk_config: Option<&'a FidcRiskControlConfig>,
pub defer_selection_risk: bool,
} }
impl SelectionContext<'_> { impl SelectionContext<'_> {
fn eligible_universe(&self) -> Vec<EligibleUniverseSnapshot> { fn eligible_universe(&self) -> Vec<EligibleUniverseSnapshot> {
let eligible = match self.risk_config { let eligible = if self.defer_selection_risk {
self.data.fundamental_universe_on(self.decision_date)
} else {
match self.risk_config {
Some(risk_config) => self Some(risk_config) => self
.data .data
.eligible_universe_on_with_risk_config(self.decision_date, risk_config), .eligible_universe_on_with_risk_config(self.decision_date, risk_config),
None => self.data.eligible_universe_on(self.decision_date).to_vec(), None => self.data.eligible_universe_on(self.decision_date).to_vec(),
}
}; };
match self.dynamic_universe { match self.dynamic_universe {
Some(symbols) if !symbols.is_empty() => eligible Some(symbols) if !symbols.is_empty() => eligible
@@ -70,6 +75,9 @@ impl SelectionContext<'_> {
} }
fn selection_risk_decisions(&self) -> Vec<FidcRiskDecisionAudit> { fn selection_risk_decisions(&self) -> Vec<FidcRiskDecisionAudit> {
if self.defer_selection_risk {
return Vec::new();
}
let default_risk_config; let default_risk_config;
let risk_config = match self.risk_config { let risk_config = match self.risk_config {
Some(value) => value, Some(value) => value,
@@ -403,6 +411,7 @@ mod tests {
data: &data, data: &data,
dynamic_universe: None, dynamic_universe: None,
risk_config: Some(&FidcRiskControlConfig::default()), risk_config: Some(&FidcRiskControlConfig::default()),
defer_selection_risk: false,
}); });
let rules = diagnostics let rules = diagnostics