实现FIDC配置化风控与交易成本

This commit is contained in:
boris
2026-07-02 07:16:47 +08:00
parent 754fc91376
commit 7db0e8da1d
17 changed files with 2689 additions and 249 deletions
+336 -33
View File
@@ -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<crate::data::EligibleUniverseSnapshot> {
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<OrderIntent>,
pub notes: Vec<String>,
pub diagnostics: Vec<String>,
pub risk_decisions: Vec<FidcRiskDecisionAudit>,
}
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<String>,
pub skip_months: Vec<u32>,
pub skip_month_day_ranges: Vec<(Option<u32>, 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::<String, usize>::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::<Vec<_>>()
.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, 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<FidcRiskDecisionAudit> {
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<String> {
if decisions.is_empty() {
return Vec::new();
}
let mut counts = BTreeMap::<String, usize>::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::<Vec<_>>()
.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::<Vec<_>>();
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::<Vec<_>>();
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::<Vec<_>>();
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::<Vec<_>>();
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()]);
}
}