让实际换股持仓继承原策略保护期限

This commit is contained in:
boris
2026-09-14 23:38:43 +08:00
parent ad76bdb6ae
commit ba4b77fd74
8 changed files with 370 additions and 44 deletions
@@ -310,6 +310,205 @@ fn paper_and_broker_observations_require_the_same_frozen_successor_scope() {
}
}
fn protected_successor_run(delayed: bool, locked: bool, amount: i32)
-> fidc_core::BacktestResult {
let policy = fidc_core::holding_policy::AutomaticTradeProtection {
sell_cooldown_days: if locked { 0 } else { 3 },
locks: if locked { vec![fidc_core::holding_policy::AutomaticTradeLock {
symbol: "000001.SZ".into(), start_date: date(11), end_date: Some(date(15)),
}] } else { vec![] }, ..Default::default()
};
protected_successor_case(delayed, policy, amount, "partial")
}
fn protected_successor_case(delayed: bool,
policy: fidc_core::holding_policy::AutomaticTradeProtection, amount: i32, scenario: &str)
-> fidc_core::BacktestResult {
let mut config = fidc_core::PlatformExprStrategyConfig::generic();
config.signal_symbol = "000002.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.explicit_action_schedule = Some(fidc_core::PlatformRebalanceSchedule {
frequency: fidc_core::PlatformScheduleFrequency::Daily,
time_rule: Some(fidc_core::ScheduleTimeRule::physical_time(14, 30)),
});
config.automatic_trade_protection = policy;
config.explicit_actions = vec![fidc_core::PlatformTradeAction::Order {
kind: fidc_core::PlatformExplicitOrderKind::Shares, symbol: "000002.SZ".into(),
amount_expr: amount.to_string(), when_expr: Some("decision_date == \"2026-09-15\"".into()),
limit_price_expr: None, time_in_force: None, start_time_expr: None, end_time_expr: None,
reason: "configured_successor_action".into(),
}];
let data = successor_execution_data();
let mut replay = source(delayed, true);
if scenario == "sold_before" {
let order = &mut replay.actions[1].orders[0];
order.quantity = 200; order.fills[0].quantity = 200;
} else if scenario == "cleared_after" {
let mut row = serde_json::to_value(&replay.actions[1]).unwrap();
let at = "2026-09-15T05:30:00Z";
let receipt = "2026-09-15T05:30:01Z";
row["actionId"] = "clear".into(); row["auditEventIds"] = serde_json::json!(["audit-clear"]);
row["confirmedAt"] = at.into(); row["confirmationObservedAt"] = at.into();
let order = &mut row["orders"][0];
order["orderId"] = "clear-order".into(); order["brokerOrderId"] = "clear-order".into();
order["symbol"] = "000002.SZ".into(); order["quantity"] = 200.into();
order["orderCreatedAt"] = at.into(); order["terminalObservedAt"] = receipt.into();
let fill = &mut order["fills"][0];
fill["tradeId"] = "clear-trade".into(); fill["observationEventId"] = "clear-receipt".into();
fill["observationSequence"] = 3.into(); fill["tradeDate"] = "2026-09-15".into();
fill["executedAt"] = at.into(); fill["observedAt"] = receipt.into();
fill["feeObservationEventId"] = "clear-receipt".into(); fill["feeObservationSequence"] = 3.into();
fill["feeObservedAt"] = receipt.into(); fill["price"] = "5".into(); fill["quantity"] = 200.into();
replay.actions.push(serde_json::from_value(row).unwrap());
}
replay.content_sha256 = replay.content_digest().unwrap();
BacktestEngine::new(data, fidc_core::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(date(10)), end_date: Some(date(15)), decision_lag_trading_days: 0,
execution_price_field: PriceField::Close,
}).with_observed_manual_executions(replay).unwrap().run().unwrap()
}
fn successor_execution_data() -> DataSet {
let parts = data(Action::Successor).snapshot_components();
DataSet::from_components_with_actions_and_quotes(parts.instruments, parts.market,
parts.factors, parts.candidates, parts.benchmarks, parts.corporate_actions,
[30, 31].into_iter().map(|minute| fidc_core::IntradayExecutionQuote {
observation_kind: fidc_core::data::QuoteObservationKind::MinuteBar,
date: date(15), symbol: "000002.SZ".into(), timestamp: date(15).and_hms_opt(14,minute,0).unwrap(),
last_price: 5., bid1: 5., ask1: 5., bid1_volume: 100000, ask1_volume: 100000,
volume_delta: 100000, amount_delta: 500000., trading_phase: Some("continuous".into()),
}).collect()).unwrap()
}
#[test]
fn native_pool_rebalance_applies_inherited_protection_without_rewriting_its_target() {
use fidc_core::stock_pool_execution as pool;
struct NativePool { locked: bool, expires: u32, exposure: i32 }
impl Strategy for NativePool {
fn name(&self) -> &str { "native pool successor protection" }
fn requires_minute_callbacks(&self) -> bool { false }
fn schedule_rules(&self) -> Vec<fidc_core::ScheduleRule> {
vec![fidc_core::ScheduleRule::daily("pool", fidc_core::ScheduleStage::OnDay)
.with_time_rule(fidc_core::ScheduleTimeRule::physical_time(14,30))]
}
fn on_scheduled(&mut self, ctx: &fidc_core::StrategyContext<'_>, _: &fidc_core::ScheduleRule)
-> Result<fidc_core::StrategyDecision, fidc_core::BacktestError> {
if ctx.execution_date != date(15) { return Ok(Default::default()); }
let symbols = vec!["000002.SZ".to_owned()];
let policy = fidc_core::holding_policy::AutomaticTradeProtection {
sell_cooldown_days: if self.locked { 0 } else { 3 },
locks: if self.locked { vec![fidc_core::holding_policy::AutomaticTradeLock {
symbol: "000001.SZ".into(), start_date: date(11), end_date: Some(date(self.expires)),
}] } else { vec![] }, ..Default::default()
};
let contract = pool::FrozenStockPoolIntent {
pool_id: "pool".into(), signal_date: date(15), frozen_equity: 10000.into(),
selection: pool::StockPoolSelection { trade_date: date(15), requested_symbols: symbols.clone(),
normal_trading_symbols: symbols.clone(), risk_eligible_symbols: symbols.clone(), final_symbols: symbols,
exclusion_reasons: Default::default(), inherited_from_generation: None, explicit_empty: false,
generation: Some("latest".into()),
}, members: vec![pool::StockPoolMemberSpec { symbol: "000002.SZ".into(), requested_order: 0,
recommendation_reason: String::new(), target_weight_bps: None, stop_loss: None, take_profit: None }],
rule: pool::StockPoolExecutionRule { pricing_mode: pool::POOL_PRICE_FIRST_TICK.into(),
window_start: "14:30".into(), window_end: "15:00".into(), automatic_trade_protection: policy,
..Default::default() }, constraints: pool::StockPoolDecisionConstraints {
target_holding_count: Some(1), ..Default::default() },
invest_ratio_bps: self.exposure, reserve_cash: 0.into(), out_of_pool_policy: "hold".into(), generation: "latest".into(),
};
Ok(fidc_core::StrategyDecision { order_intents: vec![fidc_core::OrderIntent::StockPool { contract: Box::new(contract) }], ..Default::default() })
}
}
for delayed in [false, true] {
for (locked, expires, exposure) in [(false, 15, 10000), (true, 15, 10000), (true, 15, 0), (true, 14, 10000)] {
let result = BacktestEngine::new(successor_execution_data(), NativePool { locked, expires, exposure },
BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
.with_matching_type(MatchingType::MinuteLast).with_intraday_execution_start_time(chrono::NaiveTime::from_hms_opt(14,30,0).unwrap())
.with_volume_limit(false).with_liquidity_limit(false),
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::Last })
.with_observed_manual_executions(source(delayed, true)).unwrap().run().unwrap();
if expires == 14 {
assert!(!result.fills.is_empty(), "positive control {:?}", result.order_events);
} else {
assert!(result.fills.is_empty(), "delayed={delayed} locked={locked} exposure={exposure}: {:?}", result.fills);
assert!(result.order_events.is_empty());
assert_eq!(result.holdings_summary[0].quantity, 200);
}
}
}
}
#[test]
fn successor_keeps_sell_cooldown_for_timely_and_delayed_receipts() {
for delayed in [false, true] {
let result = protected_successor_run(delayed, false, 100);
assert!(result.fills.is_empty(), "delayed={delayed}: {:?}", result.fills);
assert!(result.order_events.is_empty(), "delayed={delayed}: {:?}", result.order_events);
assert!(result.risk_decisions.iter().any(|row| row.symbol == "000002.SZ"
&& row.date == date(15) && !row.accepted && row.rule_code == "sell_fill_cooldown"),
"orders={:?} risk={:?} notes={:?}", result.order_events, result.risk_decisions,
result.equity_curve.iter().map(|row| (&row.date, &row.notes)).collect::<Vec<_>>());
assert_eq!(result.holdings_summary[0].quantity, 200);
}
}
#[test]
fn converted_holding_does_not_lose_its_configured_date_lock() {
for delayed in [false, true] {
for amount in [-100, 100] {
let result = protected_successor_run(delayed, true, amount);
assert!(result.fills.is_empty(), "delayed={delayed} amount={amount}: {:?}", result.fills);
assert!(result.order_events.is_empty(), "delayed={delayed} amount={amount}: {:?}", result.order_events);
assert!(result.risk_decisions.iter().any(|row| row.symbol == "000002.SZ"
&& row.date == date(15) && !row.accepted && row.rule_code == "automatic_trade_locked"),
"orders={:?} risk={:?} notes={:?}", result.order_events, result.risk_decisions,
result.equity_curve.iter().map(|row| (&row.date, &row.notes)).collect::<Vec<_>>());
assert_eq!(result.holdings_summary[0].quantity, 200);
}
}
}
#[test]
fn successor_lock_expires_on_the_original_configured_date_not_the_conversion_date() {
for delayed in [false, true] {
let policy = fidc_core::holding_policy::AutomaticTradeProtection {
locks: vec![fidc_core::holding_policy::AutomaticTradeLock {
symbol: "000001.SZ".into(), start_date: date(11), end_date: Some(date(14)),
}], ..Default::default()
};
let result = protected_successor_case(delayed, policy, 100, "partial");
assert_eq!(result.fills.len(), 1);
assert_eq!(result.fills[0].quantity, 100);
assert_eq!(result.holdings_summary[0].quantity, 300);
}
}
#[test]
fn lock_survives_a_manual_clear_after_conversion_but_not_a_disproved_conversion() {
for delayed in [false, true] {
let policy = fidc_core::holding_policy::AutomaticTradeProtection {
locks: vec![fidc_core::holding_policy::AutomaticTradeLock {
symbol: "000001.SZ".into(), start_date: date(11), end_date: Some(date(15)),
}], ..Default::default()
};
let cleared = protected_successor_case(delayed, policy.clone(), 100, "cleared_after");
assert!(cleared.fills.is_empty());
assert!(cleared.order_events.is_empty());
assert!(cleared.holdings_summary.is_empty());
assert_eq!(cleared.manual_executions.len(), 3);
let unconverted = protected_successor_case(delayed, policy, 100, "sold_before");
assert_eq!(unconverted.fills.len(), 1);
assert_eq!(unconverted.holdings_summary[0].quantity, 100);
}
}
#[test]
fn corporate_replay_preserves_issued_orders_cash_flows_financing_and_charged_fees() {
struct ExistingActivity {