严格校验换股证据并原子提交公司行为批次

This commit is contained in:
boris
2026-09-14 22:57:53 +08:00
parent d4e7cdd5b5
commit 59a0c95aae
5 changed files with 386 additions and 34 deletions
+303 -11
View File
@@ -4,6 +4,30 @@ use crate::{
};
use chrono::NaiveDate;
pub(crate) fn validate_action<'a>(
action: &'a crate::CorporateAction,
data: &DataSet,
) -> Result<Option<(&'a str, f64, f64)>, 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<String>,
cash_dividends_enabled: bool,
cash_dividend_adjusts_cost_basis: bool,
) -> Result<BrokerExecutionReport, BacktestError> {
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<String>,
cash_dividends_enabled: bool,
cash_dividend_adjusts_cost_basis: bool,
) -> Result<BrokerExecutionReport, BacktestError> {
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<crate::CorporateAction>, include_successor: bool) -> DataSet {
let mut instruments = data(false)
.instruments()
.values()
.cloned()
.collect::<Vec<_>>();
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"));
}
}
+21 -11
View File
@@ -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<Option<(&str, f64, f64)>, 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)))
}
}
@@ -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,
@@ -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 {
+29
View File
@@ -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结果。