让人工零仓位和零权重约束红利再投
This commit is contained in:
@@ -2,7 +2,7 @@ use crate::{
|
|||||||
AccountEvent, BacktestError, BrokerExecutionReport, CashReceivable, DataSet, FillEvent,
|
AccountEvent, BacktestError, BrokerExecutionReport, CashReceivable, DataSet, FillEvent,
|
||||||
OrderSide, PortfolioState, PositionEvent, PriceField, ProcessEvent, ProcessEventKind,
|
OrderSide, PortfolioState, PositionEvent, PriceField, ProcessEvent, ProcessEventKind,
|
||||||
};
|
};
|
||||||
use chrono::NaiveDate;
|
use chrono::{NaiveDate, TimeZone};
|
||||||
|
|
||||||
pub(crate) fn validate_action<'a>(
|
pub(crate) fn validate_action<'a>(
|
||||||
action: &'a crate::CorporateAction,
|
action: &'a crate::CorporateAction,
|
||||||
@@ -267,6 +267,7 @@ pub(crate) fn settle_receivables(
|
|||||||
portfolio: &mut PortfolioState,
|
portfolio: &mut PortfolioState,
|
||||||
notes: &mut Vec<String>,
|
notes: &mut Vec<String>,
|
||||||
reinvest_enabled: bool,
|
reinvest_enabled: bool,
|
||||||
|
runtime_input: Option<&crate::manual_execution::ManualExecutionReplay>,
|
||||||
) -> Result<BrokerExecutionReport, BacktestError> {
|
) -> Result<BrokerExecutionReport, BacktestError> {
|
||||||
if !portfolio
|
if !portfolio
|
||||||
.cash_receivables()
|
.cash_receivables()
|
||||||
@@ -277,18 +278,111 @@ pub(crate) fn settle_receivables(
|
|||||||
}
|
}
|
||||||
let mut next = portfolio.clone();
|
let mut next = portfolio.clone();
|
||||||
let mut recorded = Vec::new();
|
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;
|
*portfolio = next;
|
||||||
notes.extend(recorded);
|
notes.extend(recorded);
|
||||||
Ok(report)
|
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(
|
fn settle_receivables_inner(
|
||||||
date: NaiveDate,
|
date: NaiveDate,
|
||||||
data: &DataSet,
|
data: &DataSet,
|
||||||
portfolio: &mut PortfolioState,
|
portfolio: &mut PortfolioState,
|
||||||
notes: &mut Vec<String>,
|
notes: &mut Vec<String>,
|
||||||
reinvest_enabled: bool,
|
reinvest_enabled: bool,
|
||||||
|
control: Option<ManualReinvestmentControl<'_>>,
|
||||||
) -> Result<BrokerExecutionReport, BacktestError> {
|
) -> Result<BrokerExecutionReport, BacktestError> {
|
||||||
let mut report = BrokerExecutionReport::default();
|
let mut report = BrokerExecutionReport::default();
|
||||||
let due = portfolio.take_due_cash_receivables(date);
|
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}",
|
"cash_receivable_settled {} ex_date={} payable_date={} cash={:.2}",
|
||||||
receivable.symbol, receivable.ex_date, receivable.payable_date, receivable.amount
|
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.reason.starts_with("cash_dividend")
|
||||||
&& receivable.amount > 0.0
|
&& receivable.amount > 0.0
|
||||||
{
|
{
|
||||||
@@ -346,10 +448,32 @@ fn settle_receivables_inner(
|
|||||||
let raw_quantity = raw as u32;
|
let raw_quantity = raw as u32;
|
||||||
let reinvest_quantity = (raw_quantity / round_lot) * round_lot;
|
let reinvest_quantity = (raw_quantity / round_lot) * round_lot;
|
||||||
if reinvest_quantity > 0 {
|
if reinvest_quantity > 0 {
|
||||||
let reinvest_cash = reinvest_quantity as f64 * price;
|
// Report the same micro-unit amount actually posted to
|
||||||
let residual_cash = receivable.amount - reinvest_cash;
|
// 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
|
portfolio
|
||||||
.apply_cash_delta(-reinvest_cash)
|
.apply_cash_delta_fixed(cash_delta)
|
||||||
.map_err(BacktestError::Execution)?;
|
.map_err(BacktestError::Execution)?;
|
||||||
portfolio.position_mut(&receivable.symbol).buy(
|
portfolio.position_mut(&receivable.symbol).buy(
|
||||||
date,
|
date,
|
||||||
@@ -384,7 +508,7 @@ fn settle_receivables_inner(
|
|||||||
commission: 0.0,
|
commission: 0.0,
|
||||||
stamp_tax: 0.0,
|
stamp_tax: 0.0,
|
||||||
transfer_fee: 0.0,
|
transfer_fee: 0.0,
|
||||||
net_cash_flow: -reinvest_cash,
|
net_cash_flow: cash_delta.to_f64(),
|
||||||
reason: "dividend_reinvestment".to_string(),
|
reason: "dividend_reinvestment".to_string(),
|
||||||
});
|
});
|
||||||
report.position_events.push(PositionEvent {
|
report.position_events.push(PositionEvent {
|
||||||
@@ -475,7 +599,8 @@ mod tests {
|
|||||||
let mut book = book();
|
let mut book = book();
|
||||||
let before = book.financial_replay_identity();
|
let before = book.financial_replay_identity();
|
||||||
let error =
|
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!(error.to_string().contains("accounting reference missing"));
|
||||||
assert_eq!(book.financial_replay_identity(), before);
|
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() {
|
fn terminated_security_keeps_paid_cash_and_is_not_recreated_by_reinvestment() {
|
||||||
let mut book = book();
|
let mut book = book();
|
||||||
let mut notes = Vec::new();
|
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_eq!(book.cash(), 110.);
|
||||||
assert!(book.positions().is_empty());
|
assert!(book.positions().is_empty());
|
||||||
assert!(book.cash_receivables().is_empty());
|
assert!(book.cash_receivables().is_empty());
|
||||||
@@ -491,6 +617,63 @@ mod tests {
|
|||||||
assert!(notes[0].contains("dividend_reinvestment_not_applied"));
|
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 {
|
fn conversion() -> crate::CorporateAction {
|
||||||
crate::CorporateAction {
|
crate::CorporateAction {
|
||||||
date: date(),
|
date: date(),
|
||||||
|
|||||||
@@ -4689,7 +4689,8 @@ where
|
|||||||
fn settle_cash_receivables(
|
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> {
|
) -> 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(
|
fn settle_pending_cash_flows(
|
||||||
|
|||||||
@@ -163,6 +163,7 @@ impl ManualCorporateReplay {
|
|||||||
}
|
}
|
||||||
let at = local(observation.fill.observed_at);
|
let at = local(observation.fill.observed_at);
|
||||||
let reference = self.replay(
|
let reference = self.replay(
|
||||||
|
source,
|
||||||
current.initial_cash_fixed(),
|
current.initial_cash_fixed(),
|
||||||
&all[..applied_count],
|
&all[..applied_count],
|
||||||
self.reconciled_count.get(),
|
self.reconciled_count.get(),
|
||||||
@@ -182,6 +183,7 @@ impl ManualCorporateReplay {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
let replayed = self.replay(
|
let replayed = self.replay(
|
||||||
|
source,
|
||||||
current.initial_cash_fixed(),
|
current.initial_cash_fixed(),
|
||||||
&all[..=applied_count],
|
&all[..=applied_count],
|
||||||
applied_count + 1,
|
applied_count + 1,
|
||||||
@@ -263,6 +265,7 @@ impl ManualCorporateReplay {
|
|||||||
|
|
||||||
fn replay(
|
fn replay(
|
||||||
&self,
|
&self,
|
||||||
|
runtime_input: &ManualExecutionReplay,
|
||||||
initial_cash: FixedMoney,
|
initial_cash: FixedMoney,
|
||||||
manual: &[ManualFillObservation<'_>],
|
manual: &[ManualFillObservation<'_>],
|
||||||
economic_count: usize,
|
economic_count: usize,
|
||||||
@@ -373,7 +376,7 @@ impl ManualCorporateReplay {
|
|||||||
.map_err(|error| error.to_string())?;
|
.map_err(|error| error.to_string())?;
|
||||||
}
|
}
|
||||||
Event::Settle(date) => {
|
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())?;
|
.map_err(|error| error.to_string())?;
|
||||||
}
|
}
|
||||||
Event::Manual(observation) => {
|
Event::Manual(observation) => {
|
||||||
|
|||||||
@@ -326,3 +326,150 @@ fn weekend_receipts_and_morning_allocations_are_in_the_next_progress_batch() {
|
|||||||
result.fills.len() + result.manual_executions.len()
|
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<fidc_core::position_exposure::PositionExposureEvent>,
|
||||||
|
legacy: std::collections::BTreeMap<NaiveDate, i32>,
|
||||||
|
extra_buy_delayed: Option<bool>,
|
||||||
|
) -> 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::<u32>(), 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");
|
||||||
|
}
|
||||||
|
|||||||
@@ -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或重启生产。
|
||||||
Reference in New Issue
Block a user