diff --git a/crates/fidc-core/src/corporate_book.rs b/crates/fidc-core/src/corporate_book.rs index 20ce4a7..dda1690 100644 --- a/crates/fidc-core/src/corporate_book.rs +++ b/crates/fidc-core/src/corporate_book.rs @@ -4,6 +4,30 @@ use crate::{ }; use chrono::NaiveDate; +pub(crate) fn validate_action<'a>( + action: &'a crate::CorporateAction, + data: &DataSet, +) -> Result, String> { + let terms = action.validated_successor_terms()?; + crate::finite_serialization::validate(action).map_err(|error| { + format!( + "corporate_action_invalid_number: symbol={} action_date={} detail={error}", + action.symbol, action.date + ) + })?; + if let Some((successor, _, _)) = terms { + for (symbol, role) in [(&*action.symbol, "source"), (successor, "successor")] { + if data.instrument(symbol).is_none() { + return Err(format!( + "corporate_action_{role}_instrument_missing: symbol={symbol} action_date={} source_symbol={}", + action.date, action.symbol + )); + } + } + } + Ok(terms) +} + /// One corporate-action calculation for normal processing and audited replay. pub(crate) fn apply( date: NaiveDate, @@ -12,6 +36,44 @@ pub(crate) fn apply( notes: &mut Vec, cash_dividends_enabled: bool, cash_dividend_adjusts_cost_basis: bool, +) -> Result { + let actions = data.corporate_actions_on(date); + for action in actions { + validate_action(action, data).map_err(BacktestError::Execution)?; + } + if !actions.iter().any(|action| { + action.has_effect() + && portfolio + .position(&action.symbol) + .is_some_and(|position| position.quantity > 0) + }) { + return Ok(BrokerExecutionReport::default()); + } + // An entire settlement batch is a single ledger update. A later invalid + // cash leg must not leave an earlier split, receivable, target unit, or + // note applied to the observed account. + let mut next = portfolio.clone(); + let mut recorded = Vec::new(); + let report = apply_inner( + date, + data, + &mut next, + &mut recorded, + cash_dividends_enabled, + cash_dividend_adjusts_cost_basis, + )?; + *portfolio = next; + notes.extend(recorded); + Ok(report) +} + +fn apply_inner( + date: NaiveDate, + data: &DataSet, + portfolio: &mut PortfolioState, + notes: &mut Vec, + cash_dividends_enabled: bool, + cash_dividend_adjusts_cost_basis: bool, ) -> Result { let mut report = BrokerExecutionReport::default(); for action in data.corporate_actions_on(date) { @@ -73,6 +135,16 @@ pub(crate) fn apply( let split_ratio = action.split_ratio(); if (split_ratio - 1.0).abs() > f64::EPSILON { + checked_quantity( + &action.symbol, + date, + portfolio + .position(&action.symbol) + .expect("position exists for split") + .quantity, + split_ratio, + 0, + )?; portfolio .adjust_stock_pool_split(&action.symbol, split_ratio) .map_err(BacktestError::Execution)?; @@ -101,25 +173,33 @@ pub(crate) fn apply( } } - if action.has_successor_conversion() { - let successor_symbol = action - .successor_symbol - .as_deref() - .expect("successor symbol checked"); + if let Some((successor_symbol, ratio, cash_per_share)) = action + .validated_successor_terms() + .map_err(BacktestError::Execution)? + { + checked_quantity( + &action.symbol, + date, + portfolio + .position(&action.symbol) + .expect("position exists for conversion") + .quantity, + ratio, + portfolio + .position(successor_symbol) + .map_or(0, |position| position.quantity), + )?; let Some(outcome) = portfolio.apply_successor_conversion( &action.symbol, successor_symbol, - action.successor_ratio_value(), - action.successor_cash_value(), + ratio, + cash_per_share, ) else { continue; }; let reason = format!( "successor_conversion {}->{} ratio={:.6} cash_per_share={:.6}", - outcome.old_symbol, - outcome.new_symbol, - action.successor_ratio_value(), - action.successor_cash_value() + outcome.old_symbol, outcome.new_symbol, ratio, cash_per_share ); notes.push(reason.clone()); report.position_events.push(PositionEvent { @@ -159,6 +239,26 @@ pub(crate) fn apply( portfolio.prune_flat_positions(); Ok(report) } + +fn checked_quantity( + symbol: &str, + date: NaiveDate, + quantity: u32, + ratio: f64, + merged: u32, +) -> Result<(), BacktestError> { + let scaled = (f64::from(quantity) * ratio).round(); + if !scaled.is_finite() + || scaled < 0. + || scaled > f64::from(i32::MAX) + || scaled + f64::from(merged) > f64::from(u32::MAX) + { + return Err(BacktestError::Execution(format!( + "corporate_action_quantity_overflow: symbol={symbol} action_date={date}" + ))); + } + Ok(()) +} /// Preserve the declared fee-free accounting allocation model; this does not /// submit a market order or use a later opening quote as an earlier fact. pub(crate) fn settle_receivables( @@ -390,4 +490,196 @@ mod tests { assert!(report.fill_events.is_empty()); assert!(notes[0].contains("dividend_reinvestment_not_applied")); } + + fn conversion() -> crate::CorporateAction { + crate::CorporateAction { + date: date(), + symbol: "000001.SZ".into(), + payable_date: None, + share_cash: 0., + share_bonus: 0., + share_gift: 0., + issue_quantity: 0., + issue_price: 0., + reform: false, + adjust_factor: None, + successor_symbol: Some("000002.SZ".into()), + successor_ratio: Some(1.5), + successor_cash: Some(0.5), + } + } + + fn conversion_data(actions: Vec, include_successor: bool) -> DataSet { + let mut instruments = data(false) + .instruments() + .values() + .cloned() + .collect::>(); + if include_successor { + let mut successor = instruments[0].clone(); + successor.symbol = "000002.SZ".into(); + instruments.push(successor); + } + DataSet::from_components_with_actions( + instruments, + vec![], + vec![], + vec![], + vec![crate::BenchmarkSnapshot { + date: date(), + benchmark: "000300.SH".into(), + open: 100., + close: 100., + prev_close: 100., + volume: 0, + }], + actions, + ) + .unwrap() + } + + fn conversion_book() -> PortfolioState { + let mut book = PortfolioState::new(1000.); + book.position_mut("000001.SZ") + .buy(date().pred_opt().unwrap(), 100, 10.); + book + } + + #[test] + fn successor_without_frozen_instrument_metadata_is_not_an_implicit_new_security() { + let mut action = conversion(); + action.share_cash = 1.; + action.share_bonus = 1.; + let data = conversion_data(vec![action], false); + let mut book = conversion_book(); + let before = book.financial_replay_identity(); + let mut notes = vec!["prior".into()]; + let error = apply(date(), &data, &mut book, &mut notes, true, true).unwrap_err(); + assert!(error.to_string().contains("successor_instrument_missing")); + assert_eq!(book.financial_replay_identity(), before); + assert_eq!(notes, ["prior"]); + } + + #[test] + fn invalid_successor_terms_cannot_be_replaced_with_one_share_or_zero_cash() { + let base = conversion(); + let mut cases = Vec::new(); + for ratio in [ + None, + Some(0.), + Some(-1.), + Some(f64::NAN), + Some(f64::INFINITY), + ] { + let mut row = base.clone(); + row.successor_ratio = ratio; + cases.push(row); + } + for symbol in [ + None, + Some(""), + Some(" "), + Some("000001.SZ"), + Some(" 000002.SZ"), + ] { + let mut row = base.clone(); + row.successor_symbol = symbol.map(str::to_owned); + cases.push(row); + } + for cash in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] { + let mut row = base.clone(); + row.successor_cash = Some(cash); + cases.push(row); + } + for action in cases { + let mut book = conversion_book(); + let before = book.financial_replay_identity(); + let data = conversion_data(vec![action.clone()], true); + let mut notes = Vec::new(); + assert!( + apply(date(), &data, &mut book, &mut notes, true, true).is_err(), + "accepted {action:?}" + ); + assert_eq!(book.financial_replay_identity(), before); + assert!(notes.is_empty()); + } + } + + #[test] + fn a_late_corporate_batch_failure_keeps_prior_cash_positions_and_notes() { + let mut dividend = conversion(); + dividend.successor_symbol = None; + dividend.successor_ratio = None; + dividend.successor_cash = None; + dividend.share_cash = 1.; + dividend.share_bonus = 1.; + let mut failure = conversion(); + failure.successor_cash = Some(1e100); + let data = conversion_data(vec![dividend, failure], true); + let mut book = conversion_book(); + let mut state = crate::stock_pool_state::StockPoolExecutionState { + last_execution_date: date().pred_opt(), + ..Default::default() + }; + state.position_action_bases.insert( + "000001.SZ".into(), + crate::stock_pool_state::StockPoolPositionActionBasis { + generation: "original".into(), + first_execution_date: date().pred_opt().unwrap(), + quantity: rust_decimal::Decimal::from(100), + }, + ); + state.last_target_weights.insert("000001.SZ".into(), 10000); + book.set_stock_pool_execution_state("pool", state.clone()) + .unwrap(); + let before = book.financial_replay_identity(); + let mut notes = vec!["prior".into()]; + assert!(apply(date(), &data, &mut book, &mut notes, true, true).is_err()); + assert_eq!(book.financial_replay_identity(), before); + assert_eq!(book.stock_pool_execution_state("pool"), state); + assert_eq!(notes, ["prior"]); + } + + #[test] + fn corporate_quantity_overflow_fails_without_saturation_or_a_negative_delta() { + for split in [false, true] { + let mut action = conversion(); + if split { + action.share_bonus = 1e100; + } else { + action.successor_ratio = Some(1e100); + } + let data = conversion_data(vec![action], true); + let mut book = conversion_book(); + let before = book.financial_replay_identity(); + let mut notes = Vec::new(); + let error = apply(date(), &data, &mut book, &mut notes, true, true).unwrap_err(); + assert!( + error + .to_string() + .contains("corporate_action_quantity_overflow") + ); + assert_eq!(book.financial_replay_identity(), before); + assert!(notes.is_empty()); + } + } + + #[test] + fn verified_conversion_preserves_lots_without_creating_orders_or_fills() { + let data = conversion_data(vec![conversion()], true); + let mut book = conversion_book(); + book.position_mut("000002.SZ").buy(date(), 50, 20.); + let mut notes = Vec::new(); + let report = apply(date(), &data, &mut book, &mut notes, true, true).unwrap(); + assert!(book.position("000001.SZ").is_none()); + let successor = book.position("000002.SZ").unwrap(); + assert_eq!(successor.quantity, 200); + assert_eq!(successor.opened_date(), date().pred_opt()); + assert_eq!(successor.last_buy_date(), Some(date())); + assert_eq!(book.cash(), 1050.); + assert!(report.fill_events.is_empty()); + assert!(report.order_events.is_empty()); + assert_eq!(report.position_events.len(), 2); + assert!(notes[0].contains("ratio=1.500000")); + } } diff --git a/crates/fidc-core/src/data.rs b/crates/fidc-core/src/data.rs index 7b6446c..a21b7dc 100644 --- a/crates/fidc-core/src/data.rs +++ b/crates/fidc-core/src/data.rs @@ -459,19 +459,29 @@ impl CorporateAction { self.successor_symbol .as_ref() .is_some_and(|symbol| !symbol.trim().is_empty()) - && self.successor_ratio_value() > 0.0 } - pub fn successor_ratio_value(&self) -> f64 { - self.successor_ratio - .filter(|ratio| ratio.is_finite() && *ratio > 0.0) - .unwrap_or(1.0) - } - - pub fn successor_cash_value(&self) -> f64 { - self.successor_cash - .filter(|cash| cash.is_finite()) - .unwrap_or(0.0) + /// A code mapping alone is not evidence for a 1:1 financial conversion. + /// An absent cash component means no declared cash leg; an invalid one + /// must never be replaced with zero. + pub(crate) fn validated_successor_terms(&self) -> Result, String> { + let fail = |reason: &str| format!( + "corporate_action_{reason}: symbol={} action_date={}", self.symbol, self.date); + let Some(symbol) = self.successor_symbol.as_deref() else { + if self.successor_ratio.is_some() || self.successor_cash.is_some() { + return Err(fail("successor_symbol_missing")); + } + return Ok(None); + }; + if symbol.is_empty() || symbol.trim() != symbol || symbol == self.symbol + || self.symbol.is_empty() || self.symbol.trim() != self.symbol { + return Err(fail("successor_symbol_invalid")); + } + let ratio = self.successor_ratio.filter(|ratio| ratio.is_finite() && *ratio > 0.) + .ok_or_else(|| fail("successor_ratio_missing_or_invalid"))?; + let cash = self.successor_cash.unwrap_or(0.); + if !cash.is_finite() { return Err(fail("successor_cash_invalid")); } + Ok(Some((symbol, ratio, cash))) } } diff --git a/crates/fidc-core/src/manual_corporate_replay.rs b/crates/fidc-core/src/manual_corporate_replay.rs index 33ae651..e225310 100644 --- a/crates/fidc-core/src/manual_corporate_replay.rs +++ b/crates/fidc-core/src/manual_corporate_replay.rs @@ -101,24 +101,15 @@ impl ManualCorporateReplay { if !symbols.contains(&action.symbol) { continue; } - crate::finite_serialization::validate(action).map_err(|error| error.to_string())?; + let successor_terms = crate::corporate_book::validate_action(action, data)?; let effective = (action.split_ratio() - 1.).abs() > f64::EPSILON || action.has_successor_conversion() || (self.cash_dividends && action.share_cash.abs() > f64::EPSILON); if !effective { continue; } - if let Some(successor) = action - .successor_symbol - .as_ref() - .filter(|_| action.has_successor_conversion()) - { - if data.instrument(successor).is_none() { - return Err(format!( - "manual corporate successor is absent from frozen source data: symbol={successor} action_date={date}" - )); - } - symbols.insert(successor.clone()); + if let Some((successor, _, _)) = successor_terms { + symbols.insert(successor.to_owned()); } actions.push(ManualCorporateActionReference { date: *date, diff --git a/crates/fidc-core/tests/manual_corporate_observation.rs b/crates/fidc-core/tests/manual_corporate_observation.rs index 05a1614..ed4eda1 100644 --- a/crates/fidc-core/tests/manual_corporate_observation.rs +++ b/crates/fidc-core/tests/manual_corporate_observation.rs @@ -18,6 +18,10 @@ enum Action { } fn data(action: Action) -> DataSet { + data_with_successor_metadata(action, true) +} + +fn data_with_successor_metadata(action: Action, include_successor: bool) -> DataSet { let days = [10, 11, 14, 15].map(date); let mut market = Vec::new(); let mut factors = Vec::new(); @@ -88,6 +92,7 @@ fn data(action: Action) -> DataSet { DataSet::from_components_with_actions( ["000001.SZ", "000002.SZ"] .into_iter() + .filter(|symbol| include_successor || *symbol != "000002.SZ") .map(|symbol| Instrument { symbol: symbol.into(), name: symbol.into(), @@ -280,6 +285,31 @@ fn delayed_sale_does_not_keep_unearned_corporate_entitlements() { } } +#[test] +fn paper_and_broker_observations_require_the_same_frozen_successor_scope() { + for adapter in ["paper", "gt", "qmt"] { + for delayed in [false, true] { + for sell in [false, true] { + let mut replay = source(delayed, sell); + for action in &mut replay.actions { + for order in &mut action.orders { order.source_adapter = Some(adapter.into()); } + } + replay.content_sha256 = replay.content_digest().unwrap(); + replay.validate().unwrap(); + let complete = run_custom(data(Action::Successor), replay.clone(), Hold, true, true).unwrap(); + assert_eq!(complete.holdings_summary[0].symbol, "000002.SZ"); + assert_eq!(complete.holdings_summary[0].quantity, 200); + assert!(complete.fills.is_empty()); + assert!(complete.order_events.is_empty()); + let error = run_custom(data_with_successor_metadata(Action::Successor, false), + replay, Hold, true, true).unwrap_err(); + assert!(error.to_string().contains("successor_instrument_missing"), + "{adapter} delayed={delayed} sell={sell}: {error}"); + } + } + } +} + #[test] fn corporate_replay_preserves_issued_orders_cash_flows_financing_and_charged_fees() { struct ExistingActivity { diff --git a/docs/successor-contract-20260914.md b/docs/successor-contract-20260914.md new file mode 100644 index 0000000..95f0a3d --- /dev/null +++ b/docs/successor-contract-20260914.md @@ -0,0 +1,29 @@ +# 换股证据与公司行为批次原子性 + +2026-09-14。开发候选,未发布生产;完整目标保持未完成。 + +## 已复现的错误 + +1. 正常公司行为入口未核对后继证券是否存在于冻结数据:源持仓100股即使缺后继资料,仍可先派息、送转并生成300股后继持仓。手工校正入口此前虽有单独检查,但正常入口没有,两个路径口径不同。 +2. `successor_ratio_value` 将缺失、零、负数和非有限比例静默替换为1;非法现金字段也可能替换为0,错误输入因此成为看似成功的换股。 +3. 批次先更新派息/送转、后处理换股现金。当后续现金超出金额合同而失败时,旧证券已经消失,新证券和应收款仍留在账本;目标股数调整及日志也可能部分提交。 + +负向测试在修复前实际失败。测试代码初次访问私有日期字段的编译错误先改为正式访问方法;这不是业务反例。用于金额超界的首个有限大数仍在金额合同范围内,已改用确实超界的有限值后复现部分更新,不将未超界样例称为错误。 + +## 处理合同 + +- 正常和手工权益校正使用同一换股条款/冻结证券资料校验。必须提供明确、有限、正的换股比例;代码不能缺失、含首尾空格或指向自身。孤立的比例/现金字段也拒绝。没有声明现金组成时仍代表没有现金腿,但明确提供的非法数字不得补0。 +- 缺少源证券或后继证券资料时明确报告代码及公司行为日期,不生成隐式证券,不把价格行当成证券资料,更不把后继代码加入策略候选。 +- 有经济影响的公司行为在独立账本副本中完成整个批次,成功后一次更新资金、持仓、应收、任务目标单位及说明。失败原状态不变;没有涉及实际持仓的正常日不克隆整个账本。 +- 送转/换股股数超过事件和持仓的表示范围时明确失败,不能靠浮点转整数的饱和转换或负数delta继续运行。 +- 保留已声明条款、原取得日期及最近买入日期、成本和原策略配置;不伪造委托/市场成交,不把换股当作重新选股。没有重写已提交订单或猜测新目标权重。 + +## 验证及剩余工作 + +本机Core907、Trading625、Runner463/API129通过;原9/63/16项ignore分别保留,不计通过。本次未改UI,也没有重复上一轮UI测试。 + +新增缺后继资料、13类坏条款、金额失败全批回滚(含原目标单位/权重)、送转/换股数量溢出、有效换股保留两类取得日期等专项。整段引擎覆盖Paper/GT/QMT三种已审计来源、及时/迟到及买/卖12组合:完整资料均保留200股后继持仓且没有新增市场订单/成交;缺资料均明确失败。这是隔离回放,不是券商连接/真实委托验收。 + +Source当前公司行为Arrow字段及Runner `CorporateActionRowRecord`仍未提供换股条款,日快照仍显式为无换股字段。这是独立的正式数据能力缺口,不能以本轮核心校验冒充已完成后继证券自动取数。完整范围闭包、目标状态跨公司行为语义、清空后参考、ETF跨模式及实际Source/Runner仍需继续验证。Source运行版本冻结保持,不改旧合同SHA、不注册替代合同或启用交易;后端发布还未准入。 + +Linux使用本轮新快照单独验收,收据完成后追加,不复用旧reinvestment/AEGDYL/UUx5ru/FewUWP结果。