Compare commits

...

47 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
boris 531df39911 chore(test): remove obsolete tree-map import after compact snapshot merge 2026-09-12 16:29:56 +08:00
boris e912e2a6f5 Merge remote-tracking branch 'origin/main' 2026-09-12 16:26:06 +08:00
boris b1f2fcb85c fix(stock-pool): scan decoded native conditions instead of serialized source echoes 2026-09-12 16:23:18 +08:00
boris 828690a51e perf(data): compact immutable snapshots with current execution rules 2026-09-12 16:02:24 +08:00
boris ffd23b9920 fix(stock-pool): preserve exit roles and freeze relative reduction bases 2026-09-12 15:54:33 +08:00
boris 4ac9ee5058 fix(stock-pool): keep full stops stronger than simultaneous reductions 2026-09-12 14:21:33 +08:00
boris 848c1a514a fix(stock-pool): separate exit ownership and ordinary quote dependencies 2026-09-12 12:18:26 +08:00
boris 099759ae67 fix(stock-pool): execute verified ETF daily fallbacks with frozen next-open targets 2026-09-12 10:47:47 +08:00
boris d646ca455d merge: integrate causal capacity model with current order clocks and intent planning 2026-09-12 08:52:37 +08:00
boris 3b2a97fa84 fix(backtest): keep next-open limit remainders active on the execution day 2026-09-12 08:02:41 +08:00
boris 7ac87a90c4 test: keep snapshot price-risk fixtures in their explicit daily model 2026-09-12 06:51:33 +08:00
boris 5949d4cc69 refactor: remove retired daily quota parameters from target diagnostics 2026-09-12 06:48:07 +08:00
boris 24cb4805a7 fix: keep decision estimates independent and use timed capacity in fill fixtures 2026-09-12 06:46:49 +08:00
boris 053f880e34 test: declare historical audit assumptions in non-liquidity fixtures 2026-09-12 06:39:34 +08:00
boris 4d3a9e0e5b fix: separate historical session capacity audits from execution sizing 2026-09-12 06:32:10 +08:00
42 changed files with 11964 additions and 1655 deletions
Generated
+30
View File
@@ -145,6 +145,15 @@ version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]]
name = "castaway"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a"
dependencies = [
"rustversion",
]
[[package]] [[package]]
name = "cc" name = "cc"
version = "1.2.59" version = "1.2.59"
@@ -192,6 +201,20 @@ dependencies = [
"windows-link", "windows-link",
] ]
[[package]]
name = "compact_str"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "79fcda08c33bb58b97008b2cdada6622500e949e060f5913361763121abd2416"
dependencies = [
"castaway",
"cfg-if",
"itoa",
"serde",
"static_assertions",
"zmij",
]
[[package]] [[package]]
name = "const-random" name = "const-random"
version = "0.1.18" version = "0.1.18"
@@ -316,6 +339,7 @@ version = "0.1.0"
dependencies = [ dependencies = [
"ahash 0.8.12", "ahash 0.8.12",
"chrono", "chrono",
"compact_str",
"indexmap", "indexmap",
"rayon", "rayon",
"rhai", "rhai",
@@ -2044,3 +2068,9 @@ dependencies = [
"quote", "quote",
"syn 3.0.5", "syn 3.0.5",
] ]
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
+1
View File
@@ -6,6 +6,7 @@ license.workspace = true
authors.workspace = true authors.workspace = true
[dependencies] [dependencies]
compact_str = { version = "=0.10.0", features = ["serde"] }
rust_decimal.workspace = true rust_decimal.workspace = true
ahash.workspace = true ahash.workspace = true
chrono.workspace = true chrono.workspace = true
+271 -137
View File
@@ -7,7 +7,7 @@ use chrono::{Duration, NaiveDate, NaiveDateTime, NaiveTime};
use crate::cost::CostModel; use crate::cost::CostModel;
use crate::data::{DataSet, IntradayExecutionQuote, PriceField}; use crate::data::{DataSet, IntradayExecutionQuote, PriceField};
use crate::engine::BacktestError; use crate::engine::BacktestError;
use crate::execution_capacity::{CapacityError, ParticipationRate, VolumeObservation, VolumeObservationKind}; use crate::execution_capacity::{CapacityAuditSummary, CapacityError, ParticipationRate, SessionCapacityAudit, VolumeCapacityMode, VolumeObservation, VolumeObservationKind};
use crate::execution_schedule::TwapSchedule; use crate::execution_schedule::TwapSchedule;
use crate::events::{ use crate::events::{
AccountEvent, FillEvent, OrderEvent, OrderSide, OrderStatus, PositionEvent, ProcessEvent, AccountEvent, FillEvent, OrderEvent, OrderSide, OrderStatus, PositionEvent, ProcessEvent,
@@ -205,6 +205,7 @@ struct OpenOrder {
decision_date: Option<NaiveDate>, decision_date: Option<NaiveDate>,
order_created_date: Option<NaiveDate>, order_created_date: Option<NaiveDate>,
submission_time: Option<NaiveTime>, submission_time: Option<NaiveTime>,
accepted_date: NaiveDate,
symbol: String, symbol: String,
side: OrderSide, side: OrderSide,
requested_quantity: u32, requested_quantity: u32,
@@ -221,6 +222,7 @@ struct OpenOrder {
struct RestingOrderOrigin { struct RestingOrderOrigin {
created_date: Option<NaiveDate>, created_date: Option<NaiveDate>,
submission_time: Option<NaiveTime>, submission_time: Option<NaiveTime>,
accepted_date: NaiveDate,
} }
#[derive(Debug, Default)] #[derive(Debug, Default)]
@@ -421,6 +423,12 @@ struct AlgoExecutionRequest {
} }
pub struct BrokerSimulator<C, R> { pub struct BrokerSimulator<C, R> {
historical_etf_open_fallback: bool,
verified_etf_minute_absences: RefCell<BTreeSet<(NaiveDate, String)>>,
runtime_etf_daily_open: Cell<bool>,
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,
@@ -430,6 +438,7 @@ pub struct BrokerSimulator<C, R> {
volume_percent: f64, volume_percent: f64,
volume_rate: Result<ParticipationRate, CapacityError>, volume_rate: Result<ParticipationRate, CapacityError>,
volume_limit: bool, volume_limit: bool,
volume_capacity_mode: VolumeCapacityMode,
inactive_limit: bool, inactive_limit: bool,
liquidity_limit: bool, liquidity_limit: bool,
strict_value_budget: bool, strict_value_budget: bool,
@@ -458,6 +467,12 @@ pub struct BrokerSimulator<C, R> {
impl<C, R> BrokerSimulator<C, R> { impl<C, R> BrokerSimulator<C, R> {
pub fn new(cost_model: C, rules: R) -> Self { pub fn new(cost_model: C, rules: R) -> Self {
Self { Self {
historical_etf_open_fallback: false,
verified_etf_minute_absences: RefCell::new(BTreeSet::new()),
runtime_etf_daily_open: Cell::new(false),
deferred_etf_targets: RefCell::new(Default::default()),
deferred_stock_pools: RefCell::new(BTreeMap::new()),
runtime_stock_pool_followup: Cell::new(false),
cost_model, cost_model,
rules, rules,
board_lot_size: 100, board_lot_size: 100,
@@ -467,6 +482,7 @@ impl<C, R> BrokerSimulator<C, R> {
volume_percent: 0.25, volume_percent: 0.25,
volume_rate: ParticipationRate::new(0.25), volume_rate: ParticipationRate::new(0.25),
volume_limit: true, volume_limit: true,
volume_capacity_mode: VolumeCapacityMode::ExecutionObservation,
inactive_limit: true, inactive_limit: true,
liquidity_limit: true, liquidity_limit: true,
strict_value_budget: true, strict_value_budget: true,
@@ -499,6 +515,12 @@ impl<C, R> BrokerSimulator<C, R> {
execution_price_field: PriceField, execution_price_field: PriceField,
) -> Self { ) -> Self {
Self { Self {
historical_etf_open_fallback: false,
verified_etf_minute_absences: RefCell::new(BTreeSet::new()),
runtime_etf_daily_open: Cell::new(false),
deferred_etf_targets: RefCell::new(Default::default()),
deferred_stock_pools: RefCell::new(BTreeMap::new()),
runtime_stock_pool_followup: Cell::new(false),
cost_model, cost_model,
rules, rules,
board_lot_size: 100, board_lot_size: 100,
@@ -508,6 +530,7 @@ impl<C, R> BrokerSimulator<C, R> {
volume_percent: 0.25, volume_percent: 0.25,
volume_rate: ParticipationRate::new(0.25), volume_rate: ParticipationRate::new(0.25),
volume_limit: true, volume_limit: true,
volume_capacity_mode: VolumeCapacityMode::ExecutionObservation,
inactive_limit: true, inactive_limit: true,
liquidity_limit: true, liquidity_limit: true,
strict_value_budget: true, strict_value_budget: true,
@@ -539,6 +562,63 @@ impl<C, R> BrokerSimulator<C, R> {
self self
} }
pub fn with_volume_capacity_mode(mut self, mode: VolumeCapacityMode) -> Self {
self.volume_capacity_mode = mode;
self
}
/// Historical stock-pool adapter only. Online runtimes never enable this.
pub fn with_historical_etf_open_fallback(mut self, enabled: bool) -> Self {
self.historical_etf_open_fallback = enabled;
self
}
pub(crate) fn requires_etf_absence_check(&self, data: &DataSet, symbol: &str) -> bool {
self.historical_etf_open_fallback && data.instrument(symbol).is_some_and(|v| v.is_exchange_traded_fund())
}
pub(crate) fn record_complete_etf_minute_query(&self, date: NaiveDate, data: &DataSet, symbols: &[String]) {
for symbol in symbols {
if self.requires_etf_absence_check(data, symbol) && data.execution_quotes_on(date, symbol).is_empty() {
self.verified_etf_minute_absences.borrow_mut().insert((date, symbol.clone()));
}
}
}
pub(crate) fn has_verified_etf_minute_absence(&self, date: NaiveDate, symbol: &str) -> bool {
self.historical_etf_open_fallback && self.verified_etf_minute_absences.borrow().contains(&(date, symbol.to_string()))
}
fn with_etf_daily_open<T>(&self, operation: impl FnOnce() -> Result<T, BacktestError>) -> Result<T, BacktestError> {
if self.liquidity_limit {
return Err(BacktestError::Execution("etf_daily_open_fallback: historical opening depth is unavailable; cannot satisfy liquidity_limit".into()));
}
self.volume_capacity_mode.validate(self.volume_limit, false)
.map_err(|error| BacktestError::Execution(format!("etf_daily_open_fallback: {error}")))?;
let prior = self.runtime_etf_daily_open.replace(true);
let result = operation();
self.runtime_etf_daily_open.set(prior);
result
}
pub fn capacity_audit_summary(&self) -> CapacityAuditSummary {
CapacityAuditSummary { mode: self.volume_capacity_mode, enabled: self.volume_limit,
participation_rate: self.volume_percent, ..Default::default() }
}
pub fn audit_completed_session_capacity(&self, date: NaiveDate, data: &DataSet) -> Result<Vec<SessionCapacityAudit>, BacktestError> {
if !self.volume_limit || self.volume_capacity_mode != VolumeCapacityMode::SessionCapacityAudit {
return Ok(Vec::new());
}
let session = self.execution_session.borrow();
if session.date != Some(date) { return Ok(Vec::new()); }
let rate = self.volume_rate.map_err(|error| BacktestError::Execution(error.to_string()))?;
session.intraday_turnover.iter().filter(|(_, quantity)| **quantity > 0).map(|(symbol, quantity)| {
let market = data.market(date, symbol).ok_or_else(|| BacktestError::MissingPrice { date, symbol: symbol.clone(), field: "session capacity audit" })?;
Ok(SessionCapacityAudit::new(date, symbol.clone(), u64::from(*quantity), market.volume, rate))
}).collect()
}
pub fn with_inactive_limit(mut self, enabled: bool) -> Self { pub fn with_inactive_limit(mut self, enabled: bool) -> Self {
self.inactive_limit = enabled; self.inactive_limit = enabled;
self self
@@ -646,11 +726,21 @@ impl<C, R> BrokerSimulator<C, R> {
.or(self.intraday_execution_start_time) .or(self.intraday_execution_start_time)
} }
fn order_origin(&self) -> RestingOrderOrigin { fn order_origin(&self) -> (Option<NaiveDate>, Option<NaiveTime>) {
self.runtime_resting_order_origin.get().unwrap_or(RestingOrderOrigin { self.runtime_resting_order_origin.get().map_or(
created_date: self.runtime_order_created_date.get(), (self.runtime_order_created_date.get(), self.submission_time()),
submission_time: self.submission_time(), |origin| (origin.created_date, origin.submission_time),
}) )
}
fn accepted_order_date(&self, date: NaiveDate) -> NaiveDate {
self.runtime_resting_order_origin.get().map_or(date, |origin| origin.accepted_date)
}
fn resting_daily_open_order(&self) -> bool {
self.runtime_resting_order_origin.get().is_some()
&& self.runtime_intraday_start_time.get().is_some()
&& self.matching_type == MatchingType::NextBarOpen
} }
fn execution_phase_for_submission( fn execution_phase_for_submission(
@@ -681,8 +771,8 @@ impl<C, R> BrokerSimulator<C, R> {
let origin = self.order_origin(); let origin = self.order_origin();
self.execution_phase_for_submission( self.execution_phase_for_submission(
date, date,
origin.created_date, origin.0,
origin.submission_time, origin.1,
) )
} }
@@ -691,8 +781,11 @@ impl<C, R> BrokerSimulator<C, R> {
} }
fn effective_execution_price_field(&self, date: NaiveDate) -> PriceField { fn effective_execution_price_field(&self, date: NaiveDate) -> PriceField {
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() || (self.runtime_stock_pool_followup.get() && self.matching_type == MatchingType::NextBarOpen) {
PriceField::Last
} else { } else {
self.execution_price_field self.execution_price_field
} }
@@ -705,8 +798,8 @@ impl<C, R> BrokerSimulator<C, R> {
let origin = self.order_origin(); let origin = self.order_origin();
self.post_close_execution_quote_window_for_submission( self.post_close_execution_quote_window_for_submission(
date, date,
origin.created_date, origin.0,
origin.submission_time, origin.1,
) )
.map(|(start, end)| (date.and_time(start), date.and_time(end))) .map(|(start, end)| (date.and_time(start), date.and_time(end)))
} }
@@ -814,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;
@@ -870,6 +969,7 @@ where
symbol: &str, symbol: &str,
snapshot: &crate::data::DailyMarketSnapshot, snapshot: &crate::data::DailyMarketSnapshot,
) -> f64 { ) -> f64 {
if self.runtime_etf_daily_open.get() { return snapshot.open; }
if self.is_post_close_fixed_price(date) { if self.is_post_close_fixed_price(date) {
return snapshot.close; return snapshot.close;
} }
@@ -1228,6 +1328,7 @@ where
snapshot: &crate::data::DailyMarketSnapshot, snapshot: &crate::data::DailyMarketSnapshot,
side: OrderSide, side: OrderSide,
) -> f64 { ) -> f64 {
if self.runtime_etf_daily_open.get() { return snapshot.open; }
if self.is_post_close_fixed_price(snapshot.date) { if self.is_post_close_fixed_price(snapshot.date) {
return snapshot.close; return snapshot.close;
} }
@@ -1386,9 +1487,11 @@ where
&self, &self,
algo_request: Option<&AlgoExecutionRequest>, algo_request: Option<&AlgoExecutionRequest>,
) -> MatchingType { ) -> MatchingType {
if self.runtime_etf_daily_open.get() && algo_request.is_none() { return MatchingType::NextBarOpen; }
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() || (self.runtime_stock_pool_followup.get() && self.matching_type == MatchingType::NextBarOpen) => MatchingType::CurrentBarClose,
None => self.matching_type, None => self.matching_type,
} }
} }
@@ -1506,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,
@@ -1516,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
@@ -2420,7 +2531,7 @@ where
} }
fn current_order_created_date(&self, date: NaiveDate) -> NaiveDate { fn current_order_created_date(&self, date: NaiveDate) -> NaiveDate {
self.order_origin().created_date.unwrap_or(date) self.order_origin().0.unwrap_or(date)
} }
fn annotate_report_range( fn annotate_report_range(
@@ -2572,10 +2683,15 @@ where
std::mem::take(&mut *open_orders) std::mem::take(&mut *open_orders)
}; };
for order in pending_orders { for order in pending_orders {
if self.matching_type == MatchingType::NextBarOpen && self.runtime_intraday_start_time.get().is_none()
&& order.accepted_date == date {
self.open_orders.borrow_mut().push(order);
continue;
}
let close = self.resting_order_session_close(date, &order); let close = self.resting_order_session_close(date, &order);
let clock = self.submission_time(); let clock = self.submission_time();
let past_day = order.time_in_force == OrderTimeInForce::Day let past_day = order.time_in_force == OrderTimeInForce::Day
&& order.order_created_date.is_some_and(|created| created < date); && order.accepted_date < date;
if past_day || clock.is_some_and(|time| time > close) { if past_day || clock.is_some_and(|time| time > close) {
if order.time_in_force == OrderTimeInForce::Day { if order.time_in_force == OrderTimeInForce::Day {
Self::emit_resting_day_expiry(report, date, &order, order.filled_quantity); Self::emit_resting_day_expiry(report, date, &order, order.filled_quantity);
@@ -2611,6 +2727,7 @@ where
let previous_origin = self.runtime_resting_order_origin.replace(Some(RestingOrderOrigin { let previous_origin = self.runtime_resting_order_origin.replace(Some(RestingOrderOrigin {
created_date: order.order_created_date, created_date: order.order_created_date,
submission_time: order.submission_time, submission_time: order.submission_time,
accepted_date: order.accepted_date,
})); }));
let previous_decision_date = self.runtime_decision_date.replace(order.decision_date); let previous_decision_date = self.runtime_decision_date.replace(order.decision_date);
let execution_result = self.process_limit_shares_internal( let execution_result = self.process_limit_shares_internal(
@@ -2651,6 +2768,7 @@ where
reopened.decision_date = order.decision_date; reopened.decision_date = order.decision_date;
reopened.order_created_date = order.order_created_date; reopened.order_created_date = order.order_created_date;
reopened.submission_time = order.submission_time; reopened.submission_time = order.submission_time;
reopened.accepted_date = order.accepted_date;
reopened.requested_quantity = order.requested_quantity; reopened.requested_quantity = order.requested_quantity;
reopened.filled_quantity = cumulative_filled; reopened.filled_quantity = cumulative_filled;
reopened.remaining_quantity = remaining_quantity; reopened.remaining_quantity = remaining_quantity;
@@ -2725,7 +2843,7 @@ where
} }
fn emit_resting_day_expiry(report: &mut BrokerExecutionReport, date: NaiveDate, order: &OpenOrder, filled: u32) { fn emit_resting_day_expiry(report: &mut BrokerExecutionReport, date: NaiveDate, order: &OpenOrder, filled: u32) {
let detail = format!("DAY order expired at session end: {} remaining_quantity={}", order.symbol, order.requested_quantity.saturating_sub(filled)); let detail = format!("DAY order expired at market close: {} remaining_quantity={}", order.symbol, order.requested_quantity.saturating_sub(filled));
report.order_events.push(OrderEvent { report.order_events.push(OrderEvent {
date, decision_date: order.decision_date, order_created_date: order.order_created_date, date, decision_date: order.decision_date, order_created_date: order.order_created_date,
execution_date: Some(date), order_id: Some(order.order_id), symbol: order.symbol.clone(), execution_date: Some(date), order_id: Some(order.order_id), symbol: order.symbol.clone(),
@@ -3565,8 +3683,6 @@ where
data, data,
&symbol, &symbol,
current_qty, current_qty,
minimum_order_quantity,
order_step_size,
) )
{ {
diagnostics.push(format!( diagnostics.push(format!(
@@ -3583,8 +3699,6 @@ where
data, data,
&symbol, &symbol,
current_qty, current_qty,
minimum_order_quantity,
order_step_size,
) )
{ {
diagnostics.push(format!( diagnostics.push(format!(
@@ -3995,8 +4109,6 @@ where
data, data,
symbol, symbol,
current_qty, current_qty,
minimum_order_quantity,
order_step_size,
) else { ) else {
continue; continue;
}; };
@@ -4040,6 +4152,7 @@ where
snapshot: &crate::data::DailyMarketSnapshot, snapshot: &crate::data::DailyMarketSnapshot,
side: OrderSide, side: OrderSide,
) -> f64 { ) -> f64 {
if self.runtime_etf_daily_open.get() { return snapshot.open; }
match (self.execution_price_field, side) { match (self.execution_price_field, side) {
(PriceField::Last, _) => snapshot.price(PriceField::Last), (PriceField::Last, _) => snapshot.price(PriceField::Last),
(_, OrderSide::Buy) => snapshot.buy_price(self.execution_price_field), (_, OrderSide::Buy) => snapshot.buy_price(self.execution_price_field),
@@ -4056,6 +4169,9 @@ 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() && !self.runtime_stock_pool_followup.get() && algo_request.is_none() {
return self.execution_limit_check_price(snapshot, side);
}
let matching_type = self.matching_type_for_algo_request(algo_request); let matching_type = self.matching_type_for_algo_request(algo_request);
let start_cursor = algo_request let start_cursor = algo_request
.and_then(|request| request.start_time) .and_then(|request| request.start_time)
@@ -4264,8 +4380,6 @@ where
data: &DataSet, data: &DataSet,
symbol: &str, symbol: &str,
current_qty: u32, current_qty: u32,
minimum_order_quantity: u32,
order_step_size: u32,
) -> Option<String> { ) -> Option<String> {
if let Some(reason) = self.runtime_auto_sell_denials.borrow().get(symbol) { if let Some(reason) = self.runtime_auto_sell_denials.borrow().get(symbol) {
return Some(reason.clone()); return Some(reason.clone());
@@ -4294,12 +4408,8 @@ where
.saturating_sub(self.reserved_open_sell_quantity(symbol, None)); .saturating_sub(self.reserved_open_sell_quantity(symbol, None));
match self.market_fillable_quantity( match self.market_fillable_quantity(
snapshot, snapshot,
OrderSide::Sell,
sellable.min(current_qty), sellable.min(current_qty),
minimum_order_quantity, false,
order_step_size,
0,
sellable >= current_qty,
) { ) {
Ok(quantity) => { Ok(quantity) => {
let quantity = quantity.min(sellable).min(current_qty); let quantity = quantity.min(sellable).min(current_qty);
@@ -4320,8 +4430,6 @@ where
data: &DataSet, data: &DataSet,
symbol: &str, symbol: &str,
current_qty: u32, current_qty: u32,
minimum_order_quantity: u32,
order_step_size: u32,
) -> Option<String> { ) -> Option<String> {
let snapshot = data.require_market(date, symbol).ok()?; let snapshot = data.require_market(date, symbol).ok()?;
let candidate = data.require_candidate(date, symbol).ok()?; let candidate = data.require_candidate(date, symbol).ok()?;
@@ -4342,11 +4450,7 @@ where
} }
match self.market_fillable_quantity( match self.market_fillable_quantity(
snapshot, snapshot,
OrderSide::Buy,
u32::MAX, u32::MAX,
minimum_order_quantity,
order_step_size,
0,
false, false,
) { ) {
Ok(quantity) => { Ok(quantity) => {
@@ -4616,14 +4720,12 @@ where
} else { } else {
None None
}; };
self.volume_capacity_mode.validate(self.volume_limit, algo_request.is_some() || self.matching_type_uses_intraday_quotes())
.map_err(|error| BacktestError::Execution(error.to_string()))?;
let market_limited_qty = self.market_fillable_quantity( let market_limited_qty = self.market_fillable_quantity(
snapshot, snapshot,
OrderSide::Sell,
requested_qty.min(sellable), requested_qty.min(sellable),
self.minimum_order_quantity(data, symbol), algo_request.is_some(),
self.order_step_size(data, symbol),
*intraday_turnover.get(symbol).unwrap_or(&0),
requested_qty >= position.quantity && sellable >= position.quantity,
); );
let fillable_qty = match market_limited_qty { let fillable_qty = match market_limited_qty {
Ok(quantity) => { Ok(quantity) => {
@@ -4653,7 +4755,8 @@ 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: self.order_origin().submission_time, submission_time: self.new_open_order_submission_time(),
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)),
symbol: symbol.to_string(), symbol: symbol.to_string(),
@@ -4743,7 +4846,8 @@ 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: self.order_origin().submission_time, submission_time: self.new_open_order_submission_time(),
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)),
symbol: symbol.to_string(), symbol: symbol.to_string(),
@@ -4915,7 +5019,8 @@ where
{ {
self.upsert_open_order(OpenOrder { self.upsert_open_order(OpenOrder {
order_id, order_id,
submission_time: self.order_origin().submission_time, submission_time: self.new_open_order_submission_time(),
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)),
symbol: symbol.to_string(), symbol: symbol.to_string(),
@@ -5086,7 +5191,8 @@ where
if keep_open { if keep_open {
self.upsert_open_order(OpenOrder { self.upsert_open_order(OpenOrder {
order_id, order_id,
submission_time: self.order_origin().submission_time, submission_time: self.new_open_order_submission_time(),
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)),
symbol: symbol.to_string(), symbol: symbol.to_string(),
@@ -6442,14 +6548,12 @@ where
} }
let mut partial_fill_reason = None; let mut partial_fill_reason = None;
self.volume_capacity_mode.validate(self.volume_limit, algo_request.is_some() || self.matching_type_uses_intraday_quotes())
.map_err(|error| BacktestError::Execution(error.to_string()))?;
let market_limited_qty = self.market_fillable_quantity( let market_limited_qty = self.market_fillable_quantity(
snapshot, snapshot,
OrderSide::Buy,
requested_qty, requested_qty,
self.minimum_order_quantity(data, symbol), algo_request.is_some(),
self.order_step_size(data, symbol),
*intraday_turnover.get(symbol).unwrap_or(&0),
false,
); );
let constrained_qty = match market_limited_qty { let constrained_qty = match market_limited_qty {
Ok(quantity) => { Ok(quantity) => {
@@ -6475,7 +6579,8 @@ 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: self.order_origin().submission_time, submission_time: self.new_open_order_submission_time(),
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)),
symbol: symbol.to_string(), symbol: symbol.to_string(),
@@ -6714,7 +6819,8 @@ where
{ {
self.upsert_open_order(OpenOrder { self.upsert_open_order(OpenOrder {
order_id, order_id,
submission_time: self.order_origin().submission_time, submission_time: self.new_open_order_submission_time(),
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)),
symbol: symbol.to_string(), symbol: symbol.to_string(),
@@ -6887,7 +6993,8 @@ where
if keep_open { if keep_open {
self.upsert_open_order(OpenOrder { self.upsert_open_order(OpenOrder {
order_id, order_id,
submission_time: self.order_origin().submission_time, submission_time: self.new_open_order_submission_time(),
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)),
symbol: symbol.to_string(), symbol: symbol.to_string(),
@@ -7357,68 +7464,19 @@ where
fn market_fillable_quantity( fn market_fillable_quantity(
&self, &self,
snapshot: &crate::data::DailyMarketSnapshot, snapshot: &crate::data::DailyMarketSnapshot,
side: OrderSide,
requested_qty: u32, requested_qty: u32,
minimum_order_quantity: u32, algorithmic_order: bool,
order_step_size: u32,
consumed_turnover: u32,
allow_odd_lot_sell: bool,
) -> Result<u32, String> { ) -> Result<u32, String> {
if requested_qty == 0 { if requested_qty == 0 {
return Ok(0); return Ok(0);
} }
let uses_intraday_quantity = self.matching_type_uses_intraday_quotes(); if self.inactive_limit && snapshot.paused { return Err("paused".into()); }
let available_market_volume = if uses_intraday_quantity { self.volume_capacity_mode.validate(self.volume_limit, algorithmic_order || self.matching_type_uses_intraday_quotes())
snapshot.minute_volume .map_err(|error| error.to_string())?;
} else { // Per-observation limits are applied to each actual quote below. The
snapshot.volume // session-audit model must never size this order from the day's total.
}; Ok(requested_qty)
let no_volume_reason = if uses_intraday_quantity {
"minute no volume"
} else {
"daily no volume"
};
let volume_limit_reason = if uses_intraday_quantity {
"minute volume limit"
} else {
"daily volume limit"
};
let mut max_fill = requested_qty;
if self.inactive_limit
&& (snapshot.paused || (!uses_intraday_quantity && available_market_volume == 0))
{
return Err(if snapshot.paused {
"paused".to_string()
} else {
no_volume_reason.to_string()
});
}
if uses_intraday_quantity {
return Ok(max_fill);
}
if self.volume_limit {
let raw_limit = self.volume_rate.map_err(|error| error.to_string())?
.remaining(available_market_volume, u64::from(consumed_turnover), requested_qty);
if raw_limit == 0 {
return Err(volume_limit_reason.to_string());
}
let volume_limited = if side == OrderSide::Sell && allow_odd_lot_sell {
raw_limit
} else {
self.round_buy_quantity(raw_limit, minimum_order_quantity, order_step_size)
};
if volume_limited == 0 {
return Err(volume_limit_reason.to_string());
}
max_fill = max_fill.min(volume_limited);
}
Ok(max_fill)
} }
fn price_satisfies_limit( fn price_satisfies_limit(
@@ -7551,7 +7609,7 @@ where
.map(|start_time| date.and_time(start_time)) .map(|start_time| date.and_time(start_time))
}); });
let start_cursor = if let Some(origin) = self.runtime_resting_order_origin.get() let start_cursor = if let Some(origin) = self.runtime_resting_order_origin.get()
&& origin.created_date == Some(date) && origin.accepted_date == date
&& let Some(submitted) = origin.submission_time && let Some(submitted) = origin.submission_time
{ {
Some(start_cursor.map_or(date.and_time(submitted), |cursor| cursor.max(date.and_time(submitted)))) Some(start_cursor.map_or(date.and_time(submitted), |cursor| cursor.max(date.and_time(submitted))))
@@ -7832,7 +7890,7 @@ where
} else { } else {
remaining_qty remaining_qty
}; };
if self.volume_limit { if self.volume_limit && self.volume_capacity_mode.limits_execution_quantity() {
let consumed = execution_ledger let consumed = execution_ledger
.volume_consumed(symbol, quote.timestamp) .volume_consumed(symbol, quote.timestamp)
.saturating_add( .saturating_add(
@@ -7869,7 +7927,7 @@ where
} else { } else {
remaining_qty.min(available_qty) remaining_qty.min(available_qty)
}; };
if !(side == OrderSide::Sell && allow_odd_lot_sell && take_qty == remaining_qty) { if !(side == OrderSide::Sell && allow_odd_lot_sell) {
take_qty = take_qty =
self.round_buy_quantity(take_qty, minimum_order_quantity, order_step_size); self.round_buy_quantity(take_qty, minimum_order_quantity, order_step_size);
} }
@@ -7980,7 +8038,7 @@ where
.saturating_add(take_qty) .saturating_add(take_qty)
.min(state.displayed_quantity); .min(state.displayed_quantity);
} }
if self.volume_limit { if self.volume_limit && self.volume_capacity_mode.limits_execution_quantity() {
let consumed = pending_volume_consumption let consumed = pending_volume_consumption
.entry(quote.timestamp) .entry(quote.timestamp)
.or_default(); .or_default();
@@ -7994,7 +8052,7 @@ where
depth_price_bits, depth_price_bits,
displayed_quantity, displayed_quantity,
consume_depth, consume_depth,
consume_volume: self.volume_limit, consume_volume: self.volume_limit && self.volume_capacity_mode.limits_execution_quantity(),
quantity: take_qty, quantity: take_qty,
}); });
} }
@@ -8071,6 +8129,9 @@ 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_stock_pool_followup.get() { return true; }
if self.resting_daily_open_order() { return true; }
matches!( matches!(
self.matching_type, self.matching_type,
MatchingType::MinuteLast MatchingType::MinuteLast
@@ -8082,6 +8143,10 @@ where
&& self.intraday_execution_start_time.is_some()) && self.intraday_execution_start_time.is_some())
} }
pub(crate) fn drives_resting_quote_clock(&self) -> bool {
self.matching_type_uses_intraday_quotes() || self.matching_type == MatchingType::NextBarOpen
}
fn quote_quantity_limited(&self, matching_type: MatchingType) -> bool { fn quote_quantity_limited(&self, matching_type: MatchingType) -> bool {
match matching_type { match matching_type {
MatchingType::OpenAuction MatchingType::OpenAuction
@@ -8208,12 +8273,15 @@ 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,
decision_date: None, decision_date: None,
order_created_date: None, order_created_date: None,
submission_time: None, submission_time: None,
accepted_date: chrono::NaiveDate::from_ymd_opt(2025,1,2).unwrap(),
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".to_string(),
side: OrderSide::Buy, side: OrderSide::Buy,
requested_quantity: 200, requested_quantity: 200,
@@ -8254,7 +8322,7 @@ mod tests {
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");
DailyMarketSnapshot { DailyMarketSnapshot {
date, date,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
timestamp: Some("2025-01-02 09:33:00".to_string()), timestamp: Some("2025-01-02 09:33:00".to_string()),
day_open: 10.0, day_open: 10.0,
open: 10.0, open: 10.0,
@@ -8279,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"),
@@ -8380,7 +8448,7 @@ mod tests {
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");
CandidateEligibility { CandidateEligibility {
date, date,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
is_st: false, is_st: false,
is_star_st: false, is_star_st: false,
is_new_listing: false, is_new_listing: false,
@@ -8460,7 +8528,7 @@ mod tests {
.iter() .iter()
.map(|symbol| { .map(|symbol| {
let mut snapshot = limit_test_snapshot(); let mut snapshot = limit_test_snapshot();
snapshot.symbol = (*symbol).to_string(); snapshot.symbol = (*symbol).into();
if block_exit && *symbol == "000001.SZ" { if block_exit && *symbol == "000001.SZ" {
snapshot.day_open = snapshot.lower_limit; snapshot.day_open = snapshot.lower_limit;
snapshot.open = snapshot.lower_limit; snapshot.open = snapshot.lower_limit;
@@ -8475,7 +8543,7 @@ mod tests {
.iter() .iter()
.map(|symbol| { .map(|symbol| {
let mut candidate = limit_test_candidate(true, true); let mut candidate = limit_test_candidate(true, true);
candidate.symbol = (*symbol).to_string(); candidate.symbol = (*symbol).into();
candidate candidate
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
@@ -8542,6 +8610,7 @@ mod tests {
vec![dated_limit_test_benchmark(first), dated_limit_test_benchmark(second)], vec![dated_limit_test_benchmark(first), dated_limit_test_benchmark(second)],
).unwrap(); ).unwrap();
let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks) let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
.with_volume_capacity_mode(crate::execution_capacity::VolumeCapacityMode::SessionCapacityAudit)
.with_matching_type(MatchingType::CurrentBarClose); .with_matching_type(MatchingType::CurrentBarClose);
let mut portfolio = PortfolioState::new(100_000.0); let mut portfolio = PortfolioState::new(100_000.0);
broker.execute(first, &mut portfolio, &data, &next_open_buy_decision()).unwrap(); broker.execute(first, &mut portfolio, &data, &next_open_buy_decision()).unwrap();
@@ -8568,6 +8637,7 @@ mod tests {
let data = DataSet::from_components(vec![limit_test_instrument()], vec![limit_test_snapshot()], let data = DataSet::from_components(vec![limit_test_instrument()], vec![limit_test_snapshot()],
Vec::new(), vec![limit_test_candidate(true, true)], vec![limit_test_benchmark()]).unwrap(); Vec::new(), vec![limit_test_candidate(true, true)], vec![limit_test_benchmark()]).unwrap();
let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks) let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
.with_volume_capacity_mode(crate::execution_capacity::VolumeCapacityMode::SessionCapacityAudit)
.with_matching_type(MatchingType::CurrentBarClose); .with_matching_type(MatchingType::CurrentBarClose);
broker.upsert_open_order(test_open_order(99)); broker.upsert_open_order(test_open_order(99));
let mut decision = StrategyDecision::default(); let mut decision = StrategyDecision::default();
@@ -8595,6 +8665,7 @@ mod tests {
dated_limit_test_candidate(second, false, false, true, true)], dated_limit_test_candidate(second, false, false, true, true)],
vec![dated_limit_test_benchmark(first), dated_limit_test_benchmark(second)]).unwrap(); vec![dated_limit_test_benchmark(first), dated_limit_test_benchmark(second)]).unwrap();
let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks) let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
.with_volume_capacity_mode(crate::execution_capacity::VolumeCapacityMode::SessionCapacityAudit)
.with_matching_type(MatchingType::NextBarOpen); .with_matching_type(MatchingType::NextBarOpen);
let mut portfolio = PortfolioState::new(1_000_000.0); let mut portfolio = PortfolioState::new(1_000_000.0);
let mut initial = StrategyDecision::default(); let mut initial = StrategyDecision::default();
@@ -8791,6 +8862,30 @@ mod tests {
assert!(broker.runtime_resting_order_origin.get().is_none()); assert!(broker.runtime_resting_order_origin.get().is_none());
} }
#[test]
fn next_open_day_limit_remainder_matches_intraday_with_execution_day_ttl() {
let date=chrono::NaiveDate::from_ymd_opt(2026,7,7).unwrap();
let signal=date.pred_opt().unwrap();
let mut snapshot=dated_limit_test_snapshot(date);
snapshot.open=10.2;snapshot.close=9.8;snapshot.last_price=9.8;snapshot.upper_limit=20.;snapshot.lower_limit=1.;
let mut quote=limit_test_quote(9.8,9.8,9.8);quote.date=date;quote.timestamp=date.and_hms_opt(10,0,0).unwrap();
let data=DataSet::from_components_with_actions_and_quotes(vec![limit_test_instrument()],vec![snapshot],Vec::new(),
vec![dated_limit_test_candidate(date,false,false,true,true)],vec![dated_limit_test_benchmark(date)],Vec::new(),vec![quote]).unwrap();
let broker=BrokerSimulator::new(ChinaAShareCostModel::default(),ChinaEquityRuleHooks)
.with_matching_type(MatchingType::NextBarOpen).with_volume_limit(false).with_liquidity_limit(false);
let mut portfolio=PortfolioState::new(100000.);
let decision=StrategyDecision{order_intents:vec![OrderIntent::LimitTargetShares{symbol:"000001.SZ".into(),target_quantity:100,limit_price:10.,reason:"next-open-entry".into()}],..StrategyDecision::default()};
let report=broker.execute_with_event_dates(date,signal,signal,&mut portfolio,&data,&decision).unwrap();
assert!(report.fill_events.is_empty());assert!(broker.has_open_orders());
let report=broker.execute_between_with_event_dates(date,signal,signal,&mut portfolio,&data,&StrategyDecision::default(),
NaiveTime::from_hms_opt(10,0,0),NaiveTime::from_hms_opt(10,0,0)).unwrap();
assert_eq!(report.fill_events.len(),1,"{report:?}");
assert_eq!(report.fill_events[0].price,9.8);
assert_eq!(report.fill_events[0].execution_timestamp,date.and_hms_opt(10,0,0));
assert_eq!(report.fill_events[0].order_created_date,Some(signal));
assert!(!broker.has_open_orders());
}
#[test] #[test]
fn post_close_order_uses_close_without_slippage_and_waits_until_matching_window() { fn post_close_order_uses_close_without_slippage_and_waits_until_matching_window() {
let date = chrono::NaiveDate::from_ymd_opt(2026, 7, 6).expect("valid date"); let date = chrono::NaiveDate::from_ymd_opt(2026, 7, 6).expect("valid date");
@@ -9593,7 +9688,42 @@ mod tests {
} }
#[test] #[test]
fn current_bar_close_volume_limit_uses_daily_volume_when_minute_volume_missing() { fn daily_session_volume_changes_only_audit_not_opening_fills() {
use crate::execution_capacity::VolumeCapacityMode;
let run = |volume: u64, mode: VolumeCapacityMode| {
let mut market = limit_test_snapshot();
market.volume = volume;
let date = market.date;
let data = DataSet::from_components_with_actions_and_quotes(
vec![limit_test_instrument()], vec![market], vec![],
vec![limit_test_candidate(true, true)], vec![limit_test_benchmark()], vec![], vec![],
).unwrap();
let mut portfolio = PortfolioState::new(100_000.0);
let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
.with_volume_capacity_mode(mode).with_liquidity_limit(false);
let decision = StrategyDecision { order_intents: vec![OrderIntent::Shares {
symbol: "000001.SZ".into(), quantity: 1_000, reason: "capacity_test".into(),
}], ..StrategyDecision::default() };
let before = portfolio.cash();
let outcome = broker.execute(date, &mut portfolio, &data, &decision);
if outcome.is_err() { assert_eq!(portfolio.cash(), before); }
let audit = broker.audit_completed_session_capacity(date, &data).unwrap();
(outcome, portfolio.cash(), audit)
};
let (strict, _, _) = run(1_000_000, VolumeCapacityMode::ExecutionObservation);
assert!(strict.unwrap_err().to_string().contains("execution-time capacity is missing"));
let (a, cash_a, audit_a) = run(100, VolumeCapacityMode::SessionCapacityAudit);
let (b, cash_b, audit_b) = run(1_000_000, VolumeCapacityMode::SessionCapacityAudit);
let a = a.unwrap(); let b = b.unwrap();
assert_eq!(a.fill_events.len(), 1);
assert_eq!(serde_json::to_value(&a.fill_events).unwrap(), serde_json::to_value(&b.fill_events).unwrap());
assert_eq!(cash_a, cash_b);
assert_eq!(audit_a[0].filled_shares, 1_000);
assert!(!audit_a[0].passed); assert!(audit_b[0].passed);
}
#[test]
fn daily_capacity_requires_a_timed_observation_instead_of_falling_back_to_total_volume() {
let mut snapshot = limit_test_snapshot(); let mut snapshot = limit_test_snapshot();
snapshot.minute_volume = 0; snapshot.minute_volume = 0;
snapshot.volume = 1_000_000; snapshot.volume = 1_000_000;
@@ -9609,13 +9739,13 @@ mod tests {
.with_liquidity_limit(true); .with_liquidity_limit(true);
let fillable = let fillable =
broker.market_fillable_quantity(&snapshot, OrderSide::Buy, 5_000, 100, 100, 0, false); broker.market_fillable_quantity(&snapshot, 5_000, false);
assert_eq!(fillable, Ok(5_000)); assert!(fillable.unwrap_err().contains("daily session volume cannot size an earlier fill"));
} }
#[test] #[test]
fn volume_limit_uses_floor_for_odd_lot_sell() { fn session_capacity_audit_never_caps_an_early_odd_lot_sell() {
let mut snapshot = limit_test_snapshot(); let mut snapshot = limit_test_snapshot();
snapshot.minute_volume = 0; snapshot.minute_volume = 0;
snapshot.volume = 3; snapshot.volume = 3;
@@ -9625,18 +9755,19 @@ mod tests {
PriceField::Close, PriceField::Close,
) )
.with_matching_type(MatchingType::CurrentBarClose) .with_matching_type(MatchingType::CurrentBarClose)
.with_volume_capacity_mode(crate::execution_capacity::VolumeCapacityMode::SessionCapacityAudit)
.with_volume_limit(true) .with_volume_limit(true)
.with_volume_percent(0.5) .with_volume_percent(0.5)
.with_liquidity_limit(false); .with_liquidity_limit(false);
let fillable = let fillable =
broker.market_fillable_quantity(&snapshot, OrderSide::Sell, 10, 100, 100, 0, true); broker.market_fillable_quantity(&snapshot, 10, false);
assert_eq!(fillable, Ok(1)); assert_eq!(fillable, Ok(10));
} }
#[test] #[test]
fn current_bar_close_volume_limit_rejects_daily_zero_volume() { fn session_audit_does_not_infer_an_opening_suspension_from_future_zero_volume() {
let mut snapshot = limit_test_snapshot(); let mut snapshot = limit_test_snapshot();
snapshot.minute_volume = 0; snapshot.minute_volume = 0;
snapshot.volume = 0; snapshot.volume = 0;
@@ -9646,13 +9777,16 @@ mod tests {
PriceField::Close, PriceField::Close,
) )
.with_matching_type(MatchingType::CurrentBarClose) .with_matching_type(MatchingType::CurrentBarClose)
.with_volume_capacity_mode(crate::execution_capacity::VolumeCapacityMode::SessionCapacityAudit)
.with_volume_limit(true) .with_volume_limit(true)
.with_liquidity_limit(false); .with_liquidity_limit(false);
let fillable = let fillable =
broker.market_fillable_quantity(&snapshot, OrderSide::Buy, 5_000, 100, 100, 0, false); broker.market_fillable_quantity(&snapshot, 5_000, false);
assert_eq!(fillable, Err("daily no volume".to_string())); assert_eq!(fillable, Ok(5_000));
snapshot.paused = true;
assert_eq!(broker.market_fillable_quantity(&snapshot, 5_000, false), Err("paused".into()));
} }
#[test] #[test]
@@ -9673,7 +9807,7 @@ mod tests {
.with_liquidity_limit(false); .with_liquidity_limit(false);
let fillable = let fillable =
broker.market_fillable_quantity(&snapshot, OrderSide::Buy, 5_000, 100, 100, 0, false); broker.market_fillable_quantity(&snapshot, 5_000, false);
assert_eq!(fillable, Ok(5_000)); assert_eq!(fillable, Ok(5_000));
} }
@@ -10368,7 +10502,7 @@ mod tests {
.iter() .iter()
.map(|symbol| { .map(|symbol| {
let mut snapshot = limit_test_snapshot(); let mut snapshot = limit_test_snapshot();
snapshot.symbol = (*symbol).to_string(); snapshot.symbol = (*symbol).into();
if *symbol == "000001.SZ" { if *symbol == "000001.SZ" {
snapshot.day_open = 9.0; snapshot.day_open = 9.0;
snapshot.open = 9.0; snapshot.open = 9.0;
@@ -10386,7 +10520,7 @@ mod tests {
.iter() .iter()
.map(|symbol| { .map(|symbol| {
let mut candidate = limit_test_candidate(true, true); let mut candidate = limit_test_candidate(true, true);
candidate.symbol = (*symbol).to_string(); candidate.symbol = (*symbol).into();
candidate candidate
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
@@ -10520,7 +10654,7 @@ mod tests {
.iter() .iter()
.map(|symbol| { .map(|symbol| {
let mut snapshot = limit_test_snapshot(); let mut snapshot = limit_test_snapshot();
snapshot.symbol = (*symbol).to_string(); snapshot.symbol = (*symbol).into();
snapshot snapshot
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
@@ -10528,7 +10662,7 @@ mod tests {
.iter() .iter()
.map(|symbol| { .map(|symbol| {
let mut candidate = limit_test_candidate(true, true); let mut candidate = limit_test_candidate(true, true);
candidate.symbol = (*symbol).to_string(); candidate.symbol = (*symbol).into();
candidate candidate
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
@@ -10585,7 +10719,7 @@ mod tests {
.iter() .iter()
.map(|symbol| { .map(|symbol| {
let mut snapshot = limit_test_snapshot(); let mut snapshot = limit_test_snapshot();
snapshot.symbol = (*symbol).to_string(); snapshot.symbol = (*symbol).into();
snapshot snapshot
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
@@ -10593,7 +10727,7 @@ mod tests {
.iter() .iter()
.map(|symbol| { .map(|symbol| {
let mut candidate = limit_test_candidate(true, true); let mut candidate = limit_test_candidate(true, true);
candidate.symbol = (*symbol).to_string(); candidate.symbol = (*symbol).into();
candidate candidate
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
@@ -10723,7 +10857,7 @@ mod tests {
.iter() .iter()
.map(|symbol| { .map(|symbol| {
let mut snapshot = limit_test_snapshot(); let mut snapshot = limit_test_snapshot();
snapshot.symbol = (*symbol).to_string(); snapshot.symbol = (*symbol).into();
snapshot snapshot
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
@@ -10731,7 +10865,7 @@ mod tests {
.iter() .iter()
.map(|symbol| { .map(|symbol| {
let mut candidate = limit_test_candidate(true, true); let mut candidate = limit_test_candidate(true, true);
candidate.symbol = (*symbol).to_string(); candidate.symbol = (*symbol).into();
candidate candidate
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
@@ -11512,7 +11646,7 @@ mod tests {
.with_volume_limit(false) .with_volume_limit(false)
.with_liquidity_limit(false); .with_liquidity_limit(false);
let mut snapshot = limit_test_snapshot(); let mut snapshot = limit_test_snapshot();
snapshot.symbol = symbol.to_string(); snapshot.symbol = symbol.into();
snapshot.last_price = 4.21; snapshot.last_price = 4.21;
snapshot.close = 4.21; snapshot.close = 4.21;
snapshot.bid1 = 4.20; snapshot.bid1 = 4.20;
@@ -11597,7 +11731,7 @@ mod tests {
.with_inactive_limit(false); .with_inactive_limit(false);
let snapshot = DailyMarketSnapshot { let snapshot = DailyMarketSnapshot {
date, date,
symbol: symbol.to_string(), symbol: symbol.into(),
timestamp: Some("2023-05-08 15:00:00".to_string()), timestamp: Some("2023-05-08 15:00:00".to_string()),
day_open: 5.86, day_open: 5.86,
open: 5.86, open: 5.86,
@@ -11618,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"),
@@ -11645,7 +11779,7 @@ mod tests {
Vec::new(), Vec::new(),
vec![CandidateEligibility { vec![CandidateEligibility {
date, date,
symbol: symbol.to_string(), symbol: symbol.into(),
is_st: false, is_st: false,
is_star_st: false, is_star_st: false,
is_new_listing: false, is_new_listing: false,
+335 -27
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() {
@@ -16,6 +45,11 @@ fn decimal(value: f64, label: &str) -> Result<Decimal, BacktestError> {
.map_err(|_| BacktestError::Execution(format!("stock_pool_decimal_range_{label}"))) .map_err(|_| BacktestError::Execution(format!("stock_pool_decimal_range_{label}")))
} }
fn etf_activity(report:&mut BrokerExecutionReport,date:NaiveDate,symbol:&str,side:pool::OrderSide,detail:String) {
report.process_events.push(ProcessEvent {date,kind:ProcessEventKind::EtfExecutionFallback,order_id:None,
symbol:Some(symbol.into()),side:Some(if side==pool::OrderSide::Buy {OrderSide::Buy} else {OrderSide::Sell}),detail});
}
fn pool_positions( fn pool_positions(
portfolio: &PortfolioState, portfolio: &PortfolioState,
date: NaiveDate, date: NaiveDate,
@@ -36,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(|| {
@@ -54,7 +132,13 @@ impl<C: CostModel, R: EquityRuleHooks> BrokerSimulator<C, R> {
let instrument = data.instruments().get(symbol).ok_or_else(|| { let instrument = data.instruments().get(symbol).ok_or_else(|| {
BacktestError::Execution(format!("stock_pool_instrument_missing:{symbol}")) BacktestError::Execution(format!("stock_pool_instrument_missing:{symbol}"))
})?; })?;
let (price, prev, volume, amount, bid, ask, buy_price, sell_price) = if self let fallback = self.pool_etf_fallback_reference(date, data, symbol, execution_clock)?;
let (price, prev, volume, amount, bid, ask, buy_price, sell_price) = if let Some(reference) = fallback {
let calibration = self.slippage_calibration(data, snapshot)?;
(reference.price, snapshot.prev_close, None, None, None, None,
self.quote_execution_price(snapshot, OrderSide::Buy, reference.price, None, calibration.as_ref())?,
self.quote_execution_price(snapshot, OrderSide::Sell, reference.price, None, calibration.as_ref())?)
} else if self
.matching_type_uses_intraday_quotes() .matching_type_uses_intraday_quotes()
{ {
let time = self let time = self
@@ -89,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!(
@@ -101,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!(
@@ -123,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,
@@ -142,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)?,
) )
@@ -157,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")),
@@ -171,7 +272,18 @@ 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> {
if !self.matching_type_uses_intraday_quotes() || !self.has_verified_etf_minute_absence(date, symbol) {
return Ok(None);
}
let time = self.runtime_intraday_start_time.get().or(self.intraday_execution_start_time)
.ok_or_else(|| BacktestError::Execution("etf_daily_open_fallback: execution clock missing".into()))?;
let at = clock.unwrap_or(date.and_time(time)).max(date.and_time(time));
crate::etf_execution::reference(data, symbol, at).map(Some)
} }
pub(super) fn process_stock_pool_contract( pub(super) fn process_stock_pool_contract(
@@ -185,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
@@ -225,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
@@ -240,7 +364,12 @@ impl<C: CostModel, R: EquityRuleHooks> BrokerSimulator<C, R> {
portfolio portfolio
.set_stock_pool_execution_state(&contract.pool_id, state) .set_stock_pool_execution_state(&contract.pool_id, state)
.map_err(BacktestError::Execution)?; .map_err(BacktestError::Execution)?;
let superseded = self.deferred_etf_targets.borrow_mut().replace_generation(&contract.pool_id, &contract.generation);
if superseded > 0 { report.diagnostics.push(format!("etf_daily_open_fallback:superseded pool={} generation={} targets={superseded}", contract.pool_id, contract.generation)); }
if self.has_open_orders() { 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());
@@ -286,14 +415,31 @@ impl<C: CostModel, R: EquityRuleHooks> BrokerSimulator<C, R> {
.push("paused".into()); .push("paused".into());
} }
} }
let before_positions = portfolio // All delayed symbols in a generation share immutable configuration.
.positions() // Do not duplicate an N-member pool N times in a large mixed pool.
.keys() let mut deferred_configuration = None;
.cloned()
.collect::<BTreeSet<_>>();
for side in [pool::OrderSide::Sell, pool::OrderSide::Buy] { for side in [pool::OrderSide::Sell, pool::OrderSide::Buy] {
let quotes = if buy_only && side == pool::OrderSide::Sell { continue; }
self.pool_quote_inputs(date, data, &quote_scope, *global_execution_cursor)?; if side == pool::OrderSide::Buy && self.has_open_orders()
&& self.effective_rebalance_cash_mode() == RebalanceCashMode::SellThenBuy {
self.deferred_stock_pools.borrow_mut().insert(contract.pool_id.clone(), DeferredStockPoolExecution {
date, contract: Box::new(contract.clone()), buy_only: true, symbols: quote_scope.clone(), initial_holdings: before_positions.clone(),
});
report.diagnostics.push(format!("stock_pool_waiting_for_sell_reports generation={} no_buy_order_created=true",contract.generation));
break;
}
let mut fallback_references = BTreeMap::new();
for symbol in &quote_scope {
if let Some(reference) = self.pool_etf_fallback_reference(date, data, symbol, *global_execution_cursor)? {
let condition = if side == pool::OrderSide::Buy { &contract.rule.buy_condition } else { &contract.rule.sell_condition };
if !condition.trim().is_empty() {
return Err(BacktestError::Execution(format!("etf_daily_open_fallback: intraday condition evidence unavailable symbol={symbol} side={side:?}; daily reference is not a minute or tick signal")));
}
fallback_references.insert(symbol.clone(), reference);
}
}
let (quotes, unavailable) = 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)
@@ -307,6 +453,7 @@ impl<C: CostModel, R: EquityRuleHooks> BrokerSimulator<C, R> {
.map_err(BacktestError::Execution)?; .map_err(BacktestError::Execution)?;
constraints.pending_entry_symbols = execution_state.pending_symbols(); constraints.pending_entry_symbols = execution_state.pending_symbols();
constraints.prior_target_weights = execution_state.last_target_weights.clone(); constraints.prior_target_weights = execution_state.last_target_weights.clone();
constraints.position_action_bases = execution_state.position_action_bases_for(&contract.generation);
constraints.next_day_outside_exit_symbols = execution_state.next_day_exit_symbols(date); constraints.next_day_outside_exit_symbols = execution_state.next_day_exit_symbols(date);
let account = pool::AccountSnapshot { let account = pool::AccountSnapshot {
total_equity: contract.frozen_equity, total_equity: contract.frozen_equity,
@@ -340,6 +487,17 @@ impl<C: CostModel, R: EquityRuleHooks> BrokerSimulator<C, R> {
.insert(symbol.clone(), permission); .insert(symbol.clone(), permission);
} }
} }
if side == pool::OrderSide::Buy {
for (symbol, reference) in &fallback_references {
if !reference.immediate {
// The sell leg was queued, not filled. Keep its real
// holdings/slots and do not finance buys with proceeds
// from the following session.
constraints.automatic_permissions.entry(symbol.clone()).or_default()
.sell_denial.get_or_insert("etf_daily_open_deferred");
}
}
}
if self if self
.risk_config .risk_config
.static_rules .static_rules
@@ -400,10 +558,20 @@ 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()
let updated = execution_state && (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
.record_plan(contract.signal_date, &contract.generation, &plan) .record_plan(contract.signal_date, &contract.generation, &plan)
.map_err(BacktestError::Execution)?; .map_err(BacktestError::Execution)?;
for (symbol, reference) in &fallback_references {
if !reference.immediate && let Some(entry) = updated.entries.get_mut(symbol) {
// The signal only fixes money, not shares at a stale close.
entry.completion_quantity = None;
}
}
portfolio portfolio
.set_stock_pool_execution_state(&contract.pool_id, updated) .set_stock_pool_execution_state(&contract.pool_id, updated)
.map_err(BacktestError::Execution)?; .map_err(BacktestError::Execution)?;
@@ -425,6 +593,26 @@ impl<C: CostModel, R: EquityRuleHooks> BrokerSimulator<C, R> {
if row.side != Some(side) { if row.side != Some(side) {
continue; continue;
} }
if let Some(reference) = fallback_references.get(&row.symbol) {
let time = self.runtime_intraday_start_time.get().or(self.intraday_execution_start_time).expect("fallback clock validated");
let at = global_execution_cursor.unwrap_or(date.and_time(time)).max(date.and_time(time));
if !reference.immediate {
report.diagnostics.push(format!("etf_daily_open_fallback:deferred symbol={} signal_at={at} reference_date={} reference_price={} target_value={} execute_on={:?}", row.symbol, reference.reference_date, reference.price, row.target_value, reference.execute_on));
let deferred = deferred_configuration.get_or_insert_with(|| (
std::sync::Arc::new(contract.rule.clone()), std::sync::Arc::new(members.clone()),
));
let opening_date=reference.execute_on.map(|day|day.to_string()).unwrap_or_else(||"回测区间外(后续日历未加载)".into());
etf_activity(report,date,&row.symbol,side,format!("ETF 顺延执行:信号 {at},参考 {} 收盘 {},目标金额 {},下一正式开盘日 {opening_date};未生成成交。",reference.reference_date,reference.price,row.target_value));
self.deferred_etf_targets.borrow_mut().upsert(crate::etf_execution::DeferredEtfTarget {
pool_id:contract.pool_id.clone(), generation:contract.generation.clone(), symbol:row.symbol.clone(),
signal_date:contract.signal_date, signal_at:at, execute_on:reference.execute_on,
target_value:row.target_value, target_weight_bps:row.target_weight_bps, side,
max_positions, rule:std::sync::Arc::clone(&deferred.0), members:std::sync::Arc::clone(&deferred.1),
reason:row.source_intent.clone().unwrap_or_else(||"stock_pool_target".into()),
});
continue;
}
}
if side == pool::OrderSide::Buy if side == pool::OrderSide::Buy
&& portfolio && portfolio
.position(&row.symbol) .position(&row.symbol)
@@ -440,8 +628,14 @@ impl<C: CostModel, R: EquityRuleHooks> BrokerSimulator<C, R> {
let target = row.target_quantity.to_i32().ok_or_else(|| { let target = row.target_quantity.to_i32().ok_or_else(|| {
BacktestError::Execution("stock_pool_target_quantity_out_of_range".into()) BacktestError::Execution("stock_pool_target_quantity_out_of_range".into())
})?; })?;
let reason = row.source_intent.as_deref().unwrap_or("stock_pool_target"); let fallback_reason = fallback_references.contains_key(&row.symbol).then(|| format!("{}: etf_daily_open_fallback signal_date={} execution_date={date}", row.source_intent.as_deref().unwrap_or("stock_pool_target"), contract.signal_date));
if let Some(price) = row.limit_price { let reason = fallback_reason.as_deref().unwrap_or_else(|| row.source_intent.as_deref().unwrap_or("stock_pool_target"));
let first_fill = report.fill_events.len();
if fallback_references.contains_key(&row.symbol) {
report.diagnostics.push(format!("etf_daily_open_fallback:opening symbol={} signal_date={} execution_date={date}", row.symbol, contract.signal_date));
etf_activity(report,date,&row.symbol,side,format!("ETF 日线开盘回退:信号日 {},执行日 {date},使用正式日线开盘价;不是分钟成交行情。",contract.signal_date));
}
let mut execute = || if let Some(price) = row.limit_price {
self.process_limit_target_shares( self.process_limit_target_shares(
date, date,
portfolio, portfolio,
@@ -457,7 +651,7 @@ impl<C: CostModel, R: EquityRuleHooks> BrokerSimulator<C, R> {
global_execution_cursor, global_execution_cursor,
commission_state, commission_state,
report, report,
)?; )
} else { } else {
self.process_target_shares( self.process_target_shares(
date, date,
@@ -471,10 +665,124 @@ impl<C: CostModel, R: EquityRuleHooks> BrokerSimulator<C, R> {
global_execution_cursor, global_execution_cursor,
commission_state, commission_state,
report, report,
)?; )
} };
if fallback_references.contains_key(&row.symbol) {
self.with_etf_daily_open(execute)?;
for fill in &mut report.fill_events[first_fill..] {
fill.execution_start_timestamp.get_or_insert(date.and_time(crate::etf_execution::opening_time()));
fill.execution_timestamp.get_or_insert(date.and_time(crate::etf_execution::opening_time()));
}
} else { execute()?; }
} }
} }
Ok(()) Ok(())
} }
pub(crate) fn pending_etf_target_count(&self) -> usize {
self.deferred_etf_targets.borrow().len()
}
/// Called at the opening clock, after settlement/corporate actions and
/// auction callbacks. It never sends a stock order or replays a strategy.
pub(crate) fn execute_deferred_etf_targets(&self, date: NaiveDate, portfolio: &mut PortfolioState, data: &DataSet) -> Result<BrokerExecutionReport, BacktestError> {
let mut report = BrokerExecutionReport::default();
if self.has_open_orders() {
if self.pending_etf_target_count() > 0 {
report.diagnostics.push("etf_daily_open_fallback:waiting_for_active_orders".into());
}
return Ok(report);
}
let due = self.deferred_etf_targets.borrow_mut().take_due(date);
let dates = data.calendar().iter().collect::<Vec<_>>();
for target in due {
let instrument = data.instrument(&target.symbol).ok_or_else(|| BacktestError::Execution("etf_daily_open_fallback: instrument identity missing at execution".into()))?;
if !instrument.is_exchange_traded_fund() { return Err(BacktestError::Execution("etf_daily_open_fallback: instrument identity changed".into())); }
if let Some(reason) = instrument.dated_market_absence_reason(date) {
report.diagnostics.push(format!("etf_daily_open_fallback:blocked symbol={} date={date} reason={reason}", target.symbol));
continue;
}
let snapshot = data.market(date, &target.symbol).ok_or_else(|| BacktestError::Execution(format!("etf_daily_open_fallback: daily_open_missing symbol={} date={date}", target.symbol)))?;
if !snapshot.open.is_finite() || snapshot.open <= 0.0 {
return Err(BacktestError::Execution(format!("etf_daily_open_fallback: daily_open_invalid symbol={} date={date}", target.symbol)));
}
let position = portfolio.position(&target.symbol).filter(|p| p.quantity > 0);
let before_quantity = position.map_or(0, |p| p.quantity);
let permission = target.rule.automatic_trade_protection.evaluate(&target.symbol, date, &HoldingLifecycleEvidence {
has_position:position.is_some(), opened_date:position.and_then(|p| p.opened_date()), last_buy_date:position.and_then(|p| p.last_buy_date()),
last_sell_date:self.same_day_sold_symbols.borrow().iter().rev().find(|(day, symbols)| **day <= date && symbols.contains(&target.symbol)).map(|(day, _)| *day),
}, data.calendar()).map_err(BacktestError::Execution)?;
let denial = if target.side == pool::OrderSide::Buy {
permission.buy_denial.or(permission.max_holding_exit.then_some("max_holding_exit_pending"))
} else { permission.sell_denial };
if let Some(denial) = denial {
report.diagnostics.push(format!("etf_daily_open_fallback:protected symbol={} date={date} reason={denial}", target.symbol));
etf_activity(&mut report,date,&target.symbol,target.side,format!("ETF 顺延目标受持有保护限制:{denial};未提交委托。"));
continue;
}
if target.side == pool::OrderSide::Buy && before_quantity == 0 && Self::positive_position_count(portfolio) >= target.max_positions {
report.diagnostics.push(format!("etf_daily_open_fallback:blocked symbol={} reason=occupied_position_slots", target.symbol));
continue;
}
let value = target.target_value.to_f64().ok_or_else(|| BacktestError::Execution("etf_daily_open_fallback: target value out of range".into()))?;
let current_value = snapshot.open * f64::from(before_quantity);
let satisfied = (target.side == pool::OrderSide::Buy && value <= current_value)
|| (target.side == pool::OrderSide::Sell && value >= current_value);
let reason = format!("{}: etf_daily_open_fallback signal_at={} execution_at={} target_value={}", target.reason, target.signal_at, date.and_time(crate::etf_execution::opening_time()), target.target_value);
let mut sub = BrokerExecutionReport::default();
if !satisfied {
let (_, limit) = pool::resolve_stock_pool_order_price(&target.rule, &target.symbol, decimal(snapshot.open, "etf_open")?, target.side, decimal(snapshot.price_tick, "etf_tick")?).map_err(BacktestError::Execution)?;
let intent = match limit {
Some(limit) => OrderIntent::LimitTargetValue { symbol:target.symbol.clone(), target_value:value, limit_price:limit.to_f64().ok_or_else(|| BacktestError::Execution("ETF limit out of range".into()))?, reason:reason.clone() },
None => OrderIntent::TargetValue { symbol:target.symbol.clone(), target_value:value, reason:reason.clone() },
};
let old_time = self.runtime_intraday_start_time.replace(Some(crate::etf_execution::opening_time()));
let old_origin = self.runtime_resting_order_origin.replace(Some(RestingOrderOrigin { created_date:Some(target.signal_at.date()), submission_time:Some(target.signal_at.time()), accepted_date:date }));
let outcome = self.with_etf_daily_open(|| self.execute_with_event_dates(date, target.signal_date, target.signal_at.date(), portfolio, data, &StrategyDecision {
order_intents:vec![OrderIntent::WithTimeInForce { intent:Box::new(intent), time_in_force:OrderTimeInForce::Day }], ..Default::default()
}));
self.runtime_intraday_start_time.set(old_time);
self.runtime_resting_order_origin.set(old_origin);
sub = outcome?;
}
// The actual open determines the full requested shares. A clipped
// or rejected execution must not be recorded as completed entry.
let order = sub.order_events.iter().rev().find(|order| order.symbol == target.symbol);
let goal_quantity = order.map_or(before_quantity, |order| match order.side {
OrderSide::Buy => before_quantity.saturating_add(order.requested_quantity),
OrderSide::Sell => before_quantity.saturating_sub(order.requested_quantity),
});
let status = if satisfied || (order.is_none() && !self.has_open_orders()) { "BELOW_MINIMUM_TRADE_UNIT_ALREADY_SATISFIED" } else { "READY" };
let positions = pool_positions(portfolio, date)?;
let state = portfolio.stock_pool_execution_state(&target.pool_id)
.observe(target.signal_date, date, &dates, &target.members, &positions).map_err(BacktestError::Execution)?
.record_targets(target.signal_date, &target.generation, [crate::stock_pool_state::StockPoolGoalObservation {
symbol:&target.symbol, target_weight_bps:target.target_weight_bps, target_value:target.target_value,
current_quantity:before_quantity.into(), target_quantity:goal_quantity.into(), status,
}]).map_err(BacktestError::Execution)?
.observe(target.signal_date, date, &dates, &target.members, &positions).map_err(BacktestError::Execution)?;
portfolio.set_stock_pool_execution_state(&target.pool_id, state).map_err(BacktestError::Execution)?;
for fill in &mut sub.fill_events {
fill.decision_date.get_or_insert(target.signal_date);
fill.order_created_date.get_or_insert(target.signal_at.date());
fill.execution_date.get_or_insert(date);
fill.execution_start_timestamp.get_or_insert(date.and_time(crate::etf_execution::opening_time()));
fill.execution_timestamp.get_or_insert(date.and_time(crate::etf_execution::opening_time()));
}
for order in &mut sub.order_events {
order.decision_date.get_or_insert(target.signal_date);
order.order_created_date.get_or_insert(target.signal_at.date());
order.execution_date.get_or_insert(date);
}
report.diagnostics.push(reason);
etf_activity(&mut report,date,&target.symbol,target.side,format!("ETF 顺延目标开盘处理:原信号 {},本次 {date} 09:30,冻结目标金额 {},持仓 {before_quantity}{};按本日开盘价、资金与风控重新定量。",target.signal_at,target.target_value,portfolio.position(&target.symbol).map_or(0,|position|position.quantity)));
report.order_events.extend(sub.order_events);
report.fill_events.extend(sub.fill_events);
report.position_events.extend(sub.position_events);
report.account_events.extend(sub.account_events);
report.process_events.extend(sub.process_events);
report.diagnostics.extend(sub.diagnostics);
}
Ok(report)
}
} }
@@ -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);
}
+299 -138
View File
@@ -3,8 +3,9 @@ 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 rayon::prelude::*; use rayon::prelude::*;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use thiserror::Error; use thiserror::Error;
@@ -122,7 +123,7 @@ pub enum PriceField {
pub struct DailyMarketSnapshot { pub struct DailyMarketSnapshot {
#[serde(with = "date_format")] #[serde(with = "date_format")]
pub date: NaiveDate, pub date: NaiveDate,
pub symbol: String, pub symbol: CompactString,
pub timestamp: Option<String>, pub timestamp: Option<String>,
pub day_open: f64, pub day_open: f64,
pub open: f64, pub open: f64,
@@ -199,13 +200,13 @@ impl DailyMarketSnapshot {
} }
} }
pub type NumericFactorMap = BTreeMap<Cow<'static, str>, f64>; pub use crate::numeric_factors::NumericFactorMap;
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DailyFactorSnapshot { pub struct DailyFactorSnapshot {
#[serde(with = "date_format")] #[serde(with = "date_format")]
pub date: NaiveDate, pub date: NaiveDate,
pub symbol: String, pub symbol: CompactString,
pub market_cap_bn: f64, pub market_cap_bn: f64,
pub free_float_cap_bn: f64, pub free_float_cap_bn: f64,
pub pe_ttm: f64, pub pe_ttm: f64,
@@ -232,7 +233,7 @@ pub struct BenchmarkSnapshot {
pub struct CandidateEligibility { pub struct CandidateEligibility {
#[serde(with = "date_format")] #[serde(with = "date_format")]
pub date: NaiveDate, pub date: NaiveDate,
pub symbol: String, pub symbol: CompactString,
pub is_st: bool, pub is_st: bool,
#[serde(default)] #[serde(default)]
pub is_star_st: bool, pub is_star_st: bool,
@@ -283,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,
@@ -300,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,
@@ -1204,7 +1215,7 @@ impl SymbolPriceSeries {
fn snapshot_at(&self, index: usize) -> DailyMarketSnapshot { fn snapshot_at(&self, index: usize) -> DailyMarketSnapshot {
DailyMarketSnapshot { DailyMarketSnapshot {
date: self.dates[index], date: self.dates[index],
symbol: self.symbol.clone(), symbol: self.symbol.as_str().into(),
timestamp: self.timestamps[index].clone(), timestamp: self.timestamps[index].clone(),
day_open: self.day_opens[index], day_open: self.day_opens[index],
open: self.opens[index], open: self.opens[index],
@@ -1406,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>>>,
@@ -1574,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);
} }
@@ -1940,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),
@@ -2270,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)
@@ -2278,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)
@@ -2450,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()
@@ -2465,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
@@ -2565,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;
@@ -2577,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)
@@ -2865,7 +2869,7 @@ impl DataSet {
.filter_map(|snapshot| { .filter_map(|snapshot| {
factor_numeric_value(snapshot, &field).map(|value| FactorValue { factor_numeric_value(snapshot, &field).map(|value| FactorValue {
date: snapshot.date, date: snapshot.date,
symbol: snapshot.symbol.clone(), symbol: snapshot.symbol.to_string(),
field: field.clone(), field: field.clone(),
value, value,
}) })
@@ -2959,7 +2963,7 @@ impl DataSet {
.or_else(|| row.extra_factors.get("margin_all")) .or_else(|| row.extra_factors.get("margin_all"))
.is_some_and(|value| *value > 0.0) .is_some_and(|value| *value > 0.0)
}) })
.map(|row| row.symbol.clone()) .map(|row| row.symbol.to_string())
.collect::<Vec<_>>() .collect::<Vec<_>>()
}) })
.unwrap_or_default(); .unwrap_or_default();
@@ -3051,7 +3055,7 @@ impl DataSet {
if snapshot.prev_close.is_finite() && snapshot.prev_close > 0.0 { if snapshot.prev_close.is_finite() && snapshot.prev_close > 0.0 {
Some(FactorValue { Some(FactorValue {
date: snapshot.date, date: snapshot.date,
symbol: snapshot.symbol.clone(), symbol: snapshot.symbol.to_string(),
field: "price_change_rate".to_string(), field: "price_change_rate".to_string(),
value: snapshot.close / snapshot.prev_close - 1.0, value: snapshot.close / snapshot.prev_close - 1.0,
}) })
@@ -3173,7 +3177,7 @@ impl DataSet {
if let Some(value) = factor_numeric_value(snapshot, field) { if let Some(value) = factor_numeric_value(snapshot, field) {
return Some(FactorValue { return Some(FactorValue {
date: *factor_date, date: *factor_date,
symbol: snapshot.symbol.clone(), symbol: snapshot.symbol.to_string(),
field: field.clone(), field: field.clone(),
value, value,
}); });
@@ -3524,7 +3528,7 @@ impl DataSet {
if let Some(value) = factor_numeric_value(snapshot, field) { if let Some(value) = factor_numeric_value(snapshot, field) {
rows.push(FactorValue { rows.push(FactorValue {
date: snapshot.date, date: snapshot.date,
symbol: snapshot.symbol.clone(), symbol: snapshot.symbol.to_string(),
field: output_field.clone(), field: output_field.clone(),
value, value,
}); });
@@ -4322,7 +4326,7 @@ fn daily_market_price_bar(snapshot: &DailyMarketSnapshot) -> PriceBar {
PriceBar { PriceBar {
date: snapshot.date, date: snapshot.date,
timestamp: snapshot.timestamp.clone(), timestamp: snapshot.timestamp.clone(),
symbol: snapshot.symbol.clone(), symbol: snapshot.symbol.to_string(),
frequency: "1d".to_string(), frequency: "1d".to_string(),
open: snapshot.open, open: snapshot.open,
high: snapshot.high, high: snapshot.high,
@@ -4383,7 +4387,7 @@ fn normalize_factor_snapshots(
{ {
return Err(DataSetError::ReservedTypedFactorInExtraMap { return Err(DataSetError::ReservedTypedFactorInExtraMap {
date: snapshot.date, date: snapshot.date,
symbol: snapshot.symbol, symbol: snapshot.symbol.to_string(),
field: BACKWARD_ADJUSTMENT_FACTOR_FIELD, field: BACKWARD_ADJUSTMENT_FACTOR_FIELD,
}); });
} }
@@ -4392,14 +4396,14 @@ fn normalize_factor_snapshots(
{ {
return Err(DataSetError::InvalidBackwardAdjustmentFactor { return Err(DataSetError::InvalidBackwardAdjustmentFactor {
date: snapshot.date, date: snapshot.date,
symbol: snapshot.symbol, symbol: snapshot.symbol.to_string(),
value, value,
}); });
} }
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()
}); });
@@ -4410,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();
@@ -4428,7 +4432,7 @@ fn normalize_factor_snapshots(
{ {
return Err(DataSetError::ReservedTypedFactorInExtraMap { return Err(DataSetError::ReservedTypedFactorInExtraMap {
date: snapshot.date, date: snapshot.date,
symbol: snapshot.symbol, symbol: snapshot.symbol.to_string(),
field: BACKWARD_ADJUSTMENT_FACTOR_FIELD, field: BACKWARD_ADJUSTMENT_FACTOR_FIELD,
}); });
} }
@@ -4446,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,
@@ -4506,25 +4542,25 @@ 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()) {
symbols.insert(row.symbol.clone()); symbols.insert(row.symbol.to_string());
} }
} }
} }
for rows in factor_by_date.values() { for rows in factor_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()) {
symbols.insert(row.symbol.clone()); symbols.insert(row.symbol.to_string());
} }
} }
} }
for rows in candidate_by_date.values() { for rows in candidate_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()) {
symbols.insert(row.symbol.clone()); symbols.insert(row.symbol.to_string());
} }
} }
} }
@@ -4548,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()
@@ -4564,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()
} }
@@ -4643,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,
@@ -4654,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(
@@ -4942,7 +4985,7 @@ fn build_fundamental_universe_for_date(
continue; continue;
} }
rows.push(EligibleUniverseSnapshot { rows.push(EligibleUniverseSnapshot {
symbol: factor.symbol.clone(), symbol: factor.symbol.to_string(),
market_cap_bn, market_cap_bn,
free_float_cap_bn: decision_free_float_cap_bn(factor), free_float_cap_bn: decision_free_float_cap_bn(factor),
}); });
@@ -5012,7 +5055,7 @@ fn build_eligible_universe_for_date_from_factors(
date, date,
candidate, candidate,
market, market,
instruments.get(&factor.symbol), instruments.get(factor.symbol.as_str()),
risk_config, risk_config,
) )
.is_some() .is_some()
@@ -5025,7 +5068,7 @@ fn build_eligible_universe_for_date_from_factors(
} }
let free_float_cap_bn = decision_free_float_cap_bn(factor); let free_float_cap_bn = decision_free_float_cap_bn(factor);
rows.push(EligibleUniverseSnapshot { rows.push(EligibleUniverseSnapshot {
symbol: factor.symbol.clone(), symbol: factor.symbol.to_string(),
market_cap_bn, market_cap_bn,
free_float_cap_bn, free_float_cap_bn,
}); });
@@ -5042,7 +5085,7 @@ fn build_eligible_universe_for_date_from_factors(
pub(crate) fn missing_candidate_risk_state(date: NaiveDate, symbol: &str) -> CandidateEligibility { pub(crate) fn missing_candidate_risk_state(date: NaiveDate, symbol: &str) -> CandidateEligibility {
CandidateEligibility { CandidateEligibility {
date, date,
symbol: symbol.to_string(), symbol: symbol.into(),
is_st: false, is_st: false,
is_star_st: false, is_star_st: false,
is_new_listing: false, is_new_listing: false,
@@ -5070,7 +5113,7 @@ mod tests {
fn market_row(date: &str, prev_close: f64, volume: u64) -> DailyMarketSnapshot { fn market_row(date: &str, prev_close: f64, volume: u64) -> DailyMarketSnapshot {
DailyMarketSnapshot { DailyMarketSnapshot {
date: NaiveDate::parse_from_str(date, "%Y-%m-%d").unwrap(), date: NaiveDate::parse_from_str(date, "%Y-%m-%d").unwrap(),
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
timestamp: None, timestamp: None,
day_open: prev_close, day_open: prev_close,
open: prev_close, open: prev_close,
@@ -5157,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(),
@@ -5300,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,
@@ -5352,10 +5395,10 @@ mod tests {
for (symbol_index, symbol) in symbols.into_iter().enumerate().rev() { for (symbol_index, symbol) in symbols.into_iter().enumerate().rev() {
let close = 10.0 + date_index as f64 + symbol_index as f64; let close = 10.0 + date_index as f64 + symbol_index as f64;
let mut market_row = market_row(&date_text, close, 1_000_000); let mut market_row = market_row(&date_text, close, 1_000_000);
market_row.symbol = symbol.to_string(); market_row.symbol = symbol.into();
let factor_row = DailyFactorSnapshot { let factor_row = DailyFactorSnapshot {
date, date,
symbol: symbol.to_string(), symbol: symbol.into(),
market_cap_bn: 100.0 + close, market_cap_bn: 100.0 + close,
free_float_cap_bn: 80.0 + close, free_float_cap_bn: 80.0 + close,
pe_ttm: 0.0, pe_ttm: 0.0,
@@ -5366,7 +5409,7 @@ mod tests {
}; };
let candidate_row = CandidateEligibility { let candidate_row = CandidateEligibility {
date, date,
symbol: symbol.to_string(), symbol: symbol.into(),
is_st: false, is_st: false,
is_star_st: false, is_star_st: false,
is_new_listing: false, is_new_listing: false,
@@ -5402,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(),
@@ -5442,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 {
@@ -5516,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();
@@ -5534,12 +5697,12 @@ mod tests {
}; };
let market = |symbol: &str, close: f64| { let market = |symbol: &str, close: f64| {
let mut row = market_row("2025-01-02", close, 1_000_000); let mut row = market_row("2025-01-02", close, 1_000_000);
row.symbol = symbol.to_string(); row.symbol = symbol.into();
row row
}; };
let factor = |symbol: &str, market_cap_bn: f64| DailyFactorSnapshot { let factor = |symbol: &str, market_cap_bn: f64| DailyFactorSnapshot {
date, date,
symbol: symbol.to_string(), symbol: symbol.into(),
market_cap_bn, market_cap_bn,
free_float_cap_bn: market_cap_bn, free_float_cap_bn: market_cap_bn,
pe_ttm: 0.0, pe_ttm: 0.0,
@@ -5550,7 +5713,7 @@ mod tests {
}; };
let candidate = |symbol: &str| CandidateEligibility { let candidate = |symbol: &str| CandidateEligibility {
date, date,
symbol: symbol.to_string(), symbol: symbol.into(),
is_st: false, is_st: false,
is_star_st: false, is_star_st: false,
is_new_listing: false, is_new_listing: false,
@@ -5773,7 +5936,7 @@ mod tests {
.iter() .iter()
.map(|date| DailyFactorSnapshot { .map(|date| DailyFactorSnapshot {
date: *date, date: *date,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
market_cap_bn: 10.0, market_cap_bn: 10.0,
free_float_cap_bn: 8.0, free_float_cap_bn: 8.0,
pe_ttm: 10.0, pe_ttm: 10.0,
@@ -5977,7 +6140,7 @@ mod tests {
.enumerate() .enumerate()
.map(|(index, symbol)| { .map(|(index, symbol)| {
let mut row = market_row("2025-01-02", 10.0 + index as f64 / 1000.0, 1_000_000); let mut row = market_row("2025-01-02", 10.0 + index as f64 / 1000.0, 1_000_000);
row.symbol = symbol.clone(); row.symbol = symbol.clone().into();
row row
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
@@ -5986,7 +6149,7 @@ mod tests {
.enumerate() .enumerate()
.map(|(index, symbol)| DailyFactorSnapshot { .map(|(index, symbol)| DailyFactorSnapshot {
date, date,
symbol: symbol.clone(), symbol: symbol.clone().into(),
market_cap_bn: 10.0 + index as f64 / 1000.0, market_cap_bn: 10.0 + index as f64 / 1000.0,
free_float_cap_bn: 8.0, free_float_cap_bn: 8.0,
pe_ttm: 10.0, pe_ttm: 10.0,
@@ -6000,7 +6163,7 @@ mod tests {
.iter() .iter()
.map(|symbol| CandidateEligibility { .map(|symbol| CandidateEligibility {
date, date,
symbol: symbol.clone(), symbol: symbol.clone().into(),
is_st: false, is_st: false,
is_star_st: false, is_star_st: false,
is_new_listing: false, is_new_listing: false,
@@ -6113,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}"),
@@ -6192,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(),
@@ -6289,14 +6452,14 @@ mod tests {
fn factor_numeric_value_normalizes_fields_without_changing_aliases() { fn factor_numeric_value_normalizes_fields_without_changing_aliases() {
let snapshot = DailyFactorSnapshot { let snapshot = DailyFactorSnapshot {
date: NaiveDate::parse_from_str("2025-01-02", "%Y-%m-%d").unwrap(), date: NaiveDate::parse_from_str("2025-01-02", "%Y-%m-%d").unwrap(),
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
market_cap_bn: 12.5, market_cap_bn: 12.5,
free_float_cap_bn: 8.0, free_float_cap_bn: 8.0,
pe_ttm: 10.0, pe_ttm: 10.0,
turnover_ratio: None, turnover_ratio: None,
effective_turnover_ratio: None, effective_turnover_ratio: None,
adjustment_factor_backward1: Some(1.25), adjustment_factor_backward1: Some(1.25),
extra_factors: BTreeMap::from([("custom_factor".into(), 3.5)]), extra_factors: From::from([("custom_factor".into(), 3.5)]),
}; };
assert_eq!(factor_numeric_value(&snapshot, " MARKET_CAP "), Some(12.5)); assert_eq!(factor_numeric_value(&snapshot, " MARKET_CAP "), Some(12.5));
@@ -6312,31 +6475,29 @@ mod tests {
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 clean = normalize_factor_snapshots(vec![DailyFactorSnapshot { let clean = normalize_factor_snapshots(vec![DailyFactorSnapshot {
date, date,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
market_cap_bn: 1.0, market_cap_bn: 1.0,
free_float_cap_bn: 1.0, free_float_cap_bn: 1.0,
pe_ttm: 1.0, pe_ttm: 1.0,
turnover_ratio: None, turnover_ratio: None,
effective_turnover_ratio: None, effective_turnover_ratio: None,
adjustment_factor_backward1: None, adjustment_factor_backward1: None,
extra_factors: BTreeMap::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,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
market_cap_bn: 1.0, market_cap_bn: 1.0,
free_float_cap_bn: 1.0, free_float_cap_bn: 1.0,
pe_ttm: 1.0, pe_ttm: 1.0,
turnover_ratio: None, turnover_ratio: None,
effective_turnover_ratio: None, effective_turnover_ratio: None,
adjustment_factor_backward1: None, adjustment_factor_backward1: None,
extra_factors: BTreeMap::from([ extra_factors: From::from([
(Cow::Owned(" CUSTOM_FACTOR ".to_string()), 2.0), (Cow::Owned(" CUSTOM_FACTOR ".to_string()), 2.0),
(Cow::Borrowed("bad_nan"), f64::NAN), (Cow::Borrowed("bad_nan"), f64::NAN),
]), ]),
@@ -6351,7 +6512,7 @@ mod tests {
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 snapshot = |adjustment_factor_backward1, extra_factors| DailyFactorSnapshot { let snapshot = |adjustment_factor_backward1, extra_factors| DailyFactorSnapshot {
date, date,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
market_cap_bn: 1.0, market_cap_bn: 1.0,
free_float_cap_bn: 1.0, free_float_cap_bn: 1.0,
pe_ttm: 1.0, pe_ttm: 1.0,
@@ -6364,12 +6525,12 @@ mod tests {
assert!(matches!( assert!(matches!(
normalize_factor_snapshots(vec![snapshot( normalize_factor_snapshots(vec![snapshot(
Some(1.0), Some(1.0),
BTreeMap::from([(Cow::Borrowed(BACKWARD_ADJUSTMENT_FACTOR_FIELD), 1.0)]), BTreeMap::from([(Cow::Borrowed(BACKWARD_ADJUSTMENT_FACTOR_FIELD), 1.0)]).into(),
)]), )]),
Err(DataSetError::ReservedTypedFactorInExtraMap { .. }) Err(DataSetError::ReservedTypedFactorInExtraMap { .. })
)); ));
assert!(matches!( assert!(matches!(
normalize_factor_snapshots(vec![snapshot(Some(0.0), BTreeMap::new())]), normalize_factor_snapshots(vec![snapshot(Some(0.0), BTreeMap::new().into())]),
Err(DataSetError::InvalidBackwardAdjustmentFactor { .. }) Err(DataSetError::InvalidBackwardAdjustmentFactor { .. })
)); ));
for field in [ for field in [
@@ -6382,7 +6543,7 @@ mod tests {
matches!( matches!(
normalize_factor_snapshots(vec![snapshot( normalize_factor_snapshots(vec![snapshot(
typed_value, typed_value,
BTreeMap::from([(Cow::Borrowed(field), 2.0)]), BTreeMap::from([(Cow::Borrowed(field), 2.0)]).into(),
)]), )]),
Err(DataSetError::ReservedTypedFactorInExtraMap { .. }) Err(DataSetError::ReservedTypedFactorInExtraMap { .. })
), ),
@@ -6478,14 +6639,14 @@ mod tests {
} }
DailyFactorSnapshot { DailyFactorSnapshot {
date: *date, date: *date,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
market_cap_bn: 10.0, market_cap_bn: 10.0,
free_float_cap_bn: 8.0, free_float_cap_bn: 8.0,
pe_ttm: 10.0, pe_ttm: 10.0,
turnover_ratio: None, turnover_ratio: None,
effective_turnover_ratio: None, effective_turnover_ratio: None,
adjustment_factor_backward1: None, adjustment_factor_backward1: None,
extra_factors, extra_factors: extra_factors.into(),
} }
}) })
.collect(), .collect(),
@@ -6537,14 +6698,14 @@ mod tests {
.iter() .iter()
.map(|date| DailyFactorSnapshot { .map(|date| DailyFactorSnapshot {
date: *date, date: *date,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
market_cap_bn: 10.0, market_cap_bn: 10.0,
free_float_cap_bn: 8.0, free_float_cap_bn: 8.0,
pe_ttm: 10.0, pe_ttm: 10.0,
turnover_ratio: None, turnover_ratio: None,
effective_turnover_ratio: None, effective_turnover_ratio: None,
adjustment_factor_backward1: Some(1.0), adjustment_factor_backward1: Some(1.0),
extra_factors: BTreeMap::new(), extra_factors: Default::default(),
}) })
.collect(), .collect(),
Vec::new(), Vec::new(),
@@ -6831,14 +6992,14 @@ mod tests {
.zip(factors) .zip(factors)
.map(|(date, factor)| DailyFactorSnapshot { .map(|(date, factor)| DailyFactorSnapshot {
date: *date, date: *date,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
market_cap_bn: 10.0, market_cap_bn: 10.0,
free_float_cap_bn: 8.0, free_float_cap_bn: 8.0,
pe_ttm: 10.0, pe_ttm: 10.0,
turnover_ratio: None, turnover_ratio: None,
effective_turnover_ratio: None, effective_turnover_ratio: None,
adjustment_factor_backward1: Some(factor), adjustment_factor_backward1: Some(factor),
extra_factors: BTreeMap::new(), extra_factors: Default::default(),
}) })
.collect(), .collect(),
Vec::new(), Vec::new(),
@@ -6929,14 +7090,14 @@ mod tests {
.iter() .iter()
.map(|date| DailyFactorSnapshot { .map(|date| DailyFactorSnapshot {
date: *date, date: *date,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
market_cap_bn: 10.0, market_cap_bn: 10.0,
free_float_cap_bn: 8.0, free_float_cap_bn: 8.0,
pe_ttm: 10.0, pe_ttm: 10.0,
turnover_ratio: None, turnover_ratio: None,
effective_turnover_ratio: None, effective_turnover_ratio: None,
adjustment_factor_backward1: (*date != dates[3]).then_some(1.0), adjustment_factor_backward1: (*date != dates[3]).then_some(1.0),
extra_factors: BTreeMap::new(), extra_factors: Default::default(),
}) })
.collect(), .collect(),
Vec::new(), Vec::new(),
@@ -7008,7 +7169,7 @@ mod tests {
}; };
let market = |symbol: &str, prev_close: f64, close: f64| DailyMarketSnapshot { let market = |symbol: &str, prev_close: f64, close: f64| DailyMarketSnapshot {
date, date,
symbol: symbol.to_string(), symbol: symbol.into(),
timestamp: Some("2025-01-06 10:18:00".to_string()), timestamp: Some("2025-01-06 10:18:00".to_string()),
day_open: prev_close, day_open: prev_close,
open: prev_close, open: prev_close,
@@ -7032,18 +7193,18 @@ mod tests {
let factor = let factor =
|symbol: &str, market_cap_bn: f64, free_float_cap_bn: f64| DailyFactorSnapshot { |symbol: &str, market_cap_bn: f64, free_float_cap_bn: f64| DailyFactorSnapshot {
date, date,
symbol: symbol.to_string(), symbol: symbol.into(),
market_cap_bn, market_cap_bn,
free_float_cap_bn, free_float_cap_bn,
pe_ttm: 10.0, pe_ttm: 10.0,
turnover_ratio: Some(1.0), turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0), effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None, adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: Default::default(),
}; };
let candidate = |symbol: &str| CandidateEligibility { let candidate = |symbol: &str| CandidateEligibility {
date, date,
symbol: symbol.to_string(), symbol: symbol.into(),
is_st: false, is_st: false,
is_star_st: false, is_star_st: false,
is_new_listing: false, is_new_listing: false,
@@ -7108,7 +7269,7 @@ mod tests {
}], }],
vec![DailyMarketSnapshot { vec![DailyMarketSnapshot {
date, date,
symbol: symbol.to_string(), symbol: symbol.into(),
timestamp: Some("2025-01-06 10:18:00".to_string()), timestamp: Some("2025-01-06 10:18:00".to_string()),
day_open: 10.0, day_open: 10.0,
open: 10.0, open: 10.0,
@@ -7131,14 +7292,14 @@ mod tests {
}], }],
vec![DailyFactorSnapshot { vec![DailyFactorSnapshot {
date, date,
symbol: symbol.to_string(), symbol: symbol.into(),
market_cap_bn: 10.0, market_cap_bn: 10.0,
free_float_cap_bn: 9.0, free_float_cap_bn: 9.0,
pe_ttm: 10.0, pe_ttm: 10.0,
turnover_ratio: Some(1.0), turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0), effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None, adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: Default::default(),
}], }],
Vec::new(), Vec::new(),
vec![BenchmarkSnapshot { vec![BenchmarkSnapshot {
@@ -7193,7 +7354,7 @@ mod tests {
}], }],
vec![DailyMarketSnapshot { vec![DailyMarketSnapshot {
date, date,
symbol: symbol.to_string(), symbol: symbol.into(),
timestamp: Some("2025-01-06 10:18:00".to_string()), timestamp: Some("2025-01-06 10:18:00".to_string()),
day_open: 10.0, day_open: 10.0,
open: 10.0, open: 10.0,
@@ -7216,18 +7377,18 @@ mod tests {
}], }],
vec![DailyFactorSnapshot { vec![DailyFactorSnapshot {
date, date,
symbol: symbol.to_string(), symbol: symbol.into(),
market_cap_bn: 10.0, market_cap_bn: 10.0,
free_float_cap_bn: 9.0, free_float_cap_bn: 9.0,
pe_ttm: 10.0, pe_ttm: 10.0,
turnover_ratio: Some(1.0), turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0), effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None, adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: Default::default(),
}], }],
vec![CandidateEligibility { vec![CandidateEligibility {
date, date,
symbol: symbol.to_string(), symbol: symbol.into(),
is_st: false, is_st: false,
is_star_st: false, is_star_st: false,
is_new_listing: false, is_new_listing: false,
@@ -7268,14 +7429,14 @@ mod tests {
let date = NaiveDate::parse_from_str("2025-01-06", "%Y-%m-%d").unwrap(); let date = NaiveDate::parse_from_str("2025-01-06", "%Y-%m-%d").unwrap();
let factor = DailyFactorSnapshot { let factor = DailyFactorSnapshot {
date, date,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
market_cap_bn: 12.0, market_cap_bn: 12.0,
free_float_cap_bn: 4.0, free_float_cap_bn: 4.0,
pe_ttm: 10.0, pe_ttm: 10.0,
turnover_ratio: Some(1.0), turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0), effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None, adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: Default::default(),
}; };
assert!((decision_market_cap_bn(&factor) - 12.0).abs() < 1e-9); assert!((decision_market_cap_bn(&factor) - 12.0).abs() < 1e-9);
+111 -73
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};
@@ -122,6 +121,7 @@ impl DailyEquityPoint {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct BacktestResult { pub struct BacktestResult {
pub capacity_audit: crate::execution_capacity::CapacityAuditSummary,
pub strategy_name: String, pub strategy_name: String,
pub equity_curve: Vec<DailyEquityPoint>, pub equity_curve: Vec<DailyEquityPoint>,
pub benchmark_series: Vec<BenchmarkSnapshot>, pub benchmark_series: Vec<BenchmarkSnapshot>,
@@ -168,6 +168,8 @@ pub enum BacktestTerminalAssetClass {
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct BacktestTerminalAudit { pub struct BacktestTerminalAudit {
#[serde(default, skip_serializing_if = "is_zero_count")]
pub deferred_etf_target_count: usize,
pub status: BacktestTerminalStatus, pub status: BacktestTerminalStatus,
pub last_execution_date: Option<NaiveDate>, pub last_execution_date: Option<NaiveDate>,
pub stock_open_order_count: usize, pub stock_open_order_count: usize,
@@ -184,6 +186,7 @@ pub struct BacktestTerminalAudit {
impl Default for BacktestTerminalAudit { impl Default for BacktestTerminalAudit {
fn default() -> Self { fn default() -> Self {
Self { Self {
deferred_etf_target_count: 0,
status: BacktestTerminalStatus::Clean, status: BacktestTerminalStatus::Clean,
last_execution_date: None, last_execution_date: None,
stock_open_order_count: 0, stock_open_order_count: 0,
@@ -199,6 +202,8 @@ impl Default for BacktestTerminalAudit {
} }
} }
fn is_zero_count(value: &usize) -> bool { *value == 0 }
impl BacktestTerminalAudit { impl BacktestTerminalAudit {
pub fn is_clean(&self) -> bool { pub fn is_clean(&self) -> bool {
self.status == BacktestTerminalStatus::Clean self.status == BacktestTerminalStatus::Clean
@@ -280,6 +285,7 @@ pub struct AnalyzerRiskSummary {
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
pub struct AnalyzerReport { pub struct AnalyzerReport {
pub capacity_audit: crate::execution_capacity::CapacityAuditSummary,
pub strategy_name: String, pub strategy_name: String,
pub trades: Vec<AnalyzerTradeRow>, pub trades: Vec<AnalyzerTradeRow>,
pub positions: Vec<AnalyzerPositionRow>, pub positions: Vec<AnalyzerPositionRow>,
@@ -294,6 +300,7 @@ pub struct AnalyzerReport {
impl BacktestResult { impl BacktestResult {
pub fn analyzer_report(&self) -> AnalyzerReport { pub fn analyzer_report(&self) -> AnalyzerReport {
AnalyzerReport { AnalyzerReport {
capacity_audit: self.capacity_audit.clone(),
strategy_name: self.strategy_name.clone(), strategy_name: self.strategy_name.clone(),
trades: self trades: self
.fills .fills
@@ -465,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>>,
@@ -566,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(),
@@ -593,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
@@ -738,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,
@@ -784,6 +790,17 @@ where
end_time: Option<NaiveTime>, end_time: Option<NaiveTime>,
symbols: &mut BTreeSet<String>, symbols: &mut BTreeSet<String>,
) -> Result<(), BacktestError> { ) -> Result<(), BacktestError> {
// A missing point is not proof of an absent ETF minute dataset. Query
// its complete formal session first; loader/contract failures propagate.
if start_time.is_some() || end_time.is_some() {
let mut etfs = symbols.iter().filter(|symbol| {
self.broker.requires_etf_absence_check(&self.data, symbol)
&& !self.execution_quote_request_cache.contains(&(execution_date, (*symbol).clone(), None, None))
}).cloned().collect::<BTreeSet<_>>();
if !etfs.is_empty() {
self.load_missing_execution_quotes(execution_date, None, None, &mut etfs)?;
}
}
let mut available = BTreeSet::new(); let mut available = BTreeSet::new();
for symbol in symbols.iter() { for symbol in symbols.iter() {
let instrument = self.data.instrument(symbol).ok_or_else(|| BacktestError::Execution(format!( let instrument = self.data.instrument(symbol).ok_or_else(|| BacktestError::Execution(format!(
@@ -810,6 +827,9 @@ where
} }
*symbols = available; *symbols = available;
symbols.retain(|symbol| { symbols.retain(|symbol| {
if (start_time.is_some() || end_time.is_some()) && self.broker.has_verified_etf_minute_absence(execution_date, symbol) {
return false;
}
let request_key = (execution_date, symbol.clone(), start_time, end_time); let request_key = (execution_date, symbol.clone(), start_time, end_time);
if self.execution_quote_request_cache.contains(&request_key) { if self.execution_quote_request_cache.contains(&request_key) {
return false; return false;
@@ -854,6 +874,7 @@ where
} }
self.data.add_execution_quotes(quotes); self.data.add_execution_quotes(quotes);
if start_time.is_none() && end_time.is_none() { if start_time.is_none() && end_time.is_none() {
self.broker.record_complete_etf_minute_query(execution_date, &self.data, &requested_symbols);
self.validate_full_day_execution_quote_coverage(execution_date, &requested_symbols)?; self.validate_full_day_execution_quote_coverage(execution_date, &requested_symbols)?;
} }
for symbol in requested_symbols { for symbol in requested_symbols {
@@ -890,7 +911,7 @@ where
} }
continue; continue;
} }
if market.volume > 0 && !has_quotes { if market.volume > 0 && !has_quotes && !self.broker.has_verified_etf_minute_absence(execution_date, symbol) {
missing_active.push(symbol.clone()); missing_active.push(symbol.clone());
} }
} }
@@ -1392,6 +1413,7 @@ where
let status = if open_order_count == 0 let status = if open_order_count == 0
&& pending_cash_flow_count == 0 && pending_cash_flow_count == 0
&& cash_receivable_count == 0 && cash_receivable_count == 0
&& self.broker.pending_etf_target_count() == 0
{ {
BacktestTerminalStatus::Clean BacktestTerminalStatus::Clean
} else { } else {
@@ -1399,6 +1421,7 @@ where
}; };
BacktestTerminalAudit { BacktestTerminalAudit {
deferred_etf_target_count: self.broker.pending_etf_target_count(),
status, status,
last_execution_date, last_execution_date,
stock_open_order_count, stock_open_order_count,
@@ -2102,6 +2125,7 @@ where
.map(|(execution_date, _)| *execution_date) .map(|(execution_date, _)| *execution_date)
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let mut result = BacktestResult { let mut result = BacktestResult {
capacity_audit: self.broker.capacity_audit_summary(),
strategy_name: self.strategy.name().to_string(), strategy_name: self.strategy.name().to_string(),
benchmark_series: self benchmark_series: self
.data .data
@@ -2200,7 +2224,7 @@ where
.and_then(|(_, decision_slot)| *decision_slot); .and_then(|(_, decision_slot)| *decision_slot);
let Some((decision_index, decision_date)) = decision_slot else { let Some((decision_index, decision_date)) = decision_slot else {
let mut process_events = Vec::new(); let mut process_events = Vec::new();
let mut report = BrokerExecutionReport::default(); let mut report = self.broker.execute_deferred_etf_targets(execution_date, &mut portfolio, &self.data)?;
portfolio.update_prices_with_options( portfolio.update_prices_with_options(
execution_date, execution_date,
&self.data, &self.data,
@@ -2562,6 +2586,9 @@ where
"open_auction:post", "open_auction:post",
)?; )?;
let deferred_etfs = self.broker.execute_deferred_etf_targets(execution_date, &mut portfolio, &self.data)?;
merge_broker_report(&mut report, deferred_etfs);
publish_phase_event( publish_phase_event(
&mut self.strategy, &mut self.strategy,
&mut self.process_event_bus, &mut self.process_event_bus,
@@ -2582,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,
@@ -2850,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.matching_type_uses_intraday_quotes()) || ((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.matching_type_uses_intraday_quotes() { 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();
@@ -2942,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;
} }
@@ -3123,9 +3137,10 @@ 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.matching_type_uses_intraday_quotes() { if !newly_pending.is_empty() && self.broker.drives_resting_quote_clock() {
full_minute_symbols.extend(newly_pending.iter().cloned()); full_minute_symbols.extend(newly_pending.iter().cloned());
if self.execution_quote_loader.is_some() { if self.execution_quote_loader.is_some() {
self.load_missing_execution_quotes(execution_date, None, None, &mut newly_pending)?; self.load_missing_execution_quotes(execution_date, None, None, &mut newly_pending)?;
@@ -3146,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,
@@ -3423,6 +3440,16 @@ where
execution_date, execution_date,
); );
let daily_fill_count = result.fills.len() - day_fill_start; let daily_fill_count = result.fills.len() - day_fill_start;
for audit in self.broker.audit_completed_session_capacity(execution_date, &self.data)? {
result.capacity_audit.observe(&audit);
// Keep every audit in the durable event store, independent of
// debug phase retention. It never changes earlier executions.
result.process_events.push(ProcessEvent {
date: execution_date, kind: ProcessEventKind::SessionCapacityAudit,
order_id: None, symbol: Some(audit.symbol.clone()), side: None,
detail: serde_json::to_string(&audit).map_err(|error| BacktestError::Execution(error.to_string()))?,
});
}
let daily_order_count = result.order_events.len() - day_order_start; let daily_order_count = result.order_events.len() - day_order_start;
let execution_risk_decisions = let execution_risk_decisions =
risk_decisions_from_order_events(&result.order_events[day_order_start..]); risk_decisions_from_order_events(&result.order_events[day_order_start..]);
@@ -3627,6 +3654,8 @@ where
let split_ratio = action.split_ratio(); let split_ratio = action.split_ratio();
if (split_ratio - 1.0).abs() > f64::EPSILON { if (split_ratio - 1.0).abs() > f64::EPSILON {
portfolio.adjust_stock_pool_split(&action.symbol, split_ratio)
.map_err(BacktestError::Execution)?;
let (delta_quantity, quantity_after, average_cost) = { let (delta_quantity, quantity_after, average_cost) = {
let position = portfolio let position = portfolio
.position_mut_if_exists(&action.symbol) .position_mut_if_exists(&action.symbol)
@@ -5384,7 +5413,7 @@ mod tests {
fn market(date: NaiveDate, open: f64, close: f64) -> DailyMarketSnapshot { fn market(date: NaiveDate, open: f64, close: f64) -> DailyMarketSnapshot {
DailyMarketSnapshot { DailyMarketSnapshot {
date, date,
symbol: SYMBOL.to_string(), symbol: SYMBOL.into(),
timestamp: Some(format!("{date} 15:00:00")), timestamp: Some(format!("{date} 15:00:00")),
day_open: open, day_open: open,
open, open,
@@ -5438,21 +5467,21 @@ mod tests {
fn factor(date: NaiveDate) -> DailyFactorSnapshot { fn factor(date: NaiveDate) -> DailyFactorSnapshot {
DailyFactorSnapshot { DailyFactorSnapshot {
date, date,
symbol: SYMBOL.to_string(), symbol: SYMBOL.into(),
market_cap_bn: 10.0, market_cap_bn: 10.0,
free_float_cap_bn: 8.0, free_float_cap_bn: 8.0,
pe_ttm: 12.0, pe_ttm: 12.0,
turnover_ratio: Some(1.0), turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0), effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None, adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: Default::default(),
} }
} }
fn candidate(date: NaiveDate) -> CandidateEligibility { fn candidate(date: NaiveDate) -> CandidateEligibility {
CandidateEligibility { CandidateEligibility {
date, date,
symbol: SYMBOL.to_string(), symbol: SYMBOL.into(),
is_st: false, is_st: false,
is_star_st: false, is_star_st: false,
is_new_listing: false, is_new_listing: false,
@@ -5755,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"),
@@ -5768,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"),
@@ -5781,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"),
@@ -5904,11 +5933,12 @@ mod tests {
}], ..StrategyDecision::default() }) }], ..StrategyDecision::default() })
} }
} }
for scenario in 0..4 { for scenario in 0..5 {
let partial = scenario == 1; let partial = scenario == 1;
let closing_only = scenario >= 2; let closing_only = matches!(scenario,2|3);
let date = if closing_only { d(2026, 7, 6) } else { d(2026, 6, 1) }; let delayed = scenario == 4;
let quote = |hour, minute, price| IntradayExecutionQuote { 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 { 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,
@@ -5920,17 +5950,20 @@ mod tests {
let last = if closing_only { quote(15, 0, if scenario == 2 { 9.8 } else { 10.2 }) } else { quote(10, 1, 9.8) }; let last = if closing_only { quote(15, 0, if scenario == 2 { 9.8 } else { 10.2 }) } else { quote(10, 1, 9.8) };
let mut post_close = quote(15, 5, 9.7); let mut post_close = quote(15, 5, 9.7);
post_close.trading_phase = Some("post_close_fixed_price".into()); post_close.trading_phase = Some("post_close_fixed_price".into());
let mut data = dataset_from_market_and_candidates(vec![market(date, 10.2, 9.8)], vec![candidate(date)]); let prior = date.pred_opt().unwrap();
let markets = if delayed {vec![market(prior,10.2,10.2),market(date,10.2,9.8)]} else {vec![market(date,10.2,9.8)]};
let candidates = if delayed {vec![candidate(prior),candidate(date)]} else {vec![candidate(date)]};
let mut data = dataset_from_market_and_candidates(markets,candidates);
data.add_execution_quotes(vec![first.clone()]); data.add_execution_quotes(vec![first.clone()]);
let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks) let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
.with_matching_type(MatchingType::CurrentBarClose) .with_matching_type(if delayed {MatchingType::NextBarOpen} else {MatchingType::CurrentBarClose})
.with_intraday_execution_start_time(NaiveTime::from_hms_opt(9, 30, 0).unwrap())
.with_volume_limit(partial).with_volume_percent(0.01).with_liquidity_limit(false).with_inactive_limit(false); .with_volume_limit(partial).with_volume_percent(0.01).with_liquidity_limit(false).with_inactive_limit(false);
let broker = if delayed {broker} else {broker.with_intraday_execution_start_time(NaiveTime::from_hms_opt(9,30,0).unwrap())};
let requests = Arc::new(Mutex::new(Vec::new())); let requests = Arc::new(Mutex::new(Vec::new()));
let captured = Arc::clone(&requests); let captured = Arc::clone(&requests);
let mut engine = BacktestEngine::new(data, RestingLimit { quantity: if partial { 300 } else { 100 } }, broker, BacktestConfig { let mut engine = BacktestEngine::new(data, RestingLimit { quantity: if partial { 300 } else { 100 } }, broker, BacktestConfig {
initial_cash: 100_000.0, benchmark_code: "000852.SH".into(), start_date: Some(date), end_date: Some(date), initial_cash: 100_000.0, benchmark_code: "000852.SH".into(), start_date: Some(if delayed {prior} else {date}), end_date: Some(date),
decision_lag_trading_days: 0, execution_price_field: PriceField::Close, decision_lag_trading_days: usize::from(delayed), execution_price_field: if delayed {PriceField::Open} else {PriceField::Close},
}).with_execution_quote_loader(move |request| { }).with_execution_quote_loader(move |request| {
captured.lock().unwrap().push((request.start_time, request.end_time)); captured.lock().unwrap().push((request.start_time, request.end_time));
Ok(vec![earlier.clone(), first.clone(), unchanged.clone(), later.clone(), last.clone(), post_close.clone()]) Ok(vec![earlier.clone(), first.clone(), unchanged.clone(), later.clone(), last.clone(), post_close.clone()])
@@ -6028,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"),
@@ -7034,6 +7067,7 @@ mod tests {
let third = d(2025, 1, 6); let third = d(2025, 1, 6);
let fourth = d(2025, 1, 7); let fourth = d(2025, 1, 7);
let broker = scheduled_next_open_broker(FidcRiskControlConfig::default()) let broker = scheduled_next_open_broker(FidcRiskControlConfig::default())
.with_volume_capacity_mode(crate::execution_capacity::VolumeCapacityMode::SessionCapacityAudit)
.with_volume_limit(true) .with_volume_limit(true)
.with_volume_percent(0.25); .with_volume_percent(0.25);
let result = run_scheduled_round_trip_next_open_with_dataset_and_broker( let result = run_scheduled_round_trip_next_open_with_dataset_and_broker(
@@ -7061,12 +7095,13 @@ mod tests {
} }
#[test] #[test]
fn next_bar_open_sell_volume_limit_rejects_execution_day_zero_volume() { fn next_bar_open_session_audit_flags_zero_volume_without_rewriting_fills() {
let first = d(2025, 1, 2); let first = d(2025, 1, 2);
let second = d(2025, 1, 3); let second = d(2025, 1, 3);
let third = d(2025, 1, 6); let third = d(2025, 1, 6);
let fourth = d(2025, 1, 7); let fourth = d(2025, 1, 7);
let broker = scheduled_next_open_broker(FidcRiskControlConfig::default()) let broker = scheduled_next_open_broker(FidcRiskControlConfig::default())
.with_volume_capacity_mode(crate::execution_capacity::VolumeCapacityMode::SessionCapacityAudit)
.with_volume_limit(true) .with_volume_limit(true)
.with_volume_percent(0.25); .with_volume_percent(0.25);
let result = run_scheduled_round_trip_next_open_with_dataset_and_broker( let result = run_scheduled_round_trip_next_open_with_dataset_and_broker(
@@ -7087,7 +7122,10 @@ mod tests {
broker, broker,
); );
assert_round_trip_sell_canceled_with_reason(&result, "daily volume limit"); assert!(result.fills.iter().any(|fill| fill.side == OrderSide::Sell && fill.date == fourth));
assert_eq!(result.capacity_audit.audit_passed, Some(false));
assert_eq!(result.capacity_audit.failed_symbol_sessions, 1);
assert!(result.process_events.iter().any(|event| event.kind == crate::ProcessEventKind::SessionCapacityAudit));
} }
#[test] #[test]
+128
View File
@@ -0,0 +1,128 @@
//! Historical ETF execution fallback. Never manufactures an intraday bar.
use chrono::{NaiveDate, NaiveDateTime, NaiveTime};
use rust_decimal::Decimal;
use crate::{BacktestError, DataSet};
pub(crate) fn opening_time() -> NaiveTime {
NaiveTime::from_hms_opt(9, 30, 0).expect("valid exchange opening time")
}
#[derive(Debug, Clone)]
pub(crate) struct EtfFallbackReference {
pub price: f64,
pub reference_date: NaiveDate,
/// None means the next official session is outside the loaded calendar.
/// No natural-day guess or price from beyond the requested run is used.
pub execute_on: Option<NaiveDate>,
pub immediate: bool,
}
pub(crate) fn reference(data: &DataSet, symbol: &str, at: NaiveDateTime) -> Result<EtfFallbackReference, BacktestError> {
let fail = |reason: &str| BacktestError::Execution(format!(
"etf_daily_open_fallback:{reason} symbol={symbol} signal_at={at}"
));
let instrument = data.instrument(symbol).ok_or_else(|| fail("instrument_identity_missing"))?;
if !instrument.is_exchange_traded_fund() || instrument.listed_at.is_none() {
return Err(fail("verified_etf_identity_required"));
}
if instrument.dated_market_absence_reason(at.date()).is_some() {
return Err(fail("outside_instrument_lifecycle"));
}
if at.time() == opening_time() {
let row = data.market(at.date(), symbol).ok_or_else(|| fail("daily_open_missing"))?;
if !row.open.is_finite() || row.open <= 0.0 { return Err(fail("daily_open_invalid")); }
return Ok(EtfFallbackReference { price: row.open, reference_date: at.date(), execute_on: Some(at.date()), immediate: true });
}
let previous = data.previous_trading_date(at.date(), 1).ok_or_else(|| fail("previous_official_session_missing"))?;
let close = data.market(previous, symbol).map(|row| row.close).ok_or_else(|| fail("previous_completed_close_missing"))?;
if !close.is_finite() || close <= 0.0 { return Err(fail("previous_completed_close_invalid")); }
Ok(EtfFallbackReference {
price: close, reference_date: previous, immediate: false,
execute_on: if at.time() < opening_time() { Some(at.date()) } else { data.next_trading_date(at.date(), 1) },
})
}
#[derive(Debug, Clone)]
pub(crate) struct DeferredEtfTarget {
pub pool_id: String,
pub generation: String,
pub symbol: String,
pub signal_date: NaiveDate,
pub signal_at: NaiveDateTime,
pub execute_on: Option<NaiveDate>,
pub target_value: Decimal,
pub target_weight_bps: i32,
pub side: crate::stock_pool_execution::OrderSide,
pub max_positions: usize,
pub rule: std::sync::Arc<crate::stock_pool_execution::StockPoolExecutionRule>,
pub members: std::sync::Arc<Vec<crate::stock_pool_execution::StockPoolMemberSpec>>,
pub reason: String,
}
/// Owned by one broker/run. Replacing a full pool generation supersedes older
/// queued targets; order of the latest candidate list is retained.
#[derive(Debug, Default)]
pub(crate) struct DeferredEtfTargets {
generations: std::collections::BTreeMap<String, String>,
rows: Vec<DeferredEtfTarget>,
}
impl DeferredEtfTargets {
pub fn replace_generation(&mut self, pool_id: &str, generation: &str) -> usize {
if self.generations.get(pool_id).is_some_and(|old| old == generation) { return 0; }
self.generations.insert(pool_id.into(), generation.into());
let before = self.rows.len();
self.rows.retain(|row| row.pool_id != pool_id);
before - self.rows.len()
}
pub fn upsert(&mut self, row: DeferredEtfTarget) {
if let Some(existing) = self.rows.iter_mut().find(|v| v.pool_id == row.pool_id && v.symbol == row.symbol) {
*existing = row;
} else { self.rows.push(row); }
}
pub fn take_due(&mut self, date: NaiveDate) -> Vec<DeferredEtfTarget> {
let mut due = Vec::new();
self.rows.retain(|row| {
if row.execute_on.is_some_and(|day| day <= date) { due.push(row.clone()); false } else { true }
});
due.sort_by_key(|row| match row.side { crate::stock_pool_execution::OrderSide::Sell => 0, crate::stock_pool_execution::OrderSide::Buy => 1 });
due
}
pub fn len(&self) -> usize { self.rows.len() }
}
#[cfg(test)]
mod tests {
use super::*;
fn target(symbol:&str,side:crate::stock_pool_execution::OrderSide,generation:&str)->DeferredEtfTarget {
let date=NaiveDate::from_ymd_opt(2026,1,2).unwrap();
DeferredEtfTarget {pool_id:"pool".into(),generation:generation.into(),symbol:symbol.into(),signal_date:date,signal_at:date.and_hms_opt(13,0,0).unwrap(),execute_on:NaiveDate::from_ymd_opt(2026,1,5),target_value:1000.into(),target_weight_bps:5000,side,max_positions:2,rule:Default::default(),members:std::sync::Arc::new(vec![]),reason:"fixture".into()}
}
#[test]
fn latest_generation_overwrites_pending_targets_and_preserves_candidate_order() {
use crate::stock_pool_execution::OrderSide::{Buy,Sell};
let mut queue=DeferredEtfTargets::default();
queue.replace_generation("pool","v1");
queue.upsert(target("510300.SH",Buy,"v1"));
queue.upsert(target("159915.SZ",Buy,"v1"));
assert_eq!(queue.replace_generation("pool","v1"),0);
assert_eq!(queue.replace_generation("pool","v2"),2);
queue.upsert(target("560450.SH",Buy,"v2"));
queue.upsert(target("159915.SZ",Sell,"v2"));
queue.upsert(target("510300.SH",Buy,"v2"));
assert!(queue.take_due(NaiveDate::from_ymd_opt(2026,1,2).unwrap()).is_empty());
let due=queue.take_due(NaiveDate::from_ymd_opt(2026,1,5).unwrap());
assert_eq!(due.iter().map(|v|v.symbol.as_str()).collect::<Vec<_>>(),vec!["159915.SZ","560450.SH","510300.SH"]);
assert!(due.iter().all(|v|v.generation=="v2"));
assert_eq!(queue.len(),0);
}
#[test]
fn no_loaded_next_session_is_not_guessed_from_natural_days() {
let mut queue=DeferredEtfTargets::default();
let mut item=target("510300.SH",crate::stock_pool_execution::OrderSide::Buy,"v1");
item.execute_on=None;
queue.upsert(item);
assert!(queue.take_due(NaiveDate::from_ymd_opt(2026,2,1).unwrap()).is_empty());
assert_eq!(queue.len(),1);
}
}
+6
View File
@@ -317,6 +317,8 @@ pub enum ProcessEventKind {
AccountDepositWithdraw, AccountDepositWithdraw,
AccountFinanceRepay, AccountFinanceRepay,
AccountManagementFee, AccountManagementFee,
SessionCapacityAudit,
EtfExecutionFallback,
} }
impl ProcessEventKind { impl ProcessEventKind {
@@ -362,6 +364,8 @@ impl ProcessEventKind {
Self::AccountDepositWithdraw => "account_deposit_withdraw", Self::AccountDepositWithdraw => "account_deposit_withdraw",
Self::AccountFinanceRepay => "account_finance_repay", Self::AccountFinanceRepay => "account_finance_repay",
Self::AccountManagementFee => "account_management_fee", Self::AccountManagementFee => "account_management_fee",
Self::SessionCapacityAudit => "session_capacity_audit",
Self::EtfExecutionFallback => "etf_execution_fallback",
} }
} }
@@ -393,6 +397,8 @@ impl ProcessEventKind {
| Self::AccountDepositWithdraw | Self::AccountDepositWithdraw
| Self::AccountFinanceRepay | Self::AccountFinanceRepay
| Self::AccountManagementFee | Self::AccountManagementFee
| Self::SessionCapacityAudit
| Self::EtfExecutionFallback
| Self::Settlement | Self::Settlement
) )
} }
@@ -12,6 +12,19 @@ pub enum VolumeCapacityMode {
SessionCapacityAudit, SessionCapacityAudit,
} }
impl VolumeCapacityMode {
pub fn validate(self, enabled: bool, has_execution_observations: bool) -> Result<(), CapacityError> {
if !enabled { return Ok(()); }
match self {
Self::ExecutionObservation if !has_execution_observations => Err(CapacityError::MissingObservation),
Self::CompletedBar => Err(CapacityError::MissingCompletedBar),
_ => Ok(()),
}
}
pub fn limits_execution_quantity(self) -> bool { self != Self::SessionCapacityAudit }
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
pub enum CapacityError { pub enum CapacityError {
#[error("execution capacity ratio must be finite and in (0, 1]")] #[error("execution capacity ratio must be finite and in (0, 1]")]
@@ -26,6 +39,28 @@ pub enum CapacityError {
WrongSession, WrongSession,
#[error("execution-time capacity is missing; daily session volume cannot size an earlier fill")] #[error("execution-time capacity is missing; daily session volume cannot size an earlier fill")]
MissingObservation, MissingObservation,
#[error("completed_bar capacity requires declared bar end and availability; an undated daily total is not a completed observation")]
MissingCompletedBar,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CapacityAuditSummary {
pub mode: VolumeCapacityMode,
pub enabled: bool,
pub participation_rate: f64,
pub audited_symbol_sessions: usize,
pub failed_symbol_sessions: usize,
pub audit_passed: Option<bool>,
pub execution_time_capacity_proven: bool,
}
impl CapacityAuditSummary {
pub fn observe(&mut self, audit: &SessionCapacityAudit) {
self.audited_symbol_sessions += 1;
self.failed_symbol_sessions += usize::from(!audit.passed);
self.audit_passed = Some(self.failed_symbol_sessions == 0);
}
} }
/// Decimal semantics of the frozen JSON rate, evaluated without a float product. /// Decimal semantics of the frozen JSON rate, evaluated without a float product.
+4
View File
@@ -2,11 +2,13 @@ pub mod broker;
pub mod calendar; pub mod calendar;
pub mod cost; pub mod cost;
pub mod data; pub mod data;
mod numeric_factors;
pub mod daily_patterns; pub mod daily_patterns;
pub mod pattern_context; pub mod pattern_context;
pub mod session_events; pub mod session_events;
pub mod factor_events; pub mod factor_events;
pub mod execution_capacity; pub mod execution_capacity;
mod etf_execution;
mod execution_schedule; mod execution_schedule;
mod factor_event_catalog; mod factor_event_catalog;
pub mod factor_cross_section; pub mod factor_cross_section;
@@ -24,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;
@@ -35,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;
+415
View File
@@ -0,0 +1,415 @@
use std::borrow::Cow;
use std::collections::BTreeMap;
use std::fmt;
use std::ops::Index;
use compact_str::CompactString;
use serde::de::{MapAccess, Visitor};
use serde::ser::SerializeMap;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
/// Sorted numeric fields stored contiguously, without a tree node per snapshot.
#[derive(Clone, Default, PartialEq)]
pub struct NumericFactorMap {
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 {
pub const fn new() -> Self {
Self {
entries: Vec::new(),
}
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn clear(&mut self) {
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> {
self.entries
.binary_search_by(|(name, _)| name.as_str().cmp(key))
.ok()
.map(|index| &self.entries[index].1)
}
pub fn get_mut(&mut self, key: &str) -> Option<&mut f64> {
self.entries
.binary_search_by(|(name, _)| name.as_str().cmp(key))
.ok()
.map(|index| &mut self.entries[index].1)
}
pub fn contains_key(&self, key: &str) -> bool {
self.get(key).is_some()
}
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
.entries
.last()
.is_none_or(|(last, _)| last.as_str() < key.as_str())
{
self.entries.push((key, value));
return None;
}
match self
.entries
.binary_search_by(|(name, _)| name.as_str().cmp(key.as_str()))
{
Ok(index) => Some(std::mem::replace(&mut self.entries[index].1, value)),
Err(index) => {
self.entries.insert(index, (key, value));
None
}
}
}
pub fn remove(&mut self, key: &str) -> Option<f64> {
self.entries
.binary_search_by(|(name, _)| name.as_str().cmp(key))
.ok()
.map(|index| self.entries.remove(index).1)
}
pub fn retain(&mut self, mut keep: impl FnMut(&CompactString, &mut f64) -> bool) {
self.entries.retain_mut(|(key, value)| keep(key, value));
}
pub fn iter(&self) -> Iter<'_> {
Iter(self.entries.iter())
}
pub fn keys(&self) -> impl DoubleEndedIterator<Item = &CompactString> + ExactSizeIterator {
self.entries.iter().map(|(key, _)| key)
}
pub fn values(&self) -> impl DoubleEndedIterator<Item = &f64> + ExactSizeIterator {
self.entries.iter().map(|(_, value)| value)
}
}
impl fmt::Debug for NumericFactorMap {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_map().entries(self).finish()
}
}
impl Index<&str> for NumericFactorMap {
type Output = f64;
fn index(&self, key: &str) -> &Self::Output {
self.get(key).expect("numeric factor key not found")
}
}
pub struct Iter<'a>(std::slice::Iter<'a, (CompactString, f64)>);
impl<'a> Iterator for Iter<'a> {
type Item = (&'a CompactString, &'a f64);
fn next(&mut self) -> Option<Self::Item> {
self.0.next().map(|(k, v)| (k, v))
}
fn size_hint(&self) -> (usize, Option<usize>) {
self.0.size_hint()
}
}
impl DoubleEndedIterator for Iter<'_> {
fn next_back(&mut self) -> Option<Self::Item> {
self.0.next_back().map(|(k, v)| (k, v))
}
}
impl ExactSizeIterator for Iter<'_> {}
impl<'a> IntoIterator for &'a NumericFactorMap {
type Item = (&'a CompactString, &'a f64);
type IntoIter = Iter<'a>;
fn into_iter(self) -> Self::IntoIter {
self.iter()
}
}
impl IntoIterator for NumericFactorMap {
type Item = (CompactString, f64);
type IntoIter = std::vec::IntoIter<Self::Item>;
fn into_iter(self) -> Self::IntoIter {
self.entries.into_iter()
}
}
impl FromIterator<(Cow<'static, str>, f64)> for NumericFactorMap {
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();
// Stable sorting preserves last-value-wins for repeated input keys.
if !entries.windows(2).all(|pair| pair[0].0 <= pair[1].0) {
entries.sort_by(|left, right| left.0.cmp(&right.0));
}
entries.dedup_by(|later, earlier| {
if later.0 == earlier.0 {
earlier.1 = later.1;
true
} else {
false
}
});
Self { entries }
}
}
impl Extend<(Cow<'static, str>, f64)> for NumericFactorMap {
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();
if incoming.is_empty() {
return;
}
if self.is_empty() {
*self = incoming;
return;
}
if self.entries.last().unwrap().0 < incoming.entries[0].0 {
self.entries.append(&mut incoming.entries);
return;
}
// Merge sorted sets in linear time; wide factor batches must not shift
// the existing vector once per field. Existing keys keep their identity.
let mut merged = Vec::with_capacity(self.len() + incoming.len());
let mut old = std::mem::take(&mut self.entries).into_iter().peekable();
let mut new = incoming.entries.into_iter().peekable();
while let (Some(left), Some(right)) = (old.peek(), new.peek()) {
match left.0.cmp(&right.0) {
std::cmp::Ordering::Less => merged.push(old.next().unwrap()),
std::cmp::Ordering::Greater => merged.push(new.next().unwrap()),
std::cmp::Ordering::Equal => {
let (key, _) = old.next().unwrap();
merged.push((key, new.next().unwrap().1));
}
}
}
merged.extend(old);
merged.extend(new);
self.entries = merged;
}
}
impl<const N: usize> From<[(Cow<'static, str>, f64); N]> for NumericFactorMap {
fn from(entries: [(Cow<'static, str>, f64); N]) -> Self {
entries.into_iter().collect()
}
}
impl From<BTreeMap<Cow<'static, str>, f64>> for NumericFactorMap {
fn from(entries: BTreeMap<Cow<'static, str>, f64>) -> Self {
entries.into_iter().collect()
}
}
impl Serialize for NumericFactorMap {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
let mut map = serializer.serialize_map(Some(self.len()))?;
for (key, value) in self {
map.serialize_entry(key, value)?;
}
map.end()
}
}
impl<'de> Deserialize<'de> for NumericFactorMap {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
struct Fields;
impl<'de> Visitor<'de> for Fields {
type Value = NumericFactorMap;
fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("a numeric factor map")
}
fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<Self::Value, A::Error> {
let mut entries = Vec::new();
while let Some((key, value)) = map.next_entry::<CompactString, f64>()? {
entries.push((key, value));
}
Ok(entries.into_iter().collect())
}
}
deserializer.deserialize_map(Fields)
}
}
#[cfg(test)]
mod tests {
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]
fn updates_order_removal_and_values_match_tree_map() {
let mut flat = NumericFactorMap::new();
let mut tree = BTreeMap::new();
let mut seed = 71_u64;
for index in 0..10000 {
seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1);
let key: Cow<'static, str> = Cow::Owned(format!("factor_{:04}", (seed >> 32) % 1000));
let value = (index as f64 - 5000.0) / 7.0;
if index % 11 == 0 {
assert_eq!(flat.remove(key.as_ref()), tree.remove(key.as_ref()));
} else {
assert_eq!(flat.insert(key.clone(), value), tree.insert(key, value));
}
}
assert_eq!(
flat.iter().map(|(key, value)| (key.as_str(), value)).collect::<Vec<_>>(),
tree.iter().map(|(key, value)| (key.as_ref(), value)).collect::<Vec<_>>()
);
flat.retain(|_, value| *value > 100.0);
tree.retain(|_, value| *value > 100.0);
assert_eq!(
flat.iter().map(|(key, value)| (key.as_str(), value)).collect::<Vec<_>>(),
tree.iter().map(|(key, value)| (key.as_ref(), value)).collect::<Vec<_>>()
);
assert_eq!(
std::mem::size_of::<NumericFactorMap>(),
std::mem::size_of_val(&tree)
);
}
#[test]
fn bulk_load_is_sorted_and_keeps_last_value_for_each_field() {
let input = vec![
(Cow::Borrowed("z"), 2.0),
(Cow::Borrowed("a"), 1.0),
(Cow::Borrowed("z"), 3.0),
(Cow::Borrowed("z"), 4.0),
];
let flat: NumericFactorMap = input.clone().into_iter().collect();
let tree: BTreeMap<_, _> = input.into_iter().collect();
assert_eq!(
flat.iter().map(|(key, value)| (key.as_str(), value)).collect::<Vec<_>>(),
tree.iter().map(|(key, value)| (key.as_ref(), value)).collect::<Vec<_>>()
);
assert_eq!(flat["z"], 4.0);
}
#[test]
fn serialization_keeps_the_map_contract_and_precise_numbers() {
let input = [
(Cow::Borrowed("zero"), -0.0),
(Cow::Borrowed("tiny"), 1.0000000000000002),
(Cow::Borrowed("large"), 9.123456789123456e20),
];
let flat = NumericFactorMap::from(input.clone());
let tree = BTreeMap::from(input);
let json = serde_json::to_string(&flat).unwrap();
assert_eq!(json, serde_json::to_string(&tree).unwrap());
let decoded: NumericFactorMap = serde_json::from_str(&json).unwrap();
for (key, value) in &flat {
assert_eq!(value.to_bits(), decoded[key.as_ref()].to_bits());
}
let duplicate: NumericFactorMap = serde_json::from_str(r#"{"x":1,"x":2}"#).unwrap();
assert_eq!(duplicate["x"], 2.0);
}
#[test]
fn clone_does_not_share_mutable_values() {
let original = NumericFactorMap::from([(Cow::Borrowed("signal"), 1.0)]);
let mut copy = original.clone();
*copy.get_mut("signal").unwrap() = 0.0;
copy.insert(Cow::Borrowed("other"), 2.0);
assert_eq!(original["signal"], 1.0);
assert!(!original.contains_key("other"));
}
#[test]
fn wide_batch_merge_matches_tree_and_preserves_old_key_ownership() {
let entries = (0..4096)
.map(|index| (Cow::Owned(format!("f_{index:05}")), index as f64))
.collect::<Vec<_>>();
let mut flat: NumericFactorMap = entries.clone().into_iter().collect();
let mut tree = BTreeMap::from_iter(entries);
flat.insert(Cow::Borrowed("shared"), -0.0);
tree.insert(Cow::Borrowed("shared"), -0.0);
let incoming = (2048..8192)
.rev()
.map(|index| (Cow::Owned(format!("f_{index:05}")), -(index as f64)))
.chain([(Cow::Owned("shared".to_owned()), 1.0)])
.collect::<Vec<_>>();
flat.extend(incoming.clone());
tree.extend(incoming);
assert_eq!(
flat.iter().map(|(key, value)| (key.as_str(), value)).collect::<Vec<_>>(),
tree.iter().map(|(key, value)| (key.as_ref(), value)).collect::<Vec<_>>()
);
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)]);
assert!(flat["zz"].is_nan());
flat.extend(std::iter::empty::<(CompactString, f64)>());
assert_eq!(flat.len(), tree.len() + 1);
}
}
+41 -3
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)?),
@@ -221,7 +226,15 @@ pub fn specs_in_value(value: &Value) -> Result<Vec<PatternSpec>, String> {
} }
} }
Value::Object(items) => { Value::Object(items) => {
for v in items.values() { let typed_pool = items.get("stockPool").or_else(|| items.get("stock_pool"))
.is_some_and(Value::is_object);
for (key, v) in items {
// The executable pool already supplies decoded expressions.
// Its display/source serialization escapes those expressions
// one more time and is not another executable program.
if typed_pool && matches!(key.as_str(), "sourceCode" | "source_code") {
continue;
}
specs.extend(specs_in_value(v)?); specs.extend(specs_in_value(v)?);
} }
} }
@@ -248,6 +261,31 @@ mod tests {
use super::*; use super::*;
use crate::{BenchmarkSnapshot, DailyFactorSnapshot, DailyMarketSnapshot, Instrument}; use crate::{BenchmarkSnapshot, DailyFactorSnapshot, DailyMarketSnapshot, Instrument};
use serde_json::json; use serde_json::json;
#[test]
fn structured_pool_conditions_are_not_rescanned_inside_serialized_source_code() {
let pattern = json!({"template":"expression","parameters":{"history_window":20},
"expression":{"kind":"operator","name":"GT","args":[{"kind":"field","name":"amount"},{"kind":"number","value":0}]}});
let expr = format!("pattern_signal({})", serde_json::to_string(&pattern.to_string()).unwrap());
let pool = json!({"schema_version":1,"pool_id":"fixture","version_id":"v1","members":[],
"allocation_policy":{},"timing_policy":{},"stop_take_policy":{},"out_of_pool_policy":"hold",
"exit_signals":[{"role":"risk_exit","when_expr":expr,"remaining_position_bps":5000,"reason":"fixture"}]});
let source = format!("stock_pool.config({pool})");
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}}});
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();
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(&json!({"strategySpec":invalid})).is_err());
}
assert_eq!(specs_in_value(&json!({"sourceCode":format!("risk.stop_loss({expr})")})).unwrap().len(),1);
}
#[test] #[test]
fn normalized_rule_does_not_turn_an_omitted_window_into_explicit_null() { fn normalized_rule_does_not_turn_an_omitted_window_into_explicit_null() {
let expression:Expr=serde_json::from_value(json!({"kind":"operator","name":"GT","args":[{"kind":"field","name":"close"},{"kind":"number","value":1}]})).unwrap(); let expression:Expr=serde_json::from_value(json!({"kind":"operator","name":"GT","args":[{"kind":"field","name":"close"},{"kind":"number","value":1}]})).unwrap();
@@ -298,7 +336,7 @@ mod tests {
][n][i]; ][n][i];
market.push(DailyMarketSnapshot { market.push(DailyMarketSnapshot {
date: *d, date: *d,
symbol: s.to_string(), symbol: (*s).into(),
timestamp: None, timestamp: None,
day_open: c, day_open: c,
open: c, open: c,
@@ -321,7 +359,7 @@ mod tests {
}); });
factors.push(DailyFactorSnapshot { factors.push(DailyFactorSnapshot {
date: *d, date: *d,
symbol: s.to_string(), symbol: (*s).into(),
market_cap_bn: 1., market_cap_bn: 1.,
free_float_cap_bn: 1., free_float_cap_bn: 1.,
pe_ttm: 10., pe_ttm: 10.,
File diff suppressed because it is too large Load Diff
+31 -23
View File
@@ -14,6 +14,14 @@ impl PlatformExprStrategy {
.as_ref() .as_ref()
.ok_or_else(|| BacktestError::Execution("stock_pool_program_missing".into()))? .ok_or_else(|| BacktestError::Execution("stock_pool_program_missing".into()))?
.clone(); .clone();
if !self.config.stop_loss_expr.trim().is_empty() || !self.config.take_profit_expr.trim().is_empty()
|| self.config.position_target_rules.len() != program.exit_signals.len()
|| self.config.position_target_rules.iter().zip(&program.exit_signals).any(|(compiled, frozen)|
compiled.when_expr != frozen.when_expr || compiled.remaining_position_bps != frozen.remaining_position_bps
|| compiled.reason != frozen.reason || compiled.stock_pool_role != frozen.role)
{
return Err(BacktestError::Execution("stock_pool_exit_roles_required: exit rules must remain bound to the frozen stock_pool program".into()));
}
let mut constraints = pool::stock_pool_constraints_from_configuration( let mut constraints = pool::stock_pool_constraints_from_configuration(
&program.allocation_policy, &program.allocation_policy,
&program.stop_take_policy, &program.stop_take_policy,
@@ -78,17 +86,29 @@ impl PlatformExprStrategy {
closes, closes,
}); });
} }
let rule = pool::normalize_stock_pool_execution_rule( let rule = pool::normalize_stock_pool_execution_rule_with_exit_roles(
Some(&program.timing_policy), Some(&program.timing_policy),
!self.config.buy_filter_expr.trim().is_empty(), !self.config.buy_filter_expr.trim().is_empty(),
!self.config.stop_loss_expr.trim().is_empty() self.config.position_target_rules.iter().any(|rule| rule.stock_pool_role == pool::StockPoolExitRole::OrdinarySell),
|| !self.config.take_profit_expr.trim().is_empty() self.config.position_target_rules.iter().any(|rule| rule.stock_pool_role == pool::StockPoolExitRole::RiskExit),
|| !self.config.position_target_rules.is_empty(),
) )
.map_err(BacktestError::Execution)?; .map_err(BacktestError::Execution)?;
if self.config.in_skip_window(ctx.decision_date) { if self.config.in_skip_window(ctx.decision_date) {
return Ok(StrategyDecision::default()); return Ok(StrategyDecision::default());
} }
let explicit_quote_condition = self.selection_quote_usage != StockFilterQuoteUsage::DailyOnly
|| [self.config.buy_filter_expr.as_str(), self.config.stop_loss_expr.as_str(), self.config.take_profit_expr.as_str()]
.into_iter().chain(self.config.position_target_rules.iter().map(|rule|rule.when_expr.as_str()))
.any(|expression|Self::stock_filter_quote_usage_for_expr(expression)!=StockFilterQuoteUsage::DailyOnly);
if explicit_quote_condition && ctx.active_datetime.is_some_and(|at|at.time()<NaiveTime::from_hms_opt(15,0,0).unwrap()) {
for symbol in program.members.iter().map(|member|&member.symbol).chain(ctx.portfolio.positions().keys()) {
if ctx.data.instrument(symbol).is_some_and(|instrument|instrument.is_exchange_traded_fund()&&instrument.dated_market_absence_reason(ctx.execution_date).is_none())
&& self.scheduled_quote_at_time(ctx,ctx.execution_date,symbol,None).is_none()
{
return Err(BacktestError::Execution(format!("etf_intraday_condition_evidence_missing:{symbol}; completed daily references cannot make minute/tick conditions true")));
}
}
}
let day = self.day_state(ctx, ctx.decision_date)?; let day = self.day_state(ctx, ctx.decision_date)?;
let (market_date, universe_date, factor_date) = self.selection_dates(ctx); let (market_date, universe_date, factor_date) = self.selection_dates(ctx);
let (low, high) = self.market_cap_band(ctx, &day)?; let (low, high) = self.market_cap_band(ctx, &day)?;
@@ -120,23 +140,13 @@ impl PlatformExprStrategy {
} }
} }
} }
let native_exits = self.current_stop_take_exit_symbols(ctx, ctx.decision_date, &day)?; for (role, targets) in self.current_position_target_rules_by_role(ctx, ctx.decision_date, factor_date, &day)? {
for symbol in native_exits { let output = match role { pool::StockPoolExitRole::OrdinarySell => &mut constraints.position_target_bps, pool::StockPoolExitRole::RiskExit => &mut constraints.independent_position_target_bps };
constraints.position_target_bps.insert(symbol, 0); for (symbol, (bps, _)) in targets { output.insert(symbol, bps); }
}
for (symbol, (bps, _)) in
self.current_position_target_rules(ctx, ctx.decision_date, factor_date, &day)?
{
constraints
.position_target_bps
.entry(symbol)
.and_modify(|old| *old = (*old).min(bps))
.or_insert(bps);
} }
let limit = constraints.target_holding_count.unwrap_or(ranked.len()); let limit = constraints.target_holding_count.unwrap_or(ranked.len());
let final_symbols = ranked let final_symbols = ranked
.iter() .iter()
.filter(|symbol| !constraints.position_target_bps.contains_key(*symbol))
.take(limit) .take(limit)
.cloned() .cloned()
.collect(); .collect();
@@ -194,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()
+28 -2
View File
@@ -91,6 +91,8 @@ pub struct StrategyRebalanceSpec {
#[derive(Debug, Clone, Default, Deserialize, Serialize)] #[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct StrategyExecutionSpec { pub struct StrategyExecutionSpec {
#[serde(default, alias = "volume_capacity_mode")]
pub volume_capacity_mode: Option<crate::execution_capacity::VolumeCapacityMode>,
#[serde(default)] #[serde(default)]
pub frequency: Option<String>, pub frequency: Option<String>,
#[serde(default, alias = "matching_type")] #[serde(default, alias = "matching_type")]
@@ -164,9 +166,22 @@ pub struct StrategyExecutionSpec {
pub sell_then_buy_delay_slippage_rate: Option<f64>, pub sell_then_buy_delay_slippage_rate: Option<f64>,
} }
impl StrategyRuntimeSpec {
pub fn volume_capacity_mode(&self) -> Result<crate::execution_capacity::VolumeCapacityMode, String> {
let engine = self.engine_config.as_ref().and_then(|config| config.volume_capacity_mode);
let execution = self.execution.as_ref().and_then(|config| config.volume_capacity_mode);
if engine.zip(execution).is_some_and(|(a, b)| a != b) {
return Err("conflicting engine/execution volumeCapacityMode".into());
}
Ok(execution.or(engine).unwrap_or_default())
}
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)] #[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct StrategyEngineConfig { pub struct StrategyEngineConfig {
#[serde(default, alias = "volume_capacity_mode")]
pub volume_capacity_mode: Option<crate::execution_capacity::VolumeCapacityMode>,
#[serde(default)] #[serde(default)]
pub frequency: Option<String>, pub frequency: Option<String>,
#[serde(default, alias = "template_id")] #[serde(default, alias = "template_id")]
@@ -934,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)]
@@ -1822,6 +1839,7 @@ pub fn platform_expr_config_from_spec(
strategy_spec: Option<&StrategyRuntimeSpec>, strategy_spec: Option<&StrategyRuntimeSpec>,
) -> Result<PlatformExprStrategyConfig, String> { ) -> Result<PlatformExprStrategyConfig, String> {
let mut cfg = PlatformExprStrategyConfig::generic(); let mut cfg = PlatformExprStrategyConfig::generic();
cfg.volume_capacity_mode = strategy_spec.map(StrategyRuntimeSpec::volume_capacity_mode).transpose()?.unwrap_or_default();
cfg.strategy_name = strategy_id.to_string(); cfg.strategy_name = strategy_id.to_string();
if !signal_symbol.trim().is_empty() { if !signal_symbol.trim().is_empty() {
cfg.signal_symbol = signal_symbol.trim().to_string(); cfg.signal_symbol = signal_symbol.trim().to_string();
@@ -2212,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(),
@@ -2323,6 +2342,7 @@ pub fn platform_expr_config_from_spec(
)); ));
} }
cfg.position_target_rules.push(PlatformPositionTargetRule { cfg.position_target_rules.push(PlatformPositionTargetRule {
stock_pool_role: crate::stock_pool_execution::StockPoolExitRole::OrdinarySell,
when_expr: when_expr.to_string(), when_expr: when_expr.to_string(),
remaining_position_bps: rule.remaining_position_bps, remaining_position_bps: rule.remaining_position_bps,
reason: rule reason: rule
@@ -2696,9 +2716,14 @@ pub fn platform_expr_config_from_spec(
} }
if let Some(pool)=&spec.stock_pool { if let Some(pool)=&spec.stock_pool {
if cfg.signal_book.is_some() || spec.signal_book_ref.is_some() || !cfg.explicit_actions.is_empty(){return Err("stock_pool_program_cannot_mix_other_order_programs".into())} if cfg.signal_book.is_some() || spec.signal_book_ref.is_some() || !cfg.explicit_actions.is_empty(){return Err("stock_pool_program_cannot_mix_other_order_programs".into())}
let legacy_exit = !cfg.stop_loss_expr.trim().is_empty() || !cfg.take_profit_expr.trim().is_empty() || !cfg.position_target_rules.is_empty();
if legacy_exit { return Err("stock_pool_exit_roles_required: regenerate this historical stock-pool strategy from its saved configuration; legacy risk expressions do not preserve ordinary/risk exit roles".into()); }
let secondary_buy=!cfg.buy_filter_expr.trim().is_empty(); let secondary_buy=!cfg.buy_filter_expr.trim().is_empty();
let secondary_sell=spec.runtime_expressions.as_ref().and_then(|runtime|runtime.risk.as_ref()).is_some_and(|risk|risk.stop_loss_expr.is_some()||risk.take_profit_expr.is_some()) || !cfg.position_target_rules.is_empty(); pool.validate(secondary_buy,false)?;
pool.validate(secondary_buy,secondary_sell)?; cfg.position_target_rules.extend(pool.exit_signals.iter().map(|signal| PlatformPositionTargetRule {
when_expr: signal.when_expr.clone(), remaining_position_bps: signal.remaining_position_bps,
reason: signal.reason.clone(), stock_pool_role: signal.role,
}));
cfg.stock_pool=Some(pool.clone()); cfg.stock_pool=Some(pool.clone());
cfg.hold_until_exit_enabled=false; cfg.hold_until_exit_enabled=false;
cfg.daily_top_up_enabled=false; cfg.daily_top_up_enabled=false;
@@ -3440,6 +3465,7 @@ mod tests {
assert_eq!( assert_eq!(
cfg.position_target_rules, cfg.position_target_rules,
vec![PlatformPositionTargetRule { vec![PlatformPositionTargetRule {
stock_pool_role: crate::stock_pool_execution::StockPoolExitRole::OrdinarySell,
when_expr: "factors[\"reduce_signal\"] == 1".to_string(), when_expr: "factors[\"reduce_signal\"] == 1".to_string(),
remaining_position_bps: 5000, remaining_position_bps: 5000,
reason: "factor_reduce_position".to_string(), reason: "factor_reduce_position".to_string(),
+22 -14
View File
@@ -732,6 +732,16 @@ impl PortfolioState {
state.validate()?;self.stock_pool_states.insert(pool_id.into(),state);Ok(()) state.validate()?;self.stock_pool_states.insert(pool_id.into(),state);Ok(())
} }
pub(crate) fn adjust_stock_pool_split(&mut self, symbol: &str, ratio: f64) -> Result<(), String> {
let ratio = rust_decimal::Decimal::from_str_exact(&ratio.to_string())
.map_err(|_| "stock_pool_execution_state_split_invalid".to_string())?;
let adjusted = self.stock_pool_states.iter()
.map(|(pool, state)| Ok((pool.clone(), state.adjust_for_split(symbol, ratio)?)))
.collect::<Result<BTreeMap<_, _>, String>>()?;
self.stock_pool_states = adjusted;
Ok(())
}
pub fn initial_cash(&self) -> f64 { pub fn initial_cash(&self) -> f64 {
self.initial_cash.to_f64() self.initial_cash.to_f64()
} }
@@ -1428,8 +1438,6 @@ mod tests {
BenchmarkSnapshot, CandidateEligibility, DailyFactorSnapshot, DailyMarketSnapshot, DataSet, BenchmarkSnapshot, CandidateEligibility, DailyFactorSnapshot, DailyMarketSnapshot, DataSet,
PriceField, PriceField,
}; };
use std::collections::BTreeMap;
#[test] #[test]
fn cash_ledger_accumulates_micro_yuan_exactly() { fn cash_ledger_accumulates_micro_yuan_exactly() {
let mut portfolio = PortfolioState::new(1_000_000.0); let mut portfolio = PortfolioState::new(1_000_000.0);
@@ -1631,7 +1639,7 @@ mod tests {
vec![ vec![
DailyMarketSnapshot { DailyMarketSnapshot {
date: prev_date, date: prev_date,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
timestamp: None, timestamp: None,
day_open: 10.0, day_open: 10.0,
open: 10.0, open: 10.0,
@@ -1654,7 +1662,7 @@ mod tests {
}, },
DailyMarketSnapshot { DailyMarketSnapshot {
date, date,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
timestamp: None, timestamp: None,
day_open: 10.5, day_open: 10.5,
open: 10.5, open: 10.5,
@@ -1678,18 +1686,18 @@ mod tests {
], ],
vec![DailyFactorSnapshot { vec![DailyFactorSnapshot {
date, date,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
market_cap_bn: 50.0, market_cap_bn: 50.0,
free_float_cap_bn: 45.0, free_float_cap_bn: 45.0,
pe_ttm: 10.0, pe_ttm: 10.0,
turnover_ratio: Some(1.0), turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0), effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None, adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: Default::default(),
}], }],
vec![CandidateEligibility { vec![CandidateEligibility {
date, date,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
is_st: false, is_st: false,
is_star_st: false, is_star_st: false,
is_new_listing: false, is_new_listing: false,
@@ -1744,7 +1752,7 @@ mod tests {
}], }],
vec![DailyMarketSnapshot { vec![DailyMarketSnapshot {
date, date,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
timestamp: None, timestamp: None,
day_open: 10.5, day_open: 10.5,
open: 10.5, open: 10.5,
@@ -1767,18 +1775,18 @@ mod tests {
}], }],
vec![DailyFactorSnapshot { vec![DailyFactorSnapshot {
date, date,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
market_cap_bn: 50.0, market_cap_bn: 50.0,
free_float_cap_bn: 45.0, free_float_cap_bn: 45.0,
pe_ttm: 10.0, pe_ttm: 10.0,
turnover_ratio: Some(1.0), turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0), effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None, adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: Default::default(),
}], }],
vec![CandidateEligibility { vec![CandidateEligibility {
date, date,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
is_st: false, is_st: false,
is_star_st: false, is_star_st: false,
is_new_listing: false, is_new_listing: false,
@@ -1830,7 +1838,7 @@ mod tests {
}], }],
vec![DailyMarketSnapshot { vec![DailyMarketSnapshot {
date: prev_date, date: prev_date,
symbol: "601028.SH".to_string(), symbol: "601028.SH".into(),
timestamp: None, timestamp: None,
day_open: 10.2, day_open: 10.2,
open: 10.2, open: 10.2,
@@ -1913,7 +1921,7 @@ mod tests {
vec![ vec![
DailyMarketSnapshot { DailyMarketSnapshot {
date: buy_date, date: buy_date,
symbol: symbol.to_string(), symbol: symbol.into(),
timestamp: None, timestamp: None,
day_open: 2.99, day_open: 2.99,
open: 2.99, open: 2.99,
@@ -1936,7 +1944,7 @@ mod tests {
}, },
DailyMarketSnapshot { DailyMarketSnapshot {
date: next_date, date: next_date,
symbol: symbol.to_string(), symbol: symbol.into(),
timestamp: None, timestamp: None,
day_open: 3.03, day_open: 3.03,
open: 3.03, open: 3.03,
+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());
}
}
+7 -7
View File
@@ -869,7 +869,7 @@ mod tests {
fn candidate(date: NaiveDate) -> CandidateEligibility { fn candidate(date: NaiveDate) -> CandidateEligibility {
CandidateEligibility { CandidateEligibility {
date, date,
symbol: "002633.SZ".to_string(), symbol: "002633.SZ".into(),
is_st: false, is_st: false,
is_star_st: false, is_star_st: false,
is_new_listing: false, is_new_listing: false,
@@ -885,7 +885,7 @@ mod tests {
fn market(date: NaiveDate, last_price: f64, lower_limit: f64) -> DailyMarketSnapshot { fn market(date: NaiveDate, last_price: f64, lower_limit: f64) -> DailyMarketSnapshot {
DailyMarketSnapshot { DailyMarketSnapshot {
date, date,
symbol: "002633.SZ".to_string(), symbol: "002633.SZ".into(),
timestamp: Some(format!("{date} 10:18:00")), timestamp: Some(format!("{date} 10:18:00")),
day_open: last_price, day_open: last_price,
open: last_price, open: last_price,
@@ -944,7 +944,7 @@ mod tests {
let mut candidate=candidate(day); let mut candidate=candidate(day);
let mut snapshot=market(day,1.2,0.5); let mut snapshot=market(day,1.2,0.5);
snapshot.lower_limit=0.01;snapshot.upper_limit=10.; snapshot.lower_limit=0.01;snapshot.upper_limit=10.;
let instrument=Instrument{symbol:candidate.symbol.clone(),name:"fixture fund".into(),board:"ETF".into(),round_lot:100,listed_at:Some(d(2024,1,2)),delisted_at:None,status:"active".into()}; let instrument=Instrument{symbol:candidate.symbol.to_string(),name:"fixture fund".into(),board:"ETF".into(),round_lot:100,listed_at:Some(d(2024,1,2)),delisted_at:None,status:"active".into()};
let config=FidcRiskControlConfig::default(); let config=FidcRiskControlConfig::default();
assert_eq!(ChinaAShareRiskControl::buy_rejection_reason_with_config(day,&candidate,&snapshot,Some(&instrument),0.9,&config),None); assert_eq!(ChinaAShareRiskControl::buy_rejection_reason_with_config(day,&candidate,&snapshot,Some(&instrument),0.9,&config),None);
assert_eq!(ChinaAShareRiskControl::buy_rejection_reason_with_config(day,&candidate,&snapshot,Some(&instrument),0.,&config),Some("invalid execution price")); assert_eq!(ChinaAShareRiskControl::buy_rejection_reason_with_config(day,&candidate,&snapshot,Some(&instrument),0.,&config),Some("invalid execution price"));
@@ -1047,7 +1047,7 @@ mod tests {
config config
.static_rules .static_rules
.blacklisted_symbols .blacklisted_symbols
.insert(candidate.symbol.clone()); .insert(candidate.symbol.to_string());
let selection_reason = ChinaAShareRiskControl::selection_rejection_reason_with_config( let selection_reason = ChinaAShareRiskControl::selection_rejection_reason_with_config(
date, &candidate, &market, None, &config, date, &candidate, &market, None, &config,
@@ -1092,7 +1092,7 @@ mod tests {
let mut candidate = candidate(date); let mut candidate = candidate(date);
let config = FidcRiskControlConfig::default(); let config = FidcRiskControlConfig::default();
for symbol in ["688001.SH", "689001.SH", "000001.SZ"] { for symbol in ["688001.SH", "689001.SH", "000001.SZ"] {
candidate.symbol = symbol.to_string(); candidate.symbol = symbol.into();
for is_kcb in [false, true] { for is_kcb in [false, true] {
candidate.is_kcb = is_kcb; candidate.is_kcb = is_kcb;
let reason = ChinaAShareRiskControl::buy_rejection_reason_with_config( let reason = ChinaAShareRiskControl::buy_rejection_reason_with_config(
@@ -1200,7 +1200,7 @@ mod tests {
fn configurable_bjse_filter_can_be_disabled() { fn configurable_bjse_filter_can_be_disabled() {
let date = d(2025, 1, 2); let date = d(2025, 1, 2);
let mut candidate = candidate(date); let mut candidate = candidate(date);
candidate.symbol = "430047.BJ".to_string(); candidate.symbol = "430047.BJ".into();
candidate.allow_sell = true; candidate.allow_sell = true;
let market = market(date, 6.27, 5.63); let market = market(date, 6.27, 5.63);
let default_selection = let default_selection =
@@ -1232,7 +1232,7 @@ mod tests {
fn concrete_kcb_reason_wins_over_generic_missing_risk_state() { fn concrete_kcb_reason_wins_over_generic_missing_risk_state() {
let date = d(2025, 1, 2); let date = d(2025, 1, 2);
let mut candidate = candidate(date); let mut candidate = candidate(date);
candidate.symbol = "688506.SH".to_string(); candidate.symbol = "688506.SH".into();
candidate.is_kcb = true; candidate.is_kcb = true;
candidate.risk_level_code = Some("missing_risk_state".to_string()); candidate.risk_level_code = Some("missing_risk_state".to_string());
let market = market(date, 6.27, 5.63); let market = market(date, 6.27, 5.63);
+148 -33
View File
@@ -39,6 +39,22 @@ pub enum QuoteConditionScope {
AnyTarget, AnyTarget,
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum StockPoolExitRole {
OrdinarySell,
RiskExit,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct StockPoolExitSignal {
pub role: StockPoolExitRole,
pub when_expr: String,
pub remaining_position_bps: u32,
pub reason: String,
}
pub fn stock_pool_target_holding_count(policy: &Value) -> Result<Option<usize>, String> { pub fn stock_pool_target_holding_count(policy: &Value) -> Result<Option<usize>, String> {
let object = policy let object = policy
.as_object() .as_object()
@@ -399,6 +415,8 @@ pub struct StockPoolExecutionRule {
pub sell_condition_scope: Option<QuoteConditionScope>, pub sell_condition_scope: Option<QuoteConditionScope>,
#[serde(skip)] #[serde(skip)]
pub secondary_sell_condition: bool, pub secondary_sell_condition: bool,
#[serde(skip)]
pub independent_sell_condition: bool,
#[serde( #[serde(
default, default,
deserialize_with = "crate::holding_policy::deserialize_optional_policy" deserialize_with = "crate::holding_policy::deserialize_optional_policy"
@@ -476,6 +494,10 @@ pub struct StockPoolDecisionConstraints {
pub default_stop_loss: Option<Decimal>, pub default_stop_loss: Option<Decimal>,
pub default_take_profit: Option<Decimal>, pub default_take_profit: Option<Decimal>,
pub position_target_bps: BTreeMap<String, u32>, pub position_target_bps: BTreeMap<String, u32>,
pub independent_position_target_bps: BTreeMap<String, u32>,
/// First actually planned holding quantity for this generation. Retries
/// apply percentages to this basis, never to the remaining holding.
pub position_action_bases: BTreeMap<String, Decimal>,
pub buy_denials: BTreeMap<String, Vec<String>>, pub buy_denials: BTreeMap<String, Vec<String>>,
pub same_day_sold_symbols: BTreeSet<String>, pub same_day_sold_symbols: BTreeSet<String>,
pub automatic_permissions: BTreeMap<String, crate::holding_policy::AutomaticTradePermission>, pub automatic_permissions: BTreeMap<String, crate::holding_policy::AutomaticTradePermission>,
@@ -508,6 +530,8 @@ pub struct StockPoolPlanRow {
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct StockPoolPlan { pub struct StockPoolPlan {
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub position_action_bases: BTreeMap<String, Decimal>,
pub market_timing: Option<crate::stock_pool_index_policy::MarketTimingEvaluation>, pub market_timing: Option<crate::stock_pool_index_policy::MarketTimingEvaluation>,
pub rows: Vec<StockPoolPlanRow>, pub rows: Vec<StockPoolPlanRow>,
pub budget: Decimal, pub budget: Decimal,
@@ -549,6 +573,8 @@ pub struct StockPoolProgram {
pub timing_policy: Value, pub timing_policy: Value,
pub stop_take_policy: Value, pub stop_take_policy: Value,
pub out_of_pool_policy: String, pub out_of_pool_policy: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub exit_signals: Vec<StockPoolExitSignal>,
} }
impl StockPoolProgram { impl StockPoolProgram {
@@ -562,10 +588,19 @@ impl StockPoolProgram {
normalize_stock_pool_members(&self.members)?; normalize_stock_pool_members(&self.members)?;
stock_pool_funding_from_configuration(&self.allocation_policy)?; stock_pool_funding_from_configuration(&self.allocation_policy)?;
stock_pool_constraints_from_configuration(&self.allocation_policy, &self.stop_take_policy)?; stock_pool_constraints_from_configuration(&self.allocation_policy, &self.stop_take_policy)?;
normalize_stock_pool_execution_rule( let mut identities = BTreeSet::new();
for signal in &self.exit_signals {
if signal.when_expr.trim().is_empty() || signal.reason.trim().is_empty() || signal.remaining_position_bps >= 10000 {
return Err("stock_pool_exit_signal_invalid".into());
}
let identity = serde_json::to_string(signal).map_err(|error| error.to_string())?;
if !identities.insert(identity) { return Err("stock_pool_exit_signal_duplicate".into()); }
}
normalize_stock_pool_execution_rule_with_exit_roles(
Some(&self.timing_policy), Some(&self.timing_policy),
secondary_buy, secondary_buy,
secondary_sell, secondary_sell || self.exit_signals.iter().any(|signal| signal.role == StockPoolExitRole::OrdinarySell),
self.exit_signals.iter().any(|signal| signal.role == StockPoolExitRole::RiskExit),
)?; )?;
if !matches!( if !matches!(
self.out_of_pool_policy.as_str(), self.out_of_pool_policy.as_str(),
@@ -583,6 +618,7 @@ impl Default for StockPoolExecutionRule {
buy_condition_scope: None, buy_condition_scope: None,
sell_condition_scope: None, sell_condition_scope: None,
secondary_sell_condition: false, secondary_sell_condition: false,
independent_sell_condition: false,
automatic_trade_protection: Default::default(), automatic_trade_protection: Default::default(),
schema_version: STOCK_POOL_SCHEMA_VERSION, schema_version: STOCK_POOL_SCHEMA_VERSION,
auto_execute: true, auto_execute: true,
@@ -669,7 +705,17 @@ pub fn build_stock_pool_target_plan_with_fee_model(
} }
} }
} }
// Validate source targets before a stronger stop/expiry can replace them.
// Otherwise an invalid ratio could be hidden by target consolidation.
for (symbol, target) in constraints.position_target_bps.iter().chain(constraints.independent_position_target_bps.iter()) {
if *target >= 10_000 {
return Err(format!("factor position target for {symbol} must be below 10000 bps"));
}
}
let mut effective_position_targets = constraints.position_target_bps.clone(); let mut effective_position_targets = constraints.position_target_bps.clone();
for (symbol, target) in &constraints.independent_position_target_bps {
effective_position_targets.entry(symbol.clone()).and_modify(|current| *current = (*current).min(*target)).or_insert(*target);
}
for (symbol, permission) in &constraints.automatic_permissions { for (symbol, permission) in &constraints.automatic_permissions {
if permission.max_holding_exit { if permission.max_holding_exit {
effective_position_targets.insert(symbol.clone(), 0); effective_position_targets.insert(symbol.clone(), 0);
@@ -764,6 +810,19 @@ pub fn build_stock_pool_target_plan_with_fee_model(
if quote_map.len() != quotes.len() { if quote_map.len() != quotes.len() {
return Err("duplicate or invalid stock pool execution quotes".into()); return Err("duplicate or invalid stock pool execution quotes".into());
} }
let declared_symbols = normalized_members.iter().map(|member| member.symbol.as_str()).collect::<BTreeSet<_>>();
for (symbol, quantity) in &constraints.position_action_bases {
if normalize_stock_symbol(symbol).as_ref() != Some(symbol) || *quantity <= Decimal::ZERO {
return Err(format!("invalid stock pool position-action basis:{symbol}"));
}
}
for (symbol, _) in constraints.position_target_bps.iter().chain(constraints.independent_position_target_bps.iter()) {
if normalize_stock_symbol(symbol).as_deref() != Some(symbol.as_str()) || (!declared_symbols.contains(symbol.as_str()) && !current.contains_key(symbol)) {
return Err(format!("position action is outside declared candidates and managed holdings:{symbol}"));
}
}
// Exit rules act on managed holdings, not on an unheld candidate's entry.
effective_position_targets.retain(|symbol, _| current.get(symbol).is_some_and(|position| position.0 > Decimal::ZERO));
frozen::validate(selection.trade_date, constraints, &current)?; frozen::validate(selection.trade_date, constraints, &current)?;
for symbol in constraints.frozen_positions.keys() { for symbol in constraints.frozen_positions.keys() {
effective_position_targets.remove(symbol); effective_position_targets.remove(symbol);
@@ -863,37 +922,49 @@ pub fn build_stock_pool_target_plan_with_fee_model(
.then(|| symbol.clone()) .then(|| symbol.clone())
}) })
.collect::<BTreeSet<_>>(); .collect::<BTreeSet<_>>();
// A full stop is stricter than a simultaneous relative reduction. Merge
// the target before selecting its single owner, never emit a second exit.
for symbol in &global_stop_hits {
if let Some(target) = effective_position_targets.get_mut(symbol) {
*target = 0;
}
}
let mut quote_sell_exits = BTreeSet::new(); let mut quote_sell_exits = BTreeSet::new();
let mut sell_condition_denials = BTreeSet::new(); let mut sell_condition_denials = BTreeSet::new();
if rule.sell_trigger_mode == POOL_SELL_CONDITION { if rule.sell_trigger_mode == POOL_SELL_CONDITION {
let ordinary_enabled = !rule.sell_condition.trim().is_empty() || rule.secondary_sell_condition;
// Ordinary sell predicates only depend on positions participating in
// that stage. Independent stops/expiry and protected holdings were
// already decided above; unrelated quote fields must not block them.
let held = current let held = current
.iter() .iter()
.filter(|(symbol, row)| { .filter(|(symbol, row)| {
row.0 > Decimal::ZERO && !constraints.frozen_positions.contains_key(*symbol) row.0 > Decimal::ZERO
&& !protected_positions.contains(*symbol)
&& !global_stop_hits.contains(*symbol)
&& constraints.independent_position_target_bps.get(*symbol) != Some(&0)
&& !constraints.automatic_permissions.get(*symbol)
.is_some_and(|permission| permission.max_holding_exit)
}) })
.map(|(symbol, _)| symbol.clone()) .map(|(symbol, _)| symbol.clone())
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let qualified = quote_condition_results( let qualified = if ordinary_enabled { quote_condition_results(
&rule.sell_condition, &rule.sell_condition,
rule.sell_condition_scope, rule.sell_condition_scope,
&held, &held,
&quote_map, &quote_map,
)?; )? } else { BTreeMap::new() };
for symbol in held { for symbol in held {
if global_stop_hits.contains(&symbol) let permitted = ordinary_enabled && qualified.get(&symbol) == Some(&true)
|| constraints
.automatic_permissions
.get(&symbol)
.is_some_and(|permission| permission.max_holding_exit)
{
continue;
}
let permitted = qualified.get(&symbol) == Some(&true)
&& (!rule.secondary_sell_condition && (!rule.secondary_sell_condition
|| constraints.position_target_bps.contains_key(&symbol)); || constraints.position_target_bps.contains_key(&symbol));
if !permitted { if !permitted {
sell_condition_denials.insert(symbol.clone());
effective_position_targets.remove(&symbol); effective_position_targets.remove(&symbol);
if let Some(target) = constraints.independent_position_target_bps.get(&symbol) {
effective_position_targets.insert(symbol.clone(), *target);
} else {
sell_condition_denials.insert(symbol.clone());
}
} else if !rule.secondary_sell_condition { } else if !rule.secondary_sell_condition {
quote_sell_exits.insert(symbol.clone()); quote_sell_exits.insert(symbol.clone());
effective_position_targets.insert(symbol, 0); effective_position_targets.insert(symbol, 0);
@@ -944,6 +1015,7 @@ pub fn build_stock_pool_target_plan_with_fee_model(
let normalized_same_day_sold = let normalized_same_day_sold =
normalize_symbol_set(&same_day_sold_symbols.iter().cloned().collect::<Vec<_>>())?; normalize_symbol_set(&same_day_sold_symbols.iter().cloned().collect::<Vec<_>>())?;
let mut rebuy_exclusions = stop_take_exits.clone(); let mut rebuy_exclusions = stop_take_exits.clone();
rebuy_exclusions.extend(effective_position_targets.keys().cloned());
rebuy_exclusions.extend( rebuy_exclusions.extend(
normalized_same_day_sold normalized_same_day_sold
.iter() .iter()
@@ -1031,7 +1103,12 @@ pub fn build_stock_pool_target_plan_with_fee_model(
} }
let mut planning_symbols = active_symbols; let mut planning_symbols = active_symbols;
for symbol in &original_final_symbols { for symbol in &original_final_symbols {
if rebuy_exclusions.contains(symbol) && !planning_symbols.contains(symbol) { // An explicit quote/expiry position action owns its single target row.
// Keep it excluded from entry sizing without adding a second stop row.
if rebuy_exclusions.contains(symbol)
&& !factor_position_target_bps.contains_key(symbol)
&& !planning_symbols.contains(symbol)
{
planning_symbols.push(symbol.clone()); planning_symbols.push(symbol.clone());
} }
} }
@@ -1323,24 +1400,11 @@ pub fn build_stock_pool_target_plan_with_fee_model(
} }
for (symbol, target_bps) in factor_position_target_bps { for (symbol, target_bps) in factor_position_target_bps {
if *target_bps >= 10_000 {
return Err(format!(
"factor position target for {symbol} must be below 10000 bps"
));
}
if !member_map.contains_key(symbol) && !current.contains_key(symbol) { if !member_map.contains_key(symbol) && !current.contains_key(symbol) {
return Err(format!( return Err(format!(
"factor position-action symbol {symbol} is outside candidates and managed holdings" "factor position-action symbol {symbol} is outside candidates and managed holdings"
)); ));
} }
if selection.final_symbols.contains(symbol)
&& !maximum_holding_exits.contains(symbol)
&& !quote_sell_exits.contains(symbol)
{
return Err(format!(
"factor position-action symbol {symbol} cannot remain in final selection"
));
}
let current_quantity = current let current_quantity = current
.get(symbol) .get(symbol)
.map(|value| value.0) .map(|value| value.0)
@@ -1362,10 +1426,11 @@ pub fn build_stock_pool_target_plan_with_fee_model(
Decimal::ZERO Decimal::ZERO
} else { } else {
floor_step( floor_step(
current_quantity * Decimal::from(*target_bps) / Decimal::from(10_000), constraints.position_action_bases.get(symbol).copied().unwrap_or(current_quantity)
* Decimal::from(*target_bps) / Decimal::from(10_000),
step, step,
) )
}; }.min(current_quantity);
let desired_reduction = (current_quantity - requested_target).max(Decimal::ZERO); let desired_reduction = (current_quantity - requested_target).max(Decimal::ZERO);
let executable = if *target_bps == 0 { let executable = if *target_bps == 0 {
closable_quantity.min(current_quantity).max(Decimal::ZERO) closable_quantity.min(current_quantity).max(Decimal::ZERO)
@@ -1379,13 +1444,33 @@ pub fn build_stock_pool_target_plan_with_fee_model(
if current_quantity == Decimal::ZERO { if current_quantity == Decimal::ZERO {
( (
"FACTOR_EXIT_ALREADY_SATISFIED", "FACTOR_EXIT_ALREADY_SATISFIED",
"生产因子持仓动作命中,当前无持仓", "持仓退出规则命中,当前无持仓",
Decimal::ZERO, Decimal::ZERO,
Decimal::ZERO, Decimal::ZERO,
None, None,
None, None,
None, None,
) )
} else if desired_reduction == Decimal::ZERO {
(
"FACTOR_EXIT_ALREADY_SATISFIED",
"本次信号的持仓退出目标已达到,不重复减仓",
Decimal::ZERO,
current_quantity,
None,
None,
None,
)
} else if executable == Decimal::ZERO && closable_quantity >= desired_reduction {
(
"BELOW_MINIMUM_TRADE_UNIT_ALREADY_SATISFIED",
"目标持仓差额不足最小交易单位,无需重复委托",
Decimal::ZERO,
current_quantity,
None,
None,
None,
)
} else if executable == Decimal::ZERO { } else if executable == Decimal::ZERO {
( (
"DEFERRED_T_PLUS_ONE", "DEFERRED_T_PLUS_ONE",
@@ -1414,6 +1499,10 @@ pub fn build_stock_pool_target_plan_with_fee_model(
"达到最长持有期,按配置退出" "达到最长持有期,按配置退出"
} else if quote_sell_exits.contains(symbol) { } else if quote_sell_exits.contains(symbol) {
"卖出行情条件命中" "卖出行情条件命中"
} else if stop_take_exits.contains(symbol) {
"止损/止盈触发,覆盖较弱的减仓目标"
} else if constraints.independent_position_target_bps.get(symbol) == Some(target_bps) {
"独立风险退出条件命中"
} else if *target_bps == 0 { } else if *target_bps == 0 {
"生产因子退出条件命中" "生产因子退出条件命中"
} else { } else {
@@ -1688,6 +1777,14 @@ pub fn build_stock_pool_target_plan_with_fee_model(
} }
} }
} }
// Verify disjoint planning ownership before an index cap can address rows
// by symbol. Never deduplicate emitted intentions or count proceeds twice.
let mut owners = BTreeSet::new();
for row in &rows {
if !owners.insert(row.symbol.as_str()) {
return Err(format!("stock_pool_target_owner_conflict:{}", row.symbol));
}
}
if market_timing.is_some() { if market_timing.is_some() {
let caps = index_cap::remaining_index_targets( let caps = index_cap::remaining_index_targets(
&current, &current,
@@ -1890,7 +1987,15 @@ pub fn build_stock_pool_target_plan_with_fee_model(
.into_iter() .into_iter()
.sum(); .sum();
let estimated_cash_after = available_cash - estimated_buy_amount + estimated_sell_amount; let estimated_cash_after = available_cash - estimated_buy_amount + estimated_sell_amount;
let position_action_bases = rows.iter()
.filter(|row| effective_position_targets.get(&row.symbol).is_some_and(|bps| *bps > 0)
&& row.current_quantity > Decimal::ZERO
&& row.status != "AUTOMATIC_TRADE_PROTECTED"
&& !constraints.frozen_positions.contains_key(&row.symbol))
.map(|row| (row.symbol.clone(), constraints.position_action_bases.get(&row.symbol).copied().unwrap_or(row.current_quantity)))
.collect();
Ok(StockPoolPlan { Ok(StockPoolPlan {
position_action_bases,
market_timing, market_timing,
rows, rows,
budget, budget,
@@ -2167,6 +2272,15 @@ pub fn normalize_stock_pool_execution_rule(
raw: Option<&Value>, raw: Option<&Value>,
secondary_buy_condition: bool, secondary_buy_condition: bool,
secondary_sell_condition: bool, secondary_sell_condition: bool,
) -> Result<StockPoolExecutionRule, String> {
normalize_stock_pool_execution_rule_with_exit_roles(raw, secondary_buy_condition, secondary_sell_condition, false)
}
pub fn normalize_stock_pool_execution_rule_with_exit_roles(
raw: Option<&Value>,
secondary_buy_condition: bool,
secondary_sell_condition: bool,
independent_sell_condition: bool,
) -> Result<StockPoolExecutionRule, String> { ) -> Result<StockPoolExecutionRule, String> {
let mut rule = match raw { let mut rule = match raw {
None | Some(Value::Null) => StockPoolExecutionRule::default(), None | Some(Value::Null) => StockPoolExecutionRule::default(),
@@ -2174,6 +2288,7 @@ pub fn normalize_stock_pool_execution_rule(
.map_err(|err| format!("stock pool execution_rule is invalid: {err}"))?, .map_err(|err| format!("stock pool execution_rule is invalid: {err}"))?,
}; };
rule.secondary_sell_condition = secondary_sell_condition; rule.secondary_sell_condition = secondary_sell_condition;
rule.independent_sell_condition = independent_sell_condition;
rule.automatic_trade_protection.validate()?; rule.automatic_trade_protection.validate()?;
if rule.schema_version != STOCK_POOL_SCHEMA_VERSION { if rule.schema_version != STOCK_POOL_SCHEMA_VERSION {
return Err(format!( return Err(format!(
@@ -2260,7 +2375,7 @@ pub fn normalize_stock_pool_execution_rule(
return Err("stock pool buy_condition is not supported".to_string()); return Err("stock pool buy_condition is not supported".to_string());
} }
if rule.sell_trigger_mode == POOL_SELL_CONDITION { if rule.sell_trigger_mode == POOL_SELL_CONDITION {
if (rule.sell_condition.trim().is_empty() && !secondary_sell_condition) if (rule.sell_condition.trim().is_empty() && !secondary_sell_condition && !independent_sell_condition)
|| (!rule.sell_condition.trim().is_empty() || (!rule.sell_condition.trim().is_empty()
&& parse_stock_pool_condition(&rule.sell_condition).is_none()) && parse_stock_pool_condition(&rule.sell_condition).is_none())
{ {
@@ -509,6 +509,16 @@ fn condition_plan(
quotes: &[MarketSnapshot], quotes: &[MarketSnapshot],
constraints: &StockPoolDecisionConstraints, constraints: &StockPoolDecisionConstraints,
) -> StockPoolPlan { ) -> StockPoolPlan {
condition_plan_result(selection, rule, positions, quotes, constraints).unwrap()
}
fn condition_plan_result(
selection: &StockPoolSelection,
rule: &StockPoolExecutionRule,
positions: &[Position],
quotes: &[MarketSnapshot],
constraints: &StockPoolDecisionConstraints,
) -> Result<StockPoolPlan, String> {
let held_value = positions let held_value = positions
.iter() .iter()
.map(|position| { .map(|position| {
@@ -541,7 +551,6 @@ fn condition_plan(
Decimal::ZERO, Decimal::ZERO,
Decimal::ZERO, Decimal::ZERO,
) )
.unwrap()
} }
#[test] #[test]
@@ -704,6 +713,221 @@ fn native_sell_and_quote_conditions_are_and_but_stop_and_protection_remain_indep
); );
} }
#[test]
fn independent_stop_does_not_require_unused_ordinary_sell_quote_facts() {
let mut market = quotes(1);
market[0].last_price = 9.into();
market[0].volume = None;
let rule = normalize_stock_pool_execution_rule(
Some(&json!({"sell_trigger_mode":"condition","sell_condition":"volume>1000"})),
false,
false,
).unwrap();
let constraints = StockPoolDecisionConstraints {
default_stop_loss: Some(Decimal::new(5, 2)),
..Default::default()
};
let plan = condition_plan(&selection(1, 1), &rule, &[position(1)], &market, &constraints);
let exit = plan.rows.iter().find(|row| row.symbol == symbol(1)).unwrap();
assert_eq!(exit.side, Some(OrderSide::Sell), "{plan:?}");
assert_eq!(exit.target_quantity, Decimal::ZERO, "{plan:?}");
}
#[test]
fn ordinary_sell_scope_excludes_independent_exits_and_protected_positions() {
for scope in [QuoteConditionScope::PerSymbol, QuoteConditionScope::AllTargets, QuoteConditionScope::AnyTarget] {
for cause in ["stop_loss", "take_profit", "maximum_holding_exit", "automatic_trade_locked", "buy_fill_protection"] {
let mut market = quotes(2);
market[0].volume = None;
let mut constraints = StockPoolDecisionConstraints::default();
match cause {
"stop_loss" => { market[0].last_price = 9.into(); constraints.default_stop_loss = Some(Decimal::new(5, 2)); },
"take_profit" => { market[0].last_price = 12.into(); constraints.default_take_profit = Some(Decimal::new(10, 2)); },
"maximum_holding_exit" => { constraints.automatic_permissions.insert(symbol(1), crate::holding_policy::AutomaticTradePermission { max_holding_exit: true, ..Default::default() }); },
_ => { constraints.automatic_permissions.insert(symbol(1), crate::holding_policy::AutomaticTradePermission { sell_denial: Some(cause), buy_denial: Some(cause), ..Default::default() }); },
}
let rule = normalize_stock_pool_execution_rule(
Some(&json!({"sell_trigger_mode":"condition","sell_condition":"volume>1000","sell_condition_scope":scope})),
false, false,
).unwrap();
let plan = condition_plan(&selection(2, 2), &rule, &[position(1), position(2)], &market, &constraints);
let protected = plan.rows.iter().find(|row| row.symbol == symbol(1)).unwrap();
let normal = plan.rows.iter().find(|row| row.symbol == symbol(2)).unwrap();
assert_eq!(normal.side, Some(OrderSide::Sell), "{scope:?}/{cause}: {plan:?}");
if cause == "automatic_trade_locked" || cause == "buy_fill_protection" {
assert_eq!(protected.side, None, "{scope:?}/{cause}: {plan:?}");
assert_eq!(protected.target_quantity, 1000.into(), "{scope:?}/{cause}: {plan:?}");
assert_eq!(protected.status, "AUTOMATIC_TRADE_PROTECTED", "{scope:?}/{cause}: {plan:?}");
} else {
assert_eq!(protected.side, Some(OrderSide::Sell), "{scope:?}/{cause}: {plan:?}");
assert_eq!(protected.target_quantity, Decimal::ZERO, "{scope:?}/{cause}: {plan:?}");
}
}
}
}
#[test]
fn independent_exit_quote_priority_does_not_bypass_t_plus_one_or_price_validation() {
let rule = normalize_stock_pool_execution_rule(Some(&json!({"sell_trigger_mode":"condition","sell_condition":"volume>1000"})), false, false).unwrap();
let mut market = quotes(1);
market[0].last_price = 9.into(); market[0].volume = None;
let constraints = StockPoolDecisionConstraints { default_stop_loss: Some(Decimal::new(5,2)), ..Default::default() };
for closable in [0, 400, 1000] {
let mut held = position(1); held.closable_quantity = Decimal::from(closable);
let plan = condition_plan(&selection(1,1), &rule, &[held], &market, &constraints);
let row = plan.rows.iter().find(|row|row.symbol==symbol(1)).unwrap();
assert_eq!(row.delta_quantity, -Decimal::from(closable), "{plan:?}");
assert_eq!(row.target_quantity, Decimal::from(1000-closable), "{plan:?}");
}
market[0].last_price = Decimal::ZERO;
assert!(condition_plan_result(&selection(1,1), &rule, &[position(1)], &market, &constraints).unwrap_err().contains("execution quote is invalid"));
}
#[test]
fn full_stop_overrides_a_simultaneous_factor_reduction_without_a_second_target() {
let mut selected = selection(2, 1);
selected.final_symbols = vec![symbol(2)];
let mut market = quotes(2);
market[0].last_price = 9.into();
let constraints = StockPoolDecisionConstraints {
default_stop_loss: Some(Decimal::new(5, 2)),
position_target_bps: BTreeMap::from([(symbol(1), 5000)]),
..Default::default()
};
let plan = condition_plan(&selected, &StockPoolExecutionRule::default(), &[position(1)], &market, &constraints);
let rows = plan.rows.iter().filter(|row|row.symbol==symbol(1)).collect::<Vec<_>>();
assert_eq!(rows.len(),1,"{plan:?}");
assert_eq!(rows[0].target_quantity,Decimal::ZERO,"a full stop must not be weakened by a 50% reduction: {plan:?}");
assert_eq!(rows[0].delta_quantity,Decimal::from(-1000),"{plan:?}");
}
#[test]
fn stop_reduction_merge_matrix_preserves_protection_t_plus_one_and_invalid_config_errors() {
for take_profit in [false,true] {
for reduction in [0,2500,5000,9999] {
for closable in [0,400,1000] {
for locked in [false,true] {
let mut selected=selection(2,1);selected.final_symbols=vec![symbol(2)];
let mut market=quotes(2);market[0].last_price=if take_profit {12.into()} else {9.into()};market[0].volume=None;
let mut held=position(1);held.closable_quantity=Decimal::from(closable);
let mut constraints=StockPoolDecisionConstraints {position_target_bps:BTreeMap::from([(symbol(1),reduction)]),..Default::default()};
if take_profit {constraints.default_take_profit=Some(Decimal::new(10,2))} else {constraints.default_stop_loss=Some(Decimal::new(5,2))}
if locked {constraints.automatic_permissions.insert(symbol(1),crate::holding_policy::AutomaticTradePermission {sell_denial:Some("automatic_trade_locked"),buy_denial:Some("automatic_trade_locked"),..Default::default()});}
let rule=normalize_stock_pool_execution_rule(Some(&json!({"sell_trigger_mode":"condition","sell_condition":"volume>1000"})),false,true).unwrap();
let plan=condition_plan(&selected,&rule,&[held],&market,&constraints);
let rows=plan.rows.iter().filter(|row|row.symbol==symbol(1)).collect::<Vec<_>>();
assert_eq!(rows.len(),1,"{plan:?}");
let sold=if locked {0} else {closable};
assert_eq!(rows[0].delta_quantity,-Decimal::from(sold),"{plan:?}");
assert_eq!(rows[0].target_quantity,Decimal::from(1000-sold),"{plan:?}");
assert_eq!(plan.estimated_sell_amount,Decimal::from(sold)*market[0].last_price,"{plan:?}");
if locked {assert_eq!(rows[0].status,"AUTOMATIC_TRADE_PROTECTED","{plan:?}")}
}
}
}
}
let mut invalid=StockPoolDecisionConstraints {default_stop_loss:Some(Decimal::new(5,2)),position_target_bps:BTreeMap::from([(symbol(1),10000)]),..Default::default()};
invalid.automatic_permissions.insert(symbol(1),crate::holding_policy::AutomaticTradePermission {max_holding_exit:true,..Default::default()});
assert!(condition_plan_result(&selection(1,1),&StockPoolExecutionRule::default(),&[position(1)],&quotes(1),&invalid).unwrap_err().contains("must be below 10000"));
}
#[test]
fn ordinary_sell_keeps_required_quote_failures_and_zero_stop_is_not_an_exit() {
let rule = normalize_stock_pool_execution_rule(Some(&json!({"sell_trigger_mode":"condition","sell_condition":"volume>1000"})), false, false).unwrap();
let mut market = quotes(1); market[0].last_price = 9.into(); market[0].volume = None;
for stop in [None, Some(Decimal::ZERO)] {
let constraints = StockPoolDecisionConstraints { default_stop_loss: stop, ..Default::default() };
assert_eq!(condition_plan_result(&selection(1,1), &rule, &[position(1)], &market, &constraints).unwrap_err(), "condition requires volume");
}
}
#[test]
fn quote_field_operator_side_and_scope_matrix_matches_the_configured_predicate() {
let market = quotes(2);
for (field, threshold) in [("price", "10"), ("last", "10"), ("change_pct", "0"), ("volume", "1000000"), ("amount", "10000000"), ("bid1", "10"), ("ask1", "10")] {
for (operator, matched) in [(">",false), (">=",true), ("<",false), ("<=",true), ("==",true), ("!=",false)] {
for scope in [QuoteConditionScope::PerSymbol, QuoteConditionScope::AllTargets, QuoteConditionScope::AnyTarget] {
for side in [OrderSide::Buy, OrderSide::Sell] {
let condition = format!("{field}{operator}{threshold}");
let config = if side == OrderSide::Buy {
json!({"trigger_mode":"condition","buy_condition":condition,"buy_condition_scope":scope})
} else {
json!({"sell_trigger_mode":"condition","sell_condition":condition,"sell_condition_scope":scope})
};
let rule = normalize_stock_pool_execution_rule(Some(&config), false, false).unwrap();
let held = if side == OrderSide::Sell { vec![position(1),position(2)] } else { vec![] };
let plan = condition_plan(&selection(2,2), &rule, &held, &market, &StockPoolDecisionConstraints::default());
assert_eq!(plan.rows.iter().map(|row| &row.symbol).collect::<BTreeSet<_>>().len(), plan.rows.len(), "duplicate target ownership: {plan:?}");
let orders = plan.rows.iter().filter(|row|row.side==Some(side)).count();
assert_eq!(orders, if matched {2} else {0}, "{side:?}/{scope:?}/{condition}: {plan:?}");
if side == OrderSide::Sell && matched {
assert_eq!(plan.estimated_sell_amount, Decimal::from(20000), "{plan:?}");
assert_eq!(plan.estimated_cash_after, Decimal::from(40000), "{plan:?}");
}
}
}
}
}
}
#[test]
fn typed_exit_roles_merge_only_satisfied_ordinary_actions_with_independent_risk() {
for risk in [None,Some(0),Some(5000)] {
for ordinary in [None,Some(0),Some(7500)] {
for quote in ["","price<9","price>9"] {
for locked in [false,true] {
for closable in [0,400,1000] {
let rule=normalize_stock_pool_execution_rule_with_exit_roles(Some(&json!({"sell_trigger_mode":"condition","sell_condition":quote})),false,true,true).unwrap();
let mut constraints=StockPoolDecisionConstraints {portfolio_policy:Some(StockPoolPortfolioPolicy{schema_version:1,membership:MembershipPolicy::RetainHoldings,rebalance_weights:false}),..Default::default()};
if let Some(target)=ordinary {constraints.position_target_bps.insert(symbol(1),target);}
if let Some(target)=risk {constraints.independent_position_target_bps.insert(symbol(1),target);}
if locked {constraints.automatic_permissions.insert(symbol(1),crate::holding_policy::AutomaticTradePermission{sell_denial:Some("automatic_trade_locked"),buy_denial:Some("automatic_trade_locked"),..Default::default()});}
let mut held=position(1);held.closable_quantity=closable.into();
let plan=condition_plan(&selection(1,1),&rule,&[held],&quotes(1),&constraints);
assert_eq!(plan.rows.len(),1,"{risk:?}/{ordinary:?}/{quote}: {plan:?}");
let ordinary=if quote=="price<9" {None} else {ordinary};
let target_bps=risk.into_iter().chain(ordinary).min().unwrap_or(10000);
let desired=if target_bps==0 {0} else {(1000*target_bps/10000)/100*100};
let sold=if locked {0} else {(1000-desired).min(closable)};
assert_eq!(plan.rows[0].delta_quantity,-Decimal::from(sold),"{risk:?}/{ordinary:?}/{quote}: {plan:?}");
}
}
}
}
}
}
#[test]
fn risk_only_configuration_never_turns_into_an_unconditional_ordinary_exit() {
let rule=normalize_stock_pool_execution_rule_with_exit_roles(Some(&json!({"sell_trigger_mode":"condition"})),false,false,true).unwrap();
let mut constraints=StockPoolDecisionConstraints::default();
let hold=condition_plan(&selection(1,1),&rule,&[position(1)],&quotes(1),&constraints);
assert_ne!(hold.rows[0].side,Some(OrderSide::Sell),"a risk-only configuration must not manufacture an exit: {hold:?}");
constraints.independent_position_target_bps.insert(symbol(1),5000);
let exit=condition_plan(&selection(1,1),&rule,&[position(1)],&quotes(1),&constraints);
assert_eq!(exit.rows[0].delta_quantity,Decimal::from(-500),"{exit:?}");
let unheld=condition_plan(&selection(1,1),&rule,&[],&quotes(1),&constraints);
assert_eq!(unheld.rows[0].side,Some(OrderSide::Buy),"an exit-only rule must not secretly become a selection/buy filter: {unheld:?}");
}
#[test]
fn quote_only_exit_still_works_when_independent_risk_rules_are_configured() {
let rule=normalize_stock_pool_execution_rule_with_exit_roles(Some(&json!({"sell_trigger_mode":"condition","sell_condition":"price>9"})),false,false,true).unwrap();
let plan=condition_plan(&selection(1,1),&rule,&[position(1)],&quotes(1),&StockPoolDecisionConstraints::default());
assert_eq!(plan.rows.len(),1);assert_eq!(plan.rows[0].delta_quantity,Decimal::from(-1000),"{plan:?}");
}
#[test]
fn independent_full_exit_has_no_ordinary_quote_dependency_but_partial_risk_does_not_fake_missing_facts() {
let rule=normalize_stock_pool_execution_rule_with_exit_roles(Some(&json!({"sell_trigger_mode":"condition","sell_condition":"volume>100"})),false,true,true).unwrap();
let mut market=quotes(1);market[0].volume=None;
let mut constraints=StockPoolDecisionConstraints {position_target_bps:BTreeMap::from([(symbol(1),0)]),independent_position_target_bps:BTreeMap::from([(symbol(1),0)]),..Default::default()};
let complete=condition_plan(&selection(1,1),&rule,&[position(1)],&market,&constraints);
assert_eq!(complete.rows[0].delta_quantity,Decimal::from(-1000));
constraints.independent_position_target_bps.insert(symbol(1),5000);
assert!(condition_plan_result(&selection(1,1),&rule,&[position(1)],&market,&constraints).unwrap_err().contains("requires volume"));
}
#[test] #[test]
fn partial_sell_cooldown_restricts_increases_without_clearing_the_remainder() { fn partial_sell_cooldown_restricts_increases_without_clearing_the_remainder() {
let mut constraints = StockPoolDecisionConstraints::default(); let mut constraints = StockPoolDecisionConstraints::default();
@@ -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");
}
}
+86 -2
View File
@@ -24,6 +24,14 @@ pub struct StockPoolEntryProgress {
pub completion_quantity: Option<Decimal>, pub completion_quantity: Option<Decimal>,
} }
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct StockPoolPositionActionBasis {
pub generation: String,
pub first_execution_date: NaiveDate,
pub quantity: Decimal,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct StockPoolExecutionState { pub struct StockPoolExecutionState {
@@ -34,6 +42,10 @@ pub struct StockPoolExecutionState {
pub last_target_weights: BTreeMap<String, i32>, pub last_target_weights: BTreeMap<String, i32>,
/// First signal excluding an actually held member; not an acquisition date. /// First signal excluding an actually held member; not an acquisition date.
pub removed_since: BTreeMap<String, NaiveDate>, pub removed_since: BTreeMap<String, NaiveDate>,
/// Signal progress, not a fill or holding-period fact. Kept across retries
/// and later execution sessions until a new generation supersedes it.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub position_action_bases: BTreeMap<String, StockPoolPositionActionBasis>,
} }
pub struct StockPoolGoalObservation<'a> { pub struct StockPoolGoalObservation<'a> {
@@ -53,6 +65,7 @@ impl Default for StockPoolExecutionState {
entries: BTreeMap::new(), entries: BTreeMap::new(),
last_target_weights: BTreeMap::new(), last_target_weights: BTreeMap::new(),
removed_since: BTreeMap::new(), removed_since: BTreeMap::new(),
position_action_bases: BTreeMap::new(),
} }
} }
} }
@@ -62,6 +75,7 @@ impl StockPoolExecutionState {
if self.schema_version != 1 if self.schema_version != 1
|| self.entries.len() > 10000 || self.entries.len() > 10000
|| self.removed_since.len() > 10000 || self.removed_since.len() > 10000
|| self.position_action_bases.len() > 10000
{ {
return Err("stock_pool_execution_state_invalid_schema_or_size".into()); return Err("stock_pool_execution_state_invalid_schema_or_size".into());
} }
@@ -70,6 +84,7 @@ impl StockPoolExecutionState {
.keys() .keys()
.chain(self.removed_since.keys()) .chain(self.removed_since.keys())
.chain(self.last_target_weights.keys()) .chain(self.last_target_weights.keys())
.chain(self.position_action_bases.keys())
{ {
if normalize_stock_symbol(symbol).as_ref() != Some(symbol) { if normalize_stock_symbol(symbol).as_ref() != Some(symbol) {
return Err("stock_pool_execution_state_invalid_symbol".into()); return Err("stock_pool_execution_state_invalid_symbol".into());
@@ -97,6 +112,12 @@ impl StockPoolExecutionState {
{ {
return Err("stock_pool_execution_state_invalid_goal_or_clock".into()); return Err("stock_pool_execution_state_invalid_goal_or_clock".into());
} }
if self.position_action_bases.values().any(|basis| {
basis.generation.trim().is_empty() || basis.quantity <= Decimal::ZERO
|| self.last_execution_date.is_none_or(|date| basis.first_execution_date > date)
}) {
return Err("stock_pool_execution_state_invalid_action_basis".into());
}
Ok(()) Ok(())
} }
@@ -186,7 +207,7 @@ impl StockPoolExecutionState {
self.record_targets( self.record_targets(
decision_date, decision_date,
generation, generation,
plan.rows.iter().map(|row| StockPoolGoalObservation { plan.rows.iter().filter(|row| !plan.position_action_bases.contains_key(&row.symbol)).map(|row| StockPoolGoalObservation {
symbol: &row.symbol, symbol: &row.symbol,
target_weight_bps: row.target_weight_bps, target_weight_bps: row.target_weight_bps,
target_value: row.target_value, target_value: row.target_value,
@@ -194,7 +215,67 @@ impl StockPoolExecutionState {
target_quantity: row.target_quantity, target_quantity: row.target_quantity,
status: &row.status, status: &row.status,
}), }),
) )?.record_position_action_bases(generation, &plan.position_action_bases)
}
pub fn position_action_bases_for(&self, generation: &str) -> BTreeMap<String, Decimal> {
self.position_action_bases.iter()
.filter(|(_, basis)| basis.generation == generation)
.map(|(symbol, basis)| (symbol.clone(), basis.quantity))
.collect()
}
/// A verified split changes the share unit, not the intended reduction or
/// entry completion. Never infer a split from a changed holding quantity.
pub fn adjust_for_split(&self, symbol: &str, ratio: Decimal) -> Result<Self, String> {
self.validate()?;
if ratio <= Decimal::ZERO || normalize_stock_symbol(symbol).as_deref() != Some(symbol) {
return Err("stock_pool_execution_state_split_invalid".into());
}
let scale = |quantity: Decimal| quantity.checked_mul(ratio)
.map(|value| value.round_dp_with_strategy(0, rust_decimal::RoundingStrategy::MidpointAwayFromZero))
.ok_or_else(|| "stock_pool_execution_state_split_overflow".to_string());
let mut next = self.clone();
if let Some(entry) = next.entries.get_mut(symbol) {
if let Some(quantity) = entry.completion_quantity {
let quantity = scale(quantity)?;
entry.completion_quantity = (quantity > Decimal::ZERO).then_some(quantity);
}
}
if let Some(basis) = next.position_action_bases.get_mut(symbol) {
basis.quantity = scale(basis.quantity)?;
if basis.quantity == Decimal::ZERO { next.position_action_bases.remove(symbol); }
}
next.validate()?;
Ok(next)
}
pub fn record_position_action_bases(
&self,
generation: &str,
quantities: &BTreeMap<String, Decimal>,
) -> Result<Self, String> {
self.validate()?;
if generation.trim().is_empty() {
return Err("stock_pool_execution_state_action_generation_missing".into());
}
let first_execution_date = self.last_execution_date
.ok_or("stock_pool_execution_state_action_clock_missing")?;
let mut next = self.clone();
next.position_action_bases.retain(|_, basis| basis.generation == generation);
for (symbol, quantity) in quantities {
if let Some(basis) = next.position_action_bases.get(symbol) {
if basis.quantity != *quantity {
return Err(format!("stock_pool_execution_state_action_basis_changed:{symbol}"));
}
} else {
next.position_action_bases.insert(symbol.clone(), StockPoolPositionActionBasis {
generation: generation.into(), first_execution_date, quantity: *quantity,
});
}
}
next.validate()?;
Ok(next)
} }
pub fn record_targets<'a>( pub fn record_targets<'a>(
@@ -213,6 +294,9 @@ impl StockPoolExecutionState {
} }
let mut next = self.clone(); let mut next = self.clone();
for row in rows { for row in rows {
if row.status == "AUTOMATIC_TRADE_PROTECTED" {
continue;
}
if row.target_weight_bps > 0 { if row.target_weight_bps > 0 {
next.last_target_weights next.last_target_weights
.insert(row.symbol.into(), row.target_weight_bps); .insert(row.symbol.into(), row.target_weight_bps);
+4 -4
View File
@@ -3184,7 +3184,7 @@ mod tests {
.enumerate() .enumerate()
.map(|(index, date)| DailyMarketSnapshot { .map(|(index, date)| DailyMarketSnapshot {
date: *date, date: *date,
symbol: symbol.to_string(), symbol: symbol.into(),
timestamp: Some(format!("{date} 10:18:00")), timestamp: Some(format!("{date} 10:18:00")),
day_open: 10.0 + index as f64, day_open: 10.0 + index as f64,
open: 10.0 + index as f64, open: 10.0 + index as f64,
@@ -3210,21 +3210,21 @@ mod tests {
.iter() .iter()
.map(|date| DailyFactorSnapshot { .map(|date| DailyFactorSnapshot {
date: *date, date: *date,
symbol: symbol.to_string(), symbol: symbol.into(),
market_cap_bn: 10.0, market_cap_bn: 10.0,
free_float_cap_bn: 9.0, free_float_cap_bn: 9.0,
pe_ttm: 12.0, pe_ttm: 12.0,
turnover_ratio: Some(1.0), turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0), effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None, adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: Default::default(),
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let candidate_rows = dates let candidate_rows = dates
.iter() .iter()
.map(|date| CandidateEligibility { .map(|date| CandidateEligibility {
date: *date, date: *date,
symbol: symbol.to_string(), symbol: symbol.into(),
is_st: false, is_st: false,
is_star_st: false, is_star_st: false,
is_new_listing: false, is_new_listing: false,
+4 -4
View File
@@ -83,7 +83,7 @@ impl SelectionContext<'_> {
for factor in self.data.factor_snapshot_rows_on(self.decision_date) { for factor in self.data.factor_snapshot_rows_on(self.decision_date) {
if self if self
.dynamic_universe .dynamic_universe
.is_some_and(|symbols| !symbols.is_empty() && !symbols.contains(&factor.symbol)) .is_some_and(|symbols| !symbols.is_empty() && !symbols.contains(factor.symbol.as_str()))
{ {
continue; continue;
} }
@@ -307,7 +307,7 @@ mod tests {
fn market(symbol: &str, price: f64) -> DailyMarketSnapshot { fn market(symbol: &str, price: f64) -> DailyMarketSnapshot {
DailyMarketSnapshot { DailyMarketSnapshot {
date: d(), date: d(),
symbol: symbol.to_string(), symbol: symbol.into(),
timestamp: Some("2025-01-02 10:00:00".to_string()), timestamp: Some("2025-01-02 10:00:00".to_string()),
day_open: price, day_open: price,
open: price, open: price,
@@ -333,7 +333,7 @@ mod tests {
fn factor(symbol: &str, market_cap_bn: f64) -> DailyFactorSnapshot { fn factor(symbol: &str, market_cap_bn: f64) -> DailyFactorSnapshot {
DailyFactorSnapshot { DailyFactorSnapshot {
date: d(), date: d(),
symbol: symbol.to_string(), symbol: symbol.into(),
market_cap_bn, market_cap_bn,
free_float_cap_bn: market_cap_bn, free_float_cap_bn: market_cap_bn,
pe_ttm: 10.0, pe_ttm: 10.0,
@@ -347,7 +347,7 @@ mod tests {
fn candidate(symbol: &str, is_st: bool, is_kcb: bool) -> CandidateEligibility { fn candidate(symbol: &str, is_st: bool, is_kcb: bool) -> CandidateEligibility {
CandidateEligibility { CandidateEligibility {
date: d(), date: d(),
symbol: symbol.to_string(), symbol: symbol.into(),
is_st, is_st,
is_star_st: false, is_star_st: false,
is_new_listing: false, is_new_listing: false,
@@ -107,6 +107,7 @@ fn action(quantity: &str, when: &str) -> PlatformTradeAction {
} }
fn run(policy: AutomaticTradeProtection) -> fidc_core::BacktestResult { fn run(policy: AutomaticTradeProtection) -> fidc_core::BacktestResult {
let mut config = PlatformExprStrategyConfig::generic(); let mut config = PlatformExprStrategyConfig::generic();
config.volume_capacity_mode = fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit;
config.signal_symbol = "000001.SZ".into(); config.signal_symbol = "000001.SZ".into();
config.benchmark_symbol = "000300.SH".into(); config.benchmark_symbol = "000300.SH".into();
config.rotation_enabled = false; config.rotation_enabled = false;
@@ -119,7 +120,7 @@ fn run(policy: AutomaticTradeProtection) -> fidc_core::BacktestResult {
action("-100", "decision_date >= \"2026-09-14\""), action("-100", "decision_date >= \"2026-09-14\""),
]; ];
config.matching_type = MatchingType::CurrentBarClose; config.matching_type = MatchingType::CurrentBarClose;
let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks) let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit)
.with_matching_type(MatchingType::CurrentBarClose); .with_matching_type(MatchingType::CurrentBarClose);
BacktestEngine::new( BacktestEngine::new(
data(), data(),
@@ -276,6 +277,7 @@ fn locked_holding_keeps_its_slot_even_when_cash_can_buy_the_next_candidate() {
) )
.unwrap(); .unwrap();
let mut config = PlatformExprStrategyConfig::generic(); let mut config = PlatformExprStrategyConfig::generic();
config.volume_capacity_mode = fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit;
config.signal_symbol = "000001.SZ".into(); config.signal_symbol = "000001.SZ".into();
config.benchmark_symbol = "000300.SH".into(); config.benchmark_symbol = "000300.SH".into();
config.strategy_name = "protection_test".into(); config.strategy_name = "protection_test".into();
@@ -294,7 +296,7 @@ fn locked_holding_keeps_its_slot_even_when_cash_can_buy_the_next_candidate() {
}], }],
..Default::default() ..Default::default()
}; };
let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks) let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit)
.with_matching_type(MatchingType::CurrentBarClose); .with_matching_type(MatchingType::CurrentBarClose);
let result = BacktestEngine::new( let result = BacktestEngine::new(
dataset, dataset,
+3 -3
View File
@@ -15,7 +15,7 @@ fn d(year: i32, month: u32, day: u32) -> NaiveDate {
fn candidate() -> CandidateEligibility { fn candidate() -> CandidateEligibility {
CandidateEligibility { CandidateEligibility {
date: d(2024, 1, 3), date: d(2024, 1, 3),
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
is_st: false, is_st: false,
is_star_st: false, is_star_st: false,
is_new_listing: false, is_new_listing: false,
@@ -31,7 +31,7 @@ fn candidate() -> CandidateEligibility {
fn snapshot(open: f64, upper_limit: f64, lower_limit: f64) -> DailyMarketSnapshot { fn snapshot(open: f64, upper_limit: f64, lower_limit: f64) -> DailyMarketSnapshot {
DailyMarketSnapshot { DailyMarketSnapshot {
date: d(2024, 1, 3), date: d(2024, 1, 3),
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
timestamp: Some("2024-01-03 10:18:00".to_string()), timestamp: Some("2024-01-03 10:18:00".to_string()),
day_open: open, day_open: open,
open, open,
@@ -256,7 +256,7 @@ fn china_rule_hooks_allow_sell_when_last_price_is_above_lower_limit() {
let snapshot = DailyMarketSnapshot { let snapshot = DailyMarketSnapshot {
date: d(2024, 4, 7), date: d(2024, 4, 7),
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
timestamp: Some("2024-04-07 10:18:00".to_string()), timestamp: Some("2024-04-07 10:18:00".to_string()),
day_open: 2.53, day_open: 2.53,
open: 2.53, open: 2.53,
+18 -18
View File
@@ -113,7 +113,7 @@ impl Strategy for BuyAndHoldStrategy {
fn stock_market_snapshot(date: NaiveDate) -> DailyMarketSnapshot { fn stock_market_snapshot(date: NaiveDate) -> DailyMarketSnapshot {
DailyMarketSnapshot { DailyMarketSnapshot {
date, date,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
timestamp: Some(format!("{date} 10:18:00")), timestamp: Some(format!("{date} 10:18:00")),
day_open: 10.0, day_open: 10.0,
open: 10.0, open: 10.0,
@@ -139,21 +139,21 @@ fn stock_market_snapshot(date: NaiveDate) -> DailyMarketSnapshot {
fn stock_factor_snapshot(date: NaiveDate) -> DailyFactorSnapshot { fn stock_factor_snapshot(date: NaiveDate) -> DailyFactorSnapshot {
DailyFactorSnapshot { DailyFactorSnapshot {
date, date,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
market_cap_bn: 20.0, market_cap_bn: 20.0,
free_float_cap_bn: 18.0, free_float_cap_bn: 18.0,
pe_ttm: 10.0, pe_ttm: 10.0,
turnover_ratio: Some(1.0), turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0), effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None, adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: Default::default(),
} }
} }
fn stock_candidate(date: NaiveDate) -> CandidateEligibility { fn stock_candidate(date: NaiveDate) -> CandidateEligibility {
CandidateEligibility { CandidateEligibility {
date, date,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
is_st: false, is_st: false,
is_star_st: false, is_star_st: false,
is_new_listing: false, is_new_listing: false,
@@ -195,7 +195,7 @@ fn engine_reinvests_dividend_receivable_in_round_lots() {
vec![ vec![
DailyMarketSnapshot { DailyMarketSnapshot {
date: buy_date, date: buy_date,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
timestamp: Some("2025-01-01 10:18:00".to_string()), timestamp: Some("2025-01-01 10:18:00".to_string()),
day_open: 10.0, day_open: 10.0,
open: 10.0, open: 10.0,
@@ -218,7 +218,7 @@ fn engine_reinvests_dividend_receivable_in_round_lots() {
}, },
DailyMarketSnapshot { DailyMarketSnapshot {
date: ex_date, date: ex_date,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
timestamp: Some("2025-01-02 10:18:00".to_string()), timestamp: Some("2025-01-02 10:18:00".to_string()),
day_open: 10.0, day_open: 10.0,
open: 10.0, open: 10.0,
@@ -241,7 +241,7 @@ fn engine_reinvests_dividend_receivable_in_round_lots() {
}, },
DailyMarketSnapshot { DailyMarketSnapshot {
date: payable_date, date: payable_date,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
timestamp: Some("2025-01-03 10:18:00".to_string()), timestamp: Some("2025-01-03 10:18:00".to_string()),
day_open: 10.0, day_open: 10.0,
open: 10.0, open: 10.0,
@@ -266,42 +266,42 @@ fn engine_reinvests_dividend_receivable_in_round_lots() {
vec![ vec![
DailyFactorSnapshot { DailyFactorSnapshot {
date: buy_date, date: buy_date,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
market_cap_bn: 20.0, market_cap_bn: 20.0,
free_float_cap_bn: 18.0, free_float_cap_bn: 18.0,
pe_ttm: 10.0, pe_ttm: 10.0,
turnover_ratio: Some(1.0), turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0), effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None, adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: Default::default(),
}, },
DailyFactorSnapshot { DailyFactorSnapshot {
date: ex_date, date: ex_date,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
market_cap_bn: 20.0, market_cap_bn: 20.0,
free_float_cap_bn: 18.0, free_float_cap_bn: 18.0,
pe_ttm: 10.0, pe_ttm: 10.0,
turnover_ratio: Some(1.0), turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0), effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None, adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: Default::default(),
}, },
DailyFactorSnapshot { DailyFactorSnapshot {
date: payable_date, date: payable_date,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
market_cap_bn: 20.0, market_cap_bn: 20.0,
free_float_cap_bn: 18.0, free_float_cap_bn: 18.0,
pe_ttm: 10.0, pe_ttm: 10.0,
turnover_ratio: Some(1.0), turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0), effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None, adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: Default::default(),
}, },
], ],
vec![ vec![
CandidateEligibility { CandidateEligibility {
date: buy_date, date: buy_date,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
is_st: false, is_st: false,
is_star_st: false, is_star_st: false,
is_new_listing: false, is_new_listing: false,
@@ -314,7 +314,7 @@ fn engine_reinvests_dividend_receivable_in_round_lots() {
}, },
CandidateEligibility { CandidateEligibility {
date: ex_date, date: ex_date,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
is_st: false, is_st: false,
is_star_st: false, is_star_st: false,
is_new_listing: false, is_new_listing: false,
@@ -327,7 +327,7 @@ fn engine_reinvests_dividend_receivable_in_round_lots() {
}, },
CandidateEligibility { CandidateEligibility {
date: payable_date, date: payable_date,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
is_st: false, is_st: false,
is_star_st: false, is_star_st: false,
is_new_listing: false, is_new_listing: false,
@@ -394,7 +394,7 @@ fn engine_reinvests_dividend_receivable_in_round_lots() {
.with_minimum_commission(0.0), .with_minimum_commission(0.0),
ChinaEquityRuleHooks::default(), ChinaEquityRuleHooks::default(),
PriceField::Open, PriceField::Open,
), ).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit),
BacktestConfig { BacktestConfig {
initial_cash: 11_008.0, initial_cash: 11_008.0,
benchmark_code: "000300.SH".to_string(), benchmark_code: "000300.SH".to_string(),
@@ -475,7 +475,7 @@ fn engine_settles_same_day_dividend_after_split_for_aiquant_semantics() {
ChinaAShareCostModel::default(), ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(), ChinaEquityRuleHooks::default(),
PriceField::Open, PriceField::Open,
), ).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit),
BacktestConfig { BacktestConfig {
initial_cash: 11_008.0, initial_cash: 11_008.0,
benchmark_code: "000300.SH".to_string(), benchmark_code: "000300.SH".to_string(),
@@ -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 {
@@ -105,7 +105,7 @@ fn single_day_quote_plan_data(date: NaiveDate) -> DataSet {
fixture_instruments(), fixture_instruments(),
vec![DailyMarketSnapshot { vec![DailyMarketSnapshot {
date, date,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
timestamp: Some(format!("{date} 15:00:00")), timestamp: Some(format!("{date} 15:00:00")),
day_open: 10.0, day_open: 10.0,
open: 10.0, open: 10.0,
@@ -128,7 +128,7 @@ fn single_day_quote_plan_data(date: NaiveDate) -> DataSet {
}], }],
vec![DailyFactorSnapshot { vec![DailyFactorSnapshot {
date, date,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
market_cap_bn: 10.0, market_cap_bn: 10.0,
free_float_cap_bn: 10.0, free_float_cap_bn: 10.0,
pe_ttm: 10.0, pe_ttm: 10.0,
@@ -139,7 +139,7 @@ fn single_day_quote_plan_data(date: NaiveDate) -> DataSet {
}], }],
vec![CandidateEligibility { vec![CandidateEligibility {
date, date,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
is_st: false, is_st: false,
is_star_st: false, is_star_st: false,
is_new_listing: false, is_new_listing: false,
@@ -163,14 +163,55 @@ 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(
ChinaAShareCostModel::default(), ChinaAShareCostModel::default(),
ChinaEquityRuleHooks, ChinaEquityRuleHooks,
PriceField::Close, PriceField::Close,
) ).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit)
.with_matching_type(MatchingType::CurrentBarClose); .with_matching_type(MatchingType::CurrentBarClose);
let config = BacktestConfig { let config = BacktestConfig {
initial_cash: 10_000.0, initial_cash: 10_000.0,
@@ -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"
); );
} }
@@ -235,7 +271,7 @@ fn engine_skips_decision_quote_symbol_plan_without_loader() {
ChinaAShareCostModel::default(), ChinaAShareCostModel::default(),
ChinaEquityRuleHooks, ChinaEquityRuleHooks,
PriceField::Close, PriceField::Close,
) ).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit)
.with_matching_type(MatchingType::CurrentBarClose); .with_matching_type(MatchingType::CurrentBarClose);
let config = BacktestConfig { let config = BacktestConfig {
initial_cash: 10_000.0, initial_cash: 10_000.0,
@@ -269,7 +305,7 @@ fn engine_preloads_declared_decision_quotes_for_current_positions() {
vec![ vec![
DailyMarketSnapshot { DailyMarketSnapshot {
date: first, date: first,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
timestamp: Some("2026-01-05 15:00:00".to_string()), timestamp: Some("2026-01-05 15:00:00".to_string()),
day_open: 10.0, day_open: 10.0,
open: 10.0, open: 10.0,
@@ -292,7 +328,7 @@ fn engine_preloads_declared_decision_quotes_for_current_positions() {
}, },
DailyMarketSnapshot { DailyMarketSnapshot {
date: second, date: second,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
timestamp: Some("2026-01-06 15:00:00".to_string()), timestamp: Some("2026-01-06 15:00:00".to_string()),
day_open: 10.5, day_open: 10.5,
open: 10.5, open: 10.5,
@@ -317,7 +353,7 @@ fn engine_preloads_declared_decision_quotes_for_current_positions() {
vec![ vec![
DailyFactorSnapshot { DailyFactorSnapshot {
date: first, date: first,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
market_cap_bn: 10.0, market_cap_bn: 10.0,
free_float_cap_bn: 10.0, free_float_cap_bn: 10.0,
pe_ttm: 10.0, pe_ttm: 10.0,
@@ -328,7 +364,7 @@ fn engine_preloads_declared_decision_quotes_for_current_positions() {
}, },
DailyFactorSnapshot { DailyFactorSnapshot {
date: second, date: second,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
market_cap_bn: 10.0, market_cap_bn: 10.0,
free_float_cap_bn: 10.0, free_float_cap_bn: 10.0,
pe_ttm: 10.0, pe_ttm: 10.0,
@@ -341,7 +377,7 @@ fn engine_preloads_declared_decision_quotes_for_current_positions() {
vec![ vec![
CandidateEligibility { CandidateEligibility {
date: first, date: first,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
is_st: false, is_st: false,
is_star_st: false, is_star_st: false,
is_new_listing: false, is_new_listing: false,
@@ -354,7 +390,7 @@ fn engine_preloads_declared_decision_quotes_for_current_positions() {
}, },
CandidateEligibility { CandidateEligibility {
date: second, date: second,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
is_st: false, is_st: false,
is_star_st: false, is_star_st: false,
is_new_listing: false, is_new_listing: false,
@@ -391,7 +427,7 @@ fn engine_preloads_declared_decision_quotes_for_current_positions() {
ChinaAShareCostModel::default(), ChinaAShareCostModel::default(),
ChinaEquityRuleHooks, ChinaEquityRuleHooks,
PriceField::Last, PriceField::Last,
) ).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit)
.with_matching_type(MatchingType::MinuteLast) .with_matching_type(MatchingType::MinuteLast)
.with_intraday_execution_start_time(t(10, 40, 0)); .with_intraday_execution_start_time(t(10, 40, 0));
let config = BacktestConfig { let config = BacktestConfig {
@@ -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)),
@@ -439,7 +475,7 @@ fn engine_reuses_preloaded_decision_quotes_without_loader_call() {
vec![ vec![
DailyMarketSnapshot { DailyMarketSnapshot {
date: first, date: first,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
timestamp: Some("2026-01-05 15:00:00".to_string()), timestamp: Some("2026-01-05 15:00:00".to_string()),
day_open: 10.0, day_open: 10.0,
open: 10.0, open: 10.0,
@@ -462,7 +498,7 @@ fn engine_reuses_preloaded_decision_quotes_without_loader_call() {
}, },
DailyMarketSnapshot { DailyMarketSnapshot {
date: second, date: second,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
timestamp: Some("2026-01-06 15:00:00".to_string()), timestamp: Some("2026-01-06 15:00:00".to_string()),
day_open: 10.5, day_open: 10.5,
open: 10.5, open: 10.5,
@@ -487,7 +523,7 @@ fn engine_reuses_preloaded_decision_quotes_without_loader_call() {
vec![ vec![
DailyFactorSnapshot { DailyFactorSnapshot {
date: first, date: first,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
market_cap_bn: 10.0, market_cap_bn: 10.0,
free_float_cap_bn: 10.0, free_float_cap_bn: 10.0,
pe_ttm: 10.0, pe_ttm: 10.0,
@@ -498,7 +534,7 @@ fn engine_reuses_preloaded_decision_quotes_without_loader_call() {
}, },
DailyFactorSnapshot { DailyFactorSnapshot {
date: second, date: second,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
market_cap_bn: 10.0, market_cap_bn: 10.0,
free_float_cap_bn: 10.0, free_float_cap_bn: 10.0,
pe_ttm: 10.0, pe_ttm: 10.0,
@@ -511,7 +547,7 @@ fn engine_reuses_preloaded_decision_quotes_without_loader_call() {
vec![ vec![
CandidateEligibility { CandidateEligibility {
date: first, date: first,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
is_st: false, is_st: false,
is_star_st: false, is_star_st: false,
is_new_listing: false, is_new_listing: false,
@@ -524,7 +560,7 @@ fn engine_reuses_preloaded_decision_quotes_without_loader_call() {
}, },
CandidateEligibility { CandidateEligibility {
date: second, date: second,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
is_st: false, is_st: false,
is_star_st: false, is_star_st: false,
is_new_listing: false, is_new_listing: false,
@@ -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)),
@@ -590,7 +626,7 @@ fn engine_reuses_preloaded_decision_quotes_without_loader_call() {
ChinaAShareCostModel::default(), ChinaAShareCostModel::default(),
ChinaEquityRuleHooks, ChinaEquityRuleHooks,
PriceField::Last, PriceField::Last,
) ).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit)
.with_matching_type(MatchingType::MinuteLast) .with_matching_type(MatchingType::MinuteLast)
.with_intraday_execution_start_time(t(10, 40, 0)); .with_intraday_execution_start_time(t(10, 40, 0));
let config = BacktestConfig { let config = BacktestConfig {
@@ -674,7 +710,7 @@ fn engine_loads_distinct_decision_quote_times_on_same_day() {
vec![ vec![
DailyMarketSnapshot { DailyMarketSnapshot {
date: first, date: first,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
timestamp: Some("2026-01-05 15:00:00".to_string()), timestamp: Some("2026-01-05 15:00:00".to_string()),
day_open: 10.0, day_open: 10.0,
open: 10.0, open: 10.0,
@@ -697,7 +733,7 @@ fn engine_loads_distinct_decision_quote_times_on_same_day() {
}, },
DailyMarketSnapshot { DailyMarketSnapshot {
date: second, date: second,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
timestamp: Some("2026-01-06 15:00:00".to_string()), timestamp: Some("2026-01-06 15:00:00".to_string()),
day_open: 10.5, day_open: 10.5,
open: 10.5, open: 10.5,
@@ -722,7 +758,7 @@ fn engine_loads_distinct_decision_quote_times_on_same_day() {
vec![ vec![
DailyFactorSnapshot { DailyFactorSnapshot {
date: first, date: first,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
market_cap_bn: 10.0, market_cap_bn: 10.0,
free_float_cap_bn: 10.0, free_float_cap_bn: 10.0,
pe_ttm: 10.0, pe_ttm: 10.0,
@@ -733,7 +769,7 @@ fn engine_loads_distinct_decision_quote_times_on_same_day() {
}, },
DailyFactorSnapshot { DailyFactorSnapshot {
date: second, date: second,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
market_cap_bn: 10.0, market_cap_bn: 10.0,
free_float_cap_bn: 10.0, free_float_cap_bn: 10.0,
pe_ttm: 10.0, pe_ttm: 10.0,
@@ -746,7 +782,7 @@ fn engine_loads_distinct_decision_quote_times_on_same_day() {
vec![ vec![
CandidateEligibility { CandidateEligibility {
date: first, date: first,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
is_st: false, is_st: false,
is_star_st: false, is_star_st: false,
is_new_listing: false, is_new_listing: false,
@@ -759,7 +795,7 @@ fn engine_loads_distinct_decision_quote_times_on_same_day() {
}, },
CandidateEligibility { CandidateEligibility {
date: second, date: second,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
is_st: false, is_st: false,
is_star_st: false, is_star_st: false,
is_new_listing: false, is_new_listing: false,
@@ -796,7 +832,7 @@ fn engine_loads_distinct_decision_quote_times_on_same_day() {
ChinaAShareCostModel::default(), ChinaAShareCostModel::default(),
ChinaEquityRuleHooks, ChinaEquityRuleHooks,
PriceField::Last, PriceField::Last,
) ).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit)
.with_matching_type(MatchingType::MinuteLast) .with_matching_type(MatchingType::MinuteLast)
.with_intraday_execution_start_time(t(10, 40, 0)); .with_intraday_execution_start_time(t(10, 40, 0));
let config = BacktestConfig { let config = BacktestConfig {
@@ -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),
+30 -30
View File
@@ -71,7 +71,7 @@ fn engine_keeps_unresolved_delisted_position_without_fabricating_a_fill() {
vec![ vec![
DailyMarketSnapshot { DailyMarketSnapshot {
date: date1, date: date1,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
timestamp: Some("2025-01-02 10:18:00".to_string()), timestamp: Some("2025-01-02 10:18:00".to_string()),
day_open: 10.0, day_open: 10.0,
open: 10.0, open: 10.0,
@@ -94,7 +94,7 @@ fn engine_keeps_unresolved_delisted_position_without_fabricating_a_fill() {
}, },
DailyMarketSnapshot { DailyMarketSnapshot {
date: date1, date: date1,
symbol: "000002.SZ".to_string(), symbol: "000002.SZ".into(),
timestamp: Some("2025-01-02 10:18:00".to_string()), timestamp: Some("2025-01-02 10:18:00".to_string()),
day_open: 5.0, day_open: 5.0,
open: 5.0, open: 5.0,
@@ -117,7 +117,7 @@ fn engine_keeps_unresolved_delisted_position_without_fabricating_a_fill() {
}, },
DailyMarketSnapshot { DailyMarketSnapshot {
date: delist_date, date: delist_date,
symbol: "000002.SZ".to_string(), symbol: "000002.SZ".into(),
timestamp: Some("2025-01-03 10:18:00".to_string()), timestamp: Some("2025-01-03 10:18:00".to_string()),
day_open: 5.05, day_open: 5.05,
open: 5.05, open: 5.05,
@@ -140,7 +140,7 @@ fn engine_keeps_unresolved_delisted_position_without_fabricating_a_fill() {
}, },
DailyMarketSnapshot { DailyMarketSnapshot {
date: date2, date: date2,
symbol: "000002.SZ".to_string(), symbol: "000002.SZ".into(),
timestamp: Some("2025-01-06 10:18:00".to_string()), timestamp: Some("2025-01-06 10:18:00".to_string()),
day_open: 5.1, day_open: 5.1,
open: 5.1, open: 5.1,
@@ -165,53 +165,53 @@ fn engine_keeps_unresolved_delisted_position_without_fabricating_a_fill() {
vec![ vec![
DailyFactorSnapshot { DailyFactorSnapshot {
date: date1, date: date1,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
market_cap_bn: 20.0, market_cap_bn: 20.0,
free_float_cap_bn: 18.0, free_float_cap_bn: 18.0,
pe_ttm: 10.0, pe_ttm: 10.0,
turnover_ratio: Some(1.0), turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0), effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None, adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: Default::default(),
}, },
DailyFactorSnapshot { DailyFactorSnapshot {
date: date1, date: date1,
symbol: "000002.SZ".to_string(), symbol: "000002.SZ".into(),
market_cap_bn: 30.0, market_cap_bn: 30.0,
free_float_cap_bn: 28.0, free_float_cap_bn: 28.0,
pe_ttm: 10.0, pe_ttm: 10.0,
turnover_ratio: Some(1.0), turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0), effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None, adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: Default::default(),
}, },
DailyFactorSnapshot { DailyFactorSnapshot {
date: delist_date, date: delist_date,
symbol: "000002.SZ".to_string(), symbol: "000002.SZ".into(),
market_cap_bn: 30.5, market_cap_bn: 30.5,
free_float_cap_bn: 28.5, free_float_cap_bn: 28.5,
pe_ttm: 10.0, pe_ttm: 10.0,
turnover_ratio: Some(1.0), turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0), effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None, adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: Default::default(),
}, },
DailyFactorSnapshot { DailyFactorSnapshot {
date: date2, date: date2,
symbol: "000002.SZ".to_string(), symbol: "000002.SZ".into(),
market_cap_bn: 31.0, market_cap_bn: 31.0,
free_float_cap_bn: 29.0, free_float_cap_bn: 29.0,
pe_ttm: 10.0, pe_ttm: 10.0,
turnover_ratio: Some(1.0), turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0), effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None, adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: Default::default(),
}, },
], ],
vec![ vec![
CandidateEligibility { CandidateEligibility {
date: date1, date: date1,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
is_st: false, is_st: false,
is_star_st: false, is_star_st: false,
is_new_listing: false, is_new_listing: false,
@@ -224,7 +224,7 @@ fn engine_keeps_unresolved_delisted_position_without_fabricating_a_fill() {
}, },
CandidateEligibility { CandidateEligibility {
date: date1, date: date1,
symbol: "000002.SZ".to_string(), symbol: "000002.SZ".into(),
is_st: false, is_st: false,
is_star_st: false, is_star_st: false,
is_new_listing: false, is_new_listing: false,
@@ -237,7 +237,7 @@ fn engine_keeps_unresolved_delisted_position_without_fabricating_a_fill() {
}, },
CandidateEligibility { CandidateEligibility {
date: delist_date, date: delist_date,
symbol: "000002.SZ".to_string(), symbol: "000002.SZ".into(),
is_st: false, is_st: false,
is_star_st: false, is_star_st: false,
is_new_listing: false, is_new_listing: false,
@@ -250,7 +250,7 @@ fn engine_keeps_unresolved_delisted_position_without_fabricating_a_fill() {
}, },
CandidateEligibility { CandidateEligibility {
date: date2, date: date2,
symbol: "000002.SZ".to_string(), symbol: "000002.SZ".into(),
is_st: false, is_st: false,
is_star_st: false, is_star_st: false,
is_new_listing: false, is_new_listing: false,
@@ -295,7 +295,7 @@ fn engine_keeps_unresolved_delisted_position_without_fabricating_a_fill() {
ChinaAShareCostModel::default(), ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(), ChinaEquityRuleHooks::default(),
PriceField::Open, PriceField::Open,
); ).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit);
let mut engine = BacktestEngine::new( let mut engine = BacktestEngine::new(
data, data,
BuyThenHoldStrategy, BuyThenHoldStrategy,
@@ -364,7 +364,7 @@ fn engine_applies_successor_conversion_before_unresolved_delisting_audit() {
vec![ vec![
DailyMarketSnapshot { DailyMarketSnapshot {
date: date1, date: date1,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
timestamp: Some("2025-01-02 10:18:00".to_string()), timestamp: Some("2025-01-02 10:18:00".to_string()),
day_open: 10.0, day_open: 10.0,
open: 10.0, open: 10.0,
@@ -387,7 +387,7 @@ fn engine_applies_successor_conversion_before_unresolved_delisting_audit() {
}, },
DailyMarketSnapshot { DailyMarketSnapshot {
date: date1, date: date1,
symbol: "000002.SZ".to_string(), symbol: "000002.SZ".into(),
timestamp: Some("2025-01-02 10:18:00".to_string()), timestamp: Some("2025-01-02 10:18:00".to_string()),
day_open: 20.0, day_open: 20.0,
open: 20.0, open: 20.0,
@@ -410,7 +410,7 @@ fn engine_applies_successor_conversion_before_unresolved_delisting_audit() {
}, },
DailyMarketSnapshot { DailyMarketSnapshot {
date: date2, date: date2,
symbol: "000002.SZ".to_string(), symbol: "000002.SZ".into(),
timestamp: Some("2025-01-03 10:18:00".to_string()), timestamp: Some("2025-01-03 10:18:00".to_string()),
day_open: 21.0, day_open: 21.0,
open: 21.0, open: 21.0,
@@ -435,42 +435,42 @@ fn engine_applies_successor_conversion_before_unresolved_delisting_audit() {
vec![ vec![
DailyFactorSnapshot { DailyFactorSnapshot {
date: date1, date: date1,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
market_cap_bn: 20.0, market_cap_bn: 20.0,
free_float_cap_bn: 18.0, free_float_cap_bn: 18.0,
pe_ttm: 10.0, pe_ttm: 10.0,
turnover_ratio: Some(1.0), turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0), effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None, adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: Default::default(),
}, },
DailyFactorSnapshot { DailyFactorSnapshot {
date: date1, date: date1,
symbol: "000002.SZ".to_string(), symbol: "000002.SZ".into(),
market_cap_bn: 30.0, market_cap_bn: 30.0,
free_float_cap_bn: 28.0, free_float_cap_bn: 28.0,
pe_ttm: 10.0, pe_ttm: 10.0,
turnover_ratio: Some(1.0), turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0), effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None, adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: Default::default(),
}, },
DailyFactorSnapshot { DailyFactorSnapshot {
date: date2, date: date2,
symbol: "000002.SZ".to_string(), symbol: "000002.SZ".into(),
market_cap_bn: 31.0, market_cap_bn: 31.0,
free_float_cap_bn: 29.0, free_float_cap_bn: 29.0,
pe_ttm: 10.0, pe_ttm: 10.0,
turnover_ratio: Some(1.0), turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0), effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None, adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: Default::default(),
}, },
], ],
vec![ vec![
CandidateEligibility { CandidateEligibility {
date: date1, date: date1,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
is_st: false, is_st: false,
is_star_st: false, is_star_st: false,
is_new_listing: false, is_new_listing: false,
@@ -483,7 +483,7 @@ fn engine_applies_successor_conversion_before_unresolved_delisting_audit() {
}, },
CandidateEligibility { CandidateEligibility {
date: date1, date: date1,
symbol: "000002.SZ".to_string(), symbol: "000002.SZ".into(),
is_st: false, is_st: false,
is_star_st: false, is_star_st: false,
is_new_listing: false, is_new_listing: false,
@@ -496,7 +496,7 @@ fn engine_applies_successor_conversion_before_unresolved_delisting_audit() {
}, },
CandidateEligibility { CandidateEligibility {
date: date2, date: date2,
symbol: "000002.SZ".to_string(), symbol: "000002.SZ".into(),
is_st: false, is_st: false,
is_star_st: false, is_star_st: false,
is_new_listing: false, is_new_listing: false,
@@ -548,7 +548,7 @@ fn engine_applies_successor_conversion_before_unresolved_delisting_audit() {
ChinaAShareCostModel::default(), ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(), ChinaEquityRuleHooks::default(),
PriceField::Open, PriceField::Open,
); ).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit);
let mut engine = BacktestEngine::new( let mut engine = BacktestEngine::new(
data, data,
BuyThenHoldStrategy, BuyThenHoldStrategy,
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -17,7 +17,7 @@ fn dataset(day_count: usize, bars_per_day: usize) -> (DataSet, Vec<NaiveDate>) {
.iter() .iter()
.map(|date| DailyMarketSnapshot { .map(|date| DailyMarketSnapshot {
date: *date, date: *date,
symbol: SYMBOL.to_string(), symbol: SYMBOL.into(),
timestamp: None, timestamp: None,
day_open: 10.0, day_open: 10.0,
open: 10.0, open: 10.0,
@@ -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 {
@@ -66,7 +67,7 @@ fn data_with_fund_rules(
}; };
market.push(DailyMarketSnapshot { market.push(DailyMarketSnapshot {
date, date,
symbol: code(n), symbol: code(n).into(),
timestamp: None, timestamp: None,
day_open: price, day_open: price,
open: price, open: price,
@@ -89,7 +90,7 @@ fn data_with_fund_rules(
}); });
candidates.push(CandidateEligibility { candidates.push(CandidateEligibility {
date, date,
symbol: code(n), symbol: code(n).into(),
is_st: false, is_st: false,
is_star_st: false, is_star_st: false,
is_new_listing: false, is_new_listing: false,
@@ -131,7 +132,7 @@ fn data_with_fund_rules(
.flat_map(|date| { .flat_map(|date| {
(1..=2).map(move |n| DailyFactorSnapshot { (1..=2).map(move |n| DailyFactorSnapshot {
date, date,
symbol: code(n), symbol: code(n).into(),
market_cap_bn: 10., market_cap_bn: 10.,
free_float_cap_bn: 10., free_float_cap_bn: 10.,
pe_ttm: 10., pe_ttm: 10.,
@@ -142,7 +143,13 @@ fn data_with_fund_rules(
}) })
}) })
.collect(); .collect();
DataSet::from_components(instruments, market, factors, candidates, benchmarks).unwrap() 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(),
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,
trading_phase: Some("synthetic_observation_fixture".into()),
}).collect();
DataSet::from_components_with_actions_and_quotes(instruments, market, factors, candidates, benchmarks, vec![], quotes).unwrap()
} }
fn broker(volume: bool) -> BrokerSimulator<ChinaAShareCostModel, ChinaEquityRuleHooks> { fn broker(volume: bool) -> BrokerSimulator<ChinaAShareCostModel, ChinaEquityRuleHooks> {
let mut risk = FidcRiskControlConfig::default(); let mut risk = FidcRiskControlConfig::default();
@@ -159,6 +166,7 @@ fn broker(volume: bool) -> BrokerSimulator<ChinaAShareCostModel, ChinaEquityRule
ChinaEquityRuleHooks, ChinaEquityRuleHooks,
) )
.with_matching_type(MatchingType::NextBarOpen) .with_matching_type(MatchingType::NextBarOpen)
.with_intraday_execution_start_time(chrono::NaiveTime::from_hms_opt(9, 30, 0).unwrap())
.with_risk_config(risk) .with_risk_config(risk)
} }
fn contract(signal: NaiveDate, target: usize, preserve: bool) -> FrozenStockPoolIntent { fn contract(signal: NaiveDate, target: usize, preserve: bool) -> FrozenStockPoolIntent {
@@ -268,6 +276,7 @@ fn mixed_fund_and_stock_round_trip_uses_declared_ticks_and_asset_specific_fees()
let mut costs = ChinaAShareCostModel::default(); let mut costs = ChinaAShareCostModel::default();
costs.set_transfer_fee_rate(0.00001); costs.set_transfer_fee_rate(0.00001);
let broker = BrokerSimulator::new(costs, ChinaEquityRuleHooks) let broker = BrokerSimulator::new(costs, ChinaEquityRuleHooks)
.with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit)
.with_matching_type(MatchingType::NextBarOpen); .with_matching_type(MatchingType::NextBarOpen);
let mut account = PortfolioState::new(30_000.); let mut account = PortfolioState::new(30_000.);
let mut entry = contract(day(2), 1, false); let mut entry = contract(day(2), 1, false);
@@ -437,11 +446,122 @@ fn actual_fill_protection_is_evaluated_on_execution_date() {
assert_eq!(account.position(&code(1)).unwrap().quantity, 1000); assert_eq!(account.position(&code(1)).unwrap().quantity, 1000);
} }
#[test]
fn ordinary_sell_has_one_order_owner_before_broker_execution() {
let data = data(false);
let broker = broker(false);
let mut account = PortfolioState::new(20_000.);
account.position_mut(&code(1)).buy(day(2), 1000, 10.);
let mut intent = contract(day(2), 1, false);
intent.rule.sell_trigger_mode = POOL_SELL_CONDITION.into();
intent.rule.sell_condition = "price>0".into();
let report = broker.execute_with_event_dates(
day(5), day(2), day(2), &mut account, &data, &decision(intent),
).unwrap();
let sells=report.fill_events.iter().filter(|row|row.symbol==code(1)).collect::<Vec<_>>();
assert_eq!(sells.len(),1,"{report:?}");
assert_eq!(sells[0].quantity,1000,"{report:?}");
let owners=report.order_events.iter().filter(|row|row.symbol==code(1)).map(|row|row.order_id).collect::<BTreeSet<_>>();
assert_eq!(owners.len(),1,"{report:?}");
assert_eq!(account.position(&code(1)).map(|row|row.quantity).unwrap_or(0),0);
// The replacement may enter only after the single sell has settled.
let replacement=report.fill_events.iter().find(|row|row.symbol==code(2)).unwrap();
assert_eq!(replacement.quantity,3000,"{report:?}");
assert_eq!(report.account_events[0].cash_after,40000.);
assert_eq!(report.account_events[1].cash_before,40000.);
}
#[test]
fn repeating_the_same_partial_exit_generation_does_not_reduce_again() {
let data=data(false);let broker=broker(false);let mut account=PortfolioState::new(20000.);
account.position_mut(&code(1)).buy(day(2),1000,10.);
let mut intent=contract(day(2),1,true);
intent.constraints.independent_position_target_bps.insert(code(1),5000);
let first=broker.execute_with_event_dates(day(5),day(2),day(2),&mut account,&data,&decision(intent.clone())).unwrap();
assert_eq!(first.fill_events.iter().filter(|fill|fill.symbol==code(1)).map(|fill|fill.quantity).sum::<u32>(),500);
let repeated=broker.execute_with_event_dates(day(5),day(2),day(2),&mut account,&data,&decision(intent.clone())).unwrap();
assert!(repeated.fill_events.iter().all(|fill|fill.symbol!=code(1)),"same generation must keep its first partial-exit target: {repeated:?}");
assert_eq!(account.position(&code(1)).unwrap().quantity,500);
let next_day=broker.execute_with_event_dates(day(6),day(2),day(2),&mut account,&data,&decision(intent.clone())).unwrap();
assert!(next_day.fill_events.iter().all(|fill|fill.symbol!=code(1)),"{next_day:?}");
assert_eq!(account.position(&code(1)).unwrap().quantity,500);
intent.generation="a-new-reduction-signal".into();
let new_signal=broker.execute_with_event_dates(day(6),day(6),day(6),&mut account,&data,&decision(intent)).unwrap();
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);
for quote_condition in ["", "price<5"] { for quote_condition in ["", "price<5"] {
let program = StockPoolProgram { let program = StockPoolProgram {
exit_signals: vec![],
schema_version: 1, schema_version: 1,
pool_id: "pool-fixture".into(), pool_id: "pool-fixture".into(),
version_id: "version-fixture".into(), version_id: "version-fixture".into(),
@@ -508,6 +628,65 @@ 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]
fn parsed_typed_exit_program_keeps_ordinary_gates_and_independent_risk_targets_separate() {
for (ordinary, risk, quote, sold) in [
(Some(0),None,"price<1",0),
(None,Some(0),"price<1",3000),
(Some(0),Some(5000),"price<1",1500),
(Some(0),Some(5000),"price>1",3000),
(None,Some(5000),"",1500),
] {
let exits=ordinary.into_iter().map(|remaining_position_bps|StockPoolExitSignal{role:StockPoolExitRole::OrdinarySell,when_expr:"decision_date == \"2026-01-05\"".into(),remaining_position_bps,reason:"ordinary fixture".into()})
.chain(risk.into_iter().map(|remaining_position_bps|StockPoolExitSignal{role:StockPoolExitRole::RiskExit,when_expr:"decision_date == \"2026-01-05\"".into(),remaining_position_bps,reason:"risk fixture".into()})).collect::<Vec<_>>();
let program=StockPoolProgram{schema_version:1,pool_id:"typed-exits".into(),version_id:"v1".into(),members:contract(day(2),1,true).members,
allocation_policy:serde_json::json!({"target_holding_count":1,"invest_ratio_bps":10000,"portfolio_policy":{"schema_version":1,"membership":"retain_holdings","rebalance_weights":false}}),
timing_policy:serde_json::json!({"pricing_mode":"first_tick","sell_trigger_mode":"condition","sell_condition":quote}),
stop_take_policy:serde_json::json!({"stop_loss":null,"take_profit":null}),out_of_pool_policy:"hold".into(),exit_signals:exits};
let mut config=platform_expr_config_from_value("typed-exits","000300.SH",&serde_json::json!({"stockPool":program,"universe":{"include":[code(1),code(2)]}})).unwrap();
config.market_cap_field="close".into();config.market_cap_lower_expr="0".into();config.market_cap_upper_expr="1000000".into();
config.stock_filter_expr="close>0".into();config.selection_limit_expr="1".into();config.selection_candidate_limit_expr="2".into();config.rank_expr=format!("symbol == {:?} ? 0 : 1",code(1));
config.matching_type=MatchingType::CurrentBarClose;
let result=BacktestEngine::new(data(false),PlatformExprStrategy::new(config),broker(false).with_matching_type(MatchingType::CurrentBarClose),BacktestConfig{
initial_cash:30000.,benchmark_code:"000300.SH".into(),start_date:Some(day(2)),end_date:Some(day(5)),decision_lag_trading_days:0,execution_price_field:PriceField::Close,
}).run().unwrap();
assert_eq!(result.fills.iter().filter(|fill|fill.date==day(2)&&fill.symbol==code(1)&&fill.side==fidc_core::OrderSide::Buy).map(|fill|fill.quantity).sum::<u32>(),3000,"exit-only criteria must not suppress a new entry: {result:?}");
let sold_quantity=result.fills.iter().filter(|fill|fill.date==day(5)&&fill.symbol==code(1)&&fill.side==fidc_core::OrderSide::Sell).map(|fill|fill.quantity).sum::<u32>();
assert_eq!(sold_quantity,sold,"ordinary={ordinary:?} risk={risk:?} quote={quote}: {result:?}");
}
}
#[test] #[test]
fn frontend_compiled_unset_stops_only_builds_positions_and_keeps_holding() { fn frontend_compiled_unset_stops_only_builds_positions_and_keeps_holding() {
// Generated by OmniQuant's actual handoff and compiler, not a hand-written // Generated by OmniQuant's actual handoff and compiler, not a hand-written
@@ -630,3 +809,199 @@ fn next_day_outside_policy_executes_after_the_first_exclusion_signal() {
); );
assert_eq!(account.position(&code(2)).unwrap().quantity, 3000); assert_eq!(account.position(&code(2)).unwrap().quantity, 3000);
} }
fn etf_fallback_fixture(time: chrono::NaiveTime) -> DataSet {
let mut parts = data_with_fund_rules(1_000_000, None, true).snapshot_components();
let previous = NaiveDate::from_ymd_opt(2025,12,31).unwrap();
for instrument in &mut parts.instruments { instrument.listed_at = Some(NaiveDate::from_ymd_opt(2025,12,1).unwrap()); }
let mut past_market = parts.market.iter().filter(|row| row.date == day(2)).cloned().collect::<Vec<_>>();
for row in &mut past_market { row.date=previous; if row.symbol == code(2) { row.close=5.; row.open=5.; row.high=5.; row.low=5.; row.last_price=5.; } }
parts.market.extend(past_market);
let mut past_factors=parts.factors.iter().filter(|row|row.date==day(2)).cloned().collect::<Vec<_>>();
for row in &mut past_factors {row.date=previous;}
parts.factors.extend(past_factors);
let mut past_candidates=parts.candidates.iter().filter(|row|row.date==day(2)).cloned().collect::<Vec<_>>();
for row in &mut past_candidates {row.date=previous;}
parts.candidates.extend(past_candidates);
for factor in &mut parts.factors { if factor.symbol==code(2) {factor.market_cap_bn=f64::NAN;factor.free_float_cap_bn=f64::NAN;} }
let mut past_benchmark = parts.benchmarks[0].clone(); past_benchmark.date=previous; parts.benchmarks.push(past_benchmark);
for row in &mut parts.market {
if row.symbol==code(2) && row.date>=day(2) {
row.open=if row.date==day(2) {10.} else {4.}; row.day_open=row.open;
row.close=40.; row.last_price=40.; row.high=40.; row.low=row.open; row.prev_close=5.;
}
}
parts.execution_quotes.retain(|row| row.symbol==code(1));
for quote in &mut parts.execution_quotes { quote.timestamp=quote.date.and_time(time); }
DataSet::from_components_with_actions_and_quotes(parts.instruments,parts.market,parts.factors,parts.candidates,parts.benchmarks,parts.corporate_actions,parts.execution_quotes).unwrap()
}
struct EtfPoolSignal { at:chrono::NaiveTime, condition:String }
impl fidc_core::strategy::Strategy for EtfPoolSignal {
fn name(&self)->&str {"ETF fallback fixture"}
fn requires_minute_callbacks(&self)->bool {false}
fn decision_quote_times(&self)->Vec<chrono::NaiveTime> {vec![self.at]}
fn decision_quote_symbols(&mut self,_:&fidc_core::strategy::StrategyContext<'_>)->Result<BTreeSet<String>,fidc_core::BacktestError> {Ok(BTreeSet::from([code(1),code(2)]))}
fn on_day(&mut self,ctx:&fidc_core::strategy::StrategyContext<'_>)->Result<StrategyDecision,fidc_core::BacktestError> {
if ctx.execution_date!=day(2) {return Ok(StrategyDecision::default());}
let mut intent=contract(day(2),1,true);
intent.selection.final_symbols=vec![code(1),code(2)];
intent.constraints.target_holding_count=Some(2);
intent.rule.buy_condition=self.condition.clone();
Ok(decision(intent))
}
}
fn run_etf_fallback(time:chrono::NaiveTime,end:NaiveDate,enabled:bool,condition:&str,loader_fails:bool,volume_limit:bool)->Result<fidc_core::BacktestResult,fidc_core::BacktestError> {
let broker=broker(volume_limit).with_matching_type(MatchingType::MinuteLast)
.with_execution_price_field(PriceField::Last).with_intraday_execution_start_time(time)
.with_historical_etf_open_fallback(enabled);
BacktestEngine::new(etf_fallback_fixture(time),EtfPoolSignal{at:time,condition:condition.into()},broker,BacktestConfig{
initial_cash:30000.,benchmark_code:"000300.SH".into(),start_date:Some(day(2)),end_date:Some(end),decision_lag_trading_days:0,execution_price_field:PriceField::Last,
}).with_execution_quote_loader(Box::new(move |_| {
if loader_fails {Err(fidc_core::BacktestError::Execution("fixture_source_unavailable".into()))} else {Ok(vec![])}
})).run()
}
#[test]
fn historical_etf_open_uses_real_open_without_creating_minute_bars() {
let result=run_etf_fallback(chrono::NaiveTime::from_hms_opt(9,30,0).unwrap(),day(6),true,"",false,false).unwrap();
let etf=result.fills.iter().filter(|fill| fill.symbol==code(2)).collect::<Vec<_>>();
assert_eq!(etf.len(),1,"{:?}",result.fills);
assert_eq!((etf[0].date,etf[0].price,etf[0].quantity),(day(2),10.,1500));
assert_eq!(etf[0].execution_timestamp,Some(day(2).and_hms_opt(9,30,0).unwrap()));
assert!(etf[0].reason.contains("etf_daily_open_fallback"));
assert!(result.fills.iter().any(|fill|fill.symbol==code(1)&&fill.date==day(2)));
}
#[test]
fn historical_etf_late_signal_freezes_money_and_requantifies_at_next_official_open() {
let result=run_etf_fallback(chrono::NaiveTime::from_hms_opt(13,0,0).unwrap(),day(6),true,"",false,false).unwrap();
let etf=result.fills.iter().filter(|fill| fill.symbol==code(2)).collect::<Vec<_>>();
assert_eq!(etf.len(),1,"{:?}",result.fills);
assert_eq!((etf[0].date,etf[0].price,etf[0].quantity),(day(5),4.,3700));
assert_eq!(etf[0].execution_timestamp,Some(day(5).and_hms_opt(9,30,0).unwrap()));
assert_eq!(etf[0].order_created_date,Some(day(2)));
assert!(etf[0].reason.contains("2026-01-02 13:00:00"));
assert!(result.fills.iter().any(|fill|fill.symbol==code(1)&&fill.date==day(2)));
assert!(result.terminal_audit.is_clean());
}
#[test]
fn historical_etf_pending_target_at_end_is_not_a_fake_order_or_fill() {
let result=run_etf_fallback(chrono::NaiveTime::from_hms_opt(13,0,0).unwrap(),day(2),true,"",false,false).unwrap();
assert_eq!(result.terminal_audit.deferred_etf_target_count,1);
assert_eq!(result.terminal_audit.status,fidc_core::BacktestTerminalStatus::CompletedWithPendingState);
assert!(result.order_events.iter().all(|order|order.symbol!=code(2)));
assert!(result.fills.iter().all(|fill|fill.symbol!=code(2)));
}
#[test]
fn historical_etf_fallback_does_not_waive_source_conditions_or_capacity() {
let at=chrono::NaiveTime::from_hms_opt(9,30,0).unwrap();
assert!(run_etf_fallback(at,day(6),false,"",false,false).is_err());
assert!(run_etf_fallback(at,day(6),true,"last > 1",false,false).unwrap_err().to_string().contains("condition evidence unavailable"));
assert!(run_etf_fallback(at,day(6),true,"",true,false).unwrap_err().to_string().contains("fixture_source_unavailable"));
assert!(run_etf_fallback(at,day(6),true,"",false,true).unwrap_err().to_string().contains("capacity is missing"));
}
#[test]
fn compiled_pool_price_screen_does_not_require_unconfigured_etf_market_cap() {
let time=chrono::NaiveTime::from_hms_opt(9,30,0).unwrap();
let intent=contract(day(2),1,true);
let program=StockPoolProgram {
exit_signals: vec![],
schema_version:1,pool_id:"typed-mixed-pool".into(),version_id:"v1".into(),members:intent.members,
allocation_policy:serde_json::json!({"target_holding_count":2,"invest_ratio_bps":10000,"portfolio_policy":{"schema_version":1,"membership":"retain_holdings","rebalance_weights":false}}),
timing_policy:serde_json::json!({"pricing_mode":"first_tick","window_start":"09:30"}),
stop_take_policy:serde_json::json!({"stop_loss":null,"take_profit":null}),out_of_pool_policy:"hold".into(),
};
let mut config=platform_expr_config_from_value("etf-no-cap-filter","000300.SH",&serde_json::json!({"stockPool":program,"universe":{"include":[code(1),code(2)]}})).unwrap();
config.market_cap_field="close".into();config.market_cap_lower_expr="0".into();config.market_cap_upper_expr="1000000".into();
config.stock_filter_expr="close > 0".into();config.selection_limit_expr="2".into();config.selection_candidate_limit_expr="2".into();
config.rank_expr=format!("symbol == {:?} ? 0 : 1",code(1));
config.intraday_execution_time=Some(time);config.matching_type=MatchingType::CurrentBarClose;
config.risk_config.trading_constraints.volume_limit_enabled=false;
let result=BacktestEngine::new(etf_fallback_fixture(time),PlatformExprStrategy::new(config.clone()),
broker(false).with_matching_type(MatchingType::CurrentBarClose).with_historical_etf_open_fallback(true),
BacktestConfig{initial_cash:30000.,benchmark_code:"000300.SH".into(),start_date:Some(day(5)),end_date:Some(day(6)),decision_lag_trading_days:0,execution_price_field:PriceField::Last})
.with_execution_quote_loader(Box::new(|_|Ok(vec![]))).run().unwrap();
assert!(result.fills.iter().any(|fill|fill.symbol==code(2)),"{:?}",result.equity_curve.iter().map(|row|&row.diagnostics).collect::<Vec<_>>());
assert!(result.fills.iter().any(|fill|fill.symbol==code(1)));
config.stock_filter_expr="last != 0".into();
let rejected=BacktestEngine::new(etf_fallback_fixture(time),PlatformExprStrategy::new(config),
broker(false).with_matching_type(MatchingType::CurrentBarClose).with_historical_etf_open_fallback(true),
BacktestConfig{initial_cash:30000.,benchmark_code:"000300.SH".into(),start_date:Some(day(5)),end_date:Some(day(6)),decision_lag_trading_days:0,execution_price_field:PriceField::Last})
.with_execution_quote_loader(Box::new(|_|Ok(vec![]))).run().unwrap_err();
assert!(rejected.to_string().contains("etf_intraday_condition_evidence_missing"),"{rejected}");
}
#[test]
fn etf_signal_budget_does_not_read_the_current_sessions_future_close() {
let run=|future_close:f64| {
let time=chrono::NaiveTime::from_hms_opt(13,0,0).unwrap();
let mut parts=etf_fallback_fixture(time).snapshot_components();
for row in &mut parts.market {
if row.symbol==code(2)&&row.date==day(5) {row.close=future_close;row.last_price=future_close;row.high=future_close.max(row.open);}
}
let data=DataSet::from_components_with_actions_and_quotes(parts.instruments,parts.market,parts.factors,parts.candidates,parts.benchmarks,parts.corporate_actions,parts.execution_quotes).unwrap();
let program=StockPoolProgram{exit_signals:vec![],schema_version:1,pool_id:"budget-no-future".into(),version_id:"v1".into(),members:contract(day(2),1,true).members,
allocation_policy:serde_json::json!({"target_holding_count":2,"invest_ratio_bps":10000,"portfolio_policy":{"schema_version":1,"membership":"retain_holdings","rebalance_weights":true}}),
timing_policy:serde_json::json!({"pricing_mode":"first_tick","window_start":"13:00","window_end":"14:55"}),stop_take_policy:serde_json::json!({}),out_of_pool_policy:"hold".into()};
let mut config=platform_expr_config_from_value("etf-budget","000300.SH",&serde_json::json!({"stockPool":program,"universe":{"include":[code(1),code(2)]},"runtimeExpressions":{"schedule":{"frequency":"daily","time":"13:00"}}})).unwrap();
config.market_cap_field="close".into();config.market_cap_lower_expr="0".into();config.market_cap_upper_expr="1000000".into();
config.stock_filter_expr="true".into();config.selection_limit_expr="2".into();config.selection_candidate_limit_expr="2".into();
config.rank_expr=format!("symbol == {:?} ? 0 : 1",code(1));config.intraday_execution_time=Some(time);config.matching_type=MatchingType::CurrentBarClose;
config.risk_config.trading_constraints.volume_limit_enabled=false;
BacktestEngine::new(data,PlatformExprStrategy::new(config),broker(false).with_matching_type(MatchingType::CurrentBarClose).with_intraday_execution_start_time(time).with_historical_etf_open_fallback(true),
BacktestConfig{initial_cash:30000.,benchmark_code:"000300.SH".into(),start_date:Some(day(2)),end_date:Some(day(5)),decision_lag_trading_days:0,execution_price_field:PriceField::Last})
.with_execution_quote_loader(Box::new(|_|Ok(vec![]))).run().unwrap()
};
let a=run(40.);let b=run(400.);
let budget=|result:&fidc_core::BacktestResult|result.equity_curve.iter().find(|row|row.date==day(5)).unwrap().diagnostics.split(" | ").find(|line|line.starts_with("stock_pool_signal_frozen")).unwrap().to_string();
assert_eq!(budget(&a),budget(&b));
assert_eq!(serde_json::to_value(&a.fills).unwrap(),serde_json::to_value(&b.fills).unwrap());
}
struct EtfReallocationSignal { protection_days: u32 }
impl fidc_core::strategy::Strategy for EtfReallocationSignal {
fn name(&self)->&str {"deferred ETF sell funding"}
fn requires_minute_callbacks(&self)->bool {false}
fn decision_quote_times(&self)->Vec<chrono::NaiveTime> {vec![chrono::NaiveTime::from_hms_opt(13,0,0).unwrap()]}
fn decision_quote_symbols(&mut self,_:&fidc_core::strategy::StrategyContext<'_>)->Result<BTreeSet<String>,fidc_core::BacktestError>{Ok(BTreeSet::from([code(1),code(2)]))}
fn on_day(&mut self,ctx:&fidc_core::strategy::StrategyContext<'_>)->Result<StrategyDecision,fidc_core::BacktestError> {
if ![day(2),day(6)].contains(&ctx.execution_date) {return Ok(Default::default());}
let mut intent=contract(ctx.execution_date,1,false);
intent.rule.automatic_trade_protection.buy_protection_days=self.protection_days;
if ctx.execution_date==day(2) {intent.selection.final_symbols=vec![code(1),code(2)];intent.constraints.target_holding_count=Some(2);}
else {intent.frozen_equity=300000.into();intent.out_of_pool_policy="reduce_to_zero_when_sellable".into();}
Ok(decision(intent))
}
}
#[test]
fn deferred_etf_sell_does_not_finance_same_day_stock_topup() {
let time=chrono::NaiveTime::from_hms_opt(13,0,0).unwrap();
let result=BacktestEngine::new(etf_fallback_fixture(time),EtfReallocationSignal{protection_days:0},
broker(false).with_matching_type(MatchingType::MinuteLast).with_execution_price_field(PriceField::Last).with_intraday_execution_start_time(time).with_historical_etf_open_fallback(true),
BacktestConfig{initial_cash:30000.,benchmark_code:"000300.SH".into(),start_date:Some(day(2)),end_date:Some(day(6)),decision_lag_trading_days:0,execution_price_field:PriceField::Last})
.with_execution_quote_loader(Box::new(|_|Ok(vec![]))).run().unwrap();
assert!(result.fills.iter().any(|fill|fill.symbol==code(2)&&fill.date==day(5)));
assert!(result.fills.iter().all(|fill|fill.date!=day(6)),"{:?}",result.fills);
assert!(!result.order_events.iter().any(|order|order.date==day(6)&&order.symbol==code(1)&&order.side==fidc_core::OrderSide::Buy),"{:?}",result.order_events);
assert_eq!(result.terminal_audit.deferred_etf_target_count,1);
}
#[test]
fn etf_post_buy_protection_starts_on_deferred_fill_day_not_signal_day() {
let time=chrono::NaiveTime::from_hms_opt(13,0,0).unwrap();
let result=BacktestEngine::new(etf_fallback_fixture(time),EtfReallocationSignal{protection_days:1},
broker(false).with_matching_type(MatchingType::MinuteLast).with_execution_price_field(PriceField::Last).with_intraday_execution_start_time(time).with_historical_etf_open_fallback(true),
BacktestConfig{initial_cash:30000.,benchmark_code:"000300.SH".into(),start_date:Some(day(2)),end_date:Some(day(6)),decision_lag_trading_days:0,execution_price_field:PriceField::Last})
.with_execution_quote_loader(Box::new(|_|Ok(vec![]))).run().unwrap();
assert!(result.fills.iter().any(|fill|fill.symbol==code(2)&&fill.date==day(5)));
assert!(result.fills.iter().filter(|fill|fill.symbol==code(2)).all(|fill|fill.side!=fidc_core::OrderSide::Sell));
// Jan 2 is the signal; actual Jan 5 fill protects Jan 5 and Jan 6.
// Starting the timer on Jan 2 would incorrectly queue an exit on Jan 6.
assert_eq!(result.terminal_audit.deferred_etf_target_count,0);
}
@@ -158,6 +158,42 @@ fn legacy_state_without_quantity_keeps_its_serialized_identity() {
assert_eq!(serde_json::to_value(state).unwrap(), original); assert_eq!(serde_json::to_value(state).unwrap(), original);
} }
#[test]
fn partial_exit_basis_is_immutable_restart_safe_and_scoped_to_the_signal() {
let original = StockPoolExecutionState::default()
.observe(day(11), day(11), &[day(11), day(14)], &[member()], &[held(1000, 1000)]).unwrap();
let basis = BTreeMap::from([("000001.SZ".into(), Decimal::from(1000))]);
let saved = original.record_position_action_bases("sell-signal", &basis).unwrap();
assert!(original.position_action_bases.is_empty(), "a preview must not mutate its input");
let restored: StockPoolExecutionState = serde_json::from_slice(&serde_json::to_vec(&saved).unwrap()).unwrap();
let next_day = restored.observe(day(11), day(14), &[day(11), day(14)], &[member()], &[held(500, 500)]).unwrap();
assert_eq!(next_day.position_action_bases_for("sell-signal"), basis);
assert!(next_day.position_action_bases_for("new-signal").is_empty());
assert!(next_day.record_position_action_bases("sell-signal", &BTreeMap::from([("000001.SZ".into(), Decimal::from(500))])).unwrap_err().contains("basis_changed"));
let new_signal = next_day.record_position_action_bases("new-signal", &BTreeMap::from([("000001.SZ".into(), Decimal::from(500))])).unwrap();
assert!(new_signal.position_action_bases_for("sell-signal").is_empty());
assert_eq!(new_signal.position_action_bases_for("new-signal")["000001.SZ"], Decimal::from(500));
for invalid in [Decimal::ZERO, Decimal::NEGATIVE_ONE] {
assert!(original.record_position_action_bases("signal", &BTreeMap::from([("000001.SZ".into(), invalid)])).is_err());
}
assert!(original.record_position_action_bases(" ", &basis).is_err());
}
#[test]
fn verified_split_adjusts_exit_basis_and_entry_completion_not_generation() {
let initial = StockPoolExecutionState::default()
.observe(day(11), day(11), &[day(11)], &[member()], &[]).unwrap();
let entry_plan = plan(&initial, day(11), &[member()], &[], 10000, "hold");
let entered = initial.record_plan(day(11), "entry", &entry_plan).unwrap();
let saved = entered.record_position_action_bases("sell", &BTreeMap::from([("000001.SZ".into(), Decimal::from(1000))])).unwrap();
let adjusted = saved.adjust_for_split("000001.SZ", Decimal::new(15,1)).unwrap();
assert_eq!(adjusted.position_action_bases_for("sell")["000001.SZ"], Decimal::from(1500));
assert_eq!(adjusted.entries["000001.SZ"].completion_quantity, Some(Decimal::from(1500)));
assert_eq!(adjusted.position_action_bases["000001.SZ"].first_execution_date, day(11));
assert_eq!(saved.position_action_bases_for("sell")["000001.SZ"], Decimal::from(1000));
assert!(saved.adjust_for_split("000001.SZ", Decimal::ZERO).is_err());
}
#[test] #[test]
fn partial_entry_continues_after_restart_then_completed_holdings_are_preserved() { fn partial_entry_continues_after_restart_then_completed_holdings_are_preserved() {
let members = vec![member()]; let members = vec![member()];
+3 -3
View File
@@ -35,7 +35,7 @@ fn market(
) -> DailyMarketSnapshot { ) -> DailyMarketSnapshot {
DailyMarketSnapshot { DailyMarketSnapshot {
date: d(date), date: d(date),
symbol: symbol.to_string(), symbol: symbol.into(),
timestamp: None, timestamp: None,
day_open: open, day_open: open,
open, open,
@@ -66,7 +66,7 @@ fn factor(
) -> DailyFactorSnapshot { ) -> DailyFactorSnapshot {
DailyFactorSnapshot { DailyFactorSnapshot {
date: d(date), date: d(date),
symbol: symbol.to_string(), symbol: symbol.into(),
market_cap_bn, market_cap_bn,
free_float_cap_bn, free_float_cap_bn,
pe_ttm: 18.0, pe_ttm: 18.0,
@@ -87,7 +87,7 @@ fn candidate(
) -> CandidateEligibility { ) -> CandidateEligibility {
CandidateEligibility { CandidateEligibility {
date: d(date), date: d(date),
symbol: symbol.to_string(), symbol: symbol.into(),
is_st: false, is_st: false,
is_star_st: false, is_star_st: false,
is_new_listing, is_new_listing,
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,再配套发布。优先级仍可在真实资金或仓位约束不足时影响分配,不能预设所有不同排序的结果必须相同。