1 Commits

Author SHA1 Message Date
boris
3f383c1a88 修复平台策略撮合限价与回补语义 2026-05-18 11:14:51 +08:00
2 changed files with 425 additions and 26 deletions

View File

@@ -2277,7 +2277,12 @@ where
(fill.quantity, fill.legs) (fill.quantity, fill.legs)
} else { } else {
let execution_price = self.snapshot_execution_price(snapshot, OrderSide::Sell); 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, OrderSide::Sell,
execution_price, execution_price,
limit_price, limit_price,
@@ -3641,7 +3646,12 @@ where
(fill.quantity, fill.legs) (fill.quantity, fill.legs)
} else { } else {
let execution_price = self.snapshot_execution_price(snapshot, OrderSide::Buy); 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, OrderSide::Buy,
execution_price, execution_price,
limit_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( fn execution_price_with_limit_slippage(
&self, &self,
execution_price: f64, execution_price: f64,
@@ -4288,7 +4318,10 @@ where
fn limit_order_can_remain_open(partial_reason: Option<&str>) -> bool { fn limit_order_can_remain_open(partial_reason: Option<&str>) -> bool {
!partial_reason.is_some_and(|reason| { !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 last_timestamp = None;
let mut legs = Vec::new(); let mut legs = Vec::new();
let mut budget_block_reason = None; 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(); let saw_quote_after_cursor = !eligible_quotes.is_empty();
for (quote_index, quote) in eligible_quotes.iter().enumerate() { for (quote_index, quote) in eligible_quotes.iter().enumerate() {
@@ -4437,6 +4473,13 @@ where
else { else {
continue; 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( if !self.price_satisfies_limit(
side, side,
quote_price, quote_price,
@@ -4523,6 +4566,18 @@ where
} }
if filled_qty == 0 { 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; return None;
} }
@@ -4619,7 +4674,9 @@ fn zero_fill_status_for_reason(reason: &str) -> OrderStatus {
"tick no volume" "tick no volume"
| "tick volume limit" | "tick volume limit"
| "intraday quote liquidity exhausted" | "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, _ => OrderStatus::Rejected,
} }
} }
@@ -4629,7 +4686,9 @@ fn final_partial_fill_status(partial_reason: Option<&str>) -> OrderStatus {
Some(reason) Some(reason)
if reason.contains("market liquidity or volume limit") if reason.contains("market liquidity or volume limit")
|| reason.contains("intraday quote liquidity exhausted") || 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 OrderStatus::Canceled
} }
@@ -4658,8 +4717,54 @@ fn sell_reason(decision: &StrategyDecision, symbol: &str) -> &'static str {
mod tests { mod tests {
use super::{BrokerSimulator, MatchingType}; use super::{BrokerSimulator, MatchingType};
use crate::cost::ChinaAShareCostModel; use crate::cost::ChinaAShareCostModel;
use crate::data::{DailyMarketSnapshot, IntradayExecutionQuote, PriceField};
use crate::events::OrderSide;
use crate::rules::ChinaEquityRuleHooks; 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] #[test]
fn next_tick_last_without_volume_or_liquidity_limit_does_not_cap_quote_quantity() { fn next_tick_last_without_volume_or_liquidity_limit_does_not_cap_quote_quantity() {
let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks) let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
@@ -4706,4 +4811,78 @@ mod tests {
Some(cursor + chrono::Duration::minutes(1)) 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"));
}
} }

View File

@@ -1127,6 +1127,21 @@ impl PlatformExprStrategy {
Some(market) => market, Some(market) => market,
None => return 0, None => return 0,
}; };
let stock = match self.stock_state(ctx, date, symbol) {
Ok(stock) => stock,
Err(BacktestError::Data(crate::data::DataSetError::MissingSnapshot { .. })) => {
return 0;
}
Err(_) => return 0,
};
if self
.buy_rejection_reason(ctx, date, symbol, &stock)
.ok()
.flatten()
.is_some()
{
return 0;
}
let execution_price = self.projected_execution_price(market, OrderSide::Buy); let execution_price = self.projected_execution_price(market, OrderSide::Buy);
let sizing_price = execution_price; let sizing_price = execution_price;
if !sizing_price.is_finite() || sizing_price <= 0.0 { if !sizing_price.is_finite() || sizing_price <= 0.0 {
@@ -4872,6 +4887,7 @@ impl Strategy for PlatformExprStrategy {
let mut projected_execution_state = ProjectedExecutionState::default(); let mut projected_execution_state = ProjectedExecutionState::default();
let mut order_intents = Vec::new(); let mut order_intents = Vec::new();
let mut exit_symbols = BTreeSet::new(); let mut exit_symbols = BTreeSet::new();
let mut same_day_sold_symbols = BTreeSet::<String>::new();
let mut intraday_attempted_buys = BTreeSet::<String>::new(); let mut intraday_attempted_buys = BTreeSet::<String>::new();
let mut delayed_sold_symbols = BTreeSet::<String>::new(); let mut delayed_sold_symbols = BTreeSet::<String>::new();
let mut unresolved_stop_loss_symbols = BTreeSet::<String>::new(); let mut unresolved_stop_loss_symbols = BTreeSet::<String>::new();
@@ -4952,13 +4968,18 @@ impl Strategy for PlatformExprStrategy {
end_time: Some(NaiveTime::from_hms_opt(9, 31, 0).expect("valid time")), end_time: Some(NaiveTime::from_hms_opt(9, 31, 0).expect("valid time")),
reason: "delayed_limit_open_sell".to_string(), reason: "delayed_limit_open_sell".to_string(),
}); });
self.project_target_zero( if self
.project_target_zero(
ctx, ctx,
&mut projected, &mut projected,
execution_date, execution_date,
&symbol, &symbol,
&mut projected_execution_state, &mut projected_execution_state,
); )
.is_some()
{
same_day_sold_symbols.insert(symbol.clone());
}
delayed_sold_symbols.insert(symbol.clone()); delayed_sold_symbols.insert(symbol.clone());
self.pending_highlimit_holdings.remove(&symbol); self.pending_highlimit_holdings.remove(&symbol);
} }
@@ -4992,13 +5013,18 @@ impl Strategy for PlatformExprStrategy {
reason: sell_reason.to_string(), reason: sell_reason.to_string(),
}); });
if can_sell { if can_sell {
self.project_target_zero( if self
.project_target_zero(
ctx, ctx,
&mut projected, &mut projected,
execution_date, execution_date,
&position.symbol, &position.symbol,
&mut projected_execution_state, &mut projected_execution_state,
); )
.is_some()
{
same_day_sold_symbols.insert(position.symbol.clone());
}
} else if stop_hit { } else if stop_hit {
unresolved_stop_loss_symbols.insert(position.symbol.clone()); unresolved_stop_loss_symbols.insert(position.symbol.clone());
} }
@@ -5030,10 +5056,18 @@ impl Strategy for PlatformExprStrategy {
for symbol in &stock_list { for symbol in &stock_list {
if symbol == &position.symbol if symbol == &position.symbol
|| projected.positions().contains_key(symbol) || projected.positions().contains_key(symbol)
|| same_day_sold_symbols.contains(symbol)
|| intraday_attempted_buys.contains(symbol) || intraday_attempted_buys.contains(symbol)
{ {
continue; continue;
} }
let execution_stock = self.stock_state(ctx, execution_date, symbol)?;
if self
.buy_rejection_reason(ctx, execution_date, symbol, &execution_stock)?
.is_some()
{
continue;
}
let decision_stock = self.stock_state_with_factor_date( let decision_stock = self.stock_state_with_factor_date(
ctx, ctx,
decision_date, decision_date,
@@ -5088,13 +5122,18 @@ impl Strategy for PlatformExprStrategy {
target_value: 0.0, target_value: 0.0,
reason: "periodic_rebalance_sell".to_string(), reason: "periodic_rebalance_sell".to_string(),
}); });
self.project_target_zero( if self
.project_target_zero(
ctx, ctx,
&mut projected, &mut projected,
execution_date, execution_date,
symbol, symbol,
&mut projected_execution_state, &mut projected_execution_state,
); )
.is_some()
{
same_day_sold_symbols.insert(symbol.clone());
}
} }
let fixed_buy_cash = aiquant_total_value * trading_ratio / selection_limit as f64; let fixed_buy_cash = aiquant_total_value * trading_ratio / selection_limit as f64;
@@ -5108,6 +5147,7 @@ impl Strategy for PlatformExprStrategy {
} }
if pre_rebalance_symbols.contains(symbol) if pre_rebalance_symbols.contains(symbol)
|| projected.positions().contains_key(symbol) || projected.positions().contains_key(symbol)
|| same_day_sold_symbols.contains(symbol)
{ {
continue; continue;
} }
@@ -7118,6 +7158,186 @@ mod tests {
); );
} }
#[test]
fn platform_daily_top_up_does_not_buy_back_same_day_exit() {
let prev_date = d(2025, 2, 25);
let date = d(2025, 2, 26);
let symbols = ["000001.SZ", "000002.SZ", "000003.SZ"];
let data = DataSet::from_components_with_actions_and_quotes(
symbols
.iter()
.map(|symbol| Instrument {
symbol: (*symbol).to_string(),
name: (*symbol).to_string(),
board: "SZ".to_string(),
round_lot: 100,
listed_at: Some(d(2020, 1, 1)),
delisted_at: None,
status: "active".to_string(),
})
.collect(),
symbols
.iter()
.map(|symbol| DailyMarketSnapshot {
date,
symbol: (*symbol).to_string(),
timestamp: Some("2025-02-26 09:33:00".to_string()),
day_open: 10.0,
open: 10.0,
high: 10.5,
low: 9.8,
close: 10.0,
last_price: 10.0,
bid1: 10.0,
ask1: 10.0,
prev_close: 9.9,
volume: 1_000_000,
tick_volume: 10_000,
bid1_volume: 2_000,
ask1_volume: 2_000,
trading_phase: Some("continuous".to_string()),
paused: false,
upper_limit: 11.0,
lower_limit: 9.0,
price_tick: 0.01,
})
.collect(),
symbols
.iter()
.map(|symbol| DailyFactorSnapshot {
date,
symbol: (*symbol).to_string(),
market_cap_bn: match *symbol {
"000001.SZ" => 8.0,
"000003.SZ" => 9.0,
_ => 10.0,
},
free_float_cap_bn: 10.0,
pe_ttm: 8.0,
turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0),
extra_factors: BTreeMap::new(),
})
.collect(),
symbols
.iter()
.map(|symbol| CandidateEligibility {
date,
symbol: (*symbol).to_string(),
is_st: false,
is_new_listing: false,
is_paused: false,
allow_buy: true,
allow_sell: true,
is_kcb: false,
is_one_yuan: false,
})
.collect(),
vec![BenchmarkSnapshot {
date,
benchmark: "000852.SH".to_string(),
open: 1000.0,
close: 1002.0,
prev_close: 998.0,
volume: 1_000_000,
}],
Vec::new(),
symbols
.iter()
.map(|symbol| IntradayExecutionQuote {
date,
symbol: (*symbol).to_string(),
timestamp: date.and_hms_opt(9, 33, 0).expect("valid timestamp"),
last_price: 10.0,
bid1: 10.0,
ask1: 10.0,
bid1_volume: 10_000,
ask1_volume: 10_000,
volume_delta: 10_000,
amount_delta: 100_000.0,
trading_phase: Some("continuous".to_string()),
})
.collect(),
)
.expect("dataset");
let mut portfolio = PortfolioState::new(20_000.0);
portfolio.position_mut("000001.SZ").buy(prev_date, 100, 8.0);
portfolio
.position_mut("000002.SZ")
.buy(prev_date, 100, 10.0);
let subscriptions = BTreeSet::new();
let ctx = StrategyContext {
execution_date: date,
decision_date: date,
decision_index: 20,
data: &data,
portfolio: &portfolio,
futures_account: None,
open_orders: &[],
dynamic_universe: None,
subscriptions: &subscriptions,
process_events: &[],
active_process_event: None,
active_datetime: None,
order_events: &[],
fills: &[],
};
let mut cfg = PlatformExprStrategyConfig::microcap_rotation();
cfg.signal_symbol = "000001.SZ".to_string();
cfg.refresh_rate = 99;
cfg.max_positions = 3;
cfg.benchmark_short_ma_days = 1;
cfg.benchmark_long_ma_days = 1;
cfg.market_cap_lower_expr = "0".to_string();
cfg.market_cap_upper_expr = "100".to_string();
cfg.selection_limit_expr = "3".to_string();
cfg.stock_filter_expr = "close > 0".to_string();
cfg.take_profit_expr = "1.1".to_string();
cfg.daily_top_up_enabled = true;
let mut strategy = PlatformExprStrategy::new(cfg);
strategy.rebalance_day_counter = 2;
let decision = strategy.on_day(&ctx).expect("platform decision");
assert!(
decision.order_intents.iter().any(|intent| matches!(
intent,
OrderIntent::TargetValue {
symbol,
target_value,
..
} if symbol == "000001.SZ" && *target_value == 0.0
)),
"{:?}",
decision.order_intents
);
assert!(
decision.order_intents.iter().any(|intent| matches!(
intent,
OrderIntent::Value {
symbol,
reason,
..
} if symbol == "000003.SZ" && reason == "daily_top_up_buy"
)),
"{:?}",
decision.order_intents
);
assert!(
!decision.order_intents.iter().any(|intent| matches!(
intent,
OrderIntent::Value {
symbol,
reason,
..
} if symbol == "000001.SZ" && reason == "daily_top_up_buy"
)),
"{:?}",
decision.order_intents
);
}
#[test] #[test]
fn platform_daily_top_up_excludes_unsellable_stop_loss_from_target_count() { fn platform_daily_top_up_excludes_unsellable_stop_loss_from_target_count() {
let prev_date = d(2026, 3, 31); let prev_date = d(2026, 3, 31);