补全指数与完整范围排名的只读事件计算
This commit is contained in:
@@ -33,7 +33,10 @@ pub struct PatternSpec {
|
|||||||
pub expression: Option<crate::factor_events::Expr>,
|
pub expression: Option<crate::factor_events::Expr>,
|
||||||
}
|
}
|
||||||
impl PatternSpec {
|
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() {
|
if (self.template == "expression") != self.expression.is_some() {
|
||||||
return Err("expression_template_requires_expression_only".into());
|
return Err("expression_template_requires_expression_only".into());
|
||||||
}
|
}
|
||||||
@@ -53,7 +56,7 @@ impl PatternSpec {
|
|||||||
];
|
];
|
||||||
let missing = crate::factor_events::field_dependencies(expr)
|
let missing = crate::factor_events::field_dependencies(expr)
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|f| !supported.contains(&f.as_str()))
|
.filter(|f| !supported.contains(&f.as_str()) && !context_fields.contains(&f.as_str()))
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
if !missing.is_empty() {
|
if !missing.is_empty() {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
@@ -191,6 +194,16 @@ pub fn evaluate(
|
|||||||
spec: &PatternSpec,
|
spec: &PatternSpec,
|
||||||
days: &[NaiveDate],
|
days: &[NaiveDate],
|
||||||
series: &PatternSeries,
|
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> {
|
) -> Result<PatternResult, String> {
|
||||||
if days.len() != spec.history_len() || days.windows(2).any(|w| w[0] >= w[1]) {
|
if days.len() != spec.history_len() || days.windows(2).any(|w| w[0] >= w[1]) {
|
||||||
return Err("pattern_calendar_incomplete: 需要完整、唯一且递增的真实交易日窗口".into());
|
return Err("pattern_calendar_incomplete: 需要完整、唯一且递增的真实交易日窗口".into());
|
||||||
@@ -369,6 +382,13 @@ pub fn evaluate(
|
|||||||
.collect::<Result<Vec<_>, String>>()?;
|
.collect::<Result<Vec<_>, String>>()?;
|
||||||
fields.insert(name.into(), values);
|
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 {
|
let frame = crate::factor_events::Frame {
|
||||||
symbol: series.symbol.clone(),
|
symbol: series.symbol.clone(),
|
||||||
frequency: "1d".into(),
|
frequency: "1d".into(),
|
||||||
@@ -383,6 +403,12 @@ pub fn evaluate(
|
|||||||
result.values["expression_contract"] = json!(crate::factor_events::CONTRACT);
|
result.values["expression_contract"] = json!(crate::factor_events::CONTRACT);
|
||||||
result.values["price_policy"] = json!("backward1_anchored_to_decision_close");
|
result.values["price_policy"] = json!("backward1_anchored_to_decision_close");
|
||||||
result.score = latest;
|
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() {
|
if latest.is_none() {
|
||||||
result.exclusion = Some(
|
result.exclusion = Some(
|
||||||
json!({"reason":"expression_undefined_or_warmup","signal_date":days.last()}),
|
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> {
|
pub fn expression_specs(expression: &str) -> Result<Vec<PatternSpec>, String> {
|
||||||
let mut specs = Vec::new();
|
let mut specs = Vec::new();
|
||||||
for helper in ["pattern_signal", "pattern_score"] {
|
for helper in ["pattern_signal", "pattern_score"] {
|
||||||
@@ -687,6 +767,37 @@ pub fn expression_specs(expression: &str) -> Result<Vec<PatternSpec>, String> {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
#[test]
|
#[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() {
|
fn expression_condition_preserves_native_types_and_rejects_numeric_as_signal() {
|
||||||
let make = |expression: Value| {
|
let make = |expression: Value| {
|
||||||
serde_json::from_value::<PatternSpec>(json!({"template":"expression","parameters":{"history_window":3},"expression":expression})).unwrap().validate().unwrap()
|
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,
|
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 {
|
fn mean(values: &[f64]) -> f64 {
|
||||||
let base = values[0];
|
let base = values[0];
|
||||||
base + values
|
base + values
|
||||||
@@ -153,6 +183,20 @@ pub fn evaluate(
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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> {
|
fn rows() -> Vec<Observation> {
|
||||||
[1.0, 3.0, 3.0, 4.0]
|
[1.0, 3.0, 3.0, 4.0]
|
||||||
.iter()
|
.iter()
|
||||||
|
|||||||
Reference in New Issue
Block a user