From 1dc1e36aa82baaa24cfaab547d74134d4e44e8b7 Mon Sep 17 00:00:00 2001 From: boris Date: Sat, 10 Oct 2026 13:33:53 -0700 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8DFIDC=E9=A1=BA=E5=BB=B6?= =?UTF-8?q?=E8=B0=83=E4=BB=93=E7=9B=AE=E6=A0=87=E4=B8=A2=E5=A4=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/fidc-core/src/broker_stock_pool.rs | 2 ++ crates/fidc-core/src/etf_execution.rs | 25 +++++++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/crates/fidc-core/src/broker_stock_pool.rs b/crates/fidc-core/src/broker_stock_pool.rs index 7592939..4167f5a 100644 --- a/crates/fidc-core/src/broker_stock_pool.rs +++ b/crates/fidc-core/src/broker_stock_pool.rs @@ -787,10 +787,12 @@ impl BrokerSimulator { if let Some(denial) = denial { report.diagnostics.push(format!("etf_daily_open_fallback:protected symbol={} date={date} reason={denial}", target.symbol)); etf_activity(&mut report,date,&target.symbol,target.side,format!("ETF 顺延目标受持有保护限制:{denial};未提交委托。")); + self.deferred_etf_targets.borrow_mut().defer_to_next_session(target, date, &dates); continue; } if target.side == pool::OrderSide::Buy && before_quantity == 0 && Self::positive_position_count(portfolio) >= target.max_positions { report.diagnostics.push(format!("etf_daily_open_fallback:blocked symbol={} reason=occupied_position_slots", target.symbol)); + self.deferred_etf_targets.borrow_mut().defer_to_next_session(target, date, &dates); continue; } let value = target.target_value.to_f64().ok_or_else(|| BacktestError::Execution("etf_daily_open_fallback: target value out of range".into()))?; diff --git a/crates/fidc-core/src/etf_execution.rs b/crates/fidc-core/src/etf_execution.rs index 138501c..945aa6c 100644 --- a/crates/fidc-core/src/etf_execution.rs +++ b/crates/fidc-core/src/etf_execution.rs @@ -81,6 +81,15 @@ impl DeferredEtfTargets { *existing = row; } else { self.rows.push(row); } } + /// Keep a due target when execution was intentionally deferred by a + /// transient portfolio condition (for example an occupied slot or a + /// holding-protection window). The next official session is the only + /// valid retry clock; a natural-day retry would create a second decision + /// for the same market session. + pub fn defer_to_next_session(&mut self, mut row: DeferredEtfTarget, date: NaiveDate, dates: &[NaiveDate]) { + row.execute_on = dates.iter().copied().find(|candidate| *candidate > date); + self.upsert(row); + } pub fn take_due(&mut self, date: NaiveDate) -> Vec { let mut due = Vec::new(); self.rows.retain(|row| { @@ -126,4 +135,20 @@ mod tests { assert!(queue.take_due(NaiveDate::from_ymd_opt(2026,2,1).unwrap()).is_empty()); assert_eq!(queue.len(),1); } + + #[test] + fn deferred_target_moves_to_next_official_session_instead_of_being_lost() { + let mut queue=DeferredEtfTargets::default(); + queue.replace_generation("pool","v1"); + queue.upsert(target("510300.SH",crate::stock_pool_execution::OrderSide::Buy,"v1")); + let date=NaiveDate::from_ymd_opt(2026,1,5).unwrap(); + let due=queue.take_due(date); + assert_eq!(due.len(),1); + queue.defer_to_next_session(due.into_iter().next().unwrap(),date,&[ + NaiveDate::from_ymd_opt(2026,1,5).unwrap(), + NaiveDate::from_ymd_opt(2026,1,6).unwrap(), + ]); + assert!(queue.take_due(date).is_empty()); + assert_eq!(queue.take_due(NaiveDate::from_ymd_opt(2026,1,6).unwrap()).len(),1); + } }