Compare commits
9 Commits
v2026.9.12.3
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 099759ae67 | |||
| d646ca455d | |||
| 3b2a97fa84 | |||
| 2f2258f208 | |||
| 7ac87a90c4 | |||
| 5949d4cc69 | |||
| 24cb4805a7 | |||
| 053f880e34 | |||
| 4d3a9e0e5b |
+231
-120
@@ -7,7 +7,7 @@ use chrono::{Duration, NaiveDate, NaiveDateTime, NaiveTime};
|
|||||||
use crate::cost::CostModel;
|
use crate::cost::CostModel;
|
||||||
use crate::data::{DataSet, IntradayExecutionQuote, PriceField};
|
use crate::data::{DataSet, IntradayExecutionQuote, PriceField};
|
||||||
use crate::engine::BacktestError;
|
use crate::engine::BacktestError;
|
||||||
use crate::execution_capacity::{CapacityError, ParticipationRate, VolumeObservation, VolumeObservationKind};
|
use crate::execution_capacity::{CapacityAuditSummary, CapacityError, ParticipationRate, SessionCapacityAudit, VolumeCapacityMode, VolumeObservation, VolumeObservationKind};
|
||||||
use crate::execution_schedule::TwapSchedule;
|
use crate::execution_schedule::TwapSchedule;
|
||||||
use crate::events::{
|
use crate::events::{
|
||||||
AccountEvent, FillEvent, OrderEvent, OrderSide, OrderStatus, PositionEvent, ProcessEvent,
|
AccountEvent, FillEvent, OrderEvent, OrderSide, OrderStatus, PositionEvent, ProcessEvent,
|
||||||
@@ -205,6 +205,7 @@ struct OpenOrder {
|
|||||||
decision_date: Option<NaiveDate>,
|
decision_date: Option<NaiveDate>,
|
||||||
order_created_date: Option<NaiveDate>,
|
order_created_date: Option<NaiveDate>,
|
||||||
submission_time: Option<NaiveTime>,
|
submission_time: Option<NaiveTime>,
|
||||||
|
accepted_date: NaiveDate,
|
||||||
symbol: String,
|
symbol: String,
|
||||||
side: OrderSide,
|
side: OrderSide,
|
||||||
requested_quantity: u32,
|
requested_quantity: u32,
|
||||||
@@ -221,6 +222,7 @@ struct OpenOrder {
|
|||||||
struct RestingOrderOrigin {
|
struct RestingOrderOrigin {
|
||||||
created_date: Option<NaiveDate>,
|
created_date: Option<NaiveDate>,
|
||||||
submission_time: Option<NaiveTime>,
|
submission_time: Option<NaiveTime>,
|
||||||
|
accepted_date: NaiveDate,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default)]
|
||||||
@@ -421,6 +423,10 @@ struct AlgoExecutionRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub struct BrokerSimulator<C, R> {
|
pub struct BrokerSimulator<C, R> {
|
||||||
|
historical_etf_open_fallback: bool,
|
||||||
|
verified_etf_minute_absences: RefCell<BTreeSet<(NaiveDate, String)>>,
|
||||||
|
runtime_etf_daily_open: Cell<bool>,
|
||||||
|
deferred_etf_targets: RefCell<crate::etf_execution::DeferredEtfTargets>,
|
||||||
cost_model: C,
|
cost_model: C,
|
||||||
rules: R,
|
rules: R,
|
||||||
board_lot_size: u32,
|
board_lot_size: u32,
|
||||||
@@ -430,6 +436,7 @@ pub struct BrokerSimulator<C, R> {
|
|||||||
volume_percent: f64,
|
volume_percent: f64,
|
||||||
volume_rate: Result<ParticipationRate, CapacityError>,
|
volume_rate: Result<ParticipationRate, CapacityError>,
|
||||||
volume_limit: bool,
|
volume_limit: bool,
|
||||||
|
volume_capacity_mode: VolumeCapacityMode,
|
||||||
inactive_limit: bool,
|
inactive_limit: bool,
|
||||||
liquidity_limit: bool,
|
liquidity_limit: bool,
|
||||||
strict_value_budget: bool,
|
strict_value_budget: bool,
|
||||||
@@ -458,6 +465,10 @@ pub struct BrokerSimulator<C, R> {
|
|||||||
impl<C, R> BrokerSimulator<C, R> {
|
impl<C, R> BrokerSimulator<C, R> {
|
||||||
pub fn new(cost_model: C, rules: R) -> Self {
|
pub fn new(cost_model: C, rules: R) -> Self {
|
||||||
Self {
|
Self {
|
||||||
|
historical_etf_open_fallback: false,
|
||||||
|
verified_etf_minute_absences: RefCell::new(BTreeSet::new()),
|
||||||
|
runtime_etf_daily_open: Cell::new(false),
|
||||||
|
deferred_etf_targets: RefCell::new(Default::default()),
|
||||||
cost_model,
|
cost_model,
|
||||||
rules,
|
rules,
|
||||||
board_lot_size: 100,
|
board_lot_size: 100,
|
||||||
@@ -467,6 +478,7 @@ impl<C, R> BrokerSimulator<C, R> {
|
|||||||
volume_percent: 0.25,
|
volume_percent: 0.25,
|
||||||
volume_rate: ParticipationRate::new(0.25),
|
volume_rate: ParticipationRate::new(0.25),
|
||||||
volume_limit: true,
|
volume_limit: true,
|
||||||
|
volume_capacity_mode: VolumeCapacityMode::ExecutionObservation,
|
||||||
inactive_limit: true,
|
inactive_limit: true,
|
||||||
liquidity_limit: true,
|
liquidity_limit: true,
|
||||||
strict_value_budget: true,
|
strict_value_budget: true,
|
||||||
@@ -499,6 +511,10 @@ impl<C, R> BrokerSimulator<C, R> {
|
|||||||
execution_price_field: PriceField,
|
execution_price_field: PriceField,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
|
historical_etf_open_fallback: false,
|
||||||
|
verified_etf_minute_absences: RefCell::new(BTreeSet::new()),
|
||||||
|
runtime_etf_daily_open: Cell::new(false),
|
||||||
|
deferred_etf_targets: RefCell::new(Default::default()),
|
||||||
cost_model,
|
cost_model,
|
||||||
rules,
|
rules,
|
||||||
board_lot_size: 100,
|
board_lot_size: 100,
|
||||||
@@ -508,6 +524,7 @@ impl<C, R> BrokerSimulator<C, R> {
|
|||||||
volume_percent: 0.25,
|
volume_percent: 0.25,
|
||||||
volume_rate: ParticipationRate::new(0.25),
|
volume_rate: ParticipationRate::new(0.25),
|
||||||
volume_limit: true,
|
volume_limit: true,
|
||||||
|
volume_capacity_mode: VolumeCapacityMode::ExecutionObservation,
|
||||||
inactive_limit: true,
|
inactive_limit: true,
|
||||||
liquidity_limit: true,
|
liquidity_limit: true,
|
||||||
strict_value_budget: true,
|
strict_value_budget: true,
|
||||||
@@ -539,6 +556,63 @@ impl<C, R> BrokerSimulator<C, R> {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn with_volume_capacity_mode(mut self, mode: VolumeCapacityMode) -> Self {
|
||||||
|
self.volume_capacity_mode = mode;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Historical stock-pool adapter only. Online runtimes never enable this.
|
||||||
|
pub fn with_historical_etf_open_fallback(mut self, enabled: bool) -> Self {
|
||||||
|
self.historical_etf_open_fallback = enabled;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn requires_etf_absence_check(&self, data: &DataSet, symbol: &str) -> bool {
|
||||||
|
self.historical_etf_open_fallback && data.instrument(symbol).is_some_and(|v| v.is_exchange_traded_fund())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn record_complete_etf_minute_query(&self, date: NaiveDate, data: &DataSet, symbols: &[String]) {
|
||||||
|
for symbol in symbols {
|
||||||
|
if self.requires_etf_absence_check(data, symbol) && data.execution_quotes_on(date, symbol).is_empty() {
|
||||||
|
self.verified_etf_minute_absences.borrow_mut().insert((date, symbol.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn has_verified_etf_minute_absence(&self, date: NaiveDate, symbol: &str) -> bool {
|
||||||
|
self.historical_etf_open_fallback && self.verified_etf_minute_absences.borrow().contains(&(date, symbol.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with_etf_daily_open<T>(&self, operation: impl FnOnce() -> Result<T, BacktestError>) -> Result<T, BacktestError> {
|
||||||
|
if self.liquidity_limit {
|
||||||
|
return Err(BacktestError::Execution("etf_daily_open_fallback: historical opening depth is unavailable; cannot satisfy liquidity_limit".into()));
|
||||||
|
}
|
||||||
|
self.volume_capacity_mode.validate(self.volume_limit, false)
|
||||||
|
.map_err(|error| BacktestError::Execution(format!("etf_daily_open_fallback: {error}")))?;
|
||||||
|
let prior = self.runtime_etf_daily_open.replace(true);
|
||||||
|
let result = operation();
|
||||||
|
self.runtime_etf_daily_open.set(prior);
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn capacity_audit_summary(&self) -> CapacityAuditSummary {
|
||||||
|
CapacityAuditSummary { mode: self.volume_capacity_mode, enabled: self.volume_limit,
|
||||||
|
participation_rate: self.volume_percent, ..Default::default() }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn audit_completed_session_capacity(&self, date: NaiveDate, data: &DataSet) -> Result<Vec<SessionCapacityAudit>, BacktestError> {
|
||||||
|
if !self.volume_limit || self.volume_capacity_mode != VolumeCapacityMode::SessionCapacityAudit {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
let session = self.execution_session.borrow();
|
||||||
|
if session.date != Some(date) { return Ok(Vec::new()); }
|
||||||
|
let rate = self.volume_rate.map_err(|error| BacktestError::Execution(error.to_string()))?;
|
||||||
|
session.intraday_turnover.iter().filter(|(_, quantity)| **quantity > 0).map(|(symbol, quantity)| {
|
||||||
|
let market = data.market(date, symbol).ok_or_else(|| BacktestError::MissingPrice { date, symbol: symbol.clone(), field: "session capacity audit" })?;
|
||||||
|
Ok(SessionCapacityAudit::new(date, symbol.clone(), u64::from(*quantity), market.volume, rate))
|
||||||
|
}).collect()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn with_inactive_limit(mut self, enabled: bool) -> Self {
|
pub fn with_inactive_limit(mut self, enabled: bool) -> Self {
|
||||||
self.inactive_limit = enabled;
|
self.inactive_limit = enabled;
|
||||||
self
|
self
|
||||||
@@ -646,11 +720,21 @@ impl<C, R> BrokerSimulator<C, R> {
|
|||||||
.or(self.intraday_execution_start_time)
|
.or(self.intraday_execution_start_time)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn order_origin(&self) -> RestingOrderOrigin {
|
fn order_origin(&self) -> (Option<NaiveDate>, Option<NaiveTime>) {
|
||||||
self.runtime_resting_order_origin.get().unwrap_or(RestingOrderOrigin {
|
self.runtime_resting_order_origin.get().map_or(
|
||||||
created_date: self.runtime_order_created_date.get(),
|
(self.runtime_order_created_date.get(), self.submission_time()),
|
||||||
submission_time: self.submission_time(),
|
|origin| (origin.created_date, origin.submission_time),
|
||||||
})
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn accepted_order_date(&self, date: NaiveDate) -> NaiveDate {
|
||||||
|
self.runtime_resting_order_origin.get().map_or(date, |origin| origin.accepted_date)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resting_daily_open_order(&self) -> bool {
|
||||||
|
self.runtime_resting_order_origin.get().is_some()
|
||||||
|
&& self.runtime_intraday_start_time.get().is_some()
|
||||||
|
&& self.matching_type == MatchingType::NextBarOpen
|
||||||
}
|
}
|
||||||
|
|
||||||
fn execution_phase_for_submission(
|
fn execution_phase_for_submission(
|
||||||
@@ -681,8 +765,8 @@ impl<C, R> BrokerSimulator<C, R> {
|
|||||||
let origin = self.order_origin();
|
let origin = self.order_origin();
|
||||||
self.execution_phase_for_submission(
|
self.execution_phase_for_submission(
|
||||||
date,
|
date,
|
||||||
origin.created_date,
|
origin.0,
|
||||||
origin.submission_time,
|
origin.1,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -691,8 +775,11 @@ impl<C, R> BrokerSimulator<C, R> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn effective_execution_price_field(&self, date: NaiveDate) -> PriceField {
|
fn effective_execution_price_field(&self, date: NaiveDate) -> PriceField {
|
||||||
|
if self.runtime_etf_daily_open.get() { return PriceField::Open; }
|
||||||
if self.is_post_close_fixed_price(date) {
|
if self.is_post_close_fixed_price(date) {
|
||||||
PriceField::Close
|
PriceField::Close
|
||||||
|
} else if self.resting_daily_open_order() {
|
||||||
|
PriceField::Last
|
||||||
} else {
|
} else {
|
||||||
self.execution_price_field
|
self.execution_price_field
|
||||||
}
|
}
|
||||||
@@ -705,8 +792,8 @@ impl<C, R> BrokerSimulator<C, R> {
|
|||||||
let origin = self.order_origin();
|
let origin = self.order_origin();
|
||||||
self.post_close_execution_quote_window_for_submission(
|
self.post_close_execution_quote_window_for_submission(
|
||||||
date,
|
date,
|
||||||
origin.created_date,
|
origin.0,
|
||||||
origin.submission_time,
|
origin.1,
|
||||||
)
|
)
|
||||||
.map(|(start, end)| (date.and_time(start), date.and_time(end)))
|
.map(|(start, end)| (date.and_time(start), date.and_time(end)))
|
||||||
}
|
}
|
||||||
@@ -870,6 +957,7 @@ where
|
|||||||
symbol: &str,
|
symbol: &str,
|
||||||
snapshot: &crate::data::DailyMarketSnapshot,
|
snapshot: &crate::data::DailyMarketSnapshot,
|
||||||
) -> f64 {
|
) -> f64 {
|
||||||
|
if self.runtime_etf_daily_open.get() { return snapshot.open; }
|
||||||
if self.is_post_close_fixed_price(date) {
|
if self.is_post_close_fixed_price(date) {
|
||||||
return snapshot.close;
|
return snapshot.close;
|
||||||
}
|
}
|
||||||
@@ -1228,6 +1316,7 @@ where
|
|||||||
snapshot: &crate::data::DailyMarketSnapshot,
|
snapshot: &crate::data::DailyMarketSnapshot,
|
||||||
side: OrderSide,
|
side: OrderSide,
|
||||||
) -> f64 {
|
) -> f64 {
|
||||||
|
if self.runtime_etf_daily_open.get() { return snapshot.open; }
|
||||||
if self.is_post_close_fixed_price(snapshot.date) {
|
if self.is_post_close_fixed_price(snapshot.date) {
|
||||||
return snapshot.close;
|
return snapshot.close;
|
||||||
}
|
}
|
||||||
@@ -1386,9 +1475,11 @@ where
|
|||||||
&self,
|
&self,
|
||||||
algo_request: Option<&AlgoExecutionRequest>,
|
algo_request: Option<&AlgoExecutionRequest>,
|
||||||
) -> MatchingType {
|
) -> MatchingType {
|
||||||
|
if self.runtime_etf_daily_open.get() && algo_request.is_none() { return MatchingType::NextBarOpen; }
|
||||||
match algo_request.map(|request| request.style) {
|
match algo_request.map(|request| request.style) {
|
||||||
Some(AlgoExecutionStyle::Vwap) => MatchingType::Vwap,
|
Some(AlgoExecutionStyle::Vwap) => MatchingType::Vwap,
|
||||||
Some(AlgoExecutionStyle::Twap) => MatchingType::Twap,
|
Some(AlgoExecutionStyle::Twap) => MatchingType::Twap,
|
||||||
|
None if self.resting_daily_open_order() => MatchingType::CurrentBarClose,
|
||||||
None => self.matching_type,
|
None => self.matching_type,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2420,7 +2511,7 @@ where
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn current_order_created_date(&self, date: NaiveDate) -> NaiveDate {
|
fn current_order_created_date(&self, date: NaiveDate) -> NaiveDate {
|
||||||
self.order_origin().created_date.unwrap_or(date)
|
self.order_origin().0.unwrap_or(date)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn annotate_report_range(
|
fn annotate_report_range(
|
||||||
@@ -2572,10 +2663,15 @@ where
|
|||||||
std::mem::take(&mut *open_orders)
|
std::mem::take(&mut *open_orders)
|
||||||
};
|
};
|
||||||
for order in pending_orders {
|
for order in pending_orders {
|
||||||
|
if self.matching_type == MatchingType::NextBarOpen && self.runtime_intraday_start_time.get().is_none()
|
||||||
|
&& order.accepted_date == date {
|
||||||
|
self.open_orders.borrow_mut().push(order);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let close = self.resting_order_session_close(date, &order);
|
let close = self.resting_order_session_close(date, &order);
|
||||||
let clock = self.submission_time();
|
let clock = self.submission_time();
|
||||||
let past_day = order.time_in_force == OrderTimeInForce::Day
|
let past_day = order.time_in_force == OrderTimeInForce::Day
|
||||||
&& order.order_created_date.is_some_and(|created| created < date);
|
&& order.accepted_date < date;
|
||||||
if past_day || clock.is_some_and(|time| time > close) {
|
if past_day || clock.is_some_and(|time| time > close) {
|
||||||
if order.time_in_force == OrderTimeInForce::Day {
|
if order.time_in_force == OrderTimeInForce::Day {
|
||||||
Self::emit_resting_day_expiry(report, date, &order, order.filled_quantity);
|
Self::emit_resting_day_expiry(report, date, &order, order.filled_quantity);
|
||||||
@@ -2611,6 +2707,7 @@ where
|
|||||||
let previous_origin = self.runtime_resting_order_origin.replace(Some(RestingOrderOrigin {
|
let previous_origin = self.runtime_resting_order_origin.replace(Some(RestingOrderOrigin {
|
||||||
created_date: order.order_created_date,
|
created_date: order.order_created_date,
|
||||||
submission_time: order.submission_time,
|
submission_time: order.submission_time,
|
||||||
|
accepted_date: order.accepted_date,
|
||||||
}));
|
}));
|
||||||
let previous_decision_date = self.runtime_decision_date.replace(order.decision_date);
|
let previous_decision_date = self.runtime_decision_date.replace(order.decision_date);
|
||||||
let execution_result = self.process_limit_shares_internal(
|
let execution_result = self.process_limit_shares_internal(
|
||||||
@@ -2651,6 +2748,7 @@ where
|
|||||||
reopened.decision_date = order.decision_date;
|
reopened.decision_date = order.decision_date;
|
||||||
reopened.order_created_date = order.order_created_date;
|
reopened.order_created_date = order.order_created_date;
|
||||||
reopened.submission_time = order.submission_time;
|
reopened.submission_time = order.submission_time;
|
||||||
|
reopened.accepted_date = order.accepted_date;
|
||||||
reopened.requested_quantity = order.requested_quantity;
|
reopened.requested_quantity = order.requested_quantity;
|
||||||
reopened.filled_quantity = cumulative_filled;
|
reopened.filled_quantity = cumulative_filled;
|
||||||
reopened.remaining_quantity = remaining_quantity;
|
reopened.remaining_quantity = remaining_quantity;
|
||||||
@@ -2725,7 +2823,7 @@ where
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn emit_resting_day_expiry(report: &mut BrokerExecutionReport, date: NaiveDate, order: &OpenOrder, filled: u32) {
|
fn emit_resting_day_expiry(report: &mut BrokerExecutionReport, date: NaiveDate, order: &OpenOrder, filled: u32) {
|
||||||
let detail = format!("DAY order expired at session end: {} remaining_quantity={}", order.symbol, order.requested_quantity.saturating_sub(filled));
|
let detail = format!("DAY order expired at market close: {} remaining_quantity={}", order.symbol, order.requested_quantity.saturating_sub(filled));
|
||||||
report.order_events.push(OrderEvent {
|
report.order_events.push(OrderEvent {
|
||||||
date, decision_date: order.decision_date, order_created_date: order.order_created_date,
|
date, decision_date: order.decision_date, order_created_date: order.order_created_date,
|
||||||
execution_date: Some(date), order_id: Some(order.order_id), symbol: order.symbol.clone(),
|
execution_date: Some(date), order_id: Some(order.order_id), symbol: order.symbol.clone(),
|
||||||
@@ -3565,8 +3663,6 @@ where
|
|||||||
data,
|
data,
|
||||||
&symbol,
|
&symbol,
|
||||||
current_qty,
|
current_qty,
|
||||||
minimum_order_quantity,
|
|
||||||
order_step_size,
|
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
diagnostics.push(format!(
|
diagnostics.push(format!(
|
||||||
@@ -3583,8 +3679,6 @@ where
|
|||||||
data,
|
data,
|
||||||
&symbol,
|
&symbol,
|
||||||
current_qty,
|
current_qty,
|
||||||
minimum_order_quantity,
|
|
||||||
order_step_size,
|
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
diagnostics.push(format!(
|
diagnostics.push(format!(
|
||||||
@@ -3995,8 +4089,6 @@ where
|
|||||||
data,
|
data,
|
||||||
symbol,
|
symbol,
|
||||||
current_qty,
|
current_qty,
|
||||||
minimum_order_quantity,
|
|
||||||
order_step_size,
|
|
||||||
) else {
|
) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
@@ -4040,6 +4132,7 @@ where
|
|||||||
snapshot: &crate::data::DailyMarketSnapshot,
|
snapshot: &crate::data::DailyMarketSnapshot,
|
||||||
side: OrderSide,
|
side: OrderSide,
|
||||||
) -> f64 {
|
) -> f64 {
|
||||||
|
if self.runtime_etf_daily_open.get() { return snapshot.open; }
|
||||||
match (self.execution_price_field, side) {
|
match (self.execution_price_field, side) {
|
||||||
(PriceField::Last, _) => snapshot.price(PriceField::Last),
|
(PriceField::Last, _) => snapshot.price(PriceField::Last),
|
||||||
(_, OrderSide::Buy) => snapshot.buy_price(self.execution_price_field),
|
(_, OrderSide::Buy) => snapshot.buy_price(self.execution_price_field),
|
||||||
@@ -4056,6 +4149,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)
|
||||||
@@ -4264,8 +4360,6 @@ where
|
|||||||
data: &DataSet,
|
data: &DataSet,
|
||||||
symbol: &str,
|
symbol: &str,
|
||||||
current_qty: u32,
|
current_qty: u32,
|
||||||
minimum_order_quantity: u32,
|
|
||||||
order_step_size: u32,
|
|
||||||
) -> Option<String> {
|
) -> Option<String> {
|
||||||
if let Some(reason) = self.runtime_auto_sell_denials.borrow().get(symbol) {
|
if let Some(reason) = self.runtime_auto_sell_denials.borrow().get(symbol) {
|
||||||
return Some(reason.clone());
|
return Some(reason.clone());
|
||||||
@@ -4294,12 +4388,8 @@ where
|
|||||||
.saturating_sub(self.reserved_open_sell_quantity(symbol, None));
|
.saturating_sub(self.reserved_open_sell_quantity(symbol, None));
|
||||||
match self.market_fillable_quantity(
|
match self.market_fillable_quantity(
|
||||||
snapshot,
|
snapshot,
|
||||||
OrderSide::Sell,
|
|
||||||
sellable.min(current_qty),
|
sellable.min(current_qty),
|
||||||
minimum_order_quantity,
|
false,
|
||||||
order_step_size,
|
|
||||||
0,
|
|
||||||
sellable >= current_qty,
|
|
||||||
) {
|
) {
|
||||||
Ok(quantity) => {
|
Ok(quantity) => {
|
||||||
let quantity = quantity.min(sellable).min(current_qty);
|
let quantity = quantity.min(sellable).min(current_qty);
|
||||||
@@ -4320,8 +4410,6 @@ where
|
|||||||
data: &DataSet,
|
data: &DataSet,
|
||||||
symbol: &str,
|
symbol: &str,
|
||||||
current_qty: u32,
|
current_qty: u32,
|
||||||
minimum_order_quantity: u32,
|
|
||||||
order_step_size: u32,
|
|
||||||
) -> Option<String> {
|
) -> Option<String> {
|
||||||
let snapshot = data.require_market(date, symbol).ok()?;
|
let snapshot = data.require_market(date, symbol).ok()?;
|
||||||
let candidate = data.require_candidate(date, symbol).ok()?;
|
let candidate = data.require_candidate(date, symbol).ok()?;
|
||||||
@@ -4342,11 +4430,7 @@ where
|
|||||||
}
|
}
|
||||||
match self.market_fillable_quantity(
|
match self.market_fillable_quantity(
|
||||||
snapshot,
|
snapshot,
|
||||||
OrderSide::Buy,
|
|
||||||
u32::MAX,
|
u32::MAX,
|
||||||
minimum_order_quantity,
|
|
||||||
order_step_size,
|
|
||||||
0,
|
|
||||||
false,
|
false,
|
||||||
) {
|
) {
|
||||||
Ok(quantity) => {
|
Ok(quantity) => {
|
||||||
@@ -4616,14 +4700,12 @@ where
|
|||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
self.volume_capacity_mode.validate(self.volume_limit, algo_request.is_some() || self.matching_type_uses_intraday_quotes())
|
||||||
|
.map_err(|error| BacktestError::Execution(error.to_string()))?;
|
||||||
let market_limited_qty = self.market_fillable_quantity(
|
let market_limited_qty = self.market_fillable_quantity(
|
||||||
snapshot,
|
snapshot,
|
||||||
OrderSide::Sell,
|
|
||||||
requested_qty.min(sellable),
|
requested_qty.min(sellable),
|
||||||
self.minimum_order_quantity(data, symbol),
|
algo_request.is_some(),
|
||||||
self.order_step_size(data, symbol),
|
|
||||||
*intraday_turnover.get(symbol).unwrap_or(&0),
|
|
||||||
requested_qty >= position.quantity && sellable >= position.quantity,
|
|
||||||
);
|
);
|
||||||
let fillable_qty = match market_limited_qty {
|
let fillable_qty = match market_limited_qty {
|
||||||
Ok(quantity) => {
|
Ok(quantity) => {
|
||||||
@@ -4653,7 +4735,8 @@ where
|
|||||||
if Self::keeps_remainder_open(remainder_policy) {
|
if Self::keeps_remainder_open(remainder_policy) {
|
||||||
self.upsert_open_order(OpenOrder {
|
self.upsert_open_order(OpenOrder {
|
||||||
order_id,
|
order_id,
|
||||||
submission_time: self.order_origin().submission_time,
|
submission_time: 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(),
|
||||||
@@ -4743,7 +4826,8 @@ where
|
|||||||
.unwrap_or("no sellable quantity");
|
.unwrap_or("no sellable quantity");
|
||||||
self.upsert_open_order(OpenOrder {
|
self.upsert_open_order(OpenOrder {
|
||||||
order_id,
|
order_id,
|
||||||
submission_time: self.order_origin().submission_time,
|
submission_time: 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(),
|
||||||
@@ -4915,7 +4999,8 @@ where
|
|||||||
{
|
{
|
||||||
self.upsert_open_order(OpenOrder {
|
self.upsert_open_order(OpenOrder {
|
||||||
order_id,
|
order_id,
|
||||||
submission_time: self.order_origin().submission_time,
|
submission_time: 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(),
|
||||||
@@ -5086,7 +5171,8 @@ where
|
|||||||
if keep_open {
|
if keep_open {
|
||||||
self.upsert_open_order(OpenOrder {
|
self.upsert_open_order(OpenOrder {
|
||||||
order_id,
|
order_id,
|
||||||
submission_time: self.order_origin().submission_time,
|
submission_time: 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(),
|
||||||
@@ -6442,14 +6528,12 @@ where
|
|||||||
}
|
}
|
||||||
|
|
||||||
let mut partial_fill_reason = None;
|
let mut partial_fill_reason = None;
|
||||||
|
self.volume_capacity_mode.validate(self.volume_limit, algo_request.is_some() || self.matching_type_uses_intraday_quotes())
|
||||||
|
.map_err(|error| BacktestError::Execution(error.to_string()))?;
|
||||||
let market_limited_qty = self.market_fillable_quantity(
|
let market_limited_qty = self.market_fillable_quantity(
|
||||||
snapshot,
|
snapshot,
|
||||||
OrderSide::Buy,
|
|
||||||
requested_qty,
|
requested_qty,
|
||||||
self.minimum_order_quantity(data, symbol),
|
algo_request.is_some(),
|
||||||
self.order_step_size(data, symbol),
|
|
||||||
*intraday_turnover.get(symbol).unwrap_or(&0),
|
|
||||||
false,
|
|
||||||
);
|
);
|
||||||
let constrained_qty = match market_limited_qty {
|
let constrained_qty = match market_limited_qty {
|
||||||
Ok(quantity) => {
|
Ok(quantity) => {
|
||||||
@@ -6475,7 +6559,8 @@ where
|
|||||||
if Self::keeps_remainder_open(remainder_policy) {
|
if Self::keeps_remainder_open(remainder_policy) {
|
||||||
self.upsert_open_order(OpenOrder {
|
self.upsert_open_order(OpenOrder {
|
||||||
order_id,
|
order_id,
|
||||||
submission_time: self.order_origin().submission_time,
|
submission_time: 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(),
|
||||||
@@ -6714,7 +6799,8 @@ where
|
|||||||
{
|
{
|
||||||
self.upsert_open_order(OpenOrder {
|
self.upsert_open_order(OpenOrder {
|
||||||
order_id,
|
order_id,
|
||||||
submission_time: self.order_origin().submission_time,
|
submission_time: 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(),
|
||||||
@@ -6887,7 +6973,8 @@ where
|
|||||||
if keep_open {
|
if keep_open {
|
||||||
self.upsert_open_order(OpenOrder {
|
self.upsert_open_order(OpenOrder {
|
||||||
order_id,
|
order_id,
|
||||||
submission_time: self.order_origin().submission_time,
|
submission_time: 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(),
|
||||||
@@ -7357,68 +7444,19 @@ where
|
|||||||
fn market_fillable_quantity(
|
fn market_fillable_quantity(
|
||||||
&self,
|
&self,
|
||||||
snapshot: &crate::data::DailyMarketSnapshot,
|
snapshot: &crate::data::DailyMarketSnapshot,
|
||||||
side: OrderSide,
|
|
||||||
requested_qty: u32,
|
requested_qty: u32,
|
||||||
minimum_order_quantity: u32,
|
algorithmic_order: bool,
|
||||||
order_step_size: u32,
|
|
||||||
consumed_turnover: u32,
|
|
||||||
allow_odd_lot_sell: bool,
|
|
||||||
) -> Result<u32, String> {
|
) -> Result<u32, String> {
|
||||||
if requested_qty == 0 {
|
if requested_qty == 0 {
|
||||||
return Ok(0);
|
return Ok(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
let uses_intraday_quantity = self.matching_type_uses_intraday_quotes();
|
if self.inactive_limit && snapshot.paused { return Err("paused".into()); }
|
||||||
let available_market_volume = if uses_intraday_quantity {
|
self.volume_capacity_mode.validate(self.volume_limit, algorithmic_order || self.matching_type_uses_intraday_quotes())
|
||||||
snapshot.minute_volume
|
.map_err(|error| error.to_string())?;
|
||||||
} else {
|
// Per-observation limits are applied to each actual quote below. The
|
||||||
snapshot.volume
|
// session-audit model must never size this order from the day's total.
|
||||||
};
|
Ok(requested_qty)
|
||||||
let no_volume_reason = if uses_intraday_quantity {
|
|
||||||
"minute no volume"
|
|
||||||
} else {
|
|
||||||
"daily no volume"
|
|
||||||
};
|
|
||||||
let volume_limit_reason = if uses_intraday_quantity {
|
|
||||||
"minute volume limit"
|
|
||||||
} else {
|
|
||||||
"daily volume limit"
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut max_fill = requested_qty;
|
|
||||||
|
|
||||||
if self.inactive_limit
|
|
||||||
&& (snapshot.paused || (!uses_intraday_quantity && available_market_volume == 0))
|
|
||||||
{
|
|
||||||
return Err(if snapshot.paused {
|
|
||||||
"paused".to_string()
|
|
||||||
} else {
|
|
||||||
no_volume_reason.to_string()
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if uses_intraday_quantity {
|
|
||||||
return Ok(max_fill);
|
|
||||||
}
|
|
||||||
|
|
||||||
if self.volume_limit {
|
|
||||||
let raw_limit = self.volume_rate.map_err(|error| error.to_string())?
|
|
||||||
.remaining(available_market_volume, u64::from(consumed_turnover), requested_qty);
|
|
||||||
if raw_limit == 0 {
|
|
||||||
return Err(volume_limit_reason.to_string());
|
|
||||||
}
|
|
||||||
let volume_limited = if side == OrderSide::Sell && allow_odd_lot_sell {
|
|
||||||
raw_limit
|
|
||||||
} else {
|
|
||||||
self.round_buy_quantity(raw_limit, minimum_order_quantity, order_step_size)
|
|
||||||
};
|
|
||||||
if volume_limited == 0 {
|
|
||||||
return Err(volume_limit_reason.to_string());
|
|
||||||
}
|
|
||||||
max_fill = max_fill.min(volume_limited);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(max_fill)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn price_satisfies_limit(
|
fn price_satisfies_limit(
|
||||||
@@ -7551,7 +7589,7 @@ where
|
|||||||
.map(|start_time| date.and_time(start_time))
|
.map(|start_time| date.and_time(start_time))
|
||||||
});
|
});
|
||||||
let start_cursor = if let Some(origin) = self.runtime_resting_order_origin.get()
|
let start_cursor = if let Some(origin) = self.runtime_resting_order_origin.get()
|
||||||
&& origin.created_date == Some(date)
|
&& origin.accepted_date == date
|
||||||
&& let Some(submitted) = origin.submission_time
|
&& let Some(submitted) = origin.submission_time
|
||||||
{
|
{
|
||||||
Some(start_cursor.map_or(date.and_time(submitted), |cursor| cursor.max(date.and_time(submitted))))
|
Some(start_cursor.map_or(date.and_time(submitted), |cursor| cursor.max(date.and_time(submitted))))
|
||||||
@@ -7832,7 +7870,7 @@ where
|
|||||||
} else {
|
} else {
|
||||||
remaining_qty
|
remaining_qty
|
||||||
};
|
};
|
||||||
if self.volume_limit {
|
if self.volume_limit && self.volume_capacity_mode.limits_execution_quantity() {
|
||||||
let consumed = execution_ledger
|
let consumed = execution_ledger
|
||||||
.volume_consumed(symbol, quote.timestamp)
|
.volume_consumed(symbol, quote.timestamp)
|
||||||
.saturating_add(
|
.saturating_add(
|
||||||
@@ -7869,7 +7907,7 @@ where
|
|||||||
} else {
|
} else {
|
||||||
remaining_qty.min(available_qty)
|
remaining_qty.min(available_qty)
|
||||||
};
|
};
|
||||||
if !(side == OrderSide::Sell && allow_odd_lot_sell && take_qty == remaining_qty) {
|
if !(side == OrderSide::Sell && allow_odd_lot_sell) {
|
||||||
take_qty =
|
take_qty =
|
||||||
self.round_buy_quantity(take_qty, minimum_order_quantity, order_step_size);
|
self.round_buy_quantity(take_qty, minimum_order_quantity, order_step_size);
|
||||||
}
|
}
|
||||||
@@ -7980,7 +8018,7 @@ where
|
|||||||
.saturating_add(take_qty)
|
.saturating_add(take_qty)
|
||||||
.min(state.displayed_quantity);
|
.min(state.displayed_quantity);
|
||||||
}
|
}
|
||||||
if self.volume_limit {
|
if self.volume_limit && self.volume_capacity_mode.limits_execution_quantity() {
|
||||||
let consumed = pending_volume_consumption
|
let consumed = pending_volume_consumption
|
||||||
.entry(quote.timestamp)
|
.entry(quote.timestamp)
|
||||||
.or_default();
|
.or_default();
|
||||||
@@ -7994,7 +8032,7 @@ where
|
|||||||
depth_price_bits,
|
depth_price_bits,
|
||||||
displayed_quantity,
|
displayed_quantity,
|
||||||
consume_depth,
|
consume_depth,
|
||||||
consume_volume: self.volume_limit,
|
consume_volume: self.volume_limit && self.volume_capacity_mode.limits_execution_quantity(),
|
||||||
quantity: take_qty,
|
quantity: take_qty,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -8071,6 +8109,8 @@ where
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn matching_type_uses_intraday_quotes(&self) -> bool {
|
pub(crate) fn matching_type_uses_intraday_quotes(&self) -> bool {
|
||||||
|
if self.runtime_etf_daily_open.get() { return false; }
|
||||||
|
if self.resting_daily_open_order() { return true; }
|
||||||
matches!(
|
matches!(
|
||||||
self.matching_type,
|
self.matching_type,
|
||||||
MatchingType::MinuteLast
|
MatchingType::MinuteLast
|
||||||
@@ -8082,6 +8122,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
|
||||||
@@ -8214,6 +8258,7 @@ mod tests {
|
|||||||
decision_date: None,
|
decision_date: None,
|
||||||
order_created_date: None,
|
order_created_date: None,
|
||||||
submission_time: None,
|
submission_time: None,
|
||||||
|
accepted_date: chrono::NaiveDate::from_ymd_opt(2025,1,2).unwrap(),
|
||||||
symbol: "000001.SZ".to_string(),
|
symbol: "000001.SZ".to_string(),
|
||||||
side: OrderSide::Buy,
|
side: OrderSide::Buy,
|
||||||
requested_quantity: 200,
|
requested_quantity: 200,
|
||||||
@@ -8542,6 +8587,7 @@ mod tests {
|
|||||||
vec![dated_limit_test_benchmark(first), dated_limit_test_benchmark(second)],
|
vec![dated_limit_test_benchmark(first), dated_limit_test_benchmark(second)],
|
||||||
).unwrap();
|
).unwrap();
|
||||||
let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
|
let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
|
||||||
|
.with_volume_capacity_mode(crate::execution_capacity::VolumeCapacityMode::SessionCapacityAudit)
|
||||||
.with_matching_type(MatchingType::CurrentBarClose);
|
.with_matching_type(MatchingType::CurrentBarClose);
|
||||||
let mut portfolio = PortfolioState::new(100_000.0);
|
let mut portfolio = PortfolioState::new(100_000.0);
|
||||||
broker.execute(first, &mut portfolio, &data, &next_open_buy_decision()).unwrap();
|
broker.execute(first, &mut portfolio, &data, &next_open_buy_decision()).unwrap();
|
||||||
@@ -8568,6 +8614,7 @@ mod tests {
|
|||||||
let data = DataSet::from_components(vec![limit_test_instrument()], vec![limit_test_snapshot()],
|
let data = DataSet::from_components(vec![limit_test_instrument()], vec![limit_test_snapshot()],
|
||||||
Vec::new(), vec![limit_test_candidate(true, true)], vec![limit_test_benchmark()]).unwrap();
|
Vec::new(), vec![limit_test_candidate(true, true)], vec![limit_test_benchmark()]).unwrap();
|
||||||
let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
|
let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
|
||||||
|
.with_volume_capacity_mode(crate::execution_capacity::VolumeCapacityMode::SessionCapacityAudit)
|
||||||
.with_matching_type(MatchingType::CurrentBarClose);
|
.with_matching_type(MatchingType::CurrentBarClose);
|
||||||
broker.upsert_open_order(test_open_order(99));
|
broker.upsert_open_order(test_open_order(99));
|
||||||
let mut decision = StrategyDecision::default();
|
let mut decision = StrategyDecision::default();
|
||||||
@@ -8595,6 +8642,7 @@ mod tests {
|
|||||||
dated_limit_test_candidate(second, false, false, true, true)],
|
dated_limit_test_candidate(second, false, false, true, true)],
|
||||||
vec![dated_limit_test_benchmark(first), dated_limit_test_benchmark(second)]).unwrap();
|
vec![dated_limit_test_benchmark(first), dated_limit_test_benchmark(second)]).unwrap();
|
||||||
let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
|
let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
|
||||||
|
.with_volume_capacity_mode(crate::execution_capacity::VolumeCapacityMode::SessionCapacityAudit)
|
||||||
.with_matching_type(MatchingType::NextBarOpen);
|
.with_matching_type(MatchingType::NextBarOpen);
|
||||||
let mut portfolio = PortfolioState::new(1_000_000.0);
|
let mut portfolio = PortfolioState::new(1_000_000.0);
|
||||||
let mut initial = StrategyDecision::default();
|
let mut initial = StrategyDecision::default();
|
||||||
@@ -8791,6 +8839,30 @@ mod tests {
|
|||||||
assert!(broker.runtime_resting_order_origin.get().is_none());
|
assert!(broker.runtime_resting_order_origin.get().is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn next_open_day_limit_remainder_matches_intraday_with_execution_day_ttl() {
|
||||||
|
let date=chrono::NaiveDate::from_ymd_opt(2026,7,7).unwrap();
|
||||||
|
let signal=date.pred_opt().unwrap();
|
||||||
|
let mut snapshot=dated_limit_test_snapshot(date);
|
||||||
|
snapshot.open=10.2;snapshot.close=9.8;snapshot.last_price=9.8;snapshot.upper_limit=20.;snapshot.lower_limit=1.;
|
||||||
|
let mut quote=limit_test_quote(9.8,9.8,9.8);quote.date=date;quote.timestamp=date.and_hms_opt(10,0,0).unwrap();
|
||||||
|
let data=DataSet::from_components_with_actions_and_quotes(vec![limit_test_instrument()],vec![snapshot],Vec::new(),
|
||||||
|
vec![dated_limit_test_candidate(date,false,false,true,true)],vec![dated_limit_test_benchmark(date)],Vec::new(),vec![quote]).unwrap();
|
||||||
|
let broker=BrokerSimulator::new(ChinaAShareCostModel::default(),ChinaEquityRuleHooks)
|
||||||
|
.with_matching_type(MatchingType::NextBarOpen).with_volume_limit(false).with_liquidity_limit(false);
|
||||||
|
let mut portfolio=PortfolioState::new(100000.);
|
||||||
|
let decision=StrategyDecision{order_intents:vec![OrderIntent::LimitTargetShares{symbol:"000001.SZ".into(),target_quantity:100,limit_price:10.,reason:"next-open-entry".into()}],..StrategyDecision::default()};
|
||||||
|
let report=broker.execute_with_event_dates(date,signal,signal,&mut portfolio,&data,&decision).unwrap();
|
||||||
|
assert!(report.fill_events.is_empty());assert!(broker.has_open_orders());
|
||||||
|
let report=broker.execute_between_with_event_dates(date,signal,signal,&mut portfolio,&data,&StrategyDecision::default(),
|
||||||
|
NaiveTime::from_hms_opt(10,0,0),NaiveTime::from_hms_opt(10,0,0)).unwrap();
|
||||||
|
assert_eq!(report.fill_events.len(),1,"{report:?}");
|
||||||
|
assert_eq!(report.fill_events[0].price,9.8);
|
||||||
|
assert_eq!(report.fill_events[0].execution_timestamp,date.and_hms_opt(10,0,0));
|
||||||
|
assert_eq!(report.fill_events[0].order_created_date,Some(signal));
|
||||||
|
assert!(!broker.has_open_orders());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn post_close_order_uses_close_without_slippage_and_waits_until_matching_window() {
|
fn post_close_order_uses_close_without_slippage_and_waits_until_matching_window() {
|
||||||
let date = chrono::NaiveDate::from_ymd_opt(2026, 7, 6).expect("valid date");
|
let date = chrono::NaiveDate::from_ymd_opt(2026, 7, 6).expect("valid date");
|
||||||
@@ -9593,7 +9665,42 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn current_bar_close_volume_limit_uses_daily_volume_when_minute_volume_missing() {
|
fn daily_session_volume_changes_only_audit_not_opening_fills() {
|
||||||
|
use crate::execution_capacity::VolumeCapacityMode;
|
||||||
|
let run = |volume: u64, mode: VolumeCapacityMode| {
|
||||||
|
let mut market = limit_test_snapshot();
|
||||||
|
market.volume = volume;
|
||||||
|
let date = market.date;
|
||||||
|
let data = DataSet::from_components_with_actions_and_quotes(
|
||||||
|
vec![limit_test_instrument()], vec![market], vec![],
|
||||||
|
vec![limit_test_candidate(true, true)], vec![limit_test_benchmark()], vec![], vec![],
|
||||||
|
).unwrap();
|
||||||
|
let mut portfolio = PortfolioState::new(100_000.0);
|
||||||
|
let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
|
||||||
|
.with_volume_capacity_mode(mode).with_liquidity_limit(false);
|
||||||
|
let decision = StrategyDecision { order_intents: vec![OrderIntent::Shares {
|
||||||
|
symbol: "000001.SZ".into(), quantity: 1_000, reason: "capacity_test".into(),
|
||||||
|
}], ..StrategyDecision::default() };
|
||||||
|
let before = portfolio.cash();
|
||||||
|
let outcome = broker.execute(date, &mut portfolio, &data, &decision);
|
||||||
|
if outcome.is_err() { assert_eq!(portfolio.cash(), before); }
|
||||||
|
let audit = broker.audit_completed_session_capacity(date, &data).unwrap();
|
||||||
|
(outcome, portfolio.cash(), audit)
|
||||||
|
};
|
||||||
|
let (strict, _, _) = run(1_000_000, VolumeCapacityMode::ExecutionObservation);
|
||||||
|
assert!(strict.unwrap_err().to_string().contains("execution-time capacity is missing"));
|
||||||
|
let (a, cash_a, audit_a) = run(100, VolumeCapacityMode::SessionCapacityAudit);
|
||||||
|
let (b, cash_b, audit_b) = run(1_000_000, VolumeCapacityMode::SessionCapacityAudit);
|
||||||
|
let a = a.unwrap(); let b = b.unwrap();
|
||||||
|
assert_eq!(a.fill_events.len(), 1);
|
||||||
|
assert_eq!(serde_json::to_value(&a.fill_events).unwrap(), serde_json::to_value(&b.fill_events).unwrap());
|
||||||
|
assert_eq!(cash_a, cash_b);
|
||||||
|
assert_eq!(audit_a[0].filled_shares, 1_000);
|
||||||
|
assert!(!audit_a[0].passed); assert!(audit_b[0].passed);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn daily_capacity_requires_a_timed_observation_instead_of_falling_back_to_total_volume() {
|
||||||
let mut snapshot = limit_test_snapshot();
|
let mut snapshot = limit_test_snapshot();
|
||||||
snapshot.minute_volume = 0;
|
snapshot.minute_volume = 0;
|
||||||
snapshot.volume = 1_000_000;
|
snapshot.volume = 1_000_000;
|
||||||
@@ -9609,13 +9716,13 @@ mod tests {
|
|||||||
.with_liquidity_limit(true);
|
.with_liquidity_limit(true);
|
||||||
|
|
||||||
let fillable =
|
let fillable =
|
||||||
broker.market_fillable_quantity(&snapshot, OrderSide::Buy, 5_000, 100, 100, 0, false);
|
broker.market_fillable_quantity(&snapshot, 5_000, false);
|
||||||
|
|
||||||
assert_eq!(fillable, Ok(5_000));
|
assert!(fillable.unwrap_err().contains("daily session volume cannot size an earlier fill"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn volume_limit_uses_floor_for_odd_lot_sell() {
|
fn session_capacity_audit_never_caps_an_early_odd_lot_sell() {
|
||||||
let mut snapshot = limit_test_snapshot();
|
let mut snapshot = limit_test_snapshot();
|
||||||
snapshot.minute_volume = 0;
|
snapshot.minute_volume = 0;
|
||||||
snapshot.volume = 3;
|
snapshot.volume = 3;
|
||||||
@@ -9625,18 +9732,19 @@ mod tests {
|
|||||||
PriceField::Close,
|
PriceField::Close,
|
||||||
)
|
)
|
||||||
.with_matching_type(MatchingType::CurrentBarClose)
|
.with_matching_type(MatchingType::CurrentBarClose)
|
||||||
|
.with_volume_capacity_mode(crate::execution_capacity::VolumeCapacityMode::SessionCapacityAudit)
|
||||||
.with_volume_limit(true)
|
.with_volume_limit(true)
|
||||||
.with_volume_percent(0.5)
|
.with_volume_percent(0.5)
|
||||||
.with_liquidity_limit(false);
|
.with_liquidity_limit(false);
|
||||||
|
|
||||||
let fillable =
|
let fillable =
|
||||||
broker.market_fillable_quantity(&snapshot, OrderSide::Sell, 10, 100, 100, 0, true);
|
broker.market_fillable_quantity(&snapshot, 10, false);
|
||||||
|
|
||||||
assert_eq!(fillable, Ok(1));
|
assert_eq!(fillable, Ok(10));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn current_bar_close_volume_limit_rejects_daily_zero_volume() {
|
fn session_audit_does_not_infer_an_opening_suspension_from_future_zero_volume() {
|
||||||
let mut snapshot = limit_test_snapshot();
|
let mut snapshot = limit_test_snapshot();
|
||||||
snapshot.minute_volume = 0;
|
snapshot.minute_volume = 0;
|
||||||
snapshot.volume = 0;
|
snapshot.volume = 0;
|
||||||
@@ -9646,13 +9754,16 @@ mod tests {
|
|||||||
PriceField::Close,
|
PriceField::Close,
|
||||||
)
|
)
|
||||||
.with_matching_type(MatchingType::CurrentBarClose)
|
.with_matching_type(MatchingType::CurrentBarClose)
|
||||||
|
.with_volume_capacity_mode(crate::execution_capacity::VolumeCapacityMode::SessionCapacityAudit)
|
||||||
.with_volume_limit(true)
|
.with_volume_limit(true)
|
||||||
.with_liquidity_limit(false);
|
.with_liquidity_limit(false);
|
||||||
|
|
||||||
let fillable =
|
let fillable =
|
||||||
broker.market_fillable_quantity(&snapshot, OrderSide::Buy, 5_000, 100, 100, 0, false);
|
broker.market_fillable_quantity(&snapshot, 5_000, false);
|
||||||
|
|
||||||
assert_eq!(fillable, Err("daily no volume".to_string()));
|
assert_eq!(fillable, Ok(5_000));
|
||||||
|
snapshot.paused = true;
|
||||||
|
assert_eq!(broker.market_fillable_quantity(&snapshot, 5_000, false), Err("paused".into()));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -9673,7 +9784,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));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ fn decimal(value: f64, label: &str) -> Result<Decimal, BacktestError> {
|
|||||||
.map_err(|_| BacktestError::Execution(format!("stock_pool_decimal_range_{label}")))
|
.map_err(|_| BacktestError::Execution(format!("stock_pool_decimal_range_{label}")))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn etf_activity(report:&mut BrokerExecutionReport,date:NaiveDate,symbol:&str,side:pool::OrderSide,detail:String) {
|
||||||
|
report.process_events.push(ProcessEvent {date,kind:ProcessEventKind::EtfExecutionFallback,order_id:None,
|
||||||
|
symbol:Some(symbol.into()),side:Some(if side==pool::OrderSide::Buy {OrderSide::Buy} else {OrderSide::Sell}),detail});
|
||||||
|
}
|
||||||
|
|
||||||
fn pool_positions(
|
fn pool_positions(
|
||||||
portfolio: &PortfolioState,
|
portfolio: &PortfolioState,
|
||||||
date: NaiveDate,
|
date: NaiveDate,
|
||||||
@@ -54,7 +59,13 @@ impl<C: CostModel, R: EquityRuleHooks> BrokerSimulator<C, R> {
|
|||||||
let instrument = data.instruments().get(symbol).ok_or_else(|| {
|
let instrument = data.instruments().get(symbol).ok_or_else(|| {
|
||||||
BacktestError::Execution(format!("stock_pool_instrument_missing:{symbol}"))
|
BacktestError::Execution(format!("stock_pool_instrument_missing:{symbol}"))
|
||||||
})?;
|
})?;
|
||||||
let (price, prev, volume, amount, bid, ask, buy_price, sell_price) = if self
|
let fallback = self.pool_etf_fallback_reference(date, data, symbol, execution_clock)?;
|
||||||
|
let (price, prev, volume, amount, bid, ask, buy_price, sell_price) = if let Some(reference) = fallback {
|
||||||
|
let calibration = self.slippage_calibration(data, snapshot)?;
|
||||||
|
(reference.price, snapshot.prev_close, None, None, None, None,
|
||||||
|
self.quote_execution_price(snapshot, OrderSide::Buy, reference.price, None, calibration.as_ref())?,
|
||||||
|
self.quote_execution_price(snapshot, OrderSide::Sell, reference.price, None, calibration.as_ref())?)
|
||||||
|
} else if self
|
||||||
.matching_type_uses_intraday_quotes()
|
.matching_type_uses_intraday_quotes()
|
||||||
{
|
{
|
||||||
let time = self
|
let time = self
|
||||||
@@ -174,6 +185,16 @@ impl<C: CostModel, R: EquityRuleHooks> BrokerSimulator<C, R> {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn pool_etf_fallback_reference(&self, date: NaiveDate, data: &DataSet, symbol: &str, clock: Option<NaiveDateTime>) -> Result<Option<crate::etf_execution::EtfFallbackReference>, BacktestError> {
|
||||||
|
if !self.matching_type_uses_intraday_quotes() || !self.has_verified_etf_minute_absence(date, symbol) {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let time = self.runtime_intraday_start_time.get().or(self.intraday_execution_start_time)
|
||||||
|
.ok_or_else(|| BacktestError::Execution("etf_daily_open_fallback: execution clock missing".into()))?;
|
||||||
|
let at = clock.unwrap_or(date.and_time(time)).max(date.and_time(time));
|
||||||
|
crate::etf_execution::reference(data, symbol, at).map(Some)
|
||||||
|
}
|
||||||
|
|
||||||
pub(super) fn process_stock_pool_contract(
|
pub(super) fn process_stock_pool_contract(
|
||||||
&self,
|
&self,
|
||||||
date: NaiveDate,
|
date: NaiveDate,
|
||||||
@@ -240,6 +261,8 @@ impl<C: CostModel, R: EquityRuleHooks> BrokerSimulator<C, R> {
|
|||||||
portfolio
|
portfolio
|
||||||
.set_stock_pool_execution_state(&contract.pool_id, state)
|
.set_stock_pool_execution_state(&contract.pool_id, state)
|
||||||
.map_err(BacktestError::Execution)?;
|
.map_err(BacktestError::Execution)?;
|
||||||
|
let superseded = self.deferred_etf_targets.borrow_mut().replace_generation(&contract.pool_id, &contract.generation);
|
||||||
|
if superseded > 0 { report.diagnostics.push(format!("etf_daily_open_fallback:superseded pool={} generation={} targets={superseded}", contract.pool_id, contract.generation)); }
|
||||||
if self.has_open_orders() {
|
if self.has_open_orders() {
|
||||||
report
|
report
|
||||||
.diagnostics
|
.diagnostics
|
||||||
@@ -291,7 +314,20 @@ impl<C: CostModel, R: EquityRuleHooks> BrokerSimulator<C, R> {
|
|||||||
.keys()
|
.keys()
|
||||||
.cloned()
|
.cloned()
|
||||||
.collect::<BTreeSet<_>>();
|
.collect::<BTreeSet<_>>();
|
||||||
|
// All delayed symbols in a generation share immutable configuration.
|
||||||
|
// Do not duplicate an N-member pool N times in a large mixed pool.
|
||||||
|
let mut deferred_configuration = None;
|
||||||
for side in [pool::OrderSide::Sell, pool::OrderSide::Buy] {
|
for side in [pool::OrderSide::Sell, pool::OrderSide::Buy] {
|
||||||
|
let mut fallback_references = BTreeMap::new();
|
||||||
|
for symbol in "e_scope {
|
||||||
|
if let Some(reference) = self.pool_etf_fallback_reference(date, data, symbol, *global_execution_cursor)? {
|
||||||
|
let condition = if side == pool::OrderSide::Buy { &contract.rule.buy_condition } else { &contract.rule.sell_condition };
|
||||||
|
if !condition.trim().is_empty() {
|
||||||
|
return Err(BacktestError::Execution(format!("etf_daily_open_fallback: intraday condition evidence unavailable symbol={symbol} side={side:?}; daily reference is not a minute or tick signal")));
|
||||||
|
}
|
||||||
|
fallback_references.insert(symbol.clone(), reference);
|
||||||
|
}
|
||||||
|
}
|
||||||
let quotes =
|
let quotes =
|
||||||
self.pool_quote_inputs(date, data, "e_scope, *global_execution_cursor)?;
|
self.pool_quote_inputs(date, data, "e_scope, *global_execution_cursor)?;
|
||||||
let positions = pool_positions(portfolio, date)?;
|
let positions = pool_positions(portfolio, date)?;
|
||||||
@@ -340,6 +376,17 @@ impl<C: CostModel, R: EquityRuleHooks> BrokerSimulator<C, R> {
|
|||||||
.insert(symbol.clone(), permission);
|
.insert(symbol.clone(), permission);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if side == pool::OrderSide::Buy {
|
||||||
|
for (symbol, reference) in &fallback_references {
|
||||||
|
if !reference.immediate {
|
||||||
|
// The sell leg was queued, not filled. Keep its real
|
||||||
|
// holdings/slots and do not finance buys with proceeds
|
||||||
|
// from the following session.
|
||||||
|
constraints.automatic_permissions.entry(symbol.clone()).or_default()
|
||||||
|
.sell_denial.get_or_insert("etf_daily_open_deferred");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if self
|
if self
|
||||||
.risk_config
|
.risk_config
|
||||||
.static_rules
|
.static_rules
|
||||||
@@ -401,9 +448,15 @@ impl<C: CostModel, R: EquityRuleHooks> BrokerSimulator<C, R> {
|
|||||||
Some(&fee),
|
Some(&fee),
|
||||||
)
|
)
|
||||||
.map_err(BacktestError::Execution)?;
|
.map_err(BacktestError::Execution)?;
|
||||||
let updated = execution_state
|
let mut updated = execution_state
|
||||||
.record_plan(contract.signal_date, &contract.generation, &plan)
|
.record_plan(contract.signal_date, &contract.generation, &plan)
|
||||||
.map_err(BacktestError::Execution)?;
|
.map_err(BacktestError::Execution)?;
|
||||||
|
for (symbol, reference) in &fallback_references {
|
||||||
|
if !reference.immediate && let Some(entry) = updated.entries.get_mut(symbol) {
|
||||||
|
// The signal only fixes money, not shares at a stale close.
|
||||||
|
entry.completion_quantity = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
portfolio
|
portfolio
|
||||||
.set_stock_pool_execution_state(&contract.pool_id, updated)
|
.set_stock_pool_execution_state(&contract.pool_id, updated)
|
||||||
.map_err(BacktestError::Execution)?;
|
.map_err(BacktestError::Execution)?;
|
||||||
@@ -425,6 +478,26 @@ impl<C: CostModel, R: EquityRuleHooks> BrokerSimulator<C, R> {
|
|||||||
if row.side != Some(side) {
|
if row.side != Some(side) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if let Some(reference) = fallback_references.get(&row.symbol) {
|
||||||
|
let time = self.runtime_intraday_start_time.get().or(self.intraday_execution_start_time).expect("fallback clock validated");
|
||||||
|
let at = global_execution_cursor.unwrap_or(date.and_time(time)).max(date.and_time(time));
|
||||||
|
if !reference.immediate {
|
||||||
|
report.diagnostics.push(format!("etf_daily_open_fallback:deferred symbol={} signal_at={at} reference_date={} reference_price={} target_value={} execute_on={:?}", row.symbol, reference.reference_date, reference.price, row.target_value, reference.execute_on));
|
||||||
|
let deferred = deferred_configuration.get_or_insert_with(|| (
|
||||||
|
std::sync::Arc::new(contract.rule.clone()), std::sync::Arc::new(members.clone()),
|
||||||
|
));
|
||||||
|
let opening_date=reference.execute_on.map(|day|day.to_string()).unwrap_or_else(||"回测区间外(后续日历未加载)".into());
|
||||||
|
etf_activity(report,date,&row.symbol,side,format!("ETF 顺延执行:信号 {at},参考 {} 收盘 {},目标金额 {},下一正式开盘日 {opening_date};未生成成交。",reference.reference_date,reference.price,row.target_value));
|
||||||
|
self.deferred_etf_targets.borrow_mut().upsert(crate::etf_execution::DeferredEtfTarget {
|
||||||
|
pool_id:contract.pool_id.clone(), generation:contract.generation.clone(), symbol:row.symbol.clone(),
|
||||||
|
signal_date:contract.signal_date, signal_at:at, execute_on:reference.execute_on,
|
||||||
|
target_value:row.target_value, target_weight_bps:row.target_weight_bps, side,
|
||||||
|
max_positions, rule:std::sync::Arc::clone(&deferred.0), members:std::sync::Arc::clone(&deferred.1),
|
||||||
|
reason:row.source_intent.clone().unwrap_or_else(||"stock_pool_target".into()),
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
if side == pool::OrderSide::Buy
|
if side == pool::OrderSide::Buy
|
||||||
&& portfolio
|
&& portfolio
|
||||||
.position(&row.symbol)
|
.position(&row.symbol)
|
||||||
@@ -440,8 +513,14 @@ impl<C: CostModel, R: EquityRuleHooks> BrokerSimulator<C, R> {
|
|||||||
let target = row.target_quantity.to_i32().ok_or_else(|| {
|
let target = row.target_quantity.to_i32().ok_or_else(|| {
|
||||||
BacktestError::Execution("stock_pool_target_quantity_out_of_range".into())
|
BacktestError::Execution("stock_pool_target_quantity_out_of_range".into())
|
||||||
})?;
|
})?;
|
||||||
let reason = row.source_intent.as_deref().unwrap_or("stock_pool_target");
|
let fallback_reason = fallback_references.contains_key(&row.symbol).then(|| format!("{}: etf_daily_open_fallback signal_date={} execution_date={date}", row.source_intent.as_deref().unwrap_or("stock_pool_target"), contract.signal_date));
|
||||||
if let Some(price) = row.limit_price {
|
let reason = fallback_reason.as_deref().unwrap_or_else(|| row.source_intent.as_deref().unwrap_or("stock_pool_target"));
|
||||||
|
let first_fill = report.fill_events.len();
|
||||||
|
if fallback_references.contains_key(&row.symbol) {
|
||||||
|
report.diagnostics.push(format!("etf_daily_open_fallback:opening symbol={} signal_date={} execution_date={date}", row.symbol, contract.signal_date));
|
||||||
|
etf_activity(report,date,&row.symbol,side,format!("ETF 日线开盘回退:信号日 {},执行日 {date},使用正式日线开盘价;不是分钟成交行情。",contract.signal_date));
|
||||||
|
}
|
||||||
|
let mut execute = || if let Some(price) = row.limit_price {
|
||||||
self.process_limit_target_shares(
|
self.process_limit_target_shares(
|
||||||
date,
|
date,
|
||||||
portfolio,
|
portfolio,
|
||||||
@@ -457,7 +536,7 @@ impl<C: CostModel, R: EquityRuleHooks> BrokerSimulator<C, R> {
|
|||||||
global_execution_cursor,
|
global_execution_cursor,
|
||||||
commission_state,
|
commission_state,
|
||||||
report,
|
report,
|
||||||
)?;
|
)
|
||||||
} else {
|
} else {
|
||||||
self.process_target_shares(
|
self.process_target_shares(
|
||||||
date,
|
date,
|
||||||
@@ -471,10 +550,124 @@ impl<C: CostModel, R: EquityRuleHooks> BrokerSimulator<C, R> {
|
|||||||
global_execution_cursor,
|
global_execution_cursor,
|
||||||
commission_state,
|
commission_state,
|
||||||
report,
|
report,
|
||||||
)?;
|
)
|
||||||
}
|
};
|
||||||
|
if fallback_references.contains_key(&row.symbol) {
|
||||||
|
self.with_etf_daily_open(execute)?;
|
||||||
|
for fill in &mut report.fill_events[first_fill..] {
|
||||||
|
fill.execution_start_timestamp.get_or_insert(date.and_time(crate::etf_execution::opening_time()));
|
||||||
|
fill.execution_timestamp.get_or_insert(date.and_time(crate::etf_execution::opening_time()));
|
||||||
|
}
|
||||||
|
} else { execute()?; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn pending_etf_target_count(&self) -> usize {
|
||||||
|
self.deferred_etf_targets.borrow().len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Called at the opening clock, after settlement/corporate actions and
|
||||||
|
/// auction callbacks. It never sends a stock order or replays a strategy.
|
||||||
|
pub(crate) fn execute_deferred_etf_targets(&self, date: NaiveDate, portfolio: &mut PortfolioState, data: &DataSet) -> Result<BrokerExecutionReport, BacktestError> {
|
||||||
|
let mut report = BrokerExecutionReport::default();
|
||||||
|
if self.has_open_orders() {
|
||||||
|
if self.pending_etf_target_count() > 0 {
|
||||||
|
report.diagnostics.push("etf_daily_open_fallback:waiting_for_active_orders".into());
|
||||||
|
}
|
||||||
|
return Ok(report);
|
||||||
|
}
|
||||||
|
let due = self.deferred_etf_targets.borrow_mut().take_due(date);
|
||||||
|
let dates = data.calendar().iter().collect::<Vec<_>>();
|
||||||
|
for target in due {
|
||||||
|
let instrument = data.instrument(&target.symbol).ok_or_else(|| BacktestError::Execution("etf_daily_open_fallback: instrument identity missing at execution".into()))?;
|
||||||
|
if !instrument.is_exchange_traded_fund() { return Err(BacktestError::Execution("etf_daily_open_fallback: instrument identity changed".into())); }
|
||||||
|
if let Some(reason) = instrument.dated_market_absence_reason(date) {
|
||||||
|
report.diagnostics.push(format!("etf_daily_open_fallback:blocked symbol={} date={date} reason={reason}", target.symbol));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let snapshot = data.market(date, &target.symbol).ok_or_else(|| BacktestError::Execution(format!("etf_daily_open_fallback: daily_open_missing symbol={} date={date}", target.symbol)))?;
|
||||||
|
if !snapshot.open.is_finite() || snapshot.open <= 0.0 {
|
||||||
|
return Err(BacktestError::Execution(format!("etf_daily_open_fallback: daily_open_invalid symbol={} date={date}", target.symbol)));
|
||||||
|
}
|
||||||
|
let position = portfolio.position(&target.symbol).filter(|p| p.quantity > 0);
|
||||||
|
let before_quantity = position.map_or(0, |p| p.quantity);
|
||||||
|
let permission = target.rule.automatic_trade_protection.evaluate(&target.symbol, date, &HoldingLifecycleEvidence {
|
||||||
|
has_position:position.is_some(), opened_date:position.and_then(|p| p.opened_date()), last_buy_date:position.and_then(|p| p.last_buy_date()),
|
||||||
|
last_sell_date:self.same_day_sold_symbols.borrow().iter().rev().find(|(day, symbols)| **day <= date && symbols.contains(&target.symbol)).map(|(day, _)| *day),
|
||||||
|
}, data.calendar()).map_err(BacktestError::Execution)?;
|
||||||
|
let denial = if target.side == pool::OrderSide::Buy {
|
||||||
|
permission.buy_denial.or(permission.max_holding_exit.then_some("max_holding_exit_pending"))
|
||||||
|
} else { permission.sell_denial };
|
||||||
|
if let Some(denial) = denial {
|
||||||
|
report.diagnostics.push(format!("etf_daily_open_fallback:protected symbol={} date={date} reason={denial}", target.symbol));
|
||||||
|
etf_activity(&mut report,date,&target.symbol,target.side,format!("ETF 顺延目标受持有保护限制:{denial};未提交委托。"));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if target.side == pool::OrderSide::Buy && before_quantity == 0 && Self::positive_position_count(portfolio) >= target.max_positions {
|
||||||
|
report.diagnostics.push(format!("etf_daily_open_fallback:blocked symbol={} reason=occupied_position_slots", target.symbol));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let value = target.target_value.to_f64().ok_or_else(|| BacktestError::Execution("etf_daily_open_fallback: target value out of range".into()))?;
|
||||||
|
let current_value = snapshot.open * f64::from(before_quantity);
|
||||||
|
let satisfied = (target.side == pool::OrderSide::Buy && value <= current_value)
|
||||||
|
|| (target.side == pool::OrderSide::Sell && value >= current_value);
|
||||||
|
let reason = format!("{}: etf_daily_open_fallback signal_at={} execution_at={} target_value={}", target.reason, target.signal_at, date.and_time(crate::etf_execution::opening_time()), target.target_value);
|
||||||
|
let mut sub = BrokerExecutionReport::default();
|
||||||
|
if !satisfied {
|
||||||
|
let (_, limit) = pool::resolve_stock_pool_order_price(&target.rule, &target.symbol, decimal(snapshot.open, "etf_open")?, target.side, decimal(snapshot.price_tick, "etf_tick")?).map_err(BacktestError::Execution)?;
|
||||||
|
let intent = match limit {
|
||||||
|
Some(limit) => OrderIntent::LimitTargetValue { symbol:target.symbol.clone(), target_value:value, limit_price:limit.to_f64().ok_or_else(|| BacktestError::Execution("ETF limit out of range".into()))?, reason:reason.clone() },
|
||||||
|
None => OrderIntent::TargetValue { symbol:target.symbol.clone(), target_value:value, reason:reason.clone() },
|
||||||
|
};
|
||||||
|
let old_time = self.runtime_intraday_start_time.replace(Some(crate::etf_execution::opening_time()));
|
||||||
|
let old_origin = self.runtime_resting_order_origin.replace(Some(RestingOrderOrigin { created_date:Some(target.signal_at.date()), submission_time:Some(target.signal_at.time()), accepted_date:date }));
|
||||||
|
let outcome = self.with_etf_daily_open(|| self.execute_with_event_dates(date, target.signal_date, target.signal_at.date(), portfolio, data, &StrategyDecision {
|
||||||
|
order_intents:vec![OrderIntent::WithTimeInForce { intent:Box::new(intent), time_in_force:OrderTimeInForce::Day }], ..Default::default()
|
||||||
|
}));
|
||||||
|
self.runtime_intraday_start_time.set(old_time);
|
||||||
|
self.runtime_resting_order_origin.set(old_origin);
|
||||||
|
sub = outcome?;
|
||||||
|
}
|
||||||
|
// The actual open determines the full requested shares. A clipped
|
||||||
|
// or rejected execution must not be recorded as completed entry.
|
||||||
|
let order = sub.order_events.iter().rev().find(|order| order.symbol == target.symbol);
|
||||||
|
let goal_quantity = order.map_or(before_quantity, |order| match order.side {
|
||||||
|
OrderSide::Buy => before_quantity.saturating_add(order.requested_quantity),
|
||||||
|
OrderSide::Sell => before_quantity.saturating_sub(order.requested_quantity),
|
||||||
|
});
|
||||||
|
let status = if satisfied || (order.is_none() && !self.has_open_orders()) { "BELOW_MINIMUM_TRADE_UNIT_ALREADY_SATISFIED" } else { "READY" };
|
||||||
|
let positions = pool_positions(portfolio, date)?;
|
||||||
|
let state = portfolio.stock_pool_execution_state(&target.pool_id)
|
||||||
|
.observe(target.signal_date, date, &dates, &target.members, &positions).map_err(BacktestError::Execution)?
|
||||||
|
.record_targets(target.signal_date, &target.generation, [crate::stock_pool_state::StockPoolGoalObservation {
|
||||||
|
symbol:&target.symbol, target_weight_bps:target.target_weight_bps, target_value:target.target_value,
|
||||||
|
current_quantity:before_quantity.into(), target_quantity:goal_quantity.into(), status,
|
||||||
|
}]).map_err(BacktestError::Execution)?
|
||||||
|
.observe(target.signal_date, date, &dates, &target.members, &positions).map_err(BacktestError::Execution)?;
|
||||||
|
portfolio.set_stock_pool_execution_state(&target.pool_id, state).map_err(BacktestError::Execution)?;
|
||||||
|
for fill in &mut sub.fill_events {
|
||||||
|
fill.decision_date.get_or_insert(target.signal_date);
|
||||||
|
fill.order_created_date.get_or_insert(target.signal_at.date());
|
||||||
|
fill.execution_date.get_or_insert(date);
|
||||||
|
fill.execution_start_timestamp.get_or_insert(date.and_time(crate::etf_execution::opening_time()));
|
||||||
|
fill.execution_timestamp.get_or_insert(date.and_time(crate::etf_execution::opening_time()));
|
||||||
|
}
|
||||||
|
for order in &mut sub.order_events {
|
||||||
|
order.decision_date.get_or_insert(target.signal_date);
|
||||||
|
order.order_created_date.get_or_insert(target.signal_at.date());
|
||||||
|
order.execution_date.get_or_insert(date);
|
||||||
|
}
|
||||||
|
report.diagnostics.push(reason);
|
||||||
|
etf_activity(&mut report,date,&target.symbol,target.side,format!("ETF 顺延目标开盘处理:原信号 {},本次 {date} 09:30,冻结目标金额 {},持仓 {before_quantity} → {};按本日开盘价、资金与风控重新定量。",target.signal_at,target.target_value,portfolio.position(&target.symbol).map_or(0,|position|position.quantity)));
|
||||||
|
report.order_events.extend(sub.order_events);
|
||||||
|
report.fill_events.extend(sub.fill_events);
|
||||||
|
report.position_events.extend(sub.position_events);
|
||||||
|
report.account_events.extend(sub.account_events);
|
||||||
|
report.process_events.extend(sub.process_events);
|
||||||
|
report.diagnostics.extend(sub.diagnostics);
|
||||||
|
}
|
||||||
|
Ok(report)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>,
|
||||||
@@ -168,6 +169,8 @@ pub enum BacktestTerminalAssetClass {
|
|||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct BacktestTerminalAudit {
|
pub struct BacktestTerminalAudit {
|
||||||
|
#[serde(default, skip_serializing_if = "is_zero_count")]
|
||||||
|
pub deferred_etf_target_count: usize,
|
||||||
pub status: BacktestTerminalStatus,
|
pub status: BacktestTerminalStatus,
|
||||||
pub last_execution_date: Option<NaiveDate>,
|
pub last_execution_date: Option<NaiveDate>,
|
||||||
pub stock_open_order_count: usize,
|
pub stock_open_order_count: usize,
|
||||||
@@ -184,6 +187,7 @@ pub struct BacktestTerminalAudit {
|
|||||||
impl Default for BacktestTerminalAudit {
|
impl Default for BacktestTerminalAudit {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
|
deferred_etf_target_count: 0,
|
||||||
status: BacktestTerminalStatus::Clean,
|
status: BacktestTerminalStatus::Clean,
|
||||||
last_execution_date: None,
|
last_execution_date: None,
|
||||||
stock_open_order_count: 0,
|
stock_open_order_count: 0,
|
||||||
@@ -199,6 +203,8 @@ impl Default for BacktestTerminalAudit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn is_zero_count(value: &usize) -> bool { *value == 0 }
|
||||||
|
|
||||||
impl BacktestTerminalAudit {
|
impl BacktestTerminalAudit {
|
||||||
pub fn is_clean(&self) -> bool {
|
pub fn is_clean(&self) -> bool {
|
||||||
self.status == BacktestTerminalStatus::Clean
|
self.status == BacktestTerminalStatus::Clean
|
||||||
@@ -280,6 +286,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 +301,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
|
||||||
@@ -784,6 +792,17 @@ where
|
|||||||
end_time: Option<NaiveTime>,
|
end_time: Option<NaiveTime>,
|
||||||
symbols: &mut BTreeSet<String>,
|
symbols: &mut BTreeSet<String>,
|
||||||
) -> Result<(), BacktestError> {
|
) -> Result<(), BacktestError> {
|
||||||
|
// A missing point is not proof of an absent ETF minute dataset. Query
|
||||||
|
// its complete formal session first; loader/contract failures propagate.
|
||||||
|
if start_time.is_some() || end_time.is_some() {
|
||||||
|
let mut etfs = symbols.iter().filter(|symbol| {
|
||||||
|
self.broker.requires_etf_absence_check(&self.data, symbol)
|
||||||
|
&& !self.execution_quote_request_cache.contains(&(execution_date, (*symbol).clone(), None, None))
|
||||||
|
}).cloned().collect::<BTreeSet<_>>();
|
||||||
|
if !etfs.is_empty() {
|
||||||
|
self.load_missing_execution_quotes(execution_date, None, None, &mut etfs)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
let mut available = BTreeSet::new();
|
let mut available = BTreeSet::new();
|
||||||
for symbol in symbols.iter() {
|
for symbol in symbols.iter() {
|
||||||
let instrument = self.data.instrument(symbol).ok_or_else(|| BacktestError::Execution(format!(
|
let instrument = self.data.instrument(symbol).ok_or_else(|| BacktestError::Execution(format!(
|
||||||
@@ -810,6 +829,9 @@ where
|
|||||||
}
|
}
|
||||||
*symbols = available;
|
*symbols = available;
|
||||||
symbols.retain(|symbol| {
|
symbols.retain(|symbol| {
|
||||||
|
if (start_time.is_some() || end_time.is_some()) && self.broker.has_verified_etf_minute_absence(execution_date, symbol) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
let request_key = (execution_date, symbol.clone(), start_time, end_time);
|
let request_key = (execution_date, symbol.clone(), start_time, end_time);
|
||||||
if self.execution_quote_request_cache.contains(&request_key) {
|
if self.execution_quote_request_cache.contains(&request_key) {
|
||||||
return false;
|
return false;
|
||||||
@@ -854,6 +876,7 @@ where
|
|||||||
}
|
}
|
||||||
self.data.add_execution_quotes(quotes);
|
self.data.add_execution_quotes(quotes);
|
||||||
if start_time.is_none() && end_time.is_none() {
|
if start_time.is_none() && end_time.is_none() {
|
||||||
|
self.broker.record_complete_etf_minute_query(execution_date, &self.data, &requested_symbols);
|
||||||
self.validate_full_day_execution_quote_coverage(execution_date, &requested_symbols)?;
|
self.validate_full_day_execution_quote_coverage(execution_date, &requested_symbols)?;
|
||||||
}
|
}
|
||||||
for symbol in requested_symbols {
|
for symbol in requested_symbols {
|
||||||
@@ -890,7 +913,7 @@ where
|
|||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if market.volume > 0 && !has_quotes {
|
if market.volume > 0 && !has_quotes && !self.broker.has_verified_etf_minute_absence(execution_date, symbol) {
|
||||||
missing_active.push(symbol.clone());
|
missing_active.push(symbol.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1392,6 +1415,7 @@ where
|
|||||||
let status = if open_order_count == 0
|
let status = if open_order_count == 0
|
||||||
&& pending_cash_flow_count == 0
|
&& pending_cash_flow_count == 0
|
||||||
&& cash_receivable_count == 0
|
&& cash_receivable_count == 0
|
||||||
|
&& self.broker.pending_etf_target_count() == 0
|
||||||
{
|
{
|
||||||
BacktestTerminalStatus::Clean
|
BacktestTerminalStatus::Clean
|
||||||
} else {
|
} else {
|
||||||
@@ -1399,6 +1423,7 @@ where
|
|||||||
};
|
};
|
||||||
|
|
||||||
BacktestTerminalAudit {
|
BacktestTerminalAudit {
|
||||||
|
deferred_etf_target_count: self.broker.pending_etf_target_count(),
|
||||||
status,
|
status,
|
||||||
last_execution_date,
|
last_execution_date,
|
||||||
stock_open_order_count,
|
stock_open_order_count,
|
||||||
@@ -2102,6 +2127,7 @@ where
|
|||||||
.map(|(execution_date, _)| *execution_date)
|
.map(|(execution_date, _)| *execution_date)
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
let mut result = BacktestResult {
|
let mut result = BacktestResult {
|
||||||
|
capacity_audit: self.broker.capacity_audit_summary(),
|
||||||
strategy_name: self.strategy.name().to_string(),
|
strategy_name: self.strategy.name().to_string(),
|
||||||
benchmark_series: self
|
benchmark_series: self
|
||||||
.data
|
.data
|
||||||
@@ -2200,7 +2226,7 @@ where
|
|||||||
.and_then(|(_, decision_slot)| *decision_slot);
|
.and_then(|(_, decision_slot)| *decision_slot);
|
||||||
let Some((decision_index, decision_date)) = decision_slot else {
|
let Some((decision_index, decision_date)) = decision_slot else {
|
||||||
let mut process_events = Vec::new();
|
let mut process_events = Vec::new();
|
||||||
let mut report = BrokerExecutionReport::default();
|
let mut report = self.broker.execute_deferred_etf_targets(execution_date, &mut portfolio, &self.data)?;
|
||||||
portfolio.update_prices_with_options(
|
portfolio.update_prices_with_options(
|
||||||
execution_date,
|
execution_date,
|
||||||
&self.data,
|
&self.data,
|
||||||
@@ -2562,6 +2588,9 @@ where
|
|||||||
"open_auction:post",
|
"open_auction:post",
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
|
let deferred_etfs = self.broker.execute_deferred_etf_targets(execution_date, &mut portfolio, &self.data)?;
|
||||||
|
merge_broker_report(&mut report, deferred_etfs);
|
||||||
|
|
||||||
publish_phase_event(
|
publish_phase_event(
|
||||||
&mut self.strategy,
|
&mut self.strategy,
|
||||||
&mut self.process_event_bus,
|
&mut self.process_event_bus,
|
||||||
@@ -2850,11 +2879,11 @@ where
|
|||||||
)?;
|
)?;
|
||||||
|
|
||||||
if should_run_minute_events(&intraday_schedule_rules, &self.subscriptions)
|
if should_run_minute_events(&intraday_schedule_rules, &self.subscriptions)
|
||||||
|| (self.broker.has_open_orders() && self.broker.matching_type_uses_intraday_quotes())
|
|| (self.broker.has_open_orders() && self.broker.drives_resting_quote_clock())
|
||||||
{
|
{
|
||||||
let unfiltered_minute_stream = self.subscriptions.is_empty();
|
let unfiltered_minute_stream = self.subscriptions.is_empty();
|
||||||
let mut full_minute_symbols = self.subscriptions.clone();
|
let mut full_minute_symbols = self.subscriptions.clone();
|
||||||
if self.broker.matching_type_uses_intraday_quotes() {
|
if self.broker.drives_resting_quote_clock() {
|
||||||
full_minute_symbols.extend(self.broker.open_order_views().into_iter().map(|order| order.symbol));
|
full_minute_symbols.extend(self.broker.open_order_views().into_iter().map(|order| order.symbol));
|
||||||
}
|
}
|
||||||
if self.execution_quote_loader.is_some() && !full_minute_symbols.is_empty() {
|
if self.execution_quote_loader.is_some() && !full_minute_symbols.is_empty() {
|
||||||
@@ -3125,7 +3154,7 @@ where
|
|||||||
.map(|order| order.symbol)
|
.map(|order| order.symbol)
|
||||||
.filter(|symbol| !full_minute_symbols.contains(symbol))
|
.filter(|symbol| !full_minute_symbols.contains(symbol))
|
||||||
.collect::<BTreeSet<_>>();
|
.collect::<BTreeSet<_>>();
|
||||||
if !newly_pending.is_empty() && self.broker.matching_type_uses_intraday_quotes() {
|
if !newly_pending.is_empty() && self.broker.drives_resting_quote_clock() {
|
||||||
full_minute_symbols.extend(newly_pending.iter().cloned());
|
full_minute_symbols.extend(newly_pending.iter().cloned());
|
||||||
if self.execution_quote_loader.is_some() {
|
if self.execution_quote_loader.is_some() {
|
||||||
self.load_missing_execution_quotes(execution_date, None, None, &mut newly_pending)?;
|
self.load_missing_execution_quotes(execution_date, None, None, &mut newly_pending)?;
|
||||||
@@ -3423,6 +3452,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..]);
|
||||||
@@ -5904,10 +5943,11 @@ mod tests {
|
|||||||
}], ..StrategyDecision::default() })
|
}], ..StrategyDecision::default() })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for scenario in 0..4 {
|
for scenario in 0..5 {
|
||||||
let partial = scenario == 1;
|
let partial = scenario == 1;
|
||||||
let closing_only = scenario >= 2;
|
let closing_only = matches!(scenario,2|3);
|
||||||
let date = if closing_only { d(2026, 7, 6) } else { d(2026, 6, 1) };
|
let delayed = scenario == 4;
|
||||||
|
let date = if closing_only { d(2026, 7, 6) } else if delayed { d(2026, 6, 2) } else { d(2026, 6, 1) };
|
||||||
let quote = |hour, minute, price| IntradayExecutionQuote {
|
let quote = |hour, minute, price| IntradayExecutionQuote {
|
||||||
date, symbol: SYMBOL.into(), timestamp: date.and_hms_opt(hour, minute, 0).unwrap(),
|
date, symbol: SYMBOL.into(), timestamp: date.and_hms_opt(hour, minute, 0).unwrap(),
|
||||||
last_price: price, bid1: price, ask1: price, bid1_volume: 10_000, ask1_volume: 10_000,
|
last_price: price, bid1: price, ask1: price, bid1_volume: 10_000, ask1_volume: 10_000,
|
||||||
@@ -5920,17 +5960,20 @@ mod tests {
|
|||||||
let last = if closing_only { quote(15, 0, if scenario == 2 { 9.8 } else { 10.2 }) } else { quote(10, 1, 9.8) };
|
let last = if closing_only { quote(15, 0, if scenario == 2 { 9.8 } else { 10.2 }) } else { quote(10, 1, 9.8) };
|
||||||
let mut post_close = quote(15, 5, 9.7);
|
let mut post_close = quote(15, 5, 9.7);
|
||||||
post_close.trading_phase = Some("post_close_fixed_price".into());
|
post_close.trading_phase = Some("post_close_fixed_price".into());
|
||||||
let mut data = dataset_from_market_and_candidates(vec![market(date, 10.2, 9.8)], vec![candidate(date)]);
|
let prior = date.pred_opt().unwrap();
|
||||||
|
let markets = if delayed {vec![market(prior,10.2,10.2),market(date,10.2,9.8)]} else {vec![market(date,10.2,9.8)]};
|
||||||
|
let candidates = if delayed {vec![candidate(prior),candidate(date)]} else {vec![candidate(date)]};
|
||||||
|
let mut data = dataset_from_market_and_candidates(markets,candidates);
|
||||||
data.add_execution_quotes(vec![first.clone()]);
|
data.add_execution_quotes(vec![first.clone()]);
|
||||||
let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
|
let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
|
||||||
.with_matching_type(MatchingType::CurrentBarClose)
|
.with_matching_type(if delayed {MatchingType::NextBarOpen} else {MatchingType::CurrentBarClose})
|
||||||
.with_intraday_execution_start_time(NaiveTime::from_hms_opt(9, 30, 0).unwrap())
|
|
||||||
.with_volume_limit(partial).with_volume_percent(0.01).with_liquidity_limit(false).with_inactive_limit(false);
|
.with_volume_limit(partial).with_volume_percent(0.01).with_liquidity_limit(false).with_inactive_limit(false);
|
||||||
|
let broker = if delayed {broker} else {broker.with_intraday_execution_start_time(NaiveTime::from_hms_opt(9,30,0).unwrap())};
|
||||||
let requests = Arc::new(Mutex::new(Vec::new()));
|
let requests = Arc::new(Mutex::new(Vec::new()));
|
||||||
let captured = Arc::clone(&requests);
|
let captured = Arc::clone(&requests);
|
||||||
let mut engine = BacktestEngine::new(data, RestingLimit { quantity: if partial { 300 } else { 100 } }, broker, BacktestConfig {
|
let mut engine = BacktestEngine::new(data, RestingLimit { quantity: if partial { 300 } else { 100 } }, broker, BacktestConfig {
|
||||||
initial_cash: 100_000.0, benchmark_code: "000852.SH".into(), start_date: Some(date), end_date: Some(date),
|
initial_cash: 100_000.0, benchmark_code: "000852.SH".into(), start_date: Some(if delayed {prior} else {date}), end_date: Some(date),
|
||||||
decision_lag_trading_days: 0, execution_price_field: PriceField::Close,
|
decision_lag_trading_days: usize::from(delayed), execution_price_field: if delayed {PriceField::Open} else {PriceField::Close},
|
||||||
}).with_execution_quote_loader(move |request| {
|
}).with_execution_quote_loader(move |request| {
|
||||||
captured.lock().unwrap().push((request.start_time, request.end_time));
|
captured.lock().unwrap().push((request.start_time, request.end_time));
|
||||||
Ok(vec![earlier.clone(), first.clone(), unchanged.clone(), later.clone(), last.clone(), post_close.clone()])
|
Ok(vec![earlier.clone(), first.clone(), unchanged.clone(), later.clone(), last.clone(), post_close.clone()])
|
||||||
@@ -7034,6 +7077,7 @@ mod tests {
|
|||||||
let third = d(2025, 1, 6);
|
let third = d(2025, 1, 6);
|
||||||
let fourth = d(2025, 1, 7);
|
let fourth = d(2025, 1, 7);
|
||||||
let broker = scheduled_next_open_broker(FidcRiskControlConfig::default())
|
let broker = scheduled_next_open_broker(FidcRiskControlConfig::default())
|
||||||
|
.with_volume_capacity_mode(crate::execution_capacity::VolumeCapacityMode::SessionCapacityAudit)
|
||||||
.with_volume_limit(true)
|
.with_volume_limit(true)
|
||||||
.with_volume_percent(0.25);
|
.with_volume_percent(0.25);
|
||||||
let result = run_scheduled_round_trip_next_open_with_dataset_and_broker(
|
let result = run_scheduled_round_trip_next_open_with_dataset_and_broker(
|
||||||
@@ -7061,12 +7105,13 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn next_bar_open_sell_volume_limit_rejects_execution_day_zero_volume() {
|
fn next_bar_open_session_audit_flags_zero_volume_without_rewriting_fills() {
|
||||||
let first = d(2025, 1, 2);
|
let first = d(2025, 1, 2);
|
||||||
let second = d(2025, 1, 3);
|
let second = d(2025, 1, 3);
|
||||||
let third = d(2025, 1, 6);
|
let third = d(2025, 1, 6);
|
||||||
let fourth = d(2025, 1, 7);
|
let fourth = d(2025, 1, 7);
|
||||||
let broker = scheduled_next_open_broker(FidcRiskControlConfig::default())
|
let broker = scheduled_next_open_broker(FidcRiskControlConfig::default())
|
||||||
|
.with_volume_capacity_mode(crate::execution_capacity::VolumeCapacityMode::SessionCapacityAudit)
|
||||||
.with_volume_limit(true)
|
.with_volume_limit(true)
|
||||||
.with_volume_percent(0.25);
|
.with_volume_percent(0.25);
|
||||||
let result = run_scheduled_round_trip_next_open_with_dataset_and_broker(
|
let result = run_scheduled_round_trip_next_open_with_dataset_and_broker(
|
||||||
@@ -7087,7 +7132,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]
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
//! Historical ETF execution fallback. Never manufactures an intraday bar.
|
||||||
|
use chrono::{NaiveDate, NaiveDateTime, NaiveTime};
|
||||||
|
use rust_decimal::Decimal;
|
||||||
|
use crate::{BacktestError, DataSet};
|
||||||
|
|
||||||
|
pub(crate) fn opening_time() -> NaiveTime {
|
||||||
|
NaiveTime::from_hms_opt(9, 30, 0).expect("valid exchange opening time")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub(crate) struct EtfFallbackReference {
|
||||||
|
pub price: f64,
|
||||||
|
pub reference_date: NaiveDate,
|
||||||
|
/// None means the next official session is outside the loaded calendar.
|
||||||
|
/// No natural-day guess or price from beyond the requested run is used.
|
||||||
|
pub execute_on: Option<NaiveDate>,
|
||||||
|
pub immediate: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn reference(data: &DataSet, symbol: &str, at: NaiveDateTime) -> Result<EtfFallbackReference, BacktestError> {
|
||||||
|
let fail = |reason: &str| BacktestError::Execution(format!(
|
||||||
|
"etf_daily_open_fallback:{reason} symbol={symbol} signal_at={at}"
|
||||||
|
));
|
||||||
|
let instrument = data.instrument(symbol).ok_or_else(|| fail("instrument_identity_missing"))?;
|
||||||
|
if !instrument.is_exchange_traded_fund() || instrument.listed_at.is_none() {
|
||||||
|
return Err(fail("verified_etf_identity_required"));
|
||||||
|
}
|
||||||
|
if instrument.dated_market_absence_reason(at.date()).is_some() {
|
||||||
|
return Err(fail("outside_instrument_lifecycle"));
|
||||||
|
}
|
||||||
|
if at.time() == opening_time() {
|
||||||
|
let row = data.market(at.date(), symbol).ok_or_else(|| fail("daily_open_missing"))?;
|
||||||
|
if !row.open.is_finite() || row.open <= 0.0 { return Err(fail("daily_open_invalid")); }
|
||||||
|
return Ok(EtfFallbackReference { price: row.open, reference_date: at.date(), execute_on: Some(at.date()), immediate: true });
|
||||||
|
}
|
||||||
|
let previous = data.previous_trading_date(at.date(), 1).ok_or_else(|| fail("previous_official_session_missing"))?;
|
||||||
|
let close = data.market(previous, symbol).map(|row| row.close).ok_or_else(|| fail("previous_completed_close_missing"))?;
|
||||||
|
if !close.is_finite() || close <= 0.0 { return Err(fail("previous_completed_close_invalid")); }
|
||||||
|
Ok(EtfFallbackReference {
|
||||||
|
price: close, reference_date: previous, immediate: false,
|
||||||
|
execute_on: if at.time() < opening_time() { Some(at.date()) } else { data.next_trading_date(at.date(), 1) },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub(crate) struct DeferredEtfTarget {
|
||||||
|
pub pool_id: String,
|
||||||
|
pub generation: String,
|
||||||
|
pub symbol: String,
|
||||||
|
pub signal_date: NaiveDate,
|
||||||
|
pub signal_at: NaiveDateTime,
|
||||||
|
pub execute_on: Option<NaiveDate>,
|
||||||
|
pub target_value: Decimal,
|
||||||
|
pub target_weight_bps: i32,
|
||||||
|
pub side: crate::stock_pool_execution::OrderSide,
|
||||||
|
pub max_positions: usize,
|
||||||
|
pub rule: std::sync::Arc<crate::stock_pool_execution::StockPoolExecutionRule>,
|
||||||
|
pub members: std::sync::Arc<Vec<crate::stock_pool_execution::StockPoolMemberSpec>>,
|
||||||
|
pub reason: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Owned by one broker/run. Replacing a full pool generation supersedes older
|
||||||
|
/// queued targets; order of the latest candidate list is retained.
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub(crate) struct DeferredEtfTargets {
|
||||||
|
generations: std::collections::BTreeMap<String, String>,
|
||||||
|
rows: Vec<DeferredEtfTarget>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DeferredEtfTargets {
|
||||||
|
pub fn replace_generation(&mut self, pool_id: &str, generation: &str) -> usize {
|
||||||
|
if self.generations.get(pool_id).is_some_and(|old| old == generation) { return 0; }
|
||||||
|
self.generations.insert(pool_id.into(), generation.into());
|
||||||
|
let before = self.rows.len();
|
||||||
|
self.rows.retain(|row| row.pool_id != pool_id);
|
||||||
|
before - self.rows.len()
|
||||||
|
}
|
||||||
|
pub fn upsert(&mut self, row: DeferredEtfTarget) {
|
||||||
|
if let Some(existing) = self.rows.iter_mut().find(|v| v.pool_id == row.pool_id && v.symbol == row.symbol) {
|
||||||
|
*existing = row;
|
||||||
|
} else { self.rows.push(row); }
|
||||||
|
}
|
||||||
|
pub fn take_due(&mut self, date: NaiveDate) -> Vec<DeferredEtfTarget> {
|
||||||
|
let mut due = Vec::new();
|
||||||
|
self.rows.retain(|row| {
|
||||||
|
if row.execute_on.is_some_and(|day| day <= date) { due.push(row.clone()); false } else { true }
|
||||||
|
});
|
||||||
|
due.sort_by_key(|row| match row.side { crate::stock_pool_execution::OrderSide::Sell => 0, crate::stock_pool_execution::OrderSide::Buy => 1 });
|
||||||
|
due
|
||||||
|
}
|
||||||
|
pub fn len(&self) -> usize { self.rows.len() }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
fn target(symbol:&str,side:crate::stock_pool_execution::OrderSide,generation:&str)->DeferredEtfTarget {
|
||||||
|
let date=NaiveDate::from_ymd_opt(2026,1,2).unwrap();
|
||||||
|
DeferredEtfTarget {pool_id:"pool".into(),generation:generation.into(),symbol:symbol.into(),signal_date:date,signal_at:date.and_hms_opt(13,0,0).unwrap(),execute_on:NaiveDate::from_ymd_opt(2026,1,5),target_value:1000.into(),target_weight_bps:5000,side,max_positions:2,rule:Default::default(),members:std::sync::Arc::new(vec![]),reason:"fixture".into()}
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn latest_generation_overwrites_pending_targets_and_preserves_candidate_order() {
|
||||||
|
use crate::stock_pool_execution::OrderSide::{Buy,Sell};
|
||||||
|
let mut queue=DeferredEtfTargets::default();
|
||||||
|
queue.replace_generation("pool","v1");
|
||||||
|
queue.upsert(target("510300.SH",Buy,"v1"));
|
||||||
|
queue.upsert(target("159915.SZ",Buy,"v1"));
|
||||||
|
assert_eq!(queue.replace_generation("pool","v1"),0);
|
||||||
|
assert_eq!(queue.replace_generation("pool","v2"),2);
|
||||||
|
queue.upsert(target("560450.SH",Buy,"v2"));
|
||||||
|
queue.upsert(target("159915.SZ",Sell,"v2"));
|
||||||
|
queue.upsert(target("510300.SH",Buy,"v2"));
|
||||||
|
assert!(queue.take_due(NaiveDate::from_ymd_opt(2026,1,2).unwrap()).is_empty());
|
||||||
|
let due=queue.take_due(NaiveDate::from_ymd_opt(2026,1,5).unwrap());
|
||||||
|
assert_eq!(due.iter().map(|v|v.symbol.as_str()).collect::<Vec<_>>(),vec!["159915.SZ","560450.SH","510300.SH"]);
|
||||||
|
assert!(due.iter().all(|v|v.generation=="v2"));
|
||||||
|
assert_eq!(queue.len(),0);
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn no_loaded_next_session_is_not_guessed_from_natural_days() {
|
||||||
|
let mut queue=DeferredEtfTargets::default();
|
||||||
|
let mut item=target("510300.SH",crate::stock_pool_execution::OrderSide::Buy,"v1");
|
||||||
|
item.execute_on=None;
|
||||||
|
queue.upsert(item);
|
||||||
|
assert!(queue.take_due(NaiveDate::from_ymd_opt(2026,2,1).unwrap()).is_empty());
|
||||||
|
assert_eq!(queue.len(),1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -317,6 +317,8 @@ pub enum ProcessEventKind {
|
|||||||
AccountDepositWithdraw,
|
AccountDepositWithdraw,
|
||||||
AccountFinanceRepay,
|
AccountFinanceRepay,
|
||||||
AccountManagementFee,
|
AccountManagementFee,
|
||||||
|
SessionCapacityAudit,
|
||||||
|
EtfExecutionFallback,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ProcessEventKind {
|
impl ProcessEventKind {
|
||||||
@@ -362,6 +364,8 @@ impl ProcessEventKind {
|
|||||||
Self::AccountDepositWithdraw => "account_deposit_withdraw",
|
Self::AccountDepositWithdraw => "account_deposit_withdraw",
|
||||||
Self::AccountFinanceRepay => "account_finance_repay",
|
Self::AccountFinanceRepay => "account_finance_repay",
|
||||||
Self::AccountManagementFee => "account_management_fee",
|
Self::AccountManagementFee => "account_management_fee",
|
||||||
|
Self::SessionCapacityAudit => "session_capacity_audit",
|
||||||
|
Self::EtfExecutionFallback => "etf_execution_fallback",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -393,6 +397,8 @@ impl ProcessEventKind {
|
|||||||
| Self::AccountDepositWithdraw
|
| Self::AccountDepositWithdraw
|
||||||
| Self::AccountFinanceRepay
|
| Self::AccountFinanceRepay
|
||||||
| Self::AccountManagementFee
|
| Self::AccountManagementFee
|
||||||
|
| Self::SessionCapacityAudit
|
||||||
|
| Self::EtfExecutionFallback
|
||||||
| Self::Settlement
|
| Self::Settlement
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,19 @@ pub enum VolumeCapacityMode {
|
|||||||
SessionCapacityAudit,
|
SessionCapacityAudit,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl VolumeCapacityMode {
|
||||||
|
pub fn validate(self, enabled: bool, has_execution_observations: bool) -> Result<(), CapacityError> {
|
||||||
|
if !enabled { return Ok(()); }
|
||||||
|
match self {
|
||||||
|
Self::ExecutionObservation if !has_execution_observations => Err(CapacityError::MissingObservation),
|
||||||
|
Self::CompletedBar => Err(CapacityError::MissingCompletedBar),
|
||||||
|
_ => Ok(()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn limits_execution_quantity(self) -> bool { self != Self::SessionCapacityAudit }
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
|
||||||
pub enum CapacityError {
|
pub enum CapacityError {
|
||||||
#[error("execution capacity ratio must be finite and in (0, 1]")]
|
#[error("execution capacity ratio must be finite and in (0, 1]")]
|
||||||
@@ -26,6 +39,28 @@ pub enum CapacityError {
|
|||||||
WrongSession,
|
WrongSession,
|
||||||
#[error("execution-time capacity is missing; daily session volume cannot size an earlier fill")]
|
#[error("execution-time capacity is missing; daily session volume cannot size an earlier fill")]
|
||||||
MissingObservation,
|
MissingObservation,
|
||||||
|
#[error("completed_bar capacity requires declared bar end and availability; an undated daily total is not a completed observation")]
|
||||||
|
MissingCompletedBar,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct CapacityAuditSummary {
|
||||||
|
pub mode: VolumeCapacityMode,
|
||||||
|
pub enabled: bool,
|
||||||
|
pub participation_rate: f64,
|
||||||
|
pub audited_symbol_sessions: usize,
|
||||||
|
pub failed_symbol_sessions: usize,
|
||||||
|
pub audit_passed: Option<bool>,
|
||||||
|
pub execution_time_capacity_proven: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CapacityAuditSummary {
|
||||||
|
pub fn observe(&mut self, audit: &SessionCapacityAudit) {
|
||||||
|
self.audited_symbol_sessions += 1;
|
||||||
|
self.failed_symbol_sessions += usize::from(!audit.passed);
|
||||||
|
self.audit_passed = Some(self.failed_symbol_sessions == 0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Decimal semantics of the frozen JSON rate, evaluated without a float product.
|
/// Decimal semantics of the frozen JSON rate, evaluated without a float product.
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ pub mod pattern_context;
|
|||||||
pub mod session_events;
|
pub mod session_events;
|
||||||
pub mod factor_events;
|
pub mod factor_events;
|
||||||
pub mod execution_capacity;
|
pub mod execution_capacity;
|
||||||
|
mod etf_execution;
|
||||||
mod execution_schedule;
|
mod execution_schedule;
|
||||||
mod factor_event_catalog;
|
mod factor_event_catalog;
|
||||||
pub mod factor_cross_section;
|
pub mod factor_cross_section;
|
||||||
@@ -33,6 +34,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;
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -2730,7 +2739,19 @@ impl PlatformExprStrategy {
|
|||||||
if position.quantity == 0 {
|
if position.quantity == 0 {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let mark_price = if self.uses_intraday_execution_quotes() {
|
let etf_reference_clock = ctx.active_datetime.filter(|at| at.date() == date && at.time() < NaiveTime::from_hms_opt(15,0,0).unwrap());
|
||||||
|
let mark_price = if self.config.stock_pool.is_some()
|
||||||
|
&& ctx.data.instrument(&position.symbol).is_some_and(|instrument| instrument.is_exchange_traded_fund())
|
||||||
|
&& etf_reference_clock.is_some()
|
||||||
|
{
|
||||||
|
// The current daily close is not visible while the session is
|
||||||
|
// running. This is a valuation reference only; the execution
|
||||||
|
// adapter still requires a successful minute-absence query.
|
||||||
|
self.scheduled_last_price(ctx, date, &position.symbol).unwrap_or_else(|| {
|
||||||
|
crate::etf_execution::reference(ctx.data, &position.symbol, etf_reference_clock.unwrap())
|
||||||
|
.map(|reference| reference.price).unwrap_or(f64::NAN)
|
||||||
|
})
|
||||||
|
} else if self.uses_intraday_execution_quotes() {
|
||||||
self.scheduled_last_price(ctx, date, &position.symbol)
|
self.scheduled_last_price(ctx, date, &position.symbol)
|
||||||
.or_else(|| ctx.data.price(date, &position.symbol, PriceField::Last))
|
.or_else(|| ctx.data.price(date, &position.symbol, PriceField::Last))
|
||||||
.or_else(|| {
|
.or_else(|| {
|
||||||
@@ -2754,6 +2775,9 @@ impl PlatformExprStrategy {
|
|||||||
.filter(|price| price.is_finite() && *price > 0.0)
|
.filter(|price| price.is_finite() && *price > 0.0)
|
||||||
.unwrap_or(position.last_price)
|
.unwrap_or(position.last_price)
|
||||||
};
|
};
|
||||||
|
if !mark_price.is_finite() && self.config.stock_pool.is_some() {
|
||||||
|
return f64::NAN;
|
||||||
|
}
|
||||||
if mark_price.is_finite() && mark_price > 0.0 {
|
if mark_price.is_finite() && mark_price > 0.0 {
|
||||||
total += mark_price * position.quantity as f64;
|
total += mark_price * position.quantity as f64;
|
||||||
}
|
}
|
||||||
@@ -3155,10 +3179,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 +3231,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 +3365,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 +3554,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 +4187,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 {
|
||||||
@@ -10282,6 +10318,13 @@ impl PlatformExprStrategy {
|
|||||||
let factor_day = ctx.data.daily_snapshot_view(factor_date);
|
let factor_day = ctx.data.daily_snapshot_view(factor_date);
|
||||||
let factor_rows = factor_day.factor_rows();
|
let factor_rows = factor_day.factor_rows();
|
||||||
let factor_symbol_ids = factor_day.factor_symbol_ids();
|
let factor_symbol_ids = factor_day.factor_symbol_ids();
|
||||||
|
// Market-cap caches are an optimization, not an implicit universe
|
||||||
|
// condition. A manual/price-screened ETF need not have share capital.
|
||||||
|
let requires_total_cap = matches!(self.config.market_cap_field.as_str(),
|
||||||
|
"market_cap" | "market_cap_bn" | "candidate_market_cap" | "candidate_market_cap_bn")
|
||||||
|
|| self.rank_reuses_market_cap_order();
|
||||||
|
let requires_float_cap = matches!(self.config.market_cap_field.as_str(),
|
||||||
|
"free_float_cap" | "free_float_market_cap" | "free_float_cap_bn");
|
||||||
debug_assert_eq!(factor_rows.len(), factor_symbol_ids.len());
|
debug_assert_eq!(factor_rows.len(), factor_symbol_ids.len());
|
||||||
for (factor, symbol_id) in factor_rows.iter().zip(factor_symbol_ids.iter().copied()) {
|
for (factor, symbol_id) in factor_rows.iter().zip(factor_symbol_ids.iter().copied()) {
|
||||||
if self
|
if self
|
||||||
@@ -10350,21 +10393,24 @@ impl PlatformExprStrategy {
|
|||||||
if reject_from_universe {
|
if reject_from_universe {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if factor.market_cap_bn <= 0.0 || !factor.market_cap_bn.is_finite() {
|
if requires_total_cap && (factor.market_cap_bn <= 0.0 || !factor.market_cap_bn.is_finite()) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if !self.stock_passes_universe_exclude(candidate, market) {
|
if !self.stock_passes_universe_exclude(candidate, market) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let market_cap_bn = decision_market_cap_bn(factor);
|
let market_cap_bn = decision_market_cap_bn(factor);
|
||||||
if market_cap_bn <= 0.0 || !market_cap_bn.is_finite() {
|
if requires_total_cap && (market_cap_bn <= 0.0 || !market_cap_bn.is_finite()) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
let free_float_cap = decision_free_float_cap_bn(factor);
|
||||||
|
if requires_float_cap && (!free_float_cap.is_finite() || free_float_cap <= 0.0) { continue; }
|
||||||
eligible_symbols[symbol_id as usize] = true;
|
eligible_symbols[symbol_id as usize] = true;
|
||||||
}
|
}
|
||||||
for symbol_id in ctx
|
let ordered_ids = if requires_total_cap {
|
||||||
.data
|
ctx.data.factor_symbol_ids_by_market_cap_on(factor_date)
|
||||||
.factor_symbol_ids_by_market_cap_on(factor_date)
|
} else { factor_symbol_ids };
|
||||||
|
for symbol_id in ordered_ids
|
||||||
.iter()
|
.iter()
|
||||||
.copied()
|
.copied()
|
||||||
{
|
{
|
||||||
@@ -14526,6 +14572,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 +14606,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 +15117,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 +15223,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 +15236,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 +15469,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 +15721,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 +18093,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 +18114,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 +22429,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 +22804,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 +23761,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 +25018,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 +27486,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 +27628,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 +28419,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 +28739,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 +28788,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 +28814,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 +28850,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 +29018,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 +29196,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 +30410,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 +31673,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 +31827,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 +31992,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 +34014,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 +34192,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 +34235,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 +36559,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;
|
||||||
|
|||||||
@@ -89,6 +89,19 @@ impl PlatformExprStrategy {
|
|||||||
if self.config.in_skip_window(ctx.decision_date) {
|
if self.config.in_skip_window(ctx.decision_date) {
|
||||||
return Ok(StrategyDecision::default());
|
return Ok(StrategyDecision::default());
|
||||||
}
|
}
|
||||||
|
let explicit_quote_condition = self.selection_quote_usage != StockFilterQuoteUsage::DailyOnly
|
||||||
|
|| [self.config.buy_filter_expr.as_str(), self.config.stop_loss_expr.as_str(), self.config.take_profit_expr.as_str()]
|
||||||
|
.into_iter().chain(self.config.position_target_rules.iter().map(|rule|rule.when_expr.as_str()))
|
||||||
|
.any(|expression|Self::stock_filter_quote_usage_for_expr(expression)!=StockFilterQuoteUsage::DailyOnly);
|
||||||
|
if explicit_quote_condition && ctx.active_datetime.is_some_and(|at|at.time()<NaiveTime::from_hms_opt(15,0,0).unwrap()) {
|
||||||
|
for symbol in program.members.iter().map(|member|&member.symbol).chain(ctx.portfolio.positions().keys()) {
|
||||||
|
if ctx.data.instrument(symbol).is_some_and(|instrument|instrument.is_exchange_traded_fund()&&instrument.dated_market_absence_reason(ctx.execution_date).is_none())
|
||||||
|
&& self.scheduled_quote_at_time(ctx,ctx.execution_date,symbol,None).is_none()
|
||||||
|
{
|
||||||
|
return Err(BacktestError::Execution(format!("etf_intraday_condition_evidence_missing:{symbol}; completed daily references cannot make minute/tick conditions true")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
let day = self.day_state(ctx, ctx.decision_date)?;
|
let day = self.day_state(ctx, ctx.decision_date)?;
|
||||||
let (market_date, universe_date, factor_date) = self.selection_dates(ctx);
|
let (market_date, universe_date, factor_date) = self.selection_dates(ctx);
|
||||||
let (low, high) = self.market_cap_band(ctx, &day)?;
|
let (low, high) = self.market_cap_band(ctx, &day)?;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -630,3 +638,198 @@ fn next_day_outside_policy_executes_after_the_first_exclusion_signal() {
|
|||||||
);
|
);
|
||||||
assert_eq!(account.position(&code(2)).unwrap().quantity, 3000);
|
assert_eq!(account.position(&code(2)).unwrap().quantity, 3000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn etf_fallback_fixture(time: chrono::NaiveTime) -> DataSet {
|
||||||
|
let mut parts = data_with_fund_rules(1_000_000, None, true).snapshot_components();
|
||||||
|
let previous = NaiveDate::from_ymd_opt(2025,12,31).unwrap();
|
||||||
|
for instrument in &mut parts.instruments { instrument.listed_at = Some(NaiveDate::from_ymd_opt(2025,12,1).unwrap()); }
|
||||||
|
let mut past_market = parts.market.iter().filter(|row| row.date == day(2)).cloned().collect::<Vec<_>>();
|
||||||
|
for row in &mut past_market { row.date=previous; if row.symbol == code(2) { row.close=5.; row.open=5.; row.high=5.; row.low=5.; row.last_price=5.; } }
|
||||||
|
parts.market.extend(past_market);
|
||||||
|
let mut past_factors=parts.factors.iter().filter(|row|row.date==day(2)).cloned().collect::<Vec<_>>();
|
||||||
|
for row in &mut past_factors {row.date=previous;}
|
||||||
|
parts.factors.extend(past_factors);
|
||||||
|
let mut past_candidates=parts.candidates.iter().filter(|row|row.date==day(2)).cloned().collect::<Vec<_>>();
|
||||||
|
for row in &mut past_candidates {row.date=previous;}
|
||||||
|
parts.candidates.extend(past_candidates);
|
||||||
|
for factor in &mut parts.factors { if factor.symbol==code(2) {factor.market_cap_bn=f64::NAN;factor.free_float_cap_bn=f64::NAN;} }
|
||||||
|
let mut past_benchmark = parts.benchmarks[0].clone(); past_benchmark.date=previous; parts.benchmarks.push(past_benchmark);
|
||||||
|
for row in &mut parts.market {
|
||||||
|
if row.symbol==code(2) && row.date>=day(2) {
|
||||||
|
row.open=if row.date==day(2) {10.} else {4.}; row.day_open=row.open;
|
||||||
|
row.close=40.; row.last_price=40.; row.high=40.; row.low=row.open; row.prev_close=5.;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parts.execution_quotes.retain(|row| row.symbol==code(1));
|
||||||
|
for quote in &mut parts.execution_quotes { quote.timestamp=quote.date.and_time(time); }
|
||||||
|
DataSet::from_components_with_actions_and_quotes(parts.instruments,parts.market,parts.factors,parts.candidates,parts.benchmarks,parts.corporate_actions,parts.execution_quotes).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
struct EtfPoolSignal { at:chrono::NaiveTime, condition:String }
|
||||||
|
impl fidc_core::strategy::Strategy for EtfPoolSignal {
|
||||||
|
fn name(&self)->&str {"ETF fallback fixture"}
|
||||||
|
fn requires_minute_callbacks(&self)->bool {false}
|
||||||
|
fn decision_quote_times(&self)->Vec<chrono::NaiveTime> {vec![self.at]}
|
||||||
|
fn decision_quote_symbols(&mut self,_:&fidc_core::strategy::StrategyContext<'_>)->Result<BTreeSet<String>,fidc_core::BacktestError> {Ok(BTreeSet::from([code(1),code(2)]))}
|
||||||
|
fn on_day(&mut self,ctx:&fidc_core::strategy::StrategyContext<'_>)->Result<StrategyDecision,fidc_core::BacktestError> {
|
||||||
|
if ctx.execution_date!=day(2) {return Ok(StrategyDecision::default());}
|
||||||
|
let mut intent=contract(day(2),1,true);
|
||||||
|
intent.selection.final_symbols=vec![code(1),code(2)];
|
||||||
|
intent.constraints.target_holding_count=Some(2);
|
||||||
|
intent.rule.buy_condition=self.condition.clone();
|
||||||
|
Ok(decision(intent))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_etf_fallback(time:chrono::NaiveTime,end:NaiveDate,enabled:bool,condition:&str,loader_fails:bool,volume_limit:bool)->Result<fidc_core::BacktestResult,fidc_core::BacktestError> {
|
||||||
|
let broker=broker(volume_limit).with_matching_type(MatchingType::MinuteLast)
|
||||||
|
.with_execution_price_field(PriceField::Last).with_intraday_execution_start_time(time)
|
||||||
|
.with_historical_etf_open_fallback(enabled);
|
||||||
|
BacktestEngine::new(etf_fallback_fixture(time),EtfPoolSignal{at:time,condition:condition.into()},broker,BacktestConfig{
|
||||||
|
initial_cash:30000.,benchmark_code:"000300.SH".into(),start_date:Some(day(2)),end_date:Some(end),decision_lag_trading_days:0,execution_price_field:PriceField::Last,
|
||||||
|
}).with_execution_quote_loader(Box::new(move |_| {
|
||||||
|
if loader_fails {Err(fidc_core::BacktestError::Execution("fixture_source_unavailable".into()))} else {Ok(vec![])}
|
||||||
|
})).run()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn historical_etf_open_uses_real_open_without_creating_minute_bars() {
|
||||||
|
let result=run_etf_fallback(chrono::NaiveTime::from_hms_opt(9,30,0).unwrap(),day(6),true,"",false,false).unwrap();
|
||||||
|
let etf=result.fills.iter().filter(|fill| fill.symbol==code(2)).collect::<Vec<_>>();
|
||||||
|
assert_eq!(etf.len(),1,"{:?}",result.fills);
|
||||||
|
assert_eq!((etf[0].date,etf[0].price,etf[0].quantity),(day(2),10.,1500));
|
||||||
|
assert_eq!(etf[0].execution_timestamp,Some(day(2).and_hms_opt(9,30,0).unwrap()));
|
||||||
|
assert!(etf[0].reason.contains("etf_daily_open_fallback"));
|
||||||
|
assert!(result.fills.iter().any(|fill|fill.symbol==code(1)&&fill.date==day(2)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn historical_etf_late_signal_freezes_money_and_requantifies_at_next_official_open() {
|
||||||
|
let result=run_etf_fallback(chrono::NaiveTime::from_hms_opt(13,0,0).unwrap(),day(6),true,"",false,false).unwrap();
|
||||||
|
let etf=result.fills.iter().filter(|fill| fill.symbol==code(2)).collect::<Vec<_>>();
|
||||||
|
assert_eq!(etf.len(),1,"{:?}",result.fills);
|
||||||
|
assert_eq!((etf[0].date,etf[0].price,etf[0].quantity),(day(5),4.,3700));
|
||||||
|
assert_eq!(etf[0].execution_timestamp,Some(day(5).and_hms_opt(9,30,0).unwrap()));
|
||||||
|
assert_eq!(etf[0].order_created_date,Some(day(2)));
|
||||||
|
assert!(etf[0].reason.contains("2026-01-02 13:00:00"));
|
||||||
|
assert!(result.fills.iter().any(|fill|fill.symbol==code(1)&&fill.date==day(2)));
|
||||||
|
assert!(result.terminal_audit.is_clean());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn historical_etf_pending_target_at_end_is_not_a_fake_order_or_fill() {
|
||||||
|
let result=run_etf_fallback(chrono::NaiveTime::from_hms_opt(13,0,0).unwrap(),day(2),true,"",false,false).unwrap();
|
||||||
|
assert_eq!(result.terminal_audit.deferred_etf_target_count,1);
|
||||||
|
assert_eq!(result.terminal_audit.status,fidc_core::BacktestTerminalStatus::CompletedWithPendingState);
|
||||||
|
assert!(result.order_events.iter().all(|order|order.symbol!=code(2)));
|
||||||
|
assert!(result.fills.iter().all(|fill|fill.symbol!=code(2)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn historical_etf_fallback_does_not_waive_source_conditions_or_capacity() {
|
||||||
|
let at=chrono::NaiveTime::from_hms_opt(9,30,0).unwrap();
|
||||||
|
assert!(run_etf_fallback(at,day(6),false,"",false,false).is_err());
|
||||||
|
assert!(run_etf_fallback(at,day(6),true,"last > 1",false,false).unwrap_err().to_string().contains("condition evidence unavailable"));
|
||||||
|
assert!(run_etf_fallback(at,day(6),true,"",true,false).unwrap_err().to_string().contains("fixture_source_unavailable"));
|
||||||
|
assert!(run_etf_fallback(at,day(6),true,"",false,true).unwrap_err().to_string().contains("capacity is missing"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compiled_pool_price_screen_does_not_require_unconfigured_etf_market_cap() {
|
||||||
|
let time=chrono::NaiveTime::from_hms_opt(9,30,0).unwrap();
|
||||||
|
let intent=contract(day(2),1,true);
|
||||||
|
let program=StockPoolProgram {
|
||||||
|
schema_version:1,pool_id:"typed-mixed-pool".into(),version_id:"v1".into(),members:intent.members,
|
||||||
|
allocation_policy:serde_json::json!({"target_holding_count":2,"invest_ratio_bps":10000,"portfolio_policy":{"schema_version":1,"membership":"retain_holdings","rebalance_weights":false}}),
|
||||||
|
timing_policy:serde_json::json!({"pricing_mode":"first_tick","window_start":"09:30"}),
|
||||||
|
stop_take_policy:serde_json::json!({"stop_loss":null,"take_profit":null}),out_of_pool_policy:"hold".into(),
|
||||||
|
};
|
||||||
|
let mut config=platform_expr_config_from_value("etf-no-cap-filter","000300.SH",&serde_json::json!({"stockPool":program,"universe":{"include":[code(1),code(2)]}})).unwrap();
|
||||||
|
config.market_cap_field="close".into();config.market_cap_lower_expr="0".into();config.market_cap_upper_expr="1000000".into();
|
||||||
|
config.stock_filter_expr="close > 0".into();config.selection_limit_expr="2".into();config.selection_candidate_limit_expr="2".into();
|
||||||
|
config.rank_expr=format!("symbol == {:?} ? 0 : 1",code(1));
|
||||||
|
config.intraday_execution_time=Some(time);config.matching_type=MatchingType::CurrentBarClose;
|
||||||
|
config.risk_config.trading_constraints.volume_limit_enabled=false;
|
||||||
|
let result=BacktestEngine::new(etf_fallback_fixture(time),PlatformExprStrategy::new(config.clone()),
|
||||||
|
broker(false).with_matching_type(MatchingType::CurrentBarClose).with_historical_etf_open_fallback(true),
|
||||||
|
BacktestConfig{initial_cash:30000.,benchmark_code:"000300.SH".into(),start_date:Some(day(5)),end_date:Some(day(6)),decision_lag_trading_days:0,execution_price_field:PriceField::Last})
|
||||||
|
.with_execution_quote_loader(Box::new(|_|Ok(vec![]))).run().unwrap();
|
||||||
|
assert!(result.fills.iter().any(|fill|fill.symbol==code(2)),"{:?}",result.equity_curve.iter().map(|row|&row.diagnostics).collect::<Vec<_>>());
|
||||||
|
assert!(result.fills.iter().any(|fill|fill.symbol==code(1)));
|
||||||
|
config.stock_filter_expr="last != 0".into();
|
||||||
|
let rejected=BacktestEngine::new(etf_fallback_fixture(time),PlatformExprStrategy::new(config),
|
||||||
|
broker(false).with_matching_type(MatchingType::CurrentBarClose).with_historical_etf_open_fallback(true),
|
||||||
|
BacktestConfig{initial_cash:30000.,benchmark_code:"000300.SH".into(),start_date:Some(day(5)),end_date:Some(day(6)),decision_lag_trading_days:0,execution_price_field:PriceField::Last})
|
||||||
|
.with_execution_quote_loader(Box::new(|_|Ok(vec![]))).run().unwrap_err();
|
||||||
|
assert!(rejected.to_string().contains("etf_intraday_condition_evidence_missing"),"{rejected}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn etf_signal_budget_does_not_read_the_current_sessions_future_close() {
|
||||||
|
let run=|future_close:f64| {
|
||||||
|
let time=chrono::NaiveTime::from_hms_opt(13,0,0).unwrap();
|
||||||
|
let mut parts=etf_fallback_fixture(time).snapshot_components();
|
||||||
|
for row in &mut parts.market {
|
||||||
|
if row.symbol==code(2)&&row.date==day(5) {row.close=future_close;row.last_price=future_close;row.high=future_close.max(row.open);}
|
||||||
|
}
|
||||||
|
let data=DataSet::from_components_with_actions_and_quotes(parts.instruments,parts.market,parts.factors,parts.candidates,parts.benchmarks,parts.corporate_actions,parts.execution_quotes).unwrap();
|
||||||
|
let program=StockPoolProgram{schema_version:1,pool_id:"budget-no-future".into(),version_id:"v1".into(),members:contract(day(2),1,true).members,
|
||||||
|
allocation_policy:serde_json::json!({"target_holding_count":2,"invest_ratio_bps":10000,"portfolio_policy":{"schema_version":1,"membership":"retain_holdings","rebalance_weights":true}}),
|
||||||
|
timing_policy:serde_json::json!({"pricing_mode":"first_tick","window_start":"13:00","window_end":"14:55"}),stop_take_policy:serde_json::json!({}),out_of_pool_policy:"hold".into()};
|
||||||
|
let mut config=platform_expr_config_from_value("etf-budget","000300.SH",&serde_json::json!({"stockPool":program,"universe":{"include":[code(1),code(2)]},"runtimeExpressions":{"schedule":{"frequency":"daily","time":"13:00"}}})).unwrap();
|
||||||
|
config.market_cap_field="close".into();config.market_cap_lower_expr="0".into();config.market_cap_upper_expr="1000000".into();
|
||||||
|
config.stock_filter_expr="true".into();config.selection_limit_expr="2".into();config.selection_candidate_limit_expr="2".into();
|
||||||
|
config.rank_expr=format!("symbol == {:?} ? 0 : 1",code(1));config.intraday_execution_time=Some(time);config.matching_type=MatchingType::CurrentBarClose;
|
||||||
|
config.risk_config.trading_constraints.volume_limit_enabled=false;
|
||||||
|
BacktestEngine::new(data,PlatformExprStrategy::new(config),broker(false).with_matching_type(MatchingType::CurrentBarClose).with_intraday_execution_start_time(time).with_historical_etf_open_fallback(true),
|
||||||
|
BacktestConfig{initial_cash:30000.,benchmark_code:"000300.SH".into(),start_date:Some(day(2)),end_date:Some(day(5)),decision_lag_trading_days:0,execution_price_field:PriceField::Last})
|
||||||
|
.with_execution_quote_loader(Box::new(|_|Ok(vec![]))).run().unwrap()
|
||||||
|
};
|
||||||
|
let a=run(40.);let b=run(400.);
|
||||||
|
let budget=|result:&fidc_core::BacktestResult|result.equity_curve.iter().find(|row|row.date==day(5)).unwrap().diagnostics.split(" | ").find(|line|line.starts_with("stock_pool_signal_frozen")).unwrap().to_string();
|
||||||
|
assert_eq!(budget(&a),budget(&b));
|
||||||
|
assert_eq!(serde_json::to_value(&a.fills).unwrap(),serde_json::to_value(&b.fills).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
struct EtfReallocationSignal { protection_days: u32 }
|
||||||
|
impl fidc_core::strategy::Strategy for EtfReallocationSignal {
|
||||||
|
fn name(&self)->&str {"deferred ETF sell funding"}
|
||||||
|
fn requires_minute_callbacks(&self)->bool {false}
|
||||||
|
fn decision_quote_times(&self)->Vec<chrono::NaiveTime> {vec![chrono::NaiveTime::from_hms_opt(13,0,0).unwrap()]}
|
||||||
|
fn decision_quote_symbols(&mut self,_:&fidc_core::strategy::StrategyContext<'_>)->Result<BTreeSet<String>,fidc_core::BacktestError>{Ok(BTreeSet::from([code(1),code(2)]))}
|
||||||
|
fn on_day(&mut self,ctx:&fidc_core::strategy::StrategyContext<'_>)->Result<StrategyDecision,fidc_core::BacktestError> {
|
||||||
|
if ![day(2),day(6)].contains(&ctx.execution_date) {return Ok(Default::default());}
|
||||||
|
let mut intent=contract(ctx.execution_date,1,false);
|
||||||
|
intent.rule.automatic_trade_protection.buy_protection_days=self.protection_days;
|
||||||
|
if ctx.execution_date==day(2) {intent.selection.final_symbols=vec![code(1),code(2)];intent.constraints.target_holding_count=Some(2);}
|
||||||
|
else {intent.frozen_equity=300000.into();intent.out_of_pool_policy="reduce_to_zero_when_sellable".into();}
|
||||||
|
Ok(decision(intent))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deferred_etf_sell_does_not_finance_same_day_stock_topup() {
|
||||||
|
let time=chrono::NaiveTime::from_hms_opt(13,0,0).unwrap();
|
||||||
|
let result=BacktestEngine::new(etf_fallback_fixture(time),EtfReallocationSignal{protection_days:0},
|
||||||
|
broker(false).with_matching_type(MatchingType::MinuteLast).with_execution_price_field(PriceField::Last).with_intraday_execution_start_time(time).with_historical_etf_open_fallback(true),
|
||||||
|
BacktestConfig{initial_cash:30000.,benchmark_code:"000300.SH".into(),start_date:Some(day(2)),end_date:Some(day(6)),decision_lag_trading_days:0,execution_price_field:PriceField::Last})
|
||||||
|
.with_execution_quote_loader(Box::new(|_|Ok(vec![]))).run().unwrap();
|
||||||
|
assert!(result.fills.iter().any(|fill|fill.symbol==code(2)&&fill.date==day(5)));
|
||||||
|
assert!(result.fills.iter().all(|fill|fill.date!=day(6)),"{:?}",result.fills);
|
||||||
|
assert!(!result.order_events.iter().any(|order|order.date==day(6)&&order.symbol==code(1)&&order.side==fidc_core::OrderSide::Buy),"{:?}",result.order_events);
|
||||||
|
assert_eq!(result.terminal_audit.deferred_etf_target_count,1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn etf_post_buy_protection_starts_on_deferred_fill_day_not_signal_day() {
|
||||||
|
let time=chrono::NaiveTime::from_hms_opt(13,0,0).unwrap();
|
||||||
|
let result=BacktestEngine::new(etf_fallback_fixture(time),EtfReallocationSignal{protection_days:1},
|
||||||
|
broker(false).with_matching_type(MatchingType::MinuteLast).with_execution_price_field(PriceField::Last).with_intraday_execution_start_time(time).with_historical_etf_open_fallback(true),
|
||||||
|
BacktestConfig{initial_cash:30000.,benchmark_code:"000300.SH".into(),start_date:Some(day(2)),end_date:Some(day(6)),decision_lag_trading_days:0,execution_price_field:PriceField::Last})
|
||||||
|
.with_execution_quote_loader(Box::new(|_|Ok(vec![]))).run().unwrap();
|
||||||
|
assert!(result.fills.iter().any(|fill|fill.symbol==code(2)&&fill.date==day(5)));
|
||||||
|
assert!(result.fills.iter().filter(|fill|fill.symbol==code(2)).all(|fill|fill.side!=fidc_core::OrderSide::Sell));
|
||||||
|
// Jan 2 is the signal; actual Jan 5 fill protects Jan 5 and Jan 6.
|
||||||
|
// Starting the timer on Jan 2 would incorrectly queue an exit on Jan 6.
|
||||||
|
assert_eq!(result.terminal_audit.deferred_etf_target_count,0);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user