优化回测撮合与涨跌停约束
This commit is contained in:
+229
-11
@@ -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(
|
||||
&self,
|
||||
date: NaiveDate,
|
||||
@@ -2059,16 +2087,68 @@ where
|
||||
RuleCheck::allow()
|
||||
}
|
||||
|
||||
fn sell_rule_check(
|
||||
fn buy_rule_check_for_order(
|
||||
&self,
|
||||
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,
|
||||
candidate: &crate::data::CandidateEligibility,
|
||||
instrument: Option<&Instrument>,
|
||||
position: &crate::portfolio::Position,
|
||||
algo_request: Option<&AlgoExecutionRequest>,
|
||||
) -> RuleCheck {
|
||||
if !self.aiquant_rqalpha_execution_rules && !candidate.allow_sell {
|
||||
return RuleCheck::reject("sell_disabled");
|
||||
}
|
||||
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 {
|
||||
ChinaAShareRiskControl::sell_check_price(snapshot, self.execution_price_field)
|
||||
};
|
||||
@@ -2116,8 +2196,16 @@ where
|
||||
let Ok(candidate) = data.require_candidate(date, symbol) else {
|
||||
return current_qty;
|
||||
};
|
||||
let rule =
|
||||
self.sell_rule_check(date, snapshot, candidate, data.instrument(symbol), position);
|
||||
let rule = self.sell_rule_check_for_order(
|
||||
date,
|
||||
data,
|
||||
symbol,
|
||||
snapshot,
|
||||
candidate,
|
||||
data.instrument(symbol),
|
||||
position,
|
||||
None,
|
||||
);
|
||||
if !rule.allowed {
|
||||
return current_qty;
|
||||
}
|
||||
@@ -2155,7 +2243,15 @@ where
|
||||
let Ok(candidate) = data.require_candidate(date, symbol) else {
|
||||
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 {
|
||||
return current_qty;
|
||||
}
|
||||
@@ -2199,8 +2295,16 @@ where
|
||||
let position = portfolio.position(symbol)?;
|
||||
let snapshot = data.require_market(date, symbol).ok()?;
|
||||
let candidate = data.require_candidate(date, symbol).ok()?;
|
||||
let rule =
|
||||
self.sell_rule_check(date, snapshot, candidate, data.instrument(symbol), position);
|
||||
let rule = self.sell_rule_check_for_order(
|
||||
date,
|
||||
data,
|
||||
symbol,
|
||||
snapshot,
|
||||
candidate,
|
||||
data.instrument(symbol),
|
||||
position,
|
||||
None,
|
||||
);
|
||||
if !rule.allowed {
|
||||
return rule.reason;
|
||||
}
|
||||
@@ -2240,7 +2344,15 @@ where
|
||||
) -> Option<String> {
|
||||
let snapshot = data.require_market(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 {
|
||||
return rule.reason;
|
||||
}
|
||||
@@ -2310,8 +2422,16 @@ where
|
||||
);
|
||||
}
|
||||
|
||||
let rule =
|
||||
self.sell_rule_check(date, snapshot, candidate, data.instrument(symbol), position);
|
||||
let rule = self.sell_rule_check_for_order(
|
||||
date,
|
||||
data,
|
||||
symbol,
|
||||
snapshot,
|
||||
candidate,
|
||||
data.instrument(symbol),
|
||||
position,
|
||||
algo_request,
|
||||
);
|
||||
if !rule.allowed {
|
||||
let rule_reason = rule.reason.as_deref().unwrap_or_default().to_string();
|
||||
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 {
|
||||
let rule_reason = rule.reason.as_deref().unwrap_or_default().to_string();
|
||||
let status = match rule.reason.as_deref() {
|
||||
@@ -5773,4 +5901,94 @@ mod tests {
|
||||
assert_eq!(fill.quantity, 0);
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -435,6 +435,13 @@ struct StockExpressionState {
|
||||
extra_text_factors: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum StockFilterQuoteUsage {
|
||||
DailyOnly,
|
||||
LimitStateOnly,
|
||||
IntradayQuote,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct PositionExpressionState {
|
||||
order_book_id: String,
|
||||
@@ -1146,6 +1153,17 @@ impl PlatformExprStrategy {
|
||||
.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(
|
||||
&self,
|
||||
ctx: &StrategyContext<'_>,
|
||||
@@ -1494,7 +1512,7 @@ impl PlatformExprStrategy {
|
||||
if quantity == 0 {
|
||||
return None;
|
||||
}
|
||||
if !self.can_sell_position(ctx, date, symbol) {
|
||||
if !self.can_sell_position_at_time(ctx, date, symbol, execution_time) {
|
||||
return None;
|
||||
}
|
||||
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 {
|
||||
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 {
|
||||
return false;
|
||||
};
|
||||
@@ -5888,13 +5916,22 @@ impl PlatformExprStrategy {
|
||||
let Ok(candidate) = ctx.data.require_candidate(date, symbol) else {
|
||||
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(
|
||||
date,
|
||||
candidate,
|
||||
market,
|
||||
ctx.data.instrument(symbol),
|
||||
Some(position),
|
||||
market.price(PriceField::Last),
|
||||
check_price,
|
||||
)
|
||||
.is_none()
|
||||
}
|
||||
@@ -6033,28 +6070,69 @@ impl PlatformExprStrategy {
|
||||
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);
|
||||
Self::extract_identifier_candidates(&expr)
|
||||
.into_iter()
|
||||
.any(|name| {
|
||||
matches!(
|
||||
name.as_str(),
|
||||
"last"
|
||||
| "last_price"
|
||||
| "bid1"
|
||||
| "ask1"
|
||||
| "bid1_volume"
|
||||
| "ask1_volume"
|
||||
| "tick_volume"
|
||||
| "at_upper_limit"
|
||||
| "at_lower_limit"
|
||||
| "touched_upper_limit"
|
||||
| "touched_lower_limit"
|
||||
| "hit_upper_limit"
|
||||
| "hit_lower_limit"
|
||||
)
|
||||
})
|
||||
let mut usage = StockFilterQuoteUsage::DailyOnly;
|
||||
for name in Self::extract_identifier_candidates(&expr) {
|
||||
match name.as_str() {
|
||||
"last" | "last_price" | "bid1" | "ask1" | "bid1_volume" | "ask1_volume"
|
||||
| "tick_volume" => return StockFilterQuoteUsage::IntradayQuote,
|
||||
"at_upper_limit" | "at_lower_limit" => {
|
||||
usage = StockFilterQuoteUsage::LimitStateOnly;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
usage
|
||||
}
|
||||
|
||||
fn stock_filter_uses_intraday_quote_fields(&self) -> bool {
|
||||
self.stock_filter_quote_usage() != StockFilterQuoteUsage::DailyOnly
|
||||
}
|
||||
|
||||
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(
|
||||
@@ -6067,11 +6145,12 @@ impl PlatformExprStrategy {
|
||||
band_low: f64,
|
||||
band_high: f64,
|
||||
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 mut diagnostics = 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 {
|
||||
let stock =
|
||||
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 batch_size = selection_limit.saturating_add(80).max(120);
|
||||
let mut cursor = 0usize;
|
||||
while cursor < candidates.len() && selected_symbols.len() < selection_limit {
|
||||
let end = (cursor + batch_size).min(candidates.len());
|
||||
for (symbol, _) in &candidates[cursor..end] {
|
||||
processed_symbols.push(symbol.clone());
|
||||
let mut processed_scope = 0usize;
|
||||
for (symbol, _) in &candidates {
|
||||
processed_scope += 1;
|
||||
let stock = self.stock_state_with_factor_date(ctx, date, stock_factor_date, symbol)?;
|
||||
if !self.stock_passes_quote_plan_filter(ctx, day, &stock, quote_usage)? {
|
||||
if diagnostics.len() < 12 {
|
||||
diagnostics.push(format!("{symbol} quote_plan rejected by stock_expr"));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if quote_candidate_symbols.len() < quote_candidate_limit {
|
||||
quote_candidate_symbols.push(symbol.clone());
|
||||
}
|
||||
for (symbol, _) in &candidates[cursor..end] {
|
||||
let stock =
|
||||
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 diagnostics.len() < 12 {
|
||||
diagnostics.push(format!("{symbol} quote_plan rejected by {reason}"));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if apply_stock_filter && !self.stock_passes_expr(ctx, day, &stock)? {
|
||||
if quote_usage == StockFilterQuoteUsage::IntradayQuote
|
||||
&& !self.stock_passes_expr(ctx, day, &stock)?
|
||||
{
|
||||
if diagnostics.len() < 12 {
|
||||
diagnostics.push(format!("{symbol} quote_plan rejected by stock_expr"));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
selected_symbols.push(symbol.clone());
|
||||
if selected_symbols.len() >= selection_limit {
|
||||
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(
|
||||
@@ -6181,7 +6269,8 @@ impl PlatformExprStrategy {
|
||||
let selection_limit = self
|
||||
.selection_limit(ctx, &day)?
|
||||
.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
|
||||
.select_quote_plan_symbols(
|
||||
ctx,
|
||||
selection_date,
|
||||
universe_factor_date,
|
||||
@@ -6200,7 +6289,7 @@ impl PlatformExprStrategy {
|
||||
band_low,
|
||||
band_high,
|
||||
selection_limit,
|
||||
processed_scope: candidate_symbols.len(),
|
||||
processed_scope,
|
||||
candidate_symbols,
|
||||
order_symbols,
|
||||
diagnostics,
|
||||
@@ -6388,10 +6477,8 @@ impl Strategy for PlatformExprStrategy {
|
||||
if self.config.rotation_enabled && !self.config.in_skip_window(ctx.execution_date) {
|
||||
let plan = self.selection_quote_plan(ctx, 0)?;
|
||||
symbols.extend(plan.order_symbols);
|
||||
if plan.requires_intraday_selection_quotes {
|
||||
symbols.extend(plan.candidate_symbols);
|
||||
}
|
||||
}
|
||||
Ok(symbols)
|
||||
}
|
||||
|
||||
@@ -7297,7 +7384,7 @@ mod tests {
|
||||
PlatformAccountActionKind, PlatformExplicitActionStage, PlatformExplicitCancelKind,
|
||||
PlatformExplicitOrderKind, PlatformExprStrategy, PlatformExprStrategyConfig,
|
||||
PlatformRebalanceSchedule, PlatformScheduleFrequency, PlatformTradeAction,
|
||||
PlatformUniverseActionKind, precomputed_stock_rolling_mean,
|
||||
PlatformUniverseActionKind, StockFilterQuoteUsage, precomputed_stock_rolling_mean,
|
||||
};
|
||||
use crate::{
|
||||
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]
|
||||
fn platform_daily_top_up_keeps_unsellable_stop_loss_in_target_count() {
|
||||
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();
|
||||
let strategy = PlatformExprStrategy::new(cfg);
|
||||
|
||||
assert_eq!(
|
||||
strategy.stock_filter_quote_usage(),
|
||||
StockFilterQuoteUsage::LimitStateOnly
|
||||
);
|
||||
assert!(
|
||||
strategy.stock_filter_uses_intraday_quote_fields(),
|
||||
"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"
|
||||
);
|
||||
}
|
||||
|
||||
#[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()]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,9 +111,10 @@ impl ChinaAShareRiskControl {
|
||||
if market.paused || candidate.is_paused {
|
||||
return Some("paused");
|
||||
}
|
||||
if !candidate.allow_sell {
|
||||
return Some("sell_disabled");
|
||||
}
|
||||
// `allow_sell` is derived from the daily candidate snapshot and may
|
||||
// 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) {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user