修复股票池卖单回报后未继续执行买入阶段

This commit is contained in:
boris
2026-09-13 23:43:26 +08:00
parent 53af3a6a85
commit c98bcc3eb2
5 changed files with 844 additions and 18 deletions
+33 -10
View File
@@ -427,6 +427,8 @@ pub struct BrokerSimulator<C, R> {
verified_etf_minute_absences: RefCell<BTreeSet<(NaiveDate, String)>>, verified_etf_minute_absences: RefCell<BTreeSet<(NaiveDate, String)>>,
runtime_etf_daily_open: Cell<bool>, runtime_etf_daily_open: Cell<bool>,
deferred_etf_targets: RefCell<crate::etf_execution::DeferredEtfTargets>, deferred_etf_targets: RefCell<crate::etf_execution::DeferredEtfTargets>,
deferred_stock_pools: RefCell<BTreeMap<String, stock_pool::DeferredStockPoolExecution>>,
runtime_stock_pool_followup: Cell<bool>,
cost_model: C, cost_model: C,
rules: R, rules: R,
board_lot_size: u32, board_lot_size: u32,
@@ -469,6 +471,8 @@ impl<C, R> BrokerSimulator<C, R> {
verified_etf_minute_absences: RefCell::new(BTreeSet::new()), verified_etf_minute_absences: RefCell::new(BTreeSet::new()),
runtime_etf_daily_open: Cell::new(false), runtime_etf_daily_open: Cell::new(false),
deferred_etf_targets: RefCell::new(Default::default()), deferred_etf_targets: RefCell::new(Default::default()),
deferred_stock_pools: RefCell::new(BTreeMap::new()),
runtime_stock_pool_followup: Cell::new(false),
cost_model, cost_model,
rules, rules,
board_lot_size: 100, board_lot_size: 100,
@@ -515,6 +519,8 @@ impl<C, R> BrokerSimulator<C, R> {
verified_etf_minute_absences: RefCell::new(BTreeSet::new()), verified_etf_minute_absences: RefCell::new(BTreeSet::new()),
runtime_etf_daily_open: Cell::new(false), runtime_etf_daily_open: Cell::new(false),
deferred_etf_targets: RefCell::new(Default::default()), deferred_etf_targets: RefCell::new(Default::default()),
deferred_stock_pools: RefCell::new(BTreeMap::new()),
runtime_stock_pool_followup: Cell::new(false),
cost_model, cost_model,
rules, rules,
board_lot_size: 100, board_lot_size: 100,
@@ -778,7 +784,7 @@ impl<C, R> BrokerSimulator<C, R> {
if self.runtime_etf_daily_open.get() { return PriceField::Open; } if self.runtime_etf_daily_open.get() { return PriceField::Open; }
if self.is_post_close_fixed_price(date) { if self.is_post_close_fixed_price(date) {
PriceField::Close PriceField::Close
} else if self.resting_daily_open_order() { } else if self.resting_daily_open_order() || (self.runtime_stock_pool_followup.get() && self.matching_type == MatchingType::NextBarOpen) {
PriceField::Last PriceField::Last
} else { } else {
self.execution_price_field self.execution_price_field
@@ -901,6 +907,12 @@ impl<C, R> BrokerSimulator<C, R> {
!self.open_orders.borrow().is_empty() !self.open_orders.borrow().is_empty()
} }
fn new_open_order_submission_time(&self) -> Option<NaiveTime> {
if self.matching_type == MatchingType::NextBarOpen && !self.runtime_stock_pool_followup.get() {
NaiveTime::from_hms_opt(9, 30, 0)
} else { self.order_origin().1 }
}
fn resting_order_session_close(&self, date: NaiveDate, order: &OpenOrder) -> NaiveTime { fn resting_order_session_close(&self, date: NaiveDate, order: &OpenOrder) -> NaiveTime {
let post_close = self.execution_phase_for_submission(date, order.order_created_date, order.submission_time) let post_close = self.execution_phase_for_submission(date, order.order_created_date, order.submission_time)
== EquityExecutionPhase::PostCloseFixedPrice; == EquityExecutionPhase::PostCloseFixedPrice;
@@ -1479,7 +1491,7 @@ where
match algo_request.map(|request| request.style) { match algo_request.map(|request| request.style) {
Some(AlgoExecutionStyle::Vwap) => MatchingType::Vwap, Some(AlgoExecutionStyle::Vwap) => MatchingType::Vwap,
Some(AlgoExecutionStyle::Twap) => MatchingType::Twap, Some(AlgoExecutionStyle::Twap) => MatchingType::Twap,
None if self.resting_daily_open_order() => MatchingType::CurrentBarClose, None if self.resting_daily_open_order() || (self.runtime_stock_pool_followup.get() && self.matching_type == MatchingType::NextBarOpen) => MatchingType::CurrentBarClose,
None => self.matching_type, None => self.matching_type,
} }
} }
@@ -1597,6 +1609,13 @@ where
session: &mut BrokerExecutionSession, session: &mut BrokerExecutionSession,
) -> Result<BrokerExecutionReport, BacktestError> { ) -> Result<BrokerExecutionReport, BacktestError> {
let mut report = BrokerExecutionReport::default(); let mut report = BrokerExecutionReport::default();
// A fresh strategy intent supersedes any unsubmitted remainder before
// old order reports can resume it. Already submitted orders are kept.
for intent in &decision.order_intents {
if let OrderIntent::StockPool { contract } = intent.unwrapped() {
self.deferred_stock_pools.borrow_mut().remove(&contract.pool_id);
}
}
self.process_open_orders( self.process_open_orders(
date, date,
portfolio, portfolio,
@@ -1607,6 +1626,7 @@ where
&mut session.commission_state, &mut session.commission_state,
&mut report, &mut report,
)?; )?;
self.resume_stock_pool_executions(date, portfolio, data, session, &mut report)?;
if !decision.order_intents.is_empty() { if !decision.order_intents.is_empty() {
let mut ordered_intents = decision.order_intents.iter().collect::<Vec<_>>(); let mut ordered_intents = decision.order_intents.iter().collect::<Vec<_>>();
if self.effective_rebalance_cash_mode() != RebalanceCashMode::PreOpenCash if self.effective_rebalance_cash_mode() != RebalanceCashMode::PreOpenCash
@@ -4149,7 +4169,7 @@ where
side: OrderSide, side: OrderSide,
algo_request: Option<&AlgoExecutionRequest>, algo_request: Option<&AlgoExecutionRequest>,
) -> f64 { ) -> f64 {
if self.matching_type == MatchingType::NextBarOpen && !self.resting_daily_open_order() && algo_request.is_none() { if self.matching_type == MatchingType::NextBarOpen && !self.resting_daily_open_order() && !self.runtime_stock_pool_followup.get() && algo_request.is_none() {
return self.execution_limit_check_price(snapshot, side); return self.execution_limit_check_price(snapshot, side);
} }
let matching_type = self.matching_type_for_algo_request(algo_request); let matching_type = self.matching_type_for_algo_request(algo_request);
@@ -4735,7 +4755,7 @@ where
if Self::keeps_remainder_open(remainder_policy) { if Self::keeps_remainder_open(remainder_policy) {
self.upsert_open_order(OpenOrder { self.upsert_open_order(OpenOrder {
order_id, order_id,
submission_time: if self.matching_type == MatchingType::NextBarOpen { NaiveTime::from_hms_opt(9,30,0) } else { self.order_origin().1 }, submission_time: self.new_open_order_submission_time(),
accepted_date: self.accepted_order_date(date), accepted_date: self.accepted_order_date(date),
decision_date: Some(self.current_decision_date(date)), decision_date: Some(self.current_decision_date(date)),
order_created_date: Some(self.current_order_created_date(date)), order_created_date: Some(self.current_order_created_date(date)),
@@ -4826,7 +4846,7 @@ where
.unwrap_or("no sellable quantity"); .unwrap_or("no sellable quantity");
self.upsert_open_order(OpenOrder { self.upsert_open_order(OpenOrder {
order_id, order_id,
submission_time: if self.matching_type == MatchingType::NextBarOpen { NaiveTime::from_hms_opt(9,30,0) } else { self.order_origin().1 }, submission_time: self.new_open_order_submission_time(),
accepted_date: self.accepted_order_date(date), accepted_date: self.accepted_order_date(date),
decision_date: Some(self.current_decision_date(date)), decision_date: Some(self.current_decision_date(date)),
order_created_date: Some(self.current_order_created_date(date)), order_created_date: Some(self.current_order_created_date(date)),
@@ -4999,7 +5019,7 @@ where
{ {
self.upsert_open_order(OpenOrder { self.upsert_open_order(OpenOrder {
order_id, order_id,
submission_time: if self.matching_type == MatchingType::NextBarOpen { NaiveTime::from_hms_opt(9,30,0) } else { self.order_origin().1 }, submission_time: self.new_open_order_submission_time(),
accepted_date: self.accepted_order_date(date), accepted_date: self.accepted_order_date(date),
decision_date: Some(self.current_decision_date(date)), decision_date: Some(self.current_decision_date(date)),
order_created_date: Some(self.current_order_created_date(date)), order_created_date: Some(self.current_order_created_date(date)),
@@ -5171,7 +5191,7 @@ where
if keep_open { if keep_open {
self.upsert_open_order(OpenOrder { self.upsert_open_order(OpenOrder {
order_id, order_id,
submission_time: if self.matching_type == MatchingType::NextBarOpen { NaiveTime::from_hms_opt(9,30,0) } else { self.order_origin().1 }, submission_time: self.new_open_order_submission_time(),
accepted_date: self.accepted_order_date(date), accepted_date: self.accepted_order_date(date),
decision_date: Some(self.current_decision_date(date)), decision_date: Some(self.current_decision_date(date)),
order_created_date: Some(self.current_order_created_date(date)), order_created_date: Some(self.current_order_created_date(date)),
@@ -6559,7 +6579,7 @@ where
if Self::keeps_remainder_open(remainder_policy) { if Self::keeps_remainder_open(remainder_policy) {
self.upsert_open_order(OpenOrder { self.upsert_open_order(OpenOrder {
order_id, order_id,
submission_time: if self.matching_type == MatchingType::NextBarOpen { NaiveTime::from_hms_opt(9,30,0) } else { self.order_origin().1 }, submission_time: self.new_open_order_submission_time(),
accepted_date: self.accepted_order_date(date), accepted_date: self.accepted_order_date(date),
decision_date: Some(self.current_decision_date(date)), decision_date: Some(self.current_decision_date(date)),
order_created_date: Some(self.current_order_created_date(date)), order_created_date: Some(self.current_order_created_date(date)),
@@ -6799,7 +6819,7 @@ where
{ {
self.upsert_open_order(OpenOrder { self.upsert_open_order(OpenOrder {
order_id, order_id,
submission_time: if self.matching_type == MatchingType::NextBarOpen { NaiveTime::from_hms_opt(9,30,0) } else { self.order_origin().1 }, submission_time: self.new_open_order_submission_time(),
accepted_date: self.accepted_order_date(date), accepted_date: self.accepted_order_date(date),
decision_date: Some(self.current_decision_date(date)), decision_date: Some(self.current_decision_date(date)),
order_created_date: Some(self.current_order_created_date(date)), order_created_date: Some(self.current_order_created_date(date)),
@@ -6973,7 +6993,7 @@ where
if keep_open { if keep_open {
self.upsert_open_order(OpenOrder { self.upsert_open_order(OpenOrder {
order_id, order_id,
submission_time: if self.matching_type == MatchingType::NextBarOpen { NaiveTime::from_hms_opt(9,30,0) } else { self.order_origin().1 }, submission_time: self.new_open_order_submission_time(),
accepted_date: self.accepted_order_date(date), accepted_date: self.accepted_order_date(date),
decision_date: Some(self.current_decision_date(date)), decision_date: Some(self.current_decision_date(date)),
order_created_date: Some(self.current_order_created_date(date)), order_created_date: Some(self.current_order_created_date(date)),
@@ -8110,6 +8130,7 @@ where
pub(crate) fn matching_type_uses_intraday_quotes(&self) -> bool { pub(crate) fn matching_type_uses_intraday_quotes(&self) -> bool {
if self.runtime_etf_daily_open.get() { return false; } if self.runtime_etf_daily_open.get() { return false; }
if self.runtime_stock_pool_followup.get() { return true; }
if self.resting_daily_open_order() { return true; } if self.resting_daily_open_order() { return true; }
matches!( matches!(
self.matching_type, self.matching_type,
@@ -8252,6 +8273,8 @@ mod tests {
use crate::rules::ChinaEquityRuleHooks; use crate::rules::ChinaEquityRuleHooks;
use crate::strategy::{AlgoOrderStyle, OrderIntent, OrderTimeInForce, StrategyDecision}; use crate::strategy::{AlgoOrderStyle, OrderIntent, OrderTimeInForce, StrategyDecision};
include!("broker_stock_pool_batch_tests.rs");
fn test_open_order(order_id: u64) -> OpenOrder { fn test_open_order(order_id: u64) -> OpenOrder {
OpenOrder { OpenOrder {
order_id, order_id,
+97 -7
View File
@@ -3,6 +3,35 @@ use super::*;
use crate::holding_policy::HoldingLifecycleEvidence; use crate::holding_policy::HoldingLifecycleEvidence;
use crate::stock_pool_execution as pool; use crate::stock_pool_execution as pool;
use rust_decimal::{Decimal, prelude::ToPrimitive}; use rust_decimal::{Decimal, prelude::ToPrimitive};
use chrono::Timelike;
#[derive(Debug)]
pub(super) struct DeferredStockPoolExecution {
date: NaiveDate,
contract: Box<pool::FrozenStockPoolIntent>,
buy_only: bool,
symbols: BTreeSet<String>,
initial_holdings: BTreeSet<String>,
}
impl<C, R> BrokerSimulator<C, R> {
pub(crate) fn pending_stock_pool_symbols(&self) -> BTreeSet<String> {
self.deferred_stock_pools.borrow().values().flat_map(|pending| pending.symbols.iter().cloned()).collect()
}
pub(crate) fn has_pending_stock_pool_execution(&self) -> bool {
!self.deferred_stock_pools.borrow().is_empty()
}
pub(crate) fn finish_stock_pool_session(&self, date: NaiveDate, report: &mut BrokerExecutionReport) {
self.deferred_stock_pools.borrow_mut().retain(|_, pending| {
if pending.date <= date {
report.diagnostics.push(format!("stock_pool_unsubmitted_phase_expired generation={} date={date} no_buy_order_created=true",pending.contract.generation));
false
} else { true }
});
}
}
fn decimal(value: f64, label: &str) -> Result<Decimal, BacktestError> { fn decimal(value: f64, label: &str) -> Result<Decimal, BacktestError> {
if !value.is_finite() { if !value.is_finite() {
@@ -41,6 +70,48 @@ fn pool_positions(
} }
impl<C: CostModel, R: EquityRuleHooks> BrokerSimulator<C, R> { impl<C: CostModel, R: EquityRuleHooks> BrokerSimulator<C, R> {
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);
let mut expired = Vec::new();
for (id, pending) in self.deferred_stock_pools.borrow().iter() {
let end = NaiveTime::parse_from_str(&pending.contract.rule.window_end, "%H:%M")
.map_err(|_| BacktestError::Execution("stock_pool_execution_window_invalid".into()))?;
if pending.date != date || clock.is_some_and(|clock| clock >= end) { expired.push(id.clone()); }
}
for id in expired {
if let Some(pending) = self.deferred_stock_pools.borrow_mut().remove(&id) {
report.diagnostics.push(format!("stock_pool_unsubmitted_phase_expired generation={} date={date} no_buy_order_created=true",pending.contract.generation));
}
}
if self.has_open_orders() || clock.is_none() { return Ok(()); }
let pending = std::mem::take(&mut *self.deferred_stock_pools.borrow_mut());
for (id, pending) in pending {
let now = clock.expect("clock checked above");
let start = NaiveTime::parse_from_str(&pending.contract.rule.window_start, "%H:%M")
.map_err(|_| BacktestError::Execution("stock_pool_execution_window_invalid".into()))?;
if now < start || !pool::stock_pool_is_trading_minute(now.hour() * 60 + now.minute()) {
self.deferred_stock_pools.borrow_mut().insert(id, pending);
continue;
}
let prior_followup = self.runtime_stock_pool_followup.replace(true);
let prior_decision = self.runtime_decision_date.replace(Some(pending.contract.signal_date));
let prior_created = self.runtime_order_created_date.replace(Some(date));
let order_start = report.order_events.len();
let fill_start = report.fill_events.len();
report.diagnostics.push(format!("stock_pool_resume_after_order_reports generation={} clock={} cash={}",pending.contract.generation,clock.unwrap(),portfolio.cash()));
let result = self.process_stock_pool_contract_phase(date, portfolio, data, &pending.contract,
&mut session.intraday_turnover, &mut session.execution_cursors, &mut session.global_execution_cursor,
&mut session.commission_state, report, pending.buy_only, Some(&pending.initial_holdings));
self.runtime_stock_pool_followup.set(prior_followup);
self.runtime_decision_date.set(prior_decision);
self.runtime_order_created_date.set(prior_created);
result?;
Self::annotate_report_range(report, order_start, fill_start, pending.contract.signal_date, date, date);
}
Ok(())
}
fn pool_quote_inputs( fn pool_quote_inputs(
&self, &self,
date: NaiveDate, date: NaiveDate,
@@ -102,7 +173,7 @@ impl<C: CostModel, R: EquityRuleHooks> BrokerSimulator<C, R> {
snapshot, snapshot,
quote, quote,
OrderSide::Buy, OrderSide::Buy,
self.matching_type, self.matching_type_for_algo_request(None),
) )
.ok_or_else(|| { .ok_or_else(|| {
BacktestError::Execution(format!( BacktestError::Execution(format!(
@@ -114,7 +185,7 @@ impl<C: CostModel, R: EquityRuleHooks> BrokerSimulator<C, R> {
snapshot, snapshot,
quote, quote,
OrderSide::Sell, OrderSide::Sell,
self.matching_type, self.matching_type_for_algo_request(None),
) )
.ok_or_else(|| { .ok_or_else(|| {
BacktestError::Execution(format!( BacktestError::Execution(format!(
@@ -226,6 +297,17 @@ impl<C: CostModel, R: EquityRuleHooks> BrokerSimulator<C, R> {
global_execution_cursor: &mut Option<NaiveDateTime>, global_execution_cursor: &mut Option<NaiveDateTime>,
commission_state: &mut BTreeMap<u64, f64>, commission_state: &mut BTreeMap<u64, f64>,
report: &mut BrokerExecutionReport, report: &mut BrokerExecutionReport,
) -> Result<(), BacktestError> {
self.process_stock_pool_contract_phase(date, portfolio, data, contract, intraday_turnover,
execution_cursors, global_execution_cursor, commission_state, report, false, None)
}
fn process_stock_pool_contract_phase(
&self, date: NaiveDate, portfolio: &mut PortfolioState, data: &DataSet,
contract: &pool::FrozenStockPoolIntent, intraday_turnover: &mut BTreeMap<String, u32>,
execution_cursors: &mut IntradayExecutionLedger, global_execution_cursor: &mut Option<NaiveDateTime>,
commission_state: &mut BTreeMap<u64, f64>, report: &mut BrokerExecutionReport, buy_only: bool,
initial_holdings: Option<&BTreeSet<String>>,
) -> Result<(), BacktestError> { ) -> Result<(), BacktestError> {
if contract.signal_date > date if contract.signal_date > date
|| contract.frozen_equity < Decimal::ZERO || contract.frozen_equity < Decimal::ZERO
@@ -266,6 +348,7 @@ impl<C: CostModel, R: EquityRuleHooks> BrokerSimulator<C, R> {
.cloned() .cloned()
.collect::<BTreeSet<_>>(); .collect::<BTreeSet<_>>();
scope.extend(portfolio.positions().keys().cloned()); scope.extend(portfolio.positions().keys().cloned());
let before_positions = initial_holdings.cloned().unwrap_or_else(|| portfolio.positions().keys().cloned().collect());
let official_dates = data.calendar().iter().collect::<Vec<_>>(); let official_dates = data.calendar().iter().collect::<Vec<_>>();
let initial_positions = pool_positions(portfolio, date)?; let initial_positions = pool_positions(portfolio, date)?;
let state = portfolio let state = portfolio
@@ -284,6 +367,9 @@ impl<C: CostModel, R: EquityRuleHooks> BrokerSimulator<C, R> {
let superseded = self.deferred_etf_targets.borrow_mut().replace_generation(&contract.pool_id, &contract.generation); let superseded = self.deferred_etf_targets.borrow_mut().replace_generation(&contract.pool_id, &contract.generation);
if superseded > 0 { report.diagnostics.push(format!("etf_daily_open_fallback:superseded pool={} generation={} targets={superseded}", contract.pool_id, contract.generation)); } if superseded > 0 { report.diagnostics.push(format!("etf_daily_open_fallback:superseded pool={} generation={} targets={superseded}", contract.pool_id, contract.generation)); }
if self.has_open_orders() { if self.has_open_orders() {
self.deferred_stock_pools.borrow_mut().insert(contract.pool_id.clone(), DeferredStockPoolExecution {
date, contract: Box::new(contract.clone()), buy_only, symbols: scope, initial_holdings: before_positions,
});
report report
.diagnostics .diagnostics
.push("stock_pool_waiting_for_active_orders no_new_intent=true".into()); .push("stock_pool_waiting_for_active_orders no_new_intent=true".into());
@@ -329,15 +415,19 @@ impl<C: CostModel, R: EquityRuleHooks> BrokerSimulator<C, R> {
.push("paused".into()); .push("paused".into());
} }
} }
let before_positions = portfolio
.positions()
.keys()
.cloned()
.collect::<BTreeSet<_>>();
// All delayed symbols in a generation share immutable configuration. // All delayed symbols in a generation share immutable configuration.
// Do not duplicate an N-member pool N times in a large mixed pool. // Do not duplicate an N-member pool N times in a large mixed pool.
let mut deferred_configuration = None; let mut deferred_configuration = None;
for side in [pool::OrderSide::Sell, pool::OrderSide::Buy] { for side in [pool::OrderSide::Sell, pool::OrderSide::Buy] {
if buy_only && side == pool::OrderSide::Sell { continue; }
if side == pool::OrderSide::Buy && self.has_open_orders()
&& self.effective_rebalance_cash_mode() == RebalanceCashMode::SellThenBuy {
self.deferred_stock_pools.borrow_mut().insert(contract.pool_id.clone(), DeferredStockPoolExecution {
date, contract: Box::new(contract.clone()), buy_only: true, symbols: quote_scope.clone(), initial_holdings: before_positions.clone(),
});
report.diagnostics.push(format!("stock_pool_waiting_for_sell_reports generation={} no_buy_order_created=true",contract.generation));
break;
}
let mut fallback_references = BTreeMap::new(); let mut fallback_references = BTreeMap::new();
for symbol in &quote_scope { for symbol in &quote_scope {
if let Some(reference) = self.pool_etf_fallback_reference(date, data, symbol, *global_execution_cursor)? { if let Some(reference) = self.pool_etf_fallback_reference(date, data, symbol, *global_execution_cursor)? {
@@ -0,0 +1,684 @@
fn pool_batch_data() -> DataSet {
pool_batch_data_with(|_| true)
}
fn pool_batch_data_with(change: impl Fn(&mut IntradayExecutionQuote) -> bool) -> DataSet {
let symbols = ["000001.SZ", "000002.SZ", "000003.SZ"];
let instruments = symbols
.iter()
.map(|symbol| Instrument {
symbol: (*symbol).into(),
..limit_test_instrument()
})
.collect();
let snapshots = symbols
.iter()
.map(|symbol| DailyMarketSnapshot {
symbol: (*symbol).into(),
..limit_test_snapshot()
})
.collect();
let candidates = symbols
.iter()
.map(|symbol| CandidateEligibility {
symbol: (*symbol).into(),
..limit_test_candidate(true, true)
})
.collect();
let mut quotes = Vec::new();
for minute in [30, 31, 32, 33, 34, 36] {
for symbol in symbols {
let price = if symbol == "000001.SZ" && minute > 30 {
10.5
} else {
10.0
};
let mut quote = limit_test_quote(price, price, price);
quote.symbol = symbol.into();
quote.timestamp = quote.date.and_hms_opt(9, minute, 0).unwrap();
quote.volume_delta = 200;
quote.bid1_volume = 200;
quote.ask1_volume = 200;
quote.amount_delta = price * 200.0;
if change(&mut quote) {
quotes.push(quote);
}
}
}
DataSet::from_components_with_actions_and_quotes(
instruments,
snapshots,
Vec::new(),
candidates,
vec![limit_test_benchmark()],
Vec::new(),
quotes,
)
.unwrap()
.with_additional_trading_dates([chrono::NaiveDate::from_ymd_opt(2024, 12, 31).unwrap()])
}
fn pool_batch_decision(symbol: &str, generation: &str, end: &str) -> StrategyDecision {
use crate::stock_pool_execution as pool;
let signal = chrono::NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
let symbols = vec![symbol.to_owned()];
let rule = pool::StockPoolExecutionRule {
pricing_mode: pool::POOL_PRICE_FIXED_LIMIT.into(),
fixed_prices: [
("000001.SZ".into(), rust_decimal::Decimal::new(104, 1)),
("000002.SZ".into(), 10.into()),
("000003.SZ".into(), 10.into()),
]
.into(),
window_end: end.into(),
..Default::default()
};
StrategyDecision {
order_intents: vec![OrderIntent::StockPool {
contract: Box::new(pool::FrozenStockPoolIntent {
pool_id: "batch-test".into(),
signal_date: signal,
frozen_equity: 2000.into(),
selection: pool::StockPoolSelection {
trade_date: signal,
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(generation.into()),
},
members: vec![pool::StockPoolMemberSpec {
symbol: symbol.into(),
recommendation_reason: String::new(),
requested_order: 0,
target_weight_bps: None,
stop_loss: None,
take_profit: None,
}],
rule,
constraints: pool::StockPoolDecisionConstraints {
target_holding_count: Some(1),
..Default::default()
},
invest_ratio_bps: 10000,
reserve_cash: 0.into(),
out_of_pool_policy: "reduce_to_zero_when_sellable".into(),
generation: generation.into(),
}),
}],
..Default::default()
}
}
fn pool_batch_broker(partial: bool) -> BrokerSimulator<ChinaAShareCostModel, ChinaEquityRuleHooks> {
let cost = ChinaAShareCostModel::from_trading_constraints(
crate::risk_control::TradingConstraintConfig {
commission_rate: 0.0,
minimum_commission: 0.0,
stamp_tax_rate_before_change: 0.0,
stamp_tax_rate_after_change: 0.0,
transfer_fee_rate: 0.0,
..Default::default()
},
);
let broker =
BrokerSimulator::new_with_execution_price(cost, ChinaEquityRuleHooks, PriceField::Open)
.with_matching_type(if partial {
MatchingType::MinuteLast
} else {
MatchingType::NextBarOpen
})
.with_volume_limit(partial)
.with_volume_percent(0.5)
.with_liquidity_limit(false)
.with_inactive_limit(false);
if partial {
broker
.with_intraday_execution_start_time(chrono::NaiveTime::from_hms_opt(9, 30, 0).unwrap())
} else {
broker
}
}
fn pool_batch_account() -> PortfolioState {
let mut account = PortfolioState::new(0.0);
account.position_mut("000001.SZ").buy(
chrono::NaiveDate::from_ymd_opt(2024, 12, 30).unwrap(),
200,
10.0,
);
account
}
fn pool_batch_tick(
broker: &BrokerSimulator<ChinaAShareCostModel, ChinaEquityRuleHooks>,
account: &mut PortfolioState,
data: &DataSet,
minute: u32,
decision: &StrategyDecision,
) -> BrokerExecutionReport {
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
broker
.runtime_intraday_start_time
.set(Some(chrono::NaiveTime::from_hms_opt(9, minute, 0).unwrap()));
broker
.runtime_intraday_end_time
.set(Some(chrono::NaiveTime::from_hms_opt(9, minute, 0).unwrap()));
broker.execute(date, account, data, decision).unwrap()
}
#[test]
fn stock_pool_pending_sell_continues_buy_after_actual_fill_without_strategy_rerun() {
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
let signal = chrono::NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
let data = pool_batch_data();
let broker = pool_batch_broker(false);
let mut account = pool_batch_account();
let initial = broker
.execute_with_event_dates(
date,
signal,
signal,
&mut account,
&data,
&pool_batch_decision("000002.SZ", "first", "09:35"),
)
.unwrap();
assert!(initial.fill_events.is_empty());
assert_eq!(broker.open_order_views().len(), 1);
assert_eq!(broker.open_order_views()[0].side, OrderSide::Sell);
let done = pool_batch_tick(
&broker,
&mut account,
&data,
31,
&StrategyDecision::default(),
);
assert!(
done.fill_events
.iter()
.any(|fill| fill.symbol == "000001.SZ" && fill.side == OrderSide::Sell)
);
assert_eq!(
account.position("000002.SZ").map(|p| p.quantity),
Some(200),
"sell proceeds must trigger the retained buy phase: {:?}",
done.diagnostics
);
assert!(
account
.position("000001.SZ")
.is_none_or(|p| p.quantity == 0)
);
let repeated = pool_batch_tick(
&broker,
&mut account,
&data,
32,
&StrategyDecision::default(),
);
assert!(repeated.order_events.is_empty() && repeated.fill_events.is_empty());
}
#[test]
fn stock_pool_partial_sell_waits_for_the_whole_batch_and_never_reissues_buys() {
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
let signal = chrono::NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
let data = pool_batch_data();
let broker = pool_batch_broker(true);
let mut account = pool_batch_account();
broker
.execute_with_event_dates(
date,
signal,
signal,
&mut account,
&data,
&pool_batch_decision("000002.SZ", "partial", "09:35"),
)
.unwrap();
let first = pool_batch_tick(
&broker,
&mut account,
&data,
31,
&StrategyDecision::default(),
);
assert_eq!(account.position("000001.SZ").unwrap().quantity, 100);
assert!(account.position("000002.SZ").is_none());
assert!(
first
.order_events
.iter()
.all(|event| event.side == OrderSide::Sell)
);
let second = pool_batch_tick(
&broker,
&mut account,
&data,
32,
&StrategyDecision::default(),
);
let third = pool_batch_tick(
&broker,
&mut account,
&data,
33,
&StrategyDecision::default(),
);
assert_eq!(account.position("000002.SZ").unwrap().quantity, 200);
let ids = second
.order_events
.iter()
.chain(&third.order_events)
.filter(|event| event.side == OrderSide::Buy)
.filter_map(|event| event.order_id)
.collect::<std::collections::BTreeSet<_>>();
assert_eq!(
ids.len(),
1,
"one buy intention; partial reports must keep its ID"
);
assert!(
pool_batch_tick(
&broker,
&mut account,
&data,
34,
&StrategyDecision::default()
)
.order_events
.is_empty()
);
}
#[test]
fn stock_pool_delayed_sell_does_not_start_buys_after_the_configured_window() {
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
let signal = chrono::NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
let data = pool_batch_data();
let broker = pool_batch_broker(true);
let mut account = pool_batch_account();
broker
.execute_with_event_dates(
date,
signal,
signal,
&mut account,
&data,
&pool_batch_decision("000002.SZ", "expired", "09:32"),
)
.unwrap();
pool_batch_tick(
&broker,
&mut account,
&data,
31,
&StrategyDecision::default(),
);
let last = pool_batch_tick(
&broker,
&mut account,
&data,
32,
&StrategyDecision::default(),
);
assert!(account.position("000002.SZ").is_none());
assert!(
last.order_events
.iter()
.all(|event| event.side == OrderSide::Sell)
);
assert!(
last.diagnostics
.iter()
.any(|event| event.contains("unsubmitted_phase_expired"))
);
assert!(!broker.has_pending_stock_pool_execution());
}
#[test]
fn stock_pool_new_signal_supersedes_the_unsubmitted_buy_phase() {
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
let signal = chrono::NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
let data = pool_batch_data();
let broker = pool_batch_broker(true);
let mut account = pool_batch_account();
broker
.execute_with_event_dates(
date,
signal,
signal,
&mut account,
&data,
&pool_batch_decision("000002.SZ", "old", "09:35"),
)
.unwrap();
pool_batch_tick(
&broker,
&mut account,
&data,
31,
&pool_batch_decision("000003.SZ", "new", "09:35"),
);
pool_batch_tick(
&broker,
&mut account,
&data,
32,
&StrategyDecision::default(),
);
pool_batch_tick(
&broker,
&mut account,
&data,
33,
&StrategyDecision::default(),
);
assert!(account.position("000002.SZ").is_none());
assert_eq!(account.position("000003.SZ").unwrap().quantity, 200);
assert!(!broker.has_pending_stock_pool_execution());
}
#[test]
fn stock_pool_after_sell_uses_fresh_quotes_and_actual_submission_clock() {
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
let signal = chrono::NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
let data = pool_batch_data_with(|quote| {
if quote.symbol == "000002.SZ" {
quote.last_price = 10.2;
quote.bid1 = 10.2;
quote.ask1 = 10.2;
quote.amount_delta = 2040.0;
}
true
});
let broker = pool_batch_broker(false);
let mut account = pool_batch_account();
let mut decision = pool_batch_decision("000002.SZ", "fresh", "09:35");
if let OrderIntent::StockPool { contract } = &mut decision.order_intents[0] {
contract.rule.pricing_mode = crate::stock_pool_execution::POOL_PRICE_FORMULA_LIMIT.into();
contract.rule.sell_offset_bps = 400;
}
broker
.execute_with_event_dates(date, signal, signal, &mut account, &data, &decision)
.unwrap();
let result = pool_batch_tick(
&broker,
&mut account,
&data,
31,
&StrategyDecision::default(),
);
assert_eq!(
account.position("000002.SZ").unwrap().quantity,
100,
"2000/10.2 rounds to one 100-share lot, not 200 at stale open"
);
let fill = result
.fill_events
.iter()
.find(|fill| fill.symbol == "000002.SZ")
.unwrap();
assert_eq!(fill.price, 10.2);
assert_eq!(
fill.execution_start_timestamp,
Some(date.and_hms_opt(9, 31, 0).unwrap())
);
let event = result
.order_events
.iter()
.find(|event| event.side == OrderSide::Buy)
.unwrap();
assert_eq!(event.decision_date, Some(signal));
assert_eq!(event.order_created_date, Some(date));
}
#[test]
fn stock_pool_after_sell_rejects_missing_quote_instead_of_reusing_daily_open() {
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
let signal = chrono::NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
let data = pool_batch_data_with(|quote| quote.symbol != "000002.SZ");
let broker = pool_batch_broker(false);
let mut account = pool_batch_account();
broker
.execute_with_event_dates(
date,
signal,
signal,
&mut account,
&data,
&pool_batch_decision("000002.SZ", "missing", "09:35"),
)
.unwrap();
broker
.runtime_intraday_start_time
.set(Some(chrono::NaiveTime::from_hms_opt(9, 31, 0).unwrap()));
broker
.runtime_intraday_end_time
.set(Some(chrono::NaiveTime::from_hms_opt(9, 31, 0).unwrap()));
let error = broker
.execute(date, &mut account, &data, &StrategyDecision::default())
.unwrap_err();
assert!(
error
.to_string()
.contains("stock_pool_execution_quote_missing:000002.SZ"),
"{error}"
);
assert!(account.position("000002.SZ").is_none());
}
#[test]
fn stock_pool_delayed_take_profit_does_not_rebuy_the_same_generation_exit() {
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
let signal = chrono::NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
let data = pool_batch_data();
let broker = pool_batch_broker(false);
let mut account = PortfolioState::new(0.0);
account.position_mut("000001.SZ").buy(
chrono::NaiveDate::from_ymd_opt(2024, 12, 30).unwrap(),
200,
9.0,
);
let mut decision = pool_batch_decision("000002.SZ", "take-profit", "09:35");
if let OrderIntent::StockPool { contract } = &mut decision.order_intents[0] {
let symbols = vec!["000001.SZ".to_owned(), "000002.SZ".to_owned()];
contract.selection.requested_symbols = symbols.clone();
contract.selection.normal_trading_symbols = symbols.clone();
contract.selection.risk_eligible_symbols = symbols.clone();
contract.selection.final_symbols = symbols;
contract.constraints.target_holding_count = Some(2);
contract.members.insert(
0,
crate::stock_pool_execution::StockPoolMemberSpec {
symbol: "000001.SZ".into(),
recommendation_reason: String::new(),
requested_order: 0,
target_weight_bps: None,
stop_loss: None,
take_profit: Some(rust_decimal::Decimal::new(5, 2)),
},
);
contract.members[1].requested_order = 1;
}
broker
.execute_with_event_dates(date, signal, signal, &mut account, &data, &decision)
.unwrap();
let result = pool_batch_tick(
&broker,
&mut account,
&data,
31,
&StrategyDecision::default(),
);
assert!(
account
.position("000001.SZ")
.is_none_or(|p| p.quantity == 0)
);
assert_eq!(account.position("000002.SZ").unwrap().quantity, 200);
assert!(
!result
.order_events
.iter()
.any(|event| event.symbol == "000001.SZ" && event.side == OrderSide::Buy)
);
}
#[test]
fn stock_pool_pending_phase_cannot_cross_the_execution_session() {
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
let signal = chrono::NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
let data = pool_batch_data();
let broker = pool_batch_broker(false);
let mut account = pool_batch_account();
let mut report = broker
.execute_with_event_dates(
date,
signal,
signal,
&mut account,
&data,
&pool_batch_decision("000002.SZ", "end", "09:35"),
)
.unwrap();
assert!(broker.has_pending_stock_pool_execution());
broker.finish_stock_pool_session(date, &mut report);
assert!(!broker.has_pending_stock_pool_execution());
assert!(
report
.diagnostics
.iter()
.any(|event| event.contains("unsubmitted_phase_expired"))
);
assert_eq!(
broker.open_order_views().len(),
1,
"session cleanup preserves broker order history and remainder"
);
}
#[test]
fn stock_pool_engine_drives_the_pending_buy_without_a_minute_strategy_callback() {
use crate::{BacktestConfig, BacktestEngine, BacktestError, Strategy, StrategyContext};
struct DailyPool;
impl Strategy for DailyPool {
fn name(&self) -> &str {
"daily-pool-batch"
}
fn requires_minute_callbacks(&self) -> bool {
false
}
fn schedule_rules(&self) -> Vec<crate::ScheduleRule> {
vec![
crate::ScheduleRule::daily("open", crate::ScheduleStage::OnDay)
.with_time_rule(crate::ScheduleTimeRule::physical_time(9, 30)),
]
}
fn on_scheduled(
&mut self,
ctx: &StrategyContext<'_>,
_: &crate::ScheduleRule,
) -> Result<StrategyDecision, BacktestError> {
if ctx.execution_date.day() == 2 {
Ok(StrategyDecision {
order_intents: vec![OrderIntent::LimitTargetShares {
symbol: "000001.SZ".into(),
target_quantity: 200,
limit_price: 10.0,
reason: "initial-entry".into(),
}],
..Default::default()
})
} else {
Ok(pool_batch_decision("000002.SZ", "rotation", "09:35"))
}
}
fn on_minute(
&mut self,
_: &StrategyContext<'_>,
_: &IntradayExecutionQuote,
) -> Result<StrategyDecision, BacktestError> {
panic!("this daily strategy must not be rerun to continue a pending batch")
}
}
use chrono::Datelike;
let first = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
let last = chrono::NaiveDate::from_ymd_opt(2025, 1, 3).unwrap();
let base = pool_batch_data();
let mut market = Vec::new();
let mut factors = Vec::new();
let mut candidates = Vec::new();
let mut benchmarks = Vec::new();
let mut quotes = Vec::new();
for date in [first, last] {
for symbol in ["000001.SZ", "000002.SZ", "000003.SZ"] {
let mut row = base.market(first, symbol).unwrap().clone();
row.date = date;
market.push(row);
let mut row = base.candidate(first, symbol).unwrap().clone();
row.date = date;
candidates.push(row);
factors.push(crate::data::DailyFactorSnapshot {
date,
symbol: symbol.into(),
market_cap_bn: 10.,
free_float_cap_bn: 10.,
pe_ttm: 10.,
turnover_ratio: None,
effective_turnover_ratio: None,
adjustment_factor_backward1: Some(1.),
extra_factors: Default::default(),
});
for original in base.execution_quotes_on(first, symbol) {
let mut quote = original.clone();
quote.date = date;
quote.timestamp = date.and_time(original.timestamp.time());
quotes.push(quote);
}
}
let mut row = limit_test_benchmark();
row.date = date;
benchmarks.push(row);
}
let data = DataSet::from_components_with_actions_and_quotes(
base.instruments().values().cloned().collect(),
market,
factors,
candidates,
benchmarks,
Vec::new(),
quotes,
)
.unwrap()
.with_additional_trading_dates([chrono::NaiveDate::from_ymd_opt(2024, 12, 31).unwrap()]);
let config = BacktestConfig {
initial_cash: 2000.0,
benchmark_code: "000852.SH".into(),
start_date: Some(first),
end_date: Some(last),
decision_lag_trading_days: 0,
execution_price_field: PriceField::Open,
};
let result = BacktestEngine::new(data, DailyPool, pool_batch_broker(false), config)
.run()
.unwrap();
assert_eq!(
result.fills.len(),
3,
"initial buy, delayed sell, resumed buy: orders={:?} equity={:?}",
result.order_events,
result.equity_curve
);
assert_eq!(result.fills[2].symbol, "000002.SZ");
assert_eq!(result.fills[2].quantity, 200);
assert_eq!(
result.fills[2].execution_timestamp,
Some(last.and_hms_opt(9, 31, 0).unwrap())
);
assert_eq!(result.holdings_summary.len(), 1);
}
+6 -1
View File
@@ -2862,12 +2862,13 @@ where
)?; )?;
if should_run_minute_events(&intraday_schedule_rules, &self.subscriptions) if should_run_minute_events(&intraday_schedule_rules, &self.subscriptions)
|| (self.broker.has_open_orders() && self.broker.drives_resting_quote_clock()) || ((self.broker.has_open_orders() || self.broker.has_pending_stock_pool_execution()) && self.broker.drives_resting_quote_clock())
{ {
let unfiltered_minute_stream = self.subscriptions.is_empty(); let unfiltered_minute_stream = self.subscriptions.is_empty();
let mut full_minute_symbols = self.subscriptions.clone(); let mut full_minute_symbols = self.subscriptions.clone();
if self.broker.drives_resting_quote_clock() { if self.broker.drives_resting_quote_clock() {
full_minute_symbols.extend(self.broker.open_order_views().into_iter().map(|order| order.symbol)); full_minute_symbols.extend(self.broker.open_order_views().into_iter().map(|order| order.symbol));
full_minute_symbols.extend(self.broker.pending_stock_pool_symbols());
} }
if self.execution_quote_loader.is_some() && !full_minute_symbols.is_empty() { if self.execution_quote_loader.is_some() && !full_minute_symbols.is_empty() {
let mut minute_symbols = full_minute_symbols.clone(); let mut minute_symbols = full_minute_symbols.clone();
@@ -2954,6 +2955,7 @@ where
&& !has_minute_process_listeners && !has_minute_process_listeners
&& !schedule_candidate && !schedule_candidate
&& !self.has_open_orders() && !self.has_open_orders()
&& !self.broker.has_pending_stock_pool_execution()
{ {
continue; continue;
} }
@@ -3135,6 +3137,7 @@ where
// clock strictly after the event already processed. // clock strictly after the event already processed.
let mut newly_pending = self.broker.open_order_views().into_iter() let mut newly_pending = self.broker.open_order_views().into_iter()
.map(|order| order.symbol) .map(|order| order.symbol)
.chain(self.broker.pending_stock_pool_symbols())
.filter(|symbol| !full_minute_symbols.contains(symbol)) .filter(|symbol| !full_minute_symbols.contains(symbol))
.collect::<BTreeSet<_>>(); .collect::<BTreeSet<_>>();
if !newly_pending.is_empty() && self.broker.drives_resting_quote_clock() { if !newly_pending.is_empty() && self.broker.drives_resting_quote_clock() {
@@ -3158,6 +3161,8 @@ where
self.data.release_execution_quotes_on_date(execution_date); self.data.release_execution_quotes_on_date(execution_date);
} }
self.broker.finish_stock_pool_session(execution_date, &mut report);
portfolio.update_prices_with_options( portfolio.update_prices_with_options(
execution_date, execution_date,
&self.data, &self.data,
@@ -0,0 +1,24 @@
# 股票池卖出批次与买入续执行
2026-09-13,开发候选,尚未部署。不是完整股票池验收结论。
## 原问题
真实混合四证券的手选优先/自动优先回测在09-11出现600276.SH与300811.SZ买量差异。冻结信号权益均9,733,801.863803、90%预算8,760,421.67742270,前一日持仓/现金也相同。原进程日志证明卖出000333.SZ 500股仍为Pending时,买单已经根据未释放的总仓位预算被创建或取消;其后卖单实际成交,执行器不再继续尚未提交的买入阶段。不能仅因为账户还有现金就忽略仓位预算,也不能通过重新跑策略/重复补单掩盖。
确定性回归在旧实现中稳定复现:200股卖出限价未成交,实际成交回报处理后新标的仍没有持仓;无需网络或外部数据。现增加每池单一未提交执行阶段,sell_then_buy在卖单活动期间不创建买单,报告终结后沿同一冻结信号/权益/配置,根据当时真实现金、持仓和报价只执行买入腿。策略不再次调用,已经提交的委托不替换、不去重补救。
## 边界
- 分批成交等待整批活动委托终结;余量保持原order_id。买入以真实成交后资金与仓位预算重新定量,不借预计卖出款。
- 每池新意图先替换尚未提交阶段,已提交订单仍保留;同一次止盈/止损清仓的证券保留禁买事实,不能在等待后重新当作未建仓候选买回。
- 买单真实提交日/时刻与原信号日分开。next-open卖单延迟后,新买单使用执行时点真实分钟报价,不回到09:30或用日线开盘价代替缺失报价。原始挂单起点不变。
- 原窗口结束为排他边界,休市不创建买单;过期只终止未提交阶段,原券商模拟订单按原DAY/GTC时钟自然处理。交易日结束清除未提交阶段并记录原因,不跨日重用。
- 引擎即使没有策略分钟订阅,也为活动批次维护真实报价时钟,并加载待买标的;不新增策略回调。
- 未修改Source、行情/生命周期门禁、风控、原用户配置或历史结果。PreOpenCash/SamePointNet不因本补丁被强改成SellThenBuy。
## 当前测试
9项新增专项覆盖未成交卖出续买、部分成交/买单ID、窗口结束、新信号覆盖、发送前新价/日期、缺价拒绝、止盈清仓禁回买、跨日清理和不订阅分钟的完整引擎执行。全工作区803项通过、9项外部/专项忽略单列;配套Trading613通过,Runner本机432通过、9项忽略。完整引擎测试夹具需显式提供每日因子与候选,缺少两者会得到无执行日期,不能据空运行当作成功。
下一步以已推送精确源码构建177隔离Runner,用原两个混合请求、原24只配置和冻结数据包核对逐日目标/委托/成交/持仓及Canonical,再配套发布。优先级仍可在真实资金或仓位约束不足时影响分配,不能预设所有不同排序的结果必须相同。