删除隐藏兼容模式并统一撮合风控
This commit is contained in:
+144
-189
@@ -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)
|
||||
|
||||
@@ -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
@@ -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")
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user