diff --git a/crates/fidc-core/src/broker.rs b/crates/fidc-core/src/broker.rs index 52d4d96..c5efd01 100644 --- a/crates/fidc-core/src/broker.rs +++ b/crates/fidc-core/src/broker.rs @@ -381,6 +381,8 @@ pub struct BrokerSimulator { runtime_intraday_end_time: Cell>, runtime_decision_date: Cell>, runtime_buy_denials: RefCell>, + runtime_auto_buy_denials: RefCell>, + runtime_auto_sell_denials: RefCell>, runtime_order_created_date: Cell>, runtime_decision_total_equity: Cell>, runtime_target_position_limit: Cell>, @@ -414,6 +416,8 @@ impl BrokerSimulator { runtime_intraday_end_time: Cell::new(None), runtime_decision_date: Cell::new(None), runtime_buy_denials: RefCell::new(BTreeMap::new()), + runtime_auto_buy_denials: RefCell::new(BTreeMap::new()), + runtime_auto_sell_denials: RefCell::new(BTreeMap::new()), runtime_order_created_date: Cell::new(None), runtime_decision_total_equity: Cell::new(None), runtime_target_position_limit: Cell::new(None), @@ -451,6 +455,8 @@ impl BrokerSimulator { runtime_intraday_end_time: Cell::new(None), runtime_decision_date: Cell::new(None), runtime_buy_denials: RefCell::new(BTreeMap::new()), + runtime_auto_buy_denials: RefCell::new(BTreeMap::new()), + runtime_auto_sell_denials: RefCell::new(BTreeMap::new()), runtime_order_created_date: Cell::new(None), runtime_decision_total_equity: Cell::new(None), runtime_target_position_limit: Cell::new(None), @@ -1389,6 +1395,11 @@ where ) -> Result { let previous_decision_date = self.runtime_decision_date.get(); let previous_buy_denials = self.runtime_buy_denials.replace(decision.buy_denials.clone()); + let protection_denials = |scope| decision.risk_decisions.iter() + .filter(|row| !row.accepted && row.stage == "automatic_trade_protection" && row.scope == scope) + .map(|row| (row.symbol.clone(), row.reason.clone())).collect(); + let previous_auto_buy_denials = self.runtime_auto_buy_denials.replace(protection_denials(crate::risk_control::RiskCheckScope::Buy)); + let previous_auto_sell_denials = self.runtime_auto_sell_denials.replace(protection_denials(crate::risk_control::RiskCheckScope::Sell)); let previous_order_created_date = self.runtime_order_created_date.get(); let previous_decision_total_equity = self.runtime_decision_total_equity.get(); self.runtime_decision_date.set(Some(decision_date)); @@ -1398,6 +1409,8 @@ where .set(decision_total_equity.filter(|equity| equity.is_finite() && *equity >= 0.0)); let result = self.execute_with_runtime_dates(date, portfolio, data, decision); self.runtime_buy_denials.replace(previous_buy_denials); + self.runtime_auto_buy_denials.replace(previous_auto_buy_denials); + self.runtime_auto_sell_denials.replace(previous_auto_sell_denials); self.runtime_decision_date.set(previous_decision_date); self.runtime_order_created_date .set(previous_order_created_date); @@ -2850,6 +2863,15 @@ where return; } + let protection = match existing.side { + OrderSide::Buy => self.runtime_auto_buy_denials.borrow().get(&existing.symbol).cloned(), + OrderSide::Sell => self.runtime_auto_sell_denials.borrow().get(&existing.symbol).cloned(), + }; + if let Some(denial) = protection + && (target_limit_price.to_bits() != existing.limit_price.to_bits() || target_total_quantity != existing.requested_quantity) { + Self::emit_open_order_update_rejected(report, date, order_id, Some(&existing.symbol), Some(existing.side), reason, &denial); + return; + } let resets_queue_priority = target_limit_price.to_bits() != existing.limit_price.to_bits() || target_total_quantity > existing.requested_quantity; { @@ -4139,6 +4161,9 @@ where minimum_order_quantity: u32, order_step_size: u32, ) -> Option { + if let Some(reason) = self.runtime_auto_sell_denials.borrow().get(symbol) { + return Some(reason.clone()); + } if current_qty == 0 { return None; } @@ -4299,6 +4324,10 @@ where algo_request: Option<&AlgoExecutionRequest>, report: &mut BrokerExecutionReport, ) -> Result<(), BacktestError> { + // Existing accepted orders are not canceled by a subsequently enabled lock. + if emit_creation_events && self.runtime_auto_sell_denials.borrow().contains_key(symbol) { + return Ok(()); + } let remainder_policy = self.effective_remainder_policy(date, allow_pending_limit); let Some(position) = portfolio.position(symbol) else { return Ok(()); @@ -6074,6 +6103,9 @@ where algo_request: Option<&AlgoExecutionRequest>, report: &mut BrokerExecutionReport, ) -> Result<(), BacktestError> { + if emit_creation_events && self.runtime_auto_buy_denials.borrow().contains_key(symbol) { + return Ok(()); + } let remainder_policy = self.effective_remainder_policy(date, allow_pending_limit); if portfolio .position(symbol) diff --git a/crates/fidc-core/src/holding_policy.rs b/crates/fidc-core/src/holding_policy.rs new file mode 100644 index 0000000..760b60a --- /dev/null +++ b/crates/fidc-core/src/holding_policy.rs @@ -0,0 +1,369 @@ +use chrono::NaiveDate; +use serde::{Deserialize, Serialize}; + +use crate::TradingCalendar; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TradingActionOrigin { + Strategy, + Manual, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AutomaticTradeProtection { + #[serde(default, deserialize_with = "optional_days")] + pub buy_protection_days: u32, + #[serde(default, deserialize_with = "optional_days")] + pub sell_cooldown_days: u32, + #[serde(default, deserialize_with = "optional_days")] + pub max_holding_days: u32, + #[serde(default, deserialize_with = "optional_locks")] + pub locks: Vec, +} + +pub fn deserialize_optional_policy<'de, D: serde::Deserializer<'de>>( + deserializer: D, +) -> Result { + Ok(Option::::deserialize(deserializer)?.unwrap_or_default()) +} + +fn optional_days<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result { + let raw = serde_json::Value::deserialize(deserializer)?; + if raw.is_null() { + return Ok(0); + } + raw.as_f64() + .filter(|value| { + value.is_finite() && value.fract() == 0.0 && *value >= 0.0 && *value <= 3650.0 + }) + .map(|value| value as u32) + .ok_or_else(|| serde::de::Error::custom("protection days must be integers in 0..3650")) +} + +fn optional_locks<'de, D: serde::Deserializer<'de>>( + deserializer: D, +) -> Result, D::Error> { + Ok(Option::>::deserialize(deserializer)?.unwrap_or_default()) +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AutomaticTradeLock { + pub symbol: String, + pub start_date: NaiveDate, + pub end_date: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct HoldingLifecycleEvidence { + pub has_position: bool, + pub opened_date: Option, + pub last_buy_date: Option, + pub last_sell_date: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct AutomaticTradePermission { + pub buy_denial: Option<&'static str>, + pub sell_denial: Option<&'static str>, + pub max_holding_exit: bool, +} + +impl AutomaticTradeProtection { + pub fn enabled(&self) -> bool { + self.buy_protection_days > 0 + || self.sell_cooldown_days > 0 + || self.max_holding_days > 0 + || !self.locks.is_empty() + } + + pub fn validate(&self) -> Result<(), String> { + if [ + self.buy_protection_days, + self.sell_cooldown_days, + self.max_holding_days, + ] + .into_iter() + .any(|days| days > 3650) + { + return Err("automatic_trade_holding_days_out_of_range: expected 0..3650".into()); + } + if self.locks.len() > 2000 { + return Err("automatic_trade_locks_limit: maximum 2000 intervals".into()); + } + for lock in &self.locks { + let valid_symbol = lock.symbol.split_once('.').is_some_and(|(code, venue)| { + code.len() == 6 + && code.bytes().all(|ch| ch.is_ascii_digit()) + && matches!(venue, "SH" | "SZ" | "BJ") + }); + if !valid_symbol { + return Err(format!( + "automatic_trade_lock_invalid_symbol: {}", + lock.symbol + )); + } + if lock.end_date.is_some_and(|end| end < lock.start_date) { + return Err(format!( + "automatic_trade_lock_invalid_interval: {}", + lock.symbol + )); + } + } + Ok(()) + } + + pub fn evaluate( + &self, + symbol: &str, + execution_date: NaiveDate, + evidence: &HoldingLifecycleEvidence, + calendar: &TradingCalendar, + ) -> Result { + self.validate()?; + if self.locks.iter().any(|lock| { + lock.symbol == symbol + && lock.start_date <= execution_date + && lock.end_date.is_none_or(|end| execution_date <= end) + }) { + return Ok(AutomaticTradePermission { + buy_denial: Some("automatic_trade_locked"), + sell_denial: Some("automatic_trade_locked"), + max_holding_exit: false, + }); + } + let elapsed = |date: NaiveDate| -> Result { + let start = calendar.index_of(date).ok_or_else(|| { + format!( + "automatic_trade_holding_calendar_missing: symbol={symbol} fact_date={date}" + ) + })?; + let end = calendar.index_of(execution_date).ok_or_else(|| format!("automatic_trade_holding_calendar_missing: symbol={symbol} execution_date={execution_date}"))?; + end.checked_sub(start).ok_or_else(|| format!("automatic_trade_holding_future_fact: symbol={symbol} fact_date={date} execution_date={execution_date}")) + }; + let mut decision = AutomaticTradePermission::default(); + if self.buy_protection_days > 0 + && evidence.has_position + && let Some(date) = evidence.last_buy_date + && elapsed(date)? <= self.buy_protection_days as usize + { + decision.sell_denial = Some("buy_fill_protection"); + } + if self.sell_cooldown_days > 0 + && let Some(date) = evidence.last_sell_date + && elapsed(date)? <= self.sell_cooldown_days as usize + { + decision.buy_denial = Some("sell_fill_cooldown"); + } + if self.max_holding_days > 0 && evidence.has_position { + let opened = evidence.opened_date.ok_or_else(|| format!("automatic_trade_opened_date_missing: symbol={symbol}; require confirmed position lifecycle evidence"))?; + decision.max_holding_exit = elapsed(opened)? >= self.max_holding_days as usize + && decision.sell_denial.is_none(); + if decision.max_holding_exit { + decision.buy_denial = Some("maximum_holding_exit"); + } + } + Ok(decision) + } + + /// The caller supplies origin from its authenticated execution path, never + /// from an untrusted order-body flag. Broker and ordinary risk checks remain. + pub fn evaluate_for_origin( + &self, + origin: TradingActionOrigin, + symbol: &str, + execution_date: NaiveDate, + evidence: &HoldingLifecycleEvidence, + calendar: &TradingCalendar, + ) -> Result { + self.validate()?; + match origin { + TradingActionOrigin::Strategy => { + self.evaluate(symbol, execution_date, evidence, calendar) + } + TradingActionOrigin::Manual => Ok(AutomaticTradePermission::default()), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + fn d(value: &str) -> NaiveDate { + NaiveDate::parse_from_str(value, "%Y-%m-%d").unwrap() + } + fn calendar() -> TradingCalendar { + TradingCalendar::new( + [ + "2026-09-11", + "2026-09-14", + "2026-09-15", + "2026-09-16", + "2026-09-17", + ] + .into_iter() + .map(d) + .collect(), + ) + } + + #[test] + fn three_complete_sessions_protect_through_wednesday_not_72_hours() { + let policy = AutomaticTradeProtection { + buy_protection_days: 3, + sell_cooldown_days: 3, + ..Default::default() + }; + let evidence = HoldingLifecycleEvidence { + has_position: true, + last_buy_date: Some(d("2026-09-11")), + last_sell_date: Some(d("2026-09-11")), + ..Default::default() + }; + for day in ["2026-09-11", "2026-09-14", "2026-09-15", "2026-09-16"] { + let decision = policy + .evaluate("000001.SZ", d(day), &evidence, &calendar()) + .unwrap(); + assert_eq!(decision.sell_denial, Some("buy_fill_protection")); + assert_eq!(decision.buy_denial, Some("sell_fill_cooldown")); + } + assert_eq!( + policy + .evaluate("000001.SZ", d("2026-09-17"), &evidence, &calendar()) + .unwrap(), + AutomaticTradePermission::default() + ); + } + + #[test] + fn absolute_locks_are_inclusive_and_override_timed_exit_without_changing_other_symbols() { + let policy = AutomaticTradeProtection { + max_holding_days: 1, + locks: vec![AutomaticTradeLock { + symbol: "000001.SZ".into(), + start_date: d("2026-09-11"), + end_date: Some(d("2026-09-16")), + }], + ..Default::default() + }; + let evidence = HoldingLifecycleEvidence { + has_position: true, + opened_date: Some(d("2026-09-11")), + ..Default::default() + }; + let locked = policy + .evaluate("000001.SZ", d("2026-09-16"), &evidence, &calendar()) + .unwrap(); + assert_eq!(locked.sell_denial, Some("automatic_trade_locked")); + assert!(!locked.max_holding_exit); + assert!( + policy + .evaluate("600000.SH", d("2026-09-16"), &evidence, &calendar()) + .unwrap() + .max_holding_exit + ); + assert!( + policy + .evaluate("000001.SZ", d("2026-09-17"), &evidence, &calendar()) + .unwrap() + .max_holding_exit + ); + } + + #[test] + fn zero_disabled_and_missing_calendar_or_opened_date_are_not_inferred() { + let evidence = HoldingLifecycleEvidence { + has_position: true, + ..Default::default() + }; + assert_eq!( + AutomaticTradeProtection::default() + .evaluate( + "000001.SZ", + d("2026-09-17"), + &evidence, + &TradingCalendar::new(vec![]) + ) + .unwrap(), + AutomaticTradePermission::default() + ); + let policy = AutomaticTradeProtection { + max_holding_days: 1, + ..Default::default() + }; + assert!( + policy + .evaluate("000001.SZ", d("2026-09-17"), &evidence, &calendar()) + .unwrap_err() + .contains("opened_date_missing") + ); + let evidence = HoldingLifecycleEvidence { + opened_date: Some(d("2026-09-10")), + ..evidence + }; + assert!( + policy + .evaluate("000001.SZ", d("2026-09-17"), &evidence, &calendar()) + .unwrap_err() + .contains("calendar_missing") + ); + } + + #[test] + fn manual_origin_only_bypasses_automatic_policy_not_an_order_or_broker_permission() { + let policy = AutomaticTradeProtection { + locks: vec![AutomaticTradeLock { + symbol: "000001.SZ".into(), + start_date: d("2026-09-11"), + end_date: None, + }], + ..Default::default() + }; + assert_eq!( + policy + .evaluate_for_origin( + TradingActionOrigin::Manual, + "000001.SZ", + d("2026-09-14"), + &HoldingLifecycleEvidence::default(), + &calendar() + ) + .unwrap(), + AutomaticTradePermission::default() + ); + assert_eq!( + policy + .evaluate_for_origin( + TradingActionOrigin::Strategy, + "000001.SZ", + d("2026-09-14"), + &HoldingLifecycleEvidence::default(), + &calendar() + ) + .unwrap() + .buy_denial, + Some("automatic_trade_locked") + ); + } + + #[test] + fn opening_date_follows_fills_not_partial_sales_or_corporate_conversions() { + let mut portfolio = crate::PortfolioState::new(100_000.0); + let position = portfolio.position_mut("000001.SZ"); + position.buy(d("2026-09-11"), 100, 10.0); + position.buy(d("2026-09-14"), 200, 10.0); + position.sell(100, 10.0).unwrap(); + assert_eq!(position.opened_date(), Some(d("2026-09-11"))); + portfolio + .apply_successor_conversion("000001.SZ", "000002.SZ", 2.0, 0.0) + .unwrap(); + let successor = portfolio.position_mut("000002.SZ"); + assert_eq!(successor.opened_date(), Some(d("2026-09-11"))); + assert_eq!(successor.last_buy_date(), Some(d("2026-09-14"))); + successor.sell(400, 5.0).unwrap(); + assert_eq!(successor.opened_date(), None); + successor.buy(d("2026-09-17"), 100, 5.0); + assert_eq!(successor.opened_date(), Some(d("2026-09-17"))); + } +} diff --git a/crates/fidc-core/src/lib.rs b/crates/fidc-core/src/lib.rs index c6ad29b..7f65361 100644 --- a/crates/fidc-core/src/lib.rs +++ b/crates/fidc-core/src/lib.rs @@ -25,6 +25,7 @@ pub mod risk_control; pub mod rules; pub mod scheduler; pub mod strategy; +pub mod holding_policy; pub mod signal_contract; pub mod strategy_ai; pub mod universe; diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index 8a472bd..5dc4cfc 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -27,6 +27,7 @@ use crate::numeric_expr_vm::{ Scratch as NumericVmScratch, Value as NumericVmValue, ValueType as NumericVmValueType, }; use crate::portfolio::PortfolioState; +use crate::holding_policy::{AutomaticTradeProtection, AutomaticTradePermission, HoldingLifecycleEvidence}; use crate::portfolio_loss::{ClosedPortfolioSession, PortfolioLossConfig, PortfolioLossState}; use crate::risk_control::{ChinaAShareRiskControl, FidcRiskControlConfig, FidcRiskDecisionAudit}; use crate::scheduler::{ScheduleRule, ScheduleStage, ScheduleTimeRule, Scheduler}; @@ -657,6 +658,7 @@ pub struct PlatformExprStrategyConfig { pub retry_empty_rebalance: bool, pub calendar_rebalance_interval: bool, pub max_holding_days: Option, + pub automatic_trade_protection: AutomaticTradeProtection, pub weak_market_shrink_overweight_threshold: Option, pub commission_rate: Option, pub minimum_commission: Option, @@ -740,6 +742,7 @@ impl PlatformExprStrategyConfig { retry_empty_rebalance: false, calendar_rebalance_interval: false, max_holding_days: None, + automatic_trade_protection: AutomaticTradeProtection::default(), weak_market_shrink_overweight_threshold: None, commission_rate: None, minimum_commission: None, @@ -1350,6 +1353,12 @@ enum RuntimeHelperResolution { } pub struct PlatformExprStrategy { + protection_fill_count: usize, + protection_last_buys: BTreeMap, + protection_last_sells: BTreeMap, + automatic_trade_permissions: BTreeMap, + external_automatic_trade_evidence: Option<(NaiveDate, BTreeMap, crate::TradingCalendar)>, + automatic_holding_days: BTreeMap, pattern_results_date: RefCell>, pattern_results: RefCell>, pattern_contexts: RefCell>, @@ -1614,7 +1623,10 @@ impl PlatformExprStrategy { } } - pub fn new(config: PlatformExprStrategyConfig) -> Self { + pub fn new(mut config: PlatformExprStrategyConfig) -> Self { + if config.automatic_trade_protection.max_holding_days > 0 && config.max_holding_days.is_none() { + config.max_holding_days = Some(i64::from(config.automatic_trade_protection.max_holding_days)); + } let mut engine = Engine::new(); engine.set_fast_operators(false); engine.set_fail_on_invalid_map_property(true); @@ -1762,6 +1774,12 @@ impl PlatformExprStrategy { Self { config, engine, + protection_fill_count: 0, + protection_last_buys: BTreeMap::new(), + protection_last_sells: BTreeMap::new(), + automatic_trade_permissions: BTreeMap::new(), + external_automatic_trade_evidence: None, + automatic_holding_days: BTreeMap::new(), rebalance_day_counter: 0, last_rebalance_date: None, last_target_selection: None, @@ -2197,7 +2215,37 @@ impl PlatformExprStrategy { } matches!( name, - "signal_close" + "trade_date" + | "current_date" + | "date" + | "decision_date" + | "execution_date" + | "signal_open" + | "benchmark_open" + | "has_dynamic_universe" + | "dynamic_universe_count" + | "has_subscriptions" + | "subscription_count" + | "subscription_guard_required" + | "free_float_cap_or_market_cap" + | "turnover" + | "minimum_order_quantity" + | "order_step_size" + | "in_dynamic_universe" + | "is_subscribed" + | "stock_volume_ma5" + | "stock_volume_ma10" + | "stock_volume_ma20" + | "stock_volume_ma60" + | "stock_volume_ma100" + | "volume_ma5" + | "volume_ma10" + | "volume_ma20" + | "volume_ma60" + | "volume_ma100" + | "available_sellable_qty" + | "reserved_open_sell_qty" + | "signal_close" | "benchmark_close" | "benchmark_signal_close" | "signal_ma5" @@ -2568,6 +2616,11 @@ impl PlatformExprStrategy { } fn max_holding_days_exceeded(&self, symbol: &str) -> Option { + if self.config.automatic_trade_protection.max_holding_days > 0 { + return self.automatic_trade_permissions.get(symbol) + .filter(|permission| permission.max_holding_exit) + .and_then(|_| self.automatic_holding_days.get(symbol).copied()); + } let max_days = self.config.max_holding_days.filter(|value| *value > 0)?; let holding_days = *self.position_holding_days.get(symbol)?; (holding_days >= max_days).then_some(holding_days) @@ -3405,6 +3458,9 @@ impl PlatformExprStrategy { let position = projected.position(symbol)?; let current_qty = position.quantity; let sellable_qty = position.sellable_qty(date); + if self.automatic_trade_permissions.get(symbol).is_some_and(|permission| permission.sell_denial.is_some()) { + return None; + } let quantity = current_qty.min(sellable_qty); if quantity == 0 { return None; @@ -3549,6 +3605,9 @@ impl PlatformExprStrategy { let minimum_order_quantity = self.projected_minimum_order_quantity(ctx, symbol); let order_step_size = self.projected_order_step_size(ctx, symbol); let sellable_qty = projected.position(symbol)?.sellable_qty(date); + if self.automatic_trade_permissions.get(symbol).is_some_and(|permission| permission.sell_denial.is_some()) { + return None; + } if sellable_qty == 0 { return None; } @@ -6186,7 +6245,8 @@ impl PlatformExprStrategy { .filter(|identifier| !normalized_identifiers.contains(*identifier)), ); for identifier in factor_identifiers { - if Self::is_reserved_scope_name(identifier.as_str()) + if scope.contains(identifier) + || Self::is_reserved_scope_name(identifier.as_str()) || self.prelude_declared_identifiers.contains(identifier) || (!self.stock_extra_factor_identifiers.contains(identifier) && !item.extra_factors.contains_key(identifier.as_str()) @@ -10729,6 +10789,9 @@ impl PlatformExprStrategy { symbol: &str, execution_time: Option, ) -> bool { + if self.automatic_trade_permissions.get(symbol).is_some_and(|permission| permission.sell_denial.is_some()) { + return false; + } let Some(position) = ctx.portfolio.position(symbol) else { return false; }; @@ -10768,6 +10831,9 @@ impl PlatformExprStrategy { symbol: &str, _stock: &StockExpressionState, ) -> Result, BacktestError> { + if let Some(reason) = self.automatic_trade_permissions.get(symbol).and_then(|permission| permission.buy_denial) { + return Ok(Some(reason.into())); + } let market = ctx.data.require_market(date, symbol)?; let candidate = ctx.data.require_candidate(date, symbol)?; @@ -12227,6 +12293,7 @@ impl Strategy for PlatformExprStrategy { } fn before_trading(&mut self, ctx: &StrategyContext<'_>) -> Result<(), BacktestError> { + self.sync_automatic_trade_protection(ctx)?; let Some(config) = self.config.portfolio_loss_control.clone() else { return Ok(()); }; if ctx.futures_account.is_some() { return Err(BacktestError::Execution("portfolio loss control currently requires equity-only accounting".to_owned())); @@ -12342,6 +12409,7 @@ impl Strategy for PlatformExprStrategy { ctx: &StrategyContext<'_>, rule: &ScheduleRule, ) -> Result { + self.sync_automatic_trade_protection(ctx)?; let mut decision = if self.config.explicit_actions.is_empty() { StrategyDecision::default() } else { @@ -12361,7 +12429,7 @@ impl Strategy for PlatformExprStrategy { self.executing_scheduled_rotation = false; decision.merge_from(rotation?); } - self.attach_buy_denials(ctx, &mut decision)?; + self.attach_buy_denials(ctx, &mut decision, true)?; self.append_pattern_diagnostics(&mut decision); Ok(decision) } @@ -12388,6 +12456,7 @@ impl Strategy for PlatformExprStrategy { &mut self, ctx: &StrategyContext<'_>, ) -> Result, BacktestError> { + self.sync_automatic_trade_protection(ctx)?; let mut symbols = ctx .portfolio .positions() @@ -12407,13 +12476,14 @@ impl Strategy for PlatformExprStrategy { &mut self, ctx: &StrategyContext<'_>, ) -> Result { + self.sync_automatic_trade_protection(ctx)?; if self.config.explicit_action_stage == PlatformExplicitActionStage::OpenAuction && !self.config.explicit_actions.is_empty() && self.config.explicit_action_schedule.is_none() && self.unscheduled_explicit_actions_are_due(ctx.decision_date, ctx.execution_date) { let mut decision = self.explicit_action_decision(ctx)?; - self.attach_buy_denials(ctx, &mut decision)?; + self.attach_buy_denials(ctx, &mut decision, true)?; self.append_pattern_diagnostics(&mut decision); return Ok(decision); } @@ -12421,15 +12491,52 @@ impl Strategy for PlatformExprStrategy { } fn on_day(&mut self, ctx: &StrategyContext<'_>) -> Result { + self.sync_automatic_trade_protection(ctx)?; let mut decision = self.compute_day_decision(ctx)?; - self.attach_buy_denials(ctx, &mut decision)?; + let expiry_due = !(self.config.signal_book.is_some() && self.config.explicit_action_schedule.is_some()) + && !(self.config.rotation_enabled && self.config.rebalance_schedule.as_ref().and_then(|schedule|schedule.time_rule.as_ref()).is_some() && !self.executing_scheduled_rotation); + self.attach_buy_denials(ctx, &mut decision, expiry_due)?; self.append_pattern_diagnostics(&mut decision); Ok(decision) } } impl PlatformExprStrategy { - fn attach_buy_denials(&self, ctx: &StrategyContext<'_>, decision: &mut StrategyDecision) -> Result<(), BacktestError> { + fn attach_buy_denials(&self, ctx: &StrategyContext<'_>, decision: &mut StrategyDecision, expiry_due: bool) -> Result<(), BacktestError> { + let expired = self.automatic_trade_permissions.iter().filter(|(_, permission)| expiry_due && permission.max_holding_exit) + .map(|(symbol, _)| symbol.clone()).collect::>(); + if !expired.is_empty() { + // A configured full exit is a final target, not an additional + // partial sell appended after another action for the same stock. + let mut portfolio_target = false; + decision.order_intents.retain_mut(|intent| { + let (keep, portfolio) = Self::apply_maximum_holding_target(intent, &expired); + portfolio_target |= portfolio; + keep + }); + if !portfolio_target { + let exits = expired.iter().map(|symbol| OrderIntent::TargetValue { + symbol:symbol.clone(), target_value:0.0, reason:"max_holding_days_exit".into(), + }); + decision.order_intents.splice(0..0, exits); + } + } + for (symbol, permission) in &self.automatic_trade_permissions { + for (scope, reason) in [(crate::risk_control::RiskCheckScope::Buy, permission.buy_denial), (crate::risk_control::RiskCheckScope::Sell, permission.sell_denial)] { + if let Some(reason) = reason { + if scope == crate::risk_control::RiskCheckScope::Buy { + decision.buy_denials.entry(symbol.clone()).and_modify(|value| { value.push_str("; "); value.push_str(reason); }).or_insert_with(|| reason.into()); + } + decision.risk_decisions.push(FidcRiskDecisionAudit { + date: ctx.execution_date, symbol: symbol.clone(), scope, + stage: "automatic_trade_protection".into(), accepted: false, + rule_code: reason.into(), reason: reason.into(), + config_version: Some("strategy_automatic_trade_protection_v1".into()), + data_epoch: ctx.execution_date.to_string(), selection_batch_id: None, order_id: None, + }); + } + } + } if self.config.signal_book.is_none() && self.config.buy_filter_expr.trim().is_empty() { return Ok(()); } @@ -12468,6 +12575,89 @@ impl PlatformExprStrategy { Ok(()) } + fn apply_maximum_holding_target(intent: &mut OrderIntent, expired: &BTreeSet) -> (bool, bool) { + match intent { + OrderIntent::WithTimeInForce { intent, .. } => Self::apply_maximum_holding_target(intent, expired), + OrderIntent::TargetPortfolioSmart { target_weights, .. } => { + for symbol in expired { target_weights.insert(symbol.clone(), 0.0); } + (true, true) + } + OrderIntent::Shares {symbol,..} | OrderIntent::LimitShares {symbol,..} + | OrderIntent::Lots {symbol,..} | OrderIntent::LimitLots {symbol,..} + | OrderIntent::TargetShares {symbol,..} | OrderIntent::LimitTargetShares {symbol,..} + | OrderIntent::TargetValue {symbol,..} | OrderIntent::LimitTargetValue {symbol,..} + | OrderIntent::TimedTargetValue {symbol,..} | OrderIntent::Value {symbol,..} + | OrderIntent::LimitValue {symbol,..} | OrderIntent::Percent {symbol,..} + | OrderIntent::LimitPercent {symbol,..} | OrderIntent::TargetPercent {symbol,..} + | OrderIntent::LimitTargetPercent {symbol,..} | OrderIntent::AlgoValue {symbol,..} + | OrderIntent::AlgoPercent {symbol,..} => (!expired.contains(symbol), false), + _ => (true, false), + } + } + + /// Online adapters must validate the account, quantity, policy and snapshot + /// identity before injecting actual fill evidence into a rebuilt strategy. + pub fn set_external_automatic_trade_evidence(&mut self, execution_date: NaiveDate, facts: BTreeMap, calendar: crate::TradingCalendar) { + self.external_automatic_trade_evidence = Some((execution_date, facts, calendar)); + } + + fn sync_automatic_trade_protection(&mut self, ctx: &StrategyContext<'_>) -> Result<(), BacktestError> { + let policy = &self.config.automatic_trade_protection; + if !policy.enabled() { return Ok(()); } + policy.validate().map_err(BacktestError::Execution)?; + if ctx.futures_account.is_some() { + return Err(BacktestError::Execution("automatic_trade_protection_requires_equity_position_evidence".into())); + } + if let Some((date, facts, calendar)) = &self.external_automatic_trade_evidence { + if *date != ctx.execution_date { return Err(BacktestError::Execution("automatic_trade_protection_external_date_changed".into())); } + self.automatic_trade_permissions.clear(); + self.automatic_holding_days.clear(); + for symbol in ctx.portfolio.positions().keys() { + if !facts.contains_key(symbol) { return Err(BacktestError::Execution(format!("automatic_trade_protection_position_fact_missing:{symbol}"))); } + } + for (symbol, fact) in facts { + let permission = policy.evaluate(symbol, *date, fact, calendar).map_err(BacktestError::Execution)?; + self.automatic_trade_permissions.insert(symbol.clone(), permission); + if let Some(opened) = fact.opened_date + && let (Some(start), Some(end)) = (calendar.index_of(opened), calendar.index_of(*date)) { + self.automatic_holding_days.insert(symbol.clone(), end.saturating_sub(start) as i64); + } + } + return Ok(()); + } + if ctx.fills.len() < self.protection_fill_count { + return Err(BacktestError::Execution("automatic_trade_fill_history_rewound".into())); + } + for fill in &ctx.fills[self.protection_fill_count..] { + if fill.quantity == 0 { continue; } + let date = fill.execution_date.unwrap_or(fill.date); + if date > ctx.execution_date { return Err(BacktestError::Execution("automatic_trade_future_fill".into())); } + let values = match fill.side { OrderSide::Buy => &mut self.protection_last_buys, OrderSide::Sell => &mut self.protection_last_sells }; + values.entry(fill.symbol.clone()).and_modify(|previous| *previous = (*previous).max(date)).or_insert(date); + } + self.protection_fill_count = ctx.fills.len(); + let mut symbols = ctx.portfolio.positions().keys().cloned().collect::>(); + symbols.extend(self.protection_last_sells.keys().cloned()); + symbols.extend(policy.locks.iter().map(|lock| lock.symbol.clone())); + self.automatic_trade_permissions.clear(); + self.automatic_holding_days.clear(); + for symbol in symbols { + let position = ctx.portfolio.position(&symbol).filter(|position| position.quantity > 0); + let evidence = HoldingLifecycleEvidence { + has_position: position.is_some(), opened_date: position.and_then(|position| position.opened_date()), + last_buy_date: self.protection_last_buys.get(&symbol).copied().into_iter().chain(position.and_then(|position|position.last_buy_date())).max(), + last_sell_date: self.protection_last_sells.get(&symbol).copied(), + }; + let permission = policy.evaluate(&symbol, ctx.execution_date, &evidence, ctx.data.calendar()).map_err(BacktestError::Execution)?; + if let Some(opened) = evidence.opened_date + && let (Some(start), Some(end)) = (ctx.data.calendar().index_of(opened), ctx.data.calendar().index_of(ctx.execution_date)) { + self.automatic_holding_days.insert(symbol.clone(), end.saturating_sub(start) as i64); + } + self.automatic_trade_permissions.insert(symbol, permission); + } + Ok(()) + } + fn append_pattern_diagnostics(&self, decision: &mut StrategyDecision) { let mut contexts=BTreeMap::::new(); for ((date, symbol, spec), result) in self.pattern_results.borrow().iter() { @@ -12955,7 +13145,7 @@ impl PlatformExprStrategy { .iter() .cloned() .collect::>(); - if self.config.rotation_enabled + if (self.config.rotation_enabled || self.config.automatic_trade_protection.max_holding_days > 0) && let Some(max_holding_days) = self.config.max_holding_days.filter(|value| *value > 0) { for position in ctx.portfolio.positions().values() { @@ -14384,7 +14574,7 @@ mod tests { 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(); + let error = strategy.attach_buy_denials(&ctx, &mut decision, true).unwrap_err(); assert!(error.to_string().contains("buy condition quote unavailable"), "{error}"); assert_eq!(strategy.selection_quote_usage, StockFilterQuoteUsage::DailyOnly); } @@ -14440,7 +14630,7 @@ mod tests { ctx.active_datetime = Some(date.and_hms_opt(hour, minute, 0).unwrap()); let mut decision = crate::StrategyDecision::default(); decision.order_intents.push(OrderIntent::TargetValue { symbol: symbol.to_string(), target_value: 10_000.0, reason: "target".to_string() }); - strategy.attach_buy_denials(&ctx, &mut decision).unwrap(); + strategy.attach_buy_denials(&ctx, &mut decision, true).unwrap(); assert_eq!(decision.buy_denials.contains_key(symbol), denied); } } diff --git a/crates/fidc-core/src/platform_strategy_spec.rs b/crates/fidc-core/src/platform_strategy_spec.rs index 6078dad..a9616b4 100644 --- a/crates/fidc-core/src/platform_strategy_spec.rs +++ b/crates/fidc-core/src/platform_strategy_spec.rs @@ -664,7 +664,7 @@ fn normalize_strategy_aliases_in_value_inner( for (key, child) in object.iter_mut() { normalize_strategy_aliases_in_value_inner( child, - in_risk_policy || matches!(key.as_str(), "riskPolicy" | "risk_policy"), + in_risk_policy || matches!(key.as_str(), "riskPolicy" | "risk_policy" | "automaticTradeProtection" | "automatic_trade_protection"), )?; } } @@ -686,6 +686,7 @@ const STRATEGY_ALIAS_GROUPS: &[(&str, &[&str])] = &[ ("signalSymbol", &["signal_symbol"]), ("engineConfig", &["engine_config"]), ("runtimeExpressions", &["runtime_expressions"]), + ("automaticTradeProtection", &["automatic_trade_protection"]), ("rebalanceSchedule", &["rebalance_schedule"]), ("skipWindows", &["skip_windows"]), ("dynamicRange", &["dynamic_range"]), @@ -1004,6 +1005,8 @@ pub struct StrategyExpressionOrderingConfig { #[derive(Debug, Clone, Default, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct StrategyExpressionTradingConfig { + #[serde(default, alias = "automatic_trade_protection")] + pub automatic_trade_protection: Option, #[serde(default, alias = "buy_filter_expr")] pub buy_filter_expr: Option, #[serde(default)] @@ -2330,6 +2333,10 @@ pub fn platform_expr_config_from_spec( } } if let Some(trading) = runtime_expr.trading.as_ref() { + if let Some(policy) = &trading.automatic_trade_protection { + policy.validate()?; + cfg.automatic_trade_protection = policy.clone(); + } if let Some(expr) = trading.buy_filter_expr.as_ref() { cfg.buy_filter_expr = expr.clone(); } @@ -2639,6 +2646,14 @@ pub fn platform_expr_config_from_spec( return Err("consume_signal_requires_verified_signal_book".into()); } + let has_automatic_policy = spec.runtime_expressions.as_ref().and_then(|runtime| runtime.trading.as_ref()).is_some_and(|trading| trading.automatic_trade_protection.is_some()); + if has_automatic_policy { + let limit = i64::from(cfg.automatic_trade_protection.max_holding_days); + if cfg.max_holding_days.is_some_and(|previous| previous != limit) { + return Err("conflicting maximum holding policies".into()); + } + cfg.max_holding_days = (limit > 0).then_some(limit); + } Ok(cfg) } diff --git a/crates/fidc-core/src/portfolio.rs b/crates/fidc-core/src/portfolio.rs index ef3c329..e21befd 100644 --- a/crates/fidc-core/src/portfolio.rs +++ b/crates/fidc-core/src/portfolio.rs @@ -60,6 +60,8 @@ pub struct PositionLot { pub struct Position { pub symbol: String, pub quantity: u32, + opened_date: Option, + last_buy_date: Option, // ALV-compatible moving average execution price; partial sells do not rebase it. pub average_price: f64, // ALV-compatible moving average including buy costs; partial sells do not rebase it. @@ -88,6 +90,8 @@ impl Position { Self { symbol: symbol.into(), quantity: 0, + opened_date: None, + last_buy_date: None, average_price: 0.0, average_cost: 0.0, last_price: 0.0, @@ -114,6 +118,12 @@ impl Position { self.quantity == 0 } + pub fn opened_date(&self) -> Option { + self.opened_date + } + + pub fn last_buy_date(&self) -> Option { self.last_buy_date } + pub fn buy(&mut self, date: NaiveDate, quantity: u32, price: f64) { self.buy_with_mark_price(date, quantity, price, price); } @@ -130,6 +140,10 @@ impl Position { } let previous_quantity = self.quantity; + self.last_buy_date = Some(self.last_buy_date.map_or(date, |previous| previous.max(date))); + if previous_quantity == 0 { + self.opened_date = Some(date); + } let previous_average_price = self.average_price; let previous_average_cost = self.average_cost; let gross_amount = fixed_money_or_panic( @@ -267,6 +281,7 @@ impl Position { .checked_add(total_proceeds) .ok_or_else(|| "fixed-point day sell value overflow".to_string())?; if self.quantity == 0 { + self.opened_date = None; self.average_price = 0.0; self.recalculate_average_cost(); } else { @@ -1224,6 +1239,8 @@ impl PortfolioState { } let old_quantity = old_position.quantity; + let old_opened_date = old_position.opened_date; + let old_last_buy_date = old_position.last_buy_date; let last_price = old_position.last_price; let old_average_price = old_position.average_price; let old_average_cost = old_position.average_cost; @@ -1263,6 +1280,14 @@ impl PortfolioState { .entry(new_symbol.to_string()) .or_insert_with(|| Position::new(new_symbol)); let successor_quantity_before = successor.quantity; + successor.opened_date = match (successor.opened_date, old_opened_date) { + (Some(current), Some(previous)) => Some(current.min(previous)), + (current, previous) => current.or(previous), + }; + successor.last_buy_date = match (successor.last_buy_date, old_last_buy_date) { + (Some(current), Some(previous)) => Some(current.max(previous)), + (current, previous) => current.or(previous), + }; let successor_average_price_before = successor.average_price; let successor_average_cost_before = successor.average_cost; successor.lots.extend(converted_lots); diff --git a/crates/fidc-core/src/strategy_ai.rs b/crates/fidc-core/src/strategy_ai.rs index 2b711b1..1fd4e17 100644 --- a/crates/fidc-core/src/strategy_ai.rs +++ b/crates/fidc-core/src/strategy_ai.rs @@ -264,6 +264,10 @@ pub fn built_in_strategy_manual() -> StrategyAiManual { title: "期货 runtime action 与提交校验".to_string(), detail: "runtimeExpressions.trading.actions 支持 futures_order、futures_open、futures_close、futures_close_today、futures_close_yesterday;字段包括 symbol、direction=long|short、quantityExpr/amountExpr、可选 limitPriceExpr、transactionCostExpr、whenExpr 和 reason。期货-only 策略把请求初始资金分配给期货账户且股票账户为0;股票+期货混合策略必须显式声明 futuresInitialCash,可选 stockInitialCash。合约必须先由 Source Lake 发布 futures_contract_daily、futures_contract_spec_history、futures_cost_margin_history 三张真实数据集;缺任一张时生成/回测必须失败,禁止手写默认乘数、保证金、费用或价格。订单进入撮合前继续检查上市/退市日期、停牌、trading_phase、限价 tick、涨跌停、反向挂单自成交、保证金和可平今昨仓。".to_string(), }, + ManualSection { + title: "trading.automatic_trade_protection(...)".to_string(), + detail: r#"当前股票/ETF策略的独立自动交易保护:trading.automatic_trade_protection({"buy_protection_days":3,"sell_cooldown_days":3,"max_holding_days":90,"locks":[{"symbol":"000001.SZ","start_date":"2026-09-11","end_date":null}]})。配置冻结到 runtimeExpressions.trading.automaticTradeProtection,回测、paper/live 共用内核;不并入全局风控。0/null/未填关闭对应周期;成交日及之后N个完整正式交易日内,买入保护禁止自动卖出及止盈止损,卖出冷却禁止自动增加仓位;只由真实成交启动或延长,拒绝/未成交/撤单不启动。最长持有按首次实际建仓后的正式交易日计数,加仓与部分卖出不重置,清仓后再开仓重置;日期锁定两端包含且高于自动退出,持仓占用真实预算和槽位。人工交易通过独立服务路径执行,仍校验权限、券商及T+1,不接受客户端origin旁路。持仓来源、实际成交或正式日历缺失时明确拒绝;期货与股票期货混合账户尚不支持此能力,不得悄悄忽略。旧trading.max_holding_days仍保留旧含义,不得和新配置声明不同最大周期。"#.to_string(), + }, ManualSection { title: "trading.rotation / order.* / order.modify / cancel.* / update_universe / subscribe".to_string(), detail: "支持股票显式下单、期货 runtime action、撤单、AlgoOrder、动态 universe 和账户资金动作。可以用 trading.rotation(false) 关闭默认轮动链路,再用 trading.stage(\"open_auction\" | \"on_day\") 指定执行阶段;需要模拟 平台内核 的日内订阅保护时,可写 trading.subscription_guard(true),未订阅 symbol 的显式订单会被拦截,TargetPortfolioSmart + AlgoOrder 会过滤未订阅标的。用 trading.schedule.daily().at([\"10:18\"]) / trading.schedule.weekly(weekday=5).at([\"10:18\"]) / trading.schedule.weekly(tradingday=-1).at([\"10:18\"]) / trading.schedule.monthly(tradingday=1).at([\"10:18\"]) 指定触发频率和分钟级 time_rule,然后写 order.shares(\"600000.SH\", 1000)、order.target_shares(\"600000.SH\", 2000)、order.value(\"600000.SH\", cash * 0.25)、order.target_percent(\"600000.SH\", 0.05)、order.limit_value(\"600000.SH\", cash * 0.25, open * 0.99, time_in_force=\"gtc\")、order.vwap_value(\"600000.SH\", cash * 0.25, \"09:31\", \"09:40\")、order.twap_percent(\"600000.SH\", 0.05, \"10:00\", \"10:30\")、order.target_portfolio_smart(weights={\"600000.SH\": 0.3, \"000001.SZ\": 0.2}, order_prices=VWAPOrder(930, 940), valuation_prices={\"600000.SH\": prev_close})、cancel.order(12345)、cancel.symbol(\"600000.SH\")、cancel.all()、update_universe([\"600000.SH\", \"000001.SZ\"])、subscribe([\"000001.SZ\"])、unsubscribe([\"000001.SZ\"])、account.deposit_withdraw(100000, receiving_days=0)、account.finance_repay(50000)、account.set_management_fee_rate(0.001)。股票订单和 target_portfolio_smart 支持可选关键字 time_in_force=\"day|ioc|fok|gtc\",编译后写入 runtimeExpressions.trading.actions[].timeInForce:DAY 日内保留并在收盘 Expired,IOC 立即撤销未成交余量,FOK 必须全量可成交否则零成交,GTC 仅支持限价单并跨交易日保留;VWAP/TWAP 不接受 FOK/GTC。期货 action 必须由编译器写入结构化 runtimeExpressions,不得让策略源码直接构造 FuturesOrderIntent 或硬编码合约参数。symbol 使用标准证券/合约代码;数量、金额、仓位、时间窗、限价、order_id 和 symbol 列表都支持表达式;这些语句也支持放进 when/unless 条件块。".to_string(), diff --git a/crates/fidc-core/tests/automatic_trade_protection.rs b/crates/fidc-core/tests/automatic_trade_protection.rs new file mode 100644 index 0000000..20c5af5 --- /dev/null +++ b/crates/fidc-core/tests/automatic_trade_protection.rs @@ -0,0 +1,336 @@ +use chrono::NaiveDate; +use fidc_core::holding_policy::{AutomaticTradeLock, AutomaticTradeProtection}; +use fidc_core::{ + BacktestConfig, BacktestEngine, BenchmarkSnapshot, BrokerSimulator, CandidateEligibility, + ChinaAShareCostModel, ChinaEquityRuleHooks, DailyMarketSnapshot, DataSet, Instrument, + MatchingType, OrderSide, PlatformExplicitOrderKind, PlatformExprStrategy, + PlatformExprStrategyConfig, PlatformTradeAction, PriceField, +}; + +fn d(day: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(2026, 9, day).unwrap() +} +fn data() -> DataSet { + let dates = [11, 14, 15, 16, 17, 18].map(d); + DataSet::from_components( + vec![Instrument { + symbol: "000001.SZ".into(), + name: "测试".into(), + board: "SZ".into(), + round_lot: 100, + listed_at: Some(NaiveDate::from_ymd_opt(2020, 1, 1).unwrap()), + delisted_at: None, + status: "active".into(), + }], + dates + .iter() + .map(|date| DailyMarketSnapshot { + date: *date, + symbol: "000001.SZ".into(), + timestamp: Some(format!("{date} 15:00:00")), + day_open: 10.0, + open: 10.0, + high: 10.0, + low: 10.0, + close: 10.0, + last_price: 10.0, + bid1: 10.0, + ask1: 10.0, + prev_close: 10.0, + volume: 100_000, + minute_volume: 100_000, + bid1_volume: 100_000, + ask1_volume: 100_000, + trading_phase: Some("continuous".into()), + paused: false, + upper_limit: 11.0, + lower_limit: 9.0, + price_tick: 0.01, + }) + .collect(), + dates + .iter() + .map(|date| fidc_core::DailyFactorSnapshot { + date: *date, + symbol: "000001.SZ".into(), + market_cap_bn: 10.0, + free_float_cap_bn: 10.0, + pe_ttm: 10.0, + turnover_ratio: None, + effective_turnover_ratio: None, + adjustment_factor_backward1: Some(1.0), + extra_factors: Default::default(), + }) + .collect(), + dates + .iter() + .map(|date| CandidateEligibility { + date: *date, + symbol: "000001.SZ".into(), + is_st: false, + is_star_st: false, + is_new_listing: false, + is_paused: false, + allow_buy: true, + allow_sell: true, + is_kcb: false, + is_one_yuan: false, + risk_level_code: None, + }) + .collect(), + dates + .iter() + .map(|date| BenchmarkSnapshot { + date: *date, + benchmark: "000300.SH".into(), + open: 100.0, + close: 100.0, + prev_close: 100.0, + volume: 1_000_000, + }) + .collect(), + ) + .unwrap() +} +fn action(quantity: &str, when: &str) -> PlatformTradeAction { + PlatformTradeAction::Order { + kind: PlatformExplicitOrderKind::Shares, + symbol: "000001.SZ".into(), + amount_expr: quantity.into(), + when_expr: Some(when.into()), + limit_price_expr: None, + time_in_force: None, + start_time_expr: None, + end_time_expr: None, + reason: "configured_strategy_action".into(), + } +} +fn run(policy: AutomaticTradeProtection) -> fidc_core::BacktestResult { + let mut config = PlatformExprStrategyConfig::generic(); + config.signal_symbol = "000001.SZ".into(); + config.benchmark_symbol = "000300.SH".into(); + config.rotation_enabled = false; + config.automatic_trade_protection = policy; + config.explicit_actions = vec![ + action( + "100", + "decision_date == \"2026-09-11\" || decision_date == \"2026-09-18\"", + ), + action("-100", "decision_date >= \"2026-09-14\""), + ]; + config.matching_type = MatchingType::CurrentBarClose; + let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks) + .with_matching_type(MatchingType::CurrentBarClose); + BacktestEngine::new( + data(), + PlatformExprStrategy::new(config), + broker, + BacktestConfig { + initial_cash: 10_000.0, + benchmark_code: "000300.SH".into(), + start_date: Some(d(11)), + end_date: Some(d(18)), + decision_lag_trading_days: 0, + execution_price_field: PriceField::Close, + }, + ) + .run() + .unwrap() +} + +#[test] +fn framework_protection_uses_fills_and_covers_explicit_strategy_orders() { + let result = run(AutomaticTradeProtection { + buy_protection_days: 3, + sell_cooldown_days: 3, + ..Default::default() + }); + assert_eq!( + result + .fills + .iter() + .map(|fill| (fill.date, fill.side, fill.quantity)) + .collect::>(), + vec![(d(11), OrderSide::Buy, 100), (d(17), OrderSide::Sell, 100)] + ); + assert!(!result.order_events.iter().any(|order| order.date == d(14) + || order.date == d(15) + || order.date == d(16) + || order.date == d(18))); +} + +#[test] +fn absolute_lock_blocks_initial_strategy_buy_without_a_rejected_order() { + let result = run(AutomaticTradeProtection { + locks: vec![AutomaticTradeLock { + symbol: "000001.SZ".into(), + start_date: d(11), + end_date: None, + }], + ..Default::default() + }); + assert!(result.fills.is_empty()); + assert!(result.order_events.is_empty()); +} + +#[test] +fn maximum_holding_policy_applies_to_discrete_strategies_and_yields_to_buy_protection() { + let result = run(AutomaticTradeProtection { + max_holding_days: 1, + buy_protection_days: 3, + sell_cooldown_days: 3, + ..Default::default() + }); + assert_eq!( + result + .fills + .iter() + .map(|fill| (fill.date, fill.side)) + .collect::>(), + vec![(d(11), OrderSide::Buy), (d(17), OrderSide::Sell)] + ); + assert!( + result + .order_events + .iter() + .any(|order| order.reason == "max_holding_days_exit") + ); +} + +#[test] +fn serialized_framework_policy_survives_shared_alias_normalization_and_rejects_conflicts() { + let policy = serde_json::json!({"buy_protection_days":3,"sell_cooldown_days":3,"max_holding_days":90,"locks":[{"symbol":"000001.SZ","start_date":"2026-09-11","end_date":null}]}); + for key in ["automaticTradeProtection", "automatic_trade_protection"] { + let value = serde_json::json!({"runtimeExpressions":{"trading":{key:policy}}}); + let cfg = fidc_core::platform_expr_config_from_value("test", "000001.SZ", &value).unwrap(); + assert_eq!(cfg.automatic_trade_protection.buy_protection_days, 3); + assert_eq!(cfg.max_holding_days, Some(90)); + assert_eq!(cfg.automatic_trade_protection.locks.len(), 1); + } + let conflict = serde_json::json!({"runtimeExpressions":{"trading":{"maxHoldingDays":30,"automaticTradeProtection":policy}}}); + assert!( + fidc_core::platform_expr_config_from_value("test", "000001.SZ", &conflict) + .unwrap_err() + .to_string() + .contains("conflicting maximum") + ); + let unknown = serde_json::json!({"runtimeExpressions":{"trading":{"automaticTradeProtection":{"origin":"manual"}}}}); + assert!(fidc_core::platform_expr_config_from_value("test", "000001.SZ", &unknown).is_err()); +} + +#[test] +fn locked_holding_keeps_its_slot_even_when_cash_can_buy_the_next_candidate() { + let base = data(); + let dates = [11, 14, 15, 16, 17, 18].map(d); + let symbols = ["000001.SZ", "000002.SZ"]; + let dataset = DataSet::from_components( + symbols + .iter() + .map(|symbol| { + let mut row = base.instruments()["000001.SZ"].clone(); + row.symbol = (*symbol).into(); + row + }) + .collect(), + dates + .iter() + .flat_map(|date| { + symbols.iter().map(|symbol| { + let mut row = base.market(*date, "000001.SZ").unwrap().clone(); + row.symbol = (*symbol).into(); + row + }) + }) + .collect(), + dates + .iter() + .flat_map(|date| { + symbols.iter().map(|symbol| { + let mut row = base.factor(*date, "000001.SZ").unwrap().clone(); + row.symbol = (*symbol).into(); + row + }) + }) + .collect(), + dates + .iter() + .flat_map(|date| { + symbols.iter().map(|symbol| { + let mut row = base.candidate(*date, "000001.SZ").unwrap().clone(); + row.symbol = (*symbol).into(); + row + }) + }) + .collect(), + dates + .iter() + .map(|date| BenchmarkSnapshot { + date: *date, + benchmark: "000300.SH".into(), + open: 100.0, + close: 100.0, + prev_close: 100.0, + volume: 100_000, + }) + .collect(), + ) + .unwrap(); + let mut config = PlatformExprStrategyConfig::generic(); + config.signal_symbol = "000001.SZ".into(); + config.benchmark_symbol = "000300.SH".into(); + config.strategy_name = "protection_test".into(); + config.max_positions = 1; + config.selection_limit_expr = "1".into(); + config.refresh_rate = 1; + config.exposure_expr = "0.5".into(); + config.market_cap_lower_expr = "0".into(); + config.market_cap_upper_expr = "100".into(); + config.stock_filter_expr="(decision_date == \"2026-09-11\" && symbol == \"000001.SZ\") || (decision_date != \"2026-09-11\" && symbol == \"000002.SZ\")".into(); + config.automatic_trade_protection = AutomaticTradeProtection { + locks: vec![AutomaticTradeLock { + symbol: "000001.SZ".into(), + start_date: d(14), + end_date: Some(d(16)), + }], + ..Default::default() + }; + let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks) + .with_matching_type(MatchingType::CurrentBarClose); + let result = BacktestEngine::new( + dataset, + PlatformExprStrategy::new(config), + broker, + BacktestConfig { + initial_cash: 10_000.0, + benchmark_code: "000300.SH".into(), + start_date: Some(d(11)), + end_date: Some(d(18)), + decision_lag_trading_days: 0, + execution_price_field: PriceField::Close, + }, + ) + .run() + .unwrap(); + assert_eq!( + result + .fills + .first() + .map(|fill| (fill.symbol.as_str(), fill.date)), + Some(("000001.SZ", d(11))) + ); + assert!( + !result + .fills + .iter() + .any(|fill| [d(14), d(15), d(16)].contains(&fill.date)), + "{:?}", + result.fills + ); + assert!( + result.fills.iter().any(|fill| fill.symbol == "000002.SZ" + && fill.side == OrderSide::Buy + && fill.date == d(17)), + "{:?}", + result.fills + ); +} diff --git a/docs/automatic-trade-protection-20260911.md b/docs/automatic-trade-protection-20260911.md new file mode 100644 index 0000000..9457af8 --- /dev/null +++ b/docs/automatic-trade-protection-20260911.md @@ -0,0 +1,25 @@ +# 策略级自动交易保护 + +## 统一合同 + +`runtimeExpressions.trading.automaticTradeProtection` 是每个股票/ETF策略自己的不可变配置。股票池、表达式轮动和显式订单复用 `holding_policy` 内核,不新增全局共享配置,也不修改未配置的历史策略。 + +```json +{"buy_protection_days":3,"sell_cooldown_days":3,"max_holding_days":90,"locks":[{"symbol":"000001.SZ","start_date":"2026-09-11","end_date":"2026-09-16"}]} +``` + +- 周期为空、null或0关闭,必须为0—3650整数;锁定支持同股多个区间,起止日包含当日,截止null持续有效。 +- 买入保护禁止自动减仓/清仓及止盈止损;卖出冷却禁止自动增加仓位。只有实际成交计时,部分成交延长对应最后成交日;未成交、拒绝、撤单不启动。 +- 成交日及后续N个完整正式交易日均受保护。例如周五成交、N=3,保护到下周三结束,周四恢复;不按72小时或自然日替代。 +- 最长持有从连续持仓第一次实际买入开始,跨正式交易日计数;加仓、部分卖出和有证据的证券转换不重置,完全卖出再买入开启新周期。锁定和买入保护优先于最长持有退出。 +- 日期锁定禁止自动买卖,已接受的挂单不自动撤销;手工路径只绕过自动策略保护,不绕过账户授权、T+1、券商和风控。 +- 保留的真实持仓继续占用资金与席位,不把未完成卖出当现金。最长持有退出先形成唯一最终目标,不能叠加一笔策略部分卖出和一笔框架全量卖出。 +- 在线上下文重建必须注入已经校验的真实成交/持仓快照,不能把重建日或旧行情日当建仓日。期货或股票期货混合账户未纳入本合同,显式拒绝。 + +## 根因补充修复 + +组合 `decision_date == "2026-09-11" && symbol == "000001.SZ"` 会落到字符串表达式路径。旧代码遗漏日期等内建标识符的保留登记,又按“额外因子”注入NaN,覆盖同名真实日期,造成选股错误。现登记全部已注入内建字段,并禁止额外因子覆盖已存在的作用域变量。单独数字VM日期测试不足以发现该问题,新增日期+证券混合选择回归。 + +## 验证与边界 + +原生完整回测测试验证:显式策略真实模拟成交日启动3日保护/禁买、日期锁定零委托、最长持有让位于保护、锁定持仓占据资金与席位、解锁后才按候选顺序买入;序列化和别名归一不改max_holding_days字段,冲突策略拒绝。现有534核心用例通过(6个既有忽略项)。这些是隔离内核测试,不是GT实际成交验收。