修正回测部分成交终态合同
This commit is contained in:
@@ -29,6 +29,15 @@ pub struct BrokerExecutionReport {
|
|||||||
pub diagnostics: Vec<String>,
|
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)]
|
#[derive(Debug, Clone, Copy)]
|
||||||
struct ExecutionLeg {
|
struct ExecutionLeg {
|
||||||
price: f64,
|
price: f64,
|
||||||
@@ -1038,6 +1047,7 @@ where
|
|||||||
self.runtime_target_position_limit
|
self.runtime_target_position_limit
|
||||||
.set(previous_target_position_limit);
|
.set(previous_target_position_limit);
|
||||||
portfolio.prune_flat_positions();
|
portfolio.prune_flat_positions();
|
||||||
|
report.validate()?;
|
||||||
return Ok(report);
|
return Ok(report);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1153,6 +1163,7 @@ where
|
|||||||
}
|
}
|
||||||
|
|
||||||
portfolio.prune_flat_positions();
|
portfolio.prune_flat_positions();
|
||||||
|
report.validate()?;
|
||||||
Ok(report)
|
Ok(report)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3850,7 +3861,7 @@ where
|
|||||||
let status = if keep_open {
|
let status = if keep_open {
|
||||||
OrderStatus::PartiallyFilled
|
OrderStatus::PartiallyFilled
|
||||||
} else if filled_qty < requested_qty {
|
} else if filled_qty < requested_qty {
|
||||||
final_partial_fill_status(partial_fill_reason.as_deref())
|
OrderStatus::Canceled
|
||||||
} else {
|
} else {
|
||||||
OrderStatus::Filled
|
OrderStatus::Filled
|
||||||
};
|
};
|
||||||
@@ -3862,14 +3873,6 @@ where
|
|||||||
"order_partial_fill symbol={symbol} side=sell requested={requested_qty} filled={filled_qty} reason={detail}; remaining open"
|
"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")
|
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 {
|
} else if status == OrderStatus::Canceled && filled_qty < requested_qty {
|
||||||
let detail = partial_fill_reason
|
let detail = partial_fill_reason
|
||||||
.as_deref()
|
.as_deref()
|
||||||
@@ -5506,7 +5509,7 @@ where
|
|||||||
let status = if keep_open {
|
let status = if keep_open {
|
||||||
OrderStatus::PartiallyFilled
|
OrderStatus::PartiallyFilled
|
||||||
} else if filled_qty < requested_qty {
|
} else if filled_qty < requested_qty {
|
||||||
final_partial_fill_status(partial_fill_reason.as_deref())
|
OrderStatus::Canceled
|
||||||
} else {
|
} else {
|
||||||
OrderStatus::Filled
|
OrderStatus::Filled
|
||||||
};
|
};
|
||||||
@@ -5518,14 +5521,6 @@ where
|
|||||||
"order_partial_fill symbol={symbol} side=buy requested={requested_qty} filled={filled_qty} reason={detail}; remaining open"
|
"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")
|
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 {
|
} else if status == OrderStatus::Canceled && filled_qty < requested_qty {
|
||||||
let detail = partial_fill_reason
|
let detail = partial_fill_reason
|
||||||
.as_deref()
|
.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 {
|
fn price_field_name(field: PriceField) -> &'static str {
|
||||||
match field {
|
match field {
|
||||||
PriceField::DayOpen => "day_open",
|
PriceField::DayOpen => "day_open",
|
||||||
|
|||||||
@@ -106,6 +106,49 @@ pub struct OrderEvent {
|
|||||||
pub reason: String,
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct FillEvent {
|
pub struct FillEvent {
|
||||||
#[serde(with = "date_format")]
|
#[serde(with = "date_format")]
|
||||||
@@ -250,3 +293,44 @@ pub struct ProcessEvent {
|
|||||||
pub side: Option<OrderSide>,
|
pub side: Option<OrderSide>,
|
||||||
pub detail: String,
|
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.len(), 2);
|
||||||
assert_eq!(report.order_events[0].status, OrderStatus::Pending);
|
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_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!(
|
assert_eq!(
|
||||||
portfolio.position("000002.SZ").expect("position").quantity,
|
portfolio.position("000002.SZ").expect("position").quantity,
|
||||||
200
|
200
|
||||||
|
|||||||
Reference in New Issue
Block a user