chore: 更新 fidc-backtest-engine - 2026-05-08

This commit is contained in:
boris
2026-05-08 07:34:04 -07:00
parent a47c7c3e49
commit 65742d4d5e
6 changed files with 407 additions and 152 deletions

View File

@@ -110,6 +110,7 @@ pub struct BrokerSimulator<C, R> {
volume_limit: bool,
inactive_limit: bool,
liquidity_limit: bool,
strict_value_budget: bool,
intraday_execution_start_time: Option<NaiveTime>,
runtime_intraday_start_time: Cell<Option<NaiveTime>>,
runtime_intraday_end_time: Cell<Option<NaiveTime>>,
@@ -130,6 +131,7 @@ impl<C, R> BrokerSimulator<C, R> {
volume_limit: true,
inactive_limit: true,
liquidity_limit: true,
strict_value_budget: false,
intraday_execution_start_time: None,
runtime_intraday_start_time: Cell::new(None),
runtime_intraday_end_time: Cell::new(None),
@@ -154,6 +156,7 @@ impl<C, R> BrokerSimulator<C, R> {
volume_limit: true,
inactive_limit: true,
liquidity_limit: true,
strict_value_budget: false,
intraday_execution_start_time: None,
runtime_intraday_start_time: Cell::new(None),
runtime_intraday_end_time: Cell::new(None),
@@ -177,6 +180,11 @@ impl<C, R> BrokerSimulator<C, R> {
self
}
pub fn with_strict_value_budget(mut self, enabled: bool) -> Self {
self.strict_value_budget = enabled;
self
}
pub fn with_volume_percent(mut self, volume_percent: f64) -> Self {
self.volume_percent = volume_percent;
self
@@ -3388,6 +3396,16 @@ where
requested_qty
}
fn value_budget_gross_limit(&self, value_budget: Option<f64>) -> Option<f64> {
value_budget.map(|budget| {
if self.strict_value_budget {
budget
} else {
budget + 400.0
}
})
}
fn process_buy(
&self,
date: NaiveDate,
@@ -3559,7 +3577,7 @@ where
execution_cursors,
None,
Some(portfolio.cash()),
value_budget.map(|budget| budget + 400.0),
self.value_budget_gross_limit(value_budget),
algo_request,
limit_price,
);
@@ -3590,7 +3608,7 @@ where
let filled_qty = self.affordable_buy_quantity(
date,
portfolio.cash(),
value_budget.map(|budget| budget + 400.0),
self.value_budget_gross_limit(value_budget),
execution_price,
constrained_qty,
self.minimum_order_quantity(data, symbol),
@@ -3601,7 +3619,7 @@ where
partial_fill_reason,
self.buy_reduction_reason(
portfolio.cash(),
value_budget.map(|budget| budget + 400.0),
self.value_budget_gross_limit(value_budget),
execution_price,
constrained_qty,
filled_qty,
@@ -3660,7 +3678,7 @@ where
side: OrderSide::Buy,
requested_quantity: requested_qty,
filled_quantity: 0,
status: OrderStatus::Rejected,
status: zero_fill_status_for_reason(detail),
reason: format!("{reason}: {detail}"),
});
Self::emit_order_process_event(
@@ -3670,7 +3688,10 @@ where
order_id,
symbol,
OrderSide::Buy,
format!("status=Rejected reason={detail}"),
format!(
"status={:?} reason={detail}",
zero_fill_status_for_reason(detail)
),
);
self.clear_open_order(order_id);
return Ok(());
@@ -4255,57 +4276,43 @@ where
}
if algo_request.is_some() || self.intraday_execution_start_time.is_some() {
let execution_price = self.snapshot_execution_price(snapshot, side);
if !self.price_satisfies_limit(
side,
execution_price,
limit_price,
snapshot.effective_price_tick(),
) {
return None;
}
let execution_price =
self.execution_price_with_limit_slippage(execution_price, limit_price);
let quantity = match side {
OrderSide::Buy => self.affordable_buy_quantity(
date,
cash_limit.unwrap_or(f64::INFINITY),
gross_limit,
execution_price,
requested_qty,
minimum_order_quantity,
order_step_size,
),
OrderSide::Sell => requested_qty,
};
if quantity == 0 {
return None;
}
let next_cursor = algo_request
.and_then(|request| request.start_time)
.or(self.intraday_execution_start_time)
.map(|start_time| date.and_time(start_time) + Duration::seconds(1))
.unwrap_or_else(|| date.and_hms_opt(0, 0, 1).expect("valid midnight"));
return Some(ExecutionFill {
quantity,
quantity: 0,
next_cursor,
legs: vec![ExecutionLeg {
price: execution_price,
quantity,
}],
unfilled_reason: self.buy_reduction_reason(
cash_limit.unwrap_or(f64::INFINITY),
gross_limit,
execution_price,
requested_qty,
quantity,
),
legs: Vec::new(),
unfilled_reason: Some(self.empty_intraday_quote_reason(
quotes,
start_cursor,
end_cursor,
)),
});
}
None
}
fn empty_intraday_quote_reason(
&self,
quotes: &[IntradayExecutionQuote],
start_cursor: Option<NaiveDateTime>,
end_cursor: Option<NaiveDateTime>,
) -> &'static str {
let saw_quote_in_window = quotes.iter().any(|quote| {
!start_cursor.is_some_and(|cursor| quote.timestamp < cursor)
&& !end_cursor.is_some_and(|cursor| quote.timestamp > cursor)
});
if saw_quote_in_window {
"intraday quote liquidity exhausted"
} else {
"no execution quotes after start"
}
}
fn select_execution_fill(
&self,
snapshot: &crate::data::DailyMarketSnapshot,
@@ -4487,7 +4494,10 @@ fn merge_partial_fill_reason(current: Option<String>, next: Option<&str>) -> Opt
fn zero_fill_status_for_reason(reason: &str) -> OrderStatus {
match reason {
"tick no volume" | "tick volume limit" => OrderStatus::Canceled,
"tick no volume"
| "tick volume limit"
| "intraday quote liquidity exhausted"
| "no execution quotes after start" => OrderStatus::Canceled,
_ => OrderStatus::Rejected,
}
}