增加共享因子事件表达式与完整截面算子
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
//! Completed-session OHLCV rules shared by research and strategy execution.
|
||||
use crate::DataSet;
|
||||
use chrono::NaiveDate;
|
||||
use chrono::{FixedOffset, NaiveDate, TimeZone};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
@@ -9,6 +9,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":"冻结历史窗口与表达式;预热不足或未定义值不产生信号。复用共享指标事件内核,不修改既有任务。"},
|
||||
"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日最大量的指定倍数;不等同价格创新高。"},
|
||||
@@ -24,9 +25,14 @@ pub struct PatternSpec {
|
||||
pub template: String,
|
||||
#[serde(default)]
|
||||
pub parameters: BTreeMap<String, Value>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub expression: Option<crate::factor_events::Expr>,
|
||||
}
|
||||
impl PatternSpec {
|
||||
pub fn validate(mut self) -> Result<Self, String> {
|
||||
if (self.template == "expression") != self.expression.is_some() {
|
||||
return Err("expression_template_requires_expression_only".into());
|
||||
}
|
||||
let catalog = catalog();
|
||||
let definition = catalog["templates"]
|
||||
.get(&self.template)
|
||||
@@ -66,6 +72,7 @@ impl PatternSpec {
|
||||
}
|
||||
pub fn history_len(&self) -> usize {
|
||||
match self.template.as_str() {
|
||||
"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,
|
||||
"volume_spike" | "volume_down" => self.n("volume_window") + 1,
|
||||
@@ -246,6 +253,80 @@ pub fn evaluate(
|
||||
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() {
|
||||
"expression" => {
|
||||
let zone = FixedOffset::east_opt(8 * 3600).unwrap();
|
||||
let timestamps = days
|
||||
.iter()
|
||||
.map(|d| {
|
||||
zone.from_local_datetime(&d.and_hms_opt(16, 0, 0).unwrap())
|
||||
.single()
|
||||
.unwrap()
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let anchor = by_day[&days[len - 1]].adjustment_factor_backward1.unwrap();
|
||||
let mut fields = BTreeMap::from([
|
||||
(
|
||||
"open".into(),
|
||||
prices.iter().map(|b| Some(b.0 / anchor)).collect(),
|
||||
),
|
||||
(
|
||||
"high".into(),
|
||||
prices.iter().map(|b| Some(b.1 / anchor)).collect(),
|
||||
),
|
||||
(
|
||||
"low".into(),
|
||||
prices.iter().map(|b| Some(b.2 / anchor)).collect(),
|
||||
),
|
||||
(
|
||||
"close".into(),
|
||||
prices.iter().map(|b| Some(b.3 / anchor)).collect(),
|
||||
),
|
||||
("volume".into(), prices.iter().map(|b| Some(b.4)).collect()),
|
||||
]);
|
||||
for (name, index) in [
|
||||
("raw_open", 0),
|
||||
("raw_high", 1),
|
||||
("raw_low", 2),
|
||||
("raw_close", 3),
|
||||
] {
|
||||
fields.insert(
|
||||
name.into(),
|
||||
days.iter()
|
||||
.map(|d| {
|
||||
let b = by_day[d];
|
||||
[b.open, b.high, b.low, b.close][index]
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
}
|
||||
let frame = crate::factor_events::Frame {
|
||||
symbol: series.symbol.clone(),
|
||||
frequency: "1d".into(),
|
||||
decision_at: *timestamps.last().unwrap(),
|
||||
available_at: timestamps.clone(),
|
||||
timestamps,
|
||||
fields,
|
||||
};
|
||||
let values = crate::factor_events::evaluate(spec.expression.as_ref().unwrap(), &frame)?;
|
||||
let latest = values.values.last().copied().flatten();
|
||||
result.values["expression"] = json!(values);
|
||||
result.values["expression_contract"] = json!(crate::factor_events::CONTRACT);
|
||||
result.values["price_policy"] = json!("backward1_anchored_to_decision_close");
|
||||
result.score = latest;
|
||||
if latest.is_none() {
|
||||
result.exclusion = Some(
|
||||
json!({"reason":"expression_undefined_or_warmup","signal_date":days.last()}),
|
||||
);
|
||||
} else if values.value_type == crate::factor_events::ValueType::Boolean {
|
||||
result.matched = latest == Some(1.0);
|
||||
result
|
||||
.checks
|
||||
.push(json!({"label":"组合条件","passed":result.matched}));
|
||||
} else {
|
||||
return Err("expression_signal_requires_boolean: 数值因子必须显式比较或组合,不能自动视为买卖信号".into());
|
||||
}
|
||||
return Ok(result);
|
||||
}
|
||||
"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))?;
|
||||
@@ -491,10 +572,60 @@ pub fn expression_specs(expression: &str) -> Result<Vec<PatternSpec>, String> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn expression_condition_preserves_native_types_and_rejects_numeric_as_signal() {
|
||||
let make = |expression: Value| {
|
||||
serde_json::from_value::<PatternSpec>(json!({"template":"expression","parameters":{"history_window":3},"expression":expression})).unwrap().validate().unwrap()
|
||||
};
|
||||
let spec = make(
|
||||
json!({"kind":"operator","name":"GT","args":[{"kind":"field","name":"close"},{"kind":"indicator","name":"SMA","inputs":[{"kind":"field","name":"close"}],"parameters":{"optInTimePeriod":2}}]}),
|
||||
);
|
||||
let days = ["2026-09-04", "2026-09-07", "2026-09-08"]
|
||||
.map(|d| NaiveDate::parse_from_str(d, "%Y-%m-%d").unwrap());
|
||||
let series = PatternSeries {
|
||||
symbol: "TEST".into(),
|
||||
name: None,
|
||||
listed_at: None,
|
||||
bars: days
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, &date)| {
|
||||
let p = 10.0 + i as f64;
|
||||
PatternBar {
|
||||
date,
|
||||
open: Some(p),
|
||||
high: Some(p),
|
||||
low: Some(p),
|
||||
close: Some(p),
|
||||
volume: Some(100.0),
|
||||
adjustment_factor_backward1: Some(1.0),
|
||||
paused: Some(false),
|
||||
source_path: None,
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
let result = evaluate(&spec, &days, &series).unwrap();
|
||||
assert!(result.matched);
|
||||
assert_eq!(result.score, Some(1.0));
|
||||
assert!(
|
||||
evaluate(
|
||||
&make(json!({"kind":"field","name":"close"})),
|
||||
&days,
|
||||
&series
|
||||
)
|
||||
.unwrap_err()
|
||||
.contains("requires_boolean")
|
||||
);
|
||||
let mut missing = series.clone();
|
||||
missing.bars[1].close = None;
|
||||
assert!(evaluate(&spec, &days, &missing).is_err());
|
||||
}
|
||||
fn fixture(template: &str) -> (PatternSpec, Vec<NaiveDate>, PatternSeries) {
|
||||
let spec = PatternSpec {
|
||||
template: template.into(),
|
||||
parameters: BTreeMap::new(),
|
||||
expression: None,
|
||||
}
|
||||
.validate()
|
||||
.unwrap();
|
||||
|
||||
Reference in New Issue
Block a user