Compare commits

..

84 Commits

Author SHA1 Message Date
boris 5dc5ef9df5 补全指数与完整范围排名的只读事件计算 2026-09-09 19:57:03 +08:00
boris fe8f6c1c26 增加均量突破回踩与真实涨停整理条件 2026-09-09 13:55:45 +08:00
boris 30e8227099 接通真实昨收成交额并前置校验表达式字段 2026-09-09 11:33:58 +08:00
boris 588da4958f 合并组合亏损控制器与因子内核依赖 2026-09-09 10:40:26 +08:00
boris bc4754288e 合并主线缺值语义与因子键优化记录 2026-09-09 10:31:26 +08:00
boris 6b0cdbcecc 增加共享因子事件表达式与完整截面算子 2026-09-09 10:31:25 +08:00
boris 5ff05e0d3d merge latest engine main before portfolio risk integration 2026-09-09 10:23:02 +08:00
boris bab4d47b46 revert: remove ineffective borrowed factor key optimization 2026-09-09 09:48:41 +08:00
boris fdd26667c9 test: enable rebalance actions in portfolio risk lifecycle fixture 2026-09-09 09:40:18 +08:00
boris 29522b69fe test: trace completed risk observations in engine regression 2026-09-09 09:40:18 +08:00
boris a54489fe92 test: expose lifecycle execution diagnostics on failure 2026-09-09 09:40:18 +08:00
boris 20c14437c6 test: bind accounting lifecycle fixture to its real sample symbols 2026-09-09 09:40:18 +08:00
boris dce5454ec8 test: import explicit engine accounting fixture types 2026-09-09 09:40:18 +08:00
boris 72b64451ac test: verify portfolio loss against finalized engine accounting 2026-09-09 09:40:18 +08:00
boris d17d67d6ca build: lock existing SHA256 dependency without unrelated upgrades 2026-09-09 09:40:18 +08:00
boris 63c577bd76 feat: connect portfolio loss to finalized accounting and daily risk clock 2026-09-09 09:40:18 +08:00
boris 8c190597ae feat: add serialized causal portfolio loss controller for runtime integration 2026-09-09 09:40:18 +08:00
boris ad063264cf fix: borrow factor identifiers during lookup 2026-09-09 09:36:10 +08:00
boris 0108c91bae perf: preserve borrowed factor keys in stock state 2026-09-09 09:35:25 +08:00
boris ee2865829d Merge remote-tracking branch 'origin/main' 2026-09-09 09:31:46 +08:00
boris e66460c4e9 对齐次日交易信号基线与基准收益起点 2026-09-09 07:43:29 +08:00
boris 2811886a52 区分次日回放调度标签与实际日线可见时点 2026-09-09 07:21:13 +08:00
boris 3b5a7cd318 修复恒定小数价格累加误差产生虚假均线信号 2026-09-09 07:03:55 +08:00
boris 3fe2da3ee0 统一日线形态计算与次日分阶段信号 2026-09-09 06:41:15 +08:00
boris ee77028907 docs: record missing-value semantics and real replay gates 2026-09-09 06:07:01 +08:00
boris 1bcaa0b3d8 合并主线数值校验与买入阶段约束 2026-09-09 05:49:45 +08:00
boris 1703a7aa5e 保留已有行情和策略代码格式整理 2026-09-09 05:49:44 +08:00
boris e3f1028667 fix: reject missing numeric sizing and execution parameters 2026-09-09 04:34:09 +08:00
boris ea58ab2166 fix: enforce numeric guards in Rhai operator dispatch 2026-09-09 04:02:11 +08:00
boris 3b1aa2ebcb test: identify the dynamic missing-value comparison path 2026-09-09 03:58:49 +08:00
boris fda2e70456 fix: preserve unknown numeric conditions through boolean expressions 2026-09-09 03:56:44 +08:00
boris 3cea91467d test: provide dated amount facts in next-open selection fixture 2026-09-09 02:58:33 +08:00
boris dd6b37be16 fix: preserve missing numeric factors and reject nonfinite truthiness 2026-09-09 02:51:26 +08:00
boris 078839b0f3 fix: preserve authoritative STAR market classification in risk checks 2026-09-08 22:42:33 +08:00
boris 326438aac9 fix: evaluate buy quote conditions at the active schedule clock 2026-09-08 01:45:39 +08:00
boris 30da6eaead feat: evaluate trading buy filters into decision-scoped constraints 2026-09-08 01:27:42 +08:00
boris 3784246e6b docs: specify buy-constrained amendment behavior and validation scope 2026-09-08 01:09:14 +08:00
boris fa6f189cdd test: verify amendment rejection without order-state events 2026-09-08 01:04:51 +08:00
boris daa9d8d341 fix: apply decision buy denials to exposure-increasing amendments 2026-09-08 01:00:52 +08:00
boris c85afb59ab docs: define factor decision phase integration and acceptance gates 2026-09-08 00:53:48 +08:00
boris 7d293f092e test: cover next-open side flips and trim redundant default fields 2026-09-08 00:43:54 +08:00
boris da27204a71 test: assert broker fill events for scoped buy denials 2026-09-08 00:41:11 +08:00
boris bac721e593 feat: add decision-scoped buy denials to broker submission 2026-09-08 00:40:10 +08:00
boris bc666c6433 docs: record paper and live deployment of native factor timing fix 2026-09-07 22:30:14 +08:00
boris 4a71992752 docs: record intraday native factor and five-year next-open replays 2026-09-07 22:14:59 +08:00
boris 1b78186c4e docs: record scoped native daily factor visibility tests 2026-09-07 21:56:12 +08:00
boris cb97aa193d test: verify native daily values respect intraday availability 2026-09-07 21:54:26 +08:00
boris a02ac6e853 fix: gate bound daily indicator fields by completed session 2026-09-07 21:51:51 +08:00
boris f3cc790659 fix(data): reject normalized reserved adjustment keys 2026-09-07 19:38:28 +08:00
boris 5ffbf76565 docs(data): record typed adjustment snapshot acceptance 2026-09-07 18:13:31 +08:00
boris 04b45adf98 perf(data): type adjustment factor snapshots 2026-09-07 17:53:36 +08:00
boris 9714c051c5 精确预分配按股行情分组 2026-09-07 13:57:12 +08:00
boris fbf5a6d61a 按符号索引一次构建行情序列 2026-09-07 13:46:59 +08:00
boris 94632f42d6 拆分共享日线序列与日内字段 2026-09-07 13:34:38 +08:00
boris 3f6017d98b 保持日内覆盖与按股序列一致 2026-09-07 13:25:07 +08:00
boris d5af51c02b 支持复用只读日线基础面板 2026-09-07 12:54:49 +08:00
boris 1ec0bb65f7 记录行情计划冷路径验收 2026-09-07 12:34:50 +08:00
boris c934a948c6 流式构建总市值行情计划 2026-09-07 12:17:26 +08:00
boris 950bcaa7aa 记录通用行情覆盖层否决结论 2026-09-07 11:34:25 +08:00
boris 728ed7998d Revert "以运行覆盖层隔离补充行情"
This reverts commit 757b5665ca.
2026-09-07 11:28:33 +08:00
boris 757b5665ca 以运行覆盖层隔离补充行情 2026-09-07 11:22:20 +08:00
boris c280bbc1c3 记录分层行情索引否决结论 2026-09-07 11:15:38 +08:00
boris 68c186f649 Revert "分层共享执行行情索引"
This reverts commit f2de8b281a.
2026-09-07 11:09:02 +08:00
boris f2de8b281a 分层共享执行行情索引 2026-09-07 11:03:08 +08:00
boris 19f725dbaa 记录市值区间切片性能验收 2026-09-07 10:59:32 +08:00
boris df7a1ee382 按共享市值索引裁剪候选区间 2026-09-07 10:50:20 +08:00
boris 4fe1f0d77c 记录共享行情释放性能验收 2026-09-07 10:38:29 +08:00
boris f45b3a71fa 避免共享行情释放触发整图复制 2026-09-07 10:27:39 +08:00
boris 1aa7c28616 复用预计算行情证券范围 2026-09-07 09:41:10 +08:00
boris e542e52bdb 跳过无加载器的行情规划 2026-09-07 09:22:15 +08:00
boris 0afbdc2210 由执行风控处理无行情买单 2026-09-07 09:04:07 +08:00
boris fc6dea12eb 增加类型化静态股票池合同 2026-09-07 07:27:44 +08:00
boris c3f88ebf12 对齐持仓未实现盈亏口径 2026-09-07 06:26:34 +08:00
boris 1d1c93f8e2 补充持仓移动平均成交价 2026-09-07 05:53:30 +08:00
boris 929b105173 支持按持仓成交均价止盈止损 2026-09-07 05:36:23 +08:00
boris e00777ebc2 按完整目标集合约束持仓槽位 2026-09-07 05:18:57 +08:00
boris e4f6cdd025 对齐目标组合提交前过滤 2026-09-07 05:10:00 +08:00
boris 2a705a361a 说明目标组合退出语义 2026-09-07 05:04:34 +08:00
boris b4ec5da808 区分股票池退出后重新入场 2026-09-07 04:51:27 +08:00
boris 4d8761cc3c 阻止未完成退出反向补买 2026-09-07 04:44:12 +08:00
boris fdd0dd0525 修复目标生命周期退出后复活 2026-09-07 04:39:16 +08:00
boris df29c8d3ec 区分退出后权重重分配语义 2026-09-07 04:33:40 +08:00
boris ef24402747 保留策略目标资金比例 2026-09-07 04:19:15 +08:00
boris 78e872b609 修复盘中止盈止损行情缓存 2026-09-07 04:17:37 +08:00
45 changed files with 9088 additions and 409 deletions
Generated
+80
View File
@@ -37,6 +37,15 @@ version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
[[package]]
name = "block-buffer"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
dependencies = [
"generic-array",
]
[[package]] [[package]]
name = "bumpalo" name = "bumpalo"
version = "3.20.2" version = "3.20.2"
@@ -99,6 +108,15 @@ version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "cpufeatures"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "crossbeam-deque" name = "crossbeam-deque"
version = "0.8.7" version = "0.8.7"
@@ -130,6 +148,26 @@ version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]]
name = "crypto-common"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"typenum",
]
[[package]]
name = "digest"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
]
[[package]] [[package]]
name = "either" name = "either"
version = "1.17.0" version = "1.17.0"
@@ -153,6 +191,8 @@ dependencies = [
"rhai", "rhai",
"serde", "serde",
"serde_json", "serde_json",
"sha2",
"ta-lib",
"thiserror", "thiserror",
] ]
@@ -162,6 +202,16 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "generic-array"
version = "0.14.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
]
[[package]] [[package]]
name = "getrandom" name = "getrandom"
version = "0.2.17" version = "0.2.17"
@@ -431,6 +481,17 @@ dependencies = [
"serde_core", "serde_core",
] ]
[[package]]
name = "sha2"
version = "0.10.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]] [[package]]
name = "shlex" name = "shlex"
version = "1.3.0" version = "1.3.0"
@@ -477,6 +538,19 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "ta-lib"
version = "0.8.1"
source = "git+https://github.com/TA-Lib/ta-lib.git?rev=dd5a90259a3f9e04e2da9f38bf0719a841b40108#dd5a90259a3f9e04e2da9f38bf0719a841b40108"
dependencies = [
"ta-lib-dispatch",
]
[[package]]
name = "ta-lib-dispatch"
version = "0.1.2"
source = "git+https://github.com/TA-Lib/ta-lib.git?rev=dd5a90259a3f9e04e2da9f38bf0719a841b40108#dd5a90259a3f9e04e2da9f38bf0719a841b40108"
[[package]] [[package]]
name = "thin-vec" name = "thin-vec"
version = "0.2.16" version = "0.2.16"
@@ -512,6 +586,12 @@ dependencies = [
"crunchy", "crunchy",
] ]
[[package]]
name = "typenum"
version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]] [[package]]
name = "unicode-ident" name = "unicode-ident"
version = "1.0.24" version = "1.0.24"
+2 -1
View File
@@ -11,6 +11,7 @@ version = "0.1.0"
authors = ["OpenAI Codex"] authors = ["OpenAI Codex"]
[workspace.dependencies] [workspace.dependencies]
sha2 = "=0.10.9"
ahash = "=0.8.12" ahash = "=0.8.12"
chrono = { version = "=0.4.44", features = ["serde"] } chrono = { version = "=0.4.44", features = ["serde"] }
indexmap = { version = "=2.11.4", features = ["serde"] } indexmap = { version = "=2.11.4", features = ["serde"] }
@@ -18,5 +19,5 @@ reqwest = { version = "=0.12.24", default-features = false, features = ["json",
rayon = "=1.12.0" rayon = "=1.12.0"
rhai = { version = "=1.23.6", features = ["sync"] } rhai = { version = "=1.23.6", features = ["sync"] }
serde = { version = "=1.0.228", features = ["derive"] } serde = { version = "=1.0.228", features = ["derive"] }
serde_json = "=1.0.145" serde_json = { version = "=1.0.145", features = ["float_roundtrip"] }
thiserror = "=2.0.18" thiserror = "=2.0.18"
+2
View File
@@ -99,6 +99,8 @@ Source Lake 日线成交量保留原始可用性合同:源 `volume=null` 与
`holdUntilExit=true``stopTakeReferencePriceMode=signal_day_post_adjusted_close` 组合表示持久模型组合语义:股票进入模型目标后即记录信号日和后复权参考价,不以买单是否成交为前提。涨停、停牌或其他执行风控导致买单未成交时,模型成员仍占用目标槽位、每天累计模型持有日并继续生成目标仓位;达到止盈、止损或最大模型持有期后才从模型组合移除。实际订单仍由成交日风控独立决定,不得用实际持仓集合覆盖模型目标集合。 `holdUntilExit=true``stopTakeReferencePriceMode=signal_day_post_adjusted_close` 组合表示持久模型组合语义:股票进入模型目标后即记录信号日和后复权参考价,不以买单是否成交为前提。涨停、停牌或其他执行风控导致买单未成交时,模型成员仍占用目标槽位、每天累计模型持有日并继续生成目标仓位;达到止盈、止损或最大模型持有期后才从模型组合移除。实际订单仍由成交日风控独立决定,不得用实际持仓集合覆盖模型目标集合。
`targetPortfolioDaily=true` 时,每只股票的默认目标金额固定为 `target_budget / selection_limit * buy_scale`,候选不足、缺行情或风控拒绝产生的剩余资金保留为现金,不得自动归一到满仓。止盈、止损或最大持有期触发后,标的从活动目标顺序移除;卖出未完成时继续占用仓位槽且不得反向补买,成功释放的槽位只能由同一决策时点已排序且通过策略条件的后续候选补充。只有显式设置 `redistributeTargetWeightsAfterExit=true` 才在可用目标间重新分配权重,只有显式设置 `reenterExitedTargets=true` 才保留退出标的供后续重新入场;两个开关互相独立,默认都为 `false`
## 内置微盘策略 ## 内置微盘策略
`OmniMicroCapStrategy` 是平台内置的微盘轮动策略,用于 demo、性能验证和策略迁移基线: `OmniMicroCapStrategy` 是平台内置的微盘轮动策略,用于 demo、性能验证和策略迁移基线:
+2
View File
@@ -13,4 +13,6 @@ rayon.workspace = true
rhai.workspace = true rhai.workspace = true
serde.workspace = true serde.workspace = true
serde_json.workspace = true serde_json.workspace = true
sha2.workspace = true
thiserror.workspace = true thiserror.workspace = true
ta-lib = { git = "https://github.com/TA-Lib/ta-lib.git", rev = "dd5a90259a3f9e04e2da9f38bf0719a841b40108" }
@@ -0,0 +1,35 @@
use fidc_core::factor_events::{self, Expr, Frame};
use serde::Deserialize;
use serde_json::{Value, json};
use std::io::{self, Read};
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Request {
expressions: std::collections::BTreeMap<String, Expr>,
frame: Frame,
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut input = String::new();
io::stdin().read_to_string(&mut input)?;
let output = if input.trim().is_empty() {
factor_events::catalog()
} else {
let request: Request = serde_json::from_str(&input)?;
let results = request
.expressions
.iter()
.map(|(id, expr)| {
let result = match factor_events::evaluate(expr, &request.frame) {
Ok(v) => json!({"result":v}),
Err(e) => json!({"error":e}),
};
(id.clone(), result)
})
.collect::<std::collections::BTreeMap<String, Value>>();
json!({"contract":factor_events::CONTRACT,"results":results,"read_only":true})
};
println!("{}", serde_json::to_string(&output)?);
Ok(())
}
+233
View File
@@ -380,6 +380,7 @@ pub struct BrokerSimulator<C, R> {
runtime_intraday_start_time: Cell<Option<NaiveTime>>, runtime_intraday_start_time: Cell<Option<NaiveTime>>,
runtime_intraday_end_time: Cell<Option<NaiveTime>>, runtime_intraday_end_time: Cell<Option<NaiveTime>>,
runtime_decision_date: Cell<Option<NaiveDate>>, runtime_decision_date: Cell<Option<NaiveDate>>,
runtime_buy_denials: RefCell<BTreeMap<String, String>>,
runtime_order_created_date: Cell<Option<NaiveDate>>, runtime_order_created_date: Cell<Option<NaiveDate>>,
runtime_decision_total_equity: Cell<Option<f64>>, runtime_decision_total_equity: Cell<Option<f64>>,
runtime_target_position_limit: Cell<Option<usize>>, runtime_target_position_limit: Cell<Option<usize>>,
@@ -412,6 +413,7 @@ impl<C, R> BrokerSimulator<C, R> {
runtime_intraday_start_time: Cell::new(None), runtime_intraday_start_time: Cell::new(None),
runtime_intraday_end_time: Cell::new(None), runtime_intraday_end_time: Cell::new(None),
runtime_decision_date: Cell::new(None), runtime_decision_date: Cell::new(None),
runtime_buy_denials: RefCell::new(BTreeMap::new()),
runtime_order_created_date: Cell::new(None), runtime_order_created_date: Cell::new(None),
runtime_decision_total_equity: Cell::new(None), runtime_decision_total_equity: Cell::new(None),
runtime_target_position_limit: Cell::new(None), runtime_target_position_limit: Cell::new(None),
@@ -448,6 +450,7 @@ impl<C, R> BrokerSimulator<C, R> {
runtime_intraday_start_time: Cell::new(None), runtime_intraday_start_time: Cell::new(None),
runtime_intraday_end_time: Cell::new(None), runtime_intraday_end_time: Cell::new(None),
runtime_decision_date: Cell::new(None), runtime_decision_date: Cell::new(None),
runtime_buy_denials: RefCell::new(BTreeMap::new()),
runtime_order_created_date: Cell::new(None), runtime_order_created_date: Cell::new(None),
runtime_decision_total_equity: Cell::new(None), runtime_decision_total_equity: Cell::new(None),
runtime_target_position_limit: Cell::new(None), runtime_target_position_limit: Cell::new(None),
@@ -966,6 +969,31 @@ where
} }
} }
fn complete_daily_target_position(intent: &OrderIntent) -> Option<(&str, bool)> {
match intent.unwrapped() {
OrderIntent::TargetValue {
symbol,
target_value,
reason,
}
| OrderIntent::TimedTargetValue {
symbol,
target_value,
reason,
..
}
| OrderIntent::LimitTargetValue {
symbol,
target_value,
reason,
..
} if reason == "model_target_portfolio_daily" => {
Some((symbol, target_value.is_finite() && *target_value > 0.0))
}
_ => None,
}
}
fn infer_target_position_limit( fn infer_target_position_limit(
&self, &self,
portfolio: &PortfolioState, portfolio: &PortfolioState,
@@ -979,6 +1007,21 @@ where
return None; return None;
} }
let complete_daily_targets = intents
.iter()
.filter_map(|intent| Self::complete_daily_target_position(intent))
.collect::<Vec<_>>();
if !complete_daily_targets.is_empty() {
return Some(
complete_daily_targets
.into_iter()
.filter(|(_, positive)| *positive)
.map(|(symbol, _)| symbol)
.collect::<BTreeSet<_>>()
.len(),
);
}
let held_symbols = portfolio let held_symbols = portfolio
.positions() .positions()
.iter() .iter()
@@ -1345,6 +1388,7 @@ where
decision: &StrategyDecision, decision: &StrategyDecision,
) -> Result<BrokerExecutionReport, BacktestError> { ) -> Result<BrokerExecutionReport, BacktestError> {
let previous_decision_date = self.runtime_decision_date.get(); let previous_decision_date = self.runtime_decision_date.get();
let previous_buy_denials = self.runtime_buy_denials.replace(decision.buy_denials.clone());
let previous_order_created_date = self.runtime_order_created_date.get(); let previous_order_created_date = self.runtime_order_created_date.get();
let previous_decision_total_equity = self.runtime_decision_total_equity.get(); let previous_decision_total_equity = self.runtime_decision_total_equity.get();
self.runtime_decision_date.set(Some(decision_date)); self.runtime_decision_date.set(Some(decision_date));
@@ -1353,6 +1397,7 @@ where
self.runtime_decision_total_equity self.runtime_decision_total_equity
.set(decision_total_equity.filter(|equity| equity.is_finite() && *equity >= 0.0)); .set(decision_total_equity.filter(|equity| equity.is_finite() && *equity >= 0.0));
let result = self.execute_with_runtime_dates(date, portfolio, data, decision); let result = self.execute_with_runtime_dates(date, portfolio, data, decision);
self.runtime_buy_denials.replace(previous_buy_denials);
self.runtime_decision_date.set(previous_decision_date); self.runtime_decision_date.set(previous_decision_date);
self.runtime_order_created_date self.runtime_order_created_date
.set(previous_order_created_date); .set(previous_order_created_date);
@@ -2793,6 +2838,18 @@ where
} }
} }
if existing.side == OrderSide::Buy
&& (target_total_quantity > existing.requested_quantity
|| target_limit_price > existing.limit_price)
&& let Some(denial) = self.runtime_buy_denials.borrow().get(&existing.symbol)
{
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() let resets_queue_priority = target_limit_price.to_bits() != existing.limit_price.to_bits()
|| target_total_quantity > existing.requested_quantity; || target_total_quantity > existing.requested_quantity;
{ {
@@ -4149,6 +4206,9 @@ where
if !rule.allowed { if !rule.allowed {
return rule.reason; return rule.reason;
} }
if let Some(reason) = self.runtime_buy_denials.borrow().get(symbol) {
return Some(reason.clone());
}
match self.market_fillable_quantity( match self.market_fillable_quantity(
snapshot, snapshot,
OrderSide::Buy, OrderSide::Buy,
@@ -6100,6 +6160,12 @@ where
data.instrument(symbol), data.instrument(symbol),
algo_request, algo_request,
); );
let rule = if rule.allowed && emit_creation_events {
self.runtime_buy_denials.borrow().get(symbol)
.map_or(rule, |reason| RuleCheck::reject(reason.clone()))
} else {
rule
};
if !rule.allowed { if !rule.allowed {
let rule_reason = rule.reason.as_deref().unwrap_or_default().to_string(); let rule_reason = rule.reason.as_deref().unwrap_or_default().to_string();
let status = match rule.reason.as_deref() { let status = match rule.reason.as_deref() {
@@ -8173,6 +8239,136 @@ mod tests {
} }
} }
#[test]
fn decision_buy_denial_blocks_topup_but_allows_sell_and_does_not_leak() {
let first = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
let second = chrono::NaiveDate::from_ymd_opt(2025, 1, 3).unwrap();
let data = DataSet::from_components(
vec![limit_test_instrument()],
vec![dated_limit_test_snapshot(first), dated_limit_test_snapshot(second)],
Vec::new(),
vec![dated_limit_test_candidate(first, false, false, true, true),
dated_limit_test_candidate(second, false, false, true, true)],
vec![dated_limit_test_benchmark(first), dated_limit_test_benchmark(second)],
).unwrap();
let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
.with_matching_type(MatchingType::CurrentBarClose);
let mut portfolio = PortfolioState::new(100_000.0);
broker.execute(first, &mut portfolio, &data, &next_open_buy_decision()).unwrap();
assert_eq!(portfolio.position("000001.SZ").unwrap().quantity, 100);
let mut blocked = StrategyDecision::default();
blocked.buy_denials.insert("000001.SZ".to_string(), "strategy_buy_condition_false".to_string());
blocked.order_intents.push(OrderIntent::TargetValue {
symbol: "000001.SZ".to_string(), target_value: 3_000.0, reason: "topup".to_string(),
});
let report = broker.execute(second, &mut portfolio, &data, &blocked).unwrap();
assert!(report.fill_events.is_empty());
assert_eq!(portfolio.position("000001.SZ").unwrap().quantity, 100);
assert!(broker.runtime_buy_denials.borrow().is_empty());
blocked.order_intents = next_open_sell_decision().order_intents;
let report = broker.execute(second, &mut portfolio, &data, &blocked).unwrap();
assert_eq!(report.fill_events.len(), 1);
assert_eq!(report.fill_events[0].side, OrderSide::Sell);
assert!(broker.runtime_buy_denials.borrow().is_empty());
}
#[test]
fn decision_buy_denial_does_not_rewrite_existing_pending_order() {
let date = limit_test_snapshot().date;
let data = DataSet::from_components(vec![limit_test_instrument()], vec![limit_test_snapshot()],
Vec::new(), vec![limit_test_candidate(true, true)], vec![limit_test_benchmark()]).unwrap();
let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
.with_matching_type(MatchingType::CurrentBarClose);
broker.upsert_open_order(test_open_order(99));
let mut decision = StrategyDecision::default();
decision.buy_denials.insert("000001.SZ".to_string(), "strategy_buy_condition_false".to_string());
let mut portfolio = PortfolioState::new(100_000.0);
let report = broker.execute(date, &mut portfolio, &data, &decision).unwrap();
assert!(!report.fill_events.is_empty());
assert!(broker.runtime_buy_denials.borrow().is_empty());
}
#[test]
fn decision_buy_denial_uses_actual_next_open_target_delta() {
let first = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
let second = chrono::NaiveDate::from_ymd_opt(2025, 1, 3).unwrap();
let mut next = dated_limit_test_snapshot(second);
next.day_open = 9.5;
next.open = 9.5;
next.close = 9.5;
next.last_price = 9.5;
next.bid1 = 9.5;
next.ask1 = 9.5;
let data = DataSet::from_components(vec![limit_test_instrument()],
vec![dated_limit_test_snapshot(first), next], Vec::new(),
vec![dated_limit_test_candidate(first, false, false, true, true),
dated_limit_test_candidate(second, false, false, true, true)],
vec![dated_limit_test_benchmark(first), dated_limit_test_benchmark(second)]).unwrap();
let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
.with_matching_type(MatchingType::NextBarOpen);
let mut portfolio = PortfolioState::new(1_000_000.0);
let mut initial = StrategyDecision::default();
initial.order_intents.push(OrderIntent::Shares {
symbol: "000001.SZ".to_string(), quantity: 10_000, reason: "initial".to_string(),
});
broker.execute(first, &mut portfolio, &data, &initial).unwrap();
let mut decision = StrategyDecision::default();
decision.buy_denials.insert("000001.SZ".to_string(), "strategy_buy_condition_false".to_string());
// Below the signal-day holding value, but above next-open value.
decision.order_intents.push(OrderIntent::TargetValue {
symbol: "000001.SZ".to_string(), target_value: 97_500.0, reason: "target".to_string(),
});
let report = broker.execute_with_event_dates_and_decision_equity(
second, first, first, None, &mut portfolio, &data, &decision).unwrap();
assert!(report.fill_events.is_empty());
assert_eq!(portfolio.position("000001.SZ").unwrap().quantity, 10_000);
assert!(report.order_events.iter().any(|event| event.side == OrderSide::Buy));
assert!(broker.runtime_buy_denials.borrow().is_empty());
}
#[test]
fn decision_buy_denial_rejects_increasing_amendments_without_mutation() {
let date = limit_test_snapshot().date;
let data = DataSet::from_components(vec![limit_test_instrument()], vec![limit_test_snapshot()],
Vec::new(), vec![limit_test_candidate(true, true)], vec![limit_test_benchmark()]).unwrap();
for (quantity, price) in [(Some(300), None), (None, Some(10.5)),
(Some(100), Some(10.5)), (Some(300), Some(9.5))] {
let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks);
broker.upsert_open_order(test_open_order(1));
broker.upsert_open_order(test_open_order(2));
broker.runtime_buy_denials.borrow_mut().insert("000001.SZ".to_string(), "strategy_buy_condition_false".to_string());
let portfolio = PortfolioState::new(100_000.0);
let mut report = BrokerExecutionReport::default();
broker.modify_open_order(date, &portfolio, &data, 1, quantity, price, "amend", &mut report);
let orders = broker.open_orders.borrow();
assert_eq!(orders.iter().map(|order| order.order_id).collect::<Vec<_>>(), vec![1, 2]);
assert_eq!(orders[0].requested_quantity, 200);
assert_eq!(orders[0].remaining_quantity, 200);
assert_eq!(orders[0].limit_price, 10.0);
assert!(report.order_events.is_empty());
let event = report.process_events.last().unwrap();
assert_eq!(event.kind, crate::events::ProcessEventKind::OrderUpdateReject);
assert!(event.detail.contains("strategy_buy_condition_false"));
}
}
#[test]
fn decision_buy_denial_allows_reducing_an_existing_buy() {
let date = limit_test_snapshot().date;
let data = DataSet::from_components(vec![limit_test_instrument()], vec![limit_test_snapshot()],
Vec::new(), vec![limit_test_candidate(true, true)], vec![limit_test_benchmark()]).unwrap();
let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks);
broker.upsert_open_order(test_open_order(1));
broker.runtime_buy_denials.borrow_mut().insert("000001.SZ".to_string(), "strategy_buy_condition_false".to_string());
let portfolio = PortfolioState::new(100_000.0);
let mut report = BrokerExecutionReport::default();
broker.modify_open_order(date, &portfolio, &data, 1, Some(100), Some(9.5), "reduce", &mut report);
let orders = broker.open_orders.borrow();
assert_eq!(orders[0].requested_quantity, 100);
assert_eq!(orders[0].limit_price, 9.5);
assert!(!report.order_events.last().unwrap().reason.contains("strategy_buy_condition_false"));
}
fn next_open_sell_decision() -> StrategyDecision { fn next_open_sell_decision() -> StrategyDecision {
StrategyDecision { StrategyDecision {
order_intents: vec![OrderIntent::Shares { order_intents: vec![OrderIntent::Shares {
@@ -10206,6 +10402,43 @@ mod tests {
} }
} }
#[test]
fn complete_daily_target_batch_uses_positive_target_count_for_slot_limit() {
let prev_date = chrono::NaiveDate::from_ymd_opt(2025, 1, 1).expect("valid date");
let broker = BrokerSimulator::new_with_execution_price(
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks,
PriceField::Open,
);
let mut portfolio = PortfolioState::new(20_000.0);
for symbol in ["000001.SZ", "000002.SZ", "000003.SZ"] {
portfolio.position_mut(symbol).buy(prev_date, 1_000, 10.0);
}
let intents = vec![
OrderIntent::TargetValue {
symbol: "000001.SZ".to_string(),
target_value: 0.0,
reason: "stop_loss_exit".to_string(),
},
OrderIntent::TargetValue {
symbol: "000002.SZ".to_string(),
target_value: 10_000.0,
reason: "model_target_portfolio_daily".to_string(),
},
OrderIntent::TargetValue {
symbol: "000004.SZ".to_string(),
target_value: 10_000.0,
reason: "model_target_portfolio_daily".to_string(),
},
];
let refs = intents.iter().collect::<Vec<_>>();
assert_eq!(
broker.infer_target_position_limit(&portfolio, &refs),
Some(2)
);
}
#[test] #[test]
fn failed_target_exit_blocks_replacement_entry_when_no_position_slot_is_released() { fn failed_target_exit_blocks_replacement_entry_when_no_position_slot_is_released() {
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date"); let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date");
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+43 -3
View File
@@ -1,4 +1,5 @@
use std::collections::{BTreeMap, BTreeSet}; use std::collections::{BTreeMap, BTreeSet};
use std::sync::Arc;
use chrono::{Datelike, Duration, NaiveDate, NaiveTime}; use chrono::{Datelike, Duration, NaiveDate, NaiveTime};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -90,6 +91,9 @@ impl Default for ProcessEventRetention {
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DailyEquityPoint { pub struct DailyEquityPoint {
/// Close-of-signal-day cash baseline before lagged trading begins.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub signal_baseline: bool,
#[serde(with = "date_format")] #[serde(with = "date_format")]
pub date: NaiveDate, pub date: NaiveDate,
pub cash: f64, pub cash: f64,
@@ -108,6 +112,14 @@ pub struct DailyEquityPoint {
pub diagnostics: String, pub diagnostics: String,
} }
impl DailyEquityPoint {
pub fn benchmark_reference_close(&self) -> f64 {
if self.signal_baseline { self.benchmark_close }
else if self.benchmark_prev_close.is_finite() && self.benchmark_prev_close > f64::EPSILON { self.benchmark_prev_close }
else { self.benchmark_close }
}
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct BacktestResult { pub struct BacktestResult {
pub strategy_name: String, pub strategy_name: String,
@@ -333,7 +345,7 @@ impl BacktestResult {
let mut previous_benchmark = self let mut previous_benchmark = self
.equity_curve .equity_curve
.first() .first()
.map(|point| point.benchmark_prev_close) .map(DailyEquityPoint::benchmark_reference_close)
.unwrap_or_default(); .unwrap_or_default();
for point in &self.equity_curve { for point in &self.equity_curve {
let point_nav = if point.unit_nav.is_finite() && point.unit_nav > 0.0 { let point_nav = if point.unit_nav.is_finite() && point.unit_nav > 0.0 {
@@ -453,6 +465,7 @@ pub struct BacktestEngine<S, C, R> {
futures_cost_model: FuturesTransactionCostModel, futures_cost_model: FuturesTransactionCostModel,
futures_validation_config: FuturesValidationConfig, futures_validation_config: FuturesValidationConfig,
execution_quote_loader: Option<ExecutionQuoteLoader>, execution_quote_loader: Option<ExecutionQuoteLoader>,
preplanned_decision_quote_symbols_by_date: Option<Arc<BTreeMap<NaiveDate, BTreeSet<String>>>>,
execution_quote_request_cache: execution_quote_request_cache:
BTreeSet<(NaiveDate, String, Option<NaiveTime>, Option<NaiveTime>)>, BTreeSet<(NaiveDate, String, Option<NaiveTime>, Option<NaiveTime>)>,
risk_free_rate_contract: Option<RiskFreeRateContract>, risk_free_rate_contract: Option<RiskFreeRateContract>,
@@ -539,6 +552,7 @@ impl<S, C, R> BacktestEngine<S, C, R> {
futures_cost_model: FuturesTransactionCostModel::default(), futures_cost_model: FuturesTransactionCostModel::default(),
futures_validation_config: FuturesValidationConfig::default(), futures_validation_config: FuturesValidationConfig::default(),
execution_quote_loader: None, execution_quote_loader: None,
preplanned_decision_quote_symbols_by_date: None,
execution_quote_request_cache: BTreeSet::new(), execution_quote_request_cache: BTreeSet::new(),
risk_free_rate_contract: None, risk_free_rate_contract: None,
} }
@@ -563,6 +577,14 @@ impl<S, C, R> BacktestEngine<S, C, R> {
self self
} }
pub fn with_preplanned_decision_quote_symbols_by_date(
mut self,
symbols_by_date: Arc<BTreeMap<NaiveDate, BTreeSet<String>>>,
) -> Self {
self.preplanned_decision_quote_symbols_by_date = Some(symbols_by_date);
self
}
pub fn with_dividend_reinvestment(mut self, enabled: bool) -> Self { pub fn with_dividend_reinvestment(mut self, enabled: bool) -> Self {
self.dividend_reinvestment = enabled; self.dividend_reinvestment = enabled;
self self
@@ -2191,6 +2213,7 @@ where
previous_external_cash_flow_total = portfolio.external_cash_flow_total(); previous_external_cash_flow_total = portfolio.external_cash_flow_total();
result.equity_curve.push(DailyEquityPoint { result.equity_curve.push(DailyEquityPoint {
signal_baseline: true,
date: execution_date, date: execution_date,
cash: aggregate_cash, cash: aggregate_cash,
market_value: aggregate_market_value, market_value: aggregate_market_value,
@@ -2519,7 +2542,21 @@ where
)?; )?;
let on_day_open_orders = self.open_order_views(); let on_day_open_orders = self.open_order_views();
let decision_quote_times = self.strategy.decision_quote_times(); let decision_quote_times = self.strategy.decision_quote_times();
if !decision_quote_times.is_empty() { if self.execution_quote_loader.is_some() && !decision_quote_times.is_empty() {
if let Some(preplanned) = self
.preplanned_decision_quote_symbols_by_date
.as_ref()
.map(Arc::clone)
{
let empty_symbols = BTreeSet::new();
let decision_quote_symbols =
preplanned.get(&execution_date).unwrap_or(&empty_symbols);
self.ensure_execution_quotes_for_symbols_at_times(
execution_date,
decision_quote_symbols,
&decision_quote_times,
)?;
} else {
let decision_quote_symbols = let decision_quote_symbols =
self.strategy.decision_quote_symbols(&StrategyContext { self.strategy.decision_quote_symbols(&StrategyContext {
execution_date, execution_date,
@@ -2546,6 +2583,7 @@ where
&decision_quote_times, &decision_quote_times,
)?; )?;
} }
}
self.ensure_execution_quotes_for_portfolio_times( self.ensure_execution_quotes_for_portfolio_times(
execution_date, execution_date,
&portfolio, &portfolio,
@@ -3025,7 +3063,7 @@ where
drop(minute_group); drop(minute_group);
drop(minute_quotes); drop(minute_quotes);
drop(quote_data); drop(quote_data);
self.data.remove_execution_quotes_on_date(execution_date); self.data.release_execution_quotes_on_date(execution_date);
} }
portfolio.update_prices_with_options( portfolio.update_prices_with_options(
@@ -3343,6 +3381,7 @@ where
previous_external_cash_flow_total = portfolio.external_cash_flow_total(); previous_external_cash_flow_total = portfolio.external_cash_flow_total();
result.equity_curve.push(DailyEquityPoint { result.equity_curve.push(DailyEquityPoint {
signal_baseline: false,
date: execution_date, date: execution_date,
cash: aggregate_cash, cash: aggregate_cash,
market_value: aggregate_market_value, market_value: aggregate_market_value,
@@ -5326,6 +5365,7 @@ mod tests {
pe_ttm: 12.0, pe_ttm: 12.0,
turnover_ratio: Some(1.0), turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0), effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: BTreeMap::new(),
} }
} }
@@ -0,0 +1,234 @@
//! Cross-sectional operators require an explicit complete universe, never a UI page.
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
pub const OPERATORS: &[&str] = &[
"RANK",
"PERCENTILE",
"TOP",
"BOTTOM",
"TOP_PERCENT",
"BOTTOM_PERCENT",
"WINSORIZE",
"INDUSTRY_NEUTRALIZE",
"SIZE_NEUTRALIZE",
];
#[derive(Clone, Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Observation {
pub symbol: String,
pub value: f64,
pub industry: Option<String>,
pub market_cap: Option<f64>,
}
#[derive(Debug, Serialize)]
pub struct Output {
pub symbol: String,
pub value: f64,
}
/// Every date ranks the same frozen research universe; unknown inputs invalidate the whole date.
pub fn rank_history(
dates: &[chrono::NaiveDate], universe: &[String], values: &BTreeMap<String, Vec<Option<f64>>>,
) -> Result<serde_json::Value, String> {
use serde_json::json;
if dates.is_empty() || dates.windows(2).any(|w| w[0] >= w[1]) || universe.len() < 2
|| universe.len() > 20_000 || dates.len().saturating_mul(universe.len()) > 2_000_000
|| universe.iter().collect::<BTreeSet<_>>().len() != universe.len()
|| values.keys().collect::<BTreeSet<_>>() != universe.iter().collect::<BTreeSet<_>>()
|| values.values().any(|v| v.len() != dates.len() || v.iter().flatten().any(|v| !v.is_finite())) {
return Err("research_rank_history_incomplete_or_invalid_universe".into());
}
let mut rank = universe.iter().map(|s|(s.clone(),vec![None;dates.len()])).collect::<BTreeMap<_,_>>();
let mut percentile = rank.clone();
let mut unknown_dates = Vec::new();
for (i, date) in dates.iter().enumerate() {
let missing = universe.iter().filter(|s|values[*s][i].is_none()).collect::<Vec<_>>();
if !missing.is_empty() {
unknown_dates.push(json!({"date":date,"missing_count":missing.len(),"missing_symbol_sample":missing.iter().take(20).collect::<Vec<_>>(),"sample_limit":20}));
continue;
}
let observations = universe.iter().map(|s|Observation{symbol:s.clone(),value:values[s][i].unwrap(),industry:None,market_cap:None}).collect::<Vec<_>>();
for item in evaluate("RANK", universe, &observations, 0.0)? {rank.get_mut(&item.symbol).unwrap()[i]=Some(item.value);}
for item in evaluate("PERCENTILE", universe, &observations, 0.0)? {percentile.get_mut(&item.symbol).unwrap()[i]=Some(item.value);}
}
Ok(json!({"rank":rank,"percentile":percentile,"unknown_dates":unknown_dates,
"universe":universe,"dates":dates,"tie_policy":"average_rank_descending",
"membership_policy":"fixed_research_scope_not_historical_index_membership"}))
}
fn mean(values: &[f64]) -> f64 {
let base = values[0];
base + values
.iter()
.skip(1)
.map(|v| (v - base) / values.len() as f64)
.sum::<f64>()
}
fn quantile(sorted: &[f64], p: f64) -> f64 {
let x = p * (sorted.len() - 1) as f64;
let l = x.floor() as usize;
let r = x.ceil() as usize;
sorted[l] + (sorted[r] - sorted[l]) * (x - l as f64)
}
pub fn evaluate(
name: &str,
universe: &[String],
rows: &[Observation],
threshold: f64,
) -> Result<Vec<Output>, String> {
let expected = universe.iter().collect::<BTreeSet<_>>();
if rows.is_empty()
|| rows.len() > 20_000
|| expected.len() != universe.len()
|| rows.len() != universe.len()
|| rows.iter().map(|r| &r.symbol).collect::<BTreeSet<_>>() != expected
|| rows.iter().any(|r| !r.value.is_finite())
{
return Err("cross_section_incomplete_or_invalid_universe".into());
}
if !OPERATORS.contains(&name) || !threshold.is_finite() {
return Err("cross_section_operator_invalid".into());
}
if matches!(name, "TOP" | "BOTTOM") && (threshold < 1.0 || threshold.fract() != 0.0)
|| matches!(name, "TOP_PERCENT" | "BOTTOM_PERCENT") && !(0.0..=1.0).contains(&threshold)
|| name == "WINSORIZE" && !(0.0..0.5).contains(&threshold)
{
return Err("cross_section_threshold_invalid".into());
}
let mut sorted = rows.iter().map(|r| r.value).collect::<Vec<_>>();
sorted.sort_by(f64::total_cmp);
let mut industry_values: BTreeMap<&str, Vec<f64>> = BTreeMap::new();
if name == "INDUSTRY_NEUTRALIZE" {
for row in rows {
let industry = row
.industry
.as_deref()
.filter(|v| !v.trim().is_empty())
.ok_or("cross_section_pit_industry_missing")?;
industry_values.entry(industry).or_default().push(row.value);
}
}
let size = if name == "SIZE_NEUTRALIZE" {
let x = rows
.iter()
.map(|r| {
r.market_cap
.filter(|v| v.is_finite() && *v > 0.0)
.map(f64::ln)
.ok_or("cross_section_market_cap_missing")
})
.collect::<Result<Vec<_>, _>>()?;
let xm = mean(&x);
let ym = mean(&sorted);
let variance = x.iter().map(|v| (v - xm).powi(2)).sum::<f64>();
if variance == 0.0 || rows.len() < 3 {
return Err("cross_section_size_regression_unidentified".into());
}
let beta = x
.iter()
.zip(rows)
.map(|(x, y)| (x - xm) * (y.value - ym))
.sum::<f64>()
/ variance;
Some((x, xm, ym, beta))
} else {
None
};
rows.iter()
.enumerate()
.map(|(index, row)| {
let low = sorted.partition_point(|v| *v < row.value);
let high = sorted.partition_point(|v| *v <= row.value);
let rank = (low + 1 + high) as f64 / 2.0;
let descending = (rows.len() + 1) as f64 - rank;
let percentile = if rows.len() == 1 {
0.5
} else {
(rank - 1.0) / (rows.len() - 1) as f64
};
let value = match name {
"RANK" => descending,
"PERCENTILE" => percentile,
"TOP" => f64::from(descending <= threshold),
"BOTTOM" => f64::from(rank <= threshold),
"TOP_PERCENT" => f64::from(descending <= threshold * rows.len() as f64),
"BOTTOM_PERCENT" => f64::from(rank <= threshold * rows.len() as f64),
"WINSORIZE" => row.value.clamp(
quantile(&sorted, threshold),
quantile(&sorted, 1.0 - threshold),
),
"INDUSTRY_NEUTRALIZE" => {
row.value - mean(&industry_values[row.industry.as_deref().unwrap()])
}
"SIZE_NEUTRALIZE" => {
let (x, xm, ym, beta) = size.as_ref().unwrap();
row.value - (ym + beta * (x[index] - xm))
}
_ => unreachable!(),
};
if !value.is_finite() {
return Err("cross_section_result_nonfinite".into());
}
Ok(Output {
symbol: row.symbol.clone(),
value,
})
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn historical_ranks_keep_ties_and_unknown_full_cross_sections() {
let dates=["2026-09-07","2026-09-08","2026-09-09"].map(|d|d.parse().unwrap());
let universe=vec!["A".into(),"B".into(),"C".into()];
let values=BTreeMap::from([("A".into(),vec![None,Some(10.0),Some(20.0)]),("B".into(),vec![Some(10.0),Some(10.0),Some(10.0)]),("C".into(),vec![Some(20.0),Some(5.0),Some(15.0)])]);
let out=rank_history(&dates,&universe,&values).unwrap();
assert_eq!(out["rank"]["A"],serde_json::json!([null,1.5,1.0]));
assert_eq!(out["rank"]["C"],serde_json::json!([null,3.0,2.0]));
assert_eq!(out["unknown_dates"][0]["missing_count"],1);
let earlier=values.iter().map(|(s,v)|(s.clone(),v[..2].to_vec())).collect();
let first=rank_history(&dates[..2],&universe,&earlier).unwrap();
assert_eq!(&out["rank"]["A"].as_array().unwrap()[..2],first["rank"]["A"].as_array().unwrap());
assert!(rank_history(&dates,&universe[..2],&values).is_err());
}
fn rows() -> Vec<Observation> {
[1.0, 3.0, 3.0, 4.0]
.iter()
.enumerate()
.map(|(i, &value)| Observation {
symbol: format!("S{i}"),
value,
industry: Some(if i < 2 { "A" } else { "B" }.into()),
market_cap: Some(10.0 + i as f64),
})
.collect()
}
#[test]
fn ties_keep_equal_rank_and_missing_universe_rejects() {
let r = rows();
let u = r.iter().map(|r| r.symbol.clone()).collect::<Vec<_>>();
let out = evaluate("RANK", &u, &r, 0.0).unwrap();
assert_eq!(
out.iter().map(|r| r.value).collect::<Vec<_>>(),
vec![4.0, 2.5, 2.5, 1.0]
);
assert!(evaluate("RANK", &u, &r[..3], 0.0).is_err());
}
#[test]
fn neutralization_preserves_input_order() {
let r = rows();
let u = r.iter().map(|r| r.symbol.clone()).collect::<Vec<_>>();
let out = evaluate("INDUSTRY_NEUTRALIZE", &u, &r, 0.0).unwrap();
assert_eq!(
out.iter().map(|r| r.value).collect::<Vec<_>>(),
vec![-1.0, 1.0, -0.5, 0.5]
);
assert!(evaluate("TOP_PERCENT", &u, &r, 20.0).is_err());
}
}
File diff suppressed because it is too large Load Diff
+24 -1
View File
@@ -1,6 +1,17 @@
use chrono::NaiveDate; use chrono::NaiveDate;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
pub fn listed_sector_is_kcb(value: &str) -> Option<bool> {
match value.trim().to_ascii_uppercase().as_str() {
"科创板" | "KSH" | "STAR" | "STAR_MARKET" => Some(true),
"主板" | "沪市主板" | "深市主板" | "中小板" | "中小企业板" | "创业板"
| "北交所" | "北证" | "新三板" | "基础层" | "创新层" | "精选层"
| "MAIN" | "MAIN_BOARD" | "CHINEXT" | "GEM" | "BJ" | "BJS" | "BJSE"
| "BSE" => Some(false),
_ => None,
}
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Instrument { pub struct Instrument {
pub symbol: String, pub symbol: String,
@@ -70,7 +81,19 @@ fn default_status() -> String {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::Instrument; use super::{Instrument, listed_sector_is_kcb};
#[test]
fn listing_sector_is_explicit_and_unknown_stays_unknown() {
assert_eq!(listed_sector_is_kcb("科创板"), Some(true));
assert_eq!(listed_sector_is_kcb(" star "), Some(true));
assert_eq!(listed_sector_is_kcb("主板"), Some(false));
assert_eq!(listed_sector_is_kcb("创业板"), Some(false));
assert_eq!(listed_sector_is_kcb("北证"), Some(false));
for value in ["", "-", "SH", "688001.SH", "半导体"] {
assert_eq!(listed_sector_is_kcb(value), None);
}
}
fn instrument(board: &str, round_lot: u32) -> Instrument { fn instrument(board: &str, round_lot: u32) -> Instrument {
Instrument { Instrument {
+7 -2
View File
@@ -2,6 +2,9 @@ pub mod broker;
pub mod calendar; pub mod calendar;
pub mod cost; pub mod cost;
pub mod data; pub mod data;
pub mod daily_patterns;
pub mod factor_events;
pub mod factor_cross_section;
pub mod engine; pub mod engine;
pub mod event_bus; pub mod event_bus;
pub mod events; pub mod events;
@@ -14,6 +17,7 @@ pub mod platform_expr_strategy;
pub mod platform_runtime_schema; pub mod platform_runtime_schema;
pub mod platform_strategy_spec; pub mod platform_strategy_spec;
pub mod portfolio; pub mod portfolio;
pub mod portfolio_loss;
pub mod risk_control; pub mod risk_control;
pub mod rules; pub mod rules;
pub mod scheduler; pub mod scheduler;
@@ -31,8 +35,8 @@ pub use data::{
BenchmarkSnapshot, CandidateEligibility, CorporateAction, DailyFactorSnapshot, BenchmarkSnapshot, CandidateEligibility, CorporateAction, DailyFactorSnapshot,
DailyMarketSnapshot, DailySnapshotBundle, DataSet, DataSetError, DividendRecord, DailyMarketSnapshot, DailySnapshotBundle, DataSet, DataSetError, DividendRecord,
EligibleUniverseSnapshot, FactorTextValue, FactorValue, IntradayExecutionQuote, EligibleUniverseSnapshot, FactorTextValue, FactorValue, IntradayExecutionQuote,
IntradayOrderBookDepthLevel, NumericFactorMap, PriceBar, PriceField, SecuritiesMarginRecord, IntradayMarketSnapshotOverlay, IntradayOrderBookDepthLevel, NumericFactorMap, PriceBar,
SplitRecord, YieldCurvePoint, PriceField, SecuritiesMarginRecord, SplitRecord, YieldCurvePoint,
}; };
pub use engine::{ pub use engine::{
AnalyzerMonthlyReturnRow, AnalyzerPositionRow, AnalyzerReport, AnalyzerRiskSummary, AnalyzerMonthlyReturnRow, AnalyzerPositionRow, AnalyzerReport, AnalyzerRiskSummary,
@@ -83,6 +87,7 @@ pub use platform_strategy_spec::{
platform_expr_config_from_value, validate_strategy_risk_policy_fields, platform_expr_config_from_value, validate_strategy_risk_policy_fields,
}; };
pub use portfolio::{CashReceivable, HoldingSummary, PendingCashFlow, PortfolioState, Position}; pub use portfolio::{CashReceivable, HoldingSummary, PendingCashFlow, PortfolioState, Position};
pub use portfolio_loss::{ClosedPortfolioSession, PortfolioLossConfig, PortfolioLossDecision, PortfolioLossError, PortfolioLossState};
pub use risk_control::{ pub use risk_control::{
ChinaAShareRiskControl, FidcRiskControlConfig, FidcRiskDecisionAudit, RiskCheckScope, ChinaAShareRiskControl, FidcRiskControlConfig, FidcRiskDecisionAudit, RiskCheckScope,
StaticRiskRuleConfig, TradingConstraintConfig, StaticRiskRuleConfig, TradingConstraintConfig,
+12 -7
View File
@@ -108,13 +108,7 @@ pub fn compute_backtest_metrics(
}; };
let trade_days = equity_curve.len(); let trade_days = equity_curve.len();
let benchmark_start = if first_point.benchmark_prev_close.is_finite() let benchmark_start = first_point.benchmark_reference_close();
&& first_point.benchmark_prev_close > f64::EPSILON
{
first_point.benchmark_prev_close
} else {
first_point.benchmark_close
};
let explicit_unit_nav = equity_curve.iter().any(|point| { let explicit_unit_nav = equity_curve.iter().any(|point| {
point.external_cash_flow.abs() > f64::EPSILON point.external_cash_flow.abs() > f64::EPSILON
|| (point.unit_nav.is_finite() || (point.unit_nav.is_finite()
@@ -780,6 +774,7 @@ mod tests {
benchmark_prev_close: f64, benchmark_prev_close: f64,
) -> DailyEquityPoint { ) -> DailyEquityPoint {
DailyEquityPoint { DailyEquityPoint {
signal_baseline: false,
date: NaiveDate::parse_from_str(date, "%Y-%m-%d").unwrap(), date: NaiveDate::parse_from_str(date, "%Y-%m-%d").unwrap(),
cash: total_equity, cash: total_equity,
market_value: 0.0, market_value: 0.0,
@@ -804,11 +799,21 @@ mod tests {
assert!((metrics.benchmark_cumulative_return - expected).abs() < 1e-12); assert!((metrics.benchmark_cumulative_return - expected).abs() < 1e-12);
} }
#[test]
fn signal_baseline_uses_same_close_for_strategy_and_benchmark() {
let mut baseline=equity_point("2026-09-04",100.0,4548.0499,4552.5784);
baseline.signal_baseline=true;
let curve=vec![baseline,equity_point("2026-09-08",104.0,4558.7371,4575.0245)];
let metrics=compute_backtest_metrics(&curve,&[],&[],&[],100.0,None).unwrap();
assert!((metrics.benchmark_cumulative_return-(4558.7371/4548.0499-1.0)).abs()<1e-12);
}
#[test] #[test]
fn external_cash_flow_is_excluded_from_return_and_reported_separately() { fn external_cash_flow_is_excluded_from_return_and_reported_separately() {
let curve = vec![ let curve = vec![
equity_point("2025-01-02", 100.0, 100.0, 100.0), equity_point("2025-01-02", 100.0, 100.0, 100.0),
DailyEquityPoint { DailyEquityPoint {
signal_baseline: false,
date: NaiveDate::from_ymd_opt(2025, 1, 3).unwrap(), date: NaiveDate::from_ymd_opt(2025, 1, 3).unwrap(),
cash: 220.0, cash: 220.0,
market_value: 0.0, market_value: 0.0,
+204 -39
View File
@@ -11,6 +11,7 @@ pub(crate) enum ValueType {
pub(crate) enum Value { pub(crate) enum Value {
Number(f64), Number(f64),
Boolean(bool), Boolean(bool),
Missing(ValueType),
} }
impl Value { impl Value {
@@ -18,20 +19,28 @@ impl Value {
match self { match self {
Self::Number(_) => ValueType::Number, Self::Number(_) => ValueType::Number,
Self::Boolean(_) => ValueType::Boolean, Self::Boolean(_) => ValueType::Boolean,
Self::Missing(value_type) => value_type,
} }
} }
pub(crate) fn as_number(self) -> Option<f64> { pub(crate) fn as_number(self) -> Option<f64> {
match self { match self {
Self::Number(value) => Some(value), Self::Number(value) => Some(value),
Self::Boolean(_) => None, Self::Boolean(_) | Self::Missing(_) => None,
} }
} }
pub(crate) fn as_bool(self) -> Option<bool> { pub(crate) fn as_bool(self) -> Option<bool> {
match self { match self {
Self::Boolean(value) => Some(value), Self::Boolean(value) => Some(value),
Self::Number(_) => None, Self::Number(_) | Self::Missing(_) => None,
}
}
fn normalized(self) -> Self {
match self {
Self::Number(value) if !value.is_finite() => Self::Missing(ValueType::Number),
value => value,
} }
} }
} }
@@ -103,6 +112,8 @@ enum BinaryOp {
LessEqual, LessEqual,
Greater, Greater,
GreaterEqual, GreaterEqual,
And,
Or,
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -134,7 +145,7 @@ enum Instruction {
Binary(BinaryOp), Binary(BinaryOp),
Call { builtin: Builtin, argc: u8 }, Call { builtin: Builtin, argc: u8 },
JumpIfFalse(usize), JumpIfFalse(usize),
JumpIfTrue(usize), ShortCircuit { on: bool, target: usize },
Jump(usize), Jump(usize),
Return, Return,
} }
@@ -165,7 +176,7 @@ impl Program {
let mut pc = 0usize; let mut pc = 0usize;
while let Some(instruction) = self.instructions.get(pc) { while let Some(instruction) = self.instructions.get(pc) {
match *instruction { match *instruction {
Instruction::Push(value) => scratch.stack.push(value), Instruction::Push(value) => scratch.stack.push(value.normalized()),
Instruction::LoadVariable(index) => { Instruction::LoadVariable(index) => {
let index = usize::from(index); let index = usize::from(index);
let cached = scratch.variables[index]; let cached = scratch.variables[index];
@@ -173,7 +184,8 @@ impl Program {
Some(value) => value, Some(value) => value,
None => { None => {
let expected_type = self.variable_types[index]; let expected_type = self.variable_types[index];
let value = resolve(index, &self.variables[index], expected_type)?; let value =
resolve(index, &self.variables[index], expected_type)?.normalized();
if value.value_type() != expected_type { if value.value_type() != expected_type {
return Err(EvalError::new(format!( return Err(EvalError::new(format!(
"variable {} expected {:?}, got {:?}", "variable {} expected {:?}, got {:?}",
@@ -219,15 +231,23 @@ impl Program {
scratch.stack.push(value); scratch.stack.push(value);
} }
Instruction::JumpIfFalse(target) => { Instruction::JumpIfFalse(target) => {
let condition = pop_bool(&mut scratch.stack)?; // Like CASE WHEN, only a known true condition takes this branch.
let condition = match pop(&mut scratch.stack)? {
Value::Boolean(value) => value,
Value::Missing(ValueType::Boolean) => false,
_ => return Err(EvalError::new("boolean operand required")),
};
if !condition { if !condition {
pc = target; pc = target;
continue; continue;
} }
} }
Instruction::JumpIfTrue(target) => { Instruction::ShortCircuit { on, target } => {
let condition = pop_bool(&mut scratch.stack)?; let condition =
if condition { scratch.stack.last().copied().ok_or_else(|| {
EvalError::new("stack underflow during short circuit")
})?;
if condition.as_bool() == Some(on) {
pc = target; pc = target;
continue; continue;
} }
@@ -284,31 +304,56 @@ fn pop(stack: &mut Vec<Value>) -> Result<Value, EvalError> {
stack.pop().ok_or_else(|| EvalError::new("stack underflow")) stack.pop().ok_or_else(|| EvalError::new("stack underflow"))
} }
fn pop_bool(stack: &mut Vec<Value>) -> Result<bool, EvalError> {
pop(stack)?
.as_bool()
.ok_or_else(|| EvalError::new("boolean operand required"))
}
fn number(value: Value) -> Result<f64, EvalError> { fn number(value: Value) -> Result<f64, EvalError> {
if value == Value::Missing(ValueType::Number) {
return Ok(f64::NAN);
}
value value
.as_number() .as_number()
.ok_or_else(|| EvalError::new("numeric operand required")) .ok_or_else(|| EvalError::new("numeric operand required"))
} }
fn eval_unary(operator: UnaryOp, value: Value) -> Result<Value, EvalError> { fn eval_unary(operator: UnaryOp, value: Value) -> Result<Value, EvalError> {
match operator { if matches!(value, Value::Missing(_)) {
return Ok(value);
}
let result: Result<Value, EvalError> = match operator {
UnaryOp::Negate => Ok(Value::Number(-number(value)?)), UnaryOp::Negate => Ok(Value::Number(-number(value)?)),
UnaryOp::Not => { UnaryOp::Not => {
Ok(Value::Boolean(!value.as_bool().ok_or_else(|| { Ok(Value::Boolean(!value.as_bool().ok_or_else(|| {
EvalError::new("boolean operand required for !") EvalError::new("boolean operand required for !")
})?)) })?))
} }
} };
Ok(result?.normalized())
} }
fn eval_binary(operator: BinaryOp, lhs: Value, rhs: Value) -> Result<Value, EvalError> { fn eval_binary(operator: BinaryOp, lhs: Value, rhs: Value) -> Result<Value, EvalError> {
match operator { if matches!(operator, BinaryOp::And | BinaryOp::Or) {
let (lhs, rhs) = (lhs.as_bool(), rhs.as_bool());
let result = match operator {
BinaryOp::And if lhs == Some(false) || rhs == Some(false) => Some(false),
BinaryOp::And if lhs == Some(true) && rhs == Some(true) => Some(true),
BinaryOp::Or if lhs == Some(true) || rhs == Some(true) => Some(true),
BinaryOp::Or if lhs == Some(false) && rhs == Some(false) => Some(false),
_ => None,
};
return Ok(result
.map(Value::Boolean)
.unwrap_or(Value::Missing(ValueType::Boolean)));
}
if matches!(lhs, Value::Missing(_)) || matches!(rhs, Value::Missing(_)) {
let value_type = match operator {
BinaryOp::Add
| BinaryOp::Subtract
| BinaryOp::Multiply
| BinaryOp::Divide
| BinaryOp::Remainder => ValueType::Number,
_ => ValueType::Boolean,
};
return Ok(Value::Missing(value_type));
}
let result: Result<Value, EvalError> = match operator {
BinaryOp::Add => Ok(Value::Number(number(lhs)? + number(rhs)?)), BinaryOp::Add => Ok(Value::Number(number(lhs)? + number(rhs)?)),
BinaryOp::Subtract => Ok(Value::Number(number(lhs)? - number(rhs)?)), BinaryOp::Subtract => Ok(Value::Number(number(lhs)? - number(rhs)?)),
BinaryOp::Multiply => Ok(Value::Number(number(lhs)? * number(rhs)?)), BinaryOp::Multiply => Ok(Value::Number(number(lhs)? * number(rhs)?)),
@@ -356,7 +401,27 @@ fn eval_binary(operator: BinaryOp, lhs: Value, rhs: Value) -> Result<Value, Eval
(lhs - rhs) / float_comparison_scale(lhs, rhs) > -f64::EPSILON, (lhs - rhs) / float_comparison_scale(lhs, rhs) > -f64::EPSILON,
)) ))
} }
BinaryOp::And | BinaryOp::Or => unreachable!(),
};
Ok(result?.normalized())
}
pub(crate) fn finite_comparison(operator: &str, lhs: f64, rhs: f64) -> Option<bool> {
if !lhs.is_finite() || !rhs.is_finite() {
return None;
} }
let operator = match operator {
"==" => BinaryOp::Equal,
"!=" => BinaryOp::NotEqual,
"<" => BinaryOp::Less,
"<=" => BinaryOp::LessEqual,
">" => BinaryOp::Greater,
">=" => BinaryOp::GreaterEqual,
_ => return None,
};
eval_binary(operator, Value::Number(lhs), Value::Number(rhs))
.ok()?
.as_bool()
} }
fn float_comparison_scale(lhs: f64, rhs: f64) -> f64 { fn float_comparison_scale(lhs: f64, rhs: f64) -> f64 {
@@ -382,7 +447,16 @@ fn eval_builtin(builtin: Builtin, args: &[Value]) -> Result<Value, EvalError> {
.ok_or_else(|| EvalError::new("missing builtin argument")) .ok_or_else(|| EvalError::new("missing builtin argument"))
.and_then(number) .and_then(number)
}; };
Ok(match builtin { if !matches!(builtin, Builtin::Nz | Builtin::SafeDiv | Builtin::Iff)
&& args.iter().any(|value| matches!(value, Value::Missing(_)))
{
return Ok(Value::Missing(if builtin == Builtin::Between {
ValueType::Boolean
} else {
ValueType::Number
}));
}
let result = match builtin {
Builtin::Round => Value::Number(numeric(0)?.round()), Builtin::Round => Value::Number(numeric(0)?.round()),
Builtin::Floor => Value::Number(numeric(0)?.floor()), Builtin::Floor => Value::Number(numeric(0)?.floor()),
Builtin::Ceil => Value::Number(numeric(0)?.ceil()), Builtin::Ceil => Value::Number(numeric(0)?.ceil()),
@@ -393,7 +467,13 @@ fn eval_builtin(builtin: Builtin, args: &[Value]) -> Result<Value, EvalError> {
Builtin::Pow => Value::Number(numeric(0)?.powf(numeric(1)?)), Builtin::Pow => Value::Number(numeric(0)?.powf(numeric(1)?)),
Builtin::Log => Value::Number(numeric(0)?.ln()), Builtin::Log => Value::Number(numeric(0)?.ln()),
Builtin::Exp => Value::Number(numeric(0)?.exp()), Builtin::Exp => Value::Number(numeric(0)?.exp()),
Builtin::Clamp => Value::Number(numeric(0)?.clamp(numeric(1)?, numeric(2)?)), Builtin::Clamp => {
let (value, low, high) = (numeric(0)?, numeric(1)?, numeric(2)?);
if low > high {
return Err(EvalError::new("clamp lower bound exceeds upper bound"));
}
Value::Number(value.clamp(low, high))
}
Builtin::Between => { Builtin::Between => {
let value = numeric(0)?; let value = numeric(0)?;
Value::Boolean(value >= numeric(1)? && value <= numeric(2)?) Value::Boolean(value >= numeric(1)? && value <= numeric(2)?)
@@ -417,13 +497,15 @@ fn eval_builtin(builtin: Builtin, args: &[Value]) -> Result<Value, EvalError> {
}) })
} }
Builtin::Iff => { Builtin::Iff => {
let condition = args let condition = match args.first().copied() {
.first() Some(Value::Boolean(value)) => value,
.and_then(|value| value.as_bool()) Some(Value::Missing(ValueType::Boolean)) => false,
.ok_or_else(|| EvalError::new("iff condition must be boolean"))?; _ => return Err(EvalError::new("iff condition must be boolean")),
};
if condition { args[1] } else { args[2] } if condition { args[1] } else { args[2] }
} }
}) };
Ok(result.normalized())
} }
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
@@ -1119,24 +1201,20 @@ where
let lhs_type = self.expression(lhs)?; let lhs_type = self.expression(lhs)?;
require_type(lhs_type, ValueType::Boolean, position)?; require_type(lhs_type, ValueType::Boolean, position)?;
let branch = self.instructions.len(); let branch = self.instructions.len();
self.instructions.push(match operator { self.instructions.push(Instruction::ShortCircuit {
ParsedBinaryOp::And => Instruction::JumpIfFalse(usize::MAX), on: operator == ParsedBinaryOp::Or,
ParsedBinaryOp::Or => Instruction::JumpIfTrue(usize::MAX), target: usize::MAX,
_ => unreachable!(),
}); });
let rhs_type = self.expression(rhs)?; let rhs_type = self.expression(rhs)?;
require_type(rhs_type, ValueType::Boolean, rhs.position())?; require_type(rhs_type, ValueType::Boolean, rhs.position())?;
let end_jump = self.instructions.len();
self.instructions.push(Instruction::Jump(usize::MAX));
let short_target = self.instructions.len();
self.instructions self.instructions
.push(Instruction::Push(Value::Boolean(matches!( .push(Instruction::Binary(if operator == ParsedBinaryOp::And {
operator, BinaryOp::And
ParsedBinaryOp::Or } else {
)))); BinaryOp::Or
}));
let end_target = self.instructions.len(); let end_target = self.instructions.len();
patch_jump(&mut self.instructions, branch, short_target)?; patch_jump(&mut self.instructions, branch, end_target)?;
patch_jump(&mut self.instructions, end_jump, end_target)?;
return Ok(ValueType::Boolean); return Ok(ValueType::Boolean);
} }
@@ -1285,7 +1363,7 @@ fn patch_jump(
}; };
match instruction { match instruction {
Instruction::JumpIfFalse(value) Instruction::JumpIfFalse(value)
| Instruction::JumpIfTrue(value) | Instruction::ShortCircuit { target: value, .. }
| Instruction::Jump(value) => { | Instruction::Jump(value) => {
*value = target; *value = target;
Ok(()) Ok(())
@@ -1394,6 +1472,93 @@ mod tests {
); );
} }
#[test]
fn nullable_boolean_truth_table_preserves_unknown_under_negation() {
let unknown = Value::Missing(ValueType::Boolean);
let states = [Value::Boolean(false), Value::Boolean(true), unknown];
let and = [
[states[0], states[0], states[0]],
[states[0], states[1], unknown],
[states[0], unknown, unknown],
];
let or = [
[states[0], states[1], unknown],
[states[1], states[1], states[1]],
[unknown, states[1], unknown],
];
for (i, lhs) in states.iter().enumerate() {
for (j, rhs) in states.iter().enumerate() {
let values = [("lhs", *lhs), ("rhs", *rhs)];
assert_eq!(evaluate("lhs && rhs", &values), and[i][j]);
assert_eq!(evaluate("lhs || rhs", &values), or[i][j]);
assert_eq!(evaluate("!!(lhs && rhs)", &values), and[i][j]);
assert_eq!(evaluate("!!(lhs || rhs)", &values), or[i][j]);
}
}
assert_eq!(evaluate("!value", &[("value", unknown)]), unknown);
}
#[test]
fn missing_numeric_operands_do_not_become_boolean_false_or_zero() {
let unknown = Value::Missing(ValueType::Boolean);
for missing in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
for operator in ["==", "!=", "<", "<=", ">", ">="] {
let values = [("value", Value::Number(missing))];
assert_eq!(evaluate(&format!("value {operator} 0.0"), &values), unknown);
assert_eq!(
evaluate(&format!("!(0.0 {operator} value)"), &values),
unknown
);
assert_eq!(
evaluate(&format!("!((value + 1.0) {operator} 0.0)"), &values),
unknown
);
}
}
let missing = [("value", Value::Number(f64::NAN))];
assert_eq!(evaluate("!(min(value, 1.0) > 0.0)", &missing), unknown);
assert_eq!(evaluate("!between(value, 0.0, 1.0)", &missing), unknown);
assert_eq!(evaluate("!(1.0 / 0.0 > 0.0)", &[]), unknown);
assert_eq!(evaluate("!(sqrt(-1.0) > 0.0)", &[]), unknown);
assert_eq!(evaluate("nz(value, 7.0)", &missing), Value::Number(7.0));
assert_eq!(
evaluate("nz(value, 0.0) == 0.0", &missing),
Value::Boolean(true)
);
}
#[test]
fn nullable_short_circuit_and_explicit_conditional_branches_are_lazy() {
for source in [
"false && missing",
"true || missing",
"if true { true } else { missing }",
] {
let program = compile(source, |_| Some(ValueType::Boolean)).unwrap();
program
.evaluate(&mut Scratch::default(), |_, _, _| {
Err(EvalError::new("unused input must not be resolved"))
})
.unwrap();
}
let unknown = Value::Missing(ValueType::Boolean);
assert_eq!(
evaluate("if value { 1.0 } else { 2.0 }", &[("value", unknown)]),
Value::Number(2.0)
);
assert_eq!(
evaluate("iff(value, 1.0, 2.0)", &[("value", unknown)]),
Value::Number(2.0)
);
}
#[test]
fn invalid_clamp_returns_error_without_panicking() {
let program = compile("clamp(1.0, 2.0, 0.0)", |_| None).unwrap();
let result = program.evaluate(&mut Scratch::default(), |_, _, _| unreachable!());
assert!(result.unwrap_err().to_string().contains("lower bound"));
}
#[test] #[test]
fn short_circuit_does_not_resolve_unused_variable() { fn short_circuit_does_not_resolve_unused_variable() {
let program = compile("false && missing", |name| { let program = compile("false && missing", |name| {
File diff suppressed because it is too large Load Diff
@@ -227,6 +227,8 @@ const RUNTIME_HELPER_FUNCTIONS: &[&str] = &[
"factor", "factor",
"day_factor", "day_factor",
"rolling_mean", "rolling_mean",
"pattern_signal",
"pattern_score",
"rolling_mean_current", "rolling_mean_current",
"rolling_max_current", "rolling_max_current",
"rolling_return_stddev_current", "rolling_return_stddev_current",
+203 -20
View File
@@ -3,6 +3,7 @@ use std::collections::{BTreeMap, BTreeSet, HashSet};
use chrono::{NaiveDate, NaiveTime}; use chrono::{NaiveDate, NaiveTime};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::Value; use serde_json::Value;
use crate::portfolio_loss::PortfolioLossConfig;
use crate::{ use crate::{
DynamicSlippageConfig, MatchingType, PlatformAccountActionKind, PlatformExplicitActionStage, DynamicSlippageConfig, MatchingType, PlatformAccountActionKind, PlatformExplicitActionStage,
@@ -62,6 +63,8 @@ pub struct StrategyBenchmarkSpec {
#[derive(Debug, Clone, Default, Deserialize, Serialize)] #[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct StrategyUniverseSpec { pub struct StrategyUniverseSpec {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub include: Option<Vec<String>>,
#[serde(default)] #[serde(default)]
pub exclude: Vec<String>, pub exclude: Vec<String>,
} }
@@ -913,6 +916,8 @@ pub struct StrategyExpressionRiskConfig {
#[serde(default)] #[serde(default)]
pub portfolio_drawdown_control: Option<StrategyPortfolioDrawdownControlConfig>, pub portfolio_drawdown_control: Option<StrategyPortfolioDrawdownControlConfig>,
#[serde(default)] #[serde(default)]
pub portfolio_loss_control: Option<StrategyPortfolioLossControlConfig>,
#[serde(default)]
pub stop_loss_expr: Option<String>, pub stop_loss_expr: Option<String>,
#[serde(default)] #[serde(default)]
pub take_profit_expr: Option<String>, pub take_profit_expr: Option<String>,
@@ -961,6 +966,16 @@ pub struct StrategyPortfolioDrawdownControlConfig {
pub cooldown_trading_days: Option<usize>, pub cooldown_trading_days: Option<usize>,
} }
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct StrategyPortfolioLossControlConfig {
pub enabled: Option<bool>,
pub lookback: Option<usize>,
pub loss_trigger: Option<f64>,
pub floor_exposure: Option<f64>,
pub cooldown_trading_days: Option<usize>,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)] #[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct StrategyExpressionOrderingConfig { pub struct StrategyExpressionOrderingConfig {
@@ -975,6 +990,8 @@ pub struct StrategyExpressionOrderingConfig {
#[derive(Debug, Clone, Default, Deserialize, Serialize)] #[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct StrategyExpressionTradingConfig { pub struct StrategyExpressionTradingConfig {
#[serde(default, alias = "buy_filter_expr")]
pub buy_filter_expr: Option<String>,
#[serde(default)] #[serde(default)]
pub stage: Option<String>, pub stage: Option<String>,
#[serde(default)] #[serde(default)]
@@ -1020,6 +1037,10 @@ pub struct StrategyExpressionTradingConfig {
#[serde(default)] #[serde(default)]
pub release_slot_on_exit_signal: Option<bool>, pub release_slot_on_exit_signal: Option<bool>,
#[serde(default)] #[serde(default)]
pub redistribute_target_weights_after_exit: Option<bool>,
#[serde(default)]
pub reenter_exited_targets: Option<bool>,
#[serde(default)]
pub subscription_guard_required: Option<bool>, pub subscription_guard_required: Option<bool>,
#[serde(default)] #[serde(default)]
pub subscriptions: Vec<String>, pub subscriptions: Vec<String>,
@@ -1255,6 +1276,9 @@ fn parse_stop_take_reference_price_mode(
"position_cost_basis" | "position_cost" | "execution_cost_basis" => { "position_cost_basis" | "position_cost" | "execution_cost_basis" => {
Ok(PlatformStopTakeReferencePriceMode::PositionCostBasis) Ok(PlatformStopTakeReferencePriceMode::PositionCostBasis)
} }
"position_average_entry_price" | "position_avg_price" | "average_entry_price" => {
Ok(PlatformStopTakeReferencePriceMode::PositionAverageEntryPrice)
}
"signal_day_post_adjusted_close" "signal_day_post_adjusted_close"
| "signal_post_adjusted_close" | "signal_post_adjusted_close"
| "model_signal_post_adjusted_close" => { | "model_signal_post_adjusted_close" => {
@@ -1776,6 +1800,23 @@ pub fn platform_expr_config_from_spec(
let Some(spec) = strategy_spec else { let Some(spec) = strategy_spec else {
return Ok(cfg); return Ok(cfg);
}; };
if let Some(conditions) = spec.stock_pool_factor_contract.as_ref()
.and_then(|contract| contract.get("conditions"))
.and_then(Value::as_array)
{
for condition in conditions {
let Some(binding) = condition.pointer("/semantic/backtestBinding") else { continue };
let field = binding.get("field").and_then(Value::as_str).unwrap_or("");
let dataset = binding.get("sourceDataset").and_then(Value::as_str).unwrap_or("");
if !dataset.starts_with("indicators_") || field.is_empty()
|| !field.bytes().all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
|| field.as_bytes()[0].is_ascii_digit()
{
return Err("invalid native factor backtest binding".to_string());
}
cfg.completed_session_factor_fields.insert(field.to_string());
}
}
let mut benchmark_short_explicit = false; let mut benchmark_short_explicit = false;
let mut benchmark_long_explicit = false; let mut benchmark_long_explicit = false;
let mut stock_short_explicit = false; let mut stock_short_explicit = false;
@@ -1937,6 +1978,27 @@ pub fn platform_expr_config_from_spec(
} }
} }
if let Some(universe) = spec.universe.as_ref() { if let Some(universe) = spec.universe.as_ref() {
cfg.universe_include = universe
.include
.as_ref()
.map(|raw_symbols| {
let mut symbols = BTreeSet::new();
for raw_symbol in raw_symbols {
let symbol = normalize_symbol(raw_symbol, None);
if !is_static_cn_universe_symbol(&symbol) {
return Err(format!(
"universe.include contains invalid CN stock symbol: {raw_symbol}"
));
}
if !symbols.insert(symbol.clone()) {
return Err(format!(
"universe.include contains duplicate normalized symbol: {symbol}"
));
}
}
Ok(symbols)
})
.transpose()?;
cfg.universe_exclude = universe cfg.universe_exclude = universe
.exclude .exclude
.iter() .iter()
@@ -2127,6 +2189,18 @@ pub fn platform_expr_config_from_spec(
)); ));
} }
} }
if let Some(control) = risk.portfolio_loss_control.as_ref()
&& control.enabled.unwrap_or(true)
{
let parsed = PortfolioLossConfig {
lookback: control.lookback.ok_or("portfolioLossControl.lookback is required")?,
loss_trigger: control.loss_trigger.ok_or("portfolioLossControl.lossTrigger is required")?,
floor_exposure: control.floor_exposure.ok_or("portfolioLossControl.floorExposure is required")?,
cooldown_trading_days: control.cooldown_trading_days.ok_or("portfolioLossControl.cooldownTradingDays is required")?,
};
parsed.validate().map_err(|error| error.to_string())?;
cfg.portfolio_loss_control = Some(parsed);
}
if let Some(control) = risk.portfolio_drawdown_control.as_ref() if let Some(control) = risk.portfolio_drawdown_control.as_ref()
&& control.enabled.unwrap_or(true) && control.enabled.unwrap_or(true)
{ {
@@ -2242,6 +2316,9 @@ pub fn platform_expr_config_from_spec(
} }
} }
if let Some(trading) = runtime_expr.trading.as_ref() { if let Some(trading) = runtime_expr.trading.as_ref() {
if let Some(expr) = trading.buy_filter_expr.as_ref() {
cfg.buy_filter_expr = expr.clone();
}
if let Some(expr) = trading if let Some(expr) = trading
.refresh_rate_expr .refresh_rate_expr
.as_ref() .as_ref()
@@ -2294,6 +2371,12 @@ pub fn platform_expr_config_from_spec(
if let Some(enabled) = trading.release_slot_on_exit_signal { if let Some(enabled) = trading.release_slot_on_exit_signal {
cfg.release_slot_on_exit_signal = enabled; cfg.release_slot_on_exit_signal = enabled;
} }
if let Some(enabled) = trading.redistribute_target_weights_after_exit {
cfg.redistribute_target_weights_after_exit = enabled;
}
if let Some(enabled) = trading.reenter_exited_targets {
cfg.reenter_exited_targets = enabled;
}
if let Some(enabled) = trading.delayed_limit_open_exit { if let Some(enabled) = trading.delayed_limit_open_exit {
cfg.delayed_limit_open_exit_enabled = enabled; cfg.delayed_limit_open_exit_enabled = enabled;
if enabled { if enabled {
@@ -2996,6 +3079,15 @@ fn normalize_symbol(symbol: &str, raw_board: Option<&str>) -> String {
instrument_query_id(trimmed, &normalize_board(trimmed, raw_board)) instrument_query_id(trimmed, &normalize_board(trimmed, raw_board))
} }
fn is_static_cn_universe_symbol(symbol: &str) -> bool {
let Some((code, exchange)) = symbol.rsplit_once('.') else {
return false;
};
code.len() == 6
&& code.bytes().all(|byte| byte.is_ascii_digit())
&& matches!(exchange, "SH" | "SZ" | "BJ")
}
fn instrument_query_id(symbol: &str, board: &str) -> String { fn instrument_query_id(symbol: &str, board: &str) -> String {
if symbol.contains('.') { if symbol.contains('.') {
return symbol.to_ascii_uppercase(); return symbol.to_ascii_uppercase();
@@ -3018,8 +3110,7 @@ fn instrument_query_id(symbol: &str, board: &str) -> String {
} }
fn normalize_board(symbol: &str, raw_board: Option<&str>) -> String { fn normalize_board(symbol: &str, raw_board: Option<&str>) -> String {
let has_suffix = symbol.trim().rsplit_once('.').is_some(); if raw_board.and_then(crate::instrument::listed_sector_is_kcb) == Some(true) {
if has_suffix && symbol_is_kcb(symbol) {
return "KSH".to_string(); return "KSH".to_string();
} }
let normalized = raw_board let normalized = raw_board
@@ -3034,9 +3125,6 @@ fn normalize_board(symbol: &str, raw_board: Option<&str>) -> String {
if let Some((_, suffix)) = symbol.rsplit_once('.') { if let Some((_, suffix)) = symbol.rsplit_once('.') {
return suffix.to_ascii_uppercase(); return suffix.to_ascii_uppercase();
} }
if symbol_is_kcb(symbol) {
return "KSH".to_string();
}
if symbol.starts_with('8') || symbol.starts_with('4') { if symbol.starts_with('8') || symbol.starts_with('4') {
return "BJ".to_string(); return "BJ".to_string();
} }
@@ -3053,27 +3141,46 @@ fn normalize_board(symbol: &str, raw_board: Option<&str>) -> String {
"UNK".to_string() "UNK".to_string()
} }
fn symbol_is_kcb(symbol: &str) -> bool {
let normalized = symbol.trim().to_ascii_uppercase();
let Some((code, suffix)) = normalized.rsplit_once('.') else {
return normalized.starts_with("688") || normalized.starts_with("689");
};
suffix == "SH" && (code.starts_with("688") || code.starts_with("689"))
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
#[test] #[test]
fn normalize_board_classifies_kcb_by_688_689_sh_suffix_only() { fn parses_buy_filter_as_a_separate_trading_condition() {
assert_eq!(normalize_board("688001.SH", None), "KSH"); let cfg = platform_expr_config_from_value("buy-guard", "000001.SZ", &serde_json::json!({
assert_eq!(normalize_board("689001.SH", None), "KSH"); "runtimeExpressions": {
"selection": {"stockFilterExpr": "close > 0"},
"trading": {"buyFilterExpr": "gate > 0"}
}
})).unwrap();
assert_eq!(cfg.stock_filter_expr, "close > 0");
assert_eq!(cfg.buy_filter_expr, "gate > 0");
}
#[test]
fn native_factor_bindings_declare_completed_session_fields() {
let spec = serde_json::json!({"stockPoolFactorContract": {"conditions": [
{"factorRef": "up_days_stock", "semantic": {"backtestBinding": {
"field": "ths_up_days_stock", "sourceDataset": "indicators_up_days_stock"
}}}
]}});
let cfg = platform_expr_config_from_value("test", "000852.SH", &spec).unwrap();
assert_eq!(cfg.completed_session_factor_fields,
BTreeSet::from(["ths_up_days_stock".to_string()]));
let empty = platform_expr_config_from_value("test", "000852.SH", &serde_json::json!({})).unwrap();
assert!(empty.completed_session_factor_fields.is_empty());
}
#[test]
fn normalize_board_does_not_infer_kcb_from_security_code() {
assert_eq!(normalize_board("688001.SH", None), "SH");
assert_eq!(normalize_board("689001.SH", None), "SH");
assert_eq!(normalize_board("688001.BJ", None), "BJ"); assert_eq!(normalize_board("688001.BJ", None), "BJ");
assert_eq!(normalize_board("689001.SZ", None), "SZ"); assert_eq!(normalize_board("689001.SZ", None), "SZ");
assert_eq!(normalize_board("688001", None), "KSH"); assert_eq!(normalize_board("688001", None), "SH");
assert_eq!(normalize_board("688001", Some("SZ")), "SZ"); assert_eq!(normalize_board("688001", Some("SZ")), "SZ");
assert_eq!(normalize_board("688001.SH", Some("SH")), "KSH"); assert_eq!(normalize_board("688001.SH", Some("SH")), "SH");
assert_eq!(normalize_board("000001.SZ", Some("KSH")), "KSH");
} }
#[test] #[test]
@@ -3082,7 +3189,10 @@ mod tests {
"strategyId": "runtime_spec_test", "strategyId": "runtime_spec_test",
"signalSymbol": "000852.SH", "signalSymbol": "000852.SH",
"benchmark": { "instrumentId": "000852.SH" }, "benchmark": { "instrumentId": "000852.SH" },
"universe": { "exclude": ["paused", "st", "kcb", "one_yuan"] }, "universe": {
"include": ["600000.sh", "000001.SZ"],
"exclude": ["paused", "st", "kcb", "one_yuan"]
},
"runtimeExpressions": { "runtimeExpressions": {
"prelude": "let stocknum = 8;", "prelude": "let stocknum = 8;",
"selection": { "selection": {
@@ -3121,6 +3231,13 @@ mod tests {
assert_eq!(cfg.signal_symbol, "000852.SH"); assert_eq!(cfg.signal_symbol, "000852.SH");
assert_eq!(cfg.selection_limit_expr, "stocknum"); assert_eq!(cfg.selection_limit_expr, "stocknum");
assert_eq!(cfg.refresh_rate_expr, "year >= 2024 ? 5 : 20"); assert_eq!(cfg.refresh_rate_expr, "year >= 2024 ? 5 : 20");
assert_eq!(
cfg.universe_include,
Some(BTreeSet::from([
"000001.SZ".to_string(),
"600000.SH".to_string()
]))
);
assert_eq!(cfg.universe_exclude, ["paused", "st", "kcb", "one_yuan"]); assert_eq!(cfg.universe_exclude, ["paused", "st", "kcb", "one_yuan"]);
assert!(!cfg.rotation_enabled); assert!(!cfg.rotation_enabled);
assert!(cfg.daily_top_up_enabled); assert!(cfg.daily_top_up_enabled);
@@ -3142,6 +3259,29 @@ mod tests {
); );
} }
#[test]
fn rejects_invalid_or_duplicate_static_universe_symbols() {
let invalid = serde_json::json!({
"universe": {"include": ["not-a-stock"]}
});
assert!(
platform_expr_config_from_value("", "", &invalid)
.unwrap_err()
.to_string()
.contains("invalid CN stock symbol")
);
let duplicate = serde_json::json!({
"universe": {"include": ["600000.sh", "600000.SH"]}
});
assert!(
platform_expr_config_from_value("", "", &duplicate)
.unwrap_err()
.to_string()
.contains("duplicate normalized symbol")
);
}
#[test] #[test]
fn parses_and_rejects_invalid_position_target_rules() { fn parses_and_rejects_invalid_position_target_rules() {
let spec = serde_json::json!({ let spec = serde_json::json!({
@@ -3484,7 +3624,9 @@ mod tests {
"targetPortfolioDaily": true, "targetPortfolioDaily": true,
"rebalanceExistingPositions": true, "rebalanceExistingPositions": true,
"holdUntilExit": true, "holdUntilExit": true,
"releaseSlotOnExitSignal": true "releaseSlotOnExitSignal": true,
"redistributeTargetWeightsAfterExit": true,
"reenterExitedTargets": true
} }
} }
}); });
@@ -3498,6 +3640,8 @@ mod tests {
assert!(cfg.rebalance_existing_positions); assert!(cfg.rebalance_existing_positions);
assert!(cfg.hold_until_exit_enabled); assert!(cfg.hold_until_exit_enabled);
assert!(cfg.release_slot_on_exit_signal); assert!(cfg.release_slot_on_exit_signal);
assert!(cfg.redistribute_target_weights_after_exit);
assert!(cfg.reenter_exited_targets);
} }
#[test] #[test]
@@ -4445,6 +4589,27 @@ mod tests {
assert_eq!(control.cooldown_trading_days, 30); assert_eq!(control.cooldown_trading_days, 30);
} }
#[test]
fn portfolio_loss_contract_is_explicit_and_validated() {
let spec = serde_json::json!({"runtimeExpressions":{"risk":{"portfolioLossControl":{
"enabled":true,"lookback":20,"lossTrigger":0.05,"floorExposure":0.2,"cooldownTradingDays":10
}}}});
let cfg = platform_expr_config_from_value("", "", &spec).unwrap();
assert_eq!(cfg.portfolio_loss_control.unwrap(), PortfolioLossConfig {
lookback:20, loss_trigger:0.05, floor_exposure:0.2, cooldown_trading_days:10,
});
for (field, value) in [("lookback", serde_json::json!(0)),
("lossTrigger", serde_json::json!(0.01)), ("floorExposure", serde_json::json!(1.1)),
("cooldownTradingDays", serde_json::json!(0))] {
let mut invalid = spec.clone();
invalid["runtimeExpressions"]["risk"]["portfolioLossControl"][field] = value;
assert!(platform_expr_config_from_value("", "", &invalid).is_err());
}
let mut missing = spec.clone();
missing["runtimeExpressions"]["risk"]["portfolioLossControl"].as_object_mut().unwrap().remove("lossTrigger");
assert!(platform_expr_config_from_value("", "", &missing).is_err());
}
#[test] #[test]
fn rejects_invalid_portfolio_drawdown_control() { fn rejects_invalid_portfolio_drawdown_control() {
let spec = serde_json::json!({ let spec = serde_json::json!({
@@ -4483,6 +4648,24 @@ mod tests {
); );
} }
#[test]
fn parses_position_average_entry_stop_take_reference_price_mode() {
let spec = serde_json::json!({
"runtimeExpressions": {
"risk": {
"stopTakeReferencePriceMode": "position_average_entry_price"
}
}
});
let cfg = platform_expr_config_from_value("", "", &spec).expect("config");
assert_eq!(
cfg.stop_take_reference_price_mode,
PlatformStopTakeReferencePriceMode::PositionAverageEntryPrice
);
}
#[test] #[test]
fn rejects_unknown_stop_take_reference_price_mode() { fn rejects_unknown_stop_take_reference_price_mode() {
let spec = serde_json::json!({ let spec = serde_json::json!({
+85 -6
View File
@@ -60,6 +60,8 @@ pub struct PositionLot {
pub struct Position { pub struct Position {
pub symbol: String, pub symbol: String,
pub quantity: u32, pub quantity: u32,
// 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. // ALV-compatible moving average including buy costs; partial sells do not rebase it.
pub average_cost: f64, pub average_cost: f64,
pub last_price: f64, pub last_price: f64,
@@ -86,6 +88,7 @@ impl Position {
Self { Self {
symbol: symbol.into(), symbol: symbol.into(),
quantity: 0, quantity: 0,
average_price: 0.0,
average_cost: 0.0, average_cost: 0.0,
last_price: 0.0, last_price: 0.0,
realized_pnl: FixedMoney::ZERO, realized_pnl: FixedMoney::ZERO,
@@ -127,6 +130,7 @@ impl Position {
} }
let previous_quantity = self.quantity; let previous_quantity = self.quantity;
let previous_average_price = self.average_price;
let previous_average_cost = self.average_cost; let previous_average_cost = self.average_cost;
let gross_amount = fixed_money_or_panic( let gross_amount = fixed_money_or_panic(
execution_price * quantity as f64, execution_price * quantity as f64,
@@ -146,6 +150,18 @@ impl Position {
.day_buy_value .day_buy_value
.checked_add(gross_amount) .checked_add(gross_amount)
.expect("fixed-point day buy value overflow"); .expect("fixed-point day buy value overflow");
if previous_quantity > 0
&& previous_average_price.is_finite()
&& previous_average_price > 0.0
&& execution_price.is_finite()
&& execution_price > 0.0
{
self.average_price = (previous_average_price * previous_quantity as f64
+ execution_price * quantity as f64)
/ self.quantity as f64;
} else {
self.average_price = execution_price;
}
if previous_quantity > 0 if previous_quantity > 0
&& previous_average_cost.is_finite() && previous_average_cost.is_finite()
&& previous_average_cost > 0.0 && previous_average_cost > 0.0
@@ -186,6 +202,7 @@ impl Position {
let mut remaining_proceeds = total_proceeds; let mut remaining_proceeds = total_proceeds;
let mut realized = FixedMoney::ZERO; let mut realized = FixedMoney::ZERO;
let mut realized_entry = FixedMoney::ZERO; let mut realized_entry = FixedMoney::ZERO;
let average_price_before_sell = self.average_price;
let average_cost_before_sell = self.average_cost; let average_cost_before_sell = self.average_cost;
while remaining > 0 { while remaining > 0 {
@@ -250,12 +267,20 @@ impl Position {
.checked_add(total_proceeds) .checked_add(total_proceeds)
.ok_or_else(|| "fixed-point day sell value overflow".to_string())?; .ok_or_else(|| "fixed-point day sell value overflow".to_string())?;
if self.quantity == 0 { if self.quantity == 0 {
self.average_price = 0.0;
self.recalculate_average_cost(); self.recalculate_average_cost();
} else if average_cost_before_sell.is_finite() && average_cost_before_sell > 0.0 { } else {
if average_price_before_sell.is_finite() && average_price_before_sell > 0.0 {
self.average_price = average_price_before_sell;
} else {
self.average_price = self.average_entry_price().unwrap_or(0.0);
}
if average_cost_before_sell.is_finite() && average_cost_before_sell > 0.0 {
self.average_cost = average_cost_before_sell; self.average_cost = average_cost_before_sell;
} else { } else {
self.recalculate_average_cost(); self.recalculate_average_cost();
} }
}
self.refresh_day_pnl(); self.refresh_day_pnl();
Ok(realized.to_f64()) Ok(realized.to_f64())
} }
@@ -298,6 +323,13 @@ impl Position {
.to_f64() .to_f64()
} }
pub fn unrealized_average_price_pnl(&self) -> f64 {
if self.quantity == 0 || !self.average_price.is_finite() || self.average_price <= 0.0 {
return 0.0;
}
(self.last_price - self.average_price) * self.quantity as f64
}
pub fn pnl(&self) -> f64 { pub fn pnl(&self) -> f64 {
self.realized_pnl.to_f64() + self.unrealized_pnl() self.realized_pnl.to_f64() + self.unrealized_pnl()
} }
@@ -421,9 +453,12 @@ impl Position {
} }
pub fn holding_return(&self, price: f64) -> Option<f64> { pub fn holding_return(&self, price: f64) -> Option<f64> {
let Some(avg_price) = self.average_entry_price() else { let avg_price = self
return None; .average_price
}; .is_finite()
.then_some(self.average_price)
.filter(|value| *value > 0.0)
.or_else(|| self.average_entry_price())?;
if avg_price <= 0.0 { if avg_price <= 0.0 {
None None
} else { } else {
@@ -503,6 +538,7 @@ impl Position {
if adjust_cost_basis { if adjust_cost_basis {
self.average_cost -= dividend_per_share; self.average_cost -= dividend_per_share;
} }
self.average_price -= dividend_per_share;
self.last_price -= dividend_per_share; self.last_price -= dividend_per_share;
self.day_dividend_cash = self self.day_dividend_cash = self
.day_dividend_cash .day_dividend_cash
@@ -545,6 +581,11 @@ impl Position {
self.lots = scaled_lots; self.lots = scaled_lots;
self.quantity = self.lots.iter().map(|lot| lot.quantity).sum(); self.quantity = self.lots.iter().map(|lot| lot.quantity).sum();
self.last_price /= ratio; self.last_price /= ratio;
if self.average_price.is_finite() && self.average_price > 0.0 {
self.average_price /= ratio;
} else {
self.average_price = self.average_entry_price().unwrap_or(0.0);
}
if self.average_cost.is_finite() && self.average_cost > 0.0 { if self.average_cost.is_finite() && self.average_cost > 0.0 {
self.average_cost /= ratio; self.average_cost /= ratio;
} else { } else {
@@ -1128,8 +1169,11 @@ impl PortfolioState {
.map(|position| { .map(|position| {
let market_value = position.market_value(); let market_value = position.market_value();
let entry_average_cost = position let entry_average_cost = position
.average_entry_price() .average_price
.is_finite()
.then_some(position.average_price)
.filter(|value| value.is_finite() && *value > 0.0) .filter(|value| value.is_finite() && *value > 0.0)
.or_else(|| position.average_entry_price())
.unwrap_or(position.average_cost); .unwrap_or(position.average_cost);
HoldingSummary { HoldingSummary {
date, date,
@@ -1143,7 +1187,7 @@ impl PortfolioState {
} else { } else {
0.0 0.0
}, },
unrealized_pnl: position.unrealized_entry_pnl(), unrealized_pnl: position.unrealized_average_price_pnl(),
realized_pnl: position.realized_entry_pnl(), realized_pnl: position.realized_entry_pnl(),
pnl: position.entry_pnl(), pnl: position.entry_pnl(),
trading_pnl: position.trading_pnl, trading_pnl: position.trading_pnl,
@@ -1181,6 +1225,7 @@ impl PortfolioState {
let old_quantity = old_position.quantity; let old_quantity = old_position.quantity;
let last_price = old_position.last_price; let last_price = old_position.last_price;
let old_average_price = old_position.average_price;
let old_average_cost = old_position.average_cost; let old_average_cost = old_position.average_cost;
let realized_pnl = old_position.realized_pnl; let realized_pnl = old_position.realized_pnl;
let realized_entry_pnl = old_position.realized_entry_pnl; let realized_entry_pnl = old_position.realized_entry_pnl;
@@ -1218,6 +1263,7 @@ impl PortfolioState {
.entry(new_symbol.to_string()) .entry(new_symbol.to_string())
.or_insert_with(|| Position::new(new_symbol)); .or_insert_with(|| Position::new(new_symbol));
let successor_quantity_before = successor.quantity; let successor_quantity_before = successor.quantity;
let successor_average_price_before = successor.average_price;
let successor_average_cost_before = successor.average_cost; let successor_average_cost_before = successor.average_cost;
successor.lots.extend(converted_lots); successor.lots.extend(converted_lots);
successor.quantity = successor.lots.iter().map(|lot| lot.quantity).sum(); successor.quantity = successor.lots.iter().map(|lot| lot.quantity).sum();
@@ -1232,6 +1278,30 @@ impl PortfolioState {
if converted_last_price > 0.0 { if converted_last_price > 0.0 {
successor.last_price = converted_last_price; successor.last_price = converted_last_price;
} }
let converted_average_price = if old_average_price.is_finite()
&& old_average_price > 0.0
&& ratio.is_finite()
&& ratio > 0.0
{
Some(old_average_price / ratio)
} else {
None
};
if let Some(converted_average_price) = converted_average_price {
if successor_quantity_before > 0
&& successor_average_price_before.is_finite()
&& successor_average_price_before > 0.0
{
successor.average_price = (successor_average_price_before
* successor_quantity_before as f64
+ converted_average_price * converted_quantity as f64)
/ successor.quantity as f64;
} else {
successor.average_price = converted_average_price;
}
} else {
successor.average_price = successor.average_entry_price().unwrap_or(0.0);
}
let converted_average_cost = if old_average_cost.is_finite() let converted_average_cost = if old_average_cost.is_finite()
&& old_average_cost > 0.0 && old_average_cost > 0.0
&& ratio.is_finite() && ratio.is_finite()
@@ -1375,8 +1445,10 @@ mod tests {
let realized = position.sell(100, 6.0).expect("partial FIFO sell"); let realized = position.sell(100, 6.0).expect("partial FIFO sell");
assert_eq!(position.quantity, 100); assert_eq!(position.quantity, 100);
assert!((position.average_price - 7.5).abs() < 1e-12);
assert!((position.average_cost - 7.55).abs() < 1e-12); assert!((position.average_cost - 7.55).abs() < 1e-12);
assert!((position.average_entry_price().unwrap() - 5.0).abs() < 1e-12); assert!((position.average_entry_price().unwrap() - 5.0).abs() < 1e-12);
assert!((position.unrealized_average_price_pnl() + 150.0).abs() < 1e-12);
assert!((realized + 405.0).abs() < 1e-12); assert!((realized + 405.0).abs() < 1e-12);
assert!((position.unrealized_pnl() - 95.0).abs() < 1e-12); assert!((position.unrealized_pnl() - 95.0).abs() < 1e-12);
assert!((position.pnl() + 310.0).abs() < 1e-12); assert!((position.pnl() + 310.0).abs() < 1e-12);
@@ -1409,6 +1481,7 @@ mod tests {
position.record_buy_trade_cost(22_200, 100.0); position.record_buy_trade_cost(22_200, 100.0);
assert!(position.average_cost > 5.66); assert!(position.average_cost > 5.66);
assert!((position.average_price - 5.66).abs() < 1e-12);
assert!((position.average_entry_price().unwrap() - 5.66).abs() < 1e-12); assert!((position.average_entry_price().unwrap() - 5.66).abs() < 1e-12);
assert!((position.holding_return(6.06).unwrap() - (6.06 / 5.66 - 1.0)).abs() < 1e-12); assert!((position.holding_return(6.06).unwrap() - (6.06 / 5.66 - 1.0)).abs() < 1e-12);
} }
@@ -1430,6 +1503,7 @@ mod tests {
position.sell(2700, 16.8331).expect("partial sell"); position.sell(2700, 16.8331).expect("partial sell");
assert_eq!(position.quantity, 100); assert_eq!(position.quantity, 100);
assert!((position.average_price - 18.94711428571429).abs() < 1e-12);
assert!((position.average_cost - average_cost_before).abs() < 1e-12); assert!((position.average_cost - average_cost_before).abs() < 1e-12);
} }
@@ -1443,11 +1517,13 @@ mod tests {
position.sell(100, 6.0).expect("partial sell"); position.sell(100, 6.0).expect("partial sell");
assert_eq!(position.quantity, 100); assert_eq!(position.quantity, 100);
assert!((position.average_price - 7.5).abs() < 1e-12);
assert!((position.average_cost - 7.5).abs() < 1e-12); assert!((position.average_cost - 7.5).abs() < 1e-12);
assert!((position.average_entry_price().unwrap() - 5.0).abs() < 1e-12); assert!((position.average_entry_price().unwrap() - 5.0).abs() < 1e-12);
position.buy(date, 100, 5.0); position.buy(date, 100, 5.0);
assert_eq!(position.quantity, 200); assert_eq!(position.quantity, 200);
assert!((position.average_price - 6.25).abs() < 1e-12);
assert!((position.average_cost - 6.25).abs() < 1e-12); assert!((position.average_cost - 6.25).abs() < 1e-12);
assert!((position.average_entry_price().unwrap() - 5.0).abs() < 1e-12); assert!((position.average_entry_price().unwrap() - 5.0).abs() < 1e-12);
} }
@@ -1489,6 +1565,7 @@ mod tests {
let cash = position.apply_cash_dividend_preserve_cost_basis(0.6); let cash = position.apply_cash_dividend_preserve_cost_basis(0.6);
assert!((cash - 600.0).abs() < 1e-12); assert!((cash - 600.0).abs() < 1e-12);
assert!((position.average_price - 45.85).abs() < 1e-12);
assert!((position.average_cost - cost_before).abs() < 1e-12); assert!((position.average_cost - cost_before).abs() < 1e-12);
assert!((position.average_entry_price().unwrap() - (entry_before - 0.6)).abs() < 1e-12); assert!((position.average_entry_price().unwrap() - (entry_before - 0.6)).abs() < 1e-12);
assert!((position.last_price - 45.85).abs() < 1e-12); assert!((position.last_price - 45.85).abs() < 1e-12);
@@ -1571,6 +1648,7 @@ mod tests {
pe_ttm: 10.0, pe_ttm: 10.0,
turnover_ratio: Some(1.0), turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0), effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: BTreeMap::new(),
}], }],
vec![CandidateEligibility { vec![CandidateEligibility {
@@ -1659,6 +1737,7 @@ mod tests {
pe_ttm: 10.0, pe_ttm: 10.0,
turnover_ratio: Some(1.0), turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0), effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: BTreeMap::new(),
}], }],
vec![CandidateEligibility { vec![CandidateEligibility {
+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());
}
}
}
+20 -6
View File
@@ -397,7 +397,7 @@ impl ChinaAShareRiskControl {
RiskCheckScope::Buy => config.static_rules.reject_kcb_buy, RiskCheckScope::Buy => config.static_rules.reject_kcb_buy,
RiskCheckScope::Sell => false, RiskCheckScope::Sell => false,
}; };
if reject_kcb && (candidate.is_kcb || symbol_is_kcb(&candidate.symbol)) { if reject_kcb && candidate.is_kcb {
return Some("kcb"); return Some("kcb");
} }
let reject_bjse = match scope { let reject_bjse = match scope {
@@ -600,11 +600,6 @@ impl ChinaAShareRiskControl {
} }
} }
fn symbol_is_kcb(symbol: &str) -> bool {
let normalized = symbol.trim().to_ascii_uppercase();
(normalized.starts_with("688") || normalized.starts_with("689")) && normalized.ends_with(".SH")
}
fn symbol_is_bjse(symbol: &str) -> bool { fn symbol_is_bjse(symbol: &str) -> bool {
let normalized = symbol.trim().to_ascii_uppercase(); let normalized = symbol.trim().to_ascii_uppercase();
normalized.ends_with(".BJ") || normalized.ends_with(".BSE") || normalized.ends_with(".BE") normalized.ends_with(".BJ") || normalized.ends_with(".BSE") || normalized.ends_with(".BE")
@@ -1009,6 +1004,24 @@ mod tests {
assert_eq!(configured_reason, None); assert_eq!(configured_reason, None);
} }
#[test]
fn kcb_filter_uses_classification_instead_of_security_code() {
let date = d(2025, 1, 2);
let market = market(date, 6.27, 5.63);
let mut candidate = candidate(date);
let config = FidcRiskControlConfig::default();
for symbol in ["688001.SH", "689001.SH", "000001.SZ"] {
candidate.symbol = symbol.to_string();
for is_kcb in [false, true] {
candidate.is_kcb = is_kcb;
let reason = ChinaAShareRiskControl::buy_rejection_reason_with_config(
date, &candidate, &market, None, 6.27, &config,
);
assert_eq!(reason, is_kcb.then_some("kcb"), "{symbol}");
}
}
}
#[test] #[test]
fn st_and_star_st_filters_are_independent() { fn st_and_star_st_filters_are_independent() {
let date = d(2025, 1, 2); let date = d(2025, 1, 2);
@@ -1139,6 +1152,7 @@ mod tests {
let date = d(2025, 1, 2); let date = d(2025, 1, 2);
let mut candidate = candidate(date); let mut candidate = candidate(date);
candidate.symbol = "688506.SH".to_string(); candidate.symbol = "688506.SH".to_string();
candidate.is_kcb = true;
candidate.risk_level_code = Some("missing_risk_state".to_string()); candidate.risk_level_code = Some("missing_risk_state".to_string());
let market = market(date, 6.27, 5.63); let market = market(date, 6.27, 5.63);
let mut config = FidcRiskControlConfig::default(); let mut config = FidcRiskControlConfig::default();
+59 -1
View File
@@ -977,6 +977,7 @@ fn safe_ratio(numerator: f64, denominator: f64) -> f64 {
#[derive(Debug, Clone, Default)] #[derive(Debug, Clone, Default)]
pub struct StrategyDecision { pub struct StrategyDecision {
pub buy_denials: BTreeMap<String, String>,
pub rebalance: bool, pub rebalance: bool,
pub target_weights: BTreeMap<String, f64>, pub target_weights: BTreeMap<String, f64>,
pub exit_symbols: BTreeSet<String>, pub exit_symbols: BTreeSet<String>,
@@ -987,7 +988,20 @@ pub struct StrategyDecision {
} }
impl StrategyDecision { impl StrategyDecision {
pub fn potential_buy_symbols(&self, open_orders: &[OpenOrderView]) -> BTreeSet<String> {
let mut symbols = BTreeSet::new();
if self.rebalance {
symbols.extend(self.target_weights.iter().filter(|(_, weight)| **weight > 0.0).map(|(symbol, _)| symbol.clone()));
}
for intent in &self.order_intents {
intent.collect_potential_buy_symbols(open_orders, &mut symbols);
}
symbols.retain(|symbol| !symbol.trim().is_empty());
symbols
}
pub fn merge_from(&mut self, mut other: StrategyDecision) { pub fn merge_from(&mut self, mut other: StrategyDecision) {
self.buy_denials.append(&mut other.buy_denials);
self.rebalance |= other.rebalance; self.rebalance |= other.rebalance;
self.target_weights.append(&mut other.target_weights); self.target_weights.append(&mut other.target_weights);
self.exit_symbols.append(&mut other.exit_symbols); self.exit_symbols.append(&mut other.exit_symbols);
@@ -998,7 +1012,8 @@ impl StrategyDecision {
} }
pub fn is_empty(&self) -> bool { pub fn is_empty(&self) -> bool {
!self.rebalance self.buy_denials.is_empty()
&& !self.rebalance
&& self.target_weights.is_empty() && self.target_weights.is_empty()
&& self.exit_symbols.is_empty() && self.exit_symbols.is_empty()
&& self.order_intents.is_empty() && self.order_intents.is_empty()
@@ -1214,6 +1229,42 @@ pub enum OrderIntent {
} }
impl OrderIntent { impl OrderIntent {
fn collect_potential_buy_symbols(&self, open_orders: &[OpenOrderView], symbols: &mut BTreeSet<String>) {
match self.unwrapped() {
Self::Shares { symbol, quantity, .. } | Self::LimitShares { symbol, quantity, .. } if *quantity > 0 => { symbols.insert(symbol.clone()); }
Self::Lots { symbol, lots, .. } | Self::LimitLots { symbol, lots, .. } if *lots > 0 => { symbols.insert(symbol.clone()); }
Self::TargetShares { symbol, target_quantity, .. } | Self::LimitTargetShares { symbol, target_quantity, .. } if *target_quantity > 0 => { symbols.insert(symbol.clone()); }
Self::Value { symbol, value, .. } | Self::LimitValue { symbol, value, .. } | Self::AlgoValue { symbol, value, .. } if *value > 0.0 => { symbols.insert(symbol.clone()); }
Self::Percent { symbol, percent, .. } | Self::LimitPercent { symbol, percent, .. } | Self::AlgoPercent { symbol, percent, .. } if *percent > 0.0 => { symbols.insert(symbol.clone()); }
Self::TargetValue { symbol, target_value, .. } | Self::LimitTargetValue { symbol, target_value, .. } | Self::TimedTargetValue { symbol, target_value, .. } if *target_value > 0.0 => { symbols.insert(symbol.clone()); }
Self::TargetPercent { symbol, target_percent, .. } | Self::LimitTargetPercent { symbol, target_percent, .. } if *target_percent > 0.0 => { symbols.insert(symbol.clone()); }
Self::TargetPortfolioSmart { target_weights, .. } => {
symbols.extend(target_weights.iter().filter(|(_, weight)| **weight > 0.0).map(|(symbol, _)| symbol.clone()));
}
Self::ModifyOrder { order_id, new_total_quantity, new_limit_price, .. } => {
if let Some(order) = open_orders.iter().find(|order| order.order_id == *order_id)
&& order.side == OrderSide::Buy
&& (new_total_quantity.is_some_and(|value| value > order.requested_quantity)
|| new_limit_price.is_some_and(|value| value > order.limit_price))
{
symbols.insert(order.symbol.clone());
}
}
Self::Shares { .. } | Self::LimitShares { .. }
| Self::Lots { .. } | Self::LimitLots { .. }
| Self::TargetShares { .. } | Self::LimitTargetShares { .. }
| Self::Value { .. } | Self::LimitValue { .. } | Self::AlgoValue { .. }
| Self::Percent { .. } | Self::LimitPercent { .. } | Self::AlgoPercent { .. }
| Self::TargetValue { .. } | Self::LimitTargetValue { .. } | Self::TimedTargetValue { .. }
| Self::TargetPercent { .. } | Self::LimitTargetPercent { .. }
| Self::CancelOrder { .. } | Self::CancelSymbol { .. } | Self::CancelAll { .. }
| Self::UpdateUniverse { .. } | Self::Subscribe { .. } | Self::Unsubscribe { .. }
| Self::DepositWithdraw { .. } | Self::FinanceRepay { .. } | Self::SetManagementFeeRate { .. }
| Self::Futures { .. } => {}
Self::WithTimeInForce { .. } => unreachable!("intent is unwrapped"),
}
}
pub fn with_time_in_force(self, time_in_force: OrderTimeInForce) -> Self { pub fn with_time_in_force(self, time_in_force: OrderTimeInForce) -> Self {
match self { match self {
Self::WithTimeInForce { intent, .. } => Self::WithTimeInForce { Self::WithTimeInForce { intent, .. } => Self::WithTimeInForce {
@@ -1569,6 +1620,7 @@ impl Strategy for CnSmallCapRotationStrategy {
if self.config.in_skip_window(ctx.decision_date) { if self.config.in_skip_window(ctx.decision_date) {
self.last_gross_exposure = Some(0.0); self.last_gross_exposure = Some(0.0);
return Ok(StrategyDecision { return Ok(StrategyDecision {
buy_denials: Default::default(),
rebalance: true, rebalance: true,
target_weights: BTreeMap::new(), target_weights: BTreeMap::new(),
exit_symbols: ctx.portfolio.positions().keys().cloned().collect(), exit_symbols: ctx.portfolio.positions().keys().cloned().collect(),
@@ -1590,6 +1642,7 @@ impl Strategy for CnSmallCapRotationStrategy {
if message.contains("signal series insufficient") => if message.contains("signal series insufficient") =>
{ {
return Ok(StrategyDecision { return Ok(StrategyDecision {
buy_denials: Default::default(),
rebalance: false, rebalance: false,
target_weights: BTreeMap::new(), target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(), exit_symbols: BTreeSet::new(),
@@ -1765,6 +1818,7 @@ impl Strategy for CnSmallCapRotationStrategy {
self.last_gross_exposure = Some(gross_exposure); self.last_gross_exposure = Some(gross_exposure);
Ok(StrategyDecision { Ok(StrategyDecision {
buy_denials: Default::default(),
rebalance, rebalance,
target_weights, target_weights,
exit_symbols, exit_symbols,
@@ -2773,6 +2827,7 @@ impl Strategy for OmniMicroCapStrategy {
let lagged_execution = ctx.is_lagged_execution(); let lagged_execution = ctx.is_lagged_execution();
if self.config.in_skip_window(signal_date) { if self.config.in_skip_window(signal_date) {
return Ok(StrategyDecision { return Ok(StrategyDecision {
buy_denials: Default::default(),
rebalance: false, rebalance: false,
target_weights: BTreeMap::new(), target_weights: BTreeMap::new(),
exit_symbols: ctx.portfolio.positions().keys().cloned().collect(), exit_symbols: ctx.portfolio.positions().keys().cloned().collect(),
@@ -2803,6 +2858,7 @@ impl Strategy for OmniMicroCapStrategy {
if message.contains("insufficient benchmark") => if message.contains("insufficient benchmark") =>
{ {
return Ok(StrategyDecision { return Ok(StrategyDecision {
buy_denials: Default::default(),
rebalance: false, rebalance: false,
target_weights: BTreeMap::new(), target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(), exit_symbols: BTreeSet::new(),
@@ -3013,6 +3069,7 @@ impl Strategy for OmniMicroCapStrategy {
]; ];
Ok(StrategyDecision { Ok(StrategyDecision {
buy_denials: Default::default(),
rebalance: false, rebalance: false,
target_weights: BTreeMap::new(), target_weights: BTreeMap::new(),
exit_symbols, exit_symbols,
@@ -3154,6 +3211,7 @@ mod tests {
pe_ttm: 12.0, pe_ttm: 12.0,
turnover_ratio: Some(1.0), turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0), effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: BTreeMap::new(),
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
+1
View File
@@ -339,6 +339,7 @@ mod tests {
pe_ttm: 10.0, pe_ttm: 10.0,
turnover_ratio: Some(0.01), turnover_ratio: Some(0.01),
effective_turnover_ratio: Some(0.01), effective_turnover_ratio: Some(0.01),
adjustment_factor_backward1: None,
extra_factors: Default::default(), extra_factors: Default::default(),
} }
} }
@@ -90,6 +90,7 @@ impl Strategy for BuyAndHoldStrategy {
ctx: &StrategyContext<'_>, ctx: &StrategyContext<'_>,
) -> Result<StrategyDecision, fidc_core::BacktestError> { ) -> Result<StrategyDecision, fidc_core::BacktestError> {
Ok(StrategyDecision { Ok(StrategyDecision {
buy_denials: Default::default(),
rebalance: false, rebalance: false,
target_weights: BTreeMap::new(), target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(), exit_symbols: BTreeSet::new(),
@@ -144,6 +145,7 @@ fn stock_factor_snapshot(date: NaiveDate) -> DailyFactorSnapshot {
pe_ttm: 10.0, pe_ttm: 10.0,
turnover_ratio: Some(1.0), turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0), effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: BTreeMap::new(),
} }
} }
@@ -270,6 +272,7 @@ fn engine_reinvests_dividend_receivable_in_round_lots() {
pe_ttm: 10.0, pe_ttm: 10.0,
turnover_ratio: Some(1.0), turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0), effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: BTreeMap::new(),
}, },
DailyFactorSnapshot { DailyFactorSnapshot {
@@ -280,6 +283,7 @@ fn engine_reinvests_dividend_receivable_in_round_lots() {
pe_ttm: 10.0, pe_ttm: 10.0,
turnover_ratio: Some(1.0), turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0), effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: BTreeMap::new(),
}, },
DailyFactorSnapshot { DailyFactorSnapshot {
@@ -290,6 +294,7 @@ fn engine_reinvests_dividend_receivable_in_round_lots() {
pe_ttm: 10.0, pe_ttm: 10.0,
turnover_ratio: Some(1.0), turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0), effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: BTreeMap::new(),
}, },
], ],
@@ -5,6 +5,7 @@ use fidc_core::{
IntradayExecutionQuote, MatchingType, OrderIntent, PriceField, Strategy, StrategyContext, IntradayExecutionQuote, MatchingType, OrderIntent, PriceField, Strategy, StrategyContext,
StrategyDecision, StrategyDecision,
}; };
use std::collections::{BTreeMap, BTreeSet};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
fn d(year: i32, month: u32, day: u32) -> NaiveDate { fn d(year: i32, month: u32, day: u32) -> NaiveDate {
@@ -62,6 +63,191 @@ impl Strategy for DecisionQuoteReader {
} }
} }
struct NoLoaderDecisionQuoteStrategy {
symbol_plan_calls: Arc<Mutex<usize>>,
}
impl Strategy for NoLoaderDecisionQuoteStrategy {
fn name(&self) -> &str {
"no_loader_decision_quote_strategy"
}
fn decision_quote_times(&self) -> Vec<NaiveTime> {
vec![t(10, 18, 0)]
}
fn decision_quote_symbols(
&mut self,
_ctx: &StrategyContext<'_>,
) -> Result<BTreeSet<String>, fidc_core::BacktestError> {
*self
.symbol_plan_calls
.lock()
.expect("symbol plan counter mutex") += 1;
Ok(BTreeSet::new())
}
}
fn single_day_quote_plan_data(date: NaiveDate) -> DataSet {
DataSet::from_components(
Vec::new(),
vec![DailyMarketSnapshot {
date,
symbol: "000001.SZ".to_string(),
timestamp: Some(format!("{date} 15:00:00")),
day_open: 10.0,
open: 10.0,
high: 10.2,
low: 9.9,
close: 10.0,
last_price: 10.0,
bid1: 10.0,
ask1: 10.0,
prev_close: 9.8,
volume: 10_000,
minute_volume: 1_000,
bid1_volume: 10_000,
ask1_volume: 10_000,
trading_phase: Some("continuous".to_string()),
paused: false,
upper_limit: 10.78,
lower_limit: 8.82,
price_tick: 0.01,
}],
vec![DailyFactorSnapshot {
date,
symbol: "000001.SZ".to_string(),
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: None,
extra_factors: Default::default(),
}],
vec![CandidateEligibility {
date,
symbol: "000001.SZ".to_string(),
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,
}],
vec![BenchmarkSnapshot {
date,
benchmark: "000852.SH".to_string(),
open: 1000.0,
close: 1001.0,
prev_close: 999.0,
volume: 1_000_000,
}],
)
.expect("dataset")
}
#[test]
fn engine_uses_preplanned_decision_symbols_without_recomputing_strategy_plan() {
let date = d(2026, 1, 5);
let data = single_day_quote_plan_data(date);
let broker = BrokerSimulator::new_with_execution_price(
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks,
PriceField::Close,
)
.with_matching_type(MatchingType::CurrentBarClose);
let config = BacktestConfig {
initial_cash: 10_000.0,
benchmark_code: "000852.SH".to_string(),
start_date: Some(date),
end_date: Some(date),
decision_lag_trading_days: 0,
execution_price_field: PriceField::Close,
};
let symbol_plan_calls = Arc::new(Mutex::new(0usize));
let loader_calls = Arc::new(Mutex::new(0usize));
let strategy = NoLoaderDecisionQuoteStrategy {
symbol_plan_calls: Arc::clone(&symbol_plan_calls),
};
let captured_loader_calls = Arc::clone(&loader_calls);
let preplanned = Arc::new(BTreeMap::from([(
date,
BTreeSet::from(["000001.SZ".to_string()]),
)]));
let mut engine = BacktestEngine::new(data, strategy, broker, config)
.with_execution_quote_loader(move |request| {
*captured_loader_calls.lock().expect("loader counter mutex") += 1;
Ok(request
.symbols
.into_iter()
.map(|symbol| IntradayExecutionQuote {
date: request.date,
symbol,
timestamp: request.date.and_time(t(10, 17, 59)),
last_price: 10.0,
bid1: 10.0,
ask1: 10.0,
bid1_volume: 10_000,
ask1_volume: 10_000,
volume_delta: 10_000,
amount_delta: 100_000.0,
trading_phase: Some("continuous".to_string()),
})
.collect())
})
.with_preplanned_decision_quote_symbols_by_date(preplanned);
engine.run().expect("backtest should run");
assert_eq!(
*symbol_plan_calls.lock().expect("symbol plan counter mutex"),
0,
"the strategy plan must not be recomputed after a complete plan is supplied"
);
assert_eq!(
*loader_calls.lock().expect("loader counter mutex"),
1,
"the supplied symbols must still pass through the normal quote loader"
);
}
#[test]
fn engine_skips_decision_quote_symbol_plan_without_loader() {
let date = d(2026, 1, 5);
let data = single_day_quote_plan_data(date);
let broker = BrokerSimulator::new_with_execution_price(
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks,
PriceField::Close,
)
.with_matching_type(MatchingType::CurrentBarClose);
let config = BacktestConfig {
initial_cash: 10_000.0,
benchmark_code: "000852.SH".to_string(),
start_date: Some(date),
end_date: Some(date),
decision_lag_trading_days: 0,
execution_price_field: PriceField::Close,
};
let symbol_plan_calls = Arc::new(Mutex::new(0usize));
let strategy = NoLoaderDecisionQuoteStrategy {
symbol_plan_calls: Arc::clone(&symbol_plan_calls),
};
let mut engine = BacktestEngine::new(data, strategy, broker, config);
engine.run().expect("backtest should run");
assert_eq!(
*symbol_plan_calls.lock().expect("symbol plan counter mutex"),
0,
"a preloaded/no-loader engine cannot use a newly computed quote symbol plan"
);
}
#[test] #[test]
fn engine_preloads_declared_decision_quotes_for_current_positions() { fn engine_preloads_declared_decision_quotes_for_current_positions() {
let first = d(2026, 1, 5); let first = d(2026, 1, 5);
@@ -125,6 +311,7 @@ fn engine_preloads_declared_decision_quotes_for_current_positions() {
pe_ttm: 10.0, pe_ttm: 10.0,
turnover_ratio: None, turnover_ratio: None,
effective_turnover_ratio: None, effective_turnover_ratio: None,
adjustment_factor_backward1: None,
extra_factors: Default::default(), extra_factors: Default::default(),
}, },
DailyFactorSnapshot { DailyFactorSnapshot {
@@ -135,6 +322,7 @@ fn engine_preloads_declared_decision_quotes_for_current_positions() {
pe_ttm: 10.0, pe_ttm: 10.0,
turnover_ratio: None, turnover_ratio: None,
effective_turnover_ratio: None, effective_turnover_ratio: None,
adjustment_factor_backward1: None,
extra_factors: Default::default(), extra_factors: Default::default(),
}, },
], ],
@@ -293,6 +481,7 @@ fn engine_reuses_preloaded_decision_quotes_without_loader_call() {
pe_ttm: 10.0, pe_ttm: 10.0,
turnover_ratio: None, turnover_ratio: None,
effective_turnover_ratio: None, effective_turnover_ratio: None,
adjustment_factor_backward1: None,
extra_factors: Default::default(), extra_factors: Default::default(),
}, },
DailyFactorSnapshot { DailyFactorSnapshot {
@@ -303,6 +492,7 @@ fn engine_reuses_preloaded_decision_quotes_without_loader_call() {
pe_ttm: 10.0, pe_ttm: 10.0,
turnover_ratio: None, turnover_ratio: None,
effective_turnover_ratio: None, effective_turnover_ratio: None,
adjustment_factor_backward1: None,
extra_factors: Default::default(), extra_factors: Default::default(),
}, },
], ],
@@ -526,6 +716,7 @@ fn engine_loads_distinct_decision_quote_times_on_same_day() {
pe_ttm: 10.0, pe_ttm: 10.0,
turnover_ratio: None, turnover_ratio: None,
effective_turnover_ratio: None, effective_turnover_ratio: None,
adjustment_factor_backward1: None,
extra_factors: Default::default(), extra_factors: Default::default(),
}, },
DailyFactorSnapshot { DailyFactorSnapshot {
@@ -536,6 +727,7 @@ fn engine_loads_distinct_decision_quote_times_on_same_day() {
pe_ttm: 10.0, pe_ttm: 10.0,
turnover_ratio: None, turnover_ratio: None,
effective_turnover_ratio: None, effective_turnover_ratio: None,
adjustment_factor_backward1: None,
extra_factors: Default::default(), extra_factors: Default::default(),
}, },
], ],
+8
View File
@@ -24,6 +24,7 @@ impl Strategy for BuyThenHoldStrategy {
) -> Result<StrategyDecision, fidc_core::BacktestError> { ) -> Result<StrategyDecision, fidc_core::BacktestError> {
if ctx.decision_date == d(2025, 1, 2) && ctx.portfolio.position("000001.SZ").is_none() { if ctx.decision_date == d(2025, 1, 2) && ctx.portfolio.position("000001.SZ").is_none() {
return Ok(StrategyDecision { return Ok(StrategyDecision {
buy_denials: Default::default(),
rebalance: false, rebalance: false,
target_weights: BTreeMap::new(), target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(), exit_symbols: BTreeSet::new(),
@@ -170,6 +171,7 @@ fn engine_keeps_unresolved_delisted_position_without_fabricating_a_fill() {
pe_ttm: 10.0, pe_ttm: 10.0,
turnover_ratio: Some(1.0), turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0), effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: BTreeMap::new(),
}, },
DailyFactorSnapshot { DailyFactorSnapshot {
@@ -180,6 +182,7 @@ fn engine_keeps_unresolved_delisted_position_without_fabricating_a_fill() {
pe_ttm: 10.0, pe_ttm: 10.0,
turnover_ratio: Some(1.0), turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0), effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: BTreeMap::new(),
}, },
DailyFactorSnapshot { DailyFactorSnapshot {
@@ -190,6 +193,7 @@ fn engine_keeps_unresolved_delisted_position_without_fabricating_a_fill() {
pe_ttm: 10.0, pe_ttm: 10.0,
turnover_ratio: Some(1.0), turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0), effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: BTreeMap::new(),
}, },
DailyFactorSnapshot { DailyFactorSnapshot {
@@ -200,6 +204,7 @@ fn engine_keeps_unresolved_delisted_position_without_fabricating_a_fill() {
pe_ttm: 10.0, pe_ttm: 10.0,
turnover_ratio: Some(1.0), turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0), effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: BTreeMap::new(),
}, },
], ],
@@ -436,6 +441,7 @@ fn engine_applies_successor_conversion_before_unresolved_delisting_audit() {
pe_ttm: 10.0, pe_ttm: 10.0,
turnover_ratio: Some(1.0), turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0), effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: BTreeMap::new(),
}, },
DailyFactorSnapshot { DailyFactorSnapshot {
@@ -446,6 +452,7 @@ fn engine_applies_successor_conversion_before_unresolved_delisting_audit() {
pe_ttm: 10.0, pe_ttm: 10.0,
turnover_ratio: Some(1.0), turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0), effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: BTreeMap::new(),
}, },
DailyFactorSnapshot { DailyFactorSnapshot {
@@ -456,6 +463,7 @@ fn engine_applies_successor_conversion_before_unresolved_delisting_audit() {
pe_ttm: 10.0, pe_ttm: 10.0,
turnover_ratio: Some(1.0), turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0), effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: BTreeMap::new(),
}, },
], ],
+32
View File
@@ -78,6 +78,7 @@ fn single_day_anchor_data(date: NaiveDate) -> DataSet {
pe_ttm: 10.0, pe_ttm: 10.0,
turnover_ratio: Some(1.0), turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0), effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: BTreeMap::new(),
}], }],
vec![CandidateEligibility { vec![CandidateEligibility {
@@ -144,6 +145,7 @@ fn factor_row(
pe_ttm: 10.0, pe_ttm: 10.0,
turnover_ratio: Some(1.0), turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0), effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None,
extra_factors, extra_factors,
} }
} }
@@ -293,6 +295,7 @@ impl Strategy for HookProbeStrategy {
.borrow_mut() .borrow_mut()
.push(format!("on_day:{}", ctx.execution_date)); .push(format!("on_day:{}", ctx.execution_date));
Ok(StrategyDecision { Ok(StrategyDecision {
buy_denials: Default::default(),
rebalance: false, rebalance: false,
target_weights: BTreeMap::new(), target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(), exit_symbols: BTreeSet::new(),
@@ -332,6 +335,7 @@ impl Strategy for AuctionOrderStrategy {
_ctx: &StrategyContext<'_>, _ctx: &StrategyContext<'_>,
) -> Result<StrategyDecision, fidc_core::BacktestError> { ) -> Result<StrategyDecision, fidc_core::BacktestError> {
Ok(StrategyDecision { Ok(StrategyDecision {
buy_denials: Default::default(),
rebalance: false, rebalance: false,
target_weights: BTreeMap::new(), target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(), exit_symbols: BTreeSet::new(),
@@ -375,6 +379,7 @@ impl Strategy for FuturesOrderStrategy {
return Ok(StrategyDecision::default()); return Ok(StrategyDecision::default());
} }
Ok(StrategyDecision { Ok(StrategyDecision {
buy_denials: Default::default(),
rebalance: false, rebalance: false,
target_weights: BTreeMap::new(), target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(), exit_symbols: BTreeSet::new(),
@@ -718,6 +723,7 @@ impl Strategy for LimitCarryStrategy {
} }
self.issued = true; self.issued = true;
Ok(StrategyDecision { Ok(StrategyDecision {
buy_denials: Default::default(),
rebalance: false, rebalance: false,
target_weights: BTreeMap::new(), target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(), exit_symbols: BTreeSet::new(),
@@ -801,6 +807,7 @@ impl Strategy for UniverseDirectiveStrategy {
_ => Vec::new(), _ => Vec::new(),
}; };
Ok(StrategyDecision { Ok(StrategyDecision {
buy_denials: Default::default(),
rebalance: false, rebalance: false,
target_weights: BTreeMap::new(), target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(), exit_symbols: BTreeSet::new(),
@@ -842,6 +849,7 @@ impl Strategy for MinuteProbeStrategy {
_ctx: &StrategyContext<'_>, _ctx: &StrategyContext<'_>,
) -> Result<StrategyDecision, fidc_core::BacktestError> { ) -> Result<StrategyDecision, fidc_core::BacktestError> {
Ok(StrategyDecision { Ok(StrategyDecision {
buy_denials: Default::default(),
rebalance: false, rebalance: false,
target_weights: BTreeMap::new(), target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(), exit_symbols: BTreeSet::new(),
@@ -883,6 +891,7 @@ impl Strategy for MinuteProbeStrategy {
} }
self.ordered = true; self.ordered = true;
Ok(StrategyDecision { Ok(StrategyDecision {
buy_denials: Default::default(),
rebalance: false, rebalance: false,
target_weights: BTreeMap::new(), target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(), exit_symbols: BTreeSet::new(),
@@ -985,6 +994,7 @@ impl Strategy for OrderInspectionStrategy {
_ctx: &StrategyContext<'_>, _ctx: &StrategyContext<'_>,
) -> Result<StrategyDecision, fidc_core::BacktestError> { ) -> Result<StrategyDecision, fidc_core::BacktestError> {
Ok(StrategyDecision { Ok(StrategyDecision {
buy_denials: Default::default(),
rebalance: false, rebalance: false,
target_weights: BTreeMap::new(), target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(), exit_symbols: BTreeSet::new(),
@@ -1028,6 +1038,7 @@ impl Strategy for AccountFlowStrategy {
return Ok(StrategyDecision::default()); return Ok(StrategyDecision::default());
} }
Ok(StrategyDecision { Ok(StrategyDecision {
buy_denials: Default::default(),
rebalance: false, rebalance: false,
target_weights: BTreeMap::new(), target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(), exit_symbols: BTreeSet::new(),
@@ -1137,6 +1148,7 @@ fn engine_runs_strategy_hooks_in_daily_order() {
pe_ttm: 10.0, pe_ttm: 10.0,
turnover_ratio: Some(1.0), turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0), effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: BTreeMap::new(),
}, },
DailyFactorSnapshot { DailyFactorSnapshot {
@@ -1147,6 +1159,7 @@ fn engine_runs_strategy_hooks_in_daily_order() {
pe_ttm: 10.0, pe_ttm: 10.0,
turnover_ratio: Some(1.0), turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0), effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: BTreeMap::new(),
}, },
], ],
@@ -1353,6 +1366,7 @@ fn engine_executes_open_auction_decisions_before_on_day() {
pe_ttm: 10.0, pe_ttm: 10.0,
turnover_ratio: Some(1.0), turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0), effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: BTreeMap::new(),
}], }],
vec![CandidateEligibility { vec![CandidateEligibility {
@@ -1453,6 +1467,7 @@ fn engine_executes_futures_order_intents_against_future_account() {
pe_ttm: 10.0, pe_ttm: 10.0,
turnover_ratio: Some(1.0), turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0), effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: BTreeMap::new(),
}], }],
vec![CandidateEligibility { vec![CandidateEligibility {
@@ -2168,6 +2183,7 @@ fn engine_runs_minute_hooks_and_executes_minute_orders() {
pe_ttm: 10.0, pe_ttm: 10.0,
turnover_ratio: Some(1.0), turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0), effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: BTreeMap::new(),
}], }],
vec![CandidateEligibility { vec![CandidateEligibility {
@@ -2463,6 +2479,7 @@ fn strategy_context_exposes_engine_native_data_helpers() {
pe_ttm: 10.0, pe_ttm: 10.0,
turnover_ratio: Some(1.0), turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0), effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: BTreeMap::new(),
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
@@ -2631,6 +2648,7 @@ fn strategy_context_exposes_final_order_runtime_view() {
pe_ttm: 10.0, pe_ttm: 10.0,
turnover_ratio: Some(1.0), turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0), effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: BTreeMap::new(),
}], }],
vec![CandidateEligibility { vec![CandidateEligibility {
@@ -2874,6 +2892,7 @@ fn engine_applies_account_cash_flow_and_financing_intents() {
pe_ttm: 10.0, pe_ttm: 10.0,
turnover_ratio: Some(1.0), turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0), effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: BTreeMap::new(),
}, },
DailyFactorSnapshot { DailyFactorSnapshot {
@@ -2884,6 +2903,7 @@ fn engine_applies_account_cash_flow_and_financing_intents() {
pe_ttm: 10.0, pe_ttm: 10.0,
turnover_ratio: Some(1.0), turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0), effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: BTreeMap::new(),
}, },
], ],
@@ -3069,6 +3089,7 @@ fn engine_expires_pending_day_limit_orders_at_market_close() {
pe_ttm: 10.0, pe_ttm: 10.0,
turnover_ratio: Some(1.0), turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0), effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: BTreeMap::new(),
}, },
DailyFactorSnapshot { DailyFactorSnapshot {
@@ -3079,6 +3100,7 @@ fn engine_expires_pending_day_limit_orders_at_market_close() {
pe_ttm: 10.0, pe_ttm: 10.0,
turnover_ratio: Some(1.0), turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0), effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: BTreeMap::new(),
}, },
], ],
@@ -3266,6 +3288,7 @@ fn engine_runs_scheduled_rules_for_daily_weekly_and_monthly_triggers() {
pe_ttm: 10.0, pe_ttm: 10.0,
turnover_ratio: Some(1.0), turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0), effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: BTreeMap::new(),
}, },
DailyFactorSnapshot { DailyFactorSnapshot {
@@ -3276,6 +3299,7 @@ fn engine_runs_scheduled_rules_for_daily_weekly_and_monthly_triggers() {
pe_ttm: 10.0, pe_ttm: 10.0,
turnover_ratio: Some(1.0), turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0), effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: BTreeMap::new(),
}, },
DailyFactorSnapshot { DailyFactorSnapshot {
@@ -3286,6 +3310,7 @@ fn engine_runs_scheduled_rules_for_daily_weekly_and_monthly_triggers() {
pe_ttm: 10.0, pe_ttm: 10.0,
turnover_ratio: Some(1.0), turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0), effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: BTreeMap::new(),
}, },
], ],
@@ -3521,6 +3546,7 @@ fn engine_dispatches_process_events_to_external_bus_listeners() {
pe_ttm: 10.0, pe_ttm: 10.0,
turnover_ratio: Some(1.0), turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0), effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: BTreeMap::new(),
}, },
DailyFactorSnapshot { DailyFactorSnapshot {
@@ -3531,6 +3557,7 @@ fn engine_dispatches_process_events_to_external_bus_listeners() {
pe_ttm: 10.0, pe_ttm: 10.0,
turnover_ratio: Some(1.0), turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0), effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: BTreeMap::new(),
}, },
DailyFactorSnapshot { DailyFactorSnapshot {
@@ -3541,6 +3568,7 @@ fn engine_dispatches_process_events_to_external_bus_listeners() {
pe_ttm: 10.0, pe_ttm: 10.0,
turnover_ratio: Some(1.0), turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0), effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: BTreeMap::new(),
}, },
], ],
@@ -3886,6 +3914,7 @@ fn engine_applies_dynamic_universe_and_subscription_directives() {
pe_ttm: 10.0, pe_ttm: 10.0,
turnover_ratio: Some(1.0), turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0), effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: BTreeMap::new(),
}, },
DailyFactorSnapshot { DailyFactorSnapshot {
@@ -3896,6 +3925,7 @@ fn engine_applies_dynamic_universe_and_subscription_directives() {
pe_ttm: 12.0, pe_ttm: 12.0,
turnover_ratio: Some(1.0), turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0), effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: BTreeMap::new(),
}, },
] ]
@@ -4040,6 +4070,7 @@ fn engine_exposes_current_process_context_to_strategies() {
pe_ttm: 10.0, pe_ttm: 10.0,
turnover_ratio: Some(1.0), turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0), effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: BTreeMap::new(),
}], }],
vec![CandidateEligibility { vec![CandidateEligibility {
@@ -4112,6 +4143,7 @@ impl Strategy for BuyMissingRowThenHoldStrategy {
) -> Result<StrategyDecision, fidc_core::BacktestError> { ) -> Result<StrategyDecision, fidc_core::BacktestError> {
if ctx.execution_date == d(2025, 5, 26) { if ctx.execution_date == d(2025, 5, 26) {
return Ok(StrategyDecision { return Ok(StrategyDecision {
buy_denials: Default::default(),
rebalance: false, rebalance: false,
target_weights: BTreeMap::new(), target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(), exit_symbols: BTreeSet::new(),
@@ -50,6 +50,7 @@ fn order_value_rounding_data(date: NaiveDate, symbol: &str, price: f64) -> DataS
pe_ttm: 15.0, pe_ttm: 15.0,
turnover_ratio: Some(2.0), turnover_ratio: Some(2.0),
effective_turnover_ratio: Some(1.8), effective_turnover_ratio: Some(1.8),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: BTreeMap::new(),
}], }],
vec![CandidateEligibility { vec![CandidateEligibility {
@@ -119,6 +120,7 @@ fn intraday_liquidity_data(date: NaiveDate, symbol: &str) -> DataSet {
pe_ttm: 15.0, pe_ttm: 15.0,
turnover_ratio: Some(2.0), turnover_ratio: Some(2.0),
effective_turnover_ratio: Some(1.8), effective_turnover_ratio: Some(1.8),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: BTreeMap::new(),
}], }],
vec![CandidateEligibility { vec![CandidateEligibility {
@@ -207,6 +209,7 @@ fn execute_single_value_order(
&mut portfolio, &mut portfolio,
data, data,
&StrategyDecision { &StrategyDecision {
buy_denials: Default::default(),
rebalance: false, rebalance: false,
target_weights: BTreeMap::new(), target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(), exit_symbols: BTreeSet::new(),
@@ -272,6 +275,7 @@ fn single_symbol_limit_price_data(
pe_ttm: 15.0, pe_ttm: 15.0,
turnover_ratio: Some(2.0), turnover_ratio: Some(2.0),
effective_turnover_ratio: Some(1.8), effective_turnover_ratio: Some(1.8),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: BTreeMap::new(),
}], }],
vec![CandidateEligibility { vec![CandidateEligibility {
@@ -343,6 +347,7 @@ fn broker_executes_explicit_order_value_buy() {
pe_ttm: 15.0, pe_ttm: 15.0,
turnover_ratio: Some(2.0), turnover_ratio: Some(2.0),
effective_turnover_ratio: Some(1.8), effective_turnover_ratio: Some(1.8),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: BTreeMap::new(),
}], }],
vec![CandidateEligibility { vec![CandidateEligibility {
@@ -395,6 +400,7 @@ fn broker_executes_explicit_order_value_buy() {
&mut portfolio, &mut portfolio,
&data, &data,
&StrategyDecision { &StrategyDecision {
buy_denials: Default::default(),
rebalance: false, rebalance: false,
target_weights: BTreeMap::new(), target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(), exit_symbols: BTreeSet::new(),
@@ -495,6 +501,7 @@ fn broker_delayed_limit_open_sell_uses_minute_price() {
pe_ttm: 15.0, pe_ttm: 15.0,
turnover_ratio: Some(2.0), turnover_ratio: Some(2.0),
effective_turnover_ratio: Some(1.8), effective_turnover_ratio: Some(1.8),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: BTreeMap::new(),
}], }],
vec![CandidateEligibility { vec![CandidateEligibility {
@@ -552,6 +559,7 @@ fn broker_delayed_limit_open_sell_uses_minute_price() {
&mut portfolio, &mut portfolio,
&data, &data,
&StrategyDecision { &StrategyDecision {
buy_denials: Default::default(),
rebalance: false, rebalance: false,
target_weights: BTreeMap::new(), target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(), exit_symbols: BTreeSet::new(),
@@ -629,6 +637,7 @@ fn broker_executes_order_shares_and_order_lots() {
pe_ttm: 15.0, pe_ttm: 15.0,
turnover_ratio: Some(2.0), turnover_ratio: Some(2.0),
effective_turnover_ratio: Some(1.8), effective_turnover_ratio: Some(1.8),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: BTreeMap::new(),
}], }],
vec![CandidateEligibility { vec![CandidateEligibility {
@@ -681,6 +690,7 @@ fn broker_executes_order_shares_and_order_lots() {
&mut portfolio, &mut portfolio,
&data, &data,
&StrategyDecision { &StrategyDecision {
buy_denials: Default::default(),
rebalance: false, rebalance: false,
target_weights: BTreeMap::new(), target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(), exit_symbols: BTreeSet::new(),
@@ -757,6 +767,7 @@ fn broker_executes_target_shares_like_order_to() {
pe_ttm: 15.0, pe_ttm: 15.0,
turnover_ratio: Some(2.0), turnover_ratio: Some(2.0),
effective_turnover_ratio: Some(1.8), effective_turnover_ratio: Some(1.8),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: BTreeMap::new(),
}], }],
vec![CandidateEligibility { vec![CandidateEligibility {
@@ -799,6 +810,7 @@ fn broker_executes_target_shares_like_order_to() {
&mut portfolio, &mut portfolio,
&data, &data,
&StrategyDecision { &StrategyDecision {
buy_denials: Default::default(),
rebalance: false, rebalance: false,
target_weights: BTreeMap::new(), target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(), exit_symbols: BTreeSet::new(),
@@ -904,6 +916,7 @@ fn broker_executes_target_portfolio_smart_with_custom_prices() {
pe_ttm: 15.0, pe_ttm: 15.0,
turnover_ratio: Some(2.0), turnover_ratio: Some(2.0),
effective_turnover_ratio: Some(1.8), effective_turnover_ratio: Some(1.8),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: BTreeMap::new(),
}, },
DailyFactorSnapshot { DailyFactorSnapshot {
@@ -914,6 +927,7 @@ fn broker_executes_target_portfolio_smart_with_custom_prices() {
pe_ttm: 14.0, pe_ttm: 14.0,
turnover_ratio: Some(2.2), turnover_ratio: Some(2.2),
effective_turnover_ratio: Some(2.0), effective_turnover_ratio: Some(2.0),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: BTreeMap::new(),
}, },
], ],
@@ -972,6 +986,7 @@ fn broker_executes_target_portfolio_smart_with_custom_prices() {
&mut portfolio, &mut portfolio,
&data, &data,
&StrategyDecision { &StrategyDecision {
buy_denials: Default::default(),
rebalance: false, rebalance: false,
target_weights: BTreeMap::new(), target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(), exit_symbols: BTreeSet::new(),
@@ -1062,6 +1077,7 @@ fn broker_executes_target_portfolio_smart_with_algo_order_style() {
pe_ttm: 14.0, pe_ttm: 14.0,
turnover_ratio: Some(2.2), turnover_ratio: Some(2.2),
effective_turnover_ratio: Some(2.0), effective_turnover_ratio: Some(2.0),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: BTreeMap::new(),
}], }],
vec![CandidateEligibility { vec![CandidateEligibility {
@@ -1129,6 +1145,7 @@ fn broker_executes_target_portfolio_smart_with_algo_order_style() {
&mut portfolio, &mut portfolio,
&data, &data,
&StrategyDecision { &StrategyDecision {
buy_denials: Default::default(),
rebalance: false, rebalance: false,
target_weights: BTreeMap::new(), target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(), exit_symbols: BTreeSet::new(),
@@ -1205,6 +1222,7 @@ fn broker_executes_order_percent_and_target_percent() {
pe_ttm: 15.0, pe_ttm: 15.0,
turnover_ratio: Some(2.0), turnover_ratio: Some(2.0),
effective_turnover_ratio: Some(1.8), effective_turnover_ratio: Some(1.8),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: BTreeMap::new(),
}], }],
vec![CandidateEligibility { vec![CandidateEligibility {
@@ -1243,6 +1261,7 @@ fn broker_executes_order_percent_and_target_percent() {
&mut percent_portfolio, &mut percent_portfolio,
&data, &data,
&StrategyDecision { &StrategyDecision {
buy_denials: Default::default(),
rebalance: false, rebalance: false,
target_weights: BTreeMap::new(), target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(), exit_symbols: BTreeSet::new(),
@@ -1267,6 +1286,7 @@ fn broker_executes_order_percent_and_target_percent() {
&mut target_percent_portfolio, &mut target_percent_portfolio,
&data, &data,
&StrategyDecision { &StrategyDecision {
buy_denials: Default::default(),
rebalance: false, rebalance: false,
target_weights: BTreeMap::new(), target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(), exit_symbols: BTreeSet::new(),
@@ -1329,6 +1349,7 @@ fn broker_uses_day_open_price_for_open_auction_matching() {
pe_ttm: 15.0, pe_ttm: 15.0,
turnover_ratio: Some(2.0), turnover_ratio: Some(2.0),
effective_turnover_ratio: Some(1.8), effective_turnover_ratio: Some(1.8),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: BTreeMap::new(),
}], }],
vec![CandidateEligibility { vec![CandidateEligibility {
@@ -1368,6 +1389,7 @@ fn broker_uses_day_open_price_for_open_auction_matching() {
&mut portfolio, &mut portfolio,
&data, &data,
&StrategyDecision { &StrategyDecision {
buy_denials: Default::default(),
rebalance: false, rebalance: false,
target_weights: BTreeMap::new(), target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(), exit_symbols: BTreeSet::new(),
@@ -1435,6 +1457,7 @@ fn broker_open_auction_uses_auction_volume_without_quote_liquidity() {
pe_ttm: 15.0, pe_ttm: 15.0,
turnover_ratio: Some(2.0), turnover_ratio: Some(2.0),
effective_turnover_ratio: Some(1.8), effective_turnover_ratio: Some(1.8),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: BTreeMap::new(),
}], }],
vec![CandidateEligibility { vec![CandidateEligibility {
@@ -1474,6 +1497,7 @@ fn broker_open_auction_uses_auction_volume_without_quote_liquidity() {
&mut portfolio, &mut portfolio,
&data, &data,
&StrategyDecision { &StrategyDecision {
buy_denials: Default::default(),
rebalance: false, rebalance: false,
target_weights: BTreeMap::new(), target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(), exit_symbols: BTreeSet::new(),
@@ -1538,6 +1562,7 @@ fn broker_cancels_buy_when_open_hits_upper_limit() {
pe_ttm: 15.0, pe_ttm: 15.0,
turnover_ratio: Some(2.0), turnover_ratio: Some(2.0),
effective_turnover_ratio: Some(1.8), effective_turnover_ratio: Some(1.8),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: BTreeMap::new(),
}], }],
vec![CandidateEligibility { vec![CandidateEligibility {
@@ -1576,6 +1601,7 @@ fn broker_cancels_buy_when_open_hits_upper_limit() {
&mut portfolio, &mut portfolio,
&data, &data,
&StrategyDecision { &StrategyDecision {
buy_denials: Default::default(),
rebalance: false, rebalance: false,
target_weights: BTreeMap::new(), target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(), exit_symbols: BTreeSet::new(),
@@ -1653,6 +1679,7 @@ fn broker_applies_price_ratio_slippage_on_snapshot_fills() {
pe_ttm: 15.0, pe_ttm: 15.0,
turnover_ratio: Some(2.0), turnover_ratio: Some(2.0),
effective_turnover_ratio: Some(1.8), effective_turnover_ratio: Some(1.8),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: BTreeMap::new(),
}], }],
vec![CandidateEligibility { vec![CandidateEligibility {
@@ -1692,6 +1719,7 @@ fn broker_applies_price_ratio_slippage_on_snapshot_fills() {
&mut portfolio, &mut portfolio,
&data, &data,
&StrategyDecision { &StrategyDecision {
buy_denials: Default::default(),
rebalance: false, rebalance: false,
target_weights: BTreeMap::new(), target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(), exit_symbols: BTreeSet::new(),
@@ -1755,6 +1783,7 @@ fn broker_applies_dynamic_slippage_on_snapshot_fills() {
pe_ttm: 15.0, pe_ttm: 15.0,
turnover_ratio: Some(2.0), turnover_ratio: Some(2.0),
effective_turnover_ratio: Some(1.8), effective_turnover_ratio: Some(1.8),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: BTreeMap::new(),
}], }],
vec![CandidateEligibility { vec![CandidateEligibility {
@@ -1796,6 +1825,7 @@ fn broker_applies_dynamic_slippage_on_snapshot_fills() {
&mut portfolio, &mut portfolio,
&data, &data,
&StrategyDecision { &StrategyDecision {
buy_denials: Default::default(),
rebalance: false, rebalance: false,
target_weights: BTreeMap::new(), target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(), exit_symbols: BTreeSet::new(),
@@ -1862,6 +1892,7 @@ fn broker_applies_tick_size_slippage_on_intraday_last_fills() {
pe_ttm: 15.0, pe_ttm: 15.0,
turnover_ratio: Some(2.0), turnover_ratio: Some(2.0),
effective_turnover_ratio: Some(1.8), effective_turnover_ratio: Some(1.8),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: BTreeMap::new(),
}], }],
vec![CandidateEligibility { vec![CandidateEligibility {
@@ -1916,6 +1947,7 @@ fn broker_applies_tick_size_slippage_on_intraday_last_fills() {
&mut portfolio, &mut portfolio,
&data, &data,
&StrategyDecision { &StrategyDecision {
buy_denials: Default::default(),
rebalance: false, rebalance: false,
target_weights: BTreeMap::new(), target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(), exit_symbols: BTreeSet::new(),
@@ -1982,6 +2014,7 @@ fn broker_rejects_intraday_last_order_without_execution_quotes() {
pe_ttm: 15.0, pe_ttm: 15.0,
turnover_ratio: Some(2.0), turnover_ratio: Some(2.0),
effective_turnover_ratio: Some(1.8), effective_turnover_ratio: Some(1.8),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: BTreeMap::new(),
}], }],
vec![CandidateEligibility { vec![CandidateEligibility {
@@ -2021,6 +2054,7 @@ fn broker_rejects_intraday_last_order_without_execution_quotes() {
&mut portfolio, &mut portfolio,
&data, &data,
&StrategyDecision { &StrategyDecision {
buy_denials: Default::default(),
rebalance: false, rebalance: false,
target_weights: BTreeMap::new(), target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(), exit_symbols: BTreeSet::new(),
@@ -2091,6 +2125,7 @@ fn broker_executes_intraday_last_on_start_quote_with_trade_delta() {
pe_ttm: 15.0, pe_ttm: 15.0,
turnover_ratio: Some(2.0), turnover_ratio: Some(2.0),
effective_turnover_ratio: Some(1.8), effective_turnover_ratio: Some(1.8),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: BTreeMap::new(),
}], }],
vec![CandidateEligibility { vec![CandidateEligibility {
@@ -2144,6 +2179,7 @@ fn broker_executes_intraday_last_on_start_quote_with_trade_delta() {
&mut portfolio, &mut portfolio,
&data, &data,
&StrategyDecision { &StrategyDecision {
buy_denials: Default::default(),
rebalance: false, rebalance: false,
target_weights: BTreeMap::new(), target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(), exit_symbols: BTreeSet::new(),
@@ -2209,6 +2245,7 @@ fn broker_cancels_market_order_remainder_when_intraday_quote_liquidity_exhausted
pe_ttm: 15.0, pe_ttm: 15.0,
turnover_ratio: Some(2.0), turnover_ratio: Some(2.0),
effective_turnover_ratio: Some(1.8), effective_turnover_ratio: Some(1.8),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: BTreeMap::new(),
}], }],
vec![CandidateEligibility { vec![CandidateEligibility {
@@ -2262,6 +2299,7 @@ fn broker_cancels_market_order_remainder_when_intraday_quote_liquidity_exhausted
&mut portfolio, &mut portfolio,
&data, &data,
&StrategyDecision { &StrategyDecision {
buy_denials: Default::default(),
rebalance: false, rebalance: false,
target_weights: BTreeMap::new(), target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(), exit_symbols: BTreeSet::new(),
@@ -2339,6 +2377,7 @@ fn broker_cancels_market_buy_when_minute_has_no_volume() {
pe_ttm: 15.0, pe_ttm: 15.0,
turnover_ratio: Some(2.0), turnover_ratio: Some(2.0),
effective_turnover_ratio: Some(1.8), effective_turnover_ratio: Some(1.8),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: BTreeMap::new(),
}], }],
vec![CandidateEligibility { vec![CandidateEligibility {
@@ -2377,6 +2416,7 @@ fn broker_cancels_market_buy_when_minute_has_no_volume() {
&mut portfolio, &mut portfolio,
&data, &data,
&StrategyDecision { &StrategyDecision {
buy_denials: Default::default(),
rebalance: false, rebalance: false,
target_weights: BTreeMap::new(), target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(), exit_symbols: BTreeSet::new(),
@@ -2445,6 +2485,7 @@ fn broker_splits_intraday_quote_fills_and_tracks_commission_by_order() {
pe_ttm: 15.0, pe_ttm: 15.0,
turnover_ratio: Some(2.0), turnover_ratio: Some(2.0),
effective_turnover_ratio: Some(1.8), effective_turnover_ratio: Some(1.8),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: BTreeMap::new(),
}], }],
vec![CandidateEligibility { vec![CandidateEligibility {
@@ -2512,6 +2553,7 @@ fn broker_splits_intraday_quote_fills_and_tracks_commission_by_order() {
&mut portfolio, &mut portfolio,
&data, &data,
&StrategyDecision { &StrategyDecision {
buy_denials: Default::default(),
rebalance: false, rebalance: false,
target_weights: BTreeMap::new(), target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(), exit_symbols: BTreeSet::new(),
@@ -2616,6 +2658,7 @@ fn broker_aggregates_intraday_quote_fills_into_vwap_leg() {
pe_ttm: 15.0, pe_ttm: 15.0,
turnover_ratio: Some(2.0), turnover_ratio: Some(2.0),
effective_turnover_ratio: Some(1.8), effective_turnover_ratio: Some(1.8),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: BTreeMap::new(),
}], }],
vec![CandidateEligibility { vec![CandidateEligibility {
@@ -2684,6 +2727,7 @@ fn broker_aggregates_intraday_quote_fills_into_vwap_leg() {
&mut portfolio, &mut portfolio,
&data, &data,
&StrategyDecision { &StrategyDecision {
buy_denials: Default::default(),
rebalance: false, rebalance: false,
target_weights: BTreeMap::new(), target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(), exit_symbols: BTreeSet::new(),
@@ -2771,6 +2815,7 @@ fn broker_executes_algo_vwap_value_with_time_window() {
pe_ttm: 15.0, pe_ttm: 15.0,
turnover_ratio: Some(2.0), turnover_ratio: Some(2.0),
effective_turnover_ratio: Some(1.8), effective_turnover_ratio: Some(1.8),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: BTreeMap::new(),
}], }],
vec![CandidateEligibility { vec![CandidateEligibility {
@@ -2864,6 +2909,7 @@ fn broker_executes_algo_vwap_value_with_time_window() {
&mut portfolio, &mut portfolio,
&data, &data,
&StrategyDecision { &StrategyDecision {
buy_denials: Default::default(),
rebalance: false, rebalance: false,
target_weights: BTreeMap::new(), target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(), exit_symbols: BTreeSet::new(),
@@ -2931,6 +2977,7 @@ fn broker_executes_algo_twap_percent_across_window_quotes() {
pe_ttm: 15.0, pe_ttm: 15.0,
turnover_ratio: Some(2.0), turnover_ratio: Some(2.0),
effective_turnover_ratio: Some(1.8), effective_turnover_ratio: Some(1.8),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: BTreeMap::new(),
}], }],
vec![CandidateEligibility { vec![CandidateEligibility {
@@ -3011,6 +3058,7 @@ fn broker_executes_algo_twap_percent_across_window_quotes() {
&mut portfolio, &mut portfolio,
&data, &data,
&StrategyDecision { &StrategyDecision {
buy_denials: Default::default(),
rebalance: false, rebalance: false,
target_weights: BTreeMap::new(), target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(), exit_symbols: BTreeSet::new(),
@@ -3093,6 +3141,7 @@ fn broker_uses_best_own_price_for_intraday_matching() {
pe_ttm: 15.0, pe_ttm: 15.0,
turnover_ratio: Some(2.0), turnover_ratio: Some(2.0),
effective_turnover_ratio: Some(1.8), effective_turnover_ratio: Some(1.8),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: BTreeMap::new(),
}], }],
vec![CandidateEligibility { vec![CandidateEligibility {
@@ -3146,6 +3195,7 @@ fn broker_uses_best_own_price_for_intraday_matching() {
&mut portfolio, &mut portfolio,
&data, &data,
&StrategyDecision { &StrategyDecision {
buy_denials: Default::default(),
rebalance: false, rebalance: false,
target_weights: BTreeMap::new(), target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(), exit_symbols: BTreeSet::new(),
@@ -3210,6 +3260,7 @@ fn broker_uses_best_counterparty_price_for_intraday_matching() {
pe_ttm: 15.0, pe_ttm: 15.0,
turnover_ratio: Some(2.0), turnover_ratio: Some(2.0),
effective_turnover_ratio: Some(1.8), effective_turnover_ratio: Some(1.8),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: BTreeMap::new(),
}], }],
vec![CandidateEligibility { vec![CandidateEligibility {
@@ -3263,6 +3314,7 @@ fn broker_uses_best_counterparty_price_for_intraday_matching() {
&mut portfolio, &mut portfolio,
&data, &data,
&StrategyDecision { &StrategyDecision {
buy_denials: Default::default(),
rebalance: false, rebalance: false,
target_weights: BTreeMap::new(), target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(), exit_symbols: BTreeSet::new(),
@@ -3365,6 +3417,7 @@ fn rebalance_optimizer_skips_unfunded_buy_when_existing_position_cannot_sell() {
pe_ttm: 15.0, pe_ttm: 15.0,
turnover_ratio: Some(2.0), turnover_ratio: Some(2.0),
effective_turnover_ratio: Some(1.8), effective_turnover_ratio: Some(1.8),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: BTreeMap::new(),
}, },
DailyFactorSnapshot { DailyFactorSnapshot {
@@ -3375,6 +3428,7 @@ fn rebalance_optimizer_skips_unfunded_buy_when_existing_position_cannot_sell() {
pe_ttm: 18.0, pe_ttm: 18.0,
turnover_ratio: Some(2.0), turnover_ratio: Some(2.0),
effective_turnover_ratio: Some(1.8), effective_turnover_ratio: Some(1.8),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: BTreeMap::new(),
}, },
], ],
@@ -3432,6 +3486,7 @@ fn rebalance_optimizer_skips_unfunded_buy_when_existing_position_cannot_sell() {
&mut portfolio, &mut portfolio,
&data, &data,
&StrategyDecision { &StrategyDecision {
buy_denials: Default::default(),
rebalance: true, rebalance: true,
target_weights: BTreeMap::from([("000002.SZ".to_string(), 1.0)]), target_weights: BTreeMap::from([("000002.SZ".to_string(), 1.0)]),
exit_symbols: BTreeSet::new(), exit_symbols: BTreeSet::new(),
@@ -3558,6 +3613,7 @@ fn rebalance_uses_day_open_for_open_auction_valuation() {
pe_ttm: 10.0, pe_ttm: 10.0,
turnover_ratio: Some(1.0), turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0), effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: BTreeMap::new(),
}, },
DailyFactorSnapshot { DailyFactorSnapshot {
@@ -3568,6 +3624,7 @@ fn rebalance_uses_day_open_for_open_auction_valuation() {
pe_ttm: 10.0, pe_ttm: 10.0,
turnover_ratio: Some(1.0), turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0), effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: BTreeMap::new(),
}, },
], ],
@@ -3626,6 +3683,7 @@ fn rebalance_uses_day_open_for_open_auction_valuation() {
&mut portfolio, &mut portfolio,
&data, &data,
&StrategyDecision { &StrategyDecision {
buy_denials: Default::default(),
rebalance: true, rebalance: true,
target_weights: BTreeMap::from([ target_weights: BTreeMap::from([
("000001.SZ".to_string(), 0.5), ("000001.SZ".to_string(), 0.5),
@@ -3744,6 +3802,7 @@ fn rebalance_optimizer_prioritizes_higher_target_weight_when_cash_is_tight() {
pe_ttm: 15.0, pe_ttm: 15.0,
turnover_ratio: Some(2.0), turnover_ratio: Some(2.0),
effective_turnover_ratio: Some(1.8), effective_turnover_ratio: Some(1.8),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: BTreeMap::new(),
}, },
DailyFactorSnapshot { DailyFactorSnapshot {
@@ -3754,6 +3813,7 @@ fn rebalance_optimizer_prioritizes_higher_target_weight_when_cash_is_tight() {
pe_ttm: 18.0, pe_ttm: 18.0,
turnover_ratio: Some(2.0), turnover_ratio: Some(2.0),
effective_turnover_ratio: Some(1.8), effective_turnover_ratio: Some(1.8),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: BTreeMap::new(),
}, },
], ],
@@ -3808,6 +3868,7 @@ fn rebalance_optimizer_prioritizes_higher_target_weight_when_cash_is_tight() {
&mut portfolio, &mut portfolio,
&data, &data,
&StrategyDecision { &StrategyDecision {
buy_denials: Default::default(),
rebalance: true, rebalance: true,
target_weights: BTreeMap::from([ target_weights: BTreeMap::from([
("000001.SZ".to_string(), 0.2), ("000001.SZ".to_string(), 0.2),
@@ -3926,6 +3987,7 @@ fn rebalance_optimizer_does_not_scale_targets_above_requested_weight() {
pe_ttm: 15.0, pe_ttm: 15.0,
turnover_ratio: Some(2.0), turnover_ratio: Some(2.0),
effective_turnover_ratio: Some(1.8), effective_turnover_ratio: Some(1.8),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: BTreeMap::new(),
}, },
DailyFactorSnapshot { DailyFactorSnapshot {
@@ -3936,6 +3998,7 @@ fn rebalance_optimizer_does_not_scale_targets_above_requested_weight() {
pe_ttm: 18.0, pe_ttm: 18.0,
turnover_ratio: Some(2.0), turnover_ratio: Some(2.0),
effective_turnover_ratio: Some(1.8), effective_turnover_ratio: Some(1.8),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: BTreeMap::new(),
}, },
], ],
@@ -3990,6 +4053,7 @@ fn rebalance_optimizer_does_not_scale_targets_above_requested_weight() {
&mut portfolio, &mut portfolio,
&data, &data,
&StrategyDecision { &StrategyDecision {
buy_denials: Default::default(),
rebalance: true, rebalance: true,
target_weights: BTreeMap::from([ target_weights: BTreeMap::from([
("000001.SZ".to_string(), 0.48), ("000001.SZ".to_string(), 0.48),
@@ -4062,6 +4126,7 @@ fn broker_uses_board_specific_min_quantity_and_step_size_for_buy_sizing() {
pe_ttm: 20.0, pe_ttm: 20.0,
turnover_ratio: Some(2.0), turnover_ratio: Some(2.0),
effective_turnover_ratio: Some(1.8), effective_turnover_ratio: Some(1.8),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: BTreeMap::new(),
}], }],
vec![CandidateEligibility { vec![CandidateEligibility {
@@ -4103,6 +4168,7 @@ fn broker_uses_board_specific_min_quantity_and_step_size_for_buy_sizing() {
&mut portfolio, &mut portfolio,
&data, &data,
&StrategyDecision { &StrategyDecision {
buy_denials: Default::default(),
rebalance: false, rebalance: false,
target_weights: BTreeMap::new(), target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(), exit_symbols: BTreeSet::new(),
@@ -4166,6 +4232,7 @@ fn broker_allows_bjse_quantities_above_minimum_without_round_lot_step() {
pe_ttm: 20.0, pe_ttm: 20.0,
turnover_ratio: Some(2.0), turnover_ratio: Some(2.0),
effective_turnover_ratio: Some(1.8), effective_turnover_ratio: Some(1.8),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: BTreeMap::new(),
}], }],
vec![CandidateEligibility { vec![CandidateEligibility {
@@ -4207,6 +4274,7 @@ fn broker_allows_bjse_quantities_above_minimum_without_round_lot_step() {
&mut portfolio, &mut portfolio,
&data, &data,
&StrategyDecision { &StrategyDecision {
buy_denials: Default::default(),
rebalance: false, rebalance: false,
target_weights: BTreeMap::new(), target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(), exit_symbols: BTreeSet::new(),
@@ -4272,6 +4340,7 @@ fn broker_allows_full_odd_lot_sell_when_liquidating_position() {
pe_ttm: 15.0, pe_ttm: 15.0,
turnover_ratio: Some(2.0), turnover_ratio: Some(2.0),
effective_turnover_ratio: Some(1.8), effective_turnover_ratio: Some(1.8),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: BTreeMap::new(),
}], }],
vec![CandidateEligibility { vec![CandidateEligibility {
@@ -4312,6 +4381,7 @@ fn broker_allows_full_odd_lot_sell_when_liquidating_position() {
&mut portfolio, &mut portfolio,
&data, &data,
&StrategyDecision { &StrategyDecision {
buy_denials: Default::default(),
rebalance: false, rebalance: false,
target_weights: BTreeMap::new(), target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(), exit_symbols: BTreeSet::new(),
@@ -4386,6 +4456,7 @@ fn same_day_sell_then_rebuy_is_rejected_by_default() {
pe_ttm: 15.0, pe_ttm: 15.0,
turnover_ratio: Some(2.0), turnover_ratio: Some(2.0),
effective_turnover_ratio: Some(1.8), effective_turnover_ratio: Some(1.8),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: BTreeMap::new(),
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
@@ -4444,6 +4515,7 @@ fn same_day_sell_then_rebuy_is_rejected_by_default() {
&mut portfolio, &mut portfolio,
&data, &data,
&StrategyDecision { &StrategyDecision {
buy_denials: Default::default(),
rebalance: false, rebalance: false,
target_weights: BTreeMap::new(), target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(), exit_symbols: BTreeSet::new(),
@@ -4526,6 +4598,7 @@ fn same_day_sell_then_rebuy_can_be_allowed_by_policy() {
pe_ttm: 15.0, pe_ttm: 15.0,
turnover_ratio: Some(2.0), turnover_ratio: Some(2.0),
effective_turnover_ratio: Some(1.8), effective_turnover_ratio: Some(1.8),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: BTreeMap::new(),
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
@@ -4587,6 +4660,7 @@ fn same_day_sell_then_rebuy_can_be_allowed_by_policy() {
&mut portfolio, &mut portfolio,
&data, &data,
&StrategyDecision { &StrategyDecision {
buy_denials: Default::default(),
rebalance: false, rebalance: false,
target_weights: BTreeMap::new(), target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(), exit_symbols: BTreeSet::new(),
@@ -4640,6 +4714,7 @@ fn broker_configured_policy_can_allow_upper_limit_buy() {
&mut portfolio, &mut portfolio,
&data, &data,
&StrategyDecision { &StrategyDecision {
buy_denials: Default::default(),
rebalance: false, rebalance: false,
target_weights: BTreeMap::new(), target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(), exit_symbols: BTreeSet::new(),
@@ -4686,6 +4761,7 @@ fn broker_configured_policy_can_allow_lower_limit_sell() {
&mut portfolio, &mut portfolio,
&data, &data,
&StrategyDecision { &StrategyDecision {
buy_denials: Default::default(),
rebalance: false, rebalance: false,
target_weights: BTreeMap::new(), target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(), exit_symbols: BTreeSet::new(),
@@ -4778,6 +4854,7 @@ fn two_day_limit_order_data(day1_open: f64, day2_open: f64) -> DataSet {
pe_ttm: 15.0, pe_ttm: 15.0,
turnover_ratio: Some(2.0), turnover_ratio: Some(2.0),
effective_turnover_ratio: Some(1.8), effective_turnover_ratio: Some(1.8),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: BTreeMap::new(),
}, },
DailyFactorSnapshot { DailyFactorSnapshot {
@@ -4788,6 +4865,7 @@ fn two_day_limit_order_data(day1_open: f64, day2_open: f64) -> DataSet {
pe_ttm: 15.0, pe_ttm: 15.0,
turnover_ratio: Some(2.0), turnover_ratio: Some(2.0),
effective_turnover_ratio: Some(1.8), effective_turnover_ratio: Some(1.8),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: BTreeMap::new(),
}, },
], ],
@@ -4859,6 +4937,7 @@ fn broker_expires_day_limit_buy_at_market_close() {
&mut portfolio, &mut portfolio,
&data, &data,
&StrategyDecision { &StrategyDecision {
buy_denials: Default::default(),
rebalance: false, rebalance: false,
target_weights: BTreeMap::new(), target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(), exit_symbols: BTreeSet::new(),
@@ -4899,6 +4978,7 @@ fn broker_expires_day_limit_buy_at_market_close() {
&mut portfolio, &mut portfolio,
&data, &data,
&StrategyDecision { &StrategyDecision {
buy_denials: Default::default(),
rebalance: false, rebalance: false,
target_weights: BTreeMap::new(), target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(), exit_symbols: BTreeSet::new(),
@@ -5825,6 +5905,7 @@ fn broker_uses_limit_price_slippage_for_limit_orders() {
&mut portfolio, &mut portfolio,
&data, &data,
&StrategyDecision { &StrategyDecision {
buy_denials: Default::default(),
rebalance: false, rebalance: false,
target_weights: BTreeMap::new(), target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(), exit_symbols: BTreeSet::new(),
@@ -5863,6 +5944,7 @@ fn broker_rejects_limit_buy_when_final_execution_price_reaches_upper_limit() {
&mut portfolio, &mut portfolio,
&data, &data,
&StrategyDecision { &StrategyDecision {
buy_denials: Default::default(),
rebalance: false, rebalance: false,
target_weights: BTreeMap::new(), target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(), exit_symbols: BTreeSet::new(),
@@ -5907,6 +5989,7 @@ fn broker_executes_limit_value_and_limit_percent_intents() {
&mut value_portfolio, &mut value_portfolio,
&data, &data,
&StrategyDecision { &StrategyDecision {
buy_denials: Default::default(),
rebalance: false, rebalance: false,
target_weights: BTreeMap::new(), target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(), exit_symbols: BTreeSet::new(),
@@ -5932,6 +6015,7 @@ fn broker_executes_limit_value_and_limit_percent_intents() {
&mut percent_portfolio, &mut percent_portfolio,
&data, &data,
&StrategyDecision { &StrategyDecision {
buy_denials: Default::default(),
rebalance: false, rebalance: false,
target_weights: BTreeMap::new(), target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(), exit_symbols: BTreeSet::new(),
@@ -5968,6 +6052,7 @@ fn broker_cancels_open_order_by_order_id() {
&mut portfolio, &mut portfolio,
&data, &data,
&StrategyDecision { &StrategyDecision {
buy_denials: Default::default(),
rebalance: false, rebalance: false,
target_weights: BTreeMap::new(), target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(), exit_symbols: BTreeSet::new(),
@@ -5991,6 +6076,7 @@ fn broker_cancels_open_order_by_order_id() {
&mut portfolio, &mut portfolio,
&data, &data,
&StrategyDecision { &StrategyDecision {
buy_denials: Default::default(),
rebalance: false, rebalance: false,
target_weights: BTreeMap::new(), target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(), exit_symbols: BTreeSet::new(),
@@ -6038,6 +6124,7 @@ fn broker_emits_cancellation_reject_for_unknown_order() {
&mut portfolio, &mut portfolio,
&data, &data,
&StrategyDecision { &StrategyDecision {
buy_denials: Default::default(),
rebalance: false, rebalance: false,
target_weights: BTreeMap::new(), target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(), exit_symbols: BTreeSet::new(),
@@ -6104,6 +6191,7 @@ fn broker_reserves_sellable_quantity_for_open_limit_sells() {
pe_ttm: 15.0, pe_ttm: 15.0,
turnover_ratio: Some(2.0), turnover_ratio: Some(2.0),
effective_turnover_ratio: Some(1.8), effective_turnover_ratio: Some(1.8),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: BTreeMap::new(),
}], }],
vec![CandidateEligibility { vec![CandidateEligibility {
@@ -6145,6 +6233,7 @@ fn broker_reserves_sellable_quantity_for_open_limit_sells() {
&mut portfolio, &mut portfolio,
&data, &data,
&StrategyDecision { &StrategyDecision {
buy_denials: Default::default(),
rebalance: false, rebalance: false,
target_weights: BTreeMap::new(), target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(), exit_symbols: BTreeSet::new(),
@@ -72,6 +72,7 @@ fn factor(
pe_ttm: 18.0, pe_ttm: 18.0,
turnover_ratio: None, turnover_ratio: None,
effective_turnover_ratio: None, effective_turnover_ratio: None,
adjustment_factor_backward1: None,
extra_factors: Default::default(), extra_factors: Default::default(),
} }
} }
+24
View File
@@ -0,0 +1,24 @@
# 完成日线形态与次日信号
`fidc_daily_ohlcv_pattern_v1``fidc-core::daily_patterns` 单一计算核实现。Source Lake 只读取、核验及传输真实 OHLCV;研究服务和策略表达式不分别维护数值算法。
四种量价条件为趋势强势、前高突破、放量上涨、缩量突破;额外提供独立的均线下方、放量下跌卖出条件。前三者名称不暗示当日金叉或价格突破等未实际检验的事实。
## 应用阶段
- `filter.stock_expr(pattern_signal("<模板 JSON>"))`:选择候选,再按既有顺序和 Top N 取目标。
- `filter.buy_expr(pattern_signal("<模板 JSON>"))`:只限制正向仓位增量,不移除目标、不反向清仓,正常减仓不受影响。
- `risk.stop_loss(pattern_signal("<独立卖出模板 JSON>"))`:独立退出条件,不使用买入条件的反值。
- `pattern_score` 只可用于已通过形态条件的对象;没有放量参照或合法排除对象不伪造零分。
参数是 JSON 字符串,例如 `pattern_signal("{\"template\":\"ma_below\",\"parameters\":{\"ma_window\":20}}")`
新规则必须显式 `execution.matching_type("next_bar_open")`。信号日 D 的完整日线不能用于 D 日盘前或盘中;历史回放按 D 决策、下一真实交易日执行,实时上下文使用已完成 D 日窗口。实际委托仍需要执行日行情、资金、可卖数量、交易许可和风控。不得用研究结果开启交易路由。
## 数据与预热
所有价格统一用真实 backward1 因子,成交量不复权。缺失、非有限值、无效 OHLC、重复、未来行、未声明停牌状态均拒绝。仅按明确上市日期证明的上市前窗口或正式停牌记录可以返回结构化排除;不补价、不跳过日期压缩窗口。有效价格但缺复权因子即使停牌也报错。回测和运行态须从表达式提取真实窗口需求,冻结完整日历预热。
研究选择的范围及日期、上市/停牌排除证据、源查询和哈希需保留。固定候选的后续规则回测不等于历史全市场动态选股。CAPM 全区间拟合属于解释性诊断;要成为次日条件,必须另行使用截至 D 日的滚动估计并验证样本外表现,不得回填到拟合区间内。
旧任务默认撮合、历史筛选记录和策略源码不变;用户显式创建新规则后才采用此合同。
@@ -0,0 +1,18 @@
{
"date": "2026-09-08",
"host": "192.168.31.177",
"candidateCommit": "fa6f189",
"unitTestsPassed": 453,
"integrationTestsPassed": 122,
"ignoredManualBenchmarks": 8,
"failed": 0,
"backtestRunnerCargoCheck": "passed",
"covered": ["top-up rejection", "sell permission preserved", "decision context restored", "existing pending order not rewritten", "next-open target direction determined by actual price"],
"deployed": false,
"factorCompilerConnected": false,
"paperLivePlanConnected": false,
"orderAmendmentAcceptanceComplete": false,
"brokerAmendmentTestsPassed": true,
"amendmentPolicy": "Deny buy quantity increases or limit-price increases; allow validated reductions; preserve original state and queue order on rejection.",
"realStrategyAcceptanceComplete": false
}
@@ -0,0 +1,47 @@
{
"schemaVersion": "fidc-engine-optimization-rejection/v1",
"generatedDate": "2026-09-07",
"host": "192.168.31.177",
"candidate": {
"engineCommit": "757b5665ca01bceb2bf8afe1ae2fe2dfc9c52c5c",
"implementationIdentitySha256": "a60aed1a405c1dc4713fdf72a379dcd07bc493998ec92c0aa0573c21ee863fc8",
"description": "Keep the flat immutable quote base and merge a run-local lazy quote overlay through every DataSet quote API."
},
"revert": {
"engineCommit": "728ed795b0c3b9bac9b7e79cb90e469c106e3629",
"status": "deployed_and_healthy"
},
"measurements": {
"currentCloseOneYear": {
"acceptedHotMedianEngineSeconds": 0.722,
"candidateHotMedianEngineSeconds": 0.858,
"candidateHotMedianTotalSeconds": 1.56,
"engineRegressionPercent": 18.836565
},
"nextBarOpenFiveYear": {
"acceptedHotMedianEngineSeconds": 2.223,
"candidateHotMedianEngineSeconds": 3.319,
"candidateHotMedianTotalSeconds": 4.032,
"engineRegressionPercent": 49.302744
}
},
"correctness": {
"totalReturnEqual": true,
"tradeCountEqual": true,
"canonicalResultDigestEqual": true,
"resultStoreDigestEqual": true,
"terminalAuditEqual": true,
"candidateWorkspaceCoreTestsPassed": 442,
"revertWorkspaceCoreTestsPassed": 441,
"workspaceFailures": 0
},
"decision": {
"status": "rejected_and_removed",
"reason": "Public DataSet reads paid overlay branching and enlarged code paths even when no overlay existed; end-to-end regressions outweighed the avoided lazy-write clone.",
"replacementDirection": "Build immutable shared Base Panels before engine execution. Any supplemental intraday store must be private to the broker/loader and must not alter generic DataSet read APIs."
},
"supportingEvidence": [
"/Users/boris/WorkSpace/fidc-backtest-service/docs/evidence/current-close-run-overlay-rejection-20260907.json",
"/Users/boris/WorkSpace/fidc-backtest-service/docs/evidence/next-open-run-overlay-rejection-20260907.json"
]
}
@@ -0,0 +1,62 @@
{
"schemaVersion": "fidc-engine-optimization-rejection/v1",
"generatedDate": "2026-09-07",
"host": "192.168.31.177",
"candidate": {
"engineCommit": "f2de8b281a7f9537665d86895f8c51fb33386d9e",
"implementationIdentitySha256": "1ea0d2834901504e79d76d9df722f009a2ced3c2a02ddf247eae8722be135516",
"description": "Store execution quote dates, symbol maps and quote vectors as nested Arc values so a lazy write clones only one path."
},
"revert": {
"engineCommit": "68c186f649965118da63ff0f0dc2272005538a55",
"implementationIdentitySha256": "4f81eb707baeb76375a03eb8989cb043089b1a7c1d07a998570861eb7dc6fa95",
"description": "Restore the flat read-optimized execution quote index."
},
"measurements": {
"currentCloseOneYear": {
"candidateHotMedianTotalSeconds": 1.19,
"candidateHotMedianEngineSeconds": 0.652,
"revertHotMedianTotalSeconds": 1.183,
"revertHotMedianEngineSeconds": 0.722,
"candidateEngineImprovementPercent": 9.695291
},
"nextBarOpenFiveYear": {
"candidateHotMedianTotalSeconds": 2.896,
"candidateHotMedianEngineSeconds": 2.25,
"revertHotMedianTotalSeconds": 2.848,
"revertHotMedianEngineSeconds": 2.223,
"candidateEngineRegressionPercent": 1.214575
},
"staticCurrentClose": {
"candidateHotMedianEngineSeconds": 0.324,
"acceptedFlatIndexHotMedianEngineSeconds": 0.322
},
"fullMinute": {
"candidateHotMedianEngineSeconds": 0.225,
"acceptedFlatIndexHotMedianEngineSeconds": 0.226
}
},
"correctness": {
"totalReturnEqual": true,
"tradeCountEqual": true,
"canonicalResultDigestEqual": true,
"resultStoreDigestEqual": true,
"terminalAuditEqual": true,
"candidateWorkspaceCoreTestsPassed": 442,
"revertWorkspaceCoreTestsPassed": 441,
"workspaceFailures": 0
},
"decision": {
"status": "rejected_and_removed",
"reason": "Nested Arc indirection improves the lazy-write strategy but causes a repeatable next_bar_open regression and has no static/full-minute read benefit.",
"replacementDirection": "Keep the flat immutable base index and isolate lazy additions in a run-local overlay without changing the base iterator representation."
},
"supportingEvidence": [
"/Users/boris/WorkSpace/fidc-backtest-service/docs/evidence/current-close-layered-quote-index-rejection-20260907.json",
"/Users/boris/WorkSpace/fidc-backtest-service/docs/evidence/static-pool-layered-quote-index-rejection-20260907.json",
"/Users/boris/WorkSpace/fidc-backtest-service/docs/evidence/full-minute-layered-quote-index-rejection-20260907.json",
"/Users/boris/WorkSpace/fidc-backtest-service/docs/evidence/next-open-layered-quote-index-rejection-20260907.json",
"/Users/boris/WorkSpace/fidc-backtest-service/docs/evidence/current-close-after-layered-index-revert-20260907.json",
"/Users/boris/WorkSpace/fidc-backtest-service/docs/evidence/next-open-after-layered-index-revert-20260907.json"
]
}
@@ -0,0 +1,95 @@
{
"schemaVersion": "fidc-engine-optimization-ab/v1",
"generatedDate": "2026-09-07",
"host": "192.168.31.177",
"change": {
"engineCommit": "df7a1ee382843b22d145718576d24e78fdd6c804",
"description": "Use binary partition points on the shared ascending market-cap symbol index before constructing per-stock expression state.",
"activation": "Only ascending market-cap rank with a market-cap selection field and finite inclusive bounds.",
"fallback": "Free-float, custom field, custom expression, descending rank and non-finite bounds keep the original full candidate path.",
"cachedBusinessResults": false
},
"deployment": {
"serviceCommit": "e4ed9467b9a4ca3ed56c64724623f0ba0d005b91",
"implementationIdentitySha256": "b38dc2bb32d46197eb2c48460757b7e4e1c1a3ea80dee1447a169d35af4e759e",
"serviceBinarySha256": "31b97e2ff8d9dca00c685ab7e70c239cb038ba467ed3ec669a237de7b8d0ba27",
"runnerBinarySha256": "932d531a222df936c82ca38c3ddad1f940c386e315c906cd3da4f428348114d8",
"serviceUser": "boris",
"sourceLakeRestarted": false,
"health": "ok"
},
"currentCloseOneYear": {
"before": {
"processHotMedianTotalSeconds": 1.238,
"processHotMedianEngineSeconds": 0.773
},
"after": {
"runs": 5,
"processHotMedianTotalSeconds": 1.154,
"processHotMedianEngineSeconds": 0.714,
"totalReturn": 0.3201517861398,
"tradeCount": 5351,
"canonicalResultDigest": "7204c6f41b8e39fbf1af7fc55cd601b80f3427a7aa058394ccd8b0b14ca48eed",
"resultStoreDigest": "0b4d24ed5ec2b27cc4135707b4c51c78eb2c3e35a20da8108610778c30c72c73",
"terminalAuditStatus": "clean"
},
"improvementPercent": {
"total": 6.785137,
"engine": 7.6326
}
},
"crossModeRegression": {
"staticCurrentClose": {
"runs": 6,
"processHotMedianTotalSeconds": 0.822,
"processHotMedianEngineSeconds": 0.322,
"totalReturn": 0.13228843240310018,
"tradeCount": 4445,
"canonicalResultDigest": "fdfa855295c0b55bdbe6f39952ead1515e844bf033ced974d3c3ddc037a5d0b1",
"resultStoreDigest": "697566645116c76ff837cd36f7f9bbd7ad3eb30510a5b95012fb730d5072d511"
},
"nextBarOpenFiveYear": {
"runs": 5,
"processHotMedianTotalSeconds": 2.811,
"processHotMedianEngineSeconds": 2.188,
"totalReturn": 0.9922618879291,
"tradeCount": 25827,
"canonicalResultDigest": "ac1d167cb1e1073e1d1ecb01e914f94d7560081c1d238e6b4418d86233250719",
"resultStoreDigest": "79570e0ae6b07badc1b693dc897dd1381647d259a4fe44ed3e50bc215e2fd088"
},
"fullMinute": {
"runs": 6,
"processHotMedianTotalSeconds": 0.598,
"processHotMedianEngineSeconds": 0.226,
"totalReturn": 0.03473222656500008,
"tradeCount": 156,
"canonicalResultDigest": "7dae3a618932b90d36e9968c027f08a94d69b8fc6a56b1ea0cc1a2cb771d85b8",
"resultStoreDigest": "bbbd7080b8fd7f6e8c3a8132499842bd0e4d644dbfdc3e8dbca7d2c0c1381e93"
}
},
"profile": {
"beforePath": "/srv/fidc/canonical/run/fidc-private/evidence/current-close-shared-quote-profile-20260907",
"afterPath": "/srv/fidc/canonical/run/fidc-private/evidence/current-close-market-cap-range-profile-20260907",
"beforeSamples": 408,
"afterSamples": 375,
"lostSamples": 0,
"stockStatePercentBefore": 6.8,
"stockStatePercentAfter": 3.02,
"marketCapSelectionPercentBefore": 2.91,
"marketCapSelectionPercentAfter": 1.7
},
"verification": {
"workspaceCoreTestsPassed": 441,
"workspaceCoreTestsIgnored": 6,
"workspaceFailures": 0,
"allCanonicalDigestsMatched": true,
"allResultStoreDigestsMatched": true,
"allTerminalAuditsClean": true
},
"supportingEvidence": [
"/Users/boris/WorkSpace/fidc-backtest-service/docs/evidence/current-close-market-cap-range-20260907.json",
"/Users/boris/WorkSpace/fidc-backtest-service/docs/evidence/static-pool-market-cap-range-control-20260907.json",
"/Users/boris/WorkSpace/fidc-backtest-service/docs/evidence/next-open-market-cap-range-20260907.json",
"/Users/boris/WorkSpace/fidc-backtest-service/docs/evidence/full-minute-market-cap-range-control-20260907.json"
]
}
@@ -0,0 +1,467 @@
{
"schemaVersion": "fidc-backtest-benchmark/v5",
"label": "native-pit-intraday",
"generatedAtUnixSeconds": 1788790043,
"requestSha256": "9dd420ae606878b90ff660fcd4efe520e04a237ec758ed974b87b6d9a72cab73",
"requestOrigin": {
"kind": "request_file"
},
"baseline": null,
"baselineComparable": true,
"baselineComparison": null,
"bundleRefresh": null,
"baseUrl": "http://127.0.0.1:8081",
"runs": [
{
"slot": 0,
"cacheClass": "restart_or_cold",
"runId": "btr_1788790021780_1150210_0",
"status": "succeeded",
"queueWaitSeconds": 0.0,
"executionSeconds": 8.994,
"totalSeconds": 8.994,
"runnerSeconds": 7.904,
"bundleValidationSeconds": 0.166,
"sourceEpochSeconds": 0.0,
"strategyContractSeconds": 0.001,
"terminalCalendarSeconds": 0.0,
"riskFreeRateSeconds": 0.031,
"riskFreeRateCacheHit": false,
"engineSeconds": 0.012,
"dataSeconds": 7.662,
"resultSeconds": 0.03,
"unattributedSeconds": 0.0,
"canonicalSeconds": 0.001,
"resultFinalization": {
"canonicalSeconds": 0.001,
"processEventStoreSeconds": 0.001,
"storePublishSeconds": 0.027,
"summarySeconds": 0.0
},
"dataPreparationTimings": {
"adjustmentValidationSeconds": 0.0,
"availableStockStDates": 0,
"benchmarkFetchSeconds": 0.344,
"candidatePlanBasePanelSeconds": 0.006,
"candidatePlanSelectionSeconds": 0.004,
"corporateActionSeconds": 0.605,
"datasetConstructSeconds": 0.0,
"externalFetchSeconds": 0.344,
"historyDateSeconds": 0.0,
"instrumentIndexBuildSeconds": 0.0,
"instrumentMetadataSeconds": 0.016,
"loopSeconds": 0.0,
"marketIndexBuildSeconds": 0.001,
"quotePlanSeconds": 0.935,
"riskSnapshotSeconds": 0.0,
"sourceQuerySeconds": 5.752,
"stockStLookupSeconds": 0.0,
"supplementalExecutionFetchSeconds": 0.0,
"totalSeconds": 7.659
},
"finalizationSeconds": 1.089,
"finalizationTimings": {
"artifactVerificationSeconds": 0.0,
"cacheRegistrationSeconds": 0.0,
"postgresClientAcquireSeconds": 0.216,
"postgresPersistSeconds": 0.872,
"preTerminalUpdateSeconds": 1.089,
"schemaCheckSeconds": 0.0,
"timingSource": "fidc-backtest-service-monotonic"
},
"totalReturn": 0.011211658838299776,
"tradeCount": 104,
"riskDecisionCount": 11,
"resultStoreDigest": "5ed069a1f5999542e6da78b10797aa21aabe71e13dd79649cd11157cbc57d3b4",
"canonicalResultDigest": "5c8a110cc6f285b9d569e818a0472a8b5c76f14df853a1c2c42d1b5222c39b3a",
"terminalAudit": {
"cashReceivableCount": 0,
"cashReceivableTotalAmount": 0.0,
"earliestDeferredCashDate": null,
"futuresOpenOrderCount": 0,
"lastExecutionDate": "2025-09-12",
"omittedOpenOrderCount": 0,
"openOrderSamples": [],
"pendingCashFlowCount": 0,
"pendingCashFlowNetAmount": 0.0,
"status": "clean",
"stockOpenOrderCount": 0
},
"terminalAuditVerified": true,
"terminalAuditSha256": "5836794e260aa2cfd1a06d4568ae6577a37c9cbe09ec0f00995121ea5b2522d9",
"implementation": {
"buildManifestSha256": "fb4f7ac191c8ab8d8d39fc48014f84ce01d4c0731ffe986e7d5cb0107a3e5539",
"builtAt": "2026-09-07T22:06:18+08:00",
"engineCommit": "1b78186c4e37273c64c1f9e208c2e47021ee4fff",
"identitySha256": "14e7ff10848d64ffcec232dea8cff23a032702301b81ae036bb0b26b4b0b5e33",
"runnerBinarySha256": "7cddebf9ab5cbbd5d6f9bd5c7bf006c3895ee26967d23d80dc86b47c50fb761d",
"schemaVersion": "fidc-backtest-implementation/v1",
"serviceBinarySha256": "f09d121d73c34ad3cd5e2e0c24b0bdaf1974309c9ec3b93229cfb824b1e9f11a",
"serviceCommit": "c80a38b91a398fd713f1f1faaa19a581ea85efad",
"status": "verified"
},
"implementationVerified": true,
"implementationIdentitySha256": "14e7ff10848d64ffcec232dea8cff23a032702301b81ae036bb0b26b4b0b5e33",
"artifactManifest": {
"canonicalResult": {
"algorithm": "sha256",
"ordering": "engine_fact_order_v2",
"schemaVersion": "fidc-canonical-backtest-result/v2",
"sections": {
"accountEvents": {
"rowCount": 104,
"sha256": "ea6bbbd55df3da0692dfd712f88ef6f8af510ada318641ede71884f5c5d14e7e"
},
"equityFacts": {
"rowCount": 5,
"sha256": "96004e235e4603c0a7d1a5629a2f0d2b364258d562e1d397cdd6925718a7d060"
},
"fillEvents": {
"rowCount": 104,
"sha256": "2c37f626b7de014f5638dc8edf33f3ca8d23126ab2e62f26f01fbcb33e8c4e48"
},
"holdingSnapshots": {
"rowCount": 112,
"sha256": "50abab8a304d2e2a7de344538aff604c8bf96ef00283330d107916cc2e234f67"
},
"orderEvents": {
"rowCount": 108,
"sha256": "f327f21151a5d7dbe53d2dcde9202be699f9672f758373eeb9c67985ca3826f5"
},
"riskAudits": {
"rowCount": 11,
"sha256": "428a8d7395b54ec5a9523eb745d5007c64b9d9867615e870d95ea9ea176f536e"
}
},
"sha256": "5c8a110cc6f285b9d569e818a0472a8b5c76f14df853a1c2c42d1b5222c39b3a",
"totalRows": 444
},
"processEventStore": {
"bytes": 44302,
"identitySha256": "1444075abea626e8d7d1b8c9c9cda984a2a687ff2b9465db5648e0f4525130e7",
"manifestBytes": 461,
"manifestSha256": "b43a818096d09648b809a46d12ad9eae39638d57ea52f7696906359036a80c52",
"schemaVersion": "fidc-backtest-process-event-manifest/v1",
"sections": {
"processEvents": {
"blockCount": 1,
"bytes": 43841,
"rowCount": 370,
"sha256": "8edb6dad95777e3eef5946acbbdfc4b9125df34a79ac716d8c3ff7835b552bf6"
}
},
"sha256": "661d84b7b430ac25fb5c959df8e7fb309133784d85a7524375cd803e29e52094",
"totalEvents": 370
},
"resultStore": {
"bytes": 148006,
"identitySha256": "484b7a4a8d5cc77c6222ce01f2fe452c890995a476579f209d1d4ac43885d60a",
"manifestBytes": 2999,
"manifestSha256": "a0a7072f176db79d781846cc124d2e4794c5084c48c525876863751f709c4e7f",
"schemaVersion": "fidc-backtest-fact-manifest/v1",
"sections": {
"accountEvents": {
"blockCount": 1,
"bytes": 13313,
"rowCount": 104,
"sha256": "ab2f936c9072f80d58487b62a6f64553d279582ddfe6af0299afe20447e83424"
},
"equityFacts": {
"blockCount": 1,
"bytes": 11009,
"rowCount": 5,
"sha256": "c40650f5b27cfff873194572ffe28a5412fe77730fe75492c7b5a575f8f5caae"
},
"fillEvents": {
"blockCount": 1,
"bytes": 40541,
"rowCount": 104,
"sha256": "80a3f1d3a9c23db7dec2dc70f029280c0844370be6d87703cd396a234068f708"
},
"holdingSnapshots": {
"blockCount": 1,
"bytes": 51338,
"rowCount": 112,
"sha256": "7684244dc8e7788f777309169be61b14fcb532abdd89fe93f0a7f2980a615ba9"
},
"orderEvents": {
"blockCount": 1,
"bytes": 25768,
"rowCount": 108,
"sha256": "31ff15d2cf79b1eb9c333d19d8fb400c9c5a52c36b0f662330ec39aa9d0f805c"
},
"riskAudits": {
"blockCount": 1,
"bytes": 3038,
"rowCount": 11,
"sha256": "5a7269219a21cf14c23d788a6a96ddf1d1ddf0ef1e3b57a8db4ae4b1da789aff"
}
},
"sha256": "5ed069a1f5999542e6da78b10797aa21aabe71e13dd79649cd11157cbc57d3b4",
"totalEvents": 444
},
"schemaVersion": "fidc-backtest-artifacts/v4"
}
},
{
"slot": 1,
"cacheClass": "process_hot",
"runId": "btr_1788790036494_1150210_1",
"status": "succeeded",
"queueWaitSeconds": 0.0,
"executionSeconds": 0.596,
"totalSeconds": 0.596,
"runnerSeconds": 0.026,
"bundleValidationSeconds": 0.0,
"sourceEpochSeconds": 0.0,
"strategyContractSeconds": 0.003,
"terminalCalendarSeconds": 0.0,
"riskFreeRateSeconds": 0.0,
"riskFreeRateCacheHit": true,
"engineSeconds": 0.012,
"dataSeconds": 0.007,
"resultSeconds": 0.002,
"unattributedSeconds": 0.0,
"canonicalSeconds": 0.001,
"resultFinalization": {
"canonicalSeconds": 0.001,
"processEventStoreSeconds": 0.001,
"storePublishSeconds": 0.0,
"summarySeconds": 0.0
},
"dataPreparationTimings": {
"adjustmentValidationSeconds": 0.0,
"availableStockStDates": 0,
"benchmarkFetchSeconds": 0.0,
"candidatePlanBasePanelSeconds": 0.0,
"candidatePlanSelectionSeconds": 0.0,
"corporateActionSeconds": 0.0,
"datasetConstructSeconds": 0.0,
"externalFetchSeconds": 0.0,
"historyDateSeconds": 0.0,
"instrumentIndexBuildSeconds": 0.0,
"instrumentMetadataSeconds": 0.0,
"loopSeconds": 0.0,
"marketIndexBuildSeconds": 0.0,
"quotePlanSeconds": 0.0,
"riskSnapshotSeconds": 0.0,
"sourceQuerySeconds": 0.0,
"stockStLookupSeconds": 0.0,
"supplementalExecutionFetchSeconds": 0.0,
"totalSeconds": 0.006
},
"finalizationSeconds": 0.569,
"finalizationTimings": {
"artifactVerificationSeconds": 0.001,
"cacheRegistrationSeconds": 0.0,
"postgresClientAcquireSeconds": 0.27,
"postgresPersistSeconds": 0.298,
"preTerminalUpdateSeconds": 0.569,
"schemaCheckSeconds": 0.0,
"timingSource": "fidc-backtest-service-monotonic"
},
"totalReturn": 0.011211658838299776,
"tradeCount": 104,
"riskDecisionCount": 11,
"resultStoreDigest": "5ed069a1f5999542e6da78b10797aa21aabe71e13dd79649cd11157cbc57d3b4",
"canonicalResultDigest": "5c8a110cc6f285b9d569e818a0472a8b5c76f14df853a1c2c42d1b5222c39b3a",
"terminalAudit": {
"cashReceivableCount": 0,
"cashReceivableTotalAmount": 0.0,
"earliestDeferredCashDate": null,
"futuresOpenOrderCount": 0,
"lastExecutionDate": "2025-09-12",
"omittedOpenOrderCount": 0,
"openOrderSamples": [],
"pendingCashFlowCount": 0,
"pendingCashFlowNetAmount": 0.0,
"status": "clean",
"stockOpenOrderCount": 0
},
"terminalAuditVerified": true,
"terminalAuditSha256": "5836794e260aa2cfd1a06d4568ae6577a37c9cbe09ec0f00995121ea5b2522d9",
"implementation": {
"buildManifestSha256": "fb4f7ac191c8ab8d8d39fc48014f84ce01d4c0731ffe986e7d5cb0107a3e5539",
"builtAt": "2026-09-07T22:06:18+08:00",
"engineCommit": "1b78186c4e37273c64c1f9e208c2e47021ee4fff",
"identitySha256": "14e7ff10848d64ffcec232dea8cff23a032702301b81ae036bb0b26b4b0b5e33",
"runnerBinarySha256": "7cddebf9ab5cbbd5d6f9bd5c7bf006c3895ee26967d23d80dc86b47c50fb761d",
"schemaVersion": "fidc-backtest-implementation/v1",
"serviceBinarySha256": "f09d121d73c34ad3cd5e2e0c24b0bdaf1974309c9ec3b93229cfb824b1e9f11a",
"serviceCommit": "c80a38b91a398fd713f1f1faaa19a581ea85efad",
"status": "verified"
},
"implementationVerified": true,
"implementationIdentitySha256": "14e7ff10848d64ffcec232dea8cff23a032702301b81ae036bb0b26b4b0b5e33",
"artifactManifest": {
"canonicalResult": {
"algorithm": "sha256",
"ordering": "engine_fact_order_v2",
"schemaVersion": "fidc-canonical-backtest-result/v2",
"sections": {
"accountEvents": {
"rowCount": 104,
"sha256": "ea6bbbd55df3da0692dfd712f88ef6f8af510ada318641ede71884f5c5d14e7e"
},
"equityFacts": {
"rowCount": 5,
"sha256": "96004e235e4603c0a7d1a5629a2f0d2b364258d562e1d397cdd6925718a7d060"
},
"fillEvents": {
"rowCount": 104,
"sha256": "2c37f626b7de014f5638dc8edf33f3ca8d23126ab2e62f26f01fbcb33e8c4e48"
},
"holdingSnapshots": {
"rowCount": 112,
"sha256": "50abab8a304d2e2a7de344538aff604c8bf96ef00283330d107916cc2e234f67"
},
"orderEvents": {
"rowCount": 108,
"sha256": "f327f21151a5d7dbe53d2dcde9202be699f9672f758373eeb9c67985ca3826f5"
},
"riskAudits": {
"rowCount": 11,
"sha256": "428a8d7395b54ec5a9523eb745d5007c64b9d9867615e870d95ea9ea176f536e"
}
},
"sha256": "5c8a110cc6f285b9d569e818a0472a8b5c76f14df853a1c2c42d1b5222c39b3a",
"totalRows": 444
},
"processEventStore": {
"bytes": 44302,
"identitySha256": "1444075abea626e8d7d1b8c9c9cda984a2a687ff2b9465db5648e0f4525130e7",
"manifestBytes": 461,
"manifestSha256": "b43a818096d09648b809a46d12ad9eae39638d57ea52f7696906359036a80c52",
"schemaVersion": "fidc-backtest-process-event-manifest/v1",
"sections": {
"processEvents": {
"blockCount": 1,
"bytes": 43841,
"rowCount": 370,
"sha256": "8edb6dad95777e3eef5946acbbdfc4b9125df34a79ac716d8c3ff7835b552bf6"
}
},
"sha256": "661d84b7b430ac25fb5c959df8e7fb309133784d85a7524375cd803e29e52094",
"totalEvents": 370
},
"resultStore": {
"bytes": 148006,
"identitySha256": "484b7a4a8d5cc77c6222ce01f2fe452c890995a476579f209d1d4ac43885d60a",
"manifestBytes": 2999,
"manifestSha256": "a0a7072f176db79d781846cc124d2e4794c5084c48c525876863751f709c4e7f",
"schemaVersion": "fidc-backtest-fact-manifest/v1",
"sections": {
"accountEvents": {
"blockCount": 1,
"bytes": 13313,
"rowCount": 104,
"sha256": "ab2f936c9072f80d58487b62a6f64553d279582ddfe6af0299afe20447e83424"
},
"equityFacts": {
"blockCount": 1,
"bytes": 11009,
"rowCount": 5,
"sha256": "c40650f5b27cfff873194572ffe28a5412fe77730fe75492c7b5a575f8f5caae"
},
"fillEvents": {
"blockCount": 1,
"bytes": 40541,
"rowCount": 104,
"sha256": "80a3f1d3a9c23db7dec2dc70f029280c0844370be6d87703cd396a234068f708"
},
"holdingSnapshots": {
"blockCount": 1,
"bytes": 51338,
"rowCount": 112,
"sha256": "7684244dc8e7788f777309169be61b14fcb532abdd89fe93f0a7f2980a615ba9"
},
"orderEvents": {
"blockCount": 1,
"bytes": 25768,
"rowCount": 108,
"sha256": "31ff15d2cf79b1eb9c333d19d8fb400c9c5a52c36b0f662330ec39aa9d0f805c"
},
"riskAudits": {
"blockCount": 1,
"bytes": 3038,
"rowCount": 11,
"sha256": "5a7269219a21cf14c23d788a6a96ddf1d1ddf0ef1e3b57a8db4ae4b1da789aff"
}
},
"sha256": "5ed069a1f5999542e6da78b10797aa21aabe71e13dd79649cd11157cbc57d3b4",
"totalEvents": 444
},
"schemaVersion": "fidc-backtest-artifacts/v4"
}
}
],
"summary": {
"resultConsistent": true,
"resultStoreDigest": "5ed069a1f5999542e6da78b10797aa21aabe71e13dd79649cd11157cbc57d3b4",
"canonicalResultDigest": "5c8a110cc6f285b9d569e818a0472a8b5c76f14df853a1c2c42d1b5222c39b3a",
"totalReturn": 0.011211658838299776,
"tradeCount": 104,
"terminalAudit": {
"cashReceivableCount": 0,
"cashReceivableTotalAmount": 0.0,
"earliestDeferredCashDate": null,
"futuresOpenOrderCount": 0,
"lastExecutionDate": "2025-09-12",
"omittedOpenOrderCount": 0,
"openOrderSamples": [],
"pendingCashFlowCount": 0,
"pendingCashFlowNetAmount": 0.0,
"status": "clean",
"stockOpenOrderCount": 0
},
"terminalAuditSha256": "5836794e260aa2cfd1a06d4568ae6577a37c9cbe09ec0f00995121ea5b2522d9",
"implementation": {
"buildManifestSha256": "fb4f7ac191c8ab8d8d39fc48014f84ce01d4c0731ffe986e7d5cb0107a3e5539",
"builtAt": "2026-09-07T22:06:18+08:00",
"engineCommit": "1b78186c4e37273c64c1f9e208c2e47021ee4fff",
"identitySha256": "14e7ff10848d64ffcec232dea8cff23a032702301b81ae036bb0b26b4b0b5e33",
"runnerBinarySha256": "7cddebf9ab5cbbd5d6f9bd5c7bf006c3895ee26967d23d80dc86b47c50fb761d",
"schemaVersion": "fidc-backtest-implementation/v1",
"serviceBinarySha256": "f09d121d73c34ad3cd5e2e0c24b0bdaf1974309c9ec3b93229cfb824b1e9f11a",
"serviceCommit": "c80a38b91a398fd713f1f1faaa19a581ea85efad",
"status": "verified"
},
"implementationIdentitySha256": "14e7ff10848d64ffcec232dea8cff23a032702301b81ae036bb0b26b4b0b5e33",
"restartOrCold": {
"queueWaitSeconds": 0.0,
"executionSeconds": 8.994,
"totalSeconds": 8.994,
"runnerSeconds": 7.904,
"bundleValidationSeconds": 0.166,
"sourceEpochSeconds": 0.0,
"strategyContractSeconds": 0.001,
"terminalCalendarSeconds": 0.0,
"riskFreeRateSeconds": 0.031,
"riskFreeRateCacheHit": false,
"engineSeconds": 0.012,
"dataSeconds": 7.662,
"resultSeconds": 0.03,
"unattributedSeconds": 0.0,
"canonicalSeconds": 0.001,
"finalizationSeconds": 1.089
},
"processHotMedian": {
"queueWaitSeconds": 0.0,
"executionSeconds": 0.596,
"totalSeconds": 0.596,
"runnerSeconds": 0.026,
"bundleValidationSeconds": 0.0,
"sourceEpochSeconds": 0.0,
"strategyContractSeconds": 0.003,
"terminalCalendarSeconds": 0.0,
"riskFreeRateSeconds": 0.0,
"riskFreeRateCacheHit": true,
"engineSeconds": 0.012,
"dataSeconds": 0.007,
"resultSeconds": 0.002,
"unattributedSeconds": 0.0,
"canonicalSeconds": 0.001,
"finalizationSeconds": 0.569
}
}
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,276 @@
{
"schemaVersion": "fidc-backtest-benchmark/v5",
"label": "native-pit-nextopen",
"generatedAtUnixSeconds": 1788790370,
"requestSha256": "7948497bfa966eb118e56438e7c261869a26ab70a62186b92f761719f85e91c7",
"requestOrigin": {
"kind": "request_file"
},
"baseline": null,
"baselineComparable": true,
"baselineComparison": null,
"bundleRefresh": null,
"baseUrl": "http://127.0.0.1:8081",
"runs": [
{
"slot": 0,
"cacheClass": "restart_or_cold",
"runId": "btr_1788790344805_1150210_2",
"status": "succeeded",
"queueWaitSeconds": 0.0,
"executionSeconds": 21.61,
"totalSeconds": 21.61,
"runnerSeconds": 21.294,
"bundleValidationSeconds": 0.004,
"sourceEpochSeconds": 0.0,
"strategyContractSeconds": 0.003,
"terminalCalendarSeconds": 0.0,
"riskFreeRateSeconds": 0.017,
"riskFreeRateCacheHit": false,
"engineSeconds": 5.566,
"dataSeconds": 15.305,
"resultSeconds": 0.395,
"unattributedSeconds": 0.0,
"canonicalSeconds": 0.206,
"resultFinalization": {
"canonicalSeconds": 0.206,
"processEventStoreSeconds": 0.168,
"storePublishSeconds": 0.005,
"summarySeconds": 0.003
},
"dataPreparationTimings": {
"adjustmentValidationSeconds": 0.0,
"availableStockStDates": 0,
"benchmarkFetchSeconds": 0.023,
"candidatePlanBasePanelSeconds": 0.0,
"candidatePlanSelectionSeconds": 0.0,
"corporateActionSeconds": 0.017,
"datasetConstructSeconds": 2.943,
"externalFetchSeconds": 0.025,
"historyDateSeconds": 0.0,
"instrumentIndexBuildSeconds": 0.269,
"instrumentMetadataSeconds": 0.006,
"loopSeconds": 0.925,
"marketIndexBuildSeconds": 1.846,
"quotePlanSeconds": 0.0,
"riskSnapshotSeconds": 0.0,
"sourceQuerySeconds": 9.0,
"stockStLookupSeconds": 0.0,
"supplementalExecutionFetchSeconds": 0.0,
"totalSeconds": 15.3
},
"finalizationSeconds": 0.307,
"finalizationTimings": {
"artifactVerificationSeconds": 0.008,
"cacheRegistrationSeconds": 0.0,
"postgresClientAcquireSeconds": 0.0,
"postgresPersistSeconds": 0.299,
"preTerminalUpdateSeconds": 0.307,
"schemaCheckSeconds": 0.0,
"timingSource": "fidc-backtest-service-monotonic"
},
"totalReturn": 0.9857987122635,
"tradeCount": 25408,
"riskDecisionCount": 113,
"resultStoreDigest": "1eab5a5ffe59efeaba6b2048a33760aab31d42efa0fc10e35a4a7048db853235",
"canonicalResultDigest": "b29b085d43bcc0f8f1712767421781c70570a24112933623d4bbbef46508d710",
"terminalAudit": {
"cashReceivableCount": 0,
"cashReceivableTotalAmount": 0.0,
"earliestDeferredCashDate": null,
"futuresOpenOrderCount": 0,
"lastExecutionDate": "2026-08-28",
"omittedOpenOrderCount": 0,
"openOrderSamples": [],
"pendingCashFlowCount": 0,
"pendingCashFlowNetAmount": 0.0,
"status": "clean",
"stockOpenOrderCount": 0
},
"terminalAuditVerified": true,
"terminalAuditSha256": "0e8503c2bfe9ecfd93bab3a95f79461830886eb693cfe6f477279908c0d95bd5",
"implementation": {
"buildManifestSha256": "fb4f7ac191c8ab8d8d39fc48014f84ce01d4c0731ffe986e7d5cb0107a3e5539",
"builtAt": "2026-09-07T22:06:18+08:00",
"engineCommit": "1b78186c4e37273c64c1f9e208c2e47021ee4fff",
"identitySha256": "14e7ff10848d64ffcec232dea8cff23a032702301b81ae036bb0b26b4b0b5e33",
"runnerBinarySha256": "7cddebf9ab5cbbd5d6f9bd5c7bf006c3895ee26967d23d80dc86b47c50fb761d",
"schemaVersion": "fidc-backtest-implementation/v1",
"serviceBinarySha256": "f09d121d73c34ad3cd5e2e0c24b0bdaf1974309c9ec3b93229cfb824b1e9f11a",
"serviceCommit": "c80a38b91a398fd713f1f1faaa19a581ea85efad",
"status": "verified"
},
"implementationVerified": true,
"implementationIdentitySha256": "14e7ff10848d64ffcec232dea8cff23a032702301b81ae036bb0b26b4b0b5e33",
"artifactManifest": {
"canonicalResult": {
"algorithm": "sha256",
"ordering": "engine_fact_order_v2",
"schemaVersion": "fidc-canonical-backtest-result/v2",
"sections": {
"accountEvents": {
"rowCount": 25572,
"sha256": "da7576c1cbade9b48f1a154d07e870f1472924f4e0054cba0ef06739585ce380"
},
"equityFacts": {
"rowCount": 1216,
"sha256": "13db0a6d484cdf871e4f1dc1a4bc8db1e809d2808b21cf4fcab3961e6ad9e6fe"
},
"fillEvents": {
"rowCount": 25408,
"sha256": "92e0d103bbb487af45d27b720cdc92ef398d16e11655a6d0bcff8046013335db"
},
"holdingSnapshots": {
"rowCount": 33164,
"sha256": "97e3d60230a167a960bd2f615adfa33a9171527b62f1a181e2d5af694090e492"
},
"orderEvents": {
"rowCount": 25541,
"sha256": "635fe79eab9b1db0c116da2860ded66c350c051f44751d36306d158777039744"
},
"riskAudits": {
"rowCount": 113,
"sha256": "17bc940bcac18b050235ae3f17195f12586d1e29cd7bc64b6189edf2e07d1406"
}
},
"sha256": "b29b085d43bcc0f8f1712767421781c70570a24112933623d4bbbef46508d710",
"totalRows": 111014
},
"processEventStore": {
"bytes": 10115947,
"identitySha256": "e89d00ea1c2525c2ab1b57a29f2447047f2523083a953869ea2e25a488986565",
"manifestBytes": 9757,
"manifestSha256": "9bff5b97488c8804275ee65a717f01dca9ac68443b9766d4aab8d36189b1d8a1",
"schemaVersion": "fidc-backtest-process-event-manifest/v1",
"sections": {
"processEvents": {
"blockCount": 61,
"bytes": 10106190,
"rowCount": 83859,
"sha256": "f436a32403aaa74b3068a0d8c74bfb7c429523d3f78617234eec82a009ab3da2"
}
},
"sha256": "aa7488614a85f1527713332039b0fa212d1807f11b03e0e701480bf5d8ede1a4",
"totalEvents": 83859
},
"resultStore": {
"bytes": 35831197,
"identitySha256": "a238986d82354e802d2c73526d035021b38e1eed0a2b0b0f1eb6ce41ed4f321b",
"manifestBytes": 54237,
"manifestSha256": "0991f096db161fd16da00a7529ece54a8cf68643d88aeec8f4d3fedc6c740d03",
"schemaVersion": "fidc-backtest-fact-manifest/v1",
"sections": {
"accountEvents": {
"blockCount": 61,
"bytes": 3303444,
"rowCount": 25572,
"sha256": "276fd3edd7e60afbb05fa32c111db3e1df2025637887509b6353b24c7f127a48"
},
"equityFacts": {
"blockCount": 61,
"bytes": 3761920,
"rowCount": 1216,
"sha256": "42c22c70bad16b01d8f70fa80977028b2789381772e4eff7bb231701e18293a1"
},
"fillEvents": {
"blockCount": 61,
"bytes": 7731746,
"rowCount": 25408,
"sha256": "c59a4a1e70195bd8a2421941e11b7fbb138d658796ef4d42466c44505b8353d8"
},
"holdingSnapshots": {
"blockCount": 61,
"bytes": 15176388,
"rowCount": 33164,
"sha256": "6e40d3f1d43005dc4f0c492662c4fdf600ca96e9abe874db4b56b9bd51e89cd4"
},
"orderEvents": {
"blockCount": 61,
"bytes": 5775840,
"rowCount": 25541,
"sha256": "14fa27db9328c4c6337157af4fc1efc1c4bc9f239a4605bf92439b0f3f426200"
},
"riskAudits": {
"blockCount": 36,
"bytes": 27622,
"rowCount": 113,
"sha256": "2f6d9a01ec568ed13943b9590e21d73bf614ce2c777302764533d2da0168b2d5"
}
},
"sha256": "1eab5a5ffe59efeaba6b2048a33760aab31d42efa0fc10e35a4a7048db853235",
"totalEvents": 111014
},
"schemaVersion": "fidc-backtest-artifacts/v4"
}
}
],
"summary": {
"resultConsistent": true,
"resultStoreDigest": "1eab5a5ffe59efeaba6b2048a33760aab31d42efa0fc10e35a4a7048db853235",
"canonicalResultDigest": "b29b085d43bcc0f8f1712767421781c70570a24112933623d4bbbef46508d710",
"totalReturn": 0.9857987122635,
"tradeCount": 25408,
"terminalAudit": {
"cashReceivableCount": 0,
"cashReceivableTotalAmount": 0.0,
"earliestDeferredCashDate": null,
"futuresOpenOrderCount": 0,
"lastExecutionDate": "2026-08-28",
"omittedOpenOrderCount": 0,
"openOrderSamples": [],
"pendingCashFlowCount": 0,
"pendingCashFlowNetAmount": 0.0,
"status": "clean",
"stockOpenOrderCount": 0
},
"terminalAuditSha256": "0e8503c2bfe9ecfd93bab3a95f79461830886eb693cfe6f477279908c0d95bd5",
"implementation": {
"buildManifestSha256": "fb4f7ac191c8ab8d8d39fc48014f84ce01d4c0731ffe986e7d5cb0107a3e5539",
"builtAt": "2026-09-07T22:06:18+08:00",
"engineCommit": "1b78186c4e37273c64c1f9e208c2e47021ee4fff",
"identitySha256": "14e7ff10848d64ffcec232dea8cff23a032702301b81ae036bb0b26b4b0b5e33",
"runnerBinarySha256": "7cddebf9ab5cbbd5d6f9bd5c7bf006c3895ee26967d23d80dc86b47c50fb761d",
"schemaVersion": "fidc-backtest-implementation/v1",
"serviceBinarySha256": "f09d121d73c34ad3cd5e2e0c24b0bdaf1974309c9ec3b93229cfb824b1e9f11a",
"serviceCommit": "c80a38b91a398fd713f1f1faaa19a581ea85efad",
"status": "verified"
},
"implementationIdentitySha256": "14e7ff10848d64ffcec232dea8cff23a032702301b81ae036bb0b26b4b0b5e33",
"restartOrCold": {
"queueWaitSeconds": 0.0,
"executionSeconds": 21.61,
"totalSeconds": 21.61,
"runnerSeconds": 21.294,
"bundleValidationSeconds": 0.004,
"sourceEpochSeconds": 0.0,
"strategyContractSeconds": 0.003,
"terminalCalendarSeconds": 0.0,
"riskFreeRateSeconds": 0.017,
"riskFreeRateCacheHit": false,
"engineSeconds": 5.566,
"dataSeconds": 15.305,
"resultSeconds": 0.395,
"unattributedSeconds": 0.0,
"canonicalSeconds": 0.206,
"finalizationSeconds": 0.307
},
"processHotMedian": {
"queueWaitSeconds": 0.0,
"executionSeconds": 21.61,
"totalSeconds": 21.61,
"runnerSeconds": 21.294,
"bundleValidationSeconds": 0.004,
"sourceEpochSeconds": 0.0,
"strategyContractSeconds": 0.003,
"terminalCalendarSeconds": 0.0,
"riskFreeRateSeconds": 0.017,
"riskFreeRateCacheHit": false,
"engineSeconds": 5.566,
"dataSeconds": 15.305,
"resultSeconds": 0.395,
"unattributedSeconds": 0.0,
"canonicalSeconds": 0.206,
"finalizationSeconds": 0.307
}
}
}
@@ -0,0 +1,424 @@
{
"strategy_id": "benchmark-native-factor-overlay",
"strategy_version_id": "goal-five-year-semantics-v1",
"user_id": "boris",
"runtime": {
"start_date": "2021-08-23",
"end_date": "2026-08-28",
"frequency": "1d",
"source_table": "strategy_factory_source_lake.daily_source_rows_v1",
"signal_symbol": "000852.SH",
"benchmark_symbol": "000852.SH",
"initial_cash": 10000000.0,
"backtestDataBundleId": "bt_bundle_b44e03990c76064f54a9",
"backtestDataBundleHash": "d7c1461131edaecb5981e207852782d92e636dbfee9fd7c44063605d96eb2b4f"
},
"execution": {
"matchingType": "next_bar_open",
"rebalanceCashMode": "same_point_net",
"slippageModel": "price_ratio",
"slippageValue": 0.0001,
"commissionRate": 0.0001,
"minimumCommission": 5.0,
"stampTaxRateBeforeChange": 0.001,
"stampTaxRateAfterChange": 0.0005,
"stampTaxChangeDate": "2023-08-28",
"volumeLimit": true,
"liquidityLimit": false,
"volumePercent": 0.25,
"riskPolicy": {
"allowMarketOrders": true,
"blacklistEnabled": false,
"blacklistedSymbols": [],
"commissionRate": 0.0001,
"forbidSameDayRebuyAfterSell": true,
"liquidityLimitEnabled": false,
"liveTradingEnabled": false,
"minimumCommission": 5.0,
"rejectBjseBuy": false,
"rejectBjseSelection": false,
"rejectInactiveBuy": true,
"rejectInactiveSelection": false,
"rejectInactiveSell": true,
"rejectKcbBuy": true,
"rejectKcbSelection": false,
"rejectLowerLimitSelection": false,
"rejectLowerLimitSell": true,
"rejectNewListingBuy": true,
"rejectNewListingSelection": false,
"rejectOneYuanBuy": true,
"rejectOneYuanSelection": false,
"rejectPausedBuy": true,
"rejectPausedSelection": false,
"rejectPausedSell": true,
"rejectStBuy": true,
"rejectStSelection": false,
"rejectStarStBuy": true,
"rejectStarStSelection": false,
"rejectUpperLimitBuy": true,
"rejectUpperLimitSelection": false,
"respectAllowBuySell": true,
"stampTaxChangeDate": "2023-08-28",
"stampTaxRateAfterChange": 0.0005,
"stampTaxRateBeforeChange": 0.001,
"volumeLimitEnabled": true,
"volumePercent": 0.25
}
},
"strategy_source": {
"source_type": "platform-strategy",
"language": "engine-script",
"parser": "omniquant-engine-script-v2",
"source_code": "strategy(\"xiaoshizhi_1_06_dynamic_small_cap_csi2000_signal_day_exposure\") {\n market(\"CN_A\");\n benchmark(\"000300.SH\");\n signal(\"932000.CSI\");\n\n let stocknum = 30;\n let candidate_pool_size = 50;\n let position_denominator_extra = 1;\n let signal_close_t = rolling_mean_current(\"signal_close\", 1);\n let signal_ma10_t = rolling_mean_current(\"signal_close\", 10);\n let signal_ma30_t = rolling_mean_current(\"signal_close\", 30);\n let signal_vol20_t = rolling_return_stddev_current(\"signal_close\", 20);\n let signal_high60_t = rolling_max_current(\"signal_close\", 60);\n let signal_drawdown60_t = 1.0 - safe_div(signal_close_t, signal_high60_t);\n let signal_range_t = safe_div(clamp(signal_close_t, 2000.0, 3000.0) - 2000.0, 1000.0);\n let market_cap_lower_t = 12.0 + signal_range_t * 5.0;\n let market_cap_upper_t = 40.0 + signal_range_t * 5.0;\n let base_exposure_t = signal_ma10_t > signal_ma30_t ? 1.0 : 0.3;\n let volatility_exposure_t = signal_vol20_t >= 0.025 ? 0.3 : 1.0;\n let drawdown_exposure_t = signal_drawdown60_t >= 0.08 ? 0.2 : 1.0;\n let final_exposure_t =\n signal_close_t > 0.0 &&\n signal_ma10_t > 0.0 &&\n signal_ma30_t > 0.0 &&\n signal_high60_t > 0.0\n ? min(min(base_exposure_t, volatility_exposure_t), drawdown_exposure_t)\n : 0.0;\n\n rebalance.every_days(1).at([\"15:00\"]);\n\n selection.market_cap_band(\n field=\"market_cap\",\n lower=market_cap_lower_t,\n upper=market_cap_upper_t\n );\n\n filter.stock_expr(((!is_star_st && !is_kcb && !is_bjse && rolling_mean_current(\"close\", 5) > rolling_mean_current(\"close\", 10) && rolling_mean_current(\"close\", 10) > rolling_mean_current(\"close\", 30) && rolling_mean_current(\"volume\", 5) < rolling_mean_current(\"volume\", 100)) && (!is_st))) && (ths_up_days_stock >= 1);\n\n ordering.rank_by(\"market_cap\", \"asc\");\n selection.candidate_limit(50);\n selection.limit(stocknum);\n\n allocation.buy_scale(30.0 / 31.0);\n execution.strict_value_budget(true)\n\n trading.hold_until_exit(true);\n trading.max_holding_days(90);\n trading.daily_top_up(true);\n trading.daily_position_target_adjust(true);\n trading.target_portfolio_daily(true);\n trading.rebalance_existing_positions(true);\n trading.retry_empty_rebalance(true);\n trading.release_slot_on_exit_signal(true);\n\n risk.stop_loss(0.08);\n risk.take_profit(0.16);\n risk.reference_price_mode(\"signal_day_post_adjusted_close\");\n risk.index_exposure(final_exposure_t);\n\n risk.policy(reject_st_selection=false, reject_st_buy=true, reject_star_st_selection=false, reject_star_st_buy=true, reject_paused_selection=false, reject_paused_buy=true, reject_paused_sell=true, reject_inactive_selection=false, reject_inactive_buy=true, reject_inactive_sell=true, reject_new_listing_selection=false, reject_new_listing_buy=true, reject_kcb_selection=false, reject_kcb_buy=true, reject_bjse_selection=false, reject_bjse_buy=false, reject_one_yuan_selection=false, reject_one_yuan_buy=true, respect_allow_buy_sell=true, reject_upper_limit_selection=false, reject_lower_limit_selection=false, reject_upper_limit_buy=true, reject_lower_limit_sell=true, forbid_same_day_rebuy_after_sell=true, blacklist_enabled=false, blacklisted_symbols=[], allow_market_orders=true, live_trading_enabled=false, volume_limit_enabled=true, liquidity_limit_enabled=false, volume_percent=0.25, commission_rate=0.0001, minimum_commission=5.0, stamp_tax_rate_before_change=0.001, stamp_tax_rate_after_change=0.0005, stamp_tax_change_date=\"2023-08-28\");\n\n execution.matching_type(\"next_bar_open\");\n execution.slippage(\"price_ratio\", 0.0001);\n execution.rebalance_cash_mode(\"same_point_net\");\n}"
},
"strategy_spec": {
"benchmark": {
"fallbackInstrumentId": "000852.SH",
"instrumentId": "000852.SH"
},
"engineConfig": {
"benchmarkSymbol": "000852.SH",
"commissionRate": 0.0001,
"dividendReinvestment": false,
"dynamicRange": {
"baseCapFloor": 7,
"baseIndexLevel": 2000,
"capSpan": 10,
"xs": 0.008
},
"frequency": "1d",
"indexThrottle": {
"defensiveExposure": 0.5,
"fullExposure": 1,
"longDays": 130,
"rsiRate": 1.0001,
"shortDays": 1
},
"liquidityLimit": false,
"matchingType": "next_bar_open",
"minimumCommission": 5.0,
"rankLimit": 30,
"rebalanceCashMode": "same_point_net",
"rebalanceSchedule": {
"frequency": "daily",
"time": "15:00"
},
"refreshRate": 1,
"riskPolicy": {
"allowMarketOrders": true,
"blacklistEnabled": false,
"blacklistedSymbols": [],
"commissionRate": 0.0001,
"forbidSameDayRebuyAfterSell": true,
"liquidityLimitEnabled": false,
"liveTradingEnabled": false,
"minimumCommission": 5.0,
"rejectBjseBuy": false,
"rejectBjseSelection": false,
"rejectInactiveBuy": true,
"rejectInactiveSelection": false,
"rejectInactiveSell": true,
"rejectKcbBuy": true,
"rejectKcbSelection": false,
"rejectLowerLimitSelection": false,
"rejectLowerLimitSell": true,
"rejectNewListingBuy": true,
"rejectNewListingSelection": false,
"rejectOneYuanBuy": true,
"rejectOneYuanSelection": false,
"rejectPausedBuy": true,
"rejectPausedSelection": false,
"rejectPausedSell": true,
"rejectStBuy": true,
"rejectStSelection": false,
"rejectStarStBuy": true,
"rejectStarStSelection": false,
"rejectUpperLimitBuy": true,
"rejectUpperLimitSelection": false,
"respectAllowBuySell": true,
"stampTaxChangeDate": "2023-08-28",
"stampTaxRateAfterChange": 0.0005,
"stampTaxRateBeforeChange": 0.001,
"volumeLimitEnabled": true,
"volumePercent": 0.25
},
"rsiRate": 1.0001,
"signalSymbol": "000852.SH",
"skipWindows": [],
"slippageModel": "price_ratio",
"slippageValue": 0.0001,
"stampTaxChangeDate": "2023-08-28",
"stampTaxRateAfterChange": 0.0005,
"stampTaxRateBeforeChange": 0.001,
"stockMaFilter": {
"longDays": 30,
"midDays": 10,
"rsiRate": 1.0001,
"shortDays": 5,
"volumeLongDays": 100,
"volumeShortDays": 5
},
"stopLossMultiplier": 0.08,
"strictValueBudget": true,
"takeProfitMultiplier": 0.16,
"templateId": "xiaoshizhi_1_06_dynamic_small_cap_csi2000_signal_day_exposure",
"volumeLimit": true,
"volumePercent": 0.25
},
"execution": {
"commissionRate": 0.0001,
"executionGranularity": "daily_or_minute_bar",
"extractor": "omniquant-engine-script-v2",
"frequency": "1d",
"liquidityLimit": false,
"matchingType": "next_bar_open",
"minimumCommission": 5.0,
"priceSource": "current_bar_close_or_next_bar_open_or_minute_bar",
"rebalanceCashMode": "same_point_net",
"riskPolicy": {
"allowMarketOrders": true,
"blacklistEnabled": false,
"blacklistedSymbols": [],
"commissionRate": 0.0001,
"forbidSameDayRebuyAfterSell": true,
"liquidityLimitEnabled": false,
"liveTradingEnabled": false,
"minimumCommission": 5.0,
"rejectBjseBuy": false,
"rejectBjseSelection": false,
"rejectInactiveBuy": true,
"rejectInactiveSelection": false,
"rejectInactiveSell": true,
"rejectKcbBuy": true,
"rejectKcbSelection": false,
"rejectLowerLimitSelection": false,
"rejectLowerLimitSell": true,
"rejectNewListingBuy": true,
"rejectNewListingSelection": false,
"rejectOneYuanBuy": true,
"rejectOneYuanSelection": false,
"rejectPausedBuy": true,
"rejectPausedSelection": false,
"rejectPausedSell": true,
"rejectStBuy": true,
"rejectStSelection": false,
"rejectStarStBuy": true,
"rejectStarStSelection": false,
"rejectUpperLimitBuy": true,
"rejectUpperLimitSelection": false,
"respectAllowBuySell": true,
"stampTaxChangeDate": "2023-08-28",
"stampTaxRateAfterChange": 0.0005,
"stampTaxRateBeforeChange": 0.001,
"volumeLimitEnabled": true,
"volumePercent": 0.25
},
"selectionGranularity": "strategy_factory_source_lake.daily_source_rows_v1",
"slippageModel": "price_ratio",
"slippageValue": 0.0001,
"sourceKind": "platform-strategy",
"sourceLanguage": "engine-script",
"stampTaxChangeDate": "2023-08-28",
"stampTaxRateAfterChange": 0.0005,
"stampTaxRateBeforeChange": 0.001,
"strictValueBudget": true,
"volumeLimit": true,
"volumePercent": 0.25
},
"factorRefs": [
"market_cap",
"ths_up_days_stock"
],
"market": "CN_A",
"metadata": {
"backtestDataBundle": {
"sourceTable": "strategy_factory_source_lake.daily_source_rows_v1",
"backtestDataBundleId": "bt_bundle_b44e03990c76064f54a9",
"backtestDataBundleHash": "d7c1461131edaecb5981e207852782d92e636dbfee9fd7c44063605d96eb2b4f"
},
"backtestDataBundleHash": "d7c1461131edaecb5981e207852782d92e636dbfee9fd7c44063605d96eb2b4f",
"backtestDataBundleId": "bt_bundle_b44e03990c76064f54a9",
"sourceTable": "strategy_factory_source_lake.daily_source_rows_v1"
},
"rebalance": {
"dailyApproximation": "日线回测按 matching_type 撮合;分钟线回测按交易时刻分钟价格撮合",
"frequencyDays": 1,
"schedule": {
"frequency": "daily",
"time": "15:00"
},
"tradeTimes": [
"15:00"
]
},
"risk": {
"indexThrottleExpr": "final_exposure_t",
"stopLossExpr": "0.08",
"stopTakeReferencePriceMode": "signal_day_post_adjusted_close",
"takeProfitExpr": "0.16"
},
"runtimeExpressions": {
"allocation": {
"buyScaleExpr": "30.0 / 31.0"
},
"ordering": {
"rankBy": "market_cap",
"rankExpr": "",
"rankOrder": "asc"
},
"prelude": "let stocknum = 30;\nlet candidate_pool_size = 50;\nlet position_denominator_extra = 1;\nlet signal_close_t = rolling_mean_current(\"signal_close\", 1);\nlet signal_ma10_t = rolling_mean_current(\"signal_close\", 10);\nlet signal_ma30_t = rolling_mean_current(\"signal_close\", 30);\nlet signal_vol20_t = rolling_return_stddev_current(\"signal_close\", 20);\nlet signal_high60_t = rolling_max_current(\"signal_close\", 60);\nlet signal_drawdown60_t = 1.0 - safe_div(signal_close_t, signal_high60_t);\nlet signal_range_t = safe_div(clamp(signal_close_t, 2000.0, 3000.0) - 2000.0, 1000.0);\nlet market_cap_lower_t = 12.0 + signal_range_t * 5.0;\nlet market_cap_upper_t = 40.0 + signal_range_t * 5.0;\nlet base_exposure_t = signal_ma10_t > signal_ma30_t ? 1.0 : 0.3;\nlet volatility_exposure_t = signal_vol20_t >= 0.025 ? 0.3 : 1.0;\nlet drawdown_exposure_t = signal_drawdown60_t >= 0.08 ? 0.2 : 1.0;\nlet final_exposure_t = signal_close_t > 0.0 && signal_ma10_t > 0.0 && signal_ma30_t > 0.0 && signal_high60_t > 0.0 ? min(min(base_exposure_t, volatility_exposure_t), drawdown_exposure_t) : 0.0;\nlet warmup_probe = rolling_sum(\"amount\", 125);",
"risk": {
"exposureExpr": "final_exposure_t",
"stopLossExpr": "0.08",
"stopTakeReferencePriceMode": "signal_day_post_adjusted_close",
"takeProfitExpr": "0.16"
},
"schedule": {
"frequency": "daily",
"time": "15:00"
},
"selection": {
"candidateLimitExpr": "50",
"limitExpr": "stocknum",
"marketCapField": "market_cap",
"marketCapLowerExpr": "market_cap_lower_t",
"marketCapUpperExpr": "market_cap_upper_t",
"stockFilterExpr": "(((!is_star_st && !is_kcb && !is_bjse && rolling_mean_current(\"close\", 5) > rolling_mean_current(\"close\", 10) && rolling_mean_current(\"close\", 10) > rolling_mean_current(\"close\", 30) && rolling_mean_current(\"volume\", 5) < rolling_mean_current(\"volume\", 100)) && (!is_st))) && (ths_up_days_stock >= 1)"
},
"trading": {
"actions": [],
"dailyPositionTargetAdjust": true,
"dailyTopUp": true,
"holdUntilExit": true,
"maxHoldingDays": 90,
"rebalanceExistingPositions": true,
"releaseSlotOnExitSignal": true,
"retryEmptyRebalance": true,
"rotationEnabled": true,
"stage": "on_day",
"subscriptionGuardRequired": false,
"targetPortfolioDaily": true
}
},
"seasonality": {
"skipWindows": []
},
"selectors": [
{
"field": "market_cap",
"lowerExpr": "market_cap_lower_t",
"mapping": "market_cap -> strategy_factory_source_lake.runtime_fields.market_cap",
"type": "dynamicRange",
"upperExpr": "market_cap_upper_t"
},
{
"expr": "(((!is_star_st && !is_kcb && !is_bjse && rolling_mean_current(\"close\", 5) > rolling_mean_current(\"close\", 10) && rolling_mean_current(\"close\", 10) > rolling_mean_current(\"close\", 30) && rolling_mean_current(\"volume\", 5) < rolling_mean_current(\"volume\", 100)) && (!is_st))) && (ths_up_days_stock >= 1)",
"type": "filter"
},
{
"limitExpr": "stocknum",
"orderBy": [
"market_cap asc"
],
"type": "rank"
}
],
"signalSymbol": "000852.SH",
"sourceCode": "strategy(\"xiaoshizhi_1_06_dynamic_small_cap_csi2000_signal_day_exposure\") {\n market(\"CN_A\");\n benchmark(\"000300.SH\");\n signal(\"932000.CSI\");\n\n let stocknum = 30;\n let candidate_pool_size = 50;\n let position_denominator_extra = 1;\n let signal_close_t = rolling_mean_current(\"signal_close\", 1);\n let signal_ma10_t = rolling_mean_current(\"signal_close\", 10);\n let signal_ma30_t = rolling_mean_current(\"signal_close\", 30);\n let signal_vol20_t = rolling_return_stddev_current(\"signal_close\", 20);\n let signal_high60_t = rolling_max_current(\"signal_close\", 60);\n let signal_drawdown60_t = 1.0 - safe_div(signal_close_t, signal_high60_t);\n let signal_range_t = safe_div(clamp(signal_close_t, 2000.0, 3000.0) - 2000.0, 1000.0);\n let market_cap_lower_t = 12.0 + signal_range_t * 5.0;\n let market_cap_upper_t = 40.0 + signal_range_t * 5.0;\n let base_exposure_t = signal_ma10_t > signal_ma30_t ? 1.0 : 0.3;\n let volatility_exposure_t = signal_vol20_t >= 0.025 ? 0.3 : 1.0;\n let drawdown_exposure_t = signal_drawdown60_t >= 0.08 ? 0.2 : 1.0;\n let final_exposure_t =\n signal_close_t > 0.0 &&\n signal_ma10_t > 0.0 &&\n signal_ma30_t > 0.0 &&\n signal_high60_t > 0.0\n ? min(min(base_exposure_t, volatility_exposure_t), drawdown_exposure_t)\n : 0.0;\n\n rebalance.every_days(1).at([\"15:00\"]);\n\n selection.market_cap_band(\n field=\"market_cap\",\n lower=market_cap_lower_t,\n upper=market_cap_upper_t\n );\n\n filter.stock_expr(((!is_star_st && !is_kcb && !is_bjse && rolling_mean_current(\"close\", 5) > rolling_mean_current(\"close\", 10) && rolling_mean_current(\"close\", 10) > rolling_mean_current(\"close\", 30) && rolling_mean_current(\"volume\", 5) < rolling_mean_current(\"volume\", 100)) && (!is_st))) && (ths_up_days_stock >= 1);\n\n ordering.rank_by(\"market_cap\", \"asc\");\n selection.candidate_limit(50);\n selection.limit(stocknum);\n\n allocation.buy_scale(30.0 / 31.0);\n execution.strict_value_budget(true)\n\n trading.hold_until_exit(true);\n trading.max_holding_days(90);\n trading.daily_top_up(true);\n trading.daily_position_target_adjust(true);\n trading.target_portfolio_daily(true);\n trading.rebalance_existing_positions(true);\n trading.retry_empty_rebalance(true);\n trading.release_slot_on_exit_signal(true);\n\n risk.stop_loss(0.08);\n risk.take_profit(0.16);\n risk.reference_price_mode(\"signal_day_post_adjusted_close\");\n risk.index_exposure(final_exposure_t);\n\n risk.policy(reject_st_selection=false, reject_st_buy=true, reject_star_st_selection=false, reject_star_st_buy=true, reject_paused_selection=false, reject_paused_buy=true, reject_paused_sell=true, reject_inactive_selection=false, reject_inactive_buy=true, reject_inactive_sell=true, reject_new_listing_selection=false, reject_new_listing_buy=true, reject_kcb_selection=false, reject_kcb_buy=true, reject_bjse_selection=false, reject_bjse_buy=false, reject_one_yuan_selection=false, reject_one_yuan_buy=true, respect_allow_buy_sell=true, reject_upper_limit_selection=false, reject_lower_limit_selection=false, reject_upper_limit_buy=true, reject_lower_limit_sell=true, forbid_same_day_rebuy_after_sell=true, blacklist_enabled=false, blacklisted_symbols=[], allow_market_orders=true, live_trading_enabled=false, volume_limit_enabled=true, liquidity_limit_enabled=false, volume_percent=0.25, commission_rate=0.0001, minimum_commission=5.0, stamp_tax_rate_before_change=0.001, stamp_tax_rate_after_change=0.0005, stamp_tax_change_date=\"2023-08-28\");\n\n execution.matching_type(\"next_bar_open\");\n execution.slippage(\"price_ratio\", 0.0001);\n execution.rebalance_cash_mode(\"same_point_net\");\n}",
"strategyId": "warmup-expression-contract-acceptance",
"universe": {
"exclude": [],
"implementationNotes": [
"ST、停牌、退市、新股、科创、一元、涨跌停、同日卖出禁买、成交量和费用由 riskPolicy / RiskLimits 统一执行",
"上市日期与退市日期取自 instrument 结构化字段,不再使用股票名称做 ST/退市判断",
"盘中 current_price / last_price 由策略交易时刻批量 tick 查询驱动"
]
},
"version": "1.0.0",
"stockPoolFactorContract": {
"schemaVersion": 1,
"entryLogic": "all",
"exitLogic": "any",
"conditions": [
{
"factorRef": "up_days_stock",
"label": "连涨天数",
"role": "selection",
"registryRole": "selection_feature",
"roleRegistrySha256": "1d0b307c168feda08d5fbe20f0e88964230553f8ceb2017b66aec48dbd5a5b57",
"roleEvidence": {
"role": "selection_feature",
"polarity": "trend_persistence_positive",
"signalShape": "state",
"holdingStates": [
"flat"
],
"requiredConfirmations": [],
"cooldownTradingDays": 0,
"windowTradingDays": 1,
"recommendedParameters": {
"inputUnit": "days",
"minimum": 0
}
},
"operator": ">=",
"threshold": 1,
"semantic": {
"ref": "up_days_stock",
"label": "连涨天数",
"status": "available",
"queryable": true,
"source": "strategy-factory-source-lake:indicator",
"schema": "strategy-factory.value-semantics/v1",
"valueType": "integer",
"semanticType": "count",
"comparisonGroup": "count",
"storageUnit": "days",
"inputUnit": "days",
"inputScale": 1.0,
"allowedOperators": [
">",
">=",
"<",
"<=",
"==",
"!=",
"between",
"in"
],
"nullable": true,
"declared": true,
"metadataStatus": "declared",
"semanticProvenance": "explicit_manifest",
"businessSemanticDeclared": true,
"minimum": 0,
"backtestBinding": {
"field": "ths_up_days_stock",
"sourceDataset": "indicators_up_days_stock"
},
"tradingRoles": [
{
"role": "selection_feature",
"polarity": "trend_persistence_positive",
"signalShape": "state",
"holdingStates": [
"flat"
],
"requiredConfirmations": [],
"cooldownTradingDays": 0,
"windowTradingDays": 1,
"recommendedParameters": {
"inputUnit": "days",
"minimum": 0
}
}
],
"tradingRoleTradable": true,
"tradingRoleEvidenceStatus": "source_lake_registered_indicator",
"tradingRoleRegistrySha256": "1d0b307c168feda08d5fbe20f0e88964230553f8ceb2017b66aec48dbd5a5b57"
}
}
]
}
}
}
@@ -0,0 +1,58 @@
{
"date": "2026-09-07",
"host": "192.168.31.177",
"identity": "boris",
"implementationCommit": "a02ac6e",
"valueRegressionCommit": "cb97aa1",
"scope": "Native daily indicator fields explicitly bound in stockPoolFactorContract; other factor fields and pricing are unchanged.",
"targetedTests": {"passed": 3, "failed": 0},
"fullLibraryTestsBeforeAdditionalValueCase": {"passed": 447, "ignored": 6, "failed": 0},
"provenCases": [
"09:30, 10:30 and 14:30 resolve to the preceding trading date",
"15:00 resolves to the completed decision day",
"active intraday datetime applies when no explicit execution time exists",
"next-open retains the completed decision day",
"no previous trading date does not fall back to the current day",
"stock state with prior value 2 and current value 999 reads 2 intraday and 999 at close",
"unbound factor value remains unchanged"
],
"backtestServiceDeployed": true,
"paperLiveRuntimeDeployed": true,
"paperLiveDeploymentEvidence": "/Users/boris/WorkSpace/fidc-trading-platform/docs/evidence/trading-engine-revision-deployment-20260907.json",
"realBacktestAcceptanceComplete": false,
"scopedBacktestEvidence": {
"intraday": {
"range": "2025-09-08..2025-09-12",
"time": "09:30",
"runIds": ["btr_1788790021780_1150210_0", "btr_1788790036494_1150210_1"],
"seconds": [8.994, 0.596],
"tradeCount": 104,
"riskDecisionCount": 11,
"canonical": "5c8a110cc6f285b9d569e818a0472a8b5c76f14df853a1c2c42d1b5222c39b3a",
"identical": true,
"persistedFactorBindingVerified": true,
"rawParquetAudit": {
"buyFills": 60,
"priorPassCurrentFailExamples": 21,
"existingPositionTopUpsBelowCurrentSelectionThreshold": 23,
"retainedTargetReentryBelowCurrentSelectionThreshold": {"symbol": "600276.SH", "date": "2025-09-12", "priorExit": "2025-09-11 stop_loss_exit", "configuration": "reenterExitedTargets=true", "reason": "model_target_portfolio_daily"},
"note": "Selection-only conditions are not an execution-time buy veto. Position adjustment and explicit retained-target reentry must be audited separately from fresh candidate selection."
}
},
"nextOpen": {
"range": "2021-08-23..2026-08-28",
"runId": "btr_1788790344805_1150210_2",
"seconds": 21.610,
"tradeCount": 25408,
"canonical": "b29b085d43bcc0f8f1712767421781c70570a24112933623d4bbbef46508d710",
"matchesPreFixBaseline": true
},
"terminalAudits": "clean",
"rawEvidenceDirectory": "native-daily-factor-replays-20260907"
},
"limitations": [
"This is not a generic per-field publication-timestamp model for all factor datasets.",
"Raw dynamic fields used without a stock-pool native binding need separate availability-contract review.",
"Broader factor/PIT and actual trading acceptance remain required; these replays use isolated API research fixtures. Browser draft handoff is separately recorded in OmniQuant documentation."
]
}
@@ -0,0 +1,89 @@
{
"schemaVersion": "fidc-engine-optimization-ab/v1",
"generatedDate": "2026-09-07",
"host": "192.168.31.177",
"change": {
"engineCommit": "f45b3a71fa78ecb95b0fa80687130ae7385549e8",
"description": "Do not clone the shared multi-date execution quote map when a run reaches the end of one execution date.",
"dataContract": "The prepared-data cache remains immutable and shared. A uniquely owned run-local quote map still removes completed dates.",
"cachedBusinessResults": false
},
"deployment": {
"serviceCommit": "11cfe1b8d09c1da4807400f3d903199ea6f1e711",
"implementationIdentitySha256": "7dbc839acb26fed98fc84a0bc221c316bf370046780c6ae75a79171fd52e5535",
"serviceBinarySha256": "bc9385ef340b8e0b3d0d17a9251e159806107443865361271ee0ccd8dbe1a768",
"runnerBinarySha256": "55c321f39518b5821e35ee1ec5320e35f94590be3aeeeac0827df07670e623fc",
"serviceUser": "boris",
"sourceLakeRestarted": false,
"health": "ok"
},
"currentCloseOneYear": {
"before": {
"processHotMedianTotalSeconds": 1.548,
"processHotMedianEngineSeconds": 1.09
},
"after": {
"runs": 5,
"processHotMedianTotalSeconds": 1.238,
"processHotMedianEngineSeconds": 0.773,
"totalReturn": 0.3201517861398,
"tradeCount": 5351,
"canonicalResultDigest": "7204c6f41b8e39fbf1af7fc55cd601b80f3427a7aa058394ccd8b0b14ca48eed",
"resultStoreDigest": "0b4d24ed5ec2b27cc4135707b4c51c78eb2c3e35a20da8108610778c30c72c73",
"terminalAuditStatus": "clean"
},
"improvementPercent": {
"total": 20.02584,
"engine": 29.082569
}
},
"crossModeRegression": {
"staticCurrentClose": {
"processHotTotalSeconds": 0.866,
"processHotEngineSeconds": 0.326,
"totalReturn": 0.13228843240310018,
"tradeCount": 4445,
"canonicalResultDigest": "fdfa855295c0b55bdbe6f39952ead1515e844bf033ced974d3c3ddc037a5d0b1",
"resultStoreDigest": "697566645116c76ff837cd36f7f9bbd7ad3eb30510a5b95012fb730d5072d511"
},
"nextBarOpenFiveYear": {
"processHotTotalSeconds": 3.212,
"processHotEngineSeconds": 2.597,
"totalReturn": 0.9922618879291,
"tradeCount": 25827,
"canonicalResultDigest": "ac1d167cb1e1073e1d1ecb01e914f94d7560081c1d238e6b4418d86233250719",
"resultStoreDigest": "79570e0ae6b07badc1b693dc897dd1381647d259a4fe44ed3e50bc215e2fd088"
},
"fullMinute": {
"processHotTotalSeconds": 0.973,
"processHotEngineSeconds": 0.231,
"totalReturn": 0.03473222656500008,
"tradeCount": 156,
"canonicalResultDigest": "7dae3a618932b90d36e9968c027f08a94d69b8fc6a56b1ea0cc1a2cb771d85b8",
"resultStoreDigest": "bbbd7080b8fd7f6e8c3a8132499842bd0e4d644dbfdc3e8dbca7d2c0c1381e93"
}
},
"profile": {
"beforePath": "/srv/fidc/canonical/run/fidc-private/evidence/current-close-post-preplan-profile-20260907",
"afterPath": "/srv/fidc/canonical/run/fidc-private/evidence/current-close-shared-quote-profile-20260907",
"beforeSamples": 564,
"afterSamples": 408,
"lostSamples": 0,
"beforeFinding": "Arc::make_mut was reached from remove_execution_quotes_on_date and cloned the shared quote map.",
"afterFinding": "The remove_execution_quotes_on_date call chain is absent; release_execution_quotes_on_date accounts for 0.27 percent of sampled cycles."
},
"verification": {
"workspaceCoreTestsPassed": 440,
"workspaceCoreTestsIgnored": 6,
"workspaceFailures": 0,
"allCanonicalDigestsMatched": true,
"allResultStoreDigestsMatched": true,
"allTerminalAuditsClean": true
},
"supportingEvidence": [
"/Users/boris/WorkSpace/fidc-backtest-service/docs/evidence/current-close-shared-quote-release-20260907.json",
"/Users/boris/WorkSpace/fidc-backtest-service/docs/evidence/static-pool-shared-quote-release-20260907.json",
"/Users/boris/WorkSpace/fidc-backtest-service/docs/evidence/next-open-shared-quote-release-20260907.json",
"/Users/boris/WorkSpace/fidc-backtest-service/docs/evidence/full-minute-shared-quote-release-20260907.json"
]
}
@@ -0,0 +1,76 @@
{
"schemaVersion": "fidc-quote-plan-optimization-ab/v1",
"generatedDate": "2026-09-07",
"host": "192.168.31.177",
"implementation": {
"engineCommit": "c934a948c6b3adc0d38b99cf5826481a9a7c3ceb",
"serviceCommit": "178cd2a76961b7e687424cc47107c2c1c3f4f74e",
"implementationIdentitySha256": "192392bd7c6bbb7d9a8601aca51a55de4a518357d2c3014b4a09e644e7caccc6",
"engineChange": "Stream ascending market-cap quote candidates from the shared ordered symbol index without a candidate-state arena or redundant sort.",
"runnerChange": "Build the preliminary selection DataSet from daily bundles instead of flattening and regrouping component vectors."
},
"quotePlanSeconds": {
"baselineSamples": [
7.143,
6.848,
7.404
],
"baselineMedian": 7.143,
"candidateSamples": [
7.092,
6.115,
5.931,
6.179,
6.129,
6.096
],
"candidateMedian": 6.122,
"improvementPercent": 14.293714
},
"dynamicCurrentClose": {
"totalReturn": 0.3201517861398,
"tradeCount": 5351,
"riskDecisionCount": 2661,
"canonicalResultDigest": "7204c6f41b8e39fbf1af7fc55cd601b80f3427a7aa058394ccd8b0b14ca48eed",
"resultStoreDigest": "0b4d24ed5ec2b27cc4135707b4c51c78eb2c3e35a20da8108610778c30c72c73",
"terminalAuditStatus": "clean"
},
"crossModeRegression": {
"staticCurrentClose": {
"totalReturn": 0.13228843240310018,
"tradeCount": 4445,
"canonicalResultDigest": "fdfa855295c0b55bdbe6f39952ead1515e844bf033ced974d3c3ddc037a5d0b1",
"resultStoreDigest": "697566645116c76ff837cd36f7f9bbd7ad3eb30510a5b95012fb730d5072d511"
},
"fullMinute": {
"totalReturn": 0.03473222656500008,
"tradeCount": 156,
"canonicalResultDigest": "7dae3a618932b90d36e9968c027f08a94d69b8fc6a56b1ea0cc1a2cb771d85b8",
"resultStoreDigest": "bbbd7080b8fd7f6e8c3a8132499842bd0e4d644dbfdc3e8dbca7d2c0c1381e93"
},
"nextBarOpenFiveYear": {
"totalReturn": 0.9922618879291,
"tradeCount": 25827,
"canonicalResultDigest": "ac1d167cb1e1073e1d1ecb01e914f94d7560081c1d238e6b4418d86233250719",
"resultStoreDigest": "79570e0ae6b07badc1b693dc897dd1381647d259a4fe44ed3e50bc215e2fd088"
}
},
"testGate": {
"engineCorePassed": 441,
"engineIgnoredBenchmarks": 6,
"runnerPassed": 347,
"runnerIgnoredBenchmarks": 3,
"apiPassed": 80,
"failures": 0,
"fastPathGenericPathParityTest": true
},
"remainingBottleneck": {
"profilePath": "/srv/fidc/canonical/run/fidc-private/evidence/daily-bundle-quote-plan-cold-profile-20260907",
"dataSeconds": 11.215,
"quotePlanSeconds": 6.096,
"datasetConstructSeconds": 1.677,
"loopSeconds": 2.28,
"finding": "SymbolPriceSeries, adjusted-close series and DataSet indexes are still built once for quote planning and again for the final run DataSet.",
"next": "Replace SourceRowRecord and duplicate preliminary/final DataSet construction with one epoch-scoped typed Base Panel and lightweight run views."
}
}
@@ -0,0 +1,150 @@
{
"schemaVersion": "fidc-typed-adjustment-factor-snapshot/v1",
"generatedAt": "2026-09-07T18:12:00+08:00",
"scope": "FIDC engine, backtest runner and strategy runtime",
"changes": {
"engineCommit": "04b45adf98772f4ce3cd8b7a2c08d76480655fee",
"backtestServiceCommit": "76748bbc3d2cfc4d76ff8e35c5fcda1fcddd00ec",
"tradingPlatformCommit": "bec62d0f7f6126a36ec0a101f7b1a66b138537eb",
"datasetSchemaVersion": 58,
"sourceRowCacheSchemaVersion": 28,
"contract": "adjustment_factor_backward1 remains a nullable typed field from Arrow decode through DailyFactorSnapshot and AdjustedCloseSeries; dynamic NumericFactorMap values stay sparse",
"legacyInputPolicy": "extra_factors containing adjustment_factor_backward1 and non-positive or non-finite typed adjustment values are rejected"
},
"tests": {
"fidcCore": "445 passed, 6 ignored",
"backtestRunner": "351 passed, 3 ignored",
"backtestApi": "80 passed",
"strategyRuntime": "66 passed",
"runtimeRollingOrderRegression": "full 5/10/30/100-day framework rolling history produced a valid paper order"
},
"productionImplementation": {
"engineCommit": "04b45adf98772f4ce3cd8b7a2c08d76480655fee",
"serviceCommit": "76748bbc3d2cfc4d76ff8e35c5fcda1fcddd00ec",
"identitySha256": "2c0ab93663018f4fae8d61ba0c522c0f796837d952bbfb26a6bc229cc52bd722",
"runnerBinarySha256": "8591dfa763a52e06467dfe2ff2c8bef4d382ef875b356009fe5a7e8ea2013ea3",
"serviceBinarySha256": "4e467981f569e265c4ccc862c04be85eb99e9f4d107878785c5961a8b382d65f",
"status": "verified"
},
"fiveYearFreshProcess": {
"baseline": {
"runId": "btr_1788772852314_617886_0",
"dataSeconds": 14.121,
"sourceQuerySeconds": 4.304,
"datasetConstructSeconds": 3.672,
"riskFreeRateSeconds": 2.77,
"totalSeconds": 20.947,
"queryCompletedRssKb": 6654344,
"datasetConstructCompletedRssKb": 12775136
},
"candidate": {
"runId": "btr_1788775107699_632665_0",
"dataSeconds": 13.198,
"sourceQuerySeconds": 4.169,
"datasetConstructSeconds": 3.499,
"riskFreeRateSeconds": 0.01,
"totalSeconds": 17.381,
"queryCompletedRssKb": 6738520,
"datasetConstructCompletedRssKb": 12694000
},
"improvementPercent": {
"dataSeconds": 6.5364,
"sourceQuerySeconds": 3.1366,
"datasetConstructSeconds": 4.7113,
"datasetConstructCompletedRssKb": 0.6351,
"totalSecondsAfterRiskFreeNormalization": 4.4317
},
"comparisonNote": "total wall time is normalized only for the independently measured risk-free lookup difference; data and RSS values are compared directly"
},
"fiveYearMemoryCold": {
"baselineRunIds": [
"btr_1788772635840_613911_1",
"btr_1788772672944_613911_2",
"btr_1788772707181_613911_3"
],
"candidateRunIds": [
"btr_1788775166889_632665_1",
"btr_1788775202156_632665_2",
"btr_1788775353507_632665_5"
],
"baselineMedian": {
"totalSeconds": 16.809,
"dataSeconds": 12.8,
"sourceQuerySeconds": 4.411,
"dailyLoopSeconds": 1.077,
"datasetConstructSeconds": 3.701,
"engineSeconds": 3.278
},
"candidateMedian": {
"totalSeconds": 16.653,
"dataSeconds": 12.569,
"sourceQuerySeconds": 4.054,
"dailyLoopSeconds": 1.047,
"datasetConstructSeconds": 3.577,
"engineSeconds": 3.372
},
"improvementPercent": {
"totalSeconds": 0.9281,
"dataSeconds": 1.8047,
"sourceQuerySeconds": 8.0934,
"dailyLoopSeconds": 2.7855,
"datasetConstructSeconds": 3.3504,
"engineSeconds": -2.8676
},
"excludedQueueOutlier": {
"runId": "btr_1788775236942_632665_4",
"totalSeconds": 69.401,
"queueWaitSeconds": 48.625,
"blockingRunId": "btr_1788775223727_632665_3",
"reason": "another user backtest naturally occupied the single runner; it was not stopped or modified"
}
},
"controls": {
"currentClose": {
"runIds": [
"btr_1788775434926_632665_6",
"btr_1788775464828_632665_7",
"btr_1788775491657_632665_8"
],
"medianTotalSeconds": 10.044,
"totalReturn": 0.3201517861398,
"tradeCount": 5351,
"canonicalSha256": "7204c6f41b8e39fbf1af7fc55cd601b80f3427a7aa058394ccd8b0b14ca48eed",
"resultStoreSha256": "0b4d24ed5ec2b27cc4135707b4c51c78eb2c3e35a20da8108610778c30c72c73"
},
"staticDaily": {
"runId": "btr_1788775531308_632665_9",
"totalReturn": 0.13228843240310018,
"tradeCount": 4445,
"canonicalSha256": "fdfa855295c0b55bdbe6f39952ead1515e844bf033ced974d3c3ddc037a5d0b1",
"resultStoreSha256": "697566645116c76ff837cd36f7f9bbd7ad3eb30510a5b95012fb730d5072d511"
},
"fullMinute": {
"runId": "btr_1788775545217_632665_10",
"totalReturn": 0.03473222656500008,
"tradeCount": 156,
"canonicalSha256": "7dae3a618932b90d36e9968c027f08a94d69b8fc6a56b1ea0cc1a2cb771d85b8",
"resultStoreSha256": "bbbd7080b8fd7f6e8c3a8132499842bd0e4d644dbfdc3e8dbca7d2c0c1381e93"
},
"nextOpenFiveYear": {
"totalReturn": 0.9922618879291,
"tradeCount": 25827,
"canonicalSha256": "ac1d167cb1e1073e1d1ecb01e914f94d7560081c1d238e6b4418d86233250719",
"resultStoreSha256": "79570e0ae6b07badc1b693dc897dd1381647d259a4fe44ed3e50bc215e2fd088"
},
"terminalAuditsClean": true
},
"tradingRuntimeDeployment": {
"sourceCommit": "bec62d0f7f6126a36ec0a101f7b1a66b138537eb",
"strategyRuntimeBinarySha256": "b28aa9a20bff232d5d24ba6c8072c02ff921dea69a5ca3cb81712323e2d88d24",
"healthPorts": [9100, 9101, 9102, 9103, 9104, 9130],
"allHealthChecksPassed": true,
"orderRoutingConfigurationChanged": false,
"observedOrderRoutingMode": "disabled"
},
"decision": {
"status": "accepted",
"reason": "all business hashes and runtime rolling semantics remain exact while five-year DataSet construction, data time and steady memory-cold wall time improve",
"nextTarget": "construct the immutable Base Panel directly from Arrow column buffers so the remaining SourceRowRecord and DailyFactorSnapshot row materialization can be removed"
}
}
@@ -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 与对象分配优化仍待完成。
+36
View File
@@ -0,0 +1,36 @@
# Factor Decision Phases
Status: broker foundation implemented; factor compiler, evaluator and runtime-plan integration are not complete. Do not advertise this as a fully working stock-pool buy-condition feature.
## Separate Contracts
| Phase | Meaning | Must Not Do |
|---|---|---|
| Selection | Build and rank the candidate universe at the strategy decision clock | Pretend this also guards every later top-up |
| Buy permission | Decide whether this decision may create new buy exposure for a symbol | Convert a denied buy into a sell or silently drop a holding from a full target snapshot |
| Exit/reduction | Produce the explicitly configured exit or partial target | Normalize remaining targets upward without an explicit strategy rule |
| Execution risk | Apply actual execution-date price, ST, suspension, lifecycle, liquidity and cost constraints | Substitute decision-date risk facts for next-open execution facts |
| Existing orders | Continue the already submitted order under its execution risk and lifetime contract | Implicitly cancel or rewrite it merely because a later decision has a new buy denial |
## Broker Primitive
`StrategyDecision.buy_denials` is a symbol-to-reason map sampled by the strategy layer, not a factor evaluator. Merged decisions retain denials. The broker installs it only while processing that decision and restores the prior context afterward; it is never shared through DataSet caches.
New positive buy quantities and target-buy planning respect the map after standard market/risk checks. Sells remain permitted. The actual execution price determines whether a value/portfolio target requires buying: a target below the signal-day holding value can become a buy after a lower next open, so signal-day direction alone is insufficient.
Existing resting orders are not automatically canceled by this primitive. A buy amendment is denied if it increases total quantity or raises the limit price, even if the other dimension decreases. Reductions in both dimensions remain allowed after normal validation. A rejected amendment emits an update-rejection process event without replacing the original order state or queue priority. Full runtime-plan integration still requires testing.
## Required Integration
1. Split selection and buy-role output in the stock-pool compiler instead of folding both into `stock_filter`.
2. Evaluate buy expressions at the declared decision clock using typed field availability, units and frozen data identity. Missing data must retain its own diagnostic, not silently become a false trading signal.
3. Populate denials for every symbol a decision can buy, including portfolio targets, retained-target reentry and top-ups. Do not infer execution direction from signal-day value.
4. Preserve/consume constraints in Paper/Live strategy-plan conversion. No consumer may silently discard a nonempty denial map.
5. Carry the tested amendment policy through runtime-plan conversion; validate source-date and execution-date risk independently.
6. Verify same-bundle baseline parity when no buy constraint is configured, then test explicit buy failures across share, value, target and algorithmic orders.
## Current Evidence
On 177, broker tests verify blocked target top-ups, permitted sells, context restoration, existing pending-order preservation, a next-open target direction flip, and risk-increasing/reducing amendments with unchanged state on rejection. Full `fidc-core` tests passed: 453 unit tests and 122 integration tests, with 8 manual benchmarks ignored. The backtest runner previously compiled against the changed API.
The candidate is not deployed. The current OmniQuant compiler still needs the above integration, and no production readiness claim follows from these low-level tests.
@@ -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.