diff --git a/crates/fidc-core/src/broker.rs b/crates/fidc-core/src/broker.rs index 9348d50..89509df 100644 --- a/crates/fidc-core/src/broker.rs +++ b/crates/fidc-core/src/broker.rs @@ -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 { 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 BrokerSimulator { 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 BrokerSimulator { 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 BrokerSimulator { 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 BrokerSimulator { 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::>(); + let snapshots = symbols + .iter() + .map(|symbol| { + let mut snapshot = limit_test_snapshot(); + snapshot.symbol = (*symbol).to_string(); + snapshot + }) + .collect::>(); + let candidates = symbols + .iter() + .map(|symbol| { + let mut candidate = limit_test_candidate(true, true); + candidate.symbol = (*symbol).to_string(); + candidate + }) + .collect::>(); + 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"); diff --git a/crates/fidc-core/src/lib.rs b/crates/fidc-core/src/lib.rs index 4f76163..9b55cc0 100644 --- a/crates/fidc-core/src/lib.rs +++ b/crates/fidc-core/src/lib.rs @@ -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}; diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index 08fe495..43c46e1 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -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, pub stamp_tax_change_date: Option, 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::::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::>(); + 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") diff --git a/crates/fidc-core/src/platform_strategy_spec.rs b/crates/fidc-core/src/platform_strategy_spec.rs index ed240b6..db30201 100644 --- a/crates/fidc-core/src/platform_strategy_spec.rs +++ b/crates/fidc-core/src/platform_strategy_spec.rs @@ -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, #[serde(default, alias = "strict_value_budget")] pub strict_value_budget: Option, + #[serde(default, alias = "rebalance_cash_mode")] + pub rebalance_cash_mode: Option, } #[derive(Debug, Clone, Default, Deserialize, Serialize)] @@ -175,6 +177,8 @@ pub struct StrategyEngineConfig { pub risk_policy: Option, #[serde(default, alias = "strict_value_budget")] pub strict_value_budget: Option, + #[serde(default, alias = "rebalance_cash_mode")] + pub rebalance_cash_mode: Option, #[serde(default)] pub dividend_reinvestment: Option, #[serde(default)] @@ -1007,6 +1011,26 @@ fn parse_matching_type(value: Option<&str>) -> Result, Stri } } +fn parse_rebalance_cash_mode(value: Option<&str>) -> Result, 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, @@ -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, slippage_impact_coefficient: Option, @@ -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 [