删除隐藏兼容模式并统一撮合风控

This commit is contained in:
boris
2026-08-01 12:37:31 +08:00
parent ca9732ecb2
commit d21680ed4f
8 changed files with 441 additions and 949 deletions
+144 -189
View File
@@ -185,7 +185,6 @@ pub struct BrokerSimulator<C, R> {
strict_value_budget: bool,
rebalance_cash_mode: RebalanceCashMode,
sell_then_buy_delay_slippage_rate: f64,
aiquant_execution_rules: bool,
same_day_buy_close_mark_at_fill: bool,
risk_config: FidcRiskControlConfig,
same_day_sold_symbols: RefCell<BTreeMap<NaiveDate, BTreeSet<String>>>,
@@ -216,7 +215,6 @@ impl<C, R> BrokerSimulator<C, R> {
strict_value_budget: true,
rebalance_cash_mode: RebalanceCashMode::default(),
sell_then_buy_delay_slippage_rate: 0.0,
aiquant_execution_rules: false,
same_day_buy_close_mark_at_fill: false,
risk_config: FidcRiskControlConfig::default(),
same_day_sold_symbols: RefCell::new(BTreeMap::new()),
@@ -251,7 +249,6 @@ impl<C, R> BrokerSimulator<C, R> {
strict_value_budget: true,
rebalance_cash_mode: RebalanceCashMode::default(),
sell_then_buy_delay_slippage_rate: 0.0,
aiquant_execution_rules: false,
same_day_buy_close_mark_at_fill: false,
risk_config: FidcRiskControlConfig::default(),
same_day_sold_symbols: RefCell::new(BTreeMap::new()),
@@ -305,11 +302,6 @@ impl<C, R> BrokerSimulator<C, R> {
self
}
pub fn with_aiquant_execution_rules(mut self, enabled: bool) -> Self {
self.aiquant_execution_rules = enabled;
self
}
pub fn with_same_day_buy_close_mark_at_fill(mut self, enabled: bool) -> Self {
self.same_day_buy_close_mark_at_fill = enabled;
self
@@ -444,7 +436,7 @@ where
return execution_price;
}
}
if self.aiquant_execution_rules && self.execution_price_field == PriceField::Last {
if self.execution_price_field == PriceField::Last {
let start_cursor = self
.runtime_intraday_start_time
.get()
@@ -1075,6 +1067,8 @@ where
if current_qty > target_qty {
let requested_qty = current_qty - target_qty;
let fill_start = report.fill_events.len();
let order_start = report.order_events.len();
self.process_sell(
date,
portfolio,
@@ -1093,6 +1087,23 @@ where
None,
&mut report,
)?;
let filled_quantity = report.fill_events[fill_start..]
.iter()
.filter(|fill| fill.symbol == symbol && fill.side == OrderSide::Sell)
.map(|fill| fill.quantity)
.sum::<u32>();
if filled_quantity < requested_qty && report.diagnostics.len() < 32 {
let denial_reason = report.order_events[order_start..]
.iter()
.rev()
.find(|event| event.symbol == symbol && event.side == OrderSide::Sell)
.map(|event| event.reason.as_str())
.unwrap_or("sell_not_fully_filled");
report.diagnostics.push(format!(
"rebalance_target_denied symbol={} side=sell requested={} filled={} reason={}",
symbol, requested_qty, filled_quantity, denial_reason
));
}
}
}
@@ -1104,6 +1115,15 @@ where
.unwrap_or(0);
if target_qty > current_qty {
let requested_qty = target_qty - current_qty;
if !self.can_afford_minimum_buy(date, portfolio, data, &symbol) {
if report.diagnostics.len() < 32 {
report.diagnostics.push(format!(
"rebalance_buy_reduced symbol={} provisional={} final={} current={} reason=actual_cash_after_sells",
symbol, target_qty, current_qty, current_qty
));
}
continue;
}
self.process_buy(
date,
portfolio,
@@ -2732,6 +2752,25 @@ where
&mut local_report,
)?;
}
let filled_quantity = local_report
.fill_events
.iter()
.filter(|fill| fill.symbol == *symbol && fill.side == OrderSide::Sell)
.map(|fill| fill.quantity)
.sum::<u32>();
if filled_quantity < sell_qty && local_report.diagnostics.len() < 32 {
let denial_reason = local_report
.order_events
.iter()
.rev()
.find(|event| event.symbol == *symbol && event.side == OrderSide::Sell)
.map(|event| event.reason.as_str())
.unwrap_or("sell_not_fully_filled");
local_report.diagnostics.push(format!(
"rebalance_target_denied symbol={} side=sell requested={} filled={} reason={}",
symbol, sell_qty, filled_quantity, denial_reason
));
}
Self::extend_report(report, local_report);
}
@@ -2745,6 +2784,15 @@ where
continue;
}
let buy_qty = target_qty - current_qty;
if !self.can_afford_minimum_buy(date, portfolio, data, symbol) {
if report.diagnostics.len() < 32 {
report.diagnostics.push(format!(
"rebalance_buy_reduced symbol={} provisional={} final={} current={} reason=actual_cash_after_sells",
symbol, target_qty, current_qty, current_qty
));
}
continue;
}
let mut local_report = BrokerExecutionReport::default();
if let Some(limit_price) =
self.required_custom_order_price(date, symbol, limit_prices)?
@@ -2886,7 +2934,7 @@ where
Ok(())
}
fn aiquant_limit_check_price(
fn execution_limit_check_price(
&self,
snapshot: &crate::data::DailyMarketSnapshot,
side: OrderSide,
@@ -2898,7 +2946,7 @@ where
}
}
fn aiquant_order_limit_check_price(
fn execution_order_limit_check_price(
&self,
date: NaiveDate,
data: &DataSet,
@@ -2922,7 +2970,7 @@ where
false,
)
.and_then(|quote| self.select_quote_reference_price(snapshot, quote, side, matching_type))
.unwrap_or_else(|| self.aiquant_limit_check_price(snapshot, side))
.unwrap_or_else(|| self.execution_limit_check_price(snapshot, side))
}
#[cfg(test)]
@@ -2933,11 +2981,7 @@ where
candidate: &crate::data::CandidateEligibility,
instrument: Option<&Instrument>,
) -> RuleCheck {
let check_price = if self.aiquant_execution_rules {
self.aiquant_limit_check_price(snapshot, OrderSide::Buy)
} else {
ChinaAShareRiskControl::buy_check_price(snapshot, self.execution_price_field)
};
let check_price = self.execution_limit_check_price(snapshot, OrderSide::Buy);
if let Some(reason) = ChinaAShareRiskControl::buy_rejection_reason_with_config(
date,
candidate,
@@ -2948,7 +2992,7 @@ where
) {
return RuleCheck::reject(reason);
}
if !self.aiquant_execution_rules && !self.rules.duplicates_standard_china_risk() {
if !self.rules.duplicates_standard_china_risk() {
return self
.rules
.can_buy(date, snapshot, candidate, self.execution_price_field);
@@ -2966,18 +3010,14 @@ where
instrument: Option<&Instrument>,
algo_request: Option<&AlgoExecutionRequest>,
) -> RuleCheck {
let check_price = if self.aiquant_execution_rules {
self.aiquant_order_limit_check_price(
date,
data,
symbol,
snapshot,
OrderSide::Buy,
algo_request,
)
} else {
ChinaAShareRiskControl::buy_check_price(snapshot, self.execution_price_field)
};
let check_price = self.execution_order_limit_check_price(
date,
data,
symbol,
snapshot,
OrderSide::Buy,
algo_request,
);
if let Some(reason) = self.same_day_rebuy_rejection_reason(date, symbol) {
return RuleCheck::reject(reason);
}
@@ -2991,7 +3031,7 @@ where
) {
return RuleCheck::reject(reason);
}
if !self.aiquant_execution_rules && !self.rules.duplicates_standard_china_risk() {
if !self.rules.duplicates_standard_china_risk() {
return self
.rules
.can_buy(date, snapshot, candidate, self.execution_price_field);
@@ -3010,36 +3050,25 @@ where
position: &crate::portfolio::Position,
algo_request: Option<&AlgoExecutionRequest>,
) -> RuleCheck {
if self.risk_config.static_rules.respect_allow_buy_sell
&& !self.aiquant_execution_rules
&& !candidate.allow_sell
{
return RuleCheck::reject("sell_disabled");
}
let check_price = if self.aiquant_execution_rules {
self.aiquant_order_limit_check_price(
date,
data,
symbol,
snapshot,
OrderSide::Sell,
algo_request,
)
} else {
ChinaAShareRiskControl::sell_check_price(snapshot, self.execution_price_field)
};
let check_price = self.execution_order_limit_check_price(
date,
data,
symbol,
snapshot,
OrderSide::Sell,
algo_request,
);
let adjusted_candidate;
let candidate_for_check = if self.aiquant_execution_rules
&& self.aiquant_sell_allow_flag_is_stale_lower_limit(candidate, snapshot, check_price)
{
adjusted_candidate = crate::data::CandidateEligibility {
allow_sell: true,
..candidate.clone()
let candidate_for_check =
if self.sell_allow_flag_is_stale_lower_limit(candidate, snapshot, check_price) {
adjusted_candidate = crate::data::CandidateEligibility {
allow_sell: true,
..candidate.clone()
};
&adjusted_candidate
} else {
candidate
};
&adjusted_candidate
} else {
candidate
};
if let Some(reason) = ChinaAShareRiskControl::sell_rejection_reason_with_config(
date,
candidate_for_check,
@@ -3051,7 +3080,7 @@ where
) {
return RuleCheck::reject(reason);
}
if !self.aiquant_execution_rules && !self.rules.duplicates_standard_china_risk() {
if !self.rules.duplicates_standard_china_risk() {
return self.rules.can_sell(
date,
snapshot,
@@ -3063,7 +3092,7 @@ where
RuleCheck::allow()
}
fn aiquant_sell_allow_flag_is_stale_lower_limit(
fn sell_allow_flag_is_stale_lower_limit(
&self,
candidate: &crate::data::CandidateEligibility,
snapshot: &crate::data::DailyMarketSnapshot,
@@ -3082,11 +3111,11 @@ where
&self,
date: NaiveDate,
portfolio: &PortfolioState,
data: &DataSet,
_data: &DataSet,
symbol: &str,
current_qty: u32,
minimum_order_quantity: u32,
order_step_size: u32,
_minimum_order_quantity: u32,
_order_step_size: u32,
) -> u32 {
if current_qty == 0 {
return 0;
@@ -3094,93 +3123,23 @@ where
let Some(position) = portfolio.position(symbol) else {
return 0;
};
if self.aiquant_execution_rules {
let sellable = position
.sellable_qty(date)
.saturating_sub(self.reserved_open_sell_quantity(symbol, None));
return current_qty.saturating_sub(sellable.min(current_qty));
}
let Ok(snapshot) = data.require_market(date, symbol) else {
return current_qty;
};
let Ok(candidate) = data.require_candidate(date, symbol) else {
return current_qty;
};
let rule = self.sell_rule_check_for_order(
date,
data,
symbol,
snapshot,
candidate,
data.instrument(symbol),
position,
None,
);
if !rule.allowed {
return current_qty;
}
let sellable = position
.sellable_qty(date)
.saturating_sub(self.reserved_open_sell_quantity(symbol, None));
let sell_limit = match self.market_fillable_quantity(
snapshot,
OrderSide::Sell,
sellable.min(current_qty),
minimum_order_quantity,
order_step_size,
0,
sellable >= current_qty,
) {
Ok(quantity) => quantity.min(sellable).min(current_qty),
Err(_) => 0,
};
current_qty.saturating_sub(sell_limit)
current_qty.saturating_sub(sellable.min(current_qty))
}
fn maximum_target_quantity(
&self,
date: NaiveDate,
_date: NaiveDate,
_portfolio: &PortfolioState,
data: &DataSet,
symbol: &str,
current_qty: u32,
minimum_order_quantity: u32,
order_step_size: u32,
_data: &DataSet,
_symbol: &str,
_current_qty: u32,
_minimum_order_quantity: u32,
_order_step_size: u32,
) -> u32 {
if self.aiquant_execution_rules {
return u32::MAX;
}
let Ok(snapshot) = data.require_market(date, symbol) else {
return current_qty;
};
let Ok(candidate) = data.require_candidate(date, symbol) else {
return current_qty;
};
let rule = self.buy_rule_check_for_order(
date,
data,
symbol,
snapshot,
candidate,
data.instrument(symbol),
None,
);
if !rule.allowed {
return current_qty;
}
let additional_limit = match self.market_fillable_quantity(
snapshot,
OrderSide::Buy,
u32::MAX,
minimum_order_quantity,
order_step_size,
0,
false,
) {
Ok(quantity) => quantity,
Err(_) => 0,
};
current_qty.saturating_add(additional_limit)
u32::MAX
}
fn estimated_sell_net_cash(&self, date: NaiveDate, price: f64, quantity: u32) -> f64 {
@@ -3298,6 +3257,32 @@ where
gross + cost.total()
}
fn can_afford_minimum_buy(
&self,
date: NaiveDate,
portfolio: &PortfolioState,
data: &DataSet,
symbol: &str,
) -> bool {
let Some(snapshot) = data.market(date, symbol) else {
return true;
};
let minimum_order_quantity = self.minimum_order_quantity(data, symbol);
let order_step_size = self.order_step_size(data, symbol);
let minimum_buy_quantity = self.round_buy_quantity(
minimum_order_quantity,
minimum_order_quantity,
order_step_size,
);
if minimum_buy_quantity == 0 {
return false;
}
let minimum_execution_price =
self.snapshot_execution_price(snapshot, OrderSide::Buy, Some(minimum_buy_quantity));
self.estimated_buy_cash_out(date, minimum_execution_price, minimum_buy_quantity)
<= portfolio.cash() + 1e-6
}
fn process_sell(
&self,
date: NaiveDate,
@@ -3977,13 +3962,8 @@ where
return Ok(());
};
let current_value = if self.aiquant_execution_rules {
let valuation_price = self.target_value_valuation_price(date, data, symbol, snapshot);
valuation_price * current_qty as f64
} else {
let valuation_price = self.target_value_valuation_price(date, data, symbol, snapshot);
valuation_price * current_qty as f64
};
let valuation_price = self.target_value_valuation_price(date, data, symbol, snapshot);
let current_value = valuation_price * current_qty as f64;
let cash_delta = target_value.max(0.0) - current_value;
if cash_delta.abs() > f64::EPSILON {
@@ -4256,8 +4236,7 @@ where
commission_state: &mut BTreeMap<u64, f64>,
report: &mut BrokerExecutionReport,
) -> Result<(), BacktestError> {
let price = if self.aiquant_execution_rules && limit_price.is_finite() && limit_price > 0.0
{
let price = if limit_price.is_finite() && limit_price > 0.0 {
limit_price
} else {
data.market(date, symbol)
@@ -7645,7 +7624,7 @@ mod tests {
}
#[test]
fn target_value_valuation_uses_daily_snapshot_but_value_order_sizing_uses_intraday_minute() {
fn scheduled_target_value_valuation_and_sizing_use_same_intraday_price() {
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date");
let broker = BrokerSimulator::new_with_execution_price(
ChinaAShareCostModel::default(),
@@ -7672,7 +7651,7 @@ mod tests {
assert_eq!(
broker.target_value_valuation_price(date, &data, "000001.SZ", snapshot),
10.0
11.0
);
assert_eq!(
broker.value_sell_sizing_price(date, &data, "000001.SZ", snapshot),
@@ -8138,7 +8117,7 @@ mod tests {
}
#[test]
fn aiquant_target_portfolio_smart_defers_buy_risk_during_target_sizing() {
fn target_portfolio_smart_defers_buy_risk_until_order_validation() {
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date");
let data = DataSet::from_components_with_actions_and_quotes(
vec![limit_test_instrument()],
@@ -8154,30 +8133,17 @@ mod tests {
let mut target_weights = BTreeMap::new();
target_weights.insert("000001.SZ".to_string(), 0.50);
let default_broker = BrokerSimulator::new_with_execution_price(
let broker = BrokerSimulator::new_with_execution_price(
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks,
PriceField::Open,
)
.with_volume_limit(false)
.with_liquidity_limit(false);
let (default_targets, _) = default_broker
let (targets, _) = broker
.target_quantities(date, &portfolio, &data, &target_weights)
.expect("default target quantities");
assert_eq!(default_targets.get("000001.SZ").copied().unwrap_or(0), 0);
let aiquant_broker = BrokerSimulator::new_with_execution_price(
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks,
PriceField::Open,
)
.with_aiquant_execution_rules(true)
.with_volume_limit(false)
.with_liquidity_limit(false);
let (aiquant_targets, _) = aiquant_broker
.target_quantities(date, &portfolio, &data, &target_weights)
.expect("aiquant target quantities");
assert_eq!(aiquant_targets.get("000001.SZ").copied(), Some(49_900));
.expect("target quantities");
assert_eq!(targets.get("000001.SZ").copied(), Some(49_900));
}
#[test]
@@ -8198,7 +8164,6 @@ mod tests {
ChinaEquityRuleHooks,
PriceField::Open,
)
.with_aiquant_execution_rules(true)
.with_volume_limit(false)
.with_liquidity_limit(false);
let mut portfolio = PortfolioState::new(1_000_000.0);
@@ -8294,7 +8259,6 @@ mod tests {
ChinaEquityRuleHooks,
PriceField::Open,
)
.with_aiquant_execution_rules(true)
.with_volume_limit(false)
.with_liquidity_limit(false)
.with_inactive_limit(false);
@@ -8660,7 +8624,6 @@ mod tests {
ChinaEquityRuleHooks,
PriceField::Open,
)
.with_aiquant_execution_rules(true)
.with_rebalance_cash_mode(mode)
.with_volume_limit(false)
.with_liquidity_limit(false)
@@ -9093,16 +9056,12 @@ mod tests {
Some(1_000)
);
assert!(report.fill_events.is_empty());
assert!(report.order_events.is_empty());
assert!(
report
.diagnostics
.iter()
.any(|item| item.contains("rebalance_target_clipped")
&& item.contains("000001.SZ")),
"{:?}",
report.diagnostics
);
assert!(report.order_events.iter().any(|event| {
event.symbol == symbol
&& event.side == OrderSide::Sell
&& event.status == OrderStatus::Rejected
&& event.reason.contains("missing")
}));
}
#[test]
@@ -9204,7 +9163,6 @@ mod tests {
.with_intraday_execution_start_time(date.and_hms_opt(10, 40, 0).unwrap().time())
.with_slippage_model(SlippageModel::PriceRatio(0.002))
.with_strict_value_budget(true)
.with_aiquant_execution_rules(true)
.with_volume_limit(false)
.with_liquidity_limit(false)
.with_inactive_limit(false);
@@ -9627,8 +9585,7 @@ mod tests {
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks,
PriceField::Last,
)
.with_aiquant_execution_rules(true);
);
let aiquant_rule = aiquant_broker.buy_rule_check(date, &snapshot, &candidate, None);
assert!(!aiquant_rule.allowed);
assert_eq!(aiquant_rule.reason.as_deref(), Some("buy_disabled"));
@@ -9691,7 +9648,6 @@ mod tests {
ChinaEquityRuleHooks,
PriceField::Last,
)
.with_aiquant_execution_rules(true)
.with_intraday_execution_start_time(date.and_hms_opt(10, 18, 0).unwrap().time())
.with_volume_limit(false)
.with_liquidity_limit(false)
@@ -9781,7 +9737,6 @@ mod tests {
ChinaEquityRuleHooks,
PriceField::Last,
)
.with_aiquant_execution_rules(true)
.with_intraday_execution_start_time(date.and_hms_opt(10, 18, 0).unwrap().time())
.with_volume_limit(false)
.with_liquidity_limit(false)
+3 -29
View File
@@ -5,8 +5,6 @@ use chrono::NaiveDate;
use crate::events::OrderSide;
use crate::risk_control::TradingConstraintConfig;
pub const STOCK_PIT_TAX_CHANGE_DATE: (i32, u32, u32) = (2023, 8, 28);
#[derive(Debug, Clone, Copy)]
pub struct TradingCost {
pub commission: f64,
@@ -45,26 +43,11 @@ pub struct ChinaAShareCostModel {
impl Default for ChinaAShareCostModel {
fn default() -> Self {
Self {
commission_rate: 0.0008,
stamp_tax_rate_before_change: 0.001,
stamp_tax_rate_after_change: 0.0005,
stamp_tax_change_date: default_stamp_tax_change_date(),
minimum_commission: 5.0,
}
Self::from_trading_constraints(TradingConstraintConfig::default())
}
}
impl ChinaAShareCostModel {
pub fn aiquant_default() -> Self {
Self {
commission_rate: 0.0003,
stamp_tax_rate_before_change: 0.0005,
stamp_tax_rate_after_change: 0.0005,
..Self::default()
}
}
pub fn from_trading_constraints(config: TradingConstraintConfig) -> Self {
Self {
commission_rate: config.commission_rate,
@@ -135,15 +118,6 @@ impl ChinaAShareCostModel {
}
}
fn default_stamp_tax_change_date() -> NaiveDate {
NaiveDate::from_ymd_opt(
STOCK_PIT_TAX_CHANGE_DATE.0,
STOCK_PIT_TAX_CHANGE_DATE.1,
STOCK_PIT_TAX_CHANGE_DATE.2,
)
.expect("valid pit tax change date")
}
impl CostModel for ChinaAShareCostModel {
fn calculate(&self, date: NaiveDate, side: OrderSide, gross_amount: f64) -> TradingCost {
if gross_amount <= 0.0 {
@@ -192,8 +166,8 @@ mod tests {
use super::*;
#[test]
fn aiquant_default_matches_current_backtest_fee_model() {
let model = ChinaAShareCostModel::aiquant_default();
fn default_matches_configurable_trading_constraints() {
let model = ChinaAShareCostModel::default();
let date = NaiveDate::from_ymd_opt(2025, 11, 11).expect("valid date");
assert!((model.commission_for(248_059.812) - 74.4179436).abs() < 1e-9);
File diff suppressed because it is too large Load Diff
+45 -93
View File
@@ -119,8 +119,6 @@ pub struct StrategyEngineConfig {
pub template_id: Option<String>,
#[serde(default, alias = "profile_name")]
pub profile_name: Option<String>,
#[serde(default, alias = "compatibility_profile")]
pub compatibility_profile: Option<String>,
#[serde(default, alias = "benchmark_symbol")]
pub benchmark_symbol: Option<String>,
#[serde(default, alias = "signal_symbol")]
@@ -811,6 +809,7 @@ pub fn platform_expr_config_from_value(
return platform_expr_config_from_spec(strategy_id, signal_symbol, None)
.map_err(platform_config_error);
}
reject_removed_compatibility_fields(value).map_err(platform_config_error)?;
let mut value = value.clone();
normalize_risk_policy_aliases_in_value(&mut value).map_err(platform_config_error)?;
let spec = serde_json::from_value::<StrategyRuntimeSpec>(value)?;
@@ -818,6 +817,30 @@ pub fn platform_expr_config_from_value(
.map_err(platform_config_error)
}
fn reject_removed_compatibility_fields(value: &Value) -> Result<(), String> {
const SECTION_NAMES: [&str; 3] = ["engineConfig", "engine_config", "execution"];
const FIELD_NAMES: [&str; 4] = [
"compatibilityProfile",
"compatibility_profile",
"compatProfile",
"compat_profile",
];
for section_name in SECTION_NAMES {
let Some(section) = value.get(section_name).and_then(Value::as_object) else {
continue;
};
if let Some(field_name) = FIELD_NAMES
.iter()
.find(|field_name| section.contains_key(**field_name))
{
return Err(format!(
"{section_name}.{field_name} has been removed; configure matching, risk, fees and scheduling explicitly"
));
}
}
Ok(())
}
fn platform_config_error(message: String) -> serde_json::Error {
serde_json::Error::io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
@@ -849,12 +872,6 @@ fn parse_policy_date(value: Option<&str>) -> Option<NaiveDate> {
.ok()
}
fn is_aiquant_profile(value: Option<&str>) -> bool {
value
.map(|item| item.trim().to_ascii_lowercase().replace('-', "_"))
.is_some_and(|item| item == "aiquant" || item == "aiquant_rqalpha" || item == "rqalpha")
}
fn parse_stop_take_reference_price_mode(
value: &str,
) -> Result<PlatformStopTakeReferencePriceMode, String> {
@@ -1917,52 +1934,10 @@ pub fn platform_expr_config_from_spec(
if !cfg.benchmark_symbol.trim().is_empty() {
cfg.benchmark_symbol = normalize_symbol(&cfg.benchmark_symbol, None);
}
let aiquant_profile = spec.engine_config.as_ref().is_some_and(|engine| {
is_aiquant_profile(engine.profile_name.as_deref())
|| is_aiquant_profile(engine.compatibility_profile.as_deref())
});
if aiquant_profile {
cfg.aiquant_transaction_cost = true;
cfg.strict_value_budget = true;
if !cfg.universe_exclude.iter().any(|item| item == "bjse") {
cfg.universe_exclude.push("bjse".to_string());
}
let trading = spec
.runtime_expressions
.as_ref()
.and_then(|runtime_expr| runtime_expr.trading.as_ref());
if trading.and_then(|item| item.daily_top_up).is_none() {
cfg.daily_top_up_enabled = true;
}
if trading
.and_then(|item| item.retry_empty_rebalance)
.is_none()
{
cfg.retry_empty_rebalance = true;
}
}
let trade_times = spec_trade_times(spec);
if let Some(main_trade_time) = trade_times.last().copied() {
cfg.intraday_execution_time = Some(main_trade_time);
}
let delayed_limit_open_exit_explicit = spec
.runtime_expressions
.as_ref()
.and_then(|runtime_expr| runtime_expr.trading.as_ref())
.and_then(|trading| trading.delayed_limit_open_exit)
.is_some();
if aiquant_profile && !delayed_limit_open_exit_explicit && trade_times.len() > 1 {
let delayed_time = trade_times[0];
if trade_times
.last()
.copied()
.map(|main_time| main_time != delayed_time)
.unwrap_or(true)
{
cfg.delayed_limit_open_exit_enabled = true;
cfg.delayed_limit_open_exit_time = Some(delayed_time);
}
}
if let Some(execution) = spec.execution.as_ref() {
apply_cost_overrides(
&mut cfg,
@@ -1994,13 +1969,6 @@ pub fn platform_expr_config_from_spec(
)?;
sync_quote_quantity_limit(&mut cfg);
}
if cfg.aiquant_transaction_cost
&& cfg
.minimum_commission
.is_some_and(|value| value.is_finite() && value <= 0.0)
{
cfg.minimum_commission = None;
}
cfg.strict_value_budget = true;
Ok(cfg)
@@ -2485,16 +2453,12 @@ mod tests {
assert_eq!(cfg.signal_symbol, "000852.SH");
assert_eq!(cfg.selection_limit_expr, "stocknum");
assert_eq!(cfg.refresh_rate_expr, "year >= 2024 ? 5 : 20");
assert_eq!(
cfg.universe_exclude,
["paused", "st", "kcb", "one_yuan", "bjse"]
);
assert_eq!(cfg.universe_exclude, ["paused", "st", "kcb", "one_yuan"]);
assert!(!cfg.rotation_enabled);
assert!(cfg.daily_top_up_enabled);
assert!(cfg.retry_empty_rebalance);
assert_eq!(cfg.weak_market_shrink_overweight_threshold, Some(1.1));
assert!(!cfg.calendar_rebalance_interval);
assert!(cfg.aiquant_transaction_cost);
assert_eq!(cfg.explicit_actions.len(), 1);
assert_eq!(
cfg.explicit_action_stage,
@@ -2622,7 +2586,6 @@ mod tests {
let cfg = platform_expr_config_from_value("", "", &spec).expect("config");
assert!(cfg.aiquant_transaction_cost);
assert_eq!(cfg.commission_rate, Some(0.0003));
assert_eq!(cfg.minimum_commission, Some(5.0));
assert_eq!(cfg.stamp_tax_rate_before_change, Some(0.0005));
@@ -3084,7 +3047,7 @@ mod tests {
}
#[test]
fn aiquant_profile_defaults_to_daily_top_up_and_empty_retry() {
fn profile_name_does_not_inject_trading_behaviors() {
let spec = serde_json::json!({
"engineConfig": {
"profileName": "aiquant"
@@ -3093,9 +3056,8 @@ mod tests {
let cfg = platform_expr_config_from_value("", "", &spec).expect("config");
assert!(cfg.aiquant_transaction_cost);
assert!(cfg.daily_top_up_enabled);
assert!(cfg.retry_empty_rebalance);
assert!(!cfg.daily_top_up_enabled);
assert!(!cfg.retry_empty_rebalance);
assert!(cfg.strict_value_budget);
let explicit_off = serde_json::json!({
@@ -3125,7 +3087,7 @@ mod tests {
}
#[test]
fn engine_config_profile_name_enables_aiquant_semantics() {
fn engine_config_profile_name_is_metadata_only() {
let spec = serde_json::json!({
"engineConfig": {
"profileName": "aiquant"
@@ -3133,12 +3095,12 @@ mod tests {
});
let cfg = platform_expr_config_from_value("", "", &spec).expect("config");
assert!(cfg.aiquant_transaction_cost);
assert!(!cfg.daily_top_up_enabled);
assert!(!cfg.retry_empty_rebalance);
}
#[test]
fn engine_config_profile_name_accepts_aiquant_rqalpha_alias() {
fn legacy_profile_name_does_not_inject_hidden_defaults() {
let spec = serde_json::json!({
"engineConfig": {
"profileName": "aiquant_rqalpha"
@@ -3147,9 +3109,8 @@ mod tests {
let cfg = platform_expr_config_from_value("", "", &spec).expect("config");
assert!(cfg.aiquant_transaction_cost);
assert!(cfg.daily_top_up_enabled);
assert!(cfg.retry_empty_rebalance);
assert!(!cfg.daily_top_up_enabled);
assert!(!cfg.retry_empty_rebalance);
assert!(cfg.strict_value_budget);
}
@@ -3243,7 +3204,7 @@ mod tests {
}
#[test]
fn parses_daily_schedule_time_for_aiquant_execution_quotes() {
fn parses_daily_schedule_time_for_execution_quotes() {
let spec = serde_json::json!({
"engineConfig": { "profileName": "aiquant" },
"runtimeExpressions": {
@@ -3259,11 +3220,10 @@ mod tests {
Some(NaiveTime::from_hms_opt(9, 33, 0).unwrap())
);
assert!(!cfg.calendar_rebalance_interval);
assert!(cfg.aiquant_transaction_cost);
}
#[test]
fn parses_aiquant_rebalance_trade_times_for_delayed_limit_exit() {
fn multiple_trade_times_do_not_imply_delayed_limit_exit() {
let spec = serde_json::json!({
"engineConfig": { "profileName": "aiquant" },
"rebalance": { "tradeTimes": ["10:31", "10:40"] },
@@ -3278,15 +3238,12 @@ mod tests {
cfg.intraday_execution_time,
Some(NaiveTime::from_hms_opt(10, 40, 0).unwrap())
);
assert!(cfg.delayed_limit_open_exit_enabled);
assert_eq!(
cfg.delayed_limit_open_exit_time,
Some(NaiveTime::from_hms_opt(10, 31, 0).unwrap())
);
assert!(!cfg.delayed_limit_open_exit_enabled);
assert_eq!(cfg.delayed_limit_open_exit_time, None);
}
#[test]
fn parses_aiquant_compatibility_profile_for_delayed_limit_exit() {
fn rejects_removed_compatibility_profile() {
let spec = serde_json::json!({
"engineConfig": {
"profileName": "cn_a_microcap_v1",
@@ -3298,16 +3255,11 @@ mod tests {
}
});
let cfg = platform_expr_config_from_value("", "", &spec).expect("config");
assert_eq!(
cfg.intraday_execution_time,
Some(NaiveTime::from_hms_opt(10, 15, 0).unwrap())
);
assert!(cfg.delayed_limit_open_exit_enabled);
assert_eq!(
cfg.delayed_limit_open_exit_time,
Some(NaiveTime::from_hms_opt(9, 31, 0).unwrap())
let error = platform_expr_config_from_value("", "", &spec).expect_err("removed field");
assert!(
error
.to_string()
.contains("compatibilityProfile has been removed")
);
}
+4 -33
View File
@@ -1655,6 +1655,7 @@ pub struct OmniMicroCapConfig {
pub stock_long_ma_days: usize,
pub stock_volume_short_ma_days: usize,
pub stock_volume_long_ma_days: usize,
pub stock_volume_filter_enabled: bool,
pub rsi_rate: f64,
pub trade_rate: f64,
pub stop_loss_ratio: f64,
@@ -1684,6 +1685,7 @@ impl OmniMicroCapConfig {
stock_long_ma_days: 20,
stock_volume_short_ma_days: 5,
stock_volume_long_ma_days: 60,
stock_volume_filter_enabled: true,
rsi_rate: 1.0001,
trade_rate: 0.5,
stop_loss_ratio: 0.93,
@@ -1695,35 +1697,6 @@ impl OmniMicroCapConfig {
}
}
pub fn aiquant_v104() -> Self {
Self {
strategy_name: "aiquant-v1.0.4".to_string(),
refresh_rate: 120,
stocknum: 5,
xs: 4.0 / 500.0,
base_index_level: 2000.0,
base_cap_floor: 7.0,
cap_span: 10.0,
padding_ratio: 1.2,
min_padding: 29.5,
max_padding: 50.0,
benchmark_signal_symbol: "000852.SH".to_string(),
benchmark_short_ma_days: 5,
benchmark_long_ma_days: 20,
stock_short_ma_days: 5,
stock_mid_ma_days: 10,
stock_long_ma_days: 30,
stock_volume_short_ma_days: 5,
stock_volume_long_ma_days: 60,
rsi_rate: 1.0001,
trade_rate: 0.5,
stop_loss_ratio: 0.92,
take_profit_ratio: 1.16,
skip_month_day_ranges: Vec::new(),
risk_config: FidcRiskControlConfig::default(),
}
}
fn in_skip_window(&self, date: NaiveDate) -> bool {
let year = date.year() as u32;
let month = date.month();
@@ -2433,10 +2406,7 @@ impl OmniMicroCapStrategy {
return false;
}
if self.config.strategy_name.contains("aiquant")
|| self.config.strategy_name.contains("AiQuant")
|| self.config.strategy_name.contains("omni")
{
if self.config.stock_volume_filter_enabled {
let Some(volume_ma5) = ctx.data.market_decision_volume_moving_average(
date,
symbol,
@@ -3018,6 +2988,7 @@ mod tests {
default_cfg.stock_short_ma_days = 1;
default_cfg.stock_mid_ma_days = 2;
default_cfg.stock_long_ma_days = 3;
default_cfg.stock_volume_filter_enabled = false;
let default_strategy = OmniMicroCapStrategy::new(default_cfg.clone());
let (default_selected, _) = default_strategy
.select_symbols(&ctx, dates[2], 0.0, 100.0)
+11 -4
View File
@@ -63,13 +63,20 @@ fn china_cost_model_applies_minimum_commission_and_stamp_tax() {
assert_eq!(buy.stamp_tax, 0.0);
let sell = model.calculate(d(2023, 8, 25), OrderSide::Sell, 100_000.0);
assert!((sell.commission - 80.0).abs() < 1e-9);
assert!((sell.commission - 30.0).abs() < 1e-9);
assert!((sell.stamp_tax - 100.0).abs() < 1e-9);
}
#[test]
fn aiquant_cost_model_matches_alv_run_options() {
let model = ChinaAShareCostModel::aiquant_default();
fn configured_cost_model_matches_declared_run_options() {
let model =
ChinaAShareCostModel::from_trading_constraints(fidc_core::TradingConstraintConfig {
commission_rate: 0.0003,
minimum_commission: 5.0,
stamp_tax_rate_before_change: 0.0005,
stamp_tax_rate_after_change: 0.0005,
..fidc_core::TradingConstraintConfig::default()
});
let buy = model.calculate(d(2026, 5, 19), OrderSide::Buy, 49_978.84);
assert!((buy.commission - 14.993652).abs() < 1e-9);
@@ -130,7 +137,7 @@ fn china_cost_model_tracks_minimum_commission_per_order_id() {
assert!((first.commission - 5.0).abs() < 1e-9);
assert!(second.commission.abs() < 1e-9);
assert!((third.commission - 12.6).abs() < 1e-9);
assert!((third.commission - 1.6).abs() < 1e-9);
assert!((another_order.commission - 5.0).abs() < 1e-9);
}
+5 -1
View File
@@ -368,7 +368,11 @@ fn engine_reinvests_dividend_receivable_in_round_lots() {
first_date: buy_date,
},
BrokerSimulator::new_with_execution_price(
ChinaAShareCostModel::default(),
ChinaAShareCostModel {
commission_rate: 0.0008,
minimum_commission: 0.0,
..ChinaAShareCostModel::default()
},
ChinaEquityRuleHooks::default(),
PriceField::Open,
),
@@ -3321,7 +3321,8 @@ fn rebalance_optimizer_skips_unfunded_buy_when_existing_position_cannot_sell() {
.order_events
.iter()
.all(|event| !(event.symbol == "000002.SZ" && event.side == fidc_core::OrderSide::Buy)),
"optimizer should skip unfunded rebalance buy when locked holding cannot be sold"
"optimizer should skip unfunded rebalance buy when locked holding cannot be sold: {:#?}",
report
);
assert_eq!(
portfolio