Compare commits

..

77 Commits

Author SHA1 Message Date
boris 6160a74d2a 合并主分支最新因子元数据合同 2026-09-11 15:01:29 +08:00
boris d847cb5c28 修正回撤指标的初始净值基线并补充回归测试 2026-09-11 15:00:02 +08:00
boris fa0b316a8b refactor: separate expression metadata and tests from numerical identity 2026-09-11 13:26:39 +08:00
boris 21786187c9 feat: publish typed native indicator parameter domains 2026-09-11 12:52:28 +08:00
boris e0bed38184 Merge remote-tracking branch 'refs/remotes/highmem177/main' 2026-09-11 12:14:35 +08:00
boris c0b78846d6 fix: preserve frozen stock pool candidate order through execution 2026-09-11 12:14:33 +08:00
boris 9d72567b99 test: use the projection module state in calibration failure regression 2026-09-11 12:09:33 +08:00
boris e47228beff fix: reject invalid historical slippage bounds before execution 2026-09-11 11:57:59 +08:00
boris 1fc8a3a9e6 test: enforce causal historical slippage and reject missing calibration 2026-09-11 11:42:21 +08:00
boris 98199c02a2 refactor: isolate historical slippage calibration and propagate pricing errors 2026-09-11 11:35:58 +08:00
boris 6eaa06c1d6 docs: record per-leg price risk release and unchanged minute replay 2026-09-11 10:44:15 +08:00
boris 7e0877b586 fix: validate price risk on every execution leg before and after slippage 2026-09-11 10:21:46 +08:00
boris 36833b7a6a docs: distinguish merged trading tests from published runtime 2026-09-11 10:12:38 +08:00
boris 4c0157b66c docs: record pre-existing realtime quota outage and merged-main test scope 2026-09-11 10:08:40 +08:00
boris f7d16fb664 Merge remote-tracking branch 'origin/main' 2026-09-11 10:03:34 +08:00
boris 97cdfa5972 docs: record execution-price release and open capacity audit blockers 2026-09-11 10:01:37 +08:00
boris f2e228e0a3 合并最新执行风控与策略保护内核 2026-09-11 09:52:51 +08:00
boris 33924b1fba 统一策略成交保护与锁定周期并修正日期条件覆盖 2026-09-11 09:52:04 +08:00
boris d3c36e9478 fix: retain precise missing execution quote diagnostics during risk checks 2026-09-11 09:14:58 +08:00
boris c32807db1d test: distinguish next-open one-yuan execution from later close classification 2026-09-11 09:11:28 +08:00
boris 20d723e2a1 test: inspect broker fill events in shared signal account audit 2026-09-11 09:08:57 +08:00
boris c2b939e818 fix: price one-yuan buy risk at execution and audit account-local signal exits 2026-09-11 09:02:52 +08:00
boris 6684f48f95 test: provide the required benchmark in clock-only dataset fixtures 2026-09-11 05:28:40 +08:00
boris adbfadcc07 test: use canonical local timestamps in signal consumption cases 2026-09-11 05:25:18 +08:00
boris cb6f57be6f fix: bind signal availability to the actual consumption clock 2026-09-11 05:23:33 +08:00
boris e75db2d7b0 test: declare daily market coverage for both minute subscriptions 2026-09-11 04:58:00 +08:00
boris e42dc6938b test: align minute fixtures and missing-data assertions with strict contracts 2026-09-11 04:56:25 +08:00
boris 4f4c1ab7e0 fix: canonicalize all execution limit aliases consistently 2026-09-11 04:48:56 +08:00
boris e549a23c66 统一登记信号引用的策略字段别名 2026-09-11 01:45:46 +08:00
boris 4b21fc4f3f 开放共享策略别名规范化入口供服务统一使用 2026-09-11 01:32:05 +08:00
boris e4bac1cf40 合并最新内核并保留严格数值别名校验 2026-09-11 01:04:27 +08:00
boris 1de96494b3 统一策略数值别名并保留精确冲突校验 2026-09-11 01:03:22 +08:00
boris d9bac529d6 feat: expose frozen signal generator identity to execution clients 2026-09-11 00:04:08 +08:00
boris 3e8d652af1 merge: keep verified dependency lock and canonical v2 signal kernel 2026-09-10 23:50:04 +08:00
boris 23043ee18b build: preserve generated signal client dependency resolution 2026-09-10 23:48:54 +08:00
boris c7c2e69b88 合并已登记信号读取与稳定跨语言数值摘要 2026-09-10 23:30:41 +08:00
boris e6746a7a0e 统一第二版信号簿字段并提供原生校验入口 2026-09-10 23:27:43 +08:00
boris 123467d7ae build: lock shared signal client dependencies 2026-09-10 23:15:59 +08:00
boris b3a3bdbdfd test: provide native signal book identity probe 2026-09-10 23:00:50 +08:00
boris 3f9cff1ee5 merge: retain audited signal identity and cross-language semantic hashing 2026-09-10 22:50:54 +08:00
boris db88abb9e0 feat: share verified signal books across backtest and trading clients 2026-09-10 22:40:59 +08:00
boris 75e5e32281 绑定信号簿内容摘要并按决策日校验日线输入 2026-09-10 22:22:03 +08:00
boris 7d05f8f7c7 为执行报价预取测试补齐明确证券生命周期 2026-09-10 22:18:50 +08:00
boris d01f32ca5b Merge branch 'main' of /tmp/fidc-signal-transfer.2sgDaY/signal.bundle 2026-09-10 22:12:56 +08:00
boris 3dd7b2bd50 fix: distinguish signal consumption and strategy decision schedule dates 2026-09-10 21:20:43 +08:00
boris c8f6ed102c feat: introduce typed account-independent signal execution contract 2026-09-10 21:19:04 +08:00
boris 4664f1a2d3 排除仅作参考的基准并记录周期买入风控拒绝 2026-09-10 20:21:44 +08:00
boris 40481e8825 按日期区分生命周期缺价并保留上市前现金区间 2026-09-10 19:09:21 +08:00
boris 2473cc04bb 为原生因子研究冻结计算内核身份 2026-09-10 16:32:21 +08:00
boris 3fa1004ec5 校正封板成功率为封板家数除以触板家数 2026-09-10 14:46:32 +08:00
boris c4632bacf1 独立保留市场统计并标明行业缺失日期 2026-09-10 14:08:05 +08:00
boris 7dcaae594a 接入完整市场与申万行业事件研究内核 2026-09-10 13:35:46 +08:00
boris 999bf5bd01 研究计算隔离证券数据异常并保持交易严格校验 2026-09-10 09:04:08 +08:00
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
boris ee2865829d Merge remote-tracking branch 'origin/main' 2026-09-09 09:31:46 +08:00
boris ee77028907 docs: record missing-value semantics and real replay gates 2026-09-09 06:07:01 +08:00
42 changed files with 8074 additions and 441 deletions
Generated
+1078 -8
View File
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -1,6 +1,7 @@
[workspace]
members = [
"crates/fidc-core",
"crates/fidc-signal-client",
]
resolver = "2"
@@ -11,6 +12,7 @@ version = "0.1.0"
authors = ["OpenAI Codex"]
[workspace.dependencies]
sha2 = "=0.10.9"
ahash = "=0.8.12"
chrono = { version = "=0.4.44", features = ["serde"] }
indexmap = { version = "=2.11.4", features = ["serde"] }
@@ -18,5 +20,5 @@ reqwest = { version = "=0.12.24", default-features = false, features = ["json",
rayon = "=1.12.0"
rhai = { version = "=1.23.6", features = ["sync"] }
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"
+2
View File
@@ -13,4 +13,6 @@ rayon.workspace = true
rhai.workspace = true
serde.workspace = true
serde_json.workspace = true
sha2.workspace = true
thiserror.workspace = true
ta-lib = { git = "https://github.com/TA-Lib/ta-lib.git", rev = "dd5a90259a3f9e04e2da9f38bf0719a841b40108" }
@@ -0,0 +1,42 @@
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 if serde_json::from_str::<Value>(&input)?.get("rank_history").is_some() {
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Rank { dates:Vec<chrono::NaiveDate>, universe:Vec<String>, values:std::collections::BTreeMap<String,Vec<Option<f64>>> }
let value:Value=serde_json::from_str(&input)?;
let request:Rank=serde_json::from_value(value["rank_history"].clone())?;
json!({"result":fidc_core::factor_cross_section::rank_history(&request.dates,&request.universe,&request.values)?})
} 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,9 @@
use std::io::{self, Read};
fn main() {
let mut input=String::new();io::stdin().read_to_string(&mut input).unwrap();
let request=serde_json::from_str(&input).unwrap();
match fidc_core::market_event_context::aggregate(request) {
Ok(value)=>println!("{}",serde_json::to_string(&value).unwrap()),
Err(error)=>{eprintln!("{error}");std::process::exit(1);}
}
}
@@ -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);
}
}
}
@@ -0,0 +1,15 @@
use std::io::{Read, Write};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut raw=Vec::new();
std::io::stdin().take(64*1024*1024+1).read_to_end(&mut raw)?;
if raw.len()>64*1024*1024 {return Err("signal_book_transport_limit".into());}
let book:fidc_core::signal_contract::SignalBook=serde_json::from_slice(&raw)?;
let version=book.content_sha256()?;
let validated=book.validate()?;
let result=serde_json::json!({"schema":fidc_core::signal_contract::SIGNAL_BOOK_SCHEMA,
"versionSha256":version,"symbols":validated.symbols(),
"onlineAllowed":validated.require_observed().is_ok()});
std::io::stdout().write_all(serde_json::to_string(&result)?.as_bytes())?;
Ok(())
}
+341 -97
View File
@@ -292,42 +292,76 @@ pub struct DynamicSlippageConfig {
impl DynamicSlippageConfig {
pub fn new(impact_coefficient: f64, volatility_coefficient: f64, max_ratio: f64) -> Self {
Self {
impact_coefficient: impact_coefficient.max(0.0),
volatility_coefficient: volatility_coefficient.max(0.0),
max_ratio: max_ratio.max(0.0),
impact_coefficient,
volatility_coefficient,
max_ratio,
}
}
pub(crate) fn ratio(
&self,
snapshot: &crate::data::DailyMarketSnapshot,
raw_price: f64,
calibration: &HistoricalSlippageCalibration,
order_value: Option<f64>,
) -> f64 {
let daily_amount = (snapshot.volume as f64 * raw_price).max(0.0);
) -> Result<f64, BacktestError> {
if [self.impact_coefficient, self.volatility_coefficient, self.max_ratio]
.into_iter().any(|value| !value.is_finite() || value < 0.0)
|| self.max_ratio >= 1.0
|| order_value.is_some_and(|value| !value.is_finite() || value < 0.0)
{
return Err(BacktestError::Execution("invalid_historical_slippage_parameters_or_order_value".into()));
}
let impact_ratio = match order_value {
Some(value) if value.is_finite() && value > 0.0 && daily_amount > 0.0 => {
value / daily_amount
Some(value) if value.is_finite() && value > 0.0 => {
value / calibration.turnover_proxy
}
_ => 0.0,
};
let volatility_base = if snapshot.prev_close.is_finite() && snapshot.prev_close > 0.0 {
snapshot.prev_close
} else {
raw_price
};
let volatility = if snapshot.high.is_finite()
&& snapshot.low.is_finite()
&& volatility_base.is_finite()
&& volatility_base > 0.0
let impact = if self.impact_coefficient == 0.0 { 0.0 } else { impact_ratio * self.impact_coefficient };
let ratio = impact
+ calibration.range_ratio * self.volatility_coefficient;
Ok(ratio.clamp(0.0, self.max_ratio))
}
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct HistoricalSlippageCalibration {
source_date: NaiveDate,
turnover_proxy: f64,
range_ratio: f64,
}
impl HistoricalSlippageCalibration {
pub(crate) fn for_execution(data: &DataSet, date: NaiveDate, symbol: &str) -> Result<Self, BacktestError> {
let missing = || BacktestError::Execution(format!(
"historical_slippage_calibration_missing symbol={symbol} execution_date={date} policy=previous_completed_session"
));
let previous_date = data.previous_trading_date(date, 1).ok_or_else(missing)?;
let row = data.market(previous_date, symbol).ok_or_else(missing)?;
Self::from_completed_snapshot(row, date)
}
fn from_completed_snapshot(
row: &crate::data::DailyMarketSnapshot,
execution_date: NaiveDate,
) -> Result<Self, BacktestError> {
let turnover_proxy = row.volume as f64 * row.close;
let range_ratio = (row.high - row.low) / row.prev_close;
if row.date >= execution_date
|| [row.high, row.low, row.close, row.prev_close, turnover_proxy]
.into_iter().any(|value| !value.is_finite() || value <= 0.0)
|| row.high < row.low
|| !range_ratio.is_finite()
{
((snapshot.high - snapshot.low).abs() / volatility_base).max(0.0)
} else {
0.0
};
let ratio =
impact_ratio * self.impact_coefficient + volatility * self.volatility_coefficient;
ratio.clamp(0.0, self.max_ratio)
return Err(BacktestError::Execution(format!(
"historical_slippage_calibration_invalid symbol={} source_date={} execution_date={} volume={} high={} low={} close={} prev_close={}",
row.symbol, row.date, execution_date, row.volume, row.high, row.low, row.close, row.prev_close,
)));
}
Ok(Self {
source_date: row.date,
turnover_proxy,
range_ratio,
})
}
}
@@ -343,7 +377,7 @@ pub enum SlippageModel {
PriceRatio(f64),
TickSize(f64),
LimitPrice,
Dynamic(DynamicSlippageConfig),
HistoricalVolumeVolatility(DynamicSlippageConfig),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -381,6 +415,8 @@ pub struct BrokerSimulator<C, R> {
runtime_intraday_end_time: Cell<Option<NaiveTime>>,
runtime_decision_date: Cell<Option<NaiveDate>>,
runtime_buy_denials: RefCell<BTreeMap<String, String>>,
runtime_auto_buy_denials: RefCell<BTreeMap<String, String>>,
runtime_auto_sell_denials: RefCell<BTreeMap<String, String>>,
runtime_order_created_date: Cell<Option<NaiveDate>>,
runtime_decision_total_equity: Cell<Option<f64>>,
runtime_target_position_limit: Cell<Option<usize>>,
@@ -414,6 +450,8 @@ impl<C, R> BrokerSimulator<C, R> {
runtime_intraday_end_time: Cell::new(None),
runtime_decision_date: Cell::new(None),
runtime_buy_denials: RefCell::new(BTreeMap::new()),
runtime_auto_buy_denials: RefCell::new(BTreeMap::new()),
runtime_auto_sell_denials: RefCell::new(BTreeMap::new()),
runtime_order_created_date: Cell::new(None),
runtime_decision_total_equity: Cell::new(None),
runtime_target_position_limit: Cell::new(None),
@@ -451,6 +489,8 @@ impl<C, R> BrokerSimulator<C, R> {
runtime_intraday_end_time: Cell::new(None),
runtime_decision_date: Cell::new(None),
runtime_buy_denials: RefCell::new(BTreeMap::new()),
runtime_auto_buy_denials: RefCell::new(BTreeMap::new()),
runtime_auto_sell_denials: RefCell::new(BTreeMap::new()),
runtime_order_created_date: Cell::new(None),
runtime_decision_total_equity: Cell::new(None),
runtime_target_position_limit: Cell::new(None),
@@ -1103,12 +1143,28 @@ where
fn snapshot_execution_price(
&self,
data: &DataSet,
snapshot: &crate::data::DailyMarketSnapshot,
side: OrderSide,
quantity: Option<u32>,
) -> f64 {
) -> Result<f64, BacktestError> {
let raw_price = self.snapshot_raw_execution_price(snapshot, side);
self.apply_slippage(snapshot, side, raw_price, quantity)
let calibration = self.slippage_calibration(data, snapshot)?;
self.apply_slippage(snapshot, side, raw_price, quantity, calibration.as_ref())
}
fn slippage_calibration(
&self,
data: &DataSet,
snapshot: &crate::data::DailyMarketSnapshot,
) -> Result<Option<HistoricalSlippageCalibration>, BacktestError> {
if !matches!(self.slippage_model, SlippageModel::HistoricalVolumeVolatility(_))
|| self.is_open_auction_matching()
|| self.is_post_close_fixed_price(snapshot.date)
{
return Ok(None);
}
HistoricalSlippageCalibration::for_execution(data, snapshot.date, &snapshot.symbol).map(Some)
}
fn snapshot_raw_execution_price(
@@ -1178,17 +1234,18 @@ where
side: OrderSide,
raw_price: f64,
quantity: Option<u32>,
) -> f64 {
calibration: Option<&HistoricalSlippageCalibration>,
) -> Result<f64, BacktestError> {
if !raw_price.is_finite() || raw_price <= 0.0 {
return raw_price;
return Ok(raw_price);
}
if self.is_open_auction_matching() {
return self.clamp_execution_price(snapshot, side, raw_price);
return Ok(self.clamp_execution_price(snapshot, side, raw_price));
}
if self.is_post_close_fixed_price(snapshot.date) {
return self.clamp_execution_price(snapshot, side, raw_price);
return Ok(self.clamp_execution_price(snapshot, side, raw_price));
}
let order_value = quantity.and_then(|qty| (qty > 0).then_some(raw_price * qty as f64));
@@ -1210,8 +1267,12 @@ where
}
}
SlippageModel::LimitPrice => raw_price,
SlippageModel::Dynamic(config) => {
let ratio = config.ratio(snapshot, raw_price, order_value);
SlippageModel::HistoricalVolumeVolatility(config) => {
let calibration = calibration.filter(|value| value.source_date < snapshot.date)
.ok_or_else(|| BacktestError::Execution(format!(
"historical_slippage_calibration_required symbol={} execution_date={}", snapshot.symbol, snapshot.date,
)))?;
let ratio = config.ratio(calibration, order_value)?;
match side {
OrderSide::Buy => raw_price * (1.0 + ratio),
OrderSide::Sell => raw_price * (1.0 - ratio),
@@ -1225,7 +1286,7 @@ where
adjusted *= 1.0 + self.sell_then_buy_delay_slippage_rate;
}
self.clamp_execution_price(snapshot, side, adjusted)
Ok(self.clamp_execution_price(snapshot, side, adjusted))
}
fn clamp_execution_price(
@@ -1260,8 +1321,9 @@ where
side: OrderSide,
raw_price: f64,
quantity: Option<u32>,
) -> f64 {
self.apply_slippage(snapshot, side, raw_price, quantity)
calibration: Option<&HistoricalSlippageCalibration>,
) -> Result<f64, BacktestError> {
self.apply_slippage(snapshot, side, raw_price, quantity, calibration)
}
fn matching_type_for_algo_request(
@@ -1389,6 +1451,11 @@ where
) -> Result<BrokerExecutionReport, BacktestError> {
let previous_decision_date = self.runtime_decision_date.get();
let previous_buy_denials = self.runtime_buy_denials.replace(decision.buy_denials.clone());
let protection_denials = |scope| decision.risk_decisions.iter()
.filter(|row| !row.accepted && row.stage == "automatic_trade_protection" && row.scope == scope)
.map(|row| (row.symbol.clone(), row.reason.clone())).collect();
let previous_auto_buy_denials = self.runtime_auto_buy_denials.replace(protection_denials(crate::risk_control::RiskCheckScope::Buy));
let previous_auto_sell_denials = self.runtime_auto_sell_denials.replace(protection_denials(crate::risk_control::RiskCheckScope::Sell));
let previous_order_created_date = self.runtime_order_created_date.get();
let previous_decision_total_equity = self.runtime_decision_total_equity.get();
self.runtime_decision_date.set(Some(decision_date));
@@ -1398,6 +1465,8 @@ where
.set(decision_total_equity.filter(|equity| equity.is_finite() && *equity >= 0.0));
let result = self.execute_with_runtime_dates(date, portfolio, data, decision);
self.runtime_buy_denials.replace(previous_buy_denials);
self.runtime_auto_buy_denials.replace(previous_auto_buy_denials);
self.runtime_auto_sell_denials.replace(previous_auto_sell_denials);
self.runtime_decision_date.set(previous_decision_date);
self.runtime_order_created_date
.set(previous_order_created_date);
@@ -1564,7 +1633,7 @@ where
.unwrap_or(0);
if target_qty > current_qty {
let requested_qty = target_qty - current_qty;
if !self.can_afford_minimum_buy(date, portfolio, data, &symbol) {
if !self.can_afford_minimum_buy(date, portfolio, data, &symbol)? {
if report.diagnostics.len() < 32 {
report.diagnostics.push(format!(
"rebalance_buy_reduced symbol={} provisional={} final={} current={} reason=actual_cash_after_sells",
@@ -2850,6 +2919,15 @@ where
return;
}
let protection = match existing.side {
OrderSide::Buy => self.runtime_auto_buy_denials.borrow().get(&existing.symbol).cloned(),
OrderSide::Sell => self.runtime_auto_sell_denials.borrow().get(&existing.symbol).cloned(),
};
if let Some(denial) = protection
&& (target_limit_price.to_bits() != existing.limit_price.to_bits() || target_total_quantity != existing.requested_quantity) {
Self::emit_open_order_update_rejected(report, date, order_id, Some(&existing.symbol), Some(existing.side), reason, &denial);
return;
}
let resets_queue_priority = target_limit_price.to_bits() != existing.limit_price.to_bits()
|| target_total_quantity > existing.requested_quantity;
{
@@ -3363,7 +3441,7 @@ where
price,
minimum_order_quantity,
order_step_size,
))
)?)
} else {
self.round_buy_quantity(
(target_value / price).floor() as u32,
@@ -3419,15 +3497,17 @@ where
let buy_execution_price = data
.market(date, &symbol)
.map(|snapshot| {
self.snapshot_execution_price(snapshot, OrderSide::Buy, Some(buy_quantity))
self.snapshot_execution_price(data, snapshot, OrderSide::Buy, Some(buy_quantity))
})
.transpose()?
.filter(|execution_price| execution_price.is_finite() && *execution_price > 0.0)
.unwrap_or(price);
let sell_execution_price = data
.market(date, &symbol)
.map(|snapshot| {
self.snapshot_execution_price(snapshot, OrderSide::Sell, Some(sell_quantity))
self.snapshot_execution_price(data, snapshot, OrderSide::Sell, Some(sell_quantity))
})
.transpose()?
.filter(|execution_price| execution_price.is_finite() && *execution_price > 0.0)
.unwrap_or(price);
if desired_qty < current_qty
@@ -3757,7 +3837,7 @@ where
continue;
}
let buy_qty = target_qty - current_qty;
if !self.can_afford_minimum_buy(date, portfolio, data, symbol) {
if !self.can_afford_minimum_buy(date, portfolio, data, symbol)? {
if report.diagnostics.len() < 32 {
report.diagnostics.push(format!(
"rebalance_buy_reduced symbol={} provisional={} final={} current={} reason=actual_cash_after_sells",
@@ -4139,6 +4219,9 @@ where
minimum_order_quantity: u32,
order_step_size: u32,
) -> Option<String> {
if let Some(reason) = self.runtime_auto_sell_denials.borrow().get(symbol) {
return Some(reason.clone());
}
if current_qty == 0 {
return None;
}
@@ -4258,9 +4341,9 @@ where
portfolio: &PortfolioState,
data: &DataSet,
symbol: &str,
) -> bool {
) -> Result<bool, BacktestError> {
let Some(snapshot) = data.market(date, symbol) else {
return true;
return Ok(true);
};
let minimum_order_quantity = self.minimum_order_quantity(data, symbol);
let order_step_size = self.order_step_size(data, symbol);
@@ -4270,14 +4353,14 @@ where
order_step_size,
);
if minimum_buy_quantity == 0 {
return false;
return Ok(false);
}
let minimum_execution_price =
self.snapshot_execution_price(snapshot, OrderSide::Buy, Some(minimum_buy_quantity));
Self::fixed_cash_fits(
self.snapshot_execution_price(data, snapshot, OrderSide::Buy, Some(minimum_buy_quantity))?;
Ok(Self::fixed_cash_fits(
self.estimated_buy_cash_out(date, minimum_execution_price, minimum_buy_quantity),
portfolio.cash(),
)
))
}
fn process_sell(
@@ -4299,6 +4382,10 @@ where
algo_request: Option<&AlgoExecutionRequest>,
report: &mut BrokerExecutionReport,
) -> Result<(), BacktestError> {
// Existing accepted orders are not canceled by a subsequently enabled lock.
if emit_creation_events && self.runtime_auto_sell_denials.borrow().contains_key(symbol) {
return Ok(());
}
let remainder_policy = self.effective_remainder_policy(date, allow_pending_limit);
let Some(position) = portfolio.position(symbol) else {
return Ok(());
@@ -4681,7 +4768,7 @@ where
None,
algo_request,
limit_price,
);
)?;
let (filled_qty, execution_legs, next_cursor, liquidity_consumption) = if let Some(fill) =
fill
{
@@ -4695,7 +4782,7 @@ where
)
} else {
let execution_price =
self.snapshot_execution_price(snapshot, OrderSide::Sell, Some(fillable_qty));
self.snapshot_execution_price(data, snapshot, OrderSide::Sell, Some(fillable_qty))?;
if let Some(reason) =
self.execution_limit_rejection_reason(snapshot, OrderSide::Sell, execution_price)
{
@@ -6074,6 +6161,9 @@ where
algo_request: Option<&AlgoExecutionRequest>,
report: &mut BrokerExecutionReport,
) -> Result<(), BacktestError> {
if emit_creation_events && self.runtime_auto_buy_denials.borrow().contains_key(symbol) {
return Ok(());
}
let remainder_policy = self.effective_remainder_policy(date, allow_pending_limit);
if portfolio
.position(symbol)
@@ -6166,6 +6256,24 @@ where
} else {
rule
};
if (rule.allowed || rule.reason.as_deref() == Some("invalid execution price"))
&& let Some(missing_reason) =
self.missing_daily_execution_price_reason(snapshot, algo_request)
{
Self::reject_missing_execution_price_order(
report,
date,
order_id,
symbol,
OrderSide::Buy,
requested_qty,
reason,
missing_reason,
emit_creation_events,
);
self.clear_open_order(order_id);
return Ok(());
}
if !rule.allowed {
let rule_reason = rule.reason.as_deref().unwrap_or_default().to_string();
let status = match rule.reason.as_deref() {
@@ -6202,24 +6310,6 @@ where
return Ok(());
}
if let Some(missing_reason) =
self.missing_daily_execution_price_reason(snapshot, algo_request)
{
Self::reject_missing_execution_price_order(
report,
date,
order_id,
symbol,
OrderSide::Buy,
requested_qty,
reason,
missing_reason,
emit_creation_events,
);
self.clear_open_order(order_id);
return Ok(());
}
let current_position_quantity = portfolio
.position(symbol)
.map(|position| position.quantity)
@@ -6406,7 +6496,7 @@ where
value_gross_limit,
algo_request,
limit_price,
);
)?;
let (filled_qty, execution_legs, next_cursor, liquidity_consumption) = if let Some(fill) =
fill
{
@@ -6420,7 +6510,7 @@ where
)
} else {
let execution_price =
self.snapshot_execution_price(snapshot, OrderSide::Buy, Some(constrained_qty));
self.snapshot_execution_price(data, snapshot, OrderSide::Buy, Some(constrained_qty))?;
if let Some(reason) =
self.execution_limit_rejection_reason(snapshot, OrderSide::Buy, execution_price)
{
@@ -6462,10 +6552,11 @@ where
let mut blocked_by_final_price = false;
if filled_qty > 0 {
execution_price = self.snapshot_execution_price(
data,
snapshot,
OrderSide::Buy,
Some(filled_qty),
);
)?;
match self.execution_price_with_limit_slippage_or_rejection(
snapshot,
OrderSide::Buy,
@@ -7053,7 +7144,7 @@ where
fallback_price: f64,
minimum_order_quantity: u32,
order_step_size: u32,
) -> u32 {
) -> Result<u32, BacktestError> {
let snapshot = data.market(date, symbol);
let mut quantity = self.value_buy_quantity(
date,
@@ -7065,8 +7156,9 @@ where
for _ in 0..8 {
let execution_price = snapshot
.map(|snapshot| {
self.snapshot_execution_price(snapshot, OrderSide::Buy, Some(quantity))
self.snapshot_execution_price(data, snapshot, OrderSide::Buy, Some(quantity))
})
.transpose()?
.filter(|price| price.is_finite() && *price > 0.0)
.unwrap_or(fallback_price);
let resolved = self.value_buy_quantity(
@@ -7077,27 +7169,28 @@ where
order_step_size,
);
if resolved == quantity {
return quantity;
return Ok(quantity);
}
quantity = resolved;
}
while quantity >= minimum_order_quantity.max(1) {
let execution_price = snapshot
.map(|snapshot| {
self.snapshot_execution_price(snapshot, OrderSide::Buy, Some(quantity))
self.snapshot_execution_price(data, snapshot, OrderSide::Buy, Some(quantity))
})
.transpose()?
.filter(|price| price.is_finite() && *price > 0.0)
.unwrap_or(fallback_price);
if Self::fixed_cash_fits(
self.estimated_buy_cash_out(date, execution_price, quantity),
value_budget,
) {
return quantity;
return Ok(quantity);
}
quantity =
self.decrement_order_quantity(quantity, minimum_order_quantity, order_step_size);
}
0
Ok(0)
}
fn decrement_order_quantity(
@@ -7286,9 +7379,15 @@ where
execution_price: f64,
) -> Option<&'static str> {
if !execution_price.is_finite() || execution_price <= 0.0 {
return None;
return Some("invalid execution price");
}
match side {
OrderSide::Buy
if self.risk_config.static_rules.reject_one_yuan_buy
&& execution_price <= 1.0 =>
{
Some("one_yuan")
}
OrderSide::Buy
if self.risk_config.static_rules.reject_upper_limit_buy
&& snapshot.is_at_upper_limit_price(execution_price) =>
@@ -7358,14 +7457,14 @@ where
gross_limit: Option<f64>,
algo_request: Option<&AlgoExecutionRequest>,
limit_price: Option<f64>,
) -> Option<ExecutionFill> {
) -> Result<Option<ExecutionFill>, BacktestError> {
let matching_type = self.matching_type_for_algo_request(algo_request);
let post_close_window = self.post_close_execution_window(date);
let use_intraday_quotes = post_close_window.is_some()
|| algo_request.is_some()
|| self.matching_type_uses_intraday_quotes();
if !use_intraday_quotes {
return None;
return Ok(None);
}
let runtime_start_time = self.runtime_intraday_start_time.get();
@@ -7392,6 +7491,7 @@ where
end_cursor
};
let quotes = data.execution_quotes_on(date, symbol);
let calibration = self.slippage_calibration(data, snapshot)?;
if let Some(fill) = self.select_execution_fill_with_ledger(
symbol,
@@ -7410,8 +7510,9 @@ where
gross_limit,
limit_price,
execution_ledger,
) {
return Some(fill);
calibration.as_ref(),
)? {
return Ok(Some(fill));
}
if post_close_window.is_some()
@@ -7426,7 +7527,7 @@ where
.or(self.intraday_execution_start_time)
.map(|start_time| date.and_time(start_time) + Duration::seconds(1))
.unwrap_or_else(|| date.and_hms_opt(0, 0, 1).expect("valid midnight"));
return Some(ExecutionFill {
return Ok(Some(ExecutionFill {
quantity: 0,
next_cursor,
legs: Vec::new(),
@@ -7437,10 +7538,10 @@ where
end_cursor,
matching_type == MatchingType::MinuteLast && start_cursor.is_some(),
)),
});
}));
}
None
Ok(None)
}
fn empty_intraday_quote_reason(
@@ -7504,7 +7605,9 @@ where
gross_limit,
limit_price,
&IntradayExecutionLedger::default(),
None,
)
.expect("test quote selection without historical calibration")
}
#[allow(clippy::too_many_arguments)]
@@ -7526,9 +7629,10 @@ where
gross_limit: Option<f64>,
limit_price: Option<f64>,
execution_ledger: &IntradayExecutionLedger,
) -> Option<ExecutionFill> {
calibration: Option<&HistoricalSlippageCalibration>,
) -> Result<Option<ExecutionFill>, BacktestError> {
if requested_qty == 0 {
return None;
return Ok(None);
}
let quote_quantity_limited =
@@ -7591,6 +7695,11 @@ where
else {
continue;
};
if let Some(reason) = self.execution_limit_rejection_reason(snapshot, side, raw_quote_price) {
execution_block_reason.get_or_insert(reason);
execution_block_timestamp = Some(quote.timestamp);
continue;
}
let mark_price = self.quote_mark_price(quote, raw_quote_price);
let remaining_qty = requested_qty.saturating_sub(filled_qty);
if remaining_qty == 0 {
@@ -7671,7 +7780,7 @@ where
}
let mut quote_price =
self.quote_execution_price(snapshot, side, raw_quote_price, Some(take_qty));
self.quote_execution_price(snapshot, side, raw_quote_price, Some(take_qty), calibration)?;
if let Some(reason) = self.execution_limit_rejection_reason(snapshot, side, quote_price)
{
execution_block_reason.get_or_insert(reason);
@@ -7691,7 +7800,7 @@ where
if let Some(cash) = cash_limit {
while take_qty > 0 {
quote_price =
self.quote_execution_price(snapshot, side, raw_quote_price, Some(take_qty));
self.quote_execution_price(snapshot, side, raw_quote_price, Some(take_qty), calibration)?;
if !quote_price.is_finite() || quote_price <= 0.0 {
budget_block_reason = Some("invalid execution price");
take_qty = 0;
@@ -7743,7 +7852,7 @@ where
}
quote_price =
self.quote_execution_price(snapshot, side, raw_quote_price, Some(take_qty));
self.quote_execution_price(snapshot, side, raw_quote_price, Some(take_qty), calibration)?;
quote_price = self.execution_price_with_limit_slippage(quote_price, limit_price);
if let Some(reason) = self.execution_limit_rejection_reason(snapshot, side, quote_price)
{
@@ -7801,7 +7910,7 @@ where
if let Some(reason) = execution_block_reason
&& !saw_non_blocked_execution_price
{
return Some(ExecutionFill {
return Ok(Some(ExecutionFill {
quantity: 0,
next_cursor: execution_block_timestamp
.expect("blocked execution quote timestamp")
@@ -7809,12 +7918,12 @@ where
legs: Vec::new(),
liquidity_consumption: Vec::new(),
unfilled_reason: Some(reason),
});
}));
}
return None;
return Ok(None);
}
Some(ExecutionFill {
Ok(Some(ExecutionFill {
quantity: filled_qty,
next_cursor: last_timestamp.unwrap() + Duration::seconds(1),
legs: if matching_type == MatchingType::Vwap {
@@ -7838,7 +7947,7 @@ where
} else {
None
},
})
}))
}
fn quote_has_executable_liquidity(
@@ -8086,6 +8195,88 @@ mod tests {
}
}
#[test]
fn historical_slippage_does_not_read_later_daily_fields_for_open_or_minute_fills() {
let previous = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 3).unwrap();
let build_data = |changed: bool| {
let mut prior = dated_limit_test_snapshot(previous);
prior.timestamp = None;
let mut current = dated_limit_test_snapshot(date);
current.timestamp = None;
if changed {
current.high = 10.9;
current.low = 9.1;
current.close = 10.8;
current.volume = 400;
}
let mut quote = limit_test_quote(10.0, 10.0, 10.0);
quote.date = date;
quote.timestamp = date.and_hms_opt(13, 7, 0).unwrap();
DataSet::from_components_with_actions_and_quotes(
vec![limit_test_instrument()], vec![prior, current], Vec::new(),
vec![dated_limit_test_candidate(previous, false, false, true, true), dated_limit_test_candidate(date, false, false, true, true)],
vec![dated_limit_test_benchmark(previous), dated_limit_test_benchmark(date)],
Vec::new(), vec![quote],
).unwrap()
};
let decision = StrategyDecision {
order_intents: vec![OrderIntent::Value {
symbol: "000001.SZ".into(), value: 50_000.0, reason: "historical_model_invariance".into(),
}], ..StrategyDecision::default()
};
for matching in [MatchingType::NextBarOpen, MatchingType::MinuteLast] {
let mut results = Vec::new();
for changed in [false, true] {
let data = build_data(changed);
let mut broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
.with_matching_type(matching)
.with_volume_limit(false).with_liquidity_limit(false)
.with_slippage_model(SlippageModel::HistoricalVolumeVolatility(super::DynamicSlippageConfig::new(0.5, 0.3, 0.1)));
if matching == MatchingType::MinuteLast {
broker = broker.with_intraday_execution_start_time(NaiveTime::from_hms_opt(13, 7, 0).unwrap());
}
let mut account = PortfolioState::new(1_000_000.0);
let report = broker.execute_with_event_dates(date, previous, previous, &mut account, &data, &decision).unwrap();
assert_eq!(report.fill_events.len(), 1, "{report:?}");
results.push((serde_json::to_value(&report.fill_events).unwrap(), account.cash()));
}
assert_eq!(results[0], results[1], "{matching:?}");
}
}
#[test]
fn historical_slippage_rejects_missing_future_or_invalid_calibration_without_raw_price_fallback() {
let snapshot = limit_test_snapshot();
let date = snapshot.date;
assert!(super::HistoricalSlippageCalibration::from_completed_snapshot(&snapshot, date).is_err());
let later = date + chrono::Duration::days(1);
let mut bad = snapshot.clone();
bad.volume = 0;
assert!(super::HistoricalSlippageCalibration::from_completed_snapshot(&bad, later).is_err());
bad = snapshot.clone();
bad.high = f64::NAN;
assert!(super::HistoricalSlippageCalibration::from_completed_snapshot(&bad, later).is_err());
let calibration = super::HistoricalSlippageCalibration::from_completed_snapshot(&snapshot, later).unwrap();
assert!(super::DynamicSlippageConfig::new(f64::NAN, 0.3, 0.1).ratio(&calibration, Some(100.0)).is_err());
assert!(super::DynamicSlippageConfig::new(-1.0, 0.3, 0.1).ratio(&calibration, Some(100.0)).is_err());
assert!(super::DynamicSlippageConfig::new(0.5, 0.3, 1.0).ratio(&calibration, Some(100.0)).is_err());
let data = DataSet::from_components(vec![limit_test_instrument()], vec![snapshot], Vec::new(),
vec![limit_test_candidate(true, true)], vec![limit_test_benchmark()]).unwrap();
let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
.with_volume_limit(false).with_liquidity_limit(false)
.with_slippage_model(SlippageModel::HistoricalVolumeVolatility(super::DynamicSlippageConfig::default()));
let mut account = PortfolioState::new(1_000_000.0);
let decision = StrategyDecision { order_intents: vec![OrderIntent::Value {
symbol: "000001.SZ".into(), value: 50_000.0, reason: "missing_calibration".into(),
}], ..StrategyDecision::default() };
let error = broker.execute(date, &mut account, &data, &decision).unwrap_err();
assert!(error.to_string().contains("historical_slippage_calibration_missing"), "{error}");
assert_eq!(account.cash(), 1_000_000.0);
assert!(account.positions().is_empty());
}
fn limit_test_candidate(allow_buy: bool, allow_sell: bool) -> CandidateEligibility {
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date");
CandidateEligibility {
@@ -8399,6 +8590,11 @@ mod tests {
let mut snapshot = dated_limit_test_snapshot(date);
snapshot.close = 10.0;
snapshot.upper_limit = 20.0;
let data = DataSet::from_components(
vec![limit_test_instrument()], vec![snapshot.clone()], Vec::new(),
vec![dated_limit_test_candidate(date, false, false, true, true)],
vec![dated_limit_test_benchmark(date)],
).unwrap();
for (hour, minute) in [(14, 59), (15, 31)] {
broker
@@ -8409,7 +8605,7 @@ mod tests {
EquityExecutionPhase::ContinuousAuction
);
assert_eq!(
broker.snapshot_execution_price(&snapshot, OrderSide::Buy, Some(100)),
broker.snapshot_execution_price(&data, &snapshot, OrderSide::Buy, Some(100)).unwrap(),
12.5
);
}
@@ -8422,7 +8618,7 @@ mod tests {
EquityExecutionPhase::PostCloseFixedPrice
);
assert_eq!(
broker.snapshot_execution_price(&snapshot, OrderSide::Buy, Some(100)),
broker.snapshot_execution_price(&data, &snapshot, OrderSide::Buy, Some(100)).unwrap(),
10.0
);
}
@@ -8564,6 +8760,54 @@ mod tests {
assert_eq!(fill.quantity, 1_200);
}
#[test]
fn each_execution_leg_rechecks_one_yuan_including_slippage_and_limit_price() {
let mut snapshot = limit_test_snapshot();
snapshot.open = 1.2;
snapshot.last_price = 1.2;
snapshot.upper_limit = 2.0;
snapshot.lower_limit = 0.5;
let date = snapshot.date;
let start = date.and_hms_opt(10, 0, 0).unwrap();
let end = date.and_hms_opt(10, 2, 0).unwrap();
let mut cheap = limit_test_quote(0.9, 0.9, 0.9);
cheap.timestamp = date.and_hms_opt(10, 1, 0).unwrap();
let mut later = limit_test_quote(1.2, 1.2, 1.2);
later.timestamp = end;
let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
.with_volume_limit(false).with_liquidity_limit(false);
let fill = broker.select_execution_fill(
&snapshot, &[cheap.clone(), later], OrderSide::Buy, MatchingType::Vwap,
Some(start), Some(end), 100, 100, 100, 100, false, None, None, None,
).unwrap();
assert_eq!(fill.quantity, 100);
assert_eq!(fill.legs.len(), 1);
assert_eq!(fill.legs[0].execution_timestamp, Some(end));
assert_eq!(fill.legs[0].price, 1.2);
let slipped = broker.with_slippage_model(SlippageModel::PriceRatio(0.2));
let blocked = slipped.select_execution_fill(
&snapshot, &[cheap], OrderSide::Buy, MatchingType::Vwap,
Some(start), Some(end), 100, 100, 100, 100, false, None, None, None,
).unwrap();
assert_eq!(blocked.quantity, 0);
assert_eq!(blocked.unfilled_reason, Some("one_yuan"));
assert_eq!(slipped.execution_price_with_limit_slippage_or_rejection(&snapshot, OrderSide::Buy, 1.0, None), Err("one_yuan"));
let limit_broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
.with_slippage_model(SlippageModel::LimitPrice);
assert_eq!(limit_broker.execution_price_with_limit_slippage_or_rejection(
&snapshot, OrderSide::Buy, 1.2, Some(0.9)), Err("one_yuan"));
let mut risk = FidcRiskControlConfig::default();
risk.static_rules.reject_one_yuan_buy = false;
let allowed = limit_broker.with_risk_config(risk);
assert_eq!(allowed.execution_price_with_limit_slippage_or_rejection(
&snapshot, OrderSide::Buy, 1.2, Some(0.9)), Ok(0.9));
assert_eq!(allowed.execution_limit_rejection_reason(&snapshot, OrderSide::Buy, f64::NAN), Some("invalid execution price"));
assert_eq!(allowed.execution_limit_rejection_reason(&snapshot, OrderSide::Sell, 0.9), None);
}
#[test]
fn minute_last_uses_volume_delta_when_level1_depth_missing() {
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date");
+534 -44
View File
@@ -1,6 +1,6 @@
//! Completed-session OHLCV rules shared by research and strategy execution.
use crate::DataSet;
use chrono::NaiveDate;
use chrono::{FixedOffset, NaiveDate, TimeZone};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use std::collections::{BTreeMap, BTreeSet};
@@ -9,9 +9,15 @@ pub const CONTRACT: &str = "fidc_daily_ohlcv_pattern_v1";
pub fn catalog() -> Value {
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":"收盘价>短均线>长均线,按区间动量排序;不是当日金叉。"},
"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日最大量的指定倍数;不等同价格创新高。"},
"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":"此前观察窗有放量日,今日收盘超过该日最高价,成交量不超过其指定比例。"},
"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日最大量的指定倍数。"}
@@ -24,9 +30,59 @@ pub struct PatternSpec {
pub template: String,
#[serde(default)]
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 {
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 definition = catalog["templates"]
.get(&self.template)
@@ -44,7 +100,7 @@ impl PatternSpec {
if number < bounds[1].as_f64().unwrap() || number > bounds[2].as_f64().unwrap() {
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 {
return Err(format!("{key}必须是整数"));
}
@@ -66,11 +122,15 @@ impl PatternSpec {
}
pub fn history_len(&self) -> usize {
match self.template.as_str() {
"session_event"=>1,
"expression" => self.n("history_window"),
"strength" => self.n("slow_window").max(self.n("momentum_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),
"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!(),
}
}
@@ -85,6 +145,14 @@ pub struct PatternBar {
pub low: Option<f64>,
pub close: 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 paused: Option<bool>,
#[serde(default)]
@@ -146,6 +214,17 @@ pub fn evaluate(
days: &[NaiveDate],
series: &PatternSeries,
) -> 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]) {
return Err("pattern_calendar_incomplete: 需要完整、唯一且递增的真实交易日窗口".into());
}
@@ -154,7 +233,7 @@ pub fn evaluate(
.iter()
.map(|b| (b.date, b))
.collect::<BTreeMap<_, _>>();
if by_day.len() != series.bars.len() || series.bars.iter().any(|b| !days.contains(&b.date)) {
if by_day.len() != series.bars.len() || series.bars.iter().any(|b| days.binary_search(&b.date).is_err()) {
return Err(format!(
"pattern_input_invalid: symbol={}, reason=duplicate_or_out_of_scope",
series.symbol
@@ -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});
let mut score = None;
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: symbol={}, date={d}, field={name}, reason=invalid_value",
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" => {
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))?;
@@ -293,11 +490,12 @@ pub fn evaluate(
spec.v("max_upper_shadow"),
);
}
"volume_spike" | "volume_down" => {
let high = prices[len - 1 - spec.n("volume_window")..len - 1]
"volume_spike" | "volume_down" | "mean_volume_spike" => {
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()
.map(|b| b.4)
.fold(0.0, f64::max);
.fold(0.0, f64::max)};
if high <= 0.0 {
return Err(format!(
"pattern_input_invalid: symbol={}, reason=zero_reference_volume",
@@ -308,20 +506,20 @@ pub fn evaluate(
result.values["volume_ratio"] = json!(v / high);
check(
&mut result.checks,
"最大量倍数",
if spec.template=="mean_volume_spike" {"均量倍数"} else {"最大量倍数"},
v / high,
">=",
spec.v("volume_multiple"),
);
check(
&mut result.checks,
if spec.template == "volume_spike" {
if spec.template != "volume_down" {
"当日上涨"
} else {
"当日下跌"
},
change,
if spec.template == "volume_spike" {
if spec.template != "volume_down" {
">"
} else {
"<"
@@ -335,14 +533,15 @@ pub fn evaluate(
result.values["ma"] = json!(avg);
check(&mut result.checks, "收盘低于均线", c, "<", avg);
}
"shrink_breakout" => {
"shrink_breakout" | "mean_shrink_breakout" => {
let mut spikes = Vec::new();
let mut eligible = Vec::new();
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()
.map(|b| b.4)
.fold(0.0, f64::max);
.fold(0.0, f64::max)};
if prior <= 0.0 {
return Err(format!(
"pattern_input_invalid: symbol={}, date={}, reason=zero_reference_volume",
@@ -382,6 +581,44 @@ pub fn evaluate(
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!(),
}
@@ -399,41 +636,51 @@ pub fn evaluate_dataset(
date: NaiveDate,
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> {
let days = data.calendar().trailing_days(date, spec.history_len());
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),
adjustment_factor_backward1: data
.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,
},
)
let mut fields = context.common.clone();
fields.extend(context.by_symbol.get(symbol).cloned().unwrap_or_default());
let outside = spec.execution_context.as_ref().is_some_and(|c| c.rank_expression.is_some() && !c.rank_universe.iter().any(|s|s==symbol));
if outside {
for name in ["scope_rank","scope_percentile"] {fields.insert(name.into(),vec![None;days.len()]);}
fields.insert("scope_size".into(),vec![Some(spec.execution_context.as_ref().unwrap().rank_universe.len() as f64);days.len()]);
}
let mut result = evaluate_with_context(spec,&days,&dataset_series(data,&days,symbol),&fields,false)?;
if outside && result.score.is_none() { result.exclusion=Some(json!({"reason":"outside_frozen_rank_universe","symbol":symbol,"signal_date":date})); }
result.values["execution_context_latest"]=json!(fields.iter().map(|(k,v)|(k,v.last().copied().flatten())).collect::<BTreeMap<_,_>>());
Ok(result)
}
pub fn evaluate_batch(
spec: PatternSpec,
days: &[NaiveDate],
series: &[PatternSeries],
) -> Result<Value, String> {
evaluate_batch_with_policy(spec, days, series, false)
}
/// Partial results are research diagnostics, never strategy execution inputs.
pub fn evaluate_batch_with_policy(
spec: PatternSpec, days: &[NaiveDate], series: &[PatternSeries], isolate_data_errors: bool,
) -> Result<Value, String> {
let spec = spec.validate()?;
if series.is_empty()
@@ -449,13 +696,91 @@ pub fn evaluate_batch(
}
let rows = series
.iter()
.map(|s| evaluate(&spec, days, s))
.map(|s| research_row(evaluate(&spec, days, s), s, isolate_data_errors))
.collect::<Result<Vec<_>, _>>()?;
Ok(
json!({"contract":CONTRACT,"spec":spec,"required_history":spec.history_len(),"rows":rows,"read_only":true}),
)
}
fn research_row(result: Result<PatternResult, String>, series: &PatternSeries, isolate: bool) -> Result<Value, String> {
match result {
Ok(row) => Ok(json!(row)),
Err(detail) if isolate && detail.starts_with(&format!("pattern_input_invalid: symbol={},", series.symbol)) => {
let fields = detail.split(", ").filter_map(|p| p.split_once('=')).collect::<BTreeMap<_,_>>();
Ok(json!({"symbol":series.symbol,"name":series.name,"matched":null,"score":null,
"checks":[],"values":{},"anchor":null,"exclusion":null,
"data_issue":{"reason":fields.get("reason"),"date":fields.get("date"),"field":fields.get("field"),"detail":detail}}))
},
Err(error) => Err(error),
}
}
/// 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> {
evaluate_research_batch_with_policy(spec, days, series, context, numeric_output, false)
}
pub fn evaluate_research_batch_with_policy(
spec: PatternSpec, days: &[NaiveDate], series: &[PatternSeries],
context: &ResearchContext, numeric_output: bool, isolate_data_errors: bool,
) -> Result<Value, String> {
let common_fields = ["index_open", "index_high", "index_low", "index_close"].into_iter()
.chain(crate::market_event_context::COMMON_FIELDS.iter().copied()).collect::<Vec<_>>();
let symbol_fields = ["scope_rank", "scope_percentile", "scope_size"].into_iter()
.chain(crate::market_event_context::INDUSTRY_FIELDS.iter().copied()).collect::<Vec<_>>();
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| if name.starts_with("index_") {
!v.is_some_and(|x| x.is_finite() && x > 0.0)
} else { v.is_some_and(|x| !x.is_finite()) }) {
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 = research_row(evaluate_with_context(&spec, days, item, &fields, numeric_output), item, isolate_data_errors)?;
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> {
let mut specs = Vec::new();
for helper in ["pattern_signal", "pattern_score"] {
@@ -491,10 +816,126 @@ pub fn expression_specs(expression: &str) -> Result<Vec<PatternSpec>, String> {
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn research_isolates_missing_listing_day_without_weakening_execution() {
let days = ["2026-06-11", "2026-06-12"].map(|d|d.parse::<NaiveDate>().unwrap());
let spec: PatternSpec = serde_json::from_value(json!({"template":"ma_below","parameters":{"ma_window":2}})).unwrap();
let make = |symbol: &str| -> PatternSeries { serde_json::from_value(json!({"symbol":symbol,"listed_at":"2026-06-11","bars":days.map(|d|json!({"date":d,"open":10.,"high":11.,"low":9.,"close":10.,"volume":100.,"adjustment_factor_backward1":1.,"paused":false,"source_path":"/controlled/source.parquet"}))})).unwrap() };
let complete=make("300395.SZ");let mut missing=make("920083.BJ");missing.bars.remove(0);
let members=[complete.clone(),missing];
assert!(evaluate_batch(spec.clone(),&days,&members).unwrap_err().contains("missing_market_row"));
let partial=evaluate_batch_with_policy(spec.clone(),&days,&members,true).unwrap();
assert_eq!(partial["rows"][0],json!(evaluate(&spec.validate().unwrap(),&days,&complete).unwrap()));
assert!(partial["rows"][1]["matched"].is_null());
assert_eq!(partial["rows"][1]["data_issue"]["date"],"2026-06-11");
assert_eq!(partial["rows"][1]["data_issue"]["reason"],"missing_market_row");
let invalid:PatternSpec=serde_json::from_value(json!({"template":"not-a-template"})).unwrap();
assert!(evaluate_batch_with_policy(invalid,&days,&members,true).is_err());
}
#[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) {
let spec = PatternSpec {
template: template.into(),
parameters: BTreeMap::new(),
expression: None,
execution_context: None,
session_event: None,
}
.validate()
.unwrap();
@@ -515,6 +956,10 @@ mod tests {
low: Some(c),
close: Some(c),
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),
paused: Some(false),
source_path: Some("fixture.parquet".into()),
@@ -565,6 +1010,51 @@ mod tests {
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]
fn daily_patterns_flat_decimal_prices_do_not_create_a_sell_signal() {
let (mut spec, _, mut series) = fixture("strength");
+23 -2
View File
@@ -491,6 +491,7 @@ pub struct DataSetSnapshotComponents {
pub benchmarks: Vec<BenchmarkSnapshot>,
pub corporate_actions: Vec<CorporateAction>,
pub execution_quotes: Vec<IntradayExecutionQuote>,
pub completed_minute_bars: Vec<crate::session_events::MinuteBar>,
}
#[derive(Debug, Clone, Serialize)]
@@ -1418,6 +1419,7 @@ pub struct DataSet {
eligible_universe_by_date: Arc<OnceLock<BTreeMap<NaiveDate, Vec<EligibleUniverseSnapshot>>>>,
benchmark_code: String,
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> {
@@ -1954,6 +1956,7 @@ impl DataSet {
eligible_universe_by_date: Arc::new(OnceLock::new()),
benchmark_code,
futures_params_by_symbol: Arc::new(futures_params_by_symbol),
completed_minute_bars: Arc::new(BTreeMap::new()),
})
}
@@ -2627,9 +2630,21 @@ impl DataSet {
benchmarks,
corporate_actions,
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> {
self.benchmark_by_date.values().cloned().collect()
}
@@ -3360,6 +3375,12 @@ impl DataSet {
.unwrap_or(&[])
}
pub fn is_reference_only_benchmark(&self, symbol: &str) -> bool {
if symbol != self.benchmark_code() { return false; }
let Some(symbol_id) = self.symbol_id(symbol) else { return true; };
!self.candidate_symbol_ids_by_date.values().any(|ids| ids.contains(&symbol_id))
}
pub fn bundle_on(&self, date: NaiveDate) -> Result<DailySnapshotBundle, DataSetError> {
let benchmark = self
.benchmark(date)
@@ -6214,7 +6235,7 @@ mod tests {
}
#[test]
fn baseline_selection_uses_structured_instrument_dates_and_status_only() {
fn baseline_selection_uses_dated_lifecycle_not_latest_undated_status() {
let date = NaiveDate::parse_from_str("2025-01-02", "%Y-%m-%d").unwrap();
let instrument = |name: &str, status: &str, delisted_at: Option<NaiveDate>| Instrument {
symbol: "000001.SZ".to_string(),
@@ -6242,7 +6263,7 @@ mod tests {
Some(&instrument("退市测试", "active", None)),
date
));
assert!(!instrument_passes_baseline_selection(
assert!(instrument_passes_baseline_selection(
Some(&instrument("正常名称", "delisted", None)),
date
));
+131 -21
View File
@@ -468,9 +468,17 @@ pub struct BacktestEngine<S, C, R> {
preplanned_decision_quote_symbols_by_date: Option<Arc<BTreeMap<NaiveDate, BTreeSet<String>>>>,
execution_quote_request_cache:
BTreeSet<(NaiveDate, String, Option<NaiveTime>, Option<NaiveTime>)>,
execution_absence_notes: BTreeMap<NaiveDate, Vec<String>>,
execution_lifecycle_reported: BTreeSet<(String, String)>,
risk_free_rate_contract: Option<RiskFreeRateContract>,
}
fn all_instruments_have_dated_absence(data: &DataSet, date: NaiveDate) -> bool {
let mut instruments = data.instruments().values()
.filter(|instrument| !data.is_reference_only_benchmark(&instrument.symbol)).peekable();
instruments.peek().is_some() && instruments.all(|instrument| instrument.dated_market_absence_reason(date).is_some())
}
fn backtest_execution_schedule(
data: &DataSet,
start_date: Option<NaiveDate>,
@@ -493,10 +501,15 @@ fn backtest_execution_schedule(
if decision_lag_trading_days == 0 {
if has_decision_inputs(execution_date) {
schedule.push((execution_date, Some((calendar_idx, execution_date))));
} else if all_instruments_have_dated_absence(data, execution_date) {
schedule.push((execution_date, None));
}
continue;
}
if !has_execution_market(execution_date) {
if all_instruments_have_dated_absence(data, execution_date) {
schedule.push((execution_date, None));
}
continue;
}
let decision_slot = calendar_idx
@@ -507,6 +520,7 @@ fn backtest_execution_schedule(
schedule.push((execution_date, decision_slot));
}
None => schedule.push((execution_date, None)),
Some((_, decision_date)) if all_instruments_have_dated_absence(data, decision_date) => schedule.push((execution_date, None)),
_ => {}
}
}
@@ -554,6 +568,8 @@ impl<S, C, R> BacktestEngine<S, C, R> {
execution_quote_loader: None,
preplanned_decision_quote_symbols_by_date: None,
execution_quote_request_cache: BTreeSet::new(),
execution_absence_notes: BTreeMap::new(),
execution_lifecycle_reported: BTreeSet::new(),
risk_free_rate_contract: None,
}
}
@@ -768,6 +784,31 @@ where
end_time: Option<NaiveTime>,
symbols: &mut BTreeSet<String>,
) -> Result<(), BacktestError> {
let mut available = BTreeSet::new();
for symbol in symbols.iter() {
let instrument = self.data.instrument(symbol).ok_or_else(|| BacktestError::Execution(format!(
"execution_data_missing reason=instrument_metadata_or_code_mapping_missing symbol={symbol} execution_date={execution_date}"
)))?;
if let Some(reason) = instrument.dated_market_absence_reason(execution_date) {
if self.data.price(execution_date, symbol, PriceField::Close).is_some()
|| !self.data.execution_quotes_on(execution_date, symbol).is_empty()
{
return Err(BacktestError::Execution(format!(
"execution_data_conflict reason={reason} symbol={symbol} execution_date={execution_date} listed_at={:?} delisted_at={:?}",
instrument.listed_at, instrument.delisted_at
)));
}
if self.execution_lifecycle_reported.insert((symbol.clone(), reason.to_string())) {
self.execution_absence_notes.entry(execution_date).or_default().push(format!(
"execution_data_absence reason={reason} symbol={symbol} execution_date={execution_date} listed_at={:?} delisted_at={:?} no_price_fill=true",
instrument.listed_at, instrument.delisted_at
));
}
continue;
}
available.insert(symbol.clone());
}
*symbols = available;
symbols.retain(|symbol| {
let request_key = (execution_date, symbol.clone(), start_time, end_time);
if self.execution_quote_request_cache.contains(&request_key) {
@@ -835,9 +876,6 @@ where
let mut paused_with_quotes = Vec::new();
let mut missing_daily_market = Vec::new();
for symbol in requested_symbols {
let Some(_candidate) = self.data.candidate(execution_date, symbol) else {
continue;
};
let Some(market) = self.data.market(execution_date, symbol) else {
missing_daily_market.push(symbol.clone());
continue;
@@ -2191,12 +2229,13 @@ where
date: execution_date,
})?;
let notes = join_text_parts(corporate_action_notes.into_iter());
let absence = all_instruments_have_dated_absence(&self.data, execution_date);
let diagnostics = join_text_parts(
std::iter::once(format!(
"decision_lag_warmup lag_days={} execution_index={}",
self.config.decision_lag_trading_days, execution_idx
))
.chain(broker_diagnostics.into_iter()),
std::iter::once(if absence {
format!("execution_data_absence reason=all_instruments_outside_dated_lifecycle execution_date={execution_date} cash_period_retained=true no_price_fill=true")
} else { format!("decision_lag_warmup lag_days={} execution_index={}", self.config.decision_lag_trading_days, execution_idx) })
.chain(broker_diagnostics.into_iter())
.chain(self.execution_absence_notes.remove(&execution_date).unwrap_or_default()),
);
let holdings_for_day = portfolio.holdings_summary(execution_date);
let holding_start = result.daily_holdings.len();
@@ -2213,7 +2252,7 @@ where
previous_external_cash_flow_total = portfolio.external_cash_flow_total();
result.equity_curve.push(DailyEquityPoint {
signal_baseline: true,
signal_baseline: execution_idx == 0,
date: execution_date,
cash: aggregate_cash,
market_value: aggregate_market_value,
@@ -3364,7 +3403,8 @@ where
decision
.diagnostics
.into_iter()
.chain(broker_diagnostics.into_iter()),
.chain(broker_diagnostics.into_iter())
.chain(self.execution_absence_notes.remove(&execution_date).unwrap_or_default()),
);
let holdings_for_day = portfolio.holdings_summary(execution_date);
let holding_start = result.daily_holdings.len();
@@ -3964,17 +4004,11 @@ where
let Some(instrument) = self.data.instrument(&symbol) else {
continue;
};
let is_unresolved = instrument.is_delisted_on_or_before(date)
|| (instrument.status.eq_ignore_ascii_case("delisted")
&& instrument.delisted_at.is_none()
&& self.data.market(date, &symbol).is_none());
let is_unresolved = instrument.is_delisted_on_or_before(date);
if !is_unresolved {
continue;
}
let effective_delisted_at = instrument
.delisted_at
.or_else(|| self.data.calendar().previous_day(date))
.unwrap_or(date);
let effective_delisted_at = instrument.delisted_at.expect("dated delisting checked");
let reason = format!(
concat!(
"unresolved_delisted_position symbol={} quantity={} effective_date={} status={} ",
@@ -5543,6 +5577,34 @@ mod tests {
);
}
#[test]
fn wholly_prelisting_universe_retains_cash_days_without_fabricating_prices() {
let dates = [d(2025, 1, 2), d(2025, 1, 3), d(2025, 1, 6)];
let mut engine = engine_with_matching(MatchingType::CurrentBarClose, PriceField::Close, 0);
engine.config.end_date = Some(dates[2]);
let mut markets = vec![market(dates[2], 10.0, 10.0)];
markets.extend(dates.iter().map(|date| DailyMarketSnapshot { symbol: "000852.SH".into(), ..market(*date, 1000.0, 1000.0) }));
engine.data = DataSet::from_components(
vec![Instrument { listed_at: Some(dates[2]), ..default_instrument() }, Instrument { symbol: "000852.SH".into(), listed_at: None, ..default_instrument() }],
markets, vec![factor(dates[2])], vec![candidate(dates[2])],
dates.iter().map(|date| benchmark(*date)).collect(),
).unwrap();
assert_eq!(super::backtest_execution_dates(&engine.data, Some(dates[0]), Some(dates[2]), 0), dates);
assert_eq!(super::backtest_execution_dates(&engine.data, Some(dates[0]), Some(dates[2]), 1), dates);
let result = engine.run().unwrap();
assert_eq!(result.equity_curve.len(), 3);
for point in &result.equity_curve[..2] {
assert_eq!(point.total_equity, 100_000.0);
assert_eq!(point.market_value, 0.0);
assert!(point.diagnostics.contains("cash_period_retained=true"));
}
assert!(result.order_events.is_empty());
assert!(engine.data.market(dates[0], SYMBOL).is_none());
assert!(result.equity_curve[0].signal_baseline);
assert!(!result.equity_curve[1].signal_baseline);
assert!(!super::all_instruments_have_dated_absence(&dataset(), dates[0]));
}
fn engine_with_matching(
matching_type: MatchingType,
execution_price_field: PriceField,
@@ -5996,6 +6058,38 @@ mod tests {
.expect("zero-volume stock may have no minute bars");
}
#[test]
fn lifecycle_quote_filter_skips_only_dated_legal_absence_before_loading() {
let date = d(2025, 9, 10);
for (symbol, listed_at, delisted_at, reason) in [
("920038.BJ", Some(d(2026, 8, 5)), None, "not_yet_listed"),
("563360.SH", Some(d(2026, 8, 5)), None, "not_yet_listed"),
("000001.SZ", Some(d(2010, 1, 1)), Some(d(2025, 9, 9)), "delisted"),
] {
let instrument = Instrument { symbol: symbol.into(), listed_at, delisted_at, ..default_instrument() };
let data = DataSet::from_components(vec![instrument], vec![], vec![], vec![], vec![benchmark(date)]).unwrap();
let mut engine = full_day_coverage_engine(data, date);
engine.execution_quote_loader = Some(Box::new(|_| panic!("legal lifecycle absence must not load prices")));
engine.load_missing_execution_quotes(date, None, None, &mut BTreeSet::from([symbol.to_string()])).unwrap();
let notes = engine.execution_absence_notes.get(&date).unwrap();
assert!(notes[0].contains(reason));
assert!(notes[0].contains(symbol));
engine.load_missing_execution_quotes(date, None, None, &mut BTreeSet::from([symbol.to_string()])).unwrap();
assert_eq!(engine.execution_absence_notes[&date].len(), 1);
}
}
#[test]
fn unknown_identity_or_missing_candidate_does_not_waive_quote_coverage() {
let date = d(2025, 9, 10);
let data = DataSet::from_components(vec![default_instrument()], vec![], vec![], vec![], vec![benchmark(date)]).unwrap();
let mut engine = full_day_coverage_engine(data, date);
let error = engine.load_missing_execution_quotes(date, None, None, &mut BTreeSet::from(["unmapped".to_string()])).unwrap_err();
assert!(error.to_string().contains("instrument_metadata_or_code_mapping_missing"));
let error = engine.validate_full_day_execution_quote_coverage(date, &[SYMBOL.to_string()]).unwrap_err();
assert!(error.to_string().contains("missing_daily_market"));
}
fn run_scheduled_next_open_with_dataset(dataset: DataSet) -> super::BacktestResult {
run_scheduled_next_open_with_dataset_and_broker(
dataset,
@@ -6605,17 +6699,33 @@ mod tests {
}
#[test]
fn next_bar_open_execution_risk_rejects_execution_day_one_yuan_state() {
fn next_bar_open_execution_risk_rejects_one_yuan_open_despite_higher_close() {
let first = d(2025, 1, 2);
let second = d(2025, 1, 3);
let result = run_scheduled_next_open_with_dataset(dataset_with(
market(first, 10.0, 11.5),
market(second, 12.0, 99.0),
market(second, 0.9, 1.2),
candidate(first),
candidate(second),
));
assert_next_open_canceled_with_reason(&result, "one_yuan");
}
#[test]
fn next_bar_open_execution_risk_ignores_later_one_yuan_close() {
let first = d(2025, 1, 2);
let second = d(2025, 1, 3);
let result = run_scheduled_next_open_with_dataset(dataset_with(
market(first, 10.0, 11.5),
market(second, 1.2, 0.9),
candidate(first),
one_yuan_candidate(second),
));
assert_next_open_canceled_with_reason(&result, "one_yuan");
assert_eq!(result.fills.len(), 1);
assert_eq!(result.fills[0].date, second);
assert_eq!(result.fills[0].price, 1.2);
}
#[test]
@@ -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());
}
}
@@ -0,0 +1,52 @@
//! Indicator metadata is versioned independently from the numerical kernel.
use crate::factor_events::{CONTRACT, OPERATORS, TA_REV};
use serde_json::{Value, json};
use ta_lib::abstract_api::{self, OptInputType};
pub fn catalog() -> Value {
use sha2::{Digest, Sha256};
let mut implementation = Sha256::new();
for file in [include_bytes!("factor_events.rs").as_slice(), include_bytes!("factor_cross_section.rs").as_slice(),
include_bytes!("daily_patterns.rs").as_slice(),include_bytes!("market_event_context.rs").as_slice(),
include_bytes!("session_events.rs").as_slice(),include_bytes!("pattern_context.rs").as_slice(),TA_REV.as_bytes()] {implementation.update(file);}
let implementation_sha256=format!("{:x}",implementation.finalize());
let indicators: Vec<Value> = abstract_api::funcs().map(|f| json!({
"name":f.name, "group":format!("{:?}",f.group), "description":f.hint,
"inputs":f.inputs.iter().map(|p|json!({"name":p.param_name,"kind":format!("{:?}",p.kind),"flags":p.flags.0})).collect::<Vec<_>>(),
"parameters":f.opt_inputs.iter().map(|p|json!({"name":p.param_name,"label":p.display_name,"description":p.hint,"domain":parameter_domain(p.kind)})).collect::<Vec<_>>(),
"outputs":f.outputs.iter().enumerate().map(|(i,p)|json!({"index":i,"name":p.param_name,"kind":format!("{:?}",p.kind)})).collect::<Vec<_>>(),
"unstable_period":format!("{:?}",f.unst_id), "production_eligible":false,
})).collect();
json!({"contract":CONTRACT,"parameter_domain_contract":"fidc.indicator-parameter-domain/v1","expression_kernel_sha256":implementation_sha256,"library":{"name":"TA-Lib native Rust","revision":TA_REV,"license":"BSD-3-Clause"},
"execution_context_contract":crate::pattern_context::CONTRACT,
"execution_context_fields":crate::pattern_context::CONTEXT_FIELDS,
"market_event_context_contract":crate::market_event_context::CONTRACT,
"market_event_kernel_sha256":crate::market_event_context::implementation_sha256(),
"market_event_common_fields":crate::market_event_context::COMMON_FIELDS,
"market_event_industry_fields":crate::market_event_context::INDUSTRY_FIELDS,
"session_events":crate::session_events::EVENTS,"session_event_contract":crate::session_events::CONTRACT,
"indicators":indicators,"operators":OPERATORS,"cross_section_operators":crate::factor_cross_section::OPERATORS,"read_only":true,"live_routing":false,
"policies":{"null":"unknown_not_false","warmup":"null_until_full_history","recursive_seed":"frozen_input_start",
"breakout":"previous_window_excludes_current","boolean":"three_valued_logic","daily_execution":"next_completed_session",
"minute_execution":"strictly_after_completed_bar","cross_section":"requires_separate_complete_universe_contract"}})
}
pub(crate) fn parameter_domain(kind: OptInputType) -> Value {
match kind {
OptInputType::IntegerRange { min, max, default, .. } => json!({
"value_type":"integer", "minimum":min, "maximum":max, "default":default,
}),
OptInputType::RealRange { min, max, default, precision, .. } => json!({
"value_type":"number", "minimum":min, "maximum":max,
"default":default, "display_precision":precision,
}),
OptInputType::IntegerList { values, default } => json!({
"value_type":"integer", "default":default,
"choices":values.iter().map(|(value,label)|json!({"value":value,"label":label})).collect::<Vec<_>>(),
}),
OptInputType::RealList { values, default } => json!({
"value_type":"number", "default":default,
"choices":values.iter().map(|(value,label)|json!({"value":value,"label":label})).collect::<Vec<_>>(),
}),
}
}
+936
View File
@@ -0,0 +1,936 @@
//! Causal, typed indicator/event expressions shared by research and trading.
use chrono::{DateTime, FixedOffset};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::BTreeMap;
use ta_lib::{
Core,
abstract_api::{self, InputType, OptInputType, OutputType},
};
pub const CONTRACT: &str = "fidc_factor_event_expression_v1";
pub const TA_REV: &str = "dd5a90259a3f9e04e2da9f38bf0719a841b40108";
pub fn field_dependencies(expr: &Expr) -> std::collections::BTreeSet<String> {
let mut fields = std::collections::BTreeSet::new();
match expr {
Expr::Field { name } => {
fields.insert(name.clone());
}
Expr::Indicator { inputs, .. } => {
for e in inputs {
fields.extend(field_dependencies(e));
}
}
Expr::Operator { args, .. } => {
for e in args {
fields.extend(field_dependencies(e));
}
}
_ => {}
}
fields
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum Expr {
Number {
value: f64,
},
Field {
name: String,
},
Indicator {
name: String,
#[serde(default)]
inputs: Vec<Expr>,
#[serde(default)]
parameters: BTreeMap<String, Value>,
#[serde(default)]
output: usize,
},
Operator {
name: String,
args: Vec<Expr>,
#[serde(default, skip_serializing_if = "Option::is_none")]
window: Option<usize>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Frame {
pub symbol: String,
pub frequency: String,
pub decision_at: DateTime<FixedOffset>,
pub timestamps: Vec<DateTime<FixedOffset>>,
pub available_at: Vec<DateTime<FixedOffset>>,
pub fields: BTreeMap<String, Vec<Option<f64>>>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ValueType {
Number,
Boolean,
}
#[derive(Debug, Clone, Serialize)]
pub struct Series {
pub value_type: ValueType,
pub values: Vec<Option<f64>>,
}
pub(crate) const OPERATORS: &[&str] = &[
"GT",
"GTE",
"LT",
"LTE",
"EQ",
"NEQ",
"BETWEEN",
"OUTSIDE",
"CROSS_ABOVE",
"CROSS_BELOW",
"BREAK_ABOVE",
"BREAK_BELOW",
"BREAK_HIGH",
"BREAK_LOW",
"CHANGE",
"DIFF",
"DELTA",
"PCT_CHANGE",
"LOG_RETURN",
"RISING",
"FALLING",
"NON_DECREASING",
"NON_INCREASING",
"TURN_UP",
"TURN_DOWN",
"BOTTOM_REVERSAL",
"TOP_REVERSAL",
"SLOPE",
"SLOPE_CHANGE",
"ACCELERATION",
"HHV",
"LLV",
"ARGMAX",
"ARGMIN",
"DISTANCE_TO_HIGH",
"DISTANCE_TO_LOW",
"NEW_HIGH",
"NEW_LOW",
"NEAR_HIGH",
"NEAR_LOW",
"BULLISH_DIVERGENCE",
"BEARISH_DIVERGENCE",
"ZSCORE",
"MINMAX",
"STANDARDIZE",
"NORMALIZE",
"COUNT",
"COUNT_TRUE",
"CONSECUTIVE",
"BARS_SINCE",
"DURATION",
"DAYS_SINCE",
"TIME_SINCE",
"REF",
"LAG",
"PREV",
"SHIFT",
"ROLLING_MEAN",
"ROLLING_SUM",
"ROLLING_STD",
"ROLLING_MAX",
"ROLLING_MIN",
"ROLLING_MEDIAN",
"ROLLING_CORR",
"ROLLING_COV",
"AND",
"OR",
"NOT",
"XOR",
"ADD",
"SUB",
"MUL",
"DIV",
"ABS",
"MAX",
"MIN",
"LOG",
"SQRT",
"POWER",
"CUMMAX",
"CUMMIN",
"SIGN",
"IF",
];
pub use crate::factor_event_catalog::catalog;
impl Frame {
pub fn validate(&self) -> Result<(), String> {
let n = self.timestamps.len();
if self.symbol.is_empty()
|| n == 0
|| n > 200_000
|| self.available_at.len() != n
|| self.fields.len() > 100
|| n.saturating_mul(self.fields.len()) > 1_000_000
{
return Err("factor_frame_invalid: identity/shape/limit".into());
}
if !["1d", "1w", "1m", "5m", "15m", "30m", "60m"].contains(&self.frequency.as_str()) {
return Err("factor_frame_invalid: unsupported_frequency".into());
}
for i in 0..n {
if (i > 0 && self.timestamps[i] <= self.timestamps[i - 1])
|| self.available_at[i] < self.timestamps[i]
|| self.available_at[i] > self.decision_at
{
return Err(format!(
"factor_input_not_visible: {} index={i}",
self.symbol
));
}
}
for (field, values) in &self.fields {
if values.len() != n || values.iter().flatten().any(|v| !v.is_finite()) {
return Err(format!("factor_field_invalid: {} {field}", self.symbol));
}
}
Ok(())
}
}
pub fn evaluate(expr: &Expr, frame: &Frame) -> Result<Series, String> {
frame.validate()?;
fn cost(expr: &Expr, depth: usize, nodes: &mut usize) -> Result<usize, String> {
*nodes += 1;
if depth > 24 || *nodes > 256 {
return Err("factor_expression_size_exceeded".into());
}
let (children, own) = match expr {
Expr::Indicator {
inputs, parameters, ..
} => (
inputs.as_slice(),
parameters
.values()
.filter_map(Value::as_u64)
.max()
.unwrap_or(30)
.min(1_000_000) as usize,
),
Expr::Operator { args, window, .. } => (args.as_slice(), window.unwrap_or(1)),
_ => (&[][..], 1),
};
children.iter().try_fold(own, |total, child| {
Ok(total.saturating_add(cost(child, depth + 1, nodes)?))
})
}
if frame
.timestamps
.len()
.saturating_mul(cost(expr, 0, &mut 0)?)
> 20_000_000
{
return Err("factor_expression_compute_budget_exceeded".into());
}
evaluate_inner(expr, frame, 0)
}
fn evaluate_inner(expr: &Expr, frame: &Frame, depth: usize) -> Result<Series, String> {
if depth > 24 {
return Err("factor_expression_too_deep".into());
}
match expr {
Expr::Number { value } if value.is_finite() => Ok(Series {
value_type: ValueType::Number,
values: vec![Some(*value); frame.timestamps.len()],
}),
Expr::Number { .. } => Err("factor_constant_nonfinite".into()),
Expr::Field { name } => Ok(Series {
value_type: ValueType::Number,
values: frame
.fields
.get(name)
.ok_or_else(|| format!("factor_source_field_missing: {} {name}", frame.symbol))?
.clone(),
}),
Expr::Indicator {
name,
inputs,
parameters,
output,
} => indicator(name, inputs, parameters, *output, frame, depth),
Expr::Operator { name, args, window } => {
if args.len() > 16 {
return Err("factor_operator_arity_exceeded".into());
}
let args = args
.iter()
.map(|a| evaluate_inner(a, frame, depth + 1))
.collect::<Result<Vec<_>, _>>()?;
operator(name, &args, *window, frame)
}
}
}
fn indicator(
name: &str,
inputs: &[Expr],
parameters: &BTreeMap<String, Value>,
output: usize,
frame: &Frame,
depth: usize,
) -> Result<Series, String> {
let id =
abstract_api::get_func_handle(name).ok_or_else(|| format!("indicator_unknown: {name}"))?;
let info = id.info();
if output >= info.outputs.len() {
return Err("indicator_output_invalid".into());
}
let real_count = info
.inputs
.iter()
.filter(|i| i.kind == InputType::Real)
.count();
if inputs.len() != real_count || info.inputs.iter().any(|i| i.kind == InputType::Integer) {
return Err(format!(
"indicator_inputs_invalid: {name} expects {real_count} real series"
));
}
let mut data = inputs
.iter()
.map(|a| evaluate_inner(a, frame, depth + 1))
.collect::<Result<Vec<_>, _>>()?;
if data.iter().any(|s| s.value_type != ValueType::Number) {
return Err("indicator_requires_numeric_input".into());
}
let price_names = ["open", "high", "low", "close", "volume", "open_interest"];
let flags = info
.inputs
.iter()
.filter(|i| i.kind == InputType::Price)
.fold(0, |v, i| v | i.flags.0);
let mut price_indices = [None; 6];
for (i, field) in price_names.iter().enumerate() {
if flags & (1 << i) != 0 {
price_indices[i] = Some(data.len());
data.push(evaluate_inner(
&Expr::Field {
name: (*field).into(),
},
frame,
depth + 1,
)?);
}
}
let core = Core::new();
let mut validation = id.new_call(&core);
for (key, v) in parameters {
let slot = info
.opt_inputs
.iter()
.position(|p| p.param_name == key)
.ok_or_else(|| format!("indicator_parameter_unknown: {name}.{key}"))?;
match info.opt_inputs[slot].kind {
OptInputType::IntegerRange { .. } | OptInputType::IntegerList { .. } => {
let v = v
.as_i64()
.and_then(|v| i32::try_from(v).ok())
.ok_or("indicator_parameter_requires_integer")?;
validation.set_opt(slot, v).map_err(|e| format!("{e:?}"))?;
}
_ => {
validation
.set_opt(
slot,
v.as_f64()
.filter(|v| v.is_finite())
.ok_or("indicator_parameter_requires_finite_number")?,
)
.map_err(|e| format!("{e:?}"))?;
}
}
}
let lookback = validation
.lookback()
.map_err(|e| format!("indicator_parameter_invalid: {name} {e:?}"))?;
let n = frame.timestamps.len();
let mut result = vec![None; n];
let mut start = 0;
// Never bridge missing source observations. Recursive indicators rewarm after a gap.
while start < n {
if data.iter().any(|s| s.values[start].is_none()) {
start += 1;
continue;
}
let mut end = start + 1;
while end < n && data.iter().all(|s| s.values[end].is_some()) {
end += 1;
}
if end - start <= lookback {
start = end;
continue;
}
let arrays = data
.iter()
.map(|s| {
s.values[start..end]
.iter()
.map(|v| v.unwrap())
.collect::<Vec<_>>()
})
.collect::<Vec<_>>();
let mut float_out = (0..info.outputs.len())
.map(|_| vec![0.0; end - start])
.collect::<Vec<_>>();
let mut int_out = (0..info.outputs.len())
.map(|_| vec![0i32; end - start])
.collect::<Vec<_>>();
let mut call = id.new_call(&core);
for (key, v) in parameters {
let slot = info
.opt_inputs
.iter()
.position(|p| p.param_name == key)
.unwrap();
match info.opt_inputs[slot].kind {
OptInputType::IntegerRange { .. } | OptInputType::IntegerList { .. } => {
call.set_opt(slot, v.as_i64().unwrap() as i32)
.map_err(|e| format!("{e:?}"))?;
}
_ => {
call.set_opt(slot, v.as_f64().unwrap())
.map_err(|e| format!("{e:?}"))?;
}
}
}
let mut real_slot = 0;
for (slot, i) in info.inputs.iter().enumerate() {
if i.kind == InputType::Real {
call.set_input(slot, &arrays[real_slot])
.map_err(|e| format!("{e:?}"))?;
real_slot += 1;
} else {
let p = price_indices.map(|i| i.map(|i| arrays[i].as_slice()));
call.set_price_input(slot, p[0], p[1], p[2], p[3], p[4], p[5])
.map_err(|e| format!("{e:?}"))?;
}
}
for (slot, (floats, ints)) in float_out.iter_mut().zip(int_out.iter_mut()).enumerate() {
if info.outputs[slot].kind == OutputType::Real {
call.set_output(slot, floats)
.map_err(|e| format!("{e:?}"))?;
} else {
call.set_int_output(slot, ints)
.map_err(|e| format!("{e:?}"))?;
}
}
let range = call
.call(0, end - start - 1)
.map_err(|e| format!("indicator_failed: {name} {e:?}"))?;
drop(call);
for j in 0..range.count {
let value = if info.outputs[output].kind == OutputType::Real {
float_out[output][j]
} else {
int_out[output][j] as f64
};
if !value.is_finite() {
return Err(format!(
"indicator_nonfinite: {name} index={}",
start + range.beg_idx + j
));
}
result[start + range.beg_idx + j] = Some(value);
}
start = end;
}
Ok(Series {
value_type: ValueType::Number,
values: result,
})
}
fn average(v: &[f64]) -> f64 {
v[0] + v
.iter()
.skip(1)
.map(|x| (x - v[0]) / v.len() as f64)
.sum::<f64>()
}
fn slope(v: &[f64]) -> f64 {
let x = (v.len() - 1) as f64 / 2.0;
let y = average(v);
let num = v
.iter()
.enumerate()
.map(|(i, v)| (i as f64 - x) * (v - y))
.sum::<f64>();
let den = (0..v.len()).map(|i| (i as f64 - x).powi(2)).sum::<f64>();
num / den
}
fn boolean(v: bool) -> Option<f64> {
Some(if v { 1.0 } else { 0.0 })
}
fn operator(
name: &str,
args: &[Series],
window: Option<usize>,
frame: &Frame,
) -> Result<Series, String> {
if !OPERATORS.contains(&name) {
return Err(format!("operator_not_registered: {name}"));
}
let bool_input = matches!(
name,
"AND"
| "OR"
| "NOT"
| "XOR"
| "COUNT"
| "COUNT_TRUE"
| "CONSECUTIVE"
| "BARS_SINCE"
| "DURATION"
| "DAYS_SINCE"
| "TIME_SINCE"
);
let lag = matches!(name, "REF" | "LAG" | "PREV" | "SHIFT");
if args.is_empty()
|| (name == "IF"
&& (args.len() != 3
|| args[0].value_type != ValueType::Boolean
|| args[1].value_type != args[2].value_type))
|| (!lag
&& name != "IF"
&& args
.iter()
.any(|a| (a.value_type == ValueType::Boolean) != bool_input))
{
return Err(format!("operator_input_type_invalid: {name}"));
}
let arity = match name {
"BETWEEN" | "OUTSIDE" | "IF" => 3,
"GT" | "GTE" | "LT" | "LTE" | "EQ" | "NEQ" | "CROSS_ABOVE" | "CROSS_BELOW"
| "BREAK_ABOVE" | "BREAK_BELOW" | "ADD" | "SUB" | "MUL" | "DIV" | "MAX" | "MIN"
| "POWER" | "XOR" | "ROLLING_CORR" | "ROLLING_COV" | "NEAR_HIGH" | "NEAR_LOW"
| "BULLISH_DIVERGENCE" | "BEARISH_DIVERGENCE" => 2,
"AND" | "OR" => args.len(),
_ => 1,
};
if args.len() != arity {
return Err(format!("operator_arity_invalid: {name}"));
}
let windowed = matches!(
name,
"BREAK_HIGH"
| "BREAK_LOW"
| "RISING"
| "FALLING"
| "NON_DECREASING"
| "NON_INCREASING"
| "SLOPE"
| "SLOPE_CHANGE"
| "HHV"
| "LLV"
| "ARGMAX"
| "ARGMIN"
| "DISTANCE_TO_HIGH"
| "DISTANCE_TO_LOW"
| "NEW_HIGH"
| "NEW_LOW"
| "NEAR_HIGH"
| "NEAR_LOW"
| "BULLISH_DIVERGENCE"
| "BEARISH_DIVERGENCE"
| "ZSCORE"
| "STANDARDIZE"
| "MINMAX"
| "NORMALIZE"
| "COUNT"
| "COUNT_TRUE"
) || name.starts_with("ROLLING_");
let n = window.unwrap_or(1);
if n == 0
|| n > 10_000
|| (windowed && window.is_none())
|| (matches!(
name,
"SLOPE"
| "SLOPE_CHANGE"
| "ZSCORE"
| "STANDARDIZE"
| "ROLLING_STD"
| "ROLLING_CORR"
| "ROLLING_COV"
) && n < 2)
{
return Err(format!("operator_window_invalid: {name}"));
}
let returns_bool = matches!(
name,
"GT" | "GTE"
| "LT"
| "LTE"
| "EQ"
| "NEQ"
| "BETWEEN"
| "OUTSIDE"
| "CROSS_ABOVE"
| "CROSS_BELOW"
| "BREAK_ABOVE"
| "BREAK_BELOW"
| "BREAK_HIGH"
| "BREAK_LOW"
| "RISING"
| "FALLING"
| "NON_DECREASING"
| "NON_INCREASING"
| "TURN_UP"
| "TURN_DOWN"
| "BOTTOM_REVERSAL"
| "TOP_REVERSAL"
| "NEW_HIGH"
| "NEW_LOW"
| "NEAR_HIGH"
| "NEAR_LOW"
| "BULLISH_DIVERGENCE"
| "BEARISH_DIVERGENCE"
| "AND"
| "OR"
| "NOT"
| "XOR"
);
let len = frame.timestamps.len();
let mut out = vec![None; len];
let mut last_true = None;
let mut consecutive = Some(0usize);
let mut extreme: Option<f64> = None;
let mut cumulative_complete = true;
for i in 0..len {
let a = args[0].values[i];
let b = args.get(1).and_then(|a| a.values[i]);
let at = |j: usize| args[0].values.get(j).copied().flatten();
let history = |end: usize, count: usize| -> Option<Vec<f64>> {
if end < count {
None
} else {
args[0].values[end - count..end].iter().copied().collect()
}
};
out[i] = match name {
"IF" => a.and_then(|a| {
if a == 1.0 {
args[1].values[i]
} else {
args[2].values[i]
}
}),
"SIGN" => a.map(|v| {
if v == 0.0 {
0.0
} else if v > 0.0 {
1.0
} else {
-1.0
}
}),
"CUMMAX" | "CUMMIN" => {
cumulative_complete &= a.is_some();
extreme = a.filter(|_| cumulative_complete).map(|v| {
extreme.map_or(v, |p| if name == "CUMMAX" { p.max(v) } else { p.min(v) })
});
extreme
}
"AND" => {
if args.iter().any(|a| a.values[i] == Some(0.0)) {
Some(0.0)
} else if args.iter().any(|a| a.values[i].is_none()) {
None
} else {
Some(1.0)
}
}
"OR" => {
if args.iter().any(|a| a.values[i] == Some(1.0)) {
Some(1.0)
} else if args.iter().any(|a| a.values[i].is_none()) {
None
} else {
Some(0.0)
}
}
"NOT" => a.map(|v| 1.0 - v),
"XOR" => a.zip(b).and_then(|(a, b)| boolean(a != b)),
"GT" | "GTE" | "LT" | "LTE" | "EQ" | "NEQ" => a.zip(b).and_then(|(a, b)| {
boolean(match name {
"GT" => a > b,
"GTE" => a >= b,
"LT" => a < b,
"LTE" => a <= b,
"EQ" => a == b,
_ => a != b,
})
}),
"BETWEEN" | "OUTSIDE" => a.zip(b).zip(args[2].values[i]).and_then(|((a, b), c)| {
if b > c {
None
} else {
boolean((a >= b && a <= c) == (name == "BETWEEN"))
}
}),
"CROSS_ABOVE" | "CROSS_BELOW" | "BREAK_ABOVE" | "BREAK_BELOW" => {
if i == 0 {
None
} else {
a.zip(b).zip(at(i - 1).zip(args[1].values[i - 1])).and_then(
|((a, b), (p, q))| {
boolean(if name.ends_with("ABOVE") {
p <= q && a > b
} else {
p >= q && a < b
})
},
)
}
}
"REF" | "LAG" | "PREV" | "SHIFT" => i.checked_sub(n).and_then(at),
"CHANGE" | "DIFF" | "DELTA" | "PCT_CHANGE" | "LOG_RETURN" => a
.zip(i.checked_sub(n).and_then(at))
.and_then(|(a, p)| match name {
"PCT_CHANGE" => {
if p == 0.0 {
None
} else {
Some(a / p - 1.0)
}
}
"LOG_RETURN" => {
if a <= 0.0 || p <= 0.0 {
None
} else {
Some((a / p).ln())
}
}
_ => Some(a - p),
}),
"ACCELERATION" => a
.zip(i.checked_sub(n).and_then(at))
.zip(i.checked_sub(n * 2).and_then(at))
.map(|((a, p), q)| a - 2.0 * p + q),
"BULLISH_DIVERGENCE" | "BEARISH_DIVERGENCE" => {
if i < n || n < 4 {
None
} else {
let price: Option<Vec<f64>> =
args[0].values[i - n..=i].iter().copied().collect();
let indicator: Option<Vec<f64>> =
args[1].values[i - n..=i].iter().copied().collect();
price.zip(indicator).and_then(|(price, indicator)| {
let low = name == "BULLISH_DIVERGENCE";
let pivots = (1..n)
.filter(|&j| {
if low {
price[j] < price[j - 1] && price[j] < price[j + 1]
} else {
price[j] > price[j - 1] && price[j] > price[j + 1]
}
})
.collect::<Vec<_>>();
if pivots.last() != Some(&(n - 1)) || pivots.len() < 2 {
return boolean(false);
}
let a = pivots[pivots.len() - 2];
let b = n - 1;
boolean(if low {
price[b] < price[a] && indicator[b] > indicator[a]
} else {
price[b] > price[a] && indicator[b] < indicator[a]
})
})
}
}
"TURN_UP" | "TURN_DOWN" | "BOTTOM_REVERSAL" | "TOP_REVERSAL" => {
if i < 2 {
None
} else {
a.zip(at(i - 1)).zip(at(i - 2)).and_then(|((a, p), q)| {
if name == "ACCELERATION" {
Some(a - 2.0 * p + q)
} else {
boolean(if matches!(name, "TURN_UP" | "BOTTOM_REVERSAL") {
p < q && a > p
} else {
p > q && a < p
})
}
})
}
}
"ABS" => a.map(f64::abs),
"LOG" => a.filter(|v| *v > 0.0).map(f64::ln),
"SQRT" => a.filter(|v| *v >= 0.0).map(f64::sqrt),
"ADD" => a.zip(b).map(|(a, b)| a + b),
"SUB" => a.zip(b).map(|(a, b)| a - b),
"MUL" => a.zip(b).map(|(a, b)| a * b),
"DIV" => a.zip(b).filter(|(_, b)| *b != 0.0).map(|(a, b)| a / b),
"MAX" => a.zip(b).map(|(a, b)| a.max(b)),
"MIN" => a.zip(b).map(|(a, b)| a.min(b)),
"POWER" => a.zip(b).map(|(a, b)| a.powf(b)),
"BARS_SINCE" | "DAYS_SINCE" | "TIME_SINCE" => {
if a == Some(1.0) {
last_true = Some(i);
}
if a.is_none() {
last_true = None;
}
last_true.map(|t| {
if name == "BARS_SINCE" {
(i - t) as f64
} else {
let secs = (frame.timestamps[i] - frame.timestamps[t]).num_seconds() as f64;
if name == "DAYS_SINCE" {
secs / 86400.0
} else {
secs
}
}
})
}
"CONSECUTIVE" | "DURATION" => {
consecutive = match a {
Some(1.0) => consecutive.map(|v| v + 1),
Some(_) => Some(0),
None => None,
};
consecutive.map(|v| v as f64)
}
"BREAK_HIGH" | "NEW_HIGH" | "BREAK_LOW" | "NEW_LOW" => {
a.zip(history(i, n)).and_then(|(a, v)| {
boolean(if matches!(name, "BREAK_HIGH" | "NEW_HIGH") {
a > v.into_iter().fold(f64::NEG_INFINITY, f64::max)
} else {
a < v.into_iter().fold(f64::INFINITY, f64::min)
})
})
}
"RISING" | "FALLING" | "NON_DECREASING" | "NON_INCREASING" => history(i + 1, n + 1)
.and_then(|v| {
boolean(v.windows(2).all(|p| match name {
"RISING" => p[1] > p[0],
"FALLING" => p[1] < p[0],
"NON_DECREASING" => p[1] >= p[0],
_ => p[1] <= p[0],
}))
}),
"SLOPE_CHANGE" => history(i + 1, n)
.zip(history(i, n))
.map(|(a, b)| slope(&a) - slope(&b)),
_ => history(i + 1, n).and_then(|mut v| {
let mean = average(&v);
let lo = v.iter().copied().fold(f64::INFINITY, f64::min);
let hi = v.iter().copied().fold(f64::NEG_INFINITY, f64::max);
let variance = v.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / n as f64;
match name {
"HHV" | "ROLLING_MAX" => Some(hi),
"LLV" | "ROLLING_MIN" => Some(lo),
"ARGMAX" => v.iter().rposition(|x| *x == hi).map(|p| (n - 1 - p) as f64),
"ARGMIN" => v.iter().rposition(|x| *x == lo).map(|p| (n - 1 - p) as f64),
"DISTANCE_TO_HIGH" => {
if hi == 0.0 {
None
} else {
Some(v[n - 1] / hi - 1.0)
}
}
"DISTANCE_TO_LOW" => {
if lo == 0.0 {
None
} else {
Some(v[n - 1] / lo - 1.0)
}
}
"NEAR_HIGH" | "NEAR_LOW" => b.filter(|b| *b >= 0.0).and_then(|b| {
let base = if name == "NEAR_HIGH" { hi } else { lo };
if base == 0.0 {
None
} else {
boolean((v[n - 1] / base - 1.0).abs() <= b)
}
}),
"ZSCORE" | "STANDARDIZE" => {
if variance == 0.0 {
None
} else {
Some((v[n - 1] - mean) / variance.sqrt())
}
}
"MINMAX" | "NORMALIZE" => {
if hi == lo {
None
} else {
Some((v[n - 1] - lo) / (hi - lo))
}
}
"ROLLING_MEAN" => Some(mean),
"ROLLING_SUM" | "COUNT" | "COUNT_TRUE" => Some(v.iter().sum()),
"ROLLING_STD" => Some(variance.sqrt()),
"ROLLING_MEDIAN" => {
v.sort_by(f64::total_cmp);
Some(if n % 2 == 1 {
v[n / 2]
} else {
(v[n / 2 - 1] + v[n / 2]) / 2.0
})
}
"SLOPE" => Some(slope(&v)),
"ROLLING_CORR" | "ROLLING_COV" => {
let b: Option<Vec<f64>> =
args[1].values[i + 1 - n..=i].iter().copied().collect();
b.and_then(|b| {
let bm = average(&b);
let cov = v
.iter()
.zip(&b)
.map(|(a, b)| (a - mean) * (b - bm))
.sum::<f64>()
/ n as f64;
if name == "ROLLING_COV" {
Some(cov)
} else {
let bv = b.iter().map(|b| (b - bm).powi(2)).sum::<f64>() / n as f64;
let d = (variance * bv).sqrt();
if d == 0.0 { None } else { Some(cov / d) }
}
})
}
_ => None,
}
}),
}
.filter(|v| v.is_finite());
}
Ok(Series {
value_type: if name == "IF" {
args[1].value_type
} else if lag {
args[0].value_type
} else if returns_bool {
ValueType::Boolean
} else {
ValueType::Number
},
values: out,
})
}
#[cfg(test)]
#[path = "factor_events_tests.rs"]
mod tests;
+161
View File
@@ -0,0 +1,161 @@
use super::*;
use serde_json::json;
use crate::factor_event_catalog::parameter_domain;
#[test]
fn every_parameter_domain_is_structured_and_matches_native_defaults() {
for function in abstract_api::funcs() {
let handle = abstract_api::get_func_handle(function.name).unwrap();
let core = Core::new();
let mut call = handle.new_call(&core);
for (index, parameter) in function.opt_inputs.iter().enumerate() {
let domain = parameter_domain(parameter.kind);
let default = domain["default"].as_f64().unwrap();
assert!(default.is_finite(), "{} {}", function.name, parameter.param_name);
if let Some(choices) = domain.get("choices") {
assert!(choices.as_array().unwrap().iter().any(|v| v["value"].as_f64() == Some(default)));
} else {
assert!(default >= domain["minimum"].as_f64().unwrap());
assert!(default <= domain["maximum"].as_f64().unwrap());
}
if domain["value_type"] == "integer" {
assert_eq!(default.fract(), 0.0);
call.set_opt(index, default as i32).unwrap();
} else {
call.set_opt(index, default).unwrap();
}
}
assert!(call.lookback().is_ok(), "{}", function.name);
}
}
#[test]
fn parameter_domains_keep_enumeration_labels_without_debug_string_parsing() {
let catalog = catalog();
assert_eq!(catalog["parameter_domain_contract"], "fidc.indicator-parameter-domain/v1");
let indicators = catalog["indicators"].as_array().unwrap();
let rsi = indicators.iter().find(|v| v["name"] == "RSI").unwrap();
assert_eq!(rsi["parameters"][0]["domain"]["minimum"], 2);
let stoch = indicators.iter().find(|v| v["name"] == "STOCH").unwrap();
let ma_type = stoch["parameters"].as_array().unwrap().iter().find(|p| p["name"] == "optInSlowK_MAType").unwrap();
assert!(ma_type["domain"]["choices"].as_array().unwrap().iter().any(|v| v["label"] == "EMA" && v["value"] == 1));
}
fn frame(values: Vec<Option<f64>>) -> Frame {
let start = DateTime::parse_from_rfc3339("2026-09-01T15:30:00+08:00").unwrap();
let times = (0..values.len())
.map(|i| start + chrono::Duration::days(i as i64))
.collect::<Vec<_>>();
Frame {
symbol: "TEST".into(),
frequency: "1d".into(),
decision_at: *times.last().unwrap(),
available_at: times.clone(),
timestamps: times,
fields: BTreeMap::from([("close".into(), values)]),
}
}
fn expr(v: Value) -> Expr {
serde_json::from_value(v).unwrap()
}
#[test]
fn ta_sma_real_values_and_parameter_validation() {
let frame = frame(vec![Some(1.0), Some(2.0), Some(3.0), Some(4.0)]);
let e = expr(
json!({"kind":"indicator","name":"SMA","inputs":[{"kind":"field","name":"close"}],"parameters":{"optInTimePeriod":3}}),
);
assert_eq!(
evaluate(&e, &frame).unwrap().values,
vec![None, None, Some(2.0), Some(3.0)]
);
let bad = expr(
json!({"kind":"indicator","name":"SMA","inputs":[{"kind":"field","name":"close"}],"parameters":{"period":3}}),
);
assert!(
evaluate(&bad, &frame)
.unwrap_err()
.contains("parameter_unknown")
);
}
#[test]
fn cross_is_event_not_state_and_never_uses_future() {
let f = frame(vec![
Some(9.0),
Some(10.0),
Some(11.0),
Some(12.0),
Some(8.0),
]);
let e = expr(
json!({"kind":"operator","name":"CROSS_ABOVE","args":[{"kind":"field","name":"close"},{"kind":"number","value":10.0}]}),
);
assert_eq!(
evaluate(&e, &f).unwrap().values,
vec![None, Some(0.0), Some(1.0), Some(0.0), Some(0.0)]
);
let mut invalid = f.clone();
invalid.available_at[4] = invalid.decision_at + chrono::Duration::seconds(1);
assert!(evaluate(&e, &invalid).is_err());
}
#[test]
fn missing_is_not_zero_and_breakout_excludes_current() {
let f = frame(vec![Some(1.0), Some(2.0), Some(3.0), None, Some(5.0)]);
let e = expr(
json!({"kind":"operator","name":"BREAK_HIGH","window":2,"args":[{"kind":"field","name":"close"}]}),
);
assert_eq!(
evaluate(&e, &f).unwrap().values,
vec![None, None, Some(1.0), None, None]
);
let zero = expr(
json!({"kind":"operator","name":"DIV","args":[{"kind":"field","name":"close"},{"kind":"number","value":0}]}),
);
assert!(
evaluate(&zero, &f)
.unwrap()
.values
.iter()
.all(Option::is_none)
);
}
#[test]
fn ta_rewarms_after_gap_and_const_zscore_is_unknown() {
let f = frame(vec![Some(1.0), Some(1.0), None, Some(2.0), Some(2.0)]);
let e = expr(
json!({"kind":"indicator","name":"SMA","inputs":[{"kind":"field","name":"close"}],"parameters":{"optInTimePeriod":2}}),
);
assert_eq!(
evaluate(&e, &f).unwrap().values,
vec![None, Some(1.0), None, None, Some(2.0)]
);
let e = expr(
json!({"kind":"operator","name":"ZSCORE","window":2,"args":[{"kind":"field","name":"close"}]}),
);
assert!(evaluate(&e, &f).unwrap().values.iter().all(Option::is_none));
}
#[test]
fn no_event_has_no_bars_since_and_type_errors_reject() {
let f = frame(vec![Some(1.0), Some(1.0), Some(1.0)]);
let state = json!({"kind":"operator","name":"GT","args":[{"kind":"field","name":"close"},{"kind":"number","value":5}]});
let e = expr(json!({"kind":"operator","name":"BARS_SINCE","args":[state]}));
assert!(evaluate(&e, &f).unwrap().values.iter().all(Option::is_none));
assert!(
evaluate(
&expr(
json!({"kind":"operator","name":"NOT","args":[{"kind":"field","name":"close"}]})
),
&f
)
.is_err()
);
}
#[test]
fn literal_unknown_fields_reject_and_catalog_is_not_trading_permission() {
assert!(
serde_json::from_value::<Expr>(json!({"kind":"number","value":1,"account_id":2}))
.is_err()
);
let c = catalog();
assert!(c["indicators"].as_array().unwrap().len() > 190);
assert_eq!(c["live_routing"], false);
}
+369
View File
@@ -0,0 +1,369 @@
use chrono::NaiveDate;
use serde::{Deserialize, Serialize};
use crate::TradingCalendar;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TradingActionOrigin {
Strategy,
Manual,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AutomaticTradeProtection {
#[serde(default, deserialize_with = "optional_days")]
pub buy_protection_days: u32,
#[serde(default, deserialize_with = "optional_days")]
pub sell_cooldown_days: u32,
#[serde(default, deserialize_with = "optional_days")]
pub max_holding_days: u32,
#[serde(default, deserialize_with = "optional_locks")]
pub locks: Vec<AutomaticTradeLock>,
}
pub fn deserialize_optional_policy<'de, D: serde::Deserializer<'de>>(
deserializer: D,
) -> Result<AutomaticTradeProtection, D::Error> {
Ok(Option::<AutomaticTradeProtection>::deserialize(deserializer)?.unwrap_or_default())
}
fn optional_days<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result<u32, D::Error> {
let raw = serde_json::Value::deserialize(deserializer)?;
if raw.is_null() {
return Ok(0);
}
raw.as_f64()
.filter(|value| {
value.is_finite() && value.fract() == 0.0 && *value >= 0.0 && *value <= 3650.0
})
.map(|value| value as u32)
.ok_or_else(|| serde::de::Error::custom("protection days must be integers in 0..3650"))
}
fn optional_locks<'de, D: serde::Deserializer<'de>>(
deserializer: D,
) -> Result<Vec<AutomaticTradeLock>, D::Error> {
Ok(Option::<Vec<AutomaticTradeLock>>::deserialize(deserializer)?.unwrap_or_default())
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AutomaticTradeLock {
pub symbol: String,
pub start_date: NaiveDate,
pub end_date: Option<NaiveDate>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct HoldingLifecycleEvidence {
pub has_position: bool,
pub opened_date: Option<NaiveDate>,
pub last_buy_date: Option<NaiveDate>,
pub last_sell_date: Option<NaiveDate>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct AutomaticTradePermission {
pub buy_denial: Option<&'static str>,
pub sell_denial: Option<&'static str>,
pub max_holding_exit: bool,
}
impl AutomaticTradeProtection {
pub fn enabled(&self) -> bool {
self.buy_protection_days > 0
|| self.sell_cooldown_days > 0
|| self.max_holding_days > 0
|| !self.locks.is_empty()
}
pub fn validate(&self) -> Result<(), String> {
if [
self.buy_protection_days,
self.sell_cooldown_days,
self.max_holding_days,
]
.into_iter()
.any(|days| days > 3650)
{
return Err("automatic_trade_holding_days_out_of_range: expected 0..3650".into());
}
if self.locks.len() > 2000 {
return Err("automatic_trade_locks_limit: maximum 2000 intervals".into());
}
for lock in &self.locks {
let valid_symbol = lock.symbol.split_once('.').is_some_and(|(code, venue)| {
code.len() == 6
&& code.bytes().all(|ch| ch.is_ascii_digit())
&& matches!(venue, "SH" | "SZ" | "BJ")
});
if !valid_symbol {
return Err(format!(
"automatic_trade_lock_invalid_symbol: {}",
lock.symbol
));
}
if lock.end_date.is_some_and(|end| end < lock.start_date) {
return Err(format!(
"automatic_trade_lock_invalid_interval: {}",
lock.symbol
));
}
}
Ok(())
}
pub fn evaluate(
&self,
symbol: &str,
execution_date: NaiveDate,
evidence: &HoldingLifecycleEvidence,
calendar: &TradingCalendar,
) -> Result<AutomaticTradePermission, String> {
self.validate()?;
if self.locks.iter().any(|lock| {
lock.symbol == symbol
&& lock.start_date <= execution_date
&& lock.end_date.is_none_or(|end| execution_date <= end)
}) {
return Ok(AutomaticTradePermission {
buy_denial: Some("automatic_trade_locked"),
sell_denial: Some("automatic_trade_locked"),
max_holding_exit: false,
});
}
let elapsed = |date: NaiveDate| -> Result<usize, String> {
let start = calendar.index_of(date).ok_or_else(|| {
format!(
"automatic_trade_holding_calendar_missing: symbol={symbol} fact_date={date}"
)
})?;
let end = calendar.index_of(execution_date).ok_or_else(|| format!("automatic_trade_holding_calendar_missing: symbol={symbol} execution_date={execution_date}"))?;
end.checked_sub(start).ok_or_else(|| format!("automatic_trade_holding_future_fact: symbol={symbol} fact_date={date} execution_date={execution_date}"))
};
let mut decision = AutomaticTradePermission::default();
if self.buy_protection_days > 0
&& evidence.has_position
&& let Some(date) = evidence.last_buy_date
&& elapsed(date)? <= self.buy_protection_days as usize
{
decision.sell_denial = Some("buy_fill_protection");
}
if self.sell_cooldown_days > 0
&& let Some(date) = evidence.last_sell_date
&& elapsed(date)? <= self.sell_cooldown_days as usize
{
decision.buy_denial = Some("sell_fill_cooldown");
}
if self.max_holding_days > 0 && evidence.has_position {
let opened = evidence.opened_date.ok_or_else(|| format!("automatic_trade_opened_date_missing: symbol={symbol}; require confirmed position lifecycle evidence"))?;
decision.max_holding_exit = elapsed(opened)? >= self.max_holding_days as usize
&& decision.sell_denial.is_none();
if decision.max_holding_exit {
decision.buy_denial = Some("maximum_holding_exit");
}
}
Ok(decision)
}
/// The caller supplies origin from its authenticated execution path, never
/// from an untrusted order-body flag. Broker and ordinary risk checks remain.
pub fn evaluate_for_origin(
&self,
origin: TradingActionOrigin,
symbol: &str,
execution_date: NaiveDate,
evidence: &HoldingLifecycleEvidence,
calendar: &TradingCalendar,
) -> Result<AutomaticTradePermission, String> {
self.validate()?;
match origin {
TradingActionOrigin::Strategy => {
self.evaluate(symbol, execution_date, evidence, calendar)
}
TradingActionOrigin::Manual => Ok(AutomaticTradePermission::default()),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn d(value: &str) -> NaiveDate {
NaiveDate::parse_from_str(value, "%Y-%m-%d").unwrap()
}
fn calendar() -> TradingCalendar {
TradingCalendar::new(
[
"2026-09-11",
"2026-09-14",
"2026-09-15",
"2026-09-16",
"2026-09-17",
]
.into_iter()
.map(d)
.collect(),
)
}
#[test]
fn three_complete_sessions_protect_through_wednesday_not_72_hours() {
let policy = AutomaticTradeProtection {
buy_protection_days: 3,
sell_cooldown_days: 3,
..Default::default()
};
let evidence = HoldingLifecycleEvidence {
has_position: true,
last_buy_date: Some(d("2026-09-11")),
last_sell_date: Some(d("2026-09-11")),
..Default::default()
};
for day in ["2026-09-11", "2026-09-14", "2026-09-15", "2026-09-16"] {
let decision = policy
.evaluate("000001.SZ", d(day), &evidence, &calendar())
.unwrap();
assert_eq!(decision.sell_denial, Some("buy_fill_protection"));
assert_eq!(decision.buy_denial, Some("sell_fill_cooldown"));
}
assert_eq!(
policy
.evaluate("000001.SZ", d("2026-09-17"), &evidence, &calendar())
.unwrap(),
AutomaticTradePermission::default()
);
}
#[test]
fn absolute_locks_are_inclusive_and_override_timed_exit_without_changing_other_symbols() {
let policy = AutomaticTradeProtection {
max_holding_days: 1,
locks: vec![AutomaticTradeLock {
symbol: "000001.SZ".into(),
start_date: d("2026-09-11"),
end_date: Some(d("2026-09-16")),
}],
..Default::default()
};
let evidence = HoldingLifecycleEvidence {
has_position: true,
opened_date: Some(d("2026-09-11")),
..Default::default()
};
let locked = policy
.evaluate("000001.SZ", d("2026-09-16"), &evidence, &calendar())
.unwrap();
assert_eq!(locked.sell_denial, Some("automatic_trade_locked"));
assert!(!locked.max_holding_exit);
assert!(
policy
.evaluate("600000.SH", d("2026-09-16"), &evidence, &calendar())
.unwrap()
.max_holding_exit
);
assert!(
policy
.evaluate("000001.SZ", d("2026-09-17"), &evidence, &calendar())
.unwrap()
.max_holding_exit
);
}
#[test]
fn zero_disabled_and_missing_calendar_or_opened_date_are_not_inferred() {
let evidence = HoldingLifecycleEvidence {
has_position: true,
..Default::default()
};
assert_eq!(
AutomaticTradeProtection::default()
.evaluate(
"000001.SZ",
d("2026-09-17"),
&evidence,
&TradingCalendar::new(vec![])
)
.unwrap(),
AutomaticTradePermission::default()
);
let policy = AutomaticTradeProtection {
max_holding_days: 1,
..Default::default()
};
assert!(
policy
.evaluate("000001.SZ", d("2026-09-17"), &evidence, &calendar())
.unwrap_err()
.contains("opened_date_missing")
);
let evidence = HoldingLifecycleEvidence {
opened_date: Some(d("2026-09-10")),
..evidence
};
assert!(
policy
.evaluate("000001.SZ", d("2026-09-17"), &evidence, &calendar())
.unwrap_err()
.contains("calendar_missing")
);
}
#[test]
fn manual_origin_only_bypasses_automatic_policy_not_an_order_or_broker_permission() {
let policy = AutomaticTradeProtection {
locks: vec![AutomaticTradeLock {
symbol: "000001.SZ".into(),
start_date: d("2026-09-11"),
end_date: None,
}],
..Default::default()
};
assert_eq!(
policy
.evaluate_for_origin(
TradingActionOrigin::Manual,
"000001.SZ",
d("2026-09-14"),
&HoldingLifecycleEvidence::default(),
&calendar()
)
.unwrap(),
AutomaticTradePermission::default()
);
assert_eq!(
policy
.evaluate_for_origin(
TradingActionOrigin::Strategy,
"000001.SZ",
d("2026-09-14"),
&HoldingLifecycleEvidence::default(),
&calendar()
)
.unwrap()
.buy_denial,
Some("automatic_trade_locked")
);
}
#[test]
fn opening_date_follows_fills_not_partial_sales_or_corporate_conversions() {
let mut portfolio = crate::PortfolioState::new(100_000.0);
let position = portfolio.position_mut("000001.SZ");
position.buy(d("2026-09-11"), 100, 10.0);
position.buy(d("2026-09-14"), 200, 10.0);
position.sell(100, 10.0).unwrap();
assert_eq!(position.opened_date(), Some(d("2026-09-11")));
portfolio
.apply_successor_conversion("000001.SZ", "000002.SZ", 2.0, 0.0)
.unwrap();
let successor = portfolio.position_mut("000002.SZ");
assert_eq!(successor.opened_date(), Some(d("2026-09-11")));
assert_eq!(successor.last_buy_date(), Some(d("2026-09-14")));
successor.sell(400, 5.0).unwrap();
assert_eq!(successor.opened_date(), None);
successor.buy(d("2026-09-17"), 100, 5.0);
assert_eq!(successor.opened_date(), Some(d("2026-09-17")));
}
}
+30 -2
View File
@@ -70,8 +70,17 @@ impl Instrument {
pub fn is_active_on(&self, date: NaiveDate) -> bool {
self.listed_at.is_none_or(|listed_at| listed_at <= date)
&& !self.is_delisted_before(date)
&& !(self.status.eq_ignore_ascii_case("inactive") && self.delisted_at.is_none())
&& !self.is_delisted_on_or_before(date)
}
pub fn dated_market_absence_reason(&self, date: NaiveDate) -> Option<&'static str> {
if self.listed_at.is_some_and(|listed| date < listed) {
Some("not_yet_listed")
} else if self.is_delisted_on_or_before(date) {
Some("delisted")
} else {
None
}
}
}
@@ -107,6 +116,25 @@ mod tests {
}
}
#[test]
fn lifecycle_is_dated_and_latest_undated_terminal_status_is_not_historical_evidence() {
let mut item = instrument("BJS", 100);
let listing = chrono::NaiveDate::from_ymd_opt(2026, 8, 5).unwrap();
let removal = chrono::NaiveDate::from_ymd_opt(2026, 9, 10).unwrap();
item.listed_at = Some(listing);
item.delisted_at = Some(removal);
assert_eq!(item.dated_market_absence_reason(listing.pred_opt().unwrap()), Some("not_yet_listed"));
assert!(item.is_active_on(listing));
assert!(!item.is_active_on(removal));
assert_eq!(item.dated_market_absence_reason(removal), Some("delisted"));
item.delisted_at = None;
for status in ["delisting", "delisted", "inactive", "terminated"] {
item.status = status.into();
assert!(item.is_active_on(listing));
assert_eq!(item.dated_market_absence_reason(listing), None);
}
}
#[test]
fn order_quantity_rules_are_case_insensitive_without_allocating_normalized_boards() {
let kcb = instrument(" kSh ", 100);
+10
View File
@@ -3,6 +3,12 @@ pub mod calendar;
pub mod cost;
pub mod data;
pub mod daily_patterns;
pub mod pattern_context;
pub mod session_events;
pub mod factor_events;
mod factor_event_catalog;
pub mod factor_cross_section;
pub mod market_event_context;
pub mod engine;
pub mod event_bus;
pub mod events;
@@ -15,10 +21,13 @@ pub mod platform_expr_strategy;
pub mod platform_runtime_schema;
pub mod platform_strategy_spec;
pub mod portfolio;
pub mod portfolio_loss;
pub mod risk_control;
pub mod rules;
pub mod scheduler;
pub mod strategy;
pub mod holding_policy;
pub mod signal_contract;
pub mod strategy_ai;
pub mod universe;
@@ -84,6 +93,7 @@ pub use platform_strategy_spec::{
platform_expr_config_from_value, validate_strategy_risk_policy_fields,
};
pub use portfolio::{CashReceivable, HoldingSummary, PendingCashFlow, PortfolioState, Position};
pub use portfolio_loss::{ClosedPortfolioSession, PortfolioLossConfig, PortfolioLossDecision, PortfolioLossError, PortfolioLossState};
pub use risk_control::{
ChinaAShareRiskControl, FidcRiskControlConfig, FidcRiskDecisionAudit, RiskCheckScope,
StaticRiskRuleConfig, TradingConstraintConfig,
@@ -0,0 +1,257 @@
//! Complete published daily cross sections, independent of trading candidates and accounts.
use chrono::NaiveDate;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
pub const CONTRACT: &str = "fidc_market_event_context_v1";
pub fn implementation_sha256() -> String {
use sha2::{Digest, Sha256};
format!("{:x}", Sha256::digest(include_bytes!("market_event_context.rs")))
}
pub const COMMON_FIELDS: &[&str] = &[
"market_breadth", "market_return", "market_limit_up_count", "market_limit_down_count",
"market_limit_up_rate", "market_broken_limit_rate", "market_high_board", "market_profit_effect",
];
pub const INDUSTRY_FIELDS: &[&str] = &[
"industry_close", "industry_return_20", "industry_breadth", "industry_rank", "industry_size",
];
#[derive(Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Observation {
pub symbol: String,
pub industry: Option<String>,
pub close: Option<f64>,
pub high: Option<f64>,
pub previous_close: Option<f64>,
pub upper_limit: Option<f64>,
pub lower_limit: Option<f64>,
pub no_limit: Option<bool>,
pub paused: Option<bool>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Day {
pub date: NaiveDate,
pub universe: Vec<String>,
pub rows: Vec<Observation>,
}
#[derive(Default, Clone, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct State {
pub last_date: Option<NaiveDate>,
pub streaks: BTreeMap<String, Option<u32>>,
pub limit_ups: BTreeSet<String>,
pub industry_history: BTreeMap<String, Vec<f64>>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Request {
pub days: Vec<Day>,
#[serde(default)]
pub previous: State,
}
#[derive(Serialize)]
pub struct OutputDay {
pub date: NaiveDate,
pub common: BTreeMap<String, Option<f64>>,
pub industries: BTreeMap<String, BTreeMap<String, Option<f64>>>,
pub members: BTreeMap<String, Option<String>>,
pub securities: usize,
pub active: usize,
pub paused: usize,
pub no_limit: usize,
pub profit_effect_members: Vec<String>,
pub profit_effect_missing: Vec<String>,
pub industry_missing: Vec<String>,
}
#[derive(Serialize)]
pub struct Output {
pub contract: &'static str,
pub days: Vec<OutputDay>,
pub state: State,
}
fn positive(value: Option<f64>, symbol: &str, field: &str) -> Result<f64, String> {
value.filter(|v| v.is_finite() && *v > 0.0)
.ok_or_else(|| format!("market_event_input_invalid: {symbol} {field}"))
}
fn average(values: impl Iterator<Item = f64>, n: usize) -> f64 {
values.map(|v| v / n as f64).sum()
}
pub fn aggregate(request: Request) -> Result<Output, String> {
let mut state = request.previous;
if request.days.is_empty() || request.days.len() > 30
|| request.days.iter().map(|d| d.rows.len()).sum::<usize>() > 60_000
|| state.streaks.len() > 20_000 || state.limit_ups.len() > 20_000
|| state.industry_history.len() > 2000
|| state.industry_history.values().any(|v| v.is_empty() || v.len() > 21
|| v.iter().any(|x| !x.is_finite() || *x <= 0.0))
|| state.last_date.is_none() && (!state.streaks.is_empty() || !state.limit_ups.is_empty() || !state.industry_history.is_empty()) {
return Err("market_event_history_budget_or_state_invalid".into());
}
let mut output = Vec::new();
for day in request.days {
if state.last_date.is_some_and(|d| d >= day.date)
|| day.universe.is_empty() || day.universe.len() > 20_000
|| day.universe.iter().collect::<BTreeSet<_>>().len() != day.universe.len()
|| day.rows.len() != day.universe.len()
|| day.rows.iter().map(|r| &r.symbol).collect::<BTreeSet<_>>() != day.universe.iter().collect::<BTreeSet<_>>() {
return Err(format!("market_event_incomplete_cross_section: {}", day.date));
}
let mut returns = BTreeMap::new();
let mut groups: BTreeMap<String, Vec<f64>> = BTreeMap::new();
let mut members = BTreeMap::new();
let mut streaks = BTreeMap::new();
let mut ups = BTreeSet::new();
let mut downs = 0; let mut touched = 0; let mut broken = 0; let mut paused = 0; let mut unlimited = 0;
for row in &day.rows {
let industry = row.industry.clone().filter(|s| !s.trim().is_empty());
members.insert(row.symbol.clone(), industry.clone());
match row.paused {
Some(true) => {
paused += 1;
streaks.insert(row.symbol.clone(), state.streaks.get(&row.symbol).copied().flatten());
continue;
},
Some(false) => {},
None => return Err(format!("market_event_pause_state_missing: {} {}", day.date, row.symbol)),
}
let c = positive(row.close, &row.symbol, "close")?;
let h = positive(row.high, &row.symbol, "high")?;
let p = positive(row.previous_close, &row.symbol, "previous_close")?;
if h + 1e-8 < c { return Err(format!("market_event_high_below_close: {}", row.symbol)); }
let change = c / p - 1.0;
returns.insert(row.symbol.clone(), change);
if let Some(industry) = industry { groups.entry(industry).or_default().push(change); }
let is_up = match row.no_limit {
Some(true) => { unlimited += 1; false },
Some(false) => {
let upper = positive(row.upper_limit, &row.symbol, "upper_limit")?;
let lower = positive(row.lower_limit, &row.symbol, "lower_limit")?;
if lower >= upper || c > upper + 1e-8 || c < lower - 1e-8 {
return Err(format!("market_event_limit_bounds_invalid: {} {}", day.date, row.symbol));
}
let at_up = (c - upper).abs() <= 1e-8;
if (c - lower).abs() <= 1e-8 { downs += 1; }
if h >= upper - 1e-8 { touched += 1; if !at_up { broken += 1; } }
at_up
},
None => return Err(format!("market_event_limit_policy_missing: {}", row.symbol)),
};
if is_up {
ups.insert(row.symbol.clone());
// The first observed limit-up may already be a continuing streak.
streaks.insert(row.symbol.clone(), state.streaks.get(&row.symbol).copied().flatten().map(|v| v + 1));
} else { streaks.insert(row.symbol.clone(), Some(0)); }
}
let active = returns.len();
if active == 0 { return Err(format!("market_event_no_active_market: {}", day.date)); }
let previous_ups = state.limit_ups.iter().cloned().collect::<Vec<_>>();
let profit_missing = previous_ups.iter().filter(|s| !returns.contains_key(*s)).cloned().collect::<Vec<_>>();
let profit = if previous_ups.is_empty() || !profit_missing.is_empty() { None }
else { Some(average(previous_ups.iter().map(|s| returns[s]), previous_ups.len())) };
let board = if ups.iter().any(|s| streaks[s].is_none()) { None }
else { Some(ups.iter().map(|s| streaks[s].unwrap()).max().unwrap_or(0) as f64) };
let common = BTreeMap::from([
("market_breadth".into(), Some(returns.values().filter(|v| **v > 0.0).count() as f64 / active as f64)),
("market_return".into(), Some(average(returns.values().copied(), active))),
("market_limit_up_count".into(), Some(ups.len() as f64)),
("market_limit_down_count".into(), Some(downs as f64)),
("market_limit_up_rate".into(), (touched > 0).then(|| ups.len() as f64 / touched as f64)),
("market_broken_limit_rate".into(), (touched > 0).then(|| broken as f64 / touched as f64)),
("market_high_board".into(), board),
("market_profit_effect".into(), profit),
]);
let mut industries = BTreeMap::new();
// A disappeared group breaks its continuous history; no stale NAV is carried forward.
state.industry_history.retain(|key, _| groups.contains_key(key));
for (industry, values) in groups {
let history = state.industry_history.entry(industry.clone()).or_default();
let nav = history.last().copied().unwrap_or(1.0) * (1.0 + average(values.iter().copied(), values.len()));
history.push(nav);
if history.len() > 21 { history.remove(0); }
let momentum = (history.len() == 21).then(|| nav / history[0] - 1.0);
industries.insert(industry, BTreeMap::from([
("industry_close".into(), Some(nav)), ("industry_return_20".into(), momentum),
("industry_breadth".into(), Some(values.iter().filter(|v| **v > 0.0).count() as f64 / values.len() as f64)),
]));
}
let universe = industries.keys().cloned().collect::<Vec<_>>();
let known = industries.values().all(|g| g["industry_return_20"].is_some());
let ranks = if known && !universe.is_empty() {
crate::factor_cross_section::evaluate("RANK", &universe, &industries.iter().map(|(s,g)|
crate::factor_cross_section::Observation {symbol:s.clone(), value:g["industry_return_20"].unwrap(),industry:None,market_cap:None}).collect::<Vec<_>>(),0.0)?
.into_iter().map(|r|(r.symbol,r.value)).collect::<BTreeMap<_,_>>()
} else { BTreeMap::new() };
for (name, fields) in &mut industries {
fields.insert("industry_rank".into(), ranks.get(name).copied());
fields.insert("industry_size".into(), Some(universe.len() as f64));
}
let industry_missing=members.iter().filter(|(_,group)|group.is_none()).map(|(s,_)|s.clone()).collect::<Vec<_>>();
if !industry_missing.is_empty() {
// An unclassified member may belong to any group; never silently shrink a group.
state.industry_history.clear();
for fields in industries.values_mut() { for value in fields.values_mut() { *value=None; } }
}
output.push(OutputDay { date:day.date, common, industries, members, securities:day.rows.len(), active, paused,
no_limit:unlimited, profit_effect_members:previous_ups, profit_effect_missing:profit_missing, industry_missing });
state.last_date = Some(day.date); state.streaks = streaks; state.limit_ups = ups;
}
Ok(Output {contract:CONTRACT, days:output, state})
}
#[cfg(test)]
mod tests {
use super::*;
fn day(n: u32, up: bool) -> Day {
Day {date:NaiveDate::from_ymd_opt(2026,9,n).unwrap(), universe:vec!["A".into(),"B".into()], rows:vec![
Observation{symbol:"A".into(),industry:Some("I".into()),close:Some(if up {11.0}else{10.0}),high:Some(11.0),previous_close:Some(10.0),upper_limit:Some(11.0),lower_limit:Some(9.0),no_limit:Some(false),paused:Some(false)},
Observation{symbol:"B".into(),industry:Some("J".into()),close:Some(9.0),high:Some(10.0),previous_close:Some(10.0),upper_limit:Some(11.0),lower_limit:Some(9.0),no_limit:Some(false),paused:Some(false)}]}
}
#[test]
fn formulas_use_real_limits_and_full_denominators() {
let r=aggregate(Request{days:vec![day(1,false),day(2,true),day(3,true)],previous:State::default()}).unwrap();
let d=&r.days[1];
assert_eq!(d.common["market_breadth"],Some(0.5));
assert_eq!(d.common["market_limit_down_count"],Some(1.0));
assert_eq!(d.common["market_limit_up_rate"],Some(1.0));
assert_eq!(r.days[0].common["market_limit_up_rate"],Some(0.0));
assert_eq!(r.days[0].common["market_broken_limit_rate"],Some(1.0));
assert_eq!(r.days[2].common["market_high_board"],Some(2.0));
assert!((r.days[2].common["market_profit_effect"].unwrap()-0.1).abs()<1e-12);
assert_eq!(r.days[0].common["market_profit_effect"],None);
}
#[test]
fn missing_duplicate_and_unproven_limit_states_fail() {
let mut d=day(1,true);d.rows.pop();assert!(aggregate(Request{days:vec![d],previous:State::default()}).is_err());
let mut d=day(1,true);d.rows[0].upper_limit=None;assert!(aggregate(Request{days:vec![d],previous:State::default()}).is_err());
let mut d=day(1,true);d.rows[0].no_limit=Some(true);d.rows[0].upper_limit=None;
assert_eq!(aggregate(Request{days:vec![d],previous:State::default()}).unwrap().days[0].no_limit,1);
}
#[test]
fn chunking_and_future_append_preserve_history() {
let first=aggregate(Request{days:vec![day(1,false),day(2,true)],previous:State::default()}).unwrap();
let next=aggregate(Request{days:vec![day(3,true)],previous:first.state}).unwrap();
let full=aggregate(Request{days:vec![day(1,false),day(2,true),day(3,true)],previous:State::default()}).unwrap();
assert_eq!(serde_json::to_value(&first.days).unwrap(),serde_json::to_value(&full.days[..2]).unwrap());
assert_eq!(serde_json::to_value(&next.days).unwrap(),serde_json::to_value(&full.days[2..]).unwrap());
let unknown=aggregate(Request{days:vec![day(1,true)],previous:State::default()}).unwrap();
assert_eq!(unknown.days[0].common["market_high_board"],None);
}
#[test]
fn missing_industry_does_not_invent_groups_or_disable_independent_market_facts() {
let mut missing=day(2,true);missing.rows[0].industry=None;
let r=aggregate(Request{days:vec![day(1,false),missing,day(3,true)],previous:State::default()}).unwrap();
assert_eq!(r.days[1].common["market_breadth"],Some(0.5));
assert_eq!(r.days[1].industry_missing,vec!["A"]);
assert!(r.days[1].industries.values().flat_map(|g|g.values()).all(Option::is_none));
assert_eq!(r.days[2].industries["I"]["industry_return_20"],None);
}
}
+25 -1
View File
@@ -558,7 +558,9 @@ fn alpha_beta(
}
fn drawdown_stats(nav: &[f64]) -> (f64, usize) {
let mut peak = 0.0_f64;
// NAV is measured against the pre-period capital. The first real loss
// must not become a new zero-drawdown baseline.
let mut peak = 1.0_f64;
let mut max_drawdown = 0.0_f64;
let mut duration = 0_usize;
let mut max_duration = 0_usize;
@@ -767,6 +769,28 @@ fn safe_div(numerator: f64, denominator: f64, fallback: f64) -> f64 {
mod tests {
use super::*;
#[test]
fn drawdown_includes_initial_nav_without_adding_a_trading_day() {
let (drawdown, duration) = drawdown_stats(&[0.9, 0.99]);
assert!((drawdown + 0.1).abs() < 1e-12);
assert_eq!(duration, 2);
assert_eq!(drawdown_stats(&[1.0, 1.1, 1.1]), (0.0, 0));
assert_eq!(drawdown_stats(&[0.0]), (-1.0, 1));
assert_eq!(drawdown_stats(&[]), (0.0, 0));
}
#[test]
fn first_day_loss_is_preserved_in_shared_backtest_metrics() {
let curve = vec![
equity_point("2025-01-03", 99.16648349337, 98.81608059815, 100.0),
equity_point("2025-01-06", 99.68551588547, 98.65392198168, 98.81608059815),
];
let metrics = compute_backtest_metrics(&curve, &[], &[], &[], 100.0, None).unwrap();
assert!((metrics.max_drawdown + 0.0083351650663).abs() < 1e-12);
assert_eq!(metrics.total_trade_days, 2);
assert_eq!(metrics.max_drawdown_duration_days, 2);
}
fn equity_point(
date: &str,
total_equity: f64,
+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());
}
}
File diff suppressed because it is too large Load Diff
+227 -30
View File
@@ -3,6 +3,7 @@ use std::collections::{BTreeMap, BTreeSet, HashSet};
use chrono::{NaiveDate, NaiveTime};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::portfolio_loss::PortfolioLossConfig;
use crate::{
DynamicSlippageConfig, MatchingType, PlatformAccountActionKind, PlatformExplicitActionStage,
@@ -16,6 +17,10 @@ use crate::{
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct StrategyRuntimeSpec {
#[serde(default)]
pub signal_book: Option<crate::signal_contract::SignalBook>,
#[serde(default)]
pub signal_book_ref: Option<crate::signal_contract::SignalBookReference>,
#[serde(default, alias = "strategy_id")]
pub strategy_id: Option<String>,
#[serde(default)]
@@ -643,7 +648,7 @@ fn normalize_risk_policy_aliases_in_value(value: &mut Value) -> Result<(), Strin
/// contract can legitimately arrive with both spellings. Canonicalise those
/// pairs once at the boundary, while rejecting conflicting values instead of
/// silently choosing one.
fn normalize_strategy_aliases_in_value(value: &mut Value) -> Result<(), String> {
pub fn normalize_strategy_aliases_in_value(value: &mut Value) -> Result<(), String> {
normalize_strategy_aliases_in_value_inner(value, false)
}
@@ -659,7 +664,7 @@ fn normalize_strategy_aliases_in_value_inner(
for (key, child) in object.iter_mut() {
normalize_strategy_aliases_in_value_inner(
child,
in_risk_policy || matches!(key.as_str(), "riskPolicy" | "risk_policy"),
in_risk_policy || matches!(key.as_str(), "riskPolicy" | "risk_policy" | "automaticTradeProtection" | "automatic_trade_protection"),
)?;
}
}
@@ -674,11 +679,14 @@ fn normalize_strategy_aliases_in_value_inner(
}
const STRATEGY_ALIAS_GROUPS: &[(&str, &[&str])] = &[
("signalBook", &["signal_book"]),
("signalBookRef", &["signal_book_ref"]),
("strategyId", &["strategy_id"]),
("tradeTimes", &["trade_times"]),
("signalSymbol", &["signal_symbol"]),
("engineConfig", &["engine_config"]),
("runtimeExpressions", &["runtime_expressions"]),
("automaticTradeProtection", &["automatic_trade_protection"]),
("rebalanceSchedule", &["rebalance_schedule"]),
("skipWindows", &["skip_windows"]),
("dynamicRange", &["dynamic_range"]),
@@ -713,10 +721,8 @@ const STRATEGY_ALIAS_GROUPS: &[(&str, &[&str])] = &[
),
("stampTaxRateAfterChange", &["stamp_tax_rate_after_change"]),
("stampTaxChangeDate", &["stamp_tax_change_date"]),
("volumeLimit", &["volume_limit"]),
("volumeLimitEnabled", &["volume_limit_enabled"]),
("liquidityLimit", &["liquidity_limit"]),
("liquidityLimitEnabled", &["liquidity_limit_enabled"]),
("volumeLimit", &["volume_limit", "volumeLimitEnabled", "volume_limit_enabled"]),
("liquidityLimit", &["liquidity_limit", "liquidityLimitEnabled", "liquidity_limit_enabled"]),
("volumePercent", &["volume_percent"]),
("riskPolicy", &["risk_policy"]),
("strictValueBudget", &["strict_value_budget"]),
@@ -740,6 +746,16 @@ fn strategy_alias_values_semantically_equal(left: &Value, right: &Value) -> bool
return true;
}
match (left, right) {
(Value::Number(left), Value::Number(right)) => {
const MAX_EXACT: i64 = 9_007_199_254_740_992;
let exact_integer = |value: &serde_json::Number| {
value.as_i64().filter(|v| (-MAX_EXACT..=MAX_EXACT).contains(v)).map(|v| v as f64)
.or_else(|| value.as_u64().filter(|v| *v <= MAX_EXACT as u64).map(|v| v as f64))
};
if left.is_f64() && !right.is_f64() { exact_integer(right).zip(left.as_f64()).is_some_and(|(a,b)| a==b) }
else if right.is_f64() && !left.is_f64() { exact_integer(left).zip(right.as_f64()).is_some_and(|(a,b)| a==b) }
else { false }
}
(Value::String(left), Value::String(right)) => left.trim() == right.trim(),
(Value::String(left), Value::Number(right))
| (Value::Number(right), Value::String(left)) => left
@@ -896,6 +912,8 @@ pub struct StrategyExpressionSelectionConfig {
pub current_day_precomputed_factors: Option<bool>,
#[serde(default, alias = "candidate_symbols_by_date")]
pub candidate_symbols_by_date: BTreeMap<String, Vec<String>>,
#[serde(default, alias = "preserve_candidate_order")]
pub preserve_candidate_order: bool,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
@@ -915,6 +933,8 @@ pub struct StrategyExpressionRiskConfig {
#[serde(default)]
pub portfolio_drawdown_control: Option<StrategyPortfolioDrawdownControlConfig>,
#[serde(default)]
pub portfolio_loss_control: Option<StrategyPortfolioLossControlConfig>,
#[serde(default)]
pub stop_loss_expr: Option<String>,
#[serde(default)]
pub take_profit_expr: Option<String>,
@@ -963,6 +983,16 @@ pub struct StrategyPortfolioDrawdownControlConfig {
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)]
#[serde(rename_all = "camelCase")]
pub struct StrategyExpressionOrderingConfig {
@@ -977,6 +1007,8 @@ pub struct StrategyExpressionOrderingConfig {
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct StrategyExpressionTradingConfig {
#[serde(default, alias = "automatic_trade_protection")]
pub automatic_trade_protection: Option<crate::holding_policy::AutomaticTradeProtection>,
#[serde(default, alias = "buy_filter_expr")]
pub buy_filter_expr: Option<String>,
#[serde(default)]
@@ -1498,7 +1530,6 @@ fn normalize_slippage_model_name(value: &str) -> String {
| "price_rate"
| "price_ratio_slippage"
| "priceratioslippage" => "price_ratio".to_string(),
"dynamic_volume_volatility" => "dynamic".to_string(),
other => other.to_string(),
}
}
@@ -1543,11 +1574,13 @@ fn parse_slippage_model(
impact_coefficient: Option<f64>,
volatility_coefficient: Option<f64>,
max_value: Option<f64>,
) -> Option<SlippageModel> {
let value = valid_non_negative(value);
let impact_coefficient = valid_non_negative(impact_coefficient);
let volatility_coefficient = valid_non_negative(volatility_coefficient);
let max_value = valid_non_negative(max_value);
) -> Result<SlippageModel, String> {
for (name, parameter) in [("slippageValue", value), ("slippageImpactCoefficient", impact_coefficient),
("slippageVolatilityCoefficient", volatility_coefficient), ("slippageMaxValue", max_value)] {
if parameter.is_some_and(|number| !number.is_finite() || number < 0.0) {
return Err(format!("{name} must be finite and non-negative"));
}
}
let model = model
.map(normalize_slippage_model_name)
.filter(|item| !item.is_empty())
@@ -1560,16 +1593,23 @@ fn parse_slippage_model(
});
match model.as_str() {
"none" => Some(SlippageModel::None),
"price_ratio" => Some(SlippageModel::PriceRatio(value.unwrap_or(0.0))),
"tick_size" => Some(SlippageModel::TickSize(value.unwrap_or(0.0))),
"limit_price" => Some(SlippageModel::LimitPrice),
"dynamic" => Some(SlippageModel::Dynamic(DynamicSlippageConfig::new(
impact_coefficient.unwrap_or(0.5),
volatility_coefficient.unwrap_or(0.3),
max_value.or(value).unwrap_or(0.01),
))),
_ => None,
"none" => Ok(SlippageModel::None),
"price_ratio" => Ok(SlippageModel::PriceRatio(value.unwrap_or(0.0))),
"tick_size" => Ok(SlippageModel::TickSize(value.unwrap_or(0.0))),
"limit_price" => Ok(SlippageModel::LimitPrice),
"historical_volume_volatility" => {
let max_ratio = max_value.or(value).unwrap_or(0.01);
if max_ratio >= 1.0 {
return Err("historical slippage maximum must be less than 1".into());
}
Ok(SlippageModel::HistoricalVolumeVolatility(DynamicSlippageConfig::new(
impact_coefficient.unwrap_or(0.5), volatility_coefficient.unwrap_or(0.3), max_ratio,
)))
},
"dynamic" | "dynamic_volume_volatility" => Err(
"retired_slippage_model: dynamic used unfinished daily data; explicitly select historical_volume_volatility or another supported model".into()
),
_ => Err(format!("unsupported slippageModel: {model}")),
}
}
@@ -1600,15 +1640,13 @@ fn apply_execution_behavior_overrides(
|| slippage_volatility_coefficient.is_some()
|| slippage_max_value.is_some()
{
if let Some(parsed) = parse_slippage_model(
cfg.slippage_model = parse_slippage_model(
slippage_model,
slippage_value,
slippage_impact_coefficient,
slippage_volatility_coefficient,
slippage_max_value,
) {
cfg.slippage_model = parsed;
}
)?;
}
if strict_value_budget == Some(false) {
return Err("strictValueBudget=false is not supported".to_string());
@@ -2097,12 +2135,16 @@ pub fn platform_expr_config_from_spec(
if let Some(enabled) = selection.current_day_precomputed_factors {
cfg.current_day_precomputed_factors = enabled;
}
if selection.preserve_candidate_order && selection.candidate_symbols_by_date.is_empty() {
return Err("preserveCandidateOrder requires a dated candidate book".to_string());
}
for (raw_date, raw_symbols) in &selection.candidate_symbols_by_date {
let trade_date = NaiveDate::parse_from_str(raw_date, "%Y-%m-%d").map_err(|_| {
format!("candidateSymbolsByDate contains invalid date: {raw_date}")
})?;
let mut symbols = BTreeSet::new();
for raw_symbol in raw_symbols {
let mut order = BTreeMap::new();
for (index, raw_symbol) in raw_symbols.iter().enumerate() {
let symbol = normalize_symbol(raw_symbol, None);
let valid = symbol.rsplit_once('.').is_some_and(|(code, exchange)| {
code.len() == 6
@@ -2119,8 +2161,12 @@ pub fn platform_expr_config_from_spec(
"candidateSymbolsByDate contains duplicate date/symbol: {raw_date} {symbol}"
));
}
order.insert(symbol, index);
}
cfg.candidate_symbols_by_date.insert(trade_date, symbols);
if selection.preserve_candidate_order {
cfg.candidate_order_by_date.insert(trade_date, order);
}
}
}
if let Some(allocation) = runtime_expr.allocation.as_ref()
@@ -2176,6 +2222,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()
&& control.enabled.unwrap_or(true)
{
@@ -2291,6 +2349,10 @@ pub fn platform_expr_config_from_spec(
}
}
if let Some(trading) = runtime_expr.trading.as_ref() {
if let Some(policy) = &trading.automatic_trade_protection {
policy.validate()?;
cfg.automatic_trade_protection = policy.clone();
}
if let Some(expr) = trading.buy_filter_expr.as_ref() {
cfg.buy_filter_expr = expr.clone();
}
@@ -2513,6 +2575,10 @@ pub fn platform_expr_config_from_spec(
cfg.benchmark_symbol = normalize_symbol(&cfg.benchmark_symbol, None);
}
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
.runtime_expressions
.as_ref()
@@ -2570,6 +2636,40 @@ pub fn platform_expr_config_from_spec(
}
cfg.strict_value_budget = true;
let signal_book = match (&spec.signal_book,&spec.signal_book_ref) {
(Some(_),Some(_)) => return Err("inline_and_registered_signal_book_are_mutually_exclusive".into()),
(Some(raw),None) => Some(std::sync::Arc::new(raw.clone().validate()?)),
(None,Some(reference)) => crate::signal_contract::cached_signal_book(reference)?,
(None,None) => None,
};
if let Some(book) = signal_book {
if cfg.explicit_actions.len() != 1 || !matches!(cfg.explicit_actions[0], PlatformTradeAction::ConsumeSignal) {
return Err("signal_book_requires_one_consume_signal_action".into());
}
if !cfg.signal_rebalance_dates.is_empty() && cfg.signal_rebalance_dates != book.decision_dates() {
return Err("signal_book_schedule_does_not_match_strategy".into());
}
cfg.rotation_enabled = false;
cfg.signal_rebalance_dates = book.decision_dates();
cfg.initial_subscriptions.extend(book.symbols());
cfg.signal_book = Some(book);
} else if spec.signal_book_ref.is_some() {
if cfg.explicit_actions.len()!=1 || !matches!(cfg.explicit_actions[0],PlatformTradeAction::ConsumeSignal) {
return Err("signal_book_requires_one_consume_signal_action".into());
}
cfg.rotation_enabled=false;
} else if cfg.explicit_actions.iter().any(|action| matches!(action, PlatformTradeAction::ConsumeSignal)) {
return Err("consume_signal_requires_verified_signal_book".into());
}
let has_automatic_policy = spec.runtime_expressions.as_ref().and_then(|runtime| runtime.trading.as_ref()).is_some_and(|trading| trading.automatic_trade_protection.is_some());
if has_automatic_policy {
let limit = i64::from(cfg.automatic_trade_protection.max_holding_days);
if cfg.max_holding_days.is_some_and(|previous| previous != limit) {
return Err("conflicting maximum holding policies".into());
}
cfg.max_holding_days = (limit > 0).then_some(limit);
}
Ok(cfg)
}
@@ -2722,6 +2822,7 @@ fn parse_platform_trade_action(
None => None,
};
match kind.as_str() {
"consume_signal" if when_expr.is_none() && time_in_force.is_none() => Some(PlatformTradeAction::ConsumeSignal),
"target_portfolio_smart" => Some(PlatformTradeAction::TargetPortfolioSmart {
target_weights_expr: action
.target_weights_expr
@@ -3120,6 +3221,16 @@ fn normalize_board(symbol: &str, raw_board: Option<&str>) -> String {
mod tests {
use super::*;
#[test]
fn numeric_strategy_aliases_accept_exact_zero_but_never_hide_rounding_or_conflicts() {
let cfg = platform_expr_config_from_value("fees", "000001.SZ", &serde_json::json!({
"execution":{"minimumCommission":0.0,"minimum_commission":0}
})).unwrap();
assert_eq!(cfg.minimum_commission, Some(0.0));
assert!(!strategy_alias_values_semantically_equal(&serde_json::json!(9007199254740992u64), &serde_json::json!(9007199254740993u64)));
assert!(!strategy_alias_values_semantically_equal(&serde_json::json!(0.0), &serde_json::json!(1)));
}
#[test]
fn parses_buy_filter_as_a_separate_trading_condition() {
let cfg = platform_expr_config_from_value("buy-guard", "000001.SZ", &serde_json::json!({
@@ -3234,6 +3345,25 @@ mod tests {
);
}
#[test]
fn frozen_candidate_order_is_explicit_and_preserves_source_positions() {
let mut spec = serde_json::json!({"runtimeExpressions": {"selection": {
"candidateSymbolsByDate": {
"2025-01-02": ["600000.SH", "000001.SZ"], "2025-01-03": []
}
}}});
let date = NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
let legacy = platform_expr_config_from_value("", "", &spec).unwrap();
assert!(legacy.candidate_order_by_date.is_empty());
spec["runtimeExpressions"]["selection"]["preserveCandidateOrder"] = serde_json::json!(true);
let ordered = platform_expr_config_from_value("", "", &spec).unwrap();
assert_eq!(ordered.candidate_order_by_date[&date]["600000.SH"], 0);
assert_eq!(ordered.candidate_order_by_date[&date]["000001.SZ"], 1);
assert!(ordered.candidate_order_by_date[&NaiveDate::from_ymd_opt(2025, 1, 3).unwrap()].is_empty());
spec["runtimeExpressions"]["selection"]["candidateSymbolsByDate"] = serde_json::json!({});
assert!(platform_expr_config_from_value("", "", &spec).unwrap_err().to_string().contains("dated candidate book"));
}
#[test]
fn rejects_invalid_or_duplicate_static_universe_symbols() {
let invalid = serde_json::json!({
@@ -4037,6 +4167,28 @@ mod tests {
);
}
#[test]
fn execution_limit_aliases_normalize_to_one_serde_field_without_touching_policy() {
for section in ["execution", "engineConfig"] {
let mut spec = serde_json::json!({});
spec[section] = serde_json::json!({
"volumeLimit": false, "volumeLimitEnabled": false, "volume_limit_enabled": false,
"liquidityLimit": true, "liquidityLimitEnabled": true, "liquidity_limit_enabled": true,
"riskPolicy": {"volumeLimitEnabled": false, "liquidityLimitEnabled": true}
});
let cfg = platform_expr_config_from_value("test", "000300.SH", &spec).unwrap();
assert!(!cfg.risk_config.trading_constraints.volume_limit_enabled);
assert!(cfg.risk_config.trading_constraints.liquidity_limit_enabled);
super::normalize_strategy_aliases_in_value(&mut spec).unwrap();
assert!(spec[section].get("volumeLimitEnabled").is_none());
assert!(spec[section].get("liquidity_limit_enabled").is_none());
assert_eq!(spec[section]["riskPolicy"]["liquidityLimitEnabled"], true);
spec[section]["liquidity_limit_enabled"] = serde_json::json!(false);
assert!(platform_expr_config_from_value("test", "000300.SH", &spec)
.unwrap_err().to_string().contains("conflicting alias values"));
}
}
#[test]
fn canonicalizes_duplicate_execution_aliases_without_changing_strategy_intent() {
let spec = serde_json::json!({
@@ -4220,10 +4372,10 @@ mod tests {
}
#[test]
fn parses_dynamic_slippage_into_platform_config() {
fn parses_explicit_historical_slippage_into_platform_config() {
let spec = serde_json::json!({
"execution": {
"slippageModel": "dynamic",
"slippageModel": "historical_volume_volatility",
"slippageImpactCoefficient": 0.6,
"slippageVolatilityCoefficient": 0.2,
"slippageMaxValue": 0.015
@@ -4234,10 +4386,20 @@ mod tests {
assert_eq!(
cfg.slippage_model,
SlippageModel::Dynamic(DynamicSlippageConfig::new(0.6, 0.2, 0.015))
SlippageModel::HistoricalVolumeVolatility(DynamicSlippageConfig::new(0.6, 0.2, 0.015))
);
}
#[test]
fn retired_or_unknown_slippage_models_do_not_fall_back_to_fixed_or_none() {
for model in ["dynamic", "dynamic_volume_volatility", "dynamic-volume-volatility", "unknown"] {
let spec = serde_json::json!({"execution": {"slippageModel": model, "slippageValue": 0.002}});
assert!(platform_expr_config_from_value("", "", &spec).is_err(), "{model}");
}
let spec = serde_json::json!({"execution": {"slippageModel": "historical_volume_volatility", "slippageImpactCoefficient": -1}});
assert!(platform_expr_config_from_value("", "", &spec).is_err());
}
#[test]
fn engine_stock_ma_filter_generates_price_and_volume_expr() {
let spec = serde_json::json!({
@@ -4433,6 +4595,20 @@ mod tests {
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]
fn explicit_trading_schedule_overrides_rebalance_trade_times() {
let spec = serde_json::json!({
@@ -4564,6 +4740,27 @@ mod tests {
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]
fn rejects_invalid_portfolio_drawdown_control() {
let spec = serde_json::json!({
+46 -7
View File
@@ -60,6 +60,8 @@ pub struct PositionLot {
pub struct Position {
pub symbol: String,
pub quantity: u32,
opened_date: Option<NaiveDate>,
last_buy_date: Option<NaiveDate>,
// ALV-compatible moving average execution price; partial sells do not rebase it.
pub average_price: f64,
// ALV-compatible moving average including buy costs; partial sells do not rebase it.
@@ -88,6 +90,8 @@ impl Position {
Self {
symbol: symbol.into(),
quantity: 0,
opened_date: None,
last_buy_date: None,
average_price: 0.0,
average_cost: 0.0,
last_price: 0.0,
@@ -114,6 +118,12 @@ impl Position {
self.quantity == 0
}
pub fn opened_date(&self) -> Option<NaiveDate> {
self.opened_date
}
pub fn last_buy_date(&self) -> Option<NaiveDate> { self.last_buy_date }
pub fn buy(&mut self, date: NaiveDate, quantity: u32, price: f64) {
self.buy_with_mark_price(date, quantity, price, price);
}
@@ -130,6 +140,10 @@ impl Position {
}
let previous_quantity = self.quantity;
self.last_buy_date = Some(self.last_buy_date.map_or(date, |previous| previous.max(date)));
if previous_quantity == 0 {
self.opened_date = Some(date);
}
let previous_average_price = self.average_price;
let previous_average_cost = self.average_cost;
let gross_amount = fixed_money_or_panic(
@@ -267,6 +281,7 @@ impl Position {
.checked_add(total_proceeds)
.ok_or_else(|| "fixed-point day sell value overflow".to_string())?;
if self.quantity == 0 {
self.opened_date = None;
self.average_price = 0.0;
self.recalculate_average_cost();
} else {
@@ -1047,8 +1062,6 @@ impl PortfolioState {
let unresolved_delisting = current_market_missing
&& data.instrument(&position.symbol).is_some_and(|instrument| {
instrument.is_delisted_on_or_before(date)
|| (instrument.status.eq_ignore_ascii_case("delisted")
&& instrument.delisted_at.is_none())
});
if unresolved_delisting {
position.last_price = 0.0;
@@ -1068,11 +1081,13 @@ impl PortfolioState {
position.refresh_day_pnl();
continue;
}
let confirmed_pause = data.market(date, &position.symbol).is_some_and(|row| row.paused)
|| data.candidate(date, &position.symbol).is_some_and(|row| row.is_paused);
let price = data
.price(date, &position.symbol, field)
.or_else(|| data.price_on_or_before(date, &position.symbol, field))
.or_else(|| confirmed_pause.then(|| data.price_on_or_before(date, &position.symbol, field)).flatten())
.or_else(|| {
(position.last_price.is_finite() && position.last_price > 0.0)
(confirmed_pause && position.last_price.is_finite() && position.last_price > 0.0)
.then_some(position.last_price)
})
.ok_or_else(|| DataSetError::MissingSnapshot {
@@ -1224,6 +1239,8 @@ impl PortfolioState {
}
let old_quantity = old_position.quantity;
let old_opened_date = old_position.opened_date;
let old_last_buy_date = old_position.last_buy_date;
let last_price = old_position.last_price;
let old_average_price = old_position.average_price;
let old_average_cost = old_position.average_cost;
@@ -1263,6 +1280,14 @@ impl PortfolioState {
.entry(new_symbol.to_string())
.or_insert_with(|| Position::new(new_symbol));
let successor_quantity_before = successor.quantity;
successor.opened_date = match (successor.opened_date, old_opened_date) {
(Some(current), Some(previous)) => Some(current.min(previous)),
(current, previous) => current.or(previous),
};
successor.last_buy_date = match (successor.last_buy_date, old_last_buy_date) {
(Some(current), Some(previous)) => Some(current.max(previous)),
(current, previous) => current.or(previous),
};
let successor_average_price_before = successor.average_price;
let successor_average_cost_before = successor.average_cost;
successor.lots.extend(converted_lots);
@@ -1774,7 +1799,7 @@ mod tests {
}
#[test]
fn portfolio_carries_last_price_when_position_market_row_is_missing() {
fn portfolio_missing_market_requires_formal_suspension_before_carrying_price() {
let prev_date = NaiveDate::from_ymd_opt(2025, 5, 26).unwrap();
let missing_date = NaiveDate::from_ymd_opt(2025, 5, 27).unwrap();
let mut portfolio = PortfolioState::new(10_000.0);
@@ -1832,9 +1857,23 @@ mod tests {
.update_prices(prev_date, &dataset, PriceField::Close)
.expect("previous close");
portfolio.begin_trading_day();
portfolio
let error = portfolio
.update_prices(missing_date, &dataset, PriceField::Close)
.expect("missing current row should carry previous close");
.expect_err("unclassified missing current price must not be filled from history");
assert!(error.to_string().contains("601028.SH"));
let paused_dataset = DataSet::from_components(
vec![dataset.instrument("601028.SH").unwrap().clone()],
vec![dataset.market(prev_date, "601028.SH").unwrap().clone()],
Vec::new(),
vec![crate::data::CandidateEligibility {
date: missing_date, symbol: "601028.SH".into(), is_st: false, is_star_st: false,
is_new_listing: false, is_paused: true, allow_buy: false, allow_sell: false,
is_kcb: false, is_one_yuan: false, risk_level_code: None,
}],
vec![dataset.benchmark(prev_date).unwrap().clone()],
).unwrap();
portfolio.update_prices(missing_date, &paused_dataset, PriceField::Close)
.expect("dated suspension permits keeping the last known valuation, not creating a fill");
let position = portfolio.position("601028.SH").expect("position");
assert!((position.last_price - 10.3).abs() < 1e-6);
+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());
}
}
}
+78 -13
View File
@@ -138,6 +138,16 @@ pub struct FidcRiskDecisionAudit {
}
impl FidcRiskDecisionAudit {
pub fn rejected_buy_plan(date: NaiveDate, symbol: &str, reason: &str) -> Self {
Self {
date, symbol: symbol.into(), scope: RiskCheckScope::Buy,
stage: "buy_planning".into(), accepted: false,
rule_code: reason.into(), reason: reason.into(),
config_version: Some("inline_risk_policy".into()), data_epoch: date.to_string(),
selection_batch_id: None, order_id: None,
}
}
pub fn rejected_selection(
date: NaiveDate,
symbol: impl Into<String>,
@@ -208,14 +218,8 @@ impl ChinaAShareRiskControl {
{
return Some("inactive_or_delisted");
}
let status = instrument.status.trim().to_ascii_lowercase();
let terminal_status = matches!(
status.as_str(),
"inactive" | "delisted" | "terminated" | "expired"
);
if terminal_status && instrument.delisted_at.is_none() {
return Some("inactive_or_delisted");
}
// Latest reference status has no historical as-of date. Execution-day
// risk snapshots remain authoritative; missing quotes are not waived.
None
}
@@ -410,7 +414,7 @@ impl ChinaAShareRiskControl {
}
let reject_one_yuan = match scope {
RiskCheckScope::Selection => config.static_rules.reject_one_yuan_selection,
RiskCheckScope::Buy => config.static_rules.reject_one_yuan_buy,
RiskCheckScope::Buy => false,
RiskCheckScope::Sell => false,
};
if reject_one_yuan
@@ -483,6 +487,14 @@ impl ChinaAShareRiskControl {
) {
return Some(reason);
}
if !check_price.is_finite() || check_price <= 0.0 {
return Some("invalid execution price");
}
// Daily candidate flags can describe the later close. Execution
// price constraints must use this order's actual pricing clock.
if config.static_rules.reject_one_yuan_buy && check_price <= 1.0 {
return Some("one_yuan");
}
if config.static_rules.respect_allow_buy_sell && !candidate.allow_buy {
return Some("buy_disabled");
}
@@ -664,7 +676,6 @@ fn missing_buy_risk_state_rejected(code: &str, config: &FidcRiskControlConfig) -
|| config.static_rules.reject_new_listing_buy
|| config.static_rules.reject_kcb_buy
|| config.static_rules.reject_bjse_buy
|| config.static_rules.reject_one_yuan_buy
|| config.static_rules.reject_upper_limit_buy
|| config.static_rules.respect_allow_buy_sell;
}
@@ -741,7 +752,7 @@ fn missing_single_field_rejected(
},
"is_one_yuan" | "one_yuan" => match scope {
RiskCheckScope::Selection => config.static_rules.reject_one_yuan_selection,
RiskCheckScope::Buy => config.static_rules.reject_one_yuan_buy,
RiskCheckScope::Buy => false,
RiskCheckScope::Sell => false,
},
"allow_buy" => match scope {
@@ -785,7 +796,6 @@ fn missing_single_field_rejected(
|| config.static_rules.reject_new_listing_buy
|| config.static_rules.reject_kcb_buy
|| config.static_rules.reject_bjse_buy
|| config.static_rules.reject_one_yuan_buy
|| config.static_rules.reject_upper_limit_buy
|| config.static_rules.respect_allow_buy_sell
}
@@ -843,7 +853,7 @@ mod tests {
Some(&instrument("delisted", None)),
date,
),
Some("inactive_or_delisted")
None
);
assert_eq!(
ChinaAShareRiskControl::instrument_rejection_reason(
@@ -902,6 +912,61 @@ mod tests {
position
}
#[test]
fn one_yuan_buy_rule_uses_execution_price_not_later_close_or_earlier_open() {
let day = d(2025, 2, 6);
let mut candidate = candidate(day);
let mut snapshot = market(day, 1.2, 0.5);
let config = FidcRiskControlConfig::default();
candidate.is_one_yuan = true;
snapshot.day_open = 0.9;
snapshot.close = 0.8;
assert_eq!(ChinaAShareRiskControl::buy_rejection_reason_with_config(
day, &candidate, &snapshot, None, 1.2, &config), None);
candidate.is_one_yuan = false;
snapshot.day_open = 1.2;
snapshot.close = 1.3;
for price in [0.9, 1.0] {
assert_eq!(ChinaAShareRiskControl::buy_rejection_reason_with_config(
day, &candidate, &snapshot, None, price, &config), Some("one_yuan"));
}
let mut relaxed = config;
relaxed.static_rules.reject_one_yuan_buy = false;
assert_eq!(ChinaAShareRiskControl::buy_rejection_reason_with_config(
day, &candidate, &snapshot, None, 0.9, &relaxed), None);
}
#[test]
fn execution_quote_covers_missing_one_yuan_flag_but_not_other_risk_facts() {
let day = d(2025, 2, 6);
let mut candidate = candidate(day);
let snapshot = market(day, 1.2, 0.5);
let config = FidcRiskControlConfig::default();
candidate.risk_level_code = Some("missing_risk_state:is_one_yuan".into());
assert_eq!(ChinaAShareRiskControl::buy_rejection_reason_with_config(
day, &candidate, &snapshot, None, 1.2, &config), None);
candidate.risk_level_code = Some("missing_risk_state:is_st".into());
assert_eq!(ChinaAShareRiskControl::buy_rejection_reason_with_config(
day, &candidate, &snapshot, None, 1.2, &config), Some("missing_risk_state"));
candidate.risk_level_code = None;
for price in [0.0, f64::NAN, f64::INFINITY] {
assert_eq!(ChinaAShareRiskControl::buy_rejection_reason_with_config(
day, &candidate, &snapshot, None, price, &config), Some("invalid execution price"));
}
}
#[test]
fn explicit_one_yuan_selection_policy_still_uses_selection_facts() {
let day = d(2025, 2, 6);
let mut candidate = candidate(day);
candidate.is_one_yuan = true;
let snapshot = market(day, 1.2, 0.5);
let mut config = FidcRiskControlConfig::default();
config.static_rules.reject_one_yuan_selection = true;
assert_eq!(ChinaAShareRiskControl::selection_rejection_reason_with_config(
day, &candidate, &snapshot, None, &config), Some("one_yuan"));
}
#[test]
fn sell_rejection_respects_allow_sell_policy_on_execution_day() {
let prev_date = d(2024, 4, 16);
+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);
}
}
+590
View File
@@ -0,0 +1,590 @@
//! Immutable, account-independent trading signals. Quantity and execution
//! prices are intentionally absent; the existing broker owns those decisions.
use std::collections::{BTreeMap, BTreeSet};
use std::sync::{Arc, Mutex, OnceLock, Weak};
use chrono::{DateTime, FixedOffset, NaiveDate, NaiveDateTime, NaiveTime, Utc};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::strategy::{OrderIntent, StrategyContext};
use crate::portfolio::PortfolioState;
pub const SIGNAL_BOOK_SCHEMA: &str = "fidc.signal-book/v2";
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct SignalBookReference {
pub book_id: String,
pub version_sha256: String,
pub artifact_sha256: String,
}
impl SignalBookReference {
pub fn validate(&self) -> Result<(), String> {
if !valid_sha(&self.version_sha256) || !valid_sha(&self.artifact_sha256)
|| self.book_id != format!("signal_book_{}",self.version_sha256)
{ return Err("signal_book_reference_invalid".into()); }
Ok(())
}
}
#[derive(Default)]
struct SignalCache {
entries: BTreeMap<String,Weak<ValidatedSignalBook>>,
retained: std::collections::VecDeque<(String,Arc<ValidatedSignalBook>,usize)>,
}
fn signal_cache() -> &'static Mutex<SignalCache> {
static CACHE: OnceLock<Mutex<SignalCache>> = OnceLock::new();
CACHE.get_or_init(||Mutex::new(SignalCache::default()))
}
pub fn cached_signal_book(reference: &SignalBookReference) -> Result<Option<Arc<ValidatedSignalBook>>,String> {
reference.validate()?;
let cache=signal_cache().lock().map_err(|_|"signal_cache_lock_failed")?;
let book=cache.entries.get(&reference.artifact_sha256).and_then(Weak::upgrade);
if book.as_ref().is_some_and(|book|book.version_sha256()!=reference.version_sha256) {
return Err("signal_book_cached_version_mismatch".into());
}
Ok(book)
}
pub fn register_signal_book(reference: &SignalBookReference, body: &[u8]) -> Result<Arc<ValidatedSignalBook>,String> {
use sha2::{Digest,Sha256};
reference.validate()?;
if body.len()>64*1024*1024 || format!("{:x}",Sha256::digest(body))!=reference.artifact_sha256 {
return Err("signal_book_artifact_hash_or_size_invalid".into());
}
let raw:SignalBook=serde_json::from_slice(body).map_err(|error|format!("signal_book_decode_failed: {error}"))?;
if raw.version_sha256!=reference.version_sha256 { return Err("signal_book_version_mismatch".into()); }
let book=Arc::new(raw.validate()?);
let mut cache=signal_cache().lock().map_err(|_|"signal_cache_lock_failed")?;
cache.entries.retain(|_,value|value.strong_count()>0);
if let Some(existing)=cache.entries.get(&reference.artifact_sha256).and_then(Weak::upgrade) { return Ok(existing); }
cache.entries.insert(reference.artifact_sha256.clone(),Arc::downgrade(&book));
let estimated=body.len().saturating_mul(4);
if estimated<=128*1024*1024 {
cache.retained.push_back((reference.artifact_sha256.clone(),book.clone(),estimated));
while cache.retained.len()>4 || cache.retained.iter().map(|entry|entry.2).sum::<usize>()>128*1024*1024 {
cache.retained.pop_front();
}
}
Ok(book)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum SignalProvenance {
Observed,
Reconstructed,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum SignalFrequency {
Daily,
Minute,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum SignalAction {
TargetWeight { symbol: String, weight: f64 },
BuyCondition { symbol: String, allowed: bool },
Exit { symbol: String },
Reduce { symbol: String, remaining_ratio: f64 },
}
impl SignalAction {
fn symbol(&self) -> &str {
match self {
Self::TargetWeight { symbol, .. }
| Self::BuyCondition { symbol, .. }
| Self::Exit { symbol }
| Self::Reduce { symbol, .. } => symbol,
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct SignalSnapshot {
pub signal_at: DateTime<Utc>,
pub decision_at: DateTime<Utc>,
pub input_as_of: DateTime<Utc>,
pub input_available_at: DateTime<Utc>,
pub generated_at: DateTime<Utc>,
pub published_at: DateTime<Utc>,
pub input_sha256: String,
pub complete_targets: bool,
pub actions: Vec<SignalAction>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct SignalBook {
pub schema: String,
pub version_sha256: String,
pub generator_sha256: String,
pub model_sha256: Option<String>,
pub knowledge_cutoff: Option<DateTime<Utc>>,
pub provenance: SignalProvenance,
pub frequency: SignalFrequency,
pub expected_decisions: Vec<DateTime<Utc>>,
pub snapshots: Vec<SignalSnapshot>,
}
#[derive(Debug, Clone)]
pub struct ValidatedSignalBook {
book: SignalBook,
index: BTreeMap<NaiveDateTime, usize>,
}
fn valid_sha(value: &str) -> bool {
value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
}
fn shanghai(value: DateTime<Utc>) -> NaiveDateTime {
value.with_timezone(&FixedOffset::east_opt(8 * 3600).expect("Shanghai offset")).naive_local()
}
impl SignalBook {
pub fn content_sha256(&self) -> Result<String, String> {
let mut value=serde_json::to_value(self).map_err(|error|error.to_string())?;
value.as_object_mut().ok_or("signal_book_object_required")?.remove("versionSha256");
value["knowledgeCutoff"]=self.knowledge_cutoff.map(|at|serde_json::json!(at.timestamp_micros())).unwrap_or(serde_json::Value::Null);
value["expectedDecisions"]=serde_json::json!(self.expected_decisions.iter().map(DateTime::timestamp_micros).collect::<Vec<_>>());
for (raw,snapshot) in value["snapshots"].as_array_mut().ok_or("signal_snapshots_required")?.iter_mut().zip(&self.snapshots) {
let object=raw.as_object_mut().ok_or("signal_snapshot_required")?;
object.remove("generatedAt");
object.remove("publishedAt");
for (key,at) in [("signalAt",snapshot.signal_at),("decisionAt",snapshot.decision_at),
("inputAsOf",snapshot.input_as_of),("inputAvailableAt",snapshot.input_available_at)] {
object.insert(key.into(),serde_json::json!(at.timestamp_micros()));
}
for (raw,action) in object.get_mut("actions").and_then(serde_json::Value::as_array_mut).ok_or("signal_actions_required")?.iter_mut().zip(&snapshot.actions) {
match action {
SignalAction::TargetWeight{weight,..}=>raw["weight"]=serde_json::json!(format!("{:016x}",weight.to_bits())),
SignalAction::Reduce{remaining_ratio,..}=>raw["remaining_ratio"]=serde_json::json!(format!("{:016x}",remaining_ratio.to_bits())),
_=>{}
}
}
}
fn sorted(value:serde_json::Value)->serde_json::Value {
match value {
serde_json::Value::Object(map)=>serde_json::Value::Object(map.into_iter().map(|(key,value)|(key,sorted(value)))
.collect::<BTreeMap<_,_>>().into_iter().collect()),
serde_json::Value::Array(rows)=>serde_json::Value::Array(rows.into_iter().map(sorted).collect()),
other=>other,
}
}
let raw=serde_json::to_vec(&sorted(value)).map_err(|error|error.to_string())?;
Ok(format!("{:x}",Sha256::digest(raw)))
}
pub fn validate(self) -> Result<ValidatedSignalBook, String> {
if self.schema != SIGNAL_BOOK_SCHEMA || !valid_sha(&self.version_sha256)
|| !valid_sha(&self.generator_sha256)
{
return Err("signal_book_identity_invalid".into());
}
if self.model_sha256.as_ref().is_some_and(|value| !valid_sha(value))
|| self.model_sha256.is_some() != self.knowledge_cutoff.is_some()
{ return Err("signal_model_training_identity_incomplete".into()); }
if self.expected_decisions.is_empty() || self.expected_decisions.len() > 100_000
|| self.expected_decisions.len() != self.snapshots.len()
{
return Err("signal_book_decision_coverage_incomplete".into());
}
let mut index = BTreeMap::new();
let mut previous = None;
let mut total_actions = 0usize;
for (number, (expected, snapshot)) in self.expected_decisions.iter().zip(&self.snapshots).enumerate() {
if [*expected,snapshot.signal_at,snapshot.input_as_of,snapshot.input_available_at,snapshot.generated_at,snapshot.published_at]
.iter().any(|at|at.timestamp_subsec_nanos()%1000!=0) || self.knowledge_cutoff.is_some_and(|at|at.timestamp_subsec_nanos()%1000!=0) {
return Err("signal_timestamp_requires_microsecond_precision".into());
}
if snapshot.decision_at != *expected || previous.is_some_and(|value| value >= *expected) {
return Err("signal_book_decisions_duplicate_or_unordered".into());
}
previous = Some(*expected);
if self.knowledge_cutoff.is_some_and(|cutoff| cutoff > snapshot.signal_at) || snapshot.signal_at > *expected
|| snapshot.input_available_at > snapshot.signal_at || snapshot.input_as_of > snapshot.input_available_at
|| snapshot.published_at < snapshot.generated_at || !valid_sha(&snapshot.input_sha256)
|| snapshot.generated_at < snapshot.input_available_at
|| self.knowledge_cutoff.is_some_and(|cutoff| snapshot.generated_at < cutoff)
{
return Err("signal_book_future_or_invalid_input".into());
}
if self.provenance == SignalProvenance::Observed && snapshot.published_at > *expected {
return Err("observed_signal_not_available_at_decision".into());
}
total_actions = total_actions.checked_add(snapshot.actions.len()).ok_or("signal_book_action_limit")?;
if total_actions > 2_000_000 { return Err("signal_book_action_limit".into()); }
let mut action_keys = BTreeSet::new();
let mut target_symbols = BTreeSet::new();
let mut reductions = BTreeSet::new();
let mut total_weight = 0.0;
for action in &snapshot.actions {
let symbol = action.symbol();
if symbol.is_empty() || symbol.trim() != symbol { return Err("signal_symbol_invalid".into()); }
let kind = match action {
SignalAction::TargetWeight { weight, .. } => {
if !weight.is_finite() || !(0.0..=1.0).contains(weight) { return Err("signal_target_weight_invalid".into()); }
target_symbols.insert(symbol);
total_weight += weight;
"target"
}
SignalAction::BuyCondition { .. } => "buy_condition",
SignalAction::Exit { .. } => { reductions.insert(symbol); "exit" }
SignalAction::Reduce { remaining_ratio, .. } => {
if !remaining_ratio.is_finite() || !(0.0..1.0).contains(remaining_ratio) { return Err("signal_reduction_invalid".into()); }
reductions.insert(symbol);
"reduce"
}
};
if !action_keys.insert((symbol, kind)) { return Err("signal_action_duplicate".into()); }
}
if total_weight > 1.0 + 1e-12 { return Err("signal_target_exposure_exceeds_one".into()); }
if snapshot.complete_targets && !reductions.is_empty() {
return Err("complete_target_snapshot_cannot_mix_relative_exits".into());
}
if !target_symbols.is_disjoint(&reductions) { return Err("signal_target_exit_conflict".into()); }
for symbol in &reductions {
if action_keys.contains(&(*symbol, "exit")) && action_keys.contains(&(*symbol, "reduce")) {
return Err("signal_exit_reduction_conflict".into());
}
}
index.insert(shanghai(*expected), number);
}
if self.content_sha256()? != self.version_sha256 {
return Err("signal_book_content_hash_mismatch".into());
}
Ok(ValidatedSignalBook { book: self, index })
}
}
impl ValidatedSignalBook {
pub fn require_observed(&self) -> Result<(), String> {
if self.book.provenance != SignalProvenance::Observed {
return Err("reconstructed_signal_forbidden_in_online_execution".into());
}
Ok(())
}
pub fn version_sha256(&self) -> &str { &self.book.version_sha256 }
pub fn generator_sha256(&self) -> &str { &self.book.generator_sha256 }
pub fn decision_dates(&self) -> BTreeSet<NaiveDate> {
self.index.keys().map(|value| value.date()).collect()
}
pub fn symbols(&self) -> BTreeSet<String> {
self.book.snapshots.iter().flat_map(|snapshot| &snapshot.actions)
.map(|action| action.symbol().to_owned()).collect()
}
pub fn snapshot_for(&self, ctx: &StrategyContext<'_>) -> Result<&SignalSnapshot, String> {
let snapshot = self.snapshot_at(ctx.execution_date, ctx.current_time(), ctx.is_lagged_execution())?;
if self.book.provenance == SignalProvenance::Observed && ctx.current_datetime().is_none() {
return Err("observed_signal_consumption_clock_missing".into());
}
let consumption_clock=ctx.current_datetime()
.unwrap_or(ctx.decision_date.and_hms_opt(15,0,0).expect("completed decision session"));
let lagged_daily=ctx.is_lagged_execution() && self.book.frequency==SignalFrequency::Daily;
if lagged_daily && shanghai(snapshot.input_as_of).date()>ctx.decision_date {
return Err("next_open_signal_contains_execution_session_inputs".into());
}
if shanghai(snapshot.input_available_at)>consumption_clock || shanghai(snapshot.signal_at)>consumption_clock {
return Err("signal_not_available_at_consumption_clock".into());
}
if self.book.provenance == SignalProvenance::Observed
&& (shanghai(snapshot.generated_at)>consumption_clock || shanghai(snapshot.published_at)>consumption_clock) {
return Err("observed_signal_published_after_consumption_clock".into());
}
Ok(snapshot)
}
pub fn is_due_on(&self, execution_date: NaiveDate) -> bool {
self.index.range(execution_date.and_hms_opt(0,0,0).expect("session start")..)
.next().is_some_and(|(at,_)|at.date()==execution_date)
}
fn snapshot_at(&self, execution_date: NaiveDate, current_time: Option<NaiveTime>, lagged: bool) -> Result<&SignalSnapshot, String> {
let at = if self.book.frequency == SignalFrequency::Daily && lagged {
execution_date.and_hms_opt(9, 30, 0).expect("next open")
} else {
execution_date.and_time(current_time.unwrap_or(NaiveTime::from_hms_opt(15, 0, 0).expect("daily close")))
};
self.index.get(&at).map(|index| &self.book.snapshots[*index])
.ok_or_else(|| format!("signal_snapshot_missing_at_decision: {at}"))
}
pub fn intents(&self, ctx: &StrategyContext<'_>) -> Result<Vec<OrderIntent>, String> {
let snapshot = self.snapshot_for(ctx)?;
self.snapshot_intents(snapshot, ctx.portfolio)
}
fn snapshot_intents(&self, snapshot: &SignalSnapshot, portfolio: &PortfolioState) -> Result<Vec<OrderIntent>, String> {
let reason = format!("信号执行 version={} decision={}", self.book.version_sha256, snapshot.decision_at);
let mut intents = Vec::new();
let mut weights = BTreeMap::new();
for action in &snapshot.actions {
match action {
SignalAction::TargetWeight { symbol, weight } if snapshot.complete_targets => {
weights.insert(symbol.clone(), *weight);
}
SignalAction::TargetWeight { symbol, weight } => intents.push(OrderIntent::TargetPercent {
symbol: symbol.clone(), target_percent: *weight, reason: reason.clone(),
}),
SignalAction::Exit { symbol } => intents.push(OrderIntent::TargetPercent {
symbol: symbol.clone(), target_percent: 0.0, reason: reason.clone(),
}),
SignalAction::Reduce { symbol, remaining_ratio } => {
if let Some(position) = portfolio.position(symbol).filter(|position| position.quantity > 0) {
let quantity = (f64::from(position.quantity) * remaining_ratio).floor() as u32;
let target_quantity = i32::try_from(quantity).map_err(|_| "signal_reduction_quantity_overflow")?;
intents.push(OrderIntent::TargetShares { symbol: symbol.clone(), target_quantity, reason: reason.clone() });
}
}
SignalAction::BuyCondition { .. } => {}
}
}
if snapshot.complete_targets {
if weights.is_empty() {
for position in portfolio.positions().values().filter(|position| position.quantity > 0) {
intents.push(OrderIntent::TargetPercent { symbol: position.symbol.clone(), target_percent: 0.0, reason: reason.clone() });
}
} else {
intents.push(OrderIntent::TargetPortfolioSmart { target_weights: weights,
order_prices: None, valuation_prices: None, reason });
}
}
Ok(intents)
}
pub fn buy_denials(&self, ctx: &StrategyContext<'_>) -> Result<BTreeMap<String, String>, String> {
Ok(self.snapshot_for(ctx)?.actions.iter().filter_map(|action| match action {
SignalAction::BuyCondition { symbol, allowed: false } => Some((symbol.clone(), "信号买入条件未满足".into())),
_ => None,
}).collect())
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Duration;
use serde_json::json;
fn book() -> SignalBook {
let decision: DateTime<Utc> = "2025-01-07T09:30:00+08:00".parse().unwrap();
let source: DateTime<Utc> = "2025-01-06T15:00:00+08:00".parse().unwrap();
seal(SignalBook {
schema: SIGNAL_BOOK_SCHEMA.into(), version_sha256: "a".repeat(64), generator_sha256: "b".repeat(64),
model_sha256: Some("d".repeat(64)),
knowledge_cutoff: Some("2024-12-31T15:00:00+08:00".parse().unwrap()),
provenance: SignalProvenance::Reconstructed, frequency: SignalFrequency::Daily,
expected_decisions: vec![decision], snapshots: vec![SignalSnapshot {
signal_at: source,
decision_at: decision, input_as_of: source, input_available_at: source,
generated_at: decision + Duration::days(10), published_at: decision + Duration::days(10),
input_sha256: "c".repeat(64), complete_targets: true,
actions: vec![SignalAction::TargetWeight { symbol: "000001.SZ".into(), weight: 0.5 }],
}],
})
}
fn seal(mut book:SignalBook)->SignalBook {
book.version_sha256=book.content_sha256().unwrap();
book
}
fn at_context<T>(at: Option<NaiveDateTime>, action: impl FnOnce(&StrategyContext<'_>) -> T) -> T {
let data = crate::DataSet::from_components(vec![], vec![], vec![], vec![], vec![crate::BenchmarkSnapshot {
date:NaiveDate::from_ymd_opt(2025,1,6).unwrap(), benchmark:"clock-fixture".into(),
open:100.0, close:100.0, prev_close:100.0, volume:1,
}]).unwrap();
let portfolio = PortfolioState::new(10_000.0);
let symbols = BTreeSet::new();
action(&StrategyContext {
execution_date: NaiveDate::from_ymd_opt(2025,1,7).unwrap(),
decision_date: NaiveDate::from_ymd_opt(2025,1,6).unwrap(), decision_index:0,
data:&data, portfolio:&portfolio, futures_account:None, open_orders:&[],
dynamic_universe:None, subscriptions:&symbols, process_events:&[], active_process_event:None,
active_datetime:at, order_events:&[], fills:&[],
})
}
#[test]
fn observed_next_open_never_backdates_a_morning_publication_into_yesterdays_orders() {
let mut raw = book();
raw.provenance=SignalProvenance::Observed;
raw.snapshots[0].generated_at="2025-01-07T08:45:00+08:00".parse().unwrap();
raw.snapshots[0].published_at="2025-01-07T08:46:00+08:00".parse().unwrap();
let value=seal(raw).validate().unwrap();
for clock in ["2025-01-06T15:00:00", "2025-01-07T08:45:00"] {
at_context(Some(clock.parse().unwrap()), |ctx| {
assert_eq!(value.intents(ctx).unwrap_err(),"observed_signal_published_after_consumption_clock");
assert!(ctx.portfolio.positions().is_empty());
});
}
at_context(Some("2025-01-07T09:30:00".parse().unwrap()), |ctx| {
assert_eq!(value.intents(ctx).unwrap().len(),1);
assert!(ctx.portfolio.positions().is_empty());
});
at_context(None, |ctx| assert_eq!(value.intents(ctx).unwrap_err(),"observed_signal_consumption_clock_missing"));
}
#[test]
fn reconstruction_ignores_research_wall_clock_but_never_early_input_availability() {
let value=book().validate().unwrap();
at_context(Some("2025-01-06T15:00:00".parse().unwrap()), |ctx| assert!(value.intents(ctx).is_ok()));
at_context(Some("2025-01-06T14:59:59".parse().unwrap()), |ctx| {
assert_eq!(value.intents(ctx).unwrap_err(),"signal_not_available_at_consumption_clock");
});
let mut raw=book();
raw.snapshots[0].input_as_of="2025-01-07T08:30:00+08:00".parse().unwrap();
raw.snapshots[0].input_available_at=raw.snapshots[0].input_as_of;
raw.snapshots[0].signal_at=raw.snapshots[0].input_as_of;
let value=seal(raw).validate().unwrap();
at_context(Some("2025-01-07T09:30:00".parse().unwrap()), |ctx| {
assert_eq!(value.intents(ctx).unwrap_err(),"next_open_signal_contains_execution_session_inputs");
});
}
#[test]
fn historical_reconstruction_is_not_online_publication() {
let validated = book().validate().unwrap();
assert!(validated.require_observed().unwrap_err().contains("reconstructed"));
let mut observed = book();
observed.provenance = SignalProvenance::Observed;
assert!(observed.clone().validate().unwrap_err().contains("not_available"));
observed.snapshots[0].generated_at = observed.snapshots[0].decision_at;
observed.snapshots[0].published_at = observed.snapshots[0].decision_at;
seal(observed).validate().unwrap().require_observed().unwrap();
}
#[test]
fn rejects_future_inputs_and_model_knowledge() {
for field in 0..3 {
let mut value = book();
let future = value.snapshots[0].decision_at + Duration::seconds(1);
match field {
0 => value.snapshots[0].input_as_of = future,
1 => value.snapshots[0].input_available_at = future,
_ => value.knowledge_cutoff = Some(future),
}
assert!(value.validate().unwrap_err().contains("future"));
}
}
#[test]
fn rejects_quantities_prices_and_unknown_signal_fields() {
for name in ["quantity", "execution_price", "account_id", "cash"] {
let mut action = json!({"kind":"target_weight","symbol":"000001.SZ","weight":0.5});
action[name] = json!(100);
assert!(serde_json::from_value::<SignalAction>(action).is_err());
}
}
#[test]
fn coverage_and_duplicate_actions_fail_closed() {
let mut value = book();
value.expected_decisions.push(value.expected_decisions[0] + Duration::days(1));
assert!(value.validate().unwrap_err().contains("coverage"));
let mut value = book();
value.snapshots.push(value.snapshots[0].clone());
value.expected_decisions.push(value.expected_decisions[0]);
assert!(value.validate().unwrap_err().contains("duplicate"));
let mut value = book();
let repeated = value.snapshots[0].actions[0].clone();
value.snapshots[0].actions.push(repeated);
assert!(value.validate().unwrap_err().contains("duplicate"));
}
#[test]
fn rejects_overallocation_nonfinite_and_ambiguous_actions() {
for weight in [f64::NAN, f64::INFINITY, -0.1, 1.1] {
let mut value = book();
value.snapshots[0].actions[0] = SignalAction::TargetWeight { symbol: "000001.SZ".into(), weight };
assert!(value.validate().is_err());
}
let mut value = book();
value.snapshots[0].actions.push(SignalAction::TargetWeight { symbol:"000002.SZ".into(),weight:0.6 });
assert!(value.validate().unwrap_err().contains("exposure"));
let mut value = book();
value.snapshots[0].actions.push(SignalAction::Exit {symbol:"000001.SZ".into()});
assert!(value.validate().is_err());
}
#[test]
fn next_open_uses_decision_session_and_never_nearest_signal() {
let value = book().validate().unwrap();
let day = NaiveDate::from_ymd_opt(2025,1,7).unwrap();
assert!(value.snapshot_at(day, NaiveTime::from_hms_opt(9,30,0), true).is_ok());
assert!(value.snapshot_at(day, NaiveTime::from_hms_opt(14,59,0), false).is_err());
assert!(value.snapshot_at(day + Duration::days(1), None, true).is_err());
}
#[test]
fn reduction_is_resolved_from_each_accounts_actual_position() {
let mut raw = book();
raw.snapshots[0].complete_targets = false;
raw.snapshots[0].actions = vec![SignalAction::Reduce {symbol:"000001.SZ".into(),remaining_ratio:0.5}];
let value = seal(raw).validate().unwrap();
let day = NaiveDate::from_ymd_opt(2025,1,3).unwrap();
for (held, expected) in [(1000,500),(3000,1500)] {
let mut portfolio = PortfolioState::new(100_000.0);
portfolio.position_mut("000001.SZ").buy(day,held,10.0);
let result = value.snapshot_intents(&value.book.snapshots[0],&portfolio).unwrap();
assert!(matches!(result[0],OrderIntent::TargetShares {target_quantity,..} if target_quantity==expected));
assert_eq!(portfolio.position("000001.SZ").unwrap().quantity,held);
}
assert!(value.snapshot_intents(&value.book.snapshots[0],&PortfolioState::new(10_000.0)).unwrap().is_empty());
}
#[test]
fn empty_complete_snapshot_clears_only_that_accounts_holdings() {
let mut raw = book();
raw.snapshots[0].actions.clear();
let value = seal(raw).validate().unwrap();
let day = NaiveDate::from_ymd_opt(2025,1,3).unwrap();
let mut portfolio = PortfolioState::new(100_000.0);
portfolio.position_mut("000002.SZ").buy(day,200,10.0);
let result = value.snapshot_intents(&value.book.snapshots[0],&portfolio).unwrap();
assert!(matches!(&result[0],OrderIntent::TargetPercent {symbol,target_percent,..} if symbol=="000002.SZ" && *target_percent==0.0));
}
#[test]
fn platform_spec_consumes_book_without_running_another_selection() {
let spec = json!({"signalBook":book(),"runtimeExpressions":{"trading":{"actions":[{"kind":"consume_signal"}]}}});
let config = crate::platform_strategy_spec::platform_expr_config_from_value("signal-fixture","000001.SZ",&spec).unwrap();
assert!(!config.rotation_enabled && config.signal_book.is_some());
assert!(matches!(config.explicit_actions.as_slice(),[crate::PlatformTradeAction::ConsumeSignal]));
}
#[test]
fn changed_valid_contents_must_not_reuse_a_version_hash() {
let mut raw=book();
raw.snapshots[0].actions=vec![SignalAction::TargetWeight{symbol:"000001.SZ".into(),weight:0.4}];
assert_eq!(raw.clone().validate().unwrap_err(),"signal_book_content_hash_mismatch");
seal(raw).validate().unwrap();
}
#[test]
fn completed_daily_inputs_may_be_published_after_market_close() {
let mut raw=book();
raw.expected_decisions=vec!["2026-07-07T09:30:00+08:00".parse().unwrap()];
raw.snapshots[0].decision_at=raw.expected_decisions[0];
raw.snapshots[0].input_as_of="2026-07-06T15:30:00+08:00".parse().unwrap();
raw.snapshots[0].input_available_at="2026-07-06T16:00:00+08:00".parse().unwrap();
raw.snapshots[0].signal_at=raw.snapshots[0].input_available_at;
raw.snapshots[0].generated_at=raw.snapshots[0].input_available_at;
raw.snapshots[0].published_at=raw.snapshots[0].generated_at;
raw.provenance=SignalProvenance::Observed;
seal(raw).validate().unwrap().require_observed().unwrap();
}
}
+4
View File
@@ -264,6 +264,10 @@ pub fn built_in_strategy_manual() -> StrategyAiManual {
title: "期货 runtime action 与提交校验".to_string(),
detail: "runtimeExpressions.trading.actions 支持 futures_order、futures_open、futures_close、futures_close_today、futures_close_yesterday;字段包括 symbol、direction=long|short、quantityExpr/amountExpr、可选 limitPriceExpr、transactionCostExpr、whenExpr 和 reason。期货-only 策略把请求初始资金分配给期货账户且股票账户为0;股票+期货混合策略必须显式声明 futuresInitialCash,可选 stockInitialCash。合约必须先由 Source Lake 发布 futures_contract_daily、futures_contract_spec_history、futures_cost_margin_history 三张真实数据集;缺任一张时生成/回测必须失败,禁止手写默认乘数、保证金、费用或价格。订单进入撮合前继续检查上市/退市日期、停牌、trading_phase、限价 tick、涨跌停、反向挂单自成交、保证金和可平今昨仓。".to_string(),
},
ManualSection {
title: "trading.automatic_trade_protection(...)".to_string(),
detail: r#"当前股票/ETF策略的独立自动交易保护:trading.automatic_trade_protection({"buy_protection_days":3,"sell_cooldown_days":3,"max_holding_days":90,"locks":[{"symbol":"000001.SZ","start_date":"2026-09-11","end_date":null}]})。配置冻结到 runtimeExpressions.trading.automaticTradeProtection,回测、paper/live 共用内核;不并入全局风控。0/null/未填关闭对应周期;成交日及之后N个完整正式交易日内,买入保护禁止自动卖出及止盈止损,卖出冷却禁止自动增加仓位;只由真实成交启动或延长,拒绝/未成交/撤单不启动。最长持有按首次实际建仓后的正式交易日计数,加仓与部分卖出不重置,清仓后再开仓重置;日期锁定两端包含且高于自动退出,持仓占用真实预算和槽位。人工交易通过独立服务路径执行,仍校验权限、券商及T+1,不接受客户端origin旁路。持仓来源、实际成交或正式日历缺失时明确拒绝;期货与股票期货混合账户尚不支持此能力,不得悄悄忽略。旧trading.max_holding_days仍保留旧含义,不得和新配置声明不同最大周期。"#.to_string(),
},
ManualSection {
title: "trading.rotation / order.* / order.modify / cancel.* / update_universe / subscribe".to_string(),
detail: "支持股票显式下单、期货 runtime action、撤单、AlgoOrder、动态 universe 和账户资金动作。可以用 trading.rotation(false) 关闭默认轮动链路,再用 trading.stage(\"open_auction\" | \"on_day\") 指定执行阶段;需要模拟 平台内核 的日内订阅保护时,可写 trading.subscription_guard(true),未订阅 symbol 的显式订单会被拦截,TargetPortfolioSmart + AlgoOrder 会过滤未订阅标的。用 trading.schedule.daily().at([\"10:18\"]) / trading.schedule.weekly(weekday=5).at([\"10:18\"]) / trading.schedule.weekly(tradingday=-1).at([\"10:18\"]) / trading.schedule.monthly(tradingday=1).at([\"10:18\"]) 指定触发频率和分钟级 time_rule,然后写 order.shares(\"600000.SH\", 1000)、order.target_shares(\"600000.SH\", 2000)、order.value(\"600000.SH\", cash * 0.25)、order.target_percent(\"600000.SH\", 0.05)、order.limit_value(\"600000.SH\", cash * 0.25, open * 0.99, time_in_force=\"gtc\")、order.vwap_value(\"600000.SH\", cash * 0.25, \"09:31\", \"09:40\")、order.twap_percent(\"600000.SH\", 0.05, \"10:00\", \"10:30\")、order.target_portfolio_smart(weights={\"600000.SH\": 0.3, \"000001.SZ\": 0.2}, order_prices=VWAPOrder(930, 940), valuation_prices={\"600000.SH\": prev_close})、cancel.order(12345)、cancel.symbol(\"600000.SH\")、cancel.all()、update_universe([\"600000.SH\", \"000001.SZ\"])、subscribe([\"000001.SZ\"])、unsubscribe([\"000001.SZ\"])、account.deposit_withdraw(100000, receiving_days=0)、account.finance_repay(50000)、account.set_management_fee_rate(0.001)。股票订单和 target_portfolio_smart 支持可选关键字 time_in_force=\"day|ioc|fok|gtc\",编译后写入 runtimeExpressions.trading.actions[].timeInForceDAY 日内保留并在收盘 Expired,IOC 立即撤销未成交余量,FOK 必须全量可成交否则零成交,GTC 仅支持限价单并跨交易日保留;VWAP/TWAP 不接受 FOK/GTC。期货 action 必须由编译器写入结构化 runtimeExpressions,不得让策略源码直接构造 FuturesOrderIntent 或硬编码合约参数。symbol 使用标准证券/合约代码;数量、金额、仓位、时间窗、限价、order_id 和 symbol 列表都支持表达式;这些语句也支持放进 when/unless 条件块。".to_string(),
@@ -0,0 +1,336 @@
use chrono::NaiveDate;
use fidc_core::holding_policy::{AutomaticTradeLock, AutomaticTradeProtection};
use fidc_core::{
BacktestConfig, BacktestEngine, BenchmarkSnapshot, BrokerSimulator, CandidateEligibility,
ChinaAShareCostModel, ChinaEquityRuleHooks, DailyMarketSnapshot, DataSet, Instrument,
MatchingType, OrderSide, PlatformExplicitOrderKind, PlatformExprStrategy,
PlatformExprStrategyConfig, PlatformTradeAction, PriceField,
};
fn d(day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(2026, 9, day).unwrap()
}
fn data() -> DataSet {
let dates = [11, 14, 15, 16, 17, 18].map(d);
DataSet::from_components(
vec![Instrument {
symbol: "000001.SZ".into(),
name: "测试".into(),
board: "SZ".into(),
round_lot: 100,
listed_at: Some(NaiveDate::from_ymd_opt(2020, 1, 1).unwrap()),
delisted_at: None,
status: "active".into(),
}],
dates
.iter()
.map(|date| DailyMarketSnapshot {
date: *date,
symbol: "000001.SZ".into(),
timestamp: Some(format!("{date} 15:00:00")),
day_open: 10.0,
open: 10.0,
high: 10.0,
low: 10.0,
close: 10.0,
last_price: 10.0,
bid1: 10.0,
ask1: 10.0,
prev_close: 10.0,
volume: 100_000,
minute_volume: 100_000,
bid1_volume: 100_000,
ask1_volume: 100_000,
trading_phase: Some("continuous".into()),
paused: false,
upper_limit: 11.0,
lower_limit: 9.0,
price_tick: 0.01,
})
.collect(),
dates
.iter()
.map(|date| fidc_core::DailyFactorSnapshot {
date: *date,
symbol: "000001.SZ".into(),
market_cap_bn: 10.0,
free_float_cap_bn: 10.0,
pe_ttm: 10.0,
turnover_ratio: None,
effective_turnover_ratio: None,
adjustment_factor_backward1: Some(1.0),
extra_factors: Default::default(),
})
.collect(),
dates
.iter()
.map(|date| CandidateEligibility {
date: *date,
symbol: "000001.SZ".into(),
is_st: false,
is_star_st: false,
is_new_listing: false,
is_paused: false,
allow_buy: true,
allow_sell: true,
is_kcb: false,
is_one_yuan: false,
risk_level_code: None,
})
.collect(),
dates
.iter()
.map(|date| BenchmarkSnapshot {
date: *date,
benchmark: "000300.SH".into(),
open: 100.0,
close: 100.0,
prev_close: 100.0,
volume: 1_000_000,
})
.collect(),
)
.unwrap()
}
fn action(quantity: &str, when: &str) -> PlatformTradeAction {
PlatformTradeAction::Order {
kind: PlatformExplicitOrderKind::Shares,
symbol: "000001.SZ".into(),
amount_expr: quantity.into(),
when_expr: Some(when.into()),
limit_price_expr: None,
time_in_force: None,
start_time_expr: None,
end_time_expr: None,
reason: "configured_strategy_action".into(),
}
}
fn run(policy: AutomaticTradeProtection) -> fidc_core::BacktestResult {
let mut config = PlatformExprStrategyConfig::generic();
config.signal_symbol = "000001.SZ".into();
config.benchmark_symbol = "000300.SH".into();
config.rotation_enabled = false;
config.automatic_trade_protection = policy;
config.explicit_actions = vec![
action(
"100",
"decision_date == \"2026-09-11\" || decision_date == \"2026-09-18\"",
),
action("-100", "decision_date >= \"2026-09-14\""),
];
config.matching_type = MatchingType::CurrentBarClose;
let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
.with_matching_type(MatchingType::CurrentBarClose);
BacktestEngine::new(
data(),
PlatformExprStrategy::new(config),
broker,
BacktestConfig {
initial_cash: 10_000.0,
benchmark_code: "000300.SH".into(),
start_date: Some(d(11)),
end_date: Some(d(18)),
decision_lag_trading_days: 0,
execution_price_field: PriceField::Close,
},
)
.run()
.unwrap()
}
#[test]
fn framework_protection_uses_fills_and_covers_explicit_strategy_orders() {
let result = run(AutomaticTradeProtection {
buy_protection_days: 3,
sell_cooldown_days: 3,
..Default::default()
});
assert_eq!(
result
.fills
.iter()
.map(|fill| (fill.date, fill.side, fill.quantity))
.collect::<Vec<_>>(),
vec![(d(11), OrderSide::Buy, 100), (d(17), OrderSide::Sell, 100)]
);
assert!(!result.order_events.iter().any(|order| order.date == d(14)
|| order.date == d(15)
|| order.date == d(16)
|| order.date == d(18)));
}
#[test]
fn absolute_lock_blocks_initial_strategy_buy_without_a_rejected_order() {
let result = run(AutomaticTradeProtection {
locks: vec![AutomaticTradeLock {
symbol: "000001.SZ".into(),
start_date: d(11),
end_date: None,
}],
..Default::default()
});
assert!(result.fills.is_empty());
assert!(result.order_events.is_empty());
}
#[test]
fn maximum_holding_policy_applies_to_discrete_strategies_and_yields_to_buy_protection() {
let result = run(AutomaticTradeProtection {
max_holding_days: 1,
buy_protection_days: 3,
sell_cooldown_days: 3,
..Default::default()
});
assert_eq!(
result
.fills
.iter()
.map(|fill| (fill.date, fill.side))
.collect::<Vec<_>>(),
vec![(d(11), OrderSide::Buy), (d(17), OrderSide::Sell)]
);
assert!(
result
.order_events
.iter()
.any(|order| order.reason == "max_holding_days_exit")
);
}
#[test]
fn serialized_framework_policy_survives_shared_alias_normalization_and_rejects_conflicts() {
let policy = serde_json::json!({"buy_protection_days":3,"sell_cooldown_days":3,"max_holding_days":90,"locks":[{"symbol":"000001.SZ","start_date":"2026-09-11","end_date":null}]});
for key in ["automaticTradeProtection", "automatic_trade_protection"] {
let value = serde_json::json!({"runtimeExpressions":{"trading":{key:policy}}});
let cfg = fidc_core::platform_expr_config_from_value("test", "000001.SZ", &value).unwrap();
assert_eq!(cfg.automatic_trade_protection.buy_protection_days, 3);
assert_eq!(cfg.max_holding_days, Some(90));
assert_eq!(cfg.automatic_trade_protection.locks.len(), 1);
}
let conflict = serde_json::json!({"runtimeExpressions":{"trading":{"maxHoldingDays":30,"automaticTradeProtection":policy}}});
assert!(
fidc_core::platform_expr_config_from_value("test", "000001.SZ", &conflict)
.unwrap_err()
.to_string()
.contains("conflicting maximum")
);
let unknown = serde_json::json!({"runtimeExpressions":{"trading":{"automaticTradeProtection":{"origin":"manual"}}}});
assert!(fidc_core::platform_expr_config_from_value("test", "000001.SZ", &unknown).is_err());
}
#[test]
fn locked_holding_keeps_its_slot_even_when_cash_can_buy_the_next_candidate() {
let base = data();
let dates = [11, 14, 15, 16, 17, 18].map(d);
let symbols = ["000001.SZ", "000002.SZ"];
let dataset = DataSet::from_components(
symbols
.iter()
.map(|symbol| {
let mut row = base.instruments()["000001.SZ"].clone();
row.symbol = (*symbol).into();
row
})
.collect(),
dates
.iter()
.flat_map(|date| {
symbols.iter().map(|symbol| {
let mut row = base.market(*date, "000001.SZ").unwrap().clone();
row.symbol = (*symbol).into();
row
})
})
.collect(),
dates
.iter()
.flat_map(|date| {
symbols.iter().map(|symbol| {
let mut row = base.factor(*date, "000001.SZ").unwrap().clone();
row.symbol = (*symbol).into();
row
})
})
.collect(),
dates
.iter()
.flat_map(|date| {
symbols.iter().map(|symbol| {
let mut row = base.candidate(*date, "000001.SZ").unwrap().clone();
row.symbol = (*symbol).into();
row
})
})
.collect(),
dates
.iter()
.map(|date| BenchmarkSnapshot {
date: *date,
benchmark: "000300.SH".into(),
open: 100.0,
close: 100.0,
prev_close: 100.0,
volume: 100_000,
})
.collect(),
)
.unwrap();
let mut config = PlatformExprStrategyConfig::generic();
config.signal_symbol = "000001.SZ".into();
config.benchmark_symbol = "000300.SH".into();
config.strategy_name = "protection_test".into();
config.max_positions = 1;
config.selection_limit_expr = "1".into();
config.refresh_rate = 1;
config.exposure_expr = "0.5".into();
config.market_cap_lower_expr = "0".into();
config.market_cap_upper_expr = "100".into();
config.stock_filter_expr="(decision_date == \"2026-09-11\" && symbol == \"000001.SZ\") || (decision_date != \"2026-09-11\" && symbol == \"000002.SZ\")".into();
config.automatic_trade_protection = AutomaticTradeProtection {
locks: vec![AutomaticTradeLock {
symbol: "000001.SZ".into(),
start_date: d(14),
end_date: Some(d(16)),
}],
..Default::default()
};
let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
.with_matching_type(MatchingType::CurrentBarClose);
let result = BacktestEngine::new(
dataset,
PlatformExprStrategy::new(config),
broker,
BacktestConfig {
initial_cash: 10_000.0,
benchmark_code: "000300.SH".into(),
start_date: Some(d(11)),
end_date: Some(d(18)),
decision_lag_trading_days: 0,
execution_price_field: PriceField::Close,
},
)
.run()
.unwrap();
assert_eq!(
result
.fills
.first()
.map(|fill| (fill.symbol.as_str(), fill.date)),
Some(("000001.SZ", d(11)))
);
assert!(
!result
.fills
.iter()
.any(|fill| [d(14), d(15), d(16)].contains(&fill.date)),
"{:?}",
result.fills
);
assert!(
result.fills.iter().any(|fill| fill.symbol == "000002.SZ"
&& fill.side == OrderSide::Buy
&& fill.date == d(17)),
"{:?}",
result.fills
);
}
@@ -2,7 +2,7 @@ use chrono::{Duration, NaiveDate, NaiveTime};
use fidc_core::{
BacktestConfig, BacktestEngine, BenchmarkSnapshot, BrokerSimulator, CandidateEligibility,
ChinaAShareCostModel, ChinaEquityRuleHooks, DailyFactorSnapshot, DailyMarketSnapshot, DataSet,
IntradayExecutionQuote, MatchingType, OrderIntent, PriceField, Strategy, StrategyContext,
Instrument, IntradayExecutionQuote, MatchingType, OrderIntent, PriceField, Strategy, StrategyContext,
StrategyDecision,
};
use std::collections::{BTreeMap, BTreeSet};
@@ -16,6 +16,18 @@ fn t(hour: u32, minute: u32, second: u32) -> NaiveTime {
NaiveTime::from_hms_opt(hour, minute, second).expect("valid time")
}
fn fixture_instruments() -> Vec<Instrument> {
vec![Instrument {
symbol: "000001.SZ".to_string(),
name: "quote-plan-fixture".to_string(),
board: "SZ".to_string(),
round_lot: 100,
listed_at: Some(d(2020, 1, 1)),
delisted_at: None,
status: "active".to_string(),
}]
}
#[derive(Default)]
struct DecisionQuoteReader {
day_count: usize,
@@ -90,7 +102,7 @@ impl Strategy for NoLoaderDecisionQuoteStrategy {
fn single_day_quote_plan_data(date: NaiveDate) -> DataSet {
DataSet::from_components(
Vec::new(),
fixture_instruments(),
vec![DailyMarketSnapshot {
date,
symbol: "000001.SZ".to_string(),
@@ -253,7 +265,7 @@ fn engine_preloads_declared_decision_quotes_for_current_positions() {
let first = d(2026, 1, 5);
let second = d(2026, 1, 6);
let data = DataSet::from_components(
Vec::new(),
fixture_instruments(),
vec![
DailyMarketSnapshot {
date: first,
@@ -423,7 +435,7 @@ fn engine_reuses_preloaded_decision_quotes_without_loader_call() {
let first = d(2026, 1, 5);
let second = d(2026, 1, 6);
let data = DataSet::from_components_with_actions_and_quotes(
Vec::new(),
fixture_instruments(),
vec![
DailyMarketSnapshot {
date: first,
@@ -658,7 +670,7 @@ fn engine_loads_distinct_decision_quote_times_on_same_day() {
let first = d(2026, 1, 5);
let second = d(2026, 1, 6);
let data = DataSet::from_components(
Vec::new(),
fixture_instruments(),
vec![
DailyMarketSnapshot {
date: first,
+16 -24
View File
@@ -2143,15 +2143,15 @@ fn strategy_context_exposes_advanced_data_helpers() {
fn engine_runs_minute_hooks_and_executes_minute_orders() {
let date = d(2025, 1, 2);
let data = DataSet::from_components_with_actions_and_quotes(
vec![Instrument {
symbol: "000001.SZ".to_string(),
["000001.SZ", "000002.SZ"].into_iter().map(|symbol| Instrument {
symbol: symbol.to_string(),
name: "Anchor".to_string(),
board: "SZ".to_string(),
round_lot: 100,
listed_at: Some(d(2020, 1, 1)),
delisted_at: None,
status: "active".to_string(),
}],
}).collect(),
vec![DailyMarketSnapshot {
date,
symbol: "000001.SZ".to_string(),
@@ -2174,7 +2174,7 @@ fn engine_runs_minute_hooks_and_executes_minute_orders() {
upper_limit: 11.0,
lower_limit: 9.0,
price_tick: 0.01,
}],
}, market_row(date, "000002.SZ", 20.0, 20.4)],
vec![DailyFactorSnapshot {
date,
symbol: "000001.SZ".to_string(),
@@ -4162,7 +4162,7 @@ impl Strategy for BuyMissingRowThenHoldStrategy {
}
#[test]
fn engine_carries_position_price_when_current_market_row_is_missing() {
fn engine_rejects_an_unexplained_missing_holding_close() {
let date1 = d(2025, 5, 26);
let date2 = d(2025, 5, 27);
let data = DataSet::from_components(
@@ -4230,20 +4230,16 @@ fn engine_carries_position_price_when_current_market_row_is_missing() {
},
);
let result = engine
let error = engine
.run()
.expect("backtest should not fail on one missing holding row");
assert_eq!(result.equity_curve.len(), 2);
assert!(
result
.daily_holdings
.iter()
.any(|holding| holding.date == date2 && holding.symbol == "601028.SH")
);
.expect_err("unknown missing market data must not become a carried close");
let detail = format!("{error:?}");
assert!(detail.contains("MissingSnapshot") && detail.contains("close price"));
assert!(detail.contains("601028.SH") && detail.contains("2025-05-27"));
}
#[test]
fn platform_strategy_skips_position_stop_take_when_current_market_row_is_missing() {
fn platform_strategy_cannot_hide_missing_valuation_by_skipping_stop_take() {
let date1 = d(2025, 5, 26);
let date2 = d(2025, 5, 27);
let data = DataSet::from_components(
@@ -4333,14 +4329,10 @@ fn platform_strategy_skips_position_stop_take_when_current_market_row_is_missing
},
);
let result = engine
let error = engine
.run()
.expect("platform strategy should hold through a missing current market row");
assert_eq!(result.equity_curve.len(), 2);
assert!(
result
.daily_holdings
.iter()
.any(|holding| holding.date == date2 && holding.symbol == "601028.SH")
);
.expect_err("skipping a stop condition cannot fabricate the missing valuation");
let detail = format!("{error:?}");
assert!(detail.contains("MissingSnapshot") && detail.contains("close price"));
assert!(detail.contains("601028.SH") && detail.contains("2025-05-27"));
}
+19 -16
View File
@@ -1740,8 +1740,9 @@ fn broker_applies_price_ratio_slippage_on_snapshot_fills() {
}
#[test]
fn broker_applies_dynamic_slippage_on_snapshot_fills() {
fn broker_applies_explicit_historical_slippage_on_snapshot_fills() {
let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap();
let previous_date = NaiveDate::from_ymd_opt(2024, 1, 9).unwrap();
let data = DataSet::from_components(
vec![Instrument {
symbol: "000002.SZ".to_string(),
@@ -1752,20 +1753,20 @@ fn broker_applies_dynamic_slippage_on_snapshot_fills() {
delisted_at: None,
status: "active".to_string(),
}],
vec![DailyMarketSnapshot {
date,
[previous_date, date].into_iter().map(|day| DailyMarketSnapshot {
date: day,
symbol: "000002.SZ".to_string(),
timestamp: Some("2024-01-10 10:18:00".to_string()),
timestamp: Some(format!("{day} 15:00:00")),
day_open: 10.0,
open: 10.0,
high: 10.1,
low: 9.9,
close: 10.0,
high: if day == previous_date { 10.1 } else { 10.9 },
low: if day == previous_date { 9.9 } else { 9.1 },
close: if day == previous_date { 10.0 } else { 10.8 },
last_price: 10.0,
bid1: 9.99,
ask1: 10.01,
prev_close: 10.0,
volume: 100_000,
volume: if day == previous_date { 100_000 } else { 2_000_000 },
minute_volume: 100_000,
bid1_volume: 80_000,
ask1_volume: 80_000,
@@ -1774,7 +1775,7 @@ fn broker_applies_dynamic_slippage_on_snapshot_fills() {
upper_limit: 11.0,
lower_limit: 9.0,
price_tick: 0.01,
}],
}).collect(),
vec![DailyFactorSnapshot {
date,
symbol: "000002.SZ".to_string(),
@@ -1786,8 +1787,8 @@ fn broker_applies_dynamic_slippage_on_snapshot_fills() {
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(),
}],
vec![CandidateEligibility {
date,
[previous_date, date].into_iter().map(|day| CandidateEligibility {
date: day,
symbol: "000002.SZ".to_string(),
is_st: false,
is_star_st: false,
@@ -1798,15 +1799,15 @@ fn broker_applies_dynamic_slippage_on_snapshot_fills() {
is_kcb: false,
is_one_yuan: false,
risk_level_code: None,
}],
vec![BenchmarkSnapshot {
date,
}).collect(),
[previous_date, date].into_iter().map(|day| BenchmarkSnapshot {
date: day,
benchmark: "000300.SH".to_string(),
open: 100.0,
close: 100.0,
prev_close: 99.0,
volume: 1_000_000,
}],
}).collect(),
)
.expect("dataset");
let mut portfolio = PortfolioState::new(1_000_000.0);
@@ -1815,7 +1816,9 @@ fn broker_applies_dynamic_slippage_on_snapshot_fills() {
ChinaEquityRuleHooks::default(),
PriceField::Open,
)
.with_slippage_model(SlippageModel::Dynamic(DynamicSlippageConfig::new(
.with_volume_limit(false)
.with_liquidity_limit(false)
.with_slippage_model(SlippageModel::HistoricalVolumeVolatility(DynamicSlippageConfig::new(
0.5, 0.3, 0.1,
)));
+10
View File
@@ -0,0 +1,10 @@
[package]
name = "fidc-signal-client"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
fidc-core = { path = "../fidc-core" }
reqwest.workspace = true
serde_json.workspace = true
+43
View File
@@ -0,0 +1,43 @@
//! Shared signal transport for FIDC backtest and trading services.
use std::sync::Arc;
use fidc_core::signal_contract::{SignalBookReference,ValidatedSignalBook,cached_signal_book,register_signal_book};
use reqwest::Client;
use serde_json::{Value,json};
#[derive(Clone,Copy)]
pub enum Purpose { Backtest, Online }
pub async fn load(client:&Client, source_url:&str, token:&str, reference:&SignalBookReference, purpose:Purpose)
-> Result<Arc<ValidatedSignalBook>,String>
{
reference.validate()?;
if token.len()<32 {return Err("signal_service_auth_not_configured".into());}
let purpose_name=match purpose {Purpose::Backtest=>"backtest",Purpose::Online=>"online"};
let payload=json!({"reference":reference,"purpose":purpose_name});
let root=format!("{}/api/strategy-signals/internal",source_url.trim_end_matches('/'));
// Registration/purpose validation always precedes a process-cache hit.
let response=client.post(format!("{root}/validate"))
.header("X-FIDC-Lifecycle-Token",token).json(&payload).send().await
.map_err(|_|"signal_validation_service_unavailable")?;
if !response.status().is_success() {return Err(format!("signal_validation_rejected_http_{}",response.status()));}
let validation:Value=response.json().await.map_err(|_|"signal_validation_response_invalid")?;
if validation.get("ok")!=Some(&Value::Bool(true)) || validation.get("reference")!=Some(&json!(reference)) {
return Err("signal_validation_identity_mismatch".into());
}
let book=if let Some(book)=cached_signal_book(reference)? {book} else {
let mut response=client.post(format!("{root}/book"))
.header("X-FIDC-Lifecycle-Token",token).json(&payload).send().await
.map_err(|_|"signal_book_service_unavailable")?;
if !response.status().is_success() {return Err(format!("signal_book_rejected_http_{}",response.status()));}
if response.content_length().is_some_and(|bytes|bytes>64*1024*1024) {return Err("signal_book_transport_size_exceeded".into());}
let mut bytes=Vec::new();
while let Some(chunk)=response.chunk().await.map_err(|_|"signal_book_transport_incomplete")? {
if bytes.len().saturating_add(chunk.len())>64*1024*1024 {return Err("signal_book_transport_size_exceeded".into());}
bytes.extend_from_slice(&chunk);
}
register_signal_book(reference,&bytes)?
};
if matches!(purpose,Purpose::Online) {book.require_observed()?;}
Ok(book)
}
@@ -0,0 +1,25 @@
# 策略级自动交易保护
## 统一合同
`runtimeExpressions.trading.automaticTradeProtection` 是每个股票/ETF策略自己的不可变配置。股票池、表达式轮动和显式订单复用 `holding_policy` 内核,不新增全局共享配置,也不修改未配置的历史策略。
```json
{"buy_protection_days":3,"sell_cooldown_days":3,"max_holding_days":90,"locks":[{"symbol":"000001.SZ","start_date":"2026-09-11","end_date":"2026-09-16"}]}
```
- 周期为空、null或0关闭,必须为0—3650整数;锁定支持同股多个区间,起止日包含当日,截止null持续有效。
- 买入保护禁止自动减仓/清仓及止盈止损;卖出冷却禁止自动增加仓位。只有实际成交计时,部分成交延长对应最后成交日;未成交、拒绝、撤单不启动。
- 成交日及后续N个完整正式交易日均受保护。例如周五成交、N=3,保护到下周三结束,周四恢复;不按72小时或自然日替代。
- 最长持有从连续持仓第一次实际买入开始,跨正式交易日计数;加仓、部分卖出和有证据的证券转换不重置,完全卖出再买入开启新周期。锁定和买入保护优先于最长持有退出。
- 日期锁定禁止自动买卖,已接受的挂单不自动撤销;手工路径只绕过自动策略保护,不绕过账户授权、T+1、券商和风控。
- 保留的真实持仓继续占用资金与席位,不把未完成卖出当现金。最长持有退出先形成唯一最终目标,不能叠加一笔策略部分卖出和一笔框架全量卖出。
- 在线上下文重建必须注入已经校验的真实成交/持仓快照,不能把重建日或旧行情日当建仓日。期货或股票期货混合账户未纳入本合同,显式拒绝。
## 根因补充修复
组合 `decision_date == "2026-09-11" && symbol == "000001.SZ"` 会落到字符串表达式路径。旧代码遗漏日期等内建标识符的保留登记,又按“额外因子”注入NaN,覆盖同名真实日期,造成选股错误。现登记全部已注入内建字段,并禁止额外因子覆盖已存在的作用域变量。单独数字VM日期测试不足以发现该问题,新增日期+证券混合选择回归。
## 验证与边界
原生完整回测测试验证:显式策略真实模拟成交日启动3日保护/禁买、日期锁定零委托、最长持有让位于保护、锁定持仓占据资金与席位、解锁后才按候选顺序买入;序列化和别名归一不改max_holding_days字段,冲突策略拒绝。现有534核心用例通过(6个既有忽略项)。这些是隔离内核测试,不是GT实际成交验收。
+54
View File
@@ -0,0 +1,54 @@
# 逐成交腿价格风控验收
## 修复范围
Engine `7e0877b5860d8724da1c4507a1d1ba393b3497f5`Trading `1f7bc074024191cfaa5975546f22c2c2c733602a`,均以 tag `v2026.9.11.2` 发布177。
- 回测在每条实际报价进入撮合前检查原始参考价,滑点和限价处理后再次检查最终价。买入一元股、买入涨停、卖出跌停以及无效价格均按本腿价格处理,不能只依赖最初下单的日线标记或价格。
- Paper和Live的订单前检查与Paper撮合共用`MarketSnapshot::execution_reference_price`:普通买入用卖一、卖出用买一;未提供该侧价格时保留既有最新价合同,显式0或负数不当缺失处理、不回退。
- 选股仍独立使用其日线最新价与显式规则,不被买卖盘差异改写。盘后固定价仍使用原正式收盘价合同。
- Paper已接受/部分成交订单在新报价到达时重新检查。后续被风控拒绝不删除或回滚此前真实模拟成交,不重复扣资金或手续费。
- 实盘这里只验证发单前路径;券商实际成交事实必须原样保存,不能声称本地检查能保证委托进入券商后市场不再变化。本轮未提交证券订单。
## 测试
- 原始报价0.9、正向滑点20%后为1.08,仍不得利用滑点绕过一元股规则。
- 先出现0.9、后出现1.2的报价,只允许在后一个实际时点成交;不回写到前一时点。
- 限价滑点将最终执行价变为0.9时仍拒绝;显式关闭一元股买入规则后放行;卖出不继承买入一元股规则。
- 最后价10而卖一11触及涨停:买入拒绝。最后价11而卖一10.5:执行检查不按旧最后价误拒;显式选股涨停规则仍可按最后价拒绝。
- 最后价10而买一9触及跌停:卖出拒绝。最后价9而买一9.5:执行检查不按旧最后价误拒。
- 原挂单/部分成交后,最后价1.1但卖一0.9:余单拒绝,既有成交数和现金保持不变。
177测试:Engine 667通过/8忽略,Trading工作区548通过/10忽略,Runner370通过/3忽略,API99通过/1忽略。新场景使用隔离合成账户/报价,未以此冒充原始市场样本。
## 真实分钟回放
- 同一冻结请求、信号及bundle2025-01-03至2025-01-06,分钟13:07,初始10,000,000,滑点0.002,佣金万三/最低5,分钟25%量约束不改。
- 原基准 `btr_1789074235759_2081201_1`
- 新运行 `btr_1789093974375_2601124_0`
- 均21成交、11个最终持仓,最终资产9,968,551.588547;订单、成交、账户、权益、持仓和风险审计六项canonical完全一致。
- 总SHA `a1aa004f544b34eae0ade41e849a0fd067e39600d1c4ad1a127f5a3d6a79be11`
- 服务端3.490秒,客户端提交/读取/轮询16.927秒。未采集客户端各子段,不能把差值归因到某个具体服务,也不与原报告“提交后轮询耗时”混比。缓存条件的短样本不能外推冷态或多年性能。
## 发布与状态
通过官方Backtest和Trading installer构建和发布,没有调用Source/因子重启入口。Backtest service源码仍`75202cc3b876daf99d0d2dffb988ca456c34aabf`并重新链接上述engine。运行二进制SHA与清单一致。
本轮发布前10:27已观测到3Paper/0Live,重复读取确认;这不同于上一轮的3Paper/1Live,不是本次发布删除。本轮没有新建、恢复或删除实例。发布后仍3Paper/0Live,完整配置/状态摘要与本轮发布前相同。
Source主PID2267019和因子主PID2178403、NRestarts不变。发布后样本Paper9行、Live11行无WARN/ERRORRuntime0行不能视为实际执行成功;行情`/readyz`仍503THS -4302配额问题未恢复。
## 未完成
next-open全天量容量和动态滑点使用全天high/low/volume的问题没有被本次修复覆盖,仍按P0时点问题处理。新的执行观察规格位于`/Users/boris/WorkSpace/docs/fidc/execution-observation-prd-20260911.md`,只是后续实现规格,不是已部署能力。禁止静默改用昨量、自动关闭风控、修改旧结果或把后续一分钟量回填到开盘。
自然Paper/Live还需要合格模型、正式审批和真实可用行情;不开放2026封存,不替研究模型审批。当前实盘列表为空,不自行补建。
## 证据
`/srv/fidc/canonical/run/research/execution-leg-risk-20260911/`
- `engine-focused.log``engine-full.log``trading-full.log``backtest-full.log`
- `minute-replay/request.json``submission.json``result.json``comparison.json`
- `deploy-before.json``deploy-after.json``running-binary-verification.json``post-deploy-log-audit.json`
- 官方部署日志、研究审计脚本与执行观察设计稿。不改旧证据目录和WFT V18制品。
@@ -0,0 +1,87 @@
# 执行价风控与共享信号账户隔离验收
## 结论
本次修复已通过测试并发布 177。只证明一元股请求阶段价格修复、同一共享信号的账户隔离和既有真实样本结果不变;完整生产闭环尚未完成。next-open 全天容量、动态滑点的日内可见性及逐成交腿风控仍是未关闭项,不能称为全部成交无未来信息。
## 修复
- `risk_control.rs` 的 Buy 一元股规则改用本次 `check_price`,不再读取日线 `is_one_yuan` 或当天更早的 `day_open`。无效价格拒绝,其他缺失风险事实仍拒绝;显式 Selection 规则保留。
- Trading 共用 `risk.rs` 的 Paper/Live 订单前检查使用新鲜 `last_price`,不再被日线标记或开盘价覆盖。选股阶段的开关和日线标记单独处理。
- 缺执行价格继续输出具体 `missing_execution_price field=open`,保留 `historical_price_fallback=false`;停牌等权威状态仍优先,不因新通用校验丢失根因。
- 没有修改共享信号内容、模型、账号权限、运行配置、Source Lake、研究 checkpoint 或既有回测数据。
## 账户隔离组合
隔离共享核心测试使用同一份经过原生校验的 `fidc.signal-book/v2`,信号只表达保留 50% 持仓。当前价 10、止损 10%、止盈 20%;每个账户独立计算实际订单。
| 原数量 | 买入价 | 买入费用总额 | 预期剩余 | 结果 |
| ---: | ---: | ---: | ---: | --- |
| 1,000 | 8.00 | 0 | 0 | 止盈优先于半仓目标 |
| 1,000 | 10.00 | 0 | 500 | 按本账户数量减半 |
| 3,000 | 10.00 | 0 | 1,500 | 不共用其他账户数量 |
| 1,000 | 12.00 | 0 | 0 | 止损 |
| 1,000 | 11.11 | 0 | 500 | 尚未跨过止损阈值 |
| 1,000 | 11.11 | 2.00 | 0 | 含费用成本跨过止损阈值 |
六种账户卖出后,当天再消费同一买入目标均不得买回;独立未卖出账户可正常买入。同一信号版本不变,策略规划不预先修改持仓。这些是隔离合成账户测试,不是券商委托/成交证据。
## 回归与真实回放
- Engine:656 通过,8 个专用测试忽略。
- Trading 工作区:537 通过,9 个专用测试忽略。
- Backtest Runner370 通过、3 忽略;API:99 通过、1 忽略。
- 一元股专项覆盖真实执行价为 0.9/1.0/1.2、旧标记与新价格相反、缺失其他风险事实、NaN/无效价及开关独立性。
实际 HTTP 回测使用原始冻结请求、信号和 bundle,未复制结果:
- 原基准:`btr_req_260d0f3179d40fda5c918d48eba0a239bd335406c82a1cbe`
- 新运行:`btr_1789091571964_2429476_0`
- 区间:2025-02-05 至 2025-02-10;初始资金 10,000,000;目标 10 仓;next-open;滑点 0.002;佣金万三、最低 5。
- 两次均 28 成交,最终资产 10,089,448.918844,收益 0.89448918844%。
- 订单、成交、账户事件、权益、持仓、风险审计六项摘要相同。
- Canonical SHA256`befa50b3ec5b94adafede459903db7e2542797cf0eefe2de32900afc83ca1481`
- 服务端 3.954 秒,客户端含轮询 6.072 秒。此短区间已有缓存样本不能代表全市场冷态或多年性能。
## 发布
- Engine `d3c36e947894fd220b62ecd6fbfe02473f70bd2c`tag `v2026.9.11`
- Trading `dfcec36bd1c0f92e73cd30073540eb39dbd02835`tag `v2026.9.11`
- Backtest service `75202cc3b876daf99d0d2dffb988ca456c34aabf`,重新链接上述引擎。
- 只使用官方 installer,以 Boris 构建和运行。发布后 Backtest/Runtime/Paper/Live 的进程和 HTTP `/healthz` 正常,运行二进制核对独立清单,不仅检查源码 HEAD。实时行情 `/readyz` 仍为 503,原因如下,不能宣称自然交易正常。
- 原 3 Paper / 1 Live 配置和状态摘要前后相同;本次投影 Paper 为 `52a909117fe8f01ae35a327bd86310e2583d291609bba6596dc0f49a2b10559c`Live 为 `aaec1e9dbc984012e9fe677e54db1efb860edd59d5840d1dc87c4b230b26bac6`。仅与本次相同投影的发布前数据对比,不与此前其他字段投影混比。
- Source PID 2267019、因子主进程 PID 2178403、NRestarts 均不变。本轮未调用 Source/因子重启入口;不能由主 PID 不变推断全部因子子任务已经验收。
## 未关闭问题
### 实时行情配额
发布后文件日志审查发现 THS `-4302`:本周行情用量超过 1.5 亿。受保护的行情源目录只返回 `ths_realtime`enabled=true、ready=false;没有已配置可用的授权备用源。行情 `/readyz` 返回503、snapshot_count=0,实盘日志反复记录实际执行日2026-09-11请求150证券、收到0新鲜行情,因此 next-open 规划失败。
所查尾部8,000行日志中,配额告警最早已出现在01:30:12 UTC(上海09:30),早于本轮09:58的Trading发布。不能把该故障归因于本次一元股代码或用重启解决。不得拿昨日收盘、Source历史数据或手工报价代替实时价格;恢复账户配额或配置正式授权的可用行情源后,才能继续自然交易验收。
Paper的3条WARN为启动重建的PG读取,分别约1.015/1.122/1.460秒;本轮未见ERROR,但这只是采样范围,不能称全部日志无异常。证据:`realtime-quota-timeline.json``realtime-provider-readiness.json``post-deploy-file-log-audit.json`。Runtime无新采样日志不等于实际调度通过。
### 执行容量与校准
独立依赖探针确认:保持 next-open 订单和开盘价不变,仅修改执行日后来形成的全天量,成交量从 100 变为 1,000;仅修改全天 high/low,动态滑点成交价从 10.305 变为 11.000。探针是合成输入,不冒充市场证据。
详见 `/Users/boris/WorkSpace/docs/fidc/execution-time-capacity-coordination-20260911.md`。下一步须分离实测执行时点容量和声明的容量估计、冻结校准数据时钟、覆盖挂单逐成交腿;不能偷偷改为昨日成交量、关闭限制或使用未来一分钟量。V18 研究只允许新不可变后继评估,不能改现有结果。
自然 Paper 观察与正式 Live 仍需真实合格版本和正式审批。当前研究控制模型仅 23 个验证日,2026 留出期继续封存;不得为演示闭环降低门槛、伪造 observed、代替审批或手工发证券订单。
### 并发代码合并
报告推送时远端新增 `33924b1/f2e228e` 的策略自动交易保护。已保留并合并至main `f7d16fb`,177源码同步,组合引擎回归666通过、8忽略。该合并后的新保护尚未由本任务部署,线上仍使用本报告列出的d3c36e9/dfcec36清单;不能把源码同步当作发布或把对方功能归为本次已完成的自然交易验收。
随后 Trading main 新增 `4ff7ee9aeefa2e1013098212dc75b4969499a1a6`,本机与177均已正常快进同步,合并组合工作区545通过、10忽略。此为并发功能合并后的源码测试,同样不改变本次发布清单;不重复部署另一个任务尚在验收的完整交易保护功能。
## 证据
177 根目录:`/srv/fidc/canonical/run/research/execution-risk-signal-audit-20260911/`
- `engine-full-tests-v2.log``trading-full-tests.log``backtest-full-tests.log`
- `deploy-before.json``deploy-after.json``running-binary-verification.json`
- `same-signal-backtest/request.json``submission.json``result.json``comparison.json`
- `execution-time-dependency-probe.json` SHA256`feeb69275b8ad537f16e4c119cc7e59dd3a15773334fb591e27d7afdf34311d3`
- 官方部署日志与独立探针源码保存在相同证据根,不写入交易数据库或修改原始行情。
@@ -0,0 +1,63 @@
# 表达式缺失值与执行参数验收
## 根因
原数值执行器把 NaN 比较结果直接变成 false,外层 NOT 因而可能变成 true。
`min(NaN, value)` 还会返回另一个有效值,使缺失因子参与筛选。下单标量继续经过
`max``clamp` 或整数转换时,也可能把无效输入变成零仓位或零数量。
## 执行合同
数值 VM 使用带类型的 Missing 值,数值缺失及非有限运算结果不再提前变为布尔 false。
| 表达式 | 结果 |
| --- | --- |
| NOT unknown | unknown |
| false AND unknown | false |
| true AND unknown | unknown |
| true OR unknown | true |
| false OR unknown | unknown |
最终布尔筛选只接受 true;显式 `if`/`iff` 与 CASE WHEN 一样,只在条件确认为 true 时取真分支。
显式 `nz` 保留策略自己声明的缺失值替代含义,框架不会自行填零。
短路仍不读取未使用分支。非法 clamp 范围返回错误,不允许使进程 panic。
Rhai 的逻辑运算不能承载可空布尔,因此动态脚本遇到未知数值比较时明确报错,不能
返回错误的命中;缺失 map 属性同样报错。已关闭会绕过自定义比较保护的 Fast Operators。
有限浮点比较仍使用现有 epsilon 口径,混合整数/浮点比较也受保护。
[Rhai 运算符文档](https://rhai.rs/book/rust/operators.html)说明了该分派边界。
下单数量、目标仓位、投入比例和筛选边界必须返回有限数值,否则记录
`missing_numeric_result`,包含表达式、证券、决策日和执行日。只有排名评估保留
独立的缺失值诊断路径;没有把数据源的缺行改写为价格或交易事实。
## 代码与测试
- `fda2e70`VM 三值逻辑及动态数值保护。
- `ea58ab2`:显式关闭 Rhai 快运算符,补齐缺失 map 保护。
- `e3f1028`:执行标量必须有限,排名与执行参数分离。
- 177 引擎:585 项通过、8 项跳过。
- Runner360 项通过、3 项跳过。
- 交易工作区链接 e3f1028:510 项通过、8 项跳过。
首次回归曾发现 Rhai 快路径仍绕过保护,修复后重新完整测试,未将失败候选部署。
## 真实回放
使用已保存的原始 strategy spec、初始资金、日期、基准、频率及全部执行配置,
通过独立 runner 真正重新执行。固定为服务实际使用的16个逻辑CPU、Rayon8线程、Tokio16线程。
- 五年日线:2021-08-23 至 2026-08-281,000万元,25,408笔成交。
- 分钟样本:2025-01-02 至 2025-11-17100万元,156笔成交。
- 10次回放的 canonical 与 result-store 均等于各自同 frozen bundle 基准。
- 包含 e3f1028 的最终回放为 `five-year-strict-1``minute-strict-1`
完整证据:`/Users/boris/WorkSpace/fidc-backtest-service/docs/evidence/numeric-condition-replay-20260909.json`
日线源行6,918,227;分钟样本仅636行,不能用其亚秒耗时宣传全部分钟策略的性能。
## 边界
该候选尚未部署到常驻回测或交易服务。此验证证明两种已有策略在有效冻结数据下结果不变,
不证明所有策略、所有原始财务公告/vintage、全部缺失数据原因或真实券商交易均已验收。
Rhai 未提供与数值 VM 完全相同的 nullable 表达式能力,目前选择明确拒绝,不能称为所有
动态语言表达式都已支持三值逻辑。完整 typed Base Panel 与对象分配优化仍待完成。
+15
View File
@@ -0,0 +1,15 @@
# 生命周期、价格缺失与历史状态
证券有效区间为 `[listed_at, delisted_at)`。无明确摘牌日期的最新 terminal 标签不能反向污染历史;已知未来摘牌日不阻断此前的正常交易。退市整理期不是已摘牌。
执行价加载前分别核验证券身份、正式上市/摘牌边界。合法上市前、摘牌后不查询和补价,记录结构化原因;同一日已有正执行价与生命周期边界冲突时报错。未知身份/代码映射、上市后的分钟缺口、候选事实缺失继续失败,不因 missing candidate 而跳过校验。持仓仅在当日正式暂停交易事实成立时允许按既定估值合同沿用历史价格;普通行情缺口不再无条件沿用旧价。
整个明确证券范围尚未上市时保留官方日历内现金净值点,不缩短回测范围,不伪造成交或 OHLCV。基准只在首个基线点归一,后续无交易日不反复重置。
513 项核心测试通过,6 项原有测试忽略。新增验证包含沪深北股票和 ETF 上市前、实际摘牌日、未知证券身份、候选缺失、正式停牌和普通价格缺口、全池上市前现金期间。对单个正式分区的数据缺口仍需数据源修复,不从这些测试外推全市场完整性。
## 真实边界回放补充
177 回测 `btr_1789041425783_797911_1`920038.BJ2026-08-04 至 08-07。真实上市日08-05,原结果只保留08-05至08-07三个净值点。原因是准备面同时加载基准000300.SH,基准不是交易候选但参与了“全部证券生命周期外”的判定。现在只排除已声明且没有交易候选记录的基准,不按代码或名称猜测指数,也不把真实候选排除;补充真实准备结构的回归后,4日现金区间完整保留。
该草稿沿用源池 `rejectBjseSelection=false``rejectBjseBuy=true`,所以选中北交所但不下单符合其买入政策;原规划阶段没有记录拒绝原因则是审计缺项。新增 `scope=buy, stage=buy_planning` 审计,不伪造订单ID,不把买入否决改写成选股排除。测试验证禁止时无订单且有bjse原因,放开买入政策时正常生成意图。最新核心514项通过、6项原有忽略。
@@ -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.
@@ -0,0 +1,10 @@
# 股票池候选顺序合同
新请求可显式设置 `runtimeExpressions.selection.preserveCandidateOrder=true`,同一 `candidateSymbolsByDate` 同时冻结成员和顺序。原有未设置该标志的策略保留成员过滤后自行排名的语义,不改写历史回测。
- 顺序在解析时保留,重复证券仍报错;空日期保持空,不继承旧候选。
- 不再走市值快排或套用旧 rank 方向。选股风控和股票条件仍在 Top N 前执行,被排除后从后续已冻结候选补位。
- 该标志必须绑定非空的日期映射,不允许空映射放开全市场。
- 股票池完成日线筛选的新前端请求采用 next_bar_open,日线信号日与真实执行日分离。
本轮共享内核全量回归 668 项通过(8 项显式忽略),新增顺序/旧排名方向/选股排除补位验证。该记录不是实盘成交验收,也不代表手选与自动候选混合来源完整实现。