实现FIDC配置化风控与交易成本
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+183
-43
@@ -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,56 +3662,102 @@ 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 mut rows = Vec::new();
|
||||
for factor in factors {
|
||||
if factor.market_cap_bn <= 0.0 || !factor.market_cap_bn.is_finite() {
|
||||
continue;
|
||||
}
|
||||
let Some(candidate) = candidate_by_date.get(date).and_then(|rows| {
|
||||
find_arc_by_symbol(rows, &factor.symbol, |row| row.symbol.as_str())
|
||||
}) else {
|
||||
continue;
|
||||
};
|
||||
let Some(market) = market_by_date.get(date).and_then(|rows| {
|
||||
find_arc_by_symbol(rows, &factor.symbol, |row| row.symbol.as_str())
|
||||
}) else {
|
||||
continue;
|
||||
};
|
||||
if ChinaAShareRiskControl::selection_rejection_reason(
|
||||
*date,
|
||||
candidate,
|
||||
market,
|
||||
instruments.get(&factor.symbol),
|
||||
)
|
||||
.is_some()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let market_cap_bn = decision_market_cap_bn(factor, market);
|
||||
if market_cap_bn <= 0.0 || !market_cap_bn.is_finite() {
|
||||
continue;
|
||||
}
|
||||
let free_float_cap_bn = decision_free_float_cap_bn(factor, market);
|
||||
rows.push(EligibleUniverseSnapshot {
|
||||
symbol: factor.symbol.clone(),
|
||||
market_cap_bn,
|
||||
free_float_cap_bn,
|
||||
});
|
||||
}
|
||||
rows.sort_by(|left, right| {
|
||||
left.market_cap_bn
|
||||
.partial_cmp(&right.market_cap_bn)
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
.then_with(|| left.symbol.cmp(&right.symbol))
|
||||
});
|
||||
let rows = build_eligible_universe_for_date_from_factors(
|
||||
*date,
|
||||
factors,
|
||||
candidate_by_date,
|
||||
market_by_date,
|
||||
instruments,
|
||||
&risk_config,
|
||||
);
|
||||
per_date.insert(*date, rows);
|
||||
}
|
||||
|
||||
per_date
|
||||
}
|
||||
|
||||
fn build_eligible_universe_for_date(
|
||||
date: NaiveDate,
|
||||
factor_by_date: &BTreeMap<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 {
|
||||
continue;
|
||||
};
|
||||
let Some(market) = market_by_date
|
||||
.get(&date)
|
||||
.and_then(|rows| find_arc_by_symbol(rows, &factor.symbol, |row| row.symbol.as_str()))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if ChinaAShareRiskControl::selection_rejection_reason_with_config(
|
||||
date,
|
||||
candidate,
|
||||
market,
|
||||
instruments.get(&factor.symbol),
|
||||
risk_config,
|
||||
)
|
||||
.is_some()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let market_cap_bn = decision_market_cap_bn(factor, market);
|
||||
if market_cap_bn <= 0.0 || !market_cap_bn.is_finite() {
|
||||
continue;
|
||||
}
|
||||
let free_float_cap_bn = decision_free_float_cap_bn(factor, market);
|
||||
rows.push(EligibleUniverseSnapshot {
|
||||
symbol: factor.symbol.clone(),
|
||||
market_cap_bn,
|
||||
free_float_cap_bn,
|
||||
});
|
||||
}
|
||||
rows.sort_by(|left, right| {
|
||||
left.market_cap_bn
|
||||
.partial_cmp(&right.market_cap_bn)
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
.then_with(|| left.symbol.cmp(&right.symbol))
|
||||
});
|
||||
rows
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn instrument_passes_baseline_selection(instrument: Option<&Instrument>, date: NaiveDate) -> bool {
|
||||
ChinaAShareRiskControl::instrument_rejection_reason(instrument, date).is_none()
|
||||
@@ -3981,6 +4042,85 @@ mod tests {
|
||||
assert!((rows[1].market_cap_bn - 10.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eligible_universe_can_use_configured_risk_policy() {
|
||||
let date = NaiveDate::parse_from_str("2025-01-06", "%Y-%m-%d").unwrap();
|
||||
let symbol = "688001.SH";
|
||||
let data = DataSet::from_components(
|
||||
vec![Instrument {
|
||||
symbol: symbol.to_string(),
|
||||
name: symbol.to_string(),
|
||||
board: "SH".to_string(),
|
||||
round_lot: 100,
|
||||
listed_at: Some(NaiveDate::parse_from_str("2020-01-01", "%Y-%m-%d").unwrap()),
|
||||
delisted_at: None,
|
||||
status: "active".to_string(),
|
||||
}],
|
||||
vec![DailyMarketSnapshot {
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
timestamp: Some("2025-01-06 10:18:00".to_string()),
|
||||
day_open: 10.0,
|
||||
open: 10.0,
|
||||
high: 10.2,
|
||||
low: 9.8,
|
||||
close: 10.1,
|
||||
last_price: 10.1,
|
||||
bid1: 10.0,
|
||||
ask1: 10.1,
|
||||
prev_close: 10.0,
|
||||
volume: 100_000,
|
||||
minute_volume: 1_000,
|
||||
bid1_volume: 1_000,
|
||||
ask1_volume: 1_000,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
paused: false,
|
||||
upper_limit: 11.0,
|
||||
lower_limit: 9.0,
|
||||
price_tick: 0.01,
|
||||
}],
|
||||
vec![DailyFactorSnapshot {
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
market_cap_bn: 10.0,
|
||||
free_float_cap_bn: 9.0,
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: Some(1.0),
|
||||
effective_turnover_ratio: Some(1.0),
|
||||
extra_factors: BTreeMap::new(),
|
||||
}],
|
||||
vec![CandidateEligibility {
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
is_st: false,
|
||||
is_new_listing: false,
|
||||
is_paused: false,
|
||||
allow_buy: true,
|
||||
allow_sell: true,
|
||||
is_kcb: true,
|
||||
is_one_yuan: false,
|
||||
risk_level_code: None,
|
||||
}],
|
||||
vec![BenchmarkSnapshot {
|
||||
date,
|
||||
benchmark: "000852.SH".to_string(),
|
||||
open: 100.0,
|
||||
close: 101.0,
|
||||
prev_close: 99.0,
|
||||
volume: 1_000_000,
|
||||
}],
|
||||
)
|
||||
.expect("dataset");
|
||||
|
||||
assert!(data.eligible_universe_on(date).is_empty());
|
||||
let mut risk_config = FidcRiskControlConfig::default();
|
||||
risk_config.static_rules.reject_kcb_selection = false;
|
||||
let rows = data.eligible_universe_on_with_risk_config(date, &risk_config);
|
||||
|
||||
assert_eq!(rows.len(), 1);
|
||||
assert_eq!(rows[0].symbol, symbol);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decision_market_cap_keeps_pre_adjusted_factor() {
|
||||
let date = NaiveDate::parse_from_str("2025-01-06", "%Y-%m-%d").unwrap();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
"{:?}",
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -17,7 +17,7 @@ use crate::events::{FillEvent, OrderEvent, OrderSide, OrderStatus, ProcessEvent}
|
||||
use crate::futures::{FuturesAccountState, FuturesOrderIntent};
|
||||
use crate::instrument::Instrument;
|
||||
use crate::portfolio::PortfolioState;
|
||||
use crate::risk_control::ChinaAShareRiskControl;
|
||||
use crate::risk_control::{ChinaAShareRiskControl, FidcRiskControlConfig, FidcRiskDecisionAudit};
|
||||
use crate::scheduler::ScheduleRule;
|
||||
use crate::universe::{DynamicMarketCapBandSelector, SelectionContext, UniverseSelector};
|
||||
|
||||
@@ -512,6 +512,23 @@ impl StrategyContext<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn eligible_universe_on_with_risk_config(
|
||||
&self,
|
||||
date: NaiveDate,
|
||||
risk_config: &crate::risk_control::FidcRiskControlConfig,
|
||||
) -> Vec<crate::data::EligibleUniverseSnapshot> {
|
||||
let eligible = self
|
||||
.data
|
||||
.eligible_universe_on_with_risk_config(date, risk_config);
|
||||
match self.dynamic_universe {
|
||||
Some(symbols) if !symbols.is_empty() => eligible
|
||||
.into_iter()
|
||||
.filter(|row| symbols.contains(&row.symbol))
|
||||
.collect(),
|
||||
_ => eligible,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn current_snapshot(&self, symbol: &str) -> Option<&DailyMarketSnapshot> {
|
||||
self.data.market(self.execution_date, symbol)
|
||||
}
|
||||
@@ -911,6 +928,7 @@ pub struct StrategyDecision {
|
||||
pub order_intents: Vec<OrderIntent>,
|
||||
pub notes: Vec<String>,
|
||||
pub diagnostics: Vec<String>,
|
||||
pub risk_decisions: Vec<FidcRiskDecisionAudit>,
|
||||
}
|
||||
|
||||
impl StrategyDecision {
|
||||
@@ -921,6 +939,7 @@ impl StrategyDecision {
|
||||
self.order_intents.append(&mut other.order_intents);
|
||||
self.notes.append(&mut other.notes);
|
||||
self.diagnostics.append(&mut other.diagnostics);
|
||||
self.risk_decisions.append(&mut other.risk_decisions);
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
@@ -930,6 +949,7 @@ impl StrategyDecision {
|
||||
&& self.order_intents.is_empty()
|
||||
&& self.notes.is_empty()
|
||||
&& self.diagnostics.is_empty()
|
||||
&& self.risk_decisions.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1123,6 +1143,7 @@ pub struct CnSmallCapRotationConfig {
|
||||
pub signal_symbol: Option<String>,
|
||||
pub skip_months: Vec<u32>,
|
||||
pub skip_month_day_ranges: Vec<(Option<u32>, u32, u32, u32)>,
|
||||
pub risk_config: FidcRiskControlConfig,
|
||||
}
|
||||
|
||||
impl CnSmallCapRotationConfig {
|
||||
@@ -1150,6 +1171,7 @@ impl CnSmallCapRotationConfig {
|
||||
signal_symbol: None,
|
||||
skip_months: Vec::new(),
|
||||
skip_month_day_ranges: Vec::new(),
|
||||
risk_config: FidcRiskControlConfig::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1183,6 +1205,7 @@ impl CnSmallCapRotationConfig {
|
||||
(None, 10, 20, 30),
|
||||
(None, 12, 20, 30),
|
||||
],
|
||||
risk_config: FidcRiskControlConfig::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1367,6 +1390,7 @@ impl Strategy for CnSmallCapRotationStrategy {
|
||||
"run_daily(10:17/10:18) mapped to T-1 decision and T open execution"
|
||||
.to_string(),
|
||||
],
|
||||
risk_decisions: Vec::new(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1385,6 +1409,7 @@ impl Strategy for CnSmallCapRotationStrategy {
|
||||
diagnostics: vec![
|
||||
"insufficient history; skip trading on warmup dates".to_string(),
|
||||
],
|
||||
risk_decisions: Vec::new(),
|
||||
});
|
||||
}
|
||||
Err(err) => return Err(err),
|
||||
@@ -1422,6 +1447,7 @@ impl Strategy for CnSmallCapRotationStrategy {
|
||||
"run_daily(10:17/10:18) approximated by daily decision/open execution".to_string(),
|
||||
);
|
||||
diagnostics.push("market_cap field mapped from daily_features[_enriched]_v1.market_cap to market_cap_bn without intraday fundamentals refresh".to_string());
|
||||
let mut risk_decisions = Vec::new();
|
||||
|
||||
if rebalance && gross_exposure > 0.0 {
|
||||
let (selected_before_ma, selection_diag) =
|
||||
@@ -1431,6 +1457,7 @@ impl Strategy for CnSmallCapRotationStrategy {
|
||||
reference_level: signal_level,
|
||||
data: ctx.data,
|
||||
dynamic_universe: ctx.dynamic_universe,
|
||||
risk_config: Some(&self.config.risk_config),
|
||||
});
|
||||
let before_ma_count = selected_before_ma.len();
|
||||
let mut ma_rejects = Vec::new();
|
||||
@@ -1479,6 +1506,29 @@ impl Strategy for CnSmallCapRotationStrategy {
|
||||
selection_diag.rejection_examples.join(" | ")
|
||||
));
|
||||
}
|
||||
if !selection_diag.risk_decisions.is_empty() {
|
||||
risk_decisions.extend(selection_diag.risk_decisions.clone());
|
||||
let mut counts = BTreeMap::<String, usize>::new();
|
||||
for decision in &selection_diag.risk_decisions {
|
||||
*counts.entry(decision.rule_code.clone()).or_insert(0) += 1;
|
||||
}
|
||||
diagnostics.push(format!(
|
||||
"risk_decisions selection_total={} by_rule={}",
|
||||
selection_diag.risk_decisions.len(),
|
||||
counts
|
||||
.iter()
|
||||
.map(|(rule, count)| format!("{rule}:{count}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(",")
|
||||
));
|
||||
diagnostics.extend(
|
||||
selection_diag
|
||||
.risk_decisions
|
||||
.iter()
|
||||
.take(5)
|
||||
.map(|decision| decision.diagnostic_line()),
|
||||
);
|
||||
}
|
||||
if !ma_rejects.is_empty() {
|
||||
diagnostics.push(format!(
|
||||
"ma_filter_rejections sample={}",
|
||||
@@ -1531,6 +1581,7 @@ impl Strategy for CnSmallCapRotationStrategy {
|
||||
order_intents: Vec::new(),
|
||||
notes,
|
||||
diagnostics,
|
||||
risk_decisions,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1560,6 +1611,7 @@ pub struct OmniMicroCapConfig {
|
||||
pub stop_loss_ratio: f64,
|
||||
pub take_profit_ratio: f64,
|
||||
pub skip_month_day_ranges: Vec<(Option<u32>, u32, u32, u32)>,
|
||||
pub risk_config: FidcRiskControlConfig,
|
||||
}
|
||||
|
||||
impl OmniMicroCapConfig {
|
||||
@@ -1590,6 +1642,7 @@ impl OmniMicroCapConfig {
|
||||
// The migrated reference logic disables seasonal stop windows in
|
||||
// production-style execution, so the default keeps that behavior.
|
||||
skip_month_day_ranges: Vec::new(),
|
||||
risk_config: FidcRiskControlConfig::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1618,6 +1671,7 @@ impl OmniMicroCapConfig {
|
||||
stop_loss_ratio: 0.92,
|
||||
take_profit_ratio: 1.16,
|
||||
skip_month_day_ranges: Vec::new(),
|
||||
risk_config: FidcRiskControlConfig::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1687,12 +1741,16 @@ impl OmniMicroCapStrategy {
|
||||
0.0
|
||||
}
|
||||
|
||||
fn cost_model(&self) -> ChinaAShareCostModel {
|
||||
ChinaAShareCostModel::from_trading_constraints(self.config.risk_config.trading_constraints)
|
||||
}
|
||||
|
||||
fn buy_commission(&self, gross_amount: f64) -> f64 {
|
||||
ChinaAShareCostModel::default().commission_for(gross_amount)
|
||||
self.cost_model().commission_for(gross_amount)
|
||||
}
|
||||
|
||||
fn sell_cost(&self, date: NaiveDate, gross_amount: f64) -> f64 {
|
||||
let model = ChinaAShareCostModel::default();
|
||||
let model = self.cost_model();
|
||||
model.commission_for(gross_amount)
|
||||
+ model.stamp_tax_for(date, OrderSide::Sell, gross_amount)
|
||||
}
|
||||
@@ -1974,36 +2032,47 @@ impl OmniMicroCapStrategy {
|
||||
}
|
||||
|
||||
let mut max_fill = requested_qty;
|
||||
let top_level_liquidity = match side {
|
||||
OrderSide::Buy => snapshot.liquidity_for_buy(),
|
||||
OrderSide::Sell => snapshot.liquidity_for_sell(),
|
||||
let constraints = self.config.risk_config.trading_constraints;
|
||||
if constraints.liquidity_limit_enabled {
|
||||
let top_level_liquidity = match side {
|
||||
OrderSide::Buy => snapshot.liquidity_for_buy(),
|
||||
OrderSide::Sell => snapshot.liquidity_for_sell(),
|
||||
}
|
||||
.min(u32::MAX as u64) as u32;
|
||||
if top_level_liquidity == 0 {
|
||||
return None;
|
||||
}
|
||||
let liquidity_limited = if side == OrderSide::Sell && allow_odd_lot_sell {
|
||||
top_level_liquidity
|
||||
} else {
|
||||
self.round_lot_quantity(
|
||||
top_level_liquidity,
|
||||
minimum_order_quantity,
|
||||
order_step_size,
|
||||
)
|
||||
};
|
||||
max_fill = max_fill.min(liquidity_limited);
|
||||
}
|
||||
.min(u32::MAX as u64) as u32;
|
||||
if top_level_liquidity == 0 {
|
||||
return None;
|
||||
}
|
||||
let liquidity_limited = if side == OrderSide::Sell && allow_odd_lot_sell {
|
||||
top_level_liquidity
|
||||
} else {
|
||||
self.round_lot_quantity(top_level_liquidity, minimum_order_quantity, order_step_size)
|
||||
};
|
||||
max_fill = max_fill.min(liquidity_limited);
|
||||
|
||||
let consumed_turnover = *execution_state.intraday_turnover.get(symbol).unwrap_or(&0);
|
||||
let raw_limit =
|
||||
((snapshot.minute_volume as f64) * 0.25).round() as i64 - consumed_turnover as i64;
|
||||
if raw_limit <= 0 {
|
||||
return None;
|
||||
if constraints.volume_limit_enabled {
|
||||
let raw_limit = ((snapshot.minute_volume as f64) * constraints.volume_percent).round()
|
||||
as i64
|
||||
- consumed_turnover as i64;
|
||||
if raw_limit <= 0 {
|
||||
return None;
|
||||
}
|
||||
let volume_limited = if side == OrderSide::Sell && allow_odd_lot_sell {
|
||||
raw_limit as u32
|
||||
} else {
|
||||
self.round_lot_quantity(raw_limit as u32, minimum_order_quantity, order_step_size)
|
||||
};
|
||||
if volume_limited == 0 {
|
||||
return None;
|
||||
}
|
||||
max_fill = max_fill.min(volume_limited);
|
||||
}
|
||||
let volume_limited = if side == OrderSide::Sell && allow_odd_lot_sell {
|
||||
raw_limit as u32
|
||||
} else {
|
||||
self.round_lot_quantity(raw_limit as u32, minimum_order_quantity, order_step_size)
|
||||
};
|
||||
if volume_limited == 0 {
|
||||
return None;
|
||||
}
|
||||
Some(max_fill.min(volume_limited))
|
||||
Some(max_fill)
|
||||
}
|
||||
|
||||
fn projected_execution_start_cursor(
|
||||
@@ -2338,13 +2407,14 @@ impl OmniMicroCapStrategy {
|
||||
let Ok(candidate) = ctx.data.require_candidate(date, symbol) else {
|
||||
return false;
|
||||
};
|
||||
ChinaAShareRiskControl::sell_rejection_reason(
|
||||
ChinaAShareRiskControl::sell_rejection_reason_with_config(
|
||||
date,
|
||||
candidate,
|
||||
market,
|
||||
ctx.data.instrument(symbol),
|
||||
Some(position),
|
||||
ChinaAShareRiskControl::sell_check_price(market, PriceField::Last),
|
||||
&self.config.risk_config,
|
||||
)
|
||||
.is_none()
|
||||
}
|
||||
@@ -2358,12 +2428,13 @@ impl OmniMicroCapStrategy {
|
||||
let market = ctx.data.require_market(date, symbol)?;
|
||||
let candidate = ctx.data.require_candidate(date, symbol)?;
|
||||
|
||||
if let Some(reason) = ChinaAShareRiskControl::buy_rejection_reason(
|
||||
if let Some(reason) = ChinaAShareRiskControl::buy_rejection_reason_with_config(
|
||||
date,
|
||||
candidate,
|
||||
market,
|
||||
ctx.data.instrument(symbol),
|
||||
ChinaAShareRiskControl::buy_check_price(market, PriceField::Last),
|
||||
&self.config.risk_config,
|
||||
) {
|
||||
return Ok(Some(reason.to_string()));
|
||||
}
|
||||
@@ -2375,6 +2446,64 @@ impl OmniMicroCapStrategy {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn selection_risk_decisions(
|
||||
&self,
|
||||
ctx: &StrategyContext<'_>,
|
||||
date: NaiveDate,
|
||||
) -> Vec<FidcRiskDecisionAudit> {
|
||||
let mut decisions = Vec::new();
|
||||
for factor in ctx.data.factor_snapshots_on(date) {
|
||||
if ctx.has_dynamic_universe() && !ctx.dynamic_universe_contains(&factor.symbol) {
|
||||
continue;
|
||||
}
|
||||
let Some(candidate) = ctx.data.candidate(date, &factor.symbol) else {
|
||||
continue;
|
||||
};
|
||||
let Some(market) = ctx.data.market(date, &factor.symbol) else {
|
||||
continue;
|
||||
};
|
||||
if let Some(decision) = ChinaAShareRiskControl::selection_rejection_decision_with_config(
|
||||
date,
|
||||
candidate,
|
||||
market,
|
||||
ctx.data.instrument(&factor.symbol),
|
||||
&self.config.risk_config,
|
||||
) {
|
||||
decisions.push(decision);
|
||||
}
|
||||
}
|
||||
decisions
|
||||
}
|
||||
|
||||
fn selection_risk_decision_diagnostics(
|
||||
decisions: &[FidcRiskDecisionAudit],
|
||||
sample_limit: usize,
|
||||
) -> Vec<String> {
|
||||
if decisions.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut counts = BTreeMap::<String, usize>::new();
|
||||
for decision in decisions {
|
||||
*counts.entry(decision.rule_code.clone()).or_insert(0) += 1;
|
||||
}
|
||||
let mut diagnostics = vec![format!(
|
||||
"risk_decisions selection_total={} by_rule={}",
|
||||
decisions.len(),
|
||||
counts
|
||||
.iter()
|
||||
.map(|(rule, count)| format!("{rule}:{count}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(",")
|
||||
)];
|
||||
diagnostics.extend(
|
||||
decisions
|
||||
.iter()
|
||||
.take(sample_limit)
|
||||
.map(|decision| decision.diagnostic_line()),
|
||||
);
|
||||
diagnostics
|
||||
}
|
||||
|
||||
fn select_symbols(
|
||||
&self,
|
||||
ctx: &StrategyContext<'_>,
|
||||
@@ -2418,7 +2547,8 @@ impl OmniMicroCapStrategy {
|
||||
}
|
||||
|
||||
if selected.len() < self.config.stocknum {
|
||||
let universe = ctx.eligible_universe_on(date);
|
||||
let universe =
|
||||
ctx.eligible_universe_on_with_risk_config(date, &self.config.risk_config);
|
||||
let start = lower_bound_eligible(&universe, band_low);
|
||||
for candidate in universe.iter().skip(start) {
|
||||
if candidate.market_cap_bn > band_high {
|
||||
@@ -2453,7 +2583,7 @@ impl OmniMicroCapStrategy {
|
||||
return Ok((selected, diagnostics));
|
||||
}
|
||||
|
||||
let universe = ctx.eligible_universe_on(date);
|
||||
let universe = ctx.eligible_universe_on_with_risk_config(date, &self.config.risk_config);
|
||||
let mut diagnostics = Vec::new();
|
||||
let mut selected = Vec::new();
|
||||
let start = lower_bound_eligible(&universe, band_low);
|
||||
@@ -2654,6 +2784,7 @@ impl Strategy for OmniMicroCapStrategy {
|
||||
.collect(),
|
||||
notes: vec![format!("seasonal stop window on {}", date)],
|
||||
diagnostics: vec!["platform-native skip window forced all cash".to_string()],
|
||||
risk_decisions: Vec::new(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2672,6 +2803,7 @@ impl Strategy for OmniMicroCapStrategy {
|
||||
diagnostics: vec![
|
||||
"insufficient history; skip trading on warmup dates".to_string(),
|
||||
],
|
||||
risk_decisions: Vec::new(),
|
||||
});
|
||||
}
|
||||
Err(err) => return Err(err),
|
||||
@@ -2679,6 +2811,7 @@ impl Strategy for OmniMicroCapStrategy {
|
||||
// 使用前一交易日的指数价格计算市值区间(模拟实盘场景)
|
||||
let (band_low, band_high) = self.market_cap_band(prev_index_level);
|
||||
let (stock_list, selection_notes) = self.select_symbols(ctx, date, band_low, band_high)?;
|
||||
let risk_decisions = self.selection_risk_decisions(ctx, date);
|
||||
let periodic_rebalance = ctx.decision_index % self.config.refresh_rate == 0;
|
||||
let mut projected = ctx.portfolio.clone();
|
||||
let mut projected_execution_state = ProjectedExecutionState::default();
|
||||
@@ -2848,6 +2981,10 @@ impl Strategy for OmniMicroCapStrategy {
|
||||
));
|
||||
}
|
||||
diagnostics.extend(selection_notes);
|
||||
diagnostics.extend(Self::selection_risk_decision_diagnostics(
|
||||
&risk_decisions,
|
||||
5,
|
||||
));
|
||||
|
||||
let notes = vec![
|
||||
format!("stock_list={}", stock_list.len()),
|
||||
@@ -2861,6 +2998,7 @@ impl Strategy for OmniMicroCapStrategy {
|
||||
order_intents,
|
||||
notes,
|
||||
diagnostics,
|
||||
risk_decisions,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -2882,6 +3020,7 @@ fn lower_bound_eligible(rows: &[crate::data::EligibleUniverseSnapshot], target:
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{BenchmarkSnapshot, CandidateEligibility, DailyFactorSnapshot};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
fn temp_csv_path(name: &str) -> PathBuf {
|
||||
@@ -2927,4 +3066,168 @@ mod tests {
|
||||
Some("603657.SH")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn omni_microcap_projection_uses_configured_trading_cost() {
|
||||
let mut cfg = OmniMicroCapConfig::omni_microcap();
|
||||
cfg.risk_config.trading_constraints.commission_rate = 0.0003;
|
||||
cfg.risk_config.trading_constraints.minimum_commission = 5.0;
|
||||
cfg.risk_config
|
||||
.trading_constraints
|
||||
.stamp_tax_rate_after_change = 0.0005;
|
||||
let strategy = OmniMicroCapStrategy::new(cfg);
|
||||
|
||||
assert!((strategy.buy_commission(100_000.0) - 30.0).abs() < 1e-9);
|
||||
assert!((strategy.buy_commission(1_000.0) - 5.0).abs() < 1e-9);
|
||||
assert!(
|
||||
(strategy.sell_cost(NaiveDate::from_ymd_opt(2025, 1, 2).unwrap(), 100_000.0) - 80.0)
|
||||
.abs()
|
||||
< 1e-9
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn omni_microcap_selection_uses_configured_risk_policy() {
|
||||
let dates = [
|
||||
NaiveDate::from_ymd_opt(2025, 1, 2).unwrap(),
|
||||
NaiveDate::from_ymd_opt(2025, 1, 3).unwrap(),
|
||||
NaiveDate::from_ymd_opt(2025, 1, 6).unwrap(),
|
||||
];
|
||||
let symbol = "688001.SH";
|
||||
let market_rows = dates
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, date)| DailyMarketSnapshot {
|
||||
date: *date,
|
||||
symbol: symbol.to_string(),
|
||||
timestamp: Some(format!("{date} 10:18:00")),
|
||||
day_open: 10.0 + index as f64,
|
||||
open: 10.0 + index as f64,
|
||||
high: 10.4 + index as f64,
|
||||
low: 9.8 + index as f64,
|
||||
close: 10.2 + index as f64,
|
||||
last_price: 10.2 + index as f64,
|
||||
bid1: 10.1 + index as f64,
|
||||
ask1: 10.2 + index as f64,
|
||||
prev_close: 10.0 + index as f64,
|
||||
volume: 1_000_000 + index as u64 * 100_000,
|
||||
minute_volume: 10_000,
|
||||
bid1_volume: 10_000,
|
||||
ask1_volume: 10_000,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
paused: false,
|
||||
upper_limit: 20.0,
|
||||
lower_limit: 5.0,
|
||||
price_tick: 0.01,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let factor_rows = dates
|
||||
.iter()
|
||||
.map(|date| DailyFactorSnapshot {
|
||||
date: *date,
|
||||
symbol: symbol.to_string(),
|
||||
market_cap_bn: 10.0,
|
||||
free_float_cap_bn: 9.0,
|
||||
pe_ttm: 12.0,
|
||||
turnover_ratio: Some(1.0),
|
||||
effective_turnover_ratio: Some(1.0),
|
||||
extra_factors: BTreeMap::new(),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let candidate_rows = dates
|
||||
.iter()
|
||||
.map(|date| CandidateEligibility {
|
||||
date: *date,
|
||||
symbol: symbol.to_string(),
|
||||
is_st: false,
|
||||
is_new_listing: false,
|
||||
is_paused: false,
|
||||
allow_buy: true,
|
||||
allow_sell: true,
|
||||
is_kcb: true,
|
||||
is_one_yuan: false,
|
||||
risk_level_code: None,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let benchmark_rows = dates
|
||||
.iter()
|
||||
.map(|date| BenchmarkSnapshot {
|
||||
date: *date,
|
||||
benchmark: "000852.SH".to_string(),
|
||||
open: 100.0,
|
||||
close: 101.0,
|
||||
prev_close: 99.0,
|
||||
volume: 1_000_000,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let data = DataSet::from_components(
|
||||
vec![Instrument {
|
||||
symbol: symbol.to_string(),
|
||||
name: symbol.to_string(),
|
||||
board: "SH".to_string(),
|
||||
round_lot: 100,
|
||||
listed_at: Some(NaiveDate::from_ymd_opt(2020, 1, 1).unwrap()),
|
||||
delisted_at: None,
|
||||
status: "active".to_string(),
|
||||
}],
|
||||
market_rows,
|
||||
factor_rows,
|
||||
candidate_rows,
|
||||
benchmark_rows,
|
||||
)
|
||||
.expect("dataset");
|
||||
let portfolio = PortfolioState::new(1_000_000.0);
|
||||
let subscriptions = BTreeSet::new();
|
||||
let ctx = StrategyContext {
|
||||
execution_date: dates[2],
|
||||
decision_date: dates[2],
|
||||
decision_index: 0,
|
||||
data: &data,
|
||||
portfolio: &portfolio,
|
||||
futures_account: None,
|
||||
open_orders: &[],
|
||||
dynamic_universe: None,
|
||||
subscriptions: &subscriptions,
|
||||
process_events: &[],
|
||||
active_process_event: None,
|
||||
active_datetime: None,
|
||||
order_events: &[],
|
||||
fills: &[],
|
||||
};
|
||||
|
||||
let mut default_cfg = OmniMicroCapConfig::omni_microcap();
|
||||
default_cfg.strategy_name = "configured_risk_policy_test".to_string();
|
||||
default_cfg.stock_short_ma_days = 1;
|
||||
default_cfg.stock_mid_ma_days = 2;
|
||||
default_cfg.stock_long_ma_days = 3;
|
||||
let default_strategy = OmniMicroCapStrategy::new(default_cfg.clone());
|
||||
let (default_selected, _) = default_strategy
|
||||
.select_symbols(&ctx, dates[2], 0.0, 100.0)
|
||||
.expect("default selection");
|
||||
assert!(default_selected.is_empty());
|
||||
let default_risk_decisions = default_strategy.selection_risk_decisions(&ctx, dates[2]);
|
||||
assert_eq!(default_risk_decisions.len(), 1);
|
||||
assert_eq!(default_risk_decisions[0].symbol, symbol);
|
||||
assert_eq!(default_risk_decisions[0].rule_code, "kcb");
|
||||
assert!(
|
||||
default_risk_decisions[0]
|
||||
.diagnostic_line()
|
||||
.starts_with("risk_decision=")
|
||||
);
|
||||
|
||||
let mut cfg = default_cfg;
|
||||
cfg.risk_config.static_rules.reject_kcb_selection = false;
|
||||
cfg.risk_config.static_rules.reject_kcb_buy = false;
|
||||
let configured_strategy = OmniMicroCapStrategy::new(cfg);
|
||||
let (selected, _) = configured_strategy
|
||||
.select_symbols(&ctx, dates[2], 0.0, 100.0)
|
||||
.expect("configured selection");
|
||||
assert!(
|
||||
configured_strategy
|
||||
.selection_risk_decisions(&ctx, dates[2])
|
||||
.is_empty()
|
||||
);
|
||||
|
||||
assert_eq!(selected, vec![symbol.to_string()]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,6 +217,10 @@ pub fn built_in_strategy_manual() -> StrategyAiManual {
|
||||
title: "filter.stock_expr / risk.stop_loss / risk.take_profit / allocation.buy_scale".to_string(),
|
||||
detail: "表达式型规则,支持多条组合。stop_loss/take_profit 多条按 OR 组合,filter.stock_expr 多条按 AND 组合。".to_string(),
|
||||
},
|
||||
ManualSection {
|
||||
title: "risk.policy / risk.blacklist".to_string(),
|
||||
detail: "统一配置 FIDC 基础风控。risk.policy(...) 支持 reject_st_selection、reject_st_buy、reject_paused_selection、reject_paused_buy、reject_paused_sell、reject_inactive_selection、reject_inactive_buy、reject_inactive_sell、reject_new_listing_selection、reject_new_listing_buy、reject_kcb_selection、reject_kcb_buy、reject_one_yuan_selection、reject_one_yuan_buy、respect_allow_buy_sell、reject_upper_limit_selection、reject_lower_limit_selection、reject_upper_limit_buy、reject_lower_limit_sell、forbid_same_day_rebuy_after_sell、volume_limit_enabled、volume_percent、commission_rate、minimum_commission、stamp_tax_rate_before_change、stamp_tax_rate_after_change、stamp_tax_change_date 等命名参数;risk.blacklist([\"600000.SH\"]) 写策略级黑名单。ST、停牌、退市、科创、一元、涨跌停、同日卖出禁买、成交量和费用等基础风控必须走 risk.policy 或运行态 RiskLimits,不要写进 universe.exclude 或 filter.stock_expr。PG/Source Lake 是真相源,Redis 只可做当日锁、热配置缓存和配置变更通知。".to_string(),
|
||||
},
|
||||
ManualSection {
|
||||
title: "corporate_actions.dividend_reinvestment".to_string(),
|
||||
detail: "支持 corporate_actions.dividend_reinvestment(true)。开启后,现金分红到账会优先按 round lot 回补成同一只股票,零头保留为现金。".to_string(),
|
||||
@@ -335,7 +339,7 @@ pub fn built_in_strategy_manual() -> StrategyAiManual {
|
||||
ManualFunction { name: "vma".to_string(), signature: "vma(60)".to_string(), detail: "rolling_mean(\"volume\", lookback) 的便捷别名,用于任意窗口成交量均线,例如 vma(5) < vma(60)。".to_string() },
|
||||
ManualFunction { name: "rolling_sum / rolling_min / rolling_max".to_string(), signature: "rolling_sum(\"volume\", 20)".to_string(), detail: "任意数值字段滚动求和、最小值、最大值。可用于量能收缩、区间高低点、资金活跃度等过滤或排序。".to_string() },
|
||||
ManualFunction { name: "rolling_stddev / stddev / rolling_zscore / pct_change".to_string(), signature: "stddev(\"close\", 20) / pct_change(\"close\", 10)".to_string(), detail: "滚动标准差、最新值 Z 分数和区间涨跌幅。pct_change(field, n) 会读取 n+1 个窗口点并计算 latest / first - 1。".to_string() },
|
||||
ManualFunction { name: "数据湖指标因子".to_string(), signature: "factor_value(\"ths_valid_turnover_stock\", 1)".to_string(), detail: "ficlaw-data 数据湖中已预计算的指标会进入 extra_factors,可用 factor(\"字段\")、factors[\"字段\"]、factor_value(\"字段\", lookback) 或 rolling_mean(\"字段\", n) 读取。市值类指标统一提供亿元口径别名 ths_market_value_stock、ths_market_value_stock_bn、ths_current_mv_stock、ths_current_mv_stock_bn,同时保留 raw 后缀原始值。".to_string() },
|
||||
ManualFunction { name: "Source Lake 指标因子".to_string(), signature: "factor_value(\"ths_valid_turnover_stock\", 1)".to_string(), detail: "Strategy Factory Source Lake 中已完成 PIT/as-of 审计的 source rows 字段、已发布指标或因子 artifact 会进入 extra_factors,可用 factor(\"字段\")、factors[\"字段\"]、factor_value(\"字段\", lookback) 或 rolling_mean(\"字段\", n) 读取。市值类指标统一提供亿元口径别名 ths_market_value_stock、ths_market_value_stock_bn、ths_current_mv_stock、ths_current_mv_stock_bn,同时保留 raw 后缀原始值。".to_string() },
|
||||
ManualFunction { name: "round/floor/ceil/abs/min/max/clamp".to_string(), signature: "round(x)".to_string(), detail: "常用数值函数。".to_string() },
|
||||
ManualFunction { name: "safe_div".to_string(), signature: "safe_div(lhs, rhs, fallback)".to_string(), detail: "安全除法。".to_string() },
|
||||
ManualFunction { name: "contains/starts_with/ends_with/lower/upper/trim/strlen".to_string(), signature: "starts_with(symbol, \"60\")".to_string(), detail: "字符串辅助函数。".to_string() },
|
||||
@@ -435,7 +439,8 @@ pub fn render_manual_markdown(manual: &StrategyAiManual) -> String {
|
||||
out.push_str("## AI 代码生成硬约束\n");
|
||||
out.push_str("- 只输出完整 `engine-script` 代码;第一行必须是 `strategy(\"...\")`、`let`、`fn`、`const` 或 `//`。\n");
|
||||
out.push_str("- 禁止输出 Markdown、解释、推理过程、JSON 包装、手册复述或结果报告。\n");
|
||||
out.push_str("- 只使用支持语句块:`market`、`benchmark`、`signal`、`rebalance.every_days(...).at([...])`、`selection.limit`、`selection.market_cap_band`、`filter.stock_ma`、`filter.stock_expr`、`ordering.rank_by`、`ordering.rank_expr`、`allocation.buy_scale`、`risk.stop_loss`、`risk.take_profit`、`risk.index_exposure`、`execution.matching_type`、`execution.slippage`、`universe.exclude`。\n");
|
||||
out.push_str("- 只使用支持语句块:`market`、`benchmark`、`signal`、`rebalance.every_days(...).at([...])`、`selection.limit`、`selection.market_cap_band`、`filter.stock_ma`、`filter.stock_expr`、`ordering.rank_by`、`ordering.rank_expr`、`allocation.buy_scale`、`risk.stop_loss`、`risk.take_profit`、`risk.index_exposure`、`risk.policy`、`risk.blacklist`、`execution.matching_type`、`execution.slippage`、`universe.exclude`。\n");
|
||||
out.push_str("- `universe.exclude` 只用于用户明确要求的业务排除项;ST、停牌、退市、新股、科创、一元、涨跌停、同日卖出禁买、成交量、手续费和印花税等基础风控必须写 `risk.policy(...)` 或由运行态 RiskLimits 注入。\n");
|
||||
out.push_str("- 禁止伪 DSL:`filter(...)`、`rank(...)`、`select.top(...)`、`weight.equal(...)`、`sell_rule(...)`、`backtest(...)`、`risk.max_position(...)`。\n");
|
||||
out.push_str("- 市值表达式字段只能用 `market_cap` 或 `free_float_cap`;不要使用数据库原始字段 `float_market_cap`。\n");
|
||||
out.push_str("- 任意窗口价格均线使用 `rolling_mean(\"close\", n)` 或 `ma(\"close\", n)`;任意窗口均量使用 `rolling_mean(\"volume\", n)` 或 `vma(n)`;不要使用未列出的 `ma60`、`stock_ma60`、`signal_ma60` 或 `benchmark_ma60` 变量。\n");
|
||||
@@ -444,6 +449,7 @@ pub fn render_manual_markdown(manual: &StrategyAiManual) -> String {
|
||||
out.push_str(
|
||||
"- `risk.index_exposure(...)` 只能传一个表达式;不要生成 `risk.exposure(...)`。\n",
|
||||
);
|
||||
out.push_str("- `filter.stock_expr(...)` 只写 alpha 或业务过滤条件;不要把 `!is_st`、`!paused`、`!at_upper_limit`、`!at_lower_limit` 这类基础风控散落在过滤表达式里。\n");
|
||||
out.push_str("- 完整三元表达式 `cond ? a : b` 可在表达式参数中使用;若当前运行环境报 `Unknown operator: '?'`,先重编译并重启回测服务,不要改写策略语义掩盖运行时漂移。\n");
|
||||
out.push_str("- `next_bar_open` 的选股、排序和仓位信号来自决策日,订单在下一可交易开盘撮合;不要使用执行日价格作为下单前信号。\n");
|
||||
out.push_str("- `execution.matching_type(...)` 和 `execution.slippage(...)` 必须使用手册列出的合法取值。\n\n");
|
||||
@@ -517,17 +523,17 @@ pub fn build_generation_prompt(
|
||||
prompt.push_str("- 不要输出解释文本。\n");
|
||||
prompt.push_str("- 必须使用 strategy(\"...\") { ... } 语法。\n");
|
||||
prompt.push_str("- 如需自定义参数,使用 let 和 fn。\n");
|
||||
prompt.push_str("- 优先使用 ficlaw-data 数据湖和运行时已存在字段、factors[...]。\n\n");
|
||||
prompt.push_str("- 优先使用 Strategy Factory Source Lake 已注册 source rows 字段、已发布指标/因子 artifact 和运行时已存在字段、factors[...];不要回退 ficlaw-data、QuantAPI、旧数据中心 HTTP、ClickHouse 或临时文件。\n\n");
|
||||
prompt.push_str("- 生成的代码必须能转换为 strategy_spec 并提交 POST /v1/backtests。\n");
|
||||
prompt.push_str("- 用户指定“持仓N只、目标持仓N、stocknum=N、selection.limit(N)”时,必须把最终持仓槽位写成 N;用户指定“至少/不少于N只”时,最终持仓槽位必须 >= N。\n");
|
||||
prompt.push_str("- ");
|
||||
prompt.push_str(DEFAULT_THREE_YEAR_RETURN_TARGET_PROMPT);
|
||||
prompt.push('\n');
|
||||
prompt.push_str("- 不要使用手册未列出的字段、函数或外部平台 API 名称。\n\n");
|
||||
prompt.push_str("只允许使用这些可编译语句:market、benchmark、signal、rebalance.every_days(...).at([...])、selection.limit、selection.market_cap_band、filter.stock_ma、filter.stock_expr、ordering.rank_by、ordering.rank_expr、allocation.buy_scale、risk.stop_loss、risk.take_profit、risk.index_exposure、execution.matching_type、execution.slippage、universe.exclude。禁止输出 filter(...)、rank(...)、select.top(...)、weight.equal()、sell_rule(...)、backtest(...)、risk.max_position(...) 这类未支持伪语法。\n");
|
||||
prompt.push_str("参数形态必须严格:selection.market_cap_band 必须写 field=\"market_cap\" 或 field=\"free_float_cap\", lower=..., upper=...;禁止使用 float_market_cap;禁止使用 ma60、stock_ma60、signal_ma60、benchmark_ma60,60日价格均线写 rolling_mean(\"close\", 60) 或 ma(\"close\", 60),任意窗口均量写 rolling_mean(\"volume\", n) 或 vma(n);不要生成 fn score() 这类零参数函数,股票字段排序直接写在 ordering.rank_expr 内或用带参数函数;布尔字段按布尔使用,写 !is_st、!paused、!at_upper_limit、!at_lower_limit,不要写 is_st == 0;risk.index_exposure 只能传一个数值表达式,不要使用 risk.exposure;完整三元表达式 cond ? a : b 可以使用,但不得输出残缺问号/冒号片段;日线回测 execution.matching_type 只能取 current_bar_close 或 next_bar_open,分钟线回测只能取 minute_last;不要把 vwap、twap、open_auction、minute_best_own、minute_best_counterparty 写成全局 matching_type;next_bar_open 只能使用决策日信号,不能把执行日价格当作下单前信息;execution.slippage 必须写 execution.slippage(\"none\") 或 execution.slippage(\"price_ratio\", 0.001)。\n");
|
||||
prompt.push_str("只允许使用这些可编译语句:market、benchmark、signal、rebalance.every_days(...).at([...])、selection.limit、selection.market_cap_band、filter.stock_ma、filter.stock_expr、ordering.rank_by、ordering.rank_expr、allocation.buy_scale、risk.stop_loss、risk.take_profit、risk.index_exposure、risk.policy、risk.blacklist、execution.matching_type、execution.slippage、universe.exclude。universe.exclude 只用于用户明确要求的业务排除项,不能表达 FIDC 基础风控。禁止输出 filter(...)、rank(...)、select.top(...)、weight.equal()、sell_rule(...)、backtest(...)、risk.max_position(...) 这类未支持伪语法。\n");
|
||||
prompt.push_str("参数形态必须严格:selection.market_cap_band 必须写 field=\"market_cap\" 或 field=\"free_float_cap\", lower=..., upper=...;禁止使用 float_market_cap;禁止使用 ma60、stock_ma60、signal_ma60、benchmark_ma60,60日价格均线写 rolling_mean(\"close\", 60) 或 ma(\"close\", 60),任意窗口均量写 rolling_mean(\"volume\", n) 或 vma(n);不要生成 fn score() 这类零参数函数,股票字段排序直接写在 ordering.rank_expr 内或用带参数函数;布尔字段按布尔使用,不要写 is_st == 0;filter.stock_expr 只写 alpha 或业务过滤条件,不要把 !is_st、!paused、!at_upper_limit、!at_lower_limit 这类基础风控散落在表达式里;risk.index_exposure 只能传一个数值表达式,不要使用 risk.exposure;risk.policy 只写 FIDC 基础风控、成交量和交易成本命名参数,例如 reject_st_selection=true、reject_paused_selection=true、reject_inactive_selection=true、reject_new_listing_selection=true、reject_kcb_selection=true、reject_one_yuan_selection=true、forbid_same_day_rebuy_after_sell=true、reject_upper_limit_selection=true、reject_lower_limit_selection=true、reject_upper_limit_buy=true、reject_lower_limit_sell=true、volume_limit_enabled=true、volume_percent=0.25、commission_rate=0.0003、minimum_commission=5、stamp_tax_rate_after_change=0.0005,不要用它表达策略择时或收益规则;完整三元表达式 cond ? a : b 可以使用,但不得输出残缺问号/冒号片段;日线回测 execution.matching_type 只能取 current_bar_close 或 next_bar_open,分钟线回测只能取 minute_last;不要把 vwap、twap、open_auction、minute_best_own、minute_best_counterparty 写成全局 matching_type;next_bar_open 只能使用决策日信号,不能把执行日价格当作下单前信息;execution.slippage 必须写 execution.slippage(\"none\") 或 execution.slippage(\"price_ratio\", 0.001)。\n");
|
||||
prompt.push_str("回测成功但 tradeCount=0 或 holdingCount=0 是无效策略;第一版必须保持稳定买入覆盖率,复杂因子只能在后续优化中逐步加严。\n");
|
||||
prompt.push_str("可参考但不要照抄的最小模板,回复时不要包含 ``` 代码围栏:\nstrategy(\"cn_a_smallcap_factor_rotation\") {\nmarket(\"CN_A\")\nbenchmark(\"000852.SH\")\nsignal(\"000001.SH\")\nrebalance.every_days(5).at([\"10:18\"])\nselection.limit(40)\nselection.market_cap_band(field=\"market_cap\", lower=0, upper=1000)\nfilter.stock_expr(listed_days >= 60 && !is_st && !paused && close > 2 && !at_upper_limit && !at_lower_limit)\nordering.rank_by(\"market_cap\", \"asc\")\nallocation.buy_scale(1.0)\nrisk.index_exposure(1.0)\nrisk.stop_loss(holding_return < -0.08)\nexecution.slippage(\"price_ratio\", 0.001)\n}\n\n");
|
||||
prompt.push_str("可参考但不要照抄的最小模板,回复时不要包含 ``` 代码围栏:\nstrategy(\"cn_a_smallcap_factor_rotation\") {\nmarket(\"CN_A\")\nbenchmark(\"000852.SH\")\nsignal(\"000001.SH\")\nrebalance.every_days(5).at([\"10:18\"])\nselection.limit(40)\nselection.market_cap_band(field=\"market_cap\", lower=0, upper=1000)\nfilter.stock_expr(listed_days >= 60 && close > 2)\nordering.rank_by(\"market_cap\", \"asc\")\nallocation.buy_scale(1.0)\nrisk.policy(reject_st_selection=true, reject_paused_selection=true, reject_inactive_selection=true, reject_new_listing_selection=true, reject_kcb_selection=true, reject_one_yuan_selection=true, reject_upper_limit_selection=true, reject_lower_limit_selection=true, reject_upper_limit_buy=true, reject_lower_limit_sell=true, forbid_same_day_rebuy_after_sell=true, volume_limit_enabled=true, volume_percent=0.25, commission_rate=0.0003, minimum_commission=5)\nrisk.index_exposure(1.0)\nrisk.stop_loss(holding_return < -0.08)\nexecution.slippage(\"price_ratio\", 0.001)\n}\n\n");
|
||||
prompt.push_str("用户目标:\n");
|
||||
prompt.push_str(&format!("- {}\n", request.user_goal));
|
||||
if !request.constraints.is_empty() {
|
||||
@@ -557,7 +563,7 @@ pub fn build_optimization_prompt(
|
||||
prompt.push_str("持仓数量属于策略合同,不是优化自由参数。原策略或用户目标明确 stocknum、selection.limit、目标持仓N只或不少于N只时,优化后必须保留该目标槽位或满足最低槽位,不能为了收益或交易次数擅自改小。\n");
|
||||
prompt.push_str(DEFAULT_THREE_YEAR_RETURN_TARGET_PROMPT);
|
||||
prompt.push('\n');
|
||||
prompt.push_str("可以使用所有已入库日频字段、指标因子和表达式函数,例如 rolling_mean/ma/vma/rolling_sum/rolling_stddev/pct_change/factor/factor_value/factors;如上一轮无交易或质量分过低,必须先扩大候选覆盖并修正不可交易过滤,再优化收益。\n");
|
||||
prompt.push_str("可以使用 Strategy Factory Source Lake 已注册并完成 PIT/as-of 审计的日频 source rows 字段、已发布指标/因子 artifact 和表达式函数,例如 rolling_mean/ma/vma/rolling_sum/rolling_stddev/pct_change/factor/factor_value/factors;不要回退 ficlaw-data、QuantAPI、旧数据中心 HTTP、ClickHouse 或临时文件。如上一轮无交易或质量分过低,必须先扩大候选覆盖并修正不可交易过滤,再优化收益。\n");
|
||||
prompt.push_str("优化目标:\n");
|
||||
prompt.push_str(&format!("- {}\n\n", request.objective));
|
||||
prompt.push_str("当前策略代码如下,仅作为输入参考;回复时不要包含 Markdown 代码围栏:\n");
|
||||
@@ -600,6 +606,9 @@ mod tests {
|
||||
|
||||
assert!(prompt.contains("三年回测区间策略总收益 >= 150% 即视为满足收益目标"));
|
||||
assert!(prompt.contains("不得把已达标策略判为失败"));
|
||||
assert!(prompt.contains("Strategy Factory Source Lake 已注册 source rows 字段"));
|
||||
assert!(prompt.contains("不要回退 ficlaw-data"));
|
||||
assert!(prompt.contains("ClickHouse"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -616,5 +625,8 @@ mod tests {
|
||||
|
||||
assert!(prompt.contains("三年回测区间策略总收益 >= 150% 即视为满足收益目标"));
|
||||
assert!(prompt.contains("继续优化夏普、回撤、换手和稳定性"));
|
||||
assert!(prompt.contains("Strategy Factory Source Lake 已注册并完成 PIT/as-of 审计"));
|
||||
assert!(prompt.contains("不要回退 ficlaw-data"));
|
||||
assert!(prompt.contains("ClickHouse"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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=")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user