|
|
|
@@ -204,6 +204,7 @@ struct OpenOrder {
|
|
|
|
|
order_id: u64,
|
|
|
|
|
decision_date: Option<NaiveDate>,
|
|
|
|
|
order_created_date: Option<NaiveDate>,
|
|
|
|
|
submission_time: Option<NaiveTime>,
|
|
|
|
|
symbol: String,
|
|
|
|
|
side: OrderSide,
|
|
|
|
|
requested_quantity: u32,
|
|
|
|
@@ -216,6 +217,12 @@ struct OpenOrder {
|
|
|
|
|
reason: String,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Copy)]
|
|
|
|
|
struct RestingOrderOrigin {
|
|
|
|
|
created_date: Option<NaiveDate>,
|
|
|
|
|
submission_time: Option<NaiveTime>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Default)]
|
|
|
|
|
struct BrokerExecutionSession {
|
|
|
|
|
date: Option<NaiveDate>,
|
|
|
|
@@ -439,6 +446,7 @@ pub struct BrokerSimulator<C, R> {
|
|
|
|
|
runtime_auto_buy_denials: RefCell<BTreeMap<String, String>>,
|
|
|
|
|
runtime_auto_sell_denials: RefCell<BTreeMap<String, String>>,
|
|
|
|
|
runtime_order_created_date: Cell<Option<NaiveDate>>,
|
|
|
|
|
runtime_resting_order_origin: Cell<Option<RestingOrderOrigin>>,
|
|
|
|
|
runtime_decision_total_equity: Cell<Option<f64>>,
|
|
|
|
|
runtime_target_position_limit: Cell<Option<usize>>,
|
|
|
|
|
runtime_time_in_force: Cell<Option<OrderTimeInForce>>,
|
|
|
|
@@ -475,6 +483,7 @@ impl<C, R> BrokerSimulator<C, R> {
|
|
|
|
|
runtime_auto_buy_denials: RefCell::new(BTreeMap::new()),
|
|
|
|
|
runtime_auto_sell_denials: RefCell::new(BTreeMap::new()),
|
|
|
|
|
runtime_order_created_date: Cell::new(None),
|
|
|
|
|
runtime_resting_order_origin: Cell::new(None),
|
|
|
|
|
runtime_decision_total_equity: Cell::new(None),
|
|
|
|
|
runtime_target_position_limit: Cell::new(None),
|
|
|
|
|
runtime_time_in_force: Cell::new(None),
|
|
|
|
@@ -515,6 +524,7 @@ impl<C, R> BrokerSimulator<C, R> {
|
|
|
|
|
runtime_auto_buy_denials: RefCell::new(BTreeMap::new()),
|
|
|
|
|
runtime_auto_sell_denials: RefCell::new(BTreeMap::new()),
|
|
|
|
|
runtime_order_created_date: Cell::new(None),
|
|
|
|
|
runtime_resting_order_origin: Cell::new(None),
|
|
|
|
|
runtime_decision_total_equity: Cell::new(None),
|
|
|
|
|
runtime_target_position_limit: Cell::new(None),
|
|
|
|
|
runtime_time_in_force: Cell::new(None),
|
|
|
|
@@ -636,6 +646,13 @@ impl<C, R> BrokerSimulator<C, R> {
|
|
|
|
|
.or(self.intraday_execution_start_time)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn order_origin(&self) -> RestingOrderOrigin {
|
|
|
|
|
self.runtime_resting_order_origin.get().unwrap_or(RestingOrderOrigin {
|
|
|
|
|
created_date: self.runtime_order_created_date.get(),
|
|
|
|
|
submission_time: self.submission_time(),
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn execution_phase_for_submission(
|
|
|
|
|
&self,
|
|
|
|
|
date: NaiveDate,
|
|
|
|
@@ -661,10 +678,11 @@ impl<C, R> BrokerSimulator<C, R> {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn execution_phase(&self, date: NaiveDate) -> EquityExecutionPhase {
|
|
|
|
|
let origin = self.order_origin();
|
|
|
|
|
self.execution_phase_for_submission(
|
|
|
|
|
date,
|
|
|
|
|
self.runtime_order_created_date.get(),
|
|
|
|
|
self.submission_time(),
|
|
|
|
|
origin.created_date,
|
|
|
|
|
origin.submission_time,
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -684,10 +702,11 @@ impl<C, R> BrokerSimulator<C, R> {
|
|
|
|
|
&self,
|
|
|
|
|
date: NaiveDate,
|
|
|
|
|
) -> Option<(NaiveDateTime, NaiveDateTime)> {
|
|
|
|
|
let origin = self.order_origin();
|
|
|
|
|
self.post_close_execution_quote_window_for_submission(
|
|
|
|
|
date,
|
|
|
|
|
self.runtime_order_created_date.get(),
|
|
|
|
|
self.submission_time(),
|
|
|
|
|
origin.created_date,
|
|
|
|
|
origin.submission_time,
|
|
|
|
|
)
|
|
|
|
|
.map(|(start, end)| (date.and_time(start), date.and_time(end)))
|
|
|
|
|
}
|
|
|
|
@@ -730,7 +749,8 @@ impl<C, R> BrokerSimulator<C, R> {
|
|
|
|
|
if self.is_post_close_fixed_price(date) {
|
|
|
|
|
return match self.runtime_time_in_force.get() {
|
|
|
|
|
Some(OrderTimeInForce::Fok) => RemainderPolicy::FillOrKill,
|
|
|
|
|
_ => RemainderPolicy::Cancel,
|
|
|
|
|
Some(OrderTimeInForce::Ioc | OrderTimeInForce::Gtc) => RemainderPolicy::Cancel,
|
|
|
|
|
_ => RemainderPolicy::KeepUntilClose,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
match self.runtime_time_in_force.get() {
|
|
|
|
@@ -781,7 +801,7 @@ impl<C, R> BrokerSimulator<C, R> {
|
|
|
|
|
filled_quantity: order.filled_quantity,
|
|
|
|
|
remaining_quantity: order.remaining_quantity,
|
|
|
|
|
unfilled_quantity: order.remaining_quantity,
|
|
|
|
|
status: OrderStatus::Pending,
|
|
|
|
|
status: if order.filled_quantity > 0 { OrderStatus::PartiallyFilled } else { OrderStatus::Pending },
|
|
|
|
|
avg_price: 0.0,
|
|
|
|
|
transaction_cost: 0.0,
|
|
|
|
|
limit_price: order.limit_price,
|
|
|
|
@@ -793,6 +813,17 @@ impl<C, R> BrokerSimulator<C, R> {
|
|
|
|
|
pub fn has_open_orders(&self) -> bool {
|
|
|
|
|
!self.open_orders.borrow().is_empty()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn resting_order_session_close(&self, date: NaiveDate, order: &OpenOrder) -> NaiveTime {
|
|
|
|
|
let post_close = self.execution_phase_for_submission(date, order.order_created_date, order.submission_time)
|
|
|
|
|
== EquityExecutionPhase::PostCloseFixedPrice;
|
|
|
|
|
NaiveTime::from_hms_opt(15, if post_close { 30 } else { 0 }, 0).expect("session end")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub(crate) fn next_day_order_expiry(&self, date: NaiveDate) -> Option<NaiveTime> {
|
|
|
|
|
self.open_orders.borrow().iter().filter(|order| order.time_in_force == OrderTimeInForce::Day)
|
|
|
|
|
.map(|order| self.resting_order_session_close(date, order)).min()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl<C, R> BrokerSimulator<C, R>
|
|
|
|
@@ -2389,7 +2420,7 @@ where
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn current_order_created_date(&self, date: NaiveDate) -> NaiveDate {
|
|
|
|
|
self.runtime_order_created_date.get().unwrap_or(date)
|
|
|
|
|
self.order_origin().created_date.unwrap_or(date)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn annotate_report_range(
|
|
|
|
@@ -2541,6 +2572,18 @@ where
|
|
|
|
|
std::mem::take(&mut *open_orders)
|
|
|
|
|
};
|
|
|
|
|
for order in pending_orders {
|
|
|
|
|
let close = self.resting_order_session_close(date, &order);
|
|
|
|
|
let clock = self.submission_time();
|
|
|
|
|
let past_day = order.time_in_force == OrderTimeInForce::Day
|
|
|
|
|
&& order.order_created_date.is_some_and(|created| created < date);
|
|
|
|
|
if past_day || clock.is_some_and(|time| time > close) {
|
|
|
|
|
if order.time_in_force == OrderTimeInForce::Day {
|
|
|
|
|
Self::emit_resting_day_expiry(report, date, &order, order.filled_quantity);
|
|
|
|
|
} else {
|
|
|
|
|
self.open_orders.borrow_mut().push(order);
|
|
|
|
|
}
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
let order_event_start = report.order_events.len();
|
|
|
|
|
let fill_event_start = report.fill_events.len();
|
|
|
|
|
if let Some(commission_remaining) = order.commission_remaining {
|
|
|
|
@@ -2565,6 +2608,11 @@ where
|
|
|
|
|
let previous_time_in_force = self
|
|
|
|
|
.runtime_time_in_force
|
|
|
|
|
.replace(Some(order.time_in_force));
|
|
|
|
|
let previous_origin = self.runtime_resting_order_origin.replace(Some(RestingOrderOrigin {
|
|
|
|
|
created_date: order.order_created_date,
|
|
|
|
|
submission_time: order.submission_time,
|
|
|
|
|
}));
|
|
|
|
|
let previous_decision_date = self.runtime_decision_date.replace(order.decision_date);
|
|
|
|
|
let execution_result = self.process_limit_shares_internal(
|
|
|
|
|
date,
|
|
|
|
|
portfolio,
|
|
|
|
@@ -2582,6 +2630,8 @@ where
|
|
|
|
|
report,
|
|
|
|
|
);
|
|
|
|
|
self.runtime_time_in_force.set(previous_time_in_force);
|
|
|
|
|
self.runtime_resting_order_origin.set(previous_origin);
|
|
|
|
|
self.runtime_decision_date.set(previous_decision_date);
|
|
|
|
|
execution_result?;
|
|
|
|
|
let attempt_filled = report.fill_events[fill_event_start..]
|
|
|
|
|
.iter()
|
|
|
|
@@ -2600,6 +2650,7 @@ where
|
|
|
|
|
remains_open = remaining_quantity > 0;
|
|
|
|
|
reopened.decision_date = order.decision_date;
|
|
|
|
|
reopened.order_created_date = order.order_created_date;
|
|
|
|
|
reopened.submission_time = order.submission_time;
|
|
|
|
|
reopened.requested_quantity = order.requested_quantity;
|
|
|
|
|
reopened.filled_quantity = cumulative_filled;
|
|
|
|
|
reopened.remaining_quantity = remaining_quantity;
|
|
|
|
@@ -2611,6 +2662,13 @@ where
|
|
|
|
|
open_orders.retain(|open| open.order_id != order.order_id);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if remains_open && order.time_in_force == OrderTimeInForce::Day
|
|
|
|
|
&& clock.is_some_and(|time| time >= close)
|
|
|
|
|
{
|
|
|
|
|
self.clear_open_order(order.order_id);
|
|
|
|
|
Self::emit_resting_day_expiry(report, date, &order, cumulative_filled);
|
|
|
|
|
remains_open = false;
|
|
|
|
|
}
|
|
|
|
|
if report.order_events.len() == order_event_start && !remains_open {
|
|
|
|
|
report.order_events.push(OrderEvent {
|
|
|
|
|
date,
|
|
|
|
@@ -2666,6 +2724,18 @@ where
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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));
|
|
|
|
|
report.order_events.push(OrderEvent {
|
|
|
|
|
date, decision_date: order.decision_date, order_created_date: order.order_created_date,
|
|
|
|
|
execution_date: Some(date), order_id: Some(order.order_id), symbol: order.symbol.clone(),
|
|
|
|
|
side: order.side, requested_quantity: order.requested_quantity, filled_quantity: filled,
|
|
|
|
|
status: OrderStatus::Expired, reason: detail.clone(),
|
|
|
|
|
});
|
|
|
|
|
Self::emit_order_process_event(report, date, ProcessEventKind::OrderUnsolicitedUpdate,
|
|
|
|
|
order.order_id, &order.symbol, order.side, format!("status=Expired reason={detail}"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn cancel_open_order(
|
|
|
|
|
&self,
|
|
|
|
|
date: NaiveDate,
|
|
|
|
@@ -4391,6 +4461,7 @@ where
|
|
|
|
|
);
|
|
|
|
|
return Ok(());
|
|
|
|
|
};
|
|
|
|
|
let limit_price = limit_price.or_else(|| self.is_post_close_fixed_price(date).then_some(snapshot.close));
|
|
|
|
|
let Some(candidate) = data.candidate(date, symbol) else {
|
|
|
|
|
Self::reject_unavailable_order(
|
|
|
|
|
report,
|
|
|
|
@@ -4582,6 +4653,7 @@ where
|
|
|
|
|
if Self::keeps_remainder_open(remainder_policy) {
|
|
|
|
|
self.upsert_open_order(OpenOrder {
|
|
|
|
|
order_id,
|
|
|
|
|
submission_time: self.order_origin().submission_time,
|
|
|
|
|
decision_date: Some(self.current_decision_date(date)),
|
|
|
|
|
order_created_date: Some(self.current_order_created_date(date)),
|
|
|
|
|
symbol: symbol.to_string(),
|
|
|
|
@@ -4595,6 +4667,10 @@ where
|
|
|
|
|
execution_cursor: execution_cursors.get(symbol).copied(),
|
|
|
|
|
reason: reason.to_string(),
|
|
|
|
|
});
|
|
|
|
|
// Waiting without a fill is not a new order-state transition.
|
|
|
|
|
if !emit_creation_events {
|
|
|
|
|
return Ok(());
|
|
|
|
|
}
|
|
|
|
|
report.order_events.push(OrderEvent {
|
|
|
|
|
date,
|
|
|
|
|
decision_date: None,
|
|
|
|
@@ -4667,6 +4743,7 @@ where
|
|
|
|
|
.unwrap_or("no sellable quantity");
|
|
|
|
|
self.upsert_open_order(OpenOrder {
|
|
|
|
|
order_id,
|
|
|
|
|
submission_time: self.order_origin().submission_time,
|
|
|
|
|
decision_date: Some(self.current_decision_date(date)),
|
|
|
|
|
order_created_date: Some(self.current_order_created_date(date)),
|
|
|
|
|
symbol: symbol.to_string(),
|
|
|
|
@@ -4680,6 +4757,10 @@ where
|
|
|
|
|
execution_cursor: execution_cursors.get(symbol).copied(),
|
|
|
|
|
reason: reason.to_string(),
|
|
|
|
|
});
|
|
|
|
|
// Waiting without a fill is not a new order-state transition.
|
|
|
|
|
if !emit_creation_events {
|
|
|
|
|
return Ok(());
|
|
|
|
|
}
|
|
|
|
|
report.order_events.push(OrderEvent {
|
|
|
|
|
date,
|
|
|
|
|
decision_date: None,
|
|
|
|
@@ -4834,6 +4915,7 @@ where
|
|
|
|
|
{
|
|
|
|
|
self.upsert_open_order(OpenOrder {
|
|
|
|
|
order_id,
|
|
|
|
|
submission_time: self.order_origin().submission_time,
|
|
|
|
|
decision_date: Some(self.current_decision_date(date)),
|
|
|
|
|
order_created_date: Some(self.current_order_created_date(date)),
|
|
|
|
|
symbol: symbol.to_string(),
|
|
|
|
@@ -4847,6 +4929,10 @@ where
|
|
|
|
|
execution_cursor: execution_cursors.get(symbol).copied(),
|
|
|
|
|
reason: reason.to_string(),
|
|
|
|
|
});
|
|
|
|
|
// Waiting without a fill is not a new order-state transition.
|
|
|
|
|
if !emit_creation_events {
|
|
|
|
|
return Ok(());
|
|
|
|
|
}
|
|
|
|
|
report.order_events.push(OrderEvent {
|
|
|
|
|
date,
|
|
|
|
|
decision_date: None,
|
|
|
|
@@ -5000,6 +5086,7 @@ where
|
|
|
|
|
if keep_open {
|
|
|
|
|
self.upsert_open_order(OpenOrder {
|
|
|
|
|
order_id,
|
|
|
|
|
submission_time: self.order_origin().submission_time,
|
|
|
|
|
decision_date: Some(self.current_decision_date(date)),
|
|
|
|
|
order_created_date: Some(self.current_order_created_date(date)),
|
|
|
|
|
symbol: symbol.to_string(),
|
|
|
|
@@ -6197,6 +6284,7 @@ where
|
|
|
|
|
);
|
|
|
|
|
return Ok(());
|
|
|
|
|
};
|
|
|
|
|
let limit_price = limit_price.or_else(|| self.is_post_close_fixed_price(date).then_some(snapshot.close));
|
|
|
|
|
let Some(candidate) = data.candidate(date, symbol) else {
|
|
|
|
|
Self::reject_unavailable_order(
|
|
|
|
|
report,
|
|
|
|
@@ -6387,6 +6475,7 @@ where
|
|
|
|
|
if Self::keeps_remainder_open(remainder_policy) {
|
|
|
|
|
self.upsert_open_order(OpenOrder {
|
|
|
|
|
order_id,
|
|
|
|
|
submission_time: self.order_origin().submission_time,
|
|
|
|
|
decision_date: Some(self.current_decision_date(date)),
|
|
|
|
|
order_created_date: Some(self.current_order_created_date(date)),
|
|
|
|
|
symbol: symbol.to_string(),
|
|
|
|
@@ -6400,6 +6489,10 @@ where
|
|
|
|
|
execution_cursor: execution_cursors.get(symbol).copied(),
|
|
|
|
|
reason: reason.to_string(),
|
|
|
|
|
});
|
|
|
|
|
// Waiting without a fill is not a new order-state transition.
|
|
|
|
|
if !emit_creation_events {
|
|
|
|
|
return Ok(());
|
|
|
|
|
}
|
|
|
|
|
report.order_events.push(OrderEvent {
|
|
|
|
|
date,
|
|
|
|
|
decision_date: None,
|
|
|
|
@@ -6621,6 +6714,7 @@ where
|
|
|
|
|
{
|
|
|
|
|
self.upsert_open_order(OpenOrder {
|
|
|
|
|
order_id,
|
|
|
|
|
submission_time: self.order_origin().submission_time,
|
|
|
|
|
decision_date: Some(self.current_decision_date(date)),
|
|
|
|
|
order_created_date: Some(self.current_order_created_date(date)),
|
|
|
|
|
symbol: symbol.to_string(),
|
|
|
|
@@ -6634,6 +6728,10 @@ where
|
|
|
|
|
execution_cursor: execution_cursors.get(symbol).copied(),
|
|
|
|
|
reason: reason.to_string(),
|
|
|
|
|
});
|
|
|
|
|
// Waiting without a fill is not a new order-state transition.
|
|
|
|
|
if !emit_creation_events {
|
|
|
|
|
return Ok(());
|
|
|
|
|
}
|
|
|
|
|
report.order_events.push(OrderEvent {
|
|
|
|
|
date,
|
|
|
|
|
decision_date: None,
|
|
|
|
@@ -6789,6 +6887,7 @@ where
|
|
|
|
|
if keep_open {
|
|
|
|
|
self.upsert_open_order(OpenOrder {
|
|
|
|
|
order_id,
|
|
|
|
|
submission_time: self.order_origin().submission_time,
|
|
|
|
|
decision_date: Some(self.current_decision_date(date)),
|
|
|
|
|
order_created_date: Some(self.current_order_created_date(date)),
|
|
|
|
|
symbol: symbol.to_string(),
|
|
|
|
@@ -7442,14 +7541,24 @@ where
|
|
|
|
|
|
|
|
|
|
let runtime_start_time = self.runtime_intraday_start_time.get();
|
|
|
|
|
let runtime_end_time = self.runtime_intraday_end_time.get();
|
|
|
|
|
let start_cursor = post_close_window.map(|window| window.0).or_else(|| {
|
|
|
|
|
let start_cursor = post_close_window.map(|window| {
|
|
|
|
|
runtime_start_time.map_or(window.0, |start| window.0.max(date.and_time(start)))
|
|
|
|
|
}).or_else(|| {
|
|
|
|
|
algo_request
|
|
|
|
|
.and_then(|request| request.start_time)
|
|
|
|
|
.or(runtime_start_time)
|
|
|
|
|
.or(self.intraday_execution_start_time)
|
|
|
|
|
.map(|start_time| date.and_time(start_time))
|
|
|
|
|
});
|
|
|
|
|
let end_cursor = post_close_window.map(|window| window.1).or_else(|| {
|
|
|
|
|
let start_cursor = if let Some(origin) = self.runtime_resting_order_origin.get()
|
|
|
|
|
&& origin.created_date == Some(date)
|
|
|
|
|
&& let Some(submitted) = origin.submission_time
|
|
|
|
|
{
|
|
|
|
|
Some(start_cursor.map_or(date.and_time(submitted), |cursor| cursor.max(date.and_time(submitted))))
|
|
|
|
|
} else { start_cursor };
|
|
|
|
|
let end_cursor = post_close_window.map(|window| {
|
|
|
|
|
runtime_end_time.map_or(window.1, |end| window.1.min(date.and_time(end)))
|
|
|
|
|
}).or_else(|| {
|
|
|
|
|
algo_request
|
|
|
|
|
.and_then(|request| request.end_time)
|
|
|
|
|
.or(runtime_end_time)
|
|
|
|
@@ -8104,6 +8213,7 @@ mod tests {
|
|
|
|
|
order_id,
|
|
|
|
|
decision_date: None,
|
|
|
|
|
order_created_date: None,
|
|
|
|
|
submission_time: None,
|
|
|
|
|
symbol: "000001.SZ".to_string(),
|
|
|
|
|
side: OrderSide::Buy,
|
|
|
|
|
requested_quantity: 200,
|
|
|
|
@@ -8634,6 +8744,53 @@ mod tests {
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn gtc_resting_order_keeps_its_session_and_original_dates_across_days() {
|
|
|
|
|
let first = chrono::NaiveDate::from_ymd_opt(2026, 7, 6).unwrap();
|
|
|
|
|
let second = first.succ_opt().unwrap();
|
|
|
|
|
let mut snapshot = dated_limit_test_snapshot(first);
|
|
|
|
|
snapshot.open = 10.2;
|
|
|
|
|
snapshot.close = 9.8;
|
|
|
|
|
snapshot.upper_limit = 20.;
|
|
|
|
|
snapshot.lower_limit = 1.;
|
|
|
|
|
let mut next = snapshot.clone(); next.date = second;
|
|
|
|
|
let mut opening = limit_test_quote(10.2,10.2,10.2);
|
|
|
|
|
opening.date=first; opening.timestamp=first.and_hms_opt(9,30,0).unwrap();
|
|
|
|
|
let mut closing = opening.clone(); closing.timestamp=first.and_hms_opt(15,5,0).unwrap();
|
|
|
|
|
closing.last_price=9.8; closing.bid1=9.8; closing.ask1=9.8;
|
|
|
|
|
let mut next_open = closing.clone(); next_open.date=second; next_open.timestamp=second.and_hms_opt(9,30,0).unwrap();
|
|
|
|
|
let data=DataSet::from_components_with_actions_and_quotes(
|
|
|
|
|
vec![limit_test_instrument()], vec![snapshot,next], Vec::new(),
|
|
|
|
|
vec![dated_limit_test_candidate(first,false,false,true,true),dated_limit_test_candidate(second,false,false,true,true)],
|
|
|
|
|
vec![dated_limit_test_benchmark(first),dated_limit_test_benchmark(second)],Vec::new(),vec![opening,closing,next_open],
|
|
|
|
|
).unwrap();
|
|
|
|
|
let broker=BrokerSimulator::new(ChinaAShareCostModel::default(),ChinaEquityRuleHooks)
|
|
|
|
|
.with_matching_type(MatchingType::CurrentBarClose)
|
|
|
|
|
.with_intraday_execution_start_time(NaiveTime::from_hms_opt(9,30,0).unwrap())
|
|
|
|
|
.with_volume_limit(false).with_liquidity_limit(false);
|
|
|
|
|
let decision=StrategyDecision{order_intents:vec![OrderIntent::WithTimeInForce{
|
|
|
|
|
time_in_force:OrderTimeInForce::Gtc,intent:Box::new(OrderIntent::LimitTargetShares{
|
|
|
|
|
symbol:"000001.SZ".into(),target_quantity:100,limit_price:10.,reason:"original-entry".into(),
|
|
|
|
|
})}],..StrategyDecision::default()};
|
|
|
|
|
let mut portfolio=PortfolioState::new(100000.);
|
|
|
|
|
let report=broker.execute_between(first,&mut portfolio,&data,&decision,
|
|
|
|
|
NaiveTime::from_hms_opt(9,30,0),NaiveTime::from_hms_opt(9,30,0)).unwrap();
|
|
|
|
|
assert!(report.fill_events.is_empty());
|
|
|
|
|
let report=broker.execute_between(first,&mut portfolio,&data,&StrategyDecision::default(),
|
|
|
|
|
NaiveTime::from_hms_opt(15,5,0),NaiveTime::from_hms_opt(15,5,0)).unwrap();
|
|
|
|
|
assert!(report.fill_events.is_empty()); assert!(report.order_events.is_empty());
|
|
|
|
|
assert!(broker.has_open_orders());
|
|
|
|
|
portfolio.begin_trading_day();
|
|
|
|
|
let report=broker.execute_between(second,&mut portfolio,&data,&StrategyDecision::default(),
|
|
|
|
|
NaiveTime::from_hms_opt(9,30,0),NaiveTime::from_hms_opt(9,30,0)).unwrap();
|
|
|
|
|
assert_eq!(report.fill_events.len(),1,"{report:?}");
|
|
|
|
|
assert_eq!(report.fill_events[0].order_created_date,Some(first));
|
|
|
|
|
assert_eq!(report.fill_events[0].decision_date,Some(first));
|
|
|
|
|
assert_eq!(report.fill_events[0].execution_date,Some(second));
|
|
|
|
|
assert!(!broker.has_open_orders());
|
|
|
|
|
assert!(broker.runtime_resting_order_origin.get().is_none());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn 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");
|
|
|
|
@@ -8677,6 +8834,14 @@ mod tests {
|
|
|
|
|
)
|
|
|
|
|
.expect("post-close order executes");
|
|
|
|
|
|
|
|
|
|
assert!(report.fill_events.is_empty(), "15:00 must not receive a future 15:05 fill");
|
|
|
|
|
assert!(broker.has_open_orders());
|
|
|
|
|
let report = broker.execute_between(date, &mut portfolio, &data, &StrategyDecision::default(),
|
|
|
|
|
NaiveTime::from_hms_opt(15,4,0),NaiveTime::from_hms_opt(15,4,0)).unwrap();
|
|
|
|
|
assert!(report.fill_events.is_empty());
|
|
|
|
|
assert!(report.order_events.is_empty());
|
|
|
|
|
let report = broker.execute_between(date, &mut portfolio, &data, &StrategyDecision::default(),
|
|
|
|
|
NaiveTime::from_hms_opt(15,5,0),NaiveTime::from_hms_opt(15,5,0)).unwrap();
|
|
|
|
|
assert_eq!(report.fill_events.len(), 1, "{report:?}");
|
|
|
|
|
let fill = &report.fill_events[0];
|
|
|
|
|
assert_eq!(fill.price, 10.0, "fixed-price trading uses official close");
|
|
|
|
|