Compare commits

..

32 Commits

Author SHA1 Message Date
boris c98bcc3eb2 修复股票池卖单回报后未继续执行买入阶段 2026-09-13 23:43:26 +08:00
boris 53af3a6a85 perf(data): support exact reservation for known numeric field additions 2026-09-13 22:12:40 +08:00
boris 70c6f7e90b fix(data): expose actual snapshot row counts without cloning data 2026-09-13 17:11:06 +08:00
boris 0ed6752a73 perf(engine): deduplicate daily factor names before allocating sorted output 2026-09-13 16:47:16 +08:00
boris 3e8cc63b9a Revert "perf(engine): share immutable daily factor schemas and numeric buffers"
This reverts commit 5d0823c060bfd2a42a3f86a381e874004ab7f6af.
2026-09-13 14:16:07 +08:00
boris be171683c9 Revert "test(engine): retain static schema names across shared factor rows"
This reverts commit ce0dc0a106f0a98230bb9c428537ec086b968273.
2026-09-13 14:16:07 +08:00
boris 0a6fab9038 Revert "perf(engine): keep empty numeric maps on a direct lookup path"
This reverts commit a63dd94045f3a4b95dbfc917d5d8afa5c22f1897.
2026-09-13 14:16:07 +08:00
boris e8abf43cd4 perf(engine): keep empty numeric maps on a direct lookup path 2026-09-13 13:49:20 +08:00
boris 2286bfa757 test(engine): retain static schema names across shared factor rows 2026-09-13 13:07:52 +08:00
boris 93809eea1b perf(engine): share immutable daily factor schemas and numeric buffers 2026-09-13 13:02:17 +08:00
boris f7f0ff2951 Merge remote-tracking branch 'origin/main' 2026-09-13 11:37:38 +08:00
boris effa0c6456 test(engine): validate quote demand across different account capital 2026-09-13 11:21:09 +08:00
boris b1ca2dfada fix(engine): resolve decision quote scope from the actual run context 2026-09-13 11:18:39 +08:00
boris d15abc18ae test(engine): reproduce account-sensitive quote scope bypass 2026-09-13 11:07:57 +08:00
boris 9370dfe6e9 Merge remote-tracking branch 'origin/main' 2026-09-13 11:06:22 +08:00
boris b19108558f 统一仓位调整时点并保留策略择时与退出意图 2026-09-13 11:05:12 +08:00
boris fe05384f80 test(data): compare compact names by value and allocation class 2026-09-13 09:57:47 +08:00
boris 0ff90c4329 refactor(data): use explicit string views for compact numeric names 2026-09-13 09:55:16 +08:00
boris c85daae608 perf(data): inline numeric factor keys and preserve borrowed static names 2026-09-13 09:53:06 +08:00
boris f73513e2d4 Revert "perf(data): sort compact market-cap keys without revisiting wide snapshots"
This reverts commit 4e953b6e98.
2026-09-13 07:33:05 +08:00
boris 4e953b6e98 perf(data): sort compact market-cap keys without revisiting wide snapshots 2026-09-13 07:12:42 +08:00
boris b232847e40 docs(strategy): record feature-date gap reproduction and replay acceptance 2026-09-13 06:34:22 +08:00
boris e3b3929578 test(strategy): supply declared historical prices in next-open fixture 2026-09-13 06:09:49 +08:00
boris 20e73d567b fix(strategy): require exact feature-date market snapshots 2026-09-13 06:06:18 +08:00
boris cf4498668b test(strategy): qualify the expected missing-market error 2026-09-13 06:03:54 +08:00
boris 3f39943ee4 test(strategy): reject execution-day fallback for missing decision-day market 2026-09-13 05:59:40 +08:00
boris 5c65e65c6f perf(data): parallelize bounded daily symbol indices 2026-09-13 03:25:37 +08:00
boris f3c70ea566 docs(perf): record expression context CPU and real HTTP parity evidence 2026-09-13 01:13:44 +08:00
boris 07b7b181b6 perf(strategy): retain compact factor values in expression contexts 2026-09-13 00:50:07 +08:00
boris fe7243bbc3 perf(data): normalize owned daily bundles in parallel with stable errors 2026-09-12 19:30:28 +08:00
boris 875e31f71f fix(stock-pool): separate cumulative condition facts from quote capacity 2026-09-12 18:50:12 +08:00
boris 61bd14d001 fix(backtest): inspect the authoritative strategy inside runner bundles 2026-09-12 16:55:16 +08:00
24 changed files with 8842 additions and 336 deletions
+35 -12
View File
@@ -427,6 +427,8 @@ pub struct BrokerSimulator<C, R> {
verified_etf_minute_absences: RefCell<BTreeSet<(NaiveDate, String)>>, verified_etf_minute_absences: RefCell<BTreeSet<(NaiveDate, String)>>,
runtime_etf_daily_open: Cell<bool>, runtime_etf_daily_open: Cell<bool>,
deferred_etf_targets: RefCell<crate::etf_execution::DeferredEtfTargets>, deferred_etf_targets: RefCell<crate::etf_execution::DeferredEtfTargets>,
deferred_stock_pools: RefCell<BTreeMap<String, stock_pool::DeferredStockPoolExecution>>,
runtime_stock_pool_followup: Cell<bool>,
cost_model: C, cost_model: C,
rules: R, rules: R,
board_lot_size: u32, board_lot_size: u32,
@@ -469,6 +471,8 @@ impl<C, R> BrokerSimulator<C, R> {
verified_etf_minute_absences: RefCell::new(BTreeSet::new()), verified_etf_minute_absences: RefCell::new(BTreeSet::new()),
runtime_etf_daily_open: Cell::new(false), runtime_etf_daily_open: Cell::new(false),
deferred_etf_targets: RefCell::new(Default::default()), deferred_etf_targets: RefCell::new(Default::default()),
deferred_stock_pools: RefCell::new(BTreeMap::new()),
runtime_stock_pool_followup: Cell::new(false),
cost_model, cost_model,
rules, rules,
board_lot_size: 100, board_lot_size: 100,
@@ -515,6 +519,8 @@ impl<C, R> BrokerSimulator<C, R> {
verified_etf_minute_absences: RefCell::new(BTreeSet::new()), verified_etf_minute_absences: RefCell::new(BTreeSet::new()),
runtime_etf_daily_open: Cell::new(false), runtime_etf_daily_open: Cell::new(false),
deferred_etf_targets: RefCell::new(Default::default()), deferred_etf_targets: RefCell::new(Default::default()),
deferred_stock_pools: RefCell::new(BTreeMap::new()),
runtime_stock_pool_followup: Cell::new(false),
cost_model, cost_model,
rules, rules,
board_lot_size: 100, board_lot_size: 100,
@@ -778,7 +784,7 @@ impl<C, R> BrokerSimulator<C, R> {
if self.runtime_etf_daily_open.get() { return PriceField::Open; } if self.runtime_etf_daily_open.get() { return PriceField::Open; }
if self.is_post_close_fixed_price(date) { if self.is_post_close_fixed_price(date) {
PriceField::Close PriceField::Close
} else if self.resting_daily_open_order() { } else if self.resting_daily_open_order() || (self.runtime_stock_pool_followup.get() && self.matching_type == MatchingType::NextBarOpen) {
PriceField::Last PriceField::Last
} else { } else {
self.execution_price_field self.execution_price_field
@@ -901,6 +907,12 @@ impl<C, R> BrokerSimulator<C, R> {
!self.open_orders.borrow().is_empty() !self.open_orders.borrow().is_empty()
} }
fn new_open_order_submission_time(&self) -> Option<NaiveTime> {
if self.matching_type == MatchingType::NextBarOpen && !self.runtime_stock_pool_followup.get() {
NaiveTime::from_hms_opt(9, 30, 0)
} else { self.order_origin().1 }
}
fn resting_order_session_close(&self, date: NaiveDate, order: &OpenOrder) -> NaiveTime { fn resting_order_session_close(&self, date: NaiveDate, order: &OpenOrder) -> NaiveTime {
let post_close = self.execution_phase_for_submission(date, order.order_created_date, order.submission_time) let post_close = self.execution_phase_for_submission(date, order.order_created_date, order.submission_time)
== EquityExecutionPhase::PostCloseFixedPrice; == EquityExecutionPhase::PostCloseFixedPrice;
@@ -1479,7 +1491,7 @@ where
match algo_request.map(|request| request.style) { match algo_request.map(|request| request.style) {
Some(AlgoExecutionStyle::Vwap) => MatchingType::Vwap, Some(AlgoExecutionStyle::Vwap) => MatchingType::Vwap,
Some(AlgoExecutionStyle::Twap) => MatchingType::Twap, Some(AlgoExecutionStyle::Twap) => MatchingType::Twap,
None if self.resting_daily_open_order() => MatchingType::CurrentBarClose, None if self.resting_daily_open_order() || (self.runtime_stock_pool_followup.get() && self.matching_type == MatchingType::NextBarOpen) => MatchingType::CurrentBarClose,
None => self.matching_type, None => self.matching_type,
} }
} }
@@ -1597,6 +1609,13 @@ where
session: &mut BrokerExecutionSession, session: &mut BrokerExecutionSession,
) -> Result<BrokerExecutionReport, BacktestError> { ) -> Result<BrokerExecutionReport, BacktestError> {
let mut report = BrokerExecutionReport::default(); let mut report = BrokerExecutionReport::default();
// A fresh strategy intent supersedes any unsubmitted remainder before
// old order reports can resume it. Already submitted orders are kept.
for intent in &decision.order_intents {
if let OrderIntent::StockPool { contract } = intent.unwrapped() {
self.deferred_stock_pools.borrow_mut().remove(&contract.pool_id);
}
}
self.process_open_orders( self.process_open_orders(
date, date,
portfolio, portfolio,
@@ -1607,6 +1626,7 @@ where
&mut session.commission_state, &mut session.commission_state,
&mut report, &mut report,
)?; )?;
self.resume_stock_pool_executions(date, portfolio, data, session, &mut report)?;
if !decision.order_intents.is_empty() { if !decision.order_intents.is_empty() {
let mut ordered_intents = decision.order_intents.iter().collect::<Vec<_>>(); let mut ordered_intents = decision.order_intents.iter().collect::<Vec<_>>();
if self.effective_rebalance_cash_mode() != RebalanceCashMode::PreOpenCash if self.effective_rebalance_cash_mode() != RebalanceCashMode::PreOpenCash
@@ -4149,7 +4169,7 @@ where
side: OrderSide, side: OrderSide,
algo_request: Option<&AlgoExecutionRequest>, algo_request: Option<&AlgoExecutionRequest>,
) -> f64 { ) -> f64 {
if self.matching_type == MatchingType::NextBarOpen && !self.resting_daily_open_order() && algo_request.is_none() { if self.matching_type == MatchingType::NextBarOpen && !self.resting_daily_open_order() && !self.runtime_stock_pool_followup.get() && algo_request.is_none() {
return self.execution_limit_check_price(snapshot, side); return self.execution_limit_check_price(snapshot, side);
} }
let matching_type = self.matching_type_for_algo_request(algo_request); let matching_type = self.matching_type_for_algo_request(algo_request);
@@ -4735,7 +4755,7 @@ where
if Self::keeps_remainder_open(remainder_policy) { if Self::keeps_remainder_open(remainder_policy) {
self.upsert_open_order(OpenOrder { self.upsert_open_order(OpenOrder {
order_id, order_id,
submission_time: if self.matching_type == MatchingType::NextBarOpen { NaiveTime::from_hms_opt(9,30,0) } else { self.order_origin().1 }, submission_time: self.new_open_order_submission_time(),
accepted_date: self.accepted_order_date(date), accepted_date: self.accepted_order_date(date),
decision_date: Some(self.current_decision_date(date)), decision_date: Some(self.current_decision_date(date)),
order_created_date: Some(self.current_order_created_date(date)), order_created_date: Some(self.current_order_created_date(date)),
@@ -4826,7 +4846,7 @@ where
.unwrap_or("no sellable quantity"); .unwrap_or("no sellable quantity");
self.upsert_open_order(OpenOrder { self.upsert_open_order(OpenOrder {
order_id, order_id,
submission_time: if self.matching_type == MatchingType::NextBarOpen { NaiveTime::from_hms_opt(9,30,0) } else { self.order_origin().1 }, submission_time: self.new_open_order_submission_time(),
accepted_date: self.accepted_order_date(date), accepted_date: self.accepted_order_date(date),
decision_date: Some(self.current_decision_date(date)), decision_date: Some(self.current_decision_date(date)),
order_created_date: Some(self.current_order_created_date(date)), order_created_date: Some(self.current_order_created_date(date)),
@@ -4999,7 +5019,7 @@ where
{ {
self.upsert_open_order(OpenOrder { self.upsert_open_order(OpenOrder {
order_id, order_id,
submission_time: if self.matching_type == MatchingType::NextBarOpen { NaiveTime::from_hms_opt(9,30,0) } else { self.order_origin().1 }, submission_time: self.new_open_order_submission_time(),
accepted_date: self.accepted_order_date(date), accepted_date: self.accepted_order_date(date),
decision_date: Some(self.current_decision_date(date)), decision_date: Some(self.current_decision_date(date)),
order_created_date: Some(self.current_order_created_date(date)), order_created_date: Some(self.current_order_created_date(date)),
@@ -5171,7 +5191,7 @@ where
if keep_open { if keep_open {
self.upsert_open_order(OpenOrder { self.upsert_open_order(OpenOrder {
order_id, order_id,
submission_time: if self.matching_type == MatchingType::NextBarOpen { NaiveTime::from_hms_opt(9,30,0) } else { self.order_origin().1 }, submission_time: self.new_open_order_submission_time(),
accepted_date: self.accepted_order_date(date), accepted_date: self.accepted_order_date(date),
decision_date: Some(self.current_decision_date(date)), decision_date: Some(self.current_decision_date(date)),
order_created_date: Some(self.current_order_created_date(date)), order_created_date: Some(self.current_order_created_date(date)),
@@ -6559,7 +6579,7 @@ where
if Self::keeps_remainder_open(remainder_policy) { if Self::keeps_remainder_open(remainder_policy) {
self.upsert_open_order(OpenOrder { self.upsert_open_order(OpenOrder {
order_id, order_id,
submission_time: if self.matching_type == MatchingType::NextBarOpen { NaiveTime::from_hms_opt(9,30,0) } else { self.order_origin().1 }, submission_time: self.new_open_order_submission_time(),
accepted_date: self.accepted_order_date(date), accepted_date: self.accepted_order_date(date),
decision_date: Some(self.current_decision_date(date)), decision_date: Some(self.current_decision_date(date)),
order_created_date: Some(self.current_order_created_date(date)), order_created_date: Some(self.current_order_created_date(date)),
@@ -6799,7 +6819,7 @@ where
{ {
self.upsert_open_order(OpenOrder { self.upsert_open_order(OpenOrder {
order_id, order_id,
submission_time: if self.matching_type == MatchingType::NextBarOpen { NaiveTime::from_hms_opt(9,30,0) } else { self.order_origin().1 }, submission_time: self.new_open_order_submission_time(),
accepted_date: self.accepted_order_date(date), accepted_date: self.accepted_order_date(date),
decision_date: Some(self.current_decision_date(date)), decision_date: Some(self.current_decision_date(date)),
order_created_date: Some(self.current_order_created_date(date)), order_created_date: Some(self.current_order_created_date(date)),
@@ -6973,7 +6993,7 @@ where
if keep_open { if keep_open {
self.upsert_open_order(OpenOrder { self.upsert_open_order(OpenOrder {
order_id, order_id,
submission_time: if self.matching_type == MatchingType::NextBarOpen { NaiveTime::from_hms_opt(9,30,0) } else { self.order_origin().1 }, submission_time: self.new_open_order_submission_time(),
accepted_date: self.accepted_order_date(date), accepted_date: self.accepted_order_date(date),
decision_date: Some(self.current_decision_date(date)), decision_date: Some(self.current_decision_date(date)),
order_created_date: Some(self.current_order_created_date(date)), order_created_date: Some(self.current_order_created_date(date)),
@@ -8110,6 +8130,7 @@ where
pub(crate) fn matching_type_uses_intraday_quotes(&self) -> bool { pub(crate) fn matching_type_uses_intraday_quotes(&self) -> bool {
if self.runtime_etf_daily_open.get() { return false; } if self.runtime_etf_daily_open.get() { return false; }
if self.runtime_stock_pool_followup.get() { return true; }
if self.resting_daily_open_order() { return true; } if self.resting_daily_open_order() { return true; }
matches!( matches!(
self.matching_type, self.matching_type,
@@ -8252,6 +8273,8 @@ mod tests {
use crate::rules::ChinaEquityRuleHooks; use crate::rules::ChinaEquityRuleHooks;
use crate::strategy::{AlgoOrderStyle, OrderIntent, OrderTimeInForce, StrategyDecision}; use crate::strategy::{AlgoOrderStyle, OrderIntent, OrderTimeInForce, StrategyDecision};
include!("broker_stock_pool_batch_tests.rs");
fn test_open_order(order_id: u64) -> OpenOrder { fn test_open_order(order_id: u64) -> OpenOrder {
OpenOrder { OpenOrder {
order_id, order_id,
@@ -8324,7 +8347,7 @@ mod tests {
fn limit_test_quote(last_price: f64, bid1: f64, ask1: f64) -> IntradayExecutionQuote { fn limit_test_quote(last_price: f64, bid1: f64, ask1: f64) -> IntradayExecutionQuote {
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date"); let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date");
IntradayExecutionQuote { IntradayExecutionQuote { observation_kind: Default::default(),
date, date,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".to_string(),
timestamp: date.and_hms_opt(9, 33, 0).expect("valid timestamp"), timestamp: date.and_hms_opt(9, 33, 0).expect("valid timestamp"),
@@ -11729,7 +11752,7 @@ mod tests {
lower_limit: 5.27, lower_limit: 5.27,
price_tick: 0.01, price_tick: 0.01,
}; };
let quote = IntradayExecutionQuote { let quote = IntradayExecutionQuote { observation_kind: Default::default(),
date, date,
symbol: symbol.to_string(), symbol: symbol.to_string(),
timestamp: date.and_hms_opt(10, 39, 59).expect("valid timestamp"), timestamp: date.and_hms_opt(10, 39, 59).expect("valid timestamp"),
+134 -20
View File
@@ -3,6 +3,35 @@ use super::*;
use crate::holding_policy::HoldingLifecycleEvidence; use crate::holding_policy::HoldingLifecycleEvidence;
use crate::stock_pool_execution as pool; use crate::stock_pool_execution as pool;
use rust_decimal::{Decimal, prelude::ToPrimitive}; use rust_decimal::{Decimal, prelude::ToPrimitive};
use chrono::Timelike;
#[derive(Debug)]
pub(super) struct DeferredStockPoolExecution {
date: NaiveDate,
contract: Box<pool::FrozenStockPoolIntent>,
buy_only: bool,
symbols: BTreeSet<String>,
initial_holdings: BTreeSet<String>,
}
impl<C, R> BrokerSimulator<C, R> {
pub(crate) fn pending_stock_pool_symbols(&self) -> BTreeSet<String> {
self.deferred_stock_pools.borrow().values().flat_map(|pending| pending.symbols.iter().cloned()).collect()
}
pub(crate) fn has_pending_stock_pool_execution(&self) -> bool {
!self.deferred_stock_pools.borrow().is_empty()
}
pub(crate) fn finish_stock_pool_session(&self, date: NaiveDate, report: &mut BrokerExecutionReport) {
self.deferred_stock_pools.borrow_mut().retain(|_, pending| {
if pending.date <= date {
report.diagnostics.push(format!("stock_pool_unsubmitted_phase_expired generation={} date={date} no_buy_order_created=true",pending.contract.generation));
false
} else { true }
});
}
}
fn decimal(value: f64, label: &str) -> Result<Decimal, BacktestError> { fn decimal(value: f64, label: &str) -> Result<Decimal, BacktestError> {
if !value.is_finite() { if !value.is_finite() {
@@ -41,14 +70,58 @@ 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,
data: &DataSet, data: &DataSet,
symbols: &BTreeSet<String>, symbols: &BTreeSet<String>,
execution_clock: Option<NaiveDateTime>, execution_clock: Option<NaiveDateTime>,
) -> Result<Vec<pool::MarketSnapshot>, BacktestError> { cumulative_conditions: bool,
symbols ) -> Result<(Vec<pool::MarketSnapshot>, Vec<String>), BacktestError> {
let mut unavailable = Vec::new();
let quotes = symbols
.iter() .iter()
.map(|symbol| { .map(|symbol| {
let snapshot = data.market(date, symbol).ok_or_else(|| { let snapshot = data.market(date, symbol).ok_or_else(|| {
@@ -100,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!(
@@ -112,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!(
@@ -134,11 +207,17 @@ impl<C: CostModel, R: EquityRuleHooks> BrokerSimulator<C, R> {
None, None,
calibration.as_ref(), calibration.as_ref(),
)?; )?;
let totals = if cumulative_conditions {
match data.execution_session_totals(symbol, clock) {
Ok(totals) => Some(totals),
Err(reason) => { unavailable.push(reason); None }
}
} else { None };
( (
quote.last_price, quote.last_price,
snapshot.prev_close, snapshot.prev_close,
Some(quote.volume_delta as f64), totals.map(|total| total.0),
Some(quote.amount_delta), totals.map(|total| total.1),
Some(quote.bid1), Some(quote.bid1),
Some(quote.ask1), Some(quote.ask1),
buy, buy,
@@ -153,13 +232,24 @@ impl<C: CostModel, R: EquityRuleHooks> BrokerSimulator<C, R> {
} }
// A daily open does not reveal the session's volume/turnover. // A daily open does not reveal the session's volume/turnover.
let completed = self.effective_execution_price_field(date) == PriceField::Close; let completed = self.effective_execution_price_field(date) == PriceField::Close;
let totals = if cumulative_conditions && !completed {
let at = execution_clock.unwrap_or_else(|| date.and_hms_opt(9,30,0).unwrap());
match data.execution_session_totals(symbol, at) {
Ok(totals) => Some(totals),
Err(reason) => { unavailable.push(reason); None }
}
} else { None };
let amount = if completed && cumulative_conditions {
data.factor(date, symbol).and_then(|row| row.extra_factors.get("amount")).copied()
.map(|value| decimal(value, "amount")).transpose()?
} else { totals.map(|total| total.1) };
( (
price, price,
snapshot.prev_close, snapshot.prev_close,
completed.then_some(snapshot.volume as f64), if completed { Some(Decimal::from(snapshot.volume)) } else { totals.map(|total| total.0) },
amount,
None,
None, None,
Some(price),
Some(price),
self.snapshot_execution_price(data, snapshot, OrderSide::Buy, None)?, self.snapshot_execution_price(data, snapshot, OrderSide::Buy, None)?,
self.snapshot_execution_price(data, snapshot, OrderSide::Sell, None)?, self.snapshot_execution_price(data, snapshot, OrderSide::Sell, None)?,
) )
@@ -168,8 +258,8 @@ impl<C: CostModel, R: EquityRuleHooks> BrokerSimulator<C, R> {
symbol: symbol.clone(), symbol: symbol.clone(),
last_price: decimal(price, "price")?, last_price: decimal(price, "price")?,
prev_close: Some(decimal(prev, "prev_close")?), prev_close: Some(decimal(prev, "prev_close")?),
volume: volume.map(|v| decimal(v, "volume")).transpose()?, volume,
turnover: amount.map(|v| decimal(v, "amount")).transpose()?, turnover: amount,
bid_price_1: bid.map(|v| decimal(v, "bid")).transpose()?, bid_price_1: bid.map(|v| decimal(v, "bid")).transpose()?,
ask_price_1: ask.map(|v| decimal(v, "ask")).transpose()?, ask_price_1: ask.map(|v| decimal(v, "ask")).transpose()?,
is_kcb: Some(instrument.board.eq_ignore_ascii_case("KSH")), is_kcb: Some(instrument.board.eq_ignore_ascii_case("KSH")),
@@ -182,7 +272,8 @@ impl<C: CostModel, R: EquityRuleHooks> BrokerSimulator<C, R> {
sell_sizing_price: Some(decimal(sell_price, "sell_price")?), sell_sizing_price: Some(decimal(sell_price, "sell_price")?),
}) })
}) })
.collect() .collect::<Result<Vec<_>, BacktestError>>()?;
Ok((quotes, unavailable))
} }
fn pool_etf_fallback_reference(&self, date: NaiveDate, data: &DataSet, symbol: &str, clock: Option<NaiveDateTime>) -> Result<Option<crate::etf_execution::EtfFallbackReference>, BacktestError> { fn pool_etf_fallback_reference(&self, date: NaiveDate, data: &DataSet, symbol: &str, clock: Option<NaiveDateTime>) -> Result<Option<crate::etf_execution::EtfFallbackReference>, BacktestError> {
@@ -206,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
@@ -246,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
@@ -264,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());
@@ -309,15 +415,19 @@ impl<C: CostModel, R: EquityRuleHooks> BrokerSimulator<C, R> {
.push("paused".into()); .push("paused".into());
} }
} }
let before_positions = portfolio
.positions()
.keys()
.cloned()
.collect::<BTreeSet<_>>();
// All delayed symbols in a generation share immutable configuration. // All delayed symbols in a generation share immutable configuration.
// Do not duplicate an N-member pool N times in a large mixed pool. // Do not duplicate an N-member pool N times in a large mixed pool.
let mut deferred_configuration = None; let mut deferred_configuration = None;
for side in [pool::OrderSide::Sell, pool::OrderSide::Buy] { for side in [pool::OrderSide::Sell, pool::OrderSide::Buy] {
if buy_only && side == pool::OrderSide::Sell { continue; }
if side == pool::OrderSide::Buy && self.has_open_orders()
&& self.effective_rebalance_cash_mode() == RebalanceCashMode::SellThenBuy {
self.deferred_stock_pools.borrow_mut().insert(contract.pool_id.clone(), DeferredStockPoolExecution {
date, contract: Box::new(contract.clone()), buy_only: true, symbols: quote_scope.clone(), initial_holdings: before_positions.clone(),
});
report.diagnostics.push(format!("stock_pool_waiting_for_sell_reports generation={} no_buy_order_created=true",contract.generation));
break;
}
let mut fallback_references = BTreeMap::new(); let mut fallback_references = BTreeMap::new();
for symbol in &quote_scope { for symbol in &quote_scope {
if let Some(reference) = self.pool_etf_fallback_reference(date, data, symbol, *global_execution_cursor)? { if let Some(reference) = self.pool_etf_fallback_reference(date, data, symbol, *global_execution_cursor)? {
@@ -328,8 +438,8 @@ impl<C: CostModel, R: EquityRuleHooks> BrokerSimulator<C, R> {
fallback_references.insert(symbol.clone(), reference); fallback_references.insert(symbol.clone(), reference);
} }
} }
let quotes = let (quotes, unavailable) = self.pool_quote_inputs(date, data, &quote_scope, *global_execution_cursor,
self.pool_quote_inputs(date, data, &quote_scope, *global_execution_cursor)?; crate::stock_pool_quote_facts::requires_session_totals(&contract.rule))?;
let positions = pool_positions(portfolio, date)?; let positions = pool_positions(portfolio, date)?;
let execution_state = portfolio let execution_state = portfolio
.stock_pool_execution_state(&contract.pool_id) .stock_pool_execution_state(&contract.pool_id)
@@ -448,7 +558,11 @@ impl<C: CostModel, R: EquityRuleHooks> BrokerSimulator<C, R> {
Decimal::ZERO, Decimal::ZERO,
Some(&fee), Some(&fee),
) )
.map_err(BacktestError::Execution)?; .map_err(|error| BacktestError::Execution(if !unavailable.is_empty()
&& (error.contains("requires volume") || error.contains("requires amount")) {
format!("{error}; {}", unavailable.join("; "))
} else { error }))?;
report.diagnostics.extend(unavailable.into_iter().map(|reason| format!("stock_pool_quote_fact_unavailable {reason}")));
let mut updated = execution_state let mut updated = execution_state
.record_plan(contract.signal_date, &contract.generation, &plan) .record_plan(contract.signal_date, &contract.generation, &plan)
.map_err(BacktestError::Execution)?; .map_err(BacktestError::Execution)?;
@@ -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);
}
+235 -75
View File
@@ -3,7 +3,7 @@ use std::cmp::Reverse;
use std::collections::{BTreeMap, BTreeSet, BinaryHeap, HashMap, HashSet}; use std::collections::{BTreeMap, BTreeSet, BinaryHeap, HashMap, HashSet};
use std::sync::{Arc, OnceLock}; use std::sync::{Arc, OnceLock};
use ahash::AHashMap; use ahash::{AHashMap, AHashSet};
use chrono::{NaiveDate, NaiveDateTime}; use chrono::{NaiveDate, NaiveDateTime};
use compact_str::CompactString; use compact_str::CompactString;
use rayon::prelude::*; use rayon::prelude::*;
@@ -284,6 +284,8 @@ pub struct CorporateAction {
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IntradayExecutionQuote { pub struct IntradayExecutionQuote {
#[serde(default)]
pub observation_kind: QuoteObservationKind,
#[serde(with = "date_format")] #[serde(with = "date_format")]
pub date: NaiveDate, pub date: NaiveDate,
pub symbol: String, pub symbol: String,
@@ -301,6 +303,14 @@ pub struct IntradayExecutionQuote {
pub trading_phase: Option<String>, pub trading_phase: Option<String>,
} }
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum QuoteObservationKind {
#[default]
Unspecified,
MinuteBar,
}
/// Sparse same-day fields layered onto an already-built immutable daily panel. /// Sparse same-day fields layered onto an already-built immutable daily panel.
/// ///
/// These fields do not participate in daily price series, adjustment series, /// These fields do not participate in daily price series, adjustment series,
@@ -1407,6 +1417,7 @@ pub struct DataSet {
corporate_actions_by_date: Arc<BTreeMap<NaiveDate, Vec<CorporateAction>>>, corporate_actions_by_date: Arc<BTreeMap<NaiveDate, Vec<CorporateAction>>>,
execution_quotes_by_date: Arc<HashMap<NaiveDate, HashMap<String, Vec<IntradayExecutionQuote>>>>, execution_quotes_by_date: Arc<HashMap<NaiveDate, HashMap<String, Vec<IntradayExecutionQuote>>>>,
execution_quote_dates: Arc<Vec<NaiveDate>>, execution_quote_dates: Arc<Vec<NaiveDate>>,
condition_totals: Arc<std::sync::Mutex<crate::stock_pool_quote_facts::SessionTotalsCache>>,
order_book_depth_index: Arc<HashMap<(NaiveDate, String), Vec<IntradayOrderBookDepthLevel>>>, order_book_depth_index: Arc<HashMap<(NaiveDate, String), Vec<IntradayOrderBookDepthLevel>>>,
benchmark_by_date: Arc<BTreeMap<NaiveDate, BenchmarkSnapshot>>, benchmark_by_date: Arc<BTreeMap<NaiveDate, BenchmarkSnapshot>>,
market_series_by_symbol: Arc<AHashMap<String, Arc<SymbolPriceSeries>>>, market_series_by_symbol: Arc<AHashMap<String, Arc<SymbolPriceSeries>>>,
@@ -1575,48 +1586,15 @@ impl DataSet {
benchmark_by_date: BTreeMap::new(), benchmark_by_date: BTreeMap::new(),
corporate_actions_by_date: BTreeMap::new(), corporate_actions_by_date: BTreeMap::new(),
}; };
for mut bundle in bundles { // Indexed collection retains chronological error precedence while each
// worker validates and normalizes only its owned day buffers.
let prepared = bundles
.into_par_iter()
.map(normalize_daily_snapshot_bundle)
.collect::<Vec<_>>();
for bundle in prepared {
let bundle = bundle?;
let date = bundle.date; let date = bundle.date;
if bundle.benchmark.date != date {
return Err(DataSetError::InvalidDailyBundleComponentDate {
kind: "benchmark",
bundle_date: date,
row_date: bundle.benchmark.date,
symbol: bundle.benchmark.benchmark.clone(),
});
}
validate_daily_bundle_component_dates(
&bundle.market,
date,
"market",
|row| row.date,
|row| row.symbol.as_str(),
)?;
validate_daily_bundle_component_dates(
&bundle.factors,
date,
"factor",
|row| row.date,
|row| row.symbol.as_str(),
)?;
validate_daily_bundle_component_dates(
&bundle.candidates,
date,
"candidate",
|row| row.date,
|row| row.symbol.as_str(),
)?;
validate_daily_bundle_component_dates(
&bundle.corporate_actions,
date,
"corporate_action",
|row| row.date,
|row| row.symbol.as_str(),
)?;
sort_rows_by_symbol_if_needed(&mut bundle.market, |row| row.symbol.as_str());
bundle.factors = normalize_factor_snapshots(bundle.factors)?;
sort_rows_by_symbol_if_needed(&mut bundle.factors, |row| row.symbol.as_str());
sort_rows_by_symbol_if_needed(&mut bundle.candidates, |row| row.symbol.as_str());
if !bundle.market.is_empty() { if !bundle.market.is_empty() {
grouped.market_by_date.insert(date, bundle.market); grouped.market_by_date.insert(date, bundle.market);
} }
@@ -1941,6 +1919,7 @@ impl DataSet {
candidate_row_positions_by_date: Arc::new(candidate_row_positions_by_date), candidate_row_positions_by_date: Arc::new(candidate_row_positions_by_date),
corporate_actions_by_date: Arc::new(corporate_actions_by_date), corporate_actions_by_date: Arc::new(corporate_actions_by_date),
execution_quotes_by_date: Arc::new(execution_quotes_by_date), execution_quotes_by_date: Arc::new(execution_quotes_by_date),
condition_totals: Arc::new(std::sync::Mutex::new(Default::default())),
execution_quote_dates: Arc::new(execution_quote_dates), execution_quote_dates: Arc::new(execution_quote_dates),
order_book_depth_index: Arc::new(order_book_depth_index), order_book_depth_index: Arc::new(order_book_depth_index),
benchmark_by_date: Arc::new(benchmark_by_date), benchmark_by_date: Arc::new(benchmark_by_date),
@@ -2271,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)
@@ -2279,6 +2269,15 @@ impl DataSet {
.unwrap_or(&[]) .unwrap_or(&[])
} }
pub fn execution_session_totals(&self, symbol: &str, at: NaiveDateTime) -> Result<(rust_decimal::Decimal, rust_decimal::Decimal), String> {
let mut cache = self.condition_totals.lock().map_err(|_| "stock_pool_session_prefix_cache_poisoned")?;
if cache.date != Some(at.date()) {
cache.date = Some(at.date());
cache.symbols.clear();
}
cache.symbols.entry(symbol.into()).or_insert_with(|| crate::stock_pool_quote_facts::MinutePrefix::build(at.date(), symbol, self.execution_quotes_on(at.date(), symbol))).at(at)
}
pub fn has_execution_quotes_on_date(&self, date: NaiveDate) -> bool { pub fn has_execution_quotes_on_date(&self, date: NaiveDate) -> bool {
self.execution_quotes_by_date self.execution_quotes_by_date
.get(&date) .get(&date)
@@ -2451,6 +2450,7 @@ impl DataSet {
/// Replaces the run-local execution quote layer without touching the /// Replaces the run-local execution quote layer without touching the
/// immutable daily panel. /// immutable daily panel.
pub fn replace_execution_quotes(&mut self, quotes: Vec<IntradayExecutionQuote>) -> usize { pub fn replace_execution_quotes(&mut self, quotes: Vec<IntradayExecutionQuote>) -> usize {
self.condition_totals = Arc::new(std::sync::Mutex::new(Default::default()));
let execution_quotes_by_date = build_execution_quote_index(quotes); let execution_quotes_by_date = build_execution_quote_index(quotes);
let quote_count = execution_quotes_by_date let quote_count = execution_quotes_by_date
.values() .values()
@@ -2466,6 +2466,7 @@ impl DataSet {
} }
pub fn add_execution_quotes(&mut self, quotes: Vec<IntradayExecutionQuote>) -> usize { pub fn add_execution_quotes(&mut self, quotes: Vec<IntradayExecutionQuote>) -> usize {
self.condition_totals = Arc::new(std::sync::Mutex::new(Default::default()));
let mut grouped = HashMap::<NaiveDate, HashMap<String, Vec<IntradayExecutionQuote>>>::new(); let mut grouped = HashMap::<NaiveDate, HashMap<String, Vec<IntradayExecutionQuote>>>::new();
for quote in quotes { for quote in quotes {
grouped grouped
@@ -2566,6 +2567,7 @@ impl DataSet {
} }
pub fn remove_execution_quotes_on_date(&mut self, date: NaiveDate) -> usize { pub fn remove_execution_quotes_on_date(&mut self, date: NaiveDate) -> usize {
self.condition_totals = Arc::new(std::sync::Mutex::new(Default::default()));
let removed = Arc::make_mut(&mut self.execution_quotes_by_date).remove(&date); let removed = Arc::make_mut(&mut self.execution_quotes_by_date).remove(&date);
let Some(rows_by_symbol) = removed else { let Some(rows_by_symbol) = removed else {
return 0; return 0;
@@ -2578,6 +2580,7 @@ impl DataSet {
} }
pub fn release_execution_quotes_on_date(&mut self, date: NaiveDate) -> usize { pub fn release_execution_quotes_on_date(&mut self, date: NaiveDate) -> usize {
self.condition_totals = Arc::new(std::sync::Mutex::new(Default::default()));
let row_count = self let row_count = self
.execution_quotes_by_date .execution_quotes_by_date
.get(&date) .get(&date)
@@ -4398,9 +4401,9 @@ fn normalize_factor_snapshots(
}); });
} }
let already_normalized = snapshot.extra_factors.iter().all(|(field, value)| { let already_normalized = snapshot.extra_factors.iter().all(|(field, value)| {
let trimmed = field.as_ref().trim().trim_matches('"').trim_matches('\''); let trimmed = field.as_str().trim().trim_matches('"').trim_matches('\'');
!trimmed.is_empty() !trimmed.is_empty()
&& trimmed == field.as_ref() && trimmed == field.as_str()
&& trimmed.bytes().all(|byte| !byte.is_ascii_uppercase()) && trimmed.bytes().all(|byte| !byte.is_ascii_uppercase())
&& value.is_finite() && value.is_finite()
}); });
@@ -4411,15 +4414,15 @@ fn normalize_factor_snapshots(
.extra_factors .extra_factors
.into_iter() .into_iter()
.filter_map(|(field, value)| { .filter_map(|(field, value)| {
let trimmed = field.as_ref().trim().trim_matches('"').trim_matches('\''); let trimmed = field.as_str().trim().trim_matches('"').trim_matches('\'');
if trimmed.is_empty() || !value.is_finite() { if trimmed.is_empty() || !value.is_finite() {
None None
} else if trimmed == field.as_ref() } else if trimmed == field.as_str()
&& trimmed.bytes().all(|byte| !byte.is_ascii_uppercase()) && trimmed.bytes().all(|byte| !byte.is_ascii_uppercase())
{ {
Some((field, value)) Some((field, value))
} else { } else {
Some((Cow::Owned(trimmed.to_ascii_lowercase()), value)) Some((CompactString::from(trimmed.to_ascii_lowercase()), value))
} }
}) })
.collect(); .collect();
@@ -4447,6 +4450,38 @@ fn normalize_history_frequency(frequency: &str) -> Option<String> {
} }
} }
fn normalize_daily_snapshot_bundle(
mut bundle: DailySnapshotBundle,
) -> Result<DailySnapshotBundle, DataSetError> {
let date = bundle.date;
if bundle.benchmark.date != date {
return Err(DataSetError::InvalidDailyBundleComponentDate {
kind: "benchmark",
bundle_date: date,
row_date: bundle.benchmark.date,
symbol: bundle.benchmark.benchmark.clone(),
});
}
validate_daily_bundle_component_dates(
&bundle.market, date, "market", |row| row.date, |row| row.symbol.as_str(),
)?;
validate_daily_bundle_component_dates(
&bundle.factors, date, "factor", |row| row.date, |row| row.symbol.as_str(),
)?;
validate_daily_bundle_component_dates(
&bundle.candidates, date, "candidate", |row| row.date, |row| row.symbol.as_str(),
)?;
validate_daily_bundle_component_dates(
&bundle.corporate_actions, date, "corporate_action", |row| row.date,
|row| row.symbol.as_str(),
)?;
sort_rows_by_symbol_if_needed(&mut bundle.market, |row| row.symbol.as_str());
bundle.factors = normalize_factor_snapshots(bundle.factors)?;
sort_rows_by_symbol_if_needed(&mut bundle.factors, |row| row.symbol.as_str());
sort_rows_by_symbol_if_needed(&mut bundle.candidates, |row| row.symbol.as_str());
Ok(bundle)
}
fn validate_daily_bundle_component_dates<T, D, S>( fn validate_daily_bundle_component_dates<T, D, S>(
rows: &[T], rows: &[T],
bundle_date: NaiveDate, bundle_date: NaiveDate,
@@ -4507,7 +4542,7 @@ fn build_symbol_id_index(
factor_by_date: &BTreeMap<NaiveDate, Vec<DailyFactorSnapshot>>, factor_by_date: &BTreeMap<NaiveDate, Vec<DailyFactorSnapshot>>,
candidate_by_date: &BTreeMap<NaiveDate, Vec<CandidateEligibility>>, candidate_by_date: &BTreeMap<NaiveDate, Vec<CandidateEligibility>>,
) -> AHashMap<String, u32> { ) -> AHashMap<String, u32> {
let mut symbols = instruments.keys().cloned().collect::<HashSet<_>>(); let mut symbols = instruments.keys().cloned().collect::<AHashSet<_>>();
for rows in market_by_date.values() { for rows in market_by_date.values() {
for row in rows { for row in rows {
if !symbols.contains(row.symbol.as_str()) { if !symbols.contains(row.symbol.as_str()) {
@@ -4549,10 +4584,11 @@ fn build_group_symbol_ids<T, F>(
symbol_of: F, symbol_of: F,
) -> BTreeMap<NaiveDate, Vec<u32>> ) -> BTreeMap<NaiveDate, Vec<u32>>
where where
F: Fn(&T) -> &str + Copy, T: Sync,
F: Fn(&T) -> &str + Copy + Send + Sync,
{ {
groups groups
.iter() .par_iter()
.map(|(date, rows)| { .map(|(date, rows)| {
let symbol_ids = rows let symbol_ids = rows
.iter() .iter()
@@ -4565,6 +4601,8 @@ where
debug_assert!(symbol_ids.windows(2).all(|window| window[0] < window[1])); debug_assert!(symbol_ids.windows(2).all(|window| window[0] < window[1]));
(*date, symbol_ids) (*date, symbol_ids)
}) })
.collect::<Vec<_>>()
.into_iter()
.collect() .collect()
} }
@@ -4644,7 +4682,7 @@ fn build_factor_market_cap_order(
.collect() .collect()
} }
fn build_dense_row_positions<T>( fn build_dense_row_positions<T: Sync>(
groups: &BTreeMap<NaiveDate, Vec<T>>, groups: &BTreeMap<NaiveDate, Vec<T>>,
symbol_ids_by_date: &BTreeMap<NaiveDate, Vec<u32>>, symbol_ids_by_date: &BTreeMap<NaiveDate, Vec<u32>>,
symbol_count: usize, symbol_count: usize,
@@ -4655,23 +4693,27 @@ fn build_dense_row_positions<T>(
return None; return None;
} }
let mut positions_by_date = BTreeMap::new(); // Each task owns one bounded day index. No partial index is published if
for (date, rows) in groups { // any day has a missing, duplicate, or misaligned symbol identifier.
let symbol_ids = symbol_ids_by_date.get(date)?; groups
if rows.len() != symbol_ids.len() { .par_iter()
return None; .map(|(date, rows)| {
} let symbol_ids = symbol_ids_by_date.get(date)?;
let mut positions = vec![MISSING_ROW_POSITION; symbol_count]; if rows.len() != symbol_ids.len() {
for (row_index, symbol_id) in symbol_ids.iter().copied().enumerate() {
let position = positions.get_mut(usize::try_from(symbol_id).ok()?)?;
if *position != MISSING_ROW_POSITION {
return None; return None;
} }
*position = u32::try_from(row_index).ok()?; let mut positions = vec![MISSING_ROW_POSITION; symbol_count];
} for (row_index, symbol_id) in symbol_ids.iter().copied().enumerate() {
positions_by_date.insert(*date, positions); let position = positions.get_mut(usize::try_from(symbol_id).ok()?)?;
} if *position != MISSING_ROW_POSITION {
Some(positions_by_date) return None;
}
*position = u32::try_from(row_index).ok()?;
}
Some((*date, positions))
})
.collect::<Option<Vec<_>>>()
.map(|days| days.into_iter().collect())
} }
fn build_calendar_series_end_positions( fn build_calendar_series_end_positions(
@@ -5158,7 +5200,7 @@ mod tests {
&run_data.execution_quote_dates &run_data.execution_quote_dates
)); ));
run_data.add_execution_quotes(vec![IntradayExecutionQuote { run_data.add_execution_quotes(vec![IntradayExecutionQuote { observation_kind: Default::default(),
date, date,
timestamp: NaiveDateTime::parse_from_str("2025-01-02 10:18:00", "%Y-%m-%d %H:%M:%S") timestamp: NaiveDateTime::parse_from_str("2025-01-02 10:18:00", "%Y-%m-%d %H:%M:%S")
.unwrap(), .unwrap(),
@@ -5301,7 +5343,7 @@ mod tests {
vec![benchmark_row("2025-01-02", 12.0)], vec![benchmark_row("2025-01-02", 12.0)],
) )
.unwrap(); .unwrap();
let quote = IntradayExecutionQuote { let quote = IntradayExecutionQuote { observation_kind: Default::default(),
date, date,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".to_string(),
timestamp, timestamp,
@@ -5403,7 +5445,7 @@ mod tests {
successor_cash: None, successor_cash: None,
}; };
corporate_actions.push(corporate_action.clone()); corporate_actions.push(corporate_action.clone());
execution_quotes.push(IntradayExecutionQuote { execution_quotes.push(IntradayExecutionQuote { observation_kind: Default::default(),
date, date,
symbol: symbols[0].to_string(), symbol: symbols[0].to_string(),
timestamp: date.and_hms_opt(10, 18, 0).unwrap(), timestamp: date.and_hms_opt(10, 18, 0).unwrap(),
@@ -5443,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 {
@@ -5517,6 +5563,122 @@ mod tests {
)); ));
} }
#[test]
fn parallel_daily_bundle_validation_keeps_earliest_error_and_component_order() {
let bundles = || (2..30).rev().map(|day| {
let date = NaiveDate::from_ymd_opt(2025, 1, day).unwrap();
let mut benchmark = benchmark_row("2025-01-01", 20.0);
benchmark.date = date;
DailySnapshotBundle {
date, benchmark,
market: vec![market_row("2025-01-01", 10.0, 100)],
factors: Vec::new(), candidates: Vec::new(), corporate_actions: Vec::new(),
}
}).collect::<Vec<_>>();
for threads in [1, 2, 8] {
let pool = rayon::ThreadPoolBuilder::new().num_threads(threads).build().unwrap();
for _ in 0..4 {
let result = pool.install(|| DataSet::from_daily_bundles_with_execution_quotes(
Vec::new(), bundles(), Vec::new(),
));
assert!(matches!(result, Err(DataSetError::InvalidDailyBundleComponentDate {
kind: "market", bundle_date, ..
}) if bundle_date == NaiveDate::from_ymd_opt(2025, 1, 2).unwrap()));
}
let mut values = bundles();
values.last_mut().unwrap().benchmark.date = NaiveDate::from_ymd_opt(2025, 1, 1).unwrap();
let result = pool.install(|| DataSet::from_daily_bundles_with_execution_quotes(
Vec::new(), values, Vec::new(),
));
assert!(matches!(result, Err(DataSetError::InvalidDailyBundleComponentDate {
kind: "benchmark", bundle_date, ..
}) if bundle_date == NaiveDate::from_ymd_opt(2025, 1, 2).unwrap()));
}
}
#[test]
fn parallel_daily_symbol_indices_match_scalar_for_sparse_and_empty_days() {
let symbols = ["000001.SZ", "159915.SZ", "600000.SH", "932000.CSI", "custom-long-instrument"];
let index = symbols.iter().enumerate()
.map(|(id, symbol)| (symbol.to_string(), id as u32))
.collect::<AHashMap<_, _>>();
let groups = (1..29).map(|day| {
let date = NaiveDate::from_ymd_opt(2025, 1, day).unwrap();
let rows = symbols.iter().enumerate()
.filter(|(id, _)| day % 7 != 0 && (*id + day as usize) % 3 != 0)
.map(|(_, symbol)| symbol.to_string()).collect::<Vec<_>>();
(date, rows)
}).collect::<BTreeMap<_, _>>();
let expected_ids = groups.iter().map(|(date, rows)| {
(*date, rows.iter().map(|symbol| index[symbol]).collect::<Vec<_>>())
}).collect::<BTreeMap<_, _>>();
let expected_positions = expected_ids.iter().map(|(date, ids)| {
let mut positions = vec![super::MISSING_ROW_POSITION; symbols.len()];
for (row, id) in ids.iter().enumerate() { positions[*id as usize] = row as u32; }
(*date, positions)
}).collect::<BTreeMap<_, _>>();
for threads in [1, 2, 8] {
rayon::ThreadPoolBuilder::new().num_threads(threads).build().unwrap().install(|| {
let ids = super::build_group_symbol_ids(&groups, &index, String::as_str);
assert_eq!(ids, expected_ids);
assert_eq!(super::build_dense_row_positions(&groups, &ids, symbols.len()), Some(expected_positions.clone()));
});
}
}
#[test]
fn parallel_dense_index_rejects_invalid_days_without_publishing_partial_index() {
let day1 = NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
let day2 = NaiveDate::from_ymd_opt(2025, 1, 3).unwrap();
let groups = BTreeMap::from([(day1, vec![0, 1]), (day2, vec![0, 1])]);
let valid = BTreeMap::from([(day1, vec![0, 2]), (day2, vec![1, 2])]);
for threads in [1, 2, 8] {
rayon::ThreadPoolBuilder::new().num_threads(threads).build().unwrap().install(|| {
for invalid in [vec![], vec![1], vec![1, 1], vec![1, 3], vec![1, u32::MAX]] {
let mut ids = valid.clone();
ids.insert(day2, invalid);
assert!(super::build_dense_row_positions(&groups, &ids, 3).is_none());
}
let mut missing = valid.clone();
missing.remove(&day2);
assert!(super::build_dense_row_positions(&groups, &missing, 3).is_none());
assert!(super::build_dense_row_positions(&groups, &valid, usize::MAX).is_none());
assert!(super::build_dense_row_positions(&groups, &valid, super::MAX_DENSE_ROW_INDEX_BYTES).is_none());
});
}
}
#[test]
fn symbol_id_union_preserves_lexical_order_and_all_component_sources() {
let date = NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
let instrument = Instrument {
symbol: "932000.CSI".into(), name: "index".into(), board: "CSI".into(),
round_lot: 100, listed_at: None, delisted_at: None, status: "active".into(),
};
let mut market = market_row("2025-01-02", -0.0, 0);
market.symbol = "custom-long-instrument".into();
let factor = DailyFactorSnapshot {
date, symbol: "159915.SZ".into(), market_cap_bn: 0.0, free_float_cap_bn: 0.0,
pe_ttm: 0.0, turnover_ratio: None, effective_turnover_ratio: None,
adjustment_factor_backward1: None, extra_factors: NumericFactorMap::new(),
};
let candidate = CandidateEligibility {
date, symbol: "000001.SZ".into(), is_st: true, is_star_st: true,
is_new_listing: false, is_paused: true, allow_buy: false, allow_sell: false,
is_kcb: false, is_one_yuan: false, risk_level_code: Some("test".into()),
};
let ids = super::build_symbol_id_index(
&HashMap::from([(instrument.symbol.clone(), instrument)]),
&BTreeMap::from([(date, vec![market.clone(), market])]),
&BTreeMap::from([(date, vec![factor])]),
&BTreeMap::from([(date, vec![candidate])]),
);
assert_eq!(ids, AHashMap::from_iter([
("000001.SZ".to_string(), 0), ("159915.SZ".to_string(), 1),
("932000.CSI".to_string(), 2), ("custom-long-instrument".to_string(), 3),
]));
}
#[test] #[test]
fn direct_symbol_id_snapshot_lookups_preserve_alignment_for_sparse_rows() { fn direct_symbol_id_snapshot_lookups_preserve_alignment_for_sparse_rows() {
let date = NaiveDate::parse_from_str("2025-01-02", "%Y-%m-%d").unwrap(); let date = NaiveDate::parse_from_str("2025-01-02", "%Y-%m-%d").unwrap();
@@ -6114,7 +6276,7 @@ mod tests {
vec![benchmark_row("2025-01-02", 12.0)], vec![benchmark_row("2025-01-02", 12.0)],
) )
.unwrap(); .unwrap();
let quote = |symbol: &str, time: &str| IntradayExecutionQuote { let quote = |symbol: &str, time: &str| IntradayExecutionQuote { observation_kind: Default::default(),
date, date,
timestamp: NaiveDateTime::parse_from_str( timestamp: NaiveDateTime::parse_from_str(
&format!("2025-01-02 {time}"), &format!("2025-01-02 {time}"),
@@ -6193,7 +6355,7 @@ mod tests {
#[test] #[test]
fn shared_execution_quote_release_does_not_clone_the_base_map() { fn shared_execution_quote_release_does_not_clone_the_base_map() {
let date = NaiveDate::parse_from_str("2025-01-02", "%Y-%m-%d").unwrap(); let date = NaiveDate::parse_from_str("2025-01-02", "%Y-%m-%d").unwrap();
let quote = IntradayExecutionQuote { let quote = IntradayExecutionQuote { observation_kind: Default::default(),
date, date,
timestamp: NaiveDateTime::parse_from_str("2025-01-02 10:18:00", "%Y-%m-%d %H:%M:%S") timestamp: NaiveDateTime::parse_from_str("2025-01-02 10:18:00", "%Y-%m-%d %H:%M:%S")
.unwrap(), .unwrap(),
@@ -6323,10 +6485,8 @@ mod tests {
extra_factors: From::from([(Cow::Borrowed("amount"), 10.0)]), extra_factors: From::from([(Cow::Borrowed("amount"), 10.0)]),
}]) }])
.expect("normalize clean factor snapshot"); .expect("normalize clean factor snapshot");
assert!(matches!( assert_eq!(clean[0].extra_factors.keys().next().map(CompactString::as_str), Some("amount"));
clean[0].extra_factors.keys().next(), assert!(!clean[0].extra_factors.keys().next().unwrap().is_heap_allocated());
Some(Cow::Borrowed("amount"))
));
let dirty = normalize_factor_snapshots(vec![DailyFactorSnapshot { let dirty = normalize_factor_snapshots(vec![DailyFactorSnapshot {
date, date,
+43 -55
View File
@@ -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
@@ -746,6 +735,15 @@ where
if self.execution_quote_loader.is_none() { if self.execution_quote_loader.is_none() {
return Ok(()); return Ok(());
} }
let cumulative_conditions = decision.order_intents.iter().any(|intent| {
matches!(intent.unwrapped(), OrderIntent::StockPool { contract }
if crate::stock_pool_quote_facts::requires_session_totals(&contract.rule))
});
if cumulative_conditions && (self.broker.execution_price_field() != PriceField::Close
|| start_time.is_some() || self.broker.intraday_execution_start_time().is_some()) {
let mut symbols = execution_quote_symbols_for_decision(decision, portfolio, open_orders);
self.load_missing_execution_quotes(execution_date, None, None, &mut symbols)?;
}
let submission_time = start_time.or_else(|| self.broker.intraday_execution_start_time()); let submission_time = start_time.or_else(|| self.broker.intraday_execution_start_time());
let post_close_window = self.broker.post_close_execution_quote_window_for_order( let post_close_window = self.broker.post_close_execution_quote_window_for_order(
execution_date, execution_date,
@@ -2611,46 +2609,31 @@ 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 let decision_quote_symbols =
.preplanned_decision_quote_symbols_by_date self.strategy.decision_quote_symbols(&StrategyContext {
.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, execution_date,
decision_quote_symbols, decision_date,
&decision_quote_times, decision_index,
)?; data: &self.data,
} else { portfolio: &portfolio,
let decision_quote_symbols = futures_account: self.futures_account.as_ref(),
self.strategy.decision_quote_symbols(&StrategyContext { open_orders: &on_day_open_orders,
execution_date, dynamic_universe: self.dynamic_universe.as_ref(),
subscriptions: &self.subscriptions,
process_events: &process_events,
active_process_event: None,
active_datetime: stage_datetime(
decision_date, decision_date,
decision_index, default_stage_time(ScheduleStage::OnDay),
data: &self.data, ),
portfolio: &portfolio, order_events: result.order_events.as_slice(),
futures_account: self.futures_account.as_ref(), fills: result.fills.as_slice(),
open_orders: &on_day_open_orders, })?;
dynamic_universe: self.dynamic_universe.as_ref(), self.ensure_execution_quotes_for_symbols_at_times(
subscriptions: &self.subscriptions, execution_date,
process_events: &process_events, &decision_quote_symbols,
active_process_event: None, &decision_quote_times,
active_datetime: stage_datetime( )?;
decision_date,
default_stage_time(ScheduleStage::OnDay),
),
order_events: result.order_events.as_slice(),
fills: result.fills.as_slice(),
})?;
self.ensure_execution_quotes_for_symbols_at_times(
execution_date,
&decision_quote_symbols,
&decision_quote_times,
)?;
}
} }
self.ensure_execution_quotes_for_portfolio_times( self.ensure_execution_quotes_for_portfolio_times(
execution_date, execution_date,
@@ -2879,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();
@@ -2971,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;
} }
@@ -3152,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() {
@@ -3175,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,
@@ -5796,7 +5784,7 @@ mod tests {
fn physical_on_day_rules_keep_each_actual_submission_time() { fn physical_on_day_rules_keep_each_actual_submission_time() {
let date = d(2026, 7, 6); let date = d(2026, 7, 6);
let quotes = vec![ let quotes = vec![
IntradayExecutionQuote { IntradayExecutionQuote { observation_kind: Default::default(),
date, date,
symbol: SYMBOL.to_string(), symbol: SYMBOL.to_string(),
timestamp: date.and_hms_opt(10, 18, 0).expect("morning timestamp"), timestamp: date.and_hms_opt(10, 18, 0).expect("morning timestamp"),
@@ -5809,7 +5797,7 @@ mod tests {
amount_delta: 110_000.0, amount_delta: 110_000.0,
trading_phase: Some("continuous_auction".to_string()), trading_phase: Some("continuous_auction".to_string()),
}, },
IntradayExecutionQuote { IntradayExecutionQuote { observation_kind: Default::default(),
date, date,
symbol: SYMBOL.to_string(), symbol: SYMBOL.to_string(),
timestamp: date.and_hms_opt(10, 19, 0).expect("future timestamp"), timestamp: date.and_hms_opt(10, 19, 0).expect("future timestamp"),
@@ -5822,7 +5810,7 @@ mod tests {
amount_delta: 990_000.0, amount_delta: 990_000.0,
trading_phase: Some("continuous_auction".to_string()), trading_phase: Some("continuous_auction".to_string()),
}, },
IntradayExecutionQuote { IntradayExecutionQuote { observation_kind: Default::default(),
date, date,
symbol: SYMBOL.to_string(), symbol: SYMBOL.to_string(),
timestamp: date.and_hms_opt(15, 10, 0).expect("post-close timestamp"), timestamp: date.and_hms_opt(15, 10, 0).expect("post-close timestamp"),
@@ -5950,7 +5938,7 @@ mod tests {
let closing_only = matches!(scenario,2|3); let closing_only = matches!(scenario,2|3);
let delayed = scenario == 4; let delayed = scenario == 4;
let date = if closing_only { d(2026, 7, 6) } else if delayed { d(2026, 6, 2) } else { d(2026, 6, 1) }; let date = if closing_only { d(2026, 7, 6) } else if delayed { d(2026, 6, 2) } else { d(2026, 6, 1) };
let quote = |hour, minute, price| IntradayExecutionQuote { let quote = |hour, minute, price| IntradayExecutionQuote { observation_kind: Default::default(),
date, symbol: SYMBOL.into(), timestamp: date.and_hms_opt(hour, minute, 0).unwrap(), date, symbol: SYMBOL.into(), timestamp: date.and_hms_opt(hour, minute, 0).unwrap(),
last_price: price, bid1: price, ask1: price, bid1_volume: 10_000, ask1_volume: 10_000, last_price: price, bid1: price, ask1: price, bid1_volume: 10_000, ask1_volume: 10_000,
volume_delta: 10_000, amount_delta: price * 10_000.0, trading_phase: None, volume_delta: 10_000, amount_delta: price * 10_000.0, trading_phase: None,
@@ -6073,7 +6061,7 @@ mod tests {
Ok(request Ok(request
.symbols .symbols
.into_iter() .into_iter()
.map(|symbol| IntradayExecutionQuote { .map(|symbol| IntradayExecutionQuote { observation_kind: Default::default(),
date: request.date, date: request.date,
symbol, symbol,
timestamp: request.date.and_hms_opt(15, 5, 0).expect("valid timestamp"), timestamp: request.date.and_hms_opt(15, 5, 0).expect("valid timestamp"),
+2
View File
@@ -26,6 +26,7 @@ pub mod platform_runtime_schema;
pub mod platform_strategy_spec; pub mod platform_strategy_spec;
pub mod portfolio; pub mod portfolio;
pub mod portfolio_loss; pub mod portfolio_loss;
pub mod position_exposure;
pub mod risk_control; pub mod risk_control;
pub mod rules; pub mod rules;
pub mod scheduler; pub mod scheduler;
@@ -37,6 +38,7 @@ pub mod stock_pool_execution;
pub mod stock_pool_index_policy; pub mod stock_pool_index_policy;
pub mod stock_pool_market_cap; pub mod stock_pool_market_cap;
pub mod stock_pool_state; pub mod stock_pool_state;
pub mod stock_pool_quote_facts;
pub mod signal_contract; pub mod signal_contract;
pub mod strategy_ai; pub mod strategy_ai;
pub mod universe; pub mod universe;
+103 -27
View File
@@ -3,6 +3,7 @@ use std::collections::BTreeMap;
use std::fmt; use std::fmt;
use std::ops::Index; use std::ops::Index;
use compact_str::CompactString;
use serde::de::{MapAccess, Visitor}; use serde::de::{MapAccess, Visitor};
use serde::ser::SerializeMap; use serde::ser::SerializeMap;
use serde::{Deserialize, Deserializer, Serialize, Serializer}; use serde::{Deserialize, Deserializer, Serialize, Serializer};
@@ -10,7 +11,14 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer};
/// Sorted numeric fields stored contiguously, without a tree node per snapshot. /// Sorted numeric fields stored contiguously, without a tree node per snapshot.
#[derive(Clone, Default, PartialEq)] #[derive(Clone, Default, PartialEq)]
pub struct NumericFactorMap { pub struct NumericFactorMap {
entries: Vec<(Cow<'static, str>, f64)>, entries: Vec<(CompactString, f64)>,
}
fn compact_key(key: Cow<'static, str>) -> CompactString {
match key {
Cow::Borrowed(value) => CompactString::const_new(value),
Cow::Owned(value) => CompactString::from(value),
}
} }
impl NumericFactorMap { impl NumericFactorMap {
@@ -30,16 +38,21 @@ 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_ref().cmp(key)) .binary_search_by(|(name, _)| name.as_str().cmp(key))
.ok() .ok()
.map(|index| &self.entries[index].1) .map(|index| &self.entries[index].1)
} }
pub fn get_mut(&mut self, key: &str) -> Option<&mut f64> { pub fn get_mut(&mut self, key: &str) -> Option<&mut f64> {
self.entries self.entries
.binary_search_by(|(name, _)| name.as_ref().cmp(key)) .binary_search_by(|(name, _)| name.as_str().cmp(key))
.ok() .ok()
.map(|index| &mut self.entries[index].1) .map(|index| &mut self.entries[index].1)
} }
@@ -49,17 +62,21 @@ impl NumericFactorMap {
} }
pub fn insert(&mut self, key: Cow<'static, str>, value: f64) -> Option<f64> { pub fn insert(&mut self, key: Cow<'static, str>, value: f64) -> Option<f64> {
self.insert_compact(compact_key(key), value)
}
pub fn insert_compact(&mut self, key: CompactString, value: f64) -> Option<f64> {
if self if self
.entries .entries
.last() .last()
.is_none_or(|(last, _)| last.as_ref() < key.as_ref()) .is_none_or(|(last, _)| last.as_str() < key.as_str())
{ {
self.entries.push((key, value)); self.entries.push((key, value));
return None; return None;
} }
match self match self
.entries .entries
.binary_search_by(|(name, _)| name.as_ref().cmp(key.as_ref())) .binary_search_by(|(name, _)| name.as_str().cmp(key.as_str()))
{ {
Ok(index) => Some(std::mem::replace(&mut self.entries[index].1, value)), Ok(index) => Some(std::mem::replace(&mut self.entries[index].1, value)),
Err(index) => { Err(index) => {
@@ -71,19 +88,19 @@ impl NumericFactorMap {
pub fn remove(&mut self, key: &str) -> Option<f64> { pub fn remove(&mut self, key: &str) -> Option<f64> {
self.entries self.entries
.binary_search_by(|(name, _)| name.as_ref().cmp(key)) .binary_search_by(|(name, _)| name.as_str().cmp(key))
.ok() .ok()
.map(|index| self.entries.remove(index).1) .map(|index| self.entries.remove(index).1)
} }
pub fn retain(&mut self, mut keep: impl FnMut(&Cow<'static, str>, &mut f64) -> bool) { pub fn retain(&mut self, mut keep: impl FnMut(&CompactString, &mut f64) -> bool) {
self.entries.retain_mut(|(key, value)| keep(key, value)); self.entries.retain_mut(|(key, value)| keep(key, value));
} }
pub fn iter(&self) -> Iter<'_> { pub fn iter(&self) -> Iter<'_> {
Iter(self.entries.iter()) Iter(self.entries.iter())
} }
pub fn keys(&self) -> impl DoubleEndedIterator<Item = &Cow<'static, str>> + ExactSizeIterator { pub fn keys(&self) -> impl DoubleEndedIterator<Item = &CompactString> + ExactSizeIterator {
self.entries.iter().map(|(key, _)| key) self.entries.iter().map(|(key, _)| key)
} }
pub fn values(&self) -> impl DoubleEndedIterator<Item = &f64> + ExactSizeIterator { pub fn values(&self) -> impl DoubleEndedIterator<Item = &f64> + ExactSizeIterator {
@@ -104,9 +121,9 @@ impl Index<&str> for NumericFactorMap {
} }
} }
pub struct Iter<'a>(std::slice::Iter<'a, (Cow<'static, str>, f64)>); pub struct Iter<'a>(std::slice::Iter<'a, (CompactString, f64)>);
impl<'a> Iterator for Iter<'a> { impl<'a> Iterator for Iter<'a> {
type Item = (&'a Cow<'static, str>, &'a f64); type Item = (&'a CompactString, &'a f64);
fn next(&mut self) -> Option<Self::Item> { fn next(&mut self) -> Option<Self::Item> {
self.0.next().map(|(k, v)| (k, v)) self.0.next().map(|(k, v)| (k, v))
} }
@@ -121,14 +138,14 @@ impl DoubleEndedIterator for Iter<'_> {
} }
impl ExactSizeIterator for Iter<'_> {} impl ExactSizeIterator for Iter<'_> {}
impl<'a> IntoIterator for &'a NumericFactorMap { impl<'a> IntoIterator for &'a NumericFactorMap {
type Item = (&'a Cow<'static, str>, &'a f64); type Item = (&'a CompactString, &'a f64);
type IntoIter = Iter<'a>; type IntoIter = Iter<'a>;
fn into_iter(self) -> Self::IntoIter { fn into_iter(self) -> Self::IntoIter {
self.iter() self.iter()
} }
} }
impl IntoIterator for NumericFactorMap { impl IntoIterator for NumericFactorMap {
type Item = (Cow<'static, str>, f64); type Item = (CompactString, f64);
type IntoIter = std::vec::IntoIter<Self::Item>; type IntoIter = std::vec::IntoIter<Self::Item>;
fn into_iter(self) -> Self::IntoIter { fn into_iter(self) -> Self::IntoIter {
self.entries.into_iter() self.entries.into_iter()
@@ -137,6 +154,11 @@ impl IntoIterator for NumericFactorMap {
impl FromIterator<(Cow<'static, str>, f64)> for NumericFactorMap { impl FromIterator<(Cow<'static, str>, f64)> for NumericFactorMap {
fn from_iter<T: IntoIterator<Item = (Cow<'static, str>, f64)>>(iter: T) -> Self { fn from_iter<T: IntoIterator<Item = (Cow<'static, str>, f64)>>(iter: T) -> Self {
iter.into_iter().map(|(key, value)| (compact_key(key), value)).collect()
}
}
impl FromIterator<(CompactString, f64)> for NumericFactorMap {
fn from_iter<T: IntoIterator<Item = (CompactString, f64)>>(iter: T) -> Self {
let mut entries: Vec<_> = iter.into_iter().collect(); let mut entries: Vec<_> = iter.into_iter().collect();
// Stable sorting preserves last-value-wins for repeated input keys. // Stable sorting preserves last-value-wins for repeated input keys.
if !entries.windows(2).all(|pair| pair[0].0 <= pair[1].0) { if !entries.windows(2).all(|pair| pair[0].0 <= pair[1].0) {
@@ -155,6 +177,11 @@ impl FromIterator<(Cow<'static, str>, f64)> for NumericFactorMap {
} }
impl Extend<(Cow<'static, str>, f64)> for NumericFactorMap { impl Extend<(Cow<'static, str>, f64)> for NumericFactorMap {
fn extend<T: IntoIterator<Item = (Cow<'static, str>, f64)>>(&mut self, iter: T) { fn extend<T: IntoIterator<Item = (Cow<'static, str>, f64)>>(&mut self, iter: T) {
self.extend(iter.into_iter().map(|(key, value)| (compact_key(key), value)));
}
}
impl Extend<(CompactString, f64)> for NumericFactorMap {
fn extend<T: IntoIterator<Item = (CompactString, f64)>>(&mut self, iter: T) {
let mut incoming: Self = iter.into_iter().collect(); let mut incoming: Self = iter.into_iter().collect();
if incoming.is_empty() { if incoming.is_empty() {
return; return;
@@ -194,9 +221,7 @@ impl<const N: usize> From<[(Cow<'static, str>, f64); N]> for NumericFactorMap {
} }
impl From<BTreeMap<Cow<'static, str>, f64>> for NumericFactorMap { impl From<BTreeMap<Cow<'static, str>, f64>> for NumericFactorMap {
fn from(entries: BTreeMap<Cow<'static, str>, f64>) -> Self { fn from(entries: BTreeMap<Cow<'static, str>, f64>) -> Self {
Self { entries.into_iter().collect()
entries: entries.into_iter().collect(),
}
} }
} }
@@ -219,8 +244,8 @@ impl<'de> Deserialize<'de> for NumericFactorMap {
} }
fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<Self::Value, A::Error> { fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<Self::Value, A::Error> {
let mut entries = Vec::new(); let mut entries = Vec::new();
while let Some((key, value)) = map.next_entry::<String, f64>()? { while let Some((key, value)) = map.next_entry::<CompactString, f64>()? {
entries.push((Cow::Owned(key), value)); entries.push((key, value));
} }
Ok(entries.into_iter().collect()) Ok(entries.into_iter().collect())
} }
@@ -233,6 +258,56 @@ 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]
fn compact_keys_inline_dynamic_names_and_keep_long_static_storage() {
const LONG: &str = "a_long_static_factor_identifier_that_must_remain_borrowed";
let map = NumericFactorMap::from([
(Cow::Owned("dynamic_factor_20".to_owned()), -0.0),
(Cow::Borrowed(LONG), 1.0),
]);
let cloned = map.clone();
let short = cloned.keys().find(|key| key.as_str() == "dynamic_factor_20").unwrap();
assert!(!short.is_heap_allocated());
let long = cloned.keys().find(|key| key.as_str() == LONG).unwrap();
assert_eq!(long.as_static_str(), Some(LONG));
assert_eq!(cloned["dynamic_factor_20"].to_bits(), (-0.0_f64).to_bits());
assert_eq!(std::mem::size_of::<(CompactString, f64)>(), std::mem::size_of::<(Cow<'static, str>, f64)>());
}
#[test]
fn long_dynamic_unicode_and_short_keys_keep_the_same_json_map() {
let entries = ["", "a", "a_field_longer_than_the_inline_string_capacity", "价格因子", "ths_up_days_stock"]
.into_iter().enumerate().map(|(index, key)| (Cow::Owned(key.to_string()), index as f64 + 0.25))
.collect::<BTreeMap<_, _>>();
let map = NumericFactorMap::from(entries.clone());
assert_eq!(serde_json::to_string(&map).unwrap(), serde_json::to_string(&entries).unwrap());
let decoded: NumericFactorMap = serde_json::from_str(&serde_json::to_string(&map).unwrap()).unwrap();
assert_eq!(decoded, map);
assert!(!decoded.keys().find(|key| key.as_str() == "ths_up_days_stock").unwrap().is_heap_allocated());
}
#[test] #[test]
fn updates_order_removal_and_values_match_tree_map() { fn updates_order_removal_and_values_match_tree_map() {
let mut flat = NumericFactorMap::new(); let mut flat = NumericFactorMap::new();
@@ -249,14 +324,14 @@ mod tests {
} }
} }
assert_eq!( assert_eq!(
flat.iter().collect::<Vec<_>>(), flat.iter().map(|(key, value)| (key.as_str(), value)).collect::<Vec<_>>(),
tree.iter().collect::<Vec<_>>() tree.iter().map(|(key, value)| (key.as_ref(), value)).collect::<Vec<_>>()
); );
flat.retain(|_, value| *value > 100.0); flat.retain(|_, value| *value > 100.0);
tree.retain(|_, value| *value > 100.0); tree.retain(|_, value| *value > 100.0);
assert_eq!( assert_eq!(
flat.iter().collect::<Vec<_>>(), flat.iter().map(|(key, value)| (key.as_str(), value)).collect::<Vec<_>>(),
tree.iter().collect::<Vec<_>>() tree.iter().map(|(key, value)| (key.as_ref(), value)).collect::<Vec<_>>()
); );
assert_eq!( assert_eq!(
std::mem::size_of::<NumericFactorMap>(), std::mem::size_of::<NumericFactorMap>(),
@@ -275,8 +350,8 @@ mod tests {
let flat: NumericFactorMap = input.clone().into_iter().collect(); let flat: NumericFactorMap = input.clone().into_iter().collect();
let tree: BTreeMap<_, _> = input.into_iter().collect(); let tree: BTreeMap<_, _> = input.into_iter().collect();
assert_eq!( assert_eq!(
flat.iter().collect::<Vec<_>>(), flat.iter().map(|(key, value)| (key.as_str(), value)).collect::<Vec<_>>(),
tree.iter().collect::<Vec<_>>() tree.iter().map(|(key, value)| (key.as_ref(), value)).collect::<Vec<_>>()
); );
assert_eq!(flat["z"], 4.0); assert_eq!(flat["z"], 4.0);
} }
@@ -327,13 +402,14 @@ mod tests {
flat.extend(incoming.clone()); flat.extend(incoming.clone());
tree.extend(incoming); tree.extend(incoming);
assert_eq!( assert_eq!(
flat.iter().collect::<Vec<_>>(), flat.iter().map(|(key, value)| (key.as_str(), value)).collect::<Vec<_>>(),
tree.iter().collect::<Vec<_>>() tree.iter().map(|(key, value)| (key.as_ref(), value)).collect::<Vec<_>>()
); );
assert!(matches!(flat.keys().last(), Some(Cow::Borrowed("shared")))); assert_eq!(flat.keys().last().map(CompactString::as_str), Some("shared"));
assert!(!flat.keys().last().unwrap().is_heap_allocated());
flat.extend([(Cow::Borrowed("zz"), f64::NAN)]); flat.extend([(Cow::Borrowed("zz"), f64::NAN)]);
assert!(flat["zz"].is_nan()); assert!(flat["zz"].is_nan());
flat.extend(std::iter::empty()); flat.extend(std::iter::empty::<(CompactString, f64)>());
assert_eq!(flat.len(), tree.len() + 1); assert_eq!(flat.len(), tree.len() + 1);
} }
} }
+11
View File
@@ -212,6 +212,11 @@ pub fn build_dataset_context(
} }
pub fn specs_in_value(value: &Value) -> Result<Vec<PatternSpec>, String> { pub fn specs_in_value(value: &Value) -> Result<Vec<PatternSpec>, String> {
// A runner bundle also contains source/extract copies. Follow the same
// authoritative spec selection as the execution loader, not those copies.
if let Some(spec) = value.get("strategySpec").or_else(|| value.get("strategy_spec")) {
return specs_in_value(spec);
}
let mut specs = Vec::new(); let mut specs = Vec::new();
match value { match value {
Value::String(text) => specs.extend(crate::daily_patterns::expression_specs(text)?), Value::String(text) => specs.extend(crate::daily_patterns::expression_specs(text)?),
@@ -268,9 +273,15 @@ mod tests {
for (pool_key, source_key) in [("stockPool", "sourceCode"), ("stock_pool", "source_code")] { for (pool_key, source_key) in [("stockPool", "sourceCode"), ("stock_pool", "source_code")] {
let value = json!({pool_key:pool,source_key:source,"runtimeExpressions":{"trading":{"buyFilterExpr":expr}}}); let value = json!({pool_key:pool,source_key:source,"runtimeExpressions":{"trading":{"buyFilterExpr":expr}}});
assert_eq!(specs_in_value(&value).unwrap().len(), 2); assert_eq!(specs_in_value(&value).unwrap().len(), 2);
for wrapper in ["strategySpec", "strategy_spec"] {
let bundle = json!({wrapper:value,"strategy_source":{"source_code":source},
"strategy_extract":{"parameters":{"source_code":source}}});
assert_eq!(specs_in_value(&bundle).unwrap().len(), 2);
}
let mut invalid = value.clone(); let mut invalid = value.clone();
invalid[pool_key]["exit_signals"][0]["when_expr"] = json!("pattern_signal(not-json)"); invalid[pool_key]["exit_signals"][0]["when_expr"] = json!("pattern_signal(not-json)");
assert!(specs_in_value(&invalid).is_err(), "invalid actual conditions must still fail"); assert!(specs_in_value(&invalid).is_err(), "invalid actual conditions must still fail");
assert!(specs_in_value(&json!({"strategySpec":invalid})).is_err());
} }
assert_eq!(specs_in_value(&json!({"sourceCode":format!("risk.stop_loss({expr})")})).unwrap().len(),1); assert_eq!(specs_in_value(&json!({"sourceCode":format!("risk.stop_loss({expr})")})).unwrap().len(),1);
} }
+180 -89
View File
@@ -27,6 +27,7 @@ use crate::numeric_expr_vm::{
self, EvalError as NumericVmEvalError, Program as NumericVmProgram, self, EvalError as NumericVmEvalError, Program as NumericVmProgram,
Scratch as NumericVmScratch, Value as NumericVmValue, ValueType as NumericVmValueType, Scratch as NumericVmScratch, Value as NumericVmValue, ValueType as NumericVmValueType,
}; };
use crate::numeric_factors::NumericFactorMap;
use crate::portfolio::PortfolioState; use crate::portfolio::PortfolioState;
use crate::holding_policy::{AutomaticTradeProtection, AutomaticTradePermission, HoldingLifecycleEvidence}; use crate::holding_policy::{AutomaticTradeProtection, AutomaticTradePermission, HoldingLifecycleEvidence};
@@ -651,6 +652,7 @@ pub struct PlatformExprStrategyConfig {
pub buy_scale_expr: String, pub buy_scale_expr: String,
pub exposure_expr: String, pub exposure_expr: String,
pub position_exposure_schedule: BTreeMap<NaiveDate, f64>, pub position_exposure_schedule: BTreeMap<NaiveDate, f64>,
pub position_exposure_timeline: crate::position_exposure::PositionExposureTimeline,
pub portfolio_drawdown_control: Option<PlatformPortfolioDrawdownControlConfig>, pub portfolio_drawdown_control: Option<PlatformPortfolioDrawdownControlConfig>,
pub portfolio_loss_control: Option<PortfolioLossConfig>, pub portfolio_loss_control: Option<PortfolioLossConfig>,
pub stop_loss_expr: String, pub stop_loss_expr: String,
@@ -740,6 +742,7 @@ impl PlatformExprStrategyConfig {
buy_scale_expr: "1.0".to_string(), buy_scale_expr: "1.0".to_string(),
exposure_expr: "1.0".to_string(), exposure_expr: "1.0".to_string(),
position_exposure_schedule: BTreeMap::new(), position_exposure_schedule: BTreeMap::new(),
position_exposure_timeline: crate::position_exposure::PositionExposureTimeline::default(),
portfolio_drawdown_control: None, portfolio_drawdown_control: None,
portfolio_loss_control: None, portfolio_loss_control: None,
stop_loss_expr: String::new(), stop_loss_expr: String::new(),
@@ -845,6 +848,7 @@ fn band_low(index_close) {
} }
} }
#[cfg(test)]
fn scheduled_position_exposure( fn scheduled_position_exposure(
schedule: &BTreeMap<NaiveDate, f64>, schedule: &BTreeMap<NaiveDate, f64>,
decision_date: NaiveDate, decision_date: NaiveDate,
@@ -957,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>,
@@ -1010,7 +1024,7 @@ struct StockExpressionState {
stock_volume_ma60: f64, stock_volume_ma60: f64,
stock_volume_ma100: f64, stock_volume_ma100: f64,
current_series_end: Option<usize>, current_series_end: Option<usize>,
extra_factors: BTreeMap<String, f64>, extra_factors: NumericFactorMap,
extra_text_factors: BTreeMap<String, String>, extra_text_factors: BTreeMap<String, String>,
} }
@@ -1042,7 +1056,6 @@ impl<'a> StockStateSnapshotSource<'a> for IndexedStockStateSnapshotSource<'a> {
} }
self.data self.data
.market_by_symbol_id(self.factor_date, symbol_id) .market_by_symbol_id(self.factor_date, symbol_id)
.or_else(|| self.execution_market(symbol_id))
} }
fn factor(&self, symbol_id: u32) -> Option<&'a DailyFactorSnapshot> { fn factor(&self, symbol_id: u32) -> Option<&'a DailyFactorSnapshot> {
@@ -1071,7 +1084,6 @@ impl<'a> StockStateSnapshotSource<'a> for ViewStockStateSnapshotSource<'a, '_> {
} }
self.factor self.factor
.market(symbol_id) .market(symbol_id)
.or_else(|| self.execution_market(symbol_id))
} }
fn factor(&self, symbol_id: u32) -> Option<&'a DailyFactorSnapshot> { fn factor(&self, symbol_id: u32) -> Option<&'a DailyFactorSnapshot> {
@@ -4386,25 +4398,23 @@ 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| {
row.extra_factors.keys().map(|key| key.to_string()).chain( row.extra_factors.keys().map(|key| key.as_ref()).chain(
row.adjustment_factor_backward1 row.adjustment_factor_backward1
.map(|_| BACKWARD_ADJUSTMENT_FACTOR_FIELD.to_string()), .map(|_| BACKWARD_ADJUSTMENT_FACTOR_FIELD),
) )
}) }))
.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.clone()) .map(|row| row.field.as_str()))
.collect()
} else { } else {
BTreeSet::new() BTreeSet::new()
}, },
@@ -4736,7 +4746,6 @@ impl PlatformExprStrategy {
symbol: symbol.to_string(), symbol: symbol.to_string(),
}) })
})?; })?;
let feature_market = source.feature_market(symbol_id).unwrap_or(market);
let factor = source.factor(symbol_id).ok_or_else(|| { let factor = source.factor(symbol_id).ok_or_else(|| {
BacktestError::Data(crate::data::DataSetError::MissingSnapshot { BacktestError::Data(crate::data::DataSetError::MissingSnapshot {
kind: "factor", kind: "factor",
@@ -4744,6 +4753,13 @@ impl PlatformExprStrategy {
symbol: symbol.to_string(), symbol: symbol.to_string(),
}) })
})?; })?;
let feature_market = source.feature_market(symbol_id).ok_or_else(|| {
BacktestError::Data(crate::data::DataSetError::MissingSnapshot {
kind: "feature_market",
date: factor_date,
symbol: symbol.to_string(),
})
})?;
let intraday_same_day_factor = self.uses_intraday_execution_quotes() let intraday_same_day_factor = self.uses_intraday_execution_quotes()
&& factor_date == date && factor_date == date
&& !ctx.is_lagged_execution(); && !ctx.is_lagged_execution();
@@ -4894,12 +4910,12 @@ impl PlatformExprStrategy {
.iter() .iter()
.filter(|(field, _)| { .filter(|(field, _)| {
self.stock_extra_factor_map_required self.stock_extra_factor_map_required
|| self.stock_extra_factor_identifiers.contains(field.as_ref()) || self.stock_extra_factor_identifiers.contains(field.as_str())
}) })
.map(|(field, value)| (field.to_string(), *value)) .map(|(field, value)| (field.clone(), *value))
.collect() .collect()
} else { } else {
BTreeMap::new() NumericFactorMap::new()
}; };
if !self.config.completed_session_factor_fields.is_empty() { if !self.config.completed_session_factor_fields.is_empty() {
let visible_date = completed_session_factor_date( let visible_date = completed_session_factor_date(
@@ -4914,7 +4930,7 @@ impl PlatformExprStrategy {
.and_then(|row| row.extra_factors.get(field.as_str())) .and_then(|row| row.extra_factors.get(field.as_str()))
.copied() .copied()
.unwrap_or(f64::NAN); .unwrap_or(f64::NAN);
extra_factors.insert(field.clone(), value); extra_factors.insert(field.clone().into(), value);
} }
} }
} }
@@ -4925,7 +4941,7 @@ impl PlatformExprStrategy {
.contains(BACKWARD_ADJUSTMENT_FACTOR_FIELD)) .contains(BACKWARD_ADJUSTMENT_FACTOR_FIELD))
&& let Some(value) = factor.adjustment_factor_backward1 && let Some(value) = factor.adjustment_factor_backward1
{ {
extra_factors.insert(BACKWARD_ADJUSTMENT_FACTOR_FIELD.to_string(), value); extra_factors.insert(BACKWARD_ADJUSTMENT_FACTOR_FIELD.into(), value);
} }
let state = StockExpressionState { let state = StockExpressionState {
@@ -5642,7 +5658,7 @@ impl PlatformExprStrategy {
Dynamic::from(stock.stock_volume_ma100), Dynamic::from(stock.stock_volume_ma100),
); );
for (key, value) in &stock.extra_factors { for (key, value) in &stock.extra_factors {
factors.insert(key.clone().into(), Dynamic::from(*value)); factors.insert(key.as_str().into(), Dynamic::from(*value));
} }
for (key, value) in &stock.extra_text_factors { for (key, value) in &stock.extra_text_factors {
factors.insert(key.clone().into(), Dynamic::from(value.clone())); factors.insert(key.clone().into(), Dynamic::from(value.clone()));
@@ -8640,9 +8656,9 @@ impl PlatformExprStrategy {
let strategy_exposure = self let strategy_exposure = self
.eval_float(ctx, &self.config.exposure_expr, day, None, None)? .eval_float(ctx, &self.config.exposure_expr, day, None, None)?
.clamp(0.0, 1.0); .clamp(0.0, 1.0);
let risk_on_exposure = scheduled_position_exposure( let risk_on_exposure = self.config.position_exposure_timeline.exposure_at(
&self.config.position_exposure_schedule, portfolio_loss_decision_at(ctx), ctx.execution_date, &self.config.position_exposure_schedule,
ctx.execution_date, strategy_exposure,
) )
.unwrap_or(strategy_exposure) .unwrap_or(strategy_exposure)
.clamp(0.0, 1.0); .clamp(0.0, 1.0);
@@ -9970,6 +9986,12 @@ impl PlatformExprStrategy {
} }
} }
} }
if let Some(bps)=self.config.position_exposure_timeline.scale_at(portfolio_loss_decision_at(ctx)) {
let before=intents.len();
intents=intents.into_iter().map(|intent|crate::position_exposure::scale_explicit_intent(intent,bps,ctx.open_orders))
.collect::<Result<Vec<_>,_>>().map_err(BacktestError::Execution)?.into_iter().flatten().collect();
diagnostics.push(format!("position_override_scale requested_bps={bps} original_intents={before} emitted_intents={}",intents.len()));
}
Ok((intents, diagnostics)) Ok((intents, diagnostics))
} }
@@ -14546,6 +14568,7 @@ mod tests {
use std::sync::Arc; use std::sync::Arc;
use chrono::{NaiveDate, NaiveTime}; use chrono::{NaiveDate, NaiveTime};
use rhai::{Dynamic, Map};
use super::{ use super::{
CompiledRuntimeHelperArgs, PlatformAccountActionKind, PlatformExplicitActionStage, CompiledRuntimeHelperArgs, PlatformAccountActionKind, PlatformExplicitActionStage,
@@ -14573,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);
@@ -14718,7 +14762,7 @@ mod tests {
let date = d(2025, 1, 2); let date = d(2025, 1, 2);
let symbol = "000001.SZ"; let symbol = "000001.SZ";
let parts = single_symbol_platform_data(&[date], symbol).snapshot_components(); let parts = single_symbol_platform_data(&[date], symbol).snapshot_components();
let quotes = [(10, 18, 9.5), (14, 59, 10.5)].into_iter().map(|(hour, minute, price)| IntradayExecutionQuote { let quotes = [(10, 18, 9.5), (14, 59, 10.5)].into_iter().map(|(hour, minute, price)| IntradayExecutionQuote { observation_kind: Default::default(),
date, symbol: symbol.to_string(), timestamp: date.and_hms_opt(hour, minute, 0).unwrap(), date, symbol: symbol.to_string(), timestamp: date.and_hms_opt(hour, minute, 0).unwrap(),
last_price: price, bid1: price, ask1: price, bid1_volume: 1000, ask1_volume: 1000, last_price: price, bid1: price, ask1: price, bid1_volume: 1000, ask1_volume: 1000,
volume_delta: 1000, amount_delta: price * 1000.0, trading_phase: Some("continuous".to_string()), volume_delta: 1000, amount_delta: price * 1000.0, trading_phase: Some("continuous".to_string()),
@@ -17542,6 +17586,18 @@ mod tests {
.stock_state_with_factor_date(&ctx, date, date, present_symbol) .stock_state_with_factor_date(&ctx, date, date, present_symbol)
.expect("factor map stock state"); .expect("factor map stock state");
assert!(map_stock.extra_factors.contains_key("unused_factor")); assert!(map_stock.extra_factors.contains_key("unused_factor"));
let mut numeric_state = (*map_stock).clone();
numeric_state.extra_factors.insert("negative_zero".into(), -0.0);
numeric_state.extra_factors.insert("undefined_value".into(), f64::NAN);
let copied_state = numeric_state.clone();
assert_eq!(copied_state.extra_factors["negative_zero"].to_bits(), (-0.0_f64).to_bits());
assert!(copied_state.extra_factors["undefined_value"].is_nan());
let exposed = copied_state.extra_factors.iter()
.map(|(key, value)| (key.as_str().into(), Dynamic::from(*value)))
.collect::<Map>();
assert_eq!(exposed["negative_zero"].as_float().unwrap().to_bits(), (-0.0_f64).to_bits());
assert!(exposed["undefined_value"].as_float().unwrap().is_nan());
assert!(!exposed.contains_key("missing_factor"));
let map_day = map_strategy let map_day = map_strategy
.day_state(&ctx, date) .day_state(&ctx, date)
.expect("factor map day state"); .expect("factor map day state");
@@ -17846,7 +17902,7 @@ mod tests {
volume: 1_000_000, volume: 1_000_000,
}], }],
Vec::new(), Vec::new(),
vec![IntradayExecutionQuote { vec![IntradayExecutionQuote { observation_kind: Default::default(),
date, date,
symbol: symbol.to_string(), symbol: symbol.to_string(),
timestamp: date.and_hms_opt(10, 40, 0).expect("timestamp"), timestamp: date.and_hms_opt(10, 40, 0).expect("timestamp"),
@@ -17989,7 +18045,7 @@ mod tests {
volume: 1_000_000, volume: 1_000_000,
}], }],
Vec::new(), Vec::new(),
vec![IntradayExecutionQuote { vec![IntradayExecutionQuote { observation_kind: Default::default(),
date, date,
symbol: symbol.to_string(), symbol: symbol.to_string(),
timestamp: date.and_hms_opt(10, 40, 0).expect("timestamp"), timestamp: date.and_hms_opt(10, 40, 0).expect("timestamp"),
@@ -18077,7 +18133,7 @@ mod tests {
lower_limit: 4.50, lower_limit: 4.50,
price_tick: 0.01, price_tick: 0.01,
}; };
let quote = IntradayExecutionQuote { let quote = IntradayExecutionQuote { observation_kind: Default::default(),
date, date,
symbol: symbol.to_string(), symbol: symbol.to_string(),
timestamp: date.and_hms_opt(10, 40, 0).expect("timestamp"), timestamp: date.and_hms_opt(10, 40, 0).expect("timestamp"),
@@ -18209,7 +18265,7 @@ mod tests {
volume: 1_000_000, volume: 1_000_000,
}], }],
Vec::new(), Vec::new(),
vec![IntradayExecutionQuote { vec![IntradayExecutionQuote { observation_kind: Default::default(),
date, date,
symbol: symbol.to_string(), symbol: symbol.to_string(),
timestamp: date.and_hms_opt(10, 40, 0).expect("timestamp"), timestamp: date.and_hms_opt(10, 40, 0).expect("timestamp"),
@@ -18454,7 +18510,7 @@ mod tests {
volume: 1_000_000, volume: 1_000_000,
}], }],
Vec::new(), Vec::new(),
vec![IntradayExecutionQuote { vec![IntradayExecutionQuote { observation_kind: Default::default(),
date, date,
symbol: symbol.to_string(), symbol: symbol.to_string(),
timestamp: date.and_hms_opt(10, 39, 59).unwrap(), timestamp: date.and_hms_opt(10, 39, 59).unwrap(),
@@ -18871,7 +18927,7 @@ mod tests {
], ],
Vec::new(), Vec::new(),
vec![ vec![
IntradayExecutionQuote { IntradayExecutionQuote { observation_kind: Default::default(),
date: first_date, date: first_date,
symbol: symbol.to_string(), symbol: symbol.to_string(),
timestamp: first_date.and_hms_opt(10, 40, 0).expect("valid timestamp"), timestamp: first_date.and_hms_opt(10, 40, 0).expect("valid timestamp"),
@@ -18884,7 +18940,7 @@ mod tests {
amount_delta: 23_990.0, amount_delta: 23_990.0,
trading_phase: Some("continuous".to_string()), trading_phase: Some("continuous".to_string()),
}, },
IntradayExecutionQuote { IntradayExecutionQuote { observation_kind: Default::default(),
date: second_date, date: second_date,
symbol: symbol.to_string(), symbol: symbol.to_string(),
timestamp: second_date.and_hms_opt(10, 31, 0).expect("valid timestamp"), timestamp: second_date.and_hms_opt(10, 31, 0).expect("valid timestamp"),
@@ -19149,7 +19205,7 @@ mod tests {
}], }],
Vec::new(), Vec::new(),
vec![ vec![
IntradayExecutionQuote { IntradayExecutionQuote { observation_kind: Default::default(),
date, date,
symbol: delayed_symbol.to_string(), symbol: delayed_symbol.to_string(),
timestamp: date.and_hms_opt(9, 31, 0).expect("valid timestamp"), timestamp: date.and_hms_opt(9, 31, 0).expect("valid timestamp"),
@@ -19162,7 +19218,7 @@ mod tests {
amount_delta: 146_200.0, amount_delta: 146_200.0,
trading_phase: Some("continuous".to_string()), trading_phase: Some("continuous".to_string()),
}, },
IntradayExecutionQuote { IntradayExecutionQuote { observation_kind: Default::default(),
date, date,
symbol: delayed_symbol.to_string(), symbol: delayed_symbol.to_string(),
timestamp: date.and_hms_opt(10, 18, 0).expect("valid timestamp"), timestamp: date.and_hms_opt(10, 18, 0).expect("valid timestamp"),
@@ -19175,7 +19231,7 @@ mod tests {
amount_delta: 145_000.0, amount_delta: 145_000.0,
trading_phase: Some("continuous".to_string()), trading_phase: Some("continuous".to_string()),
}, },
IntradayExecutionQuote { IntradayExecutionQuote { observation_kind: Default::default(),
date, date,
symbol: other_symbol.to_string(), symbol: other_symbol.to_string(),
timestamp: date.and_hms_opt(10, 18, 0).expect("valid timestamp"), timestamp: date.and_hms_opt(10, 18, 0).expect("valid timestamp"),
@@ -19450,7 +19506,7 @@ mod tests {
}], }],
Vec::new(), Vec::new(),
vec![ vec![
IntradayExecutionQuote { IntradayExecutionQuote { observation_kind: Default::default(),
date, date,
symbol: delayed_symbol.to_string(), symbol: delayed_symbol.to_string(),
timestamp: date.and_hms_opt(9, 31, 0).expect("valid timestamp"), timestamp: date.and_hms_opt(9, 31, 0).expect("valid timestamp"),
@@ -19463,7 +19519,7 @@ mod tests {
amount_delta: 146_300.0, amount_delta: 146_300.0,
trading_phase: Some("continuous".to_string()), trading_phase: Some("continuous".to_string()),
}, },
IntradayExecutionQuote { IntradayExecutionQuote { observation_kind: Default::default(),
date, date,
symbol: other_symbol.to_string(), symbol: other_symbol.to_string(),
timestamp: date.and_hms_opt(10, 18, 0).expect("valid timestamp"), timestamp: date.and_hms_opt(10, 18, 0).expect("valid timestamp"),
@@ -19699,7 +19755,7 @@ mod tests {
], ],
Vec::new(), Vec::new(),
vec![ vec![
IntradayExecutionQuote { IntradayExecutionQuote { observation_kind: Default::default(),
date: first_date, date: first_date,
symbol: symbol.to_string(), symbol: symbol.to_string(),
timestamp: first_date.and_hms_opt(9, 31, 0).expect("valid timestamp"), timestamp: first_date.and_hms_opt(9, 31, 0).expect("valid timestamp"),
@@ -19712,7 +19768,7 @@ mod tests {
amount_delta: 56_450.0, amount_delta: 56_450.0,
trading_phase: Some("continuous".to_string()), trading_phase: Some("continuous".to_string()),
}, },
IntradayExecutionQuote { IntradayExecutionQuote { observation_kind: Default::default(),
date: first_date, date: first_date,
symbol: symbol.to_string(), symbol: symbol.to_string(),
timestamp: first_date.and_hms_opt(10, 18, 0).expect("valid timestamp"), timestamp: first_date.and_hms_opt(10, 18, 0).expect("valid timestamp"),
@@ -19725,7 +19781,7 @@ mod tests {
amount_delta: 49_300.0, amount_delta: 49_300.0,
trading_phase: Some("continuous".to_string()), trading_phase: Some("continuous".to_string()),
}, },
IntradayExecutionQuote { IntradayExecutionQuote { observation_kind: Default::default(),
date: second_date, date: second_date,
symbol: symbol.to_string(), symbol: symbol.to_string(),
timestamp: second_date.and_hms_opt(9, 31, 0).expect("valid timestamp"), timestamp: second_date.and_hms_opt(9, 31, 0).expect("valid timestamp"),
@@ -20027,7 +20083,7 @@ mod tests {
volume: 1_000_000, volume: 1_000_000,
}], }],
Vec::new(), Vec::new(),
vec![IntradayExecutionQuote { vec![IntradayExecutionQuote { observation_kind: Default::default(),
date, date,
symbol: symbol.to_string(), symbol: symbol.to_string(),
timestamp: date.and_hms_opt(10, 18, 0).expect("timestamp"), timestamp: date.and_hms_opt(10, 18, 0).expect("timestamp"),
@@ -20485,7 +20541,7 @@ mod tests {
vec![candidate], vec![candidate],
vec![benchmark], vec![benchmark],
Vec::new(), Vec::new(),
vec![IntradayExecutionQuote { vec![IntradayExecutionQuote { observation_kind: Default::default(),
date, date,
symbol: symbol.to_string(), symbol: symbol.to_string(),
timestamp: date.and_hms_opt(9, 30, 0).expect("timestamp"), timestamp: date.and_hms_opt(9, 30, 0).expect("timestamp"),
@@ -20672,7 +20728,7 @@ mod tests {
prev_close: 998.0, prev_close: 998.0,
volume: 1_000_000, volume: 1_000_000,
}; };
let quote = |date: NaiveDate, last_price: f64, volume_delta: u64| IntradayExecutionQuote { let quote = |date: NaiveDate, last_price: f64, volume_delta: u64| IntradayExecutionQuote { observation_kind: Default::default(),
date, date,
symbol: symbol.to_string(), symbol: symbol.to_string(),
timestamp: date.and_hms_opt(10, 15, 0).expect("timestamp"), timestamp: date.and_hms_opt(10, 15, 0).expect("timestamp"),
@@ -21231,6 +21287,37 @@ mod tests {
assert_eq!(stock.market_cap, 8.0); assert_eq!(stock.market_cap, 8.0);
assert_eq!(stock.market_cap_bn, 8.0); assert_eq!(stock.market_cap_bn, 8.0);
assert!(stock.touched_upper_limit); assert!(stock.touched_upper_limit);
let missing_feature_day = DataSet::from_components(
vec![data.instrument(symbol).unwrap().clone()],
data.market_snapshot_rows_on(date).to_vec(),
data.factor_snapshot_rows_on(factor_date).iter()
.chain(data.factor_snapshot_rows_on(date)).cloned().collect(),
data.candidate_snapshot_rows_on(date).to_vec(),
vec![data.benchmark(date).unwrap().clone()],
).unwrap();
let gap_ctx = StrategyContext {data: &missing_feature_day, decision_date: factor_date, ..ctx};
let mut gap_config = PlatformExprStrategyConfig::generic();
gap_config.matching_type = MatchingType::NextBarOpen;
gap_config.stock_filter_expr = "close > 10.0".into();
let gap_strategy = PlatformExprStrategy::new(gap_config);
let indexed = gap_strategy.stock_state_with_factor_date(&gap_ctx, date, factor_date, symbol);
assert!(matches!(&indexed, Err(crate::BacktestError::Data(crate::data::DataSetError::MissingSnapshot {
kind: "feature_market", date: missing_date, symbol: missing_symbol,
})) if *missing_date == factor_date && missing_symbol == symbol),
"missing decision-date OHLCV must not become execution-date values: {indexed:?}");
let execution_view = missing_feature_day.daily_snapshot_view(date);
let feature_view = missing_feature_day.daily_snapshot_view(factor_date);
let viewed = gap_strategy.uncached_selection_stock_state_from_views_by_symbol_id(
&gap_ctx, date, factor_date, missing_feature_day.symbol_id(symbol).unwrap(), symbol,
&execution_view, &feature_view,
);
assert!(matches!(&viewed, Err(crate::BacktestError::Data(crate::data::DataSetError::MissingSnapshot {
kind: "feature_market", date: missing_date, ..
})) if *missing_date == factor_date), "daily views must preserve the same missing-date boundary: {viewed:?}");
assert!(gap_strategy.stock_state_cache.borrow().is_empty());
let same_day = gap_strategy.stock_state_with_factor_date(&gap_ctx, date, date, symbol).unwrap();
assert_eq!(same_day.close, 20.0);
} }
#[test] #[test]
@@ -22285,7 +22372,7 @@ mod tests {
volume: 1_000_000, volume: 1_000_000,
}], }],
Vec::new(), Vec::new(),
vec![IntradayExecutionQuote { vec![IntradayExecutionQuote { observation_kind: Default::default(),
date, date,
symbol: symbol.to_string(), symbol: symbol.to_string(),
timestamp: date.and_hms_opt(10, 18, 0).expect("timestamp"), timestamp: date.and_hms_opt(10, 18, 0).expect("timestamp"),
@@ -22936,7 +23023,7 @@ mod tests {
Vec::new(), Vec::new(),
symbols symbols
.iter() .iter()
.map(|symbol| IntradayExecutionQuote { .map(|symbol| IntradayExecutionQuote { observation_kind: Default::default(),
date, date,
symbol: (*symbol).to_string(), symbol: (*symbol).to_string(),
timestamp: date.and_hms_opt(10, 18, 0).expect("timestamp"), timestamp: date.and_hms_opt(10, 18, 0).expect("timestamp"),
@@ -23090,7 +23177,7 @@ mod tests {
}], }],
Vec::new(), Vec::new(),
vec![ vec![
IntradayExecutionQuote { IntradayExecutionQuote { observation_kind: Default::default(),
date, date,
symbol: symbol.to_string(), symbol: symbol.to_string(),
timestamp: date.and_hms_opt(9, 31, 0).expect("timestamp"), timestamp: date.and_hms_opt(9, 31, 0).expect("timestamp"),
@@ -23103,7 +23190,7 @@ mod tests {
amount_delta: 110_000.0, amount_delta: 110_000.0,
trading_phase: Some("continuous".to_string()), trading_phase: Some("continuous".to_string()),
}, },
IntradayExecutionQuote { IntradayExecutionQuote { observation_kind: Default::default(),
date, date,
symbol: symbol.to_string(), symbol: symbol.to_string(),
timestamp: date.and_hms_opt(10, 18, 0).expect("timestamp"), timestamp: date.and_hms_opt(10, 18, 0).expect("timestamp"),
@@ -23247,7 +23334,7 @@ mod tests {
}], }],
Vec::new(), Vec::new(),
vec![ vec![
IntradayExecutionQuote { IntradayExecutionQuote { observation_kind: Default::default(),
date, date,
symbol: symbol.to_string(), symbol: symbol.to_string(),
timestamp: date.and_hms_opt(9, 31, 0).expect("timestamp"), timestamp: date.and_hms_opt(9, 31, 0).expect("timestamp"),
@@ -23260,7 +23347,7 @@ mod tests {
amount_delta: 108_000.0, amount_delta: 108_000.0,
trading_phase: Some("continuous".to_string()), trading_phase: Some("continuous".to_string()),
}, },
IntradayExecutionQuote { IntradayExecutionQuote { observation_kind: Default::default(),
date, date,
symbol: symbol.to_string(), symbol: symbol.to_string(),
timestamp: date.and_hms_opt(10, 18, 0).expect("timestamp"), timestamp: date.and_hms_opt(10, 18, 0).expect("timestamp"),
@@ -23404,7 +23491,7 @@ mod tests {
}], }],
Vec::new(), Vec::new(),
vec![ vec![
IntradayExecutionQuote { IntradayExecutionQuote { observation_kind: Default::default(),
date, date,
symbol: symbol.to_string(), symbol: symbol.to_string(),
timestamp: date.and_hms_opt(9, 31, 0).expect("timestamp"), timestamp: date.and_hms_opt(9, 31, 0).expect("timestamp"),
@@ -23417,7 +23504,7 @@ mod tests {
amount_delta: 110_000.0, amount_delta: 110_000.0,
trading_phase: Some("continuous".to_string()), trading_phase: Some("continuous".to_string()),
}, },
IntradayExecutionQuote { IntradayExecutionQuote { observation_kind: Default::default(),
date, date,
symbol: symbol.to_string(), symbol: symbol.to_string(),
timestamp: date.and_hms_opt(10, 18, 0).expect("timestamp"), timestamp: date.and_hms_opt(10, 18, 0).expect("timestamp"),
@@ -23574,7 +23661,7 @@ mod tests {
Vec::new(), Vec::new(),
symbols symbols
.iter() .iter()
.map(|symbol| IntradayExecutionQuote { .map(|symbol| IntradayExecutionQuote { observation_kind: Default::default(),
date, date,
symbol: (*symbol).to_string(), symbol: (*symbol).to_string(),
timestamp: date.and_hms_opt(10, 18, 0).expect("timestamp"), timestamp: date.and_hms_opt(10, 18, 0).expect("timestamp"),
@@ -23889,7 +23976,7 @@ mod tests {
volume: 1_000_000, volume: 1_000_000,
}], }],
Vec::new(), Vec::new(),
vec![IntradayExecutionQuote { vec![IntradayExecutionQuote { observation_kind: Default::default(),
date, date,
symbol: symbol.to_string(), symbol: symbol.to_string(),
timestamp: date.and_hms_opt(10, 40, 0).expect("timestamp"), timestamp: date.and_hms_opt(10, 40, 0).expect("timestamp"),
@@ -24045,7 +24132,7 @@ mod tests {
Vec::new(), Vec::new(),
symbols symbols
.iter() .iter()
.map(|symbol| IntradayExecutionQuote { .map(|symbol| IntradayExecutionQuote { observation_kind: Default::default(),
date, date,
symbol: (*symbol).to_string(), symbol: (*symbol).to_string(),
timestamp: date.and_hms_opt(10, 15, 0).expect("timestamp"), timestamp: date.and_hms_opt(10, 15, 0).expect("timestamp"),
@@ -24214,7 +24301,7 @@ mod tests {
volume: 1_000_000, volume: 1_000_000,
}], }],
Vec::new(), Vec::new(),
vec![IntradayExecutionQuote { vec![IntradayExecutionQuote { observation_kind: Default::default(),
date, date,
symbol: symbol.to_string(), symbol: symbol.to_string(),
timestamp: date.and_hms_opt(10, 15, 0).expect("timestamp"), timestamp: date.and_hms_opt(10, 15, 0).expect("timestamp"),
@@ -24372,7 +24459,7 @@ mod tests {
volume: 1_000_000, volume: 1_000_000,
}], }],
Vec::new(), Vec::new(),
vec![IntradayExecutionQuote { vec![IntradayExecutionQuote { observation_kind: Default::default(),
date, date,
symbol: symbol.to_string(), symbol: symbol.to_string(),
timestamp: date.and_hms_opt(9, 33, 0).expect("valid timestamp"), timestamp: date.and_hms_opt(9, 33, 0).expect("valid timestamp"),
@@ -24494,7 +24581,7 @@ mod tests {
}], }],
Vec::new(), Vec::new(),
vec![ vec![
IntradayExecutionQuote { IntradayExecutionQuote { observation_kind: Default::default(),
date, date,
symbol: symbol.to_string(), symbol: symbol.to_string(),
timestamp: date.and_hms_opt(10, 40, 0).expect("valid timestamp"), timestamp: date.and_hms_opt(10, 40, 0).expect("valid timestamp"),
@@ -24507,7 +24594,7 @@ mod tests {
amount_delta: 1_000.0, amount_delta: 1_000.0,
trading_phase: Some("continuous".to_string()), trading_phase: Some("continuous".to_string()),
}, },
IntradayExecutionQuote { IntradayExecutionQuote { observation_kind: Default::default(),
date, date,
symbol: symbol.to_string(), symbol: symbol.to_string(),
timestamp: date.and_hms_opt(14, 58, 59).expect("valid timestamp"), timestamp: date.and_hms_opt(14, 58, 59).expect("valid timestamp"),
@@ -24520,7 +24607,7 @@ mod tests {
amount_delta: 2_000.0, amount_delta: 2_000.0,
trading_phase: Some("continuous".to_string()), trading_phase: Some("continuous".to_string()),
}, },
IntradayExecutionQuote { IntradayExecutionQuote { observation_kind: Default::default(),
date, date,
symbol: symbol.to_string(), symbol: symbol.to_string(),
timestamp: date.and_hms_opt(14, 59, 2).expect("valid timestamp"), timestamp: date.and_hms_opt(14, 59, 2).expect("valid timestamp"),
@@ -24743,7 +24830,7 @@ mod tests {
volume: 1_000_000, volume: 1_000_000,
}], }],
Vec::new(), Vec::new(),
vec![IntradayExecutionQuote { vec![IntradayExecutionQuote { observation_kind: Default::default(),
date, date,
symbol: symbol.to_string(), symbol: symbol.to_string(),
timestamp: date.and_hms_opt(10, 39, 59).unwrap(), timestamp: date.and_hms_opt(10, 39, 59).unwrap(),
@@ -24872,6 +24959,8 @@ mod tests {
], ],
vec![ vec![
market(factor_date, signal, 10.0, 11.0, 9.0), market(factor_date, signal, 10.0, 11.0, 9.0),
market(factor_date, limit_symbol, 1.80, 1.98, 1.62),
market(factor_date, fallback_symbol, 4.20, 4.62, 3.78),
market(decision_date, signal, 10.0, 11.0, 9.0), market(decision_date, signal, 10.0, 11.0, 9.0),
market(execution_date, signal, 10.0, 11.0, 9.0), market(execution_date, signal, 10.0, 11.0, 9.0),
market(decision_date, limit_symbol, 2.20, 2.42, 1.98), market(decision_date, limit_symbol, 2.20, 2.42, 1.98),
@@ -24980,7 +25069,7 @@ mod tests {
.collect(), .collect(),
Vec::new(), Vec::new(),
vec![ vec![
IntradayExecutionQuote { IntradayExecutionQuote { observation_kind: Default::default(),
date: execution_date, date: execution_date,
symbol: limit_symbol.to_string(), symbol: limit_symbol.to_string(),
timestamp: execution_date timestamp: execution_date
@@ -24995,7 +25084,7 @@ mod tests {
amount_delta: 233.0, amount_delta: 233.0,
trading_phase: Some("continuous".to_string()), trading_phase: Some("continuous".to_string()),
}, },
IntradayExecutionQuote { IntradayExecutionQuote { observation_kind: Default::default(),
date: execution_date, date: execution_date,
symbol: fallback_symbol.to_string(), symbol: fallback_symbol.to_string(),
timestamp: execution_date timestamp: execution_date
@@ -25056,6 +25145,8 @@ mod tests {
.stock_state_with_factor_date(&ctx, decision_date, factor_date, limit_symbol) .stock_state_with_factor_date(&ctx, decision_date, factor_date, limit_symbol)
.expect("previous factor-day state"); .expect("previous factor-day state");
assert_eq!(prior_factor_state.amount, 20_000_000.0); assert_eq!(prior_factor_state.amount, 20_000_000.0);
assert_eq!(prior_factor_state.close, 1.80);
assert_eq!(decision_day_state.close, 2.20);
let decision = strategy.on_day(&ctx).expect("platform decision"); let decision = strategy.on_day(&ctx).expect("platform decision");
@@ -25233,7 +25324,7 @@ mod tests {
}) })
.collect(), .collect(),
Vec::new(), Vec::new(),
vec![IntradayExecutionQuote { vec![IntradayExecutionQuote { observation_kind: Default::default(),
date: execution_date, date: execution_date,
symbol: candidate_symbol.to_string(), symbol: candidate_symbol.to_string(),
timestamp: execution_date.and_hms_opt(9, 33, 0).unwrap(), timestamp: execution_date.and_hms_opt(9, 33, 0).unwrap(),
@@ -27761,7 +27852,7 @@ mod tests {
volume: 1_000_000, volume: 1_000_000,
}], }],
Vec::new(), Vec::new(),
vec![IntradayExecutionQuote { vec![IntradayExecutionQuote { observation_kind: Default::default(),
date, date,
symbol: "000002.SZ".to_string(), symbol: "000002.SZ".to_string(),
timestamp: date.and_hms_opt(10, 18, 0).expect("valid timestamp"), timestamp: date.and_hms_opt(10, 18, 0).expect("valid timestamp"),
@@ -28084,7 +28175,7 @@ mod tests {
Vec::new(), Vec::new(),
symbols symbols
.iter() .iter()
.map(|symbol| IntradayExecutionQuote { .map(|symbol| IntradayExecutionQuote { observation_kind: Default::default(),
date, date,
symbol: (*symbol).to_string(), symbol: (*symbol).to_string(),
timestamp: date.and_hms_opt(10, 18, 0).expect("valid timestamp"), timestamp: date.and_hms_opt(10, 18, 0).expect("valid timestamp"),
@@ -28242,7 +28333,7 @@ mod tests {
Vec::new(), Vec::new(),
symbols symbols
.iter() .iter()
.map(|symbol| IntradayExecutionQuote { .map(|symbol| IntradayExecutionQuote { observation_kind: Default::default(),
date, date,
symbol: (*symbol).to_string(), symbol: (*symbol).to_string(),
timestamp: date.and_hms_opt(10, 40, 0).expect("timestamp"), timestamp: date.and_hms_opt(10, 40, 0).expect("timestamp"),
@@ -28572,7 +28663,7 @@ mod tests {
Vec::new(), Vec::new(),
symbols symbols
.iter() .iter()
.map(|symbol| IntradayExecutionQuote { .map(|symbol| IntradayExecutionQuote { observation_kind: Default::default(),
date, date,
symbol: (*symbol).to_string(), symbol: (*symbol).to_string(),
timestamp: date.and_hms_opt(10, 40, 0).expect("timestamp"), timestamp: date.and_hms_opt(10, 40, 0).expect("timestamp"),
@@ -29342,7 +29433,7 @@ mod tests {
Vec::new(), Vec::new(),
symbols symbols
.iter() .iter()
.map(|symbol| IntradayExecutionQuote { .map(|symbol| IntradayExecutionQuote { observation_kind: Default::default(),
date, date,
symbol: (*symbol).to_string(), symbol: (*symbol).to_string(),
timestamp: date.and_hms_opt(9, 33, 0).expect("valid timestamp"), timestamp: date.and_hms_opt(9, 33, 0).expect("valid timestamp"),
@@ -29525,7 +29616,7 @@ mod tests {
Vec::new(), Vec::new(),
symbols symbols
.iter() .iter()
.map(|symbol| IntradayExecutionQuote { .map(|symbol| IntradayExecutionQuote { observation_kind: Default::default(),
date, date,
symbol: (*symbol).to_string(), symbol: (*symbol).to_string(),
timestamp: date.and_hms_opt(10, 18, 0).expect("valid timestamp"), timestamp: date.and_hms_opt(10, 18, 0).expect("valid timestamp"),
@@ -29736,7 +29827,7 @@ mod tests {
Vec::new(), Vec::new(),
symbols symbols
.iter() .iter()
.map(|symbol| IntradayExecutionQuote { .map(|symbol| IntradayExecutionQuote { observation_kind: Default::default(),
date, date,
symbol: (*symbol).to_string(), symbol: (*symbol).to_string(),
timestamp: date.and_hms_opt(10, 18, 0).expect("valid timestamp"), timestamp: date.and_hms_opt(10, 18, 0).expect("valid timestamp"),
@@ -29987,7 +30078,7 @@ mod tests {
Vec::new(), Vec::new(),
symbols symbols
.iter() .iter()
.map(|symbol| IntradayExecutionQuote { .map(|symbol| IntradayExecutionQuote { observation_kind: Default::default(),
date, date,
symbol: (*symbol).to_string(), symbol: (*symbol).to_string(),
timestamp: date.and_hms_opt(10, 18, 0).expect("valid timestamp"), timestamp: date.and_hms_opt(10, 18, 0).expect("valid timestamp"),
@@ -30203,7 +30294,7 @@ mod tests {
Vec::new(), Vec::new(),
symbols symbols
.iter() .iter()
.map(|symbol| IntradayExecutionQuote { .map(|symbol| IntradayExecutionQuote { observation_kind: Default::default(),
date, date,
symbol: (*symbol).to_string(), symbol: (*symbol).to_string(),
timestamp: date.and_hms_opt(10, 18, 0).expect("valid timestamp"), timestamp: date.and_hms_opt(10, 18, 0).expect("valid timestamp"),
@@ -30569,7 +30660,7 @@ mod tests {
volume: 1_000_000, volume: 1_000_000,
}], }],
Vec::new(), Vec::new(),
vec![IntradayExecutionQuote { vec![IntradayExecutionQuote { observation_kind: Default::default(),
date, date,
symbol: symbol.to_string(), symbol: symbol.to_string(),
timestamp: date.and_hms_opt(10, 18, 0).expect("valid timestamp"), timestamp: date.and_hms_opt(10, 18, 0).expect("valid timestamp"),
@@ -30750,7 +30841,7 @@ mod tests {
}], }],
Vec::new(), Vec::new(),
vec![ vec![
IntradayExecutionQuote { IntradayExecutionQuote { observation_kind: Default::default(),
date, date,
symbol: delayed_symbol.to_string(), symbol: delayed_symbol.to_string(),
timestamp: date.and_hms_opt(9, 31, 0).expect("valid timestamp"), timestamp: date.and_hms_opt(9, 31, 0).expect("valid timestamp"),
@@ -30763,7 +30854,7 @@ mod tests {
amount_delta: 105_000.0, amount_delta: 105_000.0,
trading_phase: Some("continuous".to_string()), trading_phase: Some("continuous".to_string()),
}, },
IntradayExecutionQuote { IntradayExecutionQuote { observation_kind: Default::default(),
date, date,
symbol: delayed_symbol.to_string(), symbol: delayed_symbol.to_string(),
timestamp: date.and_hms_opt(10, 18, 0).expect("valid timestamp"), timestamp: date.and_hms_opt(10, 18, 0).expect("valid timestamp"),
@@ -30776,7 +30867,7 @@ mod tests {
amount_delta: 90_000.0, amount_delta: 90_000.0,
trading_phase: Some("continuous".to_string()), trading_phase: Some("continuous".to_string()),
}, },
IntradayExecutionQuote { IntradayExecutionQuote { observation_kind: Default::default(),
date, date,
symbol: held_symbol.to_string(), symbol: held_symbol.to_string(),
timestamp: date.and_hms_opt(10, 18, 0).expect("valid timestamp"), timestamp: date.and_hms_opt(10, 18, 0).expect("valid timestamp"),
@@ -30789,7 +30880,7 @@ mod tests {
amount_delta: 100_000.0, amount_delta: 100_000.0,
trading_phase: Some("continuous".to_string()), trading_phase: Some("continuous".to_string()),
}, },
IntradayExecutionQuote { IntradayExecutionQuote { observation_kind: Default::default(),
date, date,
symbol: buy_symbol.to_string(), symbol: buy_symbol.to_string(),
timestamp: date.and_hms_opt(10, 18, 0).expect("valid timestamp"), timestamp: date.and_hms_opt(10, 18, 0).expect("valid timestamp"),
@@ -30978,7 +31069,7 @@ mod tests {
}], }],
Vec::new(), Vec::new(),
vec![ vec![
IntradayExecutionQuote { IntradayExecutionQuote { observation_kind: Default::default(),
date, date,
symbol: delayed_symbol.to_string(), symbol: delayed_symbol.to_string(),
timestamp: date.and_hms_opt(9, 31, 0).expect("valid timestamp"), timestamp: date.and_hms_opt(9, 31, 0).expect("valid timestamp"),
@@ -30991,7 +31082,7 @@ mod tests {
amount_delta: 4_200.0, amount_delta: 4_200.0,
trading_phase: Some("continuous".to_string()), trading_phase: Some("continuous".to_string()),
}, },
IntradayExecutionQuote { IntradayExecutionQuote { observation_kind: Default::default(),
date, date,
symbol: held_symbol.to_string(), symbol: held_symbol.to_string(),
timestamp: date.and_hms_opt(10, 18, 0).expect("valid timestamp"), timestamp: date.and_hms_opt(10, 18, 0).expect("valid timestamp"),
@@ -31004,7 +31095,7 @@ mod tests {
amount_delta: 100_000.0, amount_delta: 100_000.0,
trading_phase: Some("continuous".to_string()), trading_phase: Some("continuous".to_string()),
}, },
IntradayExecutionQuote { IntradayExecutionQuote { observation_kind: Default::default(),
date, date,
symbol: buy_symbol.to_string(), symbol: buy_symbol.to_string(),
timestamp: date.and_hms_opt(10, 18, 0).expect("valid timestamp"), timestamp: date.and_hms_opt(10, 18, 0).expect("valid timestamp"),
@@ -31215,7 +31306,7 @@ mod tests {
.flat_map(|symbol| { .flat_map(|symbol| {
let mut quotes = Vec::new(); let mut quotes = Vec::new();
if *symbol == delayed_symbol { if *symbol == delayed_symbol {
quotes.push(IntradayExecutionQuote { quotes.push(IntradayExecutionQuote { observation_kind: Default::default(),
date, date,
symbol: (*symbol).to_string(), symbol: (*symbol).to_string(),
timestamp: date.and_hms_opt(9, 31, 0).expect("valid timestamp"), timestamp: date.and_hms_opt(9, 31, 0).expect("valid timestamp"),
@@ -31229,7 +31320,7 @@ mod tests {
trading_phase: Some("continuous".to_string()), trading_phase: Some("continuous".to_string()),
}); });
} }
quotes.push(IntradayExecutionQuote { quotes.push(IntradayExecutionQuote { observation_kind: Default::default(),
date, date,
symbol: (*symbol).to_string(), symbol: (*symbol).to_string(),
timestamp: date.and_hms_opt(10, 18, 0).expect("valid timestamp"), timestamp: date.and_hms_opt(10, 18, 0).expect("valid timestamp"),
@@ -31455,7 +31546,7 @@ mod tests {
.flat_map(|symbol| { .flat_map(|symbol| {
let mut quotes = Vec::new(); let mut quotes = Vec::new();
if *symbol == delayed_symbol { if *symbol == delayed_symbol {
quotes.push(IntradayExecutionQuote { quotes.push(IntradayExecutionQuote { observation_kind: Default::default(),
date, date,
symbol: (*symbol).to_string(), symbol: (*symbol).to_string(),
timestamp: date.and_hms_opt(9, 31, 0).expect("valid timestamp"), timestamp: date.and_hms_opt(9, 31, 0).expect("valid timestamp"),
@@ -31469,7 +31560,7 @@ mod tests {
trading_phase: Some("continuous".to_string()), trading_phase: Some("continuous".to_string()),
}); });
} }
quotes.push(IntradayExecutionQuote { quotes.push(IntradayExecutionQuote { observation_kind: Default::default(),
date, date,
symbol: (*symbol).to_string(), symbol: (*symbol).to_string(),
timestamp: date.and_hms_opt(10, 18, 0).expect("valid timestamp"), timestamp: date.and_hms_opt(10, 18, 0).expect("valid timestamp"),
@@ -32141,7 +32232,7 @@ mod tests {
Vec::new(), Vec::new(),
symbols symbols
.iter() .iter()
.map(|symbol| IntradayExecutionQuote { .map(|symbol| IntradayExecutionQuote { observation_kind: Default::default(),
date, date,
symbol: (*symbol).to_string(), symbol: (*symbol).to_string(),
timestamp: date.and_hms_opt(9, 33, 0).expect("valid timestamp"), timestamp: date.and_hms_opt(9, 33, 0).expect("valid timestamp"),
@@ -32430,7 +32521,7 @@ mod tests {
Vec::new(), Vec::new(),
symbols symbols
.iter() .iter()
.map(|symbol| IntradayExecutionQuote { .map(|symbol| IntradayExecutionQuote { observation_kind: Default::default(),
date, date,
symbol: (*symbol).to_string(), symbol: (*symbol).to_string(),
timestamp: date.and_hms_opt(10, 18, 0).expect("valid timestamp"), timestamp: date.and_hms_opt(10, 18, 0).expect("valid timestamp"),
@@ -32641,7 +32732,7 @@ mod tests {
Vec::new(), Vec::new(),
symbols symbols
.iter() .iter()
.map(|symbol| IntradayExecutionQuote { .map(|symbol| IntradayExecutionQuote { observation_kind: Default::default(),
date, date,
symbol: symbol.clone(), symbol: symbol.clone(),
timestamp: date.and_hms_opt(10, 18, 0).expect("valid timestamp"), timestamp: date.and_hms_opt(10, 18, 0).expect("valid timestamp"),
@@ -32848,7 +32939,7 @@ mod tests {
Vec::new(), Vec::new(),
symbols symbols
.iter() .iter()
.map(|symbol| IntradayExecutionQuote { .map(|symbol| IntradayExecutionQuote { observation_kind: Default::default(),
date, date,
symbol: (*symbol).to_string(), symbol: (*symbol).to_string(),
timestamp: date.and_hms_opt(14, 59, 0).expect("valid timestamp"), timestamp: date.and_hms_opt(14, 59, 0).expect("valid timestamp"),
@@ -32991,7 +33082,7 @@ mod tests {
volume: 1_000_000, volume: 1_000_000,
}], }],
Vec::new(), Vec::new(),
vec![IntradayExecutionQuote { vec![IntradayExecutionQuote { observation_kind: Default::default(),
date, date,
symbol: symbol.to_string(), symbol: symbol.to_string(),
timestamp: date.and_hms_opt(10, 18, 0).expect("valid timestamp"), timestamp: date.and_hms_opt(10, 18, 0).expect("valid timestamp"),
@@ -33214,7 +33305,7 @@ mod tests {
volume: 1_000_000, volume: 1_000_000,
}], }],
Vec::new(), Vec::new(),
vec![IntradayExecutionQuote { vec![IntradayExecutionQuote { observation_kind: Default::default(),
date, date,
symbol: symbol.to_string(), symbol: symbol.to_string(),
timestamp: date.and_hms_opt(9, 33, 0).expect("valid timestamp"), timestamp: date.and_hms_opt(9, 33, 0).expect("valid timestamp"),
@@ -33351,7 +33442,7 @@ mod tests {
volume: 1_000_000, volume: 1_000_000,
}], }],
Vec::new(), Vec::new(),
vec![IntradayExecutionQuote { vec![IntradayExecutionQuote { observation_kind: Default::default(),
date, date,
symbol: other_symbol.to_string(), symbol: other_symbol.to_string(),
timestamp: date.and_hms_opt(10, 18, 0).unwrap(), timestamp: date.and_hms_opt(10, 18, 0).unwrap(),
@@ -33492,7 +33583,7 @@ mod tests {
volume: 1_000_000, volume: 1_000_000,
}], }],
Vec::new(), Vec::new(),
vec![IntradayExecutionQuote { vec![IntradayExecutionQuote { observation_kind: Default::default(),
date: decision_date, date: decision_date,
symbol: other_symbol.to_string(), symbol: other_symbol.to_string(),
timestamp: decision_date.and_hms_opt(10, 18, 0).unwrap(), timestamp: decision_date.and_hms_opt(10, 18, 0).unwrap(),
+4 -6
View File
@@ -204,12 +204,10 @@ impl PlatformExprStrategy {
let (base_ratio, reserve_cash) = let (base_ratio, reserve_cash) =
pool::stock_pool_funding_from_configuration(&program.allocation_policy) pool::stock_pool_funding_from_configuration(&program.allocation_policy)
.map_err(BacktestError::Execution)?; .map_err(BacktestError::Execution)?;
let ratio = self let ratio = self.config.position_exposure_timeline
.config .exposure_at(portfolio_loss_decision_at(ctx), ctx.execution_date, &self.config.position_exposure_schedule,
.position_exposure_schedule f64::from(base_ratio)/10000.)
.range(..=ctx.decision_date) .map(|value| (value * 10000.).round() as i64)
.next_back()
.map(|(_, value)| (*value * 10000.).round() as i64)
.unwrap_or(i64::from(base_ratio)); .unwrap_or(i64::from(base_ratio));
let invest_ratio_bps = i32::try_from(ratio) let invest_ratio_bps = i32::try_from(ratio)
.ok() .ok()
@@ -949,6 +949,8 @@ pub struct StrategyExpressionRiskConfig {
pub exposure_expr: Option<String>, pub exposure_expr: Option<String>,
#[serde(default, alias = "position_exposure_schedule")] #[serde(default, alias = "position_exposure_schedule")]
pub position_exposure_schedule: Vec<StrategyPositionExposureSchedulePoint>, pub position_exposure_schedule: Vec<StrategyPositionExposureSchedulePoint>,
#[serde(default, alias = "position_exposure_events")]
pub position_exposure_events: Vec<crate::position_exposure::PositionExposureEvent>,
#[serde(default)] #[serde(default)]
pub portfolio_drawdown_control: Option<StrategyPortfolioDrawdownControlConfig>, pub portfolio_drawdown_control: Option<StrategyPortfolioDrawdownControlConfig>,
#[serde(default)] #[serde(default)]
@@ -2228,6 +2230,7 @@ pub fn platform_expr_config_from_spec(
expr.clone() expr.clone()
}; };
} }
cfg.position_exposure_timeline = crate::position_exposure::PositionExposureTimeline::from_events(&risk.position_exposure_events)?;
for point in &risk.position_exposure_schedule { for point in &risk.position_exposure_schedule {
let effective_date = NaiveDate::parse_from_str( let effective_date = NaiveDate::parse_from_str(
point.effective_date.trim(), point.effective_date.trim(),
+428
View File
@@ -0,0 +1,428 @@
//! Dated manual adjustments are ordered facts; restoring is not a 100% target.
use chrono::{DateTime, NaiveDate, Utc};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
#[serde(tag = "action", rename_all = "snake_case", deny_unknown_fields)]
pub enum PositionExposureAction {
Scale {
#[serde(rename = "requestedBps", alias = "requested_bps")]
requested_bps: i32,
},
Set {
#[serde(rename = "targetExposureBps", alias = "target_exposure_bps")]
target_exposure_bps: i32,
},
Restore,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PositionExposureEvent {
#[serde(alias = "event_id")]
pub event_id: String,
pub sequence: u64,
#[serde(alias = "effective_at")]
pub effective_at: DateTime<Utc>,
#[serde(flatten)]
pub action: PositionExposureAction,
}
#[derive(Debug, Clone, Default)]
pub struct PositionExposureTimeline {
events: BTreeMap<(DateTime<Utc>, u64), PositionExposureAction>,
}
impl PositionExposureTimeline {
pub fn from_events(events: &[PositionExposureEvent]) -> Result<Self, String> {
let mut result = Self::default();
let mut ids = BTreeSet::new();
let mut sequences = BTreeSet::new();
for event in events {
if event.event_id.trim().is_empty() || !ids.insert(event.event_id.as_str()) {
return Err("position exposure event id is missing or duplicated".into());
}
if event.sequence == 0 || !sequences.insert(event.sequence) {
return Err("position exposure event sequence must be positive and unique".into());
}
if let PositionExposureAction::Scale { requested_bps } = event.action
&& !(0..=10000).contains(&requested_bps)
{
return Err("position exposure scale must be between 0 and 10000 bps".into());
}
if let PositionExposureAction::Set {
target_exposure_bps,
} = event.action
&& !(0..=10_000).contains(&target_exposure_bps)
{
return Err("position exposure target must be between 0 and 10000 bps".into());
}
result
.events
.insert((event.effective_at, event.sequence), event.action.clone());
}
Ok(result)
}
/// Legacy day-level contracts remain day-level; never invent intraday times.
pub fn exposure_at(
&self,
at: DateTime<Utc>,
execution_date: NaiveDate,
legacy: &BTreeMap<NaiveDate, f64>,
strategy_exposure: f64,
) -> Option<f64> {
match self
.events
.range(..=(at, u64::MAX))
.next_back()
.map(|(_, action)| action)
{
Some(PositionExposureAction::Scale { requested_bps }) => {
Some(strategy_exposure * f64::from(*requested_bps) / 10000.)
}
Some(PositionExposureAction::Set {
target_exposure_bps,
}) => Some(f64::from(*target_exposure_bps) / 10_000.),
Some(PositionExposureAction::Restore) => None,
None => legacy
.range(..=execution_date)
.next_back()
.map(|(_, value)| *value),
}
}
pub fn scale_at(&self, at: DateTime<Utc>) -> Option<i32> {
match self
.events
.range(..=(at, u64::MAX))
.next_back()
.map(|(_, action)| action)
{
Some(PositionExposureAction::Scale { requested_bps }) => Some(*requested_bps),
_ => None,
}
}
}
/// Scale new buys and desired targets without weakening sell/reduction or
/// cancellation instructions. Prices, subscriptions and cash flows are intact.
pub fn scale_explicit_intent(
mut intent: crate::OrderIntent,
bps: i32,
open_orders: &[crate::OpenOrderView],
) -> Result<Option<crate::OrderIntent>, String> {
use crate::OrderIntent as I;
if !(0..=10000).contains(&bps) {
return Err("position scale out of range".into());
}
if bps == 10000 {
return Ok(Some(intent));
}
if let I::WithTimeInForce {
intent: inner,
time_in_force,
} = intent
{
return Ok(
scale_explicit_intent(*inner, bps, open_orders)?.map(|intent| I::WithTimeInForce {
intent: Box::new(intent),
time_in_force,
}),
);
}
let integer = |value: i32| ((i64::from(value) * i64::from(bps)) / 10000) as i32;
let amount = |value: f64, target: bool| -> Result<f64, String> {
if !value.is_finite() || (target && value < 0.) {
return Err("position override received an invalid original amount".into());
}
Ok(if value > 0. {
value * f64::from(bps) / 10000.
} else {
value
})
};
match &mut intent {
I::Shares { quantity, .. }
| I::LimitShares { quantity, .. }
| I::Lots { lots: quantity, .. }
| I::LimitLots { lots: quantity, .. } => {
if *quantity > 0 {
*quantity = integer(*quantity);
if *quantity == 0 {
return Ok(None);
}
}
}
I::TargetShares {
target_quantity, ..
}
| I::LimitTargetShares {
target_quantity, ..
} => {
if *target_quantity < 0 {
return Err("position override received a negative target quantity".into());
}
*target_quantity = integer(*target_quantity);
}
I::Value { value, .. }
| I::LimitValue { value, .. }
| I::AlgoValue { value, .. }
| I::Percent { percent: value, .. }
| I::LimitPercent { percent: value, .. }
| I::AlgoPercent { percent: value, .. } => {
*value = amount(*value, false)?;
if *value == 0. {
return Ok(None);
}
}
I::TargetValue { target_value, .. }
| I::LimitTargetValue { target_value, .. }
| I::TimedTargetValue { target_value, .. }
| I::TargetPercent {
target_percent: target_value,
..
}
| I::LimitTargetPercent {
target_percent: target_value,
..
} => {
*target_value = amount(*target_value, true)?;
}
I::TargetPortfolioSmart { target_weights, .. } => {
for value in target_weights.values_mut() {
*value = amount(*value, true)?;
}
}
I::ModifyOrder {
order_id,
new_total_quantity: Some(quantity),
..
} => {
let order = open_orders
.iter()
.find(|order| order.order_id == *order_id)
.ok_or("position override cannot resolve the order being modified")?;
if order.side == crate::OrderSide::Buy && *quantity > order.requested_quantity {
let extra = u64::from(*quantity - order.requested_quantity) * bps as u64 / 10000;
*quantity = order.requested_quantity + extra as u32;
}
}
I::Futures { .. } | I::StockPool { .. } => {
return Err("manual equity scaling cannot transform this intent kind".into());
}
I::ModifyOrder { .. }
| I::CancelOrder { .. }
| I::CancelSymbol { .. }
| I::CancelAll { .. }
| I::UpdateUniverse { .. }
| I::Subscribe { .. }
| I::Unsubscribe { .. }
| I::DepositWithdraw { .. }
| I::FinanceRepay { .. }
| I::SetManagementFeeRate { .. } => {}
I::WithTimeInForce { .. } => unreachable!("wrapper handled first"),
}
Ok(Some(intent))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn scalar_preserves_strategy_risk_off_and_restore_keeps_original_exposure() {
let at = DateTime::parse_from_rfc3339("2026-01-05T09:30:00+08:00")
.unwrap()
.with_timezone(&Utc);
let event = PositionExposureEvent {
event_id: "scale".into(),
sequence: 1,
effective_at: at,
action: PositionExposureAction::Scale {
requested_bps: 5000,
},
};
let timeline = PositionExposureTimeline::from_events(&[event.clone()]).unwrap();
assert_eq!(
timeline.exposure_at(at, at.date_naive(), &BTreeMap::new(), 0.),
Some(0.)
);
assert_eq!(
timeline.exposure_at(at, at.date_naive(), &BTreeMap::new(), 0.2),
Some(0.1)
);
let restored = PositionExposureEvent {
event_id: "restore".into(),
sequence: 2,
effective_at: at,
action: PositionExposureAction::Restore,
};
let timeline = PositionExposureTimeline::from_events(&[event, restored]).unwrap();
assert_eq!(
timeline
.exposure_at(
at,
at.date_naive(),
&BTreeMap::from([(at.date_naive(), 1.)]),
0.2
)
.unwrap_or(0.2),
0.2
);
}
#[test]
fn explicit_equity_targets_and_buys_scale_but_sales_cashflows_and_prices_do_not() {
use crate::OrderIntent as I;
let symbol = "000001.SZ".to_string();
let reason = "fixture".to_string();
for bps in [0, 3000, 5000, 10000] {
let ratio = f64::from(bps) / 10000.;
let buy = I::LimitShares {
symbol: symbol.clone(),
quantity: 1000,
limit_price: 12.345,
reason: reason.clone(),
};
let scaled = scale_explicit_intent(buy, bps, &[]).unwrap();
if bps == 0 {
assert!(scaled.is_none())
} else if let Some(I::LimitShares {
quantity,
limit_price,
..
}) = scaled
{
assert_eq!(quantity, (1000. * ratio) as i32);
assert_eq!(limit_price, 12.345);
} else {
panic!("wrong intent")
}
let sell = I::Shares {
symbol: symbol.clone(),
quantity: -1000,
reason: reason.clone(),
};
assert!(matches!(
scale_explicit_intent(sell, bps, &[]).unwrap(),
Some(I::Shares {
quantity: -1000,
..
})
));
let clear = I::TargetShares {
symbol: symbol.clone(),
target_quantity: 0,
reason: reason.clone(),
};
assert!(matches!(
scale_explicit_intent(clear, bps, &[]).unwrap(),
Some(I::TargetShares {
target_quantity: 0,
..
})
));
let target = I::TargetPercent {
symbol: symbol.clone(),
target_percent: 0.2,
reason: reason.clone(),
};
if let Some(I::TargetPercent { target_percent, .. }) =
scale_explicit_intent(target, bps, &[]).unwrap()
{
assert!((target_percent - 0.2 * ratio).abs() < 1e-12)
} else {
panic!("wrong target")
}
let deposit = I::DepositWithdraw {
amount: 123.456,
receiving_days: 2,
reason: reason.clone(),
};
assert!(matches!(
scale_explicit_intent(deposit, bps, &[]).unwrap(),
Some(I::DepositWithdraw {
amount: 123.456,
receiving_days: 2,
..
})
));
}
assert!(
scale_explicit_intent(
I::TargetValue {
symbol,
target_value: f64::NAN,
reason
},
0,
&[]
)
.is_err()
);
}
#[test]
fn same_day_adjustments_restore_and_future_events_keep_their_own_times() {
let events: Vec<PositionExposureEvent> = serde_json::from_value(json!([
{"eventId":"first","sequence":1,"effectiveAt":"2026-09-10T10:00:00+08:00","action":"set","targetExposureBps":0},
{"eventId":"second","sequence":2,"effectiveAt":"2026-09-10T13:00:00+08:00","action":"set","targetExposureBps":5000},
{"eventId":"restore","sequence":3,"effectiveAt":"2026-09-10T14:00:00+08:00","action":"restore"},
{"eventId":"future","sequence":4,"effectiveAt":"2026-09-11T10:00:00+08:00","action":"set","targetExposureBps":1000}
])).unwrap();
let timeline = PositionExposureTimeline::from_events(&events).unwrap();
let date = NaiveDate::from_ymd_opt(2026, 9, 10).unwrap();
let legacy = BTreeMap::from([(date.pred_opt().unwrap(), 0.8)]);
for (time, expected) in [
("09:30:00", Some(0.8)),
("10:00:00", Some(0.)),
("12:59:59", Some(0.)),
("13:00:00", Some(0.5)),
("14:00:00", None),
("15:00:00", None),
] {
let at = DateTime::parse_from_rfc3339(&format!("2026-09-10T{time}+08:00"))
.unwrap()
.with_timezone(&Utc);
assert_eq!(
timeline.exposure_at(at, date, &legacy, 0.2),
expected,
"{time}"
);
}
let next_open = DateTime::parse_from_rfc3339("2026-09-11T09:30:00+08:00")
.unwrap()
.with_timezone(&Utc);
assert_eq!(
timeline.exposure_at(next_open, date.succ_opt().unwrap(), &legacy, 0.2),
None
);
}
#[test]
fn rejects_unknown_actions_duplicate_identity_and_invalid_bps() {
let valid = json!({"eventId":"one","sequence":1,"effectiveAt":"2026-09-10T09:30:00+08:00","action":"set","targetExposureBps":5000});
for (key, value) in [
("action", json!("other")),
("effectiveAt", json!("2026-09-10 09:30:00")),
("targetExposureBps", json!(null)),
] {
let mut invalid = valid.clone();
invalid[key] = value;
assert!(serde_json::from_value::<PositionExposureEvent>(invalid).is_err());
}
let event: PositionExposureEvent = serde_json::from_value(valid).unwrap();
assert!(PositionExposureTimeline::from_events(&[event.clone(), event.clone()]).is_err());
let mut invalid = event.clone();
invalid.action = PositionExposureAction::Set {
target_exposure_bps: 10001,
};
assert!(PositionExposureTimeline::from_events(&[invalid]).is_err());
let mut duplicate = event.clone();
duplicate.event_id = "two".into();
assert!(PositionExposureTimeline::from_events(&[event, duplicate]).is_err());
}
}
@@ -0,0 +1,148 @@
//! Condition facts are distinct from the quote's per-observation fill capacity.
//! Only a complete, declared raw-minute prefix can prove a session total.
use std::collections::BTreeMap;
use chrono::{NaiveDate, NaiveDateTime, NaiveTime, Timelike};
use rust_decimal::Decimal;
use crate::data::IntradayExecutionQuote;
use crate::stock_pool_execution::{parse_stock_pool_condition, StockPoolExecutionRule};
pub fn requires_session_totals(rule: &StockPoolExecutionRule) -> bool {
[rule.buy_condition.as_str(), if rule.sell_trigger_mode == "condition" { rule.sell_condition.as_str() } else { "" }].into_iter().any(|condition| {
parse_stock_pool_condition(condition).is_some_and(|(_, field, _, _)| matches!(field.as_str(), "volume" | "amount"))
})
}
/// The cash-equity minute feed includes the opening observation and a separate
/// post-close segment. Trading eligibility remains owned by the dated rules.
fn next_minute(time: NaiveTime) -> Option<NaiveTime> {
let minute = time.hour() * 60 + time.minute();
let next = match minute {
570..=689 | 781..=899 | 906..=929 => minute + 1,
690 => 781,
900 => 906,
_ => return None,
};
NaiveTime::from_hms_opt(next / 60, next % 60, 0)
}
#[derive(Debug, Default)]
pub(crate) struct SessionTotalsCache {
pub date: Option<NaiveDate>,
pub symbols: BTreeMap<String, MinutePrefix>,
}
#[derive(Debug)]
pub(crate) struct MinutePrefix {
values: BTreeMap<NaiveTime, (Decimal, Decimal)>,
failure: String,
}
impl MinutePrefix {
pub fn build(date: NaiveDate, symbol: &str, quotes: &[IntradayExecutionQuote]) -> Self {
let mut values = BTreeMap::new();
let mut expected = NaiveTime::from_hms_opt(9, 30, 0).unwrap();
let mut volume = 0_u64;
let mut amount = Decimal::ZERO;
let mut failure = format!("stock_pool_session_prefix_missing:{symbol}:{date}:{expected}");
for quote in quotes {
let time = quote.timestamp.time();
if quote.date != date || quote.timestamp.date() != date || quote.symbol != symbol {
failure = format!("stock_pool_session_prefix_identity_invalid:{symbol}:{date}");
break;
}
if time != expected {
failure = format!("stock_pool_session_prefix_missing:{symbol}:{date}:expected={expected}:observed={time}");
break;
}
if quote.observation_kind != crate::data::QuoteObservationKind::MinuteBar {
failure = format!("stock_pool_session_prefix_basis_unverified:{symbol}:{date}:{time}");
break;
}
let Some(next_volume) = volume.checked_add(quote.volume_delta) else {
failure = format!("stock_pool_session_volume_overflow:{symbol}:{date}:{time}");
break;
};
let delta = if quote.amount_delta.is_finite() && quote.amount_delta >= 0.0 {
quote.amount_delta.to_string().parse::<Decimal>().ok()
} else { None };
let Some(next_amount) = delta.and_then(|delta| amount.checked_add(delta)) else {
failure = format!("stock_pool_session_amount_invalid:{symbol}:{date}:{time}");
break;
};
volume = next_volume;
amount = next_amount;
values.insert(time, (Decimal::from(volume), amount));
let Some(next) = next_minute(time) else { break };
expected = next;
failure = format!("stock_pool_session_prefix_missing:{symbol}:{date}:{expected}");
}
Self { values, failure }
}
pub fn at(&self, at: NaiveDateTime) -> Result<(Decimal, Decimal), String> {
let time = at.time().with_second(0).unwrap().with_nanosecond(0).unwrap();
self.values.get(&time).copied().ok_or_else(|| self.failure.clone())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn quote(hour: u32, minute: u32, volume: u64, amount: f64) -> IntradayExecutionQuote {
let date = NaiveDate::from_ymd_opt(2026, 9, 11).unwrap();
IntradayExecutionQuote { observation_kind: crate::data::QuoteObservationKind::MinuteBar, date, symbol: "000001.SZ".into(), timestamp: date.and_hms_opt(hour, minute, 0).unwrap(),
last_price: 10., bid1: 0., ask1: 0., bid1_volume: 0, ask1_volume: 0,
volume_delta: volume, amount_delta: amount, trading_phase: Some("minute_execution_prices:raw-minute".into()) }
}
#[test]
fn totals_use_only_the_complete_observed_prefix_and_keep_decimal_amounts() {
let mut rows = vec![quote(9,30,100,10.01), quote(9,31,0,0.), quote(9,32,200,20.02)];
let prefix = MinutePrefix::build(rows[0].date, "000001.SZ", &rows);
assert_eq!(prefix.at(rows[1].timestamp).unwrap(), (100.into(), Decimal::new(1001,2)));
assert_eq!(prefix.at(rows[2].timestamp).unwrap(), (300.into(), Decimal::new(3003,2)));
rows[2].volume_delta = 999999;
rows[2].amount_delta = f64::NAN;
let changed = MinutePrefix::build(rows[0].date, "000001.SZ", &rows);
assert_eq!(changed.at(rows[1].timestamp).unwrap(), prefix.at(rows[1].timestamp).unwrap());
assert!(changed.at(rows[2].timestamp).unwrap_err().contains("amount_invalid"));
}
#[test]
fn sparse_unverified_and_overflowing_quotes_cannot_be_called_session_totals() {
let first = quote(9,30,100,1000.);
for rows in [vec![quote(9,31,100,1000.)], vec![first.clone(), quote(9,32,100,1000.)]] {
let prefix = MinutePrefix::build(first.date, "000001.SZ", &rows);
assert!(prefix.at(rows.last().unwrap().timestamp).unwrap_err().contains("prefix_missing"));
}
let mut unknown = first.clone(); unknown.observation_kind = Default::default();
assert!(MinutePrefix::build(first.date, "000001.SZ", &[unknown]).at(first.timestamp).unwrap_err().contains("basis_unverified"));
let rows = [quote(9,30,u64::MAX,0.), quote(9,31,1,0.)];
assert!(MinutePrefix::build(first.date, "000001.SZ", &rows).at(rows[1].timestamp).unwrap_err().contains("volume_overflow"));
}
#[test]
fn lunch_and_post_close_gaps_follow_the_minute_feed_segments() {
let mut rows = Vec::new(); let mut time = NaiveTime::from_hms_opt(9,30,0).unwrap();
loop {
rows.push(quote(time.hour(), time.minute(), 1, 0.01));
let Some(next) = next_minute(time) else { break }; time=next;
}
let prefix=MinutePrefix::build(rows[0].date,"000001.SZ",&rows);
assert_eq!(prefix.at(rows.last().unwrap().timestamp).unwrap(), (Decimal::from(rows.len()), Decimal::new(rows.len() as i64,2)));
assert!(!rows.iter().any(|row| row.timestamp.time().hour()==12));
assert!(!rows.iter().any(|row| row.timestamp.time()==NaiveTime::from_hms_opt(13,0,0).unwrap()));
assert!(!rows.iter().any(|row| row.timestamp.time().hour()==15 && (1..6).contains(&row.timestamp.time().minute())));
}
#[test]
#[ignore = "requires FIDC_SESSION_PREFIX_SOURCE_JSON from the frozen Source minute response"]
fn real_source_session_prefix_matches_observed_checkpoints() {
let path=std::env::var("FIDC_SESSION_PREFIX_SOURCE_JSON").expect("explicit Source evidence path");
let rows:Vec<IntradayExecutionQuote>=serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap();
let date=NaiveDate::from_ymd_opt(2026,9,8).unwrap();
assert_eq!(rows.len(),242);
let prefix=MinutePrefix::build(date,"000063.SZ",&rows);
for (hour,minute,volume,amount) in [(9,30,512700,17103672),(9,31,2296631,76576756),(9,32,2983531,99471024),(11,30,27868847,928167630),(13,1,28495518,948994890),(15,0,45625008,1518115100)] {
assert_eq!(prefix.at(date.and_hms_opt(hour,minute,0).unwrap()).unwrap(),(Decimal::from(volume),Decimal::from(amount)));
}
assert!(prefix.at(date.and_hms_opt(15,30,0).unwrap()).unwrap_err().contains("prefix_missing"),"one final aggregate is not a verified intraday prefix");
}
}
@@ -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,17 +227,13 @@ 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;
Ok(request Ok(request
.symbols .symbols
.into_iter() .into_iter()
.map(|symbol| IntradayExecutionQuote { .map(|symbol| IntradayExecutionQuote { observation_kind: Default::default(),
date: request.date, date: request.date,
symbol, symbol,
timestamp: request.date.and_time(t(10, 17, 59)), timestamp: request.date.and_time(t(10, 17, 59)),
@@ -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"
); );
} }
@@ -411,7 +447,7 @@ fn engine_preloads_declared_decision_quotes_for_current_positions() {
Ok(request Ok(request
.symbols .symbols
.into_iter() .into_iter()
.map(|symbol| IntradayExecutionQuote { .map(|symbol| IntradayExecutionQuote { observation_kind: Default::default(),
date: request.date, date: request.date,
symbol, symbol,
timestamp: request.date.and_time(t(10, 39, 59)), timestamp: request.date.and_time(t(10, 39, 59)),
@@ -556,7 +592,7 @@ fn engine_reuses_preloaded_decision_quotes_without_loader_call() {
], ],
Vec::new(), Vec::new(),
vec![ vec![
IntradayExecutionQuote { IntradayExecutionQuote { observation_kind: Default::default(),
date: first, date: first,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".to_string(),
timestamp: first.and_time(t(10, 39, 59)), timestamp: first.and_time(t(10, 39, 59)),
@@ -569,7 +605,7 @@ fn engine_reuses_preloaded_decision_quotes_without_loader_call() {
amount_delta: 100_000.0, amount_delta: 100_000.0,
trading_phase: Some("continuous".to_string()), trading_phase: Some("continuous".to_string()),
}, },
IntradayExecutionQuote { IntradayExecutionQuote { observation_kind: Default::default(),
date: second, date: second,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".to_string(),
timestamp: second.and_time(t(10, 39, 59)), timestamp: second.and_time(t(10, 39, 59)),
@@ -826,7 +862,7 @@ fn engine_loads_distinct_decision_quote_times_on_same_day() {
Ok(request Ok(request
.symbols .symbols
.into_iter() .into_iter()
.map(|symbol| IntradayExecutionQuote { .map(|symbol| IntradayExecutionQuote { observation_kind: Default::default(),
date: request.date, date: request.date,
symbol, symbol,
timestamp: request.date.and_time(start_time) - Duration::seconds(1), timestamp: request.date.and_time(start_time) - Duration::seconds(1),
+8 -8
View File
@@ -2209,7 +2209,7 @@ fn engine_runs_minute_hooks_and_executes_minute_orders() {
}], }],
Vec::new(), Vec::new(),
vec![ vec![
IntradayExecutionQuote { IntradayExecutionQuote { observation_kind: Default::default(),
date, date,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".to_string(),
timestamp: dt(2025, 1, 2, 10, 18, 0), timestamp: dt(2025, 1, 2, 10, 18, 0),
@@ -2222,7 +2222,7 @@ fn engine_runs_minute_hooks_and_executes_minute_orders() {
amount_delta: 10_200.0, amount_delta: 10_200.0,
trading_phase: Some("continuous".to_string()), trading_phase: Some("continuous".to_string()),
}, },
IntradayExecutionQuote { IntradayExecutionQuote { observation_kind: Default::default(),
date, date,
symbol: "000002.SZ".to_string(), symbol: "000002.SZ".to_string(),
timestamp: dt(2025, 1, 2, 10, 18, 0), timestamp: dt(2025, 1, 2, 10, 18, 0),
@@ -2235,7 +2235,7 @@ fn engine_runs_minute_hooks_and_executes_minute_orders() {
amount_delta: 20_400.0, amount_delta: 20_400.0,
trading_phase: Some("continuous".to_string()), trading_phase: Some("continuous".to_string()),
}, },
IntradayExecutionQuote { IntradayExecutionQuote { observation_kind: Default::default(),
date, date,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".to_string(),
timestamp: dt(2025, 1, 2, 10, 19, 0), timestamp: dt(2025, 1, 2, 10, 19, 0),
@@ -2341,7 +2341,7 @@ fn engine_skips_empty_platform_style_minute_callbacks_between_schedule_times() {
let date = d(2025, 1, 2); let date = d(2025, 1, 2);
let mut data = single_day_anchor_data(date); let mut data = single_day_anchor_data(date);
data.add_execution_quotes(vec![ data.add_execution_quotes(vec![
IntradayExecutionQuote { IntradayExecutionQuote { observation_kind: Default::default(),
date, date,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".to_string(),
timestamp: dt(2025, 1, 2, 10, 18, 0), timestamp: dt(2025, 1, 2, 10, 18, 0),
@@ -2354,7 +2354,7 @@ fn engine_skips_empty_platform_style_minute_callbacks_between_schedule_times() {
amount_delta: 10_200.0, amount_delta: 10_200.0,
trading_phase: Some("continuous".to_string()), trading_phase: Some("continuous".to_string()),
}, },
IntradayExecutionQuote { IntradayExecutionQuote { observation_kind: Default::default(),
date, date,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".to_string(),
timestamp: dt(2025, 1, 2, 10, 19, 0), timestamp: dt(2025, 1, 2, 10, 19, 0),
@@ -2519,7 +2519,7 @@ fn strategy_context_exposes_engine_native_data_helpers() {
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let quotes = vec![ let quotes = vec![
IntradayExecutionQuote { IntradayExecutionQuote { observation_kind: Default::default(),
date: date2, date: date2,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".to_string(),
timestamp: dt(2025, 1, 3, 14, 30, 0), timestamp: dt(2025, 1, 3, 14, 30, 0),
@@ -2532,7 +2532,7 @@ fn strategy_context_exposes_engine_native_data_helpers() {
amount_delta: 10_150.0, amount_delta: 10_150.0,
trading_phase: Some("continuous".to_string()), trading_phase: Some("continuous".to_string()),
}, },
IntradayExecutionQuote { IntradayExecutionQuote { observation_kind: Default::default(),
date: date3, date: date3,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".to_string(),
timestamp: dt(2025, 1, 6, 10, 18, 0), timestamp: dt(2025, 1, 6, 10, 18, 0),
@@ -2545,7 +2545,7 @@ fn strategy_context_exposes_engine_native_data_helpers() {
amount_delta: 10_250.0, amount_delta: 10_250.0,
trading_phase: Some("continuous".to_string()), trading_phase: Some("continuous".to_string()),
}, },
IntradayExecutionQuote { IntradayExecutionQuote { observation_kind: Default::default(),
date: date3, date: date3,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".to_string(),
timestamp: dt(2025, 1, 6, 10, 19, 0), timestamp: dt(2025, 1, 6, 10, 19, 0),
+25 -25
View File
@@ -146,7 +146,7 @@ fn intraday_liquidity_data(date: NaiveDate, symbol: &str) -> DataSet {
}], }],
Vec::new(), Vec::new(),
vec![ vec![
IntradayExecutionQuote { IntradayExecutionQuote { observation_kind: Default::default(),
date, date,
symbol: symbol.to_string(), symbol: symbol.to_string(),
timestamp: date.and_hms_opt(10, 18, 0).unwrap(), timestamp: date.and_hms_opt(10, 18, 0).unwrap(),
@@ -159,7 +159,7 @@ fn intraday_liquidity_data(date: NaiveDate, symbol: &str) -> DataSet {
amount_delta: 10_000.0, amount_delta: 10_000.0,
trading_phase: Some("continuous".to_string()), trading_phase: Some("continuous".to_string()),
}, },
IntradayExecutionQuote { IntradayExecutionQuote { observation_kind: Default::default(),
date, date,
symbol: symbol.to_string(), symbol: symbol.to_string(),
timestamp: date.and_hms_opt(10, 19, 0).unwrap(), timestamp: date.and_hms_opt(10, 19, 0).unwrap(),
@@ -172,7 +172,7 @@ fn intraday_liquidity_data(date: NaiveDate, symbol: &str) -> DataSet {
amount_delta: 10_000.0, amount_delta: 10_000.0,
trading_phase: Some("continuous".to_string()), trading_phase: Some("continuous".to_string()),
}, },
IntradayExecutionQuote { IntradayExecutionQuote { observation_kind: Default::default(),
date, date,
symbol: symbol.to_string(), symbol: symbol.to_string(),
timestamp: date.and_hms_opt(10, 20, 0).unwrap(), timestamp: date.and_hms_opt(10, 20, 0).unwrap(),
@@ -373,7 +373,7 @@ fn broker_executes_explicit_order_value_buy() {
volume: 1_000_000, volume: 1_000_000,
}], }],
Vec::new(), Vec::new(),
vec![IntradayExecutionQuote { vec![IntradayExecutionQuote { observation_kind: Default::default(),
date, date,
symbol: "000002.SZ".to_string(), symbol: "000002.SZ".to_string(),
timestamp: date.and_hms_opt(10, 18, 3).unwrap(), timestamp: date.and_hms_opt(10, 18, 3).unwrap(),
@@ -527,7 +527,7 @@ fn broker_delayed_limit_open_sell_uses_minute_price() {
volume: 1_000_000, volume: 1_000_000,
}], }],
Vec::new(), Vec::new(),
vec![IntradayExecutionQuote { vec![IntradayExecutionQuote { observation_kind: Default::default(),
date, date,
symbol: symbol.to_string(), symbol: symbol.to_string(),
timestamp: date.and_hms_opt(9, 31, 0).unwrap(), timestamp: date.and_hms_opt(9, 31, 0).unwrap(),
@@ -663,7 +663,7 @@ fn broker_executes_order_shares_and_order_lots() {
volume: 1_000_000, volume: 1_000_000,
}], }],
Vec::new(), Vec::new(),
vec![IntradayExecutionQuote { vec![IntradayExecutionQuote { observation_kind: Default::default(),
date, date,
symbol: "000002.SZ".to_string(), symbol: "000002.SZ".to_string(),
timestamp: date.and_hms_opt(10, 18, 3).unwrap(), timestamp: date.and_hms_opt(10, 18, 3).unwrap(),
@@ -1104,7 +1104,7 @@ fn broker_executes_target_portfolio_smart_with_algo_order_style() {
}], }],
Vec::new(), Vec::new(),
vec![ vec![
IntradayExecutionQuote { IntradayExecutionQuote { observation_kind: Default::default(),
date, date,
symbol: "000002.SZ".to_string(), symbol: "000002.SZ".to_string(),
timestamp: date.and_hms_opt(9, 31, 0).unwrap(), timestamp: date.and_hms_opt(9, 31, 0).unwrap(),
@@ -1117,7 +1117,7 @@ fn broker_executes_target_portfolio_smart_with_algo_order_style() {
amount_delta: 0.0, amount_delta: 0.0,
trading_phase: Some("continuous".to_string()), trading_phase: Some("continuous".to_string()),
}, },
IntradayExecutionQuote { IntradayExecutionQuote { observation_kind: Default::default(),
date, date,
symbol: "000002.SZ".to_string(), symbol: "000002.SZ".to_string(),
timestamp: date.and_hms_opt(9, 35, 0).unwrap(), timestamp: date.and_hms_opt(9, 35, 0).unwrap(),
@@ -1920,7 +1920,7 @@ fn broker_applies_tick_size_slippage_on_intraday_last_fills() {
volume: 1_000_000, volume: 1_000_000,
}], }],
Vec::new(), Vec::new(),
vec![IntradayExecutionQuote { vec![IntradayExecutionQuote { observation_kind: Default::default(),
date, date,
symbol: "000002.SZ".to_string(), symbol: "000002.SZ".to_string(),
timestamp: date.and_hms_opt(10, 18, 0).unwrap(), timestamp: date.and_hms_opt(10, 18, 0).unwrap(),
@@ -2153,7 +2153,7 @@ fn broker_executes_intraday_last_on_start_quote_with_trade_delta() {
volume: 1_000_000, volume: 1_000_000,
}], }],
Vec::new(), Vec::new(),
vec![IntradayExecutionQuote { vec![IntradayExecutionQuote { observation_kind: Default::default(),
date, date,
symbol: "000002.SZ".to_string(), symbol: "000002.SZ".to_string(),
timestamp: date.and_hms_opt(9, 33, 0).unwrap(), timestamp: date.and_hms_opt(9, 33, 0).unwrap(),
@@ -2273,7 +2273,7 @@ fn broker_cancels_market_order_remainder_when_intraday_quote_liquidity_exhausted
volume: 1_000_000, volume: 1_000_000,
}], }],
Vec::new(), Vec::new(),
vec![IntradayExecutionQuote { vec![IntradayExecutionQuote { observation_kind: Default::default(),
date, date,
symbol: "000002.SZ".to_string(), symbol: "000002.SZ".to_string(),
timestamp: date.and_hms_opt(10, 18, 0).unwrap(), timestamp: date.and_hms_opt(10, 18, 0).unwrap(),
@@ -2509,7 +2509,7 @@ fn broker_splits_intraday_quote_fills_and_tracks_commission_by_order() {
}], }],
Vec::new(), Vec::new(),
vec![ vec![
IntradayExecutionQuote { IntradayExecutionQuote { observation_kind: Default::default(),
date, date,
symbol: "000002.SZ".to_string(), symbol: "000002.SZ".to_string(),
timestamp: date.and_hms_opt(10, 18, 3).unwrap(), timestamp: date.and_hms_opt(10, 18, 3).unwrap(),
@@ -2522,7 +2522,7 @@ fn broker_splits_intraday_quote_fills_and_tracks_commission_by_order() {
amount_delta: 0.0, amount_delta: 0.0,
trading_phase: Some("continuous".to_string()), trading_phase: Some("continuous".to_string()),
}, },
IntradayExecutionQuote { IntradayExecutionQuote { observation_kind: Default::default(),
date, date,
symbol: "000002.SZ".to_string(), symbol: "000002.SZ".to_string(),
timestamp: date.and_hms_opt(10, 18, 6).unwrap(), timestamp: date.and_hms_opt(10, 18, 6).unwrap(),
@@ -2682,7 +2682,7 @@ fn broker_aggregates_intraday_quote_fills_into_vwap_leg() {
}], }],
Vec::new(), Vec::new(),
vec![ vec![
IntradayExecutionQuote { IntradayExecutionQuote { observation_kind: Default::default(),
date, date,
symbol: "000002.SZ".to_string(), symbol: "000002.SZ".to_string(),
timestamp: date.and_hms_opt(10, 18, 3).unwrap(), timestamp: date.and_hms_opt(10, 18, 3).unwrap(),
@@ -2695,7 +2695,7 @@ fn broker_aggregates_intraday_quote_fills_into_vwap_leg() {
amount_delta: 0.0, amount_delta: 0.0,
trading_phase: Some("continuous".to_string()), trading_phase: Some("continuous".to_string()),
}, },
IntradayExecutionQuote { IntradayExecutionQuote { observation_kind: Default::default(),
date, date,
symbol: "000002.SZ".to_string(), symbol: "000002.SZ".to_string(),
timestamp: date.and_hms_opt(10, 18, 6).unwrap(), timestamp: date.and_hms_opt(10, 18, 6).unwrap(),
@@ -2839,7 +2839,7 @@ fn broker_executes_algo_vwap_value_with_time_window() {
}], }],
Vec::new(), Vec::new(),
vec![ vec![
IntradayExecutionQuote { IntradayExecutionQuote { observation_kind: Default::default(),
date, date,
symbol: "000002.SZ".to_string(), symbol: "000002.SZ".to_string(),
timestamp: date.and_hms_opt(10, 17, 59).unwrap(), timestamp: date.and_hms_opt(10, 17, 59).unwrap(),
@@ -2852,7 +2852,7 @@ fn broker_executes_algo_vwap_value_with_time_window() {
amount_delta: 0.0, amount_delta: 0.0,
trading_phase: Some("continuous".to_string()), trading_phase: Some("continuous".to_string()),
}, },
IntradayExecutionQuote { IntradayExecutionQuote { observation_kind: Default::default(),
date, date,
symbol: "000002.SZ".to_string(), symbol: "000002.SZ".to_string(),
timestamp: date.and_hms_opt(10, 18, 3).unwrap(), timestamp: date.and_hms_opt(10, 18, 3).unwrap(),
@@ -2865,7 +2865,7 @@ fn broker_executes_algo_vwap_value_with_time_window() {
amount_delta: 0.0, amount_delta: 0.0,
trading_phase: Some("continuous".to_string()), trading_phase: Some("continuous".to_string()),
}, },
IntradayExecutionQuote { IntradayExecutionQuote { observation_kind: Default::default(),
date, date,
symbol: "000002.SZ".to_string(), symbol: "000002.SZ".to_string(),
timestamp: date.and_hms_opt(10, 18, 6).unwrap(), timestamp: date.and_hms_opt(10, 18, 6).unwrap(),
@@ -2878,7 +2878,7 @@ fn broker_executes_algo_vwap_value_with_time_window() {
amount_delta: 0.0, amount_delta: 0.0,
trading_phase: Some("continuous".to_string()), trading_phase: Some("continuous".to_string()),
}, },
IntradayExecutionQuote { IntradayExecutionQuote { observation_kind: Default::default(),
date, date,
symbol: "000002.SZ".to_string(), symbol: "000002.SZ".to_string(),
timestamp: date.and_hms_opt(10, 18, 40).unwrap(), timestamp: date.and_hms_opt(10, 18, 40).unwrap(),
@@ -3001,7 +3001,7 @@ fn broker_executes_algo_twap_percent_across_window_quotes() {
}], }],
Vec::new(), Vec::new(),
vec![ vec![
IntradayExecutionQuote { IntradayExecutionQuote { observation_kind: Default::default(),
date, date,
symbol: "000002.SZ".to_string(), symbol: "000002.SZ".to_string(),
timestamp: date.and_hms_opt(10, 0, 0).unwrap(), timestamp: date.and_hms_opt(10, 0, 0).unwrap(),
@@ -3014,7 +3014,7 @@ fn broker_executes_algo_twap_percent_across_window_quotes() {
amount_delta: 0.0, amount_delta: 0.0,
trading_phase: Some("continuous".to_string()), trading_phase: Some("continuous".to_string()),
}, },
IntradayExecutionQuote { IntradayExecutionQuote { observation_kind: Default::default(),
date, date,
symbol: "000002.SZ".to_string(), symbol: "000002.SZ".to_string(),
timestamp: date.and_hms_opt(10, 15, 0).unwrap(), timestamp: date.and_hms_opt(10, 15, 0).unwrap(),
@@ -3027,7 +3027,7 @@ fn broker_executes_algo_twap_percent_across_window_quotes() {
amount_delta: 0.0, amount_delta: 0.0,
trading_phase: Some("continuous".to_string()), trading_phase: Some("continuous".to_string()),
}, },
IntradayExecutionQuote { IntradayExecutionQuote { observation_kind: Default::default(),
date, date,
symbol: "000002.SZ".to_string(), symbol: "000002.SZ".to_string(),
timestamp: date.and_hms_opt(10, 30, 0).unwrap(), timestamp: date.and_hms_opt(10, 30, 0).unwrap(),
@@ -3165,7 +3165,7 @@ fn broker_uses_best_own_price_for_intraday_matching() {
volume: 1_000_000, volume: 1_000_000,
}], }],
Vec::new(), Vec::new(),
vec![IntradayExecutionQuote { vec![IntradayExecutionQuote { observation_kind: Default::default(),
date, date,
symbol: "000002.SZ".to_string(), symbol: "000002.SZ".to_string(),
timestamp: date.and_hms_opt(10, 18, 3).unwrap(), timestamp: date.and_hms_opt(10, 18, 3).unwrap(),
@@ -3284,7 +3284,7 @@ fn broker_uses_best_counterparty_price_for_intraday_matching() {
volume: 1_000_000, volume: 1_000_000,
}], }],
Vec::new(), Vec::new(),
vec![IntradayExecutionQuote { vec![IntradayExecutionQuote { observation_kind: Default::default(),
date, date,
symbol: "000002.SZ".to_string(), symbol: "000002.SZ".to_string(),
timestamp: date.and_hms_opt(10, 18, 3).unwrap(), timestamp: date.and_hms_opt(10, 18, 3).unwrap(),
@@ -4915,7 +4915,7 @@ fn two_day_limit_order_data(day1_open: f64, day2_open: f64) -> DataSet {
}, },
], ],
Vec::new(), Vec::new(),
[(day1, day1_open), (day2, day2_open)].into_iter().map(|(date, price)| IntradayExecutionQuote { [(day1, day1_open), (day2, day2_open)].into_iter().map(|(date, price)| IntradayExecutionQuote { observation_kind: Default::default(),
date, symbol: "000002.SZ".into(), timestamp: date.and_hms_opt(9, 30, 0).unwrap(), date, symbol: "000002.SZ".into(), timestamp: date.and_hms_opt(9, 30, 0).unwrap(),
last_price: price, bid1: price, ask1: price, bid1_volume: 0, ask1_volume: 0, last_price: price, bid1: price, ask1: price, bid1_volume: 0, ask1_volume: 0,
volume_delta: 100_000, amount_delta: 100_000.0 * price, volume_delta: 100_000, amount_delta: 100_000.0 * price,
@@ -55,7 +55,7 @@ fn dataset(day_count: usize, bars_per_day: usize) -> (DataSet, Vec<NaiveDate>) {
let session_start = date.and_hms_opt(9, 30, 0).expect("valid session start"); let session_start = date.and_hms_opt(9, 30, 0).expect("valid session start");
for offset in 0..bars_per_day { for offset in 0..bars_per_day {
let timestamp = session_start + Duration::minutes(offset as i64); let timestamp = session_start + Duration::minutes(offset as i64);
quotes.push(IntradayExecutionQuote { quotes.push(IntradayExecutionQuote { observation_kind: Default::default(),
date: *date, date: *date,
symbol: SYMBOL.to_string(), symbol: SYMBOL.to_string(),
timestamp, timestamp,
@@ -7,6 +7,7 @@ use fidc_core::{
PortfolioState, PriceField, StrategyDecision, platform_expr_config_from_value, PortfolioState, PriceField, StrategyDecision, platform_expr_config_from_value,
}; };
use rust_decimal::Decimal; use rust_decimal::Decimal;
use fidc_core::IntradayExecutionQuote;
use std::collections::{BTreeMap, BTreeSet}; use std::collections::{BTreeMap, BTreeSet};
fn day(n: u32) -> NaiveDate { fn day(n: u32) -> NaiveDate {
@@ -142,7 +143,7 @@ fn data_with_fund_rules(
}) })
}) })
.collect(); .collect();
let quotes = market.iter().filter(|row| row.symbol != "000300.SH").map(|row| fidc_core::IntradayExecutionQuote { let quotes = market.iter().filter(|row| row.symbol != "000300.SH").map(|row| fidc_core::IntradayExecutionQuote { observation_kind: Default::default(),
date: row.date, symbol: row.symbol.to_string(), timestamp: row.date.and_hms_opt(9, 30, 0).unwrap(), date: row.date, symbol: row.symbol.to_string(), timestamp: row.date.and_hms_opt(9, 30, 0).unwrap(),
last_price: row.open, bid1: row.open, ask1: row.open, bid1_volume: 0, ask1_volume: 0, last_price: row.open, bid1: row.open, ask1: row.open, bid1_volume: 0, ask1_volume: 0,
volume_delta: row.volume, amount_delta: row.open * row.volume as f64, volume_delta: row.volume, amount_delta: row.open * row.volume as f64,
@@ -489,6 +490,72 @@ fn repeating_the_same_partial_exit_generation_does_not_reduce_again() {
assert_eq!(new_signal.fill_events.iter().filter(|fill|fill.symbol==code(1)).map(|fill|fill.quantity).sum::<u32>(),300); assert_eq!(new_signal.fill_events.iter().filter(|fill|fill.symbol==code(1)).map(|fill|fill.quantity).sum::<u32>(),300);
} }
#[test]
fn daily_execution_price_does_not_satisfy_an_unobserved_order_book_condition() {
let data = data(false);
for field in ["bid1", "ask1"] {
let broker = broker(false);
let mut account = PortfolioState::new(30000.);
let mut intent = contract(day(2), 1, false);
intent.rule.trigger_mode = "condition".into();
intent.rule.buy_condition = format!("{field}>0");
let result = broker.execute_with_event_dates(day(5), day(2), day(2), &mut account, &data, &decision(intent));
assert!(result.unwrap_err().to_string().contains(field));
assert!(account.positions().is_empty());
assert_eq!(account.cash(), 30000.);
}
}
#[test]
fn cumulative_conditions_do_not_consume_future_bars_or_inflate_fill_capacity() {
let mut data = data(false);
let mut quotes = Vec::new();
for n in 1..=2 {
let price = if n == 1 {20.} else {10.};
for (minute, volume) in [(30,600), (31,0), (32,400)] {
quotes.push(IntradayExecutionQuote {
observation_kind: fidc_core::data::QuoteObservationKind::MinuteBar,
date: day(5), symbol: code(n), timestamp: day(5).and_hms_opt(9,minute,0).unwrap(),
last_price: price, bid1: 0., ask1: 0., bid1_volume: 0, ask1_volume: 0,
volume_delta: volume, amount_delta: volume as f64 * price, trading_phase: None,
});
}
}
data.replace_execution_quotes(quotes.clone());
let at = chrono::NaiveTime::from_hms_opt(9,32,0).unwrap();
for condition in ["volume>=1000", "amount>=20000"] {
let broker=broker(true).with_matching_type(MatchingType::MinuteLast).with_intraday_execution_start_time(at);
let mut account=PortfolioState::new(30000.);
let mut intent=contract(day(5),1,false);
intent.rule.buy_condition=condition.into();intent.rule.trigger_mode="condition".into();
let report=broker.execute_with_event_dates(day(5),day(5),day(5),&mut account,&data,&decision(intent)).unwrap();
assert_eq!(report.fill_events.iter().map(|fill|fill.quantity).sum::<u32>(),100,"{condition}: {report:?}");
assert_eq!(data.execution_quotes_on(day(5),&code(1))[2].volume_delta,400);
}
let mut future=quotes.last().unwrap().clone();future.symbol=code(1);future.timestamp=day(5).and_hms_opt(9,33,0).unwrap();future.volume_delta=9000;future.amount_delta=180000.;
data.add_execution_quotes(vec![future]);
let broker=broker(false).with_matching_type(MatchingType::MinuteLast).with_intraday_execution_start_time(at);
let mut account=PortfolioState::new(30000.);
let mut intent=contract(day(5),1,false);intent.rule.buy_condition="volume>1000".into();intent.rule.trigger_mode="condition".into();
let report=broker.execute_with_event_dates(day(5),day(5),day(5),&mut account,&data,&decision(intent)).unwrap();
assert!(report.fill_events.is_empty(),"future volume must not satisfy this signal: {report:?}");
}
#[test]
fn session_total_cache_is_invalidated_without_mutating_other_dataset_clones() {
let mut original=data(false);
let quote=IntradayExecutionQuote { observation_kind:fidc_core::data::QuoteObservationKind::MinuteBar,date:day(5),symbol:code(1),timestamp:day(5).and_hms_opt(9,30,0).unwrap(),last_price:20.,bid1:0.,ask1:0.,bid1_volume:0,ask1_volume:0,volume_delta:100,amount_delta:2000.,trading_phase:None };
original.replace_execution_quotes(vec![quote.clone()]);
assert_eq!(original.execution_session_totals(&code(1),quote.timestamp).unwrap().0,Decimal::from(100));
let mut changed=original.clone();let mut next=quote.clone();next.timestamp=day(5).and_hms_opt(9,31,0).unwrap();
changed.add_execution_quotes(vec![next.clone()]);
assert_eq!(changed.execution_session_totals(&code(1),next.timestamp).unwrap().0,Decimal::from(200));
assert!(original.execution_session_totals(&code(1),next.timestamp).is_err());
changed.remove_execution_quotes_on_date(day(5));
assert!(changed.execution_session_totals(&code(1),quote.timestamp).is_err());
assert_eq!(original.execution_session_totals(&code(1),quote.timestamp).unwrap().0,Decimal::from(100));
}
#[test] #[test]
fn parsed_pool_program_executes_daily_membership_changes_without_legacy_translation() { fn parsed_pool_program_executes_daily_membership_changes_without_legacy_translation() {
let intent = contract(day(2), 1, false); let intent = contract(day(2), 1, false);
@@ -561,6 +628,37 @@ fn parsed_pool_program_executes_daily_membership_changes_without_legacy_translat
} }
} }
#[test]
fn pool_position_adjustments_use_execution_clock_and_restore_original_twenty_percent() {
for timed in [false,true] {
let program=StockPoolProgram { schema_version:1,pool_id:"position-clock".into(),version_id:"v1".into(),
members:contract(day(2),1,false).members,exit_signals:vec![],
allocation_policy:serde_json::json!({"target_holding_count":1,"invest_ratio_bps":2000,"portfolio_policy":{"schema_version":1,"membership":"follow_candidates","rebalance_weights":true}}),
timing_policy:serde_json::json!({"auto_execute":true,"pricing_mode":"first_tick"}),stop_take_policy:serde_json::json!({}),out_of_pool_policy:"hold".into() };
let risk=if timed {serde_json::json!({"positionExposureEvents":[
{"eventId":"zero","sequence":1,"effectiveAt":"2026-01-05T09:30:00+08:00","action":"set","targetExposureBps":0},
{"eventId":"restore","sequence":2,"effectiveAt":"2026-01-06T09:30:00+08:00","action":"restore"}
]})}else{serde_json::json!({"positionExposureSchedule":[{"effectiveDate":"2026-01-05","targetExposureBps":1000}]})};
let mut config=platform_expr_config_from_value("position-clock",&code(1),&serde_json::json!({
"stockPool":program,"signalSymbol":code(1),"benchmark":{"instrumentId":"000300.SH"},"universe":{"include":[code(1),code(2)]},
"runtimeExpressions":{"risk":risk}
})).unwrap();
config.market_cap_field="close".into();config.market_cap_lower_expr="0".into();config.market_cap_upper_expr="1.0e12".into();
config.stock_filter_expr="true".into();config.selection_limit_expr="1".into();config.selection_candidate_limit_expr="2".into();config.rank_expr="0".into();
config.matching_type=MatchingType::NextBarOpen;
let result=BacktestEngine::new(data(false),PlatformExprStrategy::new(config),broker(false),BacktestConfig {
// The raw engine retains its first signal day as a cash baseline;
// Jan 2's signal executes Jan 5, across the fixture weekend.
initial_cash:30000.,benchmark_code:"000300.SH".into(),start_date:Some(day(2)),end_date:Some(day(6)),
decision_lag_trading_days:1,execution_price_field:PriceField::Open,
}).run().unwrap();
assert_eq!(result.fills.len(),1,"timed={timed}, fills={:?}",result.fills);
assert_eq!(result.fills[0].symbol,code(1));
assert_eq!(result.fills[0].quantity,if timed {300}else{100});
assert_eq!(result.fills[0].date,if timed {day(6)}else{day(5)});
}
}
#[test] #[test]
fn parsed_typed_exit_program_keeps_ordinary_gates_and_independent_risk_targets_separate() { fn parsed_typed_exit_program_keeps_ordinary_gates_and_independent_risk_targets_separate() {
for (ordinary, risk, quote, sold) in [ for (ordinary, risk, quote, sold) in [
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,109 @@
# 表达式上下文性能验收
## 范围
本轮优化 FIDC 引擎的逐股票表达式上下文,不修改策略、因子值、窗口、时间可见性、
选股/订单规则、费用、成交价、风控或公司行为。Source 保持 `d5b682c6d097`
研究和信号保持暂停。其他用户任务只读观察,不更改其进程、亲和性或配置。
已完成编译、回归、正式回放与177发布验收,不能据此关闭整个目标。
## 重复开销
1. DataSet 已采用 `NumericFactorMap`,但 `StockExpressionState` 仍把数值因子
重建为 `BTreeMap<String, f64>`,增加树节点和字符串分配。
2. 每日可用因子名集合先为每个证券复制名称,再由集合丢弃重复名称。
候选在表达式上下文延续紧凑数值存储。每日名称仍按当日真实字段生成完整集合,
仅改为先对借用名称去重,再为唯一名称分配字符串;文本因子同理。
没有用全局/未来日期目录替代当日字段,没有缓存选股结果或账户状态。
Rhai `factors[...]`、缺失、NaN、负零、别名、覆盖顺序与已完成交易日可见性保持原行为。
## CPU 计时
Runner 新增以下运行计时,HTTP benchmark 同样保留:
- `engineTaskWallSeconds`:实际引擎同步任务的墙钟耗时,包含其同步数据读取等待。
- `engineThreadCpuSeconds`Linux `CLOCK_THREAD_CPUTIME_ID` 实测的引擎调用线程CPU时间。
不包含其他并行线程、I/O等待或未被调度的时间;不可当作整个进程总CPU时间。
读取不可用、跨线程或时钟异常保持null,不填0。
- `engineTaskCount`:实际执行引擎的次数,包含原有修复循环的重放。
这些是 `engineSeconds` 的子指标,禁止再次加到总耗时。正常耗时与诊断运行分开保存。
计时不改写历史制品,旧记录缺少该指标时保持未知。
## 验收设置
- 固定引擎基线 `fe7243b`;候选为177的 `07b7b18`,对应本地 `df1862e`
- 两边使用同一计时版service `a9df11a``b5d22ff`仅补充benchmark字段读取。
- 2021-08-23至2025-11-17、初始1000万、原冻结runtime与策略,1025个执行交易日。
- 保留原 `session_capacity_audit`,不能当作实际开盘流动性验证。
- 官方benchmark入口、Boris执行、同CPU资源与Source版本、新进程、相同数据缓存副本、
新结果目录,不复用回测结果。
- 引擎780项、runner408项、API113项、脚本10项通过;9/8/3项手动或外部环境用例分别忽略。
- 专项延伸验证紧凑因子的克隆、Rhai映射暴露、缺失、NaN及负零;CPU计时验证睡眠和跨线程边界。
证据根:`/srv/fidc/canonical/run/research/engine-context-20260913`
## 独立进程对照
| 次序 | 样本 | 完整墙钟 | Source校验 | 数据准备 | 引擎墙钟 | 引擎线程CPU |
|---|---|---:|---:|---:|---:|---:|
| 1 | control-1 | 31.234s | 11.202s | 8.393s | 10.461s | 10.458s |
| 2 | candidate-1 | 17.002s | 0.004s | 8.404s | 7.406s | 7.404s |
| 3 | candidate-2 | 18.203s | 0.003s | 8.371s | 7.411s | 7.408s |
| 4 | control-2 | 22.983s | 0.004s | 8.323s | 13.401s | 13.398s |
| 5 | control-3 | 30.714s | 0.005s | 15.318s | 13.999s | 13.990s |
| 6 | candidate-3 | 25.336s | 0.005s | 13.524s | 10.471s | 10.468s |
首个基线的Source校验等待原样保留,不事后改称预热,不把11.202秒归因于引擎改动。
后段样本出现主机负载/缓存竞争变化,数据准备也变慢,不能直接用全组平均墙钟夸大提速。
相邻低负载对照的引擎线程CPU为10.458至7.404秒,后段为13.990至10.468秒。
CPU计时与任务墙钟非常接近,证明样本主要在执行CPU工作,而不是等待HTTP;
这不代表没有SMT、内存带宽或其他用户CPU竞争。
六次均为21,393笔成交,账户、权益、委托、成交、持仓和风控canonical及结果制品完全一致。
每份63个数据缓存文件经完整SHA核对相同,没有复制或读取旧回测结果。
## HTTP 对照
| 状态 | 版本 | 运行ID | 总耗时 | 引擎耗时 |
|---|---|---|---:|---:|
| 清DataSet,磁盘/Source保持 | 原版 | btr_1789232559582_3166774_4 | 21.987s | 11.328s |
| 清DataSet,磁盘/Source保持 | 原版 | btr_1789232585690_3166774_5 | 21.684s | 11.259s |
| 复用DataSet | 原版 | btr_1789232669598_3166774_6 | 11.820s | 11.031s |
| 复用DataSet | 原版 | btr_1789232684861_3166774_7 | 11.857s | 11.067s |
| 清DataSet,磁盘/Source保持 | 新版 | btr_1789232818009_3320588_0 | 17.296s | 7.537s |
| 清DataSet,磁盘/Source保持 | 新版 | btr_1789232839269_3320588_1 | 17.413s | 7.627s |
| 复用DataSet | 新版 | btr_1789232898983_3320588_2 | 8.549s | 7.738s |
| 复用DataSet | 新版 | btr_1789232910904_3320588_3 | 8.586s | 7.784s |
同状态HTTP均值:重建DataSet从21.836至17.355秒,减少约20.5%
复用DataSet从11.839至8.568秒,减少约27.6%。两种状态分开比较,
没有把8.568秒当作Source冷启动成绩。与上一轮不同时间的15/17秒样本不作直接百分比对比。
原版API没有线程CPU字段,保持null;新版本每次实际执行引擎一次,
两次重建的线程CPU为7.535/7.624秒。没有用新版本计时回填旧记录。
八次HTTP和六次独立回放的canonical及结果制品SHA全部相同,终态审计clean。
## 发布状态
177通过官方安装器发布 engine `07b7b181b60138c6ef1c965543c0e3192ac65903`
service `b5d22ffab16f851eced3028e12fa02627ee4c399`
运行身份 `fdd8652a47a5935be4d891beb3b8b0f3e19a468be166a902a2a97b85a9c9e01e`
- API SHA`bf22f58946c3fa495161eb381a400d4e28d7c8d327ee46f5645d83a8308117cf`
- Runner SHA`7b3849cd8af33d650db242add80c49cfdd32e8cc8686a614da7b3b4016ce2a60`
- 生产在用构建根:`/srv/fidc/canonical/build/engine-context-candidate-20260913`,禁止清理。
- 原生因子能力目录发布前后字节相同,SHA为
`cec37331a476bc39bdea32c308581b8ac2f86d005d8dd4cc7ba228c5d9dc9a2e`
- API PID3320588Boris、active、NRestarts=0Source仍为PID1700096/d5,研究未恢复。
[完整结构化验收证据](evidence/expression-context-performance-20260913.json)
SHA256 `f526950e018354c1305922beebf4063ae3823004f8c5ab20510a452f98b7b7ea`
## 边界
本轮真实长区间案例含一个原生扩展因子,动态映射、缺失及多字段语义另由引擎回归覆盖;
不宣称所有策略都具有相同比例提速。Source冷路径仍受独立冻结约束,
信号闭环和全部策略/分钟区间/财务PIT不在本轮通过范围内。
@@ -0,0 +1,75 @@
# 特征日行情缺口与跨日回退
## 问题
审查发现股票表达式上下文的三个位置把缺少的特征日行情回退到执行/当前市场日:
两个 `StockStateSnapshotSource::feature_market` 实现,以及最终构建器的 `unwrap_or(market)`
`factor_date` 早于 `date` 时,这会把后来日期的OHLCV用于原本指定的历史特征日。
这是错误日期代用,并具有前视风险;不据此推断所有历史回测都已触发此路径。
真实代码的合成缺口回归已复现:2025-04-03因子快照存在但行情缺失,
2025-04-07行情存在,旧版返回close=20.0、volume=45600、open=19.0
而非报告4月3日行情缺失。此回归使用合成数据验证代码路径,不是行情数据造假或真实市场收益样本。
## 修改
- 两个行情读取入口只返回指定特征日期的快照,删除向执行日的回退。
- 构建器缺少快照时返回 `MissingSnapshot { kind: "feature_market", date: factor_date, symbol }`
- 保持原市场、候选和因子缺失错误优先级;失败状态不写入股票上下文缓存。
- 同日上下文继续使用同日快照;正常跨日上下文仍将历史OHLCV与执行报价分开。
- 不调整价格、因子、窗口、风险、手续费、滑点、成交量或原始数据,不引入替代缓存。
新增回归覆盖索引读取、每日视图读取、错误缓存边界和同日合法输入。
原next-open涨跌停测试只有前一日因子、没有对应行情,原先隐式依赖了该回退。
已补充两只证券各自明确的历史行情,并断言历史价1.80与决策日价2.20分开;
不放宽时点规则,也不改真实数据。
## 当前状态
177的红色回归已确认为行为失败;最初缺少错误枚举限定名的编译失败另存,不作为复现证据。
修复后完整引擎783项、runner410项、API113项通过,分别9/8/3项既有外部或手动测试忽略。
已通过官方入口发布到回测服务;Paper/Live/Strategy Runtime没有在本轮重建或重启,
不能将共享源码修复等同于全部消费者已经部署。
## 真实回放
固定原策略、2021-08-23至2025-11-17、初始1000万及原冻结bundle。
保留历史`session_capacity_audit`,不能当作开盘容量验收。
全部运行重新执行引擎,Source/磁盘数据缓存保持,不缓存回测结果。
| 状态 | 版本 | 运行ID | 总耗时 | 数据准备 | 引擎 |
|---|---|---|---:|---:|---:|
| 清DataSet内存 | 原版 | btr_1789251904666_3596554_4 | 17.689s | 8.664s | 8.141s |
| 清DataSet内存 | 原版 | btr_1789251925997_3596554_5 | 17.082s | 8.333s | 7.947s |
| 清DataSet内存 | 修复版 | btr_1789252042334_3735010_0 | 15.668s | 7.247s | 7.608s |
| 清DataSet内存 | 修复版 | btr_1789252061568_3735010_1 | 15.717s | 6.949s | 7.985s |
| DataSet复用 | 修复版 | btr_1789252206579_3735010_2 | 8.790s | 0.006s | 7.988s |
| DataSet复用 | 修复版 | btr_1789252217644_3735010_3 | 9.892s | 0.006s | 7.880s |
六次均21,393笔成交,账户、权益、委托、成交、持仓、风控canonical及完整制品SHA一致,
终态clean,每次引擎执行次数为1。真实完整数据没有触发新增缺失错误。
最后一次包含1.233秒Source合同验证,不能把DataSet复用等同于Source无等待。
本轮未观察到该样本的性能回退,但这是正确性修复;主机负载及数据读取也有波动,
不将17秒至15秒归因于普遍算法提速,更不外推所有策略。
canonical`3f089cbcc5412e07bbe2308d0dd60ac561a119f0a2228010fc3323714fde8ca7`
结果制品:`1905f0c8a887215279342b26d5769a6cbe40058971eef15adf86ef2eaa02aeb9`
## 发布证据
177 engine `e3b39295787c4fd896753d633e457deddf9f1232`service `106a89d8bb74af494cdf84d9d3af5ec4bcb249cd`
- API`86f0a0385410db8ab308edf892f4ad6376c0a706c0ebbac0f397a23539d782c0`
- Runner`aebdd37ad30ee73f11a9ffd206ad8c978ced19a257cb51849881b0e5bdce36ee`
- 运行身份:`d196bd4740b1b506c42515a689ae155a21e37b8092956b99a298b8d7934e53c7`
- 在用构建根:`/srv/fidc/canonical/build/feature-date-candidate-20260913`,禁止清理。
- PID3735010、Boris、active、NRestarts=0;收据时cgroup约7.91GiB,峰值约9.29GiB。
- 原生目录字节SHA仍为`cec37331a476bc39bdea32c308581b8ac2f86d005d8dd4cc7ba228c5d9dc9a2e`
- Source保持d5/PID1700096,研究和信号未恢复,没有向券商提交订单。
证据根 `/srv/fidc/canonical/run/research/feature-date-integrity-20260913`
[结构化证据](evidence/feature-date-market-integrity-20260913.json)
SHA256 `56e70172916c45060106aca3eb006984735a3f85e6b13d2c325c409e83b8962b`
更多策略、真实缺口数据审计、Paper/Live消费者发布及完整财务PIT仍未完成。
Source目录缓存的隔离后继验证单独见Alpha Factory的`docs/native-condition-transport-20260913.md`
不能把本轮回测发布当作Source冻结解除。
+13
View File
@@ -0,0 +1,13 @@
# 仓位事件执行合同
2026-09-13。`runtimeExpressions.risk.positionExposureEvents` 使用带eventId、严格唯一sequence、UTC有效时点的事件;必须明确指定set、scale或restore。缺失动作、重复身份、非法比例和无时区日期均拒绝。
- scale用于人工比例乘数:普通轮动仍先计算策略自身仓位,0%指数择时不会被人工100%覆盖。显式权益买入和目标类委托,以及SignalBook产生的意图,同样按比例处理;不修改原SignalBook。
- 卖出/减仓增量、零目标清仓、取消、订阅、现金流和价格不被缩量。对已有买单增加数量只缩放增加部分;无法确定被改单身份时拒绝。期货等未定义类型不静默转换。
- set用于股票池投入比例等明确绝对目标;restore恢复原策略/池规则,不转换成100%。旧日期级positionExposureSchedule保留原粒度,新的恢复事件不再回落到旧人工值。
- 比例按实际执行时点读取;股票池不再用信号日读取覆盖值。原引擎首信号日现金基线和next-open调度合同不改变。
- 不改变OHLCV、费用、价格精度、证券生命周期或成交量容量合同。
验证覆盖同日多次调整、未来事件隔离、0/30/50/100%、20%原策略恢复、显式委托与现金流、以及原始引擎跨周末的股票池回放:1月2日信号在1月5日执行,1月5日覆盖在该日生效,1月6日恢复20%而不是100%。测试行情明确是隔离夹具,不代表真实历史或券商成交验收。
交易侧用不可变操作审计提供事件,保留运行任务/账户绑定和原始请求。此模块不自己下单或创建新的回测,不读取用户资金账户。未完成的独立人工调仓命令与逐笔人工交易影子回放仍需另行验收,不能据时间线通过声明所有调仓路径完成。
@@ -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,再配套发布。优先级仍可在真实资金或仓位约束不足时影响分配,不能预设所有不同排序的结果必须相同。