428 lines
16 KiB
Rust
428 lines
16 KiB
Rust
//! Explicit reference identities and frozen rank universes shared by all daily runtimes.
|
|
use crate::{
|
|
daily_patterns::{dataset_series, evaluate_with_context, PatternSpec, ResearchContext},
|
|
factor_events::{field_dependencies, Expr},
|
|
DataSet,
|
|
};
|
|
use chrono::NaiveDate;
|
|
use serde::{Deserialize, Serialize};
|
|
use serde_json::Value;
|
|
use std::collections::{BTreeMap, BTreeSet};
|
|
|
|
pub const CONTRACT: &str = "fidc_pattern_execution_context_v1";
|
|
pub const CONTEXT_FIELDS: &[&str] = &[
|
|
"index_open",
|
|
"index_high",
|
|
"index_low",
|
|
"index_close",
|
|
"scope_rank",
|
|
"scope_percentile",
|
|
"scope_size",
|
|
];
|
|
const STOCK_FIELDS: &[&str] = &[
|
|
"open",
|
|
"high",
|
|
"low",
|
|
"close",
|
|
"volume",
|
|
"raw_open",
|
|
"raw_high",
|
|
"raw_low",
|
|
"raw_close",
|
|
"prev_close",
|
|
"amount",
|
|
];
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct ExecutionContext {
|
|
pub contract: String,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub benchmark: Option<String>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub rank_expression: Option<Expr>,
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
pub rank_universe: Vec<String>,
|
|
}
|
|
|
|
fn valid_symbol(s: &str) -> bool {
|
|
let Some((code, market)) = s.split_once('.') else {
|
|
return false;
|
|
};
|
|
code.len() == 6
|
|
&& code.bytes().all(|c| c.is_ascii_digit())
|
|
&& matches!(market, "SH" | "SZ" | "BJ" | "CSI")
|
|
}
|
|
|
|
impl ExecutionContext {
|
|
pub fn fields(&self, expression: &Expr) -> BTreeSet<String> {
|
|
let mut fields = field_dependencies(expression);
|
|
if let Some(rank) = &self.rank_expression {
|
|
fields.extend(field_dependencies(rank));
|
|
}
|
|
fields
|
|
}
|
|
pub fn validate(&self, expression: &Expr) -> Result<(), String> {
|
|
if self.contract != CONTRACT {
|
|
return Err("pattern_context_contract_invalid".into());
|
|
}
|
|
let needed = field_dependencies(expression);
|
|
let ranked = needed.iter().any(|f| f.starts_with("scope_"));
|
|
if ranked != self.rank_expression.is_some() || !ranked && !self.rank_universe.is_empty() {
|
|
return Err("pattern_rank_expression_and_universe_required".into());
|
|
}
|
|
if ranked
|
|
&& (self.rank_universe.len() < 2
|
|
|| self.rank_universe.len() > 20_000
|
|
|| self.rank_universe.iter().any(|s| !valid_symbol(s))
|
|
|| self.rank_universe.iter().collect::<BTreeSet<_>>().len()
|
|
!= self.rank_universe.len())
|
|
{
|
|
return Err("pattern_rank_universe_invalid".into());
|
|
}
|
|
if let Some(rank) = &self.rank_expression {
|
|
let fields = field_dependencies(rank);
|
|
if fields
|
|
.iter()
|
|
.any(|f| !STOCK_FIELDS.contains(&f.as_str()) && !f.starts_with("index_"))
|
|
{
|
|
return Err("pattern_rank_expression_invalid_or_recursive".into());
|
|
}
|
|
}
|
|
let fields = self.fields(expression);
|
|
if fields
|
|
.iter()
|
|
.any(|f| !STOCK_FIELDS.contains(&f.as_str()) && !CONTEXT_FIELDS.contains(&f.as_str()))
|
|
{
|
|
return Err("pattern_context_unmapped_field".into());
|
|
}
|
|
let index = fields.iter().any(|f| f.starts_with("index_"));
|
|
if index != self.benchmark.is_some()
|
|
|| self
|
|
.benchmark
|
|
.as_ref()
|
|
.is_some_and(|s| !valid_symbol(s) || s.ends_with(".BJ"))
|
|
{
|
|
return Err("pattern_reference_index_required".into());
|
|
}
|
|
if !index && !ranked {
|
|
return Err("pattern_unused_context".into());
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
pub fn build_dataset_context(
|
|
spec: &PatternSpec,
|
|
data: &DataSet,
|
|
date: NaiveDate,
|
|
) -> Result<ResearchContext, String> {
|
|
let Some(config) = &spec.execution_context else {
|
|
return Ok(ResearchContext::default());
|
|
};
|
|
config.validate(
|
|
spec.expression
|
|
.as_ref()
|
|
.ok_or("pattern_context_requires_expression")?,
|
|
)?;
|
|
let days = data.calendar().trailing_days(date, spec.history_len());
|
|
if days.len() != spec.history_len() || days.last() != Some(&date) {
|
|
return Err("pattern_context_calendar_incomplete".into());
|
|
}
|
|
let needed = config.fields(spec.expression.as_ref().unwrap());
|
|
let mut context = ResearchContext::default();
|
|
if let Some(symbol) = &config.benchmark {
|
|
for name in needed.iter().filter(|f| f.starts_with("index_")) {
|
|
let values = days
|
|
.iter()
|
|
.map(|d| {
|
|
let value = if let Some(b) = data.market(*d, symbol) {
|
|
match name.as_str() {
|
|
"index_open" => Some(b.open),
|
|
"index_high" => Some(b.high),
|
|
"index_low" => Some(b.low),
|
|
"index_close" => Some(b.close),
|
|
_ => None,
|
|
}
|
|
} else if let Some(b) = data.benchmark(*d).filter(|b| &b.benchmark == symbol) {
|
|
match name.as_str() {
|
|
"index_open" => Some(b.open),
|
|
"index_close" => Some(b.close),
|
|
_ => None,
|
|
}
|
|
} else {
|
|
None
|
|
};
|
|
value
|
|
.filter(|v| v.is_finite() && *v > 0.0)
|
|
.map(Some)
|
|
.ok_or_else(|| format!("pattern_reference_missing: {symbol} {d} {name}"))
|
|
})
|
|
.collect::<Result<Vec<_>, _>>()?;
|
|
context.common.insert(name.clone(), values);
|
|
}
|
|
}
|
|
if let Some(expression) = &config.rank_expression {
|
|
let mut input = spec.clone();
|
|
input.execution_context = None;
|
|
input.expression = Some(expression.clone());
|
|
let mut values = BTreeMap::new();
|
|
for symbol in &config.rank_universe {
|
|
let row = evaluate_with_context(
|
|
&input,
|
|
&days,
|
|
&dataset_series(data, &days, symbol),
|
|
&context.common,
|
|
true,
|
|
)?;
|
|
if let Some(reason) = row.exclusion {
|
|
return Err(format!("pattern_rank_member_incomplete: {symbol} {reason}"));
|
|
}
|
|
values.insert(
|
|
symbol.clone(),
|
|
serde_json::from_value::<Vec<Option<f64>>>(
|
|
row.values["expression"]["values"].clone(),
|
|
)
|
|
.map_err(|e| e.to_string())?,
|
|
);
|
|
}
|
|
let ranks =
|
|
crate::factor_cross_section::rank_history(&days, &config.rank_universe, &values)?;
|
|
for symbol in &config.rank_universe {
|
|
let decode = |value: &Value| {
|
|
serde_json::from_value::<Vec<Option<f64>>>(value.clone()).map_err(|e| e.to_string())
|
|
};
|
|
context.by_symbol.insert(
|
|
symbol.clone(),
|
|
BTreeMap::from([
|
|
("scope_rank".into(), decode(&ranks["rank"][symbol])?),
|
|
(
|
|
"scope_percentile".into(),
|
|
decode(&ranks["percentile"][symbol])?,
|
|
),
|
|
(
|
|
"scope_size".into(),
|
|
vec![Some(config.rank_universe.len() as f64); days.len()],
|
|
),
|
|
]),
|
|
);
|
|
}
|
|
}
|
|
Ok(context)
|
|
}
|
|
|
|
pub fn specs_in_value(value: &Value) -> Result<Vec<PatternSpec>, String> {
|
|
let mut specs = Vec::new();
|
|
match value {
|
|
Value::String(text) => specs.extend(crate::daily_patterns::expression_specs(text)?),
|
|
Value::Array(items) => {
|
|
for v in items {
|
|
specs.extend(specs_in_value(v)?);
|
|
}
|
|
}
|
|
Value::Object(items) => {
|
|
for v in items.values() {
|
|
specs.extend(specs_in_value(v)?);
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
Ok(specs)
|
|
}
|
|
|
|
pub fn required_symbols(value: &Value) -> Result<(BTreeSet<String>, BTreeSet<String>), String> {
|
|
let (mut indices, mut stocks) = (BTreeSet::new(), BTreeSet::new());
|
|
for spec in specs_in_value(value)? {
|
|
if let Some(context) = spec.execution_context {
|
|
if let Some(index) = context.benchmark {
|
|
indices.insert(index);
|
|
}
|
|
stocks.extend(context.rank_universe);
|
|
}
|
|
}
|
|
Ok((indices, stocks))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::{BenchmarkSnapshot, DailyFactorSnapshot, DailyMarketSnapshot, Instrument};
|
|
use serde_json::json;
|
|
#[test]
|
|
fn normalized_rule_does_not_turn_an_omitted_window_into_explicit_null() {
|
|
let expression:Expr=serde_json::from_value(json!({"kind":"operator","name":"GT","args":[{"kind":"field","name":"close"},{"kind":"number","value":1}]})).unwrap();
|
|
assert!(serde_json::to_value(expression).unwrap().get("window").is_none());
|
|
}
|
|
fn data(future: bool, reference: bool) -> DataSet {
|
|
let mut days = vec![
|
|
NaiveDate::from_ymd_opt(2026, 9, 4).unwrap(),
|
|
NaiveDate::from_ymd_opt(2026, 9, 7).unwrap(),
|
|
NaiveDate::from_ymd_opt(2026, 9, 8).unwrap(),
|
|
];
|
|
if future {
|
|
days.push(NaiveDate::from_ymd_opt(2026, 9, 9).unwrap());
|
|
}
|
|
let symbols = vec!["000001.SZ", "000002.SZ", "000003.SZ"];
|
|
let mut instruments = symbols
|
|
.iter()
|
|
.map(|s| Instrument {
|
|
symbol: s.to_string(),
|
|
name: s.to_string(),
|
|
board: "SZ_MAIN".into(),
|
|
round_lot: 100,
|
|
listed_at: None,
|
|
delisted_at: None,
|
|
status: "active".into(),
|
|
})
|
|
.collect::<Vec<_>>();
|
|
if reference {
|
|
instruments.push(Instrument {
|
|
symbol: "399006.SZ".into(),
|
|
name: "reference".into(),
|
|
board: "INDEX".into(),
|
|
round_lot: 1,
|
|
listed_at: None,
|
|
delisted_at: None,
|
|
status: "active".into(),
|
|
});
|
|
}
|
|
let mut market = vec![];
|
|
let mut factors = vec![];
|
|
let mut benchmark = vec![];
|
|
for (i, d) in days.iter().enumerate() {
|
|
for (n, s) in symbols.iter().enumerate() {
|
|
let c = [
|
|
[10., 12., 11., 1000.],
|
|
[10., 11., 12., 1.],
|
|
[10., 10., 13., 1.],
|
|
][n][i];
|
|
market.push(DailyMarketSnapshot {
|
|
date: *d,
|
|
symbol: s.to_string(),
|
|
timestamp: None,
|
|
day_open: c,
|
|
open: c,
|
|
high: c,
|
|
low: c,
|
|
close: c,
|
|
last_price: c,
|
|
bid1: c,
|
|
ask1: c,
|
|
prev_close: 10.,
|
|
volume: 100000,
|
|
minute_volume: 0,
|
|
bid1_volume: 10000,
|
|
ask1_volume: 10000,
|
|
trading_phase: None,
|
|
paused: false,
|
|
upper_limit: c * 2.,
|
|
lower_limit: c / 2.,
|
|
price_tick: 0.01,
|
|
});
|
|
factors.push(DailyFactorSnapshot {
|
|
date: *d,
|
|
symbol: s.to_string(),
|
|
market_cap_bn: 1.,
|
|
free_float_cap_bn: 1.,
|
|
pe_ttm: 10.,
|
|
turnover_ratio: None,
|
|
effective_turnover_ratio: None,
|
|
adjustment_factor_backward1: Some(1.),
|
|
extra_factors: Default::default(),
|
|
});
|
|
}
|
|
if reference {
|
|
let mut row = market.last().unwrap().clone();
|
|
row.symbol = "399006.SZ".into();
|
|
row.open = 30.;
|
|
row.high = 30.;
|
|
row.low = 30.;
|
|
row.close = 30.;
|
|
market.push(row);
|
|
}
|
|
benchmark.push(BenchmarkSnapshot {
|
|
date: *d,
|
|
benchmark: "000300.SH".into(),
|
|
open: 4000.,
|
|
close: 4000.,
|
|
prev_close: 4000.,
|
|
volume: 1000,
|
|
});
|
|
}
|
|
DataSet::from_components(instruments, market, factors, vec![], benchmark).unwrap()
|
|
}
|
|
fn spec(rank: bool) -> PatternSpec {
|
|
let expression = if rank {
|
|
json!({"kind":"operator","name":"GT","args":[{"kind":"field","name":"scope_rank"},{"kind":"number","value":2}]})
|
|
} else {
|
|
json!({"kind":"operator","name":"LT","args":[{"kind":"field","name":"index_close"},{"kind":"number","value":100}]})
|
|
};
|
|
let context = if rank {
|
|
json!({"contract":CONTRACT,"rank_expression":{"kind":"operator","name":"PCT_CHANGE","window":1,"args":[{"kind":"field","name":"close"}]},"rank_universe":["000001.SZ","000002.SZ","000003.SZ"]})
|
|
} else {
|
|
json!({"contract":CONTRACT,"benchmark":"399006.SZ"})
|
|
};
|
|
serde_json::from_value::<PatternSpec>(json!({"template":"expression","parameters":{"history_window":3},"expression":expression,"execution_context":context})).unwrap().validate().unwrap()
|
|
}
|
|
#[test]
|
|
fn dataset_rank_is_full_scope_causal_and_equal_to_pure_cross_section() {
|
|
let spec = spec(true);
|
|
let date = NaiveDate::from_ymd_opt(2026, 9, 8).unwrap();
|
|
let original = build_dataset_context(&spec, &data(false, true), date).unwrap();
|
|
let future = build_dataset_context(&spec, &data(true, true), date).unwrap();
|
|
assert_eq!(original.by_symbol, future.by_symbol);
|
|
assert_eq!(original.by_symbol["000001.SZ"]["scope_rank"][2], Some(3.));
|
|
assert_eq!(original.by_symbol["000002.SZ"]["scope_rank"][2], Some(2.));
|
|
assert_eq!(original.by_symbol["000003.SZ"]["scope_rank"][2], Some(1.));
|
|
assert!(
|
|
crate::daily_patterns::evaluate_dataset(&spec, &data(false, true), date, "000001.SZ")
|
|
.unwrap()
|
|
.matched
|
|
);
|
|
let mut incomplete = data(false, true).snapshot_components();
|
|
incomplete.market.retain(|r| r.symbol != "000003.SZ");
|
|
let broken = DataSet::from_components(
|
|
incomplete.instruments,
|
|
incomplete.market,
|
|
incomplete.factors,
|
|
incomplete.candidates,
|
|
incomplete.benchmarks,
|
|
)
|
|
.unwrap();
|
|
assert!(build_dataset_context(&spec, &broken, date).is_err());
|
|
}
|
|
#[test]
|
|
fn reference_index_never_defaults_to_performance_benchmark() {
|
|
let spec = spec(false);
|
|
let date = NaiveDate::from_ymd_opt(2026, 9, 8).unwrap();
|
|
assert!(
|
|
crate::daily_patterns::evaluate_dataset(&spec, &data(false, true), date, "000001.SZ")
|
|
.unwrap()
|
|
.matched
|
|
);
|
|
assert!(build_dataset_context(&spec, &data(false, false), date)
|
|
.unwrap_err()
|
|
.contains("399006.SZ"));
|
|
}
|
|
#[test]
|
|
fn runtime_contract_rejects_missing_range_and_recursive_ranks() {
|
|
let mut missing = spec(true);
|
|
missing
|
|
.execution_context
|
|
.as_mut()
|
|
.unwrap()
|
|
.rank_universe
|
|
.clear();
|
|
assert!(missing.validate().is_err());
|
|
let mut recursive = spec(true);
|
|
recursive
|
|
.execution_context
|
|
.as_mut()
|
|
.unwrap()
|
|
.rank_expression = Some(Expr::Field {
|
|
name: "scope_rank".into(),
|
|
});
|
|
assert!(recursive.validate().is_err());
|
|
}
|
|
}
|