修复迟到成交跨公司行为的经济账本校正

This commit is contained in:
boris
2026-09-14 21:14:20 +08:00
parent ef9cc39882
commit 05f1cbbe00
11 changed files with 1447 additions and 154 deletions
@@ -0,0 +1,505 @@
use chrono::{DateTime, NaiveDate, Utc};
use fidc_core::manual_execution::{MANUAL_REPLAY_SCHEMA, ManualExecutionReplay};
use fidc_core::{
BacktestConfig, BacktestEngine, BenchmarkSnapshot, BrokerSimulator, CandidateEligibility,
ChinaAShareCostModel, ChinaEquityRuleHooks, CorporateAction, DailyFactorSnapshot,
DailyMarketSnapshot, DataSet, Instrument, MatchingType, PriceField, Strategy,
};
fn date(day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(2026, 9, day).unwrap()
}
#[derive(Clone, Copy, Debug)]
enum Action {
Split,
Dividend,
Successor,
}
fn data(action: Action) -> DataSet {
let days = [10, 11, 14, 15].map(date);
let mut market = Vec::new();
let mut factors = Vec::new();
let mut candidates = Vec::new();
for day in days {
for symbol in ["000001.SZ", "000002.SZ"] {
if matches!(action, Action::Successor) && symbol == "000001.SZ" && day >= date(14) {
continue;
}
let price = if day < date(14)
|| (symbol == "000002.SZ" && !matches!(action, Action::Successor))
{
10.
} else if matches!(action, Action::Dividend) {
9.
} else {
5.
};
market.push(DailyMarketSnapshot {
date: day,
symbol: symbol.into(),
timestamp: Some(format!("{day} 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,
});
factors.push(DailyFactorSnapshot {
date: day,
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(),
});
candidates.push(CandidateEligibility {
date: day,
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,
});
}
}
DataSet::from_components_with_actions(
["000001.SZ", "000002.SZ"]
.into_iter()
.map(|symbol| Instrument {
symbol: symbol.into(),
name: symbol.into(),
board: "SZ".into(),
round_lot: 100,
listed_at: Some(date(1)),
delisted_at: (matches!(action, Action::Successor) && symbol == "000001.SZ")
.then_some(date(14)),
status: "active".into(),
})
.collect(),
market,
factors,
candidates,
days.map(|day| BenchmarkSnapshot {
date: day,
benchmark: "000300.SH".into(),
open: 100.,
close: 100.,
prev_close: 100.,
volume: 100000,
})
.into(),
vec![CorporateAction {
date: date(14),
symbol: "000001.SZ".into(),
payable_date: Some(date(14)),
share_cash: if matches!(action, Action::Dividend) {
1.
} else {
0.
},
share_bonus: if matches!(action, Action::Split) {
1.
} else {
0.
},
share_gift: 0.,
issue_quantity: 0.,
issue_price: 0.,
reform: false,
adjust_factor: None,
successor_symbol: matches!(action, Action::Successor).then(|| "000002.SZ".into()),
successor_ratio: matches!(action, Action::Successor).then_some(2.),
successor_cash: matches!(action, Action::Successor).then_some(0.5),
}],
)
.unwrap()
}
fn source(delayed: bool, sell: bool) -> ManualExecutionReplay {
let trades = if sell {
vec![
("initial-buy", "Buy", 10, 200, false),
("sale", "Sell", 11, 100, delayed),
]
} else {
vec![("buy", "Buy", 11, 100, delayed)]
};
let actions = trades.into_iter().enumerate().map(|(index, (id, side, day, quantity, late))| {
let executed = format!("2026-09-{day:02}T06:00:00Z").parse::<DateTime<Utc>>().unwrap();
let observed = if late { "2026-09-15T05:00:00Z".parse().unwrap() } else { executed + chrono::Duration::seconds(1) };
let created = executed - chrono::Duration::seconds(1);
serde_json::json!({"actionId":id,"source":"manual_security_trade","auditEventIds":[format!("audit-{id}")],
"confirmedAt":created,"confirmationObservedAt":created,"outcome":"orders_terminal","orders":[{
"orderId":id,"brokerOrderId":id,"sourceAdapter":"paper","symbol":"000001.SZ","side":side,"quantity":quantity,
"orderCreatedAt":created,"terminalObservedAt":observed,"terminalStatus":"filled","fills":[{
"tradeId":id,"observationEventId":id,"observationSequence":index+1,"tradeDate":date(day),
"executedAt":executed,"observedAt":observed,"feeObservationEventId":id,"feeObservationSequence":index+1,
"feeObservedAt":observed,"timestampPrecision":"second","quantity":quantity,"price":"10","totalFee":"1"
}]
}]})
}).collect::<Vec<_>>();
let mut source: ManualExecutionReplay = serde_json::from_value(serde_json::json!({
"schema":MANUAL_REPLAY_SCHEMA,"runtimeId":"r","accountId":"a","sourceContractSha256":"a".repeat(64),
"contentSha256":"","observationCutoff":"2026-09-15T08:00:00Z","actions":actions,
})).unwrap();
source.content_sha256 = source.content_digest().unwrap();
source.validate().unwrap();
source
}
struct Hold;
impl Strategy for Hold {
fn name(&self) -> &str {
"manual corporate observation"
}
fn requires_minute_callbacks(&self) -> bool {
false
}
}
fn run_custom<S: Strategy>(
data: DataSet,
source: ManualExecutionReplay,
strategy: S,
cash_dividends: bool,
adjust_cost: bool,
) -> Result<fidc_core::BacktestResult, fidc_core::BacktestError> {
let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
.with_matching_type(MatchingType::NextBarOpen)
.with_volume_limit(false)
.with_liquidity_limit(false);
BacktestEngine::new(
data,
strategy,
broker,
BacktestConfig {
initial_cash: 10000.,
benchmark_code: "000300.SH".into(),
start_date: Some(date(10)),
end_date: Some(date(15)),
decision_lag_trading_days: 0,
execution_price_field: PriceField::Open,
},
)
.with_cash_dividends(cash_dividends)
.with_cash_dividend_cost_basis_adjustment(adjust_cost)
.with_observed_manual_executions(source)
.unwrap()
.run()
}
fn run(
action: Action,
delayed: bool,
sell: bool,
) -> Result<fidc_core::BacktestResult, fidc_core::BacktestError> {
run_custom(data(action), source(delayed, sell), Hold, true, true)
}
#[test]
fn delayed_buy_does_not_lose_corporate_entitlements() {
for action in [Action::Split, Action::Dividend, Action::Successor] {
let timely = run(action, false, false).unwrap();
let delayed = run(action, true, false).unwrap();
let project = |result: &fidc_core::BacktestResult| {
(
result.equity_curve.last().unwrap().cash,
result.equity_curve.last().unwrap().total_equity,
result
.holdings_summary
.iter()
.map(|row| (row.symbol.clone(), row.quantity))
.collect::<Vec<_>>(),
)
};
assert_eq!(project(&delayed), project(&timely), "{action:?}");
assert_eq!(delayed.manual_executions.len(), 1);
assert!(delayed.fills.is_empty());
if matches!(action, Action::Successor)
&& let Ok(directory) = std::env::var("FIDC_CORPORATE_QA_OUTPUT")
{
use std::io::Write;
let path = std::path::Path::new(&directory).join("corporate-successor-result.json");
let mut options = std::fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
let mut file = options.open(path).unwrap();
file.write_all(&serde_json::to_vec(&serde_json::json!({
"source":delayed.manual_execution_source.as_deref(), "applications":delayed.manual_executions,
})).unwrap()).unwrap();
}
}
}
#[test]
fn delayed_sale_does_not_keep_unearned_corporate_entitlements() {
for action in [Action::Split, Action::Dividend, Action::Successor] {
let timely = run(action, false, true).unwrap();
let delayed = run(action, true, true).unwrap();
let project = |result: &fidc_core::BacktestResult| {
(
result.equity_curve.last().unwrap().cash,
result.equity_curve.last().unwrap().total_equity,
result
.holdings_summary
.iter()
.map(|row| (row.symbol.clone(), row.quantity))
.collect::<Vec<_>>(),
)
};
assert_eq!(project(&delayed), project(&timely), "{action:?}");
assert_eq!(delayed.manual_executions.len(), 2);
assert!(delayed.fills.is_empty());
}
}
#[test]
fn corporate_replay_preserves_issued_orders_cash_flows_financing_and_charged_fees() {
struct ExistingActivity {
receiving_days: usize,
}
impl Strategy for ExistingActivity {
fn name(&self) -> &str {
"corporate replay with original activity"
}
fn requires_minute_callbacks(&self) -> bool {
false
}
fn on_day(
&mut self,
ctx: &fidc_core::StrategyContext<'_>,
) -> Result<fidc_core::StrategyDecision, fidc_core::BacktestError> {
use fidc_core::OrderIntent;
let order_intents = if ctx.execution_date == date(10) {
vec![
OrderIntent::DepositWithdraw {
amount: 500.,
receiving_days: self.receiving_days,
reason: "original deposit".into(),
},
OrderIntent::FinanceRepay {
amount: 200.,
reason: "original financing".into(),
},
OrderIntent::SetManagementFeeRate {
rate: 0.001,
reason: "original fee policy".into(),
},
]
} else if ctx.execution_date == date(11) {
vec![OrderIntent::Shares {
symbol: "000002.SZ".into(),
quantity: 100,
reason: "unrelated stock".into(),
}]
} else if ctx.execution_date == date(14) {
vec![OrderIntent::Shares {
symbol: "000001.SZ".into(),
quantity: 100,
reason: "already issued after corporate action".into(),
}]
} else {
vec![]
};
Ok(fidc_core::StrategyDecision {
order_intents,
..Default::default()
})
}
fn management_fee(
&mut self,
_: &fidc_core::StrategyContext<'_>,
_: f64,
) -> Result<Option<f64>, fidc_core::BacktestError> {
Ok(Some(0.25))
}
}
for receiving_days in [0, 1] {
for sell in [false, true] {
let timely = run_custom(
data(Action::Split),
source(false, sell),
ExistingActivity { receiving_days },
true,
true,
)
.unwrap();
let delayed = run_custom(
data(Action::Split),
source(true, sell),
ExistingActivity { receiving_days },
true,
true,
)
.unwrap();
assert_eq!(
serde_json::to_value(&timely.fills).unwrap(),
serde_json::to_value(&delayed.fills).unwrap()
);
assert_eq!(delayed.fills.len(), 2);
assert_eq!(
delayed.equity_curve.last().unwrap().cash,
timely.equity_curve.last().unwrap().cash
);
assert_eq!(
delayed.equity_curve.last().unwrap().total_equity,
timely.equity_curve.last().unwrap().total_equity
);
assert_eq!(
delayed
.equity_curve
.iter()
.map(|row| row.external_cash_flow)
.sum::<f64>(),
500.
);
assert_eq!(delayed.manual_executions.len(), if sell { 2 } else { 1 });
}
}
}
#[test]
fn pending_and_disabled_dividends_keep_the_configured_cash_and_cost_contract() {
for paid in [false, true] {
for enabled in [false, true] {
for adjust_cost in [false, true] {
let fixture = || {
let mut parts = data(Action::Dividend).snapshot_components();
parts.corporate_actions[0].payable_date =
Some(date(if paid { 14 } else { 16 }));
DataSet::from_components_with_actions(
parts.instruments,
parts.market,
parts.factors,
parts.candidates,
parts.benchmarks,
parts.corporate_actions,
)
.unwrap()
};
let timely =
run_custom(fixture(), source(false, false), Hold, enabled, adjust_cost)
.unwrap();
let delayed =
run_custom(fixture(), source(true, false), Hold, enabled, adjust_cost).unwrap();
assert_eq!(
delayed.equity_curve.last().unwrap().cash,
timely.equity_curve.last().unwrap().cash
);
let financial = |result: &fidc_core::BacktestResult| {
result
.holdings_summary
.iter()
.map(|row| {
(
row.symbol.clone(),
row.quantity,
row.average_cost,
row.last_price,
row.market_value,
row.unrealized_pnl,
row.realized_pnl,
row.pnl,
row.dividend_receivable,
)
})
.collect::<Vec<_>>()
};
// Receipt-day turnover is deliberately different when the
// dividend option is disabled and no adjustment is required.
assert_eq!(financial(&delayed), financial(&timely));
assert_eq!(
delayed.manual_executions[0].corporate_adjustment.is_some(),
enabled
);
if enabled && !paid {
assert_eq!(delayed.terminal_audit.cash_receivable_count, 1);
}
}
}
}
}
#[test]
fn late_fill_replays_aggregate_split_rounding_not_an_independent_rounded_fragment() {
let fixture = || {
let mut parts = data(Action::Split).snapshot_components();
parts.corporate_actions[0].share_bonus = 0.125;
for row in &mut parts.market {
if row.symbol == "000001.SZ" && row.date >= date(14) {
row.day_open = 8.89;
row.open = 8.89;
row.close = 8.89;
row.last_price = 8.89;
row.high = 8.89;
row.low = 8.89;
row.prev_close = 8.89;
row.bid1 = 8.89;
row.ask1 = 8.89;
row.upper_limit = 9.78;
row.lower_limit = 8.;
}
}
DataSet::from_components_with_actions(
parts.instruments,
parts.market,
parts.factors,
parts.candidates,
parts.benchmarks,
parts.corporate_actions,
)
.unwrap()
};
let input = |delayed| {
let mut value = source(delayed, true);
value.actions[0].orders[0].quantity = 100;
value.actions[0].orders[0].fills[0].quantity = 100;
value.actions[1].orders[0].side = fidc_core::OrderSide::Buy;
value.content_sha256 = value.content_digest().unwrap();
value
};
let timely = run_custom(fixture(), input(false), Hold, true, true).unwrap();
let delayed = run_custom(fixture(), input(true), Hold, true, true).unwrap();
assert_eq!(timely.holdings_summary[0].quantity, 225);
assert_eq!(delayed.holdings_summary[0].quantity, 225);
assert_eq!(
delayed.equity_curve.last().unwrap().total_equity,
timely.equity_curve.last().unwrap().total_equity
);
assert_eq!(
delayed.manual_executions[1]
.corporate_adjustment
.as_ref()
.unwrap()
.positions["000001.SZ"]
.quantity_before,
113
);
}