完善FIDC策略执行语义
This commit is contained in:
@@ -82,6 +82,19 @@ pub enum MatchingType {
|
|||||||
Twap,
|
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)]
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
pub struct DynamicSlippageConfig {
|
pub struct DynamicSlippageConfig {
|
||||||
pub impact_coefficient: f64,
|
pub impact_coefficient: f64,
|
||||||
@@ -171,6 +184,7 @@ pub struct BrokerSimulator<C, R> {
|
|||||||
inactive_limit: bool,
|
inactive_limit: bool,
|
||||||
liquidity_limit: bool,
|
liquidity_limit: bool,
|
||||||
strict_value_budget: bool,
|
strict_value_budget: bool,
|
||||||
|
rebalance_cash_mode: RebalanceCashMode,
|
||||||
aiquant_execution_rules: bool,
|
aiquant_execution_rules: bool,
|
||||||
same_day_buy_close_mark_at_fill: bool,
|
same_day_buy_close_mark_at_fill: bool,
|
||||||
risk_config: FidcRiskControlConfig,
|
risk_config: FidcRiskControlConfig,
|
||||||
@@ -198,6 +212,7 @@ impl<C, R> BrokerSimulator<C, R> {
|
|||||||
inactive_limit: true,
|
inactive_limit: true,
|
||||||
liquidity_limit: true,
|
liquidity_limit: true,
|
||||||
strict_value_budget: false,
|
strict_value_budget: false,
|
||||||
|
rebalance_cash_mode: RebalanceCashMode::default(),
|
||||||
aiquant_execution_rules: false,
|
aiquant_execution_rules: false,
|
||||||
same_day_buy_close_mark_at_fill: false,
|
same_day_buy_close_mark_at_fill: false,
|
||||||
risk_config: FidcRiskControlConfig::default(),
|
risk_config: FidcRiskControlConfig::default(),
|
||||||
@@ -229,6 +244,7 @@ impl<C, R> BrokerSimulator<C, R> {
|
|||||||
inactive_limit: true,
|
inactive_limit: true,
|
||||||
liquidity_limit: true,
|
liquidity_limit: true,
|
||||||
strict_value_budget: false,
|
strict_value_budget: false,
|
||||||
|
rebalance_cash_mode: RebalanceCashMode::default(),
|
||||||
aiquant_execution_rules: false,
|
aiquant_execution_rules: false,
|
||||||
same_day_buy_close_mark_at_fill: false,
|
same_day_buy_close_mark_at_fill: false,
|
||||||
risk_config: FidcRiskControlConfig::default(),
|
risk_config: FidcRiskControlConfig::default(),
|
||||||
@@ -263,6 +279,11 @@ impl<C, R> BrokerSimulator<C, R> {
|
|||||||
self
|
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 {
|
pub fn with_aiquant_execution_rules(mut self, enabled: bool) -> Self {
|
||||||
self.aiquant_execution_rules = enabled;
|
self.aiquant_execution_rules = enabled;
|
||||||
self
|
self
|
||||||
@@ -310,6 +331,18 @@ impl<C, R> BrokerSimulator<C, R> {
|
|||||||
self.matching_type
|
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 {
|
pub fn execution_price_field(&self) -> PriceField {
|
||||||
self.execution_price_field
|
self.execution_price_field
|
||||||
}
|
}
|
||||||
@@ -1937,6 +1970,7 @@ where
|
|||||||
symbols.extend(desired_targets.keys().cloned());
|
symbols.extend(desired_targets.keys().cloned());
|
||||||
|
|
||||||
let mut constraints = Vec::new();
|
let mut constraints = Vec::new();
|
||||||
|
let cash_mode = self.effective_rebalance_cash_mode();
|
||||||
let mut projected_cash = portfolio.cash();
|
let mut projected_cash = portfolio.cash();
|
||||||
for symbol in symbols {
|
for symbol in symbols {
|
||||||
let current_qty = portfolio
|
let current_qty = portfolio
|
||||||
@@ -2013,7 +2047,7 @@ where
|
|||||||
symbol, desired_qty, min_target_qty, max_target_qty, provisional_target_qty
|
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(
|
projected_cash += self.estimated_sell_net_cash(
|
||||||
date,
|
date,
|
||||||
price,
|
price,
|
||||||
@@ -5930,7 +5964,9 @@ fn sell_reason(decision: &StrategyDecision, symbol: &str) -> &'static str {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
use super::{BrokerExecutionReport, BrokerSimulator, MatchingType, SlippageModel};
|
use super::{
|
||||||
|
BrokerExecutionReport, BrokerSimulator, MatchingType, RebalanceCashMode, SlippageModel,
|
||||||
|
};
|
||||||
use crate::cost::ChinaAShareCostModel;
|
use crate::cost::ChinaAShareCostModel;
|
||||||
use crate::data::{
|
use crate::data::{
|
||||||
BenchmarkSnapshot, CandidateEligibility, DailyMarketSnapshot, DataSet,
|
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]
|
#[test]
|
||||||
fn target_value_zero_rejects_sell_when_market_snapshot_missing() {
|
fn target_value_zero_rejects_sell_when_market_snapshot_missing() {
|
||||||
let trade_date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date");
|
let trade_date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date");
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ pub mod strategy_ai;
|
|||||||
pub mod universe;
|
pub mod universe;
|
||||||
|
|
||||||
pub use broker::{
|
pub use broker::{
|
||||||
BrokerExecutionReport, BrokerSimulator, DynamicSlippageConfig, MatchingType, SlippageModel,
|
BrokerExecutionReport, BrokerSimulator, DynamicSlippageConfig, MatchingType, RebalanceCashMode,
|
||||||
|
SlippageModel,
|
||||||
};
|
};
|
||||||
pub use calendar::TradingCalendar;
|
pub use calendar::TradingCalendar;
|
||||||
pub use cost::{ChinaAShareCostModel, CostModel, TradingCost};
|
pub use cost::{ChinaAShareCostModel, CostModel, TradingCost};
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ use std::collections::{BTreeMap, BTreeSet, HashMap};
|
|||||||
use chrono::{Datelike, Duration, NaiveDate, NaiveDateTime, NaiveTime};
|
use chrono::{Datelike, Duration, NaiveDate, NaiveDateTime, NaiveTime};
|
||||||
use rhai::{AST, Dynamic, Engine, Map, Scope};
|
use rhai::{AST, Dynamic, Engine, Map, Scope};
|
||||||
|
|
||||||
use crate::broker::{MatchingType, SlippageModel};
|
use crate::broker::{MatchingType, RebalanceCashMode, SlippageModel};
|
||||||
use crate::cost::ChinaAShareCostModel;
|
use crate::cost::ChinaAShareCostModel;
|
||||||
use crate::data::{
|
use crate::data::{
|
||||||
DailyMarketSnapshot, EligibleUniverseSnapshot, PriceField, decision_free_float_cap_bn,
|
DailyMarketSnapshot, EligibleUniverseSnapshot, PriceField, decision_free_float_cap_bn,
|
||||||
@@ -240,6 +240,7 @@ pub struct PlatformExprStrategyConfig {
|
|||||||
pub stamp_tax_rate_after_change: Option<f64>,
|
pub stamp_tax_rate_after_change: Option<f64>,
|
||||||
pub stamp_tax_change_date: Option<NaiveDate>,
|
pub stamp_tax_change_date: Option<NaiveDate>,
|
||||||
pub strict_value_budget: bool,
|
pub strict_value_budget: bool,
|
||||||
|
pub rebalance_cash_mode: RebalanceCashMode,
|
||||||
pub risk_config: FidcRiskControlConfig,
|
pub risk_config: FidcRiskControlConfig,
|
||||||
pub slippage_model: SlippageModel,
|
pub slippage_model: SlippageModel,
|
||||||
pub matching_type: MatchingType,
|
pub matching_type: MatchingType,
|
||||||
@@ -308,6 +309,7 @@ fn band_low(index_close) {
|
|||||||
stamp_tax_rate_after_change: None,
|
stamp_tax_rate_after_change: None,
|
||||||
stamp_tax_change_date: None,
|
stamp_tax_change_date: None,
|
||||||
strict_value_budget: false,
|
strict_value_budget: false,
|
||||||
|
rebalance_cash_mode: RebalanceCashMode::default(),
|
||||||
risk_config: FidcRiskControlConfig::default(),
|
risk_config: FidcRiskControlConfig::default(),
|
||||||
slippage_model: SlippageModel::None,
|
slippage_model: SlippageModel::None,
|
||||||
matching_type: MatchingType::MinuteLast,
|
matching_type: MatchingType::MinuteLast,
|
||||||
@@ -623,6 +625,14 @@ impl PlatformExprStrategy {
|
|||||||
value
|
value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn effective_rebalance_cash_mode(&self) -> RebalanceCashMode {
|
||||||
|
if self.config.matching_type == MatchingType::MinuteLast {
|
||||||
|
RebalanceCashMode::SellThenBuy
|
||||||
|
} else {
|
||||||
|
self.config.rebalance_cash_mode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn new(config: PlatformExprStrategyConfig) -> Self {
|
pub fn new(config: PlatformExprStrategyConfig) -> Self {
|
||||||
let mut engine = Engine::new();
|
let mut engine = Engine::new();
|
||||||
engine.register_fn("round", |value: f64| value.round());
|
engine.register_fn("round", |value: f64| value.round());
|
||||||
@@ -7854,6 +7864,8 @@ impl Strategy for PlatformExprStrategy {
|
|||||||
.config
|
.config
|
||||||
.delayed_limit_open_exit_time
|
.delayed_limit_open_exit_time
|
||||||
.unwrap_or_else(|| self.intraday_execution_start_time());
|
.unwrap_or_else(|| self.intraday_execution_start_time());
|
||||||
|
let highlimit_mark_time = self.intraday_execution_start_time();
|
||||||
|
let mut new_highlimit_marks_after_delayed_exit = BTreeSet::<String>::new();
|
||||||
if !self.config.delayed_limit_open_exit_enabled {
|
if !self.config.delayed_limit_open_exit_enabled {
|
||||||
self.pending_highlimit_holdings.clear();
|
self.pending_highlimit_holdings.clear();
|
||||||
} else {
|
} else {
|
||||||
@@ -7861,17 +7873,32 @@ impl Strategy for PlatformExprStrategy {
|
|||||||
if position.quantity == 0 || delayed_sold_symbols.contains(&position.symbol) {
|
if position.quantity == 0 || delayed_sold_symbols.contains(&position.symbol) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
match self.stock_is_at_upper_limit_at_time(
|
let was_pending = self.pending_highlimit_holdings.contains(&position.symbol);
|
||||||
|
let delayed_limit_status = self.stock_is_at_upper_limit_at_time(
|
||||||
ctx,
|
ctx,
|
||||||
projection_date,
|
projection_date,
|
||||||
&position.symbol,
|
&position.symbol,
|
||||||
delayed_limit_exit_time,
|
delayed_limit_exit_time,
|
||||||
)? {
|
)?;
|
||||||
Some(true) => {
|
let mark_limit_status = if highlimit_mark_time == delayed_limit_exit_time {
|
||||||
|
delayed_limit_status
|
||||||
|
} else {
|
||||||
|
self.stock_is_at_upper_limit_at_time(
|
||||||
|
ctx,
|
||||||
|
projection_date,
|
||||||
|
&position.symbol,
|
||||||
|
highlimit_mark_time,
|
||||||
|
)?
|
||||||
|
};
|
||||||
|
if delayed_limit_status == Some(true) || mark_limit_status == Some(true) {
|
||||||
self.pending_highlimit_holdings
|
self.pending_highlimit_holdings
|
||||||
.insert(position.symbol.clone());
|
.insert(position.symbol.clone());
|
||||||
|
if !was_pending
|
||||||
|
&& delayed_limit_status != Some(true)
|
||||||
|
&& mark_limit_status == Some(true)
|
||||||
|
{
|
||||||
|
new_highlimit_marks_after_delayed_exit.insert(position.symbol.clone());
|
||||||
}
|
}
|
||||||
Some(false) | None => {}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -7884,6 +7911,9 @@ impl Strategy for PlatformExprStrategy {
|
|||||||
Vec::new()
|
Vec::new()
|
||||||
};
|
};
|
||||||
for symbol in pending_symbols {
|
for symbol in pending_symbols {
|
||||||
|
if new_highlimit_marks_after_delayed_exit.contains(&symbol) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if !ctx.portfolio.positions().contains_key(&symbol) {
|
if !ctx.portfolio.positions().contains_key(&symbol) {
|
||||||
self.pending_highlimit_holdings.remove(&symbol);
|
self.pending_highlimit_holdings.remove(&symbol);
|
||||||
continue;
|
continue;
|
||||||
@@ -8251,12 +8281,14 @@ impl Strategy for PlatformExprStrategy {
|
|||||||
if position.quantity == 0 || delayed_sold_symbols.contains(&position.symbol) {
|
if position.quantity == 0 || delayed_sold_symbols.contains(&position.symbol) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if self.regular_sell_should_wait_due_to_highlimit(
|
if !new_highlimit_marks_after_delayed_exit.contains(&position.symbol)
|
||||||
|
&& self.regular_sell_should_wait_due_to_highlimit(
|
||||||
ctx,
|
ctx,
|
||||||
projection_date,
|
projection_date,
|
||||||
&position.symbol,
|
&position.symbol,
|
||||||
self.intraday_execution_start_time(),
|
self.intraday_execution_start_time(),
|
||||||
)? {
|
)?
|
||||||
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if pending_full_close_symbols.contains(&position.symbol) {
|
if pending_full_close_symbols.contains(&position.symbol) {
|
||||||
@@ -8299,6 +8331,13 @@ impl Strategy for PlatformExprStrategy {
|
|||||||
.map(|projected_position| projected_position.quantity)
|
.map(|projected_position| projected_position.quantity)
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
let quantity_delta = after_qty as i32 - before_qty as i32;
|
let quantity_delta = after_qty as i32 - before_qty as i32;
|
||||||
|
if quantity_delta > 0
|
||||||
|
&& self.config.daily_top_up_enabled
|
||||||
|
&& (unsellable_stop_take_attempted_symbols.contains(&position.symbol)
|
||||||
|
|| unresolved_stop_loss_symbols.contains(&position.symbol))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if self
|
if self
|
||||||
.config
|
.config
|
||||||
.weak_market_shrink_overweight_threshold
|
.weak_market_shrink_overweight_threshold
|
||||||
@@ -8559,6 +8598,7 @@ impl Strategy for PlatformExprStrategy {
|
|||||||
.keys()
|
.keys()
|
||||||
.cloned()
|
.cloned()
|
||||||
.collect::<BTreeSet<_>>();
|
.collect::<BTreeSet<_>>();
|
||||||
|
let pre_rebalance_cash = projected.cash();
|
||||||
for symbol in pre_rebalance_symbols.iter() {
|
for symbol in pre_rebalance_symbols.iter() {
|
||||||
if stock_list.iter().any(|candidate| candidate == symbol) {
|
if stock_list.iter().any(|candidate| candidate == symbol) {
|
||||||
continue;
|
continue;
|
||||||
@@ -8598,7 +8638,12 @@ impl Strategy for PlatformExprStrategy {
|
|||||||
slot_working_symbols.remove(symbol);
|
slot_working_symbols.remove(symbol);
|
||||||
}
|
}
|
||||||
|
|
||||||
aiquant_available_cash = projected.cash();
|
aiquant_available_cash = match self.effective_rebalance_cash_mode() {
|
||||||
|
RebalanceCashMode::PreOpenCash => pre_rebalance_cash,
|
||||||
|
RebalanceCashMode::SamePointNet | RebalanceCashMode::SellThenBuy => {
|
||||||
|
projected.cash()
|
||||||
|
}
|
||||||
|
};
|
||||||
let fixed_buy_cash = if self.config.aiquant_transaction_cost {
|
let fixed_buy_cash = if self.config.aiquant_transaction_cost {
|
||||||
aiquant_available_cash * trading_ratio / selection_limit as f64
|
aiquant_available_cash * trading_ratio / selection_limit as f64
|
||||||
} else {
|
} else {
|
||||||
@@ -9031,9 +9076,9 @@ mod tests {
|
|||||||
AlgoOrderStyle, BenchmarkSnapshot, CandidateEligibility, CorporateAction,
|
AlgoOrderStyle, BenchmarkSnapshot, CandidateEligibility, CorporateAction,
|
||||||
DailyFactorSnapshot, DailyMarketSnapshot, DataSet, FactorTextValue, FuturesCommissionType,
|
DailyFactorSnapshot, DailyMarketSnapshot, DataSet, FactorTextValue, FuturesCommissionType,
|
||||||
FuturesTradingParameter, Instrument, IntradayExecutionQuote, MatchingType, OpenOrderView,
|
FuturesTradingParameter, Instrument, IntradayExecutionQuote, MatchingType, OpenOrderView,
|
||||||
OrderIntent, OrderSide, PortfolioState, ProcessEvent, ProcessEventKind, ScheduleStage,
|
OrderIntent, OrderSide, PortfolioState, ProcessEvent, ProcessEventKind, RebalanceCashMode,
|
||||||
ScheduleTimeRule, SlippageModel, Strategy, StrategyContext, TargetPortfolioOrderPricing,
|
ScheduleStage, ScheduleTimeRule, SlippageModel, Strategy, StrategyContext,
|
||||||
TradingCalendar, default_stage_time,
|
TargetPortfolioOrderPricing, TradingCalendar, default_stage_time,
|
||||||
};
|
};
|
||||||
|
|
||||||
fn d(year: i32, month: u32, day: u32) -> NaiveDate {
|
fn d(year: i32, month: u32, day: u32) -> NaiveDate {
|
||||||
@@ -21750,6 +21795,67 @@ mod tests {
|
|||||||
decision.order_intents
|
decision.order_intents
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let mut pre_open_cash_portfolio = PortfolioState::new(0.0);
|
||||||
|
pre_open_cash_portfolio
|
||||||
|
.position_mut("000003.SZ")
|
||||||
|
.buy(prev_date, 1_000, 10.0);
|
||||||
|
let pre_open_cash_ctx = StrategyContext {
|
||||||
|
execution_date: date,
|
||||||
|
decision_date: date,
|
||||||
|
decision_index: 20,
|
||||||
|
data: &data,
|
||||||
|
portfolio: &pre_open_cash_portfolio,
|
||||||
|
futures_account: None,
|
||||||
|
open_orders: &[],
|
||||||
|
dynamic_universe: None,
|
||||||
|
subscriptions: &subscriptions,
|
||||||
|
process_events: &[],
|
||||||
|
active_process_event: None,
|
||||||
|
active_datetime: None,
|
||||||
|
order_events: &[],
|
||||||
|
fills: &[],
|
||||||
|
};
|
||||||
|
let mut pre_open_cash_cfg = base_cfg.clone();
|
||||||
|
pre_open_cash_cfg.matching_type = MatchingType::CurrentBarClose;
|
||||||
|
pre_open_cash_cfg.rebalance_cash_mode = RebalanceCashMode::PreOpenCash;
|
||||||
|
let mut pre_open_cash_strategy = PlatformExprStrategy::new(pre_open_cash_cfg);
|
||||||
|
pre_open_cash_strategy.rebalance_day_counter = 20;
|
||||||
|
|
||||||
|
let pre_open_cash_decision = pre_open_cash_strategy
|
||||||
|
.on_day(&pre_open_cash_ctx)
|
||||||
|
.expect("pre_open_cash platform decision");
|
||||||
|
let pre_open_cash_periodic_buys = pre_open_cash_decision
|
||||||
|
.order_intents
|
||||||
|
.iter()
|
||||||
|
.filter(|intent| {
|
||||||
|
matches!(
|
||||||
|
intent,
|
||||||
|
OrderIntent::Value { reason, .. } if reason == "periodic_rebalance_buy"
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.count();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
pre_open_cash_periodic_buys, 0,
|
||||||
|
"{:?}",
|
||||||
|
pre_open_cash_decision.order_intents
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
pre_open_cash_decision
|
||||||
|
.order_intents
|
||||||
|
.iter()
|
||||||
|
.any(|intent| matches!(
|
||||||
|
intent,
|
||||||
|
OrderIntent::TargetValue {
|
||||||
|
symbol,
|
||||||
|
target_value,
|
||||||
|
..
|
||||||
|
} if symbol == "000003.SZ" && *target_value == 0.0
|
||||||
|
)),
|
||||||
|
"{:?}",
|
||||||
|
pre_open_cash_decision.order_intents
|
||||||
|
);
|
||||||
|
|
||||||
let mut aiquant_portfolio = PortfolioState::new(4_000.0);
|
let mut aiquant_portfolio = PortfolioState::new(4_000.0);
|
||||||
aiquant_portfolio
|
aiquant_portfolio
|
||||||
.position_mut("000003.SZ")
|
.position_mut("000003.SZ")
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ use crate::{
|
|||||||
DynamicSlippageConfig, MatchingType, PlatformAccountActionKind, PlatformExplicitActionStage,
|
DynamicSlippageConfig, MatchingType, PlatformAccountActionKind, PlatformExplicitActionStage,
|
||||||
PlatformExplicitCancelKind, PlatformExplicitOrderKind, PlatformExprStrategyConfig,
|
PlatformExplicitCancelKind, PlatformExplicitOrderKind, PlatformExprStrategyConfig,
|
||||||
PlatformRebalanceSchedule, PlatformScheduleFrequency, PlatformTradeAction,
|
PlatformRebalanceSchedule, PlatformScheduleFrequency, PlatformTradeAction,
|
||||||
PlatformUniverseActionKind, ScheduleTimeRule, SlippageModel,
|
PlatformUniverseActionKind, RebalanceCashMode, ScheduleTimeRule, SlippageModel,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
||||||
@@ -105,6 +105,8 @@ pub struct StrategyExecutionSpec {
|
|||||||
pub risk_policy: Option<StrategyRiskPolicySpec>,
|
pub risk_policy: Option<StrategyRiskPolicySpec>,
|
||||||
#[serde(default, alias = "strict_value_budget")]
|
#[serde(default, alias = "strict_value_budget")]
|
||||||
pub strict_value_budget: Option<bool>,
|
pub strict_value_budget: Option<bool>,
|
||||||
|
#[serde(default, alias = "rebalance_cash_mode")]
|
||||||
|
pub rebalance_cash_mode: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
||||||
@@ -175,6 +177,8 @@ pub struct StrategyEngineConfig {
|
|||||||
pub risk_policy: Option<StrategyRiskPolicySpec>,
|
pub risk_policy: Option<StrategyRiskPolicySpec>,
|
||||||
#[serde(default, alias = "strict_value_budget")]
|
#[serde(default, alias = "strict_value_budget")]
|
||||||
pub strict_value_budget: Option<bool>,
|
pub strict_value_budget: Option<bool>,
|
||||||
|
#[serde(default, alias = "rebalance_cash_mode")]
|
||||||
|
pub rebalance_cash_mode: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub dividend_reinvestment: Option<bool>,
|
pub dividend_reinvestment: Option<bool>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -1007,6 +1011,26 @@ fn parse_matching_type(value: Option<&str>) -> Result<Option<MatchingType>, Stri
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn parse_rebalance_cash_mode(value: Option<&str>) -> Result<Option<RebalanceCashMode>, String> {
|
||||||
|
let Some(raw) = value.map(str::trim).filter(|item| !item.is_empty()) else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
match normalize_model_name(raw).as_str() {
|
||||||
|
"same_point_net" | "same_bar_net" | "same_execution_net" | "net_rebalance" | "net" => {
|
||||||
|
Ok(Some(RebalanceCashMode::SamePointNet))
|
||||||
|
}
|
||||||
|
"sell_then_buy" | "sell_first" | "sell_first_buy_later" | "live_like" => {
|
||||||
|
Ok(Some(RebalanceCashMode::SellThenBuy))
|
||||||
|
}
|
||||||
|
"pre_open_cash" | "existing_cash" | "cash_before_open" | "conservative" => {
|
||||||
|
Ok(Some(RebalanceCashMode::PreOpenCash))
|
||||||
|
}
|
||||||
|
_ => Err(format!(
|
||||||
|
"rebalanceCashMode only supports same_point_net, sell_then_buy, pre_open_cash: {raw}"
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn parse_slippage_model(
|
fn parse_slippage_model(
|
||||||
model: Option<&str>,
|
model: Option<&str>,
|
||||||
value: Option<f64>,
|
value: Option<f64>,
|
||||||
@@ -1048,6 +1072,7 @@ fn parse_slippage_model(
|
|||||||
fn apply_execution_behavior_overrides(
|
fn apply_execution_behavior_overrides(
|
||||||
cfg: &mut PlatformExprStrategyConfig,
|
cfg: &mut PlatformExprStrategyConfig,
|
||||||
matching_type: Option<&str>,
|
matching_type: Option<&str>,
|
||||||
|
rebalance_cash_mode: Option<&str>,
|
||||||
slippage_model: Option<&str>,
|
slippage_model: Option<&str>,
|
||||||
slippage_value: Option<f64>,
|
slippage_value: Option<f64>,
|
||||||
slippage_impact_coefficient: Option<f64>,
|
slippage_impact_coefficient: Option<f64>,
|
||||||
@@ -1058,6 +1083,12 @@ fn apply_execution_behavior_overrides(
|
|||||||
if let Some(matching_type) = parse_matching_type(matching_type)? {
|
if let Some(matching_type) = parse_matching_type(matching_type)? {
|
||||||
cfg.matching_type = matching_type;
|
cfg.matching_type = matching_type;
|
||||||
}
|
}
|
||||||
|
if let Some(rebalance_cash_mode) = parse_rebalance_cash_mode(rebalance_cash_mode)? {
|
||||||
|
cfg.rebalance_cash_mode = rebalance_cash_mode;
|
||||||
|
}
|
||||||
|
if cfg.matching_type == MatchingType::MinuteLast {
|
||||||
|
cfg.rebalance_cash_mode = RebalanceCashMode::SellThenBuy;
|
||||||
|
}
|
||||||
if slippage_model.is_some()
|
if slippage_model.is_some()
|
||||||
|| slippage_value.is_some()
|
|| slippage_value.is_some()
|
||||||
|| slippage_impact_coefficient.is_some()
|
|| slippage_impact_coefficient.is_some()
|
||||||
@@ -1342,6 +1373,7 @@ pub fn platform_expr_config_from_spec(
|
|||||||
apply_execution_behavior_overrides(
|
apply_execution_behavior_overrides(
|
||||||
&mut cfg,
|
&mut cfg,
|
||||||
engine.matching_type.as_deref(),
|
engine.matching_type.as_deref(),
|
||||||
|
engine.rebalance_cash_mode.as_deref(),
|
||||||
engine.slippage_model.as_deref(),
|
engine.slippage_model.as_deref(),
|
||||||
engine.slippage_value,
|
engine.slippage_value,
|
||||||
engine.slippage_impact_coefficient,
|
engine.slippage_impact_coefficient,
|
||||||
@@ -1754,6 +1786,7 @@ pub fn platform_expr_config_from_spec(
|
|||||||
apply_execution_behavior_overrides(
|
apply_execution_behavior_overrides(
|
||||||
&mut cfg,
|
&mut cfg,
|
||||||
execution.matching_type.as_deref(),
|
execution.matching_type.as_deref(),
|
||||||
|
execution.rebalance_cash_mode.as_deref(),
|
||||||
execution.slippage_model.as_deref(),
|
execution.slippage_model.as_deref(),
|
||||||
execution.slippage_value,
|
execution.slippage_value,
|
||||||
execution.slippage_impact_coefficient,
|
execution.slippage_impact_coefficient,
|
||||||
@@ -2631,6 +2664,34 @@ mod tests {
|
|||||||
assert!(cfg.strict_value_budget);
|
assert!(cfg.strict_value_budget);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_rebalance_cash_mode_and_forces_minute_to_actual_sequence() {
|
||||||
|
let spec = serde_json::json!({
|
||||||
|
"execution": {
|
||||||
|
"matchingType": "next_bar_open",
|
||||||
|
"rebalanceCashMode": "pre_open_cash"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let cfg = platform_expr_config_from_value("", "", &spec).expect("config");
|
||||||
|
|
||||||
|
assert_eq!(cfg.matching_type, MatchingType::NextBarOpen);
|
||||||
|
assert_eq!(cfg.rebalance_cash_mode, RebalanceCashMode::PreOpenCash);
|
||||||
|
|
||||||
|
let minute_spec = serde_json::json!({
|
||||||
|
"execution": {
|
||||||
|
"matchingType": "minute_last",
|
||||||
|
"rebalanceCashMode": "pre_open_cash"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let minute_cfg = platform_expr_config_from_value("", "", &minute_spec).expect("config");
|
||||||
|
|
||||||
|
assert_eq!(minute_cfg.matching_type, MatchingType::MinuteLast);
|
||||||
|
assert_eq!(
|
||||||
|
minute_cfg.rebalance_cash_mode,
|
||||||
|
RebalanceCashMode::SellThenBuy
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn platform_spec_accepts_only_supported_matching_types() {
|
fn platform_spec_accepts_only_supported_matching_types() {
|
||||||
for (raw, expected) in [
|
for (raw, expected) in [
|
||||||
|
|||||||
Reference in New Issue
Block a user