feat: evaluate trading buy filters into decision-scoped constraints
This commit is contained in:
@@ -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": [
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user