Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3b2a97fa84 | |||
| 2f2258f208 |
@@ -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)]
|
||||||
@@ -646,11 +648,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 +693,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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -693,6 +705,8 @@ impl<C, R> BrokerSimulator<C, R> {
|
|||||||
fn effective_execution_price_field(&self, date: NaiveDate) -> PriceField {
|
fn effective_execution_price_field(&self, date: NaiveDate) -> PriceField {
|
||||||
if self.is_post_close_fixed_price(date) {
|
if self.is_post_close_fixed_price(date) {
|
||||||
PriceField::Close
|
PriceField::Close
|
||||||
|
} else if self.resting_daily_open_order() {
|
||||||
|
PriceField::Last
|
||||||
} else {
|
} else {
|
||||||
self.execution_price_field
|
self.execution_price_field
|
||||||
}
|
}
|
||||||
@@ -705,8 +719,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)))
|
||||||
}
|
}
|
||||||
@@ -1389,6 +1403,7 @@ where
|
|||||||
match algo_request.map(|request| request.style) {
|
match algo_request.map(|request| request.style) {
|
||||||
Some(AlgoExecutionStyle::Vwap) => MatchingType::Vwap,
|
Some(AlgoExecutionStyle::Vwap) => MatchingType::Vwap,
|
||||||
Some(AlgoExecutionStyle::Twap) => MatchingType::Twap,
|
Some(AlgoExecutionStyle::Twap) => MatchingType::Twap,
|
||||||
|
None if self.resting_daily_open_order() => MatchingType::CurrentBarClose,
|
||||||
None => self.matching_type,
|
None => self.matching_type,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2420,7 +2435,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 +2587,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 +2631,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 +2672,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 +2747,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(),
|
||||||
@@ -4056,6 +4078,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)
|
||||||
@@ -4653,7 +4678,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 +4769,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 +4942,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 +5114,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(),
|
||||||
@@ -6475,7 +6504,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 +6744,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 +6918,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(),
|
||||||
@@ -7551,7 +7583,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))))
|
||||||
@@ -8071,6 +8103,7 @@ where
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn matching_type_uses_intraday_quotes(&self) -> bool {
|
pub(crate) fn matching_type_uses_intraday_quotes(&self) -> bool {
|
||||||
|
if self.resting_daily_open_order() { return true; }
|
||||||
matches!(
|
matches!(
|
||||||
self.matching_type,
|
self.matching_type,
|
||||||
MatchingType::MinuteLast
|
MatchingType::MinuteLast
|
||||||
@@ -8082,6 +8115,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 +8251,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,
|
||||||
@@ -8791,6 +8829,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");
|
||||||
|
|||||||
@@ -2850,11 +2850,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 +3125,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)?;
|
||||||
@@ -5904,10 +5904,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 +5921,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()])
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ pub mod stock_pool_candidates;
|
|||||||
pub mod stock_pool_indicators;
|
pub mod stock_pool_indicators;
|
||||||
pub mod stock_pool_execution;
|
pub mod stock_pool_execution;
|
||||||
pub mod stock_pool_index_policy;
|
pub mod stock_pool_index_policy;
|
||||||
|
pub mod stock_pool_market_cap;
|
||||||
pub mod stock_pool_state;
|
pub mod stock_pool_state;
|
||||||
pub mod signal_contract;
|
pub mod signal_contract;
|
||||||
pub mod strategy_ai;
|
pub mod strategy_ai;
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user