Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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(())
|
||||||
|
}
|
||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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};
|
||||||
@@ -974,7 +974,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>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4806,7 +4806,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 +4824,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 +4835,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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user