统一日线事件上下文并接入完成分钟事件回测

This commit is contained in:
boris
2026-09-09 23:29:54 +08:00
parent 5dc5ef9df5
commit bbbd9cf3e0
8 changed files with 1007 additions and 48 deletions
+57 -36
View File
@@ -10,6 +10,7 @@ pub const CONTRACT: &str = "fidc_daily_ohlcv_pattern_v1";
pub fn catalog() -> Value {
json!({"contract":CONTRACT,"templates":{
"expression":{"label":"指标与事件条件","parameters":{"history_window":[300,2,3000]},"stages":["selection","buy","sell","position_management"],"method":"冻结历史窗口与表达式;预热不足或未定义值不产生信号。复用共享指标事件内核,不修改既有任务。"},
"session_event":{"label":"已完成分钟事件","parameters":{"opening_minutes":[30,1,120],"volume_window":[5,2,120],"volume_multiple":[3.0,1,20]},"stages":["selection","buy","sell"],"method":"仅本交易日完整分钟OHLCVA,信号K线必须早于执行时点;不使用盘口快照伪造K线。"},
"strength":{"label":"趋势强势","parameters":{"momentum_window":[25,5,120],"fast_window":[20,2,60],"slow_window":[60,20,252]},"stages":["selection","buy"],"method":"收盘价>短均线>长均线,按区间动量排序;不是当日金叉。"},
"breakout":{"label":"前高突破","parameters":{"high_window":[60,5,252],"volume_window":[10,2,60],"volume_multiple":[1.3,1,10],"max_upper_shadow":[0.1,0,1]},"stages":["selection","buy"],"method":"收盘突破此前N日最高价,量达到此前M日均量倍数,上影比例受限;参考窗口不含当日。"},
"volume_spike":{"label":"放量上涨","parameters":{"volume_window":[5,2,60],"volume_multiple":[3.0,1,10]},"stages":["selection","buy"],"method":"当日上涨且量达到此前N日最大量的指定倍数;不等同价格创新高。"},
@@ -31,10 +32,27 @@ pub struct PatternSpec {
pub parameters: BTreeMap<String, Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expression: Option<crate::factor_events::Expr>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub execution_context: Option<crate::pattern_context::ExecutionContext>,
#[serde(default,skip_serializing_if="Option::is_none")]
pub session_event:Option<String>,
}
impl PatternSpec {
pub fn validate(self) -> Result<Self, String> {
self.validate_with_context(&[])
if self.template=="session_event" {
if !self.session_event.as_deref().is_some_and(|id|crate::session_events::EVENTS.contains(&id)) || self.execution_context.is_some() {return Err("session_event_contract_invalid".into());}
} else if self.session_event.is_some() {return Err("unexpected_session_event_id".into());}
let allowed = if let Some(context) = &self.execution_context {
context.validate(self.expression.as_ref().ok_or("pattern_context_requires_expression")?)?;
crate::pattern_context::CONTEXT_FIELDS
} else { &[] };
let spec = self.validate_with_context(allowed)?;
if let Some(context) = &spec.execution_context {
if context.rank_universe.len().saturating_mul(spec.history_len()) > 2_000_000 {
return Err("pattern_rank_window_budget_exceeded: 完整截面不得截断".into());
}
}
Ok(spec)
}
fn validate_with_context(mut self, context_fields: &[&str]) -> Result<Self, String> {
if (self.template == "expression") != self.expression.is_some() {
@@ -82,7 +100,7 @@ impl PatternSpec {
if number < bounds[1].as_f64().unwrap() || number > bounds[2].as_f64().unwrap() {
return Err(format!("{key}超出允许范围"));
}
if key.ends_with("window") || key.ends_with("lookback") || key == "anchor_lag" {
if key.ends_with("window") || key.ends_with("lookback") || key == "anchor_lag" || key=="opening_minutes" {
if number.fract() != 0.0 {
return Err(format!("{key}必须是整数"));
}
@@ -104,6 +122,7 @@ impl PatternSpec {
}
pub fn history_len(&self) -> usize {
match self.template.as_str() {
"session_event"=>1,
"expression" => self.n("history_window"),
"strength" => self.n("slow_window").max(self.n("momentum_window") + 1),
"breakout" => self.n("high_window").max(self.n("volume_window")) + 1,
@@ -198,13 +217,14 @@ pub fn evaluate(
evaluate_with_context(spec, days, series, &BTreeMap::new(), false)
}
fn evaluate_with_context(
pub(crate) fn evaluate_with_context(
spec: &PatternSpec,
days: &[NaiveDate],
series: &PatternSeries,
context: &BTreeMap<String, Vec<Option<f64>>>,
numeric_output: bool,
) -> Result<PatternResult, String> {
if spec.template=="session_event" {return Err("session_event_requires_completed_minute_endpoint".into());}
if days.len() != spec.history_len() || days.windows(2).any(|w| w[0] >= w[1]) {
return Err("pattern_calendar_incomplete: 需要完整、唯一且递增的真实交易日窗口".into());
}
@@ -616,39 +636,38 @@ pub fn evaluate_dataset(
date: NaiveDate,
symbol: &str,
) -> Result<PatternResult, String> {
let context = crate::pattern_context::build_dataset_context(spec, data, date)?;
evaluate_dataset_context(spec, data, date, symbol, &context)
}
pub fn dataset_series(data: &DataSet, days: &[NaiveDate], symbol: &str) -> PatternSeries {
let bars = days.iter().filter_map(|&d| data.market(d, symbol).map(|b| PatternBar {
date:d, open:Some(b.open), high:Some(b.high), low:Some(b.low), close:Some(b.close),
volume:Some(b.volume as f64), prev_close:Some(b.prev_close),
amount:data.factor_numeric_value(d,symbol,"amount"),upper_limit:Some(b.upper_limit),
no_limit:data.factor_numeric_value(d,symbol,"no_limit").map(|v|v==1.0),
adjustment_factor_backward1:data.factor(d,symbol).and_then(|f|f.adjustment_factor_backward1),
paused:Some(b.paused), source_path:None,
})).collect();
PatternSeries{symbol:symbol.into(),name:data.instrument(symbol).map(|i|i.name.clone()),
listed_at:data.instrument(symbol).and_then(|i|i.listed_at),bars}
}
pub fn evaluate_dataset_context(
spec: &PatternSpec, data: &DataSet, date: NaiveDate, symbol: &str, context: &ResearchContext,
) -> Result<PatternResult,String> {
let days = data.calendar().trailing_days(date, spec.history_len());
let bars = days
.iter()
.filter_map(|&d| {
data.market(d, symbol).map(|b| PatternBar {
date: d,
open: Some(b.open),
high: Some(b.high),
low: Some(b.low),
close: Some(b.close),
volume: Some(b.volume as f64),
prev_close: data.factor_numeric_value(d, symbol, "pre_close"),
amount: data.factor_numeric_value(d, symbol, "amount"),
upper_limit: Some(b.upper_limit),
no_limit: data.factor_numeric_value(d,symbol,"no_limit").map(|v|v==1.0),
adjustment_factor_backward1: data
.factor(d, symbol)
.and_then(|f| f.adjustment_factor_backward1),
paused: Some(b.paused),
source_path: None,
})
})
.collect();
evaluate(
spec,
&days,
&PatternSeries {
symbol: symbol.into(),
name: None,
listed_at: data.instrument(symbol).and_then(|i| i.listed_at),
bars,
},
)
let mut fields = context.common.clone();
fields.extend(context.by_symbol.get(symbol).cloned().unwrap_or_default());
let outside = spec.execution_context.as_ref().is_some_and(|c| c.rank_expression.is_some() && !c.rank_universe.iter().any(|s|s==symbol));
if outside {
for name in ["scope_rank","scope_percentile"] {fields.insert(name.into(),vec![None;days.len()]);}
fields.insert("scope_size".into(),vec![Some(spec.execution_context.as_ref().unwrap().rank_universe.len() as f64);days.len()]);
}
let mut result = evaluate_with_context(spec,&days,&dataset_series(data,&days,symbol),&fields,false)?;
if outside && result.score.is_none() { result.exclusion=Some(json!({"reason":"outside_frozen_rank_universe","symbol":symbol,"signal_date":date})); }
result.values["execution_context_latest"]=json!(fields.iter().map(|(k,v)|(k,v.last().copied().flatten())).collect::<BTreeMap<_,_>>());
Ok(result)
}
pub fn evaluate_batch(
@@ -677,7 +696,7 @@ pub fn evaluate_batch(
)
}
/// Research transport only. Strategy PatternSpec validation still rejects these fields.
/// Values are supplied only by the verified research transport or dataset context builder.
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ResearchContext {
@@ -868,6 +887,8 @@ mod tests {
template: template.into(),
parameters: BTreeMap::new(),
expression: None,
execution_context: None,
session_event: None,
}
.validate()
.unwrap();