diff --git a/crates/fidc-core/src/manual_execution/tests.rs b/crates/fidc-core/src/manual_execution/tests.rs index f61b102..d4ec410 100644 --- a/crates/fidc-core/src/manual_execution/tests.rs +++ b/crates/fidc-core/src/manual_execution/tests.rs @@ -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 = executed.parse().unwrap(); + let observed: DateTime = 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::>(), + ["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); diff --git a/crates/fidc-core/src/portfolio.rs b/crates/fidc-core/src/portfolio.rs index c9de06a..ba7e784 100644 --- a/crates/fidc-core/src/portfolio.rs +++ b/crates/fidc-core/src/portfolio.rs @@ -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; diff --git a/crates/fidc-core/tests/automatic_trade_protection.rs b/crates/fidc-core/tests/automatic_trade_protection.rs index 6ed5012..94c4472 100644 --- a/crates/fidc-core/tests/automatic_trade_protection.rs +++ b/crates/fidc-core/tests/automatic_trade_protection.rs @@ -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 = executed.parse().unwrap(); + let observed: chrono::DateTime = 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::>(); + 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; diff --git a/crates/fidc-core/tests/corporate_actions.rs b/crates/fidc-core/tests/corporate_actions.rs index 0c641f3..61610e1 100644 --- a/crates/fidc-core/tests/corporate_actions.rs +++ b/crates/fidc-core/tests/corporate_actions.rs @@ -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, Option)>>>, + } + impl Strategy for ConvertedSale { + fn name(&self) -> &str { + "successor FIFO" + } + fn on_day( + &mut self, + ctx: &StrategyContext<'_>, + ) -> Result { + 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); diff --git a/docs/late-fill-lot-lifecycle-20260914.md b/docs/late-fill-lot-lifecycle-20260914.md new file mode 100644 index 0000000..674867c --- /dev/null +++ b/docs/late-fill-lot-lifecycle-20260914.md @@ -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/发布证据另附,本轮不修改原池、任务、历史或交易开关。