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

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
-9
View File
@@ -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);
}
}
}
}
+86 -23
View File
@@ -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 };
+16 -1
View File
@@ -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);
+29 -3
View File
@@ -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(