diff --git a/crates/fidc-core/src/broker.rs b/crates/fidc-core/src/broker.rs index 87fa4aa..a6085aa 100644 --- a/crates/fidc-core/src/broker.rs +++ b/crates/fidc-core/src/broker.rs @@ -1661,10 +1661,17 @@ where ) -> Result<(BTreeMap, Vec), BacktestError> { let equity = self.rebalance_total_equity_at_with_overrides(date, portfolio, data, valuation_prices)?; - let target_weight_sum = target_weights.values().copied().sum::(); + let target_weight_sum = target_weights + .values() + .copied() + .filter(|weight| weight.abs() > f64::EPSILON) + .sum::(); let mut desired_targets = BTreeMap::new(); let mut diagnostics = Vec::new(); for (symbol, weight) in target_weights { + if weight.abs() <= f64::EPSILON { + continue; + } let price = self.rebalance_valuation_price_with_overrides( date, symbol, @@ -5544,6 +5551,59 @@ mod tests { ); } + #[test] + fn target_portfolio_smart_ignores_zero_weight_symbols_without_market_snapshot() { + let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date"); + let broker = BrokerSimulator::new_with_execution_price( + ChinaAShareCostModel::default(), + ChinaEquityRuleHooks, + PriceField::Open, + ) + .with_volume_limit(false) + .with_liquidity_limit(false); + let data = DataSet::from_components_with_actions_and_quotes( + vec![limit_test_instrument()], + vec![limit_test_snapshot()], + Vec::new(), + vec![limit_test_candidate(true, true)], + vec![limit_test_benchmark()], + Vec::new(), + Vec::new(), + ) + .expect("valid dataset"); + let mut portfolio = PortfolioState::new(1_000_000.0); + let mut target_weights = BTreeMap::new(); + target_weights.insert("000001.SZ".to_string(), 0.50); + target_weights.insert("001239.SZ".to_string(), 0.0); + let mut report = BrokerExecutionReport::default(); + + broker + .process_target_portfolio_smart( + date, + &mut portfolio, + &data, + &target_weights, + None, + None, + "date_conditioned_target_weights", + &mut BTreeMap::new(), + &mut BTreeMap::new(), + &mut None, + &mut BTreeMap::new(), + &mut report, + ) + .expect("zero weight missing snapshot symbol ignored"); + + assert!(portfolio.position("000001.SZ").is_some()); + assert!(portfolio.position("001239.SZ").is_none()); + assert!( + report + .fill_events + .iter() + .all(|event| event.symbol != "001239.SZ") + ); + } + #[test] fn timed_target_value_zero_sells_full_position_at_requested_time_quote() { let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date"); diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index 916c2ca..846e343 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -20192,6 +20192,103 @@ mod tests { } } + #[test] + fn target_portfolio_smart_respects_current_date_weight_conditions() { + let date = d(2023, 1, 3); + let data = DataSet::from_components( + vec![], + vec![DailyMarketSnapshot { + date, + symbol: "000001.SH".to_string(), + timestamp: Some("2023-01-03 10:18:00".to_string()), + day_open: 1000.0, + open: 1000.0, + high: 1002.0, + low: 998.0, + close: 1001.0, + last_price: 1001.0, + bid1: 1000.5, + ask1: 1001.5, + prev_close: 999.0, + volume: 100_000, + minute_volume: 5_000, + bid1_volume: 2_500, + ask1_volume: 2_500, + trading_phase: Some("continuous".to_string()), + paused: false, + upper_limit: 1098.9, + lower_limit: 899.1, + price_tick: 0.01, + }], + vec![], + vec![], + vec![BenchmarkSnapshot { + date, + benchmark: "000852.SH".to_string(), + open: 1000.0, + close: 1001.0, + prev_close: 999.0, + volume: 100_000, + }], + ) + .expect("dataset"); + let portfolio = PortfolioState::new(1_000_000.0); + let subscriptions = BTreeSet::new(); + let ctx = StrategyContext { + execution_date: date, + decision_date: date, + decision_index: 0, + data: &data, + portfolio: &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 cfg = PlatformExprStrategyConfig::microcap_rotation(); + cfg.signal_symbol = "000001.SH".to_string(); + cfg.rotation_enabled = false; + cfg.benchmark_short_ma_days = 1; + cfg.benchmark_long_ma_days = 1; + cfg.explicit_actions = vec![PlatformTradeAction::TargetPortfolioSmart { + target_weights_expr: concat!( + "{", + "\"000521.SZ\": if current_date == \"2023-01-03\" { 0.025 } else { 0.0 },", + "\"001239.SZ\": if current_date == \"2024-08-13\" { 0.025 } else { 0.0 }", + "}" + ) + .to_string(), + order_prices_expr: None, + valuation_prices_expr: None, + when_expr: None, + reason: "date_conditioned_target_weights".to_string(), + }]; + let mut strategy = PlatformExprStrategy::new(cfg); + + let decision = strategy.on_day(&ctx).expect("platform decision"); + + assert_eq!(decision.order_intents.len(), 1); + match &decision.order_intents[0] { + crate::strategy::OrderIntent::TargetPortfolioSmart { target_weights, .. } => { + assert_eq!(target_weights.get("000521.SZ").copied(), Some(0.025)); + assert_eq!(target_weights.get("001239.SZ").copied(), Some(0.0)); + assert_eq!( + target_weights + .iter() + .filter(|(_, weight)| weight.abs() > f64::EPSILON) + .count(), + 1 + ); + } + other => panic!("unexpected explicit target portfolio intent: {other:?}"), + } + } + #[test] fn platform_strategy_emits_target_portfolio_smart_algo_order_style() { let date = d(2025, 2, 3);