diff --git a/crates/fidc-core/src/broker.rs b/crates/fidc-core/src/broker.rs index ce1e3e8..e68eb81 100644 --- a/crates/fidc-core/src/broker.rs +++ b/crates/fidc-core/src/broker.rs @@ -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 { 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 + ); + } } diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index f8a1c4c..4f902dc 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -435,6 +435,13 @@ struct StockExpressionState { extra_text_factors: BTreeMap, } +#[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, + ) -> Option { + 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, + ) -> 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 { + 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, Vec, Vec), BacktestError> { + ) -> Result<(Vec, Vec, usize, Vec), 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; } - 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 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 { - break; - } + if quote_candidate_symbols.len() < quote_candidate_limit { + quote_candidate_symbols.push(symbol.clone()); + } + 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 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 + && 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,16 +6269,17 @@ 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( - ctx, - selection_date, - universe_factor_date, - stock_factor_date, - &day, - band_low, - band_high, - selection_limit, - )?; + let (candidate_symbols, order_symbols, processed_scope, diagnostics) = self + .select_quote_plan_symbols( + ctx, + selection_date, + universe_factor_date, + stock_factor_date, + &day, + band_low, + band_high, + selection_limit, + )?; Ok(PlatformSelectionQuotePlan { execution_date: ctx.execution_date, decision_date: ctx.decision_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,9 +6477,7 @@ 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); - } + 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()] + ); + } } diff --git a/crates/fidc-core/src/risk_control.rs b/crates/fidc-core/src/risk_control.rs index 5740cd8..2855ac8 100644 --- a/crates/fidc-core/src/risk_control.rs +++ b/crates/fidc-core/src/risk_control.rs @@ -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")); + } +}