Compare commits

..

4 Commits

Author SHA1 Message Date
boris 30e8227099 接通真实昨收成交额并前置校验表达式字段 2026-09-09 11:33:58 +08:00
boris 588da4958f 合并组合亏损控制器与因子内核依赖 2026-09-09 10:40:26 +08:00
boris bc4754288e 合并主线缺值语义与因子键优化记录 2026-09-09 10:31:26 +08:00
boris 6b0cdbcecc 增加共享因子事件表达式与完整截面算子 2026-09-09 10:31:25 +08:00
7 changed files with 1521 additions and 1 deletions
Generated
+14
View File
@@ -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"
+1
View File
@@ -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(())
}
+211 -1
View File
@@ -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,6 +9,7 @@ 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日最大量的指定倍数;不等同价格创新高。"},
@@ -24,9 +25,39 @@ 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(mut self) -> 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()))
.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)
@@ -66,6 +97,7 @@ 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" => self.n("volume_window") + 1,
@@ -85,6 +117,10 @@ 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>,
pub adjustment_factor_backward1: Option<f64>, pub adjustment_factor_backward1: Option<f64>,
pub paused: Option<bool>, pub paused: Option<bool>,
#[serde(default)] #[serde(default)]
@@ -246,6 +282,111 @@ 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);
}
let frame = crate::factor_events::Frame {
symbol: series.symbol.clone(),
frequency: "1d".into(),
decision_at: *timestamps.last().unwrap(),
available_at: timestamps.clone(),
timestamps,
fields,
};
let values = crate::factor_events::evaluate(spec.expression.as_ref().unwrap(), &frame)?;
let latest = values.values.last().copied().flatten();
result.values["expression"] = json!(values);
result.values["expression_contract"] = json!(crate::factor_events::CONTRACT);
result.values["price_policy"] = json!("backward1_anchored_to_decision_close");
result.score = latest;
if latest.is_none() {
result.exclusion = Some(
json!({"reason":"expression_undefined_or_warmup","signal_date":days.last()}),
);
} else if values.value_type == crate::factor_events::ValueType::Boolean {
result.matched = latest == Some(1.0);
result
.checks
.push(json!({"label":"组合条件","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))?;
@@ -410,6 +551,8 @@ 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"),
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),
@@ -491,10 +634,75 @@ pub fn expression_specs(expression: &str) -> Result<Vec<PatternSpec>, String> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn expression_condition_preserves_native_types_and_rejects_numeric_as_signal() {
let make = |expression: Value| {
serde_json::from_value::<PatternSpec>(json!({"template":"expression","parameters":{"history_window":3},"expression":expression})).unwrap().validate().unwrap()
};
let spec = make(
json!({"kind":"operator","name":"GT","args":[{"kind":"field","name":"close"},{"kind":"indicator","name":"SMA","inputs":[{"kind":"field","name":"close"}],"parameters":{"optInTimePeriod":2}}]}),
);
let days = ["2026-09-04", "2026-09-07", "2026-09-08"]
.map(|d| NaiveDate::parse_from_str(d, "%Y-%m-%d").unwrap());
let series = PatternSeries {
symbol: "TEST".into(),
name: None,
listed_at: None,
bars: days
.iter()
.enumerate()
.map(|(i, &date)| {
let p = 10.0 + i as f64;
PatternBar {
date,
open: Some(p),
high: Some(p),
low: Some(p),
close: Some(p),
volume: Some(100.0),
prev_close: Some(p - 1.0),
amount: Some(p * 100.0),
adjustment_factor_backward1: Some(1.0),
paused: Some(false),
source_path: None,
}
})
.collect(),
};
let result = evaluate(&spec, &days, &series).unwrap();
assert!(result.matched);
assert_eq!(result.score, Some(1.0));
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 +723,8 @@ 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),
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()),
@@ -0,0 +1,190 @@
//! 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,
}
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::*;
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
+2
View File
@@ -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;