diff --git a/crates/fidc-core/src/daily_patterns.rs b/crates/fidc-core/src/daily_patterns.rs index 245c3aa..a172f3d 100644 --- a/crates/fidc-core/src/daily_patterns.rs +++ b/crates/fidc-core/src/daily_patterns.rs @@ -33,7 +33,10 @@ pub struct PatternSpec { pub expression: Option, } impl PatternSpec { - pub fn validate(mut self) -> Result { + pub fn validate(self) -> Result { + self.validate_with_context(&[]) + } + fn validate_with_context(mut self, context_fields: &[&str]) -> Result { if (self.template == "expression") != self.expression.is_some() { return Err("expression_template_requires_expression_only".into()); } @@ -53,7 +56,7 @@ impl PatternSpec { ]; let missing = crate::factor_events::field_dependencies(expr) .into_iter() - .filter(|f| !supported.contains(&f.as_str())) + .filter(|f| !supported.contains(&f.as_str()) && !context_fields.contains(&f.as_str())) .collect::>(); if !missing.is_empty() { return Err(format!( @@ -191,6 +194,16 @@ pub fn evaluate( spec: &PatternSpec, days: &[NaiveDate], series: &PatternSeries, +) -> Result { + evaluate_with_context(spec, days, series, &BTreeMap::new(), false) +} + +fn evaluate_with_context( + spec: &PatternSpec, + days: &[NaiveDate], + series: &PatternSeries, + context: &BTreeMap>>, + numeric_output: bool, ) -> Result { if days.len() != spec.history_len() || days.windows(2).any(|w| w[0] >= w[1]) { return Err("pattern_calendar_incomplete: 需要完整、唯一且递增的真实交易日窗口".into()); @@ -369,6 +382,13 @@ pub fn evaluate( .collect::, String>>()?; fields.insert(name.into(), values); } + for (name, values) in context { + if fields.contains_key(name) || values.len() != days.len() + || values.iter().flatten().any(|v| !v.is_finite()) { + return Err(format!("research_context_invalid: {} {name}", series.symbol)); + } + fields.insert(name.clone(), values.clone()); + } let frame = crate::factor_events::Frame { symbol: series.symbol.clone(), frequency: "1d".into(), @@ -383,6 +403,12 @@ pub fn evaluate( result.values["expression_contract"] = json!(crate::factor_events::CONTRACT); result.values["price_policy"] = json!("backward1_anchored_to_decision_close"); result.score = latest; + if numeric_output { + if values.value_type != crate::factor_events::ValueType::Number { + return Err("research_rank_input_requires_numeric_expression".into()); + } + return Ok(result); + } if latest.is_none() { result.exclusion = Some( json!({"reason":"expression_undefined_or_warmup","signal_date":days.last()}), @@ -651,6 +677,60 @@ pub fn evaluate_batch( ) } +/// Research transport only. Strategy PatternSpec validation still rejects these fields. +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ResearchContext { + #[serde(default)] + pub common: BTreeMap>>, + #[serde(default)] + pub by_symbol: BTreeMap>>>, +} + +pub fn evaluate_research_batch( + spec: PatternSpec, days: &[NaiveDate], series: &[PatternSeries], + context: &ResearchContext, numeric_output: bool, +) -> Result { + let common_fields = ["index_open", "index_high", "index_low", "index_close"]; + let symbol_fields = ["scope_rank", "scope_percentile", "scope_size"]; + if spec.template != "expression" || series.is_empty() || series.len() > 200 + || series.iter().map(|s| &s.symbol).collect::>().len() != series.len() + || context.common.keys().any(|k| !common_fields.contains(&k.as_str())) + || context.by_symbol.iter().any(|(s, fields)| !series.iter().any(|row| &row.symbol == s) + || fields.keys().any(|k| !symbol_fields.contains(&k.as_str()))) { + return Err("research_context_scope_or_fields_invalid".into()); + } + for (name, values) in &context.common { + if values.len() != days.len() || values.iter().any(|v| !v.is_some_and(|x| x.is_finite() && x > 0.0)) { + return Err(format!("research_index_window_incomplete: {name}")); + } + } + let allowed = common_fields.into_iter().chain(symbol_fields).collect::>(); + let spec = spec.validate_with_context(&allowed)?; + let dependencies = crate::factor_events::field_dependencies(spec.expression.as_ref().unwrap()); + let mut rows = Vec::with_capacity(series.len()); + for item in series { + let mut fields = context.common.clone(); + fields.extend(context.by_symbol.get(&item.symbol).cloned().unwrap_or_default()); + if allowed.iter().any(|f| dependencies.contains(*f) && !fields.contains_key(*f)) { + return Err(format!("research_context_missing: {}", item.symbol)); + } + for (name, values) in &fields { + if values.len() != days.len() || values.iter().flatten().any(|v| !v.is_finite() + || (name == "scope_percentile" && !(0.0..=1.0).contains(v)) + || (matches!(name.as_str(), "scope_rank" | "scope_size") && *v < 1.0)) { + return Err(format!("research_context_invalid: {} {name}", item.symbol)); + } + } + let mut result = evaluate_with_context(&spec, days, item, &fields, numeric_output)?; + result.values["research_context_latest"] = json!(fields.iter().map(|(k,v)|(k,v.last().copied().flatten())).collect::>()); + rows.push(result); + } + Ok(json!({"contract":CONTRACT,"context_contract":"fidc_research_event_context_v1","spec":spec, + "required_history":spec.history_len(),"rows":rows,"read_only":true, + "source_evidence_verified":false,"live_routing":false,"rule_backtest_supported":false})) +} + pub fn expression_specs(expression: &str) -> Result, String> { let mut specs = Vec::new(); for helper in ["pattern_signal", "pattern_score"] { @@ -687,6 +767,37 @@ pub fn expression_specs(expression: &str) -> Result, String> { mod tests { use super::*; #[test] + fn research_index_and_ranking_context_never_unlock_strategy_mapping() { + let days=["2026-09-04","2026-09-07","2026-09-08"].map(|s|s.parse::().unwrap()); + let spec:PatternSpec=serde_json::from_value(json!({"template":"expression","parameters":{"history_window":3}, + "expression":{"kind":"operator","name":"CROSS_ABOVE","args":[{"kind":"field","name":"close"},{"kind":"field","name":"index_close"}]}})).unwrap(); + assert!(spec.clone().validate().unwrap_err().contains("mapping_required")); + let series:PatternSeries=serde_json::from_value(json!({"symbol":"TEST","bars":days.iter().zip([9.0,10.0,11.0]).map(|(d,c)|json!({"date":d,"open":c,"high":c,"low":c,"close":c,"volume":100.0,"adjustment_factor_backward1":1.0,"paused":false})).collect::>()})).unwrap(); + let mut context=ResearchContext{common:BTreeMap::from([("index_close".into(),vec![Some(10.0);3])]),..Default::default()}; + let result=evaluate_research_batch(spec.clone(),&days,&[series.clone()],&context,false).unwrap(); + assert_eq!(result["rows"][0]["matched"],true); + assert_eq!(result["source_evidence_verified"],false); + assert_eq!(result["rule_backtest_supported"],false); + context.common.get_mut("index_close").unwrap()[1]=None; + assert!(evaluate_research_batch(spec.clone(),&days,&[series.clone()],&context,false).unwrap_err().contains("index_window_incomplete")); + context.common=BTreeMap::from([("close".into(),vec![Some(10.0);3])]); + assert!(evaluate_research_batch(spec,&days,&[series],&context,false).is_err()); + } + + #[test] + fn research_numeric_output_keeps_warmup_unknown_without_a_false_signal() { + let days=["2026-09-04","2026-09-07","2026-09-08"].map(|s|s.parse::().unwrap()); + let spec:PatternSpec=serde_json::from_value(json!({"template":"expression","parameters":{"history_window":3}, + "expression":{"kind":"operator","name":"PCT_CHANGE","window":2,"args":[{"kind":"field","name":"close"}]}})).unwrap(); + let series:PatternSeries=serde_json::from_value(json!({"symbol":"TEST","bars":days.iter().zip([10.0,10.5,11.0]).map(|(d,c)|json!({"date":d,"open":c,"high":c,"low":c,"close":c,"volume":100.0,"adjustment_factor_backward1":1.0,"paused":false})).collect::>()})).unwrap(); + assert!(evaluate_batch(spec.clone(),&days,&[series.clone()]).is_err()); + let result=evaluate_research_batch(spec,&days,&[series],&ResearchContext::default(),true).unwrap(); + let values=&result["rows"][0]["values"]["expression"]["values"]; + assert!(values[0].is_null() && values[1].is_null()); + assert!((values[2].as_f64().unwrap()-0.1).abs()<1e-12); + assert_eq!(result["rows"][0]["matched"],false); + } + #[test] fn expression_condition_preserves_native_types_and_rejects_numeric_as_signal() { let make = |expression: Value| { serde_json::from_value::(json!({"template":"expression","parameters":{"history_window":3},"expression":expression})).unwrap().validate().unwrap() diff --git a/crates/fidc-core/src/factor_cross_section.rs b/crates/fidc-core/src/factor_cross_section.rs index 87f4038..8bc88d1 100644 --- a/crates/fidc-core/src/factor_cross_section.rs +++ b/crates/fidc-core/src/factor_cross_section.rs @@ -28,6 +28,36 @@ pub struct Output { pub value: f64, } +/// Every date ranks the same frozen research universe; unknown inputs invalidate the whole date. +pub fn rank_history( + dates: &[chrono::NaiveDate], universe: &[String], values: &BTreeMap>>, +) -> Result { + use serde_json::json; + if dates.is_empty() || dates.windows(2).any(|w| w[0] >= w[1]) || universe.len() < 2 + || universe.len() > 20_000 || dates.len().saturating_mul(universe.len()) > 2_000_000 + || universe.iter().collect::>().len() != universe.len() + || values.keys().collect::>() != universe.iter().collect::>() + || values.values().any(|v| v.len() != dates.len() || v.iter().flatten().any(|v| !v.is_finite())) { + return Err("research_rank_history_incomplete_or_invalid_universe".into()); + } + let mut rank = universe.iter().map(|s|(s.clone(),vec![None;dates.len()])).collect::>(); + let mut percentile = rank.clone(); + let mut unknown_dates = Vec::new(); + for (i, date) in dates.iter().enumerate() { + let missing = universe.iter().filter(|s|values[*s][i].is_none()).collect::>(); + if !missing.is_empty() { + unknown_dates.push(json!({"date":date,"missing_count":missing.len(),"missing_symbol_sample":missing.iter().take(20).collect::>(),"sample_limit":20})); + continue; + } + let observations = universe.iter().map(|s|Observation{symbol:s.clone(),value:values[s][i].unwrap(),industry:None,market_cap:None}).collect::>(); + for item in evaluate("RANK", universe, &observations, 0.0)? {rank.get_mut(&item.symbol).unwrap()[i]=Some(item.value);} + for item in evaluate("PERCENTILE", universe, &observations, 0.0)? {percentile.get_mut(&item.symbol).unwrap()[i]=Some(item.value);} + } + Ok(json!({"rank":rank,"percentile":percentile,"unknown_dates":unknown_dates, + "universe":universe,"dates":dates,"tie_policy":"average_rank_descending", + "membership_policy":"fixed_research_scope_not_historical_index_membership"})) +} + fn mean(values: &[f64]) -> f64 { let base = values[0]; base + values @@ -153,6 +183,20 @@ pub fn evaluate( #[cfg(test)] mod tests { use super::*; + #[test] + fn historical_ranks_keep_ties_and_unknown_full_cross_sections() { + let dates=["2026-09-07","2026-09-08","2026-09-09"].map(|d|d.parse().unwrap()); + let universe=vec!["A".into(),"B".into(),"C".into()]; + let values=BTreeMap::from([("A".into(),vec![None,Some(10.0),Some(20.0)]),("B".into(),vec![Some(10.0),Some(10.0),Some(10.0)]),("C".into(),vec![Some(20.0),Some(5.0),Some(15.0)])]); + let out=rank_history(&dates,&universe,&values).unwrap(); + assert_eq!(out["rank"]["A"],serde_json::json!([null,1.5,1.0])); + assert_eq!(out["rank"]["C"],serde_json::json!([null,3.0,2.0])); + assert_eq!(out["unknown_dates"][0]["missing_count"],1); + let earlier=values.iter().map(|(s,v)|(s.clone(),v[..2].to_vec())).collect(); + let first=rank_history(&dates[..2],&universe,&earlier).unwrap(); + assert_eq!(&out["rank"]["A"].as_array().unwrap()[..2],first["rank"]["A"].as_array().unwrap()); + assert!(rank_history(&dates,&universe[..2],&values).is_err()); + } fn rows() -> Vec { [1.0, 3.0, 3.0, 4.0] .iter()