diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index f63ed67..a46cc1c 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -13401,6 +13401,91 @@ mod tests { NaiveDate::from_ymd_opt(year, month, day).expect("valid date") } + fn single_symbol_platform_data(dates: &[NaiveDate], symbol: &str) -> DataSet { + DataSet::from_components( + vec![Instrument { + symbol: symbol.to_string(), + name: symbol.to_string(), + board: "SZSE".to_string(), + round_lot: 100, + listed_at: Some(d(2010, 1, 1)), + delisted_at: None, + status: "active".to_string(), + }], + dates + .iter() + .copied() + .map(|date| DailyMarketSnapshot { + date, + symbol: symbol.to_string(), + timestamp: None, + day_open: 10.0, + open: 10.0, + high: 10.2, + low: 9.8, + close: 10.0, + last_price: 10.0, + bid1: 9.99, + ask1: 10.01, + prev_close: 10.0, + volume: 1_000_000, + minute_volume: 10_000, + bid1_volume: 10_000, + ask1_volume: 10_000, + trading_phase: Some("continuous".to_string()), + paused: false, + upper_limit: 11.0, + lower_limit: 9.0, + price_tick: 0.01, + }) + .collect(), + dates + .iter() + .copied() + .map(|date| DailyFactorSnapshot { + date, + symbol: symbol.to_string(), + market_cap_bn: 10.0, + free_float_cap_bn: 9.0, + pe_ttm: 8.0, + turnover_ratio: Some(1.0), + effective_turnover_ratio: Some(1.0), + extra_factors: BTreeMap::new(), + }) + .collect(), + dates + .iter() + .copied() + .map(|date| CandidateEligibility { + date, + symbol: symbol.to_string(), + is_st: false, + is_star_st: false, + is_new_listing: false, + is_paused: false, + allow_buy: true, + allow_sell: true, + is_kcb: false, + is_one_yuan: false, + risk_level_code: None, + }) + .collect(), + dates + .iter() + .copied() + .map(|date| BenchmarkSnapshot { + date, + benchmark: "000852.SH".to_string(), + open: 1000.0, + close: 1000.0, + prev_close: 1000.0, + volume: 1_000_000, + }) + .collect(), + ) + .expect("single-symbol platform dataset") + } + #[test] fn stock_state_cache_resets_before_reusing_compact_keys_on_another_date() { let dates = [d(2025, 1, 2), d(2025, 1, 3)]; @@ -23528,11 +23613,18 @@ mod tests { let first = d(2025, 2, 3); let between = d(2025, 2, 4); let second = d(2025, 2, 5); + let symbol = "000001.SZ"; + let data = single_symbol_platform_data(&[first, between, second], symbol); + let portfolio = PortfolioState::new(1_000_000.0); + let subscriptions = BTreeSet::new(); let mut config = PlatformExprStrategyConfig::generic(); + config.signal_symbol = symbol.to_string(); + config.benchmark_symbol = "000852.SH".to_string(); + config.rotation_enabled = false; config.signal_rebalance_dates = BTreeSet::from([first, second]); config.explicit_actions = vec![PlatformTradeAction::Order { kind: PlatformExplicitOrderKind::TargetPercent, - symbol: "000001.SZ".to_string(), + symbol: symbol.to_string(), amount_expr: format!( "if decision_date == \"{first}\" {{ 0.5 }} else if decision_date == \"{second}\" {{ 0.0 }} else {{ 0.0 }}" ), @@ -23543,11 +23635,46 @@ mod tests { when_expr: None, reason: "full_target_snapshot".to_string(), }]; - let strategy = PlatformExprStrategy::new(config); + let mut strategy = PlatformExprStrategy::new(config); assert!(strategy.unscheduled_explicit_actions_are_due(first)); assert!(!strategy.unscheduled_explicit_actions_are_due(between)); assert!(strategy.unscheduled_explicit_actions_are_due(second)); + + let mut decide = |date, decision_index| { + let ctx = StrategyContext { + execution_date: date, + decision_date: date, + decision_index, + 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: &[], + }; + strategy.on_day(&ctx).expect("explicit action decision") + }; + let first_decision = decide(first, 0); + let between_decision = decide(between, 1); + let second_decision = decide(second, 2); + + assert!(matches!( + first_decision.order_intents.as_slice(), + [OrderIntent::TargetPercent { symbol: actual_symbol, target_percent, .. }] + if actual_symbol == symbol && (*target_percent - 0.5).abs() < f64::EPSILON + )); + assert!(between_decision.order_intents.is_empty()); + assert!(matches!( + second_decision.order_intents.as_slice(), + [OrderIntent::TargetPercent { symbol: actual_symbol, target_percent, .. }] + if actual_symbol == symbol && target_percent.abs() < f64::EPSILON + )); } #[test]