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 ); }