统一日线事件上下文并接入完成分钟事件回测
This commit is contained in:
@@ -0,0 +1,422 @@
|
||||
//! 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;
|
||||
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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user