修复日内时钟提前记账并按原订单续执行算法单

This commit is contained in:
boris
2026-09-14 03:52:52 +08:00
parent 3a3091a2cf
commit 237ee15a51
8 changed files with 2001 additions and 346 deletions
+412 -61
View File
@@ -216,6 +216,9 @@ struct OpenOrder {
commission_remaining: Option<f64>,
execution_cursor: Option<NaiveDateTime>,
reason: String,
algo_request: Option<AlgoExecutionRequest>,
value_budget: Option<f64>,
reserved_cash: Option<f64>,
}
#[derive(Debug, Clone, Copy)]
@@ -420,6 +423,15 @@ struct AlgoExecutionRequest {
style: AlgoExecutionStyle,
start_time: Option<NaiveTime>,
end_time: Option<NaiveTime>,
total_quantity: Option<u32>,
filled_quantity: u32,
commission_remaining: Option<f64>,
order_id: Option<u64>,
}
struct RestoreCell<'a, T: Copy>(&'a Cell<T>, T);
impl<T: Copy> Drop for RestoreCell<'_, T> {
fn drop(&mut self) { self.0.set(self.1); }
}
pub struct BrokerSimulator<C, R> {
@@ -450,6 +462,9 @@ pub struct BrokerSimulator<C, R> {
intraday_execution_start_time: Option<NaiveTime>,
runtime_intraday_start_time: Cell<Option<NaiveTime>>,
runtime_intraday_end_time: Cell<Option<NaiveTime>>,
runtime_execution_clock: Cell<Option<NaiveTime>>,
runtime_algo_schedule: Cell<Option<AlgoExecutionRequest>>,
runtime_unprocessed_algorithm_cash: Cell<FixedMoney>,
runtime_decision_date: Cell<Option<NaiveDate>>,
runtime_buy_denials: RefCell<BTreeMap<String, String>>,
runtime_auto_buy_denials: RefCell<BTreeMap<String, String>>,
@@ -494,6 +509,9 @@ impl<C, R> BrokerSimulator<C, R> {
intraday_execution_start_time: None,
runtime_intraday_start_time: Cell::new(None),
runtime_intraday_end_time: Cell::new(None),
runtime_execution_clock: Cell::new(None),
runtime_algo_schedule: Cell::new(None),
runtime_unprocessed_algorithm_cash: Cell::new(FixedMoney::ZERO),
runtime_decision_date: Cell::new(None),
runtime_buy_denials: RefCell::new(BTreeMap::new()),
runtime_auto_buy_denials: RefCell::new(BTreeMap::new()),
@@ -542,6 +560,9 @@ impl<C, R> BrokerSimulator<C, R> {
intraday_execution_start_time: None,
runtime_intraday_start_time: Cell::new(None),
runtime_intraday_end_time: Cell::new(None),
runtime_execution_clock: Cell::new(None),
runtime_algo_schedule: Cell::new(None),
runtime_unprocessed_algorithm_cash: Cell::new(FixedMoney::ZERO),
runtime_decision_date: Cell::new(None),
runtime_buy_denials: RefCell::new(BTreeMap::new()),
runtime_auto_buy_denials: RefCell::new(BTreeMap::new()),
@@ -726,6 +747,10 @@ impl<C, R> BrokerSimulator<C, R> {
.or(self.intraday_execution_start_time)
}
fn execution_clock(&self) -> Option<NaiveTime> {
self.runtime_execution_clock.get().or(self.runtime_intraday_start_time.get())
}
fn order_origin(&self) -> (Option<NaiveDate>, Option<NaiveTime>) {
self.runtime_resting_order_origin.get().map_or(
(self.runtime_order_created_date.get(), self.submission_time()),
@@ -898,6 +923,7 @@ impl<C, R> BrokerSimulator<C, R> {
avg_price: 0.0,
transaction_cost: 0.0,
limit_price: order.limit_price,
reserved_cash: order.reserved_cash,
reason: order.reason.clone(),
})
.collect()
@@ -916,11 +942,12 @@ impl<C, R> BrokerSimulator<C, R> {
fn resting_order_session_close(&self, date: NaiveDate, order: &OpenOrder) -> NaiveTime {
let post_close = self.execution_phase_for_submission(date, order.order_created_date, order.submission_time)
== EquityExecutionPhase::PostCloseFixedPrice;
NaiveTime::from_hms_opt(15, if post_close { 30 } else { 0 }, 0).expect("session end")
let close=NaiveTime::from_hms_opt(15, if post_close { 30 } else { 0 }, 0).expect("session end");
order.algo_request.and_then(|request|request.end_time).map_or(close,|end|end.min(close))
}
pub(crate) fn next_day_order_expiry(&self, date: NaiveDate) -> Option<NaiveTime> {
self.open_orders.borrow().iter().filter(|order| order.time_in_force == OrderTimeInForce::Day)
self.open_orders.borrow().iter().filter(|order| order.time_in_force == OrderTimeInForce::Day || order.algo_request.is_some())
.map(|order| self.resting_order_session_close(date, order)).min()
}
}
@@ -1803,6 +1830,37 @@ where
)
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn execute_coarse_at_clock(
&self,
date: NaiveDate,
decision_date: NaiveDate,
order_created_date: NaiveDate,
decision_total_equity: Option<f64>,
portfolio: &mut PortfolioState,
data: &DataSet,
decision: &StrategyDecision,
clock: Option<NaiveTime>,
) -> Result<BrokerExecutionReport, BacktestError> {
// Advancing the engine clock must not turn a daily closing-bar order
// into an explicitly submitted post-close order.
let _clock_guard = RestoreCell(
&self.runtime_execution_clock,
self.runtime_execution_clock.replace(clock),
);
self.execute_between_with_event_dates_and_decision_equity(
date,
decision_date,
order_created_date,
decision_total_equity,
portfolio,
data,
decision,
None,
clock,
)
}
pub fn execute_between_with_event_dates(
&self,
date: NaiveDate,
@@ -2682,18 +2740,26 @@ where
let mut open_orders = self.open_orders.borrow_mut();
std::mem::take(&mut *open_orders)
};
let reserved=FixedMoney::checked_sum_f64(pending_orders.iter().filter_map(|order|order.reserved_cash))
.ok_or_else(||BacktestError::Execution("working order cash reservation is invalid".into()))?;
let _reservation_guard=RestoreCell(&self.runtime_unprocessed_algorithm_cash,
self.runtime_unprocessed_algorithm_cash.replace(reserved));
for order in pending_orders {
if let Some(reserved)=order.reserved_cash {
self.runtime_unprocessed_algorithm_cash.set(self.runtime_unprocessed_algorithm_cash.get()
.checked_sub(FixedMoney::from_f64(reserved).expect("validated reservation")).expect("reserved cash subset"));
}
if self.matching_type == MatchingType::NextBarOpen && self.runtime_intraday_start_time.get().is_none()
&& order.accepted_date == date {
&& order.accepted_date == date && order.algo_request.is_none() {
self.open_orders.borrow_mut().push(order);
continue;
}
let close = self.resting_order_session_close(date, &order);
let clock = self.submission_time();
let past_day = order.time_in_force == OrderTimeInForce::Day
let clock = self.execution_clock().or(self.submission_time());
let past_day = (order.time_in_force == OrderTimeInForce::Day || order.algo_request.is_some())
&& order.accepted_date < date;
if past_day || clock.is_some_and(|time| time > close) {
if order.time_in_force == OrderTimeInForce::Day {
if order.time_in_force == OrderTimeInForce::Day || order.algo_request.is_some() {
Self::emit_resting_day_expiry(report, date, &order, order.filled_quantity);
} else {
self.open_orders.borrow_mut().push(order);
@@ -2730,7 +2796,18 @@ where
accepted_date: order.accepted_date,
}));
let previous_decision_date = self.runtime_decision_date.replace(order.decision_date);
let execution_result = self.process_limit_shares_internal(
let execution_result = if let Some(mut algorithm)=order.algo_request {
algorithm.total_quantity=Some(order.requested_quantity);
algorithm.filled_quantity=order.filled_quantity;
algorithm.commission_remaining=order.commission_remaining;
if order.side==OrderSide::Buy {
self.process_buy(date,portfolio,data,&order.symbol,order.remaining_quantity,order.order_id,&order.reason,
intraday_turnover,execution_cursors,global_execution_cursor,commission_state,order.value_budget,None,false,false,Some(&algorithm),report)
} else {
self.process_sell(date,portfolio,data,&order.symbol,order.remaining_quantity,order.order_id,&order.reason,
intraday_turnover,execution_cursors,global_execution_cursor,commission_state,None,false,false,Some(&algorithm),report)
}
} else { self.process_limit_shares_internal(
date,
portfolio,
data,
@@ -2745,7 +2822,7 @@ where
global_execution_cursor,
commission_state,
report,
);
) };
self.runtime_time_in_force.set(previous_time_in_force);
self.runtime_resting_order_origin.set(previous_origin);
self.runtime_decision_date.set(previous_decision_date);
@@ -2843,7 +2920,8 @@ where
}
fn emit_resting_day_expiry(report: &mut BrokerExecutionReport, date: NaiveDate, order: &OpenOrder, filled: u32) {
let detail = format!("DAY order expired at market close: {} remaining_quantity={}", order.symbol, order.requested_quantity.saturating_sub(filled));
let label=if order.algo_request.is_some() {"algorithm execution window expired"} else {"DAY order expired at market close"};
let detail = format!("{label}: {} remaining_quantity={}", order.symbol, order.requested_quantity.saturating_sub(filled));
report.order_events.push(OrderEvent {
date, decision_date: order.decision_date, order_created_date: order.order_created_date,
execution_date: Some(date), order_id: Some(order.order_id), symbol: order.symbol.clone(),
@@ -2929,6 +3007,11 @@ where
let target_total_quantity = new_total_quantity.unwrap_or(existing.requested_quantity);
let target_limit_price = new_limit_price.unwrap_or(existing.limit_price);
if existing.algo_request.is_some() {
Self::emit_open_order_update_rejected(report,date,order_id,Some(&existing.symbol),Some(existing.side),reason,
"algorithm schedule is immutable; cancel it before submitting a different schedule");
return;
}
if target_total_quantity == existing.requested_quantity
&& target_limit_price.to_bits() == existing.limit_price.to_bits()
{
@@ -3898,6 +3981,10 @@ where
},
start_time: *start_time,
end_time: *end_time,
total_quantity: None,
filled_quantity: 0,
commission_remaining: None,
order_id: None,
}),
_ => None,
};
@@ -4173,9 +4260,8 @@ where
return self.execution_limit_check_price(snapshot, side);
}
let matching_type = self.matching_type_for_algo_request(algo_request);
let start_cursor = algo_request
.and_then(|request| request.start_time)
.or(self.runtime_intraday_start_time.get())
let start_cursor = self.execution_clock()
.or_else(||algo_request.and_then(|request| request.start_time))
.or(self.intraday_execution_start_time)
.map(|start_time| date.and_time(start_time));
self.latest_known_quote_at_or_before(
@@ -4187,7 +4273,9 @@ where
false,
)
.and_then(|quote| self.select_quote_reference_price(snapshot, quote, side, matching_type))
.unwrap_or_else(|| self.execution_limit_check_price(snapshot, side))
.unwrap_or_else(|| if algo_request.is_some() && self.execution_clock().is_some() {
f64::NAN
} else {self.execution_limit_check_price(snapshot, side)})
}
#[cfg(test)]
@@ -4534,6 +4622,8 @@ where
algo_request: Option<&AlgoExecutionRequest>,
report: &mut BrokerExecutionReport,
) -> Result<(), BacktestError> {
let algorithm = self.normalized_algorithm(date, requested_qty, order_id, commission_state.get(&order_id).copied(), algo_request);
let algo_request = algorithm.as_ref();
// Existing accepted orders are not canceled by a subsequently enabled lock.
if emit_creation_events && self.runtime_auto_sell_denials.borrow().contains_key(symbol) {
return Ok(());
@@ -4768,6 +4858,9 @@ where
time_in_force: Self::pending_time_in_force(remainder_policy),
commission_remaining: commission_state.get(&order_id).copied(),
execution_cursor: execution_cursors.get(symbol).copied(),
algo_request: None,
value_budget: None,
reserved_cash: None,
reason: reason.to_string(),
});
// Waiting without a fill is not a new order-state transition.
@@ -4859,6 +4952,9 @@ where
time_in_force: Self::pending_time_in_force(remainder_policy),
commission_remaining: commission_state.get(&order_id).copied(),
execution_cursor: execution_cursors.get(symbol).copied(),
algo_request: None,
value_budget: None,
reserved_cash: None,
reason: reason.to_string(),
});
// Waiting without a fill is not a new order-state transition.
@@ -4976,8 +5072,8 @@ where
price: execution_price,
mark_price: self.snapshot_mark_price(snapshot, OrderSide::Sell),
quantity: fillable_qty,
execution_start_timestamp: None,
execution_timestamp: None,
execution_start_timestamp: self.runtime_execution_clock.get().map(|time|date.and_time(time)),
execution_timestamp: self.runtime_execution_clock.get().map(|time|date.and_time(time)),
}],
None,
Vec::new(),
@@ -5014,8 +5110,9 @@ where
let detail = partial_fill_reason
.as_deref()
.unwrap_or("limit price not marketable yet");
if Self::keeps_remainder_open(remainder_policy)
&& Self::limit_order_can_remain_open(Some(detail))
if (Self::keeps_remainder_open(remainder_policy)
&& Self::limit_order_can_remain_open(Some(detail)))
|| self.algorithm_still_working(algo_request, Some(detail))
{
self.upsert_open_order(OpenOrder {
order_id,
@@ -5028,10 +5125,13 @@ where
requested_quantity: requested_qty,
filled_quantity: 0,
remaining_quantity: requested_qty,
limit_price: limit_price.expect("limit price for pending limit sell"),
time_in_force: Self::pending_time_in_force(remainder_policy),
limit_price: if algo_request.is_some() {limit_price.unwrap_or(0.0)} else {limit_price.expect("limit price for pending limit sell")},
time_in_force: if algo_request.is_some() {self.runtime_time_in_force.get().unwrap_or(OrderTimeInForce::Day)} else {Self::pending_time_in_force(remainder_policy)},
commission_remaining: commission_state.get(&order_id).copied(),
execution_cursor: execution_cursors.get(symbol).copied(),
algo_request: Self::progressed_algorithm(algo_request, 0, commission_state.get(&order_id).copied()),
value_budget: None,
reserved_cash: None,
reason: reason.to_string(),
});
// Waiting without a fill is not a new order-state transition.
@@ -5072,7 +5172,7 @@ where
side: OrderSide::Sell,
requested_quantity: requested_qty,
filled_quantity: 0,
status: zero_fill_status_for_reason(detail),
status: self.unfilled_algorithm_status(algo_request, detail),
reason: format!("{reason}: {detail}"),
});
Self::emit_order_process_event(
@@ -5084,7 +5184,7 @@ where
OrderSide::Sell,
format!(
"status={:?} reason={detail}",
zero_fill_status_for_reason(detail)
self.unfilled_algorithm_status(algo_request, detail)
),
);
self.clear_open_order(order_id);
@@ -5185,9 +5285,10 @@ where
*intraday_turnover.entry(symbol.to_string()).or_default() += filled_qty;
let remaining_qty = requested_qty.saturating_sub(filled_qty);
let keep_open = Self::keeps_remainder_open(remainder_policy)
let keep_open = (Self::keeps_remainder_open(remainder_policy)
&& remaining_qty > 0
&& Self::limit_order_can_remain_open(partial_fill_reason.as_deref());
&& Self::limit_order_can_remain_open(partial_fill_reason.as_deref()))
|| (remaining_qty > 0 && self.algorithm_still_working(algo_request,partial_fill_reason.as_deref()));
if keep_open {
self.upsert_open_order(OpenOrder {
order_id,
@@ -5200,10 +5301,13 @@ where
requested_quantity: requested_qty,
filled_quantity: filled_qty,
remaining_quantity: remaining_qty,
limit_price: limit_price.expect("limit price for pending limit sell"),
time_in_force: Self::pending_time_in_force(remainder_policy),
limit_price: if algo_request.is_some() {limit_price.unwrap_or(0.0)} else {limit_price.expect("limit price for pending limit sell")},
time_in_force: if algo_request.is_some() {self.runtime_time_in_force.get().unwrap_or(OrderTimeInForce::Day)} else {Self::pending_time_in_force(remainder_policy)},
commission_remaining: commission_state.get(&order_id).copied(),
execution_cursor: execution_cursors.get(symbol).copied(),
algo_request: Self::progressed_algorithm(algo_request, filled_qty, commission_state.get(&order_id).copied()),
value_budget: None,
reserved_cash: None,
reason: reason.to_string(),
});
} else {
@@ -5213,7 +5317,7 @@ where
let status = if keep_open {
OrderStatus::PartiallyFilled
} else if filled_qty < requested_qty {
OrderStatus::Canceled
if self.algorithm_window_expired(algo_request, partial_fill_reason.as_deref().unwrap_or("")) {OrderStatus::Expired} else {OrderStatus::Canceled}
} else {
OrderStatus::Filled
};
@@ -5250,7 +5354,7 @@ where
status,
reason: order_reason,
});
if matches!(status, OrderStatus::Canceled | OrderStatus::Rejected) {
if matches!(status, OrderStatus::Canceled | OrderStatus::Rejected | OrderStatus::Expired) {
Self::emit_order_process_event(
report,
date,
@@ -5399,6 +5503,10 @@ where
},
start_time,
end_time,
total_quantity: None,
filled_quantity: 0,
commission_remaining: None,
order_id: None,
};
if target_value <= f64::EPSILON {
@@ -6080,12 +6188,19 @@ where
},
start_time,
end_time,
total_quantity: None,
filled_quantity: 0,
commission_remaining: None,
order_id: None,
};
if value > 0.0 {
let round_lot = self.round_lot(data, symbol);
let minimum_order_quantity = self.minimum_order_quantity(data, symbol);
let order_step_size = self.order_step_size(data, symbol);
let price = self.sizing_price(snapshot);
let price = self.execution_order_limit_check_price(date, data, symbol, snapshot, OrderSide::Buy, Some(&algo_request));
if !price.is_finite() || price <= 0.0 {
return Err(BacktestError::MissingPrice {date, symbol:symbol.to_string(), field:"algorithm_submission_price"});
}
let snapshot_requested_qty = self.value_buy_quantity(
date,
value.abs(),
@@ -6126,7 +6241,10 @@ where
report,
)
} else {
let price = self.sizing_price(snapshot);
let price = self.execution_order_limit_check_price(date, data, symbol, snapshot, OrderSide::Sell, Some(&algo_request));
if !price.is_finite() || price <= 0.0 {
return Err(BacktestError::MissingPrice {date, symbol:symbol.to_string(), field:"algorithm_submission_price"});
}
let requested_qty = self.round_buy_quantity(
(value.abs() / price).floor() as u32,
self.minimum_order_quantity(data, symbol),
@@ -6337,6 +6455,9 @@ where
algo_request: Option<&AlgoExecutionRequest>,
report: &mut BrokerExecutionReport,
) -> Result<(), BacktestError> {
let algorithm = self.normalized_algorithm(date, requested_qty, order_id, commission_state.get(&order_id).copied(), algo_request);
let algo_request = algorithm.as_ref();
let fill_start = report.fill_events.len();
if emit_creation_events && self.runtime_auto_buy_denials.borrow().contains_key(symbol) {
return Ok(());
}
@@ -6592,6 +6713,9 @@ where
time_in_force: Self::pending_time_in_force(remainder_policy),
commission_remaining: commission_state.get(&order_id).copied(),
execution_cursor: execution_cursors.get(symbol).copied(),
algo_request: None,
value_budget: None,
reserved_cash: None,
reason: reason.to_string(),
});
// Waiting without a fill is not a new order-state transition.
@@ -6651,13 +6775,14 @@ where
}
};
let value_gross_limit = self.value_buy_gross_limit(value_budget);
let available_cash=self.cash_after_algorithm_reservations(portfolio.cash(),Some(order_id))?;
let buy_cash_limit = if self.strict_value_budget {
value_budget
.filter(|budget| budget.is_finite() && *budget > 0.0)
.map(|budget| portfolio.cash().min(budget))
.unwrap_or_else(|| portfolio.cash())
.map(|budget| available_cash.min(budget))
.unwrap_or(available_cash)
} else {
portfolio.cash()
available_cash
};
let fill = self.resolve_execution_fill(
@@ -6779,8 +6904,8 @@ where
price: execution_price,
mark_price: self.snapshot_mark_price(snapshot, OrderSide::Buy),
quantity: filled_qty,
execution_start_timestamp: None,
execution_timestamp: None,
execution_start_timestamp: self.runtime_execution_clock.get().map(|time|date.and_time(time)),
execution_timestamp: self.runtime_execution_clock.get().map(|time|date.and_time(time)),
}],
None,
Vec::new(),
@@ -6814,8 +6939,9 @@ where
let detail = partial_fill_reason
.as_deref()
.unwrap_or("insufficient cash after fees");
if Self::keeps_remainder_open(remainder_policy)
&& Self::limit_order_can_remain_open(Some(detail))
if (Self::keeps_remainder_open(remainder_policy)
&& Self::limit_order_can_remain_open(Some(detail)))
|| self.algorithm_still_working(algo_request,Some(detail))
{
self.upsert_open_order(OpenOrder {
order_id,
@@ -6828,10 +6954,13 @@ where
requested_quantity: requested_qty,
filled_quantity: 0,
remaining_quantity: requested_qty,
limit_price: limit_price.expect("limit price for pending limit buy"),
time_in_force: Self::pending_time_in_force(remainder_policy),
limit_price: if algo_request.is_some() {limit_price.unwrap_or(0.0)} else {limit_price.expect("limit price for pending limit buy")},
time_in_force: if algo_request.is_some() {self.runtime_time_in_force.get().unwrap_or(OrderTimeInForce::Day)} else {Self::pending_time_in_force(remainder_policy)},
commission_remaining: commission_state.get(&order_id).copied(),
execution_cursor: execution_cursors.get(symbol).copied(),
algo_request: Self::progressed_algorithm(algo_request, 0, commission_state.get(&order_id).copied()),
value_budget: if algo_request.is_some() {value_budget} else {None},
reserved_cash: if algo_request.is_some() {Some(self.algorithm_cash_reservation(date,value_budget,requested_qty,size_check_price,order_id,commission_state.get(&order_id).copied(),data.instruments().get(symbol),portfolio.cash())?)} else {None},
reason: reason.to_string(),
});
// Waiting without a fill is not a new order-state transition.
@@ -6872,7 +7001,7 @@ where
side: OrderSide::Buy,
requested_quantity: requested_qty,
filled_quantity: 0,
status: zero_fill_status_for_reason(detail),
status: self.unfilled_algorithm_status(algo_request, detail),
reason: format!("{reason}: {detail}"),
});
Self::emit_order_process_event(
@@ -6884,7 +7013,7 @@ where
OrderSide::Buy,
format!(
"status={:?} reason={detail}",
zero_fill_status_for_reason(detail)
self.unfilled_algorithm_status(algo_request, detail)
),
);
self.clear_open_order(order_id);
@@ -6987,9 +7116,10 @@ where
*intraday_turnover.entry(symbol.to_string()).or_default() += filled_qty;
let remaining_qty = requested_qty.saturating_sub(filled_qty);
let keep_open = Self::keeps_remainder_open(remainder_policy)
let keep_open = (Self::keeps_remainder_open(remainder_policy)
&& remaining_qty > 0
&& Self::limit_order_can_remain_open(partial_fill_reason.as_deref());
&& Self::limit_order_can_remain_open(partial_fill_reason.as_deref()))
|| (remaining_qty > 0 && self.algorithm_still_working(algo_request,partial_fill_reason.as_deref()));
if keep_open {
self.upsert_open_order(OpenOrder {
order_id,
@@ -7002,10 +7132,13 @@ where
requested_quantity: requested_qty,
filled_quantity: filled_qty,
remaining_quantity: remaining_qty,
limit_price: limit_price.expect("limit price for pending limit buy"),
time_in_force: Self::pending_time_in_force(remainder_policy),
limit_price: if algo_request.is_some() {limit_price.unwrap_or(0.0)} else {limit_price.expect("limit price for pending limit buy")},
time_in_force: if algo_request.is_some() {self.runtime_time_in_force.get().unwrap_or(OrderTimeInForce::Day)} else {Self::pending_time_in_force(remainder_policy)},
commission_remaining: commission_state.get(&order_id).copied(),
execution_cursor: execution_cursors.get(symbol).copied(),
algo_request: Self::progressed_algorithm(algo_request, filled_qty, commission_state.get(&order_id).copied()),
value_budget: if algo_request.is_some() {self.remaining_algorithm_budget(value_budget,&report.fill_events[fill_start..])?} else {None},
reserved_cash: if algo_request.is_some() {Some(self.algorithm_cash_reservation(date,self.remaining_algorithm_budget(value_budget,&report.fill_events[fill_start..])?,remaining_qty,size_check_price,order_id,commission_state.get(&order_id).copied(),data.instruments().get(symbol),portfolio.cash())?)} else {None},
reason: reason.to_string(),
});
} else {
@@ -7015,7 +7148,7 @@ where
let status = if keep_open {
OrderStatus::PartiallyFilled
} else if filled_qty < requested_qty {
OrderStatus::Canceled
if self.algorithm_window_expired(algo_request, partial_fill_reason.as_deref().unwrap_or("")) {OrderStatus::Expired} else {OrderStatus::Canceled}
} else {
OrderStatus::Filled
};
@@ -7052,7 +7185,7 @@ where
status,
reason: order_reason,
});
if matches!(status, OrderStatus::Canceled | OrderStatus::Rejected) {
if matches!(status, OrderStatus::Canceled | OrderStatus::Rejected | OrderStatus::Expired) {
Self::emit_order_process_event(
report,
date,
@@ -7569,6 +7702,192 @@ where
})
}
fn normalized_algorithm(
&self,
date: NaiveDate,
quantity: u32,
order_id: u64,
commission: Option<f64>,
request: Option<&AlgoExecutionRequest>,
) -> Option<AlgoExecutionRequest> {
request
.copied()
.or_else(|| {
(self.matching_type == MatchingType::Vwap).then_some(AlgoExecutionRequest {
style: AlgoExecutionStyle::Vwap,
start_time: self.submission_time(),
end_time: None,
total_quantity: None,
filled_quantity: 0,
commission_remaining: None,
order_id: None,
})
})
.map(|mut request| {
request.total_quantity.get_or_insert(quantity);
request.order_id = Some(order_id);
request.commission_remaining = commission;
if request.start_time.is_none() {
request.start_time = self.execution_clock().or(self.submission_time());
}
if request.end_time.is_none() && request.style == AlgoExecutionStyle::Vwap {
request.end_time = Some(
self.post_close_execution_window(date)
.map(|(_, end)| end.time())
.unwrap_or_else(|| {
NaiveTime::from_hms_opt(15, 0, 0).expect("cash session close")
}),
);
}
request
})
}
fn cash_after_algorithm_reservations(
&self,
cash: f64,
except: Option<u64>,
) -> Result<f64, BacktestError> {
let reserved = FixedMoney::checked_sum_f64(
self.open_orders
.borrow()
.iter()
.filter(|order| except != Some(order.order_id))
.filter_map(|order| order.reserved_cash),
)
.and_then(|amount| amount.checked_add(self.runtime_unprocessed_algorithm_cash.get()))
.ok_or_else(|| BacktestError::Execution("algorithm reserved cash overflow".into()))?;
FixedMoney::from_f64(cash)
.and_then(|cash| cash.checked_sub(reserved))
.map(|available| available.max(FixedMoney::ZERO).to_f64())
.ok_or_else(|| BacktestError::Execution("algorithm available cash is invalid".into()))
}
#[allow(clippy::too_many_arguments)]
fn algorithm_cash_reservation(
&self,
date: NaiveDate,
budget: Option<f64>,
quantity: u32,
price: f64,
order_id: u64,
commission: Option<f64>,
instrument: Option<&Instrument>,
cash: f64,
) -> Result<f64, BacktestError> {
let available = self.cash_after_algorithm_reservations(cash, Some(order_id))?;
if let Some(budget) = budget.filter(|_| self.strict_value_budget) {
return Ok(budget.min(available));
}
let gross = budget.unwrap_or(price * f64::from(quantity));
if !gross.is_finite() || gross < 0. {
return Err(BacktestError::Execution(
"algorithm reservation requires a current price or explicit value budget".into(),
));
}
let mut state = commission
.map(|left| (order_id, left))
.into_iter()
.collect();
let cost = self.cost_model.calculate_with_order_state_for_instrument(
date,
OrderSide::Buy,
gross,
Some(order_id),
&mut state,
instrument,
);
FixedMoney::checked_sum_f64([gross, cost.total()])
.map(|amount| amount.to_f64().min(available))
.ok_or_else(|| BacktestError::Execution("algorithm cash reservation overflow".into()))
}
fn algorithm_still_working(
&self,
request: Option<&AlgoExecutionRequest>,
reason: Option<&str>,
) -> bool {
request.is_some_and(|request| {
self.runtime_intraday_end_time
.get()
.zip(request.end_time)
.is_some_and(|(clock, end)| clock < end)
}) && self
.runtime_time_in_force
.get()
.is_none_or(|tif| matches!(tif, OrderTimeInForce::Day | OrderTimeInForce::Gtc))
&& Self::limit_order_can_remain_open(reason)
}
fn unfilled_algorithm_status(
&self,
request: Option<&AlgoExecutionRequest>,
reason: &str,
) -> OrderStatus {
if self.algorithm_window_expired(request, reason) {
OrderStatus::Expired
} else {
zero_fill_status_for_reason(reason)
}
}
fn algorithm_window_expired(
&self,
request: Option<&AlgoExecutionRequest>,
reason: &str,
) -> bool {
request.is_some_and(|request| {
self.runtime_intraday_end_time
.get()
.zip(request.end_time)
.is_some_and(|(clock, end)| clock >= end)
}) && matches!(
reason,
"intraday quote liquidity exhausted"
| "no execution quotes after start"
| "no execution quotes at or before start"
)
}
fn progressed_algorithm(
request: Option<&AlgoExecutionRequest>,
filled: u32,
commission: Option<f64>,
) -> Option<AlgoExecutionRequest> {
request.copied().map(|mut request| {
request.filled_quantity = request.filled_quantity.saturating_add(filled);
request.commission_remaining = commission;
request
})
}
fn remaining_algorithm_budget(
&self,
budget: Option<f64>,
fills: &[FillEvent],
) -> Result<Option<f64>, BacktestError> {
let Some(budget) = budget else {
return Ok(None);
};
let spent = FixedMoney::checked_sum_f64(fills.iter().map(|fill| {
if self.strict_value_budget {
-fill.net_cash_flow
} else {
fill.gross_amount
}
}))
.ok_or_else(|| {
BacktestError::Execution("algorithm budget spent amount is invalid".into())
})?;
let remaining = FixedMoney::from_f64(budget)
.and_then(|budget| budget.checked_sub(spent))
.filter(|remaining| *remaining >= FixedMoney::ZERO)
.ok_or_else(|| {
BacktestError::Execution("algorithm spent more than its frozen value budget".into())
})?;
Ok(Some(remaining.to_f64()))
}
fn resolve_execution_fill(
&self,
date: NaiveDate,
@@ -7614,6 +7933,12 @@ where
{
Some(start_cursor.map_or(date.and_time(submitted), |cursor| cursor.max(date.and_time(submitted))))
} else { start_cursor };
let start_cursor = if algo_request.is_some() {
match (start_cursor, self.execution_clock().map(|time| date.and_time(time))) {
(Some(declared), Some(clock)) => Some(declared.max(clock)),
(start, _) => start,
}
} else { start_cursor };
let end_cursor = post_close_window.map(|window| {
runtime_end_time.map_or(window.1, |end| window.1.min(date.and_time(end)))
}).or_else(|| {
@@ -7630,10 +7955,17 @@ where
} else {
end_cursor
};
let end_cursor = if algo_request.is_some() {
match (end_cursor, runtime_end_time.map(|time| date.and_time(time))) {
(Some(declared), Some(clock)) => Some(declared.min(clock)),
(end, _) => end,
}
} else { end_cursor };
let quotes = data.execution_quotes_on(date, symbol);
let calibration = self.slippage_calibration(data, snapshot)?;
if let Some(fill) = self.select_execution_fill_with_ledger(
let previous_schedule = self.runtime_algo_schedule.replace(algo_request.copied());
let selected = self.select_execution_fill_with_ledger(
symbol,
snapshot,
quotes,
@@ -7652,7 +7984,9 @@ where
execution_ledger,
calibration.as_ref(),
data.instruments().get(symbol),
)? {
);
self.runtime_algo_schedule.set(previous_schedule);
if let Some(fill) = selected? {
return Ok(Some(fill));
}
@@ -7662,11 +7996,8 @@ where
|| runtime_end_time.is_some()
|| self.intraday_execution_start_time.is_some()
{
let next_cursor = algo_request
.and_then(|request| request.start_time)
.or(runtime_start_time)
.or(self.intraday_execution_start_time)
.map(|start_time| date.and_time(start_time) + Duration::seconds(1))
let next_cursor = start_cursor
.map(|time| time + Duration::seconds(1))
.unwrap_or_else(|| date.and_hms_opt(0, 0, 1).expect("valid midnight"));
return Ok(Some(ExecutionFill {
quantity: 0,
@@ -7778,16 +8109,24 @@ where
return Ok(None);
}
let algo_schedule = self.runtime_algo_schedule.get();
let mut preview_commission_state = BTreeMap::new();
let schedule_start = algo_schedule.and_then(|request| request.start_time)
.map(|time| snapshot.date.and_time(time)).or(start_cursor);
let schedule_end = algo_schedule.and_then(|request| request.end_time)
.map(|time| snapshot.date.and_time(time)).or(end_cursor);
let quote_quantity_limited =
self.quote_quantity_limited_for_window(matching_type, start_cursor, end_cursor);
self.quote_quantity_limited_for_window(matching_type, schedule_start, schedule_end);
let twap_schedule = (matching_type == MatchingType::Twap)
.then(|| TwapSchedule::new(start_cursor, end_cursor, requested_qty))
.then(|| TwapSchedule::new(schedule_start, schedule_end,
algo_schedule.and_then(|request|request.total_quantity).unwrap_or(requested_qty)))
.transpose()?;
let lot = round_lot.max(1);
let exact_time_order_quote = matching_type != MatchingType::MinuteLast
&& start_cursor.is_some()
&& end_cursor.is_some()
&& start_cursor == end_cursor;
&& start_cursor == end_cursor
&& !(algo_schedule.is_some() && schedule_start != schedule_end);
let use_decision_time_quote = !self.is_post_close_fixed_price(snapshot.date)
&& start_cursor.is_some()
&& (matching_type == MatchingType::MinuteLast || exact_time_order_quote);
@@ -7923,7 +8262,8 @@ where
}
let mut take_qty = if let Some(schedule) = &twap_schedule {
remaining_qty.min(available_qty).min(schedule.due_quantity(execution_at, filled_qty))
remaining_qty.min(available_qty).min(schedule.due_quantity(execution_at,
algo_schedule.map_or(0,|request|request.filled_quantity).saturating_add(filled_qty)))
} else {
remaining_qty.min(available_qty)
};
@@ -7984,10 +8324,16 @@ where
);
continue;
}
let candidate_cost = self
.cost_model
.calculate_for_instrument(snapshot.date, OrderSide::Buy, candidate_gross, instrument)
.total();
let candidate_cost = if let Some(request)=algo_schedule {
preview_commission_state.clear();
if let (Some(id),Some(remaining))=(request.order_id,request.commission_remaining) {
preview_commission_state.insert(id,remaining);
}
self.cost_model.calculate_with_order_state_for_instrument(snapshot.date,OrderSide::Buy,
candidate_gross,request.order_id,&mut preview_commission_state,instrument).total()
} else {
self.cost_model.calculate_for_instrument(snapshot.date,OrderSide::Buy,candidate_gross,instrument).total()
};
let candidate_cash =
FixedMoney::checked_sum_f64([candidate_gross, candidate_cost])
.expect("buy cash must be finite fixed-point money")
@@ -8252,6 +8598,8 @@ fn sell_reason(decision: &StrategyDecision, symbol: &str) -> &'static str {
#[cfg(test)]
mod tests {
mod algorithm_clock;
use std::collections::BTreeMap;
use chrono::NaiveTime;
@@ -8291,6 +8639,9 @@ mod tests {
time_in_force: OrderTimeInForce::Gtc,
commission_remaining: None,
execution_cursor: None,
algo_request: None,
value_budget: None,
reserved_cash: None,
reason: format!("order_{order_id}"),
}
}