From 05315f05d7d446f8e4bf2c39eab9c0cd07d510bb Mon Sep 17 00:00:00 2001 From: boris Date: Mon, 5 Oct 2026 04:48:22 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8C=89=E8=B4=A6=E6=88=B7=E6=B1=87=E6=80=BB?= =?UTF-8?q?=E5=88=86=E9=85=8D=E7=99=BB=E8=AE=B0=E6=9D=83=E7=9B=8A=E5=B9=B6?= =?UTF-8?q?=E4=BF=9D=E6=8C=81=E6=98=BE=E5=BC=8F=E6=A8=A1=E6=8B=9F=E8=88=8D?= =?UTF-8?q?=E5=85=A5=E5=90=88=E5=90=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/fidc-core/src/corporate_book.rs | 1 + crates/fidc-core/src/data.rs | 3 ++ .../fidc-core/src/platform_expr_strategy.rs | 1 + crates/fidc-core/src/portfolio.rs | 35 ++++++++++++++----- .../tests/automatic_trade_protection.rs | 1 + crates/fidc-core/tests/corporate_actions.rs | 3 ++ crates/fidc-core/tests/delisting.rs | 1 + .../tests/dividend_reinvestment_contract.rs | 1 + .../tests/manual_corporate_observation.rs | 3 +- 9 files changed, 40 insertions(+), 9 deletions(-) diff --git a/crates/fidc-core/src/corporate_book.rs b/crates/fidc-core/src/corporate_book.rs index f36cba3..1abf210 100644 --- a/crates/fidc-core/src/corporate_book.rs +++ b/crates/fidc-core/src/corporate_book.rs @@ -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., diff --git a/crates/fidc-core/src/data.rs b/crates/fidc-core/src/data.rs index 95eaf17..d556b63 100644 --- a/crates/fidc-core/src/data.rs +++ b/crates/fidc-core/src/data.rs @@ -272,6 +272,8 @@ pub struct CorporateAction { pub record_date: Option, #[serde(default, with = "optional_date_format")] pub share_effective_date: Option, + #[serde(default)] + pub share_rounding_rule: Option, 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, diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index 4832c5a..496ca87 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -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, diff --git a/crates/fidc-core/src/portfolio.rs b/crates/fidc-core/src/portfolio.rs index ba96e47..910c3b2 100644 --- a/crates/fidc-core/src/portfolio.rs +++ b/crates/fidc-core/src/portfolio.rs @@ -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 }); } diff --git a/crates/fidc-core/tests/automatic_trade_protection.rs b/crates/fidc-core/tests/automatic_trade_protection.rs index 576a4b1..4056dcb 100644 --- a/crates/fidc-core/tests/automatic_trade_protection.rs +++ b/crates/fidc-core/tests/automatic_trade_protection.rs @@ -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., diff --git a/crates/fidc-core/tests/corporate_actions.rs b/crates/fidc-core/tests/corporate_actions.rs index 86dfb27..91d318d 100644 --- a/crates/fidc-core/tests/corporate_actions.rs +++ b/crates/fidc-core/tests/corporate_actions.rs @@ -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, diff --git a/crates/fidc-core/tests/delisting.rs b/crates/fidc-core/tests/delisting.rs index 01d8650..c1a993b 100644 --- a/crates/fidc-core/tests/delisting.rs +++ b/crates/fidc-core/tests/delisting.rs @@ -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, diff --git a/crates/fidc-core/tests/dividend_reinvestment_contract.rs b/crates/fidc-core/tests/dividend_reinvestment_contract.rs index 4322b77..9270cfe 100644 --- a/crates/fidc-core/tests/dividend_reinvestment_contract.rs +++ b/crates/fidc-core/tests/dividend_reinvestment_contract.rs @@ -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., diff --git a/crates/fidc-core/tests/manual_corporate_observation.rs b/crates/fidc-core/tests/manual_corporate_observation.rs index a0dc0f4..fa57b76 100644 --- a/crates/fidc-core/tests/manual_corporate_observation.rs +++ b/crates/fidc-core/tests/manual_corporate_observation.rs @@ -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 {