完善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, 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");
+2 -1
View File
@@ -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};
+117 -11
View File
@@ -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")
+62 -1
View File
@@ -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 [