分离分红应收与除权估值并禁止从股息推算参考价

This commit is contained in:
boris
2026-10-05 11:22:20 +08:00
parent f789866d33
commit a370ef54cf
10 changed files with 138 additions and 20 deletions
+2 -1
View File
@@ -10764,10 +10764,11 @@ mod tests {
portfolio.register_share_entitlement(&crate::CorporateAction {
date, symbol: "000001.SZ".into(), record_date: date.pred_opt(),
share_effective_date: date.succ_opt(), share_rounding_rule: None,
ex_reference_price: Some(10.0),
payable_date: None, share_cash: 0.0, share_bonus: 0.5, share_gift: 0.0,
issue_quantity: 0.0, issue_price: 0.0, reform: false, adjust_factor: None,
successor_symbol: None, successor_ratio: None, successor_cash: None,
}, Some(10.0)).unwrap();
}).unwrap();
let mut report = BrokerExecutionReport::default();
broker.process_target_value(date, &mut portfolio, &data, "000001.SZ", 15_000.0,
"registered shares already meet target value", &mut BTreeMap::new(),
+82 -4
View File
@@ -15,6 +15,9 @@ pub(crate) fn validate_action<'a>(
action.symbol, action.date
)
})?;
if action.ex_reference_price.is_some() {
action.required_ex_reference_price()?;
}
if action.share_cash != 0.0 {
action.required_cash_payment_date()?;
action.required_record_date()?;
@@ -70,7 +73,7 @@ pub(crate) fn apply(
action.has_effect()
&& portfolio
.position(&action.symbol)
.is_some_and(|position| position.quantity > 0)
.is_some_and(|position| position.economic_quantity() > 0)
|| (cash_dividends_enabled && action.share_cash != 0.0
&& portfolio.registered_quantity(action.record_date.expect("validated record date"), &action.symbol).expect("validated closing fact") > 0)
|| ((action.share_bonus != 0.0 || action.share_gift != 0.0)
@@ -109,6 +112,9 @@ fn apply_inner(
if !action.has_effect() {
continue;
}
if action.share_cash != 0.0 || action.share_bonus != 0.0 || action.share_gift != 0.0 {
portfolio.apply_ex_reference_mark(action).map_err(BacktestError::Execution)?;
}
if cash_dividends_enabled && action.share_cash != 0.0 {
let record_date = action.required_record_date().map_err(BacktestError::Execution)?;
let entitled_quantity = portfolio.registered_quantity(record_date, &action.symbol).map_err(BacktestError::Execution)?;
@@ -151,9 +157,7 @@ fn apply_inner(
if action.share_bonus != 0.0 || action.share_gift != 0.0 {
let record = action.required_record_date().map_err(BacktestError::Execution)?;
let reference = data.price_on_or_before(record, &action.symbol, PriceField::Close)
.map(|price| (price - action.share_cash) / action.split_ratio());
let quantity = portfolio.register_share_entitlement(action, reference).map_err(BacktestError::Execution)?;
let quantity = portfolio.register_share_entitlement(action).map_err(BacktestError::Execution)?;
if quantity > 0 {
notes.push(format!("share_distribution_receivable {} record_date={} ex_date={} effective_date={} entitled_shares={quantity}",
action.symbol, record, action.date, action.required_share_effective_date().map_err(BacktestError::Execution)?));
@@ -685,6 +689,7 @@ mod tests {
record_date: date().pred_opt(),
share_effective_date: None,
share_rounding_rule: None,
ex_reference_price: None,
share_cash: 0.,
share_bonus: 0.,
share_gift: 0.,
@@ -741,6 +746,7 @@ mod tests {
action.successor_ratio = None;
action.successor_cash = None;
action.share_cash = 0.5;
action.ex_reference_price = Some(9.5);
action.payable_date = payment;
action
}
@@ -751,10 +757,82 @@ mod tests {
action.successor_ratio = None;
action.successor_cash = None;
action.share_bonus = 0.5;
action.ex_reference_price = Some(10.0 / 1.5);
action.share_effective_date = delivery;
action
}
#[test]
fn differential_cash_entitlement_and_reference_price_remain_distinct() {
let mut action = dividend(Some(date().succ_opt().unwrap()));
// Gross cash and the diluted reference deduction differ. The prior
// 20.00 close is a synthetic fixture, not an issuer market observation.
action.share_cash = 5.028;
action.ex_reference_price = Some(20.0 - 5.0147304);
let input = conversion_data(vec![action], false);
let mut book = PortfolioState::new(1000.0);
book.position_mut("000001.SZ").buy(date().pred_opt().unwrap(), 100, 20.0);
book.record_closing_holdings(date().pred_opt().unwrap()).unwrap();
book.begin_trading_day_at(date());
apply(date(), &input, &mut book, &mut Vec::new(), true, true).unwrap();
let position = book.position("000001.SZ").unwrap();
assert!((position.last_price - 14.9852696).abs() < 1e-10);
assert!((position.average_cost - 14.972).abs() < 1e-10);
assert_eq!(book.cash(), 1000.0);
assert!((book.cash_receivables()[0].amount - 502.8).abs() < 1e-8);
assert!((book.total_equity() - 3001.32696).abs() < 1e-7);
assert!((book.daily_pnl() - 1.32696).abs() < 1e-7);
}
#[test]
fn same_day_actual_quote_is_not_reduced_by_an_older_lots_entitlement() {
let mut action = dividend(Some(date().succ_opt().unwrap()));
action.ex_reference_price = None;
let input = conversion_data(vec![action], false);
let mut book = conversion_book();
book.begin_trading_day_at(date());
book.position_mut("000001.SZ").buy(date(), 100, 8.8);
apply(date(), &input, &mut book, &mut Vec::new(), true, true).unwrap();
let position = book.position("000001.SZ").unwrap();
assert_eq!(position.last_price, 8.8);
assert_eq!(position.quantity, 200);
assert_eq!(book.cash_receivables()[0].amount, 50.0);
}
#[test]
fn missing_or_invalid_reference_cannot_guess_a_price_or_partly_change_the_ledger() {
for reference in [None, Some(0.0), Some(-1.0), Some(f64::NAN), Some(f64::INFINITY)] {
let mut action = dividend(Some(date()));
action.ex_reference_price = reference;
let input = conversion_data(vec![action], false);
let mut book = conversion_book();
let before = book.financial_replay_identity();
let mut notes = vec!["original".to_owned()];
assert!(apply(date(), &input, &mut book, &mut notes, true, true).is_err());
assert_eq!(book.financial_replay_identity(), before);
assert_eq!(notes, ["original"]);
}
}
#[test]
fn combined_cash_and_later_share_delivery_apply_the_reference_once() {
let mut action = shares(Some(date().succ_opt().unwrap()));
action.share_cash = 0.5;
action.payable_date = Some(date().succ_opt().unwrap());
action.ex_reference_price = Some(9.5 / 1.5);
let input = conversion_data(vec![action], false);
let mut book = conversion_book();
book.begin_trading_day_at(date());
apply(date(), &input, &mut book, &mut Vec::new(), true, true).unwrap();
let position = book.position("000001.SZ").unwrap();
assert!((position.last_price - 9.5 / 1.5).abs() < 1e-10);
assert_eq!(position.economic_quantity(), 150);
assert_eq!(position.sellable_qty(date()), 100);
assert_eq!(book.cash_receivables()[0].amount, 50.0);
assert!((book.total_equity() - 2000.0).abs() < 1e-8);
assert!(book.daily_pnl().abs() < 1e-8);
}
#[test]
fn registered_shares_are_valued_but_not_delivered_or_sellable_before_the_original_date() {
let delivery = date().succ_opt().unwrap().succ_opt().unwrap();
+11
View File
@@ -274,6 +274,10 @@ pub struct CorporateAction {
pub share_effective_date: Option<NaiveDate>,
#[serde(default)]
pub share_rounding_rule: Option<String>,
/// Source-verified ex-date reference price, distinct from the shareholder's
/// cash entitlement. Missing evidence must never be derived from share_cash.
#[serde(default)]
pub ex_reference_price: Option<f64>,
pub share_cash: f64,
pub share_bonus: f64,
pub share_gift: f64,
@@ -450,6 +454,12 @@ impl<'a> Iterator for ExecutionQuoteIterator<'a> {
}
impl CorporateAction {
pub(crate) fn required_ex_reference_price(&self) -> Result<f64, String> {
self.ex_reference_price.filter(|price| price.is_finite() && *price > 0.0)
.ok_or_else(|| format!("corporate_action_ex_reference_price_unverified: symbol={} ex_date={}",
self.symbol, self.date))
}
pub(crate) fn required_record_date(&self) -> Result<NaiveDate, String> {
let record = self.record_date.ok_or_else(|| format!(
"corporate_action_record_date_missing: symbol={} ex_date={}", self.symbol, self.date))?;
@@ -5550,6 +5560,7 @@ mod tests {
record_date: date.pred_opt(),
share_effective_date: None,
share_rounding_rule: None,
ex_reference_price: None,
share_cash: 0.1,
share_bonus: 0.02,
share_gift: 0.03,
@@ -26577,6 +26577,7 @@ mod tests {
record_date: date.pred_opt(),
share_effective_date: Some(date),
share_rounding_rule: None,
ex_reference_price: None,
share_cash: 0.2,
share_bonus: 0.1,
share_gift: 0.0,
+31 -15
View File
@@ -904,8 +904,28 @@ impl PortfolioState {
total.checked_add(lot.quantity).ok_or_else(|| "registered entitlement quantity overflow".into()))
}
pub(crate) fn register_share_entitlement(&mut self, action: &crate::CorporateAction,
accounting_reference: Option<f64>) -> Result<u32, String> {
pub(crate) fn apply_ex_reference_mark(&mut self, action: &crate::CorporateAction) -> Result<(), String> {
let Some(position) = self.positions.get_mut(&action.symbol) else { return Ok(()); };
if position.economic_quantity() == 0 { return Ok(()); }
if position.last_market_mark_date.is_some_and(|day| day > action.date) {
return Err("corporate_action_market_mark_after_ex_date".into());
}
if position.last_market_mark_date == Some(action.date)
&& (!position.last_price.is_finite() || position.last_price <= 0.0) {
return Err("corporate_action_same_day_market_mark_invalid".into());
}
// An actual same-day quote already reflects the corporate action.
// Prior marks need the independent, verified reference price; lot cash
// allocations and gross entitlements do not determine a market price.
if position.last_market_mark_date != Some(action.date) {
position.last_price = action.required_ex_reference_price()?;
position.last_market_mark_date = Some(action.date);
position.refresh_day_pnl();
}
Ok(())
}
pub(crate) fn register_share_entitlement(&mut self, action: &crate::CorporateAction) -> Result<u32, String> {
let record_date = action.required_record_date()?;
let effective_date = action.required_share_effective_date()?;
let rate = Decimal::from_str_exact(&(action.share_bonus + action.share_gift).to_string())
@@ -953,18 +973,15 @@ impl PortfolioState {
let economic_before = position.economic_quantity();
position.economic_quantity().checked_add(total).ok_or("economic share quantity overflow")?;
let ratio = action.split_ratio();
if position.last_price > 0.0 && position.last_price.is_finite() {
// A purchase already marked on this ex-date has an actual ex-date
// quote. Earlier marks are translated to the accounting reference.
if position.last_market_mark_date.is_some_and(|day| day < action.date) {
position.last_price /= ratio;
} else if position.last_market_mark_date.is_none() {
position.last_price = accounting_reference.filter(|price| price.is_finite() && *price > 0.0)
.ok_or("corporate_action_share_accounting_reference_missing")?;
}
} else {
position.last_price = accounting_reference.filter(|price| price.is_finite() && *price > 0.0)
.ok_or("corporate_action_share_accounting_reference_missing")?;
if position.last_market_mark_date.is_some_and(|day| day > action.date) {
return Err("corporate_action_market_mark_after_ex_date".into());
}
if position.last_market_mark_date != Some(action.date) {
position.last_price = action.required_ex_reference_price()?;
position.last_market_mark_date = Some(action.date);
}
if !position.last_price.is_finite() || position.last_price <= 0.0 {
return Err("corporate_action_share_accounting_reference_missing".into());
}
if position.day_start_economic_quantity > 0 && economic_before > 0 {
position.day_split_ratio *= f64::from(economic_before + total) / f64::from(economic_before);
@@ -1035,7 +1052,6 @@ impl PortfolioState {
let per_current_share = position_delta.to_f64() / f64::from(position.quantity);
position.average_price -= per_current_share;
if adjust_cost { position.average_cost -= per_current_share; }
position.last_price -= per_current_share;
position.day_dividend_cash = position.day_dividend_cash.checked_add(position_delta).ok_or("registered day income overflow")?;
position.refresh_day_pnl();
}
@@ -161,6 +161,7 @@ fn observed_manual_trades_then_split_keep_real_fill_protection_and_lock_dates()
record_date: Some(d(14)),
share_effective_date: Some(d(15)),
share_rounding_rule: None,
ex_reference_price: Some(5.0),
share_cash: 0.,
share_bonus: 1.,
share_gift: 0.,
@@ -270,6 +270,7 @@ fn successor_conversion_depletes_older_source_lots_before_newer_successor_buys()
record_date: Some(dates[1]),
share_effective_date: None,
share_rounding_rule: None,
ex_reference_price: None,
share_cash: 0.,
share_bonus: 0.,
share_gift: 0.,
@@ -526,6 +527,7 @@ fn engine_reinvests_dividend_receivable_in_round_lots() {
record_date: Some(buy_date),
share_effective_date: None,
share_rounding_rule: None,
ex_reference_price: Some(8.95),
share_cash: 1.05,
share_bonus: 0.0,
share_gift: 0.0,
@@ -612,6 +614,7 @@ fn engine_settles_same_day_dividend_after_split_for_aiquant_semantics() {
record_date: Some(buy_date),
share_effective_date: Some(ex_date),
share_rounding_rule: None,
ex_reference_price: Some((10.0 - 1.05) / 1.2),
share_cash: 1.05,
share_bonus: 0.2,
share_gift: 0.0,
+1
View File
@@ -533,6 +533,7 @@ fn engine_applies_successor_conversion_before_unresolved_delisting_audit() {
record_date: Some(date1),
share_effective_date: None,
share_rounding_rule: None,
ex_reference_price: None,
share_cash: 0.0,
share_bonus: 0.0,
share_gift: 0.0,
@@ -95,6 +95,7 @@ fn data() -> DataSet {
record_date: Some(day(11)),
share_effective_date: None,
share_rounding_rule: None,
ex_reference_price: Some(8.95),
share_cash: 1.05,
share_bonus: 0.,
share_gift: 0.,
@@ -123,6 +123,11 @@ fn data_with_successor_metadata(action: Action, include_successor: bool) -> Data
record_date: Some(date(11)),
share_effective_date: Some(date(14)),
share_rounding_rule: Some("aggregate_half_up".into()),
ex_reference_price: match action {
Action::Dividend => Some(9.0),
Action::Split => Some(5.0),
Action::Successor => None,
},
share_cash: if matches!(action, Action::Dividend) {
1.
} else {