修复迟到成交和换股批次的FIFO与持有期

This commit is contained in:
boris
2026-09-14 19:15:01 +08:00
parent b2eaaa0d26
commit e9c9ecbd48
5 changed files with 536 additions and 0 deletions
@@ -23,6 +23,200 @@ fn sample() -> ManualExecutionReplay {
fn reseal(input: &mut ManualExecutionReplay) {
input.content_sha256 = input.content_digest().unwrap();
}
fn delayed_buy_replay() -> ManualExecutionReplay {
let mut input = sample();
let template = input.actions[0].clone();
input.actions.clear();
for (index, side, executed, observed, price, fee) in [
(
0,
OrderSide::Buy,
"2026-09-14T01:30:00Z",
"2026-09-14T01:30:01Z",
"20",
"0.25",
),
(
1,
OrderSide::Buy,
"2026-09-11T06:00:00Z",
"2026-09-14T01:30:02Z",
"10",
"0.75",
),
(
2,
OrderSide::Sell,
"2026-09-14T01:31:00Z",
"2026-09-14T01:31:01Z",
"10",
"0.5",
),
(
3,
OrderSide::Sell,
"2026-09-14T01:32:00Z",
"2026-09-14T01:32:01Z",
"10",
"0.5",
),
] {
let executed: DateTime<Utc> = executed.parse().unwrap();
let observed: DateTime<Utc> = observed.parse().unwrap();
let mut action = template.clone();
action.action_id = format!("action-{index}");
action.audit_event_ids = vec![format!("audit-{index}")];
action.confirmed_at = executed - chrono::Duration::seconds(2);
action.confirmation_observed_at = action.confirmed_at;
let order = &mut action.orders[0];
order.order_id = format!("order-{index}");
order.broker_order_id = Some(format!("broker-{index}"));
order.side = side;
order.order_created_at = executed - chrono::Duration::seconds(1);
order.terminal_observed_at = observed;
let fill = &mut order.fills[0];
fill.trade_id = format!("trade-{index}");
fill.observation_event_id = format!("receipt-{index}");
fill.observation_sequence = index + 1;
fill.fee_observation_event_id = fill.observation_event_id.clone();
fill.fee_observation_sequence = fill.observation_sequence;
fill.trade_date = executed
.with_timezone(&FixedOffset::east_opt(8 * 3600).unwrap())
.date_naive();
fill.executed_at = executed;
fill.observed_at = observed;
fill.fee_observed_at = observed;
fill.price = price.parse().unwrap();
fill.commission = None;
fill.stamp_tax = None;
fill.transfer_fee = None;
fill.total_fee = fee.parse().unwrap();
input.actions.push(action);
}
reseal(&mut input);
input.validate().unwrap();
input
}
#[test]
fn late_buy_retains_the_earliest_opening_and_latest_buy_dates() {
let mut cursor = ManualReplayCursor::new(delayed_buy_replay()).unwrap();
let data = identity_data(NaiveDate::from_ymd_opt(2020, 1, 1).unwrap());
let mut portfolio = PortfolioState::new(10000.);
let applications = cursor
.advance(
"2026-09-14T01:30:02Z".parse().unwrap(),
&mut portfolio,
&data,
false,
)
.unwrap();
assert_eq!(
applications
.iter()
.map(|row| row.trade_id.as_str())
.collect::<Vec<_>>(),
["trade-0", "trade-1"]
);
let position = portfolio.position("000001.SZ").unwrap();
assert_eq!(position.opened_date(), NaiveDate::from_ymd_opt(2026, 9, 11));
assert_eq!(
position.last_buy_date(),
NaiveDate::from_ymd_opt(2026, 9, 14)
);
assert_eq!(position.quantity, 200);
let calendar = crate::TradingCalendar::new(
[11, 14, 15, 16, 17, 18]
.map(|day| NaiveDate::from_ymd_opt(2026, 9, day).unwrap())
.into(),
);
let evidence = crate::holding_policy::HoldingLifecycleEvidence {
has_position: true,
opened_date: position.opened_date(),
last_buy_date: position.last_buy_date(),
last_sell_date: None,
};
let mut policy = crate::holding_policy::AutomaticTradeProtection {
max_holding_days: 1,
..Default::default()
};
assert!(
policy
.evaluate(
"000001.SZ",
NaiveDate::from_ymd_opt(2026, 9, 14).unwrap(),
&evidence,
&calendar
)
.unwrap()
.max_holding_exit
);
policy.buy_protection_days = 3;
for day in [14, 15, 16, 17] {
let permission = policy
.evaluate(
"000001.SZ",
NaiveDate::from_ymd_opt(2026, 9, day).unwrap(),
&evidence,
&calendar,
)
.unwrap();
assert_eq!(permission.sell_denial, Some("buy_fill_protection"));
assert!(!permission.max_holding_exit);
}
assert!(
policy
.evaluate(
"000001.SZ",
NaiveDate::from_ymd_opt(2026, 9, 18).unwrap(),
&evidence,
&calendar
)
.unwrap()
.max_holding_exit
);
}
#[test]
fn late_buy_fifo_depletion_preserves_costs_and_cannot_unlock_today_lots() {
let mut cursor = ManualReplayCursor::new(delayed_buy_replay()).unwrap();
let data = identity_data(NaiveDate::from_ymd_opt(2020, 1, 1).unwrap());
let mut portfolio = PortfolioState::new(10000.);
let applications = cursor
.advance(
"2026-09-14T01:31:01Z".parse().unwrap(),
&mut portfolio,
&data,
false,
)
.unwrap();
assert_eq!(applications.len(), 3);
let position = portfolio.position("000001.SZ").unwrap();
assert_eq!(position.quantity, 100);
assert_eq!(position.unrealized_pnl(), -1000.25);
assert_eq!(
position.sellable_qty(NaiveDate::from_ymd_opt(2026, 9, 14).unwrap()),
0
);
assert_eq!(position.realized_pnl(), -0.75);
assert_eq!(portfolio.cash(), 7998.5);
assert_eq!(portfolio.external_cash_flow_total(), 0.);
assert!(
cursor
.advance(
"2026-09-14T01:32:01Z".parse().unwrap(),
&mut portfolio,
&data,
false
)
.unwrap_err()
.contains("T+1")
);
assert_eq!(cursor.applied_count(), 3);
assert_eq!(portfolio.cash(), 7998.5);
assert_eq!(portfolio.position("000001.SZ").unwrap().quantity, 100);
}
fn semantic_result(input: &ManualExecutionReplay) -> Result<(), String> {
let mut input = input.clone();
reseal(&mut input);
+13
View File
@@ -157,6 +157,8 @@ impl Position {
);
if previous_quantity == 0 {
self.opened_date = Some(date);
} else if let Some(opened) = self.opened_date {
self.opened_date = Some(opened.min(date));
}
let previous_average_price = self.average_price;
let previous_average_cost = self.average_cost;
@@ -232,6 +234,17 @@ impl Position {
));
}
// A delayed receipt or a successor conversion can append an older
// acquisition after a newer lot. Deplete by actual acquisition date;
// stable ordering preserves same-day receipts and their attached fees.
if quantity > 0
&& self
.lots
.windows(2)
.any(|pair| pair[0].acquired_date > pair[1].acquired_date)
{
self.lots.sort_by_key(|lot| lot.acquired_date);
}
let mut remaining = quantity;
let mut remaining_proceeds = total_proceeds;
let mut realized = FixedMoney::ZERO;
@@ -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;
+151
View File
@@ -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);
+34
View File
@@ -0,0 +1,34 @@
# 迟到成交、批次成本与持有保护
2026-09-14。阶段修复;完整股票池目标仍未完成,未据此发布生产。
## 根因与修复
旧持仓账本按收到买入回报的顺序追加批次,卖出直接扣列表首批。当较早成交的回报晚到,或旧证券换股并入已有新证券持仓时,列表先后不再等于取得日期。T+1 校验计算了合法老批次数量,却实际扣掉新批次;剩余旧批次可能再次被当作可卖。FIFO 成本与已实现/未实现盈亏随之错配。
另一问题是已有持仓收到更早买入事实时,`opened_date` 没有更新,最长持有期从较晚日期起算。
修复在账本扣减入口按真实取得日期稳定执行 FIFO;同日回报及其费用保持原关联,不重排收到的外部事件,不补单、不回写历史结果。正常日期顺序不排序,零股操作不排序。连续持仓的已知开仓日取较早日期,最近买入日仍取较晚日期;缺失的原始建仓日期不靠新买入猜测填充。移动均价展示合同与固定精度现金/费用不变。
## 负向证据
基线 `b2eaaa0d269d4aee5e2e500cd0f2b2edbda648b8` 上新增两个测试实际失败:
- 9月14日新买100股先被观察,9月11日老买100股随后才被观察。旧 `opened_date` 仍为9月14日,期望9月11日。
- 随后卖出100股时,旧代码扣了新批次,剩余未实现盈亏为 -0.75,而按老批次先卖应为 -1000.25。该样例分别使用20元/10元买入、0.25/0.75元买入费用和0.5元卖出费用;只有证券身份数据,不冒充真实市场行情。
## 回归覆盖
- 回报仍按原观察序号应用;老买入不得在收到之前进入持仓。
- 合法卖出老100股后,新100股仍不可在9月14日卖出。第二次冲突卖出拒绝且现金、股数、游标原子保留;现金7998.5、出入金0、剩余FIFO成本2000.25,费用没有串到另一批次。
- 最长持有期使用9月11日,买后3个交易日保护使用最新买入日9月14日,保护优先于最长持有退出。
- 整段引擎换股:旧股较早买入100股、已有新股较晚买入100股,旧股按2倍换成新股200股。随后卖200股先扣旧来源,留下新买100股;不重置开仓/最近买入日,成交来源及换股事件保留,已实现不含费用盈亏200。
- 整段平台表达式:真实手工两买一卖、次日送转、3日买后保护/卖后禁买、16日至17日显式锁定、最长持有退出同时配置。14日至15日审计分别记录保护和禁买,16日至17日记录锁定;18日只生成一笔卖200股、5元的最长持有退出,不重复附加显式卖单。三笔手工来源保留、不计出入金,旧股转成200股后计时不重置。
本机Core889、Trading625、Runner460/API127通过,ignore另计;针对性完整审计断言另行通过。两次测试编写阶段的私有方法/辅助函数名编译错误已修正,不计作框架失败或通过证据。不是实际Source或GT交易验收。
## 仍须继续
实际 Source/Runner 联合回放和未准入 Arrow 性能门禁尚未通过;Source明确冻结仍待独立解除授权。另需继续验证迟到回报跨越已经执行过的除权/派息/换股事件、跨模式历史持有事实及其余参数矩阵。本节只证明列出的组合,不能外推全部公司行为或关闭完整目标。
旧opening-clock-UUx5ru与FewUWP收据均不包含本次账本修复,不得覆盖。后续新的Linux/发布证据另附,本轮不修改原池、任务、历史或交易开关。