补全指数与完整范围排名的只读事件计算

This commit is contained in:
boris
2026-09-09 19:57:03 +08:00
parent fe8f6c1c26
commit 5dc5ef9df5
2 changed files with 157 additions and 2 deletions
+113 -2
View File
@@ -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()
@@ -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<String, Vec<Option<f64>>>,
) -> Result<serde_json::Value, String> {
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::<BTreeSet<_>>().len() != universe.len()
|| values.keys().collect::<BTreeSet<_>>() != universe.iter().collect::<BTreeSet<_>>()
|| 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::<BTreeMap<_,_>>();
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::<Vec<_>>();
if !missing.is_empty() {
unknown_dates.push(json!({"date":date,"missing_count":missing.len(),"missing_symbol_sample":missing.iter().take(20).collect::<Vec<_>>(),"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::<Vec<_>>();
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<Observation> {
[1.0, 3.0, 3.0, 4.0]
.iter()