让人工零仓位和零权重约束红利再投
This commit is contained in:
@@ -326,3 +326,150 @@ fn weekend_receipts_and_morning_allocations_are_in_the_next_progress_batch() {
|
||||
result.fills.len() + result.manual_executions.len()
|
||||
);
|
||||
}
|
||||
|
||||
fn exposure_event(id: &str, sequence: u64, at: &str, action: fidc_core::position_exposure::PositionExposureAction)
|
||||
-> fidc_core::position_exposure::PositionExposureEvent {
|
||||
fidc_core::position_exposure::PositionExposureEvent {
|
||||
event_id: id.into(), sequence, effective_at: at.parse().unwrap(), allocation_weights_bps: None, action,
|
||||
}
|
||||
}
|
||||
|
||||
fn cleared_reinvestment_case(
|
||||
events: Vec<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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user