优化回测撮合与涨跌停约束

This commit is contained in:
boris
2026-06-20 07:59:22 +08:00
parent 5ecb0e7986
commit 5e66e9799c
3 changed files with 863 additions and 83 deletions
+229 -11
View File
@@ -2030,6 +2030,34 @@ where
} }
} }
fn aiquant_order_limit_check_price(
&self,
date: NaiveDate,
data: &DataSet,
symbol: &str,
snapshot: &crate::data::DailyMarketSnapshot,
side: OrderSide,
algo_request: Option<&AlgoExecutionRequest>,
) -> f64 {
let matching_type = self.matching_type_for_algo_request(algo_request);
let start_cursor = algo_request
.and_then(|request| request.start_time)
.or(self.runtime_intraday_start_time.get())
.or(self.intraday_execution_start_time)
.map(|start_time| date.and_time(start_time));
self.latest_known_quote_at_or_before(
data.execution_quotes_on(date, symbol),
start_cursor,
snapshot,
side,
matching_type,
false,
)
.and_then(|quote| self.select_quote_reference_price(snapshot, quote, side, matching_type))
.unwrap_or_else(|| self.aiquant_limit_check_price(snapshot, side))
}
#[cfg(test)]
fn buy_rule_check( fn buy_rule_check(
&self, &self,
date: NaiveDate, date: NaiveDate,
@@ -2059,16 +2087,68 @@ where
RuleCheck::allow() RuleCheck::allow()
} }
fn sell_rule_check( fn buy_rule_check_for_order(
&self, &self,
date: NaiveDate, date: NaiveDate,
data: &DataSet,
symbol: &str,
snapshot: &crate::data::DailyMarketSnapshot,
candidate: &crate::data::CandidateEligibility,
instrument: Option<&Instrument>,
algo_request: Option<&AlgoExecutionRequest>,
) -> RuleCheck {
let check_price = if self.aiquant_rqalpha_execution_rules {
self.aiquant_order_limit_check_price(
date,
data,
symbol,
snapshot,
OrderSide::Buy,
algo_request,
)
} else {
ChinaAShareRiskControl::buy_check_price(snapshot, self.execution_price_field)
};
if let Some(reason) = ChinaAShareRiskControl::buy_rejection_reason(
date,
candidate,
snapshot,
instrument,
check_price,
) {
return RuleCheck::reject(reason);
}
if !self.aiquant_rqalpha_execution_rules {
return self
.rules
.can_buy(date, snapshot, candidate, self.execution_price_field);
}
RuleCheck::allow()
}
fn sell_rule_check_for_order(
&self,
date: NaiveDate,
data: &DataSet,
symbol: &str,
snapshot: &crate::data::DailyMarketSnapshot, snapshot: &crate::data::DailyMarketSnapshot,
candidate: &crate::data::CandidateEligibility, candidate: &crate::data::CandidateEligibility,
instrument: Option<&Instrument>, instrument: Option<&Instrument>,
position: &crate::portfolio::Position, position: &crate::portfolio::Position,
algo_request: Option<&AlgoExecutionRequest>,
) -> RuleCheck { ) -> RuleCheck {
if !self.aiquant_rqalpha_execution_rules && !candidate.allow_sell {
return RuleCheck::reject("sell_disabled");
}
let check_price = if self.aiquant_rqalpha_execution_rules { let check_price = if self.aiquant_rqalpha_execution_rules {
self.aiquant_limit_check_price(snapshot, OrderSide::Sell) self.aiquant_order_limit_check_price(
date,
data,
symbol,
snapshot,
OrderSide::Sell,
algo_request,
)
} else { } else {
ChinaAShareRiskControl::sell_check_price(snapshot, self.execution_price_field) ChinaAShareRiskControl::sell_check_price(snapshot, self.execution_price_field)
}; };
@@ -2116,8 +2196,16 @@ where
let Ok(candidate) = data.require_candidate(date, symbol) else { let Ok(candidate) = data.require_candidate(date, symbol) else {
return current_qty; return current_qty;
}; };
let rule = let rule = self.sell_rule_check_for_order(
self.sell_rule_check(date, snapshot, candidate, data.instrument(symbol), position); date,
data,
symbol,
snapshot,
candidate,
data.instrument(symbol),
position,
None,
);
if !rule.allowed { if !rule.allowed {
return current_qty; return current_qty;
} }
@@ -2155,7 +2243,15 @@ where
let Ok(candidate) = data.require_candidate(date, symbol) else { let Ok(candidate) = data.require_candidate(date, symbol) else {
return current_qty; return current_qty;
}; };
let rule = self.buy_rule_check(date, snapshot, candidate, data.instrument(symbol)); let rule = self.buy_rule_check_for_order(
date,
data,
symbol,
snapshot,
candidate,
data.instrument(symbol),
None,
);
if !rule.allowed { if !rule.allowed {
return current_qty; return current_qty;
} }
@@ -2199,8 +2295,16 @@ where
let position = portfolio.position(symbol)?; let position = portfolio.position(symbol)?;
let snapshot = data.require_market(date, symbol).ok()?; let snapshot = data.require_market(date, symbol).ok()?;
let candidate = data.require_candidate(date, symbol).ok()?; let candidate = data.require_candidate(date, symbol).ok()?;
let rule = let rule = self.sell_rule_check_for_order(
self.sell_rule_check(date, snapshot, candidate, data.instrument(symbol), position); date,
data,
symbol,
snapshot,
candidate,
data.instrument(symbol),
position,
None,
);
if !rule.allowed { if !rule.allowed {
return rule.reason; return rule.reason;
} }
@@ -2240,7 +2344,15 @@ where
) -> Option<String> { ) -> Option<String> {
let snapshot = data.require_market(date, symbol).ok()?; let snapshot = data.require_market(date, symbol).ok()?;
let candidate = data.require_candidate(date, symbol).ok()?; let candidate = data.require_candidate(date, symbol).ok()?;
let rule = self.buy_rule_check(date, snapshot, candidate, data.instrument(symbol)); let rule = self.buy_rule_check_for_order(
date,
data,
symbol,
snapshot,
candidate,
data.instrument(symbol),
None,
);
if !rule.allowed { if !rule.allowed {
return rule.reason; return rule.reason;
} }
@@ -2310,8 +2422,16 @@ where
); );
} }
let rule = let rule = self.sell_rule_check_for_order(
self.sell_rule_check(date, snapshot, candidate, data.instrument(symbol), position); date,
data,
symbol,
snapshot,
candidate,
data.instrument(symbol),
position,
algo_request,
);
if !rule.allowed { if !rule.allowed {
let rule_reason = rule.reason.as_deref().unwrap_or_default().to_string(); let rule_reason = rule.reason.as_deref().unwrap_or_default().to_string();
let status = match rule.reason.as_deref() { let status = match rule.reason.as_deref() {
@@ -3765,7 +3885,15 @@ where
); );
} }
let rule = self.buy_rule_check(date, snapshot, candidate, data.instrument(symbol)); let rule = self.buy_rule_check_for_order(
date,
data,
symbol,
snapshot,
candidate,
data.instrument(symbol),
algo_request,
);
if !rule.allowed { if !rule.allowed {
let rule_reason = rule.reason.as_deref().unwrap_or_default().to_string(); let rule_reason = rule.reason.as_deref().unwrap_or_default().to_string();
let status = match rule.reason.as_deref() { let status = match rule.reason.as_deref() {
@@ -5773,4 +5901,94 @@ mod tests {
assert_eq!(fill.quantity, 0); assert_eq!(fill.quantity, 0);
assert_eq!(fill.unfilled_reason, Some("open at or below lower limit")); assert_eq!(fill.unfilled_reason, Some("open at or below lower limit"));
} }
#[test]
fn aiquant_sell_rule_uses_intraday_quote_when_daily_snapshot_is_stale_lower_limit() {
let date = chrono::NaiveDate::from_ymd_opt(2024, 4, 17).expect("valid date");
let prev_date = chrono::NaiveDate::from_ymd_opt(2024, 4, 16).expect("valid date");
let symbol = "000001.SZ";
let broker = BrokerSimulator::new_with_execution_price(
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks,
PriceField::Last,
)
.with_aiquant_rqalpha_execution_rules(true)
.with_intraday_execution_start_time(date.and_hms_opt(10, 18, 0).unwrap().time())
.with_volume_limit(false)
.with_liquidity_limit(false)
.with_inactive_limit(false)
.with_slippage_model(SlippageModel::None);
let mut snapshot = limit_test_snapshot();
snapshot.date = date;
snapshot.timestamp = Some("2024-04-17 10:18:00".to_string());
snapshot.day_open = 9.0;
snapshot.open = 9.0;
snapshot.high = 10.0;
snapshot.low = 9.0;
snapshot.close = 9.9;
snapshot.last_price = 9.0;
snapshot.bid1 = 9.0;
snapshot.ask1 = 9.0;
snapshot.prev_close = 10.0;
snapshot.upper_limit = 11.0;
snapshot.lower_limit = 9.0;
let mut candidate = limit_test_candidate(true, false);
candidate.date = date;
let mut quote = limit_test_quote(10.0, 9.99, 10.0);
quote.date = date;
quote.timestamp = date.and_hms_opt(10, 18, 0).unwrap();
quote.last_price = 10.0;
quote.bid1 = 9.99;
quote.ask1 = 10.0;
let data = DataSet::from_components_with_actions_and_quotes(
vec![limit_test_instrument()],
vec![snapshot],
Vec::new(),
vec![candidate],
vec![BenchmarkSnapshot {
date,
benchmark: "000852.SH".to_string(),
open: 1000.0,
close: 1000.0,
prev_close: 1000.0,
volume: 1_000_000,
}],
Vec::new(),
vec![quote],
)
.expect("valid dataset");
let mut portfolio = PortfolioState::new(100.0);
portfolio.position_mut(symbol).buy(prev_date, 7_200, 8.48);
let mut report = BrokerExecutionReport::default();
broker
.process_target_value(
date,
&mut portfolio,
&data,
symbol,
0.0,
"stop_loss_exit",
&mut BTreeMap::new(),
&mut BTreeMap::new(),
&mut None,
&mut BTreeMap::new(),
&mut report,
)
.expect("target sell processed");
assert_eq!(report.fill_events.len(), 1);
assert_eq!(report.fill_events[0].quantity, 7_200);
assert!(portfolio.position(symbol).is_none());
assert!(
report
.order_events
.iter()
.all(|event| !event.reason.contains("open at or below lower limit")),
"{:?}",
report.order_events
);
}
} }
+535 -69
View File
@@ -435,6 +435,13 @@ struct StockExpressionState {
extra_text_factors: BTreeMap<String, String>, extra_text_factors: BTreeMap<String, String>,
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum StockFilterQuoteUsage {
DailyOnly,
LimitStateOnly,
IntradayQuote,
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
struct PositionExpressionState { struct PositionExpressionState {
order_book_id: String, order_book_id: String,
@@ -1146,6 +1153,17 @@ impl PlatformExprStrategy {
.and_then(|quote| self.projected_quote_raw_price(quote, OrderSide::Buy)) .and_then(|quote| self.projected_quote_raw_price(quote, OrderSide::Buy))
} }
fn aiquant_scheduled_sell_price_at_time(
&self,
ctx: &StrategyContext<'_>,
date: NaiveDate,
symbol: &str,
execution_time: Option<NaiveTime>,
) -> Option<f64> {
self.aiquant_scheduled_quote_at_time(ctx, date, symbol, execution_time)
.and_then(|quote| self.projected_quote_raw_price(quote, OrderSide::Sell))
}
fn aiquant_scheduled_last_price( fn aiquant_scheduled_last_price(
&self, &self,
ctx: &StrategyContext<'_>, ctx: &StrategyContext<'_>,
@@ -1494,7 +1512,7 @@ impl PlatformExprStrategy {
if quantity == 0 { if quantity == 0 {
return None; return None;
} }
if !self.can_sell_position(ctx, date, symbol) { if !self.can_sell_position_at_time(ctx, date, symbol, execution_time) {
return None; return None;
} }
let market = ctx.data.market(date, symbol)?; let market = ctx.data.market(date, symbol)?;
@@ -5876,6 +5894,16 @@ impl PlatformExprStrategy {
} }
fn can_sell_position(&self, ctx: &StrategyContext<'_>, date: NaiveDate, symbol: &str) -> bool { fn can_sell_position(&self, ctx: &StrategyContext<'_>, date: NaiveDate, symbol: &str) -> bool {
self.can_sell_position_at_time(ctx, date, symbol, None)
}
fn can_sell_position_at_time(
&self,
ctx: &StrategyContext<'_>,
date: NaiveDate,
symbol: &str,
execution_time: Option<NaiveTime>,
) -> bool {
let Some(position) = ctx.portfolio.position(symbol) else { let Some(position) = ctx.portfolio.position(symbol) else {
return false; return false;
}; };
@@ -5888,13 +5916,22 @@ impl PlatformExprStrategy {
let Ok(candidate) = ctx.data.require_candidate(date, symbol) else { let Ok(candidate) = ctx.data.require_candidate(date, symbol) else {
return false; return false;
}; };
if !self.config.aiquant_transaction_cost && !candidate.allow_sell {
return false;
}
let check_price = if self.config.aiquant_transaction_cost {
self.aiquant_scheduled_sell_price_at_time(ctx, date, symbol, execution_time)
.unwrap_or_else(|| market.price(PriceField::Last))
} else {
market.price(PriceField::Last)
};
ChinaAShareRiskControl::sell_rejection_reason( ChinaAShareRiskControl::sell_rejection_reason(
date, date,
candidate, candidate,
market, market,
ctx.data.instrument(symbol), ctx.data.instrument(symbol),
Some(position), Some(position),
market.price(PriceField::Last), check_price,
) )
.is_none() .is_none()
} }
@@ -6033,28 +6070,69 @@ impl PlatformExprStrategy {
Ok((selected, diagnostics)) Ok((selected, diagnostics))
} }
fn stock_filter_uses_intraday_quote_fields(&self) -> bool { fn stock_filter_quote_usage(&self) -> StockFilterQuoteUsage {
let expr = Self::normalize_runtime_field_aliases(&self.config.stock_filter_expr); let expr = Self::normalize_runtime_field_aliases(&self.config.stock_filter_expr);
Self::extract_identifier_candidates(&expr) let mut usage = StockFilterQuoteUsage::DailyOnly;
.into_iter() for name in Self::extract_identifier_candidates(&expr) {
.any(|name| { match name.as_str() {
matches!( "last" | "last_price" | "bid1" | "ask1" | "bid1_volume" | "ask1_volume"
name.as_str(), | "tick_volume" => return StockFilterQuoteUsage::IntradayQuote,
"last" "at_upper_limit" | "at_lower_limit" => {
| "last_price" usage = StockFilterQuoteUsage::LimitStateOnly;
| "bid1" }
| "ask1" _ => {}
| "bid1_volume" }
| "ask1_volume" }
| "tick_volume" usage
| "at_upper_limit" }
| "at_lower_limit"
| "touched_upper_limit" fn stock_filter_uses_intraday_quote_fields(&self) -> bool {
| "touched_lower_limit" self.stock_filter_quote_usage() != StockFilterQuoteUsage::DailyOnly
| "hit_upper_limit" }
| "hit_lower_limit"
) fn quote_plan_candidate_limit(&self, selection_limit: usize) -> usize {
}) selection_limit
.saturating_mul(3)
.max(selection_limit.saturating_add(80))
.max(120)
.min(500)
}
fn stock_passes_quote_plan_filter(
&self,
ctx: &StrategyContext<'_>,
day: &DayExpressionState,
stock: &StockExpressionState,
quote_usage: StockFilterQuoteUsage,
) -> Result<bool, BacktestError> {
match quote_usage {
StockFilterQuoteUsage::DailyOnly => self.stock_passes_expr(ctx, day, stock),
StockFilterQuoteUsage::LimitStateOnly => {
let mut neutral_stock = stock.clone();
neutral_stock.last = Self::neutral_limit_state_price(stock);
self.stock_passes_expr(ctx, day, &neutral_stock)
}
StockFilterQuoteUsage::IntradayQuote => Ok(true),
}
}
fn neutral_limit_state_price(stock: &StockExpressionState) -> f64 {
if stock.upper_limit.is_finite()
&& stock.lower_limit.is_finite()
&& stock.upper_limit > stock.lower_limit
{
return (stock.upper_limit + stock.lower_limit) * 0.5;
}
if stock.prev_close.is_finite() && stock.prev_close > 0.0 {
return stock.prev_close;
}
if stock.close.is_finite() && stock.close > 0.0 {
return stock.close;
}
if stock.last.is_finite() && stock.last > 0.0 {
return stock.last;
}
stock.price_tick.max(0.01)
} }
fn select_quote_plan_symbols( fn select_quote_plan_symbols(
@@ -6067,11 +6145,12 @@ impl PlatformExprStrategy {
band_low: f64, band_low: f64,
band_high: f64, band_high: f64,
selection_limit: usize, selection_limit: usize,
) -> Result<(Vec<String>, Vec<String>, Vec<String>), BacktestError> { ) -> Result<(Vec<String>, Vec<String>, usize, Vec<String>), BacktestError> {
let universe = self.selectable_universe_on(ctx, date, universe_factor_date); let universe = self.selectable_universe_on(ctx, date, universe_factor_date);
let mut diagnostics = Vec::new(); let mut diagnostics = Vec::new();
let mut candidates = Vec::new(); let mut candidates = Vec::new();
let apply_stock_filter = !self.stock_filter_uses_intraday_quote_fields(); let quote_usage = self.stock_filter_quote_usage();
let quote_candidate_limit = self.quote_plan_candidate_limit(selection_limit);
for candidate in universe { for candidate in universe {
let stock = let stock =
self.stock_state_with_factor_date(ctx, date, stock_factor_date, &candidate.symbol)?; self.stock_state_with_factor_date(ctx, date, stock_factor_date, &candidate.symbol)?;
@@ -6117,39 +6196,48 @@ impl PlatformExprStrategy {
} }
}); });
let mut processed_symbols = Vec::new(); let mut quote_candidate_symbols = Vec::new();
let mut selected_symbols = Vec::new(); let mut selected_symbols = Vec::new();
let mut batch_size = selection_limit.saturating_add(80).max(120); let mut processed_scope = 0usize;
let mut cursor = 0usize; for (symbol, _) in &candidates {
while cursor < candidates.len() && selected_symbols.len() < selection_limit { processed_scope += 1;
let end = (cursor + batch_size).min(candidates.len()); let stock = self.stock_state_with_factor_date(ctx, date, stock_factor_date, symbol)?;
for (symbol, _) in &candidates[cursor..end] { if !self.stock_passes_quote_plan_filter(ctx, day, &stock, quote_usage)? {
processed_symbols.push(symbol.clone()); if diagnostics.len() < 12 {
diagnostics.push(format!("{symbol} quote_plan rejected by stock_expr"));
}
continue;
} }
for (symbol, _) in &candidates[cursor..end] { if quote_candidate_symbols.len() < quote_candidate_limit {
let stock = quote_candidate_symbols.push(symbol.clone());
self.stock_state_with_factor_date(ctx, date, stock_factor_date, symbol)?; }
if let Some(reason) = self.buy_rejection_reason(ctx, date, symbol, &stock)? { if let Some(reason) = self.buy_rejection_reason(ctx, date, symbol, &stock)? {
if diagnostics.len() < 12 { if diagnostics.len() < 12 {
diagnostics.push(format!("{symbol} quote_plan rejected by {reason}")); diagnostics.push(format!("{symbol} quote_plan rejected by {reason}"));
} }
continue; continue;
} }
if apply_stock_filter && !self.stock_passes_expr(ctx, day, &stock)? { if quote_usage == StockFilterQuoteUsage::IntradayQuote
if diagnostics.len() < 12 { && !self.stock_passes_expr(ctx, day, &stock)?
diagnostics.push(format!("{symbol} quote_plan rejected by stock_expr")); {
} if diagnostics.len() < 12 {
continue; diagnostics.push(format!("{symbol} quote_plan rejected by stock_expr"));
} }
selected_symbols.push(symbol.clone()); continue;
if selected_symbols.len() >= selection_limit { }
break; selected_symbols.push(symbol.clone());
} if selected_symbols.len() >= selection_limit
&& quote_candidate_symbols.len() >= quote_candidate_limit
{
break;
} }
cursor = end;
batch_size = batch_size.saturating_mul(2).max(120);
} }
Ok((processed_symbols, selected_symbols, diagnostics)) Ok((
quote_candidate_symbols,
selected_symbols,
processed_scope,
diagnostics,
))
} }
pub fn selection_quote_plan( pub fn selection_quote_plan(
@@ -6181,16 +6269,17 @@ impl PlatformExprStrategy {
let selection_limit = self let selection_limit = self
.selection_limit(ctx, &day)? .selection_limit(ctx, &day)?
.min(self.config.max_positions.max(1)); .min(self.config.max_positions.max(1));
let (candidate_symbols, order_symbols, diagnostics) = self.select_quote_plan_symbols( let (candidate_symbols, order_symbols, processed_scope, diagnostics) = self
ctx, .select_quote_plan_symbols(
selection_date, ctx,
universe_factor_date, selection_date,
stock_factor_date, universe_factor_date,
&day, stock_factor_date,
band_low, &day,
band_high, band_low,
selection_limit, band_high,
)?; selection_limit,
)?;
Ok(PlatformSelectionQuotePlan { Ok(PlatformSelectionQuotePlan {
execution_date: ctx.execution_date, execution_date: ctx.execution_date,
decision_date: ctx.decision_date, decision_date: ctx.decision_date,
@@ -6200,7 +6289,7 @@ impl PlatformExprStrategy {
band_low, band_low,
band_high, band_high,
selection_limit, selection_limit,
processed_scope: candidate_symbols.len(), processed_scope,
candidate_symbols, candidate_symbols,
order_symbols, order_symbols,
diagnostics, diagnostics,
@@ -6388,9 +6477,7 @@ impl Strategy for PlatformExprStrategy {
if self.config.rotation_enabled && !self.config.in_skip_window(ctx.execution_date) { if self.config.rotation_enabled && !self.config.in_skip_window(ctx.execution_date) {
let plan = self.selection_quote_plan(ctx, 0)?; let plan = self.selection_quote_plan(ctx, 0)?;
symbols.extend(plan.order_symbols); symbols.extend(plan.order_symbols);
if plan.requires_intraday_selection_quotes { symbols.extend(plan.candidate_symbols);
symbols.extend(plan.candidate_symbols);
}
} }
Ok(symbols) Ok(symbols)
} }
@@ -7297,7 +7384,7 @@ mod tests {
PlatformAccountActionKind, PlatformExplicitActionStage, PlatformExplicitCancelKind, PlatformAccountActionKind, PlatformExplicitActionStage, PlatformExplicitCancelKind,
PlatformExplicitOrderKind, PlatformExprStrategy, PlatformExprStrategyConfig, PlatformExplicitOrderKind, PlatformExprStrategy, PlatformExprStrategyConfig,
PlatformRebalanceSchedule, PlatformScheduleFrequency, PlatformTradeAction, PlatformRebalanceSchedule, PlatformScheduleFrequency, PlatformTradeAction,
PlatformUniverseActionKind, precomputed_stock_rolling_mean, PlatformUniverseActionKind, StockFilterQuoteUsage, precomputed_stock_rolling_mean,
}; };
use crate::{ use crate::{
AlgoOrderStyle, BenchmarkSnapshot, CandidateEligibility, CorporateAction, AlgoOrderStyle, BenchmarkSnapshot, CandidateEligibility, CorporateAction,
@@ -15366,6 +15453,232 @@ mod tests {
); );
} }
#[test]
fn platform_delayed_open_exit_uses_delayed_time_for_slot_projection() {
let prev_date = d(2025, 2, 25);
let date = d(2025, 2, 26);
let delayed_symbol = "000001.SZ";
let held_symbol = "000002.SZ";
let buy_symbol = "000003.SZ";
let symbols = [delayed_symbol, held_symbol, buy_symbol];
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 10:18:00".to_string()),
day_open: 10.0,
open: 10.0,
high: 11.0,
low: 9.0,
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: 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: if *symbol == buy_symbol {
7.0
} else if *symbol == delayed_symbol {
8.0
} else {
9.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(),
vec![
IntradayExecutionQuote {
date,
symbol: delayed_symbol.to_string(),
timestamp: date.and_hms_opt(9, 31, 0).expect("valid timestamp"),
last_price: 10.5,
bid1: 10.5,
ask1: 10.51,
bid1_volume: 10_000,
ask1_volume: 10_000,
volume_delta: 10_000,
amount_delta: 105_000.0,
trading_phase: Some("continuous".to_string()),
},
IntradayExecutionQuote {
date,
symbol: delayed_symbol.to_string(),
timestamp: date.and_hms_opt(10, 18, 0).expect("valid timestamp"),
last_price: 9.0,
bid1: 9.0,
ask1: 9.01,
bid1_volume: 10_000,
ask1_volume: 10_000,
volume_delta: 10_000,
amount_delta: 90_000.0,
trading_phase: Some("continuous".to_string()),
},
IntradayExecutionQuote {
date,
symbol: held_symbol.to_string(),
timestamp: date.and_hms_opt(10, 18, 0).expect("valid timestamp"),
last_price: 10.0,
bid1: 10.0,
ask1: 10.01,
bid1_volume: 10_000,
ask1_volume: 10_000,
volume_delta: 10_000,
amount_delta: 100_000.0,
trading_phase: Some("continuous".to_string()),
},
IntradayExecutionQuote {
date,
symbol: buy_symbol.to_string(),
timestamp: date.and_hms_opt(10, 18, 0).expect("valid timestamp"),
last_price: 10.0,
bid1: 10.0,
ask1: 10.01,
bid1_volume: 10_000,
ask1_volume: 10_000,
volume_delta: 10_000,
amount_delta: 100_000.0,
trading_phase: Some("continuous".to_string()),
},
],
)
.expect("dataset");
let mut portfolio = PortfolioState::new(10_000.0);
portfolio
.position_mut(delayed_symbol)
.buy(prev_date, 100, 10.0);
portfolio
.position_mut(held_symbol)
.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 = delayed_symbol.to_string();
cfg.refresh_rate = 99;
cfg.max_positions = 2;
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 = "2".to_string();
cfg.stock_filter_expr = "close > 0".to_string();
cfg.exposure_expr = "1.0".to_string();
cfg.stop_loss_expr = "false".to_string();
cfg.take_profit_expr = "false".to_string();
cfg.daily_top_up_enabled = true;
cfg.release_slot_on_exit_signal = true;
cfg.aiquant_transaction_cost = true;
cfg.intraday_execution_time = Some(NaiveTime::from_hms_opt(10, 18, 0).unwrap());
cfg.delayed_limit_open_exit_enabled = true;
cfg.delayed_limit_open_exit_time = Some(NaiveTime::from_hms_opt(9, 31, 0).unwrap());
let mut strategy = PlatformExprStrategy::new(cfg);
strategy.rebalance_day_counter = 2;
strategy
.pending_highlimit_holdings
.insert(delayed_symbol.to_string());
let decision = strategy.on_day(&ctx).expect("platform decision");
assert!(
decision.order_intents.iter().any(|intent| matches!(
intent,
OrderIntent::AlgoValue {
symbol,
reason,
start_time,
..
} if symbol == delayed_symbol
&& reason == "delayed_limit_open_sell"
&& *start_time == Some(NaiveTime::from_hms_opt(9, 31, 0).unwrap())
)),
"{:?}",
decision.order_intents
);
assert!(
decision.order_intents.iter().any(|intent| matches!(
intent,
OrderIntent::Value {
symbol,
reason,
..
} if symbol == buy_symbol && reason == "daily_top_up_buy"
)),
"{:?}",
decision.order_intents
);
}
#[test] #[test]
fn platform_daily_top_up_keeps_unsellable_stop_loss_in_target_count() { fn platform_daily_top_up_keeps_unsellable_stop_loss_in_target_count() {
let prev_date = d(2026, 3, 31); let prev_date = d(2026, 3, 31);
@@ -18811,6 +19124,10 @@ mod tests {
cfg.stock_filter_expr = "close > 1.0 && !at_upper_limit && !at_lower_limit".to_string(); cfg.stock_filter_expr = "close > 1.0 && !at_upper_limit && !at_lower_limit".to_string();
let strategy = PlatformExprStrategy::new(cfg); let strategy = PlatformExprStrategy::new(cfg);
assert_eq!(
strategy.stock_filter_quote_usage(),
StockFilterQuoteUsage::LimitStateOnly
);
assert!( assert!(
strategy.stock_filter_uses_intraday_quote_fields(), strategy.stock_filter_uses_intraday_quote_fields(),
"selection limit-state aliases must use the scheduled intraday quote" "selection limit-state aliases must use the scheduled intraday quote"
@@ -18825,4 +19142,153 @@ mod tests {
"actual tick/quote fields still require intraday selection quotes" "actual tick/quote fields still require intraday selection quotes"
); );
} }
#[test]
fn selection_quote_plan_filters_daily_part_before_tick_candidates() {
let date = d(2025, 1, 3);
let symbols = ["000001.SZ", "000002.SZ", "000003.SZ", "000004.SZ"];
let data = DataSet::from_components(
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| {
let is_upper = *symbol == "000001.SZ";
let low_close = *symbol == "000002.SZ";
let reference_price = if low_close { 4.0 } else { 10.0 };
DailyMarketSnapshot {
date,
symbol: (*symbol).to_string(),
timestamp: Some("2025-01-03 10:18:00".to_string()),
day_open: if is_upper { 11.0 } else { reference_price },
open: if is_upper { 11.0 } else { reference_price },
high: if is_upper {
11.0
} else {
reference_price + 0.5
},
low: reference_price - 0.2,
close: reference_price,
last_price: if is_upper { 11.0 } else { reference_price },
bid1: if is_upper {
11.0
} else {
reference_price - 0.01
},
ask1: if is_upper {
11.0
} else {
reference_price + 0.01
},
prev_close: reference_price,
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: if low_close { 4.4 } else { 11.0 },
lower_limit: if low_close { 3.6 } else { 9.0 },
price_tick: 0.01,
}
})
.collect(),
symbols
.iter()
.enumerate()
.map(|(index, symbol)| DailyFactorSnapshot {
date,
symbol: (*symbol).to_string(),
market_cap_bn: index as f64 + 1.0,
free_float_cap_bn: index as f64 + 1.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,
}],
)
.expect("dataset");
let portfolio = PortfolioState::new(1_000_000.0);
let subscriptions = BTreeSet::new();
let ctx = StrategyContext {
execution_date: date,
decision_date: date,
decision_index: 0,
data: &data,
portfolio: &portfolio,
futures_account: None,
open_orders: &[],
dynamic_universe: None,
subscriptions: &subscriptions,
process_events: &[],
active_process_event: None,
active_datetime: Some(date.and_hms_opt(10, 18, 0).unwrap()),
order_events: &[],
fills: &[],
};
let mut cfg = PlatformExprStrategyConfig::microcap_rotation();
cfg.signal_symbol = "000001.SZ".to_string();
cfg.max_positions = 2;
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 = "2".to_string();
cfg.stock_filter_expr =
"prev_close > 5.0 && !at_upper_limit && !at_lower_limit".to_string();
cfg.aiquant_transaction_cost = true;
cfg.intraday_execution_time = Some(NaiveTime::from_hms_opt(10, 18, 0).unwrap());
let strategy = PlatformExprStrategy::new(cfg);
let plan = strategy.selection_quote_plan(&ctx, 0).expect("quote plan");
assert!(plan.requires_intraday_selection_quotes);
assert_eq!(plan.processed_scope, 4);
assert_eq!(
plan.candidate_symbols,
vec![
"000001.SZ".to_string(),
"000003.SZ".to_string(),
"000004.SZ".to_string(),
],
"daily close filter should run before tick quote prefetch; limit state remains a quote-time check"
);
assert_eq!(
plan.order_symbols,
vec!["000003.SZ".to_string(), "000004.SZ".to_string()]
);
}
} }
+99 -3
View File
@@ -111,9 +111,10 @@ impl ChinaAShareRiskControl {
if market.paused || candidate.is_paused { if market.paused || candidate.is_paused {
return Some("paused"); return Some("paused");
} }
if !candidate.allow_sell { // `allow_sell` is derived from the daily candidate snapshot and may
return Some("sell_disabled"); // reflect an open/close fallback rather than the actual execution tick.
} // A sell order must be blocked by the execution price lower-limit check
// below, while suspension and delisting are handled above.
if market.is_at_lower_limit_price(check_price) { if market.is_at_lower_limit_price(check_price) {
return Some("open at or below lower limit"); return Some("open at or below lower limit");
} }
@@ -134,3 +135,98 @@ impl ChinaAShareRiskControl {
} }
} }
} }
#[cfg(test)]
mod tests {
use super::*;
fn d(year: i32, month: u32, day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(year, month, day).expect("valid date")
}
fn candidate(date: NaiveDate) -> CandidateEligibility {
CandidateEligibility {
date,
symbol: "002633.SZ".to_string(),
is_st: false,
is_new_listing: false,
is_paused: false,
allow_buy: true,
allow_sell: false,
is_kcb: false,
is_one_yuan: false,
}
}
fn market(date: NaiveDate, last_price: f64, lower_limit: f64) -> DailyMarketSnapshot {
DailyMarketSnapshot {
date,
symbol: "002633.SZ".to_string(),
timestamp: Some(format!("{date} 10:18:00")),
day_open: last_price,
open: last_price,
high: last_price,
low: last_price,
close: last_price,
last_price,
bid1: last_price,
ask1: last_price,
prev_close: 6.25,
volume: 1_000_000,
tick_volume: 10_000,
bid1_volume: 10_000,
ask1_volume: 10_000,
trading_phase: Some("continuous".to_string()),
paused: false,
upper_limit: 6.89,
lower_limit,
price_tick: 0.01,
}
}
fn position(prev_date: NaiveDate) -> Position {
let mut position = Position::new("002633.SZ");
position.buy(prev_date, 7_200, 8.48);
position
}
#[test]
fn sell_rejection_uses_execution_price_not_stale_allow_sell() {
let prev_date = d(2024, 4, 16);
let date = d(2024, 4, 17);
let candidate = candidate(date);
let market = market(date, 6.27, 5.63);
let position = position(prev_date);
let reason = ChinaAShareRiskControl::sell_rejection_reason(
date,
&candidate,
&market,
None,
Some(&position),
6.27,
);
assert_eq!(reason, None);
}
#[test]
fn sell_rejection_blocks_execution_price_at_lower_limit() {
let prev_date = d(2024, 4, 16);
let date = d(2024, 4, 17);
let candidate = candidate(date);
let market = market(date, 5.63, 5.63);
let position = position(prev_date);
let reason = ChinaAShareRiskControl::sell_rejection_reason(
date,
&candidate,
&market,
None,
Some(&position),
5.63,
);
assert_eq!(reason, Some("open at or below lower limit"));
}
}