修复日内时钟提前记账并按原订单续执行算法单
This commit is contained in:
+412
-61
@@ -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}"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,677 @@
|
||||
use super::*;
|
||||
|
||||
fn time(minute: u32) -> NaiveTime {
|
||||
NaiveTime::from_hms_opt(10, minute, 0).unwrap()
|
||||
}
|
||||
|
||||
fn data(quotes: &[(u32, f64, u32)]) -> DataSet {
|
||||
data_with_snapshot(quotes, limit_test_snapshot())
|
||||
}
|
||||
|
||||
fn data_with_snapshot(quotes: &[(u32, f64, u32)], snapshot: DailyMarketSnapshot) -> DataSet {
|
||||
DataSet::from_components_with_actions_and_quotes(
|
||||
vec![limit_test_instrument()],
|
||||
vec![snapshot],
|
||||
vec![],
|
||||
vec![limit_test_candidate(true, true)],
|
||||
vec![limit_test_benchmark()],
|
||||
vec![],
|
||||
quotes
|
||||
.iter()
|
||||
.map(|&(minute, price, volume)| {
|
||||
let mut quote = limit_test_quote(price, price, price);
|
||||
quote.timestamp = quote.date.and_time(time(minute));
|
||||
quote.volume_delta = u64::from(volume);
|
||||
quote.amount_delta = price * f64::from(volume);
|
||||
quote.bid1_volume = u64::from(volume / 100);
|
||||
quote.ask1_volume = u64::from(volume / 100);
|
||||
quote
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn broker() -> BrokerSimulator<ChinaAShareCostModel, ChinaEquityRuleHooks> {
|
||||
BrokerSimulator::new(
|
||||
ChinaAShareCostModel::default()
|
||||
.with_commission_rate(0.0003)
|
||||
.with_minimum_commission(5.),
|
||||
ChinaEquityRuleHooks,
|
||||
)
|
||||
.with_matching_type(MatchingType::MinuteLast)
|
||||
.with_execution_price_field(PriceField::Last)
|
||||
.with_intraday_execution_start_time(time(0))
|
||||
.with_volume_limit(true)
|
||||
.with_volume_percent(0.25)
|
||||
.with_liquidity_limit(false)
|
||||
.with_inactive_limit(false)
|
||||
.with_strict_value_budget(true)
|
||||
}
|
||||
|
||||
fn intent(style: AlgoOrderStyle, value: f64) -> StrategyDecision {
|
||||
StrategyDecision {
|
||||
order_intents: vec![OrderIntent::AlgoValue {
|
||||
symbol: "000001.SZ".into(),
|
||||
value,
|
||||
style,
|
||||
start_time: Some(time(0)),
|
||||
end_time: Some(time(10)),
|
||||
reason: "clock-algorithm".into(),
|
||||
}],
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn step(
|
||||
broker: &BrokerSimulator<ChinaAShareCostModel, ChinaEquityRuleHooks>,
|
||||
portfolio: &mut PortfolioState,
|
||||
data: &DataSet,
|
||||
minute: u32,
|
||||
decision: &StrategyDecision,
|
||||
) -> BrokerExecutionReport {
|
||||
broker
|
||||
.execute_between(
|
||||
limit_test_snapshot().date,
|
||||
portfolio,
|
||||
data,
|
||||
decision,
|
||||
Some(time(minute)),
|
||||
Some(time(minute)),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn twap_clock_preserves_quantity_prices_fees_budget_and_parent_order() {
|
||||
let data = data(&[
|
||||
(0, 10., 4_000),
|
||||
(2, 10.1, 4_000),
|
||||
(5, 10.2, 4_000),
|
||||
(10, 10.3, 4_000),
|
||||
]);
|
||||
let decision = intent(AlgoOrderStyle::Twap, 10_000.);
|
||||
let mut synchronous_account = PortfolioState::new(20_000.);
|
||||
let reference = broker()
|
||||
.execute(
|
||||
limit_test_snapshot().date,
|
||||
&mut synchronous_account,
|
||||
&data,
|
||||
&decision,
|
||||
)
|
||||
.unwrap();
|
||||
let broker = broker();
|
||||
let mut account = PortfolioState::new(20_000.);
|
||||
let mut fills = Vec::new();
|
||||
let mut events = Vec::new();
|
||||
let empty = StrategyDecision::default();
|
||||
for minute in [0, 2, 5, 10] {
|
||||
let batch = step(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
minute,
|
||||
if minute == 0 { &decision } else { &empty },
|
||||
);
|
||||
assert!(
|
||||
batch
|
||||
.fill_events
|
||||
.iter()
|
||||
.all(|fill| fill.execution_timestamp.unwrap().time() <= time(minute))
|
||||
);
|
||||
fills.extend(batch.fill_events);
|
||||
events.extend(batch.order_events);
|
||||
}
|
||||
let canonical = |rows: &[crate::events::FillEvent]| {
|
||||
rows.iter()
|
||||
.map(|fill| {
|
||||
(
|
||||
fill.quantity,
|
||||
fill.price.to_bits(),
|
||||
fill.commission.to_bits(),
|
||||
fill.stamp_tax.to_bits(),
|
||||
fill.transfer_fee.to_bits(),
|
||||
fill.execution_timestamp,
|
||||
fill.order_id,
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
assert_eq!(canonical(&fills), canonical(&reference.fill_events));
|
||||
assert_eq!(account.cash(), synchronous_account.cash());
|
||||
assert_eq!(fills.iter().map(|fill| fill.quantity).sum::<u32>(), 900);
|
||||
assert_eq!(fills.iter().map(|fill| fill.commission).sum::<f64>(), 5.);
|
||||
assert!(fills.iter().map(|fill| -fill.net_cash_flow).sum::<f64>() <= 10_000.);
|
||||
assert!(events.iter().all(|event| event.order_id == Some(1)));
|
||||
assert_eq!(events.last().unwrap().status, OrderStatus::Filled);
|
||||
assert!(broker.open_order_views().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_algorithm_cancel_releases_reservation_and_never_executes_the_remainder() {
|
||||
let data = data(&[
|
||||
(0, 10., 4_000),
|
||||
(2, 10., 4_000),
|
||||
(5, 10., 4_000),
|
||||
(10, 10., 4_000),
|
||||
]);
|
||||
let broker = broker();
|
||||
let mut account = PortfolioState::new(20_000.);
|
||||
step(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
0,
|
||||
&intent(AlgoOrderStyle::Twap, 10_000.),
|
||||
);
|
||||
assert_eq!(broker.open_order_views()[0].reserved_cash, Some(10_000.));
|
||||
let partial = step(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
2,
|
||||
&StrategyDecision::default(),
|
||||
);
|
||||
assert_eq!(
|
||||
partial
|
||||
.fill_events
|
||||
.iter()
|
||||
.map(|fill| fill.quantity)
|
||||
.sum::<u32>(),
|
||||
100
|
||||
);
|
||||
let working = broker.open_order_views();
|
||||
assert_eq!(working[0].order_id, 1);
|
||||
assert_eq!(working[0].filled_quantity, 100);
|
||||
assert_eq!(
|
||||
working[0].reserved_cash,
|
||||
Some(10_000. + partial.fill_events[0].net_cash_flow)
|
||||
);
|
||||
let cancel = step(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
3,
|
||||
&StrategyDecision {
|
||||
order_intents: vec![OrderIntent::CancelAll {
|
||||
reason: "explicit-user-cancel".into(),
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
assert!(cancel.fill_events.is_empty());
|
||||
assert_eq!(
|
||||
cancel.order_events.last().unwrap().status,
|
||||
OrderStatus::Canceled
|
||||
);
|
||||
assert_eq!(cancel.order_events.last().unwrap().filled_quantity, 100);
|
||||
assert!(broker.open_order_views().is_empty());
|
||||
assert!(
|
||||
step(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
10,
|
||||
&StrategyDecision::default()
|
||||
)
|
||||
.fill_events
|
||||
.is_empty()
|
||||
);
|
||||
assert_eq!(account.position("000001.SZ").unwrap().quantity, 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn algorithm_expiry_without_a_quote_does_not_reuse_old_liquidity() {
|
||||
let data = data(&[(0, 10., 4_000), (2, 10., 4_000)]);
|
||||
let broker = broker();
|
||||
let mut account = PortfolioState::new(20_000.);
|
||||
step(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
0,
|
||||
&intent(AlgoOrderStyle::Twap, 10_000.),
|
||||
);
|
||||
step(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
2,
|
||||
&StrategyDecision::default(),
|
||||
);
|
||||
assert_eq!(
|
||||
broker.next_day_order_expiry(limit_test_snapshot().date),
|
||||
Some(time(10))
|
||||
);
|
||||
let terminal = step(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
10,
|
||||
&StrategyDecision::default(),
|
||||
);
|
||||
assert!(terminal.fill_events.is_empty());
|
||||
assert_eq!(
|
||||
terminal.order_events.last().unwrap().status,
|
||||
OrderStatus::Expired
|
||||
);
|
||||
assert_eq!(terminal.order_events.last().unwrap().filled_quantity, 100);
|
||||
assert!(
|
||||
terminal
|
||||
.process_events
|
||||
.iter()
|
||||
.any(|event| event.detail.contains("Expired")),
|
||||
"{:?}",
|
||||
terminal.process_events
|
||||
);
|
||||
assert!(broker.open_order_views().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn separate_buy_cannot_spend_the_working_algorithms_cash_budget() {
|
||||
let data = data(&[
|
||||
(0, 10., 4_000),
|
||||
(1, 10., 4_000),
|
||||
(2, 10., 4_000),
|
||||
(10, 10., 4_000),
|
||||
]);
|
||||
let broker = broker();
|
||||
let mut account = PortfolioState::new(11_000.);
|
||||
step(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
0,
|
||||
&intent(AlgoOrderStyle::Twap, 10_000.),
|
||||
);
|
||||
let other = step(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
1,
|
||||
&StrategyDecision {
|
||||
order_intents: vec![OrderIntent::Shares {
|
||||
symbol: "000001.SZ".into(),
|
||||
quantity: 1_000,
|
||||
reason: "separate-buy".into(),
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
assert!(
|
||||
other.fill_events.is_empty(),
|
||||
"cash reserved for order 1 was spent: {:?}",
|
||||
other.fill_events
|
||||
);
|
||||
let final_batch = step(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
10,
|
||||
&StrategyDecision::default(),
|
||||
);
|
||||
assert!(
|
||||
final_batch
|
||||
.fill_events
|
||||
.iter()
|
||||
.all(|fill| fill.order_id == Some(1))
|
||||
);
|
||||
assert_eq!(account.position("000001.SZ").unwrap().quantity, 900);
|
||||
assert!(account.cash() >= 1_000.);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changing_the_later_daily_close_does_not_resize_an_algorithm_submitted_now() {
|
||||
let quotes = [(0, 10., 4_000), (2, 10.1, 4_000), (10, 10.2, 4_000)];
|
||||
let mut changed = limit_test_snapshot();
|
||||
changed.close = 100.;
|
||||
changed.last_price = 100.;
|
||||
let run = |data: DataSet| {
|
||||
let broker = broker();
|
||||
let mut account = PortfolioState::new(20_000.);
|
||||
let initial = step(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
0,
|
||||
&intent(AlgoOrderStyle::Twap, 10_000.),
|
||||
);
|
||||
assert!(initial.fill_events.is_empty());
|
||||
let quantity = broker.open_order_views()[0].requested_quantity;
|
||||
let final_batch = step(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
10,
|
||||
&StrategyDecision::default(),
|
||||
);
|
||||
(
|
||||
quantity,
|
||||
final_batch
|
||||
.fill_events
|
||||
.iter()
|
||||
.map(|fill| {
|
||||
(
|
||||
fill.quantity,
|
||||
fill.price.to_bits(),
|
||||
fill.net_cash_flow.to_bits(),
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
};
|
||||
assert_eq!(
|
||||
run(data("es)),
|
||||
run(data_with_snapshot("es, changed))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vwap_clock_preserves_cash_costs_and_does_not_spend_future_volume() {
|
||||
let data = data(&[
|
||||
(0, 10., 400),
|
||||
(2, 10., 800),
|
||||
(5, 10., 1_200),
|
||||
(10, 10., 4_000),
|
||||
]);
|
||||
let decision = intent(AlgoOrderStyle::Vwap, 10_000.);
|
||||
let mut synchronous_account = PortfolioState::new(20_000.);
|
||||
let reference = broker()
|
||||
.execute(
|
||||
limit_test_snapshot().date,
|
||||
&mut synchronous_account,
|
||||
&data,
|
||||
&decision,
|
||||
)
|
||||
.unwrap();
|
||||
let broker = broker();
|
||||
let mut account = PortfolioState::new(20_000.);
|
||||
let empty = StrategyDecision::default();
|
||||
let mut filled = 0;
|
||||
let mut commission = 0.;
|
||||
for (minute, expected) in [(0, 100), (2, 300), (5, 600), (10, 900)] {
|
||||
let batch = step(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
minute,
|
||||
if minute == 0 { &decision } else { &empty },
|
||||
);
|
||||
filled += batch
|
||||
.fill_events
|
||||
.iter()
|
||||
.map(|fill| fill.quantity)
|
||||
.sum::<u32>();
|
||||
commission += batch
|
||||
.fill_events
|
||||
.iter()
|
||||
.map(|fill| fill.commission)
|
||||
.sum::<f64>();
|
||||
assert_eq!(filled, expected);
|
||||
assert!(batch.fill_events.iter().all(|fill| fill.order_id == Some(1)
|
||||
&& fill.execution_timestamp.unwrap().time() <= time(minute)));
|
||||
}
|
||||
assert_eq!(account.cash(), synchronous_account.cash());
|
||||
assert_eq!(
|
||||
commission,
|
||||
reference
|
||||
.fill_events
|
||||
.iter()
|
||||
.map(|fill| fill.commission)
|
||||
.sum::<f64>()
|
||||
);
|
||||
assert!(broker.open_order_views().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn global_vwap_matching_keeps_the_same_working_order_between_clock_ticks() {
|
||||
let data = data(&[(0, 10., 400), (2, 10., 400), (10, 10., 4_000)]);
|
||||
let broker = broker().with_matching_type(MatchingType::Vwap);
|
||||
let mut account = PortfolioState::new(20_000.);
|
||||
let first = step(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
0,
|
||||
&StrategyDecision {
|
||||
order_intents: vec![OrderIntent::Shares {
|
||||
symbol: "000001.SZ".into(),
|
||||
quantity: 900,
|
||||
reason: "configured-vwap".into(),
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
assert_eq!(
|
||||
first
|
||||
.fill_events
|
||||
.iter()
|
||||
.map(|fill| fill.quantity)
|
||||
.sum::<u32>(),
|
||||
100
|
||||
);
|
||||
assert_eq!(
|
||||
broker.open_order_views().len(),
|
||||
1,
|
||||
"{:?}",
|
||||
first.order_events
|
||||
);
|
||||
let second = step(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
2,
|
||||
&StrategyDecision::default(),
|
||||
);
|
||||
assert_eq!(second.fill_events[0].quantity, 100);
|
||||
assert_eq!(second.fill_events[0].order_id, Some(1));
|
||||
let final_batch = step(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
10,
|
||||
&StrategyDecision::default(),
|
||||
);
|
||||
assert_eq!(final_batch.fill_events[0].quantity, 700);
|
||||
assert_eq!(final_batch.fill_events[0].order_id, Some(1));
|
||||
assert!(broker.open_order_views().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn algorithm_sell_honors_t_plus_one_and_keeps_original_quantity_after_partial_fills() {
|
||||
let data = data(&[(0, 10., 400), (2, 10., 800), (10, 10., 4_000)]);
|
||||
let date = limit_test_snapshot().date;
|
||||
for acquired_today in [false, true] {
|
||||
let broker = broker();
|
||||
let mut account = PortfolioState::new(20_000.);
|
||||
account.position_mut("000001.SZ").buy(
|
||||
if acquired_today {
|
||||
date
|
||||
} else {
|
||||
date.pred_opt().unwrap()
|
||||
},
|
||||
1_000,
|
||||
10.,
|
||||
);
|
||||
let decision = intent(AlgoOrderStyle::Vwap, -10_000.);
|
||||
let mut fills = Vec::new();
|
||||
let mut events = Vec::new();
|
||||
let empty = StrategyDecision::default();
|
||||
for minute in [0, 2, 10] {
|
||||
let batch = step(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
minute,
|
||||
if minute == 0 { &decision } else { &empty },
|
||||
);
|
||||
fills.extend(batch.fill_events);
|
||||
events.extend(batch.order_events);
|
||||
}
|
||||
assert_eq!(
|
||||
fills.iter().map(|fill| fill.quantity).sum::<u32>(),
|
||||
if acquired_today { 0 } else { 1_000 }
|
||||
);
|
||||
assert!(events.iter().all(|event| event.order_id == Some(1)));
|
||||
if !acquired_today {
|
||||
assert_eq!(events.last().unwrap().status, OrderStatus::Filled);
|
||||
assert_eq!(events.last().unwrap().requested_quantity, 1_000);
|
||||
assert_eq!(events.last().unwrap().filled_quantity, 1_000);
|
||||
}
|
||||
assert!(broker.open_order_views().is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_explicit_ioc_or_fok_does_not_become_a_persistent_algorithm() {
|
||||
let data = data(&[(0, 10., 400), (2, 10., 4_000), (10, 10., 4_000)]);
|
||||
for tif in [
|
||||
OrderTimeInForce::Ioc,
|
||||
OrderTimeInForce::Fok,
|
||||
OrderTimeInForce::Day,
|
||||
OrderTimeInForce::Gtc,
|
||||
] {
|
||||
let broker = broker();
|
||||
let mut account = PortfolioState::new(20_000.);
|
||||
let mut decision = intent(AlgoOrderStyle::Vwap, 10_000.);
|
||||
if !decision.order_intents[0].supports_time_in_force(tif) {
|
||||
decision.order_intents = decision
|
||||
.order_intents
|
||||
.into_iter()
|
||||
.map(|intent| intent.with_time_in_force(tif))
|
||||
.collect();
|
||||
let error = broker
|
||||
.execute_between(
|
||||
limit_test_snapshot().date,
|
||||
&mut account,
|
||||
&data,
|
||||
&decision,
|
||||
Some(time(0)),
|
||||
Some(time(0)),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("is not supported for this order intent")
|
||||
);
|
||||
assert_eq!(account.cash(), 20_000.);
|
||||
assert!(broker.open_order_views().is_empty());
|
||||
continue;
|
||||
}
|
||||
decision.order_intents = decision
|
||||
.order_intents
|
||||
.into_iter()
|
||||
.map(|intent| intent.with_time_in_force(tif))
|
||||
.collect();
|
||||
let first = step(&broker, &mut account, &data, 0, &decision);
|
||||
let persists = matches!(tif, OrderTimeInForce::Day | OrderTimeInForce::Gtc);
|
||||
assert_eq!(
|
||||
!broker.open_order_views().is_empty(),
|
||||
persists,
|
||||
"{tif:?}: {:?}",
|
||||
first.order_events
|
||||
);
|
||||
if !persists {
|
||||
assert!(
|
||||
step(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
10,
|
||||
&StrategyDecision::default()
|
||||
)
|
||||
.fill_events
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_working_algorithms_reserve_only_real_cash_without_starving_the_first() {
|
||||
let data = data(&[(0, 10., 40_000), (10, 10., 40_000)]);
|
||||
let broker = broker();
|
||||
let mut account = PortfolioState::new(15_000.);
|
||||
let mut decision = intent(AlgoOrderStyle::Twap, 10_000.);
|
||||
decision
|
||||
.order_intents
|
||||
.extend(intent(AlgoOrderStyle::Twap, 10_000.).order_intents);
|
||||
step(&broker, &mut account, &data, 0, &decision);
|
||||
assert_eq!(
|
||||
broker
|
||||
.open_order_views()
|
||||
.iter()
|
||||
.map(|order| order.reserved_cash.unwrap())
|
||||
.collect::<Vec<_>>(),
|
||||
vec![10_000., 5_000.]
|
||||
);
|
||||
let report = step(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
10,
|
||||
&StrategyDecision::default(),
|
||||
);
|
||||
assert_eq!(
|
||||
report
|
||||
.fill_events
|
||||
.iter()
|
||||
.map(|fill| (fill.order_id, fill.quantity))
|
||||
.collect::<Vec<_>>(),
|
||||
vec![(Some(1), 900), (Some(2), 500)]
|
||||
);
|
||||
assert!(account.cash() >= 0.);
|
||||
assert!(broker.open_order_views().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_clock_slice_does_not_turn_window_twap_into_an_unlimited_instant_order() {
|
||||
let data = data(&[(0, 10., 100), (2, 10., 100), (10, 10.1, 100)]);
|
||||
let broker = broker()
|
||||
.with_volume_limit(false)
|
||||
.with_liquidity_limit(false);
|
||||
let mut account = PortfolioState::new(20_000.);
|
||||
step(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
0,
|
||||
&intent(AlgoOrderStyle::Twap, 10_000.),
|
||||
);
|
||||
let first = step(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
2,
|
||||
&StrategyDecision::default(),
|
||||
);
|
||||
let last = step(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
10,
|
||||
&StrategyDecision::default(),
|
||||
);
|
||||
assert_eq!(
|
||||
first
|
||||
.fill_events
|
||||
.iter()
|
||||
.map(|fill| fill.quantity)
|
||||
.sum::<u32>(),
|
||||
100
|
||||
);
|
||||
assert_eq!(
|
||||
last.fill_events
|
||||
.iter()
|
||||
.map(|fill| fill.quantity)
|
||||
.sum::<u32>(),
|
||||
100
|
||||
);
|
||||
assert_eq!(
|
||||
last.order_events.last().unwrap().status,
|
||||
OrderStatus::Expired
|
||||
);
|
||||
assert_eq!(last.order_events.last().unwrap().filled_quantity, 200);
|
||||
assert!(broker.open_order_views().is_empty());
|
||||
}
|
||||
+849
-283
File diff suppressed because it is too large
Load Diff
@@ -36234,6 +36234,7 @@ mod tests {
|
||||
avg_price: 0.0,
|
||||
transaction_cost: 0.0,
|
||||
limit_price: 10.2,
|
||||
reserved_cash: None,
|
||||
reason: "pending_limit_sell".to_string(),
|
||||
}];
|
||||
let subscriptions = BTreeSet::new();
|
||||
@@ -36382,6 +36383,7 @@ mod tests {
|
||||
avg_price: 0.0,
|
||||
transaction_cost: 0.0,
|
||||
limit_price: 9.9,
|
||||
reserved_cash: None,
|
||||
reason: "pending_limit_buy".to_string(),
|
||||
},
|
||||
OpenOrderView {
|
||||
@@ -36396,6 +36398,7 @@ mod tests {
|
||||
avg_price: 0.0,
|
||||
transaction_cost: 0.0,
|
||||
limit_price: 10.2,
|
||||
reserved_cash: None,
|
||||
reason: "pending_limit_sell".to_string(),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -102,6 +102,7 @@ pub struct OpenOrderView {
|
||||
pub avg_price: f64,
|
||||
pub transaction_cost: f64,
|
||||
pub limit_price: f64,
|
||||
pub reserved_cash: Option<f64>,
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
@@ -497,6 +498,7 @@ impl StrategyContext<'_> {
|
||||
.iter()
|
||||
.filter(|order| order.side == OrderSide::Buy)
|
||||
.map(|order| {
|
||||
if let Some(reserved) = order.reserved_cash { return reserved; }
|
||||
let price = if order.limit_price.is_finite() {
|
||||
order.limit_price.max(0.0)
|
||||
} else {
|
||||
|
||||
@@ -2748,6 +2748,7 @@ fn strategy_context_exposes_engine_native_account_runtime_view() {
|
||||
avg_price: 0.0,
|
||||
transaction_cost: 0.0,
|
||||
limit_price: 12.0,
|
||||
reserved_cash: None,
|
||||
reason: "pending_buy".to_string(),
|
||||
}];
|
||||
let subscriptions = BTreeSet::new();
|
||||
|
||||
@@ -887,6 +887,42 @@ fn historical_etf_late_signal_freezes_money_and_requantifies_at_next_official_op
|
||||
assert!(result.terminal_audit.is_clean());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deferred_etf_open_does_not_appear_in_a_pre_open_minute_callback() {
|
||||
use fidc_core::strategy::{Strategy,StrategyContext};
|
||||
use std::{cell::RefCell,rc::Rc};
|
||||
struct ObservedPool { inner:EtfPoolSignal, observations:Rc<RefCell<Vec<(chrono::NaiveDateTime,u32,usize)>>> }
|
||||
impl Strategy for ObservedPool {
|
||||
fn name(&self)->&str {"ETF actual opening clock"}
|
||||
fn initial_subscriptions(&self)->BTreeSet<String> {BTreeSet::from([code(1)])}
|
||||
fn decision_quote_times(&self)->Vec<chrono::NaiveTime> {self.inner.decision_quote_times()}
|
||||
fn decision_quote_symbols(&mut self,ctx:&StrategyContext<'_>)->Result<BTreeSet<String>,fidc_core::BacktestError> {self.inner.decision_quote_symbols(ctx)}
|
||||
fn on_day(&mut self,ctx:&StrategyContext<'_>)->Result<StrategyDecision,fidc_core::BacktestError> {self.inner.on_day(ctx)}
|
||||
fn on_minute(&mut self,ctx:&StrategyContext<'_>,quote:&IntradayExecutionQuote)->Result<StrategyDecision,fidc_core::BacktestError> {
|
||||
if quote.date==day(5) {self.observations.borrow_mut().push((quote.timestamp,
|
||||
ctx.portfolio.position(&code(2)).map_or(0,|position|position.quantity),ctx.fills.iter().filter(|fill|fill.symbol==code(2)).count()));}
|
||||
Ok(StrategyDecision::default())
|
||||
}
|
||||
}
|
||||
let time=chrono::NaiveTime::from_hms_opt(13,0,0).unwrap();
|
||||
let mut data=etf_fallback_fixture(time);
|
||||
let quote=data.execution_quotes_on(day(5),&code(1))[0].clone();
|
||||
data.add_execution_quotes([(9,15),(9,31)].into_iter().map(|(hour,minute)| {
|
||||
let mut row=quote.clone();row.timestamp=day(5).and_hms_opt(hour,minute,0).unwrap();row
|
||||
}).collect());
|
||||
let observations=Rc::new(RefCell::new(Vec::new()));
|
||||
let broker=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);
|
||||
let result=BacktestEngine::new(data,ObservedPool {inner:EtfPoolSignal{at:time,condition:String::new()},observations:observations.clone()},broker,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(|_|Ok(vec![])).run().unwrap();
|
||||
let observations=observations.borrow();
|
||||
assert_eq!(observations[0],(day(5).and_hms_opt(9,15,0).unwrap(),0,0));
|
||||
assert_eq!(observations[1],(day(5).and_hms_opt(9,31,0).unwrap(),3700,1));
|
||||
assert_eq!(result.fills.iter().filter(|fill|fill.symbol==code(2)).count(),1);
|
||||
}
|
||||
|
||||
#[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();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 日内时钟与手工回放前置问题
|
||||
|
||||
2026-09-14。本轮只有未提交的失败回归,未修改引擎实现,未部署。
|
||||
2026-09-14。本轮时钟与工作中算法单候选已完成本机回归,尚未部署。177仍运行Engine c98bcc3 / Service e81bf47;完整手工影子回放尚未实现。
|
||||
|
||||
## 已复现的精确反例
|
||||
|
||||
@@ -20,6 +20,25 @@
|
||||
|
||||
需覆盖当前/下一开盘、显式时间和默认收盘、限价/市价/算法单、部分成交及取消、股票池卖后续买、跨日/T+1、0%人工覆盖和恢复。已有真实回放与六类Canonical必须按各自合同核对,不能用收益接近或单个对照替代。
|
||||
|
||||
当前失败回归保留在`crates/fidc-core/src/engine.rs`未提交工作树,属于本任务,不删除、不忽略、不发布成绿色测试。下一步直接修复并扩充该回归,再进入逐笔手工回放;不要重新检查已完成的页头或流式消息。
|
||||
上述原失败回归已保留并修复:晚窗口执行与日度回调进入真实日内时钟,不再先写未来持仓。独立信号日及滞后执行的数据合同保留。仅有日内观察或待处理开盘目标时,未显式设时间的日线收盘回调才延至15:00;物理时钟与委托提交时点分离,不能把普通日线收盘撮合误变为15:05盘后委托。
|
||||
|
||||
## 本轮新增证据
|
||||
|
||||
- TWAP旧路径在13:00一次消费13:01、13:05报价,导致13:00观察到900股;现在逐时钟消费,同一父订单保留原始总量、已成交量、剩余金额、最低佣金余额和期限,不重新生成订单。
|
||||
- 分片时钟继续使用原算法窗口决定TWAP比例及深度约束,不把每个瞬时时钟当作新的不限量算法单;VWAP全局撮合也延续同一工作中订单。
|
||||
- 算法定量使用提交时已经可见的报价。改变当日后续收盘价不改变早先订单数量;真正缺报价明确失败,不读未来报价或日线价替代。
|
||||
- 当天已完成委托/成交记录及时移动到运行历史,后续分钟、日度与定时策略回调能读取;不逐分钟复制全部历史。
|
||||
- ETF下一开盘回退保留真实日线开盘价、3700股及原信号日,入账从早间预处理移到09:30事件;反例09:15原来可见3700股,修复后为0,09:31为3700且仅一笔ETF成交。不合成ETF分钟线。
|
||||
- 工作中算法单只预留真实可用现金;两个各10000元意图、15000元账户按顺序预留10000/5000,后续分别成交900/500股,先到订单不被后到订单的超额预留饿死。
|
||||
- 已验证部分成交后撤单、无末尾报价到期、T+1、IOC终止及原合同拒绝算法FOK/GTC;未新增不支持的有效期。
|
||||
- 同一TWAP与同步参考逐笔数量/价格/时间/订单ID/各项费用完全一致;VWAP逐时钟成交金额与总费用一致。最低佣金只扣一次,成交资金不超过冻结预算。
|
||||
|
||||
本机Core 822项通过、9项原有ignore;Trading工作区613项通过(外部PG等原有ignore未当通过);最新main的Runner446/API119项通过。同期main风控候选d2aa16a已保留并组合回归。本机测试不代替177不可变构建与真实数据回放。
|
||||
|
||||
## 发布前置与剩余边界
|
||||
|
||||
177于03:46只读核对仍为3Paper/0Live,原配置与旧委托摘要不变,Source d5b682c6/PID1700096未变,真实路由disabled。SSD剩约1.6GiB;官方编译缓存清理计划无候选,未删除任何数据或构建。官方复用审计确认target-backtest无运行引用,后续只允许带1GiB余量保护的本次构建,不能覆盖在用发布根。
|
||||
|
||||
还需完成Linux精确提交构建、固定历史合同回放及配套发布;通用process-event回调的完整时间/订单观察、盘前调度普通意图、显式手工委托/撤单回放和所有剩余参数矩阵继续跟踪,不以当前核心测试声明完整Goal完成。当前不解除手工影子拒绝门禁,不修改既有任务配置、Source冻结和研究/信号暂停。
|
||||
|
||||
Live取消请求另有待核对项:`CancelRequested`当前在网关返回后才持久化,不能不加说明就把回报时间当最初请求时间。完整手工回放需要验证并补齐真实意图/提交/成交/取消关联,当前生产手工影子仍保持明确拒绝纯比例模拟。
|
||||
|
||||
Reference in New Issue
Block a user