Compare commits
37 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 237ee15a51 | |||
| 3a3091a2cf | |||
| d2aa16a2f0 | |||
| 0576cf9b6d | |||
| 636e0dfd05 | |||
| 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 | |||
| 875e31f71f | |||
| 61bd14d001 |
+447
-73
@@ -216,6 +216,9 @@ struct OpenOrder {
|
||||
commission_remaining: Option<f64>,
|
||||
execution_cursor: Option<NaiveDateTime>,
|
||||
reason: String,
|
||||
algo_request: Option<AlgoExecutionRequest>,
|
||||
value_budget: Option<f64>,
|
||||
reserved_cash: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
@@ -420,6 +423,15 @@ struct AlgoExecutionRequest {
|
||||
style: AlgoExecutionStyle,
|
||||
start_time: Option<NaiveTime>,
|
||||
end_time: Option<NaiveTime>,
|
||||
total_quantity: Option<u32>,
|
||||
filled_quantity: u32,
|
||||
commission_remaining: Option<f64>,
|
||||
order_id: Option<u64>,
|
||||
}
|
||||
|
||||
struct RestoreCell<'a, T: Copy>(&'a Cell<T>, T);
|
||||
impl<T: Copy> Drop for RestoreCell<'_, T> {
|
||||
fn drop(&mut self) { self.0.set(self.1); }
|
||||
}
|
||||
|
||||
pub struct BrokerSimulator<C, R> {
|
||||
@@ -427,6 +439,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,
|
||||
@@ -448,6 +462,9 @@ pub struct BrokerSimulator<C, R> {
|
||||
intraday_execution_start_time: Option<NaiveTime>,
|
||||
runtime_intraday_start_time: Cell<Option<NaiveTime>>,
|
||||
runtime_intraday_end_time: Cell<Option<NaiveTime>>,
|
||||
runtime_execution_clock: Cell<Option<NaiveTime>>,
|
||||
runtime_algo_schedule: Cell<Option<AlgoExecutionRequest>>,
|
||||
runtime_unprocessed_algorithm_cash: Cell<FixedMoney>,
|
||||
runtime_decision_date: Cell<Option<NaiveDate>>,
|
||||
runtime_buy_denials: RefCell<BTreeMap<String, String>>,
|
||||
runtime_auto_buy_denials: RefCell<BTreeMap<String, String>>,
|
||||
@@ -469,6 +486,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,
|
||||
@@ -490,6 +509,9 @@ impl<C, R> BrokerSimulator<C, R> {
|
||||
intraday_execution_start_time: None,
|
||||
runtime_intraday_start_time: Cell::new(None),
|
||||
runtime_intraday_end_time: Cell::new(None),
|
||||
runtime_execution_clock: Cell::new(None),
|
||||
runtime_algo_schedule: Cell::new(None),
|
||||
runtime_unprocessed_algorithm_cash: Cell::new(FixedMoney::ZERO),
|
||||
runtime_decision_date: Cell::new(None),
|
||||
runtime_buy_denials: RefCell::new(BTreeMap::new()),
|
||||
runtime_auto_buy_denials: RefCell::new(BTreeMap::new()),
|
||||
@@ -515,6 +537,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,
|
||||
@@ -536,6 +560,9 @@ impl<C, R> BrokerSimulator<C, R> {
|
||||
intraday_execution_start_time: None,
|
||||
runtime_intraday_start_time: Cell::new(None),
|
||||
runtime_intraday_end_time: Cell::new(None),
|
||||
runtime_execution_clock: Cell::new(None),
|
||||
runtime_algo_schedule: Cell::new(None),
|
||||
runtime_unprocessed_algorithm_cash: Cell::new(FixedMoney::ZERO),
|
||||
runtime_decision_date: Cell::new(None),
|
||||
runtime_buy_denials: RefCell::new(BTreeMap::new()),
|
||||
runtime_auto_buy_denials: RefCell::new(BTreeMap::new()),
|
||||
@@ -720,6 +747,10 @@ impl<C, R> BrokerSimulator<C, R> {
|
||||
.or(self.intraday_execution_start_time)
|
||||
}
|
||||
|
||||
fn execution_clock(&self) -> Option<NaiveTime> {
|
||||
self.runtime_execution_clock.get().or(self.runtime_intraday_start_time.get())
|
||||
}
|
||||
|
||||
fn order_origin(&self) -> (Option<NaiveDate>, Option<NaiveTime>) {
|
||||
self.runtime_resting_order_origin.get().map_or(
|
||||
(self.runtime_order_created_date.get(), self.submission_time()),
|
||||
@@ -778,7 +809,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
|
||||
@@ -892,6 +923,7 @@ impl<C, R> BrokerSimulator<C, R> {
|
||||
avg_price: 0.0,
|
||||
transaction_cost: 0.0,
|
||||
limit_price: order.limit_price,
|
||||
reserved_cash: order.reserved_cash,
|
||||
reason: order.reason.clone(),
|
||||
})
|
||||
.collect()
|
||||
@@ -901,14 +933,21 @@ 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;
|
||||
NaiveTime::from_hms_opt(15, if post_close { 30 } else { 0 }, 0).expect("session end")
|
||||
let close=NaiveTime::from_hms_opt(15, if post_close { 30 } else { 0 }, 0).expect("session end");
|
||||
order.algo_request.and_then(|request|request.end_time).map_or(close,|end|end.min(close))
|
||||
}
|
||||
|
||||
pub(crate) fn next_day_order_expiry(&self, date: NaiveDate) -> Option<NaiveTime> {
|
||||
self.open_orders.borrow().iter().filter(|order| order.time_in_force == OrderTimeInForce::Day)
|
||||
self.open_orders.borrow().iter().filter(|order| order.time_in_force == OrderTimeInForce::Day || order.algo_request.is_some())
|
||||
.map(|order| self.resting_order_session_close(date, order)).min()
|
||||
}
|
||||
}
|
||||
@@ -1479,7 +1518,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 +1636,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 +1653,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
|
||||
@@ -1783,6 +1830,37 @@ where
|
||||
)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn execute_coarse_at_clock(
|
||||
&self,
|
||||
date: NaiveDate,
|
||||
decision_date: NaiveDate,
|
||||
order_created_date: NaiveDate,
|
||||
decision_total_equity: Option<f64>,
|
||||
portfolio: &mut PortfolioState,
|
||||
data: &DataSet,
|
||||
decision: &StrategyDecision,
|
||||
clock: Option<NaiveTime>,
|
||||
) -> Result<BrokerExecutionReport, BacktestError> {
|
||||
// Advancing the engine clock must not turn a daily closing-bar order
|
||||
// into an explicitly submitted post-close order.
|
||||
let _clock_guard = RestoreCell(
|
||||
&self.runtime_execution_clock,
|
||||
self.runtime_execution_clock.replace(clock),
|
||||
);
|
||||
self.execute_between_with_event_dates_and_decision_equity(
|
||||
date,
|
||||
decision_date,
|
||||
order_created_date,
|
||||
decision_total_equity,
|
||||
portfolio,
|
||||
data,
|
||||
decision,
|
||||
None,
|
||||
clock,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn execute_between_with_event_dates(
|
||||
&self,
|
||||
date: NaiveDate,
|
||||
@@ -2662,18 +2740,26 @@ where
|
||||
let mut open_orders = self.open_orders.borrow_mut();
|
||||
std::mem::take(&mut *open_orders)
|
||||
};
|
||||
let reserved=FixedMoney::checked_sum_f64(pending_orders.iter().filter_map(|order|order.reserved_cash))
|
||||
.ok_or_else(||BacktestError::Execution("working order cash reservation is invalid".into()))?;
|
||||
let _reservation_guard=RestoreCell(&self.runtime_unprocessed_algorithm_cash,
|
||||
self.runtime_unprocessed_algorithm_cash.replace(reserved));
|
||||
for order in pending_orders {
|
||||
if let Some(reserved)=order.reserved_cash {
|
||||
self.runtime_unprocessed_algorithm_cash.set(self.runtime_unprocessed_algorithm_cash.get()
|
||||
.checked_sub(FixedMoney::from_f64(reserved).expect("validated reservation")).expect("reserved cash subset"));
|
||||
}
|
||||
if self.matching_type == MatchingType::NextBarOpen && self.runtime_intraday_start_time.get().is_none()
|
||||
&& order.accepted_date == date {
|
||||
&& order.accepted_date == date && order.algo_request.is_none() {
|
||||
self.open_orders.borrow_mut().push(order);
|
||||
continue;
|
||||
}
|
||||
let close = self.resting_order_session_close(date, &order);
|
||||
let clock = self.submission_time();
|
||||
let past_day = order.time_in_force == OrderTimeInForce::Day
|
||||
let clock = self.execution_clock().or(self.submission_time());
|
||||
let past_day = (order.time_in_force == OrderTimeInForce::Day || order.algo_request.is_some())
|
||||
&& order.accepted_date < date;
|
||||
if past_day || clock.is_some_and(|time| time > close) {
|
||||
if order.time_in_force == OrderTimeInForce::Day {
|
||||
if order.time_in_force == OrderTimeInForce::Day || order.algo_request.is_some() {
|
||||
Self::emit_resting_day_expiry(report, date, &order, order.filled_quantity);
|
||||
} else {
|
||||
self.open_orders.borrow_mut().push(order);
|
||||
@@ -2710,7 +2796,18 @@ where
|
||||
accepted_date: order.accepted_date,
|
||||
}));
|
||||
let previous_decision_date = self.runtime_decision_date.replace(order.decision_date);
|
||||
let execution_result = self.process_limit_shares_internal(
|
||||
let execution_result = if let Some(mut algorithm)=order.algo_request {
|
||||
algorithm.total_quantity=Some(order.requested_quantity);
|
||||
algorithm.filled_quantity=order.filled_quantity;
|
||||
algorithm.commission_remaining=order.commission_remaining;
|
||||
if order.side==OrderSide::Buy {
|
||||
self.process_buy(date,portfolio,data,&order.symbol,order.remaining_quantity,order.order_id,&order.reason,
|
||||
intraday_turnover,execution_cursors,global_execution_cursor,commission_state,order.value_budget,None,false,false,Some(&algorithm),report)
|
||||
} else {
|
||||
self.process_sell(date,portfolio,data,&order.symbol,order.remaining_quantity,order.order_id,&order.reason,
|
||||
intraday_turnover,execution_cursors,global_execution_cursor,commission_state,None,false,false,Some(&algorithm),report)
|
||||
}
|
||||
} else { self.process_limit_shares_internal(
|
||||
date,
|
||||
portfolio,
|
||||
data,
|
||||
@@ -2725,7 +2822,7 @@ where
|
||||
global_execution_cursor,
|
||||
commission_state,
|
||||
report,
|
||||
);
|
||||
) };
|
||||
self.runtime_time_in_force.set(previous_time_in_force);
|
||||
self.runtime_resting_order_origin.set(previous_origin);
|
||||
self.runtime_decision_date.set(previous_decision_date);
|
||||
@@ -2823,7 +2920,8 @@ where
|
||||
}
|
||||
|
||||
fn emit_resting_day_expiry(report: &mut BrokerExecutionReport, date: NaiveDate, order: &OpenOrder, filled: u32) {
|
||||
let detail = format!("DAY order expired at market close: {} remaining_quantity={}", order.symbol, order.requested_quantity.saturating_sub(filled));
|
||||
let label=if order.algo_request.is_some() {"algorithm execution window expired"} else {"DAY order expired at market close"};
|
||||
let detail = format!("{label}: {} remaining_quantity={}", order.symbol, order.requested_quantity.saturating_sub(filled));
|
||||
report.order_events.push(OrderEvent {
|
||||
date, decision_date: order.decision_date, order_created_date: order.order_created_date,
|
||||
execution_date: Some(date), order_id: Some(order.order_id), symbol: order.symbol.clone(),
|
||||
@@ -2909,6 +3007,11 @@ where
|
||||
|
||||
let target_total_quantity = new_total_quantity.unwrap_or(existing.requested_quantity);
|
||||
let target_limit_price = new_limit_price.unwrap_or(existing.limit_price);
|
||||
if existing.algo_request.is_some() {
|
||||
Self::emit_open_order_update_rejected(report,date,order_id,Some(&existing.symbol),Some(existing.side),reason,
|
||||
"algorithm schedule is immutable; cancel it before submitting a different schedule");
|
||||
return;
|
||||
}
|
||||
if target_total_quantity == existing.requested_quantity
|
||||
&& target_limit_price.to_bits() == existing.limit_price.to_bits()
|
||||
{
|
||||
@@ -3878,6 +3981,10 @@ where
|
||||
},
|
||||
start_time: *start_time,
|
||||
end_time: *end_time,
|
||||
total_quantity: None,
|
||||
filled_quantity: 0,
|
||||
commission_remaining: None,
|
||||
order_id: None,
|
||||
}),
|
||||
_ => None,
|
||||
};
|
||||
@@ -4149,13 +4256,12 @@ 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);
|
||||
let start_cursor = algo_request
|
||||
.and_then(|request| request.start_time)
|
||||
.or(self.runtime_intraday_start_time.get())
|
||||
let start_cursor = self.execution_clock()
|
||||
.or_else(||algo_request.and_then(|request| request.start_time))
|
||||
.or(self.intraday_execution_start_time)
|
||||
.map(|start_time| date.and_time(start_time));
|
||||
self.latest_known_quote_at_or_before(
|
||||
@@ -4167,7 +4273,9 @@ where
|
||||
false,
|
||||
)
|
||||
.and_then(|quote| self.select_quote_reference_price(snapshot, quote, side, matching_type))
|
||||
.unwrap_or_else(|| self.execution_limit_check_price(snapshot, side))
|
||||
.unwrap_or_else(|| if algo_request.is_some() && self.execution_clock().is_some() {
|
||||
f64::NAN
|
||||
} else {self.execution_limit_check_price(snapshot, side)})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -4514,6 +4622,8 @@ where
|
||||
algo_request: Option<&AlgoExecutionRequest>,
|
||||
report: &mut BrokerExecutionReport,
|
||||
) -> Result<(), BacktestError> {
|
||||
let algorithm = self.normalized_algorithm(date, requested_qty, order_id, commission_state.get(&order_id).copied(), algo_request);
|
||||
let algo_request = algorithm.as_ref();
|
||||
// Existing accepted orders are not canceled by a subsequently enabled lock.
|
||||
if emit_creation_events && self.runtime_auto_sell_denials.borrow().contains_key(symbol) {
|
||||
return Ok(());
|
||||
@@ -4735,7 +4845,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)),
|
||||
@@ -4748,6 +4858,9 @@ where
|
||||
time_in_force: Self::pending_time_in_force(remainder_policy),
|
||||
commission_remaining: commission_state.get(&order_id).copied(),
|
||||
execution_cursor: execution_cursors.get(symbol).copied(),
|
||||
algo_request: None,
|
||||
value_budget: None,
|
||||
reserved_cash: None,
|
||||
reason: reason.to_string(),
|
||||
});
|
||||
// Waiting without a fill is not a new order-state transition.
|
||||
@@ -4826,7 +4939,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)),
|
||||
@@ -4839,6 +4952,9 @@ where
|
||||
time_in_force: Self::pending_time_in_force(remainder_policy),
|
||||
commission_remaining: commission_state.get(&order_id).copied(),
|
||||
execution_cursor: execution_cursors.get(symbol).copied(),
|
||||
algo_request: None,
|
||||
value_budget: None,
|
||||
reserved_cash: None,
|
||||
reason: reason.to_string(),
|
||||
});
|
||||
// Waiting without a fill is not a new order-state transition.
|
||||
@@ -4956,8 +5072,8 @@ where
|
||||
price: execution_price,
|
||||
mark_price: self.snapshot_mark_price(snapshot, OrderSide::Sell),
|
||||
quantity: fillable_qty,
|
||||
execution_start_timestamp: None,
|
||||
execution_timestamp: None,
|
||||
execution_start_timestamp: self.runtime_execution_clock.get().map(|time|date.and_time(time)),
|
||||
execution_timestamp: self.runtime_execution_clock.get().map(|time|date.and_time(time)),
|
||||
}],
|
||||
None,
|
||||
Vec::new(),
|
||||
@@ -4994,12 +5110,13 @@ where
|
||||
let detail = partial_fill_reason
|
||||
.as_deref()
|
||||
.unwrap_or("limit price not marketable yet");
|
||||
if Self::keeps_remainder_open(remainder_policy)
|
||||
&& Self::limit_order_can_remain_open(Some(detail))
|
||||
if (Self::keeps_remainder_open(remainder_policy)
|
||||
&& Self::limit_order_can_remain_open(Some(detail)))
|
||||
|| self.algorithm_still_working(algo_request, Some(detail))
|
||||
{
|
||||
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)),
|
||||
@@ -5008,10 +5125,13 @@ where
|
||||
requested_quantity: requested_qty,
|
||||
filled_quantity: 0,
|
||||
remaining_quantity: requested_qty,
|
||||
limit_price: limit_price.expect("limit price for pending limit sell"),
|
||||
time_in_force: Self::pending_time_in_force(remainder_policy),
|
||||
limit_price: if algo_request.is_some() {limit_price.unwrap_or(0.0)} else {limit_price.expect("limit price for pending limit sell")},
|
||||
time_in_force: if algo_request.is_some() {self.runtime_time_in_force.get().unwrap_or(OrderTimeInForce::Day)} else {Self::pending_time_in_force(remainder_policy)},
|
||||
commission_remaining: commission_state.get(&order_id).copied(),
|
||||
execution_cursor: execution_cursors.get(symbol).copied(),
|
||||
algo_request: Self::progressed_algorithm(algo_request, 0, commission_state.get(&order_id).copied()),
|
||||
value_budget: None,
|
||||
reserved_cash: None,
|
||||
reason: reason.to_string(),
|
||||
});
|
||||
// Waiting without a fill is not a new order-state transition.
|
||||
@@ -5052,7 +5172,7 @@ where
|
||||
side: OrderSide::Sell,
|
||||
requested_quantity: requested_qty,
|
||||
filled_quantity: 0,
|
||||
status: zero_fill_status_for_reason(detail),
|
||||
status: self.unfilled_algorithm_status(algo_request, detail),
|
||||
reason: format!("{reason}: {detail}"),
|
||||
});
|
||||
Self::emit_order_process_event(
|
||||
@@ -5064,7 +5184,7 @@ where
|
||||
OrderSide::Sell,
|
||||
format!(
|
||||
"status={:?} reason={detail}",
|
||||
zero_fill_status_for_reason(detail)
|
||||
self.unfilled_algorithm_status(algo_request, detail)
|
||||
),
|
||||
);
|
||||
self.clear_open_order(order_id);
|
||||
@@ -5165,13 +5285,14 @@ where
|
||||
*intraday_turnover.entry(symbol.to_string()).or_default() += filled_qty;
|
||||
|
||||
let remaining_qty = requested_qty.saturating_sub(filled_qty);
|
||||
let keep_open = Self::keeps_remainder_open(remainder_policy)
|
||||
let keep_open = (Self::keeps_remainder_open(remainder_policy)
|
||||
&& remaining_qty > 0
|
||||
&& Self::limit_order_can_remain_open(partial_fill_reason.as_deref());
|
||||
&& Self::limit_order_can_remain_open(partial_fill_reason.as_deref()))
|
||||
|| (remaining_qty > 0 && self.algorithm_still_working(algo_request,partial_fill_reason.as_deref()));
|
||||
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)),
|
||||
@@ -5180,10 +5301,13 @@ where
|
||||
requested_quantity: requested_qty,
|
||||
filled_quantity: filled_qty,
|
||||
remaining_quantity: remaining_qty,
|
||||
limit_price: limit_price.expect("limit price for pending limit sell"),
|
||||
time_in_force: Self::pending_time_in_force(remainder_policy),
|
||||
limit_price: if algo_request.is_some() {limit_price.unwrap_or(0.0)} else {limit_price.expect("limit price for pending limit sell")},
|
||||
time_in_force: if algo_request.is_some() {self.runtime_time_in_force.get().unwrap_or(OrderTimeInForce::Day)} else {Self::pending_time_in_force(remainder_policy)},
|
||||
commission_remaining: commission_state.get(&order_id).copied(),
|
||||
execution_cursor: execution_cursors.get(symbol).copied(),
|
||||
algo_request: Self::progressed_algorithm(algo_request, filled_qty, commission_state.get(&order_id).copied()),
|
||||
value_budget: None,
|
||||
reserved_cash: None,
|
||||
reason: reason.to_string(),
|
||||
});
|
||||
} else {
|
||||
@@ -5193,7 +5317,7 @@ where
|
||||
let status = if keep_open {
|
||||
OrderStatus::PartiallyFilled
|
||||
} else if filled_qty < requested_qty {
|
||||
OrderStatus::Canceled
|
||||
if self.algorithm_window_expired(algo_request, partial_fill_reason.as_deref().unwrap_or("")) {OrderStatus::Expired} else {OrderStatus::Canceled}
|
||||
} else {
|
||||
OrderStatus::Filled
|
||||
};
|
||||
@@ -5230,7 +5354,7 @@ where
|
||||
status,
|
||||
reason: order_reason,
|
||||
});
|
||||
if matches!(status, OrderStatus::Canceled | OrderStatus::Rejected) {
|
||||
if matches!(status, OrderStatus::Canceled | OrderStatus::Rejected | OrderStatus::Expired) {
|
||||
Self::emit_order_process_event(
|
||||
report,
|
||||
date,
|
||||
@@ -5379,6 +5503,10 @@ where
|
||||
},
|
||||
start_time,
|
||||
end_time,
|
||||
total_quantity: None,
|
||||
filled_quantity: 0,
|
||||
commission_remaining: None,
|
||||
order_id: None,
|
||||
};
|
||||
|
||||
if target_value <= f64::EPSILON {
|
||||
@@ -6060,12 +6188,19 @@ where
|
||||
},
|
||||
start_time,
|
||||
end_time,
|
||||
total_quantity: None,
|
||||
filled_quantity: 0,
|
||||
commission_remaining: None,
|
||||
order_id: None,
|
||||
};
|
||||
if value > 0.0 {
|
||||
let round_lot = self.round_lot(data, symbol);
|
||||
let minimum_order_quantity = self.minimum_order_quantity(data, symbol);
|
||||
let order_step_size = self.order_step_size(data, symbol);
|
||||
let price = self.sizing_price(snapshot);
|
||||
let price = self.execution_order_limit_check_price(date, data, symbol, snapshot, OrderSide::Buy, Some(&algo_request));
|
||||
if !price.is_finite() || price <= 0.0 {
|
||||
return Err(BacktestError::MissingPrice {date, symbol:symbol.to_string(), field:"algorithm_submission_price"});
|
||||
}
|
||||
let snapshot_requested_qty = self.value_buy_quantity(
|
||||
date,
|
||||
value.abs(),
|
||||
@@ -6106,7 +6241,10 @@ where
|
||||
report,
|
||||
)
|
||||
} else {
|
||||
let price = self.sizing_price(snapshot);
|
||||
let price = self.execution_order_limit_check_price(date, data, symbol, snapshot, OrderSide::Sell, Some(&algo_request));
|
||||
if !price.is_finite() || price <= 0.0 {
|
||||
return Err(BacktestError::MissingPrice {date, symbol:symbol.to_string(), field:"algorithm_submission_price"});
|
||||
}
|
||||
let requested_qty = self.round_buy_quantity(
|
||||
(value.abs() / price).floor() as u32,
|
||||
self.minimum_order_quantity(data, symbol),
|
||||
@@ -6317,6 +6455,9 @@ where
|
||||
algo_request: Option<&AlgoExecutionRequest>,
|
||||
report: &mut BrokerExecutionReport,
|
||||
) -> Result<(), BacktestError> {
|
||||
let algorithm = self.normalized_algorithm(date, requested_qty, order_id, commission_state.get(&order_id).copied(), algo_request);
|
||||
let algo_request = algorithm.as_ref();
|
||||
let fill_start = report.fill_events.len();
|
||||
if emit_creation_events && self.runtime_auto_buy_denials.borrow().contains_key(symbol) {
|
||||
return Ok(());
|
||||
}
|
||||
@@ -6559,7 +6700,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)),
|
||||
@@ -6572,6 +6713,9 @@ where
|
||||
time_in_force: Self::pending_time_in_force(remainder_policy),
|
||||
commission_remaining: commission_state.get(&order_id).copied(),
|
||||
execution_cursor: execution_cursors.get(symbol).copied(),
|
||||
algo_request: None,
|
||||
value_budget: None,
|
||||
reserved_cash: None,
|
||||
reason: reason.to_string(),
|
||||
});
|
||||
// Waiting without a fill is not a new order-state transition.
|
||||
@@ -6631,13 +6775,14 @@ where
|
||||
}
|
||||
};
|
||||
let value_gross_limit = self.value_buy_gross_limit(value_budget);
|
||||
let available_cash=self.cash_after_algorithm_reservations(portfolio.cash(),Some(order_id))?;
|
||||
let buy_cash_limit = if self.strict_value_budget {
|
||||
value_budget
|
||||
.filter(|budget| budget.is_finite() && *budget > 0.0)
|
||||
.map(|budget| portfolio.cash().min(budget))
|
||||
.unwrap_or_else(|| portfolio.cash())
|
||||
.map(|budget| available_cash.min(budget))
|
||||
.unwrap_or(available_cash)
|
||||
} else {
|
||||
portfolio.cash()
|
||||
available_cash
|
||||
};
|
||||
|
||||
let fill = self.resolve_execution_fill(
|
||||
@@ -6759,8 +6904,8 @@ where
|
||||
price: execution_price,
|
||||
mark_price: self.snapshot_mark_price(snapshot, OrderSide::Buy),
|
||||
quantity: filled_qty,
|
||||
execution_start_timestamp: None,
|
||||
execution_timestamp: None,
|
||||
execution_start_timestamp: self.runtime_execution_clock.get().map(|time|date.and_time(time)),
|
||||
execution_timestamp: self.runtime_execution_clock.get().map(|time|date.and_time(time)),
|
||||
}],
|
||||
None,
|
||||
Vec::new(),
|
||||
@@ -6794,12 +6939,13 @@ where
|
||||
let detail = partial_fill_reason
|
||||
.as_deref()
|
||||
.unwrap_or("insufficient cash after fees");
|
||||
if Self::keeps_remainder_open(remainder_policy)
|
||||
&& Self::limit_order_can_remain_open(Some(detail))
|
||||
if (Self::keeps_remainder_open(remainder_policy)
|
||||
&& Self::limit_order_can_remain_open(Some(detail)))
|
||||
|| self.algorithm_still_working(algo_request,Some(detail))
|
||||
{
|
||||
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)),
|
||||
@@ -6808,10 +6954,13 @@ where
|
||||
requested_quantity: requested_qty,
|
||||
filled_quantity: 0,
|
||||
remaining_quantity: requested_qty,
|
||||
limit_price: limit_price.expect("limit price for pending limit buy"),
|
||||
time_in_force: Self::pending_time_in_force(remainder_policy),
|
||||
limit_price: if algo_request.is_some() {limit_price.unwrap_or(0.0)} else {limit_price.expect("limit price for pending limit buy")},
|
||||
time_in_force: if algo_request.is_some() {self.runtime_time_in_force.get().unwrap_or(OrderTimeInForce::Day)} else {Self::pending_time_in_force(remainder_policy)},
|
||||
commission_remaining: commission_state.get(&order_id).copied(),
|
||||
execution_cursor: execution_cursors.get(symbol).copied(),
|
||||
algo_request: Self::progressed_algorithm(algo_request, 0, commission_state.get(&order_id).copied()),
|
||||
value_budget: if algo_request.is_some() {value_budget} else {None},
|
||||
reserved_cash: if algo_request.is_some() {Some(self.algorithm_cash_reservation(date,value_budget,requested_qty,size_check_price,order_id,commission_state.get(&order_id).copied(),data.instruments().get(symbol),portfolio.cash())?)} else {None},
|
||||
reason: reason.to_string(),
|
||||
});
|
||||
// Waiting without a fill is not a new order-state transition.
|
||||
@@ -6852,7 +7001,7 @@ where
|
||||
side: OrderSide::Buy,
|
||||
requested_quantity: requested_qty,
|
||||
filled_quantity: 0,
|
||||
status: zero_fill_status_for_reason(detail),
|
||||
status: self.unfilled_algorithm_status(algo_request, detail),
|
||||
reason: format!("{reason}: {detail}"),
|
||||
});
|
||||
Self::emit_order_process_event(
|
||||
@@ -6864,7 +7013,7 @@ where
|
||||
OrderSide::Buy,
|
||||
format!(
|
||||
"status={:?} reason={detail}",
|
||||
zero_fill_status_for_reason(detail)
|
||||
self.unfilled_algorithm_status(algo_request, detail)
|
||||
),
|
||||
);
|
||||
self.clear_open_order(order_id);
|
||||
@@ -6967,13 +7116,14 @@ where
|
||||
*intraday_turnover.entry(symbol.to_string()).or_default() += filled_qty;
|
||||
|
||||
let remaining_qty = requested_qty.saturating_sub(filled_qty);
|
||||
let keep_open = Self::keeps_remainder_open(remainder_policy)
|
||||
let keep_open = (Self::keeps_remainder_open(remainder_policy)
|
||||
&& remaining_qty > 0
|
||||
&& Self::limit_order_can_remain_open(partial_fill_reason.as_deref());
|
||||
&& Self::limit_order_can_remain_open(partial_fill_reason.as_deref()))
|
||||
|| (remaining_qty > 0 && self.algorithm_still_working(algo_request,partial_fill_reason.as_deref()));
|
||||
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)),
|
||||
@@ -6982,10 +7132,13 @@ where
|
||||
requested_quantity: requested_qty,
|
||||
filled_quantity: filled_qty,
|
||||
remaining_quantity: remaining_qty,
|
||||
limit_price: limit_price.expect("limit price for pending limit buy"),
|
||||
time_in_force: Self::pending_time_in_force(remainder_policy),
|
||||
limit_price: if algo_request.is_some() {limit_price.unwrap_or(0.0)} else {limit_price.expect("limit price for pending limit buy")},
|
||||
time_in_force: if algo_request.is_some() {self.runtime_time_in_force.get().unwrap_or(OrderTimeInForce::Day)} else {Self::pending_time_in_force(remainder_policy)},
|
||||
commission_remaining: commission_state.get(&order_id).copied(),
|
||||
execution_cursor: execution_cursors.get(symbol).copied(),
|
||||
algo_request: Self::progressed_algorithm(algo_request, filled_qty, commission_state.get(&order_id).copied()),
|
||||
value_budget: if algo_request.is_some() {self.remaining_algorithm_budget(value_budget,&report.fill_events[fill_start..])?} else {None},
|
||||
reserved_cash: if algo_request.is_some() {Some(self.algorithm_cash_reservation(date,self.remaining_algorithm_budget(value_budget,&report.fill_events[fill_start..])?,remaining_qty,size_check_price,order_id,commission_state.get(&order_id).copied(),data.instruments().get(symbol),portfolio.cash())?)} else {None},
|
||||
reason: reason.to_string(),
|
||||
});
|
||||
} else {
|
||||
@@ -6995,7 +7148,7 @@ where
|
||||
let status = if keep_open {
|
||||
OrderStatus::PartiallyFilled
|
||||
} else if filled_qty < requested_qty {
|
||||
OrderStatus::Canceled
|
||||
if self.algorithm_window_expired(algo_request, partial_fill_reason.as_deref().unwrap_or("")) {OrderStatus::Expired} else {OrderStatus::Canceled}
|
||||
} else {
|
||||
OrderStatus::Filled
|
||||
};
|
||||
@@ -7032,7 +7185,7 @@ where
|
||||
status,
|
||||
reason: order_reason,
|
||||
});
|
||||
if matches!(status, OrderStatus::Canceled | OrderStatus::Rejected) {
|
||||
if matches!(status, OrderStatus::Canceled | OrderStatus::Rejected | OrderStatus::Expired) {
|
||||
Self::emit_order_process_event(
|
||||
report,
|
||||
date,
|
||||
@@ -7549,6 +7702,192 @@ where
|
||||
})
|
||||
}
|
||||
|
||||
fn normalized_algorithm(
|
||||
&self,
|
||||
date: NaiveDate,
|
||||
quantity: u32,
|
||||
order_id: u64,
|
||||
commission: Option<f64>,
|
||||
request: Option<&AlgoExecutionRequest>,
|
||||
) -> Option<AlgoExecutionRequest> {
|
||||
request
|
||||
.copied()
|
||||
.or_else(|| {
|
||||
(self.matching_type == MatchingType::Vwap).then_some(AlgoExecutionRequest {
|
||||
style: AlgoExecutionStyle::Vwap,
|
||||
start_time: self.submission_time(),
|
||||
end_time: None,
|
||||
total_quantity: None,
|
||||
filled_quantity: 0,
|
||||
commission_remaining: None,
|
||||
order_id: None,
|
||||
})
|
||||
})
|
||||
.map(|mut request| {
|
||||
request.total_quantity.get_or_insert(quantity);
|
||||
request.order_id = Some(order_id);
|
||||
request.commission_remaining = commission;
|
||||
if request.start_time.is_none() {
|
||||
request.start_time = self.execution_clock().or(self.submission_time());
|
||||
}
|
||||
if request.end_time.is_none() && request.style == AlgoExecutionStyle::Vwap {
|
||||
request.end_time = Some(
|
||||
self.post_close_execution_window(date)
|
||||
.map(|(_, end)| end.time())
|
||||
.unwrap_or_else(|| {
|
||||
NaiveTime::from_hms_opt(15, 0, 0).expect("cash session close")
|
||||
}),
|
||||
);
|
||||
}
|
||||
request
|
||||
})
|
||||
}
|
||||
|
||||
fn cash_after_algorithm_reservations(
|
||||
&self,
|
||||
cash: f64,
|
||||
except: Option<u64>,
|
||||
) -> Result<f64, BacktestError> {
|
||||
let reserved = FixedMoney::checked_sum_f64(
|
||||
self.open_orders
|
||||
.borrow()
|
||||
.iter()
|
||||
.filter(|order| except != Some(order.order_id))
|
||||
.filter_map(|order| order.reserved_cash),
|
||||
)
|
||||
.and_then(|amount| amount.checked_add(self.runtime_unprocessed_algorithm_cash.get()))
|
||||
.ok_or_else(|| BacktestError::Execution("algorithm reserved cash overflow".into()))?;
|
||||
FixedMoney::from_f64(cash)
|
||||
.and_then(|cash| cash.checked_sub(reserved))
|
||||
.map(|available| available.max(FixedMoney::ZERO).to_f64())
|
||||
.ok_or_else(|| BacktestError::Execution("algorithm available cash is invalid".into()))
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn algorithm_cash_reservation(
|
||||
&self,
|
||||
date: NaiveDate,
|
||||
budget: Option<f64>,
|
||||
quantity: u32,
|
||||
price: f64,
|
||||
order_id: u64,
|
||||
commission: Option<f64>,
|
||||
instrument: Option<&Instrument>,
|
||||
cash: f64,
|
||||
) -> Result<f64, BacktestError> {
|
||||
let available = self.cash_after_algorithm_reservations(cash, Some(order_id))?;
|
||||
if let Some(budget) = budget.filter(|_| self.strict_value_budget) {
|
||||
return Ok(budget.min(available));
|
||||
}
|
||||
let gross = budget.unwrap_or(price * f64::from(quantity));
|
||||
if !gross.is_finite() || gross < 0. {
|
||||
return Err(BacktestError::Execution(
|
||||
"algorithm reservation requires a current price or explicit value budget".into(),
|
||||
));
|
||||
}
|
||||
let mut state = commission
|
||||
.map(|left| (order_id, left))
|
||||
.into_iter()
|
||||
.collect();
|
||||
let cost = self.cost_model.calculate_with_order_state_for_instrument(
|
||||
date,
|
||||
OrderSide::Buy,
|
||||
gross,
|
||||
Some(order_id),
|
||||
&mut state,
|
||||
instrument,
|
||||
);
|
||||
FixedMoney::checked_sum_f64([gross, cost.total()])
|
||||
.map(|amount| amount.to_f64().min(available))
|
||||
.ok_or_else(|| BacktestError::Execution("algorithm cash reservation overflow".into()))
|
||||
}
|
||||
|
||||
fn algorithm_still_working(
|
||||
&self,
|
||||
request: Option<&AlgoExecutionRequest>,
|
||||
reason: Option<&str>,
|
||||
) -> bool {
|
||||
request.is_some_and(|request| {
|
||||
self.runtime_intraday_end_time
|
||||
.get()
|
||||
.zip(request.end_time)
|
||||
.is_some_and(|(clock, end)| clock < end)
|
||||
}) && self
|
||||
.runtime_time_in_force
|
||||
.get()
|
||||
.is_none_or(|tif| matches!(tif, OrderTimeInForce::Day | OrderTimeInForce::Gtc))
|
||||
&& Self::limit_order_can_remain_open(reason)
|
||||
}
|
||||
|
||||
fn unfilled_algorithm_status(
|
||||
&self,
|
||||
request: Option<&AlgoExecutionRequest>,
|
||||
reason: &str,
|
||||
) -> OrderStatus {
|
||||
if self.algorithm_window_expired(request, reason) {
|
||||
OrderStatus::Expired
|
||||
} else {
|
||||
zero_fill_status_for_reason(reason)
|
||||
}
|
||||
}
|
||||
|
||||
fn algorithm_window_expired(
|
||||
&self,
|
||||
request: Option<&AlgoExecutionRequest>,
|
||||
reason: &str,
|
||||
) -> bool {
|
||||
request.is_some_and(|request| {
|
||||
self.runtime_intraday_end_time
|
||||
.get()
|
||||
.zip(request.end_time)
|
||||
.is_some_and(|(clock, end)| clock >= end)
|
||||
}) && matches!(
|
||||
reason,
|
||||
"intraday quote liquidity exhausted"
|
||||
| "no execution quotes after start"
|
||||
| "no execution quotes at or before start"
|
||||
)
|
||||
}
|
||||
|
||||
fn progressed_algorithm(
|
||||
request: Option<&AlgoExecutionRequest>,
|
||||
filled: u32,
|
||||
commission: Option<f64>,
|
||||
) -> Option<AlgoExecutionRequest> {
|
||||
request.copied().map(|mut request| {
|
||||
request.filled_quantity = request.filled_quantity.saturating_add(filled);
|
||||
request.commission_remaining = commission;
|
||||
request
|
||||
})
|
||||
}
|
||||
|
||||
fn remaining_algorithm_budget(
|
||||
&self,
|
||||
budget: Option<f64>,
|
||||
fills: &[FillEvent],
|
||||
) -> Result<Option<f64>, BacktestError> {
|
||||
let Some(budget) = budget else {
|
||||
return Ok(None);
|
||||
};
|
||||
let spent = FixedMoney::checked_sum_f64(fills.iter().map(|fill| {
|
||||
if self.strict_value_budget {
|
||||
-fill.net_cash_flow
|
||||
} else {
|
||||
fill.gross_amount
|
||||
}
|
||||
}))
|
||||
.ok_or_else(|| {
|
||||
BacktestError::Execution("algorithm budget spent amount is invalid".into())
|
||||
})?;
|
||||
let remaining = FixedMoney::from_f64(budget)
|
||||
.and_then(|budget| budget.checked_sub(spent))
|
||||
.filter(|remaining| *remaining >= FixedMoney::ZERO)
|
||||
.ok_or_else(|| {
|
||||
BacktestError::Execution("algorithm spent more than its frozen value budget".into())
|
||||
})?;
|
||||
Ok(Some(remaining.to_f64()))
|
||||
}
|
||||
|
||||
fn resolve_execution_fill(
|
||||
&self,
|
||||
date: NaiveDate,
|
||||
@@ -7594,6 +7933,12 @@ where
|
||||
{
|
||||
Some(start_cursor.map_or(date.and_time(submitted), |cursor| cursor.max(date.and_time(submitted))))
|
||||
} else { start_cursor };
|
||||
let start_cursor = if algo_request.is_some() {
|
||||
match (start_cursor, self.execution_clock().map(|time| date.and_time(time))) {
|
||||
(Some(declared), Some(clock)) => Some(declared.max(clock)),
|
||||
(start, _) => start,
|
||||
}
|
||||
} else { start_cursor };
|
||||
let end_cursor = post_close_window.map(|window| {
|
||||
runtime_end_time.map_or(window.1, |end| window.1.min(date.and_time(end)))
|
||||
}).or_else(|| {
|
||||
@@ -7610,10 +7955,17 @@ where
|
||||
} else {
|
||||
end_cursor
|
||||
};
|
||||
let end_cursor = if algo_request.is_some() {
|
||||
match (end_cursor, runtime_end_time.map(|time| date.and_time(time))) {
|
||||
(Some(declared), Some(clock)) => Some(declared.min(clock)),
|
||||
(end, _) => end,
|
||||
}
|
||||
} else { end_cursor };
|
||||
let quotes = data.execution_quotes_on(date, symbol);
|
||||
let calibration = self.slippage_calibration(data, snapshot)?;
|
||||
|
||||
if let Some(fill) = self.select_execution_fill_with_ledger(
|
||||
let previous_schedule = self.runtime_algo_schedule.replace(algo_request.copied());
|
||||
let selected = self.select_execution_fill_with_ledger(
|
||||
symbol,
|
||||
snapshot,
|
||||
quotes,
|
||||
@@ -7632,7 +7984,9 @@ where
|
||||
execution_ledger,
|
||||
calibration.as_ref(),
|
||||
data.instruments().get(symbol),
|
||||
)? {
|
||||
);
|
||||
self.runtime_algo_schedule.set(previous_schedule);
|
||||
if let Some(fill) = selected? {
|
||||
return Ok(Some(fill));
|
||||
}
|
||||
|
||||
@@ -7642,11 +7996,8 @@ where
|
||||
|| runtime_end_time.is_some()
|
||||
|| self.intraday_execution_start_time.is_some()
|
||||
{
|
||||
let next_cursor = algo_request
|
||||
.and_then(|request| request.start_time)
|
||||
.or(runtime_start_time)
|
||||
.or(self.intraday_execution_start_time)
|
||||
.map(|start_time| date.and_time(start_time) + Duration::seconds(1))
|
||||
let next_cursor = start_cursor
|
||||
.map(|time| time + Duration::seconds(1))
|
||||
.unwrap_or_else(|| date.and_hms_opt(0, 0, 1).expect("valid midnight"));
|
||||
return Ok(Some(ExecutionFill {
|
||||
quantity: 0,
|
||||
@@ -7758,16 +8109,24 @@ where
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let algo_schedule = self.runtime_algo_schedule.get();
|
||||
let mut preview_commission_state = BTreeMap::new();
|
||||
let schedule_start = algo_schedule.and_then(|request| request.start_time)
|
||||
.map(|time| snapshot.date.and_time(time)).or(start_cursor);
|
||||
let schedule_end = algo_schedule.and_then(|request| request.end_time)
|
||||
.map(|time| snapshot.date.and_time(time)).or(end_cursor);
|
||||
let quote_quantity_limited =
|
||||
self.quote_quantity_limited_for_window(matching_type, start_cursor, end_cursor);
|
||||
self.quote_quantity_limited_for_window(matching_type, schedule_start, schedule_end);
|
||||
let twap_schedule = (matching_type == MatchingType::Twap)
|
||||
.then(|| TwapSchedule::new(start_cursor, end_cursor, requested_qty))
|
||||
.then(|| TwapSchedule::new(schedule_start, schedule_end,
|
||||
algo_schedule.and_then(|request|request.total_quantity).unwrap_or(requested_qty)))
|
||||
.transpose()?;
|
||||
let lot = round_lot.max(1);
|
||||
let exact_time_order_quote = matching_type != MatchingType::MinuteLast
|
||||
&& start_cursor.is_some()
|
||||
&& end_cursor.is_some()
|
||||
&& start_cursor == end_cursor;
|
||||
&& start_cursor == end_cursor
|
||||
&& !(algo_schedule.is_some() && schedule_start != schedule_end);
|
||||
let use_decision_time_quote = !self.is_post_close_fixed_price(snapshot.date)
|
||||
&& start_cursor.is_some()
|
||||
&& (matching_type == MatchingType::MinuteLast || exact_time_order_quote);
|
||||
@@ -7903,7 +8262,8 @@ where
|
||||
}
|
||||
|
||||
let mut take_qty = if let Some(schedule) = &twap_schedule {
|
||||
remaining_qty.min(available_qty).min(schedule.due_quantity(execution_at, filled_qty))
|
||||
remaining_qty.min(available_qty).min(schedule.due_quantity(execution_at,
|
||||
algo_schedule.map_or(0,|request|request.filled_quantity).saturating_add(filled_qty)))
|
||||
} else {
|
||||
remaining_qty.min(available_qty)
|
||||
};
|
||||
@@ -7964,10 +8324,16 @@ where
|
||||
);
|
||||
continue;
|
||||
}
|
||||
let candidate_cost = self
|
||||
.cost_model
|
||||
.calculate_for_instrument(snapshot.date, OrderSide::Buy, candidate_gross, instrument)
|
||||
.total();
|
||||
let candidate_cost = if let Some(request)=algo_schedule {
|
||||
preview_commission_state.clear();
|
||||
if let (Some(id),Some(remaining))=(request.order_id,request.commission_remaining) {
|
||||
preview_commission_state.insert(id,remaining);
|
||||
}
|
||||
self.cost_model.calculate_with_order_state_for_instrument(snapshot.date,OrderSide::Buy,
|
||||
candidate_gross,request.order_id,&mut preview_commission_state,instrument).total()
|
||||
} else {
|
||||
self.cost_model.calculate_for_instrument(snapshot.date,OrderSide::Buy,candidate_gross,instrument).total()
|
||||
};
|
||||
let candidate_cash =
|
||||
FixedMoney::checked_sum_f64([candidate_gross, candidate_cost])
|
||||
.expect("buy cash must be finite fixed-point money")
|
||||
@@ -8110,6 +8476,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,
|
||||
@@ -8231,6 +8598,8 @@ fn sell_reason(decision: &StrategyDecision, symbol: &str) -> &'static str {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
mod algorithm_clock;
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use chrono::NaiveTime;
|
||||
@@ -8252,6 +8621,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,
|
||||
@@ -8268,6 +8639,9 @@ mod tests {
|
||||
time_in_force: OrderTimeInForce::Gtc,
|
||||
commission_remaining: None,
|
||||
execution_cursor: None,
|
||||
algo_request: None,
|
||||
value_budget: None,
|
||||
reserved_cash: None,
|
||||
reason: format!("order_{order_id}"),
|
||||
}
|
||||
}
|
||||
@@ -8324,7 +8698,7 @@ mod tests {
|
||||
|
||||
fn limit_test_quote(last_price: f64, bid1: f64, ask1: f64) -> IntradayExecutionQuote {
|
||||
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date");
|
||||
IntradayExecutionQuote {
|
||||
IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
timestamp: date.and_hms_opt(9, 33, 0).expect("valid timestamp"),
|
||||
@@ -11729,7 +12103,7 @@ mod tests {
|
||||
lower_limit: 5.27,
|
||||
price_tick: 0.01,
|
||||
};
|
||||
let quote = IntradayExecutionQuote {
|
||||
let quote = IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
timestamp: date.and_hms_opt(10, 39, 59).expect("valid timestamp"),
|
||||
|
||||
@@ -0,0 +1,677 @@
|
||||
use super::*;
|
||||
|
||||
fn time(minute: u32) -> NaiveTime {
|
||||
NaiveTime::from_hms_opt(10, minute, 0).unwrap()
|
||||
}
|
||||
|
||||
fn data(quotes: &[(u32, f64, u32)]) -> DataSet {
|
||||
data_with_snapshot(quotes, limit_test_snapshot())
|
||||
}
|
||||
|
||||
fn data_with_snapshot(quotes: &[(u32, f64, u32)], snapshot: DailyMarketSnapshot) -> DataSet {
|
||||
DataSet::from_components_with_actions_and_quotes(
|
||||
vec![limit_test_instrument()],
|
||||
vec![snapshot],
|
||||
vec![],
|
||||
vec![limit_test_candidate(true, true)],
|
||||
vec![limit_test_benchmark()],
|
||||
vec![],
|
||||
quotes
|
||||
.iter()
|
||||
.map(|&(minute, price, volume)| {
|
||||
let mut quote = limit_test_quote(price, price, price);
|
||||
quote.timestamp = quote.date.and_time(time(minute));
|
||||
quote.volume_delta = u64::from(volume);
|
||||
quote.amount_delta = price * f64::from(volume);
|
||||
quote.bid1_volume = u64::from(volume / 100);
|
||||
quote.ask1_volume = u64::from(volume / 100);
|
||||
quote
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn broker() -> BrokerSimulator<ChinaAShareCostModel, ChinaEquityRuleHooks> {
|
||||
BrokerSimulator::new(
|
||||
ChinaAShareCostModel::default()
|
||||
.with_commission_rate(0.0003)
|
||||
.with_minimum_commission(5.),
|
||||
ChinaEquityRuleHooks,
|
||||
)
|
||||
.with_matching_type(MatchingType::MinuteLast)
|
||||
.with_execution_price_field(PriceField::Last)
|
||||
.with_intraday_execution_start_time(time(0))
|
||||
.with_volume_limit(true)
|
||||
.with_volume_percent(0.25)
|
||||
.with_liquidity_limit(false)
|
||||
.with_inactive_limit(false)
|
||||
.with_strict_value_budget(true)
|
||||
}
|
||||
|
||||
fn intent(style: AlgoOrderStyle, value: f64) -> StrategyDecision {
|
||||
StrategyDecision {
|
||||
order_intents: vec![OrderIntent::AlgoValue {
|
||||
symbol: "000001.SZ".into(),
|
||||
value,
|
||||
style,
|
||||
start_time: Some(time(0)),
|
||||
end_time: Some(time(10)),
|
||||
reason: "clock-algorithm".into(),
|
||||
}],
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn step(
|
||||
broker: &BrokerSimulator<ChinaAShareCostModel, ChinaEquityRuleHooks>,
|
||||
portfolio: &mut PortfolioState,
|
||||
data: &DataSet,
|
||||
minute: u32,
|
||||
decision: &StrategyDecision,
|
||||
) -> BrokerExecutionReport {
|
||||
broker
|
||||
.execute_between(
|
||||
limit_test_snapshot().date,
|
||||
portfolio,
|
||||
data,
|
||||
decision,
|
||||
Some(time(minute)),
|
||||
Some(time(minute)),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn twap_clock_preserves_quantity_prices_fees_budget_and_parent_order() {
|
||||
let data = data(&[
|
||||
(0, 10., 4_000),
|
||||
(2, 10.1, 4_000),
|
||||
(5, 10.2, 4_000),
|
||||
(10, 10.3, 4_000),
|
||||
]);
|
||||
let decision = intent(AlgoOrderStyle::Twap, 10_000.);
|
||||
let mut synchronous_account = PortfolioState::new(20_000.);
|
||||
let reference = broker()
|
||||
.execute(
|
||||
limit_test_snapshot().date,
|
||||
&mut synchronous_account,
|
||||
&data,
|
||||
&decision,
|
||||
)
|
||||
.unwrap();
|
||||
let broker = broker();
|
||||
let mut account = PortfolioState::new(20_000.);
|
||||
let mut fills = Vec::new();
|
||||
let mut events = Vec::new();
|
||||
let empty = StrategyDecision::default();
|
||||
for minute in [0, 2, 5, 10] {
|
||||
let batch = step(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
minute,
|
||||
if minute == 0 { &decision } else { &empty },
|
||||
);
|
||||
assert!(
|
||||
batch
|
||||
.fill_events
|
||||
.iter()
|
||||
.all(|fill| fill.execution_timestamp.unwrap().time() <= time(minute))
|
||||
);
|
||||
fills.extend(batch.fill_events);
|
||||
events.extend(batch.order_events);
|
||||
}
|
||||
let canonical = |rows: &[crate::events::FillEvent]| {
|
||||
rows.iter()
|
||||
.map(|fill| {
|
||||
(
|
||||
fill.quantity,
|
||||
fill.price.to_bits(),
|
||||
fill.commission.to_bits(),
|
||||
fill.stamp_tax.to_bits(),
|
||||
fill.transfer_fee.to_bits(),
|
||||
fill.execution_timestamp,
|
||||
fill.order_id,
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
assert_eq!(canonical(&fills), canonical(&reference.fill_events));
|
||||
assert_eq!(account.cash(), synchronous_account.cash());
|
||||
assert_eq!(fills.iter().map(|fill| fill.quantity).sum::<u32>(), 900);
|
||||
assert_eq!(fills.iter().map(|fill| fill.commission).sum::<f64>(), 5.);
|
||||
assert!(fills.iter().map(|fill| -fill.net_cash_flow).sum::<f64>() <= 10_000.);
|
||||
assert!(events.iter().all(|event| event.order_id == Some(1)));
|
||||
assert_eq!(events.last().unwrap().status, OrderStatus::Filled);
|
||||
assert!(broker.open_order_views().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_algorithm_cancel_releases_reservation_and_never_executes_the_remainder() {
|
||||
let data = data(&[
|
||||
(0, 10., 4_000),
|
||||
(2, 10., 4_000),
|
||||
(5, 10., 4_000),
|
||||
(10, 10., 4_000),
|
||||
]);
|
||||
let broker = broker();
|
||||
let mut account = PortfolioState::new(20_000.);
|
||||
step(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
0,
|
||||
&intent(AlgoOrderStyle::Twap, 10_000.),
|
||||
);
|
||||
assert_eq!(broker.open_order_views()[0].reserved_cash, Some(10_000.));
|
||||
let partial = step(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
2,
|
||||
&StrategyDecision::default(),
|
||||
);
|
||||
assert_eq!(
|
||||
partial
|
||||
.fill_events
|
||||
.iter()
|
||||
.map(|fill| fill.quantity)
|
||||
.sum::<u32>(),
|
||||
100
|
||||
);
|
||||
let working = broker.open_order_views();
|
||||
assert_eq!(working[0].order_id, 1);
|
||||
assert_eq!(working[0].filled_quantity, 100);
|
||||
assert_eq!(
|
||||
working[0].reserved_cash,
|
||||
Some(10_000. + partial.fill_events[0].net_cash_flow)
|
||||
);
|
||||
let cancel = step(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
3,
|
||||
&StrategyDecision {
|
||||
order_intents: vec![OrderIntent::CancelAll {
|
||||
reason: "explicit-user-cancel".into(),
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
assert!(cancel.fill_events.is_empty());
|
||||
assert_eq!(
|
||||
cancel.order_events.last().unwrap().status,
|
||||
OrderStatus::Canceled
|
||||
);
|
||||
assert_eq!(cancel.order_events.last().unwrap().filled_quantity, 100);
|
||||
assert!(broker.open_order_views().is_empty());
|
||||
assert!(
|
||||
step(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
10,
|
||||
&StrategyDecision::default()
|
||||
)
|
||||
.fill_events
|
||||
.is_empty()
|
||||
);
|
||||
assert_eq!(account.position("000001.SZ").unwrap().quantity, 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn algorithm_expiry_without_a_quote_does_not_reuse_old_liquidity() {
|
||||
let data = data(&[(0, 10., 4_000), (2, 10., 4_000)]);
|
||||
let broker = broker();
|
||||
let mut account = PortfolioState::new(20_000.);
|
||||
step(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
0,
|
||||
&intent(AlgoOrderStyle::Twap, 10_000.),
|
||||
);
|
||||
step(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
2,
|
||||
&StrategyDecision::default(),
|
||||
);
|
||||
assert_eq!(
|
||||
broker.next_day_order_expiry(limit_test_snapshot().date),
|
||||
Some(time(10))
|
||||
);
|
||||
let terminal = step(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
10,
|
||||
&StrategyDecision::default(),
|
||||
);
|
||||
assert!(terminal.fill_events.is_empty());
|
||||
assert_eq!(
|
||||
terminal.order_events.last().unwrap().status,
|
||||
OrderStatus::Expired
|
||||
);
|
||||
assert_eq!(terminal.order_events.last().unwrap().filled_quantity, 100);
|
||||
assert!(
|
||||
terminal
|
||||
.process_events
|
||||
.iter()
|
||||
.any(|event| event.detail.contains("Expired")),
|
||||
"{:?}",
|
||||
terminal.process_events
|
||||
);
|
||||
assert!(broker.open_order_views().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn separate_buy_cannot_spend_the_working_algorithms_cash_budget() {
|
||||
let data = data(&[
|
||||
(0, 10., 4_000),
|
||||
(1, 10., 4_000),
|
||||
(2, 10., 4_000),
|
||||
(10, 10., 4_000),
|
||||
]);
|
||||
let broker = broker();
|
||||
let mut account = PortfolioState::new(11_000.);
|
||||
step(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
0,
|
||||
&intent(AlgoOrderStyle::Twap, 10_000.),
|
||||
);
|
||||
let other = step(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
1,
|
||||
&StrategyDecision {
|
||||
order_intents: vec![OrderIntent::Shares {
|
||||
symbol: "000001.SZ".into(),
|
||||
quantity: 1_000,
|
||||
reason: "separate-buy".into(),
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
assert!(
|
||||
other.fill_events.is_empty(),
|
||||
"cash reserved for order 1 was spent: {:?}",
|
||||
other.fill_events
|
||||
);
|
||||
let final_batch = step(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
10,
|
||||
&StrategyDecision::default(),
|
||||
);
|
||||
assert!(
|
||||
final_batch
|
||||
.fill_events
|
||||
.iter()
|
||||
.all(|fill| fill.order_id == Some(1))
|
||||
);
|
||||
assert_eq!(account.position("000001.SZ").unwrap().quantity, 900);
|
||||
assert!(account.cash() >= 1_000.);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changing_the_later_daily_close_does_not_resize_an_algorithm_submitted_now() {
|
||||
let quotes = [(0, 10., 4_000), (2, 10.1, 4_000), (10, 10.2, 4_000)];
|
||||
let mut changed = limit_test_snapshot();
|
||||
changed.close = 100.;
|
||||
changed.last_price = 100.;
|
||||
let run = |data: DataSet| {
|
||||
let broker = broker();
|
||||
let mut account = PortfolioState::new(20_000.);
|
||||
let initial = step(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
0,
|
||||
&intent(AlgoOrderStyle::Twap, 10_000.),
|
||||
);
|
||||
assert!(initial.fill_events.is_empty());
|
||||
let quantity = broker.open_order_views()[0].requested_quantity;
|
||||
let final_batch = step(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
10,
|
||||
&StrategyDecision::default(),
|
||||
);
|
||||
(
|
||||
quantity,
|
||||
final_batch
|
||||
.fill_events
|
||||
.iter()
|
||||
.map(|fill| {
|
||||
(
|
||||
fill.quantity,
|
||||
fill.price.to_bits(),
|
||||
fill.net_cash_flow.to_bits(),
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
};
|
||||
assert_eq!(
|
||||
run(data("es)),
|
||||
run(data_with_snapshot("es, changed))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vwap_clock_preserves_cash_costs_and_does_not_spend_future_volume() {
|
||||
let data = data(&[
|
||||
(0, 10., 400),
|
||||
(2, 10., 800),
|
||||
(5, 10., 1_200),
|
||||
(10, 10., 4_000),
|
||||
]);
|
||||
let decision = intent(AlgoOrderStyle::Vwap, 10_000.);
|
||||
let mut synchronous_account = PortfolioState::new(20_000.);
|
||||
let reference = broker()
|
||||
.execute(
|
||||
limit_test_snapshot().date,
|
||||
&mut synchronous_account,
|
||||
&data,
|
||||
&decision,
|
||||
)
|
||||
.unwrap();
|
||||
let broker = broker();
|
||||
let mut account = PortfolioState::new(20_000.);
|
||||
let empty = StrategyDecision::default();
|
||||
let mut filled = 0;
|
||||
let mut commission = 0.;
|
||||
for (minute, expected) in [(0, 100), (2, 300), (5, 600), (10, 900)] {
|
||||
let batch = step(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
minute,
|
||||
if minute == 0 { &decision } else { &empty },
|
||||
);
|
||||
filled += batch
|
||||
.fill_events
|
||||
.iter()
|
||||
.map(|fill| fill.quantity)
|
||||
.sum::<u32>();
|
||||
commission += batch
|
||||
.fill_events
|
||||
.iter()
|
||||
.map(|fill| fill.commission)
|
||||
.sum::<f64>();
|
||||
assert_eq!(filled, expected);
|
||||
assert!(batch.fill_events.iter().all(|fill| fill.order_id == Some(1)
|
||||
&& fill.execution_timestamp.unwrap().time() <= time(minute)));
|
||||
}
|
||||
assert_eq!(account.cash(), synchronous_account.cash());
|
||||
assert_eq!(
|
||||
commission,
|
||||
reference
|
||||
.fill_events
|
||||
.iter()
|
||||
.map(|fill| fill.commission)
|
||||
.sum::<f64>()
|
||||
);
|
||||
assert!(broker.open_order_views().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn global_vwap_matching_keeps_the_same_working_order_between_clock_ticks() {
|
||||
let data = data(&[(0, 10., 400), (2, 10., 400), (10, 10., 4_000)]);
|
||||
let broker = broker().with_matching_type(MatchingType::Vwap);
|
||||
let mut account = PortfolioState::new(20_000.);
|
||||
let first = step(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
0,
|
||||
&StrategyDecision {
|
||||
order_intents: vec![OrderIntent::Shares {
|
||||
symbol: "000001.SZ".into(),
|
||||
quantity: 900,
|
||||
reason: "configured-vwap".into(),
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
assert_eq!(
|
||||
first
|
||||
.fill_events
|
||||
.iter()
|
||||
.map(|fill| fill.quantity)
|
||||
.sum::<u32>(),
|
||||
100
|
||||
);
|
||||
assert_eq!(
|
||||
broker.open_order_views().len(),
|
||||
1,
|
||||
"{:?}",
|
||||
first.order_events
|
||||
);
|
||||
let second = step(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
2,
|
||||
&StrategyDecision::default(),
|
||||
);
|
||||
assert_eq!(second.fill_events[0].quantity, 100);
|
||||
assert_eq!(second.fill_events[0].order_id, Some(1));
|
||||
let final_batch = step(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
10,
|
||||
&StrategyDecision::default(),
|
||||
);
|
||||
assert_eq!(final_batch.fill_events[0].quantity, 700);
|
||||
assert_eq!(final_batch.fill_events[0].order_id, Some(1));
|
||||
assert!(broker.open_order_views().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn algorithm_sell_honors_t_plus_one_and_keeps_original_quantity_after_partial_fills() {
|
||||
let data = data(&[(0, 10., 400), (2, 10., 800), (10, 10., 4_000)]);
|
||||
let date = limit_test_snapshot().date;
|
||||
for acquired_today in [false, true] {
|
||||
let broker = broker();
|
||||
let mut account = PortfolioState::new(20_000.);
|
||||
account.position_mut("000001.SZ").buy(
|
||||
if acquired_today {
|
||||
date
|
||||
} else {
|
||||
date.pred_opt().unwrap()
|
||||
},
|
||||
1_000,
|
||||
10.,
|
||||
);
|
||||
let decision = intent(AlgoOrderStyle::Vwap, -10_000.);
|
||||
let mut fills = Vec::new();
|
||||
let mut events = Vec::new();
|
||||
let empty = StrategyDecision::default();
|
||||
for minute in [0, 2, 10] {
|
||||
let batch = step(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
minute,
|
||||
if minute == 0 { &decision } else { &empty },
|
||||
);
|
||||
fills.extend(batch.fill_events);
|
||||
events.extend(batch.order_events);
|
||||
}
|
||||
assert_eq!(
|
||||
fills.iter().map(|fill| fill.quantity).sum::<u32>(),
|
||||
if acquired_today { 0 } else { 1_000 }
|
||||
);
|
||||
assert!(events.iter().all(|event| event.order_id == Some(1)));
|
||||
if !acquired_today {
|
||||
assert_eq!(events.last().unwrap().status, OrderStatus::Filled);
|
||||
assert_eq!(events.last().unwrap().requested_quantity, 1_000);
|
||||
assert_eq!(events.last().unwrap().filled_quantity, 1_000);
|
||||
}
|
||||
assert!(broker.open_order_views().is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_explicit_ioc_or_fok_does_not_become_a_persistent_algorithm() {
|
||||
let data = data(&[(0, 10., 400), (2, 10., 4_000), (10, 10., 4_000)]);
|
||||
for tif in [
|
||||
OrderTimeInForce::Ioc,
|
||||
OrderTimeInForce::Fok,
|
||||
OrderTimeInForce::Day,
|
||||
OrderTimeInForce::Gtc,
|
||||
] {
|
||||
let broker = broker();
|
||||
let mut account = PortfolioState::new(20_000.);
|
||||
let mut decision = intent(AlgoOrderStyle::Vwap, 10_000.);
|
||||
if !decision.order_intents[0].supports_time_in_force(tif) {
|
||||
decision.order_intents = decision
|
||||
.order_intents
|
||||
.into_iter()
|
||||
.map(|intent| intent.with_time_in_force(tif))
|
||||
.collect();
|
||||
let error = broker
|
||||
.execute_between(
|
||||
limit_test_snapshot().date,
|
||||
&mut account,
|
||||
&data,
|
||||
&decision,
|
||||
Some(time(0)),
|
||||
Some(time(0)),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("is not supported for this order intent")
|
||||
);
|
||||
assert_eq!(account.cash(), 20_000.);
|
||||
assert!(broker.open_order_views().is_empty());
|
||||
continue;
|
||||
}
|
||||
decision.order_intents = decision
|
||||
.order_intents
|
||||
.into_iter()
|
||||
.map(|intent| intent.with_time_in_force(tif))
|
||||
.collect();
|
||||
let first = step(&broker, &mut account, &data, 0, &decision);
|
||||
let persists = matches!(tif, OrderTimeInForce::Day | OrderTimeInForce::Gtc);
|
||||
assert_eq!(
|
||||
!broker.open_order_views().is_empty(),
|
||||
persists,
|
||||
"{tif:?}: {:?}",
|
||||
first.order_events
|
||||
);
|
||||
if !persists {
|
||||
assert!(
|
||||
step(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
10,
|
||||
&StrategyDecision::default()
|
||||
)
|
||||
.fill_events
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_working_algorithms_reserve_only_real_cash_without_starving_the_first() {
|
||||
let data = data(&[(0, 10., 40_000), (10, 10., 40_000)]);
|
||||
let broker = broker();
|
||||
let mut account = PortfolioState::new(15_000.);
|
||||
let mut decision = intent(AlgoOrderStyle::Twap, 10_000.);
|
||||
decision
|
||||
.order_intents
|
||||
.extend(intent(AlgoOrderStyle::Twap, 10_000.).order_intents);
|
||||
step(&broker, &mut account, &data, 0, &decision);
|
||||
assert_eq!(
|
||||
broker
|
||||
.open_order_views()
|
||||
.iter()
|
||||
.map(|order| order.reserved_cash.unwrap())
|
||||
.collect::<Vec<_>>(),
|
||||
vec![10_000., 5_000.]
|
||||
);
|
||||
let report = step(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
10,
|
||||
&StrategyDecision::default(),
|
||||
);
|
||||
assert_eq!(
|
||||
report
|
||||
.fill_events
|
||||
.iter()
|
||||
.map(|fill| (fill.order_id, fill.quantity))
|
||||
.collect::<Vec<_>>(),
|
||||
vec![(Some(1), 900), (Some(2), 500)]
|
||||
);
|
||||
assert!(account.cash() >= 0.);
|
||||
assert!(broker.open_order_views().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_clock_slice_does_not_turn_window_twap_into_an_unlimited_instant_order() {
|
||||
let data = data(&[(0, 10., 100), (2, 10., 100), (10, 10.1, 100)]);
|
||||
let broker = broker()
|
||||
.with_volume_limit(false)
|
||||
.with_liquidity_limit(false);
|
||||
let mut account = PortfolioState::new(20_000.);
|
||||
step(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
0,
|
||||
&intent(AlgoOrderStyle::Twap, 10_000.),
|
||||
);
|
||||
let first = step(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
2,
|
||||
&StrategyDecision::default(),
|
||||
);
|
||||
let last = step(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
10,
|
||||
&StrategyDecision::default(),
|
||||
);
|
||||
assert_eq!(
|
||||
first
|
||||
.fill_events
|
||||
.iter()
|
||||
.map(|fill| fill.quantity)
|
||||
.sum::<u32>(),
|
||||
100
|
||||
);
|
||||
assert_eq!(
|
||||
last.fill_events
|
||||
.iter()
|
||||
.map(|fill| fill.quantity)
|
||||
.sum::<u32>(),
|
||||
100
|
||||
);
|
||||
assert_eq!(
|
||||
last.order_events.last().unwrap().status,
|
||||
OrderStatus::Expired
|
||||
);
|
||||
assert_eq!(last.order_events.last().unwrap().filled_quantity, 200);
|
||||
assert!(broker.open_order_views().is_empty());
|
||||
}
|
||||
@@ -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,14 +70,58 @@ 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,
|
||||
data: &DataSet,
|
||||
symbols: &BTreeSet<String>,
|
||||
execution_clock: Option<NaiveDateTime>,
|
||||
) -> Result<Vec<pool::MarketSnapshot>, BacktestError> {
|
||||
symbols
|
||||
cumulative_conditions: bool,
|
||||
) -> Result<(Vec<pool::MarketSnapshot>, Vec<String>), BacktestError> {
|
||||
let mut unavailable = Vec::new();
|
||||
let quotes = symbols
|
||||
.iter()
|
||||
.map(|symbol| {
|
||||
let snapshot = data.market(date, symbol).ok_or_else(|| {
|
||||
@@ -100,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!(
|
||||
@@ -112,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!(
|
||||
@@ -134,11 +207,17 @@ impl<C: CostModel, R: EquityRuleHooks> BrokerSimulator<C, R> {
|
||||
None,
|
||||
calibration.as_ref(),
|
||||
)?;
|
||||
let totals = if cumulative_conditions {
|
||||
match data.execution_session_totals(symbol, clock) {
|
||||
Ok(totals) => Some(totals),
|
||||
Err(reason) => { unavailable.push(reason); None }
|
||||
}
|
||||
} else { None };
|
||||
(
|
||||
quote.last_price,
|
||||
snapshot.prev_close,
|
||||
Some(quote.volume_delta as f64),
|
||||
Some(quote.amount_delta),
|
||||
totals.map(|total| total.0),
|
||||
totals.map(|total| total.1),
|
||||
Some(quote.bid1),
|
||||
Some(quote.ask1),
|
||||
buy,
|
||||
@@ -153,13 +232,24 @@ impl<C: CostModel, R: EquityRuleHooks> BrokerSimulator<C, R> {
|
||||
}
|
||||
// A daily open does not reveal the session's volume/turnover.
|
||||
let completed = self.effective_execution_price_field(date) == PriceField::Close;
|
||||
let totals = if cumulative_conditions && !completed {
|
||||
let at = execution_clock.unwrap_or_else(|| date.and_hms_opt(9,30,0).unwrap());
|
||||
match data.execution_session_totals(symbol, at) {
|
||||
Ok(totals) => Some(totals),
|
||||
Err(reason) => { unavailable.push(reason); None }
|
||||
}
|
||||
} else { None };
|
||||
let amount = if completed && cumulative_conditions {
|
||||
data.factor(date, symbol).and_then(|row| row.extra_factors.get("amount")).copied()
|
||||
.map(|value| decimal(value, "amount")).transpose()?
|
||||
} else { totals.map(|total| total.1) };
|
||||
(
|
||||
price,
|
||||
snapshot.prev_close,
|
||||
completed.then_some(snapshot.volume as f64),
|
||||
if completed { Some(Decimal::from(snapshot.volume)) } else { totals.map(|total| total.0) },
|
||||
amount,
|
||||
None,
|
||||
None,
|
||||
Some(price),
|
||||
Some(price),
|
||||
self.snapshot_execution_price(data, snapshot, OrderSide::Buy, None)?,
|
||||
self.snapshot_execution_price(data, snapshot, OrderSide::Sell, None)?,
|
||||
)
|
||||
@@ -168,8 +258,8 @@ impl<C: CostModel, R: EquityRuleHooks> BrokerSimulator<C, R> {
|
||||
symbol: symbol.clone(),
|
||||
last_price: decimal(price, "price")?,
|
||||
prev_close: Some(decimal(prev, "prev_close")?),
|
||||
volume: volume.map(|v| decimal(v, "volume")).transpose()?,
|
||||
turnover: amount.map(|v| decimal(v, "amount")).transpose()?,
|
||||
volume,
|
||||
turnover: amount,
|
||||
bid_price_1: bid.map(|v| decimal(v, "bid")).transpose()?,
|
||||
ask_price_1: ask.map(|v| decimal(v, "ask")).transpose()?,
|
||||
is_kcb: Some(instrument.board.eq_ignore_ascii_case("KSH")),
|
||||
@@ -182,7 +272,8 @@ impl<C: CostModel, R: EquityRuleHooks> BrokerSimulator<C, R> {
|
||||
sell_sizing_price: Some(decimal(sell_price, "sell_price")?),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
.collect::<Result<Vec<_>, BacktestError>>()?;
|
||||
Ok((quotes, unavailable))
|
||||
}
|
||||
|
||||
fn pool_etf_fallback_reference(&self, date: NaiveDate, data: &DataSet, symbol: &str, clock: Option<NaiveDateTime>) -> Result<Option<crate::etf_execution::EtfFallbackReference>, BacktestError> {
|
||||
@@ -206,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
|
||||
@@ -246,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
|
||||
@@ -264,6 +367,9 @@ impl<C: CostModel, R: EquityRuleHooks> BrokerSimulator<C, R> {
|
||||
let superseded = self.deferred_etf_targets.borrow_mut().replace_generation(&contract.pool_id, &contract.generation);
|
||||
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());
|
||||
@@ -309,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)? {
|
||||
@@ -328,8 +438,8 @@ impl<C: CostModel, R: EquityRuleHooks> BrokerSimulator<C, R> {
|
||||
fallback_references.insert(symbol.clone(), reference);
|
||||
}
|
||||
}
|
||||
let quotes =
|
||||
self.pool_quote_inputs(date, data, "e_scope, *global_execution_cursor)?;
|
||||
let (quotes, unavailable) = self.pool_quote_inputs(date, data, "e_scope, *global_execution_cursor,
|
||||
crate::stock_pool_quote_facts::requires_session_totals(&contract.rule))?;
|
||||
let positions = pool_positions(portfolio, date)?;
|
||||
let execution_state = portfolio
|
||||
.stock_pool_execution_state(&contract.pool_id)
|
||||
@@ -448,7 +558,11 @@ impl<C: CostModel, R: EquityRuleHooks> BrokerSimulator<C, R> {
|
||||
Decimal::ZERO,
|
||||
Some(&fee),
|
||||
)
|
||||
.map_err(BacktestError::Execution)?;
|
||||
.map_err(|error| BacktestError::Execution(if !unavailable.is_empty()
|
||||
&& (error.contains("requires volume") || error.contains("requires amount")) {
|
||||
format!("{error}; {}", unavailable.join("; "))
|
||||
} else { error }))?;
|
||||
report.diagnostics.extend(unavailable.into_iter().map(|reason| format!("stock_pool_quote_fact_unavailable {reason}")));
|
||||
let mut updated = execution_state
|
||||
.record_plan(contract.signal_date, &contract.generation, &plan)
|
||||
.map_err(BacktestError::Execution)?;
|
||||
|
||||
@@ -0,0 +1,684 @@
|
||||
fn pool_batch_data() -> DataSet {
|
||||
pool_batch_data_with(|_| true)
|
||||
}
|
||||
|
||||
fn pool_batch_data_with(change: impl Fn(&mut IntradayExecutionQuote) -> bool) -> DataSet {
|
||||
let symbols = ["000001.SZ", "000002.SZ", "000003.SZ"];
|
||||
let instruments = symbols
|
||||
.iter()
|
||||
.map(|symbol| Instrument {
|
||||
symbol: (*symbol).into(),
|
||||
..limit_test_instrument()
|
||||
})
|
||||
.collect();
|
||||
let snapshots = symbols
|
||||
.iter()
|
||||
.map(|symbol| DailyMarketSnapshot {
|
||||
symbol: (*symbol).into(),
|
||||
..limit_test_snapshot()
|
||||
})
|
||||
.collect();
|
||||
let candidates = symbols
|
||||
.iter()
|
||||
.map(|symbol| CandidateEligibility {
|
||||
symbol: (*symbol).into(),
|
||||
..limit_test_candidate(true, true)
|
||||
})
|
||||
.collect();
|
||||
let mut quotes = Vec::new();
|
||||
for minute in [30, 31, 32, 33, 34, 36] {
|
||||
for symbol in symbols {
|
||||
let price = if symbol == "000001.SZ" && minute > 30 {
|
||||
10.5
|
||||
} else {
|
||||
10.0
|
||||
};
|
||||
let mut quote = limit_test_quote(price, price, price);
|
||||
quote.symbol = symbol.into();
|
||||
quote.timestamp = quote.date.and_hms_opt(9, minute, 0).unwrap();
|
||||
quote.volume_delta = 200;
|
||||
quote.bid1_volume = 200;
|
||||
quote.ask1_volume = 200;
|
||||
quote.amount_delta = price * 200.0;
|
||||
if change(&mut quote) {
|
||||
quotes.push(quote);
|
||||
}
|
||||
}
|
||||
}
|
||||
DataSet::from_components_with_actions_and_quotes(
|
||||
instruments,
|
||||
snapshots,
|
||||
Vec::new(),
|
||||
candidates,
|
||||
vec![limit_test_benchmark()],
|
||||
Vec::new(),
|
||||
quotes,
|
||||
)
|
||||
.unwrap()
|
||||
.with_additional_trading_dates([chrono::NaiveDate::from_ymd_opt(2024, 12, 31).unwrap()])
|
||||
}
|
||||
|
||||
fn pool_batch_decision(symbol: &str, generation: &str, end: &str) -> StrategyDecision {
|
||||
use crate::stock_pool_execution as pool;
|
||||
let signal = chrono::NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
|
||||
let symbols = vec![symbol.to_owned()];
|
||||
let rule = pool::StockPoolExecutionRule {
|
||||
pricing_mode: pool::POOL_PRICE_FIXED_LIMIT.into(),
|
||||
fixed_prices: [
|
||||
("000001.SZ".into(), rust_decimal::Decimal::new(104, 1)),
|
||||
("000002.SZ".into(), 10.into()),
|
||||
("000003.SZ".into(), 10.into()),
|
||||
]
|
||||
.into(),
|
||||
window_end: end.into(),
|
||||
..Default::default()
|
||||
};
|
||||
StrategyDecision {
|
||||
order_intents: vec![OrderIntent::StockPool {
|
||||
contract: Box::new(pool::FrozenStockPoolIntent {
|
||||
pool_id: "batch-test".into(),
|
||||
signal_date: signal,
|
||||
frozen_equity: 2000.into(),
|
||||
selection: pool::StockPoolSelection {
|
||||
trade_date: signal,
|
||||
requested_symbols: symbols.clone(),
|
||||
normal_trading_symbols: symbols.clone(),
|
||||
risk_eligible_symbols: symbols.clone(),
|
||||
final_symbols: symbols,
|
||||
exclusion_reasons: Default::default(),
|
||||
inherited_from_generation: None,
|
||||
explicit_empty: false,
|
||||
generation: Some(generation.into()),
|
||||
},
|
||||
members: vec![pool::StockPoolMemberSpec {
|
||||
symbol: symbol.into(),
|
||||
recommendation_reason: String::new(),
|
||||
requested_order: 0,
|
||||
target_weight_bps: None,
|
||||
stop_loss: None,
|
||||
take_profit: None,
|
||||
}],
|
||||
rule,
|
||||
constraints: pool::StockPoolDecisionConstraints {
|
||||
target_holding_count: Some(1),
|
||||
..Default::default()
|
||||
},
|
||||
invest_ratio_bps: 10000,
|
||||
reserve_cash: 0.into(),
|
||||
out_of_pool_policy: "reduce_to_zero_when_sellable".into(),
|
||||
generation: generation.into(),
|
||||
}),
|
||||
}],
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn pool_batch_broker(partial: bool) -> BrokerSimulator<ChinaAShareCostModel, ChinaEquityRuleHooks> {
|
||||
let cost = ChinaAShareCostModel::from_trading_constraints(
|
||||
crate::risk_control::TradingConstraintConfig {
|
||||
commission_rate: 0.0,
|
||||
minimum_commission: 0.0,
|
||||
stamp_tax_rate_before_change: 0.0,
|
||||
stamp_tax_rate_after_change: 0.0,
|
||||
transfer_fee_rate: 0.0,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let broker =
|
||||
BrokerSimulator::new_with_execution_price(cost, ChinaEquityRuleHooks, PriceField::Open)
|
||||
.with_matching_type(if partial {
|
||||
MatchingType::MinuteLast
|
||||
} else {
|
||||
MatchingType::NextBarOpen
|
||||
})
|
||||
.with_volume_limit(partial)
|
||||
.with_volume_percent(0.5)
|
||||
.with_liquidity_limit(false)
|
||||
.with_inactive_limit(false);
|
||||
if partial {
|
||||
broker
|
||||
.with_intraday_execution_start_time(chrono::NaiveTime::from_hms_opt(9, 30, 0).unwrap())
|
||||
} else {
|
||||
broker
|
||||
}
|
||||
}
|
||||
|
||||
fn pool_batch_account() -> PortfolioState {
|
||||
let mut account = PortfolioState::new(0.0);
|
||||
account.position_mut("000001.SZ").buy(
|
||||
chrono::NaiveDate::from_ymd_opt(2024, 12, 30).unwrap(),
|
||||
200,
|
||||
10.0,
|
||||
);
|
||||
account
|
||||
}
|
||||
|
||||
fn pool_batch_tick(
|
||||
broker: &BrokerSimulator<ChinaAShareCostModel, ChinaEquityRuleHooks>,
|
||||
account: &mut PortfolioState,
|
||||
data: &DataSet,
|
||||
minute: u32,
|
||||
decision: &StrategyDecision,
|
||||
) -> BrokerExecutionReport {
|
||||
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
|
||||
broker
|
||||
.runtime_intraday_start_time
|
||||
.set(Some(chrono::NaiveTime::from_hms_opt(9, minute, 0).unwrap()));
|
||||
broker
|
||||
.runtime_intraday_end_time
|
||||
.set(Some(chrono::NaiveTime::from_hms_opt(9, minute, 0).unwrap()));
|
||||
broker.execute(date, account, data, decision).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stock_pool_pending_sell_continues_buy_after_actual_fill_without_strategy_rerun() {
|
||||
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
|
||||
let signal = chrono::NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
|
||||
let data = pool_batch_data();
|
||||
let broker = pool_batch_broker(false);
|
||||
let mut account = pool_batch_account();
|
||||
let initial = broker
|
||||
.execute_with_event_dates(
|
||||
date,
|
||||
signal,
|
||||
signal,
|
||||
&mut account,
|
||||
&data,
|
||||
&pool_batch_decision("000002.SZ", "first", "09:35"),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(initial.fill_events.is_empty());
|
||||
assert_eq!(broker.open_order_views().len(), 1);
|
||||
assert_eq!(broker.open_order_views()[0].side, OrderSide::Sell);
|
||||
let done = pool_batch_tick(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
31,
|
||||
&StrategyDecision::default(),
|
||||
);
|
||||
assert!(
|
||||
done.fill_events
|
||||
.iter()
|
||||
.any(|fill| fill.symbol == "000001.SZ" && fill.side == OrderSide::Sell)
|
||||
);
|
||||
assert_eq!(
|
||||
account.position("000002.SZ").map(|p| p.quantity),
|
||||
Some(200),
|
||||
"sell proceeds must trigger the retained buy phase: {:?}",
|
||||
done.diagnostics
|
||||
);
|
||||
assert!(
|
||||
account
|
||||
.position("000001.SZ")
|
||||
.is_none_or(|p| p.quantity == 0)
|
||||
);
|
||||
let repeated = pool_batch_tick(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
32,
|
||||
&StrategyDecision::default(),
|
||||
);
|
||||
assert!(repeated.order_events.is_empty() && repeated.fill_events.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stock_pool_partial_sell_waits_for_the_whole_batch_and_never_reissues_buys() {
|
||||
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
|
||||
let signal = chrono::NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
|
||||
let data = pool_batch_data();
|
||||
let broker = pool_batch_broker(true);
|
||||
let mut account = pool_batch_account();
|
||||
broker
|
||||
.execute_with_event_dates(
|
||||
date,
|
||||
signal,
|
||||
signal,
|
||||
&mut account,
|
||||
&data,
|
||||
&pool_batch_decision("000002.SZ", "partial", "09:35"),
|
||||
)
|
||||
.unwrap();
|
||||
let first = pool_batch_tick(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
31,
|
||||
&StrategyDecision::default(),
|
||||
);
|
||||
assert_eq!(account.position("000001.SZ").unwrap().quantity, 100);
|
||||
assert!(account.position("000002.SZ").is_none());
|
||||
assert!(
|
||||
first
|
||||
.order_events
|
||||
.iter()
|
||||
.all(|event| event.side == OrderSide::Sell)
|
||||
);
|
||||
let second = pool_batch_tick(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
32,
|
||||
&StrategyDecision::default(),
|
||||
);
|
||||
let third = pool_batch_tick(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
33,
|
||||
&StrategyDecision::default(),
|
||||
);
|
||||
assert_eq!(account.position("000002.SZ").unwrap().quantity, 200);
|
||||
let ids = second
|
||||
.order_events
|
||||
.iter()
|
||||
.chain(&third.order_events)
|
||||
.filter(|event| event.side == OrderSide::Buy)
|
||||
.filter_map(|event| event.order_id)
|
||||
.collect::<std::collections::BTreeSet<_>>();
|
||||
assert_eq!(
|
||||
ids.len(),
|
||||
1,
|
||||
"one buy intention; partial reports must keep its ID"
|
||||
);
|
||||
assert!(
|
||||
pool_batch_tick(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
34,
|
||||
&StrategyDecision::default()
|
||||
)
|
||||
.order_events
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stock_pool_delayed_sell_does_not_start_buys_after_the_configured_window() {
|
||||
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
|
||||
let signal = chrono::NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
|
||||
let data = pool_batch_data();
|
||||
let broker = pool_batch_broker(true);
|
||||
let mut account = pool_batch_account();
|
||||
broker
|
||||
.execute_with_event_dates(
|
||||
date,
|
||||
signal,
|
||||
signal,
|
||||
&mut account,
|
||||
&data,
|
||||
&pool_batch_decision("000002.SZ", "expired", "09:32"),
|
||||
)
|
||||
.unwrap();
|
||||
pool_batch_tick(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
31,
|
||||
&StrategyDecision::default(),
|
||||
);
|
||||
let last = pool_batch_tick(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
32,
|
||||
&StrategyDecision::default(),
|
||||
);
|
||||
assert!(account.position("000002.SZ").is_none());
|
||||
assert!(
|
||||
last.order_events
|
||||
.iter()
|
||||
.all(|event| event.side == OrderSide::Sell)
|
||||
);
|
||||
assert!(
|
||||
last.diagnostics
|
||||
.iter()
|
||||
.any(|event| event.contains("unsubmitted_phase_expired"))
|
||||
);
|
||||
assert!(!broker.has_pending_stock_pool_execution());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stock_pool_new_signal_supersedes_the_unsubmitted_buy_phase() {
|
||||
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
|
||||
let signal = chrono::NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
|
||||
let data = pool_batch_data();
|
||||
let broker = pool_batch_broker(true);
|
||||
let mut account = pool_batch_account();
|
||||
broker
|
||||
.execute_with_event_dates(
|
||||
date,
|
||||
signal,
|
||||
signal,
|
||||
&mut account,
|
||||
&data,
|
||||
&pool_batch_decision("000002.SZ", "old", "09:35"),
|
||||
)
|
||||
.unwrap();
|
||||
pool_batch_tick(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
31,
|
||||
&pool_batch_decision("000003.SZ", "new", "09:35"),
|
||||
);
|
||||
pool_batch_tick(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
32,
|
||||
&StrategyDecision::default(),
|
||||
);
|
||||
pool_batch_tick(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
33,
|
||||
&StrategyDecision::default(),
|
||||
);
|
||||
assert!(account.position("000002.SZ").is_none());
|
||||
assert_eq!(account.position("000003.SZ").unwrap().quantity, 200);
|
||||
assert!(!broker.has_pending_stock_pool_execution());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stock_pool_after_sell_uses_fresh_quotes_and_actual_submission_clock() {
|
||||
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
|
||||
let signal = chrono::NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
|
||||
let data = pool_batch_data_with(|quote| {
|
||||
if quote.symbol == "000002.SZ" {
|
||||
quote.last_price = 10.2;
|
||||
quote.bid1 = 10.2;
|
||||
quote.ask1 = 10.2;
|
||||
quote.amount_delta = 2040.0;
|
||||
}
|
||||
true
|
||||
});
|
||||
let broker = pool_batch_broker(false);
|
||||
let mut account = pool_batch_account();
|
||||
let mut decision = pool_batch_decision("000002.SZ", "fresh", "09:35");
|
||||
if let OrderIntent::StockPool { contract } = &mut decision.order_intents[0] {
|
||||
contract.rule.pricing_mode = crate::stock_pool_execution::POOL_PRICE_FORMULA_LIMIT.into();
|
||||
contract.rule.sell_offset_bps = 400;
|
||||
}
|
||||
broker
|
||||
.execute_with_event_dates(date, signal, signal, &mut account, &data, &decision)
|
||||
.unwrap();
|
||||
let result = pool_batch_tick(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
31,
|
||||
&StrategyDecision::default(),
|
||||
);
|
||||
assert_eq!(
|
||||
account.position("000002.SZ").unwrap().quantity,
|
||||
100,
|
||||
"2000/10.2 rounds to one 100-share lot, not 200 at stale open"
|
||||
);
|
||||
let fill = result
|
||||
.fill_events
|
||||
.iter()
|
||||
.find(|fill| fill.symbol == "000002.SZ")
|
||||
.unwrap();
|
||||
assert_eq!(fill.price, 10.2);
|
||||
assert_eq!(
|
||||
fill.execution_start_timestamp,
|
||||
Some(date.and_hms_opt(9, 31, 0).unwrap())
|
||||
);
|
||||
let event = result
|
||||
.order_events
|
||||
.iter()
|
||||
.find(|event| event.side == OrderSide::Buy)
|
||||
.unwrap();
|
||||
assert_eq!(event.decision_date, Some(signal));
|
||||
assert_eq!(event.order_created_date, Some(date));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stock_pool_after_sell_rejects_missing_quote_instead_of_reusing_daily_open() {
|
||||
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
|
||||
let signal = chrono::NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
|
||||
let data = pool_batch_data_with(|quote| quote.symbol != "000002.SZ");
|
||||
let broker = pool_batch_broker(false);
|
||||
let mut account = pool_batch_account();
|
||||
broker
|
||||
.execute_with_event_dates(
|
||||
date,
|
||||
signal,
|
||||
signal,
|
||||
&mut account,
|
||||
&data,
|
||||
&pool_batch_decision("000002.SZ", "missing", "09:35"),
|
||||
)
|
||||
.unwrap();
|
||||
broker
|
||||
.runtime_intraday_start_time
|
||||
.set(Some(chrono::NaiveTime::from_hms_opt(9, 31, 0).unwrap()));
|
||||
broker
|
||||
.runtime_intraday_end_time
|
||||
.set(Some(chrono::NaiveTime::from_hms_opt(9, 31, 0).unwrap()));
|
||||
let error = broker
|
||||
.execute(date, &mut account, &data, &StrategyDecision::default())
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("stock_pool_execution_quote_missing:000002.SZ"),
|
||||
"{error}"
|
||||
);
|
||||
assert!(account.position("000002.SZ").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stock_pool_delayed_take_profit_does_not_rebuy_the_same_generation_exit() {
|
||||
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
|
||||
let signal = chrono::NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
|
||||
let data = pool_batch_data();
|
||||
let broker = pool_batch_broker(false);
|
||||
let mut account = PortfolioState::new(0.0);
|
||||
account.position_mut("000001.SZ").buy(
|
||||
chrono::NaiveDate::from_ymd_opt(2024, 12, 30).unwrap(),
|
||||
200,
|
||||
9.0,
|
||||
);
|
||||
let mut decision = pool_batch_decision("000002.SZ", "take-profit", "09:35");
|
||||
if let OrderIntent::StockPool { contract } = &mut decision.order_intents[0] {
|
||||
let symbols = vec!["000001.SZ".to_owned(), "000002.SZ".to_owned()];
|
||||
contract.selection.requested_symbols = symbols.clone();
|
||||
contract.selection.normal_trading_symbols = symbols.clone();
|
||||
contract.selection.risk_eligible_symbols = symbols.clone();
|
||||
contract.selection.final_symbols = symbols;
|
||||
contract.constraints.target_holding_count = Some(2);
|
||||
contract.members.insert(
|
||||
0,
|
||||
crate::stock_pool_execution::StockPoolMemberSpec {
|
||||
symbol: "000001.SZ".into(),
|
||||
recommendation_reason: String::new(),
|
||||
requested_order: 0,
|
||||
target_weight_bps: None,
|
||||
stop_loss: None,
|
||||
take_profit: Some(rust_decimal::Decimal::new(5, 2)),
|
||||
},
|
||||
);
|
||||
contract.members[1].requested_order = 1;
|
||||
}
|
||||
broker
|
||||
.execute_with_event_dates(date, signal, signal, &mut account, &data, &decision)
|
||||
.unwrap();
|
||||
let result = pool_batch_tick(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
31,
|
||||
&StrategyDecision::default(),
|
||||
);
|
||||
assert!(
|
||||
account
|
||||
.position("000001.SZ")
|
||||
.is_none_or(|p| p.quantity == 0)
|
||||
);
|
||||
assert_eq!(account.position("000002.SZ").unwrap().quantity, 200);
|
||||
assert!(
|
||||
!result
|
||||
.order_events
|
||||
.iter()
|
||||
.any(|event| event.symbol == "000001.SZ" && event.side == OrderSide::Buy)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stock_pool_pending_phase_cannot_cross_the_execution_session() {
|
||||
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
|
||||
let signal = chrono::NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
|
||||
let data = pool_batch_data();
|
||||
let broker = pool_batch_broker(false);
|
||||
let mut account = pool_batch_account();
|
||||
let mut report = broker
|
||||
.execute_with_event_dates(
|
||||
date,
|
||||
signal,
|
||||
signal,
|
||||
&mut account,
|
||||
&data,
|
||||
&pool_batch_decision("000002.SZ", "end", "09:35"),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(broker.has_pending_stock_pool_execution());
|
||||
broker.finish_stock_pool_session(date, &mut report);
|
||||
assert!(!broker.has_pending_stock_pool_execution());
|
||||
assert!(
|
||||
report
|
||||
.diagnostics
|
||||
.iter()
|
||||
.any(|event| event.contains("unsubmitted_phase_expired"))
|
||||
);
|
||||
assert_eq!(
|
||||
broker.open_order_views().len(),
|
||||
1,
|
||||
"session cleanup preserves broker order history and remainder"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stock_pool_engine_drives_the_pending_buy_without_a_minute_strategy_callback() {
|
||||
use crate::{BacktestConfig, BacktestEngine, BacktestError, Strategy, StrategyContext};
|
||||
struct DailyPool;
|
||||
impl Strategy for DailyPool {
|
||||
fn name(&self) -> &str {
|
||||
"daily-pool-batch"
|
||||
}
|
||||
fn requires_minute_callbacks(&self) -> bool {
|
||||
false
|
||||
}
|
||||
fn schedule_rules(&self) -> Vec<crate::ScheduleRule> {
|
||||
vec![
|
||||
crate::ScheduleRule::daily("open", crate::ScheduleStage::OnDay)
|
||||
.with_time_rule(crate::ScheduleTimeRule::physical_time(9, 30)),
|
||||
]
|
||||
}
|
||||
fn on_scheduled(
|
||||
&mut self,
|
||||
ctx: &StrategyContext<'_>,
|
||||
_: &crate::ScheduleRule,
|
||||
) -> Result<StrategyDecision, BacktestError> {
|
||||
if ctx.execution_date.day() == 2 {
|
||||
Ok(StrategyDecision {
|
||||
order_intents: vec![OrderIntent::LimitTargetShares {
|
||||
symbol: "000001.SZ".into(),
|
||||
target_quantity: 200,
|
||||
limit_price: 10.0,
|
||||
reason: "initial-entry".into(),
|
||||
}],
|
||||
..Default::default()
|
||||
})
|
||||
} else {
|
||||
Ok(pool_batch_decision("000002.SZ", "rotation", "09:35"))
|
||||
}
|
||||
}
|
||||
fn on_minute(
|
||||
&mut self,
|
||||
_: &StrategyContext<'_>,
|
||||
_: &IntradayExecutionQuote,
|
||||
) -> Result<StrategyDecision, BacktestError> {
|
||||
panic!("this daily strategy must not be rerun to continue a pending batch")
|
||||
}
|
||||
}
|
||||
use chrono::Datelike;
|
||||
let first = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
|
||||
let last = chrono::NaiveDate::from_ymd_opt(2025, 1, 3).unwrap();
|
||||
let base = pool_batch_data();
|
||||
let mut market = Vec::new();
|
||||
let mut factors = Vec::new();
|
||||
let mut candidates = Vec::new();
|
||||
let mut benchmarks = Vec::new();
|
||||
let mut quotes = Vec::new();
|
||||
for date in [first, last] {
|
||||
for symbol in ["000001.SZ", "000002.SZ", "000003.SZ"] {
|
||||
let mut row = base.market(first, symbol).unwrap().clone();
|
||||
row.date = date;
|
||||
market.push(row);
|
||||
let mut row = base.candidate(first, symbol).unwrap().clone();
|
||||
row.date = date;
|
||||
candidates.push(row);
|
||||
factors.push(crate::data::DailyFactorSnapshot {
|
||||
date,
|
||||
symbol: symbol.into(),
|
||||
market_cap_bn: 10.,
|
||||
free_float_cap_bn: 10.,
|
||||
pe_ttm: 10.,
|
||||
turnover_ratio: None,
|
||||
effective_turnover_ratio: None,
|
||||
adjustment_factor_backward1: Some(1.),
|
||||
extra_factors: Default::default(),
|
||||
});
|
||||
for original in base.execution_quotes_on(first, symbol) {
|
||||
let mut quote = original.clone();
|
||||
quote.date = date;
|
||||
quote.timestamp = date.and_time(original.timestamp.time());
|
||||
quotes.push(quote);
|
||||
}
|
||||
}
|
||||
let mut row = limit_test_benchmark();
|
||||
row.date = date;
|
||||
benchmarks.push(row);
|
||||
}
|
||||
let data = DataSet::from_components_with_actions_and_quotes(
|
||||
base.instruments().values().cloned().collect(),
|
||||
market,
|
||||
factors,
|
||||
candidates,
|
||||
benchmarks,
|
||||
Vec::new(),
|
||||
quotes,
|
||||
)
|
||||
.unwrap()
|
||||
.with_additional_trading_dates([chrono::NaiveDate::from_ymd_opt(2024, 12, 31).unwrap()]);
|
||||
let config = BacktestConfig {
|
||||
initial_cash: 2000.0,
|
||||
benchmark_code: "000852.SH".into(),
|
||||
start_date: Some(first),
|
||||
end_date: Some(last),
|
||||
decision_lag_trading_days: 0,
|
||||
execution_price_field: PriceField::Open,
|
||||
};
|
||||
let result = BacktestEngine::new(data, DailyPool, pool_batch_broker(false), config)
|
||||
.run()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
result.fills.len(),
|
||||
3,
|
||||
"initial buy, delayed sell, resumed buy: orders={:?} equity={:?}",
|
||||
result.order_events,
|
||||
result.equity_curve
|
||||
);
|
||||
assert_eq!(result.fills[2].symbol, "000002.SZ");
|
||||
assert_eq!(result.fills[2].quantity, 200);
|
||||
assert_eq!(
|
||||
result.fills[2].execution_timestamp,
|
||||
Some(last.and_hms_opt(9, 31, 0).unwrap())
|
||||
);
|
||||
assert_eq!(result.holdings_summary.len(), 1);
|
||||
}
|
||||
+235
-75
@@ -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::*;
|
||||
@@ -284,6 +284,8 @@ pub struct CorporateAction {
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct IntradayExecutionQuote {
|
||||
#[serde(default)]
|
||||
pub observation_kind: QuoteObservationKind,
|
||||
#[serde(with = "date_format")]
|
||||
pub date: NaiveDate,
|
||||
pub symbol: String,
|
||||
@@ -301,6 +303,14 @@ pub struct IntradayExecutionQuote {
|
||||
pub trading_phase: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum QuoteObservationKind {
|
||||
#[default]
|
||||
Unspecified,
|
||||
MinuteBar,
|
||||
}
|
||||
|
||||
/// Sparse same-day fields layered onto an already-built immutable daily panel.
|
||||
///
|
||||
/// These fields do not participate in daily price series, adjustment series,
|
||||
@@ -1407,6 +1417,7 @@ pub struct DataSet {
|
||||
corporate_actions_by_date: Arc<BTreeMap<NaiveDate, Vec<CorporateAction>>>,
|
||||
execution_quotes_by_date: Arc<HashMap<NaiveDate, HashMap<String, Vec<IntradayExecutionQuote>>>>,
|
||||
execution_quote_dates: Arc<Vec<NaiveDate>>,
|
||||
condition_totals: Arc<std::sync::Mutex<crate::stock_pool_quote_facts::SessionTotalsCache>>,
|
||||
order_book_depth_index: Arc<HashMap<(NaiveDate, String), Vec<IntradayOrderBookDepthLevel>>>,
|
||||
benchmark_by_date: Arc<BTreeMap<NaiveDate, BenchmarkSnapshot>>,
|
||||
market_series_by_symbol: Arc<AHashMap<String, Arc<SymbolPriceSeries>>>,
|
||||
@@ -1575,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);
|
||||
}
|
||||
@@ -1941,6 +1919,7 @@ impl DataSet {
|
||||
candidate_row_positions_by_date: Arc::new(candidate_row_positions_by_date),
|
||||
corporate_actions_by_date: Arc::new(corporate_actions_by_date),
|
||||
execution_quotes_by_date: Arc::new(execution_quotes_by_date),
|
||||
condition_totals: Arc::new(std::sync::Mutex::new(Default::default())),
|
||||
execution_quote_dates: Arc::new(execution_quote_dates),
|
||||
order_book_depth_index: Arc::new(order_book_depth_index),
|
||||
benchmark_by_date: Arc::new(benchmark_by_date),
|
||||
@@ -2271,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)
|
||||
@@ -2279,6 +2269,15 @@ impl DataSet {
|
||||
.unwrap_or(&[])
|
||||
}
|
||||
|
||||
pub fn execution_session_totals(&self, symbol: &str, at: NaiveDateTime) -> Result<(rust_decimal::Decimal, rust_decimal::Decimal), String> {
|
||||
let mut cache = self.condition_totals.lock().map_err(|_| "stock_pool_session_prefix_cache_poisoned")?;
|
||||
if cache.date != Some(at.date()) {
|
||||
cache.date = Some(at.date());
|
||||
cache.symbols.clear();
|
||||
}
|
||||
cache.symbols.entry(symbol.into()).or_insert_with(|| crate::stock_pool_quote_facts::MinutePrefix::build(at.date(), symbol, self.execution_quotes_on(at.date(), symbol))).at(at)
|
||||
}
|
||||
|
||||
pub fn has_execution_quotes_on_date(&self, date: NaiveDate) -> bool {
|
||||
self.execution_quotes_by_date
|
||||
.get(&date)
|
||||
@@ -2451,6 +2450,7 @@ impl DataSet {
|
||||
/// Replaces the run-local execution quote layer without touching the
|
||||
/// immutable daily panel.
|
||||
pub fn replace_execution_quotes(&mut self, quotes: Vec<IntradayExecutionQuote>) -> usize {
|
||||
self.condition_totals = Arc::new(std::sync::Mutex::new(Default::default()));
|
||||
let execution_quotes_by_date = build_execution_quote_index(quotes);
|
||||
let quote_count = execution_quotes_by_date
|
||||
.values()
|
||||
@@ -2466,6 +2466,7 @@ impl DataSet {
|
||||
}
|
||||
|
||||
pub fn add_execution_quotes(&mut self, quotes: Vec<IntradayExecutionQuote>) -> usize {
|
||||
self.condition_totals = Arc::new(std::sync::Mutex::new(Default::default()));
|
||||
let mut grouped = HashMap::<NaiveDate, HashMap<String, Vec<IntradayExecutionQuote>>>::new();
|
||||
for quote in quotes {
|
||||
grouped
|
||||
@@ -2566,6 +2567,7 @@ impl DataSet {
|
||||
}
|
||||
|
||||
pub fn remove_execution_quotes_on_date(&mut self, date: NaiveDate) -> usize {
|
||||
self.condition_totals = Arc::new(std::sync::Mutex::new(Default::default()));
|
||||
let removed = Arc::make_mut(&mut self.execution_quotes_by_date).remove(&date);
|
||||
let Some(rows_by_symbol) = removed else {
|
||||
return 0;
|
||||
@@ -2578,6 +2580,7 @@ impl DataSet {
|
||||
}
|
||||
|
||||
pub fn release_execution_quotes_on_date(&mut self, date: NaiveDate) -> usize {
|
||||
self.condition_totals = Arc::new(std::sync::Mutex::new(Default::default()));
|
||||
let row_count = self
|
||||
.execution_quotes_by_date
|
||||
.get(&date)
|
||||
@@ -4398,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()
|
||||
});
|
||||
@@ -4411,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();
|
||||
@@ -4447,6 +4450,38 @@ fn normalize_history_frequency(frequency: &str) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_daily_snapshot_bundle(
|
||||
mut bundle: DailySnapshotBundle,
|
||||
) -> Result<DailySnapshotBundle, DataSetError> {
|
||||
let date = bundle.date;
|
||||
if bundle.benchmark.date != date {
|
||||
return Err(DataSetError::InvalidDailyBundleComponentDate {
|
||||
kind: "benchmark",
|
||||
bundle_date: date,
|
||||
row_date: bundle.benchmark.date,
|
||||
symbol: bundle.benchmark.benchmark.clone(),
|
||||
});
|
||||
}
|
||||
validate_daily_bundle_component_dates(
|
||||
&bundle.market, date, "market", |row| row.date, |row| row.symbol.as_str(),
|
||||
)?;
|
||||
validate_daily_bundle_component_dates(
|
||||
&bundle.factors, date, "factor", |row| row.date, |row| row.symbol.as_str(),
|
||||
)?;
|
||||
validate_daily_bundle_component_dates(
|
||||
&bundle.candidates, date, "candidate", |row| row.date, |row| row.symbol.as_str(),
|
||||
)?;
|
||||
validate_daily_bundle_component_dates(
|
||||
&bundle.corporate_actions, date, "corporate_action", |row| row.date,
|
||||
|row| row.symbol.as_str(),
|
||||
)?;
|
||||
sort_rows_by_symbol_if_needed(&mut bundle.market, |row| row.symbol.as_str());
|
||||
bundle.factors = normalize_factor_snapshots(bundle.factors)?;
|
||||
sort_rows_by_symbol_if_needed(&mut bundle.factors, |row| row.symbol.as_str());
|
||||
sort_rows_by_symbol_if_needed(&mut bundle.candidates, |row| row.symbol.as_str());
|
||||
Ok(bundle)
|
||||
}
|
||||
|
||||
fn validate_daily_bundle_component_dates<T, D, S>(
|
||||
rows: &[T],
|
||||
bundle_date: NaiveDate,
|
||||
@@ -4507,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()) {
|
||||
@@ -4549,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()
|
||||
@@ -4565,6 +4601,8 @@ where
|
||||
debug_assert!(symbol_ids.windows(2).all(|window| window[0] < window[1]));
|
||||
(*date, symbol_ids)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.into_iter()
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -4644,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,
|
||||
@@ -4655,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(
|
||||
@@ -5158,7 +5200,7 @@ mod tests {
|
||||
&run_data.execution_quote_dates
|
||||
));
|
||||
|
||||
run_data.add_execution_quotes(vec![IntradayExecutionQuote {
|
||||
run_data.add_execution_quotes(vec![IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
timestamp: NaiveDateTime::parse_from_str("2025-01-02 10:18:00", "%Y-%m-%d %H:%M:%S")
|
||||
.unwrap(),
|
||||
@@ -5301,7 +5343,7 @@ mod tests {
|
||||
vec![benchmark_row("2025-01-02", 12.0)],
|
||||
)
|
||||
.unwrap();
|
||||
let quote = IntradayExecutionQuote {
|
||||
let quote = IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
timestamp,
|
||||
@@ -5403,7 +5445,7 @@ mod tests {
|
||||
successor_cash: None,
|
||||
};
|
||||
corporate_actions.push(corporate_action.clone());
|
||||
execution_quotes.push(IntradayExecutionQuote {
|
||||
execution_quotes.push(IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: symbols[0].to_string(),
|
||||
timestamp: date.and_hms_opt(10, 18, 0).unwrap(),
|
||||
@@ -5443,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 {
|
||||
@@ -5517,6 +5563,122 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parallel_daily_bundle_validation_keeps_earliest_error_and_component_order() {
|
||||
let bundles = || (2..30).rev().map(|day| {
|
||||
let date = NaiveDate::from_ymd_opt(2025, 1, day).unwrap();
|
||||
let mut benchmark = benchmark_row("2025-01-01", 20.0);
|
||||
benchmark.date = date;
|
||||
DailySnapshotBundle {
|
||||
date, benchmark,
|
||||
market: vec![market_row("2025-01-01", 10.0, 100)],
|
||||
factors: Vec::new(), candidates: Vec::new(), corporate_actions: Vec::new(),
|
||||
}
|
||||
}).collect::<Vec<_>>();
|
||||
for threads in [1, 2, 8] {
|
||||
let pool = rayon::ThreadPoolBuilder::new().num_threads(threads).build().unwrap();
|
||||
for _ in 0..4 {
|
||||
let result = pool.install(|| DataSet::from_daily_bundles_with_execution_quotes(
|
||||
Vec::new(), bundles(), Vec::new(),
|
||||
));
|
||||
assert!(matches!(result, Err(DataSetError::InvalidDailyBundleComponentDate {
|
||||
kind: "market", bundle_date, ..
|
||||
}) if bundle_date == NaiveDate::from_ymd_opt(2025, 1, 2).unwrap()));
|
||||
}
|
||||
let mut values = bundles();
|
||||
values.last_mut().unwrap().benchmark.date = NaiveDate::from_ymd_opt(2025, 1, 1).unwrap();
|
||||
let result = pool.install(|| DataSet::from_daily_bundles_with_execution_quotes(
|
||||
Vec::new(), values, Vec::new(),
|
||||
));
|
||||
assert!(matches!(result, Err(DataSetError::InvalidDailyBundleComponentDate {
|
||||
kind: "benchmark", bundle_date, ..
|
||||
}) if bundle_date == NaiveDate::from_ymd_opt(2025, 1, 2).unwrap()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parallel_daily_symbol_indices_match_scalar_for_sparse_and_empty_days() {
|
||||
let symbols = ["000001.SZ", "159915.SZ", "600000.SH", "932000.CSI", "custom-long-instrument"];
|
||||
let index = symbols.iter().enumerate()
|
||||
.map(|(id, symbol)| (symbol.to_string(), id as u32))
|
||||
.collect::<AHashMap<_, _>>();
|
||||
let groups = (1..29).map(|day| {
|
||||
let date = NaiveDate::from_ymd_opt(2025, 1, day).unwrap();
|
||||
let rows = symbols.iter().enumerate()
|
||||
.filter(|(id, _)| day % 7 != 0 && (*id + day as usize) % 3 != 0)
|
||||
.map(|(_, symbol)| symbol.to_string()).collect::<Vec<_>>();
|
||||
(date, rows)
|
||||
}).collect::<BTreeMap<_, _>>();
|
||||
let expected_ids = groups.iter().map(|(date, rows)| {
|
||||
(*date, rows.iter().map(|symbol| index[symbol]).collect::<Vec<_>>())
|
||||
}).collect::<BTreeMap<_, _>>();
|
||||
let expected_positions = expected_ids.iter().map(|(date, ids)| {
|
||||
let mut positions = vec![super::MISSING_ROW_POSITION; symbols.len()];
|
||||
for (row, id) in ids.iter().enumerate() { positions[*id as usize] = row as u32; }
|
||||
(*date, positions)
|
||||
}).collect::<BTreeMap<_, _>>();
|
||||
for threads in [1, 2, 8] {
|
||||
rayon::ThreadPoolBuilder::new().num_threads(threads).build().unwrap().install(|| {
|
||||
let ids = super::build_group_symbol_ids(&groups, &index, String::as_str);
|
||||
assert_eq!(ids, expected_ids);
|
||||
assert_eq!(super::build_dense_row_positions(&groups, &ids, symbols.len()), Some(expected_positions.clone()));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parallel_dense_index_rejects_invalid_days_without_publishing_partial_index() {
|
||||
let day1 = NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
|
||||
let day2 = NaiveDate::from_ymd_opt(2025, 1, 3).unwrap();
|
||||
let groups = BTreeMap::from([(day1, vec![0, 1]), (day2, vec![0, 1])]);
|
||||
let valid = BTreeMap::from([(day1, vec![0, 2]), (day2, vec![1, 2])]);
|
||||
for threads in [1, 2, 8] {
|
||||
rayon::ThreadPoolBuilder::new().num_threads(threads).build().unwrap().install(|| {
|
||||
for invalid in [vec![], vec![1], vec![1, 1], vec![1, 3], vec![1, u32::MAX]] {
|
||||
let mut ids = valid.clone();
|
||||
ids.insert(day2, invalid);
|
||||
assert!(super::build_dense_row_positions(&groups, &ids, 3).is_none());
|
||||
}
|
||||
let mut missing = valid.clone();
|
||||
missing.remove(&day2);
|
||||
assert!(super::build_dense_row_positions(&groups, &missing, 3).is_none());
|
||||
assert!(super::build_dense_row_positions(&groups, &valid, usize::MAX).is_none());
|
||||
assert!(super::build_dense_row_positions(&groups, &valid, super::MAX_DENSE_ROW_INDEX_BYTES).is_none());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symbol_id_union_preserves_lexical_order_and_all_component_sources() {
|
||||
let date = NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
|
||||
let instrument = Instrument {
|
||||
symbol: "932000.CSI".into(), name: "index".into(), board: "CSI".into(),
|
||||
round_lot: 100, listed_at: None, delisted_at: None, status: "active".into(),
|
||||
};
|
||||
let mut market = market_row("2025-01-02", -0.0, 0);
|
||||
market.symbol = "custom-long-instrument".into();
|
||||
let factor = DailyFactorSnapshot {
|
||||
date, symbol: "159915.SZ".into(), market_cap_bn: 0.0, free_float_cap_bn: 0.0,
|
||||
pe_ttm: 0.0, turnover_ratio: None, effective_turnover_ratio: None,
|
||||
adjustment_factor_backward1: None, extra_factors: NumericFactorMap::new(),
|
||||
};
|
||||
let candidate = CandidateEligibility {
|
||||
date, symbol: "000001.SZ".into(), is_st: true, is_star_st: true,
|
||||
is_new_listing: false, is_paused: true, allow_buy: false, allow_sell: false,
|
||||
is_kcb: false, is_one_yuan: false, risk_level_code: Some("test".into()),
|
||||
};
|
||||
let ids = super::build_symbol_id_index(
|
||||
&HashMap::from([(instrument.symbol.clone(), instrument)]),
|
||||
&BTreeMap::from([(date, vec![market.clone(), market])]),
|
||||
&BTreeMap::from([(date, vec![factor])]),
|
||||
&BTreeMap::from([(date, vec![candidate])]),
|
||||
);
|
||||
assert_eq!(ids, AHashMap::from_iter([
|
||||
("000001.SZ".to_string(), 0), ("159915.SZ".to_string(), 1),
|
||||
("932000.CSI".to_string(), 2), ("custom-long-instrument".to_string(), 3),
|
||||
]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_symbol_id_snapshot_lookups_preserve_alignment_for_sparse_rows() {
|
||||
let date = NaiveDate::parse_from_str("2025-01-02", "%Y-%m-%d").unwrap();
|
||||
@@ -6114,7 +6276,7 @@ mod tests {
|
||||
vec![benchmark_row("2025-01-02", 12.0)],
|
||||
)
|
||||
.unwrap();
|
||||
let quote = |symbol: &str, time: &str| IntradayExecutionQuote {
|
||||
let quote = |symbol: &str, time: &str| IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
timestamp: NaiveDateTime::parse_from_str(
|
||||
&format!("2025-01-02 {time}"),
|
||||
@@ -6193,7 +6355,7 @@ mod tests {
|
||||
#[test]
|
||||
fn shared_execution_quote_release_does_not_clone_the_base_map() {
|
||||
let date = NaiveDate::parse_from_str("2025-01-02", "%Y-%m-%d").unwrap();
|
||||
let quote = IntradayExecutionQuote {
|
||||
let quote = IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
timestamp: NaiveDateTime::parse_from_str("2025-01-02 10:18:00", "%Y-%m-%d %H:%M:%S")
|
||||
.unwrap(),
|
||||
@@ -6323,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,
|
||||
|
||||
+869
-315
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
@@ -37,6 +38,7 @@ pub mod stock_pool_execution;
|
||||
pub mod stock_pool_index_policy;
|
||||
pub mod stock_pool_market_cap;
|
||||
pub mod stock_pool_state;
|
||||
pub mod stock_pool_quote_facts;
|
||||
pub mod signal_contract;
|
||||
pub mod strategy_ai;
|
||||
pub mod universe;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,6 +212,11 @@ pub fn build_dataset_context(
|
||||
}
|
||||
|
||||
pub fn specs_in_value(value: &Value) -> Result<Vec<PatternSpec>, String> {
|
||||
// A runner bundle also contains source/extract copies. Follow the same
|
||||
// authoritative spec selection as the execution loader, not those copies.
|
||||
if let Some(spec) = value.get("strategySpec").or_else(|| value.get("strategy_spec")) {
|
||||
return specs_in_value(spec);
|
||||
}
|
||||
let mut specs = Vec::new();
|
||||
match value {
|
||||
Value::String(text) => specs.extend(crate::daily_patterns::expression_specs(text)?),
|
||||
@@ -268,9 +273,15 @@ mod tests {
|
||||
for (pool_key, source_key) in [("stockPool", "sourceCode"), ("stock_pool", "source_code")] {
|
||||
let value = json!({pool_key:pool,source_key:source,"runtimeExpressions":{"trading":{"buyFilterExpr":expr}}});
|
||||
assert_eq!(specs_in_value(&value).unwrap().len(), 2);
|
||||
for wrapper in ["strategySpec", "strategy_spec"] {
|
||||
let bundle = json!({wrapper:value,"strategy_source":{"source_code":source},
|
||||
"strategy_extract":{"parameters":{"source_code":source}}});
|
||||
assert_eq!(specs_in_value(&bundle).unwrap().len(), 2);
|
||||
}
|
||||
let mut invalid = value.clone();
|
||||
invalid[pool_key]["exit_signals"][0]["when_expr"] = json!("pattern_signal(not-json)");
|
||||
assert!(specs_in_value(&invalid).is_err(), "invalid actual conditions must still fail");
|
||||
assert!(specs_in_value(&json!({"strategySpec":invalid})).is_err());
|
||||
}
|
||||
assert_eq!(specs_in_value(&json!({"sourceCode":format!("risk.stop_loss({expr})")})).unwrap().len(),1);
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -10331,6 +10353,7 @@ impl PlatformExprStrategy {
|
||||
) -> (Vec<u32>, Vec<FidcRiskDecisionAudit>) {
|
||||
let mut symbol_ids = Vec::new();
|
||||
let mut decisions = Vec::new();
|
||||
let selection_checks_enabled = self.config.risk_config.static_rules.selection_checks_enabled();
|
||||
let mut eligible_symbols = vec![false; ctx.data.symbol_count()];
|
||||
let execution_day = ctx.data.daily_snapshot_view(date);
|
||||
let factor_day = ctx.data.daily_snapshot_view(factor_date);
|
||||
@@ -10376,7 +10399,9 @@ impl PlatformExprStrategy {
|
||||
let Some(market) = execution_day.market(symbol_id) else {
|
||||
continue;
|
||||
};
|
||||
let (reject_from_universe, selection_decision) = if collect_risk_decisions {
|
||||
let (reject_from_universe, selection_decision) = if !selection_checks_enabled {
|
||||
(false, None)
|
||||
} else if collect_risk_decisions {
|
||||
let decision = ChinaAShareRiskControl::selection_rejection_decision_with_config(
|
||||
date,
|
||||
candidate,
|
||||
@@ -14546,6 +14571,7 @@ mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use chrono::{NaiveDate, NaiveTime};
|
||||
use rhai::{Dynamic, Map};
|
||||
|
||||
use super::{
|
||||
CompiledRuntimeHelperArgs, PlatformAccountActionKind, PlatformExplicitActionStage,
|
||||
@@ -14573,6 +14599,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);
|
||||
@@ -14718,7 +14765,7 @@ mod tests {
|
||||
let date = d(2025, 1, 2);
|
||||
let symbol = "000001.SZ";
|
||||
let parts = single_symbol_platform_data(&[date], symbol).snapshot_components();
|
||||
let quotes = [(10, 18, 9.5), (14, 59, 10.5)].into_iter().map(|(hour, minute, price)| IntradayExecutionQuote {
|
||||
let quotes = [(10, 18, 9.5), (14, 59, 10.5)].into_iter().map(|(hour, minute, price)| IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date, symbol: symbol.to_string(), timestamp: date.and_hms_opt(hour, minute, 0).unwrap(),
|
||||
last_price: price, bid1: price, ask1: price, bid1_volume: 1000, ask1_volume: 1000,
|
||||
volume_delta: 1000, amount_delta: price * 1000.0, trading_phase: Some("continuous".to_string()),
|
||||
@@ -17542,6 +17589,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");
|
||||
@@ -17846,7 +17905,7 @@ mod tests {
|
||||
volume: 1_000_000,
|
||||
}],
|
||||
Vec::new(),
|
||||
vec![IntradayExecutionQuote {
|
||||
vec![IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
timestamp: date.and_hms_opt(10, 40, 0).expect("timestamp"),
|
||||
@@ -17989,7 +18048,7 @@ mod tests {
|
||||
volume: 1_000_000,
|
||||
}],
|
||||
Vec::new(),
|
||||
vec![IntradayExecutionQuote {
|
||||
vec![IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
timestamp: date.and_hms_opt(10, 40, 0).expect("timestamp"),
|
||||
@@ -18077,7 +18136,7 @@ mod tests {
|
||||
lower_limit: 4.50,
|
||||
price_tick: 0.01,
|
||||
};
|
||||
let quote = IntradayExecutionQuote {
|
||||
let quote = IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
timestamp: date.and_hms_opt(10, 40, 0).expect("timestamp"),
|
||||
@@ -18209,7 +18268,7 @@ mod tests {
|
||||
volume: 1_000_000,
|
||||
}],
|
||||
Vec::new(),
|
||||
vec![IntradayExecutionQuote {
|
||||
vec![IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
timestamp: date.and_hms_opt(10, 40, 0).expect("timestamp"),
|
||||
@@ -18454,7 +18513,7 @@ mod tests {
|
||||
volume: 1_000_000,
|
||||
}],
|
||||
Vec::new(),
|
||||
vec![IntradayExecutionQuote {
|
||||
vec![IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
timestamp: date.and_hms_opt(10, 39, 59).unwrap(),
|
||||
@@ -18871,7 +18930,7 @@ mod tests {
|
||||
],
|
||||
Vec::new(),
|
||||
vec![
|
||||
IntradayExecutionQuote {
|
||||
IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date: first_date,
|
||||
symbol: symbol.to_string(),
|
||||
timestamp: first_date.and_hms_opt(10, 40, 0).expect("valid timestamp"),
|
||||
@@ -18884,7 +18943,7 @@ mod tests {
|
||||
amount_delta: 23_990.0,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
},
|
||||
IntradayExecutionQuote {
|
||||
IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date: second_date,
|
||||
symbol: symbol.to_string(),
|
||||
timestamp: second_date.and_hms_opt(10, 31, 0).expect("valid timestamp"),
|
||||
@@ -19149,7 +19208,7 @@ mod tests {
|
||||
}],
|
||||
Vec::new(),
|
||||
vec![
|
||||
IntradayExecutionQuote {
|
||||
IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: delayed_symbol.to_string(),
|
||||
timestamp: date.and_hms_opt(9, 31, 0).expect("valid timestamp"),
|
||||
@@ -19162,7 +19221,7 @@ mod tests {
|
||||
amount_delta: 146_200.0,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
},
|
||||
IntradayExecutionQuote {
|
||||
IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: delayed_symbol.to_string(),
|
||||
timestamp: date.and_hms_opt(10, 18, 0).expect("valid timestamp"),
|
||||
@@ -19175,7 +19234,7 @@ mod tests {
|
||||
amount_delta: 145_000.0,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
},
|
||||
IntradayExecutionQuote {
|
||||
IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: other_symbol.to_string(),
|
||||
timestamp: date.and_hms_opt(10, 18, 0).expect("valid timestamp"),
|
||||
@@ -19450,7 +19509,7 @@ mod tests {
|
||||
}],
|
||||
Vec::new(),
|
||||
vec![
|
||||
IntradayExecutionQuote {
|
||||
IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: delayed_symbol.to_string(),
|
||||
timestamp: date.and_hms_opt(9, 31, 0).expect("valid timestamp"),
|
||||
@@ -19463,7 +19522,7 @@ mod tests {
|
||||
amount_delta: 146_300.0,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
},
|
||||
IntradayExecutionQuote {
|
||||
IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: other_symbol.to_string(),
|
||||
timestamp: date.and_hms_opt(10, 18, 0).expect("valid timestamp"),
|
||||
@@ -19699,7 +19758,7 @@ mod tests {
|
||||
],
|
||||
Vec::new(),
|
||||
vec![
|
||||
IntradayExecutionQuote {
|
||||
IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date: first_date,
|
||||
symbol: symbol.to_string(),
|
||||
timestamp: first_date.and_hms_opt(9, 31, 0).expect("valid timestamp"),
|
||||
@@ -19712,7 +19771,7 @@ mod tests {
|
||||
amount_delta: 56_450.0,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
},
|
||||
IntradayExecutionQuote {
|
||||
IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date: first_date,
|
||||
symbol: symbol.to_string(),
|
||||
timestamp: first_date.and_hms_opt(10, 18, 0).expect("valid timestamp"),
|
||||
@@ -19725,7 +19784,7 @@ mod tests {
|
||||
amount_delta: 49_300.0,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
},
|
||||
IntradayExecutionQuote {
|
||||
IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date: second_date,
|
||||
symbol: symbol.to_string(),
|
||||
timestamp: second_date.and_hms_opt(9, 31, 0).expect("valid timestamp"),
|
||||
@@ -20027,7 +20086,7 @@ mod tests {
|
||||
volume: 1_000_000,
|
||||
}],
|
||||
Vec::new(),
|
||||
vec![IntradayExecutionQuote {
|
||||
vec![IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
timestamp: date.and_hms_opt(10, 18, 0).expect("timestamp"),
|
||||
@@ -20485,7 +20544,7 @@ mod tests {
|
||||
vec![candidate],
|
||||
vec![benchmark],
|
||||
Vec::new(),
|
||||
vec![IntradayExecutionQuote {
|
||||
vec![IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
timestamp: date.and_hms_opt(9, 30, 0).expect("timestamp"),
|
||||
@@ -20672,7 +20731,7 @@ mod tests {
|
||||
prev_close: 998.0,
|
||||
volume: 1_000_000,
|
||||
};
|
||||
let quote = |date: NaiveDate, last_price: f64, volume_delta: u64| IntradayExecutionQuote {
|
||||
let quote = |date: NaiveDate, last_price: f64, volume_delta: u64| IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
timestamp: date.and_hms_opt(10, 15, 0).expect("timestamp"),
|
||||
@@ -21231,6 +21290,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]
|
||||
@@ -22285,7 +22375,7 @@ mod tests {
|
||||
volume: 1_000_000,
|
||||
}],
|
||||
Vec::new(),
|
||||
vec![IntradayExecutionQuote {
|
||||
vec![IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
timestamp: date.and_hms_opt(10, 18, 0).expect("timestamp"),
|
||||
@@ -22936,7 +23026,7 @@ mod tests {
|
||||
Vec::new(),
|
||||
symbols
|
||||
.iter()
|
||||
.map(|symbol| IntradayExecutionQuote {
|
||||
.map(|symbol| IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: (*symbol).to_string(),
|
||||
timestamp: date.and_hms_opt(10, 18, 0).expect("timestamp"),
|
||||
@@ -23090,7 +23180,7 @@ mod tests {
|
||||
}],
|
||||
Vec::new(),
|
||||
vec![
|
||||
IntradayExecutionQuote {
|
||||
IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
timestamp: date.and_hms_opt(9, 31, 0).expect("timestamp"),
|
||||
@@ -23103,7 +23193,7 @@ mod tests {
|
||||
amount_delta: 110_000.0,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
},
|
||||
IntradayExecutionQuote {
|
||||
IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
timestamp: date.and_hms_opt(10, 18, 0).expect("timestamp"),
|
||||
@@ -23247,7 +23337,7 @@ mod tests {
|
||||
}],
|
||||
Vec::new(),
|
||||
vec![
|
||||
IntradayExecutionQuote {
|
||||
IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
timestamp: date.and_hms_opt(9, 31, 0).expect("timestamp"),
|
||||
@@ -23260,7 +23350,7 @@ mod tests {
|
||||
amount_delta: 108_000.0,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
},
|
||||
IntradayExecutionQuote {
|
||||
IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
timestamp: date.and_hms_opt(10, 18, 0).expect("timestamp"),
|
||||
@@ -23404,7 +23494,7 @@ mod tests {
|
||||
}],
|
||||
Vec::new(),
|
||||
vec![
|
||||
IntradayExecutionQuote {
|
||||
IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
timestamp: date.and_hms_opt(9, 31, 0).expect("timestamp"),
|
||||
@@ -23417,7 +23507,7 @@ mod tests {
|
||||
amount_delta: 110_000.0,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
},
|
||||
IntradayExecutionQuote {
|
||||
IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
timestamp: date.and_hms_opt(10, 18, 0).expect("timestamp"),
|
||||
@@ -23574,7 +23664,7 @@ mod tests {
|
||||
Vec::new(),
|
||||
symbols
|
||||
.iter()
|
||||
.map(|symbol| IntradayExecutionQuote {
|
||||
.map(|symbol| IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: (*symbol).to_string(),
|
||||
timestamp: date.and_hms_opt(10, 18, 0).expect("timestamp"),
|
||||
@@ -23889,7 +23979,7 @@ mod tests {
|
||||
volume: 1_000_000,
|
||||
}],
|
||||
Vec::new(),
|
||||
vec![IntradayExecutionQuote {
|
||||
vec![IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
timestamp: date.and_hms_opt(10, 40, 0).expect("timestamp"),
|
||||
@@ -24045,7 +24135,7 @@ mod tests {
|
||||
Vec::new(),
|
||||
symbols
|
||||
.iter()
|
||||
.map(|symbol| IntradayExecutionQuote {
|
||||
.map(|symbol| IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: (*symbol).to_string(),
|
||||
timestamp: date.and_hms_opt(10, 15, 0).expect("timestamp"),
|
||||
@@ -24214,7 +24304,7 @@ mod tests {
|
||||
volume: 1_000_000,
|
||||
}],
|
||||
Vec::new(),
|
||||
vec![IntradayExecutionQuote {
|
||||
vec![IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
timestamp: date.and_hms_opt(10, 15, 0).expect("timestamp"),
|
||||
@@ -24372,7 +24462,7 @@ mod tests {
|
||||
volume: 1_000_000,
|
||||
}],
|
||||
Vec::new(),
|
||||
vec![IntradayExecutionQuote {
|
||||
vec![IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
timestamp: date.and_hms_opt(9, 33, 0).expect("valid timestamp"),
|
||||
@@ -24494,7 +24584,7 @@ mod tests {
|
||||
}],
|
||||
Vec::new(),
|
||||
vec![
|
||||
IntradayExecutionQuote {
|
||||
IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
timestamp: date.and_hms_opt(10, 40, 0).expect("valid timestamp"),
|
||||
@@ -24507,7 +24597,7 @@ mod tests {
|
||||
amount_delta: 1_000.0,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
},
|
||||
IntradayExecutionQuote {
|
||||
IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
timestamp: date.and_hms_opt(14, 58, 59).expect("valid timestamp"),
|
||||
@@ -24520,7 +24610,7 @@ mod tests {
|
||||
amount_delta: 2_000.0,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
},
|
||||
IntradayExecutionQuote {
|
||||
IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
timestamp: date.and_hms_opt(14, 59, 2).expect("valid timestamp"),
|
||||
@@ -24743,7 +24833,7 @@ mod tests {
|
||||
volume: 1_000_000,
|
||||
}],
|
||||
Vec::new(),
|
||||
vec![IntradayExecutionQuote {
|
||||
vec![IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
timestamp: date.and_hms_opt(10, 39, 59).unwrap(),
|
||||
@@ -24872,6 +24962,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),
|
||||
@@ -24980,7 +25072,7 @@ mod tests {
|
||||
.collect(),
|
||||
Vec::new(),
|
||||
vec![
|
||||
IntradayExecutionQuote {
|
||||
IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date: execution_date,
|
||||
symbol: limit_symbol.to_string(),
|
||||
timestamp: execution_date
|
||||
@@ -24995,7 +25087,7 @@ mod tests {
|
||||
amount_delta: 233.0,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
},
|
||||
IntradayExecutionQuote {
|
||||
IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date: execution_date,
|
||||
symbol: fallback_symbol.to_string(),
|
||||
timestamp: execution_date
|
||||
@@ -25056,6 +25148,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");
|
||||
|
||||
@@ -25233,7 +25327,7 @@ mod tests {
|
||||
})
|
||||
.collect(),
|
||||
Vec::new(),
|
||||
vec![IntradayExecutionQuote {
|
||||
vec![IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date: execution_date,
|
||||
symbol: candidate_symbol.to_string(),
|
||||
timestamp: execution_date.and_hms_opt(9, 33, 0).unwrap(),
|
||||
@@ -27761,7 +27855,7 @@ mod tests {
|
||||
volume: 1_000_000,
|
||||
}],
|
||||
Vec::new(),
|
||||
vec![IntradayExecutionQuote {
|
||||
vec![IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: "000002.SZ".to_string(),
|
||||
timestamp: date.and_hms_opt(10, 18, 0).expect("valid timestamp"),
|
||||
@@ -28084,7 +28178,7 @@ mod tests {
|
||||
Vec::new(),
|
||||
symbols
|
||||
.iter()
|
||||
.map(|symbol| IntradayExecutionQuote {
|
||||
.map(|symbol| IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: (*symbol).to_string(),
|
||||
timestamp: date.and_hms_opt(10, 18, 0).expect("valid timestamp"),
|
||||
@@ -28242,7 +28336,7 @@ mod tests {
|
||||
Vec::new(),
|
||||
symbols
|
||||
.iter()
|
||||
.map(|symbol| IntradayExecutionQuote {
|
||||
.map(|symbol| IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: (*symbol).to_string(),
|
||||
timestamp: date.and_hms_opt(10, 40, 0).expect("timestamp"),
|
||||
@@ -28572,7 +28666,7 @@ mod tests {
|
||||
Vec::new(),
|
||||
symbols
|
||||
.iter()
|
||||
.map(|symbol| IntradayExecutionQuote {
|
||||
.map(|symbol| IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: (*symbol).to_string(),
|
||||
timestamp: date.and_hms_opt(10, 40, 0).expect("timestamp"),
|
||||
@@ -29342,7 +29436,7 @@ mod tests {
|
||||
Vec::new(),
|
||||
symbols
|
||||
.iter()
|
||||
.map(|symbol| IntradayExecutionQuote {
|
||||
.map(|symbol| IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: (*symbol).to_string(),
|
||||
timestamp: date.and_hms_opt(9, 33, 0).expect("valid timestamp"),
|
||||
@@ -29525,7 +29619,7 @@ mod tests {
|
||||
Vec::new(),
|
||||
symbols
|
||||
.iter()
|
||||
.map(|symbol| IntradayExecutionQuote {
|
||||
.map(|symbol| IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: (*symbol).to_string(),
|
||||
timestamp: date.and_hms_opt(10, 18, 0).expect("valid timestamp"),
|
||||
@@ -29736,7 +29830,7 @@ mod tests {
|
||||
Vec::new(),
|
||||
symbols
|
||||
.iter()
|
||||
.map(|symbol| IntradayExecutionQuote {
|
||||
.map(|symbol| IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: (*symbol).to_string(),
|
||||
timestamp: date.and_hms_opt(10, 18, 0).expect("valid timestamp"),
|
||||
@@ -29987,7 +30081,7 @@ mod tests {
|
||||
Vec::new(),
|
||||
symbols
|
||||
.iter()
|
||||
.map(|symbol| IntradayExecutionQuote {
|
||||
.map(|symbol| IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: (*symbol).to_string(),
|
||||
timestamp: date.and_hms_opt(10, 18, 0).expect("valid timestamp"),
|
||||
@@ -30203,7 +30297,7 @@ mod tests {
|
||||
Vec::new(),
|
||||
symbols
|
||||
.iter()
|
||||
.map(|symbol| IntradayExecutionQuote {
|
||||
.map(|symbol| IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: (*symbol).to_string(),
|
||||
timestamp: date.and_hms_opt(10, 18, 0).expect("valid timestamp"),
|
||||
@@ -30569,7 +30663,7 @@ mod tests {
|
||||
volume: 1_000_000,
|
||||
}],
|
||||
Vec::new(),
|
||||
vec![IntradayExecutionQuote {
|
||||
vec![IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
timestamp: date.and_hms_opt(10, 18, 0).expect("valid timestamp"),
|
||||
@@ -30750,7 +30844,7 @@ mod tests {
|
||||
}],
|
||||
Vec::new(),
|
||||
vec![
|
||||
IntradayExecutionQuote {
|
||||
IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: delayed_symbol.to_string(),
|
||||
timestamp: date.and_hms_opt(9, 31, 0).expect("valid timestamp"),
|
||||
@@ -30763,7 +30857,7 @@ mod tests {
|
||||
amount_delta: 105_000.0,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
},
|
||||
IntradayExecutionQuote {
|
||||
IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: delayed_symbol.to_string(),
|
||||
timestamp: date.and_hms_opt(10, 18, 0).expect("valid timestamp"),
|
||||
@@ -30776,7 +30870,7 @@ mod tests {
|
||||
amount_delta: 90_000.0,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
},
|
||||
IntradayExecutionQuote {
|
||||
IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: held_symbol.to_string(),
|
||||
timestamp: date.and_hms_opt(10, 18, 0).expect("valid timestamp"),
|
||||
@@ -30789,7 +30883,7 @@ mod tests {
|
||||
amount_delta: 100_000.0,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
},
|
||||
IntradayExecutionQuote {
|
||||
IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: buy_symbol.to_string(),
|
||||
timestamp: date.and_hms_opt(10, 18, 0).expect("valid timestamp"),
|
||||
@@ -30978,7 +31072,7 @@ mod tests {
|
||||
}],
|
||||
Vec::new(),
|
||||
vec![
|
||||
IntradayExecutionQuote {
|
||||
IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: delayed_symbol.to_string(),
|
||||
timestamp: date.and_hms_opt(9, 31, 0).expect("valid timestamp"),
|
||||
@@ -30991,7 +31085,7 @@ mod tests {
|
||||
amount_delta: 4_200.0,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
},
|
||||
IntradayExecutionQuote {
|
||||
IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: held_symbol.to_string(),
|
||||
timestamp: date.and_hms_opt(10, 18, 0).expect("valid timestamp"),
|
||||
@@ -31004,7 +31098,7 @@ mod tests {
|
||||
amount_delta: 100_000.0,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
},
|
||||
IntradayExecutionQuote {
|
||||
IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: buy_symbol.to_string(),
|
||||
timestamp: date.and_hms_opt(10, 18, 0).expect("valid timestamp"),
|
||||
@@ -31215,7 +31309,7 @@ mod tests {
|
||||
.flat_map(|symbol| {
|
||||
let mut quotes = Vec::new();
|
||||
if *symbol == delayed_symbol {
|
||||
quotes.push(IntradayExecutionQuote {
|
||||
quotes.push(IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: (*symbol).to_string(),
|
||||
timestamp: date.and_hms_opt(9, 31, 0).expect("valid timestamp"),
|
||||
@@ -31229,7 +31323,7 @@ mod tests {
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
});
|
||||
}
|
||||
quotes.push(IntradayExecutionQuote {
|
||||
quotes.push(IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: (*symbol).to_string(),
|
||||
timestamp: date.and_hms_opt(10, 18, 0).expect("valid timestamp"),
|
||||
@@ -31455,7 +31549,7 @@ mod tests {
|
||||
.flat_map(|symbol| {
|
||||
let mut quotes = Vec::new();
|
||||
if *symbol == delayed_symbol {
|
||||
quotes.push(IntradayExecutionQuote {
|
||||
quotes.push(IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: (*symbol).to_string(),
|
||||
timestamp: date.and_hms_opt(9, 31, 0).expect("valid timestamp"),
|
||||
@@ -31469,7 +31563,7 @@ mod tests {
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
});
|
||||
}
|
||||
quotes.push(IntradayExecutionQuote {
|
||||
quotes.push(IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: (*symbol).to_string(),
|
||||
timestamp: date.and_hms_opt(10, 18, 0).expect("valid timestamp"),
|
||||
@@ -32141,7 +32235,7 @@ mod tests {
|
||||
Vec::new(),
|
||||
symbols
|
||||
.iter()
|
||||
.map(|symbol| IntradayExecutionQuote {
|
||||
.map(|symbol| IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: (*symbol).to_string(),
|
||||
timestamp: date.and_hms_opt(9, 33, 0).expect("valid timestamp"),
|
||||
@@ -32430,7 +32524,7 @@ mod tests {
|
||||
Vec::new(),
|
||||
symbols
|
||||
.iter()
|
||||
.map(|symbol| IntradayExecutionQuote {
|
||||
.map(|symbol| IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: (*symbol).to_string(),
|
||||
timestamp: date.and_hms_opt(10, 18, 0).expect("valid timestamp"),
|
||||
@@ -32641,7 +32735,7 @@ mod tests {
|
||||
Vec::new(),
|
||||
symbols
|
||||
.iter()
|
||||
.map(|symbol| IntradayExecutionQuote {
|
||||
.map(|symbol| IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: symbol.clone(),
|
||||
timestamp: date.and_hms_opt(10, 18, 0).expect("valid timestamp"),
|
||||
@@ -32848,7 +32942,7 @@ mod tests {
|
||||
Vec::new(),
|
||||
symbols
|
||||
.iter()
|
||||
.map(|symbol| IntradayExecutionQuote {
|
||||
.map(|symbol| IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: (*symbol).to_string(),
|
||||
timestamp: date.and_hms_opt(14, 59, 0).expect("valid timestamp"),
|
||||
@@ -32991,7 +33085,7 @@ mod tests {
|
||||
volume: 1_000_000,
|
||||
}],
|
||||
Vec::new(),
|
||||
vec![IntradayExecutionQuote {
|
||||
vec![IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
timestamp: date.and_hms_opt(10, 18, 0).expect("valid timestamp"),
|
||||
@@ -33214,7 +33308,7 @@ mod tests {
|
||||
volume: 1_000_000,
|
||||
}],
|
||||
Vec::new(),
|
||||
vec![IntradayExecutionQuote {
|
||||
vec![IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
timestamp: date.and_hms_opt(9, 33, 0).expect("valid timestamp"),
|
||||
@@ -33351,7 +33445,7 @@ mod tests {
|
||||
volume: 1_000_000,
|
||||
}],
|
||||
Vec::new(),
|
||||
vec![IntradayExecutionQuote {
|
||||
vec![IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: other_symbol.to_string(),
|
||||
timestamp: date.and_hms_opt(10, 18, 0).unwrap(),
|
||||
@@ -33492,7 +33586,7 @@ mod tests {
|
||||
volume: 1_000_000,
|
||||
}],
|
||||
Vec::new(),
|
||||
vec![IntradayExecutionQuote {
|
||||
vec![IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date: decision_date,
|
||||
symbol: other_symbol.to_string(),
|
||||
timestamp: decision_date.and_hms_opt(10, 18, 0).unwrap(),
|
||||
@@ -36140,6 +36234,7 @@ mod tests {
|
||||
avg_price: 0.0,
|
||||
transaction_cost: 0.0,
|
||||
limit_price: 10.2,
|
||||
reserved_cash: None,
|
||||
reason: "pending_limit_sell".to_string(),
|
||||
}];
|
||||
let subscriptions = BTreeSet::new();
|
||||
@@ -36288,6 +36383,7 @@ mod tests {
|
||||
avg_price: 0.0,
|
||||
transaction_cost: 0.0,
|
||||
limit_price: 9.9,
|
||||
reserved_cash: None,
|
||||
reason: "pending_limit_buy".to_string(),
|
||||
},
|
||||
OpenOrderView {
|
||||
@@ -36302,6 +36398,7 @@ mod tests {
|
||||
avg_price: 0.0,
|
||||
transaction_cost: 0.0,
|
||||
limit_price: 10.2,
|
||||
reserved_cash: None,
|
||||
reason: "pending_limit_sell".to_string(),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -76,6 +76,26 @@ impl Default for StaticRiskRuleConfig {
|
||||
}
|
||||
}
|
||||
|
||||
impl StaticRiskRuleConfig {
|
||||
pub(crate) fn selection_checks_enabled(&self) -> bool {
|
||||
(self.blacklist_enabled && !self.blacklisted_symbols.is_empty())
|
||||
|| self.selection_state_checks_enabled()
|
||||
}
|
||||
|
||||
fn selection_state_checks_enabled(&self) -> bool {
|
||||
self.reject_st_selection
|
||||
|| self.reject_star_st_selection
|
||||
|| self.reject_paused_selection
|
||||
|| self.reject_inactive_selection
|
||||
|| self.reject_new_listing_selection
|
||||
|| self.reject_kcb_selection
|
||||
|| self.reject_bjse_selection
|
||||
|| self.reject_one_yuan_selection
|
||||
|| self.reject_upper_limit_selection
|
||||
|| self.reject_lower_limit_selection
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
pub struct TradingConstraintConfig {
|
||||
/// Shared execution limits. These fields intentionally use the same
|
||||
@@ -654,16 +674,7 @@ fn missing_risk_state_fields(code: &str) -> Vec<String> {
|
||||
fn missing_selection_risk_state_rejected(code: &str, config: &FidcRiskControlConfig) -> bool {
|
||||
let fields = missing_risk_state_fields(code);
|
||||
if fields.is_empty() {
|
||||
return config.static_rules.reject_st_selection
|
||||
|| config.static_rules.reject_star_st_selection
|
||||
|| config.static_rules.reject_paused_selection
|
||||
|| config.static_rules.reject_inactive_selection
|
||||
|| config.static_rules.reject_new_listing_selection
|
||||
|| config.static_rules.reject_kcb_selection
|
||||
|| config.static_rules.reject_bjse_selection
|
||||
|| config.static_rules.reject_one_yuan_selection
|
||||
|| config.static_rules.reject_upper_limit_selection
|
||||
|| config.static_rules.reject_lower_limit_selection;
|
||||
return config.static_rules.selection_state_checks_enabled();
|
||||
}
|
||||
missing_field_rejected(&fields, config, RiskCheckScope::Selection)
|
||||
}
|
||||
@@ -778,18 +789,7 @@ fn missing_single_field_rejected(
|
||||
RiskCheckScope::Sell => config.static_rules.reject_lower_limit_sell,
|
||||
},
|
||||
_ => match scope {
|
||||
RiskCheckScope::Selection => {
|
||||
config.static_rules.reject_st_selection
|
||||
|| config.static_rules.reject_star_st_selection
|
||||
|| config.static_rules.reject_paused_selection
|
||||
|| config.static_rules.reject_inactive_selection
|
||||
|| config.static_rules.reject_new_listing_selection
|
||||
|| config.static_rules.reject_kcb_selection
|
||||
|| config.static_rules.reject_bjse_selection
|
||||
|| config.static_rules.reject_one_yuan_selection
|
||||
|| config.static_rules.reject_upper_limit_selection
|
||||
|| config.static_rules.reject_lower_limit_selection
|
||||
}
|
||||
RiskCheckScope::Selection => config.static_rules.selection_state_checks_enabled(),
|
||||
RiskCheckScope::Buy => {
|
||||
config.static_rules.reject_st_buy
|
||||
|| config.static_rules.reject_star_st_buy
|
||||
@@ -914,6 +914,69 @@ mod tests {
|
||||
position
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_check_activation_covers_every_configured_flag_and_blacklist_state() {
|
||||
let fields = [
|
||||
"reject_st_selection", "reject_star_st_selection", "reject_paused_selection",
|
||||
"reject_inactive_selection", "reject_new_listing_selection", "reject_kcb_selection",
|
||||
"reject_bjse_selection", "reject_one_yuan_selection", "reject_upper_limit_selection",
|
||||
"reject_lower_limit_selection",
|
||||
];
|
||||
let base = serde_json::to_value(StaticRiskRuleConfig::default()).unwrap();
|
||||
let declared = base.as_object().unwrap().keys()
|
||||
.filter(|key| key.ends_with("_selection"))
|
||||
.map(String::as_str).collect::<BTreeSet<_>>();
|
||||
assert_eq!(declared, fields.into_iter().collect());
|
||||
for mask in 0..(1_u32 << fields.len()) {
|
||||
for (blacklist_enabled, populated) in [(false, false), (false, true), (true, false), (true, true)] {
|
||||
let mut value = base.clone();
|
||||
for (bit, field) in fields.iter().enumerate() {
|
||||
value[*field] = serde_json::json!(mask & (1 << bit) != 0);
|
||||
}
|
||||
value["blacklist_enabled"] = serde_json::json!(blacklist_enabled);
|
||||
value["blacklisted_symbols"] = if populated {
|
||||
serde_json::json!(["002633.SZ"])
|
||||
} else { serde_json::json!([]) };
|
||||
let config: StaticRiskRuleConfig = serde_json::from_value(value).unwrap();
|
||||
assert_eq!(config.selection_checks_enabled(), mask != 0 || (blacklist_enabled && populated));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inactive_selection_checks_preserve_missing_facts_and_execution_rejections() {
|
||||
let date = d(2025, 2, 6);
|
||||
let mut candidate = candidate(date);
|
||||
candidate.is_st = true;
|
||||
candidate.is_star_st = true;
|
||||
candidate.is_paused = true;
|
||||
candidate.is_new_listing = true;
|
||||
candidate.is_kcb = true;
|
||||
candidate.is_one_yuan = true;
|
||||
candidate.allow_buy = false;
|
||||
let snapshot = market(date, 0.9, 0.9);
|
||||
let config = FidcRiskControlConfig::default();
|
||||
assert!(!config.static_rules.selection_checks_enabled());
|
||||
let instrument = instrument("delisted", Some(date));
|
||||
for code in [None, Some("not_listed"), Some("inactive_or_delisted"),
|
||||
Some("missing_risk_state"), Some("missing_risk_state:is_st;is_kcb|allow_buy"),
|
||||
Some("missing_risk_state:unknown_fact"), Some("missing_risk_state:IS_PAUSED")] {
|
||||
candidate.risk_level_code = code.map(str::to_owned);
|
||||
assert_eq!(ChinaAShareRiskControl::selection_rejection_decision_with_config(
|
||||
date, &candidate, &snapshot, Some(&instrument), &config), None);
|
||||
}
|
||||
candidate.risk_level_code = None;
|
||||
assert_eq!(ChinaAShareRiskControl::buy_rejection_reason_with_config(
|
||||
date, &candidate, &snapshot, None, 0.9, &config), Some("paused"));
|
||||
assert_eq!(ChinaAShareRiskControl::sell_rejection_reason_with_config(
|
||||
date, &candidate, &snapshot, None, None, 0.9, &config), Some("paused"));
|
||||
let mut blacklist_only = config;
|
||||
blacklist_only.static_rules.blacklisted_symbols.insert(candidate.symbol.to_string());
|
||||
assert!(blacklist_only.static_rules.selection_checks_enabled());
|
||||
assert_eq!(ChinaAShareRiskControl::selection_rejection_reason_with_config(
|
||||
date, &candidate, &snapshot, None, &blacklist_only), Some("blacklisted"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_yuan_buy_rule_uses_execution_price_not_later_close_or_earlier_open() {
|
||||
let day = d(2025, 2, 6);
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
//! Condition facts are distinct from the quote's per-observation fill capacity.
|
||||
//! Only a complete, declared raw-minute prefix can prove a session total.
|
||||
use std::collections::BTreeMap;
|
||||
use chrono::{NaiveDate, NaiveDateTime, NaiveTime, Timelike};
|
||||
use rust_decimal::Decimal;
|
||||
use crate::data::IntradayExecutionQuote;
|
||||
use crate::stock_pool_execution::{parse_stock_pool_condition, StockPoolExecutionRule};
|
||||
|
||||
pub fn requires_session_totals(rule: &StockPoolExecutionRule) -> bool {
|
||||
[rule.buy_condition.as_str(), if rule.sell_trigger_mode == "condition" { rule.sell_condition.as_str() } else { "" }].into_iter().any(|condition| {
|
||||
parse_stock_pool_condition(condition).is_some_and(|(_, field, _, _)| matches!(field.as_str(), "volume" | "amount"))
|
||||
})
|
||||
}
|
||||
|
||||
/// The cash-equity minute feed includes the opening observation and a separate
|
||||
/// post-close segment. Trading eligibility remains owned by the dated rules.
|
||||
fn next_minute(time: NaiveTime) -> Option<NaiveTime> {
|
||||
let minute = time.hour() * 60 + time.minute();
|
||||
let next = match minute {
|
||||
570..=689 | 781..=899 | 906..=929 => minute + 1,
|
||||
690 => 781,
|
||||
900 => 906,
|
||||
_ => return None,
|
||||
};
|
||||
NaiveTime::from_hms_opt(next / 60, next % 60, 0)
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub(crate) struct SessionTotalsCache {
|
||||
pub date: Option<NaiveDate>,
|
||||
pub symbols: BTreeMap<String, MinutePrefix>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct MinutePrefix {
|
||||
values: BTreeMap<NaiveTime, (Decimal, Decimal)>,
|
||||
failure: String,
|
||||
}
|
||||
|
||||
impl MinutePrefix {
|
||||
pub fn build(date: NaiveDate, symbol: &str, quotes: &[IntradayExecutionQuote]) -> Self {
|
||||
let mut values = BTreeMap::new();
|
||||
let mut expected = NaiveTime::from_hms_opt(9, 30, 0).unwrap();
|
||||
let mut volume = 0_u64;
|
||||
let mut amount = Decimal::ZERO;
|
||||
let mut failure = format!("stock_pool_session_prefix_missing:{symbol}:{date}:{expected}");
|
||||
for quote in quotes {
|
||||
let time = quote.timestamp.time();
|
||||
if quote.date != date || quote.timestamp.date() != date || quote.symbol != symbol {
|
||||
failure = format!("stock_pool_session_prefix_identity_invalid:{symbol}:{date}");
|
||||
break;
|
||||
}
|
||||
if time != expected {
|
||||
failure = format!("stock_pool_session_prefix_missing:{symbol}:{date}:expected={expected}:observed={time}");
|
||||
break;
|
||||
}
|
||||
if quote.observation_kind != crate::data::QuoteObservationKind::MinuteBar {
|
||||
failure = format!("stock_pool_session_prefix_basis_unverified:{symbol}:{date}:{time}");
|
||||
break;
|
||||
}
|
||||
let Some(next_volume) = volume.checked_add(quote.volume_delta) else {
|
||||
failure = format!("stock_pool_session_volume_overflow:{symbol}:{date}:{time}");
|
||||
break;
|
||||
};
|
||||
let delta = if quote.amount_delta.is_finite() && quote.amount_delta >= 0.0 {
|
||||
quote.amount_delta.to_string().parse::<Decimal>().ok()
|
||||
} else { None };
|
||||
let Some(next_amount) = delta.and_then(|delta| amount.checked_add(delta)) else {
|
||||
failure = format!("stock_pool_session_amount_invalid:{symbol}:{date}:{time}");
|
||||
break;
|
||||
};
|
||||
volume = next_volume;
|
||||
amount = next_amount;
|
||||
values.insert(time, (Decimal::from(volume), amount));
|
||||
let Some(next) = next_minute(time) else { break };
|
||||
expected = next;
|
||||
failure = format!("stock_pool_session_prefix_missing:{symbol}:{date}:{expected}");
|
||||
}
|
||||
Self { values, failure }
|
||||
}
|
||||
|
||||
pub fn at(&self, at: NaiveDateTime) -> Result<(Decimal, Decimal), String> {
|
||||
let time = at.time().with_second(0).unwrap().with_nanosecond(0).unwrap();
|
||||
self.values.get(&time).copied().ok_or_else(|| self.failure.clone())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
fn quote(hour: u32, minute: u32, volume: u64, amount: f64) -> IntradayExecutionQuote {
|
||||
let date = NaiveDate::from_ymd_opt(2026, 9, 11).unwrap();
|
||||
IntradayExecutionQuote { observation_kind: crate::data::QuoteObservationKind::MinuteBar, date, symbol: "000001.SZ".into(), timestamp: date.and_hms_opt(hour, minute, 0).unwrap(),
|
||||
last_price: 10., bid1: 0., ask1: 0., bid1_volume: 0, ask1_volume: 0,
|
||||
volume_delta: volume, amount_delta: amount, trading_phase: Some("minute_execution_prices:raw-minute".into()) }
|
||||
}
|
||||
#[test]
|
||||
fn totals_use_only_the_complete_observed_prefix_and_keep_decimal_amounts() {
|
||||
let mut rows = vec![quote(9,30,100,10.01), quote(9,31,0,0.), quote(9,32,200,20.02)];
|
||||
let prefix = MinutePrefix::build(rows[0].date, "000001.SZ", &rows);
|
||||
assert_eq!(prefix.at(rows[1].timestamp).unwrap(), (100.into(), Decimal::new(1001,2)));
|
||||
assert_eq!(prefix.at(rows[2].timestamp).unwrap(), (300.into(), Decimal::new(3003,2)));
|
||||
rows[2].volume_delta = 999999;
|
||||
rows[2].amount_delta = f64::NAN;
|
||||
let changed = MinutePrefix::build(rows[0].date, "000001.SZ", &rows);
|
||||
assert_eq!(changed.at(rows[1].timestamp).unwrap(), prefix.at(rows[1].timestamp).unwrap());
|
||||
assert!(changed.at(rows[2].timestamp).unwrap_err().contains("amount_invalid"));
|
||||
}
|
||||
#[test]
|
||||
fn sparse_unverified_and_overflowing_quotes_cannot_be_called_session_totals() {
|
||||
let first = quote(9,30,100,1000.);
|
||||
for rows in [vec![quote(9,31,100,1000.)], vec![first.clone(), quote(9,32,100,1000.)]] {
|
||||
let prefix = MinutePrefix::build(first.date, "000001.SZ", &rows);
|
||||
assert!(prefix.at(rows.last().unwrap().timestamp).unwrap_err().contains("prefix_missing"));
|
||||
}
|
||||
let mut unknown = first.clone(); unknown.observation_kind = Default::default();
|
||||
assert!(MinutePrefix::build(first.date, "000001.SZ", &[unknown]).at(first.timestamp).unwrap_err().contains("basis_unverified"));
|
||||
let rows = [quote(9,30,u64::MAX,0.), quote(9,31,1,0.)];
|
||||
assert!(MinutePrefix::build(first.date, "000001.SZ", &rows).at(rows[1].timestamp).unwrap_err().contains("volume_overflow"));
|
||||
}
|
||||
#[test]
|
||||
fn lunch_and_post_close_gaps_follow_the_minute_feed_segments() {
|
||||
let mut rows = Vec::new(); let mut time = NaiveTime::from_hms_opt(9,30,0).unwrap();
|
||||
loop {
|
||||
rows.push(quote(time.hour(), time.minute(), 1, 0.01));
|
||||
let Some(next) = next_minute(time) else { break }; time=next;
|
||||
}
|
||||
let prefix=MinutePrefix::build(rows[0].date,"000001.SZ",&rows);
|
||||
assert_eq!(prefix.at(rows.last().unwrap().timestamp).unwrap(), (Decimal::from(rows.len()), Decimal::new(rows.len() as i64,2)));
|
||||
assert!(!rows.iter().any(|row| row.timestamp.time().hour()==12));
|
||||
assert!(!rows.iter().any(|row| row.timestamp.time()==NaiveTime::from_hms_opt(13,0,0).unwrap()));
|
||||
assert!(!rows.iter().any(|row| row.timestamp.time().hour()==15 && (1..6).contains(&row.timestamp.time().minute())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires FIDC_SESSION_PREFIX_SOURCE_JSON from the frozen Source minute response"]
|
||||
fn real_source_session_prefix_matches_observed_checkpoints() {
|
||||
let path=std::env::var("FIDC_SESSION_PREFIX_SOURCE_JSON").expect("explicit Source evidence path");
|
||||
let rows:Vec<IntradayExecutionQuote>=serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap();
|
||||
let date=NaiveDate::from_ymd_opt(2026,9,8).unwrap();
|
||||
assert_eq!(rows.len(),242);
|
||||
let prefix=MinutePrefix::build(date,"000063.SZ",&rows);
|
||||
for (hour,minute,volume,amount) in [(9,30,512700,17103672),(9,31,2296631,76576756),(9,32,2983531,99471024),(11,30,27868847,928167630),(13,1,28495518,948994890),(15,0,45625008,1518115100)] {
|
||||
assert_eq!(prefix.at(date.and_hms_opt(hour,minute,0).unwrap()).unwrap(),(Decimal::from(volume),Decimal::from(amount)));
|
||||
}
|
||||
assert!(prefix.at(date.and_hms_opt(15,30,0).unwrap()).unwrap_err().contains("prefix_missing"),"one final aggregate is not a verified intraday prefix");
|
||||
}
|
||||
}
|
||||
@@ -102,6 +102,7 @@ pub struct OpenOrderView {
|
||||
pub avg_price: f64,
|
||||
pub transaction_cost: f64,
|
||||
pub limit_price: f64,
|
||||
pub reserved_cash: Option<f64>,
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
@@ -497,6 +498,7 @@ impl StrategyContext<'_> {
|
||||
.iter()
|
||||
.filter(|order| order.side == OrderSide::Buy)
|
||||
.map(|order| {
|
||||
if let Some(reserved) = order.reserved_cash { return reserved; }
|
||||
let price = if order.limit_price.is_finite() {
|
||||
order.limit_price.max(0.0)
|
||||
} else {
|
||||
|
||||
@@ -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,17 +227,13 @@ 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;
|
||||
Ok(request
|
||||
.symbols
|
||||
.into_iter()
|
||||
.map(|symbol| IntradayExecutionQuote {
|
||||
.map(|symbol| IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date: request.date,
|
||||
symbol,
|
||||
timestamp: request.date.and_time(t(10, 17, 59)),
|
||||
@@ -210,20 +247,19 @@ fn engine_uses_preplanned_decision_symbols_without_recomputing_strategy_plan() {
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
})
|
||||
.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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -411,7 +447,7 @@ fn engine_preloads_declared_decision_quotes_for_current_positions() {
|
||||
Ok(request
|
||||
.symbols
|
||||
.into_iter()
|
||||
.map(|symbol| IntradayExecutionQuote {
|
||||
.map(|symbol| IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date: request.date,
|
||||
symbol,
|
||||
timestamp: request.date.and_time(t(10, 39, 59)),
|
||||
@@ -556,7 +592,7 @@ fn engine_reuses_preloaded_decision_quotes_without_loader_call() {
|
||||
],
|
||||
Vec::new(),
|
||||
vec![
|
||||
IntradayExecutionQuote {
|
||||
IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date: first,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
timestamp: first.and_time(t(10, 39, 59)),
|
||||
@@ -569,7 +605,7 @@ fn engine_reuses_preloaded_decision_quotes_without_loader_call() {
|
||||
amount_delta: 100_000.0,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
},
|
||||
IntradayExecutionQuote {
|
||||
IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date: second,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
timestamp: second.and_time(t(10, 39, 59)),
|
||||
@@ -826,7 +862,7 @@ fn engine_loads_distinct_decision_quote_times_on_same_day() {
|
||||
Ok(request
|
||||
.symbols
|
||||
.into_iter()
|
||||
.map(|symbol| IntradayExecutionQuote {
|
||||
.map(|symbol| IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date: request.date,
|
||||
symbol,
|
||||
timestamp: request.date.and_time(start_time) - Duration::seconds(1),
|
||||
|
||||
@@ -2209,7 +2209,7 @@ fn engine_runs_minute_hooks_and_executes_minute_orders() {
|
||||
}],
|
||||
Vec::new(),
|
||||
vec![
|
||||
IntradayExecutionQuote {
|
||||
IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
timestamp: dt(2025, 1, 2, 10, 18, 0),
|
||||
@@ -2222,7 +2222,7 @@ fn engine_runs_minute_hooks_and_executes_minute_orders() {
|
||||
amount_delta: 10_200.0,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
},
|
||||
IntradayExecutionQuote {
|
||||
IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: "000002.SZ".to_string(),
|
||||
timestamp: dt(2025, 1, 2, 10, 18, 0),
|
||||
@@ -2235,7 +2235,7 @@ fn engine_runs_minute_hooks_and_executes_minute_orders() {
|
||||
amount_delta: 20_400.0,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
},
|
||||
IntradayExecutionQuote {
|
||||
IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
timestamp: dt(2025, 1, 2, 10, 19, 0),
|
||||
@@ -2341,7 +2341,7 @@ fn engine_skips_empty_platform_style_minute_callbacks_between_schedule_times() {
|
||||
let date = d(2025, 1, 2);
|
||||
let mut data = single_day_anchor_data(date);
|
||||
data.add_execution_quotes(vec![
|
||||
IntradayExecutionQuote {
|
||||
IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
timestamp: dt(2025, 1, 2, 10, 18, 0),
|
||||
@@ -2354,7 +2354,7 @@ fn engine_skips_empty_platform_style_minute_callbacks_between_schedule_times() {
|
||||
amount_delta: 10_200.0,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
},
|
||||
IntradayExecutionQuote {
|
||||
IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
timestamp: dt(2025, 1, 2, 10, 19, 0),
|
||||
@@ -2519,7 +2519,7 @@ fn strategy_context_exposes_engine_native_data_helpers() {
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let quotes = vec![
|
||||
IntradayExecutionQuote {
|
||||
IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date: date2,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
timestamp: dt(2025, 1, 3, 14, 30, 0),
|
||||
@@ -2532,7 +2532,7 @@ fn strategy_context_exposes_engine_native_data_helpers() {
|
||||
amount_delta: 10_150.0,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
},
|
||||
IntradayExecutionQuote {
|
||||
IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date: date3,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
timestamp: dt(2025, 1, 6, 10, 18, 0),
|
||||
@@ -2545,7 +2545,7 @@ fn strategy_context_exposes_engine_native_data_helpers() {
|
||||
amount_delta: 10_250.0,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
},
|
||||
IntradayExecutionQuote {
|
||||
IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date: date3,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
timestamp: dt(2025, 1, 6, 10, 19, 0),
|
||||
@@ -2748,6 +2748,7 @@ fn strategy_context_exposes_engine_native_account_runtime_view() {
|
||||
avg_price: 0.0,
|
||||
transaction_cost: 0.0,
|
||||
limit_price: 12.0,
|
||||
reserved_cash: None,
|
||||
reason: "pending_buy".to_string(),
|
||||
}];
|
||||
let subscriptions = BTreeSet::new();
|
||||
|
||||
@@ -146,7 +146,7 @@ fn intraday_liquidity_data(date: NaiveDate, symbol: &str) -> DataSet {
|
||||
}],
|
||||
Vec::new(),
|
||||
vec![
|
||||
IntradayExecutionQuote {
|
||||
IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
timestamp: date.and_hms_opt(10, 18, 0).unwrap(),
|
||||
@@ -159,7 +159,7 @@ fn intraday_liquidity_data(date: NaiveDate, symbol: &str) -> DataSet {
|
||||
amount_delta: 10_000.0,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
},
|
||||
IntradayExecutionQuote {
|
||||
IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
timestamp: date.and_hms_opt(10, 19, 0).unwrap(),
|
||||
@@ -172,7 +172,7 @@ fn intraday_liquidity_data(date: NaiveDate, symbol: &str) -> DataSet {
|
||||
amount_delta: 10_000.0,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
},
|
||||
IntradayExecutionQuote {
|
||||
IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
timestamp: date.and_hms_opt(10, 20, 0).unwrap(),
|
||||
@@ -373,7 +373,7 @@ fn broker_executes_explicit_order_value_buy() {
|
||||
volume: 1_000_000,
|
||||
}],
|
||||
Vec::new(),
|
||||
vec![IntradayExecutionQuote {
|
||||
vec![IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: "000002.SZ".to_string(),
|
||||
timestamp: date.and_hms_opt(10, 18, 3).unwrap(),
|
||||
@@ -527,7 +527,7 @@ fn broker_delayed_limit_open_sell_uses_minute_price() {
|
||||
volume: 1_000_000,
|
||||
}],
|
||||
Vec::new(),
|
||||
vec![IntradayExecutionQuote {
|
||||
vec![IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
timestamp: date.and_hms_opt(9, 31, 0).unwrap(),
|
||||
@@ -663,7 +663,7 @@ fn broker_executes_order_shares_and_order_lots() {
|
||||
volume: 1_000_000,
|
||||
}],
|
||||
Vec::new(),
|
||||
vec![IntradayExecutionQuote {
|
||||
vec![IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: "000002.SZ".to_string(),
|
||||
timestamp: date.and_hms_opt(10, 18, 3).unwrap(),
|
||||
@@ -1104,7 +1104,7 @@ fn broker_executes_target_portfolio_smart_with_algo_order_style() {
|
||||
}],
|
||||
Vec::new(),
|
||||
vec![
|
||||
IntradayExecutionQuote {
|
||||
IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: "000002.SZ".to_string(),
|
||||
timestamp: date.and_hms_opt(9, 31, 0).unwrap(),
|
||||
@@ -1117,7 +1117,7 @@ fn broker_executes_target_portfolio_smart_with_algo_order_style() {
|
||||
amount_delta: 0.0,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
},
|
||||
IntradayExecutionQuote {
|
||||
IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: "000002.SZ".to_string(),
|
||||
timestamp: date.and_hms_opt(9, 35, 0).unwrap(),
|
||||
@@ -1920,7 +1920,7 @@ fn broker_applies_tick_size_slippage_on_intraday_last_fills() {
|
||||
volume: 1_000_000,
|
||||
}],
|
||||
Vec::new(),
|
||||
vec![IntradayExecutionQuote {
|
||||
vec![IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: "000002.SZ".to_string(),
|
||||
timestamp: date.and_hms_opt(10, 18, 0).unwrap(),
|
||||
@@ -2153,7 +2153,7 @@ fn broker_executes_intraday_last_on_start_quote_with_trade_delta() {
|
||||
volume: 1_000_000,
|
||||
}],
|
||||
Vec::new(),
|
||||
vec![IntradayExecutionQuote {
|
||||
vec![IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: "000002.SZ".to_string(),
|
||||
timestamp: date.and_hms_opt(9, 33, 0).unwrap(),
|
||||
@@ -2273,7 +2273,7 @@ fn broker_cancels_market_order_remainder_when_intraday_quote_liquidity_exhausted
|
||||
volume: 1_000_000,
|
||||
}],
|
||||
Vec::new(),
|
||||
vec![IntradayExecutionQuote {
|
||||
vec![IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: "000002.SZ".to_string(),
|
||||
timestamp: date.and_hms_opt(10, 18, 0).unwrap(),
|
||||
@@ -2509,7 +2509,7 @@ fn broker_splits_intraday_quote_fills_and_tracks_commission_by_order() {
|
||||
}],
|
||||
Vec::new(),
|
||||
vec![
|
||||
IntradayExecutionQuote {
|
||||
IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: "000002.SZ".to_string(),
|
||||
timestamp: date.and_hms_opt(10, 18, 3).unwrap(),
|
||||
@@ -2522,7 +2522,7 @@ fn broker_splits_intraday_quote_fills_and_tracks_commission_by_order() {
|
||||
amount_delta: 0.0,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
},
|
||||
IntradayExecutionQuote {
|
||||
IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: "000002.SZ".to_string(),
|
||||
timestamp: date.and_hms_opt(10, 18, 6).unwrap(),
|
||||
@@ -2682,7 +2682,7 @@ fn broker_aggregates_intraday_quote_fills_into_vwap_leg() {
|
||||
}],
|
||||
Vec::new(),
|
||||
vec![
|
||||
IntradayExecutionQuote {
|
||||
IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: "000002.SZ".to_string(),
|
||||
timestamp: date.and_hms_opt(10, 18, 3).unwrap(),
|
||||
@@ -2695,7 +2695,7 @@ fn broker_aggregates_intraday_quote_fills_into_vwap_leg() {
|
||||
amount_delta: 0.0,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
},
|
||||
IntradayExecutionQuote {
|
||||
IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: "000002.SZ".to_string(),
|
||||
timestamp: date.and_hms_opt(10, 18, 6).unwrap(),
|
||||
@@ -2839,7 +2839,7 @@ fn broker_executes_algo_vwap_value_with_time_window() {
|
||||
}],
|
||||
Vec::new(),
|
||||
vec![
|
||||
IntradayExecutionQuote {
|
||||
IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: "000002.SZ".to_string(),
|
||||
timestamp: date.and_hms_opt(10, 17, 59).unwrap(),
|
||||
@@ -2852,7 +2852,7 @@ fn broker_executes_algo_vwap_value_with_time_window() {
|
||||
amount_delta: 0.0,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
},
|
||||
IntradayExecutionQuote {
|
||||
IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: "000002.SZ".to_string(),
|
||||
timestamp: date.and_hms_opt(10, 18, 3).unwrap(),
|
||||
@@ -2865,7 +2865,7 @@ fn broker_executes_algo_vwap_value_with_time_window() {
|
||||
amount_delta: 0.0,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
},
|
||||
IntradayExecutionQuote {
|
||||
IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: "000002.SZ".to_string(),
|
||||
timestamp: date.and_hms_opt(10, 18, 6).unwrap(),
|
||||
@@ -2878,7 +2878,7 @@ fn broker_executes_algo_vwap_value_with_time_window() {
|
||||
amount_delta: 0.0,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
},
|
||||
IntradayExecutionQuote {
|
||||
IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: "000002.SZ".to_string(),
|
||||
timestamp: date.and_hms_opt(10, 18, 40).unwrap(),
|
||||
@@ -3001,7 +3001,7 @@ fn broker_executes_algo_twap_percent_across_window_quotes() {
|
||||
}],
|
||||
Vec::new(),
|
||||
vec![
|
||||
IntradayExecutionQuote {
|
||||
IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: "000002.SZ".to_string(),
|
||||
timestamp: date.and_hms_opt(10, 0, 0).unwrap(),
|
||||
@@ -3014,7 +3014,7 @@ fn broker_executes_algo_twap_percent_across_window_quotes() {
|
||||
amount_delta: 0.0,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
},
|
||||
IntradayExecutionQuote {
|
||||
IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: "000002.SZ".to_string(),
|
||||
timestamp: date.and_hms_opt(10, 15, 0).unwrap(),
|
||||
@@ -3027,7 +3027,7 @@ fn broker_executes_algo_twap_percent_across_window_quotes() {
|
||||
amount_delta: 0.0,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
},
|
||||
IntradayExecutionQuote {
|
||||
IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: "000002.SZ".to_string(),
|
||||
timestamp: date.and_hms_opt(10, 30, 0).unwrap(),
|
||||
@@ -3165,7 +3165,7 @@ fn broker_uses_best_own_price_for_intraday_matching() {
|
||||
volume: 1_000_000,
|
||||
}],
|
||||
Vec::new(),
|
||||
vec![IntradayExecutionQuote {
|
||||
vec![IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: "000002.SZ".to_string(),
|
||||
timestamp: date.and_hms_opt(10, 18, 3).unwrap(),
|
||||
@@ -3284,7 +3284,7 @@ fn broker_uses_best_counterparty_price_for_intraday_matching() {
|
||||
volume: 1_000_000,
|
||||
}],
|
||||
Vec::new(),
|
||||
vec![IntradayExecutionQuote {
|
||||
vec![IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: "000002.SZ".to_string(),
|
||||
timestamp: date.and_hms_opt(10, 18, 3).unwrap(),
|
||||
@@ -4915,7 +4915,7 @@ fn two_day_limit_order_data(day1_open: f64, day2_open: f64) -> DataSet {
|
||||
},
|
||||
],
|
||||
Vec::new(),
|
||||
[(day1, day1_open), (day2, day2_open)].into_iter().map(|(date, price)| IntradayExecutionQuote {
|
||||
[(day1, day1_open), (day2, day2_open)].into_iter().map(|(date, price)| IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date, symbol: "000002.SZ".into(), timestamp: date.and_hms_opt(9, 30, 0).unwrap(),
|
||||
last_price: price, bid1: price, ask1: price, bid1_volume: 0, ask1_volume: 0,
|
||||
volume_delta: 100_000, amount_delta: 100_000.0 * price,
|
||||
|
||||
@@ -55,7 +55,7 @@ fn dataset(day_count: usize, bars_per_day: usize) -> (DataSet, Vec<NaiveDate>) {
|
||||
let session_start = date.and_hms_opt(9, 30, 0).expect("valid session start");
|
||||
for offset in 0..bars_per_day {
|
||||
let timestamp = session_start + Duration::minutes(offset as i64);
|
||||
quotes.push(IntradayExecutionQuote {
|
||||
quotes.push(IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date: *date,
|
||||
symbol: SYMBOL.to_string(),
|
||||
timestamp,
|
||||
|
||||
@@ -7,6 +7,7 @@ use fidc_core::{
|
||||
PortfolioState, PriceField, StrategyDecision, platform_expr_config_from_value,
|
||||
};
|
||||
use rust_decimal::Decimal;
|
||||
use fidc_core::IntradayExecutionQuote;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
fn day(n: u32) -> NaiveDate {
|
||||
@@ -142,7 +143,7 @@ fn data_with_fund_rules(
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let quotes = market.iter().filter(|row| row.symbol != "000300.SH").map(|row| fidc_core::IntradayExecutionQuote {
|
||||
let quotes = market.iter().filter(|row| row.symbol != "000300.SH").map(|row| fidc_core::IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date: row.date, symbol: row.symbol.to_string(), timestamp: row.date.and_hms_opt(9, 30, 0).unwrap(),
|
||||
last_price: row.open, bid1: row.open, ask1: row.open, bid1_volume: 0, ask1_volume: 0,
|
||||
volume_delta: row.volume, amount_delta: row.open * row.volume as f64,
|
||||
@@ -489,6 +490,72 @@ fn repeating_the_same_partial_exit_generation_does_not_reduce_again() {
|
||||
assert_eq!(new_signal.fill_events.iter().filter(|fill|fill.symbol==code(1)).map(|fill|fill.quantity).sum::<u32>(),300);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn daily_execution_price_does_not_satisfy_an_unobserved_order_book_condition() {
|
||||
let data = data(false);
|
||||
for field in ["bid1", "ask1"] {
|
||||
let broker = broker(false);
|
||||
let mut account = PortfolioState::new(30000.);
|
||||
let mut intent = contract(day(2), 1, false);
|
||||
intent.rule.trigger_mode = "condition".into();
|
||||
intent.rule.buy_condition = format!("{field}>0");
|
||||
let result = broker.execute_with_event_dates(day(5), day(2), day(2), &mut account, &data, &decision(intent));
|
||||
assert!(result.unwrap_err().to_string().contains(field));
|
||||
assert!(account.positions().is_empty());
|
||||
assert_eq!(account.cash(), 30000.);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cumulative_conditions_do_not_consume_future_bars_or_inflate_fill_capacity() {
|
||||
let mut data = data(false);
|
||||
let mut quotes = Vec::new();
|
||||
for n in 1..=2 {
|
||||
let price = if n == 1 {20.} else {10.};
|
||||
for (minute, volume) in [(30,600), (31,0), (32,400)] {
|
||||
quotes.push(IntradayExecutionQuote {
|
||||
observation_kind: fidc_core::data::QuoteObservationKind::MinuteBar,
|
||||
date: day(5), symbol: code(n), timestamp: day(5).and_hms_opt(9,minute,0).unwrap(),
|
||||
last_price: price, bid1: 0., ask1: 0., bid1_volume: 0, ask1_volume: 0,
|
||||
volume_delta: volume, amount_delta: volume as f64 * price, trading_phase: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
data.replace_execution_quotes(quotes.clone());
|
||||
let at = chrono::NaiveTime::from_hms_opt(9,32,0).unwrap();
|
||||
for condition in ["volume>=1000", "amount>=20000"] {
|
||||
let broker=broker(true).with_matching_type(MatchingType::MinuteLast).with_intraday_execution_start_time(at);
|
||||
let mut account=PortfolioState::new(30000.);
|
||||
let mut intent=contract(day(5),1,false);
|
||||
intent.rule.buy_condition=condition.into();intent.rule.trigger_mode="condition".into();
|
||||
let report=broker.execute_with_event_dates(day(5),day(5),day(5),&mut account,&data,&decision(intent)).unwrap();
|
||||
assert_eq!(report.fill_events.iter().map(|fill|fill.quantity).sum::<u32>(),100,"{condition}: {report:?}");
|
||||
assert_eq!(data.execution_quotes_on(day(5),&code(1))[2].volume_delta,400);
|
||||
}
|
||||
let mut future=quotes.last().unwrap().clone();future.symbol=code(1);future.timestamp=day(5).and_hms_opt(9,33,0).unwrap();future.volume_delta=9000;future.amount_delta=180000.;
|
||||
data.add_execution_quotes(vec![future]);
|
||||
let broker=broker(false).with_matching_type(MatchingType::MinuteLast).with_intraday_execution_start_time(at);
|
||||
let mut account=PortfolioState::new(30000.);
|
||||
let mut intent=contract(day(5),1,false);intent.rule.buy_condition="volume>1000".into();intent.rule.trigger_mode="condition".into();
|
||||
let report=broker.execute_with_event_dates(day(5),day(5),day(5),&mut account,&data,&decision(intent)).unwrap();
|
||||
assert!(report.fill_events.is_empty(),"future volume must not satisfy this signal: {report:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_total_cache_is_invalidated_without_mutating_other_dataset_clones() {
|
||||
let mut original=data(false);
|
||||
let quote=IntradayExecutionQuote { observation_kind:fidc_core::data::QuoteObservationKind::MinuteBar,date:day(5),symbol:code(1),timestamp:day(5).and_hms_opt(9,30,0).unwrap(),last_price:20.,bid1:0.,ask1:0.,bid1_volume:0,ask1_volume:0,volume_delta:100,amount_delta:2000.,trading_phase:None };
|
||||
original.replace_execution_quotes(vec![quote.clone()]);
|
||||
assert_eq!(original.execution_session_totals(&code(1),quote.timestamp).unwrap().0,Decimal::from(100));
|
||||
let mut changed=original.clone();let mut next=quote.clone();next.timestamp=day(5).and_hms_opt(9,31,0).unwrap();
|
||||
changed.add_execution_quotes(vec![next.clone()]);
|
||||
assert_eq!(changed.execution_session_totals(&code(1),next.timestamp).unwrap().0,Decimal::from(200));
|
||||
assert!(original.execution_session_totals(&code(1),next.timestamp).is_err());
|
||||
changed.remove_execution_quotes_on_date(day(5));
|
||||
assert!(changed.execution_session_totals(&code(1),quote.timestamp).is_err());
|
||||
assert_eq!(original.execution_session_totals(&code(1),quote.timestamp).unwrap().0,Decimal::from(100));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parsed_pool_program_executes_daily_membership_changes_without_legacy_translation() {
|
||||
let intent = contract(day(2), 1, false);
|
||||
@@ -561,6 +628,37 @@ fn parsed_pool_program_executes_daily_membership_changes_without_legacy_translat
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pool_position_adjustments_use_execution_clock_and_restore_original_twenty_percent() {
|
||||
for timed in [false,true] {
|
||||
let program=StockPoolProgram { schema_version:1,pool_id:"position-clock".into(),version_id:"v1".into(),
|
||||
members:contract(day(2),1,false).members,exit_signals:vec![],
|
||||
allocation_policy:serde_json::json!({"target_holding_count":1,"invest_ratio_bps":2000,"portfolio_policy":{"schema_version":1,"membership":"follow_candidates","rebalance_weights":true}}),
|
||||
timing_policy:serde_json::json!({"auto_execute":true,"pricing_mode":"first_tick"}),stop_take_policy:serde_json::json!({}),out_of_pool_policy:"hold".into() };
|
||||
let risk=if timed {serde_json::json!({"positionExposureEvents":[
|
||||
{"eventId":"zero","sequence":1,"effectiveAt":"2026-01-05T09:30:00+08:00","action":"set","targetExposureBps":0},
|
||||
{"eventId":"restore","sequence":2,"effectiveAt":"2026-01-06T09:30:00+08:00","action":"restore"}
|
||||
]})}else{serde_json::json!({"positionExposureSchedule":[{"effectiveDate":"2026-01-05","targetExposureBps":1000}]})};
|
||||
let mut config=platform_expr_config_from_value("position-clock",&code(1),&serde_json::json!({
|
||||
"stockPool":program,"signalSymbol":code(1),"benchmark":{"instrumentId":"000300.SH"},"universe":{"include":[code(1),code(2)]},
|
||||
"runtimeExpressions":{"risk":risk}
|
||||
})).unwrap();
|
||||
config.market_cap_field="close".into();config.market_cap_lower_expr="0".into();config.market_cap_upper_expr="1.0e12".into();
|
||||
config.stock_filter_expr="true".into();config.selection_limit_expr="1".into();config.selection_candidate_limit_expr="2".into();config.rank_expr="0".into();
|
||||
config.matching_type=MatchingType::NextBarOpen;
|
||||
let result=BacktestEngine::new(data(false),PlatformExprStrategy::new(config),broker(false),BacktestConfig {
|
||||
// The raw engine retains its first signal day as a cash baseline;
|
||||
// Jan 2's signal executes Jan 5, across the fixture weekend.
|
||||
initial_cash:30000.,benchmark_code:"000300.SH".into(),start_date:Some(day(2)),end_date:Some(day(6)),
|
||||
decision_lag_trading_days:1,execution_price_field:PriceField::Open,
|
||||
}).run().unwrap();
|
||||
assert_eq!(result.fills.len(),1,"timed={timed}, fills={:?}",result.fills);
|
||||
assert_eq!(result.fills[0].symbol,code(1));
|
||||
assert_eq!(result.fills[0].quantity,if timed {300}else{100});
|
||||
assert_eq!(result.fills[0].date,if timed {day(6)}else{day(5)});
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parsed_typed_exit_program_keeps_ordinary_gates_and_independent_risk_targets_separate() {
|
||||
for (ordinary, risk, quote, sold) in [
|
||||
@@ -789,6 +887,42 @@ fn historical_etf_late_signal_freezes_money_and_requantifies_at_next_official_op
|
||||
assert!(result.terminal_audit.is_clean());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deferred_etf_open_does_not_appear_in_a_pre_open_minute_callback() {
|
||||
use fidc_core::strategy::{Strategy,StrategyContext};
|
||||
use std::{cell::RefCell,rc::Rc};
|
||||
struct ObservedPool { inner:EtfPoolSignal, observations:Rc<RefCell<Vec<(chrono::NaiveDateTime,u32,usize)>>> }
|
||||
impl Strategy for ObservedPool {
|
||||
fn name(&self)->&str {"ETF actual opening clock"}
|
||||
fn initial_subscriptions(&self)->BTreeSet<String> {BTreeSet::from([code(1)])}
|
||||
fn decision_quote_times(&self)->Vec<chrono::NaiveTime> {self.inner.decision_quote_times()}
|
||||
fn decision_quote_symbols(&mut self,ctx:&StrategyContext<'_>)->Result<BTreeSet<String>,fidc_core::BacktestError> {self.inner.decision_quote_symbols(ctx)}
|
||||
fn on_day(&mut self,ctx:&StrategyContext<'_>)->Result<StrategyDecision,fidc_core::BacktestError> {self.inner.on_day(ctx)}
|
||||
fn on_minute(&mut self,ctx:&StrategyContext<'_>,quote:&IntradayExecutionQuote)->Result<StrategyDecision,fidc_core::BacktestError> {
|
||||
if quote.date==day(5) {self.observations.borrow_mut().push((quote.timestamp,
|
||||
ctx.portfolio.position(&code(2)).map_or(0,|position|position.quantity),ctx.fills.iter().filter(|fill|fill.symbol==code(2)).count()));}
|
||||
Ok(StrategyDecision::default())
|
||||
}
|
||||
}
|
||||
let time=chrono::NaiveTime::from_hms_opt(13,0,0).unwrap();
|
||||
let mut data=etf_fallback_fixture(time);
|
||||
let quote=data.execution_quotes_on(day(5),&code(1))[0].clone();
|
||||
data.add_execution_quotes([(9,15),(9,31)].into_iter().map(|(hour,minute)| {
|
||||
let mut row=quote.clone();row.timestamp=day(5).and_hms_opt(hour,minute,0).unwrap();row
|
||||
}).collect());
|
||||
let observations=Rc::new(RefCell::new(Vec::new()));
|
||||
let broker=broker(false).with_matching_type(MatchingType::MinuteLast)
|
||||
.with_execution_price_field(PriceField::Last).with_intraday_execution_start_time(time)
|
||||
.with_historical_etf_open_fallback(true);
|
||||
let result=BacktestEngine::new(data,ObservedPool {inner:EtfPoolSignal{at:time,condition:String::new()},observations:observations.clone()},broker,BacktestConfig {
|
||||
initial_cash:30000.,benchmark_code:"000300.SH".into(),start_date:Some(day(2)),end_date:Some(day(5)),decision_lag_trading_days:0,execution_price_field:PriceField::Last,
|
||||
}).with_execution_quote_loader(|_|Ok(vec![])).run().unwrap();
|
||||
let observations=observations.borrow();
|
||||
assert_eq!(observations[0],(day(5).and_hms_opt(9,15,0).unwrap(),0,0));
|
||||
assert_eq!(observations[1],(day(5).and_hms_opt(9,31,0).unwrap(),3700,1));
|
||||
assert_eq!(result.fills.iter().filter(|fill|fill.symbol==code(2)).count(),1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn historical_etf_pending_target_at_end_is_not_a_fake_order_or_fill() {
|
||||
let result=run_etf_fallback(chrono::NaiveTime::from_hms_opt(13,0,0).unwrap(),day(2),true,"",false,false).unwrap();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,588 @@
|
||||
{
|
||||
"schema": "fidc.selection-risk-plan-acceptance/v1",
|
||||
"rows": [
|
||||
{
|
||||
"name": "control-1",
|
||||
"receiptSha256": "f18b3b484d40e2a813bd795cb38e263ff43f65b17f31004786d3a23a6af5bcb6",
|
||||
"wallSeconds": 30.986483575077727,
|
||||
"engineSeconds": 8.79,
|
||||
"dataSeconds": 8.445,
|
||||
"validationSeconds": 12.244,
|
||||
"resultSeconds": 1.292,
|
||||
"maxRssKiB": 7090392,
|
||||
"fills": 21393,
|
||||
"canonical": {
|
||||
"schemaVersion": "fidc-canonical-backtest-result/v2",
|
||||
"algorithm": "sha256",
|
||||
"ordering": "engine_fact_order_v2",
|
||||
"totalRows": 93895,
|
||||
"sha256": "3f089cbcc5412e07bbe2308d0dd60ac561a119f0a2228010fc3323714fde8ca7",
|
||||
"sections": {
|
||||
"accountEvents": {
|
||||
"rowCount": 21555,
|
||||
"sha256": "8c839c89191d2b5220fa1dd86f8d74bdd57fa566a096a0bbf6932d0247b48e8a"
|
||||
},
|
||||
"equityFacts": {
|
||||
"rowCount": 1025,
|
||||
"sha256": "f928695650bdd90eb25d2acd478e0064046883649efa25cd2d4cdbbd27035c27"
|
||||
},
|
||||
"fillEvents": {
|
||||
"rowCount": 21393,
|
||||
"sha256": "2a90bef0994dda1b7f6e914e4c58037ec0b91cd828eb09745f71aecde071f791"
|
||||
},
|
||||
"holdingSnapshots": {
|
||||
"rowCount": 28353,
|
||||
"sha256": "799371917e516fb251b4afb60aeb4a1b8fc4b269ecfc27f827a3b388c4985a04"
|
||||
},
|
||||
"orderEvents": {
|
||||
"rowCount": 21491,
|
||||
"sha256": "0685ac0c31933b6cf5d1319912de3f398c40ce7ee01c28b4abec1b98f92fb318"
|
||||
},
|
||||
"riskAudits": {
|
||||
"rowCount": 78,
|
||||
"sha256": "e394cc9e8f3bac17a2f80f9db8738b91d4e960ff230647e17e5145b86d14b986"
|
||||
}
|
||||
}
|
||||
},
|
||||
"storeSha256": "1905f0c8a887215279342b26d5769a6cbe40058971eef15adf86ef2eaa02aeb9",
|
||||
"verifiedFactBlocks": 290,
|
||||
"sharedInputsUnchanged": true
|
||||
},
|
||||
{
|
||||
"name": "control-2",
|
||||
"receiptSha256": "8e5f7f8fe77ba2a798056306277a4ae4f00b6aa98b8c277269aca8c235bbd0fb",
|
||||
"wallSeconds": 13.274638780159876,
|
||||
"engineSeconds": 6.739,
|
||||
"dataSeconds": 5.19,
|
||||
"validationSeconds": 0.209,
|
||||
"resultSeconds": 1.003,
|
||||
"maxRssKiB": 7092040,
|
||||
"fills": 21393,
|
||||
"canonical": {
|
||||
"schemaVersion": "fidc-canonical-backtest-result/v2",
|
||||
"algorithm": "sha256",
|
||||
"ordering": "engine_fact_order_v2",
|
||||
"totalRows": 93895,
|
||||
"sha256": "3f089cbcc5412e07bbe2308d0dd60ac561a119f0a2228010fc3323714fde8ca7",
|
||||
"sections": {
|
||||
"accountEvents": {
|
||||
"rowCount": 21555,
|
||||
"sha256": "8c839c89191d2b5220fa1dd86f8d74bdd57fa566a096a0bbf6932d0247b48e8a"
|
||||
},
|
||||
"equityFacts": {
|
||||
"rowCount": 1025,
|
||||
"sha256": "f928695650bdd90eb25d2acd478e0064046883649efa25cd2d4cdbbd27035c27"
|
||||
},
|
||||
"fillEvents": {
|
||||
"rowCount": 21393,
|
||||
"sha256": "2a90bef0994dda1b7f6e914e4c58037ec0b91cd828eb09745f71aecde071f791"
|
||||
},
|
||||
"holdingSnapshots": {
|
||||
"rowCount": 28353,
|
||||
"sha256": "799371917e516fb251b4afb60aeb4a1b8fc4b269ecfc27f827a3b388c4985a04"
|
||||
},
|
||||
"orderEvents": {
|
||||
"rowCount": 21491,
|
||||
"sha256": "0685ac0c31933b6cf5d1319912de3f398c40ce7ee01c28b4abec1b98f92fb318"
|
||||
},
|
||||
"riskAudits": {
|
||||
"rowCount": 78,
|
||||
"sha256": "e394cc9e8f3bac17a2f80f9db8738b91d4e960ff230647e17e5145b86d14b986"
|
||||
}
|
||||
}
|
||||
},
|
||||
"storeSha256": "1905f0c8a887215279342b26d5769a6cbe40058971eef15adf86ef2eaa02aeb9",
|
||||
"verifiedFactBlocks": 290,
|
||||
"sharedInputsUnchanged": true
|
||||
},
|
||||
{
|
||||
"name": "control-3",
|
||||
"receiptSha256": "d106ddae57c64f931e196b80ffa517443e5c0f11eb9c2079f84d55b2d693fb13",
|
||||
"wallSeconds": 13.043757867999375,
|
||||
"engineSeconds": 6.732,
|
||||
"dataSeconds": 5.159,
|
||||
"validationSeconds": 0.005,
|
||||
"resultSeconds": 1,
|
||||
"maxRssKiB": 7089984,
|
||||
"fills": 21393,
|
||||
"canonical": {
|
||||
"schemaVersion": "fidc-canonical-backtest-result/v2",
|
||||
"algorithm": "sha256",
|
||||
"ordering": "engine_fact_order_v2",
|
||||
"totalRows": 93895,
|
||||
"sha256": "3f089cbcc5412e07bbe2308d0dd60ac561a119f0a2228010fc3323714fde8ca7",
|
||||
"sections": {
|
||||
"accountEvents": {
|
||||
"rowCount": 21555,
|
||||
"sha256": "8c839c89191d2b5220fa1dd86f8d74bdd57fa566a096a0bbf6932d0247b48e8a"
|
||||
},
|
||||
"equityFacts": {
|
||||
"rowCount": 1025,
|
||||
"sha256": "f928695650bdd90eb25d2acd478e0064046883649efa25cd2d4cdbbd27035c27"
|
||||
},
|
||||
"fillEvents": {
|
||||
"rowCount": 21393,
|
||||
"sha256": "2a90bef0994dda1b7f6e914e4c58037ec0b91cd828eb09745f71aecde071f791"
|
||||
},
|
||||
"holdingSnapshots": {
|
||||
"rowCount": 28353,
|
||||
"sha256": "799371917e516fb251b4afb60aeb4a1b8fc4b269ecfc27f827a3b388c4985a04"
|
||||
},
|
||||
"orderEvents": {
|
||||
"rowCount": 21491,
|
||||
"sha256": "0685ac0c31933b6cf5d1319912de3f398c40ce7ee01c28b4abec1b98f92fb318"
|
||||
},
|
||||
"riskAudits": {
|
||||
"rowCount": 78,
|
||||
"sha256": "e394cc9e8f3bac17a2f80f9db8738b91d4e960ff230647e17e5145b86d14b986"
|
||||
}
|
||||
}
|
||||
},
|
||||
"storeSha256": "1905f0c8a887215279342b26d5769a6cbe40058971eef15adf86ef2eaa02aeb9",
|
||||
"verifiedFactBlocks": 290,
|
||||
"sharedInputsUnchanged": true
|
||||
},
|
||||
{
|
||||
"name": "candidate-1",
|
||||
"receiptSha256": "a76e11c115ad42389dfdf72ed674ad75af8ec3d4646feb57feee9e6a4418f20d",
|
||||
"wallSeconds": 12.976857921108603,
|
||||
"engineSeconds": 6.682,
|
||||
"dataSeconds": 5.132,
|
||||
"validationSeconds": 0.004,
|
||||
"resultSeconds": 1.021,
|
||||
"maxRssKiB": 7091752,
|
||||
"fills": 21393,
|
||||
"canonical": {
|
||||
"schemaVersion": "fidc-canonical-backtest-result/v2",
|
||||
"algorithm": "sha256",
|
||||
"ordering": "engine_fact_order_v2",
|
||||
"totalRows": 93895,
|
||||
"sha256": "3f089cbcc5412e07bbe2308d0dd60ac561a119f0a2228010fc3323714fde8ca7",
|
||||
"sections": {
|
||||
"accountEvents": {
|
||||
"rowCount": 21555,
|
||||
"sha256": "8c839c89191d2b5220fa1dd86f8d74bdd57fa566a096a0bbf6932d0247b48e8a"
|
||||
},
|
||||
"equityFacts": {
|
||||
"rowCount": 1025,
|
||||
"sha256": "f928695650bdd90eb25d2acd478e0064046883649efa25cd2d4cdbbd27035c27"
|
||||
},
|
||||
"fillEvents": {
|
||||
"rowCount": 21393,
|
||||
"sha256": "2a90bef0994dda1b7f6e914e4c58037ec0b91cd828eb09745f71aecde071f791"
|
||||
},
|
||||
"holdingSnapshots": {
|
||||
"rowCount": 28353,
|
||||
"sha256": "799371917e516fb251b4afb60aeb4a1b8fc4b269ecfc27f827a3b388c4985a04"
|
||||
},
|
||||
"orderEvents": {
|
||||
"rowCount": 21491,
|
||||
"sha256": "0685ac0c31933b6cf5d1319912de3f398c40ce7ee01c28b4abec1b98f92fb318"
|
||||
},
|
||||
"riskAudits": {
|
||||
"rowCount": 78,
|
||||
"sha256": "e394cc9e8f3bac17a2f80f9db8738b91d4e960ff230647e17e5145b86d14b986"
|
||||
}
|
||||
}
|
||||
},
|
||||
"storeSha256": "1905f0c8a887215279342b26d5769a6cbe40058971eef15adf86ef2eaa02aeb9",
|
||||
"verifiedFactBlocks": 290,
|
||||
"sharedInputsUnchanged": true
|
||||
},
|
||||
{
|
||||
"name": "candidate-2",
|
||||
"receiptSha256": "38f61fd0d495daa5e29d6354679ce51e33473fb3ecbbb420c93d2fd41b74246f",
|
||||
"wallSeconds": 12.927155625075102,
|
||||
"engineSeconds": 6.64,
|
||||
"dataSeconds": 5.128,
|
||||
"validationSeconds": 0.005,
|
||||
"resultSeconds": 1.01,
|
||||
"maxRssKiB": 7092320,
|
||||
"fills": 21393,
|
||||
"canonical": {
|
||||
"schemaVersion": "fidc-canonical-backtest-result/v2",
|
||||
"algorithm": "sha256",
|
||||
"ordering": "engine_fact_order_v2",
|
||||
"totalRows": 93895,
|
||||
"sha256": "3f089cbcc5412e07bbe2308d0dd60ac561a119f0a2228010fc3323714fde8ca7",
|
||||
"sections": {
|
||||
"accountEvents": {
|
||||
"rowCount": 21555,
|
||||
"sha256": "8c839c89191d2b5220fa1dd86f8d74bdd57fa566a096a0bbf6932d0247b48e8a"
|
||||
},
|
||||
"equityFacts": {
|
||||
"rowCount": 1025,
|
||||
"sha256": "f928695650bdd90eb25d2acd478e0064046883649efa25cd2d4cdbbd27035c27"
|
||||
},
|
||||
"fillEvents": {
|
||||
"rowCount": 21393,
|
||||
"sha256": "2a90bef0994dda1b7f6e914e4c58037ec0b91cd828eb09745f71aecde071f791"
|
||||
},
|
||||
"holdingSnapshots": {
|
||||
"rowCount": 28353,
|
||||
"sha256": "799371917e516fb251b4afb60aeb4a1b8fc4b269ecfc27f827a3b388c4985a04"
|
||||
},
|
||||
"orderEvents": {
|
||||
"rowCount": 21491,
|
||||
"sha256": "0685ac0c31933b6cf5d1319912de3f398c40ce7ee01c28b4abec1b98f92fb318"
|
||||
},
|
||||
"riskAudits": {
|
||||
"rowCount": 78,
|
||||
"sha256": "e394cc9e8f3bac17a2f80f9db8738b91d4e960ff230647e17e5145b86d14b986"
|
||||
}
|
||||
}
|
||||
},
|
||||
"storeSha256": "1905f0c8a887215279342b26d5769a6cbe40058971eef15adf86ef2eaa02aeb9",
|
||||
"verifiedFactBlocks": 290,
|
||||
"sharedInputsUnchanged": true
|
||||
},
|
||||
{
|
||||
"name": "candidate-3",
|
||||
"receiptSha256": "9b56896d6dc048c5dd3d56cbe863778122b5bdf42fc9769eaa41f2d1b339dcd4",
|
||||
"wallSeconds": 12.926160736009479,
|
||||
"engineSeconds": 6.664,
|
||||
"dataSeconds": 5.113,
|
||||
"validationSeconds": 0.006,
|
||||
"resultSeconds": 1.006,
|
||||
"maxRssKiB": 7091128,
|
||||
"fills": 21393,
|
||||
"canonical": {
|
||||
"schemaVersion": "fidc-canonical-backtest-result/v2",
|
||||
"algorithm": "sha256",
|
||||
"ordering": "engine_fact_order_v2",
|
||||
"totalRows": 93895,
|
||||
"sha256": "3f089cbcc5412e07bbe2308d0dd60ac561a119f0a2228010fc3323714fde8ca7",
|
||||
"sections": {
|
||||
"accountEvents": {
|
||||
"rowCount": 21555,
|
||||
"sha256": "8c839c89191d2b5220fa1dd86f8d74bdd57fa566a096a0bbf6932d0247b48e8a"
|
||||
},
|
||||
"equityFacts": {
|
||||
"rowCount": 1025,
|
||||
"sha256": "f928695650bdd90eb25d2acd478e0064046883649efa25cd2d4cdbbd27035c27"
|
||||
},
|
||||
"fillEvents": {
|
||||
"rowCount": 21393,
|
||||
"sha256": "2a90bef0994dda1b7f6e914e4c58037ec0b91cd828eb09745f71aecde071f791"
|
||||
},
|
||||
"holdingSnapshots": {
|
||||
"rowCount": 28353,
|
||||
"sha256": "799371917e516fb251b4afb60aeb4a1b8fc4b269ecfc27f827a3b388c4985a04"
|
||||
},
|
||||
"orderEvents": {
|
||||
"rowCount": 21491,
|
||||
"sha256": "0685ac0c31933b6cf5d1319912de3f398c40ce7ee01c28b4abec1b98f92fb318"
|
||||
},
|
||||
"riskAudits": {
|
||||
"rowCount": 78,
|
||||
"sha256": "e394cc9e8f3bac17a2f80f9db8738b91d4e960ff230647e17e5145b86d14b986"
|
||||
}
|
||||
}
|
||||
},
|
||||
"storeSha256": "1905f0c8a887215279342b26d5769a6cbe40058971eef15adf86ef2eaa02aeb9",
|
||||
"verifiedFactBlocks": 290,
|
||||
"sharedInputsUnchanged": true
|
||||
},
|
||||
{
|
||||
"name": "trend-40-control",
|
||||
"receiptSha256": "305f0ea34b50355661ef9d3583467f7160cfbffd95f03b9e21a631bebc37af64",
|
||||
"wallSeconds": 15.628366323187947,
|
||||
"engineSeconds": 8.199,
|
||||
"dataSeconds": 5.234,
|
||||
"validationSeconds": 0.694,
|
||||
"resultSeconds": 1.33,
|
||||
"maxRssKiB": 7108340,
|
||||
"fills": 29776,
|
||||
"canonical": {
|
||||
"schemaVersion": "fidc-canonical-backtest-result/v2",
|
||||
"algorithm": "sha256",
|
||||
"ordering": "engine_fact_order_v2",
|
||||
"totalRows": 128192,
|
||||
"sha256": "41209fed4c7a5e385a12e170afef685efae7a3b00137365fe2b9aea015dd7948",
|
||||
"sections": {
|
||||
"accountEvents": {
|
||||
"rowCount": 29968,
|
||||
"sha256": "fa578b86d94a5be9ad192258eb87c9be6a1b356d6713e70ae475b95fd130f61d"
|
||||
},
|
||||
"equityFacts": {
|
||||
"rowCount": 1025,
|
||||
"sha256": "90f63f3c51c62f0fc0abb317a29ac48aa0ead6035d5f75d650a948c33ae1e9b3"
|
||||
},
|
||||
"fillEvents": {
|
||||
"rowCount": 29776,
|
||||
"sha256": "6626ea78cfaa5d88040496c575be63cd8b573d48d1afe6812579762bf7e2abd0"
|
||||
},
|
||||
"holdingSnapshots": {
|
||||
"rowCount": 37367,
|
||||
"sha256": "491b32468148cc62779f4c01cee96e4dcf18ce1a6115ccecc437b468e49b2ceb"
|
||||
},
|
||||
"orderEvents": {
|
||||
"rowCount": 29932,
|
||||
"sha256": "565f5a6271a9e1f0fa31f152141c75db0e5f84784e4d4a83b2277b0fd14c45fc"
|
||||
},
|
||||
"riskAudits": {
|
||||
"rowCount": 124,
|
||||
"sha256": "d170e1704ee5f64a93b71cecf2178b062d30368df8f5da53c07416b8e8a9c5e5"
|
||||
}
|
||||
}
|
||||
},
|
||||
"storeSha256": "ab29a9f999a6b41330255ba82081696a9826825fa6d34941247332adac65249e",
|
||||
"verifiedFactBlocks": 293,
|
||||
"sharedInputsUnchanged": true
|
||||
},
|
||||
{
|
||||
"name": "trend-40-candidate",
|
||||
"receiptSha256": "0174941bea20079730c019b3de4185cc439528160ab54cafc1be4e3f8a0a08fc",
|
||||
"wallSeconds": 14.82603678200394,
|
||||
"engineSeconds": 8.087,
|
||||
"dataSeconds": 5.276,
|
||||
"validationSeconds": 0.004,
|
||||
"resultSeconds": 1.322,
|
||||
"maxRssKiB": 7108656,
|
||||
"fills": 29776,
|
||||
"canonical": {
|
||||
"schemaVersion": "fidc-canonical-backtest-result/v2",
|
||||
"algorithm": "sha256",
|
||||
"ordering": "engine_fact_order_v2",
|
||||
"totalRows": 128192,
|
||||
"sha256": "41209fed4c7a5e385a12e170afef685efae7a3b00137365fe2b9aea015dd7948",
|
||||
"sections": {
|
||||
"accountEvents": {
|
||||
"rowCount": 29968,
|
||||
"sha256": "fa578b86d94a5be9ad192258eb87c9be6a1b356d6713e70ae475b95fd130f61d"
|
||||
},
|
||||
"equityFacts": {
|
||||
"rowCount": 1025,
|
||||
"sha256": "90f63f3c51c62f0fc0abb317a29ac48aa0ead6035d5f75d650a948c33ae1e9b3"
|
||||
},
|
||||
"fillEvents": {
|
||||
"rowCount": 29776,
|
||||
"sha256": "6626ea78cfaa5d88040496c575be63cd8b573d48d1afe6812579762bf7e2abd0"
|
||||
},
|
||||
"holdingSnapshots": {
|
||||
"rowCount": 37367,
|
||||
"sha256": "491b32468148cc62779f4c01cee96e4dcf18ce1a6115ccecc437b468e49b2ceb"
|
||||
},
|
||||
"orderEvents": {
|
||||
"rowCount": 29932,
|
||||
"sha256": "565f5a6271a9e1f0fa31f152141c75db0e5f84784e4d4a83b2277b0fd14c45fc"
|
||||
},
|
||||
"riskAudits": {
|
||||
"rowCount": 124,
|
||||
"sha256": "d170e1704ee5f64a93b71cecf2178b062d30368df8f5da53c07416b8e8a9c5e5"
|
||||
}
|
||||
}
|
||||
},
|
||||
"storeSha256": "ab29a9f999a6b41330255ba82081696a9826825fa6d34941247332adac65249e",
|
||||
"verifiedFactBlocks": 293,
|
||||
"sharedInputsUnchanged": true
|
||||
},
|
||||
{
|
||||
"name": "pullback-40-control",
|
||||
"receiptSha256": "0d39c6af608d3ec89fc44d0715dab41229511c68cf5ea4eb01f763c10bded8bf",
|
||||
"wallSeconds": 13.775856785941869,
|
||||
"engineSeconds": 7.374,
|
||||
"dataSeconds": 4.893,
|
||||
"validationSeconds": 0.005,
|
||||
"resultSeconds": 1.358,
|
||||
"maxRssKiB": 7119352,
|
||||
"fills": 31862,
|
||||
"canonical": {
|
||||
"schemaVersion": "fidc-canonical-backtest-result/v2",
|
||||
"algorithm": "sha256",
|
||||
"ordering": "engine_fact_order_v2",
|
||||
"totalRows": 135630,
|
||||
"sha256": "49fdbd74245d7aa678f1a4851add29f7b12dde71fd50c93c070fd2ee57f35285",
|
||||
"sections": {
|
||||
"accountEvents": {
|
||||
"rowCount": 32010,
|
||||
"sha256": "9be3914d28d7766f12bac45227d2c2da47d61a921f9b918a4b11447da4b78baa"
|
||||
},
|
||||
"equityFacts": {
|
||||
"rowCount": 1025,
|
||||
"sha256": "2044627b1152c4a2ad8ea92dca3351322d41cfc18c002cda93f274bd810efdad"
|
||||
},
|
||||
"fillEvents": {
|
||||
"rowCount": 31862,
|
||||
"sha256": "b33d1b4b0f7f86c96c082c217c9a27d86830ccac0de5a33714104ac4756df8d7"
|
||||
},
|
||||
"holdingSnapshots": {
|
||||
"rowCount": 38679,
|
||||
"sha256": "a45b516a926c57b6c7827f8f1684b6e749d26118b8c5d8e8614afbb23cd87559"
|
||||
},
|
||||
"orderEvents": {
|
||||
"rowCount": 31966,
|
||||
"sha256": "73704496ab17d10d9a602429d97b728cf22d0896f2d467b0f2064f2f48bf04fb"
|
||||
},
|
||||
"riskAudits": {
|
||||
"rowCount": 88,
|
||||
"sha256": "2c8de7dbb11c7ca5ac678c17feabec0bd98aac0a031bbb4780eddaa144933293"
|
||||
}
|
||||
}
|
||||
},
|
||||
"storeSha256": "ff32f177e5e0ec4b4f3f0597b61174efdafac77b4f36e6065448b6e9c414f07e",
|
||||
"verifiedFactBlocks": 281,
|
||||
"sharedInputsUnchanged": true
|
||||
},
|
||||
{
|
||||
"name": "pullback-40-candidate",
|
||||
"receiptSha256": "3f0ec7b8b6ad74fc7349586a8716d45b8075ebad03c77776ca78fab5188d49ee",
|
||||
"wallSeconds": 13.927610703045502,
|
||||
"engineSeconds": 7.239,
|
||||
"dataSeconds": 5.137,
|
||||
"validationSeconds": 0.003,
|
||||
"resultSeconds": 1.368,
|
||||
"maxRssKiB": 7119784,
|
||||
"fills": 31862,
|
||||
"canonical": {
|
||||
"schemaVersion": "fidc-canonical-backtest-result/v2",
|
||||
"algorithm": "sha256",
|
||||
"ordering": "engine_fact_order_v2",
|
||||
"totalRows": 135630,
|
||||
"sha256": "49fdbd74245d7aa678f1a4851add29f7b12dde71fd50c93c070fd2ee57f35285",
|
||||
"sections": {
|
||||
"accountEvents": {
|
||||
"rowCount": 32010,
|
||||
"sha256": "9be3914d28d7766f12bac45227d2c2da47d61a921f9b918a4b11447da4b78baa"
|
||||
},
|
||||
"equityFacts": {
|
||||
"rowCount": 1025,
|
||||
"sha256": "2044627b1152c4a2ad8ea92dca3351322d41cfc18c002cda93f274bd810efdad"
|
||||
},
|
||||
"fillEvents": {
|
||||
"rowCount": 31862,
|
||||
"sha256": "b33d1b4b0f7f86c96c082c217c9a27d86830ccac0de5a33714104ac4756df8d7"
|
||||
},
|
||||
"holdingSnapshots": {
|
||||
"rowCount": 38679,
|
||||
"sha256": "a45b516a926c57b6c7827f8f1684b6e749d26118b8c5d8e8614afbb23cd87559"
|
||||
},
|
||||
"orderEvents": {
|
||||
"rowCount": 31966,
|
||||
"sha256": "73704496ab17d10d9a602429d97b728cf22d0896f2d467b0f2064f2f48bf04fb"
|
||||
},
|
||||
"riskAudits": {
|
||||
"rowCount": 88,
|
||||
"sha256": "2c8de7dbb11c7ca5ac678c17feabec0bd98aac0a031bbb4780eddaa144933293"
|
||||
}
|
||||
}
|
||||
},
|
||||
"storeSha256": "ff32f177e5e0ec4b4f3f0597b61174efdafac77b4f36e6065448b6e9c414f07e",
|
||||
"verifiedFactBlocks": 281,
|
||||
"sharedInputsUnchanged": true
|
||||
},
|
||||
{
|
||||
"name": "volume-momentum-80-control",
|
||||
"receiptSha256": "98239365828453888930a1fceb2a7d9b5402b03cd32c9303a9fa1532af3644ed",
|
||||
"wallSeconds": 18.176081838086247,
|
||||
"engineSeconds": 11.154,
|
||||
"dataSeconds": 4.585,
|
||||
"validationSeconds": 0.004,
|
||||
"resultSeconds": 2.268,
|
||||
"maxRssKiB": 7158556,
|
||||
"fills": 51300,
|
||||
"canonical": {
|
||||
"schemaVersion": "fidc-canonical-backtest-result/v2",
|
||||
"algorithm": "sha256",
|
||||
"ordering": "engine_fact_order_v2",
|
||||
"totalRows": 234267,
|
||||
"sha256": "4359817bb1fbd73b02c2c3456e1f0b7ee7428b090529393bd203e8212979a1dc",
|
||||
"sections": {
|
||||
"accountEvents": {
|
||||
"rowCount": 51696,
|
||||
"sha256": "ca4d333cc4768ec4b528475d1833809c7e76a80de937c635e7e933a0f18264c6"
|
||||
},
|
||||
"equityFacts": {
|
||||
"rowCount": 1025,
|
||||
"sha256": "31865ccfeb71d260757979524a679880070d691c09083c82865c3de6dd47e440"
|
||||
},
|
||||
"fillEvents": {
|
||||
"rowCount": 51300,
|
||||
"sha256": "4f45cc0906b6cd02af9ce12450a52f509f6e80e9b26157695ae04e90ce7ca9ac"
|
||||
},
|
||||
"holdingSnapshots": {
|
||||
"rowCount": 78078,
|
||||
"sha256": "79098ebfa48dcd24b00ba4d19917c291adb80a129b4d90ee1087b5f2210c43aa"
|
||||
},
|
||||
"orderEvents": {
|
||||
"rowCount": 51783,
|
||||
"sha256": "340699c5d04407c5c6f71dc39c41ac2baedc096bb11185f1ccb99ab7100ba8f8"
|
||||
},
|
||||
"riskAudits": {
|
||||
"rowCount": 385,
|
||||
"sha256": "2aa0a4d6e6078dbea815b536ce0ee8f6ca03f9c44f63cdfecf4e37a0ff9cf561"
|
||||
}
|
||||
}
|
||||
},
|
||||
"storeSha256": "5748fe4db08f8d81607622b302cc43c1d2e4ef50c3b87739fb6c20cd40de79c0",
|
||||
"verifiedFactBlocks": 309,
|
||||
"sharedInputsUnchanged": true
|
||||
},
|
||||
{
|
||||
"name": "volume-momentum-80-candidate",
|
||||
"receiptSha256": "1cbbccd9678bc8ea2754f2ffa678f3d43feb659a58f86393c4c54961daa5a8d0",
|
||||
"wallSeconds": 18.627057212870568,
|
||||
"engineSeconds": 11.013,
|
||||
"dataSeconds": 5.2,
|
||||
"validationSeconds": 0.005,
|
||||
"resultSeconds": 2.267,
|
||||
"maxRssKiB": 7152664,
|
||||
"fills": 51300,
|
||||
"canonical": {
|
||||
"schemaVersion": "fidc-canonical-backtest-result/v2",
|
||||
"algorithm": "sha256",
|
||||
"ordering": "engine_fact_order_v2",
|
||||
"totalRows": 234267,
|
||||
"sha256": "4359817bb1fbd73b02c2c3456e1f0b7ee7428b090529393bd203e8212979a1dc",
|
||||
"sections": {
|
||||
"accountEvents": {
|
||||
"rowCount": 51696,
|
||||
"sha256": "ca4d333cc4768ec4b528475d1833809c7e76a80de937c635e7e933a0f18264c6"
|
||||
},
|
||||
"equityFacts": {
|
||||
"rowCount": 1025,
|
||||
"sha256": "31865ccfeb71d260757979524a679880070d691c09083c82865c3de6dd47e440"
|
||||
},
|
||||
"fillEvents": {
|
||||
"rowCount": 51300,
|
||||
"sha256": "4f45cc0906b6cd02af9ce12450a52f509f6e80e9b26157695ae04e90ce7ca9ac"
|
||||
},
|
||||
"holdingSnapshots": {
|
||||
"rowCount": 78078,
|
||||
"sha256": "79098ebfa48dcd24b00ba4d19917c291adb80a129b4d90ee1087b5f2210c43aa"
|
||||
},
|
||||
"orderEvents": {
|
||||
"rowCount": 51783,
|
||||
"sha256": "340699c5d04407c5c6f71dc39c41ac2baedc096bb11185f1ccb99ab7100ba8f8"
|
||||
},
|
||||
"riskAudits": {
|
||||
"rowCount": 385,
|
||||
"sha256": "2aa0a4d6e6078dbea815b536ce0ee8f6ca03f9c44f63cdfecf4e37a0ff9cf561"
|
||||
}
|
||||
}
|
||||
},
|
||||
"storeSha256": "5748fe4db08f8d81607622b302cc43c1d2e4ef50c3b87739fb6c20cd40de79c0",
|
||||
"verifiedFactBlocks": 309,
|
||||
"sharedInputsUnchanged": true
|
||||
}
|
||||
],
|
||||
"sharedInputFiles": 9257,
|
||||
"sharedInputBytes": 12596608049,
|
||||
"sharedInputInventorySha256": "1a4818aaab906e77b750e28601d3d405ad9e14e0553f7937cc60b68be0c9b71d",
|
||||
"verifiedFactBlocks": 3506,
|
||||
"status": "candidate-not-deployed",
|
||||
"sourceCommit": "d5b682c6d09704ff23d725a8dd8b155db3eb6967",
|
||||
"engineCommit": "d2aa16a2f0064297d0d8c931060646d66422e9d4",
|
||||
"serviceCommit": "4e23c7558d8301ba697543c39d5604289bb82c53",
|
||||
"controlRunnerSha256": "b90886b80634c7565ca215fbe1f9ed0cbb5a6bd967373a9b1f6753be5164737d",
|
||||
"candidateRunnerSha256": "1bda2d3acc016ca5addbb12e33cfcc31a23ece562f1d7d1ff8a825fbc83873fb",
|
||||
"candidateApiSha256": "30ac3b50996e1769c1d93bd5d302a23c4af7ebe773d3e8110ee278c44aeb9501",
|
||||
"bounds": [
|
||||
"All twelve are new runner processes and private result artifacts using the same verified shared input files.",
|
||||
"Input hashing is outside the elapsed benchmark timer; no GDB samples are in these measurements.",
|
||||
"The first control had 12.244s Source validation and a slower preparation phase. Its entire latency difference is not candidate speedup.",
|
||||
"The full input set is identical across the twelve runs, not only a global cache hit counter.",
|
||||
"No Source/trading service was changed and no paused research/signal task resumed.",
|
||||
"The independently recorded intraday-clock counterexample remains open. These day-level replays do not close it."
|
||||
]
|
||||
}
|
||||
@@ -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,44 @@
|
||||
# 日内时钟与手工回放前置问题
|
||||
|
||||
2026-09-14。本轮时钟与工作中算法单候选已完成本机回归,尚未部署。177仍运行Engine c98bcc3 / Service e81bf47;完整手工影子回放尚未实现。
|
||||
|
||||
## 已复现的精确反例
|
||||
|
||||
`engine::tests::minute_observer_never_sees_a_later_fill_from_a_coarse_phase`使用实际BacktestEngine/BrokerSimulator测试入口、同一证券及合法测试日行情。开盘竞价回调生成100股限价10.0的委托,全天存在09:30、10:00、10:15、13:00、13:01报价,后续分钟回调读取真实模拟账本。
|
||||
|
||||
- CurrentBarClose/09:30窗口:10:15成交;10:00观察为0股,通过。
|
||||
- NextBarOpen/一天信号滞后/09:30窗口:10:15成交;10:00观察为0股,通过。
|
||||
- CurrentBarClose/13:00窗口:实际FillEvent时间13:00、数量100,但09:30、10:00、10:15回调均已观察到100股,失败。完整观察序列为`[(09:30,100),(10:00,100),(10:15,100),(13:00,100),(13:01,100)]`,不是仅日志显示错误。
|
||||
|
||||
根因路径是粗粒度auction/on_day阶段调用broker时使用未来的全局intraday_execution_start_time,先将13:00成交写进PortfolioState,随后引擎才从09:30开始遍历分钟事件。正常09:30路径已有边界,不能因为一次测试通过就断言所有时点安全,也不能把所有粗粒度调用一概认定有问题。
|
||||
|
||||
首次盘前调度夹具没有产生订单,因此不作为时钟证据;改用明确返回委托的open_auction回调完成上述复现。盘前on_scheduled普通委托是否被忽略应另行核对其正式合同,不能当空成功。
|
||||
|
||||
## 必须按真实执行时序修复
|
||||
|
||||
不能删掉早间回调或给显示持仓做遮掩。需要使已生成的未来执行意图、待执行批次、订单回报、策略回调、手工意图及实际投影按执行时钟前进;保留独立信号日与数据可见性。不能仅把新订单延迟却让依赖持仓的后续策略回调仍提前计算。
|
||||
|
||||
需覆盖当前/下一开盘、显式时间和默认收盘、限价/市价/算法单、部分成交及取消、股票池卖后续买、跨日/T+1、0%人工覆盖和恢复。已有真实回放与六类Canonical必须按各自合同核对,不能用收益接近或单个对照替代。
|
||||
|
||||
上述原失败回归已保留并修复:晚窗口执行与日度回调进入真实日内时钟,不再先写未来持仓。独立信号日及滞后执行的数据合同保留。仅有日内观察或待处理开盘目标时,未显式设时间的日线收盘回调才延至15:00;物理时钟与委托提交时点分离,不能把普通日线收盘撮合误变为15:05盘后委托。
|
||||
|
||||
## 本轮新增证据
|
||||
|
||||
- TWAP旧路径在13:00一次消费13:01、13:05报价,导致13:00观察到900股;现在逐时钟消费,同一父订单保留原始总量、已成交量、剩余金额、最低佣金余额和期限,不重新生成订单。
|
||||
- 分片时钟继续使用原算法窗口决定TWAP比例及深度约束,不把每个瞬时时钟当作新的不限量算法单;VWAP全局撮合也延续同一工作中订单。
|
||||
- 算法定量使用提交时已经可见的报价。改变当日后续收盘价不改变早先订单数量;真正缺报价明确失败,不读未来报价或日线价替代。
|
||||
- 当天已完成委托/成交记录及时移动到运行历史,后续分钟、日度与定时策略回调能读取;不逐分钟复制全部历史。
|
||||
- ETF下一开盘回退保留真实日线开盘价、3700股及原信号日,入账从早间预处理移到09:30事件;反例09:15原来可见3700股,修复后为0,09:31为3700且仅一笔ETF成交。不合成ETF分钟线。
|
||||
- 工作中算法单只预留真实可用现金;两个各10000元意图、15000元账户按顺序预留10000/5000,后续分别成交900/500股,先到订单不被后到订单的超额预留饿死。
|
||||
- 已验证部分成交后撤单、无末尾报价到期、T+1、IOC终止及原合同拒绝算法FOK/GTC;未新增不支持的有效期。
|
||||
- 同一TWAP与同步参考逐笔数量/价格/时间/订单ID/各项费用完全一致;VWAP逐时钟成交金额与总费用一致。最低佣金只扣一次,成交资金不超过冻结预算。
|
||||
|
||||
本机Core 822项通过、9项原有ignore;Trading工作区613项通过(外部PG等原有ignore未当通过);最新main的Runner446/API119项通过。同期main风控候选d2aa16a已保留并组合回归。本机测试不代替177不可变构建与真实数据回放。
|
||||
|
||||
## 发布前置与剩余边界
|
||||
|
||||
177于03:46只读核对仍为3Paper/0Live,原配置与旧委托摘要不变,Source d5b682c6/PID1700096未变,真实路由disabled。SSD剩约1.6GiB;官方编译缓存清理计划无候选,未删除任何数据或构建。官方复用审计确认target-backtest无运行引用,后续只允许带1GiB余量保护的本次构建,不能覆盖在用发布根。
|
||||
|
||||
还需完成Linux精确提交构建、固定历史合同回放及配套发布;通用process-event回调的完整时间/订单观察、盘前调度普通意图、显式手工委托/撤单回放和所有剩余参数矩阵继续跟踪,不以当前核心测试声明完整Goal完成。当前不解除手工影子拒绝门禁,不修改既有任务配置、Source冻结和研究/信号暂停。
|
||||
|
||||
Live取消请求另有待核对项:`CancelRequested`当前在网关返回后才持久化,不能不加说明就把回报时间当最初请求时间。完整手工回放需要验证并补齐真实意图/提交/成交/取消关联,当前生产手工影子仍保持明确拒绝纯比例模拟。
|
||||
@@ -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,126 @@
|
||||
# Selection Risk Plan Performance
|
||||
|
||||
## Status
|
||||
|
||||
Candidate tested, not deployed. The change removes selection calls that have
|
||||
no possible effect under the current frozen policy. It does not disable any
|
||||
configured rule, execution-day check or strategy expression. Engine time falls
|
||||
slightly in the measured cases; this is not the solution to the main remaining
|
||||
data construction cost and is not a general whole-backtest speedup claim.
|
||||
|
||||
The independent [intraday-clock counterexample](intraday-clock-causality-20260914.md)
|
||||
remains open. This work does not remove that test or its evidence, change the
|
||||
execution clock, or turn day-level parity into full framework acceptance.
|
||||
The published service stays at e81bf47/c98bcc3. Source d5b682c6 remains frozen;
|
||||
research and signal work stay paused. No trading operation was submitted.
|
||||
|
||||
## Evidence Leading to the Change
|
||||
|
||||
The official HTTP diagnostic replay btr_1789322878865_2871869_0 preserved the
|
||||
original canonical and result-store SHA. Ten bounded Boris-only GDB snapshots
|
||||
showed source inventory, PreparedDayBuilder, factor normalization and price
|
||||
series construction, followed by repeated selection risk calls. GDB pauses are
|
||||
not normal performance measurements and snapshot counts are not flamegraph
|
||||
percentages. Source/target PID, binary SHA and CPU/thread resources stayed fixed.
|
||||
|
||||
The diagnostic helper now shares the existing canonical executable policy with
|
||||
the saved-run profiler: it accepts both audited build roots and immutable API
|
||||
release directories, but not arbitrary paths. Seven related tests passed.
|
||||
|
||||
## Implementation and Correctness
|
||||
|
||||
StaticRiskRuleConfig reports whether selection has an enabled state rule or an
|
||||
enabled nonempty blacklist. The strategy computes this once before iterating
|
||||
candidate symbols. If no such rule exists, the old selection function would
|
||||
always return None, so that no-op call is omitted. Explicit universe conditions,
|
||||
market/factor checks and all buy/sell execution paths are unchanged.
|
||||
|
||||
The ten state flags are also shared with the existing missing-risk-state checks
|
||||
to avoid maintaining three separate flag lists. Blacklist presence is kept
|
||||
separate: a blacklist is not missing market-risk data. No cross-strategy cache,
|
||||
strategy identifier, fixed date, trading time or account state is introduced.
|
||||
|
||||
Tests enumerate all 4,096 combinations of ten selection flags and blacklist
|
||||
enabled/populated states. The flag list is checked against the serialized
|
||||
configuration, so adding a selection field requires updating the activation
|
||||
test. Further tests retain missing-state behavior and show that paused buys
|
||||
and sells remain rejected when selection checks are inactive.
|
||||
|
||||
On 177: 805 core unit/integration tests passed (9 ignored), 448 runner tests
|
||||
passed (9 ignored), 119 API tests passed (5 ignored), and 28 benchmark/profiler
|
||||
tests passed. These counts do not resolve the independently recorded clock
|
||||
failure, which is not part of this frozen committed test tree.
|
||||
|
||||
## Reproducible Shared-Input Method
|
||||
|
||||
Each of the twelve replays has a new process and a new private result root.
|
||||
The official runner benchmark gained --shared-runtime-cache. It resolves the
|
||||
explicit cache root from the declared Boris service, requires canonical private
|
||||
storage, hashes existing inputs before and after, and refuses any changed or
|
||||
removed original. This mode cannot invoke copied-input disposal.
|
||||
|
||||
All twelve runs used the same 9,257 files / 12,596,608,049 bytes. Their complete
|
||||
input inventories, file identities and byte SHA values are equal. No new Arrow
|
||||
or binary cache input appeared. No backtest result was reused. Hash preparation
|
||||
and verification are outside the measured runner interval; this is a shared
|
||||
warm-input test, not raw-disk cold IO. Unlike the earlier copied-cache method,
|
||||
it does not allocate another approximately 2 GB per replay on the nearly full
|
||||
SSD. Original inputs and every result remain intact.
|
||||
|
||||
The common execution interval is 2021-08-23 through 2025-11-17 with 10,000,000
|
||||
initial cash and each case's unchanged frozen strategy/bundle. This is not five
|
||||
complete execution years. CPU affinity and 8 Rayon / 16 Tokio threads match the
|
||||
declared reference service; no global resource limit was increased.
|
||||
|
||||
## Measurements
|
||||
|
||||
| Case | Wall seconds | Source validation | Data preparation | Engine |
|
||||
|---|---:|---:|---:|---:|
|
||||
| Rotation control 1 | 30.986 | 12.244 | 8.445 | 8.790 |
|
||||
| Rotation candidate 1 | 12.977 | 0.004 | 5.132 | 6.682 |
|
||||
| Rotation control 2 | 13.275 | 0.209 | 5.190 | 6.739 |
|
||||
| Rotation candidate 2 | 12.927 | 0.005 | 5.128 | 6.640 |
|
||||
| Rotation candidate 3 | 12.926 | 0.006 | 5.113 | 6.664 |
|
||||
| Rotation control 3 | 13.044 | 0.005 | 5.159 | 6.732 |
|
||||
| Trend 40 control | 15.628 | 0.694 | 5.234 | 8.199 |
|
||||
| Trend 40 candidate | 14.826 | 0.004 | 5.276 | 8.087 |
|
||||
| Pullback 40 control | 13.776 | 0.005 | 4.893 | 7.374 |
|
||||
| Pullback 40 candidate | 13.928 | 0.003 | 5.137 | 7.239 |
|
||||
| Volume momentum 80 control | 18.176 | 0.004 | 4.585 | 11.154 |
|
||||
| Volume momentum 80 candidate | 18.627 | 0.005 | 5.200 | 11.013 |
|
||||
|
||||
Rotation engine medians are 6.739 versus 6.664 seconds, approximately 1.1%.
|
||||
The other paired engine reductions are approximately 1.4%, 1.8% and 1.3%.
|
||||
These are small CPU-path improvements. Pullback and volume total latency did
|
||||
not improve because their preparation times were higher. The first control's
|
||||
Source wait and unexplained slower construction are recorded, not attributed
|
||||
to this code or discarded to manufacture a large speedup. Peak RSS stays about
|
||||
6.76-6.83 GiB; there is no significant memory reduction claim.
|
||||
|
||||
Each case matches its independent prior baseline for all six canonical
|
||||
sections and store bytes: 21,393 / 29,776 / 31,862 / 51,300 fills. Result receipts,
|
||||
runtime/strategy identities, physical manifests and 3,506 fact blocks were
|
||||
verified. The shared input inventory SHA is in the acceptance record. Full
|
||||
unaltered receipts remain on 177; the repository stores the compact verified
|
||||
summary rather than repeating the 9,257-file inventory in every document.
|
||||
|
||||
## Remaining Work
|
||||
|
||||
Prioritize direct typed-column reuse during daily snapshot and DataSet
|
||||
construction; approximately five seconds of preparation remain in these warm
|
||||
cases. Do not skip normalization, NULL, adjustment or date/uniqueness checks.
|
||||
Source cold-query and contract-validation latency remain separate tasks under
|
||||
the Source freeze. The earlier cache-boundary candidate still needs its missing
|
||||
cold/same-window acceptance, and this combined candidate has no HTTP publication
|
||||
gate yet. Financial PIT, minute-clock behavior, signal lifecycle and UI factor
|
||||
condition acceptance are not claimed complete.
|
||||
|
||||
- Candidate engine: d2aa16a2f0064297d0d8c931060646d66422e9d4.
|
||||
- Candidate service source: 4e23c7558d8301ba697543c39d5604289bb82c53.
|
||||
- Control runner SHA: b90886b80634c7565ca215fbe1f9ed0cbb5a6bd967373a9b1f6753be5164737d.
|
||||
- Candidate runner SHA: 1bda2d3acc016ca5addbb12e33cfcc31a23ece562f1d7d1ff8a825fbc83873fb.
|
||||
- Candidate API SHA: 30ac3b50996e1769c1d93bd5d302a23c4af7ebe773d3e8110ee278c44aeb9501.
|
||||
- Evidence root: /srv/fidc/canonical/run/research/selection-risk-plan-20260914.
|
||||
- HTTP diagnostic: /srv/fidc/canonical/run/research/http-phase-profile-20260914.
|
||||
|
||||
[Verified acceptance summary](evidence/selection-risk-plan-20260914/acceptance.json).
|
||||
@@ -0,0 +1,28 @@
|
||||
# 股票池卖出批次与买入续执行
|
||||
|
||||
2026-09-13开发,2026-09-14 00:00至00:06 CST完成177配套发布,annotated tag v2026.9.13.16。Engine c98bcc3、Service aa3fe40、Trading b1d402e;不是完整股票池验收结论。
|
||||
|
||||
## 原问题
|
||||
|
||||
真实混合四证券的手选优先/自动优先回测在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独立进程对三个原请求分别执行原版和修复版,共六次原生回放;原版各自与原历史Canonical相等,原请求及数据包不变。修复后两种优先级均10成交/4持仓/权益9,706,248.648662,逐股数量、费用、时钟、逐日权益和持仓完全一致(订单ID仍按各自原顺序生成,不伪装为同一Canonical)。原24只回放51成交/21持仓/权益9,685,563.876924999,不强求保留旧54笔:09-08和09-10卖出晚于窗口,未提交买入阶段到期;09-11卖出09:31完成后继续买入。混合样本09-09与09-10同样在窗外不新建买单,09-11在09:34完成卖出后续买,已提交DAY单可在窗口后继续成交。
|
||||
|
||||
生产API三次验收分别为btr_req_6854471517438a896378785b96a81e4ab41f0d77f898bf37、btr_req_0d32c6e07598c16728992374f1800804ad2cd06d85f18d15、btr_req_4ae4ee17bf90bbba5ca579a79c7d4e1c410fc2d4506e5800,均与对应原生候选Canonical相同;旧结果/配置回读保持。未提交券商委托、创建交易任务或改写配置,Source冻结及研究/信号暂停保持。完整逐笔回执在177 /srv/fidc/canonical/run/research/stock-pool-sell-buy-20260913,部署回执/tmp/fidc-sell-buy-api-release-20260913.json与/tmp/fidc-sell-buy-trading-release-20260913.json。
|
||||
|
||||
优先级在真实资金或仓位约束不足时仍可影响分配,不能将本例结论外推所有排序。完整Goal下一项仍是手工委托影子回放、流式日期消息/摘要投影和剩余参数矩阵;不重复此已解决样本。
|
||||
Reference in New Issue
Block a user