Compare commits

..

27 Commits

Author SHA1 Message Date
boris 5dc5ef9df5 补全指数与完整范围排名的只读事件计算 2026-09-09 19:57:03 +08:00
boris fe8f6c1c26 增加均量突破回踩与真实涨停整理条件 2026-09-09 13:55:45 +08:00
boris 30e8227099 接通真实昨收成交额并前置校验表达式字段 2026-09-09 11:33:58 +08:00
boris 588da4958f 合并组合亏损控制器与因子内核依赖 2026-09-09 10:40:26 +08:00
boris bc4754288e 合并主线缺值语义与因子键优化记录 2026-09-09 10:31:26 +08:00
boris 6b0cdbcecc 增加共享因子事件表达式与完整截面算子 2026-09-09 10:31:25 +08:00
boris 5ff05e0d3d merge latest engine main before portfolio risk integration 2026-09-09 10:23:02 +08:00
boris bab4d47b46 revert: remove ineffective borrowed factor key optimization 2026-09-09 09:48:41 +08:00
boris fdd26667c9 test: enable rebalance actions in portfolio risk lifecycle fixture 2026-09-09 09:40:18 +08:00
boris 29522b69fe test: trace completed risk observations in engine regression 2026-09-09 09:40:18 +08:00
boris a54489fe92 test: expose lifecycle execution diagnostics on failure 2026-09-09 09:40:18 +08:00
boris 20c14437c6 test: bind accounting lifecycle fixture to its real sample symbols 2026-09-09 09:40:18 +08:00
boris dce5454ec8 test: import explicit engine accounting fixture types 2026-09-09 09:40:18 +08:00
boris 72b64451ac test: verify portfolio loss against finalized engine accounting 2026-09-09 09:40:18 +08:00
boris d17d67d6ca build: lock existing SHA256 dependency without unrelated upgrades 2026-09-09 09:40:18 +08:00
boris 63c577bd76 feat: connect portfolio loss to finalized accounting and daily risk clock 2026-09-09 09:40:18 +08:00
boris 8c190597ae feat: add serialized causal portfolio loss controller for runtime integration 2026-09-09 09:40:18 +08:00
boris ad063264cf fix: borrow factor identifiers during lookup 2026-09-09 09:36:10 +08:00
boris 0108c91bae perf: preserve borrowed factor keys in stock state 2026-09-09 09:35:25 +08:00
boris ee2865829d Merge remote-tracking branch 'origin/main' 2026-09-09 09:31:46 +08:00
boris e66460c4e9 对齐次日交易信号基线与基准收益起点 2026-09-09 07:43:29 +08:00
boris 2811886a52 区分次日回放调度标签与实际日线可见时点 2026-09-09 07:21:13 +08:00
boris 3b5a7cd318 修复恒定小数价格累加误差产生虚假均线信号 2026-09-09 07:03:55 +08:00
boris 3fe2da3ee0 统一日线形态计算与次日分阶段信号 2026-09-09 06:41:15 +08:00
boris ee77028907 docs: record missing-value semantics and real replay gates 2026-09-09 06:07:01 +08:00
boris 1bcaa0b3d8 合并主线数值校验与买入阶段约束 2026-09-09 05:49:45 +08:00
boris 1703a7aa5e 保留已有行情和策略代码格式整理 2026-09-09 05:49:44 +08:00
14 changed files with 2649 additions and 79 deletions
Generated
+14
View File
@@ -192,6 +192,7 @@ dependencies = [
"serde",
"serde_json",
"sha2",
"ta-lib",
"thiserror",
]
@@ -537,6 +538,19 @@ dependencies = [
"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]]
name = "thin-vec"
version = "0.2.16"
+1
View File
@@ -15,3 +15,4 @@ serde.workspace = true
serde_json.workspace = true
sha2.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(())
}
File diff suppressed because it is too large Load Diff
+29 -34
View File
@@ -1850,9 +1850,9 @@ impl DataSet {
.iter()
.enumerate()
.filter_map(|(symbol_id, series)| {
series.as_ref().map(|series| {
(symbol_by_id[symbol_id].to_string(), Arc::clone(series))
})
series
.as_ref()
.map(|series| (symbol_by_id[symbol_id].to_string(), Arc::clone(series)))
})
.collect::<AHashMap<_, _>>();
@@ -1876,9 +1876,9 @@ impl DataSet {
.iter()
.enumerate()
.filter_map(|(symbol_id, series)| {
series.as_ref().map(|series| {
(symbol_by_id[symbol_id].to_string(), Arc::clone(series))
})
series
.as_ref()
.map(|series| (symbol_by_id[symbol_id].to_string(), Arc::clone(series)))
})
.collect::<AHashMap<_, _>>();
let factor_texts = factor_texts
@@ -1900,16 +1900,10 @@ impl DataSet {
let factor_market_cap_order_by_date =
build_factor_market_cap_order(&factor_by_date, &factor_symbol_ids_by_date);
let market_row_positions_by_date = build_dense_row_positions(
&market_by_date,
&market_symbol_ids_by_date,
symbol_count,
);
let factor_row_positions_by_date = build_dense_row_positions(
&factor_by_date,
&factor_symbol_ids_by_date,
symbol_count,
);
let market_row_positions_by_date =
build_dense_row_positions(&market_by_date, &market_symbol_ids_by_date, 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(
&candidate_by_date,
&candidate_symbol_ids_by_date,
@@ -2321,7 +2315,10 @@ impl DataSet {
}
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",
Arc::strong_count(&self.market_series_by_symbol),
@@ -2456,7 +2453,8 @@ impl DataSet {
.flat_map(|rows_by_symbol| rows_by_symbol.values())
.map(Vec::len)
.sum();
let mut execution_quote_dates = execution_quotes_by_date.keys().copied().collect::<Vec<_>>();
let mut execution_quote_dates =
execution_quotes_by_date.keys().copied().collect::<Vec<_>>();
execution_quote_dates.sort_unstable();
self.execution_quotes_by_date = Arc::new(execution_quotes_by_date);
self.execution_quote_dates = Arc::new(execution_quote_dates);
@@ -5221,10 +5219,7 @@ mod tests {
[data.symbol_id("000001.SZ").unwrap() as usize]
.as_ref()
.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_eq!(
serde_json::to_value(market_series_after.snapshot_at(0)).unwrap(),
@@ -6178,11 +6173,8 @@ mod tests {
let date = NaiveDate::parse_from_str("2025-01-02", "%Y-%m-%d").unwrap();
let quote = IntradayExecutionQuote {
date,
timestamp: NaiveDateTime::parse_from_str(
"2025-01-02 10:18:00",
"%Y-%m-%d %H:%M:%S",
)
.unwrap(),
timestamp: NaiveDateTime::parse_from_str("2025-01-02 10:18:00", "%Y-%m-%d %H:%M:%S")
.unwrap(),
symbol: "000001.SZ".to_string(),
last_price: 10.0,
bid1: 10.0,
@@ -6365,13 +6357,16 @@ mod tests {
"'adjustment_factor_backward1'",
] {
for typed_value in [None, Some(1.0)] {
assert!(matches!(
normalize_factor_snapshots(vec![snapshot(
typed_value,
BTreeMap::from([(Cow::Borrowed(field), 2.0)]),
)]),
Err(DataSetError::ReservedTypedFactorInExtraMap { .. })
), "reserved alias accepted: {field}");
assert!(
matches!(
normalize_factor_snapshots(vec![snapshot(
typed_value,
BTreeMap::from([(Cow::Borrowed(field), 2.0)]),
)]),
Err(DataSetError::ReservedTypedFactorInExtraMap { .. })
),
"reserved alias accepted: {field}"
);
}
}
}
+17 -6
View File
@@ -91,6 +91,9 @@ impl Default for ProcessEventRetention {
#[derive(Debug, Clone, Serialize, Deserialize)]
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")]
pub date: NaiveDate,
pub cash: f64,
@@ -109,6 +112,14 @@ pub struct DailyEquityPoint {
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)]
pub struct BacktestResult {
pub strategy_name: String,
@@ -334,7 +345,7 @@ impl BacktestResult {
let mut previous_benchmark = self
.equity_curve
.first()
.map(|point| point.benchmark_prev_close)
.map(DailyEquityPoint::benchmark_reference_close)
.unwrap_or_default();
for point in &self.equity_curve {
let point_nav = if point.unit_nav.is_finite() && point.unit_nav > 0.0 {
@@ -454,8 +465,7 @@ pub struct BacktestEngine<S, C, R> {
futures_cost_model: FuturesTransactionCostModel,
futures_validation_config: FuturesValidationConfig,
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:
BTreeSet<(NaiveDate, String, Option<NaiveTime>, Option<NaiveTime>)>,
risk_free_rate_contract: Option<RiskFreeRateContract>,
@@ -2203,6 +2213,7 @@ where
previous_external_cash_flow_total = portfolio.external_cash_flow_total();
result.equity_curve.push(DailyEquityPoint {
signal_baseline: true,
date: execution_date,
cash: aggregate_cash,
market_value: aggregate_market_value,
@@ -2538,9 +2549,8 @@ where
.map(Arc::clone)
{
let empty_symbols = BTreeSet::new();
let decision_quote_symbols = preplanned
.get(&execution_date)
.unwrap_or(&empty_symbols);
let decision_quote_symbols =
preplanned.get(&execution_date).unwrap_or(&empty_symbols);
self.ensure_execution_quotes_for_symbols_at_times(
execution_date,
decision_quote_symbols,
@@ -3371,6 +3381,7 @@ where
previous_external_cash_flow_total = portfolio.external_cash_flow_total();
result.equity_curve.push(DailyEquityPoint {
signal_baseline: false,
date: execution_date,
cash: aggregate_cash,
market_value: aggregate_market_value,
@@ -0,0 +1,234 @@
//! Cross-sectional operators require an explicit complete universe, never a UI page.
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
pub const OPERATORS: &[&str] = &[
"RANK",
"PERCENTILE",
"TOP",
"BOTTOM",
"TOP_PERCENT",
"BOTTOM_PERCENT",
"WINSORIZE",
"INDUSTRY_NEUTRALIZE",
"SIZE_NEUTRALIZE",
];
#[derive(Clone, Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Observation {
pub symbol: String,
pub value: f64,
pub industry: Option<String>,
pub market_cap: Option<f64>,
}
#[derive(Debug, Serialize)]
pub struct Output {
pub symbol: String,
pub value: f64,
}
/// Every date ranks the same frozen research universe; unknown inputs invalidate the whole date.
pub fn rank_history(
dates: &[chrono::NaiveDate], universe: &[String], values: &BTreeMap<String, Vec<Option<f64>>>,
) -> Result<serde_json::Value, String> {
use serde_json::json;
if dates.is_empty() || dates.windows(2).any(|w| w[0] >= w[1]) || universe.len() < 2
|| universe.len() > 20_000 || dates.len().saturating_mul(universe.len()) > 2_000_000
|| universe.iter().collect::<BTreeSet<_>>().len() != universe.len()
|| values.keys().collect::<BTreeSet<_>>() != universe.iter().collect::<BTreeSet<_>>()
|| values.values().any(|v| v.len() != dates.len() || v.iter().flatten().any(|v| !v.is_finite())) {
return Err("research_rank_history_incomplete_or_invalid_universe".into());
}
let mut rank = universe.iter().map(|s|(s.clone(),vec![None;dates.len()])).collect::<BTreeMap<_,_>>();
let mut percentile = rank.clone();
let mut unknown_dates = Vec::new();
for (i, date) in dates.iter().enumerate() {
let missing = universe.iter().filter(|s|values[*s][i].is_none()).collect::<Vec<_>>();
if !missing.is_empty() {
unknown_dates.push(json!({"date":date,"missing_count":missing.len(),"missing_symbol_sample":missing.iter().take(20).collect::<Vec<_>>(),"sample_limit":20}));
continue;
}
let observations = universe.iter().map(|s|Observation{symbol:s.clone(),value:values[s][i].unwrap(),industry:None,market_cap:None}).collect::<Vec<_>>();
for item in evaluate("RANK", universe, &observations, 0.0)? {rank.get_mut(&item.symbol).unwrap()[i]=Some(item.value);}
for item in evaluate("PERCENTILE", universe, &observations, 0.0)? {percentile.get_mut(&item.symbol).unwrap()[i]=Some(item.value);}
}
Ok(json!({"rank":rank,"percentile":percentile,"unknown_dates":unknown_dates,
"universe":universe,"dates":dates,"tie_policy":"average_rank_descending",
"membership_policy":"fixed_research_scope_not_historical_index_membership"}))
}
fn mean(values: &[f64]) -> f64 {
let base = values[0];
base + values
.iter()
.skip(1)
.map(|v| (v - base) / values.len() as f64)
.sum::<f64>()
}
fn quantile(sorted: &[f64], p: f64) -> f64 {
let x = p * (sorted.len() - 1) as f64;
let l = x.floor() as usize;
let r = x.ceil() as usize;
sorted[l] + (sorted[r] - sorted[l]) * (x - l as f64)
}
pub fn evaluate(
name: &str,
universe: &[String],
rows: &[Observation],
threshold: f64,
) -> Result<Vec<Output>, String> {
let expected = universe.iter().collect::<BTreeSet<_>>();
if rows.is_empty()
|| rows.len() > 20_000
|| expected.len() != universe.len()
|| rows.len() != universe.len()
|| rows.iter().map(|r| &r.symbol).collect::<BTreeSet<_>>() != expected
|| rows.iter().any(|r| !r.value.is_finite())
{
return Err("cross_section_incomplete_or_invalid_universe".into());
}
if !OPERATORS.contains(&name) || !threshold.is_finite() {
return Err("cross_section_operator_invalid".into());
}
if matches!(name, "TOP" | "BOTTOM") && (threshold < 1.0 || threshold.fract() != 0.0)
|| matches!(name, "TOP_PERCENT" | "BOTTOM_PERCENT") && !(0.0..=1.0).contains(&threshold)
|| name == "WINSORIZE" && !(0.0..0.5).contains(&threshold)
{
return Err("cross_section_threshold_invalid".into());
}
let mut sorted = rows.iter().map(|r| r.value).collect::<Vec<_>>();
sorted.sort_by(f64::total_cmp);
let mut industry_values: BTreeMap<&str, Vec<f64>> = BTreeMap::new();
if name == "INDUSTRY_NEUTRALIZE" {
for row in rows {
let industry = row
.industry
.as_deref()
.filter(|v| !v.trim().is_empty())
.ok_or("cross_section_pit_industry_missing")?;
industry_values.entry(industry).or_default().push(row.value);
}
}
let size = if name == "SIZE_NEUTRALIZE" {
let x = rows
.iter()
.map(|r| {
r.market_cap
.filter(|v| v.is_finite() && *v > 0.0)
.map(f64::ln)
.ok_or("cross_section_market_cap_missing")
})
.collect::<Result<Vec<_>, _>>()?;
let xm = mean(&x);
let ym = mean(&sorted);
let variance = x.iter().map(|v| (v - xm).powi(2)).sum::<f64>();
if variance == 0.0 || rows.len() < 3 {
return Err("cross_section_size_regression_unidentified".into());
}
let beta = x
.iter()
.zip(rows)
.map(|(x, y)| (x - xm) * (y.value - ym))
.sum::<f64>()
/ variance;
Some((x, xm, ym, beta))
} else {
None
};
rows.iter()
.enumerate()
.map(|(index, row)| {
let low = sorted.partition_point(|v| *v < row.value);
let high = sorted.partition_point(|v| *v <= row.value);
let rank = (low + 1 + high) as f64 / 2.0;
let descending = (rows.len() + 1) as f64 - rank;
let percentile = if rows.len() == 1 {
0.5
} else {
(rank - 1.0) / (rows.len() - 1) as f64
};
let value = match name {
"RANK" => descending,
"PERCENTILE" => percentile,
"TOP" => f64::from(descending <= threshold),
"BOTTOM" => f64::from(rank <= threshold),
"TOP_PERCENT" => f64::from(descending <= threshold * rows.len() as f64),
"BOTTOM_PERCENT" => f64::from(rank <= threshold * rows.len() as f64),
"WINSORIZE" => row.value.clamp(
quantile(&sorted, threshold),
quantile(&sorted, 1.0 - threshold),
),
"INDUSTRY_NEUTRALIZE" => {
row.value - mean(&industry_values[row.industry.as_deref().unwrap()])
}
"SIZE_NEUTRALIZE" => {
let (x, xm, ym, beta) = size.as_ref().unwrap();
row.value - (ym + beta * (x[index] - xm))
}
_ => unreachable!(),
};
if !value.is_finite() {
return Err("cross_section_result_nonfinite".into());
}
Ok(Output {
symbol: row.symbol.clone(),
value,
})
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn historical_ranks_keep_ties_and_unknown_full_cross_sections() {
let dates=["2026-09-07","2026-09-08","2026-09-09"].map(|d|d.parse().unwrap());
let universe=vec!["A".into(),"B".into(),"C".into()];
let values=BTreeMap::from([("A".into(),vec![None,Some(10.0),Some(20.0)]),("B".into(),vec![Some(10.0),Some(10.0),Some(10.0)]),("C".into(),vec![Some(20.0),Some(5.0),Some(15.0)])]);
let out=rank_history(&dates,&universe,&values).unwrap();
assert_eq!(out["rank"]["A"],serde_json::json!([null,1.5,1.0]));
assert_eq!(out["rank"]["C"],serde_json::json!([null,3.0,2.0]));
assert_eq!(out["unknown_dates"][0]["missing_count"],1);
let earlier=values.iter().map(|(s,v)|(s.clone(),v[..2].to_vec())).collect();
let first=rank_history(&dates[..2],&universe,&earlier).unwrap();
assert_eq!(&out["rank"]["A"].as_array().unwrap()[..2],first["rank"]["A"].as_array().unwrap());
assert!(rank_history(&dates,&universe[..2],&values).is_err());
}
fn rows() -> Vec<Observation> {
[1.0, 3.0, 3.0, 4.0]
.iter()
.enumerate()
.map(|(i, &value)| Observation {
symbol: format!("S{i}"),
value,
industry: Some(if i < 2 { "A" } else { "B" }.into()),
market_cap: Some(10.0 + i as f64),
})
.collect()
}
#[test]
fn ties_keep_equal_rank_and_missing_universe_rejects() {
let r = rows();
let u = r.iter().map(|r| r.symbol.clone()).collect::<Vec<_>>();
let out = evaluate("RANK", &u, &r, 0.0).unwrap();
assert_eq!(
out.iter().map(|r| r.value).collect::<Vec<_>>(),
vec![4.0, 2.5, 2.5, 1.0]
);
assert!(evaluate("RANK", &u, &r[..3], 0.0).is_err());
}
#[test]
fn neutralization_preserves_input_order() {
let r = rows();
let u = r.iter().map(|r| r.symbol.clone()).collect::<Vec<_>>();
let out = evaluate("INDUSTRY_NEUTRALIZE", &u, &r, 0.0).unwrap();
assert_eq!(
out.iter().map(|r| r.value).collect::<Vec<_>>(),
vec![-1.0, 1.0, -0.5, 0.5]
);
assert!(evaluate("TOP_PERCENT", &u, &r, 20.0).is_err());
}
}
File diff suppressed because it is too large Load Diff
+3
View File
@@ -2,6 +2,9 @@ pub mod broker;
pub mod calendar;
pub mod cost;
pub mod data;
pub mod daily_patterns;
pub mod factor_events;
pub mod factor_cross_section;
pub mod engine;
pub mod event_bus;
pub mod events;
+12 -7
View File
@@ -108,13 +108,7 @@ pub fn compute_backtest_metrics(
};
let trade_days = equity_curve.len();
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 benchmark_start = first_point.benchmark_reference_close();
let explicit_unit_nav = equity_curve.iter().any(|point| {
point.external_cash_flow.abs() > f64::EPSILON
|| (point.unit_nav.is_finite()
@@ -780,6 +774,7 @@ mod tests {
benchmark_prev_close: f64,
) -> DailyEquityPoint {
DailyEquityPoint {
signal_baseline: false,
date: NaiveDate::parse_from_str(date, "%Y-%m-%d").unwrap(),
cash: total_equity,
market_value: 0.0,
@@ -804,11 +799,21 @@ mod tests {
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]
fn external_cash_flow_is_excluded_from_return_and_reported_separately() {
let curve = vec![
equity_point("2025-01-02", 100.0, 100.0, 100.0),
DailyEquityPoint {
signal_baseline: false,
date: NaiveDate::from_ymd_opt(2025, 1, 3).unwrap(),
cash: 220.0,
market_value: 0.0,
+100 -32
View File
@@ -1287,6 +1287,7 @@ struct RuntimeHelperBinding {
#[derive(Clone)]
enum CompiledRuntimeHelperArgs {
DailyPattern { spec: crate::daily_patterns::PatternSpec },
RollingMean {
field: String,
lookback: usize,
@@ -1344,6 +1345,8 @@ enum RuntimeHelperResolution {
}
pub struct PlatformExprStrategy {
pattern_results_date: RefCell<Option<NaiveDate>>,
pattern_results: RefCell<BTreeMap<(NaiveDate, String, String), crate::daily_patterns::PatternResult>>,
config: PlatformExprStrategyConfig,
engine: Engine,
rebalance_day_counter: usize,
@@ -1792,6 +1795,8 @@ impl PlatformExprStrategy {
stock_extra_factor_identifiers,
stock_extra_factor_map_required,
stock_text_factors_required,
pattern_results: RefCell::new(BTreeMap::new()),
pattern_results_date: RefCell::new(None),
stock_state_cache_date: RefCell::new(None),
stock_state_cache_calendar_index: RefCell::new(None),
stock_state_cache: RefCell::new(AHashMap::new()),
@@ -2355,7 +2360,9 @@ impl PlatformExprStrategy {
fn is_runtime_helper(name: &str) -> bool {
matches!(
name,
"factor"
"pattern_signal"
| "pattern_score"
| "factor"
| "day_factor"
| "rolling_mean"
| "rolling_mean_current"
@@ -4293,13 +4300,10 @@ impl PlatformExprStrategy {
.factor_snapshot_rows_on(date)
.iter()
.flat_map(|row| {
row.extra_factors
.keys()
.map(|key| key.to_string())
.chain(
row.adjustment_factor_backward1
.map(|_| BACKWARD_ADJUSTMENT_FACTOR_FIELD.to_string()),
)
row.extra_factors.keys().map(|key| key.to_string()).chain(
row.adjustment_factor_backward1
.map(|_| BACKWARD_ADJUSTMENT_FACTOR_FIELD.to_string()),
)
})
.collect()
} else {
@@ -5701,6 +5705,30 @@ impl PlatformExprStrategy {
args: &CompiledRuntimeHelperArgs,
) -> Result<RuntimeHelperResolution, BacktestError> {
match args {
CompiledRuntimeHelperArgs::DailyPattern { spec } => {
if self.config.matching_type != MatchingType::NextBarOpen {
return Err(BacktestError::Execution("daily_pattern_requires_next_bar_open: 完整日线形态只能在下一交易日执行".into()));
}
let date = 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(), serde_json::to_string(spec).unwrap());
if *self.pattern_results_date.borrow() != Some(date) {
self.pattern_results.borrow_mut().clear();
*self.pattern_results_date.borrow_mut() = Some(date);
}
if !self.pattern_results.borrow().contains_key(&key) {
let result = crate::daily_patterns::evaluate_dataset(spec,ctx.data,date,&stock.symbol).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 {
field,
lookback,
@@ -6137,7 +6165,7 @@ impl PlatformExprStrategy {
if Self::is_reserved_scope_name(identifier.as_str())
|| self.prelude_declared_identifiers.contains(identifier)
|| (!self.stock_extra_factor_identifiers.contains(identifier)
&& !item.extra_factors.contains_key(identifier)
&& !item.extra_factors.contains_key(identifier.as_str())
&& !day.available_factor_names.contains(identifier)
&& !day.available_text_factor_names.contains(identifier))
{
@@ -6148,7 +6176,7 @@ impl PlatformExprStrategy {
} else {
let value = item
.extra_factors
.get(identifier)
.get(identifier.as_str())
.copied()
.unwrap_or(f64::NAN);
scope.push_dynamic(identifier.clone(), Dynamic::from(value));
@@ -6952,6 +6980,11 @@ impl PlatformExprStrategy {
))
};
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()?;
Some(CompiledRuntimeHelperArgs::DailyPattern { spec: spec.validate().ok()? })
}
"rolling_mean" | "sma" | "ma" => {
let (field, lookback) = field_lookback()?;
Some(CompiledRuntimeHelperArgs::RollingMean {
@@ -7028,8 +7061,8 @@ impl PlatformExprStrategy {
fn numeric_vm_helper_type(helper: &str) -> Option<NumericVmValueType> {
match helper {
"has_dividend" | "has_split" | "is_margin_stock" => Some(NumericVmValueType::Boolean),
"rolling_mean"
"pattern_signal" | "has_dividend" | "has_split" | "is_margin_stock" => Some(NumericVmValueType::Boolean),
"pattern_score" | "rolling_mean"
| "sma"
| "ma"
| "rolling_mean_current"
@@ -7171,6 +7204,7 @@ impl PlatformExprStrategy {
return self.resolve_compiled_runtime_helper(ctx, day, stock, helper, &compiled_args);
}
match helper {
"pattern_signal" | "pattern_score" => Err(BacktestError::Execution("daily_pattern_spec_invalid: 需要一个有效的模板 JSON 字符串参数".into())),
"factor" => {
let key = Self::normalize_runtime_factor_key(&Self::parse_string_or_identifier(
args.first().map(String::as_str).unwrap_or_default(),
@@ -10544,10 +10578,7 @@ impl PlatformExprStrategy {
) -> std::ops::Range<usize> {
if !matches!(
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_high.is_finite()
{
@@ -10565,8 +10596,7 @@ impl PlatformExprStrategy {
};
let start = symbol_ids.partition_point(|symbol_id| market_cap(*symbol_id) < band_low);
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
}
@@ -11712,11 +11742,8 @@ impl PlatformExprStrategy {
&execution_day,
&factor_day,
)?;
let field_value = self.selection_field_value_from_caps(
market_cap_bn,
free_float_cap_bn,
&stock,
);
let field_value =
self.selection_field_value_from_caps(market_cap_bn, free_float_cap_bn, &stock);
if !field_value.is_finite() {
if diagnostics.len() < 12 {
diagnostics.push(format!(
@@ -12296,6 +12323,7 @@ impl Strategy for PlatformExprStrategy {
decision.merge_from(rotation?);
}
self.attach_buy_denials(ctx, &mut decision)?;
self.append_pattern_diagnostics(&mut decision);
Ok(decision)
}
@@ -12346,6 +12374,7 @@ impl Strategy for PlatformExprStrategy {
{
let mut decision = self.explicit_action_decision(ctx)?;
self.attach_buy_denials(ctx, &mut decision)?;
self.append_pattern_diagnostics(&mut decision);
return Ok(decision);
}
Ok(StrategyDecision::default())
@@ -12354,6 +12383,7 @@ impl Strategy for PlatformExprStrategy {
fn on_day(&mut self, ctx: &StrategyContext<'_>) -> Result<StrategyDecision, BacktestError> {
let mut decision = self.compute_day_decision(ctx)?;
self.attach_buy_denials(ctx, &mut decision)?;
self.append_pattern_diagnostics(&mut decision);
Ok(decision)
}
}
@@ -12392,6 +12422,15 @@ impl PlatformExprStrategy {
Ok(())
}
fn append_pattern_diagnostics(&self, decision: &mut StrategyDecision) {
for ((date, symbol, spec), result) in self.pattern_results.borrow().iter() {
if let Some(evidence) = &result.exclusion {
let record = serde_json::json!({"event":"daily_pattern_excluded","date":date,"symbol":symbol,"spec":spec,"evidence":evidence}).to_string();
if !decision.diagnostics.contains(&record) { decision.diagnostics.push(record); }
}
}
}
fn compute_day_decision(&mut self, ctx: &StrategyContext<'_>) -> Result<StrategyDecision, BacktestError> {
if self.config.rotation_enabled
&& self
@@ -14211,6 +14250,30 @@ mod tests {
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]
fn buy_quote_filter_rejects_missing_intraday_quote_not_daily_close() {
let date = d(2025, 1, 2);
@@ -16633,15 +16696,16 @@ mod tests {
cfg.market_cap_field = "market_cap".to_string();
let strategy = PlatformExprStrategy::new(cfg.clone());
let range = strategy.market_cap_ordered_selection_range(
&factor_day,
symbol_ids,
10.0,
20.0,
);
let range =
strategy.market_cap_ordered_selection_range(&factor_day, symbol_ids, 10.0, 20.0);
let selected_caps = symbol_ids[range]
.iter()
.map(|symbol_id| factor_day.factor(*symbol_id).expect("factor row").market_cap_bn)
.map(|symbol_id| {
factor_day
.factor(*symbol_id)
.expect("factor row")
.market_cap_bn
})
.collect::<Vec<_>>();
assert_eq!(selected_caps, vec![10.0, 20.0]);
@@ -16652,8 +16716,12 @@ mod tests {
);
cfg.market_cap_field = "free_float_cap".to_string();
assert_eq!(
PlatformExprStrategy::new(cfg)
.market_cap_ordered_selection_range(&factor_day, symbol_ids, 10.0, 20.0),
PlatformExprStrategy::new(cfg).market_cap_ordered_selection_range(
&factor_day,
symbol_ids,
10.0,
20.0
),
0..symbol_ids.len()
);
}
@@ -227,6 +227,8 @@ const RUNTIME_HELPER_FUNCTIONS: &[&str] = &[
"factor",
"day_factor",
"rolling_mean",
"pattern_signal",
"pattern_score",
"rolling_mean_current",
"rolling_max_current",
"rolling_return_stddev_current",
+24
View File
@@ -0,0 +1,24 @@
# 完成日线形态与次日信号
`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 日的滚动估计并验证样本外表现,不得回填到拟合区间内。
旧任务默认撮合、历史筛选记录和策略源码不变;用户显式创建新规则后才采用此合同。
@@ -0,0 +1,63 @@
# 表达式缺失值与执行参数验收
## 根因
原数值执行器把 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 与对象分配优化仍待完成。