From ba4b77fd746963687b8029b47806f943ed6ce03f Mon Sep 17 00:00:00 2001 From: boris Date: Mon, 14 Sep 2026 23:38:43 +0800 Subject: [PATCH] =?UTF-8?q?=E8=AE=A9=E5=AE=9E=E9=99=85=E6=8D=A2=E8=82=A1?= =?UTF-8?q?=E6=8C=81=E4=BB=93=E7=BB=A7=E6=89=BF=E5=8E=9F=E7=AD=96=E7=95=A5?= =?UTF-8?q?=E4=BF=9D=E6=8A=A4=E6=9C=9F=E9=99=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/fidc-core/src/broker.rs | 9 - crates/fidc-core/src/broker_stock_pool.rs | 109 ++++++++-- crates/fidc-core/src/holding_policy.rs | 17 +- .../fidc-core/src/platform_expr_strategy.rs | 17 +- crates/fidc-core/src/portfolio.rs | 32 ++- .../tests/manual_corporate_observation.rs | 199 ++++++++++++++++++ docs/automatic-trade-protection-20260911.md | 1 + docs/successor-protection-20260914.md | 30 +++ 8 files changed, 370 insertions(+), 44 deletions(-) create mode 100644 docs/successor-protection-20260914.md diff --git a/crates/fidc-core/src/broker.rs b/crates/fidc-core/src/broker.rs index 29781e9..9c25d75 100644 --- a/crates/fidc-core/src/broker.rs +++ b/crates/fidc-core/src/broker.rs @@ -2766,15 +2766,6 @@ where if execution.side == OrderSide::Sell { let date = execution.executed_at.with_timezone(&chrono::FixedOffset::east_opt(8 * 3600).unwrap()).date_naive(); self.mark_same_day_sold(date, &execution.symbol); - if let Some(adjustment) = &execution.corporate_adjustment { - for successor in adjustment - .actions - .iter() - .filter_map(|action| action.successor_symbol.as_deref()) - { - self.mark_same_day_sold(date, successor); - } - } } } diff --git a/crates/fidc-core/src/broker_stock_pool.rs b/crates/fidc-core/src/broker_stock_pool.rs index 79db478..f48aae0 100644 --- a/crates/fidc-core/src/broker_stock_pool.rs +++ b/crates/fidc-core/src/broker_stock_pool.rs @@ -69,7 +69,89 @@ fn pool_positions( .collect() } +#[cfg(test)] +mod successor_protection_tests { + use super::*; + use crate::holding_policy::{AutomaticTradeLock, AutomaticTradeProtection}; + fn day(n: u32) -> NaiveDate { NaiveDate::from_ymd_opt(2026, 9, n).unwrap() } + + #[test] + fn deferred_etf_open_rechecks_inherited_locks_and_cooldown_before_any_order() { + let old = "159915.SZ"; + let new = "159999.SZ"; + let data = DataSet::from_components( + [old, new].into_iter().map(|symbol| crate::Instrument { + symbol: symbol.into(), name: "isolated ETF fixture".into(), board: "ETF".into(), round_lot: 100, + listed_at: Some(day(1)), delisted_at: None, status: "active".into(), + }).collect(), vec![crate::DailyMarketSnapshot { + date: day(15), symbol: new.into(), timestamp: None, day_open: 5., open: 5., high: 5., low: 5., + close: 5., last_price: 5., bid1: 5., ask1: 5., prev_close: 5., volume: 100000, + minute_volume: 0, bid1_volume: 100000, ask1_volume: 100000, trading_phase: None, + paused: false, upper_limit: 5.5, lower_limit: 4.5, price_tick: 0.001, + }], vec![], vec![crate::CandidateEligibility { + date: day(15), symbol: new.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, + }], [11,14,15].into_iter().map(|n| crate::BenchmarkSnapshot { + date: day(n), benchmark: "000300.SH".into(), open: 100., close: 100., prev_close: 100., volume: 10000, + }).collect()).unwrap(); + for mode in ["lock", "cooldown", "expired"] { + let broker = BrokerSimulator::new(crate::ChinaAShareCostModel::default(), crate::ChinaEquityRuleHooks) + .with_volume_limit(false).with_liquidity_limit(false); + let mut portfolio = PortfolioState::new(10000.); + portfolio.position_mut(old).buy(day(11), 200, 10.); + portfolio.position_mut(old).sell(100, 10.).unwrap(); + broker.mark_same_day_sold(day(11), old); + portfolio.apply_successor_conversion(old, new, 2., 0.).unwrap(); + let policy = AutomaticTradeProtection { + sell_cooldown_days: if mode == "cooldown" { 3 } else { 0 }, + locks: if mode != "cooldown" { vec![AutomaticTradeLock { + symbol: old.into(), start_date: day(11), end_date: Some(day(if mode == "expired" {14} else {15})), + }] } else { vec![] }, ..Default::default() + }; + let rule = pool::StockPoolExecutionRule { automatic_trade_protection: policy, ..Default::default() }; + broker.deferred_etf_targets.borrow_mut().replace_generation("pool", "latest"); + broker.deferred_etf_targets.borrow_mut().upsert(crate::etf_execution::DeferredEtfTarget { + pool_id: "pool".into(), generation: "latest".into(), symbol: new.into(), + signal_date: day(14), signal_at: day(14).and_hms_opt(13,0,0).unwrap(), execute_on: Some(day(15)), + target_value: 5000.into(), target_weight_bps: 10000, side: pool::OrderSide::Buy, max_positions: 1, + rule: std::sync::Arc::new(rule), members: std::sync::Arc::new(vec![pool::StockPoolMemberSpec { + symbol: new.into(), requested_order: 0, recommendation_reason: String::new(), + target_weight_bps: None, stop_loss: None, take_profit: None, + }]), reason: "isolated deferred ETF target".into(), + }); + let report = broker.execute_deferred_etf_targets(day(15), &mut portfolio, &data).unwrap(); + if mode == "expired" { + assert_eq!(report.fill_events.len(), 1, "{report:?}"); + assert_eq!(portfolio.position(new).unwrap().quantity, 1000); + } else { + assert!(report.order_events.is_empty(), "{mode}: {report:?}"); + assert!(report.fill_events.is_empty()); + assert_eq!(portfolio.position(new).unwrap().quantity, 200); + assert!(report.diagnostics.iter().any(|text| text.contains(if mode == "lock" {"automatic_trade_locked"} else {"sell_fill_cooldown"}))); + } + } + } +} + impl BrokerSimulator { + fn pool_automatic_permission(&self, symbol: &str, date: NaiveDate, + policy: &crate::holding_policy::AutomaticTradeProtection, + portfolio: &PortfolioState, data: &DataSet) + -> Result { + let position = portfolio.position(symbol).filter(|position| position.quantity > 0); + let sold = self.same_day_sold_symbols.borrow().iter().rev() + .find(|(day, symbols)| **day <= date && (symbols.contains(symbol) + || portfolio.corporate_predecessors(symbol).any(|previous| symbols.contains(previous)))) + .map(|(day, _)| *day); + let evidence = HoldingLifecycleEvidence { + has_position: position.is_some(), opened_date: position.and_then(|position| position.opened_date()), + last_buy_date: position.and_then(|position| position.last_buy_date()), last_sell_date: sold, + }; + policy.evaluate_with_predecessors(symbol, date, &evidence, data.calendar(), + portfolio.corporate_predecessors(symbol)).map_err(BacktestError::Execution) + } + pub(super) fn resume_stock_pool_executions(&self, date: NaiveDate, portfolio: &mut PortfolioState, data: &DataSet, session: &mut BrokerExecutionSession, report: &mut BrokerExecutionReport) -> Result<(), BacktestError> { let clock = self.runtime_intraday_start_time.get().or(self.intraday_execution_start_time); @@ -463,25 +545,8 @@ impl BrokerSimulator { constraints.automatic_permissions.clear(); if contract.rule.automatic_trade_protection.enabled() { for symbol in &scope { - let position = portfolio.position(symbol).filter(|p| p.quantity > 0); - let sold = self - .same_day_sold_symbols - .borrow() - .iter() - .rev() - .find(|(day, symbols)| **day <= date && symbols.contains(symbol)) - .map(|(day, _)| *day); - let evidence = HoldingLifecycleEvidence { - has_position: position.is_some(), - opened_date: position.and_then(|p| p.opened_date()), - last_buy_date: position.and_then(|p| p.last_buy_date()), - last_sell_date: sold, - }; - let permission = contract - .rule - .automatic_trade_protection - .evaluate(symbol, date, &evidence, data.calendar()) - .map_err(BacktestError::Execution)?; + let permission = self.pool_automatic_permission(symbol, date, + &contract.rule.automatic_trade_protection, portfolio, data)?; constraints .automatic_permissions .insert(symbol.clone(), permission); @@ -708,10 +773,8 @@ impl BrokerSimulator { } let position = portfolio.position(&target.symbol).filter(|p| p.quantity > 0); let before_quantity = position.map_or(0, |p| p.quantity); - let permission = target.rule.automatic_trade_protection.evaluate(&target.symbol, date, &HoldingLifecycleEvidence { - has_position:position.is_some(), opened_date:position.and_then(|p| p.opened_date()), last_buy_date:position.and_then(|p| p.last_buy_date()), - last_sell_date:self.same_day_sold_symbols.borrow().iter().rev().find(|(day, symbols)| **day <= date && symbols.contains(&target.symbol)).map(|(day, _)| *day), - }, data.calendar()).map_err(BacktestError::Execution)?; + let permission = self.pool_automatic_permission(&target.symbol, date, + &target.rule.automatic_trade_protection, portfolio, data)?; let denial = if target.side == pool::OrderSide::Buy { permission.buy_denial.or(permission.max_holding_exit.then_some("max_holding_exit_pending")) } else { permission.sell_denial }; diff --git a/crates/fidc-core/src/holding_policy.rs b/crates/fidc-core/src/holding_policy.rs index 274b87d..e4a8df8 100644 --- a/crates/fidc-core/src/holding_policy.rs +++ b/crates/fidc-core/src/holding_policy.rs @@ -120,10 +120,25 @@ impl AutomaticTradeProtection { execution_date: NaiveDate, evidence: &HoldingLifecycleEvidence, calendar: &TradingCalendar, + ) -> Result { + self.evaluate_with_predecessors(symbol, execution_date, evidence, calendar, std::iter::empty()) + } + + /// Only accept predecessors from validated, actually applied holding + /// conversions. Catalog aliases or requested strategy symbols are not + /// evidence that a configured lock covers another security. + pub fn evaluate_with_predecessors<'a>( + &self, + symbol: &str, + execution_date: NaiveDate, + evidence: &HoldingLifecycleEvidence, + calendar: &TradingCalendar, + verified_predecessors: impl IntoIterator, ) -> Result { self.validate()?; + let predecessors = verified_predecessors.into_iter().collect::>(); if self.locks.iter().any(|lock| { - lock.symbol == symbol + (lock.symbol == symbol || predecessors.contains(lock.symbol.as_str())) && lock.start_date <= execution_date && lock.end_date.is_none_or(|end| execution_date <= end) }) { diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index e021c24..49cf4a4 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -12470,11 +12470,6 @@ impl Strategy for PlatformExprStrategy { .entry(execution.symbol.clone()) .and_modify(|previous| *previous = (*previous).max(date)) .or_insert(date); - if let Some(adjustment) = &execution.corporate_adjustment { - for successor in adjustment.actions.iter().filter_map(|action| action.successor_symbol.as_ref()) { - history.entry(successor.clone()).and_modify(|previous| *previous = (*previous).max(date)).or_insert(date); - } - } Ok(()) } fn name(&self) -> &str { @@ -12828,16 +12823,22 @@ impl PlatformExprStrategy { 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())); + symbols.extend(ctx.portfolio.observed_successor_symbols().map(str::to_owned)); 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 last_observed = |history: &BTreeMap| std::iter::once(symbol.as_str()) + .chain(ctx.portfolio.corporate_predecessors(&symbol)) + .filter_map(|symbol| history.get(symbol).copied()).max(); 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(), + last_buy_date: last_observed(&self.protection_last_buys).into_iter() + .chain(position.and_then(|position|position.last_buy_date())).max(), + last_sell_date: last_observed(&self.protection_last_sells), }; - let permission = policy.evaluate(&symbol, ctx.execution_date, &evidence, ctx.data.calendar()).map_err(BacktestError::Execution)?; + let permission = policy.evaluate_with_predecessors(&symbol, ctx.execution_date, &evidence, + ctx.data.calendar(), ctx.portfolio.corporate_predecessors(&symbol)).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); diff --git a/crates/fidc-core/src/portfolio.rs b/crates/fidc-core/src/portfolio.rs index 063d40f..e5bc9c5 100644 --- a/crates/fidc-core/src/portfolio.rs +++ b/crates/fidc-core/src/portfolio.rs @@ -709,6 +709,9 @@ pub struct PortfolioState { cash_receivables: Vec, pending_cash_flows: Vec, day_sold_symbols: BTreeSet, + // Observed holding conversions, never a catalog alias or a new target. + // Kept after a position becomes flat so an active date lock is not lost. + corporate_predecessors: BTreeMap>, stock_pool_states: std::collections::BTreeMap, } @@ -750,6 +753,7 @@ impl PortfolioState { cash_receivables: Vec::new(), pending_cash_flows: Vec::new(), day_sold_symbols: BTreeSet::new(), + corporate_predecessors: BTreeMap::new(), stock_pool_states: std::collections::BTreeMap::new(), } } @@ -762,6 +766,14 @@ impl PortfolioState { pub(crate) fn initial_cash_fixed(&self) -> FixedMoney { self.initial_cash } + pub(crate) fn corporate_predecessors(&self, symbol: &str) -> impl Iterator { + self.corporate_predecessors.get(symbol).into_iter().flatten().map(String::as_str) + } + + pub(crate) fn observed_successor_symbols(&self) -> impl Iterator { + self.corporate_predecessors.keys().map(String::as_str) + } + pub(crate) fn stock_pool_execution_state(&self,pool_id:&str)->crate::stock_pool_state::StockPoolExecutionState{ self.stock_pool_states.get(pool_id).cloned().unwrap_or_default() } @@ -807,7 +819,11 @@ impl PortfolioState { let mut receivables = self.cash_receivables.iter().map(|row| (row.symbol.clone(), row.ex_date, row.payable_date, fixed_money_or_panic(row.amount, "receivable identity").to_decimal_string(), row.reason.clone())).collect::>(); receivables.sort(); - serde_json::json!({"cash":self.cash.to_decimal_string(),"positions":positions,"receivables":receivables}) + let mut identity = serde_json::json!({"cash":self.cash.to_decimal_string(),"positions":positions,"receivables":receivables}); + if !self.corporate_predecessors.is_empty() { + identity["corporatePredecessors"] = serde_json::json!(self.corporate_predecessors); + } + identity } pub(crate) fn financial_position_basis(&self, symbol: &str) -> FixedMoney { @@ -828,6 +844,9 @@ impl PortfolioState { self.positions = replayed.positions; self.cash_receivables = replayed.cash_receivables; self.day_sold_symbols = replayed.day_sold_symbols; + // Corrected actual receipts can prove a position was fully sold + // before conversion. Do not retain a now-disproved financial link. + self.corporate_predecessors = replayed.corporate_predecessors; // Existing issued units, explicit cash-flow/financing facts, and task // target state are observed controls, not counterfactual new orders. Ok(()) @@ -1557,7 +1576,7 @@ impl PortfolioState { } successor.refresh_day_pnl(); - Some(SuccessorConversionOutcome { + let outcome = SuccessorConversionOutcome { old_symbol: old_symbol_owned, new_symbol: new_symbol.to_string(), old_quantity, @@ -1569,7 +1588,14 @@ impl PortfolioState { } else { 0.0 }, - }) + }; + if converted_quantity > 0 { + let mut predecessors = self.corporate_predecessors.get(old_symbol).cloned().unwrap_or_default(); + predecessors.insert(old_symbol.to_owned()); + predecessors.remove(new_symbol); + self.corporate_predecessors.entry(new_symbol.to_owned()).or_default().extend(predecessors); + } + Some(outcome) } fn sum_fixed_money( diff --git a/crates/fidc-core/tests/manual_corporate_observation.rs b/crates/fidc-core/tests/manual_corporate_observation.rs index ed4eda1..84a9231 100644 --- a/crates/fidc-core/tests/manual_corporate_observation.rs +++ b/crates/fidc-core/tests/manual_corporate_observation.rs @@ -310,6 +310,205 @@ fn paper_and_broker_observations_require_the_same_frozen_successor_scope() { } } +fn protected_successor_run(delayed: bool, locked: bool, amount: i32) + -> fidc_core::BacktestResult { + let policy = fidc_core::holding_policy::AutomaticTradeProtection { + sell_cooldown_days: if locked { 0 } else { 3 }, + locks: if locked { vec![fidc_core::holding_policy::AutomaticTradeLock { + symbol: "000001.SZ".into(), start_date: date(11), end_date: Some(date(15)), + }] } else { vec![] }, ..Default::default() + }; + protected_successor_case(delayed, policy, amount, "partial") +} + +fn protected_successor_case(delayed: bool, + policy: fidc_core::holding_policy::AutomaticTradeProtection, amount: i32, scenario: &str) + -> fidc_core::BacktestResult { + let mut config = fidc_core::PlatformExprStrategyConfig::generic(); + config.signal_symbol = "000002.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.explicit_action_schedule = Some(fidc_core::PlatformRebalanceSchedule { + frequency: fidc_core::PlatformScheduleFrequency::Daily, + time_rule: Some(fidc_core::ScheduleTimeRule::physical_time(14, 30)), + }); + config.automatic_trade_protection = policy; + config.explicit_actions = vec![fidc_core::PlatformTradeAction::Order { + kind: fidc_core::PlatformExplicitOrderKind::Shares, symbol: "000002.SZ".into(), + amount_expr: amount.to_string(), when_expr: Some("decision_date == \"2026-09-15\"".into()), + limit_price_expr: None, time_in_force: None, start_time_expr: None, end_time_expr: None, + reason: "configured_successor_action".into(), + }]; + let data = successor_execution_data(); + let mut replay = source(delayed, true); + if scenario == "sold_before" { + let order = &mut replay.actions[1].orders[0]; + order.quantity = 200; order.fills[0].quantity = 200; + } else if scenario == "cleared_after" { + let mut row = serde_json::to_value(&replay.actions[1]).unwrap(); + let at = "2026-09-15T05:30:00Z"; + let receipt = "2026-09-15T05:30:01Z"; + row["actionId"] = "clear".into(); row["auditEventIds"] = serde_json::json!(["audit-clear"]); + row["confirmedAt"] = at.into(); row["confirmationObservedAt"] = at.into(); + let order = &mut row["orders"][0]; + order["orderId"] = "clear-order".into(); order["brokerOrderId"] = "clear-order".into(); + order["symbol"] = "000002.SZ".into(); order["quantity"] = 200.into(); + order["orderCreatedAt"] = at.into(); order["terminalObservedAt"] = receipt.into(); + let fill = &mut order["fills"][0]; + fill["tradeId"] = "clear-trade".into(); fill["observationEventId"] = "clear-receipt".into(); + fill["observationSequence"] = 3.into(); fill["tradeDate"] = "2026-09-15".into(); + fill["executedAt"] = at.into(); fill["observedAt"] = receipt.into(); + fill["feeObservationEventId"] = "clear-receipt".into(); fill["feeObservationSequence"] = 3.into(); + fill["feeObservedAt"] = receipt.into(); fill["price"] = "5".into(); fill["quantity"] = 200.into(); + replay.actions.push(serde_json::from_value(row).unwrap()); + } + replay.content_sha256 = replay.content_digest().unwrap(); + BacktestEngine::new(data, fidc_core::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(date(10)), end_date: Some(date(15)), decision_lag_trading_days: 0, + execution_price_field: PriceField::Close, + }).with_observed_manual_executions(replay).unwrap().run().unwrap() +} + +fn successor_execution_data() -> DataSet { + let parts = data(Action::Successor).snapshot_components(); + DataSet::from_components_with_actions_and_quotes(parts.instruments, parts.market, + parts.factors, parts.candidates, parts.benchmarks, parts.corporate_actions, + [30, 31].into_iter().map(|minute| fidc_core::IntradayExecutionQuote { + observation_kind: fidc_core::data::QuoteObservationKind::MinuteBar, + date: date(15), symbol: "000002.SZ".into(), timestamp: date(15).and_hms_opt(14,minute,0).unwrap(), + last_price: 5., bid1: 5., ask1: 5., bid1_volume: 100000, ask1_volume: 100000, + volume_delta: 100000, amount_delta: 500000., trading_phase: Some("continuous".into()), + }).collect()).unwrap() +} + +#[test] +fn native_pool_rebalance_applies_inherited_protection_without_rewriting_its_target() { + use fidc_core::stock_pool_execution as pool; + struct NativePool { locked: bool, expires: u32, exposure: i32 } + impl Strategy for NativePool { + fn name(&self) -> &str { "native pool successor protection" } + fn requires_minute_callbacks(&self) -> bool { false } + fn schedule_rules(&self) -> Vec { + vec![fidc_core::ScheduleRule::daily("pool", fidc_core::ScheduleStage::OnDay) + .with_time_rule(fidc_core::ScheduleTimeRule::physical_time(14,30))] + } + fn on_scheduled(&mut self, ctx: &fidc_core::StrategyContext<'_>, _: &fidc_core::ScheduleRule) + -> Result { + if ctx.execution_date != date(15) { return Ok(Default::default()); } + let symbols = vec!["000002.SZ".to_owned()]; + let policy = fidc_core::holding_policy::AutomaticTradeProtection { + sell_cooldown_days: if self.locked { 0 } else { 3 }, + locks: if self.locked { vec![fidc_core::holding_policy::AutomaticTradeLock { + symbol: "000001.SZ".into(), start_date: date(11), end_date: Some(date(self.expires)), + }] } else { vec![] }, ..Default::default() + }; + let contract = pool::FrozenStockPoolIntent { + pool_id: "pool".into(), signal_date: date(15), frozen_equity: 10000.into(), + selection: pool::StockPoolSelection { trade_date: date(15), requested_symbols: symbols.clone(), + normal_trading_symbols: symbols.clone(), risk_eligible_symbols: symbols.clone(), final_symbols: symbols, + exclusion_reasons: Default::default(), inherited_from_generation: None, explicit_empty: false, + generation: Some("latest".into()), + }, members: vec![pool::StockPoolMemberSpec { symbol: "000002.SZ".into(), requested_order: 0, + recommendation_reason: String::new(), target_weight_bps: None, stop_loss: None, take_profit: None }], + rule: pool::StockPoolExecutionRule { pricing_mode: pool::POOL_PRICE_FIRST_TICK.into(), + window_start: "14:30".into(), window_end: "15:00".into(), automatic_trade_protection: policy, + ..Default::default() }, constraints: pool::StockPoolDecisionConstraints { + target_holding_count: Some(1), ..Default::default() }, + invest_ratio_bps: self.exposure, reserve_cash: 0.into(), out_of_pool_policy: "hold".into(), generation: "latest".into(), + }; + Ok(fidc_core::StrategyDecision { order_intents: vec![fidc_core::OrderIntent::StockPool { contract: Box::new(contract) }], ..Default::default() }) + } + } + for delayed in [false, true] { + for (locked, expires, exposure) in [(false, 15, 10000), (true, 15, 10000), (true, 15, 0), (true, 14, 10000)] { + let result = BacktestEngine::new(successor_execution_data(), NativePool { locked, expires, exposure }, + BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks) + .with_matching_type(MatchingType::MinuteLast).with_intraday_execution_start_time(chrono::NaiveTime::from_hms_opt(14,30,0).unwrap()) + .with_volume_limit(false).with_liquidity_limit(false), + BacktestConfig { initial_cash: 10000., benchmark_code: "000300.SH".into(), start_date: Some(date(10)), + end_date: Some(date(15)), decision_lag_trading_days: 0, execution_price_field: PriceField::Last }) + .with_observed_manual_executions(source(delayed, true)).unwrap().run().unwrap(); + if expires == 14 { + assert!(!result.fills.is_empty(), "positive control {:?}", result.order_events); + } else { + assert!(result.fills.is_empty(), "delayed={delayed} locked={locked} exposure={exposure}: {:?}", result.fills); + assert!(result.order_events.is_empty()); + assert_eq!(result.holdings_summary[0].quantity, 200); + } + } + } +} + +#[test] +fn successor_keeps_sell_cooldown_for_timely_and_delayed_receipts() { + for delayed in [false, true] { + let result = protected_successor_run(delayed, false, 100); + assert!(result.fills.is_empty(), "delayed={delayed}: {:?}", result.fills); + assert!(result.order_events.is_empty(), "delayed={delayed}: {:?}", result.order_events); + assert!(result.risk_decisions.iter().any(|row| row.symbol == "000002.SZ" + && row.date == date(15) && !row.accepted && row.rule_code == "sell_fill_cooldown"), + "orders={:?} risk={:?} notes={:?}", result.order_events, result.risk_decisions, + result.equity_curve.iter().map(|row| (&row.date, &row.notes)).collect::>()); + assert_eq!(result.holdings_summary[0].quantity, 200); + } +} + +#[test] +fn converted_holding_does_not_lose_its_configured_date_lock() { + for delayed in [false, true] { + for amount in [-100, 100] { + let result = protected_successor_run(delayed, true, amount); + assert!(result.fills.is_empty(), "delayed={delayed} amount={amount}: {:?}", result.fills); + assert!(result.order_events.is_empty(), "delayed={delayed} amount={amount}: {:?}", result.order_events); + assert!(result.risk_decisions.iter().any(|row| row.symbol == "000002.SZ" + && row.date == date(15) && !row.accepted && row.rule_code == "automatic_trade_locked"), + "orders={:?} risk={:?} notes={:?}", result.order_events, result.risk_decisions, + result.equity_curve.iter().map(|row| (&row.date, &row.notes)).collect::>()); + assert_eq!(result.holdings_summary[0].quantity, 200); + } + } +} + +#[test] +fn successor_lock_expires_on_the_original_configured_date_not_the_conversion_date() { + for delayed in [false, true] { + let policy = fidc_core::holding_policy::AutomaticTradeProtection { + locks: vec![fidc_core::holding_policy::AutomaticTradeLock { + symbol: "000001.SZ".into(), start_date: date(11), end_date: Some(date(14)), + }], ..Default::default() + }; + let result = protected_successor_case(delayed, policy, 100, "partial"); + assert_eq!(result.fills.len(), 1); + assert_eq!(result.fills[0].quantity, 100); + assert_eq!(result.holdings_summary[0].quantity, 300); + } +} + +#[test] +fn lock_survives_a_manual_clear_after_conversion_but_not_a_disproved_conversion() { + for delayed in [false, true] { + let policy = fidc_core::holding_policy::AutomaticTradeProtection { + locks: vec![fidc_core::holding_policy::AutomaticTradeLock { + symbol: "000001.SZ".into(), start_date: date(11), end_date: Some(date(15)), + }], ..Default::default() + }; + let cleared = protected_successor_case(delayed, policy.clone(), 100, "cleared_after"); + assert!(cleared.fills.is_empty()); + assert!(cleared.order_events.is_empty()); + assert!(cleared.holdings_summary.is_empty()); + assert_eq!(cleared.manual_executions.len(), 3); + let unconverted = protected_successor_case(delayed, policy, 100, "sold_before"); + assert_eq!(unconverted.fills.len(), 1); + assert_eq!(unconverted.holdings_summary[0].quantity, 100); + } +} + #[test] fn corporate_replay_preserves_issued_orders_cash_flows_financing_and_charged_fees() { struct ExistingActivity { diff --git a/docs/automatic-trade-protection-20260911.md b/docs/automatic-trade-protection-20260911.md index 9457af8..08283a6 100644 --- a/docs/automatic-trade-protection-20260911.md +++ b/docs/automatic-trade-protection-20260911.md @@ -13,6 +13,7 @@ - 成交日及后续N个完整正式交易日均受保护。例如周五成交、N=3,保护到下周三结束,周四恢复;不按72小时或自然日替代。 - 最长持有从连续持仓第一次实际买入开始,跨正式交易日计数;加仓、部分卖出和有证据的证券转换不重置,完全卖出再买入开启新周期。锁定和买入保护优先于最长持有退出。 - 日期锁定禁止自动买卖,已接受的挂单不自动撤销;手工路径只绕过自动策略保护,不绕过账户授权、T+1、券商和风控。 +- 已校验且实际发生的持仓换股继承原保护期限与日期锁,不因改代码解锁或重计时;无实际转换的目录映射不继承。换股后手工清仓仍受原有效日期锁;确认换股前已清仓时不保留推定关系。原生策略、股票池与ETF顺延消费的修复及未发布边界见 `successor-protection-20260914.md`。 - 保留的真实持仓继续占用资金与席位,不把未完成卖出当现金。最长持有退出先形成唯一最终目标,不能叠加一笔策略部分卖出和一笔框架全量卖出。 - 在线上下文重建必须注入已经校验的真实成交/持仓快照,不能把重建日或旧行情日当建仓日。期货或股票期货混合账户未纳入本合同,显式拒绝。 diff --git a/docs/successor-protection-20260914.md b/docs/successor-protection-20260914.md new file mode 100644 index 0000000..b12b8fb --- /dev/null +++ b/docs/successor-protection-20260914.md @@ -0,0 +1,30 @@ +# 换股后的策略保护继承 + +2026-09-14。开发候选;生产未发布,完整目标仍未完成。 + +## 反例与根因 + +原代码只给跨公司行为的迟到成交附带后继代码保护;及时回报之后正常换股则没有相同处理。日期锁也只比较当前代码,原持仓换股后会失去锁定。完整引擎反例已复现:及时卖出旧证券后的禁买期内,新证券又买入100股;旧证券仍处日期锁定内,新证券却卖出100股。 + +初始15:00样例虽产生了不该生成的委托,但因没有之后的报价而到期,不能把无成交视为保护通过。改用14:30调度和14:30/14:31报价后,实际回放成交证明了上述错误。ETF正向对照最初缺候选资格行,补齐隔离输入后正常成交,未放宽生产数据或风控校验。 + +## 统一语义 + +- 仅记录已真实影响持仓的、条款与证券身份已经校验的转换关系。目录别名、请求中的代码或尚未发生的公司行为不能使另一证券受锁定;不向候选池或策略目标添加证券。 +- 连续转换保留已证明的前身关系,保护读取原实际买卖日期;日期锁沿关系生效,但到期日仍为原配置,不从换股日重新计时。买后保护和最长持有继续使用原取得/买入日期。 +- 换股后手工清仓不删除仍有效的日期锁关系,防止自动重新买回绕过锁定。若迟到回报证明在换股前已经全部卖出,则原本推定的持仓转换关系应被校正掉,不将旧锁误加给新证券。 +- 关系进入经济账本的重放校验;无换股时原账本摘要形状不变。权益校正整体替换已验证关系,不并入已被新事实推翻的旧关系;原参数、目标权重、未提交目标及已发订单不擅自换成新代码。 +- 普通表达式策略、股票池普通调仓、ETF顺延开盘消费三个入口均使用同一保护内核。股票池与ETF共用经实际持仓关系解析的保护证据,不只修表面策略层。 +- 移除按公司行为引用列表直接扩展卖出代码的两个旁路,避免无实际持仓转换也被误认为曾卖出新证券。原股票的真实卖出记录和已发订单仍保留。 + +## 验证 + +6项新增专项覆盖23个隔离配置场景:及时/迟到回报、买卖两方向、禁买期、日期锁、原日期到期、转换后手工清仓、转换前已清仓,股票池0%/100%目标以及ETF顺延开盘。已知保护有效时验证零委托/零成交及200股原持仓;到期对照必须能够真实回放成交,不能靠缺报价或被其他风险拒绝冒充保护正确。 + +本机Core913、Trading625、Runner463/API129全量通过;原9/63/16项ignore不计。没有新增私有数据库、生产页面或券商实测;UI和其他业务仓代码未改。此次修复不改变配置为0/null/空的保护规则,也不是全局共享风控配置。 + +## 发布边界与后续 + +Source公司行为接口仍缺正式换股条款及所需数据范围闭包,在线重建也仍需要权威转换持仓事实,不能仅凭最终股数或证券名称猜关系。本轮原生回放与Linux测试不是GT/QMT公司行为实盘验收。Source d5冻结保持,不改旧合同、不注册替代合同或恢复交易。 + +继续正式Source/Runner联合验收、在线转换事实持久化/重建、旧目标及活动单边界、清空后再投参考和其余ETF跨模式矩阵。Linux使用新只读快照;没有发布tag、release构建或生产重启,完成后追加收据。