From 999bf5bd01ef46b61362c44c74c7104c7ef4f9fb Mon Sep 17 00:00:00 2001 From: boris Date: Thu, 10 Sep 2026 09:04:08 +0800 Subject: [PATCH] =?UTF-8?q?=E7=A0=94=E7=A9=B6=E8=AE=A1=E7=AE=97=E9=9A=94?= =?UTF-8?q?=E7=A6=BB=E8=AF=81=E5=88=B8=E6=95=B0=E6=8D=AE=E5=BC=82=E5=B8=B8?= =?UTF-8?q?=E5=B9=B6=E4=BF=9D=E6=8C=81=E4=BA=A4=E6=98=93=E4=B8=A5=E6=A0=BC?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/fidc-core/src/daily_patterns.rs | 53 +++++++++++++++++++++++--- 1 file changed, 48 insertions(+), 5 deletions(-) diff --git a/crates/fidc-core/src/daily_patterns.rs b/crates/fidc-core/src/daily_patterns.rs index 27fc1e8..9478bcc 100644 --- a/crates/fidc-core/src/daily_patterns.rs +++ b/crates/fidc-core/src/daily_patterns.rs @@ -233,7 +233,7 @@ pub(crate) fn evaluate_with_context( .iter() .map(|b| (b.date, b)) .collect::>(); - if by_day.len() != series.bars.len() || series.bars.iter().any(|b| !days.contains(&b.date)) { + if by_day.len() != series.bars.len() || series.bars.iter().any(|b| days.binary_search(&b.date).is_err()) { return Err(format!( "pattern_input_invalid: symbol={}, reason=duplicate_or_out_of_scope", series.symbol @@ -393,7 +393,7 @@ pub(crate) fn evaluate_with_context( )?; if value < 0.0 || (name == "prev_close" && value == 0.0) { return Err(format!( - "pattern_input_invalid: {} {d} {name}", + "pattern_input_invalid: symbol={}, date={d}, field={name}, reason=invalid_value", series.symbol )); } @@ -674,6 +674,13 @@ pub fn evaluate_batch( spec: PatternSpec, days: &[NaiveDate], series: &[PatternSeries], +) -> Result { + evaluate_batch_with_policy(spec, days, series, false) +} + +/// Partial results are research diagnostics, never strategy execution inputs. +pub fn evaluate_batch_with_policy( + spec: PatternSpec, days: &[NaiveDate], series: &[PatternSeries], isolate_data_errors: bool, ) -> Result { let spec = spec.validate()?; if series.is_empty() @@ -689,13 +696,26 @@ pub fn evaluate_batch( } let rows = series .iter() - .map(|s| evaluate(&spec, days, s)) + .map(|s| research_row(evaluate(&spec, days, s), s, isolate_data_errors)) .collect::, _>>()?; Ok( json!({"contract":CONTRACT,"spec":spec,"required_history":spec.history_len(),"rows":rows,"read_only":true}), ) } +fn research_row(result: Result, series: &PatternSeries, isolate: bool) -> Result { + match result { + Ok(row) => Ok(json!(row)), + Err(detail) if isolate && detail.starts_with(&format!("pattern_input_invalid: symbol={},", series.symbol)) => { + let fields = detail.split(", ").filter_map(|p| p.split_once('=')).collect::>(); + Ok(json!({"symbol":series.symbol,"name":series.name,"matched":null,"score":null, + "checks":[],"values":{},"anchor":null,"exclusion":null, + "data_issue":{"reason":fields.get("reason"),"date":fields.get("date"),"field":fields.get("field"),"detail":detail}})) + }, + Err(error) => Err(error), + } +} + /// Values are supplied only by the verified research transport or dataset context builder. #[derive(Debug, Clone, Default, Deserialize)] #[serde(deny_unknown_fields)] @@ -709,6 +729,13 @@ pub struct ResearchContext { pub fn evaluate_research_batch( spec: PatternSpec, days: &[NaiveDate], series: &[PatternSeries], context: &ResearchContext, numeric_output: bool, +) -> Result { + evaluate_research_batch_with_policy(spec, days, series, context, numeric_output, false) +} + +pub fn evaluate_research_batch_with_policy( + spec: PatternSpec, days: &[NaiveDate], series: &[PatternSeries], + context: &ResearchContext, numeric_output: bool, isolate_data_errors: bool, ) -> Result { let common_fields = ["index_open", "index_high", "index_low", "index_close"]; let symbol_fields = ["scope_rank", "scope_percentile", "scope_size"]; @@ -741,8 +768,8 @@ pub fn evaluate_research_batch( 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::>()); + let mut result = research_row(evaluate_with_context(&spec, days, item, &fields, numeric_output), item, isolate_data_errors)?; + 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, @@ -786,6 +813,22 @@ pub fn expression_specs(expression: &str) -> Result, String> { mod tests { use super::*; #[test] + fn research_isolates_missing_listing_day_without_weakening_execution() { + let days = ["2026-06-11", "2026-06-12"].map(|d|d.parse::().unwrap()); + let spec: PatternSpec = serde_json::from_value(json!({"template":"ma_below","parameters":{"ma_window":2}})).unwrap(); + let make = |symbol: &str| -> PatternSeries { serde_json::from_value(json!({"symbol":symbol,"listed_at":"2026-06-11","bars":days.map(|d|json!({"date":d,"open":10.,"high":11.,"low":9.,"close":10.,"volume":100.,"adjustment_factor_backward1":1.,"paused":false,"source_path":"/controlled/source.parquet"}))})).unwrap() }; + let complete=make("300395.SZ");let mut missing=make("920083.BJ");missing.bars.remove(0); + let members=[complete.clone(),missing]; + assert!(evaluate_batch(spec.clone(),&days,&members).unwrap_err().contains("missing_market_row")); + let partial=evaluate_batch_with_policy(spec.clone(),&days,&members,true).unwrap(); + assert_eq!(partial["rows"][0],json!(evaluate(&spec.validate().unwrap(),&days,&complete).unwrap())); + assert!(partial["rows"][1]["matched"].is_null()); + assert_eq!(partial["rows"][1]["data_issue"]["date"],"2026-06-11"); + assert_eq!(partial["rows"][1]["data_issue"]["reason"],"missing_market_row"); + let invalid:PatternSpec=serde_json::from_value(json!({"template":"not-a-template"})).unwrap(); + assert!(evaluate_batch_with_policy(invalid,&days,&members,true).is_err()); + } + #[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},