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(), } } #[test] fn observed_manual_trades_then_split_keep_real_fill_protection_and_lock_dates() { for sell_during_lock in [false, true] { let sale = if sell_during_lock { ("manual-sell", "Sell", "2026-09-16T01:31:00Z", "2026-09-16T01:31:01Z", "5", "0.5", 200) } else { ("manual-sell", "Sell", "2026-09-14T01:31:00Z", "2026-09-14T01:31:01Z", "10", "0.5", 100) }; let actions = [ ("new-buy", "Buy", "2026-09-14T01:30:00Z", "2026-09-14T01:30:01Z", "10", "0.25", 100), ("late-buy", "Buy", "2026-09-11T06:00:00Z", "2026-09-14T01:30:02Z", "10", "0.75", 100), sale, ].into_iter().enumerate().map(|(index, (id, side, executed, observed, price, fee, quantity))| { let executed: chrono::DateTime = executed.parse().unwrap(); let observed: chrono::DateTime = observed.parse().unwrap(); let created = executed - chrono::Duration::seconds(1); serde_json::json!({"actionId":id,"source":"manual_security_trade","auditEventIds":[format!("audit-{id}")], "confirmedAt":created,"confirmationObservedAt":created,"outcome":"orders_terminal","orders":[{ "orderId":id,"brokerOrderId":id,"sourceAdapter":"paper","symbol":"000001.SZ","side":side,"quantity":quantity, "orderCreatedAt":created,"terminalObservedAt":observed,"terminalStatus":"filled","fills":[{ "tradeId":id,"observationEventId":id,"observationSequence":index+1, "tradeDate":executed.date_naive(),"executedAt":executed,"observedAt":observed, "feeObservationEventId":id,"feeObservationSequence":index+1,"feeObservedAt":observed, "timestampPrecision":"second","quantity":quantity,"price":price,"totalFee":fee }] }]}) }).collect::>(); let mut replay: fidc_core::manual_execution::ManualExecutionReplay = serde_json::from_value(serde_json::json!({ "schema":fidc_core::manual_execution::MANUAL_REPLAY_SCHEMA,"runtimeId":"r","accountId":"a", "sourceContractSha256":"a".repeat(64),"contentSha256":"","observationCutoff":"2026-09-18T08:00:00Z","actions":actions, })).unwrap(); replay.content_sha256 = replay.content_digest().unwrap(); let mut parts = data().snapshot_components(); for row in &mut parts.market { if row.date >= d(15) { row.day_open = 5.; row.open = 5.; row.high = 5.; row.low = 5.; row.close = 5.; row.last_price = 5.; row.bid1 = 5.; row.ask1 = 5.; row.prev_close = 5.; row.upper_limit = 5.5; row.lower_limit = 4.5; } } parts.corporate_actions.push(fidc_core::CorporateAction { date: d(15), symbol: "000001.SZ".into(), payable_date: None, share_cash: 0., share_bonus: 1., share_gift: 0., issue_quantity: 0., issue_price: 0., reform: false, adjust_factor: None, successor_symbol: None, successor_ratio: None, successor_cash: None, }); let data = DataSet::from_components_with_actions( parts.instruments, parts.market, parts.factors, parts.candidates, parts.benchmarks, parts.corporate_actions, ) .unwrap(); let mut config = PlatformExprStrategyConfig::generic(); config.signal_symbol = "000001.SZ".into(); config.benchmark_symbol = "000300.SH".into(); config.rotation_enabled = false; config.matching_type = MatchingType::CurrentBarClose; config.volume_capacity_mode = fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit; config.automatic_trade_protection = AutomaticTradeProtection { buy_protection_days: 3, sell_cooldown_days: 3, max_holding_days: 1, locks: vec![AutomaticTradeLock { symbol: "000001.SZ".into(), start_date: d(16), end_date: Some(d(17)), }], }; config.explicit_actions = vec![action("-200", "decision_date >= \"2026-09-14\"")]; let result = BacktestEngine::new( data, PlatformExprStrategy::new(config), BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks) .with_matching_type(MatchingType::CurrentBarClose) .with_volume_capacity_mode( fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit, ), BacktestConfig { initial_cash: 10000., 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, }, ) .with_observed_manual_executions(replay) .unwrap() .run() .unwrap(); assert_eq!(result.manual_executions.len(), 3); assert_eq!(result.manual_executions[2].quantity_after, if sell_during_lock { 200 } else { 100 }); assert_eq!(result.fills.len(), 1, "{:?}", result.fills); assert_eq!( ( result.fills[0].date, result.fills[0].side, result.fills[0].quantity, result.fills[0].price ), (d(18), OrderSide::Sell, 200, 5.) ); assert!(result.fills[0].reason.contains("max_holding_days_exit")); for day in [14, 15] { for rule in ["buy_fill_protection", "sell_fill_cooldown"] { if rule == "sell_fill_cooldown" && sell_during_lock { continue; } assert!(result.risk_decisions.iter().any(|audit| audit.date == d(day) && audit.symbol == "000001.SZ" && audit.rule_code == rule && !audit.accepted), "day={day} rule={rule}"); } } for day in [16, 17] { assert!(result.risk_decisions.iter().any(|audit| audit.date == d(day) && audit.symbol == "000001.SZ" && audit.rule_code == "automatic_trade_locked" && !audit.accepted)); } assert!( result .daily_holdings .iter() .any(|row| row.date == d(15) && row.quantity == if sell_during_lock { 400 } else { 200 }) ); assert!(result.holdings_summary.is_empty()); assert!( result .equity_curve .iter() .all(|point| point.external_cash_flow == 0.) ); } } fn run(policy: AutomaticTradeProtection) -> fidc_core::BacktestResult { let mut config = PlatformExprStrategyConfig::generic(); config.volume_capacity_mode = fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit; 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_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit) .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.volume_capacity_mode = fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit; 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_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit) .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 ); }