From 3499d4aa744efff994c5c62c96e4318427f85916 Mon Sep 17 00:00:00 2001 From: boris Date: Fri, 22 May 2026 17:22:33 +0800 Subject: [PATCH] =?UTF-8?q?chore:=20=E6=9B=B4=E6=96=B0=20fidc-backtest-eng?= =?UTF-8?q?ine=20-=202026-05-22?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/fidc-core/src/broker.rs | 90 ++++-- crates/fidc-core/src/data.rs | 67 ++++- crates/fidc-core/src/lib.rs | 2 + .../fidc-core/src/platform_expr_strategy.rs | 260 ++++++++++++++++-- crates/fidc-core/src/risk_control.rs | 125 +++++++++ crates/fidc-core/src/rules.rs | 51 ++-- crates/fidc-core/src/strategy.rs | 54 ++-- crates/fidc-core/tests/corporate_actions.rs | 2 +- 8 files changed, 526 insertions(+), 125 deletions(-) create mode 100644 crates/fidc-core/src/risk_control.rs diff --git a/crates/fidc-core/src/broker.rs b/crates/fidc-core/src/broker.rs index 740328e..2e8c0c7 100644 --- a/crates/fidc-core/src/broker.rs +++ b/crates/fidc-core/src/broker.rs @@ -10,7 +10,9 @@ use crate::events::{ AccountEvent, FillEvent, OrderEvent, OrderSide, OrderStatus, PositionEvent, ProcessEvent, ProcessEventKind, }; +use crate::instrument::Instrument; use crate::portfolio::PortfolioState; +use crate::risk_control::ChinaAShareRiskControl; use crate::rules::{EquityRuleHooks, RuleCheck}; use crate::strategy::{ AlgoOrderStyle, OpenOrderView, OrderIntent, StrategyDecision, TargetPortfolioOrderPricing, @@ -1850,19 +1852,27 @@ where date: NaiveDate, snapshot: &crate::data::DailyMarketSnapshot, candidate: &crate::data::CandidateEligibility, + instrument: Option<&Instrument>, ) -> RuleCheck { + let check_price = if self.aiquant_rqalpha_execution_rules { + self.aiquant_limit_check_price(snapshot, OrderSide::Buy) + } 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); } - if snapshot.paused || candidate.is_paused { - return RuleCheck::reject("paused"); - } - let check_price = self.aiquant_limit_check_price(snapshot, OrderSide::Buy); - if snapshot.is_at_upper_limit_price(check_price) { - return RuleCheck::reject("open at or above upper limit"); - } RuleCheck::allow() } @@ -1871,8 +1881,24 @@ where date: NaiveDate, snapshot: &crate::data::DailyMarketSnapshot, candidate: &crate::data::CandidateEligibility, + instrument: Option<&Instrument>, position: &crate::portfolio::Position, ) -> RuleCheck { + let check_price = if self.aiquant_rqalpha_execution_rules { + self.aiquant_limit_check_price(snapshot, OrderSide::Sell) + } else { + ChinaAShareRiskControl::sell_check_price(snapshot, self.execution_price_field) + }; + if let Some(reason) = ChinaAShareRiskControl::sell_rejection_reason( + date, + candidate, + snapshot, + instrument, + Some(position), + check_price, + ) { + return RuleCheck::reject(reason); + } if !self.aiquant_rqalpha_execution_rules { return self.rules.can_sell( date, @@ -1882,16 +1908,6 @@ where self.execution_price_field, ); } - if snapshot.paused || candidate.is_paused { - return RuleCheck::reject("paused"); - } - let check_price = self.aiquant_limit_check_price(snapshot, OrderSide::Sell); - if snapshot.is_at_lower_limit_price(check_price) { - return RuleCheck::reject("open at or below lower limit"); - } - if position.sellable_qty(date) == 0 { - return RuleCheck::reject("t+1 sellable quantity is zero"); - } RuleCheck::allow() } @@ -1917,7 +1933,8 @@ where let Ok(candidate) = data.require_candidate(date, symbol) else { return current_qty; }; - let rule = self.sell_rule_check(date, snapshot, candidate, position); + let rule = + self.sell_rule_check(date, snapshot, candidate, data.instrument(symbol), position); if !rule.allowed { return current_qty; } @@ -1955,7 +1972,7 @@ where let Ok(candidate) = data.require_candidate(date, symbol) else { return current_qty; }; - let rule = self.buy_rule_check(date, snapshot, candidate); + let rule = self.buy_rule_check(date, snapshot, candidate, data.instrument(symbol)); if !rule.allowed { return current_qty; } @@ -1999,7 +2016,8 @@ 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, position); + let rule = + self.sell_rule_check(date, snapshot, candidate, data.instrument(symbol), position); if !rule.allowed { return rule.reason; } @@ -2039,7 +2057,7 @@ 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); + let rule = self.buy_rule_check(date, snapshot, candidate, data.instrument(symbol)); if !rule.allowed { return rule.reason; } @@ -2109,12 +2127,15 @@ where ); } - let rule = self.sell_rule_check(date, snapshot, candidate, position); + let rule = + self.sell_rule_check(date, snapshot, candidate, data.instrument(symbol), position); if !rule.allowed { let rule_reason = rule.reason.as_deref().unwrap_or_default().to_string(); let status = match rule.reason.as_deref() { Some("paused") | Some("sell disabled by eligibility flags") + | Some("sell_disabled") + | Some("lower_limit") | Some("open at or below lower limit") => OrderStatus::Canceled, _ => OrderStatus::Rejected, }; @@ -3542,12 +3563,14 @@ where ); } - let rule = self.buy_rule_check(date, snapshot, candidate); + let rule = self.buy_rule_check(date, snapshot, candidate, data.instrument(symbol)); if !rule.allowed { let rule_reason = rule.reason.as_deref().unwrap_or_default().to_string(); let status = match rule.reason.as_deref() { Some("paused") | Some("buy disabled by eligibility flags") + | Some("buy_disabled") + | Some("upper_limit") | Some("open at or above upper limit") => OrderStatus::Canceled, _ => OrderStatus::Rejected, }; @@ -4721,6 +4744,8 @@ fn zero_fill_status_for_reason(reason: &str) -> OrderStatus { | "tick volume limit" | "intraday quote liquidity exhausted" | "no execution quotes after start" + | "upper_limit" + | "lower_limit" | "open at or above upper limit" | "open at or below lower limit" => OrderStatus::Canceled, _ => OrderStatus::Rejected, @@ -4733,6 +4758,8 @@ fn final_partial_fill_status(partial_reason: Option<&str>) -> OrderStatus { if reason.contains("market liquidity or volume limit") || reason.contains("intraday quote liquidity exhausted") || reason.contains("no execution quotes after start") + || reason.contains("upper_limit") + || reason.contains("lower_limit") || reason.contains("open at or above upper limit") || reason.contains("open at or below lower limit") => { @@ -4913,7 +4940,7 @@ mod tests { } #[test] - fn aiquant_rules_allow_buy_when_day_flags_block_but_last_price_is_tradable() { + fn aiquant_rules_keep_structured_buy_risk_while_using_aiquant_limit_price() { let mut snapshot = limit_test_snapshot(); snapshot.open = 11.0; snapshot.day_open = 11.0; @@ -4927,12 +4954,9 @@ mod tests { ChinaEquityRuleHooks, PriceField::Last, ); - let default_rule = default_broker.buy_rule_check(date, &snapshot, &candidate); + let default_rule = default_broker.buy_rule_check(date, &snapshot, &candidate, None); assert!(!default_rule.allowed); - assert_eq!( - default_rule.reason.as_deref(), - Some("buy disabled by eligibility flags") - ); + assert_eq!(default_rule.reason.as_deref(), Some("trade_disabled")); let aiquant_broker = BrokerSimulator::new_with_execution_price( ChinaAShareCostModel::default(), @@ -4940,7 +4964,13 @@ mod tests { PriceField::Last, ) .with_aiquant_rqalpha_execution_rules(true); - let aiquant_rule = aiquant_broker.buy_rule_check(date, &snapshot, &candidate); + let aiquant_rule = aiquant_broker.buy_rule_check(date, &snapshot, &candidate, None); + assert!(!aiquant_rule.allowed); + assert_eq!(aiquant_rule.reason.as_deref(), Some("trade_disabled")); + + let tradable_candidate = limit_test_candidate(true, true); + let aiquant_rule = + aiquant_broker.buy_rule_check(date, &snapshot, &tradable_candidate, None); assert!(aiquant_rule.allowed); } diff --git a/crates/fidc-core/src/data.rs b/crates/fidc-core/src/data.rs index 6dd66d0..cff9700 100644 --- a/crates/fidc-core/src/data.rs +++ b/crates/fidc-core/src/data.rs @@ -9,6 +9,7 @@ use thiserror::Error; use crate::calendar::TradingCalendar; use crate::futures::{FuturesCommissionType, FuturesTradingParameter}; use crate::instrument::Instrument; +use crate::risk_control::ChinaAShareRiskControl; mod date_format { use chrono::NaiveDate; @@ -1080,8 +1081,12 @@ impl DataSet { let market_series_by_symbol = build_market_series(&market_by_date); let benchmark_series_cache = BenchmarkPriceSeries::new(&benchmark_by_date.values().cloned().collect::>()); - let eligible_universe_by_date = - build_eligible_universe(&factor_by_date, &candidate_index, &market_index); + let eligible_universe_by_date = build_eligible_universe( + &factor_by_date, + &candidate_index, + &market_index, + &instruments, + ); let futures_params_by_symbol = build_futures_params_index(futures_params); Ok(Self { @@ -3287,6 +3292,7 @@ fn build_eligible_universe( factor_by_date: &BTreeMap>, candidate_index: &HashMap<(NaiveDate, String), CandidateEligibility>, market_index: &HashMap<(NaiveDate, String), DailyMarketSnapshot>, + instruments: &HashMap, ) -> BTreeMap> { let mut per_date = BTreeMap::>::new(); @@ -3303,7 +3309,14 @@ fn build_eligible_universe( let Some(market) = market_index.get(&key) else { continue; }; - if !candidate.eligible_for_selection() || market.paused { + if ChinaAShareRiskControl::selection_rejection_reason( + *date, + candidate, + market, + instruments.get(&factor.symbol), + ) + .is_some() + { continue; } rows.push(EligibleUniverseSnapshot { @@ -3324,6 +3337,11 @@ fn build_eligible_universe( per_date } +#[cfg(test)] +fn instrument_passes_baseline_selection(instrument: Option<&Instrument>, date: NaiveDate) -> bool { + ChinaAShareRiskControl::instrument_rejection_reason(instrument, date).is_none() +} + #[cfg(test)] mod tests { use super::*; @@ -3363,6 +3381,49 @@ mod tests { } } + #[test] + fn baseline_selection_uses_structured_instrument_dates_and_status_only() { + let date = NaiveDate::parse_from_str("2025-01-02", "%Y-%m-%d").unwrap(); + let instrument = |name: &str, status: &str, delisted_at: Option| Instrument { + symbol: "000001.SZ".to_string(), + name: name.to_string(), + board: "SZ".to_string(), + round_lot: 100, + listed_at: Some(NaiveDate::parse_from_str("2020-01-01", "%Y-%m-%d").unwrap()), + delisted_at, + status: status.to_string(), + }; + + assert!(instrument_passes_baseline_selection( + Some(&instrument("Short History Stock", "active", None)), + date + )); + assert!(instrument_passes_baseline_selection( + Some(&instrument("*ST测试", "active", None)), + date + )); + assert!(instrument_passes_baseline_selection( + Some(&instrument("ST测试", "active", None)), + date + )); + assert!(instrument_passes_baseline_selection( + Some(&instrument("退市测试", "active", None)), + date + )); + assert!(!instrument_passes_baseline_selection( + Some(&instrument("正常名称", "delisted", None)), + date + )); + assert!(!instrument_passes_baseline_selection( + Some(&instrument( + "正常名称", + "active", + Some(NaiveDate::parse_from_str("2025-01-01", "%Y-%m-%d").unwrap()), + )), + date + )); + } + #[test] fn decision_volume_average_uses_previous_completed_days_only() { let series = SymbolPriceSeries::new(&[ diff --git a/crates/fidc-core/src/lib.rs b/crates/fidc-core/src/lib.rs index 75b6b9d..ec43fa6 100644 --- a/crates/fidc-core/src/lib.rs +++ b/crates/fidc-core/src/lib.rs @@ -12,6 +12,7 @@ pub mod platform_expr_strategy; pub mod platform_runtime_schema; pub mod platform_strategy_spec; pub mod portfolio; +pub mod risk_control; pub mod rules; pub mod scheduler; pub mod strategy; @@ -66,6 +67,7 @@ pub use platform_strategy_spec::{ StrategyRuntimeSpec, platform_expr_config_from_spec, platform_expr_config_from_value, }; pub use portfolio::{CashReceivable, HoldingSummary, PendingCashFlow, PortfolioState, Position}; +pub use risk_control::ChinaAShareRiskControl; pub use rules::{ChinaEquityRuleHooks, EquityRuleHooks, RuleCheck}; pub use scheduler::{ ScheduleFrequency, ScheduleRule, ScheduleStage, ScheduleTimeRule, Scheduler, default_stage_time, diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index 977696f..d0986bb 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -9,6 +9,7 @@ use crate::data::{DailyMarketSnapshot, EligibleUniverseSnapshot, PriceField}; use crate::engine::BacktestError; use crate::events::OrderSide; use crate::portfolio::PortfolioState; +use crate::risk_control::ChinaAShareRiskControl; use crate::scheduler::{ ScheduleRule, ScheduleStage, ScheduleTimeRule, Scheduler, default_stage_time, }; @@ -433,6 +434,27 @@ struct PositionExpressionState { dividend_receivable: f64, } +fn precomputed_stock_rolling_mean( + extra_factors: &BTreeMap, + field: &str, + lookback: usize, +) -> Option { + let key = match (field.trim().to_ascii_lowercase().as_str(), lookback) { + ("close" | "prev_close" | "stock_close" | "price", 5) => "ma5_prev_close", + ("close" | "prev_close" | "stock_close" | "price", 10) => "ma10_prev_close", + ("close" | "prev_close" | "stock_close" | "price", 20) => "ma20_prev_close", + ("close" | "prev_close" | "stock_close" | "price", 30) => "ma30_prev_close", + ("volume" | "stock_volume", 5) => "avg_volume5", + ("volume" | "stock_volume", 20) => "avg_volume20", + ("volume" | "stock_volume", 60) => "avg_volume60", + _ => return None, + }; + extra_factors + .get(key) + .copied() + .filter(|value| value.is_finite()) +} + pub struct PlatformExprStrategy { config: PlatformExprStrategyConfig, engine: Engine, @@ -795,12 +817,20 @@ impl PlatformExprStrategy { ) } - fn price_is_at_limit(price: f64, limit: f64, tick: f64) -> bool { + fn price_is_at_or_above_upper_limit(price: f64, limit: f64, tick: f64) -> bool { if !price.is_finite() || !limit.is_finite() { return false; } - let tolerance = tick.abs().max(1e-6); - (price - limit).abs() <= tolerance + let tolerance = (tick.abs() * 0.5).max(1e-6); + price >= limit - tolerance + } + + fn price_is_at_or_below_lower_limit(price: f64, limit: f64, tick: f64) -> bool { + if !price.is_finite() || !limit.is_finite() { + return false; + } + let tolerance = (tick.abs() * 0.5).max(1e-6); + price <= limit + tolerance } fn intraday_execution_start_time(&self) -> NaiveTime { @@ -1485,46 +1515,75 @@ impl PlatformExprStrategy { let stock_ma_short = ctx .data .market_decision_close_moving_average(date, symbol, self.config.stock_short_ma_days) + .or_else(|| { + precomputed_stock_rolling_mean( + &factor.extra_factors, + "close", + self.config.stock_short_ma_days, + ) + }) .unwrap_or(f64::NAN); let stock_ma_mid = ctx .data .market_decision_close_moving_average(date, symbol, self.config.stock_mid_ma_days) + .or_else(|| { + precomputed_stock_rolling_mean( + &factor.extra_factors, + "close", + self.config.stock_mid_ma_days, + ) + }) .unwrap_or(f64::NAN); let stock_ma_long = ctx .data .market_decision_close_moving_average(date, symbol, self.config.stock_long_ma_days) + .or_else(|| { + precomputed_stock_rolling_mean( + &factor.extra_factors, + "close", + self.config.stock_long_ma_days, + ) + }) .unwrap_or(f64::NAN); let stock_ma5 = ctx .data .market_decision_close_moving_average(date, symbol, 5) + .or_else(|| precomputed_stock_rolling_mean(&factor.extra_factors, "close", 5)) .unwrap_or(f64::NAN); let stock_ma10 = ctx .data .market_decision_close_moving_average(date, symbol, 10) + .or_else(|| precomputed_stock_rolling_mean(&factor.extra_factors, "close", 10)) .unwrap_or(f64::NAN); let stock_ma20 = ctx .data .market_decision_close_moving_average(date, symbol, 20) + .or_else(|| precomputed_stock_rolling_mean(&factor.extra_factors, "close", 20)) .unwrap_or(f64::NAN); let stock_ma30 = ctx .data .market_decision_close_moving_average(date, symbol, 30) + .or_else(|| precomputed_stock_rolling_mean(&factor.extra_factors, "close", 30)) .unwrap_or(f64::NAN); let stock_volume_ma5 = ctx .data .market_decision_volume_moving_average(date, symbol, 5) + .or_else(|| precomputed_stock_rolling_mean(&factor.extra_factors, "volume", 5)) .unwrap_or(f64::NAN); let stock_volume_ma10 = ctx .data .market_decision_volume_moving_average(date, symbol, 10) + .or_else(|| precomputed_stock_rolling_mean(&factor.extra_factors, "volume", 10)) .unwrap_or(f64::NAN); let stock_volume_ma20 = ctx .data .market_decision_volume_moving_average(date, symbol, 20) + .or_else(|| precomputed_stock_rolling_mean(&factor.extra_factors, "volume", 20)) .unwrap_or(f64::NAN); let stock_volume_ma60 = ctx .data .market_decision_volume_moving_average(date, symbol, 60) + .or_else(|| precomputed_stock_rolling_mean(&factor.extra_factors, "volume", 60)) .unwrap_or(f64::NAN); let touched_upper_limit = !market.paused && (market.is_at_upper_limit_price(market.close) @@ -1903,10 +1962,16 @@ impl PlatformExprStrategy { ); scope.push("day_factors", day_factors); if let Some(stock) = stock { - let at_upper_limit = - Self::price_is_at_limit(stock.last, stock.upper_limit, stock.price_tick); - let at_lower_limit = - Self::price_is_at_limit(stock.last, stock.lower_limit, stock.price_tick); + let at_upper_limit = Self::price_is_at_or_above_upper_limit( + stock.last, + stock.upper_limit, + stock.price_tick, + ); + let at_lower_limit = Self::price_is_at_or_below_lower_limit( + stock.last, + stock.lower_limit, + stock.price_tick, + ); scope.push("symbol", stock.symbol.clone()); scope.push("market_cap", stock.market_cap); scope.push("free_float_cap", stock.free_float_cap); @@ -3093,12 +3158,16 @@ impl PlatformExprStrategy { "rolling_mean(\"{other}\", {lookback}) requires stock context" )) })?; - ctx.data.market_decision_numeric_moving_average( - day.date, - &stock.symbol, - other, - lookback, - ) + ctx.data + .market_decision_numeric_moving_average( + day.date, + &stock.symbol, + other, + lookback, + ) + .or_else(|| { + precomputed_stock_rolling_mean(&stock.extra_factors, other, lookback) + }) } }; value.ok_or_else(|| { @@ -4581,10 +4650,13 @@ impl PlatformExprStrategy { let Some(market) = ctx.data.market(date, &factor.symbol) else { continue; }; - if market.paused && !self.config.aiquant_transaction_cost { + if self + .baseline_risk_rejection_reason(ctx, date, &factor.symbol, candidate, market) + .is_some() + { continue; } - if !self.stock_passes_universe_exclude(candidate, market, false) { + if !self.stock_passes_universe_exclude(candidate, market) { continue; } rows.push(EligibleUniverseSnapshot { @@ -4602,17 +4674,32 @@ impl PlatformExprStrategy { rows } + fn baseline_risk_rejection_reason( + &self, + ctx: &StrategyContext<'_>, + date: NaiveDate, + symbol: &str, + candidate: &crate::data::CandidateEligibility, + market: &DailyMarketSnapshot, + ) -> Option<&'static str> { + ChinaAShareRiskControl::selection_rejection_reason( + date, + candidate, + market, + ctx.data.instrument(symbol), + ) + } + fn stock_passes_universe_exclude( &self, candidate: &crate::data::CandidateEligibility, market: &DailyMarketSnapshot, - has_special_name: bool, ) -> bool { let excludes = &self.config.universe_exclude; if excludes.iter().any(|item| item == "paused") && (market.paused || candidate.is_paused) { return false; } - if excludes.iter().any(|item| item == "st") && (candidate.is_st || has_special_name) { + if excludes.iter().any(|item| item == "st") && candidate.is_st { return false; } if excludes.iter().any(|item| item == "kcb") && candidate.is_kcb { @@ -4754,12 +4841,18 @@ impl PlatformExprStrategy { let market = ctx.data.require_market(date, symbol)?; let candidate = ctx.data.require_candidate(date, symbol)?; + if let Some(reason) = + self.baseline_risk_rejection_reason(ctx, date, symbol, &candidate, &market) + { + return Ok(Some(reason.to_string())); + } + let excludes = &self.config.universe_exclude; if excludes.iter().any(|item| item == "paused") && (market.paused || candidate.is_paused) { return Ok(Some("paused".to_string())); } if excludes.iter().any(|item| item == "st") && stock.is_st { - return Ok(Some("st_or_special_name".to_string())); + return Ok(Some("st".to_string())); } if excludes.iter().any(|item| item == "kcb") && candidate.is_kcb { return Ok(Some("kcb".to_string())); @@ -5609,6 +5702,22 @@ mod tests { assert!(!rewritten.contains('?')); } + #[test] + fn platform_expr_limit_flags_are_directional() { + assert!(PlatformExprStrategy::price_is_at_or_above_upper_limit( + 20.21, 17.60, 0.01 + )); + assert!(!PlatformExprStrategy::price_is_at_or_above_upper_limit( + 17.58, 17.60, 0.01 + )); + assert!(PlatformExprStrategy::price_is_at_or_below_lower_limit( + 14.39, 14.40, 0.01 + )); + assert!(!PlatformExprStrategy::price_is_at_or_below_lower_limit( + 14.42, 14.40, 0.01 + )); + } + #[test] fn platform_expr_rewrites_prelude_assignment_ternary() { let prelude = "let cap_low = sig_close <= 0 ? 7 : max(5, min(cap_low_raw, 70));"; @@ -6858,10 +6967,10 @@ mod tests { vec![CandidateEligibility { date, symbol: symbol.to_string(), - is_st: true, + is_st: false, is_new_listing: false, is_paused: false, - allow_buy: false, + allow_buy: true, allow_sell: true, is_kcb: false, is_one_yuan: false, @@ -6922,6 +7031,109 @@ mod tests { assert_eq!(rejection, None); } + #[test] + fn platform_strategy_baseline_risk_filter_cannot_be_disabled_by_empty_exclude() { + let date = d(2024, 12, 31); + let symbol = "002494.SZ"; + let data = DataSet::from_components( + vec![Instrument { + symbol: symbol.to_string(), + name: symbol.to_string(), + board: "SZ".to_string(), + round_lot: 100, + listed_at: Some(d(2010, 1, 1)), + delisted_at: None, + status: "active".to_string(), + }], + vec![DailyMarketSnapshot { + date, + symbol: symbol.to_string(), + timestamp: Some("2024-12-31 09:33:00".to_string()), + day_open: 8.0, + open: 8.0, + high: 8.0, + low: 8.0, + close: 8.0, + last_price: 8.0, + bid1: 8.0, + ask1: 8.0, + prev_close: 8.0, + volume: 0, + tick_volume: 0, + bid1_volume: 0, + ask1_volume: 0, + trading_phase: Some("suspended".to_string()), + paused: true, + upper_limit: 8.8, + lower_limit: 7.2, + price_tick: 0.01, + }], + vec![DailyFactorSnapshot { + date, + symbol: symbol.to_string(), + market_cap_bn: 12.0, + free_float_cap_bn: 8.0, + pe_ttm: 10.0, + turnover_ratio: Some(0.0), + effective_turnover_ratio: Some(0.0), + extra_factors: BTreeMap::new(), + }], + vec![CandidateEligibility { + date, + symbol: symbol.to_string(), + is_st: false, + is_new_listing: false, + is_paused: true, + allow_buy: true, + allow_sell: true, + is_kcb: false, + is_one_yuan: false, + }], + 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: 40, + 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.universe_exclude.clear(); + cfg.aiquant_transaction_cost = true; + let strategy = PlatformExprStrategy::new(cfg); + let stock = strategy + .stock_state(&ctx, date, symbol) + .expect("stock state"); + + let rejection = strategy + .buy_rejection_reason(&ctx, date, symbol, &stock) + .expect("rejection"); + let selected = strategy.selectable_universe_on(&ctx, date, date); + + assert_eq!(rejection.as_deref(), Some("paused")); + assert!(selected.is_empty()); + } + fn sample_calendar() -> TradingCalendar { TradingCalendar::new(vec![ d(2025, 1, 30), @@ -7810,7 +8022,7 @@ mod tests { } #[test] - fn platform_strategy_honors_configured_universe_excludes_for_new_listings() { + fn platform_strategy_baseline_risk_excludes_new_listings_even_when_config_omits_it() { let date = d(2025, 2, 3); let symbols = ["301001.SZ", "000001.SZ"]; let data = DataSet::from_components( @@ -7949,10 +8161,14 @@ mod tests { let decision = strategy.on_day(&ctx).expect("platform decision"); - assert!(decision.order_intents.iter().any(|intent| matches!( + assert!(!decision.order_intents.iter().any(|intent| matches!( intent, crate::strategy::OrderIntent::Value { symbol, .. } if symbol == "301001.SZ" ))); + assert!(decision.order_intents.iter().any(|intent| matches!( + intent, + crate::strategy::OrderIntent::Value { symbol, .. } if symbol == "000001.SZ" + ))); } #[test] diff --git a/crates/fidc-core/src/risk_control.rs b/crates/fidc-core/src/risk_control.rs new file mode 100644 index 0000000..6f0ce7f --- /dev/null +++ b/crates/fidc-core/src/risk_control.rs @@ -0,0 +1,125 @@ +use chrono::NaiveDate; + +use crate::data::{CandidateEligibility, DailyMarketSnapshot, PriceField}; +use crate::instrument::Instrument; +use crate::portfolio::Position; + +#[derive(Debug, Clone, Copy, Default)] +pub struct ChinaAShareRiskControl; + +impl ChinaAShareRiskControl { + pub fn instrument_rejection_reason( + instrument: Option<&Instrument>, + date: NaiveDate, + ) -> Option<&'static str> { + let instrument = instrument?; + if instrument + .listed_at + .is_some_and(|listed_at| listed_at > date) + { + return Some("not_listed"); + } + if instrument + .delisted_at + .is_some_and(|delisted_at| delisted_at <= date) + { + return Some("inactive_or_delisted"); + } + let status = instrument.status.trim().to_ascii_lowercase(); + if matches!( + status.as_str(), + "inactive" | "delisted" | "terminated" | "expired" + ) || status.contains("delist") + { + return Some("inactive_or_delisted"); + } + None + } + + pub fn selection_rejection_reason( + date: NaiveDate, + candidate: &CandidateEligibility, + market: &DailyMarketSnapshot, + instrument: Option<&Instrument>, + ) -> Option<&'static str> { + if let Some(reason) = Self::instrument_rejection_reason(instrument, date) { + return Some(reason); + } + if market.paused || candidate.is_paused { + return Some("paused"); + } + if candidate.is_st { + return Some("st"); + } + if candidate.is_new_listing { + return Some("new_listing"); + } + if candidate.is_kcb { + return Some("kcb"); + } + if candidate.is_one_yuan || market.day_open <= 1.0 { + return Some("one_yuan"); + } + if !candidate.allow_buy || !candidate.allow_sell { + return Some("trade_disabled"); + } + None + } + + pub fn buy_rejection_reason( + date: NaiveDate, + candidate: &CandidateEligibility, + market: &DailyMarketSnapshot, + instrument: Option<&Instrument>, + check_price: f64, + ) -> Option<&'static str> { + if let Some(reason) = Self::selection_rejection_reason(date, candidate, market, instrument) + { + return Some(reason); + } + if !candidate.allow_buy { + return Some("buy_disabled"); + } + if market.is_at_upper_limit_price(check_price) { + return Some("open at or above upper limit"); + } + None + } + + pub fn sell_rejection_reason( + date: NaiveDate, + candidate: &CandidateEligibility, + market: &DailyMarketSnapshot, + instrument: Option<&Instrument>, + position: Option<&Position>, + check_price: f64, + ) -> Option<&'static str> { + if let Some(reason) = Self::instrument_rejection_reason(instrument, date) { + return Some(reason); + } + if market.paused || candidate.is_paused { + return Some("paused"); + } + if !candidate.allow_sell { + return Some("sell_disabled"); + } + if market.is_at_lower_limit_price(check_price) { + return Some("open at or below lower limit"); + } + if position.is_some_and(|position| position.sellable_qty(date) == 0) { + return Some("t+1 sellable quantity is zero"); + } + None + } + + pub fn buy_check_price(market: &DailyMarketSnapshot, price_field: PriceField) -> f64 { + market.buy_price(price_field) + } + + pub fn sell_check_price(market: &DailyMarketSnapshot, price_field: PriceField) -> f64 { + match price_field { + PriceField::Last => market.price(PriceField::Last), + _ => market.sell_price(price_field), + } + } +} diff --git a/crates/fidc-core/src/rules.rs b/crates/fidc-core/src/rules.rs index 2e9730a..7f56743 100644 --- a/crates/fidc-core/src/rules.rs +++ b/crates/fidc-core/src/rules.rs @@ -2,6 +2,7 @@ use chrono::NaiveDate; use crate::data::{CandidateEligibility, DailyMarketSnapshot, PriceField}; use crate::portfolio::Position; +use crate::risk_control::ChinaAShareRiskControl; #[derive(Debug, Clone)] pub struct RuleCheck { @@ -47,20 +48,6 @@ pub trait EquityRuleHooks { #[derive(Debug, Clone, Default)] pub struct ChinaEquityRuleHooks; -impl ChinaEquityRuleHooks { - fn at_upper_limit(snapshot: &DailyMarketSnapshot, price_field: PriceField) -> bool { - snapshot.is_at_upper_limit_price(snapshot.buy_price(price_field)) - } - - fn at_lower_limit(snapshot: &DailyMarketSnapshot, price_field: PriceField) -> bool { - let check_price = match price_field { - PriceField::Last => snapshot.price(PriceField::Last), - _ => snapshot.sell_price(price_field), - }; - snapshot.is_at_lower_limit_price(check_price) - } -} - impl EquityRuleHooks for ChinaEquityRuleHooks { fn can_buy( &self, @@ -69,14 +56,14 @@ impl EquityRuleHooks for ChinaEquityRuleHooks { candidate: &CandidateEligibility, price_field: PriceField, ) -> RuleCheck { - if snapshot.paused || candidate.is_paused { - return RuleCheck::reject("paused"); - } - if !candidate.allow_buy { - return RuleCheck::reject("buy disabled by eligibility flags"); - } - if Self::at_upper_limit(snapshot, price_field) { - return RuleCheck::reject("open at or above upper limit"); + if let Some(reason) = ChinaAShareRiskControl::buy_rejection_reason( + _execution_date, + candidate, + snapshot, + None, + ChinaAShareRiskControl::buy_check_price(snapshot, price_field), + ) { + return RuleCheck::reject(reason); } RuleCheck::allow() @@ -90,17 +77,15 @@ impl EquityRuleHooks for ChinaEquityRuleHooks { position: &Position, price_field: PriceField, ) -> RuleCheck { - if snapshot.paused || candidate.is_paused { - return RuleCheck::reject("paused"); - } - if !candidate.allow_sell { - return RuleCheck::reject("sell disabled by eligibility flags"); - } - if Self::at_lower_limit(snapshot, price_field) { - return RuleCheck::reject("open at or below lower limit"); - } - if position.sellable_qty(execution_date) == 0 { - return RuleCheck::reject("t+1 sellable quantity is zero"); + if let Some(reason) = ChinaAShareRiskControl::sell_rejection_reason( + execution_date, + candidate, + snapshot, + None, + Some(position), + ChinaAShareRiskControl::sell_check_price(snapshot, price_field), + ) { + return RuleCheck::reject(reason); } RuleCheck::allow() diff --git a/crates/fidc-core/src/strategy.rs b/crates/fidc-core/src/strategy.rs index a0fbc94..861e61c 100644 --- a/crates/fidc-core/src/strategy.rs +++ b/crates/fidc-core/src/strategy.rs @@ -17,6 +17,7 @@ use crate::events::{FillEvent, OrderEvent, OrderSide, OrderStatus, ProcessEvent} use crate::futures::{FuturesAccountState, FuturesOrderIntent}; use crate::instrument::Instrument; use crate::portfolio::PortfolioState; +use crate::risk_control::ChinaAShareRiskControl; use crate::scheduler::ScheduleRule; use crate::universe::{DynamicMarketCapBandSelector, SelectionContext, UniverseSelector}; @@ -2330,18 +2331,6 @@ impl OmniMicroCapStrategy { true } - fn special_name(&self, ctx: &StrategyContext<'_>, symbol: &str) -> bool { - let instrument_name = ctx - .data - .instruments() - .get(symbol) - .map(|instrument| instrument.name.as_str()) - .unwrap_or(""); - instrument_name.contains("ST") - || instrument_name.contains('*') - || instrument_name.contains('退') - } - fn can_sell_position(&self, ctx: &StrategyContext<'_>, date: NaiveDate, symbol: &str) -> bool { let Some(position) = ctx.portfolio.position(symbol) else { return false; @@ -2355,11 +2344,15 @@ impl OmniMicroCapStrategy { let Ok(candidate) = ctx.data.require_candidate(date, symbol) else { return false; }; - let lower_limit_check_price = market.price(PriceField::Last); - !(market.paused - || candidate.is_paused - || !candidate.allow_sell - || market.is_at_lower_limit_price(lower_limit_check_price)) + ChinaAShareRiskControl::sell_rejection_reason( + date, + candidate, + market, + ctx.data.instrument(symbol), + Some(position), + ChinaAShareRiskControl::sell_check_price(market, PriceField::Last), + ) + .is_none() } fn buy_rejection_reason( @@ -2371,25 +2364,14 @@ impl OmniMicroCapStrategy { let market = ctx.data.require_market(date, symbol)?; let candidate = ctx.data.require_candidate(date, symbol)?; - if market.paused || candidate.is_paused { - return Ok(Some("paused".to_string())); - } - if candidate.is_st || self.special_name(ctx, symbol) { - return Ok(Some("st_or_special_name".to_string())); - } - if candidate.is_kcb { - return Ok(Some("kcb".to_string())); - } - if !candidate.allow_buy { - return Ok(Some("buy_disabled".to_string())); - } - if market.is_at_upper_limit_price(market.day_open) - || market.is_at_upper_limit_price(market.buy_price(PriceField::Last)) - { - return Ok(Some("upper_limit".to_string())); - } - if market.day_open <= 1.0 { - return Ok(Some("one_yuan".to_string())); + if let Some(reason) = ChinaAShareRiskControl::buy_rejection_reason( + date, + candidate, + market, + ctx.data.instrument(symbol), + ChinaAShareRiskControl::buy_check_price(market, PriceField::Last), + ) { + return Ok(Some(reason.to_string())); } if !self.truth_selection_contains(date, symbol) && !self.stock_passes_ma_filter(ctx, date, symbol) diff --git a/crates/fidc-core/tests/corporate_actions.rs b/crates/fidc-core/tests/corporate_actions.rs index 66ef293..cdbf584 100644 --- a/crates/fidc-core/tests/corporate_actions.rs +++ b/crates/fidc-core/tests/corporate_actions.rs @@ -300,7 +300,7 @@ fn engine_reinvests_dividend_receivable_in_round_lots() { PriceField::Open, ), BacktestConfig { - initial_cash: 11_005.0, + initial_cash: 11_008.0, benchmark_code: "000300.SH".to_string(), start_date: Some(buy_date), end_date: Some(payable_date),