From 8d90c983ec1118d65f45f7780b1795a3f899ef8b Mon Sep 17 00:00:00 2001 From: boris Date: Mon, 5 Oct 2026 05:56:31 +0800 Subject: [PATCH] =?UTF-8?q?=E9=AA=8C=E8=AF=81=E5=BE=85=E4=BA=A4=E4=BB=98?= =?UTF-8?q?=E8=82=A1=E4=BB=BD=E7=9A=84=E7=9B=AE=E6=A0=87=E4=BB=93=E4=BD=8D?= =?UTF-8?q?=E4=B8=8E=E8=AF=81=E5=88=B8=E8=BD=AC=E6=8D=A2=E4=BF=9D=E6=8A=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/fidc-core/src/broker.rs | 46 ++++++++++++++++++++++++++ crates/fidc-core/src/corporate_book.rs | 24 ++++++++++++++ 2 files changed, 70 insertions(+) diff --git a/crates/fidc-core/src/broker.rs b/crates/fidc-core/src/broker.rs index 4c58a55..a58f622 100644 --- a/crates/fidc-core/src/broker.rs +++ b/crates/fidc-core/src/broker.rs @@ -10740,6 +10740,52 @@ mod tests { assert_eq!(portfolio.position("000001.SZ").unwrap().quantity, 1_000); } + #[test] + fn pending_registered_shares_do_not_trigger_duplicate_target_buys() { + let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).unwrap(); + let broker = BrokerSimulator::new_with_execution_price( + ChinaAShareCostModel::default(), ChinaEquityRuleHooks, PriceField::Open, + ).with_matching_type(MatchingType::NextBarOpen) + .with_volume_limit(false).with_liquidity_limit(false).with_inactive_limit(false); + let mut snapshot = limit_test_snapshot(); + snapshot.date = date; + snapshot.prev_close = 10.0; + snapshot.open = 10.0; + snapshot.close = 10.0; + let data = DataSet::from_components_with_actions_and_quotes( + vec![limit_test_instrument()], vec![snapshot], Vec::new(), + vec![limit_test_candidate(true, true)], vec![limit_test_benchmark()], + Vec::new(), Vec::new(), + ).unwrap(); + let mut portfolio = PortfolioState::new(20_000.0); + portfolio.position_mut("000001.SZ").buy(date.pred_opt().unwrap(), 1_000, 10.0); + portfolio.apply_cash_delta(-10_000.0).unwrap(); + // Isolated broker fixture: these shares are an asset, but not delivered stock. + portfolio.position_mut("000001.SZ").share_receivables.push(crate::portfolio::ShareReceivable { + ex_date: date, record_date: date.pred_opt().unwrap(), + effective_date: date.succ_opt().unwrap(), quantity: 500, + holding_origin_date: date.pred_opt().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(), + &mut IntradayExecutionLedger::default(), &mut None, &mut BTreeMap::new(), &mut report).unwrap(); + broker.process_timed_target_value(date, &mut portfolio, &data, "000001.SZ", 15_000.0, + AlgoOrderStyle::Twap, None, None, "registered shares already meet timed target", + &mut BTreeMap::new(), &mut IntradayExecutionLedger::default(), &mut None, + &mut BTreeMap::new(), &mut report).unwrap(); + broker.process_target_shares(date, &mut portfolio, &data, "000001.SZ", 1_500, + "registered shares already meet quantity target", &mut BTreeMap::new(), + &mut IntradayExecutionLedger::default(), &mut None, &mut BTreeMap::new(), &mut report).unwrap(); + assert!(report.order_events.is_empty()); + assert!(report.fill_events.is_empty()); + assert_eq!(portfolio.cash(), 10_000.0); + let position = portfolio.position("000001.SZ").unwrap(); + assert_eq!(position.quantity, 1_000); + assert_eq!(position.share_receivable_quantity(), 500); + assert_eq!(position.sellable_qty(date), 1_000); + } + #[test] fn target_value_delta_below_order_step_is_audited_without_creating_zero_quantity_order() { let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date"); diff --git a/crates/fidc-core/src/corporate_book.rs b/crates/fidc-core/src/corporate_book.rs index d988472..6b489cc 100644 --- a/crates/fidc-core/src/corporate_book.rs +++ b/crates/fidc-core/src/corporate_book.rs @@ -820,6 +820,30 @@ mod tests { } } + #[test] + fn successor_conversion_cannot_discard_unresolved_share_entitlements() { + for already_pending in [false, true] { + let mut account = conversion_book(); + let action = if already_pending { + let input = conversion_data(vec![shares(Some(date().succ_opt().unwrap()))], false); + apply(date(), &input, &mut account, &mut Vec::new(), true, true).unwrap(); + conversion() + } else { + let mut action = conversion(); + action.share_bonus = 0.5; + action.share_effective_date = Some(date().succ_opt().unwrap()); + action + }; + let input = conversion_data(vec![action], true); + let before = account.financial_replay_identity(); + let mut notes = vec!["original".into()]; + let error = apply(date(), &input, &mut account, &mut notes, true, true).unwrap_err(); + assert!(error.to_string().contains("pending_share_successor_terms_required")); + assert_eq!(account.financial_replay_identity(), before); + assert_eq!(notes, ["original"]); + } + } + #[test] fn missing_or_backward_cash_payment_date_never_updates_the_account() { for payment in [None, date().pred_opt()] {