实现FIDC配置化风控与交易成本
This commit is contained in:
@@ -12,7 +12,7 @@ use crate::events::{
|
|||||||
};
|
};
|
||||||
use crate::instrument::Instrument;
|
use crate::instrument::Instrument;
|
||||||
use crate::portfolio::PortfolioState;
|
use crate::portfolio::PortfolioState;
|
||||||
use crate::risk_control::ChinaAShareRiskControl;
|
use crate::risk_control::{ChinaAShareRiskControl, FidcRiskControlConfig};
|
||||||
use crate::rules::{EquityRuleHooks, RuleCheck};
|
use crate::rules::{EquityRuleHooks, RuleCheck};
|
||||||
use crate::strategy::{
|
use crate::strategy::{
|
||||||
AlgoOrderStyle, OpenOrderView, OrderIntent, StrategyDecision, TargetPortfolioOrderPricing,
|
AlgoOrderStyle, OpenOrderView, OrderIntent, StrategyDecision, TargetPortfolioOrderPricing,
|
||||||
@@ -171,6 +171,8 @@ pub struct BrokerSimulator<C, R> {
|
|||||||
strict_value_budget: bool,
|
strict_value_budget: bool,
|
||||||
aiquant_execution_rules: bool,
|
aiquant_execution_rules: bool,
|
||||||
same_day_buy_close_mark_at_fill: 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>,
|
intraday_execution_start_time: Option<NaiveTime>,
|
||||||
runtime_intraday_start_time: Cell<Option<NaiveTime>>,
|
runtime_intraday_start_time: Cell<Option<NaiveTime>>,
|
||||||
runtime_intraday_end_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,
|
strict_value_budget: false,
|
||||||
aiquant_execution_rules: false,
|
aiquant_execution_rules: false,
|
||||||
same_day_buy_close_mark_at_fill: 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,
|
intraday_execution_start_time: None,
|
||||||
runtime_intraday_start_time: Cell::new(None),
|
runtime_intraday_start_time: Cell::new(None),
|
||||||
runtime_intraday_end_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,
|
strict_value_budget: false,
|
||||||
aiquant_execution_rules: false,
|
aiquant_execution_rules: false,
|
||||||
same_day_buy_close_mark_at_fill: 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,
|
intraday_execution_start_time: None,
|
||||||
runtime_intraday_start_time: Cell::new(None),
|
runtime_intraday_start_time: Cell::new(None),
|
||||||
runtime_intraday_end_time: Cell::new(None),
|
runtime_intraday_end_time: Cell::new(None),
|
||||||
@@ -259,6 +265,14 @@ impl<C, R> BrokerSimulator<C, R> {
|
|||||||
self
|
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 {
|
pub fn same_day_buy_close_mark_at_fill(&self) -> bool {
|
||||||
self.same_day_buy_close_mark_at_fill
|
self.same_day_buy_close_mark_at_fill
|
||||||
}
|
}
|
||||||
@@ -1381,6 +1395,34 @@ where
|
|||||||
.retain(|existing| existing.order_id != order_id);
|
.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) {
|
fn extend_report(into: &mut BrokerExecutionReport, mut other: BrokerExecutionReport) {
|
||||||
into.order_events.append(&mut other.order_events);
|
into.order_events.append(&mut other.order_events);
|
||||||
into.fill_events.append(&mut other.fill_events);
|
into.fill_events.append(&mut other.fill_events);
|
||||||
@@ -2181,16 +2223,17 @@ where
|
|||||||
} else {
|
} else {
|
||||||
ChinaAShareRiskControl::buy_check_price(snapshot, self.execution_price_field)
|
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,
|
date,
|
||||||
candidate,
|
candidate,
|
||||||
snapshot,
|
snapshot,
|
||||||
instrument,
|
instrument,
|
||||||
check_price,
|
check_price,
|
||||||
|
&self.risk_config,
|
||||||
) {
|
) {
|
||||||
return RuleCheck::reject(reason);
|
return RuleCheck::reject(reason);
|
||||||
}
|
}
|
||||||
if !self.aiquant_execution_rules {
|
if !self.aiquant_execution_rules && !self.rules.duplicates_standard_china_risk() {
|
||||||
return self
|
return self
|
||||||
.rules
|
.rules
|
||||||
.can_buy(date, snapshot, candidate, self.execution_price_field);
|
.can_buy(date, snapshot, candidate, self.execution_price_field);
|
||||||
@@ -2220,16 +2263,20 @@ where
|
|||||||
} else {
|
} else {
|
||||||
ChinaAShareRiskControl::buy_check_price(snapshot, self.execution_price_field)
|
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,
|
date,
|
||||||
candidate,
|
candidate,
|
||||||
snapshot,
|
snapshot,
|
||||||
instrument,
|
instrument,
|
||||||
check_price,
|
check_price,
|
||||||
|
&self.risk_config,
|
||||||
) {
|
) {
|
||||||
return RuleCheck::reject(reason);
|
return RuleCheck::reject(reason);
|
||||||
}
|
}
|
||||||
if !self.aiquant_execution_rules {
|
if !self.aiquant_execution_rules && !self.rules.duplicates_standard_china_risk() {
|
||||||
return self
|
return self
|
||||||
.rules
|
.rules
|
||||||
.can_buy(date, snapshot, candidate, self.execution_price_field);
|
.can_buy(date, snapshot, candidate, self.execution_price_field);
|
||||||
@@ -2263,17 +2310,18 @@ where
|
|||||||
} else {
|
} else {
|
||||||
ChinaAShareRiskControl::sell_check_price(snapshot, self.execution_price_field)
|
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,
|
date,
|
||||||
candidate,
|
candidate,
|
||||||
snapshot,
|
snapshot,
|
||||||
instrument,
|
instrument,
|
||||||
Some(position),
|
Some(position),
|
||||||
check_price,
|
check_price,
|
||||||
|
&self.risk_config,
|
||||||
) {
|
) {
|
||||||
return RuleCheck::reject(reason);
|
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(
|
return self.rules.can_sell(
|
||||||
date,
|
date,
|
||||||
snapshot,
|
snapshot,
|
||||||
@@ -2929,6 +2977,7 @@ where
|
|||||||
net_cash_flow: net_cash,
|
net_cash_flow: net_cash,
|
||||||
reason: reason.to_string(),
|
reason: reason.to_string(),
|
||||||
});
|
});
|
||||||
|
self.mark_same_day_sold(date, symbol);
|
||||||
Self::emit_order_process_event(
|
Self::emit_order_process_event(
|
||||||
report,
|
report,
|
||||||
date,
|
date,
|
||||||
@@ -5065,10 +5114,16 @@ where
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
match side {
|
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")
|
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")
|
Some("open at or below lower limit")
|
||||||
}
|
}
|
||||||
_ => None,
|
_ => None,
|
||||||
@@ -5991,6 +6046,7 @@ mod tests {
|
|||||||
stamp_tax_rate_before_change: 0.0005,
|
stamp_tax_rate_before_change: 0.0005,
|
||||||
stamp_tax_rate_after_change: 0.0005,
|
stamp_tax_rate_after_change: 0.0005,
|
||||||
minimum_commission: 5.0,
|
minimum_commission: 5.0,
|
||||||
|
..ChinaAShareCostModel::default()
|
||||||
},
|
},
|
||||||
ChinaEquityRuleHooks,
|
ChinaEquityRuleHooks,
|
||||||
PriceField::Last,
|
PriceField::Last,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ use std::collections::BTreeMap;
|
|||||||
use chrono::NaiveDate;
|
use chrono::NaiveDate;
|
||||||
|
|
||||||
use crate::events::OrderSide;
|
use crate::events::OrderSide;
|
||||||
|
use crate::risk_control::TradingConstraintConfig;
|
||||||
|
|
||||||
pub const STOCK_PIT_TAX_CHANGE_DATE: (i32, u32, u32) = (2023, 8, 28);
|
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 commission_rate: f64,
|
||||||
pub stamp_tax_rate_before_change: f64,
|
pub stamp_tax_rate_before_change: f64,
|
||||||
pub stamp_tax_rate_after_change: f64,
|
pub stamp_tax_rate_after_change: f64,
|
||||||
|
pub stamp_tax_change_date: NaiveDate,
|
||||||
pub minimum_commission: f64,
|
pub minimum_commission: f64,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,6 +49,7 @@ impl Default for ChinaAShareCostModel {
|
|||||||
commission_rate: 0.0008,
|
commission_rate: 0.0008,
|
||||||
stamp_tax_rate_before_change: 0.001,
|
stamp_tax_rate_before_change: 0.001,
|
||||||
stamp_tax_rate_after_change: 0.0005,
|
stamp_tax_rate_after_change: 0.0005,
|
||||||
|
stamp_tax_change_date: default_stamp_tax_change_date(),
|
||||||
minimum_commission: 5.0,
|
minimum_commission: 5.0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -55,13 +58,23 @@ impl Default for ChinaAShareCostModel {
|
|||||||
impl ChinaAShareCostModel {
|
impl ChinaAShareCostModel {
|
||||||
pub fn aiquant_default() -> Self {
|
pub fn aiquant_default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
commission_rate: 0.0008,
|
commission_rate: 0.0003,
|
||||||
stamp_tax_rate_before_change: 0.0005,
|
stamp_tax_rate_before_change: 0.0005,
|
||||||
stamp_tax_rate_after_change: 0.0005,
|
stamp_tax_rate_after_change: 0.0005,
|
||||||
..Self::default()
|
..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 {
|
pub fn commission_for(&self, gross_amount: f64) -> f64 {
|
||||||
if gross_amount <= 0.0 {
|
if gross_amount <= 0.0 {
|
||||||
return 0.0;
|
return 0.0;
|
||||||
@@ -70,13 +83,7 @@ impl ChinaAShareCostModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn stamp_tax_rate_for(&self, date: NaiveDate) -> f64 {
|
pub fn stamp_tax_rate_for(&self, date: NaiveDate) -> f64 {
|
||||||
let change_date = NaiveDate::from_ymd_opt(
|
if date < self.stamp_tax_change_date {
|
||||||
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 {
|
|
||||||
self.stamp_tax_rate_before_change
|
self.stamp_tax_rate_before_change
|
||||||
} else {
|
} else {
|
||||||
self.stamp_tax_rate_after_change
|
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 {
|
impl CostModel for ChinaAShareCostModel {
|
||||||
fn calculate(&self, date: NaiveDate, side: OrderSide, gross_amount: f64) -> TradingCost {
|
fn calculate(&self, date: NaiveDate, side: OrderSide, gross_amount: f64) -> TradingCost {
|
||||||
if gross_amount <= 0.0 {
|
if gross_amount <= 0.0 {
|
||||||
@@ -180,9 +196,41 @@ mod tests {
|
|||||||
let model = ChinaAShareCostModel::aiquant_default();
|
let model = ChinaAShareCostModel::aiquant_default();
|
||||||
let date = NaiveDate::from_ymd_opt(2025, 11, 11).expect("valid date");
|
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!(
|
assert!(
|
||||||
(model.stamp_tax_for(date, OrderSide::Sell, 245_747.007) - 122.8735035).abs() < 1e-9
|
(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::calendar::TradingCalendar;
|
||||||
use crate::futures::{FuturesCommissionType, FuturesTradingParameter};
|
use crate::futures::{FuturesCommissionType, FuturesTradingParameter};
|
||||||
use crate::instrument::Instrument;
|
use crate::instrument::Instrument;
|
||||||
use crate::risk_control::ChinaAShareRiskControl;
|
use crate::risk_control::{ChinaAShareRiskControl, FidcRiskControlConfig};
|
||||||
|
|
||||||
mod date_format {
|
mod date_format {
|
||||||
use chrono::NaiveDate;
|
use chrono::NaiveDate;
|
||||||
@@ -2607,6 +2607,21 @@ impl DataSet {
|
|||||||
.unwrap_or(&[])
|
.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(
|
pub fn require_market(
|
||||||
&self,
|
&self,
|
||||||
date: NaiveDate,
|
date: NaiveDate,
|
||||||
@@ -3647,56 +3662,102 @@ fn build_eligible_universe(
|
|||||||
instruments: &HashMap<String, Instrument>,
|
instruments: &HashMap<String, Instrument>,
|
||||||
) -> BTreeMap<NaiveDate, Vec<EligibleUniverseSnapshot>> {
|
) -> BTreeMap<NaiveDate, Vec<EligibleUniverseSnapshot>> {
|
||||||
let mut per_date = BTreeMap::<NaiveDate, Vec<EligibleUniverseSnapshot>>::new();
|
let mut per_date = BTreeMap::<NaiveDate, Vec<EligibleUniverseSnapshot>>::new();
|
||||||
|
let risk_config = FidcRiskControlConfig::default();
|
||||||
|
|
||||||
for (date, factors) in factor_by_date {
|
for (date, factors) in factor_by_date {
|
||||||
let mut rows = Vec::new();
|
let rows = build_eligible_universe_for_date_from_factors(
|
||||||
for factor in factors {
|
*date,
|
||||||
if factor.market_cap_bn <= 0.0 || !factor.market_cap_bn.is_finite() {
|
factors,
|
||||||
continue;
|
candidate_by_date,
|
||||||
}
|
market_by_date,
|
||||||
let Some(candidate) = candidate_by_date.get(date).and_then(|rows| {
|
instruments,
|
||||||
find_arc_by_symbol(rows, &factor.symbol, |row| row.symbol.as_str())
|
&risk_config,
|
||||||
}) 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))
|
|
||||||
});
|
|
||||||
per_date.insert(*date, rows);
|
per_date.insert(*date, rows);
|
||||||
}
|
}
|
||||||
|
|
||||||
per_date
|
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)]
|
#[cfg(test)]
|
||||||
fn instrument_passes_baseline_selection(instrument: Option<&Instrument>, date: NaiveDate) -> bool {
|
fn instrument_passes_baseline_selection(instrument: Option<&Instrument>, date: NaiveDate) -> bool {
|
||||||
ChinaAShareRiskControl::instrument_rejection_reason(instrument, date).is_none()
|
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);
|
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]
|
#[test]
|
||||||
fn decision_market_cap_keeps_pre_adjusted_factor() {
|
fn decision_market_cap_keeps_pre_adjusted_factor() {
|
||||||
let date = NaiveDate::parse_from_str("2025-01-06", "%Y-%m-%d").unwrap();
|
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::metrics::{BacktestMetrics, compute_backtest_metrics};
|
||||||
use crate::portfolio::{CashReceivable, HoldingSummary, PortfolioState};
|
use crate::portfolio::{CashReceivable, HoldingSummary, PortfolioState};
|
||||||
|
use crate::risk_control::FidcRiskDecisionAudit;
|
||||||
use crate::rules::EquityRuleHooks;
|
use crate::rules::EquityRuleHooks;
|
||||||
use crate::scheduler::{ScheduleRule, ScheduleStage, Scheduler, default_stage_time};
|
use crate::scheduler::{ScheduleRule, ScheduleStage, Scheduler, default_stage_time};
|
||||||
use crate::strategy::{
|
use crate::strategy::{
|
||||||
@@ -88,6 +89,7 @@ pub struct BacktestResult {
|
|||||||
pub strategy_name: String,
|
pub strategy_name: String,
|
||||||
pub equity_curve: Vec<DailyEquityPoint>,
|
pub equity_curve: Vec<DailyEquityPoint>,
|
||||||
pub benchmark_series: Vec<BenchmarkSnapshot>,
|
pub benchmark_series: Vec<BenchmarkSnapshot>,
|
||||||
|
pub risk_decisions: Vec<FidcRiskDecisionAudit>,
|
||||||
pub order_events: Vec<OrderEvent>,
|
pub order_events: Vec<OrderEvent>,
|
||||||
pub fills: Vec<FillEvent>,
|
pub fills: Vec<FillEvent>,
|
||||||
pub position_events: Vec<PositionEvent>,
|
pub position_events: Vec<PositionEvent>,
|
||||||
@@ -1679,6 +1681,7 @@ where
|
|||||||
.unwrap_or(true)
|
.unwrap_or(true)
|
||||||
})
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
|
risk_decisions: Vec::new(),
|
||||||
order_events: Vec::new(),
|
order_events: Vec::new(),
|
||||||
fills: Vec::new(),
|
fills: Vec::new(),
|
||||||
position_events: Vec::new(),
|
position_events: Vec::new(),
|
||||||
@@ -2745,6 +2748,7 @@ where
|
|||||||
let day_fills = report.fill_events.clone();
|
let day_fills = report.fill_events.clone();
|
||||||
let broker_diagnostics = report.diagnostics.clone();
|
let broker_diagnostics = report.diagnostics.clone();
|
||||||
self.extend_result(&mut result, report);
|
self.extend_result(&mut result, report);
|
||||||
|
result.risk_decisions.extend(decision.risk_decisions);
|
||||||
|
|
||||||
let benchmark =
|
let benchmark =
|
||||||
self.data
|
self.data
|
||||||
|
|||||||
@@ -69,7 +69,10 @@ pub use platform_strategy_spec::{
|
|||||||
StrategyRuntimeSpec, platform_expr_config_from_spec, platform_expr_config_from_value,
|
StrategyRuntimeSpec, platform_expr_config_from_spec, platform_expr_config_from_value,
|
||||||
};
|
};
|
||||||
pub use portfolio::{CashReceivable, HoldingSummary, PendingCashFlow, PortfolioState, Position};
|
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 rules::{ChinaEquityRuleHooks, EquityRuleHooks, RuleCheck};
|
||||||
pub use scheduler::{
|
pub use scheduler::{
|
||||||
ScheduleFrequency, ScheduleRule, ScheduleStage, ScheduleTimeRule, Scheduler, default_stage_time,
|
ScheduleFrequency, ScheduleRule, ScheduleStage, ScheduleTimeRule, Scheduler, default_stage_time,
|
||||||
|
|||||||
@@ -13,7 +13,9 @@ use crate::data::{
|
|||||||
use crate::engine::BacktestError;
|
use crate::engine::BacktestError;
|
||||||
use crate::events::OrderSide;
|
use crate::events::OrderSide;
|
||||||
use crate::portfolio::PortfolioState;
|
use crate::portfolio::PortfolioState;
|
||||||
use crate::risk_control::ChinaAShareRiskControl;
|
use crate::risk_control::{
|
||||||
|
ChinaAShareRiskControl, FidcRiskControlConfig, FidcRiskDecisionAudit, RiskCheckScope,
|
||||||
|
};
|
||||||
use crate::scheduler::{
|
use crate::scheduler::{
|
||||||
ScheduleRule, ScheduleStage, ScheduleTimeRule, Scheduler, default_stage_time,
|
ScheduleRule, ScheduleStage, ScheduleTimeRule, Scheduler, default_stage_time,
|
||||||
};
|
};
|
||||||
@@ -215,7 +217,9 @@ pub struct PlatformExprStrategyConfig {
|
|||||||
pub minimum_commission: Option<f64>,
|
pub minimum_commission: Option<f64>,
|
||||||
pub stamp_tax_rate_before_change: Option<f64>,
|
pub stamp_tax_rate_before_change: Option<f64>,
|
||||||
pub stamp_tax_rate_after_change: Option<f64>,
|
pub stamp_tax_rate_after_change: Option<f64>,
|
||||||
|
pub stamp_tax_change_date: Option<NaiveDate>,
|
||||||
pub strict_value_budget: bool,
|
pub strict_value_budget: bool,
|
||||||
|
pub risk_config: FidcRiskControlConfig,
|
||||||
pub slippage_model: SlippageModel,
|
pub slippage_model: SlippageModel,
|
||||||
pub matching_type: MatchingType,
|
pub matching_type: MatchingType,
|
||||||
pub quote_quantity_limit: bool,
|
pub quote_quantity_limit: bool,
|
||||||
@@ -247,14 +251,7 @@ fn band_low(index_close) {
|
|||||||
round((index_close - 2000) * 4 / 500 + 7)
|
round((index_close - 2000) * 4 / 500 + 7)
|
||||||
}"#
|
}"#
|
||||||
.to_string(),
|
.to_string(),
|
||||||
universe_exclude: vec![
|
universe_exclude: vec!["bjse".to_string()],
|
||||||
"paused".to_string(),
|
|
||||||
"st".to_string(),
|
|
||||||
"kcb".to_string(),
|
|
||||||
"bjse".to_string(),
|
|
||||||
"one_yuan".to_string(),
|
|
||||||
"new_listing".to_string(),
|
|
||||||
],
|
|
||||||
market_cap_field: "market_cap".to_string(),
|
market_cap_field: "market_cap".to_string(),
|
||||||
market_cap_lower_expr: "band_low(signal_close)".to_string(),
|
market_cap_lower_expr: "band_low(signal_close)".to_string(),
|
||||||
market_cap_upper_expr: "band_low(signal_close) + 10".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,
|
minimum_commission: None,
|
||||||
stamp_tax_rate_before_change: None,
|
stamp_tax_rate_before_change: None,
|
||||||
stamp_tax_rate_after_change: None,
|
stamp_tax_rate_after_change: None,
|
||||||
|
stamp_tax_change_date: None,
|
||||||
strict_value_budget: false,
|
strict_value_budget: false,
|
||||||
|
risk_config: FidcRiskControlConfig::default(),
|
||||||
slippage_model: SlippageModel::None,
|
slippage_model: SlippageModel::None,
|
||||||
matching_type: MatchingType::MinuteLast,
|
matching_type: MatchingType::MinuteLast,
|
||||||
quote_quantity_limit: true,
|
quote_quantity_limit: true,
|
||||||
@@ -1003,7 +1002,7 @@ impl PlatformExprStrategy {
|
|||||||
.unwrap_or_else(|| NaiveTime::from_hms_opt(10, 18, 0).expect("valid 10:18"))
|
.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
|
if self.config.delayed_limit_open_exit_enabled
|
||||||
&& let Some(time) = self.config.delayed_limit_open_exit_time
|
&& 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 {
|
if let Some(value) = self.config.stamp_tax_rate_after_change {
|
||||||
model.stamp_tax_rate_after_change = value;
|
model.stamp_tax_rate_after_change = value;
|
||||||
}
|
}
|
||||||
|
if let Some(value) = self.config.stamp_tax_change_date {
|
||||||
|
model.stamp_tax_change_date = value;
|
||||||
|
}
|
||||||
model
|
model
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1378,6 +1380,7 @@ impl PlatformExprStrategy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn projected_execution_limit_rejection_reason(
|
fn projected_execution_limit_rejection_reason(
|
||||||
|
&self,
|
||||||
market: &DailyMarketSnapshot,
|
market: &DailyMarketSnapshot,
|
||||||
side: OrderSide,
|
side: OrderSide,
|
||||||
execution_price: f64,
|
execution_price: f64,
|
||||||
@@ -1385,11 +1388,18 @@ impl PlatformExprStrategy {
|
|||||||
if !execution_price.is_finite() || execution_price <= 0.0 {
|
if !execution_price.is_finite() || execution_price <= 0.0 {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
let static_rules = &self.config.risk_config.static_rules;
|
||||||
match side {
|
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")
|
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")
|
Some("open at or below lower limit")
|
||||||
}
|
}
|
||||||
_ => None,
|
_ => None,
|
||||||
@@ -1501,7 +1511,9 @@ impl PlatformExprStrategy {
|
|||||||
|
|
||||||
let mut quote_price =
|
let mut quote_price =
|
||||||
self.projected_apply_slippage(market, side, raw_quote_price, Some(take_qty));
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -1514,7 +1526,8 @@ impl PlatformExprStrategy {
|
|||||||
raw_quote_price,
|
raw_quote_price,
|
||||||
Some(take_qty),
|
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()
|
.is_some()
|
||||||
{
|
{
|
||||||
take_qty = 0;
|
take_qty = 0;
|
||||||
@@ -1545,7 +1558,9 @@ impl PlatformExprStrategy {
|
|||||||
|
|
||||||
quote_price =
|
quote_price =
|
||||||
self.projected_apply_slippage(market, side, raw_quote_price, Some(take_qty));
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -5764,6 +5779,7 @@ impl PlatformExprStrategy {
|
|||||||
order_intents,
|
order_intents,
|
||||||
notes: Vec::new(),
|
notes: Vec::new(),
|
||||||
diagnostics,
|
diagnostics,
|
||||||
|
risk_decisions: Vec::new(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5908,7 +5924,7 @@ impl PlatformExprStrategy {
|
|||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
if self
|
if self
|
||||||
.baseline_risk_rejection_reason(ctx, date, &factor.symbol, candidate, market)
|
.selection_risk_rejection_reason(ctx, date, &factor.symbol, candidate, market)
|
||||||
.is_some()
|
.is_some()
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
@@ -5936,7 +5952,70 @@ impl PlatformExprStrategy {
|
|||||||
rows
|
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,
|
&self,
|
||||||
ctx: &StrategyContext<'_>,
|
ctx: &StrategyContext<'_>,
|
||||||
date: NaiveDate,
|
date: NaiveDate,
|
||||||
@@ -5944,44 +6023,60 @@ impl PlatformExprStrategy {
|
|||||||
candidate: &crate::data::CandidateEligibility,
|
candidate: &crate::data::CandidateEligibility,
|
||||||
market: &DailyMarketSnapshot,
|
market: &DailyMarketSnapshot,
|
||||||
) -> Option<&'static str> {
|
) -> Option<&'static str> {
|
||||||
ChinaAShareRiskControl::baseline_rejection_reason(
|
if let Some(reason) = ChinaAShareRiskControl::baseline_rejection_reason_with_config(
|
||||||
date,
|
date,
|
||||||
candidate,
|
candidate,
|
||||||
market,
|
market,
|
||||||
ctx.data.instrument(symbol),
|
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(
|
fn stock_passes_universe_exclude(
|
||||||
&self,
|
&self,
|
||||||
candidate: &crate::data::CandidateEligibility,
|
_candidate: &crate::data::CandidateEligibility,
|
||||||
market: &DailyMarketSnapshot,
|
market: &DailyMarketSnapshot,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
let excludes = &self.config.universe_exclude;
|
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") {
|
if excludes.iter().any(|item| item == "bjse") && market.symbol.ends_with(".BJ") {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if excludes.iter().any(|item| item == "new_listing") && candidate.is_new_listing {
|
true
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn stock_numeric_field_value(
|
fn stock_numeric_field_value(
|
||||||
@@ -6116,7 +6211,7 @@ impl PlatformExprStrategy {
|
|||||||
self.can_sell_position_at_time(ctx, date, symbol, None)
|
self.can_sell_position_at_time(ctx, date, symbol, None)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn candidate_requires_firisk_forced_exit(
|
fn candidate_requires_risk_level_forced_exit(
|
||||||
&self,
|
&self,
|
||||||
ctx: &StrategyContext<'_>,
|
ctx: &StrategyContext<'_>,
|
||||||
date: NaiveDate,
|
date: NaiveDate,
|
||||||
@@ -6181,13 +6276,14 @@ impl PlatformExprStrategy {
|
|||||||
} else {
|
} else {
|
||||||
market.price(PriceField::Last)
|
market.price(PriceField::Last)
|
||||||
};
|
};
|
||||||
ChinaAShareRiskControl::sell_rejection_reason(
|
ChinaAShareRiskControl::sell_rejection_reason_with_config(
|
||||||
date,
|
date,
|
||||||
candidate,
|
candidate,
|
||||||
market,
|
market,
|
||||||
ctx.data.instrument(symbol),
|
ctx.data.instrument(symbol),
|
||||||
Some(position),
|
Some(position),
|
||||||
check_price,
|
check_price,
|
||||||
|
&self.config.risk_config,
|
||||||
)
|
)
|
||||||
.is_none()
|
.is_none()
|
||||||
}
|
}
|
||||||
@@ -6197,50 +6293,39 @@ impl PlatformExprStrategy {
|
|||||||
ctx: &StrategyContext<'_>,
|
ctx: &StrategyContext<'_>,
|
||||||
date: NaiveDate,
|
date: NaiveDate,
|
||||||
symbol: &str,
|
symbol: &str,
|
||||||
stock: &StockExpressionState,
|
_stock: &StockExpressionState,
|
||||||
) -> Result<Option<String>, BacktestError> {
|
) -> Result<Option<String>, BacktestError> {
|
||||||
let market = ctx.data.require_market(date, symbol)?;
|
let market = ctx.data.require_market(date, symbol)?;
|
||||||
let candidate = ctx.data.require_candidate(date, symbol)?;
|
let candidate = ctx.data.require_candidate(date, symbol)?;
|
||||||
|
|
||||||
if let Some(reason) =
|
if self
|
||||||
self.baseline_risk_rejection_reason(ctx, date, symbol, &candidate, &market)
|
.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()));
|
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 {
|
let upper_limit_check_price = if self.config.aiquant_transaction_cost {
|
||||||
self.aiquant_scheduled_buy_price(ctx, date, symbol)
|
self.aiquant_scheduled_buy_price(ctx, date, symbol)
|
||||||
.unwrap_or_else(|| market.price(PriceField::Last))
|
.unwrap_or_else(|| market.price(PriceField::Last))
|
||||||
} else {
|
} else {
|
||||||
market.buy_price(PriceField::Last)
|
market.buy_price(PriceField::DayOpen)
|
||||||
};
|
};
|
||||||
if (!self.config.aiquant_transaction_cost
|
if let Some(reason) = ChinaAShareRiskControl::buy_rejection_reason_with_config(
|
||||||
&& market.is_at_upper_limit_price(market.day_open))
|
date,
|
||||||
|| market.is_at_upper_limit_price(upper_limit_check_price)
|
&candidate,
|
||||||
{
|
&market,
|
||||||
return Ok(Some("upper_limit".to_string()));
|
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)
|
Ok(None)
|
||||||
}
|
}
|
||||||
@@ -6255,9 +6340,15 @@ impl PlatformExprStrategy {
|
|||||||
band_low: f64,
|
band_low: f64,
|
||||||
band_high: f64,
|
band_high: f64,
|
||||||
limit: usize,
|
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 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();
|
let mut candidates = Vec::new();
|
||||||
for candidate in universe {
|
for candidate in universe {
|
||||||
let stock = self.selection_stock_state_with_factor_date(
|
let stock = self.selection_stock_state_with_factor_date(
|
||||||
@@ -6312,6 +6403,12 @@ impl PlatformExprStrategy {
|
|||||||
|
|
||||||
let mut selected = Vec::new();
|
let mut selected = Vec::new();
|
||||||
for (candidate, stock, _) in candidates {
|
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 let Some(reason) = self.buy_rejection_reason(ctx, date, &candidate.symbol, &stock)? {
|
||||||
if diagnostics.len() < 12 {
|
if diagnostics.len() < 12 {
|
||||||
diagnostics.push(format!("{} rejected by {}", candidate.symbol, reason));
|
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 {
|
fn stock_filter_quote_usage(&self) -> StockFilterQuoteUsage {
|
||||||
@@ -6988,6 +7085,12 @@ impl PlatformExprStrategy {
|
|||||||
if quote_candidate_symbols.len() < quote_candidate_limit {
|
if quote_candidate_symbols.len() < quote_candidate_limit {
|
||||||
quote_candidate_symbols.push(symbol.clone());
|
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 let Some(reason) = self.buy_rejection_reason(ctx, date, symbol, &stock)? {
|
||||||
if diagnostics.len() < 12 {
|
if diagnostics.len() < 12 {
|
||||||
diagnostics.push(format!("{symbol} quote_plan rejected by {reason}"));
|
diagnostics.push(format!("{symbol} quote_plan rejected by {reason}"));
|
||||||
@@ -7317,8 +7420,9 @@ impl Strategy for PlatformExprStrategy {
|
|||||||
} else {
|
} else {
|
||||||
0
|
0
|
||||||
};
|
};
|
||||||
|
let mut risk_decisions = Vec::new();
|
||||||
let stock_list = if self.config.rotation_enabled && !in_skip_window {
|
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,
|
ctx,
|
||||||
selection_market_date,
|
selection_market_date,
|
||||||
selection_universe_factor_date,
|
selection_universe_factor_date,
|
||||||
@@ -7329,6 +7433,7 @@ impl Strategy for PlatformExprStrategy {
|
|||||||
selection_limit,
|
selection_limit,
|
||||||
)?;
|
)?;
|
||||||
selection_notes = notes;
|
selection_notes = notes;
|
||||||
|
risk_decisions = selection_risk_decisions;
|
||||||
stock_list
|
stock_list
|
||||||
} else {
|
} else {
|
||||||
Vec::new()
|
Vec::new()
|
||||||
@@ -7372,7 +7477,7 @@ impl Strategy for PlatformExprStrategy {
|
|||||||
let mut intraday_attempted_buys = BTreeSet::<String>::new();
|
let mut intraday_attempted_buys = BTreeSet::<String>::new();
|
||||||
let mut delayed_sold_symbols = BTreeSet::<String>::new();
|
let mut delayed_sold_symbols = BTreeSet::<String>::new();
|
||||||
let mut unresolved_stop_loss_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
|
let delayed_limit_exit_time = self
|
||||||
.config
|
.config
|
||||||
.delayed_limit_open_exit_time
|
.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"
|
"platform expr skip window forced all sellable cash after delayed open exits"
|
||||||
.to_string(),
|
.to_string(),
|
||||||
],
|
],
|
||||||
|
risk_decisions: Vec::new(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -7544,7 +7650,7 @@ impl Strategy for PlatformExprStrategy {
|
|||||||
.cloned()
|
.cloned()
|
||||||
.collect::<BTreeSet<_>>();
|
.collect::<BTreeSet<_>>();
|
||||||
let mut pending_full_close_symbols = BTreeSet::<String>::new();
|
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 {
|
if self.config.aiquant_transaction_cost {
|
||||||
for position in ctx.portfolio.positions().values() {
|
for position in ctx.portfolio.positions().values() {
|
||||||
if position.quantity == 0
|
if position.quantity == 0
|
||||||
@@ -7561,14 +7667,14 @@ impl Strategy for PlatformExprStrategy {
|
|||||||
)? {
|
)? {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if self.candidate_requires_firisk_forced_exit(
|
if self.candidate_requires_risk_level_forced_exit(
|
||||||
ctx,
|
ctx,
|
||||||
execution_date,
|
execution_date,
|
||||||
&position.symbol,
|
&position.symbol,
|
||||||
firisk_forced_exit_time,
|
risk_level_forced_exit_time,
|
||||||
)? {
|
)? {
|
||||||
pending_full_close_symbols.insert(position.symbol.clone());
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
let (stop_hit, profit_hit) =
|
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) {
|
if delayed_sold_symbols.contains(&symbol) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -7614,8 +7720,8 @@ impl Strategy for PlatformExprStrategy {
|
|||||||
symbol: symbol.clone(),
|
symbol: symbol.clone(),
|
||||||
target_value: 0.0,
|
target_value: 0.0,
|
||||||
style: AlgoOrderStyle::Twap,
|
style: AlgoOrderStyle::Twap,
|
||||||
start_time: Some(firisk_forced_exit_time),
|
start_time: Some(risk_level_forced_exit_time),
|
||||||
end_time: Some(firisk_forced_exit_time),
|
end_time: Some(risk_level_forced_exit_time),
|
||||||
reason: "risk_forced_exit".to_string(),
|
reason: "risk_forced_exit".to_string(),
|
||||||
});
|
});
|
||||||
if self
|
if self
|
||||||
@@ -7625,7 +7731,7 @@ impl Strategy for PlatformExprStrategy {
|
|||||||
execution_date,
|
execution_date,
|
||||||
&symbol,
|
&symbol,
|
||||||
&mut projected_execution_state,
|
&mut projected_execution_state,
|
||||||
Some(firisk_forced_exit_time),
|
Some(risk_level_forced_exit_time),
|
||||||
)
|
)
|
||||||
.is_some()
|
.is_some()
|
||||||
&& Self::projected_position_is_flat(&projected, &symbol)
|
&& Self::projected_position_is_flat(&projected, &symbol)
|
||||||
@@ -8186,6 +8292,7 @@ impl Strategy for PlatformExprStrategy {
|
|||||||
order_intents,
|
order_intents,
|
||||||
notes,
|
notes,
|
||||||
diagnostics,
|
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]
|
#[test]
|
||||||
fn platform_expr_cost_model_uses_commission_override() {
|
fn platform_expr_cost_model_uses_commission_override() {
|
||||||
let mut cfg = PlatformExprStrategyConfig::microcap_rotation();
|
let mut cfg = PlatformExprStrategyConfig::microcap_rotation();
|
||||||
@@ -11167,7 +11588,7 @@ mod tests {
|
|||||||
assert!(stock.volume.is_nan());
|
assert!(stock.volume.is_nan());
|
||||||
|
|
||||||
let day = strategy.day_state(&ctx, date).expect("day state");
|
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)
|
.select_symbols(&ctx, date, date, date, &day, 10.0, 20.0, 1)
|
||||||
.expect("selection");
|
.expect("selection");
|
||||||
assert!(selected.is_empty());
|
assert!(selected.is_empty());
|
||||||
@@ -11281,14 +11702,14 @@ mod tests {
|
|||||||
let strategy = PlatformExprStrategy::new(cfg.clone());
|
let strategy = PlatformExprStrategy::new(cfg.clone());
|
||||||
let day = strategy.day_state(&ctx, date).expect("day state");
|
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)
|
.select_symbols(&ctx, date, date, date, &day, 11.0, 39.0, 10)
|
||||||
.expect("selection");
|
.expect("selection");
|
||||||
assert_eq!(selected_yi, vec!["003008.SZ".to_string()]);
|
assert_eq!(selected_yi, vec!["003008.SZ".to_string()]);
|
||||||
|
|
||||||
cfg.market_cap_field = "market_cap_bn".to_string();
|
cfg.market_cap_field = "market_cap_bn".to_string();
|
||||||
let strategy_bn = PlatformExprStrategy::new(cfg);
|
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)
|
.select_symbols(&ctx, date, date, date, &day, 140.0, 160.0, 10)
|
||||||
.expect("selection by bn");
|
.expect("selection by bn");
|
||||||
assert_eq!(selected_bn, vec!["300999.SZ".to_string()]);
|
assert_eq!(selected_bn, vec!["300999.SZ".to_string()]);
|
||||||
@@ -11766,7 +12187,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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 date = d(2023, 5, 5);
|
||||||
let symbol = "300621.SZ";
|
let symbol = "300621.SZ";
|
||||||
let data = DataSet::from_components(
|
let data = DataSet::from_components(
|
||||||
@@ -14085,6 +14506,106 @@ mod tests {
|
|||||||
assert_eq!(rejection.as_deref(), Some("buy_disabled"));
|
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]
|
#[test]
|
||||||
fn platform_strategy_baseline_risk_filter_cannot_be_disabled_by_empty_exclude() {
|
fn platform_strategy_baseline_risk_filter_cannot_be_disabled_by_empty_exclude() {
|
||||||
let date = d(2024, 12, 31);
|
let date = d(2024, 12, 31);
|
||||||
@@ -18595,7 +19116,7 @@ mod tests {
|
|||||||
assert!(
|
assert!(
|
||||||
decision.order_intents.iter().all(|intent| match intent {
|
decision.order_intents.iter().all(|intent| match intent {
|
||||||
OrderIntent::Value { value, reason, .. } if reason == "periodic_rebalance_buy" =>
|
OrderIntent::Value { value, reason, .. } if reason == "periodic_rebalance_buy" =>
|
||||||
(*value - 75_410.3).abs() < 1e-6,
|
(*value - 75_444.8).abs() < 1e-6,
|
||||||
_ => true,
|
_ => true,
|
||||||
}),
|
}),
|
||||||
"{:?}",
|
"{:?}",
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ use crate::{
|
|||||||
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct StrategyRuntimeSpec {
|
pub struct StrategyRuntimeSpec {
|
||||||
#[serde(default)]
|
#[serde(default, alias = "strategy_id")]
|
||||||
pub strategy_id: Option<String>,
|
pub strategy_id: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub market: Option<String>,
|
pub market: Option<String>,
|
||||||
@@ -24,15 +24,15 @@ pub struct StrategyRuntimeSpec {
|
|||||||
pub universe: Option<StrategyUniverseSpec>,
|
pub universe: Option<StrategyUniverseSpec>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub rebalance: Option<StrategyRebalanceSpec>,
|
pub rebalance: Option<StrategyRebalanceSpec>,
|
||||||
#[serde(default)]
|
#[serde(default, alias = "trade_times")]
|
||||||
pub trade_times: Vec<String>,
|
pub trade_times: Vec<String>,
|
||||||
#[serde(default)]
|
#[serde(default, alias = "signal_symbol")]
|
||||||
pub signal_symbol: Option<String>,
|
pub signal_symbol: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub execution: Option<StrategyExecutionSpec>,
|
pub execution: Option<StrategyExecutionSpec>,
|
||||||
#[serde(default)]
|
#[serde(default, alias = "engine_config")]
|
||||||
pub engine_config: Option<StrategyEngineConfig>,
|
pub engine_config: Option<StrategyEngineConfig>,
|
||||||
#[serde(default)]
|
#[serde(default, alias = "runtime_expressions")]
|
||||||
pub runtime_expressions: Option<StrategyRuntimeExpressions>,
|
pub runtime_expressions: Option<StrategyRuntimeExpressions>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub environment: Option<StrategyRuntimeEnvironment>,
|
pub environment: Option<StrategyRuntimeEnvironment>,
|
||||||
@@ -66,27 +66,37 @@ pub struct StrategyRebalanceSpec {
|
|||||||
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct StrategyExecutionSpec {
|
pub struct StrategyExecutionSpec {
|
||||||
#[serde(default)]
|
#[serde(default, alias = "matching_type")]
|
||||||
pub matching_type: Option<String>,
|
pub matching_type: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default, alias = "slippage_model")]
|
||||||
pub slippage_model: Option<String>,
|
pub slippage_model: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default, alias = "slippage_value")]
|
||||||
pub slippage_value: Option<f64>,
|
pub slippage_value: Option<f64>,
|
||||||
#[serde(default)]
|
#[serde(default, alias = "slippage_impact_coefficient")]
|
||||||
pub slippage_impact_coefficient: Option<f64>,
|
pub slippage_impact_coefficient: Option<f64>,
|
||||||
#[serde(default)]
|
#[serde(default, alias = "slippage_volatility_coefficient")]
|
||||||
pub slippage_volatility_coefficient: Option<f64>,
|
pub slippage_volatility_coefficient: Option<f64>,
|
||||||
#[serde(default)]
|
#[serde(default, alias = "slippage_max_value", alias = "slippage_max_rate")]
|
||||||
pub slippage_max_value: Option<f64>,
|
pub slippage_max_value: Option<f64>,
|
||||||
#[serde(default)]
|
#[serde(default, alias = "commission_rate")]
|
||||||
pub commission_rate: Option<f64>,
|
pub commission_rate: Option<f64>,
|
||||||
#[serde(default)]
|
#[serde(default, alias = "minimum_commission", alias = "minCommission")]
|
||||||
pub minimum_commission: Option<f64>,
|
pub minimum_commission: Option<f64>,
|
||||||
#[serde(default)]
|
#[serde(default, alias = "stamp_tax_rate_before_change")]
|
||||||
pub stamp_tax_rate_before_change: Option<f64>,
|
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>,
|
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>,
|
pub strict_value_budget: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,11 +105,11 @@ pub struct StrategyExecutionSpec {
|
|||||||
pub struct StrategyEngineConfig {
|
pub struct StrategyEngineConfig {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub template_id: Option<String>,
|
pub template_id: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default, alias = "profile_name")]
|
||||||
pub profile_name: Option<String>,
|
pub profile_name: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default, alias = "benchmark_symbol")]
|
||||||
pub benchmark_symbol: Option<String>,
|
pub benchmark_symbol: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default, alias = "signal_symbol")]
|
||||||
pub signal_symbol: Option<String>,
|
pub signal_symbol: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub rank_limit: Option<usize>,
|
pub rank_limit: Option<usize>,
|
||||||
@@ -117,27 +127,37 @@ pub struct StrategyEngineConfig {
|
|||||||
pub stop_loss_multiplier: Option<f64>,
|
pub stop_loss_multiplier: Option<f64>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub take_profit_multiplier: Option<f64>,
|
pub take_profit_multiplier: Option<f64>,
|
||||||
#[serde(default)]
|
#[serde(default, alias = "matching_type")]
|
||||||
pub matching_type: Option<String>,
|
pub matching_type: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default, alias = "slippage_model")]
|
||||||
pub slippage_model: Option<String>,
|
pub slippage_model: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default, alias = "slippage_value")]
|
||||||
pub slippage_value: Option<f64>,
|
pub slippage_value: Option<f64>,
|
||||||
#[serde(default)]
|
#[serde(default, alias = "slippage_impact_coefficient")]
|
||||||
pub slippage_impact_coefficient: Option<f64>,
|
pub slippage_impact_coefficient: Option<f64>,
|
||||||
#[serde(default)]
|
#[serde(default, alias = "slippage_volatility_coefficient")]
|
||||||
pub slippage_volatility_coefficient: Option<f64>,
|
pub slippage_volatility_coefficient: Option<f64>,
|
||||||
#[serde(default)]
|
#[serde(default, alias = "slippage_max_value", alias = "slippage_max_rate")]
|
||||||
pub slippage_max_value: Option<f64>,
|
pub slippage_max_value: Option<f64>,
|
||||||
#[serde(default)]
|
#[serde(default, alias = "commission_rate")]
|
||||||
pub commission_rate: Option<f64>,
|
pub commission_rate: Option<f64>,
|
||||||
#[serde(default)]
|
#[serde(default, alias = "minimum_commission", alias = "minCommission")]
|
||||||
pub minimum_commission: Option<f64>,
|
pub minimum_commission: Option<f64>,
|
||||||
#[serde(default)]
|
#[serde(default, alias = "stamp_tax_rate_before_change")]
|
||||||
pub stamp_tax_rate_before_change: Option<f64>,
|
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>,
|
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>,
|
pub strict_value_budget: Option<bool>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub dividend_reinvestment: Option<bool>,
|
pub dividend_reinvestment: Option<bool>,
|
||||||
@@ -149,6 +169,81 @@ pub struct StrategyEngineConfig {
|
|||||||
pub skip_windows: Vec<SkipWindowConfig>,
|
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)]
|
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct DynamicRangeConfig {
|
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)
|
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 {
|
fn is_aiquant_profile(value: Option<&str>) -> bool {
|
||||||
value
|
value
|
||||||
.map(|item| item.trim().to_ascii_lowercase().replace('-', "_"))
|
.map(|item| item.trim().to_ascii_lowercase().replace('-', "_"))
|
||||||
@@ -404,19 +520,148 @@ fn apply_cost_overrides(
|
|||||||
minimum_commission: Option<f64>,
|
minimum_commission: Option<f64>,
|
||||||
stamp_tax_rate_before_change: Option<f64>,
|
stamp_tax_rate_before_change: Option<f64>,
|
||||||
stamp_tax_rate_after_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) {
|
if let Some(value) = valid_non_negative(commission_rate) {
|
||||||
cfg.commission_rate = Some(value);
|
cfg.commission_rate = Some(value);
|
||||||
|
cfg.risk_config.trading_constraints.commission_rate = value;
|
||||||
}
|
}
|
||||||
if let Some(value) = valid_non_negative(minimum_commission) {
|
if let Some(value) = valid_non_negative(minimum_commission) {
|
||||||
cfg.minimum_commission = Some(value);
|
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) {
|
if let Some(value) = valid_non_negative(stamp_tax_rate_before_change) {
|
||||||
cfg.stamp_tax_rate_before_change = Some(value);
|
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) {
|
if let Some(value) = valid_non_negative(stamp_tax_rate_after_change) {
|
||||||
cfg.stamp_tax_rate_after_change = Some(value);
|
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 {
|
fn normalize_model_name(value: &str) -> String {
|
||||||
@@ -755,7 +1000,15 @@ pub fn platform_expr_config_from_spec(
|
|||||||
engine.minimum_commission,
|
engine.minimum_commission,
|
||||||
engine.stamp_tax_rate_before_change,
|
engine.stamp_tax_rate_before_change,
|
||||||
engine.stamp_tax_rate_after_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(
|
apply_execution_behavior_overrides(
|
||||||
&mut cfg,
|
&mut cfg,
|
||||||
engine.matching_type.as_deref(),
|
engine.matching_type.as_deref(),
|
||||||
@@ -1156,7 +1409,15 @@ pub fn platform_expr_config_from_spec(
|
|||||||
execution.minimum_commission,
|
execution.minimum_commission,
|
||||||
execution.stamp_tax_rate_before_change,
|
execution.stamp_tax_rate_before_change,
|
||||||
execution.stamp_tax_rate_after_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(
|
apply_execution_behavior_overrides(
|
||||||
&mut cfg,
|
&mut cfg,
|
||||||
execution.matching_type.as_deref(),
|
execution.matching_type.as_deref(),
|
||||||
@@ -1686,7 +1947,8 @@ mod tests {
|
|||||||
"commissionRate": 0.0003,
|
"commissionRate": 0.0003,
|
||||||
"minimumCommission": 5.0,
|
"minimumCommission": 5.0,
|
||||||
"stampTaxRateBeforeChange": 0.0005,
|
"stampTaxRateBeforeChange": 0.0005,
|
||||||
"stampTaxRateAfterChange": 0.0005
|
"stampTaxRateAfterChange": 0.0005,
|
||||||
|
"stampTaxChangeDate": "2024-01-02"
|
||||||
},
|
},
|
||||||
"engineConfig": {
|
"engineConfig": {
|
||||||
"profileName": "aiquant",
|
"profileName": "aiquant",
|
||||||
@@ -1701,6 +1963,109 @@ mod tests {
|
|||||||
assert_eq!(cfg.minimum_commission, Some(5.0));
|
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_before_change, Some(0.0005));
|
||||||
assert_eq!(cfg.stamp_tax_rate_after_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]
|
#[test]
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
|
use std::collections::BTreeSet;
|
||||||
|
|
||||||
use chrono::NaiveDate;
|
use chrono::NaiveDate;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::data::{CandidateEligibility, DailyMarketSnapshot, PriceField};
|
use crate::data::{CandidateEligibility, DailyMarketSnapshot, PriceField};
|
||||||
use crate::instrument::Instrument;
|
use crate::instrument::Instrument;
|
||||||
@@ -7,7 +10,155 @@ use crate::portfolio::Position;
|
|||||||
#[derive(Debug, Clone, Copy, Default)]
|
#[derive(Debug, Clone, Copy, Default)]
|
||||||
pub struct ChinaAShareRiskControl;
|
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 {
|
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(
|
pub fn instrument_rejection_reason(
|
||||||
instrument: Option<&Instrument>,
|
instrument: Option<&Instrument>,
|
||||||
date: NaiveDate,
|
date: NaiveDate,
|
||||||
@@ -36,43 +187,157 @@ impl ChinaAShareRiskControl {
|
|||||||
None
|
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(
|
pub fn selection_rejection_reason(
|
||||||
date: NaiveDate,
|
date: NaiveDate,
|
||||||
candidate: &CandidateEligibility,
|
candidate: &CandidateEligibility,
|
||||||
market: &DailyMarketSnapshot,
|
market: &DailyMarketSnapshot,
|
||||||
instrument: Option<&Instrument>,
|
instrument: Option<&Instrument>,
|
||||||
) -> Option<&'static str> {
|
) -> 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);
|
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");
|
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
|
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(
|
pub fn baseline_rejection_reason(
|
||||||
date: NaiveDate,
|
date: NaiveDate,
|
||||||
candidate: &CandidateEligibility,
|
candidate: &CandidateEligibility,
|
||||||
market: &DailyMarketSnapshot,
|
market: &DailyMarketSnapshot,
|
||||||
instrument: Option<&Instrument>,
|
instrument: Option<&Instrument>,
|
||||||
) -> Option<&'static str> {
|
) -> 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);
|
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");
|
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");
|
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");
|
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");
|
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");
|
return Some("one_yuan");
|
||||||
}
|
}
|
||||||
None
|
None
|
||||||
@@ -85,13 +350,39 @@ impl ChinaAShareRiskControl {
|
|||||||
instrument: Option<&Instrument>,
|
instrument: Option<&Instrument>,
|
||||||
check_price: f64,
|
check_price: f64,
|
||||||
) -> Option<&'static str> {
|
) -> 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);
|
return Some(reason);
|
||||||
}
|
}
|
||||||
if !candidate.allow_buy {
|
if config.static_rules.respect_allow_buy_sell && !candidate.allow_buy {
|
||||||
return Some("buy_disabled");
|
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");
|
return Some("open at or above upper limit");
|
||||||
}
|
}
|
||||||
None
|
None
|
||||||
@@ -105,17 +396,44 @@ impl ChinaAShareRiskControl {
|
|||||||
position: Option<&Position>,
|
position: Option<&Position>,
|
||||||
check_price: f64,
|
check_price: f64,
|
||||||
) -> Option<&'static str> {
|
) -> 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);
|
return Some(reason);
|
||||||
}
|
}
|
||||||
if market.paused || candidate.is_paused {
|
if config.static_rules.reject_paused_sell && (market.paused || candidate.is_paused) {
|
||||||
return Some("paused");
|
return Some("paused");
|
||||||
}
|
}
|
||||||
// `allow_sell` is derived from the daily candidate snapshot and may
|
// `allow_sell` is derived from the daily candidate snapshot and may
|
||||||
// reflect an open/close fallback rather than the actual execution price.
|
// reflect an open/close fallback rather than the actual execution price.
|
||||||
// A sell order must be blocked by the execution price lower-limit check
|
// A sell order must be blocked by the execution price lower-limit check
|
||||||
// below, while suspension and delisting are handled above.
|
// 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");
|
return Some("open at or below lower limit");
|
||||||
}
|
}
|
||||||
if position.is_some_and(|position| position.sellable_qty(date) == 0) {
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -230,4 +556,98 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(reason, Some("open at or below lower limit"));
|
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 {
|
pub trait EquityRuleHooks {
|
||||||
|
fn duplicates_standard_china_risk(&self) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
fn can_buy(
|
fn can_buy(
|
||||||
&self,
|
&self,
|
||||||
execution_date: NaiveDate,
|
execution_date: NaiveDate,
|
||||||
@@ -49,6 +53,10 @@ pub trait EquityRuleHooks {
|
|||||||
pub struct ChinaEquityRuleHooks;
|
pub struct ChinaEquityRuleHooks;
|
||||||
|
|
||||||
impl EquityRuleHooks for ChinaEquityRuleHooks {
|
impl EquityRuleHooks for ChinaEquityRuleHooks {
|
||||||
|
fn duplicates_standard_china_risk(&self) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
fn can_buy(
|
fn can_buy(
|
||||||
&self,
|
&self,
|
||||||
_execution_date: NaiveDate,
|
_execution_date: NaiveDate,
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ use crate::events::{FillEvent, OrderEvent, OrderSide, OrderStatus, ProcessEvent}
|
|||||||
use crate::futures::{FuturesAccountState, FuturesOrderIntent};
|
use crate::futures::{FuturesAccountState, FuturesOrderIntent};
|
||||||
use crate::instrument::Instrument;
|
use crate::instrument::Instrument;
|
||||||
use crate::portfolio::PortfolioState;
|
use crate::portfolio::PortfolioState;
|
||||||
use crate::risk_control::ChinaAShareRiskControl;
|
use crate::risk_control::{ChinaAShareRiskControl, FidcRiskControlConfig, FidcRiskDecisionAudit};
|
||||||
use crate::scheduler::ScheduleRule;
|
use crate::scheduler::ScheduleRule;
|
||||||
use crate::universe::{DynamicMarketCapBandSelector, SelectionContext, UniverseSelector};
|
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> {
|
pub fn current_snapshot(&self, symbol: &str) -> Option<&DailyMarketSnapshot> {
|
||||||
self.data.market(self.execution_date, symbol)
|
self.data.market(self.execution_date, symbol)
|
||||||
}
|
}
|
||||||
@@ -911,6 +928,7 @@ pub struct StrategyDecision {
|
|||||||
pub order_intents: Vec<OrderIntent>,
|
pub order_intents: Vec<OrderIntent>,
|
||||||
pub notes: Vec<String>,
|
pub notes: Vec<String>,
|
||||||
pub diagnostics: Vec<String>,
|
pub diagnostics: Vec<String>,
|
||||||
|
pub risk_decisions: Vec<FidcRiskDecisionAudit>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl StrategyDecision {
|
impl StrategyDecision {
|
||||||
@@ -921,6 +939,7 @@ impl StrategyDecision {
|
|||||||
self.order_intents.append(&mut other.order_intents);
|
self.order_intents.append(&mut other.order_intents);
|
||||||
self.notes.append(&mut other.notes);
|
self.notes.append(&mut other.notes);
|
||||||
self.diagnostics.append(&mut other.diagnostics);
|
self.diagnostics.append(&mut other.diagnostics);
|
||||||
|
self.risk_decisions.append(&mut other.risk_decisions);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn is_empty(&self) -> bool {
|
pub fn is_empty(&self) -> bool {
|
||||||
@@ -930,6 +949,7 @@ impl StrategyDecision {
|
|||||||
&& self.order_intents.is_empty()
|
&& self.order_intents.is_empty()
|
||||||
&& self.notes.is_empty()
|
&& self.notes.is_empty()
|
||||||
&& self.diagnostics.is_empty()
|
&& self.diagnostics.is_empty()
|
||||||
|
&& self.risk_decisions.is_empty()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1123,6 +1143,7 @@ pub struct CnSmallCapRotationConfig {
|
|||||||
pub signal_symbol: Option<String>,
|
pub signal_symbol: Option<String>,
|
||||||
pub skip_months: Vec<u32>,
|
pub skip_months: Vec<u32>,
|
||||||
pub skip_month_day_ranges: Vec<(Option<u32>, u32, u32, u32)>,
|
pub skip_month_day_ranges: Vec<(Option<u32>, u32, u32, u32)>,
|
||||||
|
pub risk_config: FidcRiskControlConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CnSmallCapRotationConfig {
|
impl CnSmallCapRotationConfig {
|
||||||
@@ -1150,6 +1171,7 @@ impl CnSmallCapRotationConfig {
|
|||||||
signal_symbol: None,
|
signal_symbol: None,
|
||||||
skip_months: Vec::new(),
|
skip_months: Vec::new(),
|
||||||
skip_month_day_ranges: Vec::new(),
|
skip_month_day_ranges: Vec::new(),
|
||||||
|
risk_config: FidcRiskControlConfig::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1183,6 +1205,7 @@ impl CnSmallCapRotationConfig {
|
|||||||
(None, 10, 20, 30),
|
(None, 10, 20, 30),
|
||||||
(None, 12, 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"
|
"run_daily(10:17/10:18) mapped to T-1 decision and T open execution"
|
||||||
.to_string(),
|
.to_string(),
|
||||||
],
|
],
|
||||||
|
risk_decisions: Vec::new(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1385,6 +1409,7 @@ impl Strategy for CnSmallCapRotationStrategy {
|
|||||||
diagnostics: vec![
|
diagnostics: vec![
|
||||||
"insufficient history; skip trading on warmup dates".to_string(),
|
"insufficient history; skip trading on warmup dates".to_string(),
|
||||||
],
|
],
|
||||||
|
risk_decisions: Vec::new(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Err(err) => return Err(err),
|
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(),
|
"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());
|
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 {
|
if rebalance && gross_exposure > 0.0 {
|
||||||
let (selected_before_ma, selection_diag) =
|
let (selected_before_ma, selection_diag) =
|
||||||
@@ -1431,6 +1457,7 @@ impl Strategy for CnSmallCapRotationStrategy {
|
|||||||
reference_level: signal_level,
|
reference_level: signal_level,
|
||||||
data: ctx.data,
|
data: ctx.data,
|
||||||
dynamic_universe: ctx.dynamic_universe,
|
dynamic_universe: ctx.dynamic_universe,
|
||||||
|
risk_config: Some(&self.config.risk_config),
|
||||||
});
|
});
|
||||||
let before_ma_count = selected_before_ma.len();
|
let before_ma_count = selected_before_ma.len();
|
||||||
let mut ma_rejects = Vec::new();
|
let mut ma_rejects = Vec::new();
|
||||||
@@ -1479,6 +1506,29 @@ impl Strategy for CnSmallCapRotationStrategy {
|
|||||||
selection_diag.rejection_examples.join(" | ")
|
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() {
|
if !ma_rejects.is_empty() {
|
||||||
diagnostics.push(format!(
|
diagnostics.push(format!(
|
||||||
"ma_filter_rejections sample={}",
|
"ma_filter_rejections sample={}",
|
||||||
@@ -1531,6 +1581,7 @@ impl Strategy for CnSmallCapRotationStrategy {
|
|||||||
order_intents: Vec::new(),
|
order_intents: Vec::new(),
|
||||||
notes,
|
notes,
|
||||||
diagnostics,
|
diagnostics,
|
||||||
|
risk_decisions,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1560,6 +1611,7 @@ pub struct OmniMicroCapConfig {
|
|||||||
pub stop_loss_ratio: f64,
|
pub stop_loss_ratio: f64,
|
||||||
pub take_profit_ratio: f64,
|
pub take_profit_ratio: f64,
|
||||||
pub skip_month_day_ranges: Vec<(Option<u32>, u32, u32, u32)>,
|
pub skip_month_day_ranges: Vec<(Option<u32>, u32, u32, u32)>,
|
||||||
|
pub risk_config: FidcRiskControlConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl OmniMicroCapConfig {
|
impl OmniMicroCapConfig {
|
||||||
@@ -1590,6 +1642,7 @@ impl OmniMicroCapConfig {
|
|||||||
// The migrated reference logic disables seasonal stop windows in
|
// The migrated reference logic disables seasonal stop windows in
|
||||||
// production-style execution, so the default keeps that behavior.
|
// production-style execution, so the default keeps that behavior.
|
||||||
skip_month_day_ranges: Vec::new(),
|
skip_month_day_ranges: Vec::new(),
|
||||||
|
risk_config: FidcRiskControlConfig::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1618,6 +1671,7 @@ impl OmniMicroCapConfig {
|
|||||||
stop_loss_ratio: 0.92,
|
stop_loss_ratio: 0.92,
|
||||||
take_profit_ratio: 1.16,
|
take_profit_ratio: 1.16,
|
||||||
skip_month_day_ranges: Vec::new(),
|
skip_month_day_ranges: Vec::new(),
|
||||||
|
risk_config: FidcRiskControlConfig::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1687,12 +1741,16 @@ impl OmniMicroCapStrategy {
|
|||||||
0.0
|
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 {
|
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 {
|
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.commission_for(gross_amount)
|
||||||
+ model.stamp_tax_for(date, OrderSide::Sell, gross_amount)
|
+ model.stamp_tax_for(date, OrderSide::Sell, gross_amount)
|
||||||
}
|
}
|
||||||
@@ -1974,36 +2032,47 @@ impl OmniMicroCapStrategy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let mut max_fill = requested_qty;
|
let mut max_fill = requested_qty;
|
||||||
let top_level_liquidity = match side {
|
let constraints = self.config.risk_config.trading_constraints;
|
||||||
OrderSide::Buy => snapshot.liquidity_for_buy(),
|
if constraints.liquidity_limit_enabled {
|
||||||
OrderSide::Sell => snapshot.liquidity_for_sell(),
|
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 consumed_turnover = *execution_state.intraday_turnover.get(symbol).unwrap_or(&0);
|
||||||
let raw_limit =
|
if constraints.volume_limit_enabled {
|
||||||
((snapshot.minute_volume as f64) * 0.25).round() as i64 - consumed_turnover as i64;
|
let raw_limit = ((snapshot.minute_volume as f64) * constraints.volume_percent).round()
|
||||||
if raw_limit <= 0 {
|
as i64
|
||||||
return None;
|
- 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 {
|
Some(max_fill)
|
||||||
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))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn projected_execution_start_cursor(
|
fn projected_execution_start_cursor(
|
||||||
@@ -2338,13 +2407,14 @@ impl OmniMicroCapStrategy {
|
|||||||
let Ok(candidate) = ctx.data.require_candidate(date, symbol) else {
|
let Ok(candidate) = ctx.data.require_candidate(date, symbol) else {
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
ChinaAShareRiskControl::sell_rejection_reason(
|
ChinaAShareRiskControl::sell_rejection_reason_with_config(
|
||||||
date,
|
date,
|
||||||
candidate,
|
candidate,
|
||||||
market,
|
market,
|
||||||
ctx.data.instrument(symbol),
|
ctx.data.instrument(symbol),
|
||||||
Some(position),
|
Some(position),
|
||||||
ChinaAShareRiskControl::sell_check_price(market, PriceField::Last),
|
ChinaAShareRiskControl::sell_check_price(market, PriceField::Last),
|
||||||
|
&self.config.risk_config,
|
||||||
)
|
)
|
||||||
.is_none()
|
.is_none()
|
||||||
}
|
}
|
||||||
@@ -2358,12 +2428,13 @@ impl OmniMicroCapStrategy {
|
|||||||
let market = ctx.data.require_market(date, symbol)?;
|
let market = ctx.data.require_market(date, symbol)?;
|
||||||
let candidate = ctx.data.require_candidate(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,
|
date,
|
||||||
candidate,
|
candidate,
|
||||||
market,
|
market,
|
||||||
ctx.data.instrument(symbol),
|
ctx.data.instrument(symbol),
|
||||||
ChinaAShareRiskControl::buy_check_price(market, PriceField::Last),
|
ChinaAShareRiskControl::buy_check_price(market, PriceField::Last),
|
||||||
|
&self.config.risk_config,
|
||||||
) {
|
) {
|
||||||
return Ok(Some(reason.to_string()));
|
return Ok(Some(reason.to_string()));
|
||||||
}
|
}
|
||||||
@@ -2375,6 +2446,64 @@ impl OmniMicroCapStrategy {
|
|||||||
Ok(None)
|
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(
|
fn select_symbols(
|
||||||
&self,
|
&self,
|
||||||
ctx: &StrategyContext<'_>,
|
ctx: &StrategyContext<'_>,
|
||||||
@@ -2418,7 +2547,8 @@ impl OmniMicroCapStrategy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if selected.len() < self.config.stocknum {
|
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);
|
let start = lower_bound_eligible(&universe, band_low);
|
||||||
for candidate in universe.iter().skip(start) {
|
for candidate in universe.iter().skip(start) {
|
||||||
if candidate.market_cap_bn > band_high {
|
if candidate.market_cap_bn > band_high {
|
||||||
@@ -2453,7 +2583,7 @@ impl OmniMicroCapStrategy {
|
|||||||
return Ok((selected, diagnostics));
|
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 diagnostics = Vec::new();
|
||||||
let mut selected = Vec::new();
|
let mut selected = Vec::new();
|
||||||
let start = lower_bound_eligible(&universe, band_low);
|
let start = lower_bound_eligible(&universe, band_low);
|
||||||
@@ -2654,6 +2784,7 @@ impl Strategy for OmniMicroCapStrategy {
|
|||||||
.collect(),
|
.collect(),
|
||||||
notes: vec![format!("seasonal stop window on {}", date)],
|
notes: vec![format!("seasonal stop window on {}", date)],
|
||||||
diagnostics: vec!["platform-native skip window forced all cash".to_string()],
|
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![
|
diagnostics: vec![
|
||||||
"insufficient history; skip trading on warmup dates".to_string(),
|
"insufficient history; skip trading on warmup dates".to_string(),
|
||||||
],
|
],
|
||||||
|
risk_decisions: Vec::new(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Err(err) => return Err(err),
|
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 (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 (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 periodic_rebalance = ctx.decision_index % self.config.refresh_rate == 0;
|
||||||
let mut projected = ctx.portfolio.clone();
|
let mut projected = ctx.portfolio.clone();
|
||||||
let mut projected_execution_state = ProjectedExecutionState::default();
|
let mut projected_execution_state = ProjectedExecutionState::default();
|
||||||
@@ -2848,6 +2981,10 @@ impl Strategy for OmniMicroCapStrategy {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
diagnostics.extend(selection_notes);
|
diagnostics.extend(selection_notes);
|
||||||
|
diagnostics.extend(Self::selection_risk_decision_diagnostics(
|
||||||
|
&risk_decisions,
|
||||||
|
5,
|
||||||
|
));
|
||||||
|
|
||||||
let notes = vec![
|
let notes = vec![
|
||||||
format!("stock_list={}", stock_list.len()),
|
format!("stock_list={}", stock_list.len()),
|
||||||
@@ -2861,6 +2998,7 @@ impl Strategy for OmniMicroCapStrategy {
|
|||||||
order_intents,
|
order_intents,
|
||||||
notes,
|
notes,
|
||||||
diagnostics,
|
diagnostics,
|
||||||
|
risk_decisions,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2882,6 +3020,7 @@ fn lower_bound_eligible(rows: &[crate::data::EligibleUniverseSnapshot], target:
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::{BenchmarkSnapshot, CandidateEligibility, DailyFactorSnapshot};
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
fn temp_csv_path(name: &str) -> PathBuf {
|
fn temp_csv_path(name: &str) -> PathBuf {
|
||||||
@@ -2927,4 +3066,168 @@ mod tests {
|
|||||||
Some("603657.SH")
|
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(),
|
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(),
|
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 {
|
ManualSection {
|
||||||
title: "corporate_actions.dividend_reinvestment".to_string(),
|
title: "corporate_actions.dividend_reinvestment".to_string(),
|
||||||
detail: "支持 corporate_actions.dividend_reinvestment(true)。开启后,现金分红到账会优先按 round lot 回补成同一只股票,零头保留为现金。".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: "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_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: "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: "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: "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() },
|
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("## AI 代码生成硬约束\n");
|
||||||
out.push_str("- 只输出完整 `engine-script` 代码;第一行必须是 `strategy(\"...\")`、`let`、`fn`、`const` 或 `//`。\n");
|
out.push_str("- 只输出完整 `engine-script` 代码;第一行必须是 `strategy(\"...\")`、`let`、`fn`、`const` 或 `//`。\n");
|
||||||
out.push_str("- 禁止输出 Markdown、解释、推理过程、JSON 包装、手册复述或结果报告。\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("- 禁止伪 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("- 市值表达式字段只能用 `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");
|
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(
|
out.push_str(
|
||||||
"- `risk.index_exposure(...)` 只能传一个表达式;不要生成 `risk.exposure(...)`。\n",
|
"- `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("- 完整三元表达式 `cond ? a : b` 可在表达式参数中使用;若当前运行环境报 `Unknown operator: '?'`,先重编译并重启回测服务,不要改写策略语义掩盖运行时漂移。\n");
|
||||||
out.push_str("- `next_bar_open` 的选股、排序和仓位信号来自决策日,订单在下一可交易开盘撮合;不要使用执行日价格作为下单前信号。\n");
|
out.push_str("- `next_bar_open` 的选股、排序和仓位信号来自决策日,订单在下一可交易开盘撮合;不要使用执行日价格作为下单前信号。\n");
|
||||||
out.push_str("- `execution.matching_type(...)` 和 `execution.slippage(...)` 必须使用手册列出的合法取值。\n\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("- 不要输出解释文本。\n");
|
||||||
prompt.push_str("- 必须使用 strategy(\"...\") { ... } 语法。\n");
|
prompt.push_str("- 必须使用 strategy(\"...\") { ... } 语法。\n");
|
||||||
prompt.push_str("- 如需自定义参数,使用 let 和 fn。\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("- 生成的代码必须能转换为 strategy_spec 并提交 POST /v1/backtests。\n");
|
||||||
prompt.push_str("- 用户指定“持仓N只、目标持仓N、stocknum=N、selection.limit(N)”时,必须把最终持仓槽位写成 N;用户指定“至少/不少于N只”时,最终持仓槽位必须 >= N。\n");
|
prompt.push_str("- 用户指定“持仓N只、目标持仓N、stocknum=N、selection.limit(N)”时,必须把最终持仓槽位写成 N;用户指定“至少/不少于N只”时,最终持仓槽位必须 >= N。\n");
|
||||||
prompt.push_str("- ");
|
prompt.push_str("- ");
|
||||||
prompt.push_str(DEFAULT_THREE_YEAR_RETURN_TARGET_PROMPT);
|
prompt.push_str(DEFAULT_THREE_YEAR_RETURN_TARGET_PROMPT);
|
||||||
prompt.push('\n');
|
prompt.push('\n');
|
||||||
prompt.push_str("- 不要使用手册未列出的字段、函数或外部平台 API 名称。\n\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("只允许使用这些可编译语句: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、!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("参数形态必须严格: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("回测成功但 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("用户目标:\n");
|
||||||
prompt.push_str(&format!("- {}\n", request.user_goal));
|
prompt.push_str(&format!("- {}\n", request.user_goal));
|
||||||
if !request.constraints.is_empty() {
|
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("持仓数量属于策略合同,不是优化自由参数。原策略或用户目标明确 stocknum、selection.limit、目标持仓N只或不少于N只时,优化后必须保留该目标槽位或满足最低槽位,不能为了收益或交易次数擅自改小。\n");
|
||||||
prompt.push_str(DEFAULT_THREE_YEAR_RETURN_TARGET_PROMPT);
|
prompt.push_str(DEFAULT_THREE_YEAR_RETURN_TARGET_PROMPT);
|
||||||
prompt.push('\n');
|
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("优化目标:\n");
|
||||||
prompt.push_str(&format!("- {}\n\n", request.objective));
|
prompt.push_str(&format!("- {}\n\n", request.objective));
|
||||||
prompt.push_str("当前策略代码如下,仅作为输入参考;回复时不要包含 Markdown 代码围栏:\n");
|
prompt.push_str("当前策略代码如下,仅作为输入参考;回复时不要包含 Markdown 代码围栏:\n");
|
||||||
@@ -600,6 +606,9 @@ mod tests {
|
|||||||
|
|
||||||
assert!(prompt.contains("三年回测区间策略总收益 >= 150% 即视为满足收益目标"));
|
assert!(prompt.contains("三年回测区间策略总收益 >= 150% 即视为满足收益目标"));
|
||||||
assert!(prompt.contains("不得把已达标策略判为失败"));
|
assert!(prompt.contains("不得把已达标策略判为失败"));
|
||||||
|
assert!(prompt.contains("Strategy Factory Source Lake 已注册 source rows 字段"));
|
||||||
|
assert!(prompt.contains("不要回退 ficlaw-data"));
|
||||||
|
assert!(prompt.contains("ClickHouse"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -616,5 +625,8 @@ mod tests {
|
|||||||
|
|
||||||
assert!(prompt.contains("三年回测区间策略总收益 >= 150% 即视为满足收益目标"));
|
assert!(prompt.contains("三年回测区间策略总收益 >= 150% 即视为满足收益目标"));
|
||||||
assert!(prompt.contains("继续优化夏普、回撤、换手和稳定性"));
|
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 serde::Serialize;
|
||||||
|
|
||||||
use crate::data::{BenchmarkSnapshot, DataSet, EligibleUniverseSnapshot};
|
use crate::data::{BenchmarkSnapshot, DataSet, EligibleUniverseSnapshot};
|
||||||
|
use crate::risk_control::{ChinaAShareRiskControl, FidcRiskControlConfig, FidcRiskDecisionAudit};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum BandRegime {
|
pub enum BandRegime {
|
||||||
@@ -39,6 +40,7 @@ pub struct SelectionDiagnostics {
|
|||||||
pub missing_market_cap_symbols: Vec<String>,
|
pub missing_market_cap_symbols: Vec<String>,
|
||||||
pub selected_symbols: Vec<String>,
|
pub selected_symbols: Vec<String>,
|
||||||
pub rejection_examples: Vec<String>,
|
pub rejection_examples: Vec<String>,
|
||||||
|
pub risk_decisions: Vec<FidcRiskDecisionAudit>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct SelectionContext<'a> {
|
pub struct SelectionContext<'a> {
|
||||||
@@ -47,20 +49,61 @@ pub struct SelectionContext<'a> {
|
|||||||
pub reference_level: f64,
|
pub reference_level: f64,
|
||||||
pub data: &'a DataSet,
|
pub data: &'a DataSet,
|
||||||
pub dynamic_universe: Option<&'a BTreeSet<String>>,
|
pub dynamic_universe: Option<&'a BTreeSet<String>>,
|
||||||
|
pub risk_config: Option<&'a FidcRiskControlConfig>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SelectionContext<'_> {
|
impl SelectionContext<'_> {
|
||||||
fn eligible_universe(&self) -> Vec<EligibleUniverseSnapshot> {
|
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 {
|
match self.dynamic_universe {
|
||||||
Some(symbols) if !symbols.is_empty() => eligible
|
Some(symbols) if !symbols.is_empty() => eligible
|
||||||
.iter()
|
.into_iter()
|
||||||
.filter(|row| symbols.contains(&row.symbol))
|
.filter(|row| symbols.contains(&row.symbol))
|
||||||
.cloned()
|
|
||||||
.collect(),
|
.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 {
|
pub trait UniverseSelector {
|
||||||
@@ -166,9 +209,23 @@ impl UniverseSelector for DynamicMarketCapBandSelector {
|
|||||||
missing_market_cap_symbols: Vec::new(),
|
missing_market_cap_symbols: Vec::new(),
|
||||||
selected_symbols: Vec::new(),
|
selected_symbols: Vec::new(),
|
||||||
rejection_examples: Vec::new(),
|
rejection_examples: Vec::new(),
|
||||||
|
risk_decisions: Vec::new(),
|
||||||
};
|
};
|
||||||
|
|
||||||
diagnostics.factor_total = ctx.data.factor_snapshots_on(ctx.decision_date).len();
|
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();
|
let eligible = ctx.eligible_universe();
|
||||||
diagnostics.market_cap_missing_count =
|
diagnostics.market_cap_missing_count =
|
||||||
diagnostics.factor_total.saturating_sub(eligible.len());
|
diagnostics.factor_total.saturating_sub(eligible.len());
|
||||||
@@ -221,3 +278,147 @@ fn to_universe_candidate(
|
|||||||
band_high,
|
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=")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -71,11 +71,11 @@ fn aiquant_cost_model_matches_alv_run_options() {
|
|||||||
let model = ChinaAShareCostModel::aiquant_default();
|
let model = ChinaAShareCostModel::aiquant_default();
|
||||||
|
|
||||||
let buy = model.calculate(d(2026, 5, 19), OrderSide::Buy, 49_978.84);
|
let buy = model.calculate(d(2026, 5, 19), OrderSide::Buy, 49_978.84);
|
||||||
assert!((buy.commission - 12.49471).abs() < 1e-9);
|
assert!((buy.commission - 14.993652).abs() < 1e-9);
|
||||||
assert_eq!(buy.stamp_tax, 0.0);
|
assert_eq!(buy.stamp_tax, 0.0);
|
||||||
|
|
||||||
let sell = model.calculate(d(2026, 5, 19), OrderSide::Sell, 100_724.72);
|
let sell = model.calculate(d(2026, 5, 19), OrderSide::Sell, 100_724.72);
|
||||||
assert!((sell.commission - 25.18118).abs() < 1e-9);
|
assert!((sell.commission - 30.217416).abs() < 1e-9);
|
||||||
assert!((sell.stamp_tax - 50.36236).abs() < 1e-9);
|
assert!((sell.stamp_tax - 50.36236).abs() < 1e-9);
|
||||||
|
|
||||||
let small_buy = model.calculate(d(2026, 5, 19), OrderSide::Buy, 1_000.0);
|
let small_buy = model.calculate(d(2026, 5, 19), OrderSide::Buy, 1_000.0);
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ impl Strategy for BuyAndHoldStrategy {
|
|||||||
},
|
},
|
||||||
notes: Vec::new(),
|
notes: Vec::new(),
|
||||||
diagnostics: Vec::new(),
|
diagnostics: Vec::new(),
|
||||||
|
risk_decisions: Vec::new(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ impl Strategy for BuyThenHoldStrategy {
|
|||||||
}],
|
}],
|
||||||
notes: Vec::new(),
|
notes: Vec::new(),
|
||||||
diagnostics: Vec::new(),
|
diagnostics: Vec::new(),
|
||||||
|
risk_decisions: Vec::new(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Ok(StrategyDecision::default())
|
Ok(StrategyDecision::default())
|
||||||
|
|||||||
@@ -294,6 +294,7 @@ impl Strategy for HookProbeStrategy {
|
|||||||
order_intents: Vec::new(),
|
order_intents: Vec::new(),
|
||||||
notes: Vec::new(),
|
notes: Vec::new(),
|
||||||
diagnostics: Vec::new(),
|
diagnostics: Vec::new(),
|
||||||
|
risk_decisions: Vec::new(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -336,6 +337,7 @@ impl Strategy for AuctionOrderStrategy {
|
|||||||
}],
|
}],
|
||||||
notes: Vec::new(),
|
notes: Vec::new(),
|
||||||
diagnostics: Vec::new(),
|
diagnostics: Vec::new(),
|
||||||
|
risk_decisions: Vec::new(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -384,6 +386,7 @@ impl Strategy for FuturesOrderStrategy {
|
|||||||
}],
|
}],
|
||||||
notes: Vec::new(),
|
notes: Vec::new(),
|
||||||
diagnostics: Vec::new(),
|
diagnostics: Vec::new(),
|
||||||
|
risk_decisions: Vec::new(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -716,6 +719,7 @@ impl Strategy for LimitCarryStrategy {
|
|||||||
}],
|
}],
|
||||||
notes: Vec::new(),
|
notes: Vec::new(),
|
||||||
diagnostics: Vec::new(),
|
diagnostics: Vec::new(),
|
||||||
|
risk_decisions: Vec::new(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -793,6 +797,7 @@ impl Strategy for UniverseDirectiveStrategy {
|
|||||||
order_intents,
|
order_intents,
|
||||||
notes: Vec::new(),
|
notes: Vec::new(),
|
||||||
diagnostics: Vec::new(),
|
diagnostics: Vec::new(),
|
||||||
|
risk_decisions: Vec::new(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -816,6 +821,7 @@ impl Strategy for MinuteProbeStrategy {
|
|||||||
}],
|
}],
|
||||||
notes: Vec::new(),
|
notes: Vec::new(),
|
||||||
diagnostics: Vec::new(),
|
diagnostics: Vec::new(),
|
||||||
|
risk_decisions: Vec::new(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -857,6 +863,7 @@ impl Strategy for MinuteProbeStrategy {
|
|||||||
}],
|
}],
|
||||||
notes: Vec::new(),
|
notes: Vec::new(),
|
||||||
diagnostics: Vec::new(),
|
diagnostics: Vec::new(),
|
||||||
|
risk_decisions: Vec::new(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -958,6 +965,7 @@ impl Strategy for OrderInspectionStrategy {
|
|||||||
}],
|
}],
|
||||||
notes: Vec::new(),
|
notes: Vec::new(),
|
||||||
diagnostics: Vec::new(),
|
diagnostics: Vec::new(),
|
||||||
|
risk_decisions: Vec::new(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1015,6 +1023,7 @@ impl Strategy for AccountFlowStrategy {
|
|||||||
],
|
],
|
||||||
notes: Vec::new(),
|
notes: Vec::new(),
|
||||||
diagnostics: Vec::new(),
|
diagnostics: Vec::new(),
|
||||||
|
risk_decisions: Vec::new(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3765,6 +3774,7 @@ impl Strategy for BuyMissingRowThenHoldStrategy {
|
|||||||
}],
|
}],
|
||||||
notes: Vec::new(),
|
notes: Vec::new(),
|
||||||
diagnostics: Vec::new(),
|
diagnostics: Vec::new(),
|
||||||
|
risk_decisions: Vec::new(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Ok(StrategyDecision::default())
|
Ok(StrategyDecision::default())
|
||||||
|
|||||||
@@ -2,8 +2,9 @@ use chrono::{NaiveDate, NaiveTime};
|
|||||||
use fidc_core::{
|
use fidc_core::{
|
||||||
AlgoOrderStyle, BenchmarkSnapshot, BrokerSimulator, CandidateEligibility, ChinaAShareCostModel,
|
AlgoOrderStyle, BenchmarkSnapshot, BrokerSimulator, CandidateEligibility, ChinaAShareCostModel,
|
||||||
ChinaEquityRuleHooks, DailyFactorSnapshot, DailyMarketSnapshot, DataSet, DynamicSlippageConfig,
|
ChinaEquityRuleHooks, DailyFactorSnapshot, DailyMarketSnapshot, DataSet, DynamicSlippageConfig,
|
||||||
Instrument, IntradayExecutionQuote, MatchingType, OrderIntent, OrderStatus, PortfolioState,
|
FidcRiskControlConfig, Instrument, IntradayExecutionQuote, MatchingType, OrderIntent,
|
||||||
PriceField, ProcessEventKind, SlippageModel, StrategyDecision, TargetPortfolioOrderPricing,
|
OrderStatus, PortfolioState, PriceField, ProcessEventKind, SlippageModel, StrategyDecision,
|
||||||
|
TargetPortfolioOrderPricing,
|
||||||
};
|
};
|
||||||
use std::collections::{BTreeMap, BTreeSet};
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
|
|
||||||
@@ -104,12 +105,87 @@ fn execute_single_value_order(
|
|||||||
}],
|
}],
|
||||||
notes: Vec::new(),
|
notes: Vec::new(),
|
||||||
diagnostics: Vec::new(),
|
diagnostics: Vec::new(),
|
||||||
|
risk_decisions: Vec::new(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.expect("broker execution");
|
.expect("broker execution");
|
||||||
(portfolio, report)
|
(portfolio, report)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn single_symbol_limit_price_data(
|
||||||
|
date: NaiveDate,
|
||||||
|
symbol: &str,
|
||||||
|
price: f64,
|
||||||
|
upper_limit: f64,
|
||||||
|
lower_limit: f64,
|
||||||
|
) -> DataSet {
|
||||||
|
DataSet::from_components(
|
||||||
|
vec![Instrument {
|
||||||
|
symbol: symbol.to_string(),
|
||||||
|
name: "Test".to_string(),
|
||||||
|
board: "SZ".to_string(),
|
||||||
|
round_lot: 100,
|
||||||
|
listed_at: Some(NaiveDate::from_ymd_opt(2020, 1, 1).unwrap()),
|
||||||
|
delisted_at: None,
|
||||||
|
status: "active".to_string(),
|
||||||
|
}],
|
||||||
|
vec![DailyMarketSnapshot {
|
||||||
|
date,
|
||||||
|
symbol: symbol.to_string(),
|
||||||
|
timestamp: Some(format!("{date} 10:18:00")),
|
||||||
|
day_open: price,
|
||||||
|
open: price,
|
||||||
|
high: price,
|
||||||
|
low: price,
|
||||||
|
close: price,
|
||||||
|
last_price: price,
|
||||||
|
bid1: price,
|
||||||
|
ask1: price,
|
||||||
|
prev_close: 10.0,
|
||||||
|
volume: 1_000_000,
|
||||||
|
minute_volume: 1_000_000,
|
||||||
|
bid1_volume: 500_000,
|
||||||
|
ask1_volume: 500_000,
|
||||||
|
trading_phase: Some("continuous".to_string()),
|
||||||
|
paused: false,
|
||||||
|
upper_limit,
|
||||||
|
lower_limit,
|
||||||
|
price_tick: 0.01,
|
||||||
|
}],
|
||||||
|
vec![DailyFactorSnapshot {
|
||||||
|
date,
|
||||||
|
symbol: symbol.to_string(),
|
||||||
|
market_cap_bn: 50.0,
|
||||||
|
free_float_cap_bn: 45.0,
|
||||||
|
pe_ttm: 15.0,
|
||||||
|
turnover_ratio: Some(2.0),
|
||||||
|
effective_turnover_ratio: Some(1.8),
|
||||||
|
extra_factors: BTreeMap::new(),
|
||||||
|
}],
|
||||||
|
vec![CandidateEligibility {
|
||||||
|
date,
|
||||||
|
symbol: symbol.to_string(),
|
||||||
|
is_st: false,
|
||||||
|
is_new_listing: false,
|
||||||
|
is_paused: false,
|
||||||
|
allow_buy: true,
|
||||||
|
allow_sell: true,
|
||||||
|
is_kcb: false,
|
||||||
|
is_one_yuan: false,
|
||||||
|
risk_level_code: None,
|
||||||
|
}],
|
||||||
|
vec![BenchmarkSnapshot {
|
||||||
|
date,
|
||||||
|
benchmark: "000300.SH".to_string(),
|
||||||
|
open: 100.0,
|
||||||
|
close: 100.0,
|
||||||
|
prev_close: 99.0,
|
||||||
|
volume: 1_000_000,
|
||||||
|
}],
|
||||||
|
)
|
||||||
|
.expect("dataset")
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn broker_executes_explicit_order_value_buy() {
|
fn broker_executes_explicit_order_value_buy() {
|
||||||
let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap();
|
let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap();
|
||||||
@@ -215,6 +291,7 @@ fn broker_executes_explicit_order_value_buy() {
|
|||||||
}],
|
}],
|
||||||
notes: Vec::new(),
|
notes: Vec::new(),
|
||||||
diagnostics: Vec::new(),
|
diagnostics: Vec::new(),
|
||||||
|
risk_decisions: Vec::new(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.expect("broker execution");
|
.expect("broker execution");
|
||||||
@@ -370,6 +447,7 @@ fn broker_delayed_limit_open_sell_uses_minute_price() {
|
|||||||
}],
|
}],
|
||||||
notes: Vec::new(),
|
notes: Vec::new(),
|
||||||
diagnostics: Vec::new(),
|
diagnostics: Vec::new(),
|
||||||
|
risk_decisions: Vec::new(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.expect("broker execution");
|
.expect("broker execution");
|
||||||
@@ -504,6 +582,7 @@ fn broker_executes_order_shares_and_order_lots() {
|
|||||||
],
|
],
|
||||||
notes: Vec::new(),
|
notes: Vec::new(),
|
||||||
diagnostics: Vec::new(),
|
diagnostics: Vec::new(),
|
||||||
|
risk_decisions: Vec::new(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.expect("broker execution");
|
.expect("broker execution");
|
||||||
@@ -613,6 +692,7 @@ fn broker_executes_target_shares_like_order_to() {
|
|||||||
}],
|
}],
|
||||||
notes: Vec::new(),
|
notes: Vec::new(),
|
||||||
diagnostics: Vec::new(),
|
diagnostics: Vec::new(),
|
||||||
|
risk_decisions: Vec::new(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.expect("broker execution");
|
.expect("broker execution");
|
||||||
@@ -793,6 +873,7 @@ fn broker_executes_target_portfolio_smart_with_custom_prices() {
|
|||||||
}],
|
}],
|
||||||
notes: Vec::new(),
|
notes: Vec::new(),
|
||||||
diagnostics: Vec::new(),
|
diagnostics: Vec::new(),
|
||||||
|
risk_decisions: Vec::new(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.expect("broker execution");
|
.expect("broker execution");
|
||||||
@@ -943,6 +1024,7 @@ fn broker_executes_target_portfolio_smart_with_algo_order_style() {
|
|||||||
}],
|
}],
|
||||||
notes: Vec::new(),
|
notes: Vec::new(),
|
||||||
diagnostics: Vec::new(),
|
diagnostics: Vec::new(),
|
||||||
|
risk_decisions: Vec::new(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.expect("broker execution");
|
.expect("broker execution");
|
||||||
@@ -1050,6 +1132,7 @@ fn broker_executes_order_percent_and_target_percent() {
|
|||||||
}],
|
}],
|
||||||
notes: Vec::new(),
|
notes: Vec::new(),
|
||||||
diagnostics: Vec::new(),
|
diagnostics: Vec::new(),
|
||||||
|
risk_decisions: Vec::new(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.expect("percent execution");
|
.expect("percent execution");
|
||||||
@@ -1073,6 +1156,7 @@ fn broker_executes_order_percent_and_target_percent() {
|
|||||||
}],
|
}],
|
||||||
notes: Vec::new(),
|
notes: Vec::new(),
|
||||||
diagnostics: Vec::new(),
|
diagnostics: Vec::new(),
|
||||||
|
risk_decisions: Vec::new(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.expect("target percent execution");
|
.expect("target percent execution");
|
||||||
@@ -1172,6 +1256,7 @@ fn broker_uses_day_open_price_for_open_auction_matching() {
|
|||||||
}],
|
}],
|
||||||
notes: Vec::new(),
|
notes: Vec::new(),
|
||||||
diagnostics: Vec::new(),
|
diagnostics: Vec::new(),
|
||||||
|
risk_decisions: Vec::new(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.expect("broker execution");
|
.expect("broker execution");
|
||||||
@@ -1276,6 +1361,7 @@ fn broker_open_auction_uses_auction_volume_without_quote_liquidity() {
|
|||||||
}],
|
}],
|
||||||
notes: Vec::new(),
|
notes: Vec::new(),
|
||||||
diagnostics: Vec::new(),
|
diagnostics: Vec::new(),
|
||||||
|
risk_decisions: Vec::new(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.expect("broker execution");
|
.expect("broker execution");
|
||||||
@@ -1376,6 +1462,7 @@ fn broker_cancels_buy_when_open_hits_upper_limit() {
|
|||||||
}],
|
}],
|
||||||
notes: Vec::new(),
|
notes: Vec::new(),
|
||||||
diagnostics: Vec::new(),
|
diagnostics: Vec::new(),
|
||||||
|
risk_decisions: Vec::new(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.expect("broker execution");
|
.expect("broker execution");
|
||||||
@@ -1490,6 +1577,7 @@ fn broker_applies_price_ratio_slippage_on_snapshot_fills() {
|
|||||||
}],
|
}],
|
||||||
notes: Vec::new(),
|
notes: Vec::new(),
|
||||||
diagnostics: Vec::new(),
|
diagnostics: Vec::new(),
|
||||||
|
risk_decisions: Vec::new(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.expect("broker execution");
|
.expect("broker execution");
|
||||||
@@ -1592,6 +1680,7 @@ fn broker_applies_dynamic_slippage_on_snapshot_fills() {
|
|||||||
}],
|
}],
|
||||||
notes: Vec::new(),
|
notes: Vec::new(),
|
||||||
diagnostics: Vec::new(),
|
diagnostics: Vec::new(),
|
||||||
|
risk_decisions: Vec::new(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.expect("broker execution");
|
.expect("broker execution");
|
||||||
@@ -1710,6 +1799,7 @@ fn broker_applies_tick_size_slippage_on_intraday_last_fills() {
|
|||||||
}],
|
}],
|
||||||
notes: Vec::new(),
|
notes: Vec::new(),
|
||||||
diagnostics: Vec::new(),
|
diagnostics: Vec::new(),
|
||||||
|
risk_decisions: Vec::new(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.expect("broker execution");
|
.expect("broker execution");
|
||||||
@@ -1813,6 +1903,7 @@ fn broker_rejects_intraday_last_order_without_execution_quotes() {
|
|||||||
}],
|
}],
|
||||||
notes: Vec::new(),
|
notes: Vec::new(),
|
||||||
diagnostics: Vec::new(),
|
diagnostics: Vec::new(),
|
||||||
|
risk_decisions: Vec::new(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.expect("broker execution");
|
.expect("broker execution");
|
||||||
@@ -1934,6 +2025,7 @@ fn broker_executes_intraday_last_on_start_quote_without_trade_delta() {
|
|||||||
}],
|
}],
|
||||||
notes: Vec::new(),
|
notes: Vec::new(),
|
||||||
diagnostics: Vec::new(),
|
diagnostics: Vec::new(),
|
||||||
|
risk_decisions: Vec::new(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.expect("broker execution");
|
.expect("broker execution");
|
||||||
@@ -2050,6 +2142,7 @@ fn broker_cancels_market_order_remainder_when_intraday_quote_liquidity_exhausted
|
|||||||
}],
|
}],
|
||||||
notes: Vec::new(),
|
notes: Vec::new(),
|
||||||
diagnostics: Vec::new(),
|
diagnostics: Vec::new(),
|
||||||
|
risk_decisions: Vec::new(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.expect("broker execution");
|
.expect("broker execution");
|
||||||
@@ -2163,6 +2256,7 @@ fn broker_cancels_market_buy_when_minute_has_no_volume() {
|
|||||||
}],
|
}],
|
||||||
notes: Vec::new(),
|
notes: Vec::new(),
|
||||||
diagnostics: Vec::new(),
|
diagnostics: Vec::new(),
|
||||||
|
risk_decisions: Vec::new(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.expect("broker execution");
|
.expect("broker execution");
|
||||||
@@ -2296,6 +2390,7 @@ fn broker_splits_intraday_quote_fills_and_tracks_commission_by_order() {
|
|||||||
}],
|
}],
|
||||||
notes: Vec::new(),
|
notes: Vec::new(),
|
||||||
diagnostics: Vec::new(),
|
diagnostics: Vec::new(),
|
||||||
|
risk_decisions: Vec::new(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.expect("broker execution");
|
.expect("broker execution");
|
||||||
@@ -2458,6 +2553,7 @@ fn broker_aggregates_intraday_quote_fills_into_vwap_leg() {
|
|||||||
}],
|
}],
|
||||||
notes: Vec::new(),
|
notes: Vec::new(),
|
||||||
diagnostics: Vec::new(),
|
diagnostics: Vec::new(),
|
||||||
|
risk_decisions: Vec::new(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.expect("broker execution");
|
.expect("broker execution");
|
||||||
@@ -2631,6 +2727,7 @@ fn broker_executes_algo_vwap_value_with_time_window() {
|
|||||||
}],
|
}],
|
||||||
notes: Vec::new(),
|
notes: Vec::new(),
|
||||||
diagnostics: Vec::new(),
|
diagnostics: Vec::new(),
|
||||||
|
risk_decisions: Vec::new(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.expect("broker execution");
|
.expect("broker execution");
|
||||||
@@ -2776,6 +2873,7 @@ fn broker_executes_algo_twap_percent_across_window_quotes() {
|
|||||||
}],
|
}],
|
||||||
notes: Vec::new(),
|
notes: Vec::new(),
|
||||||
diagnostics: Vec::new(),
|
diagnostics: Vec::new(),
|
||||||
|
risk_decisions: Vec::new(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.expect("broker execution");
|
.expect("broker execution");
|
||||||
@@ -2906,6 +3004,7 @@ fn broker_uses_best_own_price_for_intraday_matching() {
|
|||||||
}],
|
}],
|
||||||
notes: Vec::new(),
|
notes: Vec::new(),
|
||||||
diagnostics: Vec::new(),
|
diagnostics: Vec::new(),
|
||||||
|
risk_decisions: Vec::new(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.expect("broker execution");
|
.expect("broker execution");
|
||||||
@@ -3021,6 +3120,7 @@ fn broker_uses_best_counterparty_price_for_intraday_matching() {
|
|||||||
}],
|
}],
|
||||||
notes: Vec::new(),
|
notes: Vec::new(),
|
||||||
diagnostics: Vec::new(),
|
diagnostics: Vec::new(),
|
||||||
|
risk_decisions: Vec::new(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.expect("broker execution");
|
.expect("broker execution");
|
||||||
@@ -3183,6 +3283,7 @@ fn rebalance_optimizer_skips_unfunded_buy_when_existing_position_cannot_sell() {
|
|||||||
order_intents: Vec::new(),
|
order_intents: Vec::new(),
|
||||||
notes: Vec::new(),
|
notes: Vec::new(),
|
||||||
diagnostics: Vec::new(),
|
diagnostics: Vec::new(),
|
||||||
|
risk_decisions: Vec::new(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.expect("broker execution");
|
.expect("broker execution");
|
||||||
@@ -3376,6 +3477,7 @@ fn rebalance_uses_prev_close_for_open_auction_valuation() {
|
|||||||
order_intents: Vec::new(),
|
order_intents: Vec::new(),
|
||||||
notes: Vec::new(),
|
notes: Vec::new(),
|
||||||
diagnostics: Vec::new(),
|
diagnostics: Vec::new(),
|
||||||
|
risk_decisions: Vec::new(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.expect("broker execution");
|
.expect("broker execution");
|
||||||
@@ -3555,6 +3657,7 @@ fn rebalance_optimizer_prioritizes_higher_target_weight_when_cash_is_tight() {
|
|||||||
order_intents: Vec::new(),
|
order_intents: Vec::new(),
|
||||||
notes: Vec::new(),
|
notes: Vec::new(),
|
||||||
diagnostics: Vec::new(),
|
diagnostics: Vec::new(),
|
||||||
|
risk_decisions: Vec::new(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.expect("broker execution");
|
.expect("broker execution");
|
||||||
@@ -3681,6 +3784,7 @@ fn broker_uses_board_specific_min_quantity_and_step_size_for_buy_sizing() {
|
|||||||
}],
|
}],
|
||||||
notes: Vec::new(),
|
notes: Vec::new(),
|
||||||
diagnostics: Vec::new(),
|
diagnostics: Vec::new(),
|
||||||
|
risk_decisions: Vec::new(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.expect("broker execution");
|
.expect("broker execution");
|
||||||
@@ -3780,6 +3884,7 @@ fn broker_allows_bjse_quantities_above_minimum_without_round_lot_step() {
|
|||||||
}],
|
}],
|
||||||
notes: Vec::new(),
|
notes: Vec::new(),
|
||||||
diagnostics: Vec::new(),
|
diagnostics: Vec::new(),
|
||||||
|
risk_decisions: Vec::new(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.expect("broker execution");
|
.expect("broker execution");
|
||||||
@@ -3883,6 +3988,7 @@ fn broker_allows_full_odd_lot_sell_when_liquidating_position() {
|
|||||||
}],
|
}],
|
||||||
notes: Vec::new(),
|
notes: Vec::new(),
|
||||||
diagnostics: Vec::new(),
|
diagnostics: Vec::new(),
|
||||||
|
risk_decisions: Vec::new(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.expect("broker execution");
|
.expect("broker execution");
|
||||||
@@ -3894,7 +4000,7 @@ fn broker_allows_full_odd_lot_sell_when_liquidating_position() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn same_day_sell_then_rebuy_reinserts_position_at_end() {
|
fn same_day_sell_then_rebuy_is_rejected_by_default() {
|
||||||
let prev_date = NaiveDate::from_ymd_opt(2024, 1, 9).unwrap();
|
let prev_date = NaiveDate::from_ymd_opt(2024, 1, 9).unwrap();
|
||||||
let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap();
|
let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap();
|
||||||
let symbols = ["000001.SZ", "000002.SZ", "000003.SZ"];
|
let symbols = ["000001.SZ", "000002.SZ", "000003.SZ"];
|
||||||
@@ -4020,6 +4126,149 @@ fn same_day_sell_then_rebuy_reinserts_position_at_end() {
|
|||||||
],
|
],
|
||||||
notes: Vec::new(),
|
notes: Vec::new(),
|
||||||
diagnostics: Vec::new(),
|
diagnostics: Vec::new(),
|
||||||
|
risk_decisions: Vec::new(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expect("broker execution");
|
||||||
|
|
||||||
|
let symbols = portfolio.positions().keys().cloned().collect::<Vec<_>>();
|
||||||
|
assert_eq!(
|
||||||
|
symbols,
|
||||||
|
vec!["000001.SZ".to_string(), "000003.SZ".to_string()]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn same_day_sell_then_rebuy_can_be_allowed_by_policy() {
|
||||||
|
let prev_date = NaiveDate::from_ymd_opt(2024, 1, 9).unwrap();
|
||||||
|
let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap();
|
||||||
|
let symbols = ["000001.SZ", "000002.SZ", "000003.SZ"];
|
||||||
|
let instruments = symbols
|
||||||
|
.iter()
|
||||||
|
.map(|symbol| Instrument {
|
||||||
|
symbol: (*symbol).to_string(),
|
||||||
|
name: (*symbol).to_string(),
|
||||||
|
board: "SZ".to_string(),
|
||||||
|
round_lot: 100,
|
||||||
|
listed_at: None,
|
||||||
|
delisted_at: None,
|
||||||
|
status: "active".to_string(),
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let market = symbols
|
||||||
|
.iter()
|
||||||
|
.map(|symbol| DailyMarketSnapshot {
|
||||||
|
date,
|
||||||
|
symbol: (*symbol).to_string(),
|
||||||
|
timestamp: Some("2024-01-10 10:18:00".to_string()),
|
||||||
|
day_open: 10.0,
|
||||||
|
open: 10.0,
|
||||||
|
high: 10.1,
|
||||||
|
low: 9.9,
|
||||||
|
close: 10.0,
|
||||||
|
last_price: 10.0,
|
||||||
|
bid1: 9.99,
|
||||||
|
ask1: 10.01,
|
||||||
|
prev_close: 10.0,
|
||||||
|
volume: 100_000,
|
||||||
|
minute_volume: 100_000,
|
||||||
|
bid1_volume: 80_000,
|
||||||
|
ask1_volume: 80_000,
|
||||||
|
trading_phase: Some("continuous".to_string()),
|
||||||
|
paused: false,
|
||||||
|
upper_limit: 11.0,
|
||||||
|
lower_limit: 9.0,
|
||||||
|
price_tick: 0.01,
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let factors = symbols
|
||||||
|
.iter()
|
||||||
|
.map(|symbol| DailyFactorSnapshot {
|
||||||
|
date,
|
||||||
|
symbol: (*symbol).to_string(),
|
||||||
|
market_cap_bn: 50.0,
|
||||||
|
free_float_cap_bn: 45.0,
|
||||||
|
pe_ttm: 15.0,
|
||||||
|
turnover_ratio: Some(2.0),
|
||||||
|
effective_turnover_ratio: Some(1.8),
|
||||||
|
extra_factors: BTreeMap::new(),
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let candidates = symbols
|
||||||
|
.iter()
|
||||||
|
.map(|symbol| CandidateEligibility {
|
||||||
|
date,
|
||||||
|
symbol: (*symbol).to_string(),
|
||||||
|
is_st: false,
|
||||||
|
is_new_listing: false,
|
||||||
|
is_paused: false,
|
||||||
|
allow_buy: true,
|
||||||
|
allow_sell: true,
|
||||||
|
is_kcb: false,
|
||||||
|
is_one_yuan: false,
|
||||||
|
risk_level_code: None,
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let data = DataSet::from_components(
|
||||||
|
instruments,
|
||||||
|
market,
|
||||||
|
factors,
|
||||||
|
candidates,
|
||||||
|
vec![BenchmarkSnapshot {
|
||||||
|
date,
|
||||||
|
benchmark: "000300.SH".to_string(),
|
||||||
|
open: 100.0,
|
||||||
|
close: 100.0,
|
||||||
|
prev_close: 99.0,
|
||||||
|
volume: 1_000_000,
|
||||||
|
}],
|
||||||
|
)
|
||||||
|
.expect("dataset");
|
||||||
|
|
||||||
|
let mut portfolio = PortfolioState::new(1_000_000.0);
|
||||||
|
portfolio
|
||||||
|
.position_mut("000001.SZ")
|
||||||
|
.buy(prev_date, 100, 10.0);
|
||||||
|
portfolio
|
||||||
|
.position_mut("000002.SZ")
|
||||||
|
.buy(prev_date, 100, 10.0);
|
||||||
|
portfolio
|
||||||
|
.position_mut("000003.SZ")
|
||||||
|
.buy(prev_date, 100, 10.0);
|
||||||
|
|
||||||
|
let mut risk_config = FidcRiskControlConfig::default();
|
||||||
|
risk_config.static_rules.forbid_same_day_rebuy_after_sell = false;
|
||||||
|
let broker = BrokerSimulator::new_with_execution_price(
|
||||||
|
ChinaAShareCostModel::default(),
|
||||||
|
ChinaEquityRuleHooks::default(),
|
||||||
|
PriceField::Open,
|
||||||
|
)
|
||||||
|
.with_risk_config(risk_config);
|
||||||
|
|
||||||
|
broker
|
||||||
|
.execute(
|
||||||
|
date,
|
||||||
|
&mut portfolio,
|
||||||
|
&data,
|
||||||
|
&StrategyDecision {
|
||||||
|
rebalance: false,
|
||||||
|
target_weights: BTreeMap::new(),
|
||||||
|
exit_symbols: BTreeSet::new(),
|
||||||
|
order_intents: vec![
|
||||||
|
OrderIntent::TargetValue {
|
||||||
|
symbol: "000002.SZ".to_string(),
|
||||||
|
target_value: 0.0,
|
||||||
|
reason: "sell_then_rebuy".to_string(),
|
||||||
|
},
|
||||||
|
OrderIntent::Value {
|
||||||
|
symbol: "000002.SZ".to_string(),
|
||||||
|
value: 10_000.0,
|
||||||
|
reason: "sell_then_rebuy".to_string(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
notes: Vec::new(),
|
||||||
|
diagnostics: Vec::new(),
|
||||||
|
risk_decisions: Vec::new(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.expect("broker execution");
|
.expect("broker execution");
|
||||||
@@ -4035,6 +4284,94 @@ fn same_day_sell_then_rebuy_reinserts_position_at_end() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn broker_configured_policy_can_allow_upper_limit_buy() {
|
||||||
|
let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap();
|
||||||
|
let data = single_symbol_limit_price_data(date, "000002.SZ", 11.0, 11.0, 9.0);
|
||||||
|
let mut risk_config = FidcRiskControlConfig::default();
|
||||||
|
risk_config.static_rules.reject_upper_limit_buy = false;
|
||||||
|
let broker = BrokerSimulator::new_with_execution_price(
|
||||||
|
ChinaAShareCostModel::default(),
|
||||||
|
ChinaEquityRuleHooks::default(),
|
||||||
|
PriceField::Open,
|
||||||
|
)
|
||||||
|
.with_risk_config(risk_config);
|
||||||
|
let mut portfolio = PortfolioState::new(1_000_000.0);
|
||||||
|
|
||||||
|
let report = broker
|
||||||
|
.execute(
|
||||||
|
date,
|
||||||
|
&mut portfolio,
|
||||||
|
&data,
|
||||||
|
&StrategyDecision {
|
||||||
|
rebalance: false,
|
||||||
|
target_weights: BTreeMap::new(),
|
||||||
|
exit_symbols: BTreeSet::new(),
|
||||||
|
order_intents: vec![OrderIntent::Value {
|
||||||
|
symbol: "000002.SZ".to_string(),
|
||||||
|
value: 10_000.0,
|
||||||
|
reason: "configured_upper_limit_buy".to_string(),
|
||||||
|
}],
|
||||||
|
notes: Vec::new(),
|
||||||
|
diagnostics: Vec::new(),
|
||||||
|
risk_decisions: Vec::new(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expect("broker execution");
|
||||||
|
|
||||||
|
assert_eq!(report.fill_events.len(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
report.order_events.last().map(|event| event.status),
|
||||||
|
Some(OrderStatus::Filled)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn broker_configured_policy_can_allow_lower_limit_sell() {
|
||||||
|
let prev_date = NaiveDate::from_ymd_opt(2024, 1, 9).unwrap();
|
||||||
|
let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap();
|
||||||
|
let data = single_symbol_limit_price_data(date, "000002.SZ", 9.0, 11.0, 9.0);
|
||||||
|
let mut risk_config = FidcRiskControlConfig::default();
|
||||||
|
risk_config.static_rules.reject_lower_limit_sell = false;
|
||||||
|
let broker = BrokerSimulator::new_with_execution_price(
|
||||||
|
ChinaAShareCostModel::default(),
|
||||||
|
ChinaEquityRuleHooks::default(),
|
||||||
|
PriceField::Open,
|
||||||
|
)
|
||||||
|
.with_risk_config(risk_config);
|
||||||
|
let mut portfolio = PortfolioState::new(1_000_000.0);
|
||||||
|
portfolio
|
||||||
|
.position_mut("000002.SZ")
|
||||||
|
.buy(prev_date, 1_000, 10.0);
|
||||||
|
|
||||||
|
let report = broker
|
||||||
|
.execute(
|
||||||
|
date,
|
||||||
|
&mut portfolio,
|
||||||
|
&data,
|
||||||
|
&StrategyDecision {
|
||||||
|
rebalance: false,
|
||||||
|
target_weights: BTreeMap::new(),
|
||||||
|
exit_symbols: BTreeSet::new(),
|
||||||
|
order_intents: vec![OrderIntent::TargetValue {
|
||||||
|
symbol: "000002.SZ".to_string(),
|
||||||
|
target_value: 0.0,
|
||||||
|
reason: "configured_lower_limit_sell".to_string(),
|
||||||
|
}],
|
||||||
|
notes: Vec::new(),
|
||||||
|
diagnostics: Vec::new(),
|
||||||
|
risk_decisions: Vec::new(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expect("broker execution");
|
||||||
|
|
||||||
|
assert_eq!(report.fill_events.len(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
report.order_events.last().map(|event| event.status),
|
||||||
|
Some(OrderStatus::Filled)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
fn two_day_limit_order_data(day1_open: f64, day2_open: f64) -> DataSet {
|
fn two_day_limit_order_data(day1_open: f64, day2_open: f64) -> DataSet {
|
||||||
let day1 = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap();
|
let day1 = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap();
|
||||||
let day2 = NaiveDate::from_ymd_opt(2024, 1, 11).unwrap();
|
let day2 = NaiveDate::from_ymd_opt(2024, 1, 11).unwrap();
|
||||||
@@ -4195,6 +4532,7 @@ fn broker_rejects_open_limit_buy_at_market_close() {
|
|||||||
}],
|
}],
|
||||||
notes: Vec::new(),
|
notes: Vec::new(),
|
||||||
diagnostics: Vec::new(),
|
diagnostics: Vec::new(),
|
||||||
|
risk_decisions: Vec::new(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.expect("day1 execution");
|
.expect("day1 execution");
|
||||||
@@ -4229,6 +4567,7 @@ fn broker_rejects_open_limit_buy_at_market_close() {
|
|||||||
order_intents: Vec::new(),
|
order_intents: Vec::new(),
|
||||||
notes: Vec::new(),
|
notes: Vec::new(),
|
||||||
diagnostics: Vec::new(),
|
diagnostics: Vec::new(),
|
||||||
|
risk_decisions: Vec::new(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.expect("day2 execution");
|
.expect("day2 execution");
|
||||||
@@ -4266,6 +4605,7 @@ fn broker_uses_limit_price_slippage_for_limit_orders() {
|
|||||||
}],
|
}],
|
||||||
notes: Vec::new(),
|
notes: Vec::new(),
|
||||||
diagnostics: Vec::new(),
|
diagnostics: Vec::new(),
|
||||||
|
risk_decisions: Vec::new(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.expect("broker execution");
|
.expect("broker execution");
|
||||||
@@ -4303,6 +4643,7 @@ fn broker_rejects_limit_buy_when_final_execution_price_reaches_upper_limit() {
|
|||||||
}],
|
}],
|
||||||
notes: Vec::new(),
|
notes: Vec::new(),
|
||||||
diagnostics: Vec::new(),
|
diagnostics: Vec::new(),
|
||||||
|
risk_decisions: Vec::new(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.expect("broker execution");
|
.expect("broker execution");
|
||||||
@@ -4346,6 +4687,7 @@ fn broker_executes_limit_value_and_limit_percent_intents() {
|
|||||||
}],
|
}],
|
||||||
notes: Vec::new(),
|
notes: Vec::new(),
|
||||||
diagnostics: Vec::new(),
|
diagnostics: Vec::new(),
|
||||||
|
risk_decisions: Vec::new(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.expect("broker execution");
|
.expect("broker execution");
|
||||||
@@ -4370,6 +4712,7 @@ fn broker_executes_limit_value_and_limit_percent_intents() {
|
|||||||
}],
|
}],
|
||||||
notes: Vec::new(),
|
notes: Vec::new(),
|
||||||
diagnostics: Vec::new(),
|
diagnostics: Vec::new(),
|
||||||
|
risk_decisions: Vec::new(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.expect("broker execution");
|
.expect("broker execution");
|
||||||
@@ -4405,6 +4748,7 @@ fn broker_cancels_open_order_by_order_id() {
|
|||||||
}],
|
}],
|
||||||
notes: Vec::new(),
|
notes: Vec::new(),
|
||||||
diagnostics: Vec::new(),
|
diagnostics: Vec::new(),
|
||||||
|
risk_decisions: Vec::new(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.expect("day1 execution");
|
.expect("day1 execution");
|
||||||
@@ -4425,6 +4769,7 @@ fn broker_cancels_open_order_by_order_id() {
|
|||||||
}],
|
}],
|
||||||
notes: Vec::new(),
|
notes: Vec::new(),
|
||||||
diagnostics: Vec::new(),
|
diagnostics: Vec::new(),
|
||||||
|
risk_decisions: Vec::new(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.expect("day2 execution");
|
.expect("day2 execution");
|
||||||
@@ -4471,6 +4816,7 @@ fn broker_emits_cancellation_reject_for_unknown_order() {
|
|||||||
}],
|
}],
|
||||||
notes: Vec::new(),
|
notes: Vec::new(),
|
||||||
diagnostics: Vec::new(),
|
diagnostics: Vec::new(),
|
||||||
|
risk_decisions: Vec::new(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.expect("cancel reject execution");
|
.expect("cancel reject execution");
|
||||||
@@ -4585,6 +4931,7 @@ fn broker_reserves_sellable_quantity_for_open_limit_sells() {
|
|||||||
],
|
],
|
||||||
notes: Vec::new(),
|
notes: Vec::new(),
|
||||||
diagnostics: Vec::new(),
|
diagnostics: Vec::new(),
|
||||||
|
risk_decisions: Vec::new(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.expect("broker execution");
|
.expect("broker execution");
|
||||||
|
|||||||
Reference in New Issue
Block a user