From 7db0e8da1ddafc00e68f7e9faf59e8f846be0e1d Mon Sep 17 00:00:00 2001 From: boris Date: Thu, 2 Jul 2026 07:16:47 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=9E=E7=8E=B0FIDC=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E5=8C=96=E9=A3=8E=E6=8E=A7=E4=B8=8E=E4=BA=A4=E6=98=93=E6=88=90?= =?UTF-8?q?=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/fidc-core/src/broker.rs | 74 +- crates/fidc-core/src/cost.rs | 66 +- crates/fidc-core/src/data.rs | 226 ++++-- crates/fidc-core/src/engine.rs | 4 + crates/fidc-core/src/lib.rs | 5 +- .../fidc-core/src/platform_expr_strategy.rs | 707 +++++++++++++++--- .../fidc-core/src/platform_strategy_spec.rs | 427 ++++++++++- crates/fidc-core/src/risk_control.rs | 448 ++++++++++- crates/fidc-core/src/rules.rs | 8 + crates/fidc-core/src/strategy.rs | 369 ++++++++- crates/fidc-core/src/strategy_ai.rs | 26 +- crates/fidc-core/src/universe.rs | 209 +++++- crates/fidc-core/tests/core_rules.rs | 4 +- crates/fidc-core/tests/corporate_actions.rs | 1 + crates/fidc-core/tests/delisting.rs | 1 + crates/fidc-core/tests/engine_hooks.rs | 10 + crates/fidc-core/tests/explicit_order_flow.rs | 353 ++++++++- 17 files changed, 2689 insertions(+), 249 deletions(-) diff --git a/crates/fidc-core/src/broker.rs b/crates/fidc-core/src/broker.rs index 50d5e5f..41370bf 100644 --- a/crates/fidc-core/src/broker.rs +++ b/crates/fidc-core/src/broker.rs @@ -12,7 +12,7 @@ use crate::events::{ }; use crate::instrument::Instrument; use crate::portfolio::PortfolioState; -use crate::risk_control::ChinaAShareRiskControl; +use crate::risk_control::{ChinaAShareRiskControl, FidcRiskControlConfig}; use crate::rules::{EquityRuleHooks, RuleCheck}; use crate::strategy::{ AlgoOrderStyle, OpenOrderView, OrderIntent, StrategyDecision, TargetPortfolioOrderPricing, @@ -171,6 +171,8 @@ pub struct BrokerSimulator { strict_value_budget: bool, aiquant_execution_rules: bool, same_day_buy_close_mark_at_fill: bool, + risk_config: FidcRiskControlConfig, + same_day_sold_symbols: RefCell>>, intraday_execution_start_time: Option, runtime_intraday_start_time: Cell>, runtime_intraday_end_time: Cell>, @@ -194,6 +196,8 @@ impl BrokerSimulator { strict_value_budget: false, aiquant_execution_rules: false, same_day_buy_close_mark_at_fill: false, + risk_config: FidcRiskControlConfig::default(), + same_day_sold_symbols: RefCell::new(BTreeMap::new()), intraday_execution_start_time: None, runtime_intraday_start_time: Cell::new(None), runtime_intraday_end_time: Cell::new(None), @@ -221,6 +225,8 @@ impl BrokerSimulator { strict_value_budget: false, aiquant_execution_rules: false, same_day_buy_close_mark_at_fill: false, + risk_config: FidcRiskControlConfig::default(), + same_day_sold_symbols: RefCell::new(BTreeMap::new()), intraday_execution_start_time: None, runtime_intraday_start_time: Cell::new(None), runtime_intraday_end_time: Cell::new(None), @@ -259,6 +265,14 @@ impl BrokerSimulator { self } + pub fn with_risk_config(mut self, config: FidcRiskControlConfig) -> Self { + self.volume_limit = config.trading_constraints.volume_limit_enabled; + self.volume_percent = config.trading_constraints.volume_percent; + self.liquidity_limit = config.trading_constraints.liquidity_limit_enabled; + self.risk_config = config; + self + } + pub fn same_day_buy_close_mark_at_fill(&self) -> bool { self.same_day_buy_close_mark_at_fill } @@ -1381,6 +1395,34 @@ where .retain(|existing| existing.order_id != order_id); } + fn mark_same_day_sold(&self, date: NaiveDate, symbol: &str) { + self.same_day_sold_symbols + .borrow_mut() + .entry(date) + .or_default() + .insert(symbol.to_string()); + } + + fn same_day_rebuy_rejection_reason( + &self, + date: NaiveDate, + symbol: &str, + ) -> Option<&'static str> { + if self + .risk_config + .static_rules + .forbid_same_day_rebuy_after_sell + && self + .same_day_sold_symbols + .borrow() + .get(&date) + .is_some_and(|symbols| symbols.contains(symbol)) + { + return Some("same_day_rebuy_forbidden"); + } + None + } + fn extend_report(into: &mut BrokerExecutionReport, mut other: BrokerExecutionReport) { into.order_events.append(&mut other.order_events); into.fill_events.append(&mut other.fill_events); @@ -2181,16 +2223,17 @@ where } else { ChinaAShareRiskControl::buy_check_price(snapshot, self.execution_price_field) }; - if let Some(reason) = ChinaAShareRiskControl::buy_rejection_reason( + if let Some(reason) = ChinaAShareRiskControl::buy_rejection_reason_with_config( date, candidate, snapshot, instrument, check_price, + &self.risk_config, ) { return RuleCheck::reject(reason); } - if !self.aiquant_execution_rules { + if !self.aiquant_execution_rules && !self.rules.duplicates_standard_china_risk() { return self .rules .can_buy(date, snapshot, candidate, self.execution_price_field); @@ -2220,16 +2263,20 @@ where } else { ChinaAShareRiskControl::buy_check_price(snapshot, self.execution_price_field) }; - if let Some(reason) = ChinaAShareRiskControl::buy_rejection_reason( + if let Some(reason) = self.same_day_rebuy_rejection_reason(date, symbol) { + return RuleCheck::reject(reason); + } + if let Some(reason) = ChinaAShareRiskControl::buy_rejection_reason_with_config( date, candidate, snapshot, instrument, check_price, + &self.risk_config, ) { return RuleCheck::reject(reason); } - if !self.aiquant_execution_rules { + if !self.aiquant_execution_rules && !self.rules.duplicates_standard_china_risk() { return self .rules .can_buy(date, snapshot, candidate, self.execution_price_field); @@ -2263,17 +2310,18 @@ where } else { ChinaAShareRiskControl::sell_check_price(snapshot, self.execution_price_field) }; - if let Some(reason) = ChinaAShareRiskControl::sell_rejection_reason( + if let Some(reason) = ChinaAShareRiskControl::sell_rejection_reason_with_config( date, candidate, snapshot, instrument, Some(position), check_price, + &self.risk_config, ) { return RuleCheck::reject(reason); } - if !self.aiquant_execution_rules { + if !self.aiquant_execution_rules && !self.rules.duplicates_standard_china_risk() { return self.rules.can_sell( date, snapshot, @@ -2929,6 +2977,7 @@ where net_cash_flow: net_cash, reason: reason.to_string(), }); + self.mark_same_day_sold(date, symbol); Self::emit_order_process_event( report, date, @@ -5065,10 +5114,16 @@ where return None; } match side { - OrderSide::Buy if snapshot.is_at_upper_limit_price(execution_price) => { + OrderSide::Buy + if self.risk_config.static_rules.reject_upper_limit_buy + && snapshot.is_at_upper_limit_price(execution_price) => + { Some("open at or above upper limit") } - OrderSide::Sell if snapshot.is_at_lower_limit_price(execution_price) => { + OrderSide::Sell + if self.risk_config.static_rules.reject_lower_limit_sell + && snapshot.is_at_lower_limit_price(execution_price) => + { Some("open at or below lower limit") } _ => None, @@ -5991,6 +6046,7 @@ mod tests { stamp_tax_rate_before_change: 0.0005, stamp_tax_rate_after_change: 0.0005, minimum_commission: 5.0, + ..ChinaAShareCostModel::default() }, ChinaEquityRuleHooks, PriceField::Last, diff --git a/crates/fidc-core/src/cost.rs b/crates/fidc-core/src/cost.rs index fa9ac2b..8afa29a 100644 --- a/crates/fidc-core/src/cost.rs +++ b/crates/fidc-core/src/cost.rs @@ -3,6 +3,7 @@ use std::collections::BTreeMap; use chrono::NaiveDate; use crate::events::OrderSide; +use crate::risk_control::TradingConstraintConfig; pub const STOCK_PIT_TAX_CHANGE_DATE: (i32, u32, u32) = (2023, 8, 28); @@ -38,6 +39,7 @@ pub struct ChinaAShareCostModel { pub commission_rate: f64, pub stamp_tax_rate_before_change: f64, pub stamp_tax_rate_after_change: f64, + pub stamp_tax_change_date: NaiveDate, pub minimum_commission: f64, } @@ -47,6 +49,7 @@ impl Default for ChinaAShareCostModel { commission_rate: 0.0008, stamp_tax_rate_before_change: 0.001, stamp_tax_rate_after_change: 0.0005, + stamp_tax_change_date: default_stamp_tax_change_date(), minimum_commission: 5.0, } } @@ -55,13 +58,23 @@ impl Default for ChinaAShareCostModel { impl ChinaAShareCostModel { pub fn aiquant_default() -> Self { Self { - commission_rate: 0.0008, + commission_rate: 0.0003, stamp_tax_rate_before_change: 0.0005, stamp_tax_rate_after_change: 0.0005, ..Self::default() } } + pub fn from_trading_constraints(config: TradingConstraintConfig) -> Self { + Self { + commission_rate: config.commission_rate, + stamp_tax_rate_before_change: config.stamp_tax_rate_before_change, + stamp_tax_rate_after_change: config.stamp_tax_rate_after_change, + stamp_tax_change_date: config.stamp_tax_change_date, + minimum_commission: config.minimum_commission, + } + } + pub fn commission_for(&self, gross_amount: f64) -> f64 { if gross_amount <= 0.0 { return 0.0; @@ -70,13 +83,7 @@ impl ChinaAShareCostModel { } pub fn stamp_tax_rate_for(&self, date: NaiveDate) -> f64 { - let change_date = NaiveDate::from_ymd_opt( - STOCK_PIT_TAX_CHANGE_DATE.0, - STOCK_PIT_TAX_CHANGE_DATE.1, - STOCK_PIT_TAX_CHANGE_DATE.2, - ) - .expect("valid pit tax change date"); - if date < change_date { + if date < self.stamp_tax_change_date { self.stamp_tax_rate_before_change } else { self.stamp_tax_rate_after_change @@ -128,6 +135,15 @@ impl ChinaAShareCostModel { } } +fn default_stamp_tax_change_date() -> NaiveDate { + NaiveDate::from_ymd_opt( + STOCK_PIT_TAX_CHANGE_DATE.0, + STOCK_PIT_TAX_CHANGE_DATE.1, + STOCK_PIT_TAX_CHANGE_DATE.2, + ) + .expect("valid pit tax change date") +} + impl CostModel for ChinaAShareCostModel { fn calculate(&self, date: NaiveDate, side: OrderSide, gross_amount: f64) -> TradingCost { if gross_amount <= 0.0 { @@ -180,9 +196,41 @@ mod tests { let model = ChinaAShareCostModel::aiquant_default(); let date = NaiveDate::from_ymd_opt(2025, 11, 11).expect("valid date"); - assert!((model.commission_for(248_059.812) - 198.4478496).abs() < 1e-9); + assert!((model.commission_for(248_059.812) - 74.4179436).abs() < 1e-9); assert!( (model.stamp_tax_for(date, OrderSide::Sell, 245_747.007) - 122.8735035).abs() < 1e-9 ); } + + #[test] + fn cost_model_can_use_configurable_stamp_tax_change_date() { + let config = TradingConstraintConfig { + commission_rate: 0.0003, + minimum_commission: 5.0, + stamp_tax_rate_before_change: 0.002, + stamp_tax_rate_after_change: 0.001, + stamp_tax_change_date: NaiveDate::from_ymd_opt(2025, 1, 10).expect("valid date"), + ..TradingConstraintConfig::default() + }; + let model = ChinaAShareCostModel::from_trading_constraints(config); + + assert!( + (model.stamp_tax_for( + NaiveDate::from_ymd_opt(2025, 1, 9).expect("valid date"), + OrderSide::Sell, + 10_000.0 + ) - 20.0) + .abs() + < 1e-9 + ); + assert!( + (model.stamp_tax_for( + NaiveDate::from_ymd_opt(2025, 1, 10).expect("valid date"), + OrderSide::Sell, + 10_000.0 + ) - 10.0) + .abs() + < 1e-9 + ); + } } diff --git a/crates/fidc-core/src/data.rs b/crates/fidc-core/src/data.rs index d01d46a..a5006a4 100644 --- a/crates/fidc-core/src/data.rs +++ b/crates/fidc-core/src/data.rs @@ -10,7 +10,7 @@ use thiserror::Error; use crate::calendar::TradingCalendar; use crate::futures::{FuturesCommissionType, FuturesTradingParameter}; use crate::instrument::Instrument; -use crate::risk_control::ChinaAShareRiskControl; +use crate::risk_control::{ChinaAShareRiskControl, FidcRiskControlConfig}; mod date_format { use chrono::NaiveDate; @@ -2607,6 +2607,21 @@ impl DataSet { .unwrap_or(&[]) } + pub fn eligible_universe_on_with_risk_config( + &self, + date: NaiveDate, + risk_config: &FidcRiskControlConfig, + ) -> Vec { + build_eligible_universe_for_date( + date, + &self.factor_by_date, + &self.candidate_by_date, + &self.market_by_date, + &self.instruments, + risk_config, + ) + } + pub fn require_market( &self, date: NaiveDate, @@ -3647,56 +3662,102 @@ fn build_eligible_universe( instruments: &HashMap, ) -> BTreeMap> { let mut per_date = BTreeMap::>::new(); + let risk_config = FidcRiskControlConfig::default(); for (date, factors) in factor_by_date { - let mut rows = Vec::new(); - for factor in factors { - if factor.market_cap_bn <= 0.0 || !factor.market_cap_bn.is_finite() { - continue; - } - let Some(candidate) = candidate_by_date.get(date).and_then(|rows| { - find_arc_by_symbol(rows, &factor.symbol, |row| row.symbol.as_str()) - }) else { - continue; - }; - let Some(market) = market_by_date.get(date).and_then(|rows| { - find_arc_by_symbol(rows, &factor.symbol, |row| row.symbol.as_str()) - }) else { - continue; - }; - if ChinaAShareRiskControl::selection_rejection_reason( - *date, - candidate, - market, - instruments.get(&factor.symbol), - ) - .is_some() - { - continue; - } - let market_cap_bn = decision_market_cap_bn(factor, market); - if market_cap_bn <= 0.0 || !market_cap_bn.is_finite() { - continue; - } - let free_float_cap_bn = decision_free_float_cap_bn(factor, market); - rows.push(EligibleUniverseSnapshot { - symbol: factor.symbol.clone(), - market_cap_bn, - free_float_cap_bn, - }); - } - rows.sort_by(|left, right| { - left.market_cap_bn - .partial_cmp(&right.market_cap_bn) - .unwrap_or(std::cmp::Ordering::Equal) - .then_with(|| left.symbol.cmp(&right.symbol)) - }); + let rows = build_eligible_universe_for_date_from_factors( + *date, + factors, + candidate_by_date, + market_by_date, + instruments, + &risk_config, + ); per_date.insert(*date, rows); } per_date } +fn build_eligible_universe_for_date( + date: NaiveDate, + factor_by_date: &BTreeMap>>, + candidate_by_date: &BTreeMap>>, + market_by_date: &BTreeMap>>, + instruments: &HashMap, + risk_config: &FidcRiskControlConfig, +) -> Vec { + factor_by_date + .get(&date) + .map(|factors| { + build_eligible_universe_for_date_from_factors( + date, + factors, + candidate_by_date, + market_by_date, + instruments, + risk_config, + ) + }) + .unwrap_or_default() +} + +fn build_eligible_universe_for_date_from_factors( + date: NaiveDate, + factors: &[Arc], + candidate_by_date: &BTreeMap>>, + market_by_date: &BTreeMap>>, + instruments: &HashMap, + risk_config: &FidcRiskControlConfig, +) -> Vec { + let mut rows = Vec::new(); + for factor in factors { + if factor.market_cap_bn <= 0.0 || !factor.market_cap_bn.is_finite() { + continue; + } + let Some(candidate) = candidate_by_date + .get(&date) + .and_then(|rows| find_arc_by_symbol(rows, &factor.symbol, |row| row.symbol.as_str())) + else { + continue; + }; + let Some(market) = market_by_date + .get(&date) + .and_then(|rows| find_arc_by_symbol(rows, &factor.symbol, |row| row.symbol.as_str())) + else { + continue; + }; + if ChinaAShareRiskControl::selection_rejection_reason_with_config( + date, + candidate, + market, + instruments.get(&factor.symbol), + risk_config, + ) + .is_some() + { + continue; + } + let market_cap_bn = decision_market_cap_bn(factor, market); + if market_cap_bn <= 0.0 || !market_cap_bn.is_finite() { + continue; + } + let free_float_cap_bn = decision_free_float_cap_bn(factor, market); + rows.push(EligibleUniverseSnapshot { + symbol: factor.symbol.clone(), + market_cap_bn, + free_float_cap_bn, + }); + } + rows.sort_by(|left, right| { + left.market_cap_bn + .partial_cmp(&right.market_cap_bn) + .unwrap_or(std::cmp::Ordering::Equal) + .then_with(|| left.symbol.cmp(&right.symbol)) + }); + rows +} + #[cfg(test)] fn instrument_passes_baseline_selection(instrument: Option<&Instrument>, date: NaiveDate) -> bool { ChinaAShareRiskControl::instrument_rejection_reason(instrument, date).is_none() @@ -3981,6 +4042,85 @@ mod tests { assert!((rows[1].market_cap_bn - 10.0).abs() < 1e-9); } + #[test] + fn eligible_universe_can_use_configured_risk_policy() { + let date = NaiveDate::parse_from_str("2025-01-06", "%Y-%m-%d").unwrap(); + let symbol = "688001.SH"; + let data = DataSet::from_components( + vec![Instrument { + symbol: symbol.to_string(), + name: symbol.to_string(), + board: "SH".to_string(), + round_lot: 100, + listed_at: Some(NaiveDate::parse_from_str("2020-01-01", "%Y-%m-%d").unwrap()), + delisted_at: None, + status: "active".to_string(), + }], + vec![DailyMarketSnapshot { + date, + symbol: symbol.to_string(), + timestamp: Some("2025-01-06 10:18:00".to_string()), + day_open: 10.0, + open: 10.0, + high: 10.2, + low: 9.8, + close: 10.1, + last_price: 10.1, + bid1: 10.0, + ask1: 10.1, + prev_close: 10.0, + volume: 100_000, + minute_volume: 1_000, + bid1_volume: 1_000, + ask1_volume: 1_000, + trading_phase: Some("continuous".to_string()), + paused: false, + upper_limit: 11.0, + lower_limit: 9.0, + price_tick: 0.01, + }], + vec![DailyFactorSnapshot { + date, + symbol: symbol.to_string(), + market_cap_bn: 10.0, + free_float_cap_bn: 9.0, + pe_ttm: 10.0, + turnover_ratio: Some(1.0), + effective_turnover_ratio: Some(1.0), + extra_factors: BTreeMap::new(), + }], + vec![CandidateEligibility { + date, + symbol: symbol.to_string(), + is_st: false, + is_new_listing: false, + is_paused: false, + allow_buy: true, + allow_sell: true, + is_kcb: true, + is_one_yuan: false, + risk_level_code: None, + }], + vec![BenchmarkSnapshot { + date, + benchmark: "000852.SH".to_string(), + open: 100.0, + close: 101.0, + prev_close: 99.0, + volume: 1_000_000, + }], + ) + .expect("dataset"); + + assert!(data.eligible_universe_on(date).is_empty()); + let mut risk_config = FidcRiskControlConfig::default(); + risk_config.static_rules.reject_kcb_selection = false; + let rows = data.eligible_universe_on_with_risk_config(date, &risk_config); + + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].symbol, symbol); + } + #[test] fn decision_market_cap_keeps_pre_adjusted_factor() { let date = NaiveDate::parse_from_str("2025-01-06", "%Y-%m-%d").unwrap(); diff --git a/crates/fidc-core/src/engine.rs b/crates/fidc-core/src/engine.rs index 62e2c5f..700b8de 100644 --- a/crates/fidc-core/src/engine.rs +++ b/crates/fidc-core/src/engine.rs @@ -18,6 +18,7 @@ use crate::futures::{ }; use crate::metrics::{BacktestMetrics, compute_backtest_metrics}; use crate::portfolio::{CashReceivable, HoldingSummary, PortfolioState}; +use crate::risk_control::FidcRiskDecisionAudit; use crate::rules::EquityRuleHooks; use crate::scheduler::{ScheduleRule, ScheduleStage, Scheduler, default_stage_time}; use crate::strategy::{ @@ -88,6 +89,7 @@ pub struct BacktestResult { pub strategy_name: String, pub equity_curve: Vec, pub benchmark_series: Vec, + pub risk_decisions: Vec, pub order_events: Vec, pub fills: Vec, pub position_events: Vec, @@ -1679,6 +1681,7 @@ where .unwrap_or(true) }) .collect(), + risk_decisions: Vec::new(), order_events: Vec::new(), fills: Vec::new(), position_events: Vec::new(), @@ -2745,6 +2748,7 @@ where let day_fills = report.fill_events.clone(); let broker_diagnostics = report.diagnostics.clone(); self.extend_result(&mut result, report); + result.risk_decisions.extend(decision.risk_decisions); let benchmark = self.data diff --git a/crates/fidc-core/src/lib.rs b/crates/fidc-core/src/lib.rs index b86740f..a0d87b9 100644 --- a/crates/fidc-core/src/lib.rs +++ b/crates/fidc-core/src/lib.rs @@ -69,7 +69,10 @@ 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 risk_control::{ + ChinaAShareRiskControl, FidcRiskControlConfig, FidcRiskDecisionAudit, RiskCheckScope, + StaticRiskRuleConfig, TradingConstraintConfig, +}; 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 1c15e27..4b0d9c7 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -13,7 +13,9 @@ use crate::data::{ use crate::engine::BacktestError; use crate::events::OrderSide; use crate::portfolio::PortfolioState; -use crate::risk_control::ChinaAShareRiskControl; +use crate::risk_control::{ + ChinaAShareRiskControl, FidcRiskControlConfig, FidcRiskDecisionAudit, RiskCheckScope, +}; use crate::scheduler::{ ScheduleRule, ScheduleStage, ScheduleTimeRule, Scheduler, default_stage_time, }; @@ -215,7 +217,9 @@ pub struct PlatformExprStrategyConfig { pub minimum_commission: Option, pub stamp_tax_rate_before_change: Option, pub stamp_tax_rate_after_change: Option, + pub stamp_tax_change_date: Option, pub strict_value_budget: bool, + pub risk_config: FidcRiskControlConfig, pub slippage_model: SlippageModel, pub matching_type: MatchingType, pub quote_quantity_limit: bool, @@ -247,14 +251,7 @@ fn band_low(index_close) { round((index_close - 2000) * 4 / 500 + 7) }"# .to_string(), - universe_exclude: vec![ - "paused".to_string(), - "st".to_string(), - "kcb".to_string(), - "bjse".to_string(), - "one_yuan".to_string(), - "new_listing".to_string(), - ], + universe_exclude: vec!["bjse".to_string()], market_cap_field: "market_cap".to_string(), market_cap_lower_expr: "band_low(signal_close)".to_string(), market_cap_upper_expr: "band_low(signal_close) + 10".to_string(), @@ -287,7 +284,9 @@ fn band_low(index_close) { minimum_commission: None, stamp_tax_rate_before_change: None, stamp_tax_rate_after_change: None, + stamp_tax_change_date: None, strict_value_budget: false, + risk_config: FidcRiskControlConfig::default(), slippage_model: SlippageModel::None, matching_type: MatchingType::MinuteLast, quote_quantity_limit: true, @@ -1003,7 +1002,7 @@ impl PlatformExprStrategy { .unwrap_or_else(|| NaiveTime::from_hms_opt(10, 18, 0).expect("valid 10:18")) } - fn firisk_forced_exit_time(&self) -> NaiveTime { + fn risk_level_forced_exit_time(&self) -> NaiveTime { if self.config.delayed_limit_open_exit_enabled && let Some(time) = self.config.delayed_limit_open_exit_time { @@ -1042,6 +1041,9 @@ impl PlatformExprStrategy { if let Some(value) = self.config.stamp_tax_rate_after_change { model.stamp_tax_rate_after_change = value; } + if let Some(value) = self.config.stamp_tax_change_date { + model.stamp_tax_change_date = value; + } model } @@ -1378,6 +1380,7 @@ impl PlatformExprStrategy { } fn projected_execution_limit_rejection_reason( + &self, market: &DailyMarketSnapshot, side: OrderSide, execution_price: f64, @@ -1385,11 +1388,18 @@ impl PlatformExprStrategy { if !execution_price.is_finite() || execution_price <= 0.0 { return None; } + let static_rules = &self.config.risk_config.static_rules; match side { - OrderSide::Buy if market.is_at_upper_limit_price(execution_price) => { + OrderSide::Buy + if static_rules.reject_upper_limit_buy + && market.is_at_upper_limit_price(execution_price) => + { Some("open at or above upper limit") } - OrderSide::Sell if market.is_at_lower_limit_price(execution_price) => { + OrderSide::Sell + if static_rules.reject_lower_limit_sell + && market.is_at_lower_limit_price(execution_price) => + { Some("open at or below lower limit") } _ => None, @@ -1501,7 +1511,9 @@ impl PlatformExprStrategy { let mut quote_price = self.projected_apply_slippage(market, side, raw_quote_price, Some(take_qty)); - if Self::projected_execution_limit_rejection_reason(market, side, quote_price).is_some() + if self + .projected_execution_limit_rejection_reason(market, side, quote_price) + .is_some() { continue; } @@ -1514,7 +1526,8 @@ impl PlatformExprStrategy { raw_quote_price, Some(take_qty), ); - if Self::projected_execution_limit_rejection_reason(market, side, quote_price) + if self + .projected_execution_limit_rejection_reason(market, side, quote_price) .is_some() { take_qty = 0; @@ -1545,7 +1558,9 @@ impl PlatformExprStrategy { quote_price = self.projected_apply_slippage(market, side, raw_quote_price, Some(take_qty)); - if Self::projected_execution_limit_rejection_reason(market, side, quote_price).is_some() + if self + .projected_execution_limit_rejection_reason(market, side, quote_price) + .is_some() { continue; } @@ -5764,6 +5779,7 @@ impl PlatformExprStrategy { order_intents, notes: Vec::new(), diagnostics, + risk_decisions: Vec::new(), }) } @@ -5908,7 +5924,7 @@ impl PlatformExprStrategy { continue; }; if self - .baseline_risk_rejection_reason(ctx, date, &factor.symbol, candidate, market) + .selection_risk_rejection_reason(ctx, date, &factor.symbol, candidate, market) .is_some() { continue; @@ -5936,7 +5952,70 @@ impl PlatformExprStrategy { rows } - fn baseline_risk_rejection_reason( + fn selection_risk_decisions( + &self, + ctx: &StrategyContext<'_>, + date: NaiveDate, + factor_date: NaiveDate, + ) -> Vec { + let mut decisions = Vec::new(); + for factor in ctx.data.factor_snapshots_on(factor_date) { + if ctx.has_dynamic_universe() && !ctx.dynamic_universe_contains(&factor.symbol) { + continue; + } + let Some(candidate) = ctx.data.candidate(date, &factor.symbol) else { + continue; + }; + let Some(market) = ctx.data.market(date, &factor.symbol) else { + continue; + }; + if let Some(decision) = ChinaAShareRiskControl::selection_rejection_decision_with_config( + date, + candidate, + market, + ctx.data.instrument(&factor.symbol), + &self.config.risk_config, + ) { + decisions.push(decision); + } + } + decisions + } + + fn selection_risk_decision_diagnostics( + decisions: &[FidcRiskDecisionAudit], + date: NaiveDate, + factor_date: NaiveDate, + sample_limit: usize, + ) -> Vec { + if decisions.is_empty() { + return Vec::new(); + } + let mut counts = BTreeMap::::new(); + for decision in decisions { + *counts.entry(decision.rule_code.clone()).or_insert(0) += 1; + } + let mut diagnostics = vec![format!( + "risk_decisions selection_total={} by_rule={} selection_market_date={} selection_factor_date={}", + decisions.len(), + counts + .iter() + .map(|(rule, count)| format!("{rule}:{count}")) + .collect::>() + .join(","), + date, + factor_date + )]; + diagnostics.extend( + decisions + .iter() + .take(sample_limit) + .map(|decision| decision.diagnostic_line()), + ); + diagnostics + } + + fn selection_risk_rejection_reason( &self, ctx: &StrategyContext<'_>, date: NaiveDate, @@ -5944,44 +6023,60 @@ impl PlatformExprStrategy { candidate: &crate::data::CandidateEligibility, market: &DailyMarketSnapshot, ) -> Option<&'static str> { - ChinaAShareRiskControl::baseline_rejection_reason( + if let Some(reason) = ChinaAShareRiskControl::baseline_rejection_reason_with_config( date, candidate, market, ctx.data.instrument(symbol), - ) + &self.config.risk_config, + RiskCheckScope::Selection, + ) { + return Some(reason); + } + if self.config.risk_config.static_rules.respect_allow_buy_sell + && (!candidate.allow_buy || !candidate.allow_sell) + { + return Some("trade_disabled"); + } + None + } + + fn stock_selection_limit_rejection_reason( + &self, + stock: &StockExpressionState, + ) -> Option<&'static str> { + let static_rules = &self.config.risk_config.static_rules; + if static_rules.reject_upper_limit_selection + && Self::price_is_at_or_above_upper_limit( + stock.last, + stock.upper_limit, + stock.price_tick, + ) + { + return Some("upper_limit"); + } + if static_rules.reject_lower_limit_selection + && Self::price_is_at_or_below_lower_limit( + stock.last, + stock.lower_limit, + stock.price_tick, + ) + { + return Some("lower_limit"); + } + None } fn stock_passes_universe_exclude( &self, - candidate: &crate::data::CandidateEligibility, + _candidate: &crate::data::CandidateEligibility, market: &DailyMarketSnapshot, ) -> 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 { - return false; - } - if excludes.iter().any(|item| item == "kcb") && candidate.is_kcb { - return false; - } if excludes.iter().any(|item| item == "bjse") && market.symbol.ends_with(".BJ") { return false; } - if excludes.iter().any(|item| item == "new_listing") && candidate.is_new_listing { - return false; - } - if excludes.iter().any(|item| item == "one_yuan") - && (candidate.is_one_yuan || market.day_open <= 1.0) - { - return false; - } - if self.config.aiquant_transaction_cost { - return true; - } - candidate.allow_buy && candidate.allow_sell + true } fn stock_numeric_field_value( @@ -6116,7 +6211,7 @@ impl PlatformExprStrategy { self.can_sell_position_at_time(ctx, date, symbol, None) } - fn candidate_requires_firisk_forced_exit( + fn candidate_requires_risk_level_forced_exit( &self, ctx: &StrategyContext<'_>, date: NaiveDate, @@ -6181,13 +6276,14 @@ impl PlatformExprStrategy { } else { market.price(PriceField::Last) }; - ChinaAShareRiskControl::sell_rejection_reason( + ChinaAShareRiskControl::sell_rejection_reason_with_config( date, candidate, market, ctx.data.instrument(symbol), Some(position), check_price, + &self.config.risk_config, ) .is_none() } @@ -6197,50 +6293,39 @@ impl PlatformExprStrategy { ctx: &StrategyContext<'_>, date: NaiveDate, symbol: &str, - stock: &StockExpressionState, + _stock: &StockExpressionState, ) -> Result, BacktestError> { 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) + if self + .config + .universe_exclude + .iter() + .any(|item| item == "bjse") + && market.symbol.ends_with(".BJ") { - 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".to_string())); - } - if excludes.iter().any(|item| item == "kcb") && candidate.is_kcb { - return Ok(Some("kcb".to_string())); - } - if excludes.iter().any(|item| item == "bjse") && market.symbol.ends_with(".BJ") { return Ok(Some("bjse".to_string())); } - if excludes.iter().any(|item| item == "new_listing") && candidate.is_new_listing { - return Ok(Some("new_listing".to_string())); - } - if excludes.iter().any(|item| item == "one_yuan") && stock.is_one_yuan { - return Ok(Some("one_yuan".to_string())); - } - if !candidate.allow_buy { - return Ok(Some("buy_disabled".to_string())); - } let upper_limit_check_price = if self.config.aiquant_transaction_cost { self.aiquant_scheduled_buy_price(ctx, date, symbol) .unwrap_or_else(|| market.price(PriceField::Last)) } else { - market.buy_price(PriceField::Last) + market.buy_price(PriceField::DayOpen) }; - if (!self.config.aiquant_transaction_cost - && market.is_at_upper_limit_price(market.day_open)) - || market.is_at_upper_limit_price(upper_limit_check_price) - { - return Ok(Some("upper_limit".to_string())); + if let Some(reason) = ChinaAShareRiskControl::buy_rejection_reason_with_config( + date, + &candidate, + &market, + ctx.data.instrument(symbol), + upper_limit_check_price, + &self.config.risk_config, + ) { + let reason = match reason { + "open at or above upper limit" => "upper_limit", + other => other, + }; + return Ok(Some(reason.to_string())); } Ok(None) } @@ -6255,9 +6340,15 @@ impl PlatformExprStrategy { band_low: f64, band_high: f64, limit: usize, - ) -> Result<(Vec, Vec), BacktestError> { + ) -> Result<(Vec, Vec, Vec), BacktestError> { let universe = self.selectable_universe_on(ctx, date, universe_factor_date); - let mut diagnostics = Vec::new(); + let risk_decisions = self.selection_risk_decisions(ctx, date, universe_factor_date); + let mut diagnostics = Self::selection_risk_decision_diagnostics( + &risk_decisions, + date, + universe_factor_date, + 5, + ); let mut candidates = Vec::new(); for candidate in universe { let stock = self.selection_stock_state_with_factor_date( @@ -6312,6 +6403,12 @@ impl PlatformExprStrategy { let mut selected = Vec::new(); for (candidate, stock, _) in candidates { + if let Some(reason) = self.stock_selection_limit_rejection_reason(&stock) { + if diagnostics.len() < 12 { + diagnostics.push(format!("{} rejected by {}", candidate.symbol, reason)); + } + continue; + } if let Some(reason) = self.buy_rejection_reason(ctx, date, &candidate.symbol, &stock)? { if diagnostics.len() < 12 { diagnostics.push(format!("{} rejected by {}", candidate.symbol, reason)); @@ -6330,7 +6427,7 @@ impl PlatformExprStrategy { } } - Ok((selected, diagnostics)) + Ok((selected, diagnostics, risk_decisions)) } fn stock_filter_quote_usage(&self) -> StockFilterQuoteUsage { @@ -6988,6 +7085,12 @@ impl PlatformExprStrategy { if quote_candidate_symbols.len() < quote_candidate_limit { quote_candidate_symbols.push(symbol.clone()); } + if let Some(reason) = self.stock_selection_limit_rejection_reason(stock) { + if diagnostics.len() < 12 { + diagnostics.push(format!("{symbol} quote_plan rejected by {reason}")); + } + continue; + } 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}")); @@ -7317,8 +7420,9 @@ impl Strategy for PlatformExprStrategy { } else { 0 }; + let mut risk_decisions = Vec::new(); let stock_list = if self.config.rotation_enabled && !in_skip_window { - let (stock_list, notes) = self.select_symbols( + let (stock_list, notes, selection_risk_decisions) = self.select_symbols( ctx, selection_market_date, selection_universe_factor_date, @@ -7329,6 +7433,7 @@ impl Strategy for PlatformExprStrategy { selection_limit, )?; selection_notes = notes; + risk_decisions = selection_risk_decisions; stock_list } else { Vec::new() @@ -7372,7 +7477,7 @@ impl Strategy for PlatformExprStrategy { let mut intraday_attempted_buys = BTreeSet::::new(); let mut delayed_sold_symbols = BTreeSet::::new(); let mut unresolved_stop_loss_symbols = BTreeSet::::new(); - let mut pending_firisk_forced_exit_symbols = BTreeSet::::new(); + let mut pending_risk_level_forced_exit_symbols = BTreeSet::::new(); let delayed_limit_exit_time = self .config .delayed_limit_open_exit_time @@ -7528,6 +7633,7 @@ impl Strategy for PlatformExprStrategy { "platform expr skip window forced all sellable cash after delayed open exits" .to_string(), ], + risk_decisions: Vec::new(), }); } @@ -7544,7 +7650,7 @@ impl Strategy for PlatformExprStrategy { .cloned() .collect::>(); let mut pending_full_close_symbols = BTreeSet::::new(); - let firisk_forced_exit_time = self.firisk_forced_exit_time(); + let risk_level_forced_exit_time = self.risk_level_forced_exit_time(); if self.config.aiquant_transaction_cost { for position in ctx.portfolio.positions().values() { if position.quantity == 0 @@ -7561,14 +7667,14 @@ impl Strategy for PlatformExprStrategy { )? { continue; } - if self.candidate_requires_firisk_forced_exit( + if self.candidate_requires_risk_level_forced_exit( ctx, execution_date, &position.symbol, - firisk_forced_exit_time, + risk_level_forced_exit_time, )? { pending_full_close_symbols.insert(position.symbol.clone()); - pending_firisk_forced_exit_symbols.insert(position.symbol.clone()); + pending_risk_level_forced_exit_symbols.insert(position.symbol.clone()); continue; } let (stop_hit, profit_hit) = @@ -7605,7 +7711,7 @@ impl Strategy for PlatformExprStrategy { } } - for symbol in pending_firisk_forced_exit_symbols { + for symbol in pending_risk_level_forced_exit_symbols { if delayed_sold_symbols.contains(&symbol) { continue; } @@ -7614,8 +7720,8 @@ impl Strategy for PlatformExprStrategy { symbol: symbol.clone(), target_value: 0.0, style: AlgoOrderStyle::Twap, - start_time: Some(firisk_forced_exit_time), - end_time: Some(firisk_forced_exit_time), + start_time: Some(risk_level_forced_exit_time), + end_time: Some(risk_level_forced_exit_time), reason: "risk_forced_exit".to_string(), }); if self @@ -7625,7 +7731,7 @@ impl Strategy for PlatformExprStrategy { execution_date, &symbol, &mut projected_execution_state, - Some(firisk_forced_exit_time), + Some(risk_level_forced_exit_time), ) .is_some() && Self::projected_position_is_flat(&projected, &symbol) @@ -8186,6 +8292,7 @@ impl Strategy for PlatformExprStrategy { order_intents, notes, diagnostics, + risk_decisions, }) } } @@ -8289,6 +8396,320 @@ mod tests { )); } + #[test] + fn platform_projected_execution_limit_checks_respect_risk_policy() { + let date = d(2025, 1, 2); + let market = DailyMarketSnapshot { + date, + symbol: "000001.SZ".to_string(), + timestamp: None, + day_open: 10.0, + open: 10.0, + high: 10.0, + low: 10.0, + close: 10.0, + last_price: 10.0, + bid1: 10.0, + ask1: 10.0, + prev_close: 9.1, + volume: 1_000_000, + minute_volume: 10_000, + bid1_volume: 10_000, + ask1_volume: 10_000, + trading_phase: Some("continuous".to_string()), + paused: false, + upper_limit: 10.0, + lower_limit: 8.2, + price_tick: 0.01, + }; + let default_strategy = + PlatformExprStrategy::new(PlatformExprStrategyConfig::microcap_rotation()); + assert_eq!( + default_strategy.projected_execution_limit_rejection_reason( + &market, + OrderSide::Buy, + 10.0 + ), + Some("open at or above upper limit") + ); + assert_eq!( + default_strategy.projected_execution_limit_rejection_reason( + &market, + OrderSide::Sell, + 8.2 + ), + Some("open at or below lower limit") + ); + + let mut cfg = PlatformExprStrategyConfig::microcap_rotation(); + cfg.risk_config.static_rules.reject_upper_limit_buy = false; + cfg.risk_config.static_rules.reject_lower_limit_sell = false; + let configured_strategy = PlatformExprStrategy::new(cfg); + assert_eq!( + configured_strategy.projected_execution_limit_rejection_reason( + &market, + OrderSide::Buy, + 10.0 + ), + None + ); + assert_eq!( + configured_strategy.projected_execution_limit_rejection_reason( + &market, + OrderSide::Sell, + 8.2 + ), + None + ); + } + + #[test] + fn platform_selection_kcb_switch_overrides_legacy_universe_exclude() { + let date = d(2025, 1, 2); + let symbol = "688001.SH"; + let data = DataSet::from_components( + vec![Instrument { + symbol: symbol.to_string(), + name: symbol.to_string(), + board: "SH".to_string(), + round_lot: 100, + listed_at: Some(d(2020, 1, 1)), + delisted_at: None, + status: "active".to_string(), + }], + vec![DailyMarketSnapshot { + date, + symbol: symbol.to_string(), + timestamp: None, + day_open: 10.0, + open: 10.0, + high: 10.2, + low: 9.8, + close: 10.1, + last_price: 10.1, + bid1: 10.0, + ask1: 10.1, + prev_close: 10.0, + volume: 1_000_000, + minute_volume: 10_000, + bid1_volume: 10_000, + ask1_volume: 10_000, + trading_phase: Some("continuous".to_string()), + paused: false, + upper_limit: 11.0, + lower_limit: 9.0, + price_tick: 0.01, + }], + vec![DailyFactorSnapshot { + date, + symbol: symbol.to_string(), + market_cap_bn: 10.0, + free_float_cap_bn: 9.0, + pe_ttm: 12.0, + turnover_ratio: Some(1.0), + effective_turnover_ratio: Some(1.0), + extra_factors: BTreeMap::new(), + }], + vec![CandidateEligibility { + date, + symbol: symbol.to_string(), + is_st: false, + is_new_listing: false, + is_paused: false, + allow_buy: true, + allow_sell: true, + is_kcb: true, + is_one_yuan: false, + risk_level_code: None, + }], + vec![BenchmarkSnapshot { + date, + benchmark: "000852.SH".to_string(), + open: 1000.0, + close: 1001.0, + prev_close: 999.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: None, + order_events: &[], + fills: &[], + }; + + let default_strategy = + PlatformExprStrategy::new(PlatformExprStrategyConfig::microcap_rotation()); + assert!( + default_strategy + .selectable_universe_on(&ctx, date, date) + .is_empty() + ); + let risk_decisions = default_strategy.selection_risk_decisions(&ctx, date, date); + let risk_diagnostics = PlatformExprStrategy::selection_risk_decision_diagnostics( + &risk_decisions, + date, + date, + 5, + ); + assert!( + risk_diagnostics + .iter() + .any(|item| item.contains("risk_decisions selection_total=1 by_rule=kcb:1")), + "{risk_diagnostics:?}" + ); + assert!( + risk_diagnostics + .iter() + .any(|item| item.contains("\"rule_code\":\"kcb\"")), + "{risk_diagnostics:?}" + ); + + let mut cfg = PlatformExprStrategyConfig::microcap_rotation(); + cfg.universe_exclude = vec!["kcb".to_string()]; + cfg.risk_config.static_rules.reject_kcb_selection = false; + let configured_strategy = PlatformExprStrategy::new(cfg); + + let universe = configured_strategy.selectable_universe_on(&ctx, date, date); + assert_eq!(universe.len(), 1); + assert_eq!(universe[0].symbol, symbol); + } + + #[test] + fn platform_selection_and_buy_respect_allow_flags_are_configurable() { + let date = d(2025, 1, 2); + let symbol = "000001.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(2020, 1, 1)), + delisted_at: None, + status: "active".to_string(), + }], + vec![DailyMarketSnapshot { + date, + symbol: symbol.to_string(), + timestamp: None, + day_open: 10.0, + open: 10.0, + high: 10.2, + low: 9.8, + close: 10.1, + last_price: 10.1, + bid1: 10.0, + ask1: 10.1, + prev_close: 10.0, + volume: 1_000_000, + minute_volume: 10_000, + bid1_volume: 10_000, + ask1_volume: 10_000, + trading_phase: Some("continuous".to_string()), + paused: false, + upper_limit: 11.0, + lower_limit: 9.0, + price_tick: 0.01, + }], + vec![DailyFactorSnapshot { + date, + symbol: symbol.to_string(), + market_cap_bn: 10.0, + free_float_cap_bn: 9.0, + pe_ttm: 12.0, + turnover_ratio: Some(1.0), + effective_turnover_ratio: Some(1.0), + extra_factors: BTreeMap::new(), + }], + vec![CandidateEligibility { + date, + symbol: symbol.to_string(), + is_st: false, + is_new_listing: false, + is_paused: false, + allow_buy: false, + allow_sell: true, + is_kcb: false, + is_one_yuan: false, + risk_level_code: None, + }], + vec![BenchmarkSnapshot { + date, + benchmark: "000852.SH".to_string(), + open: 1000.0, + close: 1001.0, + prev_close: 999.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: None, + order_events: &[], + fills: &[], + }; + + let default_strategy = + PlatformExprStrategy::new(PlatformExprStrategyConfig::microcap_rotation()); + assert!( + default_strategy + .selectable_universe_on(&ctx, date, date) + .is_empty() + ); + let default_stock = default_strategy + .stock_state(&ctx, date, symbol) + .expect("stock state"); + assert_eq!( + default_strategy + .buy_rejection_reason(&ctx, date, symbol, &default_stock) + .expect("buy rejection"), + Some("buy_disabled".to_string()) + ); + + let mut cfg = PlatformExprStrategyConfig::microcap_rotation(); + cfg.risk_config.static_rules.respect_allow_buy_sell = false; + let configured_strategy = PlatformExprStrategy::new(cfg); + let configured_stock = configured_strategy + .stock_state(&ctx, date, symbol) + .expect("stock state"); + + let universe = configured_strategy.selectable_universe_on(&ctx, date, date); + assert_eq!(universe.len(), 1); + assert_eq!( + configured_strategy + .buy_rejection_reason(&ctx, date, symbol, &configured_stock) + .expect("buy rejection"), + None + ); + } + #[test] fn platform_expr_cost_model_uses_commission_override() { let mut cfg = PlatformExprStrategyConfig::microcap_rotation(); @@ -11167,7 +11588,7 @@ mod tests { assert!(stock.volume.is_nan()); let day = strategy.day_state(&ctx, date).expect("day state"); - let (selected, _) = strategy + let (selected, _, _) = strategy .select_symbols(&ctx, date, date, date, &day, 10.0, 20.0, 1) .expect("selection"); assert!(selected.is_empty()); @@ -11281,14 +11702,14 @@ mod tests { let strategy = PlatformExprStrategy::new(cfg.clone()); let day = strategy.day_state(&ctx, date).expect("day state"); - let (selected_yi, _) = strategy + let (selected_yi, _, _) = strategy .select_symbols(&ctx, date, date, date, &day, 11.0, 39.0, 10) .expect("selection"); assert_eq!(selected_yi, vec!["003008.SZ".to_string()]); cfg.market_cap_field = "market_cap_bn".to_string(); let strategy_bn = PlatformExprStrategy::new(cfg); - let (selected_bn, _) = strategy_bn + let (selected_bn, _, _) = strategy_bn .select_symbols(&ctx, date, date, date, &day, 140.0, 160.0, 10) .expect("selection by bn"); assert_eq!(selected_bn, vec!["300999.SZ".to_string()]); @@ -11766,7 +12187,7 @@ mod tests { } #[test] - fn platform_aiquant_firisk_risk_level_forces_existing_position_exit() { + fn platform_aiquant_risk_level_forces_existing_position_exit() { let date = d(2023, 5, 5); let symbol = "300621.SZ"; let data = DataSet::from_components( @@ -14085,6 +14506,106 @@ mod tests { assert_eq!(rejection.as_deref(), Some("buy_disabled")); } + #[test] + fn platform_can_sell_position_uses_configured_lower_limit_policy() { + let prev_date = d(2025, 4, 7); + let date = d(2025, 4, 8); + let symbol = "002633.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(2020, 1, 1)), + delisted_at: None, + status: "active".to_string(), + }], + vec![DailyMarketSnapshot { + date, + symbol: symbol.to_string(), + timestamp: Some("2025-04-08 10:18:00".to_string()), + day_open: 5.63, + open: 5.63, + high: 5.63, + low: 5.63, + close: 5.63, + last_price: 5.63, + bid1: 5.63, + ask1: 5.63, + prev_close: 6.25, + volume: 1_000_000, + minute_volume: 10_000, + bid1_volume: 1_000, + ask1_volume: 1_000, + trading_phase: Some("continuous".to_string()), + paused: false, + upper_limit: 6.89, + lower_limit: 5.63, + price_tick: 0.01, + }], + vec![DailyFactorSnapshot { + date, + symbol: symbol.to_string(), + market_cap_bn: 15.0, + free_float_cap_bn: 10.0, + pe_ttm: 8.0, + turnover_ratio: Some(2.0), + effective_turnover_ratio: Some(2.0), + extra_factors: BTreeMap::new(), + }], + vec![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, + risk_level_code: None, + }], + 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 mut portfolio = PortfolioState::new(1_000_000.0); + portfolio.position_mut(symbol).buy(prev_date, 1_000, 6.25); + 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 default_strategy = + PlatformExprStrategy::new(PlatformExprStrategyConfig::microcap_rotation()); + assert!(!default_strategy.can_sell_position(&ctx, date, symbol)); + + let mut cfg = PlatformExprStrategyConfig::microcap_rotation(); + cfg.risk_config.static_rules.reject_lower_limit_sell = false; + let configured_strategy = PlatformExprStrategy::new(cfg); + assert!(configured_strategy.can_sell_position(&ctx, date, symbol)); + } + #[test] fn platform_strategy_baseline_risk_filter_cannot_be_disabled_by_empty_exclude() { let date = d(2024, 12, 31); @@ -18595,7 +19116,7 @@ mod tests { assert!( decision.order_intents.iter().all(|intent| match intent { OrderIntent::Value { value, reason, .. } if reason == "periodic_rebalance_buy" => - (*value - 75_410.3).abs() < 1e-6, + (*value - 75_444.8).abs() < 1e-6, _ => true, }), "{:?}", diff --git a/crates/fidc-core/src/platform_strategy_spec.rs b/crates/fidc-core/src/platform_strategy_spec.rs index cf607d2..cfea5dd 100644 --- a/crates/fidc-core/src/platform_strategy_spec.rs +++ b/crates/fidc-core/src/platform_strategy_spec.rs @@ -14,7 +14,7 @@ use crate::{ #[derive(Debug, Clone, Default, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct StrategyRuntimeSpec { - #[serde(default)] + #[serde(default, alias = "strategy_id")] pub strategy_id: Option, #[serde(default)] pub market: Option, @@ -24,15 +24,15 @@ pub struct StrategyRuntimeSpec { pub universe: Option, #[serde(default)] pub rebalance: Option, - #[serde(default)] + #[serde(default, alias = "trade_times")] pub trade_times: Vec, - #[serde(default)] + #[serde(default, alias = "signal_symbol")] pub signal_symbol: Option, #[serde(default)] pub execution: Option, - #[serde(default)] + #[serde(default, alias = "engine_config")] pub engine_config: Option, - #[serde(default)] + #[serde(default, alias = "runtime_expressions")] pub runtime_expressions: Option, #[serde(default)] pub environment: Option, @@ -66,27 +66,37 @@ pub struct StrategyRebalanceSpec { #[derive(Debug, Clone, Default, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct StrategyExecutionSpec { - #[serde(default)] + #[serde(default, alias = "matching_type")] pub matching_type: Option, - #[serde(default)] + #[serde(default, alias = "slippage_model")] pub slippage_model: Option, - #[serde(default)] + #[serde(default, alias = "slippage_value")] pub slippage_value: Option, - #[serde(default)] + #[serde(default, alias = "slippage_impact_coefficient")] pub slippage_impact_coefficient: Option, - #[serde(default)] + #[serde(default, alias = "slippage_volatility_coefficient")] pub slippage_volatility_coefficient: Option, - #[serde(default)] + #[serde(default, alias = "slippage_max_value", alias = "slippage_max_rate")] pub slippage_max_value: Option, - #[serde(default)] + #[serde(default, alias = "commission_rate")] pub commission_rate: Option, - #[serde(default)] + #[serde(default, alias = "minimum_commission", alias = "minCommission")] pub minimum_commission: Option, - #[serde(default)] + #[serde(default, alias = "stamp_tax_rate_before_change")] pub stamp_tax_rate_before_change: Option, - #[serde(default)] + #[serde(default, alias = "stamp_tax_rate_after_change")] pub stamp_tax_rate_after_change: Option, - #[serde(default)] + #[serde(default, alias = "stamp_tax_change_date")] + pub stamp_tax_change_date: Option, + #[serde(default, alias = "volume_limit")] + pub volume_limit: Option, + #[serde(default, alias = "liquidity_limit")] + pub liquidity_limit: Option, + #[serde(default, alias = "volume_percent")] + pub volume_percent: Option, + #[serde(default, alias = "risk_policy")] + pub risk_policy: Option, + #[serde(default, alias = "strict_value_budget")] pub strict_value_budget: Option, } @@ -95,11 +105,11 @@ pub struct StrategyExecutionSpec { pub struct StrategyEngineConfig { #[serde(default)] pub template_id: Option, - #[serde(default)] + #[serde(default, alias = "profile_name")] pub profile_name: Option, - #[serde(default)] + #[serde(default, alias = "benchmark_symbol")] pub benchmark_symbol: Option, - #[serde(default)] + #[serde(default, alias = "signal_symbol")] pub signal_symbol: Option, #[serde(default)] pub rank_limit: Option, @@ -117,27 +127,37 @@ pub struct StrategyEngineConfig { pub stop_loss_multiplier: Option, #[serde(default)] pub take_profit_multiplier: Option, - #[serde(default)] + #[serde(default, alias = "matching_type")] pub matching_type: Option, - #[serde(default)] + #[serde(default, alias = "slippage_model")] pub slippage_model: Option, - #[serde(default)] + #[serde(default, alias = "slippage_value")] pub slippage_value: Option, - #[serde(default)] + #[serde(default, alias = "slippage_impact_coefficient")] pub slippage_impact_coefficient: Option, - #[serde(default)] + #[serde(default, alias = "slippage_volatility_coefficient")] pub slippage_volatility_coefficient: Option, - #[serde(default)] + #[serde(default, alias = "slippage_max_value", alias = "slippage_max_rate")] pub slippage_max_value: Option, - #[serde(default)] + #[serde(default, alias = "commission_rate")] pub commission_rate: Option, - #[serde(default)] + #[serde(default, alias = "minimum_commission", alias = "minCommission")] pub minimum_commission: Option, - #[serde(default)] + #[serde(default, alias = "stamp_tax_rate_before_change")] pub stamp_tax_rate_before_change: Option, - #[serde(default)] + #[serde(default, alias = "stamp_tax_rate_after_change")] pub stamp_tax_rate_after_change: Option, - #[serde(default)] + #[serde(default, alias = "stamp_tax_change_date")] + pub stamp_tax_change_date: Option, + #[serde(default, alias = "volume_limit")] + pub volume_limit: Option, + #[serde(default, alias = "liquidity_limit")] + pub liquidity_limit: Option, + #[serde(default, alias = "volume_percent")] + pub volume_percent: Option, + #[serde(default, alias = "risk_policy")] + pub risk_policy: Option, + #[serde(default, alias = "strict_value_budget")] pub strict_value_budget: Option, #[serde(default)] pub dividend_reinvestment: Option, @@ -149,6 +169,81 @@ pub struct StrategyEngineConfig { pub skip_windows: Vec, } +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct StrategyRiskPolicySpec { + #[serde( + default, + alias = "reject_st_selection", + alias = "reject_star_st_selection", + alias = "rejectStarStSelection" + )] + pub reject_st_selection: Option, + #[serde( + default, + alias = "reject_st_buy", + alias = "reject_star_st_buy", + alias = "rejectStarStBuy" + )] + pub reject_st_buy: Option, + #[serde(default, alias = "reject_paused_selection")] + pub reject_paused_selection: Option, + #[serde(default, alias = "reject_paused_buy")] + pub reject_paused_buy: Option, + #[serde(default, alias = "reject_paused_sell")] + pub reject_paused_sell: Option, + #[serde(default, alias = "reject_inactive_selection")] + pub reject_inactive_selection: Option, + #[serde(default, alias = "reject_inactive_buy")] + pub reject_inactive_buy: Option, + #[serde(default, alias = "reject_inactive_sell")] + pub reject_inactive_sell: Option, + #[serde(default, alias = "reject_new_listing_selection")] + pub reject_new_listing_selection: Option, + #[serde(default, alias = "reject_new_listing_buy")] + pub reject_new_listing_buy: Option, + #[serde(default, alias = "reject_kcb_selection")] + pub reject_kcb_selection: Option, + #[serde(default, alias = "reject_kcb_buy")] + pub reject_kcb_buy: Option, + #[serde(default, alias = "reject_one_yuan_selection")] + pub reject_one_yuan_selection: Option, + #[serde(default, alias = "reject_one_yuan_buy")] + pub reject_one_yuan_buy: Option, + #[serde(default, alias = "respect_allow_buy_sell")] + pub respect_allow_buy_sell: Option, + #[serde(default, alias = "reject_upper_limit_selection")] + pub reject_upper_limit_selection: Option, + #[serde(default, alias = "reject_lower_limit_selection")] + pub reject_lower_limit_selection: Option, + #[serde(default, alias = "reject_upper_limit_buy")] + pub reject_upper_limit_buy: Option, + #[serde(default, alias = "reject_lower_limit_sell")] + pub reject_lower_limit_sell: Option, + #[serde(default, alias = "forbid_same_day_rebuy_after_sell")] + pub forbid_same_day_rebuy_after_sell: Option, + #[serde(default, alias = "blacklist_enabled")] + pub blacklist_enabled: Option, + #[serde(default, alias = "blacklisted_symbols", alias = "blacklist")] + pub blacklisted_symbols: Vec, + #[serde(default, alias = "volume_limit_enabled")] + pub volume_limit_enabled: Option, + #[serde(default, alias = "volume_percent")] + pub volume_percent: Option, + #[serde(default, alias = "liquidity_limit_enabled")] + pub liquidity_limit_enabled: Option, + #[serde(default, alias = "commission_rate")] + pub commission_rate: Option, + #[serde(default, alias = "minimum_commission", alias = "minCommission")] + pub minimum_commission: Option, + #[serde(default, alias = "stamp_tax_rate_before_change")] + pub stamp_tax_rate_before_change: Option, + #[serde(default, alias = "stamp_tax_rate_after_change")] + pub stamp_tax_rate_after_change: Option, + #[serde(default, alias = "stamp_tax_change_date")] + pub stamp_tax_change_date: Option, +} + #[derive(Debug, Clone, Default, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct DynamicRangeConfig { @@ -392,6 +487,27 @@ fn valid_non_negative(value: Option) -> Option { value.filter(|item| item.is_finite() && *item >= 0.0) } +fn normalize_percent_ratio(value: Option) -> Option { + value + .filter(|item| item.is_finite() && *item > 0.0) + .and_then(|item| { + if item <= 1.0 { + Some(item) + } else if item <= 100.0 { + Some(item / 100.0) + } else { + None + } + }) +} + +fn parse_policy_date(value: Option<&str>) -> Option { + let raw = value.map(str::trim).filter(|item| !item.is_empty())?; + NaiveDate::parse_from_str(raw, "%Y-%m-%d") + .or_else(|_| NaiveDate::parse_from_str(raw, "%Y%m%d")) + .ok() +} + fn is_aiquant_profile(value: Option<&str>) -> bool { value .map(|item| item.trim().to_ascii_lowercase().replace('-', "_")) @@ -404,19 +520,148 @@ fn apply_cost_overrides( minimum_commission: Option, stamp_tax_rate_before_change: Option, stamp_tax_rate_after_change: Option, + stamp_tax_change_date: Option<&str>, ) { if let Some(value) = valid_non_negative(commission_rate) { cfg.commission_rate = Some(value); + cfg.risk_config.trading_constraints.commission_rate = value; } if let Some(value) = valid_non_negative(minimum_commission) { cfg.minimum_commission = Some(value); + cfg.risk_config.trading_constraints.minimum_commission = value; } if let Some(value) = valid_non_negative(stamp_tax_rate_before_change) { cfg.stamp_tax_rate_before_change = Some(value); + cfg.risk_config + .trading_constraints + .stamp_tax_rate_before_change = value; } if let Some(value) = valid_non_negative(stamp_tax_rate_after_change) { cfg.stamp_tax_rate_after_change = Some(value); + cfg.risk_config + .trading_constraints + .stamp_tax_rate_after_change = value; } + if let Some(value) = parse_policy_date(stamp_tax_change_date) { + cfg.stamp_tax_change_date = Some(value); + cfg.risk_config.trading_constraints.stamp_tax_change_date = value; + } +} + +fn apply_flat_risk_overrides( + cfg: &mut PlatformExprStrategyConfig, + volume_limit: Option, + liquidity_limit: Option, + volume_percent: Option, +) { + if let Some(enabled) = volume_limit { + cfg.risk_config.trading_constraints.volume_limit_enabled = enabled; + } + if let Some(enabled) = liquidity_limit { + cfg.risk_config.trading_constraints.liquidity_limit_enabled = enabled; + } + if let Some(value) = normalize_percent_ratio(volume_percent) { + cfg.risk_config.trading_constraints.volume_percent = value; + } + cfg.quote_quantity_limit = cfg.risk_config.trading_constraints.volume_limit_enabled + || cfg.risk_config.trading_constraints.liquidity_limit_enabled + || cfg.matching_type != MatchingType::MinuteLast; +} + +fn apply_risk_policy_overrides( + cfg: &mut PlatformExprStrategyConfig, + policy: Option<&StrategyRiskPolicySpec>, +) { + let Some(policy) = policy else { + return; + }; + let static_rules = &mut cfg.risk_config.static_rules; + if let Some(value) = policy.reject_st_selection { + static_rules.reject_st_selection = value; + } + if let Some(value) = policy.reject_st_buy { + static_rules.reject_st_buy = value; + } + if let Some(value) = policy.reject_paused_selection { + static_rules.reject_paused_selection = value; + } + if let Some(value) = policy.reject_paused_buy { + static_rules.reject_paused_buy = value; + } + if let Some(value) = policy.reject_paused_sell { + static_rules.reject_paused_sell = value; + } + if let Some(value) = policy.reject_inactive_selection { + static_rules.reject_inactive_selection = value; + } + if let Some(value) = policy.reject_inactive_buy { + static_rules.reject_inactive_buy = value; + } + if let Some(value) = policy.reject_inactive_sell { + static_rules.reject_inactive_sell = value; + } + if let Some(value) = policy.reject_new_listing_selection { + static_rules.reject_new_listing_selection = value; + } + if let Some(value) = policy.reject_new_listing_buy { + static_rules.reject_new_listing_buy = value; + } + if let Some(value) = policy.reject_kcb_selection { + static_rules.reject_kcb_selection = value; + } + if let Some(value) = policy.reject_kcb_buy { + static_rules.reject_kcb_buy = value; + } + if let Some(value) = policy.reject_one_yuan_selection { + static_rules.reject_one_yuan_selection = value; + } + if let Some(value) = policy.reject_one_yuan_buy { + static_rules.reject_one_yuan_buy = value; + } + if let Some(value) = policy.respect_allow_buy_sell { + static_rules.respect_allow_buy_sell = value; + } + if let Some(value) = policy.reject_upper_limit_selection { + static_rules.reject_upper_limit_selection = value; + } + if let Some(value) = policy.reject_lower_limit_selection { + static_rules.reject_lower_limit_selection = value; + } + if let Some(value) = policy.reject_upper_limit_buy { + static_rules.reject_upper_limit_buy = value; + } + if let Some(value) = policy.reject_lower_limit_sell { + static_rules.reject_lower_limit_sell = value; + } + if let Some(value) = policy.forbid_same_day_rebuy_after_sell { + static_rules.forbid_same_day_rebuy_after_sell = value; + } + if let Some(value) = policy.blacklist_enabled { + static_rules.blacklist_enabled = value; + } + if !policy.blacklisted_symbols.is_empty() { + static_rules.blacklisted_symbols = policy + .blacklisted_symbols + .iter() + .map(|item| item.trim().to_string()) + .filter(|item| !item.is_empty()) + .collect(); + } + + apply_flat_risk_overrides( + cfg, + policy.volume_limit_enabled, + policy.liquidity_limit_enabled, + policy.volume_percent, + ); + apply_cost_overrides( + cfg, + policy.commission_rate, + policy.minimum_commission, + policy.stamp_tax_rate_before_change, + policy.stamp_tax_rate_after_change, + policy.stamp_tax_change_date.as_deref(), + ); } fn normalize_model_name(value: &str) -> String { @@ -755,7 +1000,15 @@ pub fn platform_expr_config_from_spec( engine.minimum_commission, engine.stamp_tax_rate_before_change, engine.stamp_tax_rate_after_change, + engine.stamp_tax_change_date.as_deref(), ); + apply_flat_risk_overrides( + &mut cfg, + engine.volume_limit, + engine.liquidity_limit, + engine.volume_percent, + ); + apply_risk_policy_overrides(&mut cfg, engine.risk_policy.as_ref()); apply_execution_behavior_overrides( &mut cfg, engine.matching_type.as_deref(), @@ -1156,7 +1409,15 @@ pub fn platform_expr_config_from_spec( execution.minimum_commission, execution.stamp_tax_rate_before_change, execution.stamp_tax_rate_after_change, + execution.stamp_tax_change_date.as_deref(), ); + apply_flat_risk_overrides( + &mut cfg, + execution.volume_limit, + execution.liquidity_limit, + execution.volume_percent, + ); + apply_risk_policy_overrides(&mut cfg, execution.risk_policy.as_ref()); apply_execution_behavior_overrides( &mut cfg, execution.matching_type.as_deref(), @@ -1686,7 +1947,8 @@ mod tests { "commissionRate": 0.0003, "minimumCommission": 5.0, "stampTaxRateBeforeChange": 0.0005, - "stampTaxRateAfterChange": 0.0005 + "stampTaxRateAfterChange": 0.0005, + "stampTaxChangeDate": "2024-01-02" }, "engineConfig": { "profileName": "aiquant", @@ -1701,6 +1963,109 @@ mod tests { assert_eq!(cfg.minimum_commission, Some(5.0)); assert_eq!(cfg.stamp_tax_rate_before_change, Some(0.0005)); assert_eq!(cfg.stamp_tax_rate_after_change, Some(0.0005)); + assert_eq!( + cfg.stamp_tax_change_date, + Some(NaiveDate::from_ymd_opt(2024, 1, 2).unwrap()) + ); + } + + #[test] + fn parses_risk_policy_into_platform_config() { + let spec = serde_json::json!({ + "engineConfig": { + "riskPolicy": { + "rejectUpperLimitSelection": false, + "rejectLowerLimitSelection": false, + "rejectUpperLimitBuy": false, + "rejectLowerLimitSell": false, + "forbidSameDayRebuyAfterSell": true, + "blacklist": [" 600000.SH ", ""], + "volumeLimitEnabled": true, + "volumePercent": 0.1, + "liquidityLimitEnabled": true, + "commissionRate": 0.0003, + "minimumCommission": 5.0, + "stampTaxChangeDate": "20240103" + } + }, + "execution": { + "riskPolicy": { + "rejectUpperLimitSelection": true, + "rejectLowerLimitSelection": true, + "rejectUpperLimitBuy": true, + "rejectLowerLimitSell": true, + "volumePercent": 25 + } + } + }); + + let cfg = platform_expr_config_from_value("", "", &spec).expect("config"); + + assert!(cfg.risk_config.static_rules.reject_upper_limit_buy); + assert!(cfg.risk_config.static_rules.reject_lower_limit_sell); + assert!(cfg.risk_config.static_rules.reject_upper_limit_selection); + assert!(cfg.risk_config.static_rules.reject_lower_limit_selection); + assert!( + cfg.risk_config + .static_rules + .forbid_same_day_rebuy_after_sell + ); + assert!( + cfg.risk_config + .static_rules + .blacklisted_symbols + .contains("600000.SH") + ); + assert!(cfg.risk_config.trading_constraints.volume_limit_enabled); + assert!(cfg.risk_config.trading_constraints.liquidity_limit_enabled); + assert!((cfg.risk_config.trading_constraints.volume_percent - 0.25).abs() < 1e-12); + assert_eq!( + cfg.risk_config.trading_constraints.stamp_tax_change_date, + NaiveDate::from_ymd_opt(2024, 1, 3).unwrap() + ); + assert!(cfg.quote_quantity_limit); + } + + #[test] + fn parses_snake_case_and_star_st_risk_policy_aliases_into_platform_config() { + let spec = serde_json::json!({ + "engine_config": { + "risk_policy": { + "reject_star_st_selection": false, + "reject_star_st_buy": false, + "reject_paused_selection": false, + "reject_kcb_buy": false, + "blacklisted_symbols": ["000001.SZ"], + "volume_limit_enabled": true, + "volume_percent": 25, + "minimum_commission": 6.0, + "stamp_tax_rate_before_change": 0.001, + "stamp_tax_rate_after_change": 0.0005, + "stamp_tax_change_date": "2023-08-28" + } + }, + "execution": { + "commission_rate": 0.0003, + "matching_type": "minute_last" + } + }); + + let cfg = platform_expr_config_from_value("", "", &spec).expect("config"); + + assert!(!cfg.risk_config.static_rules.reject_st_selection); + assert!(!cfg.risk_config.static_rules.reject_st_buy); + assert!(!cfg.risk_config.static_rules.reject_paused_selection); + assert!(!cfg.risk_config.static_rules.reject_kcb_buy); + assert!( + cfg.risk_config + .static_rules + .blacklisted_symbols + .contains("000001.SZ") + ); + assert!((cfg.risk_config.trading_constraints.volume_percent - 0.25).abs() < 1e-12); + assert_eq!(cfg.risk_config.trading_constraints.minimum_commission, 6.0); + assert_eq!(cfg.commission_rate, Some(0.0003)); + assert_eq!(cfg.matching_type, MatchingType::MinuteLast); } #[test] diff --git a/crates/fidc-core/src/risk_control.rs b/crates/fidc-core/src/risk_control.rs index 1eff942..235af67 100644 --- a/crates/fidc-core/src/risk_control.rs +++ b/crates/fidc-core/src/risk_control.rs @@ -1,4 +1,7 @@ +use std::collections::BTreeSet; + use chrono::NaiveDate; +use serde::{Deserialize, Serialize}; use crate::data::{CandidateEligibility, DailyMarketSnapshot, PriceField}; use crate::instrument::Instrument; @@ -7,7 +10,155 @@ use crate::portfolio::Position; #[derive(Debug, Clone, Copy, Default)] pub struct ChinaAShareRiskControl; +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct StaticRiskRuleConfig { + pub reject_st_selection: bool, + pub reject_st_buy: bool, + pub reject_paused_selection: bool, + pub reject_paused_buy: bool, + pub reject_paused_sell: bool, + pub reject_inactive_selection: bool, + pub reject_inactive_buy: bool, + pub reject_inactive_sell: bool, + pub reject_new_listing_selection: bool, + pub reject_new_listing_buy: bool, + pub reject_kcb_selection: bool, + pub reject_kcb_buy: bool, + pub reject_one_yuan_selection: bool, + pub reject_one_yuan_buy: bool, + pub respect_allow_buy_sell: bool, + pub reject_upper_limit_selection: bool, + pub reject_lower_limit_selection: bool, + pub reject_upper_limit_buy: bool, + pub reject_lower_limit_sell: bool, + pub forbid_same_day_rebuy_after_sell: bool, + pub blacklist_enabled: bool, + #[serde(default)] + pub blacklisted_symbols: BTreeSet, +} + +impl Default for StaticRiskRuleConfig { + fn default() -> Self { + Self { + reject_st_selection: true, + reject_st_buy: true, + reject_paused_selection: true, + reject_paused_buy: true, + reject_paused_sell: true, + reject_inactive_selection: true, + reject_inactive_buy: true, + reject_inactive_sell: true, + reject_new_listing_selection: true, + reject_new_listing_buy: true, + reject_kcb_selection: true, + reject_kcb_buy: true, + reject_one_yuan_selection: true, + reject_one_yuan_buy: true, + respect_allow_buy_sell: true, + reject_upper_limit_selection: true, + reject_lower_limit_selection: true, + reject_upper_limit_buy: true, + reject_lower_limit_sell: true, + forbid_same_day_rebuy_after_sell: true, + blacklist_enabled: true, + blacklisted_symbols: BTreeSet::new(), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +pub struct TradingConstraintConfig { + pub volume_limit_enabled: bool, + pub volume_percent: f64, + pub liquidity_limit_enabled: bool, + pub commission_rate: f64, + pub minimum_commission: f64, + pub stamp_tax_rate_before_change: f64, + pub stamp_tax_rate_after_change: f64, + pub stamp_tax_change_date: NaiveDate, +} + +impl Default for TradingConstraintConfig { + fn default() -> Self { + Self { + volume_limit_enabled: true, + volume_percent: 0.25, + liquidity_limit_enabled: true, + commission_rate: 0.0003, + minimum_commission: 5.0, + stamp_tax_rate_before_change: 0.001, + stamp_tax_rate_after_change: 0.0005, + stamp_tax_change_date: NaiveDate::from_ymd_opt(2023, 8, 28) + .expect("valid stamp tax change date"), + } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] +pub struct FidcRiskControlConfig { + pub static_rules: StaticRiskRuleConfig, + pub trading_constraints: TradingConstraintConfig, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct FidcRiskDecisionAudit { + pub date: NaiveDate, + pub symbol: String, + pub scope: RiskCheckScope, + pub stage: String, + pub accepted: bool, + pub rule_code: String, + pub reason: String, + pub config_version: Option, + pub data_epoch: String, + pub selection_batch_id: Option, + pub order_id: Option, +} + +impl FidcRiskDecisionAudit { + pub fn rejected_selection( + date: NaiveDate, + symbol: impl Into, + reason: impl Into, + ) -> Self { + let reason = reason.into(); + Self { + date, + symbol: symbol.into(), + scope: RiskCheckScope::Selection, + stage: "selection".to_string(), + accepted: false, + rule_code: reason.clone(), + reason, + config_version: Some("inline_risk_policy".to_string()), + data_epoch: date.to_string(), + selection_batch_id: Some(format!("selection:{date}")), + order_id: None, + } + } + + pub fn diagnostic_line(&self) -> String { + match serde_json::to_string(self) { + Ok(value) => format!("risk_decision={value}"), + Err(err) => format!( + "risk_decision_serialize_error symbol={} date={} error={}", + self.symbol, self.date, err + ), + } + } +} + impl ChinaAShareRiskControl { + pub fn default_config() -> FidcRiskControlConfig { + FidcRiskControlConfig::default() + } + + fn is_blacklisted(config: &FidcRiskControlConfig, symbol: &str) -> bool { + config.static_rules.blacklist_enabled + && config.static_rules.blacklisted_symbols.contains(symbol) + } + pub fn instrument_rejection_reason( instrument: Option<&Instrument>, date: NaiveDate, @@ -36,43 +187,157 @@ impl ChinaAShareRiskControl { None } + pub fn instrument_rejection_reason_with_config( + instrument: Option<&Instrument>, + date: NaiveDate, + config: &FidcRiskControlConfig, + scope: RiskCheckScope, + ) -> Option<&'static str> { + let enabled = match scope { + RiskCheckScope::Selection => config.static_rules.reject_inactive_selection, + RiskCheckScope::Buy => config.static_rules.reject_inactive_buy, + RiskCheckScope::Sell => config.static_rules.reject_inactive_sell, + }; + if !enabled { + return None; + } + Self::instrument_rejection_reason(instrument, date) + } + pub fn selection_rejection_reason( date: NaiveDate, candidate: &CandidateEligibility, market: &DailyMarketSnapshot, instrument: Option<&Instrument>, ) -> Option<&'static str> { - if let Some(reason) = Self::baseline_rejection_reason(date, candidate, market, instrument) { + Self::selection_rejection_reason_with_config( + date, + candidate, + market, + instrument, + &Self::default_config(), + ) + } + + pub fn selection_rejection_reason_with_config( + date: NaiveDate, + candidate: &CandidateEligibility, + market: &DailyMarketSnapshot, + instrument: Option<&Instrument>, + config: &FidcRiskControlConfig, + ) -> Option<&'static str> { + if let Some(reason) = Self::baseline_rejection_reason_with_config( + date, + candidate, + market, + instrument, + config, + RiskCheckScope::Selection, + ) { return Some(reason); } - if !candidate.allow_buy || !candidate.allow_sell { + if config.static_rules.respect_allow_buy_sell + && (!candidate.allow_buy || !candidate.allow_sell) + { return Some("trade_disabled"); } + let selection_price = market.price(PriceField::Last); + if config.static_rules.reject_upper_limit_selection + && market.is_at_upper_limit_price(selection_price) + { + return Some("upper_limit"); + } + if config.static_rules.reject_lower_limit_selection + && market.is_at_lower_limit_price(selection_price) + { + return Some("lower_limit"); + } None } + pub fn selection_rejection_decision_with_config( + date: NaiveDate, + candidate: &CandidateEligibility, + market: &DailyMarketSnapshot, + instrument: Option<&Instrument>, + config: &FidcRiskControlConfig, + ) -> Option { + Self::selection_rejection_reason_with_config(date, candidate, market, instrument, config) + .map(|reason| { + FidcRiskDecisionAudit::rejected_selection(date, candidate.symbol.clone(), reason) + }) + } + pub fn baseline_rejection_reason( date: NaiveDate, candidate: &CandidateEligibility, market: &DailyMarketSnapshot, instrument: Option<&Instrument>, ) -> Option<&'static str> { - if let Some(reason) = Self::instrument_rejection_reason(instrument, date) { + Self::baseline_rejection_reason_with_config( + date, + candidate, + market, + instrument, + &Self::default_config(), + RiskCheckScope::Selection, + ) + } + + pub fn baseline_rejection_reason_with_config( + date: NaiveDate, + candidate: &CandidateEligibility, + market: &DailyMarketSnapshot, + instrument: Option<&Instrument>, + config: &FidcRiskControlConfig, + scope: RiskCheckScope, + ) -> Option<&'static str> { + if Self::is_blacklisted(config, &candidate.symbol) && scope != RiskCheckScope::Sell { + return Some("blacklisted"); + } + if let Some(reason) = + Self::instrument_rejection_reason_with_config(instrument, date, config, scope) + { return Some(reason); } - if market.paused || candidate.is_paused { + let reject_paused = match scope { + RiskCheckScope::Selection => config.static_rules.reject_paused_selection, + RiskCheckScope::Buy => config.static_rules.reject_paused_buy, + RiskCheckScope::Sell => config.static_rules.reject_paused_sell, + }; + if reject_paused && (market.paused || candidate.is_paused) { return Some("paused"); } - if candidate.is_st { + let reject_st = match scope { + RiskCheckScope::Selection => config.static_rules.reject_st_selection, + RiskCheckScope::Buy => config.static_rules.reject_st_buy, + RiskCheckScope::Sell => false, + }; + if reject_st && candidate.is_st { return Some("st"); } - if candidate.is_new_listing { + let reject_new_listing = match scope { + RiskCheckScope::Selection => config.static_rules.reject_new_listing_selection, + RiskCheckScope::Buy => config.static_rules.reject_new_listing_buy, + RiskCheckScope::Sell => false, + }; + if reject_new_listing && candidate.is_new_listing { return Some("new_listing"); } - if candidate.is_kcb { + let reject_kcb = match scope { + RiskCheckScope::Selection => config.static_rules.reject_kcb_selection, + RiskCheckScope::Buy => config.static_rules.reject_kcb_buy, + RiskCheckScope::Sell => false, + }; + if reject_kcb && candidate.is_kcb { return Some("kcb"); } - if candidate.is_one_yuan || market.day_open <= 1.0 { + let reject_one_yuan = match scope { + RiskCheckScope::Selection => config.static_rules.reject_one_yuan_selection, + RiskCheckScope::Buy => config.static_rules.reject_one_yuan_buy, + RiskCheckScope::Sell => false, + }; + if reject_one_yuan && (candidate.is_one_yuan || market.day_open <= 1.0) { return Some("one_yuan"); } None @@ -85,13 +350,39 @@ impl ChinaAShareRiskControl { instrument: Option<&Instrument>, check_price: f64, ) -> Option<&'static str> { - if let Some(reason) = Self::baseline_rejection_reason(date, candidate, market, instrument) { + Self::buy_rejection_reason_with_config( + date, + candidate, + market, + instrument, + check_price, + &Self::default_config(), + ) + } + + pub fn buy_rejection_reason_with_config( + date: NaiveDate, + candidate: &CandidateEligibility, + market: &DailyMarketSnapshot, + instrument: Option<&Instrument>, + check_price: f64, + config: &FidcRiskControlConfig, + ) -> Option<&'static str> { + if let Some(reason) = Self::baseline_rejection_reason_with_config( + date, + candidate, + market, + instrument, + config, + RiskCheckScope::Buy, + ) { return Some(reason); } - if !candidate.allow_buy { + if config.static_rules.respect_allow_buy_sell && !candidate.allow_buy { return Some("buy_disabled"); } - if market.is_at_upper_limit_price(check_price) { + if config.static_rules.reject_upper_limit_buy && market.is_at_upper_limit_price(check_price) + { return Some("open at or above upper limit"); } None @@ -105,17 +396,44 @@ impl ChinaAShareRiskControl { position: Option<&Position>, check_price: f64, ) -> Option<&'static str> { - if let Some(reason) = Self::instrument_rejection_reason(instrument, date) { + Self::sell_rejection_reason_with_config( + date, + candidate, + market, + instrument, + position, + check_price, + &Self::default_config(), + ) + } + + pub fn sell_rejection_reason_with_config( + date: NaiveDate, + candidate: &CandidateEligibility, + market: &DailyMarketSnapshot, + instrument: Option<&Instrument>, + position: Option<&Position>, + check_price: f64, + config: &FidcRiskControlConfig, + ) -> Option<&'static str> { + if let Some(reason) = Self::instrument_rejection_reason_with_config( + instrument, + date, + config, + RiskCheckScope::Sell, + ) { return Some(reason); } - if market.paused || candidate.is_paused { + if config.static_rules.reject_paused_sell && (market.paused || candidate.is_paused) { return Some("paused"); } // `allow_sell` is derived from the daily candidate snapshot and may // reflect an open/close fallback rather than the actual execution price. // A sell order must be blocked by the execution price lower-limit check // below, while suspension and delisting are handled above. - if market.is_at_lower_limit_price(check_price) { + if config.static_rules.reject_lower_limit_sell + && 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) { @@ -136,6 +454,14 @@ impl ChinaAShareRiskControl { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RiskCheckScope { + Selection, + Buy, + Sell, +} + #[cfg(test)] mod tests { use super::*; @@ -230,4 +556,98 @@ mod tests { assert_eq!(reason, Some("open at or below lower limit")); } + + #[test] + fn configurable_blacklist_rejects_selection_and_buy() { + let date = d(2025, 1, 2); + let candidate = candidate(date); + let market = market(date, 6.27, 5.63); + let mut config = FidcRiskControlConfig::default(); + config + .static_rules + .blacklisted_symbols + .insert(candidate.symbol.clone()); + + let selection_reason = ChinaAShareRiskControl::selection_rejection_reason_with_config( + date, &candidate, &market, None, &config, + ); + let buy_reason = ChinaAShareRiskControl::buy_rejection_reason_with_config( + date, &candidate, &market, None, 6.27, &config, + ); + + assert_eq!(selection_reason, Some("blacklisted")); + assert_eq!(buy_reason, Some("blacklisted")); + } + + #[test] + fn configurable_kcb_filter_can_be_disabled() { + let date = d(2025, 1, 2); + let mut candidate = candidate(date); + candidate.is_kcb = true; + let market = market(date, 6.27, 5.63); + let default_reason = + ChinaAShareRiskControl::selection_rejection_reason(date, &candidate, &market, None); + let mut config = FidcRiskControlConfig::default(); + config.static_rules.reject_kcb_selection = false; + config.static_rules.reject_kcb_buy = false; + let configured_reason = ChinaAShareRiskControl::buy_rejection_reason_with_config( + date, &candidate, &market, None, 6.27, &config, + ); + + assert_eq!(default_reason, Some("kcb")); + assert_eq!(configured_reason, None); + } + + #[test] + fn configurable_upper_limit_buy_filter_can_be_disabled() { + let date = d(2025, 1, 2); + let candidate = candidate(date); + let market = market(date, 6.89, 5.63); + let default_reason = + ChinaAShareRiskControl::buy_rejection_reason(date, &candidate, &market, None, 6.89); + let mut config = FidcRiskControlConfig::default(); + config.static_rules.reject_upper_limit_buy = false; + let configured_reason = ChinaAShareRiskControl::buy_rejection_reason_with_config( + date, &candidate, &market, None, 6.89, &config, + ); + + assert_eq!(default_reason, Some("open at or above upper limit")); + assert_eq!(configured_reason, None); + } + + #[test] + fn configurable_upper_limit_selection_filter_can_be_disabled() { + let date = d(2025, 1, 2); + let mut candidate = candidate(date); + candidate.allow_sell = true; + let market = market(date, 6.89, 5.63); + let default_reason = + ChinaAShareRiskControl::selection_rejection_reason(date, &candidate, &market, None); + let mut config = FidcRiskControlConfig::default(); + config.static_rules.reject_upper_limit_selection = false; + let configured_reason = ChinaAShareRiskControl::selection_rejection_reason_with_config( + date, &candidate, &market, None, &config, + ); + + assert_eq!(default_reason, Some("upper_limit")); + assert_eq!(configured_reason, None); + } + + #[test] + fn configurable_lower_limit_selection_filter_can_be_disabled() { + let date = d(2025, 1, 2); + let mut candidate = candidate(date); + candidate.allow_sell = true; + let market = market(date, 5.63, 5.63); + let default_reason = + ChinaAShareRiskControl::selection_rejection_reason(date, &candidate, &market, None); + let mut config = FidcRiskControlConfig::default(); + config.static_rules.reject_lower_limit_selection = false; + let configured_reason = ChinaAShareRiskControl::selection_rejection_reason_with_config( + date, &candidate, &market, None, &config, + ); + + assert_eq!(default_reason, Some("lower_limit")); + assert_eq!(configured_reason, None); + } } diff --git a/crates/fidc-core/src/rules.rs b/crates/fidc-core/src/rules.rs index 7f56743..f4cfc91 100644 --- a/crates/fidc-core/src/rules.rs +++ b/crates/fidc-core/src/rules.rs @@ -27,6 +27,10 @@ impl RuleCheck { } pub trait EquityRuleHooks { + fn duplicates_standard_china_risk(&self) -> bool { + false + } + fn can_buy( &self, execution_date: NaiveDate, @@ -49,6 +53,10 @@ pub trait EquityRuleHooks { pub struct ChinaEquityRuleHooks; impl EquityRuleHooks for ChinaEquityRuleHooks { + fn duplicates_standard_china_risk(&self) -> bool { + true + } + fn can_buy( &self, _execution_date: NaiveDate, diff --git a/crates/fidc-core/src/strategy.rs b/crates/fidc-core/src/strategy.rs index 6792b0e..d40a90f 100644 --- a/crates/fidc-core/src/strategy.rs +++ b/crates/fidc-core/src/strategy.rs @@ -17,7 +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::risk_control::{ChinaAShareRiskControl, FidcRiskControlConfig, FidcRiskDecisionAudit}; use crate::scheduler::ScheduleRule; use crate::universe::{DynamicMarketCapBandSelector, SelectionContext, UniverseSelector}; @@ -512,6 +512,23 @@ impl StrategyContext<'_> { } } + pub fn eligible_universe_on_with_risk_config( + &self, + date: NaiveDate, + risk_config: &crate::risk_control::FidcRiskControlConfig, + ) -> Vec { + let eligible = self + .data + .eligible_universe_on_with_risk_config(date, risk_config); + match self.dynamic_universe { + Some(symbols) if !symbols.is_empty() => eligible + .into_iter() + .filter(|row| symbols.contains(&row.symbol)) + .collect(), + _ => eligible, + } + } + pub fn current_snapshot(&self, symbol: &str) -> Option<&DailyMarketSnapshot> { self.data.market(self.execution_date, symbol) } @@ -911,6 +928,7 @@ pub struct StrategyDecision { pub order_intents: Vec, pub notes: Vec, pub diagnostics: Vec, + pub risk_decisions: Vec, } impl StrategyDecision { @@ -921,6 +939,7 @@ impl StrategyDecision { self.order_intents.append(&mut other.order_intents); self.notes.append(&mut other.notes); self.diagnostics.append(&mut other.diagnostics); + self.risk_decisions.append(&mut other.risk_decisions); } pub fn is_empty(&self) -> bool { @@ -930,6 +949,7 @@ impl StrategyDecision { && self.order_intents.is_empty() && self.notes.is_empty() && self.diagnostics.is_empty() + && self.risk_decisions.is_empty() } } @@ -1123,6 +1143,7 @@ pub struct CnSmallCapRotationConfig { pub signal_symbol: Option, pub skip_months: Vec, pub skip_month_day_ranges: Vec<(Option, u32, u32, u32)>, + pub risk_config: FidcRiskControlConfig, } impl CnSmallCapRotationConfig { @@ -1150,6 +1171,7 @@ impl CnSmallCapRotationConfig { signal_symbol: None, skip_months: Vec::new(), skip_month_day_ranges: Vec::new(), + risk_config: FidcRiskControlConfig::default(), } } @@ -1183,6 +1205,7 @@ impl CnSmallCapRotationConfig { (None, 10, 20, 30), (None, 12, 20, 30), ], + risk_config: FidcRiskControlConfig::default(), } } @@ -1367,6 +1390,7 @@ impl Strategy for CnSmallCapRotationStrategy { "run_daily(10:17/10:18) mapped to T-1 decision and T open execution" .to_string(), ], + risk_decisions: Vec::new(), }); } @@ -1385,6 +1409,7 @@ impl Strategy for CnSmallCapRotationStrategy { diagnostics: vec![ "insufficient history; skip trading on warmup dates".to_string(), ], + risk_decisions: Vec::new(), }); } Err(err) => return Err(err), @@ -1422,6 +1447,7 @@ impl Strategy for CnSmallCapRotationStrategy { "run_daily(10:17/10:18) approximated by daily decision/open execution".to_string(), ); diagnostics.push("market_cap field mapped from daily_features[_enriched]_v1.market_cap to market_cap_bn without intraday fundamentals refresh".to_string()); + let mut risk_decisions = Vec::new(); if rebalance && gross_exposure > 0.0 { let (selected_before_ma, selection_diag) = @@ -1431,6 +1457,7 @@ impl Strategy for CnSmallCapRotationStrategy { reference_level: signal_level, data: ctx.data, dynamic_universe: ctx.dynamic_universe, + risk_config: Some(&self.config.risk_config), }); let before_ma_count = selected_before_ma.len(); let mut ma_rejects = Vec::new(); @@ -1479,6 +1506,29 @@ impl Strategy for CnSmallCapRotationStrategy { selection_diag.rejection_examples.join(" | ") )); } + if !selection_diag.risk_decisions.is_empty() { + risk_decisions.extend(selection_diag.risk_decisions.clone()); + let mut counts = BTreeMap::::new(); + for decision in &selection_diag.risk_decisions { + *counts.entry(decision.rule_code.clone()).or_insert(0) += 1; + } + diagnostics.push(format!( + "risk_decisions selection_total={} by_rule={}", + selection_diag.risk_decisions.len(), + counts + .iter() + .map(|(rule, count)| format!("{rule}:{count}")) + .collect::>() + .join(",") + )); + diagnostics.extend( + selection_diag + .risk_decisions + .iter() + .take(5) + .map(|decision| decision.diagnostic_line()), + ); + } if !ma_rejects.is_empty() { diagnostics.push(format!( "ma_filter_rejections sample={}", @@ -1531,6 +1581,7 @@ impl Strategy for CnSmallCapRotationStrategy { order_intents: Vec::new(), notes, diagnostics, + risk_decisions, }) } } @@ -1560,6 +1611,7 @@ pub struct OmniMicroCapConfig { pub stop_loss_ratio: f64, pub take_profit_ratio: f64, pub skip_month_day_ranges: Vec<(Option, u32, u32, u32)>, + pub risk_config: FidcRiskControlConfig, } impl OmniMicroCapConfig { @@ -1590,6 +1642,7 @@ impl OmniMicroCapConfig { // The migrated reference logic disables seasonal stop windows in // production-style execution, so the default keeps that behavior. skip_month_day_ranges: Vec::new(), + risk_config: FidcRiskControlConfig::default(), } } @@ -1618,6 +1671,7 @@ impl OmniMicroCapConfig { stop_loss_ratio: 0.92, take_profit_ratio: 1.16, skip_month_day_ranges: Vec::new(), + risk_config: FidcRiskControlConfig::default(), } } @@ -1687,12 +1741,16 @@ impl OmniMicroCapStrategy { 0.0 } + fn cost_model(&self) -> ChinaAShareCostModel { + ChinaAShareCostModel::from_trading_constraints(self.config.risk_config.trading_constraints) + } + fn buy_commission(&self, gross_amount: f64) -> f64 { - ChinaAShareCostModel::default().commission_for(gross_amount) + self.cost_model().commission_for(gross_amount) } fn sell_cost(&self, date: NaiveDate, gross_amount: f64) -> f64 { - let model = ChinaAShareCostModel::default(); + let model = self.cost_model(); model.commission_for(gross_amount) + model.stamp_tax_for(date, OrderSide::Sell, gross_amount) } @@ -1974,36 +2032,47 @@ impl OmniMicroCapStrategy { } let mut max_fill = requested_qty; - let top_level_liquidity = match side { - OrderSide::Buy => snapshot.liquidity_for_buy(), - OrderSide::Sell => snapshot.liquidity_for_sell(), + let constraints = self.config.risk_config.trading_constraints; + if constraints.liquidity_limit_enabled { + let top_level_liquidity = match side { + OrderSide::Buy => snapshot.liquidity_for_buy(), + OrderSide::Sell => snapshot.liquidity_for_sell(), + } + .min(u32::MAX as u64) as u32; + if top_level_liquidity == 0 { + return None; + } + let liquidity_limited = if side == OrderSide::Sell && allow_odd_lot_sell { + top_level_liquidity + } else { + self.round_lot_quantity( + top_level_liquidity, + minimum_order_quantity, + order_step_size, + ) + }; + max_fill = max_fill.min(liquidity_limited); } - .min(u32::MAX as u64) as u32; - if top_level_liquidity == 0 { - return None; - } - let liquidity_limited = if side == OrderSide::Sell && allow_odd_lot_sell { - top_level_liquidity - } else { - self.round_lot_quantity(top_level_liquidity, minimum_order_quantity, order_step_size) - }; - max_fill = max_fill.min(liquidity_limited); let consumed_turnover = *execution_state.intraday_turnover.get(symbol).unwrap_or(&0); - let raw_limit = - ((snapshot.minute_volume as f64) * 0.25).round() as i64 - consumed_turnover as i64; - if raw_limit <= 0 { - return None; + if constraints.volume_limit_enabled { + let raw_limit = ((snapshot.minute_volume as f64) * constraints.volume_percent).round() + as i64 + - consumed_turnover as i64; + if raw_limit <= 0 { + return None; + } + let volume_limited = if side == OrderSide::Sell && allow_odd_lot_sell { + raw_limit as u32 + } else { + self.round_lot_quantity(raw_limit as u32, minimum_order_quantity, order_step_size) + }; + if volume_limited == 0 { + return None; + } + max_fill = max_fill.min(volume_limited); } - let volume_limited = if side == OrderSide::Sell && allow_odd_lot_sell { - raw_limit as u32 - } else { - self.round_lot_quantity(raw_limit as u32, minimum_order_quantity, order_step_size) - }; - if volume_limited == 0 { - return None; - } - Some(max_fill.min(volume_limited)) + Some(max_fill) } fn projected_execution_start_cursor( @@ -2338,13 +2407,14 @@ impl OmniMicroCapStrategy { let Ok(candidate) = ctx.data.require_candidate(date, symbol) else { return false; }; - ChinaAShareRiskControl::sell_rejection_reason( + ChinaAShareRiskControl::sell_rejection_reason_with_config( date, candidate, market, ctx.data.instrument(symbol), Some(position), ChinaAShareRiskControl::sell_check_price(market, PriceField::Last), + &self.config.risk_config, ) .is_none() } @@ -2358,12 +2428,13 @@ impl OmniMicroCapStrategy { let market = ctx.data.require_market(date, symbol)?; let candidate = ctx.data.require_candidate(date, symbol)?; - if let Some(reason) = ChinaAShareRiskControl::buy_rejection_reason( + if let Some(reason) = ChinaAShareRiskControl::buy_rejection_reason_with_config( date, candidate, market, ctx.data.instrument(symbol), ChinaAShareRiskControl::buy_check_price(market, PriceField::Last), + &self.config.risk_config, ) { return Ok(Some(reason.to_string())); } @@ -2375,6 +2446,64 @@ impl OmniMicroCapStrategy { Ok(None) } + fn selection_risk_decisions( + &self, + ctx: &StrategyContext<'_>, + date: NaiveDate, + ) -> Vec { + let mut decisions = Vec::new(); + for factor in ctx.data.factor_snapshots_on(date) { + if ctx.has_dynamic_universe() && !ctx.dynamic_universe_contains(&factor.symbol) { + continue; + } + let Some(candidate) = ctx.data.candidate(date, &factor.symbol) else { + continue; + }; + let Some(market) = ctx.data.market(date, &factor.symbol) else { + continue; + }; + if let Some(decision) = ChinaAShareRiskControl::selection_rejection_decision_with_config( + date, + candidate, + market, + ctx.data.instrument(&factor.symbol), + &self.config.risk_config, + ) { + decisions.push(decision); + } + } + decisions + } + + fn selection_risk_decision_diagnostics( + decisions: &[FidcRiskDecisionAudit], + sample_limit: usize, + ) -> Vec { + if decisions.is_empty() { + return Vec::new(); + } + let mut counts = BTreeMap::::new(); + for decision in decisions { + *counts.entry(decision.rule_code.clone()).or_insert(0) += 1; + } + let mut diagnostics = vec![format!( + "risk_decisions selection_total={} by_rule={}", + decisions.len(), + counts + .iter() + .map(|(rule, count)| format!("{rule}:{count}")) + .collect::>() + .join(",") + )]; + diagnostics.extend( + decisions + .iter() + .take(sample_limit) + .map(|decision| decision.diagnostic_line()), + ); + diagnostics + } + fn select_symbols( &self, ctx: &StrategyContext<'_>, @@ -2418,7 +2547,8 @@ impl OmniMicroCapStrategy { } if selected.len() < self.config.stocknum { - let universe = ctx.eligible_universe_on(date); + let universe = + ctx.eligible_universe_on_with_risk_config(date, &self.config.risk_config); let start = lower_bound_eligible(&universe, band_low); for candidate in universe.iter().skip(start) { if candidate.market_cap_bn > band_high { @@ -2453,7 +2583,7 @@ impl OmniMicroCapStrategy { return Ok((selected, diagnostics)); } - let universe = ctx.eligible_universe_on(date); + let universe = ctx.eligible_universe_on_with_risk_config(date, &self.config.risk_config); let mut diagnostics = Vec::new(); let mut selected = Vec::new(); let start = lower_bound_eligible(&universe, band_low); @@ -2654,6 +2784,7 @@ impl Strategy for OmniMicroCapStrategy { .collect(), notes: vec![format!("seasonal stop window on {}", date)], diagnostics: vec!["platform-native skip window forced all cash".to_string()], + risk_decisions: Vec::new(), }); } @@ -2672,6 +2803,7 @@ impl Strategy for OmniMicroCapStrategy { diagnostics: vec![ "insufficient history; skip trading on warmup dates".to_string(), ], + risk_decisions: Vec::new(), }); } Err(err) => return Err(err), @@ -2679,6 +2811,7 @@ impl Strategy for OmniMicroCapStrategy { // 使用前一交易日的指数价格计算市值区间(模拟实盘场景) let (band_low, band_high) = self.market_cap_band(prev_index_level); let (stock_list, selection_notes) = self.select_symbols(ctx, date, band_low, band_high)?; + let risk_decisions = self.selection_risk_decisions(ctx, date); let periodic_rebalance = ctx.decision_index % self.config.refresh_rate == 0; let mut projected = ctx.portfolio.clone(); let mut projected_execution_state = ProjectedExecutionState::default(); @@ -2848,6 +2981,10 @@ impl Strategy for OmniMicroCapStrategy { )); } diagnostics.extend(selection_notes); + diagnostics.extend(Self::selection_risk_decision_diagnostics( + &risk_decisions, + 5, + )); let notes = vec![ format!("stock_list={}", stock_list.len()), @@ -2861,6 +2998,7 @@ impl Strategy for OmniMicroCapStrategy { order_intents, notes, diagnostics, + risk_decisions, }) } } @@ -2882,6 +3020,7 @@ fn lower_bound_eligible(rows: &[crate::data::EligibleUniverseSnapshot], target: #[cfg(test)] mod tests { use super::*; + use crate::{BenchmarkSnapshot, CandidateEligibility, DailyFactorSnapshot}; use std::time::{SystemTime, UNIX_EPOCH}; fn temp_csv_path(name: &str) -> PathBuf { @@ -2927,4 +3066,168 @@ mod tests { Some("603657.SH") ); } + + #[test] + fn omni_microcap_projection_uses_configured_trading_cost() { + let mut cfg = OmniMicroCapConfig::omni_microcap(); + cfg.risk_config.trading_constraints.commission_rate = 0.0003; + cfg.risk_config.trading_constraints.minimum_commission = 5.0; + cfg.risk_config + .trading_constraints + .stamp_tax_rate_after_change = 0.0005; + let strategy = OmniMicroCapStrategy::new(cfg); + + assert!((strategy.buy_commission(100_000.0) - 30.0).abs() < 1e-9); + assert!((strategy.buy_commission(1_000.0) - 5.0).abs() < 1e-9); + assert!( + (strategy.sell_cost(NaiveDate::from_ymd_opt(2025, 1, 2).unwrap(), 100_000.0) - 80.0) + .abs() + < 1e-9 + ); + } + + #[test] + fn omni_microcap_selection_uses_configured_risk_policy() { + let dates = [ + NaiveDate::from_ymd_opt(2025, 1, 2).unwrap(), + NaiveDate::from_ymd_opt(2025, 1, 3).unwrap(), + NaiveDate::from_ymd_opt(2025, 1, 6).unwrap(), + ]; + let symbol = "688001.SH"; + let market_rows = dates + .iter() + .enumerate() + .map(|(index, date)| DailyMarketSnapshot { + date: *date, + symbol: symbol.to_string(), + timestamp: Some(format!("{date} 10:18:00")), + day_open: 10.0 + index as f64, + open: 10.0 + index as f64, + high: 10.4 + index as f64, + low: 9.8 + index as f64, + close: 10.2 + index as f64, + last_price: 10.2 + index as f64, + bid1: 10.1 + index as f64, + ask1: 10.2 + index as f64, + prev_close: 10.0 + index as f64, + volume: 1_000_000 + index as u64 * 100_000, + minute_volume: 10_000, + bid1_volume: 10_000, + ask1_volume: 10_000, + trading_phase: Some("continuous".to_string()), + paused: false, + upper_limit: 20.0, + lower_limit: 5.0, + price_tick: 0.01, + }) + .collect::>(); + let factor_rows = dates + .iter() + .map(|date| DailyFactorSnapshot { + date: *date, + symbol: symbol.to_string(), + market_cap_bn: 10.0, + free_float_cap_bn: 9.0, + pe_ttm: 12.0, + turnover_ratio: Some(1.0), + effective_turnover_ratio: Some(1.0), + extra_factors: BTreeMap::new(), + }) + .collect::>(); + let candidate_rows = dates + .iter() + .map(|date| CandidateEligibility { + date: *date, + symbol: symbol.to_string(), + is_st: false, + is_new_listing: false, + is_paused: false, + allow_buy: true, + allow_sell: true, + is_kcb: true, + is_one_yuan: false, + risk_level_code: None, + }) + .collect::>(); + let benchmark_rows = dates + .iter() + .map(|date| BenchmarkSnapshot { + date: *date, + benchmark: "000852.SH".to_string(), + open: 100.0, + close: 101.0, + prev_close: 99.0, + volume: 1_000_000, + }) + .collect::>(); + let data = DataSet::from_components( + vec![Instrument { + symbol: symbol.to_string(), + name: symbol.to_string(), + board: "SH".to_string(), + round_lot: 100, + listed_at: Some(NaiveDate::from_ymd_opt(2020, 1, 1).unwrap()), + delisted_at: None, + status: "active".to_string(), + }], + market_rows, + factor_rows, + candidate_rows, + benchmark_rows, + ) + .expect("dataset"); + let portfolio = PortfolioState::new(1_000_000.0); + let subscriptions = BTreeSet::new(); + let ctx = StrategyContext { + execution_date: dates[2], + decision_date: dates[2], + 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: None, + order_events: &[], + fills: &[], + }; + + let mut default_cfg = OmniMicroCapConfig::omni_microcap(); + default_cfg.strategy_name = "configured_risk_policy_test".to_string(); + default_cfg.stock_short_ma_days = 1; + default_cfg.stock_mid_ma_days = 2; + default_cfg.stock_long_ma_days = 3; + let default_strategy = OmniMicroCapStrategy::new(default_cfg.clone()); + let (default_selected, _) = default_strategy + .select_symbols(&ctx, dates[2], 0.0, 100.0) + .expect("default selection"); + assert!(default_selected.is_empty()); + let default_risk_decisions = default_strategy.selection_risk_decisions(&ctx, dates[2]); + assert_eq!(default_risk_decisions.len(), 1); + assert_eq!(default_risk_decisions[0].symbol, symbol); + assert_eq!(default_risk_decisions[0].rule_code, "kcb"); + assert!( + default_risk_decisions[0] + .diagnostic_line() + .starts_with("risk_decision=") + ); + + let mut cfg = default_cfg; + cfg.risk_config.static_rules.reject_kcb_selection = false; + cfg.risk_config.static_rules.reject_kcb_buy = false; + let configured_strategy = OmniMicroCapStrategy::new(cfg); + let (selected, _) = configured_strategy + .select_symbols(&ctx, dates[2], 0.0, 100.0) + .expect("configured selection"); + assert!( + configured_strategy + .selection_risk_decisions(&ctx, dates[2]) + .is_empty() + ); + + assert_eq!(selected, vec![symbol.to_string()]); + } } diff --git a/crates/fidc-core/src/strategy_ai.rs b/crates/fidc-core/src/strategy_ai.rs index 15a3843..e44a3e8 100644 --- a/crates/fidc-core/src/strategy_ai.rs +++ b/crates/fidc-core/src/strategy_ai.rs @@ -217,6 +217,10 @@ pub fn built_in_strategy_manual() -> StrategyAiManual { title: "filter.stock_expr / risk.stop_loss / risk.take_profit / allocation.buy_scale".to_string(), detail: "表达式型规则,支持多条组合。stop_loss/take_profit 多条按 OR 组合,filter.stock_expr 多条按 AND 组合。".to_string(), }, + ManualSection { + title: "risk.policy / risk.blacklist".to_string(), + detail: "统一配置 FIDC 基础风控。risk.policy(...) 支持 reject_st_selection、reject_st_buy、reject_paused_selection、reject_paused_buy、reject_paused_sell、reject_inactive_selection、reject_inactive_buy、reject_inactive_sell、reject_new_listing_selection、reject_new_listing_buy、reject_kcb_selection、reject_kcb_buy、reject_one_yuan_selection、reject_one_yuan_buy、respect_allow_buy_sell、reject_upper_limit_selection、reject_lower_limit_selection、reject_upper_limit_buy、reject_lower_limit_sell、forbid_same_day_rebuy_after_sell、volume_limit_enabled、volume_percent、commission_rate、minimum_commission、stamp_tax_rate_before_change、stamp_tax_rate_after_change、stamp_tax_change_date 等命名参数;risk.blacklist([\"600000.SH\"]) 写策略级黑名单。ST、停牌、退市、科创、一元、涨跌停、同日卖出禁买、成交量和费用等基础风控必须走 risk.policy 或运行态 RiskLimits,不要写进 universe.exclude 或 filter.stock_expr。PG/Source Lake 是真相源,Redis 只可做当日锁、热配置缓存和配置变更通知。".to_string(), + }, ManualSection { title: "corporate_actions.dividend_reinvestment".to_string(), detail: "支持 corporate_actions.dividend_reinvestment(true)。开启后,现金分红到账会优先按 round lot 回补成同一只股票,零头保留为现金。".to_string(), @@ -335,7 +339,7 @@ pub fn built_in_strategy_manual() -> StrategyAiManual { ManualFunction { name: "vma".to_string(), signature: "vma(60)".to_string(), detail: "rolling_mean(\"volume\", lookback) 的便捷别名,用于任意窗口成交量均线,例如 vma(5) < vma(60)。".to_string() }, ManualFunction { name: "rolling_sum / rolling_min / rolling_max".to_string(), signature: "rolling_sum(\"volume\", 20)".to_string(), detail: "任意数值字段滚动求和、最小值、最大值。可用于量能收缩、区间高低点、资金活跃度等过滤或排序。".to_string() }, ManualFunction { name: "rolling_stddev / stddev / rolling_zscore / pct_change".to_string(), signature: "stddev(\"close\", 20) / pct_change(\"close\", 10)".to_string(), detail: "滚动标准差、最新值 Z 分数和区间涨跌幅。pct_change(field, n) 会读取 n+1 个窗口点并计算 latest / first - 1。".to_string() }, - ManualFunction { name: "数据湖指标因子".to_string(), signature: "factor_value(\"ths_valid_turnover_stock\", 1)".to_string(), detail: "ficlaw-data 数据湖中已预计算的指标会进入 extra_factors,可用 factor(\"字段\")、factors[\"字段\"]、factor_value(\"字段\", lookback) 或 rolling_mean(\"字段\", n) 读取。市值类指标统一提供亿元口径别名 ths_market_value_stock、ths_market_value_stock_bn、ths_current_mv_stock、ths_current_mv_stock_bn,同时保留 raw 后缀原始值。".to_string() }, + ManualFunction { name: "Source Lake 指标因子".to_string(), signature: "factor_value(\"ths_valid_turnover_stock\", 1)".to_string(), detail: "Strategy Factory Source Lake 中已完成 PIT/as-of 审计的 source rows 字段、已发布指标或因子 artifact 会进入 extra_factors,可用 factor(\"字段\")、factors[\"字段\"]、factor_value(\"字段\", lookback) 或 rolling_mean(\"字段\", n) 读取。市值类指标统一提供亿元口径别名 ths_market_value_stock、ths_market_value_stock_bn、ths_current_mv_stock、ths_current_mv_stock_bn,同时保留 raw 后缀原始值。".to_string() }, ManualFunction { name: "round/floor/ceil/abs/min/max/clamp".to_string(), signature: "round(x)".to_string(), detail: "常用数值函数。".to_string() }, ManualFunction { name: "safe_div".to_string(), signature: "safe_div(lhs, rhs, fallback)".to_string(), detail: "安全除法。".to_string() }, ManualFunction { name: "contains/starts_with/ends_with/lower/upper/trim/strlen".to_string(), signature: "starts_with(symbol, \"60\")".to_string(), detail: "字符串辅助函数。".to_string() }, @@ -435,7 +439,8 @@ pub fn render_manual_markdown(manual: &StrategyAiManual) -> String { out.push_str("## AI 代码生成硬约束\n"); out.push_str("- 只输出完整 `engine-script` 代码;第一行必须是 `strategy(\"...\")`、`let`、`fn`、`const` 或 `//`。\n"); out.push_str("- 禁止输出 Markdown、解释、推理过程、JSON 包装、手册复述或结果报告。\n"); - out.push_str("- 只使用支持语句块:`market`、`benchmark`、`signal`、`rebalance.every_days(...).at([...])`、`selection.limit`、`selection.market_cap_band`、`filter.stock_ma`、`filter.stock_expr`、`ordering.rank_by`、`ordering.rank_expr`、`allocation.buy_scale`、`risk.stop_loss`、`risk.take_profit`、`risk.index_exposure`、`execution.matching_type`、`execution.slippage`、`universe.exclude`。\n"); + out.push_str("- 只使用支持语句块:`market`、`benchmark`、`signal`、`rebalance.every_days(...).at([...])`、`selection.limit`、`selection.market_cap_band`、`filter.stock_ma`、`filter.stock_expr`、`ordering.rank_by`、`ordering.rank_expr`、`allocation.buy_scale`、`risk.stop_loss`、`risk.take_profit`、`risk.index_exposure`、`risk.policy`、`risk.blacklist`、`execution.matching_type`、`execution.slippage`、`universe.exclude`。\n"); + out.push_str("- `universe.exclude` 只用于用户明确要求的业务排除项;ST、停牌、退市、新股、科创、一元、涨跌停、同日卖出禁买、成交量、手续费和印花税等基础风控必须写 `risk.policy(...)` 或由运行态 RiskLimits 注入。\n"); out.push_str("- 禁止伪 DSL:`filter(...)`、`rank(...)`、`select.top(...)`、`weight.equal(...)`、`sell_rule(...)`、`backtest(...)`、`risk.max_position(...)`。\n"); out.push_str("- 市值表达式字段只能用 `market_cap` 或 `free_float_cap`;不要使用数据库原始字段 `float_market_cap`。\n"); out.push_str("- 任意窗口价格均线使用 `rolling_mean(\"close\", n)` 或 `ma(\"close\", n)`;任意窗口均量使用 `rolling_mean(\"volume\", n)` 或 `vma(n)`;不要使用未列出的 `ma60`、`stock_ma60`、`signal_ma60` 或 `benchmark_ma60` 变量。\n"); @@ -444,6 +449,7 @@ pub fn render_manual_markdown(manual: &StrategyAiManual) -> String { out.push_str( "- `risk.index_exposure(...)` 只能传一个表达式;不要生成 `risk.exposure(...)`。\n", ); + out.push_str("- `filter.stock_expr(...)` 只写 alpha 或业务过滤条件;不要把 `!is_st`、`!paused`、`!at_upper_limit`、`!at_lower_limit` 这类基础风控散落在过滤表达式里。\n"); out.push_str("- 完整三元表达式 `cond ? a : b` 可在表达式参数中使用;若当前运行环境报 `Unknown operator: '?'`,先重编译并重启回测服务,不要改写策略语义掩盖运行时漂移。\n"); out.push_str("- `next_bar_open` 的选股、排序和仓位信号来自决策日,订单在下一可交易开盘撮合;不要使用执行日价格作为下单前信号。\n"); out.push_str("- `execution.matching_type(...)` 和 `execution.slippage(...)` 必须使用手册列出的合法取值。\n\n"); @@ -517,17 +523,17 @@ pub fn build_generation_prompt( prompt.push_str("- 不要输出解释文本。\n"); prompt.push_str("- 必须使用 strategy(\"...\") { ... } 语法。\n"); prompt.push_str("- 如需自定义参数,使用 let 和 fn。\n"); - prompt.push_str("- 优先使用 ficlaw-data 数据湖和运行时已存在字段、factors[...]。\n\n"); + prompt.push_str("- 优先使用 Strategy Factory Source Lake 已注册 source rows 字段、已发布指标/因子 artifact 和运行时已存在字段、factors[...];不要回退 ficlaw-data、QuantAPI、旧数据中心 HTTP、ClickHouse 或临时文件。\n\n"); prompt.push_str("- 生成的代码必须能转换为 strategy_spec 并提交 POST /v1/backtests。\n"); prompt.push_str("- 用户指定“持仓N只、目标持仓N、stocknum=N、selection.limit(N)”时,必须把最终持仓槽位写成 N;用户指定“至少/不少于N只”时,最终持仓槽位必须 >= N。\n"); prompt.push_str("- "); prompt.push_str(DEFAULT_THREE_YEAR_RETURN_TARGET_PROMPT); prompt.push('\n'); prompt.push_str("- 不要使用手册未列出的字段、函数或外部平台 API 名称。\n\n"); - prompt.push_str("只允许使用这些可编译语句:market、benchmark、signal、rebalance.every_days(...).at([...])、selection.limit、selection.market_cap_band、filter.stock_ma、filter.stock_expr、ordering.rank_by、ordering.rank_expr、allocation.buy_scale、risk.stop_loss、risk.take_profit、risk.index_exposure、execution.matching_type、execution.slippage、universe.exclude。禁止输出 filter(...)、rank(...)、select.top(...)、weight.equal()、sell_rule(...)、backtest(...)、risk.max_position(...) 这类未支持伪语法。\n"); - prompt.push_str("参数形态必须严格:selection.market_cap_band 必须写 field=\"market_cap\" 或 field=\"free_float_cap\", lower=..., upper=...;禁止使用 float_market_cap;禁止使用 ma60、stock_ma60、signal_ma60、benchmark_ma60,60日价格均线写 rolling_mean(\"close\", 60) 或 ma(\"close\", 60),任意窗口均量写 rolling_mean(\"volume\", n) 或 vma(n);不要生成 fn score() 这类零参数函数,股票字段排序直接写在 ordering.rank_expr 内或用带参数函数;布尔字段按布尔使用,写 !is_st、!paused、!at_upper_limit、!at_lower_limit,不要写 is_st == 0;risk.index_exposure 只能传一个数值表达式,不要使用 risk.exposure;完整三元表达式 cond ? a : b 可以使用,但不得输出残缺问号/冒号片段;日线回测 execution.matching_type 只能取 current_bar_close 或 next_bar_open,分钟线回测只能取 minute_last;不要把 vwap、twap、open_auction、minute_best_own、minute_best_counterparty 写成全局 matching_type;next_bar_open 只能使用决策日信号,不能把执行日价格当作下单前信息;execution.slippage 必须写 execution.slippage(\"none\") 或 execution.slippage(\"price_ratio\", 0.001)。\n"); + prompt.push_str("只允许使用这些可编译语句:market、benchmark、signal、rebalance.every_days(...).at([...])、selection.limit、selection.market_cap_band、filter.stock_ma、filter.stock_expr、ordering.rank_by、ordering.rank_expr、allocation.buy_scale、risk.stop_loss、risk.take_profit、risk.index_exposure、risk.policy、risk.blacklist、execution.matching_type、execution.slippage、universe.exclude。universe.exclude 只用于用户明确要求的业务排除项,不能表达 FIDC 基础风控。禁止输出 filter(...)、rank(...)、select.top(...)、weight.equal()、sell_rule(...)、backtest(...)、risk.max_position(...) 这类未支持伪语法。\n"); + prompt.push_str("参数形态必须严格:selection.market_cap_band 必须写 field=\"market_cap\" 或 field=\"free_float_cap\", lower=..., upper=...;禁止使用 float_market_cap;禁止使用 ma60、stock_ma60、signal_ma60、benchmark_ma60,60日价格均线写 rolling_mean(\"close\", 60) 或 ma(\"close\", 60),任意窗口均量写 rolling_mean(\"volume\", n) 或 vma(n);不要生成 fn score() 这类零参数函数,股票字段排序直接写在 ordering.rank_expr 内或用带参数函数;布尔字段按布尔使用,不要写 is_st == 0;filter.stock_expr 只写 alpha 或业务过滤条件,不要把 !is_st、!paused、!at_upper_limit、!at_lower_limit 这类基础风控散落在表达式里;risk.index_exposure 只能传一个数值表达式,不要使用 risk.exposure;risk.policy 只写 FIDC 基础风控、成交量和交易成本命名参数,例如 reject_st_selection=true、reject_paused_selection=true、reject_inactive_selection=true、reject_new_listing_selection=true、reject_kcb_selection=true、reject_one_yuan_selection=true、forbid_same_day_rebuy_after_sell=true、reject_upper_limit_selection=true、reject_lower_limit_selection=true、reject_upper_limit_buy=true、reject_lower_limit_sell=true、volume_limit_enabled=true、volume_percent=0.25、commission_rate=0.0003、minimum_commission=5、stamp_tax_rate_after_change=0.0005,不要用它表达策略择时或收益规则;完整三元表达式 cond ? a : b 可以使用,但不得输出残缺问号/冒号片段;日线回测 execution.matching_type 只能取 current_bar_close 或 next_bar_open,分钟线回测只能取 minute_last;不要把 vwap、twap、open_auction、minute_best_own、minute_best_counterparty 写成全局 matching_type;next_bar_open 只能使用决策日信号,不能把执行日价格当作下单前信息;execution.slippage 必须写 execution.slippage(\"none\") 或 execution.slippage(\"price_ratio\", 0.001)。\n"); prompt.push_str("回测成功但 tradeCount=0 或 holdingCount=0 是无效策略;第一版必须保持稳定买入覆盖率,复杂因子只能在后续优化中逐步加严。\n"); - prompt.push_str("可参考但不要照抄的最小模板,回复时不要包含 ``` 代码围栏:\nstrategy(\"cn_a_smallcap_factor_rotation\") {\nmarket(\"CN_A\")\nbenchmark(\"000852.SH\")\nsignal(\"000001.SH\")\nrebalance.every_days(5).at([\"10:18\"])\nselection.limit(40)\nselection.market_cap_band(field=\"market_cap\", lower=0, upper=1000)\nfilter.stock_expr(listed_days >= 60 && !is_st && !paused && close > 2 && !at_upper_limit && !at_lower_limit)\nordering.rank_by(\"market_cap\", \"asc\")\nallocation.buy_scale(1.0)\nrisk.index_exposure(1.0)\nrisk.stop_loss(holding_return < -0.08)\nexecution.slippage(\"price_ratio\", 0.001)\n}\n\n"); + prompt.push_str("可参考但不要照抄的最小模板,回复时不要包含 ``` 代码围栏:\nstrategy(\"cn_a_smallcap_factor_rotation\") {\nmarket(\"CN_A\")\nbenchmark(\"000852.SH\")\nsignal(\"000001.SH\")\nrebalance.every_days(5).at([\"10:18\"])\nselection.limit(40)\nselection.market_cap_band(field=\"market_cap\", lower=0, upper=1000)\nfilter.stock_expr(listed_days >= 60 && close > 2)\nordering.rank_by(\"market_cap\", \"asc\")\nallocation.buy_scale(1.0)\nrisk.policy(reject_st_selection=true, reject_paused_selection=true, reject_inactive_selection=true, reject_new_listing_selection=true, reject_kcb_selection=true, reject_one_yuan_selection=true, reject_upper_limit_selection=true, reject_lower_limit_selection=true, reject_upper_limit_buy=true, reject_lower_limit_sell=true, forbid_same_day_rebuy_after_sell=true, volume_limit_enabled=true, volume_percent=0.25, commission_rate=0.0003, minimum_commission=5)\nrisk.index_exposure(1.0)\nrisk.stop_loss(holding_return < -0.08)\nexecution.slippage(\"price_ratio\", 0.001)\n}\n\n"); prompt.push_str("用户目标:\n"); prompt.push_str(&format!("- {}\n", request.user_goal)); if !request.constraints.is_empty() { @@ -557,7 +563,7 @@ pub fn build_optimization_prompt( prompt.push_str("持仓数量属于策略合同,不是优化自由参数。原策略或用户目标明确 stocknum、selection.limit、目标持仓N只或不少于N只时,优化后必须保留该目标槽位或满足最低槽位,不能为了收益或交易次数擅自改小。\n"); prompt.push_str(DEFAULT_THREE_YEAR_RETURN_TARGET_PROMPT); prompt.push('\n'); - prompt.push_str("可以使用所有已入库日频字段、指标因子和表达式函数,例如 rolling_mean/ma/vma/rolling_sum/rolling_stddev/pct_change/factor/factor_value/factors;如上一轮无交易或质量分过低,必须先扩大候选覆盖并修正不可交易过滤,再优化收益。\n"); + prompt.push_str("可以使用 Strategy Factory Source Lake 已注册并完成 PIT/as-of 审计的日频 source rows 字段、已发布指标/因子 artifact 和表达式函数,例如 rolling_mean/ma/vma/rolling_sum/rolling_stddev/pct_change/factor/factor_value/factors;不要回退 ficlaw-data、QuantAPI、旧数据中心 HTTP、ClickHouse 或临时文件。如上一轮无交易或质量分过低,必须先扩大候选覆盖并修正不可交易过滤,再优化收益。\n"); prompt.push_str("优化目标:\n"); prompt.push_str(&format!("- {}\n\n", request.objective)); prompt.push_str("当前策略代码如下,仅作为输入参考;回复时不要包含 Markdown 代码围栏:\n"); @@ -600,6 +606,9 @@ mod tests { assert!(prompt.contains("三年回测区间策略总收益 >= 150% 即视为满足收益目标")); assert!(prompt.contains("不得把已达标策略判为失败")); + assert!(prompt.contains("Strategy Factory Source Lake 已注册 source rows 字段")); + assert!(prompt.contains("不要回退 ficlaw-data")); + assert!(prompt.contains("ClickHouse")); } #[test] @@ -616,5 +625,8 @@ mod tests { assert!(prompt.contains("三年回测区间策略总收益 >= 150% 即视为满足收益目标")); assert!(prompt.contains("继续优化夏普、回撤、换手和稳定性")); + assert!(prompt.contains("Strategy Factory Source Lake 已注册并完成 PIT/as-of 审计")); + assert!(prompt.contains("不要回退 ficlaw-data")); + assert!(prompt.contains("ClickHouse")); } } diff --git a/crates/fidc-core/src/universe.rs b/crates/fidc-core/src/universe.rs index 7383cb8..8545fb8 100644 --- a/crates/fidc-core/src/universe.rs +++ b/crates/fidc-core/src/universe.rs @@ -4,6 +4,7 @@ use chrono::NaiveDate; use serde::Serialize; use crate::data::{BenchmarkSnapshot, DataSet, EligibleUniverseSnapshot}; +use crate::risk_control::{ChinaAShareRiskControl, FidcRiskControlConfig, FidcRiskDecisionAudit}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum BandRegime { @@ -39,6 +40,7 @@ pub struct SelectionDiagnostics { pub missing_market_cap_symbols: Vec, pub selected_symbols: Vec, pub rejection_examples: Vec, + pub risk_decisions: Vec, } pub struct SelectionContext<'a> { @@ -47,20 +49,61 @@ pub struct SelectionContext<'a> { pub reference_level: f64, pub data: &'a DataSet, pub dynamic_universe: Option<&'a BTreeSet>, + pub risk_config: Option<&'a FidcRiskControlConfig>, } impl SelectionContext<'_> { fn eligible_universe(&self) -> Vec { - let eligible = self.data.eligible_universe_on(self.decision_date); + let eligible = match self.risk_config { + Some(risk_config) => self + .data + .eligible_universe_on_with_risk_config(self.decision_date, risk_config), + None => self.data.eligible_universe_on(self.decision_date).to_vec(), + }; match self.dynamic_universe { Some(symbols) if !symbols.is_empty() => eligible - .iter() + .into_iter() .filter(|row| symbols.contains(&row.symbol)) - .cloned() .collect(), - _ => eligible.to_vec(), + _ => eligible, } } + + fn selection_risk_decisions(&self) -> Vec { + let default_risk_config; + let risk_config = match self.risk_config { + Some(value) => value, + None => { + default_risk_config = FidcRiskControlConfig::default(); + &default_risk_config + } + }; + let mut decisions = Vec::new(); + for factor in self.data.factor_snapshots_on(self.decision_date) { + if self + .dynamic_universe + .is_some_and(|symbols| !symbols.is_empty() && !symbols.contains(&factor.symbol)) + { + continue; + } + let Some(candidate) = self.data.candidate(self.decision_date, &factor.symbol) else { + continue; + }; + let Some(market) = self.data.market(self.decision_date, &factor.symbol) else { + continue; + }; + if let Some(decision) = ChinaAShareRiskControl::selection_rejection_decision_with_config( + self.decision_date, + candidate, + market, + self.data.instrument(&factor.symbol), + risk_config, + ) { + decisions.push(decision); + } + } + decisions + } } pub trait UniverseSelector { @@ -166,9 +209,23 @@ impl UniverseSelector for DynamicMarketCapBandSelector { missing_market_cap_symbols: Vec::new(), selected_symbols: Vec::new(), rejection_examples: Vec::new(), + risk_decisions: Vec::new(), }; diagnostics.factor_total = ctx.data.factor_snapshots_on(ctx.decision_date).len(); + diagnostics.risk_decisions = ctx.selection_risk_decisions(); + diagnostics.not_eligible_count = diagnostics.risk_decisions.len(); + diagnostics.paused_count = diagnostics + .risk_decisions + .iter() + .filter(|decision| decision.rule_code == "paused") + .count(); + diagnostics.rejection_examples = diagnostics + .risk_decisions + .iter() + .take(8) + .map(|decision| format!("{} rejected by {}", decision.symbol, decision.rule_code)) + .collect(); let eligible = ctx.eligible_universe(); diagnostics.market_cap_missing_count = diagnostics.factor_total.saturating_sub(eligible.len()); @@ -221,3 +278,147 @@ fn to_universe_candidate( band_high, } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::data::{ + BenchmarkSnapshot, CandidateEligibility, DailyFactorSnapshot, DailyMarketSnapshot, DataSet, + }; + use crate::instrument::Instrument; + + fn d() -> NaiveDate { + NaiveDate::from_ymd_opt(2025, 1, 2).unwrap() + } + + fn instrument(symbol: &str) -> Instrument { + Instrument { + symbol: symbol.to_string(), + name: symbol.to_string(), + board: symbol.rsplit('.').next().unwrap_or("").to_string(), + round_lot: 100, + listed_at: Some(NaiveDate::from_ymd_opt(2020, 1, 1).unwrap()), + delisted_at: None, + status: "active".to_string(), + } + } + + fn market(symbol: &str, price: f64) -> DailyMarketSnapshot { + DailyMarketSnapshot { + date: d(), + symbol: symbol.to_string(), + timestamp: Some("2025-01-02 10:00:00".to_string()), + day_open: price, + open: price, + high: price, + low: price, + close: price, + last_price: price, + bid1: price, + ask1: price, + prev_close: price, + volume: 1_000_000, + minute_volume: 10_000, + bid1_volume: 10_000, + ask1_volume: 10_000, + trading_phase: Some("continuous".to_string()), + paused: false, + upper_limit: price * 1.1, + lower_limit: price * 0.9, + price_tick: 0.01, + } + } + + fn factor(symbol: &str, market_cap_bn: f64) -> DailyFactorSnapshot { + DailyFactorSnapshot { + date: d(), + symbol: symbol.to_string(), + market_cap_bn, + free_float_cap_bn: market_cap_bn, + pe_ttm: 10.0, + turnover_ratio: Some(0.01), + effective_turnover_ratio: Some(0.01), + extra_factors: Default::default(), + } + } + + fn candidate(symbol: &str, is_st: bool, is_kcb: bool) -> CandidateEligibility { + CandidateEligibility { + date: d(), + symbol: symbol.to_string(), + is_st, + is_new_listing: false, + is_paused: false, + allow_buy: true, + allow_sell: true, + is_kcb, + is_one_yuan: false, + risk_level_code: None, + } + } + + fn benchmark() -> BenchmarkSnapshot { + BenchmarkSnapshot { + date: d(), + benchmark: "000852.SH".to_string(), + open: 2000.0, + close: 2000.0, + prev_close: 1990.0, + volume: 1_000_000, + } + } + + #[test] + fn selector_records_structured_selection_risk_decisions() { + let data = DataSet::from_components( + vec![ + instrument("000001.SZ"), + instrument("688001.SH"), + instrument("000002.SZ"), + ], + vec![ + market("000001.SZ", 10.0), + market("688001.SH", 10.0), + market("000002.SZ", 10.0), + ], + vec![ + factor("000001.SZ", 8.0), + factor("688001.SH", 9.0), + factor("000002.SZ", 10.0), + ], + vec![ + candidate("000001.SZ", true, false), + candidate("688001.SH", false, true), + candidate("000002.SZ", false, false), + ], + vec![benchmark()], + ) + .unwrap(); + let selector = DynamicMarketCapBandSelector::new(2000.0, 7.0, 10.0, 0.0, 10, 0.0, 0.0, 0.0); + let (_selected, diagnostics) = selector.select_with_diagnostics(&SelectionContext { + decision_date: d(), + benchmark: &benchmark(), + reference_level: 2000.0, + data: &data, + dynamic_universe: None, + risk_config: Some(&FidcRiskControlConfig::default()), + }); + + let rules = diagnostics + .risk_decisions + .iter() + .map(|decision| decision.rule_code.as_str()) + .collect::>(); + assert!(rules.contains("st"), "{:?}", diagnostics.risk_decisions); + assert!(rules.contains("kcb"), "{:?}", diagnostics.risk_decisions); + assert_eq!( + diagnostics.not_eligible_count, + diagnostics.risk_decisions.len() + ); + assert!( + diagnostics.risk_decisions[0] + .diagnostic_line() + .starts_with("risk_decision=") + ); + } +} diff --git a/crates/fidc-core/tests/core_rules.rs b/crates/fidc-core/tests/core_rules.rs index e458249..d9830a7 100644 --- a/crates/fidc-core/tests/core_rules.rs +++ b/crates/fidc-core/tests/core_rules.rs @@ -71,11 +71,11 @@ fn aiquant_cost_model_matches_alv_run_options() { let model = ChinaAShareCostModel::aiquant_default(); let buy = model.calculate(d(2026, 5, 19), OrderSide::Buy, 49_978.84); - assert!((buy.commission - 12.49471).abs() < 1e-9); + assert!((buy.commission - 14.993652).abs() < 1e-9); assert_eq!(buy.stamp_tax, 0.0); let sell = model.calculate(d(2026, 5, 19), OrderSide::Sell, 100_724.72); - assert!((sell.commission - 25.18118).abs() < 1e-9); + assert!((sell.commission - 30.217416).abs() < 1e-9); assert!((sell.stamp_tax - 50.36236).abs() < 1e-9); let small_buy = model.calculate(d(2026, 5, 19), OrderSide::Buy, 1_000.0); diff --git a/crates/fidc-core/tests/corporate_actions.rs b/crates/fidc-core/tests/corporate_actions.rs index 7ede34e..bd458fd 100644 --- a/crates/fidc-core/tests/corporate_actions.rs +++ b/crates/fidc-core/tests/corporate_actions.rs @@ -88,6 +88,7 @@ impl Strategy for BuyAndHoldStrategy { }, notes: Vec::new(), diagnostics: Vec::new(), + risk_decisions: Vec::new(), }) } } diff --git a/crates/fidc-core/tests/delisting.rs b/crates/fidc-core/tests/delisting.rs index 74dceff..1608d4d 100644 --- a/crates/fidc-core/tests/delisting.rs +++ b/crates/fidc-core/tests/delisting.rs @@ -34,6 +34,7 @@ impl Strategy for BuyThenHoldStrategy { }], notes: Vec::new(), diagnostics: Vec::new(), + risk_decisions: Vec::new(), }); } Ok(StrategyDecision::default()) diff --git a/crates/fidc-core/tests/engine_hooks.rs b/crates/fidc-core/tests/engine_hooks.rs index fc4669b..cfa07f2 100644 --- a/crates/fidc-core/tests/engine_hooks.rs +++ b/crates/fidc-core/tests/engine_hooks.rs @@ -294,6 +294,7 @@ impl Strategy for HookProbeStrategy { order_intents: Vec::new(), notes: Vec::new(), diagnostics: Vec::new(), + risk_decisions: Vec::new(), }) } @@ -336,6 +337,7 @@ impl Strategy for AuctionOrderStrategy { }], notes: Vec::new(), diagnostics: Vec::new(), + risk_decisions: Vec::new(), }) } @@ -384,6 +386,7 @@ impl Strategy for FuturesOrderStrategy { }], notes: Vec::new(), diagnostics: Vec::new(), + risk_decisions: Vec::new(), }) } } @@ -716,6 +719,7 @@ impl Strategy for LimitCarryStrategy { }], notes: Vec::new(), diagnostics: Vec::new(), + risk_decisions: Vec::new(), }) } } @@ -793,6 +797,7 @@ impl Strategy for UniverseDirectiveStrategy { order_intents, notes: Vec::new(), diagnostics: Vec::new(), + risk_decisions: Vec::new(), }) } } @@ -816,6 +821,7 @@ impl Strategy for MinuteProbeStrategy { }], notes: Vec::new(), diagnostics: Vec::new(), + risk_decisions: Vec::new(), }) } @@ -857,6 +863,7 @@ impl Strategy for MinuteProbeStrategy { }], notes: Vec::new(), diagnostics: Vec::new(), + risk_decisions: Vec::new(), }) } } @@ -958,6 +965,7 @@ impl Strategy for OrderInspectionStrategy { }], notes: Vec::new(), diagnostics: Vec::new(), + risk_decisions: Vec::new(), }) } @@ -1015,6 +1023,7 @@ impl Strategy for AccountFlowStrategy { ], notes: Vec::new(), diagnostics: Vec::new(), + risk_decisions: Vec::new(), }) } @@ -3765,6 +3774,7 @@ impl Strategy for BuyMissingRowThenHoldStrategy { }], notes: Vec::new(), diagnostics: Vec::new(), + risk_decisions: Vec::new(), }); } Ok(StrategyDecision::default()) diff --git a/crates/fidc-core/tests/explicit_order_flow.rs b/crates/fidc-core/tests/explicit_order_flow.rs index 73dc4e8..356360d 100644 --- a/crates/fidc-core/tests/explicit_order_flow.rs +++ b/crates/fidc-core/tests/explicit_order_flow.rs @@ -2,8 +2,9 @@ use chrono::{NaiveDate, NaiveTime}; use fidc_core::{ AlgoOrderStyle, BenchmarkSnapshot, BrokerSimulator, CandidateEligibility, ChinaAShareCostModel, ChinaEquityRuleHooks, DailyFactorSnapshot, DailyMarketSnapshot, DataSet, DynamicSlippageConfig, - Instrument, IntradayExecutionQuote, MatchingType, OrderIntent, OrderStatus, PortfolioState, - PriceField, ProcessEventKind, SlippageModel, StrategyDecision, TargetPortfolioOrderPricing, + FidcRiskControlConfig, Instrument, IntradayExecutionQuote, MatchingType, OrderIntent, + OrderStatus, PortfolioState, PriceField, ProcessEventKind, SlippageModel, StrategyDecision, + TargetPortfolioOrderPricing, }; use std::collections::{BTreeMap, BTreeSet}; @@ -104,12 +105,87 @@ fn execute_single_value_order( }], notes: Vec::new(), diagnostics: Vec::new(), + risk_decisions: Vec::new(), }, ) .expect("broker execution"); (portfolio, report) } +fn single_symbol_limit_price_data( + date: NaiveDate, + symbol: &str, + price: f64, + upper_limit: f64, + lower_limit: f64, +) -> DataSet { + DataSet::from_components( + vec![Instrument { + symbol: symbol.to_string(), + name: "Test".to_string(), + board: "SZ".to_string(), + round_lot: 100, + listed_at: Some(NaiveDate::from_ymd_opt(2020, 1, 1).unwrap()), + delisted_at: None, + status: "active".to_string(), + }], + vec![DailyMarketSnapshot { + date, + symbol: symbol.to_string(), + timestamp: Some(format!("{date} 10:18:00")), + day_open: price, + open: price, + high: price, + low: price, + close: price, + last_price: price, + bid1: price, + ask1: price, + prev_close: 10.0, + volume: 1_000_000, + minute_volume: 1_000_000, + bid1_volume: 500_000, + ask1_volume: 500_000, + trading_phase: Some("continuous".to_string()), + paused: false, + upper_limit, + lower_limit, + price_tick: 0.01, + }], + vec![DailyFactorSnapshot { + date, + symbol: symbol.to_string(), + market_cap_bn: 50.0, + free_float_cap_bn: 45.0, + pe_ttm: 15.0, + turnover_ratio: Some(2.0), + effective_turnover_ratio: Some(1.8), + extra_factors: BTreeMap::new(), + }], + vec![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, + risk_level_code: None, + }], + vec![BenchmarkSnapshot { + date, + benchmark: "000300.SH".to_string(), + open: 100.0, + close: 100.0, + prev_close: 99.0, + volume: 1_000_000, + }], + ) + .expect("dataset") +} + #[test] fn broker_executes_explicit_order_value_buy() { let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap(); @@ -215,6 +291,7 @@ fn broker_executes_explicit_order_value_buy() { }], notes: Vec::new(), diagnostics: Vec::new(), + risk_decisions: Vec::new(), }, ) .expect("broker execution"); @@ -370,6 +447,7 @@ fn broker_delayed_limit_open_sell_uses_minute_price() { }], notes: Vec::new(), diagnostics: Vec::new(), + risk_decisions: Vec::new(), }, ) .expect("broker execution"); @@ -504,6 +582,7 @@ fn broker_executes_order_shares_and_order_lots() { ], notes: Vec::new(), diagnostics: Vec::new(), + risk_decisions: Vec::new(), }, ) .expect("broker execution"); @@ -613,6 +692,7 @@ fn broker_executes_target_shares_like_order_to() { }], notes: Vec::new(), diagnostics: Vec::new(), + risk_decisions: Vec::new(), }, ) .expect("broker execution"); @@ -793,6 +873,7 @@ fn broker_executes_target_portfolio_smart_with_custom_prices() { }], notes: Vec::new(), diagnostics: Vec::new(), + risk_decisions: Vec::new(), }, ) .expect("broker execution"); @@ -943,6 +1024,7 @@ fn broker_executes_target_portfolio_smart_with_algo_order_style() { }], notes: Vec::new(), diagnostics: Vec::new(), + risk_decisions: Vec::new(), }, ) .expect("broker execution"); @@ -1050,6 +1132,7 @@ fn broker_executes_order_percent_and_target_percent() { }], notes: Vec::new(), diagnostics: Vec::new(), + risk_decisions: Vec::new(), }, ) .expect("percent execution"); @@ -1073,6 +1156,7 @@ fn broker_executes_order_percent_and_target_percent() { }], notes: Vec::new(), diagnostics: Vec::new(), + risk_decisions: Vec::new(), }, ) .expect("target percent execution"); @@ -1172,6 +1256,7 @@ fn broker_uses_day_open_price_for_open_auction_matching() { }], notes: Vec::new(), diagnostics: Vec::new(), + risk_decisions: Vec::new(), }, ) .expect("broker execution"); @@ -1276,6 +1361,7 @@ fn broker_open_auction_uses_auction_volume_without_quote_liquidity() { }], notes: Vec::new(), diagnostics: Vec::new(), + risk_decisions: Vec::new(), }, ) .expect("broker execution"); @@ -1376,6 +1462,7 @@ fn broker_cancels_buy_when_open_hits_upper_limit() { }], notes: Vec::new(), diagnostics: Vec::new(), + risk_decisions: Vec::new(), }, ) .expect("broker execution"); @@ -1490,6 +1577,7 @@ fn broker_applies_price_ratio_slippage_on_snapshot_fills() { }], notes: Vec::new(), diagnostics: Vec::new(), + risk_decisions: Vec::new(), }, ) .expect("broker execution"); @@ -1592,6 +1680,7 @@ fn broker_applies_dynamic_slippage_on_snapshot_fills() { }], notes: Vec::new(), diagnostics: Vec::new(), + risk_decisions: Vec::new(), }, ) .expect("broker execution"); @@ -1710,6 +1799,7 @@ fn broker_applies_tick_size_slippage_on_intraday_last_fills() { }], notes: Vec::new(), diagnostics: Vec::new(), + risk_decisions: Vec::new(), }, ) .expect("broker execution"); @@ -1813,6 +1903,7 @@ fn broker_rejects_intraday_last_order_without_execution_quotes() { }], notes: Vec::new(), diagnostics: Vec::new(), + risk_decisions: Vec::new(), }, ) .expect("broker execution"); @@ -1934,6 +2025,7 @@ fn broker_executes_intraday_last_on_start_quote_without_trade_delta() { }], notes: Vec::new(), diagnostics: Vec::new(), + risk_decisions: Vec::new(), }, ) .expect("broker execution"); @@ -2050,6 +2142,7 @@ fn broker_cancels_market_order_remainder_when_intraday_quote_liquidity_exhausted }], notes: Vec::new(), diagnostics: Vec::new(), + risk_decisions: Vec::new(), }, ) .expect("broker execution"); @@ -2163,6 +2256,7 @@ fn broker_cancels_market_buy_when_minute_has_no_volume() { }], notes: Vec::new(), diagnostics: Vec::new(), + risk_decisions: Vec::new(), }, ) .expect("broker execution"); @@ -2296,6 +2390,7 @@ fn broker_splits_intraday_quote_fills_and_tracks_commission_by_order() { }], notes: Vec::new(), diagnostics: Vec::new(), + risk_decisions: Vec::new(), }, ) .expect("broker execution"); @@ -2458,6 +2553,7 @@ fn broker_aggregates_intraday_quote_fills_into_vwap_leg() { }], notes: Vec::new(), diagnostics: Vec::new(), + risk_decisions: Vec::new(), }, ) .expect("broker execution"); @@ -2631,6 +2727,7 @@ fn broker_executes_algo_vwap_value_with_time_window() { }], notes: Vec::new(), diagnostics: Vec::new(), + risk_decisions: Vec::new(), }, ) .expect("broker execution"); @@ -2776,6 +2873,7 @@ fn broker_executes_algo_twap_percent_across_window_quotes() { }], notes: Vec::new(), diagnostics: Vec::new(), + risk_decisions: Vec::new(), }, ) .expect("broker execution"); @@ -2906,6 +3004,7 @@ fn broker_uses_best_own_price_for_intraday_matching() { }], notes: Vec::new(), diagnostics: Vec::new(), + risk_decisions: Vec::new(), }, ) .expect("broker execution"); @@ -3021,6 +3120,7 @@ fn broker_uses_best_counterparty_price_for_intraday_matching() { }], notes: Vec::new(), diagnostics: Vec::new(), + risk_decisions: Vec::new(), }, ) .expect("broker execution"); @@ -3183,6 +3283,7 @@ fn rebalance_optimizer_skips_unfunded_buy_when_existing_position_cannot_sell() { order_intents: Vec::new(), notes: Vec::new(), diagnostics: Vec::new(), + risk_decisions: Vec::new(), }, ) .expect("broker execution"); @@ -3376,6 +3477,7 @@ fn rebalance_uses_prev_close_for_open_auction_valuation() { order_intents: Vec::new(), notes: Vec::new(), diagnostics: Vec::new(), + risk_decisions: Vec::new(), }, ) .expect("broker execution"); @@ -3555,6 +3657,7 @@ fn rebalance_optimizer_prioritizes_higher_target_weight_when_cash_is_tight() { order_intents: Vec::new(), notes: Vec::new(), diagnostics: Vec::new(), + risk_decisions: Vec::new(), }, ) .expect("broker execution"); @@ -3681,6 +3784,7 @@ fn broker_uses_board_specific_min_quantity_and_step_size_for_buy_sizing() { }], notes: Vec::new(), diagnostics: Vec::new(), + risk_decisions: Vec::new(), }, ) .expect("broker execution"); @@ -3780,6 +3884,7 @@ fn broker_allows_bjse_quantities_above_minimum_without_round_lot_step() { }], notes: Vec::new(), diagnostics: Vec::new(), + risk_decisions: Vec::new(), }, ) .expect("broker execution"); @@ -3883,6 +3988,7 @@ fn broker_allows_full_odd_lot_sell_when_liquidating_position() { }], notes: Vec::new(), diagnostics: Vec::new(), + risk_decisions: Vec::new(), }, ) .expect("broker execution"); @@ -3894,7 +4000,7 @@ fn broker_allows_full_odd_lot_sell_when_liquidating_position() { } #[test] -fn same_day_sell_then_rebuy_reinserts_position_at_end() { +fn same_day_sell_then_rebuy_is_rejected_by_default() { let prev_date = NaiveDate::from_ymd_opt(2024, 1, 9).unwrap(); let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap(); let symbols = ["000001.SZ", "000002.SZ", "000003.SZ"]; @@ -4020,6 +4126,149 @@ fn same_day_sell_then_rebuy_reinserts_position_at_end() { ], notes: Vec::new(), diagnostics: Vec::new(), + risk_decisions: Vec::new(), + }, + ) + .expect("broker execution"); + + let symbols = portfolio.positions().keys().cloned().collect::>(); + assert_eq!( + symbols, + vec!["000001.SZ".to_string(), "000003.SZ".to_string()] + ); +} + +#[test] +fn same_day_sell_then_rebuy_can_be_allowed_by_policy() { + let prev_date = NaiveDate::from_ymd_opt(2024, 1, 9).unwrap(); + let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap(); + let symbols = ["000001.SZ", "000002.SZ", "000003.SZ"]; + let instruments = symbols + .iter() + .map(|symbol| Instrument { + symbol: (*symbol).to_string(), + name: (*symbol).to_string(), + board: "SZ".to_string(), + round_lot: 100, + listed_at: None, + delisted_at: None, + status: "active".to_string(), + }) + .collect::>(); + let market = symbols + .iter() + .map(|symbol| DailyMarketSnapshot { + date, + symbol: (*symbol).to_string(), + timestamp: Some("2024-01-10 10:18:00".to_string()), + day_open: 10.0, + open: 10.0, + high: 10.1, + low: 9.9, + close: 10.0, + last_price: 10.0, + bid1: 9.99, + ask1: 10.01, + prev_close: 10.0, + volume: 100_000, + minute_volume: 100_000, + bid1_volume: 80_000, + ask1_volume: 80_000, + trading_phase: Some("continuous".to_string()), + paused: false, + upper_limit: 11.0, + lower_limit: 9.0, + price_tick: 0.01, + }) + .collect::>(); + let factors = symbols + .iter() + .map(|symbol| DailyFactorSnapshot { + date, + symbol: (*symbol).to_string(), + market_cap_bn: 50.0, + free_float_cap_bn: 45.0, + pe_ttm: 15.0, + turnover_ratio: Some(2.0), + effective_turnover_ratio: Some(1.8), + extra_factors: BTreeMap::new(), + }) + .collect::>(); + let candidates = 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, + risk_level_code: None, + }) + .collect::>(); + let data = DataSet::from_components( + instruments, + market, + factors, + candidates, + vec![BenchmarkSnapshot { + date, + benchmark: "000300.SH".to_string(), + open: 100.0, + close: 100.0, + prev_close: 99.0, + volume: 1_000_000, + }], + ) + .expect("dataset"); + + let mut portfolio = PortfolioState::new(1_000_000.0); + portfolio + .position_mut("000001.SZ") + .buy(prev_date, 100, 10.0); + portfolio + .position_mut("000002.SZ") + .buy(prev_date, 100, 10.0); + portfolio + .position_mut("000003.SZ") + .buy(prev_date, 100, 10.0); + + let mut risk_config = FidcRiskControlConfig::default(); + risk_config.static_rules.forbid_same_day_rebuy_after_sell = false; + let broker = BrokerSimulator::new_with_execution_price( + ChinaAShareCostModel::default(), + ChinaEquityRuleHooks::default(), + PriceField::Open, + ) + .with_risk_config(risk_config); + + broker + .execute( + date, + &mut portfolio, + &data, + &StrategyDecision { + rebalance: false, + target_weights: BTreeMap::new(), + exit_symbols: BTreeSet::new(), + order_intents: vec![ + OrderIntent::TargetValue { + symbol: "000002.SZ".to_string(), + target_value: 0.0, + reason: "sell_then_rebuy".to_string(), + }, + OrderIntent::Value { + symbol: "000002.SZ".to_string(), + value: 10_000.0, + reason: "sell_then_rebuy".to_string(), + }, + ], + notes: Vec::new(), + diagnostics: Vec::new(), + risk_decisions: Vec::new(), }, ) .expect("broker execution"); @@ -4035,6 +4284,94 @@ fn same_day_sell_then_rebuy_reinserts_position_at_end() { ); } +#[test] +fn broker_configured_policy_can_allow_upper_limit_buy() { + let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap(); + let data = single_symbol_limit_price_data(date, "000002.SZ", 11.0, 11.0, 9.0); + let mut risk_config = FidcRiskControlConfig::default(); + risk_config.static_rules.reject_upper_limit_buy = false; + let broker = BrokerSimulator::new_with_execution_price( + ChinaAShareCostModel::default(), + ChinaEquityRuleHooks::default(), + PriceField::Open, + ) + .with_risk_config(risk_config); + let mut portfolio = PortfolioState::new(1_000_000.0); + + let report = broker + .execute( + date, + &mut portfolio, + &data, + &StrategyDecision { + rebalance: false, + target_weights: BTreeMap::new(), + exit_symbols: BTreeSet::new(), + order_intents: vec![OrderIntent::Value { + symbol: "000002.SZ".to_string(), + value: 10_000.0, + reason: "configured_upper_limit_buy".to_string(), + }], + notes: Vec::new(), + diagnostics: Vec::new(), + risk_decisions: Vec::new(), + }, + ) + .expect("broker execution"); + + assert_eq!(report.fill_events.len(), 1); + assert_eq!( + report.order_events.last().map(|event| event.status), + Some(OrderStatus::Filled) + ); +} + +#[test] +fn broker_configured_policy_can_allow_lower_limit_sell() { + let prev_date = NaiveDate::from_ymd_opt(2024, 1, 9).unwrap(); + let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap(); + let data = single_symbol_limit_price_data(date, "000002.SZ", 9.0, 11.0, 9.0); + let mut risk_config = FidcRiskControlConfig::default(); + risk_config.static_rules.reject_lower_limit_sell = false; + let broker = BrokerSimulator::new_with_execution_price( + ChinaAShareCostModel::default(), + ChinaEquityRuleHooks::default(), + PriceField::Open, + ) + .with_risk_config(risk_config); + let mut portfolio = PortfolioState::new(1_000_000.0); + portfolio + .position_mut("000002.SZ") + .buy(prev_date, 1_000, 10.0); + + let report = broker + .execute( + date, + &mut portfolio, + &data, + &StrategyDecision { + rebalance: false, + target_weights: BTreeMap::new(), + exit_symbols: BTreeSet::new(), + order_intents: vec![OrderIntent::TargetValue { + symbol: "000002.SZ".to_string(), + target_value: 0.0, + reason: "configured_lower_limit_sell".to_string(), + }], + notes: Vec::new(), + diagnostics: Vec::new(), + risk_decisions: Vec::new(), + }, + ) + .expect("broker execution"); + + assert_eq!(report.fill_events.len(), 1); + assert_eq!( + report.order_events.last().map(|event| event.status), + Some(OrderStatus::Filled) + ); +} + fn two_day_limit_order_data(day1_open: f64, day2_open: f64) -> DataSet { let day1 = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap(); let day2 = NaiveDate::from_ymd_opt(2024, 1, 11).unwrap(); @@ -4195,6 +4532,7 @@ fn broker_rejects_open_limit_buy_at_market_close() { }], notes: Vec::new(), diagnostics: Vec::new(), + risk_decisions: Vec::new(), }, ) .expect("day1 execution"); @@ -4229,6 +4567,7 @@ fn broker_rejects_open_limit_buy_at_market_close() { order_intents: Vec::new(), notes: Vec::new(), diagnostics: Vec::new(), + risk_decisions: Vec::new(), }, ) .expect("day2 execution"); @@ -4266,6 +4605,7 @@ fn broker_uses_limit_price_slippage_for_limit_orders() { }], notes: Vec::new(), diagnostics: Vec::new(), + risk_decisions: Vec::new(), }, ) .expect("broker execution"); @@ -4303,6 +4643,7 @@ fn broker_rejects_limit_buy_when_final_execution_price_reaches_upper_limit() { }], notes: Vec::new(), diagnostics: Vec::new(), + risk_decisions: Vec::new(), }, ) .expect("broker execution"); @@ -4346,6 +4687,7 @@ fn broker_executes_limit_value_and_limit_percent_intents() { }], notes: Vec::new(), diagnostics: Vec::new(), + risk_decisions: Vec::new(), }, ) .expect("broker execution"); @@ -4370,6 +4712,7 @@ fn broker_executes_limit_value_and_limit_percent_intents() { }], notes: Vec::new(), diagnostics: Vec::new(), + risk_decisions: Vec::new(), }, ) .expect("broker execution"); @@ -4405,6 +4748,7 @@ fn broker_cancels_open_order_by_order_id() { }], notes: Vec::new(), diagnostics: Vec::new(), + risk_decisions: Vec::new(), }, ) .expect("day1 execution"); @@ -4425,6 +4769,7 @@ fn broker_cancels_open_order_by_order_id() { }], notes: Vec::new(), diagnostics: Vec::new(), + risk_decisions: Vec::new(), }, ) .expect("day2 execution"); @@ -4471,6 +4816,7 @@ fn broker_emits_cancellation_reject_for_unknown_order() { }], notes: Vec::new(), diagnostics: Vec::new(), + risk_decisions: Vec::new(), }, ) .expect("cancel reject execution"); @@ -4585,6 +4931,7 @@ fn broker_reserves_sellable_quantity_for_open_limit_sells() { ], notes: Vec::new(), diagnostics: Vec::new(), + risk_decisions: Vec::new(), }, ) .expect("broker execution");