修正回测部分成交终态合同

This commit is contained in:
boris
2026-08-26 18:28:19 +08:00
parent 935dd47e34
commit 0793473210
3 changed files with 103 additions and 37 deletions
+13 -36
View File
@@ -29,6 +29,15 @@ pub struct BrokerExecutionReport {
pub diagnostics: Vec<String>,
}
impl BrokerExecutionReport {
fn validate(&self) -> Result<(), BacktestError> {
for event in &self.order_events {
event.validate().map_err(BacktestError::Execution)?;
}
Ok(())
}
}
#[derive(Debug, Clone, Copy)]
struct ExecutionLeg {
price: f64,
@@ -1038,6 +1047,7 @@ where
self.runtime_target_position_limit
.set(previous_target_position_limit);
portfolio.prune_flat_positions();
report.validate()?;
return Ok(report);
}
@@ -1153,6 +1163,7 @@ where
}
portfolio.prune_flat_positions();
report.validate()?;
Ok(report)
}
@@ -3850,7 +3861,7 @@ where
let status = if keep_open {
OrderStatus::PartiallyFilled
} else if filled_qty < requested_qty {
final_partial_fill_status(partial_fill_reason.as_deref())
OrderStatus::Canceled
} else {
OrderStatus::Filled
};
@@ -3862,14 +3873,6 @@ where
"order_partial_fill symbol={symbol} side=sell requested={requested_qty} filled={filled_qty} reason={detail}; remaining open"
));
format!("{reason}: partial fill due to {detail}; remaining quantity pending")
} else if status == OrderStatus::PartiallyFilled {
let detail = partial_fill_reason
.as_deref()
.unwrap_or("remaining quantity could not be filled");
report.diagnostics.push(format!(
"order_partial_fill symbol={symbol} side=sell requested={requested_qty} filled={filled_qty} reason={detail}"
));
format!("{reason}: partial fill due to {detail}")
} else if status == OrderStatus::Canceled && filled_qty < requested_qty {
let detail = partial_fill_reason
.as_deref()
@@ -5506,7 +5509,7 @@ where
let status = if keep_open {
OrderStatus::PartiallyFilled
} else if filled_qty < requested_qty {
final_partial_fill_status(partial_fill_reason.as_deref())
OrderStatus::Canceled
} else {
OrderStatus::Filled
};
@@ -5518,14 +5521,6 @@ where
"order_partial_fill symbol={symbol} side=buy requested={requested_qty} filled={filled_qty} reason={detail}; remaining open"
));
format!("{reason}: partial fill due to {detail}; remaining quantity pending")
} else if status == OrderStatus::PartiallyFilled {
let detail = partial_fill_reason
.as_deref()
.unwrap_or("remaining quantity could not be filled");
report.diagnostics.push(format!(
"order_partial_fill symbol={symbol} side=buy requested={requested_qty} filled={filled_qty} reason={detail}"
));
format!("{reason}: partial fill due to {detail}")
} else if status == OrderStatus::Canceled && filled_qty < requested_qty {
let detail = partial_fill_reason
.as_deref()
@@ -6589,24 +6584,6 @@ fn zero_fill_status_for_reason(reason: &str) -> OrderStatus {
}
}
fn final_partial_fill_status(partial_reason: Option<&str>) -> OrderStatus {
match partial_reason {
Some(reason)
if reason.contains("market liquidity or volume limit")
|| reason.contains("intraday quote liquidity exhausted")
|| reason.contains("no execution quotes at or before start")
|| reason.contains("no execution quotes after start")
|| reason.contains("upper_limit")
|| reason.contains("lower_limit")
|| reason.contains("open at or above upper limit")
|| reason.contains("open at or below lower limit") =>
{
OrderStatus::Canceled
}
_ => OrderStatus::PartiallyFilled,
}
}
fn price_field_name(field: PriceField) -> &'static str {
match field {
PriceField::DayOpen => "day_open",
+84
View File
@@ -106,6 +106,49 @@ pub struct OrderEvent {
pub reason: String,
}
impl OrderEvent {
pub fn validate(&self) -> Result<(), String> {
if self.symbol.trim().is_empty() || self.requested_quantity == 0 {
return Err(format!(
"invalid order identity/quantity order_id={:?} symbol={} requested={}",
self.order_id, self.symbol, self.requested_quantity
));
}
if self.filled_quantity > self.requested_quantity {
return Err(format!(
"order overfill order_id={:?} requested={} filled={}",
self.order_id, self.requested_quantity, self.filled_quantity
));
}
let quantity_valid = match self.status {
OrderStatus::Pending => self.filled_quantity < self.requested_quantity,
OrderStatus::Filled => self.filled_quantity == self.requested_quantity,
OrderStatus::PartiallyFilled => {
self.filled_quantity > 0 && self.filled_quantity < self.requested_quantity
}
OrderStatus::Canceled => self.filled_quantity < self.requested_quantity,
OrderStatus::Rejected => self.filled_quantity == 0,
};
if !quantity_valid {
return Err(format!(
"order status/quantity mismatch order_id={:?} status={} requested={} filled={}",
self.order_id,
self.status.as_str(),
self.requested_quantity,
self.filled_quantity
));
}
if self.reason.trim().is_empty() {
return Err(format!(
"order reason is empty order_id={:?} status={}",
self.order_id,
self.status.as_str()
));
}
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FillEvent {
#[serde(with = "date_format")]
@@ -250,3 +293,44 @@ pub struct ProcessEvent {
pub side: Option<OrderSide>,
pub detail: String,
}
#[cfg(test)]
mod tests {
use chrono::NaiveDate;
use super::{OrderEvent, OrderSide, OrderStatus};
fn order_event(status: OrderStatus, filled_quantity: u32) -> OrderEvent {
OrderEvent {
date: NaiveDate::from_ymd_opt(2025, 1, 2).unwrap(),
decision_date: None,
order_created_date: None,
execution_date: None,
order_id: Some(1),
symbol: "600000.SH".to_string(),
side: OrderSide::Buy,
requested_quantity: 100,
filled_quantity,
status,
reason: "test".to_string(),
}
}
#[test]
fn order_event_status_quantity_contract_is_explicit() {
assert!(order_event(OrderStatus::Pending, 0).validate().is_ok());
assert!(
order_event(OrderStatus::PartiallyFilled, 40)
.validate()
.is_ok()
);
assert!(order_event(OrderStatus::Filled, 100).validate().is_ok());
assert!(order_event(OrderStatus::Canceled, 40).validate().is_ok());
assert!(order_event(OrderStatus::Rejected, 0).validate().is_ok());
assert!(order_event(OrderStatus::PartiallyFilled, 0).validate().is_err());
assert!(order_event(OrderStatus::Filled, 99).validate().is_err());
assert!(order_event(OrderStatus::Canceled, 100).validate().is_err());
assert!(order_event(OrderStatus::Rejected, 1).validate().is_err());
}
}
@@ -5150,8 +5150,13 @@ fn broker_reserves_sellable_quantity_for_open_limit_sells() {
assert_eq!(report.order_events.len(), 2);
assert_eq!(report.order_events[0].status, OrderStatus::Pending);
assert_eq!(report.order_events[1].status, OrderStatus::PartiallyFilled);
assert_eq!(report.order_events[1].status, OrderStatus::Canceled);
assert_eq!(report.order_events[1].filled_quantity, 100);
assert!(report.order_events[1].reason.contains("remaining quantity canceled"));
let open_orders = broker.open_order_views();
assert_eq!(open_orders.len(), 1);
assert_eq!(open_orders[0].reason, "reserve_sell");
assert_eq!(open_orders[0].remaining_quantity, 200);
assert_eq!(
portfolio.position("000002.SZ").expect("position").quantity,
200