From 3fe2da3ee0a2c7c6d7882b3024307f136cba19e0 Mon Sep 17 00:00:00 2001 From: boris Date: Wed, 9 Sep 2026 06:41:15 +0800 Subject: [PATCH] =?UTF-8?q?=E7=BB=9F=E4=B8=80=E6=97=A5=E7=BA=BF=E5=BD=A2?= =?UTF-8?q?=E6=80=81=E8=AE=A1=E7=AE=97=E4=B8=8E=E6=AC=A1=E6=97=A5=E5=88=86?= =?UTF-8?q?=E9=98=B6=E6=AE=B5=E4=BF=A1=E5=8F=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/fidc-core/src/daily_patterns.rs | 602 ++++++++++++++++++ crates/fidc-core/src/lib.rs | 1 + .../fidc-core/src/platform_expr_strategy.rs | 76 ++- .../fidc-core/src/platform_runtime_schema.rs | 2 + docs/daily-pattern-signals.md | 24 + 5 files changed, 702 insertions(+), 3 deletions(-) create mode 100644 crates/fidc-core/src/daily_patterns.rs create mode 100644 docs/daily-pattern-signals.md diff --git a/crates/fidc-core/src/daily_patterns.rs b/crates/fidc-core/src/daily_patterns.rs new file mode 100644 index 0000000..80885f7 --- /dev/null +++ b/crates/fidc-core/src/daily_patterns.rs @@ -0,0 +1,602 @@ +//! Completed-session OHLCV rules shared by research and strategy execution. +use crate::DataSet; +use chrono::NaiveDate; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use std::collections::{BTreeMap, BTreeSet}; + +pub const CONTRACT: &str = "fidc_daily_ohlcv_pattern_v1"; + +pub fn catalog() -> Value { + json!({"contract":CONTRACT,"templates":{ + "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日最大量的指定倍数;不等同价格创新高。"}, + "shrink_breakout":{"label":"缩量突破","parameters":{"spike_lookback":[5,2,30],"volume_window":[5,2,60],"volume_multiple":[3.0,1,10],"shrink_ratio":[0.5,0.01,1]},"stages":["selection","buy"],"method":"此前观察窗有放量日,今日收盘超过该日最高价,成交量不超过其指定比例。"}, + "ma_below":{"label":"均线下方","parameters":{"ma_window":[20,2,252]},"stages":["sell"],"method":"完整收盘价低于含当日的N日均线;独立卖出条件。"}, + "volume_down":{"label":"放量下跌","parameters":{"volume_window":[5,2,60],"volume_multiple":[3.0,1,10]},"stages":["sell"],"method":"当日下跌且量达到此前N日最大量的指定倍数。"} + },"data_frequency":"1d","execution_policies":["next_session_open"]}) +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PatternSpec { + pub template: String, + #[serde(default)] + pub parameters: BTreeMap, +} +impl PatternSpec { + pub fn validate(mut self) -> Result { + let catalog = catalog(); + let definition = catalog["templates"] + .get(&self.template) + .ok_or("未登记的量价模板")?; + let parameters = definition["parameters"].as_object().unwrap(); + if self.parameters.keys().any(|k| !parameters.contains_key(k)) { + return Err("模板包含未知参数".into()); + } + for (key, bounds) in parameters { + let value = self.parameters.get(key).unwrap_or(&bounds[0]); + let number = value + .as_f64() + .filter(|v| v.is_finite()) + .ok_or_else(|| format!("{key}必须为有限数值"))?; + if number < bounds[1].as_f64().unwrap() || number > bounds[2].as_f64().unwrap() { + return Err(format!("{key}超出允许范围")); + } + if key.ends_with("window") || key == "spike_lookback" { + if number.fract() != 0.0 { + return Err(format!("{key}必须是整数")); + } + self.parameters.insert(key.clone(), json!(number as usize)); + } else { + self.parameters.insert(key.clone(), json!(number)); + } + } + if self.template == "strength" && self.n("fast_window") >= self.n("slow_window") { + return Err("短均线必须小于长均线".into()); + } + Ok(self) + } + pub fn n(&self, key: &str) -> usize { + self.parameters[key].as_u64().unwrap() as usize + } + pub fn v(&self, key: &str) -> f64 { + self.parameters[key].as_f64().unwrap() + } + pub fn history_len(&self) -> usize { + match self.template.as_str() { + "strength" => self.n("slow_window").max(self.n("momentum_window") + 1), + "breakout" => self.n("high_window").max(self.n("volume_window")) + 1, + "volume_spike" | "volume_down" => self.n("volume_window") + 1, + "ma_below" => self.n("ma_window").max(2), + "shrink_breakout" => self.n("spike_lookback") + self.n("volume_window") + 1, + _ => unreachable!(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PatternBar { + pub date: NaiveDate, + pub open: Option, + pub high: Option, + pub low: Option, + pub close: Option, + pub volume: Option, + pub adjustment_factor_backward1: Option, + pub paused: Option, + #[serde(default)] + pub source_path: Option, +} +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PatternSeries { + pub symbol: String, + #[serde(default)] + pub name: Option, + #[serde(default)] + pub listed_at: Option, + pub bars: Vec, +} +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PatternResult { + pub symbol: String, + pub name: Option, + pub matched: bool, + pub score: Option, + pub checks: Vec, + pub values: Value, + pub anchor: Value, + pub exclusion: Option, +} + +fn number(v: Option, symbol: &str, day: NaiveDate, field: &str) -> Result { + v.filter(|v|v.is_finite()).ok_or_else(||format!("pattern_input_invalid: symbol={symbol}, date={day}, field={field}, reason=missing_or_nonfinite")) +} +fn check(checks: &mut Vec, label: &str, actual: f64, operator: &str, threshold: f64) { + let passed = match operator { + ">" => actual > threshold, + "<" => actual < threshold, + ">=" => actual >= threshold, + "<=" => actual <= threshold, + _ => false, + }; + checks.push(json!({"label":label,"actual":actual,"operator":operator,"threshold":threshold,"passed":passed})); +} + +/// No calendar compression, fill-forward prices or numerical substitutes. +pub fn evaluate( + spec: &PatternSpec, + days: &[NaiveDate], + series: &PatternSeries, +) -> Result { + if days.len() != spec.history_len() || days.windows(2).any(|w| w[0] >= w[1]) { + return Err("pattern_calendar_incomplete: 需要完整、唯一且递增的真实交易日窗口".into()); + } + let by_day = series + .bars + .iter() + .map(|b| (b.date, b)) + .collect::>(); + if by_day.len() != series.bars.len() || series.bars.iter().any(|b| !days.contains(&b.date)) { + return Err(format!( + "pattern_input_invalid: symbol={}, reason=duplicate_or_out_of_scope", + series.symbol + )); + } + let mut unavailable = Vec::new(); + let mut prices = Vec::new(); + for &day in days { + let Some(b) = by_day.get(&day) else { + if series.listed_at.is_some_and(|listed| day < listed) { + unavailable.push( + json!({"date":day,"reason":"before_listing","listed_at":series.listed_at}), + ); + continue; + } + return Err(format!( + "pattern_input_invalid: symbol={}, date={day}, reason=missing_market_row", + series.symbol + )); + }; + let factor = if b.close.is_some_and(|c| c.is_finite() && c > 0.0) { + let factor = number( + b.adjustment_factor_backward1, + &series.symbol, + day, + "adjustment_factor_backward1", + )?; + if factor <= 0.0 { + return Err(format!( + "pattern_input_invalid: symbol={}, date={day}, field=adjustment_factor_backward1, reason=nonpositive", + series.symbol + )); + } + factor + } else { + 1.0 + }; + let paused = b.paused.ok_or_else(|| { + format!( + "pattern_input_invalid: symbol={}, date={day}, field=paused", + series.symbol + ) + })?; + if paused { + unavailable.push( + json!({"date":day,"reason":"confirmed_suspension","source_path":b.source_path}), + ); + continue; + } + if series.listed_at.is_some_and(|listed| day < listed) { + return Err(format!( + "pattern_input_invalid: symbol={}, date={day}, reason=price_before_listing", + series.symbol + )); + } + let o = number(b.open, &series.symbol, day, "open")?; + let h = number(b.high, &series.symbol, day, "high")?; + let l = number(b.low, &series.symbol, day, "low")?; + let c = number(b.close, &series.symbol, day, "close")?; + let v = number(b.volume, &series.symbol, day, "volume")?; + if o <= 0.0 || l <= 0.0 || c <= 0.0 || h < o.max(c) || l > o.min(c) || v < 0.0 { + return Err(format!( + "pattern_input_invalid: symbol={}, date={day}, reason=invalid_ohlcv", + series.symbol + )); + } + prices.push((o * factor, h * factor, l * factor, c * factor, v)); + } + let mut result = PatternResult { + symbol: series.symbol.clone(), + name: series.name.clone(), + matched: false, + score: None, + checks: vec![], + values: json!({}), + anchor: Value::Null, + exclusion: None, + }; + if !unavailable.is_empty() { + result.exclusion = Some( + json!({"reason":"proven_incomplete_window","signal_date":days.last(),"evidence":unavailable}), + ); + return Ok(result); + } + let len = prices.len(); + let (o, h, l, c, v) = prices[len - 1]; + let change = c / prices[len - 2].3 - 1.0; + result.values = json!({"close":by_day[&days[len-1]].close,"daily_return":change}); + result.anchor = json!({"date":days[len-1],"raw_close":by_day[&days[len-1]].close,"factor":by_day[&days[len-1]].adjustment_factor_backward1}); + let mut score = None; + match spec.template.as_str() { + "strength" => { + let fast = prices[len - spec.n("fast_window")..] + .iter() + .map(|b| b.3) + .sum::() + / spec.n("fast_window") as f64; + let slow = prices[len - spec.n("slow_window")..] + .iter() + .map(|b| b.3) + .sum::() + / spec.n("slow_window") as f64; + let momentum = c / prices[len - 1 - spec.n("momentum_window")].3 - 1.0; + score = Some(momentum); + result.values["momentum"] = json!(momentum); + result.values["fast_ma"] = json!(fast); + result.values["slow_ma"] = json!(slow); + check(&mut result.checks, "收盘高于短均线", c, ">", fast); + check(&mut result.checks, "短均线高于长均线", fast, ">", slow); + } + "breakout" => { + let prior_high = prices[len - 1 - spec.n("high_window")..len - 1] + .iter() + .map(|b| b.1) + .fold(f64::NEG_INFINITY, f64::max); + let avg = prices[len - 1 - spec.n("volume_window")..len - 1] + .iter() + .map(|b| b.4) + .sum::() + / spec.n("volume_window") as f64; + if avg <= 0.0 { + return Err(format!( + "pattern_input_invalid: symbol={}, reason=zero_reference_volume", + series.symbol + )); + } + let shadow = if h > l { (h - o.max(c)) / (h - l) } else { 0.0 }; + score = Some(c / prior_high - 1.0); + result.values["volume_ratio"] = json!(v / avg); + result.values["upper_shadow"] = json!(shadow); + check(&mut result.checks, "收盘突破前高", c, ">", prior_high); + check( + &mut result.checks, + "均量倍数", + v / avg, + ">=", + spec.v("volume_multiple"), + ); + check( + &mut result.checks, + "上影比例", + shadow, + "<=", + spec.v("max_upper_shadow"), + ); + } + "volume_spike" | "volume_down" => { + let high = prices[len - 1 - spec.n("volume_window")..len - 1] + .iter() + .map(|b| b.4) + .fold(0.0, f64::max); + if high <= 0.0 { + return Err(format!( + "pattern_input_invalid: symbol={}, reason=zero_reference_volume", + series.symbol + )); + } + score = Some(v / high); + result.values["volume_ratio"] = json!(v / high); + check( + &mut result.checks, + "最大量倍数", + v / high, + ">=", + spec.v("volume_multiple"), + ); + check( + &mut result.checks, + if spec.template == "volume_spike" { + "当日上涨" + } else { + "当日下跌" + }, + change, + if spec.template == "volume_spike" { + ">" + } else { + "<" + }, + 0.0, + ); + } + "ma_below" => { + let avg = prices[len - spec.n("ma_window")..] + .iter() + .map(|b| b.3) + .sum::() + / spec.n("ma_window") as f64; + score = Some(avg / c - 1.0); + result.values["ma"] = json!(avg); + check(&mut result.checks, "收盘低于均线", c, "<", avg); + } + "shrink_breakout" => { + let mut spikes = Vec::new(); + let mut eligible = Vec::new(); + for i in len - 1 - spec.n("spike_lookback")..len - 1 { + let prior = prices[i - spec.n("volume_window")..i] + .iter() + .map(|b| b.4) + .fold(0.0, f64::max); + if prior <= 0.0 { + return Err(format!( + "pattern_input_invalid: symbol={}, date={}, reason=zero_reference_volume", + series.symbol, days[i] + )); + } + if prices[i].4 >= prior * spec.v("volume_multiple") { + spikes.push(i); + if c > prices[i].1 && v <= prices[i].4 * spec.v("shrink_ratio") { + eligible.push(i); + } + } + } + check( + &mut result.checks, + "观察窗存在放量日", + spikes.len() as f64, + ">", + 0.0, + ); + if let Some(&i) = eligible.last().or_else(|| spikes.last()) { + score = Some(c / prices[i].1 - 1.0); + result.values["spike_date"] = json!(days[i]); + result.values["volume_ratio"] = json!(v / prices[i].4); + check( + &mut result.checks, + "收盘突破放量日高点", + c, + ">", + prices[i].1, + ); + check( + &mut result.checks, + "缩量比例", + v / prices[i].4, + "<=", + spec.v("shrink_ratio"), + ); + } + } + _ => unreachable!(), + } + if score.is_some_and(|v| !v.is_finite()) { + return Err("pattern_result_nonfinite".into()); + } + result.score = score; + result.matched = result.checks.iter().all(|c| c["passed"] == true); + Ok(result) +} + +pub fn evaluate_dataset( + spec: &PatternSpec, + data: &DataSet, + date: NaiveDate, + symbol: &str, +) -> Result { + 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), + 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, + }, + ) +} + +pub fn evaluate_batch( + spec: PatternSpec, + days: &[NaiveDate], + series: &[PatternSeries], +) -> Result { + let spec = spec.validate()?; + if series.is_empty() + || series.len() > 200 + || series + .iter() + .map(|s| &s.symbol) + .collect::>() + .len() + != series.len() + { + return Err("pattern_batch_invalid: 需要1至200只唯一证券".into()); + } + let rows = series + .iter() + .map(|s| evaluate(&spec, days, s)) + .collect::, _>>()?; + Ok( + json!({"contract":CONTRACT,"spec":spec,"required_history":spec.history_len(),"rows":rows,"read_only":true}), + ) +} + +pub fn expression_specs(expression: &str) -> Result, String> { + let mut specs = Vec::new(); + for helper in ["pattern_signal", "pattern_score"] { + for (index, _) in expression.match_indices(helper) { + if index > 0 + && expression[..index] + .chars() + .next_back() + .is_some_and(|c| c.is_alphanumeric() || c == '_') + { + continue; + } + let rest = expression[index + helper.len()..].trim_start(); + let Some(rest) = rest.strip_prefix('(') else { + continue; + }; + let rest = rest.trim_start(); + let mut stream = serde_json::Deserializer::from_str(rest).into_iter::(); + let text = stream + .next() + .ok_or("missing pattern JSON")? + .map_err(|e| e.to_string())?; + if !rest[stream.byte_offset()..].trim_start().starts_with(')') { + return Err("pattern helper takes one JSON string".into()); + } + let spec: PatternSpec = serde_json::from_str(&text).map_err(|e| e.to_string())?; + specs.push(spec.validate()?); + } + } + Ok(specs) +} + +#[cfg(test)] +mod tests { + use super::*; + fn fixture(template: &str) -> (PatternSpec, Vec, PatternSeries) { + let spec = PatternSpec { + template: template.into(), + parameters: BTreeMap::new(), + } + .validate() + .unwrap(); + let days = (0..spec.history_len()) + .map(|n| { + NaiveDate::from_ymd_opt(2025, 1, 1).unwrap() + chrono::Duration::days(n as i64) + }) + .collect::>(); + let bars = days + .iter() + .enumerate() + .map(|(n, &date)| { + let c = 10.0 + n as f64; + PatternBar { + date, + open: Some(c), + high: Some(c), + low: Some(c), + close: Some(c), + volume: Some(1000.0), + adjustment_factor_backward1: Some(1.0), + paused: Some(false), + source_path: Some("fixture.parquet".into()), + } + }) + .collect(); + ( + spec, + days, + PatternSeries { + symbol: "000001.SZ".into(), + name: None, + listed_at: Some(NaiveDate::from_ymd_opt(1991, 4, 3).unwrap()), + bars, + }, + ) + } + #[test] + fn daily_patterns_all_templates_and_score_absence() { + for template in [ + "strength", + "breakout", + "volume_spike", + "shrink_breakout", + "ma_below", + "volume_down", + ] { + let (spec, days, series) = fixture(template); + let result = evaluate(&spec, &days, &series).unwrap(); + assert_eq!(result.matched, template == "strength"); + assert_eq!(result.score.is_none(), template == "shrink_breakout"); + } + } + #[test] + fn daily_patterns_adjusts_all_prices_not_volume() { + let (spec, days, series) = fixture("strength"); + let a = evaluate(&spec, &days, &series).unwrap(); + let mut split = series.clone(); + for b in &mut split.bars { + b.open = b.open.map(|p| p / 2.0); + b.high = b.high.map(|p| p / 2.0); + b.low = b.low.map(|p| p / 2.0); + b.close = b.close.map(|p| p / 2.0); + b.adjustment_factor_backward1 = Some(2.0); + } + let b = evaluate(&spec, &days, &split).unwrap(); + assert_eq!(a.score, b.score); + assert_eq!(a.checks, b.checks); + } + #[test] + fn daily_patterns_no_missing_data_fallback() { + let (spec, days, mut series) = fixture("strength"); + series.bars[0].adjustment_factor_backward1 = None; + assert!( + evaluate(&spec, &days, &series) + .unwrap_err() + .contains("adjustment_factor") + ); + series.bars[0].paused = Some(true); + assert!(evaluate(&spec, &days, &series).is_err()); + series.bars[0].adjustment_factor_backward1 = Some(1.0); + let excluded = evaluate(&spec, &days, &series).unwrap(); + assert!(excluded.exclusion.is_some()); + assert!(!excluded.matched); + series.bars.remove(0); + assert!(evaluate(&spec, &days, &series).is_err()); + series.listed_at = Some(days[1]); + assert!(evaluate(&spec, &days, &series).unwrap().exclusion.is_some()); + } + #[test] + fn daily_patterns_rejects_future_and_duplicate_bars() { + let (spec, days, mut series) = fixture("strength"); + series.bars.push(series.bars[0].clone()); + assert!(evaluate(&spec, &days, &series).is_err()); + series.bars.last_mut().unwrap().date = *days.last().unwrap() + chrono::Duration::days(1); + assert!(evaluate(&spec, &days, &series).is_err()); + } + #[test] + fn daily_patterns_helper_literal_preserves_parameters() { + let text = + serde_json::to_string(&json!({"template":"breakout","parameters":{"high_window":252}})) + .unwrap(); + let expression = format!("pattern_signal({})", serde_json::to_string(&text).unwrap()); + assert_eq!(expression_specs(&expression).unwrap()[0].history_len(), 253); + assert!(expression_specs("pattern_signal(\"{}\")").is_err()); + } +} diff --git a/crates/fidc-core/src/lib.rs b/crates/fidc-core/src/lib.rs index aabbffa..7213797 100644 --- a/crates/fidc-core/src/lib.rs +++ b/crates/fidc-core/src/lib.rs @@ -2,6 +2,7 @@ pub mod broker; pub mod calendar; pub mod cost; pub mod data; +pub mod daily_patterns; pub mod engine; pub mod event_bus; pub mod events; diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index 56f3a22..e6b8cb3 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -1267,6 +1267,7 @@ struct RuntimeHelperBinding { #[derive(Clone)] enum CompiledRuntimeHelperArgs { + DailyPattern { spec: crate::daily_patterns::PatternSpec }, RollingMean { field: String, lookback: usize, @@ -1324,6 +1325,8 @@ enum RuntimeHelperResolution { } pub struct PlatformExprStrategy { + pattern_results_date: RefCell>, + pattern_results: RefCell>, config: PlatformExprStrategyConfig, engine: Engine, rebalance_day_counter: usize, @@ -1753,6 +1756,8 @@ impl PlatformExprStrategy { stock_extra_factor_identifiers, stock_extra_factor_map_required, stock_text_factors_required, + pattern_results: RefCell::new(BTreeMap::new()), + pattern_results_date: RefCell::new(None), stock_state_cache_date: RefCell::new(None), stock_state_cache_calendar_index: RefCell::new(None), stock_state_cache: RefCell::new(AHashMap::new()), @@ -2316,7 +2321,9 @@ impl PlatformExprStrategy { fn is_runtime_helper(name: &str) -> bool { matches!( name, - "factor" + "pattern_signal" + | "pattern_score" + | "factor" | "day_factor" | "rolling_mean" | "rolling_mean_current" @@ -5659,6 +5666,29 @@ impl PlatformExprStrategy { args: &CompiledRuntimeHelperArgs, ) -> Result { match args { + CompiledRuntimeHelperArgs::DailyPattern { spec } => { + if self.config.matching_type != MatchingType::NextBarOpen { + return Err(BacktestError::Execution("daily_pattern_requires_next_bar_open: 完整日线形态只能在下一交易日执行".into())); + } + let date = day.date.min(ctx.decision_date); + if ctx.active_datetime.is_some_and(|t| t.date() == date && t.time() < NaiveTime::from_hms_opt(16, 0, 0).unwrap()) { + return Err(BacktestError::Execution("daily_pattern_not_yet_visible: 不允许使用未完成的当日日线".into())); + } + let stock = stock.ok_or_else(|| BacktestError::Execution("pattern_signal requires stock context".into()))?; + let key = (date, stock.symbol.to_string(), serde_json::to_string(spec).unwrap()); + if *self.pattern_results_date.borrow() != Some(date) { + self.pattern_results.borrow_mut().clear(); + *self.pattern_results_date.borrow_mut() = Some(date); + } + if !self.pattern_results.borrow().contains_key(&key) { + let result = crate::daily_patterns::evaluate_dataset(spec,ctx.data,date,&stock.symbol).map_err(BacktestError::Execution)?; + self.pattern_results.borrow_mut().insert(key.clone(),result); + } + let results = self.pattern_results.borrow(); + let result = &results[&key]; + if helper == "pattern_signal" { Ok(RuntimeHelperResolution::Boolean(result.matched)) } + else { result.score.map(RuntimeHelperResolution::Number).ok_or_else(|| BacktestError::Execution(format!("pattern_score_unavailable: {} {date}; 先通过形态筛选再排序",stock.symbol))) } + } CompiledRuntimeHelperArgs::RollingMean { field, lookback, @@ -6910,6 +6940,11 @@ impl PlatformExprStrategy { )) }; match helper { + "pattern_signal" | "pattern_score" if args.len() == 1 => { + let text: String = serde_json::from_str(&args[0]).ok()?; + let spec: crate::daily_patterns::PatternSpec = serde_json::from_str(&text).ok()?; + Some(CompiledRuntimeHelperArgs::DailyPattern { spec: spec.validate().ok()? }) + } "rolling_mean" | "sma" | "ma" => { let (field, lookback) = field_lookback()?; Some(CompiledRuntimeHelperArgs::RollingMean { @@ -6986,8 +7021,8 @@ impl PlatformExprStrategy { fn numeric_vm_helper_type(helper: &str) -> Option { match helper { - "has_dividend" | "has_split" | "is_margin_stock" => Some(NumericVmValueType::Boolean), - "rolling_mean" + "pattern_signal" | "has_dividend" | "has_split" | "is_margin_stock" => Some(NumericVmValueType::Boolean), + "pattern_score" | "rolling_mean" | "sma" | "ma" | "rolling_mean_current" @@ -7129,6 +7164,7 @@ impl PlatformExprStrategy { return self.resolve_compiled_runtime_helper(ctx, day, stock, helper, &compiled_args); } match helper { + "pattern_signal" | "pattern_score" => Err(BacktestError::Execution("daily_pattern_spec_invalid: 需要一个有效的模板 JSON 字符串参数".into())), "factor" => { let key = Self::normalize_runtime_factor_key(&Self::parse_string_or_identifier( args.first().map(String::as_str).unwrap_or_default(), @@ -12188,6 +12224,7 @@ impl Strategy for PlatformExprStrategy { decision.merge_from(rotation?); } self.attach_buy_denials(ctx, &mut decision)?; + self.append_pattern_diagnostics(&mut decision); Ok(decision) } @@ -12238,6 +12275,7 @@ impl Strategy for PlatformExprStrategy { { let mut decision = self.explicit_action_decision(ctx)?; self.attach_buy_denials(ctx, &mut decision)?; + self.append_pattern_diagnostics(&mut decision); return Ok(decision); } Ok(StrategyDecision::default()) @@ -12246,6 +12284,7 @@ impl Strategy for PlatformExprStrategy { fn on_day(&mut self, ctx: &StrategyContext<'_>) -> Result { let mut decision = self.compute_day_decision(ctx)?; self.attach_buy_denials(ctx, &mut decision)?; + self.append_pattern_diagnostics(&mut decision); Ok(decision) } } @@ -12284,6 +12323,15 @@ impl PlatformExprStrategy { Ok(()) } + fn append_pattern_diagnostics(&self, decision: &mut StrategyDecision) { + for ((date, symbol, spec), result) in self.pattern_results.borrow().iter() { + if let Some(evidence) = &result.exclusion { + let record = serde_json::json!({"event":"daily_pattern_excluded","date":date,"symbol":symbol,"spec":spec,"evidence":evidence}).to_string(); + if !decision.diagnostics.contains(&record) { decision.diagnostics.push(record); } + } + } + } + fn compute_day_decision(&mut self, ctx: &StrategyContext<'_>) -> Result { if self.config.rotation_enabled && self @@ -14099,6 +14147,28 @@ mod tests { assert_eq!(strategy.selection_quote_usage, StockFilterQuoteUsage::DailyOnly); } + #[test] + fn daily_pattern_runtime_uses_the_shared_kernel_and_rejects_early_visibility() { + let dates=(0..21).map(|n|d(2025,1,1)+chrono::Duration::days(n)).collect::>(); + let symbol="000001.SZ"; + let mut parts=single_symbol_platform_data(&dates,symbol).snapshot_components(); + for f in &mut parts.factors {f.adjustment_factor_backward1=Some(1.0);} + // Future execution-day prices must not enter the previous day's rule. + let last=parts.market.last_mut().unwrap();last.close=5.0;last.low=5.0;last.last_price=5.0; + let data=DataSet::from_components(parts.instruments,parts.market,parts.factors,parts.candidates,parts.benchmarks).unwrap(); + let portfolio=PortfolioState::new(100_000.0);let subscriptions=BTreeSet::new(); + let mut ctx=StrategyContext {execution_date:dates[20],decision_date:dates[19],decision_index:19,data:&data,portfolio:&portfolio,futures_account:None,open_orders:&[],dynamic_universe:None,subscriptions:&subscriptions,process_events:&[],active_process_event:None,active_datetime:None,order_events:&[],fills:&[]}; + let mut cfg=PlatformExprStrategyConfig::generic();cfg.signal_symbol=symbol.into();cfg.matching_type=MatchingType::NextBarOpen; + let mut strategy=PlatformExprStrategy::new(cfg); + let expression=r#"pattern_signal("{\"template\":\"ma_below\",\"parameters\":{\"ma_window\":20}}")"#; + let day=strategy.day_state(&ctx,dates[19]).unwrap();let stock=strategy.stock_state(&ctx,dates[19],symbol).unwrap(); + assert!(!strategy.eval_bool(&ctx,expression,&day,Some(&stock),None).unwrap()); + ctx.active_datetime=dates[19].and_hms_opt(10,0,0); + assert!(strategy.eval_bool(&ctx,expression,&day,Some(&stock),None).unwrap_err().to_string().contains("not_yet_visible")); + ctx.active_datetime=None;strategy.config.matching_type=MatchingType::CurrentBarClose; + assert!(strategy.eval_bool(&ctx,expression,&day,Some(&stock),None).unwrap_err().to_string().contains("requires_next_bar_open")); + } + #[test] fn buy_quote_filter_rejects_missing_intraday_quote_not_daily_close() { let date = d(2025, 1, 2); diff --git a/crates/fidc-core/src/platform_runtime_schema.rs b/crates/fidc-core/src/platform_runtime_schema.rs index 272328d..c0c4bb9 100644 --- a/crates/fidc-core/src/platform_runtime_schema.rs +++ b/crates/fidc-core/src/platform_runtime_schema.rs @@ -227,6 +227,8 @@ const RUNTIME_HELPER_FUNCTIONS: &[&str] = &[ "factor", "day_factor", "rolling_mean", + "pattern_signal", + "pattern_score", "rolling_mean_current", "rolling_max_current", "rolling_return_stddev_current", diff --git a/docs/daily-pattern-signals.md b/docs/daily-pattern-signals.md new file mode 100644 index 0000000..fb960a9 --- /dev/null +++ b/docs/daily-pattern-signals.md @@ -0,0 +1,24 @@ +# 完成日线形态与次日信号 + +`fidc_daily_ohlcv_pattern_v1` 由 `fidc-core::daily_patterns` 单一计算核实现。Source Lake 只读取、核验及传输真实 OHLCV;研究服务和策略表达式不分别维护数值算法。 + +四种量价条件为趋势强势、前高突破、放量上涨、缩量突破;额外提供独立的均线下方、放量下跌卖出条件。前三者名称不暗示当日金叉或价格突破等未实际检验的事实。 + +## 应用阶段 + +- `filter.stock_expr(pattern_signal("<模板 JSON>"))`:选择候选,再按既有顺序和 Top N 取目标。 +- `filter.buy_expr(pattern_signal("<模板 JSON>"))`:只限制正向仓位增量,不移除目标、不反向清仓,正常减仓不受影响。 +- `risk.stop_loss(pattern_signal("<独立卖出模板 JSON>"))`:独立退出条件,不使用买入条件的反值。 +- `pattern_score` 只可用于已通过形态条件的对象;没有放量参照或合法排除对象不伪造零分。 + +参数是 JSON 字符串,例如 `pattern_signal("{\"template\":\"ma_below\",\"parameters\":{\"ma_window\":20}}")`。 + +新规则必须显式 `execution.matching_type("next_bar_open")`。信号日 D 的完整日线不能用于 D 日盘前或盘中;历史回放按 D 决策、下一真实交易日执行,实时上下文使用已完成 D 日窗口。实际委托仍需要执行日行情、资金、可卖数量、交易许可和风控。不得用研究结果开启交易路由。 + +## 数据与预热 + +所有价格统一用真实 backward1 因子,成交量不复权。缺失、非有限值、无效 OHLC、重复、未来行、未声明停牌状态均拒绝。仅按明确上市日期证明的上市前窗口或正式停牌记录可以返回结构化排除;不补价、不跳过日期压缩窗口。有效价格但缺复权因子即使停牌也报错。回测和运行态须从表达式提取真实窗口需求,冻结完整日历预热。 + +研究选择的范围及日期、上市/停牌排除证据、源查询和哈希需保留。固定候选的后续规则回测不等于历史全市场动态选股。CAPM 全区间拟合属于解释性诊断;要成为次日条件,必须另行使用截至 D 日的滚动估计并验证样本外表现,不得回填到拟合区间内。 + +旧任务默认撮合、历史筛选记录和策略源码不变;用户显式创建新规则后才采用此合同。