Files
fidc-backtest-engine/crates/fidc-core/tests/dividend_reinvestment_contract.rs
T

476 lines
20 KiB
Rust

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()
);
}
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");
}