完善FIDC策略执行语义
This commit is contained in:
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user