Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c98bcc3eb2 | |||
| 53af3a6a85 | |||
| 70c6f7e90b | |||
| 0ed6752a73 | |||
| 3e8cc63b9a | |||
| be171683c9 | |||
| 0a6fab9038 | |||
| e8abf43cd4 | |||
| 2286bfa757 | |||
| 93809eea1b | |||
| f7f0ff2951 | |||
| effa0c6456 | |||
| b1ca2dfada | |||
| d15abc18ae |
@@ -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,
|
||||||
|
|||||||
@@ -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 "e_scope {
|
for symbol in "e_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);
|
||||||
|
}
|
||||||
@@ -2250,6 +2250,17 @@ impl DataSet {
|
|||||||
.unwrap_or(&[])
|
.unwrap_or(&[])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Counts market, factor, candidate, benchmark and corporate-action rows without cloning them.
|
||||||
|
pub fn snapshot_row_counts(&self) -> (usize, usize, usize, usize, usize) {
|
||||||
|
(
|
||||||
|
self.market_by_date.values().map(Vec::len).sum(),
|
||||||
|
self.factor_by_date.values().map(Vec::len).sum(),
|
||||||
|
self.candidate_by_date.values().map(Vec::len).sum(),
|
||||||
|
self.benchmark_by_date.len(),
|
||||||
|
self.corporate_actions_by_date.values().map(Vec::len).sum(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn execution_quotes_on(&self, date: NaiveDate, symbol: &str) -> &[IntradayExecutionQuote] {
|
pub fn execution_quotes_on(&self, date: NaiveDate, symbol: &str) -> &[IntradayExecutionQuote] {
|
||||||
self.execution_quotes_by_date
|
self.execution_quotes_by_date
|
||||||
.get(&date)
|
.get(&date)
|
||||||
@@ -5474,6 +5485,10 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.expect("daily bundle dataset");
|
.expect("daily bundle dataset");
|
||||||
|
|
||||||
|
let row_count = dates.len() * symbols.len();
|
||||||
|
let expected_counts = (row_count, row_count, row_count, dates.len(), dates.len());
|
||||||
|
assert_eq!(flat.snapshot_row_counts(), expected_counts);
|
||||||
|
assert_eq!(grouped.snapshot_row_counts(), expected_counts);
|
||||||
assert_eq!(flat.calendar().days(), grouped.calendar().days());
|
assert_eq!(flat.calendar().days(), grouped.calendar().days());
|
||||||
assert_eq!(flat.benchmark_code(), grouped.benchmark_code());
|
assert_eq!(flat.benchmark_code(), grouped.benchmark_code());
|
||||||
for date in dates {
|
for date in dates {
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
use std::collections::{BTreeMap, BTreeSet};
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use chrono::{Datelike, Duration, NaiveDate, NaiveTime};
|
use chrono::{Datelike, Duration, NaiveDate, NaiveTime};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@@ -473,7 +472,6 @@ pub struct BacktestEngine<S, C, R> {
|
|||||||
futures_cost_model: FuturesTransactionCostModel,
|
futures_cost_model: FuturesTransactionCostModel,
|
||||||
futures_validation_config: FuturesValidationConfig,
|
futures_validation_config: FuturesValidationConfig,
|
||||||
execution_quote_loader: Option<ExecutionQuoteLoader>,
|
execution_quote_loader: Option<ExecutionQuoteLoader>,
|
||||||
preplanned_decision_quote_symbols_by_date: Option<Arc<BTreeMap<NaiveDate, BTreeSet<String>>>>,
|
|
||||||
execution_quote_request_cache:
|
execution_quote_request_cache:
|
||||||
BTreeSet<(NaiveDate, String, Option<NaiveTime>, Option<NaiveTime>)>,
|
BTreeSet<(NaiveDate, String, Option<NaiveTime>, Option<NaiveTime>)>,
|
||||||
execution_absence_notes: BTreeMap<NaiveDate, Vec<String>>,
|
execution_absence_notes: BTreeMap<NaiveDate, Vec<String>>,
|
||||||
@@ -574,7 +572,6 @@ impl<S, C, R> BacktestEngine<S, C, R> {
|
|||||||
futures_cost_model: FuturesTransactionCostModel::default(),
|
futures_cost_model: FuturesTransactionCostModel::default(),
|
||||||
futures_validation_config: FuturesValidationConfig::default(),
|
futures_validation_config: FuturesValidationConfig::default(),
|
||||||
execution_quote_loader: None,
|
execution_quote_loader: None,
|
||||||
preplanned_decision_quote_symbols_by_date: None,
|
|
||||||
execution_quote_request_cache: BTreeSet::new(),
|
execution_quote_request_cache: BTreeSet::new(),
|
||||||
execution_absence_notes: BTreeMap::new(),
|
execution_absence_notes: BTreeMap::new(),
|
||||||
execution_lifecycle_reported: BTreeSet::new(),
|
execution_lifecycle_reported: BTreeSet::new(),
|
||||||
@@ -601,14 +598,6 @@ impl<S, C, R> BacktestEngine<S, C, R> {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn with_preplanned_decision_quote_symbols_by_date(
|
|
||||||
mut self,
|
|
||||||
symbols_by_date: Arc<BTreeMap<NaiveDate, BTreeSet<String>>>,
|
|
||||||
) -> Self {
|
|
||||||
self.preplanned_decision_quote_symbols_by_date = Some(symbols_by_date);
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn with_dividend_reinvestment(mut self, enabled: bool) -> Self {
|
pub fn with_dividend_reinvestment(mut self, enabled: bool) -> Self {
|
||||||
self.dividend_reinvestment = enabled;
|
self.dividend_reinvestment = enabled;
|
||||||
self
|
self
|
||||||
@@ -2620,20 +2609,6 @@ where
|
|||||||
let on_day_open_orders = self.open_order_views();
|
let on_day_open_orders = self.open_order_views();
|
||||||
let decision_quote_times = self.strategy.decision_quote_times();
|
let decision_quote_times = self.strategy.decision_quote_times();
|
||||||
if self.execution_quote_loader.is_some() && !decision_quote_times.is_empty() {
|
if self.execution_quote_loader.is_some() && !decision_quote_times.is_empty() {
|
||||||
if let Some(preplanned) = self
|
|
||||||
.preplanned_decision_quote_symbols_by_date
|
|
||||||
.as_ref()
|
|
||||||
.map(Arc::clone)
|
|
||||||
{
|
|
||||||
let empty_symbols = BTreeSet::new();
|
|
||||||
let decision_quote_symbols =
|
|
||||||
preplanned.get(&execution_date).unwrap_or(&empty_symbols);
|
|
||||||
self.ensure_execution_quotes_for_symbols_at_times(
|
|
||||||
execution_date,
|
|
||||||
decision_quote_symbols,
|
|
||||||
&decision_quote_times,
|
|
||||||
)?;
|
|
||||||
} else {
|
|
||||||
let decision_quote_symbols =
|
let decision_quote_symbols =
|
||||||
self.strategy.decision_quote_symbols(&StrategyContext {
|
self.strategy.decision_quote_symbols(&StrategyContext {
|
||||||
execution_date,
|
execution_date,
|
||||||
@@ -2660,7 +2635,6 @@ where
|
|||||||
&decision_quote_times,
|
&decision_quote_times,
|
||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
self.ensure_execution_quotes_for_portfolio_times(
|
self.ensure_execution_quotes_for_portfolio_times(
|
||||||
execution_date,
|
execution_date,
|
||||||
&portfolio,
|
&portfolio,
|
||||||
@@ -2888,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();
|
||||||
@@ -2980,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;
|
||||||
}
|
}
|
||||||
@@ -3161,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() {
|
||||||
@@ -3184,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,
|
||||||
|
|||||||
@@ -38,6 +38,11 @@ impl NumericFactorMap {
|
|||||||
self.entries.clear();
|
self.entries.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Reserve known new fields without geometric spare capacity per snapshot.
|
||||||
|
pub fn reserve_exact(&mut self, additional: usize) {
|
||||||
|
self.entries.reserve_exact(additional);
|
||||||
|
}
|
||||||
|
|
||||||
pub fn get(&self, key: &str) -> Option<&f64> {
|
pub fn get(&self, key: &str) -> Option<&f64> {
|
||||||
self.entries
|
self.entries
|
||||||
.binary_search_by(|(name, _)| name.as_str().cmp(key))
|
.binary_search_by(|(name, _)| name.as_str().cmp(key))
|
||||||
@@ -253,6 +258,28 @@ impl<'de> Deserialize<'de> for NumericFactorMap {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn exact_reservation_preserves_values_and_avoids_growth_during_known_inserts() {
|
||||||
|
let mut map = NumericFactorMap::from([
|
||||||
|
(Cow::Borrowed("amount"), 125.25),
|
||||||
|
(Cow::Borrowed("nullable_value"), f64::from_bits(0x7ff8000000000042)),
|
||||||
|
(Cow::Borrowed("signal"), -0.0),
|
||||||
|
]);
|
||||||
|
let original = map.iter().map(|(key, value)| (key.to_string(), value.to_bits())).collect::<Vec<_>>();
|
||||||
|
map.reserve_exact(2);
|
||||||
|
assert_eq!(map.iter().map(|(key, value)| (key.to_string(), value.to_bits())).collect::<Vec<_>>(), original);
|
||||||
|
let buffer = map.entries.as_ptr();
|
||||||
|
map.insert(Cow::Borrowed("pre_close"), 12.5);
|
||||||
|
map.insert(Cow::Borrowed("no_limit"), 0.0);
|
||||||
|
assert_eq!(map.entries.as_ptr(), buffer);
|
||||||
|
assert_eq!(map.len(), 5);
|
||||||
|
assert_eq!(map["signal"].to_bits(), (-0.0_f64).to_bits());
|
||||||
|
assert_eq!(map["nullable_value"].to_bits(), 0x7ff8000000000042);
|
||||||
|
let before = map.entries.as_ptr();
|
||||||
|
map.reserve_exact(0);
|
||||||
|
assert_eq!(map.entries.as_ptr(), before);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn compact_keys_inline_dynamic_names_and_keep_long_static_storage() {
|
fn compact_keys_inline_dynamic_names_and_keep_long_static_storage() {
|
||||||
const LONG: &str = "a_long_static_factor_identifier_that_must_remain_borrowed";
|
const LONG: &str = "a_long_static_factor_identifier_that_must_remain_borrowed";
|
||||||
|
|||||||
@@ -961,6 +961,16 @@ struct DayExpressionState {
|
|||||||
available_text_factor_names: BTreeSet<String>,
|
available_text_factor_names: BTreeSet<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn collect_available_factor_names<'a>(names: impl Iterator<Item = &'a str>) -> BTreeSet<String> {
|
||||||
|
// BTreeSet::from_iter first sorts a Vec containing every repeated name.
|
||||||
|
// The daily universe has many rows but usually few distinct factor fields.
|
||||||
|
let mut unique = BTreeSet::new();
|
||||||
|
for name in names {
|
||||||
|
unique.insert(name);
|
||||||
|
}
|
||||||
|
unique.into_iter().map(str::to_owned).collect()
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
struct StockExpressionState {
|
struct StockExpressionState {
|
||||||
symbol: Arc<str>,
|
symbol: Arc<str>,
|
||||||
@@ -4388,7 +4398,7 @@ impl PlatformExprStrategy {
|
|||||||
is_month_start: date.day() == 1,
|
is_month_start: date.day() == 1,
|
||||||
is_month_end,
|
is_month_end,
|
||||||
available_factor_names: if self.stock_extra_factors_required {
|
available_factor_names: if self.stock_extra_factors_required {
|
||||||
ctx.data
|
collect_available_factor_names(ctx.data
|
||||||
.factor_snapshot_rows_on(date)
|
.factor_snapshot_rows_on(date)
|
||||||
.iter()
|
.iter()
|
||||||
.flat_map(|row| {
|
.flat_map(|row| {
|
||||||
@@ -4396,23 +4406,15 @@ impl PlatformExprStrategy {
|
|||||||
row.adjustment_factor_backward1
|
row.adjustment_factor_backward1
|
||||||
.map(|_| BACKWARD_ADJUSTMENT_FACTOR_FIELD),
|
.map(|_| BACKWARD_ADJUSTMENT_FACTOR_FIELD),
|
||||||
)
|
)
|
||||||
})
|
}))
|
||||||
.collect::<BTreeSet<_>>()
|
|
||||||
.into_iter()
|
|
||||||
.map(str::to_owned)
|
|
||||||
.collect()
|
|
||||||
} else {
|
} else {
|
||||||
BTreeSet::new()
|
BTreeSet::new()
|
||||||
},
|
},
|
||||||
available_text_factor_names: if self.stock_text_factors_required {
|
available_text_factor_names: if self.stock_text_factors_required {
|
||||||
ctx.data
|
collect_available_factor_names(ctx.data
|
||||||
.factor_text_rows_on(date)
|
.factor_text_rows_on(date)
|
||||||
.iter()
|
.iter()
|
||||||
.map(|row| row.field.as_str())
|
.map(|row| row.field.as_str()))
|
||||||
.collect::<BTreeSet<_>>()
|
|
||||||
.into_iter()
|
|
||||||
.map(str::to_owned)
|
|
||||||
.collect()
|
|
||||||
} else {
|
} else {
|
||||||
BTreeSet::new()
|
BTreeSet::new()
|
||||||
},
|
},
|
||||||
@@ -14594,6 +14596,27 @@ mod tests {
|
|||||||
NaiveDate::from_ymd_opt(year, month, day).expect("valid date")
|
NaiveDate::from_ymd_opt(year, month, day).expect("valid date")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn available_factor_name_collection_preserves_sparse_and_repeated_fields() {
|
||||||
|
let fields = ["amount", "model_score", "amount", "adjustment_factor_backward1"];
|
||||||
|
let names = (0..5_000).flat_map(|_| fields.iter().copied());
|
||||||
|
let expected = names.clone().collect::<BTreeSet<_>>()
|
||||||
|
.into_iter().map(str::to_owned).collect::<BTreeSet<_>>();
|
||||||
|
assert_eq!(super::collect_available_factor_names(names), expected);
|
||||||
|
assert!(super::collect_available_factor_names(std::iter::empty()).is_empty());
|
||||||
|
assert_eq!(super::collect_available_factor_names(["today_only"].into_iter()),
|
||||||
|
BTreeSet::from(["today_only".to_string()]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn available_factor_name_collection_preserves_wide_dynamic_field_identity() {
|
||||||
|
let fields = (0..4_000).map(|index| format!("dynamic_{index:04}"))
|
||||||
|
.chain(["Model_score".to_string(), "model_score".to_string()]).collect::<Vec<_>>();
|
||||||
|
let expected = fields.iter().cloned().collect::<BTreeSet<_>>();
|
||||||
|
let names = fields.iter().rev().chain(fields.iter()).map(String::as_str);
|
||||||
|
assert_eq!(super::collect_available_factor_names(names), expected);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn buy_filter_attaches_denials_without_rewriting_selection() {
|
fn buy_filter_attaches_denials_without_rewriting_selection() {
|
||||||
let prev = d(2025, 1, 2);
|
let prev = d(2025, 1, 2);
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ use fidc_core::{
|
|||||||
Instrument, IntradayExecutionQuote, MatchingType, OrderIntent, PriceField, Strategy, StrategyContext,
|
Instrument, IntradayExecutionQuote, MatchingType, OrderIntent, PriceField, Strategy, StrategyContext,
|
||||||
StrategyDecision,
|
StrategyDecision,
|
||||||
};
|
};
|
||||||
use std::collections::{BTreeMap, BTreeSet};
|
use std::collections::BTreeSet;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
fn d(year: i32, month: u32, day: u32) -> NaiveDate {
|
fn d(year: i32, month: u32, day: u32) -> NaiveDate {
|
||||||
@@ -163,7 +163,48 @@ fn single_day_quote_plan_data(date: NaiveDate) -> DataSet {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn engine_uses_preplanned_decision_symbols_without_recomputing_strategy_plan() {
|
fn runtime_account_dependent_quote_scope_uses_the_actual_account() {
|
||||||
|
struct AccountDependentQuoteReader;
|
||||||
|
impl Strategy for AccountDependentQuoteReader {
|
||||||
|
fn name(&self) -> &str { "account_dependent_quote_reader" }
|
||||||
|
fn decision_quote_times(&self) -> Vec<NaiveTime> { vec![t(10, 18, 0)] }
|
||||||
|
fn decision_quote_symbols(&mut self, ctx: &StrategyContext<'_>) -> Result<BTreeSet<String>, fidc_core::BacktestError> {
|
||||||
|
Ok(if ctx.portfolio.cash() < 50_000.0 {
|
||||||
|
BTreeSet::from(["000001.SZ".into()])
|
||||||
|
} else { BTreeSet::new() })
|
||||||
|
}
|
||||||
|
fn on_day(&mut self, ctx: &StrategyContext<'_>) -> Result<StrategyDecision, fidc_core::BacktestError> {
|
||||||
|
let loaded = ctx.data.execution_quotes_on(ctx.execution_date, "000001.SZ").iter().any(|quote|
|
||||||
|
quote.timestamp.time()==t(10,17,59) && quote.last_price==10.0);
|
||||||
|
assert_eq!(loaded, ctx.portfolio.cash() < 50_000.0,
|
||||||
|
"quote scope must match this account, not a fixed-capital planning account");
|
||||||
|
Ok(StrategyDecision::default())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let date = d(2026, 1, 5);
|
||||||
|
for initial_cash in [10_000.0, 100_000.0] {
|
||||||
|
let broker = BrokerSimulator::new_with_execution_price(
|
||||||
|
ChinaAShareCostModel::default(), ChinaEquityRuleHooks, PriceField::Close,
|
||||||
|
).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit)
|
||||||
|
.with_matching_type(MatchingType::CurrentBarClose);
|
||||||
|
let config = BacktestConfig {
|
||||||
|
initial_cash, benchmark_code:"000852.SH".into(),
|
||||||
|
start_date:Some(date), end_date:Some(date), decision_lag_trading_days:0,
|
||||||
|
execution_price_field:PriceField::Close,
|
||||||
|
};
|
||||||
|
let mut engine = BacktestEngine::new(single_day_quote_plan_data(date), AccountDependentQuoteReader, broker, config)
|
||||||
|
.with_execution_quote_loader(move |request| Ok(request.symbols.into_iter().map(|symbol| IntradayExecutionQuote {
|
||||||
|
observation_kind:Default::default(), date:request.date, symbol,
|
||||||
|
timestamp:request.date.and_time(t(10,17,59)), last_price:10.0,bid1:10.0,ask1:10.0,
|
||||||
|
bid1_volume:10_000,ask1_volume:10_000,volume_delta:10_000,amount_delta:100_000.0,
|
||||||
|
trading_phase:Some("continuous".into()),
|
||||||
|
}).collect()));
|
||||||
|
engine.run().expect("account-dependent quote planning");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn engine_resolves_the_runtime_strategy_scope_when_a_loader_exists() {
|
||||||
let date = d(2026, 1, 5);
|
let date = d(2026, 1, 5);
|
||||||
let data = single_day_quote_plan_data(date);
|
let data = single_day_quote_plan_data(date);
|
||||||
let broker = BrokerSimulator::new_with_execution_price(
|
let broker = BrokerSimulator::new_with_execution_price(
|
||||||
@@ -186,10 +227,6 @@ fn engine_uses_preplanned_decision_symbols_without_recomputing_strategy_plan() {
|
|||||||
symbol_plan_calls: Arc::clone(&symbol_plan_calls),
|
symbol_plan_calls: Arc::clone(&symbol_plan_calls),
|
||||||
};
|
};
|
||||||
let captured_loader_calls = Arc::clone(&loader_calls);
|
let captured_loader_calls = Arc::clone(&loader_calls);
|
||||||
let preplanned = Arc::new(BTreeMap::from([(
|
|
||||||
date,
|
|
||||||
BTreeSet::from(["000001.SZ".to_string()]),
|
|
||||||
)]));
|
|
||||||
let mut engine = BacktestEngine::new(data, strategy, broker, config)
|
let mut engine = BacktestEngine::new(data, strategy, broker, config)
|
||||||
.with_execution_quote_loader(move |request| {
|
.with_execution_quote_loader(move |request| {
|
||||||
*captured_loader_calls.lock().expect("loader counter mutex") += 1;
|
*captured_loader_calls.lock().expect("loader counter mutex") += 1;
|
||||||
@@ -210,20 +247,19 @@ fn engine_uses_preplanned_decision_symbols_without_recomputing_strategy_plan() {
|
|||||||
trading_phase: Some("continuous".to_string()),
|
trading_phase: Some("continuous".to_string()),
|
||||||
})
|
})
|
||||||
.collect())
|
.collect())
|
||||||
})
|
});
|
||||||
.with_preplanned_decision_quote_symbols_by_date(preplanned);
|
|
||||||
|
|
||||||
engine.run().expect("backtest should run");
|
engine.run().expect("backtest should run");
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
*symbol_plan_calls.lock().expect("symbol plan counter mutex"),
|
*symbol_plan_calls.lock().expect("symbol plan counter mutex"),
|
||||||
0,
|
1,
|
||||||
"the strategy plan must not be recomputed after a complete plan is supplied"
|
"quote planning must use the actual run context"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
*loader_calls.lock().expect("loader counter mutex"),
|
*loader_calls.lock().expect("loader counter mutex"),
|
||||||
1,
|
0,
|
||||||
"the supplied symbols must still pass through the normal quote loader"
|
"an empty runtime scope must not fetch unrequested symbols"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,再配套发布。优先级仍可在真实资金或仓位约束不足时影响分配,不能预设所有不同排序的结果必须相同。
|
||||||
Reference in New Issue
Block a user