Compare commits

...

10 Commits

15 changed files with 797 additions and 214 deletions
+334 -110
View File
@@ -7,7 +7,7 @@ use chrono::{Duration, NaiveDate, NaiveDateTime, NaiveTime};
use crate::cost::CostModel; use crate::cost::CostModel;
use crate::data::{DataSet, IntradayExecutionQuote, PriceField}; use crate::data::{DataSet, IntradayExecutionQuote, PriceField};
use crate::engine::BacktestError; use crate::engine::BacktestError;
use crate::execution_capacity::{CapacityError, ParticipationRate, VolumeObservation, VolumeObservationKind}; use crate::execution_capacity::{CapacityAuditSummary, CapacityError, ParticipationRate, SessionCapacityAudit, VolumeCapacityMode, VolumeObservation, VolumeObservationKind};
use crate::execution_schedule::TwapSchedule; use crate::execution_schedule::TwapSchedule;
use crate::events::{ use crate::events::{
AccountEvent, FillEvent, OrderEvent, OrderSide, OrderStatus, PositionEvent, ProcessEvent, AccountEvent, FillEvent, OrderEvent, OrderSide, OrderStatus, PositionEvent, ProcessEvent,
@@ -204,6 +204,8 @@ struct OpenOrder {
order_id: u64, order_id: u64,
decision_date: Option<NaiveDate>, decision_date: Option<NaiveDate>,
order_created_date: Option<NaiveDate>, order_created_date: Option<NaiveDate>,
submission_time: Option<NaiveTime>,
accepted_date: NaiveDate,
symbol: String, symbol: String,
side: OrderSide, side: OrderSide,
requested_quantity: u32, requested_quantity: u32,
@@ -216,6 +218,13 @@ struct OpenOrder {
reason: String, reason: String,
} }
#[derive(Debug, Clone, Copy)]
struct RestingOrderOrigin {
created_date: Option<NaiveDate>,
submission_time: Option<NaiveTime>,
accepted_date: NaiveDate,
}
#[derive(Debug, Default)] #[derive(Debug, Default)]
struct BrokerExecutionSession { struct BrokerExecutionSession {
date: Option<NaiveDate>, date: Option<NaiveDate>,
@@ -423,6 +432,7 @@ pub struct BrokerSimulator<C, R> {
volume_percent: f64, volume_percent: f64,
volume_rate: Result<ParticipationRate, CapacityError>, volume_rate: Result<ParticipationRate, CapacityError>,
volume_limit: bool, volume_limit: bool,
volume_capacity_mode: VolumeCapacityMode,
inactive_limit: bool, inactive_limit: bool,
liquidity_limit: bool, liquidity_limit: bool,
strict_value_budget: bool, strict_value_budget: bool,
@@ -439,6 +449,7 @@ pub struct BrokerSimulator<C, R> {
runtime_auto_buy_denials: RefCell<BTreeMap<String, String>>, runtime_auto_buy_denials: RefCell<BTreeMap<String, String>>,
runtime_auto_sell_denials: RefCell<BTreeMap<String, String>>, runtime_auto_sell_denials: RefCell<BTreeMap<String, String>>,
runtime_order_created_date: Cell<Option<NaiveDate>>, runtime_order_created_date: Cell<Option<NaiveDate>>,
runtime_resting_order_origin: Cell<Option<RestingOrderOrigin>>,
runtime_decision_total_equity: Cell<Option<f64>>, runtime_decision_total_equity: Cell<Option<f64>>,
runtime_target_position_limit: Cell<Option<usize>>, runtime_target_position_limit: Cell<Option<usize>>,
runtime_time_in_force: Cell<Option<OrderTimeInForce>>, runtime_time_in_force: Cell<Option<OrderTimeInForce>>,
@@ -459,6 +470,7 @@ impl<C, R> BrokerSimulator<C, R> {
volume_percent: 0.25, volume_percent: 0.25,
volume_rate: ParticipationRate::new(0.25), volume_rate: ParticipationRate::new(0.25),
volume_limit: true, volume_limit: true,
volume_capacity_mode: VolumeCapacityMode::ExecutionObservation,
inactive_limit: true, inactive_limit: true,
liquidity_limit: true, liquidity_limit: true,
strict_value_budget: true, strict_value_budget: true,
@@ -475,6 +487,7 @@ impl<C, R> BrokerSimulator<C, R> {
runtime_auto_buy_denials: RefCell::new(BTreeMap::new()), runtime_auto_buy_denials: RefCell::new(BTreeMap::new()),
runtime_auto_sell_denials: RefCell::new(BTreeMap::new()), runtime_auto_sell_denials: RefCell::new(BTreeMap::new()),
runtime_order_created_date: Cell::new(None), runtime_order_created_date: Cell::new(None),
runtime_resting_order_origin: Cell::new(None),
runtime_decision_total_equity: Cell::new(None), runtime_decision_total_equity: Cell::new(None),
runtime_target_position_limit: Cell::new(None), runtime_target_position_limit: Cell::new(None),
runtime_time_in_force: Cell::new(None), runtime_time_in_force: Cell::new(None),
@@ -499,6 +512,7 @@ impl<C, R> BrokerSimulator<C, R> {
volume_percent: 0.25, volume_percent: 0.25,
volume_rate: ParticipationRate::new(0.25), volume_rate: ParticipationRate::new(0.25),
volume_limit: true, volume_limit: true,
volume_capacity_mode: VolumeCapacityMode::ExecutionObservation,
inactive_limit: true, inactive_limit: true,
liquidity_limit: true, liquidity_limit: true,
strict_value_budget: true, strict_value_budget: true,
@@ -515,6 +529,7 @@ impl<C, R> BrokerSimulator<C, R> {
runtime_auto_buy_denials: RefCell::new(BTreeMap::new()), runtime_auto_buy_denials: RefCell::new(BTreeMap::new()),
runtime_auto_sell_denials: RefCell::new(BTreeMap::new()), runtime_auto_sell_denials: RefCell::new(BTreeMap::new()),
runtime_order_created_date: Cell::new(None), runtime_order_created_date: Cell::new(None),
runtime_resting_order_origin: Cell::new(None),
runtime_decision_total_equity: Cell::new(None), runtime_decision_total_equity: Cell::new(None),
runtime_target_position_limit: Cell::new(None), runtime_target_position_limit: Cell::new(None),
runtime_time_in_force: Cell::new(None), runtime_time_in_force: Cell::new(None),
@@ -529,6 +544,29 @@ impl<C, R> BrokerSimulator<C, R> {
self self
} }
pub fn with_volume_capacity_mode(mut self, mode: VolumeCapacityMode) -> Self {
self.volume_capacity_mode = mode;
self
}
pub fn capacity_audit_summary(&self) -> CapacityAuditSummary {
CapacityAuditSummary { mode: self.volume_capacity_mode, enabled: self.volume_limit,
participation_rate: self.volume_percent, ..Default::default() }
}
pub fn audit_completed_session_capacity(&self, date: NaiveDate, data: &DataSet) -> Result<Vec<SessionCapacityAudit>, BacktestError> {
if !self.volume_limit || self.volume_capacity_mode != VolumeCapacityMode::SessionCapacityAudit {
return Ok(Vec::new());
}
let session = self.execution_session.borrow();
if session.date != Some(date) { return Ok(Vec::new()); }
let rate = self.volume_rate.map_err(|error| BacktestError::Execution(error.to_string()))?;
session.intraday_turnover.iter().filter(|(_, quantity)| **quantity > 0).map(|(symbol, quantity)| {
let market = data.market(date, symbol).ok_or_else(|| BacktestError::MissingPrice { date, symbol: symbol.clone(), field: "session capacity audit" })?;
Ok(SessionCapacityAudit::new(date, symbol.clone(), u64::from(*quantity), market.volume, rate))
}).collect()
}
pub fn with_inactive_limit(mut self, enabled: bool) -> Self { pub fn with_inactive_limit(mut self, enabled: bool) -> Self {
self.inactive_limit = enabled; self.inactive_limit = enabled;
self self
@@ -636,6 +674,23 @@ impl<C, R> BrokerSimulator<C, R> {
.or(self.intraday_execution_start_time) .or(self.intraday_execution_start_time)
} }
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()),
|origin| (origin.created_date, origin.submission_time),
)
}
fn accepted_order_date(&self, date: NaiveDate) -> NaiveDate {
self.runtime_resting_order_origin.get().map_or(date, |origin| origin.accepted_date)
}
fn resting_daily_open_order(&self) -> bool {
self.runtime_resting_order_origin.get().is_some()
&& self.runtime_intraday_start_time.get().is_some()
&& self.matching_type == MatchingType::NextBarOpen
}
fn execution_phase_for_submission( fn execution_phase_for_submission(
&self, &self,
date: NaiveDate, date: NaiveDate,
@@ -661,10 +716,11 @@ impl<C, R> BrokerSimulator<C, R> {
} }
fn execution_phase(&self, date: NaiveDate) -> EquityExecutionPhase { fn execution_phase(&self, date: NaiveDate) -> EquityExecutionPhase {
let origin = self.order_origin();
self.execution_phase_for_submission( self.execution_phase_for_submission(
date, date,
self.runtime_order_created_date.get(), origin.0,
self.submission_time(), origin.1,
) )
} }
@@ -675,6 +731,8 @@ impl<C, R> BrokerSimulator<C, R> {
fn effective_execution_price_field(&self, date: NaiveDate) -> PriceField { fn effective_execution_price_field(&self, date: NaiveDate) -> PriceField {
if self.is_post_close_fixed_price(date) { if self.is_post_close_fixed_price(date) {
PriceField::Close PriceField::Close
} else if self.resting_daily_open_order() {
PriceField::Last
} else { } else {
self.execution_price_field self.execution_price_field
} }
@@ -684,10 +742,11 @@ impl<C, R> BrokerSimulator<C, R> {
&self, &self,
date: NaiveDate, date: NaiveDate,
) -> Option<(NaiveDateTime, NaiveDateTime)> { ) -> Option<(NaiveDateTime, NaiveDateTime)> {
let origin = self.order_origin();
self.post_close_execution_quote_window_for_submission( self.post_close_execution_quote_window_for_submission(
date, date,
self.runtime_order_created_date.get(), origin.0,
self.submission_time(), origin.1,
) )
.map(|(start, end)| (date.and_time(start), date.and_time(end))) .map(|(start, end)| (date.and_time(start), date.and_time(end)))
} }
@@ -730,7 +789,8 @@ impl<C, R> BrokerSimulator<C, R> {
if self.is_post_close_fixed_price(date) { if self.is_post_close_fixed_price(date) {
return match self.runtime_time_in_force.get() { return match self.runtime_time_in_force.get() {
Some(OrderTimeInForce::Fok) => RemainderPolicy::FillOrKill, Some(OrderTimeInForce::Fok) => RemainderPolicy::FillOrKill,
_ => RemainderPolicy::Cancel, Some(OrderTimeInForce::Ioc | OrderTimeInForce::Gtc) => RemainderPolicy::Cancel,
_ => RemainderPolicy::KeepUntilClose,
}; };
} }
match self.runtime_time_in_force.get() { match self.runtime_time_in_force.get() {
@@ -781,7 +841,7 @@ impl<C, R> BrokerSimulator<C, R> {
filled_quantity: order.filled_quantity, filled_quantity: order.filled_quantity,
remaining_quantity: order.remaining_quantity, remaining_quantity: order.remaining_quantity,
unfilled_quantity: order.remaining_quantity, unfilled_quantity: order.remaining_quantity,
status: OrderStatus::Pending, status: if order.filled_quantity > 0 { OrderStatus::PartiallyFilled } else { OrderStatus::Pending },
avg_price: 0.0, avg_price: 0.0,
transaction_cost: 0.0, transaction_cost: 0.0,
limit_price: order.limit_price, limit_price: order.limit_price,
@@ -793,6 +853,17 @@ impl<C, R> BrokerSimulator<C, R> {
pub fn has_open_orders(&self) -> bool { pub fn has_open_orders(&self) -> bool {
!self.open_orders.borrow().is_empty() !self.open_orders.borrow().is_empty()
} }
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")
}
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)
.map(|order| self.resting_order_session_close(date, order)).min()
}
} }
impl<C, R> BrokerSimulator<C, R> impl<C, R> BrokerSimulator<C, R>
@@ -1358,6 +1429,7 @@ where
match algo_request.map(|request| request.style) { match algo_request.map(|request| request.style) {
Some(AlgoExecutionStyle::Vwap) => MatchingType::Vwap, Some(AlgoExecutionStyle::Vwap) => MatchingType::Vwap,
Some(AlgoExecutionStyle::Twap) => MatchingType::Twap, Some(AlgoExecutionStyle::Twap) => MatchingType::Twap,
None if self.resting_daily_open_order() => MatchingType::CurrentBarClose,
None => self.matching_type, None => self.matching_type,
} }
} }
@@ -2389,7 +2461,7 @@ where
} }
fn current_order_created_date(&self, date: NaiveDate) -> NaiveDate { fn current_order_created_date(&self, date: NaiveDate) -> NaiveDate {
self.runtime_order_created_date.get().unwrap_or(date) self.order_origin().0.unwrap_or(date)
} }
fn annotate_report_range( fn annotate_report_range(
@@ -2541,6 +2613,23 @@ where
std::mem::take(&mut *open_orders) std::mem::take(&mut *open_orders)
}; };
for order in pending_orders { for order in pending_orders {
if self.matching_type == MatchingType::NextBarOpen && self.runtime_intraday_start_time.get().is_none()
&& order.accepted_date == date {
self.open_orders.borrow_mut().push(order);
continue;
}
let close = self.resting_order_session_close(date, &order);
let clock = self.submission_time();
let past_day = order.time_in_force == OrderTimeInForce::Day
&& order.accepted_date < date;
if past_day || clock.is_some_and(|time| time > close) {
if order.time_in_force == OrderTimeInForce::Day {
Self::emit_resting_day_expiry(report, date, &order, order.filled_quantity);
} else {
self.open_orders.borrow_mut().push(order);
}
continue;
}
let order_event_start = report.order_events.len(); let order_event_start = report.order_events.len();
let fill_event_start = report.fill_events.len(); let fill_event_start = report.fill_events.len();
if let Some(commission_remaining) = order.commission_remaining { if let Some(commission_remaining) = order.commission_remaining {
@@ -2565,6 +2654,12 @@ where
let previous_time_in_force = self let previous_time_in_force = self
.runtime_time_in_force .runtime_time_in_force
.replace(Some(order.time_in_force)); .replace(Some(order.time_in_force));
let previous_origin = self.runtime_resting_order_origin.replace(Some(RestingOrderOrigin {
created_date: order.order_created_date,
submission_time: order.submission_time,
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 = self.process_limit_shares_internal(
date, date,
portfolio, portfolio,
@@ -2582,6 +2677,8 @@ where
report, report,
); );
self.runtime_time_in_force.set(previous_time_in_force); 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);
execution_result?; execution_result?;
let attempt_filled = report.fill_events[fill_event_start..] let attempt_filled = report.fill_events[fill_event_start..]
.iter() .iter()
@@ -2600,6 +2697,8 @@ where
remains_open = remaining_quantity > 0; remains_open = remaining_quantity > 0;
reopened.decision_date = order.decision_date; reopened.decision_date = order.decision_date;
reopened.order_created_date = order.order_created_date; reopened.order_created_date = order.order_created_date;
reopened.submission_time = order.submission_time;
reopened.accepted_date = order.accepted_date;
reopened.requested_quantity = order.requested_quantity; reopened.requested_quantity = order.requested_quantity;
reopened.filled_quantity = cumulative_filled; reopened.filled_quantity = cumulative_filled;
reopened.remaining_quantity = remaining_quantity; reopened.remaining_quantity = remaining_quantity;
@@ -2611,6 +2710,13 @@ where
open_orders.retain(|open| open.order_id != order.order_id); open_orders.retain(|open| open.order_id != order.order_id);
} }
} }
if remains_open && order.time_in_force == OrderTimeInForce::Day
&& clock.is_some_and(|time| time >= close)
{
self.clear_open_order(order.order_id);
Self::emit_resting_day_expiry(report, date, &order, cumulative_filled);
remains_open = false;
}
if report.order_events.len() == order_event_start && !remains_open { if report.order_events.len() == order_event_start && !remains_open {
report.order_events.push(OrderEvent { report.order_events.push(OrderEvent {
date, date,
@@ -2666,6 +2772,18 @@ where
Ok(()) Ok(())
} }
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));
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(),
side: order.side, requested_quantity: order.requested_quantity, filled_quantity: filled,
status: OrderStatus::Expired, reason: detail.clone(),
});
Self::emit_order_process_event(report, date, ProcessEventKind::OrderUnsolicitedUpdate,
order.order_id, &order.symbol, order.side, format!("status=Expired reason={detail}"));
}
fn cancel_open_order( fn cancel_open_order(
&self, &self,
date: NaiveDate, date: NaiveDate,
@@ -3495,8 +3613,6 @@ where
data, data,
&symbol, &symbol,
current_qty, current_qty,
minimum_order_quantity,
order_step_size,
) )
{ {
diagnostics.push(format!( diagnostics.push(format!(
@@ -3513,8 +3629,6 @@ where
data, data,
&symbol, &symbol,
current_qty, current_qty,
minimum_order_quantity,
order_step_size,
) )
{ {
diagnostics.push(format!( diagnostics.push(format!(
@@ -3925,8 +4039,6 @@ where
data, data,
symbol, symbol,
current_qty, current_qty,
minimum_order_quantity,
order_step_size,
) else { ) else {
continue; continue;
}; };
@@ -3986,6 +4098,9 @@ where
side: OrderSide, side: OrderSide,
algo_request: Option<&AlgoExecutionRequest>, algo_request: Option<&AlgoExecutionRequest>,
) -> f64 { ) -> f64 {
if self.matching_type == MatchingType::NextBarOpen && !self.resting_daily_open_order() && algo_request.is_none() {
return self.execution_limit_check_price(snapshot, side);
}
let matching_type = self.matching_type_for_algo_request(algo_request); let matching_type = self.matching_type_for_algo_request(algo_request);
let start_cursor = algo_request let start_cursor = algo_request
.and_then(|request| request.start_time) .and_then(|request| request.start_time)
@@ -4194,8 +4309,6 @@ where
data: &DataSet, data: &DataSet,
symbol: &str, symbol: &str,
current_qty: u32, current_qty: u32,
minimum_order_quantity: u32,
order_step_size: u32,
) -> Option<String> { ) -> Option<String> {
if let Some(reason) = self.runtime_auto_sell_denials.borrow().get(symbol) { if let Some(reason) = self.runtime_auto_sell_denials.borrow().get(symbol) {
return Some(reason.clone()); return Some(reason.clone());
@@ -4224,12 +4337,8 @@ where
.saturating_sub(self.reserved_open_sell_quantity(symbol, None)); .saturating_sub(self.reserved_open_sell_quantity(symbol, None));
match self.market_fillable_quantity( match self.market_fillable_quantity(
snapshot, snapshot,
OrderSide::Sell,
sellable.min(current_qty), sellable.min(current_qty),
minimum_order_quantity, false,
order_step_size,
0,
sellable >= current_qty,
) { ) {
Ok(quantity) => { Ok(quantity) => {
let quantity = quantity.min(sellable).min(current_qty); let quantity = quantity.min(sellable).min(current_qty);
@@ -4250,8 +4359,6 @@ where
data: &DataSet, data: &DataSet,
symbol: &str, symbol: &str,
current_qty: u32, current_qty: u32,
minimum_order_quantity: u32,
order_step_size: u32,
) -> Option<String> { ) -> Option<String> {
let snapshot = data.require_market(date, symbol).ok()?; let snapshot = data.require_market(date, symbol).ok()?;
let candidate = data.require_candidate(date, symbol).ok()?; let candidate = data.require_candidate(date, symbol).ok()?;
@@ -4272,11 +4379,7 @@ where
} }
match self.market_fillable_quantity( match self.market_fillable_quantity(
snapshot, snapshot,
OrderSide::Buy,
u32::MAX, u32::MAX,
minimum_order_quantity,
order_step_size,
0,
false, false,
) { ) {
Ok(quantity) => { Ok(quantity) => {
@@ -4391,6 +4494,7 @@ where
); );
return Ok(()); return Ok(());
}; };
let limit_price = limit_price.or_else(|| self.is_post_close_fixed_price(date).then_some(snapshot.close));
let Some(candidate) = data.candidate(date, symbol) else { let Some(candidate) = data.candidate(date, symbol) else {
Self::reject_unavailable_order( Self::reject_unavailable_order(
report, report,
@@ -4545,14 +4649,12 @@ where
} else { } else {
None None
}; };
self.volume_capacity_mode.validate(self.volume_limit, algo_request.is_some() || self.matching_type_uses_intraday_quotes())
.map_err(|error| BacktestError::Execution(error.to_string()))?;
let market_limited_qty = self.market_fillable_quantity( let market_limited_qty = self.market_fillable_quantity(
snapshot, snapshot,
OrderSide::Sell,
requested_qty.min(sellable), requested_qty.min(sellable),
self.minimum_order_quantity(data, symbol), algo_request.is_some(),
self.order_step_size(data, symbol),
*intraday_turnover.get(symbol).unwrap_or(&0),
requested_qty >= position.quantity && sellable >= position.quantity,
); );
let fillable_qty = match market_limited_qty { let fillable_qty = match market_limited_qty {
Ok(quantity) => { Ok(quantity) => {
@@ -4582,6 +4684,8 @@ where
if Self::keeps_remainder_open(remainder_policy) { if Self::keeps_remainder_open(remainder_policy) {
self.upsert_open_order(OpenOrder { self.upsert_open_order(OpenOrder {
order_id, order_id,
submission_time: if self.matching_type == MatchingType::NextBarOpen { NaiveTime::from_hms_opt(9,30,0) } else { self.order_origin().1 },
accepted_date: self.accepted_order_date(date),
decision_date: Some(self.current_decision_date(date)), decision_date: Some(self.current_decision_date(date)),
order_created_date: Some(self.current_order_created_date(date)), order_created_date: Some(self.current_order_created_date(date)),
symbol: symbol.to_string(), symbol: symbol.to_string(),
@@ -4595,6 +4699,10 @@ where
execution_cursor: execution_cursors.get(symbol).copied(), execution_cursor: execution_cursors.get(symbol).copied(),
reason: reason.to_string(), reason: reason.to_string(),
}); });
// Waiting without a fill is not a new order-state transition.
if !emit_creation_events {
return Ok(());
}
report.order_events.push(OrderEvent { report.order_events.push(OrderEvent {
date, date,
decision_date: None, decision_date: None,
@@ -4667,6 +4775,8 @@ where
.unwrap_or("no sellable quantity"); .unwrap_or("no sellable quantity");
self.upsert_open_order(OpenOrder { self.upsert_open_order(OpenOrder {
order_id, order_id,
submission_time: if self.matching_type == MatchingType::NextBarOpen { NaiveTime::from_hms_opt(9,30,0) } else { self.order_origin().1 },
accepted_date: self.accepted_order_date(date),
decision_date: Some(self.current_decision_date(date)), decision_date: Some(self.current_decision_date(date)),
order_created_date: Some(self.current_order_created_date(date)), order_created_date: Some(self.current_order_created_date(date)),
symbol: symbol.to_string(), symbol: symbol.to_string(),
@@ -4680,6 +4790,10 @@ where
execution_cursor: execution_cursors.get(symbol).copied(), execution_cursor: execution_cursors.get(symbol).copied(),
reason: reason.to_string(), reason: reason.to_string(),
}); });
// Waiting without a fill is not a new order-state transition.
if !emit_creation_events {
return Ok(());
}
report.order_events.push(OrderEvent { report.order_events.push(OrderEvent {
date, date,
decision_date: None, decision_date: None,
@@ -4834,6 +4948,8 @@ where
{ {
self.upsert_open_order(OpenOrder { self.upsert_open_order(OpenOrder {
order_id, order_id,
submission_time: if self.matching_type == MatchingType::NextBarOpen { NaiveTime::from_hms_opt(9,30,0) } else { self.order_origin().1 },
accepted_date: self.accepted_order_date(date),
decision_date: Some(self.current_decision_date(date)), decision_date: Some(self.current_decision_date(date)),
order_created_date: Some(self.current_order_created_date(date)), order_created_date: Some(self.current_order_created_date(date)),
symbol: symbol.to_string(), symbol: symbol.to_string(),
@@ -4847,6 +4963,10 @@ where
execution_cursor: execution_cursors.get(symbol).copied(), execution_cursor: execution_cursors.get(symbol).copied(),
reason: reason.to_string(), reason: reason.to_string(),
}); });
// Waiting without a fill is not a new order-state transition.
if !emit_creation_events {
return Ok(());
}
report.order_events.push(OrderEvent { report.order_events.push(OrderEvent {
date, date,
decision_date: None, decision_date: None,
@@ -5000,6 +5120,8 @@ where
if keep_open { if keep_open {
self.upsert_open_order(OpenOrder { self.upsert_open_order(OpenOrder {
order_id, order_id,
submission_time: if self.matching_type == MatchingType::NextBarOpen { NaiveTime::from_hms_opt(9,30,0) } else { self.order_origin().1 },
accepted_date: self.accepted_order_date(date),
decision_date: Some(self.current_decision_date(date)), decision_date: Some(self.current_decision_date(date)),
order_created_date: Some(self.current_order_created_date(date)), order_created_date: Some(self.current_order_created_date(date)),
symbol: symbol.to_string(), symbol: symbol.to_string(),
@@ -6197,6 +6319,7 @@ where
); );
return Ok(()); return Ok(());
}; };
let limit_price = limit_price.or_else(|| self.is_post_close_fixed_price(date).then_some(snapshot.close));
let Some(candidate) = data.candidate(date, symbol) else { let Some(candidate) = data.candidate(date, symbol) else {
Self::reject_unavailable_order( Self::reject_unavailable_order(
report, report,
@@ -6354,14 +6477,12 @@ where
} }
let mut partial_fill_reason = None; let mut partial_fill_reason = None;
self.volume_capacity_mode.validate(self.volume_limit, algo_request.is_some() || self.matching_type_uses_intraday_quotes())
.map_err(|error| BacktestError::Execution(error.to_string()))?;
let market_limited_qty = self.market_fillable_quantity( let market_limited_qty = self.market_fillable_quantity(
snapshot, snapshot,
OrderSide::Buy,
requested_qty, requested_qty,
self.minimum_order_quantity(data, symbol), algo_request.is_some(),
self.order_step_size(data, symbol),
*intraday_turnover.get(symbol).unwrap_or(&0),
false,
); );
let constrained_qty = match market_limited_qty { let constrained_qty = match market_limited_qty {
Ok(quantity) => { Ok(quantity) => {
@@ -6387,6 +6508,8 @@ where
if Self::keeps_remainder_open(remainder_policy) { if Self::keeps_remainder_open(remainder_policy) {
self.upsert_open_order(OpenOrder { self.upsert_open_order(OpenOrder {
order_id, order_id,
submission_time: if self.matching_type == MatchingType::NextBarOpen { NaiveTime::from_hms_opt(9,30,0) } else { self.order_origin().1 },
accepted_date: self.accepted_order_date(date),
decision_date: Some(self.current_decision_date(date)), decision_date: Some(self.current_decision_date(date)),
order_created_date: Some(self.current_order_created_date(date)), order_created_date: Some(self.current_order_created_date(date)),
symbol: symbol.to_string(), symbol: symbol.to_string(),
@@ -6400,6 +6523,10 @@ where
execution_cursor: execution_cursors.get(symbol).copied(), execution_cursor: execution_cursors.get(symbol).copied(),
reason: reason.to_string(), reason: reason.to_string(),
}); });
// Waiting without a fill is not a new order-state transition.
if !emit_creation_events {
return Ok(());
}
report.order_events.push(OrderEvent { report.order_events.push(OrderEvent {
date, date,
decision_date: None, decision_date: None,
@@ -6621,6 +6748,8 @@ where
{ {
self.upsert_open_order(OpenOrder { self.upsert_open_order(OpenOrder {
order_id, order_id,
submission_time: if self.matching_type == MatchingType::NextBarOpen { NaiveTime::from_hms_opt(9,30,0) } else { self.order_origin().1 },
accepted_date: self.accepted_order_date(date),
decision_date: Some(self.current_decision_date(date)), decision_date: Some(self.current_decision_date(date)),
order_created_date: Some(self.current_order_created_date(date)), order_created_date: Some(self.current_order_created_date(date)),
symbol: symbol.to_string(), symbol: symbol.to_string(),
@@ -6634,6 +6763,10 @@ where
execution_cursor: execution_cursors.get(symbol).copied(), execution_cursor: execution_cursors.get(symbol).copied(),
reason: reason.to_string(), reason: reason.to_string(),
}); });
// Waiting without a fill is not a new order-state transition.
if !emit_creation_events {
return Ok(());
}
report.order_events.push(OrderEvent { report.order_events.push(OrderEvent {
date, date,
decision_date: None, decision_date: None,
@@ -6789,6 +6922,8 @@ where
if keep_open { if keep_open {
self.upsert_open_order(OpenOrder { self.upsert_open_order(OpenOrder {
order_id, order_id,
submission_time: if self.matching_type == MatchingType::NextBarOpen { NaiveTime::from_hms_opt(9,30,0) } else { self.order_origin().1 },
accepted_date: self.accepted_order_date(date),
decision_date: Some(self.current_decision_date(date)), decision_date: Some(self.current_decision_date(date)),
order_created_date: Some(self.current_order_created_date(date)), order_created_date: Some(self.current_order_created_date(date)),
symbol: symbol.to_string(), symbol: symbol.to_string(),
@@ -7258,68 +7393,19 @@ where
fn market_fillable_quantity( fn market_fillable_quantity(
&self, &self,
snapshot: &crate::data::DailyMarketSnapshot, snapshot: &crate::data::DailyMarketSnapshot,
side: OrderSide,
requested_qty: u32, requested_qty: u32,
minimum_order_quantity: u32, algorithmic_order: bool,
order_step_size: u32,
consumed_turnover: u32,
allow_odd_lot_sell: bool,
) -> Result<u32, String> { ) -> Result<u32, String> {
if requested_qty == 0 { if requested_qty == 0 {
return Ok(0); return Ok(0);
} }
let uses_intraday_quantity = self.matching_type_uses_intraday_quotes(); if self.inactive_limit && snapshot.paused { return Err("paused".into()); }
let available_market_volume = if uses_intraday_quantity { self.volume_capacity_mode.validate(self.volume_limit, algorithmic_order || self.matching_type_uses_intraday_quotes())
snapshot.minute_volume .map_err(|error| error.to_string())?;
} else { // Per-observation limits are applied to each actual quote below. The
snapshot.volume // session-audit model must never size this order from the day's total.
}; Ok(requested_qty)
let no_volume_reason = if uses_intraday_quantity {
"minute no volume"
} else {
"daily no volume"
};
let volume_limit_reason = if uses_intraday_quantity {
"minute volume limit"
} else {
"daily volume limit"
};
let mut max_fill = requested_qty;
if self.inactive_limit
&& (snapshot.paused || (!uses_intraday_quantity && available_market_volume == 0))
{
return Err(if snapshot.paused {
"paused".to_string()
} else {
no_volume_reason.to_string()
});
}
if uses_intraday_quantity {
return Ok(max_fill);
}
if self.volume_limit {
let raw_limit = self.volume_rate.map_err(|error| error.to_string())?
.remaining(available_market_volume, u64::from(consumed_turnover), requested_qty);
if raw_limit == 0 {
return Err(volume_limit_reason.to_string());
}
let volume_limited = if side == OrderSide::Sell && allow_odd_lot_sell {
raw_limit
} else {
self.round_buy_quantity(raw_limit, minimum_order_quantity, order_step_size)
};
if volume_limited == 0 {
return Err(volume_limit_reason.to_string());
}
max_fill = max_fill.min(volume_limited);
}
Ok(max_fill)
} }
fn price_satisfies_limit( fn price_satisfies_limit(
@@ -7442,14 +7528,24 @@ where
let runtime_start_time = self.runtime_intraday_start_time.get(); let runtime_start_time = self.runtime_intraday_start_time.get();
let runtime_end_time = self.runtime_intraday_end_time.get(); let runtime_end_time = self.runtime_intraday_end_time.get();
let start_cursor = post_close_window.map(|window| window.0).or_else(|| { let start_cursor = post_close_window.map(|window| {
runtime_start_time.map_or(window.0, |start| window.0.max(date.and_time(start)))
}).or_else(|| {
algo_request algo_request
.and_then(|request| request.start_time) .and_then(|request| request.start_time)
.or(runtime_start_time) .or(runtime_start_time)
.or(self.intraday_execution_start_time) .or(self.intraday_execution_start_time)
.map(|start_time| date.and_time(start_time)) .map(|start_time| date.and_time(start_time))
}); });
let end_cursor = post_close_window.map(|window| window.1).or_else(|| { let start_cursor = if let Some(origin) = self.runtime_resting_order_origin.get()
&& origin.accepted_date == date
&& let Some(submitted) = origin.submission_time
{
Some(start_cursor.map_or(date.and_time(submitted), |cursor| cursor.max(date.and_time(submitted))))
} 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(|| {
algo_request algo_request
.and_then(|request| request.end_time) .and_then(|request| request.end_time)
.or(runtime_end_time) .or(runtime_end_time)
@@ -7723,7 +7819,7 @@ where
} else { } else {
remaining_qty remaining_qty
}; };
if self.volume_limit { if self.volume_limit && self.volume_capacity_mode.limits_execution_quantity() {
let consumed = execution_ledger let consumed = execution_ledger
.volume_consumed(symbol, quote.timestamp) .volume_consumed(symbol, quote.timestamp)
.saturating_add( .saturating_add(
@@ -7760,7 +7856,7 @@ where
} else { } else {
remaining_qty.min(available_qty) remaining_qty.min(available_qty)
}; };
if !(side == OrderSide::Sell && allow_odd_lot_sell && take_qty == remaining_qty) { if !(side == OrderSide::Sell && allow_odd_lot_sell) {
take_qty = take_qty =
self.round_buy_quantity(take_qty, minimum_order_quantity, order_step_size); self.round_buy_quantity(take_qty, minimum_order_quantity, order_step_size);
} }
@@ -7871,7 +7967,7 @@ where
.saturating_add(take_qty) .saturating_add(take_qty)
.min(state.displayed_quantity); .min(state.displayed_quantity);
} }
if self.volume_limit { if self.volume_limit && self.volume_capacity_mode.limits_execution_quantity() {
let consumed = pending_volume_consumption let consumed = pending_volume_consumption
.entry(quote.timestamp) .entry(quote.timestamp)
.or_default(); .or_default();
@@ -7885,7 +7981,7 @@ where
depth_price_bits, depth_price_bits,
displayed_quantity, displayed_quantity,
consume_depth, consume_depth,
consume_volume: self.volume_limit, consume_volume: self.volume_limit && self.volume_capacity_mode.limits_execution_quantity(),
quantity: take_qty, quantity: take_qty,
}); });
} }
@@ -7961,7 +8057,8 @@ where
quote.volume_delta > 0 && quote.bid1_volume == 0 && quote.ask1_volume == 0 quote.volume_delta > 0 && quote.bid1_volume == 0 && quote.ask1_volume == 0
} }
fn matching_type_uses_intraday_quotes(&self) -> bool { pub(crate) fn matching_type_uses_intraday_quotes(&self) -> bool {
if self.resting_daily_open_order() { return true; }
matches!( matches!(
self.matching_type, self.matching_type,
MatchingType::MinuteLast MatchingType::MinuteLast
@@ -7973,6 +8070,10 @@ where
&& self.intraday_execution_start_time.is_some()) && self.intraday_execution_start_time.is_some())
} }
pub(crate) fn drives_resting_quote_clock(&self) -> bool {
self.matching_type_uses_intraday_quotes() || self.matching_type == MatchingType::NextBarOpen
}
fn quote_quantity_limited(&self, matching_type: MatchingType) -> bool { fn quote_quantity_limited(&self, matching_type: MatchingType) -> bool {
match matching_type { match matching_type {
MatchingType::OpenAuction MatchingType::OpenAuction
@@ -8104,6 +8205,8 @@ mod tests {
order_id, order_id,
decision_date: None, decision_date: None,
order_created_date: None, order_created_date: None,
submission_time: None,
accepted_date: chrono::NaiveDate::from_ymd_opt(2025,1,2).unwrap(),
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".to_string(),
side: OrderSide::Buy, side: OrderSide::Buy,
requested_quantity: 200, requested_quantity: 200,
@@ -8432,6 +8535,7 @@ mod tests {
vec![dated_limit_test_benchmark(first), dated_limit_test_benchmark(second)], vec![dated_limit_test_benchmark(first), dated_limit_test_benchmark(second)],
).unwrap(); ).unwrap();
let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks) let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
.with_volume_capacity_mode(crate::execution_capacity::VolumeCapacityMode::SessionCapacityAudit)
.with_matching_type(MatchingType::CurrentBarClose); .with_matching_type(MatchingType::CurrentBarClose);
let mut portfolio = PortfolioState::new(100_000.0); let mut portfolio = PortfolioState::new(100_000.0);
broker.execute(first, &mut portfolio, &data, &next_open_buy_decision()).unwrap(); broker.execute(first, &mut portfolio, &data, &next_open_buy_decision()).unwrap();
@@ -8458,6 +8562,7 @@ mod tests {
let data = DataSet::from_components(vec![limit_test_instrument()], vec![limit_test_snapshot()], let data = DataSet::from_components(vec![limit_test_instrument()], vec![limit_test_snapshot()],
Vec::new(), vec![limit_test_candidate(true, true)], vec![limit_test_benchmark()]).unwrap(); Vec::new(), vec![limit_test_candidate(true, true)], vec![limit_test_benchmark()]).unwrap();
let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks) let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
.with_volume_capacity_mode(crate::execution_capacity::VolumeCapacityMode::SessionCapacityAudit)
.with_matching_type(MatchingType::CurrentBarClose); .with_matching_type(MatchingType::CurrentBarClose);
broker.upsert_open_order(test_open_order(99)); broker.upsert_open_order(test_open_order(99));
let mut decision = StrategyDecision::default(); let mut decision = StrategyDecision::default();
@@ -8485,6 +8590,7 @@ mod tests {
dated_limit_test_candidate(second, false, false, true, true)], dated_limit_test_candidate(second, false, false, true, true)],
vec![dated_limit_test_benchmark(first), dated_limit_test_benchmark(second)]).unwrap(); vec![dated_limit_test_benchmark(first), dated_limit_test_benchmark(second)]).unwrap();
let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks) let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
.with_volume_capacity_mode(crate::execution_capacity::VolumeCapacityMode::SessionCapacityAudit)
.with_matching_type(MatchingType::NextBarOpen); .with_matching_type(MatchingType::NextBarOpen);
let mut portfolio = PortfolioState::new(1_000_000.0); let mut portfolio = PortfolioState::new(1_000_000.0);
let mut initial = StrategyDecision::default(); let mut initial = StrategyDecision::default();
@@ -8634,6 +8740,77 @@ mod tests {
); );
} }
#[test]
fn gtc_resting_order_keeps_its_session_and_original_dates_across_days() {
let first = chrono::NaiveDate::from_ymd_opt(2026, 7, 6).unwrap();
let second = first.succ_opt().unwrap();
let mut snapshot = dated_limit_test_snapshot(first);
snapshot.open = 10.2;
snapshot.close = 9.8;
snapshot.upper_limit = 20.;
snapshot.lower_limit = 1.;
let mut next = snapshot.clone(); next.date = second;
let mut opening = limit_test_quote(10.2,10.2,10.2);
opening.date=first; opening.timestamp=first.and_hms_opt(9,30,0).unwrap();
let mut closing = opening.clone(); closing.timestamp=first.and_hms_opt(15,5,0).unwrap();
closing.last_price=9.8; closing.bid1=9.8; closing.ask1=9.8;
let mut next_open = closing.clone(); next_open.date=second; next_open.timestamp=second.and_hms_opt(9,30,0).unwrap();
let data=DataSet::from_components_with_actions_and_quotes(
vec![limit_test_instrument()], vec![snapshot,next], Vec::new(),
vec![dated_limit_test_candidate(first,false,false,true,true),dated_limit_test_candidate(second,false,false,true,true)],
vec![dated_limit_test_benchmark(first),dated_limit_test_benchmark(second)],Vec::new(),vec![opening,closing,next_open],
).unwrap();
let broker=BrokerSimulator::new(ChinaAShareCostModel::default(),ChinaEquityRuleHooks)
.with_matching_type(MatchingType::CurrentBarClose)
.with_intraday_execution_start_time(NaiveTime::from_hms_opt(9,30,0).unwrap())
.with_volume_limit(false).with_liquidity_limit(false);
let decision=StrategyDecision{order_intents:vec![OrderIntent::WithTimeInForce{
time_in_force:OrderTimeInForce::Gtc,intent:Box::new(OrderIntent::LimitTargetShares{
symbol:"000001.SZ".into(),target_quantity:100,limit_price:10.,reason:"original-entry".into(),
})}],..StrategyDecision::default()};
let mut portfolio=PortfolioState::new(100000.);
let report=broker.execute_between(first,&mut portfolio,&data,&decision,
NaiveTime::from_hms_opt(9,30,0),NaiveTime::from_hms_opt(9,30,0)).unwrap();
assert!(report.fill_events.is_empty());
let report=broker.execute_between(first,&mut portfolio,&data,&StrategyDecision::default(),
NaiveTime::from_hms_opt(15,5,0),NaiveTime::from_hms_opt(15,5,0)).unwrap();
assert!(report.fill_events.is_empty()); assert!(report.order_events.is_empty());
assert!(broker.has_open_orders());
portfolio.begin_trading_day();
let report=broker.execute_between(second,&mut portfolio,&data,&StrategyDecision::default(),
NaiveTime::from_hms_opt(9,30,0),NaiveTime::from_hms_opt(9,30,0)).unwrap();
assert_eq!(report.fill_events.len(),1,"{report:?}");
assert_eq!(report.fill_events[0].order_created_date,Some(first));
assert_eq!(report.fill_events[0].decision_date,Some(first));
assert_eq!(report.fill_events[0].execution_date,Some(second));
assert!(!broker.has_open_orders());
assert!(broker.runtime_resting_order_origin.get().is_none());
}
#[test]
fn next_open_day_limit_remainder_matches_intraday_with_execution_day_ttl() {
let date=chrono::NaiveDate::from_ymd_opt(2026,7,7).unwrap();
let signal=date.pred_opt().unwrap();
let mut snapshot=dated_limit_test_snapshot(date);
snapshot.open=10.2;snapshot.close=9.8;snapshot.last_price=9.8;snapshot.upper_limit=20.;snapshot.lower_limit=1.;
let mut quote=limit_test_quote(9.8,9.8,9.8);quote.date=date;quote.timestamp=date.and_hms_opt(10,0,0).unwrap();
let data=DataSet::from_components_with_actions_and_quotes(vec![limit_test_instrument()],vec![snapshot],Vec::new(),
vec![dated_limit_test_candidate(date,false,false,true,true)],vec![dated_limit_test_benchmark(date)],Vec::new(),vec![quote]).unwrap();
let broker=BrokerSimulator::new(ChinaAShareCostModel::default(),ChinaEquityRuleHooks)
.with_matching_type(MatchingType::NextBarOpen).with_volume_limit(false).with_liquidity_limit(false);
let mut portfolio=PortfolioState::new(100000.);
let decision=StrategyDecision{order_intents:vec![OrderIntent::LimitTargetShares{symbol:"000001.SZ".into(),target_quantity:100,limit_price:10.,reason:"next-open-entry".into()}],..StrategyDecision::default()};
let report=broker.execute_with_event_dates(date,signal,signal,&mut portfolio,&data,&decision).unwrap();
assert!(report.fill_events.is_empty());assert!(broker.has_open_orders());
let report=broker.execute_between_with_event_dates(date,signal,signal,&mut portfolio,&data,&StrategyDecision::default(),
NaiveTime::from_hms_opt(10,0,0),NaiveTime::from_hms_opt(10,0,0)).unwrap();
assert_eq!(report.fill_events.len(),1,"{report:?}");
assert_eq!(report.fill_events[0].price,9.8);
assert_eq!(report.fill_events[0].execution_timestamp,date.and_hms_opt(10,0,0));
assert_eq!(report.fill_events[0].order_created_date,Some(signal));
assert!(!broker.has_open_orders());
}
#[test] #[test]
fn post_close_order_uses_close_without_slippage_and_waits_until_matching_window() { fn post_close_order_uses_close_without_slippage_and_waits_until_matching_window() {
let date = chrono::NaiveDate::from_ymd_opt(2026, 7, 6).expect("valid date"); let date = chrono::NaiveDate::from_ymd_opt(2026, 7, 6).expect("valid date");
@@ -8677,6 +8854,14 @@ mod tests {
) )
.expect("post-close order executes"); .expect("post-close order executes");
assert!(report.fill_events.is_empty(), "15:00 must not receive a future 15:05 fill");
assert!(broker.has_open_orders());
let report = broker.execute_between(date, &mut portfolio, &data, &StrategyDecision::default(),
NaiveTime::from_hms_opt(15,4,0),NaiveTime::from_hms_opt(15,4,0)).unwrap();
assert!(report.fill_events.is_empty());
assert!(report.order_events.is_empty());
let report = broker.execute_between(date, &mut portfolio, &data, &StrategyDecision::default(),
NaiveTime::from_hms_opt(15,5,0),NaiveTime::from_hms_opt(15,5,0)).unwrap();
assert_eq!(report.fill_events.len(), 1, "{report:?}"); assert_eq!(report.fill_events.len(), 1, "{report:?}");
let fill = &report.fill_events[0]; let fill = &report.fill_events[0];
assert_eq!(fill.price, 10.0, "fixed-price trading uses official close"); assert_eq!(fill.price, 10.0, "fixed-price trading uses official close");
@@ -9428,7 +9613,42 @@ mod tests {
} }
#[test] #[test]
fn current_bar_close_volume_limit_uses_daily_volume_when_minute_volume_missing() { fn daily_session_volume_changes_only_audit_not_opening_fills() {
use crate::execution_capacity::VolumeCapacityMode;
let run = |volume: u64, mode: VolumeCapacityMode| {
let mut market = limit_test_snapshot();
market.volume = volume;
let date = market.date;
let data = DataSet::from_components_with_actions_and_quotes(
vec![limit_test_instrument()], vec![market], vec![],
vec![limit_test_candidate(true, true)], vec![limit_test_benchmark()], vec![], vec![],
).unwrap();
let mut portfolio = PortfolioState::new(100_000.0);
let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
.with_volume_capacity_mode(mode).with_liquidity_limit(false);
let decision = StrategyDecision { order_intents: vec![OrderIntent::Shares {
symbol: "000001.SZ".into(), quantity: 1_000, reason: "capacity_test".into(),
}], ..StrategyDecision::default() };
let before = portfolio.cash();
let outcome = broker.execute(date, &mut portfolio, &data, &decision);
if outcome.is_err() { assert_eq!(portfolio.cash(), before); }
let audit = broker.audit_completed_session_capacity(date, &data).unwrap();
(outcome, portfolio.cash(), audit)
};
let (strict, _, _) = run(1_000_000, VolumeCapacityMode::ExecutionObservation);
assert!(strict.unwrap_err().to_string().contains("execution-time capacity is missing"));
let (a, cash_a, audit_a) = run(100, VolumeCapacityMode::SessionCapacityAudit);
let (b, cash_b, audit_b) = run(1_000_000, VolumeCapacityMode::SessionCapacityAudit);
let a = a.unwrap(); let b = b.unwrap();
assert_eq!(a.fill_events.len(), 1);
assert_eq!(serde_json::to_value(&a.fill_events).unwrap(), serde_json::to_value(&b.fill_events).unwrap());
assert_eq!(cash_a, cash_b);
assert_eq!(audit_a[0].filled_shares, 1_000);
assert!(!audit_a[0].passed); assert!(audit_b[0].passed);
}
#[test]
fn daily_capacity_requires_a_timed_observation_instead_of_falling_back_to_total_volume() {
let mut snapshot = limit_test_snapshot(); let mut snapshot = limit_test_snapshot();
snapshot.minute_volume = 0; snapshot.minute_volume = 0;
snapshot.volume = 1_000_000; snapshot.volume = 1_000_000;
@@ -9444,13 +9664,13 @@ mod tests {
.with_liquidity_limit(true); .with_liquidity_limit(true);
let fillable = let fillable =
broker.market_fillable_quantity(&snapshot, OrderSide::Buy, 5_000, 100, 100, 0, false); broker.market_fillable_quantity(&snapshot, 5_000, false);
assert_eq!(fillable, Ok(5_000)); assert!(fillable.unwrap_err().contains("daily session volume cannot size an earlier fill"));
} }
#[test] #[test]
fn volume_limit_uses_floor_for_odd_lot_sell() { fn session_capacity_audit_never_caps_an_early_odd_lot_sell() {
let mut snapshot = limit_test_snapshot(); let mut snapshot = limit_test_snapshot();
snapshot.minute_volume = 0; snapshot.minute_volume = 0;
snapshot.volume = 3; snapshot.volume = 3;
@@ -9460,18 +9680,19 @@ mod tests {
PriceField::Close, PriceField::Close,
) )
.with_matching_type(MatchingType::CurrentBarClose) .with_matching_type(MatchingType::CurrentBarClose)
.with_volume_capacity_mode(crate::execution_capacity::VolumeCapacityMode::SessionCapacityAudit)
.with_volume_limit(true) .with_volume_limit(true)
.with_volume_percent(0.5) .with_volume_percent(0.5)
.with_liquidity_limit(false); .with_liquidity_limit(false);
let fillable = let fillable =
broker.market_fillable_quantity(&snapshot, OrderSide::Sell, 10, 100, 100, 0, true); broker.market_fillable_quantity(&snapshot, 10, false);
assert_eq!(fillable, Ok(1)); assert_eq!(fillable, Ok(10));
} }
#[test] #[test]
fn current_bar_close_volume_limit_rejects_daily_zero_volume() { fn session_audit_does_not_infer_an_opening_suspension_from_future_zero_volume() {
let mut snapshot = limit_test_snapshot(); let mut snapshot = limit_test_snapshot();
snapshot.minute_volume = 0; snapshot.minute_volume = 0;
snapshot.volume = 0; snapshot.volume = 0;
@@ -9481,13 +9702,16 @@ mod tests {
PriceField::Close, PriceField::Close,
) )
.with_matching_type(MatchingType::CurrentBarClose) .with_matching_type(MatchingType::CurrentBarClose)
.with_volume_capacity_mode(crate::execution_capacity::VolumeCapacityMode::SessionCapacityAudit)
.with_volume_limit(true) .with_volume_limit(true)
.with_liquidity_limit(false); .with_liquidity_limit(false);
let fillable = let fillable =
broker.market_fillable_quantity(&snapshot, OrderSide::Buy, 5_000, 100, 100, 0, false); broker.market_fillable_quantity(&snapshot, 5_000, false);
assert_eq!(fillable, Err("daily no volume".to_string())); assert_eq!(fillable, Ok(5_000));
snapshot.paused = true;
assert_eq!(broker.market_fillable_quantity(&snapshot, 5_000, false), Err("paused".into()));
} }
#[test] #[test]
@@ -9508,7 +9732,7 @@ mod tests {
.with_liquidity_limit(false); .with_liquidity_limit(false);
let fillable = let fillable =
broker.market_fillable_quantity(&snapshot, OrderSide::Buy, 5_000, 100, 100, 0, false); broker.market_fillable_quantity(&snapshot, 5_000, false);
assert_eq!(fillable, Ok(5_000)); assert_eq!(fillable, Ok(5_000));
} }
+140 -10
View File
@@ -122,6 +122,7 @@ impl DailyEquityPoint {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct BacktestResult { pub struct BacktestResult {
pub capacity_audit: crate::execution_capacity::CapacityAuditSummary,
pub strategy_name: String, pub strategy_name: String,
pub equity_curve: Vec<DailyEquityPoint>, pub equity_curve: Vec<DailyEquityPoint>,
pub benchmark_series: Vec<BenchmarkSnapshot>, pub benchmark_series: Vec<BenchmarkSnapshot>,
@@ -280,6 +281,7 @@ pub struct AnalyzerRiskSummary {
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
pub struct AnalyzerReport { pub struct AnalyzerReport {
pub capacity_audit: crate::execution_capacity::CapacityAuditSummary,
pub strategy_name: String, pub strategy_name: String,
pub trades: Vec<AnalyzerTradeRow>, pub trades: Vec<AnalyzerTradeRow>,
pub positions: Vec<AnalyzerPositionRow>, pub positions: Vec<AnalyzerPositionRow>,
@@ -294,6 +296,7 @@ pub struct AnalyzerReport {
impl BacktestResult { impl BacktestResult {
pub fn analyzer_report(&self) -> AnalyzerReport { pub fn analyzer_report(&self) -> AnalyzerReport {
AnalyzerReport { AnalyzerReport {
capacity_audit: self.capacity_audit.clone(),
strategy_name: self.strategy_name.clone(), strategy_name: self.strategy_name.clone(),
trades: self trades: self
.fills .fills
@@ -2102,6 +2105,7 @@ where
.map(|(execution_date, _)| *execution_date) .map(|(execution_date, _)| *execution_date)
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let mut result = BacktestResult { let mut result = BacktestResult {
capacity_audit: self.broker.capacity_audit_summary(),
strategy_name: self.strategy.name().to_string(), strategy_name: self.strategy.name().to_string(),
benchmark_series: self benchmark_series: self
.data .data
@@ -2849,9 +2853,16 @@ where
"bar:post", "bar:post",
)?; )?;
if should_run_minute_events(&intraday_schedule_rules, &self.subscriptions) { if should_run_minute_events(&intraday_schedule_rules, &self.subscriptions)
if self.execution_quote_loader.is_some() && !self.subscriptions.is_empty() { || (self.broker.has_open_orders() && self.broker.drives_resting_quote_clock())
let mut minute_symbols = self.subscriptions.clone(); {
let unfiltered_minute_stream = self.subscriptions.is_empty();
let mut full_minute_symbols = self.subscriptions.clone();
if self.broker.drives_resting_quote_clock() {
full_minute_symbols.extend(self.broker.open_order_views().into_iter().map(|order| order.symbol));
}
if self.execution_quote_loader.is_some() && !full_minute_symbols.is_empty() {
let mut minute_symbols = full_minute_symbols.clone();
self.load_missing_execution_quotes( self.load_missing_execution_quotes(
execution_date, execution_date,
None, None,
@@ -2862,11 +2873,11 @@ where
// Keep the iterator attached to an O(1) DataSet clone. This // Keep the iterator attached to an O(1) DataSet clone. This
// preserves the immutable quote snapshot for the day while // preserves the immutable quote snapshot for the day while
// allowing lazy quote loads and broker state updates on self. // allowing lazy quote loads and broker state updates on self.
let quote_data = self.data.clone(); let mut quote_data = self.data.clone();
let mut minute_quotes = quote_data let mut minute_quotes = quote_data
.execution_quotes_iter_on_date_for_symbols( .execution_quotes_iter_on_date_for_symbols(
execution_date, execution_date,
(!self.subscriptions.is_empty()).then_some(&self.subscriptions), (!unfiltered_minute_stream).then_some(&full_minute_symbols),
) )
.peekable(); .peekable();
let requires_minute_callbacks = self.strategy.requires_minute_callbacks(); let requires_minute_callbacks = self.strategy.requires_minute_callbacks();
@@ -2893,18 +2904,26 @@ where
.into_iter() .into_iter()
.peekable(); .peekable();
let mut minute_group = Vec::new(); let mut minute_group = Vec::new();
let mut last_minute_timestamp = None;
// Merge the immutable quote stream with clock events. Equal // Merge the immutable quote stream with clock events. Equal
// timestamps form one event; scheduled callbacks run before // timestamps form one event; scheduled callbacks run before
// `on_minute` below. // `on_minute` below.
loop { loop {
let next_quote_timestamp = minute_quotes.peek().map(|quote| quote.timestamp); let next_quote_timestamp = minute_quotes.peek().map(|quote| quote.timestamp);
let next_schedule_timestamp = minute_schedule_timestamps.peek().copied(); let next_schedule_timestamp = minute_schedule_timestamps.peek().copied();
let next_expiry_timestamp = self.broker.next_day_order_expiry(execution_date)
.map(|time| execution_date.and_time(time))
.filter(|time| last_minute_timestamp.is_none_or(|last| last < *time));
let Some(minute_timestamp) = let Some(minute_timestamp) =
next_minute_event_timestamp(next_quote_timestamp, next_schedule_timestamp) next_minute_event_timestamp(
next_minute_event_timestamp(next_quote_timestamp, next_schedule_timestamp),
next_expiry_timestamp,
)
else { else {
break; break;
}; };
let minute_time = minute_timestamp.time(); let minute_time = minute_timestamp.time();
last_minute_timestamp = Some(minute_timestamp);
minute_group.clear(); minute_group.clear();
while minute_quotes while minute_quotes
.peek() .peek()
@@ -2913,7 +2932,8 @@ where
minute_group.push( minute_group.push(
minute_quotes minute_quotes
.next() .next()
.expect("peeked minute quote must be available"), .expect("peeked minute quote must be available")
.clone(),
); );
} }
let has_specific_schedule = next_schedule_timestamp == Some(minute_timestamp); let has_specific_schedule = next_schedule_timestamp == Some(minute_timestamp);
@@ -2985,7 +3005,10 @@ where
crate::strategy::StrategyDecision::default() crate::strategy::StrategyDecision::default()
}; };
if requires_minute_callbacks { if requires_minute_callbacks {
for &quote in &minute_group { for quote in &minute_group {
if !self.subscriptions.is_empty() && !self.subscriptions.contains(&quote.symbol) {
continue;
}
minute_decision.merge_from(self.strategy.on_minute( minute_decision.merge_from(self.strategy.on_minute(
&StrategyContext { &StrategyContext {
execution_date, execution_date,
@@ -3098,6 +3121,28 @@ where
ProcessEventKind::PostMinute, ProcessEventKind::PostMinute,
format!("minute:{minute_timestamp}:post"), format!("minute:{minute_timestamp}:post"),
)?; )?;
// A scheduled strategy need not subscribe to every
// minute to keep a DAY/GTC limit order alive. Fetch the
// resting symbols once, then resume the actual quote
// clock strictly after the event already processed.
let mut newly_pending = self.broker.open_order_views().into_iter()
.map(|order| order.symbol)
.filter(|symbol| !full_minute_symbols.contains(symbol))
.collect::<BTreeSet<_>>();
if !newly_pending.is_empty() && self.broker.drives_resting_quote_clock() {
full_minute_symbols.extend(newly_pending.iter().cloned());
if self.execution_quote_loader.is_some() {
self.load_missing_execution_quotes(execution_date, None, None, &mut newly_pending)?;
}
drop(minute_quotes);
quote_data = self.data.clone();
minute_quotes = quote_data.execution_quotes_iter_on_date_for_symbols(
execution_date, (!unfiltered_minute_stream).then_some(&full_minute_symbols),
).peekable();
while minute_quotes.peek().is_some_and(|quote| quote.timestamp <= minute_timestamp) {
minute_quotes.next();
}
}
} }
drop(minute_group); drop(minute_group);
drop(minute_quotes); drop(minute_quotes);
@@ -3382,6 +3427,16 @@ where
execution_date, execution_date,
); );
let daily_fill_count = result.fills.len() - day_fill_start; let daily_fill_count = result.fills.len() - day_fill_start;
for audit in self.broker.audit_completed_session_capacity(execution_date, &self.data)? {
result.capacity_audit.observe(&audit);
// Keep every audit in the durable event store, independent of
// debug phase retention. It never changes earlier executions.
result.process_events.push(ProcessEvent {
date: execution_date, kind: ProcessEventKind::SessionCapacityAudit,
order_id: None, symbol: Some(audit.symbol.clone()), side: None,
detail: serde_json::to_string(&audit).map_err(|error| BacktestError::Execution(error.to_string()))?,
});
}
let daily_order_count = result.order_events.len() - day_order_start; let daily_order_count = result.order_events.len() - day_order_start;
let execution_risk_decisions = let execution_risk_decisions =
risk_decisions_from_order_events(&result.order_events[day_order_start..]); risk_decisions_from_order_events(&result.order_events[day_order_start..]);
@@ -5847,6 +5902,76 @@ mod tests {
); );
} }
#[test]
fn scheduled_day_limit_order_loads_later_quotes_without_strategy_minute_subscription() {
struct RestingLimit { quantity: i32 }
impl Strategy for RestingLimit {
fn name(&self) -> &str { "resting-limit" }
fn requires_minute_callbacks(&self) -> bool { false }
fn schedule_rules(&self) -> Vec<ScheduleRule> {
vec![ScheduleRule::daily("open", ScheduleStage::OnDay)
.with_time_rule(ScheduleTimeRule::physical_time(9, 30))]
}
fn on_scheduled(&mut self, _: &StrategyContext<'_>, _: &ScheduleRule) -> Result<StrategyDecision, crate::BacktestError> {
Ok(StrategyDecision { order_intents: vec![OrderIntent::LimitTargetShares {
symbol: SYMBOL.into(), target_quantity: self.quantity, limit_price: 10.0, reason: "resting-entry".into(),
}], ..StrategyDecision::default() })
}
}
for scenario in 0..5 {
let partial = scenario == 1;
let closing_only = matches!(scenario,2|3);
let delayed = scenario == 4;
let date = if closing_only { d(2026, 7, 6) } else if delayed { d(2026, 6, 2) } else { d(2026, 6, 1) };
let quote = |hour, minute, price| IntradayExecutionQuote {
date, symbol: SYMBOL.into(), timestamp: date.and_hms_opt(hour, minute, 0).unwrap(),
last_price: price, bid1: price, ask1: price, bid1_volume: 10_000, ask1_volume: 10_000,
volume_delta: 10_000, amount_delta: price * 10_000.0, trading_phase: None,
};
let first = quote(9, 30, if partial { 9.8 } else { 10.2 });
let earlier = quote(9, 29, 9.0);
let unchanged = quote(9, 45, 10.2);
let later = quote(10, 0, if closing_only { 10.2 } else { 9.8 });
let last = if closing_only { quote(15, 0, if scenario == 2 { 9.8 } else { 10.2 }) } else { quote(10, 1, 9.8) };
let mut post_close = quote(15, 5, 9.7);
post_close.trading_phase = Some("post_close_fixed_price".into());
let prior = date.pred_opt().unwrap();
let markets = if delayed {vec![market(prior,10.2,10.2),market(date,10.2,9.8)]} else {vec![market(date,10.2,9.8)]};
let candidates = if delayed {vec![candidate(prior),candidate(date)]} else {vec![candidate(date)]};
let mut data = dataset_from_market_and_candidates(markets,candidates);
data.add_execution_quotes(vec![first.clone()]);
let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
.with_matching_type(if delayed {MatchingType::NextBarOpen} else {MatchingType::CurrentBarClose})
.with_volume_limit(partial).with_volume_percent(0.01).with_liquidity_limit(false).with_inactive_limit(false);
let broker = if delayed {broker} else {broker.with_intraday_execution_start_time(NaiveTime::from_hms_opt(9,30,0).unwrap())};
let requests = Arc::new(Mutex::new(Vec::new()));
let captured = Arc::clone(&requests);
let mut engine = BacktestEngine::new(data, RestingLimit { quantity: if partial { 300 } else { 100 } }, broker, BacktestConfig {
initial_cash: 100_000.0, benchmark_code: "000852.SH".into(), start_date: Some(if delayed {prior} else {date}), end_date: Some(date),
decision_lag_trading_days: usize::from(delayed), execution_price_field: if delayed {PriceField::Open} else {PriceField::Close},
}).with_execution_quote_loader(move |request| {
captured.lock().unwrap().push((request.start_time, request.end_time));
Ok(vec![earlier.clone(), first.clone(), unchanged.clone(), later.clone(), last.clone(), post_close.clone()])
});
let result = engine.run().unwrap();
if scenario == 3 {
assert!(result.fills.is_empty(), "continuous DAY order must not migrate to post-close execution");
assert_eq!(result.order_events.len(), 2, "only initial pending and expiry are state changes");
assert_eq!(result.order_events.last().unwrap().status, crate::OrderStatus::Expired);
continue;
}
assert_eq!(result.fills.len(), if partial { 3 } else { 1 }, "resting DAY order must match later actual quotes: {:?}", result.order_events);
assert_eq!(result.fills[0].execution_timestamp, if partial { date.and_hms_opt(9, 30, 0) } else if closing_only { date.and_hms_opt(15, 0, 0) } else { date.and_hms_opt(10, 0, 0) });
assert_eq!(result.fills[0].price, 9.8);
assert_eq!(result.fills[0].quantity, 100);
assert_eq!(result.fills.iter().map(|fill| fill.quantity).sum::<u32>(), if partial { 300 } else { 100 });
assert!(result.fills.iter().all(|fill| fill.execution_timestamp >= date.and_hms_opt(9, 30, 0)));
assert_eq!(requests.lock().unwrap().as_slice(), &[(None, None)]);
assert!(!result.order_events.iter().any(|order| order.status == crate::OrderStatus::Expired));
assert_eq!(result.order_events.len(), if partial { 3 } else { 2 }, "unchanged pending attempts must not emit state transitions");
}
}
#[test] #[test]
fn scheduled_event_detail_records_actual_time_only_for_timed_rules() { fn scheduled_event_detail_records_actual_time_only_for_timed_rules() {
let timed = ScheduleRule::daily("timed", ScheduleStage::OnDay) let timed = ScheduleRule::daily("timed", ScheduleStage::OnDay)
@@ -6927,6 +7052,7 @@ mod tests {
let third = d(2025, 1, 6); let third = d(2025, 1, 6);
let fourth = d(2025, 1, 7); let fourth = d(2025, 1, 7);
let broker = scheduled_next_open_broker(FidcRiskControlConfig::default()) let broker = scheduled_next_open_broker(FidcRiskControlConfig::default())
.with_volume_capacity_mode(crate::execution_capacity::VolumeCapacityMode::SessionCapacityAudit)
.with_volume_limit(true) .with_volume_limit(true)
.with_volume_percent(0.25); .with_volume_percent(0.25);
let result = run_scheduled_round_trip_next_open_with_dataset_and_broker( let result = run_scheduled_round_trip_next_open_with_dataset_and_broker(
@@ -6954,12 +7080,13 @@ mod tests {
} }
#[test] #[test]
fn next_bar_open_sell_volume_limit_rejects_execution_day_zero_volume() { fn next_bar_open_session_audit_flags_zero_volume_without_rewriting_fills() {
let first = d(2025, 1, 2); let first = d(2025, 1, 2);
let second = d(2025, 1, 3); let second = d(2025, 1, 3);
let third = d(2025, 1, 6); let third = d(2025, 1, 6);
let fourth = d(2025, 1, 7); let fourth = d(2025, 1, 7);
let broker = scheduled_next_open_broker(FidcRiskControlConfig::default()) let broker = scheduled_next_open_broker(FidcRiskControlConfig::default())
.with_volume_capacity_mode(crate::execution_capacity::VolumeCapacityMode::SessionCapacityAudit)
.with_volume_limit(true) .with_volume_limit(true)
.with_volume_percent(0.25); .with_volume_percent(0.25);
let result = run_scheduled_round_trip_next_open_with_dataset_and_broker( let result = run_scheduled_round_trip_next_open_with_dataset_and_broker(
@@ -6980,7 +7107,10 @@ mod tests {
broker, broker,
); );
assert_round_trip_sell_canceled_with_reason(&result, "daily volume limit"); assert!(result.fills.iter().any(|fill| fill.side == OrderSide::Sell && fill.date == fourth));
assert_eq!(result.capacity_audit.audit_passed, Some(false));
assert_eq!(result.capacity_audit.failed_symbol_sessions, 1);
assert!(result.process_events.iter().any(|event| event.kind == crate::ProcessEventKind::SessionCapacityAudit));
} }
#[test] #[test]
+3
View File
@@ -317,6 +317,7 @@ pub enum ProcessEventKind {
AccountDepositWithdraw, AccountDepositWithdraw,
AccountFinanceRepay, AccountFinanceRepay,
AccountManagementFee, AccountManagementFee,
SessionCapacityAudit,
} }
impl ProcessEventKind { impl ProcessEventKind {
@@ -362,6 +363,7 @@ impl ProcessEventKind {
Self::AccountDepositWithdraw => "account_deposit_withdraw", Self::AccountDepositWithdraw => "account_deposit_withdraw",
Self::AccountFinanceRepay => "account_finance_repay", Self::AccountFinanceRepay => "account_finance_repay",
Self::AccountManagementFee => "account_management_fee", Self::AccountManagementFee => "account_management_fee",
Self::SessionCapacityAudit => "session_capacity_audit",
} }
} }
@@ -393,6 +395,7 @@ impl ProcessEventKind {
| Self::AccountDepositWithdraw | Self::AccountDepositWithdraw
| Self::AccountFinanceRepay | Self::AccountFinanceRepay
| Self::AccountManagementFee | Self::AccountManagementFee
| Self::SessionCapacityAudit
| Self::Settlement | Self::Settlement
) )
} }
@@ -12,6 +12,19 @@ pub enum VolumeCapacityMode {
SessionCapacityAudit, SessionCapacityAudit,
} }
impl VolumeCapacityMode {
pub fn validate(self, enabled: bool, has_execution_observations: bool) -> Result<(), CapacityError> {
if !enabled { return Ok(()); }
match self {
Self::ExecutionObservation if !has_execution_observations => Err(CapacityError::MissingObservation),
Self::CompletedBar => Err(CapacityError::MissingCompletedBar),
_ => Ok(()),
}
}
pub fn limits_execution_quantity(self) -> bool { self != Self::SessionCapacityAudit }
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
pub enum CapacityError { pub enum CapacityError {
#[error("execution capacity ratio must be finite and in (0, 1]")] #[error("execution capacity ratio must be finite and in (0, 1]")]
@@ -26,6 +39,28 @@ pub enum CapacityError {
WrongSession, WrongSession,
#[error("execution-time capacity is missing; daily session volume cannot size an earlier fill")] #[error("execution-time capacity is missing; daily session volume cannot size an earlier fill")]
MissingObservation, MissingObservation,
#[error("completed_bar capacity requires declared bar end and availability; an undated daily total is not a completed observation")]
MissingCompletedBar,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CapacityAuditSummary {
pub mode: VolumeCapacityMode,
pub enabled: bool,
pub participation_rate: f64,
pub audited_symbol_sessions: usize,
pub failed_symbol_sessions: usize,
pub audit_passed: Option<bool>,
pub execution_time_capacity_proven: bool,
}
impl CapacityAuditSummary {
pub fn observe(&mut self, audit: &SessionCapacityAudit) {
self.audited_symbol_sessions += 1;
self.failed_symbol_sessions += usize::from(!audit.passed);
self.audit_passed = Some(self.failed_symbol_sessions == 0);
}
} }
/// Decimal semantics of the frozen JSON rate, evaluated without a float product. /// Decimal semantics of the frozen JSON rate, evaluated without a float product.
+1
View File
@@ -33,6 +33,7 @@ pub mod stock_pool_candidates;
pub mod stock_pool_indicators; pub mod stock_pool_indicators;
pub mod stock_pool_execution; pub mod stock_pool_execution;
pub mod stock_pool_index_policy; pub mod stock_pool_index_policy;
pub mod stock_pool_market_cap;
pub mod stock_pool_state; pub mod stock_pool_state;
pub mod signal_contract; pub mod signal_contract;
pub mod strategy_ai; pub mod strategy_ai;
+55 -4
View File
@@ -17,7 +17,7 @@ use crate::data::{
decision_market_cap_bn, decision_market_cap_bn,
}; };
use crate::engine::BacktestError; use crate::engine::BacktestError;
use crate::execution_capacity::{CapacityError, ParticipationRate}; use crate::execution_capacity::{CapacityError, ParticipationRate, VolumeCapacityMode};
use crate::events::{OrderSide, ProcessEvent, ProcessEventKind}; use crate::events::{OrderSide, ProcessEvent, ProcessEventKind};
use crate::fixed_point::FixedMoney; use crate::fixed_point::FixedMoney;
use crate::futures::{ use crate::futures::{
@@ -689,6 +689,7 @@ pub struct PlatformExprStrategyConfig {
pub rebalance_cash_mode: RebalanceCashMode, pub rebalance_cash_mode: RebalanceCashMode,
pub sell_then_buy_delay_slippage_rate: f64, pub sell_then_buy_delay_slippage_rate: f64,
pub risk_config: FidcRiskControlConfig, pub risk_config: FidcRiskControlConfig,
pub volume_capacity_mode: VolumeCapacityMode,
pub slippage_model: SlippageModel, pub slippage_model: SlippageModel,
pub matching_type: MatchingType, pub matching_type: MatchingType,
pub quote_quantity_limit: bool, pub quote_quantity_limit: bool,
@@ -777,6 +778,7 @@ impl PlatformExprStrategyConfig {
rebalance_cash_mode: RebalanceCashMode::default(), rebalance_cash_mode: RebalanceCashMode::default(),
sell_then_buy_delay_slippage_rate: 0.0, sell_then_buy_delay_slippage_rate: 0.0,
risk_config: FidcRiskControlConfig::default(), risk_config: FidcRiskControlConfig::default(),
volume_capacity_mode: VolumeCapacityMode::ExecutionObservation,
slippage_model: SlippageModel::None, slippage_model: SlippageModel::None,
matching_type: MatchingType::CurrentBarClose, matching_type: MatchingType::CurrentBarClose,
quote_quantity_limit: true, quote_quantity_limit: true,
@@ -1378,6 +1380,9 @@ enum RuntimeHelperResolution {
} }
pub struct PlatformExprStrategy { pub struct PlatformExprStrategy {
// Internal service boundary, never a strategy-spec/risk switch. A planner
// returns intentions; only the broker/matcher can establish actual capacity.
intent_planning_only: bool,
protection_fill_count: usize, protection_fill_count: usize,
protection_last_buys: BTreeMap<String, NaiveDate>, protection_last_buys: BTreeMap<String, NaiveDate>,
protection_last_sells: BTreeMap<String, NaiveDate>, protection_last_sells: BTreeMap<String, NaiveDate>,
@@ -1498,6 +1503,9 @@ fn completed_session_factor_date(
} }
impl PlatformExprStrategy { impl PlatformExprStrategy {
pub fn new_intent_planner(config: PlatformExprStrategyConfig) -> Self {
Self { intent_planning_only: true, ..Self::new(config) }
}
pub fn portfolio_loss_state(&self) -> Option<&PortfolioLossState> { pub fn portfolio_loss_state(&self) -> Option<&PortfolioLossState> {
self.portfolio_loss_state.as_ref() self.portfolio_loss_state.as_ref()
} }
@@ -1799,6 +1807,7 @@ impl PlatformExprStrategy {
.map(PlatformPortfolioDrawdownController::new); .map(PlatformPortfolioDrawdownController::new);
Self { Self {
volume_rate: ParticipationRate::new(config.risk_config.trading_constraints.volume_percent), volume_rate: ParticipationRate::new(config.risk_config.trading_constraints.volume_percent),
intent_planning_only: false,
config, config,
engine, engine,
protection_fill_count: 0, protection_fill_count: 0,
@@ -3155,10 +3164,16 @@ impl PlatformExprStrategy {
allow_odd_lot_sell: bool, allow_odd_lot_sell: bool,
current_fill_quantity: u32, current_fill_quantity: u32,
execution_state: &ProjectedExecutionState, execution_state: &ProjectedExecutionState,
future_execution: bool,
) -> Result<Option<u32>, BacktestError> { ) -> Result<Option<u32>, BacktestError> {
if requested_qty == 0 { if requested_qty == 0 {
return Ok(Some(0)); return Ok(Some(0));
} }
if future_execution {
// A decision-day estimate cannot use tomorrow's liquidity to
// change the orders created today.
return Ok(Some(requested_qty));
}
let constraints = self.config.risk_config.trading_constraints; let constraints = self.config.risk_config.trading_constraints;
let mut max_fill = requested_qty; let mut max_fill = requested_qty;
@@ -3201,11 +3216,14 @@ impl PlatformExprStrategy {
} }
} }
if constraints.volume_limit_enabled { if constraints.volume_limit_enabled && self.config.volume_capacity_mode.limits_execution_quantity() {
let volume_basis = match quote { let volume_basis = match quote {
Some(quote) => quote.volume_delta, Some(quote) => quote.volume_delta,
None if market.minute_volume > 0 => market.minute_volume, // Preserve the intent budget, without inventing a fillable
None => market.volume, // volume from a daily total. The receiving paper/live service
// still applies its unchanged execution risk to actual quotes.
None if self.intent_planning_only => return Ok(Some(max_fill)),
None => return Err(BacktestError::Execution(CapacityError::MissingObservation.to_string())),
}; };
if volume_basis == 0 { if volume_basis == 0 {
return Ok(None); return Ok(None);
@@ -3332,6 +3350,7 @@ impl PlatformExprStrategy {
allow_odd_lot_sell, allow_odd_lot_sell,
filled_qty, filled_qty,
execution_state, execution_state,
Self::defer_projection_execution_risk(ctx, date),
)? )?
.unwrap_or(0); .unwrap_or(0);
if available_qty == 0 { if available_qty == 0 {
@@ -3520,6 +3539,7 @@ impl PlatformExprStrategy {
sellable_qty >= current_qty, sellable_qty >= current_qty,
0, 0,
execution_state, execution_state,
Self::defer_projection_execution_risk(ctx, date),
)?.filter(|quantity| *quantity > 0) )?.filter(|quantity| *quantity > 0)
{ {
fill = Some(ProjectedExecutionFill { fill = Some(ProjectedExecutionFill {
@@ -4152,6 +4172,7 @@ impl PlatformExprStrategy {
false, false,
0, 0,
execution_state, execution_state,
Self::defer_projection_execution_risk(ctx, date),
)?.filter(|quantity| *quantity > 0) )?.filter(|quantity| *quantity > 0)
{ {
fill = Some(ProjectedExecutionFill { fill = Some(ProjectedExecutionFill {
@@ -14526,6 +14547,7 @@ mod tests {
active_datetime: None, order_events: &[], fills: &[], active_datetime: None, order_events: &[], fills: &[],
}; };
let mut cfg = PlatformExprStrategyConfig::microcap_rotation(); let mut cfg = PlatformExprStrategyConfig::microcap_rotation();
cfg.volume_capacity_mode = crate::execution_capacity::VolumeCapacityMode::SessionCapacityAudit;
cfg.signal_symbol = symbol.to_string(); cfg.signal_symbol = symbol.to_string();
cfg.max_positions = 1; cfg.max_positions = 1;
cfg.refresh_rate = 1; cfg.refresh_rate = 1;
@@ -14559,6 +14581,7 @@ mod tests {
active_datetime: None, order_events: &[], fills: &[], active_datetime: None, order_events: &[], fills: &[],
}; };
let mut cfg = PlatformExprStrategyConfig::generic(); let mut cfg = PlatformExprStrategyConfig::generic();
cfg.volume_capacity_mode = crate::execution_capacity::VolumeCapacityMode::SessionCapacityAudit;
cfg.signal_symbol = symbol.into(); cfg.signal_symbol = symbol.into();
cfg.stock_filter_expr = "close > 0".into(); cfg.stock_filter_expr = "close > 0".into();
cfg.hold_until_exit_enabled = true; cfg.hold_until_exit_enabled = true;
@@ -15069,6 +15092,7 @@ mod tests {
order_events:&[],fills:&[], order_events:&[],fills:&[],
}; };
let mut cfg=PlatformExprStrategyConfig::generic(); let mut cfg=PlatformExprStrategyConfig::generic();
cfg.risk_config.trading_constraints.volume_limit_enabled=false;
cfg.signal_symbol=symbol.into(); cfg.signal_symbol=symbol.into();
cfg.rotation_enabled=false; cfg.rotation_enabled=false;
cfg.signal_book=Some(book); cfg.signal_book=Some(book);
@@ -15174,6 +15198,7 @@ mod tests {
} }
let data = DataSet::from_components(parts.instruments, parts.market, parts.factors, parts.candidates, parts.benchmarks).unwrap(); let data = DataSet::from_components(parts.instruments, parts.market, parts.factors, parts.candidates, parts.benchmarks).unwrap();
let mut config = PlatformExprStrategyConfig::generic(); let mut config = PlatformExprStrategyConfig::generic();
config.volume_capacity_mode = crate::execution_capacity::VolumeCapacityMode::SessionCapacityAudit;
config.universe_include = Some(BTreeSet::from(["000001.SZ".to_owned()])); config.universe_include = Some(BTreeSet::from(["000001.SZ".to_owned()]));
config.signal_symbol = "000001.SZ".to_owned(); config.signal_symbol = "000001.SZ".to_owned();
config.benchmark_symbol = "000852.SH".to_owned(); config.benchmark_symbol = "000852.SH".to_owned();
@@ -15186,6 +15211,7 @@ mod tests {
let rows = Arc::new(Mutex::new(Vec::new())); let rows = Arc::new(Mutex::new(Vec::new()));
let strategy = Capture { inner: PlatformExprStrategy::new(config), first, rows: Arc::clone(&rows) }; let strategy = Capture { inner: PlatformExprStrategy::new(config), first, rows: Arc::clone(&rows) };
let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks) let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
.with_volume_capacity_mode(crate::execution_capacity::VolumeCapacityMode::SessionCapacityAudit)
.with_matching_type(MatchingType::CurrentBarClose); .with_matching_type(MatchingType::CurrentBarClose);
let mut engine = BacktestEngine::new(data, strategy, broker, BacktestConfig { let mut engine = BacktestEngine::new(data, strategy, broker, BacktestConfig {
initial_cash: 10_000.0, benchmark_code: "000852.SH".to_owned(), start_date: Some(first), initial_cash: 10_000.0, benchmark_code: "000852.SH".to_owned(), start_date: Some(first),
@@ -15418,6 +15444,7 @@ mod tests {
fills: &[], fills: &[],
}; };
let mut config = PlatformExprStrategyConfig::microcap_rotation(); let mut config = PlatformExprStrategyConfig::microcap_rotation();
config.volume_capacity_mode = crate::execution_capacity::VolumeCapacityMode::SessionCapacityAudit;
config.signal_symbol = symbol.to_string(); config.signal_symbol = symbol.to_string();
config.refresh_rate = 1; config.refresh_rate = 1;
config.max_positions = 1; config.max_positions = 1;
@@ -15669,6 +15696,7 @@ mod tests {
fills: &[], fills: &[],
}; };
let mut cfg = PlatformExprStrategyConfig::microcap_rotation(); let mut cfg = PlatformExprStrategyConfig::microcap_rotation();
cfg.volume_capacity_mode = crate::execution_capacity::VolumeCapacityMode::SessionCapacityAudit;
cfg.signal_symbol = "000001.SZ".to_string(); cfg.signal_symbol = "000001.SZ".to_string();
cfg.refresh_rate = 1; cfg.refresh_rate = 1;
cfg.max_positions = 3; cfg.max_positions = 3;
@@ -18040,6 +18068,7 @@ mod tests {
false, false,
0, 0,
&execution_state, &execution_state,
false,
).expect("valid volume capacity"), ).expect("valid volume capacity"),
Some(2_500) Some(2_500)
); );
@@ -18060,6 +18089,7 @@ mod tests {
false, false,
0, 0,
&execution_state, &execution_state,
false,
).expect("valid remaining volume capacity"), ).expect("valid remaining volume capacity"),
Some(100) Some(100)
); );
@@ -22374,6 +22404,7 @@ mod tests {
fills: &[], fills: &[],
}; };
let mut cfg = PlatformExprStrategyConfig::microcap_rotation(); let mut cfg = PlatformExprStrategyConfig::microcap_rotation();
cfg.volume_capacity_mode = crate::execution_capacity::VolumeCapacityMode::SessionCapacityAudit;
cfg.signal_symbol = symbol.to_string(); cfg.signal_symbol = symbol.to_string();
cfg.exposure_expr = "1.0".to_string(); cfg.exposure_expr = "1.0".to_string();
cfg.selection_limit_expr = "40".to_string(); cfg.selection_limit_expr = "40".to_string();
@@ -22748,6 +22779,7 @@ mod tests {
fills: &[], fills: &[],
}; };
let mut cfg = PlatformExprStrategyConfig::microcap_rotation(); let mut cfg = PlatformExprStrategyConfig::microcap_rotation();
cfg.volume_capacity_mode = crate::execution_capacity::VolumeCapacityMode::SessionCapacityAudit;
cfg.rotation_enabled = false; cfg.rotation_enabled = false;
cfg.daily_top_up_enabled = false; cfg.daily_top_up_enabled = false;
cfg.signal_symbol = symbol.to_string(); cfg.signal_symbol = symbol.to_string();
@@ -23704,6 +23736,7 @@ mod tests {
fills: &[], fills: &[],
}; };
let mut cfg = PlatformExprStrategyConfig::microcap_rotation(); let mut cfg = PlatformExprStrategyConfig::microcap_rotation();
cfg.volume_capacity_mode = crate::execution_capacity::VolumeCapacityMode::SessionCapacityAudit;
cfg.signal_symbol = symbol.to_string(); cfg.signal_symbol = symbol.to_string();
cfg.exposure_expr = "0.5".to_string(); cfg.exposure_expr = "0.5".to_string();
cfg.selection_limit_expr = "40".to_string(); cfg.selection_limit_expr = "40".to_string();
@@ -24960,6 +24993,7 @@ mod tests {
fills: &[], fills: &[],
}; };
let mut cfg = PlatformExprStrategyConfig::microcap_rotation(); let mut cfg = PlatformExprStrategyConfig::microcap_rotation();
cfg.volume_capacity_mode = crate::execution_capacity::VolumeCapacityMode::SessionCapacityAudit;
cfg.intraday_execution_time = Some(NaiveTime::from_hms_opt(9, 33, 0).unwrap()); cfg.intraday_execution_time = Some(NaiveTime::from_hms_opt(9, 33, 0).unwrap());
cfg.signal_symbol = signal.to_string(); cfg.signal_symbol = signal.to_string();
cfg.max_positions = 1; cfg.max_positions = 1;
@@ -27427,6 +27461,7 @@ mod tests {
fills: &[], fills: &[],
}; };
let mut cfg = PlatformExprStrategyConfig::microcap_rotation(); let mut cfg = PlatformExprStrategyConfig::microcap_rotation();
cfg.volume_capacity_mode = crate::execution_capacity::VolumeCapacityMode::SessionCapacityAudit;
cfg.signal_symbol = "000001.SZ".to_string(); cfg.signal_symbol = "000001.SZ".to_string();
cfg.refresh_rate = 99; cfg.refresh_rate = 99;
cfg.max_positions = 2; cfg.max_positions = 2;
@@ -27568,6 +27603,7 @@ mod tests {
fills: &[], fills: &[],
}; };
let mut cfg = PlatformExprStrategyConfig::microcap_rotation(); let mut cfg = PlatformExprStrategyConfig::microcap_rotation();
cfg.volume_capacity_mode = crate::execution_capacity::VolumeCapacityMode::SessionCapacityAudit;
cfg.signal_symbol = "000001.SZ".to_string(); cfg.signal_symbol = "000001.SZ".to_string();
cfg.refresh_rate = 99; cfg.refresh_rate = 99;
cfg.max_positions = 2; cfg.max_positions = 2;
@@ -28358,6 +28394,7 @@ mod tests {
fills: &[], fills: &[],
}; };
let mut cfg = PlatformExprStrategyConfig::microcap_rotation(); let mut cfg = PlatformExprStrategyConfig::microcap_rotation();
cfg.volume_capacity_mode = crate::execution_capacity::VolumeCapacityMode::SessionCapacityAudit;
cfg.signal_symbol = "000001.SZ".to_string(); cfg.signal_symbol = "000001.SZ".to_string();
cfg.refresh_rate = 99; cfg.refresh_rate = 99;
cfg.max_positions = 2; cfg.max_positions = 2;
@@ -28677,6 +28714,7 @@ mod tests {
.expect("dataset"); .expect("dataset");
let mut cfg = PlatformExprStrategyConfig::microcap_rotation(); let mut cfg = PlatformExprStrategyConfig::microcap_rotation();
cfg.volume_capacity_mode = crate::execution_capacity::VolumeCapacityMode::SessionCapacityAudit;
cfg.signal_symbol = "000001.SZ".to_string(); cfg.signal_symbol = "000001.SZ".to_string();
cfg.refresh_rate = 20; cfg.refresh_rate = 20;
cfg.max_positions = 2; cfg.max_positions = 2;
@@ -28725,6 +28763,7 @@ mod tests {
); );
let mut dynamic_cfg = PlatformExprStrategyConfig::microcap_rotation(); let mut dynamic_cfg = PlatformExprStrategyConfig::microcap_rotation();
dynamic_cfg.volume_capacity_mode = crate::execution_capacity::VolumeCapacityMode::SessionCapacityAudit;
dynamic_cfg.signal_symbol = "000001.SZ".to_string(); dynamic_cfg.signal_symbol = "000001.SZ".to_string();
dynamic_cfg.refresh_rate = 20; dynamic_cfg.refresh_rate = 20;
dynamic_cfg.refresh_rate_expr = "year >= 2024 ? 5 : 20".to_string(); dynamic_cfg.refresh_rate_expr = "year >= 2024 ? 5 : 20".to_string();
@@ -28750,6 +28789,7 @@ mod tests {
); );
let mut signal_dates_cfg = PlatformExprStrategyConfig::microcap_rotation(); let mut signal_dates_cfg = PlatformExprStrategyConfig::microcap_rotation();
signal_dates_cfg.volume_capacity_mode = crate::execution_capacity::VolumeCapacityMode::SessionCapacityAudit;
signal_dates_cfg.signal_symbol = "000001.SZ".to_string(); signal_dates_cfg.signal_symbol = "000001.SZ".to_string();
signal_dates_cfg.refresh_rate = 20; signal_dates_cfg.refresh_rate = 20;
signal_dates_cfg.max_positions = 2; signal_dates_cfg.max_positions = 2;
@@ -28785,6 +28825,7 @@ mod tests {
); );
let mut no_retry_cfg = PlatformExprStrategyConfig::microcap_rotation(); let mut no_retry_cfg = PlatformExprStrategyConfig::microcap_rotation();
no_retry_cfg.volume_capacity_mode = crate::execution_capacity::VolumeCapacityMode::SessionCapacityAudit;
no_retry_cfg.signal_symbol = "000001.SZ".to_string(); no_retry_cfg.signal_symbol = "000001.SZ".to_string();
no_retry_cfg.refresh_rate = 15; no_retry_cfg.refresh_rate = 15;
no_retry_cfg.max_positions = 2; no_retry_cfg.max_positions = 2;
@@ -28952,6 +28993,7 @@ mod tests {
.expect("dataset"); .expect("dataset");
let mut cfg = PlatformExprStrategyConfig::microcap_rotation(); let mut cfg = PlatformExprStrategyConfig::microcap_rotation();
cfg.volume_capacity_mode = crate::execution_capacity::VolumeCapacityMode::SessionCapacityAudit;
cfg.signal_symbol = "000001.SZ".to_string(); cfg.signal_symbol = "000001.SZ".to_string();
cfg.refresh_rate = 10; cfg.refresh_rate = 10;
cfg.max_positions = 2; cfg.max_positions = 2;
@@ -29129,6 +29171,7 @@ mod tests {
fills: &[], fills: &[],
}; };
let mut cfg = PlatformExprStrategyConfig::microcap_rotation(); let mut cfg = PlatformExprStrategyConfig::microcap_rotation();
cfg.volume_capacity_mode = crate::execution_capacity::VolumeCapacityMode::SessionCapacityAudit;
cfg.signal_symbol = "000001.SZ".to_string(); cfg.signal_symbol = "000001.SZ".to_string();
cfg.refresh_rate = 20; cfg.refresh_rate = 20;
cfg.max_positions = 2; cfg.max_positions = 2;
@@ -30342,6 +30385,7 @@ mod tests {
fills: &[], fills: &[],
}; };
let mut cfg = PlatformExprStrategyConfig::microcap_rotation(); let mut cfg = PlatformExprStrategyConfig::microcap_rotation();
cfg.volume_capacity_mode = crate::execution_capacity::VolumeCapacityMode::SessionCapacityAudit;
cfg.rotation_enabled = false; cfg.rotation_enabled = false;
cfg.hold_until_exit_enabled = true; cfg.hold_until_exit_enabled = true;
cfg.signal_symbol = symbol.to_string(); cfg.signal_symbol = symbol.to_string();
@@ -31604,6 +31648,7 @@ mod tests {
fills: &[], fills: &[],
}; };
let mut cfg = PlatformExprStrategyConfig::microcap_rotation(); let mut cfg = PlatformExprStrategyConfig::microcap_rotation();
cfg.volume_capacity_mode = crate::execution_capacity::VolumeCapacityMode::SessionCapacityAudit;
cfg.signal_symbol = "000001.SZ".to_string(); cfg.signal_symbol = "000001.SZ".to_string();
cfg.refresh_rate = 99; cfg.refresh_rate = 99;
cfg.max_positions = 2; cfg.max_positions = 2;
@@ -31757,6 +31802,7 @@ mod tests {
fills: &[], fills: &[],
}; };
let mut cfg = PlatformExprStrategyConfig::microcap_rotation(); let mut cfg = PlatformExprStrategyConfig::microcap_rotation();
cfg.volume_capacity_mode = crate::execution_capacity::VolumeCapacityMode::SessionCapacityAudit;
cfg.signal_symbol = "000001.SZ".to_string(); cfg.signal_symbol = "000001.SZ".to_string();
cfg.refresh_rate = 99; cfg.refresh_rate = 99;
cfg.max_positions = 2; cfg.max_positions = 2;
@@ -31921,6 +31967,7 @@ mod tests {
fills: &[], fills: &[],
}; };
let mut cfg = PlatformExprStrategyConfig::microcap_rotation(); let mut cfg = PlatformExprStrategyConfig::microcap_rotation();
cfg.volume_capacity_mode = crate::execution_capacity::VolumeCapacityMode::SessionCapacityAudit;
cfg.signal_symbol = "000001.SZ".to_string(); cfg.signal_symbol = "000001.SZ".to_string();
cfg.refresh_rate = 99; cfg.refresh_rate = 99;
cfg.max_positions = 2; cfg.max_positions = 2;
@@ -33942,6 +33989,7 @@ mod tests {
fills: &[], fills: &[],
}; };
let mut cfg = PlatformExprStrategyConfig::microcap_rotation(); let mut cfg = PlatformExprStrategyConfig::microcap_rotation();
cfg.volume_capacity_mode = crate::execution_capacity::VolumeCapacityMode::SessionCapacityAudit;
cfg.signal_symbol = "000001.SZ".to_string(); cfg.signal_symbol = "000001.SZ".to_string();
cfg.refresh_rate = 99; cfg.refresh_rate = 99;
cfg.max_positions = 1; cfg.max_positions = 1;
@@ -34119,6 +34167,7 @@ mod tests {
fills: &[], fills: &[],
}; };
let mut cfg = PlatformExprStrategyConfig::microcap_rotation(); let mut cfg = PlatformExprStrategyConfig::microcap_rotation();
cfg.volume_capacity_mode = crate::execution_capacity::VolumeCapacityMode::SessionCapacityAudit;
cfg.signal_symbol = "000001.SZ".to_string(); cfg.signal_symbol = "000001.SZ".to_string();
cfg.refresh_rate = 99; cfg.refresh_rate = 99;
cfg.max_positions = 1; cfg.max_positions = 1;
@@ -34161,6 +34210,7 @@ mod tests {
); );
let mut filtered_cfg = PlatformExprStrategyConfig::microcap_rotation(); let mut filtered_cfg = PlatformExprStrategyConfig::microcap_rotation();
filtered_cfg.volume_capacity_mode = crate::execution_capacity::VolumeCapacityMode::SessionCapacityAudit;
filtered_cfg.signal_symbol = "000001.SZ".to_string(); filtered_cfg.signal_symbol = "000001.SZ".to_string();
filtered_cfg.refresh_rate = 99; filtered_cfg.refresh_rate = 99;
filtered_cfg.max_positions = 1; filtered_cfg.max_positions = 1;
@@ -36484,6 +36534,7 @@ mod tests {
fills: &[], fills: &[],
}; };
let mut cfg = PlatformExprStrategyConfig::microcap_rotation(); let mut cfg = PlatformExprStrategyConfig::microcap_rotation();
cfg.volume_capacity_mode = crate::execution_capacity::VolumeCapacityMode::SessionCapacityAudit;
cfg.signal_symbol = "000001.SZ".to_string(); cfg.signal_symbol = "000001.SZ".to_string();
cfg.rotation_enabled = false; cfg.rotation_enabled = false;
cfg.benchmark_short_ma_days = 1; cfg.benchmark_short_ma_days = 1;
@@ -91,6 +91,8 @@ pub struct StrategyRebalanceSpec {
#[derive(Debug, Clone, Default, Deserialize, Serialize)] #[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct StrategyExecutionSpec { pub struct StrategyExecutionSpec {
#[serde(default, alias = "volume_capacity_mode")]
pub volume_capacity_mode: Option<crate::execution_capacity::VolumeCapacityMode>,
#[serde(default)] #[serde(default)]
pub frequency: Option<String>, pub frequency: Option<String>,
#[serde(default, alias = "matching_type")] #[serde(default, alias = "matching_type")]
@@ -164,9 +166,22 @@ pub struct StrategyExecutionSpec {
pub sell_then_buy_delay_slippage_rate: Option<f64>, pub sell_then_buy_delay_slippage_rate: Option<f64>,
} }
impl StrategyRuntimeSpec {
pub fn volume_capacity_mode(&self) -> Result<crate::execution_capacity::VolumeCapacityMode, String> {
let engine = self.engine_config.as_ref().and_then(|config| config.volume_capacity_mode);
let execution = self.execution.as_ref().and_then(|config| config.volume_capacity_mode);
if engine.zip(execution).is_some_and(|(a, b)| a != b) {
return Err("conflicting engine/execution volumeCapacityMode".into());
}
Ok(execution.or(engine).unwrap_or_default())
}
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)] #[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct StrategyEngineConfig { pub struct StrategyEngineConfig {
#[serde(default, alias = "volume_capacity_mode")]
pub volume_capacity_mode: Option<crate::execution_capacity::VolumeCapacityMode>,
#[serde(default)] #[serde(default)]
pub frequency: Option<String>, pub frequency: Option<String>,
#[serde(default, alias = "template_id")] #[serde(default, alias = "template_id")]
@@ -1822,6 +1837,7 @@ pub fn platform_expr_config_from_spec(
strategy_spec: Option<&StrategyRuntimeSpec>, strategy_spec: Option<&StrategyRuntimeSpec>,
) -> Result<PlatformExprStrategyConfig, String> { ) -> Result<PlatformExprStrategyConfig, String> {
let mut cfg = PlatformExprStrategyConfig::generic(); let mut cfg = PlatformExprStrategyConfig::generic();
cfg.volume_capacity_mode = strategy_spec.map(StrategyRuntimeSpec::volume_capacity_mode).transpose()?.unwrap_or_default();
cfg.strategy_name = strategy_id.to_string(); cfg.strategy_name = strategy_id.to_string();
if !signal_symbol.trim().is_empty() { if !signal_symbol.trim().is_empty() {
cfg.signal_symbol = signal_symbol.trim().to_string(); cfg.signal_symbol = signal_symbol.trim().to_string();
@@ -0,0 +1,108 @@
//! Configurable index-to-market-cap band. Values are CNY, not implicit yi.
use chrono::NaiveDate;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct IndexMarketCapPolicy {
pub schema_version: u32,
pub index_code: String,
pub field: String,
pub value_unit: String,
pub index_low: f64,
pub index_high: f64,
pub lower_at_low: f64,
pub lower_at_high: f64,
pub upper_at_low: f64,
pub upper_at_high: f64,
}
impl IndexMarketCapPolicy {
pub fn validate(&self) -> Result<(), String> {
if self.schema_version != 1 || self.value_unit != "CNY"
|| !matches!(self.field.as_str(), "market_cap" | "float_market_cap")
{ return Err("index_market_cap_contract_invalid".into()); }
let index = self.index_code.split_once('.').is_some_and(|(code, exchange)| {
(6..=12).contains(&code.len())
&& code.bytes().all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit())
&& matches!(exchange, "SH" | "SZ" | "CSI" | "CNI")
});
if !index { return Err("index_market_cap_index_invalid".into()); }
if [self.index_low,self.index_high,self.lower_at_low,self.lower_at_high,self.upper_at_low,self.upper_at_high]
.iter().any(|value| !value.is_finite() || *value <= 0.)
|| self.index_low >= self.index_high || self.lower_at_low > self.upper_at_low
|| self.lower_at_high > self.upper_at_high
{ return Err("index_market_cap_bounds_invalid".into()); }
Ok(())
}
pub fn band(&self, close: f64) -> Result<(f64, f64), String> {
self.validate()?;
if !close.is_finite() || close <= 0. { return Err("index_market_cap_close_invalid".into()); }
let t = (close.clamp(self.index_low,self.index_high) - self.index_low) / (self.index_high-self.index_low);
Ok((self.lower_at_low + t*(self.lower_at_high-self.lower_at_low),
self.upper_at_low + t*(self.upper_at_high-self.upper_at_low)))
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct IndexMarketCapRow { pub date: NaiveDate, pub close: f64 }
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Request {
pub policy: IndexMarketCapPolicy,
pub official_dates: Vec<NaiveDate>,
pub index_code: String,
pub closes: Vec<IndexMarketCapRow>,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct Band { pub date: NaiveDate, pub index_close: f64, pub lower: f64, pub upper: f64 }
pub fn implementation_sha256() -> String { format!("{:x}", Sha256::digest(include_bytes!("stock_pool_market_cap.rs"))) }
pub fn evaluate(input: &Request) -> Result<Vec<Band>, String> {
input.policy.validate()?;
if input.index_code != input.policy.index_code || input.official_dates.is_empty()
|| input.official_dates.len() > 4000 || input.official_dates.len() != input.closes.len()
|| input.official_dates.windows(2).any(|pair| pair[0]>=pair[1])
|| input.closes.iter().zip(&input.official_dates).any(|(row, day)| row.date != *day)
{ return Err("index_market_cap_calendar_or_identity_mismatch".into()); }
input.closes.iter().map(|row| {
let (lower,upper)=input.policy.band(row.close)?;
Ok(Band{date:row.date,index_close:row.close,lower,upper})
}).collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn policy()->IndexMarketCapPolicy {
serde_json::from_value(serde_json::json!({"schema_version":1,"index_code":"000300.SH","field":"market_cap","value_unit":"CNY",
"index_low":4000,"index_high":6000,"lower_at_low":2000000000_f64,"lower_at_high":3000000000_f64,
"upper_at_low":5000000000_f64,"upper_at_high":8000000000_f64})).unwrap()
}
#[test]
fn interpolates_declared_endpoints_and_clamps_without_business_defaults(){
assert_eq!(policy().band(3000.).unwrap(),(2e9,5e9));
assert_eq!(policy().band(5000.).unwrap(),(2.5e9,6.5e9));
assert_eq!(policy().band(7000.).unwrap(),(3e9,8e9));
let mut decreasing=policy();decreasing.lower_at_low=3e9;decreasing.lower_at_high=2e9;
assert_eq!(decreasing.band(5000.).unwrap(),(2.5e9,6.5e9));
assert!(policy().band(f64::NAN).is_err());
let mut invalid=policy();invalid.value_unit="亿元".into();assert!(invalid.validate().is_err());
invalid=policy();invalid.index_high=4000.;assert!(invalid.validate().is_err());
invalid=policy();invalid.lower_at_low=9e9;assert!(invalid.validate().is_err());
}
#[test]
fn missing_duplicate_or_mismatched_index_inputs_do_not_shrink_the_calendar(){
let day=NaiveDate::from_ymd_opt(2026,9,11).unwrap();
let mut input=Request{policy:policy(),official_dates:vec![day],index_code:"000300.SH".into(),closes:vec![IndexMarketCapRow{date:day,close:5000.}]};
assert_eq!(evaluate(&input).unwrap()[0].lower,2.5e9);
input.official_dates.push(day);assert!(evaluate(&input).is_err());input.official_dates.pop();
input.index_code="932000.CSI".into();assert!(evaluate(&input).is_err());
input.index_code="000300.SH".into();input.closes.clear();assert!(evaluate(&input).is_err());
}
}
@@ -107,6 +107,7 @@ fn action(quantity: &str, when: &str) -> PlatformTradeAction {
} }
fn run(policy: AutomaticTradeProtection) -> fidc_core::BacktestResult { fn run(policy: AutomaticTradeProtection) -> fidc_core::BacktestResult {
let mut config = PlatformExprStrategyConfig::generic(); let mut config = PlatformExprStrategyConfig::generic();
config.volume_capacity_mode = fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit;
config.signal_symbol = "000001.SZ".into(); config.signal_symbol = "000001.SZ".into();
config.benchmark_symbol = "000300.SH".into(); config.benchmark_symbol = "000300.SH".into();
config.rotation_enabled = false; config.rotation_enabled = false;
@@ -119,7 +120,7 @@ fn run(policy: AutomaticTradeProtection) -> fidc_core::BacktestResult {
action("-100", "decision_date >= \"2026-09-14\""), action("-100", "decision_date >= \"2026-09-14\""),
]; ];
config.matching_type = MatchingType::CurrentBarClose; config.matching_type = MatchingType::CurrentBarClose;
let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks) let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit)
.with_matching_type(MatchingType::CurrentBarClose); .with_matching_type(MatchingType::CurrentBarClose);
BacktestEngine::new( BacktestEngine::new(
data(), data(),
@@ -276,6 +277,7 @@ fn locked_holding_keeps_its_slot_even_when_cash_can_buy_the_next_candidate() {
) )
.unwrap(); .unwrap();
let mut config = PlatformExprStrategyConfig::generic(); let mut config = PlatformExprStrategyConfig::generic();
config.volume_capacity_mode = fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit;
config.signal_symbol = "000001.SZ".into(); config.signal_symbol = "000001.SZ".into();
config.benchmark_symbol = "000300.SH".into(); config.benchmark_symbol = "000300.SH".into();
config.strategy_name = "protection_test".into(); config.strategy_name = "protection_test".into();
@@ -294,7 +296,7 @@ fn locked_holding_keeps_its_slot_even_when_cash_can_buy_the_next_candidate() {
}], }],
..Default::default() ..Default::default()
}; };
let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks) let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit)
.with_matching_type(MatchingType::CurrentBarClose); .with_matching_type(MatchingType::CurrentBarClose);
let result = BacktestEngine::new( let result = BacktestEngine::new(
dataset, dataset,
+2 -2
View File
@@ -394,7 +394,7 @@ fn engine_reinvests_dividend_receivable_in_round_lots() {
.with_minimum_commission(0.0), .with_minimum_commission(0.0),
ChinaEquityRuleHooks::default(), ChinaEquityRuleHooks::default(),
PriceField::Open, PriceField::Open,
), ).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit),
BacktestConfig { BacktestConfig {
initial_cash: 11_008.0, initial_cash: 11_008.0,
benchmark_code: "000300.SH".to_string(), benchmark_code: "000300.SH".to_string(),
@@ -475,7 +475,7 @@ fn engine_settles_same_day_dividend_after_split_for_aiquant_semantics() {
ChinaAShareCostModel::default(), ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(), ChinaEquityRuleHooks::default(),
PriceField::Open, PriceField::Open,
), ).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit),
BacktestConfig { BacktestConfig {
initial_cash: 11_008.0, initial_cash: 11_008.0,
benchmark_code: "000300.SH".to_string(), benchmark_code: "000300.SH".to_string(),
@@ -170,7 +170,7 @@ fn engine_uses_preplanned_decision_symbols_without_recomputing_strategy_plan() {
ChinaAShareCostModel::default(), ChinaAShareCostModel::default(),
ChinaEquityRuleHooks, ChinaEquityRuleHooks,
PriceField::Close, PriceField::Close,
) ).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit)
.with_matching_type(MatchingType::CurrentBarClose); .with_matching_type(MatchingType::CurrentBarClose);
let config = BacktestConfig { let config = BacktestConfig {
initial_cash: 10_000.0, initial_cash: 10_000.0,
@@ -235,7 +235,7 @@ fn engine_skips_decision_quote_symbol_plan_without_loader() {
ChinaAShareCostModel::default(), ChinaAShareCostModel::default(),
ChinaEquityRuleHooks, ChinaEquityRuleHooks,
PriceField::Close, PriceField::Close,
) ).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit)
.with_matching_type(MatchingType::CurrentBarClose); .with_matching_type(MatchingType::CurrentBarClose);
let config = BacktestConfig { let config = BacktestConfig {
initial_cash: 10_000.0, initial_cash: 10_000.0,
@@ -391,7 +391,7 @@ fn engine_preloads_declared_decision_quotes_for_current_positions() {
ChinaAShareCostModel::default(), ChinaAShareCostModel::default(),
ChinaEquityRuleHooks, ChinaEquityRuleHooks,
PriceField::Last, PriceField::Last,
) ).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit)
.with_matching_type(MatchingType::MinuteLast) .with_matching_type(MatchingType::MinuteLast)
.with_intraday_execution_start_time(t(10, 40, 0)); .with_intraday_execution_start_time(t(10, 40, 0));
let config = BacktestConfig { let config = BacktestConfig {
@@ -590,7 +590,7 @@ fn engine_reuses_preloaded_decision_quotes_without_loader_call() {
ChinaAShareCostModel::default(), ChinaAShareCostModel::default(),
ChinaEquityRuleHooks, ChinaEquityRuleHooks,
PriceField::Last, PriceField::Last,
) ).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit)
.with_matching_type(MatchingType::MinuteLast) .with_matching_type(MatchingType::MinuteLast)
.with_intraday_execution_start_time(t(10, 40, 0)); .with_intraday_execution_start_time(t(10, 40, 0));
let config = BacktestConfig { let config = BacktestConfig {
@@ -796,7 +796,7 @@ fn engine_loads_distinct_decision_quote_times_on_same_day() {
ChinaAShareCostModel::default(), ChinaAShareCostModel::default(),
ChinaEquityRuleHooks, ChinaEquityRuleHooks,
PriceField::Last, PriceField::Last,
) ).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit)
.with_matching_type(MatchingType::MinuteLast) .with_matching_type(MatchingType::MinuteLast)
.with_intraday_execution_start_time(t(10, 40, 0)); .with_intraday_execution_start_time(t(10, 40, 0));
let config = BacktestConfig { let config = BacktestConfig {
+2 -2
View File
@@ -295,7 +295,7 @@ fn engine_keeps_unresolved_delisted_position_without_fabricating_a_fill() {
ChinaAShareCostModel::default(), ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(), ChinaEquityRuleHooks::default(),
PriceField::Open, PriceField::Open,
); ).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit);
let mut engine = BacktestEngine::new( let mut engine = BacktestEngine::new(
data, data,
BuyThenHoldStrategy, BuyThenHoldStrategy,
@@ -548,7 +548,7 @@ fn engine_applies_successor_conversion_before_unresolved_delisting_audit() {
ChinaAShareCostModel::default(), ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(), ChinaEquityRuleHooks::default(),
PriceField::Open, PriceField::Open,
); ).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit);
let mut engine = BacktestEngine::new( let mut engine = BacktestEngine::new(
data, data,
BuyThenHoldStrategy, BuyThenHoldStrategy,
+30 -29
View File
@@ -1219,7 +1219,7 @@ fn engine_runs_strategy_hooks_in_daily_order() {
ChinaAShareCostModel::default(), ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(), ChinaEquityRuleHooks::default(),
PriceField::Open, PriceField::Open,
); ).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit);
let mut engine = BacktestEngine::new( let mut engine = BacktestEngine::new(
data, data,
strategy, strategy,
@@ -1260,7 +1260,7 @@ fn engine_runs_strategy_hooks_in_daily_order() {
ChinaAShareCostModel::default(), ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(), ChinaEquityRuleHooks::default(),
PriceField::Open, PriceField::Open,
); ).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit);
let mut compact_engine = BacktestEngine::new( let mut compact_engine = BacktestEngine::new(
compact_data, compact_data,
compact_strategy, compact_strategy,
@@ -1401,7 +1401,7 @@ fn engine_executes_open_auction_decisions_before_on_day() {
ChinaAShareCostModel::default(), ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(), ChinaEquityRuleHooks::default(),
PriceField::DayOpen, PriceField::DayOpen,
); ).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit);
let mut engine = BacktestEngine::new( let mut engine = BacktestEngine::new(
data, data,
strategy, strategy,
@@ -1497,7 +1497,7 @@ fn engine_executes_futures_order_intents_against_future_account() {
ChinaAShareCostModel::default(), ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(), ChinaEquityRuleHooks::default(),
PriceField::Open, PriceField::Open,
); ).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit);
let mut engine = BacktestEngine::new( let mut engine = BacktestEngine::new(
data, data,
FuturesOrderStrategy, FuturesOrderStrategy,
@@ -1569,7 +1569,7 @@ fn platform_runtime_actions_execute_generic_futures_open_and_close() {
ChinaAShareCostModel::default(), ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(), ChinaEquityRuleHooks::default(),
PriceField::Open, PriceField::Open,
); ).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit);
let mut engine = BacktestEngine::new( let mut engine = BacktestEngine::new(
two_day_futures_data(), two_day_futures_data(),
PlatformExprStrategy::new(cfg), PlatformExprStrategy::new(cfg),
@@ -1609,7 +1609,7 @@ fn engine_settles_configured_futures_expiration_at_settlement() {
ChinaAShareCostModel::default(), ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(), ChinaEquityRuleHooks::default(),
PriceField::Open, PriceField::Open,
); ).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit);
let mut engine = BacktestEngine::new( let mut engine = BacktestEngine::new(
single_day_anchor_data(date), single_day_anchor_data(date),
FuturesOrderStrategy, FuturesOrderStrategy,
@@ -1657,7 +1657,7 @@ fn engine_aggregates_futures_account_into_nav_and_metrics() {
ChinaAShareCostModel::default(), ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(), ChinaEquityRuleHooks::default(),
PriceField::Open, PriceField::Open,
); ).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit);
let mut engine = BacktestEngine::new( let mut engine = BacktestEngine::new(
single_day_anchor_data(date), single_day_anchor_data(date),
FuturesOrderStrategy, FuturesOrderStrategy,
@@ -1700,7 +1700,7 @@ fn engine_matches_pending_futures_limit_order_with_data_driven_costs() {
ChinaAShareCostModel::default(), ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(), ChinaEquityRuleHooks::default(),
PriceField::Open, PriceField::Open,
); ).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit);
let mut engine = BacktestEngine::new( let mut engine = BacktestEngine::new(
two_day_futures_data(), two_day_futures_data(),
FuturesLimitOrderStrategy, FuturesLimitOrderStrategy,
@@ -1753,7 +1753,7 @@ fn engine_reports_pending_futures_order_at_backtest_boundary() {
ChinaAShareCostModel::default(), ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(), ChinaEquityRuleHooks::default(),
PriceField::Open, PriceField::Open,
); ).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit);
let mut engine = BacktestEngine::new( let mut engine = BacktestEngine::new(
two_day_futures_data(), two_day_futures_data(),
FuturesLimitOrderStrategy, FuturesLimitOrderStrategy,
@@ -1805,7 +1805,7 @@ fn engine_rejects_futures_limit_orders_not_aligned_to_tick() {
ChinaAShareCostModel::default(), ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(), ChinaEquityRuleHooks::default(),
PriceField::Open, PriceField::Open,
); ).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit);
let mut engine = BacktestEngine::new( let mut engine = BacktestEngine::new(
two_day_futures_data(), two_day_futures_data(),
FuturesInvalidTickLimitStrategy, FuturesInvalidTickLimitStrategy,
@@ -1836,7 +1836,7 @@ fn engine_allows_disabling_futures_limit_tick_validation() {
ChinaAShareCostModel::default(), ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(), ChinaEquityRuleHooks::default(),
PriceField::Open, PriceField::Open,
); ).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit);
let mut engine = BacktestEngine::new( let mut engine = BacktestEngine::new(
two_day_futures_data(), two_day_futures_data(),
FuturesInvalidTickLimitStrategy, FuturesInvalidTickLimitStrategy,
@@ -1883,7 +1883,7 @@ fn engine_rejects_futures_limit_orders_outside_price_limits() {
ChinaAShareCostModel::default(), ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(), ChinaEquityRuleHooks::default(),
PriceField::Open, PriceField::Open,
); ).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit);
let mut engine = BacktestEngine::new( let mut engine = BacktestEngine::new(
two_day_futures_data(), two_day_futures_data(),
FuturesAboveUpperLimitStrategy, FuturesAboveUpperLimitStrategy,
@@ -1958,7 +1958,7 @@ fn engine_rejects_futures_orders_when_trading_phase_is_closed() {
ChinaAShareCostModel::default(), ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(), ChinaEquityRuleHooks::default(),
PriceField::Open, PriceField::Open,
); ).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit);
let mut engine = BacktestEngine::new( let mut engine = BacktestEngine::new(
data, data,
FuturesClosedPhaseOrderStrategy, FuturesClosedPhaseOrderStrategy,
@@ -2066,7 +2066,7 @@ fn engine_sweeps_futures_order_book_depth_when_available() {
ChinaAShareCostModel::default(), ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(), ChinaEquityRuleHooks::default(),
PriceField::Last, PriceField::Last,
) ).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit)
.with_matching_type(MatchingType::MinuteBestCounterparty); .with_matching_type(MatchingType::MinuteBestCounterparty);
let mut engine = BacktestEngine::new( let mut engine = BacktestEngine::new(
data, data,
@@ -2111,7 +2111,7 @@ fn strategy_context_exposes_advanced_data_helpers() {
ChinaAShareCostModel::default(), ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(), ChinaEquityRuleHooks::default(),
PriceField::Open, PriceField::Open,
); ).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit);
let mut engine = BacktestEngine::new( let mut engine = BacktestEngine::new(
two_day_futures_data(), two_day_futures_data(),
AdvancedDataApiProbeStrategy { AdvancedDataApiProbeStrategy {
@@ -2265,7 +2265,7 @@ fn engine_runs_minute_hooks_and_executes_minute_orders() {
ChinaAShareCostModel::default(), ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(), ChinaEquityRuleHooks::default(),
PriceField::Last, PriceField::Last,
); ).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit);
let loader_requests = Arc::new(Mutex::new(Vec::<ExecutionQuoteRequest>::new())); let loader_requests = Arc::new(Mutex::new(Vec::<ExecutionQuoteRequest>::new()));
let loader_requests_for_callback = Arc::clone(&loader_requests); let loader_requests_for_callback = Arc::clone(&loader_requests);
let mut engine = BacktestEngine::new( let mut engine = BacktestEngine::new(
@@ -2381,7 +2381,7 @@ fn engine_skips_empty_platform_style_minute_callbacks_between_schedule_times() {
ChinaAShareCostModel::default(), ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(), ChinaEquityRuleHooks::default(),
PriceField::Last, PriceField::Last,
); ).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit);
let mut engine = BacktestEngine::new( let mut engine = BacktestEngine::new(
data, data,
strategy, strategy,
@@ -2579,7 +2579,7 @@ fn strategy_context_exposes_engine_native_data_helpers() {
ChinaAShareCostModel::default(), ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(), ChinaEquityRuleHooks::default(),
PriceField::Open, PriceField::Open,
); ).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit);
let mut engine = BacktestEngine::new( let mut engine = BacktestEngine::new(
data, data,
strategy, strategy,
@@ -2683,7 +2683,7 @@ fn strategy_context_exposes_final_order_runtime_view() {
ChinaAShareCostModel::default(), ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(), ChinaEquityRuleHooks::default(),
PriceField::Close, PriceField::Close,
); ).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit);
let mut engine = BacktestEngine::new( let mut engine = BacktestEngine::new(
data, data,
strategy, strategy,
@@ -2959,7 +2959,7 @@ fn engine_applies_account_cash_flow_and_financing_intents() {
ChinaAShareCostModel::default(), ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(), ChinaEquityRuleHooks::default(),
PriceField::Close, PriceField::Close,
); ).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit);
let mut engine = BacktestEngine::new( let mut engine = BacktestEngine::new(
data, data,
AccountFlowStrategy, AccountFlowStrategy,
@@ -3157,7 +3157,7 @@ fn engine_expires_pending_day_limit_orders_at_market_close() {
ChinaAShareCostModel::default(), ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(), ChinaEquityRuleHooks::default(),
PriceField::Open, PriceField::Open,
); ).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit);
let strategy = LimitCarryStrategy { issued: false }; let strategy = LimitCarryStrategy { issued: false };
let mut engine = BacktestEngine::new( let mut engine = BacktestEngine::new(
data, data,
@@ -3394,7 +3394,7 @@ fn engine_runs_scheduled_rules_for_daily_weekly_and_monthly_triggers() {
ChinaAShareCostModel::default(), ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(), ChinaEquityRuleHooks::default(),
PriceField::DayOpen, PriceField::DayOpen,
); ).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit);
let mut engine = BacktestEngine::new( let mut engine = BacktestEngine::new(
data, data,
strategy, strategy,
@@ -3649,7 +3649,7 @@ fn engine_dispatches_process_events_to_external_bus_listeners() {
ChinaAShareCostModel::default(), ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(), ChinaEquityRuleHooks::default(),
PriceField::DayOpen, PriceField::DayOpen,
); ).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit);
let mut engine = BacktestEngine::new( let mut engine = BacktestEngine::new(
data, data,
strategy, strategy,
@@ -3741,7 +3741,7 @@ fn engine_installs_process_mods_on_event_bus() {
ChinaAShareCostModel::default(), ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(), ChinaEquityRuleHooks::default(),
PriceField::DayOpen, PriceField::DayOpen,
); ).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit);
let mut engine = BacktestEngine::new( let mut engine = BacktestEngine::new(
data, data,
HookProbeStrategy { HookProbeStrategy {
@@ -3778,7 +3778,7 @@ fn engine_installs_enabled_process_mods_from_loader() {
ChinaAShareCostModel::default(), ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(), ChinaEquityRuleHooks::default(),
PriceField::DayOpen, PriceField::DayOpen,
); ).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit);
let mut engine = BacktestEngine::new( let mut engine = BacktestEngine::new(
data, data,
HookProbeStrategy { HookProbeStrategy {
@@ -3981,7 +3981,7 @@ fn engine_applies_dynamic_universe_and_subscription_directives() {
ChinaAShareCostModel::default(), ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(), ChinaEquityRuleHooks::default(),
PriceField::Open, PriceField::Open,
); ).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit);
let mut engine = BacktestEngine::new( let mut engine = BacktestEngine::new(
data, data,
strategy, strategy,
@@ -4105,7 +4105,7 @@ fn engine_exposes_current_process_context_to_strategies() {
ChinaAShareCostModel::default(), ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(), ChinaEquityRuleHooks::default(),
PriceField::Last, PriceField::Last,
); ).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit);
let mut engine = BacktestEngine::new( let mut engine = BacktestEngine::new(
data, data,
strategy, strategy,
@@ -4215,7 +4215,7 @@ fn engine_rejects_an_unexplained_missing_holding_close() {
ChinaAShareCostModel::default(), ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(), ChinaEquityRuleHooks::default(),
PriceField::Open, PriceField::Open,
); ).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit);
let mut engine = BacktestEngine::new( let mut engine = BacktestEngine::new(
data, data,
BuyMissingRowThenHoldStrategy, BuyMissingRowThenHoldStrategy,
@@ -4290,6 +4290,7 @@ fn platform_strategy_cannot_hide_missing_valuation_by_skipping_stop_take() {
) )
.expect("dataset"); .expect("dataset");
let mut config = PlatformExprStrategyConfig::microcap_rotation(); let mut config = PlatformExprStrategyConfig::microcap_rotation();
config.volume_capacity_mode = fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit;
config.strategy_name = "missing-row-platform-risk".to_string(); config.strategy_name = "missing-row-platform-risk".to_string();
config.benchmark_symbol = "000300.SH".to_string(); config.benchmark_symbol = "000300.SH".to_string();
config.signal_symbol = "000001.SZ".to_string(); config.signal_symbol = "000001.SZ".to_string();
@@ -4314,7 +4315,7 @@ fn platform_strategy_cannot_hide_missing_valuation_by_skipping_stop_take() {
ChinaAShareCostModel::default(), ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(), ChinaEquityRuleHooks::default(),
PriceField::Open, PriceField::Open,
); ).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit);
let mut engine = BacktestEngine::new( let mut engine = BacktestEngine::new(
data, data,
PlatformExprStrategy::new(config), PlatformExprStrategy::new(config),
+53 -49
View File
@@ -202,6 +202,7 @@ fn execute_single_value_order(
ChinaEquityRuleHooks::default(), ChinaEquityRuleHooks::default(),
PriceField::Open, PriceField::Open,
) )
.with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit)
.with_strict_value_budget(true); .with_strict_value_budget(true);
let report = broker let report = broker
.execute( .execute(
@@ -392,7 +393,7 @@ fn broker_executes_explicit_order_value_buy() {
ChinaAShareCostModel::default(), ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(), ChinaEquityRuleHooks::default(),
PriceField::Open, PriceField::Open,
); ).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit);
let report = broker let report = broker
.execute( .execute(
@@ -682,7 +683,7 @@ fn broker_executes_order_shares_and_order_lots() {
ChinaAShareCostModel::default(), ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(), ChinaEquityRuleHooks::default(),
PriceField::Open, PriceField::Open,
); ).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit);
let report = broker let report = broker
.execute( .execute(
@@ -802,7 +803,7 @@ fn broker_executes_target_shares_like_order_to() {
let broker = BrokerSimulator::new( let broker = BrokerSimulator::new(
ChinaAShareCostModel::default(), ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(), ChinaEquityRuleHooks::default(),
); ).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit);
let report = broker let report = broker
.execute( .execute(
@@ -978,7 +979,7 @@ fn broker_executes_target_portfolio_smart_with_custom_prices() {
let broker = BrokerSimulator::new( let broker = BrokerSimulator::new(
ChinaAShareCostModel::default(), ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(), ChinaEquityRuleHooks::default(),
); ).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit);
let report = broker let report = broker
.execute( .execute(
@@ -1252,7 +1253,7 @@ fn broker_executes_order_percent_and_target_percent() {
ChinaAShareCostModel::default(), ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(), ChinaEquityRuleHooks::default(),
PriceField::Open, PriceField::Open,
); ).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit);
let mut percent_portfolio = PortfolioState::new(1_000_000.0); let mut percent_portfolio = PortfolioState::new(1_000_000.0);
let percent_report = broker let percent_report = broker
@@ -1380,7 +1381,7 @@ fn broker_uses_day_open_price_for_open_auction_matching() {
ChinaAShareCostModel::default(), ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(), ChinaEquityRuleHooks::default(),
PriceField::DayOpen, PriceField::DayOpen,
) ).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit)
.with_slippage_model(SlippageModel::PriceRatio(0.05)); .with_slippage_model(SlippageModel::PriceRatio(0.05));
let report = broker let report = broker
@@ -1414,7 +1415,7 @@ fn broker_uses_day_open_price_for_open_auction_matching() {
} }
#[test] #[test]
fn broker_open_auction_uses_auction_volume_without_quote_liquidity() { fn broker_rejects_unproven_auction_capacity_in_a_daily_snapshot() {
let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap(); let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap();
let data = DataSet::from_components( let data = DataSet::from_components(
vec![Instrument { vec![Instrument {
@@ -1511,11 +1512,10 @@ fn broker_open_auction_uses_auction_volume_without_quote_liquidity() {
risk_decisions: Vec::new(), risk_decisions: Vec::new(),
}, },
) )
.expect("broker execution"); .expect_err("a timestamped daily total is not proof of auction volume");
assert_eq!(report.fill_events.len(), 1); assert!(report.to_string().contains("execution-time capacity is missing"));
assert_eq!(report.fill_events[0].quantity, 200); assert_eq!(portfolio.cash(), 1_000_000.0);
assert_eq!(report.fill_events[0].price, 9.8);
} }
#[test] #[test]
@@ -1710,7 +1710,7 @@ fn broker_applies_price_ratio_slippage_on_snapshot_fills() {
ChinaAShareCostModel::default(), ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(), ChinaEquityRuleHooks::default(),
PriceField::Open, PriceField::Open,
) ).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit)
.with_slippage_model(SlippageModel::PriceRatio(0.01)); .with_slippage_model(SlippageModel::PriceRatio(0.01));
let report = broker let report = broker
@@ -2337,7 +2337,7 @@ fn broker_cancels_market_order_remainder_when_intraday_quote_liquidity_exhausted
} }
#[test] #[test]
fn broker_cancels_market_buy_when_minute_has_no_volume() { fn broker_rejects_missing_execution_capacity_instead_of_declaring_suspension() {
let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap(); let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap();
let data = DataSet::from_components( let data = DataSet::from_components(
vec![Instrument { vec![Instrument {
@@ -2433,15 +2433,10 @@ fn broker_cancels_market_buy_when_minute_has_no_volume() {
risk_decisions: Vec::new(), risk_decisions: Vec::new(),
}, },
) )
.expect("broker execution"); .expect_err("missing capacity is a contract error, not a normal no-volume cancellation");
assert_eq!(report.fill_events.len(), 0); assert!(report.to_string().contains("execution-time capacity is missing"));
assert_eq!(report.order_events.len(), 1); assert_eq!(portfolio.cash(), 1_000_000.0);
assert_eq!(
report.order_events[0].status,
fidc_core::OrderStatus::Canceled
);
assert!(report.order_events[0].reason.contains("daily no volume"));
} }
#[test] #[test]
@@ -3679,7 +3674,7 @@ fn rebalance_uses_day_open_for_open_auction_valuation() {
ChinaAShareCostModel::default(), ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(), ChinaEquityRuleHooks::default(),
PriceField::DayOpen, PriceField::DayOpen,
); ).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit);
let report = broker let report = broker
.execute( .execute(
@@ -3864,7 +3859,7 @@ fn rebalance_optimizer_prioritizes_higher_target_weight_when_cash_is_tight() {
ChinaAShareCostModel::default(), ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(), ChinaEquityRuleHooks::default(),
PriceField::Open, PriceField::Open,
); ).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit);
let report = broker let report = broker
.execute( .execute(
@@ -4049,7 +4044,7 @@ fn rebalance_optimizer_does_not_scale_targets_above_requested_weight() {
ChinaAShareCostModel::default(), ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(), ChinaEquityRuleHooks::default(),
PriceField::Open, PriceField::Open,
); ).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit);
broker broker
.execute( .execute(
@@ -4163,7 +4158,7 @@ fn broker_uses_board_specific_min_quantity_and_step_size_for_buy_sizing() {
ChinaAShareCostModel::default(), ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(), ChinaEquityRuleHooks::default(),
PriceField::Open, PriceField::Open,
) ).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit)
.with_risk_config(risk_config); .with_risk_config(risk_config);
let report = broker let report = broker
@@ -4269,7 +4264,7 @@ fn broker_allows_bjse_quantities_above_minimum_without_round_lot_step() {
ChinaAShareCostModel::default(), ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(), ChinaEquityRuleHooks::default(),
PriceField::Open, PriceField::Open,
) ).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit)
.with_risk_config(risk_config); .with_risk_config(risk_config);
let report = broker let report = broker
@@ -4377,7 +4372,7 @@ fn broker_allows_full_odd_lot_sell_when_liquidating_position() {
ChinaAShareCostModel::default(), ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(), ChinaEquityRuleHooks::default(),
PriceField::Open, PriceField::Open,
); ).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit);
let report = broker let report = broker
.execute( .execute(
@@ -4511,7 +4506,7 @@ fn same_day_sell_then_rebuy_is_rejected_by_default() {
ChinaAShareCostModel::default(), ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(), ChinaEquityRuleHooks::default(),
PriceField::Open, PriceField::Open,
); ).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit);
broker broker
.execute( .execute(
@@ -4655,7 +4650,7 @@ fn same_day_sell_then_rebuy_can_be_allowed_by_policy() {
ChinaAShareCostModel::default(), ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(), ChinaEquityRuleHooks::default(),
PriceField::Open, PriceField::Open,
) ).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit)
.with_risk_config(risk_config); .with_risk_config(risk_config);
broker broker
@@ -4708,7 +4703,7 @@ fn broker_configured_policy_can_allow_upper_limit_buy() {
ChinaAShareCostModel::default(), ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(), ChinaEquityRuleHooks::default(),
PriceField::Open, PriceField::Open,
) ).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit)
.with_risk_config(risk_config); .with_risk_config(risk_config);
let mut portfolio = PortfolioState::new(1_000_000.0); let mut portfolio = PortfolioState::new(1_000_000.0);
@@ -4752,7 +4747,7 @@ fn broker_configured_policy_can_allow_lower_limit_sell() {
ChinaAShareCostModel::default(), ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(), ChinaEquityRuleHooks::default(),
PriceField::Open, PriceField::Open,
) ).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit)
.with_risk_config(risk_config); .with_risk_config(risk_config);
let mut portfolio = PortfolioState::new(1_000_000.0); let mut portfolio = PortfolioState::new(1_000_000.0);
portfolio portfolio
@@ -4791,7 +4786,7 @@ fn broker_configured_policy_can_allow_lower_limit_sell() {
fn two_day_limit_order_data(day1_open: f64, day2_open: f64) -> DataSet { fn two_day_limit_order_data(day1_open: f64, day2_open: f64) -> DataSet {
let day1 = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap(); let day1 = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap();
let day2 = NaiveDate::from_ymd_opt(2024, 1, 11).unwrap(); let day2 = NaiveDate::from_ymd_opt(2024, 1, 11).unwrap();
DataSet::from_components( DataSet::from_components_with_actions_and_quotes(
vec![Instrument { vec![Instrument {
symbol: "000002.SZ".to_string(), symbol: "000002.SZ".to_string(),
name: "Test".to_string(), name: "Test".to_string(),
@@ -4919,6 +4914,13 @@ fn two_day_limit_order_data(day1_open: f64, day2_open: f64) -> DataSet {
volume: 1_000_000, volume: 1_000_000,
}, },
], ],
Vec::new(),
[(day1, day1_open), (day2, day2_open)].into_iter().map(|(date, price)| IntradayExecutionQuote {
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,
trading_phase: Some("synthetic_observation_fixture".into()),
}).collect(),
) )
.expect("dataset") .expect("dataset")
} }
@@ -4932,7 +4934,7 @@ fn broker_expires_day_limit_buy_at_market_close() {
ChinaAShareCostModel::default(), ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(), ChinaEquityRuleHooks::default(),
PriceField::Open, PriceField::Open,
); ).with_matching_type(MatchingType::MinuteLast).with_intraday_execution_start_time(NaiveTime::from_hms_opt(9, 30, 0).unwrap());
let mut portfolio = PortfolioState::new(1_000_000.0); let mut portfolio = PortfolioState::new(1_000_000.0);
let day1_report = broker let day1_report = broker
@@ -5006,7 +5008,7 @@ fn broker_ioc_limit_order_fills_available_quantity_and_cancels_remainder() {
ChinaAShareCostModel::default(), ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(), ChinaEquityRuleHooks::default(),
PriceField::Open, PriceField::Open,
) ).with_matching_type(MatchingType::MinuteLast).with_intraday_execution_start_time(NaiveTime::from_hms_opt(9, 30, 0).unwrap())
.with_volume_limit(true) .with_volume_limit(true)
.with_volume_percent(0.001) .with_volume_percent(0.001)
.with_liquidity_limit(false); .with_liquidity_limit(false);
@@ -5042,7 +5044,7 @@ fn broker_ioc_limit_order_fills_available_quantity_and_cancels_remainder() {
} }
#[test] #[test]
fn broker_persists_daily_volume_consumption_across_execute_calls() { fn broker_persists_observed_volume_consumption_across_execute_calls() {
let day1 = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap(); let day1 = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap();
let day2 = NaiveDate::from_ymd_opt(2024, 1, 11).unwrap(); let day2 = NaiveDate::from_ymd_opt(2024, 1, 11).unwrap();
let data = two_day_limit_order_data(10.0, 10.0); let data = two_day_limit_order_data(10.0, 10.0);
@@ -5051,6 +5053,8 @@ fn broker_persists_daily_volume_consumption_across_execute_calls() {
ChinaEquityRuleHooks::default(), ChinaEquityRuleHooks::default(),
PriceField::Open, PriceField::Open,
) )
.with_matching_type(MatchingType::MinuteLast)
.with_intraday_execution_start_time(NaiveTime::from_hms_opt(9, 30, 0).unwrap())
.with_volume_limit(true) .with_volume_limit(true)
.with_volume_percent(0.001) .with_volume_percent(0.001)
.with_liquidity_limit(false); .with_liquidity_limit(false);
@@ -5077,7 +5081,7 @@ fn broker_persists_daily_volume_consumption_across_execute_calls() {
assert_eq!(second.order_events.len(), 1); assert_eq!(second.order_events.len(), 1);
assert_eq!(second.order_events[0].status, OrderStatus::Canceled); assert_eq!(second.order_events[0].status, OrderStatus::Canceled);
assert_eq!(second.order_events[0].filled_quantity, 0); assert_eq!(second.order_events[0].filled_quantity, 0);
assert!(second.order_events[0].reason.contains("daily volume limit")); assert!(second.order_events[0].reason.contains("intraday quote liquidity exhausted"));
assert_eq!(portfolio.position("000002.SZ").unwrap().quantity, 100); assert_eq!(portfolio.position("000002.SZ").unwrap().quantity, 100);
let next_day = broker let next_day = broker
@@ -5294,7 +5298,7 @@ fn broker_day_market_order_cancels_remainder_without_creating_invalid_open_order
ChinaAShareCostModel::default(), ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(), ChinaEquityRuleHooks::default(),
PriceField::Open, PriceField::Open,
) ).with_matching_type(MatchingType::MinuteLast).with_intraday_execution_start_time(NaiveTime::from_hms_opt(9, 30, 0).unwrap())
.with_volume_limit(true) .with_volume_limit(true)
.with_volume_percent(0.001) .with_volume_percent(0.001)
.with_liquidity_limit(false); .with_liquidity_limit(false);
@@ -5334,7 +5338,7 @@ fn broker_fok_order_is_atomic_when_liquidity_is_insufficient() {
ChinaAShareCostModel::default(), ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(), ChinaEquityRuleHooks::default(),
PriceField::Open, PriceField::Open,
) ).with_matching_type(MatchingType::MinuteLast).with_intraday_execution_start_time(NaiveTime::from_hms_opt(9, 30, 0).unwrap())
.with_volume_limit(true) .with_volume_limit(true)
.with_volume_percent(0.001) .with_volume_percent(0.001)
.with_liquidity_limit(false); .with_liquidity_limit(false);
@@ -5421,7 +5425,7 @@ fn broker_gtc_limit_order_survives_close_and_fills_next_day() {
ChinaAShareCostModel::default(), ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(), ChinaEquityRuleHooks::default(),
PriceField::Open, PriceField::Open,
); ).with_matching_type(MatchingType::MinuteLast).with_intraday_execution_start_time(NaiveTime::from_hms_opt(9, 30, 0).unwrap());
let mut portfolio = PortfolioState::new(1_000_000.0); let mut portfolio = PortfolioState::new(1_000_000.0);
let day1_report = broker let day1_report = broker
@@ -5469,7 +5473,7 @@ fn broker_gtc_partial_fills_preserve_cumulative_order_and_commission_state() {
ChinaAShareCostModel::default(), ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(), ChinaEquityRuleHooks::default(),
PriceField::Open, PriceField::Open,
) ).with_matching_type(MatchingType::MinuteLast).with_intraday_execution_start_time(NaiveTime::from_hms_opt(9, 30, 0).unwrap())
.with_volume_limit(true) .with_volume_limit(true)
.with_volume_percent(0.001) .with_volume_percent(0.001)
.with_liquidity_limit(false); .with_liquidity_limit(false);
@@ -5536,7 +5540,7 @@ fn broker_modifies_gtc_limit_order_without_changing_order_identity() {
ChinaAShareCostModel::default(), ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(), ChinaEquityRuleHooks::default(),
PriceField::Open, PriceField::Open,
); ).with_matching_type(MatchingType::MinuteLast).with_intraday_execution_start_time(NaiveTime::from_hms_opt(9, 30, 0).unwrap());
let mut portfolio = PortfolioState::new(1_000_000.0); let mut portfolio = PortfolioState::new(1_000_000.0);
let created = broker let created = broker
@@ -5621,7 +5625,7 @@ fn broker_modifies_partially_filled_gtc_total_and_preserves_commission_state() {
ChinaAShareCostModel::default(), ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(), ChinaEquityRuleHooks::default(),
PriceField::Open, PriceField::Open,
) ).with_matching_type(MatchingType::MinuteLast).with_intraday_execution_start_time(NaiveTime::from_hms_opt(9, 30, 0).unwrap())
.with_volume_limit(true) .with_volume_limit(true)
.with_volume_percent(0.001) .with_volume_percent(0.001)
.with_liquidity_limit(false); .with_liquidity_limit(false);
@@ -5699,7 +5703,7 @@ fn broker_rejected_modify_has_zero_side_effects() {
ChinaAShareCostModel::default(), ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(), ChinaEquityRuleHooks::default(),
PriceField::Open, PriceField::Open,
) ).with_matching_type(MatchingType::MinuteLast).with_intraday_execution_start_time(NaiveTime::from_hms_opt(9, 30, 0).unwrap())
.with_volume_limit(true) .with_volume_limit(true)
.with_volume_percent(0.001) .with_volume_percent(0.001)
.with_liquidity_limit(false); .with_liquidity_limit(false);
@@ -5767,7 +5771,7 @@ fn broker_accepted_modify_resets_queue_priority_but_reduction_preserves_it() {
ChinaAShareCostModel::default(), ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(), ChinaEquityRuleHooks::default(),
PriceField::Open, PriceField::Open,
); ).with_matching_type(MatchingType::MinuteLast).with_intraday_execution_start_time(NaiveTime::from_hms_opt(9, 30, 0).unwrap());
let mut portfolio = PortfolioState::new(1_000_000.0); let mut portfolio = PortfolioState::new(1_000_000.0);
let create = |reason: &str| StrategyDecision { let create = |reason: &str| StrategyDecision {
order_intents: vec![ order_intents: vec![
@@ -5899,7 +5903,7 @@ fn broker_uses_limit_price_slippage_for_limit_orders() {
ChinaAShareCostModel::default(), ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(), ChinaEquityRuleHooks::default(),
PriceField::Open, PriceField::Open,
) ).with_matching_type(MatchingType::MinuteLast).with_intraday_execution_start_time(NaiveTime::from_hms_opt(9, 30, 0).unwrap())
.with_slippage_model(SlippageModel::LimitPrice); .with_slippage_model(SlippageModel::LimitPrice);
let mut portfolio = PortfolioState::new(1_000_000.0); let mut portfolio = PortfolioState::new(1_000_000.0);
@@ -5938,7 +5942,7 @@ fn broker_rejects_limit_buy_when_final_execution_price_reaches_upper_limit() {
ChinaAShareCostModel::default(), ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(), ChinaEquityRuleHooks::default(),
PriceField::Open, PriceField::Open,
) ).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit)
.with_slippage_model(SlippageModel::LimitPrice); .with_slippage_model(SlippageModel::LimitPrice);
let mut portfolio = PortfolioState::new(1_000_000.0); let mut portfolio = PortfolioState::new(1_000_000.0);
@@ -5984,7 +5988,7 @@ fn broker_executes_limit_value_and_limit_percent_intents() {
ChinaAShareCostModel::default(), ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(), ChinaEquityRuleHooks::default(),
PriceField::Open, PriceField::Open,
); ).with_matching_type(MatchingType::MinuteLast).with_intraday_execution_start_time(NaiveTime::from_hms_opt(9, 30, 0).unwrap());
let mut value_portfolio = PortfolioState::new(1_000_000.0); let mut value_portfolio = PortfolioState::new(1_000_000.0);
let value_report = broker let value_report = broker
@@ -6047,7 +6051,7 @@ fn broker_cancels_open_order_by_order_id() {
ChinaAShareCostModel::default(), ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(), ChinaEquityRuleHooks::default(),
PriceField::Open, PriceField::Open,
); ).with_matching_type(MatchingType::MinuteLast).with_intraday_execution_start_time(NaiveTime::from_hms_opt(9, 30, 0).unwrap());
let mut portfolio = PortfolioState::new(1_000_000.0); let mut portfolio = PortfolioState::new(1_000_000.0);
let day1_report = broker let day1_report = broker
@@ -6225,7 +6229,7 @@ fn broker_reserves_sellable_quantity_for_open_limit_sells() {
ChinaAShareCostModel::default(), ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(), ChinaEquityRuleHooks::default(),
PriceField::Open, PriceField::Open,
); ).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit);
let mut portfolio = PortfolioState::new(1_000_000.0); let mut portfolio = PortfolioState::new(1_000_000.0);
portfolio portfolio
.position_mut("000002.SZ") .position_mut("000002.SZ")
@@ -142,7 +142,13 @@ fn data_with_fund_rules(
}) })
}) })
.collect(); .collect();
DataSet::from_components(instruments, market, factors, candidates, benchmarks).unwrap() let quotes = market.iter().filter(|row| row.symbol != "000300.SH").map(|row| fidc_core::IntradayExecutionQuote {
date: row.date, symbol: row.symbol.clone(), timestamp: row.date.and_hms_opt(9, 30, 0).unwrap(),
last_price: row.open, bid1: row.open, ask1: row.open, bid1_volume: 0, ask1_volume: 0,
volume_delta: row.volume, amount_delta: row.open * row.volume as f64,
trading_phase: Some("synthetic_observation_fixture".into()),
}).collect();
DataSet::from_components_with_actions_and_quotes(instruments, market, factors, candidates, benchmarks, vec![], quotes).unwrap()
} }
fn broker(volume: bool) -> BrokerSimulator<ChinaAShareCostModel, ChinaEquityRuleHooks> { fn broker(volume: bool) -> BrokerSimulator<ChinaAShareCostModel, ChinaEquityRuleHooks> {
let mut risk = FidcRiskControlConfig::default(); let mut risk = FidcRiskControlConfig::default();
@@ -159,6 +165,7 @@ fn broker(volume: bool) -> BrokerSimulator<ChinaAShareCostModel, ChinaEquityRule
ChinaEquityRuleHooks, ChinaEquityRuleHooks,
) )
.with_matching_type(MatchingType::NextBarOpen) .with_matching_type(MatchingType::NextBarOpen)
.with_intraday_execution_start_time(chrono::NaiveTime::from_hms_opt(9, 30, 0).unwrap())
.with_risk_config(risk) .with_risk_config(risk)
} }
fn contract(signal: NaiveDate, target: usize, preserve: bool) -> FrozenStockPoolIntent { fn contract(signal: NaiveDate, target: usize, preserve: bool) -> FrozenStockPoolIntent {
@@ -268,6 +275,7 @@ fn mixed_fund_and_stock_round_trip_uses_declared_ticks_and_asset_specific_fees()
let mut costs = ChinaAShareCostModel::default(); let mut costs = ChinaAShareCostModel::default();
costs.set_transfer_fee_rate(0.00001); costs.set_transfer_fee_rate(0.00001);
let broker = BrokerSimulator::new(costs, ChinaEquityRuleHooks) let broker = BrokerSimulator::new(costs, ChinaEquityRuleHooks)
.with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit)
.with_matching_type(MatchingType::NextBarOpen); .with_matching_type(MatchingType::NextBarOpen);
let mut account = PortfolioState::new(30_000.); let mut account = PortfolioState::new(30_000.);
let mut entry = contract(day(2), 1, false); let mut entry = contract(day(2), 1, false);