feat: evaluate trading buy filters into decision-scoped constraints

This commit is contained in:
boris
2026-09-08 01:27:42 +08:00
parent 3784246e6b
commit 30da6eaead
3 changed files with 185 additions and 2 deletions
+120 -2
View File
@@ -605,6 +605,7 @@ pub struct PlatformExprStrategyConfig {
pub selection_limit_expr: String,
pub selection_candidate_limit_expr: String,
pub stock_filter_expr: String,
pub buy_filter_expr: String,
pub buy_scale_expr: String,
pub exposure_expr: String,
pub position_exposure_schedule: BTreeMap<NaiveDate, f64>,
@@ -684,6 +685,7 @@ impl PlatformExprStrategyConfig {
selection_limit_expr: "1".to_string(),
selection_candidate_limit_expr: String::new(),
stock_filter_expr: String::new(),
buy_filter_expr: String::new(),
buy_scale_expr: "1.0".to_string(),
exposure_expr: "1.0".to_string(),
position_exposure_schedule: BTreeMap::new(),
@@ -1800,6 +1802,7 @@ impl PlatformExprStrategy {
"stock_filter_expr".to_string(),
self.config.stock_filter_expr.as_str(),
),
("buy_filter_expr".to_string(), self.config.buy_filter_expr.as_str()),
(
"buy_scale_expr".to_string(),
self.config.buy_scale_expr.as_str(),
@@ -10960,6 +10963,7 @@ impl PlatformExprStrategy {
let expressions = [
config.prelude.as_str(),
config.stock_filter_expr.as_str(),
config.buy_filter_expr.as_str(),
config.buy_scale_expr.as_str(),
config.stop_loss_expr.as_str(),
config.take_profit_expr.as_str(),
@@ -10992,6 +10996,7 @@ impl PlatformExprStrategy {
for expr in [
config.prelude.as_str(),
config.stock_filter_expr.as_str(),
config.buy_filter_expr.as_str(),
config.buy_scale_expr.as_str(),
config.stop_loss_expr.as_str(),
config.take_profit_expr.as_str(),
@@ -11039,6 +11044,7 @@ impl PlatformExprStrategy {
for expr in [
config.prelude.as_str(),
config.stock_filter_expr.as_str(),
config.buy_filter_expr.as_str(),
config.buy_scale_expr.as_str(),
config.stop_loss_expr.as_str(),
config.take_profit_expr.as_str(),
@@ -11079,6 +11085,9 @@ impl PlatformExprStrategy {
if Self::expr_requires_stock_extra_factors(
&config.stock_filter_expr,
prelude_declared_identifiers,
) || Self::expr_requires_stock_extra_factors(
&config.buy_filter_expr,
prelude_declared_identifiers,
) {
return true;
}
@@ -11108,6 +11117,7 @@ impl PlatformExprStrategy {
[
config.prelude.as_str(),
config.stock_filter_expr.as_str(),
config.buy_filter_expr.as_str(),
config.buy_scale_expr.as_str(),
config.stop_loss_expr.as_str(),
config.take_profit_expr.as_str(),
@@ -11142,6 +11152,11 @@ impl PlatformExprStrategy {
&config.stock_filter_expr,
prelude_declared_identifiers,
);
Self::collect_stock_extra_factor_identifiers(
&mut identifiers,
&config.buy_filter_expr,
prelude_declared_identifiers,
);
for expr in [
config.buy_scale_expr.as_str(),
config.stop_loss_expr.as_str(),
@@ -12119,10 +12134,11 @@ impl Strategy for PlatformExprStrategy {
.is_some();
if scheduled_rotation {
self.executing_scheduled_rotation = true;
let rotation = self.on_day(ctx);
let rotation = self.compute_day_decision(ctx);
self.executing_scheduled_rotation = false;
decision.merge_from(rotation?);
}
self.attach_buy_denials(ctx, &mut decision)?;
Ok(decision)
}
@@ -12171,12 +12187,53 @@ impl Strategy for PlatformExprStrategy {
&& self.config.explicit_action_schedule.is_none()
&& self.unscheduled_explicit_actions_are_due(ctx.decision_date)
{
return self.explicit_action_decision(ctx);
let mut decision = self.explicit_action_decision(ctx)?;
self.attach_buy_denials(ctx, &mut decision)?;
return Ok(decision);
}
Ok(StrategyDecision::default())
}
fn on_day(&mut self, ctx: &StrategyContext<'_>) -> Result<StrategyDecision, BacktestError> {
let mut decision = self.compute_day_decision(ctx)?;
self.attach_buy_denials(ctx, &mut decision)?;
Ok(decision)
}
}
impl PlatformExprStrategy {
fn attach_buy_denials(&self, ctx: &StrategyContext<'_>, decision: &mut StrategyDecision) -> Result<(), BacktestError> {
if self.config.buy_filter_expr.trim().is_empty() {
return Ok(());
}
let symbols = decision.potential_buy_symbols(ctx.open_orders);
if symbols.is_empty() {
return Ok(());
}
let day = self.day_state(ctx, ctx.decision_date)?;
let (market_date, _, factor_date) = self.selection_dates(ctx);
let needs_quote = Self::stock_filter_quote_usage_for_expr(&Self::normalize_expr(&self.config.buy_filter_expr))
!= StockFilterQuoteUsage::DailyOnly;
for symbol in symbols {
if needs_quote && self.uses_intraday_execution_quotes() && !ctx.is_lagged_execution()
&& self.scheduled_quote(ctx, market_date, &symbol).is_none()
{
return Err(BacktestError::Execution(format!(
"buy condition quote unavailable: symbol={symbol} decision_date={}", ctx.decision_date,
)));
}
let stock = self.stock_state_with_factor_date(ctx, market_date, factor_date, &symbol)?;
if !self.eval_bool(ctx, &self.config.buy_filter_expr, &day, Some(&stock), None)? {
decision.buy_denials.insert(symbol, format!(
"strategy_buy_condition_false decision_date={} expression={}",
ctx.decision_date, self.config.buy_filter_expr,
));
}
}
Ok(())
}
fn compute_day_decision(&mut self, ctx: &StrategyContext<'_>) -> Result<StrategyDecision, BacktestError> {
if self.config.rotation_enabled
&& self
.config
@@ -13955,6 +14012,67 @@ mod tests {
NaiveDate::from_ymd_opt(year, month, day).expect("valid date")
}
#[test]
fn buy_filter_attaches_denials_without_rewriting_selection() {
let prev = d(2025, 1, 2);
let curr = d(2025, 1, 3);
let symbol = "000001.SZ";
let mut parts = single_symbol_platform_data(&[prev, curr], symbol).snapshot_components();
for row in &mut parts.factors { row.extra_factors.insert("entry_gate".into(), 0.0); }
let data = DataSet::from_components(parts.instruments, parts.market, parts.factors, parts.candidates, parts.benchmarks).unwrap();
let portfolio = PortfolioState::new(30_000.0);
let subscriptions = BTreeSet::new();
let ctx = StrategyContext {
execution_date: curr, decision_date: curr, decision_index: 1, data: &data,
portfolio: &portfolio, futures_account: None, open_orders: &[], dynamic_universe: None,
subscriptions: &subscriptions, process_events: &[], active_process_event: None,
active_datetime: None, order_events: &[], fills: &[],
};
let mut cfg = PlatformExprStrategyConfig::microcap_rotation();
cfg.signal_symbol = symbol.to_string();
cfg.max_positions = 1;
cfg.refresh_rate = 1;
cfg.benchmark_short_ma_days = 1;
cfg.benchmark_long_ma_days = 1;
cfg.market_cap_lower_expr = "0".to_string();
cfg.market_cap_upper_expr = "100".to_string();
cfg.selection_limit_expr = "1".to_string();
cfg.stock_filter_expr = "close > 0".to_string();
cfg.buy_filter_expr = "entry_gate > 0".to_string();
cfg.current_day_precomputed_factors = true;
let mut strategy = PlatformExprStrategy::new(cfg);
let decision = strategy.on_day(&ctx).unwrap();
assert!(!decision.order_intents.is_empty());
assert!(decision.buy_denials.contains_key(symbol));
assert!(strategy.stock_extra_factor_identifiers.contains("entry_gate"));
assert_eq!(strategy.selection_quote_usage, StockFilterQuoteUsage::DailyOnly);
}
#[test]
fn buy_quote_filter_rejects_missing_intraday_quote_not_daily_close() {
let date = d(2025, 1, 2);
let symbol = "000001.SZ";
let data = single_symbol_platform_data(&[date], symbol);
let portfolio = PortfolioState::new(30_000.0);
let subscriptions = BTreeSet::new();
let ctx = StrategyContext {
execution_date: date, decision_date: date, decision_index: 0, data: &data,
portfolio: &portfolio, futures_account: None, open_orders: &[], dynamic_universe: None,
subscriptions: &subscriptions, process_events: &[], active_process_event: None,
active_datetime: None, order_events: &[], fills: &[],
};
let mut cfg = PlatformExprStrategyConfig::generic();
cfg.signal_symbol = symbol.to_string();
cfg.buy_filter_expr = "last > 0".to_string();
cfg.intraday_execution_time = NaiveTime::from_hms_opt(10, 18, 0);
let strategy = PlatformExprStrategy::new(cfg);
let mut decision = crate::StrategyDecision::default();
decision.order_intents.push(OrderIntent::TargetValue { symbol: symbol.to_string(), target_value: 10_000.0, reason: "buy".to_string() });
let error = strategy.attach_buy_denials(&ctx, &mut decision).unwrap_err();
assert!(error.to_string().contains("buy condition quote unavailable"), "{error}");
assert_eq!(strategy.selection_quote_usage, StockFilterQuoteUsage::DailyOnly);
}
#[test]
fn completed_session_factor_dates_exclude_intraday_and_preserve_next_open() {
let prev = d(2025, 1, 2);
@@ -977,6 +977,8 @@ pub struct StrategyExpressionOrderingConfig {
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct StrategyExpressionTradingConfig {
#[serde(default, alias = "buy_filter_expr")]
pub buy_filter_expr: Option<String>,
#[serde(default)]
pub stage: Option<String>,
#[serde(default)]
@@ -2289,6 +2291,9 @@ pub fn platform_expr_config_from_spec(
}
}
if let Some(trading) = runtime_expr.trading.as_ref() {
if let Some(expr) = trading.buy_filter_expr.as_ref() {
cfg.buy_filter_expr = expr.clone();
}
if let Some(expr) = trading
.refresh_rate_expr
.as_ref()
@@ -3127,6 +3132,18 @@ fn symbol_is_kcb(symbol: &str) -> bool {
mod tests {
use super::*;
#[test]
fn parses_buy_filter_as_a_separate_trading_condition() {
let cfg = platform_expr_config_from_value("buy-guard", "000001.SZ", &serde_json::json!({
"runtimeExpressions": {
"selection": {"stockFilterExpr": "close > 0"},
"trading": {"buyFilterExpr": "gate > 0"}
}
})).unwrap();
assert_eq!(cfg.stock_filter_expr, "close > 0");
assert_eq!(cfg.buy_filter_expr, "gate > 0");
}
#[test]
fn native_factor_bindings_declare_completed_session_fields() {
let spec = serde_json::json!({"stockPoolFactorContract": {"conditions": [
+48
View File
@@ -988,6 +988,18 @@ pub struct StrategyDecision {
}
impl StrategyDecision {
pub fn potential_buy_symbols(&self, open_orders: &[OpenOrderView]) -> BTreeSet<String> {
let mut symbols = BTreeSet::new();
if self.rebalance {
symbols.extend(self.target_weights.iter().filter(|(_, weight)| **weight > 0.0).map(|(symbol, _)| symbol.clone()));
}
for intent in &self.order_intents {
intent.collect_potential_buy_symbols(open_orders, &mut symbols);
}
symbols.retain(|symbol| !symbol.trim().is_empty());
symbols
}
pub fn merge_from(&mut self, mut other: StrategyDecision) {
self.buy_denials.append(&mut other.buy_denials);
self.rebalance |= other.rebalance;
@@ -1217,6 +1229,42 @@ pub enum OrderIntent {
}
impl OrderIntent {
fn collect_potential_buy_symbols(&self, open_orders: &[OpenOrderView], symbols: &mut BTreeSet<String>) {
match self.unwrapped() {
Self::Shares { symbol, quantity, .. } | Self::LimitShares { symbol, quantity, .. } if *quantity > 0 => { symbols.insert(symbol.clone()); }
Self::Lots { symbol, lots, .. } | Self::LimitLots { symbol, lots, .. } if *lots > 0 => { symbols.insert(symbol.clone()); }
Self::TargetShares { symbol, target_quantity, .. } | Self::LimitTargetShares { symbol, target_quantity, .. } if *target_quantity > 0 => { symbols.insert(symbol.clone()); }
Self::Value { symbol, value, .. } | Self::LimitValue { symbol, value, .. } | Self::AlgoValue { symbol, value, .. } if *value > 0.0 => { symbols.insert(symbol.clone()); }
Self::Percent { symbol, percent, .. } | Self::LimitPercent { symbol, percent, .. } | Self::AlgoPercent { symbol, percent, .. } if *percent > 0.0 => { symbols.insert(symbol.clone()); }
Self::TargetValue { symbol, target_value, .. } | Self::LimitTargetValue { symbol, target_value, .. } | Self::TimedTargetValue { symbol, target_value, .. } if *target_value > 0.0 => { symbols.insert(symbol.clone()); }
Self::TargetPercent { symbol, target_percent, .. } | Self::LimitTargetPercent { symbol, target_percent, .. } if *target_percent > 0.0 => { symbols.insert(symbol.clone()); }
Self::TargetPortfolioSmart { target_weights, .. } => {
symbols.extend(target_weights.iter().filter(|(_, weight)| **weight > 0.0).map(|(symbol, _)| symbol.clone()));
}
Self::ModifyOrder { order_id, new_total_quantity, new_limit_price, .. } => {
if let Some(order) = open_orders.iter().find(|order| order.order_id == *order_id)
&& order.side == OrderSide::Buy
&& (new_total_quantity.is_some_and(|value| value > order.requested_quantity)
|| new_limit_price.is_some_and(|value| value > order.limit_price))
{
symbols.insert(order.symbol.clone());
}
}
Self::Shares { .. } | Self::LimitShares { .. }
| Self::Lots { .. } | Self::LimitLots { .. }
| Self::TargetShares { .. } | Self::LimitTargetShares { .. }
| Self::Value { .. } | Self::LimitValue { .. } | Self::AlgoValue { .. }
| Self::Percent { .. } | Self::LimitPercent { .. } | Self::AlgoPercent { .. }
| Self::TargetValue { .. } | Self::LimitTargetValue { .. } | Self::TimedTargetValue { .. }
| Self::TargetPercent { .. } | Self::LimitTargetPercent { .. }
| Self::CancelOrder { .. } | Self::CancelSymbol { .. } | Self::CancelAll { .. }
| Self::UpdateUniverse { .. } | Self::Subscribe { .. } | Self::Unsubscribe { .. }
| Self::DepositWithdraw { .. } | Self::FinanceRepay { .. } | Self::SetManagementFeeRate { .. }
| Self::Futures { .. } => {}
Self::WithTimeInForce { .. } => unreachable!("intent is unwrapped"),
}
}
pub fn with_time_in_force(self, time_in_force: OrderTimeInForce) -> Self {
match self {
Self::WithTimeInForce { intent, .. } => Self::WithTimeInForce {