修复迟到成交和换股批次的FIFO与持有期
This commit is contained in:
@@ -105,6 +105,150 @@ fn action(quantity: &str, when: &str) -> PlatformTradeAction {
|
||||
reason: "configured_strategy_action".into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn observed_manual_trades_then_split_keep_real_fill_protection_and_lock_dates() {
|
||||
let actions = [
|
||||
("new-buy", "Buy", "2026-09-14T01:30:00Z", "2026-09-14T01:30:01Z", "10", "0.25"),
|
||||
("late-buy", "Buy", "2026-09-11T06:00:00Z", "2026-09-14T01:30:02Z", "10", "0.75"),
|
||||
("manual-sell", "Sell", "2026-09-14T01:31:00Z", "2026-09-14T01:31:01Z", "10", "0.5"),
|
||||
].into_iter().enumerate().map(|(index, (id, side, executed, observed, price, fee))| {
|
||||
let executed: chrono::DateTime<chrono::Utc> = executed.parse().unwrap();
|
||||
let observed: chrono::DateTime<chrono::Utc> = observed.parse().unwrap();
|
||||
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":100,
|
||||
"orderCreatedAt":created,"terminalObservedAt":observed,"terminalStatus":"filled","fills":[{
|
||||
"tradeId":id,"observationEventId":id,"observationSequence":index+1,
|
||||
"tradeDate":executed.date_naive(),"executedAt":executed,"observedAt":observed,
|
||||
"feeObservationEventId":id,"feeObservationSequence":index+1,"feeObservedAt":observed,
|
||||
"timestampPrecision":"second","quantity":100,"price":price,"totalFee":fee
|
||||
}]
|
||||
}]})
|
||||
}).collect::<Vec<_>>();
|
||||
let mut replay: 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-18T08:00:00Z","actions":actions,
|
||||
})).unwrap();
|
||||
replay.content_sha256 = replay.content_digest().unwrap();
|
||||
let mut parts = data().snapshot_components();
|
||||
for row in &mut parts.market {
|
||||
if row.date >= d(15) {
|
||||
row.day_open = 5.;
|
||||
row.open = 5.;
|
||||
row.high = 5.;
|
||||
row.low = 5.;
|
||||
row.close = 5.;
|
||||
row.last_price = 5.;
|
||||
row.bid1 = 5.;
|
||||
row.ask1 = 5.;
|
||||
row.prev_close = 5.;
|
||||
row.upper_limit = 5.5;
|
||||
row.lower_limit = 4.5;
|
||||
}
|
||||
}
|
||||
parts.corporate_actions.push(fidc_core::CorporateAction {
|
||||
date: d(15),
|
||||
symbol: "000001.SZ".into(),
|
||||
payable_date: None,
|
||||
share_cash: 0.,
|
||||
share_bonus: 1.,
|
||||
share_gift: 0.,
|
||||
issue_quantity: 0.,
|
||||
issue_price: 0.,
|
||||
reform: false,
|
||||
adjust_factor: None,
|
||||
successor_symbol: None,
|
||||
successor_ratio: None,
|
||||
successor_cash: None,
|
||||
});
|
||||
let data = DataSet::from_components_with_actions(
|
||||
parts.instruments,
|
||||
parts.market,
|
||||
parts.factors,
|
||||
parts.candidates,
|
||||
parts.benchmarks,
|
||||
parts.corporate_actions,
|
||||
)
|
||||
.unwrap();
|
||||
let mut config = PlatformExprStrategyConfig::generic();
|
||||
config.signal_symbol = "000001.SZ".into();
|
||||
config.benchmark_symbol = "000300.SH".into();
|
||||
config.rotation_enabled = false;
|
||||
config.matching_type = MatchingType::CurrentBarClose;
|
||||
config.volume_capacity_mode =
|
||||
fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit;
|
||||
config.automatic_trade_protection = AutomaticTradeProtection {
|
||||
buy_protection_days: 3,
|
||||
sell_cooldown_days: 3,
|
||||
max_holding_days: 1,
|
||||
locks: vec![AutomaticTradeLock {
|
||||
symbol: "000001.SZ".into(),
|
||||
start_date: d(16),
|
||||
end_date: Some(d(17)),
|
||||
}],
|
||||
};
|
||||
config.explicit_actions = vec![action("-200", "decision_date >= \"2026-09-14\"")];
|
||||
let result = BacktestEngine::new(
|
||||
data,
|
||||
PlatformExprStrategy::new(config),
|
||||
BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
|
||||
.with_matching_type(MatchingType::CurrentBarClose)
|
||||
.with_volume_capacity_mode(
|
||||
fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit,
|
||||
),
|
||||
BacktestConfig {
|
||||
initial_cash: 10000.,
|
||||
benchmark_code: "000300.SH".into(),
|
||||
start_date: Some(d(11)),
|
||||
end_date: Some(d(18)),
|
||||
decision_lag_trading_days: 0,
|
||||
execution_price_field: PriceField::Close,
|
||||
},
|
||||
)
|
||||
.with_observed_manual_executions(replay)
|
||||
.unwrap()
|
||||
.run()
|
||||
.unwrap();
|
||||
assert_eq!(result.manual_executions.len(), 3);
|
||||
assert_eq!(result.manual_executions[2].quantity_after, 100);
|
||||
assert_eq!(result.fills.len(), 1, "{:?}", result.fills);
|
||||
assert_eq!(
|
||||
(
|
||||
result.fills[0].date,
|
||||
result.fills[0].side,
|
||||
result.fills[0].quantity,
|
||||
result.fills[0].price
|
||||
),
|
||||
(d(18), OrderSide::Sell, 200, 5.)
|
||||
);
|
||||
assert!(result.fills[0].reason.contains("max_holding_days_exit"));
|
||||
for day in [14, 15] {
|
||||
for rule in ["buy_fill_protection", "sell_fill_cooldown"] {
|
||||
assert!(result.risk_decisions.iter().any(|audit| audit.date == d(day)
|
||||
&& audit.symbol == "000001.SZ" && audit.rule_code == rule && !audit.accepted), "day={day} rule={rule}");
|
||||
}
|
||||
}
|
||||
for day in [16, 17] {
|
||||
assert!(result.risk_decisions.iter().any(|audit| audit.date == d(day)
|
||||
&& audit.symbol == "000001.SZ" && audit.rule_code == "automatic_trade_locked" && !audit.accepted));
|
||||
}
|
||||
assert!(
|
||||
result
|
||||
.daily_holdings
|
||||
.iter()
|
||||
.any(|row| row.date == d(15) && row.quantity == 200)
|
||||
);
|
||||
assert!(result.holdings_summary.is_empty());
|
||||
assert!(
|
||||
result
|
||||
.equity_curve
|
||||
.iter()
|
||||
.all(|point| point.external_cash_flow == 0.)
|
||||
);
|
||||
}
|
||||
|
||||
fn run(policy: AutomaticTradeProtection) -> fidc_core::BacktestResult {
|
||||
let mut config = PlatformExprStrategyConfig::generic();
|
||||
config.volume_capacity_mode = fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit;
|
||||
|
||||
@@ -177,6 +177,157 @@ fn benchmark_snapshot(date: NaiveDate) -> BenchmarkSnapshot {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn successor_conversion_depletes_older_source_lots_before_newer_successor_buys() {
|
||||
struct ConvertedSale {
|
||||
dates: [NaiveDate; 3],
|
||||
seen: std::rc::Rc<std::cell::RefCell<Option<(Option<NaiveDate>, Option<NaiveDate>)>>>,
|
||||
}
|
||||
impl Strategy for ConvertedSale {
|
||||
fn name(&self) -> &str {
|
||||
"successor FIFO"
|
||||
}
|
||||
fn on_day(
|
||||
&mut self,
|
||||
ctx: &StrategyContext<'_>,
|
||||
) -> Result<StrategyDecision, fidc_core::BacktestError> {
|
||||
let (symbol, quantity) = if ctx.execution_date == self.dates[0] {
|
||||
("000001.SZ", 100)
|
||||
} else if ctx.execution_date == self.dates[1] {
|
||||
("000002.SZ", 100)
|
||||
} else {
|
||||
let holding = ctx.portfolio.position("000002.SZ").unwrap();
|
||||
*self.seen.borrow_mut() = Some((holding.opened_date(), holding.last_buy_date()));
|
||||
("000002.SZ", -200)
|
||||
};
|
||||
Ok(StrategyDecision {
|
||||
order_intents: vec![fidc_core::OrderIntent::Shares {
|
||||
symbol: symbol.into(),
|
||||
quantity,
|
||||
reason: "dated lot test".into(),
|
||||
}],
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
}
|
||||
let dates = [d(2026, 9, 11), d(2026, 9, 14), d(2026, 9, 15)];
|
||||
let symbols = ["000001.SZ", "000002.SZ"];
|
||||
let mut market = Vec::new();
|
||||
let mut factors = Vec::new();
|
||||
let mut candidates = Vec::new();
|
||||
for date in dates {
|
||||
for symbol in symbols {
|
||||
let price = if symbol == symbols[0] {
|
||||
10.
|
||||
} else if date == dates[2] {
|
||||
6.
|
||||
} else {
|
||||
20.
|
||||
};
|
||||
let mut quote = stock_market_snapshot(date);
|
||||
quote.symbol = symbol.into();
|
||||
quote.day_open = price;
|
||||
quote.open = price;
|
||||
quote.high = price;
|
||||
quote.low = price;
|
||||
quote.close = price;
|
||||
quote.last_price = price;
|
||||
quote.bid1 = price;
|
||||
quote.ask1 = price;
|
||||
quote.prev_close = price;
|
||||
quote.upper_limit = price * 1.1;
|
||||
quote.lower_limit = price * 0.9;
|
||||
market.push(quote);
|
||||
let mut factor = stock_factor_snapshot(date);
|
||||
factor.symbol = symbol.into();
|
||||
factors.push(factor);
|
||||
let mut candidate = stock_candidate(date);
|
||||
candidate.symbol = symbol.into();
|
||||
candidates.push(candidate);
|
||||
}
|
||||
}
|
||||
let data = DataSet::from_components_with_actions(
|
||||
symbols
|
||||
.into_iter()
|
||||
.map(|symbol| Instrument {
|
||||
symbol: symbol.into(),
|
||||
name: symbol.into(),
|
||||
board: "SZ".into(),
|
||||
round_lot: 100,
|
||||
listed_at: Some(d(2020, 1, 1)),
|
||||
delisted_at: None,
|
||||
status: "active".into(),
|
||||
})
|
||||
.collect(),
|
||||
market,
|
||||
factors,
|
||||
candidates,
|
||||
dates.map(benchmark_snapshot).into(),
|
||||
vec![CorporateAction {
|
||||
date: dates[2],
|
||||
symbol: symbols[0].into(),
|
||||
payable_date: None,
|
||||
share_cash: 0.,
|
||||
share_bonus: 0.,
|
||||
share_gift: 0.,
|
||||
issue_quantity: 0.,
|
||||
issue_price: 0.,
|
||||
reform: false,
|
||||
adjust_factor: None,
|
||||
successor_symbol: Some(symbols[1].into()),
|
||||
successor_ratio: Some(2.),
|
||||
successor_cash: Some(0.),
|
||||
}],
|
||||
)
|
||||
.unwrap();
|
||||
let seen = std::rc::Rc::new(std::cell::RefCell::new(None));
|
||||
let broker = BrokerSimulator::new_with_execution_price(
|
||||
ChinaAShareCostModel::default(),
|
||||
ChinaEquityRuleHooks,
|
||||
PriceField::Open,
|
||||
)
|
||||
.with_matching_type(fidc_core::MatchingType::NextBarOpen)
|
||||
.with_volume_limit(false)
|
||||
.with_liquidity_limit(false);
|
||||
let result = BacktestEngine::new(
|
||||
data,
|
||||
ConvertedSale {
|
||||
dates,
|
||||
seen: seen.clone(),
|
||||
},
|
||||
broker,
|
||||
BacktestConfig {
|
||||
initial_cash: 10000.,
|
||||
benchmark_code: "000300.SH".into(),
|
||||
start_date: Some(dates[0]),
|
||||
end_date: Some(dates[2]),
|
||||
decision_lag_trading_days: 0,
|
||||
execution_price_field: PriceField::Open,
|
||||
},
|
||||
)
|
||||
.run()
|
||||
.unwrap();
|
||||
assert_eq!(*seen.borrow(), Some((Some(dates[0]), Some(dates[1]))));
|
||||
assert_eq!(result.fills.len(), 3);
|
||||
assert_eq!(result.fills[2].quantity, 200);
|
||||
assert_eq!(result.fills[2].symbol, symbols[1]);
|
||||
let remaining = result
|
||||
.holdings_summary
|
||||
.iter()
|
||||
.find(|row| row.symbol == symbols[1])
|
||||
.unwrap();
|
||||
assert_eq!(remaining.quantity, 100);
|
||||
assert_eq!(remaining.realized_pnl, 200.);
|
||||
assert!(
|
||||
result
|
||||
.position_events
|
||||
.iter()
|
||||
.any(|event| event.symbol == symbols[0]
|
||||
&& event.quantity_after == 0
|
||||
&& event.reason.starts_with("successor_conversion"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn engine_reinvests_dividend_receivable_in_round_lots() {
|
||||
let buy_date = d(2025, 1, 1);
|
||||
|
||||
Reference in New Issue
Block a user