diff --git a/crates/fidc-core/src/broker.rs b/crates/fidc-core/src/broker.rs index 3148b5f..c7bc8cc 100644 --- a/crates/fidc-core/src/broker.rs +++ b/crates/fidc-core/src/broker.rs @@ -2388,6 +2388,19 @@ where .sum() } + fn reserved_open_buy_quantity(&self, symbol: &str, exclude_order_id: Option) -> u32 { + self.open_orders + .borrow() + .iter() + .filter(|order| { + order.side == OrderSide::Buy + && order.symbol == symbol + && exclude_order_id.is_none_or(|order_id| order.order_id != order_id) + }) + .map(|order| order.remaining_quantity) + .sum() + } + fn process_open_orders( &self, date: NaiveDate, @@ -4251,6 +4264,49 @@ where return Ok(()); } + let size_check_price = limit_price.unwrap_or_else(|| { + self.execution_order_limit_check_price( + date, + data, + symbol, + snapshot, + OrderSide::Sell, + algo_request, + ) + }); + if let Some(rule_reason) = ChinaAShareRiskControl::order_size_rejection_reason_with_config( + OrderSide::Sell, + requested_qty, + position.quantity, + size_check_price, + &self.risk_config, + ) { + report.order_events.push(OrderEvent { + date, + decision_date: None, + order_created_date: None, + execution_date: None, + order_id: Some(order_id), + symbol: symbol.to_string(), + side: OrderSide::Sell, + requested_quantity: requested_qty, + filled_quantity: 0, + status: OrderStatus::Rejected, + reason: format!("{reason}: {rule_reason}"), + }); + Self::emit_order_process_event( + report, + date, + Self::creation_reject_kind(emit_creation_events), + order_id, + symbol, + OrderSide::Sell, + format!("status=Rejected reason={rule_reason}"), + ); + self.clear_open_order(order_id); + return Ok(()); + } + if emit_creation_events { Self::emit_order_process_event( report, @@ -5984,6 +6040,54 @@ where return Ok(()); } + let current_position_quantity = portfolio + .position(symbol) + .map(|position| position.quantity) + .unwrap_or(0) + .saturating_add(self.reserved_open_buy_quantity(symbol, Some(order_id))); + let size_check_price = limit_price.unwrap_or_else(|| { + self.execution_order_limit_check_price( + date, + data, + symbol, + snapshot, + OrderSide::Buy, + algo_request, + ) + }); + if let Some(rule_reason) = ChinaAShareRiskControl::order_size_rejection_reason_with_config( + OrderSide::Buy, + requested_qty, + current_position_quantity, + size_check_price, + &self.risk_config, + ) { + report.order_events.push(OrderEvent { + date, + decision_date: None, + order_created_date: None, + execution_date: None, + order_id: Some(order_id), + symbol: symbol.to_string(), + side: OrderSide::Buy, + requested_quantity: requested_qty, + filled_quantity: 0, + status: OrderStatus::Rejected, + reason: format!("{reason}: {rule_reason}"), + }); + Self::emit_order_process_event( + report, + date, + Self::creation_reject_kind(emit_creation_events), + order_id, + symbol, + OrderSide::Buy, + format!("status=Rejected reason={rule_reason}"), + ); + self.clear_open_order(order_id); + return Ok(()); + } + if emit_creation_events { Self::emit_order_process_event( report, diff --git a/crates/fidc-core/src/lib.rs b/crates/fidc-core/src/lib.rs index 1f82d36..c2241db 100644 --- a/crates/fidc-core/src/lib.rs +++ b/crates/fidc-core/src/lib.rs @@ -79,6 +79,7 @@ pub use platform_strategy_spec::{ StrategyRebalanceSpec, StrategyRiskPolicySpec, StrategyRuntimeEnvironment, StrategyRuntimeExpressions, StrategyRuntimeSpec, StrategyUniverseSpec, platform_expr_config_from_spec, platform_expr_config_from_value, + validate_strategy_risk_policy_fields, }; pub use portfolio::{CashReceivable, HoldingSummary, PendingCashFlow, PortfolioState, Position}; pub use risk_control::{ diff --git a/crates/fidc-core/src/platform_strategy_spec.rs b/crates/fidc-core/src/platform_strategy_spec.rs index 11274f1..ba89260 100644 --- a/crates/fidc-core/src/platform_strategy_spec.rs +++ b/crates/fidc-core/src/platform_strategy_spec.rs @@ -268,6 +268,12 @@ pub struct StrategyEngineConfig { #[derive(Debug, Clone, Default, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct StrategyRiskPolicySpec { + #[serde(default, alias = "max_order_quantity", alias = "maxOrderQuantity")] + pub max_order_quantity: Option, + #[serde(default, alias = "max_order_notional", alias = "maxOrderNotional")] + pub max_order_notional: Option, + #[serde(default, alias = "max_symbol_position", alias = "maxSymbolPosition")] + pub max_symbol_position: Option, #[serde(default, alias = "reject_st_selection", alias = "rejectStSelection")] pub reject_st_selection: Option, #[serde(default, alias = "reject_st_buy", alias = "rejectStBuy")] @@ -412,6 +418,9 @@ const RISK_POLICY_BOOL_ALIAS_GROUPS: &[(&str, &[&str])] = &[ ]; const RISK_POLICY_VALUE_ALIAS_GROUPS: &[(&str, &[&str])] = &[ + ("maxOrderQuantity", &["max_order_quantity"]), + ("maxOrderNotional", &["max_order_notional"]), + ("maxSymbolPosition", &["max_symbol_position"]), ("volumePercent", &["volume_percent"]), ("commissionRate", &["commission_rate"]), ( @@ -1042,6 +1051,7 @@ pub fn platform_expr_config_from_value( .map_err(platform_config_error); } reject_removed_compatibility_fields(value).map_err(platform_config_error)?; + validate_strategy_risk_policy_fields(value).map_err(platform_config_error)?; let mut value = value.clone(); normalize_strategy_aliases_in_value(&mut value).map_err(platform_config_error)?; normalize_risk_policy_aliases_in_value(&mut value).map_err(platform_config_error)?; @@ -1050,6 +1060,69 @@ pub fn platform_expr_config_from_value( .map_err(platform_config_error) } +/// Reject misspelled or unsupported fields inside a strategy risk policy. +/// Generic JSON deserialization otherwise ignores unknown keys and makes a +/// strategy appear protected while silently using the process default. +pub fn validate_strategy_risk_policy_fields(value: &Value) -> Result<(), String> { + let mut allowed = BTreeSet::<&str>::new(); + for (canonical, aliases) in RISK_POLICY_BOOL_ALIAS_GROUPS + .iter() + .chain(RISK_POLICY_VALUE_ALIAS_GROUPS.iter()) + { + allowed.insert(*canonical); + for key in *aliases { + allowed.insert(*key); + } + } + for key in [ + "blacklistedSymbols", + "blacklisted_symbols", + "blacklistedInstruments", + "blacklisted_instruments", + "blacklist", + // Legacy execution aliases are accepted by StrategyExecutionSpec and + // normalized into the same shared switches. + "volumeLimit", + "volume_limit", + "liquidityLimit", + "liquidity_limit", + ] { + allowed.insert(key); + } + + fn walk(value: &Value, allowed: &BTreeSet<&str>, path: &str) -> Result<(), String> { + let Some(object) = value.as_object() else { + return Ok(()); + }; + for (key, child) in object { + let child_path = if path.is_empty() { + key.clone() + } else { + format!("{path}.{key}") + }; + if matches!(key.as_str(), "riskPolicy" | "risk_policy") { + if child.is_null() { + // Typed specs serialize an omitted Option policy as null; + // that is equivalent to an absent strategy policy. + continue; + } + let Some(policy) = child.as_object() else { + return Err(format!("{child_path} must be a JSON object")); + }; + for policy_key in policy.keys() { + if !allowed.contains(policy_key.as_str()) { + return Err(format!("unsupported riskPolicy field: {policy_key}")); + } + } + } + walk(child, allowed, &child_path)?; + } + Ok(()) + } + + walk(value, &allowed, "") +} + fn reject_removed_compatibility_fields(value: &Value) -> Result<(), String> { const SECTION_NAMES: [&str; 3] = ["engineConfig", "engine_config", "execution"]; const FIELD_NAMES: [&str; 4] = [ @@ -1095,6 +1168,14 @@ fn valid_non_negative(value: Option) -> Option { value.filter(|item| item.is_finite() && *item >= 0.0) } +fn valid_positive_limit(value: Option, field_name: &str) -> Result, String> { + match value { + None => Ok(None), + Some(item) if item.is_finite() && item > 0.0 => Ok(Some(item)), + Some(_) => Err(format!("{field_name} must be a positive finite number")), + } +} + fn normalize_percent_ratio(value: f64, field_name: &str) -> Result { if !value.is_finite() || value <= 0.0 { return Err(format!("{field_name} must be a positive finite number")); @@ -1214,6 +1295,27 @@ fn apply_risk_policy_overrides( let Some(policy) = policy else { return Ok(()); }; + let max_order_quantity = valid_positive_limit( + policy.max_order_quantity, + "riskPolicy.maxOrderQuantity", + )?; + let max_order_notional = valid_positive_limit( + policy.max_order_notional, + "riskPolicy.maxOrderNotional", + )?; + let max_symbol_position = valid_positive_limit( + policy.max_symbol_position, + "riskPolicy.maxSymbolPosition", + )?; + if let Some(value) = max_order_quantity { + cfg.risk_config.trading_constraints.max_order_quantity = value; + } + if let Some(value) = max_order_notional { + cfg.risk_config.trading_constraints.max_order_notional = value; + } + if let Some(value) = max_symbol_position { + cfg.risk_config.trading_constraints.max_symbol_position = value; + } let static_rules = &mut cfg.risk_config.static_rules; if let Some(value) = policy.reject_st_selection { static_rules.reject_st_selection = value; @@ -3287,6 +3389,9 @@ mod tests { "rejectUpperLimitBuy": false, "rejectLowerLimitSell": false, "forbidSameDayRebuyAfterSell": true, + "maxOrderQuantity": 8000, + "maxOrderNotional": 2000000, + "maxSymbolPosition": 12000, "blacklist": [" 600000.SH ", ""], "volumeLimitEnabled": true, "volumePercent": 0.1, @@ -3328,6 +3433,9 @@ mod tests { ); assert!(cfg.risk_config.trading_constraints.volume_limit_enabled); assert!(cfg.risk_config.trading_constraints.liquidity_limit_enabled); + assert_eq!(cfg.risk_config.trading_constraints.max_order_quantity, 8000.0); + assert_eq!(cfg.risk_config.trading_constraints.max_order_notional, 2_000_000.0); + assert_eq!(cfg.risk_config.trading_constraints.max_symbol_position, 12_000.0); assert!((cfg.risk_config.trading_constraints.volume_percent - 0.25).abs() < 1e-12); assert_eq!( cfg.risk_config @@ -3350,6 +3458,22 @@ mod tests { assert!(cfg.quote_quantity_limit); } + #[test] + fn rejects_non_positive_shared_order_limits() { + for (field, value) in [ + ("maxOrderQuantity", 0.0), + ("maxOrderNotional", -1.0), + ("maxSymbolPosition", -0.5), + ] { + let spec = serde_json::json!({ + "execution": { "riskPolicy": { field: value } } + }); + let error = platform_expr_config_from_value("", "", &spec) + .expect_err("invalid shared order limit must fail"); + assert!(error.to_string().contains("riskPolicy")); + } + } + #[test] fn volume_limit_does_not_enable_quote_quantity_limit_for_minute_last() { let spec = serde_json::json!({ @@ -3482,6 +3606,21 @@ mod tests { ); } + #[test] + fn rejects_unknown_strategy_risk_policy_fields_before_deserialization() { + let error = validate_strategy_risk_policy_fields(&serde_json::json!({ + "execution": { + "riskPolicy": {"rejectStBuy": true, "typoRiskSwitch": false} + } + })) + .expect_err("unsupported risk policy fields must fail closed"); + assert!(error.contains("typoRiskSwitch"), "{error}"); + validate_strategy_risk_policy_fields(&serde_json::json!({ + "execution": {"riskPolicy": {"maxOrderQuantity": 1000}} + })) + .expect("supported strategy risk fields should pass"); + } + #[test] fn accepts_equivalent_risk_policy_alias_values() { let spec = serde_json::json!({ diff --git a/crates/fidc-core/src/risk_control.rs b/crates/fidc-core/src/risk_control.rs index 44daf88..aa50e59 100644 --- a/crates/fidc-core/src/risk_control.rs +++ b/crates/fidc-core/src/risk_control.rs @@ -6,6 +6,7 @@ use serde::{Deserialize, Serialize}; use crate::data::{CandidateEligibility, DailyMarketSnapshot, PriceField}; use crate::instrument::Instrument; use crate::portfolio::Position; +use crate::OrderSide; #[derive(Debug, Clone, Copy, Default)] pub struct ChinaAShareRiskControl; @@ -77,6 +78,13 @@ impl Default for StaticRiskRuleConfig { #[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] pub struct TradingConstraintConfig { + /// Shared execution limits. These fields intentionally use the same + /// names and defaults as the FIDC trading-core RiskLimits contract so a + /// strategy cannot appear protected in paper/live while being unlimited + /// in a backtest. + pub max_order_quantity: f64, + pub max_order_notional: f64, + pub max_symbol_position: f64, pub volume_limit_enabled: bool, pub volume_percent: f64, pub liquidity_limit_enabled: bool, @@ -91,6 +99,9 @@ pub struct TradingConstraintConfig { impl Default for TradingConstraintConfig { fn default() -> Self { Self { + max_order_quantity: 1_000_000.0, + max_order_notional: 100_000_000.0, + max_symbol_position: 10_000_000.0, volume_limit_enabled: true, volume_percent: 0.25, liquidity_limit_enabled: true, @@ -479,6 +490,36 @@ impl ChinaAShareRiskControl { None } + /// Apply the shared quantity/notional/position limits at the same stage + /// as paper/live `RiskLimits`. Static instrument rules remain in the + /// side-specific methods above; this helper only checks order sizing and + /// never changes selection semantics. + pub fn order_size_rejection_reason_with_config( + side: OrderSide, + requested_quantity: u32, + current_position_quantity: u32, + check_price: f64, + config: &FidcRiskControlConfig, + ) -> Option<&'static str> { + let limits = &config.trading_constraints; + if (requested_quantity as f64) > limits.max_order_quantity { + return Some("quantity exceeds max_order_quantity"); + } + if check_price.is_finite() + && check_price > 0.0 + && (requested_quantity as f64) * check_price > limits.max_order_notional + { + return Some("notional exceeds max_order_notional"); + } + if side == OrderSide::Buy + && (current_position_quantity as f64) + (requested_quantity as f64) + > limits.max_symbol_position + { + return Some("position exceeds max_symbol_position"); + } + None + } + pub fn sell_rejection_reason( date: NaiveDate, candidate: &CandidateEligibility, @@ -1426,4 +1467,53 @@ mod tests { assert_eq!(enabled_reason, Some("lower_limit")); assert_eq!(configured_reason, None); } + + #[test] + fn shared_order_size_limits_apply_to_both_sides_and_buy_position() { + let mut config = FidcRiskControlConfig::default(); + config.trading_constraints.max_order_quantity = 500.0; + config.trading_constraints.max_order_notional = 5_000.0; + config.trading_constraints.max_symbol_position = 800.0; + + assert_eq!( + ChinaAShareRiskControl::order_size_rejection_reason_with_config( + OrderSide::Buy, + 600, + 0, + 5.0, + &config, + ), + Some("quantity exceeds max_order_quantity") + ); + assert_eq!( + ChinaAShareRiskControl::order_size_rejection_reason_with_config( + OrderSide::Sell, + 400, + 10_000, + 20.0, + &config, + ), + Some("notional exceeds max_order_notional") + ); + assert_eq!( + ChinaAShareRiskControl::order_size_rejection_reason_with_config( + OrderSide::Buy, + 300, + 600, + 5.0, + &config, + ), + Some("position exceeds max_symbol_position") + ); + assert_eq!( + ChinaAShareRiskControl::order_size_rejection_reason_with_config( + OrderSide::Sell, + 200, + 10_000, + 5.0, + &config, + ), + None + ); + } } diff --git a/crates/fidc-core/src/strategy_ai.rs b/crates/fidc-core/src/strategy_ai.rs index 553fea5..2b711b1 100644 --- a/crates/fidc-core/src/strategy_ai.rs +++ b/crates/fidc-core/src/strategy_ai.rs @@ -119,8 +119,8 @@ pub struct StrategyAiOptimizeRequest { } const PERFORMANCE_ACCEPTANCE_CONTRACT_PROMPT: &str = "收益验收合同:收益、回撤、年度收益、样本外区间及比较运算符只能来自用户目标、请求约束或不可变 candidate/promotion contract;不得注入 120% 或其他默认数值,也不得提高、降低或替换已经明确的门槛。没有明确数值合同时只做策略有效性、数据时序和风险审计,禁止声称收益已经达标;存在冻结合同时必须逐项按原运算符验证,不能只看总收益。"; -const DEFAULT_RISK_POLICY_DSL_PROMPT: &str = "reject_st_selection=false、reject_st_buy=true、reject_star_st_selection=false、reject_star_st_buy=true、reject_paused_selection=false、reject_paused_buy=true、reject_paused_sell=true、reject_inactive_selection=false、reject_inactive_buy=true、reject_inactive_sell=true、reject_new_listing_selection=false、reject_new_listing_buy=true、reject_kcb_selection=false、reject_kcb_buy=true、reject_bjse_selection=false、reject_bjse_buy=true、reject_one_yuan_selection=false、reject_one_yuan_buy=true、respect_allow_buy_sell=true、reject_upper_limit_selection=false、reject_lower_limit_selection=false、reject_upper_limit_buy=true、reject_lower_limit_sell=true、forbid_same_day_rebuy_after_sell=true、blacklist_enabled=true、allow_market_orders=true、live_trading_enabled=false、volume_limit_enabled=true、liquidity_limit_enabled=true、volume_percent=0.25、commission_rate=0.0003、minimum_commission=5、stamp_tax_rate_before_change=0.001、stamp_tax_rate_after_change=0.0005、stamp_tax_change_date=\"2023-08-28\""; -const DEFAULT_RISK_POLICY_DSL_CODE: &str = "reject_st_selection=false, reject_st_buy=true, reject_star_st_selection=false, reject_star_st_buy=true, reject_paused_selection=false, reject_paused_buy=true, reject_paused_sell=true, reject_inactive_selection=false, reject_inactive_buy=true, reject_inactive_sell=true, reject_new_listing_selection=false, reject_new_listing_buy=true, reject_kcb_selection=false, reject_kcb_buy=true, reject_bjse_selection=false, reject_bjse_buy=true, reject_one_yuan_selection=false, reject_one_yuan_buy=true, respect_allow_buy_sell=true, reject_upper_limit_selection=false, reject_lower_limit_selection=false, reject_upper_limit_buy=true, reject_lower_limit_sell=true, forbid_same_day_rebuy_after_sell=true, blacklist_enabled=true, allow_market_orders=true, live_trading_enabled=false, volume_limit_enabled=true, liquidity_limit_enabled=true, volume_percent=0.25, commission_rate=0.0003, minimum_commission=5, stamp_tax_rate_before_change=0.001, stamp_tax_rate_after_change=0.0005, stamp_tax_change_date=\"2023-08-28\""; +const DEFAULT_RISK_POLICY_DSL_PROMPT: &str = "max_order_quantity=1000000、max_order_notional=100000000、max_symbol_position=10000000、reject_st_selection=false、reject_st_buy=true、reject_star_st_selection=false、reject_star_st_buy=true、reject_paused_selection=false、reject_paused_buy=true、reject_paused_sell=true、reject_inactive_selection=false、reject_inactive_buy=true、reject_inactive_sell=true、reject_new_listing_selection=false、reject_new_listing_buy=true、reject_kcb_selection=false、reject_kcb_buy=true、reject_bjse_selection=false、reject_bjse_buy=true、reject_one_yuan_selection=false、reject_one_yuan_buy=true、respect_allow_buy_sell=true、reject_upper_limit_selection=false、reject_lower_limit_selection=false、reject_upper_limit_buy=true、reject_lower_limit_sell=true、forbid_same_day_rebuy_after_sell=true、blacklist_enabled=true、allow_market_orders=true、live_trading_enabled=false、volume_limit_enabled=true、liquidity_limit_enabled=true、volume_percent=0.25、commission_rate=0.0003、minimum_commission=5、stamp_tax_rate_before_change=0.001、stamp_tax_rate_after_change=0.0005、stamp_tax_change_date=\"2023-08-28\""; +const DEFAULT_RISK_POLICY_DSL_CODE: &str = "max_order_quantity=1000000, max_order_notional=100000000, max_symbol_position=10000000, reject_st_selection=false, reject_st_buy=true, reject_star_st_selection=false, reject_star_st_buy=true, reject_paused_selection=false, reject_paused_buy=true, reject_paused_sell=true, reject_inactive_selection=false, reject_inactive_buy=true, reject_inactive_sell=true, reject_new_listing_selection=false, reject_new_listing_buy=true, reject_kcb_selection=false, reject_kcb_buy=true, reject_bjse_selection=false, reject_bjse_buy=true, reject_one_yuan_selection=false, reject_one_yuan_buy=true, respect_allow_buy_sell=true, reject_upper_limit_selection=false, reject_lower_limit_selection=false, reject_upper_limit_buy=true, reject_lower_limit_sell=true, forbid_same_day_rebuy_after_sell=true, blacklist_enabled=true, allow_market_orders=true, live_trading_enabled=false, volume_limit_enabled=true, liquidity_limit_enabled=true, volume_percent=0.25, commission_rate=0.0003, minimum_commission=5, stamp_tax_rate_before_change=0.001, stamp_tax_rate_after_change=0.0005, stamp_tax_change_date=\"2023-08-28\""; pub fn built_in_strategy_manual() -> StrategyAiManual { StrategyAiManual { @@ -250,7 +250,7 @@ pub fn built_in_strategy_manual() -> StrategyAiManual { }, ManualSection { title: "risk.policy / risk.blacklist".to_string(), - detail: "统一配置 FIDC 基础风控。risk.policy(...) 支持 reject_st_selection、reject_st_buy、reject_star_st_selection、reject_star_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_bjse_selection、reject_bjse_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、blacklist_enabled、allow_market_orders、live_trading_enabled、blacklisted_symbols、volume_limit_enabled、liquidity_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、*ST、停牌、退市、科创、北交所、一元、涨跌停、同日卖出禁买、黑名单、成交量和费用等基础风控必须走 risk.policy 或运行态 RiskLimits,不能被转换器隐式写进 universe.exclude 或 filter.stock_expr;但源策略明确写出的业务选股排除属于策略本身,必须原样保留在 filter.stock_expr,并且不能反向修改冻结的 reject_*_selection 开关。PG/Source Lake 是真相源,Redis 只可做当日锁、热配置缓存和配置变更通知。".to_string(), + detail: "统一配置 FIDC 基础风控。risk.policy(...) 支持 max_order_quantity、max_order_notional、max_symbol_position,以及 ST/*ST、停牌、退市、新股、科创、北交所、一元、涨跌停、同日卖出禁买、黑名单、成交量、流动性和交易成本等命名参数;risk.blacklist([\"600000.SH\"]) 写策略级黑名单。框架默认基础风控必须走 risk.policy 或运行态 RiskLimits,不能被转换器隐式写进 universe.exclude 或 filter.stock_expr;源策略明确写出的业务选股排除属于策略本身,必须原样保留,不能反向修改冻结的 reject_*_selection 开关;冻结的 `reject_*_selection` 值不得改变。PG/Source Lake 是真相源,Redis 只可做当日锁、热配置缓存和配置变更通知。".to_string(), }, ManualSection { title: "corporate_actions.dividend_reinvestment".to_string(),