调整回测撮合为分钟线执行价语义

This commit is contained in:
boris
2026-06-26 17:03:48 +08:00
parent 380c34aa66
commit a131c761e5
8 changed files with 59 additions and 66 deletions
+25 -28
View File
@@ -73,10 +73,9 @@ pub enum MatchingType {
OpenAuction,
CurrentBarClose,
NextBarOpen,
NextMinuteLast,
NextMinuteBestOwn,
NextMinuteBestCounterparty,
CounterpartyOffer,
MinuteLast,
MinuteBestOwn,
MinuteBestCounterparty,
Vwap,
Twap,
}
@@ -563,7 +562,7 @@ where
matching_type: MatchingType,
) -> Option<f64> {
let raw_price = match matching_type {
MatchingType::NextMinuteBestOwn => match side {
MatchingType::MinuteBestOwn => match side {
OrderSide::Buy => {
if quote.bid1.is_finite() && quote.bid1 > 0.0 {
Some(quote.bid1)
@@ -587,13 +586,11 @@ where
}
}
},
MatchingType::NextMinuteBestCounterparty | MatchingType::CounterpartyOffer => {
match side {
OrderSide::Buy => quote.buy_price(),
OrderSide::Sell => quote.sell_price(),
}
}
MatchingType::NextMinuteLast | MatchingType::Vwap | MatchingType::Twap => {
MatchingType::MinuteBestCounterparty => match side {
OrderSide::Buy => quote.buy_price(),
OrderSide::Sell => quote.sell_price(),
},
MatchingType::MinuteLast | MatchingType::Vwap | MatchingType::Twap => {
if quote.last_price.is_finite() && quote.last_price > 0.0 {
Some(quote.last_price)
} else {
@@ -4998,7 +4995,7 @@ where
quotes,
start_cursor,
end_cursor,
matching_type == MatchingType::NextMinuteLast && start_cursor.is_some(),
matching_type == MatchingType::MinuteLast && start_cursor.is_some(),
)),
});
}
@@ -5056,12 +5053,12 @@ where
let quote_quantity_limited =
self.quote_quantity_limited_for_window(matching_type, start_cursor, end_cursor);
let lot = round_lot.max(1);
let exact_time_order_quote = matching_type != MatchingType::NextMinuteLast
let exact_time_order_quote = matching_type != MatchingType::MinuteLast
&& start_cursor.is_some()
&& end_cursor.is_some()
&& start_cursor == end_cursor;
let use_decision_time_quote = start_cursor.is_some()
&& (matching_type == MatchingType::NextMinuteLast || exact_time_order_quote);
&& (matching_type == MatchingType::MinuteLast || exact_time_order_quote);
let eligible_quotes: Vec<&IntradayExecutionQuote> = if use_decision_time_quote {
self.latest_known_quote_at_or_before(
quotes,
@@ -5290,7 +5287,7 @@ where
}
fn quote_quantity_limited(&self, matching_type: MatchingType) -> bool {
self.volume_limit || self.liquidity_limit || matching_type != MatchingType::NextMinuteLast
self.volume_limit || self.liquidity_limit || matching_type != MatchingType::MinuteLast
}
fn quote_quantity_limited_for_window(
@@ -5321,7 +5318,7 @@ fn matching_type_from_price_field(field: PriceField) -> MatchingType {
PriceField::DayOpen => MatchingType::OpenAuction,
PriceField::Open => MatchingType::NextBarOpen,
PriceField::Close => MatchingType::CurrentBarClose,
PriceField::Last => MatchingType::NextMinuteLast,
PriceField::Last => MatchingType::MinuteLast,
}
}
@@ -5487,17 +5484,17 @@ mod tests {
}
#[test]
fn next_minute_last_without_volume_or_liquidity_limit_does_not_cap_quote_quantity() {
fn minute_last_without_volume_or_liquidity_limit_does_not_cap_quote_quantity() {
let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
.with_volume_limit(false)
.with_liquidity_limit(false);
assert!(!broker.quote_quantity_limited(MatchingType::NextMinuteLast));
assert!(broker.quote_quantity_limited(MatchingType::CounterpartyOffer));
assert!(!broker.quote_quantity_limited(MatchingType::MinuteLast));
assert!(broker.quote_quantity_limited(MatchingType::MinuteBestCounterparty));
}
#[test]
fn next_minute_last_keeps_quote_quantity_cap_when_limits_enabled() {
fn minute_last_keeps_quote_quantity_cap_when_limits_enabled() {
let volume_limited =
BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
.with_volume_limit(true)
@@ -5507,8 +5504,8 @@ mod tests {
.with_volume_limit(false)
.with_liquidity_limit(true);
assert!(volume_limited.quote_quantity_limited(MatchingType::NextMinuteLast));
assert!(liquidity_limited.quote_quantity_limited(MatchingType::NextMinuteLast));
assert!(volume_limited.quote_quantity_limited(MatchingType::MinuteLast));
assert!(liquidity_limited.quote_quantity_limited(MatchingType::MinuteLast));
}
#[test]
@@ -5558,7 +5555,7 @@ mod tests {
PriceField::Last,
)
.with_intraday_execution_start_time(date.and_hms_opt(10, 18, 0).unwrap().time())
.with_matching_type(MatchingType::NextMinuteLast)
.with_matching_type(MatchingType::MinuteLast)
.with_slippage_model(SlippageModel::PriceRatio(0.001))
.with_volume_limit(false)
.with_liquidity_limit(false);
@@ -5751,7 +5748,7 @@ mod tests {
}
#[test]
fn next_minute_last_execution_uses_latest_quote_before_decision_time() {
fn minute_last_execution_uses_latest_quote_before_decision_time() {
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date");
let broker = BrokerSimulator::new_with_execution_price(
ChinaAShareCostModel::default(),
@@ -5772,7 +5769,7 @@ mod tests {
&snapshot,
&[quote],
OrderSide::Sell,
MatchingType::NextMinuteLast,
MatchingType::MinuteLast,
Some(decision_time),
Some(decision_time),
200,
@@ -6026,7 +6023,7 @@ mod tests {
&snapshot,
&[quote],
OrderSide::Buy,
MatchingType::NextMinuteLast,
MatchingType::MinuteLast,
Some(start),
None,
100,
@@ -6103,7 +6100,7 @@ mod tests {
&snapshot,
&[quote],
OrderSide::Sell,
MatchingType::NextMinuteLast,
MatchingType::MinuteLast,
Some(start),
None,
100,
+6 -8
View File
@@ -1444,7 +1444,7 @@ where
let snapshot = self.data.market(date, &intent.symbol);
if matches!(
self.broker.matching_type(),
MatchingType::NextMinuteBestCounterparty | MatchingType::CounterpartyOffer
MatchingType::MinuteBestCounterparty
) {
let depth = self.data.order_book_depth_on(date, &intent.symbol);
if !depth.is_empty() {
@@ -1454,7 +1454,7 @@ where
let quotes = self.data.execution_quotes_on(date, &intent.symbol);
for quote in quotes {
let price = match self.broker.matching_type() {
MatchingType::NextMinuteBestOwn => match intent.side() {
MatchingType::MinuteBestOwn => match intent.side() {
OrderSide::Buy => {
if quote.bid1.is_finite() && quote.bid1 > 0.0 {
quote.bid1
@@ -1470,12 +1470,10 @@ where
}
}
},
MatchingType::NextMinuteBestCounterparty | MatchingType::CounterpartyOffer => {
match intent.side() {
OrderSide::Buy => quote.buy_price().unwrap_or(quote.last_price),
OrderSide::Sell => quote.sell_price().unwrap_or(quote.last_price),
}
}
MatchingType::MinuteBestCounterparty => match intent.side() {
OrderSide::Buy => quote.buy_price().unwrap_or(quote.last_price),
OrderSide::Sell => quote.sell_price().unwrap_or(quote.last_price),
},
_ => quote.last_price,
};
if let Some(snapshot) = snapshot {
+12 -13
View File
@@ -286,7 +286,7 @@ fn band_low(index_close) {
stamp_tax_rate_after_change: None,
strict_value_budget: false,
slippage_model: SlippageModel::None,
matching_type: MatchingType::NextMinuteLast,
matching_type: MatchingType::MinuteLast,
quote_quantity_limit: true,
current_day_precomputed_factors: false,
prefer_precomputed_rolling_factors: false,
@@ -1342,7 +1342,7 @@ impl PlatformExprStrategy {
let last =
|| (quote.last_price.is_finite() && quote.last_price > 0.0).then_some(quote.last_price);
match self.config.matching_type {
MatchingType::NextMinuteBestOwn => match side {
MatchingType::MinuteBestOwn => match side {
OrderSide::Buy => (quote.bid1.is_finite() && quote.bid1 > 0.0)
.then_some(quote.bid1)
.or_else(last),
@@ -1350,17 +1350,16 @@ impl PlatformExprStrategy {
.then_some(quote.ask1)
.or_else(last),
},
MatchingType::NextMinuteBestCounterparty | MatchingType::CounterpartyOffer => {
match side {
MatchingType::MinuteBestCounterparty => match side {
OrderSide::Buy => quote.buy_price(),
OrderSide::Sell => quote.sell_price(),
},
MatchingType::MinuteLast | MatchingType::Vwap | MatchingType::Twap => {
last().or_else(|| match side {
OrderSide::Buy => quote.buy_price(),
OrderSide::Sell => quote.sell_price(),
}
})
}
MatchingType::NextMinuteLast | MatchingType::Vwap | MatchingType::Twap => last()
.or_else(|| match side {
OrderSide::Buy => quote.buy_price(),
OrderSide::Sell => quote.sell_price(),
}),
_ => match side {
OrderSide::Buy => quote.buy_price(),
OrderSide::Sell => quote.sell_price(),
@@ -8423,7 +8422,7 @@ mod tests {
}
#[test]
fn platform_aiquant_next_minute_last_projection_uses_last_quote_for_buy_budget() {
fn platform_aiquant_minute_last_projection_uses_last_quote_for_buy_budget() {
let date = d(2023, 5, 4);
let symbol = "000782.SZ";
let data = DataSet::from_components_with_actions_and_quotes(
@@ -8530,7 +8529,7 @@ mod tests {
cfg.minimum_commission = Some(5.0);
cfg.strict_value_budget = true;
cfg.slippage_model = SlippageModel::PriceRatio(0.002);
cfg.matching_type = MatchingType::NextMinuteLast;
cfg.matching_type = MatchingType::MinuteLast;
cfg.quote_quantity_limit = false;
cfg.intraday_execution_time = Some(NaiveTime::from_hms_opt(10, 40, 0).expect("time"));
let strategy = PlatformExprStrategy::new(cfg);
@@ -8688,7 +8687,7 @@ mod tests {
cfg.minimum_commission = Some(5.0);
cfg.strict_value_budget = true;
cfg.slippage_model = SlippageModel::PriceRatio(0.002);
cfg.matching_type = MatchingType::NextMinuteLast;
cfg.matching_type = MatchingType::MinuteLast;
cfg.quote_quantity_limit = false;
cfg.intraday_execution_time = Some(NaiveTime::from_hms_opt(10, 40, 0).expect("time"));
let strategy = PlatformExprStrategy::new(cfg);
@@ -423,10 +423,9 @@ fn parse_matching_type(value: Option<&str>) -> Option<MatchingType> {
"open_auction" => Some(MatchingType::OpenAuction),
"current_bar_close" => Some(MatchingType::CurrentBarClose),
"next_bar_open" => Some(MatchingType::NextBarOpen),
"next_minute_last" => Some(MatchingType::NextMinuteLast),
"next_minute_best_own" => Some(MatchingType::NextMinuteBestOwn),
"next_minute_best_counterparty" => Some(MatchingType::NextMinuteBestCounterparty),
"counterparty_offer" => Some(MatchingType::CounterpartyOffer),
"minute_last" => Some(MatchingType::MinuteLast),
"minute_best_own" => Some(MatchingType::MinuteBestOwn),
"minute_best_counterparty" => Some(MatchingType::MinuteBestCounterparty),
"vwap" => Some(MatchingType::Vwap),
"twap" => Some(MatchingType::Twap),
_ => None,
@@ -1655,7 +1654,7 @@ mod tests {
fn parses_execution_slippage_overrides_into_platform_config() {
let spec = serde_json::json!({
"execution": {
"matchingType": "next_minute_last",
"matchingType": "minute_last",
"slippageModel": "price_ratio",
"slippageValue": 0.001,
"strictValueBudget": true
@@ -1671,7 +1670,7 @@ mod tests {
let cfg = platform_expr_config_from_value("", "", &spec).expect("config");
assert_eq!(cfg.matching_type, MatchingType::NextMinuteLast);
assert_eq!(cfg.matching_type, MatchingType::MinuteLast);
assert_eq!(cfg.slippage_model, SlippageModel::PriceRatio(0.001));
assert!(cfg.strict_value_budget);
}
+4 -4
View File
@@ -220,7 +220,7 @@ pub fn built_in_strategy_manual() -> StrategyAiManual {
},
ManualSection {
title: "execution.matching_type / execution.slippage".to_string(),
detail: "设置撮合模式和滑点。使用 execution.matching_type(\"next_minute_last\" | \"next_minute_best_own\" | \"next_minute_best_counterparty\" | \"counterparty_offer\" | \"vwap\" | \"current_bar_close\" | \"next_bar_open\" | \"open_auction\")。next_minute_last 使用分钟执行价 last_pricenext_minute_best_own / next_minute_best_counterparty 会按 L1 买一卖一近似 平台内核 的最优价语义;counterparty_offer 在存在 order_book_depth 多档盘口数据时按真实档位逐档扫单并计算加权成交价,不存在 depth 时回退 L1 对手方报价;vwap 会在盘中执行价链路上聚合多笔成交为单条 VWAP 成交;next_bar_open 使用决策日信号并在下一可交易日开盘撮合,禁止把执行日 open/high/low/close 解释为下单前已知数据;open_auction 使用当日集合竞价开盘价 day_open 进行撮合,且不额外施加滑点,并按竞价成交量而不是盘口一档流动性限制成交;滑点支持 execution.slippage(\"none\") / execution.slippage(\"price_ratio\", 0.001) / execution.slippage(\"tick_size\", 1) / execution.slippage(\"limit_price\"),其中 limit_price 会在限价单成交时按挂单价模拟 平台内核 的最坏成交价。".to_string(),
detail: "设置撮合模式和滑点。使用 execution.matching_type(\"minute_last\" | \"minute_best_own\" | \"minute_best_counterparty\" | \"vwap\" | \"twap\" | \"current_bar_close\" | \"next_bar_open\" | \"open_auction\")。minute_last 使用分钟执行价 last_priceminute_best_own 使用己方一档报价,minute_best_counterparty 使用对手方一档报价,存在 order_book_depth 多档盘口数据时按真实档位逐档扫单;vwap/twap 在分钟执行价窗口内聚合成交;next_bar_open 使用决策日信号并在下一可交易日开盘撮合,禁止把执行日 open/high/low/close 解释为下单前已知数据;open_auction 使用当日集合竞价开盘价 day_open 进行撮合,且不额外施加滑点,并按竞价成交量而不是盘口一档流动性限制成交;滑点支持 execution.slippage(\"none\") / execution.slippage(\"price_ratio\", 0.001) / execution.slippage(\"tick_size\", 1) / execution.slippage(\"limit_price\"),其中 limit_price 会在限价单成交时按挂单价模拟 平台内核 的最坏成交价。".to_string(),
},
ManualSection {
title: "期货提交校验".to_string(),
@@ -360,7 +360,7 @@ pub fn built_in_strategy_manual() -> StrategyAiManual {
},
ManualFactorSource {
table: "盘口深度参数".to_string(),
detail: "可选字段包括 date、symbol、timestamp、level、bid_price、bid_volume、ask_price、ask_volume。存在盘口深度时,期货 counterparty_offer / next_minute_best_counterparty 可按真实多档盘口逐档扫单;不存在时不会伪造 depth。".to_string(),
detail: "可选字段包括 date、symbol、timestamp、level、bid_price、bid_volume、ask_price、ask_volume。存在盘口深度时,期货 minute_best_counterparty 可按真实多档盘口逐档扫单;不存在时不会伪造 depth。".to_string(),
fields: vec![],
},
ManualFactorSource {
@@ -384,7 +384,7 @@ pub fn built_in_strategy_manual() -> StrategyAiManual {
},
ManualExample {
title: "分钟执行价撮合 + 最小价位滑点".to_string(),
code: "execution.matching_type(\"next_minute_last\")\nexecution.slippage(\"tick_size\", 1)".to_string(),
code: "execution.matching_type(\"minute_last\")\nexecution.slippage(\"tick_size\", 1)".to_string(),
},
ManualExample {
title: "动态 universe 和订阅".to_string(),
@@ -518,7 +518,7 @@ pub fn build_generation_prompt(
prompt.push_str("- 生成的代码必须能转换为 strategy_spec 并提交 POST /v1/backtests。\n");
prompt.push_str("- 不要使用手册未列出的字段、函数或外部平台 API 名称。\n\n");
prompt.push_str("只允许使用这些可编译语句:market、benchmark、signal、rebalance.every_days(...).at([...])、selection.limit、selection.market_cap_band、filter.stock_ma、filter.stock_expr、ordering.rank_by、ordering.rank_expr、allocation.buy_scale、risk.stop_loss、risk.take_profit、risk.index_exposure、execution.matching_type、execution.slippage、universe.exclude。禁止输出 filter(...)、rank(...)、select.top(...)、weight.equal()、sell_rule(...)、backtest(...)、risk.max_position(...) 这类未支持伪语法。\n");
prompt.push_str("参数形态必须严格:selection.market_cap_band 必须写 field=\"market_cap\" 或 field=\"free_float_cap\", lower=..., upper=...;禁止使用 float_market_cap;禁止使用 ma60、stock_ma60、signal_ma60、benchmark_ma6060日价格均线写 rolling_mean(\"close\", 60) 或 ma(\"close\", 60),任意窗口均量写 rolling_mean(\"volume\", n) 或 vma(n);不要生成 fn score() 这类零参数函数,股票字段排序直接写在 ordering.rank_expr 内或用带参数函数;布尔字段按布尔使用,写 !is_st、!paused、!at_upper_limit、!at_lower_limit,不要写 is_st == 0risk.index_exposure 只能传一个数值表达式,不要使用 risk.exposure;完整三元表达式 cond ? a : b 可以使用,但不得输出残缺问号/冒号片段;execution.matching_type 只能取 next_minute_last、next_minute_best_own、next_minute_best_counterparty、counterparty_offer、vwap、current_bar_close、next_bar_open、open_auctionnext_bar_open 只能使用决策日信号,不能把执行日价格当作下单前信息;execution.slippage 必须写 execution.slippage(\"none\") 或 execution.slippage(\"price_ratio\", 0.001)。\n");
prompt.push_str("参数形态必须严格:selection.market_cap_band 必须写 field=\"market_cap\" 或 field=\"free_float_cap\", lower=..., upper=...;禁止使用 float_market_cap;禁止使用 ma60、stock_ma60、signal_ma60、benchmark_ma6060日价格均线写 rolling_mean(\"close\", 60) 或 ma(\"close\", 60),任意窗口均量写 rolling_mean(\"volume\", n) 或 vma(n);不要生成 fn score() 这类零参数函数,股票字段排序直接写在 ordering.rank_expr 内或用带参数函数;布尔字段按布尔使用,写 !is_st、!paused、!at_upper_limit、!at_lower_limit,不要写 is_st == 0risk.index_exposure 只能传一个数值表达式,不要使用 risk.exposure;完整三元表达式 cond ? a : b 可以使用,但不得输出残缺问号/冒号片段;execution.matching_type 只能取 minute_last、minute_best_own、minute_best_counterparty、vwap、twap、current_bar_close、next_bar_open、open_auctionnext_bar_open 只能使用决策日信号,不能把执行日价格当作下单前信息;execution.slippage 必须写 execution.slippage(\"none\") 或 execution.slippage(\"price_ratio\", 0.001)。\n");
prompt.push_str("回测成功但 tradeCount=0 或 holdingCount=0 是无效策略;第一版必须保持稳定买入覆盖率,复杂因子只能在后续优化中逐步加严。\n");
prompt.push_str("可参考但不要照抄的最小模板,回复时不要包含 ``` 代码围栏:\nstrategy(\"cn_a_smallcap_factor_rotation\") {\nmarket(\"CN_A\")\nbenchmark(\"000852.SH\")\nsignal(\"000001.SH\")\nrebalance.every_days(5).at([\"10:18\"])\nselection.limit(40)\nselection.market_cap_band(field=\"market_cap\", lower=0, upper=1000)\nfilter.stock_expr(listed_days >= 60 && !is_st && !paused && close > 2 && !at_upper_limit && !at_lower_limit)\nordering.rank_by(\"market_cap\", \"asc\")\nallocation.buy_scale(1.0)\nrisk.index_exposure(1.0)\nrisk.stop_loss(holding_return < -0.08)\nexecution.slippage(\"price_ratio\", 0.001)\n}\n\n");
prompt.push_str("用户目标:\n");
@@ -190,7 +190,7 @@ fn engine_preloads_declared_decision_quotes_for_current_positions() {
ChinaEquityRuleHooks,
PriceField::Last,
)
.with_matching_type(MatchingType::NextMinuteLast)
.with_matching_type(MatchingType::MinuteLast)
.with_intraday_execution_start_time(t(10, 40, 0));
let config = BacktestConfig {
initial_cash: 10_000.0,
@@ -385,7 +385,7 @@ fn engine_reuses_preloaded_decision_quotes_without_loader_call() {
ChinaEquityRuleHooks,
PriceField::Last,
)
.with_matching_type(MatchingType::NextMinuteLast)
.with_matching_type(MatchingType::MinuteLast)
.with_intraday_execution_start_time(t(10, 40, 0));
let config = BacktestConfig {
initial_cash: 10_000.0,
@@ -587,7 +587,7 @@ fn engine_loads_distinct_decision_quote_times_on_same_day() {
ChinaEquityRuleHooks,
PriceField::Last,
)
.with_matching_type(MatchingType::NextMinuteLast)
.with_matching_type(MatchingType::MinuteLast)
.with_intraday_execution_start_time(t(10, 40, 0));
let config = BacktestConfig {
initial_cash: 10_000.0,
+1 -1
View File
@@ -1842,7 +1842,7 @@ fn engine_sweeps_futures_order_book_depth_when_available() {
ChinaEquityRuleHooks::default(),
PriceField::Last,
)
.with_matching_type(MatchingType::CounterpartyOffer);
.with_matching_type(MatchingType::MinuteBestCounterparty);
let mut engine = BacktestEngine::new(
data,
FuturesDepthLimitOrderStrategy,
@@ -349,7 +349,7 @@ fn broker_delayed_limit_open_sell_uses_minute_price() {
ChinaEquityRuleHooks::default(),
PriceField::Last,
)
.with_matching_type(MatchingType::NextMinuteLast)
.with_matching_type(MatchingType::MinuteLast)
.with_intraday_execution_start_time(NaiveTime::from_hms_opt(9, 31, 0).unwrap())
.with_volume_limit(false)
.with_liquidity_limit(false);
@@ -2888,7 +2888,7 @@ fn broker_uses_best_own_price_for_intraday_matching() {
ChinaEquityRuleHooks::default(),
PriceField::Last,
)
.with_matching_type(MatchingType::NextMinuteBestOwn);
.with_matching_type(MatchingType::MinuteBestOwn);
let report = broker
.execute(
@@ -3003,7 +3003,7 @@ fn broker_uses_best_counterparty_price_for_intraday_matching() {
ChinaEquityRuleHooks::default(),
PriceField::Last,
)
.with_matching_type(MatchingType::NextMinuteBestCounterparty);
.with_matching_type(MatchingType::MinuteBestCounterparty);
let report = broker
.execute(