统一回测策略风控字段校验

This commit is contained in:
boris
2026-08-29 14:52:03 +08:00
parent ff2844884d
commit f3a37157fc
5 changed files with 337 additions and 3 deletions
+90
View File
@@ -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
);
}
}