修复平台策略撮合限价与回补语义

This commit is contained in:
boris
2026-05-18 11:14:51 +08:00
parent 4577657c90
commit 3f383c1a88
2 changed files with 425 additions and 26 deletions

View File

@@ -2277,7 +2277,12 @@ where
(fill.quantity, fill.legs)
} else {
let execution_price = self.snapshot_execution_price(snapshot, OrderSide::Sell);
if !self.price_satisfies_limit(
if let Some(reason) =
self.execution_limit_rejection_reason(snapshot, OrderSide::Sell, execution_price)
{
partial_fill_reason = merge_partial_fill_reason(partial_fill_reason, Some(reason));
(0, Vec::new())
} else if !self.price_satisfies_limit(
OrderSide::Sell,
execution_price,
limit_price,
@@ -3641,7 +3646,12 @@ where
(fill.quantity, fill.legs)
} else {
let execution_price = self.snapshot_execution_price(snapshot, OrderSide::Buy);
if !self.price_satisfies_limit(
if let Some(reason) =
self.execution_limit_rejection_reason(snapshot, OrderSide::Buy, execution_price)
{
partial_fill_reason = merge_partial_fill_reason(partial_fill_reason, Some(reason));
(0, Vec::new())
} else if !self.price_satisfies_limit(
OrderSide::Buy,
execution_price,
limit_price,
@@ -4275,6 +4285,26 @@ where
}
}
fn execution_limit_rejection_reason(
&self,
snapshot: &crate::data::DailyMarketSnapshot,
side: OrderSide,
execution_price: f64,
) -> Option<&'static str> {
if !execution_price.is_finite() || execution_price <= 0.0 {
return None;
}
match side {
OrderSide::Buy if snapshot.is_at_upper_limit_price(execution_price) => {
Some("open at or above upper limit")
}
OrderSide::Sell if snapshot.is_at_lower_limit_price(execution_price) => {
Some("open at or below lower limit")
}
_ => None,
}
}
fn execution_price_with_limit_slippage(
&self,
execution_price: f64,
@@ -4288,7 +4318,10 @@ where
fn limit_order_can_remain_open(partial_reason: Option<&str>) -> bool {
!partial_reason.is_some_and(|reason| {
reason.contains("insufficient cash") || reason.contains("value budget")
reason.contains("insufficient cash")
|| reason.contains("value budget")
|| reason.contains("open at or above upper limit")
|| reason.contains("open at or below lower limit")
})
}
@@ -4426,6 +4459,9 @@ where
let mut last_timestamp = None;
let mut legs = Vec::new();
let mut budget_block_reason = None;
let mut execution_block_reason = None;
let mut execution_block_timestamp = None;
let mut saw_non_blocked_execution_price = false;
let saw_quote_after_cursor = !eligible_quotes.is_empty();
for (quote_index, quote) in eligible_quotes.iter().enumerate() {
@@ -4437,6 +4473,13 @@ where
else {
continue;
};
if let Some(reason) = self.execution_limit_rejection_reason(snapshot, side, quote_price)
{
execution_block_reason.get_or_insert(reason);
execution_block_timestamp = Some(quote.timestamp);
continue;
}
saw_non_blocked_execution_price = true;
if !self.price_satisfies_limit(
side,
quote_price,
@@ -4523,6 +4566,18 @@ where
}
if filled_qty == 0 {
if let Some(reason) = execution_block_reason
&& !saw_non_blocked_execution_price
{
return Some(ExecutionFill {
quantity: 0,
next_cursor: execution_block_timestamp
.expect("blocked execution quote timestamp")
+ Duration::seconds(1),
legs: Vec::new(),
unfilled_reason: Some(reason),
});
}
return None;
}
@@ -4619,7 +4674,9 @@ fn zero_fill_status_for_reason(reason: &str) -> OrderStatus {
"tick no volume"
| "tick volume limit"
| "intraday quote liquidity exhausted"
| "no execution quotes after start" => OrderStatus::Canceled,
| "no execution quotes after start"
| "open at or above upper limit"
| "open at or below lower limit" => OrderStatus::Canceled,
_ => OrderStatus::Rejected,
}
}
@@ -4629,7 +4686,9 @@ fn final_partial_fill_status(partial_reason: Option<&str>) -> OrderStatus {
Some(reason)
if reason.contains("market liquidity or volume limit")
|| reason.contains("intraday quote liquidity exhausted")
|| reason.contains("no execution quotes after start") =>
|| reason.contains("no execution quotes after start")
|| reason.contains("open at or above upper limit")
|| reason.contains("open at or below lower limit") =>
{
OrderStatus::Canceled
}
@@ -4658,8 +4717,54 @@ fn sell_reason(decision: &StrategyDecision, symbol: &str) -> &'static str {
mod tests {
use super::{BrokerSimulator, MatchingType};
use crate::cost::ChinaAShareCostModel;
use crate::data::{DailyMarketSnapshot, IntradayExecutionQuote, PriceField};
use crate::events::OrderSide;
use crate::rules::ChinaEquityRuleHooks;
fn limit_test_snapshot() -> DailyMarketSnapshot {
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date");
DailyMarketSnapshot {
date,
symbol: "000001.SZ".to_string(),
timestamp: Some("2025-01-02 09:33:00".to_string()),
day_open: 10.0,
open: 10.0,
high: 10.5,
low: 9.5,
close: 10.0,
last_price: 10.0,
bid1: 10.0,
ask1: 10.0,
prev_close: 10.0,
volume: 1_000_000,
tick_volume: 10_000,
bid1_volume: 1_000,
ask1_volume: 1_000,
trading_phase: Some("continuous".to_string()),
paused: false,
upper_limit: 11.0,
lower_limit: 9.0,
price_tick: 0.01,
}
}
fn limit_test_quote(last_price: f64, bid1: f64, ask1: f64) -> IntradayExecutionQuote {
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date");
IntradayExecutionQuote {
date,
symbol: "000001.SZ".to_string(),
timestamp: date.and_hms_opt(9, 33, 0).expect("valid timestamp"),
last_price,
bid1,
ask1,
bid1_volume: 1_000,
ask1_volume: 1_000,
volume_delta: 1_000,
amount_delta: last_price * 1_000.0,
trading_phase: Some("continuous".to_string()),
}
}
#[test]
fn next_tick_last_without_volume_or_liquidity_limit_does_not_cap_quote_quantity() {
let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
@@ -4706,4 +4811,78 @@ mod tests {
Some(cursor + chrono::Duration::minutes(1))
));
}
#[test]
fn intraday_execution_rejects_buy_at_upper_limit_price() {
let broker = BrokerSimulator::new_with_execution_price(
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks,
PriceField::Last,
)
.with_volume_limit(false)
.with_liquidity_limit(false)
.with_inactive_limit(false);
let snapshot = limit_test_snapshot();
let quote = limit_test_quote(11.0, 10.99, 11.0);
let start = quote.timestamp;
let fill = broker
.select_execution_fill(
&snapshot,
&[quote],
OrderSide::Buy,
MatchingType::NextTickLast,
Some(start),
None,
100,
100,
100,
100,
false,
None,
None,
None,
)
.expect("zero fill with rejection reason");
assert_eq!(fill.quantity, 0);
assert_eq!(fill.unfilled_reason, Some("open at or above upper limit"));
}
#[test]
fn intraday_execution_rejects_sell_at_lower_limit_price() {
let broker = BrokerSimulator::new_with_execution_price(
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks,
PriceField::Last,
)
.with_volume_limit(false)
.with_liquidity_limit(false)
.with_inactive_limit(false);
let snapshot = limit_test_snapshot();
let quote = limit_test_quote(9.0, 9.0, 9.01);
let start = quote.timestamp;
let fill = broker
.select_execution_fill(
&snapshot,
&[quote],
OrderSide::Sell,
MatchingType::NextTickLast,
Some(start),
None,
100,
100,
100,
100,
false,
None,
None,
None,
)
.expect("zero fill with rejection reason");
assert_eq!(fill.quantity, 0);
assert_eq!(fill.unfilled_reason, Some("open at or below lower limit"));
}
}