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_limit_expr: String,
|
||||||
pub selection_candidate_limit_expr: String,
|
pub selection_candidate_limit_expr: String,
|
||||||
pub stock_filter_expr: String,
|
pub stock_filter_expr: String,
|
||||||
|
pub buy_filter_expr: String,
|
||||||
pub buy_scale_expr: String,
|
pub buy_scale_expr: String,
|
||||||
pub exposure_expr: String,
|
pub exposure_expr: String,
|
||||||
pub position_exposure_schedule: BTreeMap<NaiveDate, f64>,
|
pub position_exposure_schedule: BTreeMap<NaiveDate, f64>,
|
||||||
@@ -684,6 +685,7 @@ impl PlatformExprStrategyConfig {
|
|||||||
selection_limit_expr: "1".to_string(),
|
selection_limit_expr: "1".to_string(),
|
||||||
selection_candidate_limit_expr: String::new(),
|
selection_candidate_limit_expr: String::new(),
|
||||||
stock_filter_expr: String::new(),
|
stock_filter_expr: String::new(),
|
||||||
|
buy_filter_expr: String::new(),
|
||||||
buy_scale_expr: "1.0".to_string(),
|
buy_scale_expr: "1.0".to_string(),
|
||||||
exposure_expr: "1.0".to_string(),
|
exposure_expr: "1.0".to_string(),
|
||||||
position_exposure_schedule: BTreeMap::new(),
|
position_exposure_schedule: BTreeMap::new(),
|
||||||
@@ -1800,6 +1802,7 @@ impl PlatformExprStrategy {
|
|||||||
"stock_filter_expr".to_string(),
|
"stock_filter_expr".to_string(),
|
||||||
self.config.stock_filter_expr.as_str(),
|
self.config.stock_filter_expr.as_str(),
|
||||||
),
|
),
|
||||||
|
("buy_filter_expr".to_string(), self.config.buy_filter_expr.as_str()),
|
||||||
(
|
(
|
||||||
"buy_scale_expr".to_string(),
|
"buy_scale_expr".to_string(),
|
||||||
self.config.buy_scale_expr.as_str(),
|
self.config.buy_scale_expr.as_str(),
|
||||||
@@ -10960,6 +10963,7 @@ impl PlatformExprStrategy {
|
|||||||
let expressions = [
|
let expressions = [
|
||||||
config.prelude.as_str(),
|
config.prelude.as_str(),
|
||||||
config.stock_filter_expr.as_str(),
|
config.stock_filter_expr.as_str(),
|
||||||
|
config.buy_filter_expr.as_str(),
|
||||||
config.buy_scale_expr.as_str(),
|
config.buy_scale_expr.as_str(),
|
||||||
config.stop_loss_expr.as_str(),
|
config.stop_loss_expr.as_str(),
|
||||||
config.take_profit_expr.as_str(),
|
config.take_profit_expr.as_str(),
|
||||||
@@ -10992,6 +10996,7 @@ impl PlatformExprStrategy {
|
|||||||
for expr in [
|
for expr in [
|
||||||
config.prelude.as_str(),
|
config.prelude.as_str(),
|
||||||
config.stock_filter_expr.as_str(),
|
config.stock_filter_expr.as_str(),
|
||||||
|
config.buy_filter_expr.as_str(),
|
||||||
config.buy_scale_expr.as_str(),
|
config.buy_scale_expr.as_str(),
|
||||||
config.stop_loss_expr.as_str(),
|
config.stop_loss_expr.as_str(),
|
||||||
config.take_profit_expr.as_str(),
|
config.take_profit_expr.as_str(),
|
||||||
@@ -11039,6 +11044,7 @@ impl PlatformExprStrategy {
|
|||||||
for expr in [
|
for expr in [
|
||||||
config.prelude.as_str(),
|
config.prelude.as_str(),
|
||||||
config.stock_filter_expr.as_str(),
|
config.stock_filter_expr.as_str(),
|
||||||
|
config.buy_filter_expr.as_str(),
|
||||||
config.buy_scale_expr.as_str(),
|
config.buy_scale_expr.as_str(),
|
||||||
config.stop_loss_expr.as_str(),
|
config.stop_loss_expr.as_str(),
|
||||||
config.take_profit_expr.as_str(),
|
config.take_profit_expr.as_str(),
|
||||||
@@ -11079,6 +11085,9 @@ impl PlatformExprStrategy {
|
|||||||
if Self::expr_requires_stock_extra_factors(
|
if Self::expr_requires_stock_extra_factors(
|
||||||
&config.stock_filter_expr,
|
&config.stock_filter_expr,
|
||||||
prelude_declared_identifiers,
|
prelude_declared_identifiers,
|
||||||
|
) || Self::expr_requires_stock_extra_factors(
|
||||||
|
&config.buy_filter_expr,
|
||||||
|
prelude_declared_identifiers,
|
||||||
) {
|
) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -11108,6 +11117,7 @@ impl PlatformExprStrategy {
|
|||||||
[
|
[
|
||||||
config.prelude.as_str(),
|
config.prelude.as_str(),
|
||||||
config.stock_filter_expr.as_str(),
|
config.stock_filter_expr.as_str(),
|
||||||
|
config.buy_filter_expr.as_str(),
|
||||||
config.buy_scale_expr.as_str(),
|
config.buy_scale_expr.as_str(),
|
||||||
config.stop_loss_expr.as_str(),
|
config.stop_loss_expr.as_str(),
|
||||||
config.take_profit_expr.as_str(),
|
config.take_profit_expr.as_str(),
|
||||||
@@ -11142,6 +11152,11 @@ impl PlatformExprStrategy {
|
|||||||
&config.stock_filter_expr,
|
&config.stock_filter_expr,
|
||||||
prelude_declared_identifiers,
|
prelude_declared_identifiers,
|
||||||
);
|
);
|
||||||
|
Self::collect_stock_extra_factor_identifiers(
|
||||||
|
&mut identifiers,
|
||||||
|
&config.buy_filter_expr,
|
||||||
|
prelude_declared_identifiers,
|
||||||
|
);
|
||||||
for expr in [
|
for expr in [
|
||||||
config.buy_scale_expr.as_str(),
|
config.buy_scale_expr.as_str(),
|
||||||
config.stop_loss_expr.as_str(),
|
config.stop_loss_expr.as_str(),
|
||||||
@@ -12119,10 +12134,11 @@ impl Strategy for PlatformExprStrategy {
|
|||||||
.is_some();
|
.is_some();
|
||||||
if scheduled_rotation {
|
if scheduled_rotation {
|
||||||
self.executing_scheduled_rotation = true;
|
self.executing_scheduled_rotation = true;
|
||||||
let rotation = self.on_day(ctx);
|
let rotation = self.compute_day_decision(ctx);
|
||||||
self.executing_scheduled_rotation = false;
|
self.executing_scheduled_rotation = false;
|
||||||
decision.merge_from(rotation?);
|
decision.merge_from(rotation?);
|
||||||
}
|
}
|
||||||
|
self.attach_buy_denials(ctx, &mut decision)?;
|
||||||
Ok(decision)
|
Ok(decision)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -12171,12 +12187,53 @@ impl Strategy for PlatformExprStrategy {
|
|||||||
&& self.config.explicit_action_schedule.is_none()
|
&& self.config.explicit_action_schedule.is_none()
|
||||||
&& self.unscheduled_explicit_actions_are_due(ctx.decision_date)
|
&& 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())
|
Ok(StrategyDecision::default())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn on_day(&mut self, ctx: &StrategyContext<'_>) -> Result<StrategyDecision, BacktestError> {
|
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
|
if self.config.rotation_enabled
|
||||||
&& self
|
&& self
|
||||||
.config
|
.config
|
||||||
@@ -13955,6 +14012,67 @@ mod tests {
|
|||||||
NaiveDate::from_ymd_opt(year, month, day).expect("valid date")
|
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]
|
#[test]
|
||||||
fn completed_session_factor_dates_exclude_intraday_and_preserve_next_open() {
|
fn completed_session_factor_dates_exclude_intraday_and_preserve_next_open() {
|
||||||
let prev = d(2025, 1, 2);
|
let prev = d(2025, 1, 2);
|
||||||
|
|||||||
@@ -977,6 +977,8 @@ pub struct StrategyExpressionOrderingConfig {
|
|||||||
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct StrategyExpressionTradingConfig {
|
pub struct StrategyExpressionTradingConfig {
|
||||||
|
#[serde(default, alias = "buy_filter_expr")]
|
||||||
|
pub buy_filter_expr: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub stage: Option<String>,
|
pub stage: Option<String>,
|
||||||
#[serde(default)]
|
#[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(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
|
if let Some(expr) = trading
|
||||||
.refresh_rate_expr
|
.refresh_rate_expr
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -3127,6 +3132,18 @@ fn symbol_is_kcb(symbol: &str) -> bool {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn native_factor_bindings_declare_completed_session_fields() {
|
fn native_factor_bindings_declare_completed_session_fields() {
|
||||||
let spec = serde_json::json!({"stockPoolFactorContract": {"conditions": [
|
let spec = serde_json::json!({"stockPoolFactorContract": {"conditions": [
|
||||||
|
|||||||
@@ -988,6 +988,18 @@ pub struct StrategyDecision {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl 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) {
|
pub fn merge_from(&mut self, mut other: StrategyDecision) {
|
||||||
self.buy_denials.append(&mut other.buy_denials);
|
self.buy_denials.append(&mut other.buy_denials);
|
||||||
self.rebalance |= other.rebalance;
|
self.rebalance |= other.rebalance;
|
||||||
@@ -1217,6 +1229,42 @@ pub enum OrderIntent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl 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 {
|
pub fn with_time_in_force(self, time_in_force: OrderTimeInForce) -> Self {
|
||||||
match self {
|
match self {
|
||||||
Self::WithTimeInForce { intent, .. } => Self::WithTimeInForce {
|
Self::WithTimeInForce { intent, .. } => Self::WithTimeInForce {
|
||||||
|
|||||||
Reference in New Issue
Block a user