Compare commits

...

4 Commits

5 changed files with 326 additions and 8 deletions
@@ -0,0 +1,9 @@
use std::io::{self, Read};
fn main() {
let mut input=String::new();io::stdin().read_to_string(&mut input).unwrap();
let request=serde_json::from_str(&input).unwrap();
match fidc_core::market_event_context::aggregate(request) {
Ok(value)=>println!("{}",serde_json::to_string(&value).unwrap()),
Err(error)=>{eprintln!("{error}");std::process::exit(1);}
}
}
+55 -8
View File
@@ -233,7 +233,7 @@ pub(crate) fn evaluate_with_context(
.iter()
.map(|b| (b.date, b))
.collect::<BTreeMap<_, _>>();
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<Value, String> {
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<Value, String> {
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::<Result<Vec<_>, _>>()?;
Ok(
json!({"contract":CONTRACT,"spec":spec,"required_history":spec.history_len(),"rows":rows,"read_only":true}),
)
}
fn research_row(result: Result<PatternResult, String>, series: &PatternSeries, isolate: bool) -> Result<Value, String> {
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::<BTreeMap<_,_>>();
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)]
@@ -710,8 +730,17 @@ 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"];
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<Value, String> {
let common_fields = ["index_open", "index_high", "index_low", "index_close"].into_iter()
.chain(crate::market_event_context::COMMON_FIELDS.iter().copied()).collect::<Vec<_>>();
let symbol_fields = ["scope_rank", "scope_percentile", "scope_size"].into_iter()
.chain(crate::market_event_context::INDUSTRY_FIELDS.iter().copied()).collect::<Vec<_>>();
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()))
@@ -720,7 +749,9 @@ pub fn evaluate_research_batch(
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)) {
if values.len() != days.len() || values.iter().any(|v| if name.starts_with("index_") {
!v.is_some_and(|x| x.is_finite() && x > 0.0)
} else { v.is_some_and(|x| !x.is_finite()) }) {
return Err(format!("research_index_window_incomplete: {name}"));
}
}
@@ -741,8 +772,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::<BTreeMap<_,_>>());
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::<BTreeMap<_,_>>());
rows.push(result);
}
Ok(json!({"contract":CONTRACT,"context_contract":"fidc_research_event_context_v1","spec":spec,
@@ -786,6 +817,22 @@ pub fn expression_specs(expression: &str) -> Result<Vec<PatternSpec>, 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::<NaiveDate>().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::<NaiveDate>().unwrap());
let spec:PatternSpec=serde_json::from_value(json!({"template":"expression","parameters":{"history_window":3},
+4
View File
@@ -179,6 +179,10 @@ pub fn catalog() -> Value {
json!({"contract":CONTRACT,"library":{"name":"TA-Lib native Rust","revision":TA_REV,"license":"BSD-3-Clause"},
"execution_context_contract":crate::pattern_context::CONTRACT,
"execution_context_fields":crate::pattern_context::CONTEXT_FIELDS,
"market_event_context_contract":crate::market_event_context::CONTRACT,
"market_event_kernel_sha256":crate::market_event_context::implementation_sha256(),
"market_event_common_fields":crate::market_event_context::COMMON_FIELDS,
"market_event_industry_fields":crate::market_event_context::INDUSTRY_FIELDS,
"session_events":crate::session_events::EVENTS,"session_event_contract":crate::session_events::CONTRACT,
"indicators":indicators,"operators":OPERATORS,"cross_section_operators":crate::factor_cross_section::OPERATORS,"read_only":true,"live_routing":false,
"policies":{"null":"unknown_not_false","warmup":"null_until_full_history","recursive_seed":"frozen_input_start",
+1
View File
@@ -7,6 +7,7 @@ pub mod pattern_context;
pub mod session_events;
pub mod factor_events;
pub mod factor_cross_section;
pub mod market_event_context;
pub mod engine;
pub mod event_bus;
pub mod events;
@@ -0,0 +1,257 @@
//! Complete published daily cross sections, independent of trading candidates and accounts.
use chrono::NaiveDate;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
pub const CONTRACT: &str = "fidc_market_event_context_v1";
pub fn implementation_sha256() -> String {
use sha2::{Digest, Sha256};
format!("{:x}", Sha256::digest(include_bytes!("market_event_context.rs")))
}
pub const COMMON_FIELDS: &[&str] = &[
"market_breadth", "market_return", "market_limit_up_count", "market_limit_down_count",
"market_limit_up_rate", "market_broken_limit_rate", "market_high_board", "market_profit_effect",
];
pub const INDUSTRY_FIELDS: &[&str] = &[
"industry_close", "industry_return_20", "industry_breadth", "industry_rank", "industry_size",
];
#[derive(Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Observation {
pub symbol: String,
pub industry: Option<String>,
pub close: Option<f64>,
pub high: Option<f64>,
pub previous_close: Option<f64>,
pub upper_limit: Option<f64>,
pub lower_limit: Option<f64>,
pub no_limit: Option<bool>,
pub paused: Option<bool>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Day {
pub date: NaiveDate,
pub universe: Vec<String>,
pub rows: Vec<Observation>,
}
#[derive(Default, Clone, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct State {
pub last_date: Option<NaiveDate>,
pub streaks: BTreeMap<String, Option<u32>>,
pub limit_ups: BTreeSet<String>,
pub industry_history: BTreeMap<String, Vec<f64>>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Request {
pub days: Vec<Day>,
#[serde(default)]
pub previous: State,
}
#[derive(Serialize)]
pub struct OutputDay {
pub date: NaiveDate,
pub common: BTreeMap<String, Option<f64>>,
pub industries: BTreeMap<String, BTreeMap<String, Option<f64>>>,
pub members: BTreeMap<String, Option<String>>,
pub securities: usize,
pub active: usize,
pub paused: usize,
pub no_limit: usize,
pub profit_effect_members: Vec<String>,
pub profit_effect_missing: Vec<String>,
pub industry_missing: Vec<String>,
}
#[derive(Serialize)]
pub struct Output {
pub contract: &'static str,
pub days: Vec<OutputDay>,
pub state: State,
}
fn positive(value: Option<f64>, symbol: &str, field: &str) -> Result<f64, String> {
value.filter(|v| v.is_finite() && *v > 0.0)
.ok_or_else(|| format!("market_event_input_invalid: {symbol} {field}"))
}
fn average(values: impl Iterator<Item = f64>, n: usize) -> f64 {
values.map(|v| v / n as f64).sum()
}
pub fn aggregate(request: Request) -> Result<Output, String> {
let mut state = request.previous;
if request.days.is_empty() || request.days.len() > 30
|| request.days.iter().map(|d| d.rows.len()).sum::<usize>() > 60_000
|| state.streaks.len() > 20_000 || state.limit_ups.len() > 20_000
|| state.industry_history.len() > 2000
|| state.industry_history.values().any(|v| v.is_empty() || v.len() > 21
|| v.iter().any(|x| !x.is_finite() || *x <= 0.0))
|| state.last_date.is_none() && (!state.streaks.is_empty() || !state.limit_ups.is_empty() || !state.industry_history.is_empty()) {
return Err("market_event_history_budget_or_state_invalid".into());
}
let mut output = Vec::new();
for day in request.days {
if state.last_date.is_some_and(|d| d >= day.date)
|| day.universe.is_empty() || day.universe.len() > 20_000
|| day.universe.iter().collect::<BTreeSet<_>>().len() != day.universe.len()
|| day.rows.len() != day.universe.len()
|| day.rows.iter().map(|r| &r.symbol).collect::<BTreeSet<_>>() != day.universe.iter().collect::<BTreeSet<_>>() {
return Err(format!("market_event_incomplete_cross_section: {}", day.date));
}
let mut returns = BTreeMap::new();
let mut groups: BTreeMap<String, Vec<f64>> = BTreeMap::new();
let mut members = BTreeMap::new();
let mut streaks = BTreeMap::new();
let mut ups = BTreeSet::new();
let mut downs = 0; let mut touched = 0; let mut broken = 0; let mut paused = 0; let mut unlimited = 0;
for row in &day.rows {
let industry = row.industry.clone().filter(|s| !s.trim().is_empty());
members.insert(row.symbol.clone(), industry.clone());
match row.paused {
Some(true) => {
paused += 1;
streaks.insert(row.symbol.clone(), state.streaks.get(&row.symbol).copied().flatten());
continue;
},
Some(false) => {},
None => return Err(format!("market_event_pause_state_missing: {} {}", day.date, row.symbol)),
}
let c = positive(row.close, &row.symbol, "close")?;
let h = positive(row.high, &row.symbol, "high")?;
let p = positive(row.previous_close, &row.symbol, "previous_close")?;
if h + 1e-8 < c { return Err(format!("market_event_high_below_close: {}", row.symbol)); }
let change = c / p - 1.0;
returns.insert(row.symbol.clone(), change);
if let Some(industry) = industry { groups.entry(industry).or_default().push(change); }
let is_up = match row.no_limit {
Some(true) => { unlimited += 1; false },
Some(false) => {
let upper = positive(row.upper_limit, &row.symbol, "upper_limit")?;
let lower = positive(row.lower_limit, &row.symbol, "lower_limit")?;
if lower >= upper || c > upper + 1e-8 || c < lower - 1e-8 {
return Err(format!("market_event_limit_bounds_invalid: {} {}", day.date, row.symbol));
}
let at_up = (c - upper).abs() <= 1e-8;
if (c - lower).abs() <= 1e-8 { downs += 1; }
if h >= upper - 1e-8 { touched += 1; if !at_up { broken += 1; } }
at_up
},
None => return Err(format!("market_event_limit_policy_missing: {}", row.symbol)),
};
if is_up {
ups.insert(row.symbol.clone());
// The first observed limit-up may already be a continuing streak.
streaks.insert(row.symbol.clone(), state.streaks.get(&row.symbol).copied().flatten().map(|v| v + 1));
} else { streaks.insert(row.symbol.clone(), Some(0)); }
}
let active = returns.len();
if active == 0 { return Err(format!("market_event_no_active_market: {}", day.date)); }
let previous_ups = state.limit_ups.iter().cloned().collect::<Vec<_>>();
let profit_missing = previous_ups.iter().filter(|s| !returns.contains_key(*s)).cloned().collect::<Vec<_>>();
let profit = if previous_ups.is_empty() || !profit_missing.is_empty() { None }
else { Some(average(previous_ups.iter().map(|s| returns[s]), previous_ups.len())) };
let board = if ups.iter().any(|s| streaks[s].is_none()) { None }
else { Some(ups.iter().map(|s| streaks[s].unwrap()).max().unwrap_or(0) as f64) };
let common = BTreeMap::from([
("market_breadth".into(), Some(returns.values().filter(|v| **v > 0.0).count() as f64 / active as f64)),
("market_return".into(), Some(average(returns.values().copied(), active))),
("market_limit_up_count".into(), Some(ups.len() as f64)),
("market_limit_down_count".into(), Some(downs as f64)),
("market_limit_up_rate".into(), (touched > 0).then(|| ups.len() as f64 / touched as f64)),
("market_broken_limit_rate".into(), (touched > 0).then(|| broken as f64 / touched as f64)),
("market_high_board".into(), board),
("market_profit_effect".into(), profit),
]);
let mut industries = BTreeMap::new();
// A disappeared group breaks its continuous history; no stale NAV is carried forward.
state.industry_history.retain(|key, _| groups.contains_key(key));
for (industry, values) in groups {
let history = state.industry_history.entry(industry.clone()).or_default();
let nav = history.last().copied().unwrap_or(1.0) * (1.0 + average(values.iter().copied(), values.len()));
history.push(nav);
if history.len() > 21 { history.remove(0); }
let momentum = (history.len() == 21).then(|| nav / history[0] - 1.0);
industries.insert(industry, BTreeMap::from([
("industry_close".into(), Some(nav)), ("industry_return_20".into(), momentum),
("industry_breadth".into(), Some(values.iter().filter(|v| **v > 0.0).count() as f64 / values.len() as f64)),
]));
}
let universe = industries.keys().cloned().collect::<Vec<_>>();
let known = industries.values().all(|g| g["industry_return_20"].is_some());
let ranks = if known && !universe.is_empty() {
crate::factor_cross_section::evaluate("RANK", &universe, &industries.iter().map(|(s,g)|
crate::factor_cross_section::Observation {symbol:s.clone(), value:g["industry_return_20"].unwrap(),industry:None,market_cap:None}).collect::<Vec<_>>(),0.0)?
.into_iter().map(|r|(r.symbol,r.value)).collect::<BTreeMap<_,_>>()
} else { BTreeMap::new() };
for (name, fields) in &mut industries {
fields.insert("industry_rank".into(), ranks.get(name).copied());
fields.insert("industry_size".into(), Some(universe.len() as f64));
}
let industry_missing=members.iter().filter(|(_,group)|group.is_none()).map(|(s,_)|s.clone()).collect::<Vec<_>>();
if !industry_missing.is_empty() {
// An unclassified member may belong to any group; never silently shrink a group.
state.industry_history.clear();
for fields in industries.values_mut() { for value in fields.values_mut() { *value=None; } }
}
output.push(OutputDay { date:day.date, common, industries, members, securities:day.rows.len(), active, paused,
no_limit:unlimited, profit_effect_members:previous_ups, profit_effect_missing:profit_missing, industry_missing });
state.last_date = Some(day.date); state.streaks = streaks; state.limit_ups = ups;
}
Ok(Output {contract:CONTRACT, days:output, state})
}
#[cfg(test)]
mod tests {
use super::*;
fn day(n: u32, up: bool) -> Day {
Day {date:NaiveDate::from_ymd_opt(2026,9,n).unwrap(), universe:vec!["A".into(),"B".into()], rows:vec![
Observation{symbol:"A".into(),industry:Some("I".into()),close:Some(if up {11.0}else{10.0}),high:Some(11.0),previous_close:Some(10.0),upper_limit:Some(11.0),lower_limit:Some(9.0),no_limit:Some(false),paused:Some(false)},
Observation{symbol:"B".into(),industry:Some("J".into()),close:Some(9.0),high:Some(10.0),previous_close:Some(10.0),upper_limit:Some(11.0),lower_limit:Some(9.0),no_limit:Some(false),paused:Some(false)}]}
}
#[test]
fn formulas_use_real_limits_and_full_denominators() {
let r=aggregate(Request{days:vec![day(1,false),day(2,true),day(3,true)],previous:State::default()}).unwrap();
let d=&r.days[1];
assert_eq!(d.common["market_breadth"],Some(0.5));
assert_eq!(d.common["market_limit_down_count"],Some(1.0));
assert_eq!(d.common["market_limit_up_rate"],Some(1.0));
assert_eq!(r.days[0].common["market_limit_up_rate"],Some(0.0));
assert_eq!(r.days[0].common["market_broken_limit_rate"],Some(1.0));
assert_eq!(r.days[2].common["market_high_board"],Some(2.0));
assert!((r.days[2].common["market_profit_effect"].unwrap()-0.1).abs()<1e-12);
assert_eq!(r.days[0].common["market_profit_effect"],None);
}
#[test]
fn missing_duplicate_and_unproven_limit_states_fail() {
let mut d=day(1,true);d.rows.pop();assert!(aggregate(Request{days:vec![d],previous:State::default()}).is_err());
let mut d=day(1,true);d.rows[0].upper_limit=None;assert!(aggregate(Request{days:vec![d],previous:State::default()}).is_err());
let mut d=day(1,true);d.rows[0].no_limit=Some(true);d.rows[0].upper_limit=None;
assert_eq!(aggregate(Request{days:vec![d],previous:State::default()}).unwrap().days[0].no_limit,1);
}
#[test]
fn chunking_and_future_append_preserve_history() {
let first=aggregate(Request{days:vec![day(1,false),day(2,true)],previous:State::default()}).unwrap();
let next=aggregate(Request{days:vec![day(3,true)],previous:first.state}).unwrap();
let full=aggregate(Request{days:vec![day(1,false),day(2,true),day(3,true)],previous:State::default()}).unwrap();
assert_eq!(serde_json::to_value(&first.days).unwrap(),serde_json::to_value(&full.days[..2]).unwrap());
assert_eq!(serde_json::to_value(&next.days).unwrap(),serde_json::to_value(&full.days[2..]).unwrap());
let unknown=aggregate(Request{days:vec![day(1,true)],previous:State::default()}).unwrap();
assert_eq!(unknown.days[0].common["market_high_board"],None);
}
#[test]
fn missing_industry_does_not_invent_groups_or_disable_independent_market_facts() {
let mut missing=day(2,true);missing.rows[0].industry=None;
let r=aggregate(Request{days:vec![day(1,false),missing,day(3,true)],previous:State::default()}).unwrap();
assert_eq!(r.days[1].common["market_breadth"],Some(0.5));
assert_eq!(r.days[1].industry_missing,vec!["A"]);
assert!(r.days[1].industries.values().flat_map(|g|g.values()).all(Option::is_none));
assert_eq!(r.days[2].industries["I"]["industry_return_20"],None);
}
}