完善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");
|
||||
|
||||
@@ -20,7 +20,8 @@ pub mod strategy_ai;
|
||||
pub mod universe;
|
||||
|
||||
pub use broker::{
|
||||
BrokerExecutionReport, BrokerSimulator, DynamicSlippageConfig, MatchingType, SlippageModel,
|
||||
BrokerExecutionReport, BrokerSimulator, DynamicSlippageConfig, MatchingType, RebalanceCashMode,
|
||||
SlippageModel,
|
||||
};
|
||||
pub use calendar::TradingCalendar;
|
||||
pub use cost::{ChinaAShareCostModel, CostModel, TradingCost};
|
||||
|
||||
@@ -4,7 +4,7 @@ use std::collections::{BTreeMap, BTreeSet, HashMap};
|
||||
use chrono::{Datelike, Duration, NaiveDate, NaiveDateTime, NaiveTime};
|
||||
use rhai::{AST, Dynamic, Engine, Map, Scope};
|
||||
|
||||
use crate::broker::{MatchingType, SlippageModel};
|
||||
use crate::broker::{MatchingType, RebalanceCashMode, SlippageModel};
|
||||
use crate::cost::ChinaAShareCostModel;
|
||||
use crate::data::{
|
||||
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_change_date: Option<NaiveDate>,
|
||||
pub strict_value_budget: bool,
|
||||
pub rebalance_cash_mode: RebalanceCashMode,
|
||||
pub risk_config: FidcRiskControlConfig,
|
||||
pub slippage_model: SlippageModel,
|
||||
pub matching_type: MatchingType,
|
||||
@@ -308,6 +309,7 @@ fn band_low(index_close) {
|
||||
stamp_tax_rate_after_change: None,
|
||||
stamp_tax_change_date: None,
|
||||
strict_value_budget: false,
|
||||
rebalance_cash_mode: RebalanceCashMode::default(),
|
||||
risk_config: FidcRiskControlConfig::default(),
|
||||
slippage_model: SlippageModel::None,
|
||||
matching_type: MatchingType::MinuteLast,
|
||||
@@ -623,6 +625,14 @@ impl PlatformExprStrategy {
|
||||
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 {
|
||||
let mut engine = Engine::new();
|
||||
engine.register_fn("round", |value: f64| value.round());
|
||||
@@ -7854,6 +7864,8 @@ impl Strategy for PlatformExprStrategy {
|
||||
.config
|
||||
.delayed_limit_open_exit_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 {
|
||||
self.pending_highlimit_holdings.clear();
|
||||
} else {
|
||||
@@ -7861,17 +7873,32 @@ impl Strategy for PlatformExprStrategy {
|
||||
if position.quantity == 0 || delayed_sold_symbols.contains(&position.symbol) {
|
||||
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,
|
||||
projection_date,
|
||||
&position.symbol,
|
||||
delayed_limit_exit_time,
|
||||
)? {
|
||||
Some(true) => {
|
||||
self.pending_highlimit_holdings
|
||||
.insert(position.symbol.clone());
|
||||
)?;
|
||||
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
|
||||
.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()
|
||||
};
|
||||
for symbol in pending_symbols {
|
||||
if new_highlimit_marks_after_delayed_exit.contains(&symbol) {
|
||||
continue;
|
||||
}
|
||||
if !ctx.portfolio.positions().contains_key(&symbol) {
|
||||
self.pending_highlimit_holdings.remove(&symbol);
|
||||
continue;
|
||||
@@ -8251,12 +8281,14 @@ impl Strategy for PlatformExprStrategy {
|
||||
if position.quantity == 0 || delayed_sold_symbols.contains(&position.symbol) {
|
||||
continue;
|
||||
}
|
||||
if self.regular_sell_should_wait_due_to_highlimit(
|
||||
ctx,
|
||||
projection_date,
|
||||
&position.symbol,
|
||||
self.intraday_execution_start_time(),
|
||||
)? {
|
||||
if !new_highlimit_marks_after_delayed_exit.contains(&position.symbol)
|
||||
&& self.regular_sell_should_wait_due_to_highlimit(
|
||||
ctx,
|
||||
projection_date,
|
||||
&position.symbol,
|
||||
self.intraday_execution_start_time(),
|
||||
)?
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if pending_full_close_symbols.contains(&position.symbol) {
|
||||
@@ -8299,6 +8331,13 @@ impl Strategy for PlatformExprStrategy {
|
||||
.map(|projected_position| projected_position.quantity)
|
||||
.unwrap_or(0);
|
||||
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
|
||||
.config
|
||||
.weak_market_shrink_overweight_threshold
|
||||
@@ -8559,6 +8598,7 @@ impl Strategy for PlatformExprStrategy {
|
||||
.keys()
|
||||
.cloned()
|
||||
.collect::<BTreeSet<_>>();
|
||||
let pre_rebalance_cash = projected.cash();
|
||||
for symbol in pre_rebalance_symbols.iter() {
|
||||
if stock_list.iter().any(|candidate| candidate == symbol) {
|
||||
continue;
|
||||
@@ -8598,7 +8638,12 @@ impl Strategy for PlatformExprStrategy {
|
||||
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 {
|
||||
aiquant_available_cash * trading_ratio / selection_limit as f64
|
||||
} else {
|
||||
@@ -9031,9 +9076,9 @@ mod tests {
|
||||
AlgoOrderStyle, BenchmarkSnapshot, CandidateEligibility, CorporateAction,
|
||||
DailyFactorSnapshot, DailyMarketSnapshot, DataSet, FactorTextValue, FuturesCommissionType,
|
||||
FuturesTradingParameter, Instrument, IntradayExecutionQuote, MatchingType, OpenOrderView,
|
||||
OrderIntent, OrderSide, PortfolioState, ProcessEvent, ProcessEventKind, ScheduleStage,
|
||||
ScheduleTimeRule, SlippageModel, Strategy, StrategyContext, TargetPortfolioOrderPricing,
|
||||
TradingCalendar, default_stage_time,
|
||||
OrderIntent, OrderSide, PortfolioState, ProcessEvent, ProcessEventKind, RebalanceCashMode,
|
||||
ScheduleStage, ScheduleTimeRule, SlippageModel, Strategy, StrategyContext,
|
||||
TargetPortfolioOrderPricing, TradingCalendar, default_stage_time,
|
||||
};
|
||||
|
||||
fn d(year: i32, month: u32, day: u32) -> NaiveDate {
|
||||
@@ -21750,6 +21795,67 @@ mod tests {
|
||||
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);
|
||||
aiquant_portfolio
|
||||
.position_mut("000003.SZ")
|
||||
|
||||
@@ -8,7 +8,7 @@ use crate::{
|
||||
DynamicSlippageConfig, MatchingType, PlatformAccountActionKind, PlatformExplicitActionStage,
|
||||
PlatformExplicitCancelKind, PlatformExplicitOrderKind, PlatformExprStrategyConfig,
|
||||
PlatformRebalanceSchedule, PlatformScheduleFrequency, PlatformTradeAction,
|
||||
PlatformUniverseActionKind, ScheduleTimeRule, SlippageModel,
|
||||
PlatformUniverseActionKind, RebalanceCashMode, ScheduleTimeRule, SlippageModel,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
||||
@@ -105,6 +105,8 @@ pub struct StrategyExecutionSpec {
|
||||
pub risk_policy: Option<StrategyRiskPolicySpec>,
|
||||
#[serde(default, alias = "strict_value_budget")]
|
||||
pub strict_value_budget: Option<bool>,
|
||||
#[serde(default, alias = "rebalance_cash_mode")]
|
||||
pub rebalance_cash_mode: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
||||
@@ -175,6 +177,8 @@ pub struct StrategyEngineConfig {
|
||||
pub risk_policy: Option<StrategyRiskPolicySpec>,
|
||||
#[serde(default, alias = "strict_value_budget")]
|
||||
pub strict_value_budget: Option<bool>,
|
||||
#[serde(default, alias = "rebalance_cash_mode")]
|
||||
pub rebalance_cash_mode: Option<String>,
|
||||
#[serde(default)]
|
||||
pub dividend_reinvestment: Option<bool>,
|
||||
#[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(
|
||||
model: Option<&str>,
|
||||
value: Option<f64>,
|
||||
@@ -1048,6 +1072,7 @@ fn parse_slippage_model(
|
||||
fn apply_execution_behavior_overrides(
|
||||
cfg: &mut PlatformExprStrategyConfig,
|
||||
matching_type: Option<&str>,
|
||||
rebalance_cash_mode: Option<&str>,
|
||||
slippage_model: Option<&str>,
|
||||
slippage_value: 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)? {
|
||||
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()
|
||||
|| slippage_value.is_some()
|
||||
|| slippage_impact_coefficient.is_some()
|
||||
@@ -1342,6 +1373,7 @@ pub fn platform_expr_config_from_spec(
|
||||
apply_execution_behavior_overrides(
|
||||
&mut cfg,
|
||||
engine.matching_type.as_deref(),
|
||||
engine.rebalance_cash_mode.as_deref(),
|
||||
engine.slippage_model.as_deref(),
|
||||
engine.slippage_value,
|
||||
engine.slippage_impact_coefficient,
|
||||
@@ -1754,6 +1786,7 @@ pub fn platform_expr_config_from_spec(
|
||||
apply_execution_behavior_overrides(
|
||||
&mut cfg,
|
||||
execution.matching_type.as_deref(),
|
||||
execution.rebalance_cash_mode.as_deref(),
|
||||
execution.slippage_model.as_deref(),
|
||||
execution.slippage_value,
|
||||
execution.slippage_impact_coefficient,
|
||||
@@ -2631,6 +2664,34 @@ mod tests {
|
||||
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]
|
||||
fn platform_spec_accepts_only_supported_matching_types() {
|
||||
for (raw, expected) in [
|
||||
|
||||
Reference in New Issue
Block a user