实现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
+65 -9
View File
@@ -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<C, R> {
strict_value_budget: bool,
aiquant_execution_rules: bool,
same_day_buy_close_mark_at_fill: bool,
risk_config: FidcRiskControlConfig,
same_day_sold_symbols: RefCell<BTreeMap<NaiveDate, BTreeSet<String>>>,
intraday_execution_start_time: Option<NaiveTime>,
runtime_intraday_start_time: Cell<Option<NaiveTime>>,
runtime_intraday_end_time: Cell<Option<NaiveTime>>,
@@ -194,6 +196,8 @@ impl<C, R> BrokerSimulator<C, R> {
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<C, R> BrokerSimulator<C, R> {
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<C, R> BrokerSimulator<C, R> {
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,
+57 -9
View File
@@ -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
);
}
}
+153 -13
View File
@@ -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<EligibleUniverseSnapshot> {
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,28 +3662,77 @@ fn build_eligible_universe(
instruments: &HashMap<String, Instrument>,
) -> BTreeMap<NaiveDate, Vec<EligibleUniverseSnapshot>> {
let mut per_date = BTreeMap::<NaiveDate, Vec<EligibleUniverseSnapshot>>::new();
let risk_config = FidcRiskControlConfig::default();
for (date, factors) in factor_by_date {
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<NaiveDate, Vec<Arc<DailyFactorSnapshot>>>,
candidate_by_date: &BTreeMap<NaiveDate, Vec<Arc<CandidateEligibility>>>,
market_by_date: &BTreeMap<NaiveDate, Vec<Arc<DailyMarketSnapshot>>>,
instruments: &HashMap<String, Instrument>,
risk_config: &FidcRiskControlConfig,
) -> Vec<EligibleUniverseSnapshot> {
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<DailyFactorSnapshot>],
candidate_by_date: &BTreeMap<NaiveDate, Vec<Arc<CandidateEligibility>>>,
market_by_date: &BTreeMap<NaiveDate, Vec<Arc<DailyMarketSnapshot>>>,
instruments: &HashMap<String, Instrument>,
risk_config: &FidcRiskControlConfig,
) -> Vec<EligibleUniverseSnapshot> {
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 {
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 {
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,
if ChinaAShareRiskControl::selection_rejection_reason_with_config(
date,
candidate,
market,
instruments.get(&factor.symbol),
risk_config,
)
.is_some()
{
@@ -3691,10 +3755,7 @@ fn build_eligible_universe(
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| left.symbol.cmp(&right.symbol))
});
per_date.insert(*date, rows);
}
per_date
rows
}
#[cfg(test)]
@@ -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();
+4
View File
@@ -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<DailyEquityPoint>,
pub benchmark_series: Vec<BenchmarkSnapshot>,
pub risk_decisions: Vec<FidcRiskDecisionAudit>,
pub order_events: Vec<OrderEvent>,
pub fills: Vec<FillEvent>,
pub position_events: Vec<PositionEvent>,
@@ -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
+4 -1
View File
@@ -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,
+613 -92
View File
@@ -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<f64>,
pub stamp_tax_rate_before_change: Option<f64>,
pub stamp_tax_rate_after_change: Option<f64>,
pub stamp_tax_change_date: Option<NaiveDate>,
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<FidcRiskDecisionAudit> {
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<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={} selection_market_date={} selection_factor_date={}",
decisions.len(),
counts
.iter()
.map(|(rule, count)| format!("{rule}:{count}"))
.collect::<Vec<_>>()
.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<Option<String>, 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<String>, Vec<String>), BacktestError> {
) -> Result<(Vec<String>, Vec<String>, Vec<FidcRiskDecisionAudit>), 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::<String>::new();
let mut delayed_sold_symbols = BTreeSet::<String>::new();
let mut unresolved_stop_loss_symbols = BTreeSet::<String>::new();
let mut pending_firisk_forced_exit_symbols = BTreeSet::<String>::new();
let mut pending_risk_level_forced_exit_symbols = BTreeSet::<String>::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::<BTreeSet<_>>();
let mut pending_full_close_symbols = BTreeSet::<String>::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,
}),
"{:?}",
+396 -31
View File
@@ -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<String>,
#[serde(default)]
pub market: Option<String>,
@@ -24,15 +24,15 @@ pub struct StrategyRuntimeSpec {
pub universe: Option<StrategyUniverseSpec>,
#[serde(default)]
pub rebalance: Option<StrategyRebalanceSpec>,
#[serde(default)]
#[serde(default, alias = "trade_times")]
pub trade_times: Vec<String>,
#[serde(default)]
#[serde(default, alias = "signal_symbol")]
pub signal_symbol: Option<String>,
#[serde(default)]
pub execution: Option<StrategyExecutionSpec>,
#[serde(default)]
#[serde(default, alias = "engine_config")]
pub engine_config: Option<StrategyEngineConfig>,
#[serde(default)]
#[serde(default, alias = "runtime_expressions")]
pub runtime_expressions: Option<StrategyRuntimeExpressions>,
#[serde(default)]
pub environment: Option<StrategyRuntimeEnvironment>,
@@ -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<String>,
#[serde(default)]
#[serde(default, alias = "slippage_model")]
pub slippage_model: Option<String>,
#[serde(default)]
#[serde(default, alias = "slippage_value")]
pub slippage_value: Option<f64>,
#[serde(default)]
#[serde(default, alias = "slippage_impact_coefficient")]
pub slippage_impact_coefficient: Option<f64>,
#[serde(default)]
#[serde(default, alias = "slippage_volatility_coefficient")]
pub slippage_volatility_coefficient: Option<f64>,
#[serde(default)]
#[serde(default, alias = "slippage_max_value", alias = "slippage_max_rate")]
pub slippage_max_value: Option<f64>,
#[serde(default)]
#[serde(default, alias = "commission_rate")]
pub commission_rate: Option<f64>,
#[serde(default)]
#[serde(default, alias = "minimum_commission", alias = "minCommission")]
pub minimum_commission: Option<f64>,
#[serde(default)]
#[serde(default, alias = "stamp_tax_rate_before_change")]
pub stamp_tax_rate_before_change: Option<f64>,
#[serde(default)]
#[serde(default, alias = "stamp_tax_rate_after_change")]
pub stamp_tax_rate_after_change: Option<f64>,
#[serde(default)]
#[serde(default, alias = "stamp_tax_change_date")]
pub stamp_tax_change_date: Option<String>,
#[serde(default, alias = "volume_limit")]
pub volume_limit: Option<bool>,
#[serde(default, alias = "liquidity_limit")]
pub liquidity_limit: Option<bool>,
#[serde(default, alias = "volume_percent")]
pub volume_percent: Option<f64>,
#[serde(default, alias = "risk_policy")]
pub risk_policy: Option<StrategyRiskPolicySpec>,
#[serde(default, alias = "strict_value_budget")]
pub strict_value_budget: Option<bool>,
}
@@ -95,11 +105,11 @@ pub struct StrategyExecutionSpec {
pub struct StrategyEngineConfig {
#[serde(default)]
pub template_id: Option<String>,
#[serde(default)]
#[serde(default, alias = "profile_name")]
pub profile_name: Option<String>,
#[serde(default)]
#[serde(default, alias = "benchmark_symbol")]
pub benchmark_symbol: Option<String>,
#[serde(default)]
#[serde(default, alias = "signal_symbol")]
pub signal_symbol: Option<String>,
#[serde(default)]
pub rank_limit: Option<usize>,
@@ -117,27 +127,37 @@ pub struct StrategyEngineConfig {
pub stop_loss_multiplier: Option<f64>,
#[serde(default)]
pub take_profit_multiplier: Option<f64>,
#[serde(default)]
#[serde(default, alias = "matching_type")]
pub matching_type: Option<String>,
#[serde(default)]
#[serde(default, alias = "slippage_model")]
pub slippage_model: Option<String>,
#[serde(default)]
#[serde(default, alias = "slippage_value")]
pub slippage_value: Option<f64>,
#[serde(default)]
#[serde(default, alias = "slippage_impact_coefficient")]
pub slippage_impact_coefficient: Option<f64>,
#[serde(default)]
#[serde(default, alias = "slippage_volatility_coefficient")]
pub slippage_volatility_coefficient: Option<f64>,
#[serde(default)]
#[serde(default, alias = "slippage_max_value", alias = "slippage_max_rate")]
pub slippage_max_value: Option<f64>,
#[serde(default)]
#[serde(default, alias = "commission_rate")]
pub commission_rate: Option<f64>,
#[serde(default)]
#[serde(default, alias = "minimum_commission", alias = "minCommission")]
pub minimum_commission: Option<f64>,
#[serde(default)]
#[serde(default, alias = "stamp_tax_rate_before_change")]
pub stamp_tax_rate_before_change: Option<f64>,
#[serde(default)]
#[serde(default, alias = "stamp_tax_rate_after_change")]
pub stamp_tax_rate_after_change: Option<f64>,
#[serde(default)]
#[serde(default, alias = "stamp_tax_change_date")]
pub stamp_tax_change_date: Option<String>,
#[serde(default, alias = "volume_limit")]
pub volume_limit: Option<bool>,
#[serde(default, alias = "liquidity_limit")]
pub liquidity_limit: Option<bool>,
#[serde(default, alias = "volume_percent")]
pub volume_percent: Option<f64>,
#[serde(default, alias = "risk_policy")]
pub risk_policy: Option<StrategyRiskPolicySpec>,
#[serde(default, alias = "strict_value_budget")]
pub strict_value_budget: Option<bool>,
#[serde(default)]
pub dividend_reinvestment: Option<bool>,
@@ -149,6 +169,81 @@ pub struct StrategyEngineConfig {
pub skip_windows: Vec<SkipWindowConfig>,
}
#[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<bool>,
#[serde(
default,
alias = "reject_st_buy",
alias = "reject_star_st_buy",
alias = "rejectStarStBuy"
)]
pub reject_st_buy: Option<bool>,
#[serde(default, alias = "reject_paused_selection")]
pub reject_paused_selection: Option<bool>,
#[serde(default, alias = "reject_paused_buy")]
pub reject_paused_buy: Option<bool>,
#[serde(default, alias = "reject_paused_sell")]
pub reject_paused_sell: Option<bool>,
#[serde(default, alias = "reject_inactive_selection")]
pub reject_inactive_selection: Option<bool>,
#[serde(default, alias = "reject_inactive_buy")]
pub reject_inactive_buy: Option<bool>,
#[serde(default, alias = "reject_inactive_sell")]
pub reject_inactive_sell: Option<bool>,
#[serde(default, alias = "reject_new_listing_selection")]
pub reject_new_listing_selection: Option<bool>,
#[serde(default, alias = "reject_new_listing_buy")]
pub reject_new_listing_buy: Option<bool>,
#[serde(default, alias = "reject_kcb_selection")]
pub reject_kcb_selection: Option<bool>,
#[serde(default, alias = "reject_kcb_buy")]
pub reject_kcb_buy: Option<bool>,
#[serde(default, alias = "reject_one_yuan_selection")]
pub reject_one_yuan_selection: Option<bool>,
#[serde(default, alias = "reject_one_yuan_buy")]
pub reject_one_yuan_buy: Option<bool>,
#[serde(default, alias = "respect_allow_buy_sell")]
pub respect_allow_buy_sell: Option<bool>,
#[serde(default, alias = "reject_upper_limit_selection")]
pub reject_upper_limit_selection: Option<bool>,
#[serde(default, alias = "reject_lower_limit_selection")]
pub reject_lower_limit_selection: Option<bool>,
#[serde(default, alias = "reject_upper_limit_buy")]
pub reject_upper_limit_buy: Option<bool>,
#[serde(default, alias = "reject_lower_limit_sell")]
pub reject_lower_limit_sell: Option<bool>,
#[serde(default, alias = "forbid_same_day_rebuy_after_sell")]
pub forbid_same_day_rebuy_after_sell: Option<bool>,
#[serde(default, alias = "blacklist_enabled")]
pub blacklist_enabled: Option<bool>,
#[serde(default, alias = "blacklisted_symbols", alias = "blacklist")]
pub blacklisted_symbols: Vec<String>,
#[serde(default, alias = "volume_limit_enabled")]
pub volume_limit_enabled: Option<bool>,
#[serde(default, alias = "volume_percent")]
pub volume_percent: Option<f64>,
#[serde(default, alias = "liquidity_limit_enabled")]
pub liquidity_limit_enabled: Option<bool>,
#[serde(default, alias = "commission_rate")]
pub commission_rate: Option<f64>,
#[serde(default, alias = "minimum_commission", alias = "minCommission")]
pub minimum_commission: Option<f64>,
#[serde(default, alias = "stamp_tax_rate_before_change")]
pub stamp_tax_rate_before_change: Option<f64>,
#[serde(default, alias = "stamp_tax_rate_after_change")]
pub stamp_tax_rate_after_change: Option<f64>,
#[serde(default, alias = "stamp_tax_change_date")]
pub stamp_tax_change_date: Option<String>,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DynamicRangeConfig {
@@ -392,6 +487,27 @@ fn valid_non_negative(value: Option<f64>) -> Option<f64> {
value.filter(|item| item.is_finite() && *item >= 0.0)
}
fn normalize_percent_ratio(value: Option<f64>) -> Option<f64> {
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<NaiveDate> {
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<f64>,
stamp_tax_rate_before_change: Option<f64>,
stamp_tax_rate_after_change: Option<f64>,
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<bool>,
liquidity_limit: Option<bool>,
volume_percent: Option<f64>,
) {
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]
+434 -14
View File
@@ -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<String>,
}
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<String>,
pub data_epoch: String,
pub selection_batch_id: Option<String>,
pub order_id: Option<String>,
}
impl FidcRiskDecisionAudit {
pub fn rejected_selection(
date: NaiveDate,
symbol: impl Into<String>,
reason: impl Into<String>,
) -> 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<FidcRiskDecisionAudit> {
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);
}
}
+8
View File
@@ -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,
+314 -11
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,6 +2032,8 @@ impl OmniMicroCapStrategy {
}
let mut max_fill = requested_qty;
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(),
@@ -1985,13 +2045,20 @@ impl OmniMicroCapStrategy {
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)
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 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;
}
@@ -2003,7 +2070,9 @@ impl OmniMicroCapStrategy {
if volume_limited == 0 {
return None;
}
Some(max_fill.min(volume_limited))
max_fill = 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()]);
}
}
+19 -7
View File
@@ -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_ma6060日价格均线写 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 == 0risk.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_typenext_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_ma6060日价格均线写 rolling_mean(\"close\", 60) 或 ma(\"close\", 60),任意窗口均量写 rolling_mean(\"volume\", n) 或 vma(n);不要生成 fn score() 这类零参数函数,股票字段排序直接写在 ordering.rank_expr 内或用带参数函数;布尔字段按布尔使用,不要写 is_st == 0filter.stock_expr 只写 alpha 或业务过滤条件,不要把 !is_st、!paused、!at_upper_limit、!at_lower_limit 这类基础风控散落在表达式里risk.index_exposure 只能传一个数值表达式,不要使用 risk.exposurerisk.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_typenext_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"));
}
}
+205 -4
View File
@@ -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<String>,
pub selected_symbols: Vec<String>,
pub rejection_examples: Vec<String>,
pub risk_decisions: Vec<FidcRiskDecisionAudit>,
}
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<String>>,
pub risk_config: Option<&'a FidcRiskControlConfig>,
}
impl SelectionContext<'_> {
fn eligible_universe(&self) -> Vec<EligibleUniverseSnapshot> {
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<FidcRiskDecisionAudit> {
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::<BTreeSet<_>>();
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=")
);
}
}
+2 -2
View File
@@ -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);
@@ -88,6 +88,7 @@ impl Strategy for BuyAndHoldStrategy {
},
notes: Vec::new(),
diagnostics: Vec::new(),
risk_decisions: Vec::new(),
})
}
}
+1
View File
@@ -34,6 +34,7 @@ impl Strategy for BuyThenHoldStrategy {
}],
notes: Vec::new(),
diagnostics: Vec::new(),
risk_decisions: Vec::new(),
});
}
Ok(StrategyDecision::default())
+10
View File
@@ -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())
+350 -3
View File
@@ -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::<Vec<_>>();
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::<Vec<_>>();
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::<Vec<_>>();
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::<Vec<_>>();
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::<Vec<_>>();
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");