让人工零仓位和零权重约束红利再投
This commit is contained in:
@@ -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<String>,
|
||||
reinvest_enabled: bool,
|
||||
runtime_input: Option<&crate::manual_execution::ManualExecutionReplay>,
|
||||
) -> Result<BrokerExecutionReport, BacktestError> {
|
||||
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<String> {
|
||||
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<Option<ManualReinvestmentControl<'_>>, 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<String>,
|
||||
reinvest_enabled: bool,
|
||||
control: Option<ManualReinvestmentControl<'_>>,
|
||||
) -> Result<BrokerExecutionReport, BacktestError> {
|
||||
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(),
|
||||
|
||||
@@ -4689,7 +4689,8 @@ where
|
||||
fn settle_cash_receivables(
|
||||
&self, date: NaiveDate, portfolio: &mut PortfolioState, notes: &mut Vec<String>,
|
||||
) -> Result<BrokerExecutionReport, BacktestError> {
|
||||
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(
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user