Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b281045df5 | |||
| 35acb1c7e7 | |||
| bbbd9cf3e0 | |||
| 5dc5ef9df5 | |||
| fe8f6c1c26 | |||
| 30e8227099 | |||
| 588da4958f | |||
| bc4754288e | |||
| 6b0cdbcecc | |||
| 5ff05e0d3d | |||
| bab4d47b46 |
Generated
+14
@@ -192,6 +192,7 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2",
|
"sha2",
|
||||||
|
"ta-lib",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -537,6 +538,19 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ta-lib"
|
||||||
|
version = "0.8.1"
|
||||||
|
source = "git+https://github.com/TA-Lib/ta-lib.git?rev=dd5a90259a3f9e04e2da9f38bf0719a841b40108#dd5a90259a3f9e04e2da9f38bf0719a841b40108"
|
||||||
|
dependencies = [
|
||||||
|
"ta-lib-dispatch",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ta-lib-dispatch"
|
||||||
|
version = "0.1.2"
|
||||||
|
source = "git+https://github.com/TA-Lib/ta-lib.git?rev=dd5a90259a3f9e04e2da9f38bf0719a841b40108#dd5a90259a3f9e04e2da9f38bf0719a841b40108"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "thin-vec"
|
name = "thin-vec"
|
||||||
version = "0.2.16"
|
version = "0.2.16"
|
||||||
|
|||||||
@@ -15,3 +15,4 @@ serde.workspace = true
|
|||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
sha2.workspace = true
|
sha2.workspace = true
|
||||||
thiserror.workspace = true
|
thiserror.workspace = true
|
||||||
|
ta-lib = { git = "https://github.com/TA-Lib/ta-lib.git", rev = "dd5a90259a3f9e04e2da9f38bf0719a841b40108" }
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
use fidc_core::factor_events::{self, Expr, Frame};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
use std::io::{self, Read};
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
struct Request {
|
||||||
|
expressions: std::collections::BTreeMap<String, Expr>,
|
||||||
|
frame: Frame,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let mut input = String::new();
|
||||||
|
io::stdin().read_to_string(&mut input)?;
|
||||||
|
let output = if input.trim().is_empty() {
|
||||||
|
factor_events::catalog()
|
||||||
|
} else {
|
||||||
|
let request: Request = serde_json::from_str(&input)?;
|
||||||
|
let results = request
|
||||||
|
.expressions
|
||||||
|
.iter()
|
||||||
|
.map(|(id, expr)| {
|
||||||
|
let result = match factor_events::evaluate(expr, &request.frame) {
|
||||||
|
Ok(v) => json!({"result":v}),
|
||||||
|
Err(e) => json!({"error":e}),
|
||||||
|
};
|
||||||
|
(id.clone(), result)
|
||||||
|
})
|
||||||
|
.collect::<std::collections::BTreeMap<String, Value>>();
|
||||||
|
json!({"contract":factor_events::CONTRACT,"results":results,"read_only":true})
|
||||||
|
};
|
||||||
|
println!("{}", serde_json::to_string(&output)?);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
use std::io::Read;
|
||||||
|
fn main() {
|
||||||
|
let mut input = String::new();
|
||||||
|
std::io::stdin().read_to_string(&mut input).unwrap();
|
||||||
|
let value: serde_json::Value = serde_json::from_str(&input).unwrap();
|
||||||
|
let spec: fidc_core::daily_patterns::PatternSpec =
|
||||||
|
serde_json::from_value(value["spec"].clone()).unwrap();
|
||||||
|
let bars: Vec<fidc_core::session_events::MinuteBar> =
|
||||||
|
serde_json::from_value(value["bars"].clone()).unwrap();
|
||||||
|
let result = fidc_core::session_events::evaluate(
|
||||||
|
&spec.validate().unwrap(),
|
||||||
|
value["symbol"].as_str().unwrap(),
|
||||||
|
&bars,
|
||||||
|
serde_json::from_value(value["decision_at"].clone()).unwrap(),
|
||||||
|
);
|
||||||
|
match result {
|
||||||
|
Ok(row) => println!(
|
||||||
|
"{}",
|
||||||
|
serde_json::json!({"contract":fidc_core::session_events::CONTRACT,"row":row,"read_only":true,"source_evidence_verified":false})
|
||||||
|
),
|
||||||
|
Err(error) => {
|
||||||
|
eprintln!("{error}");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
//! Completed-session OHLCV rules shared by research and strategy execution.
|
//! Completed-session OHLCV rules shared by research and strategy execution.
|
||||||
use crate::DataSet;
|
use crate::DataSet;
|
||||||
use chrono::NaiveDate;
|
use chrono::{FixedOffset, NaiveDate, TimeZone};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
use std::collections::{BTreeMap, BTreeSet};
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
@@ -9,9 +9,15 @@ pub const CONTRACT: &str = "fidc_daily_ohlcv_pattern_v1";
|
|||||||
|
|
||||||
pub fn catalog() -> Value {
|
pub fn catalog() -> Value {
|
||||||
json!({"contract":CONTRACT,"templates":{
|
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":"收盘价>短均线>长均线,按区间动量排序;不是当日金叉。"},
|
"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日均量倍数,上影比例受限;参考窗口不含当日。"},
|
"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日最大量的指定倍数;不等同价格创新高。"},
|
"volume_spike":{"label":"放量上涨","parameters":{"volume_window":[5,2,60],"volume_multiple":[3.0,1,10]},"stages":["selection","buy"],"method":"当日上涨且量达到此前N日最大量的指定倍数;不等同价格创新高。"},
|
||||||
|
"mean_volume_spike":{"label":"均量倍增","parameters":{"volume_window":[5,2,60],"volume_multiple":[3.0,1,10]},"stages":["selection","buy"],"method":"量达到此前N个交易日均量的M倍且当日上涨。分母不含当日;保留与最大量规则的区别。"},
|
||||||
|
"mean_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":"此前出现N日均量M倍放量,当前缩量阳线收盘突破该放量日最高价。"},
|
||||||
|
"breakout_retest":{"label":"突破回踩站回","parameters":{"high_window":[60,5,252],"retest_lookback":[10,2,30],"price_tolerance":[0.02,0,0.2],"shrink_ratio":[0.8,0.01,1]},"stages":["selection","buy"],"method":"观察窗先收盘突破此前N日最高价,随后低点回踩突破位容差区,今日收盘站回该位且不低于昨日、成交量收缩。突破与回踩不得同日。"},
|
||||||
|
"limit_consolidation":{"label":"涨停后整理(日线)","parameters":{"anchor_lag":[4,2,30],"price_band":[0.05,0,0.3],"volume_band":[0.15,0,2],"ma_window":[5,2,60]},"stages":["selection","buy"],"method":"明确T-i日按真实涨停价收盘,后续收盘和量相对锚日偏离受限,今日收盘低于完整日线均线;不是盘中动态MA条件。"},
|
||||||
"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":"此前观察窗有放量日,今日收盘超过该日最高价,成交量不超过其指定比例。"},
|
"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日均线;独立卖出条件。"},
|
"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日最大量的指定倍数。"}
|
"volume_down":{"label":"放量下跌","parameters":{"volume_window":[5,2,60],"volume_multiple":[3.0,1,10]},"stages":["sell"],"method":"当日下跌且量达到此前N日最大量的指定倍数。"}
|
||||||
@@ -24,9 +30,59 @@ pub struct PatternSpec {
|
|||||||
pub template: String,
|
pub template: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub parameters: BTreeMap<String, Value>,
|
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 {
|
impl PatternSpec {
|
||||||
pub fn validate(mut self) -> Result<Self, String> {
|
pub fn validate(self) -> Result<Self, String> {
|
||||||
|
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() {
|
||||||
|
return Err("expression_template_requires_expression_only".into());
|
||||||
|
}
|
||||||
|
if let Some(expr) = &self.expression {
|
||||||
|
let supported = [
|
||||||
|
"open",
|
||||||
|
"high",
|
||||||
|
"low",
|
||||||
|
"close",
|
||||||
|
"volume",
|
||||||
|
"raw_open",
|
||||||
|
"raw_high",
|
||||||
|
"raw_low",
|
||||||
|
"raw_close",
|
||||||
|
"prev_close",
|
||||||
|
"amount",
|
||||||
|
];
|
||||||
|
let missing = crate::factor_events::field_dependencies(expr)
|
||||||
|
.into_iter()
|
||||||
|
.filter(|f| !supported.contains(&f.as_str()) && !context_fields.contains(&f.as_str()))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
if !missing.is_empty() {
|
||||||
|
return Err(format!(
|
||||||
|
"expression_source_mapping_required: {}",
|
||||||
|
missing.join(",")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
let catalog = catalog();
|
let catalog = catalog();
|
||||||
let definition = catalog["templates"]
|
let definition = catalog["templates"]
|
||||||
.get(&self.template)
|
.get(&self.template)
|
||||||
@@ -44,7 +100,7 @@ impl PatternSpec {
|
|||||||
if number < bounds[1].as_f64().unwrap() || number > bounds[2].as_f64().unwrap() {
|
if number < bounds[1].as_f64().unwrap() || number > bounds[2].as_f64().unwrap() {
|
||||||
return Err(format!("{key}超出允许范围"));
|
return Err(format!("{key}超出允许范围"));
|
||||||
}
|
}
|
||||||
if key.ends_with("window") || key == "spike_lookback" {
|
if key.ends_with("window") || key.ends_with("lookback") || key == "anchor_lag" || key=="opening_minutes" {
|
||||||
if number.fract() != 0.0 {
|
if number.fract() != 0.0 {
|
||||||
return Err(format!("{key}必须是整数"));
|
return Err(format!("{key}必须是整数"));
|
||||||
}
|
}
|
||||||
@@ -66,11 +122,15 @@ impl PatternSpec {
|
|||||||
}
|
}
|
||||||
pub fn history_len(&self) -> usize {
|
pub fn history_len(&self) -> usize {
|
||||||
match self.template.as_str() {
|
match self.template.as_str() {
|
||||||
|
"session_event"=>1,
|
||||||
|
"expression" => self.n("history_window"),
|
||||||
"strength" => self.n("slow_window").max(self.n("momentum_window") + 1),
|
"strength" => self.n("slow_window").max(self.n("momentum_window") + 1),
|
||||||
"breakout" => self.n("high_window").max(self.n("volume_window")) + 1,
|
"breakout" => self.n("high_window").max(self.n("volume_window")) + 1,
|
||||||
"volume_spike" | "volume_down" => self.n("volume_window") + 1,
|
"volume_spike" | "volume_down" | "mean_volume_spike" => self.n("volume_window") + 1,
|
||||||
|
"breakout_retest" => self.n("high_window") + self.n("retest_lookback") + 1,
|
||||||
|
"limit_consolidation" => (self.n("anchor_lag")+1).max(self.n("ma_window")),
|
||||||
"ma_below" => self.n("ma_window").max(2),
|
"ma_below" => self.n("ma_window").max(2),
|
||||||
"shrink_breakout" => self.n("spike_lookback") + self.n("volume_window") + 1,
|
"shrink_breakout" | "mean_shrink_breakout" => self.n("spike_lookback") + self.n("volume_window") + 1,
|
||||||
_ => unreachable!(),
|
_ => unreachable!(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -85,6 +145,14 @@ pub struct PatternBar {
|
|||||||
pub low: Option<f64>,
|
pub low: Option<f64>,
|
||||||
pub close: Option<f64>,
|
pub close: Option<f64>,
|
||||||
pub volume: Option<f64>,
|
pub volume: Option<f64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub prev_close: Option<f64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub amount: Option<f64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub upper_limit: Option<f64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub no_limit: Option<bool>,
|
||||||
pub adjustment_factor_backward1: Option<f64>,
|
pub adjustment_factor_backward1: Option<f64>,
|
||||||
pub paused: Option<bool>,
|
pub paused: Option<bool>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -146,6 +214,17 @@ pub fn evaluate(
|
|||||||
days: &[NaiveDate],
|
days: &[NaiveDate],
|
||||||
series: &PatternSeries,
|
series: &PatternSeries,
|
||||||
) -> Result<PatternResult, String> {
|
) -> Result<PatternResult, String> {
|
||||||
|
evaluate_with_context(spec, days, series, &BTreeMap::new(), false)
|
||||||
|
}
|
||||||
|
|
||||||
|
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]) {
|
if days.len() != spec.history_len() || days.windows(2).any(|w| w[0] >= w[1]) {
|
||||||
return Err("pattern_calendar_incomplete: 需要完整、唯一且递增的真实交易日窗口".into());
|
return Err("pattern_calendar_incomplete: 需要完整、唯一且递增的真实交易日窗口".into());
|
||||||
}
|
}
|
||||||
@@ -246,6 +325,124 @@ 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});
|
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;
|
let mut score = None;
|
||||||
match spec.template.as_str() {
|
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 needed =
|
||||||
|
crate::factor_events::field_dependencies(spec.expression.as_ref().unwrap());
|
||||||
|
for name in ["prev_close", "amount"] {
|
||||||
|
if !needed.contains(name) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let values = days
|
||||||
|
.iter()
|
||||||
|
.map(|d| {
|
||||||
|
let b = by_day[d];
|
||||||
|
let value = number(
|
||||||
|
if name == "prev_close" {
|
||||||
|
b.prev_close
|
||||||
|
} else {
|
||||||
|
b.amount
|
||||||
|
},
|
||||||
|
&series.symbol,
|
||||||
|
*d,
|
||||||
|
name,
|
||||||
|
)?;
|
||||||
|
if value < 0.0 || (name == "prev_close" && value == 0.0) {
|
||||||
|
return Err(format!(
|
||||||
|
"pattern_input_invalid: {} {d} {name}",
|
||||||
|
series.symbol
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(Some(value))
|
||||||
|
})
|
||||||
|
.collect::<Result<Vec<_>, String>>()?;
|
||||||
|
fields.insert(name.into(), values);
|
||||||
|
}
|
||||||
|
for (name, values) in context {
|
||||||
|
if fields.contains_key(name) || values.len() != days.len()
|
||||||
|
|| values.iter().flatten().any(|v| !v.is_finite()) {
|
||||||
|
return Err(format!("research_context_invalid: {} {name}", series.symbol));
|
||||||
|
}
|
||||||
|
fields.insert(name.clone(), values.clone());
|
||||||
|
}
|
||||||
|
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 numeric_output {
|
||||||
|
if values.value_type != crate::factor_events::ValueType::Number {
|
||||||
|
return Err("research_rank_input_requires_numeric_expression".into());
|
||||||
|
}
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
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":"组合条件","actual":latest,"operator":"==","threshold":1,"passed":result.matched}));
|
||||||
|
} else {
|
||||||
|
return Err("expression_signal_requires_boolean: 数值因子必须显式比较或组合,不能自动视为买卖信号".into());
|
||||||
|
}
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
"strength" => {
|
"strength" => {
|
||||||
let fast = mean(prices[len - spec.n("fast_window")..].iter().map(|b| b.3))?;
|
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 slow = mean(prices[len - spec.n("slow_window")..].iter().map(|b| b.3))?;
|
||||||
@@ -293,11 +490,12 @@ pub fn evaluate(
|
|||||||
spec.v("max_upper_shadow"),
|
spec.v("max_upper_shadow"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
"volume_spike" | "volume_down" => {
|
"volume_spike" | "volume_down" | "mean_volume_spike" => {
|
||||||
let high = prices[len - 1 - spec.n("volume_window")..len - 1]
|
let reference = &prices[len - 1 - spec.n("volume_window")..len - 1];
|
||||||
|
let high = if spec.template=="mean_volume_spike" {mean(reference.iter().map(|b|b.4))?} else {reference
|
||||||
.iter()
|
.iter()
|
||||||
.map(|b| b.4)
|
.map(|b| b.4)
|
||||||
.fold(0.0, f64::max);
|
.fold(0.0, f64::max)};
|
||||||
if high <= 0.0 {
|
if high <= 0.0 {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"pattern_input_invalid: symbol={}, reason=zero_reference_volume",
|
"pattern_input_invalid: symbol={}, reason=zero_reference_volume",
|
||||||
@@ -308,20 +506,20 @@ pub fn evaluate(
|
|||||||
result.values["volume_ratio"] = json!(v / high);
|
result.values["volume_ratio"] = json!(v / high);
|
||||||
check(
|
check(
|
||||||
&mut result.checks,
|
&mut result.checks,
|
||||||
"最大量倍数",
|
if spec.template=="mean_volume_spike" {"均量倍数"} else {"最大量倍数"},
|
||||||
v / high,
|
v / high,
|
||||||
">=",
|
">=",
|
||||||
spec.v("volume_multiple"),
|
spec.v("volume_multiple"),
|
||||||
);
|
);
|
||||||
check(
|
check(
|
||||||
&mut result.checks,
|
&mut result.checks,
|
||||||
if spec.template == "volume_spike" {
|
if spec.template != "volume_down" {
|
||||||
"当日上涨"
|
"当日上涨"
|
||||||
} else {
|
} else {
|
||||||
"当日下跌"
|
"当日下跌"
|
||||||
},
|
},
|
||||||
change,
|
change,
|
||||||
if spec.template == "volume_spike" {
|
if spec.template != "volume_down" {
|
||||||
">"
|
">"
|
||||||
} else {
|
} else {
|
||||||
"<"
|
"<"
|
||||||
@@ -335,14 +533,15 @@ pub fn evaluate(
|
|||||||
result.values["ma"] = json!(avg);
|
result.values["ma"] = json!(avg);
|
||||||
check(&mut result.checks, "收盘低于均线", c, "<", avg);
|
check(&mut result.checks, "收盘低于均线", c, "<", avg);
|
||||||
}
|
}
|
||||||
"shrink_breakout" => {
|
"shrink_breakout" | "mean_shrink_breakout" => {
|
||||||
let mut spikes = Vec::new();
|
let mut spikes = Vec::new();
|
||||||
let mut eligible = Vec::new();
|
let mut eligible = Vec::new();
|
||||||
for i in len - 1 - spec.n("spike_lookback")..len - 1 {
|
for i in len - 1 - spec.n("spike_lookback")..len - 1 {
|
||||||
let prior = prices[i - spec.n("volume_window")..i]
|
let reference=&prices[i - spec.n("volume_window")..i];
|
||||||
|
let prior = if spec.template=="mean_shrink_breakout"{mean(reference.iter().map(|b|b.4))?}else{reference
|
||||||
.iter()
|
.iter()
|
||||||
.map(|b| b.4)
|
.map(|b| b.4)
|
||||||
.fold(0.0, f64::max);
|
.fold(0.0, f64::max)};
|
||||||
if prior <= 0.0 {
|
if prior <= 0.0 {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"pattern_input_invalid: symbol={}, date={}, reason=zero_reference_volume",
|
"pattern_input_invalid: symbol={}, date={}, reason=zero_reference_volume",
|
||||||
@@ -382,6 +581,44 @@ pub fn evaluate(
|
|||||||
spec.v("shrink_ratio"),
|
spec.v("shrink_ratio"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if spec.template=="mean_shrink_breakout" {check(&mut result.checks,"当前为阳线",c,">",o);}
|
||||||
|
}
|
||||||
|
"breakout_retest" => {
|
||||||
|
let mut anchors=Vec::new();let mut eligible=Vec::new();
|
||||||
|
for i in len-1-spec.n("retest_lookback")..len-1 {
|
||||||
|
let level=prices[i-spec.n("high_window")..i].iter().map(|b|b.1).fold(f64::NEG_INFINITY,f64::max);
|
||||||
|
if prices[i].3<=level {continue;}
|
||||||
|
let retraced=prices[i+1..].iter().any(|b| b.2 <= level*(1.0+spec.v("price_tolerance")));
|
||||||
|
anchors.push((i,level,retraced));
|
||||||
|
if retraced&&c>=level&&c>=prices[len-2].3&&prices[i].4>0.0&&v<=prices[i].4*spec.v("shrink_ratio") {eligible.push((i,level,retraced));}
|
||||||
|
}
|
||||||
|
check(&mut result.checks,"观察窗存在先前突破",anchors.len() as f64,">",0.0);
|
||||||
|
if let Some(&(i,level,retraced))=eligible.last().or_else(||anchors.last()) {
|
||||||
|
result.values["breakout_date"]=json!(days[i]);result.values["breakout_level"]=json!(level);result.values["days_since_breakout"]=json!(len-1-i);
|
||||||
|
check(&mut result.checks,"突破后曾回踩",if retraced{1.0}else{0.0},">",0.0);
|
||||||
|
check(&mut result.checks,"收盘重新站回突破位",c,">=",level);
|
||||||
|
check(&mut result.checks,"收盘不低于昨日",c,">=",prices[len-2].3);
|
||||||
|
if prices[i].4<=0.0{return Err("突破锚日成交量为零,不能计算缩量比例".into());}
|
||||||
|
check(&mut result.checks,"相对突破日缩量",v/prices[i].4,"<=",spec.v("shrink_ratio"));
|
||||||
|
score=Some(c/level-1.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"limit_consolidation" => {
|
||||||
|
let i=len-1-spec.n("anchor_lag");let anchor=by_day[&days[i]];
|
||||||
|
let is_limit=if anchor.no_limit==Some(true){false}else{
|
||||||
|
let upper=number(anchor.upper_limit,&series.symbol,days[i],"upper_limit")?;
|
||||||
|
if upper<=0.0||upper>=99999.0{return Err("涨停事件缺少有效源涨停价或无涨跌幅限制证据,禁止按比例推算".into());}
|
||||||
|
(anchor.close.unwrap()/upper-1.0).abs()<=1e-8
|
||||||
|
};
|
||||||
|
if prices[i].4<=0.0{return Err("涨停锚日成交量为零".into());}
|
||||||
|
let price_gap=prices[i+1..].iter().map(|b|(b.3/prices[i].3-1.0).abs()).fold(0.0,f64::max);
|
||||||
|
let volume_gap=prices[i+1..].iter().map(|b|(b.4/prices[i].4-1.0).abs()).fold(0.0,f64::max);
|
||||||
|
let avg=mean(prices[len-spec.n("ma_window")..].iter().map(|b|b.3))?;
|
||||||
|
result.values["limit_date"]=json!(days[i]);result.values["price_deviation"]=json!(price_gap);result.values["volume_deviation"]=json!(volume_gap);
|
||||||
|
check(&mut result.checks,"锚日真实涨停收盘",if is_limit{1.0}else{0.0},">",0.0);
|
||||||
|
check(&mut result.checks,"后续收盘最大偏离",price_gap,"<=",spec.v("price_band"));
|
||||||
|
check(&mut result.checks,"后续成交量最大偏离",volume_gap,"<=",spec.v("volume_band"));
|
||||||
|
check(&mut result.checks,"收盘低于日线均线",c,"<",avg);score=Some(avg/c-1.0);
|
||||||
}
|
}
|
||||||
_ => unreachable!(),
|
_ => unreachable!(),
|
||||||
}
|
}
|
||||||
@@ -399,35 +636,38 @@ pub fn evaluate_dataset(
|
|||||||
date: NaiveDate,
|
date: NaiveDate,
|
||||||
symbol: &str,
|
symbol: &str,
|
||||||
) -> Result<PatternResult, String> {
|
) -> 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 days = data.calendar().trailing_days(date, spec.history_len());
|
||||||
let bars = days
|
let mut fields = context.common.clone();
|
||||||
.iter()
|
fields.extend(context.by_symbol.get(symbol).cloned().unwrap_or_default());
|
||||||
.filter_map(|&d| {
|
let outside = spec.execution_context.as_ref().is_some_and(|c| c.rank_expression.is_some() && !c.rank_universe.iter().any(|s|s==symbol));
|
||||||
data.market(d, symbol).map(|b| PatternBar {
|
if outside {
|
||||||
date: d,
|
for name in ["scope_rank","scope_percentile"] {fields.insert(name.into(),vec![None;days.len()]);}
|
||||||
open: Some(b.open),
|
fields.insert("scope_size".into(),vec![Some(spec.execution_context.as_ref().unwrap().rank_universe.len() as f64);days.len()]);
|
||||||
high: Some(b.high),
|
}
|
||||||
low: Some(b.low),
|
let mut result = evaluate_with_context(spec,&days,&dataset_series(data,&days,symbol),&fields,false)?;
|
||||||
close: Some(b.close),
|
if outside && result.score.is_none() { result.exclusion=Some(json!({"reason":"outside_frozen_rank_universe","symbol":symbol,"signal_date":date})); }
|
||||||
volume: Some(b.volume as f64),
|
result.values["execution_context_latest"]=json!(fields.iter().map(|(k,v)|(k,v.last().copied().flatten())).collect::<BTreeMap<_,_>>());
|
||||||
adjustment_factor_backward1: data
|
Ok(result)
|
||||||
.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(
|
pub fn evaluate_batch(
|
||||||
@@ -456,6 +696,60 @@ pub fn evaluate_batch(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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 {
|
||||||
|
#[serde(default)]
|
||||||
|
pub common: BTreeMap<String, Vec<Option<f64>>>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub by_symbol: BTreeMap<String, BTreeMap<String, Vec<Option<f64>>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn evaluate_research_batch(
|
||||||
|
spec: PatternSpec, days: &[NaiveDate], series: &[PatternSeries],
|
||||||
|
context: &ResearchContext, numeric_output: bool,
|
||||||
|
) -> Result<Value, String> {
|
||||||
|
let common_fields = ["index_open", "index_high", "index_low", "index_close"];
|
||||||
|
let symbol_fields = ["scope_rank", "scope_percentile", "scope_size"];
|
||||||
|
if spec.template != "expression" || series.is_empty() || series.len() > 200
|
||||||
|
|| series.iter().map(|s| &s.symbol).collect::<BTreeSet<_>>().len() != series.len()
|
||||||
|
|| context.common.keys().any(|k| !common_fields.contains(&k.as_str()))
|
||||||
|
|| context.by_symbol.iter().any(|(s, fields)| !series.iter().any(|row| &row.symbol == s)
|
||||||
|
|| fields.keys().any(|k| !symbol_fields.contains(&k.as_str()))) {
|
||||||
|
return Err("research_context_scope_or_fields_invalid".into());
|
||||||
|
}
|
||||||
|
for (name, values) in &context.common {
|
||||||
|
if values.len() != days.len() || values.iter().any(|v| !v.is_some_and(|x| x.is_finite() && x > 0.0)) {
|
||||||
|
return Err(format!("research_index_window_incomplete: {name}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let allowed = common_fields.into_iter().chain(symbol_fields).collect::<Vec<_>>();
|
||||||
|
let spec = spec.validate_with_context(&allowed)?;
|
||||||
|
let dependencies = crate::factor_events::field_dependencies(spec.expression.as_ref().unwrap());
|
||||||
|
let mut rows = Vec::with_capacity(series.len());
|
||||||
|
for item in series {
|
||||||
|
let mut fields = context.common.clone();
|
||||||
|
fields.extend(context.by_symbol.get(&item.symbol).cloned().unwrap_or_default());
|
||||||
|
if allowed.iter().any(|f| dependencies.contains(*f) && !fields.contains_key(*f)) {
|
||||||
|
return Err(format!("research_context_missing: {}", item.symbol));
|
||||||
|
}
|
||||||
|
for (name, values) in &fields {
|
||||||
|
if values.len() != days.len() || values.iter().flatten().any(|v| !v.is_finite()
|
||||||
|
|| (name == "scope_percentile" && !(0.0..=1.0).contains(v))
|
||||||
|
|| (matches!(name.as_str(), "scope_rank" | "scope_size") && *v < 1.0)) {
|
||||||
|
return Err(format!("research_context_invalid: {} {name}", item.symbol));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut result = evaluate_with_context(&spec, days, item, &fields, numeric_output)?;
|
||||||
|
result.values["research_context_latest"] = json!(fields.iter().map(|(k,v)|(k,v.last().copied().flatten())).collect::<BTreeMap<_,_>>());
|
||||||
|
rows.push(result);
|
||||||
|
}
|
||||||
|
Ok(json!({"contract":CONTRACT,"context_contract":"fidc_research_event_context_v1","spec":spec,
|
||||||
|
"required_history":spec.history_len(),"rows":rows,"read_only":true,
|
||||||
|
"source_evidence_verified":false,"live_routing":false,"rule_backtest_supported":false}))
|
||||||
|
}
|
||||||
|
|
||||||
pub fn expression_specs(expression: &str) -> Result<Vec<PatternSpec>, String> {
|
pub fn expression_specs(expression: &str) -> Result<Vec<PatternSpec>, String> {
|
||||||
let mut specs = Vec::new();
|
let mut specs = Vec::new();
|
||||||
for helper in ["pattern_signal", "pattern_score"] {
|
for helper in ["pattern_signal", "pattern_score"] {
|
||||||
@@ -491,10 +785,110 @@ pub fn expression_specs(expression: &str) -> Result<Vec<PatternSpec>, String> {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
#[test]
|
||||||
|
fn research_index_and_ranking_context_never_unlock_strategy_mapping() {
|
||||||
|
let days=["2026-09-04","2026-09-07","2026-09-08"].map(|s|s.parse::<NaiveDate>().unwrap());
|
||||||
|
let spec:PatternSpec=serde_json::from_value(json!({"template":"expression","parameters":{"history_window":3},
|
||||||
|
"expression":{"kind":"operator","name":"CROSS_ABOVE","args":[{"kind":"field","name":"close"},{"kind":"field","name":"index_close"}]}})).unwrap();
|
||||||
|
assert!(spec.clone().validate().unwrap_err().contains("mapping_required"));
|
||||||
|
let series:PatternSeries=serde_json::from_value(json!({"symbol":"TEST","bars":days.iter().zip([9.0,10.0,11.0]).map(|(d,c)|json!({"date":d,"open":c,"high":c,"low":c,"close":c,"volume":100.0,"adjustment_factor_backward1":1.0,"paused":false})).collect::<Vec<_>>()})).unwrap();
|
||||||
|
let mut context=ResearchContext{common:BTreeMap::from([("index_close".into(),vec![Some(10.0);3])]),..Default::default()};
|
||||||
|
let result=evaluate_research_batch(spec.clone(),&days,&[series.clone()],&context,false).unwrap();
|
||||||
|
assert_eq!(result["rows"][0]["matched"],true);
|
||||||
|
assert_eq!(result["source_evidence_verified"],false);
|
||||||
|
assert_eq!(result["rule_backtest_supported"],false);
|
||||||
|
context.common.get_mut("index_close").unwrap()[1]=None;
|
||||||
|
assert!(evaluate_research_batch(spec.clone(),&days,&[series.clone()],&context,false).unwrap_err().contains("index_window_incomplete"));
|
||||||
|
context.common=BTreeMap::from([("close".into(),vec![Some(10.0);3])]);
|
||||||
|
assert!(evaluate_research_batch(spec,&days,&[series],&context,false).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn research_numeric_output_keeps_warmup_unknown_without_a_false_signal() {
|
||||||
|
let days=["2026-09-04","2026-09-07","2026-09-08"].map(|s|s.parse::<NaiveDate>().unwrap());
|
||||||
|
let spec:PatternSpec=serde_json::from_value(json!({"template":"expression","parameters":{"history_window":3},
|
||||||
|
"expression":{"kind":"operator","name":"PCT_CHANGE","window":2,"args":[{"kind":"field","name":"close"}]}})).unwrap();
|
||||||
|
let series:PatternSeries=serde_json::from_value(json!({"symbol":"TEST","bars":days.iter().zip([10.0,10.5,11.0]).map(|(d,c)|json!({"date":d,"open":c,"high":c,"low":c,"close":c,"volume":100.0,"adjustment_factor_backward1":1.0,"paused":false})).collect::<Vec<_>>()})).unwrap();
|
||||||
|
assert!(evaluate_batch(spec.clone(),&days,&[series.clone()]).is_err());
|
||||||
|
let result=evaluate_research_batch(spec,&days,&[series],&ResearchContext::default(),true).unwrap();
|
||||||
|
let values=&result["rows"][0]["values"]["expression"]["values"];
|
||||||
|
assert!(values[0].is_null() && values[1].is_null());
|
||||||
|
assert!((values[2].as_f64().unwrap()-0.1).abs()<1e-12);
|
||||||
|
assert_eq!(result["rows"][0]["matched"],false);
|
||||||
|
}
|
||||||
|
#[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),
|
||||||
|
prev_close: Some(p - 1.0),
|
||||||
|
amount: Some(p * 100.0),
|
||||||
|
upper_limit: None,
|
||||||
|
no_limit: None,
|
||||||
|
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));
|
||||||
|
let vwap_spec = make(
|
||||||
|
json!({"kind":"operator","name":"GT","args":[{"kind":"operator","name":"DIV","args":[{"kind":"field","name":"amount"},{"kind":"field","name":"volume"}]},{"kind":"field","name":"prev_close"}]}),
|
||||||
|
);
|
||||||
|
assert!(evaluate(&vwap_spec, &days, &series).unwrap().matched);
|
||||||
|
let mut missing_amount = series.clone();
|
||||||
|
missing_amount.bars[1].amount = None;
|
||||||
|
assert!(
|
||||||
|
evaluate(&vwap_spec, &days, &missing_amount)
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("amount")
|
||||||
|
);
|
||||||
|
let mut missing_previous=series.clone();missing_previous.bars[1].prev_close=None;
|
||||||
|
assert!(evaluate(&vwap_spec,&days,&missing_previous).unwrap_err().contains("prev_close"));
|
||||||
|
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) {
|
fn fixture(template: &str) -> (PatternSpec, Vec<NaiveDate>, PatternSeries) {
|
||||||
let spec = PatternSpec {
|
let spec = PatternSpec {
|
||||||
template: template.into(),
|
template: template.into(),
|
||||||
parameters: BTreeMap::new(),
|
parameters: BTreeMap::new(),
|
||||||
|
expression: None,
|
||||||
|
execution_context: None,
|
||||||
|
session_event: None,
|
||||||
}
|
}
|
||||||
.validate()
|
.validate()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -515,6 +909,10 @@ mod tests {
|
|||||||
low: Some(c),
|
low: Some(c),
|
||||||
close: Some(c),
|
close: Some(c),
|
||||||
volume: Some(1000.0),
|
volume: Some(1000.0),
|
||||||
|
prev_close: Some(c - 1.0),
|
||||||
|
amount: Some(c * 1000.0),
|
||||||
|
upper_limit: None,
|
||||||
|
no_limit: None,
|
||||||
adjustment_factor_backward1: Some(1.0),
|
adjustment_factor_backward1: Some(1.0),
|
||||||
paused: Some(false),
|
paused: Some(false),
|
||||||
source_path: Some("fixture.parquet".into()),
|
source_path: Some("fixture.parquet".into()),
|
||||||
@@ -565,6 +963,51 @@ mod tests {
|
|||||||
assert_eq!(a.checks, b.checks);
|
assert_eq!(a.checks, b.checks);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mean_volume_is_not_prior_max_and_excludes_current_bar() {
|
||||||
|
let (spec,days,mut series)=fixture("mean_volume_spike");
|
||||||
|
for (bar,volume) in series.bars.iter_mut().zip([10.,10.,10.,10.,100.,100.]) {bar.volume=Some(volume);}
|
||||||
|
assert!(evaluate(&spec,&days,&series).unwrap().matched);
|
||||||
|
let mut old=spec.clone();old.template="volume_spike".into();
|
||||||
|
assert!(!evaluate(&old,&days,&series).unwrap().matched);
|
||||||
|
assert_eq!(evaluate(&spec,&days,&series).unwrap().values["volume_ratio"],json!(100./28.));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mean_volume_followup_requires_bullish_breakout_and_shrink() {
|
||||||
|
let (spec,days,mut series)=fixture("mean_shrink_breakout");
|
||||||
|
series.bars[6].volume=Some(4000.);
|
||||||
|
let last=series.bars.last_mut().unwrap();last.open=Some(19.);last.low=Some(19.);
|
||||||
|
assert!(evaluate(&spec,&days,&series).unwrap().matched);
|
||||||
|
series.bars.last_mut().unwrap().volume=Some(3000.);
|
||||||
|
assert!(!evaluate(&spec,&days,&series).unwrap().matched);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn breakout_retest_needs_a_later_retest_not_the_breakout_candle_itself() {
|
||||||
|
let (spec,days,mut series)=fixture("breakout_retest");
|
||||||
|
for b in &mut series.bars {b.open=Some(10.);b.high=Some(10.);b.low=Some(10.);b.close=Some(10.);}
|
||||||
|
let anchor=series.bars.len()-11;
|
||||||
|
let b=&mut series.bars[anchor];b.open=Some(11.);b.high=Some(12.1);b.low=Some(9.9);b.close=Some(12.);b.volume=Some(2000.);
|
||||||
|
for b in &mut series.bars[anchor+1..] {b.open=Some(10.3);b.high=Some(10.4);b.low=Some(10.3);b.close=Some(10.4);}
|
||||||
|
assert!(!evaluate(&spec,&days,&series).unwrap().matched);
|
||||||
|
series.bars[anchor+1].low=Some(9.95);
|
||||||
|
assert!(evaluate(&spec,&days,&series).unwrap().matched);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn limit_consolidation_requires_real_limit_and_never_infers_ten_percent() {
|
||||||
|
let (spec,days,mut series)=fixture("limit_consolidation");
|
||||||
|
for (b,c) in series.bars.iter_mut().zip([10.,10.1,10.2,10.1,9.9]) {b.open=Some(c);b.high=Some(c);b.low=Some(c);b.close=Some(c);}
|
||||||
|
assert!(evaluate(&spec,&days,&series).unwrap_err().contains("upper_limit"));
|
||||||
|
series.bars[0].upper_limit=Some(10.);
|
||||||
|
assert!(evaluate(&spec,&days,&series).unwrap().matched);
|
||||||
|
series.bars[0].no_limit=Some(true);
|
||||||
|
assert!(!evaluate(&spec,&days,&series).unwrap().matched);
|
||||||
|
series.bars[0].no_limit=Some(false);series.bars[0].upper_limit=Some(0.);
|
||||||
|
assert!(evaluate(&spec,&days,&series).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn daily_patterns_flat_decimal_prices_do_not_create_a_sell_signal() {
|
fn daily_patterns_flat_decimal_prices_do_not_create_a_sell_signal() {
|
||||||
let (mut spec, _, mut series) = fixture("strength");
|
let (mut spec, _, mut series) = fixture("strength");
|
||||||
|
|||||||
@@ -491,6 +491,7 @@ pub struct DataSetSnapshotComponents {
|
|||||||
pub benchmarks: Vec<BenchmarkSnapshot>,
|
pub benchmarks: Vec<BenchmarkSnapshot>,
|
||||||
pub corporate_actions: Vec<CorporateAction>,
|
pub corporate_actions: Vec<CorporateAction>,
|
||||||
pub execution_quotes: Vec<IntradayExecutionQuote>,
|
pub execution_quotes: Vec<IntradayExecutionQuote>,
|
||||||
|
pub completed_minute_bars: Vec<crate::session_events::MinuteBar>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
@@ -1418,6 +1419,7 @@ pub struct DataSet {
|
|||||||
eligible_universe_by_date: Arc<OnceLock<BTreeMap<NaiveDate, Vec<EligibleUniverseSnapshot>>>>,
|
eligible_universe_by_date: Arc<OnceLock<BTreeMap<NaiveDate, Vec<EligibleUniverseSnapshot>>>>,
|
||||||
benchmark_code: String,
|
benchmark_code: String,
|
||||||
futures_params_by_symbol: Arc<HashMap<String, Vec<FuturesTradingParameter>>>,
|
futures_params_by_symbol: Arc<HashMap<String, Vec<FuturesTradingParameter>>>,
|
||||||
|
completed_minute_bars: Arc<BTreeMap<(NaiveDate,String),Vec<crate::session_events::MinuteBar>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct DailySymbolRows<'a, T> {
|
struct DailySymbolRows<'a, T> {
|
||||||
@@ -1954,6 +1956,7 @@ impl DataSet {
|
|||||||
eligible_universe_by_date: Arc::new(OnceLock::new()),
|
eligible_universe_by_date: Arc::new(OnceLock::new()),
|
||||||
benchmark_code,
|
benchmark_code,
|
||||||
futures_params_by_symbol: Arc::new(futures_params_by_symbol),
|
futures_params_by_symbol: Arc::new(futures_params_by_symbol),
|
||||||
|
completed_minute_bars: Arc::new(BTreeMap::new()),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2627,9 +2630,21 @@ impl DataSet {
|
|||||||
benchmarks,
|
benchmarks,
|
||||||
corporate_actions,
|
corporate_actions,
|
||||||
execution_quotes,
|
execution_quotes,
|
||||||
|
completed_minute_bars:self.completed_minute_bars.values().flatten().cloned().collect(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn with_completed_minute_bars(mut self,bars:Vec<crate::session_events::MinuteBar>)->Result<Self,String> {
|
||||||
|
self.completed_minute_bars=crate::session_events::bar_store(bars)?;Ok(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_shared_completed_minute_bars(mut self,bars:crate::session_events::BarStore)->Self {self.completed_minute_bars=bars;self}
|
||||||
|
pub fn completed_minute_bar_count(&self)->usize {self.completed_minute_bars.values().map(Vec::len).sum()}
|
||||||
|
|
||||||
|
pub fn completed_minute_bars_on(&self,date:NaiveDate,symbol:&str)->&[crate::session_events::MinuteBar] {
|
||||||
|
self.completed_minute_bars.get(&(date,symbol.into())).map(Vec::as_slice).unwrap_or(&[])
|
||||||
|
}
|
||||||
|
|
||||||
pub fn benchmark_series(&self) -> Vec<BenchmarkSnapshot> {
|
pub fn benchmark_series(&self) -> Vec<BenchmarkSnapshot> {
|
||||||
self.benchmark_by_date.values().cloned().collect()
|
self.benchmark_by_date.values().cloned().collect()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,234 @@
|
|||||||
|
//! Cross-sectional operators require an explicit complete universe, never a UI page.
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
|
|
||||||
|
pub const OPERATORS: &[&str] = &[
|
||||||
|
"RANK",
|
||||||
|
"PERCENTILE",
|
||||||
|
"TOP",
|
||||||
|
"BOTTOM",
|
||||||
|
"TOP_PERCENT",
|
||||||
|
"BOTTOM_PERCENT",
|
||||||
|
"WINSORIZE",
|
||||||
|
"INDUSTRY_NEUTRALIZE",
|
||||||
|
"SIZE_NEUTRALIZE",
|
||||||
|
];
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct Observation {
|
||||||
|
pub symbol: String,
|
||||||
|
pub value: f64,
|
||||||
|
pub industry: Option<String>,
|
||||||
|
pub market_cap: Option<f64>,
|
||||||
|
}
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct Output {
|
||||||
|
pub symbol: String,
|
||||||
|
pub value: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every date ranks the same frozen research universe; unknown inputs invalidate the whole date.
|
||||||
|
pub fn rank_history(
|
||||||
|
dates: &[chrono::NaiveDate], universe: &[String], values: &BTreeMap<String, Vec<Option<f64>>>,
|
||||||
|
) -> Result<serde_json::Value, String> {
|
||||||
|
use serde_json::json;
|
||||||
|
if dates.is_empty() || dates.windows(2).any(|w| w[0] >= w[1]) || universe.len() < 2
|
||||||
|
|| universe.len() > 20_000 || dates.len().saturating_mul(universe.len()) > 2_000_000
|
||||||
|
|| universe.iter().collect::<BTreeSet<_>>().len() != universe.len()
|
||||||
|
|| values.keys().collect::<BTreeSet<_>>() != universe.iter().collect::<BTreeSet<_>>()
|
||||||
|
|| values.values().any(|v| v.len() != dates.len() || v.iter().flatten().any(|v| !v.is_finite())) {
|
||||||
|
return Err("research_rank_history_incomplete_or_invalid_universe".into());
|
||||||
|
}
|
||||||
|
let mut rank = universe.iter().map(|s|(s.clone(),vec![None;dates.len()])).collect::<BTreeMap<_,_>>();
|
||||||
|
let mut percentile = rank.clone();
|
||||||
|
let mut unknown_dates = Vec::new();
|
||||||
|
for (i, date) in dates.iter().enumerate() {
|
||||||
|
let missing = universe.iter().filter(|s|values[*s][i].is_none()).collect::<Vec<_>>();
|
||||||
|
if !missing.is_empty() {
|
||||||
|
unknown_dates.push(json!({"date":date,"missing_count":missing.len(),"missing_symbol_sample":missing.iter().take(20).collect::<Vec<_>>(),"sample_limit":20}));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let observations = universe.iter().map(|s|Observation{symbol:s.clone(),value:values[s][i].unwrap(),industry:None,market_cap:None}).collect::<Vec<_>>();
|
||||||
|
for item in evaluate("RANK", universe, &observations, 0.0)? {rank.get_mut(&item.symbol).unwrap()[i]=Some(item.value);}
|
||||||
|
for item in evaluate("PERCENTILE", universe, &observations, 0.0)? {percentile.get_mut(&item.symbol).unwrap()[i]=Some(item.value);}
|
||||||
|
}
|
||||||
|
Ok(json!({"rank":rank,"percentile":percentile,"unknown_dates":unknown_dates,
|
||||||
|
"universe":universe,"dates":dates,"tie_policy":"average_rank_descending",
|
||||||
|
"membership_policy":"fixed_research_scope_not_historical_index_membership"}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mean(values: &[f64]) -> f64 {
|
||||||
|
let base = values[0];
|
||||||
|
base + values
|
||||||
|
.iter()
|
||||||
|
.skip(1)
|
||||||
|
.map(|v| (v - base) / values.len() as f64)
|
||||||
|
.sum::<f64>()
|
||||||
|
}
|
||||||
|
fn quantile(sorted: &[f64], p: f64) -> f64 {
|
||||||
|
let x = p * (sorted.len() - 1) as f64;
|
||||||
|
let l = x.floor() as usize;
|
||||||
|
let r = x.ceil() as usize;
|
||||||
|
sorted[l] + (sorted[r] - sorted[l]) * (x - l as f64)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn evaluate(
|
||||||
|
name: &str,
|
||||||
|
universe: &[String],
|
||||||
|
rows: &[Observation],
|
||||||
|
threshold: f64,
|
||||||
|
) -> Result<Vec<Output>, String> {
|
||||||
|
let expected = universe.iter().collect::<BTreeSet<_>>();
|
||||||
|
if rows.is_empty()
|
||||||
|
|| rows.len() > 20_000
|
||||||
|
|| expected.len() != universe.len()
|
||||||
|
|| rows.len() != universe.len()
|
||||||
|
|| rows.iter().map(|r| &r.symbol).collect::<BTreeSet<_>>() != expected
|
||||||
|
|| rows.iter().any(|r| !r.value.is_finite())
|
||||||
|
{
|
||||||
|
return Err("cross_section_incomplete_or_invalid_universe".into());
|
||||||
|
}
|
||||||
|
if !OPERATORS.contains(&name) || !threshold.is_finite() {
|
||||||
|
return Err("cross_section_operator_invalid".into());
|
||||||
|
}
|
||||||
|
if matches!(name, "TOP" | "BOTTOM") && (threshold < 1.0 || threshold.fract() != 0.0)
|
||||||
|
|| matches!(name, "TOP_PERCENT" | "BOTTOM_PERCENT") && !(0.0..=1.0).contains(&threshold)
|
||||||
|
|| name == "WINSORIZE" && !(0.0..0.5).contains(&threshold)
|
||||||
|
{
|
||||||
|
return Err("cross_section_threshold_invalid".into());
|
||||||
|
}
|
||||||
|
let mut sorted = rows.iter().map(|r| r.value).collect::<Vec<_>>();
|
||||||
|
sorted.sort_by(f64::total_cmp);
|
||||||
|
let mut industry_values: BTreeMap<&str, Vec<f64>> = BTreeMap::new();
|
||||||
|
if name == "INDUSTRY_NEUTRALIZE" {
|
||||||
|
for row in rows {
|
||||||
|
let industry = row
|
||||||
|
.industry
|
||||||
|
.as_deref()
|
||||||
|
.filter(|v| !v.trim().is_empty())
|
||||||
|
.ok_or("cross_section_pit_industry_missing")?;
|
||||||
|
industry_values.entry(industry).or_default().push(row.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let size = if name == "SIZE_NEUTRALIZE" {
|
||||||
|
let x = rows
|
||||||
|
.iter()
|
||||||
|
.map(|r| {
|
||||||
|
r.market_cap
|
||||||
|
.filter(|v| v.is_finite() && *v > 0.0)
|
||||||
|
.map(f64::ln)
|
||||||
|
.ok_or("cross_section_market_cap_missing")
|
||||||
|
})
|
||||||
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
|
let xm = mean(&x);
|
||||||
|
let ym = mean(&sorted);
|
||||||
|
let variance = x.iter().map(|v| (v - xm).powi(2)).sum::<f64>();
|
||||||
|
if variance == 0.0 || rows.len() < 3 {
|
||||||
|
return Err("cross_section_size_regression_unidentified".into());
|
||||||
|
}
|
||||||
|
let beta = x
|
||||||
|
.iter()
|
||||||
|
.zip(rows)
|
||||||
|
.map(|(x, y)| (x - xm) * (y.value - ym))
|
||||||
|
.sum::<f64>()
|
||||||
|
/ variance;
|
||||||
|
Some((x, xm, ym, beta))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
rows.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(index, row)| {
|
||||||
|
let low = sorted.partition_point(|v| *v < row.value);
|
||||||
|
let high = sorted.partition_point(|v| *v <= row.value);
|
||||||
|
let rank = (low + 1 + high) as f64 / 2.0;
|
||||||
|
let descending = (rows.len() + 1) as f64 - rank;
|
||||||
|
let percentile = if rows.len() == 1 {
|
||||||
|
0.5
|
||||||
|
} else {
|
||||||
|
(rank - 1.0) / (rows.len() - 1) as f64
|
||||||
|
};
|
||||||
|
let value = match name {
|
||||||
|
"RANK" => descending,
|
||||||
|
"PERCENTILE" => percentile,
|
||||||
|
"TOP" => f64::from(descending <= threshold),
|
||||||
|
"BOTTOM" => f64::from(rank <= threshold),
|
||||||
|
"TOP_PERCENT" => f64::from(descending <= threshold * rows.len() as f64),
|
||||||
|
"BOTTOM_PERCENT" => f64::from(rank <= threshold * rows.len() as f64),
|
||||||
|
"WINSORIZE" => row.value.clamp(
|
||||||
|
quantile(&sorted, threshold),
|
||||||
|
quantile(&sorted, 1.0 - threshold),
|
||||||
|
),
|
||||||
|
"INDUSTRY_NEUTRALIZE" => {
|
||||||
|
row.value - mean(&industry_values[row.industry.as_deref().unwrap()])
|
||||||
|
}
|
||||||
|
"SIZE_NEUTRALIZE" => {
|
||||||
|
let (x, xm, ym, beta) = size.as_ref().unwrap();
|
||||||
|
row.value - (ym + beta * (x[index] - xm))
|
||||||
|
}
|
||||||
|
_ => unreachable!(),
|
||||||
|
};
|
||||||
|
if !value.is_finite() {
|
||||||
|
return Err("cross_section_result_nonfinite".into());
|
||||||
|
}
|
||||||
|
Ok(Output {
|
||||||
|
symbol: row.symbol.clone(),
|
||||||
|
value,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
#[test]
|
||||||
|
fn historical_ranks_keep_ties_and_unknown_full_cross_sections() {
|
||||||
|
let dates=["2026-09-07","2026-09-08","2026-09-09"].map(|d|d.parse().unwrap());
|
||||||
|
let universe=vec!["A".into(),"B".into(),"C".into()];
|
||||||
|
let values=BTreeMap::from([("A".into(),vec![None,Some(10.0),Some(20.0)]),("B".into(),vec![Some(10.0),Some(10.0),Some(10.0)]),("C".into(),vec![Some(20.0),Some(5.0),Some(15.0)])]);
|
||||||
|
let out=rank_history(&dates,&universe,&values).unwrap();
|
||||||
|
assert_eq!(out["rank"]["A"],serde_json::json!([null,1.5,1.0]));
|
||||||
|
assert_eq!(out["rank"]["C"],serde_json::json!([null,3.0,2.0]));
|
||||||
|
assert_eq!(out["unknown_dates"][0]["missing_count"],1);
|
||||||
|
let earlier=values.iter().map(|(s,v)|(s.clone(),v[..2].to_vec())).collect();
|
||||||
|
let first=rank_history(&dates[..2],&universe,&earlier).unwrap();
|
||||||
|
assert_eq!(&out["rank"]["A"].as_array().unwrap()[..2],first["rank"]["A"].as_array().unwrap());
|
||||||
|
assert!(rank_history(&dates,&universe[..2],&values).is_err());
|
||||||
|
}
|
||||||
|
fn rows() -> Vec<Observation> {
|
||||||
|
[1.0, 3.0, 3.0, 4.0]
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, &value)| Observation {
|
||||||
|
symbol: format!("S{i}"),
|
||||||
|
value,
|
||||||
|
industry: Some(if i < 2 { "A" } else { "B" }.into()),
|
||||||
|
market_cap: Some(10.0 + i as f64),
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn ties_keep_equal_rank_and_missing_universe_rejects() {
|
||||||
|
let r = rows();
|
||||||
|
let u = r.iter().map(|r| r.symbol.clone()).collect::<Vec<_>>();
|
||||||
|
let out = evaluate("RANK", &u, &r, 0.0).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
out.iter().map(|r| r.value).collect::<Vec<_>>(),
|
||||||
|
vec![4.0, 2.5, 2.5, 1.0]
|
||||||
|
);
|
||||||
|
assert!(evaluate("RANK", &u, &r[..3], 0.0).is_err());
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn neutralization_preserves_input_order() {
|
||||||
|
let r = rows();
|
||||||
|
let u = r.iter().map(|r| r.symbol.clone()).collect::<Vec<_>>();
|
||||||
|
let out = evaluate("INDUSTRY_NEUTRALIZE", &u, &r, 0.0).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
out.iter().map(|r| r.value).collect::<Vec<_>>(),
|
||||||
|
vec![-1.0, 1.0, -0.5, 0.5]
|
||||||
|
);
|
||||||
|
assert!(evaluate("TOP_PERCENT", &u, &r, 20.0).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,10 @@ pub mod calendar;
|
|||||||
pub mod cost;
|
pub mod cost;
|
||||||
pub mod data;
|
pub mod data;
|
||||||
pub mod daily_patterns;
|
pub mod daily_patterns;
|
||||||
|
pub mod pattern_context;
|
||||||
|
pub mod session_events;
|
||||||
|
pub mod factor_events;
|
||||||
|
pub mod factor_cross_section;
|
||||||
pub mod engine;
|
pub mod engine;
|
||||||
pub mod event_bus;
|
pub mod event_bus;
|
||||||
pub mod events;
|
pub mod events;
|
||||||
|
|||||||
@@ -0,0 +1,427 @@
|
|||||||
|
//! 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;
|
||||||
|
#[test]
|
||||||
|
fn normalized_rule_does_not_turn_an_omitted_window_into_explicit_null() {
|
||||||
|
let expression:Expr=serde_json::from_value(json!({"kind":"operator","name":"GT","args":[{"kind":"field","name":"close"},{"kind":"number","value":1}]})).unwrap();
|
||||||
|
assert!(serde_json::to_value(expression).unwrap().get("window").is_none());
|
||||||
|
}
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,7 +14,7 @@ use crate::data::EligibleUniverseSnapshot;
|
|||||||
use crate::data::{
|
use crate::data::{
|
||||||
BACKWARD_ADJUSTMENT_FACTOR_FIELD, CandidateEligibility, DailyFactorSnapshot,
|
BACKWARD_ADJUSTMENT_FACTOR_FIELD, CandidateEligibility, DailyFactorSnapshot,
|
||||||
DailyMarketSnapshot, DailySnapshotView, DataSet, PriceField, decision_free_float_cap_bn,
|
DailyMarketSnapshot, DailySnapshotView, DataSet, PriceField, decision_free_float_cap_bn,
|
||||||
NumericFactorMap, decision_market_cap_bn,
|
decision_market_cap_bn,
|
||||||
};
|
};
|
||||||
use crate::engine::BacktestError;
|
use crate::engine::BacktestError;
|
||||||
use crate::events::{OrderSide, ProcessEvent, ProcessEventKind};
|
use crate::events::{OrderSide, ProcessEvent, ProcessEventKind};
|
||||||
@@ -672,6 +672,7 @@ pub struct PlatformExprStrategyConfig {
|
|||||||
pub completed_session_factor_fields: BTreeSet<String>,
|
pub completed_session_factor_fields: BTreeSet<String>,
|
||||||
pub candidate_symbols_by_date: BTreeMap<NaiveDate, BTreeSet<String>>,
|
pub candidate_symbols_by_date: BTreeMap<NaiveDate, BTreeSet<String>>,
|
||||||
pub intraday_execution_time: Option<NaiveTime>,
|
pub intraday_execution_time: Option<NaiveTime>,
|
||||||
|
pub session_event_times: Vec<NaiveTime>,
|
||||||
pub explicit_action_times: Vec<NaiveTime>,
|
pub explicit_action_times: Vec<NaiveTime>,
|
||||||
pub delayed_limit_open_exit_enabled: bool,
|
pub delayed_limit_open_exit_enabled: bool,
|
||||||
pub delayed_limit_open_exit_time: Option<NaiveTime>,
|
pub delayed_limit_open_exit_time: Option<NaiveTime>,
|
||||||
@@ -753,6 +754,7 @@ impl PlatformExprStrategyConfig {
|
|||||||
completed_session_factor_fields: BTreeSet::new(),
|
completed_session_factor_fields: BTreeSet::new(),
|
||||||
candidate_symbols_by_date: BTreeMap::new(),
|
candidate_symbols_by_date: BTreeMap::new(),
|
||||||
intraday_execution_time: None,
|
intraday_execution_time: None,
|
||||||
|
session_event_times: Vec::new(),
|
||||||
explicit_action_times: Vec::new(),
|
explicit_action_times: Vec::new(),
|
||||||
delayed_limit_open_exit_enabled: false,
|
delayed_limit_open_exit_enabled: false,
|
||||||
delayed_limit_open_exit_time: None,
|
delayed_limit_open_exit_time: None,
|
||||||
@@ -974,7 +976,7 @@ struct StockExpressionState {
|
|||||||
stock_volume_ma60: f64,
|
stock_volume_ma60: f64,
|
||||||
stock_volume_ma100: f64,
|
stock_volume_ma100: f64,
|
||||||
current_series_end: Option<usize>,
|
current_series_end: Option<usize>,
|
||||||
extra_factors: NumericFactorMap,
|
extra_factors: BTreeMap<String, f64>,
|
||||||
extra_text_factors: BTreeMap<String, String>,
|
extra_text_factors: BTreeMap<String, String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1287,7 +1289,7 @@ struct RuntimeHelperBinding {
|
|||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
enum CompiledRuntimeHelperArgs {
|
enum CompiledRuntimeHelperArgs {
|
||||||
DailyPattern { spec: crate::daily_patterns::PatternSpec },
|
DailyPattern { spec: crate::daily_patterns::PatternSpec, identity: String },
|
||||||
RollingMean {
|
RollingMean {
|
||||||
field: String,
|
field: String,
|
||||||
lookback: usize,
|
lookback: usize,
|
||||||
@@ -1347,6 +1349,9 @@ enum RuntimeHelperResolution {
|
|||||||
pub struct PlatformExprStrategy {
|
pub struct PlatformExprStrategy {
|
||||||
pattern_results_date: RefCell<Option<NaiveDate>>,
|
pattern_results_date: RefCell<Option<NaiveDate>>,
|
||||||
pattern_results: RefCell<BTreeMap<(NaiveDate, String, String), crate::daily_patterns::PatternResult>>,
|
pattern_results: RefCell<BTreeMap<(NaiveDate, String, String), crate::daily_patterns::PatternResult>>,
|
||||||
|
pattern_contexts: RefCell<BTreeMap<String,crate::daily_patterns::ResearchContext>>,
|
||||||
|
pattern_specs: RefCell<BTreeMap<String,String>>,
|
||||||
|
pattern_frame_at:RefCell<Option<NaiveDateTime>>,
|
||||||
config: PlatformExprStrategyConfig,
|
config: PlatformExprStrategyConfig,
|
||||||
engine: Engine,
|
engine: Engine,
|
||||||
rebalance_day_counter: usize,
|
rebalance_day_counter: usize,
|
||||||
@@ -1796,6 +1801,9 @@ impl PlatformExprStrategy {
|
|||||||
stock_extra_factor_map_required,
|
stock_extra_factor_map_required,
|
||||||
stock_text_factors_required,
|
stock_text_factors_required,
|
||||||
pattern_results: RefCell::new(BTreeMap::new()),
|
pattern_results: RefCell::new(BTreeMap::new()),
|
||||||
|
pattern_contexts: RefCell::new(BTreeMap::new()),
|
||||||
|
pattern_specs: RefCell::new(BTreeMap::new()),
|
||||||
|
pattern_frame_at:RefCell::new(None),
|
||||||
pattern_results_date: RefCell::new(None),
|
pattern_results_date: RefCell::new(None),
|
||||||
stock_state_cache_date: RefCell::new(None),
|
stock_state_cache_date: RefCell::new(None),
|
||||||
stock_state_cache_calendar_index: RefCell::new(None),
|
stock_state_cache_calendar_index: RefCell::new(None),
|
||||||
@@ -4806,7 +4814,7 @@ impl PlatformExprStrategy {
|
|||||||
self.stock_extra_factor_map_required
|
self.stock_extra_factor_map_required
|
||||||
|| self.stock_extra_factor_identifiers.contains(field.as_ref())
|
|| self.stock_extra_factor_identifiers.contains(field.as_ref())
|
||||||
})
|
})
|
||||||
.map(|(field, value)| (field.clone(), *value))
|
.map(|(field, value)| (field.to_string(), *value))
|
||||||
.collect()
|
.collect()
|
||||||
} else {
|
} else {
|
||||||
BTreeMap::new()
|
BTreeMap::new()
|
||||||
@@ -4824,7 +4832,7 @@ impl PlatformExprStrategy {
|
|||||||
.and_then(|row| row.extra_factors.get(field.as_str()))
|
.and_then(|row| row.extra_factors.get(field.as_str()))
|
||||||
.copied()
|
.copied()
|
||||||
.unwrap_or(f64::NAN);
|
.unwrap_or(f64::NAN);
|
||||||
extra_factors.insert(field.clone().into(), value);
|
extra_factors.insert(field.clone(), value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4835,10 +4843,7 @@ impl PlatformExprStrategy {
|
|||||||
.contains(BACKWARD_ADJUSTMENT_FACTOR_FIELD))
|
.contains(BACKWARD_ADJUSTMENT_FACTOR_FIELD))
|
||||||
&& let Some(value) = factor.adjustment_factor_backward1
|
&& let Some(value) = factor.adjustment_factor_backward1
|
||||||
{
|
{
|
||||||
extra_factors.insert(
|
extra_factors.insert(BACKWARD_ADJUSTMENT_FACTOR_FIELD.to_string(), value);
|
||||||
std::borrow::Cow::Borrowed(BACKWARD_ADJUSTMENT_FACTOR_FIELD),
|
|
||||||
value,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let state = StockExpressionState {
|
let state = StockExpressionState {
|
||||||
@@ -5708,23 +5713,48 @@ impl PlatformExprStrategy {
|
|||||||
args: &CompiledRuntimeHelperArgs,
|
args: &CompiledRuntimeHelperArgs,
|
||||||
) -> Result<RuntimeHelperResolution, BacktestError> {
|
) -> Result<RuntimeHelperResolution, BacktestError> {
|
||||||
match args {
|
match args {
|
||||||
CompiledRuntimeHelperArgs::DailyPattern { spec } => {
|
CompiledRuntimeHelperArgs::DailyPattern { spec, identity } => {
|
||||||
if self.config.matching_type != MatchingType::NextBarOpen {
|
if *self.pattern_results_date.borrow()!=Some(ctx.execution_date) {
|
||||||
|
self.pattern_results.borrow_mut().clear();self.pattern_contexts.borrow_mut().clear();self.pattern_specs.borrow_mut().clear();
|
||||||
|
*self.pattern_results_date.borrow_mut()=Some(ctx.execution_date);
|
||||||
|
}
|
||||||
|
if *self.pattern_frame_at.borrow()!=ctx.active_datetime {
|
||||||
|
self.pattern_results.borrow_mut().clear();*self.pattern_frame_at.borrow_mut()=ctx.active_datetime;
|
||||||
|
}
|
||||||
|
if spec.template=="session_event" {
|
||||||
|
if self.config.matching_type!=MatchingType::MinuteLast {return Err(BacktestError::Execution("session_event_requires_minute_last".into()));}
|
||||||
|
let active=ctx.active_datetime.ok_or_else(||BacktestError::Execution("session_event_requires_explicit_clock".into()))?;
|
||||||
|
let clock=active.time();
|
||||||
|
if !((NaiveTime::from_hms_opt(9,31,0).unwrap()<=clock&&clock<=NaiveTime::from_hms_opt(11,30,0).unwrap())||(NaiveTime::from_hms_opt(13,1,0).unwrap()<=clock&&clock<=NaiveTime::from_hms_opt(15,0,0).unwrap())) {
|
||||||
|
return Ok(if helper=="pattern_signal"{RuntimeHelperResolution::Boolean(false)}else{RuntimeHelperResolution::Number(0.)});
|
||||||
|
}
|
||||||
|
let stock=stock.ok_or_else(||BacktestError::Execution("session_event_requires_stock".into()))?;
|
||||||
|
let key=(active.date(),stock.symbol.to_string(),identity.clone());
|
||||||
|
if !self.pattern_results.borrow().contains_key(&key) {
|
||||||
|
let result=crate::session_events::evaluate(spec,&stock.symbol,ctx.data.completed_minute_bars_on(active.date(),&stock.symbol),active).map_err(BacktestError::Execution)?;
|
||||||
|
self.pattern_results.borrow_mut().insert(key.clone(),result);
|
||||||
|
self.pattern_specs.borrow_mut().insert(identity.clone(),serde_json::to_string(spec).unwrap());
|
||||||
|
}
|
||||||
|
let rows=self.pattern_results.borrow();let result=&rows[&key];
|
||||||
|
return if helper=="pattern_signal" {Ok(RuntimeHelperResolution::Boolean(result.matched))}else{result.score.map(RuntimeHelperResolution::Number).ok_or_else(||BacktestError::Execution("session_score_unknown".into()))};
|
||||||
|
}
|
||||||
|
if !matches!(self.config.matching_type,MatchingType::NextBarOpen|MatchingType::MinuteLast) {
|
||||||
return Err(BacktestError::Execution("daily_pattern_requires_next_bar_open: 完整日线形态只能在下一交易日执行".into()));
|
return Err(BacktestError::Execution("daily_pattern_requires_next_bar_open: 完整日线形态只能在下一交易日执行".into()));
|
||||||
}
|
}
|
||||||
let date = day.date.min(ctx.decision_date);
|
let date = if self.config.matching_type==MatchingType::MinuteLast {ctx.data.previous_trading_date(ctx.execution_date,1).ok_or_else(||BacktestError::Execution("daily_pattern_previous_completed_date_missing".into()))?} else {day.date.min(ctx.decision_date)};
|
||||||
// Lagged replay retains the decision day's schedule label; execution is on a later session.
|
// 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()) {
|
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)));
|
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 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());
|
let key = (date, stock.symbol.to_string(), identity.clone());
|
||||||
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) {
|
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)?;
|
if !self.pattern_contexts.borrow().contains_key(&key.2) {
|
||||||
|
let context=crate::pattern_context::build_dataset_context(spec,ctx.data,date).map_err(BacktestError::Execution)?;
|
||||||
|
self.pattern_contexts.borrow_mut().insert(key.2.clone(),context);
|
||||||
|
self.pattern_specs.borrow_mut().insert(key.2.clone(),serde_json::to_string(spec).unwrap());
|
||||||
|
}
|
||||||
|
let result = crate::daily_patterns::evaluate_dataset_context(spec,ctx.data,date,&stock.symbol,&self.pattern_contexts.borrow()[&key.2]).map_err(BacktestError::Execution)?;
|
||||||
self.pattern_results.borrow_mut().insert(key.clone(),result);
|
self.pattern_results.borrow_mut().insert(key.clone(),result);
|
||||||
}
|
}
|
||||||
let results = self.pattern_results.borrow();
|
let results = self.pattern_results.borrow();
|
||||||
@@ -6986,7 +7016,10 @@ impl PlatformExprStrategy {
|
|||||||
"pattern_signal" | "pattern_score" if args.len() == 1 => {
|
"pattern_signal" | "pattern_score" if args.len() == 1 => {
|
||||||
let text: String = serde_json::from_str(&args[0]).ok()?;
|
let text: String = serde_json::from_str(&args[0]).ok()?;
|
||||||
let spec: crate::daily_patterns::PatternSpec = serde_json::from_str(&text).ok()?;
|
let spec: crate::daily_patterns::PatternSpec = serde_json::from_str(&text).ok()?;
|
||||||
Some(CompiledRuntimeHelperArgs::DailyPattern { spec: spec.validate().ok()? })
|
let spec=spec.validate().ok()?;
|
||||||
|
use sha2::Digest;
|
||||||
|
let identity=format!("{:x}",sha2::Sha256::digest(serde_json::to_vec(&spec).ok()?));
|
||||||
|
Some(CompiledRuntimeHelperArgs::DailyPattern { spec, identity })
|
||||||
}
|
}
|
||||||
"rolling_mean" | "sma" | "ma" => {
|
"rolling_mean" | "sma" | "ma" => {
|
||||||
let (field, lookback) = field_lookback()?;
|
let (field, lookback) = field_lookback()?;
|
||||||
@@ -12263,13 +12296,15 @@ impl Strategy for PlatformExprStrategy {
|
|||||||
.is_some()
|
.is_some()
|
||||||
&& self.config.rotation_enabled
|
&& self.config.rotation_enabled
|
||||||
{
|
{
|
||||||
rules.push(
|
let schedule=self.config.rebalance_schedule.as_ref().expect("checked timed rebalance schedule");
|
||||||
self.config
|
if self.config.session_event_times.is_empty() {
|
||||||
.rebalance_schedule
|
rules.push(schedule.as_schedule_rule(ScheduleStage::OnDay));
|
||||||
.as_ref()
|
} else {
|
||||||
.expect("checked timed rebalance schedule")
|
for time in &self.config.session_event_times {
|
||||||
.as_schedule_rule(ScheduleStage::OnDay),
|
let mut timed=schedule.clone();timed.time_rule=Some(ScheduleTimeRule::physical_time(time.hour(),time.minute()));
|
||||||
);
|
rules.push(timed.as_schedule_rule(ScheduleStage::OnDay));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if self.config.explicit_actions.is_empty() {
|
if self.config.explicit_actions.is_empty() {
|
||||||
return rules;
|
return rules;
|
||||||
@@ -12332,6 +12367,7 @@ impl Strategy for PlatformExprStrategy {
|
|||||||
|
|
||||||
fn decision_quote_times(&self) -> Vec<NaiveTime> {
|
fn decision_quote_times(&self) -> Vec<NaiveTime> {
|
||||||
let mut times = BTreeSet::new();
|
let mut times = BTreeSet::new();
|
||||||
|
times.extend(self.config.session_event_times.iter().copied());
|
||||||
if self.uses_intraday_execution_quotes() {
|
if self.uses_intraday_execution_quotes() {
|
||||||
if self.config.explicit_action_times.is_empty() {
|
if self.config.explicit_action_times.is_empty() {
|
||||||
times.insert(self.intraday_execution_start_time());
|
times.insert(self.intraday_execution_start_time());
|
||||||
@@ -12426,12 +12462,28 @@ impl PlatformExprStrategy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn append_pattern_diagnostics(&self, decision: &mut StrategyDecision) {
|
fn append_pattern_diagnostics(&self, decision: &mut StrategyDecision) {
|
||||||
|
let mut contexts=BTreeMap::<String,serde_json::Value>::new();
|
||||||
for ((date, symbol, spec), result) in self.pattern_results.borrow().iter() {
|
for ((date, symbol, spec), result) in self.pattern_results.borrow().iter() {
|
||||||
|
if result.values["session_contract"]==crate::session_events::CONTRACT {
|
||||||
|
let record=serde_json::json!({"event":"session_event_decision","date":date,"symbol":symbol,"spec_sha256":spec,"matched":result.matched,"signal_bar_end":result.values["signal_bar_end"],"decision_at":result.values["decision_at"],"exclusion":result.exclusion}).to_string();
|
||||||
|
if !decision.diagnostics.contains(&record){decision.diagnostics.push(record)}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if result.values["execution_context_latest"].as_object().is_some_and(|v|!v.is_empty()) {
|
||||||
|
let group=contexts.entry(spec.clone()).or_insert_with(||serde_json::json!({"event":"daily_pattern_context_decisions","date":date,"spec_sha256":spec,"evaluated":0,"matched":0,"excluded":0,"sample_limit":20,"samples":[]}));
|
||||||
|
group["evaluated"]=serde_json::json!(group["evaluated"].as_u64().unwrap()+1);
|
||||||
|
group["matched"]=serde_json::json!(group["matched"].as_u64().unwrap()+u64::from(result.matched));
|
||||||
|
group["excluded"]=serde_json::json!(group["excluded"].as_u64().unwrap()+u64::from(result.exclusion.is_some()));
|
||||||
|
let samples=group["samples"].as_array_mut().unwrap();
|
||||||
|
if samples.len()<20 {samples.push(serde_json::json!({"symbol":symbol,"matched":result.matched,"context":result.values["execution_context_latest"],"exclusion":result.exclusion}));}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if let Some(evidence) = &result.exclusion {
|
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();
|
let record = serde_json::json!({"event":"daily_pattern_excluded","date":date,"symbol":symbol,"spec":self.pattern_specs.borrow().get(spec),"spec_sha256":spec,"evidence":evidence}).to_string();
|
||||||
if !decision.diagnostics.contains(&record) { decision.diagnostics.push(record); }
|
if !decision.diagnostics.contains(&record) { decision.diagnostics.push(record); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for value in contexts.values() {let record=value.to_string();if !decision.diagnostics.contains(&record){decision.diagnostics.push(record);}}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn compute_day_decision(&mut self, ctx: &StrategyContext<'_>) -> Result<StrategyDecision, BacktestError> {
|
fn compute_day_decision(&mut self, ctx: &StrategyContext<'_>) -> Result<StrategyDecision, BacktestError> {
|
||||||
@@ -14302,6 +14354,28 @@ mod tests {
|
|||||||
assert_eq!(strategy.selection_quote_usage, StockFilterQuoteUsage::DailyOnly);
|
assert_eq!(strategy.selection_quote_usage, StockFilterQuoteUsage::DailyOnly);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_event_uses_previous_bar_and_recomputes_at_each_minute_without_becoming_a_quote() {
|
||||||
|
let date = d(2026,9,8); let symbol = "000001.SZ";
|
||||||
|
let bars = (0..=33).map(|i| {
|
||||||
|
let timestamp = date.and_hms_opt(9,30,0).unwrap() + chrono::Duration::minutes(i);
|
||||||
|
let close = if i==31 {11.} else {10.};
|
||||||
|
crate::session_events::MinuteBar {symbol:symbol.into(),timestamp,available_at:timestamp,open:close,high:close,low:close,close,volume:100.,amount:close*100.}
|
||||||
|
}).collect();
|
||||||
|
let data = single_symbol_platform_data(&[date],symbol).with_completed_minute_bars(bars).unwrap();
|
||||||
|
let portfolio=PortfolioState::new(100_000.); let subscriptions=BTreeSet::new();
|
||||||
|
let mut ctx=StrategyContext {execution_date:date,decision_date:date,decision_index:0,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 config=PlatformExprStrategyConfig::generic();config.signal_symbol=symbol.into();config.matching_type=MatchingType::MinuteLast;
|
||||||
|
let strategy=PlatformExprStrategy::new(config);
|
||||||
|
let expression=r#"pattern_signal("{\"template\":\"session_event\",\"session_event\":\"OPENING_RANGE_BREAKOUT_UP\",\"parameters\":{}}")"#;
|
||||||
|
let day=strategy.day_state(&ctx,date).unwrap();let stock=strategy.stock_state(&ctx,date,symbol).unwrap();
|
||||||
|
for (minute,expected) in [(1,false),(2,true),(3,false)] {
|
||||||
|
ctx.active_datetime=date.and_hms_opt(10,minute,0);
|
||||||
|
assert_eq!(strategy.eval_bool(&ctx,expression,&day,Some(&stock),None).unwrap(),expected);
|
||||||
|
}
|
||||||
|
assert!(data.snapshot_components().execution_quotes.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn buy_filter_uses_active_schedule_time_instead_of_first_configured_time() {
|
fn buy_filter_uses_active_schedule_time_instead_of_first_configured_time() {
|
||||||
let date = d(2025, 1, 2);
|
let date = d(2025, 1, 2);
|
||||||
|
|||||||
@@ -2538,6 +2538,10 @@ pub fn platform_expr_config_from_spec(
|
|||||||
cfg.benchmark_symbol = normalize_symbol(&cfg.benchmark_symbol, None);
|
cfg.benchmark_symbol = normalize_symbol(&cfg.benchmark_symbol, None);
|
||||||
}
|
}
|
||||||
let trade_times = spec_trade_times(spec);
|
let trade_times = spec_trade_times(spec);
|
||||||
|
if crate::pattern_context::specs_in_value(&serde_json::to_value(spec).map_err(|e|e.to_string())?)?.iter().any(|p|p.template=="session_event") {
|
||||||
|
if trade_times.is_empty() {return Err("session_event_requires_explicit_trade_times".into());}
|
||||||
|
cfg.session_event_times=trade_times.clone();
|
||||||
|
}
|
||||||
let explicit_trading_schedule = spec
|
let explicit_trading_schedule = spec
|
||||||
.runtime_expressions
|
.runtime_expressions
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -4458,6 +4462,20 @@ mod tests {
|
|||||||
assert_eq!(cfg.delayed_limit_open_exit_time, None);
|
assert_eq!(cfg.delayed_limit_open_exit_time, None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_rotation_keeps_every_declared_clock_not_only_the_last_one() {
|
||||||
|
use crate::Strategy;
|
||||||
|
let literal=serde_json::to_string(&serde_json::json!({"template":"session_event","session_event":"INTRADAY_VOLUME_SPIKE","parameters":{}}).to_string()).unwrap();
|
||||||
|
let mut spec=serde_json::json!({"rebalance":{"tradeTimes":["09:35","10:40","14:59"]},"runtimeExpressions":{"schedule":{"frequency":"daily","time":"14:59"},"trading":{"rotationEnabled":true,"buyFilterExpr":format!("pattern_signal({literal})")}},"execution":{"matchingType":"minute_last"}});
|
||||||
|
let config=platform_expr_config_from_value("session","000300.SH",&spec).unwrap();
|
||||||
|
assert_eq!(config.session_event_times.len(),3);
|
||||||
|
let strategy=crate::PlatformExprStrategy::new(config);
|
||||||
|
assert_eq!(strategy.schedule_rules().len(),3);
|
||||||
|
assert_eq!(strategy.decision_quote_times().len(),3);
|
||||||
|
spec["rebalance"]["tradeTimes"]=serde_json::json!([]);
|
||||||
|
assert!(platform_expr_config_from_value("session","000300.SH",&spec).unwrap_err().to_string().contains("explicit_trade_times"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn explicit_trading_schedule_overrides_rebalance_trade_times() {
|
fn explicit_trading_schedule_overrides_rebalance_trade_times() {
|
||||||
let spec = serde_json::json!({
|
let spec = serde_json::json!({
|
||||||
|
|||||||
@@ -0,0 +1,398 @@
|
|||||||
|
//! Completed, same-session minute events. These bars never become execution quotes.
|
||||||
|
use crate::{
|
||||||
|
daily_patterns::{PatternResult, PatternSpec},
|
||||||
|
factor_events::{Expr, Frame},
|
||||||
|
};
|
||||||
|
use chrono::{FixedOffset, NaiveDateTime, NaiveTime, TimeZone, Timelike};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
pub const CONTRACT: &str = "fidc_completed_session_events_v1";
|
||||||
|
pub const EVENTS: &[&str] = &[
|
||||||
|
"PRICE_CROSS_VWAP_UP",
|
||||||
|
"PRICE_CROSS_VWAP_DOWN",
|
||||||
|
"INTRADAY_HIGH_BREAKOUT",
|
||||||
|
"INTRADAY_LOW_BREAKDOWN",
|
||||||
|
"OPENING_RANGE_BREAKOUT_UP",
|
||||||
|
"OPENING_RANGE_BREAKOUT_DOWN",
|
||||||
|
"INTRADAY_VOLUME_SPIKE",
|
||||||
|
"MORNING_HIGH_BREAKOUT",
|
||||||
|
"MORNING_LOW_BREAKDOWN",
|
||||||
|
"AFTERNOON_MOMENTUM_UP",
|
||||||
|
"AFTERNOON_MOMENTUM_DOWN",
|
||||||
|
"LATE_SESSION_STRENGTH",
|
||||||
|
"LATE_SESSION_WEAKNESS",
|
||||||
|
];
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct MinuteBar {
|
||||||
|
pub symbol: String,
|
||||||
|
pub timestamp: NaiveDateTime,
|
||||||
|
pub available_at: NaiveDateTime,
|
||||||
|
pub open: f64,
|
||||||
|
pub high: f64,
|
||||||
|
pub low: f64,
|
||||||
|
pub close: f64,
|
||||||
|
pub volume: f64,
|
||||||
|
pub amount: f64,
|
||||||
|
}
|
||||||
|
pub type BarStore = Arc<BTreeMap<(chrono::NaiveDate, String), Vec<MinuteBar>>>;
|
||||||
|
pub fn bar_store(bars: Vec<MinuteBar>) -> Result<BarStore, String> {
|
||||||
|
let mut groups = BTreeMap::<(chrono::NaiveDate, String), Vec<MinuteBar>>::new();
|
||||||
|
for bar in bars {
|
||||||
|
groups
|
||||||
|
.entry((bar.timestamp.date(), bar.symbol.clone()))
|
||||||
|
.or_default()
|
||||||
|
.push(bar);
|
||||||
|
}
|
||||||
|
for rows in groups.values_mut() {
|
||||||
|
rows.sort_by_key(|r| r.timestamp);
|
||||||
|
if rows
|
||||||
|
.windows(2)
|
||||||
|
.any(|pair| pair[0].timestamp == pair[1].timestamp)
|
||||||
|
{
|
||||||
|
return Err("duplicate_completed_minute_bar".into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Arc::new(groups))
|
||||||
|
}
|
||||||
|
fn f(name: &str) -> Expr {
|
||||||
|
Expr::Field { name: name.into() }
|
||||||
|
}
|
||||||
|
fn n(value: f64) -> Expr {
|
||||||
|
Expr::Number { value }
|
||||||
|
}
|
||||||
|
fn op(name: &str, args: Vec<Expr>, window: Option<usize>) -> Expr {
|
||||||
|
Expr::Operator {
|
||||||
|
name: name.into(),
|
||||||
|
args,
|
||||||
|
window,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn time(minutes: u32) -> NaiveTime {
|
||||||
|
NaiveTime::from_hms_opt(minutes / 60, minutes % 60, 0).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_regular_label(t: NaiveTime) -> bool {
|
||||||
|
t.second() == 0 && (time(570) <= t && t <= time(690) || time(780) < t && t <= time(900))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn expression(event: &str, p: &BTreeMap<String, Value>) -> Result<Expr, String> {
|
||||||
|
let cross = |up: bool, a: Expr, b: Expr| {
|
||||||
|
op(
|
||||||
|
if up { "CROSS_ABOVE" } else { "CROSS_BELOW" },
|
||||||
|
vec![a, b],
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
Ok(match event {
|
||||||
|
"PRICE_CROSS_VWAP_UP" => cross(true, f("close"), f("session_vwap")),
|
||||||
|
"PRICE_CROSS_VWAP_DOWN" => cross(false, f("close"), f("session_vwap")),
|
||||||
|
"INTRADAY_HIGH_BREAKOUT" => op(
|
||||||
|
"GT",
|
||||||
|
vec![
|
||||||
|
f("close"),
|
||||||
|
op("LAG", vec![op("CUMMAX", vec![f("high")], None)], Some(1)),
|
||||||
|
],
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
"INTRADAY_LOW_BREAKDOWN" => op(
|
||||||
|
"LT",
|
||||||
|
vec![
|
||||||
|
f("close"),
|
||||||
|
op("LAG", vec![op("CUMMIN", vec![f("low")], None)], Some(1)),
|
||||||
|
],
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
"OPENING_RANGE_BREAKOUT_UP" => cross(true, f("close"), f("opening_high")),
|
||||||
|
"OPENING_RANGE_BREAKOUT_DOWN" => cross(false, f("close"), f("opening_low")),
|
||||||
|
"MORNING_HIGH_BREAKOUT" => cross(true, f("close"), f("morning_high")),
|
||||||
|
"MORNING_LOW_BREAKDOWN" => cross(false, f("close"), f("morning_low")),
|
||||||
|
"AFTERNOON_MOMENTUM_UP" => cross(true, f("afternoon_return"), n(0.)),
|
||||||
|
"AFTERNOON_MOMENTUM_DOWN" => cross(false, f("afternoon_return"), n(0.)),
|
||||||
|
"LATE_SESSION_STRENGTH" => cross(true, f("late_return"), n(0.)),
|
||||||
|
"LATE_SESSION_WEAKNESS" => cross(false, f("late_return"), n(0.)),
|
||||||
|
"INTRADAY_VOLUME_SPIKE" => op(
|
||||||
|
"GTE",
|
||||||
|
vec![
|
||||||
|
f("volume"),
|
||||||
|
op(
|
||||||
|
"MUL",
|
||||||
|
vec![
|
||||||
|
op(
|
||||||
|
"LAG",
|
||||||
|
vec![op(
|
||||||
|
"ROLLING_MEAN",
|
||||||
|
vec![f("volume")],
|
||||||
|
Some(p["volume_window"].as_u64().unwrap() as usize),
|
||||||
|
)],
|
||||||
|
Some(1),
|
||||||
|
),
|
||||||
|
n(p["volume_multiple"].as_f64().unwrap()),
|
||||||
|
],
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
_ => return Err("session_event_not_registered".into()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn evaluate(
|
||||||
|
spec: &PatternSpec,
|
||||||
|
symbol: &str,
|
||||||
|
bars: &[MinuteBar],
|
||||||
|
decision: NaiveDateTime,
|
||||||
|
) -> Result<PatternResult, String> {
|
||||||
|
let mut result = PatternResult {
|
||||||
|
symbol: symbol.into(),
|
||||||
|
name: None,
|
||||||
|
matched: false,
|
||||||
|
score: None,
|
||||||
|
checks: vec![],
|
||||||
|
values: json!({}),
|
||||||
|
anchor: Value::Null,
|
||||||
|
exclusion: None,
|
||||||
|
};
|
||||||
|
if bars.is_empty() {
|
||||||
|
return Err(format!(
|
||||||
|
"session_source_missing: {symbol} {}",
|
||||||
|
decision.date()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let visible = bars
|
||||||
|
.iter()
|
||||||
|
.filter(|b| {
|
||||||
|
b.timestamp.date() == decision.date()
|
||||||
|
&& b.timestamp < decision
|
||||||
|
&& b.available_at <= decision
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
if visible.is_empty() {
|
||||||
|
result.exclusion = Some(json!({"reason":"session_before_first_completed_bar"}));
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
let last = visible.last().unwrap().timestamp;
|
||||||
|
let expected = (570..=690)
|
||||||
|
.chain(781..=900)
|
||||||
|
.map(|m| decision.date().and_time(time(m)))
|
||||||
|
.filter(|t| *t < decision)
|
||||||
|
.last();
|
||||||
|
if expected != Some(last) {
|
||||||
|
return Err(format!(
|
||||||
|
"session_latest_bar_missing: {symbol} expected={expected:?} actual={last}"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let mut indexed = BTreeMap::new();
|
||||||
|
for b in &visible {
|
||||||
|
if b.symbol != symbol
|
||||||
|
|| !is_regular_label(b.timestamp.time())
|
||||||
|
|| b.available_at < b.timestamp
|
||||||
|
|| [b.open, b.high, b.low, b.close, b.volume, b.amount]
|
||||||
|
.iter()
|
||||||
|
.any(|v| !v.is_finite())
|
||||||
|
|| b.low <= 0.
|
||||||
|
|| b.open <= 0.
|
||||||
|
|| b.close <= 0.
|
||||||
|
|| b.high < b.open.max(b.close)
|
||||||
|
|| b.low > b.open.min(b.close)
|
||||||
|
|| b.volume < 0.
|
||||||
|
|| b.amount < 0.
|
||||||
|
|| indexed.insert(b.timestamp, b).is_some()
|
||||||
|
{
|
||||||
|
return Err(format!("session_bar_invalid: {symbol} {}", b.timestamp));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for minute in (571..=690).chain(781..=900) {
|
||||||
|
let stamp = decision.date().and_time(time(minute));
|
||||||
|
if stamp <= last && !indexed.contains_key(&stamp) {
|
||||||
|
return Err(format!(
|
||||||
|
"session_bar_gap: {symbol} {stamp}; no filling or calendar compression"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let opening_end = time(570 + spec.n("opening_minutes") as u32);
|
||||||
|
let (mut volume, mut amount) = (0., 0.);
|
||||||
|
let (mut opening_high, mut opening_low) = (f64::NEG_INFINITY, f64::INFINITY);
|
||||||
|
let (mut morning_high, mut morning_low) = (f64::NEG_INFINITY, f64::INFINITY);
|
||||||
|
let (mut morning_close, mut late_close) = (None, None);
|
||||||
|
let mut fields: BTreeMap<String, Vec<Option<f64>>> = [
|
||||||
|
"open",
|
||||||
|
"high",
|
||||||
|
"low",
|
||||||
|
"close",
|
||||||
|
"volume",
|
||||||
|
"amount",
|
||||||
|
"session_vwap",
|
||||||
|
"opening_high",
|
||||||
|
"opening_low",
|
||||||
|
"morning_high",
|
||||||
|
"morning_low",
|
||||||
|
"afternoon_return",
|
||||||
|
"late_return",
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.map(|s| (s.into(), vec![]))
|
||||||
|
.collect();
|
||||||
|
let mut timestamps = vec![];
|
||||||
|
let mut available_at = vec![];
|
||||||
|
let zone = FixedOffset::east_opt(8 * 3600).unwrap();
|
||||||
|
for b in indexed.values() {
|
||||||
|
let t = b.timestamp.time();
|
||||||
|
volume += b.volume;
|
||||||
|
amount += b.amount;
|
||||||
|
if t <= opening_end {
|
||||||
|
opening_high = opening_high.max(b.high);
|
||||||
|
opening_low = opening_low.min(b.low);
|
||||||
|
}
|
||||||
|
if t <= time(690) {
|
||||||
|
morning_high = morning_high.max(b.high);
|
||||||
|
morning_low = morning_low.min(b.low);
|
||||||
|
}
|
||||||
|
if t == time(690) {
|
||||||
|
morning_close = Some(b.close);
|
||||||
|
}
|
||||||
|
if t == time(870) {
|
||||||
|
late_close = Some(b.close);
|
||||||
|
}
|
||||||
|
for (name, value) in [
|
||||||
|
("open", Some(b.open)),
|
||||||
|
("high", Some(b.high)),
|
||||||
|
("low", Some(b.low)),
|
||||||
|
("close", Some(b.close)),
|
||||||
|
("volume", Some(b.volume)),
|
||||||
|
("amount", Some(b.amount)),
|
||||||
|
("session_vwap", (volume > 0.).then_some(amount / volume)),
|
||||||
|
("opening_high", (t >= opening_end).then_some(opening_high)),
|
||||||
|
("opening_low", (t >= opening_end).then_some(opening_low)),
|
||||||
|
("morning_high", (t >= time(690)).then_some(morning_high)),
|
||||||
|
("morning_low", (t >= time(690)).then_some(morning_low)),
|
||||||
|
("afternoon_return", morning_close.map(|v| b.close / v - 1.)),
|
||||||
|
("late_return", late_close.map(|v| b.close / v - 1.)),
|
||||||
|
] {
|
||||||
|
fields.get_mut(name).unwrap().push(value);
|
||||||
|
}
|
||||||
|
timestamps.push(zone.from_local_datetime(&b.timestamp).single().unwrap());
|
||||||
|
available_at.push(zone.from_local_datetime(&b.available_at).single().unwrap());
|
||||||
|
}
|
||||||
|
let frame = Frame {
|
||||||
|
symbol: symbol.into(),
|
||||||
|
frequency: "1m".into(),
|
||||||
|
decision_at: zone.from_local_datetime(&decision).single().unwrap(),
|
||||||
|
timestamps,
|
||||||
|
available_at,
|
||||||
|
fields,
|
||||||
|
};
|
||||||
|
let event = spec
|
||||||
|
.session_event
|
||||||
|
.as_deref()
|
||||||
|
.ok_or("session_event_id_required")?;
|
||||||
|
let values = crate::factor_events::evaluate(&expression(event, &spec.parameters)?, &frame)?;
|
||||||
|
let latest = values.values.last().copied().flatten();
|
||||||
|
result.score = latest;
|
||||||
|
result.matched = latest == Some(1.);
|
||||||
|
result.values = json!({"session_event":event,"session_contract":CONTRACT,"expression":values,"signal_bar_end":last,"decision_at":decision,"bars":visible.len(),"bar_times":frame.timestamps.iter().map(|t|t.format("%Y-%m-%dT%H:%M:%S").to_string()).collect::<Vec<_>>(),"close":visible.last().unwrap().close,"session_return":visible.last().unwrap().close/visible.first().unwrap().open-1.,"price_policy":"same_session_raw_ohlcv"});
|
||||||
|
if latest.is_none() {
|
||||||
|
result.exclusion = Some(json!({"reason":"session_warmup_or_undefined"}));
|
||||||
|
} else {
|
||||||
|
result.checks.push(json!({"label":"分钟事件","actual":latest,"operator":"==","threshold":1,"passed":result.matched}));
|
||||||
|
}
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
fn spec(event: &str) -> PatternSpec {
|
||||||
|
serde_json::from_value::<PatternSpec>(
|
||||||
|
json!({"template":"session_event","session_event":event,"parameters":{}}),
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.validate()
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
fn bars() -> Vec<MinuteBar> {
|
||||||
|
let date = chrono::NaiveDate::from_ymd_opt(2026, 9, 8).unwrap();
|
||||||
|
(570..=690)
|
||||||
|
.chain(781..=900)
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, m)| {
|
||||||
|
let timestamp = date.and_time(time(m));
|
||||||
|
let price = 100. + (i % 17) as f64 / 10.;
|
||||||
|
let volume = if i % 39 == 0 { 1000. } else { 100. };
|
||||||
|
MinuteBar {
|
||||||
|
symbol: "300395.SZ".into(),
|
||||||
|
timestamp,
|
||||||
|
available_at: timestamp,
|
||||||
|
open: price,
|
||||||
|
high: price + 0.1,
|
||||||
|
low: price - 0.1,
|
||||||
|
close: price,
|
||||||
|
volume,
|
||||||
|
amount: volume * price,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn all_thirteen_events_return_native_boolean_series() {
|
||||||
|
let bars = bars();
|
||||||
|
let decision = "2026-09-08T15:00:01".parse().unwrap();
|
||||||
|
for event in EVENTS {
|
||||||
|
let value = evaluate(&spec(event), "300395.SZ", &bars, decision).unwrap();
|
||||||
|
assert!(value.score.is_some(), "{event}");
|
||||||
|
assert_eq!(value.values["expression"]["value_type"], "boolean");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn decision_uses_the_previous_completed_label_and_future_prices_do_not_rewrite() {
|
||||||
|
let mut bars = bars();
|
||||||
|
let decision = "2026-09-08T10:02:00".parse().unwrap();
|
||||||
|
for event in EVENTS {
|
||||||
|
let before = evaluate(&spec(event), "300395.SZ", &bars, decision).unwrap();
|
||||||
|
for bar in &mut bars {
|
||||||
|
if bar.timestamp >= decision {
|
||||||
|
bar.open = 1000.;
|
||||||
|
bar.close = 1000.;
|
||||||
|
bar.high = 1001.;
|
||||||
|
bar.low = 999.;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let after = evaluate(&spec(event), "300395.SZ", &bars, decision).unwrap();
|
||||||
|
assert_eq!(before.values, after.values);
|
||||||
|
assert_eq!(after.values["signal_bar_end"], "2026-09-08T10:01:00");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn gaps_and_stale_last_bars_do_not_become_false_or_repeated_signals() {
|
||||||
|
let mut values = bars();
|
||||||
|
let decision = "2026-09-08T10:02:00".parse().unwrap();
|
||||||
|
values.retain(|r| r.timestamp.time() != time(600));
|
||||||
|
assert!(evaluate(&spec(EVENTS[0]), "300395.SZ", &values, decision)
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("session_bar_gap"));
|
||||||
|
let stale = bars()
|
||||||
|
.into_iter()
|
||||||
|
.filter(|r| r.timestamp.time() < time(601))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
assert!(evaluate(&spec(EVENTS[0]), "300395.SZ", &stale, decision)
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("latest_bar_missing"));
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn opening_range_is_unavailable_before_the_range_has_completed() {
|
||||||
|
let value = evaluate(
|
||||||
|
&spec("OPENING_RANGE_BREAKOUT_UP"),
|
||||||
|
"300395.SZ",
|
||||||
|
&bars(),
|
||||||
|
"2026-09-08T09:59:01".parse().unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(value.score, None);
|
||||||
|
assert!(!value.matched);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user