From 818552bc969e0ea40081f0770c1d97db574a1981 Mon Sep 17 00:00:00 2001 From: boris Date: Tue, 15 Sep 2026 00:23:09 +0800 Subject: [PATCH] =?UTF-8?q?=E8=AE=A9=E4=BA=BA=E5=B7=A5=E9=9B=B6=E4=BB=93?= =?UTF-8?q?=E4=BD=8D=E5=92=8C=E9=9B=B6=E6=9D=83=E9=87=8D=E7=BA=A6=E6=9D=9F?= =?UTF-8?q?=E7=BA=A2=E5=88=A9=E5=86=8D=E6=8A=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/fidc-core/src/corporate_book.rs | 201 +++++++++++++++++- crates/fidc-core/src/engine.rs | 3 +- .../fidc-core/src/manual_corporate_replay.rs | 5 +- .../tests/dividend_reinvestment_contract.rs | 147 +++++++++++++ docs/manual-zero-reinvestment-20260915.md | 31 +++ 5 files changed, 376 insertions(+), 11 deletions(-) create mode 100644 docs/manual-zero-reinvestment-20260915.md diff --git a/crates/fidc-core/src/corporate_book.rs b/crates/fidc-core/src/corporate_book.rs index dda1690..594a1e5 100644 --- a/crates/fidc-core/src/corporate_book.rs +++ b/crates/fidc-core/src/corporate_book.rs @@ -2,7 +2,7 @@ use crate::{ AccountEvent, BacktestError, BrokerExecutionReport, CashReceivable, DataSet, FillEvent, OrderSide, PortfolioState, PositionEvent, PriceField, ProcessEvent, ProcessEventKind, }; -use chrono::NaiveDate; +use chrono::{NaiveDate, TimeZone}; pub(crate) fn validate_action<'a>( action: &'a crate::CorporateAction, @@ -267,6 +267,7 @@ pub(crate) fn settle_receivables( portfolio: &mut PortfolioState, notes: &mut Vec, reinvest_enabled: bool, + runtime_input: Option<&crate::manual_execution::ManualExecutionReplay>, ) -> Result { if !portfolio .cash_receivables() @@ -277,18 +278,111 @@ pub(crate) fn settle_receivables( } let mut next = portfolio.clone(); let mut recorded = Vec::new(); - let report = settle_receivables_inner(date, data, &mut next, &mut recorded, reinvest_enabled)?; + let control = if reinvest_enabled { + manual_reinvestment_control(date, runtime_input)? + } else { + None + }; + let report = settle_receivables_inner( + date, + data, + &mut next, + &mut recorded, + reinvest_enabled, + control, + )?; *portfolio = next; notes.extend(recorded); Ok(report) } +/// The accounting stage precedes the market session. A later same-day setting +/// must not retroactively change an allocation already observed at settlement. +#[derive(Clone, Copy)] +enum ManualReinvestmentControl<'a> { + Event(&'a crate::position_exposure::PositionExposureEvent), + LegacyZero(NaiveDate), +} + +impl ManualReinvestmentControl<'_> { + fn suppression(self, symbol: &str) -> Option { + use crate::position_exposure::PositionExposureAction as Action; + match self { + Self::LegacyZero(date) => Some(format!( + "runtime_zero_exposure legacy_effective_date={date}" + )), + Self::Event(event) => { + if matches!(event.action, Action::Restore) { + return None; + } + let reason = if matches!( + event.action, + Action::Scale { requested_bps: 0 } + | Action::Set { + target_exposure_bps: 0 + } + ) { + "runtime_zero_exposure" + } else if event + .allocation_weights_bps + .as_ref() + .is_some_and(|weights| weights.get(symbol).copied().unwrap_or(0) == 0) + { + "runtime_zero_allocation" + } else { + return None; + }; + Some(format!( + "{reason} event_sequence={} effective_at={}", + event.sequence, event.effective_at + )) + } + } + } +} + +fn manual_reinvestment_control( + date: NaiveDate, + runtime_input: Option<&crate::manual_execution::ManualExecutionReplay>, +) -> Result>, BacktestError> { + let Some(input) = runtime_input else { + return Ok(None); + }; + let at = chrono::FixedOffset::east_opt(8 * 3600) + .unwrap() + .from_local_datetime(&date.and_hms_opt(0, 0, 0).unwrap()) + .single() + .ok_or_else(|| { + BacktestError::Execution( + "dividend_reinvestment: accounting stage clock is out of range".into(), + ) + })? + .with_timezone(&chrono::Utc); + // The input has already been validated and bound to the runtime audit. + // Keep the same (time, sequence) precedence as PositionExposureTimeline. + if let Some(event) = input + .position_exposure_events + .iter() + .filter(|event| event.effective_at <= at) + .max_by_key(|event| (event.effective_at, event.sequence)) + { + return Ok(Some(ManualReinvestmentControl::Event(event))); + } + Ok(input + .legacy_position_exposure_bps + .range(..=date) + .next_back() + .filter(|(_, bps)| **bps == 0) + .map(|(day, _)| ManualReinvestmentControl::LegacyZero(*day))) +} + fn settle_receivables_inner( date: NaiveDate, data: &DataSet, portfolio: &mut PortfolioState, notes: &mut Vec, reinvest_enabled: bool, + control: Option>, ) -> Result { let mut report = BrokerExecutionReport::default(); let due = portfolio.take_due_cash_receivables(date); @@ -301,7 +395,15 @@ fn settle_receivables_inner( "cash_receivable_settled {} ex_date={} payable_date={} cash={:.2}", receivable.symbol, receivable.ex_date, receivable.payable_date, receivable.amount ); - if reinvest_enabled + if let Some(suppression) = + control.and_then(|control| control.suppression(&receivable.symbol)) + && receivable.reason.starts_with("cash_dividend") + && receivable.amount > 0. + { + note.push_str(&format!( + " dividend_reinvestment_not_applied reason={suppression} cash_retained=true" + )); + } else if reinvest_enabled && receivable.reason.starts_with("cash_dividend") && receivable.amount > 0.0 { @@ -346,10 +448,32 @@ fn settle_receivables_inner( let raw_quantity = raw as u32; let reinvest_quantity = (raw_quantity / round_lot) * round_lot; if reinvest_quantity > 0 { - let reinvest_cash = reinvest_quantity as f64 * price; - let residual_cash = receivable.amount - reinvest_cash; + // Report the same micro-unit amount actually posted to + // the ledger, not a floating multiplication residue. + let reinvest_money = + crate::FixedMoney::from_f64(reinvest_quantity as f64 * price) + .ok_or_else(|| { + BacktestError::Execution( + "dividend_reinvestment: allocation amount out of range" + .into(), + ) + })?; + let cash_delta = reinvest_money.checked_neg().ok_or_else(|| { + BacktestError::Execution( + "dividend_reinvestment: cash amount out of range".into(), + ) + })?; + let residual_cash = crate::FixedMoney::from_f64(receivable.amount) + .and_then(|cash| cash.checked_sub(reinvest_money)) + .ok_or_else(|| { + BacktestError::Execution( + "dividend_reinvestment: residual amount out of range".into(), + ) + })? + .to_f64(); + let reinvest_cash = reinvest_money.to_f64(); portfolio - .apply_cash_delta(-reinvest_cash) + .apply_cash_delta_fixed(cash_delta) .map_err(BacktestError::Execution)?; portfolio.position_mut(&receivable.symbol).buy( date, @@ -384,7 +508,7 @@ fn settle_receivables_inner( commission: 0.0, stamp_tax: 0.0, transfer_fee: 0.0, - net_cash_flow: -reinvest_cash, + net_cash_flow: cash_delta.to_f64(), reason: "dividend_reinvestment".to_string(), }); report.position_events.push(PositionEvent { @@ -475,7 +599,8 @@ mod tests { let mut book = book(); let before = book.financial_replay_identity(); let error = - settle_receivables(date(), &data(false), &mut book, &mut Vec::new(), true).unwrap_err(); + settle_receivables(date(), &data(false), &mut book, &mut Vec::new(), true, None) + .unwrap_err(); assert!(error.to_string().contains("accounting reference missing")); assert_eq!(book.financial_replay_identity(), before); } @@ -483,7 +608,8 @@ mod tests { fn terminated_security_keeps_paid_cash_and_is_not_recreated_by_reinvestment() { let mut book = book(); let mut notes = Vec::new(); - let report = settle_receivables(date(), &data(true), &mut book, &mut notes, true).unwrap(); + let report = + settle_receivables(date(), &data(true), &mut book, &mut notes, true, None).unwrap(); assert_eq!(book.cash(), 110.); assert!(book.positions().is_empty()); assert!(book.cash_receivables().is_empty()); @@ -491,6 +617,63 @@ mod tests { assert!(notes[0].contains("dividend_reinvestment_not_applied")); } + #[test] + fn manual_zero_skips_only_unused_allocation_facts_not_invalid_cash_evidence() { + let mut input = crate::manual_execution::ManualExecutionReplay { + schema: crate::manual_execution::MANUAL_REPLAY_SCHEMA.into(), + runtime_id: "runtime".into(), + account_id: "account".into(), + source_contract_sha256: "a".repeat(64), + content_sha256: String::new(), + observation_cutoff: "2026-09-14T08:00:00Z".parse().unwrap(), + actions: vec![], + position_exposure_events: vec![], + legacy_position_exposure_bps: std::collections::BTreeMap::from([(date(), 0)]), + }; + input.content_sha256 = input.content_digest().unwrap(); + input.validate().unwrap(); + let mut account = book(); + let mut notes = Vec::new(); + // No historical price is present, but no optional purchase is wanted. + let report = settle_receivables( + date(), + &data(false), + &mut account, + &mut notes, + true, + Some(&input), + ) + .unwrap(); + assert_eq!(account.cash(), 110.); + assert!(account.positions().is_empty()); + assert!(report.fill_events.is_empty()); + assert!(notes[0].contains("runtime_zero_exposure")); + let mut account = book(); + account.add_cash_receivable(CashReceivable { + symbol: "000001.SZ".into(), + ex_date: date(), + payable_date: date(), + amount: f64::NAN, + reason: "cash_dividend invalid fixture".into(), + }); + let mut notes = vec!["prior".into()]; + assert!( + settle_receivables( + date(), + &data(false), + &mut account, + &mut notes, + true, + Some(&input) + ) + .is_err() + ); + assert_eq!(account.cash(), 10.); + assert_eq!(account.cash_receivables().len(), 2); + assert!(account.cash_receivables()[1].amount.is_nan()); + assert_eq!(notes, ["prior"]); + } + fn conversion() -> crate::CorporateAction { crate::CorporateAction { date: date(), diff --git a/crates/fidc-core/src/engine.rs b/crates/fidc-core/src/engine.rs index 0e4bd7b..ca54890 100644 --- a/crates/fidc-core/src/engine.rs +++ b/crates/fidc-core/src/engine.rs @@ -4689,7 +4689,8 @@ where fn settle_cash_receivables( &self, date: NaiveDate, portfolio: &mut PortfolioState, notes: &mut Vec, ) -> Result { - crate::corporate_book::settle_receivables(date, &self.data, portfolio, notes, self.dividend_reinvestment) + crate::corporate_book::settle_receivables(date, &self.data, portfolio, notes, self.dividend_reinvestment, + self.manual_execution_source.as_deref()) } fn settle_pending_cash_flows( diff --git a/crates/fidc-core/src/manual_corporate_replay.rs b/crates/fidc-core/src/manual_corporate_replay.rs index e225310..1d7ada1 100644 --- a/crates/fidc-core/src/manual_corporate_replay.rs +++ b/crates/fidc-core/src/manual_corporate_replay.rs @@ -163,6 +163,7 @@ impl ManualCorporateReplay { } let at = local(observation.fill.observed_at); let reference = self.replay( + source, current.initial_cash_fixed(), &all[..applied_count], self.reconciled_count.get(), @@ -182,6 +183,7 @@ impl ManualCorporateReplay { )); } let replayed = self.replay( + source, current.initial_cash_fixed(), &all[..=applied_count], applied_count + 1, @@ -263,6 +265,7 @@ impl ManualCorporateReplay { fn replay( &self, + runtime_input: &ManualExecutionReplay, initial_cash: FixedMoney, manual: &[ManualFillObservation<'_>], economic_count: usize, @@ -373,7 +376,7 @@ impl ManualCorporateReplay { .map_err(|error| error.to_string())?; } Event::Settle(date) => { - crate::corporate_book::settle_receivables(date, data, &mut book, &mut Vec::new(), self.reinvest) + crate::corporate_book::settle_receivables(date, data, &mut book, &mut Vec::new(), self.reinvest, Some(runtime_input)) .map_err(|error| error.to_string())?; } Event::Manual(observation) => { diff --git a/crates/fidc-core/tests/dividend_reinvestment_contract.rs b/crates/fidc-core/tests/dividend_reinvestment_contract.rs index 7060dc5..06d0ea6 100644 --- a/crates/fidc-core/tests/dividend_reinvestment_contract.rs +++ b/crates/fidc-core/tests/dividend_reinvestment_contract.rs @@ -326,3 +326,150 @@ fn weekend_receipts_and_morning_allocations_are_in_the_next_progress_batch() { result.fills.len() + result.manual_executions.len() ); } + +fn exposure_event(id: &str, sequence: u64, at: &str, action: fidc_core::position_exposure::PositionExposureAction) + -> fidc_core::position_exposure::PositionExposureEvent { + fidc_core::position_exposure::PositionExposureEvent { + event_id: id.into(), sequence, effective_at: at.parse().unwrap(), allocation_weights_bps: None, action, + } +} + +fn cleared_reinvestment_case( + events: Vec, + legacy: std::collections::BTreeMap, + extra_buy_delayed: Option, +) -> fidc_core::BacktestResult { + let mut parts = data().snapshot_components(); + parts.corporate_actions[0].payable_date = Some(day(15)); + let data = DataSet::from_components_with_actions_and_quotes(parts.instruments, parts.market, + parts.factors, parts.candidates, parts.benchmarks, parts.corporate_actions, parts.execution_quotes).unwrap(); + let mut source = manual_source(false); + let mut sale = source.actions[0].clone(); + sale.action_id = "clear".into(); sale.audit_event_ids = vec!["clear-audit".into()]; + sale.confirmed_at = "2026-09-14T05:59:59Z".parse().unwrap(); + sale.confirmation_observed_at = sale.confirmed_at; + let order = &mut sale.orders[0]; + order.order_id = "clear-order".into(); order.side = fidc_core::OrderSide::Sell; + order.order_created_at = sale.confirmed_at; + order.terminal_observed_at = "2026-09-14T06:00:01Z".parse().unwrap(); + let fill = &mut order.fills[0]; + fill.trade_id = "clear-fill".into(); fill.observation_event_id = "clear-receipt".into(); + fill.observation_sequence = 2; fill.fee_observation_event_id = "clear-receipt".into(); + fill.fee_observation_sequence = 2; fill.trade_date = day(14); + fill.executed_at = "2026-09-14T06:00:00Z".parse().unwrap(); + fill.observed_at = order.terminal_observed_at; fill.fee_observed_at = order.terminal_observed_at; + fill.price = "8.95".parse().unwrap(); + source.actions.push(sale); + if let Some(delayed) = extra_buy_delayed { + let mut extra = source.actions[0].clone(); + extra.action_id = "extra".into(); extra.audit_event_ids = vec!["extra-audit".into()]; + extra.confirmed_at = "2026-09-11T06:00:59Z".parse().unwrap(); + extra.confirmation_observed_at = extra.confirmed_at; + let order = &mut extra.orders[0]; + order.order_id = "extra-order".into(); order.order_created_at = extra.confirmed_at; + order.terminal_observed_at = if delayed { "2026-09-15T01:15:00Z" } else { "2026-09-11T06:01:01Z" }.parse().unwrap(); + let fill = &mut order.fills[0]; + fill.trade_id = "extra-fill".into(); fill.observation_event_id = "extra-receipt".into(); + fill.observation_sequence = if delayed { 3 } else { 2 }; + fill.fee_observation_event_id = "extra-receipt".into(); fill.fee_observation_sequence = fill.observation_sequence; + fill.executed_at = "2026-09-11T06:01:00Z".parse().unwrap(); fill.observed_at = order.terminal_observed_at; + fill.fee_observed_at = order.terminal_observed_at; + if !delayed { + source.actions[1].orders[0].fills[0].observation_sequence = 3; + source.actions[1].orders[0].fills[0].fee_observation_sequence = 3; + } + source.actions.push(extra); + } + source.position_exposure_events = events; + source.legacy_position_exposure_bps = legacy; + source.content_sha256 = source.content_digest().unwrap(); + let mut config = fidc_core::PlatformExprStrategyConfig::generic(); + config.signal_symbol = SYMBOL.into(); config.benchmark_symbol = "000300.SH".into(); + config.rotation_enabled = false; config.matching_type = MatchingType::CurrentBarClose; + BacktestEngine::new(data, fidc_core::PlatformExprStrategy::new(config), + BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks) + .with_matching_type(MatchingType::CurrentBarClose).with_volume_limit(false).with_liquidity_limit(false), + BacktestConfig { initial_cash: 50000., benchmark_code: "000300.SH".into(), start_date: Some(day(11)), + end_date: Some(day(15)), decision_lag_trading_days: 0, execution_price_field: PriceField::Close }) + .with_dividend_reinvestment(true).with_observed_manual_executions(source).unwrap().run().unwrap() +} + +#[test] +fn an_effective_manual_zero_must_not_recreate_a_cleared_position_on_dividend_payment() { + use fidc_core::position_exposure::PositionExposureAction as Action; + let zero = exposure_event("zero", 1, "2026-09-14T07:00:00Z", Action::Set { target_exposure_bps: 0 }); + let result = cleared_reinvestment_case(vec![zero], Default::default(), None); + assert!(result.fills.is_empty(), "{:?}", result.fills); + assert!(result.holdings_summary.is_empty()); + assert_eq!(result.equity_curve.last().unwrap().cash, 49998.); + assert!(result.equity_curve.last().unwrap().notes.contains("runtime_zero_exposure")); +} + +#[test] +fn a_clear_without_a_manual_zero_keeps_the_declared_legacy_reinvestment_model() { + let result = cleared_reinvestment_case(vec![], Default::default(), None); + assert_eq!(result.fills.len(), 1); + assert_eq!((result.fills[0].quantity, result.fills[0].price, result.fills[0].commission), (100, 8.95, 0.)); + assert_eq!(result.fills[0].gross_amount, 895.); + assert_eq!(result.fills[0].net_cash_flow, -895.); + assert!(result.order_events.is_empty()); +} + +#[test] +fn reinvestment_respects_zero_restore_same_instant_sequence_and_legacy_granularity() { + use fidc_core::position_exposure::PositionExposureAction as Action; + let before = "2026-09-14T07:00:00Z"; + let settlement = "2026-09-14T16:00:00Z"; + let later = "2026-09-15T01:31:00Z"; + let zero = || exposure_event("zero", 1, before, Action::Scale { requested_bps: 0 }); + let cases = vec![ + (vec![zero()], Default::default(), false), + (vec![exposure_event("zero-at-settlement", 1, settlement, Action::Set { target_exposure_bps: 0 })], Default::default(), false), + (vec![exposure_event("later-zero", 1, later, Action::Set { target_exposure_bps: 0 })], Default::default(), true), + (vec![zero(), exposure_event("restore", 2, before, Action::Restore)], Default::default(), true), + (vec![exposure_event("restore", 1, before, Action::Restore), exposure_event("last-zero", 2, before, Action::Set { target_exposure_bps: 0 })], Default::default(), false), + (vec![exposure_event("restore", 2, before, Action::Restore), zero()], Default::default(), true), + (vec![zero(), exposure_event("later-restore", 2, later, Action::Restore)], Default::default(), false), + (vec![], std::collections::BTreeMap::from([(day(14), 0)]), false), + (vec![exposure_event("restore-legacy", 1, before, Action::Restore)], std::collections::BTreeMap::from([(day(14), 0)]), true), + (vec![exposure_event("positive", 1, before, Action::Set { target_exposure_bps: 3000 })], Default::default(), true), + ]; + for (events, legacy, allowed) in cases { + let result = cleared_reinvestment_case(events, legacy, None); + assert_eq!(result.fills.len(), usize::from(allowed)); + assert_eq!(result.equity_curve.last().unwrap().cash, if allowed { 49103. } else { 49998. }); + assert_eq!(result.holdings_summary.iter().map(|holding| holding.quantity).sum::(), if allowed {100} else {0}); + assert!(result.order_events.is_empty()); + } +} + +#[test] +fn an_explicit_zero_member_weight_or_omission_blocks_only_that_reinvestment() { + use fidc_core::position_exposure::PositionExposureAction as Action; + for included in [false, true] { + for weight in [0, 10000] { + let mut event = exposure_event("allocation", 1, "2026-09-14T07:00:00Z", Action::Set { target_exposure_bps: 5000 }); + let mut weights = std::collections::BTreeMap::from([("000002.SZ".into(), if included {10000-weight} else {10000})]); + if included { weights.insert(SYMBOL.into(), weight); } + event.allocation_weights_bps = Some(weights); + let allowed = included && weight > 0; + let result = cleared_reinvestment_case(vec![event], Default::default(), None); + assert_eq!(result.fills.len(), usize::from(allowed)); + if !allowed { assert!(result.equity_curve.last().unwrap().notes.contains("runtime_zero_allocation")); } + } + } +} + +#[test] +fn late_corporate_replay_uses_the_same_zero_policy_and_retains_actual_manual_shares() { + use fidc_core::position_exposure::PositionExposureAction as Action; + let event = exposure_event("zero", 1, "2026-09-14T07:00:00Z", Action::Set { target_exposure_bps: 0 }); + let timely = cleared_reinvestment_case(vec![event.clone()], Default::default(), Some(false)); + let late = cleared_reinvestment_case(vec![event], Default::default(), Some(true)); + assert!(timely.fills.is_empty()); assert!(late.fills.is_empty()); + assert_eq!(timely.equity_curve.last().unwrap().cash, 41047.); + assert_eq!(late.equity_curve.last().unwrap().cash, timely.equity_curve.last().unwrap().cash); + assert_eq!(late.holdings_summary[0].quantity, 1000); + assert_eq!(late.holdings_summary[0].quantity, timely.holdings_summary[0].quantity); + assert_eq!(late.manual_executions.last().unwrap().corporate_adjustment.as_ref().unwrap().corporate_cash_delta, "1050"); +} diff --git a/docs/manual-zero-reinvestment-20260915.md b/docs/manual-zero-reinvestment-20260915.md new file mode 100644 index 0000000..0c5b515 --- /dev/null +++ b/docs/manual-zero-reinvestment-20260915.md @@ -0,0 +1,31 @@ +# 人工零仓位与可选红利再投的一致性 + +2026-09-15。开发候选,未发布生产;完整目标保持未完成。 + +## 已核对的模型和反例 + +ALV `position_model.py::_handle_dividend_payable` 与FIDC现有再投都是参考价、整手、零费用的历史账务模型,不是市场委托;两边默认再投关闭。只有显式开启该模型时,才出现本轮组合问题:用户已清仓并在派息前将人工仓位设为0%,派息日仍账务买入100股。完整回放已在修复前实际复现。 + +修复不把这项功能改成开盘市价单,不改变原参考价或整手定量。没有人工零仓位时,清仓本身不能作为猜测用户设置的依据,继续按原已声明的再投模型计算。 + +## 控制优先级 + +- 只读取已校验、绑定运行审计的人工仓位/权重事件。上海交易日00:00是当前历史模型的结算入账时点,不是券商成交时间;使用此时已经生效的最新 `(effective_at, sequence)`。 +- 已生效的人工Scale 0、Set 0禁止可选再投。明确的人工分配中,证券权重0或被排除也只让该证券分红留为现金,不替它买其他股票。 +- 同时刻按真实序号,输入数组顺序不能改变结果。较晚才生效的零仓位或恢复不得倒改早先结算;Restore覆盖旧人工限制,但不代表强制100%仓位。旧日级控制保留日级粒度。 +- 没有人工限制、恢复跟随或非零且未排除该证券时,保留原再投模型,不对价格、手续费或分红金额另加比例计算。 +- 分红到账、实际成交、送转/换股等既有金融事实不会因0%被抹掉。0%停止的是可选新增分配,不伪造清仓或强制卖出已有/T+1持仓。 + +正常结算与迟到回报经济重放使用同一控制判断,控制源显式传递,不能只在策略下单层截断。日志包含 `runtime_zero_exposure` 或 `runtime_zero_allocation`、生效时间/序号以及 `cash_retained=true`。 + +## 金额口径 + +原100股×8.95元的账本已经扣895元,但新成交记录的浮点乘积可能输出894.9999999999999。现在记录与现金扣账使用同一个微元金额,输出895和-895;保留原价格、股数、零费用和旧历史结果,不将显示尾差当成真实资金差额。 + +## 验证和边界 + +新增六项专项:有效0%、无控制的原模型、时间/序号/旧日级/恢复组合、逐股0和排除、迟到权益重放、无用价格不查询且坏现金仍原子失败。清仓0%样例最终现金49998、持仓0;没有0%仍100股、8.95、零费。额外实际买入迟到的样例,不掩盖真实1000股持仓,及时/迟到最终现金都为41047,权益现金校正1050,无可选再投。 + +本机Core919、Trading625、Runner463/API129全量通过,原9/63/16项ignore不计。没有修改UI、在线配置、账户、通知或交易路由;没有进行新的私有PG、实际Source/Runner、生产页面或券商委托验收。本修复针对经审计人工控制,不提前执行策略表达式来猜测其意图。 + +Source d5明确冻结保持。仍需完成正式换股字段/范围闭包、在线转换事实及重建、旧目标和活动单边界、其余生命周期/ETF矩阵与真实Source/Runner联合验收。Linux用新快照独立验证,不复用上一轮收据;未建release/tag或重启生产。