diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index 2b389c2..c03da60 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -649,6 +649,7 @@ pub struct PlatformExprStrategyConfig { pub matching_type: MatchingType, pub quote_quantity_limit: bool, pub current_day_precomputed_factors: bool, + pub completed_session_factor_fields: BTreeSet, pub candidate_symbols_by_date: BTreeMap>, pub intraday_execution_time: Option, pub explicit_action_times: Vec, @@ -727,6 +728,7 @@ impl PlatformExprStrategyConfig { matching_type: MatchingType::CurrentBarClose, quote_quantity_limit: true, current_day_precomputed_factors: false, + completed_session_factor_fields: BTreeSet::new(), candidate_symbols_by_date: BTreeMap::new(), intraday_execution_time: None, explicit_action_times: Vec::new(), @@ -1396,6 +1398,26 @@ fn platform_safe_div_default(lhs: f64, rhs: f64) -> f64 { platform_safe_div(lhs, rhs, 0.0) } +fn completed_session_factor_date( + ctx: &StrategyContext<'_>, + date: NaiveDate, + factor_date: NaiveDate, + execution_time: Option, +) -> Option { + let factor_date = factor_date.min(ctx.decision_date); + if factor_date < date || factor_date < ctx.decision_date { + return Some(factor_date); + } + let time = execution_time.or_else(|| ctx.active_datetime.map(|value| value.time())); + // Native CN stock daily indicator rows become usable only after the + // session closes. Absence of an intraday clock denotes a daily close bar. + if time.is_none_or(|time| time >= NaiveTime::from_hms_opt(15, 0, 0).unwrap()) { + Some(factor_date) + } else { + ctx.data.previous_trading_date(factor_date, 1) + } +} + impl PlatformExprStrategy { fn market_cap_storage_to_strategy_unit(value: f64) -> f64 { value @@ -4705,6 +4727,23 @@ impl PlatformExprStrategy { } else { BTreeMap::new() }; + if !self.config.completed_session_factor_fields.is_empty() { + let visible_date = completed_session_factor_date( + ctx, date, factor_date, + execution_time.or(self.config.intraday_execution_time), + ); + let visible_factor = visible_date + .and_then(|visible_date| ctx.data.factor_by_symbol_id(visible_date, symbol_id)); + for field in &self.config.completed_session_factor_fields { + if self.stock_extra_factor_map_required || self.stock_extra_factor_identifiers.contains(field) { + let value = visible_factor + .and_then(|row| row.extra_factors.get(field.as_str())) + .copied() + .unwrap_or(f64::NAN); + extra_factors.insert(field.clone(), value); + } + } + } if self.stock_extra_factors_required && (self.stock_extra_factor_map_required || self @@ -13913,6 +13952,35 @@ mod tests { NaiveDate::from_ymd_opt(year, month, day).expect("valid date") } + #[test] + fn completed_session_factor_dates_exclude_intraday_and_preserve_next_open() { + let prev = d(2025, 1, 2); + let curr = d(2025, 1, 3); + let data = single_symbol_platform_data(&[prev, curr], "000001.SZ"); + let portfolio = PortfolioState::new(10_000.0); + let subscriptions = BTreeSet::new(); + let mut ctx = StrategyContext { + execution_date: curr, decision_date: curr, decision_index: 1, + 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: &[], + }; + for hour in [9, 10, 14] { + assert_eq!(super::completed_session_factor_date(&ctx, curr, curr, + NaiveTime::from_hms_opt(hour, 30, 0)), Some(prev)); + } + assert_eq!(super::completed_session_factor_date(&ctx, curr, curr, + NaiveTime::from_hms_opt(15, 0, 0)), Some(curr)); + assert_eq!(super::completed_session_factor_date(&ctx, curr, curr, None), Some(curr)); + ctx.active_datetime = Some(curr.and_hms_opt(10, 0, 0).unwrap()); + assert_eq!(super::completed_session_factor_date(&ctx, curr, curr, None), Some(prev)); + ctx.decision_date = prev; + assert_eq!(super::completed_session_factor_date(&ctx, curr, curr, None), Some(prev)); + ctx.execution_date = prev; + assert_eq!(super::completed_session_factor_date(&ctx, prev, prev, None), None); + } + #[test] fn target_scale_replenishment_preserves_strategy_cash_allocation() { let scale = 30.0 / 31.0; diff --git a/crates/fidc-core/src/platform_strategy_spec.rs b/crates/fidc-core/src/platform_strategy_spec.rs index c39dfc0..9e2781d 100644 --- a/crates/fidc-core/src/platform_strategy_spec.rs +++ b/crates/fidc-core/src/platform_strategy_spec.rs @@ -1785,6 +1785,23 @@ pub fn platform_expr_config_from_spec( let Some(spec) = strategy_spec else { return Ok(cfg); }; + if let Some(conditions) = spec.stock_pool_factor_contract.as_ref() + .and_then(|contract| contract.get("conditions")) + .and_then(Value::as_array) + { + for condition in conditions { + let Some(binding) = condition.pointer("/semantic/backtestBinding") else { continue }; + let field = binding.get("field").and_then(Value::as_str).unwrap_or(""); + let dataset = binding.get("sourceDataset").and_then(Value::as_str).unwrap_or(""); + if !dataset.starts_with("indicators_") || field.is_empty() + || !field.bytes().all(|byte| byte.is_ascii_alphanumeric() || byte == b'_') + || field.as_bytes()[0].is_ascii_digit() + { + return Err("invalid native factor backtest binding".to_string()); + } + cfg.completed_session_factor_fields.insert(field.to_string()); + } + } let mut benchmark_short_explicit = false; let mut benchmark_long_explicit = false; let mut stock_short_explicit = false; @@ -3110,6 +3127,20 @@ fn symbol_is_kcb(symbol: &str) -> bool { mod tests { use super::*; + #[test] + fn native_factor_bindings_declare_completed_session_fields() { + let spec = serde_json::json!({"stockPoolFactorContract": {"conditions": [ + {"factorRef": "up_days_stock", "semantic": {"backtestBinding": { + "field": "ths_up_days_stock", "sourceDataset": "indicators_up_days_stock" + }}} + ]}}); + let cfg = platform_expr_config_from_value("test", "000852.SH", &spec).unwrap(); + assert_eq!(cfg.completed_session_factor_fields, + BTreeSet::from(["ths_up_days_stock".to_string()])); + let empty = platform_expr_config_from_value("test", "000852.SH", &serde_json::json!({})).unwrap(); + assert!(empty.completed_session_factor_fields.is_empty()); + } + #[test] fn normalize_board_classifies_kcb_by_688_689_sh_suffix_only() { assert_eq!(normalize_board("688001.SH", None), "KSH");