Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c98bcc3eb2 | |||
| 53af3a6a85 | |||
| 70c6f7e90b | |||
| 0ed6752a73 | |||
| 3e8cc63b9a | |||
| be171683c9 | |||
| 0a6fab9038 | |||
| e8abf43cd4 | |||
| 2286bfa757 | |||
| 93809eea1b | |||
| f7f0ff2951 | |||
| effa0c6456 | |||
| b1ca2dfada | |||
| d15abc18ae | |||
| 9370dfe6e9 | |||
| b19108558f | |||
| fe05384f80 | |||
| 0ff90c4329 | |||
| c85daae608 | |||
| f73513e2d4 | |||
| 4e953b6e98 | |||
| b232847e40 | |||
| e3b3929578 | |||
| 20e73d567b | |||
| cf4498668b | |||
| 3f39943ee4 | |||
| 5c65e65c6f | |||
| f3c70ea566 | |||
| 07b7b181b6 | |||
| fe7243bbc3 |
@@ -427,6 +427,8 @@ pub struct BrokerSimulator<C, R> {
|
||||
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,
|
||||
rules: R,
|
||||
board_lot_size: u32,
|
||||
@@ -469,6 +471,8 @@ impl<C, R> BrokerSimulator<C, R> {
|
||||
verified_etf_minute_absences: RefCell::new(BTreeSet::new()),
|
||||
runtime_etf_daily_open: Cell::new(false),
|
||||
deferred_etf_targets: RefCell::new(Default::default()),
|
||||
deferred_stock_pools: RefCell::new(BTreeMap::new()),
|
||||
runtime_stock_pool_followup: Cell::new(false),
|
||||
cost_model,
|
||||
rules,
|
||||
board_lot_size: 100,
|
||||
@@ -515,6 +519,8 @@ impl<C, R> BrokerSimulator<C, R> {
|
||||
verified_etf_minute_absences: RefCell::new(BTreeSet::new()),
|
||||
runtime_etf_daily_open: Cell::new(false),
|
||||
deferred_etf_targets: RefCell::new(Default::default()),
|
||||
deferred_stock_pools: RefCell::new(BTreeMap::new()),
|
||||
runtime_stock_pool_followup: Cell::new(false),
|
||||
cost_model,
|
||||
rules,
|
||||
board_lot_size: 100,
|
||||
@@ -778,7 +784,7 @@ impl<C, R> BrokerSimulator<C, R> {
|
||||
if self.runtime_etf_daily_open.get() { return PriceField::Open; }
|
||||
if self.is_post_close_fixed_price(date) {
|
||||
PriceField::Close
|
||||
} else if self.resting_daily_open_order() {
|
||||
} else if self.resting_daily_open_order() || (self.runtime_stock_pool_followup.get() && self.matching_type == MatchingType::NextBarOpen) {
|
||||
PriceField::Last
|
||||
} else {
|
||||
self.execution_price_field
|
||||
@@ -901,6 +907,12 @@ impl<C, R> BrokerSimulator<C, R> {
|
||||
!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 {
|
||||
let post_close = self.execution_phase_for_submission(date, order.order_created_date, order.submission_time)
|
||||
== EquityExecutionPhase::PostCloseFixedPrice;
|
||||
@@ -1479,7 +1491,7 @@ where
|
||||
match algo_request.map(|request| request.style) {
|
||||
Some(AlgoExecutionStyle::Vwap) => MatchingType::Vwap,
|
||||
Some(AlgoExecutionStyle::Twap) => MatchingType::Twap,
|
||||
None if self.resting_daily_open_order() => MatchingType::CurrentBarClose,
|
||||
None if self.resting_daily_open_order() || (self.runtime_stock_pool_followup.get() && self.matching_type == MatchingType::NextBarOpen) => MatchingType::CurrentBarClose,
|
||||
None => self.matching_type,
|
||||
}
|
||||
}
|
||||
@@ -1597,6 +1609,13 @@ where
|
||||
session: &mut BrokerExecutionSession,
|
||||
) -> Result<BrokerExecutionReport, BacktestError> {
|
||||
let mut report = BrokerExecutionReport::default();
|
||||
// A fresh strategy intent supersedes any unsubmitted remainder before
|
||||
// old order reports can resume it. Already submitted orders are kept.
|
||||
for intent in &decision.order_intents {
|
||||
if let OrderIntent::StockPool { contract } = intent.unwrapped() {
|
||||
self.deferred_stock_pools.borrow_mut().remove(&contract.pool_id);
|
||||
}
|
||||
}
|
||||
self.process_open_orders(
|
||||
date,
|
||||
portfolio,
|
||||
@@ -1607,6 +1626,7 @@ where
|
||||
&mut session.commission_state,
|
||||
&mut report,
|
||||
)?;
|
||||
self.resume_stock_pool_executions(date, portfolio, data, session, &mut report)?;
|
||||
if !decision.order_intents.is_empty() {
|
||||
let mut ordered_intents = decision.order_intents.iter().collect::<Vec<_>>();
|
||||
if self.effective_rebalance_cash_mode() != RebalanceCashMode::PreOpenCash
|
||||
@@ -4149,7 +4169,7 @@ where
|
||||
side: OrderSide,
|
||||
algo_request: Option<&AlgoExecutionRequest>,
|
||||
) -> f64 {
|
||||
if self.matching_type == MatchingType::NextBarOpen && !self.resting_daily_open_order() && algo_request.is_none() {
|
||||
if self.matching_type == MatchingType::NextBarOpen && !self.resting_daily_open_order() && !self.runtime_stock_pool_followup.get() && algo_request.is_none() {
|
||||
return self.execution_limit_check_price(snapshot, side);
|
||||
}
|
||||
let matching_type = self.matching_type_for_algo_request(algo_request);
|
||||
@@ -4735,7 +4755,7 @@ where
|
||||
if Self::keeps_remainder_open(remainder_policy) {
|
||||
self.upsert_open_order(OpenOrder {
|
||||
order_id,
|
||||
submission_time: if self.matching_type == MatchingType::NextBarOpen { NaiveTime::from_hms_opt(9,30,0) } else { self.order_origin().1 },
|
||||
submission_time: self.new_open_order_submission_time(),
|
||||
accepted_date: self.accepted_order_date(date),
|
||||
decision_date: Some(self.current_decision_date(date)),
|
||||
order_created_date: Some(self.current_order_created_date(date)),
|
||||
@@ -4826,7 +4846,7 @@ where
|
||||
.unwrap_or("no sellable quantity");
|
||||
self.upsert_open_order(OpenOrder {
|
||||
order_id,
|
||||
submission_time: if self.matching_type == MatchingType::NextBarOpen { NaiveTime::from_hms_opt(9,30,0) } else { self.order_origin().1 },
|
||||
submission_time: self.new_open_order_submission_time(),
|
||||
accepted_date: self.accepted_order_date(date),
|
||||
decision_date: Some(self.current_decision_date(date)),
|
||||
order_created_date: Some(self.current_order_created_date(date)),
|
||||
@@ -4999,7 +5019,7 @@ where
|
||||
{
|
||||
self.upsert_open_order(OpenOrder {
|
||||
order_id,
|
||||
submission_time: if self.matching_type == MatchingType::NextBarOpen { NaiveTime::from_hms_opt(9,30,0) } else { self.order_origin().1 },
|
||||
submission_time: self.new_open_order_submission_time(),
|
||||
accepted_date: self.accepted_order_date(date),
|
||||
decision_date: Some(self.current_decision_date(date)),
|
||||
order_created_date: Some(self.current_order_created_date(date)),
|
||||
@@ -5171,7 +5191,7 @@ where
|
||||
if keep_open {
|
||||
self.upsert_open_order(OpenOrder {
|
||||
order_id,
|
||||
submission_time: if self.matching_type == MatchingType::NextBarOpen { NaiveTime::from_hms_opt(9,30,0) } else { self.order_origin().1 },
|
||||
submission_time: self.new_open_order_submission_time(),
|
||||
accepted_date: self.accepted_order_date(date),
|
||||
decision_date: Some(self.current_decision_date(date)),
|
||||
order_created_date: Some(self.current_order_created_date(date)),
|
||||
@@ -6559,7 +6579,7 @@ where
|
||||
if Self::keeps_remainder_open(remainder_policy) {
|
||||
self.upsert_open_order(OpenOrder {
|
||||
order_id,
|
||||
submission_time: if self.matching_type == MatchingType::NextBarOpen { NaiveTime::from_hms_opt(9,30,0) } else { self.order_origin().1 },
|
||||
submission_time: self.new_open_order_submission_time(),
|
||||
accepted_date: self.accepted_order_date(date),
|
||||
decision_date: Some(self.current_decision_date(date)),
|
||||
order_created_date: Some(self.current_order_created_date(date)),
|
||||
@@ -6799,7 +6819,7 @@ where
|
||||
{
|
||||
self.upsert_open_order(OpenOrder {
|
||||
order_id,
|
||||
submission_time: if self.matching_type == MatchingType::NextBarOpen { NaiveTime::from_hms_opt(9,30,0) } else { self.order_origin().1 },
|
||||
submission_time: self.new_open_order_submission_time(),
|
||||
accepted_date: self.accepted_order_date(date),
|
||||
decision_date: Some(self.current_decision_date(date)),
|
||||
order_created_date: Some(self.current_order_created_date(date)),
|
||||
@@ -6973,7 +6993,7 @@ where
|
||||
if keep_open {
|
||||
self.upsert_open_order(OpenOrder {
|
||||
order_id,
|
||||
submission_time: if self.matching_type == MatchingType::NextBarOpen { NaiveTime::from_hms_opt(9,30,0) } else { self.order_origin().1 },
|
||||
submission_time: self.new_open_order_submission_time(),
|
||||
accepted_date: self.accepted_order_date(date),
|
||||
decision_date: Some(self.current_decision_date(date)),
|
||||
order_created_date: Some(self.current_order_created_date(date)),
|
||||
@@ -8110,6 +8130,7 @@ where
|
||||
|
||||
pub(crate) fn matching_type_uses_intraday_quotes(&self) -> bool {
|
||||
if self.runtime_etf_daily_open.get() { return false; }
|
||||
if self.runtime_stock_pool_followup.get() { return true; }
|
||||
if self.resting_daily_open_order() { return true; }
|
||||
matches!(
|
||||
self.matching_type,
|
||||
@@ -8252,6 +8273,8 @@ mod tests {
|
||||
use crate::rules::ChinaEquityRuleHooks;
|
||||
use crate::strategy::{AlgoOrderStyle, OrderIntent, OrderTimeInForce, StrategyDecision};
|
||||
|
||||
include!("broker_stock_pool_batch_tests.rs");
|
||||
|
||||
fn test_open_order(order_id: u64) -> OpenOrder {
|
||||
OpenOrder {
|
||||
order_id,
|
||||
|
||||
@@ -3,6 +3,35 @@ use super::*;
|
||||
use crate::holding_policy::HoldingLifecycleEvidence;
|
||||
use crate::stock_pool_execution as pool;
|
||||
use rust_decimal::{Decimal, prelude::ToPrimitive};
|
||||
use chrono::Timelike;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(super) struct DeferredStockPoolExecution {
|
||||
date: NaiveDate,
|
||||
contract: Box<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> {
|
||||
if !value.is_finite() {
|
||||
@@ -41,6 +70,48 @@ fn pool_positions(
|
||||
}
|
||||
|
||||
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(
|
||||
&self,
|
||||
date: NaiveDate,
|
||||
@@ -102,7 +173,7 @@ impl<C: CostModel, R: EquityRuleHooks> BrokerSimulator<C, R> {
|
||||
snapshot,
|
||||
quote,
|
||||
OrderSide::Buy,
|
||||
self.matching_type,
|
||||
self.matching_type_for_algo_request(None),
|
||||
)
|
||||
.ok_or_else(|| {
|
||||
BacktestError::Execution(format!(
|
||||
@@ -114,7 +185,7 @@ impl<C: CostModel, R: EquityRuleHooks> BrokerSimulator<C, R> {
|
||||
snapshot,
|
||||
quote,
|
||||
OrderSide::Sell,
|
||||
self.matching_type,
|
||||
self.matching_type_for_algo_request(None),
|
||||
)
|
||||
.ok_or_else(|| {
|
||||
BacktestError::Execution(format!(
|
||||
@@ -226,6 +297,17 @@ impl<C: CostModel, R: EquityRuleHooks> BrokerSimulator<C, R> {
|
||||
global_execution_cursor: &mut Option<NaiveDateTime>,
|
||||
commission_state: &mut BTreeMap<u64, f64>,
|
||||
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> {
|
||||
if contract.signal_date > date
|
||||
|| contract.frozen_equity < Decimal::ZERO
|
||||
@@ -266,6 +348,7 @@ impl<C: CostModel, R: EquityRuleHooks> BrokerSimulator<C, R> {
|
||||
.cloned()
|
||||
.collect::<BTreeSet<_>>();
|
||||
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 initial_positions = pool_positions(portfolio, date)?;
|
||||
let state = portfolio
|
||||
@@ -284,6 +367,9 @@ impl<C: CostModel, R: EquityRuleHooks> BrokerSimulator<C, R> {
|
||||
let superseded = self.deferred_etf_targets.borrow_mut().replace_generation(&contract.pool_id, &contract.generation);
|
||||
if superseded > 0 { report.diagnostics.push(format!("etf_daily_open_fallback:superseded pool={} generation={} targets={superseded}", contract.pool_id, contract.generation)); }
|
||||
if self.has_open_orders() {
|
||||
self.deferred_stock_pools.borrow_mut().insert(contract.pool_id.clone(), DeferredStockPoolExecution {
|
||||
date, contract: Box::new(contract.clone()), buy_only, symbols: scope, initial_holdings: before_positions,
|
||||
});
|
||||
report
|
||||
.diagnostics
|
||||
.push("stock_pool_waiting_for_active_orders no_new_intent=true".into());
|
||||
@@ -329,15 +415,19 @@ impl<C: CostModel, R: EquityRuleHooks> BrokerSimulator<C, R> {
|
||||
.push("paused".into());
|
||||
}
|
||||
}
|
||||
let before_positions = portfolio
|
||||
.positions()
|
||||
.keys()
|
||||
.cloned()
|
||||
.collect::<BTreeSet<_>>();
|
||||
// All delayed symbols in a generation share immutable configuration.
|
||||
// Do not duplicate an N-member pool N times in a large mixed pool.
|
||||
let mut deferred_configuration = None;
|
||||
for side in [pool::OrderSide::Sell, pool::OrderSide::Buy] {
|
||||
if buy_only && side == pool::OrderSide::Sell { continue; }
|
||||
if side == pool::OrderSide::Buy && self.has_open_orders()
|
||||
&& self.effective_rebalance_cash_mode() == RebalanceCashMode::SellThenBuy {
|
||||
self.deferred_stock_pools.borrow_mut().insert(contract.pool_id.clone(), DeferredStockPoolExecution {
|
||||
date, contract: Box::new(contract.clone()), buy_only: true, symbols: quote_scope.clone(), initial_holdings: before_positions.clone(),
|
||||
});
|
||||
report.diagnostics.push(format!("stock_pool_waiting_for_sell_reports generation={} no_buy_order_created=true",contract.generation));
|
||||
break;
|
||||
}
|
||||
let mut fallback_references = BTreeMap::new();
|
||||
for symbol in "e_scope {
|
||||
if let Some(reference) = self.pool_etf_fallback_reference(date, data, symbol, *global_execution_cursor)? {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
+205
-70
@@ -3,7 +3,7 @@ use std::cmp::Reverse;
|
||||
use std::collections::{BTreeMap, BTreeSet, BinaryHeap, HashMap, HashSet};
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
use ahash::AHashMap;
|
||||
use ahash::{AHashMap, AHashSet};
|
||||
use chrono::{NaiveDate, NaiveDateTime};
|
||||
use compact_str::CompactString;
|
||||
use rayon::prelude::*;
|
||||
@@ -1586,48 +1586,15 @@ impl DataSet {
|
||||
benchmark_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;
|
||||
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() {
|
||||
grouped.market_by_date.insert(date, bundle.market);
|
||||
}
|
||||
@@ -2283,6 +2250,17 @@ impl DataSet {
|
||||
.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] {
|
||||
self.execution_quotes_by_date
|
||||
.get(&date)
|
||||
@@ -4423,9 +4401,9 @@ fn normalize_factor_snapshots(
|
||||
});
|
||||
}
|
||||
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 == field.as_ref()
|
||||
&& trimmed == field.as_str()
|
||||
&& trimmed.bytes().all(|byte| !byte.is_ascii_uppercase())
|
||||
&& value.is_finite()
|
||||
});
|
||||
@@ -4436,15 +4414,15 @@ fn normalize_factor_snapshots(
|
||||
.extra_factors
|
||||
.into_iter()
|
||||
.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() {
|
||||
None
|
||||
} else if trimmed == field.as_ref()
|
||||
} else if trimmed == field.as_str()
|
||||
&& trimmed.bytes().all(|byte| !byte.is_ascii_uppercase())
|
||||
{
|
||||
Some((field, value))
|
||||
} else {
|
||||
Some((Cow::Owned(trimmed.to_ascii_lowercase()), value))
|
||||
Some((CompactString::from(trimmed.to_ascii_lowercase()), value))
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
@@ -4472,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>(
|
||||
rows: &[T],
|
||||
bundle_date: NaiveDate,
|
||||
@@ -4532,7 +4542,7 @@ fn build_symbol_id_index(
|
||||
factor_by_date: &BTreeMap<NaiveDate, Vec<DailyFactorSnapshot>>,
|
||||
candidate_by_date: &BTreeMap<NaiveDate, Vec<CandidateEligibility>>,
|
||||
) -> 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 row in rows {
|
||||
if !symbols.contains(row.symbol.as_str()) {
|
||||
@@ -4574,10 +4584,11 @@ fn build_group_symbol_ids<T, F>(
|
||||
symbol_of: F,
|
||||
) -> BTreeMap<NaiveDate, Vec<u32>>
|
||||
where
|
||||
F: Fn(&T) -> &str + Copy,
|
||||
T: Sync,
|
||||
F: Fn(&T) -> &str + Copy + Send + Sync,
|
||||
{
|
||||
groups
|
||||
.iter()
|
||||
.par_iter()
|
||||
.map(|(date, rows)| {
|
||||
let symbol_ids = rows
|
||||
.iter()
|
||||
@@ -4590,6 +4601,8 @@ where
|
||||
debug_assert!(symbol_ids.windows(2).all(|window| window[0] < window[1]));
|
||||
(*date, symbol_ids)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.into_iter()
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -4669,7 +4682,7 @@ fn build_factor_market_cap_order(
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn build_dense_row_positions<T>(
|
||||
fn build_dense_row_positions<T: Sync>(
|
||||
groups: &BTreeMap<NaiveDate, Vec<T>>,
|
||||
symbol_ids_by_date: &BTreeMap<NaiveDate, Vec<u32>>,
|
||||
symbol_count: usize,
|
||||
@@ -4680,23 +4693,27 @@ fn build_dense_row_positions<T>(
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut positions_by_date = BTreeMap::new();
|
||||
for (date, rows) in groups {
|
||||
let symbol_ids = symbol_ids_by_date.get(date)?;
|
||||
if rows.len() != symbol_ids.len() {
|
||||
return None;
|
||||
}
|
||||
let mut positions = vec![MISSING_ROW_POSITION; symbol_count];
|
||||
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 {
|
||||
// Each task owns one bounded day index. No partial index is published if
|
||||
// any day has a missing, duplicate, or misaligned symbol identifier.
|
||||
groups
|
||||
.par_iter()
|
||||
.map(|(date, rows)| {
|
||||
let symbol_ids = symbol_ids_by_date.get(date)?;
|
||||
if rows.len() != symbol_ids.len() {
|
||||
return None;
|
||||
}
|
||||
*position = u32::try_from(row_index).ok()?;
|
||||
}
|
||||
positions_by_date.insert(*date, positions);
|
||||
}
|
||||
Some(positions_by_date)
|
||||
let mut positions = vec![MISSING_ROW_POSITION; symbol_count];
|
||||
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;
|
||||
}
|
||||
*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(
|
||||
@@ -5468,6 +5485,10 @@ mod tests {
|
||||
)
|
||||
.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.benchmark_code(), grouped.benchmark_code());
|
||||
for date in dates {
|
||||
@@ -5542,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]
|
||||
fn direct_symbol_id_snapshot_lookups_preserve_alignment_for_sparse_rows() {
|
||||
let date = NaiveDate::parse_from_str("2025-01-02", "%Y-%m-%d").unwrap();
|
||||
@@ -6348,10 +6485,8 @@ mod tests {
|
||||
extra_factors: From::from([(Cow::Borrowed("amount"), 10.0)]),
|
||||
}])
|
||||
.expect("normalize clean factor snapshot");
|
||||
assert!(matches!(
|
||||
clean[0].extra_factors.keys().next(),
|
||||
Some(Cow::Borrowed("amount"))
|
||||
));
|
||||
assert_eq!(clean[0].extra_factors.keys().next().map(CompactString::as_str), Some("amount"));
|
||||
assert!(!clean[0].extra_factors.keys().next().unwrap().is_heap_allocated());
|
||||
|
||||
let dirty = normalize_factor_snapshots(vec![DailyFactorSnapshot {
|
||||
date,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use chrono::{Datelike, Duration, NaiveDate, NaiveTime};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -473,7 +472,6 @@ pub struct BacktestEngine<S, C, R> {
|
||||
futures_cost_model: FuturesTransactionCostModel,
|
||||
futures_validation_config: FuturesValidationConfig,
|
||||
execution_quote_loader: Option<ExecutionQuoteLoader>,
|
||||
preplanned_decision_quote_symbols_by_date: Option<Arc<BTreeMap<NaiveDate, BTreeSet<String>>>>,
|
||||
execution_quote_request_cache:
|
||||
BTreeSet<(NaiveDate, String, Option<NaiveTime>, Option<NaiveTime>)>,
|
||||
execution_absence_notes: BTreeMap<NaiveDate, Vec<String>>,
|
||||
@@ -574,7 +572,6 @@ impl<S, C, R> BacktestEngine<S, C, R> {
|
||||
futures_cost_model: FuturesTransactionCostModel::default(),
|
||||
futures_validation_config: FuturesValidationConfig::default(),
|
||||
execution_quote_loader: None,
|
||||
preplanned_decision_quote_symbols_by_date: None,
|
||||
execution_quote_request_cache: BTreeSet::new(),
|
||||
execution_absence_notes: BTreeMap::new(),
|
||||
execution_lifecycle_reported: BTreeSet::new(),
|
||||
@@ -601,14 +598,6 @@ impl<S, C, R> BacktestEngine<S, C, R> {
|
||||
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 {
|
||||
self.dividend_reinvestment = enabled;
|
||||
self
|
||||
@@ -2620,46 +2609,31 @@ where
|
||||
let on_day_open_orders = self.open_order_views();
|
||||
let decision_quote_times = self.strategy.decision_quote_times();
|
||||
if self.execution_quote_loader.is_some() && !decision_quote_times.is_empty() {
|
||||
if let Some(preplanned) = self
|
||||
.preplanned_decision_quote_symbols_by_date
|
||||
.as_ref()
|
||||
.map(Arc::clone)
|
||||
{
|
||||
let empty_symbols = BTreeSet::new();
|
||||
let decision_quote_symbols =
|
||||
preplanned.get(&execution_date).unwrap_or(&empty_symbols);
|
||||
self.ensure_execution_quotes_for_symbols_at_times(
|
||||
let decision_quote_symbols =
|
||||
self.strategy.decision_quote_symbols(&StrategyContext {
|
||||
execution_date,
|
||||
decision_quote_symbols,
|
||||
&decision_quote_times,
|
||||
)?;
|
||||
} else {
|
||||
let decision_quote_symbols =
|
||||
self.strategy.decision_quote_symbols(&StrategyContext {
|
||||
execution_date,
|
||||
decision_date,
|
||||
decision_index,
|
||||
data: &self.data,
|
||||
portfolio: &portfolio,
|
||||
futures_account: self.futures_account.as_ref(),
|
||||
open_orders: &on_day_open_orders,
|
||||
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_index,
|
||||
data: &self.data,
|
||||
portfolio: &portfolio,
|
||||
futures_account: self.futures_account.as_ref(),
|
||||
open_orders: &on_day_open_orders,
|
||||
dynamic_universe: self.dynamic_universe.as_ref(),
|
||||
subscriptions: &self.subscriptions,
|
||||
process_events: &process_events,
|
||||
active_process_event: None,
|
||||
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,
|
||||
)?;
|
||||
}
|
||||
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(
|
||||
execution_date,
|
||||
@@ -2888,12 +2862,13 @@ where
|
||||
)?;
|
||||
|
||||
if should_run_minute_events(&intraday_schedule_rules, &self.subscriptions)
|
||||
|| (self.broker.has_open_orders() && self.broker.drives_resting_quote_clock())
|
||||
|| ((self.broker.has_open_orders() || self.broker.has_pending_stock_pool_execution()) && self.broker.drives_resting_quote_clock())
|
||||
{
|
||||
let unfiltered_minute_stream = self.subscriptions.is_empty();
|
||||
let mut full_minute_symbols = self.subscriptions.clone();
|
||||
if self.broker.drives_resting_quote_clock() {
|
||||
full_minute_symbols.extend(self.broker.open_order_views().into_iter().map(|order| order.symbol));
|
||||
full_minute_symbols.extend(self.broker.pending_stock_pool_symbols());
|
||||
}
|
||||
if self.execution_quote_loader.is_some() && !full_minute_symbols.is_empty() {
|
||||
let mut minute_symbols = full_minute_symbols.clone();
|
||||
@@ -2980,6 +2955,7 @@ where
|
||||
&& !has_minute_process_listeners
|
||||
&& !schedule_candidate
|
||||
&& !self.has_open_orders()
|
||||
&& !self.broker.has_pending_stock_pool_execution()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -3161,6 +3137,7 @@ where
|
||||
// clock strictly after the event already processed.
|
||||
let mut newly_pending = self.broker.open_order_views().into_iter()
|
||||
.map(|order| order.symbol)
|
||||
.chain(self.broker.pending_stock_pool_symbols())
|
||||
.filter(|symbol| !full_minute_symbols.contains(symbol))
|
||||
.collect::<BTreeSet<_>>();
|
||||
if !newly_pending.is_empty() && self.broker.drives_resting_quote_clock() {
|
||||
@@ -3184,6 +3161,8 @@ where
|
||||
self.data.release_execution_quotes_on_date(execution_date);
|
||||
}
|
||||
|
||||
self.broker.finish_stock_pool_session(execution_date, &mut report);
|
||||
|
||||
portfolio.update_prices_with_options(
|
||||
execution_date,
|
||||
&self.data,
|
||||
|
||||
@@ -26,6 +26,7 @@ pub mod platform_runtime_schema;
|
||||
pub mod platform_strategy_spec;
|
||||
pub mod portfolio;
|
||||
pub mod portfolio_loss;
|
||||
pub mod position_exposure;
|
||||
pub mod risk_control;
|
||||
pub mod rules;
|
||||
pub mod scheduler;
|
||||
|
||||
@@ -3,6 +3,7 @@ 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};
|
||||
@@ -10,7 +11,14 @@ 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<(Cow<'static, str>, f64)>,
|
||||
entries: Vec<(CompactString, f64)>,
|
||||
}
|
||||
|
||||
fn compact_key(key: Cow<'static, str>) -> CompactString {
|
||||
match key {
|
||||
Cow::Borrowed(value) => CompactString::const_new(value),
|
||||
Cow::Owned(value) => CompactString::from(value),
|
||||
}
|
||||
}
|
||||
|
||||
impl NumericFactorMap {
|
||||
@@ -30,16 +38,21 @@ impl NumericFactorMap {
|
||||
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_ref().cmp(key))
|
||||
.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_ref().cmp(key))
|
||||
.binary_search_by(|(name, _)| name.as_str().cmp(key))
|
||||
.ok()
|
||||
.map(|index| &mut self.entries[index].1)
|
||||
}
|
||||
@@ -49,17 +62,21 @@ impl NumericFactorMap {
|
||||
}
|
||||
|
||||
pub fn insert(&mut self, key: Cow<'static, str>, value: f64) -> Option<f64> {
|
||||
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_ref() < key.as_ref())
|
||||
.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_ref().cmp(key.as_ref()))
|
||||
.binary_search_by(|(name, _)| name.as_str().cmp(key.as_str()))
|
||||
{
|
||||
Ok(index) => Some(std::mem::replace(&mut self.entries[index].1, value)),
|
||||
Err(index) => {
|
||||
@@ -71,19 +88,19 @@ impl NumericFactorMap {
|
||||
|
||||
pub fn remove(&mut self, key: &str) -> Option<f64> {
|
||||
self.entries
|
||||
.binary_search_by(|(name, _)| name.as_ref().cmp(key))
|
||||
.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(&Cow<'static, str>, &mut f64) -> bool) {
|
||||
pub fn retain(&mut self, mut keep: impl FnMut(&CompactString, &mut f64) -> bool) {
|
||||
self.entries.retain_mut(|(key, value)| keep(key, value));
|
||||
}
|
||||
|
||||
pub fn iter(&self) -> Iter<'_> {
|
||||
Iter(self.entries.iter())
|
||||
}
|
||||
pub fn keys(&self) -> impl DoubleEndedIterator<Item = &Cow<'static, str>> + ExactSizeIterator {
|
||||
pub fn keys(&self) -> impl DoubleEndedIterator<Item = &CompactString> + ExactSizeIterator {
|
||||
self.entries.iter().map(|(key, _)| key)
|
||||
}
|
||||
pub fn values(&self) -> impl DoubleEndedIterator<Item = &f64> + ExactSizeIterator {
|
||||
@@ -104,9 +121,9 @@ impl Index<&str> for NumericFactorMap {
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Iter<'a>(std::slice::Iter<'a, (Cow<'static, str>, f64)>);
|
||||
pub struct Iter<'a>(std::slice::Iter<'a, (CompactString, f64)>);
|
||||
impl<'a> Iterator for Iter<'a> {
|
||||
type Item = (&'a Cow<'static, str>, &'a f64);
|
||||
type Item = (&'a CompactString, &'a f64);
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
self.0.next().map(|(k, v)| (k, v))
|
||||
}
|
||||
@@ -121,14 +138,14 @@ impl DoubleEndedIterator for Iter<'_> {
|
||||
}
|
||||
impl ExactSizeIterator for Iter<'_> {}
|
||||
impl<'a> IntoIterator for &'a NumericFactorMap {
|
||||
type Item = (&'a Cow<'static, str>, &'a f64);
|
||||
type Item = (&'a CompactString, &'a f64);
|
||||
type IntoIter = Iter<'a>;
|
||||
fn into_iter(self) -> Self::IntoIter {
|
||||
self.iter()
|
||||
}
|
||||
}
|
||||
impl IntoIterator for NumericFactorMap {
|
||||
type Item = (Cow<'static, str>, f64);
|
||||
type Item = (CompactString, f64);
|
||||
type IntoIter = std::vec::IntoIter<Self::Item>;
|
||||
fn into_iter(self) -> Self::IntoIter {
|
||||
self.entries.into_iter()
|
||||
@@ -137,6 +154,11 @@ impl IntoIterator for NumericFactorMap {
|
||||
|
||||
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) {
|
||||
@@ -155,6 +177,11 @@ impl FromIterator<(Cow<'static, str>, f64)> for NumericFactorMap {
|
||||
}
|
||||
impl Extend<(Cow<'static, str>, f64)> for NumericFactorMap {
|
||||
fn extend<T: IntoIterator<Item = (Cow<'static, str>, f64)>>(&mut self, iter: T) {
|
||||
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;
|
||||
@@ -194,9 +221,7 @@ impl<const N: usize> From<[(Cow<'static, str>, f64); N]> for NumericFactorMap {
|
||||
}
|
||||
impl From<BTreeMap<Cow<'static, str>, f64>> for NumericFactorMap {
|
||||
fn from(entries: BTreeMap<Cow<'static, str>, f64>) -> Self {
|
||||
Self {
|
||||
entries: entries.into_iter().collect(),
|
||||
}
|
||||
entries.into_iter().collect()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -219,8 +244,8 @@ impl<'de> Deserialize<'de> for NumericFactorMap {
|
||||
}
|
||||
fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<Self::Value, A::Error> {
|
||||
let mut entries = Vec::new();
|
||||
while let Some((key, value)) = map.next_entry::<String, f64>()? {
|
||||
entries.push((Cow::Owned(key), value));
|
||||
while let Some((key, value)) = map.next_entry::<CompactString, f64>()? {
|
||||
entries.push((key, value));
|
||||
}
|
||||
Ok(entries.into_iter().collect())
|
||||
}
|
||||
@@ -233,6 +258,56 @@ impl<'de> Deserialize<'de> for NumericFactorMap {
|
||||
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();
|
||||
@@ -249,14 +324,14 @@ mod tests {
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
flat.iter().collect::<Vec<_>>(),
|
||||
tree.iter().collect::<Vec<_>>()
|
||||
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().collect::<Vec<_>>(),
|
||||
tree.iter().collect::<Vec<_>>()
|
||||
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>(),
|
||||
@@ -275,8 +350,8 @@ mod tests {
|
||||
let flat: NumericFactorMap = input.clone().into_iter().collect();
|
||||
let tree: BTreeMap<_, _> = input.into_iter().collect();
|
||||
assert_eq!(
|
||||
flat.iter().collect::<Vec<_>>(),
|
||||
tree.iter().collect::<Vec<_>>()
|
||||
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);
|
||||
}
|
||||
@@ -327,13 +402,14 @@ mod tests {
|
||||
flat.extend(incoming.clone());
|
||||
tree.extend(incoming);
|
||||
assert_eq!(
|
||||
flat.iter().collect::<Vec<_>>(),
|
||||
tree.iter().collect::<Vec<_>>()
|
||||
flat.iter().map(|(key, value)| (key.as_str(), value)).collect::<Vec<_>>(),
|
||||
tree.iter().map(|(key, value)| (key.as_ref(), value)).collect::<Vec<_>>()
|
||||
);
|
||||
assert!(matches!(flat.keys().last(), Some(Cow::Borrowed("shared"))));
|
||||
assert_eq!(flat.keys().last().map(CompactString::as_str), Some("shared"));
|
||||
assert!(!flat.keys().last().unwrap().is_heap_allocated());
|
||||
flat.extend([(Cow::Borrowed("zz"), f64::NAN)]);
|
||||
assert!(flat["zz"].is_nan());
|
||||
flat.extend(std::iter::empty());
|
||||
flat.extend(std::iter::empty::<(CompactString, f64)>());
|
||||
assert_eq!(flat.len(), tree.len() + 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ use crate::numeric_expr_vm::{
|
||||
self, EvalError as NumericVmEvalError, Program as NumericVmProgram,
|
||||
Scratch as NumericVmScratch, Value as NumericVmValue, ValueType as NumericVmValueType,
|
||||
};
|
||||
use crate::numeric_factors::NumericFactorMap;
|
||||
use crate::portfolio::PortfolioState;
|
||||
use crate::holding_policy::{AutomaticTradeProtection, AutomaticTradePermission, HoldingLifecycleEvidence};
|
||||
|
||||
@@ -651,6 +652,7 @@ pub struct PlatformExprStrategyConfig {
|
||||
pub buy_scale_expr: String,
|
||||
pub exposure_expr: String,
|
||||
pub position_exposure_schedule: BTreeMap<NaiveDate, f64>,
|
||||
pub position_exposure_timeline: crate::position_exposure::PositionExposureTimeline,
|
||||
pub portfolio_drawdown_control: Option<PlatformPortfolioDrawdownControlConfig>,
|
||||
pub portfolio_loss_control: Option<PortfolioLossConfig>,
|
||||
pub stop_loss_expr: String,
|
||||
@@ -740,6 +742,7 @@ impl PlatformExprStrategyConfig {
|
||||
buy_scale_expr: "1.0".to_string(),
|
||||
exposure_expr: "1.0".to_string(),
|
||||
position_exposure_schedule: BTreeMap::new(),
|
||||
position_exposure_timeline: crate::position_exposure::PositionExposureTimeline::default(),
|
||||
portfolio_drawdown_control: None,
|
||||
portfolio_loss_control: None,
|
||||
stop_loss_expr: String::new(),
|
||||
@@ -845,6 +848,7 @@ fn band_low(index_close) {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn scheduled_position_exposure(
|
||||
schedule: &BTreeMap<NaiveDate, f64>,
|
||||
decision_date: NaiveDate,
|
||||
@@ -957,6 +961,16 @@ struct DayExpressionState {
|
||||
available_text_factor_names: BTreeSet<String>,
|
||||
}
|
||||
|
||||
fn collect_available_factor_names<'a>(names: impl Iterator<Item = &'a str>) -> BTreeSet<String> {
|
||||
// BTreeSet::from_iter first sorts a Vec containing every repeated name.
|
||||
// The daily universe has many rows but usually few distinct factor fields.
|
||||
let mut unique = BTreeSet::new();
|
||||
for name in names {
|
||||
unique.insert(name);
|
||||
}
|
||||
unique.into_iter().map(str::to_owned).collect()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct StockExpressionState {
|
||||
symbol: Arc<str>,
|
||||
@@ -1010,7 +1024,7 @@ struct StockExpressionState {
|
||||
stock_volume_ma60: f64,
|
||||
stock_volume_ma100: f64,
|
||||
current_series_end: Option<usize>,
|
||||
extra_factors: BTreeMap<String, f64>,
|
||||
extra_factors: NumericFactorMap,
|
||||
extra_text_factors: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
@@ -1042,7 +1056,6 @@ impl<'a> StockStateSnapshotSource<'a> for IndexedStockStateSnapshotSource<'a> {
|
||||
}
|
||||
self.data
|
||||
.market_by_symbol_id(self.factor_date, symbol_id)
|
||||
.or_else(|| self.execution_market(symbol_id))
|
||||
}
|
||||
|
||||
fn factor(&self, symbol_id: u32) -> Option<&'a DailyFactorSnapshot> {
|
||||
@@ -1071,7 +1084,6 @@ impl<'a> StockStateSnapshotSource<'a> for ViewStockStateSnapshotSource<'a, '_> {
|
||||
}
|
||||
self.factor
|
||||
.market(symbol_id)
|
||||
.or_else(|| self.execution_market(symbol_id))
|
||||
}
|
||||
|
||||
fn factor(&self, symbol_id: u32) -> Option<&'a DailyFactorSnapshot> {
|
||||
@@ -4386,25 +4398,23 @@ impl PlatformExprStrategy {
|
||||
is_month_start: date.day() == 1,
|
||||
is_month_end,
|
||||
available_factor_names: if self.stock_extra_factors_required {
|
||||
ctx.data
|
||||
collect_available_factor_names(ctx.data
|
||||
.factor_snapshot_rows_on(date)
|
||||
.iter()
|
||||
.flat_map(|row| {
|
||||
row.extra_factors.keys().map(|key| key.to_string()).chain(
|
||||
row.extra_factors.keys().map(|key| key.as_ref()).chain(
|
||||
row.adjustment_factor_backward1
|
||||
.map(|_| BACKWARD_ADJUSTMENT_FACTOR_FIELD.to_string()),
|
||||
.map(|_| BACKWARD_ADJUSTMENT_FACTOR_FIELD),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}))
|
||||
} else {
|
||||
BTreeSet::new()
|
||||
},
|
||||
available_text_factor_names: if self.stock_text_factors_required {
|
||||
ctx.data
|
||||
collect_available_factor_names(ctx.data
|
||||
.factor_text_rows_on(date)
|
||||
.iter()
|
||||
.map(|row| row.field.clone())
|
||||
.collect()
|
||||
.map(|row| row.field.as_str()))
|
||||
} else {
|
||||
BTreeSet::new()
|
||||
},
|
||||
@@ -4736,7 +4746,6 @@ impl PlatformExprStrategy {
|
||||
symbol: symbol.to_string(),
|
||||
})
|
||||
})?;
|
||||
let feature_market = source.feature_market(symbol_id).unwrap_or(market);
|
||||
let factor = source.factor(symbol_id).ok_or_else(|| {
|
||||
BacktestError::Data(crate::data::DataSetError::MissingSnapshot {
|
||||
kind: "factor",
|
||||
@@ -4744,6 +4753,13 @@ impl PlatformExprStrategy {
|
||||
symbol: symbol.to_string(),
|
||||
})
|
||||
})?;
|
||||
let feature_market = source.feature_market(symbol_id).ok_or_else(|| {
|
||||
BacktestError::Data(crate::data::DataSetError::MissingSnapshot {
|
||||
kind: "feature_market",
|
||||
date: factor_date,
|
||||
symbol: symbol.to_string(),
|
||||
})
|
||||
})?;
|
||||
let intraday_same_day_factor = self.uses_intraday_execution_quotes()
|
||||
&& factor_date == date
|
||||
&& !ctx.is_lagged_execution();
|
||||
@@ -4894,12 +4910,12 @@ impl PlatformExprStrategy {
|
||||
.iter()
|
||||
.filter(|(field, _)| {
|
||||
self.stock_extra_factor_map_required
|
||||
|| self.stock_extra_factor_identifiers.contains(field.as_ref())
|
||||
|| self.stock_extra_factor_identifiers.contains(field.as_str())
|
||||
})
|
||||
.map(|(field, value)| (field.to_string(), *value))
|
||||
.map(|(field, value)| (field.clone(), *value))
|
||||
.collect()
|
||||
} else {
|
||||
BTreeMap::new()
|
||||
NumericFactorMap::new()
|
||||
};
|
||||
if !self.config.completed_session_factor_fields.is_empty() {
|
||||
let visible_date = completed_session_factor_date(
|
||||
@@ -4914,7 +4930,7 @@ impl PlatformExprStrategy {
|
||||
.and_then(|row| row.extra_factors.get(field.as_str()))
|
||||
.copied()
|
||||
.unwrap_or(f64::NAN);
|
||||
extra_factors.insert(field.clone(), value);
|
||||
extra_factors.insert(field.clone().into(), value);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4925,7 +4941,7 @@ impl PlatformExprStrategy {
|
||||
.contains(BACKWARD_ADJUSTMENT_FACTOR_FIELD))
|
||||
&& let Some(value) = factor.adjustment_factor_backward1
|
||||
{
|
||||
extra_factors.insert(BACKWARD_ADJUSTMENT_FACTOR_FIELD.to_string(), value);
|
||||
extra_factors.insert(BACKWARD_ADJUSTMENT_FACTOR_FIELD.into(), value);
|
||||
}
|
||||
|
||||
let state = StockExpressionState {
|
||||
@@ -5642,7 +5658,7 @@ impl PlatformExprStrategy {
|
||||
Dynamic::from(stock.stock_volume_ma100),
|
||||
);
|
||||
for (key, value) in &stock.extra_factors {
|
||||
factors.insert(key.clone().into(), Dynamic::from(*value));
|
||||
factors.insert(key.as_str().into(), Dynamic::from(*value));
|
||||
}
|
||||
for (key, value) in &stock.extra_text_factors {
|
||||
factors.insert(key.clone().into(), Dynamic::from(value.clone()));
|
||||
@@ -8640,9 +8656,9 @@ impl PlatformExprStrategy {
|
||||
let strategy_exposure = self
|
||||
.eval_float(ctx, &self.config.exposure_expr, day, None, None)?
|
||||
.clamp(0.0, 1.0);
|
||||
let risk_on_exposure = scheduled_position_exposure(
|
||||
&self.config.position_exposure_schedule,
|
||||
ctx.execution_date,
|
||||
let risk_on_exposure = self.config.position_exposure_timeline.exposure_at(
|
||||
portfolio_loss_decision_at(ctx), ctx.execution_date, &self.config.position_exposure_schedule,
|
||||
strategy_exposure,
|
||||
)
|
||||
.unwrap_or(strategy_exposure)
|
||||
.clamp(0.0, 1.0);
|
||||
@@ -9970,6 +9986,12 @@ impl PlatformExprStrategy {
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(bps)=self.config.position_exposure_timeline.scale_at(portfolio_loss_decision_at(ctx)) {
|
||||
let before=intents.len();
|
||||
intents=intents.into_iter().map(|intent|crate::position_exposure::scale_explicit_intent(intent,bps,ctx.open_orders))
|
||||
.collect::<Result<Vec<_>,_>>().map_err(BacktestError::Execution)?.into_iter().flatten().collect();
|
||||
diagnostics.push(format!("position_override_scale requested_bps={bps} original_intents={before} emitted_intents={}",intents.len()));
|
||||
}
|
||||
Ok((intents, diagnostics))
|
||||
}
|
||||
|
||||
@@ -14546,6 +14568,7 @@ mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use chrono::{NaiveDate, NaiveTime};
|
||||
use rhai::{Dynamic, Map};
|
||||
|
||||
use super::{
|
||||
CompiledRuntimeHelperArgs, PlatformAccountActionKind, PlatformExplicitActionStage,
|
||||
@@ -14573,6 +14596,27 @@ mod tests {
|
||||
NaiveDate::from_ymd_opt(year, month, day).expect("valid date")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn available_factor_name_collection_preserves_sparse_and_repeated_fields() {
|
||||
let fields = ["amount", "model_score", "amount", "adjustment_factor_backward1"];
|
||||
let names = (0..5_000).flat_map(|_| fields.iter().copied());
|
||||
let expected = names.clone().collect::<BTreeSet<_>>()
|
||||
.into_iter().map(str::to_owned).collect::<BTreeSet<_>>();
|
||||
assert_eq!(super::collect_available_factor_names(names), expected);
|
||||
assert!(super::collect_available_factor_names(std::iter::empty()).is_empty());
|
||||
assert_eq!(super::collect_available_factor_names(["today_only"].into_iter()),
|
||||
BTreeSet::from(["today_only".to_string()]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn available_factor_name_collection_preserves_wide_dynamic_field_identity() {
|
||||
let fields = (0..4_000).map(|index| format!("dynamic_{index:04}"))
|
||||
.chain(["Model_score".to_string(), "model_score".to_string()]).collect::<Vec<_>>();
|
||||
let expected = fields.iter().cloned().collect::<BTreeSet<_>>();
|
||||
let names = fields.iter().rev().chain(fields.iter()).map(String::as_str);
|
||||
assert_eq!(super::collect_available_factor_names(names), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn buy_filter_attaches_denials_without_rewriting_selection() {
|
||||
let prev = d(2025, 1, 2);
|
||||
@@ -17542,6 +17586,18 @@ mod tests {
|
||||
.stock_state_with_factor_date(&ctx, date, date, present_symbol)
|
||||
.expect("factor map stock state");
|
||||
assert!(map_stock.extra_factors.contains_key("unused_factor"));
|
||||
let mut numeric_state = (*map_stock).clone();
|
||||
numeric_state.extra_factors.insert("negative_zero".into(), -0.0);
|
||||
numeric_state.extra_factors.insert("undefined_value".into(), f64::NAN);
|
||||
let copied_state = numeric_state.clone();
|
||||
assert_eq!(copied_state.extra_factors["negative_zero"].to_bits(), (-0.0_f64).to_bits());
|
||||
assert!(copied_state.extra_factors["undefined_value"].is_nan());
|
||||
let exposed = copied_state.extra_factors.iter()
|
||||
.map(|(key, value)| (key.as_str().into(), Dynamic::from(*value)))
|
||||
.collect::<Map>();
|
||||
assert_eq!(exposed["negative_zero"].as_float().unwrap().to_bits(), (-0.0_f64).to_bits());
|
||||
assert!(exposed["undefined_value"].as_float().unwrap().is_nan());
|
||||
assert!(!exposed.contains_key("missing_factor"));
|
||||
let map_day = map_strategy
|
||||
.day_state(&ctx, date)
|
||||
.expect("factor map day state");
|
||||
@@ -21231,6 +21287,37 @@ mod tests {
|
||||
assert_eq!(stock.market_cap, 8.0);
|
||||
assert_eq!(stock.market_cap_bn, 8.0);
|
||||
assert!(stock.touched_upper_limit);
|
||||
|
||||
let missing_feature_day = DataSet::from_components(
|
||||
vec![data.instrument(symbol).unwrap().clone()],
|
||||
data.market_snapshot_rows_on(date).to_vec(),
|
||||
data.factor_snapshot_rows_on(factor_date).iter()
|
||||
.chain(data.factor_snapshot_rows_on(date)).cloned().collect(),
|
||||
data.candidate_snapshot_rows_on(date).to_vec(),
|
||||
vec![data.benchmark(date).unwrap().clone()],
|
||||
).unwrap();
|
||||
let gap_ctx = StrategyContext {data: &missing_feature_day, decision_date: factor_date, ..ctx};
|
||||
let mut gap_config = PlatformExprStrategyConfig::generic();
|
||||
gap_config.matching_type = MatchingType::NextBarOpen;
|
||||
gap_config.stock_filter_expr = "close > 10.0".into();
|
||||
let gap_strategy = PlatformExprStrategy::new(gap_config);
|
||||
let indexed = gap_strategy.stock_state_with_factor_date(&gap_ctx, date, factor_date, symbol);
|
||||
assert!(matches!(&indexed, Err(crate::BacktestError::Data(crate::data::DataSetError::MissingSnapshot {
|
||||
kind: "feature_market", date: missing_date, symbol: missing_symbol,
|
||||
})) if *missing_date == factor_date && missing_symbol == symbol),
|
||||
"missing decision-date OHLCV must not become execution-date values: {indexed:?}");
|
||||
let execution_view = missing_feature_day.daily_snapshot_view(date);
|
||||
let feature_view = missing_feature_day.daily_snapshot_view(factor_date);
|
||||
let viewed = gap_strategy.uncached_selection_stock_state_from_views_by_symbol_id(
|
||||
&gap_ctx, date, factor_date, missing_feature_day.symbol_id(symbol).unwrap(), symbol,
|
||||
&execution_view, &feature_view,
|
||||
);
|
||||
assert!(matches!(&viewed, Err(crate::BacktestError::Data(crate::data::DataSetError::MissingSnapshot {
|
||||
kind: "feature_market", date: missing_date, ..
|
||||
})) if *missing_date == factor_date), "daily views must preserve the same missing-date boundary: {viewed:?}");
|
||||
assert!(gap_strategy.stock_state_cache.borrow().is_empty());
|
||||
let same_day = gap_strategy.stock_state_with_factor_date(&gap_ctx, date, date, symbol).unwrap();
|
||||
assert_eq!(same_day.close, 20.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -24872,6 +24959,8 @@ mod tests {
|
||||
],
|
||||
vec![
|
||||
market(factor_date, signal, 10.0, 11.0, 9.0),
|
||||
market(factor_date, limit_symbol, 1.80, 1.98, 1.62),
|
||||
market(factor_date, fallback_symbol, 4.20, 4.62, 3.78),
|
||||
market(decision_date, signal, 10.0, 11.0, 9.0),
|
||||
market(execution_date, signal, 10.0, 11.0, 9.0),
|
||||
market(decision_date, limit_symbol, 2.20, 2.42, 1.98),
|
||||
@@ -25056,6 +25145,8 @@ mod tests {
|
||||
.stock_state_with_factor_date(&ctx, decision_date, factor_date, limit_symbol)
|
||||
.expect("previous factor-day state");
|
||||
assert_eq!(prior_factor_state.amount, 20_000_000.0);
|
||||
assert_eq!(prior_factor_state.close, 1.80);
|
||||
assert_eq!(decision_day_state.close, 2.20);
|
||||
|
||||
let decision = strategy.on_day(&ctx).expect("platform decision");
|
||||
|
||||
|
||||
@@ -204,12 +204,10 @@ impl PlatformExprStrategy {
|
||||
let (base_ratio, reserve_cash) =
|
||||
pool::stock_pool_funding_from_configuration(&program.allocation_policy)
|
||||
.map_err(BacktestError::Execution)?;
|
||||
let ratio = self
|
||||
.config
|
||||
.position_exposure_schedule
|
||||
.range(..=ctx.decision_date)
|
||||
.next_back()
|
||||
.map(|(_, value)| (*value * 10000.).round() as i64)
|
||||
let ratio = self.config.position_exposure_timeline
|
||||
.exposure_at(portfolio_loss_decision_at(ctx), ctx.execution_date, &self.config.position_exposure_schedule,
|
||||
f64::from(base_ratio)/10000.)
|
||||
.map(|value| (value * 10000.).round() as i64)
|
||||
.unwrap_or(i64::from(base_ratio));
|
||||
let invest_ratio_bps = i32::try_from(ratio)
|
||||
.ok()
|
||||
|
||||
@@ -949,6 +949,8 @@ pub struct StrategyExpressionRiskConfig {
|
||||
pub exposure_expr: Option<String>,
|
||||
#[serde(default, alias = "position_exposure_schedule")]
|
||||
pub position_exposure_schedule: Vec<StrategyPositionExposureSchedulePoint>,
|
||||
#[serde(default, alias = "position_exposure_events")]
|
||||
pub position_exposure_events: Vec<crate::position_exposure::PositionExposureEvent>,
|
||||
#[serde(default)]
|
||||
pub portfolio_drawdown_control: Option<StrategyPortfolioDrawdownControlConfig>,
|
||||
#[serde(default)]
|
||||
@@ -2228,6 +2230,7 @@ pub fn platform_expr_config_from_spec(
|
||||
expr.clone()
|
||||
};
|
||||
}
|
||||
cfg.position_exposure_timeline = crate::position_exposure::PositionExposureTimeline::from_events(&risk.position_exposure_events)?;
|
||||
for point in &risk.position_exposure_schedule {
|
||||
let effective_date = NaiveDate::parse_from_str(
|
||||
point.effective_date.trim(),
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ use fidc_core::{
|
||||
Instrument, IntradayExecutionQuote, MatchingType, OrderIntent, PriceField, Strategy, StrategyContext,
|
||||
StrategyDecision,
|
||||
};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::collections::BTreeSet;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
fn d(year: i32, month: u32, day: u32) -> NaiveDate {
|
||||
@@ -163,7 +163,48 @@ fn single_day_quote_plan_data(date: NaiveDate) -> DataSet {
|
||||
}
|
||||
|
||||
#[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 data = single_day_quote_plan_data(date);
|
||||
let broker = BrokerSimulator::new_with_execution_price(
|
||||
@@ -186,10 +227,6 @@ fn engine_uses_preplanned_decision_symbols_without_recomputing_strategy_plan() {
|
||||
symbol_plan_calls: Arc::clone(&symbol_plan_calls),
|
||||
};
|
||||
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)
|
||||
.with_execution_quote_loader(move |request| {
|
||||
*captured_loader_calls.lock().expect("loader counter mutex") += 1;
|
||||
@@ -210,20 +247,19 @@ fn engine_uses_preplanned_decision_symbols_without_recomputing_strategy_plan() {
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
})
|
||||
.collect())
|
||||
})
|
||||
.with_preplanned_decision_quote_symbols_by_date(preplanned);
|
||||
});
|
||||
|
||||
engine.run().expect("backtest should run");
|
||||
|
||||
assert_eq!(
|
||||
*symbol_plan_calls.lock().expect("symbol plan counter mutex"),
|
||||
0,
|
||||
"the strategy plan must not be recomputed after a complete plan is supplied"
|
||||
1,
|
||||
"quote planning must use the actual run context"
|
||||
);
|
||||
assert_eq!(
|
||||
*loader_calls.lock().expect("loader counter mutex"),
|
||||
1,
|
||||
"the supplied symbols must still pass through the normal quote loader"
|
||||
0,
|
||||
"an empty runtime scope must not fetch unrequested symbols"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -628,6 +628,37 @@ fn parsed_pool_program_executes_daily_membership_changes_without_legacy_translat
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pool_position_adjustments_use_execution_clock_and_restore_original_twenty_percent() {
|
||||
for timed in [false,true] {
|
||||
let program=StockPoolProgram { schema_version:1,pool_id:"position-clock".into(),version_id:"v1".into(),
|
||||
members:contract(day(2),1,false).members,exit_signals:vec![],
|
||||
allocation_policy:serde_json::json!({"target_holding_count":1,"invest_ratio_bps":2000,"portfolio_policy":{"schema_version":1,"membership":"follow_candidates","rebalance_weights":true}}),
|
||||
timing_policy:serde_json::json!({"auto_execute":true,"pricing_mode":"first_tick"}),stop_take_policy:serde_json::json!({}),out_of_pool_policy:"hold".into() };
|
||||
let risk=if timed {serde_json::json!({"positionExposureEvents":[
|
||||
{"eventId":"zero","sequence":1,"effectiveAt":"2026-01-05T09:30:00+08:00","action":"set","targetExposureBps":0},
|
||||
{"eventId":"restore","sequence":2,"effectiveAt":"2026-01-06T09:30:00+08:00","action":"restore"}
|
||||
]})}else{serde_json::json!({"positionExposureSchedule":[{"effectiveDate":"2026-01-05","targetExposureBps":1000}]})};
|
||||
let mut config=platform_expr_config_from_value("position-clock",&code(1),&serde_json::json!({
|
||||
"stockPool":program,"signalSymbol":code(1),"benchmark":{"instrumentId":"000300.SH"},"universe":{"include":[code(1),code(2)]},
|
||||
"runtimeExpressions":{"risk":risk}
|
||||
})).unwrap();
|
||||
config.market_cap_field="close".into();config.market_cap_lower_expr="0".into();config.market_cap_upper_expr="1.0e12".into();
|
||||
config.stock_filter_expr="true".into();config.selection_limit_expr="1".into();config.selection_candidate_limit_expr="2".into();config.rank_expr="0".into();
|
||||
config.matching_type=MatchingType::NextBarOpen;
|
||||
let result=BacktestEngine::new(data(false),PlatformExprStrategy::new(config),broker(false),BacktestConfig {
|
||||
// The raw engine retains its first signal day as a cash baseline;
|
||||
// Jan 2's signal executes Jan 5, across the fixture weekend.
|
||||
initial_cash:30000.,benchmark_code:"000300.SH".into(),start_date:Some(day(2)),end_date:Some(day(6)),
|
||||
decision_lag_trading_days:1,execution_price_field:PriceField::Open,
|
||||
}).run().unwrap();
|
||||
assert_eq!(result.fills.len(),1,"timed={timed}, fills={:?}",result.fills);
|
||||
assert_eq!(result.fills[0].symbol,code(1));
|
||||
assert_eq!(result.fills[0].quantity,if timed {300}else{100});
|
||||
assert_eq!(result.fills[0].date,if timed {day(6)}else{day(5)});
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parsed_typed_exit_program_keeps_ordinary_gates_and_independent_risk_targets_separate() {
|
||||
for (ordinary, risk, quote, sold) in [
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,109 @@
|
||||
# 表达式上下文性能验收
|
||||
|
||||
## 范围
|
||||
|
||||
本轮优化 FIDC 引擎的逐股票表达式上下文,不修改策略、因子值、窗口、时间可见性、
|
||||
选股/订单规则、费用、成交价、风控或公司行为。Source 保持 `d5b682c6d097`,
|
||||
研究和信号保持暂停。其他用户任务只读观察,不更改其进程、亲和性或配置。
|
||||
|
||||
已完成编译、回归、正式回放与177发布验收,不能据此关闭整个目标。
|
||||
|
||||
## 重复开销
|
||||
|
||||
1. DataSet 已采用 `NumericFactorMap`,但 `StockExpressionState` 仍把数值因子
|
||||
重建为 `BTreeMap<String, f64>`,增加树节点和字符串分配。
|
||||
2. 每日可用因子名集合先为每个证券复制名称,再由集合丢弃重复名称。
|
||||
|
||||
候选在表达式上下文延续紧凑数值存储。每日名称仍按当日真实字段生成完整集合,
|
||||
仅改为先对借用名称去重,再为唯一名称分配字符串;文本因子同理。
|
||||
没有用全局/未来日期目录替代当日字段,没有缓存选股结果或账户状态。
|
||||
Rhai `factors[...]`、缺失、NaN、负零、别名、覆盖顺序与已完成交易日可见性保持原行为。
|
||||
|
||||
## CPU 计时
|
||||
|
||||
Runner 新增以下运行计时,HTTP benchmark 同样保留:
|
||||
|
||||
- `engineTaskWallSeconds`:实际引擎同步任务的墙钟耗时,包含其同步数据读取等待。
|
||||
- `engineThreadCpuSeconds`:Linux `CLOCK_THREAD_CPUTIME_ID` 实测的引擎调用线程CPU时间。
|
||||
不包含其他并行线程、I/O等待或未被调度的时间;不可当作整个进程总CPU时间。
|
||||
读取不可用、跨线程或时钟异常保持null,不填0。
|
||||
- `engineTaskCount`:实际执行引擎的次数,包含原有修复循环的重放。
|
||||
|
||||
这些是 `engineSeconds` 的子指标,禁止再次加到总耗时。正常耗时与诊断运行分开保存。
|
||||
计时不改写历史制品,旧记录缺少该指标时保持未知。
|
||||
|
||||
## 验收设置
|
||||
|
||||
- 固定引擎基线 `fe7243b`;候选为177的 `07b7b18`,对应本地 `df1862e`。
|
||||
- 两边使用同一计时版service `a9df11a`;`b5d22ff`仅补充benchmark字段读取。
|
||||
- 2021-08-23至2025-11-17、初始1000万、原冻结runtime与策略,1025个执行交易日。
|
||||
- 保留原 `session_capacity_audit`,不能当作实际开盘流动性验证。
|
||||
- 官方benchmark入口、Boris执行、同CPU资源与Source版本、新进程、相同数据缓存副本、
|
||||
新结果目录,不复用回测结果。
|
||||
- 引擎780项、runner408项、API113项、脚本10项通过;9/8/3项手动或外部环境用例分别忽略。
|
||||
- 专项延伸验证紧凑因子的克隆、Rhai映射暴露、缺失、NaN及负零;CPU计时验证睡眠和跨线程边界。
|
||||
|
||||
证据根:`/srv/fidc/canonical/run/research/engine-context-20260913`。
|
||||
|
||||
## 独立进程对照
|
||||
|
||||
| 次序 | 样本 | 完整墙钟 | Source校验 | 数据准备 | 引擎墙钟 | 引擎线程CPU |
|
||||
|---|---|---:|---:|---:|---:|---:|
|
||||
| 1 | control-1 | 31.234s | 11.202s | 8.393s | 10.461s | 10.458s |
|
||||
| 2 | candidate-1 | 17.002s | 0.004s | 8.404s | 7.406s | 7.404s |
|
||||
| 3 | candidate-2 | 18.203s | 0.003s | 8.371s | 7.411s | 7.408s |
|
||||
| 4 | control-2 | 22.983s | 0.004s | 8.323s | 13.401s | 13.398s |
|
||||
| 5 | control-3 | 30.714s | 0.005s | 15.318s | 13.999s | 13.990s |
|
||||
| 6 | candidate-3 | 25.336s | 0.005s | 13.524s | 10.471s | 10.468s |
|
||||
|
||||
首个基线的Source校验等待原样保留,不事后改称预热,不把11.202秒归因于引擎改动。
|
||||
后段样本出现主机负载/缓存竞争变化,数据准备也变慢,不能直接用全组平均墙钟夸大提速。
|
||||
相邻低负载对照的引擎线程CPU为10.458至7.404秒,后段为13.990至10.468秒。
|
||||
CPU计时与任务墙钟非常接近,证明样本主要在执行CPU工作,而不是等待HTTP;
|
||||
这不代表没有SMT、内存带宽或其他用户CPU竞争。
|
||||
|
||||
六次均为21,393笔成交,账户、权益、委托、成交、持仓和风控canonical及结果制品完全一致。
|
||||
每份63个数据缓存文件经完整SHA核对相同,没有复制或读取旧回测结果。
|
||||
|
||||
## HTTP 对照
|
||||
|
||||
| 状态 | 版本 | 运行ID | 总耗时 | 引擎耗时 |
|
||||
|---|---|---|---:|---:|
|
||||
| 清DataSet,磁盘/Source保持 | 原版 | btr_1789232559582_3166774_4 | 21.987s | 11.328s |
|
||||
| 清DataSet,磁盘/Source保持 | 原版 | btr_1789232585690_3166774_5 | 21.684s | 11.259s |
|
||||
| 复用DataSet | 原版 | btr_1789232669598_3166774_6 | 11.820s | 11.031s |
|
||||
| 复用DataSet | 原版 | btr_1789232684861_3166774_7 | 11.857s | 11.067s |
|
||||
| 清DataSet,磁盘/Source保持 | 新版 | btr_1789232818009_3320588_0 | 17.296s | 7.537s |
|
||||
| 清DataSet,磁盘/Source保持 | 新版 | btr_1789232839269_3320588_1 | 17.413s | 7.627s |
|
||||
| 复用DataSet | 新版 | btr_1789232898983_3320588_2 | 8.549s | 7.738s |
|
||||
| 复用DataSet | 新版 | btr_1789232910904_3320588_3 | 8.586s | 7.784s |
|
||||
|
||||
同状态HTTP均值:重建DataSet从21.836至17.355秒,减少约20.5%;
|
||||
复用DataSet从11.839至8.568秒,减少约27.6%。两种状态分开比较,
|
||||
没有把8.568秒当作Source冷启动成绩。与上一轮不同时间的15/17秒样本不作直接百分比对比。
|
||||
|
||||
原版API没有线程CPU字段,保持null;新版本每次实际执行引擎一次,
|
||||
两次重建的线程CPU为7.535/7.624秒。没有用新版本计时回填旧记录。
|
||||
八次HTTP和六次独立回放的canonical及结果制品SHA全部相同,终态审计clean。
|
||||
|
||||
## 发布状态
|
||||
|
||||
177通过官方安装器发布 engine `07b7b181b60138c6ef1c965543c0e3192ac65903`、
|
||||
service `b5d22ffab16f851eced3028e12fa02627ee4c399`。
|
||||
运行身份 `fdd8652a47a5935be4d891beb3b8b0f3e19a468be166a902a2a97b85a9c9e01e`。
|
||||
|
||||
- API SHA:`bf22f58946c3fa495161eb381a400d4e28d7c8d327ee46f5645d83a8308117cf`。
|
||||
- Runner SHA:`7b3849cd8af33d650db242add80c49cfdd32e8cc8686a614da7b3b4016ce2a60`。
|
||||
- 生产在用构建根:`/srv/fidc/canonical/build/engine-context-candidate-20260913`,禁止清理。
|
||||
- 原生因子能力目录发布前后字节相同,SHA为
|
||||
`cec37331a476bc39bdea32c308581b8ac2f86d005d8dd4cc7ba228c5d9dc9a2e`。
|
||||
- API PID3320588,Boris、active、NRestarts=0;Source仍为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冻结解除。
|
||||
@@ -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,再配套发布。优先级仍可在真实资金或仓位约束不足时影响分配,不能预设所有不同排序的结果必须相同。
|
||||
Reference in New Issue
Block a user