让实际换股持仓继承原策略保护期限
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(
|
||||
|
||||
Reference in New Issue
Block a user