让实际换股持仓继承原策略保护期限
This commit is contained in:
@@ -2766,15 +2766,6 @@ where
|
||||
if execution.side == OrderSide::Sell {
|
||||
let date = execution.executed_at.with_timezone(&chrono::FixedOffset::east_opt(8 * 3600).unwrap()).date_naive();
|
||||
self.mark_same_day_sold(date, &execution.symbol);
|
||||
if let Some(adjustment) = &execution.corporate_adjustment {
|
||||
for successor in adjustment
|
||||
.actions
|
||||
.iter()
|
||||
.filter_map(|action| action.successor_symbol.as_deref())
|
||||
{
|
||||
self.mark_same_day_sold(date, successor);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -69,7 +69,89 @@ fn pool_positions(
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod successor_protection_tests {
|
||||
use super::*;
|
||||
use crate::holding_policy::{AutomaticTradeLock, AutomaticTradeProtection};
|
||||
fn day(n: u32) -> NaiveDate { NaiveDate::from_ymd_opt(2026, 9, n).unwrap() }
|
||||
|
||||
#[test]
|
||||
fn deferred_etf_open_rechecks_inherited_locks_and_cooldown_before_any_order() {
|
||||
let old = "159915.SZ";
|
||||
let new = "159999.SZ";
|
||||
let data = DataSet::from_components(
|
||||
[old, new].into_iter().map(|symbol| crate::Instrument {
|
||||
symbol: symbol.into(), name: "isolated ETF fixture".into(), board: "ETF".into(), round_lot: 100,
|
||||
listed_at: Some(day(1)), delisted_at: None, status: "active".into(),
|
||||
}).collect(), vec![crate::DailyMarketSnapshot {
|
||||
date: day(15), symbol: new.into(), timestamp: None, day_open: 5., open: 5., high: 5., low: 5.,
|
||||
close: 5., last_price: 5., bid1: 5., ask1: 5., prev_close: 5., volume: 100000,
|
||||
minute_volume: 0, bid1_volume: 100000, ask1_volume: 100000, trading_phase: None,
|
||||
paused: false, upper_limit: 5.5, lower_limit: 4.5, price_tick: 0.001,
|
||||
}], vec![], vec![crate::CandidateEligibility {
|
||||
date: day(15), symbol: new.into(), is_st: false, is_star_st: false, is_new_listing: false,
|
||||
is_paused: false, allow_buy: true, allow_sell: true, is_kcb: false, is_one_yuan: false,
|
||||
risk_level_code: None,
|
||||
}], [11,14,15].into_iter().map(|n| crate::BenchmarkSnapshot {
|
||||
date: day(n), benchmark: "000300.SH".into(), open: 100., close: 100., prev_close: 100., volume: 10000,
|
||||
}).collect()).unwrap();
|
||||
for mode in ["lock", "cooldown", "expired"] {
|
||||
let broker = BrokerSimulator::new(crate::ChinaAShareCostModel::default(), crate::ChinaEquityRuleHooks)
|
||||
.with_volume_limit(false).with_liquidity_limit(false);
|
||||
let mut portfolio = PortfolioState::new(10000.);
|
||||
portfolio.position_mut(old).buy(day(11), 200, 10.);
|
||||
portfolio.position_mut(old).sell(100, 10.).unwrap();
|
||||
broker.mark_same_day_sold(day(11), old);
|
||||
portfolio.apply_successor_conversion(old, new, 2., 0.).unwrap();
|
||||
let policy = AutomaticTradeProtection {
|
||||
sell_cooldown_days: if mode == "cooldown" { 3 } else { 0 },
|
||||
locks: if mode != "cooldown" { vec![AutomaticTradeLock {
|
||||
symbol: old.into(), start_date: day(11), end_date: Some(day(if mode == "expired" {14} else {15})),
|
||||
}] } else { vec![] }, ..Default::default()
|
||||
};
|
||||
let rule = pool::StockPoolExecutionRule { automatic_trade_protection: policy, ..Default::default() };
|
||||
broker.deferred_etf_targets.borrow_mut().replace_generation("pool", "latest");
|
||||
broker.deferred_etf_targets.borrow_mut().upsert(crate::etf_execution::DeferredEtfTarget {
|
||||
pool_id: "pool".into(), generation: "latest".into(), symbol: new.into(),
|
||||
signal_date: day(14), signal_at: day(14).and_hms_opt(13,0,0).unwrap(), execute_on: Some(day(15)),
|
||||
target_value: 5000.into(), target_weight_bps: 10000, side: pool::OrderSide::Buy, max_positions: 1,
|
||||
rule: std::sync::Arc::new(rule), members: std::sync::Arc::new(vec![pool::StockPoolMemberSpec {
|
||||
symbol: new.into(), requested_order: 0, recommendation_reason: String::new(),
|
||||
target_weight_bps: None, stop_loss: None, take_profit: None,
|
||||
}]), reason: "isolated deferred ETF target".into(),
|
||||
});
|
||||
let report = broker.execute_deferred_etf_targets(day(15), &mut portfolio, &data).unwrap();
|
||||
if mode == "expired" {
|
||||
assert_eq!(report.fill_events.len(), 1, "{report:?}");
|
||||
assert_eq!(portfolio.position(new).unwrap().quantity, 1000);
|
||||
} else {
|
||||
assert!(report.order_events.is_empty(), "{mode}: {report:?}");
|
||||
assert!(report.fill_events.is_empty());
|
||||
assert_eq!(portfolio.position(new).unwrap().quantity, 200);
|
||||
assert!(report.diagnostics.iter().any(|text| text.contains(if mode == "lock" {"automatic_trade_locked"} else {"sell_fill_cooldown"})));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<C: CostModel, R: EquityRuleHooks> BrokerSimulator<C, R> {
|
||||
fn pool_automatic_permission(&self, symbol: &str, date: NaiveDate,
|
||||
policy: &crate::holding_policy::AutomaticTradeProtection,
|
||||
portfolio: &PortfolioState, data: &DataSet)
|
||||
-> Result<crate::holding_policy::AutomaticTradePermission, BacktestError> {
|
||||
let position = portfolio.position(symbol).filter(|position| position.quantity > 0);
|
||||
let sold = self.same_day_sold_symbols.borrow().iter().rev()
|
||||
.find(|(day, symbols)| **day <= date && (symbols.contains(symbol)
|
||||
|| portfolio.corporate_predecessors(symbol).any(|previous| symbols.contains(previous))))
|
||||
.map(|(day, _)| *day);
|
||||
let evidence = HoldingLifecycleEvidence {
|
||||
has_position: position.is_some(), opened_date: position.and_then(|position| position.opened_date()),
|
||||
last_buy_date: position.and_then(|position| position.last_buy_date()), last_sell_date: sold,
|
||||
};
|
||||
policy.evaluate_with_predecessors(symbol, date, &evidence, data.calendar(),
|
||||
portfolio.corporate_predecessors(symbol)).map_err(BacktestError::Execution)
|
||||
}
|
||||
|
||||
pub(super) fn resume_stock_pool_executions(&self, date: NaiveDate, portfolio: &mut PortfolioState, data: &DataSet,
|
||||
session: &mut BrokerExecutionSession, report: &mut BrokerExecutionReport) -> Result<(), BacktestError> {
|
||||
let clock = self.runtime_intraday_start_time.get().or(self.intraday_execution_start_time);
|
||||
@@ -463,25 +545,8 @@ impl<C: CostModel, R: EquityRuleHooks> BrokerSimulator<C, R> {
|
||||
constraints.automatic_permissions.clear();
|
||||
if contract.rule.automatic_trade_protection.enabled() {
|
||||
for symbol in &scope {
|
||||
let position = portfolio.position(symbol).filter(|p| p.quantity > 0);
|
||||
let sold = self
|
||||
.same_day_sold_symbols
|
||||
.borrow()
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|(day, symbols)| **day <= date && symbols.contains(symbol))
|
||||
.map(|(day, _)| *day);
|
||||
let evidence = HoldingLifecycleEvidence {
|
||||
has_position: position.is_some(),
|
||||
opened_date: position.and_then(|p| p.opened_date()),
|
||||
last_buy_date: position.and_then(|p| p.last_buy_date()),
|
||||
last_sell_date: sold,
|
||||
};
|
||||
let permission = contract
|
||||
.rule
|
||||
.automatic_trade_protection
|
||||
.evaluate(symbol, date, &evidence, data.calendar())
|
||||
.map_err(BacktestError::Execution)?;
|
||||
let permission = self.pool_automatic_permission(symbol, date,
|
||||
&contract.rule.automatic_trade_protection, portfolio, data)?;
|
||||
constraints
|
||||
.automatic_permissions
|
||||
.insert(symbol.clone(), permission);
|
||||
@@ -708,10 +773,8 @@ impl<C: CostModel, R: EquityRuleHooks> BrokerSimulator<C, R> {
|
||||
}
|
||||
let position = portfolio.position(&target.symbol).filter(|p| p.quantity > 0);
|
||||
let before_quantity = position.map_or(0, |p| p.quantity);
|
||||
let permission = target.rule.automatic_trade_protection.evaluate(&target.symbol, date, &HoldingLifecycleEvidence {
|
||||
has_position:position.is_some(), opened_date:position.and_then(|p| p.opened_date()), last_buy_date:position.and_then(|p| p.last_buy_date()),
|
||||
last_sell_date:self.same_day_sold_symbols.borrow().iter().rev().find(|(day, symbols)| **day <= date && symbols.contains(&target.symbol)).map(|(day, _)| *day),
|
||||
}, data.calendar()).map_err(BacktestError::Execution)?;
|
||||
let permission = self.pool_automatic_permission(&target.symbol, date,
|
||||
&target.rule.automatic_trade_protection, portfolio, data)?;
|
||||
let denial = if target.side == pool::OrderSide::Buy {
|
||||
permission.buy_denial.or(permission.max_holding_exit.then_some("max_holding_exit_pending"))
|
||||
} else { permission.sell_denial };
|
||||
|
||||
@@ -120,10 +120,25 @@ impl AutomaticTradeProtection {
|
||||
execution_date: NaiveDate,
|
||||
evidence: &HoldingLifecycleEvidence,
|
||||
calendar: &TradingCalendar,
|
||||
) -> Result<AutomaticTradePermission, String> {
|
||||
self.evaluate_with_predecessors(symbol, execution_date, evidence, calendar, std::iter::empty())
|
||||
}
|
||||
|
||||
/// Only accept predecessors from validated, actually applied holding
|
||||
/// conversions. Catalog aliases or requested strategy symbols are not
|
||||
/// evidence that a configured lock covers another security.
|
||||
pub fn evaluate_with_predecessors<'a>(
|
||||
&self,
|
||||
symbol: &str,
|
||||
execution_date: NaiveDate,
|
||||
evidence: &HoldingLifecycleEvidence,
|
||||
calendar: &TradingCalendar,
|
||||
verified_predecessors: impl IntoIterator<Item = &'a str>,
|
||||
) -> Result<AutomaticTradePermission, String> {
|
||||
self.validate()?;
|
||||
let predecessors = verified_predecessors.into_iter().collect::<std::collections::BTreeSet<_>>();
|
||||
if self.locks.iter().any(|lock| {
|
||||
lock.symbol == symbol
|
||||
(lock.symbol == symbol || predecessors.contains(lock.symbol.as_str()))
|
||||
&& lock.start_date <= execution_date
|
||||
&& lock.end_date.is_none_or(|end| execution_date <= end)
|
||||
}) {
|
||||
|
||||
@@ -12470,11 +12470,6 @@ impl Strategy for PlatformExprStrategy {
|
||||
.entry(execution.symbol.clone())
|
||||
.and_modify(|previous| *previous = (*previous).max(date))
|
||||
.or_insert(date);
|
||||
if let Some(adjustment) = &execution.corporate_adjustment {
|
||||
for successor in adjustment.actions.iter().filter_map(|action| action.successor_symbol.as_ref()) {
|
||||
history.entry(successor.clone()).and_modify(|previous| *previous = (*previous).max(date)).or_insert(date);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn name(&self) -> &str {
|
||||
@@ -12828,16 +12823,22 @@ impl PlatformExprStrategy {
|
||||
let mut symbols = ctx.portfolio.positions().keys().cloned().collect::<BTreeSet<_>>();
|
||||
symbols.extend(self.protection_last_sells.keys().cloned());
|
||||
symbols.extend(policy.locks.iter().map(|lock| lock.symbol.clone()));
|
||||
symbols.extend(ctx.portfolio.observed_successor_symbols().map(str::to_owned));
|
||||
self.automatic_trade_permissions.clear();
|
||||
self.automatic_holding_days.clear();
|
||||
for symbol in symbols {
|
||||
let position = ctx.portfolio.position(&symbol).filter(|position| position.quantity > 0);
|
||||
let last_observed = |history: &BTreeMap<String, NaiveDate>| std::iter::once(symbol.as_str())
|
||||
.chain(ctx.portfolio.corporate_predecessors(&symbol))
|
||||
.filter_map(|symbol| history.get(symbol).copied()).max();
|
||||
let evidence = HoldingLifecycleEvidence {
|
||||
has_position: position.is_some(), opened_date: position.and_then(|position| position.opened_date()),
|
||||
last_buy_date: self.protection_last_buys.get(&symbol).copied().into_iter().chain(position.and_then(|position|position.last_buy_date())).max(),
|
||||
last_sell_date: self.protection_last_sells.get(&symbol).copied(),
|
||||
last_buy_date: last_observed(&self.protection_last_buys).into_iter()
|
||||
.chain(position.and_then(|position|position.last_buy_date())).max(),
|
||||
last_sell_date: last_observed(&self.protection_last_sells),
|
||||
};
|
||||
let permission = policy.evaluate(&symbol, ctx.execution_date, &evidence, ctx.data.calendar()).map_err(BacktestError::Execution)?;
|
||||
let permission = policy.evaluate_with_predecessors(&symbol, ctx.execution_date, &evidence,
|
||||
ctx.data.calendar(), ctx.portfolio.corporate_predecessors(&symbol)).map_err(BacktestError::Execution)?;
|
||||
if let Some(opened) = evidence.opened_date
|
||||
&& let (Some(start), Some(end)) = (ctx.data.calendar().index_of(opened), ctx.data.calendar().index_of(ctx.execution_date)) {
|
||||
self.automatic_holding_days.insert(symbol.clone(), end.saturating_sub(start) as i64);
|
||||
|
||||
@@ -709,6 +709,9 @@ pub struct PortfolioState {
|
||||
cash_receivables: Vec<CashReceivable>,
|
||||
pending_cash_flows: Vec<PendingCashFlow>,
|
||||
day_sold_symbols: BTreeSet<String>,
|
||||
// Observed holding conversions, never a catalog alias or a new target.
|
||||
// Kept after a position becomes flat so an active date lock is not lost.
|
||||
corporate_predecessors: BTreeMap<String, BTreeSet<String>>,
|
||||
stock_pool_states: std::collections::BTreeMap<String,crate::stock_pool_state::StockPoolExecutionState>,
|
||||
}
|
||||
|
||||
@@ -750,6 +753,7 @@ impl PortfolioState {
|
||||
cash_receivables: Vec::new(),
|
||||
pending_cash_flows: Vec::new(),
|
||||
day_sold_symbols: BTreeSet::new(),
|
||||
corporate_predecessors: BTreeMap::new(),
|
||||
stock_pool_states: std::collections::BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
@@ -762,6 +766,14 @@ impl PortfolioState {
|
||||
|
||||
pub(crate) fn initial_cash_fixed(&self) -> FixedMoney { self.initial_cash }
|
||||
|
||||
pub(crate) fn corporate_predecessors(&self, symbol: &str) -> impl Iterator<Item = &str> {
|
||||
self.corporate_predecessors.get(symbol).into_iter().flatten().map(String::as_str)
|
||||
}
|
||||
|
||||
pub(crate) fn observed_successor_symbols(&self) -> impl Iterator<Item = &str> {
|
||||
self.corporate_predecessors.keys().map(String::as_str)
|
||||
}
|
||||
|
||||
pub(crate) fn stock_pool_execution_state(&self,pool_id:&str)->crate::stock_pool_state::StockPoolExecutionState{
|
||||
self.stock_pool_states.get(pool_id).cloned().unwrap_or_default()
|
||||
}
|
||||
@@ -807,7 +819,11 @@ impl PortfolioState {
|
||||
let mut receivables = self.cash_receivables.iter().map(|row| (row.symbol.clone(), row.ex_date,
|
||||
row.payable_date, fixed_money_or_panic(row.amount, "receivable identity").to_decimal_string(), row.reason.clone())).collect::<Vec<_>>();
|
||||
receivables.sort();
|
||||
serde_json::json!({"cash":self.cash.to_decimal_string(),"positions":positions,"receivables":receivables})
|
||||
let mut identity = serde_json::json!({"cash":self.cash.to_decimal_string(),"positions":positions,"receivables":receivables});
|
||||
if !self.corporate_predecessors.is_empty() {
|
||||
identity["corporatePredecessors"] = serde_json::json!(self.corporate_predecessors);
|
||||
}
|
||||
identity
|
||||
}
|
||||
|
||||
pub(crate) fn financial_position_basis(&self, symbol: &str) -> FixedMoney {
|
||||
@@ -828,6 +844,9 @@ impl PortfolioState {
|
||||
self.positions = replayed.positions;
|
||||
self.cash_receivables = replayed.cash_receivables;
|
||||
self.day_sold_symbols = replayed.day_sold_symbols;
|
||||
// Corrected actual receipts can prove a position was fully sold
|
||||
// before conversion. Do not retain a now-disproved financial link.
|
||||
self.corporate_predecessors = replayed.corporate_predecessors;
|
||||
// Existing issued units, explicit cash-flow/financing facts, and task
|
||||
// target state are observed controls, not counterfactual new orders.
|
||||
Ok(())
|
||||
@@ -1557,7 +1576,7 @@ impl PortfolioState {
|
||||
}
|
||||
successor.refresh_day_pnl();
|
||||
|
||||
Some(SuccessorConversionOutcome {
|
||||
let outcome = SuccessorConversionOutcome {
|
||||
old_symbol: old_symbol_owned,
|
||||
new_symbol: new_symbol.to_string(),
|
||||
old_quantity,
|
||||
@@ -1569,7 +1588,14 @@ impl PortfolioState {
|
||||
} else {
|
||||
0.0
|
||||
},
|
||||
})
|
||||
};
|
||||
if converted_quantity > 0 {
|
||||
let mut predecessors = self.corporate_predecessors.get(old_symbol).cloned().unwrap_or_default();
|
||||
predecessors.insert(old_symbol.to_owned());
|
||||
predecessors.remove(new_symbol);
|
||||
self.corporate_predecessors.entry(new_symbol.to_owned()).or_default().extend(predecessors);
|
||||
}
|
||||
Some(outcome)
|
||||
}
|
||||
|
||||
fn sum_fixed_money(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
- 成交日及后续N个完整正式交易日均受保护。例如周五成交、N=3,保护到下周三结束,周四恢复;不按72小时或自然日替代。
|
||||
- 最长持有从连续持仓第一次实际买入开始,跨正式交易日计数;加仓、部分卖出和有证据的证券转换不重置,完全卖出再买入开启新周期。锁定和买入保护优先于最长持有退出。
|
||||
- 日期锁定禁止自动买卖,已接受的挂单不自动撤销;手工路径只绕过自动策略保护,不绕过账户授权、T+1、券商和风控。
|
||||
- 已校验且实际发生的持仓换股继承原保护期限与日期锁,不因改代码解锁或重计时;无实际转换的目录映射不继承。换股后手工清仓仍受原有效日期锁;确认换股前已清仓时不保留推定关系。原生策略、股票池与ETF顺延消费的修复及未发布边界见 `successor-protection-20260914.md`。
|
||||
- 保留的真实持仓继续占用资金与席位,不把未完成卖出当现金。最长持有退出先形成唯一最终目标,不能叠加一笔策略部分卖出和一笔框架全量卖出。
|
||||
- 在线上下文重建必须注入已经校验的真实成交/持仓快照,不能把重建日或旧行情日当建仓日。期货或股票期货混合账户未纳入本合同,显式拒绝。
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
# 换股后的策略保护继承
|
||||
|
||||
2026-09-14。开发候选;生产未发布,完整目标仍未完成。
|
||||
|
||||
## 反例与根因
|
||||
|
||||
原代码只给跨公司行为的迟到成交附带后继代码保护;及时回报之后正常换股则没有相同处理。日期锁也只比较当前代码,原持仓换股后会失去锁定。完整引擎反例已复现:及时卖出旧证券后的禁买期内,新证券又买入100股;旧证券仍处日期锁定内,新证券却卖出100股。
|
||||
|
||||
初始15:00样例虽产生了不该生成的委托,但因没有之后的报价而到期,不能把无成交视为保护通过。改用14:30调度和14:30/14:31报价后,实际回放成交证明了上述错误。ETF正向对照最初缺候选资格行,补齐隔离输入后正常成交,未放宽生产数据或风控校验。
|
||||
|
||||
## 统一语义
|
||||
|
||||
- 仅记录已真实影响持仓的、条款与证券身份已经校验的转换关系。目录别名、请求中的代码或尚未发生的公司行为不能使另一证券受锁定;不向候选池或策略目标添加证券。
|
||||
- 连续转换保留已证明的前身关系,保护读取原实际买卖日期;日期锁沿关系生效,但到期日仍为原配置,不从换股日重新计时。买后保护和最长持有继续使用原取得/买入日期。
|
||||
- 换股后手工清仓不删除仍有效的日期锁关系,防止自动重新买回绕过锁定。若迟到回报证明在换股前已经全部卖出,则原本推定的持仓转换关系应被校正掉,不将旧锁误加给新证券。
|
||||
- 关系进入经济账本的重放校验;无换股时原账本摘要形状不变。权益校正整体替换已验证关系,不并入已被新事实推翻的旧关系;原参数、目标权重、未提交目标及已发订单不擅自换成新代码。
|
||||
- 普通表达式策略、股票池普通调仓、ETF顺延开盘消费三个入口均使用同一保护内核。股票池与ETF共用经实际持仓关系解析的保护证据,不只修表面策略层。
|
||||
- 移除按公司行为引用列表直接扩展卖出代码的两个旁路,避免无实际持仓转换也被误认为曾卖出新证券。原股票的真实卖出记录和已发订单仍保留。
|
||||
|
||||
## 验证
|
||||
|
||||
6项新增专项覆盖23个隔离配置场景:及时/迟到回报、买卖两方向、禁买期、日期锁、原日期到期、转换后手工清仓、转换前已清仓,股票池0%/100%目标以及ETF顺延开盘。已知保护有效时验证零委托/零成交及200股原持仓;到期对照必须能够真实回放成交,不能靠缺报价或被其他风险拒绝冒充保护正确。
|
||||
|
||||
本机Core913、Trading625、Runner463/API129全量通过;原9/63/16项ignore不计。没有新增私有数据库、生产页面或券商实测;UI和其他业务仓代码未改。此次修复不改变配置为0/null/空的保护规则,也不是全局共享风控配置。
|
||||
|
||||
## 发布边界与后续
|
||||
|
||||
Source公司行为接口仍缺正式换股条款及所需数据范围闭包,在线重建也仍需要权威转换持仓事实,不能仅凭最终股数或证券名称猜关系。本轮原生回放与Linux测试不是GT/QMT公司行为实盘验收。Source d5冻结保持,不改旧合同、不注册替代合同或恢复交易。
|
||||
|
||||
继续正式Source/Runner联合验收、在线转换事实持久化/重建、旧目标及活动单边界、清空后再投参考和其余ETF跨模式矩阵。Linux使用新只读快照;没有发布tag、release构建或生产重启,完成后追加收据。
|
||||
Reference in New Issue
Block a user