diff --git a/crates/fidc-core/tests/automatic_trade_protection.rs b/crates/fidc-core/tests/automatic_trade_protection.rs index 94c4472..9f20174 100644 --- a/crates/fidc-core/tests/automatic_trade_protection.rs +++ b/crates/fidc-core/tests/automatic_trade_protection.rs @@ -108,22 +108,28 @@ fn action(quantity: &str, when: &str) -> PlatformTradeAction { #[test] fn observed_manual_trades_then_split_keep_real_fill_protection_and_lock_dates() { + for sell_during_lock in [false, true] { + let sale = if sell_during_lock { + ("manual-sell", "Sell", "2026-09-16T01:31:00Z", "2026-09-16T01:31:01Z", "5", "0.5", 200) + } else { + ("manual-sell", "Sell", "2026-09-14T01:31:00Z", "2026-09-14T01:31:01Z", "10", "0.5", 100) + }; 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))| { + ("new-buy", "Buy", "2026-09-14T01:30:00Z", "2026-09-14T01:30:01Z", "10", "0.25", 100), + ("late-buy", "Buy", "2026-09-11T06:00:00Z", "2026-09-14T01:30:02Z", "10", "0.75", 100), + sale, + ].into_iter().enumerate().map(|(index, (id, side, executed, observed, price, fee, quantity))| { 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, + "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":executed.date_naive(),"executedAt":executed,"observedAt":observed, "feeObservationEventId":id,"feeObservationSequence":index+1,"feeObservedAt":observed, - "timestampPrecision":"second","quantity":100,"price":price,"totalFee":fee + "timestampPrecision":"second","quantity":quantity,"price":price,"totalFee":fee }] }]}) }).collect::>(); @@ -212,7 +218,7 @@ fn observed_manual_trades_then_split_keep_real_fill_protection_and_lock_dates() .run() .unwrap(); assert_eq!(result.manual_executions.len(), 3); - assert_eq!(result.manual_executions[2].quantity_after, 100); + assert_eq!(result.manual_executions[2].quantity_after, if sell_during_lock { 200 } else { 100 }); assert_eq!(result.fills.len(), 1, "{:?}", result.fills); assert_eq!( ( @@ -226,6 +232,7 @@ fn observed_manual_trades_then_split_keep_real_fill_protection_and_lock_dates() assert!(result.fills[0].reason.contains("max_holding_days_exit")); for day in [14, 15] { for rule in ["buy_fill_protection", "sell_fill_cooldown"] { + if rule == "sell_fill_cooldown" && sell_during_lock { continue; } 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}"); } @@ -238,7 +245,7 @@ fn observed_manual_trades_then_split_keep_real_fill_protection_and_lock_dates() result .daily_holdings .iter() - .any(|row| row.date == d(15) && row.quantity == 200) + .any(|row| row.date == d(15) && row.quantity == if sell_during_lock { 400 } else { 200 }) ); assert!(result.holdings_summary.is_empty()); assert!( @@ -247,6 +254,7 @@ fn observed_manual_trades_then_split_keep_real_fill_protection_and_lock_dates() .iter() .all(|point| point.external_cash_flow == 0.) ); + } } fn run(policy: AutomaticTradeProtection) -> fidc_core::BacktestResult { diff --git a/docs/late-fill-lot-lifecycle-20260914.md b/docs/late-fill-lot-lifecycle-20260914.md index 674867c..320aeb4 100644 --- a/docs/late-fill-lot-lifecycle-20260914.md +++ b/docs/late-fill-lot-lifecycle-20260914.md @@ -24,6 +24,7 @@ - 最长持有期使用9月11日,买后3个交易日保护使用最新买入日9月14日,保护优先于最长持有退出。 - 整段引擎换股:旧股较早买入100股、已有新股较晚买入100股,旧股按2倍换成新股200股。随后卖200股先扣旧来源,留下新买100股;不重置开仓/最近买入日,成交来源及换股事件保留,已实现不含费用盈亏200。 - 整段平台表达式:真实手工两买一卖、次日送转、3日买后保护/卖后禁买、16日至17日显式锁定、最长持有退出同时配置。14日至15日审计分别记录保护和禁买,16日至17日记录锁定;18日只生成一笔卖200股、5元的最长持有退出,不重复附加显式卖单。三笔手工来源保留、不计出入金,旧股转成200股后计时不重置。 +- 同一整段测试另验16日锁定期间的已确认手工卖出200股:与14日保护期间卖100股两个变体分别验证。前者15日送转后400股,手工卖出后200股;两个变体最终都在18日仅自动退出剩余200股。锁定仍阻止自动交易,不阻止已授权手工事实入账;T+1未绕过,买后保护和卖后禁买分别沿实际日期计算。 本机Core889、Trading625、Runner460/API127通过,ignore另计;针对性完整审计断言另行通过。两次测试编写阶段的私有方法/辅助函数名编译错误已修正,不计作框架失败或通过证据。不是实际Source或GT交易验收。