diff --git a/crates/fidc-core/src/broker.rs b/crates/fidc-core/src/broker.rs index d4aed8f..53deff4 100644 --- a/crates/fidc-core/src/broker.rs +++ b/crates/fidc-core/src/broker.rs @@ -427,6 +427,8 @@ pub struct BrokerSimulator { verified_etf_minute_absences: RefCell>, runtime_etf_daily_open: Cell, deferred_etf_targets: RefCell, + deferred_stock_pools: RefCell>, + runtime_stock_pool_followup: Cell, cost_model: C, rules: R, board_lot_size: u32, @@ -469,6 +471,8 @@ impl BrokerSimulator { verified_etf_minute_absences: RefCell::new(BTreeSet::new()), runtime_etf_daily_open: Cell::new(false), deferred_etf_targets: RefCell::new(Default::default()), + deferred_stock_pools: RefCell::new(BTreeMap::new()), + runtime_stock_pool_followup: Cell::new(false), cost_model, rules, board_lot_size: 100, @@ -515,6 +519,8 @@ impl BrokerSimulator { verified_etf_minute_absences: RefCell::new(BTreeSet::new()), runtime_etf_daily_open: Cell::new(false), deferred_etf_targets: RefCell::new(Default::default()), + deferred_stock_pools: RefCell::new(BTreeMap::new()), + runtime_stock_pool_followup: Cell::new(false), cost_model, rules, board_lot_size: 100, @@ -778,7 +784,7 @@ impl BrokerSimulator { if self.runtime_etf_daily_open.get() { return PriceField::Open; } if self.is_post_close_fixed_price(date) { 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 } else { self.execution_price_field @@ -901,6 +907,12 @@ impl BrokerSimulator { !self.open_orders.borrow().is_empty() } + fn new_open_order_submission_time(&self) -> Option { + 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 { let post_close = self.execution_phase_for_submission(date, order.order_created_date, order.submission_time) == EquityExecutionPhase::PostCloseFixedPrice; @@ -1479,7 +1491,7 @@ where match algo_request.map(|request| request.style) { Some(AlgoExecutionStyle::Vwap) => MatchingType::Vwap, 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, } } @@ -1597,6 +1609,13 @@ where session: &mut BrokerExecutionSession, ) -> Result { 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( date, portfolio, @@ -1607,6 +1626,7 @@ where &mut session.commission_state, &mut report, )?; + self.resume_stock_pool_executions(date, portfolio, data, session, &mut report)?; if !decision.order_intents.is_empty() { let mut ordered_intents = decision.order_intents.iter().collect::>(); if self.effective_rebalance_cash_mode() != RebalanceCashMode::PreOpenCash @@ -4149,7 +4169,7 @@ where side: OrderSide, algo_request: Option<&AlgoExecutionRequest>, ) -> 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); } let matching_type = self.matching_type_for_algo_request(algo_request); @@ -4735,7 +4755,7 @@ where if Self::keeps_remainder_open(remainder_policy) { self.upsert_open_order(OpenOrder { 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), decision_date: Some(self.current_decision_date(date)), order_created_date: Some(self.current_order_created_date(date)), @@ -4826,7 +4846,7 @@ where .unwrap_or("no sellable quantity"); self.upsert_open_order(OpenOrder { 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), decision_date: Some(self.current_decision_date(date)), order_created_date: Some(self.current_order_created_date(date)), @@ -4999,7 +5019,7 @@ where { self.upsert_open_order(OpenOrder { 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), decision_date: Some(self.current_decision_date(date)), order_created_date: Some(self.current_order_created_date(date)), @@ -5171,7 +5191,7 @@ where if keep_open { self.upsert_open_order(OpenOrder { 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), decision_date: Some(self.current_decision_date(date)), order_created_date: Some(self.current_order_created_date(date)), @@ -6559,7 +6579,7 @@ where if Self::keeps_remainder_open(remainder_policy) { self.upsert_open_order(OpenOrder { 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), decision_date: Some(self.current_decision_date(date)), order_created_date: Some(self.current_order_created_date(date)), @@ -6799,7 +6819,7 @@ where { self.upsert_open_order(OpenOrder { 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), decision_date: Some(self.current_decision_date(date)), order_created_date: Some(self.current_order_created_date(date)), @@ -6973,7 +6993,7 @@ where if keep_open { self.upsert_open_order(OpenOrder { 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), decision_date: Some(self.current_decision_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 { 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; } matches!( self.matching_type, @@ -8252,6 +8273,8 @@ mod tests { use crate::rules::ChinaEquityRuleHooks; use crate::strategy::{AlgoOrderStyle, OrderIntent, OrderTimeInForce, StrategyDecision}; + include!("broker_stock_pool_batch_tests.rs"); + fn test_open_order(order_id: u64) -> OpenOrder { OpenOrder { order_id, diff --git a/crates/fidc-core/src/broker_stock_pool.rs b/crates/fidc-core/src/broker_stock_pool.rs index ef5c9cc..79db478 100644 --- a/crates/fidc-core/src/broker_stock_pool.rs +++ b/crates/fidc-core/src/broker_stock_pool.rs @@ -3,6 +3,35 @@ use super::*; use crate::holding_policy::HoldingLifecycleEvidence; use crate::stock_pool_execution as pool; use rust_decimal::{Decimal, prelude::ToPrimitive}; +use chrono::Timelike; + +#[derive(Debug)] +pub(super) struct DeferredStockPoolExecution { + date: NaiveDate, + contract: Box, + buy_only: bool, + symbols: BTreeSet, + initial_holdings: BTreeSet, +} + +impl BrokerSimulator { + pub(crate) fn pending_stock_pool_symbols(&self) -> BTreeSet { + 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 { if !value.is_finite() { @@ -41,6 +70,48 @@ fn pool_positions( } impl BrokerSimulator { + 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( &self, date: NaiveDate, @@ -102,7 +173,7 @@ impl BrokerSimulator { snapshot, quote, OrderSide::Buy, - self.matching_type, + self.matching_type_for_algo_request(None), ) .ok_or_else(|| { BacktestError::Execution(format!( @@ -114,7 +185,7 @@ impl BrokerSimulator { snapshot, quote, OrderSide::Sell, - self.matching_type, + self.matching_type_for_algo_request(None), ) .ok_or_else(|| { BacktestError::Execution(format!( @@ -226,6 +297,17 @@ impl BrokerSimulator { global_execution_cursor: &mut Option, commission_state: &mut BTreeMap, 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, + execution_cursors: &mut IntradayExecutionLedger, global_execution_cursor: &mut Option, + commission_state: &mut BTreeMap, report: &mut BrokerExecutionReport, buy_only: bool, + initial_holdings: Option<&BTreeSet>, ) -> Result<(), BacktestError> { if contract.signal_date > date || contract.frozen_equity < Decimal::ZERO @@ -266,6 +348,7 @@ impl BrokerSimulator { .cloned() .collect::>(); 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::>(); let initial_positions = pool_positions(portfolio, date)?; let state = portfolio @@ -284,6 +367,9 @@ impl BrokerSimulator { 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 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 .diagnostics .push("stock_pool_waiting_for_active_orders no_new_intent=true".into()); @@ -329,15 +415,19 @@ impl BrokerSimulator { .push("paused".into()); } } - let before_positions = portfolio - .positions() - .keys() - .cloned() - .collect::>(); // All delayed symbols in a generation share immutable configuration. // Do not duplicate an N-member pool N times in a large mixed pool. let mut deferred_configuration = None; 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(); for symbol in "e_scope { if let Some(reference) = self.pool_etf_fallback_reference(date, data, symbol, *global_execution_cursor)? { diff --git a/crates/fidc-core/src/broker_stock_pool_batch_tests.rs b/crates/fidc-core/src/broker_stock_pool_batch_tests.rs new file mode 100644 index 0000000..d86567d --- /dev/null +++ b/crates/fidc-core/src/broker_stock_pool_batch_tests.rs @@ -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 { + 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, + 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::>(); + 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 { + 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 { + 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 { + 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); +} diff --git a/crates/fidc-core/src/engine.rs b/crates/fidc-core/src/engine.rs index 05dac48..8f5cb2e 100644 --- a/crates/fidc-core/src/engine.rs +++ b/crates/fidc-core/src/engine.rs @@ -2862,12 +2862,13 @@ where )?; 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 mut full_minute_symbols = self.subscriptions.clone(); 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.pending_stock_pool_symbols()); } if self.execution_quote_loader.is_some() && !full_minute_symbols.is_empty() { let mut minute_symbols = full_minute_symbols.clone(); @@ -2954,6 +2955,7 @@ where && !has_minute_process_listeners && !schedule_candidate && !self.has_open_orders() + && !self.broker.has_pending_stock_pool_execution() { continue; } @@ -3135,6 +3137,7 @@ where // clock strictly after the event already processed. let mut newly_pending = self.broker.open_order_views().into_iter() .map(|order| order.symbol) + .chain(self.broker.pending_stock_pool_symbols()) .filter(|symbol| !full_minute_symbols.contains(symbol)) .collect::>(); 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.broker.finish_stock_pool_session(execution_date, &mut report); + portfolio.update_prices_with_options( execution_date, &self.data, diff --git a/docs/stock-pool-sell-buy-continuation-20260913.md b/docs/stock-pool-sell-buy-continuation-20260913.md new file mode 100644 index 0000000..683ebcb --- /dev/null +++ b/docs/stock-pool-sell-buy-continuation-20260913.md @@ -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,再配套发布。优先级仍可在真实资金或仓位约束不足时影响分配,不能预设所有不同排序的结果必须相同。