按账户汇总分配登记权益并保持显式模拟舍入合同
This commit is contained in:
@@ -683,6 +683,7 @@ mod tests {
|
||||
payable_date: None,
|
||||
record_date: date().pred_opt(),
|
||||
share_effective_date: None,
|
||||
share_rounding_rule: None,
|
||||
share_cash: 0.,
|
||||
share_bonus: 0.,
|
||||
share_gift: 0.,
|
||||
|
||||
@@ -272,6 +272,8 @@ pub struct CorporateAction {
|
||||
pub record_date: Option<NaiveDate>,
|
||||
#[serde(default, with = "optional_date_format")]
|
||||
pub share_effective_date: Option<NaiveDate>,
|
||||
#[serde(default)]
|
||||
pub share_rounding_rule: Option<String>,
|
||||
pub share_cash: f64,
|
||||
pub share_bonus: f64,
|
||||
pub share_gift: f64,
|
||||
@@ -5547,6 +5549,7 @@ mod tests {
|
||||
payable_date: Some(date),
|
||||
record_date: date.pred_opt(),
|
||||
share_effective_date: None,
|
||||
share_rounding_rule: None,
|
||||
share_cash: 0.1,
|
||||
share_bonus: 0.02,
|
||||
share_gift: 0.03,
|
||||
|
||||
@@ -26576,6 +26576,7 @@ mod tests {
|
||||
payable_date: Some(date),
|
||||
record_date: date.pred_opt(),
|
||||
share_effective_date: Some(date),
|
||||
share_rounding_rule: None,
|
||||
share_cash: 0.2,
|
||||
share_bonus: 0.1,
|
||||
share_gift: 0.0,
|
||||
|
||||
@@ -912,16 +912,35 @@ impl PortfolioState {
|
||||
.map_err(|_| "corporate_action_quantity_overflow: share rate exceeds exact arithmetic domain")?;
|
||||
if rate <= Decimal::ZERO { return Err("corporate_action_share_rate_not_positive".into()); }
|
||||
let registered = self.registered_lots(record_date, &action.symbol)?;
|
||||
if action.share_rounding_rule.as_deref().is_some_and(|rule| rule != "aggregate_half_up") {
|
||||
return Err("corporate_action_share_rounding_rule_unsupported".into());
|
||||
}
|
||||
let ideal = registered.iter().try_fold(Decimal::ZERO, |total, lot| {
|
||||
Decimal::from(lot.quantity).checked_mul(rate).and_then(|quantity| total.checked_add(quantity))
|
||||
.ok_or("corporate_action_quantity_overflow: registered entitlement")
|
||||
})?;
|
||||
let settled = if ideal.fract() == Decimal::ZERO { ideal }
|
||||
else if action.share_rounding_rule.as_deref() == Some("aggregate_half_up") {
|
||||
ideal.round_dp_with_strategy(0, RoundingStrategy::MidpointAwayFromZero)
|
||||
} else { return Err("corporate_action_fractional_share_settlement_evidence_required".into()); };
|
||||
let total = settled.to_u32().ok_or("corporate_action_quantity_overflow: registered entitlement")?;
|
||||
let mut allocated = Vec::new();
|
||||
let mut floor_total = 0u32;
|
||||
for (index, lot) in registered.iter().enumerate() {
|
||||
let value = Decimal::from(lot.quantity).checked_mul(rate).ok_or("registered share entitlement overflow")?;
|
||||
let floor = value.trunc().to_u32().ok_or("registered share entitlement overflow")?;
|
||||
floor_total = floor_total.checked_add(floor).ok_or("registered share entitlement overflow")?;
|
||||
allocated.push((index, floor, value.fract()));
|
||||
}
|
||||
let remainder = total.checked_sub(floor_total).ok_or("registered share allocation exceeds total")? as usize;
|
||||
if remainder > allocated.len() { return Err("registered share allocation remainder invalid".into()); }
|
||||
allocated.sort_by(|a,b| b.2.cmp(&a.2).then_with(|| a.0.cmp(&b.0)));
|
||||
for (_, quantity, _) in allocated.iter_mut().take(remainder) { *quantity += 1; }
|
||||
allocated.sort_by_key(|entry| entry.0);
|
||||
let mut claims = Vec::new();
|
||||
let mut total = 0u32;
|
||||
for lot in registered {
|
||||
let quantity = Decimal::from(lot.quantity).checked_mul(rate).ok_or("corporate_action_quantity_overflow: registered entitlement")?;
|
||||
if quantity.fract() != Decimal::ZERO {
|
||||
return Err("corporate_action_fractional_share_settlement_evidence_required".into());
|
||||
}
|
||||
let quantity = quantity.to_u32().ok_or("corporate_action_quantity_overflow: registered entitlement")?;
|
||||
for ((index, quantity, _), lot) in allocated.into_iter().zip(registered) {
|
||||
let _ = index;
|
||||
if quantity == 0 { continue; }
|
||||
total = total.checked_add(quantity).ok_or("registered share entitlement overflow")?;
|
||||
claims.push(ShareReceivable { ex_date: action.date, record_date, effective_date,
|
||||
quantity, holding_origin_date: lot.origin.acquired_date });
|
||||
}
|
||||
|
||||
@@ -160,6 +160,7 @@ fn observed_manual_trades_then_split_keep_real_fill_protection_and_lock_dates()
|
||||
payable_date: None,
|
||||
record_date: Some(d(14)),
|
||||
share_effective_date: Some(d(15)),
|
||||
share_rounding_rule: None,
|
||||
share_cash: 0.,
|
||||
share_bonus: 1.,
|
||||
share_gift: 0.,
|
||||
|
||||
@@ -269,6 +269,7 @@ fn successor_conversion_depletes_older_source_lots_before_newer_successor_buys()
|
||||
payable_date: None,
|
||||
record_date: Some(dates[1]),
|
||||
share_effective_date: None,
|
||||
share_rounding_rule: None,
|
||||
share_cash: 0.,
|
||||
share_bonus: 0.,
|
||||
share_gift: 0.,
|
||||
@@ -524,6 +525,7 @@ fn engine_reinvests_dividend_receivable_in_round_lots() {
|
||||
payable_date: Some(payable_date),
|
||||
record_date: Some(buy_date),
|
||||
share_effective_date: None,
|
||||
share_rounding_rule: None,
|
||||
share_cash: 1.05,
|
||||
share_bonus: 0.0,
|
||||
share_gift: 0.0,
|
||||
@@ -609,6 +611,7 @@ fn engine_settles_same_day_dividend_after_split_for_aiquant_semantics() {
|
||||
payable_date: Some(ex_date),
|
||||
record_date: Some(buy_date),
|
||||
share_effective_date: Some(ex_date),
|
||||
share_rounding_rule: None,
|
||||
share_cash: 1.05,
|
||||
share_bonus: 0.2,
|
||||
share_gift: 0.0,
|
||||
|
||||
@@ -532,6 +532,7 @@ fn engine_applies_successor_conversion_before_unresolved_delisting_audit() {
|
||||
payable_date: None,
|
||||
record_date: Some(date1),
|
||||
share_effective_date: None,
|
||||
share_rounding_rule: None,
|
||||
share_cash: 0.0,
|
||||
share_bonus: 0.0,
|
||||
share_gift: 0.0,
|
||||
|
||||
@@ -94,6 +94,7 @@ fn data() -> DataSet {
|
||||
payable_date: Some(day(14)),
|
||||
record_date: Some(day(11)),
|
||||
share_effective_date: None,
|
||||
share_rounding_rule: None,
|
||||
share_cash: 1.05,
|
||||
share_bonus: 0.,
|
||||
share_gift: 0.,
|
||||
|
||||
@@ -121,7 +121,8 @@ fn data_with_successor_metadata(action: Action, include_successor: bool) -> Data
|
||||
symbol: "000001.SZ".into(),
|
||||
payable_date: Some(date(14)),
|
||||
record_date: Some(date(11)),
|
||||
share_effective_date: None,
|
||||
share_effective_date: Some(date(14)),
|
||||
share_rounding_rule: Some("aggregate_half_up".into()),
|
||||
share_cash: if matches!(action, Action::Dividend) {
|
||||
1.
|
||||
} else {
|
||||
|
||||
Reference in New Issue
Block a user