Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5dc5ef9df5 | |||
| fe8f6c1c26 | |||
| 30e8227099 | |||
| 588da4958f | |||
| bc4754288e | |||
| 6b0cdbcecc |
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(())
|
||||||
|
}
|
||||||
@@ -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,14 @@ 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":"冻结历史窗口与表达式;预热不足或未定义值不产生信号。复用共享指标事件内核,不修改既有任务。"},
|
||||||
"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 +29,42 @@ 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>,
|
||||||
}
|
}
|
||||||
impl PatternSpec {
|
impl PatternSpec {
|
||||||
pub fn validate(mut self) -> Result<Self, String> {
|
pub fn validate(self) -> Result<Self, String> {
|
||||||
|
self.validate_with_context(&[])
|
||||||
|
}
|
||||||
|
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 +82,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" {
|
||||||
if number.fract() != 0.0 {
|
if number.fract() != 0.0 {
|
||||||
return Err(format!("{key}必须是整数"));
|
return Err(format!("{key}必须是整数"));
|
||||||
}
|
}
|
||||||
@@ -66,11 +104,14 @@ impl PatternSpec {
|
|||||||
}
|
}
|
||||||
pub fn history_len(&self) -> usize {
|
pub fn history_len(&self) -> usize {
|
||||||
match self.template.as_str() {
|
match self.template.as_str() {
|
||||||
|
"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 +126,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)]
|
||||||
@@ -145,6 +194,16 @@ pub fn evaluate(
|
|||||||
spec: &PatternSpec,
|
spec: &PatternSpec,
|
||||||
days: &[NaiveDate],
|
days: &[NaiveDate],
|
||||||
series: &PatternSeries,
|
series: &PatternSeries,
|
||||||
|
) -> Result<PatternResult, String> {
|
||||||
|
evaluate_with_context(spec, days, series, &BTreeMap::new(), false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn evaluate_with_context(
|
||||||
|
spec: &PatternSpec,
|
||||||
|
days: &[NaiveDate],
|
||||||
|
series: &PatternSeries,
|
||||||
|
context: &BTreeMap<String, Vec<Option<f64>>>,
|
||||||
|
numeric_output: bool,
|
||||||
) -> Result<PatternResult, String> {
|
) -> Result<PatternResult, String> {
|
||||||
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 +305,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 +470,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 +486,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 +513,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 +561,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!(),
|
||||||
}
|
}
|
||||||
@@ -410,6 +627,10 @@ pub fn evaluate_dataset(
|
|||||||
low: Some(b.low),
|
low: Some(b.low),
|
||||||
close: Some(b.close),
|
close: Some(b.close),
|
||||||
volume: Some(b.volume as f64),
|
volume: Some(b.volume as f64),
|
||||||
|
prev_close: data.factor_numeric_value(d, symbol, "pre_close"),
|
||||||
|
amount: data.factor_numeric_value(d, symbol, "amount"),
|
||||||
|
upper_limit: Some(b.upper_limit),
|
||||||
|
no_limit: data.factor_numeric_value(d,symbol,"no_limit").map(|v|v==1.0),
|
||||||
adjustment_factor_backward1: data
|
adjustment_factor_backward1: data
|
||||||
.factor(d, symbol)
|
.factor(d, symbol)
|
||||||
.and_then(|f| f.adjustment_factor_backward1),
|
.and_then(|f| f.adjustment_factor_backward1),
|
||||||
@@ -456,6 +677,60 @@ pub fn evaluate_batch(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Research transport only. Strategy PatternSpec validation still rejects these fields.
|
||||||
|
#[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 +766,108 @@ 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,
|
||||||
}
|
}
|
||||||
.validate()
|
.validate()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -515,6 +888,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 +942,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");
|
||||||
|
|||||||
@@ -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,8 @@ 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 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;
|
||||||
|
|||||||
Reference in New Issue
Block a user