完善FIDC策略执行语义

This commit is contained in:
boris
2026-07-06 14:52:50 +08:00
parent 4fee8e1d07
commit cb189e3de4
4 changed files with 363 additions and 21 deletions
+176 -2
View File
@@ -82,6 +82,19 @@ pub enum MatchingType {
Twap,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RebalanceCashMode {
SamePointNet,
SellThenBuy,
PreOpenCash,
}
impl Default for RebalanceCashMode {
fn default() -> Self {
Self::SellThenBuy
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct DynamicSlippageConfig {
pub impact_coefficient: f64,
@@ -171,6 +184,7 @@ pub struct BrokerSimulator<C, R> {
inactive_limit: bool,
liquidity_limit: bool,
strict_value_budget: bool,
rebalance_cash_mode: RebalanceCashMode,
aiquant_execution_rules: bool,
same_day_buy_close_mark_at_fill: bool,
risk_config: FidcRiskControlConfig,
@@ -198,6 +212,7 @@ impl<C, R> BrokerSimulator<C, R> {
inactive_limit: true,
liquidity_limit: true,
strict_value_budget: false,
rebalance_cash_mode: RebalanceCashMode::default(),
aiquant_execution_rules: false,
same_day_buy_close_mark_at_fill: false,
risk_config: FidcRiskControlConfig::default(),
@@ -229,6 +244,7 @@ impl<C, R> BrokerSimulator<C, R> {
inactive_limit: true,
liquidity_limit: true,
strict_value_budget: false,
rebalance_cash_mode: RebalanceCashMode::default(),
aiquant_execution_rules: false,
same_day_buy_close_mark_at_fill: false,
risk_config: FidcRiskControlConfig::default(),
@@ -263,6 +279,11 @@ impl<C, R> BrokerSimulator<C, R> {
self
}
pub fn with_rebalance_cash_mode(mut self, mode: RebalanceCashMode) -> Self {
self.rebalance_cash_mode = mode;
self
}
pub fn with_aiquant_execution_rules(mut self, enabled: bool) -> Self {
self.aiquant_execution_rules = enabled;
self
@@ -310,6 +331,18 @@ impl<C, R> BrokerSimulator<C, R> {
self.matching_type
}
pub fn rebalance_cash_mode(&self) -> RebalanceCashMode {
self.rebalance_cash_mode
}
fn effective_rebalance_cash_mode(&self) -> RebalanceCashMode {
if self.matching_type == MatchingType::MinuteLast {
RebalanceCashMode::SellThenBuy
} else {
self.rebalance_cash_mode
}
}
pub fn execution_price_field(&self) -> PriceField {
self.execution_price_field
}
@@ -1937,6 +1970,7 @@ where
symbols.extend(desired_targets.keys().cloned());
let mut constraints = Vec::new();
let cash_mode = self.effective_rebalance_cash_mode();
let mut projected_cash = portfolio.cash();
for symbol in symbols {
let current_qty = portfolio
@@ -2013,7 +2047,7 @@ where
symbol, desired_qty, min_target_qty, max_target_qty, provisional_target_qty
));
}
if current_qty > provisional_target_qty {
if current_qty > provisional_target_qty && cash_mode != RebalanceCashMode::PreOpenCash {
projected_cash += self.estimated_sell_net_cash(
date,
price,
@@ -5930,7 +5964,9 @@ fn sell_reason(decision: &StrategyDecision, symbol: &str) -> &'static str {
mod tests {
use std::collections::BTreeMap;
use super::{BrokerExecutionReport, BrokerSimulator, MatchingType, SlippageModel};
use super::{
BrokerExecutionReport, BrokerSimulator, MatchingType, RebalanceCashMode, SlippageModel,
};
use crate::cost::ChinaAShareCostModel;
use crate::data::{
BenchmarkSnapshot, CandidateEligibility, DailyMarketSnapshot, DataSet,
@@ -6885,6 +6921,144 @@ mod tests {
);
}
#[test]
fn target_portfolio_smart_pre_open_cash_does_not_spend_same_batch_sell_proceeds() {
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date");
let prev_date = chrono::NaiveDate::from_ymd_opt(2025, 1, 1).expect("valid date");
let symbols = ["000001.SZ", "000002.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 snapshots = symbols
.iter()
.map(|symbol| {
let mut snapshot = limit_test_snapshot();
snapshot.symbol = (*symbol).to_string();
snapshot
})
.collect::<Vec<_>>();
let candidates = symbols
.iter()
.map(|symbol| {
let mut candidate = limit_test_candidate(true, true);
candidate.symbol = (*symbol).to_string();
candidate
})
.collect::<Vec<_>>();
let data = DataSet::from_components_with_actions_and_quotes(
instruments,
snapshots,
Vec::new(),
candidates,
vec![limit_test_benchmark()],
Vec::new(),
Vec::new(),
)
.expect("valid dataset");
let mut target_weights = BTreeMap::new();
target_weights.insert("000002.SZ".to_string(), 1.0);
let sell_then_buy_broker = BrokerSimulator::new_with_execution_price(
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks,
PriceField::Open,
)
.with_volume_limit(false)
.with_liquidity_limit(false)
.with_inactive_limit(false);
let mut sell_then_buy_portfolio = PortfolioState::new(0.0);
sell_then_buy_portfolio
.position_mut("000001.SZ")
.buy(prev_date, 1_000, 10.0);
let mut sell_then_buy_report = BrokerExecutionReport::default();
sell_then_buy_broker
.process_target_portfolio_smart(
date,
&mut sell_then_buy_portfolio,
&data,
&target_weights,
None,
None,
"rebalance_cash_mode_test",
&mut BTreeMap::new(),
&mut BTreeMap::new(),
&mut None,
&mut BTreeMap::new(),
&mut sell_then_buy_report,
)
.expect("sell_then_buy rebalance");
assert!(sell_then_buy_portfolio.position("000001.SZ").is_none());
assert!(
sell_then_buy_portfolio
.position("000002.SZ")
.is_some_and(|position| position.quantity > 0),
"{:?}",
sell_then_buy_report.fill_events
);
let pre_open_cash_broker = BrokerSimulator::new_with_execution_price(
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks,
PriceField::Open,
)
.with_rebalance_cash_mode(RebalanceCashMode::PreOpenCash)
.with_volume_limit(false)
.with_liquidity_limit(false)
.with_inactive_limit(false);
let mut pre_open_cash_portfolio = PortfolioState::new(0.0);
pre_open_cash_portfolio
.position_mut("000001.SZ")
.buy(prev_date, 1_000, 10.0);
let mut pre_open_cash_report = BrokerExecutionReport::default();
pre_open_cash_broker
.process_target_portfolio_smart(
date,
&mut pre_open_cash_portfolio,
&data,
&target_weights,
None,
None,
"rebalance_cash_mode_test",
&mut BTreeMap::new(),
&mut BTreeMap::new(),
&mut None,
&mut BTreeMap::new(),
&mut pre_open_cash_report,
)
.expect("pre_open_cash rebalance");
assert!(pre_open_cash_portfolio.position("000001.SZ").is_none());
assert!(pre_open_cash_portfolio.position("000002.SZ").is_none());
assert!(
pre_open_cash_report
.fill_events
.iter()
.any(|fill| fill.symbol == "000001.SZ" && fill.side == OrderSide::Sell),
"{:?}",
pre_open_cash_report.fill_events
);
assert!(
pre_open_cash_report
.fill_events
.iter()
.all(|fill| !(fill.symbol == "000002.SZ" && fill.side == OrderSide::Buy)),
"{:?}",
pre_open_cash_report.fill_events
);
}
#[test]
fn target_value_zero_rejects_sell_when_market_snapshot_missing() {
let trade_date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date");