Compare commits

...

6 Commits

8 changed files with 808 additions and 77 deletions
+625
View File
@@ -0,0 +1,625 @@
//! 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<String, Value>,
}
impl PatternSpec {
pub fn validate(mut self) -> Result<Self, String> {
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<f64>,
pub high: Option<f64>,
pub low: Option<f64>,
pub close: Option<f64>,
pub volume: Option<f64>,
pub adjustment_factor_backward1: Option<f64>,
pub paused: Option<bool>,
#[serde(default)]
pub source_path: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PatternSeries {
pub symbol: String,
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub listed_at: Option<NaiveDate>,
pub bars: Vec<PatternBar>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PatternResult {
pub symbol: String,
pub name: Option<String>,
pub matched: bool,
pub score: Option<f64>,
pub checks: Vec<Value>,
pub values: Value,
pub anchor: Value,
pub exclusion: Option<Value>,
}
fn number(v: Option<f64>, symbol: &str, day: NaiveDate, field: &str) -> Result<f64, String> {
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<Value>, 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}));
}
fn mean(mut values: impl ExactSizeIterator<Item = f64>) -> Result<f64, String> {
let count = values.len();
let first = values.next().ok_or("pattern_mean_empty")?;
// Center before summation so an unchanged decimal price stays exactly unchanged.
let result = first
+ values
.map(|value| (value - first) / count as f64)
.sum::<f64>();
if !result.is_finite() {
return Err("pattern_mean_nonfinite".into());
}
Ok(result)
}
/// No calendar compression, fill-forward prices or numerical substitutes.
pub fn evaluate(
spec: &PatternSpec,
days: &[NaiveDate],
series: &PatternSeries,
) -> Result<PatternResult, String> {
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::<BTreeMap<_, _>>();
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 = mean(prices[len - spec.n("fast_window")..].iter().map(|b| b.3))?;
let slow = mean(prices[len - spec.n("slow_window")..].iter().map(|b| b.3))?;
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 = mean(
prices[len - 1 - spec.n("volume_window")..len - 1]
.iter()
.map(|b| b.4),
)?;
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 = mean(prices[len - spec.n("ma_window")..].iter().map(|b| b.3))?;
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<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),
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<Value, String> {
let spec = spec.validate()?;
if series.is_empty()
|| series.len() > 200
|| series
.iter()
.map(|s| &s.symbol)
.collect::<BTreeSet<_>>()
.len()
!= series.len()
{
return Err("pattern_batch_invalid: 需要1至200只唯一证券".into());
}
let rows = series
.iter()
.map(|s| evaluate(&spec, days, s))
.collect::<Result<Vec<_>, _>>()?;
Ok(
json!({"contract":CONTRACT,"spec":spec,"required_history":spec.history_len(),"rows":rows,"read_only":true}),
)
}
pub fn expression_specs(expression: &str) -> Result<Vec<PatternSpec>, 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::<String>();
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<NaiveDate>, 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::<Vec<_>>();
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_flat_decimal_prices_do_not_create_a_sell_signal() {
let (mut spec, _, mut series) = fixture("strength");
spec.template = "ma_below".into();
spec.parameters = BTreeMap::from([("ma_window".into(), json!(60))]);
for bar in &mut series.bars {
bar.open = Some(10.1);
bar.high = Some(10.1);
bar.low = Some(10.1);
bar.close = Some(10.1);
}
let days = series.bars.iter().map(|bar| bar.date).collect::<Vec<_>>();
let result = evaluate(&spec, &days, &series).unwrap();
assert!(
!result.matched,
"unchanged decimal prices must not trigger a below-MA sell: {:?}",
result.checks
);
assert_eq!(result.values["ma"], 10.1);
}
#[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());
}
}
+23 -28
View File
@@ -1850,9 +1850,9 @@ impl DataSet {
.iter()
.enumerate()
.filter_map(|(symbol_id, series)| {
series.as_ref().map(|series| {
(symbol_by_id[symbol_id].to_string(), Arc::clone(series))
})
series
.as_ref()
.map(|series| (symbol_by_id[symbol_id].to_string(), Arc::clone(series)))
})
.collect::<AHashMap<_, _>>();
@@ -1876,9 +1876,9 @@ impl DataSet {
.iter()
.enumerate()
.filter_map(|(symbol_id, series)| {
series.as_ref().map(|series| {
(symbol_by_id[symbol_id].to_string(), Arc::clone(series))
})
series
.as_ref()
.map(|series| (symbol_by_id[symbol_id].to_string(), Arc::clone(series)))
})
.collect::<AHashMap<_, _>>();
let factor_texts = factor_texts
@@ -1900,16 +1900,10 @@ impl DataSet {
let factor_market_cap_order_by_date =
build_factor_market_cap_order(&factor_by_date, &factor_symbol_ids_by_date);
let market_row_positions_by_date = build_dense_row_positions(
&market_by_date,
&market_symbol_ids_by_date,
symbol_count,
);
let factor_row_positions_by_date = build_dense_row_positions(
&factor_by_date,
&factor_symbol_ids_by_date,
symbol_count,
);
let market_row_positions_by_date =
build_dense_row_positions(&market_by_date, &market_symbol_ids_by_date, symbol_count);
let factor_row_positions_by_date =
build_dense_row_positions(&factor_by_date, &factor_symbol_ids_by_date, symbol_count);
let candidate_row_positions_by_date = build_dense_row_positions(
&candidate_by_date,
&candidate_symbol_ids_by_date,
@@ -2321,7 +2315,10 @@ impl DataSet {
}
for (component, strong_count) in [
("daily market panel", Arc::strong_count(&self.market_by_date)),
(
"daily market panel",
Arc::strong_count(&self.market_by_date),
),
(
"market series by symbol",
Arc::strong_count(&self.market_series_by_symbol),
@@ -2456,7 +2453,8 @@ impl DataSet {
.flat_map(|rows_by_symbol| rows_by_symbol.values())
.map(Vec::len)
.sum();
let mut execution_quote_dates = execution_quotes_by_date.keys().copied().collect::<Vec<_>>();
let mut execution_quote_dates =
execution_quotes_by_date.keys().copied().collect::<Vec<_>>();
execution_quote_dates.sort_unstable();
self.execution_quotes_by_date = Arc::new(execution_quotes_by_date);
self.execution_quote_dates = Arc::new(execution_quote_dates);
@@ -5221,10 +5219,7 @@ mod tests {
[data.symbol_id("000001.SZ").unwrap() as usize]
.as_ref()
.unwrap();
assert!(!Arc::ptr_eq(
&market_series_before,
market_series_after
));
assert!(!Arc::ptr_eq(&market_series_before, market_series_after));
assert!(Arc::ptr_eq(&daily_base_before, &market_series_after.base));
assert_eq!(
serde_json::to_value(market_series_after.snapshot_at(0)).unwrap(),
@@ -6178,10 +6173,7 @@ mod tests {
let date = NaiveDate::parse_from_str("2025-01-02", "%Y-%m-%d").unwrap();
let quote = IntradayExecutionQuote {
date,
timestamp: NaiveDateTime::parse_from_str(
"2025-01-02 10:18:00",
"%Y-%m-%d %H:%M:%S",
)
timestamp: NaiveDateTime::parse_from_str("2025-01-02 10:18:00", "%Y-%m-%d %H:%M:%S")
.unwrap(),
symbol: "000001.SZ".to_string(),
last_price: 10.0,
@@ -6365,13 +6357,16 @@ mod tests {
"'adjustment_factor_backward1'",
] {
for typed_value in [None, Some(1.0)] {
assert!(matches!(
assert!(
matches!(
normalize_factor_snapshots(vec![snapshot(
typed_value,
BTreeMap::from([(Cow::Borrowed(field), 2.0)]),
)]),
Err(DataSetError::ReservedTypedFactorInExtraMap { .. })
), "reserved alias accepted: {field}");
),
"reserved alias accepted: {field}"
);
}
}
}
+17 -6
View File
@@ -91,6 +91,9 @@ impl Default for ProcessEventRetention {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DailyEquityPoint {
/// Close-of-signal-day cash baseline before lagged trading begins.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub signal_baseline: bool,
#[serde(with = "date_format")]
pub date: NaiveDate,
pub cash: f64,
@@ -109,6 +112,14 @@ pub struct DailyEquityPoint {
pub diagnostics: String,
}
impl DailyEquityPoint {
pub fn benchmark_reference_close(&self) -> f64 {
if self.signal_baseline { self.benchmark_close }
else if self.benchmark_prev_close.is_finite() && self.benchmark_prev_close > f64::EPSILON { self.benchmark_prev_close }
else { self.benchmark_close }
}
}
#[derive(Debug, Clone)]
pub struct BacktestResult {
pub strategy_name: String,
@@ -334,7 +345,7 @@ impl BacktestResult {
let mut previous_benchmark = self
.equity_curve
.first()
.map(|point| point.benchmark_prev_close)
.map(DailyEquityPoint::benchmark_reference_close)
.unwrap_or_default();
for point in &self.equity_curve {
let point_nav = if point.unit_nav.is_finite() && point.unit_nav > 0.0 {
@@ -454,8 +465,7 @@ pub struct BacktestEngine<S, C, R> {
futures_cost_model: FuturesTransactionCostModel,
futures_validation_config: FuturesValidationConfig,
execution_quote_loader: Option<ExecutionQuoteLoader>,
preplanned_decision_quote_symbols_by_date:
Option<Arc<BTreeMap<NaiveDate, BTreeSet<String>>>>,
preplanned_decision_quote_symbols_by_date: Option<Arc<BTreeMap<NaiveDate, BTreeSet<String>>>>,
execution_quote_request_cache:
BTreeSet<(NaiveDate, String, Option<NaiveTime>, Option<NaiveTime>)>,
risk_free_rate_contract: Option<RiskFreeRateContract>,
@@ -2203,6 +2213,7 @@ where
previous_external_cash_flow_total = portfolio.external_cash_flow_total();
result.equity_curve.push(DailyEquityPoint {
signal_baseline: true,
date: execution_date,
cash: aggregate_cash,
market_value: aggregate_market_value,
@@ -2538,9 +2549,8 @@ where
.map(Arc::clone)
{
let empty_symbols = BTreeSet::new();
let decision_quote_symbols = preplanned
.get(&execution_date)
.unwrap_or(&empty_symbols);
let decision_quote_symbols =
preplanned.get(&execution_date).unwrap_or(&empty_symbols);
self.ensure_execution_quotes_for_symbols_at_times(
execution_date,
decision_quote_symbols,
@@ -3371,6 +3381,7 @@ where
previous_external_cash_flow_total = portfolio.external_cash_flow_total();
result.equity_curve.push(DailyEquityPoint {
signal_baseline: false,
date: execution_date,
cash: aggregate_cash,
market_value: aggregate_market_value,
+1
View File
@@ -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;
+12 -7
View File
@@ -108,13 +108,7 @@ pub fn compute_backtest_metrics(
};
let trade_days = equity_curve.len();
let benchmark_start = if first_point.benchmark_prev_close.is_finite()
&& first_point.benchmark_prev_close > f64::EPSILON
{
first_point.benchmark_prev_close
} else {
first_point.benchmark_close
};
let benchmark_start = first_point.benchmark_reference_close();
let explicit_unit_nav = equity_curve.iter().any(|point| {
point.external_cash_flow.abs() > f64::EPSILON
|| (point.unit_nav.is_finite()
@@ -780,6 +774,7 @@ mod tests {
benchmark_prev_close: f64,
) -> DailyEquityPoint {
DailyEquityPoint {
signal_baseline: false,
date: NaiveDate::parse_from_str(date, "%Y-%m-%d").unwrap(),
cash: total_equity,
market_value: 0.0,
@@ -804,11 +799,21 @@ mod tests {
assert!((metrics.benchmark_cumulative_return - expected).abs() < 1e-12);
}
#[test]
fn signal_baseline_uses_same_close_for_strategy_and_benchmark() {
let mut baseline=equity_point("2026-09-04",100.0,4548.0499,4552.5784);
baseline.signal_baseline=true;
let curve=vec![baseline,equity_point("2026-09-08",104.0,4558.7371,4575.0245)];
let metrics=compute_backtest_metrics(&curve,&[],&[],&[],100.0,None).unwrap();
assert!((metrics.benchmark_cumulative_return-(4558.7371/4548.0499-1.0)).abs()<1e-12);
}
#[test]
fn external_cash_flow_is_excluded_from_return_and_reported_separately() {
let curve = vec![
equity_point("2025-01-02", 100.0, 100.0, 100.0),
DailyEquityPoint {
signal_baseline: false,
date: NaiveDate::from_ymd_opt(2025, 1, 3).unwrap(),
cash: 220.0,
market_value: 0.0,
+95 -27
View File
@@ -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<Option<NaiveDate>>,
pattern_results: RefCell<BTreeMap<(NaiveDate, String, String), crate::daily_patterns::PatternResult>>,
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"
@@ -4254,10 +4261,7 @@ impl PlatformExprStrategy {
.factor_snapshot_rows_on(date)
.iter()
.flat_map(|row| {
row.extra_factors
.keys()
.map(|key| key.to_string())
.chain(
row.extra_factors.keys().map(|key| key.to_string()).chain(
row.adjustment_factor_backward1
.map(|_| BACKWARD_ADJUSTMENT_FACTOR_FIELD.to_string()),
)
@@ -5662,6 +5666,30 @@ impl PlatformExprStrategy {
args: &CompiledRuntimeHelperArgs,
) -> Result<RuntimeHelperResolution, BacktestError> {
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);
// Lagged replay retains the decision day's schedule label; execution is on a later session.
if !ctx.is_lagged_execution() && ctx.active_datetime.is_some_and(|t| t.date() == date && t.time() < NaiveTime::from_hms_opt(16, 0, 0).unwrap()) {
return Err(BacktestError::Execution(format!("daily_pattern_not_yet_visible: 不允许使用未完成的当日日线; decision_date={date}, execution_date={}, active_datetime={:?}",ctx.execution_date,ctx.active_datetime)));
}
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,
@@ -6913,6 +6941,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 {
@@ -6989,8 +7022,8 @@ impl PlatformExprStrategy {
fn numeric_vm_helper_type(helper: &str) -> Option<NumericVmValueType> {
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"
@@ -7132,6 +7165,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(),
@@ -10499,10 +10533,7 @@ impl PlatformExprStrategy {
) -> std::ops::Range<usize> {
if !matches!(
self.config.market_cap_field.as_str(),
"market_cap"
| "market_cap_bn"
| "candidate_market_cap"
| "candidate_market_cap_bn"
"market_cap" | "market_cap_bn" | "candidate_market_cap" | "candidate_market_cap_bn"
) || !band_low.is_finite()
|| !band_high.is_finite()
{
@@ -10520,8 +10551,7 @@ impl PlatformExprStrategy {
};
let start = symbol_ids.partition_point(|symbol_id| market_cap(*symbol_id) < band_low);
let end = start
+ symbol_ids[start..]
.partition_point(|symbol_id| market_cap(*symbol_id) <= band_high);
+ symbol_ids[start..].partition_point(|symbol_id| market_cap(*symbol_id) <= band_high);
start..end
}
@@ -11667,11 +11697,8 @@ impl PlatformExprStrategy {
&execution_day,
&factor_day,
)?;
let field_value = self.selection_field_value_from_caps(
market_cap_bn,
free_float_cap_bn,
&stock,
);
let field_value =
self.selection_field_value_from_caps(market_cap_bn, free_float_cap_bn, &stock);
if !field_value.is_finite() {
if diagnostics.len() < 12 {
diagnostics.push(format!(
@@ -12198,6 +12225,7 @@ impl Strategy for PlatformExprStrategy {
decision.merge_from(rotation?);
}
self.attach_buy_denials(ctx, &mut decision)?;
self.append_pattern_diagnostics(&mut decision);
Ok(decision)
}
@@ -12248,6 +12276,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())
@@ -12256,6 +12285,7 @@ impl Strategy for PlatformExprStrategy {
fn on_day(&mut self, ctx: &StrategyContext<'_>) -> Result<StrategyDecision, BacktestError> {
let mut decision = self.compute_day_decision(ctx)?;
self.attach_buy_denials(ctx, &mut decision)?;
self.append_pattern_diagnostics(&mut decision);
Ok(decision)
}
}
@@ -12294,6 +12324,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<StrategyDecision, BacktestError> {
if self.config.rotation_enabled
&& self
@@ -14109,6 +14148,30 @@ 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::<Vec<_>>();
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,18,0);
assert!(!strategy.eval_bool(&ctx,expression,&day,Some(&stock),None).unwrap());
ctx.execution_date=dates[19];
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);
@@ -16446,15 +16509,16 @@ mod tests {
cfg.market_cap_field = "market_cap".to_string();
let strategy = PlatformExprStrategy::new(cfg.clone());
let range = strategy.market_cap_ordered_selection_range(
&factor_day,
symbol_ids,
10.0,
20.0,
);
let range =
strategy.market_cap_ordered_selection_range(&factor_day, symbol_ids, 10.0, 20.0);
let selected_caps = symbol_ids[range]
.iter()
.map(|symbol_id| factor_day.factor(*symbol_id).expect("factor row").market_cap_bn)
.map(|symbol_id| {
factor_day
.factor(*symbol_id)
.expect("factor row")
.market_cap_bn
})
.collect::<Vec<_>>();
assert_eq!(selected_caps, vec![10.0, 20.0]);
@@ -16465,8 +16529,12 @@ mod tests {
);
cfg.market_cap_field = "free_float_cap".to_string();
assert_eq!(
PlatformExprStrategy::new(cfg)
.market_cap_ordered_selection_range(&factor_day, symbol_ids, 10.0, 20.0),
PlatformExprStrategy::new(cfg).market_cap_ordered_selection_range(
&factor_day,
symbol_ids,
10.0,
20.0
),
0..symbol_ids.len()
);
}
@@ -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",
+24
View File
@@ -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 日的滚动估计并验证样本外表现,不得回填到拟合区间内。
旧任务默认撮合、历史筛选记录和策略源码不变;用户显式创建新规则后才采用此合同。