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

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)