Compare commits

...

22 Commits

Author SHA1 Message Date
boris b281045df5 修复事件跨服务序列化生成空窗口 2026-09-09 23:54:22 +08:00
boris 35acb1c7e7 修复分钟事件轮动仅执行最后时点的问题 2026-09-09 23:52:35 +08:00
boris bbbd9cf3e0 统一日线事件上下文并接入完成分钟事件回测 2026-09-09 23:29:54 +08:00
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
16 changed files with 3680 additions and 72 deletions
Generated
+80
View File
@@ -37,6 +37,15 @@ version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
[[package]]
name = "block-buffer"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
dependencies = [
"generic-array",
]
[[package]] [[package]]
name = "bumpalo" name = "bumpalo"
version = "3.20.2" version = "3.20.2"
@@ -99,6 +108,15 @@ version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "cpufeatures"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "crossbeam-deque" name = "crossbeam-deque"
version = "0.8.7" version = "0.8.7"
@@ -130,6 +148,26 @@ version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]]
name = "crypto-common"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"typenum",
]
[[package]]
name = "digest"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
]
[[package]] [[package]]
name = "either" name = "either"
version = "1.17.0" version = "1.17.0"
@@ -153,6 +191,8 @@ dependencies = [
"rhai", "rhai",
"serde", "serde",
"serde_json", "serde_json",
"sha2",
"ta-lib",
"thiserror", "thiserror",
] ]
@@ -162,6 +202,16 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "generic-array"
version = "0.14.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
]
[[package]] [[package]]
name = "getrandom" name = "getrandom"
version = "0.2.17" version = "0.2.17"
@@ -431,6 +481,17 @@ dependencies = [
"serde_core", "serde_core",
] ]
[[package]]
name = "sha2"
version = "0.10.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]] [[package]]
name = "shlex" name = "shlex"
version = "1.3.0" version = "1.3.0"
@@ -477,6 +538,19 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "ta-lib"
version = "0.8.1"
source = "git+https://github.com/TA-Lib/ta-lib.git?rev=dd5a90259a3f9e04e2da9f38bf0719a841b40108#dd5a90259a3f9e04e2da9f38bf0719a841b40108"
dependencies = [
"ta-lib-dispatch",
]
[[package]]
name = "ta-lib-dispatch"
version = "0.1.2"
source = "git+https://github.com/TA-Lib/ta-lib.git?rev=dd5a90259a3f9e04e2da9f38bf0719a841b40108#dd5a90259a3f9e04e2da9f38bf0719a841b40108"
[[package]] [[package]]
name = "thin-vec" name = "thin-vec"
version = "0.2.16" version = "0.2.16"
@@ -512,6 +586,12 @@ dependencies = [
"crunchy", "crunchy",
] ]
[[package]]
name = "typenum"
version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]] [[package]]
name = "unicode-ident" name = "unicode-ident"
version = "1.0.24" version = "1.0.24"
+2 -1
View File
@@ -11,6 +11,7 @@ version = "0.1.0"
authors = ["OpenAI Codex"] authors = ["OpenAI Codex"]
[workspace.dependencies] [workspace.dependencies]
sha2 = "=0.10.9"
ahash = "=0.8.12" ahash = "=0.8.12"
chrono = { version = "=0.4.44", features = ["serde"] } chrono = { version = "=0.4.44", features = ["serde"] }
indexmap = { version = "=2.11.4", features = ["serde"] } indexmap = { version = "=2.11.4", features = ["serde"] }
@@ -18,5 +19,5 @@ reqwest = { version = "=0.12.24", default-features = false, features = ["json",
rayon = "=1.12.0" rayon = "=1.12.0"
rhai = { version = "=1.23.6", features = ["sync"] } rhai = { version = "=1.23.6", features = ["sync"] }
serde = { version = "=1.0.228", features = ["derive"] } serde = { version = "=1.0.228", features = ["derive"] }
serde_json = "=1.0.145" serde_json = { version = "=1.0.145", features = ["float_roundtrip"] }
thiserror = "=2.0.18" thiserror = "=2.0.18"
+2
View File
@@ -13,4 +13,6 @@ rayon.workspace = true
rhai.workspace = true rhai.workspace = true
serde.workspace = true serde.workspace = true
serde_json.workspace = true serde_json.workspace = true
sha2.workspace = true
thiserror.workspace = true thiserror.workspace = true
ta-lib = { git = "https://github.com/TA-Lib/ta-lib.git", rev = "dd5a90259a3f9e04e2da9f38bf0719a841b40108" }
@@ -0,0 +1,35 @@
use fidc_core::factor_events::{self, Expr, Frame};
use serde::Deserialize;
use serde_json::{Value, json};
use std::io::{self, Read};
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Request {
expressions: std::collections::BTreeMap<String, Expr>,
frame: Frame,
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut input = String::new();
io::stdin().read_to_string(&mut input)?;
let output = if input.trim().is_empty() {
factor_events::catalog()
} else {
let request: Request = serde_json::from_str(&input)?;
let results = request
.expressions
.iter()
.map(|(id, expr)| {
let result = match factor_events::evaluate(expr, &request.frame) {
Ok(v) => json!({"result":v}),
Err(e) => json!({"error":e}),
};
(id.clone(), result)
})
.collect::<std::collections::BTreeMap<String, Value>>();
json!({"contract":factor_events::CONTRACT,"results":results,"read_only":true})
};
println!("{}", serde_json::to_string(&output)?);
Ok(())
}
@@ -0,0 +1,26 @@
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);
}
}
}
+485 -42
View File
@@ -1,6 +1,6 @@
//! Completed-session OHLCV rules shared by research and strategy execution. //! Completed-session OHLCV rules shared by research and strategy execution.
use crate::DataSet; use crate::DataSet;
use chrono::NaiveDate; use chrono::{FixedOffset, NaiveDate, TimeZone};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::{Value, json}; use serde_json::{Value, json};
use std::collections::{BTreeMap, BTreeSet}; use std::collections::{BTreeMap, BTreeSet};
@@ -9,9 +9,15 @@ pub const CONTRACT: &str = "fidc_daily_ohlcv_pattern_v1";
pub fn catalog() -> Value { pub fn catalog() -> Value {
json!({"contract":CONTRACT,"templates":{ json!({"contract":CONTRACT,"templates":{
"expression":{"label":"指标与事件条件","parameters":{"history_window":[300,2,3000]},"stages":["selection","buy","sell","position_management"],"method":"冻结历史窗口与表达式;预热不足或未定义值不产生信号。复用共享指标事件内核,不修改既有任务。"},
"session_event":{"label":"已完成分钟事件","parameters":{"opening_minutes":[30,1,120],"volume_window":[5,2,120],"volume_multiple":[3.0,1,20]},"stages":["selection","buy","sell"],"method":"仅本交易日完整分钟OHLCVA,信号K线必须早于执行时点;不使用盘口快照伪造K线。"},
"strength":{"label":"趋势强势","parameters":{"momentum_window":[25,5,120],"fast_window":[20,2,60],"slow_window":[60,20,252]},"stages":["selection","buy"],"method":"收盘价>短均线>长均线,按区间动量排序;不是当日金叉。"}, "strength":{"label":"趋势强势","parameters":{"momentum_window":[25,5,120],"fast_window":[20,2,60],"slow_window":[60,20,252]},"stages":["selection","buy"],"method":"收盘价>短均线>长均线,按区间动量排序;不是当日金叉。"},
"breakout":{"label":"前高突破","parameters":{"high_window":[60,5,252],"volume_window":[10,2,60],"volume_multiple":[1.3,1,10],"max_upper_shadow":[0.1,0,1]},"stages":["selection","buy"],"method":"收盘突破此前N日最高价,量达到此前M日均量倍数,上影比例受限;参考窗口不含当日。"}, "breakout":{"label":"前高突破","parameters":{"high_window":[60,5,252],"volume_window":[10,2,60],"volume_multiple":[1.3,1,10],"max_upper_shadow":[0.1,0,1]},"stages":["selection","buy"],"method":"收盘突破此前N日最高价,量达到此前M日均量倍数,上影比例受限;参考窗口不含当日。"},
"volume_spike":{"label":"放量上涨","parameters":{"volume_window":[5,2,60],"volume_multiple":[3.0,1,10]},"stages":["selection","buy"],"method":"当日上涨且量达到此前N日最大量的指定倍数;不等同价格创新高。"}, "volume_spike":{"label":"放量上涨","parameters":{"volume_window":[5,2,60],"volume_multiple":[3.0,1,10]},"stages":["selection","buy"],"method":"当日上涨且量达到此前N日最大量的指定倍数;不等同价格创新高。"},
"mean_volume_spike":{"label":"均量倍增","parameters":{"volume_window":[5,2,60],"volume_multiple":[3.0,1,10]},"stages":["selection","buy"],"method":"量达到此前N个交易日均量的M倍且当日上涨。分母不含当日;保留与最大量规则的区别。"},
"mean_shrink_breakout":{"label":"倍量后缩量阳线突破","parameters":{"spike_lookback":[5,2,30],"volume_window":[5,2,60],"volume_multiple":[3.0,1,10],"shrink_ratio":[0.5,0.01,1]},"stages":["selection","buy"],"method":"此前出现N日均量M倍放量,当前缩量阳线收盘突破该放量日最高价。"},
"breakout_retest":{"label":"突破回踩站回","parameters":{"high_window":[60,5,252],"retest_lookback":[10,2,30],"price_tolerance":[0.02,0,0.2],"shrink_ratio":[0.8,0.01,1]},"stages":["selection","buy"],"method":"观察窗先收盘突破此前N日最高价,随后低点回踩突破位容差区,今日收盘站回该位且不低于昨日、成交量收缩。突破与回踩不得同日。"},
"limit_consolidation":{"label":"涨停后整理(日线)","parameters":{"anchor_lag":[4,2,30],"price_band":[0.05,0,0.3],"volume_band":[0.15,0,2],"ma_window":[5,2,60]},"stages":["selection","buy"],"method":"明确T-i日按真实涨停价收盘,后续收盘和量相对锚日偏离受限,今日收盘低于完整日线均线;不是盘中动态MA条件。"},
"shrink_breakout":{"label":"缩量突破","parameters":{"spike_lookback":[5,2,30],"volume_window":[5,2,60],"volume_multiple":[3.0,1,10],"shrink_ratio":[0.5,0.01,1]},"stages":["selection","buy"],"method":"此前观察窗有放量日,今日收盘超过该日最高价,成交量不超过其指定比例。"}, "shrink_breakout":{"label":"缩量突破","parameters":{"spike_lookback":[5,2,30],"volume_window":[5,2,60],"volume_multiple":[3.0,1,10],"shrink_ratio":[0.5,0.01,1]},"stages":["selection","buy"],"method":"此前观察窗有放量日,今日收盘超过该日最高价,成交量不超过其指定比例。"},
"ma_below":{"label":"均线下方","parameters":{"ma_window":[20,2,252]},"stages":["sell"],"method":"完整收盘价低于含当日的N日均线;独立卖出条件。"}, "ma_below":{"label":"均线下方","parameters":{"ma_window":[20,2,252]},"stages":["sell"],"method":"完整收盘价低于含当日的N日均线;独立卖出条件。"},
"volume_down":{"label":"放量下跌","parameters":{"volume_window":[5,2,60],"volume_multiple":[3.0,1,10]},"stages":["sell"],"method":"当日下跌且量达到此前N日最大量的指定倍数。"} "volume_down":{"label":"放量下跌","parameters":{"volume_window":[5,2,60],"volume_multiple":[3.0,1,10]},"stages":["sell"],"method":"当日下跌且量达到此前N日最大量的指定倍数。"}
@@ -24,9 +30,59 @@ pub struct PatternSpec {
pub template: String, pub template: String,
#[serde(default)] #[serde(default)]
pub parameters: BTreeMap<String, Value>, pub parameters: BTreeMap<String, Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expression: Option<crate::factor_events::Expr>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub execution_context: Option<crate::pattern_context::ExecutionContext>,
#[serde(default,skip_serializing_if="Option::is_none")]
pub session_event:Option<String>,
} }
impl PatternSpec { impl PatternSpec {
pub fn validate(mut self) -> Result<Self, String> { pub fn validate(self) -> Result<Self, String> {
if self.template=="session_event" {
if !self.session_event.as_deref().is_some_and(|id|crate::session_events::EVENTS.contains(&id)) || self.execution_context.is_some() {return Err("session_event_contract_invalid".into());}
} else if self.session_event.is_some() {return Err("unexpected_session_event_id".into());}
let allowed = if let Some(context) = &self.execution_context {
context.validate(self.expression.as_ref().ok_or("pattern_context_requires_expression")?)?;
crate::pattern_context::CONTEXT_FIELDS
} else { &[] };
let spec = self.validate_with_context(allowed)?;
if let Some(context) = &spec.execution_context {
if context.rank_universe.len().saturating_mul(spec.history_len()) > 2_000_000 {
return Err("pattern_rank_window_budget_exceeded: 完整截面不得截断".into());
}
}
Ok(spec)
}
fn validate_with_context(mut self, context_fields: &[&str]) -> Result<Self, String> {
if (self.template == "expression") != self.expression.is_some() {
return Err("expression_template_requires_expression_only".into());
}
if let Some(expr) = &self.expression {
let supported = [
"open",
"high",
"low",
"close",
"volume",
"raw_open",
"raw_high",
"raw_low",
"raw_close",
"prev_close",
"amount",
];
let missing = crate::factor_events::field_dependencies(expr)
.into_iter()
.filter(|f| !supported.contains(&f.as_str()) && !context_fields.contains(&f.as_str()))
.collect::<Vec<_>>();
if !missing.is_empty() {
return Err(format!(
"expression_source_mapping_required: {}",
missing.join(",")
));
}
}
let catalog = catalog(); let catalog = catalog();
let definition = catalog["templates"] let definition = catalog["templates"]
.get(&self.template) .get(&self.template)
@@ -44,7 +100,7 @@ impl PatternSpec {
if number < bounds[1].as_f64().unwrap() || number > bounds[2].as_f64().unwrap() { if number < bounds[1].as_f64().unwrap() || number > bounds[2].as_f64().unwrap() {
return Err(format!("{key}超出允许范围")); return Err(format!("{key}超出允许范围"));
} }
if key.ends_with("window") || key == "spike_lookback" { if key.ends_with("window") || key.ends_with("lookback") || key == "anchor_lag" || key=="opening_minutes" {
if number.fract() != 0.0 { if number.fract() != 0.0 {
return Err(format!("{key}必须是整数")); return Err(format!("{key}必须是整数"));
} }
@@ -66,11 +122,15 @@ impl PatternSpec {
} }
pub fn history_len(&self) -> usize { pub fn history_len(&self) -> usize {
match self.template.as_str() { match self.template.as_str() {
"session_event"=>1,
"expression" => self.n("history_window"),
"strength" => self.n("slow_window").max(self.n("momentum_window") + 1), "strength" => self.n("slow_window").max(self.n("momentum_window") + 1),
"breakout" => self.n("high_window").max(self.n("volume_window")) + 1, "breakout" => self.n("high_window").max(self.n("volume_window")) + 1,
"volume_spike" | "volume_down" => self.n("volume_window") + 1, "volume_spike" | "volume_down" | "mean_volume_spike" => self.n("volume_window") + 1,
"breakout_retest" => self.n("high_window") + self.n("retest_lookback") + 1,
"limit_consolidation" => (self.n("anchor_lag")+1).max(self.n("ma_window")),
"ma_below" => self.n("ma_window").max(2), "ma_below" => self.n("ma_window").max(2),
"shrink_breakout" => self.n("spike_lookback") + self.n("volume_window") + 1, "shrink_breakout" | "mean_shrink_breakout" => self.n("spike_lookback") + self.n("volume_window") + 1,
_ => unreachable!(), _ => unreachable!(),
} }
} }
@@ -85,6 +145,14 @@ pub struct PatternBar {
pub low: Option<f64>, pub low: Option<f64>,
pub close: Option<f64>, pub close: Option<f64>,
pub volume: Option<f64>, pub volume: Option<f64>,
#[serde(default)]
pub prev_close: Option<f64>,
#[serde(default)]
pub amount: Option<f64>,
#[serde(default)]
pub upper_limit: Option<f64>,
#[serde(default)]
pub no_limit: Option<bool>,
pub adjustment_factor_backward1: Option<f64>, pub adjustment_factor_backward1: Option<f64>,
pub paused: Option<bool>, pub paused: Option<bool>,
#[serde(default)] #[serde(default)]
@@ -146,6 +214,17 @@ pub fn evaluate(
days: &[NaiveDate], days: &[NaiveDate],
series: &PatternSeries, series: &PatternSeries,
) -> Result<PatternResult, String> { ) -> Result<PatternResult, String> {
evaluate_with_context(spec, days, series, &BTreeMap::new(), false)
}
pub(crate) fn evaluate_with_context(
spec: &PatternSpec,
days: &[NaiveDate],
series: &PatternSeries,
context: &BTreeMap<String, Vec<Option<f64>>>,
numeric_output: bool,
) -> Result<PatternResult, String> {
if spec.template=="session_event" {return Err("session_event_requires_completed_minute_endpoint".into());}
if days.len() != spec.history_len() || days.windows(2).any(|w| w[0] >= w[1]) { if days.len() != spec.history_len() || days.windows(2).any(|w| w[0] >= w[1]) {
return Err("pattern_calendar_incomplete: 需要完整、唯一且递增的真实交易日窗口".into()); return Err("pattern_calendar_incomplete: 需要完整、唯一且递增的真实交易日窗口".into());
} }
@@ -246,6 +325,124 @@ pub fn evaluate(
result.anchor = json!({"date":days[len-1],"raw_close":by_day[&days[len-1]].close,"factor":by_day[&days[len-1]].adjustment_factor_backward1}); result.anchor = json!({"date":days[len-1],"raw_close":by_day[&days[len-1]].close,"factor":by_day[&days[len-1]].adjustment_factor_backward1});
let mut score = None; let mut score = None;
match spec.template.as_str() { match spec.template.as_str() {
"expression" => {
let zone = FixedOffset::east_opt(8 * 3600).unwrap();
let timestamps = days
.iter()
.map(|d| {
zone.from_local_datetime(&d.and_hms_opt(16, 0, 0).unwrap())
.single()
.unwrap()
})
.collect::<Vec<_>>();
let anchor = by_day[&days[len - 1]].adjustment_factor_backward1.unwrap();
let mut fields = BTreeMap::from([
(
"open".into(),
prices.iter().map(|b| Some(b.0 / anchor)).collect(),
),
(
"high".into(),
prices.iter().map(|b| Some(b.1 / anchor)).collect(),
),
(
"low".into(),
prices.iter().map(|b| Some(b.2 / anchor)).collect(),
),
(
"close".into(),
prices.iter().map(|b| Some(b.3 / anchor)).collect(),
),
("volume".into(), prices.iter().map(|b| Some(b.4)).collect()),
]);
for (name, index) in [
("raw_open", 0),
("raw_high", 1),
("raw_low", 2),
("raw_close", 3),
] {
fields.insert(
name.into(),
days.iter()
.map(|d| {
let b = by_day[d];
[b.open, b.high, b.low, b.close][index]
})
.collect(),
);
}
let needed =
crate::factor_events::field_dependencies(spec.expression.as_ref().unwrap());
for name in ["prev_close", "amount"] {
if !needed.contains(name) {
continue;
}
let values = days
.iter()
.map(|d| {
let b = by_day[d];
let value = number(
if name == "prev_close" {
b.prev_close
} else {
b.amount
},
&series.symbol,
*d,
name,
)?;
if value < 0.0 || (name == "prev_close" && value == 0.0) {
return Err(format!(
"pattern_input_invalid: {} {d} {name}",
series.symbol
));
}
Ok(Some(value))
})
.collect::<Result<Vec<_>, String>>()?;
fields.insert(name.into(), values);
}
for (name, values) in context {
if fields.contains_key(name) || values.len() != days.len()
|| values.iter().flatten().any(|v| !v.is_finite()) {
return Err(format!("research_context_invalid: {} {name}", series.symbol));
}
fields.insert(name.clone(), values.clone());
}
let frame = crate::factor_events::Frame {
symbol: series.symbol.clone(),
frequency: "1d".into(),
decision_at: *timestamps.last().unwrap(),
available_at: timestamps.clone(),
timestamps,
fields,
};
let values = crate::factor_events::evaluate(spec.expression.as_ref().unwrap(), &frame)?;
let latest = values.values.last().copied().flatten();
result.values["expression"] = json!(values);
result.values["expression_contract"] = json!(crate::factor_events::CONTRACT);
result.values["price_policy"] = json!("backward1_anchored_to_decision_close");
result.score = latest;
if numeric_output {
if values.value_type != crate::factor_events::ValueType::Number {
return Err("research_rank_input_requires_numeric_expression".into());
}
return Ok(result);
}
if latest.is_none() {
result.exclusion = Some(
json!({"reason":"expression_undefined_or_warmup","signal_date":days.last()}),
);
} else if values.value_type == crate::factor_events::ValueType::Boolean {
result.matched = latest == Some(1.0);
result
.checks
.push(json!({"label":"组合条件","actual":latest,"operator":"==","threshold":1,"passed":result.matched}));
} else {
return Err("expression_signal_requires_boolean: 数值因子必须显式比较或组合,不能自动视为买卖信号".into());
}
return Ok(result);
}
"strength" => { "strength" => {
let fast = mean(prices[len - spec.n("fast_window")..].iter().map(|b| b.3))?; let fast = mean(prices[len - spec.n("fast_window")..].iter().map(|b| b.3))?;
let slow = mean(prices[len - spec.n("slow_window")..].iter().map(|b| b.3))?; let slow = mean(prices[len - spec.n("slow_window")..].iter().map(|b| b.3))?;
@@ -293,11 +490,12 @@ pub fn evaluate(
spec.v("max_upper_shadow"), spec.v("max_upper_shadow"),
); );
} }
"volume_spike" | "volume_down" => { "volume_spike" | "volume_down" | "mean_volume_spike" => {
let high = prices[len - 1 - spec.n("volume_window")..len - 1] let reference = &prices[len - 1 - spec.n("volume_window")..len - 1];
let high = if spec.template=="mean_volume_spike" {mean(reference.iter().map(|b|b.4))?} else {reference
.iter() .iter()
.map(|b| b.4) .map(|b| b.4)
.fold(0.0, f64::max); .fold(0.0, f64::max)};
if high <= 0.0 { if high <= 0.0 {
return Err(format!( return Err(format!(
"pattern_input_invalid: symbol={}, reason=zero_reference_volume", "pattern_input_invalid: symbol={}, reason=zero_reference_volume",
@@ -308,20 +506,20 @@ pub fn evaluate(
result.values["volume_ratio"] = json!(v / high); result.values["volume_ratio"] = json!(v / high);
check( check(
&mut result.checks, &mut result.checks,
"最大量倍数", if spec.template=="mean_volume_spike" {"均量倍数"} else {"最大量倍数"},
v / high, v / high,
">=", ">=",
spec.v("volume_multiple"), spec.v("volume_multiple"),
); );
check( check(
&mut result.checks, &mut result.checks,
if spec.template == "volume_spike" { if spec.template != "volume_down" {
"当日上涨" "当日上涨"
} else { } else {
"当日下跌" "当日下跌"
}, },
change, change,
if spec.template == "volume_spike" { if spec.template != "volume_down" {
">" ">"
} else { } else {
"<" "<"
@@ -335,14 +533,15 @@ pub fn evaluate(
result.values["ma"] = json!(avg); result.values["ma"] = json!(avg);
check(&mut result.checks, "收盘低于均线", c, "<", avg); check(&mut result.checks, "收盘低于均线", c, "<", avg);
} }
"shrink_breakout" => { "shrink_breakout" | "mean_shrink_breakout" => {
let mut spikes = Vec::new(); let mut spikes = Vec::new();
let mut eligible = Vec::new(); let mut eligible = Vec::new();
for i in len - 1 - spec.n("spike_lookback")..len - 1 { for i in len - 1 - spec.n("spike_lookback")..len - 1 {
let prior = prices[i - spec.n("volume_window")..i] let reference=&prices[i - spec.n("volume_window")..i];
let prior = if spec.template=="mean_shrink_breakout"{mean(reference.iter().map(|b|b.4))?}else{reference
.iter() .iter()
.map(|b| b.4) .map(|b| b.4)
.fold(0.0, f64::max); .fold(0.0, f64::max)};
if prior <= 0.0 { if prior <= 0.0 {
return Err(format!( return Err(format!(
"pattern_input_invalid: symbol={}, date={}, reason=zero_reference_volume", "pattern_input_invalid: symbol={}, date={}, reason=zero_reference_volume",
@@ -382,6 +581,44 @@ pub fn evaluate(
spec.v("shrink_ratio"), spec.v("shrink_ratio"),
); );
} }
if spec.template=="mean_shrink_breakout" {check(&mut result.checks,"当前为阳线",c,">",o);}
}
"breakout_retest" => {
let mut anchors=Vec::new();let mut eligible=Vec::new();
for i in len-1-spec.n("retest_lookback")..len-1 {
let level=prices[i-spec.n("high_window")..i].iter().map(|b|b.1).fold(f64::NEG_INFINITY,f64::max);
if prices[i].3<=level {continue;}
let retraced=prices[i+1..].iter().any(|b| b.2 <= level*(1.0+spec.v("price_tolerance")));
anchors.push((i,level,retraced));
if retraced&&c>=level&&c>=prices[len-2].3&&prices[i].4>0.0&&v<=prices[i].4*spec.v("shrink_ratio") {eligible.push((i,level,retraced));}
}
check(&mut result.checks,"观察窗存在先前突破",anchors.len() as f64,">",0.0);
if let Some(&(i,level,retraced))=eligible.last().or_else(||anchors.last()) {
result.values["breakout_date"]=json!(days[i]);result.values["breakout_level"]=json!(level);result.values["days_since_breakout"]=json!(len-1-i);
check(&mut result.checks,"突破后曾回踩",if retraced{1.0}else{0.0},">",0.0);
check(&mut result.checks,"收盘重新站回突破位",c,">=",level);
check(&mut result.checks,"收盘不低于昨日",c,">=",prices[len-2].3);
if prices[i].4<=0.0{return Err("突破锚日成交量为零,不能计算缩量比例".into());}
check(&mut result.checks,"相对突破日缩量",v/prices[i].4,"<=",spec.v("shrink_ratio"));
score=Some(c/level-1.0);
}
}
"limit_consolidation" => {
let i=len-1-spec.n("anchor_lag");let anchor=by_day[&days[i]];
let is_limit=if anchor.no_limit==Some(true){false}else{
let upper=number(anchor.upper_limit,&series.symbol,days[i],"upper_limit")?;
if upper<=0.0||upper>=99999.0{return Err("涨停事件缺少有效源涨停价或无涨跌幅限制证据,禁止按比例推算".into());}
(anchor.close.unwrap()/upper-1.0).abs()<=1e-8
};
if prices[i].4<=0.0{return Err("涨停锚日成交量为零".into());}
let price_gap=prices[i+1..].iter().map(|b|(b.3/prices[i].3-1.0).abs()).fold(0.0,f64::max);
let volume_gap=prices[i+1..].iter().map(|b|(b.4/prices[i].4-1.0).abs()).fold(0.0,f64::max);
let avg=mean(prices[len-spec.n("ma_window")..].iter().map(|b|b.3))?;
result.values["limit_date"]=json!(days[i]);result.values["price_deviation"]=json!(price_gap);result.values["volume_deviation"]=json!(volume_gap);
check(&mut result.checks,"锚日真实涨停收盘",if is_limit{1.0}else{0.0},">",0.0);
check(&mut result.checks,"后续收盘最大偏离",price_gap,"<=",spec.v("price_band"));
check(&mut result.checks,"后续成交量最大偏离",volume_gap,"<=",spec.v("volume_band"));
check(&mut result.checks,"收盘低于日线均线",c,"<",avg);score=Some(avg/c-1.0);
} }
_ => unreachable!(), _ => unreachable!(),
} }
@@ -398,36 +635,39 @@ pub fn evaluate_dataset(
data: &DataSet, data: &DataSet,
date: NaiveDate, date: NaiveDate,
symbol: &str, symbol: &str,
) -> Result<PatternResult, String> {
let context = crate::pattern_context::build_dataset_context(spec, data, date)?;
evaluate_dataset_context(spec, data, date, symbol, &context)
}
pub fn dataset_series(data: &DataSet, days: &[NaiveDate], symbol: &str) -> PatternSeries {
let bars = days.iter().filter_map(|&d| data.market(d, symbol).map(|b| PatternBar {
date:d, open:Some(b.open), high:Some(b.high), low:Some(b.low), close:Some(b.close),
volume:Some(b.volume as f64), prev_close:Some(b.prev_close),
amount:data.factor_numeric_value(d,symbol,"amount"),upper_limit:Some(b.upper_limit),
no_limit:data.factor_numeric_value(d,symbol,"no_limit").map(|v|v==1.0),
adjustment_factor_backward1:data.factor(d,symbol).and_then(|f|f.adjustment_factor_backward1),
paused:Some(b.paused), source_path:None,
})).collect();
PatternSeries{symbol:symbol.into(),name:data.instrument(symbol).map(|i|i.name.clone()),
listed_at:data.instrument(symbol).and_then(|i|i.listed_at),bars}
}
pub fn evaluate_dataset_context(
spec: &PatternSpec, data: &DataSet, date: NaiveDate, symbol: &str, context: &ResearchContext,
) -> Result<PatternResult,String> { ) -> Result<PatternResult,String> {
let days = data.calendar().trailing_days(date, spec.history_len()); let days = data.calendar().trailing_days(date, spec.history_len());
let bars = days let mut fields = context.common.clone();
.iter() fields.extend(context.by_symbol.get(symbol).cloned().unwrap_or_default());
.filter_map(|&d| { let outside = spec.execution_context.as_ref().is_some_and(|c| c.rank_expression.is_some() && !c.rank_universe.iter().any(|s|s==symbol));
data.market(d, symbol).map(|b| PatternBar { if outside {
date: d, for name in ["scope_rank","scope_percentile"] {fields.insert(name.into(),vec![None;days.len()]);}
open: Some(b.open), fields.insert("scope_size".into(),vec![Some(spec.execution_context.as_ref().unwrap().rank_universe.len() as f64);days.len()]);
high: Some(b.high), }
low: Some(b.low), let mut result = evaluate_with_context(spec,&days,&dataset_series(data,&days,symbol),&fields,false)?;
close: Some(b.close), if outside && result.score.is_none() { result.exclusion=Some(json!({"reason":"outside_frozen_rank_universe","symbol":symbol,"signal_date":date})); }
volume: Some(b.volume as f64), result.values["execution_context_latest"]=json!(fields.iter().map(|(k,v)|(k,v.last().copied().flatten())).collect::<BTreeMap<_,_>>());
adjustment_factor_backward1: data Ok(result)
.factor(d, symbol)
.and_then(|f| f.adjustment_factor_backward1),
paused: Some(b.paused),
source_path: None,
})
})
.collect();
evaluate(
spec,
&days,
&PatternSeries {
symbol: symbol.into(),
name: None,
listed_at: data.instrument(symbol).and_then(|i| i.listed_at),
bars,
},
)
} }
pub fn evaluate_batch( pub fn evaluate_batch(
@@ -456,6 +696,60 @@ pub fn evaluate_batch(
) )
} }
/// Values are supplied only by the verified research transport or dataset context builder.
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ResearchContext {
#[serde(default)]
pub common: BTreeMap<String, Vec<Option<f64>>>,
#[serde(default)]
pub by_symbol: BTreeMap<String, BTreeMap<String, Vec<Option<f64>>>>,
}
pub fn evaluate_research_batch(
spec: PatternSpec, days: &[NaiveDate], series: &[PatternSeries],
context: &ResearchContext, numeric_output: bool,
) -> Result<Value, String> {
let common_fields = ["index_open", "index_high", "index_low", "index_close"];
let symbol_fields = ["scope_rank", "scope_percentile", "scope_size"];
if spec.template != "expression" || series.is_empty() || series.len() > 200
|| series.iter().map(|s| &s.symbol).collect::<BTreeSet<_>>().len() != series.len()
|| context.common.keys().any(|k| !common_fields.contains(&k.as_str()))
|| context.by_symbol.iter().any(|(s, fields)| !series.iter().any(|row| &row.symbol == s)
|| fields.keys().any(|k| !symbol_fields.contains(&k.as_str()))) {
return Err("research_context_scope_or_fields_invalid".into());
}
for (name, values) in &context.common {
if values.len() != days.len() || values.iter().any(|v| !v.is_some_and(|x| x.is_finite() && x > 0.0)) {
return Err(format!("research_index_window_incomplete: {name}"));
}
}
let allowed = common_fields.into_iter().chain(symbol_fields).collect::<Vec<_>>();
let spec = spec.validate_with_context(&allowed)?;
let dependencies = crate::factor_events::field_dependencies(spec.expression.as_ref().unwrap());
let mut rows = Vec::with_capacity(series.len());
for item in series {
let mut fields = context.common.clone();
fields.extend(context.by_symbol.get(&item.symbol).cloned().unwrap_or_default());
if allowed.iter().any(|f| dependencies.contains(*f) && !fields.contains_key(*f)) {
return Err(format!("research_context_missing: {}", item.symbol));
}
for (name, values) in &fields {
if values.len() != days.len() || values.iter().flatten().any(|v| !v.is_finite()
|| (name == "scope_percentile" && !(0.0..=1.0).contains(v))
|| (matches!(name.as_str(), "scope_rank" | "scope_size") && *v < 1.0)) {
return Err(format!("research_context_invalid: {} {name}", item.symbol));
}
}
let mut result = evaluate_with_context(&spec, days, item, &fields, numeric_output)?;
result.values["research_context_latest"] = json!(fields.iter().map(|(k,v)|(k,v.last().copied().flatten())).collect::<BTreeMap<_,_>>());
rows.push(result);
}
Ok(json!({"contract":CONTRACT,"context_contract":"fidc_research_event_context_v1","spec":spec,
"required_history":spec.history_len(),"rows":rows,"read_only":true,
"source_evidence_verified":false,"live_routing":false,"rule_backtest_supported":false}))
}
pub fn expression_specs(expression: &str) -> Result<Vec<PatternSpec>, String> { pub fn expression_specs(expression: &str) -> Result<Vec<PatternSpec>, String> {
let mut specs = Vec::new(); let mut specs = Vec::new();
for helper in ["pattern_signal", "pattern_score"] { for helper in ["pattern_signal", "pattern_score"] {
@@ -491,10 +785,110 @@ pub fn expression_specs(expression: &str) -> Result<Vec<PatternSpec>, String> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn research_index_and_ranking_context_never_unlock_strategy_mapping() {
let days=["2026-09-04","2026-09-07","2026-09-08"].map(|s|s.parse::<NaiveDate>().unwrap());
let spec:PatternSpec=serde_json::from_value(json!({"template":"expression","parameters":{"history_window":3},
"expression":{"kind":"operator","name":"CROSS_ABOVE","args":[{"kind":"field","name":"close"},{"kind":"field","name":"index_close"}]}})).unwrap();
assert!(spec.clone().validate().unwrap_err().contains("mapping_required"));
let series:PatternSeries=serde_json::from_value(json!({"symbol":"TEST","bars":days.iter().zip([9.0,10.0,11.0]).map(|(d,c)|json!({"date":d,"open":c,"high":c,"low":c,"close":c,"volume":100.0,"adjustment_factor_backward1":1.0,"paused":false})).collect::<Vec<_>>()})).unwrap();
let mut context=ResearchContext{common:BTreeMap::from([("index_close".into(),vec![Some(10.0);3])]),..Default::default()};
let result=evaluate_research_batch(spec.clone(),&days,&[series.clone()],&context,false).unwrap();
assert_eq!(result["rows"][0]["matched"],true);
assert_eq!(result["source_evidence_verified"],false);
assert_eq!(result["rule_backtest_supported"],false);
context.common.get_mut("index_close").unwrap()[1]=None;
assert!(evaluate_research_batch(spec.clone(),&days,&[series.clone()],&context,false).unwrap_err().contains("index_window_incomplete"));
context.common=BTreeMap::from([("close".into(),vec![Some(10.0);3])]);
assert!(evaluate_research_batch(spec,&days,&[series],&context,false).is_err());
}
#[test]
fn research_numeric_output_keeps_warmup_unknown_without_a_false_signal() {
let days=["2026-09-04","2026-09-07","2026-09-08"].map(|s|s.parse::<NaiveDate>().unwrap());
let spec:PatternSpec=serde_json::from_value(json!({"template":"expression","parameters":{"history_window":3},
"expression":{"kind":"operator","name":"PCT_CHANGE","window":2,"args":[{"kind":"field","name":"close"}]}})).unwrap();
let series:PatternSeries=serde_json::from_value(json!({"symbol":"TEST","bars":days.iter().zip([10.0,10.5,11.0]).map(|(d,c)|json!({"date":d,"open":c,"high":c,"low":c,"close":c,"volume":100.0,"adjustment_factor_backward1":1.0,"paused":false})).collect::<Vec<_>>()})).unwrap();
assert!(evaluate_batch(spec.clone(),&days,&[series.clone()]).is_err());
let result=evaluate_research_batch(spec,&days,&[series],&ResearchContext::default(),true).unwrap();
let values=&result["rows"][0]["values"]["expression"]["values"];
assert!(values[0].is_null() && values[1].is_null());
assert!((values[2].as_f64().unwrap()-0.1).abs()<1e-12);
assert_eq!(result["rows"][0]["matched"],false);
}
#[test]
fn expression_condition_preserves_native_types_and_rejects_numeric_as_signal() {
let make = |expression: Value| {
serde_json::from_value::<PatternSpec>(json!({"template":"expression","parameters":{"history_window":3},"expression":expression})).unwrap().validate().unwrap()
};
let spec = make(
json!({"kind":"operator","name":"GT","args":[{"kind":"field","name":"close"},{"kind":"indicator","name":"SMA","inputs":[{"kind":"field","name":"close"}],"parameters":{"optInTimePeriod":2}}]}),
);
let days = ["2026-09-04", "2026-09-07", "2026-09-08"]
.map(|d| NaiveDate::parse_from_str(d, "%Y-%m-%d").unwrap());
let series = PatternSeries {
symbol: "TEST".into(),
name: None,
listed_at: None,
bars: days
.iter()
.enumerate()
.map(|(i, &date)| {
let p = 10.0 + i as f64;
PatternBar {
date,
open: Some(p),
high: Some(p),
low: Some(p),
close: Some(p),
volume: Some(100.0),
prev_close: Some(p - 1.0),
amount: Some(p * 100.0),
upper_limit: None,
no_limit: None,
adjustment_factor_backward1: Some(1.0),
paused: Some(false),
source_path: None,
}
})
.collect(),
};
let result = evaluate(&spec, &days, &series).unwrap();
assert!(result.matched);
assert_eq!(result.score, Some(1.0));
let vwap_spec = make(
json!({"kind":"operator","name":"GT","args":[{"kind":"operator","name":"DIV","args":[{"kind":"field","name":"amount"},{"kind":"field","name":"volume"}]},{"kind":"field","name":"prev_close"}]}),
);
assert!(evaluate(&vwap_spec, &days, &series).unwrap().matched);
let mut missing_amount = series.clone();
missing_amount.bars[1].amount = None;
assert!(
evaluate(&vwap_spec, &days, &missing_amount)
.unwrap_err()
.contains("amount")
);
let mut missing_previous=series.clone();missing_previous.bars[1].prev_close=None;
assert!(evaluate(&vwap_spec,&days,&missing_previous).unwrap_err().contains("prev_close"));
assert!(
evaluate(
&make(json!({"kind":"field","name":"close"})),
&days,
&series
)
.unwrap_err()
.contains("requires_boolean")
);
let mut missing = series.clone();
missing.bars[1].close = None;
assert!(evaluate(&spec, &days, &missing).is_err());
}
fn fixture(template: &str) -> (PatternSpec, Vec<NaiveDate>, PatternSeries) { fn fixture(template: &str) -> (PatternSpec, Vec<NaiveDate>, PatternSeries) {
let spec = PatternSpec { let spec = PatternSpec {
template: template.into(), template: template.into(),
parameters: BTreeMap::new(), parameters: BTreeMap::new(),
expression: None,
execution_context: None,
session_event: None,
} }
.validate() .validate()
.unwrap(); .unwrap();
@@ -515,6 +909,10 @@ mod tests {
low: Some(c), low: Some(c),
close: Some(c), close: Some(c),
volume: Some(1000.0), volume: Some(1000.0),
prev_close: Some(c - 1.0),
amount: Some(c * 1000.0),
upper_limit: None,
no_limit: None,
adjustment_factor_backward1: Some(1.0), adjustment_factor_backward1: Some(1.0),
paused: Some(false), paused: Some(false),
source_path: Some("fixture.parquet".into()), source_path: Some("fixture.parquet".into()),
@@ -565,6 +963,51 @@ mod tests {
assert_eq!(a.checks, b.checks); assert_eq!(a.checks, b.checks);
} }
#[test]
fn mean_volume_is_not_prior_max_and_excludes_current_bar() {
let (spec,days,mut series)=fixture("mean_volume_spike");
for (bar,volume) in series.bars.iter_mut().zip([10.,10.,10.,10.,100.,100.]) {bar.volume=Some(volume);}
assert!(evaluate(&spec,&days,&series).unwrap().matched);
let mut old=spec.clone();old.template="volume_spike".into();
assert!(!evaluate(&old,&days,&series).unwrap().matched);
assert_eq!(evaluate(&spec,&days,&series).unwrap().values["volume_ratio"],json!(100./28.));
}
#[test]
fn mean_volume_followup_requires_bullish_breakout_and_shrink() {
let (spec,days,mut series)=fixture("mean_shrink_breakout");
series.bars[6].volume=Some(4000.);
let last=series.bars.last_mut().unwrap();last.open=Some(19.);last.low=Some(19.);
assert!(evaluate(&spec,&days,&series).unwrap().matched);
series.bars.last_mut().unwrap().volume=Some(3000.);
assert!(!evaluate(&spec,&days,&series).unwrap().matched);
}
#[test]
fn breakout_retest_needs_a_later_retest_not_the_breakout_candle_itself() {
let (spec,days,mut series)=fixture("breakout_retest");
for b in &mut series.bars {b.open=Some(10.);b.high=Some(10.);b.low=Some(10.);b.close=Some(10.);}
let anchor=series.bars.len()-11;
let b=&mut series.bars[anchor];b.open=Some(11.);b.high=Some(12.1);b.low=Some(9.9);b.close=Some(12.);b.volume=Some(2000.);
for b in &mut series.bars[anchor+1..] {b.open=Some(10.3);b.high=Some(10.4);b.low=Some(10.3);b.close=Some(10.4);}
assert!(!evaluate(&spec,&days,&series).unwrap().matched);
series.bars[anchor+1].low=Some(9.95);
assert!(evaluate(&spec,&days,&series).unwrap().matched);
}
#[test]
fn limit_consolidation_requires_real_limit_and_never_infers_ten_percent() {
let (spec,days,mut series)=fixture("limit_consolidation");
for (b,c) in series.bars.iter_mut().zip([10.,10.1,10.2,10.1,9.9]) {b.open=Some(c);b.high=Some(c);b.low=Some(c);b.close=Some(c);}
assert!(evaluate(&spec,&days,&series).unwrap_err().contains("upper_limit"));
series.bars[0].upper_limit=Some(10.);
assert!(evaluate(&spec,&days,&series).unwrap().matched);
series.bars[0].no_limit=Some(true);
assert!(!evaluate(&spec,&days,&series).unwrap().matched);
series.bars[0].no_limit=Some(false);series.bars[0].upper_limit=Some(0.);
assert!(evaluate(&spec,&days,&series).is_err());
}
#[test] #[test]
fn daily_patterns_flat_decimal_prices_do_not_create_a_sell_signal() { fn daily_patterns_flat_decimal_prices_do_not_create_a_sell_signal() {
let (mut spec, _, mut series) = fixture("strength"); let (mut spec, _, mut series) = fixture("strength");
+15
View File
@@ -491,6 +491,7 @@ 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)]
@@ -1418,6 +1419,7 @@ 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> {
@@ -1954,6 +1956,7 @@ 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()),
}) })
} }
@@ -2627,9 +2630,21 @@ 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()
} }
@@ -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
+6
View File
@@ -3,6 +3,10 @@ pub mod calendar;
pub mod cost; pub mod cost;
pub mod data; pub mod data;
pub mod daily_patterns; pub mod daily_patterns;
pub mod 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;
@@ -15,6 +19,7 @@ pub mod platform_expr_strategy;
pub mod platform_runtime_schema; pub mod platform_runtime_schema;
pub mod platform_strategy_spec; pub mod platform_strategy_spec;
pub mod portfolio; pub mod portfolio;
pub mod portfolio_loss;
pub mod risk_control; pub mod risk_control;
pub mod rules; pub mod rules;
pub mod scheduler; pub mod scheduler;
@@ -84,6 +89,7 @@ pub use platform_strategy_spec::{
platform_expr_config_from_value, validate_strategy_risk_policy_fields, platform_expr_config_from_value, validate_strategy_risk_policy_fields,
}; };
pub use portfolio::{CashReceivable, HoldingSummary, PendingCashFlow, PortfolioState, Position}; pub use portfolio::{CashReceivable, HoldingSummary, PendingCashFlow, PortfolioState, Position};
pub use portfolio_loss::{ClosedPortfolioSession, PortfolioLossConfig, PortfolioLossDecision, PortfolioLossError, PortfolioLossState};
pub use risk_control::{ pub use risk_control::{
ChinaAShareRiskControl, FidcRiskControlConfig, FidcRiskDecisionAudit, RiskCheckScope, ChinaAShareRiskControl, FidcRiskControlConfig, FidcRiskDecisionAudit, RiskCheckScope,
StaticRiskRuleConfig, TradingConstraintConfig, StaticRiskRuleConfig, TradingConstraintConfig,
+427
View File
@@ -0,0 +1,427 @@
//! 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());
}
}
+293 -29
View File
@@ -3,7 +3,8 @@ use std::collections::{BTreeMap, BTreeSet};
use std::sync::Arc; use std::sync::Arc;
use ahash::{AHashMap, AHashSet}; use ahash::{AHashMap, AHashSet};
use chrono::{Datelike, Duration, NaiveDate, NaiveDateTime, NaiveTime, Timelike}; use chrono::{Datelike, Duration, FixedOffset, NaiveDate, NaiveDateTime, NaiveTime, TimeZone, Timelike, Utc};
use sha2::{Digest, Sha256};
use rhai::{AST, Dynamic, Engine, ImmutableString, Map, Scope}; use rhai::{AST, Dynamic, Engine, ImmutableString, Map, Scope};
use crate::broker::{MatchingType, RebalanceCashMode, SlippageModel}; use crate::broker::{MatchingType, RebalanceCashMode, SlippageModel};
@@ -16,7 +17,7 @@ use crate::data::{
decision_market_cap_bn, decision_market_cap_bn,
}; };
use crate::engine::BacktestError; use crate::engine::BacktestError;
use crate::events::OrderSide; use crate::events::{OrderSide, ProcessEvent, ProcessEventKind};
use crate::fixed_point::FixedMoney; use crate::fixed_point::FixedMoney;
use crate::futures::{ use crate::futures::{
FuturesContractSpec, FuturesDirection, FuturesOrderIntent, FuturesPositionEffect, FuturesContractSpec, FuturesDirection, FuturesOrderIntent, FuturesPositionEffect,
@@ -26,6 +27,7 @@ use crate::numeric_expr_vm::{
Scratch as NumericVmScratch, Value as NumericVmValue, ValueType as NumericVmValueType, Scratch as NumericVmScratch, Value as NumericVmValue, ValueType as NumericVmValueType,
}; };
use crate::portfolio::PortfolioState; use crate::portfolio::PortfolioState;
use crate::portfolio_loss::{ClosedPortfolioSession, PortfolioLossConfig, PortfolioLossState};
use crate::risk_control::{ChinaAShareRiskControl, FidcRiskControlConfig, FidcRiskDecisionAudit}; use crate::risk_control::{ChinaAShareRiskControl, FidcRiskControlConfig, FidcRiskDecisionAudit};
use crate::scheduler::{ScheduleRule, ScheduleStage, ScheduleTimeRule, Scheduler}; use crate::scheduler::{ScheduleRule, ScheduleStage, ScheduleTimeRule, Scheduler};
use crate::strategy::{ use crate::strategy::{
@@ -179,6 +181,22 @@ impl PlatformPortfolioDrawdownController {
} }
} }
fn portfolio_gross_exposure(portfolio: &PortfolioState) -> Result<f64, BacktestError> {
let equity = portfolio.total_equity();
let market_value: f64 = portfolio.positions().values().map(|position| position.market_value().abs()).sum();
if !equity.is_finite() || equity <= 0.0 || !market_value.is_finite() {
return Err(BacktestError::Execution("portfolio loss requires finite positive accounting equity".to_owned()));
}
Ok(market_value / equity)
}
fn portfolio_loss_decision_at(ctx: &StrategyContext<'_>) -> chrono::DateTime<Utc> {
let local = ctx.active_datetime.filter(|value| value.date() == ctx.execution_date)
.unwrap_or_else(|| ctx.execution_date.and_hms_opt(9, 30, 0).unwrap());
FixedOffset::east_opt(8 * 3600).unwrap().from_local_datetime(&local)
.single().unwrap().with_timezone(&Utc)
}
fn validated_target_scales(scales: &[(String, f64)]) -> Result<Vec<(String, f64)>, BacktestError> { fn validated_target_scales(scales: &[(String, f64)]) -> Result<Vec<(String, f64)>, BacktestError> {
if scales.is_empty() { if scales.is_empty() {
return Ok(Vec::new()); return Ok(Vec::new());
@@ -610,6 +628,7 @@ pub struct PlatformExprStrategyConfig {
pub exposure_expr: String, pub exposure_expr: String,
pub position_exposure_schedule: BTreeMap<NaiveDate, f64>, pub position_exposure_schedule: BTreeMap<NaiveDate, f64>,
pub portfolio_drawdown_control: Option<PlatformPortfolioDrawdownControlConfig>, pub portfolio_drawdown_control: Option<PlatformPortfolioDrawdownControlConfig>,
pub portfolio_loss_control: Option<PortfolioLossConfig>,
pub stop_loss_expr: String, pub stop_loss_expr: String,
pub take_profit_expr: String, pub take_profit_expr: String,
pub position_target_rules: Vec<PlatformPositionTargetRule>, pub position_target_rules: Vec<PlatformPositionTargetRule>,
@@ -653,6 +672,7 @@ 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>,
@@ -690,6 +710,7 @@ impl PlatformExprStrategyConfig {
exposure_expr: "1.0".to_string(), exposure_expr: "1.0".to_string(),
position_exposure_schedule: BTreeMap::new(), position_exposure_schedule: BTreeMap::new(),
portfolio_drawdown_control: None, portfolio_drawdown_control: None,
portfolio_loss_control: None,
stop_loss_expr: String::new(), stop_loss_expr: String::new(),
take_profit_expr: String::new(), take_profit_expr: String::new(),
position_target_rules: Vec::new(), position_target_rules: Vec::new(),
@@ -733,6 +754,7 @@ 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,
@@ -1267,7 +1289,7 @@ struct RuntimeHelperBinding {
#[derive(Clone)] #[derive(Clone)]
enum CompiledRuntimeHelperArgs { enum CompiledRuntimeHelperArgs {
DailyPattern { spec: crate::daily_patterns::PatternSpec }, DailyPattern { spec: crate::daily_patterns::PatternSpec, identity: String },
RollingMean { RollingMean {
field: String, field: String,
lookback: usize, lookback: usize,
@@ -1327,6 +1349,9 @@ enum RuntimeHelperResolution {
pub struct PlatformExprStrategy { pub struct PlatformExprStrategy {
pattern_results_date: RefCell<Option<NaiveDate>>, pattern_results_date: RefCell<Option<NaiveDate>>,
pattern_results: RefCell<BTreeMap<(NaiveDate, String, String), crate::daily_patterns::PatternResult>>, 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,
@@ -1335,6 +1360,8 @@ pub struct PlatformExprStrategy {
last_target_order: Option<Vec<String>>, last_target_order: Option<Vec<String>>,
last_trading_ratio: Option<f64>, last_trading_ratio: Option<f64>,
portfolio_drawdown_controller: Option<PlatformPortfolioDrawdownController>, portfolio_drawdown_controller: Option<PlatformPortfolioDrawdownController>,
portfolio_loss_state: Option<PortfolioLossState>,
portfolio_loss_opening: Option<(NaiveDate, f64, f64)>,
pending_highlimit_holdings: BTreeSet<String>, pending_highlimit_holdings: BTreeSet<String>,
pending_full_close_symbols: BTreeSet<String>, pending_full_close_symbols: BTreeSet<String>,
position_entry_dates: BTreeMap<String, NaiveDate>, position_entry_dates: BTreeMap<String, NaiveDate>,
@@ -1433,6 +1460,21 @@ fn completed_session_factor_date(
} }
impl PlatformExprStrategy { impl PlatformExprStrategy {
pub fn portfolio_loss_state(&self) -> Option<&PortfolioLossState> {
self.portfolio_loss_state.as_ref()
}
pub fn restore_portfolio_loss_state(&mut self, state: PortfolioLossState) -> Result<(), BacktestError> {
let expected = self.config.portfolio_loss_control.as_ref().ok_or_else(|| BacktestError::Execution(
"portfolio loss state supplied for a strategy without that control".to_owned()))?;
state.validate(expected).map_err(|error| BacktestError::Execution(error.to_string()))?;
if self.portfolio_loss_opening.is_some() {
return Err(BacktestError::Execution("cannot restore portfolio loss state during an open session".to_owned()));
}
self.portfolio_loss_state = Some(state);
Ok(())
}
fn market_cap_storage_to_strategy_unit(value: f64) -> f64 { fn market_cap_storage_to_strategy_unit(value: f64) -> f64 {
value value
} }
@@ -1729,6 +1771,8 @@ impl PlatformExprStrategy {
last_target_order: None, last_target_order: None,
last_trading_ratio: None, last_trading_ratio: None,
portfolio_drawdown_controller, portfolio_drawdown_controller,
portfolio_loss_state: None,
portfolio_loss_opening: None,
pending_highlimit_holdings: BTreeSet::new(), pending_highlimit_holdings: BTreeSet::new(),
pending_full_close_symbols: BTreeSet::new(), pending_full_close_symbols: BTreeSet::new(),
position_entry_dates: BTreeMap::new(), position_entry_dates: BTreeMap::new(),
@@ -1757,6 +1801,9 @@ impl PlatformExprStrategy {
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_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), 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),
@@ -5666,23 +5713,48 @@ impl PlatformExprStrategy {
args: &CompiledRuntimeHelperArgs, args: &CompiledRuntimeHelperArgs,
) -> Result<RuntimeHelperResolution, BacktestError> { ) -> Result<RuntimeHelperResolution, BacktestError> {
match args { match args {
CompiledRuntimeHelperArgs::DailyPattern { spec } => { CompiledRuntimeHelperArgs::DailyPattern { spec, identity } => {
if self.config.matching_type != MatchingType::NextBarOpen { 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())); return Err(BacktestError::Execution("daily_pattern_requires_next_bar_open: 完整日线形态只能在下一交易日执行".into()));
} }
let date = day.date.min(ctx.decision_date); 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. // 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()) { 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))); 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 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()); let key = (date, stock.symbol.to_string(), identity.clone());
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) { 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)?; 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); self.pattern_results.borrow_mut().insert(key.clone(),result);
} }
let results = self.pattern_results.borrow(); let results = self.pattern_results.borrow();
@@ -6126,7 +6198,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) && !item.extra_factors.contains_key(identifier.as_str())
&& !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))
{ {
@@ -6137,7 +6209,7 @@ impl PlatformExprStrategy {
} else { } else {
let value = item let value = item
.extra_factors .extra_factors
.get(identifier) .get(identifier.as_str())
.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));
@@ -6944,7 +7016,10 @@ impl PlatformExprStrategy {
"pattern_signal" | "pattern_score" if args.len() == 1 => { "pattern_signal" | "pattern_score" if args.len() == 1 => {
let text: String = serde_json::from_str(&args[0]).ok()?; let text: String = serde_json::from_str(&args[0]).ok()?;
let spec: crate::daily_patterns::PatternSpec = serde_json::from_str(&text).ok()?; let spec: crate::daily_patterns::PatternSpec = serde_json::from_str(&text).ok()?;
Some(CompiledRuntimeHelperArgs::DailyPattern { spec: spec.validate().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()?;
@@ -8485,12 +8560,18 @@ impl PlatformExprStrategy {
) )
.unwrap_or(strategy_exposure) .unwrap_or(strategy_exposure)
.clamp(0.0, 1.0); .clamp(0.0, 1.0);
let Some(controller) = self.portfolio_drawdown_controller.as_mut() else { let mut exposure = risk_on_exposure;
return Ok(risk_on_exposure); if let Some(controller) = self.portfolio_drawdown_controller.as_mut() {
}; exposure = controller.update(ctx.decision_date, day.total_value, exposure)?.target_exposure;
controller }
.update(ctx.decision_date, day.total_value, risk_on_exposure) if self.config.portfolio_loss_control.is_some() {
.map(|decision| decision.target_exposure.clamp(0.0, 1.0)) let state = self.portfolio_loss_state.as_mut().ok_or_else(|| BacktestError::Execution(
"portfolio loss state must be initialized or restored before planning".to_owned()))?;
let previous = ctx.data.previous_trading_date(ctx.execution_date, 1);
exposure = state.decide(ctx.execution_date, previous, portfolio_loss_decision_at(ctx), exposure)
.map_err(|error| BacktestError::Execution(error.to_string()))?.target_exposure;
}
Ok(exposure.clamp(0.0, 1.0))
} }
fn market_cap_band( fn market_cap_band(
@@ -12144,6 +12225,59 @@ impl Strategy for PlatformExprStrategy {
self.config.strategy_name.as_str() self.config.strategy_name.as_str()
} }
fn before_trading(&mut self, ctx: &StrategyContext<'_>) -> Result<(), BacktestError> {
let Some(config) = self.config.portfolio_loss_control.clone() else { return Ok(()); };
if ctx.futures_account.is_some() {
return Err(BacktestError::Execution("portfolio loss control currently requires equity-only accounting".to_owned()));
}
if self.portfolio_loss_state.is_none() {
self.portfolio_loss_state = Some(PortfolioLossState::new(config, ctx.execution_date)
.map_err(|error| BacktestError::Execution(error.to_string()))?);
}
if let Some((date, _, _)) = self.portfolio_loss_opening {
if date == ctx.execution_date { return Ok(()); }
return Err(BacktestError::Execution("portfolio loss previous session was not finalized".to_owned()));
}
let state = self.portfolio_loss_state.as_ref().unwrap();
let start_nav = state.last_session().map(|row| row.end_unit_nav).unwrap_or_else(|| ctx.portfolio.unit_net_value());
let gross = portfolio_gross_exposure(ctx.portfolio)?;
self.portfolio_loss_opening = Some((ctx.execution_date, start_nav, gross));
// Advance the daily risk clock even when the selection schedule is not
// due. The actual current exposure budget is applied during planning.
self.portfolio_loss_state.as_mut().unwrap().decide(ctx.execution_date,
ctx.data.previous_trading_date(ctx.execution_date, 1), portfolio_loss_decision_at(ctx), 1.0)
.map_err(|error| BacktestError::Execution(error.to_string()))?;
Ok(())
}
fn on_process_event(&mut self, ctx: &StrategyContext<'_>, event: &ProcessEvent) -> Result<(), BacktestError> {
if self.config.portfolio_loss_control.is_none() || event.kind != ProcessEventKind::PostSettlement { return Ok(()); }
let Some((date, start_nav, start_gross)) = self.portfolio_loss_opening else {
return Err(BacktestError::Execution("portfolio loss settlement has no opening accounting snapshot".to_owned()));
};
if date != ctx.execution_date {
return Err(BacktestError::Execution("portfolio loss settlement date differs from opening snapshot".to_owned()));
}
let end_nav = ctx.portfolio.unit_net_value();
let end_gross = portfolio_gross_exposure(ctx.portfolio)?;
let state = self.portfolio_loss_state.as_mut().unwrap();
let previous = state.last_session().map(|row| row.date);
let mut hash = Sha256::new();
hash.update(b"fidc.engine-finalized-portfolio-session/v1\0");
hash.update(self.config.strategy_name.as_bytes());
hash.update(date.to_string().as_bytes());
for value in [start_nav, end_nav, start_gross, end_gross] { hash.update(value.to_bits().to_le_bytes()); }
state.observe(ClosedPortfolioSession {
date, previous_session_date: previous,
available_at: date.and_hms_opt(7, 30, 0).unwrap().and_utc(),
start_unit_nav: start_nav, end_unit_nav: end_nav,
start_gross_exposure: start_gross, end_gross_exposure: end_gross,
source_sha256: format!("{:x}", hash.finalize()),
}).map_err(|error| BacktestError::Execution(error.to_string()))?;
self.portfolio_loss_opening = None;
Ok(())
}
fn initial_subscriptions(&self) -> BTreeSet<String> { fn initial_subscriptions(&self) -> BTreeSet<String> {
self.config.initial_subscriptions.clone() self.config.initial_subscriptions.clone()
} }
@@ -12162,13 +12296,15 @@ impl Strategy for PlatformExprStrategy {
.is_some() .is_some()
&& self.config.rotation_enabled && self.config.rotation_enabled
{ {
rules.push( let schedule=self.config.rebalance_schedule.as_ref().expect("checked timed rebalance schedule");
self.config if self.config.session_event_times.is_empty() {
.rebalance_schedule rules.push(schedule.as_schedule_rule(ScheduleStage::OnDay));
.as_ref() } else {
.expect("checked timed rebalance schedule") for time in &self.config.session_event_times {
.as_schedule_rule(ScheduleStage::OnDay), let mut timed=schedule.clone();timed.time_rule=Some(ScheduleTimeRule::physical_time(time.hour(),time.minute()));
); 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;
@@ -12231,6 +12367,7 @@ impl Strategy for PlatformExprStrategy {
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());
@@ -12325,12 +12462,28 @@ impl PlatformExprStrategy {
} }
fn append_pattern_diagnostics(&self, decision: &mut StrategyDecision) { 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() { 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 { 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(); 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); } 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> {
@@ -14006,6 +14159,10 @@ impl PlatformExprStrategy {
{ {
diagnostics.push(diagnostic); diagnostics.push(diagnostic);
} }
if let Some(decision) = self.portfolio_loss_state.as_ref().and_then(PortfolioLossState::last_decision) {
diagnostics.push(format!("portfolio_loss_control {}", serde_json::to_string(decision)
.map_err(|error| BacktestError::Execution(error.to_string()))?));
}
let notes = vec![ let notes = vec![
format!("stock_list={}", stock_list.len()), format!("stock_list={}", stock_list.len()),
@@ -14197,6 +14354,28 @@ 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);
@@ -14535,6 +14714,91 @@ mod tests {
.expect("single-symbol platform dataset") .expect("single-symbol platform dataset")
} }
#[test]
fn portfolio_loss_observes_finalized_nav_after_fees_and_cash_flows() {
use std::sync::Mutex;
use chrono::Duration;
use crate::{BacktestConfig, BacktestEngine, BacktestError, BrokerSimulator,
ChinaAShareCostModel, ChinaEquityRuleHooks, ClosedPortfolioSession,
PortfolioLossConfig, PriceField, StrategyDecision};
struct Capture {
inner: PlatformExprStrategy,
first: NaiveDate,
rows: Arc<Mutex<Vec<(ClosedPortfolioSession, crate::portfolio_loss::PortfolioLossDecision)>>>,
}
impl Strategy for Capture {
fn name(&self) -> &str { "portfolio-loss-lifecycle-test" }
fn requires_minute_callbacks(&self) -> bool { false }
fn before_trading(&mut self, ctx: &StrategyContext<'_>) -> Result<(), BacktestError> {
self.inner.before_trading(ctx)
}
fn on_day(&mut self, ctx: &StrategyContext<'_>) -> Result<StrategyDecision, BacktestError> {
let mut decision = self.inner.on_day(ctx)?;
if ctx.execution_date == self.first {
decision.order_intents.push(OrderIntent::SetManagementFeeRate { rate: 0.001, reason: "fee accounting test".to_owned() });
}
if ctx.execution_date == self.first + Duration::days(5) {
decision.order_intents.push(OrderIntent::DepositWithdraw { amount: 10_000.0, receiving_days: 0, reason: "unit NAV flow test".to_owned() });
}
Ok(decision)
}
fn on_process_event(&mut self, ctx: &StrategyContext<'_>, event: &ProcessEvent) -> Result<(), BacktestError> {
self.inner.on_process_event(ctx, event)?;
if event.kind == ProcessEventKind::PostSettlement {
let state = self.inner.portfolio_loss_state().unwrap();
self.rows.lock().unwrap().push((state.last_session().unwrap().clone(), state.last_decision().unwrap().clone()));
}
Ok(())
}
}
let first = d(2023, 1, 3);
let dates = (0..25).map(|day| first + Duration::days(day)).collect::<Vec<_>>();
let mut parts = single_symbol_platform_data(&dates, "000001.SZ").snapshot_components();
for (index, row) in parts.market.iter_mut().enumerate() {
let price = (1000.0 * 0.99_f64.powi(index as i32)).round() / 100.0;
row.day_open = price; row.open = price; row.high = price; row.low = price;
row.close = price; row.last_price = price; row.bid1 = price; row.ask1 = price;
row.prev_close = price / 0.99; row.upper_limit = price * 1.1; row.lower_limit = price * 0.9;
}
let data = DataSet::from_components(parts.instruments, parts.market, parts.factors, parts.candidates, parts.benchmarks).unwrap();
let mut config = PlatformExprStrategyConfig::generic();
config.universe_include = Some(BTreeSet::from(["000001.SZ".to_owned()]));
config.signal_symbol = "000001.SZ".to_owned();
config.benchmark_symbol = "000852.SH".to_owned();
config.stock_filter_expr = "true".to_owned(); config.rank_expr = "1.0".to_owned();
config.selection_limit_expr = "1".to_owned(); config.max_positions = 1;
config.market_cap_lower_expr = "0.0".to_owned(); config.market_cap_upper_expr = "1000.0".to_owned();
config.exposure_expr = "0.9".to_owned(); config.refresh_rate = 1; config.refresh_rate_expr = "1".to_owned();
config.rebalance_existing_positions = true;
config.portfolio_loss_control = Some(PortfolioLossConfig { lookback: 10, loss_trigger: 0.05, floor_exposure: 0.2, cooldown_trading_days: 3 });
let rows = Arc::new(Mutex::new(Vec::new()));
let strategy = Capture { inner: PlatformExprStrategy::new(config), first, rows: Arc::clone(&rows) };
let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
.with_matching_type(MatchingType::CurrentBarClose);
let mut engine = BacktestEngine::new(data, strategy, broker, BacktestConfig {
initial_cash: 10_000.0, benchmark_code: "000852.SH".to_owned(), start_date: Some(first),
end_date: dates.last().copied(), decision_lag_trading_days: 0, execution_price_field: PriceField::Close,
});
let result = engine.run().unwrap();
let records = rows.lock().unwrap();
assert_eq!(records.len(), dates.len());
assert!(records.iter().any(|(_, decision)| decision.newly_triggered), "risk sessions={:?}",
records.iter().map(|(session, decision)| (session.date, session.start_unit_nav, session.end_unit_nav,
session.start_gross_exposure, session.end_gross_exposure, decision.trailing_unit_return, decision.target_exposure)).collect::<Vec<_>>());
assert!(result.fills.len() > 1, "fills={} orders={:?} diagnostics={:?}", result.fills.len(),
result.order_events.iter().take(4).collect::<Vec<_>>(),
result.equity_curve.iter().take(3).map(|row| &row.diagnostics).collect::<Vec<_>>());
assert_eq!(result.equity_curve[5].external_cash_flow, 10_000.0);
for ((session, decision), equity) in records.iter().zip(&result.equity_curve) {
assert_eq!(session.date, equity.date);
assert_eq!(session.end_unit_nav.to_bits(), equity.unit_nav.to_bits());
assert!(decision.observed_through.is_none_or(|date| date < session.date));
if decision.observation_count < 10 { assert!(!decision.threshold_breached); }
}
assert!(result.equity_curve[5].unit_nav < result.equity_curve[4].unit_nav);
}
#[test] #[test]
fn stock_state_cache_resets_before_reusing_compact_keys_on_another_date() { fn stock_state_cache_resets_before_reusing_compact_keys_on_another_date() {
let dates = [d(2025, 1, 2), d(2025, 1, 3)]; let dates = [d(2025, 1, 2), d(2025, 1, 3)];
@@ -3,6 +3,7 @@ use std::collections::{BTreeMap, BTreeSet, HashSet};
use chrono::{NaiveDate, NaiveTime}; use chrono::{NaiveDate, NaiveTime};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::Value; use serde_json::Value;
use crate::portfolio_loss::PortfolioLossConfig;
use crate::{ use crate::{
DynamicSlippageConfig, MatchingType, PlatformAccountActionKind, PlatformExplicitActionStage, DynamicSlippageConfig, MatchingType, PlatformAccountActionKind, PlatformExplicitActionStage,
@@ -915,6 +916,8 @@ pub struct StrategyExpressionRiskConfig {
#[serde(default)] #[serde(default)]
pub portfolio_drawdown_control: Option<StrategyPortfolioDrawdownControlConfig>, pub portfolio_drawdown_control: Option<StrategyPortfolioDrawdownControlConfig>,
#[serde(default)] #[serde(default)]
pub portfolio_loss_control: Option<StrategyPortfolioLossControlConfig>,
#[serde(default)]
pub stop_loss_expr: Option<String>, pub stop_loss_expr: Option<String>,
#[serde(default)] #[serde(default)]
pub take_profit_expr: Option<String>, pub take_profit_expr: Option<String>,
@@ -963,6 +966,16 @@ pub struct StrategyPortfolioDrawdownControlConfig {
pub cooldown_trading_days: Option<usize>, pub cooldown_trading_days: Option<usize>,
} }
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct StrategyPortfolioLossControlConfig {
pub enabled: Option<bool>,
pub lookback: Option<usize>,
pub loss_trigger: Option<f64>,
pub floor_exposure: Option<f64>,
pub cooldown_trading_days: Option<usize>,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)] #[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct StrategyExpressionOrderingConfig { pub struct StrategyExpressionOrderingConfig {
@@ -2176,6 +2189,18 @@ pub fn platform_expr_config_from_spec(
)); ));
} }
} }
if let Some(control) = risk.portfolio_loss_control.as_ref()
&& control.enabled.unwrap_or(true)
{
let parsed = PortfolioLossConfig {
lookback: control.lookback.ok_or("portfolioLossControl.lookback is required")?,
loss_trigger: control.loss_trigger.ok_or("portfolioLossControl.lossTrigger is required")?,
floor_exposure: control.floor_exposure.ok_or("portfolioLossControl.floorExposure is required")?,
cooldown_trading_days: control.cooldown_trading_days.ok_or("portfolioLossControl.cooldownTradingDays is required")?,
};
parsed.validate().map_err(|error| error.to_string())?;
cfg.portfolio_loss_control = Some(parsed);
}
if let Some(control) = risk.portfolio_drawdown_control.as_ref() if let Some(control) = risk.portfolio_drawdown_control.as_ref()
&& control.enabled.unwrap_or(true) && control.enabled.unwrap_or(true)
{ {
@@ -2513,6 +2538,10 @@ 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()
@@ -4433,6 +4462,20 @@ 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!({
@@ -4564,6 +4607,27 @@ mod tests {
assert_eq!(control.cooldown_trading_days, 30); assert_eq!(control.cooldown_trading_days, 30);
} }
#[test]
fn portfolio_loss_contract_is_explicit_and_validated() {
let spec = serde_json::json!({"runtimeExpressions":{"risk":{"portfolioLossControl":{
"enabled":true,"lookback":20,"lossTrigger":0.05,"floorExposure":0.2,"cooldownTradingDays":10
}}}});
let cfg = platform_expr_config_from_value("", "", &spec).unwrap();
assert_eq!(cfg.portfolio_loss_control.unwrap(), PortfolioLossConfig {
lookback:20, loss_trigger:0.05, floor_exposure:0.2, cooldown_trading_days:10,
});
for (field, value) in [("lookback", serde_json::json!(0)),
("lossTrigger", serde_json::json!(0.01)), ("floorExposure", serde_json::json!(1.1)),
("cooldownTradingDays", serde_json::json!(0))] {
let mut invalid = spec.clone();
invalid["runtimeExpressions"]["risk"]["portfolioLossControl"][field] = value;
assert!(platform_expr_config_from_value("", "", &invalid).is_err());
}
let mut missing = spec.clone();
missing["runtimeExpressions"]["risk"]["portfolioLossControl"].as_object_mut().unwrap().remove("lossTrigger");
assert!(platform_expr_config_from_value("", "", &missing).is_err());
}
#[test] #[test]
fn rejects_invalid_portfolio_drawdown_control() { fn rejects_invalid_portfolio_drawdown_control() {
let spec = serde_json::json!({ let spec = serde_json::json!({
+517
View File
@@ -0,0 +1,517 @@
//! Causal portfolio-loss state, independent of market-data and order adapters.
use std::collections::VecDeque;
use chrono::{DateTime, FixedOffset, NaiveDate, Utc};
use serde::{Deserialize, Serialize};
use thiserror::Error;
const STATE_SCHEMA: &str = "fidc.portfolio-loss-state/v1";
const MAX_OBSERVATIONS: usize = 120;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct PortfolioLossConfig {
pub lookback: usize,
pub loss_trigger: f64,
pub floor_exposure: f64,
pub cooldown_trading_days: usize,
}
impl PortfolioLossConfig {
pub fn validate(&self) -> Result<(), PortfolioLossError> {
if !matches!(self.lookback, 10 | 20 | 40 | 60)
|| !self.loss_trigger.is_finite()
|| !(0.02..=0.30).contains(&self.loss_trigger)
|| !self.floor_exposure.is_finite()
|| !(0.0..=1.0).contains(&self.floor_exposure)
|| !(1..=120).contains(&self.cooldown_trading_days)
{
return Err(PortfolioLossError::InvalidConfig);
}
Ok(())
}
}
/// Finalized portfolio accounting, not a market close used as a proxy for NAV.
/// Unit NAV must already exclude external deposits and withdrawals.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ClosedPortfolioSession {
pub date: NaiveDate,
pub previous_session_date: Option<NaiveDate>,
pub available_at: DateTime<Utc>,
pub start_unit_nav: f64,
pub end_unit_nav: f64,
pub start_gross_exposure: f64,
pub end_gross_exposure: f64,
pub source_sha256: String,
}
impl ClosedPortfolioSession {
fn validate(&self) -> Result<(), PortfolioLossError> {
let earliest = self.date.and_hms_opt(7, 30, 0).unwrap().and_utc();
if [self.start_unit_nav, self.end_unit_nav]
.iter()
.any(|value| !value.is_finite() || *value <= 0.0)
|| [self.start_gross_exposure, self.end_gross_exposure]
.iter()
.any(|value| !value.is_finite() || *value < 0.0)
|| self
.previous_session_date
.is_some_and(|date| date >= self.date)
|| self.available_at < earliest
|| self.source_sha256.len() != 64
|| !self
.source_sha256
.bytes()
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
{
return Err(PortfolioLossError::InvalidObservation);
}
self.unit_return()?;
Ok(())
}
fn unit_return(&self) -> Result<Option<f64>, PortfolioLossError> {
let gross = self.start_gross_exposure.max(self.end_gross_exposure);
if gross <= 1e-12 {
return Ok(None);
}
let value = (self.end_unit_nav / self.start_unit_nav - 1.0) / gross;
if !value.is_finite() {
return Err(PortfolioLossError::InvalidObservation);
}
Ok(Some(value))
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct PortfolioLossDecision {
pub execution_date: NaiveDate,
pub observed_through: Option<NaiveDate>,
pub observation_count: usize,
pub trailing_unit_return: Option<f64>,
pub threshold_breached: bool,
pub newly_triggered: bool,
pub risk_off: bool,
pub cooldown_before: usize,
pub cooldown_after: usize,
pub target_exposure: f64,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct PortfolioLossState {
schema_version: String,
config: PortfolioLossConfig,
started_on: NaiveDate,
observations: VecDeque<ClosedPortfolioSession>,
last_session: Option<ClosedPortfolioSession>,
cooldown_remaining: usize,
trigger_count: usize,
last_decision: Option<PortfolioLossDecision>,
}
#[derive(Debug, Error, PartialEq, Eq)]
pub enum PortfolioLossError {
#[error("invalid portfolio loss configuration")]
InvalidConfig,
#[error("invalid finalized portfolio session observation")]
InvalidObservation,
#[error("portfolio loss state does not match its frozen configuration")]
StateMismatch,
#[error("portfolio session history is missing, reordered or corrected")]
SessionDiscontinuity,
#[error("portfolio loss observation is not visible at the decision")]
ObservationNotVisible,
#[error("portfolio loss decisions must follow trading-session order")]
DecisionOrder,
}
impl PortfolioLossState {
pub fn new(
config: PortfolioLossConfig,
started_on: NaiveDate,
) -> Result<Self, PortfolioLossError> {
config.validate()?;
Ok(Self {
schema_version: STATE_SCHEMA.to_owned(),
config,
started_on,
observations: VecDeque::new(),
last_session: None,
cooldown_remaining: 0,
trigger_count: 0,
last_decision: None,
})
}
/// Validation is required after deserialization; a JSON hash alone is not
/// account/generation authorization, which belongs to the state owner.
pub fn validate(&self, expected: &PortfolioLossConfig) -> Result<(), PortfolioLossError> {
expected.validate()?;
if self.schema_version != STATE_SCHEMA
|| &self.config != expected
|| self.observations.len() > MAX_OBSERVATIONS
|| self.cooldown_remaining >= expected.cooldown_trading_days
{
return Err(PortfolioLossError::StateMismatch);
}
let mut previous = None;
for item in &self.observations {
item.validate()?;
if item.date < self.started_on
|| previous.is_some_and(|date| item.date <= date)
|| item.unit_return()?.is_none()
{
return Err(PortfolioLossError::StateMismatch);
}
previous = Some(item.date);
}
if let Some(last) = &self.last_session {
last.validate()?;
if last.date < self.started_on
|| previous.is_some_and(|date| date > last.date)
|| (last.unit_return()?.is_some() && self.observations.back() != Some(last))
{
return Err(PortfolioLossError::StateMismatch);
}
} else if !self.observations.is_empty() {
return Err(PortfolioLossError::StateMismatch);
}
if let Some(decision) = &self.last_decision {
let breached = decision
.trailing_unit_return
.is_some_and(|value| value <= -expected.loss_trigger);
let triggered = decision.cooldown_before == 0 && breached;
let after = if decision.cooldown_before > 0 {
decision.cooldown_before - 1
} else if triggered {
expected.cooldown_trading_days - 1
} else {
0
};
if decision.execution_date < self.started_on
|| decision
.observed_through
.is_some_and(|date| date >= decision.execution_date)
|| !decision.target_exposure.is_finite()
|| !(0.0..=1.0).contains(&decision.target_exposure)
|| decision
.trailing_unit_return
.is_some_and(|value| !value.is_finite())
|| decision.cooldown_after != self.cooldown_remaining
|| decision.observation_count > MAX_OBSERVATIONS
|| decision.cooldown_before >= expected.cooldown_trading_days
|| decision.threshold_breached != breached
|| decision.newly_triggered != triggered
|| decision.risk_off != (decision.cooldown_before > 0 || triggered)
|| decision.cooldown_after != after
|| decision.trailing_unit_return.is_some()
!= (decision.observation_count >= expected.lookback)
|| self.trigger_count
> (decision.execution_date - self.started_on).num_days() as usize + 1
{
return Err(PortfolioLossError::StateMismatch);
}
} else if self.cooldown_remaining != 0 || self.trigger_count != 0 {
return Err(PortfolioLossError::StateMismatch);
}
Ok(())
}
/// Exact duplicate delivery is idempotent. Historical corrections require
/// explicit reconciliation instead of changing an already-used window.
pub fn observe(&mut self, session: ClosedPortfolioSession) -> Result<bool, PortfolioLossError> {
self.validate(&self.config)?;
session.validate()?;
if self.last_session.as_ref() == Some(&session) {
return Ok(false);
}
let previous_date = self.last_session.as_ref().map(|value| value.date);
if session.date < self.started_on
|| session.previous_session_date != previous_date
|| previous_date.is_some_and(|date| session.date <= date)
|| (previous_date.is_none() && session.date != self.started_on)
|| self
.last_session
.as_ref()
.is_some_and(|last| session.start_unit_nav != last.end_unit_nav)
{
return Err(PortfolioLossError::SessionDiscontinuity);
}
if session.unit_return()?.is_some() {
self.observations.push_back(session.clone());
if self.observations.len() > MAX_OBSERVATIONS {
self.observations.pop_front();
}
}
self.last_session = Some(session);
Ok(true)
}
pub fn decide(
&mut self,
execution_date: NaiveDate,
previous_completed_session: Option<NaiveDate>,
decision_at: DateTime<Utc>,
risk_on_exposure: f64,
) -> Result<PortfolioLossDecision, PortfolioLossError> {
self.validate(&self.config)?;
if !risk_on_exposure.is_finite() || !(0.0..=1.0).contains(&risk_on_exposure) {
return Err(PortfolioLossError::InvalidConfig);
}
if execution_date < self.started_on
|| previous_completed_session.is_some_and(|date| date >= execution_date)
|| decision_at
.with_timezone(&FixedOffset::east_opt(8 * 3600).unwrap())
.date_naive()
!= execution_date
|| self
.last_decision
.as_ref()
.is_some_and(|last| execution_date < last.execution_date)
{
return Err(PortfolioLossError::DecisionOrder);
}
if let Some(last) = &self.last_session {
if last.date >= execution_date || last.available_at > decision_at {
return Err(PortfolioLossError::ObservationNotVisible);
}
if Some(last.date) != previous_completed_session {
return Err(PortfolioLossError::SessionDiscontinuity);
}
} else if execution_date != self.started_on {
return Err(PortfolioLossError::SessionDiscontinuity);
}
if let Some(cached) = self
.last_decision
.as_mut()
.filter(|last| last.execution_date == execution_date)
{
cached.target_exposure = if cached.risk_off {
self.config.floor_exposure.min(risk_on_exposure)
} else {
risk_on_exposure
};
return Ok(cached.clone());
}
let trailing = if self.observations.len() >= self.config.lookback {
let start = self.observations.len() - self.config.lookback;
let mut growth = 1.0;
for item in self.observations.iter().skip(start) {
growth *=
(1.0 + item.unit_return()?.expect("nonzero exposure observation")).max(0.0);
}
let result = growth - 1.0;
if !result.is_finite() {
return Err(PortfolioLossError::InvalidObservation);
}
Some(result)
} else {
None
};
let breached = trailing.is_some_and(|value| value <= -self.config.loss_trigger);
let before = self.cooldown_remaining;
let triggered = before == 0 && breached;
let risk_off = before > 0 || triggered;
let after = if before > 0 {
before - 1
} else if triggered {
self.config.cooldown_trading_days - 1
} else {
0
};
let decision = PortfolioLossDecision {
execution_date,
observed_through: self.last_session.as_ref().map(|value| value.date),
observation_count: self.observations.len(),
trailing_unit_return: trailing,
threshold_breached: breached,
newly_triggered: triggered,
risk_off,
cooldown_before: before,
cooldown_after: after,
target_exposure: if risk_off {
self.config.floor_exposure.min(risk_on_exposure)
} else {
risk_on_exposure
},
};
self.cooldown_remaining = after;
self.trigger_count += usize::from(triggered);
self.last_decision = Some(decision.clone());
Ok(decision)
}
pub fn last_session(&self) -> Option<&ClosedPortfolioSession> {
self.last_session.as_ref()
}
pub fn last_decision(&self) -> Option<&PortfolioLossDecision> {
self.last_decision.as_ref()
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::{Duration, TimeZone};
fn date(day: i64) -> NaiveDate {
NaiveDate::from_ymd_opt(2023, 1, 3).unwrap() + Duration::days(day)
}
fn time(day: i64, hour: u32) -> DateTime<Utc> {
Utc.from_utc_datetime(&date(day).and_hms_opt(hour, 0, 0).unwrap())
}
fn config() -> PortfolioLossConfig {
PortfolioLossConfig {
lookback: 10,
loss_trigger: 0.05,
floor_exposure: 0.2,
cooldown_trading_days: 3,
}
}
fn session(day: i64, start: f64, end: f64, gross: f64) -> ClosedPortfolioSession {
ClosedPortfolioSession {
date: date(day),
previous_session_date: (day > 0).then(|| date(day - 1)),
available_at: time(day, 8),
start_unit_nav: start,
end_unit_nav: end,
start_gross_exposure: gross,
end_gross_exposure: gross,
source_sha256: "a".repeat(64),
}
}
#[test]
fn restart_is_exact_and_duplicate_decisions_do_not_consume_cooldown() {
let mut state = PortfolioLossState::new(config(), date(0)).unwrap();
let mut nav = 1.0;
for day in 0..10 {
let end = nav * 0.994;
state.observe(session(day, nav, end, 1.0)).unwrap();
nav = end;
}
let first = state
.decide(date(10), Some(date(9)), time(10, 1), 0.9)
.unwrap();
assert!(first.newly_triggered);
assert_eq!(first.cooldown_after, 2);
let serialized = serde_json::to_string(&state).unwrap();
let mut restored: PortfolioLossState = serde_json::from_str(&serialized).unwrap();
restored.validate(&config()).unwrap();
assert_eq!(
first,
restored
.decide(date(10), Some(date(9)), time(10, 1), 0.9)
.unwrap()
);
let lowered = restored
.decide(date(10), Some(date(9)), time(10, 2), 0.1)
.unwrap();
assert_eq!(lowered.target_exposure, 0.1);
assert_eq!(lowered.cooldown_after, 2);
for day in 10..15 {
let end = nav * 1.01;
let row = session(day, nav, end, 0.2);
state.observe(row.clone()).unwrap();
restored.observe(row).unwrap();
nav = end;
assert_eq!(
state
.decide(date(day + 1), Some(date(day)), time(day + 1, 1), 0.9)
.unwrap(),
restored
.decide(date(day + 1), Some(date(day)), time(day + 1, 1), 0.9)
.unwrap()
);
}
}
#[test]
fn refuses_future_missing_corrected_and_incomplete_accounting() {
let mut state = PortfolioLossState::new(config(), date(0)).unwrap();
let first = session(0, 1.0, 0.99, 1.0);
assert!(state.observe(first.clone()).unwrap());
assert!(!state.observe(first.clone()).unwrap());
let original = state.clone();
let mut changed = first;
changed.end_unit_nav = 0.98;
assert_eq!(
state.observe(changed),
Err(PortfolioLossError::SessionDiscontinuity)
);
assert_eq!(state, original);
assert_eq!(
state.decide(date(0), None, time(0, 1), 0.9),
Err(PortfolioLossError::ObservationNotVisible)
);
assert_eq!(
state.decide(date(2), Some(date(1)), time(2, 1), 0.9),
Err(PortfolioLossError::SessionDiscontinuity)
);
let mut late = PortfolioLossState::new(config(), date(0)).unwrap();
let mut delayed = session(0, 1.0, 0.99, 1.0);
delayed.available_at = time(2, 1);
late.observe(delayed).unwrap();
assert_eq!(
late.decide(date(1), Some(date(0)), time(1, 1), 0.9),
Err(PortfolioLossError::ObservationNotVisible)
);
let mut invalid = session(1, 0.99, 1.0, 1.0);
invalid.end_unit_nav = f64::NAN;
assert_eq!(
state.observe(invalid),
Err(PortfolioLossError::InvalidObservation)
);
}
#[test]
fn cash_sessions_preserve_continuity_without_inventing_returns() {
let mut state = PortfolioLossState::new(config(), date(0)).unwrap();
for day in 0..20 {
state.observe(session(day, 1.0, 1.0, 0.0)).unwrap();
}
let decision = state
.decide(date(20), Some(date(19)), time(20, 1), 0.9)
.unwrap();
assert_eq!(decision.observation_count, 0);
assert_eq!(decision.trailing_unit_return, None);
assert_eq!(decision.target_exposure, 0.9);
assert_eq!(state.last_session().unwrap().date, date(19));
}
#[test]
fn restored_state_rejects_changed_policy_and_forged_cooldown() {
let state = PortfolioLossState::new(config(), date(0)).unwrap();
let mut changed = config();
changed.floor_exposure = 0.5;
assert_eq!(
state.validate(&changed),
Err(PortfolioLossError::StateMismatch)
);
let mut forged = state.clone();
forged.cooldown_remaining = 1;
assert_eq!(
forged.validate(&config()),
Err(PortfolioLossError::StateMismatch)
);
}
#[test]
fn nav_serialization_preserves_float_bits() {
let mut seed = 0xabcddcba12345678_u64;
for _ in 0..2000 {
seed ^= seed << 13;
seed ^= seed >> 7;
seed ^= seed << 17;
let value = 0.01 + (seed as f64 / u64::MAX as f64) * 9.99;
let serialized = serde_json::to_string(&value).unwrap();
let restored: f64 = serde_json::from_str(&serialized).unwrap();
assert_eq!(value.to_bits(), restored.to_bits());
}
}
}
+398
View File
@@ -0,0 +1,398 @@
//! 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);
}
}
@@ -0,0 +1,25 @@
# Production Portfolio Risk Contract
Status: implementation in progress. This document does not admit a strategy to production.
Research breadth/loss rules are not yet production controls: Alpha currently rejects dynamic breadth without full-market PIT input, and the existing Strategy Runtime creates a fresh strategy per request. A single successful request cannot prove stateful drawdown or cooldown behavior.
## Ownership
- Source Lake owns market-only aggregates, with a full-market universe distinct from the trading selection, completed-date visibility, formula/adjustment semantics and source identity. A selected Top40 subset is not a market-breadth input.
- Engine owns simulated portfolio accounting. Risk observations must be finalized after execution, settlement and fees, not inferred from benchmark returns or recorded before management fees.
- Trading Platform owns strategy-instance/generation-scoped observations and state in PostgreSQL. Loading and committing state require the execution lease and optimistic version checks. Account/generation/config identity must be checked before runtime planning; a content hash alone is not authorization.
- Strategy Runtime is a pure calculation boundary: restore verified state, consume closed-session facts, calculate intents and return proposed next state. Never silently initialize an established strategy's state on every HTTP request.
- AiQuant must calculate its own portfolio observations from its own fills/accounting under the same declared formulas. Historical target weights or researcher-generated risk-off booleans are not production logic.
## Loss Rule
The v1 research rule compounds completed net daily unit returns divided by the maximum of beginning/end gross exposure. A zero-exposure session advances continuity but adds no return observation. Window sizes are valid invested observations, not calendar days. Only sessions before the execution day and available by the decision may be consumed.
The loss trigger, floor and cooldown are explicit. Repeated evaluation within one execution day must not decrement cooldown twice. A reduced current exposure budget still caps the returned target. Corrections, dropped sessions, nonfinite values and wrong configuration are reconciliation errors, not zero-filled history. State is serialized and validated on restore, bounded to 120 observations, and is never shared across accounts or strategies.
With a zero floor, the original invested-observation rule can remain in cash while its loss window stays unchanged. That behavior must not be described as automatic market re-entry; an alternate rearm policy requires a separately frozen semantic version and research validation. Current v13 research floors are positive.
## Remaining Integration
Wire finalized engine events, JSON configuration/capability contracts and runner diagnostics. Add authoritative trading-state storage/restore and fail closed when that state is absent. Add full-market breadth input construction and both-framework consumers. Verify independent daily inputs, state after restart, exact risk decisions, orders/holdings/NAV and real same-bundle backtests before removing production gates. No live orders or production approval are authorized by component tests.