//! Explicit reference identities and frozen rank universes shared by all daily runtimes. use crate::{ daily_patterns::{dataset_series, evaluate_with_context, PatternSpec, ResearchContext}, factor_events::{field_dependencies, Expr}, DataSet, }; use chrono::NaiveDate; use serde::{Deserialize, Serialize}; use serde_json::Value; use std::collections::{BTreeMap, BTreeSet}; pub const CONTRACT: &str = "fidc_pattern_execution_context_v1"; pub const CONTEXT_FIELDS: &[&str] = &[ "index_open", "index_high", "index_low", "index_close", "scope_rank", "scope_percentile", "scope_size", ]; const STOCK_FIELDS: &[&str] = &[ "open", "high", "low", "close", "volume", "raw_open", "raw_high", "raw_low", "raw_close", "prev_close", "amount", ]; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct ExecutionContext { pub contract: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub benchmark: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub rank_expression: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub rank_universe: Vec, } fn valid_symbol(s: &str) -> bool { let Some((code, market)) = s.split_once('.') else { return false; }; code.len() == 6 && code.bytes().all(|c| c.is_ascii_digit()) && matches!(market, "SH" | "SZ" | "BJ" | "CSI") } impl ExecutionContext { pub fn fields(&self, expression: &Expr) -> BTreeSet { let mut fields = field_dependencies(expression); if let Some(rank) = &self.rank_expression { fields.extend(field_dependencies(rank)); } fields } pub fn validate(&self, expression: &Expr) -> Result<(), String> { if self.contract != CONTRACT { return Err("pattern_context_contract_invalid".into()); } let needed = field_dependencies(expression); let ranked = needed.iter().any(|f| f.starts_with("scope_")); if ranked != self.rank_expression.is_some() || !ranked && !self.rank_universe.is_empty() { return Err("pattern_rank_expression_and_universe_required".into()); } if ranked && (self.rank_universe.len() < 2 || self.rank_universe.len() > 20_000 || self.rank_universe.iter().any(|s| !valid_symbol(s)) || self.rank_universe.iter().collect::>().len() != self.rank_universe.len()) { return Err("pattern_rank_universe_invalid".into()); } if let Some(rank) = &self.rank_expression { let fields = field_dependencies(rank); if fields .iter() .any(|f| !STOCK_FIELDS.contains(&f.as_str()) && !f.starts_with("index_")) { return Err("pattern_rank_expression_invalid_or_recursive".into()); } } let fields = self.fields(expression); if fields .iter() .any(|f| !STOCK_FIELDS.contains(&f.as_str()) && !CONTEXT_FIELDS.contains(&f.as_str())) { return Err("pattern_context_unmapped_field".into()); } let index = fields.iter().any(|f| f.starts_with("index_")); if index != self.benchmark.is_some() || self .benchmark .as_ref() .is_some_and(|s| !valid_symbol(s) || s.ends_with(".BJ")) { return Err("pattern_reference_index_required".into()); } if !index && !ranked { return Err("pattern_unused_context".into()); } Ok(()) } } pub fn build_dataset_context( spec: &PatternSpec, data: &DataSet, date: NaiveDate, ) -> Result { let Some(config) = &spec.execution_context else { return Ok(ResearchContext::default()); }; config.validate( spec.expression .as_ref() .ok_or("pattern_context_requires_expression")?, )?; let days = data.calendar().trailing_days(date, spec.history_len()); if days.len() != spec.history_len() || days.last() != Some(&date) { return Err("pattern_context_calendar_incomplete".into()); } let needed = config.fields(spec.expression.as_ref().unwrap()); let mut context = ResearchContext::default(); if let Some(symbol) = &config.benchmark { for name in needed.iter().filter(|f| f.starts_with("index_")) { let values = days .iter() .map(|d| { let value = if let Some(b) = data.market(*d, symbol) { match name.as_str() { "index_open" => Some(b.open), "index_high" => Some(b.high), "index_low" => Some(b.low), "index_close" => Some(b.close), _ => None, } } else if let Some(b) = data.benchmark(*d).filter(|b| &b.benchmark == symbol) { match name.as_str() { "index_open" => Some(b.open), "index_close" => Some(b.close), _ => None, } } else { None }; value .filter(|v| v.is_finite() && *v > 0.0) .map(Some) .ok_or_else(|| format!("pattern_reference_missing: {symbol} {d} {name}")) }) .collect::, _>>()?; context.common.insert(name.clone(), values); } } if let Some(expression) = &config.rank_expression { let mut input = spec.clone(); input.execution_context = None; input.expression = Some(expression.clone()); let mut values = BTreeMap::new(); for symbol in &config.rank_universe { let row = evaluate_with_context( &input, &days, &dataset_series(data, &days, symbol), &context.common, true, )?; if let Some(reason) = row.exclusion { return Err(format!("pattern_rank_member_incomplete: {symbol} {reason}")); } values.insert( symbol.clone(), serde_json::from_value::>>( row.values["expression"]["values"].clone(), ) .map_err(|e| e.to_string())?, ); } let ranks = crate::factor_cross_section::rank_history(&days, &config.rank_universe, &values)?; for symbol in &config.rank_universe { let decode = |value: &Value| { serde_json::from_value::>>(value.clone()).map_err(|e| e.to_string()) }; context.by_symbol.insert( symbol.clone(), BTreeMap::from([ ("scope_rank".into(), decode(&ranks["rank"][symbol])?), ( "scope_percentile".into(), decode(&ranks["percentile"][symbol])?, ), ( "scope_size".into(), vec![Some(config.rank_universe.len() as f64); days.len()], ), ]), ); } } Ok(context) } pub fn specs_in_value(value: &Value) -> Result, String> { let mut specs = Vec::new(); match value { Value::String(text) => specs.extend(crate::daily_patterns::expression_specs(text)?), Value::Array(items) => { for v in items { specs.extend(specs_in_value(v)?); } } Value::Object(items) => { for v in items.values() { specs.extend(specs_in_value(v)?); } } _ => {} } Ok(specs) } pub fn required_symbols(value: &Value) -> Result<(BTreeSet, BTreeSet), String> { let (mut indices, mut stocks) = (BTreeSet::new(), BTreeSet::new()); for spec in specs_in_value(value)? { if let Some(context) = spec.execution_context { if let Some(index) = context.benchmark { indices.insert(index); } stocks.extend(context.rank_universe); } } Ok((indices, stocks)) } #[cfg(test)] mod tests { use super::*; use crate::{BenchmarkSnapshot, DailyFactorSnapshot, DailyMarketSnapshot, Instrument}; use serde_json::json; #[test] fn normalized_rule_does_not_turn_an_omitted_window_into_explicit_null() { let expression:Expr=serde_json::from_value(json!({"kind":"operator","name":"GT","args":[{"kind":"field","name":"close"},{"kind":"number","value":1}]})).unwrap(); assert!(serde_json::to_value(expression).unwrap().get("window").is_none()); } fn data(future: bool, reference: bool) -> DataSet { let mut days = vec![ NaiveDate::from_ymd_opt(2026, 9, 4).unwrap(), NaiveDate::from_ymd_opt(2026, 9, 7).unwrap(), NaiveDate::from_ymd_opt(2026, 9, 8).unwrap(), ]; if future { days.push(NaiveDate::from_ymd_opt(2026, 9, 9).unwrap()); } let symbols = vec!["000001.SZ", "000002.SZ", "000003.SZ"]; let mut instruments = symbols .iter() .map(|s| Instrument { symbol: s.to_string(), name: s.to_string(), board: "SZ_MAIN".into(), round_lot: 100, listed_at: None, delisted_at: None, status: "active".into(), }) .collect::>(); if reference { instruments.push(Instrument { symbol: "399006.SZ".into(), name: "reference".into(), board: "INDEX".into(), round_lot: 1, listed_at: None, delisted_at: None, status: "active".into(), }); } let mut market = vec![]; let mut factors = vec![]; let mut benchmark = vec![]; for (i, d) in days.iter().enumerate() { for (n, s) in symbols.iter().enumerate() { let c = [ [10., 12., 11., 1000.], [10., 11., 12., 1.], [10., 10., 13., 1.], ][n][i]; market.push(DailyMarketSnapshot { date: *d, symbol: s.to_string(), timestamp: None, day_open: c, open: c, high: c, low: c, close: c, last_price: c, bid1: c, ask1: c, prev_close: 10., volume: 100000, minute_volume: 0, bid1_volume: 10000, ask1_volume: 10000, trading_phase: None, paused: false, upper_limit: c * 2., lower_limit: c / 2., price_tick: 0.01, }); factors.push(DailyFactorSnapshot { date: *d, symbol: s.to_string(), market_cap_bn: 1., free_float_cap_bn: 1., pe_ttm: 10., turnover_ratio: None, effective_turnover_ratio: None, adjustment_factor_backward1: Some(1.), extra_factors: Default::default(), }); } if reference { let mut row = market.last().unwrap().clone(); row.symbol = "399006.SZ".into(); row.open = 30.; row.high = 30.; row.low = 30.; row.close = 30.; market.push(row); } benchmark.push(BenchmarkSnapshot { date: *d, benchmark: "000300.SH".into(), open: 4000., close: 4000., prev_close: 4000., volume: 1000, }); } DataSet::from_components(instruments, market, factors, vec![], benchmark).unwrap() } fn spec(rank: bool) -> PatternSpec { let expression = if rank { json!({"kind":"operator","name":"GT","args":[{"kind":"field","name":"scope_rank"},{"kind":"number","value":2}]}) } else { json!({"kind":"operator","name":"LT","args":[{"kind":"field","name":"index_close"},{"kind":"number","value":100}]}) }; let context = if rank { json!({"contract":CONTRACT,"rank_expression":{"kind":"operator","name":"PCT_CHANGE","window":1,"args":[{"kind":"field","name":"close"}]},"rank_universe":["000001.SZ","000002.SZ","000003.SZ"]}) } else { json!({"contract":CONTRACT,"benchmark":"399006.SZ"}) }; serde_json::from_value::(json!({"template":"expression","parameters":{"history_window":3},"expression":expression,"execution_context":context})).unwrap().validate().unwrap() } #[test] fn dataset_rank_is_full_scope_causal_and_equal_to_pure_cross_section() { let spec = spec(true); let date = NaiveDate::from_ymd_opt(2026, 9, 8).unwrap(); let original = build_dataset_context(&spec, &data(false, true), date).unwrap(); let future = build_dataset_context(&spec, &data(true, true), date).unwrap(); assert_eq!(original.by_symbol, future.by_symbol); assert_eq!(original.by_symbol["000001.SZ"]["scope_rank"][2], Some(3.)); assert_eq!(original.by_symbol["000002.SZ"]["scope_rank"][2], Some(2.)); assert_eq!(original.by_symbol["000003.SZ"]["scope_rank"][2], Some(1.)); assert!( crate::daily_patterns::evaluate_dataset(&spec, &data(false, true), date, "000001.SZ") .unwrap() .matched ); let mut incomplete = data(false, true).snapshot_components(); incomplete.market.retain(|r| r.symbol != "000003.SZ"); let broken = DataSet::from_components( incomplete.instruments, incomplete.market, incomplete.factors, incomplete.candidates, incomplete.benchmarks, ) .unwrap(); assert!(build_dataset_context(&spec, &broken, date).is_err()); } #[test] fn reference_index_never_defaults_to_performance_benchmark() { let spec = spec(false); let date = NaiveDate::from_ymd_opt(2026, 9, 8).unwrap(); assert!( crate::daily_patterns::evaluate_dataset(&spec, &data(false, true), date, "000001.SZ") .unwrap() .matched ); assert!(build_dataset_context(&spec, &data(false, false), date) .unwrap_err() .contains("399006.SZ")); } #[test] fn runtime_contract_rejects_missing_range_and_recursive_ranks() { let mut missing = spec(true); missing .execution_context .as_mut() .unwrap() .rank_universe .clear(); assert!(missing.validate().is_err()); let mut recursive = spec(true); recursive .execution_context .as_mut() .unwrap() .rank_expression = Some(Expr::Field { name: "scope_rank".into(), }); assert!(recursive.validate().is_err()); } }