明确红利账务再投来源并修复流式遗漏与校正时钟
This commit is contained in:
@@ -5335,6 +5335,7 @@ where
|
||||
.map_err(BacktestError::Execution)?;
|
||||
|
||||
report.fill_events.push(FillEvent {
|
||||
origin: crate::events::FillOrigin::MarketExecution,
|
||||
date,
|
||||
decision_date: None,
|
||||
order_created_date: None,
|
||||
@@ -7168,6 +7169,7 @@ where
|
||||
}
|
||||
|
||||
report.fill_events.push(FillEvent {
|
||||
origin: crate::events::FillOrigin::MarketExecution,
|
||||
date,
|
||||
decision_date: None,
|
||||
order_created_date: None,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use crate::{
|
||||
AccountEvent, BacktestError, BrokerExecutionReport, CashReceivable, DataSet, PortfolioState,
|
||||
PositionEvent,
|
||||
AccountEvent, BacktestError, BrokerExecutionReport, CashReceivable, DataSet, FillEvent,
|
||||
OrderSide, PortfolioState, PositionEvent, PriceField, ProcessEvent, ProcessEventKind,
|
||||
};
|
||||
use chrono::NaiveDate;
|
||||
|
||||
@@ -159,3 +159,235 @@ pub(crate) fn apply(
|
||||
portfolio.prune_flat_positions();
|
||||
Ok(report)
|
||||
}
|
||||
/// Preserve the declared fee-free accounting allocation model; this does not
|
||||
/// submit a market order or use a later opening quote as an earlier fact.
|
||||
pub(crate) fn settle_receivables(
|
||||
date: NaiveDate,
|
||||
data: &DataSet,
|
||||
portfolio: &mut PortfolioState,
|
||||
notes: &mut Vec<String>,
|
||||
reinvest_enabled: bool,
|
||||
) -> Result<BrokerExecutionReport, BacktestError> {
|
||||
if !portfolio
|
||||
.cash_receivables()
|
||||
.iter()
|
||||
.any(|row| row.payable_date <= date)
|
||||
{
|
||||
return Ok(BrokerExecutionReport::default());
|
||||
}
|
||||
let mut next = portfolio.clone();
|
||||
let mut recorded = Vec::new();
|
||||
let report = settle_receivables_inner(date, data, &mut next, &mut recorded, reinvest_enabled)?;
|
||||
*portfolio = next;
|
||||
notes.extend(recorded);
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
fn settle_receivables_inner(
|
||||
date: NaiveDate,
|
||||
data: &DataSet,
|
||||
portfolio: &mut PortfolioState,
|
||||
notes: &mut Vec<String>,
|
||||
reinvest_enabled: bool,
|
||||
) -> Result<BrokerExecutionReport, BacktestError> {
|
||||
let mut report = BrokerExecutionReport::default();
|
||||
let due = portfolio.take_due_cash_receivables(date);
|
||||
for receivable in due {
|
||||
let cash_before = portfolio.cash();
|
||||
portfolio
|
||||
.settle_cash_receivable(&receivable)
|
||||
.map_err(BacktestError::Execution)?;
|
||||
let mut note = format!(
|
||||
"cash_receivable_settled {} ex_date={} payable_date={} cash={:.2}",
|
||||
receivable.symbol, receivable.ex_date, receivable.payable_date, receivable.amount
|
||||
);
|
||||
if reinvest_enabled
|
||||
&& receivable.reason.starts_with("cash_dividend")
|
||||
&& receivable.amount > 0.0
|
||||
{
|
||||
let instrument = data.instrument(&receivable.symbol).ok_or_else(|| BacktestError::Execution(format!(
|
||||
"dividend_reinvestment: instrument metadata missing symbol={} payable_date={date}", receivable.symbol)))?;
|
||||
if let Some(reason) = instrument.dated_market_absence_reason(date) {
|
||||
note.push_str(&format!(
|
||||
" dividend_reinvestment_not_applied reason={reason} cash_retained=true"
|
||||
));
|
||||
} else {
|
||||
let (reinvest_price, reference_basis) = if let Some(position) = portfolio
|
||||
.position(&receivable.symbol)
|
||||
.filter(|position| position.quantity > 0)
|
||||
{
|
||||
(Some(position.last_price), "adjusted_carried_mark")
|
||||
} else {
|
||||
(
|
||||
data.calendar().previous_day(date).and_then(|prev_date| {
|
||||
data.price_on_or_before(
|
||||
prev_date,
|
||||
&receivable.symbol,
|
||||
PriceField::Close,
|
||||
)
|
||||
}),
|
||||
"previous_completed_close",
|
||||
)
|
||||
};
|
||||
let price = reinvest_price.filter(|price| price.is_finite() && *price > 0.).ok_or_else(|| BacktestError::Execution(format!(
|
||||
"dividend_reinvestment: accounting reference missing or invalid symbol={} payable_date={date} basis={reference_basis}", receivable.symbol)))?;
|
||||
let round_lot = instrument.round_lot;
|
||||
if round_lot == 0 {
|
||||
return Err(BacktestError::Execution(format!(
|
||||
"dividend_reinvestment: invalid quantity unit symbol={}",
|
||||
receivable.symbol
|
||||
)));
|
||||
}
|
||||
{
|
||||
let raw = (receivable.amount / price).floor();
|
||||
if !raw.is_finite() || raw > i32::MAX as f64 {
|
||||
return Err(BacktestError::Execution("dividend_reinvestment: accounting allocation quantity exceeds the ledger contract".into()));
|
||||
}
|
||||
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;
|
||||
portfolio
|
||||
.apply_cash_delta(-reinvest_cash)
|
||||
.map_err(BacktestError::Execution)?;
|
||||
portfolio.position_mut(&receivable.symbol).buy(
|
||||
date,
|
||||
reinvest_quantity,
|
||||
price,
|
||||
);
|
||||
|
||||
note = format!(
|
||||
"cash_receivable_reinvested {} ex_date={} payable_date={} cash={:.2} reinvest_qty={} reinvest_price={:.4} residual_cash={:.2}",
|
||||
receivable.symbol,
|
||||
receivable.ex_date,
|
||||
receivable.payable_date,
|
||||
receivable.amount,
|
||||
reinvest_quantity,
|
||||
price,
|
||||
residual_cash
|
||||
);
|
||||
report.fill_events.push(FillEvent {
|
||||
origin: crate::events::FillOrigin::DividendReinvestment,
|
||||
date,
|
||||
decision_date: None,
|
||||
order_created_date: None,
|
||||
execution_date: None,
|
||||
execution_start_timestamp: date.and_hms_opt(0, 0, 0),
|
||||
execution_timestamp: date.and_hms_opt(0, 0, 0),
|
||||
order_id: None,
|
||||
symbol: receivable.symbol.clone(),
|
||||
side: OrderSide::Buy,
|
||||
quantity: reinvest_quantity,
|
||||
price,
|
||||
gross_amount: reinvest_cash,
|
||||
commission: 0.0,
|
||||
stamp_tax: 0.0,
|
||||
transfer_fee: 0.0,
|
||||
net_cash_flow: -reinvest_cash,
|
||||
reason: "dividend_reinvestment".to_string(),
|
||||
});
|
||||
report.position_events.push(PositionEvent {
|
||||
date,
|
||||
symbol: receivable.symbol.clone(),
|
||||
delta_quantity: reinvest_quantity as i32,
|
||||
quantity_after: portfolio
|
||||
.position(&receivable.symbol)
|
||||
.map(|position| position.quantity)
|
||||
.unwrap_or(0),
|
||||
average_cost: portfolio
|
||||
.position(&receivable.symbol)
|
||||
.map(|position| position.average_cost)
|
||||
.unwrap_or(0.0),
|
||||
realized_pnl_delta: 0.0,
|
||||
reason: "dividend_reinvestment".to_string(),
|
||||
});
|
||||
report.process_events.push(ProcessEvent {
|
||||
date,
|
||||
kind: ProcessEventKind::Trade,
|
||||
order_id: None,
|
||||
symbol: Some(receivable.symbol.clone()),
|
||||
side: Some(OrderSide::Buy),
|
||||
detail: format!("dividend_reinvestment model=fee_free_accounting booked_at={} quantity={} price={} reference_basis={} ex_date={} payable_date={} residual_cash={}",
|
||||
date.and_hms_opt(0,0,0).unwrap(), reinvest_quantity, price, reference_basis,
|
||||
receivable.ex_date, receivable.payable_date, residual_cash),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
notes.push(note.clone());
|
||||
report.account_events.push(AccountEvent {
|
||||
date,
|
||||
cash_before,
|
||||
cash_after: portfolio.cash(),
|
||||
total_equity: portfolio.total_equity(),
|
||||
note,
|
||||
});
|
||||
}
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
fn date() -> NaiveDate {
|
||||
NaiveDate::from_ymd_opt(2026, 9, 14).unwrap()
|
||||
}
|
||||
fn data(delisted: bool) -> DataSet {
|
||||
DataSet::from_components(
|
||||
vec![crate::Instrument {
|
||||
symbol: "000001.SZ".into(),
|
||||
name: "fixture".into(),
|
||||
board: "SZ".into(),
|
||||
round_lot: 100,
|
||||
listed_at: Some(NaiveDate::from_ymd_opt(2020, 1, 1).unwrap()),
|
||||
delisted_at: delisted.then_some(date()),
|
||||
status: "active".into(),
|
||||
}],
|
||||
vec![],
|
||||
vec![],
|
||||
vec![],
|
||||
vec![crate::BenchmarkSnapshot {
|
||||
date: date(),
|
||||
benchmark: "000300.SH".into(),
|
||||
open: 100.,
|
||||
close: 100.,
|
||||
prev_close: 100.,
|
||||
volume: 0,
|
||||
}],
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
fn book() -> PortfolioState {
|
||||
let mut book = PortfolioState::new(10.);
|
||||
book.add_cash_receivable(CashReceivable {
|
||||
symbol: "000001.SZ".into(),
|
||||
ex_date: date().pred_opt().unwrap(),
|
||||
payable_date: date(),
|
||||
amount: 100.,
|
||||
reason: "cash_dividend 1".into(),
|
||||
});
|
||||
book
|
||||
}
|
||||
#[test]
|
||||
fn missing_accounting_reference_is_atomic_not_a_silent_cash_only_success() {
|
||||
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();
|
||||
assert!(error.to_string().contains("accounting reference missing"));
|
||||
assert_eq!(book.financial_replay_identity(), before);
|
||||
}
|
||||
#[test]
|
||||
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();
|
||||
assert_eq!(book.cash(), 110.);
|
||||
assert!(book.positions().is_empty());
|
||||
assert!(book.cash_receivables().is_empty());
|
||||
assert!(report.fill_events.is_empty());
|
||||
assert!(notes[0].contains("dividend_reinvestment_not_applied"));
|
||||
}
|
||||
}
|
||||
|
||||
+23
-132
@@ -439,9 +439,11 @@ pub struct BacktestDayProgress {
|
||||
pub benchmark_close: f64,
|
||||
pub daily_fill_count: usize,
|
||||
pub daily_manual_fill_count: usize,
|
||||
pub daily_corporate_allocation_count: usize,
|
||||
pub daily_order_count: usize,
|
||||
pub cumulative_trade_count: usize,
|
||||
pub cumulative_manual_fill_count: usize,
|
||||
pub cumulative_corporate_allocation_count: usize,
|
||||
pub holding_count: usize,
|
||||
pub notes: String,
|
||||
pub diagnostics: String,
|
||||
@@ -3050,7 +3052,8 @@ where
|
||||
crate::manual_corporate_replay::ManualCorporateReplay::new(
|
||||
first,
|
||||
self.cash_dividends_enabled,
|
||||
self.cash_dividend_adjusts_cost_basis,
|
||||
self.cash_dividend_adjusts_cost_basis,
|
||||
self.dividend_reinvestment,
|
||||
self.broker.matching_type(),
|
||||
self.broker.execution_price_field(),
|
||||
self.broker.same_day_buy_close_mark_at_fill(),
|
||||
@@ -3111,8 +3114,13 @@ where
|
||||
};
|
||||
let mut stock_equity_by_date = BTreeMap::<NaiveDate, f64>::new();
|
||||
let mut previous_external_cash_flow_total = portfolio.external_cash_flow_total();
|
||||
let mut cumulative_corporate_allocation_count = 0;
|
||||
|
||||
for (execution_idx, execution_date) in execution_dates.iter().copied().enumerate() {
|
||||
let day_order_start = result.order_events.len();
|
||||
let day_fill_start = result.fills.len();
|
||||
let day_manual_start = result.manual_executions.len();
|
||||
let progress_process_start = result.process_events.len();
|
||||
let mut corporate_action_notes = Vec::new();
|
||||
// Non-trading-day receipts between two sessions must precede the
|
||||
// next session's corporate actions. They do not create market bars.
|
||||
@@ -3178,10 +3186,6 @@ where
|
||||
execution_date,
|
||||
execution_date,
|
||||
);
|
||||
let day_order_start = result.order_events.len();
|
||||
let day_fill_start = result.fills.len();
|
||||
let day_manual_start = result.manual_executions.len();
|
||||
|
||||
let decision_slot = execution_schedule
|
||||
.get(execution_idx)
|
||||
.and_then(|(_, decision_slot)| *decision_slot);
|
||||
@@ -3226,6 +3230,8 @@ where
|
||||
self.extend_result(&mut result, report, execution_date, execution_date);
|
||||
result.risk_decisions.extend(execution_risk_decisions);
|
||||
let daily_fill_count = result.fills.len() - day_fill_start + result.manual_executions.len() - day_manual_start;
|
||||
let daily_corporate_allocation_count = result.fills[day_fill_start..].iter().filter(|fill| fill.origin == crate::FillOrigin::DividendReinvestment).count();
|
||||
cumulative_corporate_allocation_count += daily_corporate_allocation_count;
|
||||
let daily_order_count = result.order_events.len() - day_order_start;
|
||||
|
||||
let benchmark =
|
||||
@@ -3247,7 +3253,6 @@ where
|
||||
let holding_start = result.daily_holdings.len();
|
||||
let holding_count = holdings_for_day.len();
|
||||
result.daily_holdings.extend(holdings_for_day);
|
||||
let progress_process_start = result.process_events.len();
|
||||
self.retain_process_events(&mut result.process_events, &mut process_events);
|
||||
let aggregate_cash = self.aggregate_cash(&portfolio);
|
||||
let aggregate_market_value = self.aggregate_market_value(&portfolio);
|
||||
@@ -3285,9 +3290,11 @@ where
|
||||
benchmark_close: latest.benchmark_close,
|
||||
daily_fill_count,
|
||||
daily_manual_fill_count: result.manual_executions.len() - day_manual_start,
|
||||
daily_corporate_allocation_count,
|
||||
daily_order_count,
|
||||
cumulative_trade_count: result.fills.len() + result.manual_executions.len(),
|
||||
cumulative_manual_fill_count: result.manual_executions.len(),
|
||||
cumulative_corporate_allocation_count,
|
||||
holding_count,
|
||||
notes: include_progress_diagnostics
|
||||
.then(|| latest.notes.clone())
|
||||
@@ -4532,7 +4539,6 @@ where
|
||||
let holding_start = result.daily_holdings.len();
|
||||
let holding_count = holdings_for_day.len();
|
||||
result.daily_holdings.extend(holdings_for_day);
|
||||
let progress_process_start = result.process_events.len();
|
||||
self.retain_process_events(&mut result.process_events, &mut process_events);
|
||||
let aggregate_cash = self.aggregate_cash(&portfolio);
|
||||
let aggregate_market_value = self.aggregate_market_value(&portfolio);
|
||||
@@ -4559,6 +4565,8 @@ where
|
||||
.equity_curve
|
||||
.last()
|
||||
.expect("equity point pushed for progress event");
|
||||
let daily_corporate_allocation_count = result.fills[day_fill_start..].iter().filter(|fill| fill.origin == crate::FillOrigin::DividendReinvestment).count();
|
||||
cumulative_corporate_allocation_count += daily_corporate_allocation_count;
|
||||
on_progress(&BacktestDayProgress {
|
||||
date: execution_date,
|
||||
cash: latest.cash,
|
||||
@@ -4570,9 +4578,11 @@ where
|
||||
benchmark_close: latest.benchmark_close,
|
||||
daily_fill_count,
|
||||
daily_manual_fill_count: result.manual_executions.len() - day_manual_start,
|
||||
daily_corporate_allocation_count,
|
||||
daily_order_count,
|
||||
cumulative_trade_count: result.fills.len() + result.manual_executions.len(),
|
||||
cumulative_manual_fill_count: result.manual_executions.len(),
|
||||
cumulative_corporate_allocation_count,
|
||||
holding_count,
|
||||
notes: include_progress_diagnostics
|
||||
.then(|| latest.notes.clone())
|
||||
@@ -4677,130 +4687,9 @@ where
|
||||
}
|
||||
|
||||
fn settle_cash_receivables(
|
||||
&self,
|
||||
date: NaiveDate,
|
||||
portfolio: &mut PortfolioState,
|
||||
notes: &mut Vec<String>,
|
||||
&self, date: NaiveDate, portfolio: &mut PortfolioState, notes: &mut Vec<String>,
|
||||
) -> Result<BrokerExecutionReport, BacktestError> {
|
||||
let mut report = BrokerExecutionReport::default();
|
||||
let due = portfolio.take_due_cash_receivables(date);
|
||||
for receivable in due {
|
||||
let cash_before = portfolio.cash();
|
||||
portfolio
|
||||
.settle_cash_receivable(&receivable)
|
||||
.map_err(BacktestError::Execution)?;
|
||||
let mut note = format!(
|
||||
"cash_receivable_settled {} ex_date={} payable_date={} cash={:.2}",
|
||||
receivable.symbol, receivable.ex_date, receivable.payable_date, receivable.amount
|
||||
);
|
||||
if self.dividend_reinvestment
|
||||
&& receivable.reason.starts_with("cash_dividend")
|
||||
&& receivable.amount > 0.0
|
||||
{
|
||||
let reinvest_price = portfolio
|
||||
.position(&receivable.symbol)
|
||||
.map(|position| position.last_price)
|
||||
.filter(|price| price.is_finite() && *price > 0.0)
|
||||
.or_else(|| {
|
||||
self.data
|
||||
.calendar()
|
||||
.previous_day(date)
|
||||
.and_then(|prev_date| {
|
||||
self.data.price_on_or_before(
|
||||
prev_date,
|
||||
&receivable.symbol,
|
||||
PriceField::Close,
|
||||
)
|
||||
})
|
||||
});
|
||||
let round_lot = self
|
||||
.data
|
||||
.instrument(&receivable.symbol)
|
||||
.map(|instrument| instrument.round_lot.max(1))
|
||||
.unwrap_or(100);
|
||||
if let Some(price) = reinvest_price {
|
||||
let raw_quantity = (receivable.amount / price).floor() 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;
|
||||
portfolio
|
||||
.apply_cash_delta(-reinvest_cash)
|
||||
.map_err(BacktestError::Execution)?;
|
||||
portfolio.position_mut(&receivable.symbol).buy(
|
||||
date,
|
||||
reinvest_quantity,
|
||||
price,
|
||||
);
|
||||
|
||||
note = format!(
|
||||
"cash_receivable_reinvested {} ex_date={} payable_date={} cash={:.2} reinvest_qty={} reinvest_price={:.4} residual_cash={:.2}",
|
||||
receivable.symbol,
|
||||
receivable.ex_date,
|
||||
receivable.payable_date,
|
||||
receivable.amount,
|
||||
reinvest_quantity,
|
||||
price,
|
||||
residual_cash
|
||||
);
|
||||
report.fill_events.push(FillEvent {
|
||||
date,
|
||||
decision_date: None,
|
||||
order_created_date: None,
|
||||
execution_date: None,
|
||||
execution_start_timestamp: None,
|
||||
execution_timestamp: None,
|
||||
order_id: None,
|
||||
symbol: receivable.symbol.clone(),
|
||||
side: OrderSide::Buy,
|
||||
quantity: reinvest_quantity,
|
||||
price,
|
||||
gross_amount: reinvest_cash,
|
||||
commission: 0.0,
|
||||
stamp_tax: 0.0,
|
||||
transfer_fee: 0.0,
|
||||
net_cash_flow: -reinvest_cash,
|
||||
reason: "dividend_reinvestment".to_string(),
|
||||
});
|
||||
report.position_events.push(PositionEvent {
|
||||
date,
|
||||
symbol: receivable.symbol.clone(),
|
||||
delta_quantity: reinvest_quantity as i32,
|
||||
quantity_after: portfolio
|
||||
.position(&receivable.symbol)
|
||||
.map(|position| position.quantity)
|
||||
.unwrap_or(0),
|
||||
average_cost: portfolio
|
||||
.position(&receivable.symbol)
|
||||
.map(|position| position.average_cost)
|
||||
.unwrap_or(0.0),
|
||||
realized_pnl_delta: 0.0,
|
||||
reason: "dividend_reinvestment".to_string(),
|
||||
});
|
||||
report.process_events.push(ProcessEvent {
|
||||
date,
|
||||
kind: ProcessEventKind::Trade,
|
||||
order_id: None,
|
||||
symbol: Some(receivable.symbol.clone()),
|
||||
side: Some(OrderSide::Buy),
|
||||
detail: format!(
|
||||
"dividend_reinvestment quantity={} price={}",
|
||||
reinvest_quantity, price
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
notes.push(note.clone());
|
||||
report.account_events.push(AccountEvent {
|
||||
date,
|
||||
cash_before,
|
||||
cash_after: portfolio.cash(),
|
||||
total_equity: portfolio.total_equity(),
|
||||
note,
|
||||
});
|
||||
}
|
||||
Ok(report)
|
||||
crate::corporate_book::settle_receivables(date, &self.data, portfolio, notes, self.dividend_reinvestment)
|
||||
}
|
||||
|
||||
fn settle_pending_cash_flows(
|
||||
@@ -5647,8 +5536,10 @@ fn annotate_broker_report_dates(
|
||||
event.execution_date.get_or_insert(execution_date);
|
||||
}
|
||||
for fill in &mut report.fill_events {
|
||||
fill.decision_date.get_or_insert(decision_date);
|
||||
fill.order_created_date.get_or_insert(order_created_date);
|
||||
if fill.origin.is_market_execution() {
|
||||
fill.decision_date.get_or_insert(decision_date);
|
||||
fill.order_created_date.get_or_insert(order_created_date);
|
||||
}
|
||||
fill.execution_date.get_or_insert(execution_date);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,8 +181,22 @@ impl OrderEvent {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum FillOrigin {
|
||||
#[default]
|
||||
MarketExecution,
|
||||
DividendReinvestment,
|
||||
}
|
||||
|
||||
impl FillOrigin {
|
||||
pub fn is_market_execution(&self) -> bool { *self == Self::MarketExecution }
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FillEvent {
|
||||
#[serde(default, skip_serializing_if = "FillOrigin::is_market_execution")]
|
||||
pub origin: FillOrigin,
|
||||
#[serde(with = "date_format")]
|
||||
pub date: NaiveDate,
|
||||
#[serde(default, with = "optional_date_format")]
|
||||
@@ -219,6 +233,14 @@ pub struct FillEvent {
|
||||
|
||||
impl FillEvent {
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
if self.origin == FillOrigin::DividendReinvestment && (
|
||||
self.order_id.is_some() || self.side != OrderSide::Buy
|
||||
|| self.commission != 0. || self.stamp_tax != 0. || self.transfer_fee != 0.
|
||||
|| self.execution_timestamp != self.date.and_hms_opt(0, 0, 0)
|
||||
|| self.execution_start_timestamp != self.execution_timestamp
|
||||
) {
|
||||
return Err("dividend accounting allocation cannot carry an exchange order, fees, or a market clock".into());
|
||||
}
|
||||
if self.symbol.trim().is_empty()
|
||||
|| self.quantity == 0
|
||||
|| !self.price.is_finite()
|
||||
@@ -425,7 +447,7 @@ pub struct ProcessEvent {
|
||||
mod tests {
|
||||
use chrono::{NaiveDate, NaiveDateTime};
|
||||
|
||||
use super::{FillEvent, OrderEvent, OrderSide, OrderStatus, ProcessEventKind};
|
||||
use super::{FillEvent, FillOrigin, OrderEvent, OrderSide, OrderStatus, ProcessEventKind};
|
||||
|
||||
fn order_event(status: OrderStatus, filled_quantity: u32) -> OrderEvent {
|
||||
OrderEvent {
|
||||
@@ -469,6 +491,7 @@ mod tests {
|
||||
|
||||
fn fill_event(start: Option<NaiveDateTime>, end: Option<NaiveDateTime>) -> FillEvent {
|
||||
FillEvent {
|
||||
origin: crate::events::FillOrigin::MarketExecution,
|
||||
date: NaiveDate::from_ymd_opt(2025, 1, 2).unwrap(),
|
||||
decision_date: None,
|
||||
order_created_date: None,
|
||||
@@ -489,6 +512,27 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accounting_origin_cannot_disguise_an_exchange_order_or_fee() {
|
||||
let at = NaiveDate::from_ymd_opt(2025, 1, 2).unwrap().and_hms_opt(0, 0, 0).unwrap();
|
||||
let mut fill = fill_event(Some(at), Some(at));
|
||||
fill.origin = FillOrigin::DividendReinvestment;
|
||||
fill.order_id = None;
|
||||
fill.commission = 0.;
|
||||
fill.net_cash_flow = -1000.;
|
||||
assert!(fill.validate().is_ok());
|
||||
for kind in 0..3 {
|
||||
let mut invalid = fill.clone();
|
||||
match kind {
|
||||
0 => invalid.order_id = Some(1),
|
||||
1 => invalid.commission = 1.,
|
||||
_ => { invalid.execution_timestamp = Some(at + chrono::Duration::hours(9)); invalid.execution_start_timestamp = invalid.execution_timestamp; }
|
||||
}
|
||||
assert!(invalid.validate().is_err());
|
||||
}
|
||||
assert!(serde_json::to_value(fill_event(None, None)).unwrap().get("origin").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fill_execution_timestamp_range_is_explicit_and_backward_compatible() {
|
||||
let start = NaiveDate::from_ymd_opt(2025, 1, 2)
|
||||
|
||||
@@ -1048,6 +1048,7 @@ impl FuturesAccountState {
|
||||
)
|
||||
.to_f64();
|
||||
report.fill_events.push(FillEvent {
|
||||
origin: crate::events::FillOrigin::MarketExecution,
|
||||
date,
|
||||
decision_date: None,
|
||||
order_created_date: None,
|
||||
|
||||
@@ -69,7 +69,7 @@ pub use engine::{
|
||||
};
|
||||
pub use event_bus::{BacktestProcessMod, BacktestProcessModLoader, ProcessEventBus};
|
||||
pub use events::{
|
||||
AccountEvent, FillEvent, OrderEvent, OrderSide, OrderStatus, PositionEvent, ProcessEvent,
|
||||
AccountEvent, FillEvent, FillOrigin, OrderEvent, OrderSide, OrderStatus, PositionEvent, ProcessEvent,
|
||||
ProcessEventKind,
|
||||
};
|
||||
pub use fixed_point::{
|
||||
|
||||
@@ -26,6 +26,7 @@ pub(crate) struct ManualCorporateReplay {
|
||||
reconciled_count: Cell<usize>,
|
||||
cash_dividends: bool,
|
||||
adjust_cost: bool,
|
||||
reinvest: bool,
|
||||
matching: MatchingType,
|
||||
daily_price: PriceField,
|
||||
same_day_mark_at_fill: bool,
|
||||
@@ -36,6 +37,7 @@ impl ManualCorporateReplay {
|
||||
first_date: NaiveDate,
|
||||
cash_dividends: bool,
|
||||
adjust_cost: bool,
|
||||
reinvest: bool,
|
||||
matching: MatchingType,
|
||||
daily_price: PriceField,
|
||||
same_day_mark_at_fill: bool,
|
||||
@@ -48,6 +50,7 @@ impl ManualCorporateReplay {
|
||||
reconciled_count: Cell::new(0),
|
||||
cash_dividends,
|
||||
adjust_cost,
|
||||
reinvest,
|
||||
matching,
|
||||
daily_price,
|
||||
same_day_mark_at_fill,
|
||||
@@ -246,6 +249,7 @@ impl ManualCorporateReplay {
|
||||
observed_at: observation.fill.observed_at,
|
||||
cash_dividends_enabled: self.cash_dividends,
|
||||
dividend_cost_basis_adjustment: self.adjust_cost,
|
||||
dividend_reinvestment: self.reinvest,
|
||||
actions,
|
||||
cash_before: before.to_decimal_string(),
|
||||
cash_after: after.to_decimal_string(),
|
||||
@@ -309,6 +313,12 @@ impl ManualCorporateReplay {
|
||||
events.push((clock, 4, fills.len() + index, Event::Manual(*observation)));
|
||||
}
|
||||
for (index, fill) in fills.iter().enumerate() {
|
||||
fill.validate()?;
|
||||
if fill.origin == crate::events::FillOrigin::DividendReinvestment {
|
||||
// The declared accounting model is recalculated from the
|
||||
// corrected entitlements; this was never a submitted order.
|
||||
continue;
|
||||
}
|
||||
let date = fill.execution_date.unwrap_or(fill.date);
|
||||
// This is the frozen daily matching model, not a broker timestamp.
|
||||
// Intraday contracts must supply their actual execution clock.
|
||||
@@ -372,9 +382,8 @@ impl ManualCorporateReplay {
|
||||
.map_err(|error| error.to_string())?;
|
||||
}
|
||||
Event::Settle(date) => {
|
||||
for receivable in book.take_due_cash_receivables(date) {
|
||||
book.settle_cash_receivable(&receivable)?;
|
||||
}
|
||||
crate::corporate_book::settle_receivables(date, data, &mut book, &mut Vec::new(), self.reinvest)
|
||||
.map_err(|error| error.to_string())?;
|
||||
}
|
||||
Event::Manual(observation) => {
|
||||
observation.apply(&mut book, data, false)?;
|
||||
|
||||
@@ -493,6 +493,8 @@ pub struct ManualCorporateAdjustment {
|
||||
pub observed_at: DateTime<Utc>,
|
||||
pub cash_dividends_enabled: bool,
|
||||
pub dividend_cost_basis_adjustment: bool,
|
||||
#[serde(default, skip_serializing_if = "disabled_flag")]
|
||||
pub dividend_reinvestment: bool,
|
||||
pub actions: Vec<ManualCorporateActionReference>,
|
||||
pub cash_before: String,
|
||||
pub cash_after: String,
|
||||
@@ -502,6 +504,8 @@ pub struct ManualCorporateAdjustment {
|
||||
pub replayed_sha256: String,
|
||||
}
|
||||
|
||||
fn disabled_flag(value: &bool) -> bool { !value }
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(deny_unknown_fields, rename_all = "camelCase")]
|
||||
pub struct ManualCorporateActionReference {
|
||||
|
||||
@@ -254,7 +254,7 @@ pub fn built_in_strategy_manual() -> StrategyAiManual {
|
||||
},
|
||||
ManualSection {
|
||||
title: "corporate_actions.dividend_reinvestment".to_string(),
|
||||
detail: "支持 corporate_actions.dividend_reinvestment(true)。开启后,现金分红到账会优先按 round lot 回补成同一只股票,零头保留为现金。".to_string(),
|
||||
detail: "历史兼容的回测账务再投模型:corporate_actions.dividend_reinvestment(true) 在分红结算时按调整后的参考价分配整手股数,零头留现金,费用为0;来源标记为 dividend_reinvestment,不是交易所委托或真实自动买入。新策略不应手工处理公司行为。".to_string(),
|
||||
},
|
||||
ManualSection {
|
||||
title: "execution.matching_type / execution.slippage".to_string(),
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
use chrono::{NaiveDate, NaiveTime};
|
||||
use fidc_core::{
|
||||
BacktestConfig, BacktestEngine, BrokerSimulator, ChinaAShareCostModel, ChinaEquityRuleHooks,
|
||||
DataSet, MatchingType, OrderIntent, PriceField, Strategy, StrategyContext, StrategyDecision,
|
||||
};
|
||||
use std::{cell::RefCell, collections::BTreeSet, rc::Rc};
|
||||
|
||||
const SYMBOL: &str = "000001.SZ";
|
||||
fn day(value: u32) -> NaiveDate {
|
||||
NaiveDate::from_ymd_opt(2026, 9, value).unwrap()
|
||||
}
|
||||
|
||||
fn data() -> DataSet {
|
||||
let days = [11, 14, 15].map(day);
|
||||
DataSet::from_components_with_actions_and_quotes(
|
||||
vec![fidc_core::Instrument {
|
||||
symbol: SYMBOL.into(),
|
||||
name: "fixture".into(),
|
||||
board: "SZ".into(),
|
||||
round_lot: 100,
|
||||
listed_at: Some(day(1)),
|
||||
delisted_at: None,
|
||||
status: "active".into(),
|
||||
}],
|
||||
days.iter()
|
||||
.map(|&date| {
|
||||
let price = if date == day(11) { 10. } else { 8.95 };
|
||||
fidc_core::DailyMarketSnapshot {
|
||||
date,
|
||||
symbol: SYMBOL.into(),
|
||||
timestamp: Some(format!("{date} 15:00:00")),
|
||||
day_open: price,
|
||||
open: price,
|
||||
high: price,
|
||||
low: price,
|
||||
close: price,
|
||||
last_price: price,
|
||||
bid1: price,
|
||||
ask1: price,
|
||||
prev_close: price,
|
||||
volume: 100000,
|
||||
minute_volume: 100000,
|
||||
bid1_volume: 100000,
|
||||
ask1_volume: 100000,
|
||||
trading_phase: Some("continuous".into()),
|
||||
paused: false,
|
||||
upper_limit: price * 1.1,
|
||||
lower_limit: price * 0.9,
|
||||
price_tick: 0.01,
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
days.iter()
|
||||
.map(|&date| fidc_core::DailyFactorSnapshot {
|
||||
date,
|
||||
symbol: SYMBOL.into(),
|
||||
market_cap_bn: 10.,
|
||||
free_float_cap_bn: 10.,
|
||||
pe_ttm: 10.,
|
||||
turnover_ratio: None,
|
||||
effective_turnover_ratio: None,
|
||||
adjustment_factor_backward1: Some(1.),
|
||||
extra_factors: Default::default(),
|
||||
})
|
||||
.collect(),
|
||||
days.iter()
|
||||
.map(|&date| fidc_core::CandidateEligibility {
|
||||
date,
|
||||
symbol: SYMBOL.into(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
is_paused: false,
|
||||
allow_buy: true,
|
||||
allow_sell: true,
|
||||
is_kcb: false,
|
||||
is_one_yuan: false,
|
||||
risk_level_code: None,
|
||||
})
|
||||
.collect(),
|
||||
days.iter()
|
||||
.map(|&date| fidc_core::BenchmarkSnapshot {
|
||||
date,
|
||||
benchmark: "000300.SH".into(),
|
||||
open: 100.,
|
||||
close: 100.,
|
||||
prev_close: 100.,
|
||||
volume: 100000,
|
||||
})
|
||||
.collect(),
|
||||
vec![fidc_core::CorporateAction {
|
||||
date: day(14),
|
||||
symbol: SYMBOL.into(),
|
||||
payable_date: Some(day(14)),
|
||||
share_cash: 1.05,
|
||||
share_bonus: 0.,
|
||||
share_gift: 0.,
|
||||
issue_quantity: 0.,
|
||||
issue_price: 0.,
|
||||
reform: false,
|
||||
adjust_factor: None,
|
||||
successor_symbol: None,
|
||||
successor_ratio: None,
|
||||
successor_cash: None,
|
||||
}],
|
||||
[(9, 15), (9, 31)]
|
||||
.into_iter()
|
||||
.map(|(hour, minute)| fidc_core::IntradayExecutionQuote {
|
||||
observation_kind: fidc_core::data::QuoteObservationKind::MinuteBar,
|
||||
date: day(14),
|
||||
symbol: SYMBOL.into(),
|
||||
timestamp: day(14).and_hms_opt(hour, minute, 0).unwrap(),
|
||||
last_price: 8.95,
|
||||
bid1: 8.95,
|
||||
ask1: 8.95,
|
||||
bid1_volume: 100000,
|
||||
ask1_volume: 100000,
|
||||
volume_delta: 10000,
|
||||
amount_delta: 89500.,
|
||||
trading_phase: Some("continuous".into()),
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
struct Hold {
|
||||
seen: Rc<RefCell<Vec<(NaiveTime, u32)>>>,
|
||||
}
|
||||
impl Strategy for Hold {
|
||||
fn name(&self) -> &str {
|
||||
"accounting reinvestment contract"
|
||||
}
|
||||
fn initial_subscriptions(&self) -> BTreeSet<String> {
|
||||
[SYMBOL.into()].into()
|
||||
}
|
||||
fn on_day(
|
||||
&mut self,
|
||||
ctx: &StrategyContext<'_>,
|
||||
) -> Result<StrategyDecision, fidc_core::BacktestError> {
|
||||
Ok(if ctx.execution_date == day(11) {
|
||||
StrategyDecision {
|
||||
order_intents: vec![OrderIntent::Shares {
|
||||
symbol: SYMBOL.into(),
|
||||
quantity: 1000,
|
||||
reason: "initial".into(),
|
||||
}],
|
||||
..Default::default()
|
||||
}
|
||||
} else {
|
||||
StrategyDecision::default()
|
||||
})
|
||||
}
|
||||
fn on_minute(
|
||||
&mut self,
|
||||
ctx: &StrategyContext<'_>,
|
||||
_: &fidc_core::IntradayExecutionQuote,
|
||||
) -> Result<StrategyDecision, fidc_core::BacktestError> {
|
||||
self.seen.borrow_mut().push((
|
||||
ctx.current_time().unwrap(),
|
||||
ctx.portfolio
|
||||
.position(SYMBOL)
|
||||
.map_or(0, |position| position.quantity),
|
||||
));
|
||||
Ok(Default::default())
|
||||
}
|
||||
}
|
||||
|
||||
fn engine() -> BacktestEngine<Hold, ChinaAShareCostModel, ChinaEquityRuleHooks> {
|
||||
BacktestEngine::new(
|
||||
data(),
|
||||
Hold {
|
||||
seen: Rc::new(RefCell::new(Vec::new())),
|
||||
},
|
||||
BrokerSimulator::new_with_execution_price(
|
||||
ChinaAShareCostModel::default()
|
||||
.with_commission_rate(0.0008)
|
||||
.with_minimum_commission(0.),
|
||||
ChinaEquityRuleHooks,
|
||||
PriceField::Open,
|
||||
)
|
||||
.with_matching_type(MatchingType::NextBarOpen)
|
||||
.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::Open,
|
||||
},
|
||||
)
|
||||
.with_dividend_reinvestment(true)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accounting_reinvestment_has_an_explicit_origin_clock_and_progress_delivery() {
|
||||
let mut progress = Vec::new();
|
||||
let result = engine()
|
||||
.run_with_progress(|event| progress.push(event.clone()))
|
||||
.unwrap();
|
||||
let reinvest = result
|
||||
.fills
|
||||
.iter()
|
||||
.find(|fill| fill.reason == "dividend_reinvestment")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
(
|
||||
reinvest.quantity,
|
||||
reinvest.price,
|
||||
reinvest.commission,
|
||||
reinvest.order_id
|
||||
),
|
||||
(100, 8.95, 0., None)
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(reinvest).unwrap()["origin"],
|
||||
"dividend_reinvestment"
|
||||
);
|
||||
assert_eq!(reinvest.execution_timestamp, day(14).and_hms_opt(0, 0, 0));
|
||||
let received = progress.iter().find(|event| event.date == day(14)).unwrap();
|
||||
assert!(
|
||||
received
|
||||
.fills
|
||||
.iter()
|
||||
.any(|fill| fill.reason == "dividend_reinvestment")
|
||||
);
|
||||
assert_eq!(
|
||||
progress
|
||||
.iter()
|
||||
.map(|event| event.daily_fill_count)
|
||||
.sum::<usize>(),
|
||||
result.fills.len()
|
||||
);
|
||||
}
|
||||
|
||||
fn manual_source(delayed: bool) -> fidc_core::manual_execution::ManualExecutionReplay {
|
||||
let observed = if delayed {
|
||||
"2026-09-14T01:15:00Z"
|
||||
} else {
|
||||
"2026-09-11T06:00:01Z"
|
||||
};
|
||||
let mut source: fidc_core::manual_execution::ManualExecutionReplay = serde_json::from_value(serde_json::json!({
|
||||
"schema":fidc_core::manual_execution::MANUAL_REPLAY_SCHEMA,"runtimeId":"r","accountId":"a","sourceContractSha256":"a".repeat(64),"contentSha256":"",
|
||||
"observationCutoff":"2026-09-15T08:00:00Z","actions":[{"actionId":"manual","source":"manual_security_trade","auditEventIds":["audit"],
|
||||
"confirmedAt":"2026-09-11T05:59:59Z","confirmationObservedAt":"2026-09-11T05:59:59Z","outcome":"orders_terminal","orders":[{
|
||||
"orderId":"manual-order","sourceAdapter":"paper","symbol":SYMBOL,"side":"Buy","quantity":1000,
|
||||
"orderCreatedAt":"2026-09-11T05:59:59Z","terminalObservedAt":observed,"terminalStatus":"filled","fills":[{
|
||||
"tradeId":"manual-fill","observationEventId":"receipt","observationSequence":1,"feeObservationEventId":"receipt","feeObservationSequence":1,
|
||||
"feeObservedAt":observed,"tradeDate":"2026-09-11","executedAt":"2026-09-11T06:00:00Z","observedAt":observed,
|
||||
"timestampPrecision":"second","quantity":1000,"price":"10","totalFee":"1"
|
||||
}]
|
||||
}]}]
|
||||
})).unwrap();
|
||||
source.content_sha256 = source.content_digest().unwrap();
|
||||
source
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delayed_receipt_before_market_open_reconciles_accounting_not_future_market_fills() {
|
||||
let timely = engine()
|
||||
.with_observed_manual_executions(manual_source(false))
|
||||
.unwrap()
|
||||
.run()
|
||||
.unwrap();
|
||||
let delayed = engine()
|
||||
.with_observed_manual_executions(manual_source(true))
|
||||
.unwrap()
|
||||
.run()
|
||||
.unwrap();
|
||||
assert_eq!(delayed.holdings_summary[0].quantity, 2200);
|
||||
assert_eq!(
|
||||
delayed.holdings_summary[0].quantity,
|
||||
timely.holdings_summary[0].quantity
|
||||
);
|
||||
assert_eq!(
|
||||
delayed.equity_curve.last().unwrap().cash,
|
||||
timely.equity_curve.last().unwrap().cash
|
||||
);
|
||||
assert_eq!(
|
||||
delayed.manual_executions[0]
|
||||
.corporate_adjustment
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.corporate_cash_delta,
|
||||
"155"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn weekend_receipts_and_morning_allocations_are_in_the_next_progress_batch() {
|
||||
let mut source = manual_source(true);
|
||||
let observed = "2026-09-12T02:00:00Z".parse().unwrap();
|
||||
let order = &mut source.actions[0].orders[0];
|
||||
order.terminal_observed_at = observed;
|
||||
order.fills[0].observed_at = observed;
|
||||
order.fills[0].fee_observed_at = observed;
|
||||
source.content_sha256 = source.content_digest().unwrap();
|
||||
let mut progress = Vec::new();
|
||||
let result = engine()
|
||||
.with_observed_manual_executions(source)
|
||||
.unwrap()
|
||||
.run_with_progress(|event| progress.push(event.clone()))
|
||||
.unwrap();
|
||||
let monday = progress.iter().find(|event| event.date == day(14)).unwrap();
|
||||
assert_eq!(monday.daily_manual_fill_count, 1);
|
||||
assert_eq!(monday.manual_executions[0].observed_at, observed);
|
||||
assert!(
|
||||
monday
|
||||
.fills
|
||||
.iter()
|
||||
.any(|fill| fill.origin == fidc_core::FillOrigin::DividendReinvestment)
|
||||
);
|
||||
assert!(
|
||||
monday
|
||||
.process_events
|
||||
.iter()
|
||||
.any(|event| event.kind == fidc_core::ProcessEventKind::ManualExecutionObserved)
|
||||
);
|
||||
assert_eq!(
|
||||
progress
|
||||
.iter()
|
||||
.map(|event| event.daily_fill_count)
|
||||
.sum::<usize>(),
|
||||
result.fills.len() + result.manual_executions.len()
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user