Compare commits

..

9 Commits

18 changed files with 86 additions and 3643 deletions
Generated
-14
View File
@@ -192,7 +192,6 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"sha2", "sha2",
"ta-lib",
"thiserror", "thiserror",
] ]
@@ -538,19 +537,6 @@ 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,4 +15,3 @@ 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" }
@@ -1,35 +0,0 @@
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,26 +0,0 @@
use std::io::Read;
fn main() {
let mut input = String::new();
std::io::stdin().read_to_string(&mut input).unwrap();
let value: serde_json::Value = serde_json::from_str(&input).unwrap();
let spec: fidc_core::daily_patterns::PatternSpec =
serde_json::from_value(value["spec"].clone()).unwrap();
let bars: Vec<fidc_core::session_events::MinuteBar> =
serde_json::from_value(value["bars"].clone()).unwrap();
let result = fidc_core::session_events::evaluate(
&spec.validate().unwrap(),
value["symbol"].as_str().unwrap(),
&bars,
serde_json::from_value(value["decision_at"].clone()).unwrap(),
);
match result {
Ok(row) => println!(
"{}",
serde_json::json!({"contract":fidc_core::session_events::CONTRACT,"row":row,"read_only":true,"source_evidence_verified":false})
),
Err(error) => {
eprintln!("{error}");
std::process::exit(1);
}
}
}
File diff suppressed because it is too large Load Diff
+28 -38
View File
@@ -491,7 +491,6 @@ pub struct DataSetSnapshotComponents {
pub benchmarks: Vec<BenchmarkSnapshot>, pub benchmarks: Vec<BenchmarkSnapshot>,
pub corporate_actions: Vec<CorporateAction>, pub corporate_actions: Vec<CorporateAction>,
pub execution_quotes: Vec<IntradayExecutionQuote>, pub execution_quotes: Vec<IntradayExecutionQuote>,
pub completed_minute_bars: Vec<crate::session_events::MinuteBar>,
} }
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
@@ -1419,7 +1418,6 @@ pub struct DataSet {
eligible_universe_by_date: Arc<OnceLock<BTreeMap<NaiveDate, Vec<EligibleUniverseSnapshot>>>>, eligible_universe_by_date: Arc<OnceLock<BTreeMap<NaiveDate, Vec<EligibleUniverseSnapshot>>>>,
benchmark_code: String, benchmark_code: String,
futures_params_by_symbol: Arc<HashMap<String, Vec<FuturesTradingParameter>>>, futures_params_by_symbol: Arc<HashMap<String, Vec<FuturesTradingParameter>>>,
completed_minute_bars: Arc<BTreeMap<(NaiveDate,String),Vec<crate::session_events::MinuteBar>>>,
} }
struct DailySymbolRows<'a, T> { struct DailySymbolRows<'a, T> {
@@ -1852,9 +1850,9 @@ impl DataSet {
.iter() .iter()
.enumerate() .enumerate()
.filter_map(|(symbol_id, series)| { .filter_map(|(symbol_id, series)| {
series series.as_ref().map(|series| {
.as_ref() (symbol_by_id[symbol_id].to_string(), Arc::clone(series))
.map(|series| (symbol_by_id[symbol_id].to_string(), Arc::clone(series))) })
}) })
.collect::<AHashMap<_, _>>(); .collect::<AHashMap<_, _>>();
@@ -1878,9 +1876,9 @@ impl DataSet {
.iter() .iter()
.enumerate() .enumerate()
.filter_map(|(symbol_id, series)| { .filter_map(|(symbol_id, series)| {
series series.as_ref().map(|series| {
.as_ref() (symbol_by_id[symbol_id].to_string(), Arc::clone(series))
.map(|series| (symbol_by_id[symbol_id].to_string(), Arc::clone(series))) })
}) })
.collect::<AHashMap<_, _>>(); .collect::<AHashMap<_, _>>();
let factor_texts = factor_texts let factor_texts = factor_texts
@@ -1902,10 +1900,16 @@ impl DataSet {
let factor_market_cap_order_by_date = let factor_market_cap_order_by_date =
build_factor_market_cap_order(&factor_by_date, &factor_symbol_ids_by_date); build_factor_market_cap_order(&factor_by_date, &factor_symbol_ids_by_date);
let market_row_positions_by_date = let market_row_positions_by_date = build_dense_row_positions(
build_dense_row_positions(&market_by_date, &market_symbol_ids_by_date, symbol_count); &market_by_date,
let factor_row_positions_by_date = &market_symbol_ids_by_date,
build_dense_row_positions(&factor_by_date, &factor_symbol_ids_by_date, symbol_count); symbol_count,
);
let factor_row_positions_by_date = build_dense_row_positions(
&factor_by_date,
&factor_symbol_ids_by_date,
symbol_count,
);
let candidate_row_positions_by_date = build_dense_row_positions( let candidate_row_positions_by_date = build_dense_row_positions(
&candidate_by_date, &candidate_by_date,
&candidate_symbol_ids_by_date, &candidate_symbol_ids_by_date,
@@ -1956,7 +1960,6 @@ impl DataSet {
eligible_universe_by_date: Arc::new(OnceLock::new()), eligible_universe_by_date: Arc::new(OnceLock::new()),
benchmark_code, benchmark_code,
futures_params_by_symbol: Arc::new(futures_params_by_symbol), futures_params_by_symbol: Arc::new(futures_params_by_symbol),
completed_minute_bars: Arc::new(BTreeMap::new()),
}) })
} }
@@ -2318,10 +2321,7 @@ impl DataSet {
} }
for (component, strong_count) in [ for (component, strong_count) in [
( ("daily market panel", Arc::strong_count(&self.market_by_date)),
"daily market panel",
Arc::strong_count(&self.market_by_date),
),
( (
"market series by symbol", "market series by symbol",
Arc::strong_count(&self.market_series_by_symbol), Arc::strong_count(&self.market_series_by_symbol),
@@ -2456,8 +2456,7 @@ impl DataSet {
.flat_map(|rows_by_symbol| rows_by_symbol.values()) .flat_map(|rows_by_symbol| rows_by_symbol.values())
.map(Vec::len) .map(Vec::len)
.sum(); .sum();
let mut execution_quote_dates = let mut execution_quote_dates = execution_quotes_by_date.keys().copied().collect::<Vec<_>>();
execution_quotes_by_date.keys().copied().collect::<Vec<_>>();
execution_quote_dates.sort_unstable(); execution_quote_dates.sort_unstable();
self.execution_quotes_by_date = Arc::new(execution_quotes_by_date); self.execution_quotes_by_date = Arc::new(execution_quotes_by_date);
self.execution_quote_dates = Arc::new(execution_quote_dates); self.execution_quote_dates = Arc::new(execution_quote_dates);
@@ -2630,21 +2629,9 @@ impl DataSet {
benchmarks, benchmarks,
corporate_actions, corporate_actions,
execution_quotes, execution_quotes,
completed_minute_bars:self.completed_minute_bars.values().flatten().cloned().collect(),
} }
} }
pub fn with_completed_minute_bars(mut self,bars:Vec<crate::session_events::MinuteBar>)->Result<Self,String> {
self.completed_minute_bars=crate::session_events::bar_store(bars)?;Ok(self)
}
pub fn with_shared_completed_minute_bars(mut self,bars:crate::session_events::BarStore)->Self {self.completed_minute_bars=bars;self}
pub fn completed_minute_bar_count(&self)->usize {self.completed_minute_bars.values().map(Vec::len).sum()}
pub fn completed_minute_bars_on(&self,date:NaiveDate,symbol:&str)->&[crate::session_events::MinuteBar] {
self.completed_minute_bars.get(&(date,symbol.into())).map(Vec::as_slice).unwrap_or(&[])
}
pub fn benchmark_series(&self) -> Vec<BenchmarkSnapshot> { pub fn benchmark_series(&self) -> Vec<BenchmarkSnapshot> {
self.benchmark_by_date.values().cloned().collect() self.benchmark_by_date.values().cloned().collect()
} }
@@ -5234,7 +5221,10 @@ mod tests {
[data.symbol_id("000001.SZ").unwrap() as usize] [data.symbol_id("000001.SZ").unwrap() as usize]
.as_ref() .as_ref()
.unwrap(); .unwrap();
assert!(!Arc::ptr_eq(&market_series_before, market_series_after)); assert!(!Arc::ptr_eq(
&market_series_before,
market_series_after
));
assert!(Arc::ptr_eq(&daily_base_before, &market_series_after.base)); assert!(Arc::ptr_eq(&daily_base_before, &market_series_after.base));
assert_eq!( assert_eq!(
serde_json::to_value(market_series_after.snapshot_at(0)).unwrap(), serde_json::to_value(market_series_after.snapshot_at(0)).unwrap(),
@@ -6188,7 +6178,10 @@ mod tests {
let date = NaiveDate::parse_from_str("2025-01-02", "%Y-%m-%d").unwrap(); let date = NaiveDate::parse_from_str("2025-01-02", "%Y-%m-%d").unwrap();
let quote = IntradayExecutionQuote { let quote = IntradayExecutionQuote {
date, date,
timestamp: NaiveDateTime::parse_from_str("2025-01-02 10:18:00", "%Y-%m-%d %H:%M:%S") timestamp: NaiveDateTime::parse_from_str(
"2025-01-02 10:18:00",
"%Y-%m-%d %H:%M:%S",
)
.unwrap(), .unwrap(),
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".to_string(),
last_price: 10.0, last_price: 10.0,
@@ -6372,16 +6365,13 @@ mod tests {
"'adjustment_factor_backward1'", "'adjustment_factor_backward1'",
] { ] {
for typed_value in [None, Some(1.0)] { for typed_value in [None, Some(1.0)] {
assert!( assert!(matches!(
matches!(
normalize_factor_snapshots(vec![snapshot( normalize_factor_snapshots(vec![snapshot(
typed_value, typed_value,
BTreeMap::from([(Cow::Borrowed(field), 2.0)]), BTreeMap::from([(Cow::Borrowed(field), 2.0)]),
)]), )]),
Err(DataSetError::ReservedTypedFactorInExtraMap { .. }) Err(DataSetError::ReservedTypedFactorInExtraMap { .. })
), ), "reserved alias accepted: {field}");
"reserved alias accepted: {field}"
);
} }
} }
} }
+6 -17
View File
@@ -91,9 +91,6 @@ impl Default for ProcessEventRetention {
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DailyEquityPoint { pub struct DailyEquityPoint {
/// Close-of-signal-day cash baseline before lagged trading begins.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub signal_baseline: bool,
#[serde(with = "date_format")] #[serde(with = "date_format")]
pub date: NaiveDate, pub date: NaiveDate,
pub cash: f64, pub cash: f64,
@@ -112,14 +109,6 @@ pub struct DailyEquityPoint {
pub diagnostics: String, pub diagnostics: String,
} }
impl DailyEquityPoint {
pub fn benchmark_reference_close(&self) -> f64 {
if self.signal_baseline { self.benchmark_close }
else if self.benchmark_prev_close.is_finite() && self.benchmark_prev_close > f64::EPSILON { self.benchmark_prev_close }
else { self.benchmark_close }
}
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct BacktestResult { pub struct BacktestResult {
pub strategy_name: String, pub strategy_name: String,
@@ -345,7 +334,7 @@ impl BacktestResult {
let mut previous_benchmark = self let mut previous_benchmark = self
.equity_curve .equity_curve
.first() .first()
.map(DailyEquityPoint::benchmark_reference_close) .map(|point| point.benchmark_prev_close)
.unwrap_or_default(); .unwrap_or_default();
for point in &self.equity_curve { for point in &self.equity_curve {
let point_nav = if point.unit_nav.is_finite() && point.unit_nav > 0.0 { let point_nav = if point.unit_nav.is_finite() && point.unit_nav > 0.0 {
@@ -465,7 +454,8 @@ pub struct BacktestEngine<S, C, R> {
futures_cost_model: FuturesTransactionCostModel, futures_cost_model: FuturesTransactionCostModel,
futures_validation_config: FuturesValidationConfig, futures_validation_config: FuturesValidationConfig,
execution_quote_loader: Option<ExecutionQuoteLoader>, execution_quote_loader: Option<ExecutionQuoteLoader>,
preplanned_decision_quote_symbols_by_date: Option<Arc<BTreeMap<NaiveDate, BTreeSet<String>>>>, preplanned_decision_quote_symbols_by_date:
Option<Arc<BTreeMap<NaiveDate, BTreeSet<String>>>>,
execution_quote_request_cache: execution_quote_request_cache:
BTreeSet<(NaiveDate, String, Option<NaiveTime>, Option<NaiveTime>)>, BTreeSet<(NaiveDate, String, Option<NaiveTime>, Option<NaiveTime>)>,
risk_free_rate_contract: Option<RiskFreeRateContract>, risk_free_rate_contract: Option<RiskFreeRateContract>,
@@ -2213,7 +2203,6 @@ where
previous_external_cash_flow_total = portfolio.external_cash_flow_total(); previous_external_cash_flow_total = portfolio.external_cash_flow_total();
result.equity_curve.push(DailyEquityPoint { result.equity_curve.push(DailyEquityPoint {
signal_baseline: true,
date: execution_date, date: execution_date,
cash: aggregate_cash, cash: aggregate_cash,
market_value: aggregate_market_value, market_value: aggregate_market_value,
@@ -2549,8 +2538,9 @@ where
.map(Arc::clone) .map(Arc::clone)
{ {
let empty_symbols = BTreeSet::new(); let empty_symbols = BTreeSet::new();
let decision_quote_symbols = let decision_quote_symbols = preplanned
preplanned.get(&execution_date).unwrap_or(&empty_symbols); .get(&execution_date)
.unwrap_or(&empty_symbols);
self.ensure_execution_quotes_for_symbols_at_times( self.ensure_execution_quotes_for_symbols_at_times(
execution_date, execution_date,
decision_quote_symbols, decision_quote_symbols,
@@ -3381,7 +3371,6 @@ where
previous_external_cash_flow_total = portfolio.external_cash_flow_total(); previous_external_cash_flow_total = portfolio.external_cash_flow_total();
result.equity_curve.push(DailyEquityPoint { result.equity_curve.push(DailyEquityPoint {
signal_baseline: false,
date: execution_date, date: execution_date,
cash: aggregate_cash, cash: aggregate_cash,
market_value: aggregate_market_value, market_value: aggregate_market_value,
@@ -1,234 +0,0 @@
//! 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
-5
View File
@@ -2,11 +2,6 @@ pub mod broker;
pub mod calendar; pub mod calendar;
pub mod cost; pub mod cost;
pub mod data; pub mod data;
pub mod daily_patterns;
pub mod pattern_context;
pub mod session_events;
pub mod factor_events;
pub mod factor_cross_section;
pub mod engine; pub mod engine;
pub mod event_bus; pub mod event_bus;
pub mod events; pub mod events;
+7 -12
View File
@@ -108,7 +108,13 @@ pub fn compute_backtest_metrics(
}; };
let trade_days = equity_curve.len(); let trade_days = equity_curve.len();
let benchmark_start = first_point.benchmark_reference_close(); let benchmark_start = if first_point.benchmark_prev_close.is_finite()
&& first_point.benchmark_prev_close > f64::EPSILON
{
first_point.benchmark_prev_close
} else {
first_point.benchmark_close
};
let explicit_unit_nav = equity_curve.iter().any(|point| { let explicit_unit_nav = equity_curve.iter().any(|point| {
point.external_cash_flow.abs() > f64::EPSILON point.external_cash_flow.abs() > f64::EPSILON
|| (point.unit_nav.is_finite() || (point.unit_nav.is_finite()
@@ -774,7 +780,6 @@ mod tests {
benchmark_prev_close: f64, benchmark_prev_close: f64,
) -> DailyEquityPoint { ) -> DailyEquityPoint {
DailyEquityPoint { DailyEquityPoint {
signal_baseline: false,
date: NaiveDate::parse_from_str(date, "%Y-%m-%d").unwrap(), date: NaiveDate::parse_from_str(date, "%Y-%m-%d").unwrap(),
cash: total_equity, cash: total_equity,
market_value: 0.0, market_value: 0.0,
@@ -799,21 +804,11 @@ mod tests {
assert!((metrics.benchmark_cumulative_return - expected).abs() < 1e-12); assert!((metrics.benchmark_cumulative_return - expected).abs() < 1e-12);
} }
#[test]
fn signal_baseline_uses_same_close_for_strategy_and_benchmark() {
let mut baseline=equity_point("2026-09-04",100.0,4548.0499,4552.5784);
baseline.signal_baseline=true;
let curve=vec![baseline,equity_point("2026-09-08",104.0,4558.7371,4575.0245)];
let metrics=compute_backtest_metrics(&curve,&[],&[],&[],100.0,None).unwrap();
assert!((metrics.benchmark_cumulative_return-(4558.7371/4548.0499-1.0)).abs()<1e-12);
}
#[test] #[test]
fn external_cash_flow_is_excluded_from_return_and_reported_separately() { fn external_cash_flow_is_excluded_from_return_and_reported_separately() {
let curve = vec![ let curve = vec![
equity_point("2025-01-02", 100.0, 100.0, 100.0), equity_point("2025-01-02", 100.0, 100.0, 100.0),
DailyEquityPoint { DailyEquityPoint {
signal_baseline: false,
date: NaiveDate::from_ymd_opt(2025, 1, 3).unwrap(), date: NaiveDate::from_ymd_opt(2025, 1, 3).unwrap(),
cash: 220.0, cash: 220.0,
market_value: 0.0, market_value: 0.0,
-427
View File
@@ -1,427 +0,0 @@
//! Explicit reference identities and frozen rank universes shared by all daily runtimes.
use crate::{
daily_patterns::{dataset_series, evaluate_with_context, PatternSpec, ResearchContext},
factor_events::{field_dependencies, Expr},
DataSet,
};
use chrono::NaiveDate;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::{BTreeMap, BTreeSet};
pub const CONTRACT: &str = "fidc_pattern_execution_context_v1";
pub const CONTEXT_FIELDS: &[&str] = &[
"index_open",
"index_high",
"index_low",
"index_close",
"scope_rank",
"scope_percentile",
"scope_size",
];
const STOCK_FIELDS: &[&str] = &[
"open",
"high",
"low",
"close",
"volume",
"raw_open",
"raw_high",
"raw_low",
"raw_close",
"prev_close",
"amount",
];
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ExecutionContext {
pub contract: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub benchmark: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rank_expression: Option<Expr>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub rank_universe: Vec<String>,
}
fn valid_symbol(s: &str) -> bool {
let Some((code, market)) = s.split_once('.') else {
return false;
};
code.len() == 6
&& code.bytes().all(|c| c.is_ascii_digit())
&& matches!(market, "SH" | "SZ" | "BJ" | "CSI")
}
impl ExecutionContext {
pub fn fields(&self, expression: &Expr) -> BTreeSet<String> {
let mut fields = field_dependencies(expression);
if let Some(rank) = &self.rank_expression {
fields.extend(field_dependencies(rank));
}
fields
}
pub fn validate(&self, expression: &Expr) -> Result<(), String> {
if self.contract != CONTRACT {
return Err("pattern_context_contract_invalid".into());
}
let needed = field_dependencies(expression);
let ranked = needed.iter().any(|f| f.starts_with("scope_"));
if ranked != self.rank_expression.is_some() || !ranked && !self.rank_universe.is_empty() {
return Err("pattern_rank_expression_and_universe_required".into());
}
if ranked
&& (self.rank_universe.len() < 2
|| self.rank_universe.len() > 20_000
|| self.rank_universe.iter().any(|s| !valid_symbol(s))
|| self.rank_universe.iter().collect::<BTreeSet<_>>().len()
!= self.rank_universe.len())
{
return Err("pattern_rank_universe_invalid".into());
}
if let Some(rank) = &self.rank_expression {
let fields = field_dependencies(rank);
if fields
.iter()
.any(|f| !STOCK_FIELDS.contains(&f.as_str()) && !f.starts_with("index_"))
{
return Err("pattern_rank_expression_invalid_or_recursive".into());
}
}
let fields = self.fields(expression);
if fields
.iter()
.any(|f| !STOCK_FIELDS.contains(&f.as_str()) && !CONTEXT_FIELDS.contains(&f.as_str()))
{
return Err("pattern_context_unmapped_field".into());
}
let index = fields.iter().any(|f| f.starts_with("index_"));
if index != self.benchmark.is_some()
|| self
.benchmark
.as_ref()
.is_some_and(|s| !valid_symbol(s) || s.ends_with(".BJ"))
{
return Err("pattern_reference_index_required".into());
}
if !index && !ranked {
return Err("pattern_unused_context".into());
}
Ok(())
}
}
pub fn build_dataset_context(
spec: &PatternSpec,
data: &DataSet,
date: NaiveDate,
) -> Result<ResearchContext, String> {
let Some(config) = &spec.execution_context else {
return Ok(ResearchContext::default());
};
config.validate(
spec.expression
.as_ref()
.ok_or("pattern_context_requires_expression")?,
)?;
let days = data.calendar().trailing_days(date, spec.history_len());
if days.len() != spec.history_len() || days.last() != Some(&date) {
return Err("pattern_context_calendar_incomplete".into());
}
let needed = config.fields(spec.expression.as_ref().unwrap());
let mut context = ResearchContext::default();
if let Some(symbol) = &config.benchmark {
for name in needed.iter().filter(|f| f.starts_with("index_")) {
let values = days
.iter()
.map(|d| {
let value = if let Some(b) = data.market(*d, symbol) {
match name.as_str() {
"index_open" => Some(b.open),
"index_high" => Some(b.high),
"index_low" => Some(b.low),
"index_close" => Some(b.close),
_ => None,
}
} else if let Some(b) = data.benchmark(*d).filter(|b| &b.benchmark == symbol) {
match name.as_str() {
"index_open" => Some(b.open),
"index_close" => Some(b.close),
_ => None,
}
} else {
None
};
value
.filter(|v| v.is_finite() && *v > 0.0)
.map(Some)
.ok_or_else(|| format!("pattern_reference_missing: {symbol} {d} {name}"))
})
.collect::<Result<Vec<_>, _>>()?;
context.common.insert(name.clone(), values);
}
}
if let Some(expression) = &config.rank_expression {
let mut input = spec.clone();
input.execution_context = None;
input.expression = Some(expression.clone());
let mut values = BTreeMap::new();
for symbol in &config.rank_universe {
let row = evaluate_with_context(
&input,
&days,
&dataset_series(data, &days, symbol),
&context.common,
true,
)?;
if let Some(reason) = row.exclusion {
return Err(format!("pattern_rank_member_incomplete: {symbol} {reason}"));
}
values.insert(
symbol.clone(),
serde_json::from_value::<Vec<Option<f64>>>(
row.values["expression"]["values"].clone(),
)
.map_err(|e| e.to_string())?,
);
}
let ranks =
crate::factor_cross_section::rank_history(&days, &config.rank_universe, &values)?;
for symbol in &config.rank_universe {
let decode = |value: &Value| {
serde_json::from_value::<Vec<Option<f64>>>(value.clone()).map_err(|e| e.to_string())
};
context.by_symbol.insert(
symbol.clone(),
BTreeMap::from([
("scope_rank".into(), decode(&ranks["rank"][symbol])?),
(
"scope_percentile".into(),
decode(&ranks["percentile"][symbol])?,
),
(
"scope_size".into(),
vec![Some(config.rank_universe.len() as f64); days.len()],
),
]),
);
}
}
Ok(context)
}
pub fn specs_in_value(value: &Value) -> Result<Vec<PatternSpec>, String> {
let mut specs = Vec::new();
match value {
Value::String(text) => specs.extend(crate::daily_patterns::expression_specs(text)?),
Value::Array(items) => {
for v in items {
specs.extend(specs_in_value(v)?);
}
}
Value::Object(items) => {
for v in items.values() {
specs.extend(specs_in_value(v)?);
}
}
_ => {}
}
Ok(specs)
}
pub fn required_symbols(value: &Value) -> Result<(BTreeSet<String>, BTreeSet<String>), String> {
let (mut indices, mut stocks) = (BTreeSet::new(), BTreeSet::new());
for spec in specs_in_value(value)? {
if let Some(context) = spec.execution_context {
if let Some(index) = context.benchmark {
indices.insert(index);
}
stocks.extend(context.rank_universe);
}
}
Ok((indices, stocks))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{BenchmarkSnapshot, DailyFactorSnapshot, DailyMarketSnapshot, Instrument};
use serde_json::json;
#[test]
fn normalized_rule_does_not_turn_an_omitted_window_into_explicit_null() {
let expression:Expr=serde_json::from_value(json!({"kind":"operator","name":"GT","args":[{"kind":"field","name":"close"},{"kind":"number","value":1}]})).unwrap();
assert!(serde_json::to_value(expression).unwrap().get("window").is_none());
}
fn data(future: bool, reference: bool) -> DataSet {
let mut days = vec![
NaiveDate::from_ymd_opt(2026, 9, 4).unwrap(),
NaiveDate::from_ymd_opt(2026, 9, 7).unwrap(),
NaiveDate::from_ymd_opt(2026, 9, 8).unwrap(),
];
if future {
days.push(NaiveDate::from_ymd_opt(2026, 9, 9).unwrap());
}
let symbols = vec!["000001.SZ", "000002.SZ", "000003.SZ"];
let mut instruments = symbols
.iter()
.map(|s| Instrument {
symbol: s.to_string(),
name: s.to_string(),
board: "SZ_MAIN".into(),
round_lot: 100,
listed_at: None,
delisted_at: None,
status: "active".into(),
})
.collect::<Vec<_>>();
if reference {
instruments.push(Instrument {
symbol: "399006.SZ".into(),
name: "reference".into(),
board: "INDEX".into(),
round_lot: 1,
listed_at: None,
delisted_at: None,
status: "active".into(),
});
}
let mut market = vec![];
let mut factors = vec![];
let mut benchmark = vec![];
for (i, d) in days.iter().enumerate() {
for (n, s) in symbols.iter().enumerate() {
let c = [
[10., 12., 11., 1000.],
[10., 11., 12., 1.],
[10., 10., 13., 1.],
][n][i];
market.push(DailyMarketSnapshot {
date: *d,
symbol: s.to_string(),
timestamp: None,
day_open: c,
open: c,
high: c,
low: c,
close: c,
last_price: c,
bid1: c,
ask1: c,
prev_close: 10.,
volume: 100000,
minute_volume: 0,
bid1_volume: 10000,
ask1_volume: 10000,
trading_phase: None,
paused: false,
upper_limit: c * 2.,
lower_limit: c / 2.,
price_tick: 0.01,
});
factors.push(DailyFactorSnapshot {
date: *d,
symbol: s.to_string(),
market_cap_bn: 1.,
free_float_cap_bn: 1.,
pe_ttm: 10.,
turnover_ratio: None,
effective_turnover_ratio: None,
adjustment_factor_backward1: Some(1.),
extra_factors: Default::default(),
});
}
if reference {
let mut row = market.last().unwrap().clone();
row.symbol = "399006.SZ".into();
row.open = 30.;
row.high = 30.;
row.low = 30.;
row.close = 30.;
market.push(row);
}
benchmark.push(BenchmarkSnapshot {
date: *d,
benchmark: "000300.SH".into(),
open: 4000.,
close: 4000.,
prev_close: 4000.,
volume: 1000,
});
}
DataSet::from_components(instruments, market, factors, vec![], benchmark).unwrap()
}
fn spec(rank: bool) -> PatternSpec {
let expression = if rank {
json!({"kind":"operator","name":"GT","args":[{"kind":"field","name":"scope_rank"},{"kind":"number","value":2}]})
} else {
json!({"kind":"operator","name":"LT","args":[{"kind":"field","name":"index_close"},{"kind":"number","value":100}]})
};
let context = if rank {
json!({"contract":CONTRACT,"rank_expression":{"kind":"operator","name":"PCT_CHANGE","window":1,"args":[{"kind":"field","name":"close"}]},"rank_universe":["000001.SZ","000002.SZ","000003.SZ"]})
} else {
json!({"contract":CONTRACT,"benchmark":"399006.SZ"})
};
serde_json::from_value::<PatternSpec>(json!({"template":"expression","parameters":{"history_window":3},"expression":expression,"execution_context":context})).unwrap().validate().unwrap()
}
#[test]
fn dataset_rank_is_full_scope_causal_and_equal_to_pure_cross_section() {
let spec = spec(true);
let date = NaiveDate::from_ymd_opt(2026, 9, 8).unwrap();
let original = build_dataset_context(&spec, &data(false, true), date).unwrap();
let future = build_dataset_context(&spec, &data(true, true), date).unwrap();
assert_eq!(original.by_symbol, future.by_symbol);
assert_eq!(original.by_symbol["000001.SZ"]["scope_rank"][2], Some(3.));
assert_eq!(original.by_symbol["000002.SZ"]["scope_rank"][2], Some(2.));
assert_eq!(original.by_symbol["000003.SZ"]["scope_rank"][2], Some(1.));
assert!(
crate::daily_patterns::evaluate_dataset(&spec, &data(false, true), date, "000001.SZ")
.unwrap()
.matched
);
let mut incomplete = data(false, true).snapshot_components();
incomplete.market.retain(|r| r.symbol != "000003.SZ");
let broken = DataSet::from_components(
incomplete.instruments,
incomplete.market,
incomplete.factors,
incomplete.candidates,
incomplete.benchmarks,
)
.unwrap();
assert!(build_dataset_context(&spec, &broken, date).is_err());
}
#[test]
fn reference_index_never_defaults_to_performance_benchmark() {
let spec = spec(false);
let date = NaiveDate::from_ymd_opt(2026, 9, 8).unwrap();
assert!(
crate::daily_patterns::evaluate_dataset(&spec, &data(false, true), date, "000001.SZ")
.unwrap()
.matched
);
assert!(build_dataset_context(&spec, &data(false, false), date)
.unwrap_err()
.contains("399006.SZ"));
}
#[test]
fn runtime_contract_rejects_missing_range_and_recursive_ranks() {
let mut missing = spec(true);
missing
.execution_context
.as_mut()
.unwrap()
.rank_universe
.clear();
assert!(missing.validate().is_err());
let mut recursive = spec(true);
recursive
.execution_context
.as_mut()
.unwrap()
.rank_expression = Some(Expr::Field {
name: "scope_rank".into(),
});
assert!(recursive.validate().is_err());
}
}
+36 -181
View File
@@ -672,7 +672,6 @@ pub struct PlatformExprStrategyConfig {
pub completed_session_factor_fields: BTreeSet<String>, pub completed_session_factor_fields: BTreeSet<String>,
pub candidate_symbols_by_date: BTreeMap<NaiveDate, BTreeSet<String>>, pub candidate_symbols_by_date: BTreeMap<NaiveDate, BTreeSet<String>>,
pub intraday_execution_time: Option<NaiveTime>, pub intraday_execution_time: Option<NaiveTime>,
pub session_event_times: Vec<NaiveTime>,
pub explicit_action_times: Vec<NaiveTime>, pub explicit_action_times: Vec<NaiveTime>,
pub delayed_limit_open_exit_enabled: bool, pub delayed_limit_open_exit_enabled: bool,
pub delayed_limit_open_exit_time: Option<NaiveTime>, pub delayed_limit_open_exit_time: Option<NaiveTime>,
@@ -754,7 +753,6 @@ impl PlatformExprStrategyConfig {
completed_session_factor_fields: BTreeSet::new(), completed_session_factor_fields: BTreeSet::new(),
candidate_symbols_by_date: BTreeMap::new(), candidate_symbols_by_date: BTreeMap::new(),
intraday_execution_time: None, intraday_execution_time: None,
session_event_times: Vec::new(),
explicit_action_times: Vec::new(), explicit_action_times: Vec::new(),
delayed_limit_open_exit_enabled: false, delayed_limit_open_exit_enabled: false,
delayed_limit_open_exit_time: None, delayed_limit_open_exit_time: None,
@@ -1289,7 +1287,6 @@ struct RuntimeHelperBinding {
#[derive(Clone)] #[derive(Clone)]
enum CompiledRuntimeHelperArgs { enum CompiledRuntimeHelperArgs {
DailyPattern { spec: crate::daily_patterns::PatternSpec, identity: String },
RollingMean { RollingMean {
field: String, field: String,
lookback: usize, lookback: usize,
@@ -1347,11 +1344,6 @@ enum RuntimeHelperResolution {
} }
pub struct PlatformExprStrategy { pub struct PlatformExprStrategy {
pattern_results_date: RefCell<Option<NaiveDate>>,
pattern_results: RefCell<BTreeMap<(NaiveDate, String, String), crate::daily_patterns::PatternResult>>,
pattern_contexts: RefCell<BTreeMap<String,crate::daily_patterns::ResearchContext>>,
pattern_specs: RefCell<BTreeMap<String,String>>,
pattern_frame_at:RefCell<Option<NaiveDateTime>>,
config: PlatformExprStrategyConfig, config: PlatformExprStrategyConfig,
engine: Engine, engine: Engine,
rebalance_day_counter: usize, rebalance_day_counter: usize,
@@ -1800,11 +1792,6 @@ impl PlatformExprStrategy {
stock_extra_factor_identifiers, stock_extra_factor_identifiers,
stock_extra_factor_map_required, stock_extra_factor_map_required,
stock_text_factors_required, stock_text_factors_required,
pattern_results: RefCell::new(BTreeMap::new()),
pattern_contexts: RefCell::new(BTreeMap::new()),
pattern_specs: RefCell::new(BTreeMap::new()),
pattern_frame_at:RefCell::new(None),
pattern_results_date: RefCell::new(None),
stock_state_cache_date: RefCell::new(None), stock_state_cache_date: RefCell::new(None),
stock_state_cache_calendar_index: RefCell::new(None), stock_state_cache_calendar_index: RefCell::new(None),
stock_state_cache: RefCell::new(AHashMap::new()), stock_state_cache: RefCell::new(AHashMap::new()),
@@ -2368,9 +2355,7 @@ impl PlatformExprStrategy {
fn is_runtime_helper(name: &str) -> bool { fn is_runtime_helper(name: &str) -> bool {
matches!( matches!(
name, name,
"pattern_signal" "factor"
| "pattern_score"
| "factor"
| "day_factor" | "day_factor"
| "rolling_mean" | "rolling_mean"
| "rolling_mean_current" | "rolling_mean_current"
@@ -4308,7 +4293,10 @@ impl PlatformExprStrategy {
.factor_snapshot_rows_on(date) .factor_snapshot_rows_on(date)
.iter() .iter()
.flat_map(|row| { .flat_map(|row| {
row.extra_factors.keys().map(|key| key.to_string()).chain( row.extra_factors
.keys()
.map(|key| key.to_string())
.chain(
row.adjustment_factor_backward1 row.adjustment_factor_backward1
.map(|_| BACKWARD_ADJUSTMENT_FACTOR_FIELD.to_string()), .map(|_| BACKWARD_ADJUSTMENT_FACTOR_FIELD.to_string()),
) )
@@ -5713,55 +5701,6 @@ impl PlatformExprStrategy {
args: &CompiledRuntimeHelperArgs, args: &CompiledRuntimeHelperArgs,
) -> Result<RuntimeHelperResolution, BacktestError> { ) -> Result<RuntimeHelperResolution, BacktestError> {
match args { match args {
CompiledRuntimeHelperArgs::DailyPattern { spec, identity } => {
if *self.pattern_results_date.borrow()!=Some(ctx.execution_date) {
self.pattern_results.borrow_mut().clear();self.pattern_contexts.borrow_mut().clear();self.pattern_specs.borrow_mut().clear();
*self.pattern_results_date.borrow_mut()=Some(ctx.execution_date);
}
if *self.pattern_frame_at.borrow()!=ctx.active_datetime {
self.pattern_results.borrow_mut().clear();*self.pattern_frame_at.borrow_mut()=ctx.active_datetime;
}
if spec.template=="session_event" {
if self.config.matching_type!=MatchingType::MinuteLast {return Err(BacktestError::Execution("session_event_requires_minute_last".into()));}
let active=ctx.active_datetime.ok_or_else(||BacktestError::Execution("session_event_requires_explicit_clock".into()))?;
let clock=active.time();
if !((NaiveTime::from_hms_opt(9,31,0).unwrap()<=clock&&clock<=NaiveTime::from_hms_opt(11,30,0).unwrap())||(NaiveTime::from_hms_opt(13,1,0).unwrap()<=clock&&clock<=NaiveTime::from_hms_opt(15,0,0).unwrap())) {
return Ok(if helper=="pattern_signal"{RuntimeHelperResolution::Boolean(false)}else{RuntimeHelperResolution::Number(0.)});
}
let stock=stock.ok_or_else(||BacktestError::Execution("session_event_requires_stock".into()))?;
let key=(active.date(),stock.symbol.to_string(),identity.clone());
if !self.pattern_results.borrow().contains_key(&key) {
let result=crate::session_events::evaluate(spec,&stock.symbol,ctx.data.completed_minute_bars_on(active.date(),&stock.symbol),active).map_err(BacktestError::Execution)?;
self.pattern_results.borrow_mut().insert(key.clone(),result);
self.pattern_specs.borrow_mut().insert(identity.clone(),serde_json::to_string(spec).unwrap());
}
let rows=self.pattern_results.borrow();let result=&rows[&key];
return if helper=="pattern_signal" {Ok(RuntimeHelperResolution::Boolean(result.matched))}else{result.score.map(RuntimeHelperResolution::Number).ok_or_else(||BacktestError::Execution("session_score_unknown".into()))};
}
if !matches!(self.config.matching_type,MatchingType::NextBarOpen|MatchingType::MinuteLast) {
return Err(BacktestError::Execution("daily_pattern_requires_next_bar_open: 完整日线形态只能在下一交易日执行".into()));
}
let date = if self.config.matching_type==MatchingType::MinuteLast {ctx.data.previous_trading_date(ctx.execution_date,1).ok_or_else(||BacktestError::Execution("daily_pattern_previous_completed_date_missing".into()))?} else {day.date.min(ctx.decision_date)};
// Lagged replay retains the decision day's schedule label; execution is on a later session.
if !ctx.is_lagged_execution() && ctx.active_datetime.is_some_and(|t| t.date() == date && t.time() < NaiveTime::from_hms_opt(16, 0, 0).unwrap()) {
return Err(BacktestError::Execution(format!("daily_pattern_not_yet_visible: 不允许使用未完成的当日日线; decision_date={date}, execution_date={}, active_datetime={:?}",ctx.execution_date,ctx.active_datetime)));
}
let stock = stock.ok_or_else(|| BacktestError::Execution("pattern_signal requires stock context".into()))?;
let key = (date, stock.symbol.to_string(), identity.clone());
if !self.pattern_results.borrow().contains_key(&key) {
if !self.pattern_contexts.borrow().contains_key(&key.2) {
let context=crate::pattern_context::build_dataset_context(spec,ctx.data,date).map_err(BacktestError::Execution)?;
self.pattern_contexts.borrow_mut().insert(key.2.clone(),context);
self.pattern_specs.borrow_mut().insert(key.2.clone(),serde_json::to_string(spec).unwrap());
}
let result = crate::daily_patterns::evaluate_dataset_context(spec,ctx.data,date,&stock.symbol,&self.pattern_contexts.borrow()[&key.2]).map_err(BacktestError::Execution)?;
self.pattern_results.borrow_mut().insert(key.clone(),result);
}
let results = self.pattern_results.borrow();
let result = &results[&key];
if helper == "pattern_signal" { Ok(RuntimeHelperResolution::Boolean(result.matched)) }
else { result.score.map(RuntimeHelperResolution::Number).ok_or_else(|| BacktestError::Execution(format!("pattern_score_unavailable: {} {date}; 先通过形态筛选再排序",stock.symbol))) }
}
CompiledRuntimeHelperArgs::RollingMean { CompiledRuntimeHelperArgs::RollingMean {
field, field,
lookback, lookback,
@@ -6198,7 +6137,7 @@ impl PlatformExprStrategy {
if Self::is_reserved_scope_name(identifier.as_str()) if Self::is_reserved_scope_name(identifier.as_str())
|| self.prelude_declared_identifiers.contains(identifier) || self.prelude_declared_identifiers.contains(identifier)
|| (!self.stock_extra_factor_identifiers.contains(identifier) || (!self.stock_extra_factor_identifiers.contains(identifier)
&& !item.extra_factors.contains_key(identifier.as_str()) && !item.extra_factors.contains_key(identifier)
&& !day.available_factor_names.contains(identifier) && !day.available_factor_names.contains(identifier)
&& !day.available_text_factor_names.contains(identifier)) && !day.available_text_factor_names.contains(identifier))
{ {
@@ -6209,7 +6148,7 @@ impl PlatformExprStrategy {
} else { } else {
let value = item let value = item
.extra_factors .extra_factors
.get(identifier.as_str()) .get(identifier)
.copied() .copied()
.unwrap_or(f64::NAN); .unwrap_or(f64::NAN);
scope.push_dynamic(identifier.clone(), Dynamic::from(value)); scope.push_dynamic(identifier.clone(), Dynamic::from(value));
@@ -7013,14 +6952,6 @@ impl PlatformExprStrategy {
)) ))
}; };
match helper { match helper {
"pattern_signal" | "pattern_score" if args.len() == 1 => {
let text: String = serde_json::from_str(&args[0]).ok()?;
let spec: crate::daily_patterns::PatternSpec = serde_json::from_str(&text).ok()?;
let spec=spec.validate().ok()?;
use sha2::Digest;
let identity=format!("{:x}",sha2::Sha256::digest(serde_json::to_vec(&spec).ok()?));
Some(CompiledRuntimeHelperArgs::DailyPattern { spec, identity })
}
"rolling_mean" | "sma" | "ma" => { "rolling_mean" | "sma" | "ma" => {
let (field, lookback) = field_lookback()?; let (field, lookback) = field_lookback()?;
Some(CompiledRuntimeHelperArgs::RollingMean { Some(CompiledRuntimeHelperArgs::RollingMean {
@@ -7097,8 +7028,8 @@ impl PlatformExprStrategy {
fn numeric_vm_helper_type(helper: &str) -> Option<NumericVmValueType> { fn numeric_vm_helper_type(helper: &str) -> Option<NumericVmValueType> {
match helper { match helper {
"pattern_signal" | "has_dividend" | "has_split" | "is_margin_stock" => Some(NumericVmValueType::Boolean), "has_dividend" | "has_split" | "is_margin_stock" => Some(NumericVmValueType::Boolean),
"pattern_score" | "rolling_mean" "rolling_mean"
| "sma" | "sma"
| "ma" | "ma"
| "rolling_mean_current" | "rolling_mean_current"
@@ -7240,7 +7171,6 @@ impl PlatformExprStrategy {
return self.resolve_compiled_runtime_helper(ctx, day, stock, helper, &compiled_args); return self.resolve_compiled_runtime_helper(ctx, day, stock, helper, &compiled_args);
} }
match helper { match helper {
"pattern_signal" | "pattern_score" => Err(BacktestError::Execution("daily_pattern_spec_invalid: 需要一个有效的模板 JSON 字符串参数".into())),
"factor" => { "factor" => {
let key = Self::normalize_runtime_factor_key(&Self::parse_string_or_identifier( let key = Self::normalize_runtime_factor_key(&Self::parse_string_or_identifier(
args.first().map(String::as_str).unwrap_or_default(), args.first().map(String::as_str).unwrap_or_default(),
@@ -10614,7 +10544,10 @@ impl PlatformExprStrategy {
) -> std::ops::Range<usize> { ) -> std::ops::Range<usize> {
if !matches!( if !matches!(
self.config.market_cap_field.as_str(), self.config.market_cap_field.as_str(),
"market_cap" | "market_cap_bn" | "candidate_market_cap" | "candidate_market_cap_bn" "market_cap"
| "market_cap_bn"
| "candidate_market_cap"
| "candidate_market_cap_bn"
) || !band_low.is_finite() ) || !band_low.is_finite()
|| !band_high.is_finite() || !band_high.is_finite()
{ {
@@ -10632,7 +10565,8 @@ impl PlatformExprStrategy {
}; };
let start = symbol_ids.partition_point(|symbol_id| market_cap(*symbol_id) < band_low); let start = symbol_ids.partition_point(|symbol_id| market_cap(*symbol_id) < band_low);
let end = start let end = start
+ symbol_ids[start..].partition_point(|symbol_id| market_cap(*symbol_id) <= band_high); + symbol_ids[start..]
.partition_point(|symbol_id| market_cap(*symbol_id) <= band_high);
start..end start..end
} }
@@ -11778,8 +11712,11 @@ impl PlatformExprStrategy {
&execution_day, &execution_day,
&factor_day, &factor_day,
)?; )?;
let field_value = let field_value = self.selection_field_value_from_caps(
self.selection_field_value_from_caps(market_cap_bn, free_float_cap_bn, &stock); market_cap_bn,
free_float_cap_bn,
&stock,
);
if !field_value.is_finite() { if !field_value.is_finite() {
if diagnostics.len() < 12 { if diagnostics.len() < 12 {
diagnostics.push(format!( diagnostics.push(format!(
@@ -12296,15 +12233,13 @@ impl Strategy for PlatformExprStrategy {
.is_some() .is_some()
&& self.config.rotation_enabled && self.config.rotation_enabled
{ {
let schedule=self.config.rebalance_schedule.as_ref().expect("checked timed rebalance schedule"); rules.push(
if self.config.session_event_times.is_empty() { self.config
rules.push(schedule.as_schedule_rule(ScheduleStage::OnDay)); .rebalance_schedule
} else { .as_ref()
for time in &self.config.session_event_times { .expect("checked timed rebalance schedule")
let mut timed=schedule.clone();timed.time_rule=Some(ScheduleTimeRule::physical_time(time.hour(),time.minute())); .as_schedule_rule(ScheduleStage::OnDay),
rules.push(timed.as_schedule_rule(ScheduleStage::OnDay)); );
}
}
} }
if self.config.explicit_actions.is_empty() { if self.config.explicit_actions.is_empty() {
return rules; return rules;
@@ -12361,13 +12296,11 @@ impl Strategy for PlatformExprStrategy {
decision.merge_from(rotation?); decision.merge_from(rotation?);
} }
self.attach_buy_denials(ctx, &mut decision)?; self.attach_buy_denials(ctx, &mut decision)?;
self.append_pattern_diagnostics(&mut decision);
Ok(decision) Ok(decision)
} }
fn decision_quote_times(&self) -> Vec<NaiveTime> { fn decision_quote_times(&self) -> Vec<NaiveTime> {
let mut times = BTreeSet::new(); let mut times = BTreeSet::new();
times.extend(self.config.session_event_times.iter().copied());
if self.uses_intraday_execution_quotes() { if self.uses_intraday_execution_quotes() {
if self.config.explicit_action_times.is_empty() { if self.config.explicit_action_times.is_empty() {
times.insert(self.intraday_execution_start_time()); times.insert(self.intraday_execution_start_time());
@@ -12413,7 +12346,6 @@ impl Strategy for PlatformExprStrategy {
{ {
let mut decision = self.explicit_action_decision(ctx)?; let mut decision = self.explicit_action_decision(ctx)?;
self.attach_buy_denials(ctx, &mut decision)?; self.attach_buy_denials(ctx, &mut decision)?;
self.append_pattern_diagnostics(&mut decision);
return Ok(decision); return Ok(decision);
} }
Ok(StrategyDecision::default()) Ok(StrategyDecision::default())
@@ -12422,7 +12354,6 @@ impl Strategy for PlatformExprStrategy {
fn on_day(&mut self, ctx: &StrategyContext<'_>) -> Result<StrategyDecision, BacktestError> { fn on_day(&mut self, ctx: &StrategyContext<'_>) -> Result<StrategyDecision, BacktestError> {
let mut decision = self.compute_day_decision(ctx)?; let mut decision = self.compute_day_decision(ctx)?;
self.attach_buy_denials(ctx, &mut decision)?; self.attach_buy_denials(ctx, &mut decision)?;
self.append_pattern_diagnostics(&mut decision);
Ok(decision) Ok(decision)
} }
} }
@@ -12461,31 +12392,6 @@ impl PlatformExprStrategy {
Ok(()) Ok(())
} }
fn append_pattern_diagnostics(&self, decision: &mut StrategyDecision) {
let mut contexts=BTreeMap::<String,serde_json::Value>::new();
for ((date, symbol, spec), result) in self.pattern_results.borrow().iter() {
if result.values["session_contract"]==crate::session_events::CONTRACT {
let record=serde_json::json!({"event":"session_event_decision","date":date,"symbol":symbol,"spec_sha256":spec,"matched":result.matched,"signal_bar_end":result.values["signal_bar_end"],"decision_at":result.values["decision_at"],"exclusion":result.exclusion}).to_string();
if !decision.diagnostics.contains(&record){decision.diagnostics.push(record)}
continue;
}
if result.values["execution_context_latest"].as_object().is_some_and(|v|!v.is_empty()) {
let group=contexts.entry(spec.clone()).or_insert_with(||serde_json::json!({"event":"daily_pattern_context_decisions","date":date,"spec_sha256":spec,"evaluated":0,"matched":0,"excluded":0,"sample_limit":20,"samples":[]}));
group["evaluated"]=serde_json::json!(group["evaluated"].as_u64().unwrap()+1);
group["matched"]=serde_json::json!(group["matched"].as_u64().unwrap()+u64::from(result.matched));
group["excluded"]=serde_json::json!(group["excluded"].as_u64().unwrap()+u64::from(result.exclusion.is_some()));
let samples=group["samples"].as_array_mut().unwrap();
if samples.len()<20 {samples.push(serde_json::json!({"symbol":symbol,"matched":result.matched,"context":result.values["execution_context_latest"],"exclusion":result.exclusion}));}
continue;
}
if let Some(evidence) = &result.exclusion {
let record = serde_json::json!({"event":"daily_pattern_excluded","date":date,"symbol":symbol,"spec":self.pattern_specs.borrow().get(spec),"spec_sha256":spec,"evidence":evidence}).to_string();
if !decision.diagnostics.contains(&record) { decision.diagnostics.push(record); }
}
}
for value in contexts.values() {let record=value.to_string();if !decision.diagnostics.contains(&record){decision.diagnostics.push(record);}}
}
fn compute_day_decision(&mut self, ctx: &StrategyContext<'_>) -> Result<StrategyDecision, BacktestError> { fn compute_day_decision(&mut self, ctx: &StrategyContext<'_>) -> Result<StrategyDecision, BacktestError> {
if self.config.rotation_enabled if self.config.rotation_enabled
&& self && self
@@ -14305,30 +14211,6 @@ mod tests {
assert_eq!(strategy.selection_quote_usage, StockFilterQuoteUsage::DailyOnly); assert_eq!(strategy.selection_quote_usage, StockFilterQuoteUsage::DailyOnly);
} }
#[test]
fn daily_pattern_runtime_uses_the_shared_kernel_and_rejects_early_visibility() {
let dates=(0..21).map(|n|d(2025,1,1)+chrono::Duration::days(n)).collect::<Vec<_>>();
let symbol="000001.SZ";
let mut parts=single_symbol_platform_data(&dates,symbol).snapshot_components();
for f in &mut parts.factors {f.adjustment_factor_backward1=Some(1.0);}
// Future execution-day prices must not enter the previous day's rule.
let last=parts.market.last_mut().unwrap();last.close=5.0;last.low=5.0;last.last_price=5.0;
let data=DataSet::from_components(parts.instruments,parts.market,parts.factors,parts.candidates,parts.benchmarks).unwrap();
let portfolio=PortfolioState::new(100_000.0);let subscriptions=BTreeSet::new();
let mut ctx=StrategyContext {execution_date:dates[20],decision_date:dates[19],decision_index:19,data:&data,portfolio:&portfolio,futures_account:None,open_orders:&[],dynamic_universe:None,subscriptions:&subscriptions,process_events:&[],active_process_event:None,active_datetime:None,order_events:&[],fills:&[]};
let mut cfg=PlatformExprStrategyConfig::generic();cfg.signal_symbol=symbol.into();cfg.matching_type=MatchingType::NextBarOpen;
let mut strategy=PlatformExprStrategy::new(cfg);
let expression=r#"pattern_signal("{\"template\":\"ma_below\",\"parameters\":{\"ma_window\":20}}")"#;
let day=strategy.day_state(&ctx,dates[19]).unwrap();let stock=strategy.stock_state(&ctx,dates[19],symbol).unwrap();
assert!(!strategy.eval_bool(&ctx,expression,&day,Some(&stock),None).unwrap());
ctx.active_datetime=dates[19].and_hms_opt(10,18,0);
assert!(!strategy.eval_bool(&ctx,expression,&day,Some(&stock),None).unwrap());
ctx.execution_date=dates[19];
assert!(strategy.eval_bool(&ctx,expression,&day,Some(&stock),None).unwrap_err().to_string().contains("not_yet_visible"));
ctx.active_datetime=None;strategy.config.matching_type=MatchingType::CurrentBarClose;
assert!(strategy.eval_bool(&ctx,expression,&day,Some(&stock),None).unwrap_err().to_string().contains("requires_next_bar_open"));
}
#[test] #[test]
fn buy_quote_filter_rejects_missing_intraday_quote_not_daily_close() { fn buy_quote_filter_rejects_missing_intraday_quote_not_daily_close() {
let date = d(2025, 1, 2); let date = d(2025, 1, 2);
@@ -14354,28 +14236,6 @@ mod tests {
assert_eq!(strategy.selection_quote_usage, StockFilterQuoteUsage::DailyOnly); assert_eq!(strategy.selection_quote_usage, StockFilterQuoteUsage::DailyOnly);
} }
#[test]
fn session_event_uses_previous_bar_and_recomputes_at_each_minute_without_becoming_a_quote() {
let date = d(2026,9,8); let symbol = "000001.SZ";
let bars = (0..=33).map(|i| {
let timestamp = date.and_hms_opt(9,30,0).unwrap() + chrono::Duration::minutes(i);
let close = if i==31 {11.} else {10.};
crate::session_events::MinuteBar {symbol:symbol.into(),timestamp,available_at:timestamp,open:close,high:close,low:close,close,volume:100.,amount:close*100.}
}).collect();
let data = single_symbol_platform_data(&[date],symbol).with_completed_minute_bars(bars).unwrap();
let portfolio=PortfolioState::new(100_000.); let subscriptions=BTreeSet::new();
let mut ctx=StrategyContext {execution_date:date,decision_date:date,decision_index:0,data:&data,portfolio:&portfolio,futures_account:None,open_orders:&[],dynamic_universe:None,subscriptions:&subscriptions,process_events:&[],active_process_event:None,active_datetime:None,order_events:&[],fills:&[]};
let mut config=PlatformExprStrategyConfig::generic();config.signal_symbol=symbol.into();config.matching_type=MatchingType::MinuteLast;
let strategy=PlatformExprStrategy::new(config);
let expression=r#"pattern_signal("{\"template\":\"session_event\",\"session_event\":\"OPENING_RANGE_BREAKOUT_UP\",\"parameters\":{}}")"#;
let day=strategy.day_state(&ctx,date).unwrap();let stock=strategy.stock_state(&ctx,date,symbol).unwrap();
for (minute,expected) in [(1,false),(2,true),(3,false)] {
ctx.active_datetime=date.and_hms_opt(10,minute,0);
assert_eq!(strategy.eval_bool(&ctx,expression,&day,Some(&stock),None).unwrap(),expected);
}
assert!(data.snapshot_components().execution_quotes.is_empty());
}
#[test] #[test]
fn buy_filter_uses_active_schedule_time_instead_of_first_configured_time() { fn buy_filter_uses_active_schedule_time_instead_of_first_configured_time() {
let date = d(2025, 1, 2); let date = d(2025, 1, 2);
@@ -16773,16 +16633,15 @@ mod tests {
cfg.market_cap_field = "market_cap".to_string(); cfg.market_cap_field = "market_cap".to_string();
let strategy = PlatformExprStrategy::new(cfg.clone()); let strategy = PlatformExprStrategy::new(cfg.clone());
let range = let range = strategy.market_cap_ordered_selection_range(
strategy.market_cap_ordered_selection_range(&factor_day, symbol_ids, 10.0, 20.0); &factor_day,
symbol_ids,
10.0,
20.0,
);
let selected_caps = symbol_ids[range] let selected_caps = symbol_ids[range]
.iter() .iter()
.map(|symbol_id| { .map(|symbol_id| factor_day.factor(*symbol_id).expect("factor row").market_cap_bn)
factor_day
.factor(*symbol_id)
.expect("factor row")
.market_cap_bn
})
.collect::<Vec<_>>(); .collect::<Vec<_>>();
assert_eq!(selected_caps, vec![10.0, 20.0]); assert_eq!(selected_caps, vec![10.0, 20.0]);
@@ -16793,12 +16652,8 @@ mod tests {
); );
cfg.market_cap_field = "free_float_cap".to_string(); cfg.market_cap_field = "free_float_cap".to_string();
assert_eq!( assert_eq!(
PlatformExprStrategy::new(cfg).market_cap_ordered_selection_range( PlatformExprStrategy::new(cfg)
&factor_day, .market_cap_ordered_selection_range(&factor_day, symbol_ids, 10.0, 20.0),
symbol_ids,
10.0,
20.0
),
0..symbol_ids.len() 0..symbol_ids.len()
); );
} }
@@ -227,8 +227,6 @@ const RUNTIME_HELPER_FUNCTIONS: &[&str] = &[
"factor", "factor",
"day_factor", "day_factor",
"rolling_mean", "rolling_mean",
"pattern_signal",
"pattern_score",
"rolling_mean_current", "rolling_mean_current",
"rolling_max_current", "rolling_max_current",
"rolling_return_stddev_current", "rolling_return_stddev_current",
@@ -2538,10 +2538,6 @@ pub fn platform_expr_config_from_spec(
cfg.benchmark_symbol = normalize_symbol(&cfg.benchmark_symbol, None); cfg.benchmark_symbol = normalize_symbol(&cfg.benchmark_symbol, None);
} }
let trade_times = spec_trade_times(spec); let trade_times = spec_trade_times(spec);
if crate::pattern_context::specs_in_value(&serde_json::to_value(spec).map_err(|e|e.to_string())?)?.iter().any(|p|p.template=="session_event") {
if trade_times.is_empty() {return Err("session_event_requires_explicit_trade_times".into());}
cfg.session_event_times=trade_times.clone();
}
let explicit_trading_schedule = spec let explicit_trading_schedule = spec
.runtime_expressions .runtime_expressions
.as_ref() .as_ref()
@@ -4462,20 +4458,6 @@ mod tests {
assert_eq!(cfg.delayed_limit_open_exit_time, None); assert_eq!(cfg.delayed_limit_open_exit_time, None);
} }
#[test]
fn session_rotation_keeps_every_declared_clock_not_only_the_last_one() {
use crate::Strategy;
let literal=serde_json::to_string(&serde_json::json!({"template":"session_event","session_event":"INTRADAY_VOLUME_SPIKE","parameters":{}}).to_string()).unwrap();
let mut spec=serde_json::json!({"rebalance":{"tradeTimes":["09:35","10:40","14:59"]},"runtimeExpressions":{"schedule":{"frequency":"daily","time":"14:59"},"trading":{"rotationEnabled":true,"buyFilterExpr":format!("pattern_signal({literal})")}},"execution":{"matchingType":"minute_last"}});
let config=platform_expr_config_from_value("session","000300.SH",&spec).unwrap();
assert_eq!(config.session_event_times.len(),3);
let strategy=crate::PlatformExprStrategy::new(config);
assert_eq!(strategy.schedule_rules().len(),3);
assert_eq!(strategy.decision_quote_times().len(),3);
spec["rebalance"]["tradeTimes"]=serde_json::json!([]);
assert!(platform_expr_config_from_value("session","000300.SH",&spec).unwrap_err().to_string().contains("explicit_trade_times"));
}
#[test] #[test]
fn explicit_trading_schedule_overrides_rebalance_trade_times() { fn explicit_trading_schedule_overrides_rebalance_trade_times() {
let spec = serde_json::json!({ let spec = serde_json::json!({
-398
View File
@@ -1,398 +0,0 @@
//! Completed, same-session minute events. These bars never become execution quotes.
use crate::{
daily_patterns::{PatternResult, PatternSpec},
factor_events::{Expr, Frame},
};
use chrono::{FixedOffset, NaiveDateTime, NaiveTime, TimeZone, Timelike};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::collections::BTreeMap;
use std::sync::Arc;
pub const CONTRACT: &str = "fidc_completed_session_events_v1";
pub const EVENTS: &[&str] = &[
"PRICE_CROSS_VWAP_UP",
"PRICE_CROSS_VWAP_DOWN",
"INTRADAY_HIGH_BREAKOUT",
"INTRADAY_LOW_BREAKDOWN",
"OPENING_RANGE_BREAKOUT_UP",
"OPENING_RANGE_BREAKOUT_DOWN",
"INTRADAY_VOLUME_SPIKE",
"MORNING_HIGH_BREAKOUT",
"MORNING_LOW_BREAKDOWN",
"AFTERNOON_MOMENTUM_UP",
"AFTERNOON_MOMENTUM_DOWN",
"LATE_SESSION_STRENGTH",
"LATE_SESSION_WEAKNESS",
];
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct MinuteBar {
pub symbol: String,
pub timestamp: NaiveDateTime,
pub available_at: NaiveDateTime,
pub open: f64,
pub high: f64,
pub low: f64,
pub close: f64,
pub volume: f64,
pub amount: f64,
}
pub type BarStore = Arc<BTreeMap<(chrono::NaiveDate, String), Vec<MinuteBar>>>;
pub fn bar_store(bars: Vec<MinuteBar>) -> Result<BarStore, String> {
let mut groups = BTreeMap::<(chrono::NaiveDate, String), Vec<MinuteBar>>::new();
for bar in bars {
groups
.entry((bar.timestamp.date(), bar.symbol.clone()))
.or_default()
.push(bar);
}
for rows in groups.values_mut() {
rows.sort_by_key(|r| r.timestamp);
if rows
.windows(2)
.any(|pair| pair[0].timestamp == pair[1].timestamp)
{
return Err("duplicate_completed_minute_bar".into());
}
}
Ok(Arc::new(groups))
}
fn f(name: &str) -> Expr {
Expr::Field { name: name.into() }
}
fn n(value: f64) -> Expr {
Expr::Number { value }
}
fn op(name: &str, args: Vec<Expr>, window: Option<usize>) -> Expr {
Expr::Operator {
name: name.into(),
args,
window,
}
}
fn time(minutes: u32) -> NaiveTime {
NaiveTime::from_hms_opt(minutes / 60, minutes % 60, 0).unwrap()
}
pub fn is_regular_label(t: NaiveTime) -> bool {
t.second() == 0 && (time(570) <= t && t <= time(690) || time(780) < t && t <= time(900))
}
pub fn expression(event: &str, p: &BTreeMap<String, Value>) -> Result<Expr, String> {
let cross = |up: bool, a: Expr, b: Expr| {
op(
if up { "CROSS_ABOVE" } else { "CROSS_BELOW" },
vec![a, b],
None,
)
};
Ok(match event {
"PRICE_CROSS_VWAP_UP" => cross(true, f("close"), f("session_vwap")),
"PRICE_CROSS_VWAP_DOWN" => cross(false, f("close"), f("session_vwap")),
"INTRADAY_HIGH_BREAKOUT" => op(
"GT",
vec![
f("close"),
op("LAG", vec![op("CUMMAX", vec![f("high")], None)], Some(1)),
],
None,
),
"INTRADAY_LOW_BREAKDOWN" => op(
"LT",
vec![
f("close"),
op("LAG", vec![op("CUMMIN", vec![f("low")], None)], Some(1)),
],
None,
),
"OPENING_RANGE_BREAKOUT_UP" => cross(true, f("close"), f("opening_high")),
"OPENING_RANGE_BREAKOUT_DOWN" => cross(false, f("close"), f("opening_low")),
"MORNING_HIGH_BREAKOUT" => cross(true, f("close"), f("morning_high")),
"MORNING_LOW_BREAKDOWN" => cross(false, f("close"), f("morning_low")),
"AFTERNOON_MOMENTUM_UP" => cross(true, f("afternoon_return"), n(0.)),
"AFTERNOON_MOMENTUM_DOWN" => cross(false, f("afternoon_return"), n(0.)),
"LATE_SESSION_STRENGTH" => cross(true, f("late_return"), n(0.)),
"LATE_SESSION_WEAKNESS" => cross(false, f("late_return"), n(0.)),
"INTRADAY_VOLUME_SPIKE" => op(
"GTE",
vec![
f("volume"),
op(
"MUL",
vec![
op(
"LAG",
vec![op(
"ROLLING_MEAN",
vec![f("volume")],
Some(p["volume_window"].as_u64().unwrap() as usize),
)],
Some(1),
),
n(p["volume_multiple"].as_f64().unwrap()),
],
None,
),
],
None,
),
_ => return Err("session_event_not_registered".into()),
})
}
pub fn evaluate(
spec: &PatternSpec,
symbol: &str,
bars: &[MinuteBar],
decision: NaiveDateTime,
) -> Result<PatternResult, String> {
let mut result = PatternResult {
symbol: symbol.into(),
name: None,
matched: false,
score: None,
checks: vec![],
values: json!({}),
anchor: Value::Null,
exclusion: None,
};
if bars.is_empty() {
return Err(format!(
"session_source_missing: {symbol} {}",
decision.date()
));
}
let visible = bars
.iter()
.filter(|b| {
b.timestamp.date() == decision.date()
&& b.timestamp < decision
&& b.available_at <= decision
})
.collect::<Vec<_>>();
if visible.is_empty() {
result.exclusion = Some(json!({"reason":"session_before_first_completed_bar"}));
return Ok(result);
}
let last = visible.last().unwrap().timestamp;
let expected = (570..=690)
.chain(781..=900)
.map(|m| decision.date().and_time(time(m)))
.filter(|t| *t < decision)
.last();
if expected != Some(last) {
return Err(format!(
"session_latest_bar_missing: {symbol} expected={expected:?} actual={last}"
));
}
let mut indexed = BTreeMap::new();
for b in &visible {
if b.symbol != symbol
|| !is_regular_label(b.timestamp.time())
|| b.available_at < b.timestamp
|| [b.open, b.high, b.low, b.close, b.volume, b.amount]
.iter()
.any(|v| !v.is_finite())
|| b.low <= 0.
|| b.open <= 0.
|| b.close <= 0.
|| b.high < b.open.max(b.close)
|| b.low > b.open.min(b.close)
|| b.volume < 0.
|| b.amount < 0.
|| indexed.insert(b.timestamp, b).is_some()
{
return Err(format!("session_bar_invalid: {symbol} {}", b.timestamp));
}
}
for minute in (571..=690).chain(781..=900) {
let stamp = decision.date().and_time(time(minute));
if stamp <= last && !indexed.contains_key(&stamp) {
return Err(format!(
"session_bar_gap: {symbol} {stamp}; no filling or calendar compression"
));
}
}
let opening_end = time(570 + spec.n("opening_minutes") as u32);
let (mut volume, mut amount) = (0., 0.);
let (mut opening_high, mut opening_low) = (f64::NEG_INFINITY, f64::INFINITY);
let (mut morning_high, mut morning_low) = (f64::NEG_INFINITY, f64::INFINITY);
let (mut morning_close, mut late_close) = (None, None);
let mut fields: BTreeMap<String, Vec<Option<f64>>> = [
"open",
"high",
"low",
"close",
"volume",
"amount",
"session_vwap",
"opening_high",
"opening_low",
"morning_high",
"morning_low",
"afternoon_return",
"late_return",
]
.into_iter()
.map(|s| (s.into(), vec![]))
.collect();
let mut timestamps = vec![];
let mut available_at = vec![];
let zone = FixedOffset::east_opt(8 * 3600).unwrap();
for b in indexed.values() {
let t = b.timestamp.time();
volume += b.volume;
amount += b.amount;
if t <= opening_end {
opening_high = opening_high.max(b.high);
opening_low = opening_low.min(b.low);
}
if t <= time(690) {
morning_high = morning_high.max(b.high);
morning_low = morning_low.min(b.low);
}
if t == time(690) {
morning_close = Some(b.close);
}
if t == time(870) {
late_close = Some(b.close);
}
for (name, value) in [
("open", Some(b.open)),
("high", Some(b.high)),
("low", Some(b.low)),
("close", Some(b.close)),
("volume", Some(b.volume)),
("amount", Some(b.amount)),
("session_vwap", (volume > 0.).then_some(amount / volume)),
("opening_high", (t >= opening_end).then_some(opening_high)),
("opening_low", (t >= opening_end).then_some(opening_low)),
("morning_high", (t >= time(690)).then_some(morning_high)),
("morning_low", (t >= time(690)).then_some(morning_low)),
("afternoon_return", morning_close.map(|v| b.close / v - 1.)),
("late_return", late_close.map(|v| b.close / v - 1.)),
] {
fields.get_mut(name).unwrap().push(value);
}
timestamps.push(zone.from_local_datetime(&b.timestamp).single().unwrap());
available_at.push(zone.from_local_datetime(&b.available_at).single().unwrap());
}
let frame = Frame {
symbol: symbol.into(),
frequency: "1m".into(),
decision_at: zone.from_local_datetime(&decision).single().unwrap(),
timestamps,
available_at,
fields,
};
let event = spec
.session_event
.as_deref()
.ok_or("session_event_id_required")?;
let values = crate::factor_events::evaluate(&expression(event, &spec.parameters)?, &frame)?;
let latest = values.values.last().copied().flatten();
result.score = latest;
result.matched = latest == Some(1.);
result.values = json!({"session_event":event,"session_contract":CONTRACT,"expression":values,"signal_bar_end":last,"decision_at":decision,"bars":visible.len(),"bar_times":frame.timestamps.iter().map(|t|t.format("%Y-%m-%dT%H:%M:%S").to_string()).collect::<Vec<_>>(),"close":visible.last().unwrap().close,"session_return":visible.last().unwrap().close/visible.first().unwrap().open-1.,"price_policy":"same_session_raw_ohlcv"});
if latest.is_none() {
result.exclusion = Some(json!({"reason":"session_warmup_or_undefined"}));
} else {
result.checks.push(json!({"label":"分钟事件","actual":latest,"operator":"==","threshold":1,"passed":result.matched}));
}
Ok(result)
}
#[cfg(test)]
mod tests {
use super::*;
fn spec(event: &str) -> PatternSpec {
serde_json::from_value::<PatternSpec>(
json!({"template":"session_event","session_event":event,"parameters":{}}),
)
.unwrap()
.validate()
.unwrap()
}
fn bars() -> Vec<MinuteBar> {
let date = chrono::NaiveDate::from_ymd_opt(2026, 9, 8).unwrap();
(570..=690)
.chain(781..=900)
.enumerate()
.map(|(i, m)| {
let timestamp = date.and_time(time(m));
let price = 100. + (i % 17) as f64 / 10.;
let volume = if i % 39 == 0 { 1000. } else { 100. };
MinuteBar {
symbol: "300395.SZ".into(),
timestamp,
available_at: timestamp,
open: price,
high: price + 0.1,
low: price - 0.1,
close: price,
volume,
amount: volume * price,
}
})
.collect()
}
#[test]
fn all_thirteen_events_return_native_boolean_series() {
let bars = bars();
let decision = "2026-09-08T15:00:01".parse().unwrap();
for event in EVENTS {
let value = evaluate(&spec(event), "300395.SZ", &bars, decision).unwrap();
assert!(value.score.is_some(), "{event}");
assert_eq!(value.values["expression"]["value_type"], "boolean");
}
}
#[test]
fn decision_uses_the_previous_completed_label_and_future_prices_do_not_rewrite() {
let mut bars = bars();
let decision = "2026-09-08T10:02:00".parse().unwrap();
for event in EVENTS {
let before = evaluate(&spec(event), "300395.SZ", &bars, decision).unwrap();
for bar in &mut bars {
if bar.timestamp >= decision {
bar.open = 1000.;
bar.close = 1000.;
bar.high = 1001.;
bar.low = 999.;
}
}
let after = evaluate(&spec(event), "300395.SZ", &bars, decision).unwrap();
assert_eq!(before.values, after.values);
assert_eq!(after.values["signal_bar_end"], "2026-09-08T10:01:00");
}
}
#[test]
fn gaps_and_stale_last_bars_do_not_become_false_or_repeated_signals() {
let mut values = bars();
let decision = "2026-09-08T10:02:00".parse().unwrap();
values.retain(|r| r.timestamp.time() != time(600));
assert!(evaluate(&spec(EVENTS[0]), "300395.SZ", &values, decision)
.unwrap_err()
.contains("session_bar_gap"));
let stale = bars()
.into_iter()
.filter(|r| r.timestamp.time() < time(601))
.collect::<Vec<_>>();
assert!(evaluate(&spec(EVENTS[0]), "300395.SZ", &stale, decision)
.unwrap_err()
.contains("latest_bar_missing"));
}
#[test]
fn opening_range_is_unavailable_before_the_range_has_completed() {
let value = evaluate(
&spec("OPENING_RANGE_BREAKOUT_UP"),
"300395.SZ",
&bars(),
"2026-09-08T09:59:01".parse().unwrap(),
)
.unwrap();
assert_eq!(value.score, None);
assert!(!value.matched);
}
}
-24
View File
@@ -1,24 +0,0 @@
# 完成日线形态与次日信号
`fidc_daily_ohlcv_pattern_v1``fidc-core::daily_patterns` 单一计算核实现。Source Lake 只读取、核验及传输真实 OHLCV;研究服务和策略表达式不分别维护数值算法。
四种量价条件为趋势强势、前高突破、放量上涨、缩量突破;额外提供独立的均线下方、放量下跌卖出条件。前三者名称不暗示当日金叉或价格突破等未实际检验的事实。
## 应用阶段
- `filter.stock_expr(pattern_signal("<模板 JSON>"))`:选择候选,再按既有顺序和 Top N 取目标。
- `filter.buy_expr(pattern_signal("<模板 JSON>"))`:只限制正向仓位增量,不移除目标、不反向清仓,正常减仓不受影响。
- `risk.stop_loss(pattern_signal("<独立卖出模板 JSON>"))`:独立退出条件,不使用买入条件的反值。
- `pattern_score` 只可用于已通过形态条件的对象;没有放量参照或合法排除对象不伪造零分。
参数是 JSON 字符串,例如 `pattern_signal("{\"template\":\"ma_below\",\"parameters\":{\"ma_window\":20}}")`
新规则必须显式 `execution.matching_type("next_bar_open")`。信号日 D 的完整日线不能用于 D 日盘前或盘中;历史回放按 D 决策、下一真实交易日执行,实时上下文使用已完成 D 日窗口。实际委托仍需要执行日行情、资金、可卖数量、交易许可和风控。不得用研究结果开启交易路由。
## 数据与预热
所有价格统一用真实 backward1 因子,成交量不复权。缺失、非有限值、无效 OHLC、重复、未来行、未声明停牌状态均拒绝。仅按明确上市日期证明的上市前窗口或正式停牌记录可以返回结构化排除;不补价、不跳过日期压缩窗口。有效价格但缺复权因子即使停牌也报错。回测和运行态须从表达式提取真实窗口需求,冻结完整日历预热。
研究选择的范围及日期、上市/停牌排除证据、源查询和哈希需保留。固定候选的后续规则回测不等于历史全市场动态选股。CAPM 全区间拟合属于解释性诊断;要成为次日条件,必须另行使用截至 D 日的滚动估计并验证样本外表现,不得回填到拟合区间内。
旧任务默认撮合、历史筛选记录和策略源码不变;用户显式创建新规则后才采用此合同。
@@ -1,63 +0,0 @@
# 表达式缺失值与执行参数验收
## 根因
原数值执行器把 NaN 比较结果直接变成 false,外层 NOT 因而可能变成 true。
`min(NaN, value)` 还会返回另一个有效值,使缺失因子参与筛选。下单标量继续经过
`max``clamp` 或整数转换时,也可能把无效输入变成零仓位或零数量。
## 执行合同
数值 VM 使用带类型的 Missing 值,数值缺失及非有限运算结果不再提前变为布尔 false。
| 表达式 | 结果 |
| --- | --- |
| NOT unknown | unknown |
| false AND unknown | false |
| true AND unknown | unknown |
| true OR unknown | true |
| false OR unknown | unknown |
最终布尔筛选只接受 true;显式 `if`/`iff` 与 CASE WHEN 一样,只在条件确认为 true 时取真分支。
显式 `nz` 保留策略自己声明的缺失值替代含义,框架不会自行填零。
短路仍不读取未使用分支。非法 clamp 范围返回错误,不允许使进程 panic。
Rhai 的逻辑运算不能承载可空布尔,因此动态脚本遇到未知数值比较时明确报错,不能
返回错误的命中;缺失 map 属性同样报错。已关闭会绕过自定义比较保护的 Fast Operators。
有限浮点比较仍使用现有 epsilon 口径,混合整数/浮点比较也受保护。
[Rhai 运算符文档](https://rhai.rs/book/rust/operators.html)说明了该分派边界。
下单数量、目标仓位、投入比例和筛选边界必须返回有限数值,否则记录
`missing_numeric_result`,包含表达式、证券、决策日和执行日。只有排名评估保留
独立的缺失值诊断路径;没有把数据源的缺行改写为价格或交易事实。
## 代码与测试
- `fda2e70`VM 三值逻辑及动态数值保护。
- `ea58ab2`:显式关闭 Rhai 快运算符,补齐缺失 map 保护。
- `e3f1028`:执行标量必须有限,排名与执行参数分离。
- 177 引擎:585 项通过、8 项跳过。
- Runner360 项通过、3 项跳过。
- 交易工作区链接 e3f1028:510 项通过、8 项跳过。
首次回归曾发现 Rhai 快路径仍绕过保护,修复后重新完整测试,未将失败候选部署。
## 真实回放
使用已保存的原始 strategy spec、初始资金、日期、基准、频率及全部执行配置,
通过独立 runner 真正重新执行。固定为服务实际使用的16个逻辑CPU、Rayon8线程、Tokio16线程。
- 五年日线:2021-08-23 至 2026-08-281,000万元,25,408笔成交。
- 分钟样本:2025-01-02 至 2025-11-17100万元,156笔成交。
- 10次回放的 canonical 与 result-store 均等于各自同 frozen bundle 基准。
- 包含 e3f1028 的最终回放为 `five-year-strict-1``minute-strict-1`
完整证据:`/Users/boris/WorkSpace/fidc-backtest-service/docs/evidence/numeric-condition-replay-20260909.json`
日线源行6,918,227;分钟样本仅636行,不能用其亚秒耗时宣传全部分钟策略的性能。
## 边界
该候选尚未部署到常驻回测或交易服务。此验证证明两种已有策略在有效冻结数据下结果不变,
不证明所有策略、所有原始财务公告/vintage、全部缺失数据原因或真实券商交易均已验收。
Rhai 未提供与数值 VM 完全相同的 nullable 表达式能力,目前选择明确拒绝,不能称为所有
动态语言表达式都已支持三值逻辑。完整 typed Base Panel 与对象分配优化仍待完成。