按账户汇总分配登记权益并保持显式模拟舍入合同

This commit is contained in:
boris
2026-10-05 04:48:22 +08:00
parent 4eb4c17724
commit 05315f05d7
9 changed files with 40 additions and 9 deletions
+1
View File
@@ -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.,
+3
View File
@@ -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,
+27 -8
View File
@@ -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,
+1
View File
@@ -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 {