修复平台策略撮合限价与回补语义
This commit is contained in:
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1127,6 +1127,21 @@ impl PlatformExprStrategy {
|
||||
Some(market) => market,
|
||||
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 sizing_price = execution_price;
|
||||
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 order_intents = Vec::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 delayed_sold_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")),
|
||||
reason: "delayed_limit_open_sell".to_string(),
|
||||
});
|
||||
self.project_target_zero(
|
||||
ctx,
|
||||
&mut projected,
|
||||
execution_date,
|
||||
&symbol,
|
||||
&mut projected_execution_state,
|
||||
);
|
||||
if self
|
||||
.project_target_zero(
|
||||
ctx,
|
||||
&mut projected,
|
||||
execution_date,
|
||||
&symbol,
|
||||
&mut projected_execution_state,
|
||||
)
|
||||
.is_some()
|
||||
{
|
||||
same_day_sold_symbols.insert(symbol.clone());
|
||||
}
|
||||
delayed_sold_symbols.insert(symbol.clone());
|
||||
self.pending_highlimit_holdings.remove(&symbol);
|
||||
}
|
||||
@@ -4992,13 +5013,18 @@ impl Strategy for PlatformExprStrategy {
|
||||
reason: sell_reason.to_string(),
|
||||
});
|
||||
if can_sell {
|
||||
self.project_target_zero(
|
||||
ctx,
|
||||
&mut projected,
|
||||
execution_date,
|
||||
&position.symbol,
|
||||
&mut projected_execution_state,
|
||||
);
|
||||
if self
|
||||
.project_target_zero(
|
||||
ctx,
|
||||
&mut projected,
|
||||
execution_date,
|
||||
&position.symbol,
|
||||
&mut projected_execution_state,
|
||||
)
|
||||
.is_some()
|
||||
{
|
||||
same_day_sold_symbols.insert(position.symbol.clone());
|
||||
}
|
||||
} else if stop_hit {
|
||||
unresolved_stop_loss_symbols.insert(position.symbol.clone());
|
||||
}
|
||||
@@ -5030,10 +5056,18 @@ impl Strategy for PlatformExprStrategy {
|
||||
for symbol in &stock_list {
|
||||
if symbol == &position.symbol
|
||||
|| projected.positions().contains_key(symbol)
|
||||
|| same_day_sold_symbols.contains(symbol)
|
||||
|| intraday_attempted_buys.contains(symbol)
|
||||
{
|
||||
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(
|
||||
ctx,
|
||||
decision_date,
|
||||
@@ -5088,13 +5122,18 @@ impl Strategy for PlatformExprStrategy {
|
||||
target_value: 0.0,
|
||||
reason: "periodic_rebalance_sell".to_string(),
|
||||
});
|
||||
self.project_target_zero(
|
||||
ctx,
|
||||
&mut projected,
|
||||
execution_date,
|
||||
symbol,
|
||||
&mut projected_execution_state,
|
||||
);
|
||||
if self
|
||||
.project_target_zero(
|
||||
ctx,
|
||||
&mut projected,
|
||||
execution_date,
|
||||
symbol,
|
||||
&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;
|
||||
@@ -5108,6 +5147,7 @@ impl Strategy for PlatformExprStrategy {
|
||||
}
|
||||
if pre_rebalance_symbols.contains(symbol)
|
||||
|| projected.positions().contains_key(symbol)
|
||||
|| same_day_sold_symbols.contains(symbol)
|
||||
{
|
||||
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]
|
||||
fn platform_daily_top_up_excludes_unsellable_stop_loss_from_target_count() {
|
||||
let prev_date = d(2026, 3, 31);
|
||||
|
||||
Reference in New Issue
Block a user