补全指数与完整范围排名的只读事件计算
This commit is contained in:
@@ -33,7 +33,10 @@ pub struct PatternSpec {
|
||||
pub expression: Option<crate::factor_events::Expr>,
|
||||
}
|
||||
impl PatternSpec {
|
||||
pub fn validate(mut self) -> Result<Self, String> {
|
||||
pub fn validate(self) -> Result<Self, String> {
|
||||
self.validate_with_context(&[])
|
||||
}
|
||||
fn validate_with_context(mut self, context_fields: &[&str]) -> Result<Self, String> {
|
||||
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::<Vec<_>>();
|
||||
if !missing.is_empty() {
|
||||
return Err(format!(
|
||||
@@ -191,6 +194,16 @@ pub fn evaluate(
|
||||
spec: &PatternSpec,
|
||||
days: &[NaiveDate],
|
||||
series: &PatternSeries,
|
||||
) -> Result<PatternResult, String> {
|
||||
evaluate_with_context(spec, days, series, &BTreeMap::new(), false)
|
||||
}
|
||||
|
||||
fn evaluate_with_context(
|
||||
spec: &PatternSpec,
|
||||
days: &[NaiveDate],
|
||||
series: &PatternSeries,
|
||||
context: &BTreeMap<String, Vec<Option<f64>>>,
|
||||
numeric_output: bool,
|
||||
) -> Result<PatternResult, String> {
|
||||
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::<Result<Vec<_>, 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<String, Vec<Option<f64>>>,
|
||||
#[serde(default)]
|
||||
pub by_symbol: BTreeMap<String, BTreeMap<String, Vec<Option<f64>>>>,
|
||||
}
|
||||
|
||||
pub fn evaluate_research_batch(
|
||||
spec: PatternSpec, days: &[NaiveDate], series: &[PatternSeries],
|
||||
context: &ResearchContext, numeric_output: bool,
|
||||
) -> Result<Value, String> {
|
||||
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::<BTreeSet<_>>().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::<Vec<_>>();
|
||||
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::<BTreeMap<_,_>>());
|
||||
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<Vec<PatternSpec>, String> {
|
||||
let mut specs = Vec::new();
|
||||
for helper in ["pattern_signal", "pattern_score"] {
|
||||
@@ -687,6 +767,37 @@ pub fn expression_specs(expression: &str) -> Result<Vec<PatternSpec>, 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::<NaiveDate>().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::<Vec<_>>()})).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::<NaiveDate>().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::<Vec<_>>()})).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::<PatternSpec>(json!({"template":"expression","parameters":{"history_window":3},"expression":expression})).unwrap().validate().unwrap()
|
||||
|
||||
Reference in New Issue
Block a user