分离分红应收与除权估值并禁止从股息推算参考价
This commit is contained in:
@@ -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(),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user